「動画を自動でシーンごとに検出し、FFmpegで分割保存するGUIツール」 。

2026年10月25日

概要

このプログラムは 「動画を自動でシーンごとに検出し、FFmpegで分割保存するGUIツール」 です。

単純な「フレーム差分」ではなく、HSV色空間(H・S・V)を比較し、フラッシュ除外と適応閾値判定まで行うため、一般的なFFmpegの select=’gt(scene,0.4)’ よりかなり誤検出が少ない構成になっています。

まず何ができる?

例えば動画が

  • 0:00~0:15 オープニング
  • 0:15~1:30 会話シーン
  • 1:30~2:00 場面転換
  • 2:00~5:00 別シーン

になっている場合、自動で切れ目を探して、

movie_scene_001.mp4

movie_scene_002.mp4

movie_scene_003.mp4

movie_scene_004.mp4

として保存します。

ffmpeg.exe の役割

このソフトの心臓部分です。

プログラム単体では動画を読めないため、

FFMPEG_PATH = os.path.join(SCRIPT_DIR, “ffmpeg.exe")

で同じフォルダの ffmpeg.exe を探しています。

例

SceneSplitter.exe

ffmpeg.exe

または

SceneSplitter.py

ffmpeg.exe

を同じ場所に置く必要があります。

起動直後

画面には2つのタブがあります。

タブ1

単一ファイル処理

動画1本だけ処理

タブ2

フォルダ一括処理

フォルダ内の動画を全部処理

単一ファイル処理タブ

① 動画ファイル

動画ファイル:

右の

参照…

ボタンを押して動画選択

例

movie.mp4

② 出力先フォルダ

出力先フォルダ:

切り出した動画の保存先

例

D:\output

検出・分割設定

検出感度(閾値)

スライダー

5 ←────→ 50

小さい値

10

など

高感度

細かい場面変化も検出

結果

シーン数が増える

大きい値

30

など

低感度

大きな変化だけ検出

結果

シーン数が減る

推奨

18

プログラム標準値

最小シーン長

例

0.5 秒

これ未満の短いカットは統合されます。

例えば

0.2秒

フラッシュ除外チェック

短いフラッシュ・照明変化を除外

ON推奨

例えば

カメラのストロボ

照明点滅

爆発エフェクト

などで誤検出しにくくなります。

分割出力方式

非常に重要

◎ 正確カット

reencode

内部

-c:v libx264

-crf 18

-c:a aac

を使用

メリット

カット位置が正確

デメリット

少し時間がかかる

おすすめ

✅ 普段はこちら

○ 高速無劣化

-c copy

内部

ffmpeg -c copy

でコピー

メリット

超高速

再エンコードなし

デメリット

数フレームずれる

これは

キーフレーム位置

でしか切れないため。

操作ボタン

① シーン検出を開始

最初に押すボタン

動作

動画解析

↓

シーン検出

↓

一覧表示

ログ例

動画情報: 1920×1080

FPS: 29.97

解析中…

② 検出シーンを分割保存

シーン検出後に有効になります。

押すと

Scene001.mp4

Scene002.mp4

Scene003.mp4

生成

中止

解析中

または

分割中

に押すと

self.cancel_event.set()

が呼ばれます。

現在の処理を止めます。

シーン一覧

検出後に表示

列

シーン

1

2

3

シーン番号

開始時刻

00:00:15.200

開始位置

終了時刻

00:01:26.500

終了位置

シーン長

71.3秒

感度(変化度)

例

25.6

大きいほど場面変化が大きい

出力ファイル名

例

movie_scene_003.mp4

右クリック

シーン一覧で右クリック

選択したシーンを削除

が使えます。

不要なシーンを分割対象から外せます。

フォルダ一括処理

複数動画をまとめて処理

対象フォルダ

例

D:\Videos

の中に

a.mp4

b.mp4

c.mp4

がある場合

全部自動処理

出力先

例

D:\output

結果

output

 ├ a_scenes

 ├ b_scenes

 └ c_scenes

全ファイルを一括処理

ボタン

① シーン検出を開始

押す

検出

② 検出シーンを分割保存

分割

↓

保存

まで自動実行

このソースで実際に使われるFFmpegコマンド

動画情報取得

ffmpeg -i movie.mp4

シーン解析用フレーム抽出

ffmpeg -i movie.mp4 ^

-vf scale=64:36,fps=24 ^

-f rawvideo ^

-pix_fmt rgb24 –

低解像度化して高速解析

正確分割

ffmpeg ^

-ss 10.000 ^

-i movie.mp4 ^

-t 5.000 ^

-c:v libx264 ^

-preset veryfast ^

-crf 18 ^

-c:a aac ^

-b:a 192k ^

output.mp4

高速無劣化分割

ffmpeg ^

-ss 10.000 ^

-i movie.mp4 ^

-t 5.000 ^

-c copy ^

プログラム

import os
import sys
import re
import math
import time
import shutil
import subprocess
import threading
from dataclasses import dataclass
from typing import List, Tuple, Optional

# GUIは標準ライブラリの tkinter のみを使用
import tkinter as tk
from tkinter import ttk, filedialog, messagebox

# NumPy があれば高速演算、無ければ Pure Python で動作
try:
    import numpy as np
    NUMPY_AVAILABLE = True
except ImportError:
    NUMPY_AVAILABLE = False
    np = None

# プログラムと同じフォルダー内の ffmpeg.exe を優先使用
SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__))
FFMPEG_PATH = os.path.join(SCRIPT_DIR, "ffmpeg.exe")

if not os.path.exists(FFMPEG_PATH):
    fallback_candidate = r"D:\opencode\scenessplit\ffmpeg.exe"
    if os.path.exists(fallback_candidate):
        FFMPEG_PATH = fallback_candidate

VIDEO_EXTENSIONS = {".mp4", ".mkv", ".mov", ".avi", ".flv", ".webm", ".ts", ".wmv", ".m4v"}

@dataclass
class SceneCut:
    frame_index: int
    timestamp_sec: float
    score: float

@dataclass
class SceneInfo:
    start_sec: float
    end_sec: float
    duration_sec: float
    score: float
    filename: str

