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

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +318 -62
app.py CHANGED
@@ -1,44 +1,202 @@
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,91 +213,189 @@ def step1_extract(
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:
 
1
  # Bifröst · Video → Frames Extractor
2
+
3
+ # ────────────────────────────────────────────────────────
4
+ # Standard imports
5
+ # ────────────────────────────────────────────────────────
6
+ import os, re, json, math, time, zipfile, tempfile, subprocess, base64
7
  from pathlib import Path
8
  from typing import List, Optional
9
  import gradio as gr
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
+ # ────────────────────────────────────────────────────────
35
+ # Logo
36
+ # ────────────────────────────────────────────────────────
37
+ APP_DIR = os.getcwd()
38
+ def load_logo_base64(path: str) -> str:
39
+ with open(path, "rb") as f:
40
+ return base64.b64encode(f.read()).decode("utf-8")
41
+
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 → Frames Extractor</div>
50
+ <div style="opacity:0.8;"Bifröst → Mjölnir → Valknut</div>
51
+ <div style="opacity:0.8;">Video → Frames → Upscale → Re-encode</div>
52
+ </div>
53
+ </div>
54
+ <hr>
55
+ """
56
+
57
+ # ────────────────────────────────────────────────────────
58
+ # System checks
59
+ # ────────────────────────────────────────────────────────
60
+ def _which(name: str) -> Optional[str]:
61
+ from shutil import which
62
+ return which(name)
63
+
64
+ FFMPEG = _which("ffmpeg")
65
+ FFPROBE = _which("ffprobe")
66
+
67
+ if not FFMPEG or not FFPROBE:
68
+ MISSING_MSG = (
69
+ "⚠️ FFmpeg not found. Add a 'packages.txt' with exactly:\n"
70
+ "ffmpeg\n"
71
+ "libsm6\n"
72
+ "libxext6\n"
73
+ "Then restart the Space."
74
+ )
75
+ else:
76
+ MISSING_MSG = ""
77
+
78
+ # ────────────────────────────────────────────────────────
79
+ # Helpers
80
+ # ────────────────────────────────────────────────────────
81
+ def sanitize_prefix(txt: str) -> str:
82
+ txt = (txt or "").strip()
83
+ if not txt:
84
+ return ""
85
+ return re.sub(r"[^A-Za-z0-9._-]+", "_", txt)[:80]
86
+
87
+ def ffprobe_json(input_path: str) -> dict:
88
+ if not FFPROBE:
89
+ return {}
90
+ cmd = [FFPROBE, "-v", "error", "-print_format", "json", "-show_streams", "-show_format", input_path]
91
+ res = subprocess.run(cmd, capture_output=True, text=True)
92
+ if res.returncode != 0:
93
+ return {}
94
+ try:
95
+ return json.loads(res.stdout)
96
+ except Exception:
97
+ return {}
98
+
99
+ def parse_video_info(meta: dict) -> dict:
100
+ info = {"duration": None, "fps": None, "width": None, "height": None}
101
+ if not meta:
102
+ return info
103
+ try:
104
+ info["duration"] = float(meta.get("format", {}).get("duration", None))
105
+ except Exception:
106
+ pass
107
+ vstreams = [s for s in meta.get("streams", []) if s.get("codec_type") == "video"]
108
+ if vstreams:
109
+ v = vstreams[0]
110
+ rfr = v.get("r_frame_rate") or v.get("avg_frame_rate")
111
+ if rfr and "/" in rfr:
112
+ try:
113
+ num, den = rfr.split("/")
114
+ num = float(num); den = float(den)
115
+ if den != 0:
116
+ info["fps"] = num / den
117
+ except Exception:
118
+ pass
119
+ info["width"] = v.get("width")
120
+ info["height"] = v.get("height")
121
+ return info
122
+
123
+ def estimate_output_count(mode: str, duration: float | None, in_fps: float | None,
124
+ every_seconds: float, nth_frame: int, exact_fps: float) -> Optional[int]:
125
+ if not duration:
126
+ return None
127
+ in_fps = in_fps or 30.0
128
+ try:
129
+ if mode == "All frames":
130
+ return int(math.ceil(duration * in_fps))
131
+ if mode == "Every N seconds" and every_seconds > 0:
132
+ return int(math.ceil(duration / every_seconds))
133
+ if mode == "Every Nth frame" and nth_frame > 0:
134
+ return int(math.ceil((duration * in_fps) / nth_frame))
135
+ if mode == "Exact FPS" and exact_fps > 0:
136
+ return int(math.ceil(duration * exact_fps))
137
+ except Exception:
138
+ return None
139
+ return None
140
+
141
+ def build_ffmpeg_extract(
142
+ input_path: str,
143
+ mode: str,
144
+ every_seconds: float,
145
+ nth_frame: int,
146
+ exact_fps: float,
147
+ start_time: str,
148
+ end_time: str,
149
+ long_side: int,
150
+ out_format: str,
151
+ jpg_quality: int,
152
+ png_level: int,
153
+ scene_detect: bool,
154
+ scene_thresh: float,
155
+ out_pattern: str,
156
+ ) -> List[str]:
157
+ if not FFMPEG:
158
+ raise RuntimeError("FFmpeg not available")
159
+ cmd = [FFMPEG, "-y"]
160
+ if start_time:
161
+ cmd += ["-ss", start_time]
162
+ cmd += ["-i", input_path]
163
+ if end_time:
164
+ cmd += ["-to", end_time]
165
+ vf = []
166
+ if mode == "Every N seconds":
167
+ vf.append(f"fps={max(1e-6, 1.0/float(every_seconds or 1))}")
168
+ elif mode == "Every Nth frame":
169
+ vf.append(f"select='not(mod(n,{max(1, int(nth_frame or 1))}))'")
170
+ vf.append("setpts=N/FRAME_RATE/TB")
171
+ elif mode == "Exact FPS":
172
+ vf.append(f"fps={max(1e-6, float(exact_fps or 1))}")
173
+ elif mode == "All frames":
174
+ pass
175
+ else:
176
+ vf.append("fps=1")
177
+ if scene_detect:
178
+ vf.append(f"select='gt(scene,{float(scene_thresh)})',showinfo")
179
+ vf.append("setpts=N/FRAME_RATE/TB")
180
+ if long_side and long_side > 0:
181
+ vf.append("scale='if(gt(iw,ih),%d,-1)':'if(gt(iw,ih),-1,%d)':force_original_aspect_ratio=decrease" % (long_side, long_side))
182
+ if vf:
183
+ cmd += ["-vf", ",".join(vf)]
184
+ if out_format == "jpg":
185
+ cmd += ["-q:v", str(jpg_quality)]
186
+ elif out_format == "png":
187
+ cmd += ["-compression_level", str(png_level)]
188
+ cmd += ["-frame_pts", "1", out_pattern]
189
+ return cmd
190
+
191
  def render_progress(pct: float, label: str = "") -> str:
192
  pct = max(0.0, min(100.0, pct))
193
  return f'''<div style="width:100%;border:1px solid #ddd;border-radius:8px;overflow:hidden;height:18px;">
194
  <div style="height:100%;width:{pct:.1f}%;background:#3b82f6;"></div></div>
195
  <div style="font-size:12px;opacity:.8;margin-top:4px;">{label} {pct:.1f}%</div>'''
196
 
197
+ # ────────────────────────────────────────────────────────
198
+ # Extraction (Step 1)
199
+ # ────────────────────────────────────────────────────────
 
 
 
 
 
 
200
  def step1_extract(
201
  video: gr.File | None,
202
  mode: str,
 
213
  scene_thresh: float,
214
  prefix_in: str,
215
  prog_html: str,
216
+ preview_all: bool, # ✅ toggle
217
  ):
218
  if not video or not video.name:
219
+ yield None, None, "Upload a video.", "", prog_html, None, None, None
220
+ return
221
+ if not FFMPEG or not FFPROBE:
222
+ yield None, None, "FFmpeg missing. See note below.", MISSING_MSG, prog_html, None, None, None
223
+ return
224
 
225
  work = Path(tempfile.mkdtemp(prefix="vid2img_"))
226
  raw_dir = work / "frames_raw"
227
  raw_dir.mkdir(parents=True, exist_ok=True)
228
 
229
+ prefix = sanitize_prefix(prefix_in) or Path(video.name).stem
230
+
231
+ vinfo = parse_video_info(ffprobe_json(video.name))
232
+ full_duration = float(vinfo.get("duration") or 0.0)
233
+
234
+ def _parse_ts(ts: str) -> float:
235
+ if not ts:
236
+ return 0.0
237
+ h, m, s = ts.split(":") if ":" in ts else ("0", "0", ts)
238
+ return float(h) * 3600 + float(m) * 60 + float(s)
239
+
240
+ st_s = _parse_ts((start_time or "").strip())
241
+ et_s = _parse_ts((end_time or "").strip())
242
+ if full_duration and st_s > 0:
243
+ full_duration = max(0.0, full_duration - st_s)
244
+ if full_duration and et_s > 0 and et_s < (vinfo.get("duration") or 0):
245
+ full_duration = max(0.0, min(full_duration, et_s))
246
 
247
+ pattern = str(raw_dir / f"{prefix}_%05d.{out_format}")
248
+ cmd = build_ffmpeg_extract(
249
+ input_path=video.name,
250
+ mode=mode,
251
+ every_seconds=every_seconds,
252
+ nth_frame=nth_frame,
253
+ exact_fps=exact_fps,
254
+ start_time=(start_time or "").strip(),
255
+ end_time=(end_time or "").strip(),
256
+ long_side=long_side,
257
+ out_format=out_format,
258
+ jpg_quality=jpg_quality,
259
+ png_level=png_level,
260
+ scene_detect=scene_detect,
261
+ scene_thresh=scene_thresh,
262
+ out_pattern=pattern,
263
+ )
264
 
265
+ cmd = [cmd[0], "-progress", "pipe:2"] + cmd[1:]
266
+ cmd_preview = " ".join([s if " " not in s else f'"{s}"' for s in cmd])
267
+
268
+ proc = subprocess.Popen(
269
+ cmd, stderr=subprocess.PIPE, stdout=subprocess.DEVNULL, text=True, bufsize=1
270
+ )
271
+
272
+ last_pct = 0.0
273
+ gallery_preview = []
274
  while True:
275
  line = proc.stderr.readline()
276
  if not line and proc.poll() is not None:
277
  break
278
+ line = (line or "").strip()
279
+ if line.startswith("out_time=") and full_duration > 0:
280
+ t = line.split("=", 1)[1]
281
+ try:
282
+ h, m, s = t.split(":")
283
+ secs = float(h) * 3600 + float(m) * 60 + float(s)
284
+ except Exception:
285
+ secs = 0.0
286
+ pct = max(0.0, min(100.0, (secs / full_duration) * 100.0))
287
+ if pct - last_pct >= 1.0 or pct in (0.0, 100.0):
288
+ last_pct = pct
289
+ gallery_preview = sample_paths(sorted(raw_dir.glob(f"{prefix}_*.{out_format}"), key=_natural_key), 36)
290
+ yield gallery_preview, None, "Extracting…", cmd_preview, render_progress(pct, f"Extracting {pct:.0f}%"), None, str(raw_dir), prefix
291
 
292
+ ret = proc.wait()
293
  frames = sorted(raw_dir.glob(f"{prefix}_*.{out_format}"), key=_natural_key)
294
 
 
295
  if preview_all:
296
  gallery = [str(p) for p in frames]
297
  else:
298
  gallery = [str(p) for p in frames] if len(frames) <= 100 else sample_paths(frames, 100)
299
 
300
+ # ZIP name based on original video / custom prefix
301
  zip_path = work / f"{prefix}_frames.zip"
302
  with zipfile.ZipFile(zip_path, "w", zipfile.ZIP_DEFLATED) as zf:
303
  for p in frames:
304
  zf.write(p, p.name)
305
 
306
  if ret != 0 or not frames:
307
+ err = ""
308
+ try:
309
+ err = proc.stderr.read() if proc.stderr else ""
310
+ except Exception:
311
+ pass
312
+ yield gallery, None, f"Extraction failed.\n\n{err}", cmd_preview, render_progress(0.0, "Failed"), None, str(raw_dir), prefix
313
+ return
314
 
315
  details = f"Frames extracted: {len(frames)} | Saved to: {raw_dir}"
316
+ 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
317
 
318
+ # ────────────────────────────────────────────────────────
319
+ # UI
320
+ # ────────────────────────────────────────────────────────
321
  def build_ui():
322
  with gr.Blocks(theme=gr.themes.Soft()) as demo:
323
+ gr.HTML(render_logo_html(88))
324
+ gr.Markdown("Extract frames from a video with live progress.")
325
+
326
+ with gr.Row():
327
+ video = gr.File(label="Upload video", file_types=[".mp4", ".mov", ".mkv", ".avi", ".webm", ".m4v"], type="filepath")
328
 
329
+ preview_all = gr.Checkbox(value=False, label="Preview all frames (may be slow)")
330
+
331
+ with gr.Accordion("Extraction Settings", open=True):
332
+ with gr.Row():
333
+ mode = gr.Dropdown(["Every N seconds", "Every Nth frame", "Exact FPS", "All frames"], value="Every N seconds", label="Mode")
334
+ every_seconds = gr.Number(value=1.0, label="Every N seconds")
335
+ nth_frame = gr.Number(value=30, label="Every Nth frame")
336
+ exact_fps = gr.Number(value=1.0, label="Exact FPS")
337
+ with gr.Row():
338
+ start_time = gr.Textbox(value="", label="Start (HH:MM:SS.mmm)")
339
+ end_time = gr.Textbox(value="", label="End (HH:MM:SS.mmm)")
340
+ long_side = gr.Number(value=0, label="Resize long side px (0 = none)")
341
+ with gr.Row():
342
+ out_format = gr.Dropdown(["jpg", "png"], value="jpg", label="Output format")
343
+ jpg_quality = gr.Slider(2, 31, value=3, step=1, label="JPG quality (2=best)")
344
+ png_level = gr.Slider(0, 9, value=2, step=1, label="PNG compression level")
345
+ with gr.Row():
346
+ scene_detect = gr.Checkbox(False, label="Scene-change detect")
347
+ scene_thresh = gr.Slider(0.0, 1.0, value=0.3, step=0.01, label="Scene threshold")
348
+ prefix_vid = gr.Textbox(value="", label="Filename prefix (defaults to input video name)")
349
 
350
  btn_extract = gr.Button("Extract Frames", variant="primary")
351
  prog = gr.HTML(render_progress(0.0, "Idle"))
352
+ gallery = gr.Gallery(label="Preview (≤100 or all if toggled)", columns=6, height=480)
353
  zip_out = gr.File(label="Download frames ZIP")
354
  details = gr.Markdown("Ready.")
355
+ with gr.Accordion("Show FFmpeg command", open=False):
356
+ cmd_preview = gr.Textbox(label="ffmpeg command", lines=4)
357
+ estimate_md = gr.Markdown("Estimated output: —")
358
+
359
+ def _toggle_params(mode_val, fmt):
360
+ return (
361
+ gr.update(visible=(mode_val == "Every N seconds")),
362
+ gr.update(visible=(mode_val == "Every Nth frame")),
363
+ gr.update(visible=(mode_val == "Exact FPS")),
364
+ gr.update(visible=(fmt == "jpg")),
365
+ gr.update(visible=(fmt == "png")),
366
+ )
367
+
368
+ def update_estimate(vfile, mode_val, evs, nth, exfps, st, et):
369
+ if not vfile or not getattr(vfile, 'name', None):
370
+ return "Estimated output: —"
371
+ info = parse_video_info(ffprobe_json(vfile.name))
372
+ dur = info.get("duration")
373
+ def parse_ts(ts: str):
374
+ if not ts: return 0.0
375
+ parts = ts.split(":")
376
+ if len(parts) == 3:
377
+ try: return float(parts[0])*3600 + float(parts[1])*60 + float(parts[2])
378
+ except Exception: return 0.0
379
+ return 0.0
380
+ st_s = parse_ts(st or ""); et_s = parse_ts(et or "")
381
+ if dur:
382
+ if st_s: dur = max(0.0, dur - st_s)
383
+ if et_s and et_s < info.get("duration", 0) and et_s > 0:
384
+ dur = min(dur, et_s)
385
+ est = estimate_output_count(mode_val, dur, info.get("fps"), evs or 1.0, int(nth or 1), exfps or 1.0)
386
+ return f"Estimated output: **~{est} frames**" if est else "Estimated output: —"
387
+
388
+ mode.change(_toggle_params, [mode, out_format], [every_seconds, nth_frame, exact_fps, jpg_quality, png_level])
389
+ out_format.change(_toggle_params, [mode, out_format], [every_seconds, nth_frame, exact_fps, jpg_quality, png_level])
390
+ demo.load(_toggle_params, [mode, out_format], [every_seconds, nth_frame, exact_fps, jpg_quality, png_level])
391
+
392
+ for ctrl in [video, mode, every_seconds, nth_frame, exact_fps, start_time, end_time]:
393
+ ctrl.change(update_estimate, inputs=[video, mode, every_seconds, nth_frame, exact_fps, start_time, end_time], outputs=[estimate_md])
394
 
395
  btn_extract.click(
396
  step1_extract,
397
+ 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, preview_all],
398
+ outputs=[gallery, zip_out, details, cmd_preview, prog],
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
399
  )
400
 
401
  if MISSING_MSG: