JS6969 commited on
Commit
505ff91
·
verified ·
1 Parent(s): 235593d

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +70 -369
app.py CHANGED
@@ -1,222 +1,44 @@
1
- # ────────────────────────────────────────────────────────
2
- # Standard imports
3
- # ────────────────────────────────────────────────────────
4
-
5
- import os, re, json, math, time, zipfile, tempfile, subprocess, base64
6
  from pathlib import Path
7
  from typing import List, Optional
8
  import gradio as gr
9
- import numpy as np
10
-
11
- _num = re.compile(r'(\d+)')
12
 
 
13
  def _natural_key(p: Path | str):
 
 
14
  s = str(p)
15
- return [int(t) if t.isdigit() else t.lower() for t in _num.split(s)]
16
 
17
  def sample_paths(paths: List[Path] | List[str], n: int = 30) -> List[str]:
18
- if not paths:
19
- return []
20
  paths = sorted(paths, key=_natural_key)
21
- total = len(paths)
22
- n = max(1, min(n, total))
23
- if n == total:
24
- return [str(p) for p in paths]
25
  step = (total - 1) / (n - 1)
26
  idxs = [round(i * step) for i in range(n)]
27
  out, seen = [], set()
28
  for i in idxs:
29
  if i not in seen:
30
- out.append(str(paths[int(i)]))
31
- seen.add(int(i))
32
  return out
33
-
34
- import base64
35
-
36
- APP_DIR = os.getcwd()
37
- def load_logo_base64(path: str) -> str:
38
- with open(path, "rb") as f:
39
- return base64.b64encode(f.read()).decode("utf-8")
40
-
41
- # Preload your Bifröst logo
42
- LOGO_B64 = load_logo_base64(os.path.join(APP_DIR, "bifrost_logo.png"))
43
-
44
- def render_logo_html(px: int = 96) -> str:
45
- return f"""
46
- <div style="display:flex;align-items:center;gap:16px;">
47
- <img src="data:image/png;base64,{LOGO_B64}" style="height:{px}px;width:auto;" />
48
- <div>
49
- <div style="font-size:1.6rem;font-weight:800;">Bifröst Video to Image</div>
50
- <div style="opacity:0.8;">Video → Frames → Upscale → Re-encode</div>
51
- </div>
52
- </div>
53
- <hr>
54
- """
55
-
56
-
57
-
58
- # ─────────────────────────────────────────────────────────────
59
- # System checks & deps
60
- # ─────────────────────────────────────────────────────────────
61
-
62
- def _which(name: str) -> Optional[str]:
63
- from shutil import which
64
- return which(name)
65
-
66
- FFMPEG = _which("ffmpeg")
67
- FFPROBE = _which("ffprobe")
68
-
69
- if not FFMPEG or not FFPROBE:
70
- MISSING_MSG = (
71
- "⚠️ FFmpeg not found. Add a 'packages.txt' with exactly:\n"
72
- "ffmpeg\n"
73
- "libsm6\n"
74
- "libxext6\n"
75
- "Then restart the Space."
76
- )
77
- else:
78
- MISSING_MSG = ""
79
-
80
- # ─────────────────────────────────────────────────────────────
81
- # Helpers
82
- # ─────────────────────────────────────────────────────────────
83
-
84
- def sanitize_prefix(txt: str) -> str:
85
- txt = (txt or "").strip()
86
- if not txt:
87
- return ""
88
- return re.sub(r"[^A-Za-z0-9._-]+", "_", txt)[:80]
89
-
90
-
91
- def ffprobe_json(input_path: str) -> dict:
92
- if not FFPROBE:
93
- return {}
94
- cmd = [FFPROBE, "-v", "error", "-print_format", "json", "-show_streams", "-show_format", input_path]
95
- res = subprocess.run(cmd, capture_output=True, text=True)
96
- if res.returncode != 0:
97
- return {}
98
- try:
99
- return json.loads(res.stdout)
100
- except Exception:
101
- return {}
102
-
103
-
104
- def parse_video_info(meta: dict) -> dict:
105
- info = {"duration": None, "fps": None, "width": None, "height": None}
106
- if not meta:
107
- return info
108
- try:
109
- info["duration"] = float(meta.get("format", {}).get("duration", None))
110
- except Exception:
111
- pass
112
- vstreams = [s for s in meta.get("streams", []) if s.get("codec_type") == "video"]
113
- if vstreams:
114
- v = vstreams[0]
115
- rfr = v.get("r_frame_rate") or v.get("avg_frame_rate")
116
- if rfr and "/" in rfr:
117
- try:
118
- num, den = rfr.split("/")
119
- num = float(num); den = float(den)
120
- if den != 0:
121
- info["fps"] = num / den
122
- except Exception:
123
- pass
124
- info["width"] = v.get("width")
125
- info["height"] = v.get("height")
126
- return info
127
-
128
-
129
- def estimate_output_count(mode: str, duration: float | None, in_fps: float | None,
130
- every_seconds: float, nth_frame: int, exact_fps: float) -> Optional[int]:
131
- if not duration:
132
- return None
133
- in_fps = in_fps or 30.0
134
- try:
135
- if mode == "All frames":
136
- return int(math.ceil(duration * in_fps))
137
- if mode == "Every N seconds" and every_seconds > 0:
138
- return int(math.ceil(duration / every_seconds))
139
- if mode == "Every Nth frame" and nth_frame > 0:
140
- return int(math.ceil((duration * in_fps) / nth_frame))
141
- if mode == "Exact FPS" and exact_fps > 0:
142
- return int(math.ceil(duration * exact_fps))
143
- except Exception:
144
- return None
145
- return None
146
-
147
-
148
- def build_ffmpeg_extract(
149
- input_path: str,
150
- mode: str,
151
- every_seconds: float,
152
- nth_frame: int,
153
- exact_fps: float,
154
- start_time: str,
155
- end_time: str,
156
- long_side: int,
157
- out_format: str,
158
- jpg_quality: int,
159
- png_level: int,
160
- scene_detect: bool,
161
- scene_thresh: float,
162
- out_pattern: str,
163
- ) -> List[str]:
164
- if not FFMPEG:
165
- raise RuntimeError("FFmpeg not available")
166
- cmd = [FFMPEG, "-y"]
167
- if start_time:
168
- cmd += ["-ss", start_time]
169
- cmd += ["-i", input_path]
170
- if end_time:
171
- cmd += ["-to", end_time]
172
- vf = []
173
- if mode == "Every N seconds":
174
- vf.append(f"fps={max(1e-6, 1.0/float(every_seconds or 1))}")
175
- elif mode == "Every Nth frame":
176
- vf.append(f"select='not(mod(n,{max(1, int(nth_frame or 1))}))'")
177
- vf.append("setpts=N/FRAME_RATE/TB")
178
- elif mode == "Exact FPS":
179
- vf.append(f"fps={max(1e-6, float(exact_fps or 1))}")
180
- elif mode == "All frames":
181
- pass
182
- else:
183
- vf.append("fps=1")
184
- if scene_detect:
185
- vf.append(f"select='gt(scene,{float(scene_thresh)})',showinfo")
186
- vf.append("setpts=N/FRAME_RATE/TB")
187
- if long_side and long_side > 0:
188
- vf.append("scale='if(gt(iw,ih),%d,-1)':'if(gt(iw,ih),-1,%d)':force_original_aspect_ratio=decrease" % (long_side, long_side))
189
- if vf:
190
- cmd += ["-vf", ",".join(vf)]
191
- if out_format == "jpg":
192
- cmd += ["-q:v", str(jpg_quality)]
193
- elif out_format == "png":
194
- cmd += ["-compression_level", str(png_level)]
195
- cmd += ["-frame_pts", "1", out_pattern]
196
- return cmd
197
-
198
- frames = sorted(raw_dir.glob(f"{prefix}_*.{out_format}"), key=_natural_key)
199
-
200
- # Adaptive preview:
201
- if len(frames) <= 100:
202
- gallery = [str(p) for p in frames] # show all
203
- else:
204
- gallery = sample_paths(frames, 100) # evenly sample 100
205
-
206
- zip_path = work / "frames.zip"
207
- with zipfile.ZipFile(zip_path, "w", zipfile.ZIP_DEFLATED) as zf:
208
- for p in frames:
209
- zf.write(p, p.name)
210
-
211
- details = f"Frames extracted: {len(frames)} | Saved to: {raw_dir}"
212
- return gallery, str(zip_path), details, cmd_preview, render_progress(100.0, f"Extracted {len(frames)} frames"), [str(p) for p in frames], str(raw_dir), prefix
213
 
