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>
- app.py +27 -7
- 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 |
-
|
|
|
|
|
|
|
|
|
|
| 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(
|
| 328 |
-
if meta.duration_s >
|
| 329 |
return (
|
| 330 |
gr.update(), gr.update(), None,
|
| 331 |
-
f"β Clip too long ({meta.duration_s:.1f}s).
|
|
|
|
|
|
|
| 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 {
|
| 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}
|
|
|
|
| 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(
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
|