# [メタ情報] # 識別子: 字幕かんたんエディタappの作成_exe # 補足: env終了 # [/メタ情報] 要約: 「字幕かんたんエディタ」は、PyWebViewとBottleで構築されたmacOS向け字幕編集アプリケーションです。動画への字幕付け、BGMの追加・合成などの機能を持ち、WebKitのファイルスキーム制約回避やFFmpeg連携による効率的な処理を実現しています。配布は`build_dmg.sh`スクリプトで生成されるDMGパッケージを通じて行われ、`readme.txt`には初回起動や日常的な動画ファイルからの起動方法が案内されています。 本システムは、Gemini 3.6 Flash APIを利用した高度な自動字幕生成ワークフローも提供します。`multimodal_vtt_pipeline_auto.py`スクリプトは、入力された長尺動画をFFmpegで精密に分割し、Gemini APIで高精度なWebVTT形式の字幕を生成。同時に、ウェブページ埋め込み用の字幕一覧HTML(_listJ.txt)も自動作成します。このスクリプトは、処理完了後に「字幕かんたんエディタ.app」に動画パスを渡して自動起動し、シームレスな編集フローを実現します。 エディタ連携を伴わない単体で字幕を生成する`multimodal_vtt_pipeline.py`スクリプトも存在し、日本語の不自然な空白除去や話者区分の自動判断なども行います。 /Volumes/NO3_SSD/mybox/mybox_1/字幕かんたんエディタ/subtitle_pkg subtitle_pkgフォルダの中身: subtitle_easy_editor.py readme.txt build_dmg.sh html/index.html subtitle_easy_editor.spec vtt_parser_logic.py ターミナルで以下を入力して、アプリのビルドをする pkill -f "subtitle_easy_editor" cd ~/Desktop/subtitle_pkg source venv/bin/activate bash build_dmg.sh ビルドが成功すると、 subtitle_pkgフォルダの中に、 SubtitleEasyEditor.dmg が生成される。 SubtitleEasyEditor_distフォルダを作り SubtitleEasyEditor.dmg readme.txt を入れて、 SubtitleEasyEditor_dist.zip を作り配布する。 subtitle_easy_editor.py ``` #!/usr/bin/env python3 # -*- coding: utf-8 -*- """ =============================================================================== XXXXXX system: subtitle_easy_editor.py (統合完全版 v37) 作成日: 2026年10月3日 (JST) 概要: 字幕かんたんエディタ (PyWebView + Bottle) BGM・音声追加統合版 - extract_audio_peaks のパイプデッドロック問題を NamedTemporaryFile 方式で根本根絶 - WebKitの file:// スキーム制約を回避するため get_audio_base64 (ネイティブIPC転送) を新設 - HTTPストリーミングを介さずダイレクトに音声をデコード再生できるため Load failed エラーを完全根絶 - 手動動画選択 (select_video_file) を配備し、単体起動時でもスムーズに動画読込可能 =============================================================================== """ import os import sys import json import socket import subprocess import tempfile import urllib.parse import mimetypes import array import base64 from pathlib import Path # =============================================================================== # 補正: macOS GUI (.app) 起動時の PATH 自動追加 & FFmpeg パス検索 # =============================================================================== def fix_environment_path(): extra_paths = [ "/opt/homebrew/bin", "/usr/local/bin", "/usr/bin", "/bin", "/usr/sbin", "/sbin" ] current_path = os.environ.get("PATH", "") path_list = current_path.split(os.pathsep) if current_path else [] for p in extra_paths: if p not in path_list and os.path.exists(p): path_list.insert(0, p) os.environ["PATH"] = os.pathsep.join(path_list) fix_environment_path() def get_binary_path(binary_name): candidates = [ f"/opt/homebrew/bin/{binary_name}", f"/usr/local/bin/{binary_name}", f"/usr/bin/{binary_name}" ] for path in candidates: if os.path.exists(path) and os.access(path, os.X_OK): return path return binary_name FFMPEG_BIN = get_binary_path("ffmpeg") FFPROBE_BIN = get_binary_path("ffprobe") try: from bottle import Bottle, request, response, static_file import webview except ImportError: print("エラー: 必要なライブラリ (bottle, pywebview) が不足しています。") sys.exit(1) # グローバル設定: 空きポート自動探索 def find_free_port(start_port=8080, max_tries=50): for port in range(start_port, start_port + max_tries): with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s: if s.connect_ex(('127.0.0.1', port)) != 0: return port return start_port SERVER_PORT = find_free_port(8080) # コマンドライン引数から実在する動画ファイルパスを正確に特定する関数 def find_video_path_from_args(): if len(sys.argv) <= 1: return None for arg in sys.argv[1:]: if not arg or arg.startswith("-psn_") or arg.startswith("-"): continue abs_p = os.path.abspath(arg) if os.path.exists(abs_p) and os.path.isfile(abs_p): ext = os.path.splitext(abs_p)[1].lower() if ext in ['.mp4', '.mov', '.m4v', '.mkv', '.avi', '.webm']: return abs_p return None # =============================================================================== # ユーティリティ: FFmpeg / FFprobe 実行関数 # =============================================================================== def get_media_duration(file_path): if not file_path or not os.path.exists(file_path): return 0.0 cmd = [ FFPROBE_BIN, "-v", "error", "-show_entries", "format=duration", "-of", "default=noprint_wrappers=1:nokey=1", file_path ] try: res = subprocess.run(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, check=True) val = res.stdout.strip() return float(val) if val else 0.0 except Exception as e: print(f"[ERROR] get_media_duration: {e}") return 0.0 def has_audio_stream(file_path): if not file_path or not os.path.exists(file_path): return False cmd = [ FFPROBE_BIN, "-v", "error", "-select_streams", "a", "-show_entries", "stream=codec_type", "-of", "default=noprint_wrappers=1:nokey=1", file_path ] try: res = subprocess.run(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, check=True) return len(res.stdout.strip()) > 0 except Exception as e: print(f"[ERROR] has_audio_stream: {e}") return False def extract_audio_peaks(video_path, num_points=1200): if not has_audio_stream(video_path): return [0.0] * num_points duration = get_media_duration(video_path) if duration <= 0: return [0.0] * num_points tmp_raw = None try: with tempfile.NamedTemporaryFile(suffix=".raw", delete=False) as tmp: tmp_raw = tmp.name cmd = [ FFMPEG_BIN, "-y", "-i", video_path, "-vn", "-ac", "1", "-ar", "4000", "-f", "s16le", "-acodec", "pcm_s16le", tmp_raw ] subprocess.run(cmd, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, check=True) file_size = os.path.getsize(tmp_raw) total_samples = file_size // 2 if total_samples == 0: return [0.0] * num_points pcm = array.array('h') with open(tmp_raw, 'rb') as f: pcm.fromfile(f, total_samples) samples_per_point = max(1, total_samples // num_points) raw_peaks = [] for i in range(num_points): start = i * samples_per_point if start >= total_samples: break chunk = pcm[start:start + samples_per_point] if chunk: max_val = max(abs(s) for s in chunk) raw_peaks.append(max_val) max_peak = max(raw_peaks) if raw_peaks else 0 if max_peak > 0: normalized = [round((p / max_peak) ** 0.7, 3) for p in raw_peaks] else: normalized = [0.0] * len(raw_peaks) return normalized except Exception as e: print(f"[ERROR] extract_audio_peaks: {e}") return [0.0] * num_points finally: if tmp_raw and os.path.exists(tmp_raw): try: os.remove(tmp_raw) except Exception: pass def run_ffmpeg_bgm_mix(video_path, bgm_path, volume, loop, fade_in, fade_out, output_path): duration = get_media_duration(video_path) if duration <= 0: raise ValueError("動画の再生時間を取得できませんでした。") bgm_filters = [] bgm_filters.append(f"volume={volume}") if fade_in > 0: bgm_filters.append(f"afade=t=in:ss=0:d={fade_in}") if fade_out > 0 and duration > fade_out: start_out = duration - fade_out bgm_filters.append(f"afade=t=out:st={start_out:.3f}:d={fade_out}") bgm_filter_str = ",".join(bgm_filters) has_audio = has_audio_stream(video_path) cmd = [FFMPEG_BIN, "-y", "-i", video_path] if loop: cmd.extend(["-stream_loop", "-1"]) cmd.extend(["-i", bgm_path]) if has_audio: filter_complex = f"[1:a]{bgm_filter_str}[bgm];[0:a][bgm]amix=inputs=2:duration=first:dropout_transition=2:normalize=0[aout]" cmd.extend([ "-filter_complex", filter_complex, "-map", "0:v:0", "-map", "[aout]" ]) else: filter_complex = f"[1:a]{bgm_filter_str}[aout]" cmd.extend([ "-filter_complex", filter_complex, "-map", "0:v:0", "-map", "[aout]" ]) cmd.extend([ "-c:v", "copy", "-c:a", "aac", "-b:a", "192k", "-t", f"{duration:.3f}", output_path ]) print(f"[FFmpeg Command] {' '.join(cmd)}") subprocess.run(cmd, check=True) return True # =============================================================================== # PyWebView - JS API ブリッジクラス # =============================================================================== class ApiBridge: def __init__(self, current_video_path=None): self.current_video_path = current_video_path def get_video_info(self): video_path = self.current_video_path if not video_path or not os.path.exists(video_path): return {"success": False, "error": "動画ファイルが指定されていません。"} duration = get_media_duration(video_path) peaks = extract_audio_peaks(video_path, num_points=1200) base_path = os.path.splitext(video_path)[0] vtt_path = f"{base_path}.vtt" listj_path = f"{base_path}_listJ.txt" subtitles = [] if os.path.exists(vtt_path): subtitles = self.parse_vtt_file(vtt_path) safe_param = urllib.parse.quote(video_path, safe='') stream_url = f"http://127.0.0.1:{SERVER_PORT}/stream?path={safe_param}" return { "success": True, "video_path": video_path, "filename": os.path.basename(video_path), "duration": duration, "stream_url": stream_url, "peaks": peaks, "subtitles": subtitles, "vtt_path": vtt_path, "listj_path": listj_path } def select_video_file(self): try: window = webview.windows[0] result = window.create_file_dialog( webview.OPEN_DIALOG, allow_multiple=False, file_types=('Video Files (*.mp4;*.mov;*.m4v;*.mkv;*.avi;*.webm)', 'All files (*.*)') ) if result and len(result) > 0: self.current_video_path = result[0] return {"success": True, "path": self.current_video_path} return {"success": False} except Exception as e: return {"success": False, "error": str(e)} def parse_vtt_file(self, vtt_path): subs = [] try: with open(vtt_path, "r", encoding="utf-8") as f: lines = f.readlines() i = 0 while i < len(lines): line = lines[i].strip() if "-->" in line: parts = line.split("-->") st = parts[0].strip() et = parts[1].strip() i += 1 text_lines = [] while i < len(lines) and lines[i].strip() != "": text_lines.append(lines[i].strip()) i += 1 text = " ".join(text_lines) subs.append({"start": st, "end": et, "text": text}) i += 1 except Exception as e: print(f"[ERROR] parse_vtt_file: {e}") return subs def select_audio_file(self): try: window = webview.windows[0] result = window.create_file_dialog( webview.OPEN_DIALOG, allow_multiple=False, file_types=('Audio Files (*.mp3;*.wav;*.m4a;*.aac)', 'All files (*.*)') ) if result and len(result) > 0: selected = result[0] dur = get_media_duration(selected) return { "success": True, "path": selected, "filename": os.path.basename(selected), "duration": dur } return {"success": False} except Exception as e: return {"success": False, "error": str(e)} def get_audio_base64(self, audio_path): """ WebKitのfile://制約およびCORSを100%回避するため、 ネイティブIPCメッセージ経由で音声バイナリをBase64で直接返却 """ try: if not audio_path or not os.path.exists(audio_path): return {"success": False, "error": "指定された音声ファイルが存在しません。"} with open(audio_path, "rb") as f: data = f.read() b64_str = base64.b64encode(data).decode('ascii') return {"success": True, "data": b64_str} except Exception as e: print(f"[ERROR] get_audio_base64: {e}") return {"success": False, "error": str(e)} def save_vtt(self, payload_json): try: data = json.loads(payload_json) if isinstance(payload_json, str) else payload_json vtt_path = data.get("vtt_path") content = data.get("content") listj_path = data.get("listj_path") listj_content = data.get("listj_content") if vtt_path and content: with open(vtt_path, "w", encoding="utf-8") as f: f.write(content) if listj_path and listj_content: with open(listj_path, "w", encoding="utf-8") as f: f.write(listj_content) return {"success": True} except Exception as e: return {"success": False, "error": str(e)} def export_video_with_bgm(self, payload_json): try: data = json.loads(payload_json) if isinstance(payload_json, str) else payload_json video_path = data.get("video_path") or self.current_video_path bgm_path = data.get("bgm_path") volume = float(data.get("volume", 0.15)) loop = bool(data.get("loop", True)) fade_in = float(data.get("fade_in", 0.5)) fade_out = float(data.get("fade_out", 2.0)) output_path = data.get("output_path") if not video_path or not os.path.exists(video_path): return {"success": False, "error": "動画ファイルが存在しません。"} if not bgm_path or not os.path.exists(bgm_path): return {"success": False, "error": "BGMファイルが指定されていないか、存在しません。"} if not output_path: base, ext = os.path.splitext(video_path) output_path = f"{base}_mixed{ext}" run_ffmpeg_bgm_mix( video_path=video_path, bgm_path=bgm_path, volume=volume, loop=loop, fade_in=fade_in, fade_out=fade_out, output_path=output_path ) return { "success": True, "output_path": output_path, "message": "BGM付き動画の書き出しが完了しました。" } except Exception as e: print(f"[ERROR] export_video_with_bgm: {e}") return {"success": False, "error": str(e)} # =============================================================================== # Bottle サーバー設定 (二重防壁・ストリーミング配給用) # =============================================================================== bottle_app = Bottle() global_api_bridge = None @bottle_app.route('/stream', method=['OPTIONS']) def options_stream(): response.set_header("Access-Control-Allow-Origin", "*") response.set_header("Access-Control-Allow-Methods", "GET, OPTIONS") response.set_header("Access-Control-Allow-Headers", "Range, Content-Type") return "" @bottle_app.route('/stream', method=['GET']) def stream_video(): response.set_header("Access-Control-Allow-Origin", "*") response.set_header("Access-Control-Allow-Methods", "GET, OPTIONS") response.set_header("Access-Control-Allow-Headers", "Range, Content-Type") response.set_header("Accept-Ranges", "bytes") target_file = None # 防壁 1: クエリパラメータから取得試行 path_param = request.query.get('path', '') if path_param: candidates = [ path_param, urllib.parse.unquote(path_param), urllib.parse.unquote_plus(path_param) ] for cand in candidates: if cand and os.path.exists(cand) and os.path.isfile(cand): target_file = os.path.abspath(cand) break # 防壁 2 (最終防衛線): クエリ解読失敗時、現在開いている動画を自動参照 if not target_file and global_api_bridge and global_api_bridge.current_video_path: curr = global_api_bridge.current_video_path if os.path.exists(curr) and os.path.isfile(curr): target_file = os.path.abspath(curr) if not target_file or not os.path.exists(target_file): response.status = 404 return "File not found" folder, filename = os.path.split(target_file) guessed_type, _ = mimetypes.guess_type(target_file) mimetype = guessed_type if guessed_type else 'application/octet-stream' return static_file(filename, root=folder, mimetype=mimetype) def start_bottle_server(): try: bottle_app.run(host='127.0.0.1', port=SERVER_PORT, quiet=True) except Exception as e: print(f"[Bottle Server Error]: {e}") # =============================================================================== # メイン起動処理 # =============================================================================== def main(): global global_api_bridge video_path = find_video_path_from_args() api = ApiBridge(current_video_path=video_path) global_api_bridge = api import threading server_thread = threading.Thread(target=start_bottle_server, daemon=True) server_thread.start() pkg_dir = Path(__file__).parent.resolve() html_path = pkg_dir / "html" / "index.html" if not html_path.exists(): html_path = pkg_dir / "index.html" window = webview.create_window( title="字幕かんたんエディタ (統合完全版 v37)", url=html_path.as_uri(), js_api=api, width=1280, height=850, resizable=True ) webview.start(debug=False) if __name__ == '__main__': main() ``` readme.txt ``` ====================================================== 【はじめにお読みください】字幕かんたんエディタ 導入ガイド ====================================================== この度は「字幕かんたんエディタ」をダウンロードいただきありがとうございます。 macOSの仕様上、初回起動時のみ簡単なセキュリティ確認が必要となります。 システム設定を変更することなく、以下の3ステップで簡単にご利用いただけます。 【導入手順(初回のみ)】 1. アプリのインストール このフォルダに入っている「字幕かんたんエディタ」のアイコンを、 右隣の「Applications」フォルダへドラッグ&ドロップしてください。 (これでインストールは完了です) 2. 魔法の「右クリック」起動 アプリケーションフォルダを開き、「字幕かんたんエディタ」のアイコンを 【右クリック(またはControlキーを押しながらクリック)】し、 メニューから「開く」を選択してください。 3. 「開く」ボタンを押す 「開発元を検証できないため開けません」という警告画面が出ますが、 その中にある「開く」ボタンをクリックしてください。 ※次回からは、通常通りダブルクリックで起動できるようになります! 【便利な使い方(日常の作業フロー)】 インストール後は、以下の手順でスムーズに字幕作成を開始できます。 1. MacのFinderで、編集したい動画や音声ファイルを「右クリック」します。 2. メニューの「このアプリケーションで開く」の中から、「字幕かんたんエディタ.app」を選びます。 (※Macの環境によっては「サービス」や「クイックアクション」の中に表示される場合もあります) 3. アプリが起動したら、メニュー画面から「既存のVTTファイルを指定」または「新規にVTTファイルを作成」を選択してください。 4. 選択した動画・音声ファイルが自動的に読み込まれた状態で、エディタ画面が開きます。 ====================================================== ``` build_dmg.sh ``` #!/bin/bash # =============================================================================== # XXXXXX system: build_dmg.sh # 概要: PyInstallerでのアプリビルドおよび create-dmg による配布用DMGパッケージ作成 # =============================================================================== APP_NAME="字幕かんたんエディタ.app" APP_PATH="dist/${APP_NAME}" DMG_NAME="SubtitleEasyEditor.dmg" echo "🚀 PyInstallerでビルドを開始します..." pyinstaller subtitle_easy_editor.spec --noconfirm --clean if [ ! -d "$APP_PATH" ]; then echo "❌ エラー: ${APP_PATH} のビルドに失敗しました。" exit 1 fi rm -f "$DMG_NAME" echo "📦 DMGパッケージを作成しています..." create-dmg \ --volname "字幕かんたんエディタ インストーラ" \ --window-pos 200 120 \ --window-size 600 400 \ --icon-size 100 \ --icon "$APP_NAME" 150 190 \ --hide-extension "$APP_NAME" \ --app-drop-link 450 190 \ "$DMG_NAME" \ "dist/" if [ $? -eq 0 ]; then echo "✅ DMGの作成が正常に完了しました: $DMG_NAME" else echo "❌ DMGの作成に失敗しました。" fi ``` html/index.html ``` 字幕かんたんエディタ (統合完全版)

