JS6969 commited on
Commit
70b0566
·
verified ·
1 Parent(s): 25c98e9

Create app.py

Browse files
Files changed (1) hide show
  1. app.py +453 -0
app.py ADDED
@@ -0,0 +1,453 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # app.py
2
+ # A lightweight, user-friendly FFmpeg UI for extracting video frames on Hugging Face Spaces.
3
+ # - Works on CPU (no GPU required)
4
+ # - Shows the exact ffmpeg command used
5
+ # - Lets you extract every N seconds, every Nth frame, or at an exact FPS
6
+ # - Optional start/end time trims, resize, JPG quality / PNG compression, scene-change detection
7
+ # - Returns a ZIP of frames and a gallery preview
8
+
9
+ import os
10
+ import re
11
+ import io
12
+ import sys
13
+ import json
14
+ import math
15
+ import time
16
+ import shutil
17
+ import zipfile
18
+ import tempfile
19
+ import subprocess
20
+ from pathlib import Path
21
+ from typing import List, Tuple, Optional
22
+
23
+ import gradio as gr
24
+
25
+ # ─────────────────────────────────────────────────────────────
26
+ # Utility: check for ffmpeg/ffprobe availability
27
+ # ─────────────────────────────────────────────────────────────
28
+
29
+ def _which(name: str) -> Optional[str]:
30
+ from shutil import which
31
+ return which(name)
32
+
33
+ FFMPEG = _which("ffmpeg")
34
+ FFPROBE = _which("ffprobe")
35
+
36
+ if not FFMPEG or not FFPROBE:
37
+ # Friendly message shown in the UI footer if ffmpeg is missing
38
+ MISSING_MSG = (
39
+ "⚠️ FFmpeg not found. On Hugging Face Spaces, add a file named 'packages.txt' "
40
+ "with a single line 'ffmpeg' (and optionally 'libsm6' 'libxext6'). Then restart the Space."
41
+ )
42
+ else:
43
+ MISSING_MSG = ""
44
+
45
+ # ─────────────────────────────────────────────────────────────
46
+ # Video probing via ffprobe
47
+ # ─────────────────────────────────────────────────────────────
48
+
49
+ def ffprobe_json(input_path: str) -> dict:
50
+ if not FFPROBE:
51
+ return {}
52
+ cmd = [
53
+ FFPROBE,
54
+ "-v", "error",
55
+ "-print_format", "json",
56
+ "-show_streams",
57
+ "-show_format",
58
+ input_path,
59
+ ]
60
+ res = subprocess.run(cmd, capture_output=True, text=True)
61
+ if res.returncode != 0:
62
+ return {}
63
+ try:
64
+ return json.loads(res.stdout)
65
+ except Exception:
66
+ return {}
67
+
68
+
69
+ def parse_video_info(meta: dict) -> dict:
70
+ info = {"duration": None, "fps": None, "width": None, "height": None, "codec": None}
71
+ if not meta:
72
+ return info
73
+
74
+ # Duration from format
75
+ try:
76
+ info["duration"] = float(meta.get("format", {}).get("duration", None))
77
+ except Exception:
78
+ pass
79
+
80
+ # Find the first video stream
81
+ vstreams = [s for s in meta.get("streams", []) if s.get("codec_type") == "video"]
82
+ if vstreams:
83
+ v = vstreams[0]
84
+ info["codec"] = v.get("codec_name")
85
+ info["width"] = v.get("width")
86
+ info["height"] = v.get("height")
87
+ # FPS from r_frame_rate
88
+ rfr = v.get("r_frame_rate") or v.get("avg_frame_rate")
89
+ if rfr and "/" in rfr:
90
+ num, den = rfr.split("/")
91
+ try:
92
+ num = float(num)
93
+ den = float(den)
94
+ if den != 0:
95
+ info["fps"] = num / den
96
+ except Exception:
97
+ pass
98
+ return info
99
+
100
+
101
+ # ─────────────────────────────────────────────────────────────
102
+ # FFmpeg command builder
103
+ # ─────────────────────────────────────────────────────────────
104
+
105
+ def build_ffmpeg_command(
106
+ input_path: str,
107
+ mode: str,
108
+ every_seconds: float,
109
+ nth_frame: int,
110
+ exact_fps: float,
111
+ start_time: str,
112
+ end_time: str,
113
+ long_side: int,
114
+ out_format: str,
115
+ jpg_quality: int,
116
+ png_level: int,
117
+ scene_detect: bool,
118
+ scene_thresh: float,
119
+ out_pattern: str,
120
+ ) -> List[str]:
121
+ """Return a full ffmpeg command list for subprocess.run."""
122
+ if not FFMPEG:
123
+ raise RuntimeError("FFmpeg is not available on this system.")
124
+
125
+ cmd = [FFMPEG, "-y"]
126
+
127
+ # Optional in/out trims
128
+ if start_time:
129
+ cmd += ["-ss", start_time]
130
+
131
+ cmd += ["-i", input_path]
132
+
133
+ if end_time:
134
+ # Use -to for end timestamp relative to input start
135
+ cmd += ["-to", end_time]
136
+
137
+ # Build filter chain
138
+ vf_parts = []
139
+
140
+ # 1) Frame selection / rate
141
+ if mode == "Every N seconds":
142
+ # fps=1/seconds
143
+ rate = 1.0 / max(every_seconds, 0.000001)
144
+ vf_parts.append(f"fps={rate}")
145
+ elif mode == "Every Nth frame":
146
+ # select='not(mod(n\,N))' -> then set fps to input fps to avoid duplicating
147
+ vf_parts.append(f"select='not(mod(n,{max(nth_frame,1)}))'")
148
+ vf_parts.append("setpts=N/FRAME_RATE/TB")
149
+ elif mode == "Exact FPS":
150
+ vf_parts.append(f"fps={max(exact_fps, 0.000001)}")
151
+ elif mode == "All frames":
152
+ # No explicit fps filter — pass all frames
153
+ pass
154
+ else:
155
+ vf_parts.append("fps=1")
156
+
157
+ # 2) Scene change detection (grabs frames when scene changes by threshold)
158
+ if scene_detect:
159
+ # Use select filter: 'gt(scene,THRESH)' outputs only scene-change frames
160
+ vf_parts.append(f"select='gt(scene,{scene_thresh})',showinfo")
161
+ vf_parts.append("setpts=N/FRAME_RATE/TB")
162
+
163
+ # 3) Resize by long side
164
+ if long_side and long_side > 0:
165
+ # Maintain aspect: scale=LONG:-1 sets height auto; but we need to pick which side is longer
166
+ # Use force_original_aspect_ratio=decrease and -1 for one dim with eval
167
+ # To keep the *long* side at long_side, we can use scale logic with if(gt(iw,ih),...)
168
+ vf_parts.append(
169
+ f"scale='if(gt(iw,ih),{long_side},-1)':'if(gt(iw,ih),-1,{long_side})':force_original_aspect_ratio=decrease"
170
+ )
171
+
172
+ # Join vf
173
+ if vf_parts:
174
+ cmd += ["-vf", ",".join(vf_parts)]
175
+
176
+ # 4) Output options per format
177
+ ext = out_format.lower()
178
+ if ext == "jpg":
179
+ # Lower -q:v is higher quality. Map slider (2..31) directly
180
+ cmd += ["-q:v", str(jpg_quality)]
181
+ elif ext == "png":
182
+ # Compression level 0..9
183
+ cmd += ["-compression_level", str(png_level)]
184
+
185
+ # Avoid timestamps gaps in patterns
186
+ cmd += ["-frame_pts", "1"]
187
+
188
+ # Output pattern
189
+ cmd += [out_pattern]
190
+ return cmd
191
+
192
+
193
+ # ─────────────────────────────────────────────────────────────
194
+ # Extraction runtime
195
+ # ─────────────────────────────────────────────────────────────
196
+
197
+ def extract_frames(
198
+ video: gr.File | None,
199
+ mode: str,
200
+ every_seconds: float,
201
+ nth_frame: int,
202
+ exact_fps: float,
203
+ start_time: str,
204
+ end_time: str,
205
+ long_side: int,
206
+ out_format: str,
207
+ jpg_quality: int,
208
+ png_level: int,
209
+ scene_detect: bool,
210
+ scene_thresh: float,
211
+ prefix: str,
212
+ progress=gr.Progress(track_tqdm=True),
213
+ ):
214
+ if not video or not video.name:
215
+ return None, None, "Please upload a video.", ""
216
+
217
+ if not FFMPEG or not FFPROBE:
218
+ return None, None, "FFmpeg is not available. See the note below.", MISSING_MSG
219
+
220
+ # Probe video info
221
+ meta = ffprobe_json(video.name)
222
+ info = parse_video_info(meta)
223
+
224
+ # Prepare temp dir
225
+ work = Path(tempfile.mkdtemp(prefix="frames_"))
226
+ out_dir = work / "frames"
227
+ out_dir.mkdir(parents=True, exist_ok=True)
228
+
229
+ # Output pattern
230
+ ext = out_format.lower()
231
+ pattern = str(out_dir / f"{prefix}_%05d.{ext}")
232
+
233
+ # Build command
234
+ cmd = build_ffmpeg_command(
235
+ input_path=video.name,
236
+ mode=mode,
237
+ every_seconds=every_seconds,
238
+ nth_frame=nth_frame,
239
+ exact_fps=exact_fps,
240
+ start_time=start_time.strip(),
241
+ end_time=end_time.strip(),
242
+ long_side=long_side,
243
+ out_format=ext,
244
+ jpg_quality=jpg_quality,
245
+ png_level=png_level,
246
+ scene_detect=scene_detect,
247
+ scene_thresh=scene_thresh,
248
+ out_pattern=pattern,
249
+ )
250
+
251
+ # Friendly command preview
252
+ command_preview = " ".join([sh if " " not in sh else f'"{sh}"' for sh in cmd])
253
+
254
+ # Estimate total frames for progress (best-effort)
255
+ total = None
256
+ try:
257
+ duration = info.get("duration")
258
+ in_fps = info.get("fps") or 30
259
+ if start_time:
260
+ # Roughly convert HH:MM:SS.mmm to seconds
261
+ parts = [float(x) for x in re.split(r"[:]", start_time)]
262
+ if len(parts) == 3:
263
+ duration = max(0.0, (duration or 0) - (parts[0]*3600 + parts[1]*60 + parts[2]))
264
+ if end_time:
265
+ parts = [float(x) for x in re.split(r"[:]", end_time)]
266
+ if len(parts) == 3:
267
+ duration = min(duration or 0, (parts[0]*3600 + parts[1]*60 + parts[2]))
268
+ if duration:
269
+ if mode == "Every N seconds" and every_seconds > 0:
270
+ total = int(math.ceil(duration / every_seconds))
271
+ elif mode == "Every Nth frame" and in_fps and nth_frame > 0:
272
+ total = int(math.ceil((duration * in_fps) / nth_frame))
273
+ elif mode == "Exact FPS" and exact_fps > 0:
274
+ total = int(math.ceil(duration * exact_fps))
275
+ elif mode == "All frames" and in_fps:
276
+ total = int(math.ceil(duration * in_fps))
277
+ except Exception:
278
+ total = None
279
+
280
+ # Run ffmpeg and stream stderr for incremental progress by counting files created
281
+ proc = subprocess.Popen(cmd, stderr=subprocess.PIPE, stdout=subprocess.DEVNULL, text=True, bufsize=1)
282
+
283
+ created = 0
284
+ last_update = time.time()
285
+ while True:
286
+ line = proc.stderr.readline()
287
+ if not line and proc.poll() is not None:
288
+ break
289
+ # Periodically refresh progress based on files present
290
+ if time.time() - last_update > 0.2:
291
+ created = len(list(out_dir.glob(f"{prefix}_*.{ext}")))
292
+ if total:
293
+ progress(created / max(total, 1))
294
+ last_update = time.time()
295
+
296
+ ret = proc.wait()
297
+
298
+ # Final count
299
+ frame_files = sorted(out_dir.glob(f"{prefix}_*.{ext}"))
300
+ created = len(frame_files)
301
+
302
+ if ret != 0 or created == 0:
303
+ # Read remaining stderr to show message
304
+ try:
305
+ err_rest = proc.stderr.read() if proc.stderr else ""
306
+ except Exception:
307
+ err_rest = ""
308
+ return None, None, f"FFmpeg failed or produced no frames.\n\nStderr:\n{err_rest}", command_preview
309
+
310
+ # Build a small gallery (cap to avoid huge RAM)
311
+ gallery_cap = 60
312
+ gallery_paths = [str(p) for p in frame_files[:gallery_cap]]
313
+
314
+ # Zip everything
315
+ zip_path = work / f"{prefix}_frames.zip"
316
+ with zipfile.ZipFile(zip_path, "w", zipfile.ZIP_DEFLATED) as zf:
317
+ for p in frame_files:
318
+ zf.write(p, p.name)
319
+
320
+ # Info text
321
+ info_lines = []
322
+ if info.get("fps"):
323
+ info_lines.append(f"Input FPS: {info['fps']:.3f}")
324
+ if info.get("duration"):
325
+ info_lines.append(f"Duration: {info['duration']:.2f}s")
326
+ if info.get("width") and info.get("height"):
327
+ info_lines.append(f"Resolution: {info['width']}×{info['height']}")
328
+ info_lines.append(f"Frames extracted: {created}")
329
+
330
+ details = "\n".join(info_lines)
331
+
332
+ return gallery_paths, str(zip_path), details, command_preview
333
+
334
+
335
+ # ─────────────────────────────────────────────────────────────
336
+ # UI
337
+ # ─────────────────────────────────────────────────────────────
338
+
339
+ def build_ui():
340
+ with gr.Blocks(theme=gr.themes.Soft(), css="""
341
+ .cf-title { font-size: 1.6rem; font-weight: 800; }
342
+ .cf-sub { opacity: .8; }
343
+ .cmdbox textarea { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 12px; }
344
+ """) as demo:
345
+ gr.Markdown("""
346
+ <div class="cf-title">FFmpeg Frames UI</div>
347
+ <div class="cf-sub">Extract JPG/PNG frames from videos — friendly controls, exact command shown.</div>
348
+ """)
349
+
350
+ with gr.Row():
351
+ video = gr.File(label="Upload video", file_types=[".mp4", ".mov", ".mkv", ".avi", ".webm", ".m4v"], type="filepath")
352
+
353
+ with gr.Row():
354
+ mode = gr.Dropdown(
355
+ ["Every N seconds", "Every Nth frame", "Exact FPS", "All frames"],
356
+ value="Every N seconds", label="Extraction mode"
357
+ )
358
+ every_seconds = gr.Number(value=1.0, label="Every N seconds (e.g., 0.5 = every 500ms)")
359
+ nth_frame = gr.Number(value=30, label="Every Nth frame (e.g., 30 = 1 frame per 30)")
360
+ exact_fps = gr.Number(value=1.0, label="Exact FPS (e.g., 2.0)")
361
+
362
+ with gr.Row():
363
+ start_time = gr.Textbox(value="", label="Start time (HH:MM:SS.mmm, optional)")
364
+ end_time = gr.Textbox(value="", label="End time (HH:MM:SS.mmm, optional)")
365
+ long_side = gr.Number(value=0, label="Resize long side px (0 = no resize)")
366
+
367
+ with gr.Row():
368
+ out_format = gr.Dropdown(["jpg", "png"], value="jpg", label="Output format")
369
+ jpg_quality = gr.Slider(2, 31, value=3, step=1, label="JPG quality (2=best, 31=worst)")
370
+ png_level = gr.Slider(0, 9, value=2, step=1, label="PNG compression level (0..9)")
371
+
372
+ with gr.Row():
373
+ scene_detect = gr.Checkbox(value=False, label="Enable scene-change extraction (advanced)")
374
+ scene_thresh = gr.Slider(0.0, 1.0, value=0.3, step=0.01, label="Scene threshold (0..1)")
375
+ prefix = gr.Textbox(value="frame", label="Filename prefix")
376
+
377
+ with gr.Row():
378
+ run_btn = gr.Button("Extract Frames", variant="primary")
379
+ info_btn = gr.Button("Probe Video (FPS / Duration)")
380
+
381
+ with gr.Row():
382
+ gallery = gr.Gallery(label="Preview (first 60 frames)", columns=6, height=300)
383
+
384
+ with gr.Row():
385
+ zip_out = gr.File(label="Download all frames as ZIP")
386
+
387
+ with gr.Row():
388
+ details = gr.Markdown("Frames extracted: —")
389
+
390
+ with gr.Accordion("Show exact ffmpeg command", open=False):
391
+ cmd_preview = gr.Textbox(label="ffmpeg command", lines=4, elem_classes=["cmdbox"])
392
+
393
+ # Footer
394
+ if MISSING_MSG:
395
+ gr.Markdown(f"<span style='color:#b45309'>{MISSING_MSG}</span>")
396
+
397
+ # Wire behavior: enable/disable param groups depending on mode / format
398
+ def _toggle_params(mode_val, fmt):
399
+ return (
400
+ gr.update(visible=(mode_val == "Every N seconds")),
401
+ gr.update(visible=(mode_val == "Every Nth frame")),
402
+ gr.update(visible=(mode_val == "Exact FPS")),
403
+ gr.update(visible=(fmt == "jpg")),
404
+ gr.update(visible=(fmt == "png")),
405
+ )
406
+
407
+ mode.change(
408
+ _toggle_params,
409
+ inputs=[mode, out_format],
410
+ outputs=[every_seconds, nth_frame, exact_fps, jpg_quality, png_level],
411
+ )
412
+ out_format.change(
413
+ _toggle_params,
414
+ inputs=[mode, out_format],
415
+ outputs=[every_seconds, nth_frame, exact_fps, jpg_quality, png_level],
416
+ )
417
+ # Initialize visibility
418
+ demo.load(_toggle_params, inputs=[mode, out_format], outputs=[every_seconds, nth_frame, exact_fps, jpg_quality, png_level])
419
+
420
+ # Actions
421
+ run_btn.click(
422
+ extract_frames,
423
+ inputs=[
424
+ video, mode, every_seconds, nth_frame, exact_fps,
425
+ start_time, end_time, long_side, out_format, jpg_quality, png_level,
426
+ scene_detect, scene_thresh, prefix
427
+ ],
428
+ outputs=[gallery, zip_out, details, cmd_preview],
429
+ api_name="extract_frames",
430
+ )
431
+
432
+ def probe(video: gr.File | None):
433
+ if not video or not video.name:
434
+ return "Upload a video to probe."
435
+ meta = ffprobe_json(video.name)
436
+ info = parse_video_info(meta)
437
+ lines = ["**Video info**"]
438
+ if info.get("fps"):
439
+ lines.append(f"• FPS: **{info['fps']:.3f}**")
440
+ if info.get("duration"):
441
+ lines.append(f"• Duration: **{info['duration']:.2f}s**")
442
+ if info.get("width") and info.get("height"):
443
+ lines.append(f"• Resolution: **{info['width']}×{info['height']}**")
444
+ return "\n".join(lines)
445
+
446
+ info_btn.click(probe, inputs=[video], outputs=[details])
447
+
448
+ return demo
449
+
450
+
451
+ if __name__ == "__main__":
452
+ demo = build_ui()
453
+ demo.queue().launch()