diff --git a/__init__.py b/__init__.py index cb01c6d..5c2173b 100644 --- a/__init__.py +++ b/__init__.py @@ -5,13 +5,11 @@ bl_info = { "description": "In-House Video editing Tools", "author": "Samuel Bernou, Clement Ducarteron, Christophe Seux", "version": (0, 1, 0), - "blender": (3, 4, 1), + "blender": (4, 0, 2), "location": "Sequencer", "warning": "", "category": "Sequencer", } - # "doc_url": "https://github.com/Pullusb/REPONAME", - # "tracker_url": "https://github.com/Pullusb/REPONAME/issues", import sys from pathlib import Path @@ -21,6 +19,7 @@ from vse_toolbox import ui from vse_toolbox import operators from vse_toolbox.constants import ASSET_PREVIEWS from vse_toolbox.sequencer_utils import set_active_strip, update_text_strips +from vse_toolbox.bl_utils import get_addon_prefs modules = ( @@ -35,6 +34,7 @@ if 'bpy' in locals(): import bpy + def register(): bpy.app.handlers.frame_change_post.append(update_text_strips) bpy.app.handlers.render_pre.append(update_text_strips) @@ -44,6 +44,8 @@ def register(): bpy.app.handlers.frame_change_post.append(set_active_strip) + prefs = get_addon_prefs() + #print('\n\n-------------------', prefs.config_path) def unregister(): @@ -54,12 +56,11 @@ def unregister(): bpy.utils.previews.remove(ASSET_PREVIEWS) except Exception as e: pass - # print(f"!-- {e} --!") - # print(f"No preview collection found. {ASSET_PREVIEWS} can't be remove.") bpy.app.handlers.frame_change_post.remove(set_active_strip) for module in reversed(modules): module.unregister() + if __name__ == "__main__": register() diff --git a/constants.py b/constants.py index f2c2d7c..fb7d94e 100644 --- a/constants.py +++ b/constants.py @@ -25,6 +25,9 @@ PROJECT_ITEMS = [] EPISODE_ITEMS = [] CONFIG_DIR = Path(appdirs.user_config_dir(__package__.split('.')[0])) +APP_TEMPLATES_DIR = Path(MODULE_DIR, 'resources', 'app_templates') +REVIEW_TEMPLATE_BLEND = Path(APP_TEMPLATES_DIR, 'Review', 'startup.blend') + PREVIEWS_DIR = CONFIG_DIR / 'thumbnails' TASK_ITEMS = [] diff --git a/file_utils.py b/file_utils.py index b45e8b8..dea5fa3 100644 --- a/file_utils.py +++ b/file_utils.py @@ -5,6 +5,9 @@ import platform import sys import unicodedata from pathlib import Path +from os.path import expandvars +import json +import glob def install_module(module_name, package_name=None): @@ -109,13 +112,15 @@ def read_file(path): yaml = install_module('yaml') try: data = yaml.safe_load(txt) - except Exception: + except Exception as e: + print(e) print(f'Could not load yaml file {path}') return elif path.suffix.lower() == '.json': try: data = json.loads(txt) - except Exception: + except Exception as e: + print(e) print(f'Could not load json file {path}') return else: @@ -142,11 +147,36 @@ def open_file(filepath, env=None, select=False): subprocess.Popen(cmd, env=env) -def parse(string, template): - template = re.sub(r'{index:(.+?)}', r'(?P.+)', template) - reg = re.sub(r'{(.+?)}', r'(?P<_\1>.+)', template) +# def parse(string, template): +# template = re.sub(r'{index:(.+?)}', r'(?P.+)', 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() + +def find_last(template, **kargs): + #print(find_last, template, kargs) + pattern = Path(template).as_posix() + pattern = expandvars(pattern) + + for key, value in kargs.items(): + pattern = re.sub(r'\{%s\}'%key, value, pattern) + + pattern = re.sub(r'{\w+:(\d{2})d}', lambda x : '?' * int(x.groups()[0]), pattern) + pattern = re.sub(r'{.*?}', '*', pattern) + + print(pattern) + + filepaths = glob.glob(pattern) + if filepaths: + return Path(max(filepaths)) \ No newline at end of file diff --git a/operators/addon.py b/operators/addon.py index 1965b51..38a91e8 100644 --- a/operators/addon.py +++ b/operators/addon.py @@ -40,15 +40,26 @@ class VSETB_OT_load_settings(Operator): def execute(self, context): prefs = get_addon_prefs() settings = get_scene_settings() - project = settings.active_project if not prefs.config_path: - return {'FINISHED'} + return {'CANCELLED'} addon_config = read_file(os.path.expandvars(prefs.config_path)) + if not addon_config: + return {'CANCELLED'} + addon_config['trackers'] = addon_config.get('trackers') - trackers = addon_config.pop('trackers') + trackers_config = addon_config.pop('trackers') + for tracker_config in trackers_config: + tracker_name = norm_str(tracker_config.pop('name')) + + if not hasattr(prefs.trackers, tracker_name): + continue + + tracker = getattr(prefs.trackers, tracker_name) + for k, v in tracker_config.items(): + setattr(tracker, k, v) addon_config['spreadsheet_export'] = addon_config.get('spreadsheet_export', {}) spreadsheet_export_config = addon_config.pop('spreadsheet_export') @@ -57,10 +68,15 @@ class VSETB_OT_load_settings(Operator): spreadsheet_import_config = addon_config.pop('spreadsheet_import') project_name = addon_config.get('project_name') - if project_name: + if project_name not in settings.projects: + project = settings.projects.add() + project.name = project_name settings.project_name = project_name + #if project_name and project_name in settings.projects: + # settings.project_name = project_name # Project Properties + project = settings.active_project for k, v in addon_config.items(): try: setattr(project, k, v) @@ -132,6 +148,7 @@ class VSETB_OT_load_settings(Operator): except Exception: print(f'Could not set option {k} with value {v} to spreadsheet') + self.report({"INFO"}, 'Settings loaded with sucess') return {'FINISHED'} diff --git a/operators/exports.py b/operators/exports.py index 5cc0f4b..f308ed4 100644 --- a/operators/exports.py +++ b/operators/exports.py @@ -134,7 +134,7 @@ class VSETB_OT_render(Operator): if project.render_sequence: print('Render Sequences...') for strip in get_strips(channel='Sequences', selected_only=project.render_selected_only): - print(strip.name) + #print(strip.name) strip_settings = strip.vsetb_strip_settings strip_data = {**format_data, **strip_settings.format_data} diff --git a/operators/imports.py b/operators/imports.py index 73a4c28..8a75078 100644 --- a/operators/imports.py +++ b/operators/imports.py @@ -1,5 +1,9 @@ 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 @@ -9,11 +13,11 @@ 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) + import_sound, get_strips, get_channel_index, get_empty_channel, scale_clip_to_fit) -from vse_toolbox.bl_utils import get_scene_settings -from vse_toolbox.file_utils import install_module - +from vse_toolbox.bl_utils import (get_scene_settings, get_addon_prefs, get_scene_settings) +from vse_toolbox.file_utils import install_module, parse, find_last +#from vse_toolbox.template import Template class VSETB_OT_auto_select_files(Operator): bl_idname = "vse_toolbox.auto_select_files" @@ -194,15 +198,26 @@ 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) -class VSETB_OT_import_shots(Operator): - bl_idname = "vse_toolbox.import_shots" - bl_label = "Import Shots" - bl_description = "Import Shots for disk or tracker" + +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 @@ -212,76 +227,369 @@ class VSETB_OT_import_shots(Operator): settings = get_scene_settings() project = settings.active_project - from gadget import Project + project.task_types[self.task].import_enabled = True - p = Project.from_env() - p.shots.fetch() + 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'} + + +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' - tasks = [t for t in project.task_types if t.import_enabled] - for i, task_type in enumerate(tasks): - channel_index = 9 + i + channel_index += 3 - scn.sequence_editor.channels[channel_index].name = task_type.name + for channel in channels: + scn.sequence_editor.channels[channel_index].name = channel + + channel_index += 1 - # Create Channels: - scn.sequence_editor.channels[9] + 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 - for strip in get_strips('Shots'): - for i, task_type in enumerate(tasks): - shot = p.shots[strip.name] - versions = shot.tasks[task_type.name].outputs[0].versions.fetch() + def download_preview(self, task_type, shot): + prefs = get_addon_prefs() + tracker = prefs.tracker - if versions: - print(versions[-1]) - - imported_strip = import_movie(versions[-1].path) - imported_strip.frame_start = strip.frame_start + task = tracker.get_task(task_type.id or task_type.name, entity=shot) + last_comment = tracker.get_last_comment_with_preview(task) + if not last_comment: + return - if imported_strip.frame_final_duration != strip.frame_final_duration: - print(f'strip {strip.name} has different duration') - imported_strip.color_tag = 'COLOR_03' + 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 = self.get_preview_dir() + + filepath = Path(preview_dir, f'{sequence_name}{shot_name}_{task_type.name}') + filepath.parent.mkdir(parents=True, exist_ok=True) + + last_preview = last_comment['previews'][0] + 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 + + pattern = Path( + import_shots.shot_folder_template, + import_shots.import_video_template) + + # Normalize name in snake_case for now + sequence = sequence.name.lower().replace(' ', '_') + shot = parse(project.shot_template, shot_name)['shot'] + task = task_type.name.lower().replace(' ', '_') + + return find_last(pattern, sequence=sequence, shot=shot, task=task) + + 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] + + conformed = False + + 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']) + + strip = scn.sequence_editor.sequences.new_effect( + name=shot_data['name'], + type='COLOR', + channel=get_channel_index('Shots'), + frame_start=frame_index, + frame_end=frame_index + frames + ) + strip.blend_alpha = 0 + strip.color = (0.5, 0.5, 0.5) + + for task_type in task_types: + if import_shots.import_source == 'DISK': + preview = self.find_shot_preview(sequence, shot_data['name'], task_type) + #print(videos) + #return {"FINISHED"} - imported_strip.frame_final_end = strip.frame_final_end + else: + preview = self.download_preview(task_type, shot_data) - imported_strip.channel = get_channel_index(task_type.name) - print(get_channel_index(task_type.name), imported_strip.channel) - else: - print(f'No movie for {strip.name} of task {task_type.name}') + if not preview: + print(f'No preview found for shot {shot_data["name"]}') + continue + + print(f'Loading Preview from {preview}') + + # Load Video + channel_index = get_channel_index(f'{task_type.name} Video') + video_clip = import_movie(preview) + video_clip.frame_start = frame_index + video_clip.channel = channel_index - # for task_type in tasks: - # for strip in get_strips(task_type.name): - # strip.channel = get_channel_index(task_type.name) + 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, + frame_end=video_clip.frame_final_end) + audio_clip.channel = channel_index + + frame_index += frames + + strip = scn.sequence_editor.sequences.new_effect( + name=sequence.name, + type='COLOR', + channel=get_channel_index('Sequences'), + frame_start=sequence_start, + frame_end=frame_index + ) + strip.blend_alpha = 0 + strip.color = (0.25, 0.25, 0.25) + + scn.frame_start = 1 + scn.frame_end = frame_index -1 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(project, "import_source", text='Source', expand=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') + col.prop(import_shots, "import_video_template", text='Video') + 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(project, 'import_task', text='Import Tasks') + row.prop(import_shots, 'import_task', text='Import Tasks') + row.operator("vse_toolbox.select_task", text='', icon='ADD') - if project.import_task == 'SELECTED': - col.use_property_split = False - col.template_list("VSETB_UL_import_task", "import_task", project, "task_types", project, "task_type_index", rows=8) + 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') 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: + tmp_dir = '//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) + return context.window_manager.invoke_props_dialog(self, width=400) + + def check(self, context): + return True + classes = ( + VSETB_OT_select_sequence, + VSETB_OT_unselect_sequence, + VSETB_OT_unselect_task, + VSETB_OT_select_task, VSETB_UL_import_task, VSETB_OT_auto_select_files, VSETB_OT_import_files, diff --git a/operators/sequencer.py b/operators/sequencer.py index 6af9e97..1e04b5c 100644 --- a/operators/sequencer.py +++ b/operators/sequencer.py @@ -1,4 +1,5 @@ -from os.path import expandvars +from os.path import expandvars, abspath +from pathlib import Path import bpy from bpy.types import Operator from bpy.props import (BoolProperty, StringProperty) @@ -7,6 +8,8 @@ from vse_toolbox.sequencer_utils import (get_strips, rename_strips, set_channels get_channel_index, new_text_strip, get_strip_at, get_channel_name) from vse_toolbox.bl_utils import get_scene_settings, get_strip_settings +from vse_toolbox.constants import REVIEW_TEMPLATE_BLEND +from shutil import copy2 class VSETB_OT_rename(Operator): @@ -17,8 +20,7 @@ class VSETB_OT_rename(Operator): #template : StringProperty(name="Strip Name", default="") #increment : IntProperty(name="Increment", default=0) - channel_name : StringProperty(name="Channel Name", default="") - selected_only : BoolProperty(name="Selected Only", default=False) + selected_only : BoolProperty(name="Selected Only", default=True) #start_number : IntProperty(name="Start Number", default=0, min=0) #by_sequence : BoolProperty( # name="Reset By Sequence", @@ -29,7 +31,8 @@ class VSETB_OT_rename(Operator): @classmethod def poll(cls, context): settings = get_scene_settings() - return settings.active_project + strip = context.active_sequence_strip + return settings.active_project and get_channel_name(strip) in ('Shots', 'Sequences') def invoke(self, context, event): scn = context.scene @@ -43,39 +46,62 @@ class VSETB_OT_rename(Operator): settings = get_scene_settings() project = settings.active_project + episode = project.episode_name + sequence = str(project.sequence_start_number).zfill(project.sequence_padding) + shot = str(project.shot_start_number).zfill(project.shot_padding) + + strip = context.active_sequence_strip + channel_name = get_channel_name(strip) + col = layout.column() col.use_property_split = True col.use_property_decorate = False - if self.channel_name == 'Shots': + if channel_name == 'Shots': col.prop(project, 'shot_template', text='Shot Name') col.prop(project, 'shot_start_number', text='Start Number') col.prop(project, 'shot_increment', text='Increment') + col.prop(project, 'shot_padding', text='Padding') col.prop(project, 'reset_by_sequence') - elif self.channel_name == 'Sequences': + + + elif channel_name == 'Sequences': col.prop(project, 'sequence_template' ,text='Sequence Name') col.prop(project, 'sequence_start_number', text='Start Number') col.prop(project, 'sequence_increment', text='Increment') + col.prop(project, 'sequence_padding', text='Padding') col.prop(self, 'selected_only') + + if channel_name == 'Shots': + label = project.shot_template.format(episode=episode, sequence=sequence, shot=shot) + elif channel_name == 'Sequences': + label = project.sequence_template.format(episode=episode, sequence=sequence) + + col.label(text=f'Renaming {label}') def execute(self, context): scn = context.scene settings = get_scene_settings() project = settings.active_project - strips = get_strips(channel=self.channel_name, selected_only=self.selected_only) - if self.channel_name == 'Shots': + strip = context.active_sequence_strip + channel_name = get_channel_name(strip) + + strips = get_strips(channel=channel_name, selected_only=self.selected_only) + if channel_name == 'Shots': rename_strips(strips, template=project.shot_template, increment=project.shot_increment, start_number=project.shot_start_number, - by_sequence=project.reset_by_sequence + by_sequence=project.reset_by_sequence, + padding=project.shot_padding ) - if self.channel_name == 'Sequences': + if channel_name == 'Sequences': rename_strips(strips, template=project.sequence_template, - increment=project.sequence_increment, start_number=project.sequence_start_number + increment=project.sequence_increment, start_number=project.sequence_start_number, + padding=project.sequence_padding ) return {"FINISHED"} @@ -180,10 +206,10 @@ class VSETB_OT_set_stamps(Operator): font_size=font_size, y=margin, box_margin=box_margin, select=True, box_color=(0, 0, 0, 0.5)) # Project Name - project_text = '{project_name}' + project_text = '{project}' if project.type == 'TVSHOW': - project_text = '{project_name} / {episode_name}' - project_strip_stamp = new_text_strip('project_name_stamp', channel=1, **stamp_params, + 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_strip_stamp.crop.max_x = crop_x * 2 @@ -191,16 +217,16 @@ class VSETB_OT_set_stamps(Operator): # Shot Name - shot_strip_stamp = new_text_strip('shot_name_stamp', channel=2, **stamp_params, - text='{sequence_name} / {shot_name}', align_y='BOTTOM') + shot_strip_stamp = new_text_strip('shot_stamp', channel=2, **stamp_params, + text='sq{sequence} / sh{shot}', align_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 Range - frame_strip_stamp = new_text_strip('frame_range_stamp', channel=3, **stamp_params, - text='{shot_frame} / {shot_duration}', x=0.99, align_x='RIGHT', align_y='BOTTOM') + # 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.crop.min_x = crop_x *2 frame_strip_stamp.crop.max_y = crop_max_y @@ -269,10 +295,11 @@ class VSETB_OT_next_shot(Operator): return {"FINISHED"} -class VSETB_OT_open_shot_dir(Operator): - bl_idname = "vse_toolbox.open_shot_dir" - bl_label = "Open Shot Dir" - bl_description = "Open Shot Dir" + +class VSETB_OT_open_shot_folder(Operator): + bl_idname = "vse_toolbox.open_shot_folder" + bl_label = "Open Shot Folder" + bl_description = "Open Shot Folder" bl_options = {"REGISTER", "UNDO"} @classmethod @@ -289,13 +316,154 @@ class VSETB_OT_open_shot_dir(Operator): format_data = {**settings.format_data, **project.format_data, **strip_settings.format_data} - shot_dir_template = expandvars(project.shot_dir_template) - shot_dir_path = shot_dir_template.format(**format_data) - bpy.ops.wm.path_open(filepath=shot_dir_path) + shot_folder_template = expandvars(project.import_shots.shot_folder_template) + shot_folder_path = shot_folder_template.format(**format_data) + bpy.ops.wm.path_open(filepath=shot_folder_path) return {"FINISHED"} +class VSETB_OT_collect_files(Operator): + bl_idname = "vse_toolbox.collect_files" + bl_label = "Collect Files" + bl_description = "Collect Files" + bl_options = {"REGISTER", "UNDO"} + + collect_folder: StringProperty( + name="Collect Folder", default="//sources", subtype='DIR_PATH') + + @classmethod + def poll(cls, context): + if bpy.data.is_saved: + return True + + cls.poll_message_set('Save the blend to collect files') + + def execute(self, context): + strip = context.active_sequence_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')] + context.window_manager.progress_begin(0, len(strips)) + + for i, strip in enumerate(strips): + context.window_manager.progress_update(i) + + if strip.type == 'MOVIE': + src_path = strip.filepath + elif strip.type == 'SOUND': + src_path = strip.sound.filepath + + src_path = Path(abspath(bpy.path.abspath(src_path))) + dst_path = Path(bpy.path.abspath(self.collect_folder), src_path.name) + + if src_path == dst_path: + continue + + dst_path.parent.mkdir(exist_ok=True, parents=True) + + print(f'Copy file from {src_path} to {dst_path}') + copy2(str(src_path), str(dst_path)) + + rel_path = bpy.path.relpath(str(dst_path)) + if len(Path(rel_path).as_posix()) < len(dst_path.as_posix()): + dst_path = rel_path + + if strip.type == 'MOVIE': + strip.filepath = str(dst_path) + elif strip.type == 'SOUND': + strip.sound.filepath = str(dst_path) + + context.window_manager.progress_end() + + return {"FINISHED"} + + def invoke(self, context, event): + scn = context.scene + settings = get_scene_settings() + + return context.window_manager.invoke_props_dialog(self) + + +class WM_OT_split_view(Operator): + """Toggle Split sequencer view""" + + bl_idname = "wm.split_view" + bl_label = "Split View" + + def get_preview_areas(self, context): + return [ + x for x in context.screen.areas + if x.type == "SEQUENCE_EDITOR" and x.spaces[0].view_type == "PREVIEW" + ] + + def invoke(self, context, event): + preview_areas = self.get_preview_areas(context) + + if not preview_areas: + return {"CANCELLED"} + + scn = context.scene + video_channels = [i for i, c in enumerate(scn.sequence_editor.channels) if 'Video' in c.name] + + if len(video_channels) == 2 and len(preview_areas) == 1: + # Split area + with bpy.context.temp_override(area=preview_areas[0]): + bpy.ops.screen.area_split(direction="VERTICAL") + + # Disable toolbar on right panel + # Update areas + preview_areas = self.get_preview_areas(context) + + preview_areas[-1].spaces[0].display_channel = video_channels[-1] + preview_areas[0].spaces[0].display_channel = video_channels[-2] + + preview_areas[0].spaces[0].show_gizmo_navigate = False + preview_areas[-1].spaces[0].show_gizmo_navigate = False + # Hide toolbar + #preview_areas[0].spaces[0].show_region_toolbar = False + + else: + # Give the remaining area to have the left area's channel displayed + + # Show toolbar of remaning area + #preview_areas[0].spaces[0].show_region_toolbar = True + + # Join areas + # Simulates the mouse position as being between the two areas horizontally + # and a bit above the bottom corner vertically + cursor_x = int(preview_areas[0].x - (preview_areas[0].x - preview_areas[1].width) / 2) + cursor_y = preview_areas[0].y + 10 + + bpy.ops.screen.area_join(cursor=(cursor_x, cursor_y)) + + preview_areas = self.get_preview_areas(context) + + preview_areas[0].spaces[0].display_channel = 0 + + # Force UI update, due to Blender bug, delete when fixed -> https://developer.blender.org/T65529 + bpy.ops.screen.area_swap(cursor=(preview_areas[0].x, preview_areas[0].y)) + bpy.ops.screen.area_swap(cursor=(preview_areas[0].x, preview_areas[0].y)) + + return {"FINISHED"} + +''' +class VSETB_OT_set_review_workspace(Operator): + """Set Review Workspace""" + + bl_idname = "vse_toolbox.set_workspace" + bl_label = "Set Review Workspace" + + + def execute(self, context): + bpy.ops.workspace.append_activate(idname='Review', filepath=str(REVIEW_TEMPLATE_BLEND)) + + return {"FINISHED"} + ''' + + addon_keymaps = [] def register_keymaps(): addon = bpy.context.window_manager.keyconfigs.addon @@ -331,7 +499,9 @@ classes = ( VSETB_OT_show_waveform, VSETB_OT_previous_shot, VSETB_OT_next_shot, - VSETB_OT_open_shot_dir + VSETB_OT_open_shot_folder, + VSETB_OT_collect_files, + WM_OT_split_view ) def register(): diff --git a/operators/tracker.py b/operators/tracker.py index 9c38818..4b75465 100644 --- a/operators/tracker.py +++ b/operators/tracker.py @@ -3,6 +3,8 @@ import os from os.path import expandvars from pathlib import Path from pprint import pprint +import webbrowser +from functools import partial import bpy from bpy.types import (Operator, ) @@ -59,7 +61,7 @@ class VSETB_OT_load_assets(Operator): prefs = get_addon_prefs() tracker = prefs.tracker - tracker.connect() + tracker.admin_connect() project = settings.active_project project.assets.clear() @@ -125,7 +127,7 @@ class VSETB_OT_load_projects(Operator): tracker = prefs.tracker prev_project_name = settings.project_name - tracker.connect() + tracker.admin_connect() project_datas = tracker.get_projects() for project_data in sorted(project_datas, key=lambda x: x['name']): @@ -137,7 +139,6 @@ class VSETB_OT_load_projects(Operator): project.name = project_data['name'] project.id = project_data['id'] - if project.type == 'TVSHOW': episode_datas = tracker.get_episodes(project_data) for episode_data in episode_datas: @@ -154,6 +155,17 @@ 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)) + else: + # Add sequences + sequences_data = tracker.get_sequences(project_data) + for sequence_data in sequences_data: + sequence = project.sequences.get(sequence_data['name']) + + if not sequence: + sequence = project.sequences.add() + + sequence.name = sequence_data['name'] + sequence.id = sequence_data['id'] project.metadata_types.clear() for metadata_data in tracker.get_metadata_types(project_data): @@ -183,6 +195,7 @@ class VSETB_OT_load_projects(Operator): for task_type_data in tracker.get_shot_task_types(project_data): task_type = project.task_types.add() task_type.name = task_type_data['name'] + task_type.id = task_type_data['id'] task_type.color = self.hex_to_rgb(task_type_data['color'])[:3] project.set_shot_tasks() @@ -200,11 +213,11 @@ class VSETB_OT_load_projects(Operator): if project.name not in project_names: settings.projects.remove(list(settings.projects).index(project)) - # Restore previous project - settings.project_name = prev_project_name - bpy.ops.vse_toolbox.load_settings() + if prev_project_name != '/' and prev_project_name in settings.projects: + settings.project_name = prev_project_name + #if settings.active_project: # settings.active_project.set_strip_metadata() @@ -216,7 +229,7 @@ class VSETB_OT_load_projects(Operator): class VSETB_OT_new_episode(Operator): bl_idname = "vse_toolbox.new_episode" - bl_label = "New Epispde" + bl_label = "New Episode" bl_description = "Add new Episode to Project" bl_options = {"REGISTER", "UNDO"} @@ -254,42 +267,12 @@ class VSETB_OT_new_episode(Operator): return {'FINISHED'} -def get_task_status_items(self, context): - settings = get_scene_settings() - project = settings.active_project - - status_items = [('CURRENT', 'Current', '')] - - if project: - status_items += [(t.name, t.name, '') for t in project.task_statuses] - return status_items - -def get_task_type_items(self, context): - settings = get_scene_settings() - project = settings.active_project - - if not project: - return [('NONE', 'None', '')] - - return [(t.name, t.name, '') for t in project.task_types] - 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_options = {"REGISTER", "UNDO"} - task : EnumProperty(items=get_task_type_items) - status : EnumProperty(items=get_task_status_items) - comment : StringProperty() - add_preview : BoolProperty(default=True) - preview_mode : EnumProperty(items=[(m, m.title().replace('_', ' '), '') - for m in ('ONLY_NEW', 'REPLACE', 'ADD')]) - set_main_preview : BoolProperty(default=True) - casting : BoolProperty(default=True) - custom_data : BoolProperty(default=True) - tasks_comment: BoolProperty(default=True) - @classmethod def poll(cls, context): return True @@ -297,49 +280,66 @@ class VSETB_OT_upload_to_tracker(Operator): def invoke(self, context, event): prefs = get_addon_prefs() settings = get_scene_settings() - project = settings.active_project + self.project = settings.active_project tracker = prefs.tracker tracker.connect() #self.bl_label = f"Upload to {settings.tracker_name.title()}" - return context.window_manager.invoke_props_dialog(self) + return context.window_manager.invoke_props_dialog(self, width=350) def draw(self, context): scn = context.scene - settings = get_scene_settings() + upload_to_tracker = self.project.upload_to_tracker layout = self.layout col = layout.column() col.use_property_split = True - col.prop(self, 'task', text='Task') - col.prop(self, 'status', text='Status') - col.prop(self, 'comment', text='Comment') - row = col.row(heading='Add Preview') - row.prop(self, 'add_preview', text='') - row.prop(self, 'preview_mode', text='') + col.use_property_decorate = False + col.prop(upload_to_tracker, 'render_strips', text='Render Strips') + if upload_to_tracker.render_strips: + col.use_property_split = False + col.prop(upload_to_tracker, 'render_strip_template', text='') + col.use_property_split = True + #else: + # col.label(text=f'Source: {self.project.render_video_strip_template}') + col.separator() - col.prop(self, 'casting', text='Casting') - col.prop(self, 'custom_data', text='Custom Data') - col.prop(self, 'tasks_comment', text='Tasks Comment') - col.prop(self, 'set_main_preview', text='Set Main Preview') + + col.prop(upload_to_tracker, 'task', text='Task') + col.prop(upload_to_tracker, 'status', text='Status') + col.prop(upload_to_tracker, 'comment', text='Comment') + row = col.row(heading='Add Preview') + row.prop(upload_to_tracker, 'add_preview', text='') + row.prop(upload_to_tracker, 'preview_mode', text='') + if upload_to_tracker.add_preview and not upload_to_tracker.render_strips: + col.use_property_split = False + col.prop(self.project, 'render_video_strip_template', text='') + col.use_property_split = True + + col.separator() + col.prop(upload_to_tracker, 'custom_data', text='Custom Data') + col.prop(upload_to_tracker, 'update_frames', text='Update frames') + col.prop(upload_to_tracker, 'casting', text='Casting') + col.prop(upload_to_tracker, 'tasks_comment', text='Tasks Comment') + col.prop(upload_to_tracker, 'set_main_preview', text='Set Main Preview') def execute(self, context): #self.report({'ERROR'}, f'Export not implemented yet.') prefs = get_addon_prefs() settings = get_scene_settings() - project = settings.active_project + upload_to_tracker = self.project.upload_to_tracker + episode = None if settings.active_episode: episode = settings.active_episode.id - format_data = {**settings.format_data, **project.format_data} - + format_data = {**settings.format_data, **self.project.format_data} tracker = prefs.tracker - status = self.status + status = upload_to_tracker.status if status == 'CURRENT': status = None @@ -360,15 +360,19 @@ class VSETB_OT_upload_to_tracker(Operator): self.report({"INFO"}, f'Create shot {shot_name} in Kitsu') shot = tracker.new_shot(shot_name, sequence=sequence) - task = tracker.get_task(self.task, entity=shot) + task = tracker.get_task(upload_to_tracker.task, entity=shot) if not task: - task = tracker.new_task(shot, task_type=self.task) + task = tracker.new_task(shot, task_type=upload_to_tracker.task) preview = None - if self.add_preview: + if upload_to_tracker.add_preview: strip_data = {**format_data, **strip_settings.format_data} - preview_template = expandvars(project.render_video_strip_template) + if upload_to_tracker.render_strips: + preview_template = expandvars(upload_to_tracker.render_strip_template) + else: + preview_template = expandvars(self.project.render_video_strip_template) + preview = preview_template.format(**strip_data) preview = Path(os.path.abspath(bpy.path.abspath(preview))) #print(preview) @@ -377,24 +381,41 @@ class VSETB_OT_upload_to_tracker(Operator): preview = None elif task.get('last_comment') and task['last_comment']['previews']: - if self.preview_mode == 'REPLACE': + if upload_to_tracker.preview_mode == 'REPLACE': tracker.remove_comment(task['last_comment']) - elif self.preview_mode == 'ONLY_NEW': + elif upload_to_tracker.preview_mode == 'ONLY_NEW': preview = None - if status or preview: - tracker.new_comment(task, comment=self.comment, status=status, preview=preview, set_main_preview=self.set_main_preview) + comment_data = None + if status or comment or preview: + comment_data = tracker.new_comment(task, comment=upload_to_tracker.comment, status=status) + if preview: + print('Upload preview from', preview) + preview_data = tracker.new_preview( + task=task, + comment=comment_data, + preview=preview) - if self.custom_data: - description = strip_settings.description - tracker.update_data(shot, metadata, frames=strip.frame_final_duration, description=description) + if upload_to_tracker.set_main_preview: + bpy.app.timers.register(partial(tracker.set_main_preview, preview_data), first_interval=10) + + params = {} + if upload_to_tracker.custom_data: + params['custom_data'] = metadata + params['description'] = strip_settings.description - if self.casting: + if upload_to_tracker.update_frames: + params['frames'] = strip.frame_final_duration + + if params: + tracker.update_data(shot, **params) + + if upload_to_tracker.casting: casting = [{'asset_id': a.id, 'nb_occurences': a.instance} for a in strip_settings.casting] tracker.update_casting(shot, casting) - if self.tasks_comment: - for task_type in project.task_types: + 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) @@ -405,6 +426,28 @@ class VSETB_OT_upload_to_tracker(Operator): return {"FINISHED"} +class VSETB_OT_open_shot_on_tracker(Operator): + bl_idname = "vse_toolbox.open_shot_on_tracker" + bl_label = "Open Shot on Tracker" + bl_description = "Open Shot on Tracker" + + @classmethod + def poll(cls, context): + prefs = get_addon_prefs() + if prefs.tracker: + return True + + def execute(self, context): + prefs = get_addon_prefs() + tracker = prefs.tracker + + strips = get_strips(channel='Shots', selected_only=True) + url = tracker.get_shots_search_url([s.name for s in strips]) + + webbrowser.open_new_tab(url) + + return {"FINISHED"} + @persistent def set_asset_items(scene=None): ASSET_ITEMS.clear() @@ -420,6 +463,7 @@ classes = ( VSETB_OT_new_episode, VSETB_OT_tracker_connect, VSETB_OT_upload_to_tracker, + VSETB_OT_open_shot_on_tracker ) def register(): diff --git a/resources/app_templates/Review/__init__.py b/resources/app_templates/Review/__init__.py new file mode 100644 index 0000000..22015a2 --- /dev/null +++ b/resources/app_templates/Review/__init__.py @@ -0,0 +1,45 @@ +# SPDX-License-Identifier: GPL-2.0-or-later + +import bpy +from bpy.app.handlers import persistent + + +def update_factory_startup_screens(): + screen = bpy.data.screens["Review"] + + +def update_factory_startup_ffmpeg_preset(): + from bpy import context + + preset = "H264_in_MP4" + preset_filepath = bpy.utils.preset_find(preset, preset_path="ffmpeg") + if not preset_filepath: + print("Preset %r not found" % preset) + + for scene in bpy.data.scenes: + render = scene.render + render.image_settings.file_format = 'FFMPEG' + + if preset_filepath: + with context.temp_override(scene=scene): + bpy.ops.script.python_file_run(filepath=preset_filepath) + + render.ffmpeg.audio_codec = 'AAC' + render.ffmpeg.audio_bitrate = 128 + render.ffmpeg.audio_mixrate = 44100 + + + +@persistent +def load_handler(_): + update_factory_startup_screens() + if bpy.app.build_options.codec_ffmpeg: + update_factory_startup_ffmpeg_preset() + + +def register(): + bpy.app.handlers.load_factory_startup_post.append(load_handler) + + +def unregister(): + bpy.app.handlers.load_factory_startup_post.remove(load_handler) diff --git a/resources/app_templates/Review/startup.blend b/resources/app_templates/Review/startup.blend new file mode 100644 index 0000000..79e8e58 Binary files /dev/null and b/resources/app_templates/Review/startup.blend differ diff --git a/resources/trackers/kitsu.py b/resources/trackers/kitsu.py index 9311aee..7cb1d24 100644 --- a/resources/trackers/kitsu.py +++ b/resources/trackers/kitsu.py @@ -1,6 +1,7 @@ import bpy import os +from os.path import expandvars import re import urllib3 import traceback @@ -26,20 +27,36 @@ class Kitsu(Tracker): url: StringProperty() login: StringProperty() password: StringProperty(subtype='PASSWORD') + + admin_login : StringProperty() + admin_password : StringProperty(subtype='PASSWORD') - def connect(self, url=None, login=None, password=None): + def admin_connect(self): + url = self.url + if not url.endswith('/api'): + url += '/api' + + login = expandvars(self.admin_login or self.login) + password = expandvars(self.admin_password or self.password) + + try: + res = gazu.log_in(login, password) + LOGIN = login + return res['user'] + + except Exception as e: + print(e) + + def connect(self): '''Connect to kitsu api using provided url, login and password''' global LOGIN urllib3.disable_warnings() - if url is None: - url = self.url - if login is None: - login = self.login - if password is None: - password = self.password + url = expandvars(self.url) + login = expandvars(self.login) + password = expandvars(self.password) if not url: print(f'Kitsu Url: {self.url} is empty') @@ -168,6 +185,16 @@ class Kitsu(Tracker): return gazu.shot.get_sequence_by_name(**params) + def get_sequences(self, project=None, episode=None): + #print(f'get_sequence({sequence=}, {project=})') + project = self.get_project(project) + + if episode: + episode = self.get_episode(episode) + return gazu.shot.all_sequences_for_episode(episode) + else: + return gazu.shot.all_sequences_for_project(project) + def get_shot(self, shot, sequence, project=None): #print(f'get_shot({shot=}, {sequence=}, {project=})') project = self.get_project(project) @@ -182,6 +209,12 @@ class Kitsu(Tracker): return gazu.shot.get_shot_by_name(sequence, shot) + def get_shots(self, sequence): + #print(f'get_sequence({sequence=}, {project=})') + #project = self.get_project(project) + + return gazu.shot.all_shots_for_sequence(sequence) + def get_asset(self, asset, asset_type=None, project=None): #print('get_asset', "name", name, 'asset_type', asset_type) @@ -211,6 +244,29 @@ class Kitsu(Tracker): task = self.get_id(task) return gazu.task.get_last_comment_for_task(task) + def get_last_comment_with_preview(self, task): + task = self.get_id(task) + comments = gazu.task.all_comments_for_task(task) + for comment in comments: + if comment['previews']: + return comment + + def download_preview_file(self, preview, filepath): + preview_id = self.get_id(preview) + return gazu.files.download_preview_file(preview_id, str(filepath)) + + def get_shots_search_url(self, shot_names, project=None, episode=None): + project = self.get_project(project) + url = gazu.client.get_host().replace('/api', f'/productions/{project}') + + if episode: + episode = self.get_episode(episode) + url += f'/episodes/{episode["id"]}' + + url += f'/shots' + + return f'{url}?search={" ".join(shot_names)}' + def get_task(self, task=None, entity=None): entity = self.get_id(entity) @@ -225,6 +281,9 @@ class Kitsu(Tracker): return task + def set_main_preview(self, preview_data): + gazu.task.set_main_preview(preview_data) + def new_preview(self, task, comment, preview, set_main_preview=False): #print('new_preview', task, comment, preview, set_main_preview) @@ -238,7 +297,7 @@ class Kitsu(Tracker): if set_main_preview: #print('----', 'Settings') - gazu.task.set_main_preview(preview_data) + self.set_main_preview(preview_data) return preview_data @@ -327,16 +386,13 @@ class Kitsu(Tracker): return shot - def update_data(self, entity, data, name=None, description=None, frames=None, clear=False): + def update_data(self, entity, custom_data={}, name=None, description=None, frames=None, clear=False): if isinstance(entity, dict): entity_id = entity['id'] else: entity_id = self.get_id(entity) entity = gazu.client.fetch_one('entities', entity_id) - if data.get('custom_data'): - data['data'] = data.pop('custom_data') - if name: entity['name'] = name if description: @@ -345,9 +401,9 @@ class Kitsu(Tracker): entity['nb_frames'] = frames if clear or not entity['data']: - entity['data'] = data + entity['data'] = custom_data else: - entity['data'].update(data) + entity['data'].update(custom_data) #print('######UPDATE DATA') #pprint(entity) @@ -387,6 +443,11 @@ class Kitsu(Tracker): return gazu.casting.update_shot_casting(project, shot_id, norm_casting) def draw_prefs(self, layout): - layout.prop(self, 'url', text='Url') - layout.prop(self, 'login', text='Login') - layout.prop(self, 'password', text='Password') \ No newline at end of file + col = layout.column(align=False) + col.prop(self, 'url', text='Url') + col.prop(self, 'login', text='Login') + col.prop(self, 'password', text='Password') + + col.separator() + col.prop(self, 'admin_login', text='Admin Login') + col.prop(self, 'admin_password', text='Admin Password') \ No newline at end of file diff --git a/sequencer_utils.py b/sequencer_utils.py index 438b483..e53954a 100644 --- a/sequencer_utils.py +++ b/sequencer_utils.py @@ -13,6 +13,7 @@ from vse_toolbox.constants import SOUND_SUFFIXES #import multiprocessing #from multiprocessing.pool import ThreadPool import subprocess +from collections import defaultdict def frame_to_timecode(frame, fps): @@ -95,8 +96,7 @@ def get_strip_sequence_name(strip): else: return 'NoSequence' -def rename_strips( - strips, template, increment=10, start_number=0, by_sequence=False): +def rename_strips(strips, template, increment=10, start_number=0, padding=3, by_sequence=False): scn = bpy.context.scene settings = get_scene_settings() @@ -110,16 +110,20 @@ def rename_strips( for strip in strips: sequence_name = get_strip_sequence_name(strip) + sequence_data = parse(project.sequence_template, sequence_name) if (by_sequence and prev_sequence_name and sequence_name and sequence_name != prev_sequence_name): strip_number = 0 - name = template.format( - sequence=sequence_name, + format_data = dict( + sequence_strip=sequence_name, episode=episode_name, - index=strip_number*increment+start_number - ) + shot=str(strip_number*increment + start_number).zfill(padding)) + + format_data.update(sequence_data) + + name = template.format(**format_data) existing_strip = scn.sequence_editor.sequences_all.get(name) if existing_strip: @@ -399,8 +403,13 @@ def import_movie(filepath): res_x = scn.render.resolution_x res_y = scn.render.resolution_y + if bpy.data.is_saved: + relpath = Path(bpy.path.relpath(str(filepath))) + if len(relpath.as_posix()) < len(filepath.as_posix()): + filepath = relpath + strip = scn.sequence_editor.sequences.new_movie( - name=filepath.stem, + name=Path(filepath).stem, filepath=str(filepath), channel=get_channel_index('Movie'), frame_start=scn.frame_start @@ -414,28 +423,48 @@ def import_movie(filepath): if src_height != res_y: strip.transform.scale_y = (res_y / src_height) - if bpy.data.is_saved: - strip.filepath = bpy.path.relpath(str(filepath)) + return strip -def import_sound(filepath): +def scale_clip_to_fit(strip): + scn = bpy.context.scene + res = scn.render.resolution_x, scn.render.resolution_y + strip_res = strip.elements[0].orig_width, strip.elements[0].orig_height + + #print(strip.name, strip_res, width, height) + + strip.transform.scale_x = res[0] / strip_res[0] + strip.transform.scale_y = strip.transform.scale_x + +def import_sound(filepath, frame_start=None, frame_end=None): scn = bpy.context.scene - strip = scn.sequence_editor.sequences.new_sound( - name=filepath.stem, - filepath=str(filepath), - channel=get_channel_index('Audio'), - frame_start=scn.frame_start - ) + if frame_start is None: + frame_start = scn.frame_start if bpy.data.is_saved: - strip.sound.filepath = bpy.path.relpath(str(filepath)) + relpath = Path(bpy.path.relpath(str(filepath))) + if len(relpath.as_posix()) < len(filepath.as_posix()): + filepath = relpath - strip.show_waveform = True if strip.frame_final_duration < 10000 else False + strip = scn.sequence_editor.sequences.new_sound( + name=f'{filepath.stem} Audio', + filepath=str(filepath), + channel=get_channel_index('Audio'), + frame_start=frame_start + ) + + if frame_end is not None: + strip.frame_final_end = frame_end + + #strip.show_waveform = True if strip.frame_final_duration < 10000 else False return strip +def get_empty_channel(): + return max(s.channel for s in bpy.context.scene.sequence_editor.sequences) + 1 + def clean_sequencer(edit=False, movie=False, sound=False): scn = bpy.context.scene sequences = [] @@ -470,44 +499,51 @@ def set_active_strip(scene): @persistent def update_text_strips(scene): + if not scene.sequence_editor or not scene.sequence_editor.sequences_all: + return #print("update_text_strips") settings = get_scene_settings() project = settings.active_project episode = settings.active_episode + + class MissingKey(dict): + def __missing__(self, key): + return '{' + key + '}' + + scn = bpy.context.scene if not scn.sequence_editor: return format_data = { 'scene': scene, - 'project_name': 'None', - 'episode_name': 'None', - 'sequence_name': 'None', - 'strip_name': 'None', - 'shot_name': 'None', + 'project': '...', + 'episode': '...', + 'sequence': '...', + 'sequence_strip': '...', + 'shot': '...', + 'shot_strip': '...', 'shot_frame': 0, 'shot_duration': 0, 'shot_start': 0, 'shot_end': 0, - 'time_code': '' + 'timecode': '' } shot_strip = get_strip_at('Shots', frame=scene.frame_current) if shot_strip: format_data.update({ - 'project_name': project.name, - 'episode_name': episode.name if episode else '', - 'sequence_name': get_strip_sequence_name(shot_strip), - 'strip_name': shot_strip.name, - 'shot_name': 'sh{index:04d}'.format(**shot_strip.vsetb_strip_settings.format_data), + 'project': project.name, + 'episode': episode.name if episode else '', 'shot_duration': shot_strip.frame_final_duration, 'shot_frame': scene.frame_current - shot_strip.frame_final_start + 1, 'shot_start': shot_strip.frame_final_start, 'shot_end': shot_strip.frame_final_end, 'timecode' : frame_to_timecode(scene.frame_current, scene.render.fps) }) + format_data.update(shot_strip.vsetb_strip_settings.format_data) for strip in scene.sequence_editor.sequences_all: if not strip.type == 'TEXT': @@ -520,4 +556,4 @@ def update_text_strips(scene): strip['text_pattern'] = strip.text if 'text_pattern' in strip.keys(): - strip.text = strip['text_pattern'].format(**format_data) \ No newline at end of file + strip.text = strip['text_pattern'].format_map(MissingKey(**format_data)) \ No newline at end of file diff --git a/ui/panels.py b/ui/panels.py index c6ed0dd..002a98e 100644 --- a/ui/panels.py +++ b/ui/panels.py @@ -7,7 +7,7 @@ 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.constants import ASSET_PREVIEWS +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.file_utils import norm_str @@ -20,27 +20,53 @@ class VSETB_main: class VSETB_PT_main(VSETB_main, Panel): + bl_options = {"HIDE_HEADER"} - def draw_header_preset(self, context): - self.layout.operator('vse_toolbox.load_projects', icon='FILE_REFRESH', text='', emboss=False) + # def draw_header(self, context): + # settings = get_scene_settings() + # prefs = get_addon_prefs() + + # row = self.layout.row(align=True) + # row.label('VSE Toolbox') + + # row.prop(settings, 'project_name', text='') + + # project = settings.active_project + + # if project and project.type == 'TVSHOW': + # row.prop(project, 'episode_name', text='') + + # row.operator('vse_toolbox.load_projects', icon='FILE_REFRESH', text='', emboss=False) def draw(self, context): wm = context.window_manager scn = context.scene - settings = get_scene_settings() prefs = get_addon_prefs() + + row = self.layout.row(align=False) + + row.operator('vse_toolbox.load_projects', icon='FILE_REFRESH', text='', emboss=False) + row.prop(settings, 'project_name', text='') project = settings.active_project - layout = self.layout - col = layout.column() + if project and project.type == 'TVSHOW': + row.prop(project, 'episode_name', text='') - col.prop(settings, 'project_name', text='Project') + # settings = get_scene_settings() + # prefs = get_addon_prefs() + + # project = settings.active_project - if project: - if project.type == 'TVSHOW': - col.prop(project, 'episode_name', text='Episodes') + # layout = self.layout + # col = layout.column() + + # col.prop(settings, 'project_name', text='Project') + + # if project: + # if project.type == 'TVSHOW': + # col.prop(project, 'episode_name', text='Episodes') #col.separator() @@ -94,7 +120,7 @@ class VSETB_PT_strip(Panel): 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() @@ -113,42 +139,19 @@ 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) + col = layout.column() col.operator('vse_toolbox.set_sequencer', text='Set-Up Sequencer', icon='SEQ_SEQUENCER') - - #row = col.row() - shot_label = '' - sequence_label = '' - - if project: - episode = project.episode_name - sequence_label = project.sequence_template.format(episode=episode, index=project.sequence_start_number) - shot_label = project.shot_template.format(episode=episode, sequence=sequence_label, index=project.shot_start_number) - - #row.separator() - - strip = context.active_sequence_strip - channel_name = get_channel_name(strip) or '' - if channel_name == 'Shots': - label = shot_label - elif channel_name == 'Sequences': - label = sequence_label - else: - label = 'Not Supported' - - row = col.row(align=True) - if label == 'Not Supported': - row.enabled = False - - op = row.operator('vse_toolbox.strips_rename', text=f'Rename {channel_name} ( {label} )', icon='SORTALPHA') - op.channel_name = channel_name - + 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') class VSETB_PT_settings(VSETB_main, Panel): bl_label = "Settings" - bl_parent_id = "VSETB_PT_main" + #bl_parent_id = "VSETB_PT_main" bl_options = {'DEFAULT_CLOSED'} #def draw_header_preset(self, context): @@ -169,7 +172,7 @@ class VSETB_PT_settings(VSETB_main, Panel): class VSETB_PT_imports(VSETB_main, Panel): bl_label = "Imports" - bl_parent_id = "VSETB_PT_main" + #bl_parent_id = "VSETB_PT_main" bl_options = {'DEFAULT_CLOSED'} def draw_header_preset(self, context): @@ -197,7 +200,7 @@ class VSETB_PT_presets(PresetPanel, Panel): class VSETB_PT_exports(VSETB_main, Panel): bl_label = "Exports" - bl_parent_id = "VSETB_PT_main" + #bl_parent_id = "VSETB_PT_main" bl_options = {'DEFAULT_CLOSED'} def draw_header_preset(self, context): @@ -208,20 +211,20 @@ class VSETB_PT_exports(VSETB_main, Panel): layout = self.layout settings = get_scene_settings() project = settings.active_project + col = layout.column(align=False) # TODO FAIRE DES VRAIS OPS - layout.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('_', ' ') - layout.operator('vse_toolbox.upload_to_tracker', text=f'Upload to {tracker_label}', icon='EXPORT') - layout.operator('vse_toolbox.export_spreadsheet', text='Export Spreadsheet', icon='SPREADSHEET') - layout.operator('vse_toolbox.export_edl', text='Export edl', icon='SEQ_SEQUENCER') - + 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_parent_id = "VSETB_PT_main" bl_options = {'DEFAULT_CLOSED'} @classmethod @@ -403,8 +406,13 @@ class VSETB_MT_main_menu(Menu): def draw(self, context): layout = self.layout - layout.operator('vse_toolbox.open_shot_dir', text='Open Shot', icon='FILE_FOLDER') + 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') def draw_vse_toolbox_menu(self, context): self.layout.menu("VSETB_MT_main_menu") diff --git a/ui/preferences.py b/ui/preferences.py index eb104e8..d895d8d 100644 --- a/ui/preferences.py +++ b/ui/preferences.py @@ -29,6 +29,7 @@ from vse_toolbox.file_utils import ( ) from vse_toolbox.resources.trackers.kitsu import Kitsu + def load_trackers(): from vse_toolbox.resources.trackers.tracker import Tracker @@ -112,7 +113,9 @@ class VSETB_Prefs(AddonPreferences): layout = self.layout - layout.prop(self, 'config_path', text='Config Path') + row = layout.row(align=True) + row.prop(self, 'config_path', text='Config Path') + row.operator("vse_toolbox.load_settings", icon="CHECKMARK", text='') layout.prop(self, "sort_metadata_items", text='Sort Metadata Items') col = layout.column(align=True) diff --git a/ui/properties.py b/ui/properties.py index 0b2caa6..287006a 100644 --- a/ui/properties.py +++ b/ui/properties.py @@ -110,6 +110,12 @@ class MetadataType(PropertyGroup): class TaskType(PropertyGroup): color : FloatVectorProperty(subtype='COLOR') import_enabled : BoolProperty(default=False) + id : StringProperty(default='') + + +class Sequence(PropertyGroup): + import_enabled : BoolProperty(default=False) + id : StringProperty(default='') class TaskStatus(PropertyGroup): @@ -211,6 +217,66 @@ class SpreadsheetImport(PropertyGroup): update_edit: BoolProperty(default=True) +def get_task_status_items(self, context): + settings = get_scene_settings() + project = settings.active_project + + status_items = [('CURRENT', 'Current', '')] + + if project: + status_items += [(t.name, t.name, '') for t in project.task_statuses] + return status_items + + +def get_task_type_items(self, context): + settings = get_scene_settings() + project = settings.active_project + + if not project: + return [('NONE', 'None', '')] + + return [(t.name, t.name, '') for t in project.task_types] + + +class ImportShots(PropertyGroup): + import_source: EnumProperty(items=[(i, i.title(), '') for i in ('DISK', 'TRACKER')]) + import_task: EnumProperty(items=[(i, i.title().replace('_', ' '), '') + for i in ('LAST', 'FROM_LIST', 'ALL')], default='FROM_LIST') + + #sequence_dir_template: StringProperty( + # name="Sequence Template", default="$PROJECT_ROOT/sequences/{sequence}") + + shot_folder_template: StringProperty( + name="Shot Template", default="$PROJECT_ROOT/sequences/sq{sequence}/sh{shot}") + + import_video_template : StringProperty( + name="Video Path", default="./{task}/render/{version}.{ext}") + + import_sequence: EnumProperty(items=[(i, i.title().replace('_', ' '), '') + for i in ('SELECTED_STRIPS', 'FROM_LIST', 'ALL')], default='FROM_LIST') + + previews_folder: StringProperty( + name="Previews Folder", default="//sources", subtype='DIR_PATH') + + +class UploadToTracker(PropertyGroup): + render_strips: BoolProperty(default=False) + render_strip_template : StringProperty( + name="Movie Path", default="//render/{project_basename}.{ext}") + + task : EnumProperty(items=get_task_type_items) + status : EnumProperty(items=get_task_status_items) + comment : StringProperty() + add_preview : BoolProperty(default=True) + preview_mode : EnumProperty(items=[(m, m.title().replace('_', ' '), '') + for m in ('ONLY_NEW', 'REPLACE', 'ADD')]) + set_main_preview : BoolProperty(default=True) + casting : BoolProperty(default=True) + update_frames: BoolProperty(default=True) + custom_data : BoolProperty(default=True) + tasks_comment: BoolProperty(default=True) + + def get_episodes_items(self, context): settings = get_scene_settings() @@ -232,7 +298,10 @@ class Project(PropertyGroup): id : StringProperty(default='') shot_start_number : IntProperty(name="Shot Start Number", default=10, min=0) + shot_padding : IntProperty(name="Shot Padding", default=4, min=0, max=10) + sequence_start_number : IntProperty(name="Sequence Start Number", default=10, min=0) + sequence_padding : IntProperty(name="Sequence Padding", default=3, min=0, max=10) reset_by_sequence : BoolProperty( name="Reset By Sequence", @@ -247,13 +316,13 @@ class Project(PropertyGroup): name="Shot Increment", default=10, min=0, step=10) sequence_template : StringProperty( - name="Sequence Name", default="sq{index:03d}") + name="Sequence Name", default="sq{sequence}") episode_template : StringProperty( - name="Episode Name", default="ep{index:03d}") + name="Episode Name", default="ep{episode}") shot_template : StringProperty( - name="Shot Name", default="{sequence}_sh{index:04d}") + name="Shot Name", default="sq{sequence}_sh{shot}") render_video_template : StringProperty( name="Movie Path", default="//render/{project_basename}.{ext}") @@ -300,17 +369,15 @@ class Project(PropertyGroup): task_types : CollectionProperty(type=TaskType) task_type_index : IntProperty() task_statuses : CollectionProperty(type=TaskStatus) + sequences : CollectionProperty(type=Sequence) spreadsheet_import: PointerProperty(type=SpreadsheetImport) spreadsheet_export: PointerProperty(type=SpreadsheetExport) type : StringProperty() - import_source: EnumProperty(items=[(i, i.title(), '') for i in ('DISK', 'TRACKER')]) - import_task: EnumProperty(items=[(i, i.title(), '') for i in ('LAST', 'SELECTED', 'ALL')], default='LAST') - - shot_dir_template: StringProperty( - name="Shot Template", default="$PROJECT_ROOT/sequences/{sequence}/sh{index:04d}") + upload_to_tracker: PointerProperty(type=UploadToTracker) + import_shots: PointerProperty(type=ImportShots) @property def active_episode(self): @@ -354,8 +421,6 @@ class Project(PropertyGroup): return cell_types def set_spreadsheet(self): - - cell_names = ['Sequence', 'Shot', 'Nb Frames', 'Description'] if self.type == 'TVSHOW': cell_names.insert(0, 'Episode') @@ -563,6 +628,7 @@ def on_project_updated(self, context): os.environ['TRACKER_PROJECT_ID'] = settings.active_project.id + class VSETB_PGT_scene_settings(PropertyGroup): projects : CollectionProperty(type=Project) @@ -649,17 +715,21 @@ class VSETB_PGT_strip_settings(PropertyGroup): channel = get_channel_name(strip) if channel == 'Sequences': - data = parse(strip.name, template=project.sequence_template) - data['index'] = int(data['index']) - data['sequence'] = strip.name + data = parse(project.sequence_template, strip.name) + #data['index'] = int(data['index']) + #data['sequence'] = strip.name data['strip'] = strip.name #data['shot'] = project.shot_template elif channel == "Shots": + data = {} + if sequence_strip_name := get_strip_sequence_name(strip): + data = parse(project.sequence_template, sequence_strip_name) + data['sequence_strip'] = sequence_strip_name + + data.update(parse(project.shot_template, strip.name)) + #data['index'] = int(data['index']) - data = parse(strip.name, template=project.shot_template) - data['index'] = int(data['index']) - data['sequence'] = get_strip_sequence_name(strip) data['strip'] = strip.name #data['shot'] = project.shot_template else: @@ -677,11 +747,14 @@ classes = ( Episode, Metadata, MetadataType, + Sequence, TaskType, ShotTasks, ShotTask, SpreadsheetImport, SpreadsheetExport, + ImportShots, + UploadToTracker, Project, VSETB_UL_spreadsheet_import, VSETB_UL_spreadsheet_export,