# [メタ情報]
# 識別子: 字幕かんたんエディタ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
```
字幕かんたんエディタ (統合完全版)
(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()
```