字幕かんたんエディタ (統合完全版)

動画読み込み中...
🔊 音声波形表示
10%
🎵 BGM・音声追加
(BGM未選択)
➕ 新規字幕追加
📋 登録済み字幕一覧 (0件)
``` subtitle_easy_editor.spec ``` # -*- mode: python ; coding: utf-8 -*- block_cipher = None a = Analysis( ['subtitle_easy_editor.py'], pathex=[], binaries=[], datas=[('html', 'html')], hiddenimports=['bottle', 'webview'], hookspath=[], hooksconfig={}, runtime_hooks=[], excludes=[], win_no_prefer_redirects=False, win_private_assemblies=False, cipher=block_cipher, noarchive=False, ) pyz = PYZ(a.pure, a.zipped_data, cipher=block_cipher) exe = EXE( pyz, a.scripts, [], exclude_binaries=True, name='subtitle_easy_editor', debug=False, bootloader_ignore_signals=False, strip=False, upx=True, console=False, disable_windowed_traceback=False, argv_emulation=True, target_arch=None, codesign_identity=None, entitlements_file=None, ) info_plist = { 'CFBundleName': '字幕かんたんエディタ', 'CFBundleDisplayName': '字幕かんたんエディタ', 'CFBundleGetInfoString': 'Subtitle Easy Editor with BGM', 'CFBundleIdentifier': 'com.XXXXXX.subtitle-easy-editor', 'CFBundleVersion': '1.0.0', 'CFBundleShortVersionString': '1.0.0', 'NSServices': [ { 'NSMenuItem': { 'default': '字幕かんたんエディタ' }, 'NSMessage': 'openFile', 'NSPortName': '字幕かんたんエディタ', 'NSSendTypes': [ 'public.movie', 'public.audio' ], 'NSRequiredContext': {} } ], 'CFBundleDocumentTypes': [ { 'CFBundleTypeName': 'Media Files', 'CFBundleTypeRole': 'Viewer', 'LSHandlerRank': 'Owner', 'LSItemContentTypes': [ 'public.movie', 'public.audio' ] } ] } coll = COLLECT( exe, a.binaries, a.zipfiles, a.datas, strip=False, upx=True, upx_exclude=[], name='subtitle_easy_editor', ) app = BUNDLE( coll, name='字幕かんたんエディタ.app', icon=None, bundle_identifier='com.XXXXXX.subtitle-easy-editor', info_plist=info_plist ) ``` vtt_parser_logic.py ``` import os import re def time_to_seconds(time_str): """HH:MM:SS.mmm または MM:SS.mmm を秒数(Float)に変換""" parts = time_str.strip().split(':') if len(parts) == 3: h, m, s = parts return int(h) * 3600 + int(m) * 60 + float(s) elif len(parts) == 2: m, s = parts return int(m) * 60 + float(s) return 0.0 def sec_to_seek_tag(seconds): """秒数を Wavesurfer/HTML5シーク用の 'minutes:seconds' 形式タグに変換""" total_min = int(seconds // 60) sec = int(seconds % 60) return f"{total_min}:{sec:02d}" def generate_list_j(vtt_path): """ VTTファイルを解析し、同じフォルダに『VTTファイル名_listJ.txt』を生成する。 仕様: - 時刻リンクタグ付きのインタラクティブ・トランスクリプト形式 - Apple翻訳対策(translate="yes/no")を完全埋め込み """ try: dir_name = os.path.dirname(vtt_path) base_name = os.path.splitext(os.path.basename(vtt_path))[0] out_list_j_path = os.path.join(dir_name, f"{base_name}_listJ.txt") with open(vtt_path, 'r', encoding='utf-8') as f: lines = f.readlines() body_list_j = [] # 共通CSSテンプレート(既存の details スタイルに準拠) header_html = '
字幕一覧(クリック)

