ムフフ動画の管理

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3808329/84a30724-b314-e605-b7de-8f27cbfd4f06.png)

## 開発の経緯
- ダウンロードした動画を見る時間がない

## できること
- 動画から任意の枚数（グリッド数）の画像を抽出して一枚のアーカイブ画像として動画と同じディレクトリに保存。PPTファイルも。ただし縦長画像はPPT作りません
- アーカイブ画像をパワポに張り込んでハイパーリンクを追加


## 注意点
- HDDを使っている方はウインドウの右上の⚙マークから環境設定のウインドウを開き、シングルスレッド処理を選んでください。マルチスレッド処理がデフォです。HDDでマルチスレッドを使うと処理がバチクソ遅くなります。
- 環境設定の#を削除にチェックするとファイル名の特殊文字が置換されるので注意して下さい。この機能はファイル名に#やハイパーリンクに使えない文字が含まれている場合にリンクから動画を開けないので追加したものです。通常はOFFにしてください！エスケープする方法がわかりませんでした・・・

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3808329/e7ce4acd-9856-f4b7-39e5-0c5baba63445.png)

このような画像がPPTファイルに張り込まれてハイパーリンクが付きます
抽出フレーム数を最大にするとこんな感じ。

![Enjoy HIMARI_s brilliant performance of Bruch Violin concerto with New Japan Phil_archive.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3808329/8a137ddb-f821-9f1c-7878-5144e7208daa.jpeg)

