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()