patocolher commited on
Commit
38c9347
Β·
verified Β·
1 Parent(s): 23dd7ec

Upload 4 files

Browse files
Files changed (1) hide show
  1. app.py +341 -202
app.py CHANGED
@@ -7,6 +7,10 @@ import shutil
7
  from PIL import Image, ImageDraw
8
 
9
 
 
 
 
 
10
  def gpu_disponivel():
11
  """Verifica se a GPU NVIDIA estΓ‘ acessΓ­vel via nvidia-smi."""
12
  r = subprocess.run(["nvidia-smi"], capture_output=True, text=True)
@@ -14,10 +18,7 @@ def gpu_disponivel():
14
 
15
 
16
  def contar_frames(path):
17
- """
18
- Conta frames via nb_read_packets β€” mais confiΓ‘vel que nb_frames,
19
- que muitas vezes fica em branco dependendo do container.
20
- """
21
  r = subprocess.run([
22
  "ffprobe", "-v", "error",
23
  "-select_streams", "v:0",
@@ -32,36 +33,54 @@ def contar_frames(path):
32
  return None
33
 
34
 
 
 
 
 
35
  def preparar_logo(logo_path, vid_w, tamanho_pct, transparencia_pct):
36
  """
37
- Abre o logo, redimensiona para tamanho_pct% da largura do vΓ­deo
38
- e aplica transparencia_pct% de transparΓͺncia (0 = opaco, 100 = invisΓ­vel).
39
- Salva em PNG com canal alpha e retorna (caminho, largura, altura).
 
 
40
  """
41
- img = Image.open(logo_path).convert("RGBA")
 
 
42
 
 
43
  alvo_w = max(1, int(vid_w * tamanho_pct / 100))
44
  ratio = alvo_w / img.width
45
  alvo_h = max(1, int(img.height * ratio))
46
  img = img.resize((alvo_w, alvo_h), Image.LANCZOS)
47
 
 
 
 
 
48
  if transparencia_pct > 0:
49
- r_ch, g_ch, b_ch, a_ch = img.split()
50
  fator = 1.0 - (transparencia_pct / 100.0)
51
  a_ch = a_ch.point(lambda x: int(x * fator))
52
- img = Image.merge("RGBA", (r_ch, g_ch, b_ch, a_ch))
 
 
 
 
 
 
 
 
 
 
53
 
54
  temp_path = "/tmp/logo_overlay.png"
55
- img.save(temp_path, "PNG")
56
  return temp_path, alvo_w, alvo_h
57
 
58
 
59
  def calcular_posicao_logo(posicao, margem=20, offset_x=0, offset_y=0):
60
- """
61
- Retorna a expressΓ£o de posiΓ§Γ£o (x=...:y=...) para o filtro overlay do FFmpeg.
62
- margem = distΓ’ncia das bordas em pixels
63
- offset_x/y = ajuste fino adicional (positivo = direita/baixo)
64
- """
65
  ox, oy = offset_x, offset_y
66
  m = margem
67
  return {
@@ -70,63 +89,61 @@ def calcular_posicao_logo(posicao, margem=20, offset_x=0, offset_y=0):
70
  "Canto superior direito": f"x=W-w-{m}+{ox}:y={m+oy}",
71
  "Canto inferior esquerdo": f"x={m+ox}:y=H-h-{m}+{oy}",
72
  "Canto inferior direito": f"x=W-w-{m}+{ox}:y=H-h-{m}+{oy}",
73
- }.get(posicao, f"x=W-w-{m}+{ox}:y=H-h-{m}+{oy}")
74
 
75
 
76
- # ── Preview da logo ──────────────────────────────────────────────
77
 
78
- PREVIEW_W, PREVIEW_H = 640, 360 # canvas de preview 16:9
79
- MARGEM_PREVIEW = 12 # margem em pixels no canvas de preview
80
 
81
 
82
- def gerar_preview_logo(logo_file, logo_posicao, logo_margem, logo_offset_x, logo_offset_y, logo_tamanho, logo_transparencia):
83
- """
84
- Gera uma imagem de preview mostrando o logo posicionado sobre um fundo
85
- simulando o vΓ­deo. Atualiza sempre que qualquer controle mudar.
86
- """
87
- # Fundo cinza escuro com grid para simular vΓ­deo
88
  canvas = Image.new("RGBA", (PREVIEW_W, PREVIEW_H), (30, 30, 30, 255))
89
- draw = ImageDraw.Draw(canvas)
90
 
91
- # Grid sutil
92
  for x in range(0, PREVIEW_W, 40):
93
  draw.line([(x, 0), (x, PREVIEW_H)], fill=(50, 50, 50, 255), width=1)
94
  for y in range(0, PREVIEW_H, 40):
95
  draw.line([(0, y), (PREVIEW_W, y)], fill=(50, 50, 50, 255), width=1)
96
 
97
- # Texto central
98
  draw.text((PREVIEW_W // 2 - 60, PREVIEW_H // 2 - 8),
99
  "[ seu vΓ­deo ]", fill=(80, 80, 80, 255))
100
 
101
  if logo_file is None:
102
- canvas = canvas.convert("RGB")
103
  out = "/tmp/logo_preview.png"
104
- canvas.save(out)
105
  return out
106
 
107
  try:
108
- logo = Image.open(logo_file).convert("RGBA")
 
 
109
 
110
- # Redimensiona proporcionalmente ao canvas de preview
111
  alvo_w = max(1, int(PREVIEW_W * logo_tamanho / 100))
112
  ratio = alvo_w / logo.width
113
  alvo_h = max(1, int(logo.height * ratio))
114
  logo = logo.resize((alvo_w, alvo_h), Image.LANCZOS)
115
 
116
- # Aplica transparΓͺncia
 
117
  if logo_transparencia > 0:
118
- r_ch, g_ch, b_ch, a_ch = logo.split()
119
  fator = 1.0 - (logo_transparencia / 100.0)
120
  a_ch = a_ch.point(lambda px: int(px * fator))
121
- logo = Image.merge("RGBA", (r_ch, g_ch, b_ch, a_ch))
122
-
123
- # Calcula posiΓ§Γ£o no canvas de preview
124
- m = MARGEM_PREVIEW
125
- # escala margem e offset para o canvas de preview
126
- escala = PREVIEW_W / 1920
127
- mp = int(logo_margem * escala)
128
- ox = int(logo_offset_x * escala)
129
- oy = int(logo_offset_y * escala)
 
 
130
  pos_map = {
131
  "Centro": ((PREVIEW_W - alvo_w) // 2 + ox,
132
  (PREVIEW_H - alvo_h) // 2 + oy),
@@ -137,26 +154,70 @@ def gerar_preview_logo(logo_file, logo_posicao, logo_margem, logo_offset_x, logo
137
  PREVIEW_H - alvo_h - mp + oy),
138
  }
139
  px, py = pos_map.get(logo_posicao,
140
- (PREVIEW_W - alvo_w - mp + ox, PREVIEW_H - alvo_h - mp + oy))
141
- # clamp para nΓ£o sair do canvas
142
  px = max(0, min(px, PREVIEW_W - alvo_w))
143
  py = max(0, min(py, PREVIEW_H - alvo_h))
144
 
145
- canvas.paste(logo, (px, py), logo)
146
 
147
  except Exception:
148
- pass # se falhar, mostra sΓ³ o fundo
149
 
150
- canvas = canvas.convert("RGB")
151
  out = "/tmp/logo_preview.png"
152
- canvas.save(out)
153
  return out
154
 
155
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
156
  def reencode_video(
157
  video_file, modo, resolucao, fps, crf_valor,
158
- normalizar_audio, remover_duplicados,
159
- logo_file, logo_posicao, logo_margem, logo_offset_x, logo_offset_y, logo_tamanho, logo_transparencia
 
 
 
 
 
 
160
  ):
161
  if video_file is None:
162
  yield None, "❌ Nenhum vΓ­deo enviado!"
@@ -166,7 +227,7 @@ def reencode_video(
166
  output_path = "output_reencoded.mp4"
167
  shutil.copy(video_file, input_path)
168
 
169
- # ── Detecta orientaΓ§Γ£o e duraΓ§Γ£o do vΓ­deo ────────────────────
170
  probe = subprocess.run([
171
  "ffprobe", "-v", "error",
172
  "-select_streams", "v:0",
@@ -179,6 +240,7 @@ def reencode_video(
179
  vid_h = int(stream.get("height", 1080))
180
  is_vertical = vid_h > vid_w
181
 
 
182
  probe_dur = subprocess.run([
183
  "ffprobe", "-v", "error",
184
  "-show_entries", "format=duration",
@@ -187,7 +249,7 @@ def reencode_video(
187
  dur_data = json.loads(probe_dur.stdout)
188
  total_duration = float(dur_data.get("format", {}).get("duration", 0))
189
 
190
- # ── Detecta se TEM fluxo de Γ‘udio ───────────────────────────
191
  probe_audio = subprocess.run([
192
  "ffprobe", "-v", "error",
193
  "-select_streams", "a",
@@ -196,17 +258,12 @@ def reencode_video(
196
  ], capture_output=True, text=True)
197
  has_audio = len(json.loads(probe_audio.stdout).get("streams", [])) > 0
198
 
199
- # ── Decide se usa GPU ───────────────────────────────────────
200
  quer_gpu = "GPU" in modo
201
  use_gpu = quer_gpu and gpu_disponivel()
202
  gpu_fallback = quer_gpu and not use_gpu
203
  tem_logo = logo_file is not None
204
 
205
- # Pipeline 100% GPU sΓ³ quando:
206
- # - GPU disponΓ­vel
207
- # - mpdecimate desligado (nΓ£o tem equivalente CUDA)
208
- # - resoluΓ§Γ£o "Original" (scale_cuda pode nΓ£o estar compilado)
209
- # - sem logo (overlay Γ© filtro de software)
210
  full_gpu_pipeline = (
211
  use_gpu and
212
  not remover_duplicados and
@@ -214,18 +271,18 @@ def reencode_video(
214
  not tem_logo
215
  )
216
 
217
- # ── Prepara logo (Pillow) ────────────────────────────────────
218
  logo_tmp = None
219
  if tem_logo:
220
  try:
221
- logo_tmp, logo_w, logo_h = preparar_logo(
222
  logo_file, vid_w, logo_tamanho, logo_transparencia
223
  )
224
  except Exception as e:
225
  yield None, f"❌ Erro ao processar logo: {e}"
226
  return
227
 
228
- # ── Filtros de vΓ­deo (cadeia base) ───────────────────────────
229
  vf_parts = []
230
 
231
  if resolucao != "Original":
@@ -243,31 +300,37 @@ def reencode_video(
243
  if remover_duplicados:
244
  vf_parts.append("mpdecimate=hi=1")
245
 
246
- # ── Monta comando base ──────────────────────────────────────
247
  cmd = ["ffmpeg", "-hide_banner", "-loglevel", "error", "-progress", "pipe:1", "-y"]
248
 
249
  if full_gpu_pipeline:
250
  cmd += ["-hwaccel", "cuda", "-hwaccel_output_format", "cuda"]
251
 
252
  cmd += ["-i", input_path]
253
-
254
  if tem_logo:
255
  cmd += ["-i", logo_tmp]
256
 
257
- # ── Aplica filtros / filter_complex ─────────────────────────
258
  if tem_logo:
259
- # x=...:y=... Γ© obrigatΓ³rio para evitar ambiguidade no FFmpeg >= 5.x
260
- overlay_pos = calcular_posicao_logo(logo_posicao, logo_margem, logo_offset_x, logo_offset_y)
 
 
 
 
 
261
  if vf_parts:
262
  fc = (
263
  f"[0:v]{','.join(vf_parts)}[base];"
264
- f"[1:v]format=rgba[logo];"
265
- f"[base][logo]overlay={overlay_pos}[vout]"
 
266
  )
267
  else:
268
  fc = (
269
- f"[1:v]format=rgba[logo];"
270
- f"[0:v][logo]overlay={overlay_pos}[vout]"
 
271
  )
272
  cmd += ["-filter_complex", fc, "-map", "[vout]"]
273
  if has_audio:
@@ -281,7 +344,7 @@ def reencode_video(
281
  elif remover_duplicados and not tem_logo:
282
  cmd += ["-fps_mode", "vfr"]
283
 
284
- # ── Codec de vΓ­deo + CRF/CQ ─────────────────────────────────
285
  if "x264" in modo:
286
  if use_gpu:
287
  cmd += ["-c:v", "h264_nvenc", "-preset", "p7", "-tune", "hq",
@@ -300,10 +363,10 @@ def reencode_video(
300
  "-pix_fmt", "yuv420p",
301
  "-x265-params", "sao=0:rd=6:psy-rd=1.0:psy-rdoq=2.0:rskip=1"]
302
 
303
- # ── Áudio ───────────────────────────────────────────────────
304
  af = None
305
  if has_audio and normalizar_audio:
306
- yield None, "⏳ Analisando volume do Γ‘udio (1Βͺ passada)..."
307
  result_ln = subprocess.run([
308
  "ffmpeg", "-hide_banner", "-loglevel", "info", "-y",
309
  "-i", input_path,
@@ -314,19 +377,17 @@ def reencode_video(
314
  raw = result_ln.stderr
315
  start = raw.find("{")
316
  end = raw.rfind("}") + 1
317
-
318
  if start != -1 and end != 0:
319
  try:
320
- stats = json.loads(raw[start:end])
321
- m_I = stats["input_i"]
322
- m_TP = stats["input_tp"]
323
- m_LRA = stats["input_lra"]
324
- m_thresh = stats["input_thresh"]
325
- offset = stats["target_offset"]
326
- af = (f"loudnorm=I=-23:TP=-2:LRA=7:linear=true"
327
- f":measured_I={m_I}:measured_tp={m_TP}"
328
- f":measured_LRA={m_LRA}"
329
- f":measured_thresh={m_thresh}:offset={offset}")
330
  except (json.JSONDecodeError, KeyError):
331
  normalizar_audio = False
332
  else:
@@ -334,12 +395,10 @@ def reencode_video(
334
 
335
  if has_audio:
336
  if normalizar_audio and af:
337
- cmd += ["-af", af,
338
- "-c:a", "libfdk_aac", "-profile:a", "aac_he_v2",
339
- "-b:a", "32k", "-ar", "22050", "-ac", "2"]
340
- else:
341
- cmd += ["-c:a", "libfdk_aac", "-profile:a", "aac_he_v2",
342
- "-b:a", "32k", "-ar", "22050", "-ac", "2"]
343
  else:
344
  cmd += ["-an"]
345
 
@@ -352,13 +411,14 @@ def reencode_video(
352
  ]
353
  cmd.append(output_path)
354
 
355
- # ── Executa com Popen e lΓͺ o progresso em tempo real ───────
356
  yield None, "⏳ Iniciando codificaΓ§Γ£o..."
357
 
358
- process = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True)
 
 
359
 
360
  current_sec = 0.0
361
-
362
  while True:
363
  line = process.stdout.readline()
364
  if not line and process.poll() is not None:
@@ -371,7 +431,10 @@ def reencode_video(
371
  current_sec = int(val) / 1000000
372
  if total_duration > 0:
373
  pct = min(100, int(current_sec / total_duration * 100))
374
- yield None, f"⏳ Codificando... {pct}% ({current_sec:.1f}s de {total_duration:.1f}s)"
 
 
 
375
  else:
376
  yield None, f"⏳ Codificando... {current_sec:.1f}s processados"
377
 
@@ -380,7 +443,7 @@ def reencode_video(
380
  yield None, f"❌ Erro no FFmpeg:\n{err[-1500:]}"
381
  return
382
 
383
- # ── RelatΓ³rio Final ─────────────────────────────────────────
384
  orig_mb = os.path.getsize(input_path) / (1024 * 1024)
385
  final_mb = os.path.getsize(output_path) / (1024 * 1024)
386
  reducao = round((1 - final_mb / orig_mb) * 100, 1)
@@ -390,8 +453,8 @@ def reencode_video(
390
  extras.append("pipeline GPU completo (decode+encode na VRAM)")
391
  elif use_gpu:
392
  extras.append("encode NVENC / decode+filtros na CPU")
393
- if normalizar_audio and has_audio:
394
- extras.append("Γ‘udio normalizado")
395
  elif not has_audio:
396
  extras.append("⚠️ sem faixa de Γ‘udio")
397
  if remover_duplicados:
@@ -400,13 +463,23 @@ def reencode_video(
400
  if frames_in and frames_out:
401
  removidos = frames_in - frames_out
402
  pct_rem = round(removidos / frames_in * 100, 1)
403
- extras.append(f"mpdecimate: {removidos} frames removidos ({pct_rem}% do total)")
404
  else:
405
  extras.append("mpdecimate ativo")
406
  if tem_logo:
407
- extras.append(f"logo: {logo_posicao.lower()} | tamanho {logo_tamanho}% | transparΓͺncia {logo_transparencia}%")
 
 
 
408
  if gpu_fallback:
409
- extras.append("⚠️ GPU indisponΓ­vel β†’ usou CPU")
 
 
 
 
 
 
 
410
 
411
  yield output_path, (
412
  f"βœ… ConcluΓ­do!\n"
@@ -415,133 +488,199 @@ def reencode_video(
415
  f"ReduΓ§Γ£o : {reducao}%\n"
416
  f"FPS : {fps}\n"
417
  f"CRF/CQ : {crf_valor}\n"
418
- f"Codec : {'h264_nvenc' if 'x264' in modo and full_gpu_pipeline else 'hevc_nvenc' if full_gpu_pipeline else 'h264_nvenc' if 'x264' in modo and use_gpu else 'hevc_nvenc' if use_gpu else 'libx264' if 'x264' in modo else 'libx265'}\n"
 
 
419
  f"Extras : {' | '.join(extras) if extras else 'nenhum'}"
420
  )
421
 
422
 
423
- # ── Interface ───────────────────────────────────────────────────
424
- with gr.Blocks(title="Super Re-Encoder") as demo:
425
- gr.Markdown("# πŸŽ₯ **Super Re-Encoder**")
426
- gr.Markdown("Suba o vΓ­deo, configure as opΓ§Γ΅es e clique em Re-Encode.")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
427
 
428
- with gr.Row():
429
- video = gr.Video(label="πŸ“€ Seu vΓ­deo", sources=["upload"], height=300)
430
-
431
- with gr.Column():
432
- modo = gr.Radio(
433
- choices=["x264 GPU (mΓ‘x T4)", "x265 GPU (mΓ‘x T4)",
434
- "x264 CPU only", "x265 CPU only"],
435
- value="x264 CPU only",
436
- label="πŸ”₯ Codec / Modo"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
437
  )
438
- resolucao = gr.Dropdown(
439
- choices=[
440
- "Original",
441
- "360p (640x360)",
442
- "480p (854x480)",
443
- "540p (960x540)",
444
- "720p (1280x720)",
445
- "1080p (1920x1080)",
446
- "1440p (2560x1440)",
447
- "4K (3840x2160)",
448
- ],
449
- value="Original",
450
- label="πŸ“ ResoluΓ§Γ£o"
451
  )
452
- fps = gr.Dropdown(
453
- choices=[15, 20, 24, 25, 30, 48, 60],
454
- value=24,
455
- label="🎞️ FPS alvo"
 
456
  )
457
- crf = gr.Slider(
458
- minimum=0, maximum=51, value=24, step=1,
459
- label="🎯 CRF / CQ (Menor = Mais qualidade / Arquivo maior)"
 
460
  )
 
 
 
 
 
 
 
 
 
461
 
462
- with gr.Row():
463
- normalizar_audio = gr.Checkbox(value=True,
464
- label="πŸ”Š Normalizar Γ‘udio (loudnorm -23 LUFS)")
465
- remover_duplicados = gr.Checkbox(value=True,
466
- label="πŸ—‘οΈ Remover frames duplicados (mpdecimate β€” desative para pipeline GPU completo)")
467
-
468
- gr.Markdown("---")
469
- gr.Markdown("### πŸ–ΌοΈ Logo (opcional)")
470
-
471
- with gr.Row():
472
- with gr.Column(scale=1):
473
- logo_file = gr.Image(
474
- type="filepath",
475
- label="πŸ“ Envie o logo (PNG ou JPG com fundo transparente recomendado)",
476
- )
477
- # Preview ao vivo da logo sobre o fundo simulado
478
- logo_preview = gr.Image(
479
- label="πŸ‘οΈ Preview da posiΓ§Γ£o do logo",
480
- interactive=False,
481
- height=200,
482
- )
483
 
484
- with gr.Column(scale=1):
485
- logo_posicao = gr.Radio(
486
- choices=[
487
- "Canto inferior direito",
488
- "Canto inferior esquerdo",
489
- "Canto superior direito",
490
- "Canto superior esquerdo",
491
- "Centro",
492
- ],
493
- value="Canto inferior direito",
494
- label="πŸ“ PosiΓ§Γ£o base"
495
- )
496
- logo_margem = gr.Slider(
497
- minimum=0, maximum=300, value=20, step=5,
498
- label="πŸ“ Margem das bordas (px)"
499
- )
500
- with gr.Row():
501
- logo_offset_x = gr.Slider(
502
- minimum=-200, maximum=200, value=0, step=5,
503
- label="↔️ Ajuste fino X (px)"
504
  )
505
- logo_offset_y = gr.Slider(
506
- minimum=-200, maximum=200, value=0, step=5,
507
- label="↕️ Ajuste fino Y (px)"
 
508
  )
509
- logo_tamanho = gr.Slider(
510
- minimum=5, maximum=50, value=15, step=5,
511
- label="πŸ“ Tamanho (% da largura do vΓ­deo)"
512
- )
513
- logo_transparencia = gr.Dropdown(
514
- choices=list(range(0, 110, 10)),
515
- value=30,
516
- label="πŸ‘» TransparΓͺncia (0% = opaco, 100% = invisΓ­vel)"
517
- )
518
-
519
- # Dispara o preview ao vivo sempre que qualquer controle de logo mudar
520
- _preview_inputs = [logo_file, logo_posicao, logo_margem, logo_offset_x, logo_offset_y, logo_tamanho, logo_transparencia]
521
- _preview_outputs = [logo_preview]
522
-
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
523
  for ctrl in _preview_inputs:
524
  ctrl.change(
525
  fn=gerar_preview_logo,
526
  inputs=_preview_inputs,
527
- outputs=_preview_outputs,
528
  )
529
 
530
- btn = gr.Button("πŸš€ RE-ENCODE AGORA", variant="primary", size="large")
 
 
531
 
532
  with gr.Row():
533
- out_video = gr.Video(label="πŸ“₯ VΓ­deo final")
534
- status = gr.Textbox(label="πŸ“‹ RelatΓ³rio", lines=10)
535
 
536
  btn.click(
537
  reencode_video,
538
  inputs=[
539
  video, modo, resolucao, fps, crf,
540
- normalizar_audio, remover_duplicados,
541
- logo_file, logo_posicao, logo_margem, logo_offset_x, logo_offset_y, logo_tamanho, logo_transparencia
 
 
 
542
  ],
543
- outputs=[out_video, status]
544
  )
545
 
546
  demo.queue(max_size=5)
547
- demo.launch(server_name="0.0.0.0", server_port=7860, theme=gr.themes.Soft())
 
7
  from PIL import Image, ImageDraw
8
 
9
 
10
+ # ══════════════════════════════════════════════════════════════════
11
+ # Utilidades
12
+ # ══════════════════════════════════════════════════════════════════
13
+
14
  def gpu_disponivel():
15
  """Verifica se a GPU NVIDIA estΓ‘ acessΓ­vel via nvidia-smi."""
16
  r = subprocess.run(["nvidia-smi"], capture_output=True, text=True)
 
18
 
19
 
20
  def contar_frames(path):
21
+ """Conta frames via nb_read_packets β€” mais confiΓ‘vel que nb_frames."""
 
 
 
22
  r = subprocess.run([
23
  "ffprobe", "-v", "error",
24
  "-select_streams", "v:0",
 
33
  return None
34
 
35
 
36
+ # ══════════════════════════════════════════════════════════════════
37
+ # Logo β€” preparaΓ§Γ£o, posiΓ§Γ£o e preview ao vivo
38
+ # ══════════════════════════════════════════════════════════════════
39
+
40
  def preparar_logo(logo_path, vid_w, tamanho_pct, transparencia_pct):
41
  """
42
+ Abre o logo, redimensiona para tamanho_pct% da largura do vΓ­deo,
43
+ aplica transparencia_pct% de transparΓͺncia (0 = opaco, 100 = invisΓ­vel)
44
+ e LIMPA o RGB das Γ‘reas totalmente transparentes para eliminar
45
+ halos coloridos (verde / magenta) no overlay YUV do FFmpeg.
46
+ Retorna (caminho, largura, altura).
47
  """
48
+ img = Image.open(logo_path)
49
+ if img.mode != "RGBA":
50
+ img = img.convert("RGBA")
51
 
52
+ # Redimensiona proporcional Γ  largura alvo
53
  alvo_w = max(1, int(vid_w * tamanho_pct / 100))
54
  ratio = alvo_w / img.width
55
  alvo_h = max(1, int(img.height * ratio))
56
  img = img.resize((alvo_w, alvo_h), Image.LANCZOS)
57
 
58
+ # Separa canais
59
+ r_ch, g_ch, b_ch, a_ch = img.split()
60
+
61
+ # Aplica transparΓͺncia global (straight alpha)
62
  if transparencia_pct > 0:
 
63
  fator = 1.0 - (transparencia_pct / 100.0)
64
  a_ch = a_ch.point(lambda x: int(x * fator))
65
+
66
+ # Zera RGB nas Γ‘reas TOTALMENTE transparentes (alpha == 0)
67
+ # Isso elimina o tingimento verde/magenta que aparece quando o
68
+ # overlay converte para yuv420p com chroma subsampling.
69
+ mask_transp = a_ch.point(lambda v: 0 if v == 0 else 255)
70
+ empty = Image.new("L", img.size, 0)
71
+ r_ch = Image.composite(r_ch, empty, mask_transp)
72
+ g_ch = Image.composite(g_ch, empty, mask_transp)
73
+ b_ch = Image.composite(b_ch, empty, mask_transp)
74
+
75
+ img = Image.merge("RGBA", (r_ch, g_ch, b_ch, a_ch))
76
 
77
  temp_path = "/tmp/logo_overlay.png"
78
+ img.save(temp_path, "PNG", optimize=False)
79
  return temp_path, alvo_w, alvo_h
80
 
81
 
82
  def calcular_posicao_logo(posicao, margem=20, offset_x=0, offset_y=0):
83
+ """Retorna a expressΓ£o x=...:y=... para o filtro overlay do FFmpeg."""
 
 
 
 
84
  ox, oy = offset_x, offset_y
85
  m = margem
86
  return {
 
89
  "Canto superior direito": f"x=W-w-{m}+{ox}:y={m+oy}",
90
  "Canto inferior esquerdo": f"x={m+ox}:y=H-h-{m}+{oy}",
91
  "Canto inferior direito": f"x=W-w-{m}+{ox}:y=H-h-{m}+{oy}",
92
+ }.get(posicao, f"x=(W-w)/2+{ox}:y=(H-h)/2+{oy}")
93
 
94
 
95
+ # ── Preview ao vivo ───────────────────────────────────────────────
96
 
97
+ PREVIEW_W, PREVIEW_H = 640, 360
98
+ MARGEM_PREVIEW = 12
99
 
100
 
101
+ def gerar_preview_logo(logo_file, logo_posicao, logo_margem,
102
+ logo_offset_x, logo_offset_y,
103
+ logo_tamanho, logo_transparencia):
104
+ """Gera um preview PNG mostrando a logo posicionada sobre um fundo simulado."""
 
 
105
  canvas = Image.new("RGBA", (PREVIEW_W, PREVIEW_H), (30, 30, 30, 255))
106
+ draw = ImageDraw.Draw(canvas)
107
 
 
108
  for x in range(0, PREVIEW_W, 40):
109
  draw.line([(x, 0), (x, PREVIEW_H)], fill=(50, 50, 50, 255), width=1)
110
  for y in range(0, PREVIEW_H, 40):
111
  draw.line([(0, y), (PREVIEW_W, y)], fill=(50, 50, 50, 255), width=1)
112
 
 
113
  draw.text((PREVIEW_W // 2 - 60, PREVIEW_H // 2 - 8),
114
  "[ seu vΓ­deo ]", fill=(80, 80, 80, 255))
115
 
116
  if logo_file is None:
 
117
  out = "/tmp/logo_preview.png"
118
+ canvas.convert("RGB").save(out)
119
  return out
120
 
121
  try:
122
+ logo = Image.open(logo_file)
123
+ if logo.mode != "RGBA":
124
+ logo = logo.convert("RGBA")
125
 
 
126
  alvo_w = max(1, int(PREVIEW_W * logo_tamanho / 100))
127
  ratio = alvo_w / logo.width
128
  alvo_h = max(1, int(logo.height * ratio))
129
  logo = logo.resize((alvo_w, alvo_h), Image.LANCZOS)
130
 
131
+ # mesma lΓ³gica de limpeza + transparΓͺncia usada no render final
132
+ r_ch, g_ch, b_ch, a_ch = logo.split()
133
  if logo_transparencia > 0:
 
134
  fator = 1.0 - (logo_transparencia / 100.0)
135
  a_ch = a_ch.point(lambda px: int(px * fator))
136
+ mask_transp = a_ch.point(lambda v: 0 if v == 0 else 255)
137
+ empty = Image.new("L", logo.size, 0)
138
+ r_ch = Image.composite(r_ch, empty, mask_transp)
139
+ g_ch = Image.composite(g_ch, empty, mask_transp)
140
+ b_ch = Image.composite(b_ch, empty, mask_transp)
141
+ logo = Image.merge("RGBA", (r_ch, g_ch, b_ch, a_ch))
142
+
143
+ escala = PREVIEW_W / 1920
144
+ mp = int(logo_margem * escala)
145
+ ox = int(logo_offset_x * escala)
146
+ oy = int(logo_offset_y * escala)
147
  pos_map = {
148
  "Centro": ((PREVIEW_W - alvo_w) // 2 + ox,
149
  (PREVIEW_H - alvo_h) // 2 + oy),
 
154
  PREVIEW_H - alvo_h - mp + oy),
155
  }
156
  px, py = pos_map.get(logo_posicao,
157
+ ((PREVIEW_W - alvo_w) // 2 + ox,
158
+ (PREVIEW_H - alvo_h) // 2 + oy))
159
  px = max(0, min(px, PREVIEW_W - alvo_w))
160
  py = max(0, min(py, PREVIEW_H - alvo_h))
161
 
162
+ canvas.alpha_composite(logo, (px, py))
163
 
164
  except Exception:
165
+ pass
166
 
 
167
  out = "/tmp/logo_preview.png"
168
+ canvas.convert("RGB").save(out)
169
  return out
170
 
171
 
172
+ # ══════════════════════════════════════════════════════════════════
173
+ # Áudio β€” perfis disponΓ­veis
174
+ # ══════════════════════════════════════════════════════════════════
175
+
176
+ # label β†’ (encoder, profile_arg_list)
177
+ AUDIO_CODECS = {
178
+ "libfdk_aac HE-AACv2 (mais eficiente, sΓ³ estΓ©reo)":
179
+ ("libfdk_aac", ["-profile:a", "aac_he_v2"]),
180
+ "libfdk_aac HE-AAC (eficiente, mono ou estΓ©reo)":
181
+ ("libfdk_aac", ["-profile:a", "aac_he"]),
182
+ "libfdk_aac LC (alta qualidade, qualquer bitrate)":
183
+ ("libfdk_aac", ["-profile:a", "aac_low"]),
184
+ "aac (codec nativo do FFmpeg, LC)":
185
+ ("aac", []),
186
+ }
187
+
188
+
189
+ def montar_args_audio(codec_label, bitrate, sample_rate, canais):
190
+ """Monta a lista de argumentos FFmpeg para o codec de Γ‘udio escolhido."""
191
+ encoder, prof = AUDIO_CODECS.get(codec_label, AUDIO_CODECS[
192
+ "libfdk_aac HE-AACv2 (mais eficiente, sΓ³ estΓ©reo)"
193
+ ])
194
+
195
+ # HE-AACv2 exige estΓ©reo β€” forΓ§a se o usuΓ‘rio pediu mono
196
+ if "he_v2" in " ".join(prof) and canais == 1:
197
+ canais = 2
198
+
199
+ args = ["-c:a", encoder] + prof + [
200
+ "-b:a", bitrate,
201
+ "-ar", str(sample_rate),
202
+ "-ac", str(canais),
203
+ ]
204
+ return args
205
+
206
+
207
+ # ══════════════════════════════════════════════════════════════════
208
+ # Pipeline principal
209
+ # ══════════════════════════════════════════════════════════════════
210
+
211
  def reencode_video(
212
  video_file, modo, resolucao, fps, crf_valor,
213
+ # Γ‘udio
214
+ audio_codec_label, audio_bitrate, audio_sample_rate, audio_canais,
215
+ normalizar_audio,
216
+ # filtros
217
+ remover_duplicados,
218
+ # logo
219
+ logo_file, logo_posicao, logo_margem,
220
+ logo_offset_x, logo_offset_y, logo_tamanho, logo_transparencia,
221
  ):
222
  if video_file is None:
223
  yield None, "❌ Nenhum vΓ­deo enviado!"
 
227
  output_path = "output_reencoded.mp4"
228
  shutil.copy(video_file, input_path)
229
 
230
+ # ── Detecta dimensΓ£o / orientaΓ§Γ£o ───────────────────────────
231
  probe = subprocess.run([
232
  "ffprobe", "-v", "error",
233
  "-select_streams", "v:0",
 
240
  vid_h = int(stream.get("height", 1080))
241
  is_vertical = vid_h > vid_w
242
 
243
+ # ── DuraΓ§Γ£o ─────────────────────────────────────────────────
244
  probe_dur = subprocess.run([
245
  "ffprobe", "-v", "error",
246
  "-show_entries", "format=duration",
 
249
  dur_data = json.loads(probe_dur.stdout)
250
  total_duration = float(dur_data.get("format", {}).get("duration", 0))
251
 
252
+ # ── Detecta Γ‘udio ───────────────────────────────────────────
253
  probe_audio = subprocess.run([
254
  "ffprobe", "-v", "error",
255
  "-select_streams", "a",
 
258
  ], capture_output=True, text=True)
259
  has_audio = len(json.loads(probe_audio.stdout).get("streams", [])) > 0
260
 
261
+ # ── GPU / logo ──────────────────────────────────────────────
262
  quer_gpu = "GPU" in modo
263
  use_gpu = quer_gpu and gpu_disponivel()
264
  gpu_fallback = quer_gpu and not use_gpu
265
  tem_logo = logo_file is not None
266
 
 
 
 
 
 
267
  full_gpu_pipeline = (
268
  use_gpu and
269
  not remover_duplicados and
 
271
  not tem_logo
272
  )
273
 
274
+ # ── Prepara a logo (Pillow) ─────────────────────────────────
275
  logo_tmp = None
276
  if tem_logo:
277
  try:
278
+ logo_tmp, _, _ = preparar_logo(
279
  logo_file, vid_w, logo_tamanho, logo_transparencia
280
  )
281
  except Exception as e:
282
  yield None, f"❌ Erro ao processar logo: {e}"
283
  return
284
 
285
+ # ── Filtros de vΓ­deo base ───────────────────────────────────
286
  vf_parts = []
287
 
288
  if resolucao != "Original":
 
300
  if remover_duplicados:
301
  vf_parts.append("mpdecimate=hi=1")
302
 
303
+ # ── Monta comando FFmpeg ────────────────────────────────────
304
  cmd = ["ffmpeg", "-hide_banner", "-loglevel", "error", "-progress", "pipe:1", "-y"]
305
 
306
  if full_gpu_pipeline:
307
  cmd += ["-hwaccel", "cuda", "-hwaccel_output_format", "cuda"]
308
 
309
  cmd += ["-i", input_path]
 
310
  if tem_logo:
311
  cmd += ["-i", logo_tmp]
312
 
313
+ # ── filter_complex (overlay anti-halo verde) ────────────────
314
  if tem_logo:
315
+ overlay_pos = calcular_posicao_logo(
316
+ logo_posicao, logo_margem, logo_offset_x, logo_offset_y
317
+ )
318
+ # CorreΓ§Γ£o do "fundo verde semi-transparente":
319
+ # - logo vai para yuva420p (preserva alpha)
320
+ # - overlay usa format=auto e alpha=straight (PNG Γ© straight alpha)
321
+ # - saΓ­da final forΓ§a yuv420p para compatibilidade mΓ‘xima
322
  if vf_parts:
323
  fc = (
324
  f"[0:v]{','.join(vf_parts)}[base];"
325
+ f"[1:v]format=yuva420p[logo];"
326
+ f"[base][logo]overlay={overlay_pos}:format=auto:alpha=straight,"
327
+ f"format=yuv420p[vout]"
328
  )
329
  else:
330
  fc = (
331
+ f"[1:v]format=yuva420p[logo];"
332
+ f"[0:v][logo]overlay={overlay_pos}:format=auto:alpha=straight,"
333
+ f"format=yuv420p[vout]"
334
  )
335
  cmd += ["-filter_complex", fc, "-map", "[vout]"]
336
  if has_audio:
 
344
  elif remover_duplicados and not tem_logo:
345
  cmd += ["-fps_mode", "vfr"]
346
 
347
+ # ── Codec de vΓ­deo ──────────────────────────────────────────
348
  if "x264" in modo:
349
  if use_gpu:
350
  cmd += ["-c:v", "h264_nvenc", "-preset", "p7", "-tune", "hq",
 
363
  "-pix_fmt", "yuv420p",
364
  "-x265-params", "sao=0:rd=6:psy-rd=1.0:psy-rdoq=2.0:rskip=1"]
365
 
366
+ # ── Áudio (loudnorm + codec escolhido) ──────────────────────
367
  af = None
368
  if has_audio and normalizar_audio:
369
+ yield None, "⏳ Analisando volume do Γ‘udio (1Βͺ passada de loudnorm)..."
370
  result_ln = subprocess.run([
371
  "ffmpeg", "-hide_banner", "-loglevel", "info", "-y",
372
  "-i", input_path,
 
377
  raw = result_ln.stderr
378
  start = raw.find("{")
379
  end = raw.rfind("}") + 1
 
380
  if start != -1 and end != 0:
381
  try:
382
+ stats = json.loads(raw[start:end])
383
+ af = (
384
+ f"loudnorm=I=-23:TP=-2:LRA=7:linear=true"
385
+ f":measured_I={stats['input_i']}"
386
+ f":measured_tp={stats['input_tp']}"
387
+ f":measured_LRA={stats['input_lra']}"
388
+ f":measured_thresh={stats['input_thresh']}"
389
+ f":offset={stats['target_offset']}"
390
+ )
 
391
  except (json.JSONDecodeError, KeyError):
392
  normalizar_audio = False
393
  else:
 
395
 
396
  if has_audio:
397
  if normalizar_audio and af:
398
+ cmd += ["-af", af]
399
+ cmd += montar_args_audio(
400
+ audio_codec_label, audio_bitrate, audio_sample_rate, int(audio_canais)
401
+ )
 
 
402
  else:
403
  cmd += ["-an"]
404
 
 
411
  ]
412
  cmd.append(output_path)
413
 
414
+ # ── Executa com progresso em tempo real ─────────────────────
415
  yield None, "⏳ Iniciando codificaΓ§Γ£o..."
416
 
417
+ process = subprocess.Popen(
418
+ cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True
419
+ )
420
 
421
  current_sec = 0.0
 
422
  while True:
423
  line = process.stdout.readline()
424
  if not line and process.poll() is not None:
 
431
  current_sec = int(val) / 1000000
432
  if total_duration > 0:
433
  pct = min(100, int(current_sec / total_duration * 100))
434
+ yield None, (
435
+ f"⏳ Codificando... {pct}% "
436
+ f"({current_sec:.1f}s de {total_duration:.1f}s)"
437
+ )
438
  else:
439
  yield None, f"⏳ Codificando... {current_sec:.1f}s processados"
440
 
 
443
  yield None, f"❌ Erro no FFmpeg:\n{err[-1500:]}"
444
  return
445
 
446
+ # ── RelatΓ³rio final ─────────────────────────────────────────
447
  orig_mb = os.path.getsize(input_path) / (1024 * 1024)
448
  final_mb = os.path.getsize(output_path) / (1024 * 1024)
449
  reducao = round((1 - final_mb / orig_mb) * 100, 1)
 
453
  extras.append("pipeline GPU completo (decode+encode na VRAM)")
454
  elif use_gpu:
455
  extras.append("encode NVENC / decode+filtros na CPU")
456
+ if has_audio and normalizar_audio:
457
+ extras.append("Γ‘udio normalizado (loudnorm -23 LUFS)")
458
  elif not has_audio:
459
  extras.append("⚠️ sem faixa de Γ‘udio")
460
  if remover_duplicados:
 
463
  if frames_in and frames_out:
464
  removidos = frames_in - frames_out
465
  pct_rem = round(removidos / frames_in * 100, 1)
466
+ extras.append(f"mpdecimate: {removidos} frames removidos ({pct_rem}%)")
467
  else:
468
  extras.append("mpdecimate ativo")
469
  if tem_logo:
470
+ extras.append(
471
+ f"logo: {logo_posicao.lower()} | "
472
+ f"tam {logo_tamanho}% | transp {logo_transparencia}%"
473
+ )
474
  if gpu_fallback:
475
+ extras.append("⚠️ GPU indisponΓ­vel β†’ caiu para CPU")
476
+
477
+ codec_v = (
478
+ "h264_nvenc" if "x264" in modo and use_gpu else
479
+ "hevc_nvenc" if use_gpu else
480
+ "libx264" if "x264" in modo else
481
+ "libx265"
482
+ )
483
 
484
  yield output_path, (
485
  f"βœ… ConcluΓ­do!\n"
 
488
  f"ReduΓ§Γ£o : {reducao}%\n"
489
  f"FPS : {fps}\n"
490
  f"CRF/CQ : {crf_valor}\n"
491
+ f"VΓ­deo : {codec_v}\n"
492
+ f"Áudio : {audio_codec_label} β€’ {audio_bitrate} β€’ "
493
+ f"{audio_sample_rate} Hz β€’ {audio_canais} canal(is)\n"
494
  f"Extras : {' | '.join(extras) if extras else 'nenhum'}"
495
  )
496
 
497
 
498
+ # ══════════════════════════════════════════════════════════════════
499
+ # Interface Gradio β€” reorganizada em ordem de fluxo
500
+ # ══════════════════════════════════════���═══════════════════════════
501
+
502
+ CUSTOM_CSS = """
503
+ #app-header { text-align: center; margin: 6px 0 14px 0; }
504
+ #app-header h1 { font-size: 1.9rem; margin: 0; letter-spacing: .5px; }
505
+ #app-header p { color: #8b8b8b; margin: 4px 0 0 0; font-size: 0.95rem; }
506
+ .gr-accordion { border-radius: 12px !important; }
507
+ footer { visibility: hidden; }
508
+ """
509
+
510
+ with gr.Blocks(title="Super Re-Encoder", theme=gr.themes.Soft(),
511
+ css=CUSTOM_CSS) as demo:
512
+
513
+ # ── CabeΓ§alho ───────────────────────────────────────────────
514
+ gr.HTML(
515
+ """
516
+ <div id="app-header">
517
+ <h1>πŸŽ₯ Super Re-Encoder</h1>
518
+ <p>Recodifica vΓ­deos com alta qualidade e tamanho mΓ­nimo β€” com logo opcional.</p>
519
+ </div>
520
+ """
521
+ )
522
 
523
+ # ══════════════ 1) UPLOAD ═════════════════════════════════════
524
+ with gr.Group():
525
+ gr.Markdown("### 1 Β· Envie o vΓ­deo")
526
+ video = gr.Video(label="Arquivo de vΓ­deo", sources=["upload"], height=280)
527
+
528
+ # ══════════════ 2) VÍDEO ═════════════════════════════════════
529
+ with gr.Accordion("2 Β· ConfiguraΓ§Γ΅es de vΓ­deo", open=True):
530
+ with gr.Row():
531
+ with gr.Column():
532
+ modo = gr.Radio(
533
+ choices=["x264 GPU (mΓ‘x T4)", "x265 GPU (mΓ‘x T4)",
534
+ "x264 CPU only", "x265 CPU only"],
535
+ value="x264 CPU only",
536
+ label="Codec / Modo"
537
+ )
538
+ resolucao = gr.Dropdown(
539
+ choices=[
540
+ "Original",
541
+ "360p (640x360)",
542
+ "480p (854x480)",
543
+ "540p (960x540)",
544
+ "720p (1280x720)",
545
+ "1080p (1920x1080)",
546
+ "1440p (2560x1440)",
547
+ "4K (3840x2160)",
548
+ ],
549
+ value="Original",
550
+ label="ResoluΓ§Γ£o de saΓ­da"
551
+ )
552
+ with gr.Column():
553
+ fps = gr.Dropdown(
554
+ choices=[15, 20, 24, 25, 30, 48, 60],
555
+ value=24,
556
+ label="FPS alvo"
557
+ )
558
+ crf = gr.Slider(
559
+ minimum=0, maximum=51, value=24, step=1,
560
+ label="CRF / CQ (menor = mais qualidade, arquivo maior)"
561
+ )
562
+
563
+ # ══════════════ 3) ÁUDIO ═════════════════════════════════════
564
+ with gr.Accordion("3 Β· ConfiguraΓ§Γ΅es de Γ‘udio", open=True):
565
+ with gr.Row():
566
+ audio_codec_label = gr.Dropdown(
567
+ choices=list(AUDIO_CODECS.keys()),
568
+ value="libfdk_aac HE-AACv2 (mais eficiente, sΓ³ estΓ©reo)",
569
+ label="Codec / Perfil"
570
  )
571
+ audio_bitrate = gr.Dropdown(
572
+ choices=["16k", "24k", "32k", "48k", "64k",
573
+ "96k", "128k", "160k", "192k", "256k", "320k"],
574
+ value="32k",
575
+ label="Bitrate"
 
 
 
 
 
 
 
 
576
  )
577
+ with gr.Row():
578
+ audio_sample_rate = gr.Dropdown(
579
+ choices=[22050, 32000, 44100, 48000],
580
+ value=22050,
581
+ label="Sample rate (Hz)"
582
  )
583
+ audio_canais = gr.Dropdown(
584
+ choices=[("EstΓ©reo (2)", 2), ("Mono (1)", 1)],
585
+ value=2,
586
+ label="Canais"
587
  )
588
+ normalizar_audio = gr.Checkbox(
589
+ value=True,
590
+ label="Normalizar volume (loudnorm -23 LUFS, 2 passadas)"
591
+ )
592
+ gr.Markdown(
593
+ "> **Dica:** *HE-AACv2 32k @ 22050 Hz estΓ©reo* Γ© o preset mais "
594
+ "econΓ΄mico para voz/podcast. Para mΓΊsica, use *LC* em 128–192k.",
595
+ elem_id="audio-hint"
596
+ )
597
 
598
+ # ══════════════ 4) FILTROS EXTRAS ═════════════════════════════
599
+ with gr.Accordion("4 Β· Filtros extras", open=False):
600
+ remover_duplicados = gr.Checkbox(
601
+ value=True,
602
+ label="Remover frames duplicados (mpdecimate) β€” desative "
603
+ "para usar o pipeline GPU completo"
604
+ )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
605
 
606
+ # ══════════════ 5) LOGO ══════════════════════════════════════
607
+ with gr.Accordion("5 Β· Logo / marca d'Γ‘gua (opcional)", open=False):
608
+ with gr.Row():
609
+ with gr.Column(scale=1):
610
+ logo_file = gr.Image(
611
+ type="filepath",
612
+ label="PNG com fundo transparente (recomendado)"
 
 
 
 
 
 
 
 
 
 
 
 
 
613
  )
614
+ logo_preview = gr.Image(
615
+ label="Preview ao vivo da posiΓ§Γ£o",
616
+ interactive=False,
617
+ height=220
618
  )
619
+ with gr.Column(scale=1):
620
+ logo_posicao = gr.Radio(
621
+ choices=[
622
+ "Centro",
623
+ "Canto superior esquerdo",
624
+ "Canto superior direito",
625
+ "Canto inferior esquerdo",
626
+ "Canto inferior direito",
627
+ ],
628
+ value="Centro",
629
+ label="PosiΓ§Γ£o base"
630
+ )
631
+ logo_tamanho = gr.Slider(
632
+ minimum=5, maximum=50, value=15, step=1,
633
+ label="Tamanho (% da largura do vΓ­deo)"
634
+ )
635
+ logo_transparencia = gr.Slider(
636
+ minimum=0, maximum=100, value=0, step=5,
637
+ label="TransparΓͺncia (0 % = opaco Β· 100 % = invisΓ­vel)"
638
+ )
639
+ logo_margem = gr.Slider(
640
+ minimum=0, maximum=300, value=20, step=5,
641
+ label="Margem das bordas (px)"
642
+ )
643
+ with gr.Row():
644
+ logo_offset_x = gr.Slider(
645
+ minimum=-400, maximum=400, value=0, step=5,
646
+ label="Ajuste X (px)"
647
+ )
648
+ logo_offset_y = gr.Slider(
649
+ minimum=-400, maximum=400, value=0, step=5,
650
+ label="Ajuste Y (px)"
651
+ )
652
+
653
+ # Preview ao vivo reativo a qualquer controle de logo
654
+ _preview_inputs = [logo_file, logo_posicao, logo_margem,
655
+ logo_offset_x, logo_offset_y,
656
+ logo_tamanho, logo_transparencia]
657
  for ctrl in _preview_inputs:
658
  ctrl.change(
659
  fn=gerar_preview_logo,
660
  inputs=_preview_inputs,
661
+ outputs=[logo_preview],
662
  )
663
 
664
+ # ══════════════ 6) AÇÃO + SAÍDA ══════════════════════════════
665
+ gr.Markdown("### 6 Β· Rodar")
666
+ btn = gr.Button("πŸš€ RE-ENCODE AGORA", variant="primary", size="lg")
667
 
668
  with gr.Row():
669
+ out_video = gr.Video(label="VΓ­deo final")
670
+ status = gr.Textbox(label="RelatΓ³rio", lines=12)
671
 
672
  btn.click(
673
  reencode_video,
674
  inputs=[
675
  video, modo, resolucao, fps, crf,
676
+ audio_codec_label, audio_bitrate, audio_sample_rate, audio_canais,
677
+ normalizar_audio,
678
+ remover_duplicados,
679
+ logo_file, logo_posicao, logo_margem,
680
+ logo_offset_x, logo_offset_y, logo_tamanho, logo_transparencia,
681
  ],
682
+ outputs=[out_video, status],
683
  )
684
 
685
  demo.queue(max_size=5)
686
+ demo.launch(server_name="0.0.0.0", server_port=7860)