ということで以下が本体コードです。
```python:Ver1.4.py
# Ver1.4
import tkinter as tk
from tkinter import filedialog, messagebox, ttk
from pptx import Presentation
from PIL import Image
import os
import configparser
from pptx.util import Inches
import logging
import re
import threading
import queue
import subprocess
import json
from moviepy.editor import VideoFileClip
import time
import psutil
import gc

cache_lock = threading.Lock()
ppt_save_lock = threading.Lock()

def natural_sort_key(s):
    return [int(text) if text.isdigit() else text.lower() for text in re.split('([0-9]+)', s)]

def load_video_details_cache():
    try:
        with open("video_details_cache.json", "r") as cache_file:
            return json.load(cache_file)
    except (FileNotFoundError, json.JSONDecodeError, IOError) as e:
        logging.exception(f"キャッシュファイルの読み込み中にエラーが発生しました: {e}")
        return {}
        
def extract_video_details_thread(video_paths, progress_queue, folder_path, video_details_cache):
    for video_path in video_paths:
        try:
            video_name = os.path.basename(video_path)
            if video_name in video_details_cache:
                file_size, video_duration_min, video_duration_sec = video_details_cache[video_name]
                progress_queue.put(("detail_extraction_success", video_path, folder_path, file_size, video_duration_min, video_duration_sec))
            else:
                file_info = os.stat(video_path)
                file_size = file_info.st_size / (1024 * 1024)
                video_duration = get_video_duration(video_path)
                video_duration_min = int(video_duration // 60)
                video_duration_sec = int(video_duration % 60)
                video_details_cache[video_name] = (file_size, video_duration_min, video_duration_sec)
                progress_queue.put(("detail_extraction_success", video_path, folder_path, file_size, video_duration_min, video_duration_sec, video_details_cache))
        except Exception as e:
            logging.exception(f"動画 {video_path} の詳細情報の取得中にエラーが発生しました: {e}")
            progress_queue.put(("detail_extraction_failed", video_path, folder_path))

def get_video_duration(video_path):
    try:
        clip = VideoFileClip(video_path)
        duration = clip.duration
        clip.close()
        return duration
    except Exception as e:
        logging.exception(f"動画の再生時間の取得中にエラーが発生しました: {e}")
        return None
        
def save_video_details_cache(cache):
    with cache_lock:
        try:
            with open("video_details_cache.json", "w") as cache_file:
                json.dump(cache, cache_file)
        except IOError as e:
            logging.exception(f"キャッシュファイルの保存中にエラーが発生しました: {e}")

class TextHandler(logging.Handler):
    def __init__(self, text_widget):
        super().__init__()
        self.text_widget = text_widget

    def emit(self, record):
        msg = self.format(record)
        color = None
        if record.levelno == logging.INFO:
            if "ファイル名を変更しました" in msg:
                color = "#c39163"  # ファイル名変更のログメッセージを赤色に設定
            elif "全ての処理が完了しました。" in msg:
                color = "#c39163"  # 処理完了のログメッセージを赤色に設定
            elif "ファイル名の変更は不要です" in msg:
                color = "#abb2bf"  # ファイル名が変更されない場合のログメッセージを青色に設定
            else:
                color = '#abb2bf'  # その他のINFOレベルのログメッセージを青色に設定
        elif record.levelno == logging.WARNING:
            color = '#61aeee'
        elif record.levelno == logging.ERROR:
            color = '#c39163'

        self.text_widget.configure(state='normal')
        if color:
            self.text_widget.insert(tk.END, msg + '\n', color)
            self.text_widget.tag_config(color, foreground=color)
        else:
            self.text_widget.insert(tk.END, msg + '\n')
        self.text_widget.configure(state='disabled')
        self.text_widget.yview(tk.END)

class Application(tk.Frame):
    def __init__(self, master=None):
        super().__init__(master)
        self.master = master
        self.master.geometry("320x500")  # メインウィンドウのサイズを設定
        self.master.resizable(False, False)  # メインウィンドウのサイズ変更を禁止
        self.remove_hashtags_var = tk.BooleanVar(value=True)# ハッシュタグ削除
        self.config = configparser.ConfigParser()
        self.grid_size_var = tk.StringVar()
        self.sort_by_name_var = tk.BooleanVar()
        self.add_details_var = tk.BooleanVar(value=True)
        self.single_thread_var = tk.BooleanVar()  # 追加
        self.max_threads = os.cpu_count() or 4
        self.load_config()
        self.create_widgets()
        self.progress_queue = queue.Queue()
        self.master.protocol("WM_DELETE_WINDOW", self.on_close)

    def remove_special_characters_from_filenames(self, folder_path):
        for root, dirs, files in os.walk(folder_path):
            for file in files:
                if file.lower().endswith(('.mp4', '.avi', '.mov')):  # Process video files only
                    file_path = os.path.join(root, file)
                    new_file_name = re.sub(r'#', '＃', file)  # Replace "#" with "＃"
                    new_file_name = re.sub(r'[^\w.\s＃-]+', '_', new_file_name)  # Replace consecutive special characters with a single underscore
                    new_file_name = re.sub(r'_+', '_', new_file_name)  # Replace consecutive underscores with a single underscore
                    new_file_name = re.sub(r'\s+', ' ', new_file_name)  # Replace consecutive spaces with a single space
                    new_file_path = os.path.join(root, new_file_name)

                    # Handle duplicate video file names by adding a sequence number
                    if new_file_name != file:
                        counter = 1
                        base_name, extension = os.path.splitext(new_file_name)
                        while os.path.exists(new_file_path):
                            new_file_name = f"{base_name}_{counter:03d}{extension}"
                            new_file_path = os.path.join(root, new_file_name)
                            counter += 1

                    # Rename the file if necessary
                    if new_file_path != file_path:
                        os.rename(file_path, new_file_path)
                        logging.info(f"ファイル名を変更しました。: {file} -> {new_file_name}")  # ログメッセージを記録
                    else:
                        logging.info(f"ファイル名の変更は不要です。: {file}")  # ファイル名が変更されない場合のログメッセージを記録
            
    def get_video_details(self, video_path):
        file_size = os.path.getsize(video_path) / (1024 * 1024)  # ファイルサイズ（MB）
        duration = int(os.popen(f'ffprobe -v error -show_entries format=duration -of default=noprint_wrappers=1:nokey=1 "{video_path}"').read().strip())
        minutes, seconds = divmod(duration, 60)
        return file_size, minutes, seconds

    def update_status(self, current=None, total=None, folder_path=""):
        if current is not None and total is not None:
            progress_status = f"処理中: {current}/{total}"
        else:
            progress_status = "待機中"

        self.status_label.config(text=f"フォルダ{progress_status}")

        if len(folder_path) > 50:
            folder_path = "..." + folder_path[-47:]

        self.folder_label.config(text=folder_path)
        
    def on_close(self):
        if messagebox.askokcancel("終了", "アプリケーションを終了しますか？"):
            # 実行中のスレッドを終了
            for thread in threading.enumerate():
                if thread != threading.current_thread():
                    thread.join(timeout=1)

            # VideoFileClipインスタンスを閉じる
            for obj in gc.get_objects():
                if isinstance(obj, VideoFileClip):
                    obj.close()

            # psutilを使用して、現在のプロセスに関連するリソースを解放
            process = psutil.Process()
            for proc in process.children(recursive=True):
                proc.kill()
            process.kill()

            # すべてのウィンドウを閉じる
            self.master.destroy()

    def create_widgets(self):
        settings_icon = tk.Label(self, text="⚙️")
        settings_icon.bind("<Button-1>", lambda event: self.open_settings_dialog())
        settings_icon.pack(side=tk.TOP, pady=0, anchor=tk.E)
        
        self.select_folder_button = tk.Button(self, text="フォルダを選択", command=self.select_folder, width=20, height=2)
        self.select_folder_button.pack(pady=10, padx=10, expand=True, anchor=tk.CENTER)

        """
        self.sort_by_name_checkbutton = tk.Checkbutton(self, text="名前順でソート", variable=self.sort_by_name_var)
        self.sort_by_name_checkbutton.pack(side=tk.TOP, pady=5)

        self.add_details_checkbutton = tk.Checkbutton(self, text="詳細情報を追加", variable=self.add_details_var)
        self.add_details_checkbutton.pack(side=tk.TOP, pady=5)

        self.single_thread_var = tk.BooleanVar()
        self.single_thread_checkbutton = tk.Checkbutton(self, text="シングルスレッド処理", variable=self.single_thread_var)
        self.single_thread_checkbutton.pack(side=tk.TOP, pady=5)
        """
        values1 = ["2x2", "3x3", "4x4", "5x5", "6x6", "7x7"]
        values2 = ["2x6", "3x9", "4x12", "5x15", "6x18", "7x21"]
        values = values1 + ["─────────"] + values2
        self.grid_size_menu = ttk.Combobox(self, textvariable=self.grid_size_var, values=values)
        self.grid_size_menu.pack(side=tk.TOP, pady=5)

        self.execute_button = tk.Button(self, text="実行", command=self.execute, width=20, height=2)
        self.execute_button.pack(pady=10, padx=10)

        self.progress_bar = ttk.Progressbar(self, orient="horizontal", length=280, mode="determinate")
        self.progress_bar.pack(pady=10, padx=20)

        self.detail_extraction_progress = ttk.Progressbar(self, orient="horizontal", length=280, mode="determinate")
        self.detail_extraction_progress.pack(pady=10, padx=20)

        #self.folder_progress_bar = ttk.Progressbar(self, orient="horizontal", length=300, mode="determinate")
        #self.folder_progress_bar.pack(pady=5, padx=10)

        self.video_folder_path_label = tk.Label(self, text="パス: なし")
        self.video_folder_path_label.pack(pady=1, padx=10)

        self.selected_videos_label = tk.Label(self, text="フォルダを選択してください: 0/0")
        self.selected_videos_label.pack(pady=1, padx=10)

        self.status_label = tk.Label(self, text="ステータス: 待機中")
        self.status_label.pack(pady=1, padx=10)

        self.folder_label = tk.Label(self, text="")
        self.folder_label.pack(pady=1, padx=10)

        self.log_text = tk.Text(self, wrap=tk.WORD, width=40, height=10, state='disabled', bg="#333333", fg="white")
        self.log_text.pack(side=tk.BOTTOM, pady=(10, 20), padx=10)
        
        self.video_paths = []

    def open_settings_dialog(self):
        if not hasattr(self, 'settings_dialog') or not self.settings_dialog.winfo_exists():
            self.settings_dialog = tk.Toplevel(self)
            self.settings_dialog.title("環境設定")
            self.settings_dialog.geometry("450x200")  # 環境設定ウィンドウのサイズを設定
            self.settings_dialog.resizable(False, False)  # 環境設定ウィンドウのサイズ変更を禁止

            sort_by_name_checkbutton = tk.Checkbutton(self.settings_dialog, text="名前順でソート", variable=self.sort_by_name_var)
            sort_by_name_checkbutton.pack(side=tk.TOP, pady=5, anchor=tk.W)

            add_details_checkbutton = tk.Checkbutton(self.settings_dialog, text="詳細情報を追加", variable=self.add_details_var)
            add_details_checkbutton.pack(side=tk.TOP, pady=5, anchor=tk.W)

            single_thread_checkbutton = tk.Checkbutton(self.settings_dialog, text="シングルスレッド処理", variable=self.single_thread_var)
            single_thread_checkbutton.pack(side=tk.TOP, pady=5, anchor=tk.W)
            
            remove_hashtags_checkbutton = tk.Checkbutton(self.settings_dialog, text="#を削除", variable=self.remove_hashtags_var)
            remove_hashtags_checkbutton.pack(side=tk.TOP, pady=5, anchor=tk.W)

            ok_button = tk.Button(self.settings_dialog, text="OK", command=lambda: [self.save_config(), self.settings_dialog.destroy()])
            ok_button.pack(side=tk.TOP, pady=5)
        else:
            self.settings_dialog.deiconify()  # 環境設定ウィンドウを表示

    def load_config(self):
        self.config.read('settings.ini')
        self.video_folder_path = self.config.get('Paths', 'VideoFolder', fallback=None)
        self.ppt_path = self.config.get('Paths', 'PPTPath', fallback=None)
        grid_size_value = self.config.get('Settings', 'GridSize', fallback='4x4')
        self.grid_size_var.set(grid_size_value)
        self.sort_by_name_var.set(self.config.getboolean('Settings', 'SortByName', fallback=False))
        self.add_details_var.set(self.config.getboolean('Settings', 'AddDetails', fallback=True))
        self.single_thread_var.set(self.config.getboolean('Settings', 'SingleThread', fallback=False))
        self.remove_hashtags_var.set(self.config.getboolean('Settings', 'RemoveHashtags', fallback=True))

    def save_config(self):
        self.config['Paths'] = {
            'VideoFolder': self.video_folder_path if self.video_folder_path else '',
            'PPTPath': self.ppt_path if self.ppt_path else ''
        }
        self.config['Settings'] = {
            'RemoveHashtags': str(self.remove_hashtags_var.get()),
            'GridSize': self.grid_size_var.get(),
            'SortByName': self.sort_by_name_var.get(),
            'AddDetails': self.add_details_var.get(),
            'SingleThread': self.single_thread_var.get()
        }
        with open('settings.ini', 'w') as configfile:
            self.config.write(configfile)

    def select_folder(self):
        folder_path = filedialog.askdirectory(title="フォルダを選択")
        if folder_path:
            self.video_folder_path = folder_path
            self.video_folder_path_label.config(text=f"パス: {folder_path}")
            self.video_paths = self.get_all_video_files(folder_path)
            
            self.all_folders = self.get_all_folders(folder_path)
            self.total_folders = len(self.all_folders)

            self.progress_bar["value"] = 0
            self.detail_extraction_progress["value"] = 0
            # self.total_progress_bar["value"] = 0
            # self.folder_progress_bar["value"] = 0

            self.total_videos_count = self.count_total_videos(folder_path)
            self.progress_bar["maximum"] = self.total_videos_count # 追加
            self.processed_videos_count = 0

            if not self.video_paths:
                messagebox.showerror("エラー", "動画ファイルが見つかりませんでした。")
                return
            self.selected_videos_label.config(text=f"総動画数: 0/{len(self.video_paths)}")
            # self.selected_videos_label.config(text=f"総動画数: {len(self.video_paths)}")
            self.save_config()

    def get_all_video_files(self, folder_path):
        supported_formats = {'.mp4', '.avi', '.mov'}
        video_files = []
        for root, dirs, files in os.walk(folder_path):
            for file in files:
                if any(file.lower().endswith(ext) for ext in supported_formats):
                    video_files.append(os.path.join(root, file))
                else:
                    logging.warning(f"スキップされたファイル: {file}")

        if self.sort_by_name_var.get():
            video_files.sort(key=natural_sort_key)

        return video_files

    def execute(self):
        if not self.video_paths:
            messagebox.showerror("エラー", "動画を選択してください")
            return

        if self.remove_hashtags_var.get():
            self.remove_special_characters_from_filenames(self.video_folder_path)

        # self.total_progress_bar["value"] = 0
        # self.folder_progress_bar["value"] = 0
        self.update_status(folder_path="処理を開始しています...")

        thread = threading.Thread(target=self.process_videos)
        thread.start()
        self.master.after(100, self.check_thread, thread)

    def check_thread(self, thread):
        if thread.is_alive():
            self.master.after(100, self.check_thread, thread)
        else:
            if not self.error_occurred:
                self.update_status("待機中")
            self.reset_button_states()
            
    def process_video(self, video_path, sub_presentation, parent_presentation):
        try:
            folder_name = os.path.basename(os.path.dirname(video_path))
            image_path = self.prepare_image_path(video_path)
            
            if not os.path.exists(image_path):
                if not self.extract_screenshots(video_path):
                    raise Exception("スクリーンショットの抽出に失敗しました。")

            self.insert_image_to_ppt(sub_presentation, image_path, video_path)
            self.add_notes_to_slide(sub_presentation, video_path)
            
            self.insert_image_to_ppt(parent_presentation, image_path, video_path)
            self.add_notes_to_slide(parent_presentation, video_path)
        except Exception as e:
            logging.exception(f"動画 {video_path} の処理中にエラーが発生しました: {e}")
            raise

    def process_videos(self):
        logging.info(f"シングルスレッド処理: {self.single_thread_var.get()}")
        processed_folders = 0
        parent_presentation = Presentation()
        parent_presentation.slide_width = Inches(13.33)
        parent_presentation.slide_height = Inches(7.5)
        skipped_videos_count = 0
        video_details_cache = load_video_details_cache()  # キャッシュの読み込み

        try:
            self.error_occurred = False
            semaphore = threading.Semaphore(self.max_threads)
            processed_videos_details_count = 0

            for folder_path in self.all_folders:
                self.update_status(current=processed_folders + 1, total=self.total_folders, folder_path=os.path.basename(folder_path))

                sub_presentation = Presentation()
                sub_presentation.slide_width = Inches(13.33)
                sub_presentation.slide_height = Inches(7.5)

                video_paths = self.get_all_video_files(folder_path)
                if not video_paths:
                    continue

                total_videos = len(video_paths)
                processed_videos_in_folder = 0

                if self.single_thread_var.get():
                    # シングルスレッド処理
                    for video_path in video_paths:
                        image_path = self.prepare_image_path(video_path)
                        if os.path.exists(image_path):
                            skipped_videos_count += 1
                            self.update_total_progress(current=self.processed_videos_count + skipped_videos_count)
                            self.selected_videos_label.config(text=f"総動画数: {self.processed_videos_count + skipped_videos_count}/{self.total_videos_count}")
                            logging.info(f"動画 {video_path} のアーカイブ画像が既に存在します。スキップします。")
                        else:
                            if not self.extract_screenshots(video_path):
                                logging.warning(f"動画 {video_path} のフレーム抽出に失敗しました。次の動画に移ります。")
                            else:
                                self.processed_videos_count += 1
                                processed_videos_in_folder += 1
                                self.update_total_progress(current=self.processed_videos_count + skipped_videos_count)
                                self.selected_videos_label.config(text=f"総動画数: {self.processed_videos_count + skipped_videos_count}/{self.total_videos_count}")
                else:
                    # マルチスレッド処理
                    frame_extraction_threads = []
                    chunk_size = total_videos // self.max_threads + 1
                    for i in range(0, total_videos, chunk_size):
                        chunk_paths = video_paths[i:i+chunk_size]
                        thread = threading.Thread(target=self.process_frame_extraction_thread, args=(chunk_paths, semaphore, folder_path))
                        thread.start()
                        frame_extraction_threads.append(thread)

                    while any(thread.is_alive() for thread in frame_extraction_threads) or not self.progress_queue.empty():
                        try:
                            progress_type, video_path, folder_path, *args = self.progress_queue.get(block=False)
                            if progress_type == "extraction_success":
                                self.processed_videos_count += 1
                                processed_videos_in_folder += 1
                                self.update_total_progress(current=self.processed_videos_count + skipped_videos_count)
                                self.selected_videos_label.config(text=f"総動画数: {self.processed_videos_count + skipped_videos_count}/{self.total_videos_count}")
                            elif progress_type == "extraction_skipped":
                                skipped_videos_count += 1
                                self.update_total_progress(current=self.processed_videos_count + skipped_videos_count)
                                self.selected_videos_label.config(text=f"総動画数: {self.processed_videos_count + skipped_videos_count}/{self.total_videos_count}")
                                logging.info(f"動画 {video_path} のアーカイブ画像が既に存在します。スキップします。")
                            elif progress_type == "extraction_failed":
                                logging.warning(f"動画 {video_path} のフレーム抽出に失敗しました。次の動画に移ります。")
                        except queue.Empty:
                            pass

                        if self.master.winfo_exists():
                            self.master.update()  # Update UI

                    for thread in frame_extraction_threads:
                        thread.join()

                processed_videos_details = {}
                if self.add_details_var.get():
                    for video_path in video_paths:
                        try:
                            video_name = os.path.basename(video_path)
                            if video_name in video_details_cache:
                                file_size, video_duration_min, video_duration_sec = video_details_cache[video_name]
                            else:
                                file_info = os.stat(video_path)
                                file_size = file_info.st_size / (1024 * 1024)
                                video_duration = get_video_duration(video_path)
                                video_duration_min = int(video_duration // 60)
                                video_duration_sec = int(video_duration % 60)
                                video_details_cache[video_name] = (file_size, video_duration_min, video_duration_sec)
                            processed_videos_details[(folder_path, video_path)] = (file_size, video_duration_min, video_duration_sec)
                            processed_videos_details_count += 1
                            self.update_detail_extraction_progress(current=processed_videos_details_count, total=self.total_videos_count)
                            logging.info(f"動画 {video_path} の詳細情報を取得しました。ファイルサイズ: {file_size:.2f} MB, 動画の長さ: {video_duration_min}分{video_duration_sec}秒")
                        except Exception as e:
                            logging.exception(f"動画 {video_path} の詳細情報の取得中にエラーが発生しました: {e}")

                    save_video_details_cache(video_details_cache)  # フォルダ処理後にキャッシュを保存

                for video_path in video_paths:
                    if self.grid_size_var.get() not in ["2x6", "3x9", "4x12", "5x15", "6x18", "7x21"]:  # 縦長のグリッドサイズでない場合のみPPTファイルを作成
                        image_path = self.prepare_image_path(video_path)
                        self.insert_image_to_ppt(sub_presentation, image_path, video_path)
                        if self.add_details_var.get() and (folder_path, video_path) in processed_videos_details:
                            file_size, video_duration_min, video_duration_sec = processed_videos_details[(folder_path, video_path)]
                            self.add_notes_to_slide(sub_presentation, video_path, file_size, video_duration_min, video_duration_sec)
                        else:
                            self.add_notes_to_slide(sub_presentation, video_path)
                        self.insert_image_to_ppt(parent_presentation, image_path, video_path)
                        if self.add_details_var.get() and (folder_path, video_path) in processed_videos_details:
                            file_size, video_duration_min, video_duration_sec = processed_videos_details[(folder_path, video_path)]
                            self.add_notes_to_slide(parent_presentation, video_path, file_size, video_duration_min, video_duration_sec)
                        else:
                            self.add_notes_to_slide(parent_presentation, video_path)

                if self.grid_size_var.get() not in ["2x6", "3x9", "4x12", "5x15", "6x18", "7x21"]:  # 縦長のグリッドサイズでない場合のみPPTファイルを保存
                    sub_ppt_save_path = os.path.join(folder_path, f"{os.path.basename(folder_path)}.pptx")
                    if os.path.exists(sub_ppt_save_path):
                        os.remove(sub_ppt_save_path)
                    sub_presentation.save(sub_ppt_save_path)

                processed_folders += 1

            if self.grid_size_var.get() not in ["2x6", "3x9", "4x12", "5x15", "6x18", "7x21"]:  # 縦長のグリッドサイズでない場合のみ親PPTファイルを保存
                parent_ppt_save_path = os.path.join(self.video_folder_path, f"{os.path.basename(self.video_folder_path)}.pptx")
                if os.path.exists(parent_ppt_save_path):
                    os.remove(parent_ppt_save_path)
                parent_presentation.save(parent_ppt_save_path)

            logging.info("全ての処理が完了しました。")
        except Exception as e:
            self.error_occurred = True
            self.update_status("エラーが発生しました")
            logging.exception("処理中にエラーが発生しました: %s", str(e))
            messagebox.showerror("エラー", f"処理中にエラーが発生しました: {str(e)}")

        finally:
            # クリーンアップコード
            for obj in gc.get_objects():
                if isinstance(obj, VideoFileClip):
                    obj.close()

            save_video_details_cache(video_details_cache)  # 最終的にキャッシュを保存

            if not self.error_occurred:
                self.update_total_progress(current=self.total_videos_count)
                self.update_detail_extraction_progress(current=self.total_videos_count, total=self.total_videos_count)
                self.selected_videos_label.config(text=f"総動画数: {self.total_videos_count}/{self.total_videos_count}")
                    
    def update_detail_extraction_progress(self, current, total):
        self.detail_extraction_progress["value"] = (current / total) * 100
        self.detail_extraction_progress.update()

    def update_folder_progress(self, current, total, folder_path):
        progress = (current / total) * 100
        self.folder_progress_bar["value"] = progress
        self.status_label.config(text=f"処理中: {current}/{total}\n{folder_path}")
        self.folder_progress_bar.update()

    def get_all_folders(self, base_folder):
        all_folders = []
        for root, dirs, files in os.walk(base_folder):
            if any(file.lower().endswith(('.mp4', '.avi', '.mov')) for file in files):
                all_folders.append(root)
        return all_folders

    def count_total_videos(self, folder_path):
        total_videos = 0
        for root, _, files in os.walk(folder_path):
            for file in files:
                if any(file.lower().endswith(ext) for ext in {'.mp4', '.avi', '.mov'}):
                    total_videos += 1
        return total_videos

    def update_total_progress(self, current):
        self.progress_bar["value"] = current
        self.progress_bar.update()
        
    def reset_button_states(self):
        self.execute_button.config(relief=tk.RAISED)
        self.select_folder_button.config(relief=tk.RAISED)
        self.update_status("待機中")

    def prepare_image_path(self, video_path):
        return os.path.join(os.path.dirname(video_path), os.path.splitext(os.path.basename(video_path))[0] + "_archive.jpg")

    def add_notes_to_slide(self, prs, video_path, file_size=None, video_duration_min=None, video_duration_sec=None):
        slide = prs.slides[-1]
        notes_slide = slide.notes_slide
        notes_text_frame = notes_slide.notes_text_frame
        notes_text_frame.text = ""

        if self.add_details_var.get():
            if file_size is not None and video_duration_min is not None and video_duration_sec is not None:
                notes_text_frame.text = f"ファイルサイズ: {file_size:.2f} MB／動画の長さ: {video_duration_min}分{video_duration_sec}秒\nファイルパス: {video_path}"
            else:
                notes_text_frame.text = video_path
        else:
            notes_text_frame.text = video_path
            
    def extract_screenshots(self, video_path):
        grid_size = self.grid_size_var.get()
        grid_width, grid_height = map(int, grid_size.split('x'))

        if grid_size in ["2x6", "3x9", "4x12", "5x15", "6x18", "7x21"]:  # 縦長のグリッドサイズが選択された場合
            total_image_size = (1920, 3240)
        else:
            total_image_size = (1920, 1080)

        archive_count = grid_width * grid_height
        sub_image_size = (total_image_size[0] // grid_width, total_image_size[1] // grid_height)

        try:
            clip = VideoFileClip(str(video_path))
            video_name = os.path.splitext(os.path.basename(video_path))[0]
            output_folder = os.path.dirname(video_path)
            duration = clip.duration
            start_time = 20  # original 60

            logging.info(f"画像抽出開始: {video_name}")

            if duration <= start_time:
                raise ValueError("動画の長さが不十分です。")

            remaining_duration = duration - start_time
            archive_interval = remaining_duration / archive_count

            archive_images = []
            for i in range(archive_count):
                frame_time = start_time + i * archive_interval
                frame = clip.get_frame(frame_time)
                img = Image.fromarray(frame).resize(sub_image_size)
                img_path = os.path.join(str(output_folder), f'{video_name}_archive_{i+1}.jpg')
                img.save(img_path)
                archive_images.append(img)

            combined_image_path = os.path.join(str(output_folder), f'{video_name}_archive.jpg')
            self.resize_and_combine_images(archive_images, combined_image_path, grid_width, grid_height)

            for img_path in [os.path.join(str(output_folder), f'{video_name}_archive_{i+1}.jpg') for i in range(archive_count)]:
                os.remove(img_path)

            logging.info(f"画像抽出完了: {video_name}")
        except Exception as e:
            logging.exception(f"動画 {video_path} のスクリーンショット抽出中にエラーが発生しました: {e}")
            return False
        finally:
            if 'clip' in locals():
                clip.close()

        return True

    def resize_and_combine_images(self, images, output_path, grid_width, grid_height):
        try:
            grid_size = f"{grid_width}x{grid_height}"
            if grid_size in ["2x6", "3x9", "4x12", "5x15", "6x18", "7x21"]:  # 縦長のグリッドサイズが選択された場合
                total_image_size = (1920, 3240)
            else:
                total_image_size = (1920, 1080)

            sub_image_size = (total_image_size[0] // grid_width, total_image_size[1] // grid_height)
            new_image = Image.new('RGB', total_image_size)

            for i, img in enumerate(images):
                x = (i % grid_width) * sub_image_size[0]
                y = (i // grid_width) * sub_image_size[1]
                resized_img = img.resize(sub_image_size)
                new_image.paste(resized_img, (x, y))

            new_image.save(output_path)
        except Exception as e:
            logging.exception(f"画像の結合中にエラーが発生しました: {e}")
            
    def insert_image_to_ppt(self, prs, image_path, hyperlink):
        try:
            # 白紙のスライドレイアウトを使用 (通常はインデックス6)
            blank_slide_layout = prs.slide_layouts[6]
            slide = prs.slides.add_slide(blank_slide_layout)

            with Image.open(image_path) as img:
                original_width, original_height = img.size

            slide_width = prs.slide_width
            slide_height = prs.slide_height

            # 画像サイズの調整
            ratio = min(slide_width / original_width, slide_height / original_height)
            image_width = int(original_width * ratio)
            image_height = int(original_height * ratio)

            # 画像を中央に配置
            left = (slide_width - image_width) / 2
            top = (slide_height - image_height) / 2

            # 画像をスライドに挿入
            pic = slide.shapes.add_picture(image_path, left, top, width=image_width, height=image_height)

            # ハイパーリンクを設定 (変更部分)
            click_action = pic.click_action
            click_action.hyperlink.address = hyperlink.replace('/', '\\')
        except Exception as e:
            logging.exception(f"画像の挿入中にエラーが発生しました: {e}")
            
    def process_frame_extraction_thread(self, video_paths, semaphore, folder_path, batch_size=100, interval=5):
        processed_count = 0
        for video_path in video_paths:
            with semaphore:
                image_path = self.prepare_image_path(video_path)
                if os.path.exists(image_path):
                    self.progress_queue.put(("extraction_skipped", video_path, folder_path))
                else:
                    if not self.extract_screenshots(video_path):
                        logging.warning(f"動画 {video_path} のフレーム抽出に失敗しました。次の動画に移ります。")
                        self.progress_queue.put(("extraction_failed", video_path, folder_path))
                    else:
                        self.progress_queue.put(("extraction_success", video_path, folder_path))
                processed_count += 1
                if processed_count % batch_size == 0:
                    time.sleep(interval)  # batch_size個の処理ごとにinterva5秒のインターバルを設ける
                    time.sleep(1)  # 1秒のタイムラグを追加

    def count_videos_in_folder(self, folder_path):
        video_count = 0
        for root, dirs, files in os.walk(folder_path):
            for file in files:
                if any(file.lower().endswith(ext) for ext in {'.mp4', '.avi', '.mov'}):
                    video_count += 1
        return video_count

    def stop_processing(self):
        self.stop_event.set()

  
root = tk.Tk()
root.title("Ver1.4")
app = Application(master=root)
app.pack()

logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s')
text_handler = TextHandler(app.log_text)
text_handler.setFormatter(logging.Formatter('%(asctime)s - %(levelname)s - %(message)s'))
logger = logging.getLogger()
logger.addHandler(text_handler)

root.mainloop()

```

処理中のステータスラベルの動画数がいまいち合わないです(-_-;)