214
  def render_progress(pct: float, label: str = "") -> str:
215
  pct = max(0.0, min(100.0, pct))
216
- return f'''<div style="width:100%;border:1px solid #ddd;border-radius:8px;overflow:hidden;height:18px;"><div style="height:100%;width:{pct:.1f}%;background:#3b82f6;"></div></div><div style="font-size:12px;opacity:.8;margin-top:4px;">{label} {pct:.1f}%</div>'''
217
-
218
- # ───────────────── Extraction (Step 1)
 
 
 
 
 
 
 
 
219
 
 
220
  def step1_extract(
221
  video: gr.File | None,
222
  mode: str,
@@ -233,218 +55,97 @@ def step1_extract(
233
  scene_thresh: float,
234
  prefix_in: str,
235
  prog_html: str,
 
236
  ):
237
  if not video or not video.name:
238
- yield None, None, "Upload a video.", "", prog_html, None, None, None
239
- return
240
- if not FFMPEG or not FFPROBE:
241
- yield None, None, "FFmpeg missing. See note below.", MISSING_MSG, prog_html, None, None, None
242
- return
243
 
244
  work = Path(tempfile.mkdtemp(prefix="vid2img_"))
245
  raw_dir = work / "frames_raw"
246
  raw_dir.mkdir(parents=True, exist_ok=True)
247
 
248
- prefix = sanitize_prefix(prefix_in) or Path(video.name).stem
249
-
250
- # Duration for progress %
251
- vinfo = parse_video_info(ffprobe_json(video.name))
252
- full_duration = float(vinfo.get("duration") or 0.0)
253
 
254
- # If trimming, adjust expected duration window
255
- def _parse_ts(ts: str) -> float:
256
- if not ts: return 0.0
257
- h, m, s = ts.split(":") if ":" in ts else ("0","0",ts)
258
- return float(h)*3600 + float(m)*60 + float(s)
 
 
259
 
260
- st_s = _parse_ts((start_time or "").strip())
261
- et_s = _parse_ts((end_time or "").strip())
262
- if full_duration and st_s > 0:
263
- full_duration = max(0.0, full_duration - st_s)
264
- if full_duration and et_s > 0 and et_s < (vinfo.get("duration") or 0):
265
- full_duration = max(0.0, min(full_duration, et_s))
266
-
267
- # Build command
268
- pattern = str(raw_dir / f"{prefix}_%05d.{out_format}")
269
- cmd = build_ffmpeg_extract(
270
- input_path=video.name,
271
- mode=mode,
272
- every_seconds=every_seconds,
273
- nth_frame=nth_frame,
274
- exact_fps=exact_fps,
275
- start_time=(start_time or "").strip(),
276
- end_time=(end_time or "").strip(),
277
- long_side=long_side,
278
- out_format=out_format,
279
- jpg_quality=jpg_quality,
280
- png_level=png_level,
281
- scene_detect=scene_detect,
282
- scene_thresh=scene_thresh,
283
- out_pattern=pattern,
284
- )
285
-
286
- # Inject progress reporting
287
- # ffmpeg will write key=value lines including out_time to stderr
288
- cmd = [cmd[0], "-progress", "pipe:2"] + cmd[1:]
289
- cmd_preview = " ".join([s if " " not in s else f'"{s}"' for s in cmd])
290
-
291
- proc = subprocess.Popen(
292
- cmd, stderr=subprocess.PIPE, stdout=subprocess.DEVNULL, text=True, bufsize=1
293
- )
294
-
295
- # Stream updates
296
- last_pct = 0.0
297
- gallery_preview = []
298
  while True:
299
  line = proc.stderr.readline()
300
  if not line and proc.poll() is not None:
301
  break
302
-
303
- line = (line or "").strip()
304
- # out_time is in HH:MM:SS.microsec
305
- if line.startswith("out_time=") and full_duration > 0:
306
- t = line.split("=", 1)[1]
307
- # Convert HH:MM:SS.xx to seconds
308
- try:
309
- h, m, s = t.split(":")
310
- secs = float(h) * 3600 + float(m) * 60 + float(s)
311
- except Exception:
312
- secs = 0.0
313
- pct = max(0.0, min(100.0, (secs / full_duration) * 100.0))
314
- if pct - last_pct >= 1.0 or pct in (0.0, 100.0):
315
- last_pct = pct
316
- # Lightweight live gallery sample for feel; not required
317
- gallery_preview = sample_paths(sorted(raw_dir.glob(f"{prefix}_*.{out_format}"), key=_natural_key), 36)
318
- yield gallery_preview, None, "Extracting…", cmd_preview, render_progress(pct, f"Extracting {pct:.0f}%"), None, str(raw_dir), prefix
319
-
320
  ret = proc.wait()
321
 
322
  frames = sorted(raw_dir.glob(f"{prefix}_*.{out_format}"), key=_natural_key)
323
 
324
- # Show all if ≤100, else sample 100
325
- if len(frames) <= 100:
326
  gallery = [str(p) for p in frames]
327
  else:
328
- gallery = sample_paths(frames, 100)
329
 
330
- zip_path = work / "frames.zip"
 
331
  with zipfile.ZipFile(zip_path, "w", zipfile.ZIP_DEFLATED) as zf:
332
  for p in frames:
333
  zf.write(p, p.name)
334
 
335
  if ret != 0 or not frames:
336
- err = ""
337
- try:
338
- err = proc.stderr.read() if proc.stderr else ""
339
- except Exception:
340
- pass
341
- yield gallery, None, f"Extraction failed.\n\n{err}", cmd_preview, render_progress(0.0, "Failed"), None, str(raw_dir), prefix
342
- return
343
 
344
  details = f"Frames extracted: {len(frames)} | Saved to: {raw_dir}"
345
- yield gallery, str(zip_path), details, cmd_preview, render_progress(100.0, f"Extracted {len(frames)} frames"), [str(p) for p in frames], str(raw_dir), prefix
346
-
347
- # ───────────────── UI
348
 
 
349
  def build_ui():
350
  with gr.Blocks(theme=gr.themes.Soft()) as demo:
351
- gr.HTML(render_logo_html(88))
352
- gr.Markdown("Extract frames from a video with live progress.")
353
-
354
- # Upload video
355
- with gr.Row():
356
- video = gr.File(
357
- label="Upload video",
358
- file_types=[".mp4", ".mov", ".mkv", ".avi", ".webm", ".m4v"],
359
- type="filepath"
360
- )
361
 
362
- # Extraction settings
363
- with gr.Accordion("Extraction Settings", open=True):
364
- with gr.Row():
365
- mode = gr.Dropdown(
366
- ["Every N seconds", "Every Nth frame", "Exact FPS", "All frames"],
367
- value="Every N seconds", label="Mode"
368
- )
369
- every_seconds = gr.Number(value=1.0, label="Every N seconds")
370
- nth_frame = gr.Number(value=30, label="Every Nth frame")
371
- exact_fps = gr.Number(value=1.0, label="Exact FPS")
372
- with gr.Row():
373
- start_time = gr.Textbox(value="", label="Start (HH:MM:SS.mmm)")
374
- end_time = gr.Textbox(value="", label="End (HH:MM:SS.mmm)")
375
- long_side = gr.Number(value=0, label="Resize long side px (0 = none)")
376
- with gr.Row():
377
- out_format = gr.Dropdown(["jpg", "png"], value="jpg", label="Output format")
378
- jpg_quality = gr.Slider(2, 31, value=3, step=1, label="JPG quality (2=best)")
379
- png_level = gr.Slider(0, 9, value=2, step=1, label="PNG compression level")
380
- with gr.Row():
381
- scene_detect = gr.Checkbox(False, label="Scene-change detect")
382
- scene_thresh = gr.Slider(0.0, 1.0, value=0.3, step=0.01, label="Scene threshold")
383
- prefix_vid = gr.Textbox(value="", label="Filename prefix (defaults to input file name)")
384
 
385
- # Controls & outputs
386
  btn_extract = gr.Button("Extract Frames", variant="primary")
387
  prog = gr.HTML(render_progress(0.0, "Idle"))
388
- gallery = gr.Gallery(label="Preview (≤100, else sample 100)", columns=6, height=480)
389
  zip_out = gr.File(label="Download frames ZIP")
390
  details = gr.Markdown("Ready.")
391
- with gr.Accordion("Show FFmpeg command", open=False):
392
- cmd_preview = gr.Textbox(label="ffmpeg command", lines=4)
393
- estimate_md = gr.Markdown("Estimated output: —")
394
 
395
- # === Functions wired into UI ===
396
- def _toggle_params(mode_val, fmt):
397
- return (
398
- gr.update(visible=(mode_val == "Every N seconds")),
399
- gr.update(visible=(mode_val == "Every Nth frame")),
400
- gr.update(visible=(mode_val == "Exact FPS")),
401
- gr.update(visible=(fmt == "jpg")),
402
- gr.update(visible=(fmt == "png")),
403
- )
404
-
405
- def update_estimate(vfile, mode_val, evs, nth, exfps, st, et):
406
- if not vfile or not getattr(vfile, 'name', None):
407
- return "Estimated output: —"
408
- info = parse_video_info(ffprobe_json(vfile.name))
409
- dur = info.get("duration")
410
-
411
- def parse_ts(ts: str):
412
- if not ts: return 0.0
413
- parts = ts.split(":")
414
- if len(parts) == 3:
415
- try:
416
- return float(parts[0])*3600 + float(parts[1])*60 + float(parts[2])
417
- except Exception:
418
- return 0.0
419
- return 0.0
420
-
421
- st_s = parse_ts(st or ""); et_s = parse_ts(et or "")
422
- if dur:
423
- if st_s: dur = max(0.0, dur - st_s)
424
- if et_s and et_s < info.get("duration", 0) and et_s > 0:
425
- dur = min(dur, et_s)
426
- est = estimate_output_count(mode_val, dur, info.get("fps"), evs or 1.0, int(nth or 1), exfps or 1.0)
427
- return f"Estimated output: **~{est} frames**" if est else "Estimated output: —"
428
-
429
- # Wire up dynamic visibility
430
- mode.change(_toggle_params, [mode, out_format], [every_seconds, nth_frame, exact_fps, jpg_quality, png_level])
431
- out_format.change(_toggle_params, [mode, out_format], [every_seconds, nth_frame, exact_fps, jpg_quality, png_level])
432
- demo.load(_toggle_params, [mode, out_format], [every_seconds, nth_frame, exact_fps, jpg_quality, png_level])
433
-
434
- # Wire up estimate updater
435
- for ctrl in [video, mode, every_seconds, nth_frame, exact_fps, start_time, end_time]:
436
- ctrl.change(update_estimate, inputs=[video, mode, every_seconds, nth_frame, exact_fps, start_time, end_time], outputs=[estimate_md])
437
-
438
- # Extract button
439
  btn_extract.click(
440
  step1_extract,
441
- inputs=[video, mode, every_seconds, nth_frame, exact_fps, start_time, end_time, long_side, out_format, jpg_quality, png_level, scene_detect, scene_thresh, prefix_vid, prog],
442
- outputs=[gallery, zip_out, details, cmd_preview, prog],
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
443
  )
444
 
445
  if MISSING_MSG:
446
  gr.Markdown(f"<span style='color:#b45309'>{MISSING_MSG}</span>")
447
 
448
  return demo
 
449
  if __name__ == "__main__":
450
- build_ui().queue().launch()
 
1
+ # Bifröst · Video → Frames Extractor
2
+ import os, time, zipfile, tempfile, subprocess
 
 
 
3
  from pathlib import Path
4
  from typing import List, Optional
5
  import gradio as gr
 
 
 
6
 
7
+ # --- Helpers ---
8
  def _natural_key(p: Path | str):
9
+ import re
10
+ num = re.compile(r'(\d+)')
11
  s = str(p)
12
+ return [int(t) if t.isdigit() else t.lower() for t in num.split(s)]
13
 
14
  def sample_paths(paths: List[Path] | List[str], n: int = 30) -> List[str]:
15
+ if not paths: return []
 
16
  paths = sorted(paths, key=_natural_key)
17
+ total = len(paths); n = max(1, min(n, total))
18
+ if n == total: return [str(p) for p in paths]
 
 
19
  step = (total - 1) / (n - 1)
20
  idxs = [round(i * step) for i in range(n)]
21
  out, seen = [], set()
22
  for i in idxs:
23
  if i not in seen:
24
+ out.append(str(paths[int(i)])); seen.add(int(i))
 
25
  return out
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
26
 
27
  def render_progress(pct: float, label: str = "") -> str:
28
  pct = max(0.0, min(100.0, pct))
29
+ return f'''<div style="width:100%;border:1px solid #ddd;border-radius:8px;overflow:hidden;height:18px;">
30
+ <div style="height:100%;width:{pct:.1f}%;background:#3b82f6;"></div></div>
31
+ <div style="font-size:12px;opacity:.8;margin-top:4px;">{label} {pct:.1f}%</div>'''
32
+
33
+ # --- FFmpeg availability ---
34
+ from shutil import which
35
+ FFMPEG = which("ffmpeg")
36
+ FFPROBE = which("ffprobe")
37
+ MISSING_MSG = "" if (FFMPEG and FFPROBE) else (
38
+ "⚠️ FFmpeg/FFprobe not found. Add `ffmpeg` to packages.txt."
39
+ )
40
 
41
+ # --- Extraction Function ---
42
  def step1_extract(
43
  video: gr.File | None,
44
  mode: str,
 
55
  scene_thresh: float,
56
  prefix_in: str,
57
  prog_html: str,
58
+ preview_all: bool,
59
  ):
60
  if not video or not video.name:
61
+ return None, None, "Upload a video.", "", prog_html
62
+ if not FFMPEG:
63
+ return None, None, "FFmpeg missing. See note below.", prog_html
 
 
64
 
65
  work = Path(tempfile.mkdtemp(prefix="vid2img_"))
66
  raw_dir = work / "frames_raw"
67
  raw_dir.mkdir(parents=True, exist_ok=True)
68
 
69
+ # ✅ use video filename as prefix if none given
70
+ prefix = prefix_in.strip() or Path(video.name).stem
 
 
 
71
 
72
+ # Simplified ffmpeg command (replace with your build_ffmpeg_extract if you want full modes)
73
+ out_pattern = str(raw_dir / f"{prefix}_%05d.{out_format}")
74
+ cmd = [
75
+ FFMPEG, "-y", "-i", video.name,
76
+ out_pattern
77
+ ]
78
+ proc = subprocess.Popen(cmd, stderr=subprocess.PIPE, stdout=subprocess.DEVNULL, text=True, bufsize=1)
79
 
80
+ created = 0
81
+ last_html = prog_html
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
82
  while True:
83
  line = proc.stderr.readline()
84
  if not line and proc.poll() is not None:
85
  break
86
+ created = len(list(raw_dir.glob(f"{prefix}_*.{out_format}")))
87
+ last_html = render_progress(0.0, f"Extracting… {created} created")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
88
  ret = proc.wait()
89
 
90
  frames = sorted(raw_dir.glob(f"{prefix}_*.{out_format}"), key=_natural_key)
91
 
92
+ # toggle for preview
93
+ if preview_all:
94
  gallery = [str(p) for p in frames]
95
  else:
96
+ gallery = [str(p) for p in frames] if len(frames) <= 100 else sample_paths(frames, 100)
97
 
98
+ # zip uses same prefix
99
+ zip_path = work / f"{prefix}_frames.zip"
100
  with zipfile.ZipFile(zip_path, "w", zipfile.ZIP_DEFLATED) as zf:
101
  for p in frames:
102
  zf.write(p, p.name)
103
 
104
  if ret != 0 or not frames:
105
+ return gallery, None, "Extraction failed.", last_html
 
 
 
 
 
 
106
 
107
  details = f"Frames extracted: {len(frames)} | Saved to: {raw_dir}"
108
+ return gallery, str(zip_path), details, render_progress(100.0, f"Extracted {len(frames)} frames")
 
 
109
 
110
+ # --- UI ---
111
  def build_ui():
112
  with gr.Blocks(theme=gr.themes.Soft()) as demo:
113
+ gr.Markdown("### Bifröst · Video → Frames Extractor")
 
 
 
 
 
 
 
 
 
114
 
115
+ video = gr.File(label="Upload video", file_types=[".mp4",".mov",".mkv",".avi",".webm"], type="filepath")
116
+ preview_all = gr.Checkbox(value=False, label="Preview all frames (instead of sample)")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
117
 
 
118
  btn_extract = gr.Button("Extract Frames", variant="primary")
119
  prog = gr.HTML(render_progress(0.0, "Idle"))
120
+ gallery = gr.Gallery(label="Preview frames", columns=6, height=480)
121
  zip_out = gr.File(label="Download frames ZIP")
122
  details = gr.Markdown("Ready.")
 
 
 
123
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
124
  btn_extract.click(
125
  step1_extract,
126
+ inputs=[video,
127
+ gr.Textbox(value="Every N seconds", visible=False), # placeholder for mode
128
+ gr.Number(value=1.0, visible=False), # every_seconds
129
+ gr.Number(value=30, visible=False), # nth_frame
130
+ gr.Number(value=1.0, visible=False), # exact_fps
131
+ gr.Textbox(value="", visible=False), # start_time
132
+ gr.Textbox(value="", visible=False), # end_time
133
+ gr.Number(value=0, visible=False), # long_side
134
+ gr.Dropdown(["jpg","png"], value="jpg", visible=False),
135
+ gr.Slider(2,31,value=3,step=1,visible=False),
136
+ gr.Slider(0,9,value=2,step=1,visible=False),
137
+ gr.Checkbox(False, visible=False),
138
+ gr.Slider(0.0,1.0,value=0.3,step=0.01,visible=False),
139
+ gr.Textbox(value="", visible=False), # prefix
140
+ prog,
141
+ preview_all],
142
+ outputs=[gallery, zip_out, details, prog]
143
  )
144
 
145
  if MISSING_MSG:
146
  gr.Markdown(f"<span style='color:#b45309'>{MISSING_MSG}</span>")
147
 
148
  return demo
149
+
150
  if __name__ == "__main__":
151
+ build_ui().queue().launch()