feat: criado repositório jhonny-editor no Gitea

criado repositório jhonny-editor no Gitea
adicionado script admin/deploy.command com commit automático
atualizado admin/DEV-NOTES.md com template limpo

Resumo:
- 48 arquivos alterados
- 26 novos
- 19 modificados
- 3 removidos

 22 files changed, 658 insertions(+), 568 deletions(-)

Arquivos:
  - AGENTS.md
  - admin/DEV-NOTES.md
  - admin/OpenCut.command
  - admin/commit.command
  - admin/deploy.command
  - admin/update.command
  - code/cep-plugin/index.html
  - code/cep-plugin/main.js
  - code/cep-plugin/styles.css
  - code/engine/ARQUITETURA.md
  - code/engine/arquitetura/README.md
  - code/engine/gerar_relatorio_timeline.py
  - code/engine/integracoes/apple_speech/apple_speech_transcriber.swift
  - code/engine/integracoes/apple_speech/provider_de_transcricao_apple.py
  - code/engine/integracoes/midia/__init__.py
  - code/engine/integracoes/whisper/provider_de_transcricao_local.py
  - code/engine/scanner/__init__.py
  - code/engine/scanner/coordenacao/__init__.py
  - code/engine/scanner/descoberta/__init__.py
  - code/engine/scanner/modelos.py
  - code/engine/scanner/transcricao_da_timeline.py
  - code/src/tools/discovery.ts
  - :memory:.ses
  - admin/Jhonny.command
  - admin/inativos/OpenCut.command
  - admin/inativos/update.command
  - code/docs/glossario-analise-emocional.md
  - code/docs/levantamento-apple-vision-e-apple-intelligence.md
  - code/docs/levantamento-ferramentas-analise-visual-local.md
  - code/engine/arquitetura/biblioteca-inteligente-de-videos.md
  - code/engine/executar_scanner.py
  - code/engine/integracoes/huggingface/
  - code/engine/integracoes/midia/extracao_de_metadados.py
  - code/engine/integracoes/visual/
  - code/engine/requirements-visual.txt
  - code/engine/scanner/configuracao_visual.py
  - code/engine/scanner/descoberta/descoberta_de_arquivos.py
  - code/engine/scanner/metadados.py
  - code/engine/scanner/relatorio_visual.py
  - code/engine/scanner/retakes/
  - code/engine/scanner/visual.py
  - code/engine/testes/test_analise_visual_local.py
  - code/engine/testes/test_arquivos_e_metadados.py
  - code/engine/testes/test_provider_de_transcricao_apple.py
  - code/relatorios/analise-brools/
  - code/relatorios/analise-visual/
  - code/relatorios/audio/arquivos/
  - code/relatorios/transcricao-timeline.md
