はじめに
以前作成したプログラムに改良を加えました(加えてもらいました)
AIをChatGPTからClaudeに変えたら格段に良くなりました。基本の性能は変わってませんが、UIが良くなり、細かい使い勝手が増している・・・はずです。
また、最新のCaesiumCLTで追加されたmodifyを更新しないコマンドを追加しています。
完全に自分用なので悪しからず。
プログラムの内容
このプログラムは、ユーザーがドラッグアンドドロップで提供した画像と動画ファイルを処理するデスクトップアプリケーションです。主な機能は、ファイルの圧縮、リネーム、および指定された出力フォルダへの移動です。
機能概要
- 画像圧縮: JPEGおよびPNGファイルをCaesiumCLTを使用して圧縮します。
- リネーム処理: ファイルのメタデータ(Exif情報やファイルの最終変更日時)に基づいてリネームを行います。
- ファイルの移動: 圧縮およびリネーム後のファイルをユーザーが指定した出力フォルダに移動します。
- 設定の保存と読み込み: 出力フォルダ、CaesiumCLTのパス、圧縮設定などのユーザー設定を保存し、アプリケーション再起動時に読み込みます。
このプログラムは、画像編集や整理作業を効率化することを目的としています。
事前準備
CaesiumCLTの準備が必要です。
CaesiumCLTはCaesiumのコマンドラインから操作できるツールでgitに公開されている為、自身でビルドが必要です。
gitからダウンロードし、Rustをインストールしてgitに書かれている通りビルドしてください。
ソースコード
import sys
import os
import shutil
import subprocess
import pickle
import math
import traceback
from datetime import datetime
from PIL import Image, UnidentifiedImageError
from PyQt5.QtWidgets import (QApplication, QWidget, QVBoxLayout, QLabel, QPushButton, QMessageBox, QProgressBar,
QFileDialog, QSpinBox, QCheckBox, QLineEdit, QHBoxLayout, QFrame, QGroupBox,
QComboBox, QSplitter, QListWidget, QMainWindow, QStatusBar, QToolTip, QSizePolicy,
QTableWidget, QTableWidgetItem, QHeaderView, QTextEdit, QTabWidget)
from PyQt5.QtCore import Qt, QCoreApplication, QThread, pyqtSignal, QSize
from PyQt5.QtGui import QIcon, QFont, QDragEnterEvent, QDropEvent, QIntValidator
from concurrent.futures import ThreadPoolExecutor, as_completed
CONFIG_FILE = "app_config.pkl"
class ProcessingOverlay(QWidget):
"""処理中に表示するオーバーレイウィジェット"""
def __init__(self, parent=None):
super().__init__(parent)
self.setVisible(False)
self.setAttribute(Qt.WA_TransparentForMouseEvents)
# オーバーレイのレイアウト
layout = QVBoxLayout(self)
layout.setAlignment(Qt.AlignCenter)
# 処理中メッセージ
message = QLabel("処理中...\nファイルをドロップしないでください")
message.setAlignment(Qt.AlignCenter)
message.setStyleSheet("""
font-size: 16px;
font-weight: bold;
color: white;
background-color: rgba(200, 0, 0, 0.7);
padding: 20px;
border-radius: 10px;
""")
layout.addWidget(message)
def showEvent(self, event):
# 親ウィジェットに合わせてサイズを調整
if self.parent():
self.setGeometry(self.parent().rect())
super().showEvent(event)
def resizeEvent(self, event):
# リサイズ時に親ウィジェットに合わせてサイズを調整
if self.parent():
self.setGeometry(self.parent().rect())
super().resizeEvent(event)
class DropArea(QFrame):
files_dropped = pyqtSignal(list) # 新しいシグナルを追加
def __init__(self):
super().__init__()
self.setFrameStyle(QFrame.StyledPanel | QFrame.Sunken)
self.setAcceptDrops(True)
self.setMinimumHeight(100)
self.layout = QVBoxLayout(self)
self.label = QLabel("画像・動画ファイルをここにドロップしてください")
self.label.setAlignment(Qt.AlignCenter)
self.label.setStyleSheet("font-size: 14px; color: #555;")
self.layout.addWidget(self.label)
self.setStyleSheet("""
QFrame {
border: 2px dashed #aaa;
border-radius: 5px;
background-color: #f8f8f8;
}
QFrame:hover {
border-color: #66afe9;
background-color: #f0f7ff;
}
""")
# 処理中オーバーレイの追加
self.overlay = ProcessingOverlay(self)
def dragEnterEvent(self, event: QDragEnterEvent):
if event.mimeData().hasUrls():
event.acceptProposedAction()
else:
event.ignore()
def dropEvent(self, event: QDropEvent):
if event.mimeData().hasUrls():
event.acceptProposedAction()
files = [url.toLocalFile() for url in event.mimeData().urls()]
# シグナルを発行する
self.files_dropped.emit(files)
def showProcessingOverlay(self, show=True):
"""処理中オーバーレイの表示/非表示を切り替える"""
self.overlay.setVisible(show)
# 処理中はドロップを受け付けない
self.setAcceptDrops(not show)
# 処理中は見た目も変える
if show:
self.setStyleSheet("""
QFrame {
border: 2px solid #ff0000;
border-radius: 5px;
background-color: #fff0f0;
}
""")
else:
self.setStyleSheet("""
QFrame {
border: 2px dashed #aaa;
border-radius: 5px;
background-color: #f8f8f8;
}
QFrame:hover {
border-color: #66afe9;
background-color: #f0f7ff;
}
""")
class ProcessingThread(QThread):
progress_update = pyqtSignal(int)
file_processed = pyqtSignal(str, str, float) # ファイル名, 詳細情報, 圧縮率
processing_complete = pyqtSignal(list)
error_occurred = pyqtSignal(str, str)
log_update = pyqtSignal(str) # ログ更新用シグナル
def __init__(self, files, output_folder, thread_count, caesium_path, compress_images):
super().__init__()
self.files = files
self.output_folder = output_folder
self.thread_count = thread_count
self.caesium_path = caesium_path
self.compress_images = compress_images
self.processed_files = []
self.selected_size = 4898 # デフォルトサイズ
def run(self):
try:
# 処理開始のログ
self.log_update.emit("処理を開始します...")
self.log_update.emit(f"対象ファイル数: {len(self.files)}個")
self.log_update.emit(f"スレッド数: {self.thread_count}")
self.log_update.emit(f"圧縮設定: {'有効' if self.compress_images else '無効'}")
if self.compress_images:
self.log_update.emit(f"長辺サイズ: {self.selected_size if self.selected_size > 0 else '原寸大'}")
# 処理カウンター(ロック付き)
self.completed_count = 0
with ThreadPoolExecutor(max_workers=self.thread_count) as executor:
futures = [executor.submit(self.process_file, file) for file in self.files]
for future in as_completed(futures):
result = future.result()
if result:
self.processed_files.append(result)
# 処理完了のログ
self.log_update.emit("ファイルの処理が完了しました。リネーム処理を開始します...")
# リネーム処理
renamed_files = self.rename_files()
self.processing_complete.emit(renamed_files)
except Exception as e:
self.error_occurred.emit("処理エラー", f"処理中にエラーが発生しました: {str(e)}")
self.log_update.emit(f"エラー: {str(e)}")
self.log_update.emit(traceback.format_exc())
def process_file(self, file_path):
try:
ext = os.path.splitext(file_path)[1].lower()
result_path = None
original_size = os.path.getsize(file_path)
self.log_update.emit(f"処理開始: {os.path.basename(file_path)} ({self.convert_size(original_size)})")
if ext in [".jpg", ".jpeg", ".png"]:
if self.compress_images:
self.log_update.emit(f" 圧縮処理開始: {os.path.basename(file_path)}")
result_path = self.compress_image(file_path)
else:
result_path = self.copy_file(file_path)
elif ext in [".mov", ".mp4"]:
result_path = self.copy_file(file_path)
# 処理後のファイル情報
if result_path and os.path.exists(result_path):
new_size = os.path.getsize(result_path)
ratio = (new_size / original_size) * 100 if original_size > 0 else 100
# 詳細情報を作成
details = f"元サイズ: {self.convert_size(original_size)}, 処理後: {self.convert_size(new_size)}"
self.log_update.emit(f" 処理完了: {os.path.basename(result_path)}")
self.log_update.emit(f" 圧縮率: {ratio:.1f}% ({self.convert_size(original_size)} → {self.convert_size(new_size)})")
# UI更新のシグナル発行
self.file_processed.emit(os.path.basename(result_path), details, ratio)
# 処理カウンターを増やす
self.completed_count += 1
# 進捗更新 - カウンターで制御
self.progress_update.emit(self.completed_count)
return result_path
except Exception as e:
error_msg = f"{os.path.basename(file_path)}の処理中にエラーが発生しました: {str(e)}"
self.error_occurred.emit("ファイル処理エラー", error_msg)
self.log_update.emit(f"エラー: {error_msg}")
return None
def compress_image(self, image_path):
try:
ext = os.path.splitext(image_path)[1].lower()
if ext in [".jpg", ".jpeg", ".png"]:
# 一時的に処理用の名前を付ける
temp_output = os.path.join(self.output_folder, f"temp_{os.path.basename(image_path)}")
try:
img = Image.open(image_path)
width, height = img.size
img.close()
# 長辺のサイズを特定
compress_arg = "--width" if width > height else "--height"
if self.selected_size == 0:
# 原寸大の場合は、元の画像サイズをそのまま指定(圧縮のみ行う)
compress_value = str(width if width > height else height)
else:
# 選択されたサイズを使用
compress_value = str(self.selected_size)
# 画像サイズが長辺サイズ以下の場合は、元のサイズを使用
if width <= self.selected_size and height <= self.selected_size:
compress_value = str(width if width > height else height)
# Caesium CLTコマンドの構築
compress_command = [
self.caesium_path,
"-q", "90", # JPEG品質を90%に設定
"--keep-dates", # 変更日時を保持するオプション
compress_arg, compress_value,
"-e", # 既存ファイルを上書き
"-o", self.output_folder,
image_path
]
# Caesium CLTコマンドのログ出力
self.log_update.emit(f" 実行コマンド: {' '.join(compress_command)}")
# Caesium CLTが存在するか確認
if not os.path.exists(self.caesium_path):
raise FileNotFoundError(f"CaesiumCLTが見つかりません: {self.caesium_path}")
# サブプロセスとして実行し、出力を取得
process = subprocess.Popen(
compress_command,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
universal_newlines=True,
bufsize=1
)
# リアルタイムに出力を取得
for line in process.stdout:
line = line.strip()
if line:
self.log_update.emit(f" CaesiumCLT: {line}")
# エラー出力も取得
for line in process.stderr:
line = line.strip()
if line:
self.log_update.emit(f" CaesiumCLT (エラー): {line}")
# プロセスの終了を待つ
process.wait()
if process.returncode != 0:
self.log_update.emit(f" CaesiumCLT実行エラー: 終了コード {process.returncode}")
return self.copy_file(image_path)
output_path = os.path.join(self.output_folder, os.path.basename(image_path))
if os.path.exists(output_path):
self.log_update.emit(f" 圧縮成功: {os.path.basename(output_path)}")
return output_path
else:
# 圧縮に失敗した場合は元のファイルをコピー
self.log_update.emit(f" 圧縮ファイルが見つかりません。コピーに切り替えます。")
return self.copy_file(image_path)
except (UnidentifiedImageError, OSError) as img_err:
# 画像ファイルではない、または開けない場合
error_msg = f"{os.path.basename(image_path)}を開けませんでした: {str(img_err)}"
self.error_occurred.emit("画像処理エラー", error_msg)
self.log_update.emit(f" エラー: {error_msg}")
return self.copy_file(image_path)
except subprocess.CalledProcessError as sub_err:
# CaesiumCLTの実行中にエラーが発生した場合
error_msg = f"{os.path.basename(image_path)}の圧縮に失敗しました: {sub_err.stderr}"
self.error_occurred.emit("圧縮エラー", error_msg)
self.log_update.emit(f" エラー: {error_msg}")
return self.copy_file(image_path)
except Exception as e:
error_msg = f"{os.path.basename(image_path)}の処理中に予期しないエラーが発生しました: {str(e)}"
self.error_occurred.emit("圧縮エラー", error_msg)
self.log_update.emit(f" エラー: {error_msg}")
return self.copy_file(image_path)
def copy_file(self, file_path):
try:
# 出力フォルダが存在しなければ作成
if not os.path.exists(self.output_folder):
os.makedirs(self.output_folder)
output_path = os.path.join(self.output_folder, os.path.basename(file_path))
self.log_update.emit(f" コピー: {os.path.basename(file_path)} → {output_path}")
shutil.copy2(file_path, output_path)
self.log_update.emit(f" コピー完了: {os.path.basename(output_path)}")
return output_path
except Exception as e:
error_msg = f"{os.path.basename(file_path)}のコピー中にエラーが発生しました: {str(e)}"
self.error_occurred.emit("コピーエラー", error_msg)
self.log_update.emit(f" エラー: {error_msg}")
return None
def rename_files(self):
renamed_files = []
for file_path in self.processed_files:
if file_path and os.path.exists(file_path):
try:
ext = os.path.splitext(file_path)[1].lower()
timestamp = self.get_timestamp(file_path, ext)
new_filename = timestamp.strftime("%Y%m%d-%H%M%S")
new_filepath = self.generate_new_filepath(self.output_folder, new_filename, ext)
# リネーム処理
self.log_update.emit(f"リネーム: {os.path.basename(file_path)} → {os.path.basename(new_filepath)}")
os.rename(file_path, new_filepath)
renamed_files.append(new_filepath)
except Exception as e:
error_msg = f"{os.path.basename(file_path)}のリネーム中にエラーが発生しました: {str(e)}"
self.error_occurred.emit("リネームエラー", error_msg)
self.log_update.emit(f"エラー: {error_msg}")
renamed_files.append(file_path) # 元のファイル名のままリストに追加
return renamed_files
def generate_new_filepath(self, folder, base_filename, ext):
counter = 0
new_filepath = os.path.join(folder, f"{base_filename}-{counter}{ext}")
# 既に同名ファイルが存在する場合は連番を付ける
while os.path.exists(new_filepath):
counter += 1
new_filepath = os.path.join(folder, f"{base_filename}-{counter}{ext}")
return new_filepath
def get_timestamp(self, file_path, ext):
try:
if ext in [".jpg", ".jpeg"]:
# JPEGファイルの場合、Exif情報を使用
try:
img = Image.open(file_path)
exif_data = img._getexif()
img.close()
if exif_data and 36867 in exif_data: # 36867 = DateTimeOriginal
timestamp = exif_data[36867]
return datetime.strptime(timestamp, "%Y:%m:%d %H:%M:%S")
except (AttributeError, KeyError, ValueError, TypeError):
# Exif情報が取得できない場合はファイルの最終変更日時を使用
pass
# Exif情報が使えない場合や他の拡張子の場合はファイルの最終変更日時を使用
return datetime.fromtimestamp(os.path.getmtime(file_path))
except Exception as e:
error_msg = f"{os.path.basename(file_path)}のタイムスタンプ取得中にエラーが発生しました: {str(e)}"
self.error_occurred.emit("タイムスタンプエラー", error_msg)
self.log_update.emit(f"エラー: {error_msg}")
# エラーが発生した場合は現在時刻を使用
return datetime.now()
def convert_size(self, size_bytes):
if size_bytes == 0:
return "0B"
size_name = ("B", "KB", "MB", "GB", "TB", "PB", "EB", "ZB", "YB")
i = int(math.floor(math.log(size_bytes, 1024)))
p = math.pow(1024, i)
s = round(size_bytes / p, 2)
return f"{s} {size_name[i]}"
class ImageVideoProcessorApp(QMainWindow):
def __init__(self):
super().__init__()
self.setWindowTitle("写真リネーム&圧縮ツール")
self.setGeometry(100, 100, 900, 700) # ウィンドウサイズを大きくする
self.init_ui()
self.load_config()
self.processed_files = []
self.is_processing = False # 処理中フラグを追加
def init_ui(self):
# メインウィジェットとレイアウト
central_widget = QWidget()
self.setCentralWidget(central_widget)
main_layout = QVBoxLayout(central_widget)
# ドロップエリア
self.drop_area = DropArea()
self.drop_area.files_dropped.connect(self.handle_dropped_files) # シグナル接続
# 設定グループボックス
settings_group = QGroupBox("設定")
settings_layout = QVBoxLayout()
# 出力フォルダ
output_layout = QHBoxLayout()
output_layout.addWidget(QLabel("出力フォルダ:"))
self.output_folder_edit = QLineEdit()
self.output_folder_edit.setPlaceholderText("出力先のフォルダを選択してください")
output_layout.addWidget(self.output_folder_edit)
self.output_folder_button = QPushButton("参照...")
self.output_folder_button.clicked.connect(self.select_output_folder)
output_layout.addWidget(self.output_folder_button)
settings_layout.addLayout(output_layout)
# CaesiumCLTのパス
caesium_layout = QHBoxLayout()
caesium_layout.addWidget(QLabel("CaesiumCLTのパス:"))
self.caesium_path_edit = QLineEdit()
self.caesium_path_edit.setPlaceholderText("CaesiumCLTの実行ファイルを選択してください")
caesium_layout.addWidget(self.caesium_path_edit)
self.caesium_path_button = QPushButton("参照...")
self.caesium_path_button.clicked.connect(self.select_caesium_path)
caesium_layout.addWidget(self.caesium_path_button)
settings_layout.addLayout(caesium_layout)
# スレッド数と圧縮オプション
options_layout = QHBoxLayout()
# スレッド数
thread_layout = QHBoxLayout()
thread_layout.addWidget(QLabel("スレッド数:"))
self.thread_count_spin = QSpinBox()
self.thread_count_spin.setMinimum(1)
self.thread_count_spin.setMaximum(32)
self.thread_count_spin.setValue(16)
thread_layout.addWidget(self.thread_count_spin)
options_layout.addLayout(thread_layout)
# 圧縮オプション
self.compress_check = QCheckBox("画像を圧縮する")
self.compress_check.setChecked(True)
options_layout.addWidget(self.compress_check)
# 長辺サイズオプション
options_layout.addWidget(QLabel("長辺サイズ指定:"))
self.size_combo = QComboBox()
self.size_combo.addItems(["原寸大(変更なし)", "4898px (推奨)", "3000px", "2000px", "1500px", "カスタム"])
self.size_combo.currentIndexChanged.connect(self.on_size_option_changed)
options_layout.addWidget(self.size_combo)
# カスタムサイズ入力用テキストボックス
self.custom_size_edit = QLineEdit()
self.custom_size_edit.setPlaceholderText("例: 4000")
self.custom_size_edit.setValidator(QIntValidator(100, 10000)) # 100px〜10000pxの範囲で数値のみ許可
self.custom_size_edit.setVisible(False) # 初期状態では非表示
options_layout.addWidget(self.custom_size_edit)
settings_layout.addLayout(options_layout)
settings_group.setLayout(settings_layout)
# タブウィジェットの作成(ファイル一覧とログ用)
self.tabs = QTabWidget()
# ファイル一覧タブ
file_tab = QWidget()
file_layout = QVBoxLayout(file_tab)
# テーブルウィジェット
self.file_table = QTableWidget()
self.file_table.setColumnCount(3)
self.file_table.setHorizontalHeaderLabels(["ファイル名", "詳細", "圧縮率"])
self.file_table.horizontalHeader().setSectionResizeMode(0, QHeaderView.Stretch)
self.file_table.horizontalHeader().setSectionResizeMode(1, QHeaderView.Stretch)
self.file_table.horizontalHeader().setSectionResizeMode(2, QHeaderView.ResizeToContents)
file_layout.addWidget(self.file_table)
# ログタブ
log_tab = QWidget()
log_layout = QVBoxLayout(log_tab)
self.log_text = QTextEdit()
self.log_text.setReadOnly(True)
log_layout.addWidget(self.log_text)
# タブに追加
self.tabs.addTab(file_tab, "ファイル一覧")
self.tabs.addTab(log_tab, "処理ログ")
# プログレスバー
progress_layout = QHBoxLayout()
self.progress_bar = QProgressBar()
self.progress_bar.setTextVisible(True)
progress_layout.addWidget(self.progress_bar)
# 実行ボタン
button_layout = QHBoxLayout()
self.process_button = QPushButton("ファイルを選択して処理")
self.process_button.clicked.connect(self.select_files)
self.process_button.setStyleSheet("""
QPushButton {
background-color: #4CAF50;
color: white;
border: none;
padding: 10px;
border-radius: 4px;
font-weight: bold;
}
QPushButton:hover {
background-color: #45a049;
}
QPushButton:pressed {
background-color: #3d8b40;
}
""")
button_layout.addWidget(self.process_button)
# レイアウトに追加
main_layout.addWidget(self.drop_area)
main_layout.addWidget(settings_group)
main_layout.addWidget(self.tabs, 1) # タブに伸縮余地を与える
main_layout.addLayout(progress_layout)
main_layout.addLayout(button_layout)
# ステータスバー
self.status_bar = QStatusBar()
self.setStatusBar(self.status_bar)
self.status_bar.showMessage("準備完了")
# タブ変更イベントに接続
self.tabs.currentChanged.connect(self.on_tab_changed)
# 設定の保存
self.output_folder_edit.textChanged.connect(self.save_config)
self.caesium_path_edit.textChanged.connect(self.save_config)
self.thread_count_spin.valueChanged.connect(self.save_config)
self.compress_check.stateChanged.connect(self.save_config)
# 処理中に無効化する入力コントロールのリスト
self.input_controls = [
self.output_folder_edit, self.output_folder_button,
self.caesium_path_edit, self.caesium_path_button,
self.thread_count_spin, self.compress_check,
self.size_combo, self.custom_size_edit, self.process_button
]
def set_ui_enabled(self, enabled=True):
"""処理中にUIコントロールの有効/無効を切り替える"""
for control in self.input_controls:
control.setEnabled(enabled)
# ドロップエリアのオーバーレイ表示
self.drop_area.showProcessingOverlay(not enabled)
# 処理中フラグを更新
self.is_processing = not enabled
def select_output_folder(self):
folder = QFileDialog.getExistingDirectory(self, "出力フォルダを選択")
if folder:
self.output_folder_edit.setText(folder)
def select_caesium_path(self):
# Macと他のプラットフォームの両方に対応したフィルタ
if sys.platform == 'darwin': # macOS
filter_str = "すべてのファイル (*)"
else: # Windows
filter_str = "実行ファイル (*.exe);;すべてのファイル (*.*)"
path, _ = QFileDialog.getOpenFileName(self, "CaesiumCLTの場所を選択", "", filter_str)
if path:
self.caesium_path_edit.setText(path)
def select_files(self):
files, _ = QFileDialog.getOpenFileNames(
self, "処理するファイルを選択", "",
"画像・動画ファイル (*.jpg *.jpeg *.png *.mov *.mp4);;すべてのファイル (*.*)"
)
if files:
self.handle_dropped_files(files)
def handle_dropped_files(self, files):
# 処理中の場合は何もしない
if self.is_processing:
QMessageBox.warning(self, "処理中", "現在処理中です。完了するまでお待ちください。")
return
# 有効なファイルのみフィルタリング
valid_extensions = [".jpg", ".jpeg", ".png", ".mov", ".mp4"]
valid_files = [f for f in files if os.path.splitext(f)[1].lower() in valid_extensions]
if not valid_files:
QMessageBox.warning(self, "警告", "有効なファイルが選択されていません。\n対応形式: JPG, PNG, MOV, MP4")
return
# 出力フォルダが指定されているか確認
if not self.output_folder_edit.text():
QMessageBox.warning(self, "警告", "出力フォルダを指定してください。")
return
# 圧縮がチェックされている場合にCaesiumのパスが指定されているか確認
if self.compress_check.isChecked() and not self.caesium_path_edit.text():
QMessageBox.warning(self, "警告", "画像圧縮を行う場合はCaesiumCLTのパスを指定してください。")
return
# 選択されたサイズが有効か確認
selected_size = self.get_selected_size()
# 原寸大(0)の場合はサイズチェックをスキップする
if selected_size != 0 and selected_size < 100:
QMessageBox.warning(self, "警告", "長辺サイズは100px以上を指定してください。")
return
# UIを無効化
self.set_ui_enabled(False)
# 進捗情報をクリア
self.progress_bar.setMaximum(len(valid_files))
self.progress_bar.setValue(0)
self.file_table.setRowCount(0)
self.log_text.clear()
self.processed_files = []
# 処理情報を表示
size_info = "原寸大(サイズ変更なし)" if selected_size == 0 else f"{selected_size}px"
compression_info = "圧縮あり (JPEGクオリティ:90%)" if self.compress_check.isChecked() else "圧縮なし"
self.status_bar.showMessage(f"処理中... {len(valid_files)}ファイル | 長辺サイズ: {size_info} | {compression_info}")
# 処理用スレッド作成
self.processing_thread = ProcessingThread(
valid_files,
self.output_folder_edit.text(),
self.thread_count_spin.value(),
self.caesium_path_edit.text(),
self.compress_check.isChecked()
)
# 選択されたサイズを設定
self.processing_thread.selected_size = selected_size
# シグナルの接続
self.processing_thread.progress_update.connect(self.update_progress)
self.processing_thread.file_processed.connect(self.add_processed_file)
self.processing_thread.processing_complete.connect(self.processing_completed)
self.processing_thread.error_occurred.connect(self.show_error)
self.processing_thread.log_update.connect(self.update_log)
# スレッド開始
self.processing_thread.start()
def update_progress(self, value):
self.progress_bar.setValue(value)
def update_log(self, message):
"""ログメッセージを追加"""
self.log_text.append(message)
# スクロールを最下部に移動
self.log_text.verticalScrollBar().setValue(self.log_text.verticalScrollBar().maximum())
# ログタブのインジケーターを更新(新しいメッセージがある場合)
if self.tabs.currentIndex() != 1: # 現在ログタブが表示されていない場合
# タブのテキストを変更して新しいログがあることを示す
self.tabs.setTabText(1, "処理ログ *")
def add_processed_file(self, file_name, details, compression_ratio):
"""処理済みファイルをテーブルに追加"""
row = self.file_table.rowCount()
self.file_table.insertRow(row)
# ファイル名
self.file_table.setItem(row, 0, QTableWidgetItem(file_name))
# 詳細情報
self.file_table.setItem(row, 1, QTableWidgetItem(details))
# 圧縮率
ratio_item = QTableWidgetItem(f"{compression_ratio:.1f}%")
# 圧縮率によって背景色を変更
if compression_ratio < 50: # 50%未満の圧縮率は緑(良好)
ratio_item.setBackground(Qt.green)
elif compression_ratio < 80: # 80%未満の圧縮率は黄色(普通)
ratio_item.setBackground(Qt.yellow)
else: # 80%以上の圧縮率は赤(効果少)
ratio_item.setBackground(Qt.red)
self.file_table.setItem(row, 2, ratio_item)
# 表の最下部にスクロール
self.file_table.scrollToBottom()
def processing_completed(self, processed_files):
self.processed_files = processed_files
# UIを再度有効化
self.set_ui_enabled(True)
# ログタブのインジケーターをリセット
self.tabs.setTabText(1, "処理ログ")
# 圧縮前後の合計サイズを計算
total_original_size = 0
total_processed_size = 0
for file_path in self.processed_files:
if os.path.exists(file_path):
processed_size = os.path.getsize(file_path)
total_processed_size += processed_size
# 圧縮前のファイルサイズは完全には取得できないが、
# テーブルから取得できる情報を使用して推定
for row in range(self.file_table.rowCount()):
file_item = self.file_table.item(row, 0)
if file_item and file_item.text() == os.path.basename(file_path):
detail_item = self.file_table.item(row, 1)
ratio_item = self.file_table.item(row, 2)
if detail_item and ratio_item:
# 詳細テキストから元のサイズを抽出(仮定:「元サイズ: X MB, 処理後: Y MB」の形式)
detail_text = detail_item.text()
if "元サイズ:" in detail_text and ", 処理後:" in detail_text:
try:
# ここでは単純に処理後のサイズと圧縮率から元のサイズを推定
ratio_text = ratio_item.text().replace("%", "")
ratio = float(ratio_text) / 100
if ratio > 0:
estimated_original = processed_size / ratio
total_original_size += estimated_original
except Exception:
pass
readable_processed_size = self.convert_size(total_processed_size)
readable_original_size = self.convert_size(total_original_size) if total_original_size > 0 else "不明"
# 圧縮率の計算
compression_ratio = (total_processed_size / total_original_size * 100) if total_original_size > 0 else 0
# ログに処理完了を記録
self.update_log("====== 処理完了 ======")
self.update_log(f"処理ファイル数: {len(processed_files)}個")
self.update_log(f"元のサイズ(推定): {readable_original_size}")
self.update_log(f"処理後のサイズ: {readable_processed_size}")
if compression_ratio > 0:
self.update_log(f"全体の圧縮率: {compression_ratio:.1f}%")
# 処理完了メッセージ
QMessageBox.information(
self,
"処理完了",
f"全てのファイルの処理が完了しました。\n"
f"処理ファイル数: {len(processed_files)}個\n"
f"元のサイズ(推定): {readable_original_size}\n"
f"処理後のサイズ: {readable_processed_size}\n"
f"全体の圧縮率: {compression_ratio:.1f}%"
)
else:
# 圧縮率が計算できない場合のメッセージ
QMessageBox.information(
self,
"処理完了",
f"全てのファイルの処理が完了しました。\n"
f"処理ファイル数: {len(processed_files)}個\n"
f"処理後の合計サイズ: {readable_processed_size}"
)
# UI更新
self.status_bar.showMessage(f"処理完了: {len(processed_files)}ファイル ({readable_processed_size})")
# 出力フォルダを開くオプション
reply = QMessageBox.question(
self,
"出力フォルダを開く",
"出力フォルダを開きますか?",
QMessageBox.Yes | QMessageBox.No,
QMessageBox.Yes
)
if reply == QMessageBox.Yes:
self.open_output_folder()
def on_size_option_changed(self, index):
# カスタムが選択された場合のみカスタムサイズ入力フィールドを表示
is_custom = (index == 5) # 6番目の項目(インデックス5)が「カスタム」
self.custom_size_edit.setVisible(is_custom)
if not is_custom:
# カスタム以外が選択された場合、対応するピクセル値をセット
if index == 0:
self.custom_size_edit.setText("0") # 原寸大
elif index == 1:
self.custom_size_edit.setText("4898") # 推奨
elif index == 2:
self.custom_size_edit.setText("3000")
elif index == 3:
self.custom_size_edit.setText("2000")
elif index == 4:
self.custom_size_edit.setText("1500")
def get_selected_size(self):
"""選択された長辺サイズを返す。0の場合は原寸大を意味する"""
try:
if self.size_combo.currentIndex() == 5: # カスタム
# カスタム入力の場合、入力値を使用
size_text = self.custom_size_edit.text().strip()
return int(size_text) if size_text else 4898 # 空の場合はデフォルト値
else:
# プリセットの場合、対応する値を返す
size_texts = ["0", "4898", "3000", "2000", "1500"]
return int(size_texts[self.size_combo.currentIndex()])
except (ValueError, IndexError):
return 4898 # エラー時はデフォルト値
def show_error(self, title, message):
# エラーをステータスバーに表示するとともに、ログに追加
self.status_bar.showMessage(f"エラー: {message}")
self.update_log(f"❌ エラー: {title} - {message}")
# エラー行をテーブルに追加
row = self.file_table.rowCount()
self.file_table.insertRow(row)
error_item = QTableWidgetItem(f"エラー: {message}")
error_item.setBackground(Qt.red)
error_item.setForeground(Qt.white)
self.file_table.setItem(row, 0, error_item)
self.file_table.setSpan(row, 0, 1, 3) # セルを結合して3列分表示
def calculate_total_size(self):
total_size = 0
for file_path in self.processed_files:
if os.path.exists(file_path):
total_size += os.path.getsize(file_path)
return total_size
def convert_size(self, size_bytes):
if size_bytes == 0:
return "0B"
size_name = ("B", "KB", "MB", "GB", "TB", "PB", "EB", "ZB", "YB")
i = int(math.floor(math.log(size_bytes, 1024)))
p = math.pow(1024, i)
s = round(size_bytes / p, 2)
return f"{s} {size_name[i]}"
def open_output_folder(self):
output_folder = self.output_folder_edit.text()
if os.path.exists(output_folder):
# プラットフォームに応じてフォルダを開く
if sys.platform == 'win32':
os.startfile(output_folder)
elif sys.platform == 'darwin': # macOS
subprocess.run(['open', output_folder])
else: # Linux
subprocess.run(['xdg-open', output_folder])
def load_config(self):
try:
if os.path.exists(CONFIG_FILE):
with open(CONFIG_FILE, 'rb') as f:
config = pickle.load(f)
self.output_folder_edit.setText(config.get('output_folder', ''))
self.thread_count_spin.setValue(config.get('thread_count', 16))
self.caesium_path_edit.setText(config.get('caesium_path', ''))
self.compress_check.setChecked(config.get('compress', True))
# サイズ設定の読み込み
size_index = config.get('size_index', 0)
self.size_combo.setCurrentIndex(size_index)
# カスタムサイズの読み込み
custom_size = config.get('custom_size', '4898')
self.custom_size_edit.setText(custom_size)
# コンボボックスの状態に応じてカスタムサイズ入力欄の表示/非表示を切り替え
self.on_size_option_changed(size_index)
# 起動時のログメッセージ
self.update_log("アプリケーションを起動しました")
self.update_log("設定を読み込みました")
self.update_log(f"- 出力フォルダ: {self.output_folder_edit.text() or '未設定'}")
self.update_log(f"- CaesiumCLTパス: {self.caesium_path_edit.text() or '未設定'}")
self.update_log(f"- スレッド数: {self.thread_count_spin.value()}")
self.update_log(f"- 圧縮設定: {'有効' if self.compress_check.isChecked() else '無効'}")
self.update_log("画像・動画ファイルをドロップするか、「ファイルを選択して処理」ボタンをクリックしてください")
except Exception as e:
self.status_bar.showMessage(f"設定読み込みエラー: {str(e)}")
self.update_log(f"設定読み込みエラー: {str(e)}")
def save_config(self):
try:
config = {
'output_folder': self.output_folder_edit.text(),
'thread_count': self.thread_count_spin.value(),
'caesium_path': self.caesium_path_edit.text(),
'compress': self.compress_check.isChecked(),
'size_index': self.size_combo.currentIndex(),
'custom_size': self.custom_size_edit.text()
}
with open(CONFIG_FILE, 'wb') as f:
pickle.dump(config, f)
except Exception as e:
self.status_bar.showMessage(f"設定保存エラー: {str(e)}")
self.update_log(f"設定保存エラー: {str(e)}")
def closeEvent(self, event):
self.save_config()
event.accept()
# タブの切り替えイベントを処理するメソッド(オプション)
def on_tab_changed(self, index):
# ログタブが選択された場合、新規メッセージ通知をクリア
if index == 1: # ログタブ
self.tabs.setTabText(1, "処理ログ")
# メインコード部分(変更なし)
if __name__ == "__main__":
# 既存のインスタンスがあれば終了し、新しいインスタンスを作成する
# これにより、アプリを再実行した際にエラーが出るのを防止
app = QApplication(sys.argv)
for widget in QApplication.topLevelWidgets():
if isinstance(widget, ImageVideoProcessorApp):
widget.close()
app.setStyle('Fusion') # 一貫したUIスタイル
app.setApplicationName("画像・動画処理アプリ (詳細表示版)")
# エラーハンドリング(予期しないエラー用)
def exception_hook(exctype, value, traceback_obj):
print(exctype, value, traceback_obj)
sys.__excepthook__(exctype, value, traceback_obj)
# トレースバック情報を文字列として取得
tb_lines = []
for line in traceback.format_tb(traceback_obj):
tb_lines.append(line)
tb_text = ''.join(tb_lines)
# エラーダイアログを表示
error_msg = QMessageBox()
error_msg.setIcon(QMessageBox.Critical)
error_msg.setText("予期しないエラーが発生しました")
error_msg.setInformativeText(str(value))
error_msg.setDetailedText(tb_text) # 詳細なトレースバック情報を追加
error_msg.setWindowTitle("エラー")
error_msg.setMinimumWidth(600) # ダイアログの最小幅を設定
error_msg.exec_()
sys.excepthook = exception_hook
# アプリケーションのスタイル設定(タブ用のスタイルも追加)
app.setStyleSheet("""
QMainWindow, QDialog {
background-color: #f5f5f5;
}
QGroupBox {
border: 1px solid #ddd;
border-radius: 5px;
margin-top: 1ex;
padding-top: 10px;
}
QGroupBox::title {
subcontrol-origin: margin;
subcontrol-position: top center;
padding: 0 3px;
background-color: #f5f5f5;
}
QPushButton {
padding: 5px 10px;
border-radius: 3px;
border: 1px solid #bbb;
background-color: #e6e6e6;
}
QPushButton:hover {
background-color: #d7d7d7;
}
QLineEdit, QSpinBox, QComboBox {
padding: 5px;
border: 1px solid #bbb;
border-radius: 3px;
}
QProgressBar {
border: 1px solid #bbb;
border-radius: 3px;
text-align: center;
height: 20px;
}
QProgressBar::chunk {
background-color: #4CAF50;
width: 1px;
}
QTableWidget {
border: 1px solid #bbb;
border-radius: 3px;
background-color: white;
}
QTextEdit {
border: 1px solid #bbb;
border-radius: 3px;
background-color: white;
font-family: Consolas, Courier, monospace;
}
QTabWidget::pane {
border: 1px solid #bbb;
border-radius: 3px;
position: absolute;
top: -1px;
}
QTabBar::tab {
background-color: #e0e0e0;
border: 1px solid #bbb;
border-bottom-color: #bbb;
border-top-left-radius: 4px;
border-top-right-radius: 4px;
padding: 6px 10px;
margin-right: 2px;
}
QTabBar::tab:selected, QTabBar::tab:hover {
background-color: #f5f5f5;
}
QTabBar::tab:selected {
border-color: #9B9B9B;
border-bottom-color: #f5f5f5;
}
""")
window = ImageVideoProcessorApp()
window.show()
sys.exit(app.exec_())
使い方
1. 初期設定
アプリを起動したら、まず以下の設定を行います:
- 出力フォルダの設定:
- 「参照…」ボタンをクリックして、処理後のファイルを保存するフォルダを選択します。
- CaesiumCLTのパス設定:
- 「参照…」ボタンをクリックしてビルドしたCaesiumCLTの実行ファイルのパスを指定します。
- 画像圧縮機能を使用するには、この設定が必須です。
- スレッド数の設定:
- 処理に使用するスレッド数を指定します。
- デフォルトは16スレッドですが、お使いのCPUに合わせて調整できます。
- 圧縮設定:
- 「画像を圧縮する」にチェックを入れると、JPEGとPNGファイルが圧縮されます。
- チェックを外すと、ファイルは圧縮されずにコピーされます。
- 長辺サイズ指定:
- ドロップダウンメニューから画像のリサイズ設定を選択できます。
- 「原寸大(変更なし)」、「4898px (推奨)」など様々なオプションがあります。
- 「カスタム」を選ぶと、任意のサイズを指定できます。
2. ファイルの処理
ファイルを処理するには2つの方法があります:
方法1:ドラッグ&ドロップ
ファイルエクスプローラーから画像・動画ファイルを選択し、アプリのドロップエリアにドラッグ&ドロップします。
方法2:ファイル選択ダイアログを使用
「ファイルを選択して処理」ボタンをクリックし、処理したいファイルを選択します。
3. 処理結果の確認
ファイル処理中および処理後の情報は、二つのタブで確認できます:
- ファイル一覧タブ: 処理されたファイルの名前、サイズ情報、圧縮率が表示されます。
- 処理ログタブ: 詳細な処理経過が記録されます。エラーが発生した場合もここで確認できます。
4. 処理完了後
処理が完了すると、処理結果の概要(ファイル数、圧縮率など)が表示され、出力フォルダを開くオプションが提示されます。
注意事項
- 処理中は赤いオーバーレイが表示され、新たなファイルのドロップができなくなります。
- 全ての設定は自動的に保存され、次回アプリケーション起動時に読み込まれます。
- 元のファイルは変更されず、処理済みファイルは指定した出力フォルダに保存されます。
- 大量のファイルや大きなファイルを処理する場合は、処理時間が長くなることがあります。
終わりに(感想)
冒頭でも書きましたが、Claudeのコーディング能力に驚いています。やたら長いコードを書こうとし、自分の出力制限に引っかかることが何度かありました。
続きを書いてと注文すると続きを書いてくれますが、失敗することが多いので、初めから分割して書いてと依頼したりと一工夫は必要です。
コメント