Spaces:
Running
Running
Commit Β·
a5190e9
1
Parent(s): 937ca9c
Feature: resume from frame, partial output on interruption, audio sync on resume
Browse files- app.py +14 -4
- processors/video_processor.py +79 -50
app.py
CHANGED
|
@@ -66,7 +66,7 @@ def body_swap_image(source_img, target_img, blend_strength):
|
|
| 66 |
return None, f"Error: {e}"
|
| 67 |
|
| 68 |
|
| 69 |
-
def face_swap_video(source_img, target_video, enhance, fast_mode, progress=gr.Progress()):
|
| 70 |
if source_img is None or target_video is None:
|
| 71 |
return None, "Please upload a source face image and a target video."
|
| 72 |
|
|
@@ -78,12 +78,13 @@ def face_swap_video(source_img, target_video, enhance, fast_mode, progress=gr.Pr
|
|
| 78 |
mode="face",
|
| 79 |
enhance=enhance,
|
| 80 |
fast_mode=fast_mode,
|
|
|
|
| 81 |
progress=progress,
|
| 82 |
)
|
| 83 |
return output_path, msg
|
| 84 |
|
| 85 |
|
| 86 |
-
def body_swap_video(source_img, target_video, blend_strength, fast_mode, progress=gr.Progress()):
|
| 87 |
if source_img is None or target_video is None:
|
| 88 |
return None, "Please upload a source body image and a target video."
|
| 89 |
|
|
@@ -95,6 +96,7 @@ def body_swap_video(source_img, target_video, blend_strength, fast_mode, progres
|
|
| 95 |
mode="body",
|
| 96 |
blend_strength=blend_strength,
|
| 97 |
fast_mode=fast_mode,
|
|
|
|
| 98 |
progress=progress,
|
| 99 |
)
|
| 100 |
return output_path, msg
|
|
@@ -180,6 +182,10 @@ with gr.Blocks(title="Face & Body Swapper", theme=gr.themes.Soft()) as demo:
|
|
| 180 |
label="β‘ Fast Mode β skip every other frame (~2Γ speed, slight motion blur)",
|
| 181 |
value=False,
|
| 182 |
)
|
|
|
|
|
|
|
|
|
|
|
|
|
| 183 |
fv_btn = gr.Button("Swap Faces in Video", variant="primary")
|
| 184 |
with gr.Column(scale=1):
|
| 185 |
fv_output = gr.Video(label="Result Video")
|
|
@@ -187,7 +193,7 @@ with gr.Blocks(title="Face & Body Swapper", theme=gr.themes.Soft()) as demo:
|
|
| 187 |
|
| 188 |
fv_btn.click(
|
| 189 |
face_swap_video,
|
| 190 |
-
inputs=[fv_source, fv_target, fv_enhance, fv_fast],
|
| 191 |
outputs=[fv_output, fv_status],
|
| 192 |
)
|
| 193 |
|
|
@@ -209,6 +215,10 @@ with gr.Blocks(title="Face & Body Swapper", theme=gr.themes.Soft()) as demo:
|
|
| 209 |
label="β‘ Fast Mode β skip every other frame (~2Γ speed)",
|
| 210 |
value=False,
|
| 211 |
)
|
|
|
|
|
|
|
|
|
|
|
|
|
| 212 |
bv_btn = gr.Button("Swap Body in Video", variant="primary")
|
| 213 |
with gr.Column(scale=1):
|
| 214 |
bv_output = gr.Video(label="Result Video")
|
|
@@ -216,7 +226,7 @@ with gr.Blocks(title="Face & Body Swapper", theme=gr.themes.Soft()) as demo:
|
|
| 216 |
|
| 217 |
bv_btn.click(
|
| 218 |
body_swap_video,
|
| 219 |
-
inputs=[bv_source, bv_target, bv_blend, bv_fast],
|
| 220 |
outputs=[bv_output, bv_status],
|
| 221 |
)
|
| 222 |
|
|
|
|
| 66 |
return None, f"Error: {e}"
|
| 67 |
|
| 68 |
|
| 69 |
+
def face_swap_video(source_img, target_video, enhance, fast_mode, start_frame, progress=gr.Progress()):
|
| 70 |
if source_img is None or target_video is None:
|
| 71 |
return None, "Please upload a source face image and a target video."
|
| 72 |
|
|
|
|
| 78 |
mode="face",
|
| 79 |
enhance=enhance,
|
| 80 |
fast_mode=fast_mode,
|
| 81 |
+
start_frame=int(start_frame or 0),
|
| 82 |
progress=progress,
|
| 83 |
)
|
| 84 |
return output_path, msg
|
| 85 |
|
| 86 |
|
| 87 |
+
def body_swap_video(source_img, target_video, blend_strength, fast_mode, start_frame, progress=gr.Progress()):
|
| 88 |
if source_img is None or target_video is None:
|
| 89 |
return None, "Please upload a source body image and a target video."
|
| 90 |
|
|
|
|
| 96 |
mode="body",
|
| 97 |
blend_strength=blend_strength,
|
| 98 |
fast_mode=fast_mode,
|
| 99 |
+
start_frame=int(start_frame or 0),
|
| 100 |
progress=progress,
|
| 101 |
)
|
| 102 |
return output_path, msg
|
|
|
|
| 182 |
label="β‘ Fast Mode β skip every other frame (~2Γ speed, slight motion blur)",
|
| 183 |
value=False,
|
| 184 |
)
|
| 185 |
+
fv_resume = gr.Number(
|
| 186 |
+
label="Resume from frame (0 = start from beginning)",
|
| 187 |
+
value=0, minimum=0, step=1, precision=0,
|
| 188 |
+
)
|
| 189 |
fv_btn = gr.Button("Swap Faces in Video", variant="primary")
|
| 190 |
with gr.Column(scale=1):
|
| 191 |
fv_output = gr.Video(label="Result Video")
|
|
|
|
| 193 |
|
| 194 |
fv_btn.click(
|
| 195 |
face_swap_video,
|
| 196 |
+
inputs=[fv_source, fv_target, fv_enhance, fv_fast, fv_resume],
|
| 197 |
outputs=[fv_output, fv_status],
|
| 198 |
)
|
| 199 |
|
|
|
|
| 215 |
label="β‘ Fast Mode β skip every other frame (~2Γ speed)",
|
| 216 |
value=False,
|
| 217 |
)
|
| 218 |
+
bv_resume = gr.Number(
|
| 219 |
+
label="Resume from frame (0 = start from beginning)",
|
| 220 |
+
value=0, minimum=0, step=1, precision=0,
|
| 221 |
+
)
|
| 222 |
bv_btn = gr.Button("Swap Body in Video", variant="primary")
|
| 223 |
with gr.Column(scale=1):
|
| 224 |
bv_output = gr.Video(label="Result Video")
|
|
|
|
| 226 |
|
| 227 |
bv_btn.click(
|
| 228 |
body_swap_video,
|
| 229 |
+
inputs=[bv_source, bv_target, bv_blend, bv_fast, bv_resume],
|
| 230 |
outputs=[bv_output, bv_status],
|
| 231 |
)
|
| 232 |
|
processors/video_processor.py
CHANGED
|
@@ -41,10 +41,13 @@ class VideoProcessor:
|
|
| 41 |
enhance: bool = False,
|
| 42 |
blend_strength: float = 0.85,
|
| 43 |
fast_mode: bool = False, # skip every other frame (~2x speed)
|
|
|
|
| 44 |
progress=None,
|
| 45 |
) -> tuple[str | None, str]:
|
| 46 |
"""
|
| 47 |
Process every frame of *video_path*, applying the selected swap mode.
|
|
|
|
|
|
|
| 48 |
|
| 49 |
Returns:
|
| 50 |
(output_path, status_message)
|
|
@@ -58,12 +61,16 @@ class VideoProcessor:
|
|
| 58 |
height = int(cap.get(cv2.CAP_PROP_FRAME_HEIGHT))
|
| 59 |
total_frames = int(cap.get(cv2.CAP_PROP_FRAME_COUNT))
|
| 60 |
|
| 61 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 62 |
cap.release()
|
| 63 |
return None, (
|
| 64 |
-
f"
|
| 65 |
-
f"{MAX_FRAMES} (~{MAX_FRAMES / fps:.0f} s at {fps:.0f} fps). "
|
| 66 |
-
"
|
| 67 |
)
|
| 68 |
|
| 69 |
# ββ Pre-compute source face once (big win for face-swap mode) βββββββββ
|
|
@@ -74,73 +81,93 @@ class VideoProcessor:
|
|
| 74 |
cap.release()
|
| 75 |
return None, "No face detected in source image."
|
| 76 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 77 |
# Temp file for raw processed frames (mp4v codec)
|
| 78 |
raw_out_path = tempfile.mktemp(suffix="_raw.mp4")
|
| 79 |
fourcc = cv2.VideoWriter_fourcc(*"mp4v")
|
| 80 |
writer = cv2.VideoWriter(raw_out_path, fourcc, fps, (width, height))
|
| 81 |
|
| 82 |
-
frame_idx =
|
| 83 |
processed = 0
|
| 84 |
errors = 0
|
| 85 |
-
cached_tgt_faces = None
|
| 86 |
-
last_result = None
|
| 87 |
-
|
| 88 |
-
while True:
|
| 89 |
-
ret, frame = cap.read()
|
| 90 |
-
if not ret:
|
| 91 |
-
break
|
| 92 |
-
|
| 93 |
-
if progress is not None and total_frames > 0:
|
| 94 |
-
progress(
|
| 95 |
-
frame_idx / total_frames,
|
| 96 |
-
f"Processing frame {frame_idx + 1} / {total_frames}",
|
| 97 |
-
)
|
| 98 |
|
| 99 |
-
|
| 100 |
-
|
| 101 |
-
|
| 102 |
-
|
| 103 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 104 |
|
| 105 |
-
|
| 106 |
-
|
| 107 |
|
| 108 |
-
|
| 109 |
-
|
| 110 |
-
|
| 111 |
-
|
| 112 |
-
|
|
|
|
|
|
|
|
|
|
| 113 |
|
| 114 |
-
|
| 115 |
-
if mode == "face" and new_faces is not None:
|
| 116 |
-
cached_tgt_faces = new_faces if new_faces else cached_tgt_faces
|
| 117 |
|
| 118 |
-
|
| 119 |
-
|
| 120 |
-
last_result = result_frame
|
| 121 |
-
processed += 1
|
| 122 |
-
else:
|
| 123 |
-
writer.write(frame) # keep original on failure
|
| 124 |
-
last_result = frame
|
| 125 |
-
errors += 1
|
| 126 |
|
| 127 |
-
|
|
|
|
|
|
|
| 128 |
|
| 129 |
-
|
| 130 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 131 |
|
| 132 |
# Re-encode with H.264 and merge original audio via FFmpeg
|
| 133 |
-
|
|
|
|
|
|
|
| 134 |
|
| 135 |
try:
|
| 136 |
os.unlink(raw_out_path)
|
| 137 |
except OSError:
|
| 138 |
pass
|
| 139 |
|
|
|
|
| 140 |
status = (
|
| 141 |
-
f"
|
| 142 |
-
|
| 143 |
-
+ "."
|
| 144 |
)
|
| 145 |
return final_path, status
|
| 146 |
|
|
@@ -176,9 +203,10 @@ class VideoProcessor:
|
|
| 176 |
return None, None
|
| 177 |
|
| 178 |
@staticmethod
|
| 179 |
-
def _ffmpeg_encode(original_video_path: str, processed_raw_path: str) -> str:
|
| 180 |
"""
|
| 181 |
Re-encode processed frames as H.264 and copy the original audio track.
|
|
|
|
| 182 |
Falls back to the raw file if FFmpeg is unavailable.
|
| 183 |
"""
|
| 184 |
final_path = tempfile.mktemp(suffix="_output.mp4")
|
|
@@ -186,7 +214,8 @@ class VideoProcessor:
|
|
| 186 |
import ffmpeg
|
| 187 |
|
| 188 |
video_stream = ffmpeg.input(processed_raw_path).video
|
| 189 |
-
|
|
|
|
| 190 |
|
| 191 |
(
|
| 192 |
ffmpeg.output(
|
|
|
|
| 41 |
enhance: bool = False,
|
| 42 |
blend_strength: float = 0.85,
|
| 43 |
fast_mode: bool = False, # skip every other frame (~2x speed)
|
| 44 |
+
start_frame: int = 0, # resume from this frame index
|
| 45 |
progress=None,
|
| 46 |
) -> tuple[str | None, str]:
|
| 47 |
"""
|
| 48 |
Process every frame of *video_path*, applying the selected swap mode.
|
| 49 |
+
Set *start_frame* > 0 to resume after a dropped connection.
|
| 50 |
+
Partial output is always saved β even if processing is interrupted.
|
| 51 |
|
| 52 |
Returns:
|
| 53 |
(output_path, status_message)
|
|
|
|
| 61 |
height = int(cap.get(cv2.CAP_PROP_FRAME_HEIGHT))
|
| 62 |
total_frames = int(cap.get(cv2.CAP_PROP_FRAME_COUNT))
|
| 63 |
|
| 64 |
+
# Clamp start_frame
|
| 65 |
+
start_frame = max(0, min(start_frame, total_frames - 1))
|
| 66 |
+
remaining = total_frames - start_frame
|
| 67 |
+
|
| 68 |
+
if remaining > MAX_FRAMES:
|
| 69 |
cap.release()
|
| 70 |
return None, (
|
| 71 |
+
f"Segment starting at frame {start_frame} has {remaining} frames β "
|
| 72 |
+
f"maximum allowed is {MAX_FRAMES} (~{MAX_FRAMES / fps:.0f} s at {fps:.0f} fps). "
|
| 73 |
+
"Increase the start frame or trim the video."
|
| 74 |
)
|
| 75 |
|
| 76 |
# ββ Pre-compute source face once (big win for face-swap mode) βββββββββ
|
|
|
|
| 81 |
cap.release()
|
| 82 |
return None, "No face detected in source image."
|
| 83 |
|
| 84 |
+
# Seek to start_frame for resume support
|
| 85 |
+
if start_frame > 0:
|
| 86 |
+
cap.set(cv2.CAP_PROP_POS_FRAMES, start_frame)
|
| 87 |
+
|
| 88 |
# Temp file for raw processed frames (mp4v codec)
|
| 89 |
raw_out_path = tempfile.mktemp(suffix="_raw.mp4")
|
| 90 |
fourcc = cv2.VideoWriter_fourcc(*"mp4v")
|
| 91 |
writer = cv2.VideoWriter(raw_out_path, fourcc, fps, (width, height))
|
| 92 |
|
| 93 |
+
frame_idx = start_frame # absolute frame number in the source video
|
| 94 |
processed = 0
|
| 95 |
errors = 0
|
| 96 |
+
cached_tgt_faces = None
|
| 97 |
+
last_result = None
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 98 |
|
| 99 |
+
try:
|
| 100 |
+
while True:
|
| 101 |
+
ret, frame = cap.read()
|
| 102 |
+
if not ret:
|
| 103 |
+
break
|
| 104 |
+
|
| 105 |
+
if progress is not None and total_frames > 0:
|
| 106 |
+
progress(
|
| 107 |
+
(frame_idx - start_frame) / remaining,
|
| 108 |
+
f"Frame {frame_idx + 1} / {total_frames} "
|
| 109 |
+
f"(resume at {frame_idx} if interrupted)",
|
| 110 |
+
)
|
| 111 |
+
|
| 112 |
+
# Fast mode: skip odd frames β duplicate the previous processed result
|
| 113 |
+
if fast_mode and (frame_idx - start_frame) % 2 == 1 and last_result is not None:
|
| 114 |
+
writer.write(last_result)
|
| 115 |
+
frame_idx += 1
|
| 116 |
+
continue
|
| 117 |
+
|
| 118 |
+
# Only re-detect target faces every DET_INTERVAL frames
|
| 119 |
+
use_cache = (mode == "face") and (frame_idx % DET_INTERVAL != 0) and (cached_tgt_faces is not None)
|
| 120 |
+
|
| 121 |
+
result_frame, new_faces = self._process_frame(
|
| 122 |
+
source_bgr, frame, mode, enhance, blend_strength,
|
| 123 |
+
source_face=source_face,
|
| 124 |
+
cached_target_faces=cached_tgt_faces if use_cache else None,
|
| 125 |
+
)
|
| 126 |
|
| 127 |
+
if mode == "face" and new_faces is not None:
|
| 128 |
+
cached_tgt_faces = new_faces if new_faces else cached_tgt_faces
|
| 129 |
|
| 130 |
+
if result_frame is not None:
|
| 131 |
+
writer.write(result_frame)
|
| 132 |
+
last_result = result_frame
|
| 133 |
+
processed += 1
|
| 134 |
+
else:
|
| 135 |
+
writer.write(frame)
|
| 136 |
+
last_result = frame
|
| 137 |
+
errors += 1
|
| 138 |
|
| 139 |
+
frame_idx += 1
|
|
|
|
|
|
|
| 140 |
|
| 141 |
+
except Exception as loop_err:
|
| 142 |
+
print(f"[VideoProcessor] Loop interrupted at frame {frame_idx}: {loop_err}")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 143 |
|
| 144 |
+
finally:
|
| 145 |
+
cap.release()
|
| 146 |
+
writer.release()
|
| 147 |
|
| 148 |
+
frames_done = frame_idx - start_frame
|
| 149 |
+
if frames_done == 0:
|
| 150 |
+
try:
|
| 151 |
+
os.unlink(raw_out_path)
|
| 152 |
+
except OSError:
|
| 153 |
+
pass
|
| 154 |
+
return None, f"No frames processed. Try resuming from frame {start_frame}."
|
| 155 |
|
| 156 |
# Re-encode with H.264 and merge original audio via FFmpeg
|
| 157 |
+
# Pass start_time so audio lines up with the resumed segment
|
| 158 |
+
start_time = start_frame / fps
|
| 159 |
+
final_path = self._ffmpeg_encode(video_path, raw_out_path, audio_start=start_time)
|
| 160 |
|
| 161 |
try:
|
| 162 |
os.unlink(raw_out_path)
|
| 163 |
except OSError:
|
| 164 |
pass
|
| 165 |
|
| 166 |
+
partial = frames_done < remaining
|
| 167 |
status = (
|
| 168 |
+
f"{'Partial β ' if partial else ''}Frames {start_frame}β{frame_idx - 1} "
|
| 169 |
+
f"({processed} swapped{', ' + str(errors) + ' skipped' if errors else ''}). "
|
| 170 |
+
+ (f"Resume from frame {frame_idx} to continue." if partial else "Done.")
|
| 171 |
)
|
| 172 |
return final_path, status
|
| 173 |
|
|
|
|
| 203 |
return None, None
|
| 204 |
|
| 205 |
@staticmethod
|
| 206 |
+
def _ffmpeg_encode(original_video_path: str, processed_raw_path: str, audio_start: float = 0.0) -> str:
|
| 207 |
"""
|
| 208 |
Re-encode processed frames as H.264 and copy the original audio track.
|
| 209 |
+
audio_start: seconds offset into the original audio (for resumed segments).
|
| 210 |
Falls back to the raw file if FFmpeg is unavailable.
|
| 211 |
"""
|
| 212 |
final_path = tempfile.mktemp(suffix="_output.mp4")
|
|
|
|
| 214 |
import ffmpeg
|
| 215 |
|
| 216 |
video_stream = ffmpeg.input(processed_raw_path).video
|
| 217 |
+
# Seek audio to match the resumed start position
|
| 218 |
+
audio_stream = ffmpeg.input(original_video_path, ss=audio_start).audio
|
| 219 |
|
| 220 |
(
|
| 221 |
ffmpeg.output(
|