devkunalnaik commited on
Commit
a5190e9
Β·
1 Parent(s): 937ca9c

Feature: resume from frame, partial output on interruption, audio sync on resume

Browse files
Files changed (2) hide show
  1. app.py +14 -4
  2. 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
- if total_frames > MAX_FRAMES:
 
 
 
 
62
  cap.release()
63
  return None, (
64
- f"Video has {total_frames} frames β€” maximum allowed is "
65
- f"{MAX_FRAMES} (~{MAX_FRAMES / fps:.0f} s at {fps:.0f} fps). "
66
- "Please trim the video and try again."
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 = 0
83
  processed = 0
84
  errors = 0
85
- cached_tgt_faces = None # reused across DET_INTERVAL frames
86
- last_result = None # for fast_mode frame duplication
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
- # Fast mode: skip odd frames β€” duplicate the previous processed result
100
- if fast_mode and frame_idx % 2 == 1 and last_result is not None:
101
- writer.write(last_result)
102
- frame_idx += 1
103
- continue
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
104
 
105
- # Only re-detect target faces every DET_INTERVAL frames
106
- use_cache = (mode == "face") and (frame_idx % DET_INTERVAL != 0) and (cached_tgt_faces is not None)
107
 
108
- result_frame, new_faces = self._process_frame(
109
- source_bgr, frame, mode, enhance, blend_strength,
110
- source_face=source_face,
111
- cached_target_faces=cached_tgt_faces if use_cache else None,
112
- )
 
 
 
113
 
114
- # Refresh cache after a detection frame
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
- if result_frame is not None:
119
- writer.write(result_frame)
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
- frame_idx += 1
 
 
128
 
129
- cap.release()
130
- writer.release()
 
 
 
 
 
131
 
132
  # Re-encode with H.264 and merge original audio via FFmpeg
133
- final_path = self._ffmpeg_encode(video_path, raw_out_path)
 
 
134
 
135
  try:
136
  os.unlink(raw_out_path)
137
  except OSError:
138
  pass
139
 
 
140
  status = (
141
- f"Done β€” {processed}/{frame_idx} frames processed"
142
- + (f" ({errors} skipped)" if errors else "")
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
- audio_stream = ffmpeg.input(original_video_path).audio
 
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(