Compare commits

..
30 Commits
Author SHA1 Message Date
joseph.henry 4f0111e09a fix missing task comments on tracker upload 2026-09-22 17:35:51 +02:00
joseph.henry 4e29c0e699 refactor and fixes for episode handling gav 2026-09-01 15:44:11 +02:00
joseph.henryandClaude Opus 4.7 01fccc63d9 fix update_data: PUT only changed fields, not the whole entity
PUTting the full entity dict back to data/entities/{id} sends server-managed
fields (id, created_at, updated_at, project_id, entity_type_id, ...) that
newer Kitsu rejects with ParameterException. Build a partial payload with
only name/description/nb_frames/data — and only fetch/merge existing data
when the caller is actually updating custom_data.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 17:11:05 +02:00
joseph.henryandClaude Opus 4.7 6b06224f8c fix get_task: use gazu.task.get_task_by_entity (gazu 1.x)
get_task_by_name was removed in gazu 1.x; the addon pins gazu>=1.0,<2.0
so the call raises AttributeError for artists. get_task_by_entity is the
drop-in replacement with the same (entity, task_type) signature.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 16:37:25 +02:00
joseph.henryandClaude Opus 4.6 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
florentin.luce c46d78f2ae handle if task doesnt exist 2025-02-19 14:53:40 +01:00
christophe.seux 06c8c28071 active_sequence_strip poll issue 2025-02-05 14:57:25 +01:00
christophe.seux 671b2eeb4e install_module plugins properly 2025-01-23 20:11:13 +01:00
christophe.seux dad1d92e19 install plugins at import edit 2025-01-23 19:58:51 +01:00
christophe.seux 84a5b93387 fix otio, remove double clips 2025-01-23 19:49:28 +01:00
christophe.seux 17f3e0c71b Avoid choosing same source and destination channel in the scene cut detection 2025-01-10 16:31:57 +01:00
christophe.seux 425c842bfd Refacto of the scene cut detection, make it generic and add another algorythm as an option 2025-01-10 16:24:52 +01:00
florentin.luce de16df05ef get latest media from currrent one pattern 2024-12-19 16:43:50 +01:00
florentin.luce 23093a72e7 update media operator 2024-12-19 16:15:34 +01:00
florentin.luce 2690a197c6 update poll log 2024-12-19 10:48:21 +01:00
florentin.luce 9099b23a12 create context to work only with a selected strip 2024-12-19 10:39:11 +01:00
florentin.luce c623f3e0a8 import casting with shots 2024-12-03 17:22:36 +01:00
florentin.luce 8e21dcc6f5 Merge pull request 'shot_split_auto' (#6) from shot_split_auto into master
Reviewed-on: #6
Reviewed-by: christophe.seux <christophe@autourdeminuit.com>
2024-05-28 15:27:01 +02:00
florentin.luce fe5e6728ad remove legacy prop 2024-05-28 15:05:19 +02:00
florentin.luce 15bd62be2f fix bug 2024-05-28 14:58:49 +02:00
florentin.luce 61f6ec3896 improve usage 2024-05-28 14:49:28 +02:00
florentin.luce 940cd1100f use modal and display shots created one after other 2024-05-27 14:40:23 +02:00
florentin.luce b1bde01b57 one movie by splitter 2024-05-24 11:12:13 +02:00
florentin.luce a30e16606b Split auto (UI + Core) - 1st version 2024-05-24 09:44:19 +02:00
florentin.luce b68b43e3e1 new operator to merge shots 2024-05-23 10:07:43 +02:00
christophe.seux a53ceaf134 Insert and Remove Channel 2024-05-14 11:49:47 +02:00
ChristopheSeux dbc92922b5 attr set for single file 2024-05-01 22:13:38 +02:00
florentin.luce 83268e9ec7 Open Media strip 2024-04-25 16:19:25 +02:00
ChristopheSeux a335b28fb1 New review file 2024-04-22 22:30:36 +02:00
florentin.luce c9c84929e6 use project templates 2024-04-22 16:32:26 +02:00
18 changed files with 1593 additions and 345 deletions
+12 -6
View File
@@ -36,28 +36,34 @@ import bpy
def register():
bpy.app.handlers.frame_change_post.append(update_text_strips)
bpy.app.handlers.render_pre.append(update_text_strips)
if update_text_strips not in bpy.app.handlers.frame_change_post:
bpy.app.handlers.frame_change_post.append(update_text_strips)
if update_text_strips not in bpy.app.handlers.render_pre:
bpy.app.handlers.render_pre.append(update_text_strips)
for module in modules:
module.register()
bpy.app.handlers.frame_change_post.append(set_active_strip)
if set_active_strip not in bpy.app.handlers.frame_change_post:
bpy.app.handlers.frame_change_post.append(set_active_strip)
prefs = get_addon_prefs()
#print('\n\n-------------------', prefs.config_path)
def unregister():
bpy.app.handlers.frame_change_post.remove(update_text_strips)
bpy.app.handlers.render_pre.remove(update_text_strips)
if update_text_strips in bpy.app.handlers.frame_change_post:
bpy.app.handlers.frame_change_post.remove(update_text_strips)
if update_text_strips in bpy.app.handlers.render_pre:
bpy.app.handlers.render_pre.remove(update_text_strips)
try:
bpy.utils.previews.remove(ASSET_PREVIEWS)
except Exception as e:
pass
bpy.app.handlers.frame_change_post.remove(set_active_strip)
if set_active_strip in bpy.app.handlers.frame_change_post:
bpy.app.handlers.frame_change_post.remove(set_active_strip)
for module in reversed(modules):
module.unregister()
+1 -2
View File
@@ -49,8 +49,7 @@ def get_scene_settings():
return bpy.context.scene.vsetb_settings
def get_strip_settings():
scn = bpy.context.scene
strip = bpy.context.active_sequence_strip
strip = bpy.context.active_strip
if not strip:
return
+11 -10
View File
@@ -54,7 +54,7 @@ def norm_str(string, separator='_', format=str.lower, padding=0):
string = string.replace('_', ' ')
string = string.replace('-', ' ')
string = re.sub('[ ]+', ' ', string)
string = re.sub('[ ]+\/[ ]+', '/', string)
string = re.sub(r'[ ]+/[ ]+', '/', string)
string = string.strip()
if format:
@@ -74,7 +74,7 @@ def norm_name(string, separator='_', format=str.lower, padding=0):
string = string.replace('_', ' ')
string = string.replace('-', ' ')
string = re.sub('[ ]+', ' ', string)
string = re.sub('[ ]+\/[ ]+', '/', string)
string = re.sub(r'[ ]+/[ ]+', '/', string)
string = string.strip()
if format:
@@ -89,9 +89,9 @@ def norm_name(string, separator='_', format=str.lower, padding=0):
return string
def read_file(path):
'''Read a file with an extension in (json, yaml, yml, txt)'''
'''Read a file with an extension in (json, yaml, yml, txt, log)'''
exts = ('.json', '.yaml', '.yml', '.txt')
exts = ('.json', '.yaml', '.yml', '.txt', '.log')
if not path:
print('Try to read empty file')
@@ -147,19 +147,20 @@ def open_file(filepath, env=None, select=False):
subprocess.Popen(cmd, env=env)
"""def parse(template, string):
template = re.sub(r'{index:(.+?)}', r'(?P<index>.+)', template)
reg = re.sub(r'{(.+?)}', r'(?P<_\1>.+)', template)
# def parse(string, template):
# template = re.sub(r'{index:(.+?)}', r'(?P<index>.+)', template)
# reg = re.sub(r'{(.+?)}', r'(?P<_\1>.+)', template)
values = list(re.search(reg, string).groups())
keys = re.findall(r'{(.+?)}', template) + ['index']
# values = list(re.search(reg, string).groups())
# keys = re.findall(r'{(.+?)}', template) + ['index']
return dict(zip(keys, values))"""
# return dict(zip(keys, values))
def parse(template, string):
reg = re.sub(r'{[^}]*}|\.', lambda m: m.group() if m.group().startswith('{') else r'\.', template)
reg = re.sub(r'{(.+?)}', r'(?P<\1>.*)', reg)
#print(string, template, reg)
if result := re.match(reg, string):
return result.groupdict()
+3
View File
@@ -204,6 +204,9 @@ class VSETB_OT_remove_template(Operator):
return {'FINISHED'}
classes = (
VSETB_OT_reload_addon,
VSETB_OT_load_settings,
+77 -17
View File
@@ -53,7 +53,7 @@ class VSETB_OT_casting_replace(Operator):
item = self.assets.add()
item.name = asset.tracker_name
strip = context.active_sequence_strip
strip = context.active_strip
asset_casting_index = strip.vsetb_strip_settings.casting_index
active_asset = strip.vsetb_strip_settings.casting[asset_casting_index].asset
@@ -87,7 +87,7 @@ class VSETB_OT_casting_add(Operator):
#asset_name : EnumProperty(name='', items=get_scene_settings().active_project.get('asset_items', []))
@classmethod
def poll(cls, context):
active_strip = context.scene.sequence_editor.active_strip
active_strip = context.active_strip
if active_strip:
return True
@@ -135,7 +135,7 @@ class VSETB_OT_casting_remove(Operator):
@classmethod
def poll(cls, context):
active_strip = context.scene.sequence_editor.active_strip
active_strip = context.active_strip
if active_strip:
return True
@@ -190,7 +190,7 @@ class VSETB_OT_casting_move(Operator):
@classmethod
def poll(cls, context):
active_strip = context.scene.sequence_editor.active_strip
active_strip = context.active_strip
if active_strip:
return True
@@ -229,16 +229,15 @@ class VSETB_OT_copy_casting(Operator):
@classmethod
def poll(cls, context):
active_strip = context.scene.sequence_editor.active_strip
if not context.scene.sequence_editor:
return
active_strip = context.active_strip
strip_settings = get_strip_settings()
if active_strip and strip_settings.casting:
return True
def execute(self, context):
scn = context.scene
active_strip = scn.sequence_editor.active_strip
strip_settings = get_strip_settings()
datas = [c.to_dict() for c in strip_settings.casting]
@@ -257,9 +256,7 @@ class VSETB_OT_paste_casting(Operator):
@classmethod
def poll(cls, context):
active_strip = context.scene.sequence_editor.active_strip
if active_strip:
return True
return context.active_strip
def invoke(self, context, event):
self.mode = 'REPLACE'
@@ -282,7 +279,7 @@ class VSETB_OT_paste_casting(Operator):
casting_datas = json.loads(CASTING_BUFFER.read_text())
casting_ids = set(c['id'] for c in casting_datas)
for strip in context.selected_sequences:
for strip in context.selected_strips:
strip_settings = strip.vsetb_strip_settings
if self.mode == 'REPLACE':
@@ -320,7 +317,7 @@ class VSETB_OT_copy_metadata(Operator):
@classmethod
def poll(cls, context):
return context.selected_sequences and context.active_sequence_strip
return context.selected_strips and context.active_strip
def execute(self, context):
prefs = get_addon_prefs()
@@ -332,11 +329,11 @@ class VSETB_OT_copy_metadata(Operator):
if not metadata:
self.report({'ERROR'}, f'No Metadata named {self.metadata}')
active_strip = context.active_sequence_strip
active_strip = context.active_strip
metadata_value = getattr(active_strip.vsetb_strip_settings.metadata, metadata)
for strip in context.selected_sequences:
if strip == context.active_sequence_strip:
for strip in context.selected_strips:
if strip == context.active_strip:
continue
setattr(strip.vsetb_strip_settings.metadata, metadata, metadata_value)
@@ -344,6 +341,68 @@ class VSETB_OT_copy_metadata(Operator):
return {"FINISHED"}
def get_asset_type_items(self, context):
settings = get_scene_settings()
project = settings.active_project
if project and project.asset_types:
return [(t.name, t.name, '') for t in project.asset_types]
return [('Character', 'Character', '')]
class VSETB_OT_casting_create_asset(Operator):
bl_idname = "vse_toolbox.casting_create_asset"
bl_label = "Create & Cast Asset"
bl_description = "Create a new asset in Kitsu and add to casting"
bl_options = {"REGISTER", "UNDO"}
asset_name: StringProperty(name="Name")
asset_type: EnumProperty(name="Type", items=get_asset_type_items)
description: StringProperty(name="Description")
@classmethod
def poll(cls, context):
settings = get_scene_settings()
return settings.active_project
def invoke(self, context, event):
return context.window_manager.invoke_props_dialog(self)
def execute(self, context):
prefs = get_addon_prefs()
settings = get_scene_settings()
project = settings.active_project
tracker = prefs.tracker
try:
new_asset_data = tracker.new_asset(
self.asset_name, self.asset_type,
project=project.id, description=self.description
)
except Exception as e:
self.report({'ERROR'}, str(e))
return {'CANCELLED'}
# Add to local project assets
asset = project.assets.add()
asset.name = new_asset_data['id']
asset.id = new_asset_data['id']
asset.tracker_name = new_asset_data['name']
asset.asset_type = self.asset_type
# Cast to selected shot strips
strips = get_strips('Shots', selected_only=True)
for strip in strips:
strip_settings = strip.vsetb_strip_settings
cast_item = strip_settings.casting.add()
cast_item.name = new_asset_data['id']
cast_item.id = new_asset_data['id']
cast_item['_name'] = self.asset_name
strip_settings.casting.update()
self.report({'INFO'}, f"Created asset '{self.asset_name}' and cast to {len(strips)} shots")
return {'FINISHED'}
classes = (
VSETB_OT_casting_add,
VSETB_OT_casting_remove,
@@ -351,7 +410,8 @@ classes = (
VSETB_OT_copy_casting,
VSETB_OT_paste_casting,
VSETB_OT_casting_replace,
VSETB_OT_copy_metadata
VSETB_OT_copy_metadata,
VSETB_OT_casting_create_asset,
)
def register():
+1
View File
@@ -217,6 +217,7 @@ class VSETB_OT_export_edl(Operator):
def execute(self, context):
opentimelineio = install_module('opentimelineio')
install_module('opentimelineio', package_name='opentimelineio-plugins')
from opentimelineio.schema import (Clip, Timeline, Track, ExternalReference)
from opentimelineio.opentime import (RationalTime, TimeRange)
+235 -22
View File
@@ -15,7 +15,7 @@ from vse_toolbox.constants import (EDITS, EDIT_SUFFIXES, MOVIES, MOVIE_SUFFIXES,
from vse_toolbox.sequencer_utils import (clean_sequencer, import_edit, import_movie,
import_sound, get_strips, get_channel_index, get_empty_channel, scale_clip_to_fit)
from vse_toolbox.bl_utils import (get_scene_settings, get_addon_prefs, get_scene_settings)
from vse_toolbox.bl_utils import (get_scene_settings, get_addon_prefs, abspath)
from vse_toolbox.file_utils import install_module, parse, find_last, expand
@@ -90,6 +90,15 @@ class VSETB_OT_import_files(Operator):
import_edit : BoolProperty(name='', default=True)
edit: EnumProperty(name='', items=lambda s, c: EDITS)
edit_adapter: EnumProperty(
name='Format',
items=[
('AUTO', "Auto-detect", "Detect format from file extension"),
('cmx_3600', "EDL (CMX 3600)", "Standard Edit Decision List"),
('fcp_xml', "FCP 7 XML", "Final Cut Pro 7 XML (Toon Boom Storyboard Pro)"),
],
default='AUTO'
)
match_by : EnumProperty(name='Match By', items=[('NAME', 'Name', ''), ('INDEX', 'Index', '')])
import_movie : BoolProperty(name='', default=False)
@@ -119,6 +128,8 @@ class VSETB_OT_import_files(Operator):
sub.active = self.import_edit
sub.prop(self, 'edit')
row = layout.row(align=True)
row.prop(self, 'edit_adapter', text='Format')
row = layout.row(align=True)
row.prop(self, 'match_by', expand=True)
layout.separator()
@@ -144,7 +155,7 @@ class VSETB_OT_import_files(Operator):
return {'RUNNING_MODAL'}
def execute(self, context):
sequencer = context.scene.sequence_editor.sequences
sequencer = context.scene.sequence_editor.strips
edit_filepath = Path(self.directory, self.edit)
if not edit_filepath.exists():
@@ -168,7 +179,12 @@ class VSETB_OT_import_files(Operator):
if self.import_edit:
print(f'[>.] Loading Edit from: {str(edit_filepath)}')
import_edit(edit_filepath, adapter="cmx_3600", match_by=self.match_by)
adapter = self.edit_adapter
if adapter == 'AUTO':
ext = edit_filepath.suffix.lower()
adapter = 'fcp_xml' if ext == '.xml' else 'cmx_3600'
import_edit(edit_filepath, adapter=adapter, match_by=self.match_by)
if self.import_movie:
print(f'[>.] Loading Movie from: {str(movie_filepath)}')
@@ -190,7 +206,7 @@ class VSETB_OT_import_files(Operator):
print(f'[>.] Loading Audio from: {str(movie_filepath)}')
import_sound(movie_filepath)
context.scene.sequence_editor.sequences.update()
context.scene.sequence_editor.strips.update()
return {"FINISHED"}
@@ -368,17 +384,23 @@ class VSETB_OT_import_shots(Operator):
channel_index += 1
def get_preview_dir(self):
preview_dir = Path(bpy.app.tempdir, 'previews')
if bpy.data.filepath:
preview_dir = Path(bpy.data.filepath).parent / 'previews'
return preview_dir
# def get_preview_dir(self):
# preview_dir = Path(bpy.app.tempdir, 'previews')
# if bpy.data.filepath:
# preview_dir = Path(bpy.data.filepath).parent / 'previews'
# return preview_dir
def download_preview(self, task_type, shot):
prefs = get_addon_prefs()
tracker = prefs.tracker
settings = get_scene_settings()
project = settings.active_project
task = tracker.get_task(task_type.id or task_type.name, entity=shot)
if not task:
print(f'No task {task_type.name} found for {shot["name"]}')
return
last_comment = tracker.get_last_comment_with_preview(task)
if not last_comment:
return
@@ -391,7 +413,7 @@ class VSETB_OT_import_shots(Operator):
if shot_name.startswith(sequence_name):
shot_name = shot_name.replace(sequence_name, '', 1)
preview_dir = self.get_preview_dir()
preview_dir = abspath(project.import_shots.previews_folder)#self.get_preview_dir()
filepath = Path(preview_dir, f'{sequence_name}{shot_name}_{task_type.name}.{ext}')
filepath.parent.mkdir(parents=True, exist_ok=True)
@@ -427,6 +449,22 @@ class VSETB_OT_import_shots(Operator):
if last_preview := find_last(template, **format_data):
return last_preview
def import_casting(self, shot, strip):
prefs = get_addon_prefs()
tracker = prefs.tracker
strip_settings = strip.vsetb_strip_settings
casting_data = tracker.get_casting(shot)
for asset_data in casting_data:
item = strip_settings.casting.add()
item.name = asset_data['asset_name']
item.id = asset_data['asset_id']
item.instance = asset_data.get('nb_occurences', 1)
item['_name'] = asset_data['asset_name']
strip_settings.casting.update()
def execute(self, context):
scn = context.scene
settings = get_scene_settings()
@@ -440,30 +478,45 @@ class VSETB_OT_import_shots(Operator):
task_types = [t for t in project.task_types if t.import_enabled]
sequences = [s for s in project.sequences if s.import_enabled]
if not sequences:
self.report({'ERROR'}, "No sequences selected. For episodic projects, select an episode first.")
return {'CANCELLED'}
conformed = False
scn.sequence_editor_clear()
scn.sequence_editor_create()
if import_shots.clear:
frame_index = 1
scn.sequence_editor_clear()
scn.sequence_editor_create()
else:
frame_index = scn.frame_end +1
self.set_sequencer_channels([t.name for t in task_types])
frame_index = 1
for sequence in sequences:
shots_data = tracker.get_shots(sequence=sequence.id)
sequence_start = frame_index
for shot_data in shots_data:
frames = int(shot_data['nb_frames'])
frames = shot_data['nb_frames']
if not frames:
frames = 100
print(f'No nb frames on tracker for {shot_data["name"]}')
frames = int(frames)
frame_end = frame_index + frames
strip = scn.sequence_editor.sequences.new_effect(
strip = scn.sequence_editor.strips.new_effect(
name=shot_data['name'],
type='COLOR',
channel=get_channel_index('Shots'),
frame_start=frame_index,
frame_end=frame_index + frames
length=frames
)
strip.blend_alpha = 0
strip.color = (0.5, 0.5, 0.5)
self.import_casting(shot_data, strip)
for task_type in task_types:
if import_shots.import_source == 'DISK':
preview = self.find_shot_preview(sequence, shot_data['name'], task_type)
@@ -477,9 +530,13 @@ class VSETB_OT_import_shots(Operator):
print(f'Loading Preview from {preview}')
channel_index = get_channel_index(f'{task_type.name} Video')
video_clip = import_movie(preview, frame_start=frame_index, frame_end=frame_end)
video_clip = import_movie(preview, frame_start=frame_index)
video_clip.channel = channel_index
if strip.frame_final_end != video_clip.frame_final_end: # Conform shot duration to longest media
frames = video_clip.frame_final_duration
strip.frame_final_end = video_clip.frame_final_end
if video_clip.frame_offset_end:
video_clip.color_tag = 'COLOR_01'
@@ -491,20 +548,19 @@ class VSETB_OT_import_shots(Operator):
# Load Audio
channel_index = get_channel_index(f'{task_type.name} Audio')
audio_clip = import_sound(preview, frame_start=frame_index,
frame_end=frame_end)
audio_clip = import_sound(preview, frame_start=frame_index)
audio_clip.channel = channel_index
if video_clip.frame_offset_end:
audio_clip.color_tag = 'COLOR_01'
frame_index += frames
strip = scn.sequence_editor.sequences.new_effect(
strip = scn.sequence_editor.strips.new_effect(
name=sequence.name,
type='COLOR',
channel=get_channel_index('Sequences'),
frame_start=sequence_start,
frame_end=frame_index
length=frame_index - sequence_start
)
strip.blend_alpha = 0
strip.color = (0.25, 0.25, 0.25)
@@ -613,6 +669,8 @@ class VSETB_OT_import_shots(Operator):
row.label(icon="ERROR")
row.label(text='Add at least one Sequence')
layout.prop(import_shots, 'clear', text='Clear')
def invoke(self, context, event):
scn = context.scene
settings = get_scene_settings()
@@ -621,7 +679,7 @@ class VSETB_OT_import_shots(Operator):
tmp_dir = Path(gettempdir(), 'reviews')
if bpy.data.filepath and Path(project.import_shots.previews_folder) == tmp_dir:
tmp_dir = '//sources'
project.import_shots.previews_folder = '//sources'
if not bpy.data.filepath and project.import_shots.previews_folder == '//sources':
project.import_shots.previews_folder = str(tmp_dir)
@@ -636,6 +694,160 @@ class VSETB_OT_import_shots(Operator):
return True
class VSETB_OT_import_stb_xml(Operator):
bl_idname = "vse_toolbox.import_stb_xml"
bl_label = "Import STB XML"
bl_description = "Import Toon Boom Storyboard Pro FCP XML export with movie strips"
bl_options = {"REGISTER", "UNDO"}
filepath: StringProperty(
name="File Path",
description="Path to the Storyboard Pro FCP XML export",
subtype='FILE_PATH',
)
filter_glob: StringProperty(default="*.xml", options={'HIDDEN'})
import_movies: BoolProperty(
name="Import Movies",
description="Import matching .mov files from the same directory",
default=True,
)
clean_sequencer: BoolProperty(
name="Clean Sequencer",
description="Remove all existing strips before import",
default=True,
)
conform_resolution: BoolProperty(
name="Conform Resolution",
description="Set scene resolution and FPS from the XML metadata",
default=True,
)
@classmethod
def poll(cls, context):
return get_scene_settings().active_project
def invoke(self, context, event):
context.window_manager.fileselect_add(self)
return {'RUNNING_MODAL'}
def _parse_xml_metadata(self, filepath):
"""Extract resolution and FPS from the FCP XML."""
import xml.etree.ElementTree as ET
tree = ET.parse(filepath)
root = tree.getroot()
meta = {}
fmt = root.find('.//sequence/media/video/format/samplecharacteristics')
if fmt is not None:
w = fmt.find('width')
h = fmt.find('height')
if w is not None and h is not None:
meta['width'] = int(w.text)
meta['height'] = int(h.text)
rate = fmt.find('rate/timebase')
if rate is not None:
meta['fps'] = int(rate.text)
return meta
def execute(self, context):
scn = context.scene
xml_path = Path(self.filepath)
if not xml_path.exists():
self.report({'ERROR'}, f"File not found: {xml_path}")
return {'CANCELLED'}
xml_dir = xml_path.parent
# Conform scene resolution/fps from XML metadata
if self.conform_resolution:
meta = self._parse_xml_metadata(str(xml_path))
if 'width' in meta:
scn.render.resolution_x = meta['width']
scn.render.resolution_y = meta['height']
print(f'[STB] Set resolution to {meta["width"]}x{meta["height"]}')
if 'fps' in meta:
scn.render.fps = meta['fps']
print(f'[STB] Set FPS to {meta["fps"]}')
# Clean sequencer if requested
if self.clean_sequencer:
scn.sequence_editor_clear()
scn.sequence_editor_create()
if not scn.sequence_editor:
scn.sequence_editor_create()
# Set up channels
channels = scn.sequence_editor.channels
channel_names = {'Shots': 1, 'STB Video': 2}
for name, idx in channel_names.items():
if idx < len(channels):
channels[idx].name = name
# Patch XML for OTIO compatibility (STB Pro exports transitions without <alignment>)
import xml.etree.ElementTree as ET
tree = ET.parse(str(xml_path))
patched = False
for trans in tree.iter('transitionitem'):
if trans.find('alignment') is None:
align = ET.SubElement(trans, 'alignment')
align.text = 'center'
patched = True
if patched:
patched_path = Path(bpy.app.tempdir) / xml_path.name
tree.write(str(patched_path), xml_declaration=True, encoding='UTF-8')
import_xml = str(patched_path)
print(f'[STB] Patched {xml_path.name} (added missing <alignment> to transitions)')
else:
import_xml = str(xml_path)
# Import edit (COLOR strips on Shots channel)
print(f'[STB] Importing edit from: {xml_path}')
import_edit(import_xml, adapter='fcp_xml', channel='Shots')
# Import matching movie files
if self.import_movies:
shot_strips = get_strips(channel='Shots')
stb_channel = get_channel_index('STB Video')
for strip in shot_strips:
# Try to find a matching .mov file by strip name
mov_path = xml_dir / f"{strip.name}.mov"
if not mov_path.exists():
# Try source_name (set by import_edit)
source = strip.vsetb_strip_settings.source_name
if source:
mov_path = xml_dir / f"{Path(source).stem}.mov"
if mov_path.exists():
movie_strip = scn.sequence_editor.strips.new_movie(
name=strip.name,
filepath=str(mov_path),
channel=stb_channel,
frame_start=strip.frame_final_start,
)
movie_strip.frame_final_end = strip.frame_final_end
# Scale to fit scene resolution
scale_clip_to_fit(movie_strip)
print(f'[STB] Imported movie: {mov_path.name}')
else:
print(f'[STB] No movie found for strip: {strip.name}')
scn.frame_start = 0
scn.frame_end = max(
(s.frame_final_end for s in scn.sequence_editor.strips),
default=scn.frame_end
)
self.report({'INFO'}, f"Imported {len(get_strips(channel='Shots'))} shots from STB XML")
return {"FINISHED"}
classes = (
VSETB_OT_select_sequence,
VSETB_OT_unselect_sequence,
@@ -646,6 +858,7 @@ classes = (
VSETB_UL_import_task,
VSETB_OT_auto_select_files,
VSETB_OT_import_files,
VSETB_OT_import_stb_xml,
VSETB_OT_import_shots,
)
+543 -60
View File
@@ -1,14 +1,18 @@
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)
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)
get_channel_index, new_text_strip, get_strip_at, get_channel_name,
create_shot_strip, update_text_strips)
from vse_toolbox.bl_utils import get_scene_settings, get_strip_settings
from vse_toolbox.constants import REVIEW_TEMPLATE_BLEND
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
@@ -20,7 +24,11 @@ class VSETB_OT_rename(Operator):
#template : StringProperty(name="Strip Name", default="")
#increment : IntProperty(name="Increment", default=0)
selected_only : BoolProperty(name="Selected Only", default=True)
selected_only : BoolProperty(
name="Selected Only",
default=False,
description="Rename only selected strips; disabled renames the whole channel",
)
#start_number : IntProperty(name="Start Number", default=0, min=0)
#by_sequence : BoolProperty(
# name="Reset By Sequence",
@@ -30,9 +38,13 @@ class VSETB_OT_rename(Operator):
@classmethod
def poll(cls, context):
settings = get_scene_settings()
strip = context.active_sequence_strip
return settings.active_project and get_channel_name(strip) in ('Shots', 'Sequences')
strip = context.active_strip
if strip is None and context.scene.sequence_editor:
strip = next(
(s for s in context.scene.sequence_editor.strips if s.select),
None,
)
return strip is not None
def invoke(self, context, event):
scn = context.scene
@@ -45,19 +57,29 @@ class VSETB_OT_rename(Operator):
scn = context.scene
settings = get_scene_settings()
project = settings.active_project
if project is None and settings.projects:
project = settings.projects[0]
if project is None:
self.report({'ERROR'}, 'No VSE Toolbox project is loaded')
return
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_sequence_strip
strip = context.active_strip
if strip is None and context.scene.sequence_editor:
strip = next(
(s for s in context.scene.sequence_editor.strips if s.select),
None,
)
channel_name = get_channel_name(strip)
col = layout.column()
col.use_property_split = True
col.use_property_decorate = False
if channel_name == 'Shots':
if channel_name != 'Sequences':
col.prop(project, 'shot_template', text='Shot Name')
col.prop(project, 'shot_start_number', text='Start Number')
col.prop(project, 'shot_increment', text='Increment')
@@ -73,9 +95,9 @@ class VSETB_OT_rename(Operator):
col.prop(self, 'selected_only')
if channel_name == 'Shots':
if channel_name != 'Sequences':
label = project.shot_template.format(episode=episode, sequence=sequence, shot=shot)
elif channel_name == 'Sequences':
else:
label = project.sequence_template.format(episode=episode, sequence=sequence)
col.label(text=f'Renaming {label}')
@@ -84,12 +106,30 @@ class VSETB_OT_rename(Operator):
scn = context.scene
settings = get_scene_settings()
project = settings.active_project
if project is None:
# The config path is set during addon registration, but the
# project data is only populated when Load Settings is pressed.
bpy.ops.vse_toolbox.load_settings()
project = settings.active_project
if project is None and settings.projects:
project = settings.projects[0]
if project is None:
self.report({'ERROR'}, 'No VSE Toolbox project is loaded')
return {'CANCELLED'}
# Keep the scene's active project in sync with the fallback project;
# rename_strips reads it again from scene settings.
settings.project_name = project.name
strip = context.active_sequence_strip
strip = context.active_strip
if strip is None and context.scene.sequence_editor:
strip = next(
(s for s in context.scene.sequence_editor.strips if s.select),
None,
)
channel_name = get_channel_name(strip)
strips = get_strips(channel=channel_name, selected_only=self.selected_only)
if channel_name == 'Shots':
if channel_name != 'Sequences':
rename_strips(strips,
template=project.shot_template,
increment=project.shot_increment, start_number=project.shot_start_number,
@@ -148,8 +188,13 @@ class VSETB_OT_set_sequencer(Operator):
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
if elem is None and movie.elements:
elem = movie.elements[0]
if elem is not None:
scn.render.resolution_x = elem.orig_width
scn.render.resolution_y = elem.orig_height
else:
self.report({'WARNING'}, 'Cannot determine movie resolution.')
else:
self.report({'INFO'}, f'Cannot set Resolution. No Movie Found.')
@@ -172,6 +217,160 @@ class VSETB_OT_set_sequencer(Operator):
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', default='ANIMATED')
def invoke(self, context, event):
sequencer = context.scene.sequence_editor
channels = sequencer.channels if sequencer else None
selected_strip = context.active_strip
if selected_strip is None and context.selected_strips:
selected_strip = context.selected_strips[0]
# Prefer the channel containing the selected strip. With no selected
# strip, use the first available channel (channel 0 in the UI list).
if selected_strip is not None:
source_channel = selected_strip.channel
self.source_channel_name = get_channel_name(selected_strip)
elif channels and len(channels):
source_channel = 0
self.source_channel_name = channels[0].name
else:
source_channel = 0
# Put the result on the immediately following channel when it exists.
# Do not overwrite it when that channel is unavailable.
if channels and source_channel + 1 < len(channels):
destination_channel = channels[source_channel + 1]
self.destination_channel_name = destination_channel.name
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
# Replace the previous detection result instead of mixing it with the
# new one. This is especially important when changing thresholds.
for old_strip in list(scn.sequence_editor.strips):
if old_strip.name.startswith("tmp_shot_"):
scn.sequence_editor.strips.remove(old_strip)
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"
@@ -182,12 +381,22 @@ class VSETB_OT_set_stamps(Operator):
scn = context.scene
settings = get_scene_settings()
project = settings.active_project
#strip_settings = get_strip_settings()
channel_index = get_channel_index('Stamps')
# Remove existing stamps
for strip in get_strips('Stamps'):
if strip.type == 'META':
scn.sequence_editor.sequences.remove(strip)
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')
@@ -200,7 +409,9 @@ class VSETB_OT_set_stamps(Operator):
crop_x = int(width * 0.4)
crop_max_y = int(height - font_size*2)
#crop_min_y = int(scn.render.resolution_y * 0.01)
# 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))
@@ -208,37 +419,38 @@ class VSETB_OT_set_stamps(Operator):
# Project Name
project_text = '{project}'
if project.type == 'TVSHOW':
project_text = '{project} / ep{episode}'
project_strip_stamp = new_text_strip('project_stamp', channel=1, **stamp_params,
text=project_text, x=0.01, align_x='LEFT', align_y='BOTTOM')
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=2, **stamp_params,
text='{sequence} / sh{shot}', align_y='BOTTOM')
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=3, **stamp_params,
text='{shot_frame} / {shot_duration} {timecode}', x=0.99, align_x='RIGHT', align_y='BOTTOM')
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_sequence_strip
stamps_strip = context.active_strip
stamps_strip.name = 'Stamps'
stamps_strip.channel = channel_index
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
# frame_set() does not trigger frame_change_post when the frame does
# not change, so update the stamp text explicitly for the first view.
update_text_strips(scn)
return {"FINISHED"}
@@ -296,39 +508,54 @@ class VSETB_OT_next_shot(Operator):
return {"FINISHED"}
class VSETB_OT_open_shot_folder(Operator):
bl_idname = "vse_toolbox.open_shot_folder"
bl_label = "Open Shot Folder"
bl_description = "Open Shot Folder"
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):
settings = get_scene_settings()
project = settings.active_project
if not project.templates.get('shot_dir'):
cls.poll_message_set('No shot_dir template setted')
strip = context.active_strip
if not strip:
cls.poll_message_set('No active')
return
strip = context.active_sequence_strip
if not strip or get_channel_name(strip) != 'Shots':
cls.poll_message_set('No shot strip active')
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):
strip = context.active_sequence_strip
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)
shot_folder_template = expandvars(project.templates['shot_dir'].value)
shot_folder_path = shot_folder_template.format(**format_data)
bpy.ops.wm.path_open(filepath=shot_folder_path)
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"}
@@ -350,12 +577,12 @@ class VSETB_OT_collect_files(Operator):
cls.poll_message_set('Save the blend to collect files')
def execute(self, context):
strip = context.active_sequence_strip
strip = context.active_strip
settings = get_scene_settings()
project = settings.active_project
strips = [s for s in context.scene.sequence_editor.sequences_all if s.type in ('MOVIE', 'SOUND')]
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):
@@ -397,6 +624,91 @@ class VSETB_OT_collect_files(Operator):
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"""
@@ -418,7 +730,11 @@ class WM_OT_split_view(Operator):
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 and len(preview_areas) == 1:
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")
@@ -427,7 +743,7 @@ class WM_OT_split_view(Operator):
# Update areas
preview_areas = self.get_preview_areas(context)
preview_areas[-1].spaces[0].display_channel = video_channels[-1]
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
@@ -459,19 +775,177 @@ class WM_OT_split_view(Operator):
return {"FINISHED"}
'''
class VSETB_OT_set_review_workspace(Operator):
"""Set Review Workspace"""
bl_idname = "vse_toolbox.set_workspace"
bl_label = "Set Review Workspace"
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):
bpy.ops.workspace.append_activate(idname='Review', filepath=str(REVIEW_TEMPLATE_BLEND))
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="SQ010",
)
@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):
selected_shots = sorted(
(s for s in context.selected_strips if get_channel_name(s) == 'Shots'),
key=lambda s: s.frame_final_start,
)
if selected_shots:
first_frame = selected_shots[0].frame_final_start
sequence_channel = get_channel_index('Sequences')
previous = None
if sequence_channel:
previous = max(
(
s for s in context.scene.sequence_editor.strips
if s.channel == sequence_channel
and s.frame_final_end <= first_frame
),
key=lambda s: s.frame_final_end,
default=None,
)
if previous:
match = re.search(r'(\d+)$', previous.name)
if match:
settings = get_scene_settings()
project = settings.active_project
increment = project.sequence_increment if project else 10
number = int(match.group(1)) + increment
self.sequence_name = (
previous.name[:match.start()]
+ str(number).zfill(len(match.group(1)))
)
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 = []
@@ -491,6 +965,9 @@ def register_keymaps():
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)
@@ -505,13 +982,19 @@ def unregister_keymaps():
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_shot_folder,
VSETB_OT_open_strip_folder,
VSETB_OT_collect_files,
WM_OT_split_view
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():
+9 -5
View File
@@ -341,7 +341,7 @@ class VSETB_OT_import_spreadsheet(Operator):
settings = get_scene_settings()
project = settings.active_project
spreadsheet = project.spreadsheet_import
sequencer = scn.sequence_editor.sequences
sequencer = scn.sequence_editor.strips
assets_missing = set()
@@ -355,7 +355,9 @@ class VSETB_OT_import_spreadsheet(Operator):
#print(SPREADSHEET[:2])
cell_types = project.get_cell_types()
cell_names = {k: spreadsheet.cells[k].import_name for k in header if k}
header_enabled = [x for x in header if spreadsheet.cells[x].enabled]
cell_names = {x: spreadsheet.cells[x].import_name for x in header_enabled if x}
#separator = spreadsheet.separator.replace('\\n', '\n').replace('\\t', '\t').replace('\\r', '\r')
@@ -364,10 +366,12 @@ class VSETB_OT_import_spreadsheet(Operator):
frame_start = scn.frame_start
for row in SPREADSHEET[1:]:
#print(row)
cell_data = {cell_names[k]: v for k, v in zip(header, row) if k}
#cell_data = {k: v for k, v in zip(header, row)}
cell_data = {cell_names[k]: v for k, v in zip(header, row) if k in cell_names}
shot_name = cell_data['Shot']
if not shot_name:
raise Exception()
#strip = next((s for s in sequencer if s.vsetb_strip_settings.source_name == shot_name), None)
strip = next((s for s in shot_strips if s.name == shot_name), None)
@@ -393,7 +397,7 @@ class VSETB_OT_import_spreadsheet(Operator):
type='COLOR',
channel=channel,
frame_start=frame_start,
frame_end=frame_end,
length=frame_end - frame_start,
)
strip.blend_alpha = 0.0
+97 -17
View File
@@ -37,11 +37,11 @@ class VSETB_OT_tracker_connect(Operator):
settings = get_scene_settings()
try:
prefs.tracker.connect()
self.report({'INFO'}, f'successfully login to {settings.tracker_name.title()}')
self.report({'INFO'}, f'Successfully login to {settings.tracker_name.title()}')
return {"FINISHED"}
except Exception as e:
print('e: ', e)
self.report({'ERROR'}, f'Cannot connect to tracker.')
self.report({'ERROR'}, f'Cannot connect to tracker, check login and password')
return {"CANCELLED"}
@@ -160,6 +160,15 @@ class VSETB_OT_load_projects(Operator):
for ep in reversed(project.episodes):
if ep.name not in ep_names:
project.episodes.remove(list(project.episodes).index(ep))
# Load sequences for first episode so they're available immediately
if project.episodes:
first_episode = project.episodes[0]
project.sequences.clear()
for seq in tracker.get_sequences(project_data, episode=first_episode.id):
sequence = project.sequences.add()
sequence.name = seq['name']
sequence.id = seq['id']
else:
# Add sequences
sequences_data = tracker.get_sequences(project_data)
@@ -173,6 +182,7 @@ class VSETB_OT_load_projects(Operator):
sequence.id = sequence_data['id']
project.metadata_types.clear()
for metadata_data in tracker.get_metadata_types(project_data):
#pprint(metadata_data)
metadata_type = project.metadata_types.add()
@@ -195,6 +205,7 @@ class VSETB_OT_load_projects(Operator):
#print(metadata_data)
task_status = project.task_statuses.add()
task_status.name = status_data['short_name'].upper()
task_status.is_done = status_data.get('is_done', False)
project.task_types.clear()
for task_type_data in tracker.get_shot_task_types(project_data):
@@ -218,7 +229,10 @@ class VSETB_OT_load_projects(Operator):
if project.name not in project_names:
settings.projects.remove(list(settings.projects).index(project))
if self.ctrl or not settings.get('projects_loaded'):
# ``load_projects`` can also be called programmatically (for example
# from the upload dialog), bypassing ``invoke`` and therefore not
# initializing ``self.ctrl``.
if getattr(self, 'ctrl', False) or not settings.get('projects_loaded'):
bpy.ops.vse_toolbox.load_settings()
if prev_project_name != '/' and prev_project_name in settings.projects:
@@ -256,15 +270,24 @@ class VSETB_OT_new_episode(Operator):
settings = get_scene_settings()
prefs = get_addon_prefs()
tracker = prefs.tracker
project = settings.active_project
episode_name = settings.episode_template.format(index=int(self.episode_name))
episode = tracker.get_episode(episode_name)
if episode:
if not project:
self.report({'ERROR'}, 'No active project')
return {"CANCELLED"}
episode_name = project.episode_template.format(index=int(self.episode_name))
episode_data = tracker.get_episode(episode_name, project=project.id)
if episode_data:
self.report({'ERROR'}, f'Episode {episode_name} already exists')
return {"CANCELLED"}
tracker.new_episode(episode_name)
tracker.update_project()
episode_data = tracker.new_episode(episode_name, project=project.id)
# Add to local episode collection
episode = project.episodes.add()
episode.name = episode_data['name']
episode.id = episode_data['id']
self.report({'INFO'}, f'Episode {episode_name} successfully created')
@@ -274,11 +297,19 @@ class VSETB_OT_new_episode(Operator):
class VSETB_OT_upload_to_tracker(Operator):
bl_idname = "vse_toolbox.upload_to_tracker"
bl_label = "Upload to tracker"
bl_description = "Upload selected strip to tracker"
bl_description = (
"Upload the selected strips on the Shots track to the tracker. "
"Creates missing sequences, shots and tasks, posts a comment "
'(with a preview, rendered if "Render Strips" is enabled) and '
"updates frames, metadata, casting and task comments."
)
bl_options = {"REGISTER", "UNDO"}
@classmethod
def poll(cls, context):
if not get_strips(channel='Shots', selected_only=True):
cls.poll_message_set('Select at least one shot strip')
return False
return True
def invoke(self, context, event):
@@ -289,6 +320,13 @@ class VSETB_OT_upload_to_tracker(Operator):
tracker = prefs.tracker
tracker.connect()
# Loading the toolbox config can create the selected project locally,
# but does not populate its tracker metadata. In that case the task
# enum has no items even though Kitsu has shot task types configured.
if self.project and not self.project.task_types:
bpy.ops.vse_toolbox.load_projects()
self.project = settings.active_project
#self.bl_label = f"Upload to {settings.tracker_name.title()}"
return context.window_manager.invoke_props_dialog(self, width=350)
@@ -344,16 +382,35 @@ class VSETB_OT_upload_to_tracker(Operator):
format_data = {**settings.format_data, **self.project.format_data}
status = upload_to_tracker.status
if status == 'CURRENT':
keep_current_status = status == 'CURRENT'
if keep_current_status:
status = None
# ``CURRENT`` means preserve an existing task's status. A newly
# created task has no current status, so use Kitsu's done metadata for
# the first upload (prefer DONE when several statuses are marked done).
default_new_task_status = None
if keep_current_status:
done_statuses = [s for s in self.project.task_statuses if s.is_done]
default_new_task_status = next(
(s.name for s in done_statuses if s.name.lower() == 'done'),
done_statuses[0].name if done_statuses else None,
)
shot_strips = get_strips(channel='Shots', selected_only=True)
if not shot_strips:
self.report({'WARNING'}, 'Select at least one shot strip to upload')
return {'CANCELLED'}
context.window_manager.progress_begin(0, len(shot_strips))
for i, strip in enumerate(shot_strips):
context.window_manager.progress_update(i)
strip_settings = strip.vsetb_strip_settings
sequence_name = get_strip_sequence_name(strip)
if sequence_name == 'NoSequence':
self.report({'WARNING'}, f"Strip '{strip.name}' has no sequence — skipped")
continue
shot_name = strip.name
sequence = tracker.get_sequence(sequence_name, episode=episode)
metadata = strip_settings.metadata.to_dict(use_name=False)
@@ -370,7 +427,11 @@ class VSETB_OT_upload_to_tracker(Operator):
task = tracker.get_task(upload_to_tracker.task, entity=shot)
if not task:
task = tracker.new_task(shot, task_type=upload_to_tracker.task)
task = tracker.new_task(
shot,
task_type=upload_to_tracker.task,
status=default_new_task_status or status,
)
preview = None
if upload_to_tracker.add_preview:
@@ -380,7 +441,7 @@ class VSETB_OT_upload_to_tracker(Operator):
preview_template = expand(upload_to_tracker.render_strip_template, **project_templates)
strip_data['ext'] = 'mov'
else:
preview_template = expandvars(self.project.render_video_strip_template)
preview_template = expand(self.project.render_video_strip_template, **project_templates)
preview = preview_template.format(**strip_data)
preview = Path(os.path.abspath(bpy.path.abspath(preview)))
@@ -400,6 +461,7 @@ class VSETB_OT_upload_to_tracker(Operator):
preview = None
comment_data = None
preview_data = None
if status or upload_to_tracker.comment or preview:
comment_data = tracker.new_comment(task, comment=upload_to_tracker.comment, status=status)
if preview:
@@ -409,7 +471,7 @@ class VSETB_OT_upload_to_tracker(Operator):
comment=comment_data,
preview=preview)
if upload_to_tracker.set_main_preview:
if upload_to_tracker.set_main_preview and preview_data:
bpy.app.timers.register(partial(tracker.set_main_preview, preview_data), first_interval=10)
params = {}
@@ -418,7 +480,14 @@ class VSETB_OT_upload_to_tracker(Operator):
params['description'] = strip_settings.description
if upload_to_tracker.update_frames:
# Blender's frame_final_end is exclusive, while Kitsu's
# frame_out is inclusive. Keep both the explicit range and
# the duration in sync with the uploaded strip.
params['frames'] = strip.frame_final_duration
params.setdefault('custom_data', {}).update({
'frame_in': strip.frame_final_start,
'frame_out': strip.frame_final_end - 1,
})
if params:
tracker.update_data(shot, **params)
@@ -430,11 +499,18 @@ class VSETB_OT_upload_to_tracker(Operator):
if upload_to_tracker.tasks_comment:
for task_type in self.project.task_types:
task = getattr(strip_settings.tasks, norm_name(task_type.name))
tracker_task = tracker.get_task(task_type.name, entity=shot)
task_settings = getattr(strip_settings.tasks, norm_name(task_type.name))
if not task_settings.comment:
continue
if task.comment and tracker_task.get('last_comment') != task.comment:
tracker.new_comment(tracker_task, comment=task.comment)
tracker_task = tracker.get_task(task_type.name, entity=shot)
if not tracker_task:
tracker_task = tracker.new_task(shot, task_type=task_type.name)
last_comment = tracker_task.get('last_comment')
last_comment_text = last_comment.get('text', '') if isinstance(last_comment, dict) else str(last_comment or '')
if last_comment_text != task_settings.comment:
tracker.new_comment(tracker_task, comment=task_settings.comment)
context.window_manager.progress_end()
@@ -448,6 +524,10 @@ class VSETB_OT_open_shot_on_tracker(Operator):
@classmethod
def poll(cls, context):
if not get_strips(channel='Shots', selected_only=True):
cls.poll_message_set('No active Shots strip')
return
prefs = get_addon_prefs()
if prefs.tracker:
return True
Binary file not shown.
+61 -29
View File
@@ -15,7 +15,7 @@ from vse_toolbox.file_utils import install_module, norm_str
from vse_toolbox.resources.trackers.tracker import Tracker
try:
gazu = install_module('gazu')
gazu = install_module('gazu', package_name='gazu>=1.0,<2.0')
except Exception as e:
print('Could not install gazu')
print(e)
@@ -32,10 +32,15 @@ class Kitsu(Tracker):
admin_password : StringProperty(subtype='PASSWORD')
def admin_connect(self):
url = self.url
url = expandvars(self.url)
if not url:
raise ConnectionError("No Kitsu URL configured")
if not url.endswith('/api'):
url += '/api'
gazu.client.set_host(url)
login = expandvars(self.admin_login or self.login)
password = expandvars(self.admin_password or self.password)
@@ -74,14 +79,14 @@ class Kitsu(Tracker):
if not gazu.client.host_is_up():
print('Error: Kitsu Host is down')
try:
print(f'Info: Log in to kitsu as {login}')
res = gazu.log_in(login, password)
LOGIN = login
print(f'Info: successfully login to Kitsu as {res["user"]["full_name"]}')
return res['user']
except Exception as e:
print(f'Error: {traceback.format_exc()}')
#try:
print(f'Info: Log in to kitsu as {login}')
res = gazu.log_in(login, password)
LOGIN = login
print(f'Info: successfully login to Kitsu as {res["user"]["full_name"]}')
return res['user']
#except Exception as e:
# print(f'Error: {traceback.format_exc()}')
def get_id(self, data):
if isinstance(data, str):
@@ -115,6 +120,11 @@ class Kitsu(Tracker):
return os.environ['TRACKER_PROJECT_NAME']
def get_projects(self):
# gazu caches this endpoint indefinitely by default. This matters for
# projects created or configured after Blender started: the project
# may be present locally (from the toolbox config) while its task
# types are never loaded from Kitsu.
gazu.cache.clear_all()
return gazu.project.all_open_projects()
def get_episode(self, episode, project=None):
@@ -148,13 +158,14 @@ class Kitsu(Tracker):
def get_metadata_types(self, project=None):
project = self.get_project(project)
metadatas = []
for metadata in gazu.project.all_metadata_descriptors(project):
if metadata['name']:
metadatas.append(metadata)
try :
metadatas = gazu.project.all_metadata_descriptors(project)
except gazu.exception.NotAllowedException:
print('No autorized to fetch metadata')
return []
return metadatas
return [m for m in metadatas if m['name']]
def get_task_status(self, status=None):
status_id = self.get_id(status)
@@ -271,7 +282,7 @@ class Kitsu(Tracker):
entity = self.get_id(entity)
task_type = self.get_task_type(task)
task = gazu.task.get_task_by_name(entity, task_type)
task = gazu.task.get_task_by_entity(entity, task_type)
if not task:
return
@@ -346,6 +357,26 @@ class Kitsu(Tracker):
status = self.get_task_status(status)
return gazu.task.new_task(entity, task_type=task_type, task_status=status)
def new_asset(self, name, asset_type_name, project=None, description="", episode=None):
project = self.get_project(project)
project_dict = {'id': self.get_id(project)}
asset_type = gazu.asset.get_asset_type_by_name(asset_type_name)
if not asset_type:
raise ValueError(f"Asset type '{asset_type_name}' not found in Kitsu")
episode_dict = {'id': self.get_id(episode)} if episode else None
return gazu.asset.new_asset(
project_dict, asset_type, name,
description=description, episode=episode_dict
)
def new_episode(self, name, project=None):
project = self.get_project(project)
return gazu.shot.new_episode({'id': self.get_id(project)}, name)
def update_project(self, project=None):
"""Refresh project data — no-op for now, caller should reload via load_projects."""
pass
def new_sequence(self, sequence, episode=None, project=None):
project = self.get_project(project)
@@ -389,27 +420,28 @@ class Kitsu(Tracker):
def update_data(self, entity, custom_data={}, name=None, description=None, frames=None, clear=False):
if isinstance(entity, dict):
entity_id = entity['id']
existing_data = entity.get('data') or {}
else:
entity_id = self.get_id(entity)
entity = gazu.client.fetch_one('entities', entity_id)
existing_data = None
payload = {}
if name:
entity['name'] = name
payload['name'] = name
if description:
entity['description'] = description
payload['description'] = description
if frames:
entity['nb_frames'] = frames
payload['nb_frames'] = frames
if clear or not entity['data']:
entity['data'] = custom_data
else:
entity['data'].update(custom_data)
if custom_data or clear:
if clear:
payload['data'] = dict(custom_data)
else:
if existing_data is None:
existing_data = gazu.client.fetch_one('entities', entity_id).get('data') or {}
payload['data'] = {**existing_data, **custom_data}
#print('######UPDATE DATA')
#pprint(entity)
entity_data = gazu.client.put(f"data/entities/{entity_id}", entity)
#print()
#pprint(entity)
entity_data = gazu.client.put(f"data/entities/{entity_id}", payload)
return entity_data['data']
+148
View File
@@ -0,0 +1,148 @@
import os
import re
import subprocess
import bpy
from vse_toolbox import bl_utils
def detect_scene_change(strip, movie_type="ANIMATED", threshold=0.5, frame_start=None, frame_end=None, crop=None):
"""Launch ffmpeg command to detect changing frames from a movie strip.
Args:
movie_strip (bpy.types.Strip): blender sequence strip to detect changes.
threshold (float): value of the detection factor (from 0 to 1).
frame_start (int, optional): first frame to detect.
Defaults to None.
frame_end (int, optional): last frame to detect.
Defaults to None.
Returns:
str: ffmpeg command log.
"""
path = bl_utils.abspath(strip.filepath)
fps = bpy.context.scene.render.fps
if frame_start is None:
frame_start = 0
if frame_end is None:
frame_end = strip.frame_duration
frame_start = frame_start - strip.frame_start
start_time = frame_start/fps
end_time = frame_end/fps
#frame_start += strip.frame_offset_start
#frame_end -= strip.frame_offset_end
if movie_type == 'ANIMATED':
return select_generator(path, start_time=start_time, end_time=end_time, threshold=threshold, crop=crop)
elif movie_type == 'STILL':
return freeze_detect_generator(path, start_time=start_time, end_time=end_time, threshold=threshold, crop=crop)
else:
raise Exception(f'movie_type: {movie_type} not implemented')
def freeze_detect_generator(path, start_time, end_time, threshold=0.005, crop=None):
"""Generate the ffmpeg command which detect change from a movie.
Args:
path (_type_): path to detect changes.
threshold (_type_): value of the detection factor (from 0 to 1).
frame_start (_type_): first frame to detect.
frame_end (_type_): last frame to detect.
fps (_type_): framerate of the movie.
Returns:
list: ffmpeg command as list for subprocess module.
"""
if crop is None:
crop = [0, 0, 0, 0]
crop_expr = f"crop=iw-{crop[0]}-{crop[1]}:ih-{crop[2]}-{crop[3]}:{crop[0]}:{crop[-1]}"
command = [
'ffmpeg',
'-nostats',
'-i',
str(path),
'-ss', str(start_time),
'-t', str(end_time),
'-vf',
f"{crop_expr}, freezedetect=n={threshold}:d=0.01,metadata=print", #,
'-f',
'null',
'-'
]
print(command)
process = subprocess.Popen(
command,
stdout=subprocess.PIPE,
stderr=subprocess.STDOUT,
universal_newlines=True)
def parse_split_time(log):
print(log)
timecodes = re.findall(r'freeze_end: ([\d.]+)', log)
if timecodes:
return round(float(timecodes[0]) * bpy.context.scene.render.fps)
return filter(None, (parse_split_time(x) for x in process.stdout))
def select_generator(path, start_time, end_time, threshold, crop=None):
"""Generate the ffmpeg command which detect change from a movie.
Args:
path (_type_): path to detect changes.
threshold (_type_): value of the detection factor (from 0 to 1).
frame_start (_type_): first frame to detect.
frame_end (_type_): last frame to detect.
fps (_type_): framerate of the movie.
Returns:
list: ffmpeg command as list for subprocess module.
"""
if crop is None:
crop = [0, 0, 0, 0]
crop_expr = f"crop=iw-{crop[0]}-{crop[1]}:ih-{crop[2]}-{crop[3]}:{crop[0]}:{crop[-1]}"
command = [
'ffmpeg',
'-nostats',
'-i',
str(path),
'-ss', str(start_time),
'-t', str(end_time),
'-vf',
f"{crop_expr}, select='gt(scene, {threshold})', showinfo",
'-f',
'null',
'-'
]
print(command)
process = subprocess.Popen(
command,
stdout=subprocess.PIPE,
stderr=subprocess.STDOUT,
universal_newlines=True)
def parse_split_time(log):
print(log)
timecodes = re.findall(r'pts_time:([\d.]+)', log)
if timecodes:
return round(float(timecodes[0]) * bpy.context.scene.render.fps)
return filter(None, (parse_split_time(x) for x in process.stdout))
+95 -34
View File
@@ -41,17 +41,17 @@ def frame_to_timecode(frame, fps):
return timecode
def new_text_strip(name='Text', channel=0, start=0, end=50, text='Text', font_size=48,
x=0.5, y=0.5, align_x='CENTER', align_y='CENTER', select=False,
x=0.5, y=0.5, anchor_x='CENTER', anchor_y='CENTER', select=False,
box_color=None, box_margin=0.005):
sequences = bpy.context.scene.sequence_editor.sequences
strip = sequences.new_effect(name, 'TEXT', channel, frame_start=start, frame_end=end)
strips = bpy.context.scene.sequence_editor.strips
strip = strips.new_effect(name, 'TEXT', channel, frame_start=start, length=end - start)
strip.select = select
strip.text = text
strip.location.x = x
strip.location.y = y
strip.align_y = align_y
strip.align_x = align_x
strip.anchor_y = anchor_y
strip.anchor_x = anchor_x
strip.channel = channel
strip.font_size = font_size
@@ -74,7 +74,7 @@ def get_strips(channel=0, selected_only=False):
if isinstance(channel, str):
channel = get_channel_index(channel)
strips = [s for s in scn.sequence_editor.sequences if s.channel==channel]
strips = [s for s in scn.sequence_editor.strips if s.channel==channel]
if selected_only:
strips = [s for s in strips if s.select]
@@ -103,11 +103,27 @@ def get_channel_name(strip):
return scn.sequence_editor.channels[strip.channel].name
def get_strip_sequence_name(strip):
sequence_strip = get_strip_at(channel='Sequences', frame=strip.frame_final_start)
"""Return the sequence strip directly above a shot strip."""
scn = bpy.context.scene
sequencer = scn.sequence_editor
if not sequencer or not strip:
return 'NoSequence'
# In the GAV layout, sequence strips are placed immediately above shot
# strips (for example, Sequences on channel 3 and Shots on channel 2).
sequence_strip = next(
(
candidate for candidate in sequencer.strips
if candidate.channel == strip.channel + 1
and candidate.frame_final_start <= strip.frame_final_start
and candidate.frame_final_end >= strip.frame_final_end
),
None,
)
if sequence_strip:
return sequence_strip.name
else:
return 'NoSequence'
return 'NoSequence'
def rename_strips(strips, template, increment=10, start_number=0, padding=3, by_sequence=False):
scn = bpy.context.scene
@@ -121,12 +137,24 @@ def rename_strips(strips, template, increment=10, start_number=0, padding=3, by_
prev_sequence_name = None
strip_number = 0
planned_names = []
for strip in strips:
channel = get_channel_name(strip)
sequence_name = get_strip_sequence_name(strip)
format_data = {}
if channel == 'Shots':
format_data = parse(project.sequence_template, sequence_name)
format_data = (
parse(project.sequence_template, sequence_name)
or parse(project.sequence_template, sequence_name.lower())
or {}
)
if 'sequence' not in format_data:
# Shots created directly from an animatic do not have a
# parent Sequences strip yet. Use the configured first
# sequence number until one is created.
format_data['sequence'] = str(
project.sequence_start_number
).zfill(project.sequence_padding)
else:
format_data['sequence'] = str(strip_number*increment + start_number).zfill(padding)
@@ -141,16 +169,19 @@ def rename_strips(strips, template, increment=10, start_number=0, padding=3, by_
name = template.format(**format_data)
existing_strip = scn.sequence_editor.sequences_all.get(name)
if existing_strip:
existing_strip.name = f"{name}_tmp"
print(f'Renaming {strip.name} -> {name}')
strip.name = name
planned_names.append((strip, name))
prev_sequence_name = sequence_name
strip_number += 1
# Clear all names first so renaming never collides with a later strip and
# leaves a permanent `_tmp` suffix behind.
for index, (strip, _) in enumerate(planned_names):
strip.name = f"__vse_rename_{index:04d}"
for strip, name in planned_names:
print(f'Renaming {strip.name} -> {name}')
strip.name = name
def set_channels():
scn = bpy.context.scene
settings = get_scene_settings()
@@ -299,9 +330,10 @@ def render_scene(output, attributes=None):
(scn.render, "filepath", output),
]
print(f'Render Strip to {scn.render.filepath}')
Path(output).parent.mkdir(exist_ok=True, parents=True)
bpy.ops.render.opengl(animation=True, sequencer=True)
with attr_set(attributes):
print(f'Render Strip to {scn.render.filepath}')
bpy.ops.render.opengl(animation=True, sequencer=True)
def render_strip(strip, output, attributes=None):
output = os.path.abspath(bpy.path.abspath(str(output)))
@@ -325,11 +357,12 @@ def render_strip(strip, output, attributes=None):
def import_edit(filepath, adapter="cmx_3600", channel='Shots', match_by='name'):
opentimelineio = install_module('opentimelineio')
install_module('opentimelineio', package_name='opentimelineio-plugins')
from opentimelineio.schema import Clip
scn = bpy.context.scene
sequencer = scn.sequence_editor.sequences
sequencer = scn.sequence_editor.strips
strips = get_strips(channel='Shots')
shot_channel = get_channel_index('Shots')
@@ -339,23 +372,31 @@ def import_edit(filepath, adapter="cmx_3600", channel='Shots', match_by='name'):
s.channel = empty_channel
edl = Path(filepath)
# Extra kwargs only supported by cmx_3600 adapter
extra_kwargs = {}
if adapter == 'cmx_3600':
extra_kwargs = dict(rate=scn.render.fps, ignore_timecode_mismatch=True)
try:
timeline = opentimelineio.adapters.read_from_file(
str(edl), adapter, rate=scn.render.fps, ignore_timecode_mismatch=True)
str(edl), adapter, **extra_kwargs)
except:
print("[>.] read_from_file Failed. Using read_from_string method.")
data = edl.read_text(encoding='latin-1')
timeline = opentimelineio.adapters.read_from_string(
data, adapter, rate=scn.render.fps, ignore_timecode_mismatch=True)
data, adapter, **extra_kwargs)
scn.frame_start = (
0 if timeline.global_start_time is None else timeline.global_start_time
)
global_start = timeline.global_start_time
if global_start is None:
scn.frame_start = 0
else:
scn.frame_start = int(opentimelineio.opentime.to_frames(global_start))
# Get all video clips only
clips = []
for track in timeline.tracks:
for child in track.each_child(shallow_search=True):
for child in track.find_clips(shallow_search=True):
# FIXME Exclude Gaps for now. Gaps are Transitions, Blank Spaces...
if not isinstance(child, Clip):
@@ -365,12 +406,16 @@ def import_edit(filepath, adapter="cmx_3600", channel='Shots', match_by='name'):
if any(child.name.lower().endswith(ext) for ext in SOUND_SUFFIXES):
continue
if [c for c in clips if child.range_in_parent() == c.range_in_parent()]:
continue
clips.append(child)
clips.sort(key=lambda x: x.range_in_parent().start_time)
for i, clip in enumerate(clips):
#print(clip)
frame_start = opentimelineio.opentime.to_frames(
clip.range_in_parent().start_time)
@@ -389,6 +434,7 @@ def import_edit(filepath, adapter="cmx_3600", channel='Shots', match_by='name'):
print(f'No strip found for {clip.name}')
if strip:
#print('Strip already existing', clip)
if frame_start != strip.frame_final_start or frame_end !=strip.frame_final_end:
print(f'The strip {strip.name} is updated with new range')
#self.report({'INFO'}, f'The strip {strip.name} is updated with new range')
@@ -398,13 +444,13 @@ def import_edit(filepath, adapter="cmx_3600", channel='Shots', match_by='name'):
strip.frame_final_end = frame_end
else:
print('Create a new strip')
print('Create a new strip', clip)
strip = sequencer.new_effect(
name=clip.name,
type='COLOR',
channel=shot_channel,
frame_start=frame_start,
frame_end=frame_end,
length=frame_end - frame_start,
)
strip.blend_alpha = 0.0
@@ -432,7 +478,7 @@ def import_movie(filepath, frame_start=None, frame_end=None):
if len(relpath.as_posix()) < len(filepath.as_posix()):
filepath = relpath
strip = scn.sequence_editor.sequences.new_movie(
strip = scn.sequence_editor.strips.new_movie(
name=Path(filepath).stem,
filepath=str(filepath),
channel=get_channel_index('Movie'),
@@ -473,7 +519,7 @@ def import_sound(filepath, frame_start=None, frame_end=None):
if len(relpath.as_posix()) < len(filepath.as_posix()):
filepath = relpath
strip = scn.sequence_editor.sequences.new_sound(
strip = scn.sequence_editor.strips.new_sound(
name=f'{filepath.stem} Audio',
filepath=str(filepath),
channel=get_channel_index('Audio'),
@@ -488,7 +534,7 @@ def import_sound(filepath, frame_start=None, frame_end=None):
return strip
def get_empty_channel():
return max(s.channel for s in bpy.context.scene.sequence_editor.sequences) + 1
return max(s.channel for s in bpy.context.scene.sequence_editor.strips) + 1
def clean_sequencer(edit=False, movie=False, sound=False):
scn = bpy.context.scene
@@ -502,7 +548,7 @@ def clean_sequencer(edit=False, movie=False, sound=False):
sequences.extend(get_strips('Audio'))
for sequence in sequences:
scn.sequence_editor.sequences.remove(sequence)
scn.sequence_editor.strips.remove(sequence)
@persistent
def set_active_strip(scene):
@@ -524,7 +570,7 @@ def set_active_strip(scene):
@persistent
def update_text_strips(scene):
if not scene.sequence_editor or not scene.sequence_editor.sequences_all:
if not scene.sequence_editor or not scene.sequence_editor.strips_all:
return
#print("update_text_strips")
@@ -570,7 +616,7 @@ def update_text_strips(scene):
})
format_data.update(shot_strip.vsetb_strip_settings.format_data)
for strip in scene.sequence_editor.sequences_all:
for strip in scene.sequence_editor.strips_all:
if not strip.type == 'TEXT':
continue
@@ -582,3 +628,18 @@ def update_text_strips(scene):
if 'text_pattern' in strip.keys():
strip.text = strip['text_pattern'].format_map(MissingKey(**format_data))
def create_shot_strip(name, start, end, channel='Shots'):
shot_strip = bpy.context.scene.sequence_editor.strips.new_effect(
name,
'COLOR',
get_channel_index(channel),
frame_start=start,
length=end - start
)
shot_strip.blend_alpha = 0
shot_strip.color = (0.5, 0.5, 0.5)
return shot_strip
+207 -95
View File
@@ -6,9 +6,9 @@ import bpy
from bpy.types import Panel, Menu
from bl_ui.utils import PresetPanel
from vse_toolbox.bl_utils import (get_addon_prefs, get_scene_settings, get_strip_settings)
from vse_toolbox.bl_utils import get_addon_prefs, get_scene_settings, get_strip_settings
from vse_toolbox.constants import ASSET_PREVIEWS, REVIEW_TEMPLATE_BLEND
from vse_toolbox.sequencer_utils import (set_active_strip, get_channel_name, get_strips)
from vse_toolbox.sequencer_utils import set_active_strip, get_channel_name, get_strips
from vse_toolbox.file_utils import norm_str
@@ -47,18 +47,23 @@ class VSETB_PT_main(VSETB_main, Panel):
row = layout.row(align=True)
row.operator('vse_toolbox.load_projects', icon='FILE_REFRESH', text='', emboss=False)
row.operator(
"vse_toolbox.load_projects", icon="FILE_REFRESH", text="", emboss=False
)
row.separator(factor=0.5)
row.prop(settings, 'project_name', text='')
row.prop(settings, "project_name", text="")
project = settings.active_project
if project and project.type == 'TVSHOW':
if not project:
return
if project and project.type == "TVSHOW":
row.separator(factor=0.5)
row.prop(project, 'episode_name', text='')
row.prop(project, "episode_name", text="")
row.separator(factor=0.5)
row.prop(project, "show_settings", icon="PREFERENCES", text='')
row.prop(project, "show_settings", icon="PREFERENCES", text="")
if project.show_settings:
box = layout.box()
split = box.split(factor=0.3)
@@ -73,7 +78,7 @@ class VSETB_PT_main(VSETB_main, Panel):
row.separator(factor=0.5)
row.label(text="Template")
row.operator("vse_toolbox.add_template", text="", icon='ADD', emboss=False)
row.operator("vse_toolbox.add_template", text="", icon="ADD", emboss=False)
for i, template in enumerate(project.templates):
row = name_col.row()
@@ -83,8 +88,9 @@ class VSETB_PT_main(VSETB_main, Panel):
subrow = row.row()
subrow.prop(template, "value", text="")
row.separator(factor=0.25)
row.operator("vse_toolbox.remove_template", text="", icon='REMOVE', emboss=False).index = i
row.operator(
"vse_toolbox.remove_template", text="", icon="REMOVE", emboss=False
).index = i
# settings = get_scene_settings()
# prefs = get_addon_prefs()
@@ -100,13 +106,13 @@ class VSETB_PT_main(VSETB_main, Panel):
# if project.type == 'TVSHOW':
# col.prop(project, 'episode_name', text='Episodes')
#col.separator()
# col.separator()
#row = col.row(align=True)
# row = col.row(align=True)
#row.prop(settings, 'toogle_prefs', text='', icon='PREFERENCES', toggle=True)
# row.prop(settings, 'toogle_prefs', text='', icon='PREFERENCES', toggle=True)
'''
"""
if settings.toogle_prefs:
box = col.box()
col = box.column(align=True)
@@ -123,21 +129,22 @@ class VSETB_PT_main(VSETB_main, Panel):
#col.prop(project, 'shot_template')
# col.separator()
# col.operator('vse_toolbox.new_episode', text='Add Episode', icon='IMPORT')
'''
"""
# Rename
class VSETB_PT_strip(Panel):
bl_space_type = "SEQUENCE_EDITOR"
bl_region_type = "UI"
bl_category = "Strip"
bl_label = "VSE ToolBox"
#bl_order = 0
# bl_order = 0
@classmethod
def poll(cls, context):
strip = context.active_sequence_strip
return strip and get_channel_name(strip) == 'Shots'
strip = context.active_strip
return strip and get_channel_name(strip) == "Shots"
def draw(self, context):
prefs = get_addon_prefs()
@@ -147,22 +154,28 @@ class VSETB_PT_strip(Panel):
layout.use_property_split = True
layout.use_property_decorate = False
layout.prop(settings, 'source_name', text='Source Name')
layout.prop(settings, "source_name", text="Source Name")
class VSETB_PT_sequencer(VSETB_main, Panel):
bl_label = "Sequencer"
#bl_parent_id = "VSETB_PT_main"
# bl_parent_id = "VSETB_PT_main"
def draw_header_preset(self, context):
settings = get_scene_settings()
audio_strips = get_strips('Audio')
audio_strips = [s for s in get_strips("Audio") if s.type == "SOUND"]
depress = any(s.show_waveform for s in audio_strips)
self.layout.operator('vse_toolbox.show_waveform', text="", icon="IPO_ELASTIC", depress=depress).enabled = not depress
self.layout.operator(
"vse_toolbox.show_waveform", text="", icon="IPO_ELASTIC", depress=depress
).enabled = not depress
ico = ("RESTRICT_SELECT_OFF" if settings.auto_select_strip else "RESTRICT_SELECT_ON")
ico = (
"RESTRICT_SELECT_OFF"
if settings.auto_select_strip
else "RESTRICT_SELECT_ON"
)
self.layout.prop(settings, "auto_select_strip", text="", icon=ico)
def draw(self, context):
@@ -171,22 +184,36 @@ class VSETB_PT_sequencer(VSETB_main, Panel):
settings = get_scene_settings()
project = settings.active_project
strip = context.active_sequence_strip
channel = get_channel_name(strip)
strip = context.active_strip
if strip is None and context.scene.sequence_editor:
strip = next(
(s for s in context.scene.sequence_editor.strips if s.select),
None,
)
channel = get_channel_name(strip) or "Selected Channel"
col = layout.column()
col.operator('vse_toolbox.set_sequencer', text='Set-Up Sequencer', icon='SEQ_SEQUENCER')
col.operator('vse_toolbox.strips_rename', text=f'Rename {channel}', icon='SORTALPHA')
col.operator('vse_toolbox.set_stamps', text='Set Stamps', icon='COLOR')
col.operator("vse_toolbox.collect_files", text='Collect Files', icon='PACKAGE')
col.operator(
"vse_toolbox.strips_rename", text=f"Rename {channel}", icon="SORTALPHA"
)
col.operator("vse_toolbox.set_stamps", text="Set Stamps", icon="COLOR")
col.operator("vse_toolbox.collect_files", text="Collect Files", icon="PACKAGE")
col.operator(
"vse_toolbox.scene_cut_detection",
text="Scene Cut Detection",
icon="SCULPTMODE_HLT",
)
col.operator(
"vse_toolbox.create_sequence_strip", text="Create Sequence", icon="SEQUENCE"
)
class VSETB_PT_settings(VSETB_main, Panel):
bl_label = "Settings"
#bl_parent_id = "VSETB_PT_main"
bl_options = {'DEFAULT_CLOSED'}
# bl_parent_id = "VSETB_PT_main"
bl_options = {"DEFAULT_CLOSED"}
#def draw_header_preset(self, context):
# def draw_header_preset(self, context):
# self.layout.operator('vse_toolbox.import_files', icon='IMPORT', text='', emboss=False)
def draw(self, context):
@@ -196,19 +223,21 @@ class VSETB_PT_settings(VSETB_main, Panel):
project = settings.active_project
col = layout.column()
#row = col.row(align=True)
col.prop(project, 'sequence_template')
col.prop(project, 'shot_template')
col.prop(project, 'render_template')
# row = col.row(align=True)
col.prop(project, "sequence_template")
col.prop(project, "shot_template")
col.prop(project, "render_template")
class VSETB_PT_imports(VSETB_main, Panel):
bl_label = "Imports"
#bl_parent_id = "VSETB_PT_main"
#bl_options = {'DEFAULT_CLOSED'}
# bl_parent_id = "VSETB_PT_main"
# bl_options = {'DEFAULT_CLOSED'}
def draw_header_preset(self, context):
self.layout.operator('vse_toolbox.import_files', icon='IMPORT', text='', emboss=False)
self.layout.operator(
"vse_toolbox.import_files", icon="IMPORT", text="", emboss=False
)
def draw(self, context):
prefs = get_addon_prefs()
@@ -217,26 +246,35 @@ class VSETB_PT_imports(VSETB_main, Panel):
project = settings.active_project
col = layout.column()
#row = col.row(align=True)
#col.operator('vse_toolbox.import_files', text='Import', icon='IMPORT')
col.operator('vse_toolbox.import_spreadsheet', text='Import Spreadsheet', icon='SPREADSHEET')
col.operator("vse_toolbox.import_shots", text='Import Shots', icon="FILE_MOVIE")
# row = col.row(align=True)
# col.operator('vse_toolbox.import_files', text='Import', icon='IMPORT')
col.operator(
"vse_toolbox.import_spreadsheet",
text="Import Spreadsheet",
icon="SPREADSHEET",
)
col.operator("vse_toolbox.import_shots", text="Import Shots", icon="FILE_MOVIE")
col.operator(
"vse_toolbox.import_stb_xml", text="Import STB XML", icon="FILE_TEXT"
)
class VSETB_PT_presets(PresetPanel, Panel):
bl_label = 'Spreadsheet Presets'
preset_subdir = 'vse_toolbox'
preset_operator = 'script.execute_preset'
bl_label = "Spreadsheet Presets"
preset_subdir = "vse_toolbox"
preset_operator = "script.execute_preset"
preset_add_operator = "vse_toolbox.add_spreadsheet_preset"
class VSETB_PT_exports(VSETB_main, Panel):
bl_label = "Exports"
#bl_parent_id = "VSETB_PT_main"
bl_options = {'DEFAULT_CLOSED'}
# bl_parent_id = "VSETB_PT_main"
bl_options = {"DEFAULT_CLOSED"}
def draw_header_preset(self, context):
self.layout.operator('vse_toolbox.export_spreadsheet', icon='EXPORT', text='', emboss=False)
self.layout.operator(
"vse_toolbox.export_spreadsheet", icon="EXPORT", text="", emboss=False
)
def draw(self, context):
prefs = get_addon_prefs()
@@ -246,39 +284,48 @@ class VSETB_PT_exports(VSETB_main, Panel):
col = layout.column(align=False)
# TODO FAIRE DES VRAIS OPS
col.operator('vse_toolbox.strips_render', text='Render Strips', icon='SEQUENCE')
col.operator("vse_toolbox.strips_render", text="Render Strips", icon="SEQUENCE")
tracker_label = settings.tracker_name.title().replace('_', ' ')
col.operator('vse_toolbox.upload_to_tracker', text=f'Upload to {tracker_label}', icon='EXPORT')
col.operator('vse_toolbox.export_spreadsheet', text='Export Spreadsheet', icon='SPREADSHEET')
col.operator('vse_toolbox.export_edl', text='Export edl', icon='SEQ_SEQUENCER')
tracker_label = settings.tracker_name.title().replace("_", " ")
col.operator(
"vse_toolbox.upload_to_tracker",
text=f"Upload to {tracker_label}",
icon="EXPORT",
)
col.operator(
"vse_toolbox.export_spreadsheet",
text="Export Spreadsheet",
icon="SPREADSHEET",
)
col.operator("vse_toolbox.export_edl", text="Export edl", icon="SEQ_SEQUENCER")
class VSETB_PT_tracker(VSETB_main, Panel):
bl_label = "Tracker"
#bl_parent_id = "VSETB_PT_main"
bl_options = {'DEFAULT_CLOSED'}
# bl_parent_id = "VSETB_PT_main"
bl_options = {"DEFAULT_CLOSED"}
@classmethod
def poll(cls, context):
return context.active_sequence_strip
return context.active_strip
def draw_header_preset(self, context):
active_strip = context.active_sequence_strip
active_strip = context.active_strip
self.layout.label(text=active_strip.name)
def draw(self, context):
return
class VSETB_PT_casting(VSETB_main, Panel):
bl_label = "Casting"
bl_parent_id = "VSETB_PT_tracker"
bl_options = {'DEFAULT_CLOSED'}
bl_options = {"DEFAULT_CLOSED"}
@classmethod
def poll(cls, context):
strip = context.active_sequence_strip
return strip and get_channel_name(strip) == 'Shots'
strip = context.active_strip
return strip and get_channel_name(strip) == "Shots"
def draw(self, context):
layout = self.layout
@@ -293,25 +340,39 @@ class VSETB_PT_casting(VSETB_main, Panel):
if not project.assets:
row = layout.row(align=True)
row.label(text='No Assets in this Project')
row.label(text="No Assets in this Project")
else:
row = layout.row()
col = row.column()
col.template_list("VSETB_UL_casting", "shot_casting", strip_settings, "casting", strip_settings, "casting_index", rows=6)
col.template_list(
"VSETB_UL_casting",
"shot_casting",
strip_settings,
"casting",
strip_settings,
"casting_index",
rows=6,
)
col_tool = row.column(align=True)
col_tool.operator('vse_toolbox.casting_add', icon='ADD', text="")
col_tool.operator('vse_toolbox.casting_remove', icon='REMOVE', text="")
col_tool.operator("vse_toolbox.casting_add", icon="ADD", text="")
col_tool.operator("vse_toolbox.casting_remove", icon="REMOVE", text="")
col_tool.separator()
col_tool.operator('vse_toolbox.casting_move', icon='TRIA_UP', text="").direction = 'UP'
col_tool.operator('vse_toolbox.casting_move', icon='TRIA_DOWN', text="").direction = 'DOWN'
col_tool.operator(
"vse_toolbox.casting_move", icon="TRIA_UP", text=""
).direction = "UP"
col_tool.operator(
"vse_toolbox.casting_move", icon="TRIA_DOWN", text=""
).direction = "DOWN"
col_tool.separator()
col_tool.operator('vse_toolbox.copy_casting', icon='COPYDOWN', text="")
col_tool.operator('vse_toolbox.paste_casting', icon='PASTEDOWN', text="")
col_tool.operator("vse_toolbox.copy_casting", icon="COPYDOWN", text="")
col_tool.operator("vse_toolbox.paste_casting", icon="PASTEDOWN", text="")
col_tool.separator()
col_tool.operator('vse_toolbox.casting_replace', icon='ZOOM_ALL', text="")
col_tool.operator("vse_toolbox.casting_replace", icon="ZOOM_ALL", text="")
col_tool.separator()
col_tool.operator("vse_toolbox.casting_create_asset", icon="PLUS", text="")
if strip_settings.casting:
casting_item = strip_settings.casting[strip_settings.casting_index]
@@ -319,7 +380,7 @@ class VSETB_PT_casting(VSETB_main, Panel):
if asset:
if asset.icon_id:
row = col.row(align=True)
#row.scale_y = 0.5
# row.scale_y = 0.5
# box = col.box()
# box.template_icon(icon_value=ico.icon_id, scale=7.5)
@@ -329,11 +390,11 @@ class VSETB_PT_casting(VSETB_main, Panel):
class VSETB_PT_metadata(VSETB_main, Panel):
bl_label = "Shot Metadata"
bl_parent_id = "VSETB_PT_tracker"
bl_options = {'DEFAULT_CLOSED'}
bl_options = {"DEFAULT_CLOSED"}
@classmethod
def poll(cls, context):
return context.active_sequence_strip and get_scene_settings().active_project
return context.active_strip and get_scene_settings().active_project
def draw(self, context):
@@ -343,11 +404,11 @@ class VSETB_PT_metadata(VSETB_main, Panel):
layout = self.layout
row = layout.row(align=True)
label_col = row.column(align=True)
label_col.alignment = 'RIGHT'
label_col.alignment = "RIGHT"
field_col = row.column(align=True)
for metadata_type in project.metadata_types:
if metadata_type.entity_type != 'SHOT':
if metadata_type.entity_type != "SHOT":
continue
metadata_key = metadata_type.field_name
@@ -359,41 +420,47 @@ class VSETB_PT_metadata(VSETB_main, Panel):
if metadata_type.choices:
metadata_value = getattr(strip_settings.metadata, metadata_key)
icon = 'LAYER_USED'
icon = "LAYER_USED"
if metadata_value:
if metadata_value in metadata_type.choices:
icon = 'DOT'
icon = "DOT"
else:
icon = 'ADD'
icon = "ADD"
field_row.prop_search(strip_settings.metadata, metadata_key, metadata_type, 'choices',
results_are_suggestions=True, icon=icon, text='')
field_row.prop_search(
strip_settings.metadata,
metadata_key,
metadata_type,
"choices",
results_are_suggestions=True,
icon=icon,
text="",
)
else:
field_row.prop(strip_settings.metadata, metadata_key, text='')
field_row.prop(strip_settings.metadata, metadata_key, text="")
class VSETB_PT_comments(VSETB_main, Panel):
bl_label = "Comments"
bl_parent_id = "VSETB_PT_tracker"
bl_options = {'DEFAULT_CLOSED'}
bl_options = {"DEFAULT_CLOSED"}
@classmethod
def poll(cls, context):
return context.active_sequence_strip and get_scene_settings().active_project
return context.active_strip and get_scene_settings().active_project
def draw(self, context):
layout = self.layout
row = layout.row(align=True)
label_col = row.column(align=True)
label_col.alignment = 'RIGHT'
label_col.alignment = "RIGHT"
field_col = row.column(align=True)
strip_settings = get_strip_settings()
project = get_scene_settings().active_project
for task_type in project.task_types:
norm_task_name = norm_str(task_type.name)
@@ -406,17 +473,20 @@ class VSETB_PT_comments(VSETB_main, Panel):
row = field_col.row(align=True)
row.separator()
sub = row.row(align=True)
sub.alignment = 'LEFT'
sub.alignment = "LEFT"
sub.scale_x = 0.15
sub.prop(task_type, 'color', text='')
sub.prop(task_type, "color", text="")
sub.enabled = False
row.prop(getattr(strip_settings.tasks, norm_task_name), 'comment', text='')
row.prop(getattr(strip_settings.tasks, norm_task_name), "comment", text="")
def context_menu_prop(self, context):
if not hasattr(context, 'button_prop') or context.space_data.type != 'SEQUENCE_EDITOR':
if (
not hasattr(context, "button_prop")
or context.space_data.type != "SEQUENCE_EDITOR"
):
return
settings = get_strip_settings()
@@ -429,7 +499,9 @@ def context_menu_prop(self, context):
layout = self.layout
layout.separator()
layout.operator('vse_toolbox.copy_metadata', icon='PASTEDOWN', text='Copy metadata to selected').metadata = button_prop.name
layout.operator(
"vse_toolbox.copy_metadata", icon="PASTEDOWN", text="Copy metadata to selected"
).metadata = button_prop.name
class VSETB_MT_main_menu(Menu):
@@ -438,18 +510,52 @@ class VSETB_MT_main_menu(Menu):
def draw(self, context):
layout = self.layout
op = layout.operator('workspace.append_activate', text='Set Review Workspace', icon="WORKSPACE")
op.idname = 'Review'
op = layout.operator(
"workspace.append_activate", text="Set Review Workspace", icon="WORKSPACE"
)
op.idname = "Review"
op.filepath = str(REVIEW_TEMPLATE_BLEND)
layout.operator("wm.split_view", icon="ARROW_LEFTRIGHT")
layout.operator('vse_toolbox.open_shot_folder', text='Open Shot Folder', icon='FILE_FOLDER')
layout.operator('vse_toolbox.open_shot_on_tracker', text='Open Shot on Tracker', icon='URL')
layout.separator()
layout.operator(
"vse_toolbox.update_media", text="Update Media", icon="FILE_REFRESH"
)
layout.operator(
"vse_toolbox.open_shot_on_tracker", text="Open Shot on Tracker", icon="URL"
)
layout.operator(
"vse_toolbox.open_strip_folder",
text="Open Strip Folder",
icon="FILE_FOLDER",
)
layout.separator()
layout.operator(
"vse_toolbox.insert_channel", text="Insert Channel", icon="TRIA_UP_BAR"
)
layout.operator(
"vse_toolbox.remove_channel", text="Remove Channel", icon="TRIA_DOWN_BAR"
)
layout.separator()
layout.operator("vse_toolbox.merge_shot_strips", text="Merge Shots")
layout.operator("vse_toolbox.create_sequence_strip", text="Create Sequence")
layout.operator(
"vse_toolbox.scene_cut_detection",
text="Scene Cut Detection",
icon="SCULPTMODE_HLT",
)
def draw_vse_toolbox_menu(self, context):
self.layout.menu("VSETB_MT_main_menu")
def draw_file_new(self, context):
self.layout.separator()
op = self.layout.operator("wm.read_homefile", text="Review")
op.filepath = str(REVIEW_TEMPLATE_BLEND)
op.load_ui = True
classes = (
VSETB_PT_main,
@@ -462,9 +568,10 @@ classes = (
VSETB_PT_presets,
VSETB_PT_exports,
VSETB_PT_strip,
VSETB_MT_main_menu
VSETB_MT_main_menu,
)
def register():
for cls in classes:
bpy.utils.register_class(cls)
@@ -472,9 +579,14 @@ def register():
bpy.types.UI_MT_button_context_menu.append(context_menu_prop)
bpy.types.SEQUENCER_MT_editor_menus.append(draw_vse_toolbox_menu)
bpy.types.TOPBAR_MT_file_new.append(draw_file_new)
def unregister():
for cls in reversed(classes):
bpy.utils.unregister_class(cls)
bpy.types.UI_MT_button_context_menu.remove(context_menu_prop)
bpy.types.SEQUENCER_MT_editor_menus.remove(draw_vse_toolbox_menu)
bpy.types.TOPBAR_MT_file_new.remove(draw_file_new)
+23 -2
View File
@@ -64,6 +64,18 @@ def load_trackers():
print(e)
def _load_settings_after_startup():
"""Apply the config once Blender has a normal context available."""
if not getattr(bpy.context, 'scene', None):
return 0.5
try:
bpy.ops.vse_toolbox.load_settings()
except RuntimeError as e:
print(f'Could not load VSE Toolbox settings at startup: {e}')
return None
def load_prefs():
prefs = get_addon_prefs()
prefs_config_file = prefs.config_path
@@ -91,7 +103,16 @@ def load_prefs():
continue
setattr(tracker_pref, k, os.path.expandvars(v))
prefs['tracker_name'] = prefs_datas['tracker_name']
try:
settings = get_scene_settings()
settings.tracker_name = prefs_datas['tracker_name']
except AttributeError:
pass # Scene not available yet during register()
# Loading preferences only sets the config path and tracker credentials;
# apply the project configuration once Blender has finished registering
# and provides a normal context (register() uses _RestrictContext).
bpy.app.timers.register(_load_settings_after_startup, first_interval=0.1)
class Trackers(PropertyGroup):
@@ -155,7 +176,7 @@ def register():
config_path = os.getenv('VSE_TOOLBOX_CONFIG')
if config_path:
prefs['config_path'] = os.path.expandvars(config_path)
prefs.config_path = os.path.expandvars(config_path)
load_prefs()
+32 -8
View File
@@ -119,6 +119,7 @@ class Sequence(PropertyGroup):
class TaskStatus(PropertyGroup):
is_done : BoolProperty(default=False)
__annotations__ = {}
@@ -252,6 +253,7 @@ class ImportShots(PropertyGroup):
#shot_folder_template: StringProperty(
# name="Shot Template", default="$PROJECT_ROOT/sequences/sq{sequence}/sh{shot}")
clear: BoolProperty(default=True)
video_template : StringProperty(
name="Video Path", default="//sources/{sequence}_{shot}_{task}.{ext}")
@@ -266,7 +268,8 @@ class ImportShots(PropertyGroup):
class UploadToTracker(PropertyGroup):
render_strips: BoolProperty(default=False)
render_strips: BoolProperty(default=False,
description='Render each selected shot strip to the Movie Path before uploading it as the preview')
render_strip_template : StringProperty(
name="Movie Path", default="//render/{strip}.{ext}")
@@ -298,7 +301,28 @@ def get_episodes_items(self, context):
def on_episode_updated(self, context):
settings = get_scene_settings()
os.environ['TRACKER_EPISODE_ID'] = settings.active_episode.id
project = settings.active_project
episode = settings.active_episode
if not episode or not episode.id:
return
os.environ['TRACKER_EPISODE_ID'] = episode.id
# Reload sequences for the selected episode (TVSHOW workflow)
prefs = get_addon_prefs()
tracker = prefs.tracker
if not tracker or not project:
return
project.sequences.clear()
try:
for seq in tracker.get_sequences(project.id, episode=episode.id):
item = project.sequences.add()
item.name = seq['name']
item.id = seq['id']
except Exception as e:
print(f'Could not load sequences for episode {episode.name}: {e}')
class Project(PropertyGroup):
id : StringProperty(default='')
@@ -714,7 +738,7 @@ class VSETB_PGT_strip_settings(PropertyGroup):
@property
def strip(self):
sequences = bpy.context.scene.sequence_editor.sequences_all
sequences = bpy.context.scene.sequence_editor.strips_all
return next(s for s in sequences if s.vsetb_strip_settings == self)
@property
@@ -727,7 +751,7 @@ class VSETB_PGT_strip_settings(PropertyGroup):
channel = get_channel_name(strip)
if channel == 'Sequences':
data = parse(project.sequence_template, strip.name)
data = parse(project.sequence_template, strip.name) or {}
#data['index'] = int(data['index'])
#data['sequence'] = strip.name
data['strip'] = strip.name
@@ -736,10 +760,10 @@ class VSETB_PGT_strip_settings(PropertyGroup):
elif channel == "Shots":
data = {}
if sequence_strip_name := get_strip_sequence_name(strip):
data = parse(project.sequence_template, sequence_strip_name)
data = parse(project.sequence_template, sequence_strip_name) or {}
data['sequence_strip'] = sequence_strip_name
data.update(parse(project.shot_template, strip.name))
data.update(parse(project.shot_template, strip.name) or {})
#data['index'] = int(data['index'])
data['strip'] = strip.name
@@ -807,7 +831,7 @@ def register():
bpy.utils.register_class(cls)
bpy.types.Scene.vsetb_settings = PointerProperty(type=VSETB_PGT_scene_settings)
bpy.types.Sequence.vsetb_strip_settings = PointerProperty(type=VSETB_PGT_strip_settings)
bpy.types.Strip.vsetb_strip_settings = PointerProperty(type=VSETB_PGT_strip_settings)
#load_metadata_types()
bpy.app.handlers.load_post.append(load_handler)
@@ -817,7 +841,7 @@ def unregister():
for cls in reversed(classes):
bpy.utils.unregister_class(cls)
del bpy.types.Sequence.vsetb_strip_settings
del bpy.types.Strip.vsetb_strip_settings
del bpy.types.Scene.vsetb_settings
bpy.app.handlers.load_post.remove(load_handler)