BoxOfColors Claude Opus 4.7 (1M context) commited on
Commit
48bdb38
Β·
1 Parent(s): ccd9487

feat: auto-trim long uploads and add audio re-encode fallback

Browse files

- extract_frames accepts max_duration_s; pipeline now silently trims
to first 15 s instead of rejecting clips up to 60 s
- on_video_upload shows a trim notice when the uploaded clip exceeds
the processing window
- attach_audio falls back to AAC re-encode when stream-copy fails
(fixes silent mux failure for sources with MP4-incompatible audio
codecs like raw PCM in .mov or Opus in .webm)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

Files changed (2) hide show
  1. app.py +27 -7
  2. pipeline/video.py +26 -7
app.py CHANGED
@@ -46,6 +46,11 @@ from pipeline.video import (
46
  attach_audio, extract_first_frame, extract_frames, frames_to_video, probe,
47
  )
48
 
 
 
 
 
 
49
  # ---------------------------------------------------------------------------
50
  # CSS β€” dark premium theme
51
  # ---------------------------------------------------------------------------
@@ -319,28 +324,34 @@ def on_video_upload(video_path: str | None):
319
  meta = probe(video_path)
320
 
321
  # ── Input validation β€” guard against disk exhaustion on ZeroGPU ──
322
- MAX_DURATION_S = 15.0
 
 
 
323
  MAX_PIXELS = 1920 * 1080
324
  # Max frames caps videos where ffprobe returns N/A for duration
325
  # (VFR/container-less formats). duration_s would be 0.0 after our guard,
326
  # so the duration check alone would pass an arbitrarily long clip.
327
- MAX_FRAMES = round(MAX_DURATION_S * max(meta.fps, 1.0))
328
- if meta.duration_s > MAX_DURATION_S:
329
  return (
330
  gr.update(), gr.update(), None,
331
- f"❌ Clip too long ({meta.duration_s:.1f}s). Max {MAX_DURATION_S:.0f} seconds.",
 
 
332
  )
333
  if meta.frame_count > MAX_FRAMES:
334
  return (
335
  gr.update(), gr.update(), None,
336
  f"❌ Clip too long ({meta.frame_count} frames at {meta.fps:.2f} fps). "
337
- f"Max {MAX_DURATION_S:.0f} seconds.",
338
  )
339
  if meta.width * meta.height > MAX_PIXELS:
340
  return (
341
  gr.update(), gr.update(), None,
342
  f"❌ Resolution too high ({meta.width}Γ—{meta.height}). Max 1920Γ—1080.",
343
  )
 
344
 
345
  # Extract first frame β€” mkstemp so the fd is closed before FFmpeg writes
346
  fd, tmp_path = tempfile.mkstemp(suffix=".png", prefix="wm_frame_")
@@ -366,11 +377,17 @@ def on_video_upload(video_path: str | None):
366
  "layers": [],
367
  "composite": None,
368
  }
 
 
 
 
 
369
  return (
370
  gr.update(value=editor_val),
371
  gr.update(value=None),
372
  _meta_to_dict(meta),
373
- f"βœ“ Loaded β€” {meta_str}\n\nNow draw over the watermark with the brush tool.",
 
374
  )
375
  except Exception as e:
376
  return gr.update(), gr.update(), None, f"❌ Error: {e}"
@@ -549,7 +566,10 @@ def run_pipeline(
549
 
550
  # ── Extract frames (CFR-forced for VFR safety) ─────────────────
551
  progress(0.10, desc="Extracting frames…")
552
- frame_paths = extract_frames(safe_video, ws.frames_dir, fps=meta.fps)
 
 
 
553
  total = len(frame_paths)
554
 
555
  # ── GPU: inpaint + composite + save ────────────────────────────
 
46
  attach_audio, extract_first_frame, extract_frames, frames_to_video, probe,
47
  )
48
 
49
+ # Working window the pipeline actually inpaints. Longer uploads are
50
+ # accepted (up to UPLOAD_DURATION_S in on_video_upload) and silently
51
+ # trimmed to this length during frame extraction.
52
+ PROCESS_DURATION_S = 15.0
53
+
54
  # ---------------------------------------------------------------------------
55
  # CSS β€” dark premium theme
56
  # ---------------------------------------------------------------------------
 
324
  meta = probe(video_path)
325
 
326
  # ── Input validation β€” guard against disk exhaustion on ZeroGPU ──
327
+ # UPLOAD_DURATION_S is the upper bound we accept; longer clips are
328
+ # silently auto-trimmed to PROCESS_DURATION_S during frame
329
+ # extraction (see run_pipeline β†’ extract_frames).
330
+ UPLOAD_DURATION_S = 60.0
331
  MAX_PIXELS = 1920 * 1080
332
  # Max frames caps videos where ffprobe returns N/A for duration
333
  # (VFR/container-less formats). duration_s would be 0.0 after our guard,
334
  # so the duration check alone would pass an arbitrarily long clip.
335
+ MAX_FRAMES = round(UPLOAD_DURATION_S * max(meta.fps, 1.0))
336
+ if meta.duration_s > UPLOAD_DURATION_S:
337
  return (
338
  gr.update(), gr.update(), None,
339
+ f"❌ Clip too long ({meta.duration_s:.1f}s). "
340
+ f"Max {UPLOAD_DURATION_S:.0f}s; only the first "
341
+ f"{PROCESS_DURATION_S:.0f}s would be processed anyway.",
342
  )
343
  if meta.frame_count > MAX_FRAMES:
344
  return (
345
  gr.update(), gr.update(), None,
346
  f"❌ Clip too long ({meta.frame_count} frames at {meta.fps:.2f} fps). "
347
+ f"Max {UPLOAD_DURATION_S:.0f} seconds.",
348
  )
