File size: 11,484 Bytes
712d67d cf1639b 92c0551 cf1639b 012fee1 712d67d ab45b8c 712d67d 0e38c1a 6ae38c3 ad797d3 ab45b8c 0e38c1a 92c0551 712d67d 92c0551 712d67d 0e38c1a 92c0551 0e38c1a 712d67d 92c0551 712d67d ab45b8c 92c0551 ab45b8c 92c0551 ab45b8c 92c0551 712d67d ab45b8c 92c0551 0e38c1a 92c0551 6ae38c3 92c0551 712d67d 0e38c1a 92c0551 712d67d ad797d3 92c0551 712d67d ad797d3 92c0551 712d67d 92c0551 712d67d ad797d3 92c0551 0e38c1a 92c0551 b47e197 ad797d3 012fee1 92c0551 6ae38c3 92c0551 6ae38c3 ad797d3 92c0551 ad797d3 6ae38c3 92c0551 6ae38c3 92c0551 ad797d3 6ae38c3 cf1639b 92c0551 6ae38c3 ad797d3 6ae38c3 92c0551 6ae38c3 92c0551 6ae38c3 92c0551 6ae38c3 92c0551 012fee1 6ae38c3 92c0551 6ae38c3 ad797d3 6ae38c3 92c0551 0e38c1a 92c0551 179b24b 8dfb48f b47e197 7e9bb4d 179b24b 92c0551 cf1639b 92c0551 012fee1 92c0551 6ae38c3 012fee1 92c0551 6ae38c3 92c0551 cf1639b 6ae38c3 92c0551 76e8403 6ae38c3 ab45b8c |
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 |
import subprocess
import sys
import gradio as gr
from PIL import Image, PngImagePlugin
import tempfile
import os
import zipfile
from datetime import datetime
import shutil
import json # 修正点: jsonライブラリをインポート
"""
Video Frame Extractor (PNG)
--------------------------
* ffmpeg で **フルレンジ RGB24 PNG** を抽出。
* gAMA / cHRM チャンクを削除して sRGB 相当へ正規化(色ずれ防止)。
* 個別ダウンロード (gr.Files) と ZIP (ZIP_STORED) の両方を提供。
* 末尾フレームは `-sseof -0.05` 方式で確実に取得。
* 🆕 2025‑08‑08: ファイル名にフレーム番号 (7 桁ゼロ埋め) を付与。
* 例) `sample_0000000_start.png`, `sample_0001234_end.png`
* 🛠️ 2025-08-08: ffprobe の情報取得を JSON 形式に変更し、安定性を向上。
"""
###############################
# 1. ユーティリティ
###############################
def install_ffmpeg():
try:
subprocess.run(["ffmpeg", "-version"], capture_output=True, check=True)
return True, "ffmpeg は既にインストール済みです"
except (subprocess.CalledProcessError, FileNotFoundError):
if not sys.platform.startswith("linux"):
return False, f"自動インストール未対応 OS: {sys.platform}"
try:
subprocess.run(["apt", "update"], check=True)
subprocess.run(["apt", "install", "-y", "ffmpeg"], check=True)
return True, "ffmpeg をインストールしました"
except subprocess.CalledProcessError as e:
return False, f"ffmpeg インストール失敗: {e}"
# 修正点: この関数をまるごと入れ替え
def get_video_info(video_path: str):
"""
ffprobe を使って動画のフレーム数と再生時間を取得します。
より安定した JSON 出力形式を利用します。
"""
cmd = [
"ffprobe",
"-v", "quiet",
"-select_streams", "v:0",
"-show_entries", "stream=nb_frames,duration,r_frame_rate",
"-of", "json",
video_path,
]
try:
# ffprobeの実行と結果の取得
result = subprocess.run(cmd, capture_output=True, text=True, check=True)
data = json.loads(result.stdout)
stream_data = data.get("streams", [{}])[0]
# フレーム数の取得 (nb_framesが優先)
frame_count_str = stream_data.get("nb_frames")
if frame_count_str and frame_count_str.isdigit():
frame_count = int(frame_count_str)
else:
# nb_framesがなければ、デュレーションとフレームレートから計算
duration_str = stream_data.get("duration")
frame_rate_str = stream_data.get("r_frame_rate", "0/1")
if duration_str and "/" in frame_rate_str:
duration_val = float(duration_str)
num, den = map(int, frame_rate_str.split('/'))
if den > 0:
frame_rate = num / den
frame_count = int(duration_val * frame_rate)
else:
frame_count = None # 計算不能
else:
frame_count = None # 取得不能
# 再生時間の取得
duration = float(stream_data.get("duration", 0))
return frame_count, duration
except (subprocess.CalledProcessError, json.JSONDecodeError, IndexError, KeyError):
# エラーが発生した場合はNoneを返す
return (None, None)
###############################
# 2. PNG 正規化
###############################
def normalize_png(path: str):
img = Image.open(path).convert("RGB")
pnginfo = PngImagePlugin.PngInfo()
img.save(path, "PNG", pnginfo=pnginfo, optimize=True)
###############################
# 3. フレーム抽出
###############################
def extract_frame_with_ffmpeg(video_path: str, output_path: str, *, is_last: bool = False):
try:
if is_last:
cmd = [
"ffmpeg", "-v", "error", "-sseof", "-0.05", "-i", video_path,
"-vframes", "1", "-vcodec", "png", "-pix_fmt", "rgb24", "-color_range", "pc",
"-y", output_path,
]
else:
cmd = [
"ffmpeg", "-v", "error", "-i", video_path,
"-vframes", "1", "-vcodec", "png", "-pix_fmt", "rgb24", "-color_range", "pc",
"-y", output_path,
]
subprocess.run(cmd, check=True)
normalize_png(output_path)
return True, "成功"
except subprocess.CalledProcessError as e:
return False, f"抽出失敗: {e}"
###############################
# 4. メイン処理
###############################
def pad(num: int, width: int = 7) -> str:
return str(num).zfill(width)
def extract_frames_from_multiple_videos(video_files):
if not video_files:
return None, None, None, None, "⚠️ ビデオを選択してください"
ok, msg_ffm = install_ffmpeg()
if not ok:
return None, None, None, None, f"❌ {msg_ffm}"
results = [f"ℹ️ {msg_ffm}"]
temp_dir = tempfile.mkdtemp()
saved_files, first_imgs, last_imgs = [], [], []
for fobj in video_files:
base = os.path.splitext(os.path.basename(fobj.name))[0]
frame_count, duration = get_video_info(fobj.name)
# ファイル名生成
first_png = os.path.join(temp_dir, f"{base}_{pad(0)}_start.png")
if frame_count is not None and frame_count > 0:
last_png = os.path.join(temp_dir, f"{base}_{pad(frame_count - 1)}_end.png")
else:
last_png = os.path.join(temp_dir, f"{base}_unknown_end.png")
# 抽出
ok_first, _ = extract_frame_with_ffmpeg(fobj.name, first_png, is_last=False)
if not ok_first:
results.append(f"❌ {base}: start 抽出失敗")
continue
first_imgs.append(Image.open(first_png)); saved_files.append(first_png)
ok_last, _ = extract_frame_with_ffmpeg(fobj.name, last_png, is_last=True)
if ok_last:
last_imgs.append(Image.open(last_png)); saved_files.append(last_png)
else:
shutil.copy2(first_png, last_png)
last_imgs.append(Image.open(last_png)); saved_files.append(last_png)
results.append(f"⚠️ {base}: end 抽出失敗 → start 流用")
results.append(
f"✅ {base}: frames {frame_count or '?'} / {duration or '?'}s")
# ZIP 生成
zip_path = os.path.join(temp_dir, f"frames_{datetime.now():%Y%m%d_%H%M%S}.zip")
with zipfile.ZipFile(zip_path, "w", compression=zipfile.ZIP_STORED) as z:
for p in saved_files:
z.write(p, os.path.basename(p))
status = "\n".join(results)
return (
first_imgs[0] if first_imgs else None,
last_imgs[0] if last_imgs else None,
saved_files,
zip_path,
status,
)
###############################
# 5. ギャラリー
###############################
def create_gallery(video_files, is_last=False):
imgs, tmp = [], tempfile.mkdtemp()
for f in video_files:
base = os.path.splitext(os.path.basename(f.name))[0]
# シンプルなギャラリー名 (番号不要)
p = os.path.join(tmp, f"{base}_{'end' if is_last else 'start'}_gal.png")
if extract_frame_with_ffmpeg(f.name, p, is_last=is_last)[0]:
imgs.append(Image.open(p))
return imgs
###############################
# 6. Gradio UI
###############################
# オシャレ配色のテーマ作成
def create_custom_theme():
return gr.Theme(
primary_hue="slate",
secondary_hue="stone",
neutral_hue="zinc",
text_size="md",
spacing_size="lg",
radius_size="sm",
font=[
"Hiragino Sans",
"Noto Sans JP",
"Yu Gothic",
"system-ui",
"sans-serif"
],
font_mono=[
"SF Mono",
"Monaco",
"monospace"
]
).set(
# 背景・文字色
body_background_fill="#ffffff",
body_text_color="#2A4359", # ダークブルー
# プライマリボタン
button_primary_background_fill="#F2163E", # 鮮やかなレッド
button_primary_background_fill_hover="#C177F2", # ホバー時パープル
button_primary_text_color="#ffffff",
# セカンダリボタン
button_secondary_background_fill="#D9BFD4", # 落ち着いたピンクグレー
button_secondary_text_color="#2A4359",
# 入力フォーム
input_background_fill="#ffffff",
input_border_color="#77D9D9", # アクアブルーの枠線
input_border_color_focus="#2A4359", # フォーカス時濃紺
# ブロック・パネル
block_background_fill="#ffffff",
block_border_color="#D9BFD4",
panel_background_fill="#ffffff",
panel_border_color="#D9BFD4",
# スライダー
slider_color="#F2163E"
)
# Gradio UIにテーマ適用
custom_theme = create_custom_theme()
with gr.Blocks(theme=custom_theme, title="Video Frame Extractor (PNG)") as demo:
# ヘッダー
gr.HTML(f"""
<div style='
text-align: center;
margin-bottom: 2rem;
padding: 2rem;
background: linear-gradient(135deg, #F2163E 0%, #77D9D9 100%);
color: white;
border-radius: 12px;
'>
<h1 style='
font-size: 2.5rem;
margin-bottom: 0.5rem;
text-shadow: 2px 2px 4px rgba(0,0,0,0.3);
'>
🎞️ Video Frame Extractor
</h1>
<p style='
font-size: 1.1rem;
margin: 0;
color: #ffffffcc;
'>
抽出した最初と最後のフレームを美しく保存
</p>
</div>
""")
with gr.Row():
video_input = gr.File(
label="動画をアップロード (複数可)",
file_types=["video"],
file_count="multiple"
)
extract_btn = gr.Button("フレームを抽出", variant="primary")
status_box = gr.Textbox(label="ステータス", interactive=False, lines=8)
with gr.Row():
with gr.Column():
first_preview = gr.Image(label="最初のフレーム", type="pil")
last_preview = gr.Image(label="最後のフレーム", type="pil")
with gr.Column():
download_files = gr.Files(label="個別フレーム (PNG)")
download_zip = gr.File(label="まとめて ZIP")
with gr.Row():
gallery_first = gr.Gallery(label="全ての最初", columns=3, rows=2, height="auto")
gallery_last = gr.Gallery(label="全ての最後", columns=3, rows=2, height="auto")
def process(videos):
first, last, files, zipf, status = extract_frames_from_multiple_videos(videos)
g_first = create_gallery(videos, is_last=False)
g_last = create_gallery(videos, is_last=True)
return first, last, files, zipf, status, g_first, g_last
extract_btn.click(
fn=process,
inputs=video_input,
outputs=[
first_preview,
last_preview,
download_files,
download_zip,
status_box,
gallery_first,
gallery_last,
],
)
if __name__ == "__main__":
demo.launch() |