scene cut detection
This commit is contained in:
+85
-1
@@ -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
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
Reference in New Issue
Block a user