naicoi commited on
Commit
64a2ea3
·
verified ·
1 Parent(s): 72cfabc

full-lipsync-youtube (#9)

Browse files

- lipsync - fullvideo - youtube (5e11afae8f27e0ca3f8530731610d7b91c2ff0f3)

Files changed (5) hide show
  1. app.py +7 -19
  2. audio_processing.py +96 -16
  3. face_processing.py +585 -577
  4. processing.py +299 -165
  5. video_processing.py +126 -13
app.py CHANGED
@@ -97,7 +97,7 @@ with gr.Blocks(css=css) as demo:
97
  with gr.Column():
98
  gr.HTML("""
99
  <div>
100
- <span style="font-size: 20px;">🎬 Final Output Video</span><br>
101
  </div>
102
  """)
103
  final_video = gr.Video(label="Final Output", height=512)
@@ -105,31 +105,19 @@ with gr.Blocks(css=css) as demo:
105
  gr.HTML("<h3>Processing Steps</h3>")
106
  with gr.Row():
107
  with gr.Column():
108
- gr.HTML('<p style="margin: 0; color: #666;">1. Original (Looped)</p>')
109
- video_looped_output = gr.Video(label="Original Video", height=256)
110
  with gr.Column():
111
- gr.HTML('<p style="margin: 0; color: #666;">2. Face Cropped</p>')
112
- face_cropped_output = gr.Video(label="Face Cropped", height=256)
113
- with gr.Column():
114
- gr.HTML('<p style="margin: 0; color: #666;">3. Lipsynced Face</p>')
115
- lipsynced_face_output = gr.Video(label="Lipsynced Face", height=256)
116
-
117
- with gr.Row():
118
- with gr.Column():
119
- gr.HTML(
120
- '<p style="margin: 0; color: #666;">4. Lipsynced Full (Blended)</p>'
121
- )
122
- lipsynced_full_output = gr.Video(label="Lipsynced Full", height=256)
123
 
124
  lipsync_only_btn.click(
125
  fn=lipsync_with_audio_target,
126
  inputs=[video_input, audio_input, session_state, crop_size_radio],
127
  outputs=[
128
  final_video,
129
- video_looped_output,
130
- face_cropped_output,
131
- lipsynced_face_output,
132
- lipsynced_full_output,
133
  ],
134
  )
135
 
 
97
  with gr.Column():
98
  gr.HTML("""
99
  <div>
100
+ <span style="font-size: 20px;">🎬 Final Output (YouTube)</span><br>
101
  </div>
102
  """)
103
  final_video = gr.Video(label="Final Output", height=512)
 
105
  gr.HTML("<h3>Processing Steps</h3>")
106
  with gr.Row():
107
  with gr.Column():
108
+ gr.HTML('<p style="margin: 0; color: #666;">1. Normalized Video</p>')
109
+ video_normalized_output = gr.Video(label="Normalized Video", height=256)
110
  with gr.Column():
111
+ gr.HTML('<p style="margin: 0; color: #666;">2. Lipsynced Video</p>')
112
+ lipsynced_video_output = gr.Video(label="Lipsynced Video", height=256)
 
 
 
 
 
 
 
 
 
 
113
 
114
  lipsync_only_btn.click(
115
  fn=lipsync_with_audio_target,
116
  inputs=[video_input, audio_input, session_state, crop_size_radio],
117
  outputs=[
118
  final_video,
119
+ video_normalized_output,
120
+ lipsynced_video_output,
 
 
121
  ],
122
  )
123
 
audio_processing.py CHANGED
@@ -28,20 +28,77 @@ def get_audio_duration(audio_path: str) -> float:
28
  return float(result.stdout.strip())
29
 
30
 
31
- def prepare_target_audio(audio_path: str, output_dir: str) -> tuple:
32
- """Prepare target audio for lipsync
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
33
 
34
  Args:
35
- audio_path: Path to target audio
36
  output_dir: Output directory
37
 
38
  Returns:
39
- (audio_16k_path, audio_upsampled_path)
40
  """
41
  audio_16k = os.path.join(output_dir, "audio_16k.wav")
42
- audio_upsampled = os.path.join(output_dir, "audio_upsampled.wav")
43
 
44
- ffmpeg1 = FFmpeg(
45
  inputs={audio_path: None},
46
  outputs={
47
  audio_16k: [
@@ -58,20 +115,43 @@ def prepare_target_audio(audio_path: str, output_dir: str) -> tuple:
58
  },
59
  )
60
  try:
61
- ffmpeg1.run()
62
  except FFRuntimeError as e:
63
  raise Exception(f"FFmpeg failed to convert to 16k: {e}")
64
 
65
- ffmpeg2 = FFmpeg(
66
- inputs={audio_16k: None},
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
67
  outputs={
68
- audio_upsampled: [
69
  "-ar",
70
- "48000",
71
  "-ac",
72
- "1",
73
  "-acodec",
74
- "pcm_s16le",
 
 
75
  "-loglevel",
76
  "error",
77
  "-y",
@@ -79,11 +159,11 @@ def prepare_target_audio(audio_path: str, output_dir: str) -> tuple:
79
  },
80
  )
81
  try:
82
- ffmpeg2.run()
83
  except FFRuntimeError as e:
84
- raise Exception(f"FFmpeg failed to upsample to 48k: {e}")
85
 
86
- return audio_16k, audio_upsampled
87
 
88
 
89
  def prepare_audio_for_youtube(audio_path: str, output_dir: str) -> str:
 
28
  return float(result.stdout.strip())
29
 
30
 
31
+ # def prepare_target_audio(audio_path: str, output_dir: str) -> tuple:
32
+ # """Prepare target audio for lipsync (DEPRECATED - use prepare_audio_for_lipsync instead)
33
+ #
34
+ # Args:
35
+ # audio_path: Path to target audio
36
+ # output_dir: Output directory
37
+ #
38
+ # Returns:
39
+ # (audio_16k_path, audio_upsampled_path)
40
+ # """
41
+ # audio_16k = os.path.join(output_dir, "audio_16k.wav")
42
+ # audio_upsampled = os.path.join(output_dir, "audio_upsampled.wav")
43
+ #
44
+ # ffmpeg1 = FFmpeg(
45
+ # inputs={audio_path: None},
46
+ # outputs={
47
+ # audio_16k: [
48
+ # "-ar",
49
+ # "16000",
50
+ # "-ac",
51
+ # "1",
52
+ # "-acodec",
53
+ # "pcm_s16le",
54
+ # "-loglevel",
55
+ # "error",
56
+ # "-y",
57
+ # ]
58
+ # },
59
+ # )
60
+ # try:
61
+ # ffmpeg1.run()
62
+ # except FFRuntimeError as e:
63
+ # raise Exception(f"FFmpeg failed to convert to 16k: {e}")
64
+ #
65
+ # ffmpeg2 = FFmpeg(
66
+ # inputs={audio_16k: None},
67
+ # outputs={
68
+ # audio_upsampled: [
69
+ # "-ar",
70
+ # "48000",
71
+ # "-ac",
72
+ # "1",
73
+ # "-acodec",
74
+ # "pcm_s16le",
75
+ # "-loglevel",
76
+ # "error",
77
+ # "-y",
78
+ # ]
79
+ # },
80
+ # )
81
+ # try:
82
+ # ffmpeg2.run()
83
+ # except FFRuntimeError as e:
84
+ # raise Exception(f"FFmpeg failed to upsample to 48k: {e}")
85
+ #
86
+ # return audio_16k, audio_upsampled
87
+
88
+
89
+ def prepare_audio_for_lipsync(audio_path: str, output_dir: str) -> str:
90
+ """Chuẩn bị audio 16kHz mono cho lipsync pipeline
91
 
92
  Args:
93
+ audio_path: Path audio gốc
94
  output_dir: Output directory
95
 
96
  Returns:
97
+ Path audio 16k WAV
98
  """
99
  audio_16k = os.path.join(output_dir, "audio_16k.wav")
 
100
 
101
+ ffmpeg = FFmpeg(
102
  inputs={audio_path: None},
103
  outputs={
104
  audio_16k: [
 
115
  },
116
  )
117
  try:
118
+ ffmpeg.run()
119
  except FFRuntimeError as e:
120
  raise Exception(f"FFmpeg failed to convert to 16k: {e}")
121
 
122
+ return audio_16k
123
+
124
+
125
+ def prepare_audio_for_youtube_aac(audio_path: str, output_dir: str) -> str:
126
+ """Chuẩn bị audio theo chuẩn YouTube (AAC)
127
+
128
+ Args:
129
+ audio_path: Path audio gốc
130
+ output_dir: Output directory
131
+
132
+ Returns:
133
+ Path audio YouTube (AAC)
134
+ """
135
+ from config import (
136
+ YOUTUBE_AUDIO_CODEC,
137
+ YOUTUBE_AUDIO_BITRATE,
138
+ YOUTUBE_AUDIO_SAMPLE_RATE,
139
+ )
140
+
141
+ output_path = os.path.join(output_dir, "audio_youtube.aac")
142
+
143
+ ffmpeg = FFmpeg(
144
+ inputs={audio_path: None},
145
  outputs={
146
+ output_path: [
147
  "-ar",
148
+ str(YOUTUBE_AUDIO_SAMPLE_RATE),
149
  "-ac",
150
+ "2",
151
  "-acodec",
152
+ YOUTUBE_AUDIO_CODEC,
153
+ "-b:a",
154
+ YOUTUBE_AUDIO_BITRATE,
155
  "-loglevel",
156
  "error",
157
  "-y",
 
159
  },
160
  )
161
  try:
162
+ ffmpeg.run()
163
  except FFRuntimeError as e:
164
+ raise Exception(f"FFmpeg failed to prepare audio for YouTube: {e}")
165
 
166
+ return output_path
167
 
168
 
169
  def prepare_audio_for_youtube(audio_path: str, output_dir: str) -> str:
face_processing.py CHANGED
@@ -1,577 +1,585 @@
1
- """Face detection and region extraction for lipsync optimization"""
2
-
3
- import os
4
- import math
5
- import logging
6
- from typing import List, Dict, Tuple, Optional
7
-
8
- import cv2
9
- import numpy as np
10
- import mediapipe as mp
11
- from ffmpy import FFmpeg, FFRuntimeError
12
-
13
- from video_processing import get_video_info
14
-
15
- logger = logging.getLogger(__name__)
16
-
17
-
18
- class FaceDetectionError(Exception):
19
- """Custom exception for face detection errors"""
20
-
21
- pass
22
-
23
-
24
- def sample_frames_from_video(
25
- video_path: str, output_dir: str, sample_count: int = 5
26
- ) -> List[Tuple[int, str]]:
27
- """Extract uniform sample frames from video using OpenCV CUDA (HuggingFace)
28
-
29
- Args:
30
- video_path: Path to video
31
- output_dir: Directory to save extracted frames
32
- sample_count: Number of frames to sample
33
-
34
- Returns:
35
- List of (frame_index, frame_path) tuples
36
- """
37
- video_info = get_video_info(video_path)
38
- fps = video_info["fps"]
39
- duration = video_info["duration"]
40
- total_frames = int(duration * fps)
41
-
42
- frames_dir = os.path.join(output_dir, "sampled_frames")
43
- os.makedirs(frames_dir, exist_ok=True)
44
-
45
- if total_frames <= sample_count:
46
- frame_indices = list(range(total_frames))
47
- else:
48
- frame_indices = [
49
- int(i * total_frames / sample_count) for i in range(sample_count)
50
- ]
51
-
52
- extracted_frames = []
53
- cap = cv2.VideoCapture(video_path)
54
-
55
- try:
56
- for idx, frame_idx in enumerate(frame_indices):
57
- cap.set(cv2.CAP_PROP_POS_FRAMES, frame_idx)
58
- ret, frame = cap.read()
59
-
60
- if not ret or frame is None:
61
- logger.warning(f"Failed to read frame {frame_idx}")
62
- continue
63
-
64
- frame_path = os.path.join(frames_dir, f"frame_{idx:04d}.jpg")
65
- cv2.imwrite(frame_path, frame, [cv2.IMWRITE_JPEG_QUALITY, 90])
66
- extracted_frames.append((frame_idx, frame_path))
67
- finally:
68
- cap.release()
69
-
70
- logger.info(f"Extracted {len(extracted_frames)} frames from {video_path}")
71
- return extracted_frames
72
-
73
-
74
- def detect_faces_in_frames(
75
- extracted_frames: List[Tuple[int, str]],
76
- min_confidence: float = 0.5,
77
- min_face_pixels: int = 100,
78
- ) -> List[Dict]:
79
- """Detect faces in all sampled frames using MediaPipe Face Detection API
80
-
81
- Args:
82
- extracted_frames: List of (frame_index, frame_path) tuples
83
- min_confidence: Minimum detection confidence (0-1)
84
- min_face_pixels: Minimum face size in pixels
85
-
86
- Returns:
87
- List of detections: [{"frame_idx", "confidence", "bbox": (x, y, w, h)}]
88
- """
89
- detections = []
90
-
91
- with mp.solutions.face_detection.FaceDetection(
92
- model_selection=0, min_detection_confidence=min_confidence
93
- ) as face_detection:
94
- for frame_idx, frame_path in extracted_frames:
95
- frame = cv2.imread(frame_path)
96
- if frame is None:
97
- logger.warning(f"Failed to read frame: {frame_path}")
98
- continue
99
-
100
- h, w = frame.shape[:2]
101
- frame_rgb = cv2.cvtColor(frame, cv2.COLOR_BGR2RGB)
102
-
103
- results = face_detection.process(frame_rgb)
104
-
105
- if results.detections:
106
- for detection in results.detections:
107
- bbox = detection.location_data.relative_bounding_box
108
-
109
- x = int(bbox.xmin * w)
110
- y = int(bbox.ymin * h)
111
- face_w = int(bbox.width * w)
112
- face_h = int(bbox.height * h)
113
-
114
- x = max(0, x)
115
- y = max(0, y)
116
- face_w = min(w - x, face_w)
117
- face_h = min(h - y, face_h)
118
-
119
- confidence = detection.score[0] if detection.score else 0.0
120
-
121
- if face_w >= min_face_pixels and face_h >= min_face_pixels:
122
- detections.append(
123
- {
124
- "frame_idx": frame_idx,
125
- "confidence": float(confidence),
126
- "bbox": (x, y, face_w, face_h),
127
- }
128
- )
129
-
130
- logger.info(f"Detected {len(detections)} faces in {len(extracted_frames)} frames")
131
- return detections
132
-
133
-
134
- def cluster_face_detections(
135
- detections: List[Dict], max_distance: int = 100
136
- ) -> List[List[Dict]]:
137
- """Group face detections belonging to the same person using clustering
138
-
139
- Args:
140
- detections: List of face detections
141
- max_distance: Maximum distance (pixels) to consider detections as same person
142
-
143
- Returns:
144
- List of clusters (each cluster is a list of detections)
145
- """
146
- if not detections:
147
- return []
148
-
149
- clusters = []
150
- visited = set()
151
-
152
- for i, det_i in enumerate(detections):
153
- if i in visited:
154
- continue
155
-
156
- x_i, y_i, w_i, h_i = det_i["bbox"]
157
- center_i = (x_i + w_i / 2, y_i + h_i / 2)
158
-
159
- cluster = [det_i]
160
- visited.add(i)
161
-
162
- for j, det_j in enumerate(detections):
163
- if j in visited:
164
- continue
165
-
166
- x_j, y_j, w_j, h_j = det_j["bbox"]
167
- center_j = (x_j + w_j / 2, y_j + h_j / 2)
168
-
169
- distance = math.sqrt(
170
- (center_i[0] - center_j[0]) ** 2 + (center_i[1] - center_j[1]) ** 2
171
- )
172
-
173
- if distance < max_distance:
174
- cluster.append(det_j)
175
- visited.add(j)
176
-
177
- clusters.append(cluster)
178
-
179
- logger.info(f"Clustered {len(detections)} detections into {len(clusters)} clusters")
180
- return clusters
181
-
182
-
183
- def select_best_cluster(clusters: List[List[Dict]]) -> Optional[List[Dict]]:
184
- """Select the best face cluster (highest frequency)
185
-
186
- Args:
187
- clusters: List of clusters
188
-
189
- Returns:
190
- Best cluster (most frequent) or None
191
- """
192
- if not clusters:
193
- return None
194
-
195
- scored_clusters = [(len(cluster), cluster) for cluster in clusters]
196
- scored_clusters.sort(key=lambda x: x[0], reverse=True)
197
-
198
- best_cluster = scored_clusters[0][1]
199
- logger.info(f"Selected best cluster with {len(best_cluster)} detections")
200
- return best_cluster
201
-
202
-
203
- def verify_face_stability(
204
- cluster: List[Dict], max_movement_percent: float = 0.3
205
- ) -> bool:
206
- """Verify face doesn't move too much between frames
207
-
208
- Args:
209
- cluster: Face detections for the same person
210
- max_movement_percent: Max movement as percentage of average face size
211
-
212
- Returns:
213
- True if face is stable, False otherwise
214
- """
215
- if len(cluster) < 2:
216
- return True
217
-
218
- centers = []
219
- sizes = []
220
-
221
- for det in cluster:
222
- x, y, w, h = det["bbox"]
223
- centers.append((x + w / 2, y + h / 2))
224
- sizes.append(w * h)
225
-
226
- avg_size = sum(sizes) / len(sizes)
227
- avg_face_dim = math.sqrt(avg_size)
228
- max_allowed_movement = avg_face_dim * max_movement_percent
229
-
230
- for i in range(len(centers) - 1):
231
- dx = abs(centers[i + 1][0] - centers[i][0])
232
- dy = abs(centers[i + 1][1] - centers[i][1])
233
- movement = math.sqrt(dx**2 + dy**2)
234
-
235
- if movement > max_allowed_movement:
236
- logger.warning(
237
- f"Face movement {movement:.1f}px > {max_allowed_movement:.1f}px"
238
- )
239
- return False
240
-
241
- return True
242
-
243
-
244
- def calculate_face_bbox_from_cluster(cluster: List[Dict]) -> Dict:
245
- """Calculate average face bounding box from cluster
246
-
247
- Args:
248
- cluster: Face detections for the same person
249
-
250
- Returns:
251
- Dict: {"x", "y", "width", "height"}
252
- """
253
- weighted_x = 0
254
- weighted_y = 0
255
- weighted_w = 0
256
- weighted_h = 0
257
- total_weight = 0
258
-
259
- for det in cluster:
260
- x, y, w, h = det["bbox"]
261
- weight = det["confidence"]
262
- weighted_x += x * weight
263
- weighted_y += y * weight
264
- weighted_w += w * weight
265
- weighted_h += h * weight
266
- total_weight += weight
267
-
268
- avg_bbox = {
269
- "x": int(weighted_x / total_weight),
270
- "y": int(weighted_y / total_weight),
271
- "width": int(weighted_w / total_weight),
272
- "height": int(weighted_h / total_weight),
273
- }
274
-
275
- return avg_bbox
276
-
277
-
278
- def calculate_safe_crop_size(
279
- face_bbox: Dict, video_width: int, video_height: int, crop_size: int = 512
280
- ) -> Dict:
281
- """Calculate safe crop region ensuring face is inside
282
-
283
- Args:
284
- face_bbox: Face bounding box {"x", "y", "width", "height"}
285
- video_width: Video width
286
- video_height: Video height
287
- crop_size: Size of crop region (default: 512)
288
-
289
- Returns:
290
- Dict: {"x", "y", "width", "height"}
291
- """
292
- crop_half = crop_size // 2
293
-
294
- face_center_x = face_bbox["x"] + face_bbox["width"] / 2
295
- face_center_y = face_bbox["y"] + face_bbox["height"] / 2
296
-
297
- crop_x = int(face_center_x - crop_half)
298
- crop_y = int(face_center_y - crop_half)
299
-
300
- crop_x = max(0, crop_x)
301
- crop_y = max(0, crop_y)
302
- crop_x = min(video_width - crop_size, crop_x)
303
- crop_y = min(video_height - crop_size, crop_y)
304
-
305
- face_right = face_bbox["x"] + face_bbox["width"]
306
- face_bottom = face_bbox["y"] + face_bbox["height"]
307
- crop_right = crop_x + crop_size
308
- crop_bottom = crop_y + crop_size
309
-
310
- if (
311
- face_bbox["x"] < crop_x
312
- or face_bbox["y"] < crop_y
313
- or face_right > crop_right
314
- or face_bottom > crop_bottom
315
- ):
316
- if face_bbox["x"] < crop_x:
317
- crop_x = face_bbox["x"]
318
- elif face_right > crop_right:
319
- crop_x = face_right - crop_size
320
-
321
- if face_bbox["y"] < crop_y:
322
- crop_y = face_bbox["y"]
323
- elif face_bottom > crop_bottom:
324
- crop_y = face_bottom - crop_size
325
-
326
- crop_x = max(0, crop_x)
327
- crop_y = max(0, crop_y)
328
- crop_x = min(video_width - crop_size, crop_x)
329
- crop_y = min(video_height - crop_size, crop_y)
330
-
331
- return {"x": crop_x, "y": crop_y, "width": crop_size, "height": crop_size}
332
-
333
-
334
- def detect_face_region(
335
- video_path: str,
336
- output_dir: str,
337
- crop_size: int = 512,
338
- sample_count: int = 20,
339
- min_confidence: float = 0.5,
340
- min_face_pixels: int = 100,
341
- max_face_movement_percent: float = 0.3,
342
- ) -> Dict:
343
- """Main function: Detect face and calculate safe crop
344
-
345
- Args:
346
- video_path: Path to video
347
- output_dir: Directory for temporary files
348
- crop_size: Size of crop region (default: 512)
349
- sample_count: Number of frames to sample (default: 20)
350
- min_confidence: Minimum detection confidence
351
- min_face_pixels: Minimum face size in pixels
352
- max_face_movement_percent: Max allowed face movement
353
-
354
- Returns:
355
- Dict: {"x", "y", "width", "height", "face_bbox"}
356
-
357
- Raises:
358
- FaceDetectionError: If face detection fails
359
- """
360
- try:
361
- logger.info(f"Starting face detection for: {video_path}")
362
- video_info = get_video_info(video_path)
363
- video_w, video_h = video_info["width"], video_info["height"]
364
- logger.info(
365
- f"Video: {video_w}x{video_h}, {video_info['fps']:.1f}fps, {video_info['duration']:.1f}s"
366
- )
367
-
368
- if video_w < crop_size or video_h < crop_size:
369
- raise FaceDetectionError(
370
- f"Video resolution {video_w}x{video_h} < {crop_size}x{crop_size}. "
371
- f"Please upload higher resolution video."
372
- )
373
-
374
- extracted_frames = sample_frames_from_video(
375
- video_path, output_dir, sample_count
376
- )
377
- logger.info(f"Sampled {len(extracted_frames)} frames for detection")
378
-
379
- detections = detect_faces_in_frames(
380
- extracted_frames, min_confidence, min_face_pixels
381
- )
382
-
383
- if not detections:
384
- raise FaceDetectionError(
385
- f"No face detected in {sample_count} sampled frames. "
386
- f"Please upload a video with a visible face."
387
- )
388
-
389
- logger.info(f"Found {len(detections)} face detections")
390
-
391
- frames_with_face = len(set(d["frame_idx"] for d in detections))
392
- face_coverage = frames_with_face / len(extracted_frames)
393
- logger.info(
394
- f"Face coverage: {frames_with_face}/{len(extracted_frames)} ({face_coverage * 100:.1f}%)"
395
- )
396
-
397
- if face_coverage < 0.5:
398
- raise FaceDetectionError(
399
- f"Face detected in only {frames_with_face}/{len(extracted_frames)} frames "
400
- f"({face_coverage * 100:.1f}%). "
401
- f"Please upload a video with a visible face."
402
- )
403
-
404
- clusters = cluster_face_detections(detections)
405
- logger.info(f"Grouped into {len(clusters)} face clusters")
406
-
407
- best_cluster = select_best_cluster(clusters)
408
-
409
- if best_cluster is None:
410
- raise FaceDetectionError(
411
- f"Failed to identify main face in video. "
412
- f"Please upload a video with a clear, visible face."
413
- )
414
-
415
- logger.info(f"Selected main face cluster with {len(best_cluster)} detections")
416
-
417
- if not verify_face_stability(best_cluster, max_face_movement_percent):
418
- raise FaceDetectionError(
419
- f"Face moves too much between frames. "
420
- f"Please upload a video with a stable face position."
421
- )
422
-
423
- logger.info("Face stability check passed")
424
-
425
- face_bbox = calculate_face_bbox_from_cluster(best_cluster)
426
- crop_bbox = calculate_safe_crop_size(face_bbox, video_w, video_h, crop_size)
427
-
428
- crop_bbox["face_bbox"] = face_bbox
429
-
430
- logger.info(
431
- f"Face detected at ({face_bbox['x']}, {face_bbox['y']}) "
432
- f"size {face_bbox['width']}x{face_bbox['height']}, "
433
- f"crop at ({crop_bbox['x']}, {crop_bbox['y']})"
434
- )
435
- logger.info("Face detection completed successfully")
436
-
437
- return crop_bbox
438
-
439
- except FaceDetectionError:
440
- raise
441
- except Exception as e:
442
- logger.error(f"Face detection failed: {e}")
443
- raise FaceDetectionError(f"Face detection failed: {str(e)}")
444
-
445
-
446
- def crop_video_to_size(
447
- video_path: str, crop_bbox: Dict, output_dir: str, crop_size: int = 512
448
- ) -> str:
449
- """Crop video to specified size using calculated bbox
450
-
451
- Args:
452
- video_path: Path to input video
453
- crop_bbox: Crop region {"x", "y", "width", "height"}
454
- output_dir: Directory to save output
455
- crop_size: Size of crop region (default: 512)
456
-
457
- Returns:
458
- Path to cropped video
459
- """
460
- output_path = os.path.join(output_dir, f"face_cropped_{crop_size}x{crop_size}.mp4")
461
-
462
- logger.info(
463
- f"Crop box: x={crop_bbox['x']}, y={crop_bbox['y']}, "
464
- f"width={crop_bbox['width']}, height={crop_bbox['height']}"
465
- )
466
-
467
- ffmpeg = FFmpeg(
468
- inputs={video_path: None},
469
- outputs={
470
- output_path: [
471
- "-vf",
472
- f"crop={crop_bbox['width']}:{crop_bbox['height']}:{crop_bbox['x']}:{crop_bbox['y']}",
473
- "-c:v",
474
- "libx264",
475
- "-preset",
476
- "slow",
477
- "-crf",
478
- "18",
479
- "-profile:v",
480
- "high",
481
- "-pix_fmt",
482
- "yuv420p",
483
- "-c:a",
484
- "copy",
485
- "-loglevel",
486
- "error",
487
- "-y",
488
- ]
489
- },
490
- )
491
- try:
492
- ffmpeg.run()
493
- except FFRuntimeError as e:
494
- logger.error(f"FFmpeg failed: {e}")
495
- raise
496
- logger.info(f"Cropped video to {crop_size}x{crop_size}: {output_path}")
497
- return output_path
498
-
499
-
500
- def blend_face_into_original(
501
- original_video: str,
502
- face_video: str,
503
- crop_bbox: Dict,
504
- output_dir: str,
505
- lipsynced_info: Dict | None = None,
506
- feather: int = 15,
507
- ) -> str:
508
- """Blend face video back into original video with edge feather only
509
-
510
- Args:
511
- original_video: Path to original video
512
- face_video: Path to lipsynced face video (cropped)
513
- crop_bbox: Crop region {"x", "y", "width", "height"}
514
- output_dir: Directory to save output
515
- lipsynced_info: Info of lipsynced video {"width", "height"} (optional)
516
- feather: Feather radius for smooth blending at edges
517
-
518
- Returns:
519
- Path to blended video
520
- """
521
- output_path = os.path.join(output_dir, "face_blended.mp4")
522
-
523
- overlay_x = crop_bbox["x"]
524
- overlay_y = crop_bbox["y"]
525
-
526
- if lipsynced_info:
527
- face_width = lipsynced_info["width"]
528
- face_height = lipsynced_info["height"]
529
- logger.info(
530
- f"Blending {face_width}x{face_height} at ({overlay_x}, {overlay_y}) "
531
- f"(crop_bbox: {crop_bbox})"
532
- )
533
- else:
534
- face_width = crop_bbox["width"]
535
- face_height = crop_bbox["height"]
536
- logger.info(f"Blending at ({overlay_x}, {overlay_y})")
537
-
538
- mask_w = face_width
539
- mask_h = face_height
540
-
541
- feather_radius = 50
542
-
543
- ffmpeg = FFmpeg(
544
- inputs={original_video: None, face_video: None},
545
- outputs={
546
- output_path: [
547
- "-filter_complex",
548
- f"[0:v][1:v]overlay={overlay_x}:{overlay_y}",
549
- "-c:v",
550
- "libx264",
551
- "-preset",
552
- "slow",
553
- "-crf",
554
- "18",
555
- "-profile:v",
556
- "high",
557
- "-pix_fmt",
558
- "yuv420p",
559
- "-threads",
560
- "0",
561
- "-movflags",
562
- "+faststart",
563
- "-c:a",
564
- "copy",
565
- "-loglevel",
566
- "error",
567
- "-y",
568
- ]
569
- },
570
- )
571
- try:
572
- ffmpeg.run()
573
- except FFRuntimeError as e:
574
- logger.error(f"FFmpeg failed: {e}")
575
- raise
576
- logger.info(f"Blended face into original: {output_path}")
577
- return output_path
 
 
 
 
 
 
 
 
 
1
+ """Face detection and region extraction for lipsync optimization (DEPRECATED - Pipeline handles this automatically)"""
2
+
3
+ # NOTE: All functions in this module are DEPRECATED.
4
+ # The lipsync pipeline (latentsync/pipelines/lipsync_pipeline.py) now handles:
5
+ # - Face detection
6
+ # - Affine transformation
7
+ # - Crop
8
+ # - Restore
9
+ # These functions are kept for reference but not used in the new workflow.
10
+
11
+ # import os
12
+ # import math
13
+ # import logging
14
+ # from typing import List, Dict, Tuple, Optional
15
+ #
16
+ # import cv2
17
+ # import numpy as np
18
+ # import mediapipe as mp
19
+ # from ffmpy import FFmpeg, FFRuntimeError
20
+ #
21
+ # from video_processing import get_video_info
22
+ #
23
+ # logger = logging.getLogger(__name__)
24
+ #
25
+ #
26
+ # class FaceDetectionError(Exception):
27
+ # """Custom exception for face detection errors"""
28
+ #
29
+ # pass
30
+ #
31
+ #
32
+ # def sample_frames_from_video(
33
+ # video_path: str, output_dir: str, sample_count: int = 5
34
+ # ) -> List[Tuple[int, str]]:
35
+ # """Extract uniform sample frames from video using OpenCV CUDA (HuggingFace)
36
+ #
37
+ # Args:
38
+ # video_path: Path to video
39
+ # output_dir: Directory to save extracted frames
40
+ # sample_count: Number of frames to sample
41
+ #
42
+ # Returns:
43
+ # List of (frame_index, frame_path) tuples
44
+ # """
45
+ # video_info = get_video_info(video_path)
46
+ # fps = video_info["fps"]
47
+ # duration = video_info["duration"]
48
+ # total_frames = int(duration * fps)
49
+ #
50
+ # frames_dir = os.path.join(output_dir, "sampled_frames")
51
+ # os.makedirs(frames_dir, exist_ok=True)
52
+ #
53
+ # if total_frames <= sample_count:
54
+ # frame_indices = list(range(total_frames))
55
+ # else:
56
+ # frame_indices = [
57
+ # int(i * total_frames / sample_count) for i in range(sample_count)
58
+ # ]
59
+ #
60
+ # extracted_frames = []
61
+ # cap = cv2.VideoCapture(video_path)
62
+ #
63
+ # try:
64
+ # for idx, frame_idx in enumerate(frame_indices):
65
+ # cap.set(cv2.CAP_PROP_POS_FRAMES, frame_idx)
66
+ # ret, frame = cap.read()
67
+ #
68
+ # if not ret or frame is None:
69
+ # logger.warning(f"Failed to read frame {frame_idx}")
70
+ # continue
71
+ #
72
+ # frame_path = os.path.join(frames_dir, f"frame_{idx:04d}.jpg")
73
+ # cv2.imwrite(frame_path, frame, [cv2.IMWRITE_JPEG_QUALITY, 90])
74
+ # extracted_frames.append((frame_idx, frame_path))
75
+ # finally:
76
+ # cap.release()
77
+ #
78
+ # logger.info(f"Extracted {len(extracted_frames)} frames from {video_path}")
79
+ # return extracted_frames
80
+ #
81
+ #
82
+ # def detect_faces_in_frames(
83
+ # extracted_frames: List[Tuple[int, str]],
84
+ # min_confidence: float = 0.5,
85
+ # min_face_pixels: int = 100,
86
+ # ) -> List[Dict]:
87
+ # """Detect faces in all sampled frames using MediaPipe Face Detection API
88
+ #
89
+ # Args:
90
+ # extracted_frames: List of (frame_index, frame_path) tuples
91
+ # min_confidence: Minimum detection confidence (0-1)
92
+ # min_face_pixels: Minimum face size in pixels
93
+ #
94
+ # Returns:
95
+ # List of detections: [{"frame_idx", "confidence", "bbox": (x, y, w, h)}]
96
+ # """
97
+ # detections = []
98
+ #
99
+ # with mp.solutions.face_detection.FaceDetection(
100
+ # model_selection=0, min_detection_confidence=min_confidence
101
+ # ) as face_detection:
102
+ # for frame_idx, frame_path in extracted_frames:
103
+ # frame = cv2.imread(frame_path)
104
+ # if frame is None:
105
+ # logger.warning(f"Failed to read frame: {frame_path}")
106
+ # continue
107
+ #
108
+ # h, w = frame.shape[:2]
109
+ # frame_rgb = cv2.cvtColor(frame, cv2.COLOR_BGR2RGB)
110
+ #
111
+ # results = face_detection.process(frame_rgb)
112
+ #
113
+ # if results.detections:
114
+ # for detection in results.detections:
115
+ # bbox = detection.location_data.relative_bounding_box
116
+ #
117
+ # x = int(bbox.xmin * w)
118
+ # y = int(bbox.ymin * h)
119
+ # face_w = int(bbox.width * w)
120
+ # face_h = int(bbox.height * h)
121
+ #
122
+ # x = max(0, x)
123
+ # y = max(0, y)
124
+ # face_w = min(w - x, face_w)
125
+ # face_h = min(h - y, face_h)
126
+ #
127
+ # confidence = detection.score[0] if detection.score else 0.0
128
+ #
129
+ # if face_w >= min_face_pixels and face_h >= min_face_pixels:
130
+ # detections.append(
131
+ # {
132
+ # "frame_idx": frame_idx,
133
+ # "confidence": float(confidence),
134
+ # "bbox": (x, y, face_w, face_h),
135
+ # }
136
+ # )
137
+ #
138
+ # logger.info(f"Detected {len(detections)} faces in {len(extracted_frames)} frames")
139
+ # return detections
140
+ #
141
+ #
142
+ # def cluster_face_detections(
143
+ # detections: List[Dict], max_distance: int = 100
144
+ # ) -> List[List[Dict]]:
145
+ # """Group face detections belonging to the same person using clustering
146
+ #
147
+ # Args:
148
+ # detections: List of face detections
149
+ # max_distance: Maximum distance (pixels) to consider detections as same person
150
+ #
151
+ # Returns:
152
+ # List of clusters (each cluster is a list of detections)
153
+ # """
154
+ # if not detections:
155
+ # return []
156
+ #
157
+ # clusters = []
158
+ # visited = set()
159
+ #
160
+ # for i, det_i in enumerate(detections):
161
+ # if i in visited:
162
+ # continue
163
+ #
164
+ # x_i, y_i, w_i, h_i = det_i["bbox"]
165
+ # center_i = (x_i + w_i / 2, y_i + h_i / 2)
166
+ #
167
+ # cluster = [det_i]
168
+ # visited.add(i)
169
+ #
170
+ # for j, det_j in enumerate(detections):
171
+ # if j in visited:
172
+ # continue
173
+ #
174
+ # x_j, y_j, w_j, h_j = det_j["bbox"]
175
+ # center_j = (x_j + w_j / 2, y_j + h_j / 2)
176
+ #
177
+ # distance = math.sqrt(
178
+ # (center_i[0] - center_j[0]) ** 2 + (center_i[1] - center_j[1]) ** 2
179
+ # )
180
+ #
181
+ # if distance < max_distance:
182
+ # cluster.append(det_j)
183
+ # visited.add(j)
184
+ #
185
+ # clusters.append(cluster)
186
+ #
187
+ # logger.info(f"Clustered {len(detections)} detections into {len(clusters)} clusters")
188
+ # return clusters
189
+ #
190
+ #
191
+ # def select_best_cluster(clusters: List[List[Dict]]) -> Optional[List[Dict]]:
192
+ # """Select the best face cluster (highest frequency)
193
+ #
194
+ # Args:
195
+ # clusters: List of clusters
196
+ #
197
+ # Returns:
198
+ # Best cluster (most frequent) or None
199
+ # """
200
+ # if not clusters:
201
+ # return None
202
+ #
203
+ # scored_clusters = [(len(cluster), cluster) for cluster in clusters]
204
+ # scored_clusters.sort(key=lambda x: x[0], reverse=True)
205
+ #
206
+ # best_cluster = scored_clusters[0][1]
207
+ # logger.info(f"Selected best cluster with {len(best_cluster)} detections")
208
+ # return best_cluster
209
+ #
210
+ #
211
+ # def verify_face_stability(
212
+ # cluster: List[Dict], max_movement_percent: float = 0.3
213
+ # ) -> bool:
214
+ # """Verify face doesn't move too much between frames
215
+ #
216
+ # Args:
217
+ # cluster: Face detections for the same person
218
+ # max_movement_percent: Max movement as percentage of average face size
219
+ #
220
+ # Returns:
221
+ # True if face is stable, False otherwise
222
+ # """
223
+ # if len(cluster) < 2:
224
+ # return True
225
+ #
226
+ # centers = []
227
+ # sizes = []
228
+ #
229
+ # for det in cluster:
230
+ # x, y, w, h = det["bbox"]
231
+ # centers.append((x + w / 2, y + h / 2))
232
+ # sizes.append(w * h)
233
+ #
234
+ # avg_size = sum(sizes) / len(sizes)
235
+ # avg_face_dim = math.sqrt(avg_size)
236
+ # max_allowed_movement = avg_face_dim * max_movement_percent
237
+ #
238
+ # for i in range(len(centers) - 1):
239
+ # dx = abs(centers[i + 1][0] - centers[i][0])
240
+ # dy = abs(centers[i + 1][1] - centers[i][1])
241
+ # movement = math.sqrt(dx**2 + dy**2)
242
+ #
243
+ # if movement > max_allowed_movement:
244
+ # logger.warning(
245
+ # f"Face movement {movement:.1f}px > {max_allowed_movement:.1f}px"
246
+ # )
247
+ # return False
248
+ #
249
+ # return True
250
+ #
251
+ #
252
+ # def calculate_face_bbox_from_cluster(cluster: List[Dict]) -> Dict:
253
+ # """Calculate average face bounding box from cluster
254
+ #
255
+ # Args:
256
+ # cluster: Face detections for the same person
257
+ #
258
+ # Returns:
259
+ # Dict: {"x", "y", "width", "height"}
260
+ # """
261
+ # weighted_x = 0
262
+ # weighted_y = 0
263
+ # weighted_w = 0
264
+ # weighted_h = 0
265
+ # total_weight = 0
266
+ #
267
+ # for det in cluster:
268
+ # x, y, w, h = det["bbox"]
269
+ # weight = det["confidence"]
270
+ # weighted_x += x * weight
271
+ # weighted_y += y * weight
272
+ # weighted_w += w * weight
273
+ # weighted_h += h * weight
274
+ # total_weight += weight
275
+ #
276
+ # avg_bbox = {
277
+ # "x": int(weighted_x / total_weight),
278
+ # "y": int(weighted_y / total_weight),
279
+ # "width": int(weighted_w / total_weight),
280
+ # "height": int(weighted_h / total_weight),
281
+ # }
282
+ #
283
+ # return avg_bbox
284
+ #
285
+ #
286
+ # def calculate_safe_crop_size(
287
+ # face_bbox: Dict, video_width: int, video_height: int, crop_size: int = 512
288
+ # ) -> Dict:
289
+ # """Calculate safe crop region ensuring face is inside
290
+ #
291
+ # Args:
292
+ # face_bbox: Face bounding box {"x", "y", "width", "height"}
293
+ # video_width: Video width
294
+ # video_height: Video height
295
+ # crop_size: Size of crop region (default: 512)
296
+ #
297
+ # Returns:
298
+ # Dict: {"x", "y", "width", "height"}
299
+ # """
300
+ # crop_half = crop_size // 2
301
+ #
302
+ # face_center_x = face_bbox["x"] + face_bbox["width"] / 2
303
+ # face_center_y = face_bbox["y"] + face_bbox["height"] / 2
304
+ #
305
+ # crop_x = int(face_center_x - crop_half)
306
+ # crop_y = int(face_center_y - crop_half)
307
+ #
308
+ # crop_x = max(0, crop_x)
309
+ # crop_y = max(0, crop_y)
310
+ # crop_x = min(video_width - crop_size, crop_x)
311
+ # crop_y = min(video_height - crop_size, crop_y)
312
+ #
313
+ # face_right = face_bbox["x"] + face_bbox["width"]
314
+ # face_bottom = face_bbox["y"] + face_bbox["height"]
315
+ # crop_right = crop_x + crop_size
316
+ # crop_bottom = crop_y + crop_size
317
+ #
318
+ # if (
319
+ # face_bbox["x"] < crop_x
320
+ # or face_bbox["y"] < crop_y
321
+ # or face_right > crop_right
322
+ # or face_bottom > crop_bottom
323
+ # ):
324
+ # if face_bbox["x"] < crop_x:
325
+ # crop_x = face_bbox["x"]
326
+ # elif face_right > crop_right:
327
+ # crop_x = face_right - crop_size
328
+ #
329
+ # if face_bbox["y"] < crop_y:
330
+ # crop_y = face_bbox["y"]
331
+ # elif face_bottom > crop_bottom:
332
+ # crop_y = face_bottom - crop_size
333
+ #
334
+ # crop_x = max(0, crop_x)
335
+ # crop_y = max(0, crop_y)
336
+ # crop_x = min(video_width - crop_size, crop_x)
337
+ # crop_y = min(video_height - crop_size, crop_y)
338
+ #
339
+ # return {"x": crop_x, "y": crop_y, "width": crop_size, "height": crop_size}
340
+ #
341
+ #
342
+ # def detect_face_region(
343
+ # video_path: str,
344
+ # output_dir: str,
345
+ # crop_size: int = 512,
346
+ # sample_count: int = 20,
347
+ # min_confidence: float = 0.5,
348
+ # min_face_pixels: int = 100,
349
+ # max_face_movement_percent: float = 0.3,
350
+ # ) -> Dict:
351
+ # """Main function: Detect face and calculate safe crop (DEPRECATED - Pipeline handles this)
352
+ #
353
+ # Args:
354
+ # video_path: Path to video
355
+ # output_dir: Directory for temporary files
356
+ # crop_size: Size of crop region (default: 512)
357
+ # sample_count: Number of frames to sample (default: 20)
358
+ # min_confidence: Minimum detection confidence
359
+ # min_face_pixels: Minimum face size in pixels
360
+ # max_face_movement_percent: Max allowed face movement
361
+ #
362
+ # Returns:
363
+ # Dict: {"x", "y", "width", "height", "face_bbox"}
364
+ #
365
+ # Raises:
366
+ # FaceDetectionError: If face detection fails
367
+ # """
368
+ # try:
369
+ # logger.info(f"Starting face detection for: {video_path}")
370
+ # video_info = get_video_info(video_path)
371
+ # video_w, video_h = video_info["width"], video_info["height"]
372
+ # logger.info(
373
+ # f"Video: {video_w}x{video_h}, {video_info['fps']:.1f}fps, {video_info['duration']:.1f}s"
374
+ # )
375
+ #
376
+ # if video_w < crop_size or video_h < crop_size:
377
+ # raise FaceDetectionError(
378
+ # f"Video resolution {video_w}x{video_h} < {crop_size}x{crop_size}. "
379
+ # f"Please upload higher resolution video."
380
+ # )
381
+ #
382
+ # extracted_frames = sample_frames_from_video(
383
+ # video_path, output_dir, sample_count
384
+ # )
385
+ # logger.info(f"Sampled {len(extracted_frames)} frames for detection")
386
+ #
387
+ # detections = detect_faces_in_frames(
388
+ # extracted_frames, min_confidence, min_face_pixels
389
+ # )
390
+ #
391
+ # if not detections:
392
+ # raise FaceDetectionError(
393
+ # f"No face detected in {sample_count} sampled frames. "
394
+ # f"Please upload a video with a visible face."
395
+ # )
396
+ #
397
+ # logger.info(f"Found {len(detections)} face detections")
398
+ #
399
+ # frames_with_face = len(set(d["frame_idx"] for d in detections))
400
+ # face_coverage = frames_with_face / len(extracted_frames)
401
+ # logger.info(
402
+ # f"Face coverage: {frames_with_face}/{len(extracted_frames)} ({face_coverage * 100:.1f}%)"
403
+ # )
404
+ #
405
+ # if face_coverage < 0.5:
406
+ # raise FaceDetectionError(
407
+ # f"Face detected in only {frames_with_face}/{len(extracted_frames)} frames "
408
+ # f"({face_coverage * 100:.1f}%). "
409
+ # f"Please upload a video with a visible face."
410
+ # )
411
+ #
412
+ # clusters = cluster_face_detections(detections)
413
+ # logger.info(f"Grouped into {len(clusters)} face clusters")
414
+ #
415
+ # best_cluster = select_best_cluster(clusters)
416
+ #
417
+ # if best_cluster is None:
418
+ # raise FaceDetectionError(
419
+ # f"Failed to identify main face in video. "
420
+ # f"Please upload a video with a clear, visible face."
421
+ # )
422
+ #
423
+ # logger.info(f"Selected main face cluster with {len(best_cluster)} detections")
424
+ #
425
+ # if not verify_face_stability(best_cluster, max_face_movement_percent):
426
+ # raise FaceDetectionError(
427
+ # f"Face moves too much between frames. "
428
+ # f"Please upload a video with a stable face position."
429
+ # )
430
+ #
431
+ # logger.info("Face stability check passed")
432
+ #
433
+ # face_bbox = calculate_face_bbox_from_cluster(best_cluster)
434
+ # crop_bbox = calculate_safe_crop_size(face_bbox, video_w, video_h, crop_size)
435
+ #
436
+ # crop_bbox["face_bbox"] = face_bbox
437
+ #
438
+ # logger.info(
439
+ # f"Face detected at ({face_bbox['x']}, {face_bbox['y']}) "
440
+ # f"size {face_bbox['width']}x{face_bbox['height']}, "
441
+ # f"crop at ({crop_bbox['x']}, {crop_bbox['y']})"
442
+ # )
443
+ # logger.info("Face detection completed successfully")
444
+ #
445
+ # return crop_bbox
446
+ #
447
+ # except FaceDetectionError:
448
+ # raise
449
+ # except Exception as e:
450
+ # logger.error(f"Face detection failed: {e}")
451
+ # raise FaceDetectionError(f"Face detection failed: {str(e)}")
452
+ #
453
+ #
454
+ # def crop_video_to_size(
455
+ # video_path: str, crop_bbox: Dict, output_dir: str, crop_size: int = 512
456
+ # ) -> str:
457
+ # """Crop video to specified size using calculated bbox (DEPRECATED - Pipeline handles this)
458
+ #
459
+ # Args:
460
+ # video_path: Path to input video
461
+ # crop_bbox: Crop region {"x", "y", "width", "height"}
462
+ # output_dir: Directory to save output
463
+ # crop_size: Size of crop region (default: 512)
464
+ #
465
+ # Returns:
466
+ # Path to cropped video
467
+ # """
468
+ # output_path = os.path.join(output_dir, f"face_cropped_{crop_size}x{crop_size}.mp4")
469
+ #
470
+ # logger.info(
471
+ # f"Crop box: x={crop_bbox['x']}, y={crop_bbox['y']}, "
472
+ # f"width={crop_bbox['width']}, height={crop_bbox['height']}"
473
+ # )
474
+ #
475
+ # ffmpeg = FFmpeg(
476
+ # inputs={video_path: None},
477
+ # outputs={
478
+ # output_path: [
479
+ # "-vf",
480
+ # f"crop={crop_bbox['width']}:{crop_bbox['height']}:{crop_bbox['x']}:{crop_bbox['y']}",
481
+ # "-c:v",
482
+ # "libx264",
483
+ # "-preset",
484
+ # "slow",
485
+ # "-crf",
486
+ # "18",
487
+ # "-profile:v",
488
+ # "high",
489
+ # "-pix_fmt",
490
+ # "yuv420p",
491
+ # "-c:a",
492
+ # "copy",
493
+ # "-loglevel",
494
+ # "error",
495
+ # "-y",
496
+ # ]
497
+ # },
498
+ # )
499
+ # try:
500
+ # ffmpeg.run()
501
+ # except FFRuntimeError as e:
502
+ # logger.error(f"FFmpeg failed: {e}")
503
+ # raise
504
+ # logger.info(f"Cropped video to {crop_size}x{crop_size}: {output_path}")
505
+ # return output_path
506
+ #
507
+ #
508
+ # def blend_face_into_original(
509
+ # original_video: str,
510
+ # face_video: str,
511
+ # crop_bbox: Dict,
512
+ # output_dir: str,
513
+ # lipsynced_info: Dict | None = None,
514
+ # feather: int = 15,
515
+ # ) -> str:
516
+ # """Blend face video back into original video with edge feather only (DEPRECATED - Pipeline handles this)
517
+ #
518
+ # Args:
519
+ # original_video: Path to original video
520
+ # face_video: Path to lipsynced face video (cropped)
521
+ # crop_bbox: Crop region {"x", "y", "width", "height"}
522
+ # output_dir: Directory to save output
523
+ # lipsynced_info: Info of lipsynced video {"width", "height"} (optional)
524
+ # feather: Feather radius for smooth blending at edges
525
+ #
526
+ # Returns:
527
+ # Path to blended video
528
+ # """
529
+ # output_path = os.path.join(output_dir, "face_blended.mp4")
530
+ #
531
+ # overlay_x = crop_bbox["x"]
532
+ # overlay_y = crop_bbox["y"]
533
+ #
534
+ # if lipsynced_info:
535
+ # face_width = lipsynced_info["width"]
536
+ # face_height = lipsynced_info["height"]
537
+ # logger.info(
538
+ # f"Blending {face_width}x{face_height} at ({overlay_x}, {overlay_y}) "
539
+ # f"(crop_bbox: {crop_bbox})"
540
+ # )
541
+ # else:
542
+ # face_width = crop_bbox["width"]
543
+ # face_height = crop_bbox["height"]
544
+ # logger.info(f"Blending at ({overlay_x}, {overlay_y})")
545
+ #
546
+ # mask_w = face_width
547
+ # mask_h = face_height
548
+ #
549
+ # feather_radius = 50
550
+ #
551
+ # ffmpeg = FFmpeg(
552
+ # inputs={original_video: None, face_video: None},
553
+ # outputs={
554
+ # output_path: [
555
+ # "-filter_complex",
556
+ # f"[0:v][1:v]overlay={overlay_x}:{overlay_y}",
557
+ # "-c:v",
558
+ # "libx264",
559
+ # "-preset",
560
+ # "slow",
561
+ # "-crf",
562
+ # "18",
563
+ # "-profile:v",
564
+ # "high",
565
+ # "-pix_fmt",
566
+ # "yuv420p",
567
+ # "-threads",
568
+ # "0",
569
+ # "-movflags",
570
+ # "+faststart",
571
+ # "-c:a",
572
+ # "copy",
573
+ # "-loglevel",
574
+ # "error",
575
+ # "-y",
576
+ # ]
577
+ # },
578
+ # )
579
+ # try:
580
+ # ffmpeg.run()
581
+ # except FFRuntimeError as e:
582
+ # logger.error(f"FFmpeg failed: {e}")
583
+ # raise
584
+ # logger.info(f"Blended face into original: {output_path}")
585
+ # return output_path
processing.py CHANGED
@@ -12,22 +12,16 @@ import torch
12
 
13
  from audio_processing import (
14
  get_audio_duration,
15
- prepare_target_audio,
 
16
  prepare_audio_for_youtube,
17
  )
18
  from config import PROCESSED_RESULTS_DIR
19
- from face_processing import (
20
- blend_face_into_original,
21
- calculate_safe_crop_size,
22
- crop_video_to_size,
23
- detect_face_region,
24
- FaceDetectionError,
25
- )
26
  from lipsync_processing import apply_lipsync_to_video, get_video_info
27
  from time_util import timer
28
  from utils import setup_output_dir
29
  from video_processing import (
30
- loop_video_to_match_audio,
31
  merge_audio_video,
32
  )
33
 
@@ -88,31 +82,270 @@ def validate_input(video_file, audio_file):
88
  return video_path, audio_path
89
 
90
 
91
- def process_lipsync_with_audio_target(
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
92
  video_file,
93
  audio_file,
94
  session_id=None,
95
  crop_size=256,
96
  progress=gr.Progress(track_tqdm=True),
97
  ):
98
- """Lipsync video source với audio target
 
 
 
 
 
 
 
 
99
 
100
  Args:
101
  video_file: Path to video source
102
  audio_file: Path to audio target (English only)
103
  session_id: Session identifier
104
- crop_size: Size of crop region (256 or 512)
105
  progress: Progress tracking object
106
 
107
  Returns:
108
- Tuple of (final_video, video_looped, face_cropped, lipsynced_face, lipsynced_full)
109
  """
110
- video_looped = None
111
- face_cropped = None
112
- lipsynced_face = None
113
- lipsynced_full = None
114
  final_video = None
115
- error_msg = None
116
 
117
  try:
118
  video_path, audio_path = validate_input(video_file, audio_file)
@@ -122,204 +355,105 @@ def process_lipsync_with_audio_target(
122
  logger.info(f"Memory at start: {get_memory_usage()}")
123
 
124
  audio_duration = get_audio_duration(audio_path)
 
125
 
126
- progress(0.1, desc="🎬 Đang chuẩn bị video...")
127
- logger.info(f"Memory before video loop: {get_memory_usage()}")
128
 
129
- with timer("Looping/cropping video to match audio"):
130
  try:
131
- video_looped = loop_video_to_match_audio(
132
  video_path, audio_duration, output_dir
133
  )
 
 
 
 
 
134
  except Exception as e:
135
- error_msg = f"Video loop failed: {str(e)}"
136
- logger.error(error_msg)
137
  traceback.print_exc()
138
- return (
139
- final_video,
140
- video_looped,
141
- face_cropped,
142
- lipsynced_face,
143
- lipsynced_full,
144
- )
145
 
146
  gc.collect()
147
- logger.info(f"Memory after video loop: {get_memory_usage()}")
148
 
149
- progress(0.2, desc="👤 Đang phát hiện khuôn mặt...")
150
- with timer("Detecting face"):
151
- try:
152
- face_bbox = detect_face_region(video_looped, output_dir, crop_size)
153
- except FaceDetectionError as e:
154
- error_msg = str(e)
155
- logger.error(f"Face detection failed: {e}")
156
- return (
157
- final_video,
158
- video_looped,
159
- face_cropped,
160
- lipsynced_face,
161
- lipsynced_full,
162
- )
163
-
164
- gc.collect()
165
- logger.info(f"Memory after face detection: {get_memory_usage()}")
166
-
167
- actual_crop_size = crop_size * 2
168
- progress(
169
- 0.25, desc=f"👤 Đang tính lại crop {actual_crop_size}x{actual_crop_size}..."
170
- )
171
- with timer(
172
- f"Recalculating crop bbox for {actual_crop_size}x{actual_crop_size}"
173
- ):
174
- from video_processing import get_video_info
175
 
 
176
  try:
177
- video_info = get_video_info(video_looped)
178
- crop_bbox = calculate_safe_crop_size(
179
- face_bbox["face_bbox"],
180
- video_info["width"],
181
- video_info["height"],
182
- actual_crop_size,
183
- )
184
  except Exception as e:
185
- error_msg = f"Calculate crop bbox failed: {str(e)}"
186
- logger.error(error_msg)
187
  traceback.print_exc()
188
- return (
189
- final_video,
190
- video_looped,
191
- face_cropped,
192
- lipsynced_face,
193
- lipsynced_full,
194
- )
195
-
196
- progress(
197
- 0.3, desc=f"✂️ Đang crop video {actual_crop_size}x{actual_crop_size}..."
198
- )
199
- with timer(f"Cropping video to {actual_crop_size}x{actual_crop_size}"):
200
- try:
201
- face_cropped = crop_video_to_size(
202
- video_looped, crop_bbox, output_dir, actual_crop_size
203
- )
204
- except Exception as e:
205
- error_msg = f"Crop video failed: {str(e)}"
206
- logger.error(error_msg)
207
- traceback.print_exc()
208
- return (
209
- final_video,
210
- video_looped,
211
- face_cropped,
212
- lipsynced_face,
213
- lipsynced_full,
214
- )
215
 
216
  gc.collect()
217
- logger.info(f"Memory after crop: {get_memory_usage()}")
218
 
219
- progress(0.4, desc="🎵 Đang xử audio...")
220
- logger.info(f"Memory before audio prep: {get_memory_usage()}")
221
 
222
- with timer("Preparing target audio"):
223
  try:
224
- audio_16k, audio_upsampled = prepare_target_audio(
225
- audio_path, output_dir
226
- )
227
  except Exception as e:
228
- error_msg = f"Prepare audio failed: {str(e)}"
229
- logger.error(error_msg)
230
  traceback.print_exc()
231
- return (
232
- final_video,
233
- video_looped,
234
- face_cropped,
235
- lipsynced_face,
236
- lipsynced_full,
237
- )
238
 
239
  gc.collect()
240
- logger.info(f"Memory after audio prep: {get_memory_usage()}")
241
 
242
- progress(0.6, desc="👄 Đang lipsync...")
243
 
244
- video_info = get_video_info(face_cropped)
245
  logger.info(
246
- f"Starting lipsync: video={face_cropped}, audio_16k={audio_16k}, output={output_dir}"
247
- )
248
- logger.info(
249
- f"Video info: {video_info['width']}x{video_info['height']}, {video_info['fps']:.1f}fps, {video_info['duration']:.1f}s"
250
  )
251
  logger.info(f"Memory before lipsync: {get_memory_usage()}")
252
 
253
  with timer("Applying lipsync"):
254
  try:
255
- lipsynced_face, lipsynced_info = apply_lipsync_to_video(
256
- face_cropped, audio_16k, output_dir, crop_size
257
  )
258
  logger.info(
259
- f"Lipsynced video size: {lipsynced_info['width']}x{lipsynced_info['height']}"
260
  )
261
  except Exception as e:
262
- error_msg = f"Lipsync failed: {str(e)}"
263
  logger.error(f"Lipsync failed with error: {type(e).__name__}: {e}")
264
  logger.error(f"Memory after crash: {get_memory_usage()}")
265
  traceback.print_exc()
266
- return (
267
- final_video,
268
- video_looped,
269
- face_cropped,
270
- lipsynced_face,
271
- lipsynced_full,
272
- )
273
 
274
  gc.collect()
275
  logger.info(f"Memory after lipsync: {get_memory_usage()}")
276
 
277
- progress(0.8, desc="🔀 Đang ghép video...")
278
- with timer("Blending face into original"):
 
 
279
  try:
280
- lipsynced_full = blend_face_into_original(
281
- video_looped, lipsynced_face, crop_bbox, output_dir, lipsynced_info
282
  )
 
283
  except Exception as e:
284
- error_msg = f"Blend video failed: {str(e)}"
285
- logger.error(error_msg)
286
  traceback.print_exc()
287
- return (
288
- final_video,
289
- video_looped,
290
- face_cropped,
291
- lipsynced_face,
292
- lipsynced_full,
293
- )
294
-
295
- gc.collect()
296
- logger.info(f"Memory after blend: {get_memory_usage()}")
297
-
298
- progress(0.9, desc="🔗 Đang ghép audio...")
299
- try:
300
- audio_final = prepare_audio_for_youtube(audio_upsampled, output_dir)
301
- final_video = merge_audio_video(lipsynced_full, audio_final, output_dir)
302
- except Exception as e:
303
- error_msg = f"Merge audio failed: {str(e)}"
304
- logger.error(error_msg)
305
- traceback.print_exc()
306
- return (
307
- final_video,
308
- video_looped,
309
- face_cropped,
310
- lipsynced_face,
311
- lipsynced_full,
312
- )
313
 
314
  progress(1.0, desc="✅ Hoàn tất!")
315
  logger.info(f"Memory at end: {get_memory_usage()}")
316
 
317
- return final_video, video_looped, face_cropped, lipsynced_face, lipsynced_full
318
 
319
  except Exception as e:
320
- print(f"ERROR in process_lipsync_with_audio_target: {e}")
321
  traceback.print_exc()
322
- return final_video, video_looped, face_cropped, lipsynced_face, lipsynced_full
323
 
324
 
325
  @spaces.GPU(duration=600)
@@ -333,12 +467,12 @@ def lipsync_with_audio_target(
333
  """Wrapper for Gradio: Lipsync video source with audio target (English only)
334
 
335
  Returns:
336
- Tuple of (final_video, video_looped, face_cropped, lipsynced_face, lipsynced_full)
337
  """
338
  if video_file is None:
339
  raise gr.Error("Please upload a video source.")
340
  if audio_file is None:
341
  raise gr.Error("Please upload a target audio.")
342
- return process_lipsync_with_audio_target(
343
  video_file, audio_file, session_id, crop_size, progress
344
  )
 
12
 
13
  from audio_processing import (
14
  get_audio_duration,
15
+ prepare_audio_for_lipsync,
16
+ prepare_audio_for_youtube_aac,
17
  prepare_audio_for_youtube,
18
  )
19
  from config import PROCESSED_RESULTS_DIR
 
 
 
 
 
 
 
20
  from lipsync_processing import apply_lipsync_to_video, get_video_info
21
  from time_util import timer
22
  from utils import setup_output_dir
23
  from video_processing import (
24
+ normalize_video_for_youtube,
25
  merge_audio_video,
26
  )
27
 
 
82
  return video_path, audio_path
83
 
84
 
85
+ # def process_lipsync_with_audio_target(
86
+ # video_file,
87
+ # audio_file,
88
+ # session_id=None,
89
+ # crop_size=256,
90
+ # progress=gr.Progress(track_tqdm=True),
91
+ # ):
92
+ # """Lipsync video source với audio target (DEPRECATED - use process_lipsync_with_audio_target_new)
93
+ #
94
+ # Args:
95
+ # video_file: Path to video source
96
+ # audio_file: Path to audio target (English only)
97
+ # session_id: Session identifier
98
+ # crop_size: Size of crop region (256 or 512)
99
+ # progress: Progress tracking object
100
+ #
101
+ # Returns:
102
+ # Tuple of (final_video, video_looped, face_cropped, lipsynced_face, lipsynced_full)
103
+ # """
104
+ # video_looped = None
105
+ # face_cropped = None
106
+ # lipsynced_face = None
107
+ # lipsynced_full = None
108
+ # final_video = None
109
+ # error_msg = None
110
+ #
111
+ # try:
112
+ # video_path, audio_path = validate_input(video_file, audio_file)
113
+ #
114
+ # output_dir = setup_output_dir(session_id)
115
+ #
116
+ # logger.info(f"Memory at start: {get_memory_usage()}")
117
+ #
118
+ # audio_duration = get_audio_duration(audio_path)
119
+ #
120
+ # progress(0.1, desc="🎬 Đang chuẩn bị video...")
121
+ # logger.info(f"Memory before video loop: {get_memory_usage()}")
122
+ #
123
+ # with timer("Looping/cropping video to match audio"):
124
+ # try:
125
+ # video_looped = loop_video_to_match_audio(
126
+ # video_path, audio_duration, output_dir
127
+ # )
128
+ # except Exception as e:
129
+ # error_msg = f"Video loop failed: {str(e)}"
130
+ # logger.error(error_msg)
131
+ # traceback.print_exc()
132
+ # return (
133
+ # final_video,
134
+ # video_looped,
135
+ # face_cropped,
136
+ # lipsynced_face,
137
+ # lipsynced_full,
138
+ # )
139
+ #
140
+ # gc.collect()
141
+ # logger.info(f"Memory after video loop: {get_memory_usage()}")
142
+ #
143
+ # progress(0.2, desc="👤 Đang phát hiện khuôn mặt...")
144
+ # with timer("Detecting face"):
145
+ # try:
146
+ # face_bbox = detect_face_region(video_looped, output_dir, crop_size)
147
+ # except FaceDetectionError as e:
148
+ # error_msg = str(e)
149
+ # logger.error(f"Face detection failed: {e}")
150
+ # return (
151
+ # final_video,
152
+ # video_looped,
153
+ # face_cropped,
154
+ # lipsynced_face,
155
+ # lipsynced_full,
156
+ # )
157
+ #
158
+ # gc.collect()
159
+ # logger.info(f"Memory after face detection: {get_memory_usage()}")
160
+ #
161
+ # actual_crop_size = crop_size * 2
162
+ # progress(
163
+ # 0.25, desc=f"👤 Đang tính lại crop {actual_crop_size}x{actual_crop_size}..."
164
+ # )
165
+ # with timer(
166
+ # f"Recalculating crop bbox for {actual_crop_size}x{actual_crop_size}"
167
+ # ):
168
+ # from video_processing import get_video_info
169
+ #
170
+ # try:
171
+ # video_info = get_video_info(video_looped)
172
+ # crop_bbox = calculate_safe_crop_size(
173
+ # face_bbox["face_bbox"],
174
+ # video_info["width"],
175
+ # video_info["height"],
176
+ # actual_crop_size,
177
+ # )
178
+ # except Exception as e:
179
+ # error_msg = f"Calculate crop bbox failed: {str(e)}"
180
+ # logger.error(error_msg)
181
+ # traceback.print_exc()
182
+ # return (
183
+ # final_video,
184
+ # video_looped,
185
+ # face_cropped,
186
+ # lipsynced_face,
187
+ # lipsynced_full,
188
+ # )
189
+ #
190
+ # progress(
191
+ # 0.3, desc=f"✂️ Đang crop video {actual_crop_size}x{actual_crop_size}..."
192
+ # )
193
+ # with timer(f"Cropping video to {actual_crop_size}x{actual_crop_size}"):
194
+ # try:
195
+ # face_cropped = crop_video_to_size(
196
+ # video_looped, crop_bbox, output_dir, actual_crop_size
197
+ # )
198
+ # except Exception as e:
199
+ # error_msg = f"Crop video failed: {str(e)}"
200
+ # logger.error(error_msg)
201
+ # traceback.print_exc()
202
+ # return (
203
+ # final_video,
204
+ # video_looped,
205
+ # face_cropped,
206
+ # lipsynced_face,
207
+ # lipsynced_full,
208
+ # )
209
+ #
210
+ # gc.collect()
211
+ # logger.info(f"Memory after crop: {get_memory_usage()}")
212
+ #
213
+ # progress(0.4, desc="🎵 Đang xử lý audio...")
214
+ # logger.info(f"Memory before audio prep: {get_memory_usage()}")
215
+ #
216
+ # with timer("Preparing target audio"):
217
+ # try:
218
+ # audio_16k, audio_upsampled = prepare_target_audio(
219
+ # audio_path, output_dir
220
+ # )
221
+ # except Exception as e:
222
+ # error_msg = f"Prepare audio failed: {str(e)}"
223
+ # logger.error(error_msg)
224
+ # traceback.print_exc()
225
+ # return (
226
+ # final_video,
227
+ # video_looped,
228
+ # face_cropped,
229
+ # lipsynced_face,
230
+ # lipsynced_full,
231
+ # )
232
+ #
233
+ # gc.collect()
234
+ # logger.info(f"Memory after audio prep: {get_memory_usage()}")
235
+ #
236
+ # progress(0.6, desc="👄 Đang lipsync...")
237
+ #
238
+ # video_info = get_video_info(face_cropped)
239
+ # logger.info(
240
+ # f"Starting lipsync: video={face_cropped}, audio_16k={audio_16k}, output={output_dir}"
241
+ # )
242
+ # logger.info(
243
+ # f"Video info: {video_info['width']}x{video_info['height']}, {video_info['fps']:.1f}fps, {video_info['duration']:.1f}s"
244
+ # )
245
+ # logger.info(f"Memory before lipsync: {get_memory_usage()}")
246
+ #
247
+ # with timer("Applying lipsync"):
248
+ # try:
249
+ # lipsynced_face, lipsynced_info = apply_lipsync_to_video(
250
+ # face_cropped, audio_16k, output_dir, crop_size
251
+ # )
252
+ # logger.info(
253
+ # f"Lipsynced video size: {lipsynced_info['width']}x{lipsynced_info['height']}"
254
+ # )
255
+ # except Exception as e:
256
+ # error_msg = f"Lipsync failed: {str(e)}"
257
+ # logger.error(f"Lipsync failed with error: {type(e).__name__}: {e}")
258
+ # logger.error(f"Memory after crash: {get_memory_usage()}")
259
+ # traceback.print_exc()
260
+ # return (
261
+ # final_video,
262
+ # video_looped,
263
+ # face_cropped,
264
+ # lipsynced_face,
265
+ # lipsynced_full,
266
+ # )
267
+ #
268
+ # gc.collect()
269
+ # logger.info(f"Memory after lipsync: {get_memory_usage()}")
270
+ #
271
+ # progress(0.8, desc="🔀 Đang ghép video...")
272
+ # with timer("Blending face into original"):
273
+ # try:
274
+ # lipsynced_full = blend_face_into_original(
275
+ # video_looped, lipsynced_face, crop_bbox, output_dir, lipsynced_info
276
+ # )
277
+ # except Exception as e:
278
+ # error_msg = f"Blend video failed: {str(e)}"
279
+ # logger.error(error_msg)
280
+ # traceback.print_exc()
281
+ # return (
282
+ # final_video,
283
+ # video_looped,
284
+ # face_cropped,
285
+ # lipsynced_face,
286
+ # lipsynced_full,
287
+ # )
288
+ #
289
+ # gc.collect()
290
+ # logger.info(f"Memory after blend: {get_memory_usage()}")
291
+ #
292
+ # progress(0.9, desc="🔗 Đang ghép audio...")
293
+ # try:
294
+ # audio_final = prepare_audio_for_youtube(audio_upsampled, output_dir)
295
+ # final_video = merge_audio_video(lipsynced_full, audio_final, output_dir)
296
+ # except Exception as e:
297
+ # error_msg = f"Merge audio failed: {str(e)}"
298
+ # logger.error(error_msg)
299
+ # traceback.print_exc()
300
+ # return (
301
+ # final_video,
302
+ # video_looped,
303
+ # face_cropped,
304
+ # lipsynced_face,
305
+ # lipsynced_full,
306
+ # )
307
+ #
308
+ # progress(1.0, desc="✅ Hoàn tất!")
309
+ # logger.info(f"Memory at end: {get_memory_usage()}")
310
+ #
311
+ # return final_video, video_looped, face_cropped, lipsynced_face, lipsynced_full
312
+ #
313
+ # except Exception as e:
314
+ # print(f"ERROR in process_lipsync_with_audio_target: {e}")
315
+ # traceback.print_exc()
316
+ # return final_video, video_looped, face_cropped, lipsynced_face, lipsynced_full
317
+
318
+
319
+ def process_lipsync_with_audio_target_new(
320
  video_file,
321
  audio_file,
322
  session_id=None,
323
  crop_size=256,
324
  progress=gr.Progress(track_tqdm=True),
325
  ):
326
+ """Workflow mới: Chuẩn hóa YouTube rồi lipsync
327
+
328
+ Steps:
329
+ 1. Validate inputs
330
+ 2. Chuẩn hóa video YouTube (loop/crop + re-encode)
331
+ 3. Chuẩn hóa audio YouTube (AAC 320k)
332
+ 4. Chuẩn bị audio 16k cho lipsync
333
+ 5. Lipsync pipeline (tự detect/crop/lipsync/restore)
334
+ 6. Merge audio YouTube + video lipsynced
335
 
336
  Args:
337
  video_file: Path to video source
338
  audio_file: Path to audio target (English only)
339
  session_id: Session identifier
340
+ crop_size: Size cho lipsync (256 or 512)
341
  progress: Progress tracking object
342
 
343
  Returns:
344
+ Tuple of (final_video, video_normalized, lipsynced_video)
345
  """
346
+ video_normalized = None
347
+ lipsynced_video = None
 
 
348
  final_video = None
 
349
 
350
  try:
351
  video_path, audio_path = validate_input(video_file, audio_file)
 
355
  logger.info(f"Memory at start: {get_memory_usage()}")
356
 
357
  audio_duration = get_audio_duration(audio_path)
358
+ logger.info(f"Audio duration: {audio_duration:.2f}s")
359
 
360
+ progress(0.15, desc="🎬 Đang chuẩn hóa video YouTube...")
361
+ logger.info(f"Memory before video normalization: {get_memory_usage()}")
362
 
363
+ with timer("Normalizing video for YouTube"):
364
  try:
365
+ video_normalized = normalize_video_for_youtube(
366
  video_path, audio_duration, output_dir
367
  )
368
+ video_info = get_video_info(video_normalized)
369
+ logger.info(
370
+ f"Normalized video: {video_info['width']}x{video_info['height']}, "
371
+ f"{video_info['fps']:.1f}fps, {video_info['duration']:.1f}s"
372
+ )
373
  except Exception as e:
374
+ logger.error(f"Video normalization failed: {str(e)}")
 
375
  traceback.print_exc()
376
+ return final_video, video_normalized, lipsynced_video
 
 
 
 
 
 
377
 
378
  gc.collect()
379
+ logger.info(f"Memory after video normalization: {get_memory_usage()}")
380
 
381
+ progress(0.25, desc="🎵 Đang chuẩn hóa audio YouTube...")
382
+ logger.info(f"Memory before audio normalization: {get_memory_usage()}")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
383
 
384
+ with timer("Normalizing audio for YouTube"):
385
  try:
386
+ audio_youtube = prepare_audio_for_youtube_aac(audio_path, output_dir)
387
+ logger.info(f"Audio YouTube: {audio_youtube}")
 
 
 
 
 
388
  except Exception as e:
389
+ logger.error(f"Audio normalization failed: {str(e)}")
 
390
  traceback.print_exc()
391
+ return final_video, video_normalized, lipsynced_video
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
392
 
393
  gc.collect()
394
+ logger.info(f"Memory after audio normalization: {get_memory_usage()}")
395
 
396
+ progress(0.35, desc="🔊 Đang chuẩn bị audio cho lipsync...")
 
397
 
398
+ with timer("Preparing audio for lipsync"):
399
  try:
400
+ audio_16k = prepare_audio_for_lipsync(audio_path, output_dir)
401
+ logger.info(f"Audio 16k for lipsync: {audio_16k}")
 
402
  except Exception as e:
403
+ logger.error(f"Audio lipsync preparation failed: {str(e)}")
 
404
  traceback.print_exc()
405
+ return final_video, video_normalized, lipsynced_video
 
 
 
 
 
 
406
 
407
  gc.collect()
408
+ logger.info(f"Memory after audio preparation: {get_memory_usage()}")
409
 
410
+ progress(0.55, desc="👄 Đang lipsync...")
411
 
 
412
  logger.info(
413
+ f"Starting lipsync: video={video_normalized}, audio_16k={audio_16k}, output={output_dir}"
 
 
 
414
  )
415
  logger.info(f"Memory before lipsync: {get_memory_usage()}")
416
 
417
  with timer("Applying lipsync"):
418
  try:
419
+ lipsynced_video, lipsynced_info = apply_lipsync_to_video(
420
+ video_normalized, audio_16k, output_dir, crop_size
421
  )
422
  logger.info(
423
+ f"Lipsynced video: {lipsynced_video}, size: {lipsynced_info['width']}x{lipsynced_info['height']}"
424
  )
425
  except Exception as e:
 
426
  logger.error(f"Lipsync failed with error: {type(e).__name__}: {e}")
427
  logger.error(f"Memory after crash: {get_memory_usage()}")
428
  traceback.print_exc()
429
+ return final_video, video_normalized, lipsynced_video
 
 
 
 
 
 
430
 
431
  gc.collect()
432
  logger.info(f"Memory after lipsync: {get_memory_usage()}")
433
 
434
+ progress(0.85, desc="🔗 Đang ghép audio YouTube...")
435
+ logger.info(f"Memory before merge: {get_memory_usage()}")
436
+
437
+ with timer("Merging audio and video"):
438
  try:
439
+ final_video = merge_audio_video(
440
+ lipsynced_video, audio_youtube, output_dir
441
  )
442
+ logger.info(f"Final video: {final_video}")
443
  except Exception as e:
444
+ logger.error(f"Merge audio failed: {str(e)}")
 
445
  traceback.print_exc()
446
+ return final_video, video_normalized, lipsynced_video
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
447
 
448
  progress(1.0, desc="✅ Hoàn tất!")
449
  logger.info(f"Memory at end: {get_memory_usage()}")
450
 
451
+ return final_video, video_normalized, lipsynced_video
452
 
453
  except Exception as e:
454
+ print(f"ERROR in process_lipsync_with_audio_target_new: {e}")
455
  traceback.print_exc()
456
+ return final_video, video_normalized, lipsynced_video
457
 
458
 
459
  @spaces.GPU(duration=600)
 
467
  """Wrapper for Gradio: Lipsync video source with audio target (English only)
468
 
469
  Returns:
470
+ Tuple of (final_video, video_normalized, lipsynced_video)
471
  """
472
  if video_file is None:
473
  raise gr.Error("Please upload a video source.")
474
  if audio_file is None:
475
  raise gr.Error("Please upload a target audio.")
476
+ return process_lipsync_with_audio_target_new(
477
  video_file, audio_file, session_id, crop_size, progress
478
  )
video_processing.py CHANGED
@@ -18,24 +18,54 @@ from config import (
18
  )
19
 
20
 
21
- def crop_video_duration(video_path: str, duration: int, output_dir: str) -> str:
22
- """Crop video to specified duration using FFmpeg
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
23
 
24
  Args:
25
- video_path: Path to input video
26
- duration: Duration in seconds
27
- output_dir: Directory to save cropped video
28
 
29
  Returns:
30
- Path to cropped video
31
  """
32
- cropped_video_path = os.path.join(output_dir, "input_cropped.mp4")
33
  ffmpeg = FFmpeg(
34
- inputs={video_path: None},
35
  outputs={
36
- cropped_video_path: [
37
- "-t",
38
- f"{duration}",
39
  "-c",
40
  "copy",
41
  "-loglevel",
@@ -47,8 +77,91 @@ def crop_video_duration(video_path: str, duration: int, output_dir: str) -> str:
47
  try:
48
  ffmpeg.run()
49
  except FFRuntimeError as e:
50
- raise Exception(f"FFmpeg failed: {e}")
51
- return cropped_video_path
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
52
 
53
 
54
  def merge_audio_video(video_path: str, audio_path: str, output_dir: str) -> str:
 
18
  )
19
 
20
 
21
+ # def crop_video_duration(video_path: str, duration: int, output_dir: str) -> str:
22
+ # """Crop video to specified duration using FFmpeg (DEPRECATED - merged into normalize_video_for_youtube)
23
+ #
24
+ # Args:
25
+ # video_path: Path to input video
26
+ # duration: Duration in seconds
27
+ # output_dir: Directory to save cropped video
28
+ #
29
+ # Returns:
30
+ # Path to cropped video
31
+ # """
32
+ # cropped_video_path = os.path.join(output_dir, "input_cropped.mp4")
33
+ # ffmpeg = FFmpeg(
34
+ # inputs={video_path: None},
35
+ # outputs={
36
+ # cropped_video_path: [
37
+ # "-t",
38
+ # f"{duration}",
39
+ # "-c",
40
+ # "copy",
41
+ # "-loglevel",
42
+ # "error",
43
+ # "-y",
44
+ # ]
45
+ # },
46
+ # )
47
+ # try:
48
+ # ffmpeg.run()
49
+ # except FFRuntimeError as e:
50
+ # raise Exception(f"FFmpeg failed: {e}")
51
+ # return cropped_video_path
52
+
53
+
54
+ def loop_video(video_path: str, output_path: str, loop_count: int) -> str:
55
+ """Loop video bằng stream_loop với copy codec
56
 
57
  Args:
58
+ video_path: Path video gốc
59
+ output_path: Path output video đã loop
60
+ loop_count: Số lần loop
61
 
62
  Returns:
63
+ Path video đã loop
64
  """
 
65
  ffmpeg = FFmpeg(
66
+ inputs={video_path: ["-stream_loop", f"{loop_count}"]},
67
  outputs={
68
+ output_path: [
 
 
69
  "-c",
70
  "copy",
71
  "-loglevel",
 
77
  try:
78
  ffmpeg.run()
79
  except FFRuntimeError as e:
80
+ raise Exception(f"FFmpeg failed to loop video: {e}")
81
+ return output_path
82
+
83
+
84
+ def encode_video_for_youtube(
85
+ video_path: str, output_path: str, duration: float | None = None
86
+ ) -> str:
87
+ """Encode video theo tiêu chuẩn YouTube
88
+
89
+ Args:
90
+ video_path: Path video input
91
+ output_path: Path output video
92
+ duration: Duration crop (None = không crop)
93
+
94
+ Returns:
95
+ Path video đã encode
96
+ """
97
+ ffmpeg_args = [
98
+ "-c:v",
99
+ "libx264",
100
+ "-preset",
101
+ YOUTUBE_VIDEO_PRESET,
102
+ "-crf",
103
+ str(YOUTUBE_VIDEO_CRF),
104
+ "-profile:v",
105
+ YOUTUBE_VIDEO_PROFILE,
106
+ "-level",
107
+ YOUTUBE_VIDEO_LEVEL,
108
+ "-pix_fmt",
109
+ YOUTUBE_VIDEO_PIX_FMT,
110
+ "-c:a",
111
+ "copy",
112
+ "-movflags",
113
+ "+faststart",
114
+ "-loglevel",
115
+ "error",
116
+ "-y",
117
+ ]
118
+
119
+ if duration is not None:
120
+ ffmpeg_args = ["-t", f"{duration}"] + ffmpeg_args
121
+
122
+ ffmpeg = FFmpeg(
123
+ inputs={video_path: None},
124
+ outputs={output_path: ffmpeg_args},
125
+ )
126
+ try:
127
+ ffmpeg.run()
128
+ except FFRuntimeError as e:
129
+ raise Exception(f"FFmpeg failed to encode video: {e}")
130
+ return output_path
131
+
132
+
133
+ def normalize_video_for_youtube(
134
+ video_path: str, audio_duration: float, output_dir: str
135
+ ) -> str:
136
+ """Chuẩn hóa video theo tiêu chuẩn YouTube
137
+
138
+ - Loop nếu ngắn hơn audio, crop nếu dài hơn
139
+ - Re-encode: libx264, preset slow, CRF 18, profile high, level 4.2, yuv420p
140
+
141
+ Args:
142
+ video_path: Path video gốc
143
+ audio_duration: Duration audio (seconds)
144
+ output_dir: Output directory
145
+
146
+ Returns:
147
+ Path video đã chuẩn hóa
148
+ """
149
+ video_info = get_video_info(video_path)
150
+ video_duration = video_info["duration"]
151
+
152
+ output_path = os.path.join(output_dir, "video_normalized.mp4")
153
+
154
+ if video_duration >= audio_duration:
155
+ encode_video_for_youtube(video_path, output_path, audio_duration)
156
+ else:
157
+ loop_count = int(audio_duration // video_duration) + 1
158
+ temp_looped = os.path.join(output_dir, "video_looped_temp.mp4")
159
+
160
+ loop_video(video_path, temp_looped, loop_count)
161
+ encode_video_for_youtube(temp_looped, output_path, audio_duration)
162
+ os.remove(temp_looped)
163
+
164
+ return output_path
165
 
166
 
167
  def merge_audio_video(video_path: str, audio_path: str, output_dir: str) -> str: