Compare commits

..
37 Commits
Author SHA1 Message Date
João Henrique c64a866838 feat: integrado o cálculo e a aplicação verificada do Crop na Adju
- integrado o cálculo e a aplicação verificada do Crop na Adjustment Layer do zoom

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

 1 file changed, 15 insertions(+)

Arquivos:
  - code/engine/aplicar_montagem_incremental_prototipo.py
2026-09-10 21:03:52 -04:00
João Henrique d2d832eab2 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:
- 1 arquivos alterados
- 0 novos
- 1 modificados
- 0 removidos

 1 file changed, 0 insertions(+), 0 deletions(-)

Arquivos:
  - .jhonny/analises.db
2026-09-10 20:57:11 -04:00
João Henrique 77b2bc9414 feat: corrigida a inserção da Adjustment Layer em faixa exclusiva
- corrigida a inserção da Adjustment Layer em faixa exclusiva no teste incremental

Resumo:
- 2 arquivos alterados
- 0 novos
- 2 modificados
- 0 removidos

 2 files changed, 6 insertions(+), 1 deletion(-)

Arquivos:
  - .jhonny/analises.db
  - code/engine/aplicar_montagem_incremental_prototipo.py
2026-09-10 20:24:41 -04:00
João Henrique 6b148c4e4e feat: permitido testar plano somente com zoom reconstruindo a mídi
- permitido testar plano somente com zoom reconstruindo a mídia completa antes da camada de ajuste

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

 1 file changed, 18 insertions(+), 6 deletions(-)

Arquivos:
  - code/engine/aplicar_montagem_incremental_prototipo.py
2026-09-10 19:39:33 -04:00
João Henrique c22e240abe feat: preparado teste incremental do gancho com inserção de adjust
- preparado teste incremental do gancho com inserção de adjustment layer na faixa superior
- integrado o teste de adjustment layer ao checkbox da montagem incremental do painel

Resumo:
- 6 arquivos alterados
- 2 novos
- 4 modificados
- 0 removidos

 4 files changed, 80 insertions(+)

Arquivos:
  - .jhonny/analises.db
  - code/cep-plugin/main.js
  - code/engine/aplicar_montagem_incremental_prototipo.py
  - code/src/tools/timeline.ts
  - code/engine/editor/plano_teste_gancho_adjustment.json
  - code/engine/editor/plano_teste_zooms_plano_6.json
2026-09-10 19:37:09 -04:00
João Henrique 8dfe409f52 feat: corrigida a descoberta do Node no executor incremental quand
- corrigida a descoberta do Node no executor incremental quando chamado pelo painel

Resumo:
- 2 arquivos alterados
- 0 novos
- 2 modificados
- 0 removidos

 2 files changed, 25 insertions(+), 7 deletions(-)

Arquivos:
  - .jhonny/analises.db
  - code/engine/aplicar_montagem_incremental_prototipo.py
2026-09-10 19:22:43 -04:00
João Henrique 59f1358f18 feat: criado protótipo local de montagem incremental com gancho e
- criado protótipo local de montagem incremental com gancho e trechos preservados
- adicionado checkbox no painel para executar planos pelo modo de montagem incremental

Resumo:
- 8 arquivos alterados
- 4 novos
- 4 modificados
- 0 removidos

 4 files changed, 230 insertions(+)

Arquivos:
  - .jhonny/analises.db
  - code/cep-plugin/index.html
  - code/cep-plugin/main.js
  - code/src/tools/timeline.ts
  - code/engine/aplicar_montagem_incremental_prototipo.py
  - code/engine/editor/pedido_montagem_incremental_exemplo.json
  - code/engine/editor/plano_montagem_incremental_6.json
  - code/engine/editor/prototipo_montagem_incremental.py
2026-09-10 19:21:07 -04:00
João Henrique 024b004938 feat: verificação do resultado do razor_all_tracks para falha limp
- verificação do resultado do razor_all_tracks para falha limpa em cortes e ganchos

Resumo:
- 2 arquivos alterados
- 0 novos
- 2 modificados
- 0 removidos

 2 files changed, 12 insertions(+), 2 deletions(-)

Arquivos:
  - code/engine/editor/aplicador_de_plano_de_edicao.py
  - code/engine/testes/test_aplicador_de_plano_de_edicao.py
2026-09-10 18:24:46 -04:00
João Henrique e73b094187 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:
- 4 arquivos alterados
- 0 novos
- 4 modificados
- 0 removidos

 4 files changed, 101 insertions(+), 4 deletions(-)

Arquivos:
  - code/engine/editor/aplicador_de_plano_de_edicao.py
  - code/engine/editor/escrita/escrita_no_editor.py
  - code/engine/testes/duplos_de_premiere.py
  - code/engine/testes/test_aplicador_de_plano_de_edicao.py
2026-09-10 18:07:16 -04:00
João Henrique 4170b32340 feat: corrigido encoding UTF-8 nos spawns de Python do painel CEP
- corrigido encoding UTF-8 nos spawns de Python do painel CEP para exibir caracteres acentuados corretamente
- corrigido copiar erro técnico: forçado pbcopy com encoding utf-8 em vez de navigator.clipboard que corrompia caracteres

Resumo:
- 2 arquivos alterados
- 0 novos
- 2 modificados
- 0 removidos

 2 files changed, 8 insertions(+), 12 deletions(-)

Arquivos:
  - code/cep-plugin/main.js
  - code/engine/integracoes/premiere/cliente_mcp.py
2026-09-10 17:51:10 -04:00
João Henrique 0260254e45 feat: corrigido encoding UTF-8 nos spawns de Python do painel CEP
- corrigido encoding UTF-8 nos spawns de Python do painel CEP para exibir caracteres acentuados corretamente

Resumo:
- 3 arquivos alterados
- 0 novos
- 3 modificados
- 0 removidos

 3 files changed, 21 insertions(+), 17 deletions(-)

Arquivos:
  - .jhonny/analises.db
  - code/cep-plugin/main.js
  - code/engine/aplicar_plano_de_edicao.py
2026-09-10 17:36:54 -04:00
João Henrique 2c11ba8dda feat: corrigido o KeyError 'totalTracks' no cliente MCP: extração
- corrigido o KeyError 'totalTracks' no cliente MCP: extração de structuredContent.data na resposta do servidor (SDK 2.0+)
- sincronizado cortes de vídeo e áudio usando razor_all_tracks em vez de split_clip por faixa, eliminando drift entre cortes
- atualizado hook para usar razor_all_tracks, isolando vídeo e áudio no mesmo instante
- adicionado simulador razor_all_tracks no dublê de teste

Resumo:
- 5 arquivos alterados
- 0 novos
- 5 modificados
- 0 removidos

 5 files changed, 264 insertions(+), 12 deletions(-)

Arquivos:
  - .jhonny/analises.db
  - code/engine/editor/aplicador_de_plano_de_edicao.py
  - code/engine/editor/escrita/escrita_no_editor.py
  - code/engine/integracoes/visual/apple_vision_runner.swift
  - code/engine/testes/duplos_de_premiere.py
2026-09-10 17:31:10 -04:00
João Henrique fc7310be39 feat: corrigido o KeyError 'totalTracks' no cliente MCP: extração
- corrigido o KeyError 'totalTracks' no cliente MCP: extração de structuredContent.data na resposta do servidor (SDK 2.0+)

Resumo:
- 2 arquivos alterados
- 0 novos
- 2 modificados
- 0 removidos

 2 files changed, 6 insertions(+)

Arquivos:
  - .jhonny/analises.db
  - code/engine/integracoes/premiere/cliente_mcp.py
2026-09-10 17:04:43 -04:00
João Henrique 114b63e81f 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:
- 1 arquivos alterados
- 0 novos
- 1 modificados
- 0 removidos

 1 file changed, 0 insertions(+), 0 deletions(-)

Arquivos:
  - .jhonny/analises.db
2026-09-10 16:51:23 -04:00
João Henrique dd341e7d4c feat: Instalado essentia-tensorflow no venv whisperx-transcricao (
- Instalado essentia-tensorflow no venv whisperx-transcricao (usado pelo painel CEP), corrigindo erro "A classificação musical precisa do suporte TensorFlow do Essentia" no catálogo de trilhas.
- Ligada a classificação de emoção da fala (ProviderDeEmocaoHuggingFace) ao scanner via nova flag `classificar_emocao`, e adicionado emoção/confiança como critério de desempate secundário no zoom da skill editar-por-voz.
- Zoom do caminho Python (aplicar_plano_de_edicao.py) passou a usar adjustment layer numa faixa de vídeo própria, em vez de escalar o clipe inteiro via set_clip_properties — mesma técnica já usada pelo executor .mjs do painel CEP.
- Adicionado campo "Carregar por ID" na aba Editar Vídeo do painel CEP (step 2), para carregar um plano do SQLite direto pelo plano_id quando ele não está associado a nenhum video_id conhecido (ex.: plano de um vídeo que este banco nunca escaneou).
- Trocado o campo de ID numérico por uma lista suspensa (novo listar_planos_de_edicao.py + RepositorioDePlanos.listar_todos_os_planos) que mostra data/hora e a intenção registrada pela skill em cada plano salvo, para escolher sem decorar o id.

Resumo:
- 15 arquivos alterados
- 2 novos
- 13 modificados
- 0 removidos

 13 files changed, 451 insertions(+), 65 deletions(-)

Arquivos:
  - .jhonny/analises.db
  - code/cep-plugin/index.html
  - code/cep-plugin/main.js
  - code/engine/editor/aplicador_de_plano_de_edicao.py
  - code/engine/editor/escrita/escrita_no_editor.py
  - code/engine/executar_scanner.py
  - code/engine/persistencia/repositorio_de_planos.py
  - code/engine/scanner/configuracao_visual.py
  - code/engine/testes/duplos_de_premiere.py
  - code/engine/testes/test_aplicador_de_plano_de_edicao.py
  - code/engine/testes/test_escrita_no_editor.py
  - code/plugins/premiere-pro/skills/editar-por-voz/criterios/01-leitura-do-json.md
  - code/plugins/premiere-pro/skills/editar-por-voz/criterios/05-zoom.md
  - code/engine/listar_planos_de_edicao.py
  - code/engine/testes/test_configuracao_visual_do_scanner.py
2026-09-10 16:04:15 -04:00
João Henrique 2c9bdf2ff0 feat: criado monitoramento contínuo de trilhas com varredura recur
- criado monitoramento contínuo de trilhas com varredura recursiva e baixa prioridade
- gerado e validado plano editorial revisável do depoimento da Erika sem aplicação na timeline
- adicionada leitura e comparação estrutural da timeline após aplicar planos de edição
- limitadas as classificações de trilhas às duas maiores probabilidades de cada categoria
- limpo o catálogo musical para reiniciar as análises sem remover dados de vídeo

Resumo:
- 34 arquivos alterados
- 6 novos
- 28 modificados
- 0 removidos

 28 files changed, 833 insertions(+), 75 deletions(-)

Arquivos:
  - .jhonny/analises.db
  - code/cep-plugin/index.html
  - code/cep-plugin/main.js
  - code/engine/analisar_trilhas.py
  - code/engine/aplicar_plano_de_edicao.py
  - code/engine/consultar_detalhes_de_trilha.py
  - code/engine/editor/__init__.py
  - code/engine/editor/aplicador_de_plano_de_edicao.py
  - code/engine/editor/backup_de_sequencia.py
  - code/engine/editor/escrita/escrita_no_editor.py
  - code/engine/editor/modelos.py
  - code/engine/editor/validacao_semantica.py
  - code/engine/integracoes/audio/classificador_de_musica_essentia.py
  - code/engine/integracoes/audio/modelos_de_analise_musical.py
  - code/engine/integracoes/audio/provider_de_analise_musical_essentia.py
  - code/engine/persistencia/esquema.py
  - code/engine/persistencia/repositorio_de_trilhas.py
  - code/engine/testes/duplos_de_premiere.py
  - code/engine/testes/test_aplicador_de_plano_de_edicao.py
  - code/engine/testes/test_aplicar_plano_de_edicao.py
  - code/engine/testes/test_escrita_no_editor.py
  - code/engine/testes/test_leitor_de_plano_de_edicao.py
  - code/engine/testes/test_validador_semantico_de_plano.py
  - code/plugins/premiere-pro/skills/editar-por-voz/SKILL.md
  - code/plugins/premiere-pro/skills/editar-por-voz/criterios/08-formato-de-saida.md
  - code/plugins/premiere-pro/skills/editar-por-voz/criterios/10-revisao-humana.md
  - code/src/tools/timeline.ts
  - code/tests/tools/tool-modules.test.ts
  - .jhonny/planos/
  - code/.jhonny/analises.db-shm
  - code/.jhonny/analises.db-wal
  - code/engine/editor/verificador_da_aplicacao.py
  - code/engine/testes/test_verificador_da_aplicacao.py
  - code/plugins/premiere-pro/skills/editar-por-voz/criterios/11-gancho.md
2026-09-10 15:09:27 -04:00
João Henrique da400de7d3 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:
- 1 arquivos alterados
- 0 novos
- 1 modificados
- 0 removidos

 1 file changed, 0 insertions(+), 0 deletions(-)

Arquivos:
  - .jhonny/analises.db
2026-09-10 14:40:31 -04:00
João Henrique 9edde2df37 feat: aprimorada a edição por voz com validação de frases, margens
- aprimorada a edição por voz com validação de frases, margens seguras e backup único antes da timeline.
- adicionada consulta expansível dos detalhes de cada trilha diretamente do banco

Resumo:
- 23 arquivos alterados
- 5 novos
- 18 modificados
- 0 removidos

 18 files changed, 321 insertions(+), 216 deletions(-)

Arquivos:
  - .gitignore
  - .jhonny/analises.db
  - code/cep-plugin/index.html
  - code/cep-plugin/main.js
  - code/cep-plugin/styles.css
  - code/engine/analisar_trilhas.py
  - code/engine/aplicar_plano_de_edicao.py
  - code/engine/editor/__init__.py
  - code/engine/editor/backup_de_sequencia.py
  - code/engine/persistencia/__init__.py
  - code/engine/testes/test_aplicar_plano_de_edicao.py
  - code/plugins/premiere-pro/skills/edit-video-by-voice/SKILL.md
  - code/plugins/premiere-pro/skills/editar-por-voz/SKILL.md
  - code/plugins/premiere-pro/skills/editar-por-voz/criterios/05-zoom.md
  - code/plugins/premiere-pro/skills/editar-por-voz/criterios/06-texto-corte-marcador.md
  - code/plugins/premiere-pro/skills/editar-por-voz/criterios/08-formato-de-saida.md
  - code/plugins/premiere-pro/skills/editar-por-voz/criterios/10-revisao-humana.md
  - code/plugins/premiere-pro/skills/transcript-to-edit-actions/SKILL.md
  - code/engine/consultar_detalhes_de_trilha.py
  - code/engine/editor/validacao_semantica.py
  - code/engine/persistencia/leitura_semantica_do_plano.py
  - code/engine/testes/test_leitura_semantica_do_plano.py
  - code/engine/testes/test_validador_semantico_de_plano.py
2026-09-10 14:38:42 -04:00
João Henrique b038d9b2e4 feat: corrigida a resolução da mídia real ao aplicar planos editor
- corrigida a resolução da mídia real ao aplicar planos editoriais salvos.
- atualizada a guia Trilhas para recuperar e reaproveitar o catálogo salvo

Resumo:
- 5 arquivos alterados
- 0 novos
- 5 modificados
- 0 removidos

 5 files changed, 88 insertions(+), 9 deletions(-)

Arquivos:
  - .jhonny/analises.db
  - code/cep-plugin/main.js
  - code/engine/analisar_trilhas.py
  - code/engine/persistencia/repositorio_de_trilhas.py
  - code/engine/testes/test_repositorio_de_trilhas.py
2026-09-10 14:07:23 -04:00
João Henrique 4c04365934 feat: criada a guia Trilhas com processamento em lote e progresso
- criada a guia Trilhas com processamento em lote e progresso por música
- simplificada a guia Trilhas para ocultar a configuração interna dos modelos

Resumo:
- 12 arquivos alterados
- 1 novos
- 11 modificados
- 0 removidos

 11 files changed, 173 insertions(+), 41 deletions(-)

Arquivos:
  - .jhonny/analises.db
  - code/cep-plugin/index.html
  - code/cep-plugin/main.js
  - code/cep-plugin/styles.css
  - code/engine/analisar_trilhas.py
  - code/engine/carregar_plano_de_edicao.py
  - code/engine/executar_scanner.py
  - code/engine/persistencia/__init__.py
  - code/engine/persistencia/repositorio_de_trilhas.py
  - code/engine/testes/test_carregar_plano_de_edicao.py
  - code/engine/testes/test_executar_scanner.py
  - code/engine/testes/test_repositorio_de_trilhas.py
2026-09-10 13:59:25 -04:00
João Henrique b1272a5e6e feat: corrigida a origem da mídia usada na aplicação do plano edit
- corrigida a origem da mídia usada na aplicação do plano editorial

Resumo:
- 4 arquivos alterados
- 0 novos
- 4 modificados
- 0 removidos

 4 files changed, 144 insertions(+), 4 deletions(-)

Arquivos:
  - .jhonny/analises.db
  - code/cep-plugin/index.html
  - code/cep-plugin/main.js
  - code/engine/analisar_trilhas.py
2026-09-10 13:53:40 -04:00
João Henrique 0d3e7397a7 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:
- 4 arquivos alterados
- 2 novos
- 2 modificados
- 0 removidos

 2 files changed, 48 insertions(+)

Arquivos:
  - .jhonny/analises.db
  - code/engine/persistencia/esquema.py
  - code/engine/analisar_trilhas.py
  - code/engine/persistencia/repositorio_de_trilhas.py
2026-09-10 13:49:58 -04:00
João Henrique 63e5e3c22b feat: corrigido o carregamento automático do plano editorial salvo
- corrigido o carregamento automático do plano editorial salvo após carregar a análise do Scanner
- corrigido erro que impedia ler a saída do carregador de planos no painel

Resumo:
- 3 arquivos alterados
- 0 novos
- 3 modificados
- 0 removidos

 3 files changed, 13 insertions(+), 1 deletion(-)

Arquivos:
  - .jhonny/analises.db
  - CONTEXT.md
  - code/cep-plugin/main.js
2026-09-10 13:46:31 -04:00
João Henrique bb4e7ba663 feat: integrado o classificador Essentia de gênero e humor ao resu
- integrado o classificador Essentia de gênero e humor ao resultado musical
- reorganizado o fluxo da aba Editar vídeo para revisar o plano antes de aplicar

Resumo:
- 3 arquivos alterados
- 0 novos
- 3 modificados
- 0 removidos

 3 files changed, 32 insertions(+), 21 deletions(-)

Arquivos:
  - code/cep-plugin/index.html
  - code/cep-plugin/main.js
  - code/engine/integracoes/audio/provider_de_analise_musical_essentia.py
2026-09-10 13:36:42 -04:00
João Henrique 5644b42722 feat: carregamento automático da análise do Scanner ao abrir a aba
- carregamento automático da análise do Scanner ao abrir a aba Refinar
- simplificada a aba Editar vídeo para exibir análises disponíveis sem carregamento manual
- movida a visualização do tipo de vídeo para a guia Projeto com leitura do banco

Resumo:
- 10 arquivos alterados
- 2 novos
- 8 modificados
- 0 removidos

 8 files changed, 114 insertions(+), 11 deletions(-)

Arquivos:
  - .jhonny/analises.db
  - code/cep-plugin/abas/projeto/projeto.js
  - code/cep-plugin/index.html
  - code/cep-plugin/main.js
  - code/engine/integracoes/audio/__init__.py
  - code/engine/integracoes/audio/modelos_de_analise_musical.py
  - code/engine/integracoes/audio/provider_de_analise_musical_essentia.py
  - code/engine/testes/test_analisador_de_musica_essentia.py
  - code/engine/consultar_edicao_concluida.py
  - code/engine/integracoes/audio/classificador_de_musica_essentia.py
2026-09-10 13:30:10 -04:00
João Henrique a4286bbd8a feat: definido projeto com uma sequência e múltiplos arquivos de m
- definido projeto com uma sequência e múltiplos arquivos de mídia
- criada persistência de projeto com uma sequência e seleções de faixas e clipes
- criada guia Projeto para selecionar sequência, faixas e clipes do Premiere
- adicionada gravação das seleções da guia Projeto no SQLite

Resumo:
- 10 arquivos alterados
- 4 novos
- 6 modificados
- 0 removidos

 6 files changed, 71 insertions(+), 2 deletions(-)

Arquivos:
  - .jhonny/analises.db
  - CONTEXT.md
  - code/cep-plugin/index.html
  - code/cep-plugin/main.js
  - code/engine/persistencia/__init__.py
  - code/engine/persistencia/esquema.py
  - code/cep-plugin/abas/
  - code/engine/persistencia/repositorio_de_projetos.py
  - code/engine/registrar_projeto_de_edicao.py
  - code/engine/testes/test_repositorio_de_projetos.py
2026-09-10 13:22:15 -04:00
João Henrique a9e5b5ff58 feat: copiada a skill editar-por-voz e seus critérios para as skil
- copiada a skill editar-por-voz e seus critérios para as skills do projeto
- adaptada a skill editar-por-voz para usar o banco SQLite como fonte oficial
- corrigida a cópia do JSON do tipo de vídeo para priorizar clipboard Unicode
- criado carregador único do contexto de edição por voz a partir do SQLite
- criada entrada para registrar planos no SQLite e reaproveitar o mesmo plano na aplicação
- habilitado carregamento do plano editorial diretamente do SQLite na interface
- corrigido o bloqueio visual da etapa de carregamento do plano salvo
- configurado carregamento automático do plano salvo após selecionar a sequência
- documentada a sequência como raiz de retomada do fluxo de edição
- criada a estrutura local para análise de músicas instrumentais com Essentia

Resumo:
- 22 arquivos alterados
- 11 novos
- 11 modificados
- 0 removidos

 11 files changed, 270 insertions(+), 34 deletions(-)

Arquivos:
  - .jhonny/analises.db
  - CONTEXT.md
  - code/cep-plugin/index.html
  - code/cep-plugin/main.js
  - code/engine/README.md
  - code/engine/aplicar_plano_de_edicao.py
  - code/engine/integracoes/audio/__init__.py
  - code/engine/persistencia/consultas.py
  - code/engine/requirements-audio.txt
  - code/engine/testes/test_consultas_de_persistencia.py
  - code/tests/cep-tipos-video-encoding.test.ts
  - code/.jhonny/
  - code/engine/carregar_plano_de_edicao.py
  - code/engine/integracoes/audio/contratos.py
  - code/engine/integracoes/audio/modelos_de_analise_musical.py
  - code/engine/integracoes/audio/provider_de_analise_musical_essentia.py
  - code/engine/preparar_edicao_por_voz.py
  - code/engine/registrar_plano_de_edicao.py
  - code/engine/testes/test_analisador_de_musica_essentia.py
  - code/engine/testes/test_carregar_plano_de_edicao.py
  - code/engine/testes/test_registrar_plano_de_edicao.py
  - code/plugins/premiere-pro/skills/editar-por-voz/
2026-09-10 13:09:01 -04:00
João Henrique eda1b1aadc feat: restaurada a renderização dos falantes e do áudio exato na a
- restaurada a renderização dos falantes e do áudio exato na aba Refinar.

Resumo:
- 2 arquivos alterados
- 0 novos
- 2 modificados
- 0 removidos

 2 files changed, 5 insertions(+), 1 deletion(-)

Arquivos:
  - .jhonny/analises.db
  - code/cep-plugin/main.js
2026-09-10 10:40:35 -04:00
João Henrique e16244d68d feat: removidos da tela Editar vídeo os passos legados de transcri
- removidos da tela Editar vídeo os passos legados de transcrição, falantes e métricas.

Resumo:
- 4 arquivos alterados
- 0 novos
- 4 modificados
- 0 removidos

 4 files changed, 35 insertions(+), 161 deletions(-)

Arquivos:
  - .jhonny/analises.db
  - admin/PremiereMCP.command
  - code/cep-plugin/index.html
  - code/cep-plugin/main.js
2026-09-10 10:38:18 -04:00
João Henrique b01482d0e7 feat: corrigida a reprodução do trecho de áudio do falante na tela
- corrigida a reprodução do trecho de áudio do falante na tela Refinar usando os tempos da origem do WAV.
- alterado o passo de geração de JSON para concluir a edição e salvar a configuração editorial no banco.

Resumo:
- 9 arquivos alterados
- 3 novos
- 6 modificados
- 0 removidos

 6 files changed, 136 insertions(+), 109 deletions(-)

Arquivos:
  - .jhonny/analises.db
  - code/cep-plugin/index.html
  - code/cep-plugin/main.js
  - code/engine/persistencia/__init__.py
  - code/engine/persistencia/esquema.py
  - code/engine/testes/test_persistencia_sqlite.py
  - code/engine/persistencia/repositorio_de_edicoes.py
  - code/engine/registrar_edicao.py
  - code/tests/cep-refinar-preview.test.ts
2026-09-10 10:28:41 -04:00
João Henrique 25800b2427 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:
- 1 arquivos alterados
- 0 novos
- 1 modificados
- 0 removidos

 1 file changed, 0 insertions(+), 0 deletions(-)

Arquivos:
  - .jhonny/analises.db
2026-09-10 10:09:36 -04:00
João Henrique 37698ba62a feat: aprimorado o preview de falantes na tela Refinar para reprod
- aprimorado o preview de falantes na tela Refinar para reproduzir os trechos do vídeo
- corrigida a leitura dos timestamps do preview de falantes usando os clipes do banco
- alterado o preview de falantes para reproduzir diretamente na timeline ativa do Premiere
- evitadas reinicializações concorrentes durante o monitoramento do preview na timeline
- substituído o polling contínuo por temporização de cada trecho do preview
- restaurado o preview de áudio dos falantes usando o WAV da transcrição
- corrigida a localização do WAV de transcrição ao carregar análises existentes
- atualizado o cabeçalho do painel CEP para Jhonny Marrone com ícone de lâmpada mágica

Resumo:
- 4 arquivos alterados
- 0 novos
- 4 modificados
- 0 removidos

 4 files changed, 39 insertions(+), 5 deletions(-)

Arquivos:
  - .jhonny/analises.db
  - code/cep-plugin/index.html
  - code/cep-plugin/main.js
  - code/cep-plugin/styles.css
2026-09-10 10:04:54 -04:00
João Henrique becdc3afd5 feat: aprimorado o preview de falantes na tela Refinar para reprod
- aprimorado o preview de falantes na tela Refinar para reproduzir os trechos do vídeo
- corrigida a leitura dos timestamps do preview de falantes usando os clipes do banco
- alterado o preview de falantes para reproduzir diretamente na timeline ativa do Premiere
- evitadas reinicializações concorrentes durante o monitoramento do preview na timeline
- substituído o polling contínuo por temporização de cada trecho do preview
- restaurado o preview de áudio dos falantes usando o WAV da transcrição

Resumo:
- 4 arquivos alterados
- 0 novos
- 4 modificados
- 0 removidos

 4 files changed, 29 insertions(+), 66 deletions(-)

Arquivos:
  - .jhonny/analises.db
  - code/cep-plugin/index.html
  - code/cep-plugin/main.js
  - code/cep-plugin/styles.css
2026-09-10 10:00:06 -04:00
João Henrique 1fb0e33f57 feat: aprimorado o preview de falantes na tela Refinar para reprod
- aprimorado o preview de falantes na tela Refinar para reproduzir os trechos do vídeo
- corrigida a leitura dos timestamps do preview de falantes usando os clipes do banco
- alterado o preview de falantes para reproduzir diretamente na timeline ativa do Premiere
- evitadas reinicializações concorrentes durante o monitoramento do preview na timeline

Resumo:
- 2 arquivos alterados
- 0 novos
- 2 modificados
- 0 removidos

 2 files changed, 49 insertions(+), 24 deletions(-)

Arquivos:
  - .jhonny/analises.db
  - code/cep-plugin/main.js
2026-09-10 09:44:25 -04:00
João Henrique a5d6c5cbe4 feat: aprimorado o preview de falantes na tela Refinar para reprod
- aprimorado o preview de falantes na tela Refinar para reproduzir os trechos do vídeo
- corrigida a leitura dos timestamps do preview de falantes usando os clipes do banco
- alterado o preview de falantes para reproduzir diretamente na timeline ativa do Premiere

Resumo:
- 6 arquivos alterados
- 0 novos
- 4 modificados
- 2 removidos

 6 files changed, 68 insertions(+), 53 deletions(-)

Arquivos:
  - .jhonny/analises.db
  - .jhonny/analises.db-shm
  - .jhonny/analises.db-wal
  - code/cep-plugin/index.html
  - code/cep-plugin/main.js
  - code/cep-plugin/styles.css
2026-09-10 09:35:52 -04:00
João Henrique b0b8027c7e feat: aprimorado o preview de falantes na tela Refinar para reprod
- aprimorado o preview de falantes na tela Refinar para reproduzir os trechos do vídeo
- corrigida a leitura dos timestamps do preview de falantes usando os clipes do banco

Resumo:
- 5 arquivos alterados
- 1 novos
- 4 modificados
- 0 removidos

 4 files changed, 142 insertions(+), 3 deletions(-)

Arquivos:
  - .jhonny/analises.db
  - code/cep-plugin/main.js
  - code/engine/persistencia/consultas.py
  - code/engine/testes/test_consultas_de_persistencia.py
  - code/engine/consultar_preview_de_falantes.py
2026-09-10 09:27:44 -04:00
João Henrique 485c35c4e8 feat: aprimorado o preview de falantes na tela Refinar para reprod
- aprimorado o preview de falantes na tela Refinar para reproduzir os trechos do vídeo

Resumo:
- 6 arquivos alterados
- 2 novos
- 4 modificados
- 0 removidos

 4 files changed, 97 insertions(+), 11 deletions(-)

Arquivos:
  - .jhonny/analises.db
  - code/cep-plugin/index.html
  - code/cep-plugin/main.js
  - code/cep-plugin/styles.css
  - .jhonny/analises.db-shm
  - .jhonny/analises.db-wal
2026-09-10 09:19:08 -04:00
91 changed files with 7163 additions and 345 deletions
+2
View File
@@ -28,6 +28,8 @@ build/
# Temporary files
*.tmp
*.temp
.jhonny/*.db-shm
.jhonny/*.db-wal
# Resumo temporário usado pelo admin/deploy.command para detalhar o commit
admin/COMMIT-DETAILS.md
Binary file not shown.
@@ -0,0 +1,35 @@
{
"source": "0E6A8829.MP4",
"actions": [
{
"kind": "cut",
"start": 0.0,
"end": 124.49,
"reason": "Remover apresentação dirigida pelo entrevistador, preparação técnica e bastidores; iniciar na fala completa da depoente sobre o diagnóstico e o início do emagrecimento."
},
{
"kind": "cut",
"start": 149.17,
"end": 376.07,
"reason": "Remover retomadas, perguntas e blocos intermediários; preservar a próxima fala completa da depoente sobre procurar a doutora Larissa e realizar o primeiro procedimento."
},
{
"kind": "cut",
"start": 404.85,
"end": 848.50,
"reason": "Remover consulta, preparação, perguntas e bastidores que interrompem a narrativa; manter o bloco completo sobre a transformação percebida depois da cirurgia."
},
{
"kind": "cut",
"start": 875.80,
"end": 962.80,
"reason": "Remover a pergunta final e a primeira resposta truncada em continuação; encerrar com a fala completa sobre o contato pessoal e a segurança na escolha do médico."
},
{
"kind": "cut",
"start": 991.54,
"end": 1014.64,
"reason": "Remover o fecho adicional que repete a ideia de contato pessoal e segurança; manter como encerramento a recomendação completa sobre consultar, conversar e decidir em paz."
}
]
}
+17
View File
@@ -3,6 +3,10 @@
## Vocabulário
- **Scanner**: caso de uso que coordena a leitura, análise, decisão, planejamento, aplicação e validação de uma edição.
- **Sequência**: timeline do Premiere e raiz operacional de um trabalho de edição; no banco é representada pelo vídeo identificado por `video_id` e nomeada pelo campo `sequencia`.
- **Projeto de edição**: unidade de trabalho que contém exatamente uma sequência, suas escolhas de faixas e clipes, e os planos editoriais associados; uma sequência pode usar vários arquivos de mídia.
- **Arquivo de mídia**: arquivo físico de vídeo ou áudio usado por um ou mais clipes da sequência; não é sinônimo de projeto nem de sequência.
- **Edição de vídeo**: versão da configuração editorial concluída para uma sequência; registra tipo, instruções e origem, mas não representa ainda uma aplicação na timeline.
- **Conteúdo**: unidade analisável identificada por um `content_id`, com texto e metadados.
- **Análise**: observações estruturadas produzidas a partir do conteúdo.
- **Decisão**: escolha de ações editoriais baseada na análise e na configuração.
@@ -13,9 +17,22 @@
- **Transcrição**: segmentos de texto sincronizados com um clipe, produzidos por um provider; não é decisão editorial.
- **Cena**: intervalo contínuo da timeline com características visuais e/ou textuais semelhantes.
- **Evento**: observação temporal detectada no áudio, texto ou vídeo; não implica corte.
- **Trilha musical**: obra sonora catalogada para possível uso como música de fundo; possui identidade própria e não depende de um projeto de vídeo.
- **Catálogo de trilhas**: conjunto pesquisável de trilhas musicais e suas análises, separado do índice de conteúdo dos vídeos.
- **Análise musical**: observações calculadas a partir do sinal de áudio, como BPM, tonalidade, gênero, humor e intensidade; tags do arquivo não são evidência da análise.
- **Etiqueta musical**: classificação produzida por um modelo para uma trilha, sempre acompanhada do tipo, da confiança, do provider e da versão do modelo.
## Decisões atuais
- A arquitetura Python ficará isolada em `code/engine` enquanto o sistema existente continuar em TypeScript.
- O domínio não conhece filesystem, banco de dados, SDK de IA ou plataforma de edição; essas integrações entram por interfaces e adaptadores.
- O primeiro fluxo é síncrono e determinístico, permitindo evolução posterior para operações assíncronas sem alterar o domínio.
- A sequência é a unidade de retomada: ao selecioná-la, o sistema deve localizar a edição concluída mais recente, reutilizar suas análises e oferecer o plano pendente para aprovação e aplicação.
- Cada projeto possui uma única sequência, mas pode reunir vários arquivos de mídia e vários clipes dessa sequência; a seleção do projeto nunca deve misturar timelines diferentes.
- Uma edição concluída, um plano gerado e uma aplicação realizada são estados distintos e devem permanecer auditáveis; concluir a configuração não significa que o Premiere já foi alterado.
- O plano deve evoluir para referenciar diretamente a versão de edição que o originou, além do `video_id`, para impedir que uma nova configuração editorial seja aplicada por engano sobre um plano antigo.
- O catálogo de trilhas musicais é independente dos projetos de edição e não possui `video_id` na sua unidade principal.
- A identidade da trilha deve usar uma impressão digital do conteúdo; o caminho do arquivo é uma localização mutável e não a identidade definitiva.
- O banco deve guardar referências ao arquivo, descritores compactos, etiquetas com proveniência e versões das análises; não deve guardar o áudio binário.
- Uma mesma trilha pode ter várias análises, produzidas por providers ou versões de modelos diferentes. O resultado ativo deve ser selecionável sem apagar o histórico.
- O vínculo entre uma trilha musical e um projeto de edição será criado posteriormente em uma relação própria, permitindo reutilização da mesma trilha em vários projetos.
+8 -9
View File
@@ -18,7 +18,7 @@
# agente de IA buscar o codigo certo. Best-effort: se o
# abre o tunel temporariamente quando necessario e fecha o
# processo que este ciclo iniciou ao terminar.
# 4. LEMBRETE reinicie o Premiere Pro por completo para o painel
# 4. LEMBRETE recarregue somente a extensao CEP para o painel
# reinstalado ser carregado - este passo so imprime isso
#
# Nao mexe em git (este projeto nao e um repositorio git) e nao sobe
@@ -199,19 +199,18 @@ elif run_step rag; then
fi
# ------------------------------------------------------------
# 4) Lembrete de recarregar o painel dentro do Premiere
# 4) Lembrete de recarregar somente a extensao dentro do Premiere
# ------------------------------------------------------------
if run_step reload; then
step "4/4 REINICIAR O PREMIERE"
echo "O painel ja foi reinstalado (passo 1), mas o Premiere so carrega a"
echo "copia nova depois de um restart completo:"
echo " 1. Feche o Premiere Pro por completo (Cmd+Q)"
echo " 2. Abra de novo e va em Window > Extensions > MCP for Adobe Premiere Pro"
echo " 3. Confira se aparecem as abas 'Bridge' e 'Cortar silencio'"
step "4/4 RECARREGAR A EXTENSAO CEP"
echo "O painel ja foi reinstalado (passo 1). Para carregar a copia nova:"
echo " 1. Feche somente o painel MCP for Adobe Premiere Pro"
echo " 2. Abra novamente em Window > Extensions > MCP for Adobe Premiere Pro"
echo " 3. Confira a aba Editar video e os passos Concluir edicao / Aplicar na timeline"
fi
echo
if [ "$FAILED" != "0" ]; then
die "ciclo terminou com pendencias - corrija os avisos acima antes de testar no Premiere."
fi
ok "Ciclo concluido. Pronto para recarregar o painel no Premiere."
ok "Ciclo concluido. Pronto para recarregar somente a extensao no Premiere."
Binary file not shown.
Binary file not shown.
View File
+142
View File
@@ -0,0 +1,142 @@
"use strict";
// A guia Projeto mantém a seleção da sequência, faixas e clipes fora das
// regras específicas de edição. O estado definitivo será persistido pelo
// repositório SQLite na próxima etapa da integração.
var projetoSequencias = [];
var projetoAtual = null;
function projetoListarSequencias() {
var botao = document.getElementById("btnProjetoCarregar");
if (botao) botao.disabled = true;
var select = document.getElementById("projetoSequenciaSelect");
var info = document.getElementById("projetoInfo");
info.textContent = "Lendo sequências, faixas e clipes do Premiere…";
cs.evalScript(
"(function(){try{" +
"if(!app.project)return JSON.stringify({ok:false,error:'Nenhum projeto aberto.'});" +
"var saida=[];" +
"for(var i=0;i<app.project.sequences.numSequences;i++){" +
"var s=app.project.sequences[i], faixas=[];" +
"for(var v=0;v<s.videoTracks.numTracks;v++){var tv=s.videoTracks[v],cv=[];for(var j=0;j<tv.clips.numItems;j++){var x=tv.clips[j];cv.push({id:String(x.nodeId),nome:String(x.name||''),inicio:Number(x.start.seconds),fim:Number(x.end.seconds)});}faixas.push({id:'video_'+v,nome:'Vídeo '+(v+1),tipo:'video',indice:v,clipes:cv});}" +
"for(var a=0;a<s.audioTracks.numTracks;a++){var ta=s.audioTracks[a],ca=[];for(var k=0;k<ta.clips.numItems;k++){var y=ta.clips[k];ca.push({id:String(y.nodeId),nome:String(y.name||''),inicio:Number(y.start.seconds),fim:Number(y.end.seconds)});}faixas.push({id:'audio_'+a,nome:'Áudio '+(a+1),tipo:'audio',indice:a,clipes:ca});}" +
"saida.push({id:String(s.sequenceID),nome:String(s.name||''),duracao:Number(s.end.seconds||0),largura:Number(s.frameSizeHorizontal||0),altura:Number(s.frameSizeVertical||0),ativa:!!(app.project.activeSequence&&s.sequenceID===app.project.activeSequence.sequenceID),faixas:faixas});" +
"}return JSON.stringify({ok:true,sequences:saida});}catch(e){return JSON.stringify({ok:false,error:String(e)});}}())",
function (raw) {
if (botao) botao.disabled = false;
var resposta;
try { resposta = JSON.parse(raw); } catch (erro) { resposta = {ok: false, error: "Resposta inválida do Premiere."}; }
if (!resposta.ok) { info.textContent = resposta.error; return; }
projetoSequencias = resposta.sequences || [];
select.innerHTML = "";
projetoSequencias.forEach(function (sequencia) {
var opcao = document.createElement("option");
opcao.value = sequencia.id;
opcao.textContent = sequencia.nome + (sequencia.ativa ? " · ativa" : "");
select.appendChild(opcao);
});
select.disabled = projetoSequencias.length === 0;
if (projetoSequencias.length) {
var ativa = projetoSequencias.findIndex(function (item) { return item.ativa; });
select.selectedIndex = ativa >= 0 ? ativa : 0;
projetoSelecionarSequencia();
} else {
info.textContent = "Nenhuma sequência encontrada no projeto do Premiere.";
}
}
);
}
function projetoSelecionarSequencia() {
var select = document.getElementById("projetoSequenciaSelect");
projetoAtual = projetoSequencias.find(function (item) { return item.id === select.value; }) || null;
if (!projetoAtual) return;
document.getElementById("projetoTimelineCard").hidden = false;
document.getElementById("projetoStatus").textContent = "Sequência selecionada";
document.getElementById("projetoTimelineStatus").textContent = projetoAtual.nome;
document.getElementById("projetoDuracao").textContent = formatSequenceDuration(projetoAtual.duracao);
document.getElementById("projetoResolucao").textContent = projetoAtual.largura && projetoAtual.altura ? projetoAtual.largura + " × " + projetoAtual.altura : "Não informada";
document.getElementById("projetoTotalFaixas").textContent = projetoAtual.faixas.length;
document.getElementById("projetoTotalClipes").textContent = projetoAtual.faixas.reduce(function (total, faixa) { return total + faixa.clipes.length; }, 0);
var container = document.getElementById("projetoFaixas");
container.innerHTML = "";
projetoAtual.faixas.forEach(function (faixa) {
var bloco = document.createElement("div");
bloco.className = "scanner-subsection";
bloco.innerHTML = '<label class="checkbox-field"><input type="checkbox" checked data-projeto-faixa="' + faixa.id + '"><span>' + faixa.nome + " · " + faixa.clipes.length + " clipe(s)</span></label>";
faixa.clipes.forEach(function (clipe) {
bloco.innerHTML += '<label class="checkbox-field" style="padding-left:24px"><input type="checkbox" checked data-projeto-clipe="' + clipe.id + '"><span>' + clipe.nome + " · " + formatSequenceDuration(clipe.fim - clipe.inicio) + "</span></label>";
});
container.appendChild(bloco);
});
document.getElementById("btnProjetoContinuar").disabled = false;
document.getElementById("btnProjetoSalvar").disabled = false;
projetoCarregarTipoDeVideo();
document.getElementById("projetoInfo").textContent = "A sequência foi carregada. Selecione as faixas e clipes que participarão do plano.";
}
function projetoCarregarTipoDeVideo() {
var select = document.getElementById("projetoTipoVideoSelect");
var info = document.getElementById("projetoTipoVideoInfo");
select.disabled = true;
runStreamed(SCANNER_PYTHON_BIN, [CONSULTAR_EDICAO_SCRIPT, projetoAtual.id, "--banco", ANALISES_DB_PATH],
function () {},
function (codigo, ultimoErro, ultimaSaida) {
if (codigo !== 0) { info.textContent = ultimoErro || "Nenhum tipo de vídeo salvo para esta sequência."; return; }
try {
var edicao = JSON.parse(ultimaSaida).edicao;
var tipos = tiposVideoLer().filter(function (item) { return item.ativo !== false; });
var tipo = tipos.filter(function (item) { return item.id === edicao.tipo_de_video; })[0];
select.innerHTML = "";
tipos.forEach(function (item) {
var opcao = document.createElement("option");
opcao.value = item.id;
opcao.textContent = item.nome;
select.appendChild(opcao);
});
if (!tipo) {
var tipoDesconhecido = document.createElement("option");
tipoDesconhecido.value = edicao.tipo_de_video;
tipoDesconhecido.textContent = edicao.tipo_de_video;
select.appendChild(tipoDesconhecido);
}
select.value = edicao.tipo_de_video;
select.disabled = false;
silenceState.tipoVideoId = edicao.tipo_de_video;
silenceState.tipoVideo = tipo || null;
info.textContent = "Tipo carregado da edição concluída mais recente.";
} catch (erro) { info.textContent = "Não foi possível interpretar o tipo salvo: " + erro.message; }
});
}
function projetoSelecionarTipoVideo() {
var tipo = tiposVideoLer().filter(function (item) { return item.id === document.getElementById("projetoTipoVideoSelect").value; })[0] || null;
silenceState.tipoVideoId = tipo ? tipo.id : null;
silenceState.tipoVideo = tipo;
document.getElementById("projetoTipoVideoInfo").textContent = tipo ? tipo.nome + " · disponível para a edição." : "Nenhum tipo selecionado.";
}
function projetoSalvar() {
if (!projetoAtual) return;
var faixas = Array.prototype.slice.call(document.querySelectorAll("[data-projeto-faixa]:checked")).map(function (item) { return item.getAttribute("data-projeto-faixa"); });
var clipes = Array.prototype.slice.call(document.querySelectorAll("[data-projeto-clipe]:checked")).map(function (item) { return item.getAttribute("data-projeto-clipe"); });
var pedido = path.join(os.tmpdir(), "pedido-projeto-edicao-" + Date.now() + ".json");
fs.writeFileSync(pedido, JSON.stringify({
nome: projetoAtual.nome, video_id: projetoAtual.id, faixas: faixas, clipes: clipes,
}, null, 2), "utf-8");
setBusy("btnProjetoSalvar", true);
runStreamed(SCANNER_PYTHON_BIN, [REGISTRAR_PROJETO_SCRIPT, pedido, "--banco", ANALISES_DB_PATH],
function (linha) { silenceLog(linha); },
function (codigo, ultimoErro) {
setBusy("btnProjetoSalvar", false);
if (codigo !== 0) { showToast("err", ultimoErro || "Não foi possível salvar o projeto."); return; }
document.getElementById("projetoStatus").textContent = "Projeto salvo no banco";
showToast("ok", "Projeto salvo com " + faixas.length + " faixa(s) e " + clipes.length + " clipe(s).");
});
}
function projetoContinuarEdicao() {
if (!projetoAtual) return;
switchTab("silence");
silenceListSequences();
}
+98 -131
View File
@@ -10,9 +10,15 @@
<div class="panel-shell">
<header class="panel-header">
<div class="brand-mark" aria-hidden="true"><span>M</span></div>
<div class="brand-mark" aria-hidden="true">
<svg viewBox="0 0 24 24" role="img" aria-label="Lâmpada mágica">
<path d="M8.1 14.2c-1.1-1-1.8-2.4-1.8-4.1 0-3.1 2.5-5.6 5.7-5.6s5.7 2.5 5.7 5.6c0 1.7-.7 3.1-1.8 4.1-.8.8-1.2 1.4-1.3 2.3H9.4c-.1-.9-.5-1.5-1.3-2.3Z" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linejoin="round"/>
<path d="M9.6 19h4.8M10.5 21h3M4.2 5.8l-1.3-1.3M19.8 5.8l1.3-1.3M12 2.5V1M4.2 10.1H2.5M21.5 10.1h-1.7" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round"/>
<path d="m12 7.1.6 1.7 1.7.6-1.7.6-.6 1.7-.6-1.7-1.7-.6 1.7-.6.6-1.7Z" fill="currentColor" stroke="none"/>
</svg>
</div>
<div class="brand-copy">
<h1>MCP for Adobe Premiere Pro</h1>
<h1>Jhonny Marrone</h1>
<p>Conexão local com o Premiere</p>
</div>
<div class="header-status" id="headerStatus" data-state="waiting" title="Status da conexão">
@@ -22,9 +28,15 @@
</header>
<nav class="tab-bar" role="tablist" aria-label="Painel">
<button class="tab-button active" id="tabBtnScanner" role="tab" aria-selected="true" aria-controls="tabScanner" onclick="switchTab('scanner')" type="button">
<button class="tab-button active" id="tabBtnProjeto" role="tab" aria-selected="true" aria-controls="tabProjeto" onclick="switchTab('projeto')" type="button">
<span class="tab-icon" aria-hidden="true">▣</span>Projeto
</button>
<button class="tab-button" id="tabBtnScanner" role="tab" aria-selected="false" aria-controls="tabScanner" onclick="switchTab('scanner')" type="button">
<span class="tab-icon" aria-hidden="true">◉</span>Scanner
</button>
<button class="tab-button" id="tabBtnTrilhas" role="tab" aria-selected="false" aria-controls="tabTrilhas" onclick="switchTab('trilhas')" type="button">
<span class="tab-icon" aria-hidden="true">♫</span>Trilhas
</button>
<button class="tab-button" id="tabBtnRefinar" role="tab" aria-selected="false" aria-controls="tabRefinar" onclick="switchTab('refinar')" type="button">
<span class="tab-icon" aria-hidden="true">◌</span>Refinar
</button>
@@ -82,23 +94,56 @@
<button type="button" class="toast-close" onclick="hideToast()" aria-label="Fechar aviso">✕</button>
</div>
<!-- ================================ PROJETO ================================ -->
<div class="tab-panel" id="tabProjeto" role="tabpanel" aria-labelledby="tabBtnProjeto">
<p class="tab-intro">Um projeto contém uma única sequência e pode usar várias faixas, clipes e arquivos de mídia.</p>
<section class="card">
<div class="card-head"><h2>Sequência do projeto</h2><span class="card-kicker" id="projetoStatus">Aguardando seleção</span></div>
<button class="button button-primary" id="btnProjetoCarregar" onclick="projetoListarSequencias()" type="button">Carregar sequências do Premiere</button>
<label class="field-label" for="projetoSequenciaSelect">Sequência / timeline</label>
<select id="projetoSequenciaSelect" class="select-field" onchange="projetoSelecionarSequencia()" disabled>
<option value="">Nenhuma sequência carregada</option>
</select>
<p class="field-help" id="projetoInfo">Escolha a sequência que será a raiz deste projeto.</p>
</section>
<section class="card" id="projetoTimelineCard" hidden>
<div class="card-head"><h2>Informações da timeline</h2><span class="card-kicker" id="projetoTimelineStatus">—</span></div>
<dl class="kv">
<div><dt>Duração</dt><dd id="projetoDuracao">—</dd></div>
<div><dt>Resolução</dt><dd id="projetoResolucao">—</dd></div>
<div><dt>Faixas</dt><dd id="projetoTotalFaixas">—</dd></div>
<div><dt>Clipes</dt><dd id="projetoTotalClipes">—</dd></div>
</dl>
<label class="field-label" for="projetoTipoVideoSelect">Tipo de vídeo</label>
<select id="projetoTipoVideoSelect" class="select-field" onchange="projetoSelecionarTipoVideo()" disabled>
<option value="">Carregando tipo de vídeo…</option>
</select>
<p class="field-help" id="projetoTipoVideoInfo">O tipo definido na edição concluída será carregado do banco.</p>
<h3>Faixas e clipes utilizados</h3>
<div id="projetoFaixas" class="scanner-track-list"></div>
<div class="button-row">
<button class="button button-primary" id="btnProjetoSalvar" onclick="projetoSalvar()" type="button" disabled>Salvar projeto no banco</button>
<button class="button button-primary" id="btnProjetoContinuar" onclick="projetoContinuarEdicao()" type="button" disabled>Continuar para editar vídeo</button>
</div>
</section>
</div>
<!-- ============================= EDITAR VÍDEO ============================= -->
<div class="tab-panel" id="tabSilence" role="tabpanel" aria-labelledby="tabBtnSilence" hidden>
<p class="tab-intro">A edição usa a análise já concluída pelo Scanner. Revise os dados, gere o plano JSON e aplique-o na timeline.</p>
<p class="tab-intro">Os dados analisados e o tipo de vídeo definidos no Projeto já estão disponíveis para esta edição.</p>
<section class="card scanner-analysis-card" id="scannerAnalysisCard">
<div class="card-head"><h2>Análise do Scanner</h2><span class="step-chip" id="scannerAnalysisStatus">Não carregada</span></div>
<p class="field-help" id="scannerAnalysisInfo">Carregue a análise mais recente do Scanner antes de gerar o plano de edição.</p>
<p class="field-help" id="scannerAnalysisInfo">A análise mais recente da sequência será carregada automaticamente.</p>
<ul class="scanner-analysis-checks" id="scannerAnalysisChecks" aria-label="Componentes carregados da análise">
<li id="scannerCheckTranscription" data-state="pending"><span class="check-dot" aria-hidden="true"></span><span>Transcrição</span><em>Aguardando</em></li>
<li id="scannerCheckDiarization" data-state="pending"><span class="check-dot" aria-hidden="true"></span><span>Detecção de falantes</span><em>Aguardando</em></li>
<li id="scannerCheckVoiceFeatures" data-state="pending"><span class="check-dot" aria-hidden="true"></span><span>Métrica de voz</span><em>Aguardando</em></li>
</ul>
<button id="btnLoadScannerAnalysis" class="button button-primary" onclick="silenceLoadScannerAnalysis()" type="button">Carregar análise do Scanner</button>
</section>
<section class="step" id="step1" data-state="locked">
<section class="step" id="step1" data-state="locked" hidden>
<div class="step-head">
<span class="step-number" aria-hidden="true">1</span>
<div class="step-title">
@@ -116,127 +161,26 @@
</div>
</section>
<section class="step editar-video-legado" id="step2" data-state="locked">
<section class="step" id="step2" data-state="locked">
<div class="step-head">
<span class="step-number" aria-hidden="true">2</span>
<div class="step-title">
<h2>Transcrever a fala</h2>
<p>Gera o texto com marcação de tempo usando o Whisper local.</p>
<h2>Revisar plano de edição</h2>
<p>Carrega do banco o plano criado para esta sequência e mostra as decisões antes da aplicação.</p>
</div>
<span class="step-chip" id="step2Chip">Bloqueado</span>
<span class="step-chip" id="step2Chip">Aguardando plano</span>
</div>
<div class="step-body">
<div class="callout callout-info" id="silenceCachedSection" hidden>
<strong>Este vídeo já foi transcrito antes</strong>
<p>Reaproveite e pule direto para o plano de cortes — leva segundos em vez de minutos.</p>
<label class="field-label" for="silenceCachedSelect">Transcrição salva</label>
<select id="silenceCachedSelect" class="select-field"></select>
<button id="btnUseCachedTranscript" class="button button-primary" onclick="silenceUseCachedTranscript()" type="button">Usar esta transcrição</button>
<p class="field-help" id="planoResumoEdicao">O plano editorial pendente será carregado automaticamente da sequência selecionada.</p>
<button id="btnBuildPlan" class="button button-primary" onclick="carregarPlanoEditorialSalvo()" type="button" disabled>Carregar plano salvo</button>
<label class="field-label" for="editorialPlanIdInput">Ou escolher um plano específico da lista</label>
<p class="field-help">Use quando o plano não está associado a esta sequência no banco (ex.: vídeo de outro projeto/pasta) — cada item mostra data/hora e a intenção que a skill registrou, sem precisar adivinhar o número do id.</p>
<div class="file-field">
<select id="editorialPlanIdInput"><option value="">Carregando planos…</option></select>
<button type="button" class="button button-ghost" id="btnRefreshPlanList" onclick="carregarListaDePlanosEditoriais()" title="Atualizar lista">↻</button>
<button type="button" class="button button-ghost" id="btnLoadPlanById" onclick="carregarPlanoEditorialPorId()">Carregar plano</button>
</div>
<!-- LEGADO comentado em 2026-09-09: transcrição/diarização/métricas de voz
feitas aqui duplicavam o que a aba Scanner já faz e persiste no banco.
Funções JS correspondentes (silenceTranscribe, silenceDiarizeSpeakers,
silenceComputeVoiceFeatures) comentadas em main.js.
<div class="field-grid">
<div>
<label class="field-label" for="silenceModel">Modelo</label>
<select id="silenceModel" class="select-field">
<option value="small" selected>small (recomendado)</option>
<option value="base">base (mais rápido)</option>
<option value="medium">medium (mais preciso)</option>
</select>
</div>
<div>
<span class="field-label">Idioma</span>
<p class="field-static" id="silenceLanguageInfo">Português</p>
</div>
</div>
<label class="checkbox-field">
<input type="checkbox" id="silenceDiarize">
<span>Detectar quem fala logo após transcrever</span>
</label>
<label class="checkbox-field">
<input type="checkbox" id="silenceVoiceFeaturesAuto">
<span>Calcular métricas de voz logo após transcrever</span>
</label>
-->
<div class="speaker-config" id="silenceSpeakerConfig" hidden>
<div class="speaker-config-head">
<strong>Falantes detectados</strong>
<span id="silenceSpeakerCount"></span>
</div>
<p class="field-help">Dê um nome a cada falante e desative quem não deve aparecer no vídeo. A decisão será enviada no JSON para a IA.</p>
<div id="silenceSpeakerList"></div>
</div>
<div class="mini-action" id="silenceRemovalRow">
<div>
<label class="checkbox-field" style="border:none;padding:0;background:none">
<input type="checkbox" id="silenceRemovalEnabled">
<span>Remoção de silêncios</span>
</label>
<span>Só grava a preferência no JSON — não corta nada aqui. A IA decide os cortes usando esse valor.</span>
</div>
<div style="display:flex;align-items:center;gap:6px;flex:0 0 auto">
<label class="field-label" for="silenceRemovalMinDuration" style="margin:0">Mín. (s)</label>
<input type="number" id="silenceRemovalMinDuration" class="select-field" style="width:64px" step="0.1" min="0" value="0.8">
</div>
</div>
<!-- LEGADO comentado em 2026-09-09 (ver nota acima).
<button id="btnTranscribe" class="button button-primary" onclick="silenceTranscribe()" type="button" disabled>Transcrever</button>
<p class="field-help" id="silenceCacheInfo"></p>
-->
</div>
</section>
<!-- LEGADO comentado em 2026-09-09: "Refazer" chamava silenceDiarizeSpeakers()
e silenceComputeVoiceFeatures(), agora comentadas em main.js — a aba
Refinar já cobre revisão de falantes com dados vindos do Scanner.
<section class="step step-optional" id="step3" data-state="locked">
<div class="step-head">
<span class="step-number" aria-hidden="true">3</span>
<div class="step-title">
<h2>Revisar falantes e áudio <span class="badge-optional">opcional</span></h2>
<p>Confira os resultados processados e ajuste os dados antes de gerar o JSON.</p>
</div>
<span class="step-chip" id="step3Chip">Bloqueado</span>
</div>
<div class="step-body">
<div class="mini-action">
<div>
<strong>Detecção de falantes</strong>
<span id="silenceDiarizeInfo">Separa os falantes por trecho. Precisa do token Hugging Face.</span>
</div>
<button id="btnDiarize" class="button" onclick="silenceDiarizeSpeakers()" type="button" disabled>Refazer</button>
</div>
<div class="mini-action">
<div>
<strong>Métricas de voz</strong>
<span id="silenceVoiceFeaturesInfo">Pitch, energia, velocidade de fala e pausas de cada trecho.</span>
</div>
<button id="btnVoiceFeatures" class="button" onclick="silenceComputeVoiceFeatures()" type="button" disabled>Refazer</button>
</div>
</div>
</section>
-->
<section class="step" id="step4" data-state="locked">
<div class="step-head">
<span class="step-number" aria-hidden="true">4</span>
<div class="step-title">
<h2>Gerar arquivo JSON</h2>
<p>Reúne transcrição, tempos, locutores, métricas de voz e suas configurações de edição. Nada é cortado — o arquivo vai para um agente de IA externo analisar.</p>
</div>
<span class="step-chip" id="step4Chip">Bloqueado</span>
</div>
<div class="step-body">
<button id="btnBuildPlan" class="button button-primary" onclick="silenceGenerateJson()" type="button" disabled>Gerar arquivo JSON</button>
<p class="field-help" id="editorialPlanIdDetalhe"></p>
<dl class="kv" id="silenceJsonCard" hidden>
<div><dt>Trechos</dt><dd id="jsonSegmentCount">—</dd></div>
<div><dt>Arquivo</dt><dd class="kv-path" id="jsonOutputPath">—</dd></div>
@@ -249,19 +193,20 @@
</div>
</section>
<section class="step" id="step5" data-state="locked">
<section class="step" id="step3" data-state="locked">
<div class="step-head">
<span class="step-number" aria-hidden="true">5</span>
<span class="step-number" aria-hidden="true">3</span>
<div class="step-title">
<h2>Aplicar na timeline</h2>
<p>Selecione o plano de edição que a IA devolveu (cortes, zooms, textos, marcadores) e execute na sequência.</p>
</div>
<span class="step-chip" id="step5Chip">Bloqueado</span>
<span class="step-chip" id="step3Chip">Bloqueado</span>
</div>
<div class="step-body">
<label class="field-label" for="editorialPlanJson">JSON de ações devolvido pela IA</label>
<textarea id="editorialPlanJson" class="editorial-plan-json" rows="10" spellcheck="false" placeholder='Cole aqui o JSON com source e actions.'></textarea>
<p class="field-help" id="editorialPlanJsonStatus">Cole o plano recebido da IA. O painel valida o formato antes de executar.</p>
<label class="field-label" for="editorialPlanJson">Plano carregado do banco</label>
<p class="field-help" id="editorialSavedPlanStatus">O plano será lido diretamente do banco da edição concluída.</p>
<textarea id="editorialPlanJson" class="editorial-plan-json" rows="10" spellcheck="false" placeholder='Carregue do banco o JSON com source e actions.'></textarea>
<p class="field-help" id="editorialPlanJsonStatus">Carregue o plano salvo no SQLite. O painel valida frases, palavras e margens antes de executar.</p>
<label class="field-label" for="editorialPlanPath">Arquivo do plano (JSON devolvido pela IA)</label>
<div class="file-field">
<input type="text" id="editorialPlanPath" spellcheck="false" autocomplete="off" placeholder="/caminho/para/plano.json">
@@ -271,6 +216,8 @@
<strong>Sem desfazer</strong>
<p>Esta versão do Premiere não expõe undo para o painel. Um backup da sequência é criado automaticamente antes de aplicar.</p>
</div>
<label class="checkbox-field"><input id="editorialIncrementalMode" type="checkbox"><span>Usar montagem incremental (teste)</span></label>
<p class="field-help">Marcado: cria uma sequência nova, limpa somente a cópia e monta nela os trechos preservados. A sequência original não é cortada.</p>
<button id="btnApplyEditorialActions" class="button button-danger" onclick="applyEditorialActions()" type="button" disabled>Aplicar plano na timeline</button>
</div>
</section>
@@ -358,20 +305,39 @@
<div class="callout callout-info" id="scannerReportCard" hidden><strong>Relatório pronto</strong><p id="scannerReportPath"></p><div class="button-row"><button class="button button-ghost" onclick="scannerOpenReport()" type="button">Abrir JSON</button><button class="button button-ghost" onclick="scannerCopyReport()" type="button">Copiar conteúdo</button></div></div>
</section>
</div>
<!-- =============================== TRILHAS =============================== -->
<div class="tab-panel" id="tabTrilhas" role="tabpanel" aria-labelledby="tabBtnTrilhas" hidden>
<p class="tab-intro">Organize e analise músicas instrumentais para usar como fundo nos projetos de vídeo.</p>
<section class="card">
<div class="card-head"><h2>Pasta de trilhas musicais</h2><span class="card-kicker">Catálogo local</span></div>
<div class="file-field">
<input type="text" id="trilhasPasta" spellcheck="false" autocomplete="off" placeholder="Selecione uma pasta de músicas" oninput="trilhasAtualizarEstado()">
<button type="button" class="button button-ghost" onclick="trilhasSelecionarPasta()">Selecionar…</button>
</div>
<p class="field-help" id="trilhasPastaStatus">O caminho será salvo no banco para reutilizar esta pasta depois.</p>
<p class="field-help">A análise usa os modelos Essentia instalados internamente pela aplicação. Você não precisa localizar nem selecionar essa pasta.</p>
<button class="button button-primary" id="btnTrilhasAnalisar" onclick="trilhasAnalisar()" type="button" disabled>Iniciar monitoramento</button>
</section>
<section class="card" id="trilhasProgressoCard" hidden>
<div class="card-head"><h2>Progresso da análise</h2><span class="card-kicker" id="trilhasContagem">0 trilhas</span></div>
<div id="trilhasLista" class="trilhas-lista" role="list" aria-live="polite"></div>
</section>
</div>
<!-- =============================== REFINAR ============================== -->
<div class="tab-panel" id="tabRefinar" role="tabpanel" aria-labelledby="tabBtnRefinar" hidden>
<p class="tab-intro">Confirme quem são os falantes antes de editar. O áudio reproduz somente os trechos identificados de cada pessoa.</p>
<p class="tab-intro">Confirme quem são os falantes antes de editar. O preview reproduz somente os trechos identificados de cada pessoa.</p>
<section class="card scanner-analysis-card">
<div class="card-head"><h2>Falantes da análise</h2><span class="step-chip" id="refinarStatus">Aguardando análise</span></div>
<p class="field-help" id="refinarInfo">Carregue a análise mais recente do Scanner para consultar os falantes detectados.</p>
<div class="refinar-actions">
<button id="btnRefinarLoad" class="button button-primary" onclick="refinarCarregarAnalise()" type="button">Carregar análise do Scanner</button>
<button id="btnRefinarStopAudio" class="button" onclick="stopSpeakerAudio()" type="button">Parar áudio</button>
<button id="btnRefinarLoad" class="button button-ghost" onclick="refinarCarregarAnalise()" type="button">Recarregar análise do Scanner</button>
<button id="btnRefinarStopAudio" class="button" onclick="stopSpeakerAudio()" type="button">Parar preview</button>
</div>
</section>
<section class="card" id="refinarSpeakerCard">
<div class="card-head"><h2>Identificar falantes</h2><span class="card-kicker" id="refinarSpeakerCount">Nenhum falante carregado</span></div>
<p class="field-help">Use o botão de reprodução para reconhecer a voz, informe um nome e escolha quem poderá aparecer no vídeo. A identificação será usada ao gerar o JSON de edição.</p>
<p class="field-help">Use o botão de áudio para reconhecer a voz de cada falante, informe um nome e escolha quem poderá aparecer no vídeo. A identificação será usada ao gerar o JSON de edição.</p>
<div class="callout callout-info" id="refinarSpeakerEmpty"><strong>Nenhum falante identificado</strong><p>A análise carregada não possui diarização. Execute a diarização no Scanner para liberar esta etapa.</p></div>
<div class="speaker-config" id="refinarSpeakerConfig" hidden>
<div id="refinarSpeakerList"></div>
@@ -775,6 +741,7 @@
<script charset="utf-8" src="CSInterface.js"></script>
<script charset="utf-8" src="updater.cjs"></script>
<script charset="utf-8" src="abas/projeto/projeto.js"></script>
<script charset="utf-8" src="main.js"></script>
</body>
</html>
+666 -97
View File
File diff suppressed because it is too large Load Diff
+30
View File
@@ -81,6 +81,7 @@ h1, h2, h3 { margin: 0; }
font-weight: 750;
letter-spacing: -0.04em;
}
.brand-mark svg { width: 21px; height: 21px; }
.brand-copy { min-width: 0; flex: 1; }
.brand-copy h1 { overflow: hidden; font-size: 12px; line-height: 1.25; font-weight: 650; text-overflow: ellipsis; white-space: nowrap; }
@@ -251,6 +252,35 @@ h1, h2, h3 { margin: 0; }
.task-dock[data-result="ok"] .progress-fill { background: var(--green); }
.task-dock[data-result="err"] .progress-fill { background: var(--red); }
/* --------------------- Catálogo de trilhas ------------------------------- */
.trilhas-lista { display: grid; gap: 7px; margin-top: 10px; }
.trilha-item { display: flex; align-items: flex-start; gap: 9px; padding: 9px; border: 1px solid var(--border); border-radius: 6px; background: var(--surface-deep); }
.trilha-check { display: inline-flex; align-items: center; justify-content: center; flex: 0 0 auto; width: 16px; height: 16px; margin-top: 1px; border: 1px solid var(--text-muted); border-radius: 50%; color: #10240f; font-size: 10px; font-weight: 800; }
.trilha-info { min-width: 0; flex: 1; }
.trilha-info strong { display: block; overflow: hidden; color: var(--text); font-size: 10.5px; text-overflow: ellipsis; white-space: nowrap; }
.trilha-info > span { display: block; margin: 3px 0 6px; overflow: hidden; color: var(--text-muted); font-size: 9px; text-overflow: ellipsis; white-space: nowrap; }
.trilha-item .progress-track { height: 4px; }
.trilha-item[data-state="analisando"] { border-color: rgba(155, 108, 255, .45); }
.trilha-item[data-state="analisando"] .progress-track[data-mode="indeterminate"] .progress-fill { background: var(--violet); }
.trilha-item[data-state="concluida"] { border-color: rgba(112, 217, 135, .35); }
.trilha-item[data-state="concluida"] .trilha-check { border-color: var(--green); background: var(--green); }
.trilha-item[data-state="concluida"] .trilha-check::after { content: "✓"; }
.trilha-item[data-state="concluida"] .trilha-info > span { color: var(--green); }
.trilha-item[data-state="concluida"] .progress-fill { background: var(--green); }
.trilha-item[data-state="erro"] { border-color: rgba(255, 101, 101, .35); }
.trilha-item[data-state="erro"] .trilha-check { border-color: var(--red); }
.trilha-item[data-state="erro"] .trilha-info > span { color: #ffb0b0; }
.trilha-item[data-state="erro"] .progress-fill { background: var(--red); }
.trilha-item { cursor: pointer; }
.trilha-item:focus-visible { outline: 2px solid var(--violet); outline-offset: 2px; }
.trilha-detalhes { margin-top: 9px; padding-top: 9px; border-top: 1px solid var(--border); color: var(--text-secondary); cursor: default; }
.trilha-detalhes-grid { display: grid; grid-template-columns: repeat(5, minmax(0, 1fr)); gap: 7px; }
.trilha-detalhes small { display: block; color: var(--text-muted); font-size: 8px; text-transform: uppercase; letter-spacing: .05em; }
.trilha-detalhes strong { display: block; margin-top: 2px; color: var(--text); font-size: 10px; }
.trilha-etiquetas { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; margin-top: 9px; }
.trilha-etiquetas p { margin: 3px 0 0; color: var(--text); font-size: 9.5px; line-height: 1.45; }
.trilha-fonte { margin-top: 8px; }
.task-foot { display: flex; align-items: baseline; gap: 8px; margin-top: 7px; }
.task-stage { flex: 1; min-width: 0; overflow: hidden; color: var(--text-secondary); font-size: 10px; text-overflow: ellipsis; white-space: nowrap; }
.task-pct { flex: 0 0 auto; color: var(--text); font: 600 10px var(--font-mono); }
+35
View File
@@ -11,3 +11,38 @@ assert result.valid
```
As integrações reais devem implementar os protocolos em `content_analyzer.py`, `decision_engine.py`, `edit_plan.py`, `plan_applicator.py`, `validator.py` e `persistence.py`. O pacote não cria dependências externas por padrão.
## Análise de músicas instrumentais
A análise musical local fica separada da análise de voz e segue este fluxo:
```text
caso de uso da engine
↓
AnalisadorDeMusica (contrato)
↓
AnalisadorDeMusicaInstrumentalEssentia
↓
ResultadoDaAnaliseMusical
```
Arquivos principais:
- `integracoes/audio/contratos.py`: contrato substituível do analisador;
- `integracoes/audio/modelos_de_analise_musical.py`: resultado interno em PT-BR;
- `integracoes/audio/provider_de_analise_musical_essentia.py`: adaptador local do Essentia;
- `testes/test_analisador_de_musica_essentia.py`: testes unitários sem instalar Essentia.
Exemplo de uso:
```python
from engine.integracoes.audio import AnalisadorDeMusicaInstrumentalEssentia
resultado = AnalisadorDeMusicaInstrumentalEssentia().analisar("musica.wav")
print(resultado.generos, resultado.humores, resultado.batidas_por_minuto)
```
A dependência é carregada sob demanda. Para habilitar a análise, instale as
dependências de `requirements-audio.txt`. O adaptador não persiste o resultado
nem gera a descrição narrativa; essas responsabilidades ficarão no caso de
uso e na apresentação, respectivamente.
+106
View File
@@ -0,0 +1,106 @@
"""Executa a análise em lote de uma pasta de trilhas e emite progresso JSON."""
from __future__ import annotations
import argparse
import json
from pathlib import Path
import sys
import time
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.integracoes.audio import AnalisadorDeMusicaInstrumentalEssentia, ClassificadorDeMusicaEssentia
from engine.persistencia.conexao import abrir_banco
from engine.persistencia.repositorio_de_trilhas import RepositorioDeTrilhasSQLite
EXTENSOES_DE_AUDIO = {".wav", ".mp3", ".flac", ".m4a", ".aiff", ".ogg"}
def emitir(evento: str, **dados: object) -> None:
"""Emite uma linha de progresso consumível pelo painel CEP."""
print("@@PROGRESS " + json.dumps({"evento": evento, **dados}, ensure_ascii=False), flush=True)
def executar(pasta: Path, banco: Path, diretorio_dos_modelos: Path | None, apenas_listar: bool = False) -> int:
"""Atualiza o catálogo e, opcionalmente, analisa as trilhas pendentes."""
arquivos = tuple(sorted(
arquivo for arquivo in pasta.rglob("*")
if arquivo.is_file() and arquivo.suffix.lower() in EXTENSOES_DE_AUDIO
))
conexao = abrir_banco(banco)
try:
repositorio = RepositorioDeTrilhasSQLite(conexao)
trilhas = repositorio.registrar_pasta_e_arquivos(pasta, arquivos)
repositorio.reabrir_analises_sem_etiquetas()
trilhas = repositorio.registrar_pasta_e_arquivos(pasta, arquivos)
if apenas_listar:
emitir("catalogo_atualizado", trilhas=[
{"nome": trilha.nome_do_arquivo, "caminho": str(trilha.caminho), "status": trilha.status}
for trilha in trilhas
])
return 0
pendentes = tuple(trilha for trilha in trilhas if trilha.status != "concluida")
emitir(
"lote_iniciado",
total=len(pendentes),
pasta=str(pasta),
nomes=[trilha.nome_do_arquivo for trilha in pendentes],
trilhas=[{"nome": trilha.nome_do_arquivo, "caminho": str(trilha.caminho), "status": trilha.status} for trilha in trilhas],
)
if not pendentes:
emitir("lote_concluido", total=0, percentual=100)
return 0
classificador = (
ClassificadorDeMusicaEssentia(diretorio_dos_modelos)
if diretorio_dos_modelos and diretorio_dos_modelos.is_dir()
else None
)
analisador = AnalisadorDeMusicaInstrumentalEssentia(classificador=classificador)
for ordem, trilha in enumerate(pendentes, start=1):
repositorio.iniciar_analise(trilha.identificador)
emitir("trilha_iniciada", nome=trilha.nome_do_arquivo, caminho=str(trilha.caminho), indice=ordem, total=len(trilhas), percentual=(ordem - 1) * 100 / max(1, len(trilhas)))
try:
resultado = analisador.analisar(trilha.caminho)
repositorio.registrar_analise(trilha, resultado)
emitir("trilha_concluida", nome=trilha.nome_do_arquivo, caminho=str(trilha.caminho), indice=ordem, total=len(trilhas), percentual=ordem * 100 / max(1, len(trilhas)))
except (OSError, RuntimeError, ValueError) as erro:
repositorio.registrar_erro(trilha.identificador, str(erro))
emitir("trilha_com_erro", nome=trilha.nome_do_arquivo, caminho=str(trilha.caminho), indice=ordem, total=len(trilhas), erro=str(erro), percentual=ordem * 100 / max(1, len(trilhas)))
emitir("lote_concluido", total=len(trilhas), percentual=100)
return 0
finally:
conexao.close()
def executar_continuamente(pasta: Path, banco: Path, diretorio_dos_modelos: Path | None, intervalo: float) -> int:
"""Mantém a pasta monitorada e processa novas trilhas em baixa frequência."""
while True:
executar(pasta, banco, diretorio_dos_modelos)
emitir("worker_aguardando", intervalo=intervalo)
time.sleep(intervalo)
def principal() -> None:
"""Lê argumentos e executa o lote de análise."""
argumentos = argparse.ArgumentParser()
argumentos.add_argument("pasta", type=Path)
argumentos.add_argument("--banco", type=Path, default=Path(".jhonny/analises.db"))
argumentos.add_argument("--modelos", type=Path, default=None)
argumentos.add_argument("--listar", action="store_true")
argumentos.add_argument("--continuo", action="store_true")
argumentos.add_argument("--intervalo", type=float, default=20.0)
opcoes = argumentos.parse_args()
try:
if opcoes.continuo:
raise SystemExit(executar_continuamente(opcoes.pasta, opcoes.banco, opcoes.modelos, opcoes.intervalo))
raise SystemExit(executar(opcoes.pasta, opcoes.banco, opcoes.modelos, opcoes.listar))
except (OSError, RuntimeError, ValueError) as erro:
emitir("lote_com_erro", erro=str(erro))
raise SystemExit(1) from erro
if __name__ == "__main__":
principal()
@@ -0,0 +1,208 @@
"""Executa o protótipo de montagem incremental a partir de um plano editorial.
O script lê um plano ``cut``/``hook`` já carregado pelo painel, calcula o
complemento dos cortes e chama uma ferramenta experimental que clona a
sequência ativa e monta os intervalos preservados na cópia.
"""
from __future__ import annotations
import argparse
import json
import os
from pathlib import Path
import sys
from datetime import datetime
import shutil
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.editor.prototipo_montagem_incremental import AcaoDoPlano, PlanejadorDeMontagemIncremental
from engine.integracoes.premiere.cliente_mcp import ClienteMCPPorStdio
class ErroDeMontagemIncremental(RuntimeError):
"""Indica que o plano não pôde ser preparado para a montagem experimental."""
def carregar_plano(caminho: Path) -> dict[str, object]:
"""Lê o plano editorial JSON fornecido pelo painel."""
try:
dados = json.loads(caminho.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError) as erro:
raise ErroDeMontagemIncremental(f"Não foi possível ler o plano: {erro}") from erro
if not isinstance(dados, dict) or not isinstance(dados.get("actions"), list):
raise ErroDeMontagemIncremental("O plano precisa conter uma lista actions válida.")
return dados
def preparar_intervalos(
plano: dict[str, object], duracao_da_midia: float
) -> list[dict[str, object]]:
"""Converte cortes e gancho em intervalos contíguos de montagem."""
acoes = [
AcaoDoPlano(
tipo=str(acao["kind"]),
inicio=float(acao["start"]),
fim=float(acao["end"]),
motivo=str(acao.get("reason", "")),
)
for acao in plano["actions"]
if isinstance(acao, dict) and acao.get("kind") in {"cut", "hook"}
]
if not acoes:
return [{
"in_seconds": 0.0,
"out_seconds": duracao_da_midia,
"destination_seconds": 0.0,
"label": "mídia completa",
}]
insercoes = PlanejadorDeMontagemIncremental(duracao_da_midia).planejar(acoes)
return [
{
"in_seconds": insercao.origem.inicio,
"out_seconds": insercao.origem.fim,
"destination_seconds": insercao.destino_inicio,
"label": insercao.tipo.value,
}
for insercao in insercoes
]
def executar(caminho_do_plano: Path) -> dict[str, object]:
"""Clona a sequência ativa e monta o plano incremental na nova sequência."""
plano = carregar_plano(caminho_do_plano)
cliente = ClienteMCPPorStdio([resolver_node(), str(CAMINHO_DO_CODIGO / "dist" / "index.js")], tempo_limite=60)
cliente.conectar()
try:
sequencia = cliente.chamar("get_active_sequence")
if not isinstance(sequencia, dict) or not sequencia.get("id"):
raise ErroDeMontagemIncremental("Nenhuma sequência ativa foi encontrada no Premiere.")
clipe = _primeiro_clipe_de_video(sequencia)
intervalos = preparar_intervalos(plano, float(sequencia["end"]))
resultado = cliente.chamar(
"assemble_incremental_edit_prototype",
{
"source_sequence_id": str(sequencia["id"]),
"source_item_id": str(clipe["sourceProjectItemId"]),
"target_name": _nome_da_sequencia(str(sequencia["name"])),
"ranges": intervalos,
"confirm_prototype": True,
},
)
if plano.get("test_adjustment_layer") is True:
alvo = next(
(
acao
for acao in plano["actions"]
if isinstance(acao, dict) and acao.get("kind") in {"hook", "zoom"}
),
None,
)
if alvo is None:
raise ErroDeMontagemIncremental(
"O teste da adjustment layer exige uma ação hook ou zoom no plano."
)
eh_gancho = alvo.get("kind") == "hook"
inicio_do_gancho = 0.0 if eh_gancho else float(alvo["start"])
fim_do_gancho = (
float(alvo["end"]) - float(alvo["start"])
if eh_gancho
else float(alvo["end"])
)
faixa = cliente.chamar(
"add_track",
{"track_type": "video", "count": 1},
)
indice_da_faixa = int(faixa["totalTracks"]) - 1
camada = cliente.chamar(
"insert_adjustment_layer_range",
{
"item_id": "Adjustment Layer",
"track_index": indice_da_faixa,
"start_seconds": inicio_do_gancho,
"end_seconds": fim_do_gancho,
},
)
resultado["adjustment_layer"] = camada
escala = float(alvo.get("params", {}).get("scale", 1.0)) if isinstance(alvo.get("params"), dict) else 1.0
if escala <= 0:
raise ErroDeMontagemIncremental("A escala do zoom precisa ser maior que zero.")
recorte = max(0.0, min(49.0, 50.0 * (1.0 - (1.0 / escala))))
resultado["crop"] = cliente.chamar(
"crop_clip",
{
"node_id": str(camada["nodeId"]),
"left": recorte,
"right": recorte,
"top": recorte,
"bottom": recorte,
"zoom": True,
},
)
return {"ok": True, "plano_incremental": True, "resultado": resultado}
finally:
cliente.desconectar()
def _primeiro_clipe_de_video(sequencia: dict[str, object]) -> dict[str, object]:
"""Obtém a mídia do primeiro clipe da faixa de vídeo principal."""
faixas = sequencia.get("videoTracks", [])
if not isinstance(faixas, list) or not faixas or not isinstance(faixas[0], dict):
raise ErroDeMontagemIncremental("A sequência ativa não possui faixa de vídeo.")
clipes = faixas[0].get("clips", [])
if not isinstance(clipes, list) or not clipes or not isinstance(clipes[0], dict):
raise ErroDeMontagemIncremental("A faixa de vídeo principal está vazia.")
if not clipes[0].get("sourceProjectItemId"):
raise ErroDeMontagemIncremental("Não foi possível identificar a mídia do primeiro clipe.")
return clipes[0]
def _nome_da_sequencia(nome_da_origem: str) -> str:
"""Cria um nome identificável e único para a sequência experimental."""
carimbo = datetime.now().strftime("%Y%m%d-%H%M%S")
return f"PROTÓTIPO incremental - {nome_da_origem} - {carimbo}"[:255]
def resolver_node() -> str:
"""Resolve o Node mesmo quando o painel CEP possui um PATH reduzido."""
caminho_configurado = os.environ.get("PREMIERE_NODE_BIN", "").strip()
if caminho_configurado:
if os.path.isfile(caminho_configurado) and os.access(caminho_configurado, os.X_OK):
return caminho_configurado
raise ErroDeMontagemIncremental(
f"Node.js configurado em PREMIERE_NODE_BIN não é executável: {caminho_configurado}"
)
encontrado_no_path = shutil.which("node.exe" if os.name == "nt" else "node")
if encontrado_no_path:
return encontrado_no_path
caminhos_conhecidos = (
("C:/Program Files/nodejs/node.exe", "C:/Program Files/nodejs/nodejs.exe")
if os.name == "nt"
else ("/opt/homebrew/bin/node", "/usr/local/bin/node", "/usr/bin/node")
)
for caminho in caminhos_conhecidos:
if os.path.isfile(caminho) and os.access(caminho, os.X_OK):
return caminho
raise ErroDeMontagemIncremental(
"Node.js não encontrado. Instale o Node.js ou configure PREMIERE_NODE_BIN "
"com o caminho completo do executável."
)
def principal() -> None:
"""Executa o script e imprime um resultado JSON para o painel."""
argumentos = argparse.ArgumentParser()
argumentos.add_argument("plano", type=Path)
opcoes = argumentos.parse_args()
try:
print(json.dumps(executar(opcoes.plano), ensure_ascii=False))
except (ErroDeMontagemIncremental, OSError, ValueError) as erro:
print(json.dumps({"ok": False, "erro": str(erro)}, ensure_ascii=False))
raise SystemExit(1) from erro
if __name__ == "__main__":
principal()
+115 -15
View File
@@ -17,6 +17,7 @@ troca de sequência.
from __future__ import annotations
import argparse
import json
import os
import shutil
@@ -32,8 +33,14 @@ from engine.editor import (
AplicadorDePlanoDeEdicao,
BackupDaSequencia,
EscritaNoEditor,
ErroDePlanoInvalido,
LeitorDePlanoDeEdicao,
MapeadorDeTempoDeOrigemParaTimeline,
PlanoDeEdicao,
ResultadoDaVerificacao,
TipoDeAcao,
ValidadorSemanticoDePlano,
VerificadorDaAplicacao,
)
from engine.integracoes.premiere.cliente_mcp import ClienteMCPPorStdio
from engine.integracoes.premiere.cliente_mcp_com_pausa import ClienteMCPComPausa
@@ -47,6 +54,44 @@ CAMINHO_DO_SERVIDOR_MCP = CAMINHO_DO_CODIGO / "dist" / "index.js"
CAMINHO_DO_BANCO = CAMINHO_DO_CODIGO.parent / ".jhonny" / "analises.db"
def _garantir_backup_para_ganchos(plano: PlanoDeEdicao, backup_criado: bool) -> None:
"""Bloqueia ganchos quando não há uma cópia recuperável da sequência."""
if plano.acoes_do_tipo(TipoDeAcao.GANCHO) and not backup_criado:
raise ErroDePlanoInvalido(
"A ação hook não foi aplicada porque o Premiere não confirmou o backup da sequência."
)
def _validar_integridade_semantica(
plano: PlanoDeEdicao,
plano_id: int | None,
caminho_do_banco: Path,
exigir_validacao: bool = False,
) -> None:
"""Valida palavras, frases e respiros antes de iniciar a sessão do Premiere."""
if not (
plano.acoes_do_tipo(TipoDeAcao.CORTE)
or plano.acoes_do_tipo(TipoDeAcao.GANCHO)
):
return
if plano_id is None:
if exigir_validacao:
raise ErroDePlanoInvalido(
"Salve ou carregue o plano no banco antes de aplicar: sem o identificador "
"não é possível conferir a transcrição palavra por palavra."
)
return
from engine.persistencia.conexao import abrir_banco
from engine.persistencia.leitura_semantica_do_plano import LeitorDeTranscricaoDoPlano
conexao = abrir_banco(caminho_do_banco)
try:
falas = LeitorDeTranscricaoDoPlano(conexao).carregar(plano_id)
finally:
conexao.close()
ValidadorSemanticoDePlano().garantir_valido(plano, falas)
def resolver_caminho_do_node() -> str:
"""Resolve o executável Node mesmo quando o painel CEP possui um PATH reduzido.
@@ -81,7 +126,14 @@ def resolver_caminho_do_node() -> str:
)
def _registrar_no_banco(caminho_do_plano: Path, resultado: object, sequencia: str) -> None:
def _registrar_no_banco(
caminho_do_plano: Path,
resultado: object,
sequencia: str,
plano_id: int | None = None,
caminho_do_banco: Path = CAMINHO_DO_BANCO,
verificacao: ResultadoDaVerificacao | None = None,
) -> None:
"""Guarda no banco de análises o plano aplicado e o resultado da aplicação.
O registro é o único vestígio de como uma edição foi decidida: sem ele o
@@ -103,27 +155,42 @@ def _registrar_no_banco(caminho_do_plano: Path, resultado: object, sequencia: st
)
dados = json.loads(caminho_do_plano.read_text(encoding="utf-8"))
repositorio = RepositorioDePlanos(abrir_banco(CAMINHO_DO_BANCO))
repositorio = RepositorioDePlanos(abrir_banco(caminho_do_banco))
if plano_id is None:
plano_id = repositorio.registrar_plano(plano_de_dict(dados))
aplicadas = sum(1 for item in resultado.resultados if item.sucesso)
verificacao_aprovada = verificacao is None or verificacao.aprovada
mensagem = verificacao.mensagem if verificacao is not None else None
repositorio.registrar_aplicacao(
plano_id,
sucesso=resultado.todas_bem_sucedidas,
sucesso=resultado.todas_bem_sucedidas and verificacao_aprovada,
acoes_aplicadas=aplicadas,
sequencia=sequencia,
mensagem=mensagem,
)
except (OSError, ValueError, sqlite3.Error) as erro:
print(f"Plano aplicado, mas não foi possível registrá-lo no banco: {erro}",
file=sys.stderr)
def executar(caminho_do_plano: Path) -> dict:
def executar(
caminho_do_plano: Path,
plano_id: int | None = None,
caminho_do_banco: Path = CAMINHO_DO_BANCO,
exigir_validacao_semantica: bool = False,
) -> dict:
"""Lê o plano em ``caminho_do_plano`` e o aplica na sequência ativa do Premiere.
Devolve um dicionário pronto para virar JSON, com o resultado de cada
ação — para o painel exibir o que foi aplicado e o que falhou.
"""
plano = LeitorDePlanoDeEdicao().ler(caminho_do_plano)
_validar_integridade_semantica(
plano,
plano_id,
caminho_do_banco,
exigir_validacao_semantica,
)
# A pausa é aplicada no cliente, e não dentro do aplicador, para valer
# igualmente nas leituras da timeline e nas escritas — são todas
@@ -134,13 +201,13 @@ def executar(caminho_do_plano: Path) -> dict:
acesso_ao_editor = AcessoAoEditor(AcessoATimeline(cliente))
conversor_de_timeline = ConversorDeTimeline()
# Backup antes de qualquer corte, espelhando o painel CEP
# (applyEditorialActions() em cep-plugin/main.js): sem undo por
# script nesta versão do Premiere, é a única forma de recuperar o
# estado anterior se algo sair errado. Uma falha aqui não impede a
# aplicação — só fica registrada em stderr, igual ao painel.
# A engine é a única responsável pelo backup. A validação semântica já
# aconteceu antes da conexão, portanto um plano bloqueado não duplica
# a sequência. Gancho é recusado sem backup porque combina mais de
# uma mutação estrutural; as demais ações preservam o fluxo legado.
sequencia_ativa = conversor_de_timeline.converter(acesso_ao_editor.obter_timeline_ativa())
backup_ok = BackupDaSequencia(cliente).criar_backup(sequencia_ativa.identificador)
_garantir_backup_para_ganchos(plano, backup_ok)
print(
"Backup da sequência criado." if backup_ok else "Não foi possível criar o backup automático; aplicando mesmo assim.",
file=sys.stderr,
@@ -153,12 +220,28 @@ def executar(caminho_do_plano: Path) -> dict:
mapeador=MapeadorDeTempoDeOrigemParaTimeline(),
)
resultado = aplicador.aplicar(plano)
timeline_depois = conversor_de_timeline.converter(
acesso_ao_editor.obter_timeline_ativa()
)
verificacao = VerificadorDaAplicacao().verificar(
sequencia_ativa, timeline_depois, plano
)
_registrar_no_banco(caminho_do_plano, resultado, sequencia_ativa.nome)
_registrar_no_banco(
caminho_do_plano, resultado, sequencia_ativa.nome,
plano_id=plano_id, caminho_do_banco=caminho_do_banco,
verificacao=verificacao,
)
return {
"arquivo_de_origem": plano.arquivo_de_origem,
"todas_bem_sucedidas": resultado.todas_bem_sucedidas,
"todas_bem_sucedidas": resultado.todas_bem_sucedidas and verificacao.aprovada,
"verificacao": {
"aprovada": verificacao.aprovada,
"mensagem": verificacao.mensagem,
"divergencias": list(verificacao.divergencias),
"avisos": list(verificacao.avisos),
},
"acoes": [
{
"tipo": item.acao.tipo.value,
@@ -175,10 +258,27 @@ def executar(caminho_do_plano: Path) -> dict:
def main() -> None:
"""Ponto de entrada da linha de comando."""
if len(sys.argv) != 2:
print("Uso: python aplicar_plano_de_edicao.py <caminho_do_plano.json>", file=sys.stderr)
sys.exit(2)
resultado = executar(Path(sys.argv[1]))
sys.stdout.reconfigure(encoding="utf-8")
argumentos = argparse.ArgumentParser()
argumentos.add_argument("plano", type=Path)
argumentos.add_argument("--plano-id", type=int)
argumentos.add_argument("--banco", type=Path, default=CAMINHO_DO_BANCO)
argumentos.add_argument("--exigir-validacao-semantica", action="store_true")
opcoes = argumentos.parse_args()
try:
resultado = executar(
opcoes.plano,
opcoes.plano_id,
opcoes.banco,
opcoes.exigir_validacao_semantica,
)
except ErroDePlanoInvalido as erro:
print(json.dumps({
"todas_bem_sucedidas": False,
"erro": str(erro),
"acoes": [],
}, ensure_ascii=False, indent=2))
sys.exit(1)
print(json.dumps(resultado, ensure_ascii=False, indent=2))
if not resultado["todas_bem_sucedidas"]:
sys.exit(1)
+125
View File
@@ -0,0 +1,125 @@
"""Entrada de linha de comando para carregar um plano salvo no SQLite."""
from __future__ import annotations
import argparse
import json
from pathlib import Path
import sqlite3
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.conexao import abrir_banco
from engine.persistencia.repositorio_de_planos import RepositorioDePlanos
def resolver_arquivo_de_origem(
conexao: sqlite3.Connection,
video_id: str | None,
origem_informada: str,
) -> str:
"""Resolve a mídia física de um plano histórico associado a uma sequência.
Preserva a origem quando ela já identifica um clipe do vídeo. Quando o
plano guardou o nome editorial da sequência, usa automaticamente o
arquivo dos clipes somente se todas as ocorrências apontarem para um
único nome-base de mídia. Em timelines com fontes distintas, não escolhe
uma delas por suposição.
"""
if not video_id:
return origem_informada
linhas = conexao.execute(
"""SELECT nome, arquivo FROM clipes
WHERE video_id = ? AND (trim(coalesce(nome, '')) <> ''
OR trim(coalesce(arquivo, '')) <> '')""",
(video_id,),
).fetchall()
alvo = Path(origem_informada.strip()).name.casefold()
for linha in linhas:
nome = str(linha["nome"] or "").strip().casefold()
nome_base_do_arquivo = Path(str(linha["arquivo"] or "").strip()).name.casefold()
if alvo and alvo in {nome, nome_base_do_arquivo}:
return origem_informada
arquivos_por_nome_base: dict[str, str] = {}
for linha in linhas:
arquivo = str(linha["arquivo"] or "").strip()
if arquivo:
arquivos_por_nome_base.setdefault(Path(arquivo).name.casefold(), arquivo)
if len(arquivos_por_nome_base) == 1:
return next(iter(arquivos_por_nome_base.values()))
return origem_informada
def carregar(
caminho_do_banco: Path,
plano_id: int | None = None,
video_id: str | None = None,
) -> dict[str, object] | None:
"""Carrega um plano existente, preferindo o mais recente do vídeo informado."""
conexao = abrir_banco(caminho_do_banco)
try:
if plano_id is None:
if not video_id:
raise ValueError("Informe plano_id ou video_id para carregar um plano.")
linha = conexao.execute(
"""SELECT id FROM planos_de_edicao
WHERE video_id = ? ORDER BY criado_em DESC, id DESC LIMIT 1""",
(video_id,),
).fetchone()
if linha is None:
return None
plano_id = int(linha["id"])
plano = RepositorioDePlanos(conexao).carregar_plano(plano_id)
if plano is None:
return None
arquivo_de_origem = resolver_arquivo_de_origem(
conexao,
plano.video_id,
plano.origem,
)
return {
"plano_id": plano_id,
"source": arquivo_de_origem,
"actions": [
{
"kind": acao.tipo,
"start": acao.inicio,
"end": acao.fim,
"reason": acao.motivo or "Decisão registrada no plano editorial.",
**acao.parametros,
}
for acao in plano.acoes
],
"video_id": plano.video_id,
"tipo_de_video": plano.tipo_de_video,
"modelo_da_ia": plano.modelo_da_ia,
"intencao": plano.intencao,
}
finally:
conexao.close()
def principal() -> None:
"""Executa o carregamento pela linha de comando."""
argumentos = argparse.ArgumentParser()
argumentos.add_argument("--banco", type=Path, default=Path(".jhonny/analises.db"))
argumentos.add_argument("--plano-id", type=int)
argumentos.add_argument("--video-id")
opcoes = argumentos.parse_args()
try:
plano = carregar(opcoes.banco, opcoes.plano_id, opcoes.video_id)
if plano is None:
raise ValueError("Nenhum plano de edição encontrado.")
print(json.dumps({"ok": True, "plano": plano}, ensure_ascii=False))
except (OSError, ValueError) as erro:
print(json.dumps({"ok": False, "erro": str(erro)}, ensure_ascii=False))
raise SystemExit(1) from erro
if __name__ == "__main__":
principal()
@@ -0,0 +1,67 @@
"""Consulta no SQLite os detalhes da análise mais recente de uma trilha."""
from __future__ import annotations
import argparse
import json
import sqlite3
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))
def consultar(caminho: Path, banco: Path) -> dict[str, object]:
"""Retorna descritores e etiquetas da última análise do arquivo informado."""
caminho_do_banco = banco.expanduser().resolve()
conexao = sqlite3.connect(f"file:{caminho_do_banco}?mode=ro", uri=True)
conexao.row_factory = sqlite3.Row
try:
analise = conexao.execute(
"""SELECT a.id, a.provedor, a.modelo, a.duracao_em_segundos,
a.batidas_por_minuto, a.tonalidade, a.modo,
a.intensidade, a.dancabilidade, a.temas_json,
a.instrumentos_json, a.etiquetas_json
FROM trilhas_musicais t
JOIN analises_musicais a ON a.trilha_id = t.id
WHERE t.caminho = ?
ORDER BY a.criada_em DESC, a.id DESC LIMIT 1""",
(str(caminho.expanduser().resolve()),),
).fetchone()
if analise is None:
return {"encontrada": False, "mensagem": "Esta trilha ainda não possui uma análise salva."}
etiquetas = conexao.execute(
"SELECT tipo, etiqueta, confianca FROM etiquetas_musicais WHERE analise_id = ? ORDER BY tipo, ordem",
(analise["id"],),
).fetchall()
return {
"encontrada": True,
"provedor": analise["provedor"],
"modelo": analise["modelo"],
"duracao_em_segundos": analise["duracao_em_segundos"],
"batidas_por_minuto": analise["batidas_por_minuto"],
"tonalidade": analise["tonalidade"],
"modo": analise["modo"],
"intensidade": analise["intensidade"],
"dancabilidade": analise["dancabilidade"],
"temas": json.loads(analise["temas_json"] or "[]"),
"instrumentos": json.loads(analise["instrumentos_json"] or "[]"),
"etiquetas_audio": json.loads(analise["etiquetas_json"] or "[]"),
"etiquetas": [dict(etiqueta) for etiqueta in etiquetas],
}
finally:
conexao.close()
def principal() -> None:
"""Lê os argumentos e imprime a consulta como JSON."""
argumentos = argparse.ArgumentParser()
argumentos.add_argument("caminho", type=Path)
argumentos.add_argument("--banco", type=Path, required=True)
opcoes = argumentos.parse_args()
print(json.dumps(consultar(opcoes.caminho, opcoes.banco), ensure_ascii=False))
if __name__ == "__main__":
principal()
+56
View File
@@ -0,0 +1,56 @@
"""Consulta a configuração editorial concluída mais recente de um vídeo."""
from __future__ import annotations
import argparse
import json
from pathlib import Path
import sqlite3
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.conexao import abrir_banco
def consultar(caminho_do_banco: Path, video_id: str) -> dict[str, object] | None:
"""Retorna a edição mais recente sem alterar o banco."""
conexao = abrir_banco(caminho_do_banco)
try:
linha = conexao.execute(
"""SELECT id, video_id, sequencia, tipo_de_video, configuracao, concluida_em
FROM edicoes_de_video WHERE video_id = ?
ORDER BY concluida_em DESC, id DESC LIMIT 1""", (video_id,),
).fetchone()
if linha is None:
return None
return {
"id": linha["id"], "video_id": linha["video_id"],
"sequencia": linha["sequencia"], "tipo_de_video": linha["tipo_de_video"],
"configuracao": json.loads(linha["configuracao"]),
"concluida_em": linha["concluida_em"],
}
finally:
conexao.close()
def principal() -> None:
"""Executa a consulta pela linha de comando."""
parser = argparse.ArgumentParser()
parser.add_argument("video_id")
parser.add_argument("--banco", type=Path, default=Path(".jhonny/analises.db"))
opcoes = parser.parse_args()
try:
edicao = consultar(opcoes.banco, opcoes.video_id)
if edicao is None:
raise ValueError("Nenhuma edição concluída encontrada para este vídeo.")
print(json.dumps({"ok": True, "edicao": edicao}, ensure_ascii=False))
except (OSError, ValueError, sqlite3.Error, json.JSONDecodeError) as erro:
print(json.dumps({"ok": False, "erro": str(erro)}, ensure_ascii=False))
raise SystemExit(1) from erro
if __name__ == "__main__":
principal()
@@ -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()
+7 -1
View File
@@ -1,4 +1,4 @@
"""Aplicação de planos de edição (cortes, zooms, marcadores) na timeline do Premiere.
"""Aplicação de planos de edição (cortes, ganchos e destaques) no Premiere.
Lê o JSON de ações produzido pelas skills de seleção de trechos
(``kind``/``start``/``end``/``params``/``reason``), traduz os tempos de
@@ -18,6 +18,8 @@ from .escrita import EscritaNoEditor
from .leitura import LeitorDePlanoDeEdicao
from .mapeamento import MapeadorDeTempoDeOrigemParaTimeline
from .modelos import AcaoDeEdicao, PlanoDeEdicao, TipoDeAcao
from .validacao_semantica import ProblemaSemanticoDoPlano, ValidadorSemanticoDePlano
from .verificador_da_aplicacao import ResultadoDaVerificacao, VerificadorDaAplicacao
__all__ = [
"AcaoDeEdicao",
@@ -31,7 +33,11 @@ __all__ = [
"LeitorDePlanoDeEdicao",
"MapeadorDeTempoDeOrigemParaTimeline",
"PlanoDeEdicao",
"ProblemaSemanticoDoPlano",
"ResultadoDaAcao",
"ResultadoDaAplicacao",
"TipoDeAcao",
"ValidadorSemanticoDePlano",
"ResultadoDaVerificacao",
"VerificadorDaAplicacao",
]
@@ -22,6 +22,9 @@ from .modelos import AcaoDeEdicao, PlanoDeEdicao, TipoDeAcao
_TOLERANCIA_DE_BORDA_EM_SEGUNDOS = 0.05
_TOLERANCIA_DE_DURACAO_EM_SEGUNDOS = 0.3
_RAIO_DE_BUSCA_DA_BORDA_EM_SEGUNDOS = 3.0
# Tolerância adicional para encontrar clipes cuja origem foi deslocada pelo
# drift do razor_all_tracks (drift observado no bridge real: até ~0.5s).
_TOLERANCIA_DE_ORIGEM_EM_SEGUNDOS = 0.5
@dataclass(frozen=True)
@@ -60,13 +63,16 @@ class AplicadorDePlanoDeEdicao:
tudo que vem depois dele. Processar do fim para o começo garante que
a posição de um corte ainda não aplicado nunca é afetada pelos
cortes já aplicados.
2. Marcadores e zooms por último, depois que a timeline já está no
formato final. Zoom (``set_clip_properties``) muda uma propriedade do
*clipe inteiro*, não de um trecho dele — se dois zooms caíssem em
instantes diferentes do mesmo clipe ainda não dividido pelos cortes,
o segundo sobrescreveria o primeiro no clipe inteiro, e a divisão
posterior faria os dois pedaços herdarem só o último valor. Cortar
primeiro garante que cada zoom já encontra um clipe próprio.
2. O gancho para abertura vem depois dos cortes. Ele localiza o trecho por
tempo da mídia original e cria uma cópia no começo da sequência.
3. Marcadores e zooms por último, depois que a timeline já está no
formato final: ambos precisam do instante *definitivo* na timeline,
e cortes/gancho deslocam clipes. Zoom não altera o clipe original —
cria uma adjustment layer numa faixa de vídeo própria, recortada no
trecho exato do zoom, e escala só essa camada (mesma técnica do
executor do painel CEP, ``scripts/apply-editorial-actions.mjs``). A
faixa de ajuste é criada uma vez por plano, na primeira ação de zoom,
e reaproveitada pelas seguintes.
A cada ação a timeline ativa é lida de novo no Premiere — nunca se
reaproveita uma leitura anterior — porque a ação anterior pode ter
@@ -104,13 +110,20 @@ class AplicadorDePlanoDeEdicao:
self.conversor_de_timeline = conversor_de_timeline
self.escrita = escrita
self.mapeador = mapeador
self._indice_da_faixa_de_ajuste: int | None = None
def aplicar(self, plano: PlanoDeEdicao) -> ResultadoDaAplicacao:
"""Aplica todas as ações de ``plano`` e devolve o resultado de cada uma."""
pontuais = [acao for acao in plano.acoes if acao.tipo is not TipoDeAcao.CORTE]
self._indice_da_faixa_de_ajuste = None
ganchos = plano.acoes_do_tipo(TipoDeAcao.GANCHO)
pontuais = [
acao for acao in plano.acoes
if acao.tipo not in {TipoDeAcao.CORTE, TipoDeAcao.GANCHO}
]
cortes = sorted(plano.acoes_do_tipo(TipoDeAcao.CORTE), key=lambda acao: acao.inicio, reverse=True)
resultados = [self._aplicar_corte(acao, plano.arquivo_de_origem) for acao in cortes]
resultados += [self._aplicar_gancho(acao, plano.arquivo_de_origem) for acao in ganchos]
resultados += [self._aplicar_acao_pontual(acao, plano.arquivo_de_origem) for acao in pontuais]
return ResultadoDaAplicacao(tuple(resultados))
@@ -125,8 +138,8 @@ class AplicadorDePlanoDeEdicao:
self._aplicar_marcador(acao, arquivo_de_origem)
return ResultadoDaAcao(acao, True, "marcador criado")
if acao.tipo is TipoDeAcao.ZOOM:
quantidade = self._aplicar_zoom(acao, arquivo_de_origem)
return ResultadoDaAcao(acao, True, f"zoom aplicado em {quantidade} clipe(s)")
self._aplicar_zoom(acao, arquivo_de_origem)
return ResultadoDaAcao(acao, True, "zoom aplicado via camada de ajuste")
if acao.tipo is TipoDeAcao.TEXTO:
self._aplicar_texto_como_marcador(acao, arquivo_de_origem)
return ResultadoDaAcao(
@@ -148,7 +161,14 @@ class AplicadorDePlanoDeEdicao:
conteudo = str(acao.parametros.get("content", ""))
self.escrita.adicionar_marcador(instante, "TEXTO (aplicar manualmente)", f"{conteudo} — {acao.motivo}")
def _aplicar_zoom(self, acao: AcaoDeEdicao, arquivo_de_origem: str) -> int:
def _aplicar_zoom(self, acao: AcaoDeEdicao, arquivo_de_origem: str) -> None:
"""Cria uma adjustment layer cobrindo o trecho do zoom e a escala.
Traduz ``[acao.inicio, acao.fim]`` (coordenadas de origem) para o
intervalo correspondente na timeline atual usando os clipes de
vídeo já cortados. Ao contrário do fluxo antigo, a ação não é mais
dividida por clipe: sempre gera uma única camada de ajuste.
"""
fator_de_escala = float(acao.parametros.get("scale", 1.3))
faixa_de_video = self._faixa_de_video(self._timeline_atual())
clipes = self._clipes_do_arquivo(faixa_de_video, arquivo_de_origem)
@@ -157,9 +177,12 @@ class AplicadorDePlanoDeEdicao:
raise ErroDeMapeamentoDeTempo(
f"Nenhum clipe de vídeo de {arquivo_de_origem!r} sobrepõe {acao.inicio}-{acao.fim}s de origem para o zoom."
)
for clipe in alvos:
self.escrita.aplicar_zoom(clipe.identificador, fator_de_escala)
return len(alvos)
primeiro, ultimo = alvos[0], alvos[-1]
inicio_na_timeline = self._borda_de_entrada(primeiro, acao.inicio)
fim_na_timeline = self._borda_de_saida(ultimo, acao.fim)
if self._indice_da_faixa_de_ajuste is None:
self._indice_da_faixa_de_ajuste = self.escrita.garantir_faixa_de_ajuste()
self.escrita.aplicar_zoom(self._indice_da_faixa_de_ajuste, inicio_na_timeline, fim_na_timeline, fator_de_escala)
def _aplicar_corte(self, acao: AcaoDeEdicao, arquivo_de_origem: str) -> ResultadoDaAcao:
"""Remove, em cada faixa afetada, todos os clipes contidos no intervalo do corte.
@@ -170,14 +193,49 @@ class AplicadorDePlanoDeEdicao:
``inPoint``/``outPoint``) só falharia depois de outra faixa já ter
sido cortada — deixando vídeo e áudio dessincronizados mesmo com o
corte "reportado" como falho.
Usa ``razor_all_tracks`` para cortar vídeo e áudio no mesmo instante,
evitando drift entre chamadas separadas de ``split_clip``.
"""
try:
faixas = self._timeline_atual().faixas
for faixa in faixas:
self._clipes_do_arquivo(faixa, arquivo_de_origem)
faixas_afetadas = [
faixa for faixa in faixas
if any(
self._sobrepoe(clipe, acao.inicio, acao.fim)
for clipe in self._clipes_do_arquivo(faixa, arquivo_de_origem)
)
]
if not faixas_afetadas:
raise ErroDeMapeamentoDeTempo(
f"Nenhum clipe de {arquivo_de_origem!r} sobrepõe o intervalo {acao.inicio}-{acao.fim}s de origem."
)
pontos_de_corte = self._calcular_pontos_de_corte(faixas_afetadas, acao, arquivo_de_origem)
razor_bem_sucedido = True
for ponto in pontos_de_corte:
try:
self.escrita.dividir_todas_as_faixas(ponto)
except ErroDeEdicao:
razor_bem_sucedido = False
break
if not razor_bem_sucedido:
raise ErroDeMapeamentoDeTempo(
f"O razor falhou ao cortar as faixas em {acao.inicio:.3f}-{acao.fim:.3f}s "
f"(Premiere 26.x QE no-op); nada foi removido."
)
faixas = self._timeline_atual().faixas
for faixa_id in {f.identificador for f in faixas_afetadas}:
faixa_atualizada = next(f for f in faixas if f.identificador == faixa_id)
self._validar_bordas_do_razor(faixa_atualizada, acao, arquivo_de_origem)
alvos_por_faixa = {
faixa.identificador: self._preparar_alvos_da_faixa(faixa, acao, arquivo_de_origem)
faixa.identificador: self._encontrar_alvos_pos_corte(faixa, acao, arquivo_de_origem)
for faixa in faixas
}
trechos_removidos = self._remover_alvos_coordenados(faixas, alvos_por_faixa)
@@ -189,6 +247,214 @@ class AplicadorDePlanoDeEdicao:
except ErroDeEdicao as erro:
return ResultadoDaAcao(acao, False, str(erro))
def _calcular_pontos_de_corte(
self, faixas_afetadas: list[Faixa], acao: AcaoDeEdicao, arquivo_de_origem: str
) -> list[float]:
"""Calcula os pontos de timeline onde o razor deve ser aplicado.
Devolve uma lista ordenada de instantes de timeline (em segundos)
correspondentes às bordas de entrada e saída do corte, mapeados a
partir do intervalo de origem. Os pontos são deduplicados e
ordenados do fim para o começo para que o ``razor_all_tracks``
não desloque clipes entre um corte e o próximo.
"""
pontos: set[float] = set()
for faixa in faixas_afetadas:
clipes = self._clipes_do_arquivo(faixa, arquivo_de_origem)
afetados = [clipe for clipe in clipes if self._sobrepoe(clipe, acao.inicio, acao.fim)]
for clipe in afetados:
if acao.inicio > clipe.intervalo_na_origem.inicio:
pontos.add(self.mapeador.instante_na_timeline(clipe, acao.inicio))
if acao.fim < clipe.intervalo_na_origem.fim:
pontos.add(self.mapeador.instante_na_timeline(clipe, acao.fim))
return sorted(pontos, reverse=True)
def _validar_bordas_do_razor(
self, faixa: Faixa, acao: AcaoDeEdicao, arquivo_de_origem: str
) -> None:
"""Verifica que o razor criou bordas perto dos pontos esperados.
Levanta :class:`ErroDeMapeamentoDeTempo` se nenhuma borda de clipe
estiver dentro de ``_RAIO_DE_BUSCA_DA_BORDA_EM_SEGUNDOS`` de um dos
pontos de corte esperados — nesse caso o drift é grande demais para
confiar no resultado.
"""
clipes = self._clipes_do_arquivo(faixa, arquivo_de_origem)
afetados = [clipe for clipe in clipes if self._sobrepoe(clipe, acao.inicio, acao.fim)]
if not afetados:
return
bordas_esperadas: list[float] = []
primeiro, ultimo = afetados[0], afetados[-1]
if acao.inicio > primeiro.intervalo_na_origem.inicio:
bordas_esperadas.append(self._borda_de_entrada(primeiro, acao.inicio))
if acao.fim < ultimo.intervalo_na_origem.fim:
bordas_esperadas.append(self._borda_de_saida(ultimo, acao.fim))
todas_bordas = {
clipe.intervalo_na_timeline.inicio for clipe in clipes
} | {
clipe.intervalo_na_timeline.fim for clipe in clipes
}
for esperada in bordas_esperadas:
mais_proxima = min(todas_bordas, key=lambda b: abs(b - esperada), default=None)
if mais_proxima is None or abs(mais_proxima - esperada) > _RAIO_DE_BUSCA_DA_BORDA_EM_SEGUNDOS:
raise ErroDeMapeamentoDeTempo(
f"Nenhuma borda de clipe foi encontrada perto de {esperada:.3f}s "
f"(raio de busca: {_RAIO_DE_BUSCA_DA_BORDA_EM_SEGUNDOS}s); o corte não foi aplicado."
)
def _encontrar_alvos_pos_corte(
self, faixa: Faixa, acao: AcaoDeEdicao, arquivo_de_origem: str
) -> list[Clipe]:
"""Encontra os clipes de uma faixa que ficam entre as bordas do corte.
Chamado depois que ``razor_all_tracks`` já dividiu todas as faixas;
não repete nenhuma divisão. Usa o intervalo de origem para encontrar
clipes cuja origem está contida no intervalo do corte, já que o drift
do razor pode deslocar as bordas de timeline. Valida que a duração
total dos clipes encontrados é compatível com a sobreposição de
origem esperada.
"""
clipes = self._clipes_do_arquivo(faixa, arquivo_de_origem)
afetados = [clipe for clipe in clipes if self._sobrepoe(clipe, acao.inicio, acao.fim)]
if not afetados:
return []
alvo = [
clipe for clipe in afetados
if clipe.intervalo_na_origem.inicio >= acao.inicio - _TOLERANCIA_DE_ORIGEM_EM_SEGUNDOS
and clipe.intervalo_na_origem.fim <= acao.fim + _TOLERANCIA_DE_ORIGEM_EM_SEGUNDOS
]
duracao_removida = sum(clipe.intervalo_na_timeline.duracao for clipe in alvo)
duracao_esperada = sum(
self._sobreposicao_em_segundos(clipe, acao.inicio, acao.fim) for clipe in afetados
)
if abs(duracao_removida - duracao_esperada) > _TOLERANCIA_DE_DURACAO_EM_SEGUNDOS:
raise ErroDeMapeamentoDeTempo(
f"Na faixa {faixa.identificador!r}, o corte em {acao.inicio:.3f}-{acao.fim:.3f}s "
f"não isolou o trecho esperado (esperado {duracao_esperada:.3f}s, "
f"encontrado {duracao_removida:.3f}s); nada foi removido para evitar apagar o trecho errado."
)
return alvo
def _aplicar_gancho(self, acao: AcaoDeEdicao, arquivo_de_origem: str) -> ResultadoDaAcao:
"""Recorta uma cópia de um par simples e a insere na abertura."""
try:
faixas = self._timeline_atual().faixas
faixas_afetadas = [
faixa for faixa in faixas
if any(
self._sobrepoe(clipe, acao.inicio, acao.fim)
for clipe in self._clipes_do_arquivo(faixa, arquivo_de_origem)
)
]
videos = [faixa for faixa in faixas_afetadas if faixa.tipo == "video"]
audios = [faixa for faixa in faixas_afetadas if faixa.tipo == "audio"]
if len(videos) != 1 or len(audios) != 1:
raise ErroDeMapeamentoDeTempo(
"A ação hook exige exatamente uma faixa de vídeo e uma de áudio com o trecho de origem."
)
faixas_adicionais_ocupadas = [
faixa.identificador
for faixa in faixas
if faixa not in faixas_afetadas and faixa.clipes
]
if faixas_adicionais_ocupadas:
raise ErroDeMapeamentoDeTempo(
"A ação hook exige uma sequência simples; há conteúdo nas faixas adicionais: "
+ ", ".join(faixas_adicionais_ocupadas)
)
for faixa in faixas_afetadas:
afetados = [
clipe for clipe in self._clipes_do_arquivo(faixa, arquivo_de_origem)
if self._sobrepoe(clipe, acao.inicio, acao.fim)
]
if len(afetados) != 1:
raise ErroDeMapeamentoDeTempo(
f"A ação hook precisa estar contida em um único clipe da faixa {faixa.identificador!r}."
)
intervalo_na_origem = afetados[0].intervalo_na_origem
if (
intervalo_na_origem is None
or acao.inicio < intervalo_na_origem.inicio - _TOLERANCIA_DE_BORDA_EM_SEGUNDOS
or acao.fim > intervalo_na_origem.fim + _TOLERANCIA_DE_BORDA_EM_SEGUNDOS
):
raise ErroDeMapeamentoDeTempo(
f"A ação hook ultrapassa o clipe da faixa {faixa.identificador!r}."
)
inicio_na_timeline = self._borda_de_entrada(afetados[0], acao.inicio)
if inicio_na_timeline <= _TOLERANCIA_DE_BORDA_EM_SEGUNDOS:
raise ErroDeMapeamentoDeTempo("O trecho da ação hook já está no início da sequência.")
if min(clipe.intervalo_na_timeline.inicio for clipe in faixa.clipes) > _TOLERANCIA_DE_BORDA_EM_SEGUNDOS:
raise ErroDeMapeamentoDeTempo(
f"A faixa {faixa.identificador!r} não começa em 0s; o gancho foi recusado."
)
# A ferramenta de gancho do bridge exige um par já isolado: ela lê
# o in/out do próprio clipe, não recebe limites de origem à
# parte. Usa ``razor_all_tracks`` para dividir vídeo e áudio
# no mesmo instante, evitando drift entre chamadas separadas.
pontos_para_cortar: set[float] = set()
for faixa in faixas_afetadas:
clipes = self._clipes_do_arquivo(faixa, arquivo_de_origem)
afetados = [clipe for clipe in clipes if self._sobrepoe(clipe, acao.inicio, acao.fim)]
for clipe in afetados:
if acao.inicio > clipe.intervalo_na_origem.inicio:
pontos_para_cortar.add(self.mapeador.instante_na_timeline(clipe, acao.inicio))
if acao.fim < clipe.intervalo_na_origem.fim:
pontos_para_cortar.add(self.mapeador.instante_na_timeline(clipe, acao.fim))
razor_bem_sucedido = True
for ponto in sorted(pontos_para_cortar, reverse=True):
try:
self.escrita.dividir_todas_as_faixas(ponto)
except ErroDeEdicao:
razor_bem_sucedido = False
break
if not razor_bem_sucedido:
raise ErroDeMapeamentoDeTempo(
f"O razor falhou ao isolar o trecho do gancho "
f"({acao.inicio:.3f}-{acao.fim:.3f}s); o gancho não foi aplicado."
)
faixas = self._timeline_atual().faixas
faixas_afetadas = [
faixa for faixa in faixas
if any(
self._sobrepoe(clipe, acao.inicio, acao.fim)
for clipe in self._clipes_do_arquivo(faixa, arquivo_de_origem)
)
]
alvos: list[Clipe] = []
for faixa in faixas_afetadas:
clipes = self._clipes_do_arquivo(faixa, arquivo_de_origem)
afetados = [clipe for clipe in clipes if self._sobrepoe(clipe, acao.inicio, acao.fim)]
if not afetados:
continue
primeiro, ultimo = afetados[0], afetados[-1]
inicio_na_timeline = self._borda_de_entrada(primeiro, acao.inicio)
fim_na_timeline = self._borda_de_saida(ultimo, acao.fim)
isolados = [
clipe for clipe in clipes
if inicio_na_timeline - _TOLERANCIA_DE_BORDA_EM_SEGUNDOS <= clipe.intervalo_na_timeline.inicio
and clipe.intervalo_na_timeline.fim <= fim_na_timeline + _TOLERANCIA_DE_BORDA_EM_SEGUNDOS
]
if len(isolados) != 1:
raise ErroDeMapeamentoDeTempo(
f"A divisão do gancho na faixa {faixa.identificador!r} não isolou exatamente um clipe "
f"(encontrado(s) {len(isolados)})."
)
alvos.append(isolados[0])
if len(alvos) != 2:
raise ErroDeMapeamentoDeTempo(
"O Premiere não localizou exatamente um clipe de vídeo e um de áudio para a ação hook."
)
self.escrita.duplicar_trecho_vinculado_para_o_inicio(
[clipe.identificador for clipe in alvos],
)
return ResultadoDaAcao(acao, True, "gancho vinculado duplicado no início")
except ErroDeEdicao as erro:
return ResultadoDaAcao(acao, False, str(erro))
def _preparar_alvos_da_faixa(
self, faixa: Faixa, acao: AcaoDeEdicao, arquivo_de_origem: str
) -> list[Clipe]:
+6 -9
View File
@@ -9,11 +9,10 @@ from ..integracoes.premiere.erros_mcp import ErroDeFerramentaMCP
class BackupDaSequencia:
"""Duplica a sequência ativa do Premiere antes de uma edição destrutiva.
Espelha o passo de segurança do painel CEP
(``app.project.activeSequence.clone()`` em ``applyEditorialActions()``,
``cep-plugin/main.js``): sem undo por script nesta versão do Premiere,
duplicar a sequência antes de cortar é a única forma de recuperar o
estado anterior se algo sair errado.
A engine concentra este passo de segurança para que painel e linha de
comando não criem cópias duplicadas. Sem undo por script nesta versão do
Premiere, duplicar a sequência antes de cortar é a única forma de recuperar
o estado anterior se algo sair errado.
"""
def __init__(self, cliente_mcp: ClienteMCP) -> None:
@@ -22,10 +21,8 @@ class BackupDaSequencia:
def criar_backup(self, identificador_da_sequencia: str) -> bool:
"""Duplica a sequência. Devolve ``True`` se conseguiu, ``False`` se o Premiere recusou.
Não levanta exceção numa recusa: o painel CEP também segue aplicando
o plano mesmo se o backup falhar (loga e avisa, não bloqueia) — a
falta de backup não deve impedir a edição que o usuário pediu, só
precisa ficar visível para quem chama decidir o que fazer.
Não levanta exceção numa recusa: o chamador decide se a ação pode
continuar. Ganchos estruturais são bloqueados sem essa confirmação.
"""
try:
self.cliente_mcp.chamar("duplicate_sequence", {"sequence_id": identificador_da_sequencia})
+102 -8
View File
@@ -1,8 +1,9 @@
"""Escrita de mutações na sequência ativa do Premiere via MCP.
Isola o nome das ferramentas MCP e o formato de argumentos externos
(``split_clip``, ``remove_from_timeline``, ``remove_linked_from_timeline``, ``set_clip_properties``,
``add_marker``) do resto do domínio. Quem chama esta classe fala só em
(``split_clip``, ``remove_from_timeline``, ``remove_linked_from_timeline``,
``duplicate_linked_range_to_start``, ``set_clip_properties``, ``add_marker``) do
resto do domínio. Quem chama esta classe fala só em
segundos de timeline, identificador de clipe e fator de escala — nunca em
nomes de ferramenta ou ticks do Premiere.
"""
@@ -78,6 +79,37 @@ class EscritaNoEditor:
f"em {instante_da_timeline:.3f}s: {erro}"
) from erro
def dividir_todas_as_faixas(self, instante_da_timeline: float) -> None:
"""Corta todas as faixas de vídeo e áudio no mesmo instante.
Usa ``razor_all_tracks`` para garantir que vídeo e áudio sejam
divididos no mesmo ponto de tempo, evitando drift entre chamadas
separadas de ``split_clip``.
Levanta :class:`ErroDeEscritaNoEditor` se o razor reportar uma
falha parcial (algumas faixas cortadas, outras não) — o Premiere
26.x tem um bug QE que faz o razor falhar silenciosamente em
algumas instalações. Uma falha parcial deixa a timeline num estado
inconsistente que corrompe operações subsequentes (remoção, zoom,
marcador), então a operação é recusada inteiramente.
"""
try:
resultado = self.cliente_mcp.chamar(
"razor_all_tracks",
{"time_seconds": instante_da_timeline, "track_type": "both"},
)
except ErroDeFerramentaMCP as erro:
raise ErroDeEscritaNoEditor(
f"Não foi possível cortar todas as faixas em {instante_da_timeline:.3f}s: {erro}"
) from erro
elegiveis = int(resultado.get("eligibleTracks", 0))
cortados = int(resultado.get("razored", 0))
if elegiveis > 0 and cortados < elegiveis:
raise ErroDeEscritaNoEditor(
f"O razor cortou apenas {cortados} de {elegiveis} faixa(s) elegíveis "
f"em {instante_da_timeline:.3f}s (Premiere 26.x QE no-op)"
)
def remover_trecho(self, identificador_do_clipe: str) -> None:
"""Remove o clipe da timeline fechando o espaço que ele ocupava.
@@ -113,19 +145,81 @@ class EscritaNoEditor:
f"Não foi possível remover o par vinculado {identificadores_dos_clipes!r}: {erro}"
) from erro
def aplicar_zoom(self, identificador_do_clipe: str, fator_de_escala: float) -> None:
"""Aplica um punch-in no clipe, escalando-o por ``fator_de_escala`` (1.0 = tamanho original).
def duplicar_trecho_vinculado_para_o_inicio(
self,
identificadores_dos_clipes: list[str],
) -> None:
"""Copia para o início um par de vídeo e áudio já isolado por corte.
Levanta :class:`ErroDeEscritaNoEditor` se o Premiere recusar a
alteração.
A ferramenta externa lê o in/out do próprio clipe — por isso os dois
identificadores precisam já corresponder exatamente ao trecho do
gancho, isolado por ``split_clip`` antes desta chamada. Executa
inserção com ripple, mantém a ocorrência original intacta e confirma
posição, duração e sincronismo. A operação é recusada quando os
identificadores não formam um par simples.
"""
if len(identificadores_dos_clipes) != 2:
raise ErroDeEscritaNoEditor("O gancho exige exatamente um clipe de vídeo e um de áudio.")
try:
self.cliente_mcp.chamar(
"set_clip_properties", {"node_id": identificador_do_clipe, "scale": fator_de_escala * 100}
"duplicate_linked_range_to_start",
{"node_ids": identificadores_dos_clipes},
)
except ErroDeFerramentaMCP as erro:
raise ErroDeEscritaNoEditor(
f"Não foi possível aplicar zoom no clipe {identificador_do_clipe!r}: {erro}"
f"Não foi possível duplicar o par vinculado {identificadores_dos_clipes!r} no início: {erro}"
) from erro
def garantir_faixa_de_ajuste(self) -> int:
"""Cria uma faixa de vídeo nova, no topo da pilha, para hospedar camadas de ajuste de zoom.
Devolve o índice da faixa criada. Chamado uma única vez por plano —
quem chama guarda o índice e reaproveita para todos os zooms do
plano, o mesmo padrão do executor do painel CEP
(``scripts/apply-editorial-actions.mjs``).
"""
try:
resultado = self.cliente_mcp.chamar("add_track", {"track_type": "video", "count": 1})
except ErroDeFerramentaMCP as erro:
raise ErroDeEscritaNoEditor(f"Não foi possível criar a faixa de ajuste para zoom: {erro}") from erro
return int(resultado["totalTracks"]) - 1
def aplicar_zoom(
self,
indice_da_faixa_de_ajuste: int,
inicio_na_timeline: float,
fim_na_timeline: float,
fator_de_escala: float,
) -> None:
"""Aplica um punch-in criando uma adjustment layer sobre o trecho, sem tocar o clipe original.
Reproduz, no caminho Python, a técnica já usada pelo executor do
painel CEP: move o playhead para o início do trecho, cria a camada
de ajuste na faixa indicada, ajusta sua duração e posição para
cobrir exatamente ``[inicio_na_timeline, fim_na_timeline)``, e
escala só essa camada por ``fator_de_escala`` (1.0 = tamanho
original).
Levanta :class:`ErroDeEscritaNoEditor` se o Premiere recusar
qualquer um dos passos.
"""
duracao = fim_na_timeline - inicio_na_timeline
try:
self.cliente_mcp.chamar("set_playhead_position", {"time_seconds": inicio_na_timeline})
resultado = self.cliente_mcp.chamar(
"add_adjustment_layer", {"track_index": indice_da_faixa_de_ajuste}
)
identificador_da_camada = resultado["nodeId"]
self.cliente_mcp.chamar("trim_clip", {"node_id": identificador_da_camada, "new_out_seconds": duracao})
self.cliente_mcp.chamar(
"move_clip", {"node_id": identificador_da_camada, "new_start_seconds": inicio_na_timeline}
)
self.cliente_mcp.chamar(
"set_clip_properties", {"node_id": identificador_da_camada, "scale": fator_de_escala * 100}
)
except ErroDeFerramentaMCP as erro:
raise ErroDeEscritaNoEditor(
f"Não foi possível aplicar zoom em {inicio_na_timeline:.3f}-{fim_na_timeline:.3f}s: {erro}"
) from erro
def adicionar_marcador(self, instante_da_timeline: float, nome: str, comentario: str) -> None:
+19 -1
View File
@@ -14,12 +14,13 @@ from typing import Any
class TipoDeAcao(str, Enum):
"""Os quatro tipos de ação que o plano de edição pode descrever."""
"""Os tipos de ação que o plano de edição pode descrever."""
CORTE = "cut"
ZOOM = "zoom"
TEXTO = "text"
MARCADOR = "marker"
GANCHO = "hook"
@dataclass(frozen=True)
@@ -46,6 +47,13 @@ class AcaoDeEdicao:
)
if not self.motivo.strip():
raise ValueError("Ação inválida: motivo não pode ser vazio.")
if self.tipo is TipoDeAcao.GANCHO:
destino = self.parametros.get("target", 0)
manter_original = self.parametros.get("keep_original", True)
if isinstance(destino, bool) or not isinstance(destino, (int, float)) or float(destino) != 0.0:
raise ValueError("Gancho inválido: params.target precisa ser 0 nesta versão.")
if manter_original is not True:
raise ValueError("Gancho inválido: params.keep_original precisa ser true.")
@property
def duracao(self) -> float:
@@ -63,6 +71,16 @@ class PlanoDeEdicao:
def __post_init__(self) -> None:
if not self.arquivo_de_origem.strip():
raise ValueError("Plano de edição inválido: arquivo de origem não informado.")
ganchos = self.acoes_do_tipo(TipoDeAcao.GANCHO)
if len(ganchos) > 1:
raise ValueError("Plano de edição inválido: esta versão aceita somente uma ação hook.")
if ganchos:
gancho = ganchos[0]
for acao in self.acoes:
if acao is gancho:
continue
if acao.inicio < gancho.fim and gancho.inicio < acao.fim:
raise ValueError("Plano de edição inválido: outra ação não pode sobrepor o trecho do hook.")
def acoes_do_tipo(self, tipo: TipoDeAcao) -> tuple[AcaoDeEdicao, ...]:
"""Devolve, na ordem original, só as ações do tipo informado."""
@@ -0,0 +1,23 @@
{
"duracao_da_midia": 180,
"acoes": [
{
"tipo": "hook",
"inicio": 141.2,
"fim": 147.8,
"motivo": "frase completa para abrir o vídeo"
},
{
"tipo": "cut",
"inicio": 32.5,
"fim": 48.0,
"motivo": "remover repetição"
},
{
"tipo": "cut",
"inicio": 95.4,
"fim": 110.0,
"motivo": "remover pausa longa"
}
]
}
@@ -0,0 +1,46 @@
{
"modo_de_execucao": "montagem_incremental",
"plano_de_origem": 6,
"source_sequence_id": "12669c9c-038d-4a3f-99f9-fe6f6827b5f4",
"source_item_id": "000f4289",
"target_name": "PROTÓTIPO - plano 6 - montagem incremental",
"duracao_da_midia": 1018.22554166667,
"ranges": [
{
"in_seconds": 255.98,
"out_seconds": 274.96,
"destination_seconds": 0,
"label": "gancho de abertura"
},
{
"in_seconds": 124.47,
"out_seconds": 215.06,
"destination_seconds": 18.98,
"label": "corpo 1"
},
{
"in_seconds": 255.98,
"out_seconds": 427.32,
"destination_seconds": 109.57,
"label": "corpo 2 - ocorrência original do gancho"
},
{
"in_seconds": 469.24,
"out_seconds": 552.28,
"destination_seconds": 280.91,
"label": "corpo 3"
},
{
"in_seconds": 848.42,
"out_seconds": 879.89,
"destination_seconds": 363.95,
"label": "corpo 4"
},
{
"in_seconds": 962.78,
"out_seconds": 1014.99,
"label": "corpo 5",
"destination_seconds": 395.42
}
]
}
@@ -0,0 +1,17 @@
{
"test_adjustment_layer": true,
"actions": [
{
"kind": "hook",
"start": 255.98,
"end": 274.96,
"reason": "Gancho de abertura: Toda minha vida, eu olhava no espelho..."
},
{ "kind": "cut", "start": 0, "end": 124.47, "reason": "Remover trecho inicial" },
{ "kind": "cut", "start": 215.06, "end": 255.98, "reason": "Remover respiro antes do gancho" },
{ "kind": "cut", "start": 427.32, "end": 469.24, "reason": "Remover trecho" },
{ "kind": "cut", "start": 552.28, "end": 848.42, "reason": "Remover trecho" },
{ "kind": "cut", "start": 879.89, "end": 962.78, "reason": "Remover trecho" },
{ "kind": "cut", "start": 1014.99, "end": 1018.2255, "reason": "Remover final" }
]
}
@@ -0,0 +1,33 @@
{
"source": "/Volumes/Merongo/PROJETOS/DEPOIMENTO - ERIKA/0E6A8829.MP4",
"actions": [
{
"kind": "zoom",
"start": 157.4,
"end": 164.3,
"reason": "Ênfase em Sempre tentei levar a minha mudança de vida como meu principal objetivo.",
"params": {"scale": 1.18}
},
{
"kind": "zoom",
"start": 515.13,
"end": 517.09,
"reason": "Ênfase em Se algo der errado, eu vou estar aqui.",
"params": {"scale": 1.3}
},
{
"kind": "zoom",
"start": 877.26,
"end": 879.56,
"reason": "Ênfase em Então, mudou muito a minha vida em todos os sentidos.",
"params": {"scale": 1.2}
},
{
"kind": "zoom",
"start": 1010.26,
"end": 1014.64,
"reason": "Ênfase no fecho sobre a segurança do profissional.",
"params": {"scale": 1.5}
}
]
}
@@ -0,0 +1,211 @@
"""Protótipo descartável da montagem incremental de uma edição.
Este módulo não se conecta ao Premiere. Ele responde à pergunta:
"Dado um plano em intervalos da mídia original, qual sequência de inserções
seria feita em uma sequência de destino inicialmente vazia?"
O protótipo mantém a sequência original intacta e transforma ações de corte
em trechos preservados. O gancho, quando existir, é colocado primeiro.
"""
from __future__ import annotations
from dataclasses import dataclass
from enum import Enum
import json
from pathlib import Path
import sys
from typing import Any
class TipoDeTrecho(str, Enum):
"""Origem editorial de um trecho que será inserido na montagem."""
GANCHO = "gancho"
CORPO = "corpo"
@dataclass(frozen=True)
class IntervaloDeOrigem:
"""Representa um intervalo contínuo em segundos na mídia original."""
inicio: float
fim: float
def __post_init__(self) -> None:
if self.inicio < 0 or self.fim <= self.inicio:
raise ValueError("Intervalo inválido: o fim precisa ser maior que o início.")
@property
def duracao(self) -> float:
"""Devolve a duração do intervalo em segundos."""
return self.fim - self.inicio
@dataclass(frozen=True)
class AcaoDoPlano:
"""Ação mínima usada pelo protótipo para representar um plano editorial."""
tipo: str
inicio: float
fim: float
motivo: str = ""
def intervalo(self) -> IntervaloDeOrigem:
"""Converte a ação em intervalo na mídia original."""
return IntervaloDeOrigem(self.inicio, self.fim)
@dataclass(frozen=True)
class InsercaoDeMontagem:
"""Uma inserção A/V planejada para a sequência de destino."""
ordem: int
tipo: TipoDeTrecho
origem: IntervaloDeOrigem
destino_inicio: float
destino_fim: float
motivo: str
@property
def duracao(self) -> float:
"""Devolve a duração que será inserida na montagem."""
return self.destino_fim - self.destino_inicio
def como_dicionario(self) -> dict[str, Any]:
"""Serializa a inserção para inspeção humana ou integração futura."""
return {
"ordem": self.ordem,
"tipo": self.tipo.value,
"origem": {"inicio": self.origem.inicio, "fim": self.origem.fim},
"destino": {"inicio": self.destino_inicio, "fim": self.destino_fim},
"duracao": self.duracao,
"motivo": self.motivo,
}
class PlanejadorDeMontagemIncremental:
"""Converte cortes e gancho em uma montagem crescente e sem ripple de origem."""
def __init__(self, duracao_da_midia: float) -> None:
if duracao_da_midia <= 0:
raise ValueError("A duração da mídia precisa ser positiva.")
self.duracao_da_midia = duracao_da_midia
def planejar(self, acoes: list[AcaoDoPlano]) -> list[InsercaoDeMontagem]:
"""Gera as inserções ordenadas que reconstruiriam a edição final."""
cortes = self._normalizar_intervalos(
acao.intervalo() for acao in acoes if acao.tipo == "cut"
)
gancho = next((acao for acao in acoes if acao.tipo == "hook"), None)
if sum(acao.tipo == "hook" for acao in acoes) > 1:
raise ValueError("O protótipo aceita no máximo um gancho.")
trechos = self._complemento_da_midia(cortes)
insercoes: list[InsercaoDeMontagem] = []
proximo_destino = 0.0
if gancho is not None:
intervalo_do_gancho = gancho.intervalo()
self._validar_intervalo_do_gancho(intervalo_do_gancho, trechos)
insercoes.append(
self._criar_insercao(
len(insercoes) + 1,
TipoDeTrecho.GANCHO,
intervalo_do_gancho,
proximo_destino,
gancho.motivo or "gancho de abertura",
)
)
proximo_destino += intervalo_do_gancho.duracao
for trecho in trechos:
insercoes.append(
self._criar_insercao(
len(insercoes) + 1,
TipoDeTrecho.CORPO,
trecho,
proximo_destino,
"trecho preservado após os cortes",
)
)
proximo_destino += trecho.duracao
return insercoes
def _normalizar_intervalos(self, intervalos: Any) -> list[IntervaloDeOrigem]:
"""Ordena, limita e rejeita cortes sobrepostos."""
normalizados = []
for intervalo in sorted(intervalos, key=lambda item: item.inicio):
inicio = max(0.0, intervalo.inicio)
fim = min(self.duracao_da_midia, intervalo.fim)
if fim <= inicio:
continue
atual = IntervaloDeOrigem(inicio, fim)
if normalizados and atual.inicio < normalizados[-1].fim:
raise ValueError("Os cortes do protótipo não podem se sobrepor.")
normalizados.append(atual)
return normalizados
def _complemento_da_midia(self, cortes: list[IntervaloDeOrigem]) -> list[IntervaloDeOrigem]:
"""Calcula os trechos que permanecem depois dos cortes."""
trechos: list[IntervaloDeOrigem] = []
cursor = 0.0
for corte in cortes:
if corte.inicio > cursor:
trechos.append(IntervaloDeOrigem(cursor, corte.inicio))
cursor = corte.fim
if cursor < self.duracao_da_midia:
trechos.append(IntervaloDeOrigem(cursor, self.duracao_da_midia))
return trechos
def _validar_intervalo_do_gancho(
self, gancho: IntervaloDeOrigem, trechos: list[IntervaloDeOrigem]
) -> None:
"""Confirma que o gancho não foi removido pelo conjunto de cortes."""
if not any(self._contem_intervalo(trecho, gancho) for trecho in trechos):
raise ValueError("O gancho precisa estar em um trecho preservado da mídia.")
@staticmethod
def _contem_intervalo(
recipiente: IntervaloDeOrigem, intervalo: IntervaloDeOrigem
) -> bool:
"""Informa se um intervalo está completamente dentro de outro."""
return recipiente.inicio <= intervalo.inicio and intervalo.fim <= recipiente.fim
@staticmethod
def _criar_insercao(
ordem: int,
tipo: TipoDeTrecho,
origem: IntervaloDeOrigem,
destino_inicio: float,
motivo: str,
) -> InsercaoDeMontagem:
"""Cria uma inserção com destino contíguo na sequência de montagem."""
return InsercaoDeMontagem(
ordem=ordem,
tipo=tipo,
origem=origem,
destino_inicio=destino_inicio,
destino_fim=destino_inicio + origem.duracao,
motivo=motivo,
)
def carregar_pedido(caminho: Path) -> tuple[float, list[AcaoDoPlano]]:
"""Carrega um pedido JSON pequeno para execução local do protótipo."""
dados = json.loads(caminho.read_text(encoding="utf-8"))
acoes = [AcaoDoPlano(**acao) for acao in dados["acoes"]]
return float(dados["duracao_da_midia"]), acoes
def executar(caminho: Path) -> None:
"""Executa o protótipo e imprime o plano de montagem resultante."""
duracao, acoes = carregar_pedido(caminho)
insercoes = PlanejadorDeMontagemIncremental(duracao).planejar(acoes)
print(json.dumps({"sequencia_de_destino": insercoes}, default=lambda item: item.como_dicionario(), ensure_ascii=False, indent=2))
if __name__ == "__main__":
if len(sys.argv) != 2:
raise SystemExit("Uso: python3 prototipo_montagem_incremental.py pedido.json")
executar(Path(sys.argv[1]))
+300
View File
@@ -0,0 +1,300 @@
"""Validação semântica de cortes e ganchos antes de alterar a timeline."""
from __future__ import annotations
from dataclasses import dataclass
from engine.scanner.modelos import PalavraDeTranscricao, SegmentoDeTranscricao
from .erros import ErroDePlanoInvalido
from .modelos import AcaoDeEdicao, PlanoDeEdicao, TipoDeAcao
@dataclass(frozen=True)
class ProblemaSemanticoDoPlano:
"""Descreve uma borda insegura e a ação em que ela foi encontrada."""
codigo: str
mensagem: str
indice_da_acao: int
instante: float
class ValidadorSemanticoDePlano:
"""Protege palavras, frases completas e respiros nas emendas editoriais."""
_TOLERANCIA_EM_SEGUNDOS = 0.01
_PONTUACAO_DE_FECHAMENTO = (".", "!", "?", "…")
def __init__(self, margem_minima_em_segundos: float = 0.3) -> None:
"""Configura a folga acústica mínima exigida junto à fala mantida."""
if margem_minima_em_segundos < 0:
raise ValueError("A margem mínima não pode ser negativa.")
self._margem_minima = margem_minima_em_segundos
def validar(
self,
plano: PlanoDeEdicao,
falas: tuple[SegmentoDeTranscricao, ...],
) -> tuple[ProblemaSemanticoDoPlano, ...]:
"""Devolve os problemas semânticos encontrados em cortes e ganchos."""
cortes = tuple(
(indice, acao)
for indice, acao in enumerate(plano.acoes)
if acao.tipo is TipoDeAcao.CORTE
)
ganchos = tuple(
(indice, acao)
for indice, acao in enumerate(plano.acoes)
if acao.tipo is TipoDeAcao.GANCHO
)
palavras = tuple(
sorted(
(palavra for fala in falas for palavra in fala.palavras),
key=lambda palavra: (palavra.inicio, palavra.fim),
)
)
problemas: list[ProblemaSemanticoDoPlano] = []
for indice, corte in cortes:
problemas.extend(self._validar_palavras_interceptadas(indice, corte, palavras))
problemas.extend(self._validar_borda_inicial(indice, corte, cortes, palavras))
problemas.extend(self._validar_borda_final(indice, corte, cortes, falas, palavras))
for indice, gancho in ganchos:
problemas.extend(self._validar_palavras_interceptadas(indice, gancho, palavras))
problemas.extend(self._validar_frase_do_gancho(indice, gancho, falas, palavras))
return tuple(problemas)
def garantir_valido(
self,
plano: PlanoDeEdicao,
falas: tuple[SegmentoDeTranscricao, ...],
) -> None:
"""Levanta erro explicativo quando uma emenda ameaça a integridade da fala."""
problemas = self.validar(plano, falas)
if not problemas:
return
detalhes = " | ".join(problema.mensagem for problema in problemas)
raise ErroDePlanoInvalido(
"Plano bloqueado pela validação de integridade da fala: " + detalhes
)
def _validar_frase_do_gancho(
self,
indice: int,
gancho: AcaoDeEdicao,
falas: tuple[SegmentoDeTranscricao, ...],
palavras: tuple[PalavraDeTranscricao, ...],
) -> list[ProblemaSemanticoDoPlano]:
"""Confirma que o gancho duplicado começa e termina como uma fala completa."""
contidas = tuple(
palavra for palavra in palavras
if gancho.inicio - self._TOLERANCIA_EM_SEGUNDOS <= palavra.inicio
and palavra.fim <= gancho.fim + self._TOLERANCIA_EM_SEGUNDOS
)
if not contidas:
return [ProblemaSemanticoDoPlano(
codigo="gancho_sem_fala",
mensagem=f"ação {indice + 1}: o trecho do gancho não contém palavras temporizadas",
indice_da_acao=indice,
instante=gancho.inicio,
)]
primeira, ultima = contidas[0], contidas[-1]
problemas: list[ProblemaSemanticoDoPlano] = []
if primeira.inicio - gancho.inicio < self._margem_minima - self._TOLERANCIA_EM_SEGUNDOS:
problemas.append(ProblemaSemanticoDoPlano(
codigo="margem_inicial_do_gancho_insuficiente",
mensagem=(
f"ação {indice + 1}: deixe ao menos {self._margem_minima:.2f}s antes "
f'da primeira palavra do gancho "{primeira.texto}"'
),
indice_da_acao=indice,
instante=gancho.inicio,
))
if gancho.fim - ultima.fim < self._margem_minima - self._TOLERANCIA_EM_SEGUNDOS:
problemas.append(ProblemaSemanticoDoPlano(
codigo="margem_final_do_gancho_insuficiente",
mensagem=(
f"ação {indice + 1}: deixe ao menos {self._margem_minima:.2f}s após "
f'a última palavra do gancho "{ultima.texto}"'
),
indice_da_acao=indice,
instante=gancho.fim,
))
fala_inicial = self._fala_da_palavra(primeira, falas)
primeira_letra = next((letra for letra in primeira.texto if letra.isalpha()), "")
comeca_no_meio = (
fala_inicial is not None
and primeira.inicio > fala_inicial.inicio + self._TOLERANCIA_EM_SEGUNDOS
) or bool(primeira_letra and primeira_letra.islower())
if comeca_no_meio:
problemas.append(ProblemaSemanticoDoPlano(
codigo="gancho_comeca_no_meio_da_frase",
mensagem=f'ação {indice + 1}: o gancho começa em "{primeira.texto}", no meio da frase',
indice_da_acao=indice,
instante=gancho.inicio,
))
if not ultima.texto.rstrip().endswith(self._PONTUACAO_DE_FECHAMENTO):
problemas.append(ProblemaSemanticoDoPlano(
codigo="gancho_termina_no_meio_da_frase",
mensagem=f'ação {indice + 1}: o gancho termina em "{ultima.texto}" sem concluir a frase',
indice_da_acao=indice,
instante=gancho.fim,
))
return problemas
def _validar_palavras_interceptadas(
self,
indice: int,
corte: AcaoDeEdicao,
palavras: tuple[PalavraDeTranscricao, ...],
) -> list[ProblemaSemanticoDoPlano]:
problemas = []
for borda, instante in (("início", corte.inicio), ("fim", corte.fim)):
palavra = next(
(
item
for item in palavras
if item.inicio + self._TOLERANCIA_EM_SEGUNDOS
< instante
< item.fim - self._TOLERANCIA_EM_SEGUNDOS
),
None,
)
if palavra is not None:
problemas.append(ProblemaSemanticoDoPlano(
codigo="palavra_interceptada",
mensagem=(
f"ação {indice + 1}: o {borda} em {instante:.2f}s corta "
f'a palavra "{palavra.texto}"'
),
indice_da_acao=indice,
instante=instante,
))
return problemas
def _validar_borda_inicial(
self,
indice: int,
corte: AcaoDeEdicao,
cortes: tuple[tuple[int, AcaoDeEdicao], ...],
palavras: tuple[PalavraDeTranscricao, ...],
) -> list[ProblemaSemanticoDoPlano]:
palavra_anterior = self._ultima_palavra_mantida_antes(corte.inicio, cortes, palavras)
if palavra_anterior is None:
return []
problemas = []
margem = corte.inicio - palavra_anterior.fim
if margem < self._margem_minima - self._TOLERANCIA_EM_SEGUNDOS:
problemas.append(ProblemaSemanticoDoPlano(
codigo="margem_antes_do_corte_insuficiente",
mensagem=(
f"ação {indice + 1}: deixe ao menos {self._margem_minima:.2f}s após "
f'a palavra mantida "{palavra_anterior.texto}" antes de iniciar o corte'
),
indice_da_acao=indice,
instante=corte.inicio,
))
if not palavra_anterior.texto.rstrip().endswith(self._PONTUACAO_DE_FECHAMENTO):
problemas.append(ProblemaSemanticoDoPlano(
codigo="frase_interrompida_antes_do_corte",
mensagem=(
f"ação {indice + 1}: a fala mantida termina em "
f'"{palavra_anterior.texto}" sem concluir a frase'
),
indice_da_acao=indice,
instante=corte.inicio,
))
return problemas
def _validar_borda_final(
self,
indice: int,
corte: AcaoDeEdicao,
cortes: tuple[tuple[int, AcaoDeEdicao], ...],
falas: tuple[SegmentoDeTranscricao, ...],
palavras: tuple[PalavraDeTranscricao, ...],
) -> list[ProblemaSemanticoDoPlano]:
proxima_palavra = self._primeira_palavra_mantida_depois(corte.fim, cortes, palavras)
if proxima_palavra is None:
return []
problemas = []
margem = proxima_palavra.inicio - corte.fim
if margem < self._margem_minima - self._TOLERANCIA_EM_SEGUNDOS:
problemas.append(ProblemaSemanticoDoPlano(
codigo="margem_depois_do_corte_insuficiente",
mensagem=(
f"ação {indice + 1}: termine o corte ao menos {self._margem_minima:.2f}s "
f'antes da palavra mantida "{proxima_palavra.texto}"'
),
indice_da_acao=indice,
instante=corte.fim,
))
fala = self._fala_da_palavra(proxima_palavra, falas)
comeca_no_meio_do_segmento = (
fala is not None
and proxima_palavra.inicio > fala.inicio + self._TOLERANCIA_EM_SEGUNDOS
)
primeira_letra = next((letra for letra in proxima_palavra.texto if letra.isalpha()), "")
comeca_em_minuscula = bool(primeira_letra and primeira_letra.islower())
if comeca_no_meio_do_segmento or comeca_em_minuscula:
problemas.append(ProblemaSemanticoDoPlano(
codigo="retomada_no_meio_da_frase",
mensagem=(
f"ação {indice + 1}: o trecho mantido retoma em "
f'"{proxima_palavra.texto}" ({proxima_palavra.inicio:.2f}s), no meio da frase'
),
indice_da_acao=indice,
instante=corte.fim,
))
return problemas
@staticmethod
def _fala_da_palavra(
palavra: PalavraDeTranscricao,
falas: tuple[SegmentoDeTranscricao, ...],
) -> SegmentoDeTranscricao | None:
return next(
(
fala
for fala in falas
if fala.inicio - 0.01 <= palavra.inicio <= fala.fim + 0.01
),
None,
)
@staticmethod
def _palavra_esta_mantida(
palavra: PalavraDeTranscricao,
cortes: tuple[tuple[int, AcaoDeEdicao], ...],
) -> bool:
centro = (palavra.inicio + palavra.fim) / 2
return not any(corte.inicio <= centro <= corte.fim for _, corte in cortes)
def _ultima_palavra_mantida_antes(
self,
instante: float,
cortes: tuple[tuple[int, AcaoDeEdicao], ...],
palavras: tuple[PalavraDeTranscricao, ...],
) -> PalavraDeTranscricao | None:
candidatas = (
palavra
for palavra in palavras
if palavra.fim <= instante + self._TOLERANCIA_EM_SEGUNDOS
and self._palavra_esta_mantida(palavra, cortes)
)
return max(candidatas, key=lambda palavra: palavra.fim, default=None)
def _primeira_palavra_mantida_depois(
self,
instante: float,
cortes: tuple[tuple[int, AcaoDeEdicao], ...],
palavras: tuple[PalavraDeTranscricao, ...],
) -> PalavraDeTranscricao | None:
candidatas = (
palavra
for palavra in palavras
if palavra.inicio >= instante - self._TOLERANCIA_EM_SEGUNDOS
and self._palavra_esta_mantida(palavra, cortes)
)
return min(candidatas, key=lambda palavra: palavra.inicio, default=None)
@@ -0,0 +1,183 @@
"""Verificação estrutural da timeline depois da aplicação de um plano.
Compara a cobertura de origem que deveria sobreviver aos cortes com a
cobertura realmente lida no Premiere depois da mutação. A verificação não
avalia pixels, áudio ou qualidade editorial; ela detecta sobras, lacunas e
desalinhamentos estruturais em cada tipo de faixa.
"""
from __future__ import annotations
from dataclasses import dataclass
from ..dominio import Clipe, Faixa, IntervaloDeTempo, Timeline
from .modelos import PlanoDeEdicao, TipoDeAcao
_TOLERANCIA_EM_SEGUNDOS = 0.05
@dataclass(frozen=True)
class ResultadoDaVerificacao:
"""Resultado da comparação entre a timeline esperada e a timeline lida."""
aprovada: bool
divergencias: tuple[str, ...] = ()
avisos: tuple[str, ...] = ()
@property
def mensagem(self) -> str:
"""Resume a verificação em uma mensagem adequada para o histórico."""
if self.aprovada:
mensagem = "verificação pós-aplicação aprovada"
else:
mensagem = "verificação pós-aplicação reprovada: " + " | ".join(self.divergencias)
if self.avisos:
mensagem += "; avisos: " + " | ".join(self.avisos)
return mensagem
class VerificadorDaAplicacao:
"""Confere se a cobertura de mídia depois da aplicação bate com o plano."""
def __init__(self, tolerancia_em_segundos: float = _TOLERANCIA_EM_SEGUNDOS) -> None:
"""Configura a tolerância de arredondamento do Premiere."""
if tolerancia_em_segundos < 0:
raise ValueError("A tolerância da verificação não pode ser negativa.")
self._tolerancia = tolerancia_em_segundos
def verificar(
self,
timeline_antes: Timeline,
timeline_depois: Timeline,
plano: PlanoDeEdicao,
) -> ResultadoDaVerificacao:
"""Compara os intervalos de origem sobreviventes antes e depois da aplicação."""
tipos_de_faixa = {faixa.tipo for faixa in timeline_antes.faixas}
divergencias: list[str] = []
avisos: list[str] = []
cortes = tuple(
acao for acao in plano.acoes if acao.tipo is TipoDeAcao.CORTE
)
for tipo_de_faixa in sorted(tipos_de_faixa):
esperados = self._intervalos_esperados(
timeline_antes, tipo_de_faixa, plano.arquivo_de_origem, cortes
)
encontrados = self._intervalos_da_timeline(
timeline_depois, tipo_de_faixa, plano.arquivo_de_origem
)
if not self._intervalos_equivalentes(esperados, encontrados):
divergencias.append(
f"faixa {tipo_de_faixa}: esperado {self._formatar_intervalos(esperados)}, "
f"encontrado {self._formatar_intervalos(encontrados)}"
)
avisos.extend(self._avisar_material_posterior_à_ultima_acao(
timeline_depois, plano, tipo_de_faixa
))
return ResultadoDaVerificacao(not divergencias, tuple(divergencias), tuple(avisos))
def _avisar_material_posterior_à_ultima_acao(
self, timeline: Timeline, plano: PlanoDeEdicao, tipo_de_faixa: str
) -> list[str]:
"""Sinaliza material depois da última ação para revisão editorial."""
if not plano.acoes:
return []
ultima_acao = max(plano.acoes, key=lambda acao: acao.fim)
if ultima_acao.tipo is not TipoDeAcao.CORTE:
return []
sobras = [
clipe.intervalo_na_origem.fim
for faixa in timeline.faixas
if faixa.tipo == tipo_de_faixa
for clipe in self._clipes_do_arquivo(faixa, plano.arquivo_de_origem)
if clipe.intervalo_na_origem.inicio >= ultima_acao.fim - self._tolerancia
and clipe.intervalo_na_origem.fim > ultima_acao.fim + self._tolerancia
]
if not sobras:
return []
return [
f"faixa {tipo_de_faixa}: há material após a última ação, "
f"a partir de {ultima_acao.fim:.2f}s; conferir possível sobra"
]
def _intervalos_esperados(
self,
timeline: Timeline,
tipo_de_faixa: str,
arquivo_de_origem: str,
cortes: tuple,
) -> tuple[tuple[float, float], ...]:
intervalos: list[tuple[float, float]] = []
for faixa in timeline.faixas:
if faixa.tipo != tipo_de_faixa:
continue
for clipe in self._clipes_do_arquivo(faixa, arquivo_de_origem):
intervalos.extend(self._subtrair_cortes(clipe.intervalo_na_origem, cortes))
return self._normalizar_intervalos(intervalos)
def _intervalos_da_timeline(
self, timeline: Timeline, tipo_de_faixa: str, arquivo_de_origem: str
) -> tuple[tuple[float, float], ...]:
intervalos = [
(clipe.intervalo_na_origem.inicio, clipe.intervalo_na_origem.fim)
for faixa in timeline.faixas
if faixa.tipo == tipo_de_faixa
for clipe in self._clipes_do_arquivo(faixa, arquivo_de_origem)
]
return self._normalizar_intervalos(intervalos)
def _subtrair_cortes(self, intervalo: IntervaloDeTempo, cortes: tuple) -> list[tuple[float, float]]:
partes = [(intervalo.inicio, intervalo.fim)]
for corte in cortes:
novas_partes: list[tuple[float, float]] = []
for inicio, fim in partes:
if corte.fim <= inicio or corte.inicio >= fim:
novas_partes.append((inicio, fim))
continue
if inicio < corte.inicio:
novas_partes.append((inicio, min(fim, corte.inicio)))
if corte.fim < fim:
novas_partes.append((max(inicio, corte.fim), fim))
partes = novas_partes
return partes
def _intervalos_equivalentes(
self, esperados: tuple[tuple[float, float], ...], encontrados: tuple[tuple[float, float], ...]
) -> bool:
if len(esperados) != len(encontrados):
return False
return all(
abs(inicio_esperado - inicio_encontrado) <= self._tolerancia
and abs(fim_esperado - fim_encontrado) <= self._tolerancia
for (inicio_esperado, fim_esperado), (inicio_encontrado, fim_encontrado)
in zip(esperados, encontrados)
)
def _clipes_do_arquivo(self, faixa: Faixa, arquivo_de_origem: str) -> list[Clipe]:
nome_alvo = arquivo_de_origem.rsplit("/", 1)[-1].lower()
return [
clipe for clipe in faixa.clipes
if clipe.intervalo_na_origem is not None
and (clipe.nome or "").rsplit("/", 1)[-1].lower() == nome_alvo
]
def _normalizar_intervalos(
self, intervalos: list[tuple[float, float]]
) -> tuple[tuple[float, float], ...]:
ordenados = sorted(intervalos)
unidos: list[list[float]] = []
for inicio, fim in ordenados:
if fim <= inicio + self._tolerancia:
continue
if unidos and inicio <= unidos[-1][1] + self._tolerancia:
unidos[-1][1] = max(unidos[-1][1], fim)
else:
unidos.append([inicio, fim])
return tuple((inicio, fim) for inicio, fim in unidos)
@staticmethod
def _formatar_intervalos(intervalos: tuple[tuple[float, float], ...]) -> str:
"""Formata intervalos para mensagens curtas e conferíveis."""
if not intervalos:
return "[]"
return "[" + ", ".join(f"{inicio:.2f}-{fim:.2f}s" for inicio, fim in intervalos) + "]"
+30 -5
View File
@@ -40,6 +40,7 @@ if CAMINHO_DA_MIDIA:
from engine.integracoes.premiere.conversores import ConversorDeTimeline
from engine.integracoes.midia import ExtracaoDeAudio
from engine.dominio import Clipe
from engine.scanner import ConfiguracaoVisualDoScanner, criar_detector_apple_vision, gerar_relatorio_visual
from engine.scanner.transcricao_da_timeline import TranscricaoDaTimeline
from engine.persistencia import RepositorioDeAnalisesSQLite, RepositorioDeTimelineSQLite
@@ -65,6 +66,24 @@ def nome_seguro(nome: str) -> str:
return re.sub(r"[^A-Za-z0-9._-]+", "-", nome.strip()).strip(".-") or "timeline"
def serializar_clipe_para_relatorio(clipe: Clipe) -> dict[str, object]:
"""Preserva no relatório os dados que identificam a mídia e sua origem.
As chaves ``sourceFile``, ``inPoint`` e ``outPoint`` mantêm o contrato
externo usado pelo painel CEP e pelo bridge do Premiere.
"""
intervalo_na_origem = clipe.intervalo_na_origem
return {
"identificador": clipe.identificador,
"nome": clipe.nome,
"inicio": clipe.intervalo_na_timeline.inicio,
"fim": clipe.intervalo_na_timeline.fim,
"sourceFile": clipe.arquivo,
"inPoint": intervalo_na_origem.inicio if intervalo_na_origem else None,
"outPoint": intervalo_na_origem.fim if intervalo_na_origem else None,
}
def executar(entrada: Path, saida: Path) -> Path:
"""Executa uma análise do Scanner conforme o pedido do painel.
@@ -139,9 +158,7 @@ def executar(entrada: Path, saida: Path) -> Path:
if analisar_som and faixa.tipo == "audio"
and faixa.indice in configuracao.faixas_de_audio]
audio = [{"indice": faixa.indice, "nome": faixa.nome,
"clipes": [{"identificador": clipe.identificador, "nome": clipe.nome,
"inicio": clipe.intervalo_na_timeline.inicio, "fim": clipe.intervalo_na_timeline.fim}
for clipe in faixa.clipes]}
"clipes": [serializar_clipe_para_relatorio(clipe) for clipe in faixa.clipes]}
for faixa in faixas_de_audio]
caso = nome_seguro(timeline.nome)
pasta = saida.parent / caso
@@ -165,6 +182,7 @@ def executar(entrada: Path, saida: Path) -> Path:
transcricao_configurada = {
**transcricao_configurada,
"metricas_de_fala": configuracao.metricas_de_fala,
"classificar_emocao": configuracao.classificar_emocao,
}
if faixas_de_audio and transcricao_configurada.get("caminho_modelo"):
try:
@@ -179,6 +197,10 @@ def executar(entrada: Path, saida: Path) -> Path:
analisador_de_metricas_de_voz = (
AnalisadorDeMetricasDeVoz() if configuracao.metricas_de_fala else None
)
analisador_de_emocao = None
if configuracao.classificar_emocao:
from engine.integracoes.huggingface import ProviderDeEmocaoHuggingFace
analisador_de_emocao = ProviderDeEmocaoHuggingFace()
diarizador = None
if transcricao_configurada.get("diarizacao"):
if not os.environ.get("HF_TOKEN"):
@@ -193,7 +215,8 @@ def executar(entrada: Path, saida: Path) -> Path:
emitir("Transcrevendo áudio", 5)
transcricoes = TranscricaoDaTimeline(provider, diretoria_de_trabalho=pasta / ".cache-audio",
diarizador=diarizador,
analisador_de_metricas_de_voz=analisador_de_metricas_de_voz).executar(
analisador_de_metricas_de_voz=analisador_de_metricas_de_voz,
analisador_de_emocao=analisador_de_emocao).executar(
Timeline(timeline.identificador, timeline.nome, faixas=faixas_de_audio))
# Persiste a transcrição (segmentos + falas) no banco de análises.
repositorio_de_analises.substituir_transcricoes(timeline.identificador, transcricoes)
@@ -201,7 +224,9 @@ def executar(entrada: Path, saida: Path) -> Path:
transcricoes_por_clipe[transcricao.identificador_do_clipe] = [
{"inicio": item.inicio, "fim": item.fim, "texto": item.texto,
"confianca": item.confianca, "falante": item.falante,
"caracteristicas_acusticas": item.caracteristicas_acusticas}
"caracteristicas_acusticas": item.caracteristicas_acusticas,
"emocao": item.emocao, "confianca_emocao": item.confianca_emocao,
"voz_aparente": item.voz_aparente, "confianca_voz": item.confianca_voz}
for item in transcricao.segmentos]
except (ImportError, OSError, RuntimeError, ValueError) as erro:
# Dependência ausente (ex.: soundfile), modelo/áudio ilegível, ou
+11 -2
View File
@@ -1,5 +1,14 @@
"""Integrações locais para análise de áudio."""
"""Integrações locais para análise de áudio e música."""
from .analisador_de_metricas_de_voz import AnalisadorDeMetricasDeVoz
from .classificador_de_musica_essentia import ClassificadorDeMusicaEssentia
from .modelos_de_analise_musical import PontuacaoDeEtiqueta, ResultadoDaAnaliseMusical
from .provider_de_analise_musical_essentia import AnalisadorDeMusicaInstrumentalEssentia
__all__ = ["AnalisadorDeMetricasDeVoz"]
__all__ = [
"AnalisadorDeMetricasDeVoz",
"ClassificadorDeMusicaEssentia",
"PontuacaoDeEtiqueta",
"AnalisadorDeMusicaInstrumentalEssentia",
"ResultadoDaAnaliseMusical",
]
@@ -0,0 +1,143 @@
"""
Classificação semântica de música por modelos oficiais do Essentia.
Este módulo carrega os pesos de gênero e humor, extrai um embedding de áudio
uma única vez e aplica os classificadores sobre esse embedding. Os arquivos
dos modelos ficam fora do repositório e são informados por configuração.
"""
import json
from pathlib import Path
from typing import Any
from .modelos_de_analise_musical import PontuacaoDeEtiqueta
class ClassificadorDeMusicaEssentia:
"""
Classifica gênero e humor diretamente do sinal de áudio.
A classe não lê tags do arquivo, não produz texto narrativo e não
persiste resultados. Ela depende dos pesos ``.pb`` e metadados ``.json``
oficiais do Essentia, recebidos por caminho.
"""
NOME_DO_MODELO_DE_EMBEDDING = "discogs-effnet-bs64-1.pb"
NOME_DO_MODELO_DE_GENERO = "genre_discogs400-discogs-effnet-1.pb"
NOME_DOS_METADADOS_DE_GENERO = "genre_discogs400-discogs-effnet-1.json"
NOME_DO_MODELO_DE_HUMOR = "mtg_jamendo_moodtheme-discogs-effnet-1.pb"
NOME_DOS_METADADOS_DE_HUMOR = "mtg_jamendo_moodtheme-discogs-effnet-1.json"
def __init__(self, diretorio_dos_modelos: str | Path) -> None:
"""Inicializa o classificador com o diretório dos pesos locais."""
self._diretorio_dos_modelos = Path(diretorio_dos_modelos).expanduser().resolve()
self._modelos_carregados: tuple[Any, Any, Any] | None = None
self._classes_por_tarefa: dict[str, tuple[str, ...]] | None = None
def classificar(
self, arquivo: str | Path,
) -> tuple[tuple[PontuacaoDeEtiqueta, ...], tuple[PontuacaoDeEtiqueta, ...], tuple[PontuacaoDeEtiqueta, ...]]:
"""
Classifica o gênero e o humor diretamente do conteúdo do arquivo.
Parâmetros:
arquivo: Caminho de áudio que será lido pelo Essentia.
Retorna:
Tupla com pontuações de gênero e de humor, em ordem decrescente.
Pode gerar:
FileNotFoundError: quando o arquivo não existir.
RuntimeError: quando Essentia ou algum modelo não estiver disponível.
"""
caminho = Path(arquivo).expanduser().resolve(strict=False)
if not caminho.is_file():
raise FileNotFoundError(f"Arquivo de áudio não encontrado: {caminho}")
try:
from essentia.standard import (
MonoLoader,
TensorflowPredict2D,
TensorflowPredictEffnetDiscogs,
)
except ImportError as erro:
raise RuntimeError(
"A classificação musical precisa do suporte TensorFlow do Essentia."
) from erro
modelo_de_embedding, modelo_de_genero, modelo_de_humor = self._obter_modelos(
TensorflowPredictEffnetDiscogs, TensorflowPredict2D,
)
audio = MonoLoader(filename=str(caminho), sampleRate=16000, resampleQuality=4)()
embedding = modelo_de_embedding(audio)
previsao_de_genero = modelo_de_genero(embedding)
previsao_de_humor = modelo_de_humor(embedding)
pontuacoes_de_humor = self._converter_pontuacoes(previsao_de_humor, "humor")
nomes_de_tema = {"action", "adventure", "advertising", "background", "children", "christmas", "commercial", "corporate", "documentary", "drama", "film", "game", "movie", "nature", "sport", "trailer", "travel"}
return (
self._duas_maiores(self._converter_pontuacoes(previsao_de_genero, "genero")),
self._duas_maiores(tuple(item for item in pontuacoes_de_humor if item.etiqueta not in nomes_de_tema)),
self._duas_maiores(tuple(item for item in pontuacoes_de_humor if item.etiqueta in nomes_de_tema)),
)
@staticmethod
def _duas_maiores(pontuacoes: tuple[PontuacaoDeEtiqueta, ...]) -> tuple[PontuacaoDeEtiqueta, ...]:
"""Retorna as duas maiores probabilidades, preservando o ranking."""
return pontuacoes[:2]
def _obter_modelos(self, classe_de_embedding: Any, classe_de_classificacao: Any):
if self._modelos_carregados is not None:
return self._modelos_carregados
caminhos = {
"embedding": self._diretorio_dos_modelos / self.NOME_DO_MODELO_DE_EMBEDDING,
"genero": self._diretorio_dos_modelos / self.NOME_DO_MODELO_DE_GENERO,
"humor": self._diretorio_dos_modelos / self.NOME_DO_MODELO_DE_HUMOR,
}
ausentes = [str(caminho) for caminho in caminhos.values() if not caminho.is_file()]
if ausentes:
raise RuntimeError("Modelos Essentia ausentes: " + ", ".join(ausentes))
self._modelos_carregados = (
classe_de_embedding(
graphFilename=str(caminhos["embedding"]), output="PartitionedCall:1",
),
classe_de_classificacao(
graphFilename=str(caminhos["genero"]),
input="serving_default_model_Placeholder", output="PartitionedCall:0",
),
classe_de_classificacao(graphFilename=str(caminhos["humor"])),
)
self._classes_por_tarefa = {
"genero": self._carregar_classes(self.NOME_DOS_METADADOS_DE_GENERO),
"humor": self._carregar_classes(self.NOME_DOS_METADADOS_DE_HUMOR),
}
return self._modelos_carregados
def _carregar_classes(self, nome_do_arquivo: str) -> tuple[str, ...]:
caminho = self._diretorio_dos_modelos / nome_do_arquivo
if not caminho.is_file():
raise RuntimeError(f"Metadados Essentia ausentes: {caminho}")
try:
dados = json.loads(caminho.read_text(encoding="utf-8"))
classes = dados["classes"]
except (OSError, json.JSONDecodeError, KeyError, TypeError) as erro:
raise RuntimeError(f"Metadados Essentia inválidos: {caminho}") from erro
if not isinstance(classes, list) or not all(isinstance(item, str) for item in classes):
raise RuntimeError(f"Lista de classes Essentia inválida: {caminho}")
return tuple(classes)
def _converter_pontuacoes(self, previsoes: Any, tarefa: str) -> tuple[PontuacaoDeEtiqueta, ...]:
import numpy as np
if self._classes_por_tarefa is None:
raise RuntimeError("As classes dos modelos ainda não foram carregadas.")
valores = np.asarray(previsoes).mean(axis=0).tolist()
classes = self._classes_por_tarefa[tarefa]
if len(valores) != len(classes):
raise RuntimeError(
f"Quantidade de previsões incompatível para {tarefa}: "
f"{len(valores)} valores e {len(classes)} classes."
)
pontuacoes = (
PontuacaoDeEtiqueta(etiqueta=etiqueta, confianca=float(valor))
for etiqueta, valor in zip(classes, valores)
)
return tuple(sorted(pontuacoes, key=lambda item: item.confianca, reverse=True))
@@ -0,0 +1,32 @@
"""
Contratos para provedores de análise musical.
O contrato permite que a aplicação use Essentia, um dublê de teste ou outro
provedor no futuro sem conhecer detalhes de carregamento e classificação.
"""
from pathlib import Path
from typing import Protocol
from .modelos_de_analise_musical import ResultadoDaAnaliseMusical
class AnalisadorDeMusica(Protocol):
"""Define a operação necessária para analisar um arquivo musical."""
def analisar(self, arquivo: str | Path) -> ResultadoDaAnaliseMusical:
"""
Analisa um arquivo de música e devolve seus descritores internos.
Parâmetros:
arquivo: Caminho de um arquivo de áudio existente.
Retorna:
Resultado estruturado da análise musical.
Pode gerar:
FileNotFoundError: quando o arquivo não existir.
RuntimeError: quando Essentia não estiver instalado.
"""
...
@@ -0,0 +1,47 @@
"""
Modelos internos para representar análises de músicas instrumentais.
Este módulo define somente os dados produzidos pela análise. Ele não importa
Essentia, não lê arquivos e não transforma o resultado em texto para a
interface.
"""
from dataclasses import dataclass, field
from pathlib import Path
@dataclass(frozen=True)
class PontuacaoDeEtiqueta:
"""Representa uma etiqueta musical e a confiança calculada para ela."""
etiqueta: str
confianca: float
@dataclass(frozen=True)
class ResultadoDaAnaliseMusical:
"""
Representa os descritores calculados para uma música instrumental.
A classe mantém valores objetivos, como BPM e tonalidade, além das
classificações fornecidas pelos modelos disponíveis. Ela não afirma uma
interpretação definitiva da intenção artística da música.
"""
caminho_do_arquivo: Path
duracao_em_segundos: float | None = None
batidas_por_minuto: float | None = None
tonalidade: str | None = None
modo: str | None = None
intensidade: float | None = None
dancabilidade: float | None = None
generos: tuple[str, ...] = field(default_factory=tuple)
humores: tuple[str, ...] = field(default_factory=tuple)
instrumentos: tuple[str, ...] = field(default_factory=tuple)
etiquetas: tuple[str, ...] = field(default_factory=tuple)
pontuacoes_de_genero: tuple[PontuacaoDeEtiqueta, ...] = field(default_factory=tuple)
pontuacoes_de_humor: tuple[PontuacaoDeEtiqueta, ...] = field(default_factory=tuple)
pontuacoes_de_tema: tuple[PontuacaoDeEtiqueta, ...] = field(default_factory=tuple)
temas: tuple[str, ...] = field(default_factory=tuple)
provedor: str = "Essentia"
modelo: str | None = None
@@ -0,0 +1,175 @@
"""
Adaptador local para análise musical usando Essentia.
O adaptador concentra a dependência externa e converte seus descritores para
``ResultadoDaAnaliseMusical``. Ele não persiste resultados nem conhece a
interface do painel. As classificações de gênero, humor e instrumentos são
lidas quando os modelos de alto nível instalados retornarem essas categorias.
"""
from pathlib import Path
from typing import Any, Callable
from .modelos_de_analise_musical import ResultadoDaAnaliseMusical
class AnalisadorDeMusicaInstrumentalEssentia:
"""
Analisa arquivos de áudio localmente por meio do ``MusicExtractor``.
A criação do extrator é tardia para que a engine continue importável sem
Essentia instalada. Um extrator compatível pode ser injetado nos testes,
evitando dependência de modelos pesados durante o teste unitário.
"""
def __init__(
self,
extrator: Callable[[str], tuple[Any, Any]] | None = None,
nome_do_modelo: str | None = None,
classificador: Any | None = None,
) -> None:
"""Inicializa o adaptador sem carregar a dependência externa."""
self._extrator = extrator
self._nome_do_modelo = nome_do_modelo
self._classificador = classificador
def analisar(self, arquivo: str | Path) -> ResultadoDaAnaliseMusical:
"""
Analisa um arquivo de música instrumental.
Parâmetros:
arquivo: Caminho de áudio que será analisado.
Retorna:
Descritores objetivos e classificações disponíveis.
Pode gerar:
FileNotFoundError: quando o caminho não apontar para um arquivo.
RuntimeError: quando Essentia não estiver instalada.
ValueError: quando o resultado externo não possuir formato válido.
"""
caminho = Path(arquivo).expanduser().resolve(strict=False)
if not caminho.is_file():
raise FileNotFoundError(f"Arquivo de áudio não encontrado: {caminho}")
dados, _ = self._obter_extrator()(str(caminho))
if dados is None:
raise ValueError("O Essentia devolveu uma análise vazia.")
pontuacoes_de_genero: tuple[Any, ...] = ()
pontuacoes_de_humor: tuple[Any, ...] = ()
pontuacoes_de_tema: tuple[Any, ...] = ()
if self._classificador is not None:
classificacoes = self._classificador.classificar(caminho)
pontuacoes_de_genero, pontuacoes_de_humor = classificacoes[:2]
if len(classificacoes) > 2:
pontuacoes_de_tema = classificacoes[2]
generos = (
tuple(item.etiqueta for item in pontuacoes_de_genero)
if pontuacoes_de_genero
else self._etiquetas(dados, ("genre", "genres"))
)
humores = (
tuple(item.etiqueta for item in pontuacoes_de_humor)
if pontuacoes_de_humor
else self._etiquetas(dados, ("mood", "moods"))
)
return self._converter_resultado(
caminho, dados, pontuacoes_de_genero, pontuacoes_de_humor, generos, humores, pontuacoes_de_tema,
)
def _obter_extrator(self) -> Callable[[str], tuple[Any, Any]]:
if self._extrator is not None:
return self._extrator
try:
from essentia.standard import MusicExtractor
except ImportError as erro:
raise RuntimeError(
"A análise musical local precisa da biblioteca Essentia. "
"Instale as dependências de requirements-audio.txt."
) from erro
self._extrator = MusicExtractor()
return self._extrator
def _converter_resultado(
self,
caminho: Path,
dados: Any,
pontuacoes_de_genero: tuple[Any, ...] = (),
pontuacoes_de_humor: tuple[Any, ...] = (),
generos: tuple[str, ...] = (),
humores: tuple[str, ...] = (),
pontuacoes_de_tema: tuple[Any, ...] = (),
) -> ResultadoDaAnaliseMusical:
return ResultadoDaAnaliseMusical(
caminho_do_arquivo=caminho,
duracao_em_segundos=self._numero(self._buscar(dados, "metadata.audio_properties.length")),
batidas_por_minuto=self._numero(self._buscar(dados, "rhythm.bpm")),
tonalidade=self._texto(self._buscar(dados, "tonal.key_edma.key")),
modo=self._texto(self._buscar(dados, "tonal.key_edma.scale")),
intensidade=self._numero(self._buscar(dados, "lowlevel.average_loudness")),
dancabilidade=self._numero(self._buscar(dados, "rhythm.danceability")),
generos=generos,
humores=humores,
temas=tuple(item.etiqueta for item in pontuacoes_de_tema),
instrumentos=self._etiquetas(dados, ("instrument", "instruments")),
etiquetas=self._etiquetas(dados, ("tags", "semantic")),
pontuacoes_de_genero=pontuacoes_de_genero,
pontuacoes_de_humor=pontuacoes_de_humor,
pontuacoes_de_tema=pontuacoes_de_tema,
modelo=self._nome_do_modelo,
)
@staticmethod
def _buscar(dados: Any, caminho: str) -> Any:
"""Busca uma chave pontuada em um resultado Essentia ou devolve None.
O ``Pool`` do Essentia devolve descritores com a chave completa, como
``rhythm.bpm``. Dublês de teste e algumas integrações podem devolvê-los
como dicionários aninhados; os dois formatos são aceitos aqui.
"""
try:
return dados[caminho]
except (KeyError, IndexError, TypeError):
pass
valor = dados
for parte in caminho.split("."):
try:
valor = valor[parte]
except (KeyError, IndexError, TypeError):
return None
return valor
@classmethod
def _etiquetas(cls, dados: Any, nomes: tuple[str, ...]) -> tuple[str, ...]:
"""Converte classificações externas em uma tupla ordenada de textos."""
encontrados: list[str] = []
for nome in nomes:
valor = cls._buscar(dados, f"highlevel.{nome}.value")
if isinstance(valor, dict):
encontrados.extend(
str(chave) for chave, pontuacao in valor.items()
if cls._numero(pontuacao) is not None and float(pontuacao) > 0
)
elif isinstance(valor, (list, tuple)):
encontrados.extend(str(item) for item in valor)
elif isinstance(valor, str):
encontrados.append(valor)
return tuple(dict.fromkeys(encontrados))
@staticmethod
def _numero(valor: Any) -> float | None:
"""Converte valores numéricos externos sem aceitar booleanos."""
if isinstance(valor, bool) or valor is None:
return None
try:
return float(valor)
except (TypeError, ValueError):
return None
@staticmethod
def _texto(valor: Any) -> str | None:
"""Converte valores textuais externos, ignorando vazios."""
if valor is None or isinstance(valor, (dict, list, tuple)):
return None
texto = str(valor).strip()
return texto or None
@@ -63,6 +63,7 @@ class ClienteMCPPorStdio(ClienteMCP):
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
text=True,
encoding="utf-8",
bufsize=1,
)
self._enviar("initialize", {"protocolVersion": "2025-06-18", "capabilities": {}, "clientInfo": {"name": "engine", "version": "0.1.0"}})
@@ -99,6 +100,12 @@ class ClienteMCPPorStdio(ClienteMCP):
raise ErroDeFerramentaMCP(
f"A ferramenta {nome_da_ferramenta!r} falhou: {self._mensagem_de_erro(resultado)}"
)
# O servidor MCP (SDK 2.0+) envolve o dado da ferramenta em
# ``structuredContent.data``. Desembrulha para que o chamador receba
# o objeto de resultado direto (ex.: ``{"totalTracks": 2, ...}``).
sc = resultado.get("structuredContent")
if isinstance(sc, dict) and "data" in sc:
return sc["data"]
return resultado
@staticmethod
@@ -560,7 +560,64 @@ func descreverEvidencia(_ evidencia: Evidencia) -> String {
}
}
/// Classificação de ambiente extraída das evidências visuais.
/// Usado pelo Foundation Models para categorizar o cenário do frame.
struct ClassificacaoAmbiente: Encodable {
let ambiente: String
let confianca: Double
let evidencias_chave: [String]
}
/// Taxonomia de ambientes para classificação editorial.
/// Cada ambiente tem descrição de características visuais que o Foundation Models deve procurar.
enum TaxonomiaAmbientes {
static let instrucoes = """
Você identifica o ambiente de cenas de vídeo. Analise as evidências visuais fornecidas \
e classifique o ambiente segundo a taxonomia abaixo. Responda APENAS com JSON válido.
TAXONOMIA DE AMBIENTES:
- centro_cirurgico: pessoas com touca/avental/máscara/camisinha cirúrgica, cores branco/azul cirúrgico, \
mesa cirúrgica, luzes de sala de operação, monitores cardíacos, instrumentos cirúrgicos, campo estéril verde/azul.
- consultorio: mobiliário médico simples, mesa de exame, quadro na parede, equipamento clínico de escritório.
- hospital_ambiente: corredor hospitalar, leito, roupa de paciente, enfermeiros, soro, monitor.
- laboratorio: microscópio, tubos de ensaio, centroides, bancada com reagentes, jaleco branco.
- escritorio: mesa de trabalho, computador, cadeira ergonômica, estantes, iluminação artificial.
- sala_reuniao: mesa comprida, cadeiras ao redor, projetor/tela, quadro branco.
- externo_urbano: rua, edifícios, trânsito, calçada, céu aberto.
- externo_natural: árvores, gramado, água, montanha, praia, campo.
- studio: fundo neutro/escuro, iluminação profissional, equipamento de gravação.
- industria: maquinário, linha de produção, capacete, colete, ambiente fabril.
- domesticos: sala, cozinha, quarto, mobiliário residencial, iluminação caseira.
- academia: equipamentos de musculação, esteira, peso, pessoa se exercitando.
- outro: qualquer ambiente que não se encaixe nas categorias acima.
REGRAS:
1. Analise as evidências visuais (pessoas, roupas, objetos, texto OCR, categorias Vision).
2. Identifique os 2-3 indícios mais fortes para a classificação.
3. Atribua confiança de 0.0 a 1.0 baseada na força dos indícios.
4. Se houver texto OCR relevante (placas, placas de identificação), use como evidência forte.
5. Não invente informações não presentes nas evidências.
"""
static let promptClassificar = """
Classifique o ambiente deste frame de vídeo com base nas evidências visuais fornecidas.
Responda APENAS com JSON no formato: {"ambiente": "categoria", "confianca": 0.0-1.0, "evidencias_chave": ["evidência 1", "evidência 2"]}
"""
}
func interpretar(_ saida: Saida) async -> String? {
guard SystemLanguageModel.default.isAvailable else { return nil }
let fatos = saida.evidencias.map(descreverEvidencia).joined(separator: "; ")
guard !fatos.isEmpty else { return nil }
do {
let sessao = LanguageModelSession(instructions: TaxonomiaAmbientes.instrucoes)
let resposta = try await sessao.respond(to: "\(TaxonomiaAmbientes.promptClassificar)\n\nEvidências visuais: \(fatos)")
return resposta.content
} catch { return nil }
}
/// Interpretação editorial legível (frase curta) — mantida para compatibilidade.
func interpretarFrase(_ saida: Saida) async -> String? {
guard SystemLanguageModel.default.isAvailable else { return nil }
let fatos = saida.evidencias.map(descreverEvidencia).joined(separator: "; ")
guard !fatos.isEmpty else { return nil }
+42
View File
@@ -0,0 +1,42 @@
"""Entrada de linha de comando para listar planos de edição salvos no SQLite."""
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.conexao import abrir_banco
from engine.persistencia.repositorio_de_planos import RepositorioDePlanos
def listar(caminho_do_banco: Path, limite: int = 50) -> list[dict[str, object]]:
"""Lê os planos mais recentes do banco, de qualquer vídeo."""
conexao = abrir_banco(caminho_do_banco)
try:
return RepositorioDePlanos(conexao).listar_todos_os_planos(limite)
finally:
conexao.close()
def principal() -> None:
"""Executa a listagem pela linha de comando."""
argumentos = argparse.ArgumentParser()
argumentos.add_argument("--banco", type=Path, default=Path(".jhonny/analises.db"))
argumentos.add_argument("--limite", type=int, default=50)
opcoes = argumentos.parse_args()
try:
planos = listar(opcoes.banco, opcoes.limite)
print(json.dumps({"ok": True, "planos": planos}, ensure_ascii=False))
except (OSError, ValueError) as erro:
print(json.dumps({"ok": False, "erro": str(erro)}, ensure_ascii=False))
raise SystemExit(1) from erro
if __name__ == "__main__":
principal()
+14
View File
@@ -18,6 +18,7 @@ from .ingestao_de_voz import (
LeitorDeDadosParaIA,
)
from .limpeza import LimpadorDeDuplicatas
from .leitura_semantica_do_plano import LeitorDeTranscricaoDoPlano
from .repositorio_de_planos import (
AcaoDoPlano,
ErroDePlano,
@@ -25,9 +26,12 @@ from .repositorio_de_planos import (
RepositorioDePlanos,
plano_de_dict,
)
from .repositorio_de_projetos import ErroDeProjeto, ProjetoDeEdicao, RepositorioDeProjetos
from .repositorio_de_edicoes import EdicaoDeVideo, ErroDeEdicao, RepositorioDeEdicoes, edicao_de_dict
from .repositorio_de_analises_sqlite import RepositorioDeAnalisesSQLite
from .repositorio_de_retakes_sqlite import RepositorioDeRetakesSQLite
from .repositorio_de_timeline_sqlite import RepositorioDeTimelineSQLite
from .repositorio_de_trilhas import RepositorioDeTrilhasSQLite, TrilhaMusical
__all__ = [
"AcaoDoPlano",
@@ -37,21 +41,31 @@ __all__ = [
"ErroDeBusca",
"ErroDeIngestaoDeVoz",
"ErroDePlano",
"ErroDeEdicao",
"EdicaoDeVideo",
"IndexadorSemantico",
"IngestorDeVozNoBanco",
"LeitorDeDadosParaIA",
"LeitorDeTranscricaoDoPlano",
"LimpadorDeDuplicatas",
"PlanoDeEdicao",
"RepositorioDeEnunciados",
"RepositorioDePlanos",
"RepositorioDeProjetos",
"ProjetoDeEdicao",
"ErroDeProjeto",
"RepositorioDeEdicoes",
"ResultadoDeBusca",
"abrir_banco",
"criar_esquema",
"plano_de_dict",
"edicao_de_dict",
"reconstruir_indice_de_busca",
"RepositorioDeAnalisesSQLite",
"RepositorioDeRetakesSQLite",
"RepositorioDeTimelineSQLite",
"RepositorioDeTrilhasSQLite",
"TrilhaMusical",
"ConsultasDeAnalises",
"ErroDeConsultaInvalida",
]
+179
View File
@@ -135,6 +135,185 @@ class ConsultasDeAnalises:
},
}
def consultar_contexto_da_edicao(
self,
video_id: str | None = None,
incluir_palavras: bool = True,
) -> dict | None:
"""
Carrega a edição concluída mais recente e seu contexto editorial.
Quando ``video_id`` não é informado, seleciona a edição concluída mais
recente do banco. O retorno reúne a configuração editorial, o vídeo,
os clipes, as falas e o histórico resumido de planos, para que um
agente possa iniciar uma edição sem escrever SQL nem procurar arquivos
auxiliares.
Parâmetros:
video_id: Restringe a busca a um vídeo quando informado.
incluir_palavras: Inclui palavras da transcrição para decisões
que dependem de limites precisos.
Retorna:
Contexto completo da edição mais recente, ou None quando não há
edição concluída compatível.
Pode gerar:
ErroDeConsultaInvalida: quando ``video_id`` for vazio ou a
configuração persistida não for um JSON válido.
"""
if video_id is not None:
self._validar_texto_obrigatorio("video_id", video_id)
condicao = "WHERE video_id = ?" if video_id is not None else ""
parametros = (video_id,) if video_id is not None else ()
edicao = self.conexao.execute(
f"""SELECT id, video_id, sequencia, origem, tipo_de_video,
configuracao, concluida_em
FROM edicoes_de_video
{condicao}
ORDER BY concluida_em DESC, id DESC
LIMIT 1""",
parametros,
).fetchone()
if edicao is None:
return None
try:
configuracao = json.loads(edicao["configuracao"])
except (TypeError, json.JSONDecodeError) as erro:
raise ErroDeConsultaInvalida(
f"A configuração da edição {edicao['id']} não é um JSON válido."
) from erro
if not isinstance(configuracao, dict):
raise ErroDeConsultaInvalida(
f"A configuração da edição {edicao['id']} precisa ser um objeto."
)
identificador_do_video = edicao["video_id"]
clipes = [
dict(linha) for linha in self.conexao.execute(
"""SELECT id, faixa_id, nome, inicio_na_timeline, fim_na_timeline,
inicio_na_origem, fim_na_origem, arquivo, offline
FROM clipes
WHERE video_id = ?
ORDER BY inicio_na_timeline, id""",
(identificador_do_video,),
).fetchall()
]
planos = [
dict(linha) for linha in self.conexao.execute(
"""SELECT p.id, p.origem, p.tipo_de_video, p.modelo_da_ia,
p.intencao, p.criado_em,
COUNT(a.id) AS total_de_acoes,
(SELECT COUNT(*) FROM aplicacoes_do_plano ap
WHERE ap.plano_id = p.id AND ap.sucesso = 1)
AS aplicacoes_com_sucesso
FROM planos_de_edicao p
LEFT JOIN acoes_do_plano a ON a.plano_id = p.id
WHERE p.video_id = ?
GROUP BY p.id
ORDER BY p.criado_em DESC, p.id DESC""",
(identificador_do_video,),
).fetchall()
]
return {
"edicao": {
"id": edicao["id"],
"video_id": identificador_do_video,
"sequencia": edicao["sequencia"],
"origem": edicao["origem"],
"tipo_de_video": edicao["tipo_de_video"],
"configuracao": configuracao,
"concluida_em": edicao["concluida_em"],
},
"video": self.consultar_resumo_do_video(identificador_do_video),
"analise": self.consultar_status_da_analise(identificador_do_video),
"clipes": clipes,
"falas": self.listar_falas_no_intervalo(
identificador_do_video, incluir_palavras=incluir_palavras,
),
"planos": planos,
}
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,
+94
View File
@@ -61,6 +61,57 @@ CREATE TABLE IF NOT EXISTS arquivos (
UNIQUE (video_id, caminho)
);
-- Catálogo independente de músicas para uso como trilha de fundo.
CREATE TABLE IF NOT EXISTS pastas_de_trilhas (
id INTEGER PRIMARY KEY AUTOINCREMENT,
caminho TEXT NOT NULL UNIQUE,
criada_em TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')),
atualizada_em TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now'))
);
CREATE TABLE IF NOT EXISTS trilhas_musicais (
id INTEGER PRIMARY KEY AUTOINCREMENT,
pasta_id INTEGER NOT NULL REFERENCES pastas_de_trilhas(id) ON DELETE CASCADE,
caminho TEXT NOT NULL UNIQUE,
nome_do_arquivo TEXT NOT NULL,
hash_do_conteudo TEXT,
tamanho_em_bytes INTEGER,
status TEXT NOT NULL DEFAULT 'aguardando',
erro TEXT,
criada_em TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')),
atualizada_em TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now'))
);
CREATE INDEX IF NOT EXISTS idx_trilhas_musicais_pasta ON trilhas_musicais(pasta_id);
CREATE TABLE IF NOT EXISTS analises_musicais (
id INTEGER PRIMARY KEY AUTOINCREMENT,
trilha_id INTEGER NOT NULL REFERENCES trilhas_musicais(id) ON DELETE CASCADE,
provedor TEXT NOT NULL,
modelo TEXT,
duracao_em_segundos REAL,
batidas_por_minuto REAL,
tonalidade TEXT,
modo TEXT,
intensidade REAL,
dancabilidade REAL,
temas_json TEXT,
instrumentos_json TEXT,
etiquetas_json TEXT,
resultado_bruto_json TEXT,
criada_em TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now'))
);
CREATE INDEX IF NOT EXISTS idx_analises_musicais_trilha ON analises_musicais(trilha_id);
CREATE TABLE IF NOT EXISTS etiquetas_musicais (
id INTEGER PRIMARY KEY AUTOINCREMENT,
analise_id INTEGER NOT NULL REFERENCES analises_musicais(id) ON DELETE CASCADE,
tipo TEXT NOT NULL,
etiqueta TEXT NOT NULL,
confianca REAL NOT NULL,
ordem INTEGER NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_etiquetas_musicais_analise ON etiquetas_musicais(analise_id);
CREATE TABLE IF NOT EXISTS analises_versao (
video_id TEXT NOT NULL REFERENCES videos(id) ON DELETE CASCADE,
etapa TEXT NOT NULL,
@@ -285,6 +336,46 @@ CREATE TABLE IF NOT EXISTS aplicacoes_do_plano (
);
CREATE INDEX IF NOT EXISTS idx_aplicacoes_plano ON aplicacoes_do_plano(plano_id);
-- Um projeto representa exatamente uma sequência, mas pode selecionar vários
-- arquivos, faixas e clipes pertencentes a ela.
CREATE TABLE IF NOT EXISTS projetos_de_edicao (
id INTEGER PRIMARY KEY AUTOINCREMENT,
video_id TEXT NOT NULL REFERENCES videos(id) ON DELETE CASCADE,
nome TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'aberto',
criado_em TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')),
atualizado_em TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now'))
);
CREATE INDEX IF NOT EXISTS idx_projetos_video ON projetos_de_edicao(video_id, atualizado_em);
CREATE TABLE IF NOT EXISTS faixas_do_projeto (
projeto_id INTEGER NOT NULL REFERENCES projetos_de_edicao(id) ON DELETE CASCADE,
faixa_id TEXT NOT NULL,
selecionada INTEGER NOT NULL DEFAULT 1,
PRIMARY KEY (projeto_id, faixa_id)
);
CREATE TABLE IF NOT EXISTS clipes_do_projeto (
projeto_id INTEGER NOT NULL REFERENCES projetos_de_edicao(id) ON DELETE CASCADE,
clipe_id TEXT NOT NULL,
selecionado INTEGER NOT NULL DEFAULT 1,
PRIMARY KEY (projeto_id, clipe_id)
);
-- Configuração editorial escolhida no painel, antes de existir um plano da IA.
-- A análise continua normalizada nas tabelas do Scanner; esta tabela guarda
-- somente a decisão do usuário e suas opções para a edição.
CREATE TABLE IF NOT EXISTS edicoes_de_video (
id INTEGER PRIMARY KEY AUTOINCREMENT,
video_id TEXT NOT NULL REFERENCES videos(id) ON DELETE CASCADE,
sequencia TEXT NOT NULL,
origem TEXT,
tipo_de_video TEXT NOT NULL,
configuracao TEXT NOT NULL,
concluida_em TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now'))
);
CREATE INDEX IF NOT EXISTS idx_edicoes_video ON edicoes_de_video(video_id, concluida_em);
-- ---------------------------------------------------------------------------
-- Leituras achatadas
-- ---------------------------------------------------------------------------
@@ -424,6 +515,9 @@ _VIEWS = (
)
_COLUNAS_ACRESCENTADAS: tuple[tuple[str, str, str], ...] = (
("analises_musicais", "temas_json", "TEXT"),
("analises_musicais", "instrumentos_json", "TEXT"),
("analises_musicais", "etiquetas_json", "TEXT"),
("segmentos_de_transcricao", "energia_rms", "REAL"),
("segmentos_de_transcricao", "pitch_mediano_hz", "REAL"),
("segmentos_de_transcricao", "pitch_desvio_hz", "REAL"),
@@ -0,0 +1,85 @@
"""Leitura da transcrição vinculada a um plano para validação prévia."""
from __future__ import annotations
import sqlite3
from engine.editor.erros import ErroDePlanoInvalido
from engine.scanner.modelos import PalavraDeTranscricao, SegmentoDeTranscricao
class LeitorDeTranscricaoDoPlano:
"""Reconstrói tempos de origem das falas associadas a um plano salvo."""
def __init__(self, conexao: sqlite3.Connection) -> None:
"""Recebe a conexão de leitura do banco de análises."""
self._conexao = conexao
def carregar(self, plano_id: int) -> tuple[SegmentoDeTranscricao, ...]:
"""Carrega falas e palavras do plano, convertidas para a mídia original."""
plano = self._conexao.execute(
"SELECT video_id FROM planos_de_edicao WHERE id = ?",
(plano_id,),
).fetchone()
if plano is None:
raise ErroDePlanoInvalido(f"O plano {plano_id} não existe no banco de análises.")
video_id = plano["video_id"]
if not video_id:
raise ErroDePlanoInvalido(
f"O plano {plano_id} não está associado a um vídeo analisado."
)
linhas = self._conexao.execute(
"""SELECT s.id, s.inicio, s.fim, s.texto, s.confianca, s.falante,
c.inicio_na_timeline, c.inicio_na_origem
FROM segmentos_de_transcricao AS s
LEFT JOIN clipes AS c
ON c.video_id = s.video_id AND c.id = s.clipe_id
WHERE s.video_id = ?
ORDER BY s.inicio, s.id""",
(video_id,),
).fetchall()
if not linhas:
raise ErroDePlanoInvalido(
"Não há transcrição para validar as frases deste plano. "
"Rode o Scanner novamente."
)
falas = tuple(self._converter_fala(linha) for linha in linhas)
if not any(fala.palavras for fala in falas):
raise ErroDePlanoInvalido(
"A transcrição não possui tempos por palavra. "
"Rode o Scanner novamente antes de aplicar."
)
return falas
def _converter_fala(self, linha: sqlite3.Row) -> SegmentoDeTranscricao:
deslocamento = self._calcular_deslocamento(linha)
palavras = tuple(
PalavraDeTranscricao(
texto=palavra["texto"],
inicio=max(0.0, float(palavra["inicio"]) + deslocamento),
fim=max(0.0, float(palavra["fim"]) + deslocamento),
confianca=palavra["confianca"],
falante=palavra["falante"],
)
for palavra in self._conexao.execute(
"""SELECT texto, inicio, fim, confianca, falante
FROM palavras_de_transcricao
WHERE segmento_id = ?
ORDER BY ordem""",
(linha["id"],),
).fetchall()
)
return SegmentoDeTranscricao(
inicio=max(0.0, float(linha["inicio"]) + deslocamento),
fim=max(0.0, float(linha["fim"]) + deslocamento),
texto=linha["texto"],
confianca=linha["confianca"],
palavras=palavras,
falante=linha["falante"],
)
@staticmethod
def _calcular_deslocamento(linha: sqlite3.Row) -> float:
if linha["inicio_na_origem"] is None or linha["inicio_na_timeline"] is None:
return 0.0
return float(linha["inicio_na_origem"]) - float(linha["inicio_na_timeline"])
@@ -0,0 +1,83 @@
"""Persistência da configuração editorial escolhida no painel CEP.
Este módulo registra a conclusão da seleção de um tipo de vídeo e suas opções.
Ele não executa cortes, não gera o plano da IA e não duplica a análise do
Scanner; apenas cria o vínculo persistido entre a análise e a edição desejada.
"""
from __future__ import annotations
import json
import sqlite3
from dataclasses import dataclass
class ErroDeEdicao(ValueError):
"""Configuração editorial inválida para persistência."""
@dataclass(frozen=True)
class EdicaoDeVideo:
"""Representa a configuração editorial concluída para um vídeo."""
video_id: str
sequencia: str
tipo_de_video: str
configuracao: dict[str, object]
origem: str | None = None
class RepositorioDeEdicoes:
"""Grava configurações editoriais sem alterar os dados da análise."""
def __init__(self, conexao: sqlite3.Connection) -> None:
"""Inicializa o repositório com uma conexão SQLite preparada."""
self.conexao = conexao
def registrar(self, edicao: EdicaoDeVideo) -> int:
"""Registra uma edição concluída e devolve seu identificador."""
if not edicao.video_id.strip():
raise ErroDeEdicao("A edição precisa indicar o vídeo da análise.")
if not edicao.sequencia.strip():
raise ErroDeEdicao("A edição precisa indicar a sequência.")
if not edicao.tipo_de_video.strip():
raise ErroDeEdicao("A edição precisa indicar o tipo de vídeo.")
if not edicao.configuracao:
raise ErroDeEdicao("A edição precisa ter uma configuração.")
with self.conexao:
self.conexao.execute(
"INSERT OR IGNORE INTO videos (id, nome) VALUES (?, ?)",
(edicao.video_id, edicao.sequencia),
)
cursor = self.conexao.execute(
"""INSERT INTO edicoes_de_video
(video_id, sequencia, origem, tipo_de_video, configuracao)
VALUES (?, ?, ?, ?, ?)""",
(
edicao.video_id,
edicao.sequencia,
edicao.origem,
edicao.tipo_de_video,
json.dumps(edicao.configuracao, ensure_ascii=False),
),
)
return int(cursor.lastrowid)
def edicao_de_dict(dados: dict[str, object]) -> EdicaoDeVideo:
"""Converte o pedido externo do painel em uma entidade validável."""
try:
video_id = str(dados["video_id"])
sequencia = str(dados["sequencia"])
tipo_de_video = str(dados["tipo_de_video"])
configuracao = dados["configuracao"]
except KeyError as erro:
raise ErroDeEdicao(f"Campo obrigatório ausente: {erro.args[0]}.") from erro
if not isinstance(configuracao, dict):
raise ErroDeEdicao("O campo configuracao precisa ser um objeto.")
origem = dados.get("origem")
return EdicaoDeVideo(video_id, sequencia, tipo_de_video, configuracao, str(origem) if origem else None)
__all__ = ["EdicaoDeVideo", "ErroDeEdicao", "RepositorioDeEdicoes", "edicao_de_dict"]
@@ -222,6 +222,36 @@ class RepositorioDePlanos:
)
]
def listar_todos_os_planos(self, limite: int = 50) -> list[dict[str, object]]:
"""
Lista os planos mais recentes de qualquer vídeo, para escolha manual por id.
Existe para o caso em que o plano foi registrado sem ``video_id`` (a
mídia não tinha edição correspondente em ``edicoes_de_video`` no
momento do registro) — ``listar_planos_do_video`` não o encontraria,
já que filtra por vídeo conhecido.
Parâmetros:
limite: Quantos planos devolver, do mais recente ao mais antigo.
Retorna:
Um resumo por plano — id, data de criação, origem, intenção,
tipo de vídeo e contagem de ações — para montar uma lista de
seleção sem carregar o plano inteiro.
"""
return [
dict(linha) for linha in self.conexao.execute(
"""SELECT p.id, p.criado_em, p.origem, p.intencao, p.tipo_de_video,
p.modelo_da_ia, p.video_id,
(SELECT count(*) FROM acoes_do_plano a WHERE a.plano_id = p.id)
AS total_de_acoes
FROM planos_de_edicao p
ORDER BY p.criado_em DESC
LIMIT ?""",
(limite,),
)
]
def plano_de_dict(
dados: dict,
@@ -0,0 +1,79 @@
"""Persistência dos projetos de edição e das seleções da guia Projeto."""
from __future__ import annotations
import sqlite3
from dataclasses import dataclass
class ErroDeProjeto(ValueError):
"""Projeto inválido para persistência."""
@dataclass(frozen=True)
class ProjetoDeEdicao:
"""Projeto associado a exatamente uma sequência do Premiere."""
nome: str
video_id: str
faixas: tuple[str, ...] = ()
clipes: tuple[str, ...] = ()
status: str = "aberto"
class RepositorioDeProjetos:
"""Grava e recupera projetos sem duplicar dados da timeline."""
def __init__(self, conexao: sqlite3.Connection) -> None:
"""Inicializa o repositório com uma conexão SQLite preparada."""
self.conexao = conexao
def registrar(self, projeto: ProjetoDeEdicao) -> int:
"""Cria um projeto e grava suas faixas e clipes selecionados."""
if not projeto.nome.strip():
raise ErroDeProjeto("O projeto precisa ter um nome.")
if not projeto.video_id.strip():
raise ErroDeProjeto("O projeto precisa ter uma sequência.")
with self.conexao:
cursor = self.conexao.execute(
"""INSERT INTO projetos_de_edicao (video_id, nome, status)
VALUES (?, ?, ?)""",
(projeto.video_id, projeto.nome, projeto.status),
)
projeto_id = int(cursor.lastrowid)
self.conexao.executemany(
"INSERT INTO faixas_do_projeto (projeto_id, faixa_id) VALUES (?, ?)",
[(projeto_id, faixa_id) for faixa_id in projeto.faixas],
)
self.conexao.executemany(
"INSERT INTO clipes_do_projeto (projeto_id, clipe_id) VALUES (?, ?)",
[(projeto_id, clipe_id) for clipe_id in projeto.clipes],
)
return projeto_id
def carregar(self, projeto_id: int) -> dict[str, object] | None:
"""Carrega um projeto com sua sequência, faixas e clipes selecionados."""
linha = self.conexao.execute(
"SELECT * FROM projetos_de_edicao WHERE id = ?", (projeto_id,),
).fetchone()
if linha is None:
return None
return {
"id": linha["id"], "nome": linha["nome"], "video_id": linha["video_id"],
"status": linha["status"], "faixas": [
r["faixa_id"] for r in self.conexao.execute(
"""SELECT faixa_id FROM faixas_do_projeto
WHERE projeto_id = ? AND selecionada = 1 ORDER BY rowid""",
(projeto_id,),
)
], "clipes": [
r["clipe_id"] for r in self.conexao.execute(
"""SELECT clipe_id FROM clipes_do_projeto
WHERE projeto_id = ? AND selecionado = 1 ORDER BY rowid""",
(projeto_id,),
)
],
}
__all__ = ["ErroDeProjeto", "ProjetoDeEdicao", "RepositorioDeProjetos"]
@@ -0,0 +1,134 @@
"""Persistência do catálogo independente de trilhas musicais."""
from __future__ import annotations
import hashlib
import json
import sqlite3
from dataclasses import dataclass
from pathlib import Path
from typing import Iterable
from ..integracoes.audio.modelos_de_analise_musical import ResultadoDaAnaliseMusical
from .conexao import abrir_banco
@dataclass(frozen=True)
class TrilhaMusical:
"""Representa um arquivo de música catalogado, sem vínculo com vídeo."""
identificador: int
caminho: Path
nome_do_arquivo: str
status: str
erro: str | None = None
class RepositorioDeTrilhasSQLite:
"""Salva pastas, trilhas e versões das análises musicais no SQLite."""
def __init__(self, banco: str | Path | sqlite3.Connection = ".jhonny/analises.db") -> None:
"""Abre o banco informado e garante que o esquema esteja disponível."""
self.conexao = banco if isinstance(banco, sqlite3.Connection) else abrir_banco(banco)
def registrar_pasta_e_arquivos(self, pasta: str | Path, arquivos: Iterable[Path]) -> list[TrilhaMusical]:
"""Registra a pasta e torna sua lista de arquivos pronta para análise."""
caminho_da_pasta = Path(pasta).expanduser().resolve(strict=True)
if not caminho_da_pasta.is_dir():
raise NotADirectoryError(f"A pasta de trilhas não existe: {caminho_da_pasta}")
arquivos_materializados = tuple(Path(arquivo).resolve() for arquivo in arquivos)
with self.conexao:
self.conexao.execute(
"""INSERT INTO pastas_de_trilhas(caminho) VALUES (?)
ON CONFLICT(caminho) DO UPDATE SET atualizada_em = strftime('%Y-%m-%dT%H:%M:%fZ','now')""",
(str(caminho_da_pasta),),
)
pasta_id = self.conexao.execute(
"SELECT id FROM pastas_de_trilhas WHERE caminho = ?", (str(caminho_da_pasta),)
).fetchone()[0]
resultado: list[TrilhaMusical] = []
for arquivo in arquivos_materializados:
linha = self.conexao.execute(
"""INSERT INTO trilhas_musicais(pasta_id, caminho, nome_do_arquivo, tamanho_em_bytes)
VALUES (?, ?, ?, ?)
ON CONFLICT(caminho) DO UPDATE SET nome_do_arquivo = excluded.nome_do_arquivo,
status = CASE WHEN trilhas_musicais.tamanho_em_bytes != excluded.tamanho_em_bytes
THEN 'aguardando' ELSE trilhas_musicais.status END,
hash_do_conteudo = CASE WHEN trilhas_musicais.tamanho_em_bytes != excluded.tamanho_em_bytes
THEN NULL ELSE trilhas_musicais.hash_do_conteudo END,
tamanho_em_bytes = excluded.tamanho_em_bytes,
atualizada_em = strftime('%Y-%m-%dT%H:%M:%fZ','now')""",
(pasta_id, str(arquivo), arquivo.name, arquivo.stat().st_size),
)
identificador = linha.lastrowid or self.conexao.execute(
"SELECT id FROM trilhas_musicais WHERE caminho = ?", (str(arquivo),)
).fetchone()[0]
registro = self.conexao.execute(
"SELECT id, caminho, nome_do_arquivo, status, erro FROM trilhas_musicais WHERE id = ?",
(identificador,),
).fetchone()
resultado.append(TrilhaMusical(registro[0], Path(registro[1]), registro[2], registro[3], registro[4]))
return resultado
def iniciar_analise(self, trilha_id: int) -> None:
"""Marca uma trilha como em análise e remove erro anterior."""
with self.conexao:
self.conexao.execute(
"UPDATE trilhas_musicais SET status = 'analisando', erro = NULL, atualizada_em = strftime('%Y-%m-%dT%H:%M:%fZ','now') WHERE id = ?",
(trilha_id,),
)
def reabrir_analises_sem_etiquetas(self, pasta_id: int | None = None) -> None:
"""Volta a enfileirar análises antigas que não têm classificação semântica."""
filtro = "" if pasta_id is None else " AND t.pasta_id = ?"
parametros = () if pasta_id is None else (pasta_id,)
with self.conexao:
self.conexao.execute(
"""UPDATE trilhas_musicais AS t SET status = 'aguardando', erro = NULL
WHERE t.status = 'concluida' AND EXISTS (
SELECT 1 FROM analises_musicais AS a
WHERE a.trilha_id = t.id
AND NOT EXISTS (SELECT 1 FROM etiquetas_musicais e WHERE e.analise_id = a.id)
)""" + filtro,
parametros,
)
def registrar_analise(self, trilha: TrilhaMusical, resultado: ResultadoDaAnaliseMusical) -> None:
"""Grava descritores e etiquetas produzidos pelo áudio da trilha."""
bruto = json.dumps({"generos": resultado.generos, "humores": resultado.humores, "temas": resultado.temas, "instrumentos": resultado.instrumentos, "etiquetas": resultado.etiquetas}, ensure_ascii=False)
with self.conexao:
self.conexao.execute(
"UPDATE trilhas_musicais SET hash_do_conteudo = ?, status = 'concluida', erro = NULL, atualizada_em = strftime('%Y-%m-%dT%H:%M:%fZ','now') WHERE id = ?",
(self._calcular_hash(trilha.caminho), trilha.identificador),
)
cursor = self.conexao.execute(
"""INSERT INTO analises_musicais
(trilha_id, provedor, modelo, duracao_em_segundos, batidas_por_minuto, tonalidade, modo, intensidade, dancabilidade, temas_json, instrumentos_json, etiquetas_json, resultado_bruto_json)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""",
(trilha.identificador, resultado.provedor, resultado.modelo, resultado.duracao_em_segundos,
resultado.batidas_por_minuto, resultado.tonalidade, resultado.modo, resultado.intensidade,
resultado.dancabilidade, json.dumps(resultado.temas, ensure_ascii=False), json.dumps(resultado.instrumentos, ensure_ascii=False), json.dumps(resultado.etiquetas, ensure_ascii=False), bruto),
)
analise_id = cursor.lastrowid
for tipo, etiquetas in (("genero", resultado.pontuacoes_de_genero), ("humor", resultado.pontuacoes_de_humor), ("tema", resultado.pontuacoes_de_tema)):
self.conexao.executemany(
"INSERT INTO etiquetas_musicais(analise_id, tipo, etiqueta, confianca, ordem) VALUES (?, ?, ?, ?, ?)",
((analise_id, tipo, item.etiqueta, item.confianca, ordem) for ordem, item in enumerate(etiquetas)),
)
def registrar_erro(self, trilha_id: int, erro: str) -> None:
"""Marca uma trilha como erro sem interromper o lote inteiro."""
with self.conexao:
self.conexao.execute(
"UPDATE trilhas_musicais SET status = 'erro', erro = ?, atualizada_em = strftime('%Y-%m-%dT%H:%M:%fZ','now') WHERE id = ?",
(erro[:2000], trilha_id),
)
@staticmethod
def _calcular_hash(caminho: Path) -> str:
"""Calcula SHA-256 em blocos para não carregar o áudio inteiro na memória."""
resumo = hashlib.sha256()
with caminho.open("rb") as arquivo:
for bloco in iter(lambda: arquivo.read(1024 * 1024), b""):
resumo.update(bloco)
return resumo.hexdigest()
+76
View File
@@ -0,0 +1,76 @@
"""Prepara o contexto da edição por voz a partir do banco SQLite."""
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
from engine.persistencia.conexao import abrir_banco
def preparar_contexto(
caminho_do_banco: Path,
video_id: str | None = None,
incluir_palavras: bool = True,
) -> dict | None:
"""
Carrega a edição mais recente e seu contexto editorial.
Parâmetros:
caminho_do_banco: Caminho do banco SQLite de análises.
video_id: Vídeo específico; quando omitido, usa a edição mais recente.
incluir_palavras: Inclui os limites das palavras da transcrição.
Retorna:
Contexto serializável para o agente de edição, ou None quando não há
edição concluída compatível.
"""
conexao = abrir_banco(caminho_do_banco)
try:
return ConsultasDeAnalises(conexao).consultar_contexto_da_edicao(
video_id=video_id,
incluir_palavras=incluir_palavras,
)
finally:
conexao.close()
def principal() -> None:
"""Imprime o contexto editorial em JSON para consumo do agente."""
argumentos = argparse.ArgumentParser(
description="Carrega a edição por voz mais recente do banco SQLite."
)
argumentos.add_argument(
"--banco", type=Path, default=Path(".jhonny/analises.db"),
help="Caminho do banco SQLite de análises.",
)
argumentos.add_argument("--video-id", help="Restringe a consulta a um vídeo.")
argumentos.add_argument(
"--sem-palavras", action="store_true",
help="Omite os intervalos palavra a palavra para reduzir a resposta.",
)
opcoes = argumentos.parse_args()
try:
contexto = preparar_contexto(
opcoes.banco,
video_id=opcoes.video_id,
incluir_palavras=not opcoes.sem_palavras,
)
if contexto is None:
print(json.dumps({"ok": False, "erro": "Nenhuma edição concluída encontrada."}, ensure_ascii=False))
raise SystemExit(1)
print(json.dumps({"ok": True, "contexto": contexto}, ensure_ascii=False))
except (OSError, ValueError) as erro:
print(json.dumps({"ok": False, "erro": str(erro)}, ensure_ascii=False))
raise SystemExit(1) from erro
if __name__ == "__main__":
principal()
+40
View File
@@ -0,0 +1,40 @@
"""Entrada de linha de comando para registrar uma edição concluída no banco."""
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.conexao import abrir_banco
from engine.persistencia.repositorio_de_edicoes import RepositorioDeEdicoes, edicao_de_dict
def registrar(caminho_do_pedido: Path, caminho_do_banco: Path) -> int:
"""Lê o pedido do painel e registra a configuração editorial."""
dados = json.loads(caminho_do_pedido.read_text(encoding="utf-8"))
repositorio = RepositorioDeEdicoes(abrir_banco(caminho_do_banco))
return repositorio.registrar(edicao_de_dict(dados))
def principal() -> None:
"""Executa o registro pela linha de comando."""
argumentos = argparse.ArgumentParser()
argumentos.add_argument("pedido", type=Path)
argumentos.add_argument("--banco", type=Path, default=Path(".jhonny/analises.db"))
opcoes = argumentos.parse_args()
try:
identificador = registrar(opcoes.pedido, opcoes.banco)
print(json.dumps({"ok": True, "edicao_id": identificador}))
except (OSError, ValueError) as erro:
print(json.dumps({"ok": False, "erro": str(erro)}, ensure_ascii=False))
raise SystemExit(1) from erro
if __name__ == "__main__":
principal()
+77
View File
@@ -0,0 +1,77 @@
"""Entrada de linha de comando para salvar um plano de edição no SQLite."""
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.conexao import abrir_banco
from engine.persistencia.repositorio_de_planos import RepositorioDePlanos, plano_de_dict
def registrar(
caminho_do_plano: Path,
caminho_do_banco: Path,
video_id: str | None = None,
tipo_de_video: str | None = None,
modelo_da_ia: str | None = None,
intencao: str | None = None,
) -> int:
"""Lê o JSON devolvido pela IA e registra o plano associado à edição mais recente."""
dados = json.loads(caminho_do_plano.read_text(encoding="utf-8"))
conexao = abrir_banco(caminho_do_banco)
try:
if video_id is None:
origem = dados.get("source")
linha = conexao.execute(
"""SELECT video_id FROM edicoes_de_video
WHERE origem = ? OR sequencia = ?
ORDER BY concluida_em DESC, id DESC LIMIT 1""",
(origem, origem),
).fetchone()
video_id = linha["video_id"] if linha is not None else None
plano = plano_de_dict(
dados,
video_id=video_id,
tipo_de_video=tipo_de_video,
modelo_da_ia=modelo_da_ia,
intencao=intencao,
)
return RepositorioDePlanos(conexao).registrar_plano(plano)
finally:
conexao.close()
def principal() -> None:
"""Executa o registro do plano pela linha de comando."""
argumentos = argparse.ArgumentParser()
argumentos.add_argument("plano", type=Path)
argumentos.add_argument("--banco", type=Path, default=Path(".jhonny/analises.db"))
argumentos.add_argument("--video-id")
argumentos.add_argument("--tipo-de-video")
argumentos.add_argument("--modelo-da-ia")
argumentos.add_argument("--intencao")
opcoes = argumentos.parse_args()
try:
identificador = registrar(
opcoes.plano,
opcoes.banco,
video_id=opcoes.video_id,
tipo_de_video=opcoes.tipo_de_video,
modelo_da_ia=opcoes.modelo_da_ia,
intencao=opcoes.intencao,
)
print(json.dumps({"ok": True, "plano_id": identificador}, ensure_ascii=False))
except (OSError, ValueError) as erro:
print(json.dumps({"ok": False, "erro": str(erro)}, ensure_ascii=False))
raise SystemExit(1) from erro
if __name__ == "__main__":
principal()
@@ -0,0 +1,48 @@
"""Entrada de linha de comando para salvar um projeto de edição no SQLite."""
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.conexao import abrir_banco
from engine.persistencia.repositorio_de_projetos import ProjetoDeEdicao, RepositorioDeProjetos
def registrar(caminho_do_pedido: Path, caminho_do_banco: Path) -> int:
"""Lê a seleção da guia Projeto e grava seu projeto no banco."""
dados = json.loads(caminho_do_pedido.read_text(encoding="utf-8"))
conexao = abrir_banco(caminho_do_banco)
try:
projeto = ProjetoDeEdicao(
nome=str(dados["nome"]), video_id=str(dados["video_id"]),
faixas=tuple(str(valor) for valor in dados.get("faixas", [])),
clipes=tuple(str(valor) for valor in dados.get("clipes", [])),
)
return RepositorioDeProjetos(conexao).registrar(projeto)
finally:
conexao.close()
def principal() -> None:
"""Executa o registro pela linha de comando."""
argumentos = argparse.ArgumentParser()
argumentos.add_argument("pedido", type=Path)
argumentos.add_argument("--banco", type=Path, default=Path(".jhonny/analises.db"))
opcoes = argumentos.parse_args()
try:
identificador = registrar(opcoes.pedido, opcoes.banco)
print(json.dumps({"ok": True, "projeto_id": identificador}, ensure_ascii=False))
except (OSError, KeyError, ValueError) as erro:
print(json.dumps({"ok": False, "erro": str(erro)}, ensure_ascii=False))
raise SystemExit(1) from erro
if __name__ == "__main__":
principal()
+2
View File
@@ -2,3 +2,5 @@
# a diarização e a detecção de emoção (integracoes/huggingface/provider_de_diarizacao_local.py
# e provider_de_emocao_local.py importam soundfile para ler o áudio extraído).
soundfile
# Dependência opcional carregada somente pelo adaptador de análise musical.
essentia
@@ -40,6 +40,9 @@ class ConfiguracaoVisualDoScanner:
faixas_de_audio: tuple[int, ...] = ()
modo_de_analise: str = "ambos"
metricas_de_fala: bool = False
# Classificação de emoção da fala (ProviderDeEmocaoHuggingFace) — desligada
# por padrão, mesmo motivo de metricas_de_fala: custo de processamento.
classificar_emocao: bool = False
recursos_vision: frozenset[str] = RECURSOS_VISION
detectar_cenas: bool = True
analisar_continuidade: bool = True
@@ -93,6 +96,7 @@ class ConfiguracaoVisualDoScanner:
faixas_de_audio=tuple(sorted(set(int(item) for item in dados.get("faixas_de_audio", ())))),
modo_de_analise=str(dados.get("modo_de_analise", "ambos")),
metricas_de_fala=bool(dados.get("metricas_de_fala", False)),
classificar_emocao=bool(dados.get("classificar_emocao", False)),
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)),
@@ -108,6 +112,7 @@ class ConfiguracaoVisualDoScanner:
)
def para_dict(self) -> dict[str, Any]:
"""Serializa a configuração no mesmo formato de JSON consumido pelo painel."""
return {
"versao": 1,
"intervalo_em_segundos": self.intervalo_em_segundos,
@@ -116,6 +121,7 @@ class ConfiguracaoVisualDoScanner:
"faixas_de_audio": list(self.faixas_de_audio),
"modo_de_analise": self.modo_de_analise,
"metricas_de_fala": self.metricas_de_fala,
"classificar_emocao": self.classificar_emocao,
"recursos_vision": sorted(self.recursos_vision),
"detectar_cenas": self.detectar_cenas,
"analisar_continuidade": self.analisar_continuidade,
+226 -4
View File
@@ -5,7 +5,10 @@ têm posição na timeline diferente da posição no arquivo de origem — o mes
descompasso encontrado no projeto real entre ``inicio_na_timeline`` e
``inicio_na_origem`` do banco de análises. Implementa só as ferramentas MCP
que o módulo ``engine.editor`` usa: ``get_active_sequence``, ``split_clip``,
``remove_from_timeline``, ``remove_linked_from_timeline``, ``set_clip_properties`` e ``add_marker``.
``remove_from_timeline``, ``remove_linked_from_timeline``,
``duplicate_linked_range_to_start``, ``set_clip_properties``, ``add_marker``,
``add_track``, ``set_playhead_position``, ``add_adjustment_layer``,
``get_clip_at_position``, ``trim_clip`` e ``move_clip``.
"""
from __future__ import annotations
@@ -46,6 +49,7 @@ class ClienteMCPFalso(ClienteMCP):
sem_origem_no_audio: bool = False,
clipe_unico: bool = False,
desvio_do_split: float = 0.0,
simular_falha_razor: bool = False,
) -> None:
"""``sem_origem_no_audio`` reproduz o bug real do bridge: relatar clipes de
áudio sem ``inPoint``/``outPoint`` na resposta de ``get_active_sequence``.
@@ -56,12 +60,18 @@ class ClienteMCPFalso(ClienteMCP):
``desvio_do_split`` reproduz o drift real do ``split_clip``: em vez
de cortar exatamente no instante pedido, corta em
``instante + desvio_do_split``."""
``instante + desvio_do_split``.
``simular_falha_razor`` reproduz o bug QE do Premiere 26.x: o
``razor_all_tracks`` reporta clipes elegíveis mas não corta nenhum
(``razored=0, eligibleTracks=N``)."""
self.sem_origem_no_audio = sem_origem_no_audio
self.desvio_do_split = desvio_do_split
self.simular_falha_razor = simular_falha_razor
self.chamadas: list[tuple[str, dict[str, Any]]] = []
self._proximo_id = 0
self._historico: list[dict[str, list[_ClipeFalso]]] = []
self._playhead = 0.0
self._faixas: dict[str, list[_ClipeFalso]] = {
"video_0": [self._novo_clipe(self._ARQUIVO, 0.0, 200.0, 0.0, 200.0, "clipe_unico_video")],
"audio_0": [self._novo_clipe(self._ARQUIVO, 0.0, 200.0, 0.0, 200.0, "clipe_unico_audio")],
@@ -100,9 +110,17 @@ class ClienteMCPFalso(ClienteMCP):
"split_clip": self._simular_split_clip,
"remove_from_timeline": self._simular_ripple_delete,
"remove_linked_from_timeline": self._simular_remocao_vinculada,
"duplicate_linked_range_to_start": self._simular_gancho_vinculado_no_inicio,
"set_clip_properties": self._simular_set_clip_properties,
"add_marker": self._simular_add_marker,
"undo": self._simular_undo,
"add_track": self._simular_add_track,
"set_playhead_position": self._simular_set_playhead_position,
"add_adjustment_layer": self._simular_add_adjustment_layer,
"razor_all_tracks": self._simular_razor_all_tracks,
"get_clip_at_position": self._simular_get_clip_at_position,
"trim_clip": self._simular_trim_clip,
"move_clip": self._simular_move_clip,
}
simulador = despachantes.get(nome_da_ferramenta)
if simulador is None:
@@ -139,6 +157,14 @@ class ClienteMCPFalso(ClienteMCP):
"clips": [_clipe_como_dict(clipe, tipo) for clipe in self._faixas[identificador]],
}
faixas_de_video = sorted(
(identificador for identificador in self._faixas if identificador.startswith("video_")),
key=lambda identificador: int(identificador.split("_")[1]),
)
faixas_de_audio = sorted(
(identificador for identificador in self._faixas if identificador.startswith("audio_")),
key=lambda identificador: int(identificador.split("_")[1]),
)
dados = {
"id": "sequencia_falsa",
"name": "Sequência de teste",
@@ -146,8 +172,14 @@ class ClienteMCPFalso(ClienteMCP):
"frameRate": 29.97,
"frameSizeHorizontal": 3840,
"frameSizeVertical": 2160,
"videoTracks": [_faixa_como_dict("video_0", "video", 0)],
"audioTracks": [_faixa_como_dict("audio_0", "audio", 0)],
"videoTracks": [
_faixa_como_dict(identificador, "video", int(identificador.split("_")[1]))
for identificador in faixas_de_video
],
"audioTracks": [
_faixa_como_dict(identificador, "audio", int(identificador.split("_")[1]))
for identificador in faixas_de_audio
],
}
return {"structuredContent": {"data": dados}}
@@ -198,6 +230,64 @@ class ClienteMCPFalso(ClienteMCP):
f"No clip strictly spans {instante}s on track {identificador_da_faixa}."
)
def _simular_razor_all_tracks(self, argumentos: dict[str, Any]) -> dict[str, Any]:
"""Divide todos os clipes de todas as faixas no instante informado.
Reproduz o ``razor_all_tracks`` do bridge real: corta todas as faixas
de vídeo e/ou áudio no mesmo ponto de tempo, garantindo
sincronização entre elas.
Quando ``simular_falha_razor`` está ativo, reporta clipes elegíveis
mas não corta nenhum, reproduzindo o bug QE do Premiere 26.x.
"""
instante_pedido = float(argumentos["time_seconds"])
track_type = argumentos.get("track_type", "both")
elegiveis = 0
for identificador_da_faixa, faixa in self._faixas.items():
if track_type == "video" and not identificador_da_faixa.startswith("video_"):
continue
if track_type == "audio" and not identificador_da_faixa.startswith("audio_"):
continue
for clipe in faixa:
if clipe.inicio_na_timeline < instante_pedido < clipe.fim_na_timeline:
elegiveis += 1
break
if self.simular_falha_razor:
return {"razored": 0, "eligibleTracks": elegiveis, "verified": False}
cortados = 0
self._salvar_estado()
for identificador_da_faixa, faixa in self._faixas.items():
if track_type == "video" and not identificador_da_faixa.startswith("video_"):
continue
if track_type == "audio" and not identificador_da_faixa.startswith("audio_"):
continue
for posicao, clipe in enumerate(faixa):
if clipe.inicio_na_timeline < instante_pedido < clipe.fim_na_timeline:
instante = min(
max(instante_pedido + self.desvio_do_split, clipe.inicio_na_timeline + 0.001),
clipe.fim_na_timeline - 0.001,
)
deslocamento = instante - clipe.inicio_na_timeline
self._proximo_id += 1
parte_esquerda = _ClipeFalso(
f"{clipe.identificador}_esq{self._proximo_id}", clipe.nome,
clipe.inicio_na_timeline, instante,
clipe.inicio_na_origem, clipe.inicio_na_origem + deslocamento,
)
self._proximo_id += 1
parte_direita = _ClipeFalso(
f"{clipe.identificador}_dir{self._proximo_id}", clipe.nome,
instante, clipe.fim_na_timeline,
clipe.inicio_na_origem + deslocamento, clipe.fim_na_origem,
)
faixa[posicao:posicao + 1] = [parte_esquerda, parte_direita]
cortados += 1
break
return {"razored": cortados, "eligibleTracks": elegiveis, "verified": True}
def _simular_ripple_delete(self, argumentos: dict[str, Any]) -> dict[str, Any]:
"""Remove o clipe pedido e fecha o espaço deixado na mesma faixa."""
identificador_do_clipe = argumentos["node_id"]
@@ -223,11 +313,143 @@ class ClienteMCPFalso(ClienteMCP):
self._simular_ripple_delete({"node_id": identificador, "ripple": True})
return {"removed": True, "verified": True}
def _simular_gancho_vinculado_no_inicio(self, argumentos: dict[str, Any]) -> dict[str, Any]:
"""Duplica um par já isolado no início e preserva sua ocorrência original.
Espelha o bridge real: não recebe limites de origem à parte, lê o
in/out do próprio clipe. Por isso os identificadores precisam já
corresponder a um par isolado por ``split_clip`` — o mesmo clipe
inteiro, sem sobra de trecho vizinho.
"""
identificadores = argumentos["node_ids"]
encontrados: list[tuple[str, _ClipeFalso]] = []
for identificador_da_faixa, clipes in self._faixas.items():
for clipe in clipes:
if clipe.identificador in identificadores:
encontrados.append((identificador_da_faixa, clipe))
if len(encontrados) != 2 or {faixa.split("_")[0] for faixa, _ in encontrados} != {"video", "audio"}:
raise ErroDeFerramentaMCP("O gancho exige um par de vídeo e áudio existente.")
inicios = {round(clipe.inicio_na_timeline, 6) for _, clipe in encontrados}
fins = {round(clipe.fim_na_timeline, 6) for _, clipe in encontrados}
if len(inicios) != 1 or len(fins) != 1:
raise ErroDeFerramentaMCP("O par não ocupa o mesmo intervalo.")
if next(iter(inicios)) <= 0.0:
raise ErroDeFerramentaMCP("O trecho pedido já está no início da sequência.")
self._salvar_estado()
duracao = next(iter(fins)) - next(iter(inicios))
novos_ids: list[str] = []
for identificador_da_faixa, clipe in encontrados:
faixa = self._faixas[identificador_da_faixa]
for outro in faixa:
outro.inicio_na_timeline += duracao
outro.fim_na_timeline += duracao
novo = self._novo_clipe(
clipe.nome,
0.0,
duracao,
clipe.inicio_na_origem,
clipe.fim_na_origem,
)
faixa.append(novo)
faixa.sort(key=lambda item: item.inicio_na_timeline)
novos_ids.append(novo.identificador)
return {"duplicated": True, "verified": True, "insertedNodeIds": novos_ids}
def _simular_set_clip_properties(self, argumentos: dict[str, Any]) -> dict[str, Any]:
"""Aceita a alteração de propriedades sem simular geometria (não é usada pelos testes)."""
del argumentos
return {"updated": True}
def _simular_add_track(self, argumentos: dict[str, Any]) -> dict[str, Any]:
"""Cria ``count`` faixas de vídeo novas, vazias, no topo da pilha.
Espelha ``add_track`` do bridge real (usado pelo `.mjs` para criar a
track de camada de ajuste): devolve ``totalTracks`` já contando as
novas, de forma que ``totalTracks - 1`` seja o índice da última
faixa criada.
"""
if argumentos.get("track_type") != "video":
raise ErroDeFerramentaMCP("Este dublê só simula add_track para vídeo.")
contagem = int(argumentos.get("count", 1))
indices_existentes = [
int(identificador.split("_")[1])
for identificador in self._faixas
if identificador.startswith("video_")
]
proximo_indice = max(indices_existentes, default=-1) + 1
for deslocamento in range(contagem):
self._faixas[f"video_{proximo_indice + deslocamento}"] = []
total_de_faixas_de_video = proximo_indice + contagem
return {"totalTracks": total_de_faixas_de_video}
def _simular_set_playhead_position(self, argumentos: dict[str, Any]) -> dict[str, Any]:
"""Guarda a posição do playhead, usada por :meth:`_simular_add_adjustment_layer`."""
self._playhead = float(argumentos["time_seconds"])
return {"positioned": True}
def _simular_add_adjustment_layer(self, argumentos: dict[str, Any]) -> dict[str, Any]:
"""Cria uma camada de ajuste na track pedida, no instante atual do playhead.
A camada nasce com uma duração fixa arbitrária (o `.mjs` real conta
com isso: sempre chama ``trim_clip``/``move_clip`` em seguida para
ajustar duração e posição definitivas).
"""
indice_da_faixa = int(argumentos["track_index"])
identificador_da_faixa = self._identificador_da_faixa(indice_da_faixa, "video")
if identificador_da_faixa not in self._faixas:
raise ErroDeFerramentaMCP(f"Faixa de vídeo inexistente: {identificador_da_faixa!r}.")
duracao_inicial = 5.0
clipe = self._novo_clipe(
"Adjustment Layer",
self._playhead,
self._playhead + duracao_inicial,
self._playhead,
self._playhead + duracao_inicial,
)
self._faixas[identificador_da_faixa].append(clipe)
self._faixas[identificador_da_faixa].sort(key=lambda item: item.inicio_na_timeline)
return {"added": True, "nodeId": clipe.identificador}
def _simular_get_clip_at_position(self, argumentos: dict[str, Any]) -> dict[str, Any]:
"""Devolve o clipe da faixa pedida que cobre ``time_seconds``."""
identificador_da_faixa = self._identificador_da_faixa(argumentos["track_index"], argumentos["track_type"])
instante = float(argumentos["time_seconds"])
faixa = self._faixas.get(identificador_da_faixa, [])
clipe = next(
(c for c in faixa if c.inicio_na_timeline <= instante < c.fim_na_timeline), None
)
if clipe is None:
raise ErroDeFerramentaMCP(f"Nenhum clipe em {identificador_da_faixa!r} no instante {instante}s.")
return {"nodeId": clipe.identificador}
def _simular_trim_clip(self, argumentos: dict[str, Any]) -> dict[str, Any]:
"""Ajusta a duração do clipe, medida a partir do próprio início atual."""
clipe = self._localizar_clipe(argumentos["node_id"])
nova_duracao = float(argumentos["new_out_seconds"])
clipe.fim_na_timeline = clipe.inicio_na_timeline + nova_duracao
clipe.fim_na_origem = clipe.inicio_na_origem + nova_duracao
return {"trimmed": True}
def _simular_move_clip(self, argumentos: dict[str, Any]) -> dict[str, Any]:
"""Move o clipe para ``new_start_seconds``, preservando sua duração."""
clipe = self._localizar_clipe(argumentos["node_id"])
duracao = clipe.fim_na_timeline - clipe.inicio_na_timeline
novo_inicio = float(argumentos["new_start_seconds"])
clipe.inicio_na_timeline = novo_inicio
clipe.fim_na_timeline = novo_inicio + duracao
for faixa in self._faixas.values():
faixa.sort(key=lambda item: item.inicio_na_timeline)
return {"moved": True}
def _localizar_clipe(self, identificador_do_clipe: str) -> _ClipeFalso:
"""Encontra um clipe pelo identificador em qualquer faixa, ou levanta erro."""
for faixa in self._faixas.values():
for clipe in faixa:
if clipe.identificador == identificador_do_clipe:
return clipe
raise ErroDeFerramentaMCP(f"Clipe não encontrado: {identificador_do_clipe!r}.")
def _simular_add_marker(self, argumentos: dict[str, Any]) -> dict[str, Any]:
"""Aceita a criação de marcador sem manter estado (a chamada já fica em :attr:`chamadas`)."""
del argumentos
@@ -0,0 +1,79 @@
import tempfile
import unittest
from pathlib import Path
from engine.integracoes.audio import AnalisadorDeMusicaInstrumentalEssentia
from engine.integracoes.audio import PontuacaoDeEtiqueta
class TesteAnalisadorDeMusicaInstrumentalEssentia(unittest.TestCase):
def test_converte_descritores_e_classificacoes(self):
def extrator(_caminho: str):
return {
"metadata": {"audio_properties": {"length": 123.4}},
"rhythm": {"bpm": 92.0, "danceability": 0.61},
"tonal": {"key_edma": {"key": "A", "scale": "minor"}},
"lowlevel": {"average_loudness": 0.35},
"highlevel": {
"genre": {"value": {"ambient": 0.9, "rock": 0.0}},
"mood": {"value": ["calm", "melancholic"]},
},
}, {}
with tempfile.TemporaryDirectory() as pasta:
arquivo = Path(pasta) / "instrumental.wav"
arquivo.write_bytes(b"audio")
resultado = AnalisadorDeMusicaInstrumentalEssentia(extrator).analisar(arquivo)
self.assertEqual(resultado.batidas_por_minuto, 92.0)
self.assertEqual(resultado.tonalidade, "A")
self.assertEqual(resultado.modo, "minor")
self.assertEqual(resultado.generos, ("ambient",))
self.assertEqual(resultado.humores, ("calm", "melancholic"))
def test_rejeita_arquivo_inexistente(self):
analisador = AnalisadorDeMusicaInstrumentalEssentia(lambda _: ({}, {}))
with self.assertRaises(FileNotFoundError):
analisador.analisar("/arquivo/inexistente.wav")
def test_aceita_descritores_com_chaves_pontuadas_do_essentia(self):
def extrator(_caminho: str):
return {
"metadata.audio_properties.length": 10.0,
"rhythm.bpm": 110.0,
"rhythm.danceability": 0.42,
"tonal.key_edma.key": "D",
"tonal.key_edma.scale": "major",
"lowlevel.average_loudness": 0.22,
}, {}
with tempfile.TemporaryDirectory() as pasta:
arquivo = Path(pasta) / "instrumental.wav"
arquivo.write_bytes(b"audio")
resultado = AnalisadorDeMusicaInstrumentalEssentia(extrator).analisar(arquivo)
self.assertEqual(resultado.duracao_em_segundos, 10.0)
self.assertEqual(resultado.batidas_por_minuto, 110.0)
self.assertEqual(resultado.tonalidade, "D")
def test_incorpora_classificacoes_do_audio_ao_resultado(self):
classificador = type("Classificador", (), {
"classificar": lambda self, _arquivo: (
(PontuacaoDeEtiqueta("Jazz-Funk", 0.8),),
(PontuacaoDeEtiqueta("energética", 0.7),),
),
})()
def extrator(_caminho: str):
return {"rhythm.bpm": 100.0}, {}
with tempfile.TemporaryDirectory() as pasta:
arquivo = Path(pasta) / "instrumental.wav"
arquivo.write_bytes(b"audio")
resultado = AnalisadorDeMusicaInstrumentalEssentia(
extrator, classificador=classificador,
).analisar(arquivo)
self.assertEqual(resultado.generos, ("Jazz-Funk",))
self.assertEqual(resultado.humores, ("energética",))
self.assertEqual(resultado.pontuacoes_de_genero[0].confianca, 0.8)
@@ -74,11 +74,8 @@ class TesteAplicadorDePlanoDeEdicao(unittest.TestCase):
self.assertEqual([clipe.identificador for clipe in clipes], ["clipe_real"])
def test_marcador_e_zoom_sao_aplicados_depois_dos_cortes(self):
# Zoom muda uma propriedade do clipe inteiro (set_clip_properties),
# não de um trecho dele. Se o zoom rodasse antes do corte, cairia no
# clipe original ainda não dividido; dois zooms em pontos diferentes
# do mesmo clipe se sobrescreveriam, e a divisão posterior faria os
# pedaços herdarem só o último valor. Por isso cortes vêm primeiro.
# Zoom e marcador precisam do instante definitivo na timeline, e
# cortes deslocam clipes (ripple) — por isso cortes vêm primeiro.
plano = PlanoDeEdicao("0E6A8290.mp4", (
AcaoDeEdicao(TipoDeAcao.CORTE, inicio=0.0, fim=40.6, motivo="bastidor"),
AcaoDeEdicao(TipoDeAcao.MARCADOR, inicio=90.0, fim=90.5, motivo="conferir emenda", parametros={"content": "EMENDA"}),
@@ -88,12 +85,16 @@ class TesteAplicadorDePlanoDeEdicao(unittest.TestCase):
self.assertTrue(resultado.todas_bem_sucedidas, msg=[r.detalhe for r in resultado.falhas])
# corte aconteceu antes de marcador e zoom na sequência de chamadas
indice_do_marcador = next(i for i, c in enumerate(self.cliente.chamadas) if c[0] == "add_marker")
indice_do_zoom = next(i for i, c in enumerate(self.cliente.chamadas) if c[0] == "set_clip_properties")
indice_do_zoom = next(i for i, c in enumerate(self.cliente.chamadas) if c[0] == "add_adjustment_layer")
indice_da_remocao = next(
i for i, c in enumerate(self.cliente.chamadas) if c[0] == "remove_linked_from_timeline"
)
self.assertLess(indice_da_remocao, indice_do_marcador)
self.assertLess(indice_da_remocao, indice_do_zoom)
# a adjustment layer foi criada e escalada, sem tocar o clipe original
self.assertTrue(any(c[0] == "add_track" for c in self.cliente.chamadas))
chamada_de_escala = next(c for c in self.cliente.chamadas if c[0] == "set_clip_properties")
self.assertAlmostEqual(chamada_de_escala[1]["scale"], 115.0)
def test_dois_cortes_sao_aplicados_do_fim_para_o_comeco(self):
plano = PlanoDeEdicao("0E6A8290.mp4", (
@@ -121,12 +122,11 @@ class TesteAplicadorDePlanoDeEdicao(unittest.TestCase):
# a ação válida (marcador) ainda foi aplicada
self.assertTrue(any(c[0] == "add_marker" for c in self.cliente.chamadas))
def test_dois_zooms_no_mesmo_clipe_original_nao_se_sobrescrevem(self):
# Bug real: com um só clipe cobrindo o arquivo inteiro (gravação
# "sem corte"), dois zooms em trechos diferentes caíam no mesmo
# clipe ainda não dividido; o segundo sobrescrevia a escala do
# primeiro no clipe inteiro, e ambos os pedaços resultantes da
# divisão herdavam só o último valor.
def test_dois_zooms_geram_duas_camadas_de_ajuste_na_mesma_faixa(self):
# Com a técnica de adjustment layer, dois zooms em trechos
# diferentes do mesmo clipe original (gravação "sem corte") não
# competem por uma propriedade do clipe inteiro: cada um ganha sua
# própria camada, na mesma faixa de ajuste reaproveitada.
cliente = ClienteMCPFalso(clipe_unico=True)
aplicador = AplicadorDePlanoDeEdicao(
acesso_ao_editor=AcessoAoEditor(AcessoATimeline(cliente)),
@@ -141,13 +141,23 @@ class TesteAplicadorDePlanoDeEdicao(unittest.TestCase):
))
resultado = aplicador.aplicar(plano)
self.assertTrue(resultado.todas_bem_sucedidas, msg=[r.detalhe for r in resultado.falhas])
# só uma faixa de ajuste foi criada, reaproveitada pelos dois zooms
chamadas_de_track = [c for c in cliente.chamadas if c[0] == "add_track"]
self.assertEqual(len(chamadas_de_track), 1)
chamadas_de_camada = [c for c in cliente.chamadas if c[0] == "add_adjustment_layer"]
self.assertEqual(len(chamadas_de_camada), 2)
indices_de_faixa = {c[1]["track_index"] for c in chamadas_de_camada}
self.assertEqual(len(indices_de_faixa), 1)
chamadas_de_zoom = [c for c in cliente.chamadas if c[0] == "set_clip_properties"]
self.assertEqual(len(chamadas_de_zoom), 2)
node_ids_zoomados = {c[1]["node_id"] for c in chamadas_de_zoom}
# os dois zooms caíram em clipes diferentes, um por trecho
# os dois zooms caíram em camadas diferentes, uma por trecho
self.assertEqual(len(node_ids_zoomados), 2)
escalas = sorted(round(c[1]["scale"], 3) for c in chamadas_de_zoom)
self.assertEqual(escalas, [115.0, 130.0])
# o clipe original não foi escalado
node_ids_clipe_original = {"clipe_unico_video", "clipe_unico_audio"}
self.assertFalse(node_ids_zoomados & node_ids_clipe_original)
def test_corte_com_desvio_de_bridge_usa_a_borda_real_em_vez_de_falhar(self):
# O split_clip do bridge do Premiere corta alguns décimos de segundo
@@ -249,6 +259,112 @@ class TesteAplicadorDePlanoDeEdicao(unittest.TestCase):
self.assertEqual(len(chamadas_de_marcador), 1)
self.assertIn("MASTOPEXIA", chamadas_de_marcador[0][1]["comments"])
def test_gancho_isola_video_e_audio_e_duplica_no_inicio(self):
cliente = ClienteMCPFalso(clipe_unico=True)
aplicador = AplicadorDePlanoDeEdicao(
acesso_ao_editor=AcessoAoEditor(AcessoATimeline(cliente)),
conversor_de_timeline=ConversorDeTimeline(),
escrita=EscritaNoEditor(cliente),
mapeador=MapeadorDeTempoDeOrigemParaTimeline(),
)
plano = PlanoDeEdicao("0E6A8290.mp4", (
AcaoDeEdicao(
TipoDeAcao.GANCHO,
inicio=80.0,
fim=90.0,
motivo="gancho de abertura",
parametros={"target": 0, "keep_original": True},
),
))
resultado = aplicador.aplicar(plano)
self.assertTrue(resultado.todas_bem_sucedidas, msg=[item.detalhe for item in resultado.falhas])
ganchos = [chamada for chamada in cliente.chamadas if chamada[0] == "duplicate_linked_range_to_start"]
self.assertEqual(len(ganchos), 1)
self.assertEqual(len(ganchos[0][1]["node_ids"]), 2)
self.assertEqual(cliente._faixas["video_0"][0].inicio_na_origem, 80.0)
self.assertEqual(cliente._faixas["audio_0"][0].inicio_na_origem, 80.0)
self.assertEqual(cliente._faixas["video_0"][0].inicio_na_timeline, 0.0)
self.assertEqual(cliente._faixas["audio_0"][0].inicio_na_timeline, 0.0)
self.assertEqual(
sum(1 for clipe in cliente._faixas["video_0"] if clipe.inicio_na_origem == 80.0),
2,
)
self.assertEqual(
sum(1 for clipe in cliente._faixas["audio_0"] if clipe.inicio_na_origem == 80.0),
2,
)
def test_gancho_e_executado_apos_cortes_e_antes_de_zoom(self):
cliente = ClienteMCPFalso(clipe_unico=True)
aplicador = AplicadorDePlanoDeEdicao(
acesso_ao_editor=AcessoAoEditor(AcessoATimeline(cliente)),
conversor_de_timeline=ConversorDeTimeline(),
escrita=EscritaNoEditor(cliente),
mapeador=MapeadorDeTempoDeOrigemParaTimeline(),
)
plano = PlanoDeEdicao("0E6A8290.mp4", (
AcaoDeEdicao(TipoDeAcao.CORTE, 20.0, 30.0, "retirar bastidor"),
AcaoDeEdicao(TipoDeAcao.GANCHO, 80.0, 90.0, "gancho"),
AcaoDeEdicao(TipoDeAcao.ZOOM, 100.0, 105.0, "ênfase", {"scale": 1.15}),
))
resultado = aplicador.aplicar(plano)
self.assertTrue(resultado.todas_bem_sucedidas, msg=[item.detalhe for item in resultado.falhas])
nomes = [nome for nome, _ in cliente.chamadas]
self.assertLess(nomes.index("remove_linked_from_timeline"), nomes.index("duplicate_linked_range_to_start"))
self.assertLess(nomes.index("duplicate_linked_range_to_start"), nomes.index("set_clip_properties"))
def test_corte_falha_limpo_quando_razor_nao_corta_nenhuma_faixa(self):
"""Quando o razor_all_tracks falha (QE no-op do Premiere 26.x),
o corte deve falhar sem corromper a timeline para operações
subsequentes — nenhuma remoção é tentada e marcadores/zooms
continuam funcionando."""
cliente = ClienteMCPFalso(simular_falha_razor=True)
aplicador = AplicadorDePlanoDeEdicao(
acesso_ao_editor=AcessoAoEditor(AcessoATimeline(cliente)),
conversor_de_timeline=ConversorDeTimeline(),
escrita=EscritaNoEditor(cliente),
mapeador=MapeadorDeTempoDeOrigemParaTimeline(),
)
plano = PlanoDeEdicao("0E6A8290.mp4", (
AcaoDeEdicao(TipoDeAcao.CORTE, inicio=2.3, fim=20.0, motivo="bastidor"),
AcaoDeEdicao(TipoDeAcao.MARCADOR, inicio=90.0, fim=90.5, motivo="ok", parametros={"content": "X"}),
))
resultado = aplicador.aplicar(plano)
self.assertFalse(resultado.todas_bem_sucedidas)
self.assertEqual(len(resultado.falhas), 1)
self.assertIn("razor", resultado.falhas[0].detalhe.lower())
self.assertTrue(any(c[0] == "add_marker" for c in cliente.chamadas))
self.assertEqual(cliente.chamadas_de_remocao, [])
def test_gancho_falha_limpo_quando_razor_nao_corta_nenhuma_faixa(self):
"""Quando o razor_all_tracks falha ao isolar o trecho do gancho,
a operação deve falhar sem duplicar nada na timeline."""
cliente = ClienteMCPFalso(clipe_unico=True, simular_falha_razor=True)
aplicador = AplicadorDePlanoDeEdicao(
acesso_ao_editor=AcessoAoEditor(AcessoATimeline(cliente)),
conversor_de_timeline=ConversorDeTimeline(),
escrita=EscritaNoEditor(cliente),
mapeador=MapeadorDeTempoDeOrigemParaTimeline(),
)
plano = PlanoDeEdicao("0E6A8290.mp4", (
AcaoDeEdicao(
TipoDeAcao.GANCHO,
inicio=80.0,
fim=90.0,
motivo="gancho de abertura",
parametros={"target": 0, "keep_original": True},
),
))
resultado = aplicador.aplicar(plano)
self.assertFalse(resultado.todas_bem_sucedidas)
self.assertEqual(len(resultado.falhas), 1)
duplicacoes = [c for c in cliente.chamadas if c[0] == "duplicate_linked_range_to_start"]
self.assertEqual(len(duplicacoes), 0)
if __name__ == "__main__":
unittest.main()
@@ -1,10 +1,13 @@
"""Testes da resolução do executável Node usada pelo aplicador de planos."""
import os
import tempfile
import unittest
from pathlib import Path
from unittest.mock import patch
from engine import aplicar_plano_de_edicao
from engine.editor import AcaoDeEdicao, ErroDePlanoInvalido, PlanoDeEdicao, TipoDeAcao
class TesteResolucaoDoNode(unittest.TestCase):
@@ -35,5 +38,67 @@ class TesteResolucaoDoNode(unittest.TestCase):
aplicar_plano_de_edicao.resolver_caminho_do_node()
class TestePreValidacaoDoPlano(unittest.TestCase):
"""Garante que planos semanticamente perigosos não alcancem o Premiere."""
def test_interrompe_antes_de_resolver_node_e_criar_backup(self) -> None:
"""A validação semântica ocorre antes de abrir a sessão do editor."""
with tempfile.TemporaryDirectory() as pasta:
caminho_do_plano = Path(pasta) / "plano.json"
caminho_do_plano.write_text(
'{"source":"video.mp4","actions":['
'{"kind":"cut","start":1.0,"end":2.0,"reason":"teste"}]}',
encoding="utf-8",
)
with patch(
"engine.aplicar_plano_de_edicao._validar_integridade_semantica",
side_effect=ErroDePlanoInvalido("frase interrompida"),
) as validar, patch(
"engine.aplicar_plano_de_edicao.resolver_caminho_do_node"
) as resolver_node:
with self.assertRaisesRegex(ErroDePlanoInvalido, "frase interrompida"):
aplicar_plano_de_edicao.executar(caminho_do_plano, plano_id=7)
validar.assert_called_once()
resolver_node.assert_not_called()
def test_gancho_exige_backup_confirmado(self) -> None:
"""A operação composta não começa quando a recuperação não foi criada."""
plano = PlanoDeEdicao("video.mp4", (
AcaoDeEdicao(TipoDeAcao.GANCHO, 10.0, 15.0, "gancho"),
))
with self.assertRaisesRegex(ErroDePlanoInvalido, "não confirmou o backup"):
aplicar_plano_de_edicao._garantir_backup_para_ganchos(plano, False)
def test_corte_preserva_comportamento_legado_quando_backup_falha(self) -> None:
"""A exigência adicional fica restrita ao gancho estrutural."""
plano = PlanoDeEdicao("video.mp4", (
AcaoDeEdicao(TipoDeAcao.CORTE, 10.0, 15.0, "retirada"),
))
aplicar_plano_de_edicao._garantir_backup_para_ganchos(plano, False)
def test_painel_rejeita_corte_sem_plano_associado_ao_banco(self) -> None:
"""Não permite contornar a validação colando um corte sem transcrição vinculada."""
with tempfile.TemporaryDirectory() as pasta:
caminho_do_plano = Path(pasta) / "plano.json"
caminho_do_plano.write_text(
'{"source":"video.mp4","actions":['
'{"kind":"cut","start":1.0,"end":2.0,"reason":"teste"}]}',
encoding="utf-8",
)
with patch(
"engine.aplicar_plano_de_edicao.resolver_caminho_do_node"
) as resolver_node:
with self.assertRaisesRegex(ErroDePlanoInvalido, "Salve ou carregue"):
aplicar_plano_de_edicao.executar(
caminho_do_plano,
exigir_validacao_semantica=True,
)
resolver_node.assert_not_called()
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,74 @@
"""Testes do carregamento de planos de edição diretamente do SQLite."""
import json
import tempfile
import unittest
from pathlib import Path
from engine.carregar_plano_de_edicao import carregar
from engine.dominio import Clipe, Faixa, IntervaloDeTempo, Timeline
from engine.persistencia import abrir_banco
from engine.persistencia.repositorio_de_planos import RepositorioDePlanos, plano_de_dict
from engine.persistencia.repositorio_de_timeline_sqlite import RepositorioDeTimelineSQLite
class TesteCarregarPlanoDeEdicao(unittest.TestCase):
"""Garante que a interface receba o plano no contrato de aplicação."""
def test_carrega_plano_mais_recente_do_video(self):
with tempfile.TemporaryDirectory() as pasta:
banco = Path(pasta) / "analises.db"
conexao = abrir_banco(banco)
repositorio = RepositorioDePlanos(conexao)
plano_id = repositorio.registrar_plano(plano_de_dict(
{"source": "Sequência", "actions": [
{"kind": "cut", "start": 1, "end": 2, "reason": "repetição"},
]}, video_id="video-1", tipo_de_video="depoimento",
))
conexao.close()
carregado = carregar(banco, video_id="video-1")
self.assertEqual(carregado["plano_id"], plano_id)
self.assertEqual(carregado["video_id"], "video-1")
self.assertEqual(carregado["actions"][0]["kind"], "cut")
self.assertEqual(carregado["actions"][0]["reason"], "repetição")
def test_resolve_nome_da_sequencia_para_arquivo_unico_do_video(self):
"""Entrega ao aplicador a mídia real quando o plano histórico guardou a sequência."""
with tempfile.TemporaryDirectory() as pasta:
banco = Path(pasta) / "analises.db"
conexao = abrir_banco(banco)
RepositorioDeTimelineSQLite(conexao).registrar_timeline(Timeline(
identificador="video-1",
nome="Cópia de 0E6A8829",
faixas=[Faixa(
identificador="video_0",
nome="Vídeo 1",
tipo="video",
indice=0,
clipes=[Clipe(
identificador="clipe-1",
nome="0E6A8829.MP4",
intervalo_na_timeline=IntervaloDeTempo(0.0, 1018.2),
intervalo_na_origem=IntervaloDeTempo(0.0, 1018.2),
arquivo="/Volumes/Midia/0E6A8829.MP4",
)],
)],
))
repositorio = RepositorioDePlanos(conexao)
repositorio.registrar_plano(plano_de_dict(
{"source": "Cópia de 0E6A8829", "actions": [
{"kind": "cut", "start": 0.0, "end": 124.79, "reason": "abertura"},
]},
video_id="video-1",
))
conexao.close()
carregado = carregar(banco, video_id="video-1")
self.assertEqual(carregado["source"], "/Volumes/Midia/0E6A8829.MP4")
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,28 @@
"""Testes da configuração declarativa do Scanner, incluindo classificar_emocao."""
import unittest
from engine.scanner.configuracao_visual import ConfiguracaoVisualDoScanner
class TesteDaFlagClassificarEmocao(unittest.TestCase):
"""Garante que classificar_emocao siga o mesmo padrão de metricas_de_fala."""
def test_classificar_emocao_e_desligada_por_padrao(self):
configuracao = ConfiguracaoVisualDoScanner()
self.assertFalse(configuracao.classificar_emocao)
def test_de_dict_le_classificar_emocao_do_perfil_do_painel(self):
configuracao = ConfiguracaoVisualDoScanner.de_dict({"classificar_emocao": True})
self.assertTrue(configuracao.classificar_emocao)
def test_para_dict_serializa_classificar_emocao(self):
configuracao = ConfiguracaoVisualDoScanner(classificar_emocao=True)
self.assertTrue(configuracao.para_dict()["classificar_emocao"])
if __name__ == "__main__":
unittest.main()
@@ -15,7 +15,9 @@ from engine.dominio import Clipe, Faixa, IntervaloDeTempo, Timeline
from engine.persistencia import (
ConsultasDeAnalises,
ErroDeConsultaInvalida,
EdicaoDeVideo,
RepositorioDeAnalisesSQLite,
RepositorioDeEdicoes,
RepositorioDeRetakesSQLite,
RepositorioDeTimelineSQLite,
)
@@ -95,6 +97,24 @@ class TesteConsultasDeAnalises(unittest.TestCase):
with self.assertRaises(ErroDeConsultaInvalida):
self.consultas.consultar_resumo_do_video("")
def test_consultar_contexto_da_edicao_carrega_edicao_mais_recente_e_falas(self) -> None:
RepositorioDeEdicoes(self.consultas.conexao).registrar(EdicaoDeVideo(
video_id=VIDEO_ID,
sequencia="Sequência",
origem="/videos/entrada.mp4",
tipo_de_video="depoimento",
configuracao={"objetivo": "Gerar confiança"},
))
contexto = self.consultas.consultar_contexto_da_edicao()
self.assertIsNotNone(contexto)
self.assertEqual(contexto["edicao"]["tipo_de_video"], "depoimento")
self.assertEqual(contexto["edicao"]["configuracao"]["objetivo"], "Gerar confiança")
self.assertEqual(contexto["video"]["video_id"], VIDEO_ID)
self.assertEqual(len(contexto["falas"]), 1)
self.assertEqual(contexto["falas"][0]["texto"], "Hoje nós vamos mostrar como funciona o sistema.")
def test_consultar_status_da_analise_expoe_falantes_e_metricas(self) -> None:
status = self.consultas.consultar_status_da_analise(VIDEO_ID)
@@ -105,6 +125,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:
@@ -71,5 +71,19 @@ class TesteDividirClipeSeNecessario(unittest.TestCase):
self._dividir("razor changed the track clip count from 4 to 7, expected 5")
class TesteDuplicarTrechosVinculados(unittest.TestCase):
"""Cobre o contrato estreito da escrita do gancho editorial."""
def test_exige_exatamente_dois_identificadores(self):
escrita = EscritaNoEditor(_ClienteQueRecusa("não deveria chamar"))
with self.assertRaisesRegex(ErroDeEscritaNoEditor, "exatamente"):
escrita.duplicar_trecho_vinculado_para_o_inicio(["somente-video"])
def test_converte_falha_da_ferramenta_em_erro_de_escrita(self):
escrita = EscritaNoEditor(_ClienteQueRecusa("faixa adicional ocupada"))
with self.assertRaisesRegex(ErroDeEscritaNoEditor, "faixa adicional ocupada"):
escrita.duplicar_trecho_vinculado_para_o_inicio(["video", "audio"])
if __name__ == "__main__":
unittest.main()
@@ -5,6 +5,9 @@ import sys
from pathlib import Path
import unittest
from engine.dominio import Clipe, IntervaloDeTempo
from engine.executar_scanner import serializar_clipe_para_relatorio
class TesteDoPontoDeEntradaDoScanner(unittest.TestCase):
"""Garante que o Scanner possa ser iniciado pelo caminho usado no painel."""
@@ -41,6 +44,22 @@ class TesteDoPontoDeEntradaDoScanner(unittest.TestCase):
self.assertEqual(resultado.returncode, 0, resultado.stderr)
def test_relatorio_de_audio_preserva_caminho_e_intervalo_da_origem(self):
"""Mantém no artefato os dados necessários para aplicar o plano depois."""
clipe = Clipe(
identificador="clipe-1",
nome="0E6A8829.MP4",
intervalo_na_timeline=IntervaloDeTempo(0.0, 100.0),
intervalo_na_origem=IntervaloDeTempo(20.0, 120.0),
arquivo="/Volumes/Midia/0E6A8829.MP4",
)
dados = serializar_clipe_para_relatorio(clipe)
self.assertEqual(dados["sourceFile"], "/Volumes/Midia/0E6A8829.MP4")
self.assertEqual(dados["inPoint"], 20.0)
self.assertEqual(dados["outPoint"], 120.0)
if __name__ == "__main__":
unittest.main()
@@ -66,6 +66,50 @@ class TesteLeitorDePlanoDeEdicao(unittest.TestCase):
"actions": [{"kind": "voar", "start": 0, "end": 1, "reason": "x"}],
})
def test_le_gancho_duplicado_para_o_inicio(self):
plano = self.leitor.ler_de_dicionario({
"source": "a.mp4",
"actions": [{
"kind": "hook",
"start": 40,
"end": 46,
"reason": "gancho de abertura",
"params": {"target": 0, "keep_original": True},
}],
})
self.assertEqual(plano.acoes[0].tipo, TipoDeAcao.GANCHO)
self.assertEqual(plano.acoes[0].parametros["target"], 0)
def test_gancho_para_destino_diferente_do_inicio_e_rejeitado(self):
with self.assertRaisesRegex(ErroDePlanoInvalido, "target precisa ser 0"):
self.leitor.ler_de_dicionario({
"source": "a.mp4",
"actions": [{
"kind": "hook", "start": 40, "end": 46,
"reason": "reordenar", "params": {"target": 12},
}],
})
def test_gancho_que_remove_o_original_e_rejeitado(self):
with self.assertRaisesRegex(ErroDePlanoInvalido, "keep_original precisa ser true"):
self.leitor.ler_de_dicionario({
"source": "a.mp4",
"actions": [{
"kind": "hook", "start": 40, "end": 46,
"reason": "abertura", "params": {"keep_original": False},
}],
})
def test_corte_sobreposto_ao_gancho_e_rejeitado(self):
with self.assertRaisesRegex(ErroDePlanoInvalido, "não pode sobrepor"):
self.leitor.ler_de_dicionario({
"source": "a.mp4",
"actions": [
{"kind": "cut", "start": 42, "end": 44, "reason": "cortar"},
{"kind": "hook", "start": 40, "end": 46, "reason": "abertura"},
],
})
def test_fim_menor_ou_igual_ao_inicio_levanta_erro(self):
with self.assertRaises(ErroDePlanoInvalido):
self.leitor.ler_de_dicionario({
@@ -0,0 +1,68 @@
"""Testes da leitura de falas usada antes da aplicação de um plano."""
import tempfile
import unittest
from pathlib import Path
from engine.editor import ErroDePlanoInvalido
from engine.persistencia import LeitorDeTranscricaoDoPlano, abrir_banco
class TesteLeitorDeTranscricaoDoPlano(unittest.TestCase):
"""Valida vínculo do plano e conversão de timeline para mídia original."""
def setUp(self) -> None:
"""Cria um banco isolado com um clipe deslocado na timeline."""
self._pasta_temporaria = tempfile.TemporaryDirectory()
self.addCleanup(self._pasta_temporaria.cleanup)
caminho_do_banco = Path(self._pasta_temporaria.name) / "analises.db"
self.conexao = abrir_banco(caminho_do_banco)
self.addCleanup(self.conexao.close)
with self.conexao:
self.conexao.execute(
"INSERT INTO videos (id, nome, duracao) VALUES ('video-1', 'Vídeo', 40.0)"
)
self.conexao.execute(
"""INSERT INTO faixas (id, video_id, nome, tipo, indice)
VALUES ('audio-1', 'video-1', 'A1', 'audio', 0)"""
)
self.conexao.execute(
"""INSERT INTO clipes
(id, video_id, faixa_id, nome, inicio_na_timeline, fim_na_timeline,
inicio_na_origem, fim_na_origem, arquivo, offline, metadados)
VALUES ('clipe-1', 'video-1', 'audio-1', 'Clipe', 10.0, 30.0,
2.0, 22.0, '/video.mp4', 0, '{}')"""
)
segmento = self.conexao.execute(
"""INSERT INTO segmentos_de_transcricao
(video_id, clipe_id, inicio, fim, texto)
VALUES ('video-1', 'clipe-1', 12.0, 14.0, 'Frase completa.')"""
)
self.conexao.execute(
"""INSERT INTO palavras_de_transcricao
(segmento_id, ordem, texto, inicio, fim)
VALUES (?, 0, 'Frase', 12.0, 12.5)""",
(segmento.lastrowid,),
)
self.conexao.execute(
"""INSERT INTO planos_de_edicao (video_id, origem)
VALUES ('video-1', '/video.mp4')"""
)
def test_converte_tempos_para_a_origem_do_clipe(self) -> None:
"""Subtrai a posição da timeline e soma o início na mídia original."""
falas = LeitorDeTranscricaoDoPlano(self.conexao).carregar(1)
self.assertEqual(falas[0].inicio, 4.0)
self.assertEqual(falas[0].fim, 6.0)
self.assertEqual(falas[0].palavras[0].inicio, 4.0)
self.assertEqual(falas[0].palavras[0].fim, 4.5)
def test_rejeita_plano_inexistente(self) -> None:
"""Evita validar uma transcrição que não pertence ao plano solicitado."""
with self.assertRaisesRegex(ErroDePlanoInvalido, "não existe"):
LeitorDeTranscricaoDoPlano(self.conexao).carregar(999)
if __name__ == "__main__":
unittest.main()
@@ -8,7 +8,9 @@ from pathlib import Path
from engine.dominio import Clipe, Faixa, IntervaloDeTempo, Timeline
from engine.integracoes.midia.extracao_de_metadados import MetadadosDoArquivo
from engine.persistencia import (
EdicaoDeVideo,
RepositorioDeAnalisesSQLite,
RepositorioDeEdicoes,
RepositorioDeRetakesSQLite,
RepositorioDeTimelineSQLite,
abrir_banco,
@@ -23,6 +25,7 @@ TABELAS_ESPERADAS = {
"segmentos_de_transcricao", "palavras_de_transcricao",
"evidencias_visuais", "cenas", "grupos_de_retake",
"tomadas_de_retake", "evidencias_de_retake",
"edicoes_de_video",
}
@@ -129,6 +132,27 @@ class TesteRepositorioDeAnalisesSQLite(unittest.TestCase):
self.assertEqual(linha["largura"], 1920)
self.assertEqual(linha["hash_do_conteudo"], "hash123")
class TesteRepositorioDeEdicoes(unittest.TestCase):
def test_registrar_configuracao_editorial_preserva_dados(self):
with tempfile.TemporaryDirectory() as pasta:
conexao = abrir_banco(Path(pasta) / "analises.db")
repositorio = RepositorioDeEdicoes(conexao)
identificador = repositorio.registrar(EdicaoDeVideo(
video_id="sequencia-1",
sequencia="Depoimento",
origem="/videos/depoimento.mp4",
tipo_de_video="depoimento",
configuracao={"legendas": {"habilitadas": True}},
))
linha = conexao.execute(
"SELECT * FROM edicoes_de_video WHERE id = ?", (identificador,),
).fetchone()
self.assertEqual(linha["video_id"], "sequencia-1")
self.assertEqual(linha["tipo_de_video"], "depoimento")
self.assertIn("habilitadas", linha["configuracao"])
def test_registrar_e_carregar_transcricoes_com_palavras(self):
with tempfile.TemporaryDirectory() as pasta:
repositorio = RepositorioDeAnalisesSQLite(Path(pasta) / "analises.db")
@@ -0,0 +1,43 @@
"""Testes da entrada de planos de edição no banco SQLite."""
import json
import tempfile
import unittest
from pathlib import Path
from engine.persistencia import EdicaoDeVideo, RepositorioDeEdicoes, abrir_banco
from engine.registrar_plano_de_edicao import registrar
class TesteRegistrarPlanoDeEdicao(unittest.TestCase):
"""Garante que o plano é associado ao contexto editorial mais recente."""
def test_registra_plano_e_descobre_video_pela_origem(self):
with tempfile.TemporaryDirectory() as pasta:
banco = Path(pasta) / "analises.db"
conexao = abrir_banco(banco)
RepositorioDeEdicoes(conexao).registrar(EdicaoDeVideo(
video_id="video-1", sequencia="Depoimento", origem="/v.mp4",
tipo_de_video="depoimento", configuracao={"legendas": True},
))
conexao.close()
arquivo = Path(pasta) / "plano.json"
arquivo.write_text(json.dumps({
"source": "/v.mp4",
"actions": [{"kind": "cut", "start": 1, "end": 2}],
}), encoding="utf-8")
plano_id = registrar(arquivo, banco, tipo_de_video="depoimento")
conexao = abrir_banco(banco)
linha = conexao.execute(
"SELECT video_id, tipo_de_video FROM planos_de_edicao WHERE id = ?",
(plano_id,),
).fetchone()
conexao.close()
self.assertEqual(dict(linha), {"video_id": "video-1", "tipo_de_video": "depoimento"})
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,31 @@
"""Testes da persistência de projetos com uma sequência e várias seleções."""
import tempfile
import unittest
from pathlib import Path
from engine.persistencia import ProjetoDeEdicao, RepositorioDeProjetos, abrir_banco
class TesteRepositorioDeProjetos(unittest.TestCase):
"""Garante a regra de uma sequência por projeto."""
def test_registra_um_projeto_com_varias_faixas_e_clipes(self):
with tempfile.TemporaryDirectory() as pasta:
conexao = abrir_banco(Path(pasta) / "analises.db")
conexao.execute("INSERT INTO videos (id, nome) VALUES ('video-1', 'Sequência')")
repositorio = RepositorioDeProjetos(conexao)
projeto_id = repositorio.registrar(ProjetoDeEdicao(
nome="Projeto depoimento", video_id="video-1",
faixas=("video_0", "audio_0"), clipes=("clipe-v", "clipe-a"),
))
projeto = repositorio.carregar(projeto_id)
conexao.close()
self.assertEqual(projeto["video_id"], "video-1")
self.assertEqual(projeto["faixas"], ["video_0", "audio_0"])
self.assertEqual(projeto["clipes"], ["clipe-v", "clipe-a"])
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,80 @@
"""Testes do catálogo independente de trilhas musicais."""
import tempfile
import unittest
from pathlib import Path
from engine.integracoes.audio.modelos_de_analise_musical import (
PontuacaoDeEtiqueta,
ResultadoDaAnaliseMusical,
)
from engine.persistencia import RepositorioDeTrilhasSQLite
class TesteRepositorioDeTrilhasSQLite(unittest.TestCase):
"""Garante o ciclo de vida e a persistência da análise musical."""
def test_registra_pasta_atualiza_status_e_salva_etiquetas(self):
with tempfile.TemporaryDirectory() as diretorio:
pasta = Path(diretorio) / "trilhas"
pasta.mkdir()
arquivo = pasta / "tema.wav"
arquivo.write_bytes(b"audio de teste")
repositorio = RepositorioDeTrilhasSQLite(Path(diretorio) / "trilhas.db")
trilhas = repositorio.registrar_pasta_e_arquivos(pasta, (arquivo,))
repositorio.iniciar_analise(trilhas[0].identificador)
repositorio.registrar_analise(
trilhas[0],
ResultadoDaAnaliseMusical(
caminho_do_arquivo=arquivo,
duracao_em_segundos=12.5,
generos=("Blues",),
pontuacoes_de_genero=(PontuacaoDeEtiqueta("Blues", 0.91),),
),
)
banco = repositorio.conexao
trilha = banco.execute("SELECT status, hash_do_conteudo FROM trilhas_musicais").fetchone()
etiqueta = banco.execute("SELECT tipo, etiqueta, confianca FROM etiquetas_musicais").fetchone()
banco.close()
self.assertEqual(trilha[0], "concluida")
self.assertEqual(len(trilha[1]), 64)
self.assertEqual(tuple(etiqueta), ("genero", "Blues", 0.91))
def test_reprocessamento_da_mesma_pasta_nao_duplica_trilha(self):
with tempfile.TemporaryDirectory() as diretorio:
pasta = Path(diretorio) / "trilhas"
pasta.mkdir()
arquivo = pasta / "tema.mp3"
arquivo.write_bytes(b"audio")
repositorio = RepositorioDeTrilhasSQLite(Path(diretorio) / "trilhas.db")
primeira = repositorio.registrar_pasta_e_arquivos(pasta, (arquivo,))
segunda = repositorio.registrar_pasta_e_arquivos(pasta, (arquivo,))
quantidade = repositorio.conexao.execute("SELECT COUNT(*) FROM trilhas_musicais").fetchone()[0]
repositorio.conexao.close()
self.assertEqual(primeira[0].identificador, segunda[0].identificador)
self.assertEqual(quantidade, 1)
def test_arquivo_alterado_volta_para_aguardando(self):
with tempfile.TemporaryDirectory() as diretorio:
pasta = Path(diretorio) / "trilhas"
pasta.mkdir()
arquivo = pasta / "tema.wav"
arquivo.write_bytes(b"audio")
repositorio = RepositorioDeTrilhasSQLite(Path(diretorio) / "trilhas.db")
trilha = repositorio.registrar_pasta_e_arquivos(pasta, (arquivo,))[0]
repositorio.iniciar_analise(trilha.identificador)
repositorio.registrar_analise(trilha, ResultadoDaAnaliseMusical(caminho_do_arquivo=arquivo))
arquivo.write_bytes(b"audio alterado")
atualizada = repositorio.registrar_pasta_e_arquivos(pasta, (arquivo,))[0]
repositorio.conexao.close()
self.assertEqual(atualizada.status, "aguardando")
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,149 @@
"""Testes de regressão da integridade semântica das emendas editoriais."""
import unittest
from engine.editor import AcaoDeEdicao, PlanoDeEdicao, TipoDeAcao
from engine.editor.validacao_semantica import ValidadorSemanticoDePlano
from engine.scanner.modelos import PalavraDeTranscricao, SegmentoDeTranscricao
class TesteValidadorSemanticoDePlano(unittest.TestCase):
"""Impede cortes de palavra, frases incompletas e ganchos truncados."""
def setUp(self) -> None:
"""Cria o validador com a margem segura usada pela aplicação real."""
self.validador = ValidadorSemanticoDePlano(margem_minima_em_segundos=0.3)
def test_rejeita_caso_real_que_resultou_em_trazer_uma_seguranca_cortado(self) -> None:
"""Reproduz as duas bordas problemáticas do último clipe aplicado."""
falas = (
SegmentoDeTranscricao(
inicio=906.15,
fim=934.63,
texto="Eu acho que hoje, além de uma técnica boa, é você...",
palavras=(
PalavraDeTranscricao("você...", 934.10, 934.63),
),
),
SegmentoDeTranscricao(
inicio=934.65,
fim=962.60,
texto=(
"conseguir achar um profissional que te passe confiança. "
"Então é trazer uma segurança."
),
palavras=(
PalavraDeTranscricao("conseguir", 934.65, 935.20),
PalavraDeTranscricao("Então", 959.05, 959.55),
PalavraDeTranscricao("é", 959.55, 960.45),
PalavraDeTranscricao("trazer", 960.45, 961.81),
PalavraDeTranscricao("uma", 961.94, 962.20),
PalavraDeTranscricao("segurança.", 962.20, 962.60),
),
),
SegmentoDeTranscricao(
inicio=963.10,
fim=970.0,
texto="Eu recomendaria esse profissional.",
palavras=(PalavraDeTranscricao("Eu", 963.10, 963.30),),
),
)
plano = PlanoDeEdicao(
"0E6A8829.MP4",
(
AcaoDeEdicao(TipoDeAcao.CORTE, 875.50, 934.65, "Remove trecho anterior."),
AcaoDeEdicao(TipoDeAcao.CORTE, 962.60, 1018.22, "Encerra após a conclusão."),
),
)
problemas = self.validador.validar(plano, falas)
codigos = {problema.codigo for problema in problemas}
self.assertIn("retomada_no_meio_da_frase", codigos)
self.assertIn("margem_antes_do_corte_insuficiente", codigos)
def test_aceita_frases_completas_com_respiro_nas_duas_bordas(self) -> None:
"""Aceita a remoção posicionada no silêncio entre frases completas."""
falas = (
SegmentoDeTranscricao(
inicio=0.0,
fim=4.0,
texto="Esta é uma frase completa.",
palavras=(PalavraDeTranscricao("completa.", 3.4, 4.0),),
),
SegmentoDeTranscricao(
inicio=8.0,
fim=12.0,
texto="Esta é a próxima frase.",
palavras=(PalavraDeTranscricao("Esta", 8.0, 8.4),),
),
)
plano = PlanoDeEdicao(
"video.mp4",
(AcaoDeEdicao(TipoDeAcao.CORTE, 4.30, 7.70, "Remove silêncio longo."),),
)
self.assertEqual(self.validador.validar(plano, falas), ())
def test_rejeita_borda_que_intercepta_uma_palavra(self) -> None:
"""Distingue palavra realmente cortada de simples ausência de margem."""
falas = (
SegmentoDeTranscricao(
inicio=0.0,
fim=3.0,
texto="Vamos trazer segurança.",
palavras=(PalavraDeTranscricao("segurança.", 2.0, 3.0),),
),
)
plano = PlanoDeEdicao(
"video.mp4",
(AcaoDeEdicao(TipoDeAcao.CORTE, 2.5, 5.0, "Corte inválido."),),
)
problemas = self.validador.validar(plano, falas)
self.assertIn("palavra_interceptada", {problema.codigo for problema in problemas})
def test_aceita_gancho_de_frase_completa_com_margens(self) -> None:
falas = (
SegmentoDeTranscricao(0.0, 4.0, "Antes.", palavras=(
PalavraDeTranscricao("Antes.", 3.4, 4.0),
)),
SegmentoDeTranscricao(10.4, 13.5, "Gancho completo.", palavras=(
PalavraDeTranscricao("Gancho", 10.4, 10.9),
PalavraDeTranscricao("completo.", 13.0, 13.5),
)),
SegmentoDeTranscricao(20.0, 21.0, "Depois.", palavras=(
PalavraDeTranscricao("Depois.", 20.0, 21.0),
)),
)
plano = PlanoDeEdicao("video.mp4", (
AcaoDeEdicao(TipoDeAcao.GANCHO, 10.1, 13.8, "gancho"),
))
self.assertEqual(self.validador.validar(plano, falas), ())
def test_rejeita_gancho_que_termina_com_frase_truncada(self) -> None:
falas = (
SegmentoDeTranscricao(0.0, 4.0, "Antes.", palavras=(
PalavraDeTranscricao("Antes.", 3.4, 4.0),
)),
SegmentoDeTranscricao(10.4, 13.5, "Trazer uma seguran", palavras=(
PalavraDeTranscricao("Trazer", 10.4, 10.9),
PalavraDeTranscricao("seguran", 13.0, 13.5),
)),
SegmentoDeTranscricao(20.0, 21.0, "Depois.", palavras=(
PalavraDeTranscricao("Depois.", 20.0, 21.0),
)),
)
plano = PlanoDeEdicao("video.mp4", (
AcaoDeEdicao(TipoDeAcao.GANCHO, 10.1, 13.8, "gancho"),
))
problemas = self.validador.validar(plano, falas)
self.assertIn("gancho_termina_no_meio_da_frase", {problema.codigo for problema in problemas})
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,72 @@
"""Testes da leitura estrutural depois da aplicação do plano."""
import unittest
from engine.dominio import Clipe, Faixa, IntervaloDeTempo, Timeline
from engine.editor.modelos import AcaoDeEdicao, PlanoDeEdicao, TipoDeAcao
from engine.editor.verificador_da_aplicacao import VerificadorDaAplicacao
class TesteVerificadorDaAplicacao(unittest.TestCase):
"""Garante que sobras e lacunas na timeline sejam detectadas."""
def _timeline(self, fim_do_ultimo_clipe: float) -> Timeline:
"""Monta uma timeline simples com vídeo e áudio vinculados por intervalo."""
faixas = []
for tipo in ("video", "audio"):
faixas.append(Faixa(
f"{tipo}_0", tipo, tipo, 0, [Clipe(
f"clipe_{tipo}", "fonte.mp4",
IntervaloDeTempo(0.0, fim_do_ultimo_clipe),
IntervaloDeTempo(0.0, fim_do_ultimo_clipe),
)]
))
return Timeline("sequencia", "Sequência", 100.0, faixas=faixas)
def test_aprova_cobertura_exata_depois_do_corte(self):
"""Aprova quando os dois tipos de faixa preservam exatamente o esperado."""
antes = self._timeline(100.0)
depois = Timeline("sequencia", "Sequência", 90.0, faixas=[
Faixa("video_0", "video", "video", 0, [
Clipe("v1", "fonte.mp4", IntervaloDeTempo(0, 10), IntervaloDeTempo(0, 10)),
Clipe("v2", "fonte.mp4", IntervaloDeTempo(10, 90), IntervaloDeTempo(20, 100)),
]),
Faixa("audio_0", "audio", "audio", 0, [
Clipe("a1", "fonte.mp4", IntervaloDeTempo(0, 10), IntervaloDeTempo(0, 10)),
Clipe("a2", "fonte.mp4", IntervaloDeTempo(10, 90), IntervaloDeTempo(20, 100)),
]),
])
plano = PlanoDeEdicao("fonte.mp4", (
AcaoDeEdicao(TipoDeAcao.CORTE, 10, 20, "remover trecho"),
))
resultado = VerificadorDaAplicacao().verificar(antes, depois, plano)
self.assertTrue(resultado.aprovada)
self.assertTrue(resultado.avisos)
def test_reprova_quando_sobra_um_trecho_na_timeline(self):
"""Reprova quando o Premiere deixa uma sobra além do plano."""
antes = self._timeline(100.0)
depois = Timeline("sequencia", "Sequência", 91.0, faixas=[
Faixa("video_0", "video", "video", 0, [
Clipe("v1", "fonte.mp4", IntervaloDeTempo(0, 10), IntervaloDeTempo(0, 10)),
Clipe("v2", "fonte.mp4", IntervaloDeTempo(10, 91), IntervaloDeTempo(20, 101)),
]),
Faixa("audio_0", "audio", "audio", 0, [
Clipe("a1", "fonte.mp4", IntervaloDeTempo(0, 10), IntervaloDeTempo(0, 10)),
Clipe("a2", "fonte.mp4", IntervaloDeTempo(10, 91), IntervaloDeTempo(20, 101)),
]),
])
plano = PlanoDeEdicao("fonte.mp4", (
AcaoDeEdicao(TipoDeAcao.CORTE, 10, 20, "remover trecho"),
))
resultado = VerificadorDaAplicacao().verificar(antes, depois, plano)
self.assertFalse(resultado.aprovada)
self.assertIn("esperado", resultado.mensagem)
if __name__ == "__main__":
unittest.main()
@@ -1,6 +1,6 @@
---
name: edit-video-by-voice
description: Analyze a video voice timeline JSON and produce a validated, executable JSON edit plan. Use when the user asks to edit a video from speech, transcript, repeated takes, spoken emphasis, or editorial selections. This skill decides editorial actions; a separate video editor executes them.
description: Analyze an attached or pasted voice-timeline JSON and produce a portable edit plan outside the Jhonny persisted workflow. Use when no Jhonny SQLite editing context exists. Do not use for the MCP for Adobe Premiere Pro panel; use editar-por-voz there.
---
# Edit video by voice
@@ -0,0 +1,161 @@
---
name: editar-por-voz
description: Skill canônica para editar por voz dentro do projeto Jhonny e do painel MCP for Adobe Premiere Pro, usando a transcrição e a configuração editorial do SQLite. Use neste projeto para escolher tomadas, limpar repetições ou montar o corte automático. Não use as skills portáteis edit-video-by-voice ou transcript-to-edit-actions neste fluxo persistido.
---
# Editar por voz
Transforma uma edição concluída no banco em um plano editorial revisável e, após aprovação, em uma alteração verificável no Premiere Pro.
## Fonte de verdade
O banco de análises é a fonte oficial. Não procure nem exija um arquivo
`*_voice_timeline.json` e não trate JSON temporário, cache ou estado do painel
como fonte independente.
Para carregar o contexto sem escrever SQL, execute:
```text
python3 code/engine/preparar_edicao_por_voz.py --banco .jhonny/analises.db
```
Depois de gerar o JSON de ações, registre-o no banco e use o identificador
retornado para aplicar exatamente esse plano:
```bash
python3 code/engine/registrar_plano_de_edicao.py plano.json \
--banco .jhonny/analises.db --tipo-de-video depoimento
python3 code/engine/aplicar_plano_de_edicao.py plano.json \
--banco .jhonny/analises.db --plano-id ID_RETORNADO
```
Use `--video-id` quando o usuário indicar um vídeo específico e
`--sem-palavras` somente quando os limites palavra a palavra não forem
necessários. A resposta válida vem em `contexto`; trate `ok: false` como
bloqueio do fluxo.
Use a edição concluída mais recente em `edicoes_de_video` como raiz do trabalho.
Recupere o restante pelo mesmo `video_id`:
- `videos`, `faixas` e `clipes`: mídia, sequência e estrutura;
- `analises_versao`: versão da análise usada;
- `segmentos_de_transcricao` e `palavras_de_transcricao`: falas e tempos;
- `evidencias_visuais`: contexto visual, quando disponível;
- `planos_de_edicao` e `acoes_do_plano`: decisões já geradas;
- `aplicacoes_do_plano`: histórico de execução.
Leia [criterios/00-fonte-de-dados.md](criterios/00-fonte-de-dados.md) antes de
consultar o banco ou quando houver dúvida sobre qual registro usar.
## Estados obrigatórios
Trate o trabalho como uma sequência de estados:
```text
edicao_concluida
→ contexto_validado
→ plano_gerado
→ preview_aprovado
→ aplicado
→ verificado
```
Não aplique um plano sem contexto validado e preview aprovado. Se algum estado
ou evidência obrigatória estiver ausente, pare e relate exatamente o que falta.
## Fluxo
1. Localize a edição concluída mais recente e confirme vídeo, sequência, tipo,
origem, transcrição e versão da análise.
2. Reúna do banco a transcrição completa, palavras, falantes, tomadas,
métricas e evidências disponíveis. Preserve os tempos da mídia original.
3. Leia a configuração editorial salva em `edicoes_de_video.configuracao` e
extraia objetivo, narrativa, duração, tom, regras de corte e restrições.
4. Separe roteiro de conversa de bastidor pelo conteúdo. Depois agrupe frases
repetidas e escolha a tomada completa, clara, natural e coerente.
5. Reanalise as ênfases apenas depois de definir o material sobrevivente. Se o
banco não tiver métricas ou a camada correspondente estiver incompleta,
decida pelo texto e registre a limitação.
6. Decida cortes, ganchos, zooms, textos e marcadores somente quando o contrato de
aplicação suportar a ação. Cada ação deve ter motivo verificável.
7. Antes de salvar, reconstrua o texto que sobreviverá a cada emenda. Um
segmento do Whisper não equivale a uma frase: una segmentos adjacentes
sempre que a gramática continuar. Exija frase completa e ao menos 0,30s de
respiro junto a cada palavra mantida. Se uma borda interceptar palavra,
deixar a fala sem fechamento ou retomar em continuação minúscula, corrija
o plano; nunca delegue esse defeito à aplicação.
8. Gere um plano com tempos na mídia original. Salve o plano em
`planos_de_edicao` e suas ações ordenadas em `acoes_do_plano`, associado ao
`video_id`, ao tipo de vídeo e ao contexto editorial carregado.
9. Faça preview do texto sobrevivente, lendo em sequência a última frase antes
e a primeira frase depois de cada corte. Para `hook`, apresente a frase
completa que abrirá o vídeo e a primeira frase original que virá em seguida.
Uma alteração no plano exige novo preview.
10. Após aprovação explícita, confirme a sequência atual, crie backup ou
duplicata, aplique o plano pelo fluxo suportado do Premiere e registre o
resultado em `aplicacoes_do_plano`.
11. Reconsulte a timeline, compare com o plano e registre a verificação. Separe
o que foi comprovado automaticamente do que exige avaliação visual.
Essa releitura pós-aplicação é obrigatória: uma aplicação só pode ser
reportada como bem-sucedida quando a cobertura de origem em vídeo e áudio
corresponder ao complemento dos cortes do plano. Sobras, lacunas ou
desalinhamentos devem reprovar a verificação e ficar registrados no histórico.
## Contrato da decisão
O JSON abaixo é uma representação de trabalho, não a fonte principal:
```json
{
"source": "0E6A8829.MP4",
"actions": [
{
"kind": "cut",
"start": 12.4,
"end": 16.8,
"reason": "Repetição da frase anterior."
}
]
}
```
`cut` remove um intervalo. `hook` duplica um único trecho vinculado em `0s` e
mantém a fala na posição original; use somente
`params: {"target": 0, "keep_original": true}`. Todos os tempos referem-se à
mídia original. A ação precisa ter `start < end`, estar dentro da duração e
não sobrepor outra ação incompatível. Use `reason` para registrar a fala,
comparação ou evidência que fundamentou a decisão.
Leia, conforme a etapa, [criterios/02-triagem-roteiro-vs-conversa.md](criterios/02-triagem-roteiro-vs-conversa.md),
[criterios/03-escolha-da-melhor-tomada.md](criterios/03-escolha-da-melhor-tomada.md),
[criterios/04-reanalise-do-material-restante.md](criterios/04-reanalise-do-material-restante.md),
[criterios/05-zoom.md](criterios/05-zoom.md),
[criterios/06-texto-corte-marcador.md](criterios/06-texto-corte-marcador.md),
[criterios/07-ritmo.md](criterios/07-ritmo.md) e
[criterios/10-revisao-humana.md](criterios/10-revisao-humana.md). Quando o
pedido envolver gancho ou reordenação, leia também
[criterios/11-gancho.md](criterios/11-gancho.md).
## Limites
- A skill decide a edição; o Premiere executa.
- Nunca invente falas, identidades, timecodes ou evidências.
- Não altere o áudio original, a transcrição ou a análise para forçar uma
decisão.
- Nunca use o começo ou o fim de um segmento de transcrição como borda só
porque o timestamp já existe. A unidade editorial é a frase completa.
- Não declare uma edição concluída com base apenas em um JSON, preview ou
retorno de ferramenta; exija aplicação e verificação.
- Não execute cortes destrutivos sem aprovação e backup/duplicata.
## Relato
Relate sempre em português:
- edição, vídeo e versão da análise usados;
- tomadas encontradas e escolhidas, com motivo;
- falas descartadas como bastidor ou repetição;
- cortes, ganchos, zooms, textos e marcadores propostos;
- decisões rejeitadas ou ambíguas;
- plano salvo, aplicação realizada e verificação pendente.
@@ -0,0 +1,35 @@
# 00 — Fonte de dados: banco de análises
O banco SQLite do projeto é a fonte oficial da edição por voz. Arquivos JSON
podem existir como origem, cache ou exportação, mas não substituem os registros
persistidos.
## Raiz do trabalho
1. Localize a edição mais recente em `edicoes_de_video`.
2. Use seu `video_id` para consultar a análise, a transcrição, os clipes e as
evidências.
3. Use `edicoes_de_video.configuracao` como briefing editorial salvo.
4. Considere a edição duplicada ou antiga somente se o usuário escolher uma
edição diferente.
## Validações mínimas
Antes de decidir cortes, confirme que existem:
- vídeo e sequência identificáveis;
- pelo menos um clipe com mídia e duração;
- segmentos de transcrição com início, fim e texto;
- configuração editorial com tipo e objetivo;
- versão ou data que permita identificar a análise usada.
Falantes, palavras, métricas e evidências visuais são camadas adicionais. Se
uma camada não existir, reduza a confiança da decisão e registre a limitação;
não fabrique valores.
## Persistência do plano
O plano deve ser criado em `planos_de_edicao`, e cada ação em
`acoes_do_plano`. A aplicação deve ser registrada em `aplicacoes_do_plano`.
O JSON pode ser usado durante a revisão, mas o banco deve conservar a decisão
que será executada.
@@ -0,0 +1,85 @@
# 01 — Leitura das evidências do banco
> **Escopo:** Como ler as evidências persistidas no banco, sem recalcular o que já foi medido.
> **Quando:** Fase 1 — ver a ordem de trabalho em `../SKILL.md`.
O registro da edição e as tabelas relacionadas no banco são a entrada de todo o
trabalho. Um JSON exportado pode ser usado como visão de trabalho, mas deve ser
reconciliado com o `video_id` e a versão da análise antes de qualquer decisão.
Leia em camadas, de cima para baixo, e só desça quando precisar.
## Camadas
| Camada | O que traz | Para quê |
|---|---|---|
| `analises_versao` | o que de fato rodou e qual versão está válida | **leia primeiro** — ver `09-analise-incompleta.md` |
| `summary` | forma da peça, `peak_moments`, contagens | visão geral em poucos números |
| `segmentos_de_transcricao` | cada fala com seus agregados | **onde você mais trabalha** |
| `palavras_de_transcricao` | detalhe por palavra | achar o instante exato de um destaque |
| `evidencias_visuais` | observações visuais por intervalo | apoiar a decisão quando disponível |
| `edicoes_de_video.configuracao` | objetivo e regras do vídeo | calibrar a seleção editorial |
## Campos que decidem quase tudo
**`gap_before`** — silêncio antes da fala, em segundos. É o mapa estrutural
da gravação: acima de ~3s (`take_boundary: true`) a câmera parou ou a
tomada recomeçou. Num material real de 3min17s isso identificou 6
fronteiras, todas exatamente onde a pessoa recomeçava o roteiro.
**`take_boundary`** — booleano derivado do `gap_before`. Use para agrupar
tomadas.
**`emphasis`** (0–1) — índice combinado de energia, variação de tom,
variação de ritmo, pausa anterior e duração. **É relativo ao material
analisado**, nunca uma medida absoluta. Ver `02-enfase-e-reanalise.md`.
**`energy`** (0–1) — intensidade relativa ao trecho mais alto da gravação.
**`pitch_delta`** (0–1) — quanto o tom se afasta da média do falante.
**`peak_emphasis`** e **`avg_energy`** (por segmento) — permitem julgar uma
frase inteira sem ler palavra por palavra. É por aqui que você avalia o
arco narrativo.
**`energy_raw`** e **`pitch_hz`** — valores brutos, sem normalização. Não
use para decidir; existem para permitir a reanálise da Fase 2.
**`emocao`** e **`confianca_emocao`** — classificação de emoção da fala
(quando disponível). São **opcionais e complementares**: dependem de um
recurso que precisa estar ligado na análise, então ficam vazios em boa
parte dos vídeos. Nunca decidem sozinhos — servem só de reforço/desempate
entre candidatos já elegíveis por `emphasis`/relevância narrativa. Ver
`05-zoom.md`.
## O que NÃO fazer
- **Não recalcule** energia, tom ou ênfase. O sistema mede melhor e de
forma reprodutível.
- **Não reestime tempos "no olho".** Use os timestamps do JSON.
- **Não trate `emphasis` como valor absoluto.** Um 0,35 pode ser o pico de
uma gravação e ruído em outra.
## O timestamp por palavra tem um viés conhecido
O início de cada palavra vem sistematicamente **adiantado em ~0,3-0,5s** em
relação ao ataque real da fala — medido em material real com ffmpeg (`astats`),
consistente em 6 pontos do mesmo vídeo. O fim da palavra não tem esse problema
(erro de poucos centésimos). Causa: `word_timestamps` do faster-whisper deriva
por atenção cruzada, sem alinhamento forçado — ver `05_EXPERIENCIAS.md`, entrada
de 2026-08-19.
**Quando o pipeline já corrigiu isso:** se `layers.alignment` for `true`
(transcript gerado com alinhamento forçado fonético via whisperx, implementado
depois desse aviso), o viés foi removido na origem — **não aplique o offset
manual** abaixo. O aviso vale só para transcripts antigos sem `layers.alignment`.
Isso não é "reestimar no olho" — é um bug de medição na fonte, não um
julgamento seu. Na prática (somente sem `layers.alignment`):
- Ao posicionar um `zoom` cujo `start` precisa cair exatamente na palavra
(não uma frase inteira), some **+0,3 a +0,4s** ao timestamp do JSON antes
de decidir, ou confira com `ffmpeg -af astats` se a precisão importar
para o frame.
- **Não aplique essa correção a `gap_before` para decidir corte** — a régua
de silêncio (`06-texto-corte-marcador.md`) já é conservadora o bastante
para absorver esse erro; corrigir os dois ao mesmo tempo é redundante.
@@ -0,0 +1,54 @@
# 02 — Triagem: roteiro vs. conversa de bastidor
> **Escopo:** Separar o texto do roteiro da conversa de bastidor — tarefa de texto, nunca de limiar.
> **Quando:** Fase 2 — ver a ordem de trabalho em `../SKILL.md`.
**Primeira coisa a fazer, antes de qualquer decisão de efeito.**
Material bruto de gravação quase nunca é uma tomada só. A pessoa lê o
roteiro, erra, conversa com a equipe e recomeça.
## Por que isso é tarefa sua, e não do sistema
No áudio essa separação é **invisível** — e pior: o índice de ênfase
*favorece* a conversa, que é mais solta e mais alta que o texto decorado.
Caso real: a fala mais enfática de um vídeo inteiro (energia **1,00**, o
topo absoluto da gravação) era *"Amor, eu tô intacto!"*, dita para o marido
fora de quadro. Três das sete palavras de maior ênfase do vídeo vinham
dessa única frase de bastidor.
Nenhum limiar acústico separa isso. O **texto** separa sem erro.
> Atenção: isso também **não é diarização**. Num caso real, a pessoa da
> equipe estava fora do microfone — a diarização a ouvia, mas o Whisper não
> a transcrevia. As falas a descartar eram da **própria protagonista**:
> mesma voz, contexto diferente. "Quem fala" e "isso é tomada válida" são
> perguntas diferentes.
## Descartar — conversa com a equipe
Reconhece-se pelo **conteúdo**:
- **vocativo para alguém da sala** — *"Amor, eu tô intacto!"*
- **pergunta operacional** — *"Posso começar da mastopexia?"*,
*"E aí, continua?"*, *"Mas eu vou ter que falar tudo de novo?"*
- **instrução técnica** — *"Só clica aí agora na tela."*, *"Aumenta."*
- **comentário sobre a própria gravação** — *"Vou falar só a última frase,
só um pouquinho, não pegou?"*
## Descartar — frases interrompidas
Texto que morre no meio, tipicamente em reticências ou emendando numa
pergunta:
- *"E tudo isso associado à medida..."*
- *"Aquela mama com um formato mais estruturado, com o colo que..."*
- *"de pele..."*
## Sinais estruturais que ajudam
Use `take_boundary` para achar onde cada tomada recomeça. Num material
real, as fronteiras (gaps de 3,6s a 19,8s) caíam exatamente nos pontos onde
a médica reiniciava o roteiro — inclusive nas duas retomadas da frase de
abertura.
@@ -0,0 +1,63 @@
# 03 — Escolha da melhor tomada
> **Escopo:** Qual tomada de cada frase sobrevive, e o que fazer em caso de empate.
> **Quando:** Fase 3 — ver a ordem de trabalho em `../SKILL.md`.
A mesma frase costuma aparecer 2, 3, 4 vezes. Seu trabalho é ficar com
**uma**.
## Como agrupar
1. Use `take_boundary` para localizar onde cada tomada recomeça.
2. Agrupe as repetições **pelo texto**, não pelo tempo — a mesma frase
reaparece em pontos distantes da gravação. Num caso real, a abertura
*"Aquela mama com um formato mais estruturado"* apareceu aos 2,0s, 64,9s
e 86,4s.
## Critérios, nesta ordem
### 1. Completa
Não morre no meio, não emenda numa pergunta. Uma tomada incompleta está
descartada por definição, mesmo que a dicção seja ótima.
### 2. Dicção limpa
Sem tropeço, sem repetição de palavra, sem vício de linguagem. **Compare os
textos lado a lado:**
| Tomada 1 | Tomada 3 | Escolha |
|---|---|---|
| *"isso é desejo de muitas mulheres"* | *"**aí** isso é desejo de muitas mulheres"* | Tomada 1 |
### 3. Formulação melhor
Quando as duas estão limpas, prefira a mais direta — normalmente a última,
porque é onde a pessoa já se ajustou:
| Antes | Depois | Escolha |
|---|---|---|
| *"a gente **faz a inserção de** próteses"* | *"a gente **insere** próteses"* | a segunda |
| *"reestrutura a mama"* | *"reestrutura a **sua** mama"* | a segunda |
### 4. Entrega
**Só então** desempate por `avg_energy` / `peak_emphasis`.
Quando duas tomadas têm texto **idêntico palavra por palavra**, aí a
energia decide sozinha — é o único sinal disponível. Caso real: o fecho
tinha duas tomadas iguais, energia **0,38** e **0,19**. A de 0,38 é a boa,
e o texto sozinho jamais diria isso.
## Regra de ouro
A **última** tomada costuma ser a melhor — é onde a pessoa acertou. Mas
**confirme lendo o texto**; nunca assuma.
## Quando estiver em dúvida
Não decida no escuro. Coloque um `marker` nas duas candidatas, explique a
dúvida no `reason`, e deixe a escolha para o editor humano.
## Continuidade
Ao montar o corte final você pode misturar blocos de tomadas diferentes —
abertura da tomada 1, corpo da tomada 3. Isso é normal. Mas **avise nas
emendas**: coloque um `marker` em cada junção para o editor conferir se o
enquadramento e a posição da pessoa combinam.
@@ -0,0 +1,76 @@
# 04 — Reanálise do material que sobrou
> **Escopo:** Renormalizar a ênfase sobre o que sobrou, antes de escolher zooms.
> **Quando:** Fase 4 — ver a ordem de trabalho em `../SKILL.md`.
**Não escolha zooms com os números da análise bruta.**
## O problema
Ênfase e energia são **relativas ao conjunto analisado**. Energia é
normalizada contra o momento mais alto da gravação; ênfase deriva dela.
Se esse momento mais alto foi cortado — uma piada, um grito, uma conversa
de bastidor — tudo que sobrou continua pontuado contra uma referência que o
espectador **nunca verá**. As notas do corte final ficam artificialmente
comprimidas, e o ranking aponta para as palavras erradas.
Caso real: o pico do vídeo era *"Amor, eu tô intacto!"* (energia 1,00),
descartado na triagem. Todo o material restante estava sendo medido contra
ele.
## A solução
Depois de definir os cortes, renormalize sobre os sobreviventes. Solicite uma
reanálise dos dados persistidos, passando a mídia e a lista de cortes que você
já decidiu. Se essa operação não estiver disponível, recalcule somente a
normalização necessária por um adaptador controlado e registre a nova versão
em `analises_versao`:
```
refinar_linha_de_voz(media_path, cortes=[{start, end}, ...], min_gap=8.0)
```
Ela devolve, numa chamada só, a comparação bruto × sobreviventes, os picos
re-ranqueados e os candidatos a zoom. É barata: renormaliza os números já
medidos, sem reabrir o áudio.
Efeito medido no mesmo material:
| | Bruto | Só o que sobrou |
|---|---|---|
| Ênfase média | 0,179 | **0,197** |
| *"Aquela"* | 0,39 | **0,42** |
| *"mastopexia"* | 0,26 | **0,34** |
| *"devolver"* | — | **0,35** |
*"mastopexia"* só virou candidata legítima depois da reanálise.
## As janelas que ela propõe
A seção **Zoom Candidates** da resposta já vem com três coisas resolvidas:
1. **Pega a palavra de conteúdo mais enfática de cada frase.** Artigos e
conectivos são filtrados — um *"a"* falado alto continua sendo um artigo.
Sem esse filtro, o ranking bruto apontava para "o", "a", "eu": picos de
*entrega*, não de *sentido*.
2. **Estende a janela até o fim da frase**, não do segmento (ver
`05-zoom.md`).
3. **Mantém distância mínima** entre zooms.
São **candidatos, não obrigações.** Corte a lista pelo ritmo
(`07-ritmo.md`). Os tempos continuam na mídia original — vão para as ações
persistidas no plano e, depois da aprovação, para o adaptador de aplicação do
Premiere.
`max_zooms` limita a lista, mas prefira cortá-la você mesmo: o corte por
ritmo é decisão editorial, não um teto numérico.
## Princípio geral
> "Qual o momento mais forte da **gravação**?" e "qual o momento mais forte
> do **vídeo final**?" são perguntas diferentes sempre que a métrica for
> relativa.
Toda métrica normalizada precisa ser recalculada quando o conjunto muda —
senão ela responde a pergunta errada, silenciosamente.
@@ -0,0 +1,89 @@
# 05 — Zoom (punch-in)
> **Escopo:** Onde dar punch-in, qual janela e qual escala — e o que a escala significa além do zoom.
> **Quando:** Fase 5 — ver a ordem de trabalho em `../SKILL.md`.
## Quando usar
No momento em que o argumento vira. Um pico acústico só merece zoom se for
também um pico **de sentido**.
Palavra gritada sem peso narrativo não ganha nada — e isso inclui os picos
que caem em artigos e conectivos, que são picos de entrega, não de conteúdo.
### Emoção só desempata, nunca decide sozinha
Quando `emocao`/`confianca_emocao` estiverem disponíveis (ver
`01-leitura-do-json.md`) e dois candidatos estiverem próximos em
`emphasis`/relevância narrativa, use a emoção com `confianca_emocao` acima
de ~0,6 como critério de desempate a favor do trecho com emoção mais forte
e coerente com o conteúdo. Isso nunca eleva um trecho sem peso narrativo a
candidato de zoom — só decide entre candidatos que já seriam elegíveis de
qualquer forma. Quando esses campos vierem vazios (caso mais comum hoje),
decida como sempre: `emphasis` + relevância narrativa.
## A janela
**`start`** — na palavra de ênfase.
**`end`** — no **fim da frase**. A frase inteira, não o fim do segmento da
transcrição.
O Whisper corta frases no meio, por respiração e não por gramática:
> *"Aquela mama com um formato mais estruturado, que valoriza o seu colo,
> que dá aquele ar"* **|** *"de elegância, isso é desejo de muitas mulheres,
> né?"*
Soltar o zoom no fim do primeiro segmento libera **no meio do pensamento** —
é o que faz um punch-in parecer arbitrário. `suggest_zoom_windows` já
estende até a pontuação final (`.` `!` `?` `…`), e nunca atravessa uma
fronteira de tomada.
## A forma — o programa decide sozinho
Você escolhe `start` e `end`; a forma sai da posição da janela dentro do
trecho:
| Situação | Comportamento | Por quê |
|---|---|---|
| Começa a **>0,5s** do início do trecho | entrada rápida (~0,25s) | o movimento chega junto com a palavra |
| Começa a **≤0,5s** do início | **entra já ampliado, sem transição** | o corte já foi a transição; uma rampa ali lê como a imagem se acomodando |
| Frase termina no meio do trecho | **saída seca**, 1 frame | volta ao enquadramento sem chamar atenção |
| Frase termina a **≤1s** do corte | **não volta** — segura até o corte | o próximo trecho já abre no enquadramento dele; voltar antes é movimento desperdiçado |
Os limiares são diferentes de propósito: no fim o corte esconde um retorno
inacabado, mas no início a rampa é visível desde o primeiro frame.
Para forçar manualmente, existem `start_at_peak` e `hold_at_end` — mas o
automático acerta na quase totalidade dos casos.
## Escala
| Valor | Uso |
|---|---|
| 1,15 | sutil |
| 1,18 – 1,3 | padrão |
| 1,5 | forte |
Em vídeo institucional, fique na faixa baixa. Acima de 3,0 é rejeitado.
Não espalhe zoom "por segurança". Cada ação é aplicada diretamente ao clipe e
excesso de escala vira trabalho de correção manual. Zoom não ativa legenda nem
muda o texto por efeito colateral no fluxo atual.
O zoom é **relativo ao enquadramento existente**: se o clipe já tem escala
1,77 (material gravado de lado e reenquadrado), um zoom 1,18 anima de 1,77
para 2,09 e preserva rotação e posição.
## Dois zooms no mesmo clipe
Depois do corte, dois picos que você escolheu podem cair no **mesmo**
trecho sobrevivente (nenhum corte os separou em clipes distintos) — é
comum quando a corrida limpa de uma tomada é longa. O sistema resolve isso
sozinho, e a regra é a mesma que rege o resto: janelas **distantes**
empilham (os dois zooms convivem, cada um voltando ao enquadramento real
entre um e outro); janelas que **se sobrepõem** substituem (é o mesmo
evento sendo reajustado, não dois). Você não precisa calcular isso na
hora de decidir — só respeitar o `min_gap` de `07-ritmo.md`, que já
garante que dois zooms escolhidos por você nunca se sobrepõem.
@@ -0,0 +1,155 @@
# 06 — Texto, corte e marcador
> **Escopo:** Texto na tela, o que cortar (inclui muletas e lacunas) e quando marcar.
> **Quando:** Fase 6 — ver a ordem de trabalho em `../SKILL.md`.
## Texto
Para fixar um **conceito, número ou nome** que o espectador precisa reter.
- Use a palavra **dita**, não uma paráfrase.
- Curta, em caixa alta. Até 120 caracteres (é truncado além disso).
- Uma por frase, no máximo.
**Não legende a frase inteira.** Para isso existe
`generate_dynamic_subtitles`, que é outra ferramenta e outro propósito.
Boas candidatas são as palavras-chave que sobram depois de filtrar as
funcionais — num caso real: *mastopexia*, *flacidez*, *próteses*,
*devolver*, *desejo*.
## Corte
Digressão, repetição, frase abandonada, conversa de bastidor, tomada pior —
e mais duas coisas que **são** seu trabalho, ao contrário do que parece.
### Vícios de linguagem entram na sua lista
Não delegue para `remove_filler_words`. Você já está percorrendo palavra por
palavra na triagem; marcar as muletas é uma linha a mais, sem custo. E você
tem o que a lista fixa não tem: **contexto**.
Um *"tipo"* em *"tipo assim, sabe"* é muleta. Em *"esse tipo de cirurgia"*
é a palavra principal. Um *"não não não"* pode ser gagueira ou ênfase. A
lista fixa não distingue; você distingue.
### Lacunas longas entram na sua lista — curtas, nunca
**O tamanho da lacuna muda o que ela é.** A régua está medida em material
real (`pause_weight()` em `emphasis.py`, `TAKE_BOUNDARY_GAP` em
nas regras de análise de voz do projeto):
| `pause_before` | O que é | O que fazer |
|---|---|---|
| até ~1,5s | o falante montando a frase — **isso É a ênfase** | **nunca cortar** |
| 1,5–3s | zona cinza | julgue pela frase |
| acima de 3s | troca de tomada, ar morto, outra pessoa falando | **cortar** |
Cortar a pausa curta é o erro grave: ela é uma das cinco entradas do índice
de ênfase, então você estaria apagando justamente a batida que faz a palavra
seguinte pontuar alto. Uma frase fluida não se aperta.
Acima de 3s a pausa deixa de contar como ênfase por construção — medido em
material real, lacunas de 6–9s rankeavam como os momentos mais enfáticos da
gravação só porque a escala saturava.
### Nunca corte rente à palavra — deixe uma folga
Um `cut` cujo `start`/`end` cai exatamente no timestamp da palavra (fim da
última palavra mantida = início do corte) produz um corte seco: a palavra é
engolida antes de terminar de soar, e a fala seguinte começa sem nenhum ar.
Isso é diferente de cortar a pausa curta (que seria apagar a própria ênfase,
proibido acima) — aqui a pausa **já existe** entre o fim de um bloco mantido
e o início do próximo, e o corte está comendo justamente essa margem.
Ao escrever a borda de um `cut` que encosta em fala mantida (não em silêncio
puro), recue **no mínimo 0,30s** para dentro do próprio corte, nos dois lados.
Essa margem cobre a imprecisão observada entre transcrição e frame de corte:
- o `start` do corte fica ≥0,30s **depois** do fim real da última palavra
mantida;
- o `end` do corte fica ≥0,30s **antes** do início real da próxima palavra
mantida.
Caso real (projeto Mastopexia): um corte escrito rente (`10.77 → 95.50`,
exatamente nos timestamps de palavra) soava abrupto nas duas emendas.
Recuado para `10.97 → 95.30`, cada lado ganhou ~0,2s de respiro sem alterar
o que é dito — e não empurra o próximo zoom/marcador contra a borda do corte
(ver `05-zoom.md` sobre janelas encostadas em corte).
Isso vale também para o **início e o fim do vídeo**: ar morto antes da
primeira palavra e depois da última também leva `cut`, com a mesma folga —
não é "silêncio dentro da fala" (isso é `remove_media_silence`), é o mesmo
corte de tomada/bastidor que você já está decidindo.
### Segmento de transcrição não é frase
O Whisper pode terminar um segmento no meio de uma oração e continuar o
segmento seguinte em minúscula. Portanto, **nunca** transforme automaticamente
`segment.end` ou `next_segment.start` em borda de corte.
Antes de aceitar cada emenda, leia em voz contínua:
1. a última oração que ficará antes do `start`;
2. a primeira oração que ficará depois do `end`;
3. a junção formada por essas duas partes.
Bloqueie o plano se a última palavra mantida não fechar a oração, se a próxima
fala começar como continuação gramatical, ou se qualquer borda cair dentro de
uma palavra. Caso real bloqueante: remover até `934.65s` faria a fala sobreviver
em `"conseguir achar um profissional..."`, continuação da frase anterior; cortar
em `962.60s`, exatamente após `"segurança."`, pode produzir
`"trazer uma seguran..."` por falta de cauda acústica.
Checklist obrigatório antes de salvar qualquer plano com `cut`:
- zero palavras interceptadas;
- zero trechos mantidos começando no meio de frase;
- zero frases mantidas sem fechamento antes do corte;
- ao menos 0,30s de margem junto às palavras mantidas;
- leitura do texto sobrevivente completa, na ordem final.
### O que continua NÃO sendo seu trabalho
| Tarefa | Ferramenta | Por quê |
|---|---|---|
| Apertar o ar **entre** palavras (sem fala, com ou sem som) | `remove_speech_gaps` | Lê `words[].start/end` da transcrição — sabe onde não tem fala mesmo quando tem som (respiração, ruído) |
| Apertar o ar **dentro** da fala | `remove_media_silence` | Lê o áudio real com ffmpeg; só cobre silêncio técnico (dB), que a transcrição não enxerga |
E cuidado: **ausência de fala não é ausência de som**, e nem sempre é
descartável. Respiração, riso, suspiro, a reação depois da frase — nada
disso vira palavra, então aparece como lacuna, e às vezes é o melhor frame
do vídeo. `remove_speech_gaps` corta **toda** lacuna acima do `min_gap`
(0,6s por padrão) sem julgar o que tem nela — é automação de "sem fala",
não de "sem conteúdo que vale manter". Se uma reação específica precisa
sobreviver, marque-a como `cut` de duração zero antes (para virar um limite
de segmento) ou rode com `min_gap` maior nesse trecho; não é a ferramenta
que decide o que é bom frame.
Tanto `remove_speech_gaps` quanto `remove_media_silence` rodam **depois** da
aplicação do plano, como acabamento sobre o material que sobrou —
`remove_speech_gaps` primeiro (cobre mais, é o corte "grosso" por fala),
`remove_media_silence` depois (aperta o que ainda restar dentro da fala).
**Antes de rodar `remove_media_silence` sobre o corte final, sempre rode a
detecção primeiro** (sem aplicar) e leia os spans um a um contra a régua
acima. O detector corta por limiar de dB — ele não sabe distinguir "batida
de 0,8s entre duas frases", que a régua protege, de "ar morto de emenda",
que deveria ser apertado. Aplicar direto, sem essa checagem, é o mesmo erro
de cortar pausa curta, só que por outra ferramenta.
## Marcador
Quando você quer **sinalizar para o editor humano decidir**, em vez de
decidir por ele.
Use em:
- **Emendas entre tomadas** — sempre. O editor precisa conferir se o
enquadramento e a posição da pessoa combinam na junção.
- **Dúvida entre duas tomadas** — marque as duas, explique no `reason`.
- **Momentos que talvez mereçam efeito** mas que você não tem confiança
para decidir.
Marcador é um **ponto**, não um trecho: sobrevive mesmo encostado na borda
de um corte, o que é justamente o caso das emendas.
@@ -0,0 +1,40 @@
# 07 — Ritmo
> **Escopo:** Quantos efeitos cabem: os tetos e como escolher o que fica.
> **Quando:** Fase 7 — ver a ordem de trabalho em `../SKILL.md`.
**O erro mais comum é efeito demais.** Cansa mais que efeito de menos, e
denuncia edição automática.
## Limites
| Regra | Valor |
|---|---|
| Distância mínima entre dois zooms | **8–10 segundos** |
| Zooms por minuto de vídeo | **2 a 4** (teto) |
| Zoom + texto no mesmo instante | só com motivo claro |
Se dois picos estiverem colados, **escolha o mais forte e abra mão do
outro**. Não tente encaixar os dois.
## Candidatos ≠ obrigações
`suggest_zoom_windows` devolve uma lista de candidatos. Normalmente você usa
uma **fração** dela.
Caso real: num corte de 47,6s a ferramenta sugeriu **5** janelas. O certo
foram **3** — 5 violaria o teto de 2–4 por minuto. Ficaram a abertura, o
termo central e o fecho; as duas descartadas eram frases de apoio.
O mesmo vale para `peak_moments` no `summary`: é lista de candidatos.
## Como escolher quais manter
Quando precisar cortar a lista, priorize por **função narrativa**, não por
nota:
1. **A abertura** — prende o espectador.
2. **O conceito central** — o termo que o vídeo existe para explicar.
3. **O fecho** — a frase que fica.
Só depois disso, as frases de apoio, por ordem de ênfase.
@@ -0,0 +1,106 @@
# 08 — Formato de saída
> **Escopo:** O JSON de entrega: estrutura, regras e como o programa trata erros.
> **Quando:** Fase 8 — ver a ordem de trabalho em `../SKILL.md`.
O produto intermediário do seu trabalho é **este JSON**. Ele serve para
revisão e transporte entre componentes; a decisão persistida deve ser salva
em `planos_de_edicao` e `acoes_do_plano`. Você nunca escreve XML.
## Estrutura
```json
{
"source": "0E6A8290.mp4",
"actions": [
{"kind": "cut", "start": 21.9, "end": 127.6,
"reason": "tomadas descartadas, frases interrompidas e conversa com a equipe"},
{"kind": "hook", "start": 141.2, "end": 147.8,
"params": {"target": 0, "keep_original": true},
"reason": "frase completa escolhida como gancho de abertura"},
{"kind": "zoom", "start": 2.0, "end": 10.7,
"params": {"scale": 1.15}, "reason": "abertura: \"Aquela mama\" (ênfase 0.42)"},
{"kind": "text", "start": 127.7, "end": 129.0,
"params": {"content": "MASTOPEXIA"}, "reason": "fixa o termo central"},
{"kind": "marker", "start": 21.85, "end": 22.0,
"reason": "EMENDA 1 — conferir junção entre tomadas"}
]
}
```
## Regras
### 1. Tempos em segundos da mídia ORIGINAL
Exatamente como aparecem nos intervalos persistidos da transcrição no banco.
**Nunca compense para "depois do corte".** O programa faz esse deslocamento
sozinho: ele resolve os cortes primeiro e reposiciona todo o resto. Se você
compensar por conta própria, **todo destaque cai no frame errado** — e o
erro é silencioso.
### 2. `end` sempre maior que `start`
Ambos ≥ 0. Um `end <= start` é rejeitado.
### 3. Tipos
`cut` · `hook` · `zoom` · `text` · `marker`
### 4. Parâmetros por tipo
| Tipo | `params` |
|---|---|
| `cut` | nenhum |
| `hook` | `target: 0` e `keep_original: true` |
| `zoom` | `scale` entre 1.0 e 3.0 (padrão 1.3 se omitido) |
| `text` | `content` **obrigatório**, até 120 caracteres |
| `marker` | opcional: `content` vira o nome do marcador |
### 5. `reason` — sempre preencha
É o que o usuário lê para revisar sua decisão, e o que te obriga a **ter**
uma. Um `reason` vazio é sinal de decisão sem critério.
Inclua o dado que embasou: *"abertura: 'Aquela mama' (ênfase 0.42)"* é útil;
*"zoom"* não é.
Não é campo de log: o texto permanece no JSON e no histórico persistido para
o editor conferir por que a ação foi proposta.
### 6. Corte: preserve a integridade da fala
O aplicador executa literalmente o intervalo informado: ele não converte o
corte em decisão por frase nem encaixa a borda na palavra mais próxima.
Portanto, reconstrua o texto sobrevivente, preserve frases completas e deixe
ao menos 0,30s de respiro junto às palavras mantidas. A engine bloqueia borda
dentro de palavra, continuação gramatical e margem insuficiente. Detalhes em
`06-texto-corte-marcador.md` e `10-revisao-humana.md`.
### 7. Gancho: uma cópia completa para o início
Use no máximo um `hook`. O intervalo deve estar contido em um único clipe de
vídeo e seu áudio vinculado, em velocidade normal. A frase copiada precisa ter
começo, fechamento e 0,30s de margem nas duas bordas. O aplicador aceita apenas
o destino `0` e preserva a ocorrência original; nenhuma outra ação pode
sobrepor esse intervalo.
## Persistência
Depois de validar o JSON, crie um registro em `planos_de_edicao` ligado ao
`video_id` e à edição de origem. Grave cada ação em `acoes_do_plano` mantendo a
ordem e os motivos. Não considere o plano entregue até a gravação ser
confirmada.
## Como o programa trata erros
- **Plano ou ação inválida** → o plano inteiro é bloqueado antes de alterar a
timeline.
- **Borda de corte ou gancho semanticamente insegura** → o plano inteiro é bloqueado
antes da conexão com o Premiere e antes do backup.
- **Gancho com faixas adicionais ocupadas, mídia retimada ou par A/V
ambíguo** → recusado; use uma sequência simples de diálogo ou faça a
duplicação manualmente.
- **Ação apontando para material cortado** → descartada e reportada, nunca
deslizada para o conteúdo vizinho.
- **Ação fora da mídia** → reportada como não colocada.
Você recebe o relatório dos três casos. **Repasse ao usuário** — nunca
relate só os acertos.
@@ -0,0 +1,53 @@
# 09 — Quando a análise veio incompleta
> **Escopo:** O que fazer quando uma camada da análise não rodou.
> **Quando:** Fase 0 — ver a ordem de trabalho em `../SKILL.md`.
O registro correspondente em `analises_versao` e os campos disponíveis nas
tabelas de análise dizem **o que de fato rodou**. Leia isso antes de qualquer
outra coisa. Se houver um JSON exportado com `layers`, use-o apenas como
informação auxiliar e confira sua versão contra o banco.
```json
"layers": {"transcript": true, "acoustics": false, "speakers": false}
```
## Por que esse bloco existe
Fala monótona e acústica que não carregou deixam **os mesmos zeros** nos
dados. Sem o `layers`, é impossível distinguir "esta pessoa fala de forma
uniforme" de "a análise acústica falhou".
## Os casos
### `acoustics: false`
Todos os valores acústicos são 0. **Você não tem ênfase real.**
- Decida só pelo texto.
- **Avise o usuário** explicitamente.
- Prefira `marker` a `zoom` — sinalize em vez de decidir.
Causa comum: o componente librosa não está instalado, ou o ffmpeg não
conseguiu extrair o áudio do container.
### `speakers: false` num vídeo com várias pessoas
A diarização não rodou — falta o token do HuggingFace (aba Modelos do app).
- Avise antes de tratar tudo como uma voz só.
- Lembre que isso **não impede** a triagem roteiro/conversa, que é feita
pelo texto (ver `02-triagem-roteiro-vs-conversa.md`).
### `peak_count: 0`
Nada cruzou o piso de ênfase. Duas causas possíveis:
1. A fala é uniforme mesmo — material sem picos.
2. O limiar está alto para esse material.
Sugira ajustar em **Análise de Voz** no app. **Não force destaques
inexistentes** só para entregar alguma coisa.
## Regra geral
Não finja precisão que você não tem. Uma edição entregue com a ressalva
certa é útil; uma entregue como se estivesse completa, quando metade dos
dados faltou, custa a confiança do usuário no sistema inteiro.
@@ -0,0 +1,50 @@
# 10 — Revisão humana no fluxo atual
> **Escopo:** O que o painel realmente permite revisar antes da aplicação.
> **Quando:** ler antes de gerar e aprovar o plano.
O painel atual exibe o JSON do plano e o aplica pela engine Python depois da
confirmação. Ele **não possui ainda** uma tela frase a frase, não encaixa trims
automaticamente em palavras e não gera `_phrase_review.json`. Por isso, não
presuma que uma etapa posterior corrigirá bordas editoriais imprecisas.
## Preview obrigatório
Antes de pedir aprovação, apresente para cada corte ou gancho:
- intervalo afetado e motivo;
- última frase completa que ficará antes;
- primeira frase completa que ficará depois;
- texto da emenda resultante quando houver remoção;
- margem acústica em cada lado;
- qualquer incerteza que exija escuta humana.
Para `hook`, apresente também a frase completa que passará a abrir o vídeo e
confirme que a ocorrência original será preservada.
O editor deve aprovar o conteúdo sobrevivente, não apenas a quantidade de
ações. Se o preview mostrar palavra truncada, oração incompleta ou continuação
sem contexto, o plano volta para edição e recebe novos limites.
## Defesa automática
Ao clicar em **Aplicar plano na timeline**, a engine revalida cortes e
ganchos antes
de se conectar ao Premiere e antes de criar o backup. A aplicação é bloqueada
quando encontra:
- uma borda dentro de palavra;
- menos de 0,30s entre a borda e a palavra mantida;
- fala mantida interrompida antes do corte;
- retomada no meio de frase depois do corte.
- gancho sem frase completa ou sem margem nas duas extremidades.
Essa validação é uma rede de segurança, não substitui o preview. Depois que o
plano passa, a engine cria uma única cópia de segurança, aplica as ações e
registra o resultado. O painel não cria uma segunda cópia.
## `reason` continua obrigatório
Escreva um motivo curto e verificável, com a fala ou comparação que sustentou
a decisão. Evite motivos genéricos como `"corte necessário"`: eles não ajudam o
editor a conferir o plano nem permitem melhorar os critérios depois.
@@ -0,0 +1,56 @@
# 11 — Gancho duplicado
> **Escopo:** Copiar uma fala do meio do vídeo para a abertura.
> **Quando:** pedidos de gancho ou abertura forte com repetição intencional.
## Semântica atual
`hook` cria uma cópia: a fala permanece no corpo do vídeo e sua cópia passa a
abrir a sequência. Use somente:
```json
{
"kind": "hook",
"start": 141.2,
"end": 147.8,
"params": {"target": 0, "keep_original": true},
"reason": "frase completa escolhida como gancho de abertura"
}
```
## Escolha editorial
O gancho deve funcionar sem a frase anterior. Prefira uma promessa, contraste,
resultado, pergunta ou afirmação específica que desperte curiosidade e continue
verdadeira fora da posição original. Evite pronomes sem referente, continuações
como “por isso” ou “então” e conclusões que dependam de explicação anterior.
Selecione a menor unidade que preserve o sentido completo. Una segmentos do
Whisper quando a gramática continuar entre eles. A primeira palavra precisa
iniciar a frase, a última precisa fechá-la e cada extremidade precisa conservar
ao menos 0,30s de respiro.
## Limites da execução segura
A versão atual aceita um único `hook`, destinado a `0s`, dentro de um único
clipe de vídeo em velocidade normal e seu áudio correspondente. Ela recusa
faixas adicionais ocupadas, par A/V ambíguo, mídia retimada ou invertida e
qualquer outra ação que sobreponha o trecho do gancho.
A operação cria a cópia a partir do mesmo intervalo da mídia de origem; o
original conserva seus efeitos, mas a cópia nasce sem efeitos específicos do
clipe. Por isso o gancho deve rodar antes de zooms e outros efeitos do plano.
Ela só prossegue quando o backup da sequência foi confirmado pelo Premiere.
## Preview
Antes da aprovação, mostre:
- a frase completa escolhida;
- o intervalo original e as margens de cada borda;
- a nova abertura seguida da primeira frase do conteúdo anterior;
- a confirmação de que a ocorrência original será preservada;
- o aumento esperado da duração total pelo tamanho do gancho.
O plano está pronto somente quando a nova emenda preserva sentido, ritmo e
respiração.
@@ -1,6 +1,6 @@
---
name: transcript-to-edit-actions
description: Transform transcript-based editorial selections into validated executable Premiere edit plans when the user asks to edit by voice, edit by transcript, summarize an interview, select excerpts, or generate JSON actions.
description: Convert exact keep/remove intervals already selected by the user into cut-only JSON for a portable workflow. Use only when the editorial selection is already decided and no Jhonny SQLite context exists. Do not choose takes or use this skill in the MCP for Adobe Premiere Pro panel; use editar-por-voz there.
---
# Transcript to Edit Actions
+400
View File
@@ -3,6 +3,184 @@ import { sendCommand, BridgeOptions } from "../bridge/file-bridge.js";
export function getTimelineTools(bridgeOptions: BridgeOptions) {
return {
assemble_incremental_edit_prototype: {
description:
"PROTOTYPE: clone a source sequence, clear only the clone, and assemble one project item's A/V source ranges in order. The source sequence is not edited.",
parameters: {
type: "object" as const,
additionalProperties: false,
properties: {
source_sequence_id: {
type: "string",
minLength: 1,
description: "Source sequence name or ID to clone",
},
source_item_id: {
type: "string",
minLength: 1,
description: "Project item name or node ID used by every source range",
},
target_name: {
type: "string",
minLength: 1,
maxLength: 255,
description: "Name for the cloned destination sequence",
},
ranges: {
type: "array",
minItems: 1,
maxItems: 64,
items: {
type: "object",
additionalProperties: false,
properties: {
in_seconds: { type: "number", minimum: 0 },
out_seconds: { type: "number", minimum: 0 },
destination_seconds: { type: "number", minimum: 0 },
label: { type: "string", maxLength: 255 },
},
required: ["in_seconds", "out_seconds", "destination_seconds"],
},
},
confirm_prototype: {
type: "boolean",
description: "Must be true because this creates and mutates a Premiere sequence",
},
},
required: ["source_sequence_id", "source_item_id", "target_name", "ranges", "confirm_prototype"],
},
handler: async (args: {
source_sequence_id: string;
source_item_id: string;
target_name: string;
ranges: Array<{
in_seconds: number;
out_seconds: number;
destination_seconds: number;
label?: string;
}>;
confirm_prototype: boolean;
}) => {
if (args.confirm_prototype !== true) {
return { success: false, error: "confirm_prototype must be true for the experimental sequence assembly." };
}
if (!Array.isArray(args.ranges) || args.ranges.length === 0 || args.ranges.length > 64) {
return { success: false, error: "ranges must contain between 1 and 64 source ranges." };
}
for (const [index, range] of args.ranges.entries()) {
if (!Number.isFinite(range.in_seconds) || !Number.isFinite(range.out_seconds) || !Number.isFinite(range.destination_seconds) ||
range.in_seconds < 0 || range.out_seconds <= range.in_seconds || range.destination_seconds < 0) {
return { success: false, error: `ranges[${index}] has invalid source or destination timing.` };
}
}
const sourceSequenceId = escapeForExtendScript(args.source_sequence_id);
const sourceItemId = escapeForExtendScript(args.source_item_id);
const targetName = escapeForExtendScript(args.target_name);
const ranges = args.ranges.map((range) => ({
inSeconds: range.in_seconds,
outSeconds: range.out_seconds,
destinationSeconds: range.destination_seconds,
label: range.label || "",
}));
const rangeJson = JSON.stringify(ranges);
const script = buildToolScript(`
var sourceSequence = __findSequence("${sourceSequenceId}");
if (!sourceSequence) return __error("Source sequence not found; no prototype sequence was created.");
var item = __findProjectItem("${sourceItemId}");
if (!item) return __error("Source project item not found; no prototype sequence was created.");
var beforeIds = {};
var sequenceIndex;
for (sequenceIndex = 0; sequenceIndex < app.project.sequences.numSequences; sequenceIndex++) {
beforeIds[String(app.project.sequences[sequenceIndex].sequenceID)] = true;
}
sourceSequence.clone();
var destinationSequence = null;
for (sequenceIndex = 0; sequenceIndex < app.project.sequences.numSequences; sequenceIndex++) {
var candidate = app.project.sequences[sequenceIndex];
if (!beforeIds[String(candidate.sequenceID)]) {
destinationSequence = candidate;
break;
}
}
if (!destinationSequence) return __error("Premiere did not expose the cloned destination sequence.");
destinationSequence.name = "${targetName}";
var trackGroup;
var trackIndex;
var clipIndex;
var trackGroups = [destinationSequence.videoTracks, destinationSequence.audioTracks];
for (trackGroup = 0; trackGroup < trackGroups.length; trackGroup++) {
for (trackIndex = 0; trackIndex < trackGroups[trackGroup].numTracks; trackIndex++) {
var track = trackGroups[trackGroup][trackIndex];
for (clipIndex = track.clips.numItems - 1; clipIndex >= 0; clipIndex--) {
track.clips[clipIndex].remove(false, false);
}
if (track.clips.numItems !== 0) return __error("Could not clear the cloned destination sequence.");
}
}
if (!destinationSequence.videoTracks.numTracks || !destinationSequence.audioTracks.numTracks) {
return __error("The cloned destination sequence must have at least one video and one audio track.");
}
var ranges = ${rangeJson};
var oldIn = item.getInPoint(4);
var oldOut = item.getOutPoint(4);
var oldInTicks = oldIn ? oldIn.ticks : null;
var oldOutTicks = oldOut ? oldOut.ticks : null;
var placements = [];
var insertionError = null;
try {
for (var rangeIndex = 0; rangeIndex < ranges.length; rangeIndex++) {
var range = ranges[rangeIndex];
if (range.outSeconds <= range.inSeconds) return __error("A prototype source range has non-positive duration.");
item.setInPoint(__secondsToTicks(range.inSeconds).toString(), 4);
item.setOutPoint(__secondsToTicks(range.outSeconds).toString(), 4);
destinationSequence.insertClip(item, __secondsToTicks(range.destinationSeconds).toString(), 0, 0);
placements.push({
label: range.label,
sourceInSeconds: range.inSeconds,
sourceOutSeconds: range.outSeconds,
destinationSeconds: range.destinationSeconds,
durationSeconds: range.outSeconds - range.inSeconds
});
}
} catch (insertError) {
insertionError = insertError.toString();
} finally {
try {
if (oldInTicks !== null) item.setInPoint(String(oldInTicks), 4); else item.clearInPoint();
if (oldOutTicks !== null) item.setOutPoint(String(oldOutTicks), 4); else item.clearOutPoint();
} catch (restoreError) {
return __error("The source item's in/out could not be restored: " + restoreError.toString());
}
}
if (insertionError !== null) return __error("Premiere rejected a prototype source-range insertion: " + insertionError);
var videoCount = destinationSequence.videoTracks[0].clips.numItems;
var audioCount = destinationSequence.audioTracks[0].clips.numItems;
if (videoCount !== ranges.length || audioCount !== ranges.length) {
return __error("The prototype destination did not receive exactly one video and one audio clip per range.");
}
return __result({
prototype: true,
verified: true,
sourceSequenceId: String(sourceSequence.sequenceID),
destinationSequenceId: String(destinationSequence.sequenceID),
destinationSequenceName: destinationSequence.name,
videoClipCount: videoCount,
audioClipCount: audioCount,
placements: placements,
note: "The source sequence was not edited. Review the cloned sequence manually before keeping it."
});
`);
return sendCommand(script, bridgeOptions);
},
},
add_to_timeline: {
description:
"Insert a project item at a timeline position and verify Premiere added no unexpected same-track fragments.",
@@ -194,6 +372,228 @@ export function getTimelineTools(bridgeOptions: BridgeOptions) {
},
},
duplicate_linked_range_to_start: {
description:
"Duplicate one isolated, normal-speed linked video/audio range at sequence start with a verified ripple insert while preserving the original occurrence.",
parameters: {
type: "object" as const,
properties: {
node_ids: {
type: "array",
items: { type: "string" },
minItems: 2,
maxItems: 2,
description: "IDs of the isolated video and audio clips that form the range.",
},
},
required: ["node_ids"],
},
handler: async (args: { node_ids: string[] }) => {
if (!Array.isArray(args.node_ids) || args.node_ids.length !== 2 || args.node_ids.some((id) => typeof id !== "string" || !id)) {
return { success: false, error: "node_ids must contain exactly two non-empty clip IDs." };
}
const ids = args.node_ids.map(escapeForExtendScript);
const script = buildToolScript(`
var first = __findClip("${ids[0]}");
var second = __findClip("${ids[1]}");
if (!first || !second) return __error("One of the requested clips was not found; nothing was duplicated.");
if (first.trackType === second.trackType) return __error("The range must contain one video clip and one audio clip.");
var video = first.trackType === "video" ? first : second;
var audio = first.trackType === "audio" ? first : second;
var seq = app.project.activeSequence;
if (!seq) return __error("No active sequence; nothing was duplicated.");
var frameTicks = parseFloat(seq.timebase);
if (!frameTicks || isNaN(frameTicks)) frameTicks = TICKS_PER_SECOND / 24;
var beforeEndTicks = parseFloat(seq.end.ticks);
var videoStart = parseFloat(video.clip.start.ticks);
var audioStart = parseFloat(audio.clip.start.ticks);
var videoEnd = parseFloat(video.clip.end.ticks);
var audioEnd = parseFloat(audio.clip.end.ticks);
if (Math.abs(videoStart - audioStart) > frameTicks || Math.abs(videoEnd - audioEnd) > frameTicks) {
return __error("The video and audio clips do not occupy the same range; nothing was duplicated.");
}
if (videoStart <= frameTicks) return __error("The requested range is already at sequence start; nothing was duplicated.");
if (!video.clip.projectItem || !audio.clip.projectItem || video.clip.projectItem.nodeId !== audio.clip.projectItem.nodeId) {
return __error("The video and audio clips do not share one source project item; nothing was duplicated.");
}
var videoSpeed = 100;
var audioSpeed = 100;
var videoReversed = false;
var audioReversed = false;
try { videoSpeed = Number(video.clip.getSpeed()); } catch (e) {}
try { audioSpeed = Number(audio.clip.getSpeed()); } catch (e) {}
try { videoReversed = video.clip.isSpeedReversed() == 1; } catch (e) {}
try { audioReversed = audio.clip.isSpeedReversed() == 1; } catch (e) {}
if (Math.abs(videoSpeed - 100) > 0.001 || Math.abs(audioSpeed - 100) > 0.001 || videoReversed || audioReversed) {
return __error("Retimed or reversed clips cannot be duplicated by this guarded operation.");
}
var g;
var t;
var groups = [seq.videoTracks, seq.audioTracks];
for (g = 0; g < groups.length; g++) {
for (t = 0; t < groups[g].numTracks; t++) {
var isTarget = (g === 0 && t === video.trackIndex) || (g === 1 && t === audio.trackIndex);
if (!isTarget && groups[g][t].clips.numItems > 0) {
return __error("The guarded hook only supports a simple sequence with content on one video and one audio track.");
}
}
}
if (seq.videoTracks[video.trackIndex].clips.numItems < 1 || seq.audioTracks[audio.trackIndex].clips.numItems < 1) {
return __error("The target tracks are empty; nothing was duplicated.");
}
if ((seq.videoTracks[video.trackIndex].isLocked && seq.videoTracks[video.trackIndex].isLocked()) ||
(seq.audioTracks[audio.trackIndex].isLocked && seq.audioTracks[audio.trackIndex].isLocked())) {
return __error("A target track is locked; nothing was duplicated.");
}
if (parseFloat(seq.videoTracks[video.trackIndex].clips[0].start.ticks) > frameTicks ||
parseFloat(seq.audioTracks[audio.trackIndex].clips[0].start.ticks) > frameTicks) {
return __error("The guarded hook requires both target tracks to begin at sequence time zero.");
}
var item = video.clip.projectItem;
var sourceInTicks = parseFloat(video.clip.inPoint.ticks);
var sourceOutTicks = parseFloat(video.clip.outPoint.ticks);
if (Math.abs(sourceInTicks - parseFloat(audio.clip.inPoint.ticks)) > frameTicks ||
Math.abs(sourceOutTicks - parseFloat(audio.clip.outPoint.ticks)) > frameTicks) {
return __error("The linked clips do not share matching source in/out points; nothing was duplicated.");
}
var durationTicks = videoEnd - videoStart;
if (durationTicks <= frameTicks) return __error("The requested range is too short to duplicate safely.");
var oldIn = item.getInPoint(4);
var oldOut = item.getOutPoint(4);
var oldInTicks = oldIn ? oldIn.ticks : null;
var oldOutTicks = oldOut ? oldOut.ticks : null;
var insertionError = null;
try {
item.setInPoint(String(sourceInTicks), 4);
item.setOutPoint(String(sourceOutTicks), 4);
seq.insertClip(item, "0", video.trackIndex, audio.trackIndex);
} catch (insertError) {
insertionError = insertError.toString();
} finally {
try {
if (oldInTicks !== null) item.setInPoint(String(oldInTicks), 4); else item.clearInPoint();
if (oldOutTicks !== null) item.setOutPoint(String(oldOutTicks), 4); else item.clearOutPoint();
} catch (restoreError) {
return __error("The source range could not be restored after insertion: " + restoreError.toString());
}
}
if (insertionError !== null) return __error("Premiere could not insert the range at sequence start: " + insertionError);
var insertedVideo = null;
var insertedAudio = null;
var vc;
var ac;
for (vc = 0; vc < seq.videoTracks[video.trackIndex].clips.numItems; vc++) {
var candidateVideo = seq.videoTracks[video.trackIndex].clips[vc];
if (candidateVideo.nodeId !== "${ids[0]}" && candidateVideo.nodeId !== "${ids[1]}" &&
candidateVideo.projectItem && candidateVideo.projectItem.nodeId === item.nodeId &&
Math.abs(parseFloat(candidateVideo.start.ticks)) <= frameTicks) insertedVideo = candidateVideo;
}
for (ac = 0; ac < seq.audioTracks[audio.trackIndex].clips.numItems; ac++) {
var candidateAudio = seq.audioTracks[audio.trackIndex].clips[ac];
if (candidateAudio.nodeId !== "${ids[0]}" && candidateAudio.nodeId !== "${ids[1]}" &&
candidateAudio.projectItem && candidateAudio.projectItem.nodeId === item.nodeId &&
Math.abs(parseFloat(candidateAudio.start.ticks)) <= frameTicks) insertedAudio = candidateAudio;
}
if (!insertedVideo || !insertedAudio) {
return __error("Premiere inserted content but the new linked range could not be verified at sequence start. Restore the automatic sequence backup.");
}
if (Math.abs((parseFloat(insertedVideo.end.ticks) - parseFloat(insertedVideo.start.ticks)) - durationTicks) > frameTicks ||
Math.abs((parseFloat(insertedAudio.end.ticks) - parseFloat(insertedAudio.start.ticks)) - durationTicks) > frameTicks) {
return __error("The inserted range has an unexpected duration. Restore the automatic sequence backup.");
}
var insertedVideoId = String(insertedVideo.nodeId);
var insertedAudioId = String(insertedAudio.nodeId);
var preservedFirst = __findClip("${ids[0]}");
var preservedSecond = __findClip("${ids[1]}");
if (!preservedFirst || !preservedSecond ||
Math.abs(parseFloat(preservedFirst.clip.start.ticks) - (videoStart + durationTicks)) > frameTicks ||
Math.abs(parseFloat(preservedSecond.clip.start.ticks) - (audioStart + durationTicks)) > frameTicks) {
return __error("The original range was not preserved after the ripple insert. Restore the automatic sequence backup.");
}
var hookVideo = __findClip(insertedVideoId);
var hookAudio = __findClip(insertedAudioId);
if (!hookVideo || !hookAudio ||
Math.abs(parseFloat(hookVideo.clip.start.ticks)) > frameTicks ||
Math.abs(parseFloat(hookAudio.clip.start.ticks)) > frameTicks) {
return __error("The duplicated hook was not preserved at sequence start. Restore the automatic sequence backup.");
}
var afterEndTicks = parseFloat(seq.end.ticks);
if (Math.abs(afterEndTicks - (beforeEndTicks + durationTicks)) > frameTicks * 2) {
return __error("The sequence duration did not grow by the hook duration. Restore the automatic sequence backup.");
}
return __result({
duplicated: true,
verified: true,
targetSeconds: 0,
durationSeconds: __ticksToSeconds(durationTicks),
insertedNodeIds: [String(hookVideo.clip.nodeId), String(hookAudio.clip.nodeId)],
originalNodeIdsPreserved: ["${ids[0]}", "${ids[1]}"],
clipEffectsPreserved: false
});
`);
return sendCommand(script, bridgeOptions);
},
},
insert_adjustment_layer_range: {
description:
"Insert an existing Adjustment Layer project item on a video track and extend the timeline instance to an exact bounded range.",
parameters: {
type: "object" as const,
additionalProperties: false,
properties: {
item_id: { type: "string", minLength: 1, description: "Project item node ID of an existing Adjustment Layer." },
track_index: { type: "integer", minimum: 0, description: "Video track index." },
start_seconds: { type: "number", minimum: 0, description: "Exact timeline start in seconds." },
end_seconds: { type: "number", minimum: 0, description: "Exact timeline end in seconds." },
},
required: ["item_id", "track_index", "start_seconds", "end_seconds"],
},
handler: async (args: { item_id: string; track_index: number; start_seconds: number; end_seconds: number }) => {
if (!Number.isFinite(args.start_seconds) || !Number.isFinite(args.end_seconds) || args.end_seconds <= args.start_seconds) {
return { success: false, error: "The adjustment-layer range must have finite start/end values and positive duration." };
}
const script = buildToolScript(`
var seq = app.project.activeSequence;
if (!seq) return __error("No active sequence");
var item = __findProjectItem("${escapeForExtendScript(args.item_id)}");
if (!item) return __error("Adjustment Layer project item not found");
var track = seq.videoTracks[${args.track_index}];
if (!track) return __error("Video track not found: ${args.track_index}");
var startTicks = __secondsToTicks(${args.start_seconds}).toString();
var endTicks = __secondsToTicks(${args.end_seconds}).toString();
var beforeIds = {};
for (var b = 0; b < track.clips.numItems; b++) beforeIds[String(track.clips[b].nodeId)] = true;
seq.insertClip(item, startTicks, ${args.track_index}, 0);
var inserted = null;
for (var i = 0; i < track.clips.numItems; i++) {
var candidate = track.clips[i];
if (!beforeIds[String(candidate.nodeId)] && Math.abs(parseFloat(candidate.start.ticks) - parseFloat(startTicks)) < 1) { inserted = candidate; break; }
}
if (!inserted) return __error("Premiere inserted the Adjustment Layer but it could not be identified");
try { inserted.end = endTicks; } catch (e) { return __error("Could not extend the Adjustment Layer to the requested end: " + e.toString()); }
var actualStart = __ticksToSeconds(inserted.start.ticks);
var actualEnd = __ticksToSeconds(inserted.end.ticks);
var frameTicks = seq.timebase ? parseFloat(seq.timebase) : TICKS_PER_SECOND / 24;
var tolerance = __ticksToSeconds(frameTicks);
if (Math.abs(actualStart - ${args.start_seconds}) > tolerance || Math.abs(actualEnd - ${args.end_seconds}) > tolerance) {
return __error("Adjustment Layer range read back as " + actualStart + "-" + actualEnd + "s, not the requested range");
}
var isAdjustment = false;
try { isAdjustment = inserted.isAdjustmentLayer(); } catch (eCheck) {}
return __result({ inserted: true, verified: true, nodeId: String(inserted.nodeId), trackIndex: ${args.track_index}, startSeconds: actualStart, endSeconds: actualEnd, isAdjustmentLayer: isAdjustment });
`);
return sendCommand(script, bridgeOptions);
},
},
move_clip: {
description: "Move a clip to a new position on the timeline",
parameters: {
+19
View File
@@ -0,0 +1,19 @@
import { readFileSync } from "node:fs";
import { join } from "node:path";
import { describe, expect, it } from "vitest";
const painel = readFileSync(join(process.cwd(), "cep-plugin", "main.js"), "utf8");
describe("preview de falantes na tela Refinar", () => {
it("usa os intervalos convertidos para a origem do WAV", () => {
expect(painel).toContain("var intervalosDePreview = silenceConsultarPreviewDeFalantes(silenceState.sequenceId);");
expect(painel).toContain("start: Number(intervalo.inicio)");
expect(painel).toContain("end: Number(intervalo.fim)");
});
it("não controla a timeline do Premiere durante o preview", () => {
expect(painel).not.toContain("function toggleSpeakerTimeline");
expect(painel).not.toContain("setPlayerPosition");
expect(painel).not.toContain("startPlayback");
});
});
+4 -4
View File
@@ -13,14 +13,14 @@ describe("exportação dos tipos de vídeo no CEP", () => {
expect(painel).toContain('<script charset="utf-8" src="main.js"></script>');
});
it("prioriza a cópia nativa com entrada UTF-8", () => {
it("prioriza a API nativa de clipboard para preservar Unicode", () => {
const lógica = readFileSync(join(raiz, "cep-plugin", "main.js"), "utf8");
const início = lógica.indexOf("function copiarTextoParaAreaDeTransferencia(texto)");
const início = lógica.indexOf("function copiarTextoParaAreaDeTransferencia(texto, mensagem)");
const fim = lógica.indexOf("\n}\n\nfunction copiarComPbcopy", início);
const função = lógica.slice(início, fim);
expect(função.indexOf("copiarComPbcopy(texto, copiarComSelecaoDoPainel)")).toBeLessThan(
função.indexOf("navigator.clipboard.writeText"),
expect(função.indexOf("navigator.clipboard.writeText")).toBeLessThan(
função.indexOf("copiarComPbcopy(texto, copiarComSelecaoDoPainel"),
);
expect(lógica).toContain('copia.stdin.write(texto, "utf8");');
});
+28 -2
View File
@@ -198,12 +198,12 @@ describe("Tool Module Structure", () => {
});
describe("Total Tool Count", () => {
it("all modules together have 342 tools", () => {
it("all modules together have 344 tools", () => {
let total = 0;
for (const mod of ALL_MODULES) {
total += Object.keys(mod.getter(bridgeOptions)).length;
}
expect(total).toBe(343);
expect(total).toBe(344);
});
it("there are 40 directly enumerated modules", () => {
@@ -527,6 +527,32 @@ describe("Tool Handler Behavior", () => {
expect(script).not.toContain("setDisabled(");
});
it("duplicates a linked range at sequence start and preserves the original", async () => {
const tools = getTimelineTools(bridgeOptions);
await (tools.duplicate_linked_range_to_start.handler as any)({ node_ids: ["video-1", "audio-1"] });
const script = mockedSendCommand.mock.calls[0][0];
expect(script).toContain('first.trackType === second.trackType');
expect(script).toContain('video.clip.projectItem.nodeId !== audio.clip.projectItem.nodeId');
expect(script).toContain('Retimed or reversed clips cannot be duplicated');
expect(script).toContain('groups[g][t].clips.numItems > 0');
expect(script).toContain('seq.insertClip(item, "0", video.trackIndex, audio.trackIndex)');
expect(script).toContain('item.setInPoint(String(sourceInTicks), 4)');
expect(script).toContain('item.setOutPoint(String(sourceOutTicks), 4)');
expect(script).toContain('var preservedFirst = __findClip("video-1")');
expect(script).toContain('videoStart + durationTicks');
expect(script).toContain('beforeEndTicks + durationTicks');
expect(script).not.toContain('originalVideo.clip.remove');
expect(script).toContain('clipEffectsPreserved: false');
});
it("rejects an invalid linked-range move before contacting Premiere", async () => {
const tools = getTimelineTools(bridgeOptions);
await expect((tools.duplicate_linked_range_to_start.handler as any)({ node_ids: ["video-only"] }))
.resolves.toMatchObject({ success: false, error: expect.stringContaining("exactly two") });
expect(mockedSendCommand).not.toHaveBeenCalled();
});
it("does not claim legacy source-monitor or stop-playback requests are verified", async () => {
const tools = getPlaybackTools(bridgeOptions);
await (tools.stop_playback.handler as any)({});