Trabalho da branch feat/revisao-enfases: pipeline de edição por voz ganha alinhamento forçado (whisperx), roteirização por LLM local (Ollama), e a etapa 5 (revisão de frases) passa a refletir de verdade o que é aplicado. - generate_subtitles_by_emphasis: legenda comum cobre o clipe inteiro, legenda dinâmica só nas frases de ênfase, e a comum é desativada (enabled="0") onde a dinâmica cobre, em vez de nunca ser gerada ali. - validate_subtitle_layout ignora títulos com enabled="0" — corrige falso positivo de colisão contra o que está desativado no lugar dele. - Corrige zoom/marcador sendo descartado quando a borda encosta exatamente no início de um corte. - Etapa 5 do Assistente: recarrega quando as decisões da IA mudam (com fresh=true, ignorando a revisão salva antiga) — resolve a dessincronia entre "ativa" na tela e o que já foi cortado no FCPXML. - Etapa "Processar" reaplica as decisões da revisão (_phrase_actions.json) antes da cadeia de remoção de silêncio/legendas — antes, desativar uma frase na etapa 5 não tinha efeito nenhum no vídeo final. - Etapa "Concluído" fundida em "Processar" — abrir no Final Cut/Finder aparece assim que termina, sem slide extra. - Palavra clicável na etapa 5 agora funciona como toggle (clique de novo desfaz) e mostra a própria ênfase (sublinhado colorido + peso da fonte). - fcpxml/forced_align.py, fcpxml/llm_local.py, ai_edit.py: alinhamento fonético via whisperx e roteirização local via Ollama/Gemma. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
205 lines
10 KiB
Markdown
205 lines
10 KiB
Markdown
# 08 — O app macOS (`MacApp/`) e o Assistente
|
||
|
||
> **Escopo:** O app SwiftUI e o Assistente: build, telas, ponte e a etapa 5.
|
||
> **Não cobre:** Engine Python (→ 02) · ferramentas MCP (→ 03)
|
||
|
||
O app SwiftUI é como o usuário opera o sistema sem abrir terminal nem conversar
|
||
com uma IA. São ~5.500 linhas em `MacApp/Sources/`, e ele **não tem lógica de
|
||
edição**: tudo que ele faz é montar argumentos, chamar a ponte Python e mostrar
|
||
o resultado.
|
||
|
||
Última varredura: 2026-08-19
|
||
|
||
---
|
||
|
||
## 1. Como o app é construído — leia antes de mexer
|
||
|
||
**Não existe `.xcodeproj` nem `Package.swift`.** O app é compilado invocando o
|
||
`swiftc` direto sobre `MacApp/Sources/*.swift`:
|
||
|
||
```bash
|
||
cd code && ./MacApp/build_app.sh # compila e monta o .app
|
||
admin/run_app.command # compila, fecha a instância antiga e abre (padrão de revisão)
|
||
```
|
||
|
||
Consequências práticas, todas já sentidas:
|
||
|
||
- **Arquivo novo em `Sources/` entra sozinho** no build. Não há lista de alvos.
|
||
- **Não dá para adicionar dependência SPM** sem antes migrar o build inteiro.
|
||
- **Compilar não prova que roda.** Componentes SwiftUI que embrulham classes
|
||
Objective-C podem falhar só em tempo de execução, ao abrir a tela. Foi o que
|
||
aconteceu com `VideoPlayer` (AVKit): compilava limpo e abortava ao abrir a
|
||
etapa 5 (`05_EXPERIENCIAS.md` #22). Por isso a regra: **alterou a interface,
|
||
abra a tela de fato.**
|
||
|
||
### Testando uma tela sem navegar o app inteiro
|
||
|
||
Um harness de vinte linhas compila os mesmos fontes com um `@main` próprio que
|
||
monta só a tela em questão. Reproduz crash de runtime em segundos:
|
||
|
||
```bash
|
||
swiftc -parse-as-library -sdk "$(xcrun --sdk macosx --show-sdk-path)" \
|
||
-target arm64-apple-macosx26.0 \
|
||
MacApp/Sources/PhraseReviewView.swift MacApp/Sources/PhraseReviewModel.swift \
|
||
MacApp/Sources/TimelineTracksView.swift MacApp/Sources/Models.swift \
|
||
MacApp/Sources/PythonBridge.swift /tmp/HarnessMain.swift -o /tmp/harness
|
||
```
|
||
|
||
O `@main` do harness carrega a tela, imprime o que interessa e chama
|
||
`NSApplication.shared.terminate` — dá para afirmar "abriu e funcionou" sem
|
||
depender de screenshot.
|
||
|
||
---
|
||
|
||
## 2. Estrutura das telas
|
||
|
||
| Arquivo | Linhas | Papel |
|
||
|---------|-------:|-------|
|
||
| `WizardView.swift` | 808 | **O Assistente** — fluxo guiado de 7 etapas |
|
||
| `TranscriptionView.swift` | 843 | Transcrição avulsa e processamento em lote |
|
||
| `ModelDownloadView.swift` | 545 | Catálogo e download de modelos Whisper |
|
||
| `CaptionsView.swift` | 545 | Legendas dinâmicas: estilo + preview ao vivo |
|
||
| `TimelineTracksView.swift` | 506 | Timeline com trilhas, zoom e playhead |
|
||
| `PhraseReviewModel.swift` | 429 | Estado da etapa 5: frases, player, zooms |
|
||
| `PhraseReviewView.swift` | 413 | Etapa 5: preview + inspector de frases |
|
||
| `VoiceAnalysisView.swift` | 322 | Parâmetros do motor de ênfase |
|
||
| `ProjectView.swift` | 293 | Inspeção do `.fcpxml` |
|
||
| `Models.swift` | 274 | Espelhos Swift do JSON da ponte |
|
||
| `PythonBridge.swift` | 230 | **A ponte** — ver seção 3 |
|
||
| `SubtitlePreviewView.swift` | 218 | Preview 9:16 das legendas |
|
||
| `App.swift` | 69 | `NavigationSplitView` e as abas |
|
||
|
||
Abas (`ActiveTab` em `App.swift`): Assistente · Projeto · Legendas · Análise de
|
||
Voz · Modelos · Sobre. As cinco últimas são "Avançado" — atalhos para operações
|
||
soltas. O Assistente é o caminho principal.
|
||
|
||
---
|
||
|
||
## 3. `PythonBridge.swift` — como o app fala com o Python
|
||
|
||
O app lança `admin/models_api.py` como **subprocesso**, passando o comando e um
|
||
JSON como `argv`, e lê **JSON-lines** no stdout.
|
||
|
||
```swift
|
||
PythonBridge.call(command: "build_phrase_review",
|
||
arguments: ["voice_timeline": path]) { result, error in … }
|
||
```
|
||
|
||
Dois pontos que já causaram problema e estão resolvidos no código — não os
|
||
desfaça sem entender:
|
||
|
||
- **`uv run` precisa rodar com cwd em `code/`.** O `uv` escolhe o ambiente pelo
|
||
diretório do processo, não pelo caminho do script. Rodar da raiz fazia o `uv`
|
||
criar um segundo `.venv` vazio e ignorar tudo que estava instalado em
|
||
`code/.venv` — librosa e pyannote instalavam com sucesso e o app insistia que
|
||
faltavam.
|
||
- **`scriptURL` procura `admin/models_api.py`** subindo diretórios a partir do
|
||
cwd, do bundle e do home. É o que faz o app funcionar tanto rodando do Xcode
|
||
quanto do `.app` montado.
|
||
- **O `sys.path` que torna `fcpxml`/`server_tools` importáveis dentro de
|
||
`admin/api/` mora só em `admin/api/__init__.py`.** Não copie esse cálculo
|
||
para um módulo de comando individual — foi exatamente essa cópia,
|
||
desatualizada em um nível de diretório, que quebrou toda ação que passa por
|
||
`server` (`05_EXPERIENCIAS.md` #25). E não confie em "testei com `uv run` e
|
||
funcionou": esse comando roda no mesmo venv com install editável que
|
||
mascara esse tipo de erro. O teste que pega de verdade é
|
||
`tests/test_models_api.py::TestCodeDirResolution`.
|
||
|
||
Para adicionar um comando: função em `admin/api/<assunto>.py`, registro na
|
||
tabela de `admin/models_api.py`, e `PythonBridge.call` do lado Swift. Os 37
|
||
comandos e seus formatos estão documentados no docstring de `models_api.py`.
|
||
|
||
---
|
||
|
||
## 4. O Assistente — as 7 etapas
|
||
|
||
`WizardStep` (`WizardView.swift`) é um enum sequencial; `canAdvance` decide
|
||
quando o botão "Continuar" libera.
|
||
|
||
| # | Etapa | O que acontece | Comando da ponte |
|
||
|---|-------|----------------|------------------|
|
||
| 1 | Projeto | Escolhe a pasta de saída e o `.fcpxml` | `project_config` |
|
||
| 2 | Transcrever | Transcreve toda a mídia do projeto | `transcribe` |
|
||
| 3 | Analisar voz | Mede ênfase, locutores, emoção | `analyze_voice` |
|
||
| 4 | Decisões da IA | Copia para o chat **ou** gera por IA local (Ollama/Gemma 3), aplica | `apply_voice_actions` / `generate_voice_script` |
|
||
| 5 | **Revisar ênfases** | Lapida frase a frase — ver seção 5 | `build_phrase_review` / `save_phrase_review` |
|
||
| 6 | Processar | Silêncios, preenchimento, legendas | vários, em cadeia |
|
||
| 7 | Concluído | Abre no FCP ou mostra no Finder | — |
|
||
|
||
**A etapa 4 tem duas saídas:**
|
||
|
||
- **Manual (chat):** o app monta o pedido pronto no clipboard (skill `editar-por-voz`) e recebe o JSON de volta — o julgamento de qual tomada usar e onde dar zoom fica com a IA numa conversa.
|
||
- **Automática (IA local):** botão "Gerar roteiro por IA local (Ollama/Gemma 3)". Ele manda a *voice timeline inteira* (o arquivo) junto com o brief para um modelo local (Ollama), que decide cortes/zooms/textos de uma vez, devolve o roteiro legível + o JSON de ações e já aplica no FCPXML (non-destructive). Não precisa sair do app nem colar nada. O modelo é escolhido num **picker que lista os modelos instalados no Ollama** (populado via `list_ollama_models` quando a etapa abre); se o Ollama estiver fora do ar, cai para um campo de texto livre. Troque para `llama3` etc. se tiver outro modelo. Requer o Ollama rodando em `localhost:11434`.
|
||
|
||
**Etapa 1 — armadilha registrada:** não escolha como "o projeto" um arquivo já
|
||
gerado pelo fluxo (`_voice_edit`, `_silence_removed`, …). Os cortes de voz
|
||
assumem timestamps da mídia **original**; reaplicá-los sobre um arquivo já
|
||
cortado desloca tudo em silêncio. O wizard avisa (`looksLikeGeneratedFile`).
|
||
|
||
---
|
||
|
||
## 5. Etapa 5 — a sala de edição
|
||
|
||
Única tela que ocupa a janela toda: o corpo do wizard é uma coluna de 640pt, e
|
||
essa etapa escapa dela porque precisa da largura (`step == .revisar` em
|
||
`WizardView.body`).
|
||
|
||
```
|
||
┌────────────────────────────┬──────────────┐
|
||
│ Preview (AVPlayerLayer) │ Inspector │
|
||
│ enquadrado no formato │ de frases │
|
||
│ de entrega do projeto │ │
|
||
├────────────────────────────┴──────────────┤
|
||
│ Timeline: 6 trilhas, zoom, playhead │
|
||
└───────────────────────────────────────────┘
|
||
```
|
||
|
||
**Trilhas:** zooms · frases · energia por palavra · emoção · locutor ·
|
||
roteiro/bastidor. Todas desenhadas sobre o mesmo eixo de tempo, com uma coluna
|
||
fixa à esquerda nomeando cada uma.
|
||
|
||
**O que o usuário decide por frase:** nível de ênfase (0–3), ativo/inativo,
|
||
texto, roteiro/bastidor e o trim das pontas. O trim anda em **fronteira de
|
||
palavra** — cortar é apontar para uma palavra, arrastando a borda do bloco ou
|
||
clicando na palavra no inspector.
|
||
|
||
**Zoom manual:** arrastar na timeline marca um trecho; botão direito cria um
|
||
zoom nele. O zoom guarda **só o quando** — escala e ramp vêm das configurações
|
||
de Análise de Voz no momento do render, então mudar lá restiliza todos.
|
||
|
||
**Decisões de implementação que parecem detalhe e não são:**
|
||
|
||
- **O preview não renderiza nada.** Ele toca a mídia original e *pula* os
|
||
trechos removidos. Renderizar para conferir um toggle poria minutos entre a
|
||
decisão e o resultado. O observador roda a 60 Hz porque o período dele é
|
||
exatamente quanto de material cortado dá para ouvir antes do pulo.
|
||
- **O enquadramento é o do projeto, não o da mídia.** As gravações são
|
||
horizontais e a entrega é vertical; o app lê o formato do `.fcpxml`
|
||
(`inspect`) e mostra o corte central aproximado, com um selo para alternar
|
||
para a mídia original. O enquadramento real de cada clipe vem do FCP — o
|
||
preview é aproximação, e o selo diz isso.
|
||
- **Nada é processado aqui.** "Continuar" grava o `_phrase_review.json` e o
|
||
`_phrase_actions.json` derivado dele. A geração é da etapa 6.
|
||
- **A revisão é sempre remontada da análise atual**, com as decisões salvas
|
||
reaplicadas por cima (`merge_saved_decisions`). Assim refazer a análise de voz
|
||
melhora a tela em vez de ficar mascarado por uma cópia velha; uma decisão cuja
|
||
frase se moveu mais de 0,25 s é descartada em vez de colar na frase errada.
|
||
|
||
---
|
||
|
||
## 6. Estado atual e o que falta
|
||
|
||
**Funciona e foi verificado:** carga das frases com decisões da IA, as 6
|
||
trilhas, seleção sincronizada nos três painéis, trim por palavra, zoom manual,
|
||
reprodução parando no ponto exato (erro de 0 ms medido), pulo dos trechos
|
||
removidos, enquadramento vertical, gravação ao avançar.
|
||
|
||
**Ainda em aberto:**
|
||
|
||
- **A etapa 6 não consome o `_phrase_review.json`.** A ligação — zoom e legenda
|
||
dinâmica só nas frases de ênfase, legenda comum no resto — é a próxima tarefa.
|
||
- **`MacApp/` não tem teste automatizado.** A rede é o harness da seção 1 e o
|
||
olho do usuário. Toda mudança de interface precisa ser aberta de fato.
|
||
- **O preview aproxima o reenquadramento vertical** pelo corte central; se os
|
||
clipes forem reposicionados no FCP, diverge.
|