動画サムネイルビューアーを作りました。フリーアプリとして公開します!

22

麻衣子です。

いつも大量の動画から映像シーンをいちいち覚えていないので、便利なサムネイル表示できるアプリがないか探していたのですが、サムネイルを一時ファイルとして作成するようなアプリばかりで、いまいち使い辛かったので自分で作成しました。

フォルダ内のすべての動画をサムネイル表示できるアプリです。1次ファイルなど作りません。サムネイルをクリックすれば普段使いの動画アプリが起動して動画を視聴できます。winodows専用アプリです。

ファイルサイズが大きいのでダウンロードに少し時間がかかります。

ついでに作成したコードも紹介しますね。といってもChatGPTで作っているので自分でコードを作成していませんがw

import sys
import os
import cv2
import platform
import math
from concurrent.futures import ThreadPoolExecutor, as_completed

from PyQt6.QtWidgets import (
    QApplication, QMainWindow, QPushButton, QWidget, QLabel,
    QFileDialog, QScrollArea, QVBoxLayout, QHBoxLayout
)
from PyQt6.QtGui import QPixmap, QImage, QCursor
from PyQt6.QtCore import Qt, QThread, pyqtSignal, QObject

def extract_thumbnails(video_path, num_thumbs=10):
    cap = cv2.VideoCapture(video_path)
    thumbs = []
    total_frames = int(cap.get(cv2.CAP_PROP_FRAME_COUNT))
    if total_frames <= 0:
        cap.release()
        return None
    interval = total_frames // (num_thumbs + 1)
    for i in range(1, num_thumbs + 1):
        cap.set(cv2.CAP_PROP_POS_FRAMES, i * interval)
        ret, frame = cap.read()
        if ret:
            frame = cv2.cvtColor(frame, cv2.COLOR_BGR2RGB)
            thumbs.append(frame.copy())
    cap.release()
    return thumbs if thumbs else None

def open_video(video_path):
    try:
        if platform.system() == "Windows":
            os.startfile(video_path)
        elif platform.system() == "Darwin":
            os.system(f"open '{video_path}'")
        else:
            os.system(f"xdg-open '{video_path}'")
    except Exception as e:
        print(f"[ERROR] 動画再生失敗: {e}")

class Worker(QObject):
    progress = pyqtSignal(str)
    video_loaded = pyqtSignal(int, int, str, list)
    finished = pyqtSignal()

    def __init__(self, folder):
        super().__init__()
        self.folder = folder
        self._stop = False

    def stop(self):
        self._stop = True

    def run(self):
        video_files = []
        # 指定フォルダ内の動画ファイルを探索
        for root_dir, _, files in os.walk(self.folder):
            for f in files:
                if f.lower().endswith(('.mp4', '.avi', '.mov', '.mkv')):
                    video_files.append(os.path.join(root_dir, f))
        total = len(video_files)
        idx = 0
        max_workers = 30  # 環境に合わせて並列数を調整可能

        # ThreadPoolExecutor を利用して並列処理
        with ThreadPoolExecutor(max_workers=max_workers) as executor:
            future_to_video = {executor.submit(extract_thumbnails, video): video for video in video_files}
            for future in as_completed(future_to_video):
                if self._stop:
                    break
                idx += 1
                video = future_to_video[future]
                try:
                    thumbs = future.result()
                except Exception as e:
                    thumbs = []
                self.progress.emit(f"{idx}/{total} を処理中: {os.path.basename(video)}")
                self.video_loaded.emit(idx, total, video, thumbs if thumbs else [])
        self.finished.emit()

