docs(skill): corte deve deixar folga na borda que encosta em fala mantida

Cortes escritos rente ao timestamp da palavra soavam secos (relatado no
projeto Mastopexia) — o critério e o prompt do modelo local mandavam cobrir
a frase inteira sem orientar a borda que toca fala mantida. Adiciona a regra
de recuar ~0,15-0,25s nas duas pontas quando o corte encosta em conteúdo
que fica, tanto no skill (06-texto-corte-marcador.md) quanto no prompt
embutido do Ollama (llm_local.py) — pra não precisar ajustar na mão de novo.

Também registra em 05_EXPERIENCIAS.md/09_MANUTENCAO.md a dívida de
resolve_actions não tolerar margem quando zoom/marker encosta na borda
de um corte (contornado manualmente, não corrigido em código ainda), e
atualiza a lista de dívidas abertas (etapa 6/offset de whisper já resolvidos
nesta sessão).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
João Henrique
2026-08-21 18:31:27 -04:00
co-authored by Claude Sonnet 5
parent 7b5aed79ee
commit fd791e116a
4 changed files with 89 additions and 23 deletions
@@ -53,6 +53,34 @@ Acima de 3s a pausa deixa de contar como ênfase por construção — medido em
material real, lacunas de 6–9s rankeavam como os momentos mais enfáticos da material real, lacunas de 6–9s rankeavam como os momentos mais enfáticos da
gravação só porque a escala saturava. gravação só porque a escala saturava.
### Nunca corte rente à palavra — deixe uma folga
Um `cut` cujo `start`/`end` cai exatamente no timestamp da palavra (fim da
última palavra mantida = início do corte) produz um corte seco: a palavra é
engolida antes de terminar de soar, e a fala seguinte começa sem nenhum ar.
Isso é diferente de cortar a pausa curta (que seria apagar a própria ênfase,
proibido acima) — aqui a pausa **já existe** entre o fim de um bloco mantido
e o início do próximo, e o corte está comendo justamente essa margem.
Ao escrever a borda de um `cut` que encosta em fala mantida (não em silêncio
puro), recue **~0,15–0,25s** para dentro do próprio corte, nos dois lados:
- o `start` do corte fica ~0,2s **depois** do fim real da última palavra
mantida;
- o `end` do corte fica ~0,2s **antes** do início real da próxima palavra
mantida.
Caso real (projeto Mastopexia): um corte escrito rente (`10.77 → 95.50`,
exatamente nos timestamps de palavra) soava abrupto nas duas emendas.
Recuado para `10.97 → 95.30`, cada lado ganhou ~0,2s de respiro sem alterar
o que é dito — e não empurra o próximo zoom/marcador contra a borda do corte
(ver `05-zoom.md` sobre janelas encostadas em corte).
Isso vale também para o **início e o fim do vídeo**: ar morto antes da
primeira palavra e depois da última também leva `cut`, com a mesma folga —
não é "silêncio dentro da fala" (isso é `remove_media_silence`), é o mesmo
corte de tomada/bastidor que você já está decidindo.
### O que continua NÃO sendo seu trabalho ### O que continua NÃO sendo seu trabalho
| Tarefa | Ferramenta | Por quê | | Tarefa | Ferramenta | Por quê |
+45
View File
@@ -44,6 +44,7 @@ que merece entrada.
| 24 | 2026-08-19 | `admin/test_models_api.py` existia mas estava fora de `testpaths` — 13 testes que nunca rodaram | `resolvido` | | 24 | 2026-08-19 | `admin/test_models_api.py` existia mas estava fora de `testpaths` — 13 testes que nunca rodaram | `resolvido` |
| 25 | 2026-08-20 | `admin/api/shared.py` apontava para `admin/code` (inexistente) após a divisão — install editável mascarou o bug em toda validação anterior | `resolvido` | | 25 | 2026-08-20 | `admin/api/shared.py` apontava para `admin/code` (inexistente) após a divisão — install editável mascarou o bug em toda validação anterior | `resolvido` |
| 26 | 2026-08-21 | `generate_voice_script` (IA local/Ollama) caía com "Falha ao gerar roteiro por IA local" — prompt embutia a timeline inteira (47k tokens) e estourava `num_ctx`; e `response.json()` de conexão caída escapava como `JSONDecodeError` | `resolvido` | | 26 | 2026-08-21 | `generate_voice_script` (IA local/Ollama) caía com "Falha ao gerar roteiro por IA local" — prompt embutia a timeline inteira (47k tokens) e estourava `num_ctx`; e `response.json()` de conexão caída escapava como `JSONDecodeError` | `resolvido` |
| 27 | 2026-08-21 | Cortes escritos rente ao timestamp da palavra soam secos — critério da skill e prompt do modelo local não instruíam folga na borda | `resolvido` |
> Mantenha o índice acima sempre sincronizado com as entradas mais recentes. > Mantenha o índice acima sempre sincronizado com as entradas mais recentes.
@@ -1414,3 +1415,47 @@ o outro; percentil entrega um punhado útil nos dois casos.
> de análise cru no prompt; projetar só o que a decisão usa. E qualquer parse > de análise cru no prompt; projetar só o que a decisão usa. E qualquer parse
> de resposta de servidor local deve tratar body vazio/quebrado como erro de > de resposta de servidor local deve tratar body vazio/quebrado como erro de
> transporte, não como sucesso mudo. > transporte, não como sucesso mudo.
---
### 2026-08-21 — Cortes escritos rente ao timestamp da palavra soam secos
- **Sintoma:** usuário revisou o corte final (projeto Mastopexia) e reportou
"os cortes estão muito secos, principalmente no final de frase — falta um
tempinho a mais pra concluir as palavras". Também notou que o ar morto
antes da primeira fala do vídeo não tinha sido cortado.
- **Causa:** o critério `06-texto-corte-marcador.md` (e o prompt embutido do
modelo local em `fcpxml/llm_local.py`) instruíam cobrir a frase inteira
(`start..end = início..fim da frase`) ao escrever um `cut`, sem nenhuma
orientação sobre a borda que encosta em fala **mantida** (não em silêncio
puro). Um `cut` com `start` exatamente no fim da última palavra mantida
engole essa palavra antes dela terminar de soar; um `cut` com `end` no
início exato da próxima engole o ataque da fala seguinte. É um problema
diferente de cortar a pausa curta (proibido, é a própria ênfase) — aqui a
pausa natural entre os blocos já existe, e o corte estava comendo essa
margem sozinho.
- **Correção:**
- `06-texto-corte-marcador.md` ganhou a seção "Nunca corte rente à
palavra — deixe uma folga": recuar `start`/`end` do corte em ~0,15–0,25s
para dentro do próprio corte nas bordas que tocam fala mantida (não em
silêncio puro), incluindo o início/fim do vídeo.
- `fcpxml/llm_local.py::_SYSTEM_PROMPT` (item 4) recebeu a mesma
instrução, para o modelo local gerar decisões já com a folga.
- **Validação manual:** reaplicado no projeto Mastopexia real —
`10.77 → 95.50` (rente) virou `10.97 → 95.30` (folga de ~0,2s nas duas
pontas), e as 4 emendas seguintes receberam o mesmo tratamento; zoom/texto/
marcador continuaram longe o suficiente da nova borda do corte — a folga
também evita o problema relacionado (não corrigido em código, só
contornado manualmente nesta sessão): um `zoom`/`marker` cuja borda cai
exatamente em cima do início/fim de um `cut` é descartado por
`resolve_actions` como "apontando para material cortado", mesmo quando a
intenção era ficar bem ao lado. Vale registrar como dívida: `resolve_actions`
poderia tolerar uma margem de meio-frame antes de considerar a ação "dentro"
do corte.
- **Estado:** `resolvido`
> **Aprendizado:** "cobrir a frase inteira" não é a instrução completa para
> um corte — a frase que **sobra** ao lado do corte também precisa de uma
> borda que respire. Regra prática: só cortar rente ao timestamp quando a
> borda encosta em silêncio real (`gap_before` grande) ou em conteúdo que
> também será descartado; encostando em fala mantida, sempre recuar.
+15 -22
View File
@@ -7,7 +7,7 @@ Este é o documento de rota. Os outros descrevem o que **é**; este diz o que
**fazer** e por onde começar quando chega uma implementação, uma melhoria ou **fazer** e por onde começar quando chega uma implementação, uma melhoria ou
uma correção. uma correção.
Última varredura: 2026-08-19 · 1.466 testes · lint zerado Última varredura: 2026-08-21 · 1.498 testes · lint zerado (fora de server.py/ai_edit.py/llm_local.py, pré-existentes)
--- ---
@@ -33,40 +33,33 @@ vai para `fcpxml/`.
Ordenado por quanto atrapalha, não por esforço. Ordenado por quanto atrapalha, não por esforço.
### 2.1 A etapa 6 ignora a revisão de ênfases ### 2.1 `resolve_actions` não tolera margem no encosto de zoom/marker contra um corte
O usuário lapida as frases na etapa 5, o `_phrase_review.json` é gravado — e a Um `zoom`/`marker` cuja borda cai exatamente em cima do `start`/`end` de um
etapa 6 ainda processa como antes. Falta ligar: **zoom e legenda dinâmica só `cut` é descartado como "apontando para material cortado" — mesmo quando a
nas frases de ênfase, legenda comum no resto**. É a continuação natural do intenção era ficar bem ao lado. Contornado manualmente no projeto Mastopexia
trabalho da etapa 5 e o item mais valioso da lista. (recuando as bordas na mão); a correção estrutural é dar a `resolve_actions`
→ `MacApp/Sources/WizardView.swift` (`finalizeProcessing`), `admin/api/subtitles.py`, uma margem de tolerância (meio frame) antes de considerar uma ação "dentro"
`fcpxml/phrase_review.py` (`emphasis_spans` já é produzido e ninguém consome). do corte. → `fcpxml/voice_actions.py` (`resolve_actions`/`shift_after_cuts`),
`05_EXPERIENCIAS.md` #27.
### 2.2 Offset de ~400 ms no timing por palavra ### 2.2 `MacApp/` não tem teste automatizado
O faster-whisper sem alinhamento forçado erra o início de cada palavra em
~0,4 s. Isso desloca zoom, corte e `gap_before` de uma vez. Há paliativo
aplicado por projeto; a correção estrutural é ligar o **WhisperX** (ou
alinhamento equivalente) em `transcribe.py`, o que levaria o erro para ~30 ms.
Custo real: regerar todos os `_transcript.json` e `_voice_timeline.json`
existentes. → `05_EXPERIENCIAS.md` #14, estado `parcialmente resolvido`.
### 2.3 `MacApp/` não tem teste automatizado
5.500 linhas de Swift sem uma asserção. A rede hoje é o harness manual (§4) e 5.500 linhas de Swift sem uma asserção. A rede hoje é o harness manual (§4) e
o olho do usuário. Não é para sair criando suíte de UI — mas lógica pura que o olho do usuário. Não é para sair criando suíte de UI — mas lógica pura que
foi parar na camada de tela (cálculo de trim, mapeamento de tempo) deveria foi parar na camada de tela (cálculo de trim, mapeamento de tempo) deveria
descer para o Python, onde já existe rede. descer para o Python, onde já existe rede.
### 2.4 `admin/` fica fora do lint ### 2.3 `admin/` fica fora do lint
`run_after_fix.sh` roda o ruff de dentro de `code/`, então `admin/` — 1.751 `run_after_fix.sh` roda o ruff de dentro de `code/`, então `admin/` — 1.751
linhas de código que o app depende para funcionar — nunca é verificado. linhas de código que o app depende para funcionar — nunca é verificado.
Incluir mexe no gate, então é decisão consciente, não esquecimento. Incluir mexe no gate, então é decisão consciente, não esquecimento.
### 2.5 Confirmações visuais pendentes no FCP ### 2.4 Confirmações visuais pendentes no FCP
Várias entradas do `05_EXPERIENCIAS.md` estão marcadas como resolvidas *no XML* Várias entradas do `05_EXPERIENCIAS.md` estão marcadas como resolvidas *no XML*
— testes verdes, DTD válido — mas **pendentes de importação real no Final Cut**. — testes verdes, DTD válido — mas **pendentes de importação real no Final Cut**.
XML válido não é o mesmo que XML que renderiza como o esperado. Ao mexer em XML válido não é o mesmo que XML que renderiza como o esperado. Ao mexer em
legenda, zoom ou keyframe, a confirmação final é abrir no FCP. legenda, zoom ou keyframe, a confirmação final é abrir no FCP.
### 2.6 Submódulo `WHISPERX` com conteúdo modificado e não commitado ### 2.5 Submódulo `WHISPERX` com conteúdo modificado e não commitado
Está fora dos commits de propósito, porque ninguém verificou o que mudou lá Está fora dos commits de propósito, porque ninguém verificou o que mudou lá
dentro. Precisa ser olhado e resolvido — ou commitado, ou revertido. dentro. Precisa ser olhado e resolvido — ou commitado, ou revertido.
@@ -98,7 +91,7 @@ quanto arquivo gigante.
## 4. Checklist antes de dar algo por pronto ## 4. Checklist antes de dar algo por pronto
```bash ```bash
cd code && ./Engine/run_after_fix.sh # lint zerado + 1.466 testes cd code && ./Engine/run_after_fix.sh # lint zerado + 1.498 testes
admin/run_app.command # se mexeu no app (padrão de revisão) admin/run_app.command # se mexeu no app (padrão de revisão)
``` ```
@@ -125,7 +118,7 @@ E, além do script:
| FCP recusa o arquivo ao importar | `id` inválido, ordem de filhos, timebase | `writer/validation.py`, `dtd.py` | | FCP recusa o arquivo ao importar | `id` inválido, ordem de filhos, timebase | `writer/validation.py`, `dtd.py` |
| Título importa mas não aparece | Template/uid Motion que não resolve | `writer/titles.py` | | Título importa mas não aparece | Template/uid Motion que não resolve | `writer/titles.py` |
| Corte no lugar errado | Tempo pós-corte usado como se fosse original | `voice_actions.py` (`shift_after_cuts`) | | Corte no lugar errado | Tempo pós-corte usado como se fosse original | `voice_actions.py` (`shift_after_cuts`) |
| Zoom no lugar errado | Idem, ou offset de timing do Whisper | §2.2 | | Zoom/marker sumindo perto de um corte | Borda encostando exatamente no `cut` | §2.1 |
| Legenda sobrepondo | Layout ou conteúdo antigo no arquivo | `collision.py`, `text_layout.py` | | Legenda sobrepondo | Layout ou conteúdo antigo no arquivo | `collision.py`, `text_layout.py` |
| "Ênfase" apontando para palavra à toa | Falta renormalizar após o corte | `refine_voice_timeline` | | "Ênfase" apontando para palavra à toa | Falta renormalizar após o corte | `refine_voice_timeline` |
| App diz que falta librosa/pyannote | `uv run` com cwd errado | `PythonBridge.swift` (§3 do doc 08) | | App diz que falta librosa/pyannote | `uv run` com cwd errado | `PythonBridge.swift` (§3 do doc 08) |
+1 -1
View File
@@ -63,7 +63,7 @@ Regras (siga rigorosamente):
3. ESCOLHER A MELHOR TOMADA de cada frase quando há repetições: mantenha a mais limpa e corte as outras (cut cobrindo a frase inteira). 3. ESCOLHER A MELHOR TOMADA de cada frase quando há repetições: mantenha a mais limpa e corte as outras (cut cobrindo a frase inteira).
4. CORTE (kind "cut"): para REMOVER uma frase, cubra ela inteira (start..end = início..fim da frase). Para APARAR só uma hesitação no começo ou fim, corte só da borda até a palavra (corte de meia frase é ambíguo — passe de 60% e apaga a linha toda). Nunca corte o silêncio entre falas. 4. CORTE (kind "cut"): para REMOVER uma frase, cubra ela inteira (start..end = início..fim da frase). Para APARAR só uma hesitação no começo ou fim, corte só da borda até a palavra (corte de meia frase é ambíguo — passe de 60% e apaga a linha toda). Nunca corte o silêncio entre falas. Quando a borda do corte encosta em fala mantida (não em silêncio puro), recue ~0,15-0,25s para dentro do corte nos dois lados — start ~0,2s DEPOIS do fim real da última palavra mantida, end ~0,2s ANTES do início real da próxima palavra mantida — senão o corte soa seco, engolindo a palavra antes de terminar de soar. Isso vale também pro início/fim do vídeo (ar morto antes da primeira palavra e depois da última).
5. ZOOM (kind "zoom"): só em palavra de CONTEÚDO bem enfatizada (emphasis alto, não artigo). params.scale entre 1.0 e 3.0 (padrão 1.3 se omitido). Posicione em torno da palavra, segurando até o fim da frase. 5. ZOOM (kind "zoom"): só em palavra de CONTEÚDO bem enfatizada (emphasis alto, não artigo). params.scale entre 1.0 e 3.0 (padrão 1.3 se omitido). Posicione em torno da palavra, segurando até o fim da frase.