- 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>
871 lines
29 KiB
Python
871 lines
29 KiB
Python
|
|
from pathlib import Path
|
|
from os.path import expandvars
|
|
import re
|
|
import glob
|
|
from tempfile import gettempdir
|
|
|
|
import bpy
|
|
from bpy.types import Operator, UIList
|
|
from bpy.props import (CollectionProperty, BoolProperty, EnumProperty, StringProperty, IntProperty)
|
|
|
|
from vse_toolbox.constants import (EDITS, EDIT_SUFFIXES, MOVIES, MOVIE_SUFFIXES,
|
|
SOUNDS, SOUND_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, abspath)
|
|
from vse_toolbox.file_utils import install_module, parse, find_last, expand
|
|
|
|
|
|
class VSETB_OT_auto_select_files(Operator):
|
|
bl_idname = "vse_toolbox.auto_select_files"
|
|
bl_label = "Auto Select"
|
|
bl_description = "Auto Select Files"
|
|
bl_options = {"REGISTER", "UNDO"}
|
|
|
|
@classmethod
|
|
def poll(cls, context):
|
|
return True
|
|
|
|
def get_items(self, items=[]):
|
|
if not items:
|
|
return [('NONE', 'None', '', 0)]
|
|
return [(e, e, '', i) for i, e in enumerate(items)]
|
|
|
|
def execute(self, context):
|
|
params = context.space_data.params
|
|
directory = Path(params.directory.decode())
|
|
|
|
EDITS.clear()
|
|
MOVIES.clear()
|
|
SOUNDS.clear()
|
|
|
|
edits = []
|
|
movies = []
|
|
sounds = []
|
|
|
|
for file_entry in directory.glob('*'):
|
|
if file_entry.is_dir():
|
|
continue
|
|
|
|
if file_entry.suffix in EDIT_SUFFIXES:
|
|
edits.append(file_entry.name)
|
|
elif file_entry.suffix in MOVIE_SUFFIXES:
|
|
movies.append(file_entry.name)
|
|
elif file_entry.suffix in SOUND_SUFFIXES:
|
|
sounds.append(file_entry.name)
|
|
|
|
edits.sort(reverse=True)
|
|
movies.sort(reverse=True)
|
|
sounds.sort(reverse=True)
|
|
|
|
EDITS.extend(self.get_items(items=edits))
|
|
MOVIES.extend(self.get_items(items=movies))
|
|
SOUNDS.extend(self.get_items(items=sounds))
|
|
|
|
return {'FINISHED'}
|
|
|
|
|
|
class VSETB_OT_import_files(Operator):
|
|
bl_idname = "vse_toolbox.import_files"
|
|
bl_label = "Import"
|
|
bl_description = "Import Edit"
|
|
bl_options = {"REGISTER", "UNDO"}
|
|
|
|
directory : StringProperty(subtype='DIR_PATH')
|
|
filepath: StringProperty(
|
|
name="File Path",
|
|
description="Filepath used for importing the file",
|
|
maxlen=1024,
|
|
subtype='FILE_PATH',
|
|
)
|
|
files : CollectionProperty(type=bpy.types.OperatorFileListElement)
|
|
clean_sequencer : BoolProperty(
|
|
name="Clean Sequencer",
|
|
default=False,
|
|
description="Clean all existing strips in sequencer",
|
|
)
|
|
|
|
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)
|
|
movie: EnumProperty(name='', items=lambda s, c: MOVIES)
|
|
|
|
import_sound : BoolProperty(name='', default=False)
|
|
sound: EnumProperty(name='', items=lambda s, c: SOUNDS)
|
|
|
|
@classmethod
|
|
def poll(cls, context):
|
|
return True
|
|
|
|
def draw(self, context):
|
|
scn = context.scene
|
|
settings = get_scene_settings()
|
|
|
|
layout = self.layout
|
|
layout.use_property_split = True
|
|
layout.use_property_decorate = False
|
|
|
|
col = layout.column(align=True)
|
|
col.operator('vse_toolbox.auto_select_files', text='Auto Select')
|
|
|
|
row = layout.row(heading="Import Edit", align=True)
|
|
row.prop(self, 'import_edit')
|
|
sub = row.row(align=True)
|
|
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()
|
|
|
|
row = layout.row(heading="Import Movie", align=True)
|
|
row.prop(self, 'import_movie')
|
|
sub = row.row()
|
|
sub.active = self.import_movie
|
|
sub.prop(self, 'movie')
|
|
|
|
row = layout.row(heading="Import Sound", align=True)
|
|
row.prop(self, 'import_sound')
|
|
sub = row.row()
|
|
sub.active = self.import_sound
|
|
sub.prop(self, 'sound')
|
|
|
|
col = layout.column()
|
|
col.separator()
|
|
col.prop(self, 'clean_sequencer')
|
|
|
|
def invoke(self, context, event):
|
|
context.window_manager.fileselect_add(self)
|
|
return {'RUNNING_MODAL'}
|
|
|
|
def execute(self, context):
|
|
sequencer = context.scene.sequence_editor.strips
|
|
|
|
edit_filepath = Path(self.directory, self.edit)
|
|
if not edit_filepath.exists():
|
|
self.import_edit = False
|
|
|
|
movie_filepath = Path(self.directory, self.movie)
|
|
if not movie_filepath.exists():
|
|
self.import_movie = False
|
|
|
|
sound_filepath = Path(self.directory, self.sound)
|
|
if not sound_filepath.exists():
|
|
self.import_sound = False
|
|
|
|
if self.clean_sequencer:
|
|
clean_sequencer(
|
|
edit=self.import_edit,
|
|
movie=self.import_movie,
|
|
sound=self.import_sound,
|
|
)
|
|
|
|
if self.import_edit:
|
|
print(f'[>.] Loading Edit from: {str(edit_filepath)}')
|
|
|
|
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)}')
|
|
|
|
for strip in get_strips(channel='Movie'):
|
|
sequencer.remove(strip)
|
|
|
|
import_movie(movie_filepath)
|
|
|
|
if self.import_sound or (not self.import_sound and self.import_movie):
|
|
|
|
for strip in get_strips(channel='Audio'):
|
|
sequencer.remove(strip)
|
|
|
|
if self.import_sound:
|
|
print(f'[>.] Loading Audio from: {str(sound_filepath)}')
|
|
import_sound(sound_filepath)
|
|
else:
|
|
print(f'[>.] Loading Audio from: {str(movie_filepath)}')
|
|
import_sound(movie_filepath)
|
|
|
|
context.scene.sequence_editor.strips.update()
|
|
|
|
return {"FINISHED"}
|
|
|
|
class VSETB_UL_import_task(UIList):
|
|
def draw_item(self, context, layout, data, item, icon, active_data,
|
|
active_propname, index):
|
|
|
|
layout.separator(factor=0.5)
|
|
layout.prop(item, 'import_enabled', text='')
|
|
layout.label(text=item.name)
|
|
|
|
|
|
def get_task_items(self, context):
|
|
settings = get_scene_settings()
|
|
project = settings.active_project
|
|
|
|
return [(t, t, '') for t in project.task_types.keys()]
|
|
class VSETB_OT_select_task(Operator):
|
|
bl_idname = "vse_toolbox.select_task"
|
|
bl_label = "Select Task"
|
|
bl_description = "Select Task"
|
|
bl_options = {"REGISTER", "UNDO"}
|
|
|
|
bl_property = "task"
|
|
|
|
task: EnumProperty(name="My Search", items=get_task_items)
|
|
|
|
@classmethod
|
|
def poll(cls, context):
|
|
return True
|
|
|
|
def execute(self, context):
|
|
scn = context.scene
|
|
settings = get_scene_settings()
|
|
project = settings.active_project
|
|
|
|
project.task_types[self.task].import_enabled = True
|
|
|
|
return {'FINISHED'}
|
|
|
|
def invoke(self, context, event):
|
|
context.window_manager.invoke_search_popup(self)
|
|
return {"FINISHED"}
|
|
|
|
|
|
class VSETB_OT_unselect_task(Operator):
|
|
bl_idname = "vse_toolbox.unselect_task"
|
|
bl_label = "Unselect Task"
|
|
bl_description = "Unselect Task"
|
|
bl_options = {"REGISTER", "UNDO"}
|
|
|
|
task : StringProperty()
|
|
|
|
def execute(self, context):
|
|
scn = context.scene
|
|
settings = get_scene_settings()
|
|
project = settings.active_project
|
|
|
|
project.task_types[self.task].import_enabled = False
|
|
|
|
return {'FINISHED'}
|
|
|
|
|
|
class VSETB_OT_add_import_template(Operator):
|
|
bl_idname = "vse_toolbox.add_import_template"
|
|
bl_label = "Add Template"
|
|
bl_options = {"REGISTER", "UNDO"}
|
|
|
|
def execute(self, context):
|
|
scn = context.scene
|
|
settings = get_scene_settings()
|
|
project = settings.active_project
|
|
|
|
project.import_shots.video_templates.add()
|
|
|
|
return {'FINISHED'}
|
|
|
|
|
|
class VSETB_OT_remove_import_template(Operator):
|
|
bl_idname = "vse_toolbox.remove_import_template"
|
|
bl_label = "Remove Template"
|
|
bl_options = {"REGISTER", "UNDO"}
|
|
|
|
index : IntProperty()
|
|
|
|
def execute(self, context):
|
|
scn = context.scene
|
|
settings = get_scene_settings()
|
|
project = settings.active_project
|
|
|
|
project.import_shots.video_templates.remove(self.index)
|
|
|
|
return {'FINISHED'}
|
|
|
|
|
|
def get_sequence_items(self, context):
|
|
settings = get_scene_settings()
|
|
project = settings.active_project
|
|
|
|
return [(t, t, '') for t in project.sequences.keys()]
|
|
class VSETB_OT_select_sequence(Operator):
|
|
bl_idname = "vse_toolbox.select_sequence"
|
|
bl_label = "Select Sequence"
|
|
bl_description = "Select Sequence"
|
|
bl_options = {"REGISTER", "UNDO"}
|
|
|
|
bl_property = "sequence"
|
|
|
|
sequence: EnumProperty(name="My Search", items=get_sequence_items)
|
|
|
|
@classmethod
|
|
def poll(cls, context):
|
|
return True
|
|
|
|
def execute(self, context):
|
|
scn = context.scene
|
|
settings = get_scene_settings()
|
|
project = settings.active_project
|
|
|
|
project.sequences[self.sequence].import_enabled = True
|
|
|
|
return {'FINISHED'}
|
|
|
|
def invoke(self, context, event):
|
|
context.window_manager.invoke_search_popup(self)
|
|
return {"FINISHED"}
|
|
|
|
|
|
class VSETB_OT_unselect_sequence(Operator):
|
|
bl_idname = "vse_toolbox.unselect_sequence"
|
|
bl_label = "Unselect sequence"
|
|
bl_description = "Unselect sequence"
|
|
bl_options = {"REGISTER", "UNDO"}
|
|
|
|
sequence : StringProperty()
|
|
|
|
def execute(self, context):
|
|
scn = context.scene
|
|
settings = get_scene_settings()
|
|
project = settings.active_project
|
|
|
|
project.sequences[self.sequence].import_enabled = False
|
|
|
|
return {'FINISHED'}
|
|
|
|
class VSETB_OT_import_shots(Operator):
|
|
bl_idname = "vse_toolbox.import_shots"
|
|
bl_label = "Import Shots"
|
|
bl_description = "Import Shots for disk or tracker"
|
|
bl_options = {"REGISTER", "UNDO"}
|
|
|
|
@classmethod
|
|
def poll(cls, context):
|
|
return get_scene_settings().active_project
|
|
|
|
def set_sequencer_channels(self, task_types):
|
|
scn = bpy.context.scene
|
|
channels = ['Shots', 'Sequences', 'Stamps']
|
|
#channel_index = len(task_types * 3) + len(channels) + 1
|
|
|
|
channel_index = 1
|
|
|
|
for task_type in task_types:
|
|
audio_channel = scn.sequence_editor.channels[channel_index]
|
|
audio_channel.name = f'{task_type} Audio'
|
|
audio_channel.mute = (task_type != task_types[-1])
|
|
|
|
video_channel = scn.sequence_editor.channels[channel_index+1]
|
|
video_channel.name = f'{task_type} Video'
|
|
|
|
channel_index += 3
|
|
|
|
for channel in channels:
|
|
scn.sequence_editor.channels[channel_index].name = channel
|
|
|
|
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 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
|
|
|
|
last_preview = last_comment['previews'][0]
|
|
|
|
ext = last_preview['extension']
|
|
shot_name = shot['name']
|
|
sequence_name = f"{shot['sequence_name']}_"
|
|
if shot_name.startswith(sequence_name):
|
|
shot_name = shot_name.replace(sequence_name, '', 1)
|
|
|
|
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)
|
|
|
|
tracker.download_preview_file(last_preview, str(filepath))
|
|
|
|
return filepath
|
|
|
|
def conform_render(self, clip):
|
|
scn = bpy.context.scene
|
|
scn.render.resolution_x = clip.elements[0].orig_width
|
|
scn.render.resolution_y = clip.elements[0].orig_height
|
|
scn.render.fps = int(clip.elements[0].orig_fps)
|
|
|
|
scn.view_settings.view_transform = 'Standard'
|
|
|
|
def find_shot_preview(self, sequence, shot_name, task_type):
|
|
settings = get_scene_settings()
|
|
project = settings.active_project
|
|
import_shots = project.import_shots
|
|
project_templates = {t.name: t.value for t in project.templates}
|
|
|
|
for template in [import_shots.video_template] + list(import_shots.video_templates.keys()):
|
|
|
|
template = expand(template, **project_templates)
|
|
|
|
format_data = project.format_data
|
|
format_data.update(parse(project.sequence_template, sequence.name))
|
|
format_data.update(parse(project.shot_template, shot_name))
|
|
# Normalize name in snake_case for now
|
|
format_data.update(task=task_type.name.lower().replace(' ', '_'))
|
|
|
|
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()
|
|
project = settings.active_project
|
|
import_shots = project.import_shots
|
|
|
|
prefs = get_addon_prefs()
|
|
tracker = prefs.tracker
|
|
tracker.admin_connect()
|
|
|
|
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
|
|
|
|
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])
|
|
|
|
|
|
for sequence in sequences:
|
|
shots_data = tracker.get_shots(sequence=sequence.id)
|
|
sequence_start = frame_index
|
|
for shot_data in shots_data:
|
|
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.strips.new_effect(
|
|
name=shot_data['name'],
|
|
type='COLOR',
|
|
channel=get_channel_index('Shots'),
|
|
frame_start=frame_index,
|
|
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)
|
|
else:
|
|
preview = self.download_preview(task_type, shot_data)
|
|
if not preview:
|
|
print(f'No preview found for shot {shot_data["name"]}')
|
|
|
|
if not preview:
|
|
continue
|
|
|
|
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)
|
|
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'
|
|
|
|
if not conformed:
|
|
self.conform_render(video_clip)
|
|
conformed = True
|
|
|
|
scale_clip_to_fit(video_clip)
|
|
|
|
# Load Audio
|
|
channel_index = get_channel_index(f'{task_type.name} Audio')
|
|
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.strips.new_effect(
|
|
name=sequence.name,
|
|
type='COLOR',
|
|
channel=get_channel_index('Sequences'),
|
|
frame_start=sequence_start,
|
|
length=frame_index - sequence_start
|
|
)
|
|
strip.blend_alpha = 0
|
|
strip.color = (0.25, 0.25, 0.25)
|
|
|
|
scn.frame_start = 1
|
|
scn.frame_end = frame_index -1
|
|
|
|
#bpy.ops.vse_toolbox.set_stamps()
|
|
|
|
return {'FINISHED'}
|
|
|
|
def draw(self, context):
|
|
settings = get_scene_settings()
|
|
project = settings.active_project
|
|
import_shots = project.import_shots
|
|
|
|
layout = self.layout
|
|
|
|
col = layout.column(align=False)
|
|
col.use_property_split = True
|
|
col.use_property_decorate = False
|
|
|
|
row = col.row(align=True)
|
|
row.prop(import_shots, "import_source", text='Videos Source', expand=True)
|
|
|
|
if import_shots.import_source == 'DISK':
|
|
#col.prop(import_shots, "sequence_dir_template", text='Sequence Dir')
|
|
#col.prop(import_shots, "shot_folder_template", text='Shot Folder')
|
|
row = col.row(align=True)
|
|
row.prop(import_shots, "video_template", text='')
|
|
row.operator("vse_toolbox.add_import_template", text='', icon='ADD')
|
|
|
|
for i, template in enumerate(import_shots.video_templates):
|
|
row = col.row(align=True)
|
|
row.prop(template, "name", text='')
|
|
row.operator("vse_toolbox.remove_import_template", text='', icon='REMOVE').index=i
|
|
|
|
col.separator()
|
|
else:
|
|
col.prop(import_shots, "previews_folder", text='Previews Folder')
|
|
col.separator()
|
|
#if bpy.data.filepath:
|
|
#col.label(text=f' {self.get_preview_dir()}')
|
|
#else:
|
|
# col.label(icon="ERROR", text="Save your Blender to keep the previews")
|
|
|
|
row = col.row(align=True)
|
|
row.prop(import_shots, 'import_task', text='Import Tasks')
|
|
row.operator("vse_toolbox.select_task", text='', icon='ADD')
|
|
|
|
tasks = [t for t in project.task_types if t.import_enabled]
|
|
|
|
if import_shots.import_task == "FROM_LIST":
|
|
if tasks:
|
|
split = col.split(factor=0.4)
|
|
split.row()
|
|
box = split.box()
|
|
box_col = box.column(align=True)
|
|
|
|
for task_type in tasks:
|
|
row = box_col.row(align=True)
|
|
sub = row.row(align=True)
|
|
sub.enabled = False
|
|
sub.alignment = 'LEFT'
|
|
sub.scale_x = 0.15
|
|
sub.prop(task_type, 'color', text='')
|
|
|
|
op = row.operator("vse_toolbox.unselect_task", text=task_type.name)
|
|
op.task = task_type.name
|
|
|
|
row.separator(factor=0.5)
|
|
op = row.operator("vse_toolbox.unselect_task", text='', icon='REMOVE', emboss=False)
|
|
op.task = task_type.name
|
|
else:
|
|
split = col.split(factor=0.4)
|
|
split.row()
|
|
row = split.row()
|
|
row.label(icon="ERROR")
|
|
row.label(text='Add at least one task')
|
|
|
|
# Choose Sequences
|
|
row = col.row(align=True)
|
|
row.prop(import_shots, 'import_sequence', text='Import Sequences')
|
|
row.operator("vse_toolbox.select_sequence", text='', icon='ADD')
|
|
|
|
sequences = [s for s in project.sequences if s.import_enabled]
|
|
if import_shots.import_sequence == "FROM_LIST":
|
|
if sequences:
|
|
split = col.split(factor=0.4)
|
|
split.row()
|
|
box = split.box()
|
|
box_col = box.column(align=True)
|
|
|
|
for sequence in sequences:
|
|
row = box_col.row(align=True)
|
|
op = row.operator("vse_toolbox.unselect_sequence", text=sequence.name)
|
|
op.sequence = sequence.name
|
|
|
|
row.separator(factor=0.5)
|
|
op = row.operator("vse_toolbox.unselect_sequence", text='', icon='REMOVE', emboss=False)
|
|
op.sequence = sequence.name
|
|
else:
|
|
split = col.split(factor=0.4)
|
|
split.row()
|
|
row = split.row()
|
|
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()
|
|
project = settings.active_project
|
|
|
|
tmp_dir = Path(gettempdir(), 'reviews')
|
|
|
|
if bpy.data.filepath and Path(project.import_shots.previews_folder) == tmp_dir:
|
|
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)
|
|
|
|
# if not bpy.data.filepath:
|
|
# self.report({"ERROR"}, "Save your Blender file first")
|
|
# return {"CANCELLED"}
|
|
|
|
return context.window_manager.invoke_props_dialog(self, width=350)
|
|
|
|
def check(self, context):
|
|
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,
|
|
VSETB_OT_unselect_task,
|
|
VSETB_OT_select_task,
|
|
VSETB_OT_add_import_template,
|
|
VSETB_OT_remove_import_template,
|
|
VSETB_UL_import_task,
|
|
VSETB_OT_auto_select_files,
|
|
VSETB_OT_import_files,
|
|
VSETB_OT_import_stb_xml,
|
|
VSETB_OT_import_shots,
|
|
)
|
|
|
|
def register():
|
|
for cls in classes:
|
|
bpy.utils.register_class(cls)
|
|
|
|
def unregister():
|
|
for cls in reversed(classes):
|
|
bpy.utils.unregister_class(cls) |