diff --git a/.jhonny/analises.db b/.jhonny/analises.db index 90ed321..3529444 100644 Binary files a/.jhonny/analises.db and b/.jhonny/analises.db differ diff --git a/code/cep-plugin/main.js b/code/cep-plugin/main.js index c75a650..c27fde8 100755 --- a/code/cep-plugin/main.js +++ b/code/cep-plugin/main.js @@ -944,6 +944,7 @@ var RETAKES_ENGINE_SCRIPT = "/Volumes/Merongo/SISTEMAS/GENIAL SISTEMAS/Jhonny/co // (Python 3.14, com faster_whisper) garante que Scanner e Retakes carreguem bem. var SCANNER_PYTHON_BIN = "/Volumes/Merongo/SISTEMAS/venvs/whisperx-transcricao/bin/python"; var ANALISE_STATUS_SCRIPT = "/Volumes/Merongo/SISTEMAS/GENIAL SISTEMAS/Jhonny/code/engine/consultar_status_da_analise.py"; +var ANALISE_PREVIEW_SCRIPT = "/Volumes/Merongo/SISTEMAS/GENIAL SISTEMAS/Jhonny/code/engine/consultar_preview_de_falantes.py"; var retakesCasoAtual = null; var retakesGrupos = []; var retakesProcesso = null; @@ -2231,6 +2232,20 @@ function silenceConsultarStatusNoBanco(videoId) { } catch (_) { return null; } } +function silenceConsultarPreviewDeFalantes(videoId) { + if (!videoId || !childProcess.spawnSync) return null; + try { + var resposta = childProcess.spawnSync( + SCANNER_PYTHON_BIN, + [ANALISE_PREVIEW_SCRIPT, videoId, "--banco", "/Volumes/Merongo/SISTEMAS/GENIAL SISTEMAS/Jhonny/.jhonny/analises.db"], + { cwd: "/Volumes/Merongo/SISTEMAS/GENIAL SISTEMAS/Jhonny/code", encoding: "utf-8", maxBuffer: 4 * 1024 * 1024 } + ); + if (resposta.status !== 0) return null; + var dados = JSON.parse(String(resposta.stdout || "{}")); + return Array.isArray(dados.intervalos) ? dados.intervalos : null; + } catch (_) { return null; } +} + function silenceLocalizarAudioParaFalantes(pasta) { var encontrados = []; function visitar(caminho) { @@ -2307,6 +2322,20 @@ function silenceLoadScannerAnalysis() { var turnos = transcricao.segments.filter(function (segmento) { return segmento.falante; }).map(function (segmento) { return { speaker: segmento.falante, start: segmento.start, end: segmento.end }; }); + var intervalosDoBanco = silenceConsultarPreviewDeFalantes(silenceState.sequenceId); + if (intervalosDoBanco && intervalosDoBanco.length) { + turnos = intervalosDoBanco.map(function (intervalo) { + return { + speaker: intervalo.falante, + start: intervalo.inicio, + end: intervalo.fim, + timelineStart: intervalo.inicio_na_timeline, + timelineEnd: intervalo.fim_na_timeline, + mediaPath: intervalo.arquivo, + }; + }); + silenceState.mediaPath = intervalosDoBanco[0].arquivo || silenceState.mediaPath; + } renderSpeakerConfiguration(falantes, turnos); atualizarTelaRefinar(falantes, audioDoBanco); status.textContent = "Carregada"; @@ -3089,9 +3118,6 @@ function toggleSpeakerVideo(speakerId, button) { speakerVideoPlayer = video; preview.hidden = false; if (info) info.textContent = "Reproduzindo os trechos de " + speakerId + "."; - video.src = speakerMediaFileUrl(silenceState.mediaPath); - button.textContent = "■ vídeo"; - button.classList.add("is-playing"); video.onloadedmetadata = playCurrentSpeakerVideoRange; video.ontimeupdate = advanceSpeakerVideoRange; video.onended = advanceSpeakerVideoRange; @@ -3099,6 +3125,10 @@ function toggleSpeakerVideo(speakerId, button) { stopSpeakerAudio(); showToast("err", "Não foi possível reproduzir o vídeo deste falante."); }; + button.textContent = "■ vídeo"; + button.classList.add("is-playing"); + video.src = speakerMediaFileUrl(silenceState.mediaPath); + video.load(); } function playCurrentSpeakerVideoRange() { diff --git a/code/engine/consultar_preview_de_falantes.py b/code/engine/consultar_preview_de_falantes.py new file mode 100644 index 0000000..5464fc1 --- /dev/null +++ b/code/engine/consultar_preview_de_falantes.py @@ -0,0 +1,37 @@ +"""Consulta os intervalos de vídeo necessários ao preview dos falantes.""" + +from __future__ import annotations + +import argparse +import json +from pathlib import Path +import sys + +CAMINHO_DO_CODIGO = Path(__file__).resolve().parent.parent +if str(CAMINHO_DO_CODIGO) not in sys.path: + sys.path.insert(0, str(CAMINHO_DO_CODIGO)) + +from engine.persistencia.consultas import ConsultasDeAnalises + + +def executar(caminho_do_banco: Path, video_id: str) -> dict: + """Consulta e serializa os intervalos de preview de um vídeo.""" + intervalos = ConsultasDeAnalises(caminho_do_banco).listar_intervalos_de_preview_dos_falantes(video_id) + return {"video_id": video_id, "intervalos": intervalos} + + +def principal() -> None: + """Executa a consulta pela linha de comando.""" + argumentos = argparse.ArgumentParser() + argumentos.add_argument("video_id") + argumentos.add_argument("--banco", type=Path, default=Path(".jhonny/analises.db")) + opcoes = argumentos.parse_args() + try: + print(json.dumps(executar(opcoes.banco, opcoes.video_id), ensure_ascii=False)) + except (OSError, ValueError) as erro: + print(json.dumps({"erro": str(erro)}, ensure_ascii=False)) + raise SystemExit(1) from erro + + +if __name__ == "__main__": + principal() diff --git a/code/engine/persistencia/consultas.py b/code/engine/persistencia/consultas.py index d59d195..95beb9e 100644 --- a/code/engine/persistencia/consultas.py +++ b/code/engine/persistencia/consultas.py @@ -135,6 +135,86 @@ class ConsultasDeAnalises: }, } + def listar_intervalos_de_preview_dos_falantes(self, video_id: str) -> list[dict]: + """ + Lista os intervalos dos falantes convertidos para o arquivo de vídeo. + + Os segmentos de transcrição usam segundos da timeline e normalmente + pertencem a um clipe de áudio. A consulta encontra o clipe de vídeo + correspondente e calcula os segundos na origem usando os campos + ``inicio_na_timeline`` e ``inicio_na_origem`` da tabela ``clipes``. + + Parâmetros: + video_id: Identificador do vídeo analisado. + + Retorna: + Lista ordenada de intervalos com falante, tempos da timeline, + tempos no arquivo de origem e caminho do vídeo. + + Pode gerar: + ErroDeConsultaInvalida: quando ``video_id`` estiver vazio. + """ + self._validar_texto_obrigatorio("video_id", video_id) + linhas = self.conexao.execute( + """SELECT s.clipe_id, s.inicio, s.fim, s.falante, + a.arquivo AS arquivo_do_audio, + v.id AS clipe_de_video, v.inicio_na_timeline, + v.fim_na_timeline, v.inicio_na_origem, + v.fim_na_origem, v.arquivo AS arquivo_do_video + FROM segmentos_de_transcricao AS s + LEFT JOIN clipes AS a + ON a.video_id = s.video_id AND a.id = s.clipe_id + LEFT JOIN clipes AS v + ON v.video_id = s.video_id + AND v.inicio_na_timeline < s.fim + AND v.fim_na_timeline > s.inicio + LEFT JOIN faixas AS f + ON f.video_id = v.video_id AND f.id = v.faixa_id + AND f.tipo = 'video' + WHERE s.video_id = ? + AND s.falante IS NOT NULL + AND TRIM(s.falante) <> '' + AND (f.id IS NOT NULL OR v.id IS NULL) + ORDER BY s.inicio, s.fim, + (v.arquivo = a.arquivo) DESC, s.id""", + (video_id,), + ).fetchall() + + resultado = [] + vistos = set() + for linha in linhas: + chave = (linha["clipe_id"], linha["inicio"], linha["fim"], linha["falante"]) + if chave in vistos: + continue + vistos.add(chave) + inicio_na_timeline = float(linha["inicio"]) + fim_na_timeline = float(linha["fim"]) + inicio_na_origem = linha["inicio_na_origem"] + fim_na_origem = linha["fim_na_origem"] + arquivo = linha["arquivo_do_video"] or linha["arquivo_do_audio"] + if inicio_na_origem is None: + inicio_no_arquivo = inicio_na_timeline + fim_no_arquivo = fim_na_timeline + else: + deslocamento = inicio_na_timeline - float(linha["inicio_na_timeline"]) + inicio_no_arquivo = float(inicio_na_origem) + deslocamento + fim_no_arquivo = inicio_no_arquivo + (fim_na_timeline - inicio_na_timeline) + if fim_na_origem is not None: + fim_no_arquivo = min(fim_no_arquivo, float(fim_na_origem)) + if not arquivo or fim_no_arquivo <= inicio_no_arquivo: + continue + resultado.append({ + "clipe_id": linha["clipe_id"], + "falante": linha["falante"], + "inicio_na_timeline": inicio_na_timeline, + "fim_na_timeline": fim_na_timeline, + "inicio": max(0.0, inicio_no_arquivo), + "fim": max(0.0, fim_no_arquivo), + "arquivo": arquivo, + "clipe_de_video": linha["clipe_de_video"], + }) + return resultado + def listar_falas_no_intervalo( self, video_id: str, diff --git a/code/engine/testes/test_consultas_de_persistencia.py b/code/engine/testes/test_consultas_de_persistencia.py index 27af880..f68fbf1 100644 --- a/code/engine/testes/test_consultas_de_persistencia.py +++ b/code/engine/testes/test_consultas_de_persistencia.py @@ -105,6 +105,35 @@ class TesteConsultasDeAnalises(unittest.TestCase): self.assertFalse(status["audio"]["metricas_de_voz_disponiveis"]) self.assertEqual(status["audio"]["falantes"], ["locutor"]) + def test_listar_intervalos_de_preview_converte_timeline_para_origem(self) -> None: + self.consultas.conexao.executescript( + """INSERT INTO faixas (id, video_id, nome, tipo, indice) + VALUES ('audio-2', 'v1', 'A2', 'audio', 1); + INSERT INTO faixas (id, video_id, nome, tipo, indice) + VALUES ('video-2', 'v1', 'V2', 'video', 1); + INSERT INTO clipes + (id, video_id, faixa_id, nome, inicio_na_timeline, + fim_na_timeline, inicio_na_origem, fim_na_origem, arquivo) + VALUES ('audio-clip-2', 'v1', 'audio-2', 'A2', 30.0, 40.0, + 100.0, 110.0, '/videos/entrevista.mp4'); + INSERT INTO clipes + (id, video_id, faixa_id, nome, inicio_na_timeline, + fim_na_timeline, inicio_na_origem, fim_na_origem, arquivo) + VALUES ('video-clip-2', 'v1', 'video-2', 'V2', 30.0, 40.0, + 100.0, 110.0, '/videos/entrevista.mp4'); + INSERT INTO segmentos_de_transcricao + (video_id, clipe_id, inicio, fim, texto, falante) + VALUES ('v1', 'audio-clip-2', 32.5, 35.0, 'trecho', 'locutor-2');""" + ) + + intervalos = self.consultas.listar_intervalos_de_preview_dos_falantes(VIDEO_ID) + + intervalo = next(item for item in intervalos if item["falante"] == "locutor-2") + self.assertEqual(intervalo["clipe_de_video"], "video-clip-2") + self.assertEqual(intervalo["inicio"], 102.5) + self.assertEqual(intervalo["fim"], 105.0) + self.assertEqual(intervalo["arquivo"], "/videos/entrevista.mp4") + # --- listar_falas_no_intervalo ------------------------------------------------------ def test_listar_falas_no_intervalo_filtra_por_tempo_e_omite_palavras_por_padrao(self) -> None: