From d67265aa397abcf16894800fe579180bbf147a70 Mon Sep 17 00:00:00 2001 From: Hare Date: Thu, 19 Feb 2026 12:55:30 +0900 Subject: [PATCH] =?UTF-8?q?UI=E5=91=A8=E3=82=8A=E3=81=AE=E6=94=B9=E5=96=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- operators/apply_blur.py | 241 +++++++++++++++++++++++-------------- operators/generate_mask.py | 20 +-- panels/vse_panel.py | 51 +++++--- 3 files changed, 197 insertions(+), 115 deletions(-) diff --git a/operators/apply_blur.py b/operators/apply_blur.py index 69e9ede..95aed25 100644 --- a/operators/apply_blur.py +++ b/operators/apply_blur.py @@ -50,46 +50,28 @@ def _set_strip_source(strip, filepath: str): _reload_movie_strip(strip) -class SEQUENCER_OT_bake_and_swap_blur_source(Operator): - """Bake masked blur and replace active strip source with baked video.""" +def _start_bake_impl(operator, context, force: bool = False): + """Bakeの共通実装。force=True でキャッシュを無視して再Bakeする。""" + seq_editor = context.scene.sequence_editor + scene = context.scene + video_strip = seq_editor.active_strip - bl_idname = "sequencer.bake_and_swap_blur_source" - bl_label = "Bake & Swap Source" - bl_description = "Bake masked blur to video and swap active strip source" - bl_options = {"REGISTER", "UNDO"} + video_path = bpy.path.abspath(video_strip.filepath) + detections_path = get_detections_path_for_strip(video_strip.name) + if not os.path.exists(video_path): + operator.report({"ERROR"}, f"Source video not found: {video_path}") + return {"CANCELLED"} + if not os.path.exists(detections_path): + operator.report({"ERROR"}, f"Detection cache not found: {detections_path}") + return {"CANCELLED"} - @classmethod - def poll(cls, context): - if not context.scene.sequence_editor: - return False - # Prevent overlapping heavy tasks - if get_mask_generator().is_running: - return False - if get_bake_generator().is_running: - return False - strip = context.scene.sequence_editor.active_strip - return bool(strip and strip.type == "MOVIE") + bake_format = scene.facemask_bake_format + output_path = _output_path(video_strip, detections_path, bake_format) + blur_size = int(scene.facemask_bake_blur_size) + display_scale = float(scene.facemask_bake_display_scale) - def execute(self, context): - seq_editor = context.scene.sequence_editor - scene = context.scene - video_strip = seq_editor.active_strip - - video_path = bpy.path.abspath(video_strip.filepath) - detections_path = get_detections_path_for_strip(video_strip.name) - if not os.path.exists(video_path): - self.report({"ERROR"}, f"Source video not found: {video_path}") - return {"CANCELLED"} - if not os.path.exists(detections_path): - self.report({"ERROR"}, f"Detection cache not found: {detections_path}") - return {"CANCELLED"} - - bake_format = scene.facemask_bake_format - output_path = _output_path(video_strip, detections_path, bake_format) - blur_size = int(scene.facemask_bake_blur_size) - display_scale = float(scene.facemask_bake_display_scale) - - # Reuse baked cache when parameters match and file still exists. + if not force: + # パラメータが一致するキャッシュがあればswapのみ cached_baked_path = video_strip.get(KEY_BAKED) cached_format = video_strip.get(KEY_FORMAT) cached_blur_size = video_strip.get(KEY_BLUR_SIZE) @@ -112,66 +94,141 @@ class SEQUENCER_OT_bake_and_swap_blur_source(Operator): if video_strip.get(KEY_MODE) != "baked": video_strip[KEY_MODE] = "baked" _set_strip_source(video_strip, cached_baked_path) - self.report({"INFO"}, "Using cached baked blur") + operator.report({"INFO"}, "Using cached baked blur") return {"FINISHED"} - bake_generator = get_bake_generator() - wm = context.window_manager + bake_generator = get_bake_generator() + wm = context.window_manager - def on_complete(status, data): - strip = context.scene.sequence_editor.strips.get(video_strip.name) - if not strip: - print(f"[FaceMask] Bake complete but strip no longer exists: {video_strip.name}") - return + def on_complete(status, data): + strip = context.scene.sequence_editor.strips.get(video_strip.name) + if not strip: + print(f"[FaceMask] Bake complete but strip no longer exists: {video_strip.name}") + return - if status == "done": - result_path = data or output_path - original_path = strip.get(KEY_ORIGINAL) - current_mode = strip.get(KEY_MODE, "original") - if not original_path or current_mode != "baked": - strip[KEY_ORIGINAL] = video_path - strip[KEY_BAKED] = result_path - strip[KEY_MODE] = "baked" - strip[KEY_FORMAT] = bake_format - strip[KEY_BLUR_SIZE] = blur_size - strip[KEY_DISPLAY_SCALE] = display_scale - _set_strip_source(strip, result_path) - print(f"[FaceMask] Bake completed and source swapped: {result_path}") - elif status == "error": - print(f"[FaceMask] Bake failed: {data}") - elif status == "cancelled": - print("[FaceMask] Bake cancelled") + if status == "done": + result_path = data or output_path + original_path = strip.get(KEY_ORIGINAL) + current_mode = strip.get(KEY_MODE, "original") + if not original_path or current_mode != "baked": + strip[KEY_ORIGINAL] = video_path + strip[KEY_BAKED] = result_path + strip[KEY_MODE] = "baked" + strip[KEY_FORMAT] = bake_format + strip[KEY_BLUR_SIZE] = blur_size + strip[KEY_DISPLAY_SCALE] = display_scale + _set_strip_source(strip, result_path) + print(f"[FaceMask] Bake completed and source swapped: {result_path}") + elif status == "error": + print(f"[FaceMask] Bake failed: {data}") + elif status == "cancelled": + print("[FaceMask] Bake cancelled") - for area in context.screen.areas: - if area.type == "SEQUENCE_EDITOR": - area.tag_redraw() + for area in context.screen.areas: + if area.type == "SEQUENCE_EDITOR": + area.tag_redraw() - def on_progress(current, total): - wm.bake_progress = current - wm.bake_total = max(total, 1) - for area in context.screen.areas: - if area.type == "SEQUENCE_EDITOR": - area.tag_redraw() + def on_progress(current, total): + wm.bake_progress = current + wm.bake_total = max(total, 1) + for area in context.screen.areas: + if area.type == "SEQUENCE_EDITOR": + area.tag_redraw() - wm.bake_progress = 0 - wm.bake_total = 1 + wm.bake_progress = 0 + wm.bake_total = 1 - try: - bake_generator.start( - video_path=video_path, - detections_path=detections_path, - output_path=output_path, - blur_size=blur_size, - display_scale=display_scale, - fmt=bake_format.lower(), - on_complete=on_complete, - on_progress=on_progress, - ) - except Exception as e: - self.report({"ERROR"}, f"Failed to start bake: {e}") - return {"CANCELLED"} + try: + bake_generator.start( + video_path=video_path, + detections_path=detections_path, + output_path=output_path, + blur_size=blur_size, + display_scale=display_scale, + fmt=bake_format.lower(), + on_complete=on_complete, + on_progress=on_progress, + ) + except Exception as e: + operator.report({"ERROR"}, f"Failed to start bake: {e}") + return {"CANCELLED"} - self.report({"INFO"}, "Started blur bake in background") + operator.report({"INFO"}, "Started blur bake in background") + return {"FINISHED"} + + +class SEQUENCER_OT_bake_and_swap_blur_source(Operator): + """Bake masked blur (reuse cache if parameters match).""" + + bl_idname = "sequencer.bake_and_swap_blur_source" + bl_label = "Bake" + bl_description = "Bake masked blur to video and swap active strip source" + bl_options = {"REGISTER", "UNDO"} + + @classmethod + def poll(cls, context): + if not context.scene.sequence_editor: + return False + if get_mask_generator().is_running: + return False + if get_bake_generator().is_running: + return False + strip = context.scene.sequence_editor.active_strip + return bool(strip and strip.type == "MOVIE") + + def execute(self, context): + return _start_bake_impl(self, context, force=False) + + +class SEQUENCER_OT_force_rebake_blur(Operator): + """Force re-bake, ignoring any existing cached result.""" + + bl_idname = "sequencer.force_rebake_blur" + bl_label = "Re-bake" + bl_description = "Discard cached bake and re-bake from scratch" + bl_options = {"REGISTER", "UNDO"} + + @classmethod + def poll(cls, context): + if not context.scene.sequence_editor: + return False + if get_mask_generator().is_running: + return False + if get_bake_generator().is_running: + return False + strip = context.scene.sequence_editor.active_strip + return bool(strip and strip.type == "MOVIE") + + def execute(self, context): + return _start_bake_impl(self, context, force=True) + + +class SEQUENCER_OT_swap_to_baked_blur(Operator): + """Swap active strip source to already-baked video (no re-bake).""" + + bl_idname = "sequencer.swap_to_baked_blur" + bl_label = "Swap to Baked" + bl_description = "Switch active strip source to the baked video without re-baking" + bl_options = {"REGISTER", "UNDO"} + + @classmethod + def poll(cls, context): + if not context.scene.sequence_editor: + return False + if get_bake_generator().is_running: + return False + strip = context.scene.sequence_editor.active_strip + if not strip or strip.type != "MOVIE": + return False + baked_path = strip.get(KEY_BAKED) + return bool(baked_path and os.path.exists(baked_path)) + + def execute(self, context): + strip = context.scene.sequence_editor.active_strip + baked_path = strip.get(KEY_BAKED) + _set_strip_source(strip, baked_path) + strip[KEY_MODE] = "baked" + self.report({"INFO"}, "Swapped to baked source") return {"FINISHED"} @@ -179,7 +236,7 @@ class SEQUENCER_OT_restore_original_source(Operator): """Restore active strip source filepath to original video.""" bl_idname = "sequencer.restore_original_source" - bl_label = "Restore Original Source" + bl_label = "Restore Original" bl_description = "Restore active strip to original source filepath" bl_options = {"REGISTER", "UNDO"} @@ -192,6 +249,8 @@ class SEQUENCER_OT_restore_original_source(Operator): strip = context.scene.sequence_editor.active_strip if not strip or strip.type != "MOVIE": return False + if strip.get(KEY_MODE, "original") == "original": + return False return bool(strip.get(KEY_ORIGINAL)) def execute(self, context): @@ -215,7 +274,7 @@ class SEQUENCER_OT_apply_mask_blur(Operator): bl_idname = "sequencer.apply_mask_blur" bl_label = "Apply Mask Blur" - bl_description = "Compatibility alias for Bake & Swap Source" + bl_description = "Compatibility alias for Bake" bl_options = {"REGISTER", "UNDO"} @classmethod @@ -246,6 +305,8 @@ class SEQUENCER_OT_cancel_bake_blur(Operator): classes = [ SEQUENCER_OT_bake_and_swap_blur_source, + SEQUENCER_OT_force_rebake_blur, + SEQUENCER_OT_swap_to_baked_blur, SEQUENCER_OT_restore_original_source, SEQUENCER_OT_cancel_bake_blur, SEQUENCER_OT_apply_mask_blur, diff --git a/operators/generate_mask.py b/operators/generate_mask.py index ba0455c..3919ad4 100644 --- a/operators/generate_mask.py +++ b/operators/generate_mask.py @@ -58,10 +58,16 @@ class SEQUENCER_OT_generate_face_mask(Operator): self.report({'INFO'}, f"Using cached detections from {output_dir}") return {'FINISHED'} - # Get frame range - start_frame = strip.frame_final_start - end_frame = strip.frame_final_end - fps = scene.render.fps / scene.render.fps_base + # 動画の実際のフレーム数を取得(Blenderプロジェクトのfpsと動画のfpsが + # 異なる場合にタイムライン上のフレーム数では不足するため) + import cv2 as _cv2 + _cap = _cv2.VideoCapture(video_path) + total_video_frames = int(_cap.get(_cv2.CAP_PROP_FRAME_COUNT)) + fps = _cap.get(_cv2.CAP_PROP_FPS) or (scene.render.fps / scene.render.fps_base) + _cap.release() + if total_video_frames <= 0: + self.report({'ERROR'}, f"Could not read frame count from video: {video_path}") + return {'CANCELLED'} # Start async generation generator = get_generator() @@ -105,7 +111,7 @@ class SEQUENCER_OT_generate_face_mask(Operator): # Initialize progress wm = context.window_manager wm.mask_progress = 0 - wm.mask_total = end_frame - start_frame + 1 + wm.mask_total = total_video_frames # Get parameters from scene properties conf_threshold = scene.facemask_conf_threshold @@ -115,8 +121,8 @@ class SEQUENCER_OT_generate_face_mask(Operator): generator.start( video_path=video_path, output_dir=output_dir, - start_frame=0, # Frame indices in video - end_frame=end_frame - start_frame, + start_frame=0, + end_frame=total_video_frames - 1, fps=fps, conf_threshold=conf_threshold, iou_threshold=iou_threshold, diff --git a/panels/vse_panel.py b/panels/vse_panel.py index 5b3d496..d162caf 100644 --- a/panels/vse_panel.py +++ b/panels/vse_panel.py @@ -216,7 +216,7 @@ class SEQUENCER_PT_face_mask(Panel): has_mask = bpy.path.abspath(detections_path) and os.path.exists( bpy.path.abspath(detections_path) ) - + if not has_mask: box.label(text="Generate detection cache first", icon='INFO') return @@ -227,24 +227,39 @@ class SEQUENCER_PT_face_mask(Panel): col.prop(context.scene, "facemask_bake_display_scale") col.prop(context.scene, "facemask_bake_format") - # Source status - source_mode = strip.get("facemask_source_mode", "original") - if source_mode == "baked": - box.label(text="Source: Baked", icon='CHECKMARK') - else: - box.label(text="Source: Original", icon='FILE_MOVIE') + box.separator() - # Bake and restore buttons - box.operator( - "sequencer.bake_and_swap_blur_source", - text="Bake & Swap Source", - icon='RENDER_STILL', - ) - box.operator( - "sequencer.restore_original_source", - text="Restore Original Source", - icon='LOOP_BACK', - ) + baked_path = strip.get("facemask_baked_filepath", "") + has_baked = bool(baked_path and os.path.exists(bpy.path.abspath(baked_path))) + source_mode = strip.get("facemask_source_mode", "original") + + if not has_baked: + # 初回: Bakeのみ + box.operator( + "sequencer.bake_and_swap_blur_source", + text="Bake", + icon='RENDER_STILL', + ) + else: + # Bake済み: ソース切り替え + Re-bake + row = box.row(align=True) + if source_mode == "baked": + row.operator( + "sequencer.restore_original_source", + text="Restore Original", + icon='LOOP_BACK', + ) + else: + row.operator( + "sequencer.swap_to_baked_blur", + text="Swap to Baked", + icon='PLAY', + ) + row.operator( + "sequencer.force_rebake_blur", + text="Re-bake", + icon='FILE_REFRESH', + ) # Registration