feat: removido o tamanho do modelo do seletor da tela Editar vídeo

- removido o tamanho do modelo do seletor da tela Editar vídeo
- acelerada a transcrição do Scanner com inferência Whisper em lote e cache de hashes
- garantido um resultado separado para cada clipe de áudio selecionado no Scanner
- adicionado cancelamento do processamento do Scanner pela barra de progresso
- exibido estado visual próprio para Scanner cancelado na barra de progresso
- criado modo explícito de análise do Scanner para separar som, imagem ou ambos
- adicionadas métricas opcionais de fala ao Scanner com análise acústica na engine
- corrigido o progresso da transcrição do Scanner durante o processamento do Whisper
- refatorado o runner Swift do Apple Vision para executar requests em lote com retry CPU-only, corrigindo falhas de CVPixelBuffer/ANE/OCR
- documentado o fluxo de edição de vídeo por voz integrado ao Scanner, banco e engine
- formalizado o protocolo de edição por chat com perfil editorial e consultas progressivas
- gerado plano JSON de corte para depoimento vertical da Erika no Instagram
- esquematizado o fluxo de colar e executar planos da IA pela engine após o Scanner
- ajustado o plano para manter somente as três etapas existentes da tela de edição
- adicionado processamento incremental de locutores e métricas no Scanner com confirmação de reprocessamento
- implementado o plano JSON colado na tela e a persistência idempotente das análises do Scanner

Resumo:
- 8 arquivos alterados
- 1 novos
- 7 modificados
- 0 removidos

 7 files changed, 437 insertions(+), 55 deletions(-)

Arquivos:
  - code/cep-plugin/index.html
  - code/cep-plugin/main.js
  - code/cep-plugin/styles.css
  - code/engine/executar_scanner.py
  - code/engine/persistencia/repositorio_de_analises_sqlite.py
  - code/engine/testes/test_persistencia_sqlite.py
  - docs/PLANO-EDICAO-DE-VIDEO-POR-VOZ.md
  - code/relatorios/plano-edicao-depoimento-erika-instagram.json
