diff --git a/operators/sequencer.py b/operators/sequencer.py index 99005b8..741d10f 100644 --- a/operators/sequencer.py +++ b/operators/sequencer.py @@ -11,7 +11,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, create_shot_strip, update_text_strips) -from vse_toolbox.scene_cut_detection import detect_scene_change +from vse_toolbox.scene_cut_detection import (detect_scene_change, get_scene_scores, + compute_scene_threshold) from vse_toolbox.bl_utils import get_scene_settings, get_strip_settings, get_addon_prefs from shutil import copy2 @@ -230,6 +231,11 @@ class VSETB_OT_scene_cut_detection(Operator): description='Probability for the current frame to introduce a new scene') still_threshold: FloatProperty(name="Threshold", default=0.001, min=0, max=1, precision=4, description="Noise tolerance, difference ratio between 0 and 1") + known_cuts: StringProperty( + name='Known Cuts', + description="Manually checked cut frames (timeline numbers), separated by ','. " + "Calibrates the threshold automatically (Animated only), e.g. 680,890,1059", + default='') frame_start: IntProperty(name='Start Split') frame_end: IntProperty(name='End Split') source_channel_name: EnumProperty( @@ -288,6 +294,7 @@ class VSETB_OT_scene_cut_detection(Operator): row.prop(self, 'movie_type', expand=True) if self.movie_type == 'ANIMATED': col.prop(self, 'animated_threshold') + col.prop(self, 'known_cuts') else: col.prop(self, 'still_threshold') @@ -298,11 +305,85 @@ class VSETB_OT_scene_cut_detection(Operator): split_col.prop(self, 'frame_start', text='Frame Split First') split_col.prop(self, 'frame_end', text='Last') + def _parse_known_cuts(self): + """Return the Known Cuts frames as a list of int, or None on bad input.""" + try: + return [int(f) for f in re.split(r'[:;, ]+', self.known_cuts.strip()) if f] + except ValueError: + return None + + def _calibrate_threshold(self): + """Compute the threshold from the Known Cuts frames and apply it.""" + known_frames = self._parse_known_cuts() + + # Score the whole split range, extended to cover every known cut: + # known cuts outside the split range are still valid calibration points. + scan_start = min([self.frame_start] + known_frames) + scan_end = max([self.frame_end] + [frame + 1 for frame in known_frames]) + + scene_scores = {} + for strip in get_strips(channel=self.source_channel_name): + if strip.type != 'MOVIE': + continue + + # Skip strip outside the scanned frame range. + if strip.frame_final_start >= scan_end or strip.frame_final_end <= scan_start: + continue + + scene_scores.update(get_scene_scores( + strip, + frame_start=scan_start, + frame_end=scan_end, + crop=(strip.crop.min_x, strip.crop.max_x, strip.crop.min_y, strip.crop.max_y))) + + threshold, missing, weak = compute_scene_threshold(scene_scores, known_frames) + + if missing: + self.report({"WARNING"}, + f"Known Cuts not found in source channel: {', '.join(str(f) for f in missing)}") + + if weak: + self.report({"WARNING"}, + "Known Cuts at noise level, skipped (dissolve or wrong frame?): " + + ', '.join(f"{frame} ({scene_scores[frame]:.4f})" for frame in weak)) + + if threshold is None: + if not scene_scores: + self.report({"ERROR"}, + f'Known Cuts: no frames scored on channel "{self.source_channel_name}", ' + f'check that it contains the movie strip') + elif weak: + self.report({"ERROR"}, + 'Known Cuts: every found frame scores below noise level (0.01), ' + 'threshold not calibrated') + else: + fps = bpy.context.scene.render.fps + first, last = min(scene_scores), max(scene_scores) + self.report({"ERROR"}, + f'Known Cuts: no matching frames (scored {len(scene_scores)} frames, ' + f'{first} to {last}, scene fps {fps})') + return + + self.animated_threshold = round(threshold, 4) + cut_count = sum(1 for frame, score in scene_scores.items() + if score > threshold and self.frame_start <= frame <= self.frame_end) + scores_str = ', '.join(f"{frame}: {scene_scores[frame]:.4f}" + for frame in known_frames + if frame in scene_scores and frame not in weak) + self.report({"INFO"}, + f"Calibrated threshold: {self.animated_threshold} " + f"(known cuts: {scores_str}; {cut_count} cuts detected)") + def execute(self, context): if self.source_channel_name == self.destination_channel_name: self.report({"ERROR"}, 'Source and Destination cannot be the same channel') return {'CANCELLED'} + if self.movie_type == 'ANIMATED' and self.known_cuts.strip(): + if self._parse_known_cuts() is None: + self.report({"ERROR"}, "Known Cuts: expected frame numbers separated by ','") + return {'CANCELLED'} + context.window_manager.modal_handler_add(self) return {'PASS_THROUGH'} @@ -314,6 +395,9 @@ class VSETB_OT_scene_cut_detection(Operator): if old_strip.name.startswith("tmp_shot_"): scn.sequence_editor.strips.remove(old_strip) + if self.movie_type == 'ANIMATED' and self.known_cuts.strip(): + self._calibrate_threshold() + strips = get_strips(channel=self.source_channel_name) i = 1 diff --git a/operators/tracker.py b/operators/tracker.py index 638e977..659e27d 100644 --- a/operators/tracker.py +++ b/operators/tracker.py @@ -1,22 +1,29 @@ - import os from os.path import expandvars from pathlib import Path from pprint import pprint +import traceback import webbrowser from functools import partial import bpy -from bpy.types import (Operator, ) -from bpy.props import (BoolProperty, EnumProperty, StringProperty) +from bpy.types import ( + Operator, +) +from bpy.props import BoolProperty, EnumProperty, StringProperty -from vse_toolbox.constants import (ASSET_PREVIEWS, PREVIEWS_DIR, ASSET_ITEMS) - -from vse_toolbox.sequencer_utils import (get_strips, get_strip_render_path, get_strip_sequence_name, - render_strip, get_render_attributes) +from vse_toolbox.constants import ASSET_PREVIEWS, PREVIEWS_DIR, ASSET_ITEMS -from vse_toolbox.bl_utils import (get_addon_prefs, get_scene_settings) -from vse_toolbox.file_utils import (norm_name, expand) +from vse_toolbox.sequencer_utils import ( + get_strips, + get_strip_render_path, + get_strip_sequence_name, + render_strip, + get_render_attributes, +) + +from vse_toolbox.bl_utils import get_addon_prefs, get_scene_settings +from vse_toolbox.file_utils import norm_name, expand from bpy.app.handlers import persistent @@ -29,7 +36,7 @@ class VSETB_OT_tracker_connect(Operator): def poll(cls, context): prefs = get_addon_prefs() if prefs.tracker: - + return True def execute(self, context): @@ -37,13 +44,17 @@ class VSETB_OT_tracker_connect(Operator): settings = get_scene_settings() try: prefs.tracker.connect() - self.report({'INFO'}, f'Successfully login to {settings.tracker_name.title()}') + self.report( + {"INFO"}, f"Successfully login to {settings.tracker_name.title()}" + ) return {"FINISHED"} except Exception as e: - print('e: ', e) - self.report({'ERROR'}, f'Cannot connect to tracker, check login and password') + print("e: ", e) + self.report( + {"ERROR"}, f"Cannot connect to tracker, check login and password" + ) return {"CANCELLED"} - + class VSETB_OT_load_assets(Operator): bl_idname = "vse_toolbox.load_assets" @@ -61,40 +72,46 @@ class VSETB_OT_load_assets(Operator): settings = get_scene_settings() prefs = get_addon_prefs() tracker = prefs.tracker - + tracker.admin_connect() project = settings.active_project project.assets.clear() - - assets = tracker.get_assets(project['id']) + + assets = tracker.get_assets(project["id"]) if not assets: - self.report({'ERROR'}, f'No Assets found for {project.name}.') + self.report({"ERROR"}, f"No Assets found for {project.name}.") for asset_data in assets: asset = project.assets.add() - asset.name = asset_data['id'] - asset.norm_name = norm_name(asset_data['name'], separator=' ', format=str.lower) + asset.name = asset_data["id"] + asset.norm_name = norm_name( + asset_data["name"], separator=" ", format=str.lower + ) - asset.tracker_name = asset_data['name'] - asset.id = asset_data['id'] - asset.asset_type = asset_data['asset_type'] - - #for key, value in asset_data.get('data', {}).items(): - asset['metadata'] = asset_data.get('data', {}) + asset.tracker_name = asset_data["name"] + asset.id = asset_data["id"] + asset.asset_type = asset_data["asset_type"] - preview_id = asset_data.get('preview_file_id') + # for key, value in asset_data.get('data', {}).items(): + asset["metadata"] = asset_data.get("data", {}) + + preview_id = asset_data.get("preview_file_id") if preview_id: asset.preview = preview_id - preview_path = Path(PREVIEWS_DIR / project.id / preview_id).with_suffix('.png') + preview_path = Path(PREVIEWS_DIR / project.id / preview_id).with_suffix( + ".png" + ) tracker.download_preview(preview_id, preview_path) - + if preview_id not in ASSET_PREVIEWS: - ASSET_PREVIEWS.load(preview_id, preview_path.as_posix(), 'IMAGE', True) - + ASSET_PREVIEWS.load( + preview_id, preview_path.as_posix(), "IMAGE", True + ) + set_asset_items() - self.report({'INFO'}, f'Assets for {project.name} successfully loaded') + self.report({"INFO"}, f"Assets for {project.name} successfully loaded") return {"FINISHED"} @@ -119,8 +136,11 @@ class VSETB_OT_load_projects(Operator): @staticmethod def hex_to_rgb(color_str): # supports '123456', '#123456' and '0x123456' - (r,g,b), a = map(lambda component: component / 255, bytes.fromhex(color_str[-6:])), 1.0 - return (r,g,b,a) + (r, g, b), a = ( + map(lambda component: component / 255, bytes.fromhex(color_str[-6:])), + 1.0, + ) + return (r, g, b, a) def invoke(self, context, event): self.ctrl = event.ctrl @@ -135,28 +155,28 @@ class VSETB_OT_load_projects(Operator): tracker.admin_connect() project_datas = tracker.get_projects() - for project_data in sorted(project_datas, key=lambda x: x['name']): - project = settings.projects.get(project_data['name']) + for project_data in sorted(project_datas, key=lambda x: x["name"]): + project = settings.projects.get(project_data["name"]) if not project: project = settings.projects.add() - project.type = project_data['production_type'].upper().replace(' ', '') - project.name = project_data['name'] - project.id = project_data['id'] + project.type = project_data["production_type"].upper().replace(" ", "") + project.name = project_data["name"] + project.id = project_data["id"] - if project.type == 'TVSHOW': + if project.type == "TVSHOW": episode_datas = tracker.get_episodes(project_data) for episode_data in episode_datas: - episode = project.episodes.get(episode_data['name']) + episode = project.episodes.get(episode_data["name"]) if not episode: episode = project.episodes.add() - episode.name = episode_data['name'] - episode.id = episode_data['id'] + episode.name = episode_data["name"] + episode.id = episode_data["id"] # Clear deleted episodes - ep_names = [e['name'] for e in episode_datas] + ep_names = [e["name"] for e in episode_datas] for ep in reversed(project.episodes): if ep.name not in ep_names: project.episodes.remove(list(project.episodes).index(ep)) @@ -165,66 +185,68 @@ class VSETB_OT_load_projects(Operator): if project.episodes: first_episode = project.episodes[0] project.sequences.clear() - for seq in tracker.get_sequences(project_data, episode=first_episode.id): + for seq in tracker.get_sequences( + project_data, episode=first_episode.id + ): sequence = project.sequences.add() - sequence.name = seq['name'] - sequence.id = seq['id'] + sequence.name = seq["name"] + sequence.id = seq["id"] else: # Add sequences sequences_data = tracker.get_sequences(project_data) for sequence_data in sequences_data: - sequence = project.sequences.get(sequence_data['name']) - + sequence = project.sequences.get(sequence_data["name"]) + if not sequence: sequence = project.sequences.add() - sequence.name = sequence_data['name'] - sequence.id = sequence_data['id'] + sequence.name = sequence_data["name"] + sequence.id = sequence_data["id"] project.metadata_types.clear() - + for metadata_data in tracker.get_metadata_types(project_data): - #pprint(metadata_data) + # pprint(metadata_data) metadata_type = project.metadata_types.add() - metadata_type.name = metadata_data['name'] - metadata_type.field_name = metadata_data['field_name'] - metadata_type.data_type = metadata_data['data_type'] - #metadata_type['choices'] = metadata_data['choices'] + metadata_type.name = metadata_data["name"] + metadata_type.field_name = metadata_data["field_name"] + metadata_type.data_type = metadata_data["data_type"] + # metadata_type['choices'] = metadata_data['choices'] if prefs.sort_metadata_items: - metadata_data['choices'].sort() + metadata_data["choices"].sort() - for choice in metadata_data['choices']: + for choice in metadata_data["choices"]: choice_item = metadata_type.choices.add() choice_item.name = choice - - metadata_type['entity_type'] = metadata_data['entity_type'].upper() - + + metadata_type["entity_type"] = metadata_data["entity_type"].upper() + project.task_statuses.clear() for status_data in tracker.get_task_statuses(project_data): - #print(metadata_data) + # print(metadata_data) task_status = project.task_statuses.add() - task_status.name = status_data['short_name'].upper() - task_status.is_done = status_data.get('is_done', False) + task_status.name = status_data["short_name"].upper() + task_status.is_done = status_data.get("is_done", False) project.task_types.clear() for task_type_data in tracker.get_shot_task_types(project_data): 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] + 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() project.asset_types.clear() for asset_type_data in tracker.get_asset_types(project_data): - asset_type = project.asset_types.add() - asset_type.name = asset_type_data['name'] - + asset_type = project.asset_types.add() + asset_type.name = asset_type_data["name"] + project.set_spreadsheet() # Remove deleted projects - project_names = [p['name'] for p in project_datas] + project_names = [p["name"] for p in project_datas] for project in reversed(settings.projects): if project.name not in project_names: settings.projects.remove(list(settings.projects).index(project)) @@ -232,20 +254,20 @@ class VSETB_OT_load_projects(Operator): # ``load_projects`` can also be called programmatically (for example # from the upload dialog), bypassing ``invoke`` and therefore not # initializing ``self.ctrl``. - if getattr(self, 'ctrl', False) or not settings.get('projects_loaded'): + if getattr(self, "ctrl", False) or not settings.get("projects_loaded"): bpy.ops.vse_toolbox.load_settings() - if prev_project_name != '/' and prev_project_name in settings.projects: + if prev_project_name != "/" and prev_project_name in settings.projects: settings.project_name = prev_project_name - #if settings.active_project: + # if settings.active_project: # settings.active_project.set_strip_metadata() - settings['projects_loaded'] = True + settings["projects_loaded"] = True - self.report({"INFO"}, 'Successfully Load Tracker Projects') + self.report({"INFO"}, "Successfully Load Tracker Projects") - return {'FINISHED'} + return {"FINISHED"} class VSETB_OT_new_episode(Operator): @@ -254,7 +276,7 @@ class VSETB_OT_new_episode(Operator): bl_description = "Add new Episode to Project" bl_options = {"REGISTER", "UNDO"} - episode_name : StringProperty(name="Episode Name", default="") + episode_name: StringProperty(name="Episode Name", default="") @classmethod def poll(cls, context): @@ -263,7 +285,7 @@ class VSETB_OT_new_episode(Operator): def invoke(self, context, event): scn = context.scene settings = get_scene_settings() - + return context.window_manager.invoke_props_dialog(self) def execute(self, context): @@ -273,25 +295,25 @@ class VSETB_OT_new_episode(Operator): project = settings.active_project if not project: - self.report({'ERROR'}, 'No active project') + self.report({"ERROR"}, "No active project") return {"CANCELLED"} episode_name = project.episode_template.format(index=int(self.episode_name)) episode_data = tracker.get_episode(episode_name, project=project.id) if episode_data: - self.report({'ERROR'}, f'Episode {episode_name} already exists') + self.report({"ERROR"}, f"Episode {episode_name} already exists") return {"CANCELLED"} episode_data = tracker.new_episode(episode_name, project=project.id) # Add to local episode collection episode = project.episodes.add() - episode.name = episode_data['name'] - episode.id = episode_data['id'] + episode.name = episode_data["name"] + episode.id = episode_data["id"] - self.report({'INFO'}, f'Episode {episode_name} successfully created') + self.report({"INFO"}, f"Episode {episode_name} successfully created") - return {'FINISHED'} + return {"FINISHED"} class VSETB_OT_upload_to_tracker(Operator): @@ -307,8 +329,8 @@ class VSETB_OT_upload_to_tracker(Operator): @classmethod def poll(cls, context): - if not get_strips(channel='Shots', selected_only=True): - cls.poll_message_set('Select at least one shot strip') + if not get_strips(channel="Shots", selected_only=True): + cls.poll_message_set("Select at least one shot strip") return False return True @@ -327,7 +349,7 @@ class VSETB_OT_upload_to_tracker(Operator): bpy.ops.vse_toolbox.load_projects() self.project = settings.active_project - #self.bl_label = f"Upload to {settings.tracker_name.title()}" + # self.bl_label = f"Upload to {settings.tracker_name.title()}" return context.window_manager.invoke_props_dialog(self, width=350) @@ -337,36 +359,36 @@ class VSETB_OT_upload_to_tracker(Operator): layout = self.layout col = layout.column() - col.use_property_split = True + col.use_property_split = True col.use_property_decorate = False - col.prop(upload_to_tracker, 'render_strips', text='Render Strips') + 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 + col.prop(upload_to_tracker, "render_strip_template", text="") + col.use_property_split = True col.separator() - 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='') + 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.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') + 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.') + # self.report({'ERROR'}, f'Export not implemented yet.') prefs = get_addon_prefs() settings = get_scene_settings() tracker = prefs.tracker @@ -380,9 +402,9 @@ class VSETB_OT_upload_to_tracker(Operator): episode = settings.active_episode.id format_data = {**settings.format_data, **self.project.format_data} - + status = upload_to_tracker.status - keep_current_status = status == 'CURRENT' + keep_current_status = status == "CURRENT" if keep_current_status: status = None @@ -393,14 +415,14 @@ class VSETB_OT_upload_to_tracker(Operator): if keep_current_status: done_statuses = [s for s in self.project.task_statuses if s.is_done] default_new_task_status = next( - (s.name for s in done_statuses if s.name.lower() == 'done'), + (s.name for s in done_statuses if s.name.lower() == "done"), done_statuses[0].name if done_statuses else None, ) - shot_strips = get_strips(channel='Shots', selected_only=True) + shot_strips = get_strips(channel="Shots", selected_only=True) if not shot_strips: - self.report({'WARNING'}, 'Select at least one shot strip to upload') - return {'CANCELLED'} + self.report({"WARNING"}, "Select at least one shot strip to upload") + return {"CANCELLED"} context.window_manager.progress_begin(0, len(shot_strips)) @@ -408,40 +430,54 @@ class VSETB_OT_upload_to_tracker(Operator): context.window_manager.progress_update(i) strip_settings = strip.vsetb_strip_settings sequence_name = get_strip_sequence_name(strip) - if sequence_name == 'NoSequence': - self.report({'WARNING'}, f"Strip '{strip.name}' has no sequence — skipped") + if sequence_name == "NoSequence": + self.report( + {"WARNING"}, f"Strip '{strip.name}' has no sequence — skipped" + ) continue shot_name = strip.name - sequence = tracker.get_sequence(sequence_name, episode=episode) metadata = strip_settings.metadata.to_dict(use_name=False) - #print(metadata) + # print(metadata) - if not sequence: - self.report({"INFO"}, f'Create sequence {sequence_name} in Kitsu') - sequence = tracker.new_sequence(sequence_name, episode=episode) + try: + sequence = tracker.get_sequence(sequence_name, episode=episode) + if not sequence: + self.report({"INFO"}, f"Create sequence {sequence_name} in Kitsu") + sequence = tracker.new_sequence(sequence_name, episode=episode) - shot = tracker.get_shot(shot_name, sequence=sequence) - if not shot: - self.report({"INFO"}, f'Create shot {shot_name} in Kitsu') - shot = tracker.new_shot(shot_name, sequence=sequence) + shot = tracker.get_shot(shot_name, sequence=sequence) + if not shot: + self.report({"INFO"}, f"Create shot {shot_name} in Kitsu") + shot = tracker.new_shot(shot_name, sequence=sequence) - task = tracker.get_task(upload_to_tracker.task, entity=shot) - if not task: - task = tracker.new_task( - shot, - task_type=upload_to_tracker.task, - status=default_new_task_status or status, + task = tracker.get_task(upload_to_tracker.task, entity=shot) + if not task: + task = tracker.new_task( + shot, + task_type=upload_to_tracker.task, + status=default_new_task_status or status, + ) + except Exception as error: + traceback.print_exc() + self.report( + {"WARNING"}, + f"{strip.name}: tracker setup failed ({error}); continuing", ) + continue preview = None if upload_to_tracker.add_preview: - + strip_data = {**format_data, **strip_settings.format_data} if upload_to_tracker.render_strips: - preview_template = expand(upload_to_tracker.render_strip_template, **project_templates) - strip_data['ext'] = 'mov' + preview_template = expand( + upload_to_tracker.render_strip_template, **project_templates + ) + strip_data["ext"] = "mov" else: - preview_template = expand(self.project.render_video_strip_template, **project_templates) + preview_template = expand( + self.project.render_video_strip_template, **project_templates + ) preview = preview_template.format(**strip_data) preview = Path(os.path.abspath(bpy.path.abspath(preview))) @@ -449,73 +485,168 @@ class VSETB_OT_upload_to_tracker(Operator): if upload_to_tracker.render_strips: render_strip(strip, preview, attributes=render_attrs) - #print(preview) + # print(preview) if not preview.exists(): - print(f'The preview {preview} not exists') + print(f"The preview {preview} not exists") preview = None - elif task.get('last_comment') and task['last_comment']['previews']: - if upload_to_tracker.preview_mode == 'REPLACE': - tracker.remove_comment(task['last_comment']) - elif upload_to_tracker.preview_mode == 'ONLY_NEW': + elif task.get("last_comment") and task["last_comment"]["previews"]: + if upload_to_tracker.preview_mode == "REPLACE": + try: + tracker.remove_comment(task["last_comment"]) + except Exception as error: + traceback.print_exc() + self.report( + {"WARNING"}, + f"{strip.name}: replacing preview failed ({error}); continuing", + ) + continue + elif upload_to_tracker.preview_mode == "ONLY_NEW": preview = None - + comment_data = None preview_data = None if status or upload_to_tracker.comment or preview: - comment_data = tracker.new_comment(task, comment=upload_to_tracker.comment, status=status) + previous_comment = task.get("last_comment") or {} + try: + comment_data = tracker.new_comment( + task, comment=upload_to_tracker.comment, status=status + ) + except Exception as error: + traceback.print_exc() + # A proxy timeout can happen after Zou committed the comment. + # Recover it instead of retrying the non-idempotent POST. + try: + latest_comment = tracker.get_last_comment(task) + except Exception: + latest_comment = None + if latest_comment and latest_comment.get( + "id" + ) != previous_comment.get("id"): + comment_data = latest_comment + self.report( + {"WARNING"}, + f"{strip.name}: recovered comment after tracker timeout", + ) + else: + self.report( + {"WARNING"}, + f"{strip.name}: comment failed ({error}); continuing", + ) + continue + if preview: - print('Upload preview from', preview) - preview_data = tracker.new_preview( - task=task, - comment=comment_data, - preview=preview) + print("Upload preview from", preview) + try: + preview_data = tracker.new_preview( + task=task, comment=comment_data, preview=preview + ) + except Exception as error: + traceback.print_exc() + # As with comments, the upload may have completed before + # the proxy timed out. Recover the resulting preview. + try: + refreshed_comment = tracker.get_comment(comment_data) + previews = refreshed_comment.get("previews") or [] + preview_data = previews[-1] if previews else None + except Exception: + preview_data = None + if preview_data: + self.report( + {"WARNING"}, + f"{strip.name}: recovered preview after tracker timeout", + ) + else: + self.report( + {"WARNING"}, + f"{strip.name}: preview failed ({error}); continuing", + ) + continue if upload_to_tracker.set_main_preview and preview_data: - bpy.app.timers.register(partial(tracker.set_main_preview, preview_data), first_interval=10) + 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 + params["custom_data"] = metadata + params["description"] = strip_settings.description if upload_to_tracker.update_frames: # Blender's frame_final_end is exclusive, while Kitsu's # frame_out is inclusive. Keep both the explicit range and # the duration in sync with the uploaded strip. - params['frames'] = strip.frame_final_duration - params.setdefault('custom_data', {}).update({ - 'frame_in': strip.frame_final_start, - 'frame_out': strip.frame_final_end - 1, - }) + params["frames"] = strip.frame_final_duration + params.setdefault("custom_data", {}).update( + { + "frame_in": strip.frame_final_start, + "frame_out": strip.frame_final_end - 1, + } + ) if params: - tracker.update_data(shot, **params) - + try: + tracker.update_data(shot, **params) + except Exception as error: + traceback.print_exc() + self.report( + {"WARNING"}, + f"{strip.name}: shot update failed ({error}); continuing", + ) + 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) + casting = [ + {"asset_id": a.id, "nb_occurences": a.instance} + for a in strip_settings.casting + ] + try: + tracker.update_casting(shot, casting) + except Exception as error: + traceback.print_exc() + self.report( + {"WARNING"}, + f"{strip.name}: casting update failed ({error}); continuing", + ) if upload_to_tracker.tasks_comment: for task_type in self.project.task_types: - task_settings = getattr(strip_settings.tasks, norm_name(task_type.name)) + task_settings = getattr( + strip_settings.tasks, norm_name(task_type.name) + ) if not task_settings.comment: continue - tracker_task = tracker.get_task(task_type.name, entity=shot) - if not tracker_task: - tracker_task = tracker.new_task(shot, task_type=task_type.name) + try: + tracker_task = tracker.get_task(task_type.name, entity=shot) + if not tracker_task: + tracker_task = tracker.new_task( + shot, task_type=task_type.name + ) - last_comment = tracker_task.get('last_comment') - last_comment_text = last_comment.get('text', '') if isinstance(last_comment, dict) else str(last_comment or '') - if last_comment_text != task_settings.comment: - tracker.new_comment(tracker_task, comment=task_settings.comment) + last_comment = tracker_task.get("last_comment") + last_comment_text = ( + last_comment.get("text", "") + if isinstance(last_comment, dict) + else str(last_comment or "") + ) + if last_comment_text != task_settings.comment: + tracker.new_comment( + tracker_task, comment=task_settings.comment + ) + except Exception as error: + traceback.print_exc() + self.report( + {"WARNING"}, + f"{strip.name}: {task_type.name} comment failed ({error}); continuing", + ) context.window_manager.progress_end() return {"FINISHED"} - + class VSETB_OT_open_shot_on_tracker(Operator): bl_idname = "vse_toolbox.open_shot_on_tracker" @@ -524,8 +655,8 @@ class VSETB_OT_open_shot_on_tracker(Operator): @classmethod def poll(cls, context): - if not get_strips(channel='Shots', selected_only=True): - cls.poll_message_set('No active Shots strip') + if not get_strips(channel="Shots", selected_only=True): + cls.poll_message_set("No active Shots strip") return prefs = get_addon_prefs() @@ -535,8 +666,8 @@ class VSETB_OT_open_shot_on_tracker(Operator): def execute(self, context): prefs = get_addon_prefs() tracker = prefs.tracker - - strips = get_strips(channel='Shots', selected_only=True) + + 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) @@ -547,10 +678,15 @@ class VSETB_OT_open_shot_on_tracker(Operator): @persistent def set_asset_items(scene=None): ASSET_ITEMS.clear() - + settings = get_scene_settings() if settings.active_project: - ASSET_ITEMS.extend([(a.id, a.label, '', i) for i, a in enumerate(settings.active_project.assets)]) + ASSET_ITEMS.extend( + [ + (a.id, a.label, "", i) + for i, a in enumerate(settings.active_project.assets) + ] + ) classes = ( @@ -559,9 +695,10 @@ classes = ( VSETB_OT_new_episode, VSETB_OT_tracker_connect, VSETB_OT_upload_to_tracker, - VSETB_OT_open_shot_on_tracker + VSETB_OT_open_shot_on_tracker, ) + def register(): if not bpy.app.background: bpy.app.handlers.load_post.append(set_asset_items) @@ -569,9 +706,10 @@ def register(): for cls in classes: bpy.utils.register_class(cls) + def unregister(): for cls in reversed(classes): bpy.utils.unregister_class(cls) - + if not bpy.app.background: - bpy.app.handlers.load_post.remove(set_asset_items) \ No newline at end of file + bpy.app.handlers.load_post.remove(set_asset_items) diff --git a/resources/trackers/kitsu.py b/resources/trackers/kitsu.py index e64f4bf..6a3ad09 100644 --- a/resources/trackers/kitsu.py +++ b/resources/trackers/kitsu.py @@ -7,6 +7,7 @@ import urllib3 import traceback import time import uuid +from functools import partial from pprint import pprint from bpy.props import PointerProperty, StringProperty @@ -255,6 +256,9 @@ class Kitsu(Tracker): task = self.get_id(task) return gazu.task.get_last_comment_for_task(task) + def get_comment(self, comment): + return gazu.task.get_comment(self.get_id(comment)) + def get_last_comment_with_preview(self, task): task = self.get_id(task) comments = gazu.task.all_comments_for_task(task) @@ -292,8 +296,19 @@ class Kitsu(Tracker): return task - def set_main_preview(self, preview_data): - gazu.task.set_main_preview(preview_data) + def set_main_preview(self, preview_data, retries=3): + try: + gazu.task.set_main_preview(preview_data) + except Exception: + traceback.print_exc() + if retries: + print(f'Retrying set_main_preview in 15 seconds ({retries} left)') + bpy.app.timers.register( + partial(self.set_main_preview, preview_data, retries - 1), + first_interval=15, + ) + else: + print(f'Failed to set main preview after retries: {self.get_id(preview_data)}') def new_preview(self, task, comment, preview, set_main_preview=False): #print('new_preview', task, comment, preview, set_main_preview) diff --git a/scene_cut_detection.py b/scene_cut_detection.py index c038a26..94d21a3 100644 --- a/scene_cut_detection.py +++ b/scene_cut_detection.py @@ -7,6 +7,129 @@ import bpy from vse_toolbox import bl_utils +def get_scene_scores(strip, frame_start=None, frame_end=None, crop=None): + """Run ffmpeg over the strip and return the scene score of every frame. + + The command mirrors the ANIMATED detection pass (same crop and seek), but + uses the metadata filter to print each frame's scene score instead of + selecting frames above a threshold. + + Args: + strip (bpy.types.Strip): blender sequence strip to score. + frame_start (int, optional): first timeline frame to score. + Defaults to None. + frame_end (int, optional): last timeline frame to score. + Defaults to None. + crop (list, optional): crop values [min_x, max_x, min_y, max_y]. + Defaults to None. + + Returns: + dict: timeline frames -> scene score (float, from 0 to 1). + """ + + path = bl_utils.abspath(strip.filepath) + fps = bpy.context.scene.render.fps + + if frame_start is None: + frame_start = 0 + if frame_end is None: + frame_end = strip.frame_duration + + start_time = (frame_start - strip.frame_start) / fps + end_time = frame_end / fps + + if crop is None: + crop = [0, 0, 0, 0] + + crop_expr = f"crop=iw-{crop[0]}-{crop[1]}:ih-{crop[2]}-{crop[3]}:{crop[0]}:{crop[-1]}" + + command = [ + 'ffmpeg', + '-nostats', + '-i', + str(path), + '-ss', str(start_time), + '-t', str(end_time), + '-vf', + f"{crop_expr}, select='gte(scene,0)', metadata=print", + '-f', + 'null', + '-' + ] + + print(command) + + process = subprocess.Popen( + command, + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + universal_newlines=True) + + scores = {} + offset_frame = None + for line in process.stdout: + match = re.search(r'pts_time:([\d.]+)', line) + if match: + offset_frame = round(float(match.group(1)) * fps) + + match = re.search(r'lavfi.scene_score=([\d.]+)', line) + if match and offset_frame is not None: + # pts_time restarts at the -ss seek point, shift back to timeline frames. + scores[offset_frame + int(frame_start)] = float(match.group(1)) + + process.wait() + return scores + + +def compute_scene_threshold(scene_scores, known_frames, noise_floor=0.01): + """Compute a scene-change threshold that detects every known cut frame. + + The threshold is placed just below the weakest known cut. When there is a + clear gap under it (highest non-known score at least 10% lower), the + threshold sits in the middle of that gap. Otherwise it defaults to 90% of + the weakest known cut score to stay robust against small score shifts. + + Known cuts scoring below `noise_floor` are considered undetectable by the + select filter (dissolves or misread frames) and are skipped: pulling the + threshold under the noise level would split on noise. + + Args: + scene_scores (dict): timeline frames -> scene score. + known_frames (list): timeline frames with manually checked cuts. + noise_floor (float, optional): score under which a known cut is + considered noise. Defaults to 0.01. + + Returns: + tuple: (threshold or None if no usable known frame was found, + list of known frames missing from the scores, + list of known frames skipped as noise). + """ + + known_frames = list(known_frames) + missing = [frame for frame in known_frames if frame not in scene_scores] + found = {frame: scene_scores[frame] for frame in known_frames if frame in scene_scores} + if not found: + return None, missing, [] + + weak = [frame for frame, score in found.items() if score < noise_floor] + usable = {frame: score for frame, score in found.items() if score >= noise_floor} + if not usable: + return None, missing, weak + + min_known = min(usable.values()) + known = set(usable) + below = [score for frame, score in scene_scores.items() + if frame not in known and score < min_known] + max_below = max(below) if below else 0.0 + + if min_known - max_below > 0.1 * min_known: + threshold = (min_known + max_below) / 2 + else: + threshold = 0.9 * min_known + + return threshold, missing, weak + + def detect_scene_change(strip, movie_type="ANIMATED", threshold=0.5, frame_start=None, frame_end=None, crop=None): """Launch ffmpeg command to detect changing frames from a movie strip.