Spaces:
Running
Running
Update app.py
Browse files
app.py
CHANGED
|
@@ -50,12 +50,7 @@ class Segment:
|
|
| 50 |
# Funções de Timecode
|
| 51 |
# =========================
|
| 52 |
def _tc_to_hmsf(tc: str, fps: int = FPS) -> Tuple[int, int, int, int]:
|
| 53 |
-
"""
|
| 54 |
-
Converte timecode para (hh, mm, ss, ff). Aceita:
|
| 55 |
-
- HH:MM:SS:FF ou HH:MM:SS;FF
|
| 56 |
-
- HH:MM:SS[.,]mmm (milissegundos)
|
| 57 |
-
- H:MM:SS (sem frames)
|
| 58 |
-
"""
|
| 59 |
s = tc.strip()
|
| 60 |
|
| 61 |
# HH:MM:SS:FF ou HH:MM:SS;FF
|
|
@@ -102,17 +97,7 @@ def frames_to_timecode(frames: int, fps: int = FPS) -> str:
|
|
| 102 |
# Parser de Transcrição
|
| 103 |
# =========================
|
| 104 |
def parse_transcript(txt: str) -> List[Segment]:
|
| 105 |
-
"""
|
| 106 |
-
Aceita múltiplos formatos:
|
| 107 |
-
A) Uma linha: 00:00:00:00 - 00:00:10:00 Texto...
|
| 108 |
-
B) Duas linhas: 00:00:00:00 - 00:00:10:00 \n Texto...
|
| 109 |
-
C) SRT/VTT com setas:
|
| 110 |
-
1
|
| 111 |
-
00:00:05,120 --> 00:00:08,300
|
| 112 |
-
Texto linha 1
|
| 113 |
-
Texto linha 2
|
| 114 |
-
[linha em branco]
|
| 115 |
-
"""
|
| 116 |
if not txt or not txt.strip():
|
| 117 |
return []
|
| 118 |
|
|
@@ -135,7 +120,7 @@ def parse_transcript(txt: str) -> List[Segment]:
|
|
| 135 |
i += 1
|
| 136 |
continue
|
| 137 |
|
| 138 |
-
#
|
| 139 |
m = line_range.match(raw)
|
| 140 |
if m:
|
| 141 |
start_tc, end_tc, trailing_text = m.groups()
|
|
@@ -144,7 +129,6 @@ def parse_transcript(txt: str) -> List[Segment]:
|
|
| 144 |
if trailing_text.strip():
|
| 145 |
text_parts.append(trailing_text.strip())
|
| 146 |
else:
|
| 147 |
-
# Texto nas linhas seguintes até linha em branco ou novo bloco
|
| 148 |
j = i + 1
|
| 149 |
while j < len(lines):
|
| 150 |
nxt = lines[j].strip()
|
|
@@ -152,9 +136,9 @@ def parse_transcript(txt: str) -> List[Segment]:
|
|
| 152 |
break
|
| 153 |
if line_range.match(nxt):
|
| 154 |
break
|
| 155 |
-
if re.match(r'^\d+\s*$', nxt):
|
| 156 |
break
|
| 157 |
-
if arrow.search(nxt):
|
| 158 |
break
|
| 159 |
text_parts.append(nxt)
|
| 160 |
j += 1
|
|
@@ -178,9 +162,8 @@ def parse_transcript(txt: str) -> List[Segment]:
|
|
| 178 |
i += 1
|
| 179 |
continue
|
| 180 |
|
| 181 |
-
#
|
| 182 |
if arrow.search(raw) or (i + 1 < len(lines) and arrow.search(lines[i + 1])):
|
| 183 |
-
# Se a linha atual não tem arrow, tente a próxima (muitos SRTs têm um índice numérico antes)
|
| 184 |
line_with_tc = raw if arrow.search(raw) else lines[i + 1]
|
| 185 |
mm = arrow.search(line_with_tc)
|
| 186 |
if mm:
|
|
@@ -191,7 +174,6 @@ def parse_transcript(txt: str) -> List[Segment]:
|
|
| 191 |
nxt = lines[j].strip()
|
| 192 |
if not nxt:
|
| 193 |
break
|
| 194 |
-
# próximo bloco: índice seguido de timecode
|
| 195 |
if re.match(r'^\d+\s*$', nxt) and (j + 1 < len(lines) and arrow.search(lines[j + 1])):
|
| 196 |
break
|
| 197 |
if arrow.search(nxt):
|
|
@@ -215,7 +197,6 @@ def parse_transcript(txt: str) -> List[Segment]:
|
|
| 215 |
except Exception:
|
| 216 |
pass
|
| 217 |
|
| 218 |
-
# Avança o ponteiro para depois do bloco
|
| 219 |
i = j + 1
|
| 220 |
continue
|
| 221 |
|
|
@@ -242,241 +223,410 @@ def parse_manual_timecodes(manual_input: str) -> List[Tuple[str, str]]:
|
|
| 242 |
|
| 243 |
|
| 244 |
# =========================
|
| 245 |
-
# Interpretação do Comando (NLP
|
| 246 |
# =========================
|
| 247 |
@dataclass
|
| 248 |
class CommandSpec:
|
| 249 |
-
total_segments: int
|
| 250 |
-
per_segment_seconds: Optional[int]
|
| 251 |
-
total_minutes: Optional[float]
|
| 252 |
-
start_timecode: Optional[str]
|
| 253 |
-
|
| 254 |
-
|
|
|
|
|
|
|
| 255 |
|
| 256 |
|
| 257 |
def parse_natural_command(text: str) -> CommandSpec:
|
| 258 |
-
"""
|
| 259 |
-
Extrai:
|
| 260 |
-
- quantidade de cortes: "3 cortes", "crie 2"
|
| 261 |
-
- duração por corte: "cortes de 30s", "clipes de 1min", "1 minuto"
|
| 262 |
-
- duração total: "corte de 10 minutos", "15min", "faça 5 minutos"
|
| 263 |
-
- timecode de início: "começando em 00:02:10:00" ou "a partir de 00:02:10,500"
|
| 264 |
-
- palavras-chave: "sobre X", "da parte do X", "tema X", "palavra X"
|
| 265 |
-
- melhores momentos: presença de "melhores momentos"
|
| 266 |
-
Regras:
|
| 267 |
-
- se per_segment_seconds e total_minutes vierem juntos, prioriza per_segment_seconds (mais específico)
|
| 268 |
-
- caso apenas total_minutes: cria 1 corte dessa duração (ou divide pelos 'total_segments' se quantidade também vier)
|
| 269 |
-
"""
|
| 270 |
s = text.strip().lower()
|
| 271 |
-
|
| 272 |
-
#
|
| 273 |
count = 1
|
| 274 |
-
|
| 275 |
-
|
| 276 |
-
|
| 277 |
-
|
| 278 |
-
|
|
|
|
|
|
|
|
|
|
| 279 |
if m:
|
| 280 |
count = max(1, int(m.group(1)))
|
|
|
|
| 281 |
|
| 282 |
-
#
|
| 283 |
per_seg_sec = None
|
| 284 |
-
|
| 285 |
-
|
| 286 |
-
|
| 287 |
-
|
| 288 |
-
|
| 289 |
-
|
|
|
|
|
|
|
| 290 |
if m:
|
| 291 |
per_seg_sec = int(m.group(1))
|
|
|
|
| 292 |
|
| 293 |
-
#
|
| 294 |
if per_seg_sec is None:
|
| 295 |
-
|
| 296 |
-
|
| 297 |
-
|
| 298 |
-
|
| 299 |
-
|
| 300 |
-
|
|
|
|
|
|
|
| 301 |
if m:
|
| 302 |
-
per_seg_sec = int(m.group(1)) * 60
|
|
|
|
| 303 |
|
| 304 |
-
#
|
| 305 |
total_min = None
|
| 306 |
-
|
| 307 |
-
|
| 308 |
-
|
| 309 |
-
|
| 310 |
-
|
| 311 |
-
|
|
|
|
|
|
|
| 312 |
if m:
|
| 313 |
total_min = float(m.group(1))
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 314 |
|
| 315 |
-
#
|
| 316 |
-
m = re.search(r'(?:começando|comecando|a partir de|starting at|start at)\s*(\d{1,2}:\d{2}:\d{2}(?:[:;]\d{2}|[.,]\d{1,3})?)', s)
|
| 317 |
-
start_tc = m.group(1) if m else None
|
| 318 |
-
|
| 319 |
-
# palavras-chave depois de "sobre", "da parte do", "tema", "assunto"
|
| 320 |
kw = []
|
| 321 |
-
|
| 322 |
-
|
| 323 |
-
|
| 324 |
-
|
| 325 |
-
|
| 326 |
-
|
| 327 |
-
|
| 328 |
-
|
| 329 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 330 |
return CommandSpec(
|
| 331 |
total_segments=count,
|
| 332 |
per_segment_seconds=per_seg_sec,
|
| 333 |
total_minutes=total_min,
|
| 334 |
start_timecode=start_tc,
|
|
|
|
| 335 |
keywords=kw,
|
| 336 |
-
use_best_moments=best
|
|
|
|
| 337 |
)
|
| 338 |
|
| 339 |
|
| 340 |
# =========================
|
| 341 |
-
# Utilidades
|
| 342 |
# =========================
|
| 343 |
-
def find_keyword_in_segments(segs: List[Segment], keywords: List[str]) -> int:
|
|
|
|
| 344 |
if not segs or not keywords:
|
| 345 |
-
return 0
|
| 346 |
-
|
|
|
|
|
|
|
|
|
|
| 347 |
for idx, seg in enumerate(segs):
|
| 348 |
text_lower = seg.text.lower()
|
| 349 |
-
score =
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 350 |
if score > best_score:
|
| 351 |
best_idx, best_score = idx, score
|
| 352 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 353 |
|
| 354 |
|
| 355 |
def create_continuous_segment_from(start_frame: int, duration_frames: int, segs_preview: List[Segment]) -> Segment:
|
| 356 |
end_frame = max(start_frame + duration_frames, start_frame + 1)
|
| 357 |
-
|
| 358 |
text_parts = []
|
| 359 |
-
for seg in segs_preview[:
|
| 360 |
-
if seg.text:
|
| 361 |
-
text_parts.append(seg.text[:
|
| 362 |
-
|
|
|
|
|
|
|
| 363 |
return Segment(
|
| 364 |
start_tc=frames_to_timecode(start_frame),
|
| 365 |
end_tc=frames_to_timecode(end_frame),
|
| 366 |
start_f=start_frame,
|
| 367 |
end_f=end_frame,
|
| 368 |
-
text=
|
| 369 |
score=100.0
|
| 370 |
)
|
| 371 |
|
| 372 |
|
| 373 |
-
def process_with_command(
|
| 374 |
-
|
| 375 |
-
command: str,
|
| 376 |
-
use_llm: bool
|
| 377 |
-
) -> List[Segment]:
|
| 378 |
-
"""
|
| 379 |
-
Processa instruções naturais. Funciona com ou sem transcrição:
|
| 380 |
-
- sem transcrição: cria cortes contínuos a partir do timecode (ou 00:00)
|
| 381 |
-
- com transcrição: usa keywords/LLM para achar início e criar cortes
|
| 382 |
-
Regras de duração:
|
| 383 |
-
- se per_segment_seconds for fornecido -> aplica em cada corte
|
| 384 |
-
- do contrário, se total_minutes e total_segments > 1 -> divide igualmente
|
| 385 |
-
- se apenas total_minutes -> 1 corte com essa duração
|
| 386 |
-
- default se nada especificado -> 1 corte de 60s
|
| 387 |
-
"""
|
| 388 |
spec = parse_natural_command(command)
|
| 389 |
|
| 390 |
-
#
|
| 391 |
if spec.per_segment_seconds:
|
| 392 |
per_seg_seconds = spec.per_segment_seconds
|
| 393 |
total_segments = max(1, spec.total_segments)
|
| 394 |
-
elif spec.total_minutes and spec.total_segments and spec.total_segments > 1:
|
| 395 |
-
total_seconds = int(spec.total_minutes * 60)
|
| 396 |
-
total_segments = spec.total_segments
|
| 397 |
-
per_seg_seconds = max(1, total_seconds // total_segments)
|
| 398 |
elif spec.total_minutes:
|
| 399 |
-
|
| 400 |
-
total_segments
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 401 |
else:
|
| 402 |
per_seg_seconds = 60
|
| 403 |
total_segments = max(1, spec.total_segments)
|
| 404 |
|
| 405 |
-
#
|
| 406 |
start_frame = 0
|
|
|
|
|
|
|
|
|
|
|
|
|
| 407 |
if spec.start_timecode:
|
| 408 |
try:
|
| 409 |
start_frame = parse_timecode_to_frames(spec.start_timecode)
|
|
|
|
| 410 |
except Exception:
|
| 411 |
-
|
| 412 |
-
|
| 413 |
-
#
|
| 414 |
-
|
| 415 |
-
|
| 416 |
-
if
|
| 417 |
-
start_idx =
|
| 418 |
-
|
| 419 |
-
|
| 420 |
-
|
| 421 |
-
|
| 422 |
-
|
| 423 |
-
|
| 424 |
-
|
| 425 |
-
|
| 426 |
-
|
| 427 |
-
|
| 428 |
-
|
| 429 |
-
|
| 430 |
-
|
| 431 |
-
|
| 432 |
-
|
| 433 |
-
|
| 434 |
-
|
| 435 |
-
|
| 436 |
-
|
| 437 |
-
|
| 438 |
-
|
| 439 |
-
|
| 440 |
-
|
| 441 |
-
|
| 442 |
-
|
| 443 |
-
|
| 444 |
-
|
| 445 |
-
|
| 446 |
-
|
| 447 |
-
#
|
| 448 |
segments_out: List[Segment] = []
|
| 449 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 450 |
if not segs:
|
| 451 |
-
# Sem transcrição: cortes contínuos a partir do timecode (ou zero)
|
| 452 |
-
base_frame = start_frame
|
| 453 |
for _ in range(total_segments):
|
| 454 |
duration_frames = int(per_seg_seconds * FPS)
|
| 455 |
seg = create_continuous_segment_from(base_frame, duration_frames, [])
|
| 456 |
segments_out.append(seg)
|
| 457 |
base_frame = seg.end_f
|
| 458 |
return segments_out
|
| 459 |
-
|
| 460 |
# Com transcrição
|
| 461 |
-
|
| 462 |
-
if start_idx is not None and 0 <= start_idx < len(segs):
|
| 463 |
-
start_frame = segs[start_idx].start_f
|
| 464 |
-
# Se já havia start_timecode, preserva; se não, usa 0 como fallback
|
| 465 |
-
base_frame = max(0, start_frame)
|
| 466 |
-
|
| 467 |
-
for _ in range(total_segments):
|
| 468 |
duration_frames = int(per_seg_seconds * FPS)
|
| 469 |
-
|
| 470 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 471 |
seg = create_continuous_segment_from(base_frame, duration_frames, seg_preview)
|
| 472 |
segments_out.append(seg)
|
| 473 |
base_frame = seg.end_f
|
| 474 |
-
|
| 475 |
return segments_out
|
| 476 |
|
| 477 |
|
| 478 |
# =========================
|
| 479 |
-
# Modo Automático
|
| 480 |
# =========================
|
| 481 |
def auto_score_segments(
|
| 482 |
segs: List[Segment],
|
|
@@ -487,32 +637,57 @@ def auto_score_segments(
|
|
| 487 |
weight_learn: float,
|
| 488 |
weight_viral: float
|
| 489 |
) -> List[Segment]:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 490 |
for s in segs:
|
| 491 |
score = 0.0
|
| 492 |
text = (s.text or "").lower()
|
| 493 |
-
|
| 494 |
-
|
| 495 |
-
|
| 496 |
-
|
| 497 |
-
|
| 498 |
-
|
| 499 |
-
|
| 500 |
-
|
| 501 |
-
|
| 502 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 503 |
if custom_keywords:
|
| 504 |
for kw in custom_keywords.split(","):
|
| 505 |
-
|
| 506 |
-
|
| 507 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 508 |
s.score = score
|
| 509 |
-
|
| 510 |
segs.sort(key=lambda x: x.score, reverse=True)
|
| 511 |
return segs[:num_segments]
|
| 512 |
|
| 513 |
|
| 514 |
# =========================
|
| 515 |
-
# Edição de XML
|
| 516 |
# =========================
|
| 517 |
def deep_copy_element(elem: ET.Element) -> ET.Element:
|
| 518 |
new = ET.Element(elem.tag, attrib=dict(elem.attrib))
|
|
@@ -537,13 +712,11 @@ def edit_xml(tree: ET.ElementTree, segs: List[Segment]) -> ET.ElementTree:
|
|
| 537 |
v_template = v_track.find("./clipitem")
|
| 538 |
a_template = a_track.find("./clipitem")
|
| 539 |
|
| 540 |
-
# Limpa clips existentes
|
| 541 |
for clip in list(v_track.findall("./clipitem")):
|
| 542 |
v_track.remove(clip)
|
| 543 |
for clip in list(a_track.findall("./clipitem")):
|
| 544 |
a_track.remove(clip)
|
| 545 |
|
| 546 |
-
# Adiciona novos clips
|
| 547 |
timeline_pos = 0
|
| 548 |
for i, seg in enumerate(segs, 1):
|
| 549 |
duration = seg.end_f - seg.start_f
|
|
@@ -623,10 +796,10 @@ def select_segments(
|
|
| 623 |
pass
|
| 624 |
return result
|
| 625 |
|
| 626 |
-
# 2) Parser de transcrição
|
| 627 |
segs = parse_transcript(transcript_txt) if transcript_txt else []
|
| 628 |
|
| 629 |
-
# 3) Linguagem natural
|
| 630 |
if natural_instructions.strip():
|
| 631 |
return process_with_command(segs, natural_instructions, use_llm and LLM_AVAILABLE)
|
| 632 |
|
|
@@ -648,18 +821,19 @@ def process_files(
|
|
| 648 |
weight_emotion, weight_break, weight_learn, weight_viral
|
| 649 |
):
|
| 650 |
if not xml_file:
|
| 651 |
-
return "Envie o XML", None, f"LLM: {LLM_AVAILABLE}"
|
| 652 |
|
| 653 |
try:
|
| 654 |
-
|
|
|
|
| 655 |
transcript = ""
|
| 656 |
manual = parse_manual_timecodes(manual_timecodes)
|
| 657 |
|
| 658 |
if not manual and txt_file:
|
| 659 |
with open(txt_file.name, "r", encoding="utf-8-sig") as f:
|
| 660 |
transcript = f.read()
|
|
|
|
| 661 |
|
| 662 |
-
# Seleciona segmentos
|
| 663 |
segments = select_segments(
|
| 664 |
transcript, use_llm and LLM_AVAILABLE, num_segments,
|
| 665 |
custom_keywords, manual_timecodes, natural_instructions,
|
|
@@ -667,93 +841,171 @@ def process_files(
|
|
| 667 |
)
|
| 668 |
|
| 669 |
if not segments:
|
| 670 |
-
return "Nenhum segmento selecionado", None, f"LLM: {LLM_AVAILABLE}"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 671 |
|
| 672 |
-
# Edita XML
|
| 673 |
tree = ET.parse(xml_file.name)
|
| 674 |
tree = edit_xml(tree, segments)
|
| 675 |
|
| 676 |
-
# Salva
|
| 677 |
basename = os.path.splitext(os.path.basename(xml_file.name))[0]
|
| 678 |
output = os.path.join(OUTPUT_DIR, f"{basename}_EDITADO.xml")
|
| 679 |
tree.write(output, encoding="utf-8", xml_declaration=True)
|
| 680 |
|
| 681 |
-
# Resumo
|
| 682 |
total_sec = sum((s.end_f - s.start_f) / FPS for s in segments)
|
| 683 |
total_min = total_sec / 60.0
|
| 684 |
-
|
| 685 |
-
|
| 686 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 687 |
for i, seg in enumerate(segments, 1):
|
| 688 |
dur_sec = (seg.end_f - seg.start_f) / FPS
|
| 689 |
-
|
| 690 |
-
|
| 691 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 692 |
summary_lines.append(line)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 693 |
summary = "\n".join(summary_lines)
|
| 694 |
-
|
| 695 |
-
|
| 696 |
return summary, output, status
|
| 697 |
|
| 698 |
except Exception as e:
|
| 699 |
import traceback
|
| 700 |
-
traceback.
|
| 701 |
-
|
|
|
|
|
|
|
|
|
|
| 702 |
|
| 703 |
|
| 704 |
# =========================
|
| 705 |
-
# Interface
|
| 706 |
# =========================
|
| 707 |
with gr.Blocks(theme=gr.themes.Soft(), title="Editor XML Premiere") as demo:
|
| 708 |
-
gr.Markdown("# Editor XML Premiere - IA")
|
| 709 |
-
gr.Markdown("
|
|
|
|
|
|
|
|
|
|
| 710 |
|
| 711 |
with gr.Row():
|
| 712 |
-
xml_in = gr.File(label="XML do Premiere", file_types=[".xml"])
|
| 713 |
-
txt_in = gr.File(label="Transcrição (.txt) - opcional", file_types=[".txt"])
|
| 714 |
|
| 715 |
with gr.Row():
|
| 716 |
-
use_llm = gr.Checkbox(
|
| 717 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 718 |
|
| 719 |
-
with gr.Accordion("Comando em linguagem natural", open=True):
|
| 720 |
gr.Markdown("""
|
| 721 |
-
Exemplos:
|
| 722 |
-
|
| 723 |
-
|
| 724 |
-
- "
|
| 725 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 726 |
""")
|
| 727 |
natural_instructions = gr.Textbox(
|
| 728 |
label="Seu comando",
|
| 729 |
-
placeholder='Ex: "Crie 2 cortes de 45s sobre
|
| 730 |
-
lines=
|
| 731 |
)
|
| 732 |
|
| 733 |
-
with gr.Accordion("Minutagens manuais", open=False):
|
|
|
|
| 734 |
manual_timecodes = gr.Textbox(
|
| 735 |
-
label="Timecodes (
|
| 736 |
-
placeholder="00:21:18:09 - 00:31:18:09",
|
| 737 |
-
lines=
|
| 738 |
)
|
| 739 |
|
| 740 |
-
with gr.Accordion("Modo automático (com transcrição)", open=False):
|
| 741 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 742 |
with gr.Row():
|
| 743 |
-
weight_emotion = gr.Slider(0, 5, 2.0, 0.1, label="Peso: emoção")
|
| 744 |
-
weight_break = gr.Slider(0, 5, 1.5, 0.1, label="Peso: quebra")
|
| 745 |
with gr.Row():
|
| 746 |
-
weight_learn = gr.Slider(0, 5, 1.2, 0.1, label="Peso: aprendizado")
|
| 747 |
-
weight_viral = gr.Slider(0, 5, 1.0, 0.1, label="Peso: viral")
|
| 748 |
|
| 749 |
-
btn = gr.Button("Processar", variant="primary", size="lg")
|
| 750 |
|
| 751 |
with gr.Row():
|
| 752 |
with gr.Column(scale=2):
|
| 753 |
-
summary_out = gr.Textbox(label="Resumo", lines=
|
| 754 |
with gr.Column(scale=1):
|
| 755 |
-
status_out = gr.Textbox(label="Status")
|
| 756 |
-
file_out = gr.File(label="Download")
|
| 757 |
|
| 758 |
btn.click(
|
| 759 |
process_files,
|
|
@@ -762,6 +1014,19 @@ Se não fornecer transcrição, os cortes serão contínuos a partir do timecode
|
|
| 762 |
weight_emotion, weight_break, weight_learn, weight_viral],
|
| 763 |
[summary_out, file_out, status_out]
|
| 764 |
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 765 |
|
| 766 |
if __name__ == "__main__":
|
| 767 |
-
demo.launch()
|
|
|
|
| 50 |
# Funções de Timecode
|
| 51 |
# =========================
|
| 52 |
def _tc_to_hmsf(tc: str, fps: int = FPS) -> Tuple[int, int, int, int]:
|
| 53 |
+
"""Converte timecode para (hh, mm, ss, ff)."""
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 54 |
s = tc.strip()
|
| 55 |
|
| 56 |
# HH:MM:SS:FF ou HH:MM:SS;FF
|
|
|
|
| 97 |
# Parser de Transcrição
|
| 98 |
# =========================
|
| 99 |
def parse_transcript(txt: str) -> List[Segment]:
|
| 100 |
+
"""Parser robusto para múltiplos formatos de transcrição."""
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 101 |
if not txt or not txt.strip():
|
| 102 |
return []
|
| 103 |
|
|
|
|
| 120 |
i += 1
|
| 121 |
continue
|
| 122 |
|
| 123 |
+
# Formato com traço
|
| 124 |
m = line_range.match(raw)
|
| 125 |
if m:
|
| 126 |
start_tc, end_tc, trailing_text = m.groups()
|
|
|
|
| 129 |
if trailing_text.strip():
|
| 130 |
text_parts.append(trailing_text.strip())
|
| 131 |
else:
|
|
|
|
| 132 |
j = i + 1
|
| 133 |
while j < len(lines):
|
| 134 |
nxt = lines[j].strip()
|
|
|
|
| 136 |
break
|
| 137 |
if line_range.match(nxt):
|
| 138 |
break
|
| 139 |
+
if re.match(r'^\d+\s*$', nxt):
|
| 140 |
break
|
| 141 |
+
if arrow.search(nxt):
|
| 142 |
break
|
| 143 |
text_parts.append(nxt)
|
| 144 |
j += 1
|
|
|
|
| 162 |
i += 1
|
| 163 |
continue
|
| 164 |
|
| 165 |
+
# Formato SRT/VTT
|
| 166 |
if arrow.search(raw) or (i + 1 < len(lines) and arrow.search(lines[i + 1])):
|
|
|
|
| 167 |
line_with_tc = raw if arrow.search(raw) else lines[i + 1]
|
| 168 |
mm = arrow.search(line_with_tc)
|
| 169 |
if mm:
|
|
|
|
| 174 |
nxt = lines[j].strip()
|
| 175 |
if not nxt:
|
| 176 |
break
|
|
|
|
| 177 |
if re.match(r'^\d+\s*$', nxt) and (j + 1 < len(lines) and arrow.search(lines[j + 1])):
|
| 178 |
break
|
| 179 |
if arrow.search(nxt):
|
|
|
|
| 197 |
except Exception:
|
| 198 |
pass
|
| 199 |
|
|
|
|
| 200 |
i = j + 1
|
| 201 |
continue
|
| 202 |
|
|
|
|
| 223 |
|
| 224 |
|
| 225 |
# =========================
|
| 226 |
+
# Interpretação do Comando (NLP otimizado)
|
| 227 |
# =========================
|
| 228 |
@dataclass
|
| 229 |
class CommandSpec:
|
| 230 |
+
total_segments: int
|
| 231 |
+
per_segment_seconds: Optional[int]
|
| 232 |
+
total_minutes: Optional[float]
|
| 233 |
+
start_timecode: Optional[str]
|
| 234 |
+
end_timecode: Optional[str]
|
| 235 |
+
keywords: List[str]
|
| 236 |
+
use_best_moments: bool
|
| 237 |
+
search_mode: str
|
| 238 |
|
| 239 |
|
| 240 |
def parse_natural_command(text: str) -> CommandSpec:
|
| 241 |
+
"""Parser NLP robusto com múltiplos padrões."""
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 242 |
s = text.strip().lower()
|
| 243 |
+
|
| 244 |
+
# Quantidade
|
| 245 |
count = 1
|
| 246 |
+
patterns = [
|
| 247 |
+
r'(\d+)\s*(?:cortes?|clipes?|segmentos?|trechos?|partes?)',
|
| 248 |
+
r'(?:crie?|faça?|faca|gere?|monte?|extraia?)\s+(\d+)',
|
| 249 |
+
r'quero\s+(\d+)',
|
| 250 |
+
r'preciso\s+(?:de\s+)?(\d+)'
|
| 251 |
+
]
|
| 252 |
+
for pattern in patterns:
|
| 253 |
+
m = re.search(pattern, s)
|
| 254 |
if m:
|
| 255 |
count = max(1, int(m.group(1)))
|
| 256 |
+
break
|
| 257 |
|
| 258 |
+
# Duração em segundos
|
| 259 |
per_seg_sec = None
|
| 260 |
+
patterns_sec = [
|
| 261 |
+
r'(?:cortes?|clipes?|trechos?)\s+de\s+(\d+)\s*(?:segundos?|s\b)',
|
| 262 |
+
r'(\d+)\s*(?:segundos?|s\b)\s+(?:cada|por)',
|
| 263 |
+
r'(?:duração|duracao)\s+(?:de\s+)?(\d+)\s*s\b',
|
| 264 |
+
r'com\s+(\d+)\s*segundos?'
|
| 265 |
+
]
|
| 266 |
+
for pattern in patterns_sec:
|
| 267 |
+
m = re.search(pattern, s)
|
| 268 |
if m:
|
| 269 |
per_seg_sec = int(m.group(1))
|
| 270 |
+
break
|
| 271 |
|
| 272 |
+
# Duração em minutos
|
| 273 |
if per_seg_sec is None:
|
| 274 |
+
patterns_min = [
|
| 275 |
+
r'(?:cortes?|clipes?|trechos?)\s+de\s+(\d+(?:\.\d+)?)\s*(?:minutos?|min\b)',
|
| 276 |
+
r'(\d+(?:\.\d+)?)\s*(?:minutos?|min\b)\s+(?:cada|por)',
|
| 277 |
+
r'(?:duração|duracao)\s+(?:de\s+)?(\d+(?:\.\d+)?)\s*min',
|
| 278 |
+
r'com\s+(\d+(?:\.\d+)?)\s*minutos?'
|
| 279 |
+
]
|
| 280 |
+
for pattern in patterns_min:
|
| 281 |
+
m = re.search(pattern, s)
|
| 282 |
if m:
|
| 283 |
+
per_seg_sec = int(float(m.group(1)) * 60)
|
| 284 |
+
break
|
| 285 |
|
| 286 |
+
# Duração total
|
| 287 |
total_min = None
|
| 288 |
+
patterns_total = [
|
| 289 |
+
r'(?:corte|video|vídeo)\s+(?:de|com)\s+(\d+(?:\.\d+)?)\s*(?:minutos?|min\b)',
|
| 290 |
+
r'(?:totalizando|total\s+de)\s+(\d+(?:\.\d+)?)\s*min',
|
| 291 |
+
r'(?:faça|faca|crie)\s+(\d+(?:\.\d+)?)\s*minutos?',
|
| 292 |
+
r'(\d+(?:\.\d+)?)\s*minutos?\s+no\s+total'
|
| 293 |
+
]
|
| 294 |
+
for pattern in patterns_total:
|
| 295 |
+
m = re.search(pattern, s)
|
| 296 |
if m:
|
| 297 |
total_min = float(m.group(1))
|
| 298 |
+
break
|
| 299 |
+
|
| 300 |
+
# Timecode início
|
| 301 |
+
start_tc = None
|
| 302 |
+
patterns_start = [
|
| 303 |
+
r'(?:começando|comecando|iniciando|a partir de|desde|starting at|from)\s+(?:em\s+|às\s+|as\s+)?(\d{1,2}:\d{2}:\d{2}(?:[:;]\d{2}|[.,]\d{1,3})?)',
|
| 304 |
+
r'(?:do|no)\s+(?:tempo|timecode|tc)\s+(\d{1,2}:\d{2}:\d{2}(?:[:;]\d{2}|[.,]\d{1,3})?)'
|
| 305 |
+
]
|
| 306 |
+
for pattern in patterns_start:
|
| 307 |
+
m = re.search(pattern, s)
|
| 308 |
+
if m:
|
| 309 |
+
start_tc = m.group(1)
|
| 310 |
+
break
|
| 311 |
+
|
| 312 |
+
# Timecode fim
|
| 313 |
+
end_tc = None
|
| 314 |
+
patterns_end = [
|
| 315 |
+
r'(?:até|ate|terminando em|até o|finalizando em)\s+(\d{1,2}:\d{2}:\d{2}(?:[:;]\d{2}|[.,]\d{1,3})?)',
|
| 316 |
+
r'(?:ao|no)\s+(?:tempo|timecode|tc)\s+(\d{1,2}:\d{2}:\d{2}(?:[:;]\d{2}|[.,]\d{1,3})?)'
|
| 317 |
+
]
|
| 318 |
+
for pattern in patterns_end:
|
| 319 |
+
m = re.search(pattern, s)
|
| 320 |
+
if m:
|
| 321 |
+
end_tc = m.group(1)
|
| 322 |
+
break
|
| 323 |
|
| 324 |
+
# Keywords
|
|
|
|
|
|
|
|
|
|
|
|
|
| 325 |
kw = []
|
| 326 |
+
patterns_kw = [
|
| 327 |
+
r'(?:sobre|falando sobre|abordando|tratando de|relacionado a)\s+([^,\.]+)',
|
| 328 |
+
r'(?:da parte|trecho|momento|cena)\s+(?:do|da|dos|das)\s+([^,\.]+)',
|
| 329 |
+
r'(?:tema|assunto|tópico|topico|conteúdo|conteudo)\s+([^,\.]+)',
|
| 330 |
+
r'(?:com|contendo|que menciona?|que fala sobre)\s+([^,\.]+)',
|
| 331 |
+
r'(?:onde|quando|que)\s+(?:fala|menciona|cita|aparece)\s+([^,\.]+)'
|
| 332 |
+
]
|
| 333 |
+
for pattern in patterns_kw:
|
| 334 |
+
m = re.search(pattern, s)
|
| 335 |
+
if m:
|
| 336 |
+
keywords_text = m.group(1)
|
| 337 |
+
keywords_text = re.sub(r'\s+(?:e|ou|,)\s+', ',', keywords_text)
|
| 338 |
+
kw = [k.strip() for k in keywords_text.split(',') if k.strip()]
|
| 339 |
+
stopwords = {'o', 'a', 'os', 'as', 'de', 'do', 'da', 'dos', 'das', 'em', 'no', 'na'}
|
| 340 |
+
kw = [k for k in kw if k.lower() not in stopwords]
|
| 341 |
+
break
|
| 342 |
+
|
| 343 |
+
if not kw:
|
| 344 |
+
for word in ['sobre', 'do', 'da', 'dos', 'das']:
|
| 345 |
+
if word in s:
|
| 346 |
+
idx = s.index(word)
|
| 347 |
+
tail = s[idx + len(word):].strip()
|
| 348 |
+
end_words = ['começando', 'comecando', 'iniciando', 'de', 'com', 'em']
|
| 349 |
+
for ew in end_words:
|
| 350 |
+
if ew in tail:
|
| 351 |
+
tail = tail[:tail.index(ew)]
|
| 352 |
+
if tail:
|
| 353 |
+
kw = [w.strip() for w in tail.split() if len(w.strip()) > 2][:5]
|
| 354 |
+
break
|
| 355 |
+
|
| 356 |
+
# Melhores momentos
|
| 357 |
+
best = bool(re.search(r'melhor(?:es)?\s+momento|mais\s+interessante|destaque|highlight', s))
|
| 358 |
+
|
| 359 |
+
# Modo de busca
|
| 360 |
+
search_mode = 'continuous'
|
| 361 |
+
if best:
|
| 362 |
+
search_mode = 'best_moments'
|
| 363 |
+
elif kw:
|
| 364 |
+
search_mode = 'keyword'
|
| 365 |
+
|
| 366 |
return CommandSpec(
|
| 367 |
total_segments=count,
|
| 368 |
per_segment_seconds=per_seg_sec,
|
| 369 |
total_minutes=total_min,
|
| 370 |
start_timecode=start_tc,
|
| 371 |
+
end_timecode=end_tc,
|
| 372 |
keywords=kw,
|
| 373 |
+
use_best_moments=best,
|
| 374 |
+
search_mode=search_mode
|
| 375 |
)
|
| 376 |
|
| 377 |
|
| 378 |
# =========================
|
| 379 |
+
# Utilidades (melhoradas)
|
| 380 |
# =========================
|
| 381 |
+
def find_keyword_in_segments(segs: List[Segment], keywords: List[str]) -> Tuple[int, float]:
|
| 382 |
+
"""Retorna (índice, score) do melhor match."""
|
| 383 |
if not segs or not keywords:
|
| 384 |
+
return 0, 0.0
|
| 385 |
+
|
| 386 |
+
best_idx, best_score = 0, 0.0
|
| 387 |
+
kw_lower = [kw.lower() for kw in keywords]
|
| 388 |
+
|
| 389 |
for idx, seg in enumerate(segs):
|
| 390 |
text_lower = seg.text.lower()
|
| 391 |
+
score = 0.0
|
| 392 |
+
|
| 393 |
+
for kw in kw_lower:
|
| 394 |
+
if kw in text_lower:
|
| 395 |
+
score += len(kw.split()) * 5.0
|
| 396 |
+
|
| 397 |
+
words = text_lower.split()
|
| 398 |
+
for kw in kw_lower:
|
| 399 |
+
kw_words = kw.split()
|
| 400 |
+
for kw_word in kw_words:
|
| 401 |
+
if len(kw_word) > 2:
|
| 402 |
+
for word in words:
|
| 403 |
+
if kw_word in word or word in kw_word:
|
| 404 |
+
score += 1.0
|
| 405 |
+
|
| 406 |
if score > best_score:
|
| 407 |
best_idx, best_score = idx, score
|
| 408 |
+
|
| 409 |
+
return best_idx, best_score
|
| 410 |
+
|
| 411 |
+
|
| 412 |
+
def find_llm_segment(segs: List[Segment], keywords: List[str], command: str) -> Tuple[Optional[int], float]:
|
| 413 |
+
"""Usa LLM para encontrar segmento. Retorna (índice, confiança)."""
|
| 414 |
+
if not LLM_AVAILABLE or not segs:
|
| 415 |
+
return None, 0.0
|
| 416 |
+
|
| 417 |
+
try:
|
| 418 |
+
preview_lines = []
|
| 419 |
+
for i, s in enumerate(segs[:100]):
|
| 420 |
+
text_preview = (s.text or '')[:120]
|
| 421 |
+
duration_sec = (s.end_f - s.start_f) / FPS
|
| 422 |
+
preview_lines.append(f"{i}|{s.start_tc}|{duration_sec:.1f}s|{text_preview}")
|
| 423 |
+
|
| 424 |
+
preview_text = "\n".join(preview_lines)
|
| 425 |
+
keywords_str = ", ".join(keywords[:10]) if keywords else "não especificado"
|
| 426 |
+
|
| 427 |
+
prompt = f"""Analise os segmentos e retorne APENAS o número do índice onde o conteúdo solicitado começa.
|
| 428 |
+
|
| 429 |
+
IMPORTANTE: Responda SOMENTE com o número do índice (ex: 42). Não explique.
|
| 430 |
+
|
| 431 |
+
COMANDO DO USUÁRIO: {command}
|
| 432 |
+
|
| 433 |
+
PALAVRAS-CHAVE: {keywords_str}
|
| 434 |
+
|
| 435 |
+
SEGMENTOS (formato: índice|timecode|duração|texto):
|
| 436 |
+
{preview_text}
|
| 437 |
+
|
| 438 |
+
Qual índice melhor corresponde ao início do conteúdo solicitado?
|
| 439 |
+
Responda apenas o número:"""
|
| 440 |
+
|
| 441 |
+
response = LLM.generate_content(
|
| 442 |
+
prompt,
|
| 443 |
+
generation_config={
|
| 444 |
+
"temperature": 0.1,
|
| 445 |
+
"max_output_tokens": 30,
|
| 446 |
+
"top_p": 0.8
|
| 447 |
+
}
|
| 448 |
+
)
|
| 449 |
+
|
| 450 |
+
text = (response.text or "").strip()
|
| 451 |
+
|
| 452 |
+
patterns = [
|
| 453 |
+
r'^\s*(\d+)\s*$',
|
| 454 |
+
r'(?:índice|index|segmento)\s*(\d+)',
|
| 455 |
+
r'(?:número|numero|#)\s*(\d+)',
|
| 456 |
+
r'\b(\d+)\b'
|
| 457 |
+
]
|
| 458 |
+
|
| 459 |
+
for pattern in patterns:
|
| 460 |
+
m = re.search(pattern, text, re.IGNORECASE)
|
| 461 |
+
if m:
|
| 462 |
+
idx = int(m.group(1))
|
| 463 |
+
if 0 <= idx < len(segs):
|
| 464 |
+
confidence = 0.9 if pattern == patterns[0] else 0.7
|
| 465 |
+
return idx, confidence
|
| 466 |
+
|
| 467 |
+
return None, 0.0
|
| 468 |
+
|
| 469 |
+
except Exception as e:
|
| 470 |
+
print(f"Erro no LLM: {e}")
|
| 471 |
+
return None, 0.0
|
| 472 |
|
| 473 |
|
| 474 |
def create_continuous_segment_from(start_frame: int, duration_frames: int, segs_preview: List[Segment]) -> Segment:
|
| 475 |
end_frame = max(start_frame + duration_frames, start_frame + 1)
|
| 476 |
+
|
| 477 |
text_parts = []
|
| 478 |
+
for seg in segs_preview[:15]:
|
| 479 |
+
if seg.text and len(seg.text.strip()) > 5:
|
| 480 |
+
text_parts.append(seg.text[:100])
|
| 481 |
+
|
| 482 |
+
combined = " [...] ".join(text_parts)[:400] if text_parts else ""
|
| 483 |
+
|
| 484 |
return Segment(
|
| 485 |
start_tc=frames_to_timecode(start_frame),
|
| 486 |
end_tc=frames_to_timecode(end_frame),
|
| 487 |
start_f=start_frame,
|
| 488 |
end_f=end_frame,
|
| 489 |
+
text=combined if combined else f"Corte contínuo de {duration_frames/FPS:.1f}s",
|
| 490 |
score=100.0
|
| 491 |
)
|
| 492 |
|
| 493 |
|
| 494 |
+
def process_with_command(segs: List[Segment], command: str, use_llm: bool) -> List[Segment]:
|
| 495 |
+
"""Processa instruções naturais com sistema multi-camadas."""
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 496 |
spec = parse_natural_command(command)
|
| 497 |
|
| 498 |
+
# Calcula duração
|
| 499 |
if spec.per_segment_seconds:
|
| 500 |
per_seg_seconds = spec.per_segment_seconds
|
| 501 |
total_segments = max(1, spec.total_segments)
|
|
|
|
|
|
|
|
|
|
|
|
|
| 502 |
elif spec.total_minutes:
|
| 503 |
+
total_seconds = int(spec.total_minutes * 60)
|
| 504 |
+
if spec.total_segments > 1:
|
| 505 |
+
per_seg_seconds = max(5, total_seconds // spec.total_segments)
|
| 506 |
+
total_segments = spec.total_segments
|
| 507 |
+
else:
|
| 508 |
+
per_seg_seconds = total_seconds
|
| 509 |
+
total_segments = 1
|
| 510 |
else:
|
| 511 |
per_seg_seconds = 60
|
| 512 |
total_segments = max(1, spec.total_segments)
|
| 513 |
|
| 514 |
+
# Determina início com fallback
|
| 515 |
start_frame = 0
|
| 516 |
+
start_idx = None
|
| 517 |
+
search_confidence = 0.0
|
| 518 |
+
|
| 519 |
+
# Timecode explícito
|
| 520 |
if spec.start_timecode:
|
| 521 |
try:
|
| 522 |
start_frame = parse_timecode_to_frames(spec.start_timecode)
|
| 523 |
+
search_confidence = 1.0
|
| 524 |
except Exception:
|
| 525 |
+
pass
|
| 526 |
+
|
| 527 |
+
# LLM
|
| 528 |
+
if search_confidence < 0.8 and use_llm and segs and (spec.keywords or spec.search_mode == 'llm'):
|
| 529 |
+
llm_idx, llm_conf = find_llm_segment(segs, spec.keywords, command)
|
| 530 |
+
if llm_idx is not None and llm_conf > search_confidence:
|
| 531 |
+
start_idx = llm_idx
|
| 532 |
+
start_frame = segs[start_idx].start_f
|
| 533 |
+
search_confidence = llm_conf
|
| 534 |
+
|
| 535 |
+
# Keywords
|
| 536 |
+
if search_confidence < 0.6 and segs and spec.keywords:
|
| 537 |
+
kw_idx, kw_score = find_keyword_in_segments(segs, spec.keywords)
|
| 538 |
+
kw_conf = min(0.9, kw_score / 10.0)
|
| 539 |
+
if kw_conf > search_confidence:
|
| 540 |
+
start_idx = kw_idx
|
| 541 |
+
start_frame = segs[start_idx].start_f
|
| 542 |
+
search_confidence = kw_conf
|
| 543 |
+
|
| 544 |
+
# Melhores momentos
|
| 545 |
+
if spec.use_best_moments and segs:
|
| 546 |
+
scored = [(i, s) for i, s in enumerate(segs) if s.score > 0]
|
| 547 |
+
if scored:
|
| 548 |
+
scored.sort(key=lambda x: x[1].score, reverse=True)
|
| 549 |
+
start_idx = scored[0][0]
|
| 550 |
+
start_frame = segs[start_idx].start_f
|
| 551 |
+
search_confidence = 0.8
|
| 552 |
+
|
| 553 |
+
# Determina fim
|
| 554 |
+
end_frame = None
|
| 555 |
+
if spec.end_timecode:
|
| 556 |
+
try:
|
| 557 |
+
end_frame = parse_timecode_to_frames(spec.end_timecode)
|
| 558 |
+
except Exception:
|
| 559 |
+
pass
|
| 560 |
+
|
| 561 |
+
# Construção dos cortes
|
| 562 |
segments_out: List[Segment] = []
|
| 563 |
+
|
| 564 |
+
# Intervalo específico
|
| 565 |
+
if end_frame and end_frame > start_frame:
|
| 566 |
+
duration_frames = end_frame - start_frame
|
| 567 |
+
if total_segments == 1:
|
| 568 |
+
seg_preview = []
|
| 569 |
+
if segs and start_idx is not None:
|
| 570 |
+
seg_preview = segs[start_idx:start_idx + 20]
|
| 571 |
+
seg = create_continuous_segment_from(start_frame, duration_frames, seg_preview)
|
| 572 |
+
segments_out.append(seg)
|
| 573 |
+
else:
|
| 574 |
+
frames_per_seg = duration_frames // total_segments
|
| 575 |
+
base = start_frame
|
| 576 |
+
for i in range(total_segments):
|
| 577 |
+
seg_preview = []
|
| 578 |
+
if segs and start_idx is not None:
|
| 579 |
+
seg_preview = segs[start_idx + i:start_idx + i + 10]
|
| 580 |
+
seg = create_continuous_segment_from(base, frames_per_seg, seg_preview)
|
| 581 |
+
segments_out.append(seg)
|
| 582 |
+
base = seg.end_f
|
| 583 |
+
return segments_out
|
| 584 |
+
|
| 585 |
+
# Cortes sequenciais
|
| 586 |
+
base_frame = start_frame
|
| 587 |
+
|
| 588 |
if not segs:
|
|
|
|
|
|
|
| 589 |
for _ in range(total_segments):
|
| 590 |
duration_frames = int(per_seg_seconds * FPS)
|
| 591 |
seg = create_continuous_segment_from(base_frame, duration_frames, [])
|
| 592 |
segments_out.append(seg)
|
| 593 |
base_frame = seg.end_f
|
| 594 |
return segments_out
|
| 595 |
+
|
| 596 |
# Com transcrição
|
| 597 |
+
for i in range(total_segments):
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 598 |
duration_frames = int(per_seg_seconds * FPS)
|
| 599 |
+
|
| 600 |
+
seg_start_idx = None
|
| 601 |
+
if start_idx is not None:
|
| 602 |
+
for idx in range(start_idx, len(segs)):
|
| 603 |
+
if segs[idx].start_f >= base_frame:
|
| 604 |
+
seg_start_idx = idx
|
| 605 |
+
break
|
| 606 |
+
else:
|
| 607 |
+
for idx, s in enumerate(segs):
|
| 608 |
+
if s.start_f >= base_frame:
|
| 609 |
+
seg_start_idx = idx
|
| 610 |
+
break
|
| 611 |
+
|
| 612 |
+
seg_preview = []
|
| 613 |
+
if seg_start_idx is not None:
|
| 614 |
+
end_of_cut = base_frame + duration_frames
|
| 615 |
+
for s in segs[seg_start_idx:]:
|
| 616 |
+
if s.start_f < end_of_cut:
|
| 617 |
+
seg_preview.append(s)
|
| 618 |
+
else:
|
| 619 |
+
break
|
| 620 |
+
|
| 621 |
seg = create_continuous_segment_from(base_frame, duration_frames, seg_preview)
|
| 622 |
segments_out.append(seg)
|
| 623 |
base_frame = seg.end_f
|
| 624 |
+
|
| 625 |
return segments_out
|
| 626 |
|
| 627 |
|
| 628 |
# =========================
|
| 629 |
+
# Modo Automático
|
| 630 |
# =========================
|
| 631 |
def auto_score_segments(
|
| 632 |
segs: List[Segment],
|
|
|
|
| 637 |
weight_learn: float,
|
| 638 |
weight_viral: float
|
| 639 |
) -> List[Segment]:
|
| 640 |
+
"""Sistema de pontuação expandido."""
|
| 641 |
+
emotion_words = ['medo', 'coragem', 'amor', 'ódio', 'paixão', 'alegria', 'tristeza',
|
| 642 |
+
'ansiedade', 'felicidade', 'emoção', 'sentimento', 'coração']
|
| 643 |
+
break_words = ['nunca', 'de repente', 'surpreendente', 'inesperado', 'incrível',
|
| 644 |
+
'chocante', 'virada', 'mudança', 'momento', 'aconteceu']
|
| 645 |
+
learn_words = ['aprendi', 'descobri', 'entendi', 'percebi', 'compreendi', 'lição',
|
| 646 |
+
'ensinamento', 'experiência', 'conhecimento', 'insight']
|
| 647 |
+
viral_words = ['segredo', 'verdade', 'ninguém sabe', 'revelação', 'exclusivo',
|
| 648 |
+
'primeira vez', 'confissão', 'polêmica', 'controverso']
|
| 649 |
+
|
| 650 |
for s in segs:
|
| 651 |
score = 0.0
|
| 652 |
text = (s.text or "").lower()
|
| 653 |
+
|
| 654 |
+
for word in emotion_words:
|
| 655 |
+
if word in text:
|
| 656 |
+
score += weight_emotion
|
| 657 |
+
|
| 658 |
+
for word in break_words:
|
| 659 |
+
if word in text:
|
| 660 |
+
score += weight_break
|
| 661 |
+
|
| 662 |
+
for word in learn_words:
|
| 663 |
+
if word in text:
|
| 664 |
+
score += weight_learn
|
| 665 |
+
|
| 666 |
+
for word in viral_words:
|
| 667 |
+
if word in text:
|
| 668 |
+
score += weight_viral
|
| 669 |
+
|
| 670 |
if custom_keywords:
|
| 671 |
for kw in custom_keywords.split(","):
|
| 672 |
+
kw_clean = kw.strip().lower()
|
| 673 |
+
if kw_clean and kw_clean in text:
|
| 674 |
+
score += 3.0 * len(kw_clean.split())
|
| 675 |
+
|
| 676 |
+
duration_sec = (s.end_f - s.start_f) / FPS
|
| 677 |
+
if 10 <= duration_sec <= 120:
|
| 678 |
+
score += 0.5
|
| 679 |
+
|
| 680 |
+
if len(text) > 100:
|
| 681 |
+
score += 0.3
|
| 682 |
+
|
| 683 |
s.score = score
|
| 684 |
+
|
| 685 |
segs.sort(key=lambda x: x.score, reverse=True)
|
| 686 |
return segs[:num_segments]
|
| 687 |
|
| 688 |
|
| 689 |
# =========================
|
| 690 |
+
# Edição de XML
|
| 691 |
# =========================
|
| 692 |
def deep_copy_element(elem: ET.Element) -> ET.Element:
|
| 693 |
new = ET.Element(elem.tag, attrib=dict(elem.attrib))
|
|
|
|
| 712 |
v_template = v_track.find("./clipitem")
|
| 713 |
a_template = a_track.find("./clipitem")
|
| 714 |
|
|
|
|
| 715 |
for clip in list(v_track.findall("./clipitem")):
|
| 716 |
v_track.remove(clip)
|
| 717 |
for clip in list(a_track.findall("./clipitem")):
|
| 718 |
a_track.remove(clip)
|
| 719 |
|
|
|
|
| 720 |
timeline_pos = 0
|
| 721 |
for i, seg in enumerate(segs, 1):
|
| 722 |
duration = seg.end_f - seg.start_f
|
|
|
|
| 796 |
pass
|
| 797 |
return result
|
| 798 |
|
| 799 |
+
# 2) Parser de transcrição
|
| 800 |
segs = parse_transcript(transcript_txt) if transcript_txt else []
|
| 801 |
|
| 802 |
+
# 3) Linguagem natural
|
| 803 |
if natural_instructions.strip():
|
| 804 |
return process_with_command(segs, natural_instructions, use_llm and LLM_AVAILABLE)
|
| 805 |
|
|
|
|
| 821 |
weight_emotion, weight_break, weight_learn, weight_viral
|
| 822 |
):
|
| 823 |
if not xml_file:
|
| 824 |
+
return "⚠️ Envie o XML do Premiere", None, f"LLM: {'✓' if LLM_AVAILABLE else '✗'}"
|
| 825 |
|
| 826 |
try:
|
| 827 |
+
debug_info = []
|
| 828 |
+
|
| 829 |
transcript = ""
|
| 830 |
manual = parse_manual_timecodes(manual_timecodes)
|
| 831 |
|
| 832 |
if not manual and txt_file:
|
| 833 |
with open(txt_file.name, "r", encoding="utf-8-sig") as f:
|
| 834 |
transcript = f.read()
|
| 835 |
+
debug_info.append(f"📄 Transcrição carregada: {len(transcript)} caracteres")
|
| 836 |
|
|
|
|
| 837 |
segments = select_segments(
|
| 838 |
transcript, use_llm and LLM_AVAILABLE, num_segments,
|
| 839 |
custom_keywords, manual_timecodes, natural_instructions,
|
|
|
|
| 841 |
)
|
| 842 |
|
| 843 |
if not segments:
|
| 844 |
+
return "⚠️ Nenhum segmento selecionado. Verifique os parâmetros.", None, f"LLM: {'✓' if LLM_AVAILABLE else '✗'}"
|
| 845 |
+
|
| 846 |
+
valid_segments = []
|
| 847 |
+
for seg in segments:
|
| 848 |
+
if seg.end_f > seg.start_f and seg.end_f - seg.start_f >= FPS:
|
| 849 |
+
valid_segments.append(seg)
|
| 850 |
+
|
| 851 |
+
if not valid_segments:
|
| 852 |
+
return "⚠️ Segmentos inválidos (duração muito curta). Ajuste os parâmetros.", None, f"LLM: {'✓' if LLM_AVAILABLE else '✗'}"
|
| 853 |
+
|
| 854 |
+
segments = valid_segments
|
| 855 |
+
debug_info.append(f"✓ {len(segments)} segmento(s) válido(s)")
|
| 856 |
|
|
|
|
| 857 |
tree = ET.parse(xml_file.name)
|
| 858 |
tree = edit_xml(tree, segments)
|
| 859 |
|
|
|
|
| 860 |
basename = os.path.splitext(os.path.basename(xml_file.name))[0]
|
| 861 |
output = os.path.join(OUTPUT_DIR, f"{basename}_EDITADO.xml")
|
| 862 |
tree.write(output, encoding="utf-8", xml_declaration=True)
|
| 863 |
|
|
|
|
| 864 |
total_sec = sum((s.end_f - s.start_f) / FPS for s in segments)
|
| 865 |
total_min = total_sec / 60.0
|
| 866 |
+
|
| 867 |
+
if manual:
|
| 868 |
+
mode = "🎯 MANUAL"
|
| 869 |
+
elif natural_instructions.strip():
|
| 870 |
+
spec = parse_natural_command(natural_instructions)
|
| 871 |
+
if spec.keywords:
|
| 872 |
+
mode = f"🤖 IA + BUSCA ({', '.join(spec.keywords[:3])})"
|
| 873 |
+
else:
|
| 874 |
+
mode = "📐 IA + CONTÍNUO"
|
| 875 |
+
else:
|
| 876 |
+
mode = "⚙️ AUTOMÁTICO"
|
| 877 |
+
|
| 878 |
+
summary_lines = [
|
| 879 |
+
"═" * 60,
|
| 880 |
+
f"✨ RESULTADO: {len(segments)} corte(s) | {total_min:.1f} min total",
|
| 881 |
+
f"📊 Modo: {mode}",
|
| 882 |
+
"═" * 60,
|
| 883 |
+
""
|
| 884 |
+
]
|
| 885 |
+
|
| 886 |
for i, seg in enumerate(segments, 1):
|
| 887 |
dur_sec = (seg.end_f - seg.start_f) / FPS
|
| 888 |
+
dur_min = dur_sec / 60.0
|
| 889 |
+
|
| 890 |
+
line = f"🎬 Corte {i}:"
|
| 891 |
+
line += f"\n ⏱️ {seg.start_tc} → {seg.end_tc} ({dur_min:.2f} min)"
|
| 892 |
+
|
| 893 |
+
if seg.text and len(seg.text.strip()) > 10:
|
| 894 |
+
text_preview = seg.text[:150].strip()
|
| 895 |
+
if len(seg.text) > 150:
|
| 896 |
+
text_preview += "..."
|
| 897 |
+
line += f"\n 💬 {text_preview}"
|
| 898 |
+
|
| 899 |
+
if seg.score > 0:
|
| 900 |
+
line += f"\n ⭐ Score: {seg.score:.1f}"
|
| 901 |
+
|
| 902 |
summary_lines.append(line)
|
| 903 |
+
summary_lines.append("")
|
| 904 |
+
|
| 905 |
+
if debug_info:
|
| 906 |
+
summary_lines.append("═" * 60)
|
| 907 |
+
summary_lines.append("🔍 Debug:")
|
| 908 |
+
summary_lines.extend(f" {info}" for info in debug_info)
|
| 909 |
+
|
| 910 |
summary = "\n".join(summary_lines)
|
| 911 |
+
status = f"✅ Sucesso | {mode} | {total_min:.1f} min | LLM: {'✓' if LLM_AVAILABLE else '✗'}"
|
| 912 |
+
|
| 913 |
return summary, output, status
|
| 914 |
|
| 915 |
except Exception as e:
|
| 916 |
import traceback
|
| 917 |
+
error_trace = traceback.format_exc()
|
| 918 |
+
print(error_trace)
|
| 919 |
+
|
| 920 |
+
error_msg = f"❌ Erro: {str(e)}\n\n🔍 Detalhes técnicos:\n{error_trace[:500]}"
|
| 921 |
+
return error_msg, None, f"LLM: {'✓' if LLM_AVAILABLE else '✗'}"
|
| 922 |
|
| 923 |
|
| 924 |
# =========================
|
| 925 |
+
# Interface Gradio
|
| 926 |
# =========================
|
| 927 |
with gr.Blocks(theme=gr.themes.Soft(), title="Editor XML Premiere") as demo:
|
| 928 |
+
gr.Markdown("# 🎬 Editor XML Premiere - IA Avançada")
|
| 929 |
+
gr.Markdown("Sistema inteligente de cortes com IA (Gemini), busca por keywords e timecodes manuais.")
|
| 930 |
+
|
| 931 |
+
status_inicial = f"{'🟢 IA Disponível (Gemini 2.0)' if LLM_AVAILABLE else '🟡 Modo básico (IA desabilitada - configure GEMINI_API_KEY)'}"
|
| 932 |
+
gr.Markdown(f"**Status:** {status_inicial}")
|
| 933 |
|
| 934 |
with gr.Row():
|
| 935 |
+
xml_in = gr.File(label="📄 XML do Premiere", file_types=[".xml"])
|
| 936 |
+
txt_in = gr.File(label="📝 Transcrição (.txt) - opcional", file_types=[".txt"])
|
| 937 |
|
| 938 |
with gr.Row():
|
| 939 |
+
use_llm = gr.Checkbox(
|
| 940 |
+
label="🤖 Usar IA (Gemini) para busca inteligente",
|
| 941 |
+
value=USE_LLM_DEFAULT and LLM_AVAILABLE,
|
| 942 |
+
interactive=LLM_AVAILABLE
|
| 943 |
+
)
|
| 944 |
+
num_segments = gr.Slider(2, 20, 5, 1, label="📊 Segmentos (modo automático)")
|
| 945 |
|
| 946 |
+
with gr.Accordion("💬 Comando em linguagem natural (RECOMENDADO)", open=True):
|
| 947 |
gr.Markdown("""
|
| 948 |
+
**Exemplos de comandos suportados:**
|
| 949 |
+
|
| 950 |
+
📌 **Duração e quantidade:**
|
| 951 |
+
- "Crie 3 cortes de 30 segundos"
|
| 952 |
+
- "Faça 1 corte de 10 minutos"
|
| 953 |
+
- "Quero 5 clipes de 45s cada"
|
| 954 |
+
|
| 955 |
+
📍 **Com timecode:**
|
| 956 |
+
- "2 cortes de 1min começando em 00:02:10:00"
|
| 957 |
+
- "Corte de 5 minutos a partir de 00:05:00:00"
|
| 958 |
+
|
| 959 |
+
🔍 **Com busca de conteúdo (requer transcrição + IA):**
|
| 960 |
+
- "3 cortes de 30s sobre Maria e José"
|
| 961 |
+
- "1 corte de 10 minutos da parte do tenista"
|
| 962 |
+
- "2 clipes de 45s falando sobre coragem"
|
| 963 |
+
- "Corte sobre disciplina começando em 00:02:00"
|
| 964 |
+
|
| 965 |
+
🎯 **Intervalo específico:**
|
| 966 |
+
- "Corte de 00:10:00:00 até 00:15:00:00"
|
| 967 |
+
- "3 segmentos começando em 00:02:00 até 00:05:00"
|
| 968 |
+
|
| 969 |
+
💡 **Dicas:**
|
| 970 |
+
- Com transcrição + IA: busca automática do conteúdo
|
| 971 |
+
- Sem transcrição: cortes contínuos a partir do timecode
|
| 972 |
+
- Seja específico nas durações e palavras-chave
|
| 973 |
""")
|
| 974 |
natural_instructions = gr.Textbox(
|
| 975 |
label="Seu comando",
|
| 976 |
+
placeholder='Ex: "Crie 2 cortes de 45s sobre disciplina, começando em 00:01:00:00"',
|
| 977 |
+
lines=3
|
| 978 |
)
|
| 979 |
|
| 980 |
+
with gr.Accordion("🎯 Minutagens manuais (alta precisão)", open=False):
|
| 981 |
+
gr.Markdown("Use este modo quando souber exatamente os timecodes. Um por linha ou separados por vírgula.")
|
| 982 |
manual_timecodes = gr.Textbox(
|
| 983 |
+
label="Timecodes (formato: HH:MM:SS:FF - HH:MM:SS:FF)",
|
| 984 |
+
placeholder="00:21:18:09 - 00:31:18:09\n00:45:20:15 - 00:50:10:22",
|
| 985 |
+
lines=4
|
| 986 |
)
|
| 987 |
|
| 988 |
+
with gr.Accordion("⚙️ Modo automático (com transcrição)", open=False):
|
| 989 |
+
gr.Markdown("Sistema de pontuação automática baseado em palavras-chave e pesos.")
|
| 990 |
+
custom_keywords = gr.Textbox(
|
| 991 |
+
label="Palavras-chave personalizadas (separadas por vírgula)",
|
| 992 |
+
placeholder="coragem, superação, vitória"
|
| 993 |
+
)
|
| 994 |
with gr.Row():
|
| 995 |
+
weight_emotion = gr.Slider(0, 5, 2.0, 0.1, label="⚡ Peso: emoção")
|
| 996 |
+
weight_break = gr.Slider(0, 5, 1.5, 0.1, label="💥 Peso: quebra")
|
| 997 |
with gr.Row():
|
| 998 |
+
weight_learn = gr.Slider(0, 5, 1.2, 0.1, label="🎓 Peso: aprendizado")
|
| 999 |
+
weight_viral = gr.Slider(0, 5, 1.0, 0.1, label="🔥 Peso: viral")
|
| 1000 |
|
| 1001 |
+
btn = gr.Button("🚀 Processar e Gerar XML", variant="primary", size="lg")
|
| 1002 |
|
| 1003 |
with gr.Row():
|
| 1004 |
with gr.Column(scale=2):
|
| 1005 |
+
summary_out = gr.Textbox(label="📋 Resumo dos Cortes", lines=15, max_lines=25)
|
| 1006 |
with gr.Column(scale=1):
|
| 1007 |
+
status_out = gr.Textbox(label="📊 Status", lines=3)
|
| 1008 |
+
file_out = gr.File(label="⬇️ Download XML Editado")
|
| 1009 |
|
| 1010 |
btn.click(
|
| 1011 |
process_files,
|
|
|
|
| 1014 |
weight_emotion, weight_break, weight_learn, weight_viral],
|
| 1015 |
[summary_out, file_out, status_out]
|
| 1016 |
)
|
| 1017 |
+
|
| 1018 |
+
gr.Markdown("""
|
| 1019 |
+
---
|
| 1020 |
+
### 📚 Como usar:
|
| 1021 |
+
1. **Envie o XML** exportado do Premiere (File > Export > Final Cut Pro XML)
|
| 1022 |
+
2. **Opcional:** Envie transcrição para buscas inteligentes
|
| 1023 |
+
3. **Escolha um modo:**
|
| 1024 |
+
- 💬 Linguagem natural (mais fácil)
|
| 1025 |
+
- 🎯 Minutagens manuais (mais preciso)
|
| 1026 |
+
- ⚙️ Automático (experimental)
|
| 1027 |
+
4. Clique em **Processar** e faça download do XML editado
|
| 1028 |
+
5. Importe de volta no Premiere (File > Import)
|
| 1029 |
+
""")
|
| 1030 |
|
| 1031 |
if __name__ == "__main__":
|
| 1032 |
+
demo.launch()
|