This commit is contained in:
João Henrique
2026-09-09 19:10:20 -04:00
parent c4c942044f
commit f6dd03f7b4
8 changed files with 526 additions and 55 deletions
+92 -2
View File
@@ -8,6 +8,7 @@ from pathlib import Path
import sys
import tempfile
import re
from types import SimpleNamespace
# O painel chama este arquivo diretamente, portanto o Python inclui apenas
# ``code/engine`` no caminho de importação. O pacote público fica em ``code``.
@@ -167,7 +168,7 @@ def executar(entrada: Path, saida: Path) -> Path:
analisador_de_metricas_de_voz=analisador_de_metricas_de_voz).executar(
Timeline(timeline.identificador, timeline.nome, faixas=faixas_de_audio))
# Persiste a transcrição (segmentos + falas) no banco de análises.
repositorio_de_analises.registrar_transcricoes(timeline.identificador, transcricoes)
repositorio_de_analises.substituir_transcricoes(timeline.identificador, transcricoes)
for transcricao in transcricoes:
transcricoes_por_clipe[transcricao.identificador_do_clipe] = [
{"inicio": item.inicio, "fim": item.fim, "texto": item.texto,
@@ -193,9 +194,98 @@ def executar(entrada: Path, saida: Path) -> Path:
return saida
def executar_complemento(entrada: Path, relatorio: Path, acao: str) -> Path:
"""Processa locutores ou métricas usando a transcrição já persistida."""
pedido = json.loads(entrada.read_text(encoding="utf-8"))
dados_relatorio = json.loads(relatorio.read_text(encoding="utf-8"))
timeline = ConversorDeTimeline().converter(pedido["timeline"])
clipes = {clipe.identificador: clipe for faixa in timeline.faixas for clipe in faixa.clipes}
partes_por_arquivo: dict[str, list] = {}
diarizacao_por_parte: dict[str, list] = {}
if acao == "diarizacao":
if not os.environ.get("HF_TOKEN"):
raise RuntimeError("Token do Hugging Face não configurado para identificar locutores.")
from engine.integracoes.huggingface import ProviderDeDiarizacaoHuggingFace
diarizador = ProviderDeDiarizacaoHuggingFace(os.environ.get("HF_DIARIZATION_MODEL", MODELO_DIARIZACAO_PADRAO))
analisador = None
elif acao == "metricas_de_fala":
from engine.integracoes.audio import AnalisadorDeMetricasDeVoz
diarizador = None
analisador = AnalisadorDeMetricasDeVoz()
else:
raise ValueError(f"Complemento desconhecido: {acao}")
arquivos_audio = dados_relatorio["artefatos"].get("audio", [])
repositorio_de_analises = RepositorioDeAnalisesSQLite(CAMINHO_DO_BANCO)
total = max(1, len(arquivos_audio))
for indice_arquivo, nome_arquivo in enumerate(arquivos_audio, 1):
caminho_relatorio = relatorio.parent / nome_arquivo
dados_audio = json.loads(caminho_relatorio.read_text(encoding="utf-8"))
for grupo in dados_audio.get("segmentos_por_clipe", []):
clipe = clipes.get(grupo.get("clipe"))
if not clipe or not clipe.arquivo:
continue
caminho_origem = str(clipe.arquivo)
if caminho_origem not in partes_por_arquivo:
partes_por_arquivo[caminho_origem] = ExtracaoDeAudio().extrair(
caminho_origem, relatorio.parent / ".cache-audio-complementar" / str(abs(hash(caminho_origem))))
partes = partes_por_arquivo[caminho_origem]
inicio_origem = clipe.intervalo_na_origem.inicio if clipe.intervalo_na_origem else 0.0
fim_anterior = None
for segmento in grupo.get("segmentos", []):
inicio = inicio_origem + segmento["inicio"] - clipe.intervalo_na_timeline.inicio
fim = inicio_origem + segmento["fim"] - clipe.intervalo_na_timeline.inicio
parte = next((item for item in partes if item.inicio <= inicio <= item.fim), partes[-1])
inicio_local = max(0.0, inicio - parte.inicio)
fim_local = max(inicio_local, min(fim, parte.fim) - parte.inicio)
chave = str(parte.caminho)
if acao == "diarizacao":
if chave not in diarizacao_por_parte:
diarizacao_por_parte[chave] = diarizador.analisar(parte.caminho)
falas = diarizacao_por_parte[chave]
sobreposicoes = [(min(fim_local, saida) - max(inicio_local, entrada), falante)
for entrada, saida, falante in falas
if entrada < fim_local and saida > inicio_local]
segmento["falante"] = max(sobreposicoes, default=(0.0, None))[1]
repositorio_de_analises.atualizar_enriquecimento_de_transcricao(
timeline.identificador,
clipe.identificador,
segmento["inicio"],
segmento["fim"],
falante=segmento.get("falante"),
)
else:
palavras = [SimpleNamespace(inicio=0.0, fim=0.0)
for _ in segmento.get("texto", "").split()]
segmento["caracteristicas_acusticas"] = analisador.analisar(
parte.caminho, inicio_local, fim_local, palavras,
segmento["inicio"], fim_anterior)
repositorio_de_analises.atualizar_enriquecimento_de_transcricao(
timeline.identificador,
clipe.identificador,
segmento["inicio"],
segmento["fim"],
caracteristicas_acusticas=segmento["caracteristicas_acusticas"],
)
fim_anterior = segmento["fim"]
dados_audio.setdefault("transcricao", {})["diarizacao" if acao == "diarizacao" else "metricas_de_fala"] = True
caminho_relatorio.write_text(json.dumps(dados_audio, ensure_ascii=False, indent=2), encoding="utf-8")
print(json.dumps({"evento": "progresso", "etapa": "Complementando transcrição",
"percentual": round(100 * indice_arquivo / total, 1)}, ensure_ascii=False), flush=True)
dados_relatorio.setdefault("perfil", {}).setdefault("transcricao", {})[
"diarizacao" if acao == "diarizacao" else "metricas_de_fala"] = True
dados_relatorio.write_text(json.dumps(dados_relatorio, ensure_ascii=False, indent=2), encoding="utf-8")
return relatorio
if __name__ == "__main__":
parser = argparse.ArgumentParser()
parser.add_argument("entrada", type=Path)
parser.add_argument("saida", type=Path)
parser.add_argument("--complemento", choices=("diarizacao", "metricas_de_fala"))
args = parser.parse_args()
print(json.dumps({"evento": "concluido", "arquivo": str(executar(args.entrada, args.saida))}, ensure_ascii=False))
if args.complemento:
caminho = executar_complemento(args.entrada, args.saida, args.complemento)
else:
caminho = executar(args.entrada, args.saida)
print(json.dumps({"evento": "concluido", "arquivo": str(caminho)}, ensure_ascii=False))
@@ -65,6 +65,56 @@ class RepositorioDeAnalisesSQLite:
for segmento in transcricao.segmentos:
self._inserir_segmento(video_id, transcricao.identificador_do_clipe, segmento)
def substituir_transcricoes(
self, video_id: str, transcricoes: Iterable[TranscricaoDoClipe],
) -> None:
"""Substitui a transcrição somente dos clipes recebidos.
O método torna o reprocessamento idempotente sem apagar análises de
outros clipes ou faixas que não participaram da execução atual.
"""
with self.conexao:
self._garantir_video(video_id)
transcricoes_materializadas = tuple(transcricoes)
for transcricao in transcricoes_materializadas:
self.conexao.execute(
"""DELETE FROM segmentos_de_transcricao
WHERE video_id = ? AND clipe_id = ?""",
(video_id, transcricao.identificador_do_clipe),
)
for segmento in transcricao.segmentos:
self._inserir_segmento(video_id, transcricao.identificador_do_clipe, segmento)
def atualizar_enriquecimento_de_transcricao(
self,
video_id: str,
clipe_id: str,
inicio: float,
fim: float,
*,
falante: str | None = None,
caracteristicas_acusticas: dict[str, object] | None = None,
) -> None:
"""Atualiza diarização e métricas de um segmento já persistido."""
campos = []
parametros: list[object] = []
if falante is not None:
campos.append("falante = ?")
parametros.append(falante)
if caracteristicas_acusticas is not None:
campos.append("caracteristicas_acusticas = ?")
parametros.append(json.dumps(caracteristicas_acusticas, ensure_ascii=False))
if not campos:
return
parametros.extend((video_id, clipe_id, inicio, fim))
with self.conexao:
self.conexao.execute(
f"""UPDATE segmentos_de_transcricao
SET {', '.join(campos)}
WHERE video_id = ? AND clipe_id = ? AND inicio = ? AND fim = ?""",
parametros,
)
def _inserir_segmento(
self, video_id: str, clipe_id: str, segmento: SegmentoDeTranscricao,
) -> int:
@@ -156,6 +156,43 @@ class TesteRepositorioDeAnalisesSQLite(unittest.TestCase):
self.assertEqual(recarregado.caracteristicas_acusticas, {"pitch_medio": 120.5})
self.assertEqual(recarregado.voz_aparente, "masculina")
def test_substituir_transcricoes_nao_duplica_ao_reprocessar(self):
with tempfile.TemporaryDirectory() as pasta:
repositorio = RepositorioDeAnalisesSQLite(Path(pasta) / "analises.db")
primeiro = TranscricaoDoClipe(
"clip-1", "/v.mp4", 0.0, 2.0,
[SegmentoDeTranscricao(0.0, 1.0, "primeira versão", 0.9)],
)
segundo = TranscricaoDoClipe(
"clip-1", "/v.mp4", 0.0, 2.0,
[SegmentoDeTranscricao(0.0, 1.0, "segunda versão", 0.95)],
)
repositorio.substituir_transcricoes("v1", [primeiro])
repositorio.substituir_transcricoes("v1", [segundo])
carregados = repositorio.carregar_transcricoes("v1", "clip-1")
self.assertEqual(len(carregados), 1)
self.assertEqual(carregados[0].texto, "segunda versão")
def test_atualizar_enriquecimento_persiste_falante_e_metricas(self):
with tempfile.TemporaryDirectory() as pasta:
repositorio = RepositorioDeAnalisesSQLite(Path(pasta) / "analises.db")
segmento = SegmentoDeTranscricao(0.0, 2.0, "fala", 0.9)
repositorio.registrar_transcricoes(
"v1", [TranscricaoDoClipe("clip-1", "/v.mp4", 0.0, 2.0, [segmento])],
)
repositorio.atualizar_enriquecimento_de_transcricao(
"v1", "clip-1", 0.0, 2.0,
falante="SPEAKER_00",
caracteristicas_acusticas={"energia_rms": 0.12},
)
carregado = repositorio.carregar_transcricoes("v1", "clip-1")[0]
self.assertEqual(carregado.falante, "SPEAKER_00")
self.assertEqual(carregado.caracteristicas_acusticas, {"energia_rms": 0.12})
def test_registrar_evidencias_visuais_e_cenas(self):
with tempfile.TemporaryDirectory() as pasta:
repositorio = RepositorioDeAnalisesSQLite(Path(pasta) / "analises.db")