def format_timestamp(seconds: float) -> str:
    """秒数を HH:MM:SS.mmm 形式に変換"""
    if seconds < 0:
        seconds = 0.0
    hrs = int(seconds // 3600)
    mins = int((seconds % 3600) // 60)
    secs = seconds % 60
    return f"{hrs:02d}:{mins:02d}:{secs:06.3f}"

def get_video_info(video_path: str, ffmpeg_path: str) -> Tuple[float, int, int, float]:
    """
    ffmpeg を使って動画の (fps, width, height, duration_sec) を取得
    """
    startupinfo = None
    if sys.platform == "win32":
        startupinfo = subprocess.STARTUPINFO()
        startupinfo.dwFlags |= subprocess.STARTF_USESHOWWINDOW
        startupinfo.wShowWindow = 0

    cmd = [ffmpeg_path, "-i", video_path]
    proc = subprocess.Popen(
        cmd,
        stdout=subprocess.PIPE,
        stderr=subprocess.PIPE,
        startupinfo=startupinfo,
        text=True,
        encoding="utf-8",
        errors="replace"
    )
    _, stderr = proc.communicate()

    duration_sec = 0.0
    dur_match = re.search(r"Duration:\s*(\d+):(\d+):(\d+\.?\d*)", stderr)
    if dur_match:
        h, m, s = dur_match.groups()
        duration_sec = int(h) * 3600 + int(m) * 60 + float(s)

    fps = 30.0
    width = 1280
    height = 720

    res_match = re.search(r",\s*(\d{2,5})x(\d{2,5})", stderr)
    if res_match:
        width = int(res_match.group(1))
        height = int(res_match.group(2))

    fps_match = re.search(r"(\d+(?:\.\d+)?)\s*fps", stderr)
    if fps_match:
        fps = float(fps_match.group(1))

    return fps, width, height, duration_sec


class PreciseSceneDetector:
    """
    高精度(24〜30fps)でフレームをデコード&縮小し、
    色相(H)、彩度(S)、明るさ(V)の比較、適応型基準値・フラッシュ除外フィルタ・近接マージ。
    """
    def __init__(
        self,
        ffmpeg_path: str = FFMPEG_PATH,
        threshold: float = 18.0,
        min_scene_len_sec: float = 0.5,
        down_w: int = 64,
        down_h: int = 36,
        target_fps: float = 24.0,
        flash_filter: bool = True,
        adaptive_window: int = 15,
        progress_callback = None,
        log_callback = None,
        cancel_event: threading.Event = None
    ):
        self.ffmpeg_path = ffmpeg_path
        self.threshold = threshold
        self.min_scene_len_sec = min_scene_len_sec
        self.down_w = down_w
        self.down_h = down_h
        self.target_fps = target_fps
        self.flash_filter = flash_filter
        self.adaptive_window = adaptive_window
        self.progress_callback = progress_callback
        self.log_callback = log_callback
        self.cancel_event = cancel_event

    def log(self, msg: str):
        if self.log_callback:
            self.log_callback(msg)

    def detect_scenes(self, video_path: str) -> List[Tuple[float, float, float]]:
        """
        [(start_sec, end_sec, score), ...] を返す
        """
        if not os.path.exists(self.ffmpeg_path):
            raise RuntimeError(f"ffmpeg.exe が見つかりません: {self.ffmpeg_path}")

        fps, orig_w, orig_h, duration_sec = get_video_info(video_path, self.ffmpeg_path)
        self.log(f"動画情報: {orig_w}x{orig_h}, FPS: {fps:.2f}, 長さ: {format_timestamp(duration_sec)}")

        sample_fps = min(fps, max(24.0, self.target_fps))
        if fps <= 30.0:
            sample_fps = fps

        total_sample_frames = int(duration_sec * sample_fps) if duration_sec > 0 else 0

        self.log(f"解析設定: {sample_fps:.2f} fps, 縮小サイズ {self.down_w}x{self.down_h}")
        self.log(f"検出閾値: {self.threshold:.1f}, 最小シーン長: {self.min_scene_len_sec:.1f}秒")

        cmd = [
            self.ffmpeg_path,
            "-i", video_path,
            "-vf", f"scale={self.down_w}:{self.down_h},fps={sample_fps}",
            "-f", "rawvideo",
            "-pix_fmt", "rgb24",
            "-vsync", "vfr",
            "-"
        ]

        startupinfo = None
        if sys.platform == "win32":
            startupinfo = subprocess.STARTUPINFO()
            startupinfo.dwFlags |= subprocess.STARTF_USESHOWWINDOW
            startupinfo.wShowWindow = 0

        stderr_logs = []
        proc = subprocess.Popen(
            cmd,
            stdout=subprocess.PIPE,
            stderr=subprocess.PIPE,
            startupinfo=startupinfo,
            bufsize=65536
        )

        def _drain_stderr():
            try:
                for line in iter(proc.stderr.readline, b''):
                    line_str = line.decode('utf-8', errors='replace').strip()
                    if line_str:
                        if len(stderr_logs) < 50:
                            stderr_logs.append(line_str)
                        else:
                            stderr_logs.pop(0)
                            stderr_logs.append(line_str)
            except Exception:
                pass

        stderr_thread = threading.Thread(target=_drain_stderr, daemon=True)
        stderr_thread.start()

        num_pixels = self.down_w * self.down_h
        frame_bytes = num_pixels * 3

        diff_scores: List[float] = []
        timestamps: List[float] = []
        frame_means: List[Tuple[float, float, float]] = []

        prev_hsv = None
        frame_idx = 0
        last_log_time = time.time()
        max_score = 0.0

        self.log("高精度フレーム解析中 (HSV特徴量の比較)...")

        while True:
            if self.cancel_event and self.cancel_event.is_set():
                proc.kill()
                self.log("解析が中断されました。")
                return []

            raw = proc.stdout.read(frame_bytes)
            if not raw or len(raw) < frame_bytes:
                break

            current_time = frame_idx / sample_fps
            timestamps.append(current_time)

            if NUMPY_AVAILABLE:
                rgb = np.frombuffer(raw, dtype=np.uint8).reshape((num_pixels, 3)).astype(np.float32)
                r, g, b = rgb[:, 0], rgb[:, 1], rgb[:, 2]

                v = np.maximum(np.maximum(r, g), b)
                mn = np.minimum(np.minimum(r, g), b)
                chroma = v - mn

                s = np.zeros_like(v)
                nonzero_v = v > 0
                s[nonzero_v] = (chroma[nonzero_v] * 255.0) / v[nonzero_v]

                h = np.zeros_like(v)
                nonzero_c = chroma > 0

                mask_r = nonzero_c & (v == r)
                mask_g = nonzero_c & (v == g)
                mask_b = nonzero_c & (v == b) & (~mask_r) & (~mask_g)

                h[mask_r] = ((30.0 * (g[mask_r] - b[mask_r]) / chroma[mask_r]) % 180.0)
                h[mask_g] = ((30.0 * (b[mask_g] - r[mask_g]) / chroma[mask_g] + 60.0) % 180.0)
                h[mask_b] = ((30.0 * (r[mask_b] - g[mask_b]) / chroma[mask_b] + 120.0) % 180.0)

                avg_h = float(np.mean(h))
                avg_s = float(np.mean(s))
                avg_v = float(np.mean(v))
                frame_means.append((avg_h, avg_s, avg_v))

                if prev_hsv is None:
                    score = 0.0
                else:
                    prev_h, prev_s, prev_v = prev_hsv
                    dh = np.abs(h - prev_h)
                    dh = np.minimum(dh, 180.0 - dh) * (255.0 / 90.0)
                    ds = np.abs(s - prev_s)
                    dv = np.abs(v - prev_v)
                    p_diff = 0.35 * dh + 0.25 * ds + 0.40 * dv
                    score = float(np.mean(p_diff))

                diff_scores.append(score)
                prev_hsv = (h, s, v)

            else:
                h_arr, s_arr, v_arr = [], [], []
                sum_h = sum_s = sum_v = 0.0

                for i in range(0, frame_bytes, 3):
                    r_val = raw[i]
                    g_val = raw[i+1]
                    b_val = raw[i+2]

                    v_val = r_val if r_val >= g_val and r_val >= b_val else (g_val if g_val >= b_val else b_val)
                    mn_val = r_val if r_val <= g_val and r_val <= b_val else (g_val if g_val <= b_val else b_val)
                    c_val = v_val - mn_val

                    s_val = (c_val * 255) // v_val if v_val > 0 else 0

                    if c_val == 0:
                        h_val = 0
                    elif v_val == r_val:
                        h_val = int(30 * (g_val - b_val) / c_val) % 180
                    elif v_val == g_val:
                        h_val = int(30 * (b_val - r_val) / c_val + 60) % 180
                    else:
                        h_val = int(30 * (r_val - g_val) / c_val + 120) % 180

                    h_arr.append(h_val)
                    s_arr.append(s_val)
                    v_arr.append(v_val)
                    sum_h += h_val
                    sum_s += s_val
                    sum_v += v_val

                avg_h = sum_h / num_pixels
                avg_s = sum_s / num_pixels
                avg_v = sum_v / num_pixels
                frame_means.append((avg_h, avg_s, avg_v))

                if prev_hsv is None:
                    score = 0.0
                else:
                    prev_h, prev_s, prev_v = prev_hsv
                    diff_sum = 0.0
                    for p in range(num_pixels):
                        dh = abs(h_arr[p] - prev_h[p])
                        if dh > 90:
                            dh = 180 - dh
                        diff_sum += (0.35 * (dh * 2.833) + 0.25 * abs(s_arr[p] - prev_s[p]) + 0.40 * abs(v_arr[p] - prev_v[p]))
                    score = diff_sum / num_pixels

                diff_scores.append(score)
                prev_hsv = (h_arr, s_arr, v_arr)

            if score > max_score:
                max_score = score

            frame_idx += 1

            now = time.time()
            if now - last_log_time >= 0.4 or frame_idx == total_sample_frames:
                last_log_time = now
                prog = (frame_idx / total_sample_frames * 100.0) if total_sample_frames > 0 else 0
                if self.progress_callback:
                    self.progress_callback(min(100.0, prog))
                if frame_idx % 120 == 0:
                    self.log(f"解析中: {frame_idx}/{total_sample_frames} フレーム ({prog:.1f}%) [最大変化度: {max_score:.1f}]")

        proc.stdout.close()
        proc.wait()

        total_frames = len(diff_scores)
        if total_frames == 0:
            err_msg = "\n".join(stderr_logs[-5:]) if stderr_logs else "フレームを取得できませんでした"
            self.log(f"エラー: 動画フレームが読み込めませんでした。\n{err_msg}")
            return []

        avg_score = sum(diff_scores) / total_frames
        self.log(f"【解析完了】取得フレーム: {total_frames}, 平均変化度: {avg_score:.2f}, 最大変化度: {max_score:.2f}")

        # 3. 適応型基準値およびフラッシュ除外フィルタ
        cuts: List[SceneCut] = []
        window = max(5, self.adaptive_window)
        half_w = window // 2

        for i in range(1, total_frames):
            score = diff_scores[i]
            start_w = max(0, i - half_w)
            end_w = min(total_frames, i + half_w + 1)
            local_avg = sum(diff_scores[start_w:end_w]) / (end_w - start_w)

            is_cut = False
            if score >= self.threshold:
                if (score - local_avg >= self.threshold * 0.25) or (score >= self.threshold * 1.3):
                    is_cut = True

            if is_cut:
                is_flash = False
                if self.flash_filter:
                    cur_v = frame_means[i][2]
                    if 1 <= i < total_frames - 1:
                        prev_m = frame_means[i-1]
                        next_m = frame_means[i+1]
                        if abs(cur_v - prev_m[2]) > 20 and abs(cur_v - next_m[2]) > 20:
                            dist = math.sqrt((prev_m[0]-next_m[0])**2 + (prev_m[1]-next_m[1])**2 + (prev_m[2]-next_m[2])**2)
                            if dist < 25.0:
                                is_flash = True

                    if not is_flash and 1 <= i < total_frames - 2:
                        prev_m = frame_means[i-1]
                        next2_m = frame_means[i+2]
                        if abs(cur_v - prev_m[2]) > 20:
                            dist = math.sqrt((prev_m[0]-next2_m[0])**2 + (prev_m[1]-next2_m[1])**2 + (prev_m[2]-next2_m[2])**2)
                            if dist < 25.0:
                                is_flash = True

                if is_flash:
                    continue

                cuts.append(SceneCut(frame_index=i, timestamp_sec=timestamps[i], score=score))

        # 4. 近接カットの統合 (Min Scene Length)
        min_frames = max(1, int(self.min_scene_len_sec * sample_fps))
        filtered_cuts: List[SceneCut] = []
        for cut in cuts:
            if not filtered_cuts:
                filtered_cuts.append(cut)
            else:
                last_cut = filtered_cuts[-1]
                if (cut.frame_index - last_cut.frame_index) < min_frames:
                    if cut.score > last_cut.score:
                        filtered_cuts[-1] = cut
                else:
                    filtered_cuts.append(cut)

        # 5. シーン区間 [(start, end, score), ...] の生成
        scenes: List[Tuple[float, float, float]] = []
        start_t = 0.0
        for cut in filtered_cuts:
            cut_t = cut.timestamp_sec
            if cut_t - start_t >= (self.min_scene_len_sec * 0.7):
                scenes.append((start_t, cut_t, cut.score))
                start_t = cut_t

        end_video_t = duration_sec if duration_sec > 0 else (timestamps[-1] if timestamps else 0.0)
        if end_video_t - start_t > 0.15:
            # 最後のシーンのスコアは直前のスコアまたは0.0
            last_sc = filtered_cuts[-1].score if filtered_cuts else 0.0
            scenes.append((start_t, end_video_t, last_sc))

        return scenes


def split_video_scenes(
    ffmpeg_path: str,
    video_path: str,
    scenes: List[Tuple[float, float]],
    out_dir: str,
    mode: str = "reencode",
    log_callback = None,
    progress_callback = None,
    cancel_event: threading.Event = None
) -> bool:
    """
    指定されたシーン区間リストを ffmpeg で正確に分割書き出し
    """
    os.makedirs(out_dir, exist_ok=True)
    base_name = os.path.splitext(os.path.basename(video_path))[0]
    ext = os.path.splitext(video_path)[1] or ".mp4"

    tot = len(scenes)
    for idx, (st, et) in enumerate(scenes, 1):
        if cancel_event and cancel_event.is_set():
            if log_callback:
                log_callback("分割処理が中断されました。")
            return False

        dur = et - st
        fn = f"{base_name}_scene_{idx:03d}{ext}"
        out_file = os.path.join(out_dir, fn)

        if log_callback:
            log_callback(f"[{idx}/{tot}] 分割出力中: {fn} ({format_timestamp(st)} -> {format_timestamp(et)}, 長さ: {dur:.2f}秒)")

        if mode == "reencode":
            cmd = [
                ffmpeg_path,
                "-y",
                "-loglevel", "error",
                "-ss", f"{st:.3f}",
                "-i", video_path,
                "-t", f"{dur:.3f}",
                "-c:v", "libx264",
                "-preset", "veryfast",
                "-crf", "18",
                "-c:a", "aac",
                "-b:a", "192k",
                "-avoid_negative_ts", "make_zero",
                out_file
            ]
        else:
            cmd = [
                ffmpeg_path,
                "-y",
                "-loglevel", "error",
                "-ss", f"{st:.3f}",
                "-i", video_path,
                "-t", f"{dur:.3f}",
                "-c", "copy",
                "-avoid_negative_ts", "make_zero",
                out_file
            ]

        startupinfo = None
        if sys.platform == "win32":
            startupinfo = subprocess.STARTUPINFO()
            startupinfo.dwFlags |= subprocess.STARTF_USESHOWWINDOW
            startupinfo.wShowWindow = 0

        proc = subprocess.Popen(
            cmd,
            stdout=subprocess.DEVNULL,
            stderr=subprocess.PIPE,
            startupinfo=startupinfo,
            text=True,
            encoding="utf-8",
            errors="replace"
        )

        while proc.poll() is None:
            if cancel_event and cancel_event.is_set():
                proc.kill()
                return False
            time.sleep(0.08)

        if proc.returncode != 0:
            err = proc.stderr.read()
            if log_callback:
                log_callback(f"分割エラー ({fn}): {err[-180:] if err else '不明'}")
        else:
            if log_callback:
                log_callback(f"完了: {fn}")

        if progress_callback:
            progress_callback(idx / tot * 100.0)

    return True


class TkinterSceneSplitterApp:
    def __init__(self, root: tk.Tk):
        self.root = root
        self.root.title("動画自動シーン分割ツール (Tkinter GUI)")
        self.root.geometry("940x770")
        self.root.minsize(800, 630)

        self.cancel_event = threading.Event()
        self.is_processing = False
        self.detected_scenes: List[SceneInfo] = []
        self.batch_video_files: List[str] = []
        self.batch_detection_results = {} # vpath -> [(st, et, score), ...]

        self.setup_ui()
        self.check_system()

    def setup_ui(self):
        style = ttk.Style()
        style.theme_use("clam")

        self.notebook = ttk.Notebook(self.root)
        self.notebook.pack(fill=tk.BOTH, expand=True, padx=8, pady=8)

        # タブ1: 単一ファイル処理
        self.single_tab = ttk.Frame(self.notebook, padding="10")
        self.notebook.add(self.single_tab, text=" 単一ファイル処理 ")

        # タブ2: フォルダ一括処理
        self.batch_tab = ttk.Frame(self.notebook, padding="10")
        self.notebook.add(self.batch_tab, text=" 📁 フォルダ一括処理 (複数ファイル) ")

        self.setup_single_tab()
        self.setup_batch_tab()

    # ==============================================================
    # タブ1: 単一ファイル処理 UI
    # ==============================================================
    def setup_single_tab(self):
        tab = self.single_tab

        # 1. ファイル選択
        file_frame = ttk.LabelFrame(tab, text=" 1. 対象動画と出力先フォルダ ", padding="8")
        file_frame.pack(fill=tk.X, pady=(0, 8))

        ttk.Label(file_frame, text="動画ファイル:").grid(row=0, column=0, sticky=tk.W, pady=2)
        self.video_var = tk.StringVar()
        self.video_entry = ttk.Entry(file_frame, textvariable=self.video_var, width=62)
        self.video_entry.grid(row=0, column=1, sticky=tk.EW, padx=5, pady=2)
        ttk.Button(file_frame, text="参照...", command=self.browse_video).grid(row=0, column=2, padx=2, pady=2)

        ttk.Label(file_frame, text="出力先フォルダ:").grid(row=1, column=0, sticky=tk.W, pady=2)
        self.outdir_var = tk.StringVar()
        self.outdir_entry = ttk.Entry(file_frame, textvariable=self.outdir_var, width=62)
        self.outdir_entry.grid(row=1, column=1, sticky=tk.EW, padx=5, pady=2)
        ttk.Button(file_frame, text="参照...", command=self.browse_output).grid(row=1, column=2, padx=2, pady=2)

        file_frame.columnconfigure(1, weight=1)

        # 2. パラメータ設定
        param_frame = ttk.LabelFrame(tab, text=" 2. 検出・分割設定 ", padding="8")
        param_frame.pack(fill=tk.X, pady=(0, 8))

        ttk.Label(param_frame, text="検出感度 (閾値):").grid(row=0, column=0, sticky=tk.W, pady=2)
        self.thresh_var = tk.DoubleVar(value=18.0)
        t_scale = ttk.Scale(param_frame, from_=5.0, to=50.0, variable=self.thresh_var, orient=tk.HORIZONTAL, command=self.on_thresh_change)
        t_scale.grid(row=0, column=1, sticky=tk.EW, padx=5, pady=2)
        self.thresh_lbl = ttk.Label(param_frame, text="18.0 (標準)")
        self.thresh_lbl.grid(row=0, column=2, sticky=tk.W, padx=5, pady=2)

        ttk.Label(param_frame, text="最小シーン長:").grid(row=1, column=0, sticky=tk.W, pady=2)
        self.min_len_var = tk.DoubleVar(value=0.5)
        min_spin = ttk.Spinbox(param_frame, from_=0.1, to=10.0, increment=0.1, textvariable=self.min_len_var, width=8)
        min_spin.grid(row=1, column=1, sticky=tk.W, padx=5, pady=2)
        ttk.Label(param_frame, text="秒 (これより短いカットは自動統合)").grid(row=1, column=2, sticky=tk.W, padx=5, pady=2)

        self.flash_var = tk.BooleanVar(value=True)
        ttk.Checkbutton(param_frame, text="短いフラッシュ・照明変化を除外 (適応型フィルタ)", variable=self.flash_var).grid(row=2, column=0, columnspan=2, sticky=tk.W, pady=3)

        ttk.Label(param_frame, text="分割出力方式:").grid(row=3, column=0, sticky=tk.W, pady=2)
        self.split_mode_var = tk.StringVar(value="reencode")
        mode_box = ttk.Frame(param_frame)
        mode_box.grid(row=3, column=1, columnspan=2, sticky=tk.W, padx=5, pady=2)
        ttk.Radiobutton(mode_box, text="◎ 正確カット【推奨: ズレなし】(H.264+AAC)", variable=self.split_mode_var, value="reencode").pack(anchor=tk.W)
        ttk.Radiobutton(mode_box, text="○ 高速無劣化 (-c copy: キーフレーム依存のためズレあり)", variable=self.split_mode_var, value="copy").pack(anchor=tk.W)

        param_frame.columnconfigure(1, weight=1)

        # 3. 操作パネル (2つのボタン)
        action_frame = ttk.LabelFrame(tab, text=" 3. 操作パネル ", padding="8")
        action_frame.pack(fill=tk.X, pady=(0, 8))

        self.detect_btn = ttk.Button(action_frame, text="① シーン検出を開始", command=self.start_detection, width=22)
        self.detect_btn.pack(side=tk.LEFT, padx=(0, 10), ipady=3)

        self.split_btn = ttk.Button(action_frame, text="② 検出シーンを分割保存", command=self.start_splitting, state=tk.DISABLED, width=24)
        self.split_btn.pack(side=tk.LEFT, padx=(0, 10), ipady=3)

        self.cancel_btn = ttk.Button(action_frame, text="中止", command=self.cancel_operation, state=tk.DISABLED, width=10)
        self.cancel_btn.pack(side=tk.LEFT, ipady=3)

        self.status_lbl = ttk.Label(action_frame, text="待機中: 動画を選択して「① シーン検出を開始」を押してください", foreground="#444444")
        self.status_lbl.pack(side=tk.RIGHT, padx=5)

        self.pbar = ttk.Progressbar(tab, orient=tk.HORIZONTAL, mode='determinate')
        self.pbar.pack(fill=tk.X, pady=(0, 8))

        # 4. シーン一覧 & ログ (感度/変化度列を追加!)
        paned = ttk.PanedWindow(tab, orient=tk.VERTICAL)
        paned.pack(fill=tk.BOTH, expand=True)

        list_frame = ttk.LabelFrame(paned, text=" 検出されたシーン一覧 (右クリックで選択シーン削除可能) ", padding="5")
        self.tree = ttk.Treeview(list_frame, columns=("num", "start", "end", "dur", "score", "file"), show="headings", height=5)
        self.tree.heading("num", text="シーン #")
        self.tree.heading("start", text="開始時刻")
        self.tree.heading("end", text="終了時刻")
        self.tree.heading("dur", text="シーン長")
        self.tree.heading("score", text="感度(変化度)")
        self.tree.heading("file", text="出力ファイル名")

        self.tree.column("num", width=60, anchor=tk.CENTER)
        self.tree.column("start", width=105, anchor=tk.CENTER)
        self.tree.column("end", width=105, anchor=tk.CENTER)
        self.tree.column("dur", width=90, anchor=tk.CENTER)
        self.tree.column("score", width=90, anchor=tk.CENTER)
        self.tree.column("file", width=260, anchor=tk.W)

        tree_s = ttk.Scrollbar(list_frame, orient=tk.VERTICAL, command=self.tree.yview)
        self.tree.configure(yscrollcommand=tree_s.set)
        self.tree.pack(side=tk.LEFT, fill=tk.BOTH, expand=True)
        tree_s.pack(side=tk.RIGHT, fill=tk.Y)
        paned.add(list_frame, weight=1)

        self.tree_menu = tk.Menu(self.root, tearoff=0)
        self.tree_menu.add_command(label="選択したシーンをリストから削除", command=self.delete_selected_scene)
        self.tree.bind("<Button-3>", self.show_context_menu)

        log_frame = ttk.LabelFrame(paned, text=" ログ ", padding="5")
        self.log_box = tk.Text(log_frame, wrap=tk.WORD, height=5, font=("Consolas", 9))
        log_s = ttk.Scrollbar(log_frame, orient=tk.VERTICAL, command=self.log_box.yview)
        self.log_box.configure(yscrollcommand=log_s.set)
        self.log_box.pack(side=tk.LEFT, fill=tk.BOTH, expand=True)
        log_s.pack(side=tk.RIGHT, fill=tk.Y)
        paned.add(log_frame, weight=1)

    # ==============================================================
    # タブ2: フォルダ一括処理 (複数ファイル) UI
    # ==============================================================
    def setup_batch_tab(self):
        tab = self.batch_tab

        # 1. フォルダ選択
        dir_frame = ttk.LabelFrame(tab, text=" 1. 対象フォルダと出力先フォルダ ", padding="8")
        dir_frame.pack(fill=tk.X, pady=(0, 8))

        ttk.Label(dir_frame, text="対象フォルダ:").grid(row=0, column=0, sticky=tk.W, pady=2)
        self.batch_src_var = tk.StringVar()
        self.batch_src_entry = ttk.Entry(dir_frame, textvariable=self.batch_src_var, width=62)
        self.batch_src_entry.grid(row=0, column=1, sticky=tk.EW, padx=5, pady=2)
        ttk.Button(dir_frame, text="参照...", command=self.browse_batch_src).grid(row=0, column=2, padx=2, pady=2)

        ttk.Label(dir_frame, text="出力先親フォルダ:").grid(row=1, column=0, sticky=tk.W, pady=2)
        self.batch_out_var = tk.StringVar()
        self.batch_out_entry = ttk.Entry(dir_frame, textvariable=self.batch_out_var, width=62)
        self.batch_out_entry.grid(row=1, column=1, sticky=tk.EW, padx=5, pady=2)
        ttk.Button(dir_frame, text="参照...", command=self.browse_batch_out).grid(row=1, column=2, padx=2, pady=2)

        dir_frame.columnconfigure(1, weight=1)

        # 2. 一括処理用パラメータ (スライダー連動)
        bparam_frame = ttk.LabelFrame(tab, text=" 2. 一括処理設定 ", padding="8")
        bparam_frame.pack(fill=tk.X, pady=(0, 8))

        ttk.Label(bparam_frame, text="検出感度 (閾値):").grid(row=0, column=0, sticky=tk.W, pady=2)
        self.batch_thresh_var = tk.DoubleVar(value=18.0)
        # ★command=self.on_batch_thresh_change でスライダー移動時に数値をリアルタイム更新!
        bt_scale = ttk.Scale(
            bparam_frame,
            from_=5.0,
            to=50.0,
            variable=self.batch_thresh_var,
            orient=tk.HORIZONTAL,
            command=self.on_batch_thresh_change
        )
        bt_scale.grid(row=0, column=1, sticky=tk.EW, padx=5, pady=2)
        self.batch_thresh_lbl = ttk.Label(bparam_frame, text="18.0 (標準)")
        self.batch_thresh_lbl.grid(row=0, column=2, sticky=tk.W, padx=5, pady=2)

        ttk.Label(bparam_frame, text="最小シーン長:").grid(row=1, column=0, sticky=tk.W, pady=2)
        self.batch_min_len_var = tk.DoubleVar(value=0.5)
        b_spin = ttk.Spinbox(bparam_frame, from_=0.1, to=10.0, increment=0.1, textvariable=self.batch_min_len_var, width=8)
        b_spin.grid(row=1, column=1, sticky=tk.W, padx=5, pady=2)
        ttk.Label(bparam_frame, text="秒").grid(row=1, column=2, sticky=tk.W, padx=5, pady=2)

        ttk.Label(bparam_frame, text="分割出力方式:").grid(row=2, column=0, sticky=tk.W, pady=2)
        self.batch_split_mode_var = tk.StringVar(value="reencode")
        bmode_box = ttk.Frame(bparam_frame)
        bmode_box.grid(row=2, column=1, columnspan=2, sticky=tk.W, padx=5, pady=2)
        ttk.Radiobutton(bmode_box, text="◎ 正確カット【推奨: ズレなし】", variable=self.batch_split_mode_var, value="reencode").pack(side=tk.LEFT, padx=(0, 15))
        ttk.Radiobutton(bmode_box, text="○ 高速無劣化 (-c copy)", variable=self.batch_split_mode_var, value="copy").pack(side=tk.LEFT)

        bparam_frame.columnconfigure(1, weight=1)

        # 3. 操作パネル (2ステップ: ①一括検出 → ②一括分割保存)
        bact_frame = ttk.LabelFrame(tab, text=" 3. 操作パネル (2ステップ) ", padding="8")
        bact_frame.pack(fill=tk.X, pady=(0, 8))

        self.batch_detect_btn = ttk.Button(bact_frame, text="① 一括シーン検出を開始", command=self.start_batch_detection, width=22)
        self.batch_detect_btn.pack(side=tk.LEFT, padx=(0, 10), ipady=3)

        self.batch_split_btn = ttk.Button(bact_frame, text="② 検出シーンを一括分割保存", command=self.start_batch_splitting, state=tk.DISABLED, width=24)
        self.batch_split_btn.pack(side=tk.LEFT, padx=(0, 10), ipady=3)

        self.batch_cancel_btn = ttk.Button(bact_frame, text="中止", command=self.cancel_operation, state=tk.DISABLED, width=10)
        self.batch_cancel_btn.pack(side=tk.LEFT, ipady=3)

        self.batch_status_lbl = ttk.Label(bact_frame, text="待機中: 対象フォルダを選択して「① 一括シーン検出を開始」を押してください", foreground="#444444")
        self.batch_status_lbl.pack(side=tk.RIGHT, padx=5)

        # 全体進捗バーと個別進捗バー
        prog_box = ttk.Frame(tab)
        prog_box.pack(fill=tk.X, pady=(0, 8))
        ttk.Label(prog_box, text="全体進捗:").pack(side=tk.LEFT, padx=(0, 5))
        self.batch_pbar_total = ttk.Progressbar(prog_box, orient=tk.HORIZONTAL, mode='determinate')
        self.batch_pbar_total.pack(side=tk.LEFT, fill=tk.X, expand=True, padx=(0, 15))
        ttk.Label(prog_box, text="現在の動画:").pack(side=tk.LEFT, padx=(0, 5))
        self.batch_pbar_current = ttk.Progressbar(prog_box, orient=tk.HORIZONTAL, mode='determinate')
        self.batch_pbar_current.pack(side=tk.LEFT, fill=tk.X, expand=True)

        # 4. 動画ファイル一覧テーブル & ログ (★「感度(閾値)」列を追加!)
        bpaned = ttk.PanedWindow(tab, orient=tk.VERTICAL)
        bpaned.pack(fill=tk.BOTH, expand=True)

        blist_frame = ttk.LabelFrame(bpaned, text=" 対象動画ファイル一覧 ", padding="5")
        self.batch_tree = ttk.Treeview(blist_frame, columns=("num", "name", "size", "thresh", "scenes", "status"), show="headings", height=5)
        self.batch_tree.heading("num", text="#")
        self.batch_tree.heading("name", text="ファイル名")
        self.batch_tree.heading("size", text="サイズ")
        self.batch_tree.heading("thresh", text="設定感度")
        self.batch_tree.heading("scenes", text="検出シーン数")
        self.batch_tree.heading("status", text="処理状態")

        self.batch_tree.column("num", width=40, anchor=tk.CENTER)
        self.batch_tree.column("name", width=310, anchor=tk.W)
        self.batch_tree.column("size", width=80, anchor=tk.CENTER)
        self.batch_tree.column("thresh", width=85, anchor=tk.CENTER)
        self.batch_tree.column("scenes", width=95, anchor=tk.CENTER)
        self.batch_tree.column("status", width=120, anchor=tk.CENTER)

        btree_s = ttk.Scrollbar(blist_frame, orient=tk.VERTICAL, command=self.batch_tree.yview)
        self.batch_tree.configure(yscrollcommand=btree_s.set)
        self.batch_tree.pack(side=tk.LEFT, fill=tk.BOTH, expand=True)
        btree_s.pack(side=tk.RIGHT, fill=tk.Y)
        bpaned.add(blist_frame, weight=1)

        blog_frame = ttk.LabelFrame(bpaned, text=" 一括処理ログ ", padding="5")
        self.batch_log_box = tk.Text(blog_frame, wrap=tk.WORD, height=5, font=("Consolas", 9))
        blog_s = ttk.Scrollbar(blog_frame, orient=tk.VERTICAL, command=self.batch_log_box.yview)
        self.batch_log_box.configure(yscrollcommand=blog_s.set)
        self.batch_log_box.pack(side=tk.LEFT, fill=tk.BOTH, expand=True)
        blog_s.pack(side=tk.RIGHT, fill=tk.Y)
        bpaned.add(blog_frame, weight=1)

    # ==============================================================
    # スライダー連動イベント
    # ==============================================================
    def on_thresh_change(self, val):
        v = float(val)
        desc = "標準"
        if v < 15:
            desc = "高感度"
        elif v > 25:
            desc = "低感度"
        self.thresh_lbl.config(text=f"{v:.1f} ({desc})")

    def on_batch_thresh_change(self, val):
        """一括処理用スライダー移動時にラベルとテーブルの感度表示をリアルタイム更新"""
        v = float(val)
        desc = "標準"
        if v < 15:
            desc = "高感度"
        elif v > 25:
            desc = "低感度"
        self.batch_thresh_lbl.config(text=f"{v:.1f} ({desc})")

        # リストの未処理行の設定感度表示も連動更新!
        for item_id in self.batch_tree.get_children():
            st = self.batch_tree.set(item_id, "status")
            if st in ("待機中", "検出なし", ""):
                self.batch_tree.set(item_id, "thresh", f"{v:.1f}")

    def show_context_menu(self, event):
        item = self.tree.identify_row(event.y)
        if item:
            self.tree.selection_set(item)
            self.tree_menu.post(event.x_root, event.y_root)

    def delete_selected_scene(self):
        selected = self.tree.selection()
        if not selected:
            return
        item_id = selected[0]
        idx_str = self.tree.item(item_id, "values")[0]
        self.tree.delete(item_id)
        try:
            target_idx = int(idx_str) - 1
            if 0 <= target_idx < len(self.detected_scenes):
                self.detected_scenes.pop(target_idx)
        except Exception:
            pass
        self.log(f"シーン #{idx_str} をリストから削除しました。(残り {len(self.tree.get_children())} シーン)")
        if not self.tree.get_children():
            self.split_btn.config(state=tk.DISABLED)

    def log(self, text: str):
        def _append():
            msg = f"[{time.strftime('%H:%M:%S')}] {text}\n"
            self.log_box.insert(tk.END, msg)
            self.log_box.see(tk.END)
            self.batch_log_box.insert(tk.END, msg)
            self.batch_log_box.see(tk.END)
        self.root.after(0, _append)

    def check_system(self):
        self.log("=== システム初期化 ===")
        self.log("GUI: Python標準 Tkinter")
        self.log(f"ffmpegパス: {FFMPEG_PATH}")
        if os.path.exists(FFMPEG_PATH):
            self.log("ffmpeg.exe: プログラムフォルダー内で正常に確認されました。")
        else:
            self.log("警告: ffmpeg.exe が見つかりません。プログラムと同じフォルダに配置してください。")

        if NUMPY_AVAILABLE:
            self.log("演算モード: NumPy ベクトル演算 (高精度・高速)")
        else:
            self.log("演算モード: Pure Python 最適化演算")

    def browse_video(self):
        p = filedialog.askopenfilename(
            title="動画ファイルを選択",
            filetypes=[("動画ファイル", "*.mp4;*.mkv;*.mov;*.avi;*.flv;*.webm;*.ts;*.wmv"), ("すべて", "*.*")]
        )
        if p:
            self.video_var.set(p)
            base = os.path.splitext(os.path.basename(p))[0]
            out_d = os.path.join(os.path.dirname(p), f"{base}_scenes")
            self.outdir_var.set(out_d)

    def browse_output(self):
        d = filedialog.askdirectory(title="出力先フォルダを選択")
        if d:
            self.outdir_var.set(d)

    def browse_batch_src(self):
        d = filedialog.askdirectory(title="動画が入っている対象フォルダを選択")
        if d:
            self.batch_src_var.set(d)
            default_out = os.path.join(d, "split_output")
            self.batch_out_var.set(default_out)
            self.refresh_batch_file_list(d)

    def browse_batch_out(self):
        d = filedialog.askdirectory(title="一括出力先の親フォルダを選択")
        if d:
            self.batch_out_var.set(d)

    def refresh_batch_file_list(self, folder: str):
        for it in self.batch_tree.get_children():
            self.batch_tree.delete(it)
        self.batch_video_files.clear()
        self.batch_detection_results.clear()
        self.batch_split_btn.config(state=tk.DISABLED)

        if not os.path.isdir(folder):
            return

        files = sorted(os.listdir(folder))
        count = 0
        current_th = f"{self.batch_thresh_var.get():.1f}"

        for f in files:
            ext = os.path.splitext(f)[1].lower()
            if ext in VIDEO_EXTENSIONS:
                full_path = os.path.join(folder, f)
                self.batch_video_files.append(full_path)
                count += 1
                size_mb = os.path.getsize(full_path) / (1024 * 1024)
                # 列: num, name, size, thresh, scenes, status
                self.batch_tree.insert("", tk.END, values=(
                    f"{count}",
                    f,
                    f"{size_mb:.1f} MB",
                    current_th,
                    "-",
                    "待機中"
                ))

        self.batch_status_lbl.config(text=f"{count} 個の動画ファイルを検出しました")
        self.log(f"フォルダ内に {count} 個の動画ファイルを検出しました: {folder}")

    # ==============================================================
    # 単一ファイル処理 ロジック
    # ==============================================================
    def start_detection(self):
        vpath = self.video_var.get().strip()
        if not vpath or not os.path.isfile(vpath):
            messagebox.showerror("エラー", "有効な動画ファイルを選択してください。")
            return

        if not os.path.exists(FFMPEG_PATH):
            messagebox.showerror("エラー", f"ffmpeg.exe が見つかりません:\n{FFMPEG_PATH}")
            return

        self.is_processing = True
        self.cancel_event.clear()
        self.detect_btn.config(state=tk.DISABLED)
        self.split_btn.config(state=tk.DISABLED)
        self.cancel_btn.config(state=tk.NORMAL)
        self.status_lbl.config(text="高精度シーン検出中...", foreground="#0055bb")
        self.pbar["value"] = 0

        for it in self.tree.get_children():
            self.tree.delete(it)
        self.detected_scenes.clear()

        threading.Thread(target=self._run_single_detection_worker, daemon=True).start()

    def _run_single_detection_worker(self):
        vpath = self.video_var.get().strip()
        thresh = self.thresh_var.get()
        min_sec = self.min_len_var.get()
        flash = self.flash_var.get()

        try:
            detector = PreciseSceneDetector(
                ffmpeg_path=FFMPEG_PATH,
                threshold=thresh,
                min_scene_len_sec=min_sec,
                down_w=64,
                down_h=36,
                target_fps=24.0,
                flash_filter=flash,
                progress_callback=lambda p: self.root.after(0, lambda: self.pbar.configure(value=p)),
                log_callback=self.log,
                cancel_event=self.cancel_event
            )

            # scenes は [(st, et, score), ...]
            scenes = detector.detect_scenes(vpath)

            if self.cancel_event.is_set():
                self._finish_single_detection("検出が中止されました", False)
                return

            if not scenes:
                self._finish_single_detection("シーンが検出されませんでした", False)
                return

            base_name = os.path.splitext(os.path.basename(vpath))[0]
            ext = os.path.splitext(vpath)[1] or ".mp4"

            self.detected_scenes = []
            for i, (st, et, sc) in enumerate(scenes, 1):
                dur = et - st
                fn = f"{base_name}_scene_{i:03d}{ext}"
                self.detected_scenes.append(SceneInfo(
                    start_sec=st,
                    end_sec=et,
                    duration_sec=dur,
                    score=sc,
                    filename=fn
                ))

                sc_text = f"{sc:.1f}" if sc > 0 else "-"
                def _add(idx=i, s=st, e=et, d=dur, score_str=sc_text, f=fn):
                    self.tree.insert("", tk.END, values=(
                        f"{idx}",
                        format_timestamp(s),
                        format_timestamp(e),
                        f"{d:.2f}秒",
                        score_str,
                        f
                    ))
                self.root.after(0, _add)

            self.log(f"【検出完了】合計 {len(scenes)} 個のシーンをリストアップしました。")
            self._finish_single_detection(f"検出完了 ({len(scenes)} シーン)", True)

        except Exception as e:
            self.log(f"検出エラー: {str(e)}")
            self._finish_single_detection("検出エラー発生", False)

    def _finish_single_detection(self, msg: str, success: bool):
        def _f():
            self.is_processing = False
            self.detect_btn.config(state=tk.NORMAL)
            self.cancel_btn.config(state=tk.DISABLED)
            if success and len(self.detected_scenes) > 0:
                self.split_btn.config(state=tk.NORMAL)
                self.pbar["value"] = 100
                self.status_lbl.config(text=msg, foreground="#008800")
            else:
                self.split_btn.config(state=tk.DISABLED)
                self.status_lbl.config(text=msg, foreground="#cc0000")
        self.root.after(0, _f)

    def start_splitting(self):
        if not self.detected_scenes:
            messagebox.showwarning("警告", "分割対象のシーンがありません。")
            return

        vpath = self.video_var.get().strip()
        out_d = self.outdir_var.get().strip()
        if not out_d:
            out_d = os.path.join(os.path.dirname(vpath), "scenes_output")
            self.outdir_var.set(out_d)

        self.is_processing = True
        self.cancel_event.clear()
        self.detect_btn.config(state=tk.DISABLED)
        self.split_btn.config(state=tk.DISABLED)
        self.cancel_btn.config(state=tk.NORMAL)
        self.status_lbl.config(text="分割保存中...", foreground="#0055bb")
        self.pbar["value"] = 0

        threading.Thread(target=self._run_single_splitting_worker, daemon=True).start()

    def _run_single_splitting_worker(self):
        vpath = self.video_var.get().strip()
        out_d = self.outdir_var.get().strip()
        mode = self.split_mode_var.get()

        scenes_tuples = [(sc.start_sec, sc.end_sec) for sc in self.detected_scenes]

        ok = split_video_scenes(
            ffmpeg_path=FFMPEG_PATH,
            video_path=vpath,
            scenes=scenes_tuples,
            out_dir=out_d,
            mode=mode,
            log_callback=self.log,
            progress_callback=lambda p: self.root.after(0, lambda: self.pbar.configure(value=p)),
            cancel_event=self.cancel_event
        )

        def _f():
            self.is_processing = False
            self.detect_btn.config(state=tk.NORMAL)
            self.split_btn.config(state=tk.NORMAL)
            self.cancel_btn.config(state=tk.DISABLED)
            if ok:
                self.pbar["value"] = 100
                self.status_lbl.config(text="全シーン分割完了", foreground="#008800")
                res = messagebox.askyesno("保存完了", f"分割が完了しました!\n\nフォルダを開きますか?\n{out_d}")
                if res and os.path.exists(out_d):
                    os.startfile(out_d)
            else:
                self.status_lbl.config(text="分割中止/エラー", foreground="#cc0000")
        self.root.after(0, _f)

    # ==============================================================
    # フォルダ一括処理 (複数ファイル) ロジック (2ステップ: ①検出 → ②分割保存)
    # ==============================================================
    def start_batch_detection(self):
        if not self.batch_video_files:
            messagebox.showwarning("警告", "対象フォルダ内に動画ファイルがありません。")
            return

        if not os.path.exists(FFMPEG_PATH):
            messagebox.showerror("エラー", f"ffmpeg.exe が見つかりません:\n{FFMPEG_PATH}")
            return

        self.is_processing = True
        self.cancel_event.clear()
        self.batch_detect_btn.config(state=tk.DISABLED)
        self.batch_split_btn.config(state=tk.DISABLED)
        self.batch_cancel_btn.config(state=tk.NORMAL)
        self.batch_status_lbl.config(text="一括シーン検出中...", foreground="#0055bb")
        self.batch_pbar_total["value"] = 0
        self.batch_pbar_current["value"] = 0
        self.batch_detection_results.clear()

        # テーブルのシーン数と状態を初期化
        for item_id in self.batch_tree.get_children():
            self.batch_tree.set(item_id, "scenes", "-")
            self.batch_tree.set(item_id, "status", "待機中")

        threading.Thread(target=self._run_batch_detection_worker, daemon=True).start()

    def _run_batch_detection_worker(self):
        thresh = self.batch_thresh_var.get()
        min_sec = self.batch_min_len_var.get()
        total_files = len(self.batch_video_files)

        self.log(f"==================================================")
        self.log(f"【一括シーン検出開始】合計 {total_files} ファイルの検出を開始します (設定感度: {thresh:.1f})")
        self.log(f"==================================================")

        for file_idx, vpath in enumerate(self.batch_video_files, 1):
            if self.cancel_event.is_set():
                self.log("一括シーン検出が中断されました。")
                break

            fname = os.path.basename(vpath)
            tree_items = self.batch_tree.get_children()
            item_id = tree_items[file_idx - 1]

            self.root.after(0, lambda it=item_id, th=f"{thresh:.1f}": (
                self.batch_tree.set(it, "thresh", th),
                self.batch_tree.set(it, "status", "検出中...")
            ))
            self.root.after(0, lambda: self.batch_pbar_current.configure(value=0))
            self.log(f"\n>>> [{file_idx}/{total_files}] 検出中: {fname} (感度: {thresh:.1f})")

            try:
                detector = PreciseSceneDetector(
                    ffmpeg_path=FFMPEG_PATH,
                    threshold=thresh,
                    min_scene_len_sec=min_sec,
                    down_w=64,
                    down_h=36,
                    target_fps=24.0,
                    progress_callback=lambda p: self.root.after(0, lambda: self.batch_pbar_current.configure(value=p)),
                    log_callback=self.log,
                    cancel_event=self.cancel_event
                )
                scenes = detector.detect_scenes(vpath)

                if self.cancel_event.is_set():
                    break

                self.batch_detection_results[vpath] = scenes
                num_scenes = len(scenes)

                if num_scenes <= 1:
                    self.root.after(0, lambda it=item_id, n=num_scenes: (
                        self.batch_tree.set(it, "scenes", f"{n} シーン"),
                        self.batch_tree.set(it, "status", "検出完了 (分割なし)")
                    ))
                    self.log(f"検出結果: {fname} → {num_scenes} シーン (分割不要)")
                else:
                    self.root.after(0, lambda it=item_id, n=num_scenes: (
                        self.batch_tree.set(it, "scenes", f"{n} シーン"),
                        self.batch_tree.set(it, "status", "検出完了")
                    ))
                    self.log(f"検出結果: {fname} → {num_scenes} シーン")

            except Exception as e:
                self.log(f"検出エラー ({fname}): {str(e)}")
                self.root.after(0, lambda it=item_id: self.batch_tree.set(it, "status", "検出エラー"))

            total_pct = (file_idx / total_files) * 100.0
            self.root.after(0, lambda p=total_pct: self.batch_pbar_total.configure(value=p))

        def _finish_detection():
            self.is_processing = False
            self.batch_detect_btn.config(state=tk.NORMAL)
            self.batch_cancel_btn.config(state=tk.DISABLED)
            if len(self.batch_detection_results) > 0 and not self.cancel_event.is_set():
                self.batch_split_btn.config(state=tk.NORMAL)
                self.batch_status_lbl.config(
                    text="一括検出完了: 「② 検出シーンを一括分割保存」を押してください",
                    foreground="#008800"
                )
                self.log("\n【一括検出完了】結果を確認し、「② 検出シーンを一括分割保存」を押してください。")
            else:
                self.batch_status_lbl.config(text="検出中止/エラー", foreground="#cc0000")
        self.root.after(0, _finish_detection)

    def start_batch_splitting(self):
        if not self.batch_detection_results:
            messagebox.showwarning("警告", "先にシーン検出を実行してください。")
            return

        out_root = self.batch_out_var.get().strip()
        if not out_root:
            messagebox.showwarning("警告", "出力先フォルダを指定してください。")
            return

        if not os.path.exists(FFMPEG_PATH):
            messagebox.showerror("エラー", f"ffmpeg.exe が見つかりません:\n{FFMPEG_PATH}")
            return

        self.is_processing = True
        self.cancel_event.clear()
        self.batch_detect_btn.config(state=tk.DISABLED)
        self.batch_split_btn.config(state=tk.DISABLED)
        self.batch_cancel_btn.config(state=tk.NORMAL)
        self.batch_status_lbl.config(text="一括分割保存中...", foreground="#0055bb")
        self.batch_pbar_total["value"] = 0
        self.batch_pbar_current["value"] = 0

        threading.Thread(target=self._run_batch_splitting_worker, daemon=True).start()

    def _run_batch_splitting_worker(self):
        out_root = self.batch_out_var.get().strip()
        mode = self.batch_split_mode_var.get()
        total_files = len(self.batch_video_files)

        # ★フォルダを作らずに直接 split_output の下にすべて出力
        os.makedirs(out_root, exist_ok=True)

        self.log(f"==================================================")
        self.log(f"【一括分割保存開始】合計 {total_files} ファイルの保存を開始します")
        self.log(f"保存先 (サブフォルダなし・直接保存): {out_root}")
        self.log(f"==================================================")

        for file_idx, vpath in enumerate(self.batch_video_files, 1):
            if self.cancel_event.is_set():
                self.log("一括分割保存が中断されました。")
                break

            fname = os.path.basename(vpath)
            tree_items = self.batch_tree.get_children()
            item_id = tree_items[file_idx - 1]

            scenes = self.batch_detection_results.get(vpath, [])
            num_scenes = len(scenes)

            self.root.after(0, lambda: self.batch_pbar_current.configure(value=0))
            self.log(f"\n>>> [{file_idx}/{total_files}] 保存処理中: {fname}")

            try:
                # 分割しないファイル (1シーン以下): split_output 直下にそのままコピー
                if num_scenes <= 1:
                    dest_file = os.path.join(out_root, fname)
                    self.log(f"※分割対象外 (シーン数: {num_scenes}) → {dest_file} へそのままコピーします")
                    self.root.after(0, lambda it=item_id: self.batch_tree.set(it, "status", "コピー中..."))

                    try:
                        shutil.copy2(vpath, dest_file)
                        self.log(f"✔ コピー完了: {dest_file}")
                        self.root.after(0, lambda it=item_id: self.batch_tree.set(it, "status", "✔ そのままコピー済"))
                    except Exception as copy_err:
                        self.log(f"コピー失敗 ({fname}): {str(copy_err)}")
                        self.root.after(0, lambda it=item_id: self.batch_tree.set(it, "status", "コピー失敗"))

                    self.root.after(0, lambda: self.batch_pbar_current.configure(value=100.0))
                    total_pct = (file_idx / total_files) * 100.0
                    self.root.after(0, lambda p=total_pct: self.batch_pbar_total.configure(value=p))
                    continue

                # 2シーン以上に分割するファイル: サブフォルダを作らず out_root 直下に直接出力!
                self.root.after(0, lambda it=item_id: self.batch_tree.set(it, "status", "分割保存中..."))
                scenes_tuples = [(st, et) for (st, et, _) in scenes]

                ok = split_video_scenes(
                    ffmpeg_path=FFMPEG_PATH,
                    video_path=vpath,
                    scenes=scenes_tuples,
                    out_dir=out_root, # ★サブフォルダを作らず直接保存
                    mode=mode,
                    log_callback=self.log,
                    progress_callback=lambda p: self.root.after(0, lambda: self.batch_pbar_current.configure(value=p)),
                    cancel_event=self.cancel_event
                )

                if ok:
                    self.root.after(0, lambda it=item_id: self.batch_tree.set(it, "status", "✔ 分割完了"))
                else:
                    self.root.after(0, lambda it=item_id: self.batch_tree.set(it, "status", "中止"))

            except Exception as e:
                self.log(f"保存エラー発生 ({fname}): {str(e)}")
                self.root.after(0, lambda it=item_id: self.batch_tree.set(it, "status", "エラー"))

            total_pct = (file_idx / total_files) * 100.0
            self.root.after(0, lambda p=total_pct: self.batch_pbar_total.configure(value=p))

        self.log(f"\n==================================================")
        self.log(f"【一括分割保存終了】すべての処理が終了しました。")
        self.log(f"==================================================")

        def _finish_batch_splitting():
            self.is_processing = False
            self.batch_detect_btn.config(state=tk.NORMAL)
            self.batch_split_btn.config(state=tk.NORMAL)
            self.batch_cancel_btn.config(state=tk.DISABLED)
            self.batch_status_lbl.config(text="一括分割保存完了", foreground="#008800")
            res = messagebox.askyesno("一括処理完了", f"すべての処理が完了しました!\n\n出力先フォルダを開きますか?\n{out_root}")
            if res and os.path.exists(out_root):
                os.startfile(out_root)
        self.root.after(0, _finish_batch_splitting)

    def cancel_operation(self):
        if self.is_processing:
            self.cancel_event.set()
            self.status_lbl.config(text="中止処理中...", foreground="#cc0000")
            self.batch_status_lbl.config(text="中止処理中...", foreground="#cc0000")
            self.log("ユーザーによる中止要求を送信しました。")


def main():
    root = tk.Tk()
    app = TkinterSceneSplitterApp(root)
    root.mainloop()

if __name__ == "__main__":
    main()

etc

Posted by eightban