\n' footer_html = '

\n' # ▼ 両方の環境で共存できるハイブリッドスタイルに変更 style_html = """\n""" i = 0 max_i = len(lines) while i < max_i: line = lines[i].strip() if "-->" in line: # タイムスタンプ行の取得 parts = line.split("-->") start_str = parts[0].strip()[:12] # "HH:MM:SS.mmm" display_stamp = start_str[:8] # "HH:MM:SS" # シーク秒数の算出 seconds = time_to_seconds(start_str) seek_tag = sec_to_seek_tag(seconds) # 次の行以降から字幕本文を収集(空行で終了、または次のタイムスタンプまで) caption_text_raw = "" j = i + 1 while j < max_i: next_line = lines[j].strip() if next_line == "": break # 数字のみの連番行はスキップ if next_line.isdigit(): j += 1 continue if "-->" in next_line: break if caption_text_raw == "": caption_text_raw = next_line else: caption_text_raw += " " + next_line j += 1 # _listJ.txt用の整形(先頭に既存の括弧書きのタイムスタンプがあればトリム) caption_for_j = caption_text_raw if caption_for_j.startswith("("): # 例: "(00:01:23) 本文" ➔ "本文" caption_for_j = re.sub(r'^\(\d{1,2}:\d{2}(:\d{2})?\)\s*', '', caption_for_j) # リンク付きの1行を生成 (時刻は翻訳させない「translate="no"」仕様) one_j = f'({display_stamp}) {caption_for_j}
\n' body_list_j.append(one_j) i = j else: i += 1 # ファイル書き出し with open(out_list_j_path, 'w', encoding='utf-8') as out_f: out_f.write(header_html) out_f.writelines(body_list_j) out_f.write(footer_html) out_f.write(style_html) print(f"✅ 字幕一覧用HTMLの生成完了: {out_list_j_path}") return True except Exception as e: print(f"❌ リスト生成エラー: {e}") return False ``` 全自動VTT字幕作成&エディタ起動.workflow の処理の後に、自動的に、字幕かんたんエディタ.appが起動するように、 全自動VTT字幕作成&エディタ起動.workflow を作成した。 ``` on run {input, parameters} -- ユーザー名(XXXXXX / XXXXXX 等)を動的に取得して環境依存を排除 set currentUser to short user name of (system info) -- ▼ 動的に組み上げるPythonスクリプトパスと仮想環境Pythonパス set pythonScript to "/Users/" & currentUser & "/python_scripts/multimodal_vtt_pipeline_auto.py" set pythonBin to "/Users/" & currentUser & "/venv_vtt/bin/python" repeat with f in input set videoPath to POSIX path of f set qVideoPath to quoted form of videoPath set qPythonScript to quoted form of pythonScript set qPythonBin to quoted form of pythonBin -- 仮想環境のpythonが存在する場合はそれを使い、無ければシステム標準のpython3へ自動フォールバックするコマンドを生成 set cmd to "export PATH=\"/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin\"; if [ -x " & qPythonBin & " ]; then PY=" & qPythonBin & "; else PY=\"python3\"; fi; $PY " & qPythonScript & " " & qVideoPath -- ターミナルを起動してコマンドを実行(見える形) tell application "Terminal" activate do script cmd end tell end repeat return input end run ``` /Users/XXXXXX/python_scripts/multimodal_vtt_pipeline_auto.py ``` #!/usr/bin/env python3 # -*- coding: utf-8 -*- """ =============================================================================== XXXXXX system: multimodal_vtt_pipeline_auto.py (完全自動VTT字幕生成&ListJペア生成&エディタ連動 v3) 作成日: 2026年10月1日 (JST) 概要: 長尺動画からGemini 3.6 Flash APIを用いて全自動でVTT字幕を生成。 生成直後に_listJ.txtを100%自動ペア出力し、 完了後に「字幕かんたんエディタ.app」へ動画パスのみをバトンタッチ起動。 =============================================================================== """ import os import sys import subprocess import shutil import re import time import tempfile import warnings import unicodedata from pathlib import Path from dotenv import load_dotenv warnings.filterwarnings("ignore", category=FutureWarning) try: from google import genai from google.genai import types except ImportError: pass # ========================================== # 1. 環境設定(金庫 .env からの動的ロード & フォールバック完全排除) # ========================================== ENV_PATH = Path.home() / "python_scripts" / ".env" load_dotenv(dotenv_path=ENV_PATH) API_KEY = os.getenv("GEMINI_API_KEY") # ========================================== # システムプロンプト # ========================================== SYSTEM_PROMPT = """添付された動画ファイルの音声を解析し、極めて正確な文字起こしを行い、指定されたWebVTT(.vtt)形式の構造(コードデータのみ)を出力してください。挨拶やマークダウン装飾、解説は一切出力しないでください。 [字幕構造ルール] 1行目: 通し番号(1から始まる整数) 2行目: タイムスタンプ(HH:MM:SS.mmm --> HH:MM:SS.mmm) 3行目: 日本語の字幕テキスト(文脈に応じた適切な句読点あり) 各ブロックの間には空行を1行入れる。 [日本語テキスト品質ルール(最重要)] - 単語と単語の間、助詞(は、が、の、を、に等)の前後に半角スペースや全角スペースを絶対に入れないでください。通常の自然な日本語として文字を詰めて記述してください。 - 句読点(、や。)の前後にスペースを入れないでください。 - 「えー」「あのー」「えっと」「まあ」「そのー」などの意味のないフィラー(言い淀み・無駄な発声)は自然に100%除去してください。 - 会話の抜け落ちがないよう、音声は一言一句漏らさず正確に文字起こししてください。 [タイムスタンプの厳格ルール] - タイムスタンプは必ず渡された動画ファイルの冒頭「00:00:00.000(0秒)」からの相対経過時間で記述してください。 - 形式は必ず「HH:MM:SS.mmm --> HH:MM:SS.mmm」(時:分:秒.ミリ秒)としてください。 - 1つの字幕ブロックの継続時間は通常2秒〜6秒程度とし、10秒を超える長大な字幕は絶対に作らないでください。 - 前の字幕の終了時刻より後の字幕の開始時刻が逆戻り(逆行)しないようにしてください。 [話者区分の判断] 映像と音声を解析し、もし「複数人による会話(対談、インタビュー等)」であると判断した場合は、字幕テキストの先頭に必ず「[男性]:」「[女性]:」または「[話者A]:」「[話者B]:」などの話者ラベルを付与してください。 もし「単独の喋り手」による動画であると判断した場合は、話者ラベルは付与せず、通常の字幕テキストのみを出力してください。""" SPLIT_BASE_SECONDS = 270.0 # 基本分割秒数 (4分30秒) # ========================================== # ユーティリティ関数 # ========================================== def check_dependencies(): if not shutil.which("ffmpeg") or not shutil.which("ffprobe"): print("エラー: FFmpeg または FFprobe がインストールされていません。Homebrew等でインストールしてください。", file=sys.stderr) sys.exit(1) def clean_japanese_spaces(text): jp_char = r'[\u3000-\u303F\u3040-\u309F\u30A0-\u30FF\u4E00-\u9FFF\uFF01-\uFF60]' for _ in range(3): text = re.sub(rf'({jp_char})\s+({jp_char})', r'\1\2', text) text = re.sub(r'\s+([、。!?,.)】」』])', r'\1', text) text = re.sub(r'([(【「『])\s+', r'\1', text) return text.strip() def sec_to_vtt_timestamp(seconds): hours = int(seconds // 3600) minutes = int((seconds % 3600) // 60) secs = seconds % 60 return f"{hours:02d}:{minutes:02d}:{secs:06.3f}" def get_video_duration(video_path): cmd = [ 'ffprobe', '-v', 'error', '-show_entries', 'format=duration', '-of', 'default=noprint_wrappers=1:nokey=1', video_path ] result = subprocess.run(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True) return float(result.stdout.strip()) def get_silence_points(video_path): cmd = ['ffmpeg', '-i', video_path, '-af', 'silencedetect=noise=-30dB:d=1.0', '-f', 'null', '-'] result = subprocess.run(cmd, stderr=subprocess.PIPE, text=True) silence_points = [] for line in result.stderr.split('\n'): match = re.search(r'silence_start: (\d+(\.\d+)?)', line) if match: silence_points.append(float(match.group(1))) return silence_points def parse_and_shift_vtt(vtt_text, offset_sec, part_duration): vtt_text = clean_japanese_spaces(vtt_text) blocks = re.split(r'\n\s*\n', vtt_text.strip()) parsed_blocks = [] for b in blocks: lines = [line.strip() for line in b.splitlines() if line.strip()] stamp_idx = -1 for idx, line in enumerate(lines): if '-->' in line: stamp_idx = idx break if stamp_idx != -1: st_str, et_str = [s.strip() for s in lines[stamp_idx].split('-->')] def ts_to_sec(ts): parts = ts.split(':') if len(parts) == 3: return float(parts[0])*3600 + float(parts[1])*60 + float(parts[2]) elif len(parts) == 2: return float(parts[0])*60 + float(parts[1]) return 0.0 rel_start = ts_to_sec(st_str) rel_end = ts_to_sec(et_str) start_sec = rel_start + offset_sec end_sec = rel_end + offset_sec text_lines = lines[stamp_idx+1:] text = "\n".join(text_lines) text = clean_japanese_spaces(text) if text: parsed_blocks.append({ 'start': start_sec, 'end': end_sec, 'text': text }) return parsed_blocks def process_video_part(client, part_path, offset_sec, part_duration, retry_count=8): print(f" 📤 動画パーツをアップロード中: {os.path.basename(part_path)}") uploaded_file = client.files.upload(file=part_path) while uploaded_file.state.name == "PROCESSING": time.sleep(2) uploaded_file = client.files.get(name=uploaded_file.name) if uploaded_file.state.name == "FAILED": raise Exception("Google API側での動画処理に失敗しました。") print(" 🤖 Gemini 3.6 Flash に高精度文字起こしをリクエスト中 (Temperature=0.1)...") max_retries = 8 retry_delay = 20.0 for attempt in range(1, max_retries + 1): try: print(f" [試行 {attempt}/{max_retries}] API推論実行中...") response = client.models.generate_content( model='gemini-3.6-flash', contents=[uploaded_file, SYSTEM_PROMPT], config=types.GenerateContentConfig( temperature=0.1 ) ) vtt_raw = response.text vtt_cleaned = re.sub(r'^```vtt\s*', '', vtt_raw, flags=re.MULTILINE) vtt_cleaned = re.sub(r'^```\s*$', '', vtt_cleaned, flags=re.MULTILINE) blocks = parse_and_shift_vtt(vtt_cleaned, offset_sec, part_duration) if blocks: return blocks, uploaded_file else: print(f" ⚠️ レスポンスのパースに失敗。リトライします ({attempt}/{max_retries})...") except Exception as e: print(f" ⚠️ APIエラー ({e})。{retry_delay:.0f}秒待機してリトライします ({attempt}/{max_retries})...") time.sleep(retry_delay) retry_delay += 20.0 raise Exception("規定のリトライ回数を超えましたが、有効な字幕データを取得できませんでした。") # ========================================== # _listJ.txt 自動ペア生成関数 # ========================================== def parse_vtt_to_listj(vtt_path): if not os.path.exists(vtt_path): return "" try: with open(vtt_path, "r", encoding="utf-8") as f: lines = f.readlines() except Exception as e: print(f"[ERROR] VTT読み込み失敗: {e}") return "" cues = [] i = 0 while i < len(lines): line = lines[i].strip() if "-->" in line: parts = line.split("-->") start_str = parts[0].strip() time_parts = start_str.split(":") if len(time_parts) == 3: hh, mm, ss = time_parts ss_sec = ss.split(".")[0] data_seek = f"{int(mm)}:{ss_sec.zfill(2)}" if int(hh) == 0 else f"{int(hh)}:{mm}:{ss_sec.zfill(2)}" display_time = f"{hh.zfill(2)}:{mm.zfill(2)}:{ss_sec.zfill(2)}" elif len(time_parts) == 2: mm, ss = time_parts ss_sec = ss.split(".")[0] data_seek = f"{int(mm)}:{ss_sec.zfill(2)}" display_time = f"00:{mm.zfill(2)}:{ss_sec.zfill(2)}" else: data_seek = "0:00" display_time = "00:00:00" i += 1 text_lines = [] while i < len(lines) and lines[i].strip() != "": text_lines.append(lines[i].strip()) i += 1 text = " ".join(text_lines) if text: cues.append((data_seek, display_time, text)) i += 1 listj_lines = [ '
字幕一覧(クリック)

\n' ] for data_seek, display_time, text in cues: cue_html = f'({display_time}) {text}
' listj_lines.append(f"{cue_html}\n") listj_lines.append('

\n') listj_lines.append('\n') return "".join(listj_lines) def generate_listj_from_vtt(vtt_path, listj_path): listj_content = parse_vtt_to_listj(vtt_path) if listj_content: try: with open(listj_path, "w", encoding="utf-8") as f: f.write(listj_content) print(f"✅ ListJ自動ペア生成完了: {listj_path}") return True except Exception as e: print(f"[ERROR] ListJ保存失敗: {e}") return False return False # ========================================== # メインパイプライン処理 # ========================================== def main(): check_dependencies() if len(sys.argv) < 2: print("使用方法: python multimodal_vtt_pipeline_auto.py <動画ファイルのパス>", file=sys.stderr) sys.exit(1) if not API_KEY: print("エラー:必要な鍵(GEMINI_API_KEY)が.envに設定されていません", file=sys.stderr) sys.exit(1) raw_video_path = sys.argv[1] video_path = unicodedata.normalize('NFC', raw_video_path) if not os.path.exists(video_path): print(f"エラー: ファイルが見つかりません: {video_path}", file=sys.stderr) sys.exit(1) print(f"🎬 全自動VTT字幕生成を開始します: {os.path.basename(video_path)}") client = genai.Client(api_key=API_KEY) total_duration = get_video_duration(video_path) silence_points = get_silence_points(video_path) split_points = [0.0] current_target = SPLIT_BASE_SECONDS while current_target < total_duration: valid_points = [p for p in silence_points if current_target - 30.0 <= p <= current_target + 30.0] if valid_points: best_point = min(valid_points, key=lambda x: abs(x - current_target)) split_points.append(best_point) current_target = best_point + SPLIT_BASE_SECONDS else: split_points.append(current_target) current_target += SPLIT_BASE_SECONDS split_points.append(total_duration) all_vtt_blocks = [] with tempfile.TemporaryDirectory() as tmp_dir: part_count = len(split_points) - 1 print(f"✂️ 動画を {part_count} 個に分割して処理します。") for i in range(part_count): start = split_points[i] end = split_points[i+1] part_dur = end - start part_path = os.path.join(tmp_dir, f"part_{i+1:03d}.mp4") print(f"\n[パーツ {i+1}/{part_count}] 範囲: {start:.1f}s - {end:.1f}s (長さ: {part_dur:.1f}s)") split_cmd = [ 'ffmpeg', '-y', '-ss', str(start), '-to', str(end), '-i', video_path, '-c', 'copy', '-avoid_negative_ts', 'make_zero', part_path ] subprocess.run(split_cmd, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) try: blocks, uploaded_file = process_video_part(client, part_path, start, part_dur) all_vtt_blocks.extend(blocks) except Exception as e: print(f"エラー発生 ([{i+1}/{part_count}]): {e}", file=sys.stderr) finally: if 'uploaded_file' in locals(): print(f"[{i+1}/{part_count}] API上の動画ファイルを削除中...") try: client.files.delete(name=uploaded_file.name) except: pass print("\n📝 最終的なVTTを結合し、連番を再付与中...") dir_name = os.path.dirname(video_path) base_name = os.path.splitext(os.path.basename(video_path))[0] out_path = os.path.join(dir_name, f"{base_name}.vtt") listj_path = os.path.join(dir_name, f"{base_name}_listJ.txt") all_vtt_blocks.sort(key=lambda x: x['start']) with open(out_path, 'w', encoding='utf-8') as f: f.write("WEBVTT\n\n") for idx, block in enumerate(all_vtt_blocks, 1): start_str = sec_to_vtt_timestamp(block['start']) end_str = sec_to_vtt_timestamp(block['end']) f.write(f"{idx}\n") f.write(f"{start_str} --> {end_str}\n") f.write(f"{block['text']}\n\n") print(f"✅ VTT作成完了: {out_path}") # _listJ.txt を全自動ペア生成 generate_listj_from_vtt(out_path, listj_path) # 「字幕かんたんエディタ.app」へ動画パスのみをバトンタッチ起動 editor_app = "/Applications/字幕かんたんエディタ.app" if os.path.exists(editor_app): print("🚀 字幕かんたんエディタを完全バトンタッチ起動中...") try: subprocess.Popen(["open", "-a", editor_app, video_path]) except Exception as e: print(f"エディタ起動エラー: {e}") else: print(f"[INFO] エディタアプリが見つかりません: {editor_app}") try: subprocess.run([ "osascript", "-e", 'display notification "字幕作成&ListJペア生成が完了しました!" with title "XXXXXXシステム"' ]) except: pass if __name__ == "__main__": main() ``` 単体起動 全自動VTT字幕作成.workflow ファイルまたはフォルダ Finder.app AppleScriptを実行 ``` on run {input, parameters} -- ▼ Pythonスクリプトを保存したパス set pythonScript to "/Users/XXXXXX/python_scripts/multimodal_vtt_pipeline.py" -- ▼ 仮想環境(venv)のPython set pythonBin to "/Users/XXXXXX/venv_vtt/bin/python" repeat with f in input set videoPath to POSIX path of f set qVideoPath to quoted form of videoPath set qPythonScript to quoted form of pythonScript -- ターミナルで実行するコマンドを組み立てる set cmd to "export PATH=\"/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin\"; " & pythonBin & " " & qPythonScript & " " & qVideoPath -- ターミナルを起動してコマンドを実行(見える形) tell application "Terminal" activate do script cmd end tell end repeat return input end run ``` 字幕自動作成のみの単体 /Users/XXXXXX/python_scripts/multimodal_vtt_pipeline.py ``` #!/usr/bin/env python3 # -*- coding: utf-8 -*- """ 長尺動画の全自動マルチモーダルVTT字幕生成パイプライン (FFmpeg精密分割・PTS完全リセット + Gemini 3.6 Flash + 自動リトライ + タイムシフト自動結合 + 日本語空白自動整形) ※ 最新SDK google-genai 対応版 (単体版・.envロード仕様) """ import os import sys import subprocess import shutil import re import time import tempfile import warnings from pathlib import Path from dotenv import load_dotenv warnings.filterwarnings("ignore", category=FutureWarning) # ========================================== # .envファイルの動的ロードと起動ガード # ========================================== ENV_PATH = Path.home() / "python_scripts" / ".env" load_dotenv(dotenv_path=ENV_PATH) API_KEY = os.getenv("GEMINI_API_KEY") if not API_KEY: print("エラー:必要な鍵(GEMINI_API_KEY)が.envに設定されていません。", file=sys.stderr) sys.exit(1) from google import genai from google.genai import types # ========================================== # 設定とシステムプロンプト # ========================================== SYSTEM_PROMPT = """添付された動画ファイルの音声を解析し、極めて正確な文字起こしを行い、指定されたWebVTT(.vtt)形式の構造(コードデータのみ)を出力してください。挨拶やマークダウン装飾、解説は一切出力しないでください。 [字幕構造ルール] 1行目: 通し番号(1から始まる整数) 2行目: タイムスタンプ(HH:MM:SS.mmm --> HH:MM:SS.mmm) 3行目: 日本語の字幕テキスト(文脈に応じた適切な句読点あり) 各ブロックの間には空行を1行入れる。 [日本語テキスト品質ルール(最重要)] - 単語と単語の間、助詞(は、が、の、を、に等)の前後に半角スペースや全角スペースを絶対に入れないでください。通常の自然な日本語として文字を詰めて記述してください。 - 句読点(、や。)の前後にスペースを入れないでください。 - 「えー」「あのー」などの意味のないフィラーは自然に除去してください。 - 会話の抜け落ちがないよう、音声は一言一句漏らさず正確に文字起こししてください。 [タイムスタンプの厳格ルール] - タイムスタンプは必ず渡された動画ファイルの冒頭「00:00:00.000(0秒)」からの相対経過時間で記述してください。 - 形式は必ず「HH:MM:SS.mmm --> HH:MM:SS.mmm」(時:分:秒.ミリ秒)としてください。 - 1つの字幕ブロックの継続時間は通常2秒〜6秒程度とし、10秒を超える長大な字幕は絶対に作らないでください。 - 前の字幕の終了時刻より後の字幕の開始時刻が逆戻り(逆行)しないようにしてください。 [話者区分の判断] 映像と音声を解析し、もし「複数人による会話(対談、インタビュー等)」であると判断した場合は、字幕テキストの先頭に必ず「[男性]:」「[女性]:」または「[話者A]:」「[話者B]:」などの話者ラベルを付与してください。 もし「単独の喋り手」による動画であると判断した場合は、話者ラベルは付与せず、通常の字幕テキストのみを出力してください。""" SPLIT_BASE_SECONDS = 270.0 # ========================================== # ユーティリティ関数 # ========================================== def check_dependencies(): if not shutil.which("ffmpeg") or not shutil.which("ffprobe"): print("エラー: FFmpeg または FFprobe がインストールされていません。Homebrew等でインストールしてください。") sys.exit(1) def clean_japanese_spaces(text): jp_char = r'[\u3000-\u303F\u3040-\u309F\u30A0-\u30FF\u4E00-\u9FFF\uFF01-\uFF60]' for _ in range(3): text = re.sub(rf'({jp_char})\s+({jp_char})', r'\1\2', text) text = re.sub(r'\s+([、。!?,.)】」』])', r'\1', text) text = re.sub(r'([(【「『])\s+', r'\1', text) return text.strip() def time_to_sec(ts, max_duration=None): ts = ts.strip().replace(',', '.') parts = ts.split(':') if len(parts) == 3: h_val = float(parts[0]) m_val = float(parts[1]) s_val = float(parts[2]) if max_duration is not None and h_val > 0 and (h_val * 3600 > max_duration * 1.5): sec = h_val * 60 + m_val + s_val / 1000.0 else: sec = h_val * 3600 + m_val * 60 + s_val elif len(parts) == 2: m_val = float(parts[0]) s_val = float(parts[1]) sec = m_val * 60 + s_val elif len(parts) == 1: sec = float(parts[0]) else: raise ValueError("Invalid time format") return sec def sec_to_time(sec): if sec < 0: sec = 0.0 h = int(sec // 3600) m = int((sec % 3600) // 60) s = int(sec % 60) ms = int(round((sec - int(sec)) * 1000)) if ms >= 1000: s += 1 ms -= 1000 if s >= 60: s -= 60 m += 1 if m >= 60: m -= 60 h += 1 return f"{h:02d}:{m:02d}:{s:02d}.{ms:03d}" def get_video_duration(video_path): cmd = ['ffprobe', '-v', 'error', '-show_entries', 'format=duration', '-of', 'default=noprint_wrappers=1:nokey=1', video_path] result = subprocess.run(cmd, stdout=subprocess.PIPE, text=True) try: return float(result.stdout.strip()) except: return 0.0 def get_silence_points(video_path): cmd = ['ffmpeg', '-i', video_path, '-af', 'silencedetect=noise=-30dB:d=1.0', '-f', 'null', '-'] result = subprocess.run(cmd, stderr=subprocess.PIPE, text=True) silence_points = [] for line in result.stderr.split('\n'): match = re.search(r'silence_start: (\d+(\.\d+)?)', line) if match: silence_points.append(float(match.group(1))) return silence_points def parse_and_shift_vtt(vtt_text, offset_sec, part_duration): blocks = [] lines = vtt_text.strip().split('\n') i = 0 last_rel_end = 0.0 while i < len(lines): line = lines[i].strip() if line == "WEBVTT" or line == "" or re.match(r'^\d+$', line) or line.startswith("```"): i += 1 continue if "-->" in line: parts = line.split("-->") if len(parts) == 2: try: rel_start = time_to_sec(parts[0], max_duration=part_duration) rel_end = time_to_sec(parts[1], max_duration=part_duration) except: i += 1 continue if rel_start > part_duration: rel_start = min(last_rel_end + 0.5, part_duration) if rel_end > part_duration + 2.0: rel_end = min(rel_start + 6.0, part_duration) if rel_end <= rel_start: rel_end = rel_start + 6.0 if (rel_end - rel_start) > 20.0: rel_end = rel_start + 10.0 if rel_start < last_rel_end: if (last_rel_end - rel_start) < 1.5: rel_start = last_rel_end + 0.1 rel_end = max(rel_end, rel_start + 1.0) else: rel_end = max(rel_end, rel_start + 1.0) last_rel_end = rel_end start_sec = rel_start + offset_sec end_sec = rel_end + offset_sec i += 1 text_lines = [] while i < len(lines) and lines[i].strip() != "": cleaned = lines[i].strip().replace("```vtt", "").replace("```", "") if cleaned and not re.match(r'^\d+$', cleaned): cleaned = clean_japanese_spaces(cleaned) text_lines.append(cleaned) i += 1 if text_lines: blocks.append({ "start": start_sec, "end": end_sec, "text": "\n".join(text_lines) }) else: i += 1 return blocks # ========================================== # メイン処理 # ========================================== def main(): if len(sys.argv) < 2: print("エラー: 動画ファイルのパスを引数として渡してください。") sys.exit(1) video_path = sys.argv[1] if not os.path.exists(video_path): print(f"エラー: ファイルが見つかりません -> {video_path}") sys.exit(1) check_dependencies() client = genai.Client(api_key=API_KEY) print(f"🎬 処理開始: {os.path.basename(video_path)}") total_duration = get_video_duration(video_path) if total_duration == 0: print("エラー: 動画の長さを取得できませんでした。") sys.exit(1) print("🔍 無音区間を解析中...") silence_points = get_silence_points(video_path) split_points = [0.0] current_target = SPLIT_BASE_SECONDS while current_target < total_duration: valid_points = [p for p in silence_points if p > split_points[-1] and p <= current_target + 15] if valid_points: best_point = min(valid_points, key=lambda x: abs(x - current_target)) split_points.append(best_point) current_target = best_point + SPLIT_BASE_SECONDS else: split_points.append(current_target) current_target += SPLIT_BASE_SECONDS split_points.append(total_duration) all_vtt_blocks = [] with tempfile.TemporaryDirectory() as tmp_dir: part_count = len(split_points) - 1 print(f"✂️ 動画を {part_count} 個に分割して処理します。") for i in range(part_count): start = split_points[i] end = split_points[i+1] part_dur = end - start ext = os.path.splitext(video_path)[1] part_filename = f"part_{i+1}{ext}" part_path = os.path.join(tmp_dir, part_filename) print(f"\n[{i+1}/{part_count}] 動画の精密切り出し中 ({start:.1f}s - {end:.1f}s / 長さ: {part_dur:.1f}s)...") split_cmd = [ 'ffmpeg', '-y', '-ss', str(start), '-i', video_path, '-t', str(part_dur), '-c:v', 'libx264', '-preset', 'ultrafast', '-crf', '28', '-c:a', 'aac', '-b:a', '128k', part_path ] subprocess.run(split_cmd, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) print(f"[{i+1}/{part_count}] APIへアップロード中...") try: uploaded_file = client.files.upload(file=part_path) print(f"[{i+1}/{part_count}] サーバーでの処理完了を待機中...") while True: file_info = client.files.get(name=uploaded_file.name) if file_info.state == "PROCESSING": time.sleep(3) elif file_info.state == "FAILED": raise Exception("動画の処理に失敗しました。") else: break target_model = "gemini-3.6-flash" gen_config = types.GenerateContentConfig( temperature=0.1 ) max_retries = 5 retry_delay = 10.0 response = None for attempt in range(1, max_retries + 1): try: print(f"[{i+1}/{part_count}] 高精度文字起こし(推論)を実行中... (試行 {attempt}/{max_retries})") response = client.models.generate_content( model=target_model, contents=[uploaded_file, SYSTEM_PROMPT], config=gen_config ) break except Exception as api_err: err_str = str(api_err) if attempt < max_retries and ("503" in err_str or "429" in err_str or "UNAVAILABLE" in err_str or "RESOURCE_EXHAUSTED" in err_str): print(f"⚠️ API混雑/制限検知。{retry_delay:.0f}秒待機して再試行します... (エラー: {err_str[:80]})") time.sleep(retry_delay) retry_delay += 15.0 else: raise api_err print(f"[{i+1}/{part_count}] VTTのパース・空白除去・タイムシフトを実行中...") blocks = parse_and_shift_vtt(response.text, start, part_dur) all_vtt_blocks.extend(blocks) except Exception as e: print(f"エラー発生 ([{i+1}/{part_count}]): {e}") finally: if 'uploaded_file' in locals(): print(f"[{i+1}/{part_count}] API上の動画ファイルを削除中...") try: client.files.delete(name=uploaded_file.name) except: pass print("\n📝 最終的なVTTを結合し、連番を再付与中...") dir_name = os.path.dirname(video_path) base_name = os.path.splitext(os.path.basename(video_path))[0] out_path = os.path.join(dir_name, f"{base_name}_completed.vtt") all_vtt_blocks.sort(key=lambda x: x['start']) with open(out_path, 'w', encoding='utf-8') as f: f.write("WEBVTT\n\n") for idx, block in enumerate(all_vtt_blocks, 1): start_str = sec_to_time(block['start']) end_str = sec_to_time(block['end']) f.write(f"{idx}\n") f.write(f"{start_str} --> {end_str}\n") f.write(f"{block['text']}\n\n") print(f"✅ 完了! 保存先: {out_path}") try: subprocess.run([ "osascript", "-e", 'display notification "字幕作成が完了しました!" with title "XXXXXXシステム"' ]) except: pass if __name__ == "__main__": main() ```