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:
@@ -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")
|
||||
|
||||
Reference in New Issue
Block a user