Commit ·
e1b19be
1
Parent(s): a46605e
Add smooth crossfade transition and improve video sync on speech end
Browse files- Add 300ms crossfade transition between speaking and idle video
- Save last speaking frame for smooth visual transition
- Pass end_video_time_ms from Wav2Lip to frontend for precise sync
- Increase JPEG quality from 85 to 100 on Wav2Lip server
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
interface/index_optimized.html
CHANGED
|
@@ -280,14 +280,47 @@
|
|
| 280 |
let speakingFrameIndex = 0;
|
| 281 |
let lastSpeakingRenderTime = 0;
|
| 282 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 283 |
function startUnifiedRenderLoop() {
|
| 284 |
if (unifiedRenderLoop) return; // Já está rodando
|
| 285 |
|
| 286 |
function renderFrame() {
|
| 287 |
if (renderSource === 'idle') {
|
| 288 |
-
//
|
| 289 |
-
if (
|
| 290 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 291 |
}
|
| 292 |
} else if (renderSource === 'speaking') {
|
| 293 |
// Desenha frames do servidor com timing controlado
|
|
@@ -298,6 +331,8 @@
|
|
| 298 |
// Se o tempo esperado de fim do áudio já passou, parar de renderizar frames
|
| 299 |
if (audioExpectedEndTime > 0 && now >= audioExpectedEndTime) {
|
| 300 |
console.log(`Audio terminou (${now.toFixed(0)} >= ${audioExpectedEndTime.toFixed(0)}), finalizando video`);
|
|
|
|
|
|
|
| 301 |
finishPlayback();
|
| 302 |
return;
|
| 303 |
}
|
|
@@ -334,6 +369,8 @@
|
|
| 334 |
|
| 335 |
// Verificar se terminou
|
| 336 |
if (streamDone && frameQueue.length === 0) {
|
|
|
|
|
|
|
| 337 |
finishPlayback();
|
| 338 |
}
|
| 339 |
}
|
|
@@ -354,6 +391,23 @@
|
|
| 354 |
}
|
| 355 |
}
|
| 356 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 357 |
// Iniciar o render loop quando a página carrega
|
| 358 |
idleVideo.addEventListener('loadeddata', () => {
|
| 359 |
// Ajustar tamanho do canvas para match do vídeo
|
|
@@ -953,6 +1007,10 @@
|
|
| 953 |
audioPlaybackStartTime = 0;
|
| 954 |
audioExpectedEndTime = 0;
|
| 955 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 956 |
document.getElementById("frames").textContent = "0";
|
| 957 |
document.getElementById("rendered").textContent = "0";
|
| 958 |
document.getElementById("buffer").textContent = "0";
|
|
@@ -1018,6 +1076,13 @@
|
|
| 1018 |
idleVideo.loop = true;
|
| 1019 |
idleVideo.play().catch(e => console.log("Erro ao retomar idle video:", e));
|
| 1020 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1021 |
// Transição: speaking → idle (só muda a fonte, canvas continua renderizando)
|
| 1022 |
renderSource = 'idle';
|
| 1023 |
console.log("Transição para idle - renderSource mudou, idle video playing");
|
|
|
|
| 280 |
let speakingFrameIndex = 0;
|
| 281 |
let lastSpeakingRenderTime = 0;
|
| 282 |
|
| 283 |
+
// Crossfade transition variables
|
| 284 |
+
let isTransitioning = false;
|
| 285 |
+
let transitionStartTime = 0;
|
| 286 |
+
let lastSpeakingFrame = null; // Guarda o último frame do speaking para crossfade
|
| 287 |
+
const TRANSITION_DURATION_MS = 300; // Duração do crossfade em ms
|
| 288 |
+
|
| 289 |
function startUnifiedRenderLoop() {
|
| 290 |
if (unifiedRenderLoop) return; // Já está rodando
|
| 291 |
|
| 292 |
function renderFrame() {
|
| 293 |
if (renderSource === 'idle') {
|
| 294 |
+
// Se estamos em transição, fazer crossfade
|
| 295 |
+
if (isTransitioning && lastSpeakingFrame) {
|
| 296 |
+
const now = performance.now();
|
| 297 |
+
const elapsed = now - transitionStartTime;
|
| 298 |
+
const progress = Math.min(elapsed / TRANSITION_DURATION_MS, 1);
|
| 299 |
+
|
| 300 |
+
// Desenha o último frame do speaking
|
| 301 |
+
ctx.globalAlpha = 1 - progress;
|
| 302 |
+
ctx.drawImage(lastSpeakingFrame, 0, 0, canvas.width, canvas.height);
|
| 303 |
+
|
| 304 |
+
// Desenha o vídeo idle por cima com alpha crescente
|
| 305 |
+
ctx.globalAlpha = progress;
|
| 306 |
+
if (idleVideo.readyState >= 2) {
|
| 307 |
+
ctx.drawImage(idleVideo, 0, 0, canvas.width, canvas.height);
|
| 308 |
+
}
|
| 309 |
+
|
| 310 |
+
// Restaurar alpha
|
| 311 |
+
ctx.globalAlpha = 1;
|
| 312 |
+
|
| 313 |
+
// Fim da transição
|
| 314 |
+
if (progress >= 1) {
|
| 315 |
+
isTransitioning = false;
|
| 316 |
+
lastSpeakingFrame = null;
|
| 317 |
+
console.log("Crossfade concluído");
|
| 318 |
+
}
|
| 319 |
+
} else {
|
| 320 |
+
// Desenha o frame atual do vídeo idle no canvas
|
| 321 |
+
if (idleVideo.readyState >= 2) { // HAVE_CURRENT_DATA
|
| 322 |
+
ctx.drawImage(idleVideo, 0, 0, canvas.width, canvas.height);
|
| 323 |
+
}
|
| 324 |
}
|
| 325 |
} else if (renderSource === 'speaking') {
|
| 326 |
// Desenha frames do servidor com timing controlado
|
|
|
|
| 331 |
// Se o tempo esperado de fim do áudio já passou, parar de renderizar frames
|
| 332 |
if (audioExpectedEndTime > 0 && now >= audioExpectedEndTime) {
|
| 333 |
console.log(`Audio terminou (${now.toFixed(0)} >= ${audioExpectedEndTime.toFixed(0)}), finalizando video`);
|
| 334 |
+
// Salvar último frame para crossfade
|
| 335 |
+
saveLastFrameForTransition();
|
| 336 |
finishPlayback();
|
| 337 |
return;
|
| 338 |
}
|
|
|
|
| 369 |
|
| 370 |
// Verificar se terminou
|
| 371 |
if (streamDone && frameQueue.length === 0) {
|
| 372 |
+
// Salvar último frame para crossfade
|
| 373 |
+
saveLastFrameForTransition();
|
| 374 |
finishPlayback();
|
| 375 |
}
|
| 376 |
}
|
|
|
|
| 391 |
}
|
| 392 |
}
|
| 393 |
|
| 394 |
+
// Salvar o último frame do speaking para crossfade
|
| 395 |
+
function saveLastFrameForTransition() {
|
| 396 |
+
// Criar uma cópia do canvas atual como imagem
|
| 397 |
+
const tempCanvas = document.createElement('canvas');
|
| 398 |
+
tempCanvas.width = canvas.width;
|
| 399 |
+
tempCanvas.height = canvas.height;
|
| 400 |
+
const tempCtx = tempCanvas.getContext('2d');
|
| 401 |
+
tempCtx.drawImage(canvas, 0, 0);
|
| 402 |
+
|
| 403 |
+
// Criar imagem a partir do canvas
|
| 404 |
+
const img = new Image();
|
| 405 |
+
img.src = tempCanvas.toDataURL('image/jpeg', 0.95);
|
| 406 |
+
lastSpeakingFrame = img;
|
| 407 |
+
|
| 408 |
+
console.log("Último frame salvo para crossfade");
|
| 409 |
+
}
|
| 410 |
+
|
| 411 |
// Iniciar o render loop quando a página carrega
|
| 412 |
idleVideo.addEventListener('loadeddata', () => {
|
| 413 |
// Ajustar tamanho do canvas para match do vídeo
|
|
|
|
| 1007 |
audioPlaybackStartTime = 0;
|
| 1008 |
audioExpectedEndTime = 0;
|
| 1009 |
|
| 1010 |
+
// Reset crossfade state
|
| 1011 |
+
isTransitioning = false;
|
| 1012 |
+
lastSpeakingFrame = null;
|
| 1013 |
+
|
| 1014 |
document.getElementById("frames").textContent = "0";
|
| 1015 |
document.getElementById("rendered").textContent = "0";
|
| 1016 |
document.getElementById("buffer").textContent = "0";
|
|
|
|
| 1076 |
idleVideo.loop = true;
|
| 1077 |
idleVideo.play().catch(e => console.log("Erro ao retomar idle video:", e));
|
| 1078 |
|
| 1079 |
+
// Iniciar crossfade se temos o último frame salvo
|
| 1080 |
+
if (lastSpeakingFrame) {
|
| 1081 |
+
isTransitioning = true;
|
| 1082 |
+
transitionStartTime = performance.now();
|
| 1083 |
+
console.log("Iniciando crossfade de 300ms");
|
| 1084 |
+
}
|
| 1085 |
+
|
| 1086 |
// Transição: speaking → idle (só muda a fonte, canvas continua renderizando)
|
| 1087 |
renderSource = 'idle';
|
| 1088 |
console.log("Transição para idle - renderSource mudou, idle video playing");
|
interface/server_optimized.py
CHANGED
|
@@ -47,6 +47,7 @@ class OptimizedSession:
|
|
| 47 |
self.start_time = None
|
| 48 |
self.total_frames = 0
|
| 49 |
self.total_bytes = 0
|
|
|
|
| 50 |
|
| 51 |
async def send_json(self, msg_type: str, **kwargs):
|
| 52 |
"""Envia mensagem JSON (para status/controle)."""
|
|
@@ -233,7 +234,10 @@ class OptimizedSession:
|
|
| 233 |
break
|
| 234 |
|
| 235 |
elif msg_type == "done":
|
| 236 |
-
|
|
|
|
|
|
|
|
|
|
| 237 |
break
|
| 238 |
|
| 239 |
elif msg.type in (aiohttp.WSMsgType.CLOSED, aiohttp.WSMsgType.ERROR):
|
|
@@ -267,7 +271,8 @@ class OptimizedSession:
|
|
| 267 |
"done",
|
| 268 |
total_frames=self.total_frames,
|
| 269 |
elapsed_ms=int(elapsed * 1000),
|
| 270 |
-
bytes_sent=self.total_bytes
|
|
|
|
| 271 |
)
|
| 272 |
|
| 273 |
def stop(self):
|
|
|
|
| 47 |
self.start_time = None
|
| 48 |
self.total_frames = 0
|
| 49 |
self.total_bytes = 0
|
| 50 |
+
self.end_video_time_ms = None # Tempo do vídeo onde parou (para sync)
|
| 51 |
|
| 52 |
async def send_json(self, msg_type: str, **kwargs):
|
| 53 |
"""Envia mensagem JSON (para status/controle)."""
|
|
|
|
| 234 |
break
|
| 235 |
|
| 236 |
elif msg_type == "done":
|
| 237 |
+
# Capturar end_video_time_ms para sincronização
|
| 238 |
+
self.end_video_time_ms = data.get("end_video_time_ms")
|
| 239 |
+
end_frame_idx = data.get("end_frame_idx")
|
| 240 |
+
print(f"[Optimized] Wav2Lip done: {self.total_frames} frames, end_video_time_ms={self.end_video_time_ms}, end_frame_idx={end_frame_idx}")
|
| 241 |
break
|
| 242 |
|
| 243 |
elif msg.type in (aiohttp.WSMsgType.CLOSED, aiohttp.WSMsgType.ERROR):
|
|
|
|
| 271 |
"done",
|
| 272 |
total_frames=self.total_frames,
|
| 273 |
elapsed_ms=int(elapsed * 1000),
|
| 274 |
+
bytes_sent=self.total_bytes,
|
| 275 |
+
end_video_time_ms=self.end_video_time_ms
|
| 276 |
)
|
| 277 |
|
| 278 |
def stop(self):
|