feat: criado repositório jhonny-editor no Gitea
criado repositório jhonny-editor no Gitea adicionado script admin/deploy.command com commit automático atualizado admin/DEV-NOTES.md com template limpo Resumo: - 48 arquivos alterados - 26 novos - 19 modificados - 3 removidos 22 files changed, 658 insertions(+), 568 deletions(-) Arquivos: - AGENTS.md - admin/DEV-NOTES.md - admin/OpenCut.command - admin/commit.command - admin/deploy.command - admin/update.command - code/cep-plugin/index.html - code/cep-plugin/main.js - code/cep-plugin/styles.css - code/engine/ARQUITETURA.md - code/engine/arquitetura/README.md - code/engine/gerar_relatorio_timeline.py - code/engine/integracoes/apple_speech/apple_speech_transcriber.swift - code/engine/integracoes/apple_speech/provider_de_transcricao_apple.py - code/engine/integracoes/midia/__init__.py - code/engine/integracoes/whisper/provider_de_transcricao_local.py - code/engine/scanner/__init__.py - code/engine/scanner/coordenacao/__init__.py - code/engine/scanner/descoberta/__init__.py - code/engine/scanner/modelos.py - code/engine/scanner/transcricao_da_timeline.py - code/src/tools/discovery.ts - :memory:.ses - admin/Jhonny.command - admin/inativos/OpenCut.command - admin/inativos/update.command - code/docs/glossario-analise-emocional.md - code/docs/levantamento-apple-vision-e-apple-intelligence.md - code/docs/levantamento-ferramentas-analise-visual-local.md - code/engine/arquitetura/biblioteca-inteligente-de-videos.md - code/engine/executar_scanner.py - code/engine/integracoes/huggingface/ - code/engine/integracoes/midia/extracao_de_metadados.py - code/engine/integracoes/visual/ - code/engine/requirements-visual.txt - code/engine/scanner/configuracao_visual.py - code/engine/scanner/descoberta/descoberta_de_arquivos.py - code/engine/scanner/metadados.py - code/engine/scanner/relatorio_visual.py - code/engine/scanner/retakes/ - code/engine/scanner/visual.py - code/engine/testes/test_analise_visual_local.py - code/engine/testes/test_arquivos_e_metadados.py - code/engine/testes/test_provider_de_transcricao_apple.py - code/relatorios/analise-brools/ - code/relatorios/analise-visual/ - code/relatorios/audio/arquivos/ - code/relatorios/transcricao-timeline.md
This commit is contained in:
@@ -0,0 +1,2 @@
|
|||||||
|
1788878781647
|
||||||
|
0de531b6-352d-4673-95fd-f920138f27ea
|
||||||
@@ -2,6 +2,10 @@
|
|||||||
|
|
||||||
## Project Skills
|
## Project Skills
|
||||||
|
|
||||||
|
## Interface do painel
|
||||||
|
|
||||||
|
Quando o usuário pedir uma implementação “na tela”, “no painel” ou “na interface do MCP”, use o painel CEP em `code/cep-plugin/`, identificado por **MCP for Adobe Premiere Pro** e pelas abas **Editar vídeo**, **Scanner**, **Conexão**, **Modelos** e **Ajustes**. Essa é a interface de produto; não criar fluxos de interface alternativos em `code/uxp-plugin/`.
|
||||||
|
|
||||||
Este projeto usa skills do mattpocock/skills para manter integridade e organização do código.
|
Este projeto usa skills do mattpocock/skills para manter integridade e organização do código.
|
||||||
|
|
||||||
### Skills disponíveis
|
### Skills disponíveis
|
||||||
|
|||||||
+15
-25
@@ -1,33 +1,23 @@
|
|||||||
# DEV-NOTES — Registro de implementações (Ronald)
|
# DEV-NOTES — Registro de Alterações (Jhonny Editor)
|
||||||
|
|
||||||
> Este arquivo é a **fonte da mensagem de commit**.
|
|
||||||
> O script `admin/update.command` lê automaticamente a seção `## NOVO NESTE LOTE`
|
|
||||||
> abaixo (se houver texto) e usa o conteúdo no corpo da mensagem no momento do commit.
|
|
||||||
>
|
|
||||||
> **Como usar:**
|
> **Como usar:**
|
||||||
> 1. Ao trabalhar, adicione bullets em `## NOVO NESTE LOTE` descrevendo o que mudou.
|
> 1. Ao fazer alterações, adicione bullets na seção `## NOVO NESTE LOTE`
|
||||||
> 2. Depois rode `admin/update.command` — ele monta o commit com esse texto + resumo
|
> 2. Rod `admin/deploy.command` — ele lê essas notas e usa como descrição do commit
|
||||||
> automático dos arquivos alterados, faz build/deploy, commita e dá push.
|
> 3. Após o deploy, mova os bullets para `## Histórico` e esvazie `NOVO NESTE LOTE`
|
||||||
> 3. Após o script rodar com sucesso, você pode mover os bullets desta seção para
|
|
||||||
> `## Histórico` e esvaziar `NOVO NESTE LOTE`.
|
|
||||||
|
|
||||||
---
|
---
|
||||||
## NOVO NESTE LOTE
|
|
||||||
<!-- Cole aqui as implementações do lote atual (uma por linha, resumida). Exemplo:
|
|
||||||
- adicionada rota /captions/ai com detecção por lotes
|
|
||||||
- corrigido bug na transcrição de áudio
|
|
||||||
- atualizada tradução pt-BR dos painéis (2909 chaves)
|
|
||||||
-->
|
|
||||||
- adicionado admin/update.command (automatizacao de build+deploy+commit+push)
|
|
||||||
- adicionado admin/DEV-NOTES.md (fonte de texto para a mensagem de commit)
|
|
||||||
|
|
||||||
## Uso / lembretes (não apagar)
|
## NOVO NESTE LOTE
|
||||||
- `admin/update.command` → build + deploy + commit + push em um só
|
- criado repositório jhonny-editor no Gitea
|
||||||
- `admin/DEV-NOTES.md` → fonte da mensagem (seção NOVO NESTE LOTE)
|
- adicionado script admin/deploy.command com commit automático
|
||||||
|
- atualizado admin/DEV-NOTES.md com template limpo
|
||||||
|
|
||||||
## Histórico
|
## Histórico
|
||||||
- Versão inicial: inicialização do repositório Ronald (backend OpenCut + painéis UXP/CEP + admin)
|
<!-- Mova os bullets da seção NOVO NESTE LOTE para cá após cada deploy -->
|
||||||
|
|
||||||
## Anotações / pendências
|
## Notas Técnicas
|
||||||
- Os PNGs de `code/extension/design/` (~46 MB) são mockups de system design — avaliar se mantém ou otimiza.
|
<!-- Anotações sobre decisões técnicas, pendências, etc -->
|
||||||
- `admin/VPS-ACCESS.md` é o único arquivo com credenciais e NÃO deve ser commitado (já está no .gitignore).
|
|
||||||
|
## Anotações / Pendências
|
||||||
|
- repositório: https://gitea.nacarmed.cloud/joaohenrique/jhonny-editor
|
||||||
|
- remote SSH: ssh://git@gitea.nacarmed.cloud:2222/joaohenrique/jhonny-editor.git
|
||||||
|
|||||||
Executable
+47
@@ -0,0 +1,47 @@
|
|||||||
|
#!/bin/zsh
|
||||||
|
# ============================================================
|
||||||
|
# Jhonny Editor - admin/Jhonny.command
|
||||||
|
#
|
||||||
|
# PONTO DE ENTRADA UNICO: builda/instala o painel do Premiere e,
|
||||||
|
# se tudo deu certo, commita + faz push.
|
||||||
|
#
|
||||||
|
# 1. PremiereMCP.command build TS, reinstala a copia do painel
|
||||||
|
# CEP, confere venv Python, reindexa RAG
|
||||||
|
# 2. deploy.command commit + push (usa as notas de
|
||||||
|
# admin/DEV-NOTES.md)
|
||||||
|
#
|
||||||
|
# Se o passo 1 falhar, o script PARA antes de commitar - nada de
|
||||||
|
# publicar painel quebrado.
|
||||||
|
#
|
||||||
|
# Uso:
|
||||||
|
# admin/Jhonny.command ciclo completo (pede confirmacao pro push)
|
||||||
|
# AUTO=1 admin/Jhonny.command nao pergunta antes do push
|
||||||
|
# SKIP_BUILD=1 admin/Jhonny.command pula o build (so commit+push)
|
||||||
|
# SKIP_PYTHON=1 admin/Jhonny.command repassado ao PremiereMCP.command
|
||||||
|
# SKIP_RAG=1 admin/Jhonny.command repassado ao PremiereMCP.command
|
||||||
|
# ============================================================
|
||||||
|
set -u
|
||||||
|
|
||||||
|
ADMIN="$(cd "$(dirname "$0")" && pwd -P)"
|
||||||
|
|
||||||
|
GRN="$(tput setaf 2 2>/dev/null)"; YLW="$(tput setaf 3 2>/dev/null)"
|
||||||
|
RD="$(tput setaf 1 2>/dev/null)"; BLU="$(tput setaf 4 2>/dev/null)"
|
||||||
|
NC="$(tput sgr0 2>/dev/null)"
|
||||||
|
step() { echo; echo "${BLU}============================================================${NC}"
|
||||||
|
echo "${BLU} $*${NC}"
|
||||||
|
echo "${BLU}============================================================${NC}"; }
|
||||||
|
ok() { echo "${GRN}-> OK ${NC}$*"; }
|
||||||
|
die() { echo "${RD}-> Erro: ${NC}$*" >&2; exit 1; }
|
||||||
|
|
||||||
|
if [[ "${SKIP_BUILD:-0}" != "1" ]]; then
|
||||||
|
step "1/2 - PremiereMCP.command (build + instalar painel)"
|
||||||
|
"$ADMIN/PremiereMCP.command" || die "PremiereMCP.command falhou - corrija antes de publicar"
|
||||||
|
ok "build/instalação concluída"
|
||||||
|
else
|
||||||
|
ok "SKIP_BUILD=1 - pulando etapa de build"
|
||||||
|
fi
|
||||||
|
|
||||||
|
step "2/2 - deploy.command (commit + push)"
|
||||||
|
"$ADMIN/deploy.command" || die "deploy.command falhou"
|
||||||
|
|
||||||
|
ok "Jhonny.command concluído"
|
||||||
@@ -1,127 +0,0 @@
|
|||||||
#!/bin/zsh
|
|
||||||
# ============================================================
|
|
||||||
# OpenCut Ronald - admin/commit.command
|
|
||||||
#
|
|
||||||
# COMMIT: monta a mensagem, commita e envia para o Gitea.
|
|
||||||
# 1. Le a secao 'NOVO NESTE LOTE' do admin/DEV-NOTES.md
|
|
||||||
# 2. Junta o resumo automatico dos arquivos alterados
|
|
||||||
# 3. git add -A + commit + push
|
|
||||||
#
|
|
||||||
# O push e a unica etapa que sai desta maquina, entao ele pede
|
|
||||||
# confirmacao. Use AUTO=1 para rodar sem perguntar (ex.: dentro do
|
|
||||||
# OpenCut.command em modo automatico).
|
|
||||||
#
|
|
||||||
# Uso:
|
|
||||||
# admin/commit.command commita e pergunta antes do push
|
|
||||||
# DRY=1 admin/commit.command so mostra o plano, nao commita
|
|
||||||
# AUTO=1 admin/commit.command commita e faz push sem perguntar
|
|
||||||
# ============================================================
|
|
||||||
set -u
|
|
||||||
umask 077
|
|
||||||
|
|
||||||
ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
|
|
||||||
ADMIN="$ROOT/admin"
|
|
||||||
NOTES="$ADMIN/DEV-NOTES.md"
|
|
||||||
REMOTE="origin"
|
|
||||||
|
|
||||||
GRN="$(tput setaf 2 2>/dev/null)"; YLW="$(tput setaf 3 2>/dev/null)"
|
|
||||||
RD="$(tput setaf 1 2>/dev/null)"; BLU="$(tput setaf 4 2>/dev/null)"
|
|
||||||
NC="$(tput sgr0 2>/dev/null)"
|
|
||||||
say() { echo "${BLU}== ${NC}$*"; }
|
|
||||||
ok() { echo "${GRN}-> OK ${NC}$*"; }
|
|
||||||
warn() { echo "${YLW}-> Aviso: ${NC}$*"; }
|
|
||||||
die() { echo "${RD}-> Erro: ${NC}$*" >&2; exit 1; }
|
|
||||||
|
|
||||||
cd "$ROOT" || die "nao consegui entrar em $ROOT"
|
|
||||||
[[ -d .git ]] || die "nao e um repositorio git em $ROOT"
|
|
||||||
git remote get-url "$REMOTE" >/dev/null 2>&1 || die "remote '$REMOTE' nao configurado"
|
|
||||||
|
|
||||||
echo "${BLU}============================================================${NC}"
|
|
||||||
say "commit.command - commit + push"
|
|
||||||
say "repositorio: $(git remote get-url "$REMOTE")"
|
|
||||||
say "branch: $(git branch --show-current)"
|
|
||||||
echo "${BLU}============================================================${NC}"
|
|
||||||
|
|
||||||
# ------------------------------------------------------------
|
|
||||||
# 1) Ha o que commitar?
|
|
||||||
# ------------------------------------------------------------
|
|
||||||
CHANGES="$(git status --porcelain)"
|
|
||||||
if [[ -z "$CHANGES" ]]; then
|
|
||||||
ok "Nenhuma alteracao para commitar."
|
|
||||||
exit 0
|
|
||||||
fi
|
|
||||||
|
|
||||||
say "Alteracoes detectadas:"
|
|
||||||
echo "$CHANGES" | sed 's/^/ /' | head -60
|
|
||||||
echo " ... ($(echo "$CHANGES" | grep -c .) itens)"
|
|
||||||
echo
|
|
||||||
|
|
||||||
# ------------------------------------------------------------
|
|
||||||
# 2) Mensagem do commit
|
|
||||||
# ------------------------------------------------------------
|
|
||||||
MSG_NOTA=""
|
|
||||||
if [[ -f "$NOTES" && -s "$NOTES" ]]; then
|
|
||||||
MSG_NOTA="$(awk '
|
|
||||||
/^## NOVO NESTE LOTE/ {f=1; next}
|
|
||||||
/^## / && f {exit}
|
|
||||||
f
|
|
||||||
' "$NOTES" 2>/dev/null \
|
|
||||||
| sed 's/^[[:space:]]*//; s/[[:space:]]*$//' \
|
|
||||||
| grep -v '^$' \
|
|
||||||
| awk '/^<!--/{c=1;next} /-->/&&c{c=0;next} c{next} {print}' \
|
|
||||||
| sed 's/^[-*] //' \
|
|
||||||
| grep -v '(_vazio' || true)"
|
|
||||||
fi
|
|
||||||
|
|
||||||
FILES="$(git status --porcelain | sed 's/^.../ - /' | head -80)"
|
|
||||||
STAT="$(git diff --stat HEAD | tail -1)"
|
|
||||||
|
|
||||||
if [[ -n "$MSG_NOTA" ]]; then
|
|
||||||
BODY="$MSG_NOTA"
|
|
||||||
else
|
|
||||||
BODY="(sem nota em DEV-NOTES.md; veja os arquivos alterados abaixo)"
|
|
||||||
fi
|
|
||||||
|
|
||||||
COMMIT_MSG="update automatizado: alteracoes no projeto
|
|
||||||
$(printf '%s\n' "$BODY")
|
|
||||||
|
|
||||||
$STAT
|
|
||||||
|
|
||||||
Arquivos alterados:
|
|
||||||
$FILES"
|
|
||||||
|
|
||||||
say "Mensagem do commit a ser usada:"
|
|
||||||
echo "----------------------------------------------"
|
|
||||||
echo "$COMMIT_MSG"
|
|
||||||
echo "----------------------------------------------"
|
|
||||||
echo
|
|
||||||
|
|
||||||
if [[ "${DRY:-0}" = "1" ]]; then
|
|
||||||
say "DRY=1 - nao vou commitar/push."
|
|
||||||
exit 0
|
|
||||||
fi
|
|
||||||
|
|
||||||
# ------------------------------------------------------------
|
|
||||||
# 3) Commit
|
|
||||||
# ------------------------------------------------------------
|
|
||||||
say "Adicionando arquivos e commitando..."
|
|
||||||
git add -A
|
|
||||||
git commit -m "$COMMIT_MSG" || die "commit falhou"
|
|
||||||
ok "commit criado: $(git rev-parse --short HEAD)"
|
|
||||||
echo
|
|
||||||
|
|
||||||
# ------------------------------------------------------------
|
|
||||||
# 4) Push (confirmado - e o passo que publica)
|
|
||||||
# ------------------------------------------------------------
|
|
||||||
if [[ "${AUTO:-0}" != "1" ]]; then
|
|
||||||
printf "Enviar para '%s' agora? [s/N] " "$REMOTE"
|
|
||||||
read -r RESP
|
|
||||||
case "$RESP" in
|
|
||||||
[sS]|[sS][iI][mM]|[yY]|[yY][eE][sS]) ;;
|
|
||||||
*) warn "push cancelado - o commit ficou local (envie depois com: git push $REMOTE HEAD)"; exit 0 ;;
|
|
||||||
esac
|
|
||||||
fi
|
|
||||||
|
|
||||||
say "Fazendo push para '$REMOTE'..."
|
|
||||||
git push "$REMOTE" HEAD || die "push falhou"
|
|
||||||
ok "push concluido"
|
|
||||||
+120
-101
@@ -1,128 +1,147 @@
|
|||||||
#!/bin/zsh
|
#!/bin/zsh
|
||||||
# ============================================================
|
# ============================================================
|
||||||
# OpenCut Ronald - admin/deploy.command
|
# Jhonny Editor - admin/deploy.command
|
||||||
#
|
#
|
||||||
# DEPLOY: garante o backend OpenCut no ar em http://127.0.0.1:5679
|
# DEPLOY COMMIT: verifica alterações, documenta e faz push
|
||||||
# e sobe a ponte de live-updates (WebSocket) que o painel consome.
|
|
||||||
#
|
#
|
||||||
# E este o script que o painel do Premiere dispara quando pede
|
# Fluxo:
|
||||||
# "iniciar backend" (via ~/.opencut/launcher.command). Por isso ele
|
# 1. Verifica se há alterações no repositório
|
||||||
# nao pergunta nada e nao mexe no git: roda sozinho e sai.
|
# 2. Lê as notas do admin/DEV-NOTES.md
|
||||||
|
# 3. Monta mensagem de commit descritiva
|
||||||
|
# 4. Faz commit e push automático
|
||||||
#
|
#
|
||||||
# Uso:
|
# Uso:
|
||||||
# admin/deploy.command sobe (ou recupera) o backend
|
# admin/deploy.command commit + push interativo
|
||||||
# OPENCUT_RESTART=1 admin/deploy.command reinicia o backend atual
|
# AUTO=1 admin/deploy.command commit + push automático
|
||||||
# OPENCUT_NO_OPEN=1 admin/deploy.command nao abre o navegador
|
# DRY=1 admin/deploy.command mostra plano sem executar
|
||||||
# ============================================================
|
# ============================================================
|
||||||
set -u
|
set -u
|
||||||
|
umask 077
|
||||||
|
|
||||||
ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
|
ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
|
||||||
CODE="$ROOT/code"
|
ADMIN="$ROOT/admin"
|
||||||
START_SCRIPT="$CODE/start-opencut.sh"
|
NOTES="$ADMIN/DEV-NOTES.md"
|
||||||
RUNTIME_DIR="$CODE/.opencut-runtime"
|
REMOTE="origin"
|
||||||
LOG="$RUNTIME_DIR/server.log"
|
BRANCH="main"
|
||||||
URL="http://127.0.0.1:5679"
|
|
||||||
HEALTH="$URL/health"
|
|
||||||
RESTART="${OPENCUT_RESTART:-0}"
|
|
||||||
|
|
||||||
notify() { osascript -e "display notification \"$1\" with title \"OpenCut\"" 2>/dev/null || true; }
|
GRN="$(tput setaf 2 2>/dev/null)"; YLW="$(tput setaf 3 2>/dev/null)"
|
||||||
open_ui() { [ "${OPENCUT_NO_OPEN:-0}" = "1" ] || open "$URL" 2>/dev/null || true; }
|
RD="$(tput setaf 1 2>/dev/null)"; BLU="$(tput setaf 4 2>/dev/null)"
|
||||||
|
NC="$(tput sgr0 2>/dev/null)"
|
||||||
|
say() { echo "${BLU}== ${NC}$*"; }
|
||||||
|
ok() { echo "${GRN}-> OK ${NC}$*"; }
|
||||||
|
warn() { echo "${YLW}-> Aviso: ${NC}$*"; }
|
||||||
|
die() { echo "${RD}-> Erro: ${NC}$*" >&2; exit 1; }
|
||||||
|
|
||||||
server_ok() { curl -s -m 5 "$HEALTH" 2>/dev/null | grep -q '"status":"ok"'; }
|
cd "$ROOT" || die "nao consegui entrar em $ROOT"
|
||||||
|
[[ -d .git ]] || die "nao e um repositorio git em $ROOT"
|
||||||
|
git remote get-url "$REMOTE" >/dev/null 2>&1 || die "remote '$REMOTE' nao configurado"
|
||||||
|
|
||||||
stop_opencut_pids() {
|
echo "${BLU}============================================================${NC}"
|
||||||
local signal="$1"
|
say "Jhonny Editor - Deploy com Commit Automático"
|
||||||
local pid
|
say "repositorio: $(git remote get-url "$REMOTE")"
|
||||||
for pid in ${(f)PGREP}; do
|
say "branch: $(git branch --show-current)"
|
||||||
[ -n "$pid" ] && kill "$signal" "$pid" 2>/dev/null || true
|
echo "${BLU}============================================================${NC}"
|
||||||
done
|
|
||||||
}
|
|
||||||
|
|
||||||
# ------------------------------------------------------------
|
# ------------------------------------------------------------
|
||||||
# Ponte de live-updates (WebSocket)
|
# 1) Ha o que commitar?
|
||||||
#
|
|
||||||
# O backend nao sobe a ponte sozinho: ela so existe apos um POST em
|
|
||||||
# /ws/start, e esse endpoint exige o token CSRF que o /health entrega.
|
|
||||||
# Sem este passo o painel abre mostrando "Bridge stopped" e o usuario
|
|
||||||
# precisa clicar em "Start" na mao a cada boot.
|
|
||||||
# ------------------------------------------------------------
|
# ------------------------------------------------------------
|
||||||
start_bridge() {
|
CHANGES="$(git status --porcelain)"
|
||||||
if curl -s -m 5 "$URL/ws/status" 2>/dev/null | grep -q '"running":true'; then
|
if [[ -z "$CHANGES" ]]; then
|
||||||
echo "-> ponte de live-updates ja ativa"
|
ok "Nenhuma alteracao para commitar."
|
||||||
return 0
|
|
||||||
fi
|
|
||||||
local token
|
|
||||||
token="$(curl -s -m 5 -H "Origin: $URL" "$HEALTH" 2>/dev/null \
|
|
||||||
| /usr/bin/python3 -c 'import sys,json
|
|
||||||
try: print(json.load(sys.stdin).get("csrf_token") or "")
|
|
||||||
except Exception: print("")' 2>/dev/null)"
|
|
||||||
if [ -z "$token" ]; then
|
|
||||||
echo "-> Aviso: /health nao devolveu token CSRF; ponte nao iniciada"
|
|
||||||
return 1
|
|
||||||
fi
|
|
||||||
if curl -s -m 10 -X POST "$URL/ws/start" \
|
|
||||||
-H "Content-Type: application/json" \
|
|
||||||
-H "X-OpenCut-Token: $token" \
|
|
||||||
-H "Origin: $URL" -d '{}' 2>/dev/null | grep -q '"running":true'; then
|
|
||||||
echo "-> ponte de live-updates ativa"
|
|
||||||
return 0
|
|
||||||
fi
|
|
||||||
echo "-> Aviso: a ponte de live-updates nao subiu (veja $LOG)"
|
|
||||||
return 1
|
|
||||||
}
|
|
||||||
|
|
||||||
# 1) Ja esta no ar?
|
|
||||||
if server_ok && [ "$RESTART" != "1" ]; then
|
|
||||||
echo "-> backend ja rodando em $URL"
|
|
||||||
start_bridge
|
|
||||||
notify "Backend ja esta rodando em $URL"
|
|
||||||
open_ui
|
|
||||||
exit 0
|
exit 0
|
||||||
fi
|
fi
|
||||||
|
|
||||||
if [ "$RESTART" = "1" ]; then
|
say "Alteracoes detectadas:"
|
||||||
echo "-> reinicio solicitado; substituindo o backend atual"
|
echo "$CHANGES" | sed 's/^/ /' | head -60
|
||||||
|
echo " ... ($(echo "$CHANGES" | grep -c .) itens)"
|
||||||
|
echo
|
||||||
|
|
||||||
|
# ------------------------------------------------------------
|
||||||
|
# 2) Extrair notas do DEV-NOTES.md
|
||||||
|
# ------------------------------------------------------------
|
||||||
|
MSG_NOTA=""
|
||||||
|
if [[ -f "$NOTES" && -s "$NOTES" ]]; then
|
||||||
|
MSG_NOTA="$(awk '
|
||||||
|
/^## NOVO NESTE LOTE/ {f=1; next}
|
||||||
|
/^## / && f {exit}
|
||||||
|
f
|
||||||
|
' "$NOTES" 2>/dev/null \
|
||||||
|
| sed 's/^[[:space:]]*//; s/[[:space:]]*$//' \
|
||||||
|
| grep -v '^$' \
|
||||||
|
| awk '/^<!--/{c=1;next} /-->/&&c{c=0;next} c{next} {print}' \
|
||||||
|
| sed 's/^[-*] //' \
|
||||||
|
| grep -v '(_vazio' || true)"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# 2) Processo antigo travado? Derruba antes de subir outro.
|
# ------------------------------------------------------------
|
||||||
PGREP=$(pgrep -f 'opencut.server' || true)
|
# 3) Montar estatísticas
|
||||||
if [ -n "$PGREP" ]; then
|
# ------------------------------------------------------------
|
||||||
notify "Backend travado detectado - reiniciando..."
|
FILES="$(git status --porcelain | sed 's/^.../ - /' | head -80)"
|
||||||
echo "Encerrando processo(s) antigo(s): $PGREP"
|
STAT="$(git diff --stat HEAD | tail -1)"
|
||||||
stop_opencut_pids TERM
|
TOTAL_FILES=$(echo "$CHANGES" | grep -c .)
|
||||||
sleep 3
|
NEW_FILES=$(echo "$CHANGES" | grep -c '^??' || true)
|
||||||
PGREP=$(pgrep -f 'opencut.server' || true)
|
MODIFIED_FILES=$(echo "$CHANGES" | grep -c '^ M\|^M' || true)
|
||||||
[ -n "$PGREP" ] && stop_opencut_pids KILL
|
DELETED_FILES=$(echo "$CHANGES" | grep -c '^ D\|^D' || true)
|
||||||
sleep 1
|
|
||||||
|
# ------------------------------------------------------------
|
||||||
|
# 4) Montar mensagem do commit
|
||||||
|
# ------------------------------------------------------------
|
||||||
|
if [[ -n "$MSG_NOTA" ]]; then
|
||||||
|
BODY="$MSG_NOTA"
|
||||||
|
else
|
||||||
|
BODY="(sem nota em DEV-NOTES.md)"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# 3) Sobe pelo start-opencut.sh (venv, modelos, ffmpeg e tmp no Merongo)
|
COMMIT_MSG="feat: $(echo "$BODY" | head -1 | cut -c1-50)
|
||||||
if [ ! -x "$START_SCRIPT" ]; then
|
|
||||||
echo "Erro: $START_SCRIPT nao encontrado ou sem permissao de execucao" >&2
|
|
||||||
notify "Falha: start-opencut.sh ausente"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
mkdir -p "$RUNTIME_DIR"
|
|
||||||
mkdir -p "/Volumes/Merongo/SISTEMAS/OPENCUT-MEDIA/tmp"
|
|
||||||
nohup "$START_SCRIPT" > "$LOG" 2>&1 &
|
|
||||||
NEW_PID=$!
|
|
||||||
echo "Backend iniciado (PID $NEW_PID). Aguardando health check..."
|
|
||||||
|
|
||||||
# 4) Espera ate 60s pelo health check
|
$BODY
|
||||||
for i in {1..30}; do
|
|
||||||
if server_ok; then
|
Resumo:
|
||||||
start_bridge
|
- $TOTAL_FILES arquivos alterados
|
||||||
notify "Backend pronto em $URL"
|
- $NEW_FILES novos
|
||||||
open_ui
|
- $MODIFIED_FILES modificados
|
||||||
|
- $DELETED_FILES removidos
|
||||||
|
|
||||||
|
$STAT
|
||||||
|
|
||||||
|
Arquivos:
|
||||||
|
$FILES"
|
||||||
|
|
||||||
|
say "Mensagem do commit:"
|
||||||
|
echo "----------------------------------------------"
|
||||||
|
echo "$COMMIT_MSG"
|
||||||
|
echo "----------------------------------------------"
|
||||||
|
echo
|
||||||
|
|
||||||
|
if [[ "${DRY:-0}" = "1" ]]; then
|
||||||
|
say "DRY=1 - nao vou commitar/push."
|
||||||
exit 0
|
exit 0
|
||||||
fi
|
fi
|
||||||
if ! kill -0 $NEW_PID 2>/dev/null; then
|
|
||||||
break
|
|
||||||
fi
|
|
||||||
sleep 2
|
|
||||||
done
|
|
||||||
|
|
||||||
echo "Falha ao iniciar. Log em $LOG" >&2
|
# ------------------------------------------------------------
|
||||||
notify "Falha ao iniciar o backend. Veja $LOG"
|
# 5) Commit
|
||||||
tail -30 "$LOG"
|
# ------------------------------------------------------------
|
||||||
exit 1
|
say "Adicionando arquivos e commitando..."
|
||||||
|
git add -A
|
||||||
|
git commit -m "$COMMIT_MSG" || die "commit falhou"
|
||||||
|
ok "commit criado: $(git rev-parse --short HEAD)"
|
||||||
|
echo
|
||||||
|
|
||||||
|
# ------------------------------------------------------------
|
||||||
|
# 6) Push (automático ou interativo)
|
||||||
|
# ------------------------------------------------------------
|
||||||
|
if [[ "${AUTO:-0}" = "1" ]]; then
|
||||||
|
RESP="s"
|
||||||
|
else
|
||||||
|
printf "Enviar para '%s' agora? [s/N] " "$REMOTE"
|
||||||
|
read -r RESP
|
||||||
|
fi
|
||||||
|
|
||||||
|
case "$RESP" in
|
||||||
|
[sS]|[sS][iI][mM]|[yY]|[yY][eE][sS]) ;;
|
||||||
|
*) warn "push cancelado - commit local salvo"; exit 0 ;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
say "Fazendo push para '$REMOTE'..."
|
||||||
|
git push "$REMOTE" "$BRANCH" || die "push falhou"
|
||||||
|
ok "push concluido - deploy finalizado!"
|
||||||
|
|||||||
@@ -25,6 +25,9 @@
|
|||||||
<button class="tab-button active" id="tabBtnSilence" role="tab" aria-selected="true" aria-controls="tabSilence" onclick="switchTab('silence')" type="button">
|
<button class="tab-button active" id="tabBtnSilence" role="tab" aria-selected="true" aria-controls="tabSilence" onclick="switchTab('silence')" type="button">
|
||||||
<span class="tab-icon" aria-hidden="true">✂</span>Editar vídeo
|
<span class="tab-icon" aria-hidden="true">✂</span>Editar vídeo
|
||||||
</button>
|
</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="tabBtnBridge" role="tab" aria-selected="false" aria-controls="tabBridge" onclick="switchTab('bridge')" type="button">
|
<button class="tab-button" id="tabBtnBridge" role="tab" aria-selected="false" aria-controls="tabBridge" onclick="switchTab('bridge')" type="button">
|
||||||
<span class="tab-icon" aria-hidden="true">⇄</span>Conexão
|
<span class="tab-icon" aria-hidden="true">⇄</span>Conexão
|
||||||
</button>
|
</button>
|
||||||
@@ -218,6 +221,44 @@
|
|||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- ============================== SCANNER =============================== -->
|
||||||
|
<div class="tab-panel" id="tabScanner" role="tabpanel" aria-labelledby="tabBtnScanner" hidden>
|
||||||
|
<p class="tab-intro">Escolha o que será lido no arquivo original. O Scanner usa os tempos da timeline, mas não exporta frames pelo Premiere.</p>
|
||||||
|
<section class="card">
|
||||||
|
<div class="card-head"><h2>Amostragem e faixas</h2></div>
|
||||||
|
<label class="field-label" for="scannerInterval">Frames de vídeo</label>
|
||||||
|
<select id="scannerInterval" class="select-field"><option value="0.2">Precisa — 5 frames por segundo</option><option value="1" selected>Equilibrada — 1 frame por segundo</option><option value="5">Econômica — 1 frame a cada 5 segundos</option></select>
|
||||||
|
<p class="field-help">Use a amostragem precisa somente no trecho que será reenquadrado.</p>
|
||||||
|
<button class="button button-ghost" id="btnScannerDetectTracks" onclick="scannerDetectTracks()" type="button">Detectar faixas da sequência</button>
|
||||||
|
<p class="field-help" id="scannerTracksHelp">Abra a sequência desejada e detecte as faixas para selecioná-las.</p>
|
||||||
|
<div class="scanner-track-grid"><div><span class="field-label">Faixas de vídeo</span><div class="scanner-track-list" id="scannerVideoTracks"></div></div><div><span class="field-label">Faixas de áudio</span><div class="scanner-track-list" id="scannerAudioTracks"></div></div></div>
|
||||||
|
</section>
|
||||||
|
<section class="card"><div class="card-head"><h2>Transcrição</h2><span class="card-kicker" id="scannerModelCount">—</span></div>
|
||||||
|
<div class="field-grid"><div><label class="field-label" for="scannerTranscribeModel">Modelo instalado</label><select id="scannerTranscribeModel" class="select-field"></select></div><div><label class="field-label" for="scannerTranscribeLanguage">Idioma</label><select id="scannerTranscribeLanguage" class="select-field"></select></div></div>
|
||||||
|
<label class="checkbox-field"><input id="scannerDiarization" type="checkbox"><span>Identificar locutores após transcrever</span></label>
|
||||||
|
<div class="hf-token-status" id="scannerHfStatus" data-state="unset"><span class="check-dot" aria-hidden="true"></span><span id="scannerHfStatusText">Hugging Face não configurado</span><button class="link-button" type="button" onclick="switchTab('settings')">Configurar</button></div>
|
||||||
|
<p class="field-help">A lista inclui somente modelos encontrados nas pastas locais configuradas. O token do Hugging Face é usado apenas na diarização; nunca é salvo no perfil nem no relatório.</p>
|
||||||
|
</section>
|
||||||
|
<div class="scanner-options-grid"><section class="card"><div class="card-head"><h2>O que detectar</h2></div>
|
||||||
|
<label class="checkbox-field"><input type="checkbox" data-scanner-resource="faces" checked><span>Rostos e qualidade facial</span></label>
|
||||||
|
<label class="checkbox-field"><input type="checkbox" data-scanner-resource="pessoas" checked><span>Pessoas</span></label>
|
||||||
|
<label class="checkbox-field"><input type="checkbox" data-scanner-resource="pose" checked><span>Pose e mãos</span></label>
|
||||||
|
<label class="checkbox-field"><input type="checkbox" data-scanner-resource="ocr" checked><span>Texto e códigos</span></label>
|
||||||
|
<label class="checkbox-field"><input type="checkbox" data-scanner-resource="categorias" checked><span>Objetos e categorias</span></label>
|
||||||
|
<label class="checkbox-field"><input type="checkbox" data-scanner-resource="estetica" checked><span>Estética, horizonte e geometria</span></label>
|
||||||
|
<label class="checkbox-field"><input type="checkbox" data-scanner-resource="segmentacao_de_pessoas" checked><span>Segmentação de pessoas</span></label>
|
||||||
|
</section>
|
||||||
|
<section class="card"><div class="card-head"><h2>Leitura temporal</h2></div>
|
||||||
|
<label class="checkbox-field"><input id="scannerScenes" type="checkbox" checked><span>Agrupar cenas</span></label>
|
||||||
|
<label class="checkbox-field"><input id="scannerContinuity" type="checkbox" checked><span>Medir continuidade e cortes</span></label>
|
||||||
|
<label class="checkbox-field"><input id="scannerReframe" type="checkbox"><span>Guardar geometria para reenquadramento</span></label>
|
||||||
|
<label class="checkbox-field"><input id="scannerInterpret" type="checkbox" checked><span>Descrever a cena com Foundation Models</span></label>
|
||||||
|
<div class="button-row"><button class="button button-ghost" onclick="saveScannerProfile()" type="button">Salvar perfil</button><button class="button button-primary" id="btnScannerProcess" onclick="scannerProcess()" type="button" disabled>Processar</button></div>
|
||||||
|
<p class="field-help" id="scannerProfileStatus">Perfil padrão pronto.</p>
|
||||||
|
<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>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- ============================== CONEXÃO ============================== -->
|
<!-- ============================== CONEXÃO ============================== -->
|
||||||
<div class="tab-panel" id="tabBridge" role="tabpanel" aria-labelledby="tabBtnBridge" hidden>
|
<div class="tab-panel" id="tabBridge" role="tabpanel" aria-labelledby="tabBtnBridge" hidden>
|
||||||
|
|
||||||
|
|||||||
@@ -148,6 +148,7 @@ function stepState(n) {
|
|||||||
function switchTab(name) {
|
function switchTab(name) {
|
||||||
var tabs = [
|
var tabs = [
|
||||||
{ key: "silence", panel: "tabSilence", btn: "tabBtnSilence" },
|
{ key: "silence", panel: "tabSilence", btn: "tabBtnSilence" },
|
||||||
|
{ key: "scanner", panel: "tabScanner", btn: "tabBtnScanner" },
|
||||||
{ key: "bridge", panel: "tabBridge", btn: "tabBtnBridge" },
|
{ key: "bridge", panel: "tabBridge", btn: "tabBtnBridge" },
|
||||||
{ key: "models", panel: "tabModels", btn: "tabBtnModels" },
|
{ key: "models", panel: "tabModels", btn: "tabBtnModels" },
|
||||||
{ key: "settings", panel: "tabSettings", btn: "tabBtnSettings" },
|
{ key: "settings", panel: "tabSettings", btn: "tabBtnSettings" },
|
||||||
@@ -164,8 +165,116 @@ function switchTab(name) {
|
|||||||
if (name === "models") renderModelList();
|
if (name === "models") renderModelList();
|
||||||
if (name === "silence") { syncTranscribeModelSelect(); syncLanguageInfo(); }
|
if (name === "silence") { syncTranscribeModelSelect(); syncLanguageInfo(); }
|
||||||
if (name === "settings") renderEditorPersonalities();
|
if (name === "settings") renderEditorPersonalities();
|
||||||
|
if (name === "scanner") { renderScannerTranscriptionOptions(); loadScannerProfile(); renderScannerHuggingFaceStatus(); }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---- Perfil do Scanner -----------------------------------------------------
|
||||||
|
// O painel e o Engine compartilham este contrato versionado. O perfil não
|
||||||
|
// contém caminhos de mídia; estes continuam sendo resolvidos pelo MCP.
|
||||||
|
var SCANNER_PROFILE_KEY = "mcp_scanner_profile_v1";
|
||||||
|
var SCANNER_ALL_RESOURCES = ["faces", "qualidade_facial", "pessoas", "pose", "maos", "ocr", "codigos", "categorias", "estetica", "horizonte", "retangulos", "contornos", "segmentacao_de_pessoas"];
|
||||||
|
|
||||||
|
var scannerTimeline = null;
|
||||||
|
var scannerReportPath = null;
|
||||||
|
var SCANNER_ENGINE_SCRIPT = "/Volumes/Merongo/SISTEMAS/GENIAL SISTEMAS/Jhonny/code/engine/executar_scanner.py";
|
||||||
|
|
||||||
|
// `path` e `os` são carregados mais abaixo, junto com o runtime Node do CEP.
|
||||||
|
// Calcular isso aqui interrompia todo o script antes da inicialização do painel.
|
||||||
|
function scannerOutputRoot() { return path.join(os.homedir(), ".premiere-mcp", "scanner"); }
|
||||||
|
|
||||||
|
function scannerSelectedTracks(kind) { return Array.prototype.slice.call(document.querySelectorAll("[data-scanner-track='" + kind + "']:checked")).map(function (item) { return Number(item.value); }).sort(function (a, b) { return a - b; }); }
|
||||||
|
|
||||||
|
function scannerRenderTracks(kind, tracks, selected) {
|
||||||
|
var target = document.getElementById(kind === "video" ? "scannerVideoTracks" : "scannerAudioTracks");
|
||||||
|
target.innerHTML = "";
|
||||||
|
if (!tracks.length) { target.textContent = "Nenhuma faixa encontrada."; return; }
|
||||||
|
tracks.forEach(function (track) { var label = document.createElement("label"); label.className = "checkbox-field"; var input = document.createElement("input"); input.type = "checkbox"; input.setAttribute("data-scanner-track", kind); input.value = track.index; input.checked = selected.indexOf(track.index) >= 0; label.appendChild(input); label.appendChild(document.createTextNode(" " + track.name + " · " + track.numClips + " clipe(s)")); target.appendChild(label); });
|
||||||
|
}
|
||||||
|
|
||||||
|
function scannerProfileFromScreen() {
|
||||||
|
var enabled = Array.prototype.slice.call(document.querySelectorAll("[data-scanner-resource]:checked")).map(function (item) { return item.getAttribute("data-scanner-resource"); });
|
||||||
|
function include(source, extras) { if (enabled.indexOf(source) >= 0) extras.forEach(function (item) { if (enabled.indexOf(item) < 0) enabled.push(item); }); }
|
||||||
|
include("faces", ["qualidade_facial"]); include("pose", ["maos"]); include("ocr", ["codigos"]); include("estetica", ["horizonte", "retangulos", "contornos"]);
|
||||||
|
var modelo = document.getElementById("scannerTranscribeModel").value;
|
||||||
|
var opcao = document.getElementById("scannerTranscribeModel").selectedOptions[0];
|
||||||
|
return { versao: 1, intervalo_em_segundos: Number(document.getElementById("scannerInterval").value), faixas_de_video: scannerSelectedTracks("video"), faixas_de_audio: scannerSelectedTracks("audio"), recursos_vision: SCANNER_ALL_RESOURCES.filter(function (item) { return enabled.indexOf(item) >= 0; }), detectar_cenas: document.getElementById("scannerScenes").checked, analisar_continuidade: document.getElementById("scannerContinuity").checked, preparar_reenquadramento: document.getElementById("scannerReframe").checked, interpretar_cena: document.getElementById("scannerInterpret").checked, transcricao: { modelo: modelo, caminho_modelo: opcao && opcao.dataset.path || "", idioma: document.getElementById("scannerTranscribeLanguage").value, diarizacao: document.getElementById("scannerDiarization").checked, usar_hugging_face: !!loadHfToken() } };
|
||||||
|
}
|
||||||
|
|
||||||
|
function saveScannerProfile() {
|
||||||
|
try { var profile = scannerProfileFromScreen(); localStorage.setItem(SCANNER_PROFILE_KEY, JSON.stringify(profile)); document.getElementById("scannerProfileStatus").textContent = "Perfil salvo: " + profile.intervalo_em_segundos + " s por frame; " + profile.faixas_de_video.length + " faixa(s) de vídeo."; showToast("ok", "Perfil do Scanner salvo."); } catch (error) { showToast("err", error.message || String(error)); }
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadScannerProfile() {
|
||||||
|
var saved; try { saved = JSON.parse(localStorage.getItem(SCANNER_PROFILE_KEY) || "null"); } catch (_) { saved = null; }
|
||||||
|
if (!saved) return;
|
||||||
|
document.getElementById("scannerInterval").value = String(saved.intervalo_em_segundos || 1);
|
||||||
|
if (scannerTimeline) { scannerRenderTracks("video", scannerTimeline.videoTracks, saved.faixas_de_video || []); scannerRenderTracks("audio", scannerTimeline.audioTracks, saved.faixas_de_audio || []); }
|
||||||
|
var resources = saved.recursos_vision || SCANNER_ALL_RESOURCES;
|
||||||
|
Array.prototype.slice.call(document.querySelectorAll("[data-scanner-resource]")).forEach(function (item) { item.checked = resources.indexOf(item.getAttribute("data-scanner-resource")) >= 0; });
|
||||||
|
document.getElementById("scannerScenes").checked = saved.detectar_cenas !== false; document.getElementById("scannerContinuity").checked = saved.analisar_continuidade !== false; document.getElementById("scannerReframe").checked = !!saved.preparar_reenquadramento; document.getElementById("scannerInterpret").checked = saved.interpretar_cena !== false;
|
||||||
|
if (saved.transcricao) { document.getElementById("scannerTranscribeModel").value = saved.transcricao.modelo || ""; document.getElementById("scannerTranscribeLanguage").value = saved.transcricao.idioma || loadLanguage(); document.getElementById("scannerDiarization").checked = !!saved.transcricao.diarizacao && !!loadHfToken(); }
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderScannerTranscriptionOptions() {
|
||||||
|
var modelos=document.getElementById("scannerTranscribeModel"), idiomas=document.getElementById("scannerTranscribeLanguage"); if (!modelos || !idiomas) return;
|
||||||
|
var atual=modelos.value; modelos.innerHTML=""; var disponiveis=discoverPortugueseModelsOnMerongo();
|
||||||
|
if (!disponiveis.length) { var vazio=document.createElement("option"); vazio.value=""; vazio.textContent="Nenhum modelo com português encontrado no Merongo"; modelos.appendChild(vazio); } else { disponiveis.forEach(function(item){var option=document.createElement("option");option.value=item.nome;option.dataset.path=item.caminho;option.textContent=item.nome+" · "+formatMB(item.tamanho);modelos.appendChild(option);}); if (atual) modelos.value=atual; }
|
||||||
|
idiomas.innerHTML=""; LANGUAGE_CATALOG.forEach(function(item){var option=document.createElement("option");option.value=item.value;option.textContent=item.label;idiomas.appendChild(option);}); idiomas.value=loadLanguage(); document.getElementById("scannerModelCount").textContent=disponiveis.length+" instalado(s)"; renderScannerHuggingFaceStatus();
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderScannerHuggingFaceStatus() {
|
||||||
|
var status = document.getElementById("scannerHfStatus");
|
||||||
|
var text = document.getElementById("scannerHfStatusText");
|
||||||
|
var diarization = document.getElementById("scannerDiarization");
|
||||||
|
if (!status || !text || !diarization) return;
|
||||||
|
var available = !!loadHfToken();
|
||||||
|
status.setAttribute("data-state", available ? "valid" : "unset");
|
||||||
|
text.textContent = available ? "Hugging Face disponível para identificar locutores" : "Configure o token do Hugging Face para identificar locutores";
|
||||||
|
diarization.disabled = !available;
|
||||||
|
if (!available) diarization.checked = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
var MERONGO_MODEL_ROOTS = ["/Volumes/Merongo/SISTEMAS/WHISPERX/whisper/models", "/Volumes/Merongo/SISTEMAS/MODELOSIA/hf-cache", "/Volumes/Merongo/Applications/WhisperX/huggingface/hub", "/Volumes/Merongo/Applications/Trasncritor", "/Volumes/Merongo/SISTEMAS/AUX"];
|
||||||
|
function discoverPortugueseModelsOnMerongo() {
|
||||||
|
var encontrados = {}, roots = [effectiveModelsPath()].concat(MERONGO_MODEL_ROOTS);
|
||||||
|
function walk(dir, profundidade) { if (profundidade > 5) return; var entries; try { entries=fs.readdirSync(dir,{withFileTypes:true}); } catch (_) { return; } entries.forEach(function(entry) { if (!entry.isDirectory() || entry.name === ".locks" || entry.name === ".git") return; var full=path.join(dir,entry.name); if (fs.existsSync(path.join(full,"model.bin")) && modelSupportsPortuguese(full)) { var nome=modelNameFromPath(full); if (!encontrados[nome]) encontrados[nome]={nome:nome,caminho:full,tamanho:folderSizeMB(full)}; return; } walk(full,profundidade+1); }); }
|
||||||
|
roots.filter(function(root,index){return root && roots.indexOf(root)===index;}).forEach(function(root){walk(root,0);});
|
||||||
|
return Object.keys(encontrados).sort().map(function(nome){return encontrados[nome];});
|
||||||
|
}
|
||||||
|
function modelSupportsPortuguese(folder) { try { var tokenizer=path.join(folder,"tokenizer.json"); if (!fs.existsSync(tokenizer)) return false; return fs.readFileSync(tokenizer,"utf-8").indexOf("<|pt|>") >= 0; } catch (_) { return false; } }
|
||||||
|
function modelNameFromPath(folder) { var value=String(folder).replace(/.*faster-whisper-/i,"").replace(/[\\/].*$/,""); return value || path.basename(folder); }
|
||||||
|
|
||||||
|
function scannerDetectTracks() {
|
||||||
|
setBusy("btnScannerDetectTracks", true); document.getElementById("scannerTracksHelp").textContent = "Lendo as faixas da sequência ativa…";
|
||||||
|
cs.evalScript("(function(){try{var s=app.project.activeSequence;if(!s)return JSON.stringify({ok:false,error:'Nenhuma sequência ativa.'});function tracks(c,a){for(var i=0;i<c.numTracks;i++){var t=c[i],clips=[];for(var j=0;j<t.clips.numItems;j++){var x=t.clips[j],p=x.projectItem;clips.push({nodeId:x.nodeId,name:x.name,start:Number(x.start.seconds),end:Number(x.end.seconds),inPoint:Number(x.inPoint.seconds),outPoint:Number(x.outPoint.seconds),duration:Number(x.duration.seconds),sourceFile:p&&p.getMediaPath?p.getMediaPath():'',sourceOffline:p&&p.isOffline?p.isOffline():false});}a.push({index:i,name:t.name||('Faixa '+(i+1)),numClips:t.clips.numItems,clips:clips});}}var v=[],a=[];tracks(s.videoTracks,v);tracks(s.audioTracks,a);return JSON.stringify({ok:true,id:s.sequenceID,name:s.name,end:Number(s.end.seconds),videoTracks:v,audioTracks:a});}catch(e){return JSON.stringify({ok:false,error:String(e)});}}())", function(raw) { setBusy("btnScannerDetectTracks", false); var data; try { data=JSON.parse(raw); } catch (_) { data={ok:false,error:"Resposta inválida do Premiere."}; } if (!data.ok) { document.getElementById("scannerTracksHelp").textContent=data.error; showToast("err",data.error); return; } scannerTimeline=data; var saved; try { saved=JSON.parse(localStorage.getItem(SCANNER_PROFILE_KEY)||"null")||{}; } catch (_) { saved={}; } scannerRenderTracks("video",data.videoTracks,saved.faixas_de_video||data.videoTracks.map(function(t){return t.index;})); scannerRenderTracks("audio",data.audioTracks,saved.faixas_de_audio||[]); document.getElementById("scannerTracksHelp").textContent=data.name+": "+data.videoTracks.length+" faixa(s) de vídeo e "+data.audioTracks.length+" de áudio detectadas."; document.getElementById("btnScannerProcess").disabled=false; });
|
||||||
|
}
|
||||||
|
|
||||||
|
function scannerProcess() {
|
||||||
|
if (!scannerTimeline) { showToast("err", "Primeiro detecte as faixas da sequência."); return; }
|
||||||
|
var profile; try { profile=scannerProfileFromScreen(); } catch (error) { showToast("err", error.message); return; }
|
||||||
|
if (!profile.faixas_de_video.length && !profile.faixas_de_audio.length) { showToast("err", "Selecione ao menos uma faixa."); return; }
|
||||||
|
saveScannerProfile(); var outputRoot=scannerOutputRoot(); ensureDir(outputRoot);
|
||||||
|
var stamp=new Date().toISOString().replace(/[:.]/g,"-");
|
||||||
|
var input=path.join(outputRoot,"pedido-"+stamp+".json");
|
||||||
|
scannerReportPath=path.join(outputRoot,"relatorio-"+stamp+".json");
|
||||||
|
fs.writeFileSync(input,JSON.stringify({perfil:profile,timeline:scannerTimeline},null,2),"utf-8");
|
||||||
|
setBusy("btnScannerProcess",true); taskStart("Scanner", "Preparando análise", null);
|
||||||
|
var spawnOptions={cwd:"/Volumes/Merongo/SISTEMAS/GENIAL SISTEMAS/Jhonny/code",stdio:["ignore","pipe","pipe"]};
|
||||||
|
var huggingFaceEnvironment=hfSpawnOptions(); if(huggingFaceEnvironment&&huggingFaceEnvironment.env) spawnOptions.env=huggingFaceEnvironment.env;
|
||||||
|
var child=childProcess.spawn("python3",[SCANNER_ENGINE_SCRIPT,input,scannerReportPath],spawnOptions);
|
||||||
|
var stderr="", stdout="";
|
||||||
|
child.stdout.on("data",function(chunk) {
|
||||||
|
stdout += chunk.toString(); var lines=stdout.split("\n"); stdout=lines.pop();
|
||||||
|
lines.forEach(function(line) { try { var event=JSON.parse(line); if (event.evento === "progresso") taskUpdate({stage:event.etapa, detail:event.clipe || "", pct:event.percentual}); if (event.evento === "concluido" && event.arquivo) scannerReportPath=event.arquivo; } catch (_) {} });
|
||||||
|
});
|
||||||
|
child.stderr.on("data",function(chunk){stderr+=chunk.toString();});
|
||||||
|
child.on("error",function(error){setBusy("btnScannerProcess",false);taskEnd(false,"Scanner indisponível");showToast("err",error.message);});
|
||||||
|
child.on("close",function(code){setBusy("btnScannerProcess",false);if(code!==0||!fs.existsSync(scannerReportPath)){taskEnd(false,"Scanner falhou");showToast("err",stderr||"Não foi possível gerar o relatório.");return;}taskEnd(true,"Relatório JSON gerado");document.getElementById("scannerReportPath").textContent=scannerReportPath;document.getElementById("scannerReportCard").hidden=false;showToast("ok","Scanner concluído.");});
|
||||||
|
}
|
||||||
|
|
||||||
|
function scannerOpenReport() { if (!scannerReportPath) return; childProcess.spawn("open",[scannerReportPath],{detached:true,stdio:"ignore"}).unref(); }
|
||||||
|
function scannerCopyReport() { if (!scannerReportPath) return; try { var copy=childProcess.spawn("pbcopy"); copy.stdin.end(fs.readFileSync(scannerReportPath,"utf-8")); showToast("ok","Conteúdo do relatório copiado."); } catch (error) { showToast("err",error.message); } }
|
||||||
|
|
||||||
// Mostra na aba de edição o idioma configurado, para não precisar ir até
|
// Mostra na aba de edição o idioma configurado, para não precisar ir até
|
||||||
// Modelos só para conferir com que idioma a transcrição vai rodar.
|
// Modelos só para conferir com que idioma a transcrição vai rodar.
|
||||||
function syncLanguageInfo() {
|
function syncLanguageInfo() {
|
||||||
|
|||||||
@@ -145,6 +145,11 @@ h1, h2, h3 { margin: 0; }
|
|||||||
.tab-button.active .tab-icon { color: var(--violet); opacity: 1; }
|
.tab-button.active .tab-icon { color: var(--violet); opacity: 1; }
|
||||||
.tab-panel[hidden] { display: none; }
|
.tab-panel[hidden] { display: none; }
|
||||||
.tab-intro { margin: 0 0 12px; color: var(--text-muted); font-size: 10px; }
|
.tab-intro { margin: 0 0 12px; color: var(--text-muted); font-size: 10px; }
|
||||||
|
.scanner-track-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 10px; margin-top: 10px; }
|
||||||
|
.scanner-track-list { min-height: 34px; max-height: 150px; overflow-y: auto; border: 1px solid var(--border); border-radius: 6px; background: var(--surface-deep); padding: 5px; }
|
||||||
|
.scanner-track-list .checkbox-field { margin: 2px 0; padding: 5px; border: 0; background: none; }
|
||||||
|
.scanner-options-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 10px; align-items: start; }
|
||||||
|
.scanner-options-grid .card { margin: 0 0 10px; }
|
||||||
|
|
||||||
/* --------------------- Painel de progresso (task dock) ------------------- */
|
/* --------------------- Painel de progresso (task dock) ------------------- */
|
||||||
.task-dock {
|
.task-dock {
|
||||||
@@ -584,6 +589,7 @@ button:focus-visible, input:focus-visible, select:focus-visible, textarea:focus-
|
|||||||
.tab-button { font-size: 0; gap: 0; padding: 10px 2px; }
|
.tab-button { font-size: 0; gap: 0; padding: 10px 2px; }
|
||||||
.tab-icon { font-size: 14px; }
|
.tab-icon { font-size: 14px; }
|
||||||
.field-grid { grid-template-columns: minmax(0, 1fr); }
|
.field-grid { grid-template-columns: minmax(0, 1fr); }
|
||||||
|
.scanner-track-grid, .scanner-options-grid { grid-template-columns: minmax(0, 1fr); }
|
||||||
.status-panel { grid-template-columns: 38px minmax(0, 1fr); }
|
.status-panel { grid-template-columns: 38px minmax(0, 1fr); }
|
||||||
.command-stat { grid-column: 2; padding: 8px 0 0; text-align: left; }
|
.command-stat { grid-column: 2; padding: 8px 0 0; text-align: left; }
|
||||||
.command-stat strong, .command-stat span { display: inline; }
|
.command-stat strong, .command-stat span { display: inline; }
|
||||||
|
|||||||
@@ -0,0 +1,50 @@
|
|||||||
|
# Glossário da análise emocional
|
||||||
|
|
||||||
|
## Modelo utilizado
|
||||||
|
|
||||||
|
O classificador local é `superb/wav2vec2-base-superb-er`, executado com
|
||||||
|
`transformers` e PyTorch. O modelo foi treinado para classificação de emoção
|
||||||
|
em fala e retorna quatro classes: `neu`, `hap`, `ang` e `sad`.
|
||||||
|
|
||||||
|
## Campos do JSON
|
||||||
|
|
||||||
|
### `emocao`
|
||||||
|
|
||||||
|
Classe emocional mais provável para o segmento de áudio.
|
||||||
|
|
||||||
|
### `confianca_emocao`
|
||||||
|
|
||||||
|
Probabilidade atribuída pelo classificador à classe escolhida, entre `0` e
|
||||||
|
`1`. Não é uma certeza nem uma medida de qualidade da transcrição. Um valor
|
||||||
|
de `0.58`, por exemplo, indica uma classificação fraca/moderada; valores
|
||||||
|
próximos de `1` indicam maior segurança relativa do modelo.
|
||||||
|
|
||||||
|
### `palavras`
|
||||||
|
|
||||||
|
Lista de palavras reconhecidas pelo Whisper, cada uma com seu intervalo de
|
||||||
|
tempo no arquivo original ou na timeline projetada.
|
||||||
|
|
||||||
|
### `caracteristicas_acusticas`
|
||||||
|
|
||||||
|
Campo reservado para medidas como pitch, energia, velocidade da fala e
|
||||||
|
pausas. Ainda não é preenchido pelo classificador atual.
|
||||||
|
|
||||||
|
## Glossário das classes
|
||||||
|
|
||||||
|
| Código | Significado | Interpretação editorial |
|
||||||
|
|---|---|---|
|
||||||
|
| `neu` | Neutral | Fala sem sinal emocional forte ou com expressão equilibrada. |
|
||||||
|
| `hap` | Happy | Sinal acústico associado a alegria, animação ou entusiasmo. |
|
||||||
|
| `ang` | Angry | Sinal acústico associado a irritação, tensão ou intensidade agressiva. |
|
||||||
|
| `sad` | Sad | Sinal acústico associado a tristeza, abatimento ou baixa energia. |
|
||||||
|
|
||||||
|
Os códigos são abreviações do modelo: `hap` significa *happy* e `ang`
|
||||||
|
significa *angry*. Eles não devem ser interpretados como diagnóstico do
|
||||||
|
estado emocional real da pessoa. Ruído, sotaque, idioma, contexto, atuação,
|
||||||
|
ironia e qualidade da gravação podem alterar a classificação.
|
||||||
|
|
||||||
|
## Decisão de uso no editor
|
||||||
|
|
||||||
|
A emoção é calculada por segmento/frase, enquanto as palavras mantêm seus
|
||||||
|
timestamps individuais. Isso permite usar a emoção como sinal editorial para
|
||||||
|
ordenar ou sugerir cortes sem atribuir uma emoção artificial a cada palavra.
|
||||||
@@ -0,0 +1,287 @@
|
|||||||
|
# Levantamento: Apple Vision e Apple Intelligence para análise editorial de vídeo
|
||||||
|
|
||||||
|
Data: 2026-09-08
|
||||||
|
Escopo: analisar vídeos localmente, construir contexto confiável para o programa e, somente depois, pedir à IA uma seleção editorial revisável.
|
||||||
|
|
||||||
|
## Resumo executivo
|
||||||
|
|
||||||
|
A ideia é viável, mas a primeira entrega deve ser um **arquivo cronológico de contexto multimodal**, não um motor de decisão editorial. Esse arquivo será a fonte que posteriormente poderá ser lida pelo editor humano ou por uma IA de decisão.
|
||||||
|
|
||||||
|
O pipeline tem duas camadas:
|
||||||
|
|
||||||
|
1. **Apple Vision + AVFoundation** leem os frames e produzem fatos temporais: texto, rostos, pessoas, pose, códigos, saliência, qualidade, similaridade e movimento.
|
||||||
|
2. **Apple Intelligence via Foundation Models**, inicialmente opcional, pode transformar os fatos em descrições de cena e resumos. Mais adiante, outra chamada de IA poderá ler o arquivo completo e decidir cortes, permanências e ordem.
|
||||||
|
|
||||||
|
O arquivo de contexto não deve ser confundido com a decisão. A precisão temporal vem do nosso pipeline de frames e da transcrição; a IA pode interpretar e decidir depois. Toda decisão futura deverá apontar de volta para os intervalos e evidências do arquivo, para que o editor saiba “cortar ou manter” e por quê.
|
||||||
|
|
||||||
|
## O que cada tecnologia faz
|
||||||
|
|
||||||
|
### AVFoundation: acessar o vídeo com timestamp correto
|
||||||
|
|
||||||
|
AVFoundation deve ser a camada de decodificação. Ela lê o `AVAsset`, duração, taxa de frames, orientação, dimensões e amostras de vídeo (`CMSampleBuffer`/`CVPixelBuffer`). O ponto importante é preservar o timestamp do arquivo de origem e a transformação de orientação antes de entregar a imagem ao Vision.
|
||||||
|
|
||||||
|
O projeto hoje extrai uma amostra via OpenCV. Isso é suficiente para o primeiro protótipo, mas um helper Swift com AVFoundation será melhor quando precisarmos de timestamps exatos, inclusive VFR; leitura de frames próximos a cortes; orientação, HDR e color space explícitos; processamento em streaming; e `CVPixelBuffer` direto para Vision e Foundation Models.
|
||||||
|
|
||||||
|
`AVAssetReader` é a API oficial para ler dados de mídia de um `AVAsset`.
|
||||||
|
|
||||||
|
### Vision: fatos observáveis por frame
|
||||||
|
|
||||||
|
O Vision trabalha com o padrão “criar request → executar sobre imagem/frame → ler observations”. Também possui `VNSequenceRequestHandler` para requests que acompanham uma sequência de frames. As caixas usam coordenadas normalizadas; o adapter deve converter somente na borda do sistema e manter a convenção do domínio documentada.
|
||||||
|
|
||||||
|
| Capacidade | API Vision | Evidência útil para edição |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| Texto em tela | `RecognizeTextRequest` / `VNRecognizeTextRequest` | texto, confiança, idioma e bounding box; localizar cartelas, placas, telas e erros de legenda |
|
||||||
|
| Regiões de texto | `DetectTextRectanglesRequest` | área de texto mesmo quando o OCR não consegue ler o conteúdo |
|
||||||
|
| Rostos | `DetectFaceRectanglesRequest` | quantidade, caixas e presença/posição de rosto |
|
||||||
|
| Landmarks faciais | `DetectFaceLandmarksRequest` | olhos, boca, sobrancelhas e contornos; útil para enquadramento e olhos fechados, não para inferir emoção |
|
||||||
|
| Qualidade facial | `DetectFaceCaptureQualityRequest` | sinal de nitidez/qualidade de captura do rosto |
|
||||||
|
| Pessoas | `DetectHumanRectanglesRequest` e requests de pessoa | presença, quantidade e ocupação aproximada |
|
||||||
|
| Pose corporal | `DetectHumanBodyPoseRequest` | juntas, posição e confiança; base para postura e ação simples |
|
||||||
|
| Mãos | `DetectHumanHandPoseRequest` | juntas da mão e confiança; base para gestos definidos por regras |
|
||||||
|
| Animais | `RecognizeAnimalsRequest` | presença de gato, cachorro e outros animais reconhecidos |
|
||||||
|
| Barcodes/QR | `DetectBarcodesRequest` | conteúdo, simbologia e localização |
|
||||||
|
| Classificação | `ClassifyImageRequest` / `VNClassifyImageRequest` | rótulos gerais e confiança; sinal de assunto, não verdade editorial |
|
||||||
|
| Similaridade visual | `GenerateImageFeaturePrintRequest` | distância entre frames; deduplicar quase-iguais e agrupar takes |
|
||||||
|
| Saliencia | `GenerateAttentionBasedSaliencyImageRequest` e `GenerateObjectnessBasedSaliencyImageRequest` | regiões que atraem atenção; apoiar crop e composição |
|
||||||
|
| Máscara de pessoa | `GeneratePersonInstanceMaskRequest` / segmentação | separar pessoa/fundo e medir ocupação |
|
||||||
|
| Estética | `CalculateImageAestheticsScoresRequest` | score auxiliar para escolher thumbnail ou frame representativo |
|
||||||
|
| Horizonte | `DetectHorizonRequest` | inclinação do horizonte; sinal técnico/compositivo |
|
||||||
|
| Movimento/tracking | `TrackObjectRequest`, `TrackRectangleRequest`, optical flow | continuidade de sujeito, deslocamento e movimento entre frames |
|
||||||
|
|
||||||
|
Essas APIs não entregam, sozinhas, “essa é a melhor tomada”, “a pessoa está nervosa” ou “este trecho deve entrar no corte”. Elas entregam observações e scores. Conceitos editoriais precisam ser derivados por regras, comparação temporal ou Foundation Models.
|
||||||
|
|
||||||
|
### Inventário ampliado da documentação
|
||||||
|
|
||||||
|
Além da tabela acima, o framework inclui estas famílias que podem entrar no Scanner conforme o caso:
|
||||||
|
|
||||||
|
| Família | O que pode ser extraído |
|
||||||
|
| --- | --- |
|
||||||
|
| Documentos | estrutura de documento, palavras, linhas, parágrafos, listas, tabelas, regiões de texto e códigos; útil para cenas com prontuário, formulário, receita ou tela organizada |
|
||||||
|
| Segmentação interativa | máscara a partir de pontos, retângulo ou rabisco; útil quando o usuário indicar manualmente o sujeito |
|
||||||
|
| Pose 3D | pontos do corpo humano em espaço 3D relativo à câmera; exige validar se o vídeo e o dispositivo fornecem resultado estável |
|
||||||
|
| Pose animal | pontos/partes do corpo de animais; útil para identificar ação em cenas com animais |
|
||||||
|
| Trajetórias | trajetória de formas em movimento parabólico; caso especializado, não um detector geral de ação |
|
||||||
|
| Contornos | linhas/arestas da imagem; útil para medir forma, desenho e mudanças bruscas, mas gera muitos dados |
|
||||||
|
| Retângulos | quadriláteros/regiões retangulares projetadas; útil para telas, documentos, quadros e superfícies |
|
||||||
|
| Mancha na lente | indício de smudge na lente em frame ou vídeo; alerta técnico |
|
||||||
|
| Registro de imagem | transformação translacional, homográfica ou alinhamento entre imagens; útil para comparar takes e estabilidade |
|
||||||
|
| Subject lifting | máscara de objetos perceptíveis ou pessoas para separar primeiro plano e fundo |
|
||||||
|
| Modelo customizado | `CoreMLRequest` executa um modelo Core ML escolhido pelo produto; as classes e atributos passam a depender do modelo distribuído por nós |
|
||||||
|
|
||||||
|
Os resultados normalmente são uma combinação de `confidence`, `boundingBox`/região normalizada, pontos/landmarks, rótulos, score, máscara, distância ou transformação. Para vídeo, cada resultado precisa receber o `timestamp` do frame; requests com estado devem usar o mesmo handler de sequência.
|
||||||
|
|
||||||
|
**O que o Vision não oferece pronto:** descrição livre de “o que está acontecendo”, emoção da fala, identidade da pessoa, intenção, qualidade narrativa, transcrição de áudio ou decisão de corte. Essas camadas vêm, respectivamente, de Foundation Models/VLM, análise de áudio, regras de privacidade, transcrição e um motor editorial posterior. O Vision pode fornecer os sinais que apoiam algumas dessas inferências, mas não deve receber esse significado como se fosse uma observação nativa.
|
||||||
|
|
||||||
|
Fontes: [Vision](https://developer.apple.com/documentation/vision), [VNSequenceRequestHandler](https://developer.apple.com/documentation/vision/vnsequencerequesthandler), [feature prints](https://developer.apple.com/documentation/vision/generateimagefeatureprintrequest) e [thumbnails de vídeo](https://developer.apple.com/documentation/vision/generating-thumbnails-from-videos).
|
||||||
|
|
||||||
|
### Foundation Models: interpretar evidências e gerar um plano
|
||||||
|
|
||||||
|
`FoundationModels` dá acesso ao `SystemLanguageModel`, o modelo de linguagem on-device que alimenta o Apple Intelligence, quando estiver disponível no dispositivo. Ele é bom para resumir transcrição e evidências; extrair entidades, assuntos e tags; classificar trechos segundo critérios fornecidos; comparar descrições de cenas; gerar JSON/Swift estruturado com `@Generable`; e propor títulos, capítulos, selects e versões para plataformas.
|
||||||
|
|
||||||
|
A documentação atual também descreve prompting multimodal: uma imagem pode ser anexada ao prompt e o modelo pode analisá-la. Isso permite enviar frames ou uma contact sheet, mas não transforma a sessão em um analisador de vídeo temporal. A orquestração de frames, timestamps e cenas continua sendo responsabilidade do nosso programa.
|
||||||
|
|
||||||
|
O desenho mais forte para nós é usar ferramentas: o modelo recebe contexto textual e pode chamar uma ferramenta controlada que consulta evidências Vision, OCR ou um índice local. A ferramenta retorna fatos; o modelo interpreta; o modelo não ganha permissão implícita para alterar o Premiere.
|
||||||
|
|
||||||
|
Limitações relevantes:
|
||||||
|
|
||||||
|
- verificar disponibilidade em runtime com `SystemLanguageModel.default.availability`;
|
||||||
|
- a janela on-device documentada é de 4096 tokens por sessão;
|
||||||
|
- registrar prompt e versão, pois o modelo muda com atualizações do sistema;
|
||||||
|
- validar respostas estruturadas; não usar texto livre como contrato de edição;
|
||||||
|
- limitar imagens por sessão, pois muitas imagens aumentam custo, latência e contexto;
|
||||||
|
- manter fallback quando o modelo local estiver indisponível.
|
||||||
|
|
||||||
|
Fontes: [Foundation Models](https://developer.apple.com/documentation/foundationmodels), [geração e tarefas](https://developer.apple.com/documentation/foundationmodels/generating-content-and-performing-tasks-with-foundation-models), [prompting multimodal](https://developer.apple.com/documentation/foundationmodels/analyzing-images-with-multimodal-prompting), [context window](https://developer.apple.com/documentation/technotes/tn3193-managing-the-on-device-foundation-model-s-context-window) e [SystemLanguageModel](https://developer.apple.com/documentation/foundationmodels/systemlanguagemodel).
|
||||||
|
|
||||||
|
## Como analisar frame a frame sem desperdiçar processamento
|
||||||
|
|
||||||
|
“Frame a frame” deve significar que cada frame escolhido tem timestamp e evidências próprias — não necessariamente processar todos os 24/30/60 frames por segundo na primeira passagem.
|
||||||
|
|
||||||
|
```text
|
||||||
|
Vídeo original
|
||||||
|
↓ AVFoundation / sampler
|
||||||
|
Frames de baixa cadência + frames ao redor de cortes
|
||||||
|
↓ Vision barato
|
||||||
|
Mapa temporal de cenas, movimento, qualidade, faces e similaridade
|
||||||
|
↓ seleção de candidatos
|
||||||
|
Frames representativos + contact sheet + transcrição por intervalo
|
||||||
|
↓ Foundation Models
|
||||||
|
Descrição, tags, ranking e plano editorial estruturado
|
||||||
|
↓ revisão
|
||||||
|
Operações Premiere com IDs e guards de revisão
|
||||||
|
```
|
||||||
|
|
||||||
|
**Passagem 1 — cobertura:** 1 frame por segundo, ou cadência ajustada à duração, mais frames imediatamente antes/depois de mudanças de cena. Usar qualidade, faces/pessoas, OCR curto, feature print e diferença entre frames.
|
||||||
|
|
||||||
|
**Passagem 2 — refinamento:** subdividir somente as cenas candidatas. Aumentar a cadência para localizar entrada/saída do sujeito, fala, gesto, olho fechado, blur, troca de enquadramento e continuidade.
|
||||||
|
|
||||||
|
**Passagem 3 — semântica:** enviar ao Foundation Models uma descrição compacta por cena e, quando necessário, uma contact sheet com poucos frames etiquetados por timestamp. A IA escolhe entre evidências já localizadas, não inventa intervalos.
|
||||||
|
|
||||||
|
O exemplo oficial da Apple para thumbnails combina score estético por amostra e feature prints para evitar frames visualmente semelhantes. É um bom ponto de partida para “melhor frame da cena”, mas o score estético deve continuar sendo somente um sinal auxiliar.
|
||||||
|
|
||||||
|
## Produto inicial: arquivo cronológico de contexto
|
||||||
|
|
||||||
|
O primeiro produto deve ser um arquivo único por projeto ou vídeo, ordenado cronologicamente. Ele funciona como um “roteiro técnico aumentado”: cada intervalo contém o que foi falado, o que aparece na imagem e os sinais que podem ajudar uma decisão futura.
|
||||||
|
|
||||||
|
Pode haver duas representações do mesmo conteúdo:
|
||||||
|
|
||||||
|
- `contexto-video.json`: formato completo, estável e consumível por máquina;
|
||||||
|
- `contexto-video.md` ou `.srt` enriquecido: leitura humana, com timestamp, transcrição e descrição da cena no mesmo bloco.
|
||||||
|
|
||||||
|
O JSON é a fonte de verdade. Markdown e SRT são visões derivadas e não devem substituir os IDs, timestamps, confiança e revisões do JSON.
|
||||||
|
|
||||||
|
Exemplo de unidade cronológica:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"segment_id": "clip-07@42.100-49.800",
|
||||||
|
"source_id": "project-item-123",
|
||||||
|
"start": 42.1,
|
||||||
|
"end": 49.8,
|
||||||
|
"transcription": {
|
||||||
|
"text": "A frase falada neste intervalo.",
|
||||||
|
"speaker": "speaker-01",
|
||||||
|
"confidence": 0.93
|
||||||
|
},
|
||||||
|
"caption": {"text": "A frase falada neste intervalo."},
|
||||||
|
"visual": {
|
||||||
|
"description": "Pessoa em plano médio falando para a câmera.",
|
||||||
|
"objects": ["person", "microphone"],
|
||||||
|
"faces": 1,
|
||||||
|
"text_on_screen": [],
|
||||||
|
"composition": {"face_well_framed": true, "caption_area_available": true},
|
||||||
|
"technical_alerts": []
|
||||||
|
},
|
||||||
|
"speech_signals": {
|
||||||
|
"emotion": "calm",
|
||||||
|
"confidence": 0.61,
|
||||||
|
"provider": "speech_emotion_provider"
|
||||||
|
},
|
||||||
|
"representative_frames": [
|
||||||
|
{"frame_id": "clip-07@44.000", "timestamp": 44.0, "path": "frames/...jpg"}
|
||||||
|
],
|
||||||
|
"evidence_ids": ["clip-07@44.000:face-01"],
|
||||||
|
"status": "observed"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
A descrição visual deve ser marcada como `observed`, `inferred` ou `unknown`. “Pessoa em plano médio” pode ser uma descrição apoiada por Vision; “parece confiante” é uma interpretação e deve carregar confiança e provider. Emoção de fala também é um sinal probabilístico, não um fato psicológico. Nunca esconder incerteza dentro de uma frase narrativa.
|
||||||
|
|
||||||
|
Esse arquivo permite ao editor navegar cronologicamente e responder, em uma etapa posterior:
|
||||||
|
|
||||||
|
- manter ou cortar este intervalo;
|
||||||
|
- escolher entre takes semelhantes;
|
||||||
|
- remover repetição, silêncio, erro técnico ou retake;
|
||||||
|
- preservar uma fala importante mesmo quando o frame não é o mais bonito;
|
||||||
|
- reorganizar trechos para uma intenção editorial específica.
|
||||||
|
|
||||||
|
## Contexto que o programa deve montar
|
||||||
|
|
||||||
|
Cada evidência deve ser persistida separadamente da interpretação:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"evidence_id": "clip-07@12.480:face-01",
|
||||||
|
"clip_id": "clip-07",
|
||||||
|
"source_id": "project-item-123",
|
||||||
|
"timestamp": 12.48,
|
||||||
|
"interval": {"start": 12.0, "end": 13.0},
|
||||||
|
"kind": "face",
|
||||||
|
"value": {"bounding_box": {"x": 0.31, "y": 0.18, "width": 0.22, "height": 0.42}},
|
||||||
|
"confidence": 0.97,
|
||||||
|
"provider": "apple_vision",
|
||||||
|
"model_or_revision": "vision-revision-x",
|
||||||
|
"source_revision": "sha256:..."
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
O arquivo deve conter intenção opcional do usuário; metadados do clipe e da timeline; transcrição temporal; legenda correspondente; intervalos de cena; OCR consolidado; presença/posição de pessoas e rostos; qualidade e composição; movimento, similaridade e possíveis retakes; sinais de emoção da fala; frames representativos; limitações, falhas de provider e confiança.
|
||||||
|
|
||||||
|
Não enviar uma lista gigantesca de observações repetidas. Consolidar eventos contínuos, por exemplo `rosto presente de 12.0s a 18.4s`, mantendo os frames brutos para auditoria.
|
||||||
|
|
||||||
|
## Etapa posterior: decisão editorial por IA
|
||||||
|
|
||||||
|
Depois que o arquivo cronológico estiver validado, podemos entregá-lo a uma IA de decisão. Essa IA será complementar ao contexto, não parte obrigatória da primeira análise. Ela poderá receber:
|
||||||
|
|
||||||
|
1. o JSON completo, quando couber no contexto;
|
||||||
|
2. chunks cronológicos com estado resumido entre eles;
|
||||||
|
3. uma intenção editorial, como “vídeo de 60 segundos para apresentação”;
|
||||||
|
4. regras explícitas, como preservar falas sobre determinado assunto;
|
||||||
|
5. autorização para produzir somente uma decisão ou também um plano de edição.
|
||||||
|
|
||||||
|
Quando o arquivo for grande, não devemos simplesmente truncá-lo. O programa deve fazer chunking cronológico, consolidar o estado e gerar um resultado final com referências globais aos `segment_id` e `evidence_ids` originais.
|
||||||
|
|
||||||
|
## Contrato da saída editorial
|
||||||
|
|
||||||
|
A saída posterior da IA de decisão deve ser um artefato de planejamento, não uma edição aplicada:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"scene_id": "scene-04",
|
||||||
|
"decision": "candidate",
|
||||||
|
"score": 0.84,
|
||||||
|
"reasons": ["fala cobre o tema", "rosto bem enquadrado", "sem alerta técnico"],
|
||||||
|
"selected_range": {"start": 42.1, "end": 49.8},
|
||||||
|
"evidence_ids": ["clip-07@42.1:...", "clip-07@47.0:..."],
|
||||||
|
"warnings": [],
|
||||||
|
"requires_review": true
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Regras: `selected_range` só aponta para timestamps/evidências existentes; toda decisão tem `evidence_ids`; ausência de evidência vira `unknown`; a IA sugere trim, descarte, agrupamento e ordem, mas não escreve diretamente no projeto; o aplicador confere `source_revision` e `timeline_revision`; decisões de alto impacto exigem confirmação.
|
||||||
|
|
||||||
|
Isso encaixa no que já existe em `EditorialContextPack` e `EditorialPlan`: ambos são revision-aware, citáveis e read-only antes da aplicação.
|
||||||
|
|
||||||
|
## Situação atual do projeto
|
||||||
|
|
||||||
|
Já temos `QuadroDeVideo` com timestamp, índice, dimensões, imagem e caminho; `AnalisadorDeFrame` e `AnalisadorDeSequenciaDeQuadros`; `DetectorDeCenas`; análises OpenCV de qualidade, composição, movimento e continuidade; adapter inicial `AnalisadorAppleVision` para OCR e faces via PyObjC; adapters ONNX/Core ML e MediaPipe; e contexto editorial/plano com evidência, revisões e revisão obrigatória.
|
||||||
|
|
||||||
|
Gaps para a implementação Apple completa:
|
||||||
|
|
||||||
|
1. ampliar o adapter Vision para pose, mãos, qualidade facial, feature print, saliência, pessoa e estética;
|
||||||
|
2. adicionar runner Swift/AVFoundation para timestamps e `CVPixelBuffer`, mantendo PyObjC como protótipo;
|
||||||
|
3. consolidar evidências contínuas e gerar contact sheets etiquetadas;
|
||||||
|
4. criar `FoundationModelsProvider` separado, com disponibilidade, timeout, versão, token budget e saída tipada;
|
||||||
|
5. criar store de frames/evidências por hash do arquivo e revision;
|
||||||
|
6. testar orientação, VFR, HDR, vertical/horizontal e falha parcial de requests;
|
||||||
|
7. medir precisão editorial em fixtures reais antes de permitir aplicação automática.
|
||||||
|
|
||||||
|
## Ordem recomendada
|
||||||
|
|
||||||
|
### Fase 1 — Vision determinístico
|
||||||
|
|
||||||
|
Implementar OCR, faces, pessoas, qualidade, feature print e estética. O resultado deve ser JSON versionado por frame. Manter OpenCV para métricas existentes e comparar resultados, em vez de substituir tudo de uma vez.
|
||||||
|
|
||||||
|
### Fase 2 — cenas e selects
|
||||||
|
|
||||||
|
Combinar cortes, similaridade e qualidade para produzir cenas e frames representativos. Criar visualização de auditoria com timeline, thumbnail, timestamp, observações e confiança.
|
||||||
|
|
||||||
|
### Fase 3 — descrições de cena
|
||||||
|
|
||||||
|
Gerar descrições visuais sincronizadas com os intervalos da transcrição e produzir o JSON cronológico. Começar com descrições baseadas nos fatos Vision; usar Foundation Models somente para transformar evidências em linguagem curta, com marcação de confiança e origem.
|
||||||
|
|
||||||
|
### Fase 4 — Apple Intelligence como decisão complementar
|
||||||
|
|
||||||
|
Enviar o arquivo cronológico ou seus chunks para uma IA separada. Começar por resumo e tags; depois manter/cortar; por fim plano de edição com saída guiada e validação estrita. Se o modelo estiver indisponível, o Scanner continua entregando o arquivo completo de contexto.
|
||||||
|
|
||||||
|
### Fase 5 — Premiere
|
||||||
|
|
||||||
|
Usar o plano existente como camada de revisão. Somente depois da confirmação chamar operações de organização, stringout, rough cut, marcadores ou legendas, sempre com readback.
|
||||||
|
|
||||||
|
## Decisão
|
||||||
|
|
||||||
|
Devemos implementar **Vision como os olhos do Scanner** e um **arquivo cronológico multimodal como o produto central da primeira fase**. O Foundation Models pode ajudar a escrever as descrições de cena. Mais tarde, uma IA de decisão poderá ler esse arquivo e sugerir “corta/mantém/usa este take”, sempre apontando para os intervalos e evidências que justificam a escolha.
|
||||||
|
|
||||||
|
Essa arquitetura aproveita o Apple Silicon, preserva privacidade, funciona com o modelo local quando disponível e mantém fallback para OpenCV/ONNX/Whisper e outros hosts. Também evita confundir Apple Intelligence com Media Intelligence do Premiere: são produtos e APIs diferentes.
|
||||||
|
|
||||||
|
## Fontes oficiais
|
||||||
|
|
||||||
|
- [Apple Vision](https://developer.apple.com/documentation/vision)
|
||||||
|
- [Processar vídeo e gerar thumbnails](https://developer.apple.com/documentation/vision/generating-thumbnails-from-videos)
|
||||||
|
- [AVAssetReader](https://developer.apple.com/documentation/avfoundation/avassetreader)
|
||||||
|
- [Foundation Models](https://developer.apple.com/documentation/foundationmodels)
|
||||||
|
- [Analisar imagens com prompting multimodal](https://developer.apple.com/documentation/foundationmodels/analyzing-images-with-multimodal-prompting)
|
||||||
|
- [Gerar conteúdo e executar tarefas](https://developer.apple.com/documentation/foundationmodels/generating-content-and-performing-tasks-with-foundation-models)
|
||||||
|
- [Janela de contexto do modelo on-device](https://developer.apple.com/documentation/technotes/tn3193-managing-the-on-device-foundation-model-s-context-window)
|
||||||
|
- [SystemLanguageModel](https://developer.apple.com/documentation/foundationmodels/systemlanguagemodel)
|
||||||
|
- [Disponibilidade e dispositivos compatíveis com Apple Intelligence](https://support.apple.com/en-us/121115)
|
||||||
@@ -0,0 +1,217 @@
|
|||||||
|
# Levantamento de ferramentas locais para análise visual
|
||||||
|
|
||||||
|
Data: 2026-09-08
|
||||||
|
Escopo: escolher a base para implementar um `ProviderDeAnaliseVisual` real no `engine/scanner`.
|
||||||
|
|
||||||
|
## Recomendação executiva
|
||||||
|
|
||||||
|
Adotar uma arquitetura em camadas:
|
||||||
|
|
||||||
|
1. **Apple Vision + Core ML** como provider principal para macOS: OCR, rostos, pessoas, poses, códigos, classificação, qualidade e similaridade visual.
|
||||||
|
2. **PySceneDetect** para detectar cortes e delimitar cenas no arquivo de origem.
|
||||||
|
3. **ONNX Runtime com CoreML Execution Provider** como runtime de modelos customizados, mantendo a possibilidade de usar os mesmos modelos em outras plataformas.
|
||||||
|
4. **MediaPipe Tasks** como alternativa especializada para pose, mãos, rosto e rastreamento quando o resultado do Vision não for suficiente.
|
||||||
|
5. **Modelo multimodal local** somente como uma etapa semântica opcional, posterior às análises determinísticas; não deve ser a fonte primária de eventos temporais.
|
||||||
|
|
||||||
|
Essa composição aproveita o Apple Silicon sem enviar mídia para a nuvem, separa fatos observáveis de interpretação e permite evoluir os modelos sem alterar o contrato do Scanner.
|
||||||
|
|
||||||
|
## Critérios usados
|
||||||
|
|
||||||
|
- execução local e offline;
|
||||||
|
- integração com macOS/Apple Silicon;
|
||||||
|
- possibilidade de obter intervalos temporais e confiança;
|
||||||
|
- suporte a frames, vídeo e processamento em lote;
|
||||||
|
- integração com Python ou bridge nativa pequeno;
|
||||||
|
- licença adequada para produto comercial;
|
||||||
|
- maturidade e manutenção do projeto;
|
||||||
|
- adequação ao nosso domínio: cenas, objetos, pessoas, OCR, qualidade e possíveis retakes.
|
||||||
|
|
||||||
|
## Candidatos
|
||||||
|
|
||||||
|
### 1. Apple Vision + Core ML — recomendação principal
|
||||||
|
|
||||||
|
O Vision oferece APIs pré-treinadas para análise de imagens e vídeo, incluindo texto, códigos, rostos, pessoas, poses, classificação, qualidade e similaridade visual. O processamento ocorre no dispositivo. Core ML executa modelos usando CPU, GPU e Neural Engine, também sem exigir conexão de rede.
|
||||||
|
|
||||||
|
**Pontos fortes**
|
||||||
|
|
||||||
|
- melhor alinhamento com o ambiente macOS existente;
|
||||||
|
- privacidade e execução local;
|
||||||
|
- bons blocos prontos para OCR, faces, pessoas, poses e qualidade;
|
||||||
|
- suporte nativo a tracking entre frames;
|
||||||
|
- possibilidade de usar modelos próprios convertidos para Core ML;
|
||||||
|
- sem dependência de um servidor Python pesado.
|
||||||
|
|
||||||
|
**Limitações**
|
||||||
|
|
||||||
|
- a API é Swift/Objective-C, não Python;
|
||||||
|
- será necessário um processo auxiliar Swift ou uma pequena bridge JSON;
|
||||||
|
- não substitui um detector geral de objetos com classes customizadas;
|
||||||
|
- os resultados são observações de visão, não uma noção editorial pronta de “take bom” ou “retake”.
|
||||||
|
|
||||||
|
**Uso recomendado no Scanner**
|
||||||
|
|
||||||
|
- `ProviderDeAnaliseVisualApple` executando um helper Swift;
|
||||||
|
- entrada: arquivo + lista de timestamps;
|
||||||
|
- saída: observações por frame, cada uma com `timestamp`, tipo, bounding box, label, confiança e provider;
|
||||||
|
- usar `VNGenerateImageFeaturePrintRequest`/similaridade visual para apoiar comparação de takes;
|
||||||
|
- usar análise de qualidade para foco, exposição e problemas técnicos quando disponível.
|
||||||
|
|
||||||
|
Fontes: [Apple Vision](https://developer.apple.com/documentation/vision), [detecção de objetos no Vision](https://developer.apple.com/documentation/vision/detecting_objects_in_still_images), [reconhecimento de texto](https://developer.apple.com/documentation/vision/recognizing-text-in-images) e [Apple Core ML](https://developer.apple.com/documentation/coreml).
|
||||||
|
|
||||||
|
### 2. PySceneDetect — recomendação para cortes de cena
|
||||||
|
|
||||||
|
Biblioteca Python/BSD-3 para detectar mudanças de cena. Possui `ContentDetector`, `AdaptiveDetector` e `ThresholdDetector`, retorna pares de início/fim e oferece integração com FFmpeg.
|
||||||
|
|
||||||
|
**Pontos fortes**
|
||||||
|
|
||||||
|
- integração direta com o engine Python;
|
||||||
|
- contrato temporal já próximo do nosso `Cena`;
|
||||||
|
- simples de testar com arquivos reais e fixtures;
|
||||||
|
- licença BSD-3-Clause;
|
||||||
|
- mais apropriado para “onde a cena muda” do que um VLM.
|
||||||
|
|
||||||
|
**Limitações**
|
||||||
|
|
||||||
|
- detecta transições visuais, não entende o conteúdo sem um segundo modelo;
|
||||||
|
- pode gerar falsos positivos com movimento de câmera, flashes e mudanças de iluminação;
|
||||||
|
- deve operar sobre o arquivo de origem e depois ser projetado para os intervalos da timeline.
|
||||||
|
|
||||||
|
Fonte: [repositório oficial PySceneDetect](https://github.com/Breakthrough/PySceneDetect).
|
||||||
|
|
||||||
|
### 3. ONNX Runtime + CoreML Execution Provider — recomendação como runtime extensível
|
||||||
|
|
||||||
|
ONNX Runtime fornece API Python e permite executar modelos ONNX com `CoreMLExecutionProvider`. As wheels oficiais para macOS incluem o provider Core ML; há opções para CPU, GPU e Neural Engine quando suportadas pelo modelo e pelo dispositivo.
|
||||||
|
|
||||||
|
**Pontos fortes**
|
||||||
|
|
||||||
|
- desacopla o Scanner do framework de treinamento;
|
||||||
|
- permite plugar modelos de detecção, classificação, pose ou embeddings;
|
||||||
|
- mantém uma rota futura para Windows/Linux por outros execution providers;
|
||||||
|
- cacheia modelos compilados do Core ML;
|
||||||
|
- API Python direta.
|
||||||
|
|
||||||
|
**Limitações**
|
||||||
|
|
||||||
|
- a compatibilidade depende dos operadores e do formato do modelo;
|
||||||
|
- precisamos controlar conversão, pré-processamento, pós-processamento e versionamento;
|
||||||
|
- usar Core ML não garante que 100% do grafo rode na Neural Engine.
|
||||||
|
|
||||||
|
**Uso recomendado no Scanner**
|
||||||
|
|
||||||
|
- runtime para modelos licenciados de forma compatível com o produto;
|
||||||
|
- um `ModeloVisualONNX` interno com adaptadores para detecção/classificação;
|
||||||
|
- fallback CPU explícito e registro do device usado no resultado.
|
||||||
|
|
||||||
|
Fonte: [ONNX Runtime — CoreML Execution Provider](https://onnxruntime.ai/docs/execution-providers/CoreML-ExecutionProvider.html).
|
||||||
|
|
||||||
|
### 4. MediaPipe Tasks — alternativa especializada
|
||||||
|
|
||||||
|
MediaPipe oferece tarefas de visão com APIs para Python e modelos prontos, incluindo detecção de objetos, classificação, segmentação, rosto, mãos, pose e holístico.
|
||||||
|
|
||||||
|
**Quando usar**
|
||||||
|
|
||||||
|
- pose corporal, mãos, gestos e landmarks;
|
||||||
|
- tracking de pessoas em trechos curtos;
|
||||||
|
- quando quisermos manter o mesmo componente em macOS, Linux e Windows.
|
||||||
|
|
||||||
|
**Limitações**
|
||||||
|
|
||||||
|
- exige escolher e distribuir arquivos de modelo;
|
||||||
|
- a cobertura e o formato de resultados são diferentes do Vision;
|
||||||
|
- não deve ser adicionado apenas por sobreposição: primeiro precisamos de um caso que o Vision não resolva.
|
||||||
|
|
||||||
|
Fonte: [Google AI Edge — MediaPipe Object Detector para Python](https://ai.google.dev/edge/mediapipe/solutions/vision/object_detector/python).
|
||||||
|
|
||||||
|
### 5. Ultralytics YOLO — tecnicamente forte, com risco de licença
|
||||||
|
|
||||||
|
Ultralytics oferece detecção, segmentação, pose, classificação e tracking em Python, com modelos pré-treinados e treinamento customizado.
|
||||||
|
|
||||||
|
**Vantagens técnicas**
|
||||||
|
|
||||||
|
- integração rápida;
|
||||||
|
- ecossistema amplo;
|
||||||
|
- bom caminho para objetos específicos do nosso domínio;
|
||||||
|
- suporta tracking e modelos customizados.
|
||||||
|
|
||||||
|
**Risco decisivo**
|
||||||
|
|
||||||
|
O código e os artefatos são apresentados sob AGPL-3.0, e a própria documentação indica licença Enterprise para desenvolvimento e produção que não possam cumprir as obrigações da AGPL. Não devemos incorporar Ultralytics ao produto sem uma decisão jurídica/licenciamento explícita.
|
||||||
|
|
||||||
|
**Decisão**
|
||||||
|
|
||||||
|
Usar apenas em benchmark/protótipo isolado até resolver a licença. Para produção, preferir modelos e runtimes com licença compatível ou adquirir a licença Enterprise.
|
||||||
|
|
||||||
|
Fontes: [documentação oficial Ultralytics](https://github.com/ultralytics/ultralytics), [licença e opções comerciais](https://github.com/ultralytics/ultralytics/blob/main/docs/en/index.md).
|
||||||
|
|
||||||
|
### 6. OpenCV — fundação de processamento, não provider semântico
|
||||||
|
|
||||||
|
OpenCV é útil para decodificação de frames, resize, cor, blur, histograma, métricas técnicas, comparação de imagens e pré/pós-processamento. Também pode executar modelos via DNN, mas não deve ser tratado sozinho como a solução de reconhecimento semântico.
|
||||||
|
|
||||||
|
**Uso recomendado**
|
||||||
|
|
||||||
|
- extração/amostragem de frames;
|
||||||
|
- foco, exposição, contraste, ruído e estabilidade;
|
||||||
|
- diferenças entre frames;
|
||||||
|
- suporte aos providers Vision, ONNX e MediaPipe.
|
||||||
|
|
||||||
|
Fonte: [licença oficial OpenCV](https://opencv.org/license/).
|
||||||
|
|
||||||
|
## O que não recomendar como núcleo
|
||||||
|
|
||||||
|
Um VLM local pode produzir descrições como “pessoa falando em uma sala”, mas é menos determinístico, mais caro em memória e menos adequado para localizar cortes com precisão. Ele pode entrar depois para gerar descrição semântica de cenas já delimitadas, nunca substituir `PySceneDetect`, Vision ou um detector temporal.
|
||||||
|
|
||||||
|
Também não devemos começar pelo YOLO/Ultralytics como dependência central antes de resolver AGPL/licença comercial.
|
||||||
|
|
||||||
|
## Matriz resumida
|
||||||
|
|
||||||
|
| Ferramenta | Melhor uso | Python direto | Apple Silicon | Offline | Licença/risco | Decisão |
|
||||||
|
|---|---|---:|---:|---:|---|---|
|
||||||
|
| Vision | OCR, faces, pessoas, pose, qualidade, similaridade | Não | Excelente | Sim | Framework Apple | Adotar |
|
||||||
|
| Core ML | Modelos próprios no hardware Apple | Via bridge/runtime | Excelente | Sim | Framework Apple | Adotar |
|
||||||
|
| PySceneDetect | Cortes e intervalos de cena | Sim | Boa | Sim | BSD-3-Clause | Adotar |
|
||||||
|
| ONNX Runtime + CoreML EP | Runtime de modelos customizados | Sim | Excelente | Sim | MIT | Adotar como extensão |
|
||||||
|
| MediaPipe Tasks | Pose, mãos, rosto, holístico | Sim | Boa | Sim | Apache-2.0 no framework; conferir cada modelo | Avaliar por caso |
|
||||||
|
| OpenCV | Frames e métricas técnicas | Sim | Boa | Sim | Apache-2.0 | Adotar como base auxiliar |
|
||||||
|
| Ultralytics YOLO | Objetos/pose/tracking | Sim | Boa | Sim | AGPL ou Enterprise | Protótipo somente até decisão |
|
||||||
|
|
||||||
|
## Desenho proposto para o nosso código
|
||||||
|
|
||||||
|
```text
|
||||||
|
Clipe/arquivo
|
||||||
|
↓
|
||||||
|
ExtratorDeQuadros (FFmpeg/OpenCV)
|
||||||
|
↓
|
||||||
|
ProviderDeAnaliseVisual
|
||||||
|
├── AppleVisionProvider
|
||||||
|
├── PySceneDetectProvider
|
||||||
|
├── ONNXCoreMLProvider (opcional)
|
||||||
|
└── MediaPipeProvider (opcional)
|
||||||
|
↓
|
||||||
|
EvidenciaVisual(timestamp, intervalo, tipo, valor, confianca, provider)
|
||||||
|
↓
|
||||||
|
ContextoDeAnalise.caracteristicas_visuais / cenas / eventos
|
||||||
|
```
|
||||||
|
|
||||||
|
O contrato atual `ProviderDeAnaliseVisual.analisar(clipe, quadros)` é suficiente para o primeiro passo, mas a saída precisa evoluir de um `dict` livre para evidências temporais. A unidade mínima deveria conter:
|
||||||
|
|
||||||
|
- `tipo`: `ocr`, `pessoa`, `objeto`, `pose`, `qualidade`, `similaridade` ou `mudanca_de_cena`;
|
||||||
|
- `inicio` e `fim` ou `timestamp`;
|
||||||
|
- `valor` estruturado;
|
||||||
|
- `confianca` quando fornecida pelo modelo;
|
||||||
|
- `provider` e `modelo`;
|
||||||
|
- versão do modelo e hash opcional;
|
||||||
|
- referência ao arquivo/frame analisado.
|
||||||
|
|
||||||
|
## Ordem recomendada de implementação
|
||||||
|
|
||||||
|
1. Criar `ExtracaoDeQuadros` usando FFmpeg/OpenCV, com amostragem por intervalo e cache por hash do arquivo.
|
||||||
|
2. Implementar um helper Swift pequeno para Apple Vision e um `ProviderDeAnaliseVisualApple` Python que troca JSON por stdin/stdout.
|
||||||
|
3. Adicionar PySceneDetect como etapa própria de detecção de cenas.
|
||||||
|
4. Criar testes com fixture curta para OCR, presença de pessoa, qualidade e mudança de cena.
|
||||||
|
5. Medir tempo, memória, cobertura e estabilidade dos resultados em Apple Silicon.
|
||||||
|
6. Só depois adicionar ONNX/MediaPipe para lacunas concretas.
|
||||||
|
7. Avaliar um VLM local apenas para descrição semântica de cenas já delimitadas.
|
||||||
|
|
||||||
|
## Conclusão
|
||||||
|
|
||||||
|
Para o produto atual, a melhor escolha não é uma única biblioteca. É **Vision/Core ML + PySceneDetect + OpenCV**, com **ONNX Runtime/CoreML** como ponto de extensão. Essa combinação cobre a maior parte do Scanner com processamento local, preserva a privacidade e evita amarrar o domínio a uma biblioteca de modelos com licença problemática.
|
||||||
@@ -364,6 +364,18 @@ O modelo utilizado deverá ficar no módulo `providers/transcricao/`.
|
|||||||
|
|
||||||
# 6. Submódulo `visual`
|
# 6. Submódulo `visual`
|
||||||
|
|
||||||
|
## Regra de acesso ao Premiere
|
||||||
|
|
||||||
|
Sempre que o Scanner precisar acessar a timeline, a sequência ativa, as faixas
|
||||||
|
ou os clipes, deve utilizar `AcessoAoEditor` em
|
||||||
|
`engine.integracoes.premiere.leitura`. Essa classe já encapsula o cliente MCP e
|
||||||
|
é a única porta de leitura do Premiere para o Engine.
|
||||||
|
|
||||||
|
O submódulo `visual` recebe uma `Timeline` de domínio pelo
|
||||||
|
`ContextoDeAnalise`; seus adapters não devem chamar o MCP, o CEP ou o UXP
|
||||||
|
diretamente. Em testes, injete uma timeline pronta ou um fake de
|
||||||
|
`AcessoAoEditor` na etapa de descoberta.
|
||||||
|
|
||||||
## `ExtracaoDeQuadros`
|
## `ExtracaoDeQuadros`
|
||||||
|
|
||||||
### Papel
|
### Papel
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ Cada módulo principal deverá possuir um documento próprio nesta pasta.
|
|||||||
arquitetura/
|
arquitetura/
|
||||||
├── README.md
|
├── README.md
|
||||||
├── scanner.md
|
├── scanner.md
|
||||||
|
├── biblioteca-inteligente-de-videos.md
|
||||||
├── integracao-com-premiere.md
|
├── integracao-com-premiere.md
|
||||||
├── leitura-da-timeline-via-mcp.md
|
├── leitura-da-timeline-via-mcp.md
|
||||||
├── plano-primeira-etapa-scanner-e-mcp.md
|
├── plano-primeira-etapa-scanner-e-mcp.md
|
||||||
|
|||||||
@@ -0,0 +1,466 @@
|
|||||||
|
# Biblioteca Inteligente de Vídeos
|
||||||
|
|
||||||
|
## Objetivo
|
||||||
|
|
||||||
|
Transformar uma pasta selecionada pelo usuário em uma biblioteca audiovisual documentada e pesquisável. O sistema deve responder não apenas quais arquivos existem, mas quais evidências de conteúdo aparecem em cada arquivo e em que intervalo temporal.
|
||||||
|
|
||||||
|
Exemplos de consultas:
|
||||||
|
|
||||||
|
- “Encontre vídeos em que uma pessoa caminha na rua.”
|
||||||
|
- “Mostre cenas em que alguém fala diante de uma câmera.”
|
||||||
|
- “Encontre momentos em que aparece um carro vermelho.”
|
||||||
|
|
||||||
|
O módulo é de descoberta, análise e recuperação. Ele não decide cortes, não altera a timeline e não deve ser confundido com o índice de Media Intelligence do Premiere. Será um índice local próprio, alimentado pelos adapters disponíveis no projeto.
|
||||||
|
|
||||||
|
## Decisões arquiteturais
|
||||||
|
|
||||||
|
### 1. Nova unidade de domínio: biblioteca, ativo e evidência
|
||||||
|
|
||||||
|
O scanner atual é orientado a uma `Timeline`; este requisito é orientado a uma `BibliotecaDeVideos`. Os dois fluxos podem compartilhar adapters de mídia, mas não devem compartilhar o mesmo contexto de execução.
|
||||||
|
|
||||||
|
```text
|
||||||
|
BibliotecaDeVideos
|
||||||
|
└── AtivoDeVideo
|
||||||
|
├── MetadadosDoArquivo
|
||||||
|
├── SegmentoDeConteudo
|
||||||
|
│ ├── EvidenciaVisual
|
||||||
|
│ ├── SegmentoDeTranscricao
|
||||||
|
│ └── EvidenciaDeAudio
|
||||||
|
└── RepresentacoesDeBusca
|
||||||
|
```
|
||||||
|
|
||||||
|
Um `AtivoDeVideo` é identificado por uma identidade estável do conteúdo, não apenas pelo nome do arquivo. A identidade inicial deve combinar caminho normalizado, tamanho, data de modificação e uma impressão digital do arquivo. Quando houver colisão ou suspeita de alteração, o hash completo deve confirmar a identidade.
|
||||||
|
|
||||||
|
Uma `Evidencia` é um fato observado por um provider, com tipo, valor, intervalo no arquivo de origem, confiança, provider e versão do modelo. Descrições geradas por IA são evidências com proveniência; não são fatos absolutos nem instruções executáveis.
|
||||||
|
|
||||||
|
### 2. O módulo externo deve ser profundo
|
||||||
|
|
||||||
|
O chamador não deve conhecer FFmpeg, OpenCV, Vision, Whisper, filas, SQLite ou o mecanismo vetorial. A seam pública deve oferecer poucas operações orientadas a intenção:
|
||||||
|
|
||||||
|
```python
|
||||||
|
class BibliotecaDeVideos:
|
||||||
|
def indexar_pasta(self, pasta: str | Path, configuracao: ConfiguracaoDaBiblioteca) -> IdDaExecucao: ...
|
||||||
|
def obter_status(self, execucao: IdDaExecucao) -> StatusDaIndexacao: ...
|
||||||
|
def buscar(self, consulta: str, opcoes: OpcoesDeBusca | None = None) -> list[ResultadoDeBusca]: ...
|
||||||
|
def obter_ativo(self, identificador: str) -> AtivoDeVideo | None: ...
|
||||||
|
```
|
||||||
|
|
||||||
|
`indexar_pasta` inicia ou agenda uma execução idempotente e retorna imediatamente. O progresso é consultado pelo identificador da execução. `buscar` retorna resultados agrupados por ativo, com trechos temporais e evidências que justificam cada resultado.
|
||||||
|
|
||||||
|
O módulo esconde a coordenação entre descoberta incremental, análise, persistência e indexação de busca. Adapters internos podem variar sem alterar essa interface.
|
||||||
|
|
||||||
|
### 3. Indexação incremental é regra do domínio
|
||||||
|
|
||||||
|
Cada ativo deve registrar:
|
||||||
|
|
||||||
|
- impressão digital observada;
|
||||||
|
- versão do contrato de análise;
|
||||||
|
- versão de cada provider/modelo usado;
|
||||||
|
- etapas concluídas;
|
||||||
|
- etapas com erro ou aviso;
|
||||||
|
- último status e última execução.
|
||||||
|
|
||||||
|
Um arquivo inalterado e já concluído não deve ser analisado novamente. Um arquivo novo entra na fila. Um arquivo cuja impressão digital mudou invalida apenas as etapas derivadas daquele conteúdo. Se somente um provider ou sua versão mudou, a política pode invalidar apenas a etapa correspondente.
|
||||||
|
|
||||||
|
Arquivos removidos da pasta não devem ser apagados imediatamente do índice: passam a `ausente_na_origem`, preservando resultados históricos e permitindo recuperação caso retornem. A remoção definitiva deve ser uma operação explícita futura.
|
||||||
|
|
||||||
|
## Módulos e responsabilidades
|
||||||
|
|
||||||
|
### `biblioteca`
|
||||||
|
|
||||||
|
Módulo profundo e seam principal. Recebe a intenção do usuário, cria uma execução, delega o trabalho ao coordenador e expõe status, resultados e erros normalizados.
|
||||||
|
|
||||||
|
Não conhece detalhes de providers nem executa chamadas de sistema diretamente.
|
||||||
|
|
||||||
|
### `descoberta_da_pasta`
|
||||||
|
|
||||||
|
Percorre a pasta autorizada, respeitando configuração de recursão, extensões suportadas, exclusões e links simbólicos. Produz candidatos de arquivos de vídeo e reconcilia o snapshot atual com o último snapshot persistido.
|
||||||
|
|
||||||
|
Não extrai metadados, não abre frames e não chama IA.
|
||||||
|
|
||||||
|
### `identidade_do_ativo`
|
||||||
|
|
||||||
|
Calcula e compara a impressão digital do arquivo. Encapsula a política de “novo”, “inalterado”, “alterado” e “ausente”. Deve ser determinística e testável com um filesystem falso.
|
||||||
|
|
||||||
|
### `coordenacao_da_indexacao`
|
||||||
|
|
||||||
|
Transforma candidatos em trabalhos por ativo e etapa, respeita dependências, atualiza progresso, trata cancelamento, permite retomada e aplica a política de erro por ativo.
|
||||||
|
|
||||||
|
Dependências sugeridas:
|
||||||
|
|
||||||
|
```text
|
||||||
|
descoberta
|
||||||
|
└── metadados
|
||||||
|
├── amostragem_de_frames ── análise_visual ── segmentos_visuais
|
||||||
|
└── extração_de_audio ── transcrição ── segmentos_de_fala
|
||||||
|
└── análise_de_audio
|
||||||
|
|
||||||
|
segmentos + evidências ── consolidação_temporal ── indexação_de_busca
|
||||||
|
```
|
||||||
|
|
||||||
|
Metadados devem ser pré-requisito para calcular duração e amostragem. O ramo visual e o ramo de áudio podem executar em paralelo. A consolidação e a indexação só ocorrem depois dos ramos habilitados, sem exigir que todos tenham sucesso.
|
||||||
|
|
||||||
|
### `analise_de_conteudo_audiovisual`
|
||||||
|
|
||||||
|
Orquestra os adapters já existentes no projeto:
|
||||||
|
|
||||||
|
- `ExtracaoDeMetadados` para dados técnicos;
|
||||||
|
- `ExtratorDeQuadros` e `DetectorDeCenas` para amostragem e intervalos;
|
||||||
|
- analisadores OpenCV, Apple Vision, ONNX ou MediaPipe para evidências visuais;
|
||||||
|
- adapters Whisper, Apple Speech ou Groq para transcrição;
|
||||||
|
- diarização e emoção local quando habilitadas.
|
||||||
|
|
||||||
|
O resultado deve usar modelos do domínio, especialmente `EvidenciaVisual`, `CenaVisual` e `SegmentoDeTranscricao`. Um provider indisponível gera aviso de capacidade e não deve apagar evidências produzidas por outros providers.
|
||||||
|
|
||||||
|
O módulo não deve pedir que um modelo de linguagem “assista” ao arquivo inteiro. A análise deve trabalhar com amostras temporais, cenas e agregação de evidências. Uma descrição de cena pode ser criada a partir de um conjunto limitado de frames, sempre mantendo os timestamps que a sustentam.
|
||||||
|
|
||||||
|
### `consolidacao_temporal`
|
||||||
|
|
||||||
|
Converte observações pontuais em intervalos úteis para busca. Observações do mesmo tipo e valor semelhante podem ser agrupadas quando estão próximas, com uma margem configurável. O resultado precisa conservar as observações originais, pois elas são a evidência auditável do intervalo consolidado.
|
||||||
|
|
||||||
|
Esse módulo é o lugar correto para responder “em que momento” e para evitar que a busca retorne apenas o arquivo inteiro.
|
||||||
|
|
||||||
|
### `persistencia_do_indice`
|
||||||
|
|
||||||
|
Adapter responsável por salvar e ler o estado durável. A primeira implementação deve ser local e transacional, preferencialmente SQLite, com arquivos de frames/áudio tratados como cache reconstruível fora do banco.
|
||||||
|
|
||||||
|
Interface mínima:
|
||||||
|
|
||||||
|
```python
|
||||||
|
class RepositorioDaBiblioteca(Protocol):
|
||||||
|
def reconciliar_snapshot(self, snapshot: SnapshotDaPasta) -> ResultadoDaReconciliacao: ...
|
||||||
|
def salvar_resultado(self, resultado: ResultadoDoAtivo) -> None: ...
|
||||||
|
def obter_trabalho_pendente(self, limite: int) -> list[TrabalhoDeIndexacao]: ...
|
||||||
|
def atualizar_status(self, status: StatusDaIndexacao) -> None: ...
|
||||||
|
def buscar_evidencias(self, consulta: ConsultaNormalizada) -> list[ResultadoDeBusca]: ...
|
||||||
|
```
|
||||||
|
|
||||||
|
O contrato deve permitir um adapter em memória para testes. Nenhum provider deve escrever diretamente no banco.
|
||||||
|
|
||||||
|
### `busca_semantica`
|
||||||
|
|
||||||
|
Recebe texto livre, normaliza a consulta e combina três fontes:
|
||||||
|
|
||||||
|
1. busca lexical em nomes, transcrições, rótulos e descrições;
|
||||||
|
2. busca vetorial em descrições de cenas, transcrições e evidências;
|
||||||
|
3. filtros estruturados por ativo, intervalo, tipo de evidência, confiança e status.
|
||||||
|
|
||||||
|
O MVP deve priorizar busca híbrida com ranking explicável. Cada resultado deve informar score, trecho, arquivo e evidências correspondentes. Busca vetorial sem evidência temporal não atende ao requisito.
|
||||||
|
|
||||||
|
Um adapter vetorial pode ser adicionado depois. A persistência deve permitir começar com SQLite FTS e embeddings opcionais, sem acoplar o domínio a um banco vetorial específico.
|
||||||
|
|
||||||
|
### `integracao_com_timeline_e_editor`
|
||||||
|
|
||||||
|
Traduz um `ResultadoDeBusca` para ações de revisão: abrir o arquivo, posicionar o playhead, criar marcador ou propor um clipe para o fluxo editorial. A integração deve ser somente leitura/proposição na primeira versão.
|
||||||
|
|
||||||
|
Não deve alterar a timeline automaticamente nem tratar um resultado sem revisão como autorização de edição.
|
||||||
|
|
||||||
|
## Modelo de dados lógico
|
||||||
|
|
||||||
|
```text
|
||||||
|
bibliotecas
|
||||||
|
id, raiz, configuracao_json, criada_em, atualizada_em
|
||||||
|
|
||||||
|
ativos
|
||||||
|
id, biblioteca_id, caminho, nome, extensao, status_origem,
|
||||||
|
tamanho, modificado_em, fingerprint, criado_em, atualizado_em
|
||||||
|
|
||||||
|
metadados_dos_ativos
|
||||||
|
ativo_id, duracao, largura, altura, fps, codecs, formato, json_extra
|
||||||
|
|
||||||
|
execucoes_de_indexacao
|
||||||
|
id, biblioteca_id, status, motivo, iniciada_em, finalizada_em,
|
||||||
|
total_trabalhos, concluidos, falhos, cancelada_em
|
||||||
|
|
||||||
|
trabalhos_de_indexacao
|
||||||
|
id, execucao_id, ativo_id, etapa, versao, status, tentativas,
|
||||||
|
erro, iniciada_em, finalizada_em
|
||||||
|
|
||||||
|
segmentos_de_conteudo
|
||||||
|
id, ativo_id, inicio, fim, tipo, resumo, confianca, origem
|
||||||
|
|
||||||
|
evidencias
|
||||||
|
id, segmento_id, tipo, valor_json, inicio, fim, confianca,
|
||||||
|
provider, modelo, versao
|
||||||
|
|
||||||
|
transcricoes
|
||||||
|
id, ativo_id, inicio, fim, texto, falante, confianca, provider, versao
|
||||||
|
|
||||||
|
representacoes_de_busca
|
||||||
|
id, alvo_tipo, alvo_id, texto, embedding, indice_lexical
|
||||||
|
```
|
||||||
|
|
||||||
|
Os intervalos são sempre relativos ao arquivo de origem. Quando houver uso numa timeline, a tradução para o intervalo da timeline pertence à integração com o editor, usando o `intervalo_na_origem` do domínio existente.
|
||||||
|
|
||||||
|
## Fluxo completo
|
||||||
|
|
||||||
|
```text
|
||||||
|
Usuário seleciona pasta
|
||||||
|
↓
|
||||||
|
BibliotecaDeVideos.indexar_pasta()
|
||||||
|
↓
|
||||||
|
Execução persistida + trabalhos pendentes
|
||||||
|
↓
|
||||||
|
Snapshot da pasta e reconciliação por fingerprint
|
||||||
|
↓
|
||||||
|
Metadados por ativo novo/alterado
|
||||||
|
↓
|
||||||
|
Ramos visual e áudio em segundo plano
|
||||||
|
↓
|
||||||
|
Consolidação de cenas, evidências e falas
|
||||||
|
↓
|
||||||
|
Atualização transacional do índice lexical/vetorial
|
||||||
|
↓
|
||||||
|
Status consultável e busca com timestamps
|
||||||
|
```
|
||||||
|
|
||||||
|
Cada trabalho deve ser retomável. A gravação de uma etapa deve ser atômica: o índice não pode anunciar uma etapa concluída antes de seus dados e sua versão estarem persistidos.
|
||||||
|
|
||||||
|
## Contrato de status e falhas
|
||||||
|
|
||||||
|
Status da biblioteca: `nao_indexada`, `indexando`, `parcial`, `concluida`, `falhou` ou `ausente_na_origem`.
|
||||||
|
|
||||||
|
Status da etapa: `pendente`, `em_execucao`, `concluida`, `concluida_com_avisos`, `falhou`, `cancelada`.
|
||||||
|
|
||||||
|
Falha de um arquivo não deve interromper toda a biblioteca. Falha de descoberta ou persistência deve interromper a execução, pois torna o resultado inconsistente. Falha de um provider deve marcar a capacidade correspondente como indisponível e preservar as demais etapas.
|
||||||
|
|
||||||
|
O status deve conter progresso por contagem de trabalhos e por ativo; percentual baseado apenas em duração de vídeo pode ficar enganoso quando há arquivos muito diferentes.
|
||||||
|
|
||||||
|
## Escopo recomendado do MVP
|
||||||
|
|
||||||
|
1. Seleção e persistência de uma biblioteca local.
|
||||||
|
2. Descoberta recursiva de extensões configuradas.
|
||||||
|
3. Fingerprint e reconciliação incremental.
|
||||||
|
4. Metadados via FFprobe.
|
||||||
|
5. Amostragem de frames e análise visual já disponível localmente.
|
||||||
|
6. Transcrição por um adapter configurado, com timestamps.
|
||||||
|
7. Segmentos temporais e evidências persistidos em SQLite.
|
||||||
|
8. Busca lexical por nome, transcrição, rótulos e descrições normalizadas.
|
||||||
|
9. Status, retomada e cancelamento do processamento.
|
||||||
|
10. Resultado com caminho, intervalo, confiança e evidência.
|
||||||
|
|
||||||
|
Ficam para uma segunda etapa: embeddings, ranking híbrido, diarização avançada, reconhecimento de identidade, monitoramento contínuo por filesystem watcher, agrupamento de takes semelhantes e criação automática de marcadores no Premiere.
|
||||||
|
|
||||||
|
## Integração com o que já existe
|
||||||
|
|
||||||
|
O novo módulo deve reutilizar os adapters de `code/engine/integracoes/midia`, `code/engine/integracoes/visual` e `code/engine/integracoes/whisper`. A implementação atual de `scanner` pode continuar atendendo análises de timeline; o novo coordenador transforma `AtivoDeVideo` em uma entrada compatível com os adapters, sem fazer o domínio da biblioteca depender de `Timeline`.
|
||||||
|
|
||||||
|
O `DescobertaDeArquivos` existente contém parte da política de validação local e pode fornecer um adapter compartilhado, desde que sua interface não passe a conhecer biblioteca, fila ou persistência. A análise visual local já produz evidências temporais adequadas, mas a etapa de consolidação deve ficar fora do detector de cenas para manter a separação entre observação e indexação.
|
||||||
|
|
||||||
|
As limitações documentadas em `advanced-feature-support.ts` permanecem válidas: o sistema não deve alegar acesso ao índice nativo do Premiere nem iniciar/monitorar operações não expostas por API pública. A biblioteca local é uma capacidade independente.
|
||||||
|
|
||||||
|
## Testabilidade e seams
|
||||||
|
|
||||||
|
O domínio deve ser testável sem FFmpeg, GPU, macOS Vision, rede ou arquivos reais. Adapters necessários para testes:
|
||||||
|
|
||||||
|
- filesystem que retorna snapshots controlados;
|
||||||
|
- calculador de fingerprint determinístico;
|
||||||
|
- provider de metadados falso;
|
||||||
|
- extrator de frames falso;
|
||||||
|
- providers visual e de transcrição falsos;
|
||||||
|
- relógio injetável;
|
||||||
|
- repositório em memória;
|
||||||
|
- fila síncrona ou executor controlado;
|
||||||
|
- ranking lexical/vetorial falso.
|
||||||
|
|
||||||
|
Testes prioritários:
|
||||||
|
|
||||||
|
- arquivo inalterado não gera trabalho novamente;
|
||||||
|
- novo arquivo gera apenas as etapas necessárias;
|
||||||
|
- arquivo alterado invalida resultados derivados;
|
||||||
|
- mudança de versão de provider invalida somente sua etapa;
|
||||||
|
- falha visual não apaga transcrição;
|
||||||
|
- timestamps nunca ultrapassam a duração conhecida;
|
||||||
|
- resultados de busca preservam evidências e intervalo;
|
||||||
|
- retomada continua do último trabalho persistido;
|
||||||
|
- cancelamento não deixa etapa marcada como concluída;
|
||||||
|
- arquivo removido vira ausente sem perder histórico.
|
||||||
|
|
||||||
|
## Decisões em aberto
|
||||||
|
|
||||||
|
Antes da implementação, ainda precisamos escolher:
|
||||||
|
|
||||||
|
1. banco local definitivo: SQLite puro, SQLite com FTS5 e embeddings em arquivo, ou outro adapter;
|
||||||
|
2. executor em segundo plano: processo Python separado, thread controlada ou integração com o host;
|
||||||
|
3. provider visual padrão do MVP: OpenCV, Apple Vision ou configuração por perfil;
|
||||||
|
4. provider de transcrição padrão e política de privacidade para enviar áudio a serviços externos;
|
||||||
|
5. extensões, exclusões e limite de profundidade da pasta;
|
||||||
|
6. política de retenção do cache de frames e áudio;
|
||||||
|
7. formato do contrato de descrição semântica produzido pelo modelo de IA.
|
||||||
|
|
||||||
|
Essas escolhas não devem alterar a interface de `BibliotecaDeVideos`; devem apenas selecionar adapters e configuração.
|
||||||
|
|
||||||
|
## Catálogo SQLite e processamento contínuo
|
||||||
|
|
||||||
|
### SQLite como catálogo, não como depósito de mídia
|
||||||
|
|
||||||
|
SQLite é adequado para o catálogo local porque oferece transações, consultas relacionais, FTS5 para busca textual e baixo custo operacional. O banco não deve armazenar vídeos, áudio extraído ou imagens em escala. Esses dados ficam na pasta original ou em um cache controlado; no banco ficam referências, metadados, resultados compactos e estado de processamento.
|
||||||
|
|
||||||
|
O arquivo do catálogo deve ficar fora da pasta indexada, em um diretório de dados do aplicativo. Assim, uma pasta pode ser removida, movida ou compartilhada sem levar o estado interno do agente junto com ela. A configuração deve permitir uma biblioteca por pasta e várias bibliotecas no mesmo catálogo.
|
||||||
|
|
||||||
|
Para lidar com nomes repetidos, a chave do ativo não será `nome`. O cadastro deve usar um identificador interno e único, por exemplo:
|
||||||
|
|
||||||
|
```text
|
||||||
|
identificador_do_ativo = UUID interno
|
||||||
|
identidade_do_conteudo = hash do conteúdo + tamanho
|
||||||
|
localizacao = biblioteca + caminho relativo normalizado
|
||||||
|
```
|
||||||
|
|
||||||
|
O caminho relativo distingue duas cópias iguais em locais diferentes; a identidade do conteúdo permite reconhecer um arquivo renomeado ou movido dentro da mesma biblioteca. O cálculo deve ser progressivo: primeiro comparar tamanho e data de modificação, depois calcular uma impressão digital parcial; o hash completo fica reservado para arquivos suspeitos, duplicatas ou confirmação de identidade.
|
||||||
|
|
||||||
|
### Estado persistido por etapa
|
||||||
|
|
||||||
|
O catálogo deve tratar a análise como um conjunto de trabalhos independentes, não como uma operação monolítica por vídeo. Cada trabalho tem `ativo_id`, `etapa`, `versao_da_etapa`, `status`, `progresso`, `checkpoint`, `tentativas`, `erro` e timestamps.
|
||||||
|
|
||||||
|
Checkpoints possíveis:
|
||||||
|
|
||||||
|
- último timestamp de frame processado;
|
||||||
|
- último intervalo de áudio transcrito;
|
||||||
|
- identificador da janela de cena atual;
|
||||||
|
- lote de evidências já persistido;
|
||||||
|
- versão do modelo e parâmetros efetivos.
|
||||||
|
|
||||||
|
Um checkpoint só é confirmado junto com os resultados daquele lote. Em caso de interrupção, o executor retoma a partir do último checkpoint confirmado, com uma pequena sobreposição temporal para não perder eventos na transição entre lotes. A escrita deve ser idempotente usando uma chave lógica como `ativo + etapa + versão + intervalo + provider`.
|
||||||
|
|
||||||
|
### Worker de baixa prioridade
|
||||||
|
|
||||||
|
O processamento contínuo deve ser implementado como um worker controlado pelo sistema, com uma iteração curta e cooperativa. Ele não deve manter vídeos, frames ou áudios inteiros em memória.
|
||||||
|
|
||||||
|
Política inicial:
|
||||||
|
|
||||||
|
- um ativo por vez por padrão;
|
||||||
|
- um lote pequeno de frames por vez;
|
||||||
|
- áudio extraído em arquivo temporário ou stream, nunca inteiro em memória;
|
||||||
|
- transação SQLite curta por lote;
|
||||||
|
- pausa entre lotes quando a máquina estiver ocupada;
|
||||||
|
- suspensão com bateria, modo de economia de energia, temperatura alta ou pressão de memória;
|
||||||
|
- limite configurável de CPU, memória, espaço de cache e tempo por ciclo;
|
||||||
|
- cancelamento cooperativo verificado entre frames, lotes e etapas;
|
||||||
|
- processos de provider isolados quando uma biblioteca nativa puder bloquear ou consumir memória excessiva.
|
||||||
|
|
||||||
|
O worker deve consultar uma política de recursos antes de iniciar cada lote:
|
||||||
|
|
||||||
|
```python
|
||||||
|
class PoliticaDeRecursos(Protocol):
|
||||||
|
def pode_executar(self, trabalho: TrabalhoDeIndexacao) -> bool: ...
|
||||||
|
def tamanho_do_lote(self, trabalho: TrabalhoDeIndexacao) -> int: ...
|
||||||
|
def deve_pausar(self) -> bool: ...
|
||||||
|
```
|
||||||
|
|
||||||
|
O objetivo não é manter o agente analisando a qualquer custo, mas aproveitar períodos ociosos. Se a máquina estiver ocupada, o worker deve dormir e deixar o sistema responsivo. A prioridade do processo e a afinidade de CPU são detalhes de um adapter do host, não regras espalhadas pelo domínio.
|
||||||
|
|
||||||
|
### Varredura contínua
|
||||||
|
|
||||||
|
O modo contínuo deve combinar duas estratégias:
|
||||||
|
|
||||||
|
1. uma varredura periódica e lenta da pasta, que é a fonte de verdade;
|
||||||
|
2. um watcher opcional para antecipar a descoberta de arquivos novos ou alterados.
|
||||||
|
|
||||||
|
O watcher não deve iniciar análise diretamente. Ele apenas marca a biblioteca como “precisa reconciliar”; a próxima varredura confirma o estado, evitando arquivos ainda sendo copiados. Um arquivo só entra na fila depois de permanecer estável por um intervalo configurável e passar por uma leitura mínima de metadados.
|
||||||
|
|
||||||
|
Ao iniciar, retomar ou acordar após ocioso, o worker deve:
|
||||||
|
|
||||||
|
```text
|
||||||
|
reconciliar pasta → atualizar origem dos ativos → recalcular trabalhos necessários
|
||||||
|
→ escolher próximo trabalho → processar lote → persistir checkpoint → repetir
|
||||||
|
```
|
||||||
|
|
||||||
|
### Frequência de frames
|
||||||
|
|
||||||
|
Não existe uma frequência única ideal. Analisar todos os frames é caro e, para busca semântica, geralmente redundante. A recomendação para o MVP é uma amostragem em camadas:
|
||||||
|
|
||||||
|
| Camada | Frequência inicial | Finalidade |
|
||||||
|
|---|---:|---|
|
||||||
|
| triagem | 1 frame a cada 2–5 s | descobrir duração, atividade e mudanças grosseiras |
|
||||||
|
| cena ativa | 1–2 frames/s | descrever objetos, pessoas e composição |
|
||||||
|
| transição | frequência temporariamente maior | refinar início/fim de uma mudança de cena |
|
||||||
|
| áudio | janelas contínuas | transcrição e detecção de fala/silêncio |
|
||||||
|
|
||||||
|
O valor de `1 frame/s` é um bom ponto de partida para vídeos comuns, mas deve ser uma configuração, não uma regra fixa. Conteúdo com movimento rápido, cortes frequentes, texto na tela ou ações curtas precisa de amostragem adaptativa. Conteúdo estático pode reduzir a frequência quando os frames consecutivos têm baixa diferença visual.
|
||||||
|
|
||||||
|
O detector deve aumentar a frequência localmente quando detectar mudança de cena, movimento relevante, fala, texto novo ou baixa confiança. Deve reduzir a frequência quando houver continuidade visual, silêncio prolongado ou repetição de frames. Cada evidência precisa registrar a amostragem usada, para que a ausência de detecção não seja interpretada como prova de ausência.
|
||||||
|
|
||||||
|
### Transcrição
|
||||||
|
|
||||||
|
O adapter local baseado em `faster-whisper` é a escolha padrão recomendada para o catálogo: mantém o processamento privado, entrega timestamps e já se encaixa nos providers existentes. O tamanho do modelo deve ser configurável por perfil de recurso:
|
||||||
|
|
||||||
|
- perfil econômico: modelo menor, CPU e baixa prioridade;
|
||||||
|
- perfil equilibrado: modelo intermediário, possivelmente aceleração disponível;
|
||||||
|
- perfil qualidade: modelo maior, somente quando solicitado ou quando houver tempo ocioso.
|
||||||
|
|
||||||
|
Apple Speech pode ser um adapter preferencial em macOS quando o requisito for baixo consumo e o idioma suportado for suficiente. Groq ou outro provider remoto deve ser opt-in, com consentimento explícito, indicação de que o áudio sai da máquina e registro do provider usado. A transcrição persistida deve guardar idioma, modelo, provider, confiança e timestamps; trocar o modelo invalida somente a etapa de transcrição, não os metadados nem necessariamente a análise visual.
|
||||||
|
|
||||||
|
### Combinação de fontes
|
||||||
|
|
||||||
|
O cadastro útil para o agente deve manter as fontes separadas e criar uma representação consolidada:
|
||||||
|
|
||||||
|
```text
|
||||||
|
metadados técnicos
|
||||||
|
+ transcrição temporal
|
||||||
|
+ evidências visuais temporais
|
||||||
|
+ evidências de áudio
|
||||||
|
+ mudanças de cena
|
||||||
|
↓
|
||||||
|
segmento de conteúdo
|
||||||
|
↓
|
||||||
|
texto indexável + filtros + evidências
|
||||||
|
```
|
||||||
|
|
||||||
|
Um segmento pode ter o resumo “pessoa entra em uma sala enquanto fala”, mas deve apontar para: o intervalo temporal, os frames que sustentam “pessoa” e “entra”, e o trecho de transcrição que sustenta “fala”. O resumo serve para recuperação; as evidências servem para auditoria, ranking e revisão humana.
|
||||||
|
|
||||||
|
### Estratégia de prioridade
|
||||||
|
|
||||||
|
A prioridade deve ser calculada no momento de escolher o próximo trabalho, sem alterar a identidade nem o resultado do ativo. Uma pontuação inicial pode considerar:
|
||||||
|
|
||||||
|
```text
|
||||||
|
prioridade = intenção explícita
|
||||||
|
+ ativo aberto ou usado recentemente no editor
|
||||||
|
+ arquivo novo ou alterado
|
||||||
|
+ etapa necessária para uma busca pendente
|
||||||
|
+ tamanho/tempo estimado que permita concluir um lote
|
||||||
|
- custo estimado
|
||||||
|
- idade da última tentativa com falha
|
||||||
|
```
|
||||||
|
|
||||||
|
Categorias práticas:
|
||||||
|
|
||||||
|
1. urgente: ativo solicitado numa busca ou aberto para edição;
|
||||||
|
2. alta: arquivo novo, alterado ou associado ao projeto atual;
|
||||||
|
3. normal: arquivos ainda não indexados;
|
||||||
|
4. baixa: reprocessamento por melhoria de modelo ou análise opcional.
|
||||||
|
|
||||||
|
Para evitar starvation, trabalhos de baixa prioridade recebem um aumento gradual de prioridade conforme envelhecem. Um arquivo grande não deve bloquear a fila inteira: o scheduler o divide em lotes e alterna com trabalhos menores.
|
||||||
|
|
||||||
|
## Configuração operacional proposta
|
||||||
|
|
||||||
|
```python
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class ConfiguracaoDaBiblioteca:
|
||||||
|
extensoes: tuple[str, ...] = (".mp4", ".mov", ".mxf", ".mkv", ".avi")
|
||||||
|
recursiva: bool = True
|
||||||
|
intervalo_da_varredura_em_segundos: int = 900
|
||||||
|
estabilidade_do_arquivo_em_segundos: int = 30
|
||||||
|
frequencia_de_triagem_em_fps: float = 0.25
|
||||||
|
frequencia_de_cena_em_fps: float = 1.0
|
||||||
|
tamanho_do_lote_de_frames: int = 32
|
||||||
|
concorrencia: int = 1
|
||||||
|
limite_de_cache_em_gb: float = 10.0
|
||||||
|
permitir_provider_remoto: bool = False
|
||||||
|
perfil_de_recursos: str = "economico"
|
||||||
|
```
|
||||||
|
|
||||||
|
Os defaults favorecem responsividade e privacidade. A configuração não deve expor detalhes de cada biblioteca de visão; deve selecionar perfis e permitir ajustes apenas onde houver evidência de necessidade.
|
||||||
|
|
||||||
|
## Fases de evolução
|
||||||
|
|
||||||
|
### Fase 1 — catálogo confiável
|
||||||
|
|
||||||
|
SQLite, descoberta periódica, identidade por fingerprint, metadados, fila persistida, checkpoints, análise visual em baixa frequência, transcrição local e busca textual temporal.
|
||||||
|
|
||||||
|
### Fase 2 — recuperação semântica
|
||||||
|
|
||||||
|
Embeddings para segmentos e transcrições, busca híbrida, reranking e consultas por conceitos não presentes literalmente no texto. O embedding deve ser versionado e reconstruível; não deve ser a única representação do conhecimento.
|
||||||
|
|
||||||
|
### Fase 3 — integração editorial
|
||||||
|
|
||||||
|
Busca a partir do contexto da timeline, abertura no trecho encontrado, marcadores de revisão e propostas de stringout. Qualquer mutação na timeline continua exigindo uma etapa explícita de revisão e autorização.
|
||||||
@@ -0,0 +1,136 @@
|
|||||||
|
"""Entrada local do painel CEP para uma execução configurada do Scanner."""
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import json
|
||||||
|
import math
|
||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
import tempfile
|
||||||
|
import re
|
||||||
|
|
||||||
|
from engine.integracoes.premiere.conversores import ConversorDeTimeline
|
||||||
|
from engine.scanner import ConfiguracaoVisualDoScanner, criar_detector_apple_vision, gerar_relatorio_visual
|
||||||
|
from engine.scanner.transcricao_da_timeline import TranscricaoDaTimeline
|
||||||
|
|
||||||
|
|
||||||
|
MODELO_DIARIZACAO_PADRAO = "pyannote/speaker-diarization-community-1"
|
||||||
|
|
||||||
|
|
||||||
|
def nome_seguro(nome: str) -> str:
|
||||||
|
return re.sub(r"[^A-Za-z0-9._-]+", "-", nome.strip()).strip(".-") or "timeline"
|
||||||
|
|
||||||
|
|
||||||
|
def executar(entrada: Path, saida: Path) -> Path:
|
||||||
|
pedido = json.loads(entrada.read_text(encoding="utf-8"))
|
||||||
|
configuracao = ConfiguracaoVisualDoScanner.de_dict(pedido["perfil"])
|
||||||
|
timeline = ConversorDeTimeline().converter(pedido["timeline"])
|
||||||
|
clipes = [clipe for faixa in timeline.faixas if faixa.tipo == "video"
|
||||||
|
and faixa.indice in configuracao.faixas_de_video for clipe in faixa.clipes]
|
||||||
|
total_estimado = sum(max(1, math.ceil((clipe.intervalo_na_origem or clipe.intervalo_na_timeline).duracao /
|
||||||
|
configuracao.intervalo_em_segundos)) + 1 for clipe in clipes) or 1
|
||||||
|
concluidos = 0
|
||||||
|
|
||||||
|
def emitir(etapa: str, percentual: float, clipe: str = "") -> None:
|
||||||
|
print(json.dumps({"evento": "progresso", "etapa": etapa, "percentual": round(percentual, 1),
|
||||||
|
"clipe": clipe}, ensure_ascii=False), flush=True)
|
||||||
|
|
||||||
|
resultados = []
|
||||||
|
for clipe in clipes:
|
||||||
|
peso = max(1, math.ceil((clipe.intervalo_na_origem or clipe.intervalo_na_timeline).duracao /
|
||||||
|
configuracao.intervalo_em_segundos)) + 1
|
||||||
|
inicio_do_clipe = concluidos
|
||||||
|
|
||||||
|
def progresso_atual(atual: int, total: int, nome: str = clipe.nome,
|
||||||
|
base: int = inicio_do_clipe, peso_do_clipe: int = peso) -> None:
|
||||||
|
emitir("Analisando frames", 100 * (base + peso_do_clipe * atual / max(total, 1)) / total_estimado, nome)
|
||||||
|
|
||||||
|
detector = criar_detector_apple_vision(Path(tempfile.gettempdir()) / "premiere-mcp-scanner",
|
||||||
|
configuracao=configuracao)
|
||||||
|
detector.ao_progresso = progresso_atual
|
||||||
|
emitir("Extraindo frames", 100 * inicio_do_clipe / total_estimado, clipe.nome)
|
||||||
|
try:
|
||||||
|
resultado = detector.detectar(clipe)
|
||||||
|
arquivo = Path(tempfile.gettempdir()) / f"scanner-{clipe.identificador}.json"
|
||||||
|
gerar_relatorio_visual(clipe, resultado, arquivo, configuracao.intervalo_em_segundos)
|
||||||
|
resultados.append(json.loads(arquivo.read_text(encoding="utf-8")))
|
||||||
|
except Exception as erro:
|
||||||
|
resultados.append({"clipe": {"identificador": clipe.identificador, "nome": clipe.nome,
|
||||||
|
"arquivo_original": clipe.arquivo}, "erro": str(erro)})
|
||||||
|
concluidos += peso
|
||||||
|
faixas_de_audio = [faixa for faixa in timeline.faixas
|
||||||
|
if 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]}
|
||||||
|
for faixa in faixas_de_audio]
|
||||||
|
caso = nome_seguro(timeline.nome)
|
||||||
|
pasta = saida.parent / caso
|
||||||
|
pasta.mkdir(parents=True, exist_ok=True)
|
||||||
|
saida = pasta / f"{caso}-resultado.json"
|
||||||
|
detalhado = pasta / "detalhado"
|
||||||
|
detalhado.mkdir(parents=True, exist_ok=True)
|
||||||
|
geometria = []
|
||||||
|
for resultado in resultados:
|
||||||
|
for frame in resultado.get("observacoes_por_frame", []):
|
||||||
|
fatos = [item for item in frame["evidencias"] if item["tipo"] in {"rosto", "pessoa", "pose", "mao"}]
|
||||||
|
if fatos:
|
||||||
|
geometria.append({"clipe": resultado["clipe"]["identificador"], "timestamp_na_origem": frame["timestamp_na_origem"], "evidencias": fatos})
|
||||||
|
frame["evidencias"] = [item for item in frame["evidencias"] if item not in fatos]
|
||||||
|
(detalhado / "geometria.jsonl").write_text("".join(json.dumps(item, ensure_ascii=False) + "\n" for item in geometria), encoding="utf-8")
|
||||||
|
video_arquivo = f"{caso}-video.json"
|
||||||
|
(pasta / video_arquivo).write_text(json.dumps({"clipes": resultados}, ensure_ascii=False, indent=2), encoding="utf-8")
|
||||||
|
audio_arquivos = []
|
||||||
|
transcricoes_por_clipe: dict[str, list[dict]] = {}
|
||||||
|
transcricao_configurada = pedido["perfil"].get("transcricao", {})
|
||||||
|
if faixas_de_audio and transcricao_configurada.get("caminho_modelo"):
|
||||||
|
try:
|
||||||
|
from engine.integracoes.whisper import ProviderDeTranscricaoLocal
|
||||||
|
from engine.dominio import Timeline
|
||||||
|
emitir("Transcrevendo áudio", 5)
|
||||||
|
provider = ProviderDeTranscricaoLocal(transcricao_configurada["caminho_modelo"],
|
||||||
|
idioma=transcricao_configurada.get("idioma", "pt"))
|
||||||
|
diarizador = None
|
||||||
|
if transcricao_configurada.get("diarizacao"):
|
||||||
|
if not os.environ.get("HF_TOKEN"):
|
||||||
|
transcricao_configurada = {**transcricao_configurada,
|
||||||
|
"aviso_diarizacao": "Token do Hugging Face não configurado."}
|
||||||
|
else:
|
||||||
|
from engine.integracoes.huggingface import ProviderDeDiarizacaoHuggingFace
|
||||||
|
modelo_diarizacao = os.environ.get("HF_DIARIZATION_MODEL", MODELO_DIARIZACAO_PADRAO)
|
||||||
|
diarizador = ProviderDeDiarizacaoHuggingFace(modelo_diarizacao)
|
||||||
|
transcricao_configurada = {**transcricao_configurada,
|
||||||
|
"modelo_diarizacao": modelo_diarizacao}
|
||||||
|
transcricoes = TranscricaoDaTimeline(provider, diretoria_de_trabalho=pasta / ".cache-audio",
|
||||||
|
diarizador=diarizador).executar(
|
||||||
|
Timeline(timeline.identificador, timeline.nome, faixas=faixas_de_audio))
|
||||||
|
for transcricao in transcricoes:
|
||||||
|
transcricoes_por_clipe[transcricao.identificador_do_clipe] = [
|
||||||
|
{"inicio": item.inicio, "fim": item.fim, "texto": item.texto,
|
||||||
|
"confianca": item.confianca, "falante": item.falante}
|
||||||
|
for item in transcricao.segmentos]
|
||||||
|
except Exception as erro:
|
||||||
|
transcricao_configurada = {**transcricao_configurada, "erro": str(erro)}
|
||||||
|
for faixa in audio:
|
||||||
|
arquivo = f"{caso}-audio-faixa-{faixa['indice'] + 1}.json"
|
||||||
|
segmentos = [{"clipe": clipe["identificador"], "segmentos": transcricoes_por_clipe.get(clipe["identificador"], [])}
|
||||||
|
for clipe in faixa["clipes"]]
|
||||||
|
(pasta / arquivo).write_text(json.dumps({"faixa": faixa, "transcricao": transcricao_configurada,
|
||||||
|
"segmentos_por_clipe": segmentos,
|
||||||
|
"status": "concluida" if transcricoes_por_clipe else "indisponivel"},
|
||||||
|
ensure_ascii=False, indent=2), encoding="utf-8")
|
||||||
|
audio_arquivos.append(arquivo)
|
||||||
|
saida.write_text(json.dumps({"versao": 1, "perfil": pedido["perfil"],
|
||||||
|
"sequencia": {"id": timeline.identificador, "nome": timeline.nome},
|
||||||
|
"artefatos": {"audio": audio_arquivos, "video": video_arquivo,
|
||||||
|
"geometria": "detalhado/geometria.jsonl"}}, ensure_ascii=False, indent=2), encoding="utf-8")
|
||||||
|
emitir("Relatório JSON gerado", 100)
|
||||||
|
return saida
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
parser = argparse.ArgumentParser()
|
||||||
|
parser.add_argument("entrada", type=Path)
|
||||||
|
parser.add_argument("saida", type=Path)
|
||||||
|
args = parser.parse_args()
|
||||||
|
print(json.dumps({"evento": "concluido", "arquivo": str(executar(args.entrada, args.saida))}, ensure_ascii=False))
|
||||||
@@ -1,7 +1,9 @@
|
|||||||
import json
|
import json
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from engine.integracoes.midia import ExtracaoDeAudio
|
from engine.integracoes.midia import ExtracaoDeAudio
|
||||||
from engine.integracoes.groq import ProviderDeTranscricaoGroq
|
from engine.integracoes.whisper import ProviderDeTranscricaoLocal
|
||||||
|
from engine.integracoes.huggingface import (ProviderDeDiarizacaoHuggingFace,
|
||||||
|
ProviderDeEmocaoHuggingFace)
|
||||||
from engine.integracoes.premiere.cliente_mcp import ClienteMCPPorStdio
|
from engine.integracoes.premiere.cliente_mcp import ClienteMCPPorStdio
|
||||||
from engine.integracoes.premiere.conversores import ConversorDeTimeline
|
from engine.integracoes.premiere.conversores import ConversorDeTimeline
|
||||||
from engine.scanner.transcricao_da_timeline import TranscricaoDaTimeline
|
from engine.scanner.transcricao_da_timeline import TranscricaoDaTimeline
|
||||||
@@ -10,7 +12,7 @@ ARQUIVO = Path('/Volumes/Merongo/PROJETOS/03 - Mastopexia/0E6A8290.MP4')
|
|||||||
CHAVE = json.loads((Path.home()/'.premiere-mcp/config.json').read_text()).get('groqApiKey','')
|
CHAVE = json.loads((Path.home()/'.premiere-mcp/config.json').read_text()).get('groqApiKey','')
|
||||||
SAIDA = Path('/Volumes/Merongo/SISTEMAS/GENIAL SISTEMAS/Jhonny/code/relatorios')
|
SAIDA = Path('/Volumes/Merongo/SISTEMAS/GENIAL SISTEMAS/Jhonny/code/relatorios')
|
||||||
|
|
||||||
cliente = ClienteMCPPorStdio(['node', 'dist/index.js'])
|
cliente = ClienteMCPPorStdio(['node', str(Path(__file__).resolve().parents[1] / 'dist/index.js')])
|
||||||
cliente.conectar()
|
cliente.conectar()
|
||||||
bruta = cliente.chamar('get_active_sequence', {})['structuredContent']['data']
|
bruta = cliente.chamar('get_active_sequence', {})['structuredContent']['data']
|
||||||
timeline = ConversorDeTimeline().converter(bruta)
|
timeline = ConversorDeTimeline().converter(bruta)
|
||||||
@@ -18,8 +20,13 @@ for faixa in timeline.faixas:
|
|||||||
for clipe in faixa.clipes:
|
for clipe in faixa.clipes:
|
||||||
if clipe.nome == '0E6A8290.MP4': clipe.arquivo = str(ARQUIVO)
|
if clipe.nome == '0E6A8290.MP4': clipe.arquivo = str(ARQUIVO)
|
||||||
|
|
||||||
provider = ProviderDeTranscricaoGroq(CHAVE)
|
MODELO = Path('/Volumes/Merongo/SISTEMAS/MODELOSIA/hf-cache/hub/models--mobiuslabsgmbh--faster-whisper-large-v3-turbo/snapshots/0a363e9161cbc7ed1431c9597a8ceaf0c4f78fcf')
|
||||||
transcricoes = TranscricaoDaTimeline(provider, ExtracaoDeAudio(duracao_da_parte=600), SAIDA/'audio').executar(timeline)
|
provider = ProviderDeTranscricaoLocal(str(MODELO), idioma='pt')
|
||||||
|
MODELO_EMOCAO = Path('/Volumes/Merongo/SISTEMAS/MODELOSIA/models/wav2vec2-xls-r-300m-pt-br-spontaneous-speech-emotion-recognition')
|
||||||
|
analisador_de_emocao = ProviderDeEmocaoHuggingFace(str(MODELO_EMOCAO))
|
||||||
|
MODELO_DIARIZACAO = Path('/Volumes/Merongo/SISTEMAS/MODELOSIA/models/pyannote-speaker-diarization-community-1')
|
||||||
|
diarizador = ProviderDeDiarizacaoHuggingFace(str(MODELO_DIARIZACAO))
|
||||||
|
transcricoes = TranscricaoDaTimeline(provider, ExtracaoDeAudio(duracao_da_parte=600), SAIDA/'audio', analisador_de_emocao, diarizador).executar(timeline)
|
||||||
|
|
||||||
linhas = [f'# Relatório de transcrição — {timeline.nome}', '', f'Duração: {bruta["end"]:.3f} s',
|
linhas = [f'# Relatório de transcrição — {timeline.nome}', '', f'Duração: {bruta["end"]:.3f} s',
|
||||||
f'Clipes processados: {sum(len(f.clipes) for f in timeline.faixas if f.tipo == "video")}', '']
|
f'Clipes processados: {sum(len(f.clipes) for f in timeline.faixas if f.tipo == "video")}', '']
|
||||||
@@ -32,5 +39,17 @@ for faixa in timeline.faixas:
|
|||||||
texto = resultado.texto if resultado else ''
|
texto = resultado.texto if resultado else ''
|
||||||
linhas += [f'## {clipe.identificador} — {clipe.intervalo_na_timeline.inicio:.3f}s–{clipe.intervalo_na_timeline.fim:.3f}s',
|
linhas += [f'## {clipe.identificador} — {clipe.intervalo_na_timeline.inicio:.3f}s–{clipe.intervalo_na_timeline.fim:.3f}s',
|
||||||
f'Origem: {intervalo.inicio:.3f}s–{intervalo.fim:.3f}s', '', texto or '_Sem fala detectada._', '']
|
f'Origem: {intervalo.inicio:.3f}s–{intervalo.fim:.3f}s', '', texto or '_Sem fala detectada._', '']
|
||||||
|
if resultado and resultado.segmentos:
|
||||||
|
linhas.append('Emoções detectadas por segmento:')
|
||||||
|
for segmento in resultado.segmentos:
|
||||||
|
if segmento.emocao:
|
||||||
|
voz = f', voz {segmento.voz_aparente}' if segmento.voz_aparente else ''
|
||||||
|
linhas.append(f'- [{segmento.inicio:.3f}s–{segmento.fim:.3f}s] {segmento.falante or "falante_indefinido"}: {segmento.emocao} ({segmento.confianca_emocao:.2f}{voz})')
|
||||||
|
linhas.append('')
|
||||||
|
for segmento in resultado.segmentos if resultado else []:
|
||||||
|
for palavra in segmento.palavras:
|
||||||
|
linhas.append(f'- [{palavra.inicio:.3f}s–{palavra.fim:.3f}s] {palavra.texto}')
|
||||||
|
if resultado and resultado.segmentos:
|
||||||
|
linhas.append('')
|
||||||
(SAIDA/'transcricao-timeline.md').write_text('\n'.join(linhas), encoding='utf-8')
|
(SAIDA/'transcricao-timeline.md').write_text('\n'.join(linhas), encoding='utf-8')
|
||||||
print(SAIDA/'transcricao-timeline.md')
|
print(SAIDA/'transcricao-timeline.md')
|
||||||
|
|||||||
@@ -1,44 +1,82 @@
|
|||||||
import AppKit
|
|
||||||
import Foundation
|
import Foundation
|
||||||
import Speech
|
import Speech
|
||||||
|
|
||||||
final class AppDelegate: NSObject, NSApplicationDelegate {
|
/// Encapsula o ciclo de vida do Apple Speech para a Engine.
|
||||||
func applicationDidFinishLaunching(_ notification: Notification) {
|
///
|
||||||
guard CommandLine.arguments.count >= 3 else { finalizar("uso: arquivo locale [somente_no_dispositivo]", 2); return }
|
/// A classe não conhece o scanner, o catálogo ou a persistência. Sua única
|
||||||
let url = URL(fileURLWithPath: CommandLine.arguments[1])
|
/// responsabilidade é converter um arquivo de mídia em JSON temporal. O
|
||||||
let locale = Locale(identifier: CommandLine.arguments[2])
|
/// processo é Foundation-only para poder ser executado como CLI sem iniciar
|
||||||
let somenteNoDispositivo = CommandLine.arguments.count > 3 && CommandLine.arguments[3] == "true"
|
/// AppKit ou NSApplication.
|
||||||
guard let recognizer = SFSpeechRecognizer(locale: locale), recognizer.isAvailable else {
|
final class TranscritorDeFalaApple {
|
||||||
finalizar("Apple Speech indisponível para o locale solicitado", 3); return
|
private let semaforo = DispatchSemaphore(value: 0)
|
||||||
|
private var payload: [String: Any] = ["segmentos": []]
|
||||||
|
private var codigoDeSaida: Int32 = 0
|
||||||
|
private var finalizado = false
|
||||||
|
|
||||||
|
func executar(argumentos: [String]) -> Int32 {
|
||||||
|
guard argumentos.count >= 3 else {
|
||||||
|
return finalizar("uso: apple-speech-transcriber arquivo locale [somente_no_dispositivo]", 2)
|
||||||
}
|
}
|
||||||
SFSpeechRecognizer.requestAuthorization { status in
|
|
||||||
guard status == .authorized else { self.finalizar("Permissão do Apple Speech não concedida", 4); return }
|
let arquivo = URL(fileURLWithPath: argumentos[1])
|
||||||
let request = SFSpeechURLRecognitionRequest(url: url)
|
let locale = Locale(identifier: argumentos[2])
|
||||||
|
let somenteNoDispositivo = argumentos.count > 3 && argumentos[3] == "true"
|
||||||
|
|
||||||
|
guard let recognizer = SFSpeechRecognizer(locale: locale), recognizer.isAvailable else {
|
||||||
|
return finalizar("Apple Speech indisponível para o locale solicitado", 3)
|
||||||
|
}
|
||||||
|
|
||||||
|
SFSpeechRecognizer.requestAuthorization { [weak self] status in
|
||||||
|
guard let self else { return }
|
||||||
|
guard status == .authorized else {
|
||||||
|
self.finalizar("Permissão do Apple Speech não concedida", 4)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
let request = SFSpeechURLRecognitionRequest(url: arquivo)
|
||||||
request.shouldReportPartialResults = false
|
request.shouldReportPartialResults = false
|
||||||
if somenteNoDispositivo && recognizer.supportsOnDeviceRecognition { request.requiresOnDeviceRecognition = true }
|
if somenteNoDispositivo && recognizer.supportsOnDeviceRecognition {
|
||||||
recognizer.recognitionTask(with: request) { result, error in
|
request.requiresOnDeviceRecognition = true
|
||||||
if let result = result, result.isFinal {
|
}
|
||||||
let segmentos = result.bestTranscription.segments.map {
|
recognizer.recognitionTask(with: request) { [weak self] resultado, erro in
|
||||||
|
guard let self else { return }
|
||||||
|
if let resultado, resultado.isFinal {
|
||||||
|
self.payload["segmentos"] = resultado.bestTranscription.segments.map {
|
||||||
["inicio": $0.timestamp, "fim": $0.timestamp + $0.duration,
|
["inicio": $0.timestamp, "fim": $0.timestamp + $0.duration,
|
||||||
"texto": $0.substring, "confianca": $0.confidence] as [String: Any]
|
"texto": $0.substring, "confianca": $0.confidence] as [String: Any]
|
||||||
}
|
}
|
||||||
let data = try! JSONSerialization.data(withJSONObject: ["segmentos": segmentos])
|
self.finalizar(nil, 0)
|
||||||
print(String(data: data, encoding: .utf8)!)
|
} else if let erro {
|
||||||
}
|
self.finalizar("Falha no Apple Speech: \(erro.localizedDescription)", 5)
|
||||||
if error != nil || result?.isFinal == true { self.finalizar(nil, 0) }
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func finalizar(_ mensagem: String?, _ codigo: Int32) {
|
semaforo.wait()
|
||||||
|
imprimirResultado()
|
||||||
|
return codigoDeSaida
|
||||||
|
}
|
||||||
|
|
||||||
|
@discardableResult
|
||||||
|
private func finalizar(_ mensagem: String?, _ codigo: Int32) -> Int32 {
|
||||||
|
guard !finalizado else { return codigoDeSaida }
|
||||||
|
finalizado = true
|
||||||
if let mensagem { fputs(mensagem + "\n", stderr) }
|
if let mensagem { fputs(mensagem + "\n", stderr) }
|
||||||
NSApplication.shared.terminate(nil)
|
codigoDeSaida = codigo
|
||||||
exit(codigo)
|
semaforo.signal()
|
||||||
|
return codigo
|
||||||
|
}
|
||||||
|
|
||||||
|
private func imprimirResultado() {
|
||||||
|
guard let dados = try? JSONSerialization.data(withJSONObject: payload),
|
||||||
|
let texto = String(data: dados, encoding: .utf8) else {
|
||||||
|
fputs("Não foi possível serializar o resultado do Apple Speech\n", stderr)
|
||||||
|
codigoDeSaida = 6
|
||||||
|
return
|
||||||
|
}
|
||||||
|
print(texto)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let app = NSApplication.shared
|
let transcritor = TranscritorDeFalaApple()
|
||||||
let delegate = AppDelegate()
|
exit(transcritor.executar(argumentos: CommandLine.arguments))
|
||||||
app.delegate = delegate
|
|
||||||
app.setActivationPolicy(.accessory)
|
|
||||||
app.run()
|
|
||||||
|
|||||||
@@ -7,10 +7,10 @@ from ...scanner.modelos import SegmentoDeTranscricao
|
|||||||
|
|
||||||
|
|
||||||
class ProviderDeTranscricaoApple:
|
class ProviderDeTranscricaoApple:
|
||||||
"""Provider nativo macOS Speech; o áudio não passa por API Groq."""
|
"""Provider nativo macOS Speech, local por padrão e sem AppKit."""
|
||||||
|
|
||||||
def __init__(self, executavel: str = "/private/tmp/AppleSpeechTranscriber.app/Contents/MacOS/apple-speech-transcriber", locale: str = "pt-BR",
|
def __init__(self, executavel: str = "/private/tmp/AppleSpeechTranscriber.app/Contents/MacOS/apple-speech-transcriber", locale: str = "pt-BR",
|
||||||
somente_no_dispositivo: bool = False) -> None:
|
somente_no_dispositivo: bool = True) -> None:
|
||||||
self.executavel = executavel
|
self.executavel = executavel
|
||||||
self.locale = locale
|
self.locale = locale
|
||||||
self.somente_no_dispositivo = somente_no_dispositivo
|
self.somente_no_dispositivo = somente_no_dispositivo
|
||||||
@@ -24,5 +24,5 @@ class ProviderDeTranscricaoApple:
|
|||||||
check=True, capture_output=True, text=True,
|
check=True, capture_output=True, text=True,
|
||||||
)
|
)
|
||||||
dados = json.loads(resultado.stdout)
|
dados = json.loads(resultado.stdout)
|
||||||
return [SegmentoDeTranscricao(float(s["inicio"]), float(s["fim"]), str(s["texto"]), s.get("confianca"))
|
return [SegmentoDeTranscricao(float(s["inicio"]), float(s["fim"]), str(s["texto"]).strip(), s.get("confianca"))
|
||||||
for s in dados.get("segmentos", [])]
|
for s in dados.get("segmentos", [])]
|
||||||
|
|||||||
@@ -0,0 +1,4 @@
|
|||||||
|
from .provider_de_emocao_local import ProviderDeEmocaoHuggingFace
|
||||||
|
from .provider_de_diarizacao_local import ProviderDeDiarizacaoHuggingFace
|
||||||
|
|
||||||
|
__all__ = ["ProviderDeDiarizacaoHuggingFace", "ProviderDeEmocaoHuggingFace"]
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
||||||
|
class ProviderDeDiarizacaoHuggingFace:
|
||||||
|
"""Identifica intervalos de falas por participante em áudio local."""
|
||||||
|
|
||||||
|
def __init__(self, modelo: str) -> None:
|
||||||
|
from pyannote.audio import Pipeline
|
||||||
|
self.pipeline = Pipeline.from_pretrained(modelo)
|
||||||
|
|
||||||
|
def analisar(self, arquivo: str | Path) -> list[tuple[float, float, str]]:
|
||||||
|
import soundfile as sf
|
||||||
|
import torch
|
||||||
|
audio, taxa = sf.read(str(arquivo), dtype="float32")
|
||||||
|
if getattr(audio, "ndim", 1) > 1:
|
||||||
|
audio = audio.mean(axis=1)
|
||||||
|
saida = self.pipeline({"waveform": torch.from_numpy(audio).unsqueeze(0),
|
||||||
|
"sample_rate": taxa})
|
||||||
|
diarizacao = getattr(saida, "exclusive_speaker_diarization", saida)
|
||||||
|
return [(float(turno.start), float(turno.end), str(falante))
|
||||||
|
for turno, _, falante in diarizacao.itertracks(yield_label=True)]
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
|
||||||
|
class ProviderDeEmocaoHuggingFace:
|
||||||
|
"""Classifica a emoção de uma fala localmente com Transformers."""
|
||||||
|
|
||||||
|
def __init__(self, modelo: str = "superb/wav2vec2-base-superb-er") -> None:
|
||||||
|
from transformers import AutoFeatureExtractor, AutoModelForAudioClassification
|
||||||
|
self.modelo = modelo
|
||||||
|
self.processador = AutoFeatureExtractor.from_pretrained(modelo, local_files_only=True)
|
||||||
|
self.classificador = AutoModelForAudioClassification.from_pretrained(
|
||||||
|
modelo, local_files_only=True
|
||||||
|
)
|
||||||
|
self.classificador.eval()
|
||||||
|
|
||||||
|
def analisar(self, arquivo: str | Path, inicio: float, fim: float) -> dict[str, Any]:
|
||||||
|
import soundfile as sf
|
||||||
|
import torch
|
||||||
|
audio, taxa = sf.read(str(arquivo), start=max(0, int(inicio * 16000)),
|
||||||
|
stop=max(0, int(fim * 16000)), dtype="float32")
|
||||||
|
if getattr(audio, "ndim", 1) > 1:
|
||||||
|
audio = audio.mean(axis=1)
|
||||||
|
entradas = self.processador(audio, sampling_rate=taxa, return_tensors="pt")
|
||||||
|
with torch.no_grad():
|
||||||
|
logits = self.classificador(**entradas).logits
|
||||||
|
probabilidades = torch.softmax(logits[0], dim=-1)
|
||||||
|
indice = int(torch.argmax(probabilidades))
|
||||||
|
rotulo = self.classificador.config.id2label[indice]
|
||||||
|
voz_aparente = {"non-neutral-female": "feminina",
|
||||||
|
"non-neutral-male": "masculina"}.get(rotulo)
|
||||||
|
return {"emocao": self.classificador.config.id2label[indice],
|
||||||
|
"confianca": float(probabilidades[indice]),
|
||||||
|
"voz_aparente": voz_aparente,
|
||||||
|
"confianca_voz": float(probabilidades[indice]) if voz_aparente else None}
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
from .extracao_de_audio import ExtracaoDeAudio, ParteDeAudio
|
from .extracao_de_audio import ExtracaoDeAudio, ParteDeAudio
|
||||||
|
from .extracao_de_metadados import ExtracaoDeMetadados, MetadadosDoArquivo
|
||||||
|
|
||||||
__all__ = ["ExtracaoDeAudio", "ParteDeAudio"]
|
__all__ = ["ExtracaoDeAudio", "ExtracaoDeMetadados", "MetadadosDoArquivo", "ParteDeAudio"]
|
||||||
|
|||||||
@@ -0,0 +1,106 @@
|
|||||||
|
import json
|
||||||
|
import subprocess
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from fractions import Fraction
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from ...scanner.modelos import ErroDeAnalise
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class MetadadosDoArquivo:
|
||||||
|
caminho: Path
|
||||||
|
formato: str | None = None
|
||||||
|
duracao: float | None = None
|
||||||
|
codec_de_video: str | None = None
|
||||||
|
codec_de_audio: str | None = None
|
||||||
|
largura: int | None = None
|
||||||
|
altura: int | None = None
|
||||||
|
taxa_de_quadros: float | None = None
|
||||||
|
canais_de_audio: int | None = None
|
||||||
|
taxa_de_amostragem: int | None = None
|
||||||
|
tamanho_em_bytes: int | None = None
|
||||||
|
orientacao: str | None = None
|
||||||
|
timecode: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class ExtracaoDeMetadados:
|
||||||
|
"""Extrai metadados técnicos via ffprobe, com cache por caminho normalizado."""
|
||||||
|
|
||||||
|
def __init__(self, ffprobe: str = "ffprobe") -> None:
|
||||||
|
self.ffprobe = ffprobe
|
||||||
|
self._cache: dict[Path, MetadadosDoArquivo] = {}
|
||||||
|
|
||||||
|
def extrair(self, arquivo: str | Path) -> MetadadosDoArquivo:
|
||||||
|
caminho = Path(arquivo).expanduser().resolve(strict=False)
|
||||||
|
if not caminho.is_file():
|
||||||
|
raise ErroDeAnalise("arquivo_inacessivel", "Arquivo de mídia não encontrado.", str(caminho))
|
||||||
|
if caminho in self._cache:
|
||||||
|
return self._cache[caminho]
|
||||||
|
try:
|
||||||
|
processo = subprocess.run(
|
||||||
|
[self.ffprobe, "-v", "error", "-print_format", "json", "-show_format", "-show_streams", str(caminho)],
|
||||||
|
check=True, capture_output=True, text=True,
|
||||||
|
)
|
||||||
|
dados = json.loads(processo.stdout)
|
||||||
|
except (OSError, subprocess.SubprocessError, json.JSONDecodeError) as exc:
|
||||||
|
raise ErroDeAnalise("metadados_indisponiveis", f"Não foi possível ler os metadados: {exc}", str(caminho)) from exc
|
||||||
|
|
||||||
|
metadados = self._converter(caminho, dados)
|
||||||
|
self._cache[caminho] = metadados
|
||||||
|
return metadados
|
||||||
|
|
||||||
|
def limpar_cache(self) -> None:
|
||||||
|
self._cache.clear()
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def _converter(cls, caminho: Path, dados: dict[str, Any]) -> MetadadosDoArquivo:
|
||||||
|
formato = dados.get("format") or {}
|
||||||
|
streams = dados.get("streams") or []
|
||||||
|
video = next((item for item in streams if item.get("codec_type") == "video"), {})
|
||||||
|
audio = next((item for item in streams if item.get("codec_type") == "audio"), {})
|
||||||
|
duracao = cls._float(formato.get("duration"))
|
||||||
|
if duracao is None:
|
||||||
|
duracao = cls._float(video.get("duration") or audio.get("duration"))
|
||||||
|
return MetadadosDoArquivo(
|
||||||
|
caminho=caminho,
|
||||||
|
formato=cls._format_name(formato.get("format_name")),
|
||||||
|
duracao=duracao,
|
||||||
|
codec_de_video=video.get("codec_name") or None,
|
||||||
|
codec_de_audio=audio.get("codec_name") or None,
|
||||||
|
largura=cls._int(video.get("width")), altura=cls._int(video.get("height")),
|
||||||
|
taxa_de_quadros=cls._fps(video.get("avg_frame_rate") or video.get("r_frame_rate")),
|
||||||
|
canais_de_audio=cls._int(audio.get("channels")),
|
||||||
|
taxa_de_amostragem=cls._int(audio.get("sample_rate")),
|
||||||
|
tamanho_em_bytes=cls._int(formato.get("size")) or caminho.stat().st_size,
|
||||||
|
orientacao=video.get("side_data_list", [{}])[0].get("rotation") if video.get("side_data_list") else None,
|
||||||
|
timecode=(formato.get("tags") or {}).get("timecode") or (video.get("tags") or {}).get("timecode"),
|
||||||
|
)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _float(valor: Any) -> float | None:
|
||||||
|
try:
|
||||||
|
return None if valor in (None, "", "N/A") else float(valor)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return None
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _int(valor: Any) -> int | None:
|
||||||
|
try:
|
||||||
|
return None if valor in (None, "", "N/A") else int(valor)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return None
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _fps(valor: Any) -> float | None:
|
||||||
|
if valor in (None, "", "0/0", "N/A"):
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
return float(Fraction(str(valor)))
|
||||||
|
except (ValueError, ZeroDivisionError):
|
||||||
|
return None
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _format_name(valor: Any) -> str | None:
|
||||||
|
return str(valor).split(",", 1)[0] if valor else None
|
||||||
@@ -0,0 +1,161 @@
|
|||||||
|
# Análise visual local
|
||||||
|
|
||||||
|
## Configuração no painel
|
||||||
|
|
||||||
|
A configuração do Scanner é apresentada na aba **Scanner** do painel CEP em
|
||||||
|
`code/cep-plugin/`. O perfil salvo segue o contrato versionado de
|
||||||
|
`ConfiguracaoVisualDoScanner`; ele define amostragem, faixas, recursos Vision,
|
||||||
|
cenas, continuidade, reenquadramento e interpretação antes de montar os
|
||||||
|
adapters.
|
||||||
|
|
||||||
|
## Acesso à timeline do Premiere
|
||||||
|
|
||||||
|
Quando a análise precisar ler a timeline, a sequência ativa, as faixas ou os
|
||||||
|
clipes do Premiere, use sempre a classe existente
|
||||||
|
`engine.integracoes.premiere.leitura.AcessoAoEditor`. O módulo visual não deve
|
||||||
|
criar chamadas MCP, abrir processos do Premiere ou acessar o bridge diretamente.
|
||||||
|
|
||||||
|
O acesso deve seguir esta composição:
|
||||||
|
|
||||||
|
```python
|
||||||
|
from engine.integracoes.premiere.leitura import AcessoAoEditor, AcessoATimeline
|
||||||
|
from engine.scanner import DescobertaDaTimeline
|
||||||
|
from engine.integracoes.premiere.conversores import ConversorDeTimeline
|
||||||
|
|
||||||
|
acesso_ao_editor = AcessoAoEditor(AcessoATimeline(cliente_mcp))
|
||||||
|
descoberta = DescobertaDaTimeline(acesso_ao_editor, ConversorDeTimeline())
|
||||||
|
```
|
||||||
|
|
||||||
|
Depois, a timeline convertida entra no `ContextoDeAnalise` e o Scanner executa
|
||||||
|
`AnaliseVisualDaTimeline`. A classe de acesso isola o MCP e preserva a separação
|
||||||
|
entre a integração com o Premiere e os analyzers locais.
|
||||||
|
|
||||||
|
Não duplicar esse acesso em novos adapters ou analyzers. Para testes, injetar um
|
||||||
|
fake de `AcessoAoEditor`/`AcessoATimeline` ou usar uma timeline de domínio pronta.
|
||||||
|
|
||||||
|
## Captura para análise visual
|
||||||
|
|
||||||
|
A análise visual não exporta um frame renderizado pelo Premiere. Para cada clipe
|
||||||
|
de vídeo, o fluxo usa os campos retornados pelo MCP em `get_active_sequence`:
|
||||||
|
|
||||||
|
1. `sourceFile` identifica o arquivo original do `projectItem`.
|
||||||
|
2. `inPoint` e `outPoint` delimitam o trecho usado pelo clipe.
|
||||||
|
3. O extrator abre `sourceFile` localmente e amostra o primeiro frame do trecho,
|
||||||
|
usando `inPoint` como tempo inicial na origem.
|
||||||
|
4. Vision/OpenCV/ONNX recebem esse frame persistido, mantendo o timestamp de
|
||||||
|
origem; nenhum frame é exportado da timeline do Premiere.
|
||||||
|
|
||||||
|
Portanto, `start`/`end` são tempos da timeline e `inPoint`/`outPoint` são tempos
|
||||||
|
do arquivo original. Não misturar esses relógios ao analisar um clipe.
|
||||||
|
|
||||||
|
O Scanner usa apenas as interfaces em `contratos.py`. As bibliotecas externas
|
||||||
|
ficam em adapters concretos, para que possam ser habilitadas, substituídas ou
|
||||||
|
testadas isoladamente.
|
||||||
|
|
||||||
|
| Papel | Adapter |
|
||||||
|
| --- | --- |
|
||||||
|
| Extrair frames | `ExtratorDeQuadrosOpenCV` |
|
||||||
|
| Medir qualidade | `AnalisadorDeQualidadeOpenCV` |
|
||||||
|
| Rosto e olhos visíveis | `AnalisadorDeRostosOpenCV` |
|
||||||
|
| Composição e área para legendas | `AnalisadorDeComposicaoOpenCV` |
|
||||||
|
| Tremor/movimento de câmera | `AnalisadorDeTremorOpenCV` |
|
||||||
|
| Continuidade e frame congelado | `AnalisadorDeContinuidadeOpenCV` |
|
||||||
|
| Detectar objetos | `AnalisadorDeObjetosONNX` |
|
||||||
|
| Detectar pose e mãos | `AnalisadorDePoseMediaPipe` |
|
||||||
|
| OCR e faces macOS | `AnalisadorAppleVision` |
|
||||||
|
| Vision + Apple Intelligence (Swift isolado) | `AnalisadorAppleVisionNativo` |
|
||||||
|
| Similaridade temporal por feature print | `AnalisadorSequenciaAppleVisionNativo` |
|
||||||
|
| Extrair frames para Vision sem OpenCV | `ExtratorDeQuadrosFFmpeg` |
|
||||||
|
| Detectar cenas | `DetectorDeCenasPySceneDetect` |
|
||||||
|
|
||||||
|
`AnalisadorDeObjetosONNX` exige dois adapters específicos do modelo:
|
||||||
|
`preparar(imagem, entradas)` e `decodificar(saidas, quadro)`. Isso mantém os
|
||||||
|
detalhes de cada arquivo ONNX fora do Scanner e permite trocar de modelo sem
|
||||||
|
alterar a interface do domínio.
|
||||||
|
|
||||||
|
`AnalisadorDePoseMediaPipe` recebe caminhos explícitos para os arquivos `.task`
|
||||||
|
de pose e mãos. Os modelos ficam em `/Volumes/Merongo/SISTEMAS/MODELOSIA/mediapipe/`
|
||||||
|
e não são acoplados ao pacote Python. Ele executa em subprocesso: falhas nativas
|
||||||
|
do MediaPipe são contidas e retornam como indisponibilidade do adapter, sem
|
||||||
|
encerrar o processo do Scanner.
|
||||||
|
|
||||||
|
`AnalisadorDeRostosOpenCV` usa os arquivos locais
|
||||||
|
`haarcascade_frontalface_default.xml` e `haarcascade_eye.xml`. Quando a
|
||||||
|
distribuição do OpenCV não os incluir, forneça `diretorio_de_modelos` apontando
|
||||||
|
para a pasta que os contém.
|
||||||
|
|
||||||
|
`AnalisadorAppleVisionNativo` é a opção Apple recomendada. Seu runner Swift
|
||||||
|
executa faces/landmarks, qualidade facial, pessoas, pose, mãos, OCR,
|
||||||
|
classificação e estética de forma independente. A interpretação editorial pelo
|
||||||
|
`FoundationModels` só é criada a partir dessas evidências e é salva em separado.
|
||||||
|
Falhas nativas por recurso viram `diagnostico_apple_vision`, sem derrubar o
|
||||||
|
Scanner nem descartar os fatos que funcionaram.
|
||||||
|
|
||||||
|
Além de rostos, pessoas, pose, mãos, OCR, categorias e estética, o runner
|
||||||
|
Apple retorna códigos/QR, horizonte, retângulos, densidade de contornos,
|
||||||
|
ocupação da máscara de pessoas e similaridade visual entre frames. Os valores
|
||||||
|
são fatos normalizados; descrição, contexto e ação devem ser derivados depois,
|
||||||
|
a partir dessas evidências temporizadas.
|
||||||
|
|
||||||
|
## Montagem
|
||||||
|
|
||||||
|
```python
|
||||||
|
from engine.integracoes.visual import (
|
||||||
|
AnalisadorDeQualidadeOpenCV,
|
||||||
|
AnalisadorDeRostosOpenCV,
|
||||||
|
AnalisadorDeComposicaoOpenCV,
|
||||||
|
AnalisadorDeTremorOpenCV,
|
||||||
|
AnalisadorDeContinuidadeOpenCV,
|
||||||
|
DetectorDeCenasPySceneDetect,
|
||||||
|
ExtratorDeQuadrosOpenCV,
|
||||||
|
)
|
||||||
|
from engine.scanner.visual import DetectorDeCenas
|
||||||
|
|
||||||
|
detector = DetectorDeCenas(
|
||||||
|
ExtratorDeQuadrosOpenCV(intervalo_em_segundos=1.0, diretorio_de_cache=".frames"),
|
||||||
|
analisadores_de_frame=[
|
||||||
|
AnalisadorDeQualidadeOpenCV(), AnalisadorDeRostosOpenCV(),
|
||||||
|
AnalisadorDeComposicaoOpenCV(),
|
||||||
|
],
|
||||||
|
analisadores_de_sequencia=[
|
||||||
|
AnalisadorDeTremorOpenCV(), AnalisadorDeContinuidadeOpenCV(),
|
||||||
|
],
|
||||||
|
detectores_de_intervalo=[DetectorDeCenasPySceneDetect()],
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
Depois, passe esse `DetectorDeCenas` para `AnaliseVisualDaTimeline` ao montar o
|
||||||
|
`PipelineDoScanner`.
|
||||||
|
|
||||||
|
Para o caminho local padrão, a montagem pode ser reduzida a:
|
||||||
|
|
||||||
|
```python
|
||||||
|
from engine.scanner import AnaliseVisualDaTimeline, PipelineDoScanner, criar_detector_visual_local
|
||||||
|
|
||||||
|
pipeline = PipelineDoScanner([AnaliseVisualDaTimeline(criar_detector_visual_local())])
|
||||||
|
contexto = pipeline.executar(contexto)
|
||||||
|
```
|
||||||
|
|
||||||
|
O resultado fica em `contexto.caracteristicas_visuais` (por clipe),
|
||||||
|
`contexto.cenas_visuais` (cenas com observações) e `contexto.avisos`.
|
||||||
|
Quando OpenCV/FFmpeg ou outro adapter opcional não estiver disponível, o clipe
|
||||||
|
é marcado com `disponivel=False` e os demais continuam sendo processados.
|
||||||
|
|
||||||
|
## Montagem recomendada para Apple Vision
|
||||||
|
|
||||||
|
```python
|
||||||
|
from engine.scanner import AnaliseVisualDaTimeline, PipelineDoScanner, criar_detector_apple_vision
|
||||||
|
|
||||||
|
pipeline = PipelineDoScanner([
|
||||||
|
AnaliseVisualDaTimeline(criar_detector_apple_vision(intervalo_em_segundos=1.0)),
|
||||||
|
])
|
||||||
|
```
|
||||||
|
|
||||||
|
Esse detector extrai PNGs do arquivo original com FFmpeg, usando o intervalo
|
||||||
|
`inPoint`/`outPoint` do clipe, e não exporta imagens renderizadas pelo Premiere.
|
||||||
|
Ele também recua a amostra no fim de vídeos muito curtos quando não há um frame
|
||||||
|
decodificável exatamente no timestamp solicitado.
|
||||||
|
|
||||||
|
Os analyzers de sequência não tentam concluir a intenção de uma pessoa. Eles
|
||||||
|
retornam indícios temporais (`possivel_tremor`, `possivel_descontinuidade` e
|
||||||
|
`possivel_frame_congelado`) para que a camada editorial decida como tratá-los.
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
from .analisadores import (AnalisadorAppleVision, AnalisadorDeComposicaoOpenCV,
|
||||||
|
AnalisadorDeContinuidadeOpenCV, AnalisadorDeObjetosONNX,
|
||||||
|
AnalisadorDePoseMediaPipe, AnalisadorDeQualidadeOpenCV,
|
||||||
|
AnalisadorDeRostosOpenCV, AnalisadorDeTremorOpenCV)
|
||||||
|
from .apple_vision import (AnalisadorAppleVisionNativo, AnalisadorSequenciaAppleVisionNativo,
|
||||||
|
ExecutorDoRunnerApple)
|
||||||
|
from .contratos import (AnalisadorDeFrame, AnalisadorDeSequenciaDeQuadros,
|
||||||
|
DetectorDeIntervalosDeCena, ExtratorDeQuadros)
|
||||||
|
from .detectores_de_cena import DetectorDeCenasPySceneDetect
|
||||||
|
from .extrator_open_cv import ExtratorDeQuadrosOpenCV
|
||||||
|
from .extrator_ffmpeg import ExtratorDeQuadrosFFmpeg
|
||||||
|
from .modelos import QuadroDeVideo
|
||||||
|
|
||||||
|
__all__ = ["AnalisadorAppleVision", "AnalisadorAppleVisionNativo", "AnalisadorSequenciaAppleVisionNativo", "AnalisadorDeComposicaoOpenCV",
|
||||||
|
"AnalisadorDeContinuidadeOpenCV", "AnalisadorDeFrame",
|
||||||
|
"AnalisadorDeObjetosONNX", "AnalisadorDePoseMediaPipe",
|
||||||
|
"AnalisadorDeQualidadeOpenCV", "AnalisadorDeRostosOpenCV",
|
||||||
|
"AnalisadorDeSequenciaDeQuadros", "AnalisadorDeTremorOpenCV",
|
||||||
|
"DetectorDeCenasPySceneDetect", "DetectorDeIntervalosDeCena",
|
||||||
|
"ExecutorDoRunnerApple", "ExtratorDeQuadros", "ExtratorDeQuadrosFFmpeg", "ExtratorDeQuadrosOpenCV", "QuadroDeVideo"]
|
||||||
@@ -0,0 +1,331 @@
|
|||||||
|
import json
|
||||||
|
from pathlib import Path
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
from typing import Any, Callable
|
||||||
|
|
||||||
|
from ...scanner.modelos import EvidenciaVisual
|
||||||
|
from .contratos import AnalisadorDeFrame, AnalisadorDeSequenciaDeQuadros
|
||||||
|
from .modelos import QuadroDeVideo
|
||||||
|
|
||||||
|
|
||||||
|
class AnalisadorDeQualidadeOpenCV(AnalisadorDeFrame):
|
||||||
|
"""Mede nitidez, brilho e contraste de cada frame usando OpenCV."""
|
||||||
|
|
||||||
|
nome = "opencv"
|
||||||
|
|
||||||
|
def __init__(self, cv2_module: Any | None = None) -> None:
|
||||||
|
self._cv2 = cv2_module
|
||||||
|
|
||||||
|
@property
|
||||||
|
def cv2(self) -> Any:
|
||||||
|
if self._cv2 is None:
|
||||||
|
try:
|
||||||
|
import cv2
|
||||||
|
except ImportError as exc:
|
||||||
|
raise RuntimeError("OpenCV não está instalado. Instale opencv-python.") from exc
|
||||||
|
self._cv2 = cv2
|
||||||
|
return self._cv2
|
||||||
|
|
||||||
|
def analisar(self, quadro: QuadroDeVideo) -> list[EvidenciaVisual]:
|
||||||
|
imagem = quadro.imagem
|
||||||
|
cinza = self.cv2.cvtColor(imagem, self.cv2.COLOR_BGR2GRAY) if len(imagem.shape) == 3 else imagem
|
||||||
|
media, desvio = self.cv2.meanStdDev(cinza)
|
||||||
|
nitidez = float(self.cv2.Laplacian(cinza, self.cv2.CV_64F).var())
|
||||||
|
valor = {
|
||||||
|
"largura": quadro.largura,
|
||||||
|
"altura": quadro.altura,
|
||||||
|
"brilho": float(media[0][0]),
|
||||||
|
"contraste": float(desvio[0][0]),
|
||||||
|
"nitidez_laplaciana": nitidez,
|
||||||
|
}
|
||||||
|
return [EvidenciaVisual("qualidade", quadro.timestamp, quadro.timestamp, valor,
|
||||||
|
provider=self.nome)]
|
||||||
|
|
||||||
|
|
||||||
|
class _AdapterOpenCV:
|
||||||
|
"""Implementação compartilhada para adapters OpenCV, com importação tardia."""
|
||||||
|
|
||||||
|
def __init__(self, cv2_module: Any | None = None) -> None:
|
||||||
|
self._cv2 = cv2_module
|
||||||
|
|
||||||
|
@property
|
||||||
|
def cv2(self) -> Any:
|
||||||
|
if self._cv2 is None:
|
||||||
|
try:
|
||||||
|
import cv2
|
||||||
|
except ImportError as exc:
|
||||||
|
raise RuntimeError("OpenCV não está instalado. Instale opencv-python.") from exc
|
||||||
|
self._cv2 = cv2
|
||||||
|
return self._cv2
|
||||||
|
|
||||||
|
|
||||||
|
class AnalisadorDeRostosOpenCV(_AdapterOpenCV, AnalisadorDeFrame):
|
||||||
|
"""Encontra rostos e olhos localmente; não infere identidade nem emoção."""
|
||||||
|
|
||||||
|
nome = "opencv_haar"
|
||||||
|
|
||||||
|
def __init__(self, escala: float = 1.1, vizinhos_minimos: int = 5,
|
||||||
|
diretorio_de_modelos: str | Path | None = None,
|
||||||
|
tamanho_minimo_relativo: float = 0.04,
|
||||||
|
cv2_module: Any | None = None) -> None:
|
||||||
|
super().__init__(cv2_module)
|
||||||
|
self.escala = escala
|
||||||
|
self.vizinhos_minimos = vizinhos_minimos
|
||||||
|
self.diretorio_de_modelos = Path(diretorio_de_modelos) if diretorio_de_modelos else None
|
||||||
|
self.tamanho_minimo_relativo = tamanho_minimo_relativo
|
||||||
|
self._detectores: tuple[Any, Any] | None = None
|
||||||
|
|
||||||
|
def analisar(self, quadro: QuadroDeVideo) -> list[EvidenciaVisual]:
|
||||||
|
detector_de_rostos, detector_de_olhos = self._obter_detectores()
|
||||||
|
cinza = self.cv2.cvtColor(quadro.imagem, self.cv2.COLOR_BGR2GRAY)
|
||||||
|
rostos = detector_de_rostos.detectMultiScale(cinza, scaleFactor=self.escala,
|
||||||
|
minNeighbors=self.vizinhos_minimos)
|
||||||
|
evidencias: list[EvidenciaVisual] = []
|
||||||
|
for x, y, largura, altura in rostos:
|
||||||
|
caixa = _caixa_normalizada(x, y, largura, altura, quadro.largura, quadro.altura)
|
||||||
|
if min(caixa["largura"], caixa["altura"]) < self.tamanho_minimo_relativo:
|
||||||
|
continue
|
||||||
|
rosto = EvidenciaVisual("rosto", quadro.timestamp, quadro.timestamp,
|
||||||
|
{"bounding_box": caixa, "proximo_da_borda": _proximo_da_borda(caixa)}, provider=self.nome)
|
||||||
|
olhos = detector_de_olhos.detectMultiScale(cinza[y:y + altura, x:x + largura],
|
||||||
|
scaleFactor=1.1, minNeighbors=4)
|
||||||
|
evidencias.extend((rosto, EvidenciaVisual("olhos_visiveis", quadro.timestamp, quadro.timestamp,
|
||||||
|
{"quantidade": len(olhos), "rosto": caixa}, provider=self.nome)))
|
||||||
|
return evidencias
|
||||||
|
|
||||||
|
def _obter_detectores(self) -> tuple[Any, Any]:
|
||||||
|
if self._detectores is None:
|
||||||
|
base = self.diretorio_de_modelos or Path(self.cv2.data.haarcascades)
|
||||||
|
rostos = self.cv2.CascadeClassifier(str(base / "haarcascade_frontalface_default.xml"))
|
||||||
|
olhos = self.cv2.CascadeClassifier(str(base / "haarcascade_eye.xml"))
|
||||||
|
if rostos.empty() or olhos.empty():
|
||||||
|
raise RuntimeError(f"Cascades Haar do OpenCV não estão disponíveis em: {base}")
|
||||||
|
self._detectores = rostos, olhos
|
||||||
|
return self._detectores
|
||||||
|
|
||||||
|
|
||||||
|
class AnalisadorDeComposicaoOpenCV(_AdapterOpenCV, AnalisadorDeFrame):
|
||||||
|
"""Mede orientação, poluição visual e disponibilidade das zonas para legendas."""
|
||||||
|
|
||||||
|
nome = "opencv_composicao"
|
||||||
|
|
||||||
|
def analisar(self, quadro: QuadroDeVideo) -> list[EvidenciaVisual]:
|
||||||
|
cinza = self.cv2.cvtColor(quadro.imagem, self.cv2.COLOR_BGR2GRAY)
|
||||||
|
bordas = self.cv2.Canny(cinza, 80, 160)
|
||||||
|
densidade_de_bordas = float((bordas > 0).mean())
|
||||||
|
terco_inferior = cinza[int(quadro.altura * 2 / 3):, :]
|
||||||
|
zona_de_legenda_livre = float((self.cv2.Canny(terco_inferior, 80, 160) > 0).mean()) < 0.08
|
||||||
|
valor = {
|
||||||
|
"orientacao": "vertical" if quadro.altura > quadro.largura else "horizontal",
|
||||||
|
"densidade_de_bordas": densidade_de_bordas,
|
||||||
|
"fundo_visual_poluido": densidade_de_bordas > 0.16,
|
||||||
|
"zona_inferior_livre_para_legenda": zona_de_legenda_livre,
|
||||||
|
}
|
||||||
|
return [EvidenciaVisual("composicao", quadro.timestamp, quadro.timestamp, valor, provider=self.nome)]
|
||||||
|
|
||||||
|
|
||||||
|
class AnalisadorDeTremorOpenCV(_AdapterOpenCV, AnalisadorDeSequenciaDeQuadros):
|
||||||
|
"""Estima deslocamento global entre frames para sinalizar possível tremor de câmera."""
|
||||||
|
|
||||||
|
nome = "opencv_movimento"
|
||||||
|
|
||||||
|
def analisar(self, quadros: list[QuadroDeVideo]) -> list[EvidenciaVisual]:
|
||||||
|
if len(quadros) < 2:
|
||||||
|
return []
|
||||||
|
deslocamentos = [_deslocamento_global(self.cv2, anterior.imagem, atual.imagem)
|
||||||
|
for anterior, atual in zip(quadros, quadros[1:])]
|
||||||
|
deslocamentos = [item for item in deslocamentos if item is not None]
|
||||||
|
if not deslocamentos:
|
||||||
|
return []
|
||||||
|
medio = sum(deslocamentos) / len(deslocamentos)
|
||||||
|
variacao = sum(abs(item - medio) for item in deslocamentos) / len(deslocamentos)
|
||||||
|
inicio, fim = quadros[0].timestamp, quadros[-1].timestamp
|
||||||
|
return [EvidenciaVisual("movimento_de_camera", inicio, fim, {
|
||||||
|
"deslocamento_medio_pixels": medio,
|
||||||
|
"variacao_do_deslocamento": variacao,
|
||||||
|
"possivel_tremor": variacao > 2.0 and medio > 1.0,
|
||||||
|
"amostras": len(deslocamentos),
|
||||||
|
}, provider=self.nome)]
|
||||||
|
|
||||||
|
|
||||||
|
class AnalisadorDeContinuidadeOpenCV(_AdapterOpenCV, AnalisadorDeSequenciaDeQuadros):
|
||||||
|
"""Compara pares de frames e retorna indícios, não certezas, de descontinuidade ou congelamento."""
|
||||||
|
|
||||||
|
nome = "opencv_continuidade"
|
||||||
|
|
||||||
|
def analisar(self, quadros: list[QuadroDeVideo]) -> list[EvidenciaVisual]:
|
||||||
|
evidencias: list[EvidenciaVisual] = []
|
||||||
|
for anterior, atual in zip(quadros, quadros[1:]):
|
||||||
|
cinza_anterior = self.cv2.cvtColor(anterior.imagem, self.cv2.COLOR_BGR2GRAY)
|
||||||
|
cinza_atual = self.cv2.cvtColor(atual.imagem, self.cv2.COLOR_BGR2GRAY)
|
||||||
|
diferenca = float(self.cv2.absdiff(cinza_anterior, cinza_atual).mean())
|
||||||
|
evidencias.append(EvidenciaVisual("continuidade", anterior.timestamp, atual.timestamp, {
|
||||||
|
"diferenca_media_de_luminancia": diferenca,
|
||||||
|
"possivel_frame_congelado": diferenca < 0.8,
|
||||||
|
"possivel_descontinuidade": diferenca > 38.0,
|
||||||
|
}, provider=self.nome))
|
||||||
|
return evidencias
|
||||||
|
|
||||||
|
|
||||||
|
class AnalisadorDeObjetosONNX(AnalisadorDeFrame):
|
||||||
|
"""Adapter de modelo ONNX; pré e pós-processamento pertencem ao modelo escolhido."""
|
||||||
|
|
||||||
|
nome = "onnxruntime"
|
||||||
|
|
||||||
|
def __init__(self, modelo: str | Path, preparar: Callable[[Any, list[str]], dict[str, Any]],
|
||||||
|
decodificar: Callable[[list[Any], QuadroDeVideo], list[dict[str, Any]]],
|
||||||
|
usar_coreml: bool = True, ort_module: Any | None = None) -> None:
|
||||||
|
self.modelo = str(modelo)
|
||||||
|
self.preparar = preparar
|
||||||
|
self.decodificar = decodificar
|
||||||
|
self._ort = ort_module
|
||||||
|
ort = self.ort
|
||||||
|
preferidos = ["CoreMLExecutionProvider", "CPUExecutionProvider"] if usar_coreml else ["CPUExecutionProvider"]
|
||||||
|
disponiveis = set(ort.get_available_providers())
|
||||||
|
self.providers = [provider for provider in preferidos if provider in disponiveis]
|
||||||
|
if not self.providers:
|
||||||
|
raise RuntimeError("ONNX Runtime não possui provider compatível neste ambiente.")
|
||||||
|
self.sessao = ort.InferenceSession(self.modelo, providers=self.providers)
|
||||||
|
self.entradas = [entrada.name for entrada in self.sessao.get_inputs()]
|
||||||
|
|
||||||
|
@property
|
||||||
|
def ort(self) -> Any:
|
||||||
|
if self._ort is None:
|
||||||
|
try:
|
||||||
|
import onnxruntime
|
||||||
|
except ImportError as exc:
|
||||||
|
raise RuntimeError("ONNX Runtime não está instalado. Instale onnxruntime.") from exc
|
||||||
|
self._ort = onnxruntime
|
||||||
|
return self._ort
|
||||||
|
|
||||||
|
def analisar(self, quadro: QuadroDeVideo) -> list[EvidenciaVisual]:
|
||||||
|
entradas = self.preparar(quadro.imagem, self.entradas)
|
||||||
|
saidas = self.sessao.run(None, entradas)
|
||||||
|
deteccoes = self.decodificar(saidas, quadro)
|
||||||
|
return [EvidenciaVisual("objeto", quadro.timestamp, quadro.timestamp, deteccao,
|
||||||
|
confianca=deteccao.get("confianca"), provider=self.nome,
|
||||||
|
modelo=Path(self.modelo).name) for deteccao in deteccoes]
|
||||||
|
|
||||||
|
|
||||||
|
class AnalisadorDePoseMediaPipe(AnalisadorDeFrame):
|
||||||
|
"""Adapter MediaPipe Tasks para pose e mãos em um frame."""
|
||||||
|
|
||||||
|
nome = "mediapipe"
|
||||||
|
|
||||||
|
def __init__(self, modelo_de_pose: str | Path | None = None,
|
||||||
|
modelo_de_maos: str | Path | None = None) -> None:
|
||||||
|
self.modelo_de_pose = Path(modelo_de_pose) if modelo_de_pose else None
|
||||||
|
self.modelo_de_maos = Path(modelo_de_maos) if modelo_de_maos else None
|
||||||
|
if self.modelo_de_pose is None and self.modelo_de_maos is None:
|
||||||
|
raise ValueError("Informe ao menos um modelo MediaPipe de pose ou mãos.")
|
||||||
|
def analisar(self, quadro: QuadroDeVideo) -> list[EvidenciaVisual]:
|
||||||
|
if quadro.caminho is None:
|
||||||
|
raise ValueError("MediaPipe requer que o frame tenha caminho persistido em disco.")
|
||||||
|
for modelo in (self.modelo_de_pose, self.modelo_de_maos):
|
||||||
|
if modelo:
|
||||||
|
self._validar_modelo(modelo)
|
||||||
|
carga = {
|
||||||
|
"frame": str(quadro.caminho), "pose": str(self.modelo_de_pose) if self.modelo_de_pose else None,
|
||||||
|
"maos": str(self.modelo_de_maos) if self.modelo_de_maos else None,
|
||||||
|
}
|
||||||
|
processo = subprocess.run(
|
||||||
|
[sys.executable, "-m", "engine.integracoes.visual.mediapipe_runner"],
|
||||||
|
input=json.dumps(carga), capture_output=True, text=True, timeout=60,
|
||||||
|
)
|
||||||
|
if processo.returncode != 0:
|
||||||
|
detalhe = processo.stderr.strip().splitlines()[-1] if processo.stderr.strip() else "falha nativa sem diagnóstico"
|
||||||
|
raise RuntimeError(f"MediaPipe falhou em processo isolado (código {processo.returncode}): {detalhe}")
|
||||||
|
dados = json.loads(processo.stdout)
|
||||||
|
return [EvidenciaVisual(item["tipo"], quadro.timestamp, quadro.timestamp, item["valor"],
|
||||||
|
provider=self.nome) for item in dados]
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _validar_modelo(caminho: Path) -> None:
|
||||||
|
if not caminho.is_file():
|
||||||
|
raise FileNotFoundError(f"Modelo MediaPipe não encontrado: {caminho}")
|
||||||
|
|
||||||
|
|
||||||
|
class AnalisadorAppleVision(AnalisadorDeFrame):
|
||||||
|
"""Usa PyObjC/Vision para OCR e detecção de faces em frames persistidos."""
|
||||||
|
|
||||||
|
nome = "apple_vision"
|
||||||
|
|
||||||
|
def __init__(self, reconhecer_texto: bool = True, detectar_faces: bool = True) -> None:
|
||||||
|
self.reconhecer_texto = reconhecer_texto
|
||||||
|
self.detectar_faces = detectar_faces
|
||||||
|
|
||||||
|
def analisar(self, quadro: QuadroDeVideo) -> list[EvidenciaVisual]:
|
||||||
|
if quadro.caminho is None:
|
||||||
|
raise ValueError("Apple Vision requer que o frame tenha caminho persistido em disco.")
|
||||||
|
try:
|
||||||
|
from Foundation import NSURL
|
||||||
|
from Vision import VNDetectFaceRectanglesRequest, VNImageRequestHandler, VNRecognizeTextRequest
|
||||||
|
except ImportError as exc:
|
||||||
|
raise RuntimeError("Apple Vision requer PyObjC e macOS.") from exc
|
||||||
|
requisicoes = []
|
||||||
|
texto = VNRecognizeTextRequest.alloc().initWithCompletionHandler_(None) if self.reconhecer_texto else None
|
||||||
|
faces = VNDetectFaceRectanglesRequest.alloc().initWithCompletionHandler_(None) if self.detectar_faces else None
|
||||||
|
if texto:
|
||||||
|
requisicoes.append(texto)
|
||||||
|
if faces:
|
||||||
|
requisicoes.append(faces)
|
||||||
|
handler = VNImageRequestHandler.alloc().initWithURL_options_(NSURL.fileURLWithPath_(str(quadro.caminho)), {})
|
||||||
|
sucesso, erro = handler.performRequests_error_(requisicoes, None)
|
||||||
|
if not sucesso:
|
||||||
|
detalhe = str(erro) if erro else "o macOS não retornou detalhe; verifique Vision/Neural Engine no host"
|
||||||
|
raise RuntimeError(f"Apple Vision falhou: {detalhe}")
|
||||||
|
evidencias: list[EvidenciaVisual] = []
|
||||||
|
if texto:
|
||||||
|
for observacao in texto.results() or []:
|
||||||
|
candidatos = observacao.topCandidates_(1)
|
||||||
|
if candidatos:
|
||||||
|
candidato = candidatos[0]
|
||||||
|
evidencias.append(EvidenciaVisual("ocr", quadro.timestamp, quadro.timestamp,
|
||||||
|
{"texto": str(candidato.string()), "bounding_box": _retangulo(observacao.boundingBox())},
|
||||||
|
confianca=float(candidato.confidence()), provider=self.nome))
|
||||||
|
if faces:
|
||||||
|
for observacao in faces.results() or []:
|
||||||
|
evidencias.append(EvidenciaVisual("rosto", quadro.timestamp, quadro.timestamp,
|
||||||
|
{"bounding_box": _retangulo(observacao.boundingBox())}, provider=self.nome))
|
||||||
|
return evidencias
|
||||||
|
|
||||||
|
|
||||||
|
def _retangulo(retangulo: Any) -> dict[str, float]:
|
||||||
|
return {"x": float(retangulo.origin.x), "y": float(retangulo.origin.y),
|
||||||
|
"largura": float(retangulo.size.width), "altura": float(retangulo.size.height)}
|
||||||
|
|
||||||
|
|
||||||
|
def _caixa_normalizada(x: int, y: int, largura: int, altura: int,
|
||||||
|
largura_do_frame: int, altura_do_frame: int) -> dict[str, float]:
|
||||||
|
return {"x": float(x / largura_do_frame), "y": float(y / altura_do_frame),
|
||||||
|
"largura": float(largura / largura_do_frame), "altura": float(altura / altura_do_frame)}
|
||||||
|
|
||||||
|
|
||||||
|
def _proximo_da_borda(caixa: dict[str, float], margem: float = 0.04) -> bool:
|
||||||
|
return (caixa["x"] < margem or caixa["y"] < margem
|
||||||
|
or caixa["x"] + caixa["largura"] > 1 - margem
|
||||||
|
or caixa["y"] + caixa["altura"] > 1 - margem)
|
||||||
|
|
||||||
|
|
||||||
|
def _deslocamento_global(cv2: Any, imagem_anterior: Any, imagem_atual: Any) -> float | None:
|
||||||
|
"""Retorna o módulo do deslocamento de câmera, descartando pares sem pontos úteis."""
|
||||||
|
import math
|
||||||
|
|
||||||
|
anterior = cv2.cvtColor(imagem_anterior, cv2.COLOR_BGR2GRAY)
|
||||||
|
atual = cv2.cvtColor(imagem_atual, cv2.COLOR_BGR2GRAY)
|
||||||
|
pontos = cv2.goodFeaturesToTrack(anterior, maxCorners=150, qualityLevel=0.01,
|
||||||
|
minDistance=12, blockSize=7)
|
||||||
|
if pontos is None or len(pontos) < 8:
|
||||||
|
return None
|
||||||
|
encontrados, status, _ = cv2.calcOpticalFlowPyrLK(anterior, atual, pontos, None)
|
||||||
|
if encontrados is None or status is None:
|
||||||
|
return None
|
||||||
|
origem = pontos[status.ravel() == 1]
|
||||||
|
destino = encontrados[status.ravel() == 1]
|
||||||
|
if len(origem) < 8:
|
||||||
|
return None
|
||||||
|
matriz, _ = cv2.estimateAffinePartial2D(origem, destino, method=cv2.RANSAC)
|
||||||
|
if matriz is None:
|
||||||
|
return None
|
||||||
|
return math.hypot(float(matriz[0, 2]), float(matriz[1, 2]))
|
||||||
@@ -0,0 +1,126 @@
|
|||||||
|
"""Adapter Python para o runner Swift que concentra Vision e Apple Intelligence."""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
import subprocess
|
||||||
|
import tempfile
|
||||||
|
from typing import Any, Callable
|
||||||
|
|
||||||
|
from ...scanner.modelos import EvidenciaVisual
|
||||||
|
from .contratos import AnalisadorDeFrame, AnalisadorDeSequenciaDeQuadros
|
||||||
|
from .modelos import QuadroDeVideo
|
||||||
|
|
||||||
|
|
||||||
|
RECURSOS_PADRAO = (
|
||||||
|
"faces", "qualidade_facial", "pessoas", "pose", "maos", "ocr", "categorias",
|
||||||
|
"estetica", "codigos", "horizonte", "retangulos", "contornos", "segmentacao_de_pessoas",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class ExecutorDoRunnerApple:
|
||||||
|
"""Compila o runner Swift quando necessário e o executa em processo isolado."""
|
||||||
|
|
||||||
|
def __init__(self, fonte: str | Path | None = None, compilador: str = "swiftc",
|
||||||
|
diretorio_de_build: str | Path | None = None) -> None:
|
||||||
|
self.fonte = Path(fonte) if fonte else Path(__file__).with_name("apple_vision_runner.swift")
|
||||||
|
self.compilador = compilador
|
||||||
|
self.diretorio_de_build = Path(diretorio_de_build) if diretorio_de_build else (
|
||||||
|
Path(tempfile.gettempdir()) / "jhonny-apple-vision")
|
||||||
|
|
||||||
|
def executar(self, carga: dict[str, Any], timeout: float) -> dict[str, Any]:
|
||||||
|
executavel = self._garantir_compilado()
|
||||||
|
processo = subprocess.run([str(executavel)], input=json.dumps(carga), capture_output=True,
|
||||||
|
text=True, timeout=timeout)
|
||||||
|
if processo.returncode != 0:
|
||||||
|
detalhe = processo.stderr.strip() or "runner Apple terminou sem diagnóstico"
|
||||||
|
raise RuntimeError(f"Runner Apple Vision falhou: {detalhe}")
|
||||||
|
try:
|
||||||
|
return json.loads(processo.stdout)
|
||||||
|
except json.JSONDecodeError as exc:
|
||||||
|
raise RuntimeError(f"Runner Apple Vision devolveu JSON inválido: {processo.stdout!r}") from exc
|
||||||
|
|
||||||
|
def _garantir_compilado(self) -> Path:
|
||||||
|
if not self.fonte.is_file():
|
||||||
|
raise FileNotFoundError(f"Fonte do runner Apple não encontrada: {self.fonte}")
|
||||||
|
self.diretorio_de_build.mkdir(parents=True, exist_ok=True)
|
||||||
|
executavel = self.diretorio_de_build / "apple-vision-runner"
|
||||||
|
if not executavel.exists() or executavel.stat().st_mtime < self.fonte.stat().st_mtime:
|
||||||
|
ambiente = os.environ | {"CLANG_MODULE_CACHE_PATH": str(self.diretorio_de_build / "module-cache")}
|
||||||
|
processo = subprocess.run([self.compilador, "-parse-as-library", str(self.fonte), "-o", str(executavel)],
|
||||||
|
capture_output=True, text=True, timeout=120, env=ambiente)
|
||||||
|
if processo.returncode != 0:
|
||||||
|
raise RuntimeError(f"Não foi possível compilar runner Apple Vision: {processo.stderr.strip()}")
|
||||||
|
return executavel
|
||||||
|
|
||||||
|
|
||||||
|
class AnalisadorAppleVisionNativo(AnalisadorDeFrame):
|
||||||
|
"""Módulo profundo: pede fatos visuais nativos e opcionalmente interpretação local.
|
||||||
|
|
||||||
|
A interface recebe somente um `QuadroDeVideo`; Vision, Foundation Models,
|
||||||
|
Swift, compilação e erros nativos permanecem atrás deste adapter.
|
||||||
|
"""
|
||||||
|
|
||||||
|
nome = "apple_vision"
|
||||||
|
|
||||||
|
def __init__(self, recursos: tuple[str, ...] = RECURSOS_PADRAO,
|
||||||
|
interpretar_com_apple_intelligence: bool = True,
|
||||||
|
executor: ExecutorDoRunnerApple | None = None,
|
||||||
|
timeout_em_segundos: float = 90.0) -> None:
|
||||||
|
self.recursos = recursos
|
||||||
|
self.interpretar_com_apple_intelligence = interpretar_com_apple_intelligence
|
||||||
|
self.executor = executor or ExecutorDoRunnerApple()
|
||||||
|
self.timeout_em_segundos = timeout_em_segundos
|
||||||
|
|
||||||
|
def analisar(self, quadro: QuadroDeVideo) -> list[EvidenciaVisual]:
|
||||||
|
if quadro.caminho is None:
|
||||||
|
raise ValueError("Apple Vision requer que o frame tenha caminho persistido em disco.")
|
||||||
|
dados = self.executor.executar({"frame": str(quadro.caminho), "recursos": list(self.recursos),
|
||||||
|
"resumir": self.interpretar_com_apple_intelligence},
|
||||||
|
self.timeout_em_segundos)
|
||||||
|
evidencias = [self._converter(item, quadro) for item in dados.get("evidencias", [])]
|
||||||
|
avisos = dados.get("avisos", [])
|
||||||
|
if avisos:
|
||||||
|
evidencias.append(EvidenciaVisual("diagnostico_apple_vision", quadro.timestamp, quadro.timestamp,
|
||||||
|
{"avisos": avisos, "recursos_solicitados": list(self.recursos)}, provider=self.nome))
|
||||||
|
return evidencias
|
||||||
|
|
||||||
|
def _converter(self, item: dict[str, Any], quadro: QuadroDeVideo) -> EvidenciaVisual:
|
||||||
|
return EvidenciaVisual(item["tipo"], quadro.timestamp, quadro.timestamp, item["valor"],
|
||||||
|
confianca=item.get("confianca"), provider=self.nome,
|
||||||
|
modelo=item.get("modelo"))
|
||||||
|
|
||||||
|
|
||||||
|
class AnalisadorSequenciaAppleVisionNativo(AnalisadorDeSequenciaDeQuadros):
|
||||||
|
"""Compara frames com feature prints nativos sem expor o protocolo Swift."""
|
||||||
|
|
||||||
|
nome = "apple_vision_sequencia"
|
||||||
|
|
||||||
|
def __init__(self, executor: ExecutorDoRunnerApple | None = None,
|
||||||
|
timeout_em_segundos: float = 90.0) -> None:
|
||||||
|
self.executor = executor or ExecutorDoRunnerApple()
|
||||||
|
self.timeout_em_segundos = timeout_em_segundos
|
||||||
|
|
||||||
|
def analisar(self, quadros: list[QuadroDeVideo]) -> list[EvidenciaVisual]:
|
||||||
|
if len(quadros) < 2:
|
||||||
|
return []
|
||||||
|
if any(quadro.caminho is None for quadro in quadros):
|
||||||
|
raise ValueError("Apple Vision de sequência requer frames persistidos em disco.")
|
||||||
|
dados = self.executor.executar({"frames": [str(quadro.caminho) for quadro in quadros],
|
||||||
|
"recursos": [], "resumir": False}, self.timeout_em_segundos)
|
||||||
|
evidencias: list[EvidenciaVisual] = []
|
||||||
|
for item in dados.get("evidencias", []):
|
||||||
|
valor = dict(item["valor"])
|
||||||
|
inicio = int(valor.pop("frame_inicial", 0))
|
||||||
|
fim = int(valor.pop("frame_final", inicio + 1))
|
||||||
|
if inicio < 0 or fim >= len(quadros) or fim < inicio:
|
||||||
|
raise RuntimeError("Runner Apple Vision devolveu índices temporais inválidos.")
|
||||||
|
evidencias.append(EvidenciaVisual(item["tipo"], quadros[inicio].timestamp,
|
||||||
|
quadros[fim].timestamp, valor,
|
||||||
|
confianca=item.get("confianca"), provider=self.nome,
|
||||||
|
modelo=item.get("modelo")))
|
||||||
|
avisos = dados.get("avisos", [])
|
||||||
|
if avisos:
|
||||||
|
evidencias.append(EvidenciaVisual("diagnostico_apple_vision", quadros[0].timestamp,
|
||||||
|
quadros[-1].timestamp, {"avisos": avisos}, provider=self.nome))
|
||||||
|
return evidencias
|
||||||
@@ -0,0 +1,368 @@
|
|||||||
|
import Foundation
|
||||||
|
import ImageIO
|
||||||
|
import Vision
|
||||||
|
import FoundationModels
|
||||||
|
import CoreVideo
|
||||||
|
|
||||||
|
struct Entrada: Decodable {
|
||||||
|
let frame: String?
|
||||||
|
let frames: [String]?
|
||||||
|
let recursos: [String]
|
||||||
|
let resumir: Bool
|
||||||
|
}
|
||||||
|
|
||||||
|
struct Evidencia: Encodable {
|
||||||
|
let tipo: String
|
||||||
|
let valor: [String: JSONValue]
|
||||||
|
let confianca: Double?
|
||||||
|
let modelo: String?
|
||||||
|
}
|
||||||
|
|
||||||
|
struct Saida: Encodable {
|
||||||
|
let evidencias: [Evidencia]
|
||||||
|
let avisos: [String]
|
||||||
|
}
|
||||||
|
|
||||||
|
enum JSONValue: Encodable {
|
||||||
|
case texto(String), numero(Double), booleano(Bool), objeto([String: JSONValue]), lista([JSONValue]), nulo
|
||||||
|
|
||||||
|
func encode(to encoder: Encoder) throws {
|
||||||
|
var container = encoder.singleValueContainer()
|
||||||
|
switch self {
|
||||||
|
case .texto(let value): try container.encode(value)
|
||||||
|
case .numero(let value): try container.encode(value)
|
||||||
|
case .booleano(let value): try container.encode(value)
|
||||||
|
case .objeto(let value): try container.encode(value)
|
||||||
|
case .lista(let value): try container.encode(value)
|
||||||
|
case .nulo: try container.encodeNil()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func caixa(_ rect: CGRect) -> [String: JSONValue] {
|
||||||
|
["x": .numero(rect.origin.x), "y": .numero(rect.origin.y),
|
||||||
|
"largura": .numero(rect.size.width), "altura": .numero(rect.size.height)]
|
||||||
|
}
|
||||||
|
|
||||||
|
func ponto(_ point: CGPoint) -> JSONValue {
|
||||||
|
.objeto(["x": .numero(point.x), "y": .numero(point.y)])
|
||||||
|
}
|
||||||
|
|
||||||
|
func ponto(_ point: VNRecognizedPoint) -> JSONValue {
|
||||||
|
.objeto(["x": .numero(point.location.x), "y": .numero(point.location.y),
|
||||||
|
"confianca": .numero(Double(point.confidence))])
|
||||||
|
}
|
||||||
|
|
||||||
|
func executar<T: VNRequest>(_ request: T, em url: URL) throws -> [VNObservation] {
|
||||||
|
guard let source = CGImageSourceCreateWithURL(url as CFURL, nil),
|
||||||
|
let imagem = CGImageSourceCreateImageAtIndex(source, 0, nil) else {
|
||||||
|
throw NSError(domain: "JhonnyAppleVision", code: 1,
|
||||||
|
userInfo: [NSLocalizedDescriptionKey: "ImageIO não conseguiu decodificar o frame"])
|
||||||
|
}
|
||||||
|
let handler = VNImageRequestHandler(cgImage: imagem, options: [:])
|
||||||
|
try handler.perform([request])
|
||||||
|
return request.results ?? []
|
||||||
|
}
|
||||||
|
|
||||||
|
func coberturaDaMascara(_ pixelBuffer: CVPixelBuffer) -> Double {
|
||||||
|
CVPixelBufferLockBaseAddress(pixelBuffer, .readOnly)
|
||||||
|
defer { CVPixelBufferUnlockBaseAddress(pixelBuffer, .readOnly) }
|
||||||
|
guard let base = CVPixelBufferGetBaseAddress(pixelBuffer) else { return 0 }
|
||||||
|
let altura = CVPixelBufferGetHeight(pixelBuffer)
|
||||||
|
let largura = CVPixelBufferGetWidth(pixelBuffer)
|
||||||
|
let stride = CVPixelBufferGetBytesPerRow(pixelBuffer)
|
||||||
|
let bytes = base.assumingMemoryBound(to: UInt8.self)
|
||||||
|
var ocupados = 0
|
||||||
|
for y in 0..<altura {
|
||||||
|
for x in 0..<largura where bytes[y * stride + x] > 12 { ocupados += 1 }
|
||||||
|
}
|
||||||
|
return Double(ocupados) / Double(max(1, altura * largura))
|
||||||
|
}
|
||||||
|
|
||||||
|
func analisarVision(_ entrada: Entrada) -> Saida {
|
||||||
|
guard let frame = entrada.frame else {
|
||||||
|
return Saida(evidencias: [], avisos: ["frame: caminho obrigatório para análise individual"])
|
||||||
|
}
|
||||||
|
let url = URL(fileURLWithPath: frame)
|
||||||
|
var evidencias: [Evidencia] = []
|
||||||
|
var avisos: [String] = []
|
||||||
|
|
||||||
|
func tentar(_ recurso: String, _ bloco: () throws -> [Evidencia]) {
|
||||||
|
guard entrada.recursos.contains(recurso) else { return }
|
||||||
|
do { evidencias.append(contentsOf: try bloco()) }
|
||||||
|
catch { avisos.append("\(recurso): \(error.localizedDescription)") }
|
||||||
|
}
|
||||||
|
|
||||||
|
tentar("faces") {
|
||||||
|
let request = VNDetectFaceLandmarksRequest()
|
||||||
|
return try executar(request, em: url).compactMap { observacao -> Evidencia? in
|
||||||
|
guard let face = observacao as? VNFaceObservation else { return nil }
|
||||||
|
var valor: [String: JSONValue] = ["bounding_box": .objeto(caixa(face.boundingBox))]
|
||||||
|
if let landmarks = face.landmarks {
|
||||||
|
let grupos: [(String, VNFaceLandmarkRegion2D?)] = [
|
||||||
|
("olho_esquerdo", landmarks.leftEye), ("olho_direito", landmarks.rightEye),
|
||||||
|
("sobrancelha_esquerda", landmarks.leftEyebrow), ("sobrancelha_direita", landmarks.rightEyebrow),
|
||||||
|
("nariz", landmarks.nose), ("labios", landmarks.outerLips), ("rosto", landmarks.faceContour)
|
||||||
|
]
|
||||||
|
valor["landmarks"] = .objeto(Dictionary(uniqueKeysWithValues: grupos.compactMap { nome, regiao in
|
||||||
|
guard let regiao else { return nil }
|
||||||
|
return (nome, .lista(regiao.normalizedPoints.map { .objeto(["x": .numero($0.x), "y": .numero($0.y)]) }))
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
return Evidencia(tipo: "rosto", valor: valor, confianca: Double(face.confidence), modelo: "VNDetectFaceLandmarksRequest")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
tentar("qualidade_facial") {
|
||||||
|
let request = VNDetectFaceCaptureQualityRequest()
|
||||||
|
return try executar(request, em: url).compactMap { observacao -> Evidencia? in
|
||||||
|
guard let face = observacao as? VNFaceObservation, let qualidade = face.faceCaptureQuality else { return nil }
|
||||||
|
return Evidencia(tipo: "qualidade_do_rosto", valor: ["bounding_box": .objeto(caixa(face.boundingBox)),
|
||||||
|
"score": .numero(Double(qualidade))], confianca: Double(face.confidence), modelo: "VNDetectFaceCaptureQualityRequest")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
tentar("pessoas") {
|
||||||
|
let request = VNDetectHumanRectanglesRequest()
|
||||||
|
request.upperBodyOnly = false
|
||||||
|
return try executar(request, em: url).compactMap { observacao in
|
||||||
|
guard let pessoa = observacao as? VNHumanObservation else { return nil }
|
||||||
|
return Evidencia(tipo: "pessoa", valor: ["bounding_box": .objeto(caixa(pessoa.boundingBox)),
|
||||||
|
"ocupacao_do_quadro": .numero(pessoa.boundingBox.width * pessoa.boundingBox.height)],
|
||||||
|
confianca: Double(pessoa.confidence), modelo: "VNDetectHumanRectanglesRequest")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
tentar("pose") {
|
||||||
|
let request = VNDetectHumanBodyPoseRequest()
|
||||||
|
return try executar(request, em: url).compactMap { observacao in
|
||||||
|
guard let pose = observacao as? VNHumanBodyPoseObservation else { return nil }
|
||||||
|
let pontos = try? pose.recognizedPoints(.all)
|
||||||
|
let dados: [String: JSONValue] = pontos.map { Dictionary(uniqueKeysWithValues: $0.map { (String(describing: $0.key), ponto($0.value)) }) } ?? [:]
|
||||||
|
return Evidencia(tipo: "pose", valor: ["pontos": .objeto(dados)], confianca: Double(pose.confidence), modelo: "VNDetectHumanBodyPoseRequest")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
tentar("maos") {
|
||||||
|
let request = VNDetectHumanHandPoseRequest()
|
||||||
|
request.maximumHandCount = 4
|
||||||
|
return try executar(request, em: url).compactMap { observacao in
|
||||||
|
guard let mao = observacao as? VNHumanHandPoseObservation else { return nil }
|
||||||
|
let pontos = try? mao.recognizedPoints(.all)
|
||||||
|
let dados: [String: JSONValue] = pontos.map { Dictionary(uniqueKeysWithValues: $0.map { (String(describing: $0.key), ponto($0.value)) }) } ?? [:]
|
||||||
|
return Evidencia(tipo: "mao", valor: ["pontos": .objeto(dados)], confianca: Double(mao.confidence), modelo: "VNDetectHumanHandPoseRequest")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
tentar("ocr") {
|
||||||
|
let request = VNRecognizeTextRequest()
|
||||||
|
request.recognitionLevel = .accurate
|
||||||
|
request.recognitionLanguages = ["pt-BR", "en-US"]
|
||||||
|
return try executar(request, em: url).compactMap { observacao in
|
||||||
|
guard let texto = observacao as? VNRecognizedTextObservation,
|
||||||
|
let candidato = texto.topCandidates(1).first else { return nil }
|
||||||
|
return Evidencia(tipo: "ocr", valor: ["texto": .texto(candidato.string),
|
||||||
|
"bounding_box": .objeto(caixa(texto.boundingBox))], confianca: Double(candidato.confidence), modelo: "VNRecognizeTextRequest")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
tentar("categorias") {
|
||||||
|
let request = VNClassifyImageRequest()
|
||||||
|
return try executar(request, em: url).compactMap { observacao in
|
||||||
|
guard let categoria = observacao as? VNClassificationObservation, categoria.confidence >= 0.2 else { return nil }
|
||||||
|
return Evidencia(tipo: "categoria", valor: ["identificador": .texto(categoria.identifier)],
|
||||||
|
confianca: Double(categoria.confidence), modelo: "VNClassifyImageRequest")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
tentar("estetica") {
|
||||||
|
let request = VNCalculateImageAestheticsScoresRequest()
|
||||||
|
return try executar(request, em: url).compactMap { observacao in
|
||||||
|
guard let score = observacao as? VNImageAestheticsScoresObservation else { return nil }
|
||||||
|
return Evidencia(tipo: "estetica", valor: ["score_global": .numero(Double(score.overallScore))],
|
||||||
|
confianca: nil, modelo: "VNCalculateImageAestheticsScoresRequest")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
tentar("codigos") {
|
||||||
|
let request = VNDetectBarcodesRequest()
|
||||||
|
return try executar(request, em: url).compactMap { observacao in
|
||||||
|
guard let codigo = observacao as? VNBarcodeObservation else { return nil }
|
||||||
|
return Evidencia(tipo: "codigo", valor: ["payload": .texto(codigo.payloadStringValue ?? ""),
|
||||||
|
"simbologia": .texto(codigo.symbology.rawValue), "bounding_box": .objeto(caixa(codigo.boundingBox))],
|
||||||
|
confianca: Double(codigo.confidence), modelo: "VNDetectBarcodesRequest")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
tentar("horizonte") {
|
||||||
|
let request = VNDetectHorizonRequest()
|
||||||
|
return try executar(request, em: url).compactMap { observacao in
|
||||||
|
guard let horizonte = observacao as? VNHorizonObservation else { return nil }
|
||||||
|
return Evidencia(tipo: "horizonte", valor: ["angulo_radianos": .numero(Double(horizonte.angle))],
|
||||||
|
confianca: Double(horizonte.confidence), modelo: "VNDetectHorizonRequest")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
tentar("retangulos") {
|
||||||
|
let request = VNDetectRectanglesRequest()
|
||||||
|
return try executar(request, em: url).compactMap { observacao in
|
||||||
|
guard let retangulo = observacao as? VNRectangleObservation else { return nil }
|
||||||
|
return Evidencia(tipo: "retangulo", valor: ["bounding_box": .objeto(caixa(retangulo.boundingBox)),
|
||||||
|
"cantos": .objeto(["superior_esquerdo": ponto(retangulo.topLeft),
|
||||||
|
"superior_direito": ponto(retangulo.topRight),
|
||||||
|
"inferior_esquerdo": ponto(retangulo.bottomLeft),
|
||||||
|
"inferior_direito": ponto(retangulo.bottomRight)])],
|
||||||
|
confianca: Double(retangulo.confidence), modelo: "VNDetectRectanglesRequest")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
tentar("contornos") {
|
||||||
|
let request = VNDetectContoursRequest()
|
||||||
|
return try executar(request, em: url).compactMap { observacao in
|
||||||
|
guard let contornos = observacao as? VNContoursObservation else { return nil }
|
||||||
|
return Evidencia(tipo: "contornos", valor: ["quantidade_principal": .numero(Double(contornos.topLevelContours.count))],
|
||||||
|
confianca: Double(contornos.confidence), modelo: "VNDetectContoursRequest")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
tentar("segmentacao_de_pessoas") {
|
||||||
|
let request = VNGeneratePersonSegmentationRequest()
|
||||||
|
request.qualityLevel = .balanced
|
||||||
|
return try executar(request, em: url).compactMap { observacao in
|
||||||
|
guard let mascara = observacao as? VNPixelBufferObservation else { return nil }
|
||||||
|
return Evidencia(tipo: "segmentacao_de_pessoas", valor: ["ocupacao_do_quadro": .numero(coberturaDaMascara(mascara.pixelBuffer))],
|
||||||
|
confianca: nil, modelo: "VNGeneratePersonSegmentationRequest")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return Saida(evidencias: evidencias, avisos: avisos)
|
||||||
|
}
|
||||||
|
|
||||||
|
func featurePrint(_ url: URL) throws -> VNFeaturePrintObservation {
|
||||||
|
let request = VNGenerateImageFeaturePrintRequest()
|
||||||
|
guard let resultado = try executar(request, em: url).first as? VNFeaturePrintObservation else {
|
||||||
|
throw NSError(domain: "JhonnyAppleVision", code: 2, userInfo: [NSLocalizedDescriptionKey: "Vision não produziu feature print"])
|
||||||
|
}
|
||||||
|
return resultado
|
||||||
|
}
|
||||||
|
|
||||||
|
func analisarSequencia(_ entrada: Entrada) -> Saida {
|
||||||
|
let caminhos = entrada.frames ?? []
|
||||||
|
guard caminhos.count >= 2 else { return Saida(evidencias: [], avisos: ["sequencia: informe ao menos dois frames"]) }
|
||||||
|
var evidencias: [Evidencia] = []
|
||||||
|
var avisos: [String] = []
|
||||||
|
for indice in 0..<(caminhos.count - 1) {
|
||||||
|
do {
|
||||||
|
var distancia: Float = 0
|
||||||
|
try featurePrint(URL(fileURLWithPath: caminhos[indice])).computeDistance(
|
||||||
|
&distancia, to: featurePrint(URL(fileURLWithPath: caminhos[indice + 1])))
|
||||||
|
evidencias.append(Evidencia(tipo: "similaridade_visual", valor: [
|
||||||
|
"frame_inicial": .numero(Double(indice)), "frame_final": .numero(Double(indice + 1)),
|
||||||
|
"distancia_feature_print": .numero(Double(distancia)),
|
||||||
|
"possivel_mudanca_de_cena": .booleano(distancia > 12),
|
||||||
|
], confianca: nil, modelo: "VNGenerateImageFeaturePrintRequest"))
|
||||||
|
} catch { avisos.append("similaridade_visual[\(indice)]: \(error.localizedDescription)") }
|
||||||
|
}
|
||||||
|
return Saida(evidencias: evidencias, avisos: avisos)
|
||||||
|
}
|
||||||
|
|
||||||
|
extension Dictionary where Key == String, Value == JSONValue {
|
||||||
|
func numero(_ chave: String) -> Double? {
|
||||||
|
if case .numero(let valor)? = self[chave] { return valor }
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
func texto(_ chave: String) -> String? {
|
||||||
|
if case .texto(let valor)? = self[chave] { return valor }
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
func booleano(_ chave: String) -> Bool? {
|
||||||
|
if case .booleano(let valor)? = self[chave] { return valor }
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
func objeto(_ chave: String) -> [String: JSONValue]? {
|
||||||
|
if case .objeto(let valor)? = self[chave] { return valor }
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Traduz uma evidência em uma frase factual com os valores medidos, nunca só o nome do tipo —
|
||||||
|
/// é isso que alimenta a interpretação editorial, então precisa carregar o fato, não só o rótulo.
|
||||||
|
func descreverEvidencia(_ evidencia: Evidencia) -> String {
|
||||||
|
let valor = evidencia.valor
|
||||||
|
switch evidencia.tipo {
|
||||||
|
case "rosto":
|
||||||
|
let grupos = valor.objeto("landmarks")?.count ?? 0
|
||||||
|
return "rosto detectado (\(grupos) grupos de landmarks mapeados)"
|
||||||
|
case "qualidade_do_rosto":
|
||||||
|
guard let score = valor.numero("score") else { return "qualidade do rosto avaliada" }
|
||||||
|
return "qualidade do rosto: score \(String(format: "%.2f", score))"
|
||||||
|
case "pessoa":
|
||||||
|
guard let ocupacao = valor.numero("ocupacao_do_quadro") else { return "pessoa detectada" }
|
||||||
|
return "pessoa ocupando \(String(format: "%.0f", ocupacao * 100))% do quadro"
|
||||||
|
case "pose":
|
||||||
|
let pontos = valor.objeto("pontos")?.count ?? 0
|
||||||
|
return "pose corporal com \(pontos) pontos reconhecidos"
|
||||||
|
case "mao":
|
||||||
|
let pontos = valor.objeto("pontos")?.count ?? 0
|
||||||
|
return "mão com \(pontos) pontos reconhecidos"
|
||||||
|
case "ocr":
|
||||||
|
guard let texto = valor.texto("texto"), !texto.isEmpty else { return "nenhum texto legível na imagem" }
|
||||||
|
return "texto detectado na imagem: \"\(texto)\""
|
||||||
|
case "categoria":
|
||||||
|
guard let identificador = valor.texto("identificador") else { return "categoria detectada" }
|
||||||
|
let confianca = evidencia.confianca.map { String(format: "%.0f%%", $0 * 100) } ?? "confiança desconhecida"
|
||||||
|
return "categoria \"\(identificador)\" (\(confianca))"
|
||||||
|
case "estetica":
|
||||||
|
guard let score = valor.numero("score_global") else { return "score estético calculado" }
|
||||||
|
return "score estético global: \(String(format: "%.2f", score))"
|
||||||
|
case "codigo":
|
||||||
|
guard let payload = valor.texto("payload"), !payload.isEmpty else { return "código detectado sem payload legível" }
|
||||||
|
return "código detectado: \"\(payload)\""
|
||||||
|
case "horizonte":
|
||||||
|
guard let angulo = valor.numero("angulo_radianos") else { return "horizonte detectado" }
|
||||||
|
return "horizonte inclinado \(String(format: "%.1f", angulo * 180 / .pi))°"
|
||||||
|
case "retangulo":
|
||||||
|
guard let bbox = valor.objeto("bounding_box"), let largura = bbox.numero("largura"), let altura = bbox.numero("altura") else {
|
||||||
|
return "retângulo/documento detectado no quadro"
|
||||||
|
}
|
||||||
|
return "retângulo detectado ocupando \(String(format: "%.0f", largura * 100))%x\(String(format: "%.0f", altura * 100))% do quadro"
|
||||||
|
case "contornos":
|
||||||
|
guard let quantidade = valor.numero("quantidade_principal") else { return "contornos detectados" }
|
||||||
|
return "\(Int(quantidade)) contornos principais detectados"
|
||||||
|
case "segmentacao_de_pessoas":
|
||||||
|
guard let ocupacao = valor.numero("ocupacao_do_quadro") else { return "segmentação de pessoa calculada" }
|
||||||
|
return "silhueta de pessoa cobrindo \(String(format: "%.0f", ocupacao * 100))% do quadro"
|
||||||
|
case "similaridade_visual":
|
||||||
|
let mudanca = valor.booleano("possivel_mudanca_de_cena") ?? false
|
||||||
|
return mudanca ? "possível corte de cena entre os quadros" : "quadros visualmente semelhantes, sem corte de cena"
|
||||||
|
default:
|
||||||
|
return evidencia.tipo
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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: "Você é um assistente editorial. Descreva apenas fatos explicitamente fornecidos; não invente pessoas, emoções, intenção ou identidade. Responda em uma frase curta em português.")
|
||||||
|
return try await sessao.respond(to: "Evidências visuais disponíveis: \(fatos). Gere uma observação editorial conservadora.").content
|
||||||
|
} catch { return nil }
|
||||||
|
}
|
||||||
|
|
||||||
|
@main struct Principal {
|
||||||
|
static func main() async {
|
||||||
|
do {
|
||||||
|
let entrada = try JSONDecoder().decode(Entrada.self, from: FileHandle.standardInput.readDataToEndOfFile())
|
||||||
|
var saida = entrada.frames?.count ?? 0 > 1 ? analisarSequencia(entrada) : analisarVision(entrada)
|
||||||
|
if entrada.resumir, let resumo = await interpretar(saida) {
|
||||||
|
saida = Saida(evidencias: saida.evidencias + [Evidencia(tipo: "interpretacao_editorial", valor: ["texto": .texto(resumo)], confianca: nil, modelo: "AppleFoundationModels")], avisos: saida.avisos)
|
||||||
|
}
|
||||||
|
let dados = try JSONEncoder().encode(saida)
|
||||||
|
FileHandle.standardOutput.write(dados)
|
||||||
|
} catch {
|
||||||
|
FileHandle.standardError.write(Data("\(error.localizedDescription)\n".utf8))
|
||||||
|
exit(1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
from abc import ABC, abstractmethod
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from ...dominio import Clipe
|
||||||
|
from ...scanner.modelos import Cena, EvidenciaVisual
|
||||||
|
from .modelos import QuadroDeVideo
|
||||||
|
|
||||||
|
|
||||||
|
class ExtratorDeQuadros(ABC):
|
||||||
|
"""Interface para obter uma amostra temporal de frames de um clipe."""
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def extrair(self, clipe: Clipe) -> list[QuadroDeVideo]: ...
|
||||||
|
|
||||||
|
|
||||||
|
class AnalisadorDeFrame(ABC):
|
||||||
|
"""Interface comum: recebe um frame e devolve evidências do domínio."""
|
||||||
|
|
||||||
|
nome: str
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def analisar(self, quadro: QuadroDeVideo) -> list[EvidenciaVisual]: ...
|
||||||
|
|
||||||
|
|
||||||
|
class AnalisadorDeSequenciaDeQuadros(ABC):
|
||||||
|
"""Interface para evidências que só existem ao comparar vários frames."""
|
||||||
|
|
||||||
|
nome: str
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def analisar(self, quadros: list[QuadroDeVideo]) -> list[EvidenciaVisual]: ...
|
||||||
|
|
||||||
|
|
||||||
|
class DetectorDeIntervalosDeCena(ABC):
|
||||||
|
"""Interface para algoritmos que encontram intervalos de cena no clipe."""
|
||||||
|
|
||||||
|
nome: str
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def detectar(self, clipe: Clipe, quadros: list[QuadroDeVideo]) -> list[Cena]: ...
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from ...dominio import Clipe
|
||||||
|
from ...scanner.modelos import Cena
|
||||||
|
from .contratos import DetectorDeIntervalosDeCena
|
||||||
|
from .modelos import QuadroDeVideo
|
||||||
|
|
||||||
|
|
||||||
|
class DetectorDeCenasPySceneDetect(DetectorDeIntervalosDeCena):
|
||||||
|
"""Adapter PySceneDetect que devolve somente cenas do domínio."""
|
||||||
|
|
||||||
|
nome = "pyscenedetect"
|
||||||
|
|
||||||
|
def __init__(self, limiar: float = 27.0, detector: Any | None = None,
|
||||||
|
detectar_funcao: Any | None = None) -> None:
|
||||||
|
self.limiar = limiar
|
||||||
|
self._detector = detector
|
||||||
|
self._detectar_funcao = detectar_funcao
|
||||||
|
|
||||||
|
def detectar(self, clipe: Clipe, quadros: list[QuadroDeVideo]) -> list[Cena]:
|
||||||
|
if not clipe.arquivo:
|
||||||
|
raise ValueError("Clipe não possui arquivo de origem.")
|
||||||
|
if self._detectar_funcao is None:
|
||||||
|
try:
|
||||||
|
from scenedetect import ContentDetector, detect
|
||||||
|
except ImportError as exc:
|
||||||
|
raise RuntimeError("PySceneDetect não está instalado. Instale scenedetect.") from exc
|
||||||
|
detector = self._detector or ContentDetector(threshold=self.limiar)
|
||||||
|
detectar = lambda arquivo: detect(arquivo, detector)
|
||||||
|
else:
|
||||||
|
detectar = self._detectar_funcao
|
||||||
|
resultado = detectar(str(clipe.arquivo))
|
||||||
|
return [Cena(float(inicio.get_seconds()), float(fim.get_seconds()), referencias=(float(inicio.get_seconds()),))
|
||||||
|
for inicio, fim in resultado]
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
"""Extrator de frames para Vision usando somente ferramentas locais do sistema."""
|
||||||
|
|
||||||
|
import json
|
||||||
|
from pathlib import Path
|
||||||
|
import subprocess
|
||||||
|
|
||||||
|
from ...dominio import Clipe
|
||||||
|
from .contratos import ExtratorDeQuadros
|
||||||
|
from .extrator_open_cv import ExtratorDeQuadrosOpenCV
|
||||||
|
from .modelos import QuadroDeVideo
|
||||||
|
|
||||||
|
|
||||||
|
class ExtratorDeQuadrosFFmpeg(ExtratorDeQuadros):
|
||||||
|
"""Persiste amostras de um arquivo original sem carregar OpenCV no processo."""
|
||||||
|
|
||||||
|
def __init__(self, intervalo_em_segundos: float = 1.0,
|
||||||
|
diretorio_de_cache: str | Path = ".frames",
|
||||||
|
maximo_de_quadros: int | None = None,
|
||||||
|
ffmpeg: str = "ffmpeg", ffprobe: str = "ffprobe") -> None:
|
||||||
|
if intervalo_em_segundos <= 0:
|
||||||
|
raise ValueError("intervalo_em_segundos deve ser positivo.")
|
||||||
|
if maximo_de_quadros is not None and maximo_de_quadros < 1:
|
||||||
|
raise ValueError("maximo_de_quadros deve ser maior que zero.")
|
||||||
|
self.intervalo_em_segundos = intervalo_em_segundos
|
||||||
|
self.diretorio_de_cache = Path(diretorio_de_cache)
|
||||||
|
self.maximo_de_quadros = maximo_de_quadros
|
||||||
|
self.ffmpeg = ffmpeg
|
||||||
|
self.ffprobe = ffprobe
|
||||||
|
|
||||||
|
def extrair(self, clipe: Clipe) -> list[QuadroDeVideo]:
|
||||||
|
if not clipe.arquivo:
|
||||||
|
raise ValueError("Clipe não possui arquivo de origem.")
|
||||||
|
arquivo = Path(clipe.arquivo)
|
||||||
|
if not arquivo.is_file():
|
||||||
|
raise FileNotFoundError(f"Arquivo do clipe não encontrado: {arquivo}")
|
||||||
|
largura, altura, duracao = self._metadados(arquivo)
|
||||||
|
inicio, fim = ExtratorDeQuadrosOpenCV._intervalo_de_origem(clipe, duracao)
|
||||||
|
timestamps = ExtratorDeQuadrosOpenCV(self.intervalo_em_segundos,
|
||||||
|
maximo_de_quadros=self.maximo_de_quadros)._timestamps(inicio, fim)
|
||||||
|
resultado: list[QuadroDeVideo] = []
|
||||||
|
for indice, timestamp in enumerate(timestamps):
|
||||||
|
# PNG evita a recodificação JPEG de YUV limitado, que falha em parte
|
||||||
|
# dos vídeos móveis e ainda preserva melhor OCR/segmentação para Vision.
|
||||||
|
destino = self.diretorio_de_cache / arquivo.stem / f"frame-{indice:06d}.png"
|
||||||
|
destino.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
amostrado, processo = self._extrair_frame(arquivo, destino, timestamp, inicio)
|
||||||
|
if processo.returncode != 0 or not destino.is_file():
|
||||||
|
detalhe = processo.stderr.strip() or "ffmpeg não produziu o frame"
|
||||||
|
raise RuntimeError(f"Não foi possível extrair frame em {timestamp}s: {detalhe}")
|
||||||
|
resultado.append(QuadroDeVideo(amostrado, indice, largura, altura, None, destino))
|
||||||
|
return resultado
|
||||||
|
|
||||||
|
def _extrair_frame(self, arquivo: Path, destino: Path, timestamp: float,
|
||||||
|
inicio: float) -> tuple[float, subprocess.CompletedProcess[str]]:
|
||||||
|
"""Recua um pouco no fim do arquivo se o decoder não encontrar frame no timestamp."""
|
||||||
|
ultimo: subprocess.CompletedProcess[str] | None = None
|
||||||
|
for candidato in (timestamp, max(inicio, timestamp - 0.1)):
|
||||||
|
if destino.exists():
|
||||||
|
destino.unlink()
|
||||||
|
processo = subprocess.run([self.ffmpeg, "-hide_banner", "-loglevel", "error", "-ss", str(candidato),
|
||||||
|
"-i", str(arquivo), "-frames:v", "1", "-y", str(destino)],
|
||||||
|
capture_output=True, text=True, timeout=60)
|
||||||
|
if processo.returncode == 0 and destino.is_file():
|
||||||
|
return candidato, processo
|
||||||
|
ultimo = processo
|
||||||
|
assert ultimo is not None
|
||||||
|
return timestamp, ultimo
|
||||||
|
|
||||||
|
def _metadados(self, arquivo: Path) -> tuple[int, int, float]:
|
||||||
|
processo = subprocess.run([self.ffprobe, "-v", "error", "-select_streams", "v:0",
|
||||||
|
"-show_entries", "stream=width,height:format=duration",
|
||||||
|
"-of", "json", str(arquivo)], capture_output=True, text=True, timeout=30)
|
||||||
|
if processo.returncode != 0:
|
||||||
|
raise RuntimeError(processo.stderr.strip() or "ffprobe não conseguiu ler o vídeo")
|
||||||
|
dados = json.loads(processo.stdout)
|
||||||
|
stream = (dados.get("streams") or [{}])[0]
|
||||||
|
return int(stream["width"]), int(stream["height"]), float((dados.get("format") or {}).get("duration") or 0)
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from ...dominio import Clipe
|
||||||
|
from .contratos import ExtratorDeQuadros
|
||||||
|
from .modelos import QuadroDeVideo
|
||||||
|
|
||||||
|
|
||||||
|
class ExtratorDeQuadrosOpenCV(ExtratorDeQuadros):
|
||||||
|
"""Extrai frames em intervalos regulares sem expor detalhes do OpenCV."""
|
||||||
|
|
||||||
|
def __init__(self, intervalo_em_segundos: float = 1.0,
|
||||||
|
diretorio_de_cache: str | Path | None = None,
|
||||||
|
maximo_de_quadros: int | None = None,
|
||||||
|
cv2_module: Any | None = None) -> None:
|
||||||
|
if intervalo_em_segundos <= 0:
|
||||||
|
raise ValueError("intervalo_em_segundos deve ser positivo.")
|
||||||
|
if maximo_de_quadros is not None and maximo_de_quadros < 1:
|
||||||
|
raise ValueError("maximo_de_quadros deve ser maior que zero.")
|
||||||
|
self.intervalo_em_segundos = intervalo_em_segundos
|
||||||
|
self.diretorio_de_cache = Path(diretorio_de_cache) if diretorio_de_cache else None
|
||||||
|
self.maximo_de_quadros = maximo_de_quadros
|
||||||
|
self._cv2 = cv2_module
|
||||||
|
|
||||||
|
@property
|
||||||
|
def cv2(self) -> Any:
|
||||||
|
if self._cv2 is None:
|
||||||
|
try:
|
||||||
|
import cv2
|
||||||
|
except ImportError as exc:
|
||||||
|
raise RuntimeError("OpenCV não está instalado. Instale opencv-python.") from exc
|
||||||
|
self._cv2 = cv2
|
||||||
|
return self._cv2
|
||||||
|
|
||||||
|
def extrair(self, clipe: Clipe) -> list[QuadroDeVideo]:
|
||||||
|
if not clipe.arquivo:
|
||||||
|
raise ValueError("Clipe não possui arquivo de origem.")
|
||||||
|
caminho = Path(clipe.arquivo)
|
||||||
|
if not caminho.is_file():
|
||||||
|
raise FileNotFoundError(f"Arquivo do clipe não encontrado: {caminho}")
|
||||||
|
captura = self.cv2.VideoCapture(str(caminho))
|
||||||
|
if not captura.isOpened():
|
||||||
|
raise RuntimeError(f"OpenCV não conseguiu abrir o vídeo: {caminho}")
|
||||||
|
try:
|
||||||
|
fps = float(captura.get(self.cv2.CAP_PROP_FPS) or 0)
|
||||||
|
total_de_frames = int(captura.get(self.cv2.CAP_PROP_FRAME_COUNT) or 0)
|
||||||
|
duracao = total_de_frames / fps if fps > 0 else 0.0
|
||||||
|
inicio, fim = self._intervalo_de_origem(clipe, duracao)
|
||||||
|
timestamps = self._timestamps(inicio, fim)
|
||||||
|
resultado: list[QuadroDeVideo] = []
|
||||||
|
for indice, timestamp in enumerate(timestamps):
|
||||||
|
captura.set(self.cv2.CAP_PROP_POS_MSEC, timestamp * 1000.0)
|
||||||
|
sucesso, imagem = captura.read()
|
||||||
|
if not sucesso or imagem is None:
|
||||||
|
continue
|
||||||
|
altura, largura = imagem.shape[:2]
|
||||||
|
salvo = self._salvar(caminho, indice, imagem)
|
||||||
|
resultado.append(QuadroDeVideo(timestamp, indice, largura, altura, imagem, salvo))
|
||||||
|
return resultado
|
||||||
|
finally:
|
||||||
|
captura.release()
|
||||||
|
|
||||||
|
def _timestamps(self, inicio: float, fim: float) -> list[float]:
|
||||||
|
if fim <= inicio:
|
||||||
|
return [inicio]
|
||||||
|
quantidade = int((fim - inicio) / self.intervalo_em_segundos) + 1
|
||||||
|
limite = max(inicio, fim - 0.001)
|
||||||
|
timestamps = [min(inicio + indice * self.intervalo_em_segundos, limite)
|
||||||
|
for indice in range(quantidade)]
|
||||||
|
return timestamps[:self.maximo_de_quadros]
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _intervalo_de_origem(clipe: Clipe, duracao: float) -> tuple[float, float]:
|
||||||
|
if clipe.intervalo_na_origem is None:
|
||||||
|
return 0.0, duracao
|
||||||
|
inicio = max(0.0, clipe.intervalo_na_origem.inicio)
|
||||||
|
fim = min(duracao, clipe.intervalo_na_origem.fim) if duracao > 0 else clipe.intervalo_na_origem.fim
|
||||||
|
return inicio, max(inicio, fim)
|
||||||
|
|
||||||
|
def _salvar(self, arquivo: Path, indice: int, imagem: Any) -> Path | None:
|
||||||
|
if self.diretorio_de_cache is None:
|
||||||
|
return None
|
||||||
|
destino = self.diretorio_de_cache / arquivo.stem / f"frame-{indice:06d}.jpg"
|
||||||
|
destino.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
if not self.cv2.imwrite(str(destino), imagem):
|
||||||
|
raise RuntimeError(f"Não foi possível salvar frame: {destino}")
|
||||||
|
return destino
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
"""Executável interno isolado para evitar que falhas nativas do MediaPipe derrubem o Scanner."""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
||||||
|
def _pontos(landmarks, visibilidade: bool = False):
|
||||||
|
return [{"x": ponto.x, "y": ponto.y, "z": ponto.z,
|
||||||
|
**({"visibilidade": getattr(ponto, "visibility", None)} if visibilidade else {})}
|
||||||
|
for ponto in landmarks]
|
||||||
|
|
||||||
|
|
||||||
|
def executar(carga: dict) -> list[dict]:
|
||||||
|
import cv2
|
||||||
|
import mediapipe as mp
|
||||||
|
|
||||||
|
imagem_bgr = cv2.imread(carga["frame"])
|
||||||
|
if imagem_bgr is None:
|
||||||
|
raise FileNotFoundError(f"Frame não encontrado: {carga['frame']}")
|
||||||
|
imagem = mp.Image(image_format=mp.ImageFormat.SRGB, data=imagem_bgr[:, :, ::-1])
|
||||||
|
resultado: list[dict] = []
|
||||||
|
if carga.get("pose"):
|
||||||
|
opcoes = mp.tasks.vision.PoseLandmarkerOptions(
|
||||||
|
base_options=mp.tasks.BaseOptions(model_asset_path=carga["pose"], delegate=mp.tasks.BaseOptions.Delegate.CPU),
|
||||||
|
running_mode=mp.tasks.vision.RunningMode.IMAGE,
|
||||||
|
)
|
||||||
|
with mp.tasks.vision.PoseLandmarker.create_from_options(opcoes) as detector:
|
||||||
|
for pose in detector.detect(imagem).pose_landmarks:
|
||||||
|
resultado.append({"tipo": "pose", "valor": {"pontos": _pontos(pose, visibilidade=True)}})
|
||||||
|
if carga.get("maos"):
|
||||||
|
opcoes = mp.tasks.vision.HandLandmarkerOptions(
|
||||||
|
base_options=mp.tasks.BaseOptions(model_asset_path=carga["maos"], delegate=mp.tasks.BaseOptions.Delegate.CPU),
|
||||||
|
running_mode=mp.tasks.vision.RunningMode.IMAGE, num_hands=4,
|
||||||
|
)
|
||||||
|
with mp.tasks.vision.HandLandmarker.create_from_options(opcoes) as detector:
|
||||||
|
for mao in detector.detect(imagem).hand_landmarks:
|
||||||
|
resultado.append({"tipo": "mao", "valor": {"pontos": _pontos(mao)}})
|
||||||
|
return resultado
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
try:
|
||||||
|
print(json.dumps(executar(json.loads(sys.stdin.read()))))
|
||||||
|
except Exception as erro:
|
||||||
|
print(str(erro), file=sys.stderr)
|
||||||
|
raise
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
from dataclasses import dataclass
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class QuadroDeVideo:
|
||||||
|
"""Frame extraído, com timestamp relativo ao arquivo de origem."""
|
||||||
|
|
||||||
|
timestamp: float
|
||||||
|
indice: int
|
||||||
|
largura: int
|
||||||
|
altura: int
|
||||||
|
imagem: Any
|
||||||
|
caminho: Path | None = None
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from ...scanner.modelos import SegmentoDeTranscricao
|
from ...scanner.modelos import PalavraDeTranscricao, SegmentoDeTranscricao
|
||||||
|
|
||||||
|
|
||||||
class ProviderDeTranscricaoLocal:
|
class ProviderDeTranscricaoLocal:
|
||||||
@@ -9,6 +9,9 @@ class ProviderDeTranscricaoLocal:
|
|||||||
|
|
||||||
def __init__(self, modelo: str, dispositivo: str = "cpu", tipo_de_calculo: str = "int8",
|
def __init__(self, modelo: str, dispositivo: str = "cpu", tipo_de_calculo: str = "int8",
|
||||||
idioma: str = "pt") -> None:
|
idioma: str = "pt") -> None:
|
||||||
|
self.nome_do_modelo = Path(modelo).name
|
||||||
|
if "faster-whisper-" in modelo:
|
||||||
|
self.nome_do_modelo = modelo.split("faster-whisper-", 1)[1].split("/", 1)[0]
|
||||||
from faster_whisper import WhisperModel
|
from faster_whisper import WhisperModel
|
||||||
self.modelo = WhisperModel(modelo, device=dispositivo, compute_type=tipo_de_calculo)
|
self.modelo = WhisperModel(modelo, device=dispositivo, compute_type=tipo_de_calculo)
|
||||||
self.idioma = idioma
|
self.idioma = idioma
|
||||||
@@ -17,5 +20,9 @@ class ProviderDeTranscricaoLocal:
|
|||||||
arquivo = Path(clipe.arquivo)
|
arquivo = Path(clipe.arquivo)
|
||||||
if not arquivo.is_file():
|
if not arquivo.is_file():
|
||||||
raise FileNotFoundError(f"Arquivo de áudio não encontrado: {arquivo}")
|
raise FileNotFoundError(f"Arquivo de áudio não encontrado: {arquivo}")
|
||||||
segmentos, _ = self.modelo.transcribe(str(arquivo), language=self.idioma, vad_filter=True)
|
segmentos, _ = self.modelo.transcribe(str(arquivo), language=self.idioma,
|
||||||
return [SegmentoDeTranscricao(float(s.start), float(s.end), s.text.strip()) for s in segmentos]
|
vad_filter=True, word_timestamps=True)
|
||||||
|
return [SegmentoDeTranscricao(float(s.start), float(s.end), s.text.strip(),
|
||||||
|
None, tuple(PalavraDeTranscricao(w.word.strip(), float(w.start), float(w.end),
|
||||||
|
getattr(w, "probability", None))
|
||||||
|
for w in (s.words or []) if w.word.strip())) for s in segmentos]
|
||||||
|
|||||||
@@ -0,0 +1,7 @@
|
|||||||
|
# Dependências opcionais para os adapters locais de análise visual.
|
||||||
|
opencv-python
|
||||||
|
scenedetect
|
||||||
|
onnxruntime
|
||||||
|
mediapipe
|
||||||
|
# Apenas macOS; permite usar Vision diretamente a partir do Python.
|
||||||
|
pyobjc-framework-Vision
|
||||||
@@ -1,7 +1,22 @@
|
|||||||
from .coordenacao import AnalisadorDeTimeline, ContextoDeAnalise, PipelineDoScanner
|
from .coordenacao import AnalisadorDeTimeline, ContextoDeAnalise, PipelineDoScanner
|
||||||
|
from .descoberta import ArquivoDescoberto, DescobertaDeArquivos, LeitorLocalDeArquivos
|
||||||
__all__ = ["AnalisadorDeTimeline", "ContextoDeAnalise", "PipelineDoScanner"]
|
from .metadados import ExtracaoDeMetadados, MetadadosDoArquivo
|
||||||
from .modelos import Cena, ErroDeAnalise, Evento, ResultadoDaAnalise, SegmentoDeTranscricao, StatusDaAnalise
|
from .modelos import (Cena, CenaVisual, EvidenciaVisual, ErroDeAnalise, Evento, ObservacaoVisual,
|
||||||
|
PalavraDeTranscricao,
|
||||||
|
ResultadoDaAnalise, SegmentoDeTranscricao, StatusDaAnalise)
|
||||||
from .transcricao_da_timeline import TranscricaoDaTimeline, TranscricaoDoClipe
|
from .transcricao_da_timeline import TranscricaoDaTimeline, TranscricaoDoClipe
|
||||||
|
from .visual import (AnaliseVisualDaTimeline, DetectorDeCenas, ResultadoVisualDoClipe,
|
||||||
|
criar_detector_apple_vision, criar_detector_visual_local)
|
||||||
|
from .relatorio_visual import gerar_relatorio_visual, gerar_resumo_visual_markdown
|
||||||
|
from .configuracao_visual import ConfiguracaoVisualDoScanner
|
||||||
|
|
||||||
__all__ = ["Cena", "ErroDeAnalise", "Evento", "ResultadoDaAnalise", "SegmentoDeTranscricao", "StatusDaAnalise", "TranscricaoDaTimeline", "TranscricaoDoClipe"]
|
__all__ = ["AnaliseVisualDaTimeline", "AnalisadorDeTimeline", "ArquivoDescoberto",
|
||||||
|
"Cena", "CenaVisual", "ContextoDeAnalise", "criar_detector_apple_vision", "criar_detector_visual_local", "DescobertaDeArquivos", "DetectorDeCenas",
|
||||||
|
"ConfiguracaoVisualDoScanner",
|
||||||
|
"EvidenciaVisual", "ErroDeAnalise", "Evento", "ExtracaoDeMetadados",
|
||||||
|
"gerar_relatorio_visual",
|
||||||
|
"gerar_resumo_visual_markdown",
|
||||||
|
"LeitorLocalDeArquivos", "MetadadosDoArquivo", "ObservacaoVisual", "PalavraDeTranscricao",
|
||||||
|
"PipelineDoScanner", "ResultadoDaAnalise", "ResultadoVisualDoClipe",
|
||||||
|
"SegmentoDeTranscricao", "StatusDaAnalise", "TranscricaoDaTimeline",
|
||||||
|
"TranscricaoDoClipe"]
|
||||||
|
|||||||
@@ -0,0 +1,60 @@
|
|||||||
|
"""Perfil declarativo que liga a configuração do painel à leitura visual."""
|
||||||
|
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
|
||||||
|
RECURSOS_VISION = frozenset((
|
||||||
|
"faces", "qualidade_facial", "pessoas", "pose", "maos", "ocr", "categorias",
|
||||||
|
"estetica", "codigos", "horizonte", "retangulos", "contornos", "segmentacao_de_pessoas",
|
||||||
|
))
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class ConfiguracaoVisualDoScanner:
|
||||||
|
"""Interface curta para uma execução de leitura visual da timeline."""
|
||||||
|
|
||||||
|
intervalo_em_segundos: float = 1.0
|
||||||
|
faixas_de_video: tuple[int, ...] = ()
|
||||||
|
faixas_de_audio: tuple[int, ...] = ()
|
||||||
|
recursos_vision: frozenset[str] = RECURSOS_VISION
|
||||||
|
detectar_cenas: bool = True
|
||||||
|
analisar_continuidade: bool = True
|
||||||
|
preparar_reenquadramento: bool = False
|
||||||
|
interpretar_cena: bool = True
|
||||||
|
|
||||||
|
def __post_init__(self) -> None:
|
||||||
|
if not 0.04 <= self.intervalo_em_segundos <= 60:
|
||||||
|
raise ValueError("intervalo_em_segundos deve estar entre 0,04 e 60.")
|
||||||
|
desconhecidos = self.recursos_vision - RECURSOS_VISION
|
||||||
|
if desconhecidos:
|
||||||
|
raise ValueError(f"Recursos Vision desconhecidos: {', '.join(sorted(desconhecidos))}")
|
||||||
|
if any(indice < 0 for indice in self.faixas_de_video + self.faixas_de_audio):
|
||||||
|
raise ValueError("Índices de faixa não podem ser negativos.")
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def de_dict(cls, dados: dict[str, Any]) -> "ConfiguracaoVisualDoScanner":
|
||||||
|
"""Lê o mesmo JSON produzido pelo painel, sem vazar detalhes de adapters."""
|
||||||
|
return cls(
|
||||||
|
intervalo_em_segundos=float(dados.get("intervalo_em_segundos", 1)),
|
||||||
|
faixas_de_video=tuple(sorted(set(int(item) for item in dados.get("faixas_de_video", ())))),
|
||||||
|
faixas_de_audio=tuple(sorted(set(int(item) for item in dados.get("faixas_de_audio", ())))),
|
||||||
|
recursos_vision=frozenset(dados.get("recursos_vision", RECURSOS_VISION)),
|
||||||
|
detectar_cenas=bool(dados.get("detectar_cenas", True)),
|
||||||
|
analisar_continuidade=bool(dados.get("analisar_continuidade", True)),
|
||||||
|
preparar_reenquadramento=bool(dados.get("preparar_reenquadramento", False)),
|
||||||
|
interpretar_cena=bool(dados.get("interpretar_cena", True)),
|
||||||
|
)
|
||||||
|
|
||||||
|
def para_dict(self) -> dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"versao": 1,
|
||||||
|
"intervalo_em_segundos": self.intervalo_em_segundos,
|
||||||
|
"faixas_de_video": list(self.faixas_de_video),
|
||||||
|
"faixas_de_audio": list(self.faixas_de_audio),
|
||||||
|
"recursos_vision": sorted(self.recursos_vision),
|
||||||
|
"detectar_cenas": self.detectar_cenas,
|
||||||
|
"analisar_continuidade": self.analisar_continuidade,
|
||||||
|
"preparar_reenquadramento": self.preparar_reenquadramento,
|
||||||
|
"interpretar_cena": self.interpretar_cena,
|
||||||
|
}
|
||||||
@@ -14,7 +14,10 @@ class ContextoDeAnalise:
|
|||||||
transcricoes: dict[str, list[Any]] = field(default_factory=dict)
|
transcricoes: dict[str, list[Any]] = field(default_factory=dict)
|
||||||
caracteristicas_visuais: dict[str, dict[str, Any]] = field(default_factory=dict)
|
caracteristicas_visuais: dict[str, dict[str, Any]] = field(default_factory=dict)
|
||||||
cenas: list[Any] = field(default_factory=list)
|
cenas: list[Any] = field(default_factory=list)
|
||||||
|
cenas_visuais: list[Any] = field(default_factory=list)
|
||||||
eventos: list[Any] = field(default_factory=list)
|
eventos: list[Any] = field(default_factory=list)
|
||||||
|
arquivos: dict[str, Any] = field(default_factory=dict)
|
||||||
|
metadados_dos_arquivos: dict[str, Any] = field(default_factory=dict)
|
||||||
|
|
||||||
|
|
||||||
class Analisador(Protocol):
|
class Analisador(Protocol):
|
||||||
@@ -42,5 +45,5 @@ class AnalisadorDeTimeline:
|
|||||||
def __init__(self, pipeline: PipelineDoScanner) -> None:
|
def __init__(self, pipeline: PipelineDoScanner) -> None:
|
||||||
self.pipeline = pipeline
|
self.pipeline = pipeline
|
||||||
|
|
||||||
def analisar(self) -> ContextoDeAnalise:
|
def analisar(self, timeline: Timeline | None = None) -> ContextoDeAnalise:
|
||||||
return self.pipeline.executar(ContextoDeAnalise())
|
return self.pipeline.executar(ContextoDeAnalise(timeline=timeline))
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
from .descoberta_da_timeline import DescobertaDaTimeline
|
from .descoberta_da_timeline import DescobertaDaTimeline
|
||||||
|
from .descoberta_de_arquivos import ArquivoDescoberto, DescobertaDeArquivos, LeitorLocalDeArquivos
|
||||||
|
|
||||||
__all__ = ["DescobertaDaTimeline"]
|
__all__ = ["ArquivoDescoberto", "DescobertaDaTimeline", "DescobertaDeArquivos", "LeitorLocalDeArquivos"]
|
||||||
|
|||||||
@@ -0,0 +1,97 @@
|
|||||||
|
from dataclasses import dataclass
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Protocol
|
||||||
|
|
||||||
|
from ...dominio import Clipe
|
||||||
|
from ...scanner.modelos import ErroDeAnalise
|
||||||
|
from ..coordenacao import ContextoDeAnalise
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class ArquivoDescoberto:
|
||||||
|
"""Resultado da validação do arquivo associado a um clipe."""
|
||||||
|
|
||||||
|
caminho: Path
|
||||||
|
existe: bool
|
||||||
|
acessivel: bool
|
||||||
|
tipo_de_midia: str | None = None
|
||||||
|
tamanho_em_bytes: int | None = None
|
||||||
|
|
||||||
|
@property
|
||||||
|
def offline(self) -> bool:
|
||||||
|
return not self.existe or not self.acessivel
|
||||||
|
|
||||||
|
|
||||||
|
class LeitorDeArquivos(Protocol):
|
||||||
|
def descobrir(self, caminho: str | Path) -> ArquivoDescoberto: ...
|
||||||
|
|
||||||
|
|
||||||
|
class LeitorLocalDeArquivos:
|
||||||
|
"""Adapter que resolve e valida caminhos no sistema de arquivos local."""
|
||||||
|
|
||||||
|
def descobrir(self, caminho: str | Path) -> ArquivoDescoberto:
|
||||||
|
caminho_resolvido = Path(caminho).expanduser().resolve(strict=False)
|
||||||
|
existe = caminho_resolvido.is_file()
|
||||||
|
acessivel = existe
|
||||||
|
tamanho = None
|
||||||
|
if existe:
|
||||||
|
try:
|
||||||
|
tamanho = caminho_resolvido.stat().st_size
|
||||||
|
with caminho_resolvido.open("rb"):
|
||||||
|
pass
|
||||||
|
except (OSError, PermissionError):
|
||||||
|
acessivel = False
|
||||||
|
return ArquivoDescoberto(
|
||||||
|
caminho=caminho_resolvido,
|
||||||
|
existe=existe,
|
||||||
|
acessivel=acessivel,
|
||||||
|
tipo_de_midia=caminho_resolvido.suffix.lower().lstrip(".") or None,
|
||||||
|
tamanho_em_bytes=tamanho,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class DescobertaDeArquivos:
|
||||||
|
"""Relaciona os clipes da timeline aos arquivos físicos de origem."""
|
||||||
|
|
||||||
|
nome = "descoberta_de_arquivos"
|
||||||
|
|
||||||
|
def __init__(self, leitor: LeitorDeArquivos | None = None) -> None:
|
||||||
|
self.leitor = leitor or LeitorLocalDeArquivos()
|
||||||
|
|
||||||
|
def executar(self, contexto: ContextoDeAnalise) -> ContextoDeAnalise:
|
||||||
|
if contexto.timeline is None:
|
||||||
|
return contexto
|
||||||
|
|
||||||
|
vistos: dict[Path, str] = {}
|
||||||
|
for clipe in self._clipes(contexto):
|
||||||
|
if not clipe.arquivo:
|
||||||
|
clipe.offline = True
|
||||||
|
self._registrar_erro(contexto, "arquivo_ausente", "Clipe sem sourceFile.", clipe)
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
descoberto = self.leitor.descobrir(clipe.arquivo)
|
||||||
|
except (OSError, ValueError, TypeError) as exc:
|
||||||
|
clipe.offline = True
|
||||||
|
self._registrar_erro(contexto, "arquivo_inacessivel", str(exc), clipe)
|
||||||
|
continue
|
||||||
|
|
||||||
|
clipe.arquivo = str(descoberto.caminho)
|
||||||
|
clipe.offline = descoberto.offline
|
||||||
|
contexto.arquivos[clipe.identificador] = descoberto
|
||||||
|
if descoberto.offline:
|
||||||
|
self._registrar_erro(contexto, "arquivo_inacessivel", "Arquivo não encontrado ou sem permissão de leitura.", clipe)
|
||||||
|
elif descoberto.caminho in vistos:
|
||||||
|
contexto.avisos.append(
|
||||||
|
f"Arquivo duplicado entre os clipes {vistos[descoberto.caminho]} e {clipe.identificador}."
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
vistos[descoberto.caminho] = clipe.identificador
|
||||||
|
return contexto
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _clipes(contexto: ContextoDeAnalise) -> list[Clipe]:
|
||||||
|
return [clipe for faixa in contexto.timeline.faixas for clipe in faixa.clipes]
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _registrar_erro(contexto: ContextoDeAnalise, codigo: str, mensagem: str, clipe: Clipe) -> None:
|
||||||
|
contexto.erros.append(str(ErroDeAnalise(codigo, mensagem, f"clipe[{clipe.identificador}].sourceFile")))
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
from typing import Protocol
|
||||||
|
|
||||||
|
from ..integracoes.midia.extracao_de_metadados import (
|
||||||
|
ExtracaoDeMetadados as ExtratorDeMetadados,
|
||||||
|
MetadadosDoArquivo,
|
||||||
|
)
|
||||||
|
from .coordenacao import ContextoDeAnalise
|
||||||
|
|
||||||
|
|
||||||
|
class ExtratorDeMetadadosProtocol(Protocol):
|
||||||
|
def extrair(self, arquivo: str) -> MetadadosDoArquivo: ...
|
||||||
|
|
||||||
|
|
||||||
|
class ExtracaoDeMetadados:
|
||||||
|
"""Enriquece cada clipe com os metadados técnicos do seu arquivo."""
|
||||||
|
|
||||||
|
nome = "extracao_de_metadados"
|
||||||
|
|
||||||
|
def __init__(self, extrator: ExtratorDeMetadadosProtocol | None = None) -> None:
|
||||||
|
self.extrator = extrator or ExtratorDeMetadados()
|
||||||
|
|
||||||
|
def executar(self, contexto: ContextoDeAnalise) -> ContextoDeAnalise:
|
||||||
|
for identificador, arquivo in contexto.arquivos.items():
|
||||||
|
if arquivo.offline:
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
metadados = self.extrator.extrair(str(arquivo.caminho))
|
||||||
|
contexto.metadados_dos_arquivos[identificador] = metadados
|
||||||
|
for faixa in contexto.timeline.faixas if contexto.timeline else []:
|
||||||
|
for clipe in faixa.clipes:
|
||||||
|
if clipe.identificador == identificador:
|
||||||
|
clipe.metadados["arquivo"] = metadados
|
||||||
|
break
|
||||||
|
except Exception as exc:
|
||||||
|
# Uma mídia inválida não deve descartar os resultados dos demais clipes.
|
||||||
|
contexto.erros.append(f"metadados_indisponiveis: {arquivo.caminho}: {exc}")
|
||||||
|
return contexto
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = ["ExtracaoDeMetadados", "MetadadosDoArquivo"]
|
||||||
@@ -28,12 +28,32 @@ class ResultadoDaAnalise:
|
|||||||
avisos: list[str] = field(default_factory=list)
|
avisos: list[str] = field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class PalavraDeTranscricao:
|
||||||
|
texto: str
|
||||||
|
inicio: float
|
||||||
|
fim: float
|
||||||
|
confianca: float | None = None
|
||||||
|
falante: str | None = None
|
||||||
|
|
||||||
|
def __post_init__(self) -> None:
|
||||||
|
if self.inicio < 0 or self.fim < self.inicio:
|
||||||
|
raise ValueError("Intervalo de palavra inválido.")
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
class SegmentoDeTranscricao:
|
class SegmentoDeTranscricao:
|
||||||
inicio: float
|
inicio: float
|
||||||
fim: float
|
fim: float
|
||||||
texto: str
|
texto: str
|
||||||
confianca: float | None = None
|
confianca: float | None = None
|
||||||
|
palavras: tuple[PalavraDeTranscricao, ...] = ()
|
||||||
|
emocao: str | None = None
|
||||||
|
confianca_emocao: float | None = None
|
||||||
|
caracteristicas_acusticas: dict[str, float] = field(default_factory=dict)
|
||||||
|
falante: str | None = None
|
||||||
|
voz_aparente: str | None = None
|
||||||
|
confianca_voz: float | None = None
|
||||||
|
|
||||||
def __post_init__(self) -> None:
|
def __post_init__(self) -> None:
|
||||||
if self.inicio < 0 or self.fim < self.inicio:
|
if self.inicio < 0 or self.fim < self.inicio:
|
||||||
@@ -54,3 +74,38 @@ class Evento:
|
|||||||
inicio: float
|
inicio: float
|
||||||
fim: float
|
fim: float
|
||||||
confianca: float | None = None
|
confianca: float | None = None
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class EvidenciaVisual:
|
||||||
|
"""Fato visual normalizado, independente da biblioteca que o produziu."""
|
||||||
|
|
||||||
|
tipo: str
|
||||||
|
inicio: float
|
||||||
|
fim: float
|
||||||
|
valor: dict[str, Any]
|
||||||
|
confianca: float | None = None
|
||||||
|
provider: str = ""
|
||||||
|
modelo: str | None = None
|
||||||
|
|
||||||
|
def __post_init__(self) -> None:
|
||||||
|
if self.inicio < 0 or self.fim < self.inicio:
|
||||||
|
raise ValueError("Intervalo de evidência visual inválido.")
|
||||||
|
|
||||||
|
|
||||||
|
ObservacaoVisual = EvidenciaVisual
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class CenaVisual:
|
||||||
|
"""Intervalo visual enriquecido com as observações que o sustentam."""
|
||||||
|
|
||||||
|
identificador_do_clipe: str
|
||||||
|
inicio: float
|
||||||
|
fim: float
|
||||||
|
observacoes: tuple[EvidenciaVisual, ...] = ()
|
||||||
|
referencias_de_cena: tuple[float, ...] = ()
|
||||||
|
|
||||||
|
def __post_init__(self) -> None:
|
||||||
|
if self.inicio < 0 or self.fim < self.inicio:
|
||||||
|
raise ValueError("Intervalo de cena visual inválido.")
|
||||||
|
|||||||
@@ -0,0 +1,79 @@
|
|||||||
|
"""Exportação estruturada de uma leitura visual de clipe."""
|
||||||
|
|
||||||
|
from collections import defaultdict
|
||||||
|
from dataclasses import asdict
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
import json
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from ..dominio import Clipe
|
||||||
|
from .visual import ResultadoVisualDoClipe
|
||||||
|
|
||||||
|
|
||||||
|
VERSAO_DO_RELATORIO_VISUAL = "1.0"
|
||||||
|
|
||||||
|
|
||||||
|
def gerar_relatorio_visual(clipe: Clipe, resultado: ResultadoVisualDoClipe,
|
||||||
|
destino: str | Path, intervalo_de_amostragem: float) -> Path:
|
||||||
|
"""Grava um JSON autocontido, com fatos por frame e fatos temporais separados."""
|
||||||
|
if intervalo_de_amostragem <= 0:
|
||||||
|
raise ValueError("intervalo_de_amostragem deve ser positivo.")
|
||||||
|
caminho = Path(destino)
|
||||||
|
caminho.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
caminho.write_text(json.dumps(_dados_do_relatorio(clipe, resultado, intervalo_de_amostragem),
|
||||||
|
ensure_ascii=False, indent=2), encoding="utf-8")
|
||||||
|
return caminho
|
||||||
|
|
||||||
|
|
||||||
|
def gerar_resumo_visual_markdown(clipe: Clipe, resultado: ResultadoVisualDoClipe,
|
||||||
|
destino: str | Path, intervalo_de_amostragem: float) -> Path:
|
||||||
|
"""Grava uma visão humana do mesmo resultado exportado em JSON."""
|
||||||
|
caminho = Path(destino)
|
||||||
|
caminho.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
dados = _dados_do_relatorio(clipe, resultado, intervalo_de_amostragem)
|
||||||
|
linhas = [f"# Relatório visual — {clipe.nome}", "",
|
||||||
|
f"- Clipe: `{clipe.identificador}`",
|
||||||
|
f"- Origem: `{clipe.arquivo}`",
|
||||||
|
f"- Timeline: {clipe.intervalo_na_timeline.inicio:.3f}s–{clipe.intervalo_na_timeline.fim:.3f}s",
|
||||||
|
f"- Amostragem: a cada {intervalo_de_amostragem:g} segundo(s)", "",
|
||||||
|
"## Observações por frame", ""]
|
||||||
|
for frame in dados["observacoes_por_frame"]:
|
||||||
|
tipos = ", ".join(item["tipo"] for item in frame["evidencias"])
|
||||||
|
linhas.extend([f"### Origem {frame['timestamp_na_origem']:.3f}s", "", tipos or "Sem evidências.", ""])
|
||||||
|
linhas.extend(["## Continuidade", ""])
|
||||||
|
for evidencia in dados["evidencias_temporais"]:
|
||||||
|
linhas.append(f"- {evidencia['tipo']}: {evidencia['inicio']:.3f}s–{evidencia['fim']:.3f}s — "
|
||||||
|
f"`{json.dumps(evidencia['valor'], ensure_ascii=False)}`")
|
||||||
|
caminho.write_text("\n".join(linhas) + "\n", encoding="utf-8")
|
||||||
|
return caminho
|
||||||
|
|
||||||
|
|
||||||
|
def _dados_do_relatorio(clipe: Clipe, resultado: ResultadoVisualDoClipe,
|
||||||
|
intervalo_de_amostragem: float) -> dict:
|
||||||
|
por_frame = defaultdict(list)
|
||||||
|
temporais = []
|
||||||
|
for evidencia in resultado.evidencias:
|
||||||
|
item = asdict(evidencia)
|
||||||
|
if evidencia.inicio == evidencia.fim:
|
||||||
|
por_frame[evidencia.inicio].append(item)
|
||||||
|
else:
|
||||||
|
temporais.append(item)
|
||||||
|
origem = clipe.intervalo_na_origem
|
||||||
|
return {
|
||||||
|
"versao": VERSAO_DO_RELATORIO_VISUAL,
|
||||||
|
"gerado_em": datetime.now(timezone.utc).isoformat(),
|
||||||
|
"clipe": {
|
||||||
|
"identificador": clipe.identificador,
|
||||||
|
"nome": clipe.nome,
|
||||||
|
"arquivo_original": clipe.arquivo,
|
||||||
|
"intervalo_na_timeline": asdict(clipe.intervalo_na_timeline),
|
||||||
|
"intervalo_na_origem": asdict(origem) if origem else None,
|
||||||
|
},
|
||||||
|
"amostragem": {"intervalo_em_segundos": intervalo_de_amostragem},
|
||||||
|
"observacoes_por_frame": [
|
||||||
|
{"timestamp_na_origem": timestamp, "evidencias": evidencias}
|
||||||
|
for timestamp, evidencias in sorted(por_frame.items())
|
||||||
|
],
|
||||||
|
"evidencias_temporais": temporais,
|
||||||
|
"cenas": [asdict(cena) for cena in resultado.cenas_visuais],
|
||||||
|
}
|
||||||
@@ -0,0 +1,116 @@
|
|||||||
|
"""Converte a transcrição existente em falas ordenadas para a análise.
|
||||||
|
|
||||||
|
O módulo de retakes não realiza transcrição. Ele recebe a saída do
|
||||||
|
``TranscricaoDaTimeline`` (lista de ``TranscricaoDoClipe``) e a converte
|
||||||
|
em ``Fala``s ordenadas ao longo da timeline, preservando o vínculo com o
|
||||||
|
segmento/clipe de origem e as palavras alinhadas quando disponíveis.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import re
|
||||||
|
from typing import Iterable
|
||||||
|
|
||||||
|
from ..transcricao_da_timeline import TranscricaoDoClipe
|
||||||
|
from .modelos_de_retakes import Fala
|
||||||
|
|
||||||
|
# Sinais de erro mais comuns vindos dos providers de transcrição.
|
||||||
|
_PALAVRAS_DE_ERRO = {"", "...", "…"}
|
||||||
|
|
||||||
|
_VOYELS = "aeiouáéíóúâêôãõàèìòù"
|
||||||
|
_CONSOANTES = "bcdfghjklmnpqrstvwxyz"
|
||||||
|
_PADRAO_SILABA = re.compile(
|
||||||
|
rf"([{_VOYELS}][^aeiouáéíóúâêôãõàèìòù]*)|([{_CONSOANTES}]+[aeiouáéíóúâêôãõàèìòù]?)",
|
||||||
|
re.IGNORECASE,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _normalizar(texto: str) -> str:
|
||||||
|
"""Minúsculas, sem pontuação e sem espaços duplicados."""
|
||||||
|
sem_pontuacao = re.sub(r"[^\w\s]", " ", texto)
|
||||||
|
return " ".join(sem_pontuacao.lower().split())
|
||||||
|
|
||||||
|
|
||||||
|
def _prefixo_comum(a: list[str], b: list[str]) -> int:
|
||||||
|
n = 0
|
||||||
|
for x, y in zip(a, b):
|
||||||
|
if x != y:
|
||||||
|
break
|
||||||
|
n += 1
|
||||||
|
return n
|
||||||
|
|
||||||
|
|
||||||
|
def _sucesso_de_silabas(a: list[str], b: list[str]) -> float:
|
||||||
|
if not a or not b:
|
||||||
|
return 0.0
|
||||||
|
silabas_a = [_PADRAO_SILABA.findall(p)[0][0] for p in a]
|
||||||
|
silabas_b = [_PADRAO_SILABA.findall(p)[0][0] for p in b]
|
||||||
|
n = _prefixo_comum(silabas_a, silabas_b)
|
||||||
|
return n / max(len(silabas_a), len(silabas_b))
|
||||||
|
|
||||||
|
|
||||||
|
def _e_ruido(texto: str) -> bool:
|
||||||
|
texto_limpo = _normalizar(texto)
|
||||||
|
if texto_limpo in _PALAVRAS_DE_ERRO:
|
||||||
|
return True
|
||||||
|
# Fala em branco ou "vazia" por provedor.
|
||||||
|
return not texto_limpo
|
||||||
|
|
||||||
|
|
||||||
|
class AdaptadorDeFalas:
|
||||||
|
"""Transforma a transcrição da timeline em falas prontas para análise.
|
||||||
|
|
||||||
|
Recebe uma coleção de ``TranscricaoDoClipe`` (uma por faixa de áudio do
|
||||||
|
vídeo) e devolve as falas ordenadas por tempo. O ``video_id`` é um rótulo
|
||||||
|
informado pelo chamador; quando ausente, usa o identificador da timeline.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, video_id: str | None = None, faixa_id: str | None = None) -> None:
|
||||||
|
self.video_id = video_id
|
||||||
|
self.faixa_id = faixa_id
|
||||||
|
|
||||||
|
def converter(
|
||||||
|
self,
|
||||||
|
transcricoes: Iterable[TranscricaoDoClipe],
|
||||||
|
video_id: str | None = None,
|
||||||
|
faixa_id: str | None = None,
|
||||||
|
) -> list[Fala]:
|
||||||
|
video_id = video_id or self.video_id or "video"
|
||||||
|
faixa_id = faixa_id or self.faixa_id or "faixa"
|
||||||
|
|
||||||
|
falas: list[Fala] = []
|
||||||
|
for transcricao in transcricoes:
|
||||||
|
segmento_id = transcricao.identificador_do_clipe
|
||||||
|
for segmento in transcricao.segmentos:
|
||||||
|
if _e_ruido(segmento.texto):
|
||||||
|
continue
|
||||||
|
falas.append(Fala(
|
||||||
|
id=f"{segmento_id}:{segmento.inicio:.3f}",
|
||||||
|
video_id=video_id,
|
||||||
|
faixa_id=faixa_id,
|
||||||
|
segmento_id=segmento_id,
|
||||||
|
ordem=-1, # preenchida após a ordenação
|
||||||
|
inicio=segmento.inicio,
|
||||||
|
fim=segmento.fim,
|
||||||
|
texto=segmento.texto,
|
||||||
|
texto_normalizado=_normalizar(segmento.texto),
|
||||||
|
palavras=segmento.palavras,
|
||||||
|
falante=segmento.falante,
|
||||||
|
))
|
||||||
|
|
||||||
|
falas.sort(key=lambda item: (item.inicio, item.fim))
|
||||||
|
for indice, fala in enumerate(falas):
|
||||||
|
falas[indice] = Fala(
|
||||||
|
id=fala.id,
|
||||||
|
video_id=fala.video_id,
|
||||||
|
faixa_id=fala.faixa_id,
|
||||||
|
segmento_id=fala.segmento_id,
|
||||||
|
ordem=indice,
|
||||||
|
inicio=fala.inicio,
|
||||||
|
fim=fala.fim,
|
||||||
|
texto=fala.texto,
|
||||||
|
texto_normalizado=fala.texto_normalizado,
|
||||||
|
palavras=fala.palavras,
|
||||||
|
falante=fala.falante,
|
||||||
|
)
|
||||||
|
return falas
|
||||||
@@ -0,0 +1,148 @@
|
|||||||
|
"""Agrupamento de falas que representam retakes da mesma fala ou ideia.
|
||||||
|
|
||||||
|
Trabalha com uma janela temporal: compara cada fala com as ``n`` seguintes
|
||||||
|
(e só as da mesma faixa de áudio), evitando agrupar frases iguais que
|
||||||
|
aparecem em partes muito distantes do vídeo. Não decide a classificação —
|
||||||
|
apenas forma grupos candidatos e reúne as métricas.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Iterable
|
||||||
|
|
||||||
|
from .analisador_de_intervalos import AnalisadorDeIntervalos
|
||||||
|
from .comparador_de_falas import ComparadorDeFalas
|
||||||
|
from .comparador_semantico import ComparadorSemantico
|
||||||
|
from .detector_de_reinicios import DetectorDeReinicios
|
||||||
|
from .modelos_de_retakes import (
|
||||||
|
AgrupamentoDeFalas,
|
||||||
|
Fala,
|
||||||
|
ParAgrupado,
|
||||||
|
ResultadoDoIntervalo,
|
||||||
|
SinalDeReinicio,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Limiares de candidatura de um par a pertencer a um grupo de retake.
|
||||||
|
_SIMILARIDADE_MINIMA_CANDIDATO = 0.55
|
||||||
|
_SIMILARIDADE_FORTE = 0.75
|
||||||
|
_PROXIMIDADE_AUXILIAR = 0.60
|
||||||
|
|
||||||
|
# Janela padrão de falas a serem comparadas com cada uma.
|
||||||
|
_JANELA_PADRAO = 5
|
||||||
|
|
||||||
|
# Região de dúvida que dispara a comparação semântica.
|
||||||
|
_ZONA_DE_DUVIDA_INFERIOR = 0.60
|
||||||
|
_ZONA_DE_DUVIDA_SUPERIOR = 0.85
|
||||||
|
|
||||||
|
|
||||||
|
class AgrupadorDeTakes:
|
||||||
|
"""Gera grupos candidatos conectando falas semelhantes e próximas."""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
comparador: ComparadorDeFalas,
|
||||||
|
comparador_semantico: ComparadorSemantico,
|
||||||
|
analisador_de_intervalos: AnalisadorDeIntervalos,
|
||||||
|
detector_de_reinicios: DetectorDeReinicios,
|
||||||
|
janela: int = _JANELA_PADRAO,
|
||||||
|
) -> None:
|
||||||
|
self.comparador = comparador
|
||||||
|
self.comparador_semantico = comparador_semantico
|
||||||
|
self.analisador_de_intervalos = analisador_de_intervalos
|
||||||
|
self.detector_de_reinicios = detector_de_reinicios
|
||||||
|
self.janela = janela
|
||||||
|
|
||||||
|
def agrupar(self, falas: Iterable[Fala]) -> list[AgrupamentoDeFalas]:
|
||||||
|
falas = sorted(falas, key=lambda item: (item.ordem, item.inicio))
|
||||||
|
sinais = {sinal.fala_id: sinal for sinal in self.detector_de_reinicios.detectar(falas)}
|
||||||
|
arestas: list[tuple[int, int, ParAgrupado]] = []
|
||||||
|
|
||||||
|
for indice, fala_a in enumerate(falas):
|
||||||
|
limite = min(len(falas), indice + 1 + self.janela)
|
||||||
|
for jindice in range(indice + 1, limite):
|
||||||
|
fala_b = falas[jindice]
|
||||||
|
if fala_a.faixa_id != fala_b.faixa_id:
|
||||||
|
continue
|
||||||
|
par = self._avaliar_par(fala_a, fala_b, sinais)
|
||||||
|
if par is not None:
|
||||||
|
arestas.append((indice, jindice, par))
|
||||||
|
|
||||||
|
return self._agrupar_por_arestas(falas, arestas, sinais)
|
||||||
|
|
||||||
|
def _avaliar_par(
|
||||||
|
self,
|
||||||
|
fala_a: Fala,
|
||||||
|
fala_b: Fala,
|
||||||
|
sinais: dict[str, SinalDeReinicio],
|
||||||
|
) -> ParAgrupado | None:
|
||||||
|
comparacao = self.comparador.comparar(fala_a, fala_b)
|
||||||
|
intervalo = self.analisador_de_intervalos.analisar(fala_a, fala_b)
|
||||||
|
|
||||||
|
tem_reinicio = sinais.get(fala_b.id) is not None
|
||||||
|
semantica = 0.0
|
||||||
|
|
||||||
|
# Comparação semântica só na zona de dúvida, para evitar custo.
|
||||||
|
texto = comparacao.similaridade_final
|
||||||
|
if _ZONA_DE_DUVIDA_INFERIOR <= texto <= _ZONA_DE_DUVIDA_SUPERIOR:
|
||||||
|
semantica = self.comparador_semantico.comparar(fala_a, fala_b)
|
||||||
|
|
||||||
|
melhor = max(comparacao.similaridade_final, semantica)
|
||||||
|
candidato = (
|
||||||
|
melhor >= _SIMILARIDADE_FORTE
|
||||||
|
or (melhor >= _SIMILARIDADE_MINIMA_CANDIDATO
|
||||||
|
and intervalo.proximidade_temporal >= _PROXIMIDADE_AUXILIAR
|
||||||
|
and (tem_reinicio or intervalo.indicio_de_nova_tentativa))
|
||||||
|
)
|
||||||
|
if candidato:
|
||||||
|
return ParAgrupado(fala_a, fala_b, comparacao, intervalo,
|
||||||
|
round(semantica, 4))
|
||||||
|
return None
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _agrupar_por_arestas(
|
||||||
|
falas: list[Fala],
|
||||||
|
arestas: list[tuple[int, int, ParAgrupado]],
|
||||||
|
sinais: dict[str, SinalDeReinicio],
|
||||||
|
) -> list[AgrupamentoDeFalas]:
|
||||||
|
pai = list(range(len(falas)))
|
||||||
|
|
||||||
|
def encontrar(x: int) -> int:
|
||||||
|
while pai[x] != x:
|
||||||
|
pai[x] = pai[pai[x]]
|
||||||
|
x = pai[x]
|
||||||
|
return x
|
||||||
|
|
||||||
|
arestas.sort(key=lambda item: (item[0], item[1]))
|
||||||
|
for a, b, _ in arestas:
|
||||||
|
raiz_a, raiz_b = encontrar(a), encontrar(b)
|
||||||
|
if raiz_a != raiz_b:
|
||||||
|
pai[raiz_b] = raiz_a
|
||||||
|
|
||||||
|
componentes: dict[int, list[int]] = {}
|
||||||
|
for indice in range(len(falas)):
|
||||||
|
componentes.setdefault(encontrar(indice), []).append(indice)
|
||||||
|
|
||||||
|
grupos: list[AgrupamentoDeFalas] = []
|
||||||
|
for inds in componentes.values():
|
||||||
|
if len(inds) < 2:
|
||||||
|
continue
|
||||||
|
inds.sort()
|
||||||
|
fala_ids = tuple(falas[indice].id for indice in inds)
|
||||||
|
pares_por_chave: dict[tuple[str, str], ParAgrupado] = {
|
||||||
|
(par.fala_a.id, par.fala_b.id): par
|
||||||
|
for _, _, par in arestas
|
||||||
|
if par.fala_a.id in fala_ids and par.fala_b.id in fala_ids
|
||||||
|
}
|
||||||
|
pares: list[ParAgrupado] = []
|
||||||
|
for a, b in zip(inds, inds[1:]):
|
||||||
|
chave = (falas[a].id, falas[b].id)
|
||||||
|
par = pares_por_chave.get(chave)
|
||||||
|
if par is None:
|
||||||
|
par = pares_por_chave.get((falas[b].id, falas[a].id))
|
||||||
|
if par is not None:
|
||||||
|
pares.append(par)
|
||||||
|
sinais_do_grupo = tuple(
|
||||||
|
sinais[fala_id] for fala_id in fala_ids if fala_id in sinais)
|
||||||
|
if pares:
|
||||||
|
grupos.append(AgrupamentoDeFalas(fala_ids, tuple(pares), sinais_do_grupo))
|
||||||
|
return grupos
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
"""Análise da relação temporal entre falas.
|
||||||
|
|
||||||
|
Verifica quanto tempo há entre duas falas, se estão na mesma faixa/segmento
|
||||||
|
e se a proximidade sugere uma nova tentativa. Um intervalo muito grande
|
||||||
|
reduz a confiança de que seja um retake imediato.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from .modelos_de_retakes import Fala, ResultadoDoIntervalo
|
||||||
|
|
||||||
|
# A partir de quantos segundos o intervalo passa a não sugerir retake.
|
||||||
|
_INTERVALO_MAXIMO = 12.0
|
||||||
|
|
||||||
|
|
||||||
|
class AnalisadorDeIntervalos:
|
||||||
|
"""Calcula a proximidade temporal e o indício de nova tentativa."""
|
||||||
|
|
||||||
|
def __init__(self, intervalo_maximo: float = _INTERVALO_MAXIMO) -> None:
|
||||||
|
self.intervalo_maximo = intervalo_maximo
|
||||||
|
|
||||||
|
def analisar(self, fala_a: Fala, fala_b: Fala) -> ResultadoDoIntervalo:
|
||||||
|
intervalo = max(0.0, fala_b.inicio - fala_a.fim)
|
||||||
|
mesma_faixa = fala_a.faixa_id == fala_b.faixa_id
|
||||||
|
mesmo_segmento = mesma_faixa and fala_a.segmento_id == fala_b.segmento_id
|
||||||
|
|
||||||
|
if intervalo >= self.intervalo_maximo:
|
||||||
|
proximidade = 0.0
|
||||||
|
elif intervalo <= 0:
|
||||||
|
proximidade = 1.0
|
||||||
|
else:
|
||||||
|
# Decai suavemente com o intervalo: 1.0 → ~0 em 12s.
|
||||||
|
proximidade = max(0.0, 1.0 - intervalo / self.intervalo_maximo)
|
||||||
|
|
||||||
|
indicio = mesma_faixa and intervalo <= self.intervalo_maximo and proximidade >= 0.5
|
||||||
|
return ResultadoDoIntervalo(
|
||||||
|
intervalo_em_segundos=round(intervalo, 3),
|
||||||
|
mesmo_segmento=mesmo_segmento,
|
||||||
|
proximidade_temporal=round(proximidade, 4),
|
||||||
|
indicio_de_nova_tentativa=bool(indicio),
|
||||||
|
)
|
||||||
@@ -0,0 +1,112 @@
|
|||||||
|
"""Comparação textual entre falas.
|
||||||
|
|
||||||
|
Responsável pelos algoritmos de similaridade: Jaccard (conjunto de
|
||||||
|
palavras), sequência (ordem das palavras via maior subsequência comum) e
|
||||||
|
similaridade do início/fim da frase. Não decide nada sozinho — apenas
|
||||||
|
produz métricas para o classificador.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import re
|
||||||
|
|
||||||
|
from .modelos_de_retakes import Fala, ResultadoDaComparacao
|
||||||
|
|
||||||
|
|
||||||
|
def _normalizar(texto: str) -> str:
|
||||||
|
"""Minúsculas, sem pontuação e sem espaços duplicados."""
|
||||||
|
sem_pontuacao = re.sub(r"[^\w\s]", " ", texto)
|
||||||
|
return " ".join(sem_pontuacao.lower().split())
|
||||||
|
|
||||||
|
|
||||||
|
def _lcs(a: list[str], b: list[str]) -> int:
|
||||||
|
"""Tamanho da maior subsequência comum preservando a ordem."""
|
||||||
|
anterior = [0] * (len(b) + 1)
|
||||||
|
for palavra_a in a:
|
||||||
|
atual = [0] * (len(b) + 1)
|
||||||
|
for j, palavra_b in enumerate(b):
|
||||||
|
if palavra_a == palavra_b:
|
||||||
|
atual[j + 1] = anterior[j] + 1
|
||||||
|
else:
|
||||||
|
atual[j + 1] = max(atual[j], anterior[j + 1])
|
||||||
|
anterior = atual
|
||||||
|
return anterior[-1]
|
||||||
|
|
||||||
|
|
||||||
|
class ComparadorDeFalas:
|
||||||
|
"""Compara duas falas e calcula as métricas de similaridade textual."""
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def normalizar(texto: str) -> str:
|
||||||
|
return _normalizar(texto)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _palavras_de(fala: Fala) -> list[str]:
|
||||||
|
return fala.texto_normalizado.split() or _normalizar(fala.texto).split()
|
||||||
|
|
||||||
|
def comparar(self, fala_a: Fala, fala_b: Fala) -> ResultadoDaComparacao:
|
||||||
|
palavras_a = self._palavras_de(fala_a)
|
||||||
|
palavras_b = self._palavras_de(fala_b)
|
||||||
|
|
||||||
|
if not palavras_a or not palavras_b:
|
||||||
|
return ResultadoDaComparacao(fala_a.id, fala_b.id)
|
||||||
|
|
||||||
|
# 1. Jaccard — conjunto de palavras (rápido, ótimo candidato).
|
||||||
|
conjunto_a = set(palavras_a)
|
||||||
|
conjunto_b = set(palavras_b)
|
||||||
|
intersecao = len(conjunto_a & conjunto_b)
|
||||||
|
uniao = len(conjunto_a | conjunto_b)
|
||||||
|
jaccard = intersecao / uniao if uniao else 0.0
|
||||||
|
|
||||||
|
# 2. Sequência — ordem das palavras via LCS normalizada.
|
||||||
|
lcs = _lcs(palavras_a, palavras_b)
|
||||||
|
sequencia = lcs / max(len(palavras_a), len(palavras_b))
|
||||||
|
|
||||||
|
# 3. Início e final da frase.
|
||||||
|
inicio = self._similaridade_de_prefijo(palavras_a, palavras_b)
|
||||||
|
final = self._similaridade_de_sufixo(palavras_a, palavras_b)
|
||||||
|
|
||||||
|
# 4. Similaridade final ponderada.
|
||||||
|
fim = 0.45 * sequencia + 0.30 * jaccard + 0.15 * inicio + 0.10 * final
|
||||||
|
return ResultadoDaComparacao(
|
||||||
|
fala_a_id=fala_a.id,
|
||||||
|
fala_b_id=fala_b.id,
|
||||||
|
similaridade_jaccard=round(jaccard, 4),
|
||||||
|
similaridade_de_sequencia=round(sequencia, 4),
|
||||||
|
similaridade_do_inicio=round(inicio, 4),
|
||||||
|
similaridade_do_final=round(final, 4),
|
||||||
|
similaridade_final=round(min(1.0, fim), 4),
|
||||||
|
)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _similaridade_de_prefijo(a: list[str], b: list[str]) -> float:
|
||||||
|
if not a or not b:
|
||||||
|
return 0.0
|
||||||
|
n = 0
|
||||||
|
for x, y in zip(a, b):
|
||||||
|
if x != y:
|
||||||
|
break
|
||||||
|
n += 1
|
||||||
|
return n / max(len(a), len(b))
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _similaridade_de_sufixo(a: list[str], b: list[str]) -> float:
|
||||||
|
if not a or not b:
|
||||||
|
return 0.0
|
||||||
|
n = 0
|
||||||
|
for x, y in zip(reversed(a), reversed(b)):
|
||||||
|
if x != y:
|
||||||
|
break
|
||||||
|
n += 1
|
||||||
|
return n / max(len(a), len(b))
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def prefixo_comum_em_palavras(texto_a: str, texto_b: str) -> int:
|
||||||
|
a = _normalizar(texto_a).split()
|
||||||
|
b = _normalizar(texto_b).split()
|
||||||
|
n = 0
|
||||||
|
for x, y in zip(a, b):
|
||||||
|
if x != y:
|
||||||
|
break
|
||||||
|
n += 1
|
||||||
|
return n
|
||||||
@@ -0,0 +1,113 @@
|
|||||||
|
"""Comparação semântica entre falas (opcional).
|
||||||
|
|
||||||
|
A similaridade textual não cobre casos em que as palavras são diferentes
|
||||||
|
mas a ideia é a mesma. Este módulo define um protocolo para providers de
|
||||||
|
embeddings e uma implementação local (via Hugging Face Transformers), além
|
||||||
|
de um fallback puramente lexical usado quando nenhum provider está
|
||||||
|
disponível.
|
||||||
|
|
||||||
|
O `ComparadorSemantico` nunca decide sozinho que há retake — apenas
|
||||||
|
fornece uma métrica adicional para o classificador.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import math
|
||||||
|
from typing import Protocol
|
||||||
|
|
||||||
|
from .modelos_de_retakes import Fala
|
||||||
|
|
||||||
|
|
||||||
|
class ProviderDeSimilaridadeSemantica(Protocol):
|
||||||
|
"""Contrato para quem calcula similaridade semântica entre dois textos."""
|
||||||
|
|
||||||
|
def similaridade(self, texto_a: str, texto_b: str) -> float: ...
|
||||||
|
|
||||||
|
|
||||||
|
class ComparadorLexicalSemantico:
|
||||||
|
"""Fallback puramente lexical para quando não há embeddings.
|
||||||
|
|
||||||
|
Reconstrói uma "similaridade semântica" a partir de palavras que
|
||||||
|
compartilham a mesma raiz (stemming simples por prefixo comum) e de
|
||||||
|
sinônimos frequentes em pt-PT. Destina-se a permitir executar a análise
|
||||||
|
sem carregar modelos pesados.
|
||||||
|
"""
|
||||||
|
|
||||||
|
_PARES_SINONIMOS = (
|
||||||
|
({"mostrar", "explicar", "demonstrar", "apresentar"},),
|
||||||
|
({"sistema", "programa", "aplicativo", "software"},),
|
||||||
|
({"configurar", "configuracao", "instalar", "ajustar"},),
|
||||||
|
)
|
||||||
|
|
||||||
|
def similaridade(self, texto_a: str, texto_b: str) -> float:
|
||||||
|
palavras_a = {p for p in self._palavras(texto_a)}
|
||||||
|
palavras_b = {p for p in self._palavras(texto_b)}
|
||||||
|
if not palavras_a or not palavras_b:
|
||||||
|
return 0.0
|
||||||
|
|
||||||
|
intersecao = 0.0
|
||||||
|
for palavra_a in palavras_a:
|
||||||
|
for palavra_b in palavras_b:
|
||||||
|
if palavra_a == palavra_b:
|
||||||
|
intersecao += 1.0
|
||||||
|
elif self._mesma_raiz(palavra_a, palavra_b):
|
||||||
|
intersecao += 0.7
|
||||||
|
elif self._mesmo_sinonimo(palavra_a, palavra_b):
|
||||||
|
intersecao += 0.6
|
||||||
|
tam = max(len(palavras_a), len(palavras_b))
|
||||||
|
return round(min(1.0, intersecao / tam), 4)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _palavras(texto: str) -> list[str]:
|
||||||
|
return [p for p in texto.lower().split()]
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _mesma_raiz(a: str, b: str) -> bool:
|
||||||
|
raiz = min(len(a), len(b), 4)
|
||||||
|
return raiz >= 4 and a[:raiz] == b[:raiz]
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _mesmo_sinonimo(a: str, b: str) -> bool:
|
||||||
|
return any(a in grupo and b in grupo for grupo in ComparadorLexicalSemantico._PARES_SINONIMOS)
|
||||||
|
|
||||||
|
|
||||||
|
class ComparadorSemantico:
|
||||||
|
"""Calcula similaridade semântica usando um provider injetável.
|
||||||
|
|
||||||
|
Quando o provider é ``None``, cai no ``ComparadorLexicalSemantico``
|
||||||
|
para não bloquear a análise na ausência de modelos locais.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, provider: ProviderDeSimilaridadeSemantica | None = None) -> None:
|
||||||
|
self.provider = provider or ComparadorLexicalSemantico()
|
||||||
|
|
||||||
|
def comparar(self, fala_a: Fala, fala_b: Fala) -> float:
|
||||||
|
return float(self.provider.similaridade(fala_a.texto, fala_b.texto))
|
||||||
|
|
||||||
|
|
||||||
|
class ProviderDeSimilaridadeHuggingFace:
|
||||||
|
"""Embeds os textos localmente com um modelo de embeddings.
|
||||||
|
|
||||||
|
O modelo é carregado de forma preguiçosa para não custar nada até ser
|
||||||
|
de fato necessário (zona de dúvida do classificador).
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, modelo: str = "sentence-transformers/paraphrase-multilingual-MiniLM-L12-v2") -> None:
|
||||||
|
self.modelo = modelo
|
||||||
|
self._encoder = None
|
||||||
|
|
||||||
|
def similaridade(self, texto_a: str, texto_b: str) -> float:
|
||||||
|
if self._encoder is None:
|
||||||
|
from sentence_transformers import SentenceTransformer
|
||||||
|
self._encoder = SentenceTransformer(self.modelo)
|
||||||
|
embeddings = self._encoder.encode([texto_a, texto_b], normalize_embeddings=True)
|
||||||
|
return float(_cosseno(embeddings[0], embeddings[1]))
|
||||||
|
|
||||||
|
|
||||||
|
def _cosseno(a: list[float], b: list[float]) -> float:
|
||||||
|
produto = sum(x * y for x, y in zip(a, b))
|
||||||
|
norma_a = math.sqrt(sum(x * x for x in a))
|
||||||
|
norma_b = math.sqrt(sum(y * y for y in b))
|
||||||
|
if norma_a == 0 or norma_b == 0:
|
||||||
|
return 0.0
|
||||||
|
return max(0.0, min(1.0, produto / (norma_a * norma_b)))
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
"""Detecção de sinais de reinício/recomeço de fala.
|
||||||
|
|
||||||
|
Analisa padrões de que a pessoa começou novamente uma ideia: repetição do
|
||||||
|
início de uma frase, pausas longas, marcadores explícitos ("não", "pera",
|
||||||
|
"vamos de novo", "desculpa") e repetição de palavras consecutivas.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import re
|
||||||
|
from typing import Iterable
|
||||||
|
|
||||||
|
from .comparador_de_falas import ComparadorDeFalas
|
||||||
|
from .modelos_de_retakes import Fala, SinalDeReinicio
|
||||||
|
|
||||||
|
_MARCADORES = (
|
||||||
|
"não", "pera", "peraí", "pera ai", "espera", "vamos de novo", "de novo",
|
||||||
|
"vamos recomeçar", "recomeçar", "desculpa", "desculpe", "deixa eu ver",
|
||||||
|
"vou repetir", "esquece", "hmm", "hm", "uhm", "tá", "ta",
|
||||||
|
)
|
||||||
|
|
||||||
|
_RE_MARCADOR = re.compile(
|
||||||
|
r"\b(?:" + "|".join(re.escape(m) for m in _MARCADORES) + r")\b",
|
||||||
|
re.IGNORECASE,
|
||||||
|
)
|
||||||
|
|
||||||
|
_PAUSA_LONGA = 1.2 # segundos a partir dos quais a pausa sugere recomeço.
|
||||||
|
|
||||||
|
|
||||||
|
class DetectorDeReinicios:
|
||||||
|
"""Encontra ``SinalDeReinicio`` em cada fala em relação à anterior."""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
comparador: ComparadorDeFalas | None = None,
|
||||||
|
pausa_longa: float = _PAUSA_LONGA,
|
||||||
|
) -> None:
|
||||||
|
self.comparador = comparador or ComparadorDeFalas()
|
||||||
|
self.pausa_longa = pausa_longa
|
||||||
|
|
||||||
|
def detectar(self, falas: Iterable[Fala]) -> list[SinalDeReinicio]:
|
||||||
|
falas = list(falas)
|
||||||
|
sinais: list[SinalDeReinicio] = []
|
||||||
|
for indice, fala in enumerate(falas):
|
||||||
|
sinal = self._analisar(fala, falas[indice - 1] if indice > 0 else None)
|
||||||
|
if sinal:
|
||||||
|
sinais.append(sinal)
|
||||||
|
return sinais
|
||||||
|
|
||||||
|
def _analisar(self, fala: Fala, anterior: Fala | None) -> SinalDeReinicio | None:
|
||||||
|
evidencias: list[str] = []
|
||||||
|
intensidades: list[float] = []
|
||||||
|
palavras = fala.texto_normalizado.split()
|
||||||
|
|
||||||
|
# 1. Marcadores explícitos de recomeço no início.
|
||||||
|
if palavras and _RE_MARCADOR.match(palavras[0]):
|
||||||
|
evidencias.append("marcador explícito de recomeço")
|
||||||
|
intensidades.append(0.9)
|
||||||
|
|
||||||
|
if anterior is None:
|
||||||
|
if intensidades:
|
||||||
|
return SinalDeReinicio(fala.id, "reinicio_explicito",
|
||||||
|
round(max(intensidades), 3), evidencias)
|
||||||
|
return None
|
||||||
|
|
||||||
|
# 2. Pausa longa antes da fala.
|
||||||
|
pausa = fala.inicio - anterior.fim
|
||||||
|
if pausa >= self.pausa_longa:
|
||||||
|
evidencias.append(f"pausa de {pausa:.1f} segundos")
|
||||||
|
intensidades.append(min(1.0, 0.5 + pausa / 8.0))
|
||||||
|
|
||||||
|
# 3. Repetição do início da frase anterior.
|
||||||
|
prefixo = self.comparador.prefixo_comum_em_palavras(anterior.texto, fala.texto)
|
||||||
|
if prefixo >= 3:
|
||||||
|
evidencias.append(f"repetição das primeiras {prefixo} palavras")
|
||||||
|
intensidades.append(min(1.0, 0.4 + prefixo * 0.08))
|
||||||
|
|
||||||
|
if not evidencias:
|
||||||
|
return None
|
||||||
|
|
||||||
|
return SinalDeReinicio(
|
||||||
|
fala_id=fala.id,
|
||||||
|
tipo="repeticao_do_inicio" if any("repetição" in e for e in evidencias) else "reinicio_pos_pausa",
|
||||||
|
intensidade=round(min(1.0, max(intensidades)), 3),
|
||||||
|
evidencias=evidencias,
|
||||||
|
)
|
||||||
@@ -0,0 +1,154 @@
|
|||||||
|
"""Modelos de dados do submódulo de detecção de retakes.
|
||||||
|
|
||||||
|
Mantemos os mesmos princípios de ``scanner/modelos.py``: dataclasses
|
||||||
|
imutáveis (``frozen=True``) com validação em ``__post_init__`` e sem
|
||||||
|
dependência de implementações concretas de providers.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import uuid
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
|
from ..modelos import PalavraDeTranscricao
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class Fala:
|
||||||
|
"""Uma fala normalizada, já posicionada na timeline do vídeo.
|
||||||
|
|
||||||
|
Corresponde a um ``SegmentoDeTranscricao`` convertido pelo adaptador,
|
||||||
|
mas carrega o contexto necessário para a análise temporal e de
|
||||||
|
agrupamento: vídeo, faixa de áudio, número de sequência e o vínculo
|
||||||
|
com o segmento/clipe de origem.
|
||||||
|
"""
|
||||||
|
|
||||||
|
id: str
|
||||||
|
video_id: str
|
||||||
|
faixa_id: str
|
||||||
|
segmento_id: str
|
||||||
|
ordem: int
|
||||||
|
inicio: float
|
||||||
|
fim: float
|
||||||
|
texto: str
|
||||||
|
texto_normalizado: str = ""
|
||||||
|
palavras: tuple[PalavraDeTranscricao, ...] = ()
|
||||||
|
falante: str | None = None
|
||||||
|
|
||||||
|
def __post_init__(self) -> None:
|
||||||
|
if self.inicio < 0 or self.fim < self.inicio:
|
||||||
|
raise ValueError(f"Intervalo da fala {self.id} inválido.")
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class SinalDeReinicio:
|
||||||
|
"""Indício de que uma fala recomeçou uma ideia já iniciada."""
|
||||||
|
|
||||||
|
fala_id: str
|
||||||
|
tipo: str
|
||||||
|
intensidade: float
|
||||||
|
evidencias: list[str] = field(default_factory=list)
|
||||||
|
|
||||||
|
def __post_init__(self) -> None:
|
||||||
|
if not 0.0 <= self.intensidade <= 1.0:
|
||||||
|
raise ValueError(f"Intensidade do reinício {self.fala_id} fora de [0, 1].")
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class ResultadoDaComparacao:
|
||||||
|
"""Resultado da comparação de duas falas, textual e por sequência."""
|
||||||
|
|
||||||
|
fala_a_id: str
|
||||||
|
fala_b_id: str
|
||||||
|
similaridade_jaccard: float = 0.0
|
||||||
|
similaridade_de_sequencia: float = 0.0
|
||||||
|
similaridade_do_inicio: float = 0.0
|
||||||
|
similaridade_do_final: float = 0.0
|
||||||
|
similaridade_final: float = 0.0
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class ResultadoDoIntervalo:
|
||||||
|
"""Relação temporal entre duas falas."""
|
||||||
|
|
||||||
|
intervalo_em_segundos: float
|
||||||
|
mesmo_segmento: bool = False
|
||||||
|
proximidade_temporal: float = 0.0
|
||||||
|
indicio_de_nova_tentativa: bool = False
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class EvidenciaDeRetake:
|
||||||
|
"""Uma evidência legível que sustenta a classificação de um grupo."""
|
||||||
|
|
||||||
|
tipo: str
|
||||||
|
descricao: str
|
||||||
|
valor: float | None = None
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class TomadaDeRetake:
|
||||||
|
"""Uma tomada (fala) pertencente a um grupo de retakes."""
|
||||||
|
|
||||||
|
ordem: int
|
||||||
|
fala_id: str
|
||||||
|
segmento_id: str
|
||||||
|
inicio: float
|
||||||
|
fim: float
|
||||||
|
texto: str
|
||||||
|
similaridade_com_anterior: float = 0.0
|
||||||
|
confianca: float = 0.0
|
||||||
|
|
||||||
|
|
||||||
|
def gerar_id_do_grupo() -> str:
|
||||||
|
"""Gera um id curto e monótono para um grupo de retakes."""
|
||||||
|
return f"retake_{uuid.uuid4().hex[:6]}"
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class ParAgrupado:
|
||||||
|
"""Um par de falas que o agrupador juntou como candidato a retake."""
|
||||||
|
|
||||||
|
fala_a: Fala
|
||||||
|
fala_b: Fala
|
||||||
|
comparacao: ResultadoDaComparacao
|
||||||
|
intervalo: ResultadoDoIntervalo
|
||||||
|
similaridade_semantica: float = 0.0
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class AgrupamentoDeFalas:
|
||||||
|
"""Um grupo candidato formado pelo agrupador, antes da classificação."""
|
||||||
|
|
||||||
|
fala_ids: tuple[str, ...]
|
||||||
|
pares: tuple[ParAgrupado, ...]
|
||||||
|
sinais_de_reinicio: tuple[SinalDeReinicio, ...] = ()
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class GrupoDeRetake:
|
||||||
|
"""Agrupa as tomadas que representam tentativas da mesma fala."""
|
||||||
|
|
||||||
|
id: str
|
||||||
|
video_id: str
|
||||||
|
faixa_id: str
|
||||||
|
tipo: str
|
||||||
|
confianca: float
|
||||||
|
tomadas: list[TomadaDeRetake] = field(default_factory=list)
|
||||||
|
evidencias: list[EvidenciaDeRetake] = field(default_factory=list)
|
||||||
|
criado_em: str = field(default_factory=lambda: datetime.now(timezone.utc).isoformat())
|
||||||
|
|
||||||
|
def __post_init__(self) -> None:
|
||||||
|
if not 0.0 <= self.confianca <= 1.0:
|
||||||
|
raise ValueError(f"Confiança do grupo {self.id} fora de [0, 1].")
|
||||||
|
|
||||||
|
@property
|
||||||
|
def tomada_principal_id(self) -> str | None:
|
||||||
|
if not self.tomadas:
|
||||||
|
return None
|
||||||
|
return max(self.tomadas, key=lambda item: item.confianca).fala_id
|
||||||
|
|
||||||
|
@property
|
||||||
|
def fala_ids(self) -> list[str]:
|
||||||
|
return [tomada.fala_id for tomada in self.tomadas]
|
||||||
@@ -1,10 +1,11 @@
|
|||||||
from dataclasses import asdict, dataclass
|
from dataclasses import asdict, dataclass
|
||||||
|
import hashlib
|
||||||
import json
|
import json
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any, Callable
|
from typing import Any, Callable
|
||||||
|
|
||||||
from ..integracoes.midia import ExtracaoDeAudio
|
from ..integracoes.midia import ExtracaoDeAudio
|
||||||
from .modelos import SegmentoDeTranscricao
|
from .modelos import PalavraDeTranscricao, SegmentoDeTranscricao
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
@@ -14,6 +15,7 @@ class TranscricaoDoClipe:
|
|||||||
inicio_na_timeline: float
|
inicio_na_timeline: float
|
||||||
fim_na_timeline: float
|
fim_na_timeline: float
|
||||||
segmentos: list[SegmentoDeTranscricao]
|
segmentos: list[SegmentoDeTranscricao]
|
||||||
|
intervalo_na_origem: tuple[float, float] | None = None
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def texto(self) -> str:
|
def texto(self) -> str:
|
||||||
@@ -21,38 +23,167 @@ class TranscricaoDoClipe:
|
|||||||
|
|
||||||
|
|
||||||
class TranscricaoDaTimeline:
|
class TranscricaoDaTimeline:
|
||||||
"""Transcreve somente os intervalos efetivamente usados na timeline."""
|
"""Transcreve cada arquivo uma vez e projeta o resultado nos cortes.
|
||||||
|
|
||||||
|
A primeira versão suporta apenas mapeamento linear em velocidade normal.
|
||||||
|
Os timestamps retornados pelo provider são sempre relativos ao arquivo
|
||||||
|
original; somente a cópia materializada para cada clipe recebe timestamps
|
||||||
|
da timeline.
|
||||||
|
"""
|
||||||
|
|
||||||
def __init__(self, provider: Any, extrator: ExtracaoDeAudio | None = None,
|
def __init__(self, provider: Any, extrator: ExtracaoDeAudio | None = None,
|
||||||
diretoria_de_trabalho: str | Path = ".transcricao") -> None:
|
diretoria_de_trabalho: str | Path = ".transcricao",
|
||||||
|
analisador_de_emocao: Any | None = None,
|
||||||
|
diarizador: Any | None = None) -> None:
|
||||||
self.provider = provider
|
self.provider = provider
|
||||||
self.extrator = extrator or ExtracaoDeAudio()
|
self.extrator = extrator or ExtracaoDeAudio()
|
||||||
self.diretoria_de_trabalho = Path(diretoria_de_trabalho)
|
self.diretoria_de_trabalho = Path(diretoria_de_trabalho)
|
||||||
|
self.analisador_de_emocao = analisador_de_emocao
|
||||||
|
self.diarizador = diarizador
|
||||||
|
|
||||||
def executar(self, timeline: Any) -> list[TranscricaoDoClipe]:
|
def executar(self, timeline: Any) -> list[TranscricaoDoClipe]:
|
||||||
resultados: list[TranscricaoDoClipe] = []
|
clipes: list[Any] = []
|
||||||
for faixa in timeline.faixas:
|
for faixa in timeline.faixas:
|
||||||
for clipe in faixa.clipes:
|
for clipe in faixa.clipes:
|
||||||
if not clipe.arquivo or clipe.offline:
|
if not clipe.arquivo or clipe.offline:
|
||||||
continue
|
continue
|
||||||
|
clipes.append(clipe)
|
||||||
|
|
||||||
|
transcricoes: dict[str, list[SegmentoDeTranscricao]] = {}
|
||||||
|
for clipe in clipes:
|
||||||
|
chave = self._chave_do_arquivo(clipe.arquivo)
|
||||||
|
if chave not in transcricoes:
|
||||||
|
transcricoes[chave] = self._transcrever_arquivo(clipe.arquivo, chave)
|
||||||
|
|
||||||
|
resultados: list[TranscricaoDoClipe] = []
|
||||||
|
vistos: set[tuple[str, float, float, float, float]] = set()
|
||||||
|
for clipe in clipes:
|
||||||
intervalo = clipe.intervalo_na_timeline
|
intervalo = clipe.intervalo_na_timeline
|
||||||
pasta = self.diretoria_de_trabalho / clipe.identificador
|
|
||||||
origem = clipe.intervalo_na_origem
|
origem = clipe.intervalo_na_origem
|
||||||
partes = self.extrator.extrair_intervalo(
|
inicio_origem = origem.inicio if origem else 0.0
|
||||||
clipe.arquivo, pasta, origem.inicio if origem else 0.0,
|
fim_origem = origem.fim if origem else float("inf")
|
||||||
origem.fim if origem else None,
|
chave = (self._chave_do_arquivo(clipe.arquivo), inicio_origem, fim_origem,
|
||||||
|
intervalo.inicio, intervalo.fim)
|
||||||
|
# Vídeo e áudio vinculados podem representar o mesmo corte.
|
||||||
|
if chave in vistos:
|
||||||
|
continue
|
||||||
|
vistos.add(chave)
|
||||||
|
segmentos = []
|
||||||
|
for segmento in transcricoes[chave[0]]:
|
||||||
|
fim = min(segmento.fim, fim_origem)
|
||||||
|
inicio = max(segmento.inicio, inicio_origem)
|
||||||
|
if inicio < fim:
|
||||||
|
palavras = tuple(
|
||||||
|
PalavraDeTranscricao(
|
||||||
|
palavra.texto,
|
||||||
|
intervalo.inicio + max(palavra.inicio, inicio_origem) - inicio_origem,
|
||||||
|
intervalo.inicio + min(palavra.fim, fim_origem) - inicio_origem,
|
||||||
|
palavra.confianca,
|
||||||
|
palavra.falante,
|
||||||
)
|
)
|
||||||
|
for palavra in segmento.palavras
|
||||||
|
if palavra.inicio < fim_origem and palavra.fim > inicio_origem
|
||||||
|
)
|
||||||
|
segmentos.append(SegmentoDeTranscricao(
|
||||||
|
intervalo.inicio + inicio - inicio_origem,
|
||||||
|
intervalo.inicio + fim - inicio_origem,
|
||||||
|
segmento.texto, segmento.confianca, palavras,
|
||||||
|
segmento.emocao, segmento.confianca_emocao,
|
||||||
|
segmento.caracteristicas_acusticas, segmento.falante,
|
||||||
|
segmento.voz_aparente, segmento.confianca_voz))
|
||||||
|
resultados.append(TranscricaoDoClipe(clipe.identificador, clipe.arquivo,
|
||||||
|
intervalo.inicio, intervalo.fim, segmentos,
|
||||||
|
(inicio_origem, fim_origem) if origem else None))
|
||||||
|
return resultados
|
||||||
|
|
||||||
|
def _chave_do_arquivo(self, arquivo: str) -> str:
|
||||||
|
caminho = Path(arquivo).expanduser().resolve()
|
||||||
|
digest = hashlib.sha256()
|
||||||
|
with caminho.open("rb") as conteudo:
|
||||||
|
for bloco in iter(lambda: conteudo.read(1024 * 1024), b""):
|
||||||
|
digest.update(bloco)
|
||||||
|
return digest.hexdigest()
|
||||||
|
|
||||||
|
def _transcrever_arquivo(self, arquivo: str, chave: str) -> list[SegmentoDeTranscricao]:
|
||||||
|
pasta = self.diretoria_de_trabalho / "arquivos" / chave
|
||||||
|
nome_arquivo = Path(arquivo).stem
|
||||||
|
modelo = self._nome_do_modelo()
|
||||||
|
prefixo = f"transcricao-{self._nome_seguro(nome_arquivo)}-{self._nome_seguro(modelo)}"
|
||||||
|
cache = pasta / f"{prefixo}.json"
|
||||||
|
if cache.is_file():
|
||||||
|
dados = json.loads(cache.read_text(encoding="utf-8"))
|
||||||
|
if all("palavras" in item and
|
||||||
|
(self.analisador_de_emocao is None or
|
||||||
|
("emocao" in item and "voz_aparente" in item))
|
||||||
|
for item in dados):
|
||||||
|
return [self._segmento_do_json(item) for item in dados]
|
||||||
|
partes = self.extrator.extrair(arquivo, pasta / "audio")
|
||||||
segmentos: list[SegmentoDeTranscricao] = []
|
segmentos: list[SegmentoDeTranscricao] = []
|
||||||
for parte in partes:
|
for parte in partes:
|
||||||
|
falas = self._diarizar(parte.caminho)
|
||||||
for segmento in self.provider.transcrever(type("Audio", (), {"arquivo": str(parte.caminho)})()):
|
for segmento in self.provider.transcrever(type("Audio", (), {"arquivo": str(parte.caminho)})()):
|
||||||
base_origem = origem.inicio if origem else 0.0
|
analise = self._analisar_emocao(parte.caminho, segmento.inicio, segmento.fim)
|
||||||
inicio = max(intervalo.inicio, intervalo.inicio + (parte.inicio - base_origem) + segmento.inicio)
|
falante = self._falante_em(falas, segmento.inicio, segmento.fim)
|
||||||
fim = min(intervalo.fim, intervalo.inicio + (parte.inicio - base_origem) + segmento.fim)
|
segmentos.append(SegmentoDeTranscricao(parte.inicio + segmento.inicio,
|
||||||
if inicio < intervalo.fim and fim > inicio:
|
parte.inicio + segmento.fim, segmento.texto, segmento.confianca,
|
||||||
segmentos.append(SegmentoDeTranscricao(inicio, fim, segmento.texto, segmento.confianca))
|
tuple(PalavraDeTranscricao(p.texto, parte.inicio + p.inicio,
|
||||||
resultados.append(TranscricaoDoClipe(clipe.identificador, clipe.arquivo,
|
parte.inicio + p.fim, p.confianca,
|
||||||
intervalo.inicio, intervalo.fim, segmentos))
|
self._falante_em(falas, p.inicio, p.fim))
|
||||||
return resultados
|
for p in segmento.palavras),
|
||||||
|
analise.get("emocao"), analise.get("confianca"),
|
||||||
|
dict(analise.get("caracteristicas_acusticas", {})), falante,
|
||||||
|
analise.get("voz_aparente"), analise.get("confianca_voz")))
|
||||||
|
pasta.mkdir(parents=True, exist_ok=True)
|
||||||
|
cache.write_text(json.dumps([asdict(item) for item in segmentos], ensure_ascii=False, indent=2), encoding="utf-8")
|
||||||
|
(pasta / f"{prefixo}.txt").write_text(
|
||||||
|
" ".join(item.texto for item in segmentos if item.texto).strip() + "\n",
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
return segmentos
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _segmento_do_json(item: dict[str, Any]) -> SegmentoDeTranscricao:
|
||||||
|
palavras = tuple(PalavraDeTranscricao(str(p["texto"]), float(p["inicio"]),
|
||||||
|
float(p["fim"]), p.get("confianca"),
|
||||||
|
p.get("falante"))
|
||||||
|
for p in item.get("palavras", []))
|
||||||
|
return SegmentoDeTranscricao(float(item["inicio"]), float(item["fim"]),
|
||||||
|
str(item["texto"]), item.get("confianca"), palavras,
|
||||||
|
item.get("emocao"), item.get("confianca_emocao"),
|
||||||
|
dict(item.get("caracteristicas_acusticas", {})), item.get("falante"),
|
||||||
|
item.get("voz_aparente"), item.get("confianca_voz"))
|
||||||
|
|
||||||
|
def _analisar_emocao(self, arquivo: Path, inicio: float, fim: float) -> dict[str, Any]:
|
||||||
|
if self.analisador_de_emocao is None:
|
||||||
|
return {}
|
||||||
|
return dict(self.analisador_de_emocao.analisar(arquivo, inicio, fim))
|
||||||
|
|
||||||
|
def _diarizar(self, arquivo: Path) -> list[tuple[float, float, str]]:
|
||||||
|
if self.diarizador is None:
|
||||||
|
return []
|
||||||
|
return list(self.diarizador.analisar(arquivo))
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _falante_em(falas: list[tuple[float, float, str]], inicio: float, fim: float) -> str | None:
|
||||||
|
sobreposicoes = [(min(fim, saida) - max(inicio, entrada), falante)
|
||||||
|
for entrada, saida, falante in falas
|
||||||
|
if entrada < fim and saida > inicio]
|
||||||
|
return max(sobreposicoes, default=(0.0, None))[1]
|
||||||
|
|
||||||
|
def _nome_do_modelo(self) -> str:
|
||||||
|
"""Obtém o nome público do modelo sem acoplar o scanner ao provider."""
|
||||||
|
modelo = getattr(self.provider, "nome_do_modelo", None)
|
||||||
|
if modelo:
|
||||||
|
return str(modelo)
|
||||||
|
modelo = getattr(self.provider, "modelo", None)
|
||||||
|
if modelo:
|
||||||
|
return Path(str(modelo)).name
|
||||||
|
return self.provider.__class__.__name__.lower()
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _nome_seguro(valor: str) -> str:
|
||||||
|
return "".join(caractere if caractere.isalnum() or caractere in "._-" else "_"
|
||||||
|
for caractere in valor).strip("._") or "arquivo"
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def gerar_relatorio(resultados: list[TranscricaoDoClipe], destino: str | Path) -> Path:
|
def gerar_relatorio(resultados: list[TranscricaoDoClipe], destino: str | Path) -> Path:
|
||||||
|
|||||||
@@ -0,0 +1,184 @@
|
|||||||
|
from collections.abc import Callable
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
|
||||||
|
from ..dominio import Clipe
|
||||||
|
from ..integracoes.visual.contratos import (AnalisadorDeFrame, AnalisadorDeSequenciaDeQuadros,
|
||||||
|
DetectorDeIntervalosDeCena, ExtratorDeQuadros)
|
||||||
|
from ..integracoes.visual.modelos import QuadroDeVideo
|
||||||
|
from .modelos import Cena, CenaVisual, EvidenciaVisual
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class ResultadoVisualDoClipe:
|
||||||
|
identificador_do_clipe: str
|
||||||
|
evidencias: list[EvidenciaVisual] = field(default_factory=list)
|
||||||
|
cenas: list[Cena] = field(default_factory=list)
|
||||||
|
cenas_visuais: list[CenaVisual] = field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
|
class DetectorDeCenas:
|
||||||
|
"""Módulo profundo que orquestra frames, analisadores e cenas de um clipe.
|
||||||
|
|
||||||
|
A interface não expõe OpenCV, ONNX, MediaPipe, PySceneDetect ou Vision.
|
||||||
|
Cada adapter pode ser trocado sem alterar chamadores nem resultados do domínio.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, extrator: ExtratorDeQuadros,
|
||||||
|
analisadores_de_frame: list[AnalisadorDeFrame] | None = None,
|
||||||
|
detectores_de_intervalo: list[DetectorDeIntervalosDeCena] | None = None,
|
||||||
|
analisadores_de_sequencia: list[AnalisadorDeSequenciaDeQuadros] | None = None,
|
||||||
|
ao_progresso: Callable[[int, int], None] | None = None) -> None:
|
||||||
|
self.extrator = extrator
|
||||||
|
self.analisadores_de_frame = analisadores_de_frame or []
|
||||||
|
self.analisadores_de_sequencia = analisadores_de_sequencia or []
|
||||||
|
self.detectores_de_intervalo = detectores_de_intervalo or []
|
||||||
|
self.ao_progresso = ao_progresso
|
||||||
|
|
||||||
|
def detectar(self, clipe: Clipe) -> ResultadoVisualDoClipe:
|
||||||
|
quadros = self.extrator.extrair(clipe)
|
||||||
|
total = len(quadros) * len(self.analisadores_de_frame) + len(self.analisadores_de_sequencia)
|
||||||
|
progresso = [0]
|
||||||
|
evidencias = self._analisar_quadros(quadros, progresso, total) + self._analisar_sequencia(quadros, progresso, total)
|
||||||
|
cenas = self._detectar_intervalos(clipe, quadros)
|
||||||
|
cenas_visuais = [CenaVisual(
|
||||||
|
clipe.identificador, cena.inicio, cena.fim,
|
||||||
|
tuple(item for item in evidencias if item.inicio <= cena.fim and item.fim >= cena.inicio),
|
||||||
|
cena.referencias,
|
||||||
|
) for cena in cenas]
|
||||||
|
return ResultadoVisualDoClipe(clipe.identificador, evidencias, cenas, cenas_visuais)
|
||||||
|
|
||||||
|
def _analisar_quadros(self, quadros: list[QuadroDeVideo], progresso: list[int] | None = None,
|
||||||
|
total: int = 0) -> list[EvidenciaVisual]:
|
||||||
|
resultado: list[EvidenciaVisual] = []
|
||||||
|
for quadro in quadros:
|
||||||
|
for analisador in self.analisadores_de_frame:
|
||||||
|
resultado.extend(analisador.analisar(quadro))
|
||||||
|
self._avancar_progresso(progresso, total)
|
||||||
|
return resultado
|
||||||
|
|
||||||
|
def _analisar_sequencia(self, quadros: list[QuadroDeVideo], progresso: list[int] | None = None,
|
||||||
|
total: int = 0) -> list[EvidenciaVisual]:
|
||||||
|
resultado: list[EvidenciaVisual] = []
|
||||||
|
for analisador in self.analisadores_de_sequencia:
|
||||||
|
resultado.extend(analisador.analisar(quadros))
|
||||||
|
self._avancar_progresso(progresso, total)
|
||||||
|
return resultado
|
||||||
|
|
||||||
|
def _avancar_progresso(self, progresso: list[int] | None, total: int) -> None:
|
||||||
|
if progresso is None or total <= 0:
|
||||||
|
return
|
||||||
|
progresso[0] += 1
|
||||||
|
if self.ao_progresso:
|
||||||
|
self.ao_progresso(progresso[0], total)
|
||||||
|
|
||||||
|
def _detectar_intervalos(self, clipe: Clipe, quadros: list[QuadroDeVideo]) -> list[Cena]:
|
||||||
|
cenas: list[Cena] = []
|
||||||
|
for detector in self.detectores_de_intervalo:
|
||||||
|
cenas.extend(detector.detectar(clipe, quadros))
|
||||||
|
cenas = self._consolidar_cenas(cenas)
|
||||||
|
if not cenas and quadros:
|
||||||
|
cenas = [Cena(quadros[0].timestamp, quadros[-1].timestamp)]
|
||||||
|
return cenas
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _consolidar_cenas(cenas: list[Cena]) -> list[Cena]:
|
||||||
|
resultado: list[Cena] = []
|
||||||
|
for cena in sorted(cenas, key=lambda item: (item.inicio, item.fim)):
|
||||||
|
if resultado and cena.inicio <= resultado[-1].fim:
|
||||||
|
anterior = resultado[-1]
|
||||||
|
resultado[-1] = Cena(anterior.inicio, max(anterior.fim, cena.fim),
|
||||||
|
anterior.confianca or cena.confianca,
|
||||||
|
anterior.referencias + cena.referencias)
|
||||||
|
else:
|
||||||
|
resultado.append(cena)
|
||||||
|
return resultado
|
||||||
|
|
||||||
|
|
||||||
|
class AnaliseVisualDaTimeline:
|
||||||
|
"""Etapa do Scanner que guarda evidências e cenas por clipe no contexto."""
|
||||||
|
|
||||||
|
nome = "analise_visual_local"
|
||||||
|
|
||||||
|
def __init__(self, detector: DetectorDeCenas, continuar_em_falha: bool = True) -> None:
|
||||||
|
self.detector = detector
|
||||||
|
self.continuar_em_falha = continuar_em_falha
|
||||||
|
|
||||||
|
def executar(self, contexto):
|
||||||
|
if contexto.timeline is None:
|
||||||
|
return contexto
|
||||||
|
for faixa in contexto.timeline.faixas:
|
||||||
|
for clipe in faixa.clipes:
|
||||||
|
if clipe.offline or not clipe.arquivo:
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
resultado = self.detector.detectar(clipe)
|
||||||
|
except Exception as exc:
|
||||||
|
if not self.continuar_em_falha:
|
||||||
|
raise
|
||||||
|
contexto.avisos.append(
|
||||||
|
f"analise_visual_indisponivel: clipe {clipe.identificador}: {exc}"
|
||||||
|
)
|
||||||
|
contexto.caracteristicas_visuais[clipe.identificador] = {
|
||||||
|
"evidencias": [], "cenas": [], "cenas_visuais": [], "disponivel": False,
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
contexto.caracteristicas_visuais[clipe.identificador] = {
|
||||||
|
"evidencias": resultado.evidencias,
|
||||||
|
"cenas": resultado.cenas,
|
||||||
|
"cenas_visuais": resultado.cenas_visuais,
|
||||||
|
"disponivel": True,
|
||||||
|
}
|
||||||
|
contexto.cenas.extend(resultado.cenas)
|
||||||
|
contexto.cenas_visuais.extend(resultado.cenas_visuais)
|
||||||
|
contexto.cenas = DetectorDeCenas._consolidar_cenas(contexto.cenas)
|
||||||
|
return contexto
|
||||||
|
|
||||||
|
|
||||||
|
def criar_detector_visual_local(
|
||||||
|
diretorio_de_cache: str | None = ".frames",
|
||||||
|
intervalo_em_segundos: float = 1.0,
|
||||||
|
) -> DetectorDeCenas:
|
||||||
|
"""Monta o detector local padrão com uma interface curta para o Scanner."""
|
||||||
|
from ..integracoes.visual import (
|
||||||
|
AnalisadorDeComposicaoOpenCV,
|
||||||
|
AnalisadorDeContinuidadeOpenCV,
|
||||||
|
AnalisadorDeQualidadeOpenCV,
|
||||||
|
ExtratorDeQuadrosOpenCV,
|
||||||
|
)
|
||||||
|
|
||||||
|
return DetectorDeCenas(
|
||||||
|
ExtratorDeQuadrosOpenCV(
|
||||||
|
intervalo_em_segundos=intervalo_em_segundos,
|
||||||
|
diretorio_de_cache=diretorio_de_cache,
|
||||||
|
),
|
||||||
|
analisadores_de_frame=[
|
||||||
|
AnalisadorDeQualidadeOpenCV(),
|
||||||
|
AnalisadorDeComposicaoOpenCV(),
|
||||||
|
],
|
||||||
|
analisadores_de_sequencia=[AnalisadorDeContinuidadeOpenCV()],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def criar_detector_apple_vision(
|
||||||
|
diretorio_de_cache: str | None = ".frames",
|
||||||
|
intervalo_em_segundos: float = 1.0,
|
||||||
|
configuracao=None,
|
||||||
|
) -> DetectorDeCenas:
|
||||||
|
"""Monta a análise local de cena baseada em Vision e arquivos de origem."""
|
||||||
|
from .configuracao_visual import ConfiguracaoVisualDoScanner
|
||||||
|
from ..integracoes.visual import (AnalisadorAppleVisionNativo,
|
||||||
|
AnalisadorSequenciaAppleVisionNativo,
|
||||||
|
ExtratorDeQuadrosFFmpeg)
|
||||||
|
|
||||||
|
perfil = configuracao or ConfiguracaoVisualDoScanner(intervalo_em_segundos=intervalo_em_segundos)
|
||||||
|
|
||||||
|
return DetectorDeCenas(
|
||||||
|
ExtratorDeQuadrosFFmpeg(intervalo_em_segundos=perfil.intervalo_em_segundos,
|
||||||
|
diretorio_de_cache=diretorio_de_cache or ".frames"),
|
||||||
|
analisadores_de_frame=[AnalisadorAppleVisionNativo(
|
||||||
|
recursos=tuple(sorted(perfil.recursos_vision)),
|
||||||
|
interpretar_com_apple_intelligence=perfil.interpretar_cena,
|
||||||
|
)],
|
||||||
|
analisadores_de_sequencia=[AnalisadorSequenciaAppleVisionNativo()]
|
||||||
|
if perfil.analisar_continuidade else [],
|
||||||
|
)
|
||||||
@@ -0,0 +1,216 @@
|
|||||||
|
import unittest
|
||||||
|
from pathlib import Path
|
||||||
|
import json
|
||||||
|
import tempfile
|
||||||
|
|
||||||
|
from engine.dominio import Clipe, Faixa, IntervaloDeTempo, Timeline
|
||||||
|
from engine.integracoes.visual.contratos import (AnalisadorDeFrame, AnalisadorDeSequenciaDeQuadros,
|
||||||
|
DetectorDeIntervalosDeCena,
|
||||||
|
ExtratorDeQuadros)
|
||||||
|
from engine.integracoes.visual.modelos import QuadroDeVideo
|
||||||
|
from engine.integracoes.visual.extrator_open_cv import ExtratorDeQuadrosOpenCV
|
||||||
|
from engine.integracoes.visual.apple_vision import (AnalisadorAppleVisionNativo,
|
||||||
|
AnalisadorSequenciaAppleVisionNativo)
|
||||||
|
from engine.scanner.coordenacao import ContextoDeAnalise
|
||||||
|
from engine.scanner.modelos import Cena, EvidenciaVisual
|
||||||
|
from engine.scanner.visual import AnaliseVisualDaTimeline, DetectorDeCenas, ResultadoVisualDoClipe
|
||||||
|
from engine.scanner.relatorio_visual import gerar_relatorio_visual, gerar_resumo_visual_markdown
|
||||||
|
from engine.scanner.configuracao_visual import ConfiguracaoVisualDoScanner
|
||||||
|
|
||||||
|
|
||||||
|
class ExtratorSimulado(ExtratorDeQuadros):
|
||||||
|
def extrair(self, clipe):
|
||||||
|
return [
|
||||||
|
QuadroDeVideo(0.0, 0, 1920, 1080, "frame-0"),
|
||||||
|
QuadroDeVideo(1.0, 1, 1920, 1080, "frame-1"),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
class AnalisadorSimulado(AnalisadorDeFrame):
|
||||||
|
nome = "simulado"
|
||||||
|
|
||||||
|
def analisar(self, quadro):
|
||||||
|
return [EvidenciaVisual("qualidade", quadro.timestamp, quadro.timestamp,
|
||||||
|
{"nitidez_laplaciana": 42.0}, provider=self.nome)]
|
||||||
|
|
||||||
|
|
||||||
|
class DetectorSimulado(DetectorDeIntervalosDeCena):
|
||||||
|
nome = "simulado"
|
||||||
|
|
||||||
|
def detectar(self, clipe, quadros):
|
||||||
|
return [Cena(0.0, 1.0, referencias=(0.0,)), Cena(1.0, 2.0, referencias=(1.0,))]
|
||||||
|
|
||||||
|
|
||||||
|
class AnalisadorTemporalSimulado(AnalisadorDeSequenciaDeQuadros):
|
||||||
|
nome = "temporal_simulado"
|
||||||
|
|
||||||
|
def analisar(self, quadros):
|
||||||
|
return [EvidenciaVisual("continuidade", quadros[0].timestamp, quadros[-1].timestamp,
|
||||||
|
{"possivel_descontinuidade": False}, provider=self.nome)]
|
||||||
|
|
||||||
|
|
||||||
|
class ExecutorAppleSimulado:
|
||||||
|
def executar(self, carga, timeout):
|
||||||
|
self.carga, self.timeout = carga, timeout
|
||||||
|
return {"evidencias": [{"tipo": "pessoa", "valor": {"ocupacao_do_quadro": 0.3},
|
||||||
|
"confianca": 0.9, "modelo": "VNDetectHumanRectanglesRequest"}],
|
||||||
|
"avisos": ["ocr: indisponível"]}
|
||||||
|
|
||||||
|
|
||||||
|
class ExecutorAppleSequenciaSimulado:
|
||||||
|
def executar(self, carga, timeout):
|
||||||
|
self.carga, self.timeout = carga, timeout
|
||||||
|
return {"evidencias": [{"tipo": "similaridade_visual", "valor": {
|
||||||
|
"frame_inicial": 0, "frame_final": 1, "distancia_feature_print": 0.2,
|
||||||
|
"possivel_mudanca_de_cena": False,
|
||||||
|
}, "modelo": "VNGenerateImageFeaturePrintRequest"}], "avisos": []}
|
||||||
|
|
||||||
|
|
||||||
|
class TesteAnaliseVisualLocal(unittest.TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self.clipe = Clipe("clip-1", "camera", IntervaloDeTempo(0, 2), arquivo="/video.mp4")
|
||||||
|
|
||||||
|
def test_detector_envia_cada_frame_aos_analisadores_e_consolida_cenas(self):
|
||||||
|
detector = DetectorDeCenas(ExtratorSimulado(), [AnalisadorSimulado()], [DetectorSimulado()])
|
||||||
|
|
||||||
|
resultado = detector.detectar(self.clipe)
|
||||||
|
|
||||||
|
self.assertEqual(resultado.identificador_do_clipe, "clip-1")
|
||||||
|
self.assertEqual([item.inicio for item in resultado.evidencias], [0.0, 1.0])
|
||||||
|
self.assertEqual(len(resultado.cenas), 1)
|
||||||
|
self.assertEqual((resultado.cenas[0].inicio, resultado.cenas[0].fim), (0.0, 2.0))
|
||||||
|
|
||||||
|
def test_etapa_da_timeline_guarda_evidencias_por_clipe(self):
|
||||||
|
detector = DetectorDeCenas(ExtratorSimulado(), [AnalisadorSimulado()], [DetectorSimulado()])
|
||||||
|
timeline = Timeline("timeline-1", "Principal", faixas=[
|
||||||
|
Faixa("video-1", "V1", "video", 0, [self.clipe]),
|
||||||
|
])
|
||||||
|
contexto = ContextoDeAnalise(timeline=timeline)
|
||||||
|
|
||||||
|
AnaliseVisualDaTimeline(detector).executar(contexto)
|
||||||
|
|
||||||
|
self.assertEqual(len(contexto.caracteristicas_visuais["clip-1"]["evidencias"]), 2)
|
||||||
|
self.assertEqual(len(contexto.cenas), 1)
|
||||||
|
|
||||||
|
def test_detector_executa_analisadores_de_sequencia_uma_vez_por_clipe(self):
|
||||||
|
detector = DetectorDeCenas(ExtratorSimulado(), analisadores_de_sequencia=[AnalisadorTemporalSimulado()])
|
||||||
|
|
||||||
|
resultado = detector.detectar(self.clipe)
|
||||||
|
|
||||||
|
self.assertEqual(len(resultado.evidencias), 1)
|
||||||
|
self.assertEqual(resultado.evidencias[0].tipo, "continuidade")
|
||||||
|
|
||||||
|
def test_fluxo_produz_cena_visual_com_observacoes_e_continuidade(self):
|
||||||
|
detector = DetectorDeCenas(
|
||||||
|
ExtratorSimulado(), [AnalisadorSimulado()],
|
||||||
|
analisadores_de_sequencia=[AnalisadorTemporalSimulado()],
|
||||||
|
)
|
||||||
|
contexto = ContextoDeAnalise(timeline=Timeline(
|
||||||
|
"timeline-1", "Principal", faixas=[Faixa("video-1", "V1", "video", 0, [self.clipe])]
|
||||||
|
))
|
||||||
|
|
||||||
|
AnaliseVisualDaTimeline(detector).executar(contexto)
|
||||||
|
|
||||||
|
cena = contexto.cenas_visuais[0]
|
||||||
|
self.assertEqual(cena.identificador_do_clipe, "clip-1")
|
||||||
|
self.assertEqual([item.tipo for item in cena.observacoes], ["qualidade", "qualidade", "continuidade"])
|
||||||
|
self.assertTrue(contexto.caracteristicas_visuais["clip-1"]["disponivel"])
|
||||||
|
|
||||||
|
def test_falha_de_adapter_e_registrada_sem_interromper_outro_clipe(self):
|
||||||
|
class ExtratorQueFalha(ExtratorDeQuadros):
|
||||||
|
def extrair(self, clipe):
|
||||||
|
if clipe.identificador == "clip-1":
|
||||||
|
raise RuntimeError("OpenCV não instalado")
|
||||||
|
return [QuadroDeVideo(0.0, 0, 1920, 1080, "frame")]
|
||||||
|
|
||||||
|
segundo = Clipe("clip-2", "camera-2", IntervaloDeTempo(2, 3), arquivo="/video-2.mp4")
|
||||||
|
timeline = Timeline("timeline-1", "Principal", faixas=[
|
||||||
|
Faixa("video-1", "V1", "video", 0, [self.clipe, segundo]),
|
||||||
|
])
|
||||||
|
contexto = ContextoDeAnalise(timeline=timeline)
|
||||||
|
detector = DetectorDeCenas(ExtratorQueFalha(), [AnalisadorSimulado()])
|
||||||
|
|
||||||
|
AnaliseVisualDaTimeline(detector).executar(contexto)
|
||||||
|
|
||||||
|
self.assertFalse(contexto.caracteristicas_visuais["clip-1"]["disponivel"])
|
||||||
|
self.assertTrue(contexto.caracteristicas_visuais["clip-2"]["disponivel"])
|
||||||
|
self.assertEqual(len(contexto.avisos), 1)
|
||||||
|
|
||||||
|
def test_adapter_apple_converte_fatos_e_preserva_avisos_por_recurso(self):
|
||||||
|
executor = ExecutorAppleSimulado()
|
||||||
|
quadro = QuadroDeVideo(0.0, 0, 1920, 1080, "frame", Path("/frame.jpg"))
|
||||||
|
|
||||||
|
evidencias = AnalisadorAppleVisionNativo(executor=executor).analisar(quadro)
|
||||||
|
|
||||||
|
self.assertEqual(evidencias[0].tipo, "pessoa")
|
||||||
|
self.assertEqual(evidencias[0].modelo, "VNDetectHumanRectanglesRequest")
|
||||||
|
self.assertEqual(evidencias[1].tipo, "diagnostico_apple_vision")
|
||||||
|
self.assertTrue(executor.carga["resumir"])
|
||||||
|
|
||||||
|
def test_adapter_apple_de_sequencia_converte_indices_em_intervalos(self):
|
||||||
|
executor = ExecutorAppleSequenciaSimulado()
|
||||||
|
quadros = [QuadroDeVideo(2.0, 0, 1920, 1080, "frame", Path("/frame-0.jpg")),
|
||||||
|
QuadroDeVideo(3.0, 1, 1920, 1080, "frame", Path("/frame-1.jpg"))]
|
||||||
|
|
||||||
|
evidencias = AnalisadorSequenciaAppleVisionNativo(executor=executor).analisar(quadros)
|
||||||
|
|
||||||
|
self.assertEqual(evidencias[0].tipo, "similaridade_visual")
|
||||||
|
self.assertEqual((evidencias[0].inicio, evidencias[0].fim), (2.0, 3.0))
|
||||||
|
self.assertEqual(evidencias[0].valor["distancia_feature_print"], 0.2)
|
||||||
|
self.assertEqual(executor.carga["frames"], ["/frame-0.jpg", "/frame-1.jpg"])
|
||||||
|
|
||||||
|
def test_evidencia_rejeita_intervalo_invalido(self):
|
||||||
|
with self.assertRaises(ValueError):
|
||||||
|
EvidenciaVisual("objeto", 2.0, 1.0, {})
|
||||||
|
|
||||||
|
def test_relatorio_visual_separa_frames_de_evidencias_temporais(self):
|
||||||
|
resultado = ResultadoVisualDoClipe("clip-1", [
|
||||||
|
EvidenciaVisual("categoria", 2.0, 2.0, {"identificador": "drink"}),
|
||||||
|
EvidenciaVisual("similaridade_visual", 2.0, 3.0, {"distancia": 0.2}),
|
||||||
|
])
|
||||||
|
with tempfile.TemporaryDirectory() as pasta:
|
||||||
|
caminho = gerar_relatorio_visual(self.clipe, resultado, Path(pasta) / "visual.json", 1.0)
|
||||||
|
dados = json.loads(caminho.read_text(encoding="utf-8"))
|
||||||
|
|
||||||
|
self.assertEqual(dados["clipe"]["identificador"], "clip-1")
|
||||||
|
self.assertEqual(dados["observacoes_por_frame"][0]["timestamp_na_origem"], 2.0)
|
||||||
|
self.assertEqual(dados["observacoes_por_frame"][0]["evidencias"][0]["tipo"], "categoria")
|
||||||
|
self.assertEqual(dados["evidencias_temporais"][0]["tipo"], "similaridade_visual")
|
||||||
|
|
||||||
|
def test_resumo_visual_markdown_lista_frames_e_transicoes(self):
|
||||||
|
resultado = ResultadoVisualDoClipe("clip-1", [
|
||||||
|
EvidenciaVisual("categoria", 2.0, 2.0, {"identificador": "drink"}),
|
||||||
|
EvidenciaVisual("similaridade_visual", 2.0, 3.0, {"distancia": 0.2}),
|
||||||
|
])
|
||||||
|
with tempfile.TemporaryDirectory() as pasta:
|
||||||
|
caminho = gerar_resumo_visual_markdown(self.clipe, resultado, Path(pasta) / "visual.md", 1.0)
|
||||||
|
conteudo = caminho.read_text(encoding="utf-8")
|
||||||
|
|
||||||
|
self.assertIn("Origem 2.000s", conteudo)
|
||||||
|
self.assertIn("similaridade_visual", conteudo)
|
||||||
|
|
||||||
|
def test_configuracao_visual_valida_o_perfil_do_painel(self):
|
||||||
|
perfil = ConfiguracaoVisualDoScanner.de_dict({
|
||||||
|
"intervalo_em_segundos": 5,
|
||||||
|
"faixas_de_video": [2, 0, 2],
|
||||||
|
"recursos_vision": ["faces", "pessoas"],
|
||||||
|
"analisar_continuidade": False,
|
||||||
|
})
|
||||||
|
|
||||||
|
self.assertEqual(perfil.faixas_de_video, (0, 2))
|
||||||
|
self.assertEqual(perfil.para_dict()["recursos_vision"], ["faces", "pessoas"])
|
||||||
|
self.assertFalse(perfil.analisar_continuidade)
|
||||||
|
|
||||||
|
def test_extrator_respeita_intervalo_na_origem_e_limite_de_frames(self):
|
||||||
|
clipe = Clipe("clip-1", "camera", IntervaloDeTempo(0, 2),
|
||||||
|
intervalo_na_origem=IntervaloDeTempo(2.3, 5.0), arquivo="/video.mp4")
|
||||||
|
extrator = ExtratorDeQuadrosOpenCV(intervalo_em_segundos=1.0, maximo_de_quadros=1,
|
||||||
|
cv2_module=object())
|
||||||
|
|
||||||
|
inicio, fim = extrator._intervalo_de_origem(clipe, 10.0)
|
||||||
|
|
||||||
|
self.assertEqual(extrator._timestamps(inicio, fim), [2.3])
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
import json
|
||||||
|
import tempfile
|
||||||
|
import unittest
|
||||||
|
from pathlib import Path
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
from engine.dominio import Clipe, Faixa, IntervaloDeTempo, Timeline
|
||||||
|
from engine.scanner.coordenacao import ContextoDeAnalise
|
||||||
|
from engine.scanner.descoberta import ArquivoDescoberto, DescobertaDeArquivos
|
||||||
|
from engine.scanner.metadados import ExtracaoDeMetadados
|
||||||
|
from engine.integracoes.midia.extracao_de_metadados import ExtracaoDeMetadados as Adapter
|
||||||
|
|
||||||
|
|
||||||
|
class TesteArquivosEMetadados(unittest.TestCase):
|
||||||
|
def _contexto(self, *arquivos: str | None) -> ContextoDeAnalise:
|
||||||
|
clipes = [Clipe(str(i), f"clipe-{i}", IntervaloDeTempo(0, 1), arquivo=arquivo)
|
||||||
|
for i, arquivo in enumerate(arquivos)]
|
||||||
|
timeline = Timeline("timeline", "Principal", faixas=[Faixa("v1", "V1", "video", 0, clipes)])
|
||||||
|
return ContextoDeAnalise(timeline=timeline)
|
||||||
|
|
||||||
|
def test_descoberta_normaliza_caminho_e_marca_offline(self):
|
||||||
|
with tempfile.TemporaryDirectory() as pasta:
|
||||||
|
arquivo = Path(pasta) / "camera.mp4"
|
||||||
|
arquivo.touch()
|
||||||
|
contexto = self._contexto(str(arquivo), str(Path(pasta) / "ausente.mp4"), None)
|
||||||
|
|
||||||
|
DescobertaDeArquivos().executar(contexto)
|
||||||
|
|
||||||
|
self.assertEqual(contexto.timeline.faixas[0].clipes[0].arquivo, str(arquivo.resolve()))
|
||||||
|
self.assertFalse(contexto.timeline.faixas[0].clipes[0].offline)
|
||||||
|
self.assertTrue(contexto.timeline.faixas[0].clipes[1].offline)
|
||||||
|
self.assertTrue(contexto.timeline.faixas[0].clipes[2].offline)
|
||||||
|
self.assertEqual(len(contexto.erros), 2)
|
||||||
|
|
||||||
|
def test_descoberta_identifica_arquivo_duplicado(self):
|
||||||
|
with tempfile.TemporaryDirectory() as pasta:
|
||||||
|
arquivo = Path(pasta) / "camera.mov"
|
||||||
|
arquivo.touch()
|
||||||
|
contexto = self._contexto(str(arquivo), str(arquivo))
|
||||||
|
|
||||||
|
DescobertaDeArquivos().executar(contexto)
|
||||||
|
|
||||||
|
self.assertEqual(len(contexto.avisos), 1)
|
||||||
|
|
||||||
|
def test_extracao_converte_ffprobe_e_usa_cache(self):
|
||||||
|
dados = {"format": {"format_name": "mov,mp4,m4a", "duration": "12.5", "size": "900"},
|
||||||
|
"streams": [{"codec_type": "video", "codec_name": "h264", "width": 1920,
|
||||||
|
"height": 1080, "avg_frame_rate": "30000/1001"},
|
||||||
|
{"codec_type": "audio", "codec_name": "aac", "channels": 2,
|
||||||
|
"sample_rate": "48000"}]}
|
||||||
|
with tempfile.TemporaryDirectory() as pasta:
|
||||||
|
arquivo = Path(pasta) / "camera.mp4"
|
||||||
|
arquivo.touch()
|
||||||
|
adapter = Adapter(ffprobe="ffprobe-simulado")
|
||||||
|
processo = type("Processo", (), {"stdout": json.dumps(dados)})()
|
||||||
|
with patch("engine.integracoes.midia.extracao_de_metadados.subprocess.run", return_value=processo) as run:
|
||||||
|
primeiro = adapter.extrair(arquivo)
|
||||||
|
segundo = adapter.extrair(arquivo)
|
||||||
|
|
||||||
|
self.assertEqual(primeiro.codec_de_video, "h264")
|
||||||
|
self.assertEqual(primeiro.codec_de_audio, "aac")
|
||||||
|
self.assertEqual((primeiro.largura, primeiro.altura), (1920, 1080))
|
||||||
|
self.assertAlmostEqual(primeiro.taxa_de_quadros, 29.97002997, places=5)
|
||||||
|
self.assertEqual(primeiro.duracao, 12.5)
|
||||||
|
self.assertIs(primeiro, segundo)
|
||||||
|
run.assert_called_once()
|
||||||
|
|
||||||
|
def test_etapa_de_metadados_ignora_arquivos_offline(self):
|
||||||
|
class ExtratorSimulado:
|
||||||
|
def extrair(self, arquivo):
|
||||||
|
return "metadados"
|
||||||
|
|
||||||
|
with tempfile.TemporaryDirectory() as pasta:
|
||||||
|
arquivo = Path(pasta) / "camera.mp4"
|
||||||
|
arquivo.touch()
|
||||||
|
contexto = self._contexto(str(arquivo), str(Path(pasta) / "ausente.mp4"))
|
||||||
|
DescobertaDeArquivos().executar(contexto)
|
||||||
|
|
||||||
|
ExtracaoDeMetadados(ExtratorSimulado()).executar(contexto)
|
||||||
|
|
||||||
|
self.assertEqual(contexto.metadados_dos_arquivos["0"], "metadados")
|
||||||
|
self.assertNotIn("1", contexto.metadados_dos_arquivos)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
import json
|
||||||
|
import tempfile
|
||||||
|
import unittest
|
||||||
|
from pathlib import Path
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
from engine.integracoes.apple_speech import ProviderDeTranscricaoApple
|
||||||
|
|
||||||
|
|
||||||
|
class TesteProviderDeTranscricaoApple(unittest.TestCase):
|
||||||
|
@patch("engine.integracoes.apple_speech.provider_de_transcricao_apple.subprocess.run")
|
||||||
|
def test_transcreve_em_pt_br_no_dispositivo_e_preserva_timestamps(self, executar):
|
||||||
|
executar.return_value = type("Resultado", (), {
|
||||||
|
"stdout": json.dumps({"segmentos": [
|
||||||
|
{"inicio": 1.25, "fim": 2.75, "texto": " Olá mundo ", "confianca": 0.93},
|
||||||
|
]}),
|
||||||
|
})()
|
||||||
|
|
||||||
|
with tempfile.TemporaryDirectory() as pasta:
|
||||||
|
arquivo = Path(pasta) / "video.mp4"
|
||||||
|
arquivo.write_bytes(b"video")
|
||||||
|
clipe = type("Clipe", (), {"arquivo": str(arquivo)})()
|
||||||
|
resultado = ProviderDeTranscricaoApple(executavel="transcritor").transcrever(clipe)
|
||||||
|
|
||||||
|
self.assertEqual(resultado[0].texto, "Olá mundo")
|
||||||
|
self.assertEqual((resultado[0].inicio, resultado[0].fim), (1.25, 2.75))
|
||||||
|
comando = executar.call_args.args[0]
|
||||||
|
self.assertEqual(comando, ["transcritor", str(arquivo), "pt-BR", "true"])
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
# Teste de transcrição local — `0E6A8870.MP4`
|
||||||
|
|
||||||
|
## Configuração
|
||||||
|
|
||||||
|
- Modelo: `faster-whisper-base`
|
||||||
|
- Idioma: `pt`
|
||||||
|
- Dispositivo: CPU
|
||||||
|
- Quantização: `int8`
|
||||||
|
- Provider: `ProviderDeTranscricaoLocal`
|
||||||
|
- Origem: `/Volumes/Merongo/PROJETOS/brools/0E6A8870.MP4`
|
||||||
|
- Duração do vídeo: aproximadamente `6,05 s`
|
||||||
|
|
||||||
|
## Desempenho
|
||||||
|
|
||||||
|
| Medição | Resultado |
|
||||||
|
|---|---:|
|
||||||
|
| Carregamento do modelo | 1,849 s |
|
||||||
|
| Transcrição | 0,802 s |
|
||||||
|
| Memória máxima do processo | aproximadamente 665 MB |
|
||||||
|
|
||||||
|
## Resultado temporal
|
||||||
|
|
||||||
|
| Início | Fim | Texto reconhecido |
|
||||||
|
|---:|---:|---|
|
||||||
|
| 0,000 s | 6,000 s | “a mais é fazer bem pesado, porque não vai fazer as boguinhas que a gente ame de 11, então aqui...” |
|
||||||
|
|
||||||
|
## Observações
|
||||||
|
|
||||||
|
O processamento local funcionou sem autorização do Apple Speech e sem acesso de rede. O modelo `base` foi rápido para este arquivo curto, mas a transcrição apresenta baixa clareza em parte do trecho; a qualidade deve ser comparada com o modelo `small` antes de escolher o perfil definitivo.
|
||||||
|
|
||||||
|
Para o catálogo SQLite, este resultado deve ser persistido como um segmento de transcrição associado ao identificador do ativo e à versão do modelo. O modelo carregado deve permanecer reutilizável pelo worker, evitando pagar o custo de 1,849 s a cada arquivo.
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
# Teste de indexação — `0E6A8873.MP4`
|
||||||
|
|
||||||
|
## Identificação do ativo
|
||||||
|
|
||||||
|
- Origem: `/Volumes/Merongo/PROJETOS/brools/0E6A8873.MP4`
|
||||||
|
- Tamanho: `354.817.464` bytes
|
||||||
|
- Fingerprint parcial SHA-256: `d1072c11b67454a6e2eb0422ca037b1648a1ed05725ab2064c2e01d7194f2fdc`
|
||||||
|
- Duração: `23,315 s`
|
||||||
|
- Vídeo: H.264, `3840 × 2160`, aproximadamente `23,976 FPS`
|
||||||
|
- Áudio: AAC, 2 canais, `48 kHz`
|
||||||
|
|
||||||
|
## Resultado por etapa
|
||||||
|
|
||||||
|
| Etapa | Status | Tempo | Resultado |
|
||||||
|
|---|---|---:|---|
|
||||||
|
| FFprobe/metadados | concluída | 0,097 s | Metadados técnicos lidos |
|
||||||
|
| Extração de áudio | concluída | 0,078 s | WAV mono, 16 kHz, 745.570 bytes |
|
||||||
|
| Amostragem visual simples | concluída | 2,147 s | 23 frames, 1 FPS, reduzidos para 640 px |
|
||||||
|
| Apple Speech `pt-BR` on-device | autorização pendente | 0,011 s | Código 4: permissão não concedida |
|
||||||
|
|
||||||
|
## Interpretação
|
||||||
|
|
||||||
|
O pipeline barato de preparação é viável para processamento em segundo plano. Para este arquivo, a leitura técnica e a extração de áudio são praticamente instantâneas; a amostragem visual reduzida é a etapa mais custosa entre as etapas executadas, mas ainda terminou em aproximadamente 2,1 segundos.
|
||||||
|
|
||||||
|
O Apple Speech não apresentou mais o crash `SIGABRT`. O processo Foundation-only iniciou normalmente e retornou um erro controlado de autorização. Depois que o bundle receber permissão de reconhecimento de fala no macOS, esta mesma etapa poderá ser repetida sem alterar o catálogo nem as etapas já concluídas.
|
||||||
|
|
||||||
|
## Como isso será armazenado no SQLite
|
||||||
|
|
||||||
|
Este teste deverá produzir, conceitualmente:
|
||||||
|
|
||||||
|
```text
|
||||||
|
ativos
|
||||||
|
id = UUID interno
|
||||||
|
caminho_relativo = 0E6A8873.MP4
|
||||||
|
fingerprint = d1072...
|
||||||
|
tamanho = 354817464
|
||||||
|
duracao = 23.314958
|
||||||
|
status = parcial
|
||||||
|
|
||||||
|
trabalhos_de_indexacao
|
||||||
|
ativo_id = ...
|
||||||
|
etapa = metadados
|
||||||
|
status = concluida
|
||||||
|
tempo = 0.097
|
||||||
|
|
||||||
|
trabalhos_de_indexacao
|
||||||
|
ativo_id = ...
|
||||||
|
etapa = amostragem_visual
|
||||||
|
status = concluida
|
||||||
|
checkpoint = 23.315
|
||||||
|
|
||||||
|
trabalhos_de_indexacao
|
||||||
|
ativo_id = ...
|
||||||
|
etapa = transcricao
|
||||||
|
status = aguardando_autorizacao
|
||||||
|
provider = Apple Speech
|
||||||
|
locale = pt-BR
|
||||||
|
```
|
||||||
|
|
||||||
|
Os frames e o WAV são cache reconstruível e não devem ser tratados como o cadastro principal. Quando houver transcrição e análise visual, seus segmentos e evidências serão gravados com timestamps no arquivo de origem.
|
||||||
|
|
||||||
|
## Próximo teste
|
||||||
|
|
||||||
|
Conceder a permissão do Apple Speech ao bundle do transcritor e repetir somente a etapa de transcrição deste arquivo. Em seguida, comparar a qualidade do resultado antes de enfileirar os demais 23 arquivos.
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
# Descrição preliminar das cenas — próximos quatro vídeos
|
||||||
|
|
||||||
|
## Escopo
|
||||||
|
|
||||||
|
Este relatório complementa `proximos-4-transcricoes.md`. A transcrição foi executada
|
||||||
|
com `ProviderDeTranscricaoLocal`; a análise visual foi tentada através de
|
||||||
|
`AnalisadorAppleVisionNativo.analisar(QuadroDeVideo)`, usando frames PNG reduzidos
|
||||||
|
para 640 px de largura.
|
||||||
|
|
||||||
|
## Observações preliminares dos frames amostrados
|
||||||
|
|
||||||
|
Estas descrições são uma inspeção visual dos frames de teste e ainda não devem ser
|
||||||
|
gravadas como evidência automática definitiva no catálogo.
|
||||||
|
|
||||||
|
| Arquivo | Descrição preliminar |
|
||||||
|
|---|---|
|
||||||
|
| `0E6A8871.MP4` | Close de uma mão segurando uma caneta sobre um tablet. A tela mostra um diagrama de rosto com marcações/pontos; a ação principal é indicar ou marcar regiões do rosto. |
|
||||||
|
| `0E6A8872.MP4` | Mulher de blusa vermelha e cabelo preso demonstra algo usando uma caneta e um tablet com diagrama facial. Há alternância entre enquadramento da apresentadora e close do tablet/caneta. |
|
||||||
|
| `0E6A8873.MP4` | Demonstração presencial: mulher de blusa vermelha aparece junto de outra pessoa em posição reclinada e, em outro momento, junto de uma pessoa sentada. A atividade visível parece envolver indicação/avaliação da região facial, mas a finalidade não foi inferida. |
|
||||||
|
| `0E6A8874.MP4` | Plano médio frontal da mulher de blusa vermelha falando e gesticulando. Em alguns momentos ela aponta ou toca o próprio rosto; há um objeto/tablet no primeiro plano. |
|
||||||
|
|
||||||
|
## Resultado técnico da tentativa automática
|
||||||
|
|
||||||
|
O adapter existente foi chamado corretamente, sem criar uma implementação paralela:
|
||||||
|
|
||||||
|
```text
|
||||||
|
AnalisadorAppleVisionNativo(
|
||||||
|
recursos=("pessoas", "categorias", "ocr"),
|
||||||
|
interpretar_com_apple_intelligence=False,
|
||||||
|
).analisar(QuadroDeVideo(...))
|
||||||
|
```
|
||||||
|
|
||||||
|
O retorno não trouxe `pessoas`, `categorias` nem `ocr`. Trouxe somente uma
|
||||||
|
evidência `diagnostico_apple_vision`, com estes avisos:
|
||||||
|
|
||||||
|
- `pessoas`: falha ao carregar o modelo ANE do Vision;
|
||||||
|
- `ocr`: erro genérico do framework;
|
||||||
|
- `categorias`: falha ao criar `CVPixelBufferPool` com dimensões zeradas.
|
||||||
|
|
||||||
|
O fallback `AnalisadorDeRostosOpenCV`, `AnalisadorDeComposicaoOpenCV` e
|
||||||
|
`AnalisadorDeQualidadeOpenCV` também não pôde ser executado neste teste porque o
|
||||||
|
interpretador usado pelo engine não possui `cv2` instalado.
|
||||||
|
|
||||||
|
## Decisão para o catálogo
|
||||||
|
|
||||||
|
As descrições acima devem ficar, por enquanto, como resultado de validação manual
|
||||||
|
do teste. O catálogo deve armazenar a etapa visual como `parcial`/`indisponível`,
|
||||||
|
preservando os avisos técnicos e os frames amostrados para reprocessamento. Não
|
||||||
|
devemos registrar como fato automático algo que o Vision não confirmou.
|
||||||
|
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
# Teste de transcrição — próximos quatro vídeos
|
||||||
|
|
||||||
|
## Configuração comum
|
||||||
|
|
||||||
|
- Provider: `ProviderDeTranscricaoLocal`
|
||||||
|
- Modelo: `faster-whisper-base`
|
||||||
|
- Idioma: `pt-BR`
|
||||||
|
- Dispositivo: CPU
|
||||||
|
- Cálculo: `int8`
|
||||||
|
- Modelo carregado uma única vez: `1,613 s`
|
||||||
|
- Memória máxima observada no processo: aproximadamente `805 MB`
|
||||||
|
|
||||||
|
## Desempenho
|
||||||
|
|
||||||
|
| Arquivo | Duração | Tempo de transcrição |
|
||||||
|
|---|---:|---:|
|
||||||
|
| `0E6A8871.MP4` | 5,797 s | 4,213 s |
|
||||||
|
| `0E6A8872.MP4` | 17,726 s | 1,377 s |
|
||||||
|
| `0E6A8873.MP4` | 23,315 s | 1,737 s |
|
||||||
|
| `0E6A8874.MP4` | 14,556 s | 1,300 s |
|
||||||
|
|
||||||
|
Tempo de transcrição dos quatro arquivos: `8,627 s`, além do carregamento inicial do modelo.
|
||||||
|
|
||||||
|
## Segmentos reconhecidos
|
||||||
|
|
||||||
|
### `0E6A8871.MP4`
|
||||||
|
|
||||||
|
- `[0,000–3,840]` Tende um pouquinho mais aqui, acho que pode dratar um pouquinho mais a pé, se eu
|
||||||
|
- `[3,840–5,580]` doutora do entratante, a noite, ouzo.
|
||||||
|
|
||||||
|
### `0E6A8872.MP4`
|
||||||
|
|
||||||
|
- `[0,000–3,360]` Então, por isso que eu vou fazer tanto, não se paguei nem com um pouquinho mais lá para trás.
|
||||||
|
- `[4,120–10,580]` E, espero, por biculares aqui, que é o musco orbicular da boca, que ele serve com o esfímpere, ele fecha sim, né?
|
||||||
|
- `[10,900–14,700]` Então, quando você faz o biquinho, fica três movinhas de um lado,
|
||||||
|
- `[15,020–17,680]` é uma, dois de cada lado, basicamente só que essas daqui são um pouco...
|
||||||
|
|
||||||
|
### `0E6A8873.MP4`
|
||||||
|
|
||||||
|
- `[0,000–4,700]` é que essa parte aqui em perior tem alguns que soem aqui, tem um pouquinho de
|
||||||
|
- `[4,700–8,960]` classidente de pés, né? Então isso, quando eu passei em dia, tem uma certa
|
||||||
|
- `[8,960–12,840]` classidade, a gente não bloqueia tanto justamente porque esse bloqueia
|
||||||
|
- `[12,840–15,940]` vai parecer que está mais classindo ainda, porque daí é um
|
||||||
|
- `[15,940–20,180]` muito bom explotido, isso vai ficar mais explotidamente. Então quando tem uma
|
||||||
|
- `[20,180–23,260]` sobre eu, quando você tem bolsa, daí a gente não faz aqui...
|
||||||
|
|
||||||
|
### `0E6A8874.MP4`
|
||||||
|
|
||||||
|
- `[0,000–2,740]` O profissão da pele, porque a gente está tratando músculo,
|
||||||
|
- `[2,860–5,880]` a gente está enfraquecendo músculos que estão mudando impressões na pele.
|
||||||
|
- `[6,280–8,900]` Mas quando a pele está muito fininha que é a parte da Pávera,
|
||||||
|
- `[9,020–12,700]` a gente tem que tratar a pele como um todo, da parte de dermatológica, entendeu?
|
||||||
|
- `[13,000–14,520]` Então, por isso que a gente não pode fazer...
|
||||||
|
|
||||||
|
## Avaliação
|
||||||
|
|
||||||
|
O desempenho é suficiente para um worker de baixa prioridade, especialmente porque o modelo fica carregado e é reutilizado. A qualidade do `base` é compreensível, mas há erros fonéticos relevantes em termos técnicos. Para o catálogo, esses segmentos ainda são úteis para busca aproximada; para texto final de edição, o próximo experimento deve comparar o mesmo conjunto com `faster-whisper-small`.
|
||||||
|
|
||||||
|
Cada segmento deverá ser salvo no SQLite com `ativo_id`, `inicio`, `fim`, `texto`, `modelo`, `provider`, `idioma`, `confianca` quando disponível e versão da etapa.
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 1.1 MiB |
@@ -0,0 +1,37 @@
|
|||||||
|
# Análise do primeiro frame — corte `000f4763`
|
||||||
|
|
||||||
|
- Arquivo: `0E6A8290.MP4`
|
||||||
|
- Início do corte na timeline: `0.000s`
|
||||||
|
- Primeiro frame efetivo do corte na origem: `2.3023s`
|
||||||
|
- Resolução: `3840 × 2160`
|
||||||
|
- Frame extraído: `frame-000000.jpg`
|
||||||
|
|
||||||
|
## OpenCV
|
||||||
|
|
||||||
|
- Brilho médio: `114.71 / 255`
|
||||||
|
- Contraste (desvio padrão): `61.81`
|
||||||
|
- Nitidez (variância do Laplaciano): `9.73`
|
||||||
|
|
||||||
|
A métrica de nitidez é baixa para este frame; há aparência de foco suave e/ou
|
||||||
|
desfoque de movimento. Brilho e contraste estão em uma faixa utilizável, sem
|
||||||
|
indício de subexposição extrema no cálculo global.
|
||||||
|
|
||||||
|
## Observação visual
|
||||||
|
|
||||||
|
O frame mostra uma pessoa em plano médio, bem exposta, diante de fundo neutro
|
||||||
|
desfocado. A imagem foi registrada deitada no arquivo, apesar da sequência ser
|
||||||
|
vertical; a correção de orientação deve entrar como etapa própria antes de
|
||||||
|
qualquer análise semântica ou geração de preview.
|
||||||
|
|
||||||
|
## Providers indisponíveis neste host
|
||||||
|
|
||||||
|
- MediaPipe 1.0.1: abort nativo em `DrishtiMetalHelper` durante a criação do
|
||||||
|
`PoseLandmarker` no macOS 26.6.2. O adapter agora roda em subprocesso para
|
||||||
|
conter esse abort.
|
||||||
|
- Apple Vision: o host não conseguiu criar o plano de inferência do modelo
|
||||||
|
nativo (`ANECF error`) neste momento. O adapter retorna indisponibilidade sem
|
||||||
|
interromper o Scanner.
|
||||||
|
- ONNX: instalado, mas ainda não há um modelo ONNX e seu decodificador
|
||||||
|
configurados para detecção de pessoas/objetos.
|
||||||
|
- PySceneDetect: instalado, mas não se aplica a um único frame; ele precisa de
|
||||||
|
uma sequência de frames para identificar uma transição.
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,35 @@
|
|||||||
|
# Relatório visual — 0E6A8290.MP4
|
||||||
|
|
||||||
|
- Clipe: `000f4765`
|
||||||
|
- Origem: `/Volumes/Merongo/PROJETOS/03 - Mastopexia/0E6A8290.MP4`
|
||||||
|
- Timeline: 38.272s–42.409s
|
||||||
|
- Amostragem: a cada 1 segundo(s)
|
||||||
|
|
||||||
|
## Observações por frame
|
||||||
|
|
||||||
|
### Origem 41.808s
|
||||||
|
|
||||||
|
rosto, qualidade_do_rosto, pessoa, pose, mao, mao, categoria, categoria, estetica, horizonte, retangulo, contornos, segmentacao_de_pessoas, interpretacao_editorial
|
||||||
|
|
||||||
|
### Origem 42.808s
|
||||||
|
|
||||||
|
rosto, qualidade_do_rosto, pessoa, pose, mao, mao, categoria, categoria, estetica, horizonte, retangulo, contornos, segmentacao_de_pessoas, interpretacao_editorial
|
||||||
|
|
||||||
|
### Origem 43.808s
|
||||||
|
|
||||||
|
rosto, qualidade_do_rosto, pessoa, pose, mao, mao, categoria, categoria, estetica, horizonte, retangulo, contornos, segmentacao_de_pessoas, interpretacao_editorial
|
||||||
|
|
||||||
|
### Origem 44.808s
|
||||||
|
|
||||||
|
rosto, qualidade_do_rosto, pessoa, pose, mao, mao, categoria, categoria, estetica, horizonte, contornos, segmentacao_de_pessoas, interpretacao_editorial
|
||||||
|
|
||||||
|
### Origem 45.808s
|
||||||
|
|
||||||
|
rosto, qualidade_do_rosto, pessoa, pose, mao, mao, categoria, categoria, estetica, horizonte, retangulo, contornos, segmentacao_de_pessoas, interpretacao_editorial
|
||||||
|
|
||||||
|
## Continuidade
|
||||||
|
|
||||||
|
- similaridade_visual: 41.808s–42.808s — `{"possivel_mudanca_de_cena": false, "distancia_feature_print": 0.13040070235729218}`
|
||||||
|
- similaridade_visual: 42.808s–43.808s — `{"possivel_mudanca_de_cena": false, "distancia_feature_print": 0.1921818107366562}`
|
||||||
|
- similaridade_visual: 43.808s–44.808s — `{"possivel_mudanca_de_cena": false, "distancia_feature_print": 0.15198232233524323}`
|
||||||
|
- similaridade_visual: 44.808s–45.808s — `{"possivel_mudanca_de_cena": false, "distancia_feature_print": 0.20895899832248688}`
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 1.1 MiB |
Binary file not shown.
|
After Width: | Height: | Size: 376 KiB |
BIN
Binary file not shown.
+2444
File diff suppressed because it is too large
Load Diff
+1
@@ -0,0 +1 @@
|
|||||||
|
Aquela mama com um formato mais estruturado, que valoriza o seu colo, que dá aquele ar de elegância. Ah, isso é o desejo de muitas mulheres, né? Com o tempo, nosso corpo muda e nossas mamas também mudam. É a amamentação, perda de peso, efeito sanfona, tudo isso altera o formato e a sustentação das mamas. Então a mastopexia é uma cirurgia que reestrutura a mama e trata a flacidez, o excesso de pele e a queda das mamas. E associada a essa cirurgia, a gente faz a inserção de prótese de silicone, para dar aquele formato e volume mais proporcional para o seu tórax. E tudo isso associado a medida... Amor, eu tô intacta! Mas eu vou ter que falar tudo de novo? Tudo de novo? Aquela mama com um formato mais estruturado, com o colo que... Amor, é que eu... Isso, só clica aí agora, na tela. Aquela mama com um formato mais estruturado, que valoriza o seu colo, dá um ar de elegância. Ah, isso é desejo de muitas mulheres, né? Com o tempo, o corpo muda e as nossas mamas também mudam. É a amamentação, perda de peso, efeito sanfona, tudo isso modifica o formato e a sustentação das mamas. A mastopexia é a cirurgia que reestrutura a mama. Ela trata a flacidez de pele, o excesso de pele... Posso começar da mastopexia? A mastopexia é a cirurgia que reestrutura a sua mama. A mastopexia é a cirurgia que reestrutura a sua mama, tratando a flacidez, o excesso de pele e a queda das mamas. E associada a essa cirurgia, a gente insere próteses de silicone para dar um formato e um volume mais proporcional para o seu corpo. E associada a técnicas modernas, como sutiã interna e alça muscular, a gente consegue manter por mais tempo o formato e a sustentação das mamas. Vou falar só a última frase, solta aqui. Não pegou? Aumenta. E aí, continua? Então é devolver forma e sustentação de um jeito que pareça que sempre foi assim. Então é devolver forma e sustentação de um jeito que pareça que sempre foi assim.
|
||||||
+182
@@ -0,0 +1,182 @@
|
|||||||
|
[
|
||||||
|
{
|
||||||
|
"inicio": 2.23,
|
||||||
|
"fim": 8.11,
|
||||||
|
"texto": "Aquela mama com um formato mais estruturado, que valoriza o seu colo, que dá aquele ar de elegância.",
|
||||||
|
"confianca": null
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"inicio": 8.67,
|
||||||
|
"fim": 10.79,
|
||||||
|
"texto": "Ah, isso é o desejo de muitas mulheres, né?",
|
||||||
|
"confianca": null
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"inicio": 11.43,
|
||||||
|
"fim": 14.67,
|
||||||
|
"texto": "Com o tempo, nosso corpo muda e nossas mamas também mudam.",
|
||||||
|
"confianca": null
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"inicio": 15.15,
|
||||||
|
"fim": 21.89,
|
||||||
|
"texto": "É a amamentação, perda de peso, efeito sanfona, tudo isso altera o formato e a sustentação das mamas.",
|
||||||
|
"confianca": null
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"inicio": 22.41,
|
||||||
|
"fim": 30.43,
|
||||||
|
"texto": "Então a mastopexia é uma cirurgia que reestrutura a mama e trata a flacidez, o excesso de pele e a queda das mamas.",
|
||||||
|
"confianca": null
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"inicio": 30.43,
|
||||||
|
"fim": 38.35,
|
||||||
|
"texto": "E associada a essa cirurgia, a gente faz a inserção de prótese de silicone, para dar aquele formato e volume mais proporcional para o seu tórax.",
|
||||||
|
"confianca": null
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"inicio": 38.83,
|
||||||
|
"fim": 42.8,
|
||||||
|
"texto": "E tudo isso associado a medida...",
|
||||||
|
"confianca": null
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"inicio": 42.8,
|
||||||
|
"fim": 47.43,
|
||||||
|
"texto": "Amor, eu tô intacta!",
|
||||||
|
"confianca": null
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"inicio": 47.73,
|
||||||
|
"fim": 48.95,
|
||||||
|
"texto": "Mas eu vou ter que falar tudo de novo?",
|
||||||
|
"confianca": null
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"inicio": 55.68,
|
||||||
|
"fim": 56.38,
|
||||||
|
"texto": "Tudo de novo?",
|
||||||
|
"confianca": null
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"inicio": 64.98,
|
||||||
|
"fim": 69.38,
|
||||||
|
"texto": "Aquela mama com um formato mais estruturado, com o colo que...",
|
||||||
|
"confianca": null
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"inicio": 70.38,
|
||||||
|
"fim": 71.64,
|
||||||
|
"texto": "Amor, é que eu...",
|
||||||
|
"confianca": null
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"inicio": 71.64,
|
||||||
|
"fim": 73.78,
|
||||||
|
"texto": "Isso, só clica aí agora, na tela.",
|
||||||
|
"confianca": null
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"inicio": 73.78,
|
||||||
|
"fim": 92.38,
|
||||||
|
"texto": "Aquela mama com um formato mais estruturado, que valoriza o seu colo, dá um ar de elegância.",
|
||||||
|
"confianca": null
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"inicio": 92.96,
|
||||||
|
"fim": 94.88,
|
||||||
|
"texto": "Ah, isso é desejo de muitas mulheres, né?",
|
||||||
|
"confianca": null
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"inicio": 95.48,
|
||||||
|
"fim": 98.74,
|
||||||
|
"texto": "Com o tempo, o corpo muda e as nossas mamas também mudam.",
|
||||||
|
"confianca": null
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"inicio": 99.2,
|
||||||
|
"fim": 106.3,
|
||||||
|
"texto": "É a amamentação, perda de peso, efeito sanfona, tudo isso modifica o formato e a sustentação das mamas.",
|
||||||
|
"confianca": null
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"inicio": 106.94,
|
||||||
|
"fim": 109.92,
|
||||||
|
"texto": "A mastopexia é a cirurgia que reestrutura a mama.",
|
||||||
|
"confianca": null
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"inicio": 109.92,
|
||||||
|
"fim": 113.92,
|
||||||
|
"texto": "Ela trata a flacidez de pele, o excesso de pele...",
|
||||||
|
"confianca": null
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"inicio": 113.92,
|
||||||
|
"fim": 115.82,
|
||||||
|
"texto": "Posso começar da mastopexia?",
|
||||||
|
"confianca": null
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"inicio": 116.26,
|
||||||
|
"fim": 126.12,
|
||||||
|
"texto": "A mastopexia é a cirurgia que reestrutura a sua mama.",
|
||||||
|
"confianca": null
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"inicio": 127.22,
|
||||||
|
"fim": 135.0,
|
||||||
|
"texto": "A mastopexia é a cirurgia que reestrutura a sua mama, tratando a flacidez, o excesso de pele e a queda das mamas.",
|
||||||
|
"confianca": null
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"inicio": 135.32,
|
||||||
|
"fim": 141.8,
|
||||||
|
"texto": "E associada a essa cirurgia, a gente insere próteses de silicone para dar um formato e um volume mais proporcional para o seu corpo.",
|
||||||
|
"confianca": null
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"inicio": 141.8,
|
||||||
|
"fim": 150.34,
|
||||||
|
"texto": "E associada a técnicas modernas, como sutiã interna e alça muscular, a gente consegue manter por mais tempo o formato e a sustentação das mamas.",
|
||||||
|
"confianca": null
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"inicio": 151.14,
|
||||||
|
"fim": 153.0,
|
||||||
|
"texto": "Vou falar só a última frase, solta aqui.",
|
||||||
|
"confianca": null
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"inicio": 153.82,
|
||||||
|
"fim": 154.26,
|
||||||
|
"texto": "Não pegou?",
|
||||||
|
"confianca": null
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"inicio": 159.91,
|
||||||
|
"fim": 160.47,
|
||||||
|
"texto": "Aumenta.",
|
||||||
|
"confianca": null
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"inicio": 180.75,
|
||||||
|
"fim": 181.35,
|
||||||
|
"texto": "E aí, continua?",
|
||||||
|
"confianca": null
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"inicio": 184.14,
|
||||||
|
"fim": 188.7,
|
||||||
|
"texto": "Então é devolver forma e sustentação de um jeito que pareça que sempre foi assim.",
|
||||||
|
"confianca": null
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"inicio": 189.9,
|
||||||
|
"fim": 194.64,
|
||||||
|
"texto": "Então é devolver forma e sustentação de um jeito que pareça que sempre foi assim.",
|
||||||
|
"confianca": null
|
||||||
|
}
|
||||||
|
]
|
||||||
+1
@@ -0,0 +1 @@
|
|||||||
|
Aquela mama com um formato mais estruturado, que valoriza o seu colo, que dá aquele ar de elegância. Ah, isso é o desejo de muitas mulheres, né? Com o tempo, nosso corpo muda e nossas mamas também mudam. É a amamentação, perda de peso, efeito sanfona, tudo isso altera o formato e a sustentação das mamas. Então a mastopexia é uma cirurgia que reestrutura a mama e trata a flacidez, o excesso de pele e a queda das mamas. E associada a essa cirurgia, a gente faz a inserção de prótese de silicone, para dar aquele formato e volume mais proporcional para o seu tórax. E tudo isso associado a medida... Amor, eu tô intacta! Mas eu vou ter que falar tudo de novo? Tudo de novo? Aquela mama com um formato mais estruturado, com o colo que... Amor, é que eu... Isso, só clica aí agora, na tela. Aquela mama com um formato mais estruturado, que valoriza o seu colo, dá um ar de elegância. Ah, isso é desejo de muitas mulheres, né? Com o tempo, o corpo muda e as nossas mamas também mudam. É a amamentação, perda de peso, efeito sanfona, tudo isso modifica o formato e a sustentação das mamas. A mastopexia é a cirurgia que reestrutura a mama. Ela trata a flacidez de pele, o excesso de pele... Posso começar da mastopexia? A mastopexia é a cirurgia que reestrutura a sua mama. A mastopexia é a cirurgia que reestrutura a sua mama, tratando a flacidez, o excesso de pele e a queda das mamas. E associada a essa cirurgia, a gente insere próteses de silicone para dar um formato e um volume mais proporcional para o seu corpo. E associada a técnicas modernas, como sutiã interna e alça muscular, a gente consegue manter por mais tempo o formato e a sustentação das mamas. Vou falar só a última frase, solta aqui. Não pegou? Aumenta. E aí, continua? Então é devolver forma e sustentação de um jeito que pareça que sempre foi assim. Então é devolver forma e sustentação de um jeito que pareça que sempre foi assim.
|
||||||
@@ -0,0 +1,554 @@
|
|||||||
|
# Relatório de transcrição — VIdeo Laryssa — sem corte
|
||||||
|
|
||||||
|
Duração: 141.475 s
|
||||||
|
Clipes processados: 25
|
||||||
|
|
||||||
|
## 000f4763 — 0.000s–38.272s
|
||||||
|
Origem: 2.302s–40.574s
|
||||||
|
|
||||||
|
Aquela mama com um formato mais estruturado, que valoriza o seu colo, que dá aquele ar de elegância. Ah, isso é o desejo de muitas mulheres, né? Com o tempo, nosso corpo muda e nossas mamas também mudam. É a amamentação, perda de peso, efeito sanfona, tudo isso altera o formato e a sustentação das mamas. Então a mastopexia é uma cirurgia que reestrutura a mama e trata a flacidez, o excesso de pele e a queda das mamas. E associada a essa cirurgia, a gente faz a inserção de prótese de silicone, para dar aquele formato e volume mais proporcional para o seu tórax. E tudo isso associado a medida...
|
||||||
|
|
||||||
|
Emoções detectadas por segmento:
|
||||||
|
- [0.000s–5.808s] hap (0.58)
|
||||||
|
- [6.208s–8.448s] hap (0.97)
|
||||||
|
- [8.728s–12.348s] ang (0.96)
|
||||||
|
- [12.808s–19.588s] ang (0.79)
|
||||||
|
- [19.968s–28.128s] ang (0.76)
|
||||||
|
- [28.128s–36.028s] ang (0.67)
|
||||||
|
- [36.288s–38.272s] ang (0.81)
|
||||||
|
|
||||||
|
- [0.000s–0.448s] Aquela
|
||||||
|
- [0.448s–0.688s] mama
|
||||||
|
- [0.688s–0.908s] com
|
||||||
|
- [0.908s–1.088s] um
|
||||||
|
- [1.088s–1.488s] formato
|
||||||
|
- [1.488s–1.688s] mais
|
||||||
|
- [1.688s–2.488s] estruturado,
|
||||||
|
- [2.648s–2.668s] que
|
||||||
|
- [2.668s–3.328s] valoriza
|
||||||
|
- [3.328s–3.468s] o
|
||||||
|
- [3.468s–3.628s] seu
|
||||||
|
- [3.628s–4.088s] colo,
|
||||||
|
- [4.208s–4.208s] que
|
||||||
|
- [4.208s–4.308s] dá
|
||||||
|
- [4.308s–4.568s] aquele
|
||||||
|
- [4.568s–4.868s] ar
|
||||||
|
- [4.868s–5.008s] de
|
||||||
|
- [5.008s–5.808s] elegância.
|
||||||
|
- [6.208s–6.508s] Ah,
|
||||||
|
- [6.508s–6.708s] isso
|
||||||
|
- [6.708s–6.828s] é
|
||||||
|
- [6.828s–6.948s] o
|
||||||
|
- [6.948s–7.068s] desejo
|
||||||
|
- [7.068s–7.208s] de
|
||||||
|
- [7.208s–7.608s] muitas
|
||||||
|
- [7.608s–8.168s] mulheres,
|
||||||
|
- [8.368s–8.448s] né?
|
||||||
|
- [8.728s–9.288s] Com
|
||||||
|
- [9.288s–9.468s] o
|
||||||
|
- [9.468s–9.748s] tempo,
|
||||||
|
- [9.808s–10.028s] nosso
|
||||||
|
- [10.028s–10.368s] corpo
|
||||||
|
- [10.368s–10.788s] muda
|
||||||
|
- [10.788s–10.868s] e
|
||||||
|
- [10.868s–11.148s] nossas
|
||||||
|
- [11.148s–11.508s] mamas
|
||||||
|
- [11.508s–11.848s] também
|
||||||
|
- [11.848s–12.348s] mudam.
|
||||||
|
- [12.808s–12.948s] É
|
||||||
|
- [12.948s–13.048s] a
|
||||||
|
- [13.048s–13.708s] amamentação,
|
||||||
|
- [14.008s–14.288s] perda
|
||||||
|
- [14.288s–14.448s] de
|
||||||
|
- [14.448s–14.788s] peso,
|
||||||
|
- [15.048s–15.388s] efeito
|
||||||
|
- [15.388s–16.108s] sanfona,
|
||||||
|
- [16.388s–16.648s] tudo
|
||||||
|
- [16.648s–16.928s] isso
|
||||||
|
- [16.928s–17.468s] altera
|
||||||
|
- [17.468s–17.588s] o
|
||||||
|
- [17.588s–18.108s] formato
|
||||||
|
- [18.108s–18.208s] e
|
||||||
|
- [18.208s–18.268s] a
|
||||||
|
- [18.268s–18.928s] sustentação
|
||||||
|
- [18.928s–19.168s] das
|
||||||
|
- [19.168s–19.588s] mamas.
|
||||||
|
- [19.968s–20.308s] Então
|
||||||
|
- [20.308s–20.468s] a
|
||||||
|
- [20.468s–21.428s] mastopexia
|
||||||
|
- [21.428s–21.648s] é
|
||||||
|
- [21.648s–21.748s] uma
|
||||||
|
- [21.748s–22.228s] cirurgia
|
||||||
|
- [22.228s–22.408s] que
|
||||||
|
- [22.408s–23.248s] reestrutura
|
||||||
|
- [23.248s–23.408s] a
|
||||||
|
- [23.408s–23.628s] mama
|
||||||
|
- [23.628s–24.088s] e
|
||||||
|
- [24.088s–24.448s] trata
|
||||||
|
- [24.448s–24.648s] a
|
||||||
|
- [24.648s–25.208s] flacidez,
|
||||||
|
- [25.648s–25.708s] o
|
||||||
|
- [25.708s–26.168s] excesso
|
||||||
|
- [26.168s–26.288s] de
|
||||||
|
- [26.288s–26.628s] pele
|
||||||
|
- [26.628s–26.988s] e
|
||||||
|
- [26.988s–27.148s] a
|
||||||
|
- [27.148s–27.428s] queda
|
||||||
|
- [27.428s–27.688s] das
|
||||||
|
- [27.688s–28.128s] mamas.
|
||||||
|
- [28.128s–28.548s] E
|
||||||
|
- [28.548s–29.048s] associada
|
||||||
|
- [29.048s–29.168s] a
|
||||||
|
- [29.168s–29.228s] essa
|
||||||
|
- [29.228s–29.688s] cirurgia,
|
||||||
|
- [29.808s–29.808s] a
|
||||||
|
- [29.808s–29.928s] gente
|
||||||
|
- [29.928s–30.188s] faz
|
||||||
|
- [30.188s–30.288s] a
|
||||||
|
- [30.288s–30.848s] inserção
|
||||||
|
- [30.848s–31.028s] de
|
||||||
|
- [31.028s–31.408s] prótese
|
||||||
|
- [31.408s–31.568s] de
|
||||||
|
- [31.568s–32.028s] silicone,
|
||||||
|
- [32.268s–32.508s] para
|
||||||
|
- [32.508s–32.648s] dar
|
||||||
|
- [32.648s–32.888s] aquele
|
||||||
|
- [32.888s–33.548s] formato
|
||||||
|
- [33.548s–33.688s] e
|
||||||
|
- [33.688s–34.188s] volume
|
||||||
|
- [34.188s–34.448s] mais
|
||||||
|
- [34.448s–35.188s] proporcional
|
||||||
|
- [35.188s–35.408s] para
|
||||||
|
- [35.408s–35.408s] o
|
||||||
|
- [35.408s–35.568s] seu
|
||||||
|
- [35.568s–36.028s] tórax.
|
||||||
|
- [36.288s–36.608s] E
|
||||||
|
- [36.608s–36.788s] tudo
|
||||||
|
- [36.788s–36.988s] isso
|
||||||
|
- [36.988s–37.588s] associado
|
||||||
|
- [37.588s–37.648s] a
|
||||||
|
- [37.648s–38.272s] medida...
|
||||||
|
|
||||||
|
## 000f4765 — 38.272s–42.409s
|
||||||
|
Origem: 41.808s–45.946s
|
||||||
|
|
||||||
|
Amor, eu tô intacta!
|
||||||
|
|
||||||
|
Emoções detectadas por segmento:
|
||||||
|
- [39.063s–40.323s] ang (0.67)
|
||||||
|
|
||||||
|
- [39.063s–39.483s] Amor,
|
||||||
|
- [39.563s–39.663s] eu
|
||||||
|
- [39.663s–39.803s] tô
|
||||||
|
- [39.803s–40.323s] intacta!
|
||||||
|
|
||||||
|
## 000f4767 — 42.409s–44.344s
|
||||||
|
Origem: 47.547s–49.483s
|
||||||
|
|
||||||
|
Mas eu vou ter que falar tudo de novo?
|
||||||
|
|
||||||
|
Emoções detectadas por segmento:
|
||||||
|
- [42.492s–43.792s] hap (0.96)
|
||||||
|
|
||||||
|
- [42.492s–42.732s] Mas
|
||||||
|
- [42.732s–42.832s] eu
|
||||||
|
- [42.832s–42.892s] vou
|
||||||
|
- [42.892s–42.972s] ter
|
||||||
|
- [42.972s–43.032s] que
|
||||||
|
- [43.032s–43.172s] falar
|
||||||
|
- [43.172s–43.372s] tudo
|
||||||
|
- [43.372s–43.492s] de
|
||||||
|
- [43.492s–43.792s] novo?
|
||||||
|
|
||||||
|
## 000f4769 — 44.344s–46.346s
|
||||||
|
Origem: 51.418s–53.420s
|
||||||
|
|
||||||
|
_Sem fala detectada._
|
||||||
|
|
||||||
|
## 000f476b — 46.346s–47.614s
|
||||||
|
Origem: 55.622s–56.890s
|
||||||
|
|
||||||
|
Tudo de novo?
|
||||||
|
|
||||||
|
Emoções detectadas por segmento:
|
||||||
|
- [46.346s–47.104s] neu (0.37)
|
||||||
|
|
||||||
|
- [46.346s–46.644s] Tudo
|
||||||
|
- [46.644s–46.804s] de
|
||||||
|
- [46.804s–47.104s] novo?
|
||||||
|
|
||||||
|
## 000f476d — 47.614s–49.383s
|
||||||
|
Origem: 59.059s–60.827s
|
||||||
|
|
||||||
|
_Sem fala detectada._
|
||||||
|
|
||||||
|
## 000f476f — 49.383s–53.453s
|
||||||
|
Origem: 64.932s–69.002s
|
||||||
|
|
||||||
|
Aquela mama com um formato mais estruturado, com o colo que...
|
||||||
|
|
||||||
|
Emoções detectadas por segmento:
|
||||||
|
- [49.391s–53.453s] ang (0.96)
|
||||||
|
|
||||||
|
- [49.391s–49.871s] Aquela
|
||||||
|
- [49.871s–50.131s] mama
|
||||||
|
- [50.131s–50.371s] com
|
||||||
|
- [50.371s–50.531s] um
|
||||||
|
- [50.531s–50.951s] formato
|
||||||
|
- [50.951s–51.171s] mais
|
||||||
|
- [51.171s–52.151s] estruturado,
|
||||||
|
- [52.331s–52.571s] com
|
||||||
|
- [52.571s–52.751s] o
|
||||||
|
- [52.751s–53.031s] colo
|
||||||
|
- [53.031s–53.453s] que...
|
||||||
|
|
||||||
|
## 000f4771 — 53.453s–57.224s
|
||||||
|
Origem: 70.270s–74.041s
|
||||||
|
|
||||||
|
Amor, é que eu... Isso, só clica aí agora, na tela.
|
||||||
|
|
||||||
|
Emoções detectadas por segmento:
|
||||||
|
- [53.453s–54.563s] hap (0.94)
|
||||||
|
- [54.563s–56.883s] ang (0.80)
|
||||||
|
|
||||||
|
- [53.453s–53.823s] Amor,
|
||||||
|
- [53.943s–53.983s] é
|
||||||
|
- [53.983s–54.063s] que
|
||||||
|
- [54.063s–54.563s] eu...
|
||||||
|
- [54.563s–55.103s] Isso,
|
||||||
|
- [55.363s–55.963s] só
|
||||||
|
- [55.963s–56.243s] clica
|
||||||
|
- [56.243s–56.383s] aí
|
||||||
|
- [56.383s–56.583s] agora,
|
||||||
|
- [56.743s–56.803s] na
|
||||||
|
- [56.803s–56.883s] tela.
|
||||||
|
|
||||||
|
## 000f4773 — 57.224s–58.091s
|
||||||
|
Origem: 81.448s–82.316s
|
||||||
|
|
||||||
|
_Sem fala detectada._
|
||||||
|
|
||||||
|
## 000f4775 — 58.091s–85.953s
|
||||||
|
Origem: 86.186s–114.047s
|
||||||
|
|
||||||
|
Aquela mama com um formato mais estruturado, que valoriza o seu colo, dá um ar de elegância. Ah, isso é desejo de muitas mulheres, né? Com o tempo, o corpo muda e as nossas mamas também mudam. É a amamentação, perda de peso, efeito sanfona, tudo isso modifica o formato e a sustentação das mamas. A mastopexia é a cirurgia que reestrutura a mama. Ela trata a flacidez de pele, o excesso de pele...
|
||||||
|
|
||||||
|
Emoções detectadas por segmento:
|
||||||
|
- [58.325s–64.285s] hap (0.49)
|
||||||
|
- [64.605s–66.745s] hap (0.67)
|
||||||
|
- [67.205s–70.605s] ang (0.98)
|
||||||
|
- [71.045s–78.185s] ang (0.92)
|
||||||
|
- [78.725s–81.805s] ang (0.97)
|
||||||
|
- [81.805s–85.953s] ang (0.88)
|
||||||
|
|
||||||
|
- [58.325s–58.825s] Aquela
|
||||||
|
- [58.825s–59.045s] mama
|
||||||
|
- [59.045s–59.285s] com
|
||||||
|
- [59.285s–59.485s] um
|
||||||
|
- [59.485s–59.945s] formato
|
||||||
|
- [59.945s–60.145s] mais
|
||||||
|
- [60.145s–61.005s] estruturado,
|
||||||
|
- [61.165s–61.225s] que
|
||||||
|
- [61.225s–61.845s] valoriza
|
||||||
|
- [61.845s–61.985s] o
|
||||||
|
- [61.985s–62.125s] seu
|
||||||
|
- [62.125s–62.725s] colo,
|
||||||
|
- [62.805s–62.885s] dá
|
||||||
|
- [62.885s–63.045s] um
|
||||||
|
- [63.045s–63.325s] ar
|
||||||
|
- [63.325s–63.485s] de
|
||||||
|
- [63.485s–64.285s] elegância.
|
||||||
|
- [64.605s–64.985s] Ah,
|
||||||
|
- [64.985s–65.145s] isso
|
||||||
|
- [65.145s–65.225s] é
|
||||||
|
- [65.225s–65.525s] desejo
|
||||||
|
- [65.525s–65.665s] de
|
||||||
|
- [65.665s–65.965s] muitas
|
||||||
|
- [65.965s–66.485s] mulheres,
|
||||||
|
- [66.645s–66.745s] né?
|
||||||
|
- [67.205s–67.585s] Com
|
||||||
|
- [67.585s–67.745s] o
|
||||||
|
- [67.745s–68.005s] tempo,
|
||||||
|
- [68.125s–68.225s] o
|
||||||
|
- [68.225s–68.525s] corpo
|
||||||
|
- [68.525s–69.005s] muda
|
||||||
|
- [69.005s–69.145s] e
|
||||||
|
- [69.145s–69.265s] as
|
||||||
|
- [69.265s–69.525s] nossas
|
||||||
|
- [69.525s–69.765s] mamas
|
||||||
|
- [69.765s–70.105s] também
|
||||||
|
- [70.105s–70.605s] mudam.
|
||||||
|
- [71.045s–71.185s] É
|
||||||
|
- [71.185s–71.265s] a
|
||||||
|
- [71.265s–71.945s] amamentação,
|
||||||
|
- [72.245s–72.605s] perda
|
||||||
|
- [72.605s–72.765s] de
|
||||||
|
- [72.765s–73.145s] peso,
|
||||||
|
- [73.425s–73.805s] efeito
|
||||||
|
- [73.805s–74.505s] sanfona,
|
||||||
|
- [74.725s–75.105s] tudo
|
||||||
|
- [75.105s–75.405s] isso
|
||||||
|
- [75.405s–76.025s] modifica
|
||||||
|
- [76.025s–76.185s] o
|
||||||
|
- [76.185s–76.685s] formato
|
||||||
|
- [76.685s–76.785s] e
|
||||||
|
- [76.785s–76.865s] a
|
||||||
|
- [76.865s–77.525s] sustentação
|
||||||
|
- [77.525s–77.765s] das
|
||||||
|
- [77.765s–78.185s] mamas.
|
||||||
|
- [78.725s–78.945s] A
|
||||||
|
- [78.945s–79.765s] mastopexia
|
||||||
|
- [79.765s–79.905s] é
|
||||||
|
- [79.905s–79.925s] a
|
||||||
|
- [79.925s–80.305s] cirurgia
|
||||||
|
- [80.305s–80.465s] que
|
||||||
|
- [80.465s–81.385s] reestrutura
|
||||||
|
- [81.385s–81.565s] a
|
||||||
|
- [81.565s–81.805s] mama.
|
||||||
|
- [81.805s–82.285s] Ela
|
||||||
|
- [82.285s–82.625s] trata
|
||||||
|
- [82.625s–82.805s] a
|
||||||
|
- [82.805s–83.285s] flacidez
|
||||||
|
- [83.285s–83.485s] de
|
||||||
|
- [83.485s–83.905s] pele,
|
||||||
|
- [84.225s–84.445s] o
|
||||||
|
- [84.445s–85.025s] excesso
|
||||||
|
- [85.025s–85.125s] de
|
||||||
|
- [85.125s–85.953s] pele...
|
||||||
|
|
||||||
|
## 000f4777 — 85.953s–87.487s
|
||||||
|
Origem: 114.615s–116.149s
|
||||||
|
|
||||||
|
Posso começar da mastopexia?
|
||||||
|
|
||||||
|
Emoções detectadas por segmento:
|
||||||
|
- [85.953s–87.138s] neu (0.90)
|
||||||
|
|
||||||
|
- [85.953s–86.218s] Posso
|
||||||
|
- [86.218s–86.438s] começar
|
||||||
|
- [86.438s–86.578s] da
|
||||||
|
- [86.578s–87.138s] mastopexia?
|
||||||
|
|
||||||
|
## 000f4779 — 87.487s–91.158s
|
||||||
|
Origem: 116.650s–120.320s
|
||||||
|
|
||||||
|
_Sem fala detectada._
|
||||||
|
|
||||||
|
## 000f477b — 91.158s–94.761s
|
||||||
|
Origem: 122.856s–126.460s
|
||||||
|
|
||||||
|
A mastopexia é a cirurgia que reestrutura a sua mama.
|
||||||
|
|
||||||
|
Emoções detectadas por segmento:
|
||||||
|
- [91.202s–94.402s] ang (0.83)
|
||||||
|
|
||||||
|
- [91.202s–91.682s] A
|
||||||
|
- [91.682s–92.162s] mastopexia
|
||||||
|
- [92.162s–92.302s] é
|
||||||
|
- [92.302s–92.382s] a
|
||||||
|
- [92.382s–92.782s] cirurgia
|
||||||
|
- [92.782s–92.942s] que
|
||||||
|
- [92.942s–93.782s] reestrutura
|
||||||
|
- [93.782s–93.962s] a
|
||||||
|
- [93.962s–94.042s] sua
|
||||||
|
- [94.042s–94.402s] mama.
|
||||||
|
|
||||||
|
## 000f477d — 94.761s–117.851s
|
||||||
|
Origem: 127.561s–150.650s
|
||||||
|
|
||||||
|
A mastopexia é a cirurgia que reestrutura a sua mama, tratando a flacidez, o excesso de pele e a queda das mamas. E associada a essa cirurgia, a gente insere próteses de silicone para dar um formato e um volume mais proporcional para o seu corpo. E associada a técnicas modernas, como sutiã interna e alça muscular, a gente consegue manter por mais tempo o formato e a sustentação das mamas.
|
||||||
|
|
||||||
|
Emoções detectadas por segmento:
|
||||||
|
- [94.861s–102.181s] ang (0.92)
|
||||||
|
- [102.361s–109.001s] ang (0.91)
|
||||||
|
- [109.001s–117.521s] ang (0.78)
|
||||||
|
|
||||||
|
- [94.861s–95.341s] A
|
||||||
|
- [95.341s–95.821s] mastopexia
|
||||||
|
- [95.821s–96.021s] é
|
||||||
|
- [96.021s–96.021s] a
|
||||||
|
- [96.021s–96.361s] cirurgia
|
||||||
|
- [96.361s–96.541s] que
|
||||||
|
- [96.541s–97.381s] reestrutura
|
||||||
|
- [97.381s–97.541s] a
|
||||||
|
- [97.541s–97.621s] sua
|
||||||
|
- [97.621s–97.961s] mama,
|
||||||
|
- [98.181s–98.721s] tratando
|
||||||
|
- [98.721s–98.821s] a
|
||||||
|
- [98.821s–99.401s] flacidez,
|
||||||
|
- [99.741s–99.901s] o
|
||||||
|
- [99.901s–100.341s] excesso
|
||||||
|
- [100.341s–100.441s] de
|
||||||
|
- [100.441s–100.821s] pele
|
||||||
|
- [100.821s–101.121s] e
|
||||||
|
- [101.121s–101.241s] a
|
||||||
|
- [101.241s–101.541s] queda
|
||||||
|
- [101.541s–101.781s] das
|
||||||
|
- [101.781s–102.181s] mamas.
|
||||||
|
- [102.361s–102.581s] E
|
||||||
|
- [102.581s–103.221s] associada
|
||||||
|
- [103.221s–103.341s] a
|
||||||
|
- [103.341s–103.421s] essa
|
||||||
|
- [103.421s–103.961s] cirurgia,
|
||||||
|
- [104.081s–104.101s] a
|
||||||
|
- [104.101s–104.241s] gente
|
||||||
|
- [104.241s–104.681s] insere
|
||||||
|
- [104.681s–105.221s] próteses
|
||||||
|
- [105.221s–105.321s] de
|
||||||
|
- [105.321s–105.741s] silicone
|
||||||
|
- [105.741s–106.021s] para
|
||||||
|
- [106.021s–106.161s] dar
|
||||||
|
- [106.161s–106.301s] um
|
||||||
|
- [106.301s–106.841s] formato
|
||||||
|
- [106.841s–106.941s] e
|
||||||
|
- [106.941s–107.041s] um
|
||||||
|
- [107.041s–107.441s] volume
|
||||||
|
- [107.441s–107.701s] mais
|
||||||
|
- [107.701s–108.261s] proporcional
|
||||||
|
- [108.261s–108.441s] para
|
||||||
|
- [108.441s–108.481s] o
|
||||||
|
- [108.481s–108.621s] seu
|
||||||
|
- [108.621s–109.001s] corpo.
|
||||||
|
- [109.001s–109.621s] E
|
||||||
|
- [109.621s–110.081s] associada
|
||||||
|
- [110.081s–110.241s] a
|
||||||
|
- [110.241s–110.561s] técnicas
|
||||||
|
- [110.561s–111.201s] modernas,
|
||||||
|
- [111.321s–111.381s] como
|
||||||
|
- [111.381s–111.821s] sutiã
|
||||||
|
- [111.821s–112.281s] interna
|
||||||
|
- [112.281s–112.361s] e
|
||||||
|
- [112.361s–112.701s] alça
|
||||||
|
- [112.701s–113.121s] muscular,
|
||||||
|
- [113.601s–113.661s] a
|
||||||
|
- [113.661s–113.781s] gente
|
||||||
|
- [113.781s–114.161s] consegue
|
||||||
|
- [114.161s–114.641s] manter
|
||||||
|
- [114.641s–114.881s] por
|
||||||
|
- [114.881s–115.161s] mais
|
||||||
|
- [115.161s–115.521s] tempo
|
||||||
|
- [115.521s–115.661s] o
|
||||||
|
- [115.661s–116.121s] formato
|
||||||
|
- [116.121s–116.221s] e
|
||||||
|
- [116.221s–116.321s] a
|
||||||
|
- [116.321s–116.861s] sustentação
|
||||||
|
- [116.861s–117.061s] das
|
||||||
|
- [117.061s–117.521s] mamas.
|
||||||
|
|
||||||
|
## 000f477f — 117.851s–120.287s
|
||||||
|
Origem: 150.951s–153.387s
|
||||||
|
|
||||||
|
Vou falar só a última frase, solta aqui.
|
||||||
|
|
||||||
|
Emoções detectadas por segmento:
|
||||||
|
- [117.851s–119.860s] neu (0.98)
|
||||||
|
|
||||||
|
- [117.851s–118.180s] Vou
|
||||||
|
- [118.180s–118.380s] falar
|
||||||
|
- [118.380s–118.520s] só
|
||||||
|
- [118.520s–118.600s] a
|
||||||
|
- [118.600s–118.760s] última
|
||||||
|
- [118.760s–119.240s] frase,
|
||||||
|
- [119.380s–119.640s] solta
|
||||||
|
- [119.640s–119.860s] aqui.
|
||||||
|
|
||||||
|
## 000f4781 — 120.287s–121.221s
|
||||||
|
Origem: 153.720s–154.655s
|
||||||
|
|
||||||
|
Não pegou?
|
||||||
|
|
||||||
|
Emoções detectadas por segmento:
|
||||||
|
- [120.287s–120.827s] neu (0.64)
|
||||||
|
|
||||||
|
- [120.287s–120.487s] Não
|
||||||
|
- [120.487s–120.827s] pegou?
|
||||||
|
|
||||||
|
## 000f4783 — 121.221s–123.690s
|
||||||
|
Origem: 156.356s–158.825s
|
||||||
|
|
||||||
|
_Sem fala detectada._
|
||||||
|
|
||||||
|
## 000f4785 — 123.690s–126.360s
|
||||||
|
Origem: 159.726s–162.396s
|
||||||
|
|
||||||
|
Aumenta.
|
||||||
|
|
||||||
|
Emoções detectadas por segmento:
|
||||||
|
- [123.814s–124.394s] hap (0.69)
|
||||||
|
|
||||||
|
- [123.814s–124.394s] Aumenta.
|
||||||
|
|
||||||
|
## 000f4787 — 126.360s–127.794s
|
||||||
|
Origem: 172.305s–173.740s
|
||||||
|
|
||||||
|
_Sem fala detectada._
|
||||||
|
|
||||||
|
## 000f4789 — 127.794s–129.429s
|
||||||
|
Origem: 174.741s–176.376s
|
||||||
|
|
||||||
|
_Sem fala detectada._
|
||||||
|
|
||||||
|
## 000f478b — 129.429s–131.164s
|
||||||
|
Origem: 180.547s–182.282s
|
||||||
|
|
||||||
|
E aí, continua?
|
||||||
|
|
||||||
|
Emoções detectadas por segmento:
|
||||||
|
- [129.429s–130.212s] hap (0.75)
|
||||||
|
|
||||||
|
- [129.429s–129.712s] E
|
||||||
|
- [129.712s–129.832s] aí,
|
||||||
|
- [129.912s–130.212s] continua?
|
||||||
|
|
||||||
|
## 000f478d — 131.164s–136.403s
|
||||||
|
Origem: 183.784s–189.022s
|
||||||
|
|
||||||
|
Então é devolver forma e sustentação de um jeito que pareça que sempre foi assim.
|
||||||
|
|
||||||
|
Emoções detectadas por segmento:
|
||||||
|
- [131.481s–136.021s] hap (0.56)
|
||||||
|
|
||||||
|
- [131.481s–132.061s] Então
|
||||||
|
- [132.061s–132.201s] é
|
||||||
|
- [132.201s–132.621s] devolver
|
||||||
|
- [132.621s–133.061s] forma
|
||||||
|
- [133.061s–133.181s] e
|
||||||
|
- [133.181s–133.881s] sustentação
|
||||||
|
- [133.881s–134.381s] de
|
||||||
|
- [134.381s–134.441s] um
|
||||||
|
- [134.441s–134.621s] jeito
|
||||||
|
- [134.621s–134.761s] que
|
||||||
|
- [134.761s–135.081s] pareça
|
||||||
|
- [135.081s–135.221s] que
|
||||||
|
- [135.221s–135.501s] sempre
|
||||||
|
- [135.501s–135.741s] foi
|
||||||
|
- [135.741s–136.021s] assim.
|
||||||
|
|
||||||
|
## 000f478f — 136.403s–141.274s
|
||||||
|
Origem: 190.123s–194.995s
|
||||||
|
|
||||||
|
Então é devolver forma e sustentação de um jeito que pareça que sempre foi assim.
|
||||||
|
|
||||||
|
Emoções detectadas por segmento:
|
||||||
|
- [136.403s–140.900s] ang (0.77)
|
||||||
|
|
||||||
|
- [136.403s–136.720s] Então
|
||||||
|
- [136.720s–136.840s] é
|
||||||
|
- [136.840s–137.360s] devolver
|
||||||
|
- [137.360s–137.860s] forma
|
||||||
|
- [137.860s–137.940s] e
|
||||||
|
- [137.940s–138.680s] sustentação
|
||||||
|
- [138.680s–138.880s] de
|
||||||
|
- [138.880s–138.980s] um
|
||||||
|
- [138.980s–139.220s] jeito
|
||||||
|
- [139.220s–139.420s] que
|
||||||
|
- [139.420s–139.860s] pareça
|
||||||
|
- [139.860s–140.020s] que
|
||||||
|
- [140.020s–140.340s] sempre
|
||||||
|
- [140.340s–140.580s] foi
|
||||||
|
- [140.580s–140.900s] assim.
|
||||||
|
|
||||||
|
## 000f4791 — 141.274s–141.475s
|
||||||
|
Origem: 196.463s–196.663s
|
||||||
|
|
||||||
|
_Sem fala detectada._
|
||||||
@@ -137,7 +137,14 @@ export function getDiscoveryTools(bridgeOptions: BridgeOptions) {
|
|||||||
end: __ticksToSeconds(clip.end.ticks),
|
end: __ticksToSeconds(clip.end.ticks),
|
||||||
inPoint: __ticksToSeconds(clip.inPoint.ticks),
|
inPoint: __ticksToSeconds(clip.inPoint.ticks),
|
||||||
outPoint: __ticksToSeconds(clip.outPoint.ticks),
|
outPoint: __ticksToSeconds(clip.outPoint.ticks),
|
||||||
duration: __ticksToSeconds(clip.duration.ticks)
|
duration: __ticksToSeconds(clip.duration.ticks),
|
||||||
|
sourceFile: clip.projectItem && clip.projectItem.getMediaPath
|
||||||
|
? String(clip.projectItem.getMediaPath() || "")
|
||||||
|
: "",
|
||||||
|
sourceProjectItemId: clip.projectItem ? String(clip.projectItem.nodeId || "") : "",
|
||||||
|
sourceOffline: clip.projectItem && clip.projectItem.isOffline
|
||||||
|
? !!clip.projectItem.isOffline()
|
||||||
|
: false
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
videoTracks.push({
|
videoTracks.push({
|
||||||
|
|||||||
Reference in New Issue
Block a user