scene cut detection

This commit is contained in:
2026-10-05 11:25:56 +02:00
parent 4f0111e09a
commit 693f24da7a
4 changed files with 541 additions and 181 deletions
+85 -1
View File
@@ -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, get_channel_index, new_text_strip, get_strip_at, get_channel_name,
create_shot_strip, update_text_strips) 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 vse_toolbox.bl_utils import get_scene_settings, get_strip_settings, get_addon_prefs
from shutil import copy2 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') 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, still_threshold: FloatProperty(name="Threshold", default=0.001, min=0, max=1, precision=4,
description="Noise tolerance, difference ratio between 0 and 1") 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_start: IntProperty(name='Start Split')
frame_end: IntProperty(name='End Split') frame_end: IntProperty(name='End Split')
source_channel_name: EnumProperty( source_channel_name: EnumProperty(
@@ -288,6 +294,7 @@ class VSETB_OT_scene_cut_detection(Operator):
row.prop(self, 'movie_type', expand=True) row.prop(self, 'movie_type', expand=True)
if self.movie_type == 'ANIMATED': if self.movie_type == 'ANIMATED':
col.prop(self, 'animated_threshold') col.prop(self, 'animated_threshold')
col.prop(self, 'known_cuts')
else: else:
col.prop(self, 'still_threshold') 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_start', text='Frame Split First')
split_col.prop(self, 'frame_end', text='Last') 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): def execute(self, context):
if self.source_channel_name == self.destination_channel_name: if self.source_channel_name == self.destination_channel_name:
self.report({"ERROR"}, 'Source and Destination cannot be the same channel') self.report({"ERROR"}, 'Source and Destination cannot be the same channel')
return {'CANCELLED'} 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) context.window_manager.modal_handler_add(self)
return {'PASS_THROUGH'} return {'PASS_THROUGH'}
@@ -314,6 +395,9 @@ class VSETB_OT_scene_cut_detection(Operator):
if old_strip.name.startswith("tmp_shot_"): if old_strip.name.startswith("tmp_shot_"):
scn.sequence_editor.strips.remove(old_strip) 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) strips = get_strips(channel=self.source_channel_name)
i = 1 i = 1
+316 -178
View File
@@ -1,22 +1,29 @@
import os import os
from os.path import expandvars from os.path import expandvars
from pathlib import Path from pathlib import Path
from pprint import pprint from pprint import pprint
import traceback
import webbrowser import webbrowser
from functools import partial from functools import partial
import bpy import bpy
from bpy.types import (Operator, ) from bpy.types import (
from bpy.props import (BoolProperty, EnumProperty, StringProperty) Operator,
)
from bpy.props import BoolProperty, EnumProperty, StringProperty
from vse_toolbox.constants import (ASSET_PREVIEWS, PREVIEWS_DIR, ASSET_ITEMS) 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.bl_utils import (get_addon_prefs, get_scene_settings) from vse_toolbox.sequencer_utils import (
from vse_toolbox.file_utils import (norm_name, expand) 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 from bpy.app.handlers import persistent
@@ -29,7 +36,7 @@ class VSETB_OT_tracker_connect(Operator):
def poll(cls, context): def poll(cls, context):
prefs = get_addon_prefs() prefs = get_addon_prefs()
if prefs.tracker: if prefs.tracker:
return True return True
def execute(self, context): def execute(self, context):
@@ -37,13 +44,17 @@ class VSETB_OT_tracker_connect(Operator):
settings = get_scene_settings() settings = get_scene_settings()
try: try:
prefs.tracker.connect() 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"} return {"FINISHED"}
except Exception as e: except Exception as e:
print('e: ', e) print("e: ", e)
self.report({'ERROR'}, f'Cannot connect to tracker, check login and password') self.report(
{"ERROR"}, f"Cannot connect to tracker, check login and password"
)
return {"CANCELLED"} return {"CANCELLED"}
class VSETB_OT_load_assets(Operator): class VSETB_OT_load_assets(Operator):
bl_idname = "vse_toolbox.load_assets" bl_idname = "vse_toolbox.load_assets"
@@ -61,40 +72,46 @@ class VSETB_OT_load_assets(Operator):
settings = get_scene_settings() settings = get_scene_settings()
prefs = get_addon_prefs() prefs = get_addon_prefs()
tracker = prefs.tracker tracker = prefs.tracker
tracker.admin_connect() tracker.admin_connect()
project = settings.active_project project = settings.active_project
project.assets.clear() project.assets.clear()
assets = tracker.get_assets(project['id']) assets = tracker.get_assets(project["id"])
if not assets: 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: for asset_data in assets:
asset = project.assets.add() asset = project.assets.add()
asset.name = asset_data['id'] asset.name = asset_data["id"]
asset.norm_name = norm_name(asset_data['name'], separator=' ', format=str.lower) asset.norm_name = norm_name(
asset_data["name"], separator=" ", format=str.lower
)
asset.tracker_name = asset_data['name'] asset.tracker_name = asset_data["name"]
asset.id = asset_data['id'] asset.id = asset_data["id"]
asset.asset_type = asset_data['asset_type'] asset.asset_type = asset_data["asset_type"]
#for key, value in asset_data.get('data', {}).items():
asset['metadata'] = asset_data.get('data', {})
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: if preview_id:
asset.preview = 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) tracker.download_preview(preview_id, preview_path)
if preview_id not in ASSET_PREVIEWS: 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() 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"} return {"FINISHED"}
@@ -119,8 +136,11 @@ class VSETB_OT_load_projects(Operator):
@staticmethod @staticmethod
def hex_to_rgb(color_str): def hex_to_rgb(color_str):
# supports '123456', '#123456' and '0x123456' # supports '123456', '#123456' and '0x123456'
(r,g,b), a = map(lambda component: component / 255, bytes.fromhex(color_str[-6:])), 1.0 (r, g, b), a = (
return (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): def invoke(self, context, event):
self.ctrl = event.ctrl self.ctrl = event.ctrl
@@ -135,28 +155,28 @@ class VSETB_OT_load_projects(Operator):
tracker.admin_connect() tracker.admin_connect()
project_datas = tracker.get_projects() project_datas = tracker.get_projects()
for project_data in sorted(project_datas, key=lambda x: x['name']): for project_data in sorted(project_datas, key=lambda x: x["name"]):
project = settings.projects.get(project_data['name']) project = settings.projects.get(project_data["name"])
if not project: if not project:
project = settings.projects.add() project = settings.projects.add()
project.type = project_data['production_type'].upper().replace(' ', '') project.type = project_data["production_type"].upper().replace(" ", "")
project.name = project_data['name'] project.name = project_data["name"]
project.id = project_data['id'] project.id = project_data["id"]
if project.type == 'TVSHOW': if project.type == "TVSHOW":
episode_datas = tracker.get_episodes(project_data) episode_datas = tracker.get_episodes(project_data)
for episode_data in episode_datas: for episode_data in episode_datas:
episode = project.episodes.get(episode_data['name']) episode = project.episodes.get(episode_data["name"])
if not episode: if not episode:
episode = project.episodes.add() episode = project.episodes.add()
episode.name = episode_data['name'] episode.name = episode_data["name"]
episode.id = episode_data['id'] episode.id = episode_data["id"]
# Clear deleted episodes # 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): for ep in reversed(project.episodes):
if ep.name not in ep_names: if ep.name not in ep_names:
project.episodes.remove(list(project.episodes).index(ep)) project.episodes.remove(list(project.episodes).index(ep))
@@ -165,66 +185,68 @@ class VSETB_OT_load_projects(Operator):
if project.episodes: if project.episodes:
first_episode = project.episodes[0] first_episode = project.episodes[0]
project.sequences.clear() 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 = project.sequences.add()
sequence.name = seq['name'] sequence.name = seq["name"]
sequence.id = seq['id'] sequence.id = seq["id"]
else: else:
# Add sequences # Add sequences
sequences_data = tracker.get_sequences(project_data) sequences_data = tracker.get_sequences(project_data)
for sequence_data in sequences_data: for sequence_data in sequences_data:
sequence = project.sequences.get(sequence_data['name']) sequence = project.sequences.get(sequence_data["name"])
if not sequence: if not sequence:
sequence = project.sequences.add() sequence = project.sequences.add()
sequence.name = sequence_data['name'] sequence.name = sequence_data["name"]
sequence.id = sequence_data['id'] sequence.id = sequence_data["id"]
project.metadata_types.clear() project.metadata_types.clear()
for metadata_data in tracker.get_metadata_types(project_data): for metadata_data in tracker.get_metadata_types(project_data):
#pprint(metadata_data) # pprint(metadata_data)
metadata_type = project.metadata_types.add() metadata_type = project.metadata_types.add()
metadata_type.name = metadata_data['name'] metadata_type.name = metadata_data["name"]
metadata_type.field_name = metadata_data['field_name'] metadata_type.field_name = metadata_data["field_name"]
metadata_type.data_type = metadata_data['data_type'] metadata_type.data_type = metadata_data["data_type"]
#metadata_type['choices'] = metadata_data['choices'] # metadata_type['choices'] = metadata_data['choices']
if prefs.sort_metadata_items: 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 = metadata_type.choices.add()
choice_item.name = choice 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() project.task_statuses.clear()
for status_data in tracker.get_task_statuses(project_data): for status_data in tracker.get_task_statuses(project_data):
#print(metadata_data) # print(metadata_data)
task_status = project.task_statuses.add() task_status = project.task_statuses.add()
task_status.name = status_data['short_name'].upper() task_status.name = status_data["short_name"].upper()
task_status.is_done = status_data.get('is_done', False) task_status.is_done = status_data.get("is_done", False)
project.task_types.clear() project.task_types.clear()
for task_type_data in tracker.get_shot_task_types(project_data): for task_type_data in tracker.get_shot_task_types(project_data):
task_type = project.task_types.add() task_type = project.task_types.add()
task_type.name = task_type_data['name'] task_type.name = task_type_data["name"]
task_type.id = task_type_data['id'] task_type.id = task_type_data["id"]
task_type.color = self.hex_to_rgb(task_type_data['color'])[:3] task_type.color = self.hex_to_rgb(task_type_data["color"])[:3]
project.set_shot_tasks() project.set_shot_tasks()
project.asset_types.clear() project.asset_types.clear()
for asset_type_data in tracker.get_asset_types(project_data): for asset_type_data in tracker.get_asset_types(project_data):
asset_type = project.asset_types.add() asset_type = project.asset_types.add()
asset_type.name = asset_type_data['name'] asset_type.name = asset_type_data["name"]
project.set_spreadsheet() project.set_spreadsheet()
# Remove deleted projects # 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): for project in reversed(settings.projects):
if project.name not in project_names: if project.name not in project_names:
settings.projects.remove(list(settings.projects).index(project)) 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 # ``load_projects`` can also be called programmatically (for example
# from the upload dialog), bypassing ``invoke`` and therefore not # from the upload dialog), bypassing ``invoke`` and therefore not
# initializing ``self.ctrl``. # 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() 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 settings.project_name = prev_project_name
#if settings.active_project: # if settings.active_project:
# settings.active_project.set_strip_metadata() # 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): class VSETB_OT_new_episode(Operator):
@@ -254,7 +276,7 @@ class VSETB_OT_new_episode(Operator):
bl_description = "Add new Episode to Project" bl_description = "Add new Episode to Project"
bl_options = {"REGISTER", "UNDO"} bl_options = {"REGISTER", "UNDO"}
episode_name : StringProperty(name="Episode Name", default="") episode_name: StringProperty(name="Episode Name", default="")
@classmethod @classmethod
def poll(cls, context): def poll(cls, context):
@@ -263,7 +285,7 @@ class VSETB_OT_new_episode(Operator):
def invoke(self, context, event): def invoke(self, context, event):
scn = context.scene scn = context.scene
settings = get_scene_settings() settings = get_scene_settings()
return context.window_manager.invoke_props_dialog(self) return context.window_manager.invoke_props_dialog(self)
def execute(self, context): def execute(self, context):
@@ -273,25 +295,25 @@ class VSETB_OT_new_episode(Operator):
project = settings.active_project project = settings.active_project
if not project: if not project:
self.report({'ERROR'}, 'No active project') self.report({"ERROR"}, "No active project")
return {"CANCELLED"} return {"CANCELLED"}
episode_name = project.episode_template.format(index=int(self.episode_name)) episode_name = project.episode_template.format(index=int(self.episode_name))
episode_data = tracker.get_episode(episode_name, project=project.id) episode_data = tracker.get_episode(episode_name, project=project.id)
if episode_data: if episode_data:
self.report({'ERROR'}, f'Episode {episode_name} already exists') self.report({"ERROR"}, f"Episode {episode_name} already exists")
return {"CANCELLED"} return {"CANCELLED"}
episode_data = tracker.new_episode(episode_name, project=project.id) episode_data = tracker.new_episode(episode_name, project=project.id)
# Add to local episode collection # Add to local episode collection
episode = project.episodes.add() episode = project.episodes.add()
episode.name = episode_data['name'] episode.name = episode_data["name"]
episode.id = episode_data['id'] 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): class VSETB_OT_upload_to_tracker(Operator):
@@ -307,8 +329,8 @@ class VSETB_OT_upload_to_tracker(Operator):
@classmethod @classmethod
def poll(cls, context): def poll(cls, context):
if not get_strips(channel='Shots', selected_only=True): if not get_strips(channel="Shots", selected_only=True):
cls.poll_message_set('Select at least one shot strip') cls.poll_message_set("Select at least one shot strip")
return False return False
return True return True
@@ -327,7 +349,7 @@ class VSETB_OT_upload_to_tracker(Operator):
bpy.ops.vse_toolbox.load_projects() bpy.ops.vse_toolbox.load_projects()
self.project = settings.active_project 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) return context.window_manager.invoke_props_dialog(self, width=350)
@@ -337,36 +359,36 @@ class VSETB_OT_upload_to_tracker(Operator):
layout = self.layout layout = self.layout
col = layout.column() col = layout.column()
col.use_property_split = True col.use_property_split = True
col.use_property_decorate = False 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: if upload_to_tracker.render_strips:
col.use_property_split = False col.use_property_split = False
col.prop(upload_to_tracker, 'render_strip_template', text='') col.prop(upload_to_tracker, "render_strip_template", text="")
col.use_property_split = True col.use_property_split = True
col.separator() col.separator()
col.prop(upload_to_tracker, 'task', text='Task') col.prop(upload_to_tracker, "task", text="Task")
col.prop(upload_to_tracker, 'status', text='Status') col.prop(upload_to_tracker, "status", text="Status")
col.prop(upload_to_tracker, 'comment', text='Comment') col.prop(upload_to_tracker, "comment", text="Comment")
row = col.row(heading='Add Preview') row = col.row(heading="Add Preview")
row.prop(upload_to_tracker, 'add_preview', text='') row.prop(upload_to_tracker, "add_preview", text="")
row.prop(upload_to_tracker, 'preview_mode', text='') row.prop(upload_to_tracker, "preview_mode", text="")
if upload_to_tracker.add_preview and not upload_to_tracker.render_strips: if upload_to_tracker.add_preview and not upload_to_tracker.render_strips:
col.use_property_split = False col.use_property_split = False
col.prop(self.project, 'render_video_strip_template', text='') col.prop(self.project, "render_video_strip_template", text="")
col.use_property_split = True col.use_property_split = True
col.separator() col.separator()
col.prop(upload_to_tracker, 'custom_data', text='Custom Data') 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, "update_frames", text="Update frames")
col.prop(upload_to_tracker, 'casting', text='Casting') col.prop(upload_to_tracker, "casting", text="Casting")
col.prop(upload_to_tracker, 'tasks_comment', text='Tasks Comment') 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, "set_main_preview", text="Set Main Preview")
def execute(self, context): def execute(self, context):
#self.report({'ERROR'}, f'Export not implemented yet.') # self.report({'ERROR'}, f'Export not implemented yet.')
prefs = get_addon_prefs() prefs = get_addon_prefs()
settings = get_scene_settings() settings = get_scene_settings()
tracker = prefs.tracker tracker = prefs.tracker
@@ -380,9 +402,9 @@ class VSETB_OT_upload_to_tracker(Operator):
episode = settings.active_episode.id episode = settings.active_episode.id
format_data = {**settings.format_data, **self.project.format_data} format_data = {**settings.format_data, **self.project.format_data}
status = upload_to_tracker.status status = upload_to_tracker.status
keep_current_status = status == 'CURRENT' keep_current_status = status == "CURRENT"
if keep_current_status: if keep_current_status:
status = None status = None
@@ -393,14 +415,14 @@ class VSETB_OT_upload_to_tracker(Operator):
if keep_current_status: if keep_current_status:
done_statuses = [s for s in self.project.task_statuses if s.is_done] done_statuses = [s for s in self.project.task_statuses if s.is_done]
default_new_task_status = next( 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, 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: if not shot_strips:
self.report({'WARNING'}, 'Select at least one shot strip to upload') self.report({"WARNING"}, "Select at least one shot strip to upload")
return {'CANCELLED'} return {"CANCELLED"}
context.window_manager.progress_begin(0, len(shot_strips)) 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) context.window_manager.progress_update(i)
strip_settings = strip.vsetb_strip_settings strip_settings = strip.vsetb_strip_settings
sequence_name = get_strip_sequence_name(strip) sequence_name = get_strip_sequence_name(strip)
if sequence_name == 'NoSequence': if sequence_name == "NoSequence":
self.report({'WARNING'}, f"Strip '{strip.name}' has no sequence — skipped") self.report(
{"WARNING"}, f"Strip '{strip.name}' has no sequence — skipped"
)
continue continue
shot_name = strip.name shot_name = strip.name
sequence = tracker.get_sequence(sequence_name, episode=episode)
metadata = strip_settings.metadata.to_dict(use_name=False) metadata = strip_settings.metadata.to_dict(use_name=False)
#print(metadata) # print(metadata)
if not sequence: try:
self.report({"INFO"}, f'Create sequence {sequence_name} in Kitsu') sequence = tracker.get_sequence(sequence_name, episode=episode)
sequence = tracker.new_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) shot = tracker.get_shot(shot_name, sequence=sequence)
if not shot: if not shot:
self.report({"INFO"}, f'Create shot {shot_name} in Kitsu') self.report({"INFO"}, f"Create shot {shot_name} in Kitsu")
shot = tracker.new_shot(shot_name, sequence=sequence) shot = tracker.new_shot(shot_name, sequence=sequence)
task = tracker.get_task(upload_to_tracker.task, entity=shot) task = tracker.get_task(upload_to_tracker.task, entity=shot)
if not task: if not task:
task = tracker.new_task( task = tracker.new_task(
shot, shot,
task_type=upload_to_tracker.task, task_type=upload_to_tracker.task,
status=default_new_task_status or status, 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 preview = None
if upload_to_tracker.add_preview: if upload_to_tracker.add_preview:
strip_data = {**format_data, **strip_settings.format_data} strip_data = {**format_data, **strip_settings.format_data}
if upload_to_tracker.render_strips: if upload_to_tracker.render_strips:
preview_template = expand(upload_to_tracker.render_strip_template, **project_templates) preview_template = expand(
strip_data['ext'] = 'mov' upload_to_tracker.render_strip_template, **project_templates
)
strip_data["ext"] = "mov"
else: 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 = preview_template.format(**strip_data)
preview = Path(os.path.abspath(bpy.path.abspath(preview))) 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: if upload_to_tracker.render_strips:
render_strip(strip, preview, attributes=render_attrs) render_strip(strip, preview, attributes=render_attrs)
#print(preview) # print(preview)
if not preview.exists(): if not preview.exists():
print(f'The preview {preview} not exists') print(f"The preview {preview} not exists")
preview = None preview = None
elif task.get('last_comment') and task['last_comment']['previews']: elif task.get("last_comment") and task["last_comment"]["previews"]:
if upload_to_tracker.preview_mode == 'REPLACE': if upload_to_tracker.preview_mode == "REPLACE":
tracker.remove_comment(task['last_comment']) try:
elif upload_to_tracker.preview_mode == 'ONLY_NEW': 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 preview = None
comment_data = None comment_data = None
preview_data = None preview_data = None
if status or upload_to_tracker.comment or preview: 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: if preview:
print('Upload preview from', preview) print("Upload preview from", preview)
preview_data = tracker.new_preview( try:
task=task, preview_data = tracker.new_preview(
comment=comment_data, task=task, comment=comment_data, preview=preview
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: 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 = {} params = {}
if upload_to_tracker.custom_data: if upload_to_tracker.custom_data:
params['custom_data'] = metadata params["custom_data"] = metadata
params['description'] = strip_settings.description params["description"] = strip_settings.description
if upload_to_tracker.update_frames: if upload_to_tracker.update_frames:
# Blender's frame_final_end is exclusive, while Kitsu's # Blender's frame_final_end is exclusive, while Kitsu's
# frame_out is inclusive. Keep both the explicit range and # frame_out is inclusive. Keep both the explicit range and
# the duration in sync with the uploaded strip. # the duration in sync with the uploaded strip.
params['frames'] = strip.frame_final_duration params["frames"] = strip.frame_final_duration
params.setdefault('custom_data', {}).update({ params.setdefault("custom_data", {}).update(
'frame_in': strip.frame_final_start, {
'frame_out': strip.frame_final_end - 1, "frame_in": strip.frame_final_start,
}) "frame_out": strip.frame_final_end - 1,
}
)
if params: 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: if upload_to_tracker.casting:
casting = [{'asset_id': a.id, 'nb_occurences': a.instance} for a in strip_settings.casting] casting = [
tracker.update_casting(shot, 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: if upload_to_tracker.tasks_comment:
for task_type in self.project.task_types: 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: if not task_settings.comment:
continue continue
tracker_task = tracker.get_task(task_type.name, entity=shot) try:
if not tracker_task: tracker_task = tracker.get_task(task_type.name, entity=shot)
tracker_task = tracker.new_task(shot, task_type=task_type.name) if not tracker_task:
tracker_task = tracker.new_task(
shot, task_type=task_type.name
)
last_comment = tracker_task.get('last_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 '') last_comment_text = (
if last_comment_text != task_settings.comment: last_comment.get("text", "")
tracker.new_comment(tracker_task, comment=task_settings.comment) 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() context.window_manager.progress_end()
return {"FINISHED"} return {"FINISHED"}
class VSETB_OT_open_shot_on_tracker(Operator): class VSETB_OT_open_shot_on_tracker(Operator):
bl_idname = "vse_toolbox.open_shot_on_tracker" bl_idname = "vse_toolbox.open_shot_on_tracker"
@@ -524,8 +655,8 @@ class VSETB_OT_open_shot_on_tracker(Operator):
@classmethod @classmethod
def poll(cls, context): def poll(cls, context):
if not get_strips(channel='Shots', selected_only=True): if not get_strips(channel="Shots", selected_only=True):
cls.poll_message_set('No active Shots strip') cls.poll_message_set("No active Shots strip")
return return
prefs = get_addon_prefs() prefs = get_addon_prefs()
@@ -535,8 +666,8 @@ class VSETB_OT_open_shot_on_tracker(Operator):
def execute(self, context): def execute(self, context):
prefs = get_addon_prefs() prefs = get_addon_prefs()
tracker = prefs.tracker 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]) url = tracker.get_shots_search_url([s.name for s in strips])
webbrowser.open_new_tab(url) webbrowser.open_new_tab(url)
@@ -547,10 +678,15 @@ class VSETB_OT_open_shot_on_tracker(Operator):
@persistent @persistent
def set_asset_items(scene=None): def set_asset_items(scene=None):
ASSET_ITEMS.clear() ASSET_ITEMS.clear()
settings = get_scene_settings() settings = get_scene_settings()
if settings.active_project: 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 = ( classes = (
@@ -559,9 +695,10 @@ classes = (
VSETB_OT_new_episode, VSETB_OT_new_episode,
VSETB_OT_tracker_connect, VSETB_OT_tracker_connect,
VSETB_OT_upload_to_tracker, VSETB_OT_upload_to_tracker,
VSETB_OT_open_shot_on_tracker VSETB_OT_open_shot_on_tracker,
) )
def register(): def register():
if not bpy.app.background: if not bpy.app.background:
bpy.app.handlers.load_post.append(set_asset_items) bpy.app.handlers.load_post.append(set_asset_items)
@@ -569,9 +706,10 @@ def register():
for cls in classes: for cls in classes:
bpy.utils.register_class(cls) bpy.utils.register_class(cls)
def unregister(): def unregister():
for cls in reversed(classes): for cls in reversed(classes):
bpy.utils.unregister_class(cls) bpy.utils.unregister_class(cls)
if not bpy.app.background: if not bpy.app.background:
bpy.app.handlers.load_post.remove(set_asset_items) bpy.app.handlers.load_post.remove(set_asset_items)
+17 -2
View File
@@ -7,6 +7,7 @@ import urllib3
import traceback import traceback
import time import time
import uuid import uuid
from functools import partial
from pprint import pprint from pprint import pprint
from bpy.props import PointerProperty, StringProperty from bpy.props import PointerProperty, StringProperty
@@ -255,6 +256,9 @@ class Kitsu(Tracker):
task = self.get_id(task) task = self.get_id(task)
return gazu.task.get_last_comment_for_task(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): def get_last_comment_with_preview(self, task):
task = self.get_id(task) task = self.get_id(task)
comments = gazu.task.all_comments_for_task(task) comments = gazu.task.all_comments_for_task(task)
@@ -292,8 +296,19 @@ class Kitsu(Tracker):
return task return task
def set_main_preview(self, preview_data): def set_main_preview(self, preview_data, retries=3):
gazu.task.set_main_preview(preview_data) 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): def new_preview(self, task, comment, preview, set_main_preview=False):
#print('new_preview', task, comment, preview, set_main_preview) #print('new_preview', task, comment, preview, set_main_preview)
+123
View File
@@ -7,6 +7,129 @@ import bpy
from vse_toolbox import bl_utils 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): 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. """Launch ffmpeg command to detect changing frames from a movie strip.