class VideoThumbViewer(QMainWindow):
    def __init__(self):
        super().__init__()
        self.setWindowTitle("🎞️ PyQt6 動画サムネイルビューア")
        self.setGeometry(100, 100, 1200, 800)

        self.thread = None
        self.worker = None

        # 動画エントリウィジェットの保持とページ管理
        self.video_widgets = []
        self.current_page = 0
        self.items_per_page = 100

        self.container = QWidget()
        self.layout = QVBoxLayout(self.container)

        btn_layout = QHBoxLayout()
        self.load_btn = QPushButton("📂 フォルダを選択")
        self.stop_btn = QPushButton("⏹️ 読み込みを停止")
        self.stop_btn.setEnabled(False)
        btn_layout.addWidget(self.load_btn)
        btn_layout.addWidget(self.stop_btn)
        self.layout.addLayout(btn_layout)

        self.status = QLabel("")
        self.status.setStyleSheet("color: blue")
        self.layout.addWidget(self.status)

        self.scroll_area = QScrollArea()
        self.scroll_area.setWidgetResizable(True)
        self.scroll_widget = QWidget()
        self.scroll_layout = QVBoxLayout(self.scroll_widget)
        self.scroll_area.setWidget(self.scroll_widget)
        self.layout.addWidget(self.scroll_area)

        # ページネーション用ウィジェットの設定
        self.pagination_widget = QWidget()
        self.pagination_layout = QHBoxLayout(self.pagination_widget)
        self.prev_btn = QPushButton("前のページ")
        self.next_btn = QPushButton("次のページ")
        self.page_label = QLabel("Page 1 of 1")
        self.prev_btn.clicked.connect(self.prev_page)
        self.next_btn.clicked.connect(self.next_page)
        self.pagination_layout.addWidget(self.prev_btn)
        self.pagination_layout.addWidget(self.page_label)
        self.pagination_layout.addWidget(self.next_btn)
        self.layout.addWidget(self.pagination_widget)

        self.setCentralWidget(self.container)

        self.load_btn.clicked.connect(self.load_folder)
        self.stop_btn.clicked.connect(self.stop_processing)

    def load_folder(self):
        folder = QFileDialog.getExistingDirectory(self, "フォルダを選択")
        if not folder:
            return

        # 既存ウィジェットのクリア
        for i in reversed(range(self.scroll_layout.count())):
            widget = self.scroll_layout.itemAt(i).widget()
            if widget:
                widget.deleteLater()
        self.video_widgets = []
        self.current_page = 0
        self.update_page_display()

        self.status.setText("動画をスキャン中...")
        self.stop_btn.setEnabled(True)

        self.thread = QThread()
        self.worker = Worker(folder)
        self.worker.moveToThread(self.thread)

        self.worker.progress.connect(self.status.setText)
        self.worker.video_loaded.connect(self.add_video_row)
        self.worker.finished.connect(self.processing_finished)
        self.thread.started.connect(self.worker.run)

        self.thread.start()

    def stop_processing(self):
        if self.worker:
            self.worker.stop()
        self.status.setText("⏹️ 処理を停止しました。")
        self.stop_btn.setEnabled(False)

    def processing_finished(self):
        self.status.setText("✅ 全動画の処理が完了しました!")
        self.stop_btn.setEnabled(False)
        self.thread.quit()
        self.thread.wait()

    def update_page_display(self):
        # スクロール領域内のウィジェットをクリア
        for i in reversed(range(self.scroll_layout.count())):
            widget = self.scroll_layout.itemAt(i).widget()
            if widget:
                widget.setParent(None)
        total_pages = math.ceil(len(self.video_widgets) / self.items_per_page) if self.video_widgets else 1
        if self.current_page >= total_pages:
            self.current_page = total_pages - 1
        start = self.current_page * self.items_per_page
        end = start + self.items_per_page
        for widget in self.video_widgets[start:end]:
            self.scroll_layout.addWidget(widget)
        self.page_label.setText(f"Page {self.current_page + 1} of {total_pages}")
        self.prev_btn.setEnabled(self.current_page > 0)
        self.next_btn.setEnabled(self.current_page < total_pages - 1)

    def prev_page(self):
        if self.current_page > 0:
            self.current_page -= 1
            self.update_page_display()

    def next_page(self):
        total_pages = math.ceil(len(self.video_widgets) / self.items_per_page) if self.video_widgets else 1
        if self.current_page < total_pages - 1:
            self.current_page += 1
            self.update_page_display()

    def add_video_row(self, idx, total, video_path, thumbs):
        group = QWidget()
        group_layout = QVBoxLayout(group)

        # フルパスを表示するように変更
        title = QLabel(f"[{idx} / {total}] {video_path}")
        title.setStyleSheet("font-weight: bold;")
        group_layout.addWidget(title)

        if not thumbs:
            err = QLabel("⚠️ 壊れた動画(フレーム取得不可)")
            err.setStyleSheet("color: red; font-style: italic;")
            group_layout.addWidget(err)
        else:
            row = QHBoxLayout()
            for thumb_array in thumbs:
                height, width, channel = thumb_array.shape
                bytes_per_line = 3 * width
                qimg = QImage(thumb_array.data, width, height, bytes_per_line, QImage.Format.Format_RGB888)
                pixmap = QPixmap.fromImage(qimg).scaled(249, 135, Qt.AspectRatioMode.KeepAspectRatio)
                lbl = QLabel()
                lbl.setPixmap(pixmap)
                lbl.setCursor(QCursor(Qt.CursorShape.PointingHandCursor))
                lbl.mousePressEvent = lambda e, path=video_path: open_video(path)
                row.addWidget(lbl)
            group_layout.addLayout(row)

        self.video_widgets.append(group)
        total_pages = math.ceil(len(self.video_widgets) / self.items_per_page)
        if self.current_page == total_pages - 1:
            self.update_page_display()
        else:
            self.page_label.setText(f"Page {self.current_page + 1} of {total_pages}")
            self.prev_btn.setEnabled(self.current_page > 0)
            self.next_btn.setEnabled(self.current_page < total_pages - 1)

if __name__ == "__main__":
    app = QApplication(sys.argv)
    viewer = VideoThumbViewer()
    viewer.show()
    sys.exit(app.exec())

テキトーにカスタマイズして使ってください!

それではまた!

関連記事

コメント

  1. この記事へのコメントはありません。