Spaces:
Sleeping
Sleeping
| 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() |