This commit is contained in:
João Henrique
2026-09-08 16:13:27 -04:00
parent b541f502ba
commit b9bf3b2863
76 changed files with 16147 additions and 322 deletions
+19 -4
View File
@@ -1,7 +1,22 @@
from .coordenacao import AnalisadorDeTimeline, ContextoDeAnalise, PipelineDoScanner
__all__ = ["AnalisadorDeTimeline", "ContextoDeAnalise", "PipelineDoScanner"]
from .modelos import Cena, ErroDeAnalise, Evento, ResultadoDaAnalise, SegmentoDeTranscricao, StatusDaAnalise
from .descoberta import ArquivoDescoberto, DescobertaDeArquivos, LeitorLocalDeArquivos
from .metadados import ExtracaoDeMetadados, MetadadosDoArquivo
from .modelos import (Cena, CenaVisual, EvidenciaVisual, ErroDeAnalise, Evento, ObservacaoVisual,
PalavraDeTranscricao,
ResultadoDaAnalise, SegmentoDeTranscricao, StatusDaAnalise)
from .transcricao_da_timeline import TranscricaoDaTimeline, TranscricaoDoClipe
from .visual import (AnaliseVisualDaTimeline, DetectorDeCenas, ResultadoVisualDoClipe,
criar_detector_apple_vision, criar_detector_visual_local)
from .relatorio_visual import gerar_relatorio_visual, gerar_resumo_visual_markdown
from .configuracao_visual import ConfiguracaoVisualDoScanner
__all__ = ["Cena", "ErroDeAnalise", "Evento", "ResultadoDaAnalise", "SegmentoDeTranscricao", "StatusDaAnalise", "TranscricaoDaTimeline", "TranscricaoDoClipe"]
__all__ = ["AnaliseVisualDaTimeline", "AnalisadorDeTimeline", "ArquivoDescoberto",
"Cena", "CenaVisual", "ContextoDeAnalise", "criar_detector_apple_vision", "criar_detector_visual_local", "DescobertaDeArquivos", "DetectorDeCenas",
"ConfiguracaoVisualDoScanner",
"EvidenciaVisual", "ErroDeAnalise", "Evento", "ExtracaoDeMetadados",
"gerar_relatorio_visual",
"gerar_resumo_visual_markdown",
"LeitorLocalDeArquivos", "MetadadosDoArquivo", "ObservacaoVisual", "PalavraDeTranscricao",
"PipelineDoScanner", "ResultadoDaAnalise", "ResultadoVisualDoClipe",
"SegmentoDeTranscricao", "StatusDaAnalise", "TranscricaoDaTimeline",
"TranscricaoDoClipe"]
@@ -0,0 +1,60 @@
"""Perfil declarativo que liga a configuração do painel à leitura visual."""
from dataclasses import dataclass
from typing import Any
RECURSOS_VISION = frozenset((
"faces", "qualidade_facial", "pessoas", "pose", "maos", "ocr", "categorias",
"estetica", "codigos", "horizonte", "retangulos", "contornos", "segmentacao_de_pessoas",
))
@dataclass(frozen=True)
class ConfiguracaoVisualDoScanner:
"""Interface curta para uma execução de leitura visual da timeline."""
intervalo_em_segundos: float = 1.0
faixas_de_video: tuple[int, ...] = ()
faixas_de_audio: tuple[int, ...] = ()
recursos_vision: frozenset[str] = RECURSOS_VISION
detectar_cenas: bool = True
analisar_continuidade: bool = True
preparar_reenquadramento: bool = False
interpretar_cena: bool = True
def __post_init__(self) -> None:
if not 0.04 <= self.intervalo_em_segundos <= 60:
raise ValueError("intervalo_em_segundos deve estar entre 0,04 e 60.")
desconhecidos = self.recursos_vision - RECURSOS_VISION
if desconhecidos:
raise ValueError(f"Recursos Vision desconhecidos: {', '.join(sorted(desconhecidos))}")
if any(indice < 0 for indice in self.faixas_de_video + self.faixas_de_audio):
raise ValueError("Índices de faixa não podem ser negativos.")
@classmethod
def de_dict(cls, dados: dict[str, Any]) -> "ConfiguracaoVisualDoScanner":
"""Lê o mesmo JSON produzido pelo painel, sem vazar detalhes de adapters."""
return cls(
intervalo_em_segundos=float(dados.get("intervalo_em_segundos", 1)),
faixas_de_video=tuple(sorted(set(int(item) for item in dados.get("faixas_de_video", ())))),
faixas_de_audio=tuple(sorted(set(int(item) for item in dados.get("faixas_de_audio", ())))),
recursos_vision=frozenset(dados.get("recursos_vision", RECURSOS_VISION)),
detectar_cenas=bool(dados.get("detectar_cenas", True)),
analisar_continuidade=bool(dados.get("analisar_continuidade", True)),
preparar_reenquadramento=bool(dados.get("preparar_reenquadramento", False)),
interpretar_cena=bool(dados.get("interpretar_cena", True)),
)
def para_dict(self) -> dict[str, Any]:
return {
"versao": 1,
"intervalo_em_segundos": self.intervalo_em_segundos,
"faixas_de_video": list(self.faixas_de_video),
"faixas_de_audio": list(self.faixas_de_audio),
"recursos_vision": sorted(self.recursos_vision),
"detectar_cenas": self.detectar_cenas,
"analisar_continuidade": self.analisar_continuidade,
"preparar_reenquadramento": self.preparar_reenquadramento,
"interpretar_cena": self.interpretar_cena,
}
+5 -2
View File
@@ -14,7 +14,10 @@ class ContextoDeAnalise:
transcricoes: dict[str, list[Any]] = field(default_factory=dict)
caracteristicas_visuais: dict[str, dict[str, Any]] = field(default_factory=dict)
cenas: list[Any] = field(default_factory=list)
cenas_visuais: list[Any] = field(default_factory=list)
eventos: list[Any] = field(default_factory=list)
arquivos: dict[str, Any] = field(default_factory=dict)
metadados_dos_arquivos: dict[str, Any] = field(default_factory=dict)
class Analisador(Protocol):
@@ -42,5 +45,5 @@ class AnalisadorDeTimeline:
def __init__(self, pipeline: PipelineDoScanner) -> None:
self.pipeline = pipeline
def analisar(self) -> ContextoDeAnalise:
return self.pipeline.executar(ContextoDeAnalise())
def analisar(self, timeline: Timeline | None = None) -> ContextoDeAnalise:
return self.pipeline.executar(ContextoDeAnalise(timeline=timeline))
+2 -1
View File
@@ -1,3 +1,4 @@
from .descoberta_da_timeline import DescobertaDaTimeline
from .descoberta_de_arquivos import ArquivoDescoberto, DescobertaDeArquivos, LeitorLocalDeArquivos
__all__ = ["DescobertaDaTimeline"]
__all__ = ["ArquivoDescoberto", "DescobertaDaTimeline", "DescobertaDeArquivos", "LeitorLocalDeArquivos"]
@@ -0,0 +1,97 @@
from dataclasses import dataclass
from pathlib import Path
from typing import Protocol
from ...dominio import Clipe
from ...scanner.modelos import ErroDeAnalise
from ..coordenacao import ContextoDeAnalise
@dataclass(frozen=True)
class ArquivoDescoberto:
"""Resultado da validação do arquivo associado a um clipe."""
caminho: Path
existe: bool
acessivel: bool
tipo_de_midia: str | None = None
tamanho_em_bytes: int | None = None
@property
def offline(self) -> bool:
return not self.existe or not self.acessivel
class LeitorDeArquivos(Protocol):
def descobrir(self, caminho: str | Path) -> ArquivoDescoberto: ...
class LeitorLocalDeArquivos:
"""Adapter que resolve e valida caminhos no sistema de arquivos local."""
def descobrir(self, caminho: str | Path) -> ArquivoDescoberto:
caminho_resolvido = Path(caminho).expanduser().resolve(strict=False)
existe = caminho_resolvido.is_file()
acessivel = existe
tamanho = None
if existe:
try:
tamanho = caminho_resolvido.stat().st_size
with caminho_resolvido.open("rb"):
pass
except (OSError, PermissionError):
acessivel = False
return ArquivoDescoberto(
caminho=caminho_resolvido,
existe=existe,
acessivel=acessivel,
tipo_de_midia=caminho_resolvido.suffix.lower().lstrip(".") or None,
tamanho_em_bytes=tamanho,
)
class DescobertaDeArquivos:
"""Relaciona os clipes da timeline aos arquivos físicos de origem."""
nome = "descoberta_de_arquivos"
def __init__(self, leitor: LeitorDeArquivos | None = None) -> None:
self.leitor = leitor or LeitorLocalDeArquivos()
def executar(self, contexto: ContextoDeAnalise) -> ContextoDeAnalise:
if contexto.timeline is None:
return contexto
vistos: dict[Path, str] = {}
for clipe in self._clipes(contexto):
if not clipe.arquivo:
clipe.offline = True
self._registrar_erro(contexto, "arquivo_ausente", "Clipe sem sourceFile.", clipe)
continue
try:
descoberto = self.leitor.descobrir(clipe.arquivo)
except (OSError, ValueError, TypeError) as exc:
clipe.offline = True
self._registrar_erro(contexto, "arquivo_inacessivel", str(exc), clipe)
continue
clipe.arquivo = str(descoberto.caminho)
clipe.offline = descoberto.offline
contexto.arquivos[clipe.identificador] = descoberto
if descoberto.offline:
self._registrar_erro(contexto, "arquivo_inacessivel", "Arquivo não encontrado ou sem permissão de leitura.", clipe)
elif descoberto.caminho in vistos:
contexto.avisos.append(
f"Arquivo duplicado entre os clipes {vistos[descoberto.caminho]} e {clipe.identificador}."
)
else:
vistos[descoberto.caminho] = clipe.identificador
return contexto
@staticmethod
def _clipes(contexto: ContextoDeAnalise) -> list[Clipe]:
return [clipe for faixa in contexto.timeline.faixas for clipe in faixa.clipes]
@staticmethod
def _registrar_erro(contexto: ContextoDeAnalise, codigo: str, mensagem: str, clipe: Clipe) -> None:
contexto.erros.append(str(ErroDeAnalise(codigo, mensagem, f"clipe[{clipe.identificador}].sourceFile")))
+40
View File
@@ -0,0 +1,40 @@
from typing import Protocol
from ..integracoes.midia.extracao_de_metadados import (
ExtracaoDeMetadados as ExtratorDeMetadados,
MetadadosDoArquivo,
)
from .coordenacao import ContextoDeAnalise
class ExtratorDeMetadadosProtocol(Protocol):
def extrair(self, arquivo: str) -> MetadadosDoArquivo: ...
class ExtracaoDeMetadados:
"""Enriquece cada clipe com os metadados técnicos do seu arquivo."""
nome = "extracao_de_metadados"
def __init__(self, extrator: ExtratorDeMetadadosProtocol | None = None) -> None:
self.extrator = extrator or ExtratorDeMetadados()
def executar(self, contexto: ContextoDeAnalise) -> ContextoDeAnalise:
for identificador, arquivo in contexto.arquivos.items():
if arquivo.offline:
continue
try:
metadados = self.extrator.extrair(str(arquivo.caminho))
contexto.metadados_dos_arquivos[identificador] = metadados
for faixa in contexto.timeline.faixas if contexto.timeline else []:
for clipe in faixa.clipes:
if clipe.identificador == identificador:
clipe.metadados["arquivo"] = metadados
break
except Exception as exc:
# Uma mídia inválida não deve descartar os resultados dos demais clipes.
contexto.erros.append(f"metadados_indisponiveis: {arquivo.caminho}: {exc}")
return contexto
__all__ = ["ExtracaoDeMetadados", "MetadadosDoArquivo"]
+55
View File
@@ -28,12 +28,32 @@ class ResultadoDaAnalise:
avisos: list[str] = field(default_factory=list)
@dataclass(frozen=True)
class PalavraDeTranscricao:
texto: str
inicio: float
fim: float
confianca: float | None = None
falante: str | None = None
def __post_init__(self) -> None:
if self.inicio < 0 or self.fim < self.inicio:
raise ValueError("Intervalo de palavra inválido.")
@dataclass(frozen=True)
class SegmentoDeTranscricao:
inicio: float
fim: float
texto: str
confianca: float | None = None
palavras: tuple[PalavraDeTranscricao, ...] = ()
emocao: str | None = None
confianca_emocao: float | None = None
caracteristicas_acusticas: dict[str, float] = field(default_factory=dict)
falante: str | None = None
voz_aparente: str | None = None
confianca_voz: float | None = None
def __post_init__(self) -> None:
if self.inicio < 0 or self.fim < self.inicio:
@@ -54,3 +74,38 @@ class Evento:
inicio: float
fim: float
confianca: float | None = None
@dataclass(frozen=True)
class EvidenciaVisual:
"""Fato visual normalizado, independente da biblioteca que o produziu."""
tipo: str
inicio: float
fim: float
valor: dict[str, Any]
confianca: float | None = None
provider: str = ""
modelo: str | None = None
def __post_init__(self) -> None:
if self.inicio < 0 or self.fim < self.inicio:
raise ValueError("Intervalo de evidência visual inválido.")
ObservacaoVisual = EvidenciaVisual
@dataclass(frozen=True)
class CenaVisual:
"""Intervalo visual enriquecido com as observações que o sustentam."""
identificador_do_clipe: str
inicio: float
fim: float
observacoes: tuple[EvidenciaVisual, ...] = ()
referencias_de_cena: tuple[float, ...] = ()
def __post_init__(self) -> None:
if self.inicio < 0 or self.fim < self.inicio:
raise ValueError("Intervalo de cena visual inválido.")
+79
View File
@@ -0,0 +1,79 @@
"""Exportação estruturada de uma leitura visual de clipe."""
from collections import defaultdict
from dataclasses import asdict
from datetime import datetime, timezone
import json
from pathlib import Path
from ..dominio import Clipe
from .visual import ResultadoVisualDoClipe
VERSAO_DO_RELATORIO_VISUAL = "1.0"
def gerar_relatorio_visual(clipe: Clipe, resultado: ResultadoVisualDoClipe,
destino: str | Path, intervalo_de_amostragem: float) -> Path:
"""Grava um JSON autocontido, com fatos por frame e fatos temporais separados."""
if intervalo_de_amostragem <= 0:
raise ValueError("intervalo_de_amostragem deve ser positivo.")
caminho = Path(destino)
caminho.parent.mkdir(parents=True, exist_ok=True)
caminho.write_text(json.dumps(_dados_do_relatorio(clipe, resultado, intervalo_de_amostragem),
ensure_ascii=False, indent=2), encoding="utf-8")
return caminho
def gerar_resumo_visual_markdown(clipe: Clipe, resultado: ResultadoVisualDoClipe,
destino: str | Path, intervalo_de_amostragem: float) -> Path:
"""Grava uma visão humana do mesmo resultado exportado em JSON."""
caminho = Path(destino)
caminho.parent.mkdir(parents=True, exist_ok=True)
dados = _dados_do_relatorio(clipe, resultado, intervalo_de_amostragem)
linhas = [f"# Relatório visual — {clipe.nome}", "",
f"- Clipe: `{clipe.identificador}`",
f"- Origem: `{clipe.arquivo}`",
f"- Timeline: {clipe.intervalo_na_timeline.inicio:.3f}s–{clipe.intervalo_na_timeline.fim:.3f}s",
f"- Amostragem: a cada {intervalo_de_amostragem:g} segundo(s)", "",
"## Observações por frame", ""]
for frame in dados["observacoes_por_frame"]:
tipos = ", ".join(item["tipo"] for item in frame["evidencias"])
linhas.extend([f"### Origem {frame['timestamp_na_origem']:.3f}s", "", tipos or "Sem evidências.", ""])
linhas.extend(["## Continuidade", ""])
for evidencia in dados["evidencias_temporais"]:
linhas.append(f"- {evidencia['tipo']}: {evidencia['inicio']:.3f}s–{evidencia['fim']:.3f}s — "
f"`{json.dumps(evidencia['valor'], ensure_ascii=False)}`")
caminho.write_text("\n".join(linhas) + "\n", encoding="utf-8")
return caminho
def _dados_do_relatorio(clipe: Clipe, resultado: ResultadoVisualDoClipe,
intervalo_de_amostragem: float) -> dict:
por_frame = defaultdict(list)
temporais = []
for evidencia in resultado.evidencias:
item = asdict(evidencia)
if evidencia.inicio == evidencia.fim:
por_frame[evidencia.inicio].append(item)
else:
temporais.append(item)
origem = clipe.intervalo_na_origem
return {
"versao": VERSAO_DO_RELATORIO_VISUAL,
"gerado_em": datetime.now(timezone.utc).isoformat(),
"clipe": {
"identificador": clipe.identificador,
"nome": clipe.nome,
"arquivo_original": clipe.arquivo,
"intervalo_na_timeline": asdict(clipe.intervalo_na_timeline),
"intervalo_na_origem": asdict(origem) if origem else None,
},
"amostragem": {"intervalo_em_segundos": intervalo_de_amostragem},
"observacoes_por_frame": [
{"timestamp_na_origem": timestamp, "evidencias": evidencias}
for timestamp, evidencias in sorted(por_frame.items())
],
"evidencias_temporais": temporais,
"cenas": [asdict(cena) for cena in resultado.cenas_visuais],
}
@@ -0,0 +1,116 @@
"""Converte a transcrição existente em falas ordenadas para a análise.
O módulo de retakes não realiza transcrição. Ele recebe a saída do
``TranscricaoDaTimeline`` (lista de ``TranscricaoDoClipe``) e a converte
em ``Fala``s ordenadas ao longo da timeline, preservando o vínculo com o
segmento/clipe de origem e as palavras alinhadas quando disponíveis.
"""
from __future__ import annotations
import re
from typing import Iterable
from ..transcricao_da_timeline import TranscricaoDoClipe
from .modelos_de_retakes import Fala
# Sinais de erro mais comuns vindos dos providers de transcrição.
_PALAVRAS_DE_ERRO = {"", "...", "…"}
_VOYELS = "aeiouáéíóúâêôãõàèìòù"
_CONSOANTES = "bcdfghjklmnpqrstvwxyz"
_PADRAO_SILABA = re.compile(
rf"([{_VOYELS}][^aeiouáéíóúâêôãõàèìòù]*)|([{_CONSOANTES}]+[aeiouáéíóúâêôãõàèìòù]?)",
re.IGNORECASE,
)
def _normalizar(texto: str) -> str:
"""Minúsculas, sem pontuação e sem espaços duplicados."""
sem_pontuacao = re.sub(r"[^\w\s]", " ", texto)
return " ".join(sem_pontuacao.lower().split())
def _prefixo_comum(a: list[str], b: list[str]) -> int:
n = 0
for x, y in zip(a, b):
if x != y:
break
n += 1
return n
def _sucesso_de_silabas(a: list[str], b: list[str]) -> float:
if not a or not b:
return 0.0
silabas_a = [_PADRAO_SILABA.findall(p)[0][0] for p in a]
silabas_b = [_PADRAO_SILABA.findall(p)[0][0] for p in b]
n = _prefixo_comum(silabas_a, silabas_b)
return n / max(len(silabas_a), len(silabas_b))
def _e_ruido(texto: str) -> bool:
texto_limpo = _normalizar(texto)
if texto_limpo in _PALAVRAS_DE_ERRO:
return True
# Fala em branco ou "vazia" por provedor.
return not texto_limpo
class AdaptadorDeFalas:
"""Transforma a transcrição da timeline em falas prontas para análise.
Recebe uma coleção de ``TranscricaoDoClipe`` (uma por faixa de áudio do
vídeo) e devolve as falas ordenadas por tempo. O ``video_id`` é um rótulo
informado pelo chamador; quando ausente, usa o identificador da timeline.
"""
def __init__(self, video_id: str | None = None, faixa_id: str | None = None) -> None:
self.video_id = video_id
self.faixa_id = faixa_id
def converter(
self,
transcricoes: Iterable[TranscricaoDoClipe],
video_id: str | None = None,
faixa_id: str | None = None,
) -> list[Fala]:
video_id = video_id or self.video_id or "video"
faixa_id = faixa_id or self.faixa_id or "faixa"
falas: list[Fala] = []
for transcricao in transcricoes:
segmento_id = transcricao.identificador_do_clipe
for segmento in transcricao.segmentos:
if _e_ruido(segmento.texto):
continue
falas.append(Fala(
id=f"{segmento_id}:{segmento.inicio:.3f}",
video_id=video_id,
faixa_id=faixa_id,
segmento_id=segmento_id,
ordem=-1, # preenchida após a ordenação
inicio=segmento.inicio,
fim=segmento.fim,
texto=segmento.texto,
texto_normalizado=_normalizar(segmento.texto),
palavras=segmento.palavras,
falante=segmento.falante,
))
falas.sort(key=lambda item: (item.inicio, item.fim))
for indice, fala in enumerate(falas):
falas[indice] = Fala(
id=fala.id,
video_id=fala.video_id,
faixa_id=fala.faixa_id,
segmento_id=fala.segmento_id,
ordem=indice,
inicio=fala.inicio,
fim=fala.fim,
texto=fala.texto,
texto_normalizado=fala.texto_normalizado,
palavras=fala.palavras,
falante=fala.falante,
)
return falas
@@ -0,0 +1,148 @@
"""Agrupamento de falas que representam retakes da mesma fala ou ideia.
Trabalha com uma janela temporal: compara cada fala com as ``n`` seguintes
(e só as da mesma faixa de áudio), evitando agrupar frases iguais que
aparecem em partes muito distantes do vídeo. Não decide a classificação —
apenas forma grupos candidatos e reúne as métricas.
"""
from __future__ import annotations
from typing import Iterable
from .analisador_de_intervalos import AnalisadorDeIntervalos
from .comparador_de_falas import ComparadorDeFalas
from .comparador_semantico import ComparadorSemantico
from .detector_de_reinicios import DetectorDeReinicios
from .modelos_de_retakes import (
AgrupamentoDeFalas,
Fala,
ParAgrupado,
ResultadoDoIntervalo,
SinalDeReinicio,
)
# Limiares de candidatura de um par a pertencer a um grupo de retake.
_SIMILARIDADE_MINIMA_CANDIDATO = 0.55
_SIMILARIDADE_FORTE = 0.75
_PROXIMIDADE_AUXILIAR = 0.60
# Janela padrão de falas a serem comparadas com cada uma.
_JANELA_PADRAO = 5
# Região de dúvida que dispara a comparação semântica.
_ZONA_DE_DUVIDA_INFERIOR = 0.60
_ZONA_DE_DUVIDA_SUPERIOR = 0.85
class AgrupadorDeTakes:
"""Gera grupos candidatos conectando falas semelhantes e próximas."""
def __init__(
self,
comparador: ComparadorDeFalas,
comparador_semantico: ComparadorSemantico,
analisador_de_intervalos: AnalisadorDeIntervalos,
detector_de_reinicios: DetectorDeReinicios,
janela: int = _JANELA_PADRAO,
) -> None:
self.comparador = comparador
self.comparador_semantico = comparador_semantico
self.analisador_de_intervalos = analisador_de_intervalos
self.detector_de_reinicios = detector_de_reinicios
self.janela = janela
def agrupar(self, falas: Iterable[Fala]) -> list[AgrupamentoDeFalas]:
falas = sorted(falas, key=lambda item: (item.ordem, item.inicio))
sinais = {sinal.fala_id: sinal for sinal in self.detector_de_reinicios.detectar(falas)}
arestas: list[tuple[int, int, ParAgrupado]] = []
for indice, fala_a in enumerate(falas):
limite = min(len(falas), indice + 1 + self.janela)
for jindice in range(indice + 1, limite):
fala_b = falas[jindice]
if fala_a.faixa_id != fala_b.faixa_id:
continue
par = self._avaliar_par(fala_a, fala_b, sinais)
if par is not None:
arestas.append((indice, jindice, par))
return self._agrupar_por_arestas(falas, arestas, sinais)
def _avaliar_par(
self,
fala_a: Fala,
fala_b: Fala,
sinais: dict[str, SinalDeReinicio],
) -> ParAgrupado | None:
comparacao = self.comparador.comparar(fala_a, fala_b)
intervalo = self.analisador_de_intervalos.analisar(fala_a, fala_b)
tem_reinicio = sinais.get(fala_b.id) is not None
semantica = 0.0
# Comparação semântica só na zona de dúvida, para evitar custo.
texto = comparacao.similaridade_final
if _ZONA_DE_DUVIDA_INFERIOR <= texto <= _ZONA_DE_DUVIDA_SUPERIOR:
semantica = self.comparador_semantico.comparar(fala_a, fala_b)
melhor = max(comparacao.similaridade_final, semantica)
candidato = (
melhor >= _SIMILARIDADE_FORTE
or (melhor >= _SIMILARIDADE_MINIMA_CANDIDATO
and intervalo.proximidade_temporal >= _PROXIMIDADE_AUXILIAR
and (tem_reinicio or intervalo.indicio_de_nova_tentativa))
)
if candidato:
return ParAgrupado(fala_a, fala_b, comparacao, intervalo,
round(semantica, 4))
return None
@staticmethod
def _agrupar_por_arestas(
falas: list[Fala],
arestas: list[tuple[int, int, ParAgrupado]],
sinais: dict[str, SinalDeReinicio],
) -> list[AgrupamentoDeFalas]:
pai = list(range(len(falas)))
def encontrar(x: int) -> int:
while pai[x] != x:
pai[x] = pai[pai[x]]
x = pai[x]
return x
arestas.sort(key=lambda item: (item[0], item[1]))
for a, b, _ in arestas:
raiz_a, raiz_b = encontrar(a), encontrar(b)
if raiz_a != raiz_b:
pai[raiz_b] = raiz_a
componentes: dict[int, list[int]] = {}
for indice in range(len(falas)):
componentes.setdefault(encontrar(indice), []).append(indice)
grupos: list[AgrupamentoDeFalas] = []
for inds in componentes.values():
if len(inds) < 2:
continue
inds.sort()
fala_ids = tuple(falas[indice].id for indice in inds)
pares_por_chave: dict[tuple[str, str], ParAgrupado] = {
(par.fala_a.id, par.fala_b.id): par
for _, _, par in arestas
if par.fala_a.id in fala_ids and par.fala_b.id in fala_ids
}
pares: list[ParAgrupado] = []
for a, b in zip(inds, inds[1:]):
chave = (falas[a].id, falas[b].id)
par = pares_por_chave.get(chave)
if par is None:
par = pares_por_chave.get((falas[b].id, falas[a].id))
if par is not None:
pares.append(par)
sinais_do_grupo = tuple(
sinais[fala_id] for fala_id in fala_ids if fala_id in sinais)
if pares:
grupos.append(AgrupamentoDeFalas(fala_ids, tuple(pares), sinais_do_grupo))
return grupos
@@ -0,0 +1,41 @@
"""Análise da relação temporal entre falas.
Verifica quanto tempo há entre duas falas, se estão na mesma faixa/segmento
e se a proximidade sugere uma nova tentativa. Um intervalo muito grande
reduz a confiança de que seja um retake imediato.
"""
from __future__ import annotations
from .modelos_de_retakes import Fala, ResultadoDoIntervalo
# A partir de quantos segundos o intervalo passa a não sugerir retake.
_INTERVALO_MAXIMO = 12.0
class AnalisadorDeIntervalos:
"""Calcula a proximidade temporal e o indício de nova tentativa."""
def __init__(self, intervalo_maximo: float = _INTERVALO_MAXIMO) -> None:
self.intervalo_maximo = intervalo_maximo
def analisar(self, fala_a: Fala, fala_b: Fala) -> ResultadoDoIntervalo:
intervalo = max(0.0, fala_b.inicio - fala_a.fim)
mesma_faixa = fala_a.faixa_id == fala_b.faixa_id
mesmo_segmento = mesma_faixa and fala_a.segmento_id == fala_b.segmento_id
if intervalo >= self.intervalo_maximo:
proximidade = 0.0
elif intervalo <= 0:
proximidade = 1.0
else:
# Decai suavemente com o intervalo: 1.0 → ~0 em 12s.
proximidade = max(0.0, 1.0 - intervalo / self.intervalo_maximo)
indicio = mesma_faixa and intervalo <= self.intervalo_maximo and proximidade >= 0.5
return ResultadoDoIntervalo(
intervalo_em_segundos=round(intervalo, 3),
mesmo_segmento=mesmo_segmento,
proximidade_temporal=round(proximidade, 4),
indicio_de_nova_tentativa=bool(indicio),
)
@@ -0,0 +1,112 @@
"""Comparação textual entre falas.
Responsável pelos algoritmos de similaridade: Jaccard (conjunto de
palavras), sequência (ordem das palavras via maior subsequência comum) e
similaridade do início/fim da frase. Não decide nada sozinho — apenas
produz métricas para o classificador.
"""
from __future__ import annotations
import re
from .modelos_de_retakes import Fala, ResultadoDaComparacao
def _normalizar(texto: str) -> str:
"""Minúsculas, sem pontuação e sem espaços duplicados."""
sem_pontuacao = re.sub(r"[^\w\s]", " ", texto)
return " ".join(sem_pontuacao.lower().split())
def _lcs(a: list[str], b: list[str]) -> int:
"""Tamanho da maior subsequência comum preservando a ordem."""
anterior = [0] * (len(b) + 1)
for palavra_a in a:
atual = [0] * (len(b) + 1)
for j, palavra_b in enumerate(b):
if palavra_a == palavra_b:
atual[j + 1] = anterior[j] + 1
else:
atual[j + 1] = max(atual[j], anterior[j + 1])
anterior = atual
return anterior[-1]
class ComparadorDeFalas:
"""Compara duas falas e calcula as métricas de similaridade textual."""
@staticmethod
def normalizar(texto: str) -> str:
return _normalizar(texto)
@staticmethod
def _palavras_de(fala: Fala) -> list[str]:
return fala.texto_normalizado.split() or _normalizar(fala.texto).split()
def comparar(self, fala_a: Fala, fala_b: Fala) -> ResultadoDaComparacao:
palavras_a = self._palavras_de(fala_a)
palavras_b = self._palavras_de(fala_b)
if not palavras_a or not palavras_b:
return ResultadoDaComparacao(fala_a.id, fala_b.id)
# 1. Jaccard — conjunto de palavras (rápido, ótimo candidato).
conjunto_a = set(palavras_a)
conjunto_b = set(palavras_b)
intersecao = len(conjunto_a & conjunto_b)
uniao = len(conjunto_a | conjunto_b)
jaccard = intersecao / uniao if uniao else 0.0
# 2. Sequência — ordem das palavras via LCS normalizada.
lcs = _lcs(palavras_a, palavras_b)
sequencia = lcs / max(len(palavras_a), len(palavras_b))
# 3. Início e final da frase.
inicio = self._similaridade_de_prefijo(palavras_a, palavras_b)
final = self._similaridade_de_sufixo(palavras_a, palavras_b)
# 4. Similaridade final ponderada.
fim = 0.45 * sequencia + 0.30 * jaccard + 0.15 * inicio + 0.10 * final
return ResultadoDaComparacao(
fala_a_id=fala_a.id,
fala_b_id=fala_b.id,
similaridade_jaccard=round(jaccard, 4),
similaridade_de_sequencia=round(sequencia, 4),
similaridade_do_inicio=round(inicio, 4),
similaridade_do_final=round(final, 4),
similaridade_final=round(min(1.0, fim), 4),
)
@staticmethod
def _similaridade_de_prefijo(a: list[str], b: list[str]) -> float:
if not a or not b:
return 0.0
n = 0
for x, y in zip(a, b):
if x != y:
break
n += 1
return n / max(len(a), len(b))
@staticmethod
def _similaridade_de_sufixo(a: list[str], b: list[str]) -> float:
if not a or not b:
return 0.0
n = 0
for x, y in zip(reversed(a), reversed(b)):
if x != y:
break
n += 1
return n / max(len(a), len(b))
@staticmethod
def prefixo_comum_em_palavras(texto_a: str, texto_b: str) -> int:
a = _normalizar(texto_a).split()
b = _normalizar(texto_b).split()
n = 0
for x, y in zip(a, b):
if x != y:
break
n += 1
return n
@@ -0,0 +1,113 @@
"""Comparação semântica entre falas (opcional).
A similaridade textual não cobre casos em que as palavras são diferentes
mas a ideia é a mesma. Este módulo define um protocolo para providers de
embeddings e uma implementação local (via Hugging Face Transformers), além
de um fallback puramente lexical usado quando nenhum provider está
disponível.
O `ComparadorSemantico` nunca decide sozinho que há retake — apenas
fornece uma métrica adicional para o classificador.
"""
from __future__ import annotations
import math
from typing import Protocol
from .modelos_de_retakes import Fala
class ProviderDeSimilaridadeSemantica(Protocol):
"""Contrato para quem calcula similaridade semântica entre dois textos."""
def similaridade(self, texto_a: str, texto_b: str) -> float: ...
class ComparadorLexicalSemantico:
"""Fallback puramente lexical para quando não há embeddings.
Reconstrói uma "similaridade semântica" a partir de palavras que
compartilham a mesma raiz (stemming simples por prefixo comum) e de
sinônimos frequentes em pt-PT. Destina-se a permitir executar a análise
sem carregar modelos pesados.
"""
_PARES_SINONIMOS = (
({"mostrar", "explicar", "demonstrar", "apresentar"},),
({"sistema", "programa", "aplicativo", "software"},),
({"configurar", "configuracao", "instalar", "ajustar"},),
)
def similaridade(self, texto_a: str, texto_b: str) -> float:
palavras_a = {p for p in self._palavras(texto_a)}
palavras_b = {p for p in self._palavras(texto_b)}
if not palavras_a or not palavras_b:
return 0.0
intersecao = 0.0
for palavra_a in palavras_a:
for palavra_b in palavras_b:
if palavra_a == palavra_b:
intersecao += 1.0
elif self._mesma_raiz(palavra_a, palavra_b):
intersecao += 0.7
elif self._mesmo_sinonimo(palavra_a, palavra_b):
intersecao += 0.6
tam = max(len(palavras_a), len(palavras_b))
return round(min(1.0, intersecao / tam), 4)
@staticmethod
def _palavras(texto: str) -> list[str]:
return [p for p in texto.lower().split()]
@staticmethod
def _mesma_raiz(a: str, b: str) -> bool:
raiz = min(len(a), len(b), 4)
return raiz >= 4 and a[:raiz] == b[:raiz]
@staticmethod
def _mesmo_sinonimo(a: str, b: str) -> bool:
return any(a in grupo and b in grupo for grupo in ComparadorLexicalSemantico._PARES_SINONIMOS)
class ComparadorSemantico:
"""Calcula similaridade semântica usando um provider injetável.
Quando o provider é ``None``, cai no ``ComparadorLexicalSemantico``
para não bloquear a análise na ausência de modelos locais.
"""
def __init__(self, provider: ProviderDeSimilaridadeSemantica | None = None) -> None:
self.provider = provider or ComparadorLexicalSemantico()
def comparar(self, fala_a: Fala, fala_b: Fala) -> float:
return float(self.provider.similaridade(fala_a.texto, fala_b.texto))
class ProviderDeSimilaridadeHuggingFace:
"""Embeds os textos localmente com um modelo de embeddings.
O modelo é carregado de forma preguiçosa para não custar nada até ser
de fato necessário (zona de dúvida do classificador).
"""
def __init__(self, modelo: str = "sentence-transformers/paraphrase-multilingual-MiniLM-L12-v2") -> None:
self.modelo = modelo
self._encoder = None
def similaridade(self, texto_a: str, texto_b: str) -> float:
if self._encoder is None:
from sentence_transformers import SentenceTransformer
self._encoder = SentenceTransformer(self.modelo)
embeddings = self._encoder.encode([texto_a, texto_b], normalize_embeddings=True)
return float(_cosseno(embeddings[0], embeddings[1]))
def _cosseno(a: list[float], b: list[float]) -> float:
produto = sum(x * y for x, y in zip(a, b))
norma_a = math.sqrt(sum(x * x for x in a))
norma_b = math.sqrt(sum(y * y for y in b))
if norma_a == 0 or norma_b == 0:
return 0.0
return max(0.0, min(1.0, produto / (norma_a * norma_b)))
@@ -0,0 +1,86 @@
"""Detecção de sinais de reinício/recomeço de fala.
Analisa padrões de que a pessoa começou novamente uma ideia: repetição do
início de uma frase, pausas longas, marcadores explícitos ("não", "pera",
"vamos de novo", "desculpa") e repetição de palavras consecutivas.
"""
from __future__ import annotations
import re
from typing import Iterable
from .comparador_de_falas import ComparadorDeFalas
from .modelos_de_retakes import Fala, SinalDeReinicio
_MARCADORES = (
"não", "pera", "peraí", "pera ai", "espera", "vamos de novo", "de novo",
"vamos recomeçar", "recomeçar", "desculpa", "desculpe", "deixa eu ver",
"vou repetir", "esquece", "hmm", "hm", "uhm", "tá", "ta",
)
_RE_MARCADOR = re.compile(
r"\b(?:" + "|".join(re.escape(m) for m in _MARCADORES) + r")\b",
re.IGNORECASE,
)
_PAUSA_LONGA = 1.2 # segundos a partir dos quais a pausa sugere recomeço.
class DetectorDeReinicios:
"""Encontra ``SinalDeReinicio`` em cada fala em relação à anterior."""
def __init__(
self,
comparador: ComparadorDeFalas | None = None,
pausa_longa: float = _PAUSA_LONGA,
) -> None:
self.comparador = comparador or ComparadorDeFalas()
self.pausa_longa = pausa_longa
def detectar(self, falas: Iterable[Fala]) -> list[SinalDeReinicio]:
falas = list(falas)
sinais: list[SinalDeReinicio] = []
for indice, fala in enumerate(falas):
sinal = self._analisar(fala, falas[indice - 1] if indice > 0 else None)
if sinal:
sinais.append(sinal)
return sinais
def _analisar(self, fala: Fala, anterior: Fala | None) -> SinalDeReinicio | None:
evidencias: list[str] = []
intensidades: list[float] = []
palavras = fala.texto_normalizado.split()
# 1. Marcadores explícitos de recomeço no início.
if palavras and _RE_MARCADOR.match(palavras[0]):
evidencias.append("marcador explícito de recomeço")
intensidades.append(0.9)
if anterior is None:
if intensidades:
return SinalDeReinicio(fala.id, "reinicio_explicito",
round(max(intensidades), 3), evidencias)
return None
# 2. Pausa longa antes da fala.
pausa = fala.inicio - anterior.fim
if pausa >= self.pausa_longa:
evidencias.append(f"pausa de {pausa:.1f} segundos")
intensidades.append(min(1.0, 0.5 + pausa / 8.0))
# 3. Repetição do início da frase anterior.
prefixo = self.comparador.prefixo_comum_em_palavras(anterior.texto, fala.texto)
if prefixo >= 3:
evidencias.append(f"repetição das primeiras {prefixo} palavras")
intensidades.append(min(1.0, 0.4 + prefixo * 0.08))
if not evidencias:
return None
return SinalDeReinicio(
fala_id=fala.id,
tipo="repeticao_do_inicio" if any("repetição" in e for e in evidencias) else "reinicio_pos_pausa",
intensidade=round(min(1.0, max(intensidades)), 3),
evidencias=evidencias,
)
@@ -0,0 +1,154 @@
"""Modelos de dados do submódulo de detecção de retakes.
Mantemos os mesmos princípios de ``scanner/modelos.py``: dataclasses
imutáveis (``frozen=True``) com validação em ``__post_init__`` e sem
dependência de implementações concretas de providers.
"""
from __future__ import annotations
import uuid
from dataclasses import dataclass, field
from datetime import datetime, timezone
from ..modelos import PalavraDeTranscricao
@dataclass(frozen=True)
class Fala:
"""Uma fala normalizada, já posicionada na timeline do vídeo.
Corresponde a um ``SegmentoDeTranscricao`` convertido pelo adaptador,
mas carrega o contexto necessário para a análise temporal e de
agrupamento: vídeo, faixa de áudio, número de sequência e o vínculo
com o segmento/clipe de origem.
"""
id: str
video_id: str
faixa_id: str
segmento_id: str
ordem: int
inicio: float
fim: float
texto: str
texto_normalizado: str = ""
palavras: tuple[PalavraDeTranscricao, ...] = ()
falante: str | None = None
def __post_init__(self) -> None:
if self.inicio < 0 or self.fim < self.inicio:
raise ValueError(f"Intervalo da fala {self.id} inválido.")
@dataclass(frozen=True)
class SinalDeReinicio:
"""Indício de que uma fala recomeçou uma ideia já iniciada."""
fala_id: str
tipo: str
intensidade: float
evidencias: list[str] = field(default_factory=list)
def __post_init__(self) -> None:
if not 0.0 <= self.intensidade <= 1.0:
raise ValueError(f"Intensidade do reinício {self.fala_id} fora de [0, 1].")
@dataclass(frozen=True)
class ResultadoDaComparacao:
"""Resultado da comparação de duas falas, textual e por sequência."""
fala_a_id: str
fala_b_id: str
similaridade_jaccard: float = 0.0
similaridade_de_sequencia: float = 0.0
similaridade_do_inicio: float = 0.0
similaridade_do_final: float = 0.0
similaridade_final: float = 0.0
@dataclass(frozen=True)
class ResultadoDoIntervalo:
"""Relação temporal entre duas falas."""
intervalo_em_segundos: float
mesmo_segmento: bool = False
proximidade_temporal: float = 0.0
indicio_de_nova_tentativa: bool = False
@dataclass(frozen=True)
class EvidenciaDeRetake:
"""Uma evidência legível que sustenta a classificação de um grupo."""
tipo: str
descricao: str
valor: float | None = None
@dataclass(frozen=True)
class TomadaDeRetake:
"""Uma tomada (fala) pertencente a um grupo de retakes."""
ordem: int
fala_id: str
segmento_id: str
inicio: float
fim: float
texto: str
similaridade_com_anterior: float = 0.0
confianca: float = 0.0
def gerar_id_do_grupo() -> str:
"""Gera um id curto e monótono para um grupo de retakes."""
return f"retake_{uuid.uuid4().hex[:6]}"
@dataclass(frozen=True)
class ParAgrupado:
"""Um par de falas que o agrupador juntou como candidato a retake."""
fala_a: Fala
fala_b: Fala
comparacao: ResultadoDaComparacao
intervalo: ResultadoDoIntervalo
similaridade_semantica: float = 0.0
@dataclass(frozen=True)
class AgrupamentoDeFalas:
"""Um grupo candidato formado pelo agrupador, antes da classificação."""
fala_ids: tuple[str, ...]
pares: tuple[ParAgrupado, ...]
sinais_de_reinicio: tuple[SinalDeReinicio, ...] = ()
@dataclass(frozen=True)
class GrupoDeRetake:
"""Agrupa as tomadas que representam tentativas da mesma fala."""
id: str
video_id: str
faixa_id: str
tipo: str
confianca: float
tomadas: list[TomadaDeRetake] = field(default_factory=list)
evidencias: list[EvidenciaDeRetake] = field(default_factory=list)
criado_em: str = field(default_factory=lambda: datetime.now(timezone.utc).isoformat())
def __post_init__(self) -> None:
if not 0.0 <= self.confianca <= 1.0:
raise ValueError(f"Confiança do grupo {self.id} fora de [0, 1].")
@property
def tomada_principal_id(self) -> str | None:
if not self.tomadas:
return None
return max(self.tomadas, key=lambda item: item.confianca).fala_id
@property
def fala_ids(self) -> list[str]:
return [tomada.fala_id for tomada in self.tomadas]
+152 -21
View File
@@ -1,10 +1,11 @@
from dataclasses import asdict, dataclass
import hashlib
import json
from pathlib import Path
from typing import Any, Callable
from ..integracoes.midia import ExtracaoDeAudio
from .modelos import SegmentoDeTranscricao
from .modelos import PalavraDeTranscricao, SegmentoDeTranscricao
@dataclass(frozen=True)
@@ -14,6 +15,7 @@ class TranscricaoDoClipe:
inicio_na_timeline: float
fim_na_timeline: float
segmentos: list[SegmentoDeTranscricao]
intervalo_na_origem: tuple[float, float] | None = None
@property
def texto(self) -> str:
@@ -21,39 +23,168 @@ class TranscricaoDoClipe:
class TranscricaoDaTimeline:
"""Transcreve somente os intervalos efetivamente usados na timeline."""
"""Transcreve cada arquivo uma vez e projeta o resultado nos cortes.
A primeira versão suporta apenas mapeamento linear em velocidade normal.
Os timestamps retornados pelo provider são sempre relativos ao arquivo
original; somente a cópia materializada para cada clipe recebe timestamps
da timeline.
"""
def __init__(self, provider: Any, extrator: ExtracaoDeAudio | None = None,
diretoria_de_trabalho: str | Path = ".transcricao") -> None:
diretoria_de_trabalho: str | Path = ".transcricao",
analisador_de_emocao: Any | None = None,
diarizador: Any | None = None) -> None:
self.provider = provider
self.extrator = extrator or ExtracaoDeAudio()
self.diretoria_de_trabalho = Path(diretoria_de_trabalho)
self.analisador_de_emocao = analisador_de_emocao
self.diarizador = diarizador
def executar(self, timeline: Any) -> list[TranscricaoDoClipe]:
resultados: list[TranscricaoDoClipe] = []
clipes: list[Any] = []
for faixa in timeline.faixas:
for clipe in faixa.clipes:
if not clipe.arquivo or clipe.offline:
continue
intervalo = clipe.intervalo_na_timeline
pasta = self.diretoria_de_trabalho / clipe.identificador
origem = clipe.intervalo_na_origem
partes = self.extrator.extrair_intervalo(
clipe.arquivo, pasta, origem.inicio if origem else 0.0,
origem.fim if origem else None,
)
segmentos: list[SegmentoDeTranscricao] = []
for parte in partes:
for segmento in self.provider.transcrever(type("Audio", (), {"arquivo": str(parte.caminho)})()):
base_origem = origem.inicio if origem else 0.0
inicio = max(intervalo.inicio, intervalo.inicio + (parte.inicio - base_origem) + segmento.inicio)
fim = min(intervalo.fim, intervalo.inicio + (parte.inicio - base_origem) + segmento.fim)
if inicio < intervalo.fim and fim > inicio:
segmentos.append(SegmentoDeTranscricao(inicio, fim, segmento.texto, segmento.confianca))
resultados.append(TranscricaoDoClipe(clipe.identificador, clipe.arquivo,
intervalo.inicio, intervalo.fim, segmentos))
clipes.append(clipe)
transcricoes: dict[str, list[SegmentoDeTranscricao]] = {}
for clipe in clipes:
chave = self._chave_do_arquivo(clipe.arquivo)
if chave not in transcricoes:
transcricoes[chave] = self._transcrever_arquivo(clipe.arquivo, chave)
resultados: list[TranscricaoDoClipe] = []
vistos: set[tuple[str, float, float, float, float]] = set()
for clipe in clipes:
intervalo = clipe.intervalo_na_timeline
origem = clipe.intervalo_na_origem
inicio_origem = origem.inicio if origem else 0.0
fim_origem = origem.fim if origem else float("inf")
chave = (self._chave_do_arquivo(clipe.arquivo), inicio_origem, fim_origem,
intervalo.inicio, intervalo.fim)
# Vídeo e áudio vinculados podem representar o mesmo corte.
if chave in vistos:
continue
vistos.add(chave)
segmentos = []
for segmento in transcricoes[chave[0]]:
fim = min(segmento.fim, fim_origem)
inicio = max(segmento.inicio, inicio_origem)
if inicio < fim:
palavras = tuple(
PalavraDeTranscricao(
palavra.texto,
intervalo.inicio + max(palavra.inicio, inicio_origem) - inicio_origem,
intervalo.inicio + min(palavra.fim, fim_origem) - inicio_origem,
palavra.confianca,
palavra.falante,
)
for palavra in segmento.palavras
if palavra.inicio < fim_origem and palavra.fim > inicio_origem
)
segmentos.append(SegmentoDeTranscricao(
intervalo.inicio + inicio - inicio_origem,
intervalo.inicio + fim - inicio_origem,
segmento.texto, segmento.confianca, palavras,
segmento.emocao, segmento.confianca_emocao,
segmento.caracteristicas_acusticas, segmento.falante,
segmento.voz_aparente, segmento.confianca_voz))
resultados.append(TranscricaoDoClipe(clipe.identificador, clipe.arquivo,
intervalo.inicio, intervalo.fim, segmentos,
(inicio_origem, fim_origem) if origem else None))
return resultados
def _chave_do_arquivo(self, arquivo: str) -> str:
caminho = Path(arquivo).expanduser().resolve()
digest = hashlib.sha256()
with caminho.open("rb") as conteudo:
for bloco in iter(lambda: conteudo.read(1024 * 1024), b""):
digest.update(bloco)
return digest.hexdigest()
def _transcrever_arquivo(self, arquivo: str, chave: str) -> list[SegmentoDeTranscricao]:
pasta = self.diretoria_de_trabalho / "arquivos" / chave
nome_arquivo = Path(arquivo).stem
modelo = self._nome_do_modelo()
prefixo = f"transcricao-{self._nome_seguro(nome_arquivo)}-{self._nome_seguro(modelo)}"
cache = pasta / f"{prefixo}.json"
if cache.is_file():
dados = json.loads(cache.read_text(encoding="utf-8"))
if all("palavras" in item and
(self.analisador_de_emocao is None or
("emocao" in item and "voz_aparente" in item))
for item in dados):
return [self._segmento_do_json(item) for item in dados]
partes = self.extrator.extrair(arquivo, pasta / "audio")
segmentos: list[SegmentoDeTranscricao] = []
for parte in partes:
falas = self._diarizar(parte.caminho)
for segmento in self.provider.transcrever(type("Audio", (), {"arquivo": str(parte.caminho)})()):
analise = self._analisar_emocao(parte.caminho, segmento.inicio, segmento.fim)
falante = self._falante_em(falas, segmento.inicio, segmento.fim)
segmentos.append(SegmentoDeTranscricao(parte.inicio + segmento.inicio,
parte.inicio + segmento.fim, segmento.texto, segmento.confianca,
tuple(PalavraDeTranscricao(p.texto, parte.inicio + p.inicio,
parte.inicio + p.fim, p.confianca,
self._falante_em(falas, p.inicio, p.fim))
for p in segmento.palavras),
analise.get("emocao"), analise.get("confianca"),
dict(analise.get("caracteristicas_acusticas", {})), falante,
analise.get("voz_aparente"), analise.get("confianca_voz")))
pasta.mkdir(parents=True, exist_ok=True)
cache.write_text(json.dumps([asdict(item) for item in segmentos], ensure_ascii=False, indent=2), encoding="utf-8")
(pasta / f"{prefixo}.txt").write_text(
" ".join(item.texto for item in segmentos if item.texto).strip() + "\n",
encoding="utf-8",
)
return segmentos
@staticmethod
def _segmento_do_json(item: dict[str, Any]) -> SegmentoDeTranscricao:
palavras = tuple(PalavraDeTranscricao(str(p["texto"]), float(p["inicio"]),
float(p["fim"]), p.get("confianca"),
p.get("falante"))
for p in item.get("palavras", []))
return SegmentoDeTranscricao(float(item["inicio"]), float(item["fim"]),
str(item["texto"]), item.get("confianca"), palavras,
item.get("emocao"), item.get("confianca_emocao"),
dict(item.get("caracteristicas_acusticas", {})), item.get("falante"),
item.get("voz_aparente"), item.get("confianca_voz"))
def _analisar_emocao(self, arquivo: Path, inicio: float, fim: float) -> dict[str, Any]:
if self.analisador_de_emocao is None:
return {}
return dict(self.analisador_de_emocao.analisar(arquivo, inicio, fim))
def _diarizar(self, arquivo: Path) -> list[tuple[float, float, str]]:
if self.diarizador is None:
return []
return list(self.diarizador.analisar(arquivo))
@staticmethod
def _falante_em(falas: list[tuple[float, float, str]], inicio: float, fim: float) -> str | None:
sobreposicoes = [(min(fim, saida) - max(inicio, entrada), falante)
for entrada, saida, falante in falas
if entrada < fim and saida > inicio]
return max(sobreposicoes, default=(0.0, None))[1]
def _nome_do_modelo(self) -> str:
"""Obtém o nome público do modelo sem acoplar o scanner ao provider."""
modelo = getattr(self.provider, "nome_do_modelo", None)
if modelo:
return str(modelo)
modelo = getattr(self.provider, "modelo", None)
if modelo:
return Path(str(modelo)).name
return self.provider.__class__.__name__.lower()
@staticmethod
def _nome_seguro(valor: str) -> str:
return "".join(caractere if caractere.isalnum() or caractere in "._-" else "_"
for caractere in valor).strip("._") or "arquivo"
@staticmethod
def gerar_relatorio(resultados: list[TranscricaoDoClipe], destino: str | Path) -> Path:
caminho = Path(destino)
+184
View File
@@ -0,0 +1,184 @@
from collections.abc import Callable
from dataclasses import dataclass, field
from ..dominio import Clipe
from ..integracoes.visual.contratos import (AnalisadorDeFrame, AnalisadorDeSequenciaDeQuadros,
DetectorDeIntervalosDeCena, ExtratorDeQuadros)
from ..integracoes.visual.modelos import QuadroDeVideo
from .modelos import Cena, CenaVisual, EvidenciaVisual
@dataclass(frozen=True)
class ResultadoVisualDoClipe:
identificador_do_clipe: str
evidencias: list[EvidenciaVisual] = field(default_factory=list)
cenas: list[Cena] = field(default_factory=list)
cenas_visuais: list[CenaVisual] = field(default_factory=list)
class DetectorDeCenas:
"""Módulo profundo que orquestra frames, analisadores e cenas de um clipe.
A interface não expõe OpenCV, ONNX, MediaPipe, PySceneDetect ou Vision.
Cada adapter pode ser trocado sem alterar chamadores nem resultados do domínio.
"""
def __init__(self, extrator: ExtratorDeQuadros,
analisadores_de_frame: list[AnalisadorDeFrame] | None = None,
detectores_de_intervalo: list[DetectorDeIntervalosDeCena] | None = None,
analisadores_de_sequencia: list[AnalisadorDeSequenciaDeQuadros] | None = None,
ao_progresso: Callable[[int, int], None] | None = None) -> None:
self.extrator = extrator
self.analisadores_de_frame = analisadores_de_frame or []
self.analisadores_de_sequencia = analisadores_de_sequencia or []
self.detectores_de_intervalo = detectores_de_intervalo or []
self.ao_progresso = ao_progresso
def detectar(self, clipe: Clipe) -> ResultadoVisualDoClipe:
quadros = self.extrator.extrair(clipe)
total = len(quadros) * len(self.analisadores_de_frame) + len(self.analisadores_de_sequencia)
progresso = [0]
evidencias = self._analisar_quadros(quadros, progresso, total) + self._analisar_sequencia(quadros, progresso, total)
cenas = self._detectar_intervalos(clipe, quadros)
cenas_visuais = [CenaVisual(
clipe.identificador, cena.inicio, cena.fim,
tuple(item for item in evidencias if item.inicio <= cena.fim and item.fim >= cena.inicio),
cena.referencias,
) for cena in cenas]
return ResultadoVisualDoClipe(clipe.identificador, evidencias, cenas, cenas_visuais)
def _analisar_quadros(self, quadros: list[QuadroDeVideo], progresso: list[int] | None = None,
total: int = 0) -> list[EvidenciaVisual]:
resultado: list[EvidenciaVisual] = []
for quadro in quadros:
for analisador in self.analisadores_de_frame:
resultado.extend(analisador.analisar(quadro))
self._avancar_progresso(progresso, total)
return resultado
def _analisar_sequencia(self, quadros: list[QuadroDeVideo], progresso: list[int] | None = None,
total: int = 0) -> list[EvidenciaVisual]:
resultado: list[EvidenciaVisual] = []
for analisador in self.analisadores_de_sequencia:
resultado.extend(analisador.analisar(quadros))
self._avancar_progresso(progresso, total)
return resultado
def _avancar_progresso(self, progresso: list[int] | None, total: int) -> None:
if progresso is None or total <= 0:
return
progresso[0] += 1
if self.ao_progresso:
self.ao_progresso(progresso[0], total)
def _detectar_intervalos(self, clipe: Clipe, quadros: list[QuadroDeVideo]) -> list[Cena]:
cenas: list[Cena] = []
for detector in self.detectores_de_intervalo:
cenas.extend(detector.detectar(clipe, quadros))
cenas = self._consolidar_cenas(cenas)
if not cenas and quadros:
cenas = [Cena(quadros[0].timestamp, quadros[-1].timestamp)]
return cenas
@staticmethod
def _consolidar_cenas(cenas: list[Cena]) -> list[Cena]:
resultado: list[Cena] = []
for cena in sorted(cenas, key=lambda item: (item.inicio, item.fim)):
if resultado and cena.inicio <= resultado[-1].fim:
anterior = resultado[-1]
resultado[-1] = Cena(anterior.inicio, max(anterior.fim, cena.fim),
anterior.confianca or cena.confianca,
anterior.referencias + cena.referencias)
else:
resultado.append(cena)
return resultado
class AnaliseVisualDaTimeline:
"""Etapa do Scanner que guarda evidências e cenas por clipe no contexto."""
nome = "analise_visual_local"
def __init__(self, detector: DetectorDeCenas, continuar_em_falha: bool = True) -> None:
self.detector = detector
self.continuar_em_falha = continuar_em_falha
def executar(self, contexto):
if contexto.timeline is None:
return contexto
for faixa in contexto.timeline.faixas:
for clipe in faixa.clipes:
if clipe.offline or not clipe.arquivo:
continue
try:
resultado = self.detector.detectar(clipe)
except Exception as exc:
if not self.continuar_em_falha:
raise
contexto.avisos.append(
f"analise_visual_indisponivel: clipe {clipe.identificador}: {exc}"
)
contexto.caracteristicas_visuais[clipe.identificador] = {
"evidencias": [], "cenas": [], "cenas_visuais": [], "disponivel": False,
}
continue
contexto.caracteristicas_visuais[clipe.identificador] = {
"evidencias": resultado.evidencias,
"cenas": resultado.cenas,
"cenas_visuais": resultado.cenas_visuais,
"disponivel": True,
}
contexto.cenas.extend(resultado.cenas)
contexto.cenas_visuais.extend(resultado.cenas_visuais)
contexto.cenas = DetectorDeCenas._consolidar_cenas(contexto.cenas)
return contexto
def criar_detector_visual_local(
diretorio_de_cache: str | None = ".frames",
intervalo_em_segundos: float = 1.0,
) -> DetectorDeCenas:
"""Monta o detector local padrão com uma interface curta para o Scanner."""
from ..integracoes.visual import (
AnalisadorDeComposicaoOpenCV,
AnalisadorDeContinuidadeOpenCV,
AnalisadorDeQualidadeOpenCV,
ExtratorDeQuadrosOpenCV,
)
return DetectorDeCenas(
ExtratorDeQuadrosOpenCV(
intervalo_em_segundos=intervalo_em_segundos,
diretorio_de_cache=diretorio_de_cache,
),
analisadores_de_frame=[
AnalisadorDeQualidadeOpenCV(),
AnalisadorDeComposicaoOpenCV(),
],
analisadores_de_sequencia=[AnalisadorDeContinuidadeOpenCV()],
)
def criar_detector_apple_vision(
diretorio_de_cache: str | None = ".frames",
intervalo_em_segundos: float = 1.0,
configuracao=None,
) -> DetectorDeCenas:
"""Monta a análise local de cena baseada em Vision e arquivos de origem."""
from .configuracao_visual import ConfiguracaoVisualDoScanner
from ..integracoes.visual import (AnalisadorAppleVisionNativo,
AnalisadorSequenciaAppleVisionNativo,
ExtratorDeQuadrosFFmpeg)
perfil = configuracao or ConfiguracaoVisualDoScanner(intervalo_em_segundos=intervalo_em_segundos)
return DetectorDeCenas(
ExtratorDeQuadrosFFmpeg(intervalo_em_segundos=perfil.intervalo_em_segundos,
diretorio_de_cache=diretorio_de_cache or ".frames"),
analisadores_de_frame=[AnalisadorAppleVisionNativo(
recursos=tuple(sorted(perfil.recursos_vision)),
interpretar_com_apple_intelligence=perfil.interpretar_cena,
)],
analisadores_de_sequencia=[AnalisadorSequenciaAppleVisionNativo()]
if perfil.analisar_continuidade else [],
)