349
  if meta.width * meta.height > MAX_PIXELS:
350
  return (
351
  gr.update(), gr.update(), None,
352
  f"❌ Resolution too high ({meta.width}Γ—{meta.height}). Max 1920Γ—1080.",
353
  )
354
+ will_trim = meta.duration_s > PROCESS_DURATION_S
355
 
356
  # Extract first frame β€” mkstemp so the fd is closed before FFmpeg writes
357
  fd, tmp_path = tempfile.mkstemp(suffix=".png", prefix="wm_frame_")
 
377
  "layers": [],
378
  "composite": None,
379
  }
380
+ trim_note = (
381
+ f"\n\n⚠️ Clip is {meta.duration_s:.1f}s β€” only the first "
382
+ f"{PROCESS_DURATION_S:.0f}s will be processed."
383
+ if will_trim else ""
384
+ )
385
  return (
386
  gr.update(value=editor_val),
387
  gr.update(value=None),
388
  _meta_to_dict(meta),
389
+ f"βœ“ Loaded β€” {meta_str}{trim_note}"
390
+ f"\n\nNow draw over the watermark with the brush tool.",
391
  )
392
  except Exception as e:
393
  return gr.update(), gr.update(), None, f"❌ Error: {e}"
 
566
 
567
  # ── Extract frames (CFR-forced for VFR safety) ─────────────────
568
  progress(0.10, desc="Extracting frames…")
569
+ frame_paths = extract_frames(
570
+ safe_video, ws.frames_dir, fps=meta.fps,
571
+ max_duration_s=PROCESS_DURATION_S,
572
+ )
573
  total = len(frame_paths)
574
 
575
  # ── GPU: inpaint + composite + save ────────────────────────────
pipeline/video.py CHANGED
@@ -158,6 +158,7 @@ def extract_frames(
158
  out_dir: str | Path,
159
  pattern: str = "%06d.png",
160
  fps: float | None = None,
 
161
  ) -> List[Path]:
162
  """
163
  Extract every frame from *video_path* as PNG images into *out_dir*.
@@ -177,6 +178,9 @@ def extract_frames(
177
  fps : float, optional
178
  If given, force constant-rate output at this rate. Pass
179
  ``VideoMeta.fps`` to guarantee A/V sync on reassembly.
 
 
 
180
 
181
  Returns
182
  -------
@@ -191,6 +195,8 @@ def extract_frames(
191
  "-y",
192
  "-i", str(video_path),
193
  ]
 
 
194
  if fps is not None:
195
  # Force constant frame rate; handles VFR sources without re-encoding
196
  # by inserting duplicate frames where the source skips.
@@ -364,18 +370,31 @@ def attach_audio(
364
  shutil.copy2(str(silent_video), str(out_path))
365
  return out_path
366
 
367
- cmd = [
368
  "ffmpeg",
369
  "-y",
370
  "-i", str(silent_video), # stream 0: video
371
  "-i", str(source_video), # stream 1: audio donor
372
- "-c", "copy",
373
- "-map", "0:v:0",
374
- "-map", "1:a:0",
375
- "-shortest", # trim to shorter stream (video)
376
- str(out_path),
377
  ]
378
- _run(cmd)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
379
  return out_path
380
 
381
 
 
158
  out_dir: str | Path,
159
  pattern: str = "%06d.png",
160
  fps: float | None = None,
161
+ max_duration_s: float | None = None,
162
  ) -> List[Path]:
163
  """
164
  Extract every frame from *video_path* as PNG images into *out_dir*.
 
178
  fps : float, optional
179
  If given, force constant-rate output at this rate. Pass
180
  ``VideoMeta.fps`` to guarantee A/V sync on reassembly.
181
+ max_duration_s : float, optional
182
+ If given, only extract the first *max_duration_s* seconds of video
183
+ (FFmpeg ``-t`` flag). Shorter sources pass through unchanged.
184
 
185
  Returns
186
  -------
 
195
  "-y",
196
  "-i", str(video_path),
197
  ]
198
+ if max_duration_s is not None:
199
+ cmd += ["-t", f"{max_duration_s:.6g}"]
200
  if fps is not None:
201
  # Force constant frame rate; handles VFR sources without re-encoding
202
  # by inserting duplicate frames where the source skips.
 
370
  shutil.copy2(str(silent_video), str(out_path))
371
  return out_path
372
 
373
+ base_cmd = [
374
  "ffmpeg",
375
  "-y",
376
  "-i", str(silent_video), # stream 0: video
377
  "-i", str(source_video), # stream 1: audio donor
 
 
 
 
 
378
  ]
379
+ map_flags = ["-map", "0:v:0", "-map", "1:a:0", "-shortest"]
380
+ out = [str(out_path)]
381
+
382
+ # First try stream-copy (no re-encode) β€” fast and lossless when the
383
+ # source audio codec is MP4-compatible (AAC, MP3, AC3, etc.).
384
+ try:
385
+ _run(base_cmd + ["-c", "copy"] + map_flags + out)
386
+ return out_path
387
+ except RuntimeError:
388
+ # Stream-copy failed β€” most likely the source audio codec is
389
+ # incompatible with MP4 (e.g. PCM in .mov, Opus in .webm).
390
+ # Fall back to re-encoding audio only; video remains stream-copied.
391
+ if out_path.exists():
392
+ try:
393
+ out_path.unlink()
394
+ except OSError:
395
+ pass
396
+
397
+ _run(base_cmd + ["-c:v", "copy", "-c:a", "aac", "-b:a", "192k"] + map_flags + out)
398
  return out_path
399
 
400