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,
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
+289 -151
View File
@@ -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.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.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 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
@@ -37,11 +44,15 @@ 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"}
@@ -66,35 +77,41 @@ class VSETB_OT_load_assets(Operator):
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']
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', {})
# for key, value in asset_data.get('data', {}).items():
asset["metadata"] = asset_data.get("data", {})
preview_id = asset_data.get('preview_file_id')
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):
@@ -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)
@@ -339,34 +361,34 @@ class VSETB_OT_upload_to_tracker(Operator):
col = layout.column()
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.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.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
@@ -382,7 +404,7 @@ class VSETB_OT_upload_to_tracker(Operator):
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,68 +485,163 @@ 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()
@@ -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()
@@ -536,7 +667,7 @@ class VSETB_OT_open_shot_on_tracker(Operator):
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)
@@ -550,7 +681,12 @@ def set_asset_items(scene=None):
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,6 +706,7 @@ def register():
for cls in classes:
bpy.utils.register_class(cls)
def unregister():
for cls in reversed(classes):
bpy.utils.unregister_class(cls)
+17 -2
View File
@@ -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)
+123
View File
@@ -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.