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

このプログラムは 「動画を自動でシーンごとに検出し、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()










ディスカッション
コメント一覧
まだ、コメントがありません