#!/usr/bin/env python3
"""Editor de vídeo · projeto-modelo HyperFrames.

Lê o projeto.json e escreve o index.html (e uma sub-composição por peça em compositions/): o vídeo cortado,
a cena de cada bloco (tela cheia, prova, moldura, tela verde), a legenda, as animações do catálogo, as cenas
sob medida e o texto atrás da pessoa, tudo no tempo das palavras. Cada coisa ganha uma faixa com nome na
linha do tempo do estúdio do HyperFrames.

  python3 build.py palavras [de] [ate]      palavras do corte com o tempo, os cortes e os blocos
  python3 build.py corte                    index.html do corte inteiro só com o vídeo e a legenda (revisar o corte)
  python3 build.py amostra <inicio> <fim>   index.html só desse trecho (confira 6 a 15 s antes do vídeo todo)
  python3 build.py completo                 index.html do vídeo inteiro (com o cartão final, se ligado)
  python3 build.py estudio [--porta N] [--sem-abrir]   sobe o estúdio (linha do tempo por camada) e diz a URL
  python3 build.py estudio --parar          derruba o estúdio deste projeto (confere que a porta fechou)
  python3 build.py hf <comando> [...]       roda o HyperFrames na versão fixa do package.json (lint, check,
                                            render, snapshot...): o mesmo que o estúdio usa
  python3 build.py novo <PASTA> [--edit E]  (rodado no modelo dentro da skill) copia o modelo para PASTA e anota
                                            a skill no projeto.json
  python3 build.py atualizar <PASTA>        (rodado no modelo dentro da skill) leva o projeto PASTA para o modelo
                                            novo: build.py, ferramentas e kits; projeto.json, assets, cenas e
                                            renders ficam; diz o que mudou
  python3 build.py preparar                 baixa o GSAP para assets/ (render sem internet)

Depois: python3 build.py hf lint  ·  python3 build.py hf check  ·  estúdio  ·  render só no fim:
python3 build.py hf render --quality draft --output renders/amostra.mp4
Só usa a biblioteca padrão do Python (3.8 ou mais) e o ffmpeg; o estúdio e o render pedem o Node 22 ou mais novo.
"""
import bisect
import hashlib
import json
import math
import os
import re
import shutil
import signal
import socket
import subprocess
import sys
import time
import unicodedata
import urllib.error
import urllib.parse
import urllib.request
from array import array
from contextlib import contextmanager
from html import escape
from html.parser import HTMLParser
from string import Template

AQUI = os.path.dirname(os.path.abspath(__file__))
# versão do modelo e o que mudou em cada uma (o `build.py atualizar` mostra o que o projeto ainda não tinha).
# Mudou o comportamento do modelo? Suba a versão e escreva a novidade em uma linha.
MODELO_VERSAO = '2026-10-20'
NOVIDADES = [
    ('2026-09-29', 'legenda: nenhuma palavra dita some; a frase se divide por sentido ("Eu gravei" / "esse vídeo" / '
                   '"uma vez só,") e a sobra junta com a vizinha'),
    ('2026-09-29', 'lettering: cada palavra grande (e do apoio) entra na hora em que é dita, medida na onda, antecipando '
                   'no máximo 0,15 s'),
    ('2026-09-29', 'peça que vai até o fim do corte fica legível até o último quadro (também no segura_fim)'),
    ('2026-09-29', 'lettering: "legenda_junto" (padrão com a legenda discreta): a legenda continua nas palavras que não '
                   'estão escritas grandes'),
    ('2026-09-29', 'sons: o nível que cada peça pede vale no plano_sfx; destaque no máximo um a cada 6 s; o apoio do '
                   'lettering pede o seu pop e a conclusão um pouco mais forte'),
    ('2026-09-29', 'lettering: aviso quando a expressão não tem tempo de leitura antes da próxima (tire uma ou junte) e '
                   'nota com o tamanho da letra e o porquê'),
    ('2026-09-29', 'comando atualizar; espaço sem quebra nas correcoes cola duas palavras'),
    ('2026-10-05', 'o cartão final fica na tela até o último quadro (antes sobrava 0,1 a 0,2 s de preto no fim) e o título aceita *destaque*'),
    ('2026-10-05b', 'estilo "laranja" (escuro com laranja, o visual escuro de anúncio); as peças leem do estilo a cor do texto '
                    'sobre o laranja, do check e do carimbo, e as cenas do kit 2D escolhem letra clara ou escura pelo fundo'),
    ('2026-10-05c', 'laranja em 16:9 e 4:5: as cenas do kit 2D em tela cheia pintam o fundo do estilo (grade e brilho, '
                    'window.__cenas[...].fundo_css); em 16:9 a lista_check tem a largura do texto (cabe ao lado do rosto) e o '
                    'passo_a_passo cresce até o texto do passo caber'),
    ('2026-10-05d', 'cartão por cima da pessoa: k2d_medidor e k2d_comando aceitam area: "acima_do_rosto" (cartão pequeno acima da cabeça, sem '
                    'fundo, com aviso quando não há espaço ou a area escrita à mão toca o rosto) e a câmera viva respeita '
                    '"sobre_a_pessoa": true no cenas_proprias (zoom 1,0 e sem aproximação nesse trecho)'),
    ('2026-10-06', 'kit 2D com duas cenas novas, k2d_busca (barra de busca que digita o termo, resultado e selo de chamada) e '
                   'k2d_notas (bloco de notas com itens escritos na palavra e check); o cartão acima da cabeça (acima_do_rosto) '
                   'agora vai até 40 px abaixo do topo estimado do rosto e cresce até caber, nas quatro cenas que o aceitam'),
    ('2026-10-20', 'PrimoCode: texto longe do rosto: o lettering confere a caixa do rosto a cada 0,1 s do trecho inteiro '
                   '(não só na entrada), com folga de ROSTO_PX, e troca de posição antes de encolher demais; o lugar() '
                   'das peças não põe mais o "topo" em cima da cabeça. O portão reprova texto a menos de 40 px do rosto'),
]
FPS = 30
ZW = '​'   # espaço de largura zero: sem ele o GSAP lê '01' como número e escreve '1'
GSAP_URL = 'https://cdn.jsdelivr.net/npm/gsap@3.14.2/dist/gsap.min.js'
HF_VERSAO = '0.8.79'      # a do package.json manda; esta vale se o package.json não disser
MARCA_GERADO = '<!-- gerado pelo build.py'
PASTA_PECAS = 'compositions'
PASTA_MEXIDOS = '.mexido-no-estudio'

# faixas da linha do tempo do estúdio (só organizam a tela: o render não lê o número). O estúdio põe as faixas em
# ordem crescente; faixa com um clipe só leva o nome dele, faixa com vários aparece como "Track N" (cada clipe
# continua com o nome dele). Os elementos de estrutura (câmera, moldura, papel) ganham número alto para ficarem
# embaixo, perto do som, e não no meio das peças.
TRILHA_VIDEO, TRILHA_LEGENDA, TRILHA_PECAS, TRILHA_CENAS = 0, 1, 10, 30
TRILHA_ULTIMO = 2
TRILHA_PESSOA, TRILHA_MIDIA, TRILHA_FUNDO = 40, 41, 42
PASTA_GERADO = os.path.join('assets', 'gerado')   # mídia que o build gera (borrão queimado, último quadro, fundo)
MOLDURA_FECHA = 0.7                                # quanto a moldura leva para fechar (tela cheia -> quadro)
VOLUME_MAX = 3.98                                  # +12 dB: o máximo que o HyperFrames aceita no data-volume
TRILHA_ESTRUTURA = 60
TRILHA_VOZ, TRILHA_SONS = 80, 81
TRILHA_COMPARA_SOM = 79                            # som de um lado do bloco comparar
ESTRUTURA = [('palco', 'Câmera e cenas (tudo junto)'), ('inner', 'Câmera: zoom e posição'), ('frame', 'Moldura: recorte'),
             ('bg', 'Papel de fundo'), ('borda', 'Borda da moldura'), ('atras', 'Camada atrás da pessoa'),
             ('innerp', 'Câmera da pessoa recortada'), ('pecas', 'Camada das peças'), ('fundos', 'Camada do fundo desfocado')]


def faixa_estrutura(nome):
    i = [n for n, _ in ESTRUTURA].index(nome)
    return f'data-track-index="{TRILHA_ESTRUTURA + i}" data-timeline-label="{ESTRUTURA[i][1]}"'


class Erro(Exception):
    pass


AVISOS = []
NOTAS = []            # o que o build decidiu e é bom saber, sem ser problema (a letra do lettering e por quê)


def aviso(msg):
    AVISOS.append(msg)


def nota(msg):
    NOTAS.append(msg)


# PrimoCode: o texto da edição fica a ROSTO_PX (no quadro de 1920 de altura) da caixa do rosto, o tempo todo em que
# está na tela. O portão (helpers/portao.py) reprova com 40 px; o layout pede um pouco mais, pra a leitura do quadro
# exportado (a caixa do leitor não é exata) nunca cair abaixo do portão.
ROSTO_PX = 56


def distancia_caixas(a, b):
    """distância em px entre duas caixas (x0, y0, x1, y1); negativa quando se sobrepõem"""
    dx = max(b[0] - a[2], a[0] - b[2])
    dy = max(b[1] - a[3], a[1] - b[3])
    if dx < 0 and dy < 0:
        return max(dx, dy)
    return max(dx, dy) if (dx < 0 or dy < 0) else (dx * dx + dy * dy) ** 0.5


def perto_do_rosto(caixa, r, gap):
    """a caixa do texto fica a menos de `gap` px da caixa do rosto na tela (r de Montagem.rosto_tela)?"""
    return bool(r) and distancia_caixas(caixa, (r['x0'], r['y0'], r['x1'], r['y1'])) < gap


def instantes(t_in, t_out, cortes, passo=0.1):
    """os instantes a conferir de uma peça: a cada `passo` s do começo ao fim, mais cada corte no meio (a câmera e o
    rosto mudam ao longo do trecho; conferir só a entrada deixava o título colado na testa depois do zoom)"""
    out, t = [], t_in
    while t < t_out - 1e-6:
        out.append(round(t, 3))
        t += passo
    out += [c for c in cortes if t_in < c < t_out]
    return sorted(set(out)) or [t_in]


def clamp(v, lo, hi):
    return max(lo, min(hi, v))


# ---------------------------------------------------------------------------------------- formatos e estilos
FORMATOS = {
    '9:16': dict(W=1080, H=1920, seguro=(140, 230, 940, 1530), moldura=(520, 980, 400, 540), raio=40,
                 area_moldura=(140, 230, 940, 950), area_baixo_esq=(140, 990, 500, 1530),
                 legenda=(1100, 1400), legenda_alt=(1420, 1120), legenda_padrao=1290, legenda_folga=300, fonte_leg=60,
                 max_letras=14, fonte_leg_discreta=44, max_letras_discreta=22, hud_topo=160, escala=1.0),
    '16:9': dict(W=1920, H=1080, seguro=(80, 60, 1840, 1020), moldura=(1080, 120, 720, 840), raio=36,
                 area_moldura=(100, 130, 1020, 960), area_baixo_esq=(100, 600, 1020, 960),
                 legenda=(860, 930), legenda_alt=(930, 150), legenda_padrao=915, legenda_folga=160, fonte_leg=56,
                 max_letras=26, fonte_leg_discreta=40, max_letras_discreta=36, hud_topo=40, escala=1.0),
    # 4:5 e 1:1: margens folgadas, sem medida conferida numa rede (o build avisa: "a conferir na rede")
    '4:5': dict(W=1080, H=1350, seguro=(100, 110, 960, 1220), moldura=(560, 700, 380, 500), raio=36,
                area_moldura=(100, 110, 960, 670), area_baixo_esq=(100, 700, 540, 1220),
                legenda=(820, 1080), legenda_alt=(1110, 800), legenda_padrao=960, legenda_folga=220, fonte_leg=56,
                max_letras=16, fonte_leg_discreta=42, max_letras_discreta=24, hud_topo=60, escala=1.0,
                a_conferir=True),
    '1:1': dict(W=1080, H=1080, seguro=(90, 90, 970, 980), moldura=(620, 530, 340, 430), raio=32,
                area_moldura=(90, 90, 970, 510), area_baixo_esq=(90, 530, 590, 980),
                legenda=(640, 860), legenda_alt=(880, 620), legenda_padrao=780, legenda_folga=200, fonte_leg=54,
                max_letras=18, fonte_leg_discreta=40, max_letras_discreta=26, hud_topo=50, escala=1.0,
                a_conferir=True),
}
CENAS = ('tela_cheia', 'prova', 'moldura', 'tela_verde', 'contida', 'comparar')

# estilos da legenda do modelo. Outros jeitos (a frase se escrevendo à mão, manchete) são cena própria.
LEGENDA_ESTILOS = ('padrao', 'discreta', 'cinetica')
# (PrimoCode) a legenda cinética e o lettering "cinetico": Inter 800, palavra por palavra no começo medido na onda, a
# palavra-chave do vídeo (legenda.chaves) no degradê da marca ("gradiente": [cor1, cor2]; sem marca, rosa → violeta)
GRADIENTE_PADRAO = ('#f44f86', '#5b45ec')
SOMBRA_CINETICA = 'drop-shadow(0 4px 0 rgba(0,0,0,.22)) drop-shadow(0 10px 22px rgba(0,0,0,.3))'   # sombra escura, sem brilho
LEGENDA_CINETICA_ALTURA = 0.10     # a letra da legenda cinética: 10% da altura do quadro (até 3 palavras, em 1 ou 2 linhas)


def gradiente_css(cfg):
    cores = cfg.get('gradiente') or GRADIENTE_PADRAO
    if not (isinstance(cores, (list, tuple)) and len(cores) >= 2
            and all(re.fullmatch(r'#[0-9a-fA-F]{6}', str(c)) for c in cores)):
        raise Erro(f'"gradiente" é [cor1, cor2] em #rrggbb (pediu {cores!r})')
    return f'linear-gradient(90deg,{",".join(cores)})'
LEGENDA_CENA_PROPRIA = ('papelaria', 'impacto', 'manchete', 'escrita', 'mao', 'a_mao')

# Os tokens de cada estilo (as peças, a moldura, a legenda e as cenas do kit leem só daqui; cor fixa no código só
# onde vale para todos os estilos):
#   papel (cartão), linha (fio e grade), base (apoio: bolinha do número, destaque da letra grande), destaque (acento,
#   botão), tinta (texto sobre o papel), verde, vermelho, contorno_leg (contorno da legenda e da letra grande),
#   texto_fundo (texto sobre o fundo), botao, sombra (cartão), borda (aro da moldura), fundo (o que fica atrás da
#   moldura e do cartão final);
#   sobre_destaque (texto e ícone sobre destaque e botão), sobre_base (texto sobre base), sobre_verde e sobre_vermelho
#   (o traço do check e do X), carimbo_fundo, cel_aro e cel_corpo (a borda e o corpo do celular da notificação),
#   item (cartão dentro do celular), polaroide (moldura da foto), hud_fundo, hud_borda, hud_n e hud_tc (as pílulas do painel
#   "editando ao vivo"),
#   texto_atras (cor padrão do texto atrás da pessoa), marca_texto (destaque do lettering manuscrito),
#   cena_no_fundo_do_estilo (true só no laranja: as cenas do kit em tela cheia pintam o `fundo` do estilo, com a grade e o
#   brilho, no lugar do papel quadriculado delas; papelaria e noite ficam com false e desenham o papel de sempre).
ESTILOS = {
    'papelaria': dict(
        papel='#fffdfd', linha='#e6ddf8', base='#c7b7ef', destaque='#665080', tinta='#352c40',
        verde='#2e9e5b', vermelho='#e5484d', contorno_leg='#352c40', texto_fundo='#352c40', botao='#665080',
        sombra='0 12px 0 -2px #c7b7ef, 0 22px 44px rgba(53,44,64,.35), inset 0 -8px 0 rgba(199,183,239,.35)',
        borda='0 0 0 6px #c7b7ef, 0 16px 0 2px #665080, 0 30px 50px rgba(53,44,64,.35)',
        fundo='background-color:#fffdfd;background-image:linear-gradient(#e6ddf8 2px,transparent 2px),'
              'linear-gradient(90deg,#e6ddf8 2px,transparent 2px);background-size:60px 60px',
        sobre_destaque='#ffffff', sobre_base='#352c40', sobre_verde='#ffffff', sobre_vermelho='#ffffff',
        carimbo_fundo='rgba(255,253,253,.94)', cel_aro='#fffdfd', cel_corpo='#352c40', item='#ffffff',
        polaroide='#fffdfd', hud_fundo='rgba(11,8,25,.88)', hud_borda='#49445d', hud_n='#c9c0f0', hud_tc='#b9b5c7',
        texto_atras='#fffdfd', marca_texto='#ffd84d', cena_no_fundo_do_estilo=False),
    'noite': dict(
        papel='#ffffff', linha='#dfe8f7', base='#a9c7f2', destaque='#1a5ee1', tinta='#1f2a44',
        verde='#16a34a', vermelho='#e5484d', contorno_leg='#000000', texto_fundo='#ffffff', botao='#1a5ee1',
        sombra='0 10px 0 -2px #a9c7f2, 0 18px 34px rgba(0,0,0,.4)',
        borda='0 0 0 4px rgba(79,195,255,.7), 0 0 34px rgba(79,195,255,.35)',
        fundo='background-color:#0b1a33;background-image:radial-gradient(rgba(169,199,242,.16) 2px,transparent 2.5px),'
              'radial-gradient(120% 70% at 50% 28%,#12325f 0%,#0b1a33 62%,#060e1d 100%);'
              'background-size:44px 44px,100% 100%',
        sobre_destaque='#ffffff', sobre_base='#1f2a44', sobre_verde='#ffffff', sobre_vermelho='#ffffff',
        carimbo_fundo='rgba(255,253,253,.94)', cel_aro='#ffffff', cel_corpo='#1f2a44', item='#ffffff',
        polaroide='#fffdfd', hud_fundo='rgba(11,8,25,.88)', hud_borda='#49445d', hud_n='#c9c0f0', hud_tc='#b9b5c7',
        texto_atras='#ffffff', marca_texto='#ffd84d', cena_no_fundo_do_estilo=False),
    # escuro com laranja, o visual escuro de anúncio: fundo quase preto com grade e brilho laranja suave, cartão escuro
    # com fio laranja, texto claro, laranja de destaque (o texto e o ícone sobre o laranja são escuros)
    'laranja': dict(
        papel='#15161d', linha='#3b3c45', base='#ff7a1a', destaque='#ff7a1a', tinta='#f5f5f7',
        verde='#35d07f', vermelho='#ff3b30', contorno_leg='#0b0b0d', texto_fundo='#f5f5f7', botao='#ff7a1a',
        sombra='0 0 0 2px rgba(255,122,26,.6), 0 22px 50px rgba(0,0,0,.6)',
        borda='0 0 0 3px rgba(255,122,26,.75), 0 0 46px 4px rgba(255,122,26,.35), 0 40px 110px rgba(0,0,0,.7)',
        fundo='background-color:#0c0d13;background-image:radial-gradient(130% 90% at 50% 50%,rgba(0,0,0,0) 60%,rgba(0,0,0,.6) 100%),'
              'radial-gradient(90% 45% at 50% 22%,rgba(255,122,26,.34),rgba(0,0,0,0) 72%),'
              'linear-gradient(rgba(255,255,255,.2) 2px,transparent 2px),'
              'linear-gradient(90deg,rgba(255,255,255,.2) 2px,transparent 2px);'
              'background-size:100% 100%,100% 100%,90px 90px,90px 90px',
        sobre_destaque='#140a02', sobre_base='#140a02', sobre_verde='#06210f', sobre_vermelho='#ffffff',
        carimbo_fundo='rgba(12,13,19,.94)', cel_aro='#3b3c45', cel_corpo='#0b0b0d', item='#21222b',
        polaroide='#15161d', hud_fundo='rgba(10,10,12,.88)', hud_borda='#3b3c45', hud_n='#ff7a1a', hud_tc='#8b8b93',
        texto_atras='#f5f5f7', marca_texto='#ff7a1a', cena_no_fundo_do_estilo=True),
    # (PrimoCode) limpo, o visual da régua do dono: chão claro com um rosa e um violeta bem leves, cartão branco, texto
    # quase preto, sombra macia e nenhum brilho; a moldura é um quadro arredondado sobre esse chão
    'limpo': dict(
        papel='#ffffff', linha='#ecebef', base='#f44f86', destaque='#5b45ec', tinta='#1d1d1f',
        verde='#16a34a', vermelho='#e5484d', contorno_leg='#1d1d1f', texto_fundo='#1d1d1f', botao='#1d1d1f',
        sombra='0 18px 40px rgba(29,29,31,.14), 0 2px 6px rgba(29,29,31,.08)',
        borda='0 30px 70px rgba(29,29,31,.22), 0 4px 12px rgba(29,29,31,.10)',
        fundo='background-color:#f1f0ed;background-image:radial-gradient(60% 40% at 78% 84%,rgba(244,79,134,.16),'
              'rgba(244,79,134,0) 70%),radial-gradient(55% 35% at 16% 14%,rgba(91,69,236,.10),rgba(91,69,236,0) 70%)',
        sobre_destaque='#ffffff', sobre_base='#ffffff', sobre_verde='#ffffff', sobre_vermelho='#ffffff',
        carimbo_fundo='rgba(255,255,255,.96)', cel_aro='#ffffff', cel_corpo='#1d1d1f', item='#f6f5f3',
        polaroide='#ffffff', hud_fundo='rgba(255,255,255,.94)', hud_borda='#e2e0e6', hud_n='#5b45ec', hud_tc='#6b6b73',
        texto_atras='#ffffff', marca_texto='#f44f86', cena_no_fundo_do_estilo=True),
}

# tipos de som que as animações pedem (o helpers/plano_sfx.py da skill escolhe o arquivo de cada um) e o nível do
# pico em dBFS, que o plano respeita (references/tabela-de-sons.md): destaque no máximo um a cada 6 s (o build
# rebaixa os outros para médio)
SOM_BAIXO, SOM_PEQUENO, SOM_MEDIO, SOM_CHAVE = -20, -18, -15, -12
DESTAQUE_A_CADA = 6.0


# ---------------------------------------------------------------------------------------- projeto e dados
def norm(s):
    s = s.strip().lower().strip('.,!?…:;"\'()«»“”')
    return ''.join(c for c in unicodedata.normalize('NFD', s) if unicodedata.category(c) != 'Mn')


PONTUACAO_FIM = '.,!?…:;'


# ---------------------------------------------------------------------------------------- a skill de onde veio
def eh_skill(p):
    return bool(p) and os.path.isfile(os.path.join(os.path.expanduser(p), 'helpers', 'matte.py'))


def skill_do_modelo():
    """a skill onde este build.py mora, quando ele roda no modelo original (dentro da skill)"""
    pai = os.path.dirname(AQUI)
    return pai if eh_skill(pai) and os.path.basename(AQUI) == 'modelo-projeto' else None


def gravar_campo_projeto(chave, valor, pasta=None):
    """escreve um campo no topo do projeto.json sem reformatar o resto do arquivo"""
    p = os.path.join(pasta or AQUI, 'projeto.json')
    with open(p, encoding='utf-8') as f:
        txt = f.read()
    v = json.dumps(valor, ensure_ascii=False)
    pat = re.compile(r'("%s"\s*:\s*)("(?:[^"\\]|\\.)*"|null)' % re.escape(chave))
    if pat.search(txt):
        novo = pat.sub(lambda m: m.group(1) + v, txt, count=1)
    else:
        novo = re.sub(r'^\s*\{', lambda m: m.group(0) + f'\n  "{chave}": {v},', txt, count=1)
    try:
        dados = json.loads(novo)
        assert dados.get(chave) == valor
    except (ValueError, AssertionError):
        dados = json.loads(txt)
        dados[chave] = valor
        novo = json.dumps(dados, ensure_ascii=False, indent=2) + '\n'
    with open(p, 'w', encoding='utf-8') as f:
        f.write(novo)


def registrar_skill(cfg):
    """o campo "skill" diz de que skill o projeto veio: as ferramentas usam essa primeiro. Vazio: o build anota
    a do EDITOR_VIDEO_DIR (o `build.py novo` já anota a pasta do modelo)."""
    atual = str(cfg.get('skill') or '').strip()
    if atual:
        if not eh_skill(atual):
            aviso(f'o "skill" do projeto.json ({atual}) não tem helpers/matte.py: confira o caminho da skill')
        return
    if skill_do_modelo():          # rodando no modelo original: não escreve nele
        return
    env = os.environ.get('EDITOR_VIDEO_DIR') or ''
    if eh_skill(env):
        cand = os.path.abspath(os.path.expanduser(env))
        cfg['skill'] = cand
        gravar_campo_projeto('skill', cand)
        print(f'  skill anotada no projeto.json (EDITOR_VIDEO_DIR): {cand}')
        return
    aviso('o projeto.json não diz de que skill ele veio ("skill"): as ferramentas vão usar a skill instalada e dizer '
          'qual. Anote a certa: "skill": "<SKILL>" no projeto.json (o `build.py novo` da skill já faz isso).')


def skill_em_uso(cfg):
    """a skill das ferramentas: o "skill" do projeto.json, a skill onde o modelo está, o EDITOR_VIDEO_DIR"""
    for cand in (str(cfg.get('skill') or '').strip(), skill_do_modelo() or '', os.environ.get('EDITOR_VIDEO_DIR') or ''):
        if eh_skill(cand):
            return os.path.abspath(os.path.expanduser(cand))
    return None


def achar_uv():
    for c in (shutil.which('uv'), os.path.join(os.path.expanduser('~'), '.local', 'bin', 'uv'),
              os.path.join(os.path.expanduser('~'), '.cargo', 'bin', 'uv'), '/opt/homebrew/bin/uv', '/usr/local/bin/uv'):
        if c and os.path.isfile(c):
            return c
    return None


def ler_json_ou_nada(p):
    try:
        with open(p, encoding='utf-8') as f:
            return json.load(f)
    except (OSError, ValueError):
        return None


def assinatura_ns(p):
    """a mesma do helpers/common.file_signature da skill: tamanho e mtime em ns"""
    st = os.stat(p)
    return f'{st.st_size}:{st.st_mtime_ns}'


def medir_rosto(P):
    """roda o helpers/rastrear_rosto.py da skill do projeto (Python da skill, detector YuNet)"""
    skill = skill_em_uso(P.cfg)
    cmd_txt = f'uv run --directory \'<SKILL>\' python helpers/rastrear_rosto.py \'{P.edit}\''
    if not skill or not os.path.exists(os.path.join(skill, 'helpers', 'rastrear_rosto.py')):
        raise Erro(f'"acompanhar" precisa do rosto medido 10 vezes por segundo e a skill do projeto não tem o '
                   f'helpers/rastrear_rosto.py ("skill" do projeto.json: {P.cfg.get("skill") or "vazio"}). Rode com a '
                   f'skill certa: {cmd_txt}')
    # PrimoCode: o Python do editor (venv próprio em ~/.primocode/editor-venv) vem em PRIMOCODE_EDITOR_PYTHON
    py = os.environ.get('PRIMOCODE_EDITOR_PYTHON')
    if py and os.path.isfile(py):
        base_cmd = [py, 'helpers/rastrear_rosto.py']
    else:
        uv = achar_uv()
        if not uv:
            raise Erro(f'"acompanhar" mede o rosto com o Python da skill e não achei o uv. Rode: {cmd_txt}')
        base_cmd = [uv, 'run', '--directory', skill, 'python', 'helpers/rastrear_rosto.py']
    print('  acompanhar: medindo o rosto 10 vezes por segundo no corte (helpers/rastrear_rosto.py)...', flush=True)
    r = subprocess.run(base_cmd + [P.edit], cwd=skill,
                       capture_output=True, text=True, encoding='utf-8', errors='replace')
    for linha in (r.stdout or '').splitlines():
        if linha.strip() and 's medidos (' not in linha and not linha.startswith('medindo'):
            print('   ' + linha.rstrip(), flush=True)
    if r.returncode != 0:
        raise Erro('a medida do rosto falhou: ' + ((r.stderr or r.stdout).strip()[-400:]) + f'. Rode à mão: {cmd_txt}')


def separar(texto):
    """as palavras de um texto, separadas só pelo espaço comum: o espaço sem quebra (U+00A0) cola duas palavras numa
    só ("vez\u00a0só" fica junto na legenda)"""
    return [x for x in re.split(r'[ \t\r\n]+', str(texto).strip()) if x]


def corrigir(words, fix):
    """correções da transcrição. A chave pode ter mais de uma palavra ("um IA": "uma IA"): troca a sequência
    inteira, nunca a palavra solta em outro lugar. Chave igual ao texto troca como está; igual sem pontuação,
    acento ou maiúscula troca e mantém a pontuação do fim ("IA," com {"ia": "IA"} vira "IA,"). O valor pode ter
    outro número de palavras: cada uma ganha um pedaço do tempo, pelo tamanho. Valor "" apaga. Espaço sem quebra
    no valor ("vez\u00a0só,") cola as palavras numa só."""
    if not fix:
        return words
    regras = []
    for k, v in fix.items():
        partes = separar(k)
        if partes:
            regras.append((partes, str(v)))
    regras.sort(key=lambda r: -len(r[0]))            # a sequência mais comprida primeiro
    out, i = [], 0
    while i < len(words):
        for partes, v in regras:
            n = len(partes)
            trecho = words[i:i + n]
            if len(trecho) < n:
                continue
            exato = n == 1 and trecho[0]['text'] == partes[0]
            if not exato and not all(norm(w['text']) == norm(p) for w, p in zip(trecho, partes)):
                continue
            novas = separar(v)
            if novas and not exato:
                fimp = ''
                ult = trecho[-1]['text']
                while ult and ult[-1] in PONTUACAO_FIM:
                    fimp = ult[-1] + fimp
                    ult = ult[:-1]
                if fimp and novas[-1][-1] not in PONTUACAO_FIM:
                    novas[-1] += fimp
            if len(novas) == n:
                out += [dict(w, text=t) for w, t in zip(trecho, novas)]
            elif novas:
                a, z = trecho[0]['start'], trecho[-1]['end']
                total = sum(max(1, len(t)) for t in novas)
                t0 = a
                for t in novas:
                    t1 = t0 + (z - a) * max(1, len(t)) / total
                    out.append(dict(text=t, start=round(t0, 3), end=round(t1, 3)))
                    t0 = t1
            i += n
            break
        else:
            out.append(words[i])
            i += 1
    return out


class Projeto:
    def __init__(self):
        caminho = os.path.join(AQUI, 'projeto.json')
        if not os.path.exists(caminho):
            raise Erro('não achei o projeto.json nesta pasta')
        with open(caminho, encoding='utf-8') as f:
            self.cfg = json.load(f)
        c = self.cfg
        registrar_skill(c)
        self.edit = os.path.normpath(os.path.join(AQUI, c.get('edit', '../edit')))
        if not os.path.isdir(self.edit):
            raise Erro(f'não achei a pasta edit do corte da skill (Fase 1): {self.edit} (campo "edit" do projeto.json)')
        self.formato = c.get('formato', '9:16')
        if self.formato not in FORMATOS:
            raise Erro('formato tem que ser "9:16", "16:9", "4:5" ou "1:1"')
        self.F = FORMATOS[self.formato]
        if self.F.get('a_conferir'):
            sx0, sy0, sx1, sy1 = self.F['seguro']
            aviso(f'formato {self.formato}: a zona segura (x {sx0} a {sx1}, y {sy0} a {sy1}) é uma margem folgada, não '
                  f'medida numa rede: a conferir na rede de destino antes de publicar (texto, botão e rosto)')
        self.W, self.H = self.F['W'], self.F['H']
        self.E = ESTILOS.get(c.get('estilo', 'papelaria'))
        if not self.E:
            raise Erro('estilo tem que ser ' + ', '.join(f'"{e}"' for e in ESTILOS))
        self.video_src = os.path.join(self.edit, c.get('video', 'cut.mp4'))
        tl = self._json('cut_timeline.json')
        self.VDUR = round(tl['duration'], 3)
        self.fps = float(tl.get('fps') or FPS)
        self.vw, self.vh = int(tl.get('width') or self.W), int(tl.get('height') or self.H)   # tamanho do cut.mp4
        self.cortes = sorted(float(s['out_start']) for s in tl['segments'] if s['out_start'] > 0.01)
        if self.formato == '16:9':
            # vídeo gravado em pé que virou 16:9 no corte: só uma faixa da altura ficou (a cabeça)
            kept = [float((s.get('crop') or {}).get('kept', 1)) for s in tl['segments']
                    if (s.get('crop') or {}).get('axis') == 'y']
            if (kept and min(kept) < 0.7) or self.vh > self.vw:
                pct = f' (só {min(kept):.0%} da altura ficou)' if kept else ''
                aviso(f'o vídeo foi gravado em pé e vai sair em 16:9{pct}: a pessoa aparece em close, só a cabeça, e na '
                      f'moldura fica ainda mais perto. Prefira 9:16, ou mostre a gravação inteira com o bloco "contida" '
                      f'nos trechos em que o corpo ou as mãos importam (references/enquadramento.md, "Quando não cabe").')
        fl = self._json('face_layout.json', opcional=True)
        self.rostos = fl['segments'] if fl else []
        if not fl:
            aviso('sem face_layout.json: o enquadramento usa o centro do quadro. Rode o face_layout.py do corte da skill (Fase 1).')
        tr = self._json(os.path.join('transcripts', 'cut.json'))
        cruas = [dict(text=w['text'].strip(), start=float(w['start']), end=float(w['end'])) for w in tr['words']
                 if w['text'].strip()]
        self.tempo_fala = self._tempos_na_onda(cruas)
        cruas = corrigir(cruas, c.get('correcoes', {}))
        self.words, prev = [], None
        for w in cruas:
            # o Whisper às vezes repete a mesma palavra numa pausa ("aqui aqui"): fica uma só
            if prev and norm(w['text']) == norm(prev['text']) and w['start'] - prev['end'] < 0.6:
                continue
            prev = w
            self.words.append(prev)

    def _json(self, nome, opcional=False):
        p = os.path.join(self.edit, nome)
        if not os.path.exists(p):
            if opcional:
                return None
            raise Erro(f'falta {p}. Termine o corte da skill (Fase 1): render_cut.py e transcribe.py do cut.mp4.')
        with open(p, encoding='utf-8') as f:
            return json.load(f)

    def _tempos_na_onda(self, cruas):
        """o começo de cada palavra medido na onda (palavras_onda.json do helpers/palavras_na_onda.py): o Whisper
        adianta até 0,3 s. Casa pela hora do Whisper e pelo texto; arquivo de outro corte é ignorado com aviso.
        Devolve "onda" ou "whisper" (o tempo que as âncoras, a legenda e as cenas usam)."""
        try:
            d = self._json('palavras_onda.json', opcional=True)
        except ValueError:
            d = None
        lista = (d or {}).get('palavras') if isinstance(d, dict) else None
        if not lista:
            return 'whisper'
        por_hora = {}
        for r in lista:
            try:
                por_hora.setdefault(round(float(r['whisper']), 2), []).append(r)
            except (KeyError, TypeError, ValueError):
                continue
        casadas, mudar = 0, []
        for w in cruas:
            for r in por_hora.get(round(w['start'], 2), []):
                if norm(str(r.get('text', ''))) == norm(w['text']):
                    novo = float(r['start'])
                    if abs(novo - w['start']) <= 0.45:
                        mudar.append((w, novo))
                    casadas += 1
                    break
        if casadas < 0.8 * len(cruas):
            aviso(f'palavras_onda.json não bate com a transcrição do corte ({casadas} de {len(cruas)} palavras): é de '
                  f'outro corte. Rode de novo: uv run --directory \'<SKILL>\' python helpers/palavras_na_onda.py \'<EDIT>\'')
            return 'whisper'
        for w, novo in mudar:
            w['start'] = round(novo, 3)
            w['end'] = round(max(w['end'], novo + 0.05), 3)
        return 'onda'

    # --- palavras
    def tempo_palavra(self, expr, depois=0.0, fim=False):
        alvo = [norm(x) for x in str(expr).split()]
        ws = self.words
        for i, w in enumerate(ws):
            if w['start'] < depois - 1e-6 or i + len(alvo) > len(ws):
                continue
            if all(norm(ws[i + k]['text']).startswith(a) for k, a in enumerate(alvo)):
                return ws[i + len(alvo) - 1]['end'] if fim else w['start']
        raise Erro(f'não achei "{expr}" depois de {depois:.2f} s. Veja as palavras: python3 build.py palavras '
                   f'{max(0, depois - 2):.0f} {depois + 12:.0f}')

    def tempo(self, d, chave, depois=0.0, antecipa=0.0, fim=False):
        """número = segundos do corte; texto = a palavra (procura a partir de depois_de)."""
        v = d.get(chave)
        if v is None:
            return None
        if isinstance(v, (int, float)):
            return float(v)
        if isinstance(v, str):
            try:
                return float(v.replace(',', '.'))
            except ValueError:
                base = d.get(chave + '_depois_de')
                if base is None:
                    base = max(float(d.get('depois_de', 0)), depois)
                return self.tempo_palavra(v, float(base), fim) - antecipa
        raise Erro(f'"{chave}" tem que ser número (segundos) ou a palavra')

    # --- rosto
    def rosto(self, t):
        ult = None
        for s in self.rostos:
            if s.get('face'):
                ult = s['face']
            # 2 ms de folga para o lado do trecho novo: tempo arredondado no corte já pega o rosto da tomada nova
            if s['start'] - 2e-3 <= t < s['end'] - 2e-3 and s.get('face'):
                return s['face']
        return ult or dict(x0=0.35, y0=0.3, x1=0.65, y1=0.47)

    # --- rosto acompanhado dentro da tomada (blocos com "acompanhar": rosto_trilha.json do helpers/rastrear_rosto.py)
    trilha = None

    def carregar_trilha(self):
        """o caminho do rosto 10 vezes por segundo, suavizado; mede (pelo helper da skill) quando falta ou quando o
        corte mudou depois da medida"""
        if self.trilha is not None:
            return self.trilha
        p = os.path.join(self.edit, 'rosto_trilha.json')
        cut = os.path.join(self.edit, 'cut.mp4')
        if not os.path.exists(cut):
            raise Erro(f'"acompanhar" mede o rosto no cut.mp4 e não achei {cut}')
        d = ler_json_ou_nada(p)
        if not (isinstance(d, dict) and d.get('versao') == 1 and d.get('video_sig') == assinatura_ns(cut)):
            medir_rosto(self)
            d = ler_json_ou_nada(p)
            if not isinstance(d, dict) or not d.get('tomadas'):
                raise Erro(f'a medida do rosto não gravou {p}')
        for tm in d['tomadas']:
            tm['_ts'] = [pt[0] for pt in tm.get('pontos') or []]
        self.trilha = d
        return d

    def rosto_acompanhado(self, t):
        """caixa do rosto (fração do cut.mp4) no caminho suavizado, no tempo t; None fora da trilha ou sem rosto"""
        d = self.trilha
        if not d:
            return None
        for tm in d['tomadas']:
            if tm['de'] - 2e-3 <= t < tm['ate'] - 2e-3:
                pts, ts = tm.get('pontos') or [], tm.get('_ts') or []
                if not pts:
                    return None
                k = bisect.bisect_right(ts, t)
                if k <= 0:
                    p = pts[0]
                elif k >= len(pts):
                    p = pts[-1]
                else:
                    a, b = pts[k - 1], pts[k]
                    u = (t - a[0]) / max(1e-6, b[0] - a[0])
                    p = [a[i] + (b[i] - a[i]) * u for i in range(5)]
                cx, cy, w, h = p[1], p[2], p[3], p[4]
                return dict(x0=cx - w / 2, x1=cx + w / 2, y0=cy - h / 2, y1=cy + h / 2,
                            wmed=float(tm.get('w_med') or w), acompanhado=True)
        return None


# ---------------------------------------------------------------------------------------- faixas (sub-composições)
class Faixa:
    """uma peça numa sub-composição própria: vira uma faixa com nome na linha do tempo do estúdio"""

    def __init__(self, pid, rotulo, trilha, camada):
        self.pid, self.rotulo, self.trilha, self.camada = pid, rotulo, trilha, camada
        self.cid = 'peca-' + pid
        self.HT, self.CSS, self.R = [], {}, []
        self.saida = None          # (entrada, saída) da peça, anotado pela entrada_saida
        self.host = ''
        self.ajuste_fontes = None  # lettering: [(id da linha, largura máxima)] conferidos com a fonte carregada


def duracao_tween(b):
    if not isinstance(b, dict):
        return 0.0
    d = float(b.get('duration', 0) or 0)
    kf = b.get('keyframes')
    if isinstance(kf, list):
        d = sum(float(k.get('duration', 0) or 0) for k in kf)
    rep = b.get('repeat') or 0
    if rep > 0:
        d *= 1 + rep
    if b.get('stagger'):
        d += float(b['stagger']) * 20    # estimativa folgada: até 20 alvos
    return d


def janela_registros(R):
    ini = fim = None
    for k, _sel, _a, b, t in R:
        if k == 'inicio':
            continue
        if k == 'quadros':
            ts = [tq for tq, _ in b]
            if not ts:
                continue
            t0, t1 = min(ts), max(ts)
        else:
            t0, t1 = t, t + duracao_tween(b)
        ini = t0 if ini is None else min(ini, t0)
        fim = t1 if fim is None else max(fim, t1)
    return ini, fim


def resolver_blocos(P):
    """os blocos do projeto.json com o tempo resolvido; buraco entre blocos vira tela cheia"""
    bl = []
    for i, b in enumerate(P.cfg.get('blocos', [])):
        d = dict(b)
        d['de'] = P.tempo(b, 'de') if 'de' in b else 0.0
        d['ate'] = P.tempo(b, 'ate', depois=d['de']) if 'ate' in b else P.VDUR
        if d.get('cena') not in CENAS:
            raise Erro(f'bloco {i + 1}: "cena" tem que ser {", ".join(CENAS[:-1])} ou {CENAS[-1]}')
        if d.get('acompanhar') and d['cena'] not in ('tela_cheia', 'moldura'):
            raise Erro(f'bloco {i + 1} ({d["cena"]}): "acompanhar" vale em tela_cheia e moldura (na tela verde a pessoa '
                       f'fica parada no canto; na contida a gravação aparece inteira)')
        if d['cena'] in ('moldura', 'tela_verde', 'contida') and d.get('no_corte', True):
            for k in [k for k in ('de', 'ate') if k in b]:   # entra e sai no corte (a câmera volta pro rosto ali)
                c = min(P.cortes, key=lambda c: abs(c - d[k]), default=None)
                if c is not None and abs(c - d[k]) <= 0.35:
                    if abs(c - d[k]) > 0.02:
                        aviso(f'bloco {i + 1} ({d["cena"]}): {k} {d[k]:.2f} encostado no corte {c:.2f}')
                    d[f'_{k}_pedido'] = d[k]         # o que a pessoa escreveu (a cena que começa ali não é da moldura)
                    d[k] = c
        bl.append(d)
    bl.sort(key=lambda d: d['de'])
    cheio, t = [], 0.0
    for d in bl:                          # buraco entre blocos vira tela cheia
        if d['de'] > t + 1e-3:
            cheio.append(dict(cena='tela_cheia', de=t, ate=d['de']))
        d['de'] = max(d['de'], t)
        if d['ate'] > d['de'] + 1e-3:
            cheio.append(d)
        t = max(t, d['ate'])
    if t < P.VDUR - 1e-3:
        cheio.append(dict(cena='tela_cheia', de=t, ate=P.VDUR))
    cheio[-1]['ate'] = max(cheio[-1]['ate'], P.VDUR + 60)   # o último vale até o fim (e na cauda)
    for k, d in enumerate(cheio):
        if d['cena'] == 'contida':
            fundo = d.setdefault('fundo', 'desfocado')
            if fundo not in ('desfocado', 'papel') and not re.match(r'^#[0-9a-fA-F]{3}([0-9a-fA-F]{3})?$', str(fundo)):
                raise Erro(f'contida em {d["de"]:.2f} s: "fundo" tem que ser "desfocado" (o padrão: a própria gravação '
                           f'desfocada atrás), "papel" (o papel do estilo) ou uma cor ("#1b1830")')
        if d['cena'] != 'moldura':
            continue
        # quando o quadro termina de fechar: peça que cai antes disso passaria na frente do rosto
        ant = cheio[k - 1]['cena'] if k else None
        d['_fecha'] = d['de'] if d['de'] < 1e-3 else d['de'] + (0.25 if ant == 'tela_verde' else MOLDURA_FECHA)
        fundo = d.get('fundo', 'papel')
        if fundo not in ('papel', 'desfocado'):
            raise Erro(f'moldura em {d["de"]:.2f} s: "fundo" tem que ser "papel" (o padrão) ou "desfocado" '
                       f'(o próprio vídeo desfocado atrás do quadro)')
    return cheio


# ---------------------------------------------------------------------------------------- montagem
class Montagem:
    def __init__(self, P, T0, T1, modo='completo', segura=0.0, cartao=0.0):
        self.P, self.F, self.E = P, P.F, P.E
        self.W, self.H = P.W, P.H
        self.modo = modo
        self.T0 = round(T0 * FPS) / FPS
        self.T1 = round(T1 * FPS) / FPS
        self.segura, self.cartao = segura, cartao          # fim parado (voz já acabou) e cartão final, depois dele
        self.cauda = segura + cartao
        self.VFIM = min(self.T1, P.VDUR)                  # fim do vídeo dentro da janela
        self.DUR = round(self.VFIM - self.T0 + self.cauda, 3)
        self._HT, self._CSS, self.R, self.AUD = [], {}, [], []
        self.ATRAS, self.PESSOA_ATRAS = [], []             # texto atrás da pessoa e a pessoa recortada na frente dele
        self.FUNDOS = []                                   # vídeo desfocado atrás da moldura
        self.alvo, self.faixas = None, []
        self.cenas_js, self.esconder_extra = {}, []
        self.sons_sug, self.retangulos, self.n_pecas = [], [], 0
        self.empurrada = False                             # a última peça foi empurrada para depois da moldura fechar
        self.ate_o_fim = False                             # a última peça vai até o fim do corte: legível até o fim
        self.fora_da_legenda, self.junto_ok = set(), set() # lettering com legenda_junto: palavras escritas grandes
        self.CAM = '.camera'                               # #inner (vídeo) e #innerp (pessoa recortada) andam juntos
        self.MOL = tuple(P.cfg.get('moldura', {}).get('caixa', self.F['moldura']))
        self.FULL = (0, 0, self.W, self.H)
        # onde o cut.mp4 fica dentro da câmera (#inner, do tamanho da tela): o retângulo que cobre a tela sem
        # distorcer. Com o corte na proporção do formato é a própria tela; um corte 9:16 num projeto 4:5 ou 1:1
        # passa da tela em cima e embaixo, e a conta do rosto usa esse retângulo (a contida mostra ele inteiro).
        k = max(self.W / max(1, P.vw), self.H / max(1, P.vh))
        self.dw, self.dh = P.vw * k, P.vh * k
        self.ox, self.oy = (self.W - self.dw) / 2, (self.H - self.dh) / 2
        self.PLANOS = []                                   # movimento do fundo (2,5D): [(t_in, t_out, pid)]
        self.ult_atras = None                              # o último texto atrás da pessoa (a frente entra junto)
        self.COMPARA_HT, self.voz_fora = [], []            # bloco comparar: fundo, lados e rótulos; voz que sai
        self.blocos = self._blocos()
        self.zooms = self._zooms()
        if any(b.get('acompanhar') for b in self.blocos):
            P.carregar_trilha()

    # --- para onde vai o que a peça escreve (a sub-composição dela, ou o index.html)
    @property
    def HT(self):
        return self.alvo.HT if self.alvo is not None else self._HT

    @property
    def CSS(self):
        return self.alvo.CSS if self.alvo is not None else self._CSS

    @contextmanager
    def faixa(self, pid, rotulo, trilha, camada='pecas'):
        F = Faixa(pid, rotulo, trilha, camada)
        self.alvo = F
        try:
            yield F
        finally:
            self.alvo = None
        self.faixas.append(F)
        (self.ATRAS if camada == 'atras' else self._HT).append(F)   # o lugar da peça na pilha

    # --- tempo
    def rr(self, t):
        return round(max(0.0, t - self.T0), 3)       # nada em tempo negativo: o GSAP empurraria tudo pra frente

    def dentro(self, a, b):
        return b > self.T0 and a < self.T1

    # --- GSAP (guardado com o tempo do corte; o texto final sai no fim, com a origem certa de cada timeline)
    @staticmethod
    def _o(d):
        return json.dumps(d, ensure_ascii=False, separators=(',', ':'))

    def _rec(self, r):
        # a tremida do carimbo mexe no #palco e o movimento do fundo (2,5D) mexe no .plfundo, que ficam no
        # index.html: a timeline de uma sub-composição não alcança
        if self.alvo is not None and not str(r[1]).startswith(('#palco', '.plfundo')):
            self.alvo.R.append(r)
        else:
            self.R.append(r)

    def FT(self, sel, a, b, t):
        b = dict(b)
        b.setdefault('immediateRender', False)
        self._rec(('ft', sel, a, b, t))

    def TO(self, sel, b, t):
        b = dict(b)
        b.setdefault('immediateRender', False)
        self._rec(('to', sel, None, b, t))

    def SET(self, sel, b, t):
        self._rec(('set', sel, None, b, t))

    def SETQ(self, sel, b, tq):
        """set num quadro exato (tempo do corte), arredondado para baixo em ms: nunca cai um quadro depois"""
        self._rec(('setq', sel, None, b, tq))

    def QUADROS(self, sel, pares):
        """um set por quadro (tempo do corte, props), escrito como dados + laço: seek em qualquer ponto acerta"""
        self._rec(('quadros', sel, None, list(pares), None))

    def INICIO(self, sel, props):
        """estado no quadro 0: fromTo curto (um set de duração zero em 0 pode sumir quando o seek volta a 0)"""
        self.R.append(('inicio', sel, props, None, None))

    @staticmethod
    def _rt(t, org):
        return round(max(0.0, t - org), 3)

    @staticmethod
    def _rq(t, org):
        return math.floor((t - org) * 1000 + 1e-6) / 1000   # para baixo em ms: nunca cai um quadro depois

    def js(self, R, org, fim):
        o, out = self._o, []
        for k, sel, a, b, t in R:
            if k == 'ft':
                out.append(f'tl.fromTo({o(sel)},{o(a)},{o(b)},{self._rt(t, org)});')
            elif k == 'to':
                out.append(f'tl.to({o(sel)},{o(b)},{self._rt(t, org)});')
            elif k == 'set':
                out.append(f'tl.set({o(sel)},{o(b)},{self._rt(t, org)});')
            elif k == 'setq':
                tc = self._rq(t, org)
                if tc >= 0:
                    out.append(f'tl.set({o(sel)},{o(b)},{tc});')
            elif k == 'quadros':
                dados = []
                for tq, props in b:
                    tc = self._rq(tq, org)
                    if 0 <= tc <= fim:
                        dados.append([tc, props])
                if dados:
                    out.append(f'for (const [t, p] of {o(dados)}) tl.set({o(sel)}, p, t);')
            elif k == 'inicio':
                out.append(f'tl.fromTo({o(sel)},{o(a)},{o(dict(a, duration=0.04, ease="none"))},0);')
        return out

    def som(self, tipo, t, db=SOM_PEQUENO, peca=''):
        if self.T0 - 0.05 <= t <= self.T0 + self.DUR:
            self.sons_sug.append(dict(tipo=tipo, em=round(t, 3), db=db, peca=peca))

    # --- blocos
    def _blocos(self):
        if self.modo == 'corte':      # revisar o corte: tela cheia do começo ao fim, enquadrada pelo rosto
            return [dict(cena='tela_cheia', de=0.0, ate=self.P.VDUR + 60, zoom=float(self.P.cfg.get('zoom', 1.08)))]
        return resolver_blocos(self.P)

    def bloco(self, t):
        for b in self.blocos:
            if b['de'] - 1e-6 <= t < b['ate']:
                return b
        return self.blocos[-1]

    def legenda_ligada(self, b):
        lg = self.P.cfg.get('legenda', {})
        if self.modo == 'corte':
            return True
        if 'legenda' in b:
            return bool(b['legenda'])
        if not lg.get('ligada', True):
            return False
        if b['cena'] == 'moldura':
            return bool(lg.get('na_moldura', False))     # na moldura a animação explica: legenda sai
        if b['cena'] == 'tela_verde':
            return bool(lg.get('na_tela_verde', True))
        if b['cena'] == 'comparar':
            return False                                 # texto por cima do que se compara atrapalha a comparação
        return True

    # --- enquadramento
    def vid(self, fx, fy):
        """um ponto do cut.mp4 (fração de 0 a 1) em pixels da câmera (#inner), antes do zoom"""
        return self.ox + fx * self.dw, self.oy + fy * self.dh

    def rosto(self, t, b=None):
        """o rosto da tomada (face_layout.json), ou o do caminho acompanhado quando o bloco pede "acompanhar" """
        b = b or self.bloco(t)
        if b.get('acompanhar') and self.P.trilha:
            f = self.P.rosto_acompanhado(t)
            if f:
                return f
        return self.P.rosto(t)

    def place(self, t, box, zoom=1.0, b=None):
        """x, y, escala do vídeo para o rosto ficar bem na caixa (tela cheia ou moldura)"""
        W, H = self.W, self.H
        f = self.rosto(t, b)
        cx, cy = self.vid((f['x0'] + f['x1']) / 2, (f['y0'] + f['y1']) / 2)
        fwp = float(f.get('wmed') or (f['x1'] - f['x0'])) * self.dw     # acompanhado: largura da tomada (sem "respirar")
        bx, by, bw, bh = box
        if bw >= W and bh >= H:
            z = zoom
        else:                                      # moldura: rosto com uns 40% da largura da caixa
            # PrimoCode: `zoom` é um múltiplo do enquadramento da moldura (punch-in dentro do quadro; 1.0 = o de sempre)
            z = max(0.40 * bw / max(1.0, fwp), bw / self.dw, bh / self.dh) * max(1.0, float(zoom))
        # sem mostrar borda: o vídeo (retângulo ox, oy, dw, dh da câmera) cobre a caixa inteira
        x = clamp(bw / 2 - z * cx, bw - z * (self.ox + self.dw), -z * self.ox)
        # foco_y (PrimoCode, camera_viva): sob um título grande no alto, o rosto desce no quadro e abre espaço em cima
        fy = float((b or {}).get('foco_y', 0.42)) if bw >= W and bh >= H else 0.42
        y = clamp(fy * bh - z * cy, bh - z * (self.oy + self.dh), -z * self.oy)
        return dict(x=round(x + bx, 1), y=round(y + by, 1), scale=round(z, 4))

    def cam(self, t, b=None):
        """enquadramento da câmera no tempo t, com o zoom do bloco (campo "zooms") quando ele está ativo"""
        b = b or self.bloco(t)
        z = self.zoom_em(t, 'camera')
        if z and self.blocos[z['bloco']] is b:
            return self.quadro_zoom(z, t)
        return self.cam_base(t, b)

    def cam_base(self, t, b=None):
        b = b or self.bloco(t)
        if b['cena'] == 'moldura':
            return self.place(t, self.MOL, float(b.get('zoom', 1.0)), b=b)
        if b['cena'] == 'prova':
            z = float(b.get('zoom', 1.0))
            return dict(x=0, y=0, scale=1) if z == 1 else self.place(t, self.FULL, z, b=b)
        if b['cena'] in ('tela_verde', 'comparar'):
            return dict(x=0, y=0, scale=1)
        if b['cena'] == 'contida':
            rx, ry, rw, rh = self.caixa_contida(b)
            zc = rw / self.dw
            return dict(x=round(rx - zc * self.ox, 1), y=round(ry - zc * self.oy, 1), scale=round(zc, 4))
        return self.place(t, self.FULL, float(b.get('zoom', self.P.cfg.get('zoom', 1.08))), b=b)

    def caixa_contida(self, b):
        """a gravação inteira dentro da tela, com margem: (x, y, largura, altura) na tela"""
        m = float(b.get('margem', round(0.06 * min(self.W, self.H))))
        zc = min((self.W - 2 * m) / self.dw, (self.H - 2 * m) / self.dh)
        rw, rh = self.dw * zc, self.dh * zc
        return round((self.W - rw) / 2, 1), round((self.H - rh) / 2, 1), round(rw, 1), round(rh, 1)

    def quadro_de(self, b):
        """(caixa, raio) do recorte do #frame no bloco: a tela inteira, o quadro da moldura ou a gravação contida"""
        if b['cena'] == 'moldura':
            return self.MOL, self.F['raio']
        if b['cena'] == 'contida':
            return self.caixa_contida(b), int(b.get('raio', self.F['raio']))
        return self.FULL, 0

    # --- zooms dentro do bloco (punch-in na palavra, ou o zoom que anda até um ponto da tela gravada)
    def _zooms(self):
        P, out = self.P, []
        if self.modo == 'corte':
            return out
        for i, b in enumerate(self.blocos):
            zs = b.get('zooms') or []
            if not zs:
                continue
            if b['cena'] not in ('tela_cheia', 'prova', 'moldura'):
                raise Erro(f'bloco {b["cena"]} em {b["de"]:.2f} s: "zooms" só vale em tela_cheia, prova e moldura')
            alvo = 'camera' if not (b['cena'] == 'prova' and b.get('arquivo')) else f'pvw{i}'
            lista = []
            for k, z in enumerate(zs):
                nome = f'zoom {k + 1} do bloco {b["cena"]} em {b["de"]:.2f} s'
                if not isinstance(z, dict):
                    raise Erro(f'{nome}: cada zoom é {{"em": ..., "ate": ..., "caixa": [x, y, largura, altura]}}')
                t0 = P.tempo(z, 'em', depois=max(0.0, b['de'] - 0.3), antecipa=float(z.get('antecipa', 0.0)))
                if t0 is None:
                    raise Erro(f'{nome}: falta "em" (segundos do corte ou a palavra)')
                if not b['de'] - 0.35 <= t0 < b['ate'] - 0.1:
                    raise Erro(f'{nome}: "em" ({t0:.2f} s) tem que ficar dentro do bloco ({b["de"]:.2f} a {b["ate"]:.2f} s)')
                t0 = max(t0, b['de'])
                t1 = P.tempo(z, 'ate', depois=t0 + 0.05) if 'ate' in z else None
                volta = t1 is not None
                if t1 is None or t1 > b['ate'] - 1e-3:
                    if t1 is not None:
                        aviso(f'{nome}: "ate" passa do fim do bloco; o zoom fica até {b["ate"]:.2f} s')
                    t1, volta = b['ate'], False
                caixa = z.get('caixa')
                if caixa is not None and (not isinstance(caixa, (list, tuple)) or len(caixa) != 4
                                          or not all(0 <= float(v) <= 1 for v in caixa)):
                    raise Erro(f'{nome}: "caixa" é [x, y, largura, altura] em fração do vídeo gravado, de 0 a 1')
                dur = float(z.get('duracao', 0.45 if caixa else 0.2))
                lista.append(dict(bloco=i, alvo=alvo, t0=round(t0, 3), t1=round(t1, 3), volta=volta, dur=dur,
                                  dur_volta=float(z.get('duracao_volta', max(dur, 0.35))),
                                  caixa=[float(v) for v in caixa] if caixa else None,
                                  escala=float(z['escala']) if z.get('escala') else None,
                                  ease=z.get('ease', 'power3.inOut' if caixa else 'power2.out'),
                                  som=z.get('som', 'whoosh')))
            lista.sort(key=lambda z: z['t0'])
            for a_, b_ in zip(lista, lista[1:]):      # zoom que emenda no próximo: vai direto de um ponto ao outro
                if a_['t1'] > b_['t0'] - 1e-3:
                    a_['t1'], a_['volta'] = b_['t0'], False
                elif a_['volta'] and a_['t1'] + a_['dur_volta'] > b_['t0']:
                    a_['dur_volta'] = max(0.1, b_['t0'] - a_['t1'])
            out += lista
        return out

    def fim_sem_corte(self, t, dur):
        """t + dur quando não tem corte no meio (o fim de uma transição é o enquadramento do rosto naquele instante,
        o que importa quando o bloco acompanha a pessoa); com corte no meio, t (o set do corte cuida do resto)"""
        return t if any(t + 1e-3 < c <= t + dur + 1e-3 for c in self.P.cortes) else t + dur

    def zoom_em(self, t, alvo):
        for z in self.zooms:
            if z['alvo'] == alvo and z['t0'] - 1e-6 <= t < z['t1'] - 1e-6:
                return z
        return None

    def _midia_px(self, alvo):
        """(largura, altura, encaixe) do que o zoom mexe: o cut.mp4 na câmera, ou o arquivo da prova"""
        if alvo == 'camera':
            return self.P.vw, self.P.vh, 'cobrir'
        b = self.blocos[int(alvo[3:])]
        w, h = tamanho_midia(os.path.join(AQUI, b['arquivo']))
        return w or self.W, h or self.H, b.get('encaixe', 'cobrir')

    def quadro_zoom(self, z, t):
        W, H = self.W, self.H
        if z['caixa'] is None:
            s = z['escala'] or 1.3
            if z['alvo'] == 'camera':          # punch-in no rosto
                bl = self.blocos[z['bloco']]
                if bl['cena'] == 'moldura':    # dentro do quadro da moldura: múltiplo do enquadramento
                    return self.place(t, self.MOL, s, b=bl)
                return self.place(t, self.FULL, s)
            return dict(x=round(W / 2 * (1 - s), 1), y=round(H / 2 * (1 - s), 1), scale=round(s, 4))
        vw, vh, enc = self._midia_px(z['alvo'])
        k = min(W / vw, H / vh) if enc == 'inteiro' else max(W / vw, H / vh)
        ox, oy = (W - vw * k) / 2, (H - vh * k) / 2
        x0, y0, cw, ch = z['caixa']
        bx, by, bw, bh = ox + x0 * vw * k, oy + y0 * vh * k, max(1.0, cw * vw * k), max(1.0, ch * vh * k)
        s = z['escala'] or clamp(0.85 * min(W / bw, H / bh), 1.0, 6.0)
        if enc == 'inteiro':
            x = clamp(W / 2 - s * (bx + bw / 2), W - s * W, 0)
            y = clamp(H / 2 - s * (by + bh / 2), H - s * H, 0)
        else:                                  # o vídeo passa da tela (corte 9:16 num 4:5): pode andar até a borda dele
            x = clamp(W / 2 - s * (bx + bw / 2), W - s * (ox + vw * k), -s * ox)
            y = clamp(H / 2 - s * (by + bh / 2), H - s * (oy + vh * k), -s * oy)
        return dict(x=round(x, 1), y=round(y, 1), scale=round(s, 4))

    def quadro_antes_do_zoom(self, z):
        for p in self.zooms:
            if p is not z and p['alvo'] == z['alvo'] and p['t0'] < z['t0'] and p['t1'] >= z['t0'] - 1e-6:
                return self.quadro_zoom(p, z['t0'])
        if z['alvo'] == 'camera':
            return self.cam_base(z['t0'], self.blocos[z['bloco']])
        return dict(x=0, y=0, scale=1)

    def quadro_base(self, z, t):
        return self.cam_base(t, self.blocos[z['bloco']]) if z['alvo'] == 'camera' else dict(x=0, y=0, scale=1)

    def clip(self, box, rad):
        x, y, w, h = box
        r = lambda v: f'{round(v, 1):g}'   # noqa: E731
        return f'inset({r(y)}px {r(self.W - x - w)}px {r(self.H - y - h)}px {r(x)}px round {rad}px)'

    def pessoa_tf(self, b):
        """onde a pessoa recortada fica na tela verde: (tx, ty, escala)"""
        W, H = self.W, self.H
        pos = b.get('posicao', 'direita')
        s = float(b.get('altura', 0.8 if pos == 'centro' else 0.62))
        f = self.P.rosto(b['de'] + 0.05)
        fx = self.vid((f['x0'] + f['x1']) / 2, 0)[0]
        alvo = {'direita': 0.70, 'esquerda': 0.30, 'centro': 0.5}.get(pos, 0.70) * W
        tx = alvo - s * fx
        st = self._pessoa_stats(b)
        if st:   # o lado que a câmera cortou (ombro, braço) encosta na borda da tela
            if pos == 'direita' and st.get('touch_right', 0) > 0.3:
                tx = W - s * W
            elif pos == 'esquerda' and st.get('touch_left', 0) > 0.3:
                tx = 0
            lado_dentro = st.get('touch_left', 0) if pos == 'direita' else st.get('touch_right', 0)
            if pos != 'centro' and lado_dentro > 0.3:
                aviso(f'tela verde {b["de"]:.1f}s: a câmera corta o corpo do lado de dentro. Troque de lado ou use "centro".')
        return round(tx, 1), round(H - s * H, 1), round(s, 4)

    def pessoa_janela(self, recorte='assets/pessoa.webm'):
        """trecho do corte que o recorte cobre (a ferramenta pode montar só um pedaço)"""
        p = os.path.join(AQUI, os.path.splitext(recorte)[0] + '.json')
        if not os.path.exists(p):
            return 0.0, self.P.VDUR
        with open(p, encoding='utf-8') as f:
            d = json.load(f)
        return float(d.get('inicio', 0.0)), float(d.get('fim', self.P.VDUR))

    def _pessoa_stats(self, b):
        p = os.path.join(AQUI, 'assets', 'pessoa.json')
        if not os.path.exists(p):
            return None
        with open(p, encoding='utf-8') as f:
            d = json.load(f)
        for r in d.get('trechos', []):
            if r['de'] - 1e-3 <= b['de'] + 0.05 < r['ate']:
                return r.get('stats') or {}
        return None

    def rosto_tela(self, t):
        """caixa do rosto em pixels da tela no tempo t (None quando não tem rosto na tela)"""
        b = self.bloco(t)
        if (b['cena'] == 'prova' and b.get('arquivo')) or b['cena'] == 'comparar':
            return None
        if b['cena'] == 'tela_verde':
            f = self.P.rosto(t)
            tx, ty, s = self.pessoa_tf(b)
            c = dict(x=tx, y=ty, scale=s)
        else:
            f = self.rosto(t, b)
            c = self.cam(t, b)
        (ax, ay), (bx_, by_) = self.vid(f['x0'], f['y0']), self.vid(f['x1'], f['y1'])
        x0, x1 = c['x'] + c['scale'] * ax, c['x'] + c['scale'] * bx_
        y0, y1 = c['y'] + c['scale'] * ay, c['y'] + c['scale'] * by_
        return dict(x0=x0, x1=x1, y0=y0, y1=y1, topo=y0 - 0.35 * (y1 - y0))

    # --- lugar das peças
    def lugar(self, t, w, h, onde='auto', nome=''):
        if isinstance(onde, (list, tuple)):
            return int(onde[0]), int(onde[1])
        F = self.F
        sx0, sy0, sx1, sy1 = F['seguro']
        b = self.bloco(t)
        k = b['cena']
        if k == 'moldura' and b.get('rosto_grande') and onde == 'auto':
            # PrimoCode: rosto grande (quadro de largura total): a peça fica na faixa de cima, centrada, no topo da zona segura;
            # pode cobrir cabelo, nunca olhos e boca (que ficam bem abaixo do topo do quadro)
            return int((sx0 + sx1) / 2 - w / 2), int(sy0)
        if onde == 'baixo_esquerda' or (k == 'moldura' and onde == 'auto'):
            ax0, ay0, ax1, ay1 = F['area_baixo_esq'] if onde == 'baixo_esquerda' else F['area_moldura']
            if h > ay1 - ay0 or w > ax1 - ax0:
                aviso(f'{nome}: a peça ({w}x{h}) é maior que o espaço livre da moldura')
            # um pouco acima do meio da área livre: não gruda no topo nem encosta na moldura
            return int((ax0 + ax1) / 2 - w / 2), int(ay0 + max(0, (ay1 - ay0 - h) * 0.35))
        cx = (sx0 + sx1) / 2
        if k in ('prova', 'tela_verde', 'comparar'):
            return int(cx - w / 2), int(sy0 + 20)
        r = self.rosto_tela(t)
        if not r:
            return int(cx - w / 2), int(sy0 + 20)
        gap = ROSTO_PX * self.H / 1920
        livre_topo = not perto_do_rosto((cx - w / 2, sy0 + 20, cx + w / 2, sy0 + 20 + h), r, gap)
        if onde == 'topo':
            if livre_topo:
                return int(cx - w / 2), int(sy0 + 20)
            onde = 'auto'      # o topo encosta no rosto: a peça procura outro lugar (acima, embaixo)
        acima = r['topo'] - 30 - (sy0 + 20)
        if onde in ('auto', 'acima') and acima >= h:
            return int(cx - w / 2), int(max(sy0 + 20, r['topo'] - 40 - h))
        if self.formato_deitado() and onde == 'auto':     # 16:9: do lado oposto ao rosto
            fx = (r['x0'] + r['x1']) / 2
            if fx > self.W / 2 and r['x0'] - 60 - sx0 >= w:
                return int((sx0 + r['x0'] - 40) / 2 - w / 2), int(clamp((r['y0'] + r['y1']) / 2 - h / 2, sy0, sy1 - h))
            if fx <= self.W / 2 and sx1 - r['x1'] - 60 >= w:
                return int((r['x1'] + 40 + sx1) / 2 - w / 2), int(clamp((r['y0'] + r['y1']) / 2 - h / 2, sy0, sy1 - h))
        y = r['y1'] + max(40, gap)
        if y + h <= sy1:
            return int(cx - w / 2), int(y)
        if livre_topo:          # no alto da zona segura, sobre o cabelo, mas longe da caixa do rosto
            return int(cx - w / 2), int(sy0 + 20)
        aviso(f'{nome}: não coube sem encostar no rosto (em {t:.2f} s). Use moldura nesse trecho ou uma peça menor.')
        return int(cx - w / 2), int(sy0 + 20)

    def formato_deitado(self):
        return self.W > self.H

    def reserva(self, t0, t1, x, y, w, h):
        self.retangulos.append((t0, t1, x, y, w, h))

    def css(self, chave, texto):
        if chave not in self.CSS:
            v = {k: v2 for k, v2 in self.F.items() if isinstance(v2, (int, float))}
            v.update(self.E)
            v.update(W=self.W, H=self.H)
            self.CSS[chave] = Template(texto).substitute(v)


# ---------------------------------------------------------------------------------------- texto
def rico(texto):
    """escapa e troca *palavra* por destaque"""
    partes = escape(str(texto)).split('*')
    return ''.join(f'<b>{p}</b>' if i % 2 else p for i, p in enumerate(partes))


def largura_texto(texto, fs, peso=900):
    limpo = str(texto).replace('*', '')
    fator = 0.74 if limpo.upper() == limpo else 0.64
    return len(limpo) * fs * fator * (1 if peso >= 800 else 0.9)


def grafemas(texto):
    """letra por letra, com o acento combinante junto da letra (senão o acento vira uma letra solta)"""
    out = []
    for ch in unicodedata.normalize('NFC', texto):
        if out and unicodedata.combining(ch):
            out[-1] += ch
        else:
            out.append(ch)
    return out


# ---------------------------------------------------------------------------------------- peças do catálogo
def moldura_fechando(M, t):
    """a moldura que ainda está fechando no tempo t (ou chega nos próximos 0,5 s), ou None"""
    for b in M.blocos:
        if b['cena'] == 'moldura' and b['de'] - 0.5 - 1e-6 <= t < b.get('_fecha', b['de']) - 1e-3:
            return b
    return None


def depois_de(tempos, t0, passo=0.15):
    """itens que ficariam antes da peça aparecer entram logo depois dela, em ordem"""
    out, ant = [], None
    for t in tempos:
        t2 = max(t, t0) if ant is None else max(t, t0, ant + passo)
        out.append(t2)
        ant = t2
    return out


FIM_SEM_SAIDA = 0.35     # a peça que vai até o fim sai depois do último quadro (as saídas levam até 0,3 s)


def entrada_saida(M, p, padrao):
    P = M.P
    M.empurrada = M.ate_o_fim = False
    t_in = P.tempo(p, 'em', antecipa=float(p.get('antecipa', 0.1)))
    if t_in is None:
        raise Erro(f'animação {p.get("id", "?")}: falta "em" (segundos ou a palavra)')
    t_out = P.tempo(p, 'sai', depois=t_in + 0.05, antecipa=0.05)
    if t_out is None:
        t_out = t_in + padrao
    if t_out >= P.VDUR - 0.25:
        # a peça vai até o fim do corte: fica legível até o último quadro (com o fim parado, segura_fim, até o fim
        # dele); a saída cai depois do último quadro
        if p.get('tipo') == 'atras_da_pessoa' and M.segura:
            aviso(f'{p.get("id")}: o texto atrás da pessoa sai no fim da fala ({P.VDUR:.2f} s): no fim parado não tem a '
                  f'pessoa recortada na frente dele')
            t_out = min(t_out, P.VDUR)
        else:
            t_out = P.VDUR + M.segura + FIM_SEM_SAIDA
            M.ate_o_fim = True
    mol = moldura_fechando(M, t_in)
    if mol is not None:
        # o quadro vence: enquanto a moldura fecha (0,7 s) a peça passaria na frente do rosto ainda grande
        novo = round(mol['_fecha'] + 0.02, 3)
        aviso(f'{p.get("id")}: entra em {novo:.2f} s, quando o quadro da moldura terminou de fechar (pedia {t_in:.2f}; '
              f'a moldura entra em {mol["de"]:.2f} e leva {novo - mol["de"]:.1f} s)')
        t_in, M.empurrada = novo, True
    b = M.bloco(t_in)
    if b['cena'] in ('moldura', 'tela_verde', 'prova', 'contida', 'comparar') and t_out > b['ate'] - 0.1 and b['ate'] < P.VDUR:
        aviso(f'{p.get("id")}: sai em {b["ate"] - 0.1:.2f} s, antes do fim do bloco {b["cena"]} (pedia {t_out:.2f})')
        t_out = b['ate'] - 0.1
    t_out = max(t_out, t_in + 0.4)
    if M.alvo is not None:
        M.alvo.saida = (t_in, t_out)
    return t_in, t_out


def corta_itens(pid, itens, tempos, t_out, nome='item'):
    """item cuja palavra cai depois da saída da peça ficaria na tela para sempre: sai da lista, com aviso"""
    manter = [k for k, t in enumerate(tempos) if t <= t_out - 0.35]
    if len(manter) < len(tempos):
        fora = ', '.join(str(itens[k].get('texto', k + 1)) for k in range(len(tempos)) if k not in manter)
        aviso(f'{pid}: {nome} "{fora}" cai depois da saída ({t_out:.2f} s) e ficou de fora')
    if not manter:
        raise Erro(f'{pid}: nenhum {nome} cabe antes da saída ({t_out:.2f} s)')
    return [itens[k] for k in manter], [tempos[k] for k in manter]


def medida_rotulo(texto, s, lmax=None):
    """(letra, largura) do rótulo; com largura máxima (comparar), a letra desce até caber"""
    fs = round(46 * s)
    lmax = float(lmax or 800 * s)
    if largura_texto(texto, fs) + 110 * s > lmax:
        fs = max(26, int(fs * (lmax - 110 * s) / max(1.0, largura_texto(texto, fs))))
    return fs, int(clamp(largura_texto(texto, fs) + 110 * s, min(300 * s, lmax), lmax))


def peca_rotulo(M, p, pid):
    s = M.F['escala']
    t_in, t_out = entrada_saida(M, p, 2.5)
    fs, w = medida_rotulo(p['texto'], s, p.get('largura_max'))
    h = round(100 * s)
    x, y = M.lugar(t_in, w, h, p.get('onde', 'auto'), pid)
    M.css('rotulo', '''.rotulo{position:absolute;border-radius:999px;display:flex;align-items:center;justify-content:center;
  font-weight:900;color:$tinta;white-space:nowrap;opacity:0;background:$papel;box-shadow:$sombra}
.rotulo b{color:$destaque}''')
    M.HT.append(f'<div class="rotulo" id="{pid}" style="left:{x}px;top:{y}px;width:{w}px;height:{h}px;font-size:{fs}px">'
                f'<span>{rico(p["texto"])}</span></div>')   # span: texto solto em flex perde o espaço antes do <b>
    # nenhuma peça anima escala: depois de um tween de escala o Chrome guarda o desenho numa escala que depende do
    # que a página já tocou e da máquina, e dois renders do mesmo projeto saem diferentes (references/animacao.md)
    M.FT(f'#{pid}', {'opacity': 0, 'y': -44}, {'opacity': 1, 'y': 0, 'duration': 0.35, 'ease': 'back.out(2)'}, t_in)
    M.TO(f'#{pid}', {'opacity': 0, 'y': -28, 'duration': 0.25, 'ease': 'power3.in'}, t_out - 0.25)
    M.reserva(t_in, t_out, x, y, w, h)
    M.som('pop', t_in + 0.05, SOM_PEQUENO, pid)


def carimbo_bate(M, sel, h, t):
    """o carimbo cai e bate: vem de cima acelerando (power4.in), girando de -18 para -8 graus, e pousa no lugar no
    quadro do impacto. Sem escala (antes caía de 2,4x): tween de escala deixa dois renders diferentes."""
    M.FT(sel, {'opacity': 0, 'y': -round(1.6 * h), 'rotation': -18},
         {'opacity': 1, 'y': 0, 'rotation': -8, 'duration': 0.28, 'ease': 'power4.in'}, t)


def peca_carimbo(M, p, pid):
    s = M.F['escala']
    t_in, t_out = entrada_saida(M, p, 1.6)
    fs = round(64 * s)
    w = int(largura_texto(p['texto'], fs) + 80 * s)
    h = round(fs * 1.25 + 34 * s)
    x, y = M.lugar(t_in, w, h, p.get('onde', 'auto'), pid)
    peca_carimbo_css(M)
    cor = ' verde' if p.get('cor') == 'verde' else ''
    M.HT.append(f'<div class="carimbo{cor}" id="{pid}" style="left:{x}px;top:{y}px;width:{w}px;height:{h}px;font-size:{fs}px">'
                f'{escape(p["texto"])}</div>')
    carimbo_bate(M, f'#{pid}', h, t_in)
    if p.get('tremer', True):   # a tela treme no impacto (o #palco fica no index.html: vai na timeline principal)
        M.FT('#palco', {'x': 0}, {'keyframes': [{'x': -8, 'duration': 0.04}, {'x': 7, 'duration': 0.04}, {'x': 0, 'duration': 0.05}]}, t_in + 0.28)
    M.TO(f'#{pid}', {'opacity': 0, 'y': -16, 'duration': 0.2, 'ease': 'power2.in'}, t_out - 0.2)
    M.reserva(t_in, t_out, x, y, w, h)
    M.som('impacto', t_in + 0.27, SOM_CHAVE, pid)


SVG_OK = '<svg viewBox="0 0 40 40"><path pathLength="1" d="M8 21 L17 30 L33 11"/></svg>'
SVG_X = '<svg viewBox="0 0 40 40"><path pathLength="1" d="M10 10 L30 30"/><path pathLength="1" d="M30 10 L10 30"/></svg>'


def peca_lista(M, p, pid):
    P, s = M.P, M.F['escala']
    itens = p['itens']
    tempos = []
    depois = P.tempo(p, 'em') - 0.5 if 'em' in p else float(p.get('depois_de', 0))
    for k, it in enumerate(itens):
        it = dict(it, depois_de=it.get('depois_de', depois))
        t = P.tempo(it, 'em', antecipa=0.1)
        if t is None:
            raise Erro(f'{pid}: item {k + 1} sem "em" (segundos ou a palavra)')
        tempos.append(t)
        depois = t + 0.05
    p = dict(p)
    p.setdefault('em', tempos[0] - 0.35)
    t_in, t_out = entrada_saida(M, dict(p, antecipa=0), max(2.0, tempos[-1] - tempos[0] + 1.6))
    if M.empurrada:
        tempos = depois_de(tempos, t_in + 0.1)
    itens, tempos = corta_itens(pid, itens, tempos, t_out)
    marca = p.get('marca', 'check')
    lh, fs = round(110 * s), round(44 * s)
    tit = p.get('titulo')
    w = round(780 * s)
    if M.formato_deitado():
        # 16:9: com o rosto perto do meio, de cada lado dele sobram uns 700 px; a lista do tamanho do texto (nunca menos
        # de 520) cabe ao lado do rosto, e a de 780 cairia por cima dele
        folga = round((68 + 20 + 64) * s)
        precisa = max([largura_texto(str(it.get('texto', '')), fs) + folga for it in itens]
                      + ([largura_texto(str(tit), fs) + round(68 * s)] if tit else [0]))
        w = int(clamp(precisa * 1.08, 520 * s, w))
    h = round((26 * 2 + (62 if tit else 0)) * s) + lh * len(itens)
    x, y = M.lugar(t_in, w, h, p.get('onde', 'auto'), pid)
    M.css('lista', '''.lista{position:absolute;border-radius:32px;padding:26px 34px;opacity:0;background:$papel;box-shadow:$sombra}
.lista .tit{font-weight:900;color:$tinta;white-space:nowrap}
.lista .tit b{color:$destaque}
.linha{position:relative;display:flex;align-items:center;gap:20px;border-top:3px dashed $linha;opacity:0}
.linha .txt{position:relative;font-weight:900;white-space:nowrap;color:$tinta}
.marca{margin-left:auto;width:64px;height:64px;border-radius:50%;display:flex;align-items:center;justify-content:center;opacity:0;flex:none}
.marca.ok{background:$verde}
.marca.x{background:$vermelho}
.marca svg{width:40px;height:40px;overflow:visible}
.marca path{fill:none;stroke-width:8;stroke-linecap:round;stroke-linejoin:round;stroke-dasharray:1;stroke-dashoffset:1}
.marca.ok path{stroke:$sobre_verde}
.marca.x path{stroke:$sobre_vermelho}
.risco{position:absolute;left:-8px;right:-8px;top:50%;height:8px;margin-top:-4px;border-radius:4px;background:$vermelho;opacity:0}''')
    linhas = []
    for k, it in enumerate(itens):
        risco = f'<i class="risco" id="{pid}-r{k}"></i>' if marca == 'x' else ''
        icone = SVG_X if marca == 'x' else SVG_OK
        linhas.append(f'<div class="linha" id="{pid}-l{k}" style="height:{lh}px"><span class="txt" style="font-size:{fs}px">'
                      f'{rico(it["texto"])}{risco}</span><span class="marca {"x" if marca == "x" else "ok"}" id="{pid}-m{k}">{icone}</span></div>')
    titulo = f'<div class="tit" style="font-size:{fs}px;margin-bottom:{round(12 * s)}px">{rico(tit)}</div>' if tit else ''
    M.HT.append(f'<div class="lista" id="{pid}" style="left:{x}px;top:{y}px;width:{w}px">{titulo}{"".join(linhas)}</div>')
    M.FT(f'#{pid}', {'opacity': 0, 'y': 64}, {'opacity': 1, 'y': 0, 'duration': 0.35, 'ease': 'back.out(2)'}, t_in)
    M.som('pop', t_in + 0.05, SOM_PEQUENO, pid)
    for k, t in enumerate(tempos):
        M.FT(f'#{pid}-l{k}', {'opacity': 0, 'x': -40}, {'opacity': 1, 'x': 0, 'duration': 0.3, 'ease': 'back.out(2)'}, t)
        # a bolinha bate: cai de cima acelerando (power4.in) e se abre do meio para fora, sem escala
        M.FT(f'#{pid}-m{k}', {'opacity': 0, 'y': -26, 'clipPath': 'circle(20% at 50% 50%)'},
             {'opacity': 1, 'y': 0, 'clipPath': 'circle(71% at 50% 50%)', 'duration': 0.18, 'ease': 'power4.in'}, t + 0.25)
        M.FT(f'#{pid}-m{k} path', {'strokeDashoffset': 1}, {'strokeDashoffset': 0, 'duration': 0.25, 'ease': 'power2.out'}, t + 0.3)
        if marca == 'x':
            M.FT(f'#{pid}-r{k}', {'opacity': 1, 'clipPath': 'inset(0% 100% 0% 0%)'},
                 {'clipPath': 'inset(0% 0% 0% 0%)', 'duration': 0.22, 'ease': 'power2.out'}, t + 0.3)
        M.som('tique' if marca != 'x' else 'risco', t + 0.43, SOM_PEQUENO, f'{pid}-{k}')
    M.TO(f'#{pid}', {'opacity': 0, 'y': -28, 'duration': 0.25, 'ease': 'power3.in'}, t_out - 0.25)
    M.reserva(t_in, t_out, x, y, w, h)


def peca_passos(M, p, pid):
    P, s = M.P, M.F['escala']
    passos, tempos, depois = p['passos'], [], (P.tempo(p, 'em') - 0.5 if 'em' in p else float(p.get('depois_de', 0)))
    for k, it in enumerate(passos):
        t = P.tempo(dict(it, depois_de=it.get('depois_de', depois)), 'em', antecipa=0.1)
        if t is None:
            raise Erro(f'{pid}: passo {k + 1} sem "em"')
        tempos.append(t)
        depois = t + 0.05
    fim = p.get('final')
    t_fim = P.tempo(dict(fim, depois_de=fim.get('depois_de', depois)), 'em', antecipa=0.1) if fim else None
    p = dict(p)
    p.setdefault('em', tempos[0] - 0.1)
    t_in, t_out = entrada_saida(M, dict(p, antecipa=0), (t_fim or tempos[-1]) - tempos[0] + 1.4)
    if M.empurrada:
        tempos = depois_de(tempos, t_in + 0.1, 0.4)
        if t_fim is not None:
            t_fim = max(t_fim, tempos[-1] + 0.4)
    passos, tempos = corta_itens(pid, passos, tempos, t_out, 'passo')
    if fim and t_fim > t_out - 0.35:
        aviso(f'{pid}: o final "{fim["texto"]}" cai depois da saída ({t_out:.2f} s) e ficou de fora')
        fim, t_fim = None, None
    bh, gap, bw = round(118 * s), round(52 * s), round(600 * s)
    # passo com texto comprido ("Palavra por palavra"): a caixa cresce até o texto caber (a de 600 só vale para os curtos)
    sx0, _sy0, sx1, _sy1 = M.F['seguro']
    # largura do texto na Montserrat 900 medida no navegador: 0,55 da letra por caractere (0,72 em caixa alta)
    precisa = max(len(t) * round(48 * s) * (0.72 if t.upper() == t else 0.55)
                  for t in (str(it.get('texto', '')).replace('*', '') for it in passos)) + round((30 * 2 + 70 + 22 + 6) * s)
    bw = int(clamp(precisa, bw, max(bw, sx1 - sx0 - 20)))
    w, h = bw, len(passos) * bh + (len(passos) - 1) * gap + (round(120 * s) if fim else 0)
    x, y = M.lugar(t_in, w, h, p.get('onde', 'auto'), pid)
    M.css('passos', '''.passo{position:absolute;border-radius:30px;display:flex;align-items:center;gap:22px;padding:0 30px;opacity:0;background:$papel;box-shadow:$sombra}
.passo .n{width:70px;height:70px;border-radius:50%;background:$base;color:$sobre_base;font-weight:900;font-size:38px;display:flex;align-items:center;justify-content:center;flex:none}
.passo b{font-weight:900;color:$tinta;white-space:nowrap}
.seta{position:absolute;overflow:visible;opacity:0}
.seta path{fill:none;stroke:$destaque;stroke-width:6;stroke-linecap:round;stroke-linejoin:round;stroke-dasharray:1;stroke-dashoffset:1}''')
    partes = []
    for k, it in enumerate(passos):
        yy = y + k * (bh + gap)
        partes.append(f'<div class="passo" id="{pid}-p{k}" style="left:{x}px;top:{yy}px;width:{bw}px;height:{bh}px">'
                      f'<span class="n">{k + 1}</span><b style="font-size:{round(48 * s)}px">{rico(it["texto"])}</b></div>')
        if k:
            partes.append(f'<svg class="seta" id="{pid}-s{k}" style="left:{x + bw / 2 - 20:.0f}px;top:{yy - gap:.0f}px;width:40px;height:{gap}px" '
                          f'viewBox="0 0 40 {gap}"><path pathLength="1" d="M20 4 L20 {gap - 8} M9 {gap - 19} L20 {gap - 6} L31 {gap - 19}"/></svg>')
    if fim:
        partes.append(f'<div class="carimbo verde" id="{pid}-f" style="left:{x + bw - round(300 * s)}px;top:{y + h - round(110 * s)}px;'
                      f'height:{round(100 * s)}px;padding:0 {round(24 * s)}px;font-size:{round(56 * s)}px">{escape(fim["texto"])}</div>')
        peca_carimbo_css(M)
    M.HT.append(f'<div id="{pid}">{"".join(partes)}</div>')
    for k, t in enumerate(tempos):
        if k:
            M.FT(f'#{pid}-s{k}', {'opacity': 0}, {'opacity': 1, 'duration': 0.05}, t - 0.35)
            M.FT(f'#{pid}-s{k} path', {'strokeDashoffset': 1}, {'strokeDashoffset': 0, 'duration': 0.3}, t - 0.35)
        M.FT(f'#{pid}-p{k}', {'opacity': 0, 'x': -60}, {'opacity': 1, 'x': 0, 'duration': 0.35, 'ease': 'back.out(2)'}, t)
        M.som('clique', t + 0.08, SOM_PEQUENO, f'{pid}-{k}')
    if fim:
        carimbo_bate(M, f'#{pid}-f', round(100 * s), t_fim)
        M.som('impacto', t_fim + 0.27, SOM_CHAVE, f'{pid}-final')
    M.TO(f'#{pid} > *', {'opacity': 0, 'y': -20, 'duration': 0.2, 'stagger': 0.03, 'ease': 'power3.in'}, t_out - 0.3)
    M.reserva(t_in, t_out, x, y, w, h)


def peca_carimbo_css(M):
    M.css('carimbo', '''.carimbo{position:absolute;display:flex;align-items:center;justify-content:center;border:7px solid $vermelho;
  border-radius:16px;color:$vermelho;font-weight:900;letter-spacing:2px;white-space:nowrap;opacity:0;background:$carimbo_fundo}
.carimbo.verde{border-color:$verde;color:$verde}''')


def fmt_num(v, casas):
    s = f'{abs(v):,.{casas}f}'.replace(',', '#').replace('.', ',').replace('#', '.')
    return ('-' if v < 0 else '') + s


def peca_contador(M, p, pid):
    s = M.F['escala']
    t_in, t_out = entrada_saida(M, p, 2.4)
    de, ate, casas = float(p.get('valor_inicial', 0)), float(p['valor_final']), int(p.get('casas', 0))
    pre, suf = p.get('prefixo', ''), p.get('sufixo', '')
    dur = float(p.get('duracao', 0.9))
    fs = round(120 * s)
    num_max = fmt_num(max(abs(de), abs(ate)), casas)
    w = int(clamp(len(num_max) * fs * 0.72 + largura_texto(pre + suf, fs * 0.45) + 110 * s, 420 * s, 800 * s))
    h = round((fs + (70 if p.get('rotulo') else 0) + 60) * 1)
    x, y = M.lugar(t_in, w, h, p.get('onde', 'auto'), pid)
    M.css('contador', '''.contador{position:absolute;border-radius:32px;display:flex;flex-direction:column;align-items:center;justify-content:center;opacity:0;background:$papel;box-shadow:$sombra}
.contador .num{font-weight:900;line-height:1;color:$tinta;font-variant-numeric:tabular-nums;white-space:nowrap}
.contador .num i{font-style:normal;color:$destaque;font-size:.45em;margin:0 .15em}
.contador .rot{font-family:Caveat;font-weight:700;font-size:54px;color:$destaque;line-height:1.2}''')
    rot = f'<div class="rot">{escape(p["rotulo"])}</div>' if p.get('rotulo') else ''
    # o texto começa com o valor inicial no HTML: texto que nasce vazio não volta quando o seek anda pra trás
    M.HT.append(f'<div class="contador" id="{pid}" style="left:{x}px;top:{y}px;width:{w}px;height:{h}px">'
                f'<div class="num" style="font-size:{fs}px"><i>{escape(pre)}</i><span id="{pid}-v">{ZW}{fmt_num(de, casas)}</span>'
                f'<i>{escape(suf)}</i></div>{rot}</div>')
    M.FT(f'#{pid}', {'opacity': 0, 'y': 60}, {'opacity': 1, 'y': 0, 'duration': 0.35, 'ease': 'back.out(2)'}, t_in)
    a = t_in + 0.2
    f0, f1 = int(math.ceil(a * FPS)), int(math.ceil((a + dur) * FPS))
    pares = []
    for f in range(f0, f1 + 1):   # um valor por quadro: seek em qualquer ponto mostra o número certo
        u = clamp((f / FPS - a) / dur, 0, 1)
        v = de + (ate - de) * (1 - (1 - u) ** 3)
        pares.append((f / FPS, {'textContent': ZW + fmt_num(v, casas)}))
    M.QUADROS(f'#{pid}-v', pares)
    # pousa: um pulinho para cima e volta (no lugar do "cresce 6%")
    M.FT(f'#{pid}', {'y': 0}, {'keyframes': [{'y': round(-14 * s), 'duration': 0.12, 'ease': 'power2.out'},
                                             {'y': 0, 'duration': 0.25, 'ease': 'power2.in'}]}, a + dur)
    M.TO(f'#{pid}', {'opacity': 0, 'y': -28, 'duration': 0.25, 'ease': 'power3.in'}, t_out - 0.25)
    M.reserva(t_in, t_out, x, y, w, h)
    M.som('contagem', a, SOM_PEQUENO, pid)
    M.som('impacto', a + dur, SOM_CHAVE, pid)


SVG_SACOLA = ('<svg viewBox="0 0 30 30"><path d="M7 11h16l-1.6 14H8.6z M11 11V9a4 4 0 0 1 8 0v2" fill="none" stroke="currentColor" '
              'stroke-width="3" stroke-linejoin="round" stroke-linecap="round"/></svg>')
SVG_OK_P = '<svg viewBox="0 0 30 30" width="24" height="24"><path d="M7 15 L13 21 L23 9" fill="none" stroke="currentColor" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/></svg>'


def peca_notificacao(M, p, pid):
    P, s = M.P, M.F['escala']
    notas, tempos, depois = p['notas'], [], (P.tempo(p, 'em') - 0.5 if 'em' in p else float(p.get('depois_de', 0)))
    for k, it in enumerate(notas):
        t = P.tempo(dict(it, depois_de=it.get('depois_de', depois)), 'em', antecipa=0.05)
        if t is None:
            raise Erro(f'{pid}: notificação {k + 1} sem "em"')
        tempos.append(t)
        depois = t + 0.05
    p = dict(p)
    p.setdefault('em', tempos[0] - 0.5)
    t_in, t_out = entrada_saida(M, dict(p, antecipa=0), tempos[-1] - tempos[0] + 2.0)
    if M.empurrada:
        tempos = depois_de(tempos, t_in + 0.5, 0.3)
    notas, tempos = corta_itens(pid, notas, tempos, t_out, 'notificação')
    w, h = round(400 * s), round(700 * s)
    x, y = M.lugar(t_in, w, h, p.get('onde', 'auto'), pid)
    M.css('notificacao', '''.cel{position:absolute;border-radius:52px;border:12px solid $cel_aro;background:$cel_corpo;opacity:0;box-shadow:$sombra}
.cel .tela{position:absolute;inset:12px;border-radius:36px;overflow:hidden;padding:24px 18px;background:$papel}
.cel .hoje{font-family:Caveat;font-weight:700;font-size:56px;color:$destaque;margin-bottom:14px;line-height:1.1}
.nt{height:92px;border-radius:20px;background:$item;opacity:0;display:flex;align-items:center;gap:12px;padding:0 14px;margin-bottom:14px;box-shadow:0 6px 0 -1px $base,0 8px 18px rgba(0,0,0,.12)}
.nt .ic{width:52px;height:52px;border-radius:14px;background:$destaque;color:$sobre_destaque;flex:none;display:flex;align-items:center;justify-content:center}
.nt .ic svg{width:30px;height:30px}
.nt b{font-weight:900;font-size:26px;white-space:nowrap;color:$tinta}
.nt .ok{margin-left:auto;width:42px;height:42px;border-radius:50%;background:$verde;color:$sobre_verde;flex:none;display:flex;align-items:center;justify-content:center}''')
    nts = ''.join(f'<div class="nt" id="{pid}-n{k}"><span class="ic">{SVG_SACOLA}</span><b>{escape(it["texto"])}</b>'
                  f'<span class="ok">{SVG_OK_P}</span></div>' for k, it in enumerate(notas))
    M.HT.append(f'<div class="cel" id="{pid}" style="left:{x}px;top:{y}px;width:{w}px;height:{h}px"><div class="tela">'
                f'<div class="hoje">{escape(p.get("titulo", "Hoje"))}</div>{nts}</div></div>')
    M.FT(f'#{pid}', {'opacity': 0, 'y': 120}, {'opacity': 1, 'y': 0, 'duration': 0.5, 'ease': 'back.out(1.5)'}, t_in)
    M.som('whoosh', t_in + 0.2, SOM_PEQUENO, pid)
    for k, t in enumerate(tempos):
        M.FT(f'#{pid}-n{k}', {'opacity': 0, 'y': -80}, {'opacity': 1, 'y': 0, 'duration': 0.3, 'ease': 'back.out(2)'}, t)
        M.som('ding' if k == 0 else 'pop', t + 0.1, SOM_CHAVE if k == 0 else SOM_PEQUENO, f'{pid}-{k}')
    M.TO(f'#{pid}', {'opacity': 0, 'y': 60, 'duration': 0.3, 'ease': 'power3.in'}, t_out - 0.3)
    M.reserva(t_in, t_out, x, y, w, h)


def peca_polaroide(M, p, pid):
    P, s = M.P, M.F['escala']
    fotos, tempos, depois = p['fotos'], [], (P.tempo(p, 'em') - 0.5 if 'em' in p else float(p.get('depois_de', 0)))
    for k, it in enumerate(fotos):
        t = P.tempo(dict(it, depois_de=it.get('depois_de', depois)), 'em', antecipa=0.0)
        if t is None:
            raise Erro(f'{pid}: foto {k + 1} sem "em"')
        if not os.path.exists(os.path.join(AQUI, it['arquivo'])):
            raise Erro(f'{pid}: não achei {it["arquivo"]} (copie a foto para assets/)')
        tempos.append(t)
        depois = t + 0.05
    p = dict(p)
    p.setdefault('em', tempos[0])
    t_in, t_out = entrada_saida(M, dict(p, antecipa=0), tempos[-1] - tempos[0] + 2.2)
    if M.empurrada:
        tempos = depois_de(tempos, t_in, 0.4)
    fotos, tempos = corta_itens(pid, fotos, tempos, t_out, 'foto')
    n = len(fotos)
    fw, fh = round(330 * s), round(420 * s)
    gap = round(120 * s) if n == 2 else round(30 * s)
    w, h = n * fw + (n - 1) * gap, fh
    x, y = M.lugar(t_in, w, h, p.get('onde', 'auto'), pid)
    M.css('polaroide', '''.pola{position:absolute;border-radius:14px;opacity:0;background:$polaroide;box-shadow:$sombra}
.pola img{display:block;border-radius:8px;object-fit:cover}
.pola span{display:block;text-align:center;font-family:Caveat;font-weight:700;color:$destaque;white-space:nowrap}''')
    pad = round(16 * s)
    partes = []
    for k, it in enumerate(fotos):
        xx = x + k * (fw + gap)
        foco = it.get('foco', '50% 50%')
        iw_, ih_ = fw - 2 * pad, round(fh * 0.786)
        foto = imagem_no_tamanho(it['arquivo'], iw_, ih_, 'cover')
        partes.append(f'<div class="pola" id="{pid}-f{k}" style="left:{xx}px;top:{y}px;width:{fw}px;height:{fh}px;padding:{pad}px {pad}px 0">'
                      f'<img src="{escape(foto)}" alt="" style="width:{iw_}px;height:{ih_}px;object-position:{foco}"/>'
                      f'<span style="font-size:{round(46 * s)}px;line-height:{round(fh * 0.16)}px">{escape(it.get("legenda", ""))}</span></div>')
    M.HT.append(f'<div id="{pid}">{"".join(partes)}</div>')
    for k, t in enumerate(tempos):
        rot = -6 if k % 2 == 0 else 5
        M.FT(f'#{pid}-f{k}', {'opacity': 0, 'y': -120, 'rotation': rot * 2.6},
             {'opacity': 1, 'y': 0, 'rotation': rot, 'duration': 0.4, 'ease': 'back.out(1.7)'}, t)
        M.som('camera', t + 0.12, SOM_PEQUENO, f'{pid}-{k}')
    M.TO(f'#{pid} > .pola', {'opacity': 0, 'y': -40, 'duration': 0.25, 'stagger': 0.05, 'ease': 'power3.in'}, t_out - 0.3)
    M.reserva(t_in, t_out, x, y, w, h)


def peca_na_mao(M, p, pid):
    """imagem presa na mão: segue a palma quadro a quadro (maos.json da ferramenta rastrear_maos.py)"""
    t_in, t_out = entrada_saida(M, p, 3.0)
    arq = os.path.join(AQUI, p.get('maos', 'assets/maos.json'))
    if not os.path.exists(arq):
        raise Erro(f'{pid}: falta {p.get("maos", "assets/maos.json")}. Rode ferramentas/rastrear_maos.py antes.')
    with open(arq, encoding='utf-8') as f:
        D = json.load(f)
    qual = p.get('mao', 'direita_da_imagem')
    tam = float(p.get('tamanho', 180 * M.F['escala']))
    folga = float(p.get('folga', 30))
    quadros = {int(round(q['t'] * FPS)): q for q in D['quadros']}
    n0, n1 = int(math.floor(t_in * FPS)), int(math.ceil(t_out * FPS))
    alvo, ult = {}, None
    for n in range(n0, n1 + 1):
        q = quadros.get(n)
        maos = [m for m in (q or {}).get('maos', []) if qual == 'qualquer' or m['lado'] == qual]
        if maos:
            m = maos[0]
            c = M.cam(n / FPS)
            # acima dos nós dos dedos: a palma muitas vezes fica perto da borda de baixo
            nx, ny = M.vid(m['nos'][0], m['nos'][1])
            sx, sy = c['x'] + c['scale'] * nx, c['y'] + c['scale'] * ny
            ult = (sx, sy - tam / 2 - folga)
        alvo[n] = ult
    if not any(alvo.values()):
        raise Erro(f'{pid}: a mão "{qual}" não aparece entre {t_in:.2f} e {t_out:.2f} s')
    prim = next(v for v in alvo.values() if v)
    for n in alvo:
        alvo[n] = alvo[n] or prim
    # mola: segue com um atraso curto e sobra um pouco na parada (parece preso na mão, não colado na tela)
    wn, zeta, sub = 2 * math.pi * 3.4, 0.45, 10
    pos, vel = list(prim), [0.0, 0.0]
    M.css('namao', '''.namao{position:absolute;left:0;top:0;width:0;height:0}
.namao .pop{position:absolute;opacity:0}
.namao img{display:block;width:100%;height:100%;object-fit:contain;filter:drop-shadow(0 14px 22px rgba(0,0,0,.45))}''')
    img = imagem_no_tamanho(p['imagem'], round(tam), round(tam), 'contain')
    M.HT.append(f'<div class="namao" id="{pid}"><div class="pop" id="{pid}-p" style="left:{-tam / 2:.0f}px;top:{-tam / 2:.0f}px;'
                f'width:{tam:.0f}px;height:{tam:.0f}px"><img src="{escape(img)}" alt=""/></div></div>')
    pares = []
    for n in range(n0, n1 + 1):
        tgt = alvo[n]
        for _ in range(sub):
            for d in (0, 1):
                acc = wn * wn * (tgt[d] - pos[d]) - 2 * zeta * wn * vel[d]
                vel[d] += acc / (FPS * sub)
                pos[d] += vel[d] / (FPS * sub)
        rz = clamp(-vel[0] * 0.006, -11, 11)
        pares.append((n / FPS, {'x': round(pos[0], 1), 'y': round(pos[1], 1), 'rotation': round(rz, 2)}))
    M.QUADROS(f'#{pid}', pares)
    # nasce da mão: um círculo que se abre do meio (a sombra cabe no círculo aberto) e sobe um pouco, sem escala
    M.FT(f'#{pid}-p', {'opacity': 0, 'y': 24, 'clipPath': 'circle(12% at 50% 50%)'},
         {'opacity': 1, 'y': 0, 'clipPath': 'circle(100% at 50% 50%)', 'duration': 0.4, 'ease': 'back.out(2)'}, t_in)
    M.TO(f'#{pid}-p', {'opacity': 0, 'clipPath': 'circle(12% at 50% 50%)', 'duration': 0.18, 'ease': 'power2.in'}, t_out - 0.18)
    M.som('pop', t_in + 0.08, SOM_CHAVE, pid)
    M.som('pop', t_out - 0.1, SOM_PEQUENO, pid)


def peca_hud(M, p, pid):
    """pílula de "editando ao vivo" com o timecode do quadro e o nome da etapa"""
    t_in, t_out = entrada_saida(M, dict(p, antecipa=0), 6.0)
    secoes = []
    for sct in p.get('secoes', []):
        secoes.append((M.P.tempo(sct, 'em', depois=t_in, antecipa=0.02), sct['nome']))
    fim = M.P.tempo(p, 'concluido_em', depois=t_in) if 'concluido_em' in p else None
    k = float(p.get('tamanho', 1.0))           # 1 = pílula de 52 px com letra de 21 px; 1.4 lê melhor no celular
    if not 0.5 <= k <= 3:
        raise Erro(f'{pid}: "tamanho" do hud é um fator de 0.5 a 3 (1 = o padrão, 1.4 = 40% maior)')
    topo = int(p.get('topo', M.F['hud_topo']))
    if topo < M.F['seguro'][1] and k > 1.2:
        aviso(f'{pid}: o painel está em y {topo}, acima da zona segura (o Reels põe o nome do perfil ali); '
              f'com "tamanho" maior, pense em "topo": {M.F["seguro"][1]}')

    def px(v):
        return f'{round(v * k, 1):g}px'
    M.css('hud', f'''#hud{{position:absolute;left:0;top:{topo}px;width:100%;height:{px(60)};opacity:0;pointer-events:none;z-index:60}}
#hud .pill{{position:absolute;top:0;height:{px(52)};border-radius:{px(26)};background:$hud_fundo;border:{px(1.5)} solid $hud_borda;
  display:flex;align-items:center;font-family:Mono;font-weight:700;font-size:{px(21)};letter-spacing:.13em;text-transform:uppercase;white-space:nowrap}}
#hud .l{{left:{px(48)};padding:0 {px(24)} 0 {px(22)};gap:{px(14)}}}
#hud-dot{{width:{px(17)};height:{px(17)};border-radius:50%;background:#ff5b6a;box-shadow:0 0 {px(12)} #ff5b6a;display:block}}
#hud-state{{color:#f9f5fe}}
#hud-tc{{color:$hud_tc}}
#hud .r{{right:{px(48)};padding:0 {px(28)};gap:{px(12)};opacity:0}}
#hud-n{{color:$hud_n}}
#hud-name{{color:#f7f3fe}}''')
    prim = secoes[0][1].split(' ', 1) + [''] if secoes else ['', '']
    M.HT.append(f'<div id="hud"><div class="pill l"><i id="hud-dot"></i><b id="hud-state">{escape(p.get("texto", "Editando ao vivo"))}</b>'
                f'<span id="hud-tc">00:00:00</span></div><div class="pill r" id="hud-r"><span id="hud-n">{ZW}{escape(prim[0])}</span>'
                f'<b id="hud-name">{escape(prim[1])}</b></div></div>')
    M.FT('#hud', {'opacity': 0, 'y': -16}, {'opacity': 1, 'y': 0, 'duration': 0.35, 'ease': 'power3.out'}, t_in)
    fim_pulso = fim or t_out
    voltas = max(1, int((fim_pulso - t_in) / 0.55) // 2 * 2 - 1)     # número ímpar: termina aceso
    M.FT('#hud-dot', {'opacity': 1}, {'opacity': 0.15, 'duration': 0.55, 'ease': 'sine.inOut', 'yoyo': True, 'repeat': voltas}, t_in)
    pares = []
    for f in range(int(math.ceil(max(t_in, M.T0) * FPS)), int(math.floor(min(t_out, M.VFIM) * FPS)) + 1):
        t = f / FPS   # o timecode mostra o tempo do corte, quadro a quadro
        pares.append((t, {'textContent': f'{int(t // 60):02d}:{int(t % 60):02d}:{f % FPS:02d}'}))
    M.QUADROS('#hud-tc', pares)
    for t, nome in secoes:
        n, nm = (nome.split(' ', 1) + [''])[:2]
        M.TO('#hud-r', {'opacity': 0, 'duration': 0.06}, t - 0.08)
        M.SET('#hud-n', {'textContent': ZW + n}, t - 0.02)
        M.SET('#hud-name', {'textContent': nm}, t - 0.02)
        # a pílula abre da direita para a esquerda (recorte com a borda redonda, no lugar do scaleX)
        M.FT('#hud-r', {'opacity': 0, 'clipPath': f'inset(0% 0% 0% 22% round {px(26)})'},
             {'opacity': 1, 'clipPath': f'inset(0% 0% 0% 0% round {px(26)})', 'duration': 0.16, 'ease': 'power2.out'}, t)
        M.som('clique', t, SOM_PEQUENO, pid)
    if fim:
        M.TO('#hud-r', {'opacity': 0, 'duration': 0.12}, fim - 0.05)
        M.SET('#hud-state', {'textContent': p.get('texto_fim', 'Edição concluída')}, fim)
        M.SET('#hud-dot', {'backgroundColor': '#3bc59d', 'boxShadow': '0 0 12px #3bc59d', 'opacity': 1}, fim)
        M.som('sucesso', fim, SOM_CHAVE, pid)
    M.TO('#hud', {'opacity': 0, 'y': -10, 'duration': 0.3}, t_out - 0.3)


def peca_atras(M, p, pid):
    """texto grande entre o vídeo e a pessoa recortada (2,5D): a cabeça passa na frente da palavra"""
    P, F, s = M.P, M.F, M.F['escala']
    if not str(p.get('texto', '')).strip():
        raise Erro(f'{pid}: falta "texto" (a palavra que vai atrás da pessoa)')
    t_in, t_out = entrada_saida(M, p, 2.2)
    b = M.bloco(t_in)
    if b['cena'] != 'tela_cheia':
        raise Erro(f'{pid}: o texto atrás da pessoa só funciona em tela cheia (em {t_in:.2f} s a cena é {b["cena"]})')
    if t_out > b['ate'] - 0.1 and b['ate'] < P.VDUR:
        aviso(f'{pid}: sai em {b["ate"] - 0.1:.2f} s, antes do fim da tela cheia (pedia {t_out:.2f})')
        t_out = max(t_in + 0.4, b['ate'] - 0.1)
        M.alvo.saida = (t_in, t_out)
    # o recorte da pessoa desse trecho tem que existir (e ter sido aprovado num teste curto)
    rec = p.get('recorte', 'assets/pessoa.webm')
    rj = os.path.splitext(rec)[0] + '.json'
    a, z = t_in - 0.1, t_out + 0.1                   # a pessoa já está na frente antes da primeira letra
    cmd = (f'python3 ferramentas/pessoa_recortada.py --edit \'<EDIT>\' --de {max(0.0, a - 0.5):.2f} --ate {z + 0.5:.2f}'
           + ('' if rec == 'assets/pessoa.webm' else f' --saida {rec}'))
    if not os.path.exists(os.path.join(AQUI, rec)) or not os.path.exists(os.path.join(AQUI, rj)):
        raise Erro(f'{pid}: o texto atrás da pessoa precisa do recorte da pessoa nesse trecho ({rec}). Rode primeiro '
                   f'{cmd} e aprove um teste de uns 5 s (cabelo, mãos e borda) antes de pôr o texto.')
    ini, fim = M.pessoa_janela(rec)
    if t_in < ini - 0.02 or t_out > fim + 0.02:
        raise Erro(f'{pid}: o recorte {rec} cobre de {ini:.2f} a {fim:.2f} s e o texto vai de {t_in:.2f} a {t_out:.2f} s. '
                   f'Rode de novo: {cmd} (e aprove um teste de uns 5 s)')
    texto = str(p['texto'])
    caixa_alta = p.get('caixa_alta', True)
    sx0, sy0, sx1, sy1 = F['seguro']
    larg = sx1 - sx0
    limpo = texto.replace('*', '')
    letras = len(grafemas(limpo))
    fator = 0.78 if (caixa_alta or limpo.upper() == limpo) else 0.66
    fs = float(p.get('tamanho') or min(round(0.16 * max(M.W, M.H)), larg / (max(1, letras) * fator)))
    fs = round(fs)
    w = int(round(max(1, letras) * fs * fator))
    r = M.rosto_tela(t_in)
    cab = cabeca_tela(M, t_in, rec)          # topo e centro da cabeça medidos no próprio recorte (mais certo que o rosto)
    cx = cab['cx'] if cab else ((r['x0'] + r['x1']) / 2 if r else M.W / 2)
    x = int(clamp(cx - w / 2, sx0, sx1 - w) if w <= larg else (sx0 + sx1) / 2 - w / 2)
    altura = p.get('altura', 'cabeca')
    if isinstance(altura, (int, float)):
        y = float(altura)
    elif altura == 'topo':
        y = sy0 - 0.12 * fs
    elif altura == 'cabeca':
        # o topo da cabeça passa um pouco abaixo do meio das letras: a cabeça esconde a parte de baixo do miolo da
        # palavra e a metade de cima continua à vista
        topo = cab['topo'] if cab else (r['topo'] if r else sy0 + 0.6 * fs)
        y = topo - 0.535 * fs
    else:
        raise Erro(f'{pid}: "altura" tem que ser "cabeca", "topo" ou o topo do texto em pixels')
    if y < sy0 - 0.15 * fs:
        aviso(f'{pid}: o texto subiria acima da zona segura; ficou em y {sy0 - 0.15 * fs:.0f}')
        y = sy0 - 0.15 * fs
    y = int(round(y))
    # a palavra tem que continuar legível: quanto o rosto medido (com o cabelo) e o corpo cobrem das letras.
    # Uma letra só conta como coberta quando mais da metade da altura dela fica atrás da pessoa (a cabeça passando
    # na parte de baixo do miolo da palavra é o efeito; a palavra continua legível).
    pior = (0.0, t_in, 'rosto')
    ly0, ly1 = y + 0.15 * fs, y + 0.85 * fs                 # onde as letras maiúsculas ficam dentro da caixa
    for t in [t_in] + [c for c in P.cortes if t_in < c < t_out]:
        rr_ = M.rosto_tela(t + 0.01)
        if not rr_:
            continue
        cab_t = cabeca_tela(M, t + 0.01, rec)
        if cab_t:
            rr_ = dict(rr_, topo=cab_t['topo'])
        if min(ly1, rr_['y1']) - max(ly0, rr_['topo']) > 0.5 * (ly1 - ly0):
            cob = max(0.0, min(rr_['x1'], x + w) - max(rr_['x0'], x)) / w
            if cob > pior[0]:
                pior = (cob, t, 'rosto')
        if ly1 - max(ly0, rr_['y1']) > 0.5 * (ly1 - ly0):   # letras abaixo do queixo: o corpo passa na frente
            fcx, fw = (rr_['x0'] + rr_['x1']) / 2, rr_['x1'] - rr_['x0']
            cob = max(0.0, min(fcx + 1.3 * fw, x + w) - max(fcx - 1.3 * fw, x)) / w
            if cob > pior[0]:
                pior = (cob, t, 'corpo')
    if pior[0] > 0.4:
        quem = 'o rosto medido' if pior[2] == 'rosto' else 'o corpo (estimado pelo rosto)'
        aviso(f'{pid}: {quem} cobre {pior[0]:.0%} da largura de "{limpo}" em {pior[1]:.2f} s: a palavra pode ficar ilegível. '
              f'Use "altura": "topo", uma palavra mais comprida ou outro trecho.')
    cor = p.get('cor', M.E['texto_atras'])
    M.css('atras', '''.atras-txt{position:absolute;display:flex;align-items:center;justify-content:center;font-weight:900;line-height:1;
  white-space:nowrap;letter-spacing:-.01em;text-shadow:0 14px 40px rgba(0,0,0,.28)}
.atras-txt .atl{display:inline-block;opacity:0}
.atras-txt .atl.d{color:$destaque}''')
    spans, destaque, k = [], False, 0
    for parte_i, parte in enumerate(texto.split('*')):
        destaque = parte_i % 2 == 1
        for g in grafemas(parte.upper() if caixa_alta else parte):
            cls = 'atl d' if destaque else 'atl'
            conteudo = '&nbsp;' if g == ' ' else escape(g)
            spans.append(f'<span class="{cls}" id="{pid}-l{k}">{conteudo}</span>')
            k += 1
    n = max(1, k)
    M.HT.append(f'<div class="atras-txt" id="{pid}" style="left:{x}px;top:{y}px;width:{w}px;height:{fs}px;font-size:{fs}px;'
                f'color:{escape(str(cor))}">{"".join(spans)}</div>')
    st = round(min(0.05, 0.4 / n), 3)
    M.FT(f'#{pid} .atl', {'opacity': 0, 'yPercent': 70}, {'opacity': 1, 'yPercent': 0, 'duration': 0.45, 'ease': 'power3.out', 'stagger': st}, t_in)
    par = float(p.get('paralaxe', 1.0))
    if par:   # a palavra desliza devagar de lado e a pessoa fica parada na frente: a profundidade aparece
        M.FT(f'#{pid}', {'x': round(-18 * par * s), 'y': round(6 * par * s)},
             {'x': round(18 * par * s), 'y': round(-6 * par * s), 'duration': round(t_out - t_in, 3), 'ease': 'none'}, t_in)
    st2 = round(min(0.02, 0.2 / n), 3)
    M.TO(f'#{pid} .atl', {'opacity': 0, 'yPercent': -35, 'duration': 0.28, 'ease': 'power2.in', 'stagger': st2},
         max(t_in + 0.5, t_out - 0.28 - st2 * (n - 1)))
    M.reserva(t_in, t_out, x, y, w, fs)
    M.som('whoosh', t_in + 0.08, SOM_PEQUENO, pid)
    M.ult_atras = dict(t_in=t_in, t_out=t_out, pid=pid, x=x, y=y, w=w, fs=fs, par=par)
    # 2,5D: o fundo (a gravação, com a pessoa junto: saem do mesmo vídeo) anda pouco, a palavra mais, a frente mais
    # ainda. O fundo só se aproxima uns 1,5% e desliza 6 px, e volta ao normal depois (no corte, seco).
    if par and p.get('fundo_anda', True):
        if any(a0 < t_out + 0.6 and t_in < z0 + 0.6 for a0, z0, _p in M.PLANOS):
            aviso(f'{pid}: outro texto atrás da pessoa ainda mexe no fundo nesse trecho; o fundo fica parado neste')
        else:
            M.PLANOS.append((t_in, t_out, pid))
            dx, k = round(6 * par * s), round(1 + 0.015 * par, 4)
            M.FT('.plfundo', {'x': 0, 'scale': 1}, {'x': dx, 'scale': k, 'duration': round(t_out - t_in, 3),
                                                    'ease': 'sine.inOut'}, t_in)
            corte = next((c for c in P.cortes if t_out - 1e-3 <= c <= t_out + 0.6), None)
            if corte is not None:
                M.SET('.plfundo', {'x': 0, 'scale': 1}, corte)
            else:
                M.FT('.plfundo', {'x': dx, 'scale': k}, {'x': 0, 'scale': 1, 'duration': 0.5, 'ease': 'sine.inOut'}, t_out)
    pa, pz = max(a, ini), min(z, fim)
    if M.dentro(pa, pz):
        aa, zz = max(pa, M.T0), min(pz, M.VFIM)
        M.PESSOA_ATRAS.append(
            f'<video id="pa-{pid}" class="pessoa-atras" src="{escape(rec)}" muted playsinline data-start="{M.rr(aa)}" '
            f'data-duration="{round(zz - aa, 3)}" data-media-start="{round(aa - ini, 3)}" data-track-index="{TRILHA_PESSOA}" '
            f'data-timeline-label="Pessoa recortada · {escape(limpo)}"></video>')


def cabeca_tela(M, t, recorte):
    """topo e centro da cabeça na tela no tempo t, medidos no recorte da pessoa (stats do pessoa.json), ou None"""
    p = os.path.join(AQUI, os.path.splitext(recorte)[0] + '.json')
    if not os.path.exists(p):
        return None
    with open(p, encoding='utf-8') as f:
        d = json.load(f)
    for tr in d.get('trechos', []):
        st = tr.get('stats') or {}
        if tr['de'] - 1e-3 <= t < tr['ate'] and st.get('top_median') is not None:
            c = M.cam(t)
            hx, hy = M.vid(float(st.get('head_cx', 0.5)), float(st['top_median']))
            return dict(topo=c['y'] + c['scale'] * hy, cx=c['x'] + c['scale'] * hx)
    return None


def peca_frente(M, p, pid):
    """o terceiro plano do texto atrás da pessoa: uma imagem do projeto NA FRENTE da pessoa, entrando depois da
    palavra, com deslocamento maior que o dela (a frente anda mais que a palavra, que anda mais que o fundo)"""
    P, F, s = M.P, M.F, M.F['escala']
    fr = p['frente']
    fr = {'imagem': fr} if isinstance(fr, str) else dict(fr)
    img = str(fr.get('imagem') or fr.get('arquivo') or '')
    fid = f'{pid}-frente'
    if not img:
        raise Erro(f'{pid}: "frente" é a imagem do projeto que fica na frente da pessoa ("assets/objeto.png"), ou '
                   f'{{"imagem": ..., "em": ..., "lado": "esquerda|direita", "tamanho": px}}')
    if not os.path.exists(os.path.join(AQUI, img)):
        raise Erro(f'{pid}: não achei a imagem da frente {img} (copie para assets/)')
    ext = os.path.splitext(img)[1].lower()
    if ext not in ('.png', '.webp', '.svg', '.jpg', '.jpeg', '.gif'):
        raise Erro(f'{pid}: a frente é uma imagem (png com fundo transparente, webp ou svg); veio {img}')
    if ext in ('.jpg', '.jpeg'):
        aviso(f'{pid}: {img} não tem transparência: a frente aparece como um retângulo. Use um png com fundo transparente')
    A = M.ult_atras
    t_in = P.tempo(fr, 'em', depois=A['t_in'], antecipa=0.05) if 'em' in fr else A['t_in'] + 0.35
    if t_in < A['t_in'] + 0.2:          # um elemento por vez: a palavra chega primeiro
        t_in = A['t_in'] + 0.2
    t_out = P.tempo(fr, 'sai', depois=t_in + 0.05) if 'sai' in fr else A['t_out']
    t_out = min(t_out, A['t_out'])
    if t_out - t_in < 0.6:
        aviso(f'{pid}: a frente fica só {t_out - t_in:.2f} s (entra em {t_in:.2f}, sai com a palavra em {t_out:.2f}): '
              f'antecipe o "em" dela')
        t_out = t_in + 0.6
    iw, ih = tamanho_midia(os.path.join(AQUI, img)) if ext != '.svg' else (None, None)
    razao = (iw / ih) if iw and ih else 1.0
    w = int(fr.get('tamanho') or round(0.26 * min(M.W, M.H)))
    h = int(round(w / razao))
    sx0, sy0, sx1, sy1 = F['seguro']
    r = M.rosto_tela(t_in)
    lado = fr.get('lado') or ('esquerda' if r and (r['x0'] + r['x1']) / 2 > M.W / 2 else 'direita')
    if lado not in ('esquerda', 'direita'):
        raise Erro(f'{pid}: "lado" da frente é "esquerda" ou "direita"')
    if isinstance(fr.get('onde'), (list, tuple)):
        x, y = int(fr['onde'][0]), int(fr['onde'][1])
    else:
        x = sx0 if lado == 'esquerda' else sx1 - w
        y = int(sy1 - h)
    for t in [t_in] + [c for c in P.cortes if t_in < c < t_out]:
        rr_ = M.rosto_tela(t + 0.01)
        if rr_ and min(rr_['x1'], x + w) - max(rr_['x0'], x) > 0 and min(rr_['y1'], y + h) - max(rr_['topo'], y) > 0:
            aviso(f'{pid}: a frente ({img}) passa na frente do rosto em {t:.2f} s: troque o "lado", diminua o "tamanho" '
                  f'ou ponha em outro lugar ("onde": [x, y])')
            break
    par = float(p.get('paralaxe', 1.0))
    M.css('frente', '''.frente{position:absolute}
.frente .fr2{position:absolute;inset:0;opacity:0}
.frente img{display:block;width:100%;height:100%;object-fit:contain;filter:drop-shadow(0 18px 26px rgba(0,0,0,.45))}''')
    M.HT.append(f'<div class="frente" id="{fid}" style="left:{x}px;top:{y}px;width:{w}px;height:{h}px">'
                f'<div class="fr2" id="{fid}-in"><img src="{escape(imagem_no_tamanho(img, w, h))}" alt=""/></div></div>')
    M.FT(f'#{fid}-in', {'opacity': 0, 'y': 110}, {'opacity': 1, 'y': 0, 'duration': 0.4, 'ease': 'back.out(1.6)'}, t_in)
    if par:   # a frente anda mais que a palavra (±40 px contra ±18): é o plano mais perto
        M.FT(f'#{fid}', {'x': round(-40 * par * s), 'y': round(8 * par * s)},
             {'x': round(40 * par * s), 'y': round(-8 * par * s), 'duration': round(t_out - t_in, 3), 'ease': 'none'}, t_in)
    M.TO(f'#{fid}-in', {'opacity': 0, 'y': 30, 'duration': 0.25, 'ease': 'power2.in'}, t_out - 0.25)
    if M.alvo is not None:
        M.alvo.saida = (t_in, t_out)
    M.reserva(t_in, t_out, x, y, w, h)
    M.som('pop', t_in + 0.06, SOM_PEQUENO, fid)


# ---------------------------------------------------------------------------------------- lettering
NEGA = ('nao', 'nunca', 'nem', 'jamais', 'nada', 'nenhum', 'nenhuma', 'ninguem', 'tampouco', 'sem')
GRANDEZA = ('mil', 'milhao', 'milhoes', 'bi', 'bilhao', 'bilhoes', 'mi', 'k', '%', 'reais', 'real', 'dolares', 'x')
SEM_QUEBRA_DEPOIS = ('o', 'a', 'os', 'as', 'um', 'uma', 'uns', 'umas', 'de', 'do', 'da', 'dos', 'das', 'em', 'no', 'na',
                     'nos', 'nas', 'por', 'pelo', 'pela', 'pra', 'pro', 'para', 'com', 'ao', 'aos', 'num', 'numa', 'e',
                     'ou', 'que', 'se', 'nao', 'nem', 'sem', 'r$', 'us$', '$', 'meu', 'minha', 'seu', 'sua', 'mais', 'menos')
_LARGURAS = {}


def larguras():
    """a largura de cada letra das fontes do modelo, medida no arquivo da fonte e no peso usado
    (assets/fontes/larguras.json, que o ferramentas/medir_fontes.py gera)"""
    if not _LARGURAS:
        d = ler_json_ou_nada(os.path.join(AQUI, 'assets', 'fontes', 'larguras.json'))
        _LARGURAS['d'] = d if isinstance(d, dict) and d.get('fontes') else {}
    return _LARGURAS['d']


def medir_texto(texto, familia, peso, px):
    """largura do texto em px com a letra de verdade (a tabela medida); sem a tabela, a estimativa antiga"""
    d = larguras()
    tab = (((d.get('fontes') or {}).get(familia) or {}).get('pesos') or {}).get(str(peso))
    if not tab:
        return largura_texto(texto, px, peso) * (0.8 if familia == 'Caveat' else 1)
    media = tab.get('n', 600)
    return sum(tab.get(ch, media) for ch in unicodedata.normalize('NFC', texto)) * px / float(d.get('em', 1000))


def palavras_rico(texto):
    """[(palavra, destaque)] de um texto com *destaque* (o destaque pode pegar várias palavras)"""
    out = []
    for i, parte in enumerate(str(texto).split('*')):
        for w in parte.split():
            if out and not any(ch.isalnum() for ch in w):     # pontuação solta ("*gaguejei*, repeti") fica na palavra
                out[-1] = (out[-1][0] + w, out[-1][1])
            else:
                out.append((w, i % 2 == 1))
    return out


def tem_numero(w):
    return any(ch.isdigit() for ch in w)


def pode_quebrar(ws, k):
    """pode quebrar a linha (ou o momento) entre ws[k-1] e ws[k]? Nunca separa artigo e preposição do nome, número da
    unidade, "não" do verbo, nem deixa a negação do fim sozinha na outra linha"""
    a, b = norm(ws[k - 1][0]), norm(ws[k][0])
    if a in SEM_QUEBRA_DEPOIS or (tem_numero(ws[k - 1][0]) and (b in GRANDEZA or not tem_numero(ws[k][0]))):
        return False
    if b in GRANDEZA and tem_numero(ws[k - 1][0]):
        return False
    if b in NEGA and k == len(ws) - 1 and not any(norm(x[0]) in NEGA for x in ws[:k]):
        return False                          # "precisa não": a negação do fim vai junto com a palavra de antes
    return True


def passos_de_revelar(ws, por_letra=True):
    """as unidades de cada palavra e o passo em que cada uma aparece. Letra por letra (grafema: o acento junto da
    letra), mas o número aparece inteiro (com R$ e mil junto) e a negação aparece junto com a palavra de antes: nenhum
    quadro no meio mostra outro valor ou o sentido contrário."""
    unid, passo = [], -1        # unid[k] = [(texto, passo), ...] da palavra k
    k = 0
    while k < len(ws):
        w = ws[k][0]
        nw = norm(w)
        # a negação sem outra antes dela ("precisa não", "tem nada") aparece junto com a palavra de antes; depois
        # de um "não" a frase já é negativa ("não viu nada"), então a segunda negação pode vir letra por letra
        nega_sozinha = nw in NEGA and not any(norm(x[0]) in NEGA for x in ws[:k])
        junto_antes = k > 0 and (nega_sozinha or (nw in GRANDEZA and tem_numero(ws[k - 1][0]))
                                 or (tem_numero(w) and norm(ws[k - 1][0]) in ('r$', 'us$', '$')))
        if junto_antes:
            # a palavra de antes e esta aparecem no mesmo passo (inteiras)
            ant = ''.join(t for t, _ in unid[k - 1])
            p0 = min(pp for _t, pp in unid[k - 1])
            unid[k - 1] = [(ant, p0)]
            unid.append([(w, p0)])
            passo = p0
        elif tem_numero(w) or not por_letra:
            passo += 1
            unid.append([(w, passo)])
        else:
            gs = grafemas(w)
            unid.append([(g, passo + 1 + j) for j, g in enumerate(gs)])
            passo += len(gs)
        k += 1
    # passos em sequência, sem buraco
    usados = sorted({pp for u in unid for _t, pp in u})
    novo = {pp: i for i, pp in enumerate(usados)}
    return [[(t, novo[pp]) for t, pp in u] for u in unid], len(usados)


def encaixar_linhas(ws, familia, peso, larg, px_max, px_min, max_linhas=2):
    """a melhor arrumação da frase: 1 linha, ou 2 quebrando por sentido; a letra só diminui por último.
    Devolve (px, [linhas de palavras]) ou None quando nem com a letra mínima cabe."""
    gap = 0.28

    def largura(linha, px):
        return sum(medir_texto(w, familia, peso, px) for w, _d in linha) + gap * px * (len(linha) - 1)
    opcoes = [[ws]]
    quebras = [k for k in range(1, len(ws)) if pode_quebrar(ws, k)]
    if max_linhas >= 2:
        opcoes += [[ws[:k], ws[k:]] for k in quebras]
    if max_linhas >= 3:
        opcoes += [[ws[:k], ws[k:j], ws[j:]] for k in quebras for j in quebras if j > k]
    melhor = None
    for linhas in opcoes:
        maior = max(largura(li, 1000.0) for li in linhas) / 1000.0
        px = min(px_max, larg / max(maior, 1e-6))
        if px < px_min - 1e-6:
            continue
        chave = (round(px * 0.9 ** (len(linhas) - 1)), -len(linhas))   # menos linhas vence por pouco
        if melhor is None or chave > melhor[0]:
            melhor = (chave, int(px), linhas)
    return (melhor[1], melhor[2]) if melhor else None


def dividir_momentos(ws):
    """mais de 3 palavras grandes: dois momentos (duas telas), quebrando por sentido e no meio"""
    if len(ws) <= 3:
        return [ws]
    cands = [k for k in range(1, len(ws)) if pode_quebrar(ws, k) and k <= 3 and len(ws) - k <= 3]
    if not cands:
        raise Erro(f'o "principal" tem {len(ws)} palavras e não dá para dividir em dois momentos de até 3 sem separar '
                   f'palavras que andam juntas: encurte (lettering é 2 a 4 expressões) ou use duas peças')
    pont = [k for k in cands if ws[k - 1][0][-1:] in ',.;:!?']
    k = min(pont or cands, key=lambda k: (abs(k - len(ws) / 2), -k))   # empate: o primeiro momento maior
    return [ws[:k], ws[k:]]


def achar_na_fala(P, textos, de, ate, k_de=None):
    """cada palavra do texto na fala, em ordem, de `de` (ou do índice k_de) até `ate`: [índice em P.words ou None].
    Entre uma palavra e a seguinte pula até 3 palavras ditas (a muleta que o texto cortou); a primeira pode estar
    até 6 palavras depois do começo da busca."""
    ws = P.words
    k0 = k_de if k_de is not None else next((k for k, w in enumerate(ws) if w['start'] >= de - 1e-6), len(ws))
    out, primeira = [], True
    for t in textos:
        alvo, achou = norm(t), None
        for k in range(k0, min(len(ws), k0 + (7 if primeira else 4))):
            if ws[k]['start'] > ate:
                break
            fal = norm(ws[k]['text'])
            if alvo and (fal == alvo or (min(len(alvo), len(fal)) >= 4 and (fal.startswith(alvo) or alvo.startswith(fal)))):
                achou = k
                break
        out.append(achou)
        if achou is not None:
            k0, primeira = achou + 1, False
    return out


def fala_dur(P, k):
    """quanto a palavra k leva para ser dita (None: palavra que não está na fala)"""
    return None if k is None else max(0.0, P.words[k]['end'] - P.words[k]['start'])


def tempos_das_palavras(P, idx, t_min, antecipa, passo=0.15):
    """a hora de cada palavra: a da fala menos a antecipação (nunca antes de t_min nem da palavra de antes); a que
    não está na fala vem logo depois da anterior"""
    out, ant = [], None
    for k in idx:
        if k is not None:
            t = P.words[k]['start'] - antecipa
        else:
            t = t_min if ant is None else ant + passo
        t = max(t, t_min, ant if ant is not None else t_min)
        out.append(round(t, 3))
        ant = t
    return out


def revelar_no_tempo(ws, tempos, durs, por_letra=True):
    """[(texto, t)] de cada palavra: cada uma a partir da sua hora, letra por letra dentro do tempo em que ela é dita
    (de 0,1 a 0,3 s). O que tem que aparecer junto (número com a unidade, a negação com a palavra de antes) aparece
    na hora da última delas: nada antes de ser dito."""
    unid, _n = passos_de_revelar(ws, por_letra)
    donos = {}
    for k, u in enumerate(unid):
        for _t, pp in u:
            donos.setdefault(pp, set()).add(k)
    t0 = list(tempos)
    for ks in donos.values():
        if len(ks) > 1:
            t = max(tempos[k] for k in ks)
            for k in ks:
                t0[k] = max(t0[k], t)
    out = []
    for k, u in enumerate(unid):
        janela = clamp(0.8 * durs[k], 0.1, 0.3) if durs[k] else 0.2
        st = clamp(janela / max(1, len(u) - 1), 0.018, 0.05)
        out.append([(txt, round(t0[k] + j * st, 3)) for j, (txt, _pp) in enumerate(u)])
    return out


def proxima_lettering(P, p, t_in):
    """(id, entrada) da próxima peça lettering depois desta, ou None (esta é a última: a conclusão)"""
    melhor = None
    for i, q in enumerate(P.cfg.get('animacoes', [])):
        if q is p or q.get('tipo') != 'lettering' or 'em' not in q:
            continue
        try:
            t = P.tempo(q, 'em', antecipa=float(q.get('antecipa', 0.05)))
        except (Erro, ValueError, TypeError):
            continue
        if t is not None and t > t_in + 0.05 and (melhor is None or t < melhor[1]):
            melhor = (q.get('id') or f'a{i + 1}', t)
    return melhor


def peca_lettering(M, p, pid):
    """texto grande com ordem de leitura: principal primeiro (escala, peso, contraste), apoio depois, chamada por
    último; letra por letra montada já no lugar final, cada palavra na hora em que é dita; encaixe por procedimento;
    a legenda sai no trecho, ou continua nas palavras que não estão escritas grandes (legenda_junto)"""
    P, F, E, s = M.P, M.F, M.E, M.F['escala']
    if not str(p.get('principal', '')).strip():
        raise Erro(f'{pid}: falta "principal" (o texto que manda, com *destaque* opcional)')
    estilo = p.get('estilo', 'bloco')
    if estilo not in ('bloco', 'manuscrito', 'cinetico'):
        raise Erro(f'{pid}: "estilo" do lettering é "bloco" (Montserrat 900), "manuscrito" (Caveat, à mão) ou '
                   f'"cinetico" (Inter 800, destaque no degradê)')
    cin = estilo == 'cinetico'
    posicao = p.get('posicao')
    if posicao not in (None, 'topo', 'meio', 'base'):
        raise Erro(f'{pid}: "posicao" é "topo", "meio" ou "base"')
    t_in, t_out = entrada_saida(M, dict(p, antecipa=p.get('antecipa', 0.05)), 2.6)
    ate_fim = M.ate_o_fim
    # legenda junto (padrão com a legenda discreta): a legenda continua nas palavras que não estão escritas grandes
    junto = p.get('legenda_junto')
    if junto is None:
        junto = estilo_legenda(M) == 'discreta'
    junto = bool(junto) and p.get('esconde_legenda', True) is not False
    b = M.bloco(t_in)
    if b['cena'] == 'moldura':
        ax0, ay0, ax1, ay1 = F['area_moldura']
    else:
        ax0, ay0, ax1, ay1 = F['seguro']
    pad = round(24 * s)
    aw = (ax1 - ax0) - 2 * pad
    base_px = min(M.W, M.H)
    caixa_alta = p.get('caixa_alta', estilo == 'bloco')
    fam_p, peso_p = ('Montserrat', 900) if estilo == 'bloco' else (('Inter', 800) if cin else ('Caveat', 700))
    px_max = int(p.get('tamanho') or (round(0.11 * M.H) if cin else round((0.13 if estilo == 'bloco' else 0.16) * base_px)))
    px_min = int(F['fonte_leg'] + 12)
    principal = palavras_rico(p['principal'])
    if caixa_alta:
        principal = [(w.upper(), d) for w, d in principal]
    elif cin and principal:                     # o título cinético começa com maiúscula, como a régua
        principal[0] = (principal[0][0][:1].upper() + principal[0][0][1:], principal[0][1])
    if len(principal) > 6:
        raise Erro(f'{pid}: o "principal" tem {len(principal)} palavras. Lettering são 2 a 4 expressões por trecho, até '
                   f'3 palavras grandes por vez: encurte, ou use a legenda')
    try:
        momentos = dividir_momentos(principal)
    except Erro as e:
        raise Erro(f'{pid}: {e}')
    apoio = palavras_rico(p['apoio']) if str(p.get('apoio') or '').strip() else []
    chamada = str(p.get('chamada') or '').strip()
    cx = (ax0 + ax1) / 2

    def arrumar(cap):
        """a arrumação com a letra do principal até `cap`: menos linhas vence por pouco; apoio e chamada menores"""
        arr = []
        for ws in momentos:
            enc = encaixar_linhas(ws, fam_p, peso_p, aw, cap, px_min, max_linhas=3)
            if not enc:
                return None
            arr.append(enc)
        pxp = min(px for px, _l in arr)             # os dois momentos com a mesma letra
        arr = [(pxp, li) for _px, li in arr]
        pxa, lia = 0, []
        if apoio:
            enc = encaixar_linhas(apoio, 'Montserrat', 800, aw, int(clamp(round(0.5 * pxp), 46, 72)), 34)
            if not enc:
                raise Erro(f'{pid}: o "apoio" não cabe: encurte (apoio é um complemento curto)')
            pxa, lia = enc
        pxc = int(clamp(round(0.36 * pxp), 38, 56)) if chamada else 0
        if chamada:
            cw = medir_texto(chamada, 'Montserrat', 900, pxc) + 2 * 0.9 * pxc
            if cw > aw:
                pxc = int(pxc * aw / cw)
                if pxc < 32:
                    raise Erro(f'{pid}: a "chamada" não cabe: encurte')
        lhp, lha, hch = round(pxp * (1.18 if estilo == 'bloco' else (1.04 if cin else 1.08))), round(pxa * 1.25), round(pxc * 1.9)
        g1_, g2_ = round(0.22 * pxp), round(0.40 * pxp)
        fin = len(arr[-1][1]) * lhp + (g1_ + len(lia) * lha if apoio else 0) + (g2_ + hch if chamada else 0)
        amax = max(fin, max(len(li) * lhp for _px, li in arr))
        lmax = max(sum(medir_texto(w, fam_p, peso_p, pxp) for w, _d in linha) + 0.28 * pxp * (len(linha) - 1)
                   for _px, li in arr for linha in li)
        return dict(arr=arr, px_p=pxp, px_a=pxa, linhas_a=lia, px_c=pxc, lh_p=lhp, lh_a=lha, hc=hch, g1=g1_, g2=g2_,
                    alt_final=fin, alt_max=amax, larg_max=lmax)

    def topo_em(onde, lay):
        if b['cena'] == 'moldura' or onde == 'topo':
            return int(ay0 + 10)
        if onde == 'base':                  # cinético: a palavra sobe de baixo (e a perna do p): sobra embaixo
            return int(ay1 - 10 - lay['alt_final'] - (round(0.32 * lay['px_p']) if cin else 0))
        return int((ay0 + ay1) / 2 - lay['alt_final'] / 2)

    gap_rosto = ROSTO_PX * M.H / 1920

    def cobre(y, lay):
        """o primeiro instante em que o texto fica a menos de ROSTO_PX da caixa do rosto (None: livre o trecho todo)"""
        x0, x1 = cx - lay['larg_max'] / 2, cx + lay['larg_max'] / 2
        if lay['alt_max'] > ay1 - ay0:
            return t_in
        for t in instantes(t_in, t_out, P.cortes):
            if perto_do_rosto((x0, y, x1, y + lay['alt_max']), M.rosto_tela(t + 0.01), gap_rosto):
                return t
        return None
    # a maior letra que cabe numa área livre (sem encostar no rosto); a letra só desce por último. A posição pedida vem
    # primeiro; se ela encosta no rosto, as outras entram antes de a letra encolher demais
    ordem = ([posicao] + [o for o in ('topo', 'base') if o != posicao]) if posicao else ['topo', 'base', 'meio']
    escolha, primeira, cap, cap_usado = None, None, px_max, px_max
    onde_tentado = ', '.join(ordem)
    while cap >= px_min - 1e-6 and escolha is None:
        lay = arrumar(int(cap))
        if lay is None:
            break
        primeira = primeira or lay
        for onde in ordem:
            if cobre(topo_em(onde, lay), lay) is None:
                escolha, cap_usado = (lay, onde), cap
                break
        cap = cap * 0.92 if cap > px_min else px_min - 1
    if primeira is None:
        raise Erro(f'{pid}: "{" ".join(w for w, _d in principal)}" não cabe nem com a letra de {px_min} px (abaixo disso '
                   f'não funciona como lettering). Encurte o principal ou divida em duas peças')
    if escolha is None:
        lay = arrumar(px_min) or primeira
        if lay['alt_max'] > ay1 - ay0:
            raise Erro(f'{pid}: o lettering ficou mais alto ({lay["alt_max"]} px) que a área ({ay1 - ay0} px): tire o '
                       f'apoio ou encurte')
        escolha, cap_usado = (lay, ordem[0]), px_min
        aviso(f'{pid}: o lettering passa na frente do rosto em {cobre(topo_em(ordem[0], lay), lay):.2f} s '
              f'({ordem[0] if posicao else "topo, base e meio"}), mesmo com a letra menor. Troque a "posicao", encurte '
              f'o texto ou use moldura nesse trecho')
    lay, onde = escolha
    if posicao and onde != posicao:
        nota(f'{pid}: "{posicao}" encostava no rosto; o lettering foi para "{onde}"')
    y_top = topo_em(onde, lay)
    arrumados, px_a, linhas_a, px_c = lay['arr'], lay['px_a'], lay['linhas_a'], lay['px_c']
    lh_p, lh_a, hc, g1, g2 = lay['lh_p'], lay['lh_a'], lay['hc'], lay['g1'], lay['g2']
    alt_max, larg_max, px_p = lay['alt_max'], lay['larg_max'], lay['px_p']
    # tempo: cada palavra entra na hora em que é dita (o começo medido na onda, com o palavras_onda.json), antecipando
    # no máximo 0,15 s; o momento 2 entra na palavra dele; o apoio só depois de o principal terminar de chegar, também
    # palavra por palavra na fala; a chamada por último
    DUR_U = 0.24
    antecipa = clamp(float(p.get('antecipa', 0.05)), 0.0, 0.15)
    idx_p = achar_na_fala(P, [w for w, _d in principal], t_in - 0.3, t_out)
    if not any(k is not None for k in idx_p):
        aviso(f'{pid}: não achei as palavras do principal na fala entre {t_in:.2f} e {t_out:.2f} s: elas entram uma a '
              f'uma a partir do "em". Confira o texto e o "em" com python3 build.py palavras')
    elif P.tempo_fala != 'onda':
        aviso('lettering: sem o palavras_onda.json as palavras grandes entram no tempo do Whisper, que adianta até 0,3 s. '
              "Rode: uv run --directory '<SKILL>' python helpers/palavras_na_onda.py '<EDIT>'")
    tp = tempos_das_palavras(P, idx_p, t_in, antecipa)
    tempos_m, ini_m = [t_in], [0]
    if len(arrumados) > 1:
        k2 = len(momentos[0])
        ini_m.append(k2)
        tm2 = tp[k2] if idx_p[k2] is not None else None
        if tm2 is None:
            try:
                tm2 = P.tempo_palavra(norm(momentos[1][0][0]), t_in + 0.3) - antecipa
            except Erro:
                tm2 = None
            if tm2 is None or tm2 > t_out - 1.0:
                tm2 = t_in + (t_out - t_in) * len(momentos[0]) / len(principal)
        tempos_m.append(max(tm2, t_in + 0.9))
    ultima_let = proxima_lettering(P, p, t_in) is None       # a conclusão: a batida dela pede um som um pouco mais forte
    stroke = E['contorno_leg']
    destaque = E['base'] if estilo == 'bloco' else E['marca_texto']
    M.css('lettering', f'''.lt-m{{position:absolute;left:0;top:0;width:${{W}}px;height:${{H}}px}}
.lt-l{{position:absolute;text-align:center;white-space:nowrap;color:#ffffff;paint-order:stroke fill}}
.lt-w{{display:inline-block;white-space:nowrap}}
.lt-u{{display:inline-block;opacity:0}}
.lt-p{{font-weight:900;text-shadow:0 10px 28px rgba(0,0,0,.45)}}
.lt-p.m{{font-family:Caveat;font-weight:700}}
.lt-p .d{{color:{destaque}}}
.lt-p.c{{font-family:Inter;font-weight:800;letter-spacing:-.035em;text-shadow:none;filter:{SOMBRA_CINETICA}}}
.lt-p.c .d .lt-u{{background:{gradiente_css(P.cfg)};-webkit-background-clip:text;background-clip:text;color:transparent}}
.lt-a{{font-weight:800;text-shadow:0 4px 14px rgba(0,0,0,.55)}}
.lt-c{{position:absolute;display:flex;align-items:center;justify-content:center;border-radius:999px;background:$botao;
  color:$sobre_destaque;font-weight:900;white-space:nowrap;opacity:0;box-shadow:$sombra}}''')
    partes, ajuste, fim_m = [], [], []
    idx_a, idx_c = [], []
    y_linha0 = y_top
    for mi, (px, linhas) in enumerate(arrumados):
        grupo, u_id = [], 0
        todas = [w for li in linhas for w in li]
        k0 = ini_m[mi]
        ts = [max(tp[k0 + j], tempos_m[mi]) for j in range(len(todas))]
        unid = revelar_no_tempo(todas, ts, [fala_dur(P, idx_p[k0 + j]) for j in range(len(todas))], por_letra=not cin)
        por_t = {}
        k = 0
        for li_i, linha in enumerate(linhas):
            spans = []
            for j, (w, dq) in enumerate(linha):
                us = []
                for texto, tt in unid[k]:
                    uid = f'{pid}-m{mi}u{u_id}'
                    u_id += 1
                    por_t.setdefault(tt, []).append('#' + uid)
                    us.append(f'<span class="lt-u" id="{uid}">{escape(texto)}</span>')
                marg = f' style="margin-left:{round(0.28 * px)}px"' if j else ''
                spans.append(f'<span class="lt-w{" d" if dq else ""}"{marg}>{"".join(us)}</span>')
                k += 1
            lid = f'{pid}-m{mi}l{li_i}'
            yy = y_linha0 + li_i * lh_p
            chao = f'color:{E["texto_fundo"]};filter:none;' if cin and b['cena'] == 'moldura' else ''
            grupo.append(f'<div class="lt-l lt-p{" m" if estilo == "manuscrito" else (" c" if cin else "")}" id="{lid}" style="{chao}left:{ax0 + pad}px;'
                         f'top:{yy}px;width:{aw}px;height:{lh_p}px;line-height:{lh_p}px;font-size:{px}px;'
                         f'-webkit-text-stroke:{0 if cin else max(3, round((0.085 if estilo == "bloco" else 0.05) * px))}px {stroke}">'
                         f'{"".join(spans)}</div>')
            ajuste.append((lid, aw))
        for tt, sels in sorted(por_t.items()):
            M.FT(','.join(sels), {'opacity': 0, 'yPercent': 25 if cin else 55},
                 {'opacity': 1, 'yPercent': 0, 'duration': DUR_U, 'ease': 'power3.out'}, tt)
        completo = max(por_t) + DUR_U
        fim_m.append(completo)
        ultimo = mi == len(arrumados) - 1
        if estilo in ('bloco', 'cinetico'):   # a batida quando a palavra que manda termina de chegar: um pulinho e volta
            M.FT(f'#{pid}-m{mi}', {'y': 0}, {'keyframes': [{'y': -round(0.07 * px), 'duration': 0.1, 'ease': 'power2.out'},
                                                           {'y': 0, 'duration': 0.2, 'ease': 'power2.in'}]},
                 completo - 0.02)
            M.som('pop', completo - 0.05, SOM_MEDIO if (ultimo and ultima_let) else SOM_PEQUENO, f'{pid}-{mi}')
        else:
            M.som('whoosh', min(por_t) + 0.05, SOM_PEQUENO, f'{pid}-{mi}')
        y_extra = y_linha0 + len(linhas) * lh_p
        t_prox = None
        k_depois = max([k for k in idx_p if k is not None], default=None)
        if ultimo:
            pedido = P.tempo(p, 'apoio_em', depois=t_in) if 'apoio_em' in p else None
            t_ap = max(completo + 0.12, pedido or 0.0)
            if apoio:
                ya = y_extra + g1
                wa = [w for li in linhas_a for w in li]
                idx_a = achar_na_fala(P, [w for w, _d in wa], t_in - 0.3, t_out,
                                      k_de=None if k_depois is None else k_depois + 1)
                primeira_a = next((P.words[k]['start'] - antecipa for k in idx_a if k is not None), None)
                if pedido is not None and pedido < completo + 0.12:
                    aviso(f'{pid}: o apoio entra em {t_ap:.2f} s, depois do principal terminar de chegar (pedia {pedido:.2f})')
                elif pedido is None and primeira_a is not None and primeira_a < completo + 0.12 - 0.05:
                    aviso(f'{pid}: o apoio é dito em {primeira_a + antecipa:.2f} s e entra em {t_ap:.2f} s, depois do '
                          f'principal terminar de chegar')
                ta = tempos_das_palavras(P, idx_a, t_ap, antecipa, passo=0.06)
                ua = revelar_no_tempo(wa, ta, [fala_dur(P, k) for k in idx_a], por_letra=False)
                por_ta, ka, uia = {}, 0, 0
                for li_i, linha in enumerate(linhas_a):
                    spans = []
                    for j, (w, dq) in enumerate(linha):
                        us = []
                        for texto, tt in ua[ka]:
                            uid = f'{pid}-a{uia}'
                            uia += 1
                            por_ta.setdefault(tt, []).append('#' + uid)
                            us.append(f'<span class="lt-u" id="{uid}">{escape(texto)}</span>')
                        marg = f' style="margin-left:{round(0.28 * px_a)}px"' if j else ''
                        spans.append(f'<span class="lt-w{" d" if dq else ""}"{marg}>{"".join(us)}</span>')
                        ka += 1
                    lid = f'{pid}-al{li_i}'
                    grupo.append(f'<div class="lt-l lt-a" id="{lid}" style="left:{ax0 + pad}px;top:{ya + li_i * lh_a}px;'
                                 f'width:{aw}px;height:{lh_a}px;line-height:{lh_a}px;font-size:{px_a}px;'
                                 f'-webkit-text-stroke:{max(2, round(0.07 * px_a))}px {stroke}">{"".join(spans)}</div>')
                    ajuste.append((lid, aw))
                for tt, sels in sorted(por_ta.items()):
                    M.FT(','.join(sels), {'opacity': 0, 'y': 18}, {'opacity': 1, 'y': 0, 'duration': 0.25, 'ease': 'power2.out'},
                         tt)
                # o apoio também pede o seu som, baixo, no pouso dele (a última palavra chegando), como a batida do principal
                M.som('pop', max(por_ta) + 0.2, SOM_BAIXO, f'{pid}-apoio')
                t_prox = max(por_ta) + 0.25
                y_extra = ya + len(linhas_a) * lh_a
                k_depois = max([k for k in idx_a if k is not None], default=k_depois)
            if chamada:
                pedido = P.tempo(p, 'chamada_em', depois=t_in) if 'chamada_em' in p else None
                t_ch = max((t_prox or completo) + 0.15, pedido or 0.0)
                if pedido is not None and pedido < (t_prox or completo) + 0.15:
                    aviso(f'{pid}: a chamada entra em {t_ch:.2f} s, por último (pedia {pedido:.2f})')
                idx_c = achar_na_fala(P, separar(chamada), t_in - 0.3, t_out + 1.0,
                                      k_de=None if k_depois is None else k_depois + 1)
                cw = int(medir_texto(chamada, 'Montserrat', 900, px_c) + 2 * 0.9 * px_c)
                yc = y_extra + g2
                grupo.append(f'<div class="lt-c" id="{pid}-c" style="left:{int(cx - cw / 2)}px;top:{yc}px;width:{cw}px;'
                             f'height:{hc}px;font-size:{px_c}px"><span>{escape(chamada)}</span></div>')
                M.FT(f'#{pid}-c', {'opacity': 0, 'y': 36}, {'opacity': 1, 'y': 0, 'duration': 0.3, 'ease': 'back.out(2)'},
                     t_ch)
                M.som('pop', t_ch + 0.05, SOM_PEQUENO, f'{pid}-chamada')
                t_prox = t_ch + 0.3
            fim_m[-1] = max(fim_m[-1], t_prox or 0)
        partes.append(f'<div class="lt-m" id="{pid}-m{mi}">{"".join(grupo)}</div>')
        # saída: o momento 1 sai inteiro antes do 2 entrar (nunca os dois cruzando); o último sai no "sai". A peça que
        # vai até o fim do corte fica legível até o último quadro (sem saída)
        if not ultimo:
            saida = tempos_m[mi + 1] - 0.2
            M.TO(f'#{pid}-m{mi}', {'opacity': 0, 'y': -24, 'duration': 0.18, 'ease': 'power2.in'}, saida)
            if saida < completo + 0.4:
                aviso(f'{pid}: o primeiro momento ("{" ".join(w for w, _d in momentos[0])}") fica só '
                      f'{max(0.0, saida - completo):.2f} s completo antes do segundo: dê mais tempo ou encurte')
        elif not ate_fim:
            M.TO(f'#{pid}-m{mi}', {'opacity': 0, 'y': -24, 'duration': 0.25, 'ease': 'power2.in'}, t_out - 0.25)
    M.HT.append(f'<div class="lt" id="{pid}">{"".join(partes)}</div>')
    if M.alvo is not None:
        M.alvo.ajuste_fontes = ajuste
    # legenda junto: a legenda discreta continua nas palavras que não estão escritas grandes naquele instante
    if junto:
        achadas = sum(1 for k in idx_p if k is not None)
        if achadas * 2 >= len(idx_p):
            M.fora_da_legenda.update(k for k in idx_p + idx_a + idx_c if k is not None)
            M.junto_ok.add(pid)
        else:
            aviso(f'{pid}: "legenda_junto" tira da legenda as palavras escritas grandes, e só achei {achadas} das '
                  f'{len(idx_p)} do principal na fala: a legenda sai do trecho inteiro')
    # tempo de leitura depois que tudo chegou: 1 s até 4 palavras, mais uns 0,3 s por palavra
    n_final = len(momentos[-1]) + len(apoio) + len(chamada.split())
    precisa = 1.0 + 0.3 * max(0, n_final - 4)
    fim_visivel = (P.VDUR + M.segura) if ate_fim else t_out - 0.25
    tem = fim_visivel - fim_m[-1]
    if tem < precisa - 0.05:
        prox = proxima_lettering(P, p, t_in)
        if prox and prox[1] < fim_m[-1] + precisa + 0.25 + 0.1:
            # atrasar o "sai" não resolve: a próxima expressão entra na palavra dela
            aviso(f'{pid}: o texto fica completo só {max(0.0, tem):.2f} s (pede uns {precisa:.1f} s para ler {n_final} '
                  f'palavras) e a próxima expressão ({prox[0]}) entra em {prox[1]:.2f} s: não dá para atrasar o "sai". Em '
                  f'fala rápida cada expressão ocupa uns 2 s (chegar, 1 s de leitura e sair): tire uma expressão ou junte '
                  f'esta com a próxima numa peça só (a segunda vira o apoio)')
        else:
            aviso(f'{pid}: o texto fica completo só {max(0.0, tem):.2f} s antes de sair (pede uns {precisa:.1f} s para '
                  f'ler {n_final} palavras): atrase o "sai", tire o apoio ou encurte')
    if px_p < px_max:
        nota(f'{pid}: letra de {px_p} px (o teto é {px_max}): '
             + (f'com letra maior o texto cobria o rosto ({onde_tentado})' if cap_usado < px_max - 1
                else f'maior não cabe na largura da área ({aw} px) em {len(arrumados[0][1])} linha(s)'))
    M.reserva(t_in, min(t_out, P.VDUR + M.segura), int(cx - larg_max / 2), y_top, int(larg_max), alt_max)

PECAS = {'rotulo': peca_rotulo, 'carimbo': peca_carimbo, 'lista_check': peca_lista, 'passo_a_passo': peca_passos,
         'contador': peca_contador, 'notificacao': peca_notificacao, 'polaroide': peca_polaroide,
         'objeto_na_mao': peca_na_mao, 'hud': peca_hud, 'atras_da_pessoa': peca_atras, 'lettering': peca_lettering}

NOMES = {'rotulo': 'Rótulo', 'carimbo': 'Carimbo', 'lista_check': 'Lista', 'passo_a_passo': 'Passo a passo',
         'contador': 'Contador', 'notificacao': 'Notificação', 'polaroide': 'Polaroide', 'objeto_na_mao': 'Objeto na mão',
         'hud': 'Editando ao vivo', 'atras_da_pessoa': 'Texto atrás', 'lettering': 'Lettering'}


def rotulo_peca(p, tipo, pid):
    if p.get('nome'):
        return str(p['nome'])
    base = NOMES.get(tipo, tipo)
    txt = p.get('texto') or p.get('titulo') or p.get('principal') or (str(p['valor_final']) if 'valor_final' in p else '')
    txt = str(txt).replace('*', '').strip()
    return f'{base} · {txt}' if txt else f'{base} · {pid}'


def cartao_final(M, cf):
    s, W, H = M.F['escala'], M.W, M.H
    t0 = M.P.VDUR + M.segura          # com "segura_fim", o cartão entra depois do fim parado
    M.css('fim', '''#fim{position:absolute;inset:0;opacity:0;z-index:70;$fundo}
#fim .logo{position:absolute;background-position:center;background-size:contain;background-repeat:no-repeat;opacity:0}
#fim .tit{position:absolute;left:0;right:0;text-align:center;font-weight:900;color:$texto_fundo;opacity:0;white-space:nowrap}
#fim .tit b{color:$destaque}
#fim .sub{position:absolute;left:0;right:0;text-align:center;font-family:Caveat;font-weight:700;color:$texto_fundo;opacity:0;white-space:nowrap}
#fim .bt{position:absolute;border-radius:36px;background:$botao;color:$sobre_destaque;display:flex;align-items:center;justify-content:center;font-weight:900;opacity:0;box-shadow:$sombra;white-space:nowrap}''')
    bw, bh = round(700 * s), round(140 * s)
    ytit = round(H * (0.46 if H > W else 0.42))
    logo = ''
    if cf.get('logo'):
        lw, lh = round(560 * s), round(300 * s)
        # fundo em vez de <img>: a mesma imagem em dois <img> sem tempo faz o lint avisar de mídia duplicada
        logo = (f'<div class="logo" id="fim-logo" style="background-image:url({escape(imagem_no_tamanho(cf["logo"], lw, lh))});left:{(W - lw) // 2}px;'
                f'top:{ytit - lh - round(60 * s)}px;width:{lw}px;height:{lh}px"></div>')
    sub = f'<div class="sub" id="fim-sub" style="top:{ytit + round(90 * s)}px;font-size:{round(64 * s)}px">{escape(cf["sub"])}</div>' if cf.get('sub') else ''
    M.HT.append(f'<div id="fim">{logo}<div class="tit" id="fim-tit" style="top:{ytit}px;font-size:{round(66 * s)}px">{rico(cf.get("titulo", ""))}</div>'
                f'{sub}<div class="bt" id="fim-bt" style="left:{(W - bw) // 2}px;top:{ytit + round(210 * s)}px;width:{bw}px;height:{bh}px;font-size:{round(52 * s)}px">'
                f'{escape(cf.get("botao", "Toque em Saiba Mais"))}</div></div>')
    M.FT('#fim', {'opacity': 0}, {'opacity': 1, 'duration': 0.25}, t0 - 0.1)
    if logo:
        M.FT('#fim-logo', {'opacity': 0, 'y': 50}, {'opacity': 1, 'y': 0, 'duration': 0.45, 'ease': 'back.out(1.8)'}, t0)
    M.FT('#fim-tit', {'opacity': 0, 'y': 30}, {'opacity': 1, 'y': 0, 'duration': 0.35, 'ease': 'back.out(2)'}, t0 + 0.1)
    if sub:
        M.FT('#fim-sub', {'opacity': 0, 'y': 30}, {'opacity': 1, 'y': 0, 'duration': 0.35, 'ease': 'back.out(2)'}, t0 + 0.2)
    M.FT('#fim-bt', {'opacity': 0, 'y': 30}, {'opacity': 1, 'y': 0, 'duration': 0.4, 'ease': 'back.out(2)'}, t0 + 0.35)
    M.FT('#fim-bt', {'y': 0}, {'keyframes': [{'y': round(-12 * s), 'duration': 0.3, 'ease': 'power2.out'},
                                             {'y': 0, 'duration': 0.4, 'ease': 'power2.in'}]}, t0 + 0.9)
    fim = M.VFIM + M.cauda           # o último quadro do vídeo: a faixa vai até ele (sem isso, acabava com a última animação e sobrava preto)
    if fim - (t0 + 0.75) > 0.02:
        M.FT('#fim', {'opacity': 1}, {'opacity': 1, 'duration': round(fim - (t0 + 0.75), 3), 'ease': 'none'}, t0 + 0.75)
    M.som('whoosh', t0, SOM_PEQUENO, 'cartao_final')
    M.som('pop', t0 + 0.45, SOM_PEQUENO, 'cartao_final')


# ---------------------------------------------------------------------------------------- câmera e cenas
def cenas(M):
    P, R = M.P, M.F['raio']
    FULL, MOL = M.FULL, M.MOL
    T0 = M.T0
    b0 = M.bloco(T0)
    q0, r0 = M.quadro_de(b0)
    M.INICIO('#frame', {'clipPath': M.clip(q0, r0), 'opacity': 0 if b0['cena'] == 'tela_verde' else 1})
    M.INICIO(M.CAM, M.cam(T0, b0))
    M.INICIO('#bg', {'opacity': 1 if b0['cena'] in ('moldura', 'tela_verde', 'contida') else 0})
    M.INICIO('#borda', {'opacity': 1 if b0['cena'] == 'moldura' else 0})
    fronteiras = [(a, b) for a, b in zip(M.blocos, M.blocos[1:]) if M.dentro(b['de'] - 0.01, b['de'] + 0.01) and b['de'] > T0 + 1e-3]
    marcados = set()
    for A, B in fronteiras:
        t = B['de']
        ka, kb = A['cena'], B['cena']
        no_corte = any(abs(c - t) < 0.05 for c in P.cortes)
        marcados.add(round(t, 3))
        if 'contida' in (ka, kb):
            transicao_contida(M, A, B, t, no_corte)
        elif kb == 'moldura' and ka == 'tela_verde':   # o quadro já nasce no lugar e aparece (o papel já está atrás)
            M.SET('#frame', {'clipPath': M.clip(MOL, R)}, t)
            M.SET(M.CAM, M.cam(t, B), t)
            M.FT('#frame', {'opacity': 0}, {'opacity': 1, 'duration': 0.25}, t)
            M.FT('#borda', {'opacity': 0}, {'opacity': 1, 'duration': 0.25}, t + 0.1)
            M.som('pop', t + 0.1, SOM_PEQUENO, 'moldura')
        elif kb == 'moldura' and ka != 'moldura':
            M.SET(M.CAM, M.cam(t, A), t)
            M.FT('#frame', {'clipPath': M.clip(FULL, 0)}, {'clipPath': M.clip(MOL, R), 'duration': 0.7, 'ease': 'power3.inOut'}, t)
            # acompanhando: o fim do fechamento já é o enquadramento do rosto em t + 0,7 (sem salto depois)
            M.FT(M.CAM, M.cam(t, A), dict(M.cam(M.fim_sem_corte(t, 0.7), B), duration=0.7, ease='power3.inOut'), t)
            M.FT('#borda', {'opacity': 0}, {'opacity': 1, 'duration': 0.25}, t + 0.55)
            M.FT('#bg', {'opacity': 0}, {'opacity': 1, 'duration': 0.6}, t)
            M.som('whoosh', t + 0.35, -12, 'moldura')
        elif ka == 'moldura' and kb != 'moldura':
            if kb == 'tela_verde':
                M.TO('#borda', {'opacity': 0, 'duration': 0.2}, t)
                M.TO('#frame', {'opacity': 0, 'duration': 0.25}, t)
            elif no_corte:            # sai no corte: troca seca, o corte esconde o salto
                M.TO('#bg', {'opacity': 0, 'duration': 0.2}, t - 0.05)
                M.SET('#borda', {'opacity': 0}, t)
                M.SET('#frame', {'clipPath': M.clip(FULL, 0)}, t)
                M.SET(M.CAM, M.cam(t, B), t)
            else:                     # sai no meio de uma tomada: volta animando
                M.TO('#borda', {'opacity': 0, 'duration': 0.15}, t)
                M.FT('#frame', {'clipPath': M.clip(MOL, R)}, {'clipPath': M.clip(FULL, 0), 'duration': 0.55, 'ease': 'power3.inOut'}, t)
                M.FT(M.CAM, M.cam(t, A), dict(M.cam(t + 0.55, B), duration=0.55, ease='power3.inOut'), t)
                M.TO('#bg', {'opacity': 0, 'duration': 0.3}, t + 0.4)
                M.som('whoosh', t + 0.25, SOM_PEQUENO, 'moldura')
        elif kb == 'tela_verde':
            M.FT('#bg', {'opacity': 0}, {'opacity': 1, 'duration': 0.3}, t)
            M.TO('#frame', {'opacity': 0, 'duration': 0.25}, t)
            M.som('whoosh', t + 0.1, SOM_PEQUENO, 'tela_verde')
        elif ka == 'tela_verde':
            M.SET('#frame', {'clipPath': M.clip(FULL, 0)}, t)
            M.SET(M.CAM, M.cam(t, B), t)
            M.FT('#frame', {'opacity': 0}, {'opacity': 1, 'duration': 0.2}, t)
            M.TO('#bg', {'opacity': 0, 'duration': 0.3}, t + 0.2)
        else:
            M.SET(M.CAM, M.cam(t, B), t)
    for c in P.cortes:
        if not M.dentro(c, c) or c <= T0 + 1e-3 or round(c, 3) in marcados:
            continue
        # corte dentro da transição da moldura: usa o enquadramento do bloco novo (senão a moldura mostra o peito)
        M.SET(M.CAM, M.cam(c), c)
    zooms_na_timeline(M)
    moldura_flutua(M)
    camera_acompanhada(M)
    fundos_desfocados(M, fronteiras)
    fundos_contida(M, fronteiras)
    comparacoes(M)
    # tela verde: fundo + pessoa recortada
    pv = os.path.join(AQUI, 'assets', 'pessoa.webm')
    for i, b in enumerate(M.blocos):
        if b['cena'] != 'tela_verde' or not M.dentro(b['de'], b['ate']):
            continue
        if not os.path.exists(pv):
            raise Erro('tela verde precisa de assets/pessoa.webm. Rode ferramentas/pessoa_recortada.py antes.')
        a, z = max(b['de'], T0), min(b['ate'], M.VFIM)
        fundo = b.get('fundo')
        if fundo:
            midia(M, f'tv{i}', fundo, a, z, b.get('encaixe', 'cobrir'), 'Fundo da tela verde',
                  inicio=float(b.get('inicio', 0)), de_bloco=b['de'])
        tx, ty, s = M.pessoa_tf(b)
        ini, fim = M.pessoa_janela()
        if a < ini - 0.02 or z > fim + 0.02:
            raise Erro(f'o pessoa.webm cobre de {ini:.2f} a {fim:.2f} s e a tela verde vai de {a:.2f} a {z:.2f}. '
                       f'Rode de novo: python3 ferramentas/pessoa_recortada.py --edit <EDIT> --de {a:.2f} --ate {z:.2f}')
        M.css('tela_verde', """.pw{position:absolute;left:0;top:0;width:${W}px;height:${H}px;transform-origin:0 0}
.pw2{position:absolute;inset:0;opacity:0}
.pw2 video{position:absolute;left:0;top:0;width:100%;height:100%;object-fit:cover}""")
        o = 5 / s
        contorno = ' '.join(f'drop-shadow({dx * o:.1f}px {dy * o:.1f}px 0 #ffffff)' for dx, dy in
                            ((1, 0), (-1, 0), (0, 1), (0, -1), (0.7, 0.7), (-0.7, 0.7), (0.7, -0.7), (-0.7, -0.7)))
        filtro = f'{contorno} drop-shadow(0 {14 / s:.0f}px {18 / s:.0f}px rgba(0,0,0,.45))' if b.get('contorno', True) else 'none'
        M.HT.append(f'<div class="pw" style="transform:translate({tx}px,{ty}px) scale({s})"><div class="pw2" id="pw{i}" style="filter:{filtro}">'
                    f'<video id="pessoa{i}" src="assets/pessoa.webm" muted playsinline data-start="{M.rr(a)}" '
                    f'data-duration="{round(z - a, 3)}" data-media-start="{round(a - ini, 3)}" data-track-index="{TRILHA_PESSOA}" '
                    f'data-timeline-label="Pessoa recortada · tela verde"></video></div></div>')
        M.FT(f'#pw{i}', {'opacity': 0, 'y': 40}, {'opacity': 1, 'y': 0, 'duration': 0.3, 'ease': 'power3.out'}, a)
    # prova: imagem ou vídeo por cima (numa caixa que o zoom mexe), rótulo em cima. O borrão vai queimado no arquivo.
    for i, b in enumerate(M.blocos):
        if b['cena'] != 'prova' or not M.dentro(b['de'], b['ate']):
            continue
        a, z = max(b['de'], T0), min(b['ate'], M.VFIM)
        if b['de'] > 0.05 and b['de'] >= T0 - 0.05:
            # a prova entra em corte seco: o som é o da troca, com o pico no quadro do corte
            M.som('whoosh', b['de'], SOM_PEQUENO, f'prova{i}')
        if b.get('arquivo'):
            arq = M.midias_borradas.get(i, b['arquivo'])
            midia(M, f'pr{i}', arq, a, z, b.get('encaixe', 'cobrir'), 'Prova', inicio=float(b.get('inicio', 0)),
                  de_bloco=b['de'], caixa=f'pvw{i}', nome=b['arquivo'])
        if b.get('rotulo'):
            pid = f'provarot{i}'
            # o rótulo entra 0,62 s depois do corte: o pop dele não briga com o som da troca (espaço de 0,65 s)
            em = b['de'] + (0.62 if b['ate'] - b['de'] >= 1.6 else 0.15)
            with M.faixa(pid, f'Rótulo da prova · {str(b["rotulo"]).replace("*", "")}', TRILHA_PECAS + 19):
                peca_rotulo(M, dict(id=pid, texto=b['rotulo'], em=em, antecipa=0, sai=b['ate'] - 0.15, onde='topo'), pid)


def moldura_flutua(M):
    """(PrimoCode) nada parado: depois de fechar, o quadro da moldura sobe e desce devagar (uns 14 px), e volta ao lugar
    antes de abrir de novo. O aro (#borda) anda junto."""
    amp = round(14 * M.F['escala'])
    for b in M.blocos:
        if b['cena'] != 'moldura' or b.get('flutua') is False:
            continue
        t0, t1 = max(b.get('_fecha', b['de']), M.T0), min(b['ate'], M.VFIM) - 0.12
        if t1 - t0 < 1.2:
            continue
        meio = round((t1 - t0) / 2, 3)
        M.FT('#frame, #borda', {'y': 0}, {'keyframes': [{'y': -amp, 'duration': meio, 'ease': 'sine.inOut'},
                                                          {'y': 0, 'duration': meio, 'ease': 'sine.inOut'}]}, t0)


def zooms_na_timeline(M):
    """zoom que anda até o ponto (caixa) ou punch-in no rosto, na palavra; volta ao enquadramento do bloco no ate"""
    for z in M.zooms:
        if not M.dentro(z['t0'] - 0.01, z['t1'] + z['dur_volta']):
            continue
        sel = M.CAM if z['alvo'] == 'camera' else '#' + z['alvo']
        if z['t0'] < M.T0 - 1e-6 < z['t1'] and z['alvo'] != 'camera':
            M.INICIO(sel, M.quadro_zoom(z, M.T0))         # amostra que começa com o zoom já dado
        if z['t0'] >= M.T0 - 1e-6:
            M.FT(sel, M.quadro_antes_do_zoom(z), dict(M.quadro_zoom(z, M.fim_sem_corte(z['t0'], z['dur'])), duration=z['dur'],
                                                      ease=z['ease']), z['t0'])
            if z['som']:
                M.som(str(z['som']), z['t0'] + z['dur'] * 0.5, SOM_PEQUENO, 'zoom')
        if z['volta'] and z['t1'] >= M.T0 - 1e-6:
            M.FT(sel, M.quadro_zoom(z, z['t1']), dict(M.quadro_base(z, M.fim_sem_corte(z['t1'], z['dur_volta'])),
                                                     duration=z['dur_volta'], ease='power3.inOut'), z['t1'])


def transicao_contida(M, A, B, t, no_corte):
    """troca que entra ou sai da gravação contida: o recorte do #frame anda de um quadro ao outro junto com a câmera
    (0,6 s na entrada, 0,55 na saída); no corte, troca seca"""
    ka, kb = A['cena'], B['cena']
    (qa, ra), (qb, rb) = M.quadro_de(A), M.quadro_de(B)
    papel_a, papel_b = ka in ('moldura', 'tela_verde', 'contida'), kb in ('moldura', 'tela_verde', 'contida')
    if kb == 'tela_verde':                       # a gravação some e a pessoa recortada aparece no papel
        M.TO('#frame', {'opacity': 0, 'duration': 0.25}, t)
        M.som('whoosh', t + 0.1, SOM_PEQUENO, 'tela_verde')
        return
    if ka == 'tela_verde':                       # o quadro já nasce contido e aparece
        M.SET('#frame', {'clipPath': M.clip(qb, rb)}, t)
        M.SET(M.CAM, M.cam(t, B), t)
        M.FT('#frame', {'opacity': 0}, {'opacity': 1, 'duration': 0.25}, t)
        M.som('pop', t + 0.1, SOM_PEQUENO, 'contida')
        return
    dur = 0.6 if kb == 'contida' else 0.55
    if no_corte:
        M.SET('#frame', {'clipPath': M.clip(qb, rb)}, t)
        M.SET(M.CAM, M.cam(t, B), t)
    else:
        M.SET(M.CAM, M.cam(t, A), t)
        M.FT('#frame', {'clipPath': M.clip(qa, ra)}, {'clipPath': M.clip(qb, rb), 'duration': dur, 'ease': 'power3.inOut'}, t)
        M.FT(M.CAM, M.cam(t, A), dict(M.cam(M.fim_sem_corte(t, dur), B), duration=dur, ease='power3.inOut'), t)
        M.som('whoosh', t + dur / 2, SOM_PEQUENO, 'contida')
    if ka == 'moldura':
        M.TO('#borda', {'opacity': 0, 'duration': 0.15}, t)
    if kb == 'moldura':
        M.FT('#borda', {'opacity': 0}, {'opacity': 1, 'duration': 0.25}, t + (0.0 if no_corte else dur - 0.15))
    if papel_a and not papel_b:
        M.TO('#bg', {'opacity': 0, 'duration': 0.2 if no_corte else 0.3}, t if no_corte else t + dur - 0.2)
    elif papel_b and not papel_a:
        M.FT('#bg', {'opacity': 0}, {'opacity': 1, 'duration': 0.2 if no_corte else 0.4}, t)


def camera_acompanhada(M):
    """blocos com "acompanhar": a câmera segue o caminho suavizado do rosto quadro a quadro (dados + laço, seek em
    qualquer ponto acerta), fora das transições animadas; a pessoa recortada (#innerp, também .camera) recebe a
    mesma conta. Confere se a cabeça cabe no quadro o tempo todo."""
    blocos = [b for b in M.blocos if b.get('acompanhar') and M.dentro(b['de'], b['ate'])]
    if not blocos:
        return
    ocupado = [(t - 1e-3, t + duracao_tween(bb) + 1e-3) for k, sel, _a, bb, t in M.R
               if sel == M.CAM and k in ('ft', 'to')]
    for b in blocos:
        a, z = max(b['de'], M.T0), min(b['ate'], M.VFIM)
        pares, ant, forcar = [], None, True
        for f in range(int(math.ceil(a * FPS - 1e-6)), int(math.floor(z * FPS - 1e-6)) + 1):
            t = f / FPS
            if any(x <= t <= y for x, y in ocupado):
                forcar = True
                continue
            c = M.cam(t, b)
            if (forcar or ant is None or abs(c['x'] - ant['x']) > 0.05 or abs(c['y'] - ant['y']) > 0.05
                    or abs(c['scale'] - ant['scale']) > 1e-4):
                pares.append((t, c))
                ant, forcar = c, False
        if pares:
            M.QUADROS(M.CAM, pares)
        conferir_cabeca(M, b, a, z)


def intervalos(tempos, passo, minimo):
    """tempos marcados (em ordem) viram intervalos [(de, ate)], juntando vizinhos a até 1,5 passo; fica o que dura
    pelo menos `minimo`"""
    out = []
    for t in tempos:
        if out and t - out[-1][1] <= 1.5 * passo + 1e-6:
            out[-1][1] = t
        else:
            out.append([t, t])
    return [(x, y + passo) for x, y in out if y + passo - x >= minimo - 1e-6]


def conferir_cabeca(M, b, a, z):
    """a cabeça inteira (rosto e cabelo) dentro do quadro do bloco, 10 vezes por segundo, com o rosto acompanhado"""
    (bx, by, bw, bh), _r = M.quadro_de(b)
    fora, sem = [], set()
    t = a
    while t < z - 1e-6:
        if not M.P.rosto_acompanhado(t):
            sem.add(next((tm['id'] for tm in M.P.trilha['tomadas'] if tm['de'] - 2e-3 <= t < tm['ate'] - 2e-3), '?'))
        r = M.rosto_tela(t)
        if r and (r['topo'] < by - 1 or r['y1'] > by + bh + 1 or r['x0'] < bx - 1 or r['x1'] > bx + bw + 1):
            fora.append(round(t, 2))
        t += 0.1
    nome = f'acompanhar ({b["cena"]} {b["de"]:.2f} a {min(b["ate"], M.P.VDUR):.2f} s)'
    if sem:
        aviso(f'{nome}: sem rosto medido na tomada {", ".join(sorted(sem))}: ali o enquadramento fica o da tomada')
    for x, y in intervalos(fora, 0.1, 0.2):
        aviso(f'{nome}: a cabeça sai do quadro de {x:.2f} a {y:.2f} s (a pessoa foi para a borda da gravação e o '
              f'enquadramento não tem para onde ir). Não cabe: diminua o zoom, use tela cheia (sem moldura) ou "contida" '
              f'nesse trecho')


def conferir_maos(M):
    """com assets/maos.json (ferramentas/rastrear_maos.py): avisa quando uma mão que a gravação mostra sai do
    quadro da moldura ou da tela (o zoom ou o recorte corta o gesto)"""
    arq = os.path.join(AQUI, 'assets', 'maos.json')
    D = ler_json_ou_nada(arq)
    if not isinstance(D, dict) or not D.get('quadros'):
        return
    passo = 1.0 / float(D.get('fps') or FPS)
    marcas, tv = {}, {}
    for q in D['quadros']:
        t = float(q.get('t', -1))
        if not (M.T0 <= t < M.VFIM) or not q.get('maos'):
            continue
        b = M.bloco(t)
        if b['cena'] not in ('tela_cheia', 'moldura', 'tela_verde'):
            continue
        if b['cena'] == 'tela_verde':
            if id(b) not in tv:                   # o lugar da pessoa na tela verde é o mesmo o bloco todo
                tv[id(b)] = M.pessoa_tf(b)
            tx, ty, s = tv[id(b)]
            c, (bx, by, bw, bh) = dict(x=tx, y=ty, scale=s), M.FULL
        else:
            c, (bx, by, bw, bh) = M.cam(t, b), M.quadro_de(b)[0]
        for m in q['maos']:
            pts = [p for p in (m.get('pts') or []) if 0 <= p[0] <= 1 and 0 <= p[1] <= 1]
            if len(pts) < 5:
                continue
            fora = 0
            for px, py in pts:
                vx, vy = M.vid(px, py)
                sx, sy = c['x'] + c['scale'] * vx, c['y'] + c['scale'] * vy
                if not (bx - 4 <= sx <= bx + bw + 4 and by - 4 <= sy <= by + bh + 4):
                    fora += 1
            if fora / len(pts) > 0.25:
                marcas.setdefault((id(b), m.get('lado', '?')), (b, []))[1].append(round(t, 3))
    for (_ib, lado), (b, ts) in marcas.items():
        onde = 'do quadro da moldura' if b['cena'] == 'moldura' else 'da tela'
        conserto = ('aumente o quadro ("moldura": {"caixa": [...]}), use tela cheia ou "contida" nesse trecho'
                    if b['cena'] == 'moldura' else 'diminua o zoom do bloco ou use "contida" nesse trecho')
        for x, y in intervalos(sorted(ts), passo, 0.2):
            aviso(f'mão ({lado.replace("_", " ")}) sai {onde} de {x:.2f} a {y:.2f} s: o gesto fica cortado. Se a mão '
                  f'mostra algo nesse trecho, {conserto}')


# ---------------------------------------------------------------------------------------- comparar (antes e depois)
OPINIAO = ('melhor', 'pior', 'incrivel', 'perfeito', 'perfeita', 'ruim', 'horrivel', 'lindo', 'linda', 'feio', 'feia',
           'otimo', 'otima', 'pessimo', 'pessima', 'profissional', 'amador', 'amadora', 'top', 'lixo', 'maravilhoso')


def css_midia(M):
    M.css('midia', """.midia{position:absolute;left:0;top:0;width:100%;height:100%;object-fit:cover}
.midia.borrada{filter:blur(30px) brightness(.55)}
.midia.inteira{object-fit:contain}
.pvw{position:absolute;inset:0;transform-origin:0 0}""")


def copia_ligada(rel, sufixo):
    """o mesmo arquivo nos dois lados (ou o próprio corte) vira outro nome em assets/gerado (ligação no mesmo disco):
    a mesma mídia duas vezes na página faz o lint reclamar"""
    src = os.path.join(AQUI, rel)
    base, ext = os.path.splitext(os.path.basename(rel))
    dst_rel = f'{PASTA_GERADO}/{base}_{sufixo}{ext}'.replace(os.sep, '/')
    dst = os.path.join(AQUI, dst_rel)
    os.makedirs(os.path.dirname(dst), exist_ok=True)
    copiar_video(src, dst)
    return dst_rel


def lado_comparar(b, chave, sufixo):
    s = b.get(chave)
    nome = f'comparar em {b["de"]:.2f} s, lado "{chave}"'
    if not isinstance(s, dict) or not s.get('arquivo'):
        raise Erro(f'{nome}: falta {{"arquivo": "assets/...", "rotulo": "..."}} (ou "arquivo": "corte" para um trecho do '
                   f'próprio corte)')
    rot = str(s.get('rotulo') or '').strip()
    if not rot:
        raise Erro(f'{nome}: falta "rotulo", literal: o que aquele lado é ("Gravação crua", "Com a limpeza de voz"), '
                   f'sem opinião')
    ruins = [w for w in re.findall(r'\w+', norm(rot.replace('*', ''))) if w in OPINIAO]
    if ruins:
        aviso(f'{nome}: o rótulo "{rot}" dá opinião ({", ".join(ruins)}). Rótulo de comparação é literal: diga o que o '
              f'lado é e deixe a diferença aparecer')
    arq = 'assets/cut.mp4' if s['arquivo'] == 'corte' else str(s['arquivo'])
    if not os.path.exists(os.path.join(AQUI, arq)):
        raise Erro(f'{nome}: não achei {arq} (copie para assets/)')
    video = arq.lower().endswith(('.mp4', '.mov', '.webm', '.m4v'))
    if s['arquivo'] == 'corte':
        arq = copia_ligada(arq, sufixo)
    w, h = tamanho_midia(os.path.join(AQUI, arq))
    if not w or not h:
        raise Erro(f'{nome}: não consegui ler o tamanho de {arq}')
    return dict(chave=chave, arquivo=arq, nome=s['arquivo'], rotulo=rot, inicio=float(s.get('inicio', 0) or 0),
                video=video, w=w, h=h)


def encaixe_comparar(M, modo, r, arranjo='auto'):
    """(caixas [(x, y, w, h)], altura do rótulo) na zona segura, na mesma escala para os dois lados"""
    sx0, sy0, sx1, sy1 = M.F['seguro']
    s = M.F['escala']
    lab, gap = round(116 * s), round(40 * s)
    # a imagem pode passar da zona segura dos lados (só o texto precisa ficar nela)
    sx0, sx1 = max(40 * s, sx0 - 80 * s), min(M.W - 40 * s, sx1 + 80 * s)
    aw, ah = sx1 - sx0, sy1 - sy0
    if modo == 'alternando':
        fw = min(aw, (ah - lab) * r)
        fh = fw / r
        x, y = sx0 + (aw - fw) / 2, sy0 + lab + (ah - lab - fh) / 2
        return [(x, y, fw, fh)], lab
    # lado a lado: em linha ou empilhado, o que deixar a imagem maior
    lw = min((aw - gap) / 2, (ah - lab) * r)
    cw = min(aw, ((ah - 2 * lab - gap) / 2) * r)
    # (PrimoCode) "arranjo": "lado" ou "empilhado" fixa a escolha; "auto" fica com o que deixa a imagem maior
    if arranjo == 'lado' or (arranjo != 'empilhado' and lw >= cw):
        fh = lw / r
        cx, y = sx0 + aw / 2, sy0 + lab + (ah - lab - fh) / 2
        return [(cx - gap / 2 - lw, y, lw, fh), (cx + gap / 2, y, lw, fh)], lab
    fh = cw / r
    tot = 2 * (lab + fh) + gap
    y0 = sy0 + (ah - tot) / 2
    x = sx0 + (aw - cw) / 2
    return [(x, y0 + lab, cw, fh), (x, y0 + 2 * lab + fh + gap, cw, fh)], lab


def loudness(caminho, ini, dur):
    """volume percebido (LUFS integrado) de um trecho, ou None"""
    r = subprocess.run(['ffmpeg', '-hide_banner', '-nostats', '-ss', f'{max(0.0, ini):.3f}', '-t', f'{dur:.3f}', '-i', caminho,
                        '-vn', '-af', 'ebur128=framelog=quiet', '-f', 'null', '-'], capture_output=True, text=True,
                       encoding='utf-8', errors='replace')
    m = re.findall(r'I:\s+(-?[0-9.]+) LUFS', r.stderr or '')
    return float(m[-1]) if m else None


def tem_som(caminho):
    v = ffprobe_valor(caminho, 'stream=codec_type', 'a:0')
    return bool(v and v[0].strip())


def comparacoes(M):
    """bloco "comparar": dois arquivos (ou dois trechos) na mesma escala e na mesma velocidade, lado a lado ou
    alternando, com rótulos literais e o som de um lado só (ou os dois no mesmo volume percebido, alternando)"""
    P = M.P
    for i, b in enumerate(M.blocos):
        if b['cena'] != 'comparar' or not M.dentro(b['de'], b['ate']):
            continue
        nome = f'comparar em {b["de"]:.2f} s'
        modo = b.get('modo', 'lado_a_lado')
        if modo not in ('lado_a_lado', 'alternando'):
            raise Erro(f'{nome}: "modo" é "lado_a_lado" (o padrão) ou "alternando"')
        som = b.get('som', 'voz')
        if som not in ('voz', 'a', 'b', 'igualado'):
            raise Erro(f'{nome}: "som" é "voz" (o padrão: a fala continua e os dois lados ficam mudos), "a" ou "b" '
                       f'(só o som desse lado) ou "igualado" (alternando: o som do lado que está na tela, os dois no '
                       f'mesmo volume percebido)')
        if som == 'igualado' and modo != 'alternando':
            raise Erro(f'{nome}: lado a lado, os dois sons tocariam juntos. Use "som": "a" ou "b", ou "modo": '
                       f'"alternando" com "som": "igualado"')
        A = lado_comparar(b, 'a', f'cmp{i}a')
        B = lado_comparar(b, 'b', f'cmp{i}b')
        if A['arquivo'] == B['arquivo']:                 # dois trechos do mesmo arquivo
            B['arquivo'] = copia_ligada(B['arquivo'], f'cmp{i}b')
        ra, rb = A['w'] / A['h'], B['w'] / B['h']
        if abs(ra / rb - 1) > 0.02:
            aviso(f'{nome}: os dois lados têm proporções diferentes ({A["w"]}x{A["h"]} e {B["w"]}x{B["h"]}): o lado b fica '
                  f'com faixas para não esticar. Para a mesma escala, use arquivos com o mesmo formato')
        if A['w'] * A['h'] != B['w'] * B['h'] and abs(ra / rb - 1) <= 0.02:
            menor = A if A['w'] * A['h'] < B['w'] * B['h'] else B
            aviso(f'{nome}: o lado {menor["chave"]} tem resolução menor no arquivo ({menor["w"]}x{menor["h"]}): a '
                  f'diferença de nitidez vem do arquivo, não da montagem (os dois aparecem do mesmo tamanho)')
        a, z = max(b['de'], M.T0), min(b['ate'], M.VFIM)
        dd = round(z - a, 3)
        for L in (A, B):
            L['ms'] = round(L['inicio'] + (a - b['de']), 3)
            if L['video']:
                dur = duracao_midia(os.path.join(AQUI, L['arquivo']))
                if dur and L['ms'] + dd > dur + 0.05:
                    aviso(f'{nome}: o lado {L["chave"]} ({L["nome"]}) tem {dur:.2f} s e o trecho pede até '
                          f'{L["ms"] + dd:.2f} s: o fim fica parado. Diminua o bloco ou o "inicio"')
        arranjo = b.get('arranjo', 'auto')
        if arranjo not in ('auto', 'lado', 'empilhado'):
            raise Erro(f'{nome}: "arranjo" é "auto" (o padrão), "lado" (um ao lado do outro) ou "empilhado" (um em cima do outro)')
        caixas, lab = encaixe_comparar(M, modo, ra, arranjo)
        css_midia(M)
        M.css('comparar', '''.cmp-fundo{position:absolute;inset:0;$fundo}
.cmp-lado{position:absolute;overflow:hidden;border-radius:24px;opacity:0;background:#000;box-shadow:$sombra}''')
        M.COMPARA_HT.append(f'<div class="clip cmp-fundo" id="cmpf{i}" data-start="{M.rr(a)}" data-duration="{dd}" '
                            f'data-track-index="{TRILHA_MIDIA}" data-timeline-label="Comparar · fundo"></div>')
        # trocas (alternando): segundos ou a palavra; sem trocas, uma no meio
        trocas = []
        if modo == 'alternando':
            depois = b['de']
            for k, x in enumerate(b.get('trocas') or []):
                tq = P.tempo({'em': x}, 'em', depois=depois)
                if not b['de'] + 0.3 <= tq <= b['ate'] - 0.3:
                    raise Erro(f'{nome}: a troca {k + 1} ({tq:.2f} s) tem que ficar dentro do bloco, a 0,3 s das pontas')
                trocas.append(tq)
                depois = tq + 0.05
            if not trocas:
                trocas = [round((b['de'] + b['ate']) / 2, 3)]
            pontos = [b['de']] + trocas + [b['ate']]
            curto = min(y - x for x, y in zip(pontos, pontos[1:]))
            if curto < 0.8:
                raise Erro(f'{nome}: cada lado precisa ficar pelo menos 0,8 s na tela para dar tempo de ver e ler o '
                           f'rótulo (o menor ficou com {curto:.2f} s): espace as "trocas"')
        lados = (A, B)
        for k, L in enumerate(lados):
            x, y, w, h = caixas[0] if modo == 'alternando' else caixas[k]
            enc = ' inteira' if L is B and abs(ra / rb - 1) > 0.02 else ''
            lab_txt = escape(f'Comparar · {L["chave"].upper()} · {L["rotulo"].replace("*", "")}')
            if L['video']:
                tag = (f'<video id="cmp{i}{L["chave"]}v" class="midia{enc}" src="{escape(L["arquivo"])}" muted playsinline '
                       f'data-start="{M.rr(a)}" data-duration="{dd}" data-media-start="{L["ms"]}" '
                       f'data-track-index="{TRILHA_MIDIA}" data-timeline-label="{lab_txt}"></video>')
            else:
                tag = (f'<img id="cmp{i}{L["chave"]}v" class="clip midia{enc}" src="{escape(L["arquivo"])}" alt="" '
                       f'data-start="{M.rr(a)}" data-duration="{dd}" data-track-index="{TRILHA_MIDIA}" '
                       f'data-timeline-label="{lab_txt}"/>')
            M.COMPARA_HT.append(f'<div class="cmp-lado" id="cmp{i}{L["chave"]}" style="left:{x:.0f}px;top:{y:.0f}px;'
                                f'width:{w:.0f}px;height:{h:.0f}px">{tag}</div>')
        # quem aparece quando: lado a lado os dois o bloco todo; alternando, a, b, a...
        janelas = {'a': [(b['de'], b['ate'])], 'b': [(b['de'], b['ate'])]}
        if modo == 'alternando':
            pontos = [b['de']] + trocas + [b['ate']]
            janelas = {'a': [], 'b': []}
            for k in range(len(pontos) - 1):
                janelas['a' if k % 2 == 0 else 'b'].append((pontos[k], pontos[k + 1]))
        for ch, js in janelas.items():
            sel = f'#cmp{i}{ch}'
            for x0, x1 in js:
                if x1 <= M.T0 or x0 >= M.VFIM:
                    continue
                if x0 <= M.T0 + 1e-3:
                    M.INICIO(sel, {'opacity': 1})
                else:
                    M.SETQ(sel, {'opacity': 1}, x0)
                if x1 < M.VFIM - 1e-3:
                    M.SETQ(sel, {'opacity': 0}, x1)
        # rótulo literal em cima de cada lado (ou do lado que está na tela)
        s = M.F['escala']
        hr = round(100 * s)
        sx0, _sy0, sx1, _sy1 = M.F['seguro']
        for k, L in enumerate(lados):
            x, y, w, h = caixas[0] if modo == 'alternando' else caixas[k]
            em_linha = modo == 'lado_a_lado' and abs(caixas[0][1] - caixas[1][1]) < 1
            lmax = min(w, (sx1 - sx0 - 24) / 2 if em_linha else sx1 - sx0)
            _fs, lw = medida_rotulo(L['rotulo'], s, lmax)
            onde = [int(clamp(x + w / 2 - lw / 2, sx0, sx1 - lw)), int(max(M.F['seguro'][1], y - hr - (lab - hr) / 2))]
            for n, (x0, x1) in enumerate(janelas[L['chave']]):
                if x1 <= M.T0 or x0 >= M.VFIM:
                    continue
                pid = f'cmp{i}{L["chave"]}r{n}'
                ultimo = x1 >= b['ate'] - 1e-3
                with M.faixa(pid, f'Rótulo · {L["rotulo"].replace("*", "")}', TRILHA_PECAS + 17):
                    peca_rotulo(M, dict(id=pid, texto=L['rotulo'], em=x0 + (0.15 if x0 > b['de'] else 0.2), antecipa=0,
                                        sai=x1 - (0.05 if not ultimo else 0.12), onde=onde, largura_max=lmax), pid)
        if b['de'] >= M.T0 - 0.05 and b['de'] > 0.05:
            M.som('whoosh', b['de'], SOM_PEQUENO, f'comparar{i}')
        for tq in trocas:
            M.som('clique', tq, SOM_PEQUENO, f'comparar{i}')
        # som
        if som == 'voz':
            continue
        M.voz_fora.append((b['de'], b['ate']))
        falas = [w['text'] for w in P.words if b['de'] - 0.02 <= w['start'] < b['ate'] - 0.05]
        if falas:
            aviso(f'{nome}: com "som": "{som}" a voz do corte sai nesse trecho e a fala "{" ".join(falas)[:80]}" fica muda. '
                  f'Deixe esse espaço no corte (trecho sem fala) ou use "som": "voz"')
        fontes = {'a': [(a, z)] if som == 'a' else [], 'b': [(a, z)] if som == 'b' else []}
        if som == 'igualado':
            fontes = {ch: [(max(x0, a), min(x1, z)) for x0, x1 in js if x1 > a and x0 < z] for ch, js in janelas.items()}
        ganho = {'a': 1.0, 'b': 1.0}
        for L in lados:
            if fontes[L['chave']] and not (L['video'] and tem_som(os.path.join(AQUI, L['arquivo']))):
                raise Erro(f'{nome}: o lado {L["chave"]} ({L["nome"]}) não tem som, e "som" pede o som dele')
        if som == 'igualado':
            nivel = {L['chave']: loudness(os.path.join(AQUI, L['arquivo']), L['inicio'], b['ate'] - b['de']) for L in lados}
            if None in nivel.values():
                aviso(f'{nome}: não consegui medir o volume dos dois lados; ficaram no volume do arquivo')
            else:
                baixo = min(nivel.values())
                for ch, v in nivel.items():       # só abaixa o mais alto: nenhum lado é empurrado para cima
                    ganho[ch] = round(10 ** ((baixo - v) / 20), 3)
                print(f'  {nome}: volume igualado (a {nivel["a"]:.1f} LUFS, b {nivel["b"]:.1f} LUFS; o mais alto desceu '
                      f'{abs(nivel["a"] - nivel["b"]):.1f} dB)')
        for L in lados:
            for n, (x0, x1) in enumerate(fontes[L['chave']]):
                if x1 - x0 < 0.02:
                    continue
                ms = round(L['inicio'] + (x0 - b['de']), 3)
                M.AUD.append(f'<audio id="cmp{i}{L["chave"]}s{n}" src="{escape(L["arquivo"])}" data-start="{M.rr(x0)}" '
                             f'data-duration="{round(x1 - x0, 3)}" data-media-start="{ms}" data-volume="{ganho[L["chave"]]}" '
                             f'data-track-index="{TRILHA_COMPARA_SOM}" data-timeline-label="Som do lado '
                             f'{L["chave"]} · {escape(L["rotulo"].replace("*", ""))}"></audio>')


def fundos_contida(M, fronteiras):
    """gravação contida: a cor de fundo (quando "fundo" é uma cor) e a sombra em volta da gravação. O fundo
    desfocado é o mesmo da moldura (fundos_desfocados); o papel é o #bg."""
    saidas = {id(A): (B, any(abs(c - B['de']) < 0.05 for c in M.P.cortes)) for A, B in fronteiras}
    entradas = {id(B): (A, any(abs(c - B['de']) < 0.05 for c in M.P.cortes)) for A, B in fronteiras}
    for i, b in enumerate(M.blocos):
        if b['cena'] != 'contida':
            continue
        prox, no_corte = saidas.get(id(b), (None, True))
        anima = prox is not None and prox['cena'] != 'tela_verde' and not no_corte
        fim_b = b['ate'] + (0.6 if anima else 0)
        if not M.dentro(b['de'], fim_b):
            continue
        a, z = max(b['de'], M.T0), min(fim_b, M.VFIM)
        if b['ate'] >= M.P.VDUR - 1e-3 and M.VFIM >= M.P.VDUR - 1e-3:
            z = M.T0 + M.DUR                      # vai até o fim, também no fim parado
        M.css('contida', '.cfundo{position:absolute;inset:0}\n'
                         '.cborda{position:absolute;opacity:0;box-shadow:0 18px 50px rgba(0,0,0,.45),0 0 0 3px rgba(255,255,255,.14)}')
        if str(b['fundo']).startswith('#'):
            M.FUNDOS.append(f'<div class="clip cfundo" id="cfun{i}" style="background:{escape(str(b["fundo"]))}" '
                            f'data-start="{M.rr(a)}" data-duration="{round(z - a, 3)}" data-track-index="{TRILHA_FUNDO}" '
                            f'data-timeline-label="Fundo da gravação contida"></div>')
        rx, ry, rw, rh = M.caixa_contida(b)
        raio = int(b.get('raio', M.F['raio']))
        M.FUNDOS.append(f'<div class="cborda" id="cbrd{i}" style="left:{rx:g}px;top:{ry:g}px;width:{rw:g}px;height:{rh:g}px;'
                        f'border-radius:{raio}px"></div>')
        ant, no_corte_e = entradas.get(id(b), (None, True))
        if b['de'] <= M.T0 + 1e-3 or ant is None:
            M.INICIO(f'#cbrd{i}', {'opacity': 1})
        else:
            entra_animada = ant['cena'] != 'tela_verde' and not no_corte_e
            M.FT(f'#cbrd{i}', {'opacity': 0}, {'opacity': 1, 'duration': 0.25}, b['de'] + (0.45 if entra_animada else 0.0))
        if b['ate'] < M.VFIM:
            M.TO(f'#cbrd{i}', {'opacity': 0, 'duration': 0.12}, b['ate'])


def fundos_desfocados(M, fronteiras):
    """moldura (ou contida) com "fundo": "desfocado": o próprio vídeo, desfocado, atrás do quadro no lugar do papel"""
    saidas = {id(A): (B, any(abs(c - B['de']) < 0.05 for c in M.P.cortes)) for A, B in fronteiras}
    for i, b in enumerate(M.blocos):
        if b['cena'] not in ('moldura', 'contida') or b.get('fundo') != 'desfocado':
            continue
        prox, no_corte = saidas.get(id(b), (None, True))
        anima = prox is not None and prox['cena'] != 'tela_verde' and not no_corte
        fim_b = b['ate'] + (0.75 if anima else 0)
        if not M.dentro(b['de'], fim_b):
            continue
        src = gerar_fundo_desfocado()
        a, z = max(b['de'], M.T0), min(fim_b, M.VFIM)
        M.css('desf', '.desfw{position:absolute;inset:0;opacity:0}\n'
                      '.desfw video,.desfw img{position:absolute;left:0;top:0;width:100%;height:100%;object-fit:cover;'
                      'filter:brightness(.82)}')
        parado = ''
        if M.cauda and b['ate'] >= M.P.VDUR - 1e-3 and M.VFIM >= M.P.VDUR - 1e-3:
            # fim parado (segura_fim): o fundo desfocado também fica no último quadro, em vez de sumir
            VD = round(M.VFIM - M.T0, 3)
            parado = (f'<img id="desfu{i}" class="clip" src="{gerar_ultimo_desfocado(src)}" alt="" '
                      f'data-start="{round(max(0.0, VD - 2 / FPS), 3)}" data-duration="{round(M.cauda + 2 / FPS, 3)}" '
                      f'data-track-index="{TRILHA_FUNDO}" data-timeline-label="Fundo desfocado parado"/>')
        M.FUNDOS.append(f'<div class="desfw" id="desf{i}">{parado}<video id="desfv{i}" src="{src}" muted playsinline '
                        f'data-start="{M.rr(a)}" data-duration="{round(z - a, 3)}" data-media-start="{round(a, 3)}" '
                        f'data-track-index="{TRILHA_FUNDO}" data-timeline-label="Fundo desfocado da '
                        f'{"moldura" if b["cena"] == "moldura" else "gravação contida"}"></video></div>')
        if b['de'] <= M.T0 + 1e-3:
            M.INICIO(f'#desf{i}', {'opacity': 1})
        else:
            M.FT(f'#desf{i}', {'opacity': 0}, {'opacity': 1, 'duration': 0.6}, b['de'])
        if anima:
            M.TO(f'#desf{i}', {'opacity': 0, 'duration': 0.3}, b['ate'] + 0.4)


def midia(M, mid, arquivo, a, z, encaixe, rotulo, inicio=0.0, de_bloco=None, caixa=None, nome=None):
    """imagem ou vídeo com tempo; 'inteiro' mostra a peça toda por cima de uma cópia borrada. "inicio" é o segundo
    do arquivo em que o bloco começa (vídeo). "caixa" embrulha numa caixa sem tempo, que o zoom mexe."""
    if not os.path.exists(os.path.join(AQUI, arquivo)):
        raise Erro(f'não achei {arquivo} (copie para assets/)')
    video = arquivo.lower().endswith(('.mp4', '.mov', '.webm', '.m4v'))
    css_midia(M)
    ds, dd = M.rr(a), round(z - a, 3)
    lab = escape(f'{rotulo} · {os.path.basename(nome or arquivo)}')
    ms = round(max(0.0, inicio + (a - (de_bloco if de_bloco is not None else a))), 3)
    if video:
        dur = duracao_midia(os.path.join(AQUI, arquivo))
        if dur and ms + dd > dur + 0.05:
            aviso(f'{rotulo.lower()} {nome or arquivo}: o vídeo tem {dur:.2f} s e o trecho pede até {ms + dd:.2f} s '
                  f'(inicio {inicio:g} + {dd:.2f} s): a parte final fica sem imagem. Diminua o bloco ou o "inicio".')

    def tag(i, extra):
        if video:
            return (f'<video id="{mid}{i}" class="midia{extra}" src="{escape(arquivo)}" muted playsinline data-start="{ds}" '
                    f'data-duration="{dd}" data-media-start="{ms}" data-track-index="{TRILHA_MIDIA}" data-timeline-label="{lab}"></video>')
        return (f'<img id="{mid}{i}" class="clip midia{extra}" src="{escape(arquivo)}" alt="" data-start="{ds}" data-duration="{dd}" '
                f'data-track-index="{TRILHA_MIDIA}" data-timeline-label="{lab}"/>')
    tags = [tag('b', ' borrada'), tag('', ' inteira')] if encaixe == 'inteiro' else [tag('', '')]
    if caixa:
        M.HT.append(f'<div class="pvw" id="{caixa}">' + ''.join(tags) + '</div>')
    else:
        M.HT.extend(tags)


# ---------------------------------------------------------------------------------------- cenas sob medida
RE_TEMPLATE = re.compile(r'<template\b[^>]*>(.*)</template>', re.S | re.I)
RE_CID = re.compile(r'data-composition-id\s*=\s*["\']([^"\']+)["\']')


def ler_cena(arq):
    """confere o contrato da sub-composição escrita pela IA e devolve o data-composition-id dela"""
    caminho = os.path.join(AQUI, arq)
    if not os.path.exists(caminho):
        raise Erro(f'cena própria: não achei {arq} (o caminho é relativo à pasta do projeto, ex.: "compositions/cenas/s03_orcamento.html")')
    with open(caminho, encoding='utf-8') as f:
        txt = f.read()
    m = RE_TEMPLATE.search(txt)
    if not m:
        raise Erro(f'{arq}: a cena tem que estar dentro de <template> (veja compositions/cenas/LEIA.md)')
    m2 = RE_CID.search(m.group(1))
    if not m2:
        raise Erro(f'{arq}: falta data-composition-id na raiz da cena (veja compositions/cenas/LEIA.md)')
    cid = m2.group(1)
    if not re.search(r'__timelines\s*\[\s*["\']' + re.escape(cid) + r'["\']\s*\]\s*=', m.group(1)):
        raise Erro(f'{arq}: a cena tem que registrar a timeline com o mesmo nome da raiz: window.__timelines["{cid}"] = tl;')
    if cid == 'main' or cid.startswith('peca-'):
        raise Erro(f'{arq}: o data-composition-id "{cid}" é reservado pelo build (use o nome do arquivo, ex.: "s03_orcamento")')
    return cid


RE_RAIZ = re.compile(r'<[A-Za-z][\w-]*\b[^>]*\bdata-composition-id\s*=\s*["\'][^"\']+["\'][^>]*>')
RE_SCRIPT = re.compile(r'<script\b[^>]*>(.*?)</script\s*>', re.S | re.I)


def raiz_da_cena(arq):
    """(largura, altura, se_desenha): o tamanho que a raiz da cena declara (o HyperFrames usa ESSE tamanho, não o
    do host) e se a cena se desenha pelo tamanho que o build manda (lê "largura" e "altura" do window.__cenas, ou
    usa o motor do kit 3D, que lê). Só com se_desenha trocar o tamanho da raiz é seguro."""
    with open(os.path.join(AQUI, arq), encoding='utf-8') as f:
        m = RE_TEMPLATE.search(f.read())
    corpo = m.group(1) if m else ''
    tag = RE_RAIZ.search(corpo)

    def num(nome):
        v = re.search(r'\b' + nome + r'\s*=\s*["\']?\s*(\d+(?:\.\d+)?)', tag.group(0)) if tag else None
        return int(round(float(v.group(1)))) if v else None
    js = '\n'.join(RE_SCRIPT.findall(corpo))
    se_desenha = '__cenas' in js and (('motor-three' in js) or bool(re.search(r'\blargura\b', js) and re.search(r'\baltura\b', js)))
    return num('data-width'), num('data-height'), se_desenha


def ajustar_raiz(M, arq, cid):
    """raiz com outro tamanho que o vídeo (a cena do kit é 1080x1920 e o projeto é 16:9, 4:5 ou 1:1): o HyperFrames
    monta a raiz e o host com o tamanho declarado e a cena sai cortada. Quando a cena se desenha pelo largura e
    altura que recebe, o index.html força o tamanho do vídeo na raiz e no host (o arquivo da cena não muda) e
    devolve o tamanho declarado; senão, avisa e devolve None (None também quando a raiz já está certa)."""
    rw, rh, se_desenha = raiz_da_cena(arq)
    if rw is None or rh is None or (abs(rw - M.W) <= 1 and abs(rh - M.H) <= 1):
        return None
    if se_desenha:
        # o HyperFrames escreve o tamanho declarado no style da raiz (sem !important), no estúdio e no render (que
        # troca a raiz por um div sem id nem data-composition-id, filho único do host): a regra com !important do
        # index.html vence nos dois
        M.css(f'raiz-{cid}', f'#cena-{cid},#cena-{cid}>*{{width:{M.W}px!important;height:{M.H}px!important}}')
        return rw, rh
    aviso(f'{arq}: a raiz declara {rw}x{rh} e o vídeo é {M.W}x{M.H}: a cena sai cortada (o HyperFrames usa o tamanho '
          f'da raiz). Troque para data-width="{M.W}" data-height="{M.H}" e desenhe pelo largura e altura do '
          f'window.__cenas (compositions/cenas/LEIA.md), ou faça a cena para este formato.')
    return None


def dividem_quadro(a0, a1, b0, b1):
    """[a0, a1) e [b0, b1), meio-abertos, dividem pelo menos um quadro do render? Encostar não conta (um começa no
    quadro em que o outro acaba), nem a sobra de arredondamento: o tempo escrito com 3 casas ("corte em 4.933")
    contra o corte de verdade (4.93333), ou fora da grade de quadros. 1 ms de folga para cada lado."""
    ini, fim = max(a0, b0), min(a1, b1)
    k = math.ceil((ini - 1e-3) * FPS - 1e-9)
    return k / FPS < fim - 1e-3


def moldura_da_cena(M, de, ate):
    """o bloco moldura que a cena cruza, ou None. Os limites valem como a pessoa escreveu E como ficaram no corte
    (o bloco encosta no corte até 0,35 s): a cena que começa quando a moldura acaba não é da moldura"""
    for b in M.blocos:
        if b['cena'] != 'moldura':
            continue
        b0 = max(b['de'], b.get('_de_pedido', b['de']))
        b1 = min(b['ate'], b.get('_ate_pedido', b['ate']))
        if b1 > b0 and dividem_quadro(de, ate, b0, b1):
            return b
    return None


def nome_da_cena(arq):
    base = os.path.splitext(os.path.basename(arq))[0]
    return 'Cena · ' + base.replace('_', ' ')


def cenas_proprias(M):
    P = M.P
    vistos, n, ajustadas = {}, 0, []
    for i, c in enumerate(P.cfg.get('cenas_proprias', [])):
        arq = c.get('arquivo')
        if not arq:
            raise Erro(f'cena própria {i + 1}: falta "arquivo" (ex.: "compositions/cenas/s03_orcamento.html")')
        cid = ler_cena(arq)
        if cid in vistos:
            raise Erro(f'{arq}: o data-composition-id "{cid}" já é usado por {vistos[cid]}')
        vistos[cid] = arq
        de = P.tempo(c, 'de')
        if de is None:
            raise Erro(f'{arq}: falta "de" (segundos do corte ou a palavra)')
        ate = P.tempo(c, 'ate', depois=de + 0.05)
        if ate is None or ate <= de + 0.1:
            raise Erro(f'{arq}: falta "ate" (segundos do corte ou a palavra, depois do "de")')
        if M.segura and P.VDUR - 0.25 <= ate < P.VDUR + M.segura:
            ate = P.VDUR + M.segura          # termina com o corte: continua no fim parado (segura_fim)
        if c.get('esconde_legenda'):
            M.esconder_extra.append((de - 0.1, ate))
        if c.get('sobre_a_pessoa') not in (None, True, False):
            raise Erro(f'{arq}: "sobre_a_pessoa" é true ou false (cartão pequeno acima da cabeça, a câmera viva não aproxima)')
        if c.get('sobre_a_pessoa'):
            # a câmera que chega perto sobe a cabeça para baixo do cartão: o camera_viva.py deixa o zoom em 1,0 nesse trecho
            for b in M.blocos:
                if b['de'] < ate - 0.05 and b['ate'] > de + 0.05 and b['cena'] == 'tela_cheia' and \
                        (b.get('zooms') or float(b.get('zoom', P.cfg.get('zoom', 1.08))) > 1.005):
                    aviso(f'{arq}: a cena é "sobre_a_pessoa" mas o bloco tela_cheia de {b["de"]:.2f} a {min(b["ate"], P.VDUR):.2f} s '
                          f'aproxima a câmera (zoom ou "zooms"): a cabeça sobe para baixo do cartão. Rode o helpers/camera_viva.py '
                          f'(zoom 1,0 nesse trecho) ou tire o zoom do bloco')
                    break
        if not M.dentro(de, ate):
            continue
        a, z = max(de, M.T0), min(ate, M.T0 + M.DUR)
        pb = round(a - de, 3)
        trilha = int(c.get('faixa', TRILHA_CENAS + i))
        rot = escape(str(c.get('nome') or nome_da_cena(arq)))
        M._HT.append(f'<div id="cena-{cid}" class="faixa" data-composition-id="{cid}" data-composition-src="{escape(arq)}" '
                     f'data-start="{M.rr(a)}" data-duration="{round(z - a, 3)}"' + (f' data-playback-start="{pb}"' if pb > 0 else '')
                     + f' data-track-index="{trilha}" data-timeline-label="{rot}" data-width="{M.W}" data-height="{M.H}"></div>')
        raiz = ajustar_raiz(M, arq, cid)
        if raiz:
            ajustadas.append(f'{os.path.basename(arq)} ({raiz[0]}x{raiz[1]})')
        r = M.rosto_tela(de + 0.02)
        mol = moldura_da_cena(M, de, ate)
        if mol is not None and mol['de'] - 0.4 <= de < mol['_fecha'] - 1e-3:
            aviso(f'{arq}: a cena começa em {de:.2f} s e a moldura só termina de fechar em {mol["_fecha"]:.2f} s: o que '
                  f'aparecer antes disso passa na frente do rosto ainda grande. Use window.__cenas[...].moldura.fechada_em.')
        M.cenas_js[cid] = dict(
            de=round(de, 3), ate=round(ate, 3), duracao=round(ate - de, 3), largura=M.W, altura=M.H,
            seguro=list(M.F['seguro']), cores={k: M.E[k] for k in ('papel', 'linha', 'base', 'destaque', 'tinta', 'verde', 'vermelho')},
            fundo_css=M.E['fundo'] if M.E.get('cena_no_fundo_do_estilo') else None,
            rosto={k: round(r[k]) for k in ('x0', 'y0', 'x1', 'y1', 'topo')} if r else None,
            moldura=dict(caixa=list(M.MOL), area_livre=list(M.F['area_moldura']),
                         fechada_em=round(max(0.0, mol['_fecha'] - de), 3)) if mol is not None else None,
            palavras=[dict(texto=w['text'], t=round(w['start'] - de, 3), fim=round(w['end'] - de, 3))
                      for w in P.words if de - 0.05 <= w['start'] < ate],
            tempo=P.tempo_fala)
        n += 1
    if ajustadas:
        aviso(f'raiz ajustada para {M.W}x{M.H} no index.html em {len(ajustadas)} cena(s) que declaram outro tamanho e se '
              f'desenham pelo "largura" e "altura" que recebem: {", ".join(ajustadas)}. O arquivo não muda (aberta sozinha '
              f'no estúdio, a cena fica no tamanho que declara)')
    return n


# ---------------------------------------------------------------------------------------- legenda
def estilo_legenda(M):
    lg = M.P.cfg.get('legenda', {}) or {}
    est = norm(str(lg.get('estilo', 'padrao'))).replace(' ', '_')
    if est in LEGENDA_CENA_PROPRIA:
        raise Erro(f'legenda "{lg.get("estilo")}" não é do modelo: a frase se escrevendo à mão (papelaria), manchete ou '
                   f'impacto é cena própria. Desligue a legenda nesse trecho ("legenda": false no bloco, ou '
                   f'"esconde_legenda" na cena) e escreva a cena (compositions/cenas/LEIA.md). Estilos do modelo: '
                   f'{", ".join(LEGENDA_ESTILOS)}.')
    if est not in LEGENDA_ESTILOS:
        raise Erro(f'legenda.estilo tem que ser {" ou ".join(LEGENDA_ESTILOS)} (pediu "{lg.get("estilo")}")')
    return 'padrao' if M.modo == 'corte' else est


def legendas(M):
    P, F = M.P, M.F
    lg = P.cfg.get('legenda', {}) or {}
    est = estilo_legenda(M)
    esconder = []
    if M.modo != 'corte':
        esconder = [(P.tempo(dict(de=x[0]), 'de'), P.tempo(dict(ate=x[1]), 'ate')) for x in lg.get('esconder', [])]
        for i, pp in enumerate(P.cfg.get('animacoes', [])):
            # o lettering escreve a fala: a legenda sai do trecho dele (a não ser que ele diga "esconde_legenda": false),
            # ou, com "legenda_junto", só as palavras que ele escreve grande saem (M.fora_da_legenda)
            if pp.get('tipo') == 'lettering' and (pp.get('id') or f'a{i + 1}') in M.junto_ok:
                continue
            if pp.get('esconde_legenda', pp.get('tipo') == 'lettering'):
                a = P.tempo(pp, 'em') or 0
                esconder.append((a - 0.1, P.tempo(pp, 'sai', depois=a) or a + 1.5))
        esconder += M.esconder_extra
    max_l = int(lg.get('max_letras', 16 if est == 'cinetica' else (F['max_letras'] if est == 'padrao' else F['max_letras_discreta'])))
    chaves = {norm(str(x)) for x in lg.get('chaves') or []}
    max_p = max(1, int(lg.get('max_palavras', 3)))

    def ligada(t):
        if any(a <= t < b for a, b in esconder):
            return False
        if not M.legenda_ligada(M.bloco(t)):
            return False
        # palavra colada numa troca para um trecho sem legenda também sai (a legenda não pisca antes da troca).
        # Na volta, a primeira palavra do bloco novo tem legenda normal.
        return M.legenda_ligada(M.bloco(t + 0.3))
    # a fala em frases (pausa de mais de 0,6 s, pontuação, trecho sem legenda) e cada frase em legendas: nenhuma
    # palavra dita fica de fora. Com a peça lettering com "legenda_junto", as palavras que ela escreve grande naquele
    # instante saem da legenda (M.fora_da_legenda) e o resto da fala continua legendado.
    fora = M.fora_da_legenda if M.modo != 'corte' else set()
    frases, cur = [], []
    for k, w in enumerate(P.words):
        if w['end'] <= M.T0 + 0.05 or w['start'] >= M.VFIM - 0.05 or not ligada(w['start']) or k in fora:
            if cur:
                frases.append(cur)
                cur = []
            continue
        if cur and w['start'] - cur[-1]['end'] > 0.6:
            frases.append(cur)
            cur = []
        cur.append(w)
        if w['text'][-1] in ',.?!…':
            frases.append(cur)
            cur = []
    if cur:
        frases.append(cur)
    limpas = [c for f in frases for c in dividir_frase(f, max_p, max_l)]
    paradas = sorted([b['de'] for b in M.blocos] + [x for ab in esconder for x in ab] + [M.VFIM])

    def janela(i):
        c = limpas[i]
        a = c[0]['start']
        limite = min([x for x in paradas if x > a + 0.05] + ([limpas[i + 1][0]['start']] if i + 1 < len(limpas) else [])
                     + [M.VFIM])
        b = min(c[-1]['end'] + 0.12, limite)      # nunca duas na tela
        return a, max(b, min(a + 0.35, limite))   # palavra curta fica pelo menos uns 0,35 s, se der

    def junta(x, y):
        return (y[0]['start'] - x[-1]['end'] <= 0.6 and x[-1]['text'][-1] not in '.?!…'
                and not any(x[0]['start'] + 0.05 < p <= y[-1]['end'] for p in paradas))
    mudou = True
    # legenda de uma palavra só que piscaria menos de 0,3 s junta com a vizinha (mesmo passando de max_palavras por
    # um instante): palavra nenhuma some
    while mudou:
        mudou = False
        for i, c in enumerate(limpas):
            a, b = janela(i)
            if len(c) != 1 or b - a >= 0.3:
                continue
            cand = ([i - 1] if i > 0 and junta(limpas[i - 1], c) else []) + \
                   ([i + 1] if i + 1 < len(limpas) and junta(c, limpas[i + 1]) else [])
            if not cand:
                continue
            j = min(cand, key=lambda j: len(' '.join(x['text'] for x in limpas[j])))
            limpas[j] = limpas[j] + c if j < i else c + limpas[j]
            del limpas[i]
            mudou = True
            break
    if est == 'discreta':
        fs = F['fonte_leg_discreta']
        hcap = round(fs * 1.55)
        M.css('legenda', """.cap{position:absolute;left:40px;right:40px;text-align:center;white-space:nowrap;opacity:0;z-index:50}
.cap span{display:inline-block;font-weight:700;color:#ffffff;line-height:1.25;letter-spacing:.2px;padding:.1em .5em .14em;
  border-radius:.45em;background:rgba(18,14,28,.55);text-shadow:0 1px 2px rgba(0,0,0,.35)}""")
    elif est == 'cinetica':
        fs = round(LEGENDA_CINETICA_ALTURA * M.H)  # GIGANTE como a régua: ~10% da altura do quadro (192 px no 9:16)
        hcap = round(fs * 1.02)
        M.css('legenda', f""".cap{{position:absolute;text-align:center;opacity:0;z-index:50;
  font-family:Inter;font-weight:800;color:#ffffff;letter-spacing:-.02em;filter:{SOMBRA_CINETICA}}}
.cap .cl{{display:block;white-space:nowrap}}
.cap .cw{{display:inline-block;opacity:0}}
.cap .ck{{background:{gradiente_css(P.cfg)};-webkit-background-clip:text;background-clip:text;color:transparent}}""")
    else:
        fs = F['fonte_leg']
        hcap = round(fs * 1.3)
        M.css('legenda', """.cap{position:absolute;left:40px;right:40px;text-align:center;white-space:nowrap;font-weight:900;color:#ffffff;
  opacity:0;letter-spacing:.5px;-webkit-text-stroke:10px $contorno_leg;paint-order:stroke fill;text-shadow:0 6px 18px rgba(0,0,0,.45);z-index:50}""")
    n, ultimo = 0, None
    for i, c in enumerate(limpas):
        a, b = janela(i)
        if b <= M.T0:
            continue
        if est == 'cinetica':
            ultimo = legenda_cinetica(M, i, c, a, b, fs, chaves, ultimo)
            n += 1
            continue
        caixa = caixa_legenda(M, a)
        texto = ' '.join(x['text'] for x in c).rstrip(',.')
        # a largura da própria legenda (centrada): uma peça no canto não empurra a legenda à toa
        larg = medir_texto(texto.upper() if est == 'padrao' else texto, 'Montserrat', 900 if est == 'padrao' else 700, fs)
        topo = topo_legenda(M, a, b, hcap, ultimo, caixa, larg + (20 if est == 'padrao' else 0.9 * fs) + 24)
        ultimo = topo
        if est == 'padrao':
            texto = texto.upper()
        ini = max(a, M.T0)
        lugar = f'top:{topo}px;font-size:{fs}px'
        if caixa:
            lugar += f';left:{caixa[0]}px;right:auto;width:{caixa[1]}px'
        conteudo = escape(texto) if est == 'padrao' else f'<span>{escape(texto)}</span>'
        # cada legenda é um clipe na faixa "Legenda" do estúdio: dá para ver onde cada uma entra e sai
        M._HT.append(f'<div class="cap caption-cue clip" id="cap{i}" data-start="{M.rr(ini)}" data-duration="{round(b - ini, 3)}" '
                     f'data-track-index="{TRILHA_LEGENDA}" data-track-kind="captions" data-timeline-label="Legenda · {escape(texto)}" '
                     f'style="{lugar}">{conteudo}</div>')
        M.FT(f'#cap{i}', {'opacity': 0, 'y': 14}, {'opacity': 1, 'y': 0, 'duration': 0.12}, a)
        M.SET(f'#cap{i}', {'opacity': 0}, b)
        n += 1
    return n


def legenda_cinetica(M, i, c, a, b, fs, chaves, ultimo):
    """(PrimoCode) uma legenda cinética: letra gigante (fs), em 1 linha ou empilhada em 2 dentro da largura da zona
    segura (a letra só desce se nem empilhada couber), palavra por palavra no começo dela, a palavra-chave no degradê.
    Fica fora do rosto (abaixo dele, senão acima) e dentro da zona segura. Devolve o topo usado."""
    sx0, sy0, sx1, sy1 = M.F['seguro']
    aw = sx1 - sx0
    folga = 0.9 * aw                               # a sombra e o pulo da palavra-chave (escala 1,18) cabem na zona segura
    ps = [x['text'] for x in c]
    ps[-1] = ps[-1].rstrip(',.') or ps[-1]
    ps[0] = ps[0][:1].upper() + ps[0][1:]
    ks = [j for j, x in enumerate(c) if norm(x['text']) in chaves]
    kj = max(ks, key=lambda j: len(c[j]['text'])) if ks else None

    def larg(idx, px):
        return sum(medir_texto(ps[j], 'Inter', 800, px) for j in idx) + 0.26 * px * (len(idx) - 1)

    def orfa(li):                                  # linha só com "a", "o", "de"...: melhor encolher a letra
        return len(li) == 1 and len(ps[li[0]]) <= 3
    tudo, px, linhas = list(range(len(ps))), fs, None
    while linhas is None:
        if larg(tudo, px) <= folga:
            linhas = [tudo]
        else:
            ops = [[tudo[:k], tudo[k:]] for k in range(1, len(tudo)) if max(larg(tudo[:k], px), larg(tudo[k:], px)) <= folga
                   and (px <= 0.7 * fs or not (orfa(tudo[:k]) or orfa(tudo[k:])))]
            if not ops and len(tudo) == 3 and all(larg([j], px) <= folga for j in tudo) and not any(len(ps[j]) <= 3 for j in tudo):
                ops = [[[0], [1], [2]]]               # três palavras longas: uma por linha, ainda gigantes
            if ops:
                linhas = min(ops, key=lambda o: max(larg(li, px) for li in o) - min(larg(li, px) for li in o))
            elif px <= 0.45 * fs:
                linhas = [tudo[:1], tudo[1:]] if len(tudo) > 1 else [tudo]
            else:
                px = int(px * 0.92)
    lh = round(px * 1.02)
    # a perna do p e do g passa da linha (line-height 1,02): a caixa reservada leva essa sobra embaixo
    h, w = lh * len(linhas) + round(0.16 * px), max(larg(li, px) for li in linhas)
    y = lugar_cinetica(M, a, b, h, w, ultimo)
    html = ''.join('<div class="cl">' + ' '.join(f'<span class="cw{" ck" if j == kj else ""}" id="cap{i}w{j}">{escape(ps[j])}</span>'
                                                 for j in li) + '</div>' for li in linhas)
    ini = max(a, M.T0)
    texto = ' '.join(ps)
    M._HT.append(f'<div class="cap caption-cue clip" id="cap{i}" data-start="{M.rr(ini)}" data-duration="{round(b - ini, 3)}" '
                 f'data-track-index="{TRILHA_LEGENDA}" data-track-kind="captions" data-timeline-label="Legenda · {escape(texto)}" '
                 f'style="top:{y}px;left:{sx0}px;width:{aw}px;font-size:{px}px;line-height:{lh}px">{html}</div>')
    M.SET(f'#cap{i}', {'opacity': 1}, a)
    for j, x in enumerate(c):                      # cada palavra no começo dela (medido na onda com palavras_onda.json)
        tj = clamp(x['start'], a, max(a, b - 0.08))
        M.FT(f'#cap{i}w{j}', {'opacity': 0, 'y': round(0.22 * px), 'scale': 1.18 if j == kj else 0.9},
             {'opacity': 1, 'y': 0, 'scale': 1, 'duration': 0.24 if j == kj else 0.16, 'ease': 'back.out(2)'}, tj)
    M.SET(f'#cap{i}', {'opacity': 0}, b)
    return y


def lugar_cinetica(M, a, b, h, w, ultimo=None):
    """(PrimoCode) o topo da legenda cinética: na mesma altura da anterior se ainda couber, senão logo abaixo do rosto,
    senão acima dele (com o cabelo), senão na base da zona segura; nunca sobre o rosto nem sobre uma peça"""
    sx0, sy0, sx1, sy1 = M.F['seguro']
    cx0, cx1 = M.W / 2 - w / 2, M.W / 2 + w / 2
    gap = round(28 * M.H / 1920)
    rostos = [r for r in (M.rosto_tela(t) for t in (a, (a + b) / 2, max(a, b - 0.02))) if r]

    def livre(y):
        if y < sy0 or y + h > sy1:
            return False
        if any(y < r['y1'] + gap and r['topo'] - gap < y + h and r['x0'] < cx1 and cx0 < r['x1'] for r in rostos):
            return False
        return not any(t0 < b and a < t1 and y < yy + hh + 10 and yy < y + h + 10 and x < cx1 and cx0 < x + ww
                       for t0, t1, x, yy, ww, hh in M.retangulos)
    cands = ([ultimo] if ultimo is not None else []) + \
        ([max(r['y1'] for r in rostos) + gap, min(r['topo'] for r in rostos) - gap - h] if rostos else []) + \
        [sy1 - h, M.F['legenda_padrao'], sy0]
    for y in cands:
        if livre(int(y)):
            return int(y)
    aviso(f'legenda em {a:.2f} s: não coube fora do rosto em letra grande; ficou na base da zona segura')
    return int(sy1 - h)


def dividir_frase(ws, max_p, max_l):
    """uma frase em legendas de até max_p palavras e max_l letras, sem perder palavra. Entre as divisões com menos
    legendas, a que não termina legenda em artigo, preposição ou "não" (vai com a palavra seguinte), não deixa
    palavra sozinha quando dá, quebra onde a fala respira e equilibra o tamanho: "Eu gravei esse vídeo uma vez só,"
    com 3 palavras vira "Eu gravei" / "esse vídeo" / "uma vez só," (e não "vídeo uma vez" / "só,")."""
    n = len(ws)
    if n <= 1:
        return [ws] if ws else []

    def cabe(i, j):
        return j - i == 1 or (j - i <= max_p and len(' '.join(w['text'] for w in ws[i:j])) <= max_l)

    def custo(i, j):
        c = 0.1 * (j - i) ** 2                       # equilíbrio
        if j - i == 1:
            c += 4.0                                 # palavra sozinha
        if j < n:                                    # quebra depois de ws[j - 1]
            if norm(ws[j - 1]['text']) in SEM_QUEBRA_DEPOIS:
                c += 3.0
            c -= 2.0 * clamp(ws[j]['start'] - ws[j - 1]['end'], 0.0, 0.6)   # onde a fala respira
        return c
    melhor = [None] * (n + 1)
    melhor[0] = (0, 0.0, [])
    for j in range(1, n + 1):
        for i in range(max(0, j - max_p), j):
            if melhor[i] is None or not cabe(i, j):
                continue
            q, c, cortes = melhor[i]
            cand = (q + 1, round(c + custo(i, j), 6), cortes + [(i, j)])
            if melhor[j] is None or cand[:2] < melhor[j][:2]:
                melhor[j] = cand
    return [ws[i:j] for i, j in melhor[n][2]]


def caixa_legenda(M, t):
    """legenda na moldura (legenda.na_moldura): no 16:9 fica na área livre ao lado do quadro, sem atravessar a
    borda dele; devolve (x, largura) ou None (a legenda usa a largura da tela)"""
    if M.bloco(t)['cena'] != 'moldura' or not M.formato_deitado():
        return None
    x0, _y0, x1, _y1 = M.F['area_moldura']
    return int(x0), int(x1 - x0)


def topo_legenda(M, a, b, hcap, ultimo=None, caixa=None, largura=None):
    F = M.F
    lo, hi = F['legenda']
    r = M.rosto_tela(a)
    cx0, cx1 = (caixa[0], caixa[0] + caixa[1]) if caixa else (40, M.W - 40)
    if largura and not caixa:
        cx0, cx1 = max(cx0, M.W / 2 - largura / 2), min(cx1, M.W / 2 + largura / 2)
    base = F['legenda_padrao'] if not r else int(clamp(r['y1'] + F['legenda_folga'], lo, hi))   # no peito
    alternativas = list(F['legenda_alt'])
    if M.bloco(a)['cena'] == 'moldura':
        mx, my, mw, mh = M.MOL
        ax0, ay0, ax1, ay1 = F['area_moldura']
        if caixa:                    # 16:9: embaixo da área livre, ou em cima dela
            base, alternativas = int(ay1 - hcap - 10), [int(ay0 + 10)]
        else:                        # 9:16: logo acima do quadro, fora dele
            base, alternativas = int(my - hcap - 24), [int(ay0 + 10)]
    motivo = []

    def bate(y):
        for t0, t1, x, yy, w, h in M.retangulos:
            if t0 < b and a < t1 and y < yy + h + 10 and yy < y + hcap + 10 and x < cx1 and cx0 < x + w:
                motivo.append('numa animação')
                return True
        if r is not None and y < r['y1'] and r['y0'] < y + hcap and r['x0'] < cx1 and cx0 < r['x1']:
            motivo.append('no rosto')
            return True
        return False
    # altura estável: diferença pequena entre uma legenda e a próxima vira tremida, então fica na mesma altura
    prefer = [ultimo] if ultimo is not None and abs(ultimo - base) < 40 else []
    for y in prefer + [base] + alternativas:
        if not bate(y):
            return int(y)
    onde = 'numa animação' if 'numa animação' in motivo else 'no rosto'
    aviso(f'legenda em {a:.2f} s encosta {onde}; ficou em {base}px')
    return base


# ---------------------------------------------------------------------------------------- sons
def medir_som(caminho):
    """(duração, tempo do pico dentro do arquivo, pico em dBFS, envelope), medido na taxa e nos canais do próprio
    arquivo. O envelope é o pico de cada bloco de 5 ms (para medir o pico só do trecho que toca)."""
    cache_p = os.path.join(AQUI, '.cache_sons.json')
    try:
        with open(cache_p, encoding='utf-8') as f:
            cache = json.load(f)
    except (OSError, ValueError):
        cache = {}
    st = os.stat(caminho)
    chave = f'{caminho}|{st.st_size}|{int(st.st_mtime)}|v2'
    if chave in cache:
        return tuple(cache[chave])
    info = subprocess.run(['ffprobe', '-v', 'error', '-select_streams', 'a:0', '-show_entries', 'stream=sample_rate,channels',
                           '-of', 'csv=p=0', caminho], capture_output=True, text=True).stdout.strip().split(',')
    try:
        sr, ch = int(info[0]), int(info[1])
    except (ValueError, IndexError):
        raise Erro(f'não consegui ler o som {caminho} (o ffprobe não entendeu o arquivo)')
    raw = subprocess.run(['ffmpeg', '-v', 'error', '-i', caminho, '-f', 's16le', '-acodec', 'pcm_s16le', '-'], capture_output=True).stdout
    a = array('h')
    a.frombytes(raw[:len(raw) // 2 * 2])
    if sys.byteorder == 'big':
        a.byteswap()
    bi, bv = 0, 1
    passo = max(1, int(sr * 0.005)) * ch
    env = []
    for k0 in range(0, len(a), passo):
        m = 1
        for k in range(k0, min(len(a), k0 + passo)):
            v = abs(a[k])
            if v > m:
                m = v
                if v > bv:
                    bv, bi = v, k
        env.append(m)
    res = (len(a) / ch / sr, (bi // ch) / sr, 20 * math.log10(bv / 32768), env)
    cache[chave] = res
    with open(cache_p, 'w', encoding='utf-8') as f:
        json.dump(cache, f)
    return res


def pico_trecho(env, a, b):
    """pico em dBFS só do trecho [a, b] do arquivo (segundos), pelo envelope de 5 ms"""
    i0, i1 = int(a / 0.005), max(int(a / 0.005) + 1, int(math.ceil(b / 0.005)))
    v = max(env[i0:i1] or [1])
    return 20 * math.log10(max(v, 1) / 32768)


def sons(M):
    """os sons do projeto.json ("sons"), cada um com o pico no quadro "em". Aceita o que o plano_sfx.py --projeto
    escreve: "pico_no_arquivo", "inicio_no_arquivo" (pula o começo do arquivo), "dur", "volume" ou "db", "fade" e
    "fade_entrada". Som cujo começo cairia antes do 0 perde só a cabeça; nenhum som some sem aviso."""
    lista = M.P.cfg.get('sons', [])
    fim_video = M.P.VDUR + M.cauda
    n = 0
    for i, s in enumerate(lista):
        nome = f'som {i + 1} ({s.get("arquivo", "?")})'
        if not s.get('arquivo'):
            aviso(f'som {i + 1}: falta "arquivo"')
            continue
        arq = os.path.join(AQUI, s['arquivo'])
        if not os.path.exists(arq):
            aviso(f'{nome}: não achei o arquivo; ficou de fora')
            continue
        try:
            pico = M.P.tempo(s, 'em')
        except Erro as e:
            aviso(f'{nome}: {e}; ficou de fora')
            continue
        if pico is None:
            aviso(f'{nome}: falta "em" (o segundo do corte em que o pico cai); ficou de fora')
            continue
        dur_arq, tp_medido, _pdb, env = medir_som(arq)
        tp = float(s['pico_no_arquivo']) if s.get('pico_no_arquivo') is not None else tp_medido
        off = float(s.get('inicio_no_arquivo', 0) or 0)
        if pico < -1e-3 or pico > fim_video + 1e-3:
            aviso(f'{nome}: o pico cai em {pico:.2f} s, fora do vídeo (0 a {fim_video:.2f} s); ficou de fora')
            continue
        ini = pico - (tp - off)                 # o pico do som cai no quadro do movimento
        fade_in = float(s.get('fade_entrada', 0) or 0)
        tocar = float(s['dur']) if s.get('dur') is not None else float(s.get('max', 1.2))
        fim_arq = min(off + tocar, dur_arq)     # até onde o arquivo toca
        if ini < 0:                             # começaria antes do vídeo: perde só a cabeça (o fim fica no lugar)
            off, ini = off - ini, 0.0
            fade_in = max(fade_in, 0.01)
        dur = fim_arq - off
        if dur <= 0.02:
            aviso(f'{nome}: "inicio_no_arquivo" ({off:.3f} s) passa do fim do arquivo ({dur_arq:.3f} s); ficou de fora')
            continue
        # janela da amostra: o som que cruza a borda toca só o pedaço de dentro
        if ini + dur <= M.T0 + 1e-3 or ini >= M.T0 + M.DUR - 1e-3:
            continue
        if ini < M.T0:
            off, dur, ini = off + (M.T0 - ini), dur - (M.T0 - ini), M.T0
            fade_in = max(fade_in, 0.01)
        cortado = ini + dur > M.T0 + M.DUR
        dur = min(dur, M.T0 + M.DUR - ini)
        if dur <= 0.02:
            continue
        if s.get('volume') is not None:
            vol = float(s['volume'])
        else:
            vol = 10 ** ((float(s.get('db', SOM_PEQUENO)) - pico_trecho(env, off, off + dur)) / 20)
        if vol > VOLUME_MAX:
            aviso(f'{nome}: pedia volume {vol:.2f} (mais de +12 dB); ficou em {VOLUME_MAX}. Use um som mais alto de origem.')
            vol = VOLUME_MAX
        fade = min(float(s.get('fade', 0) or 0), dur)
        if cortado and fade < 0.03:
            fade = min(0.05, dur)               # cortado no fim do vídeo: sem estalo
        extra = (f' data-fade-in="{round(min(fade_in, dur), 3)}"' if fade_in > 0 else '') + \
                (f' data-fade-out="{round(fade, 3)}"' if fade > 0 else '')
        rot = s.get('tipo') or os.path.splitext(os.path.basename(s['arquivo']))[0]
        if s.get('nota'):
            rot = f'{rot} · {s["nota"]}'
        M.AUD.append(f'<audio id="som{i}" src="{escape(s["arquivo"])}" data-start="{M.rr(ini)}" data-duration="{round(dur, 3)}" '
                     f'data-media-start="{round(off, 4)}" data-volume="{round(vol, 3)}"{extra} data-track-index="{TRILHA_SONS + i}" '
                     f'data-timeline-label="Som · {escape(str(rot))}"></audio>')
        n += 1
    return n


# ---------------------------------------------------------------------------------------- página
# caminho sempre a partir da pasta do projeto, também dentro de compositions/ (o estúdio serve tudo a partir da raiz;
# "../assets" é erro no lint)
FONTES_CSS = '''@font-face{font-family:Montserrat;src:url("assets/fontes/Montserrat.ttf") format("truetype");font-weight:100 900;font-display:block}
@font-face{font-family:Caveat;src:url("assets/fontes/Caveat.ttf") format("truetype");font-weight:400 700;font-display:block}
@font-face{font-family:Inter;src:url("assets/fontes/Inter.ttf") format("truetype");font-weight:100 900;font-display:block}
@font-face{font-family:Mono;src:url("assets/fontes/JetBrainsMono.ttf") format("truetype");font-weight:100 800;font-display:block}'''

BASE_CSS = '''*{box-sizing:border-box;margin:0;padding:0}
html,body{width:${W}px;height:${H}px;overflow:hidden;background:#000}
#root{position:relative;width:100%;height:100%;overflow:hidden;font-family:Montserrat;background:#000;color:$tinta}
#palco{position:absolute;inset:0}
#bg{position:absolute;inset:0;opacity:0;$fundo}
#borda{position:absolute;left:${mx}px;top:${my}px;width:${mw}px;height:${mh}px;border-radius:${raio}px;opacity:0;box-shadow:$borda}
#frame{position:absolute;left:0;top:0;width:${W}px;height:${H}px;overflow:hidden}
#inner,#innerp{position:absolute;left:0;top:0;width:${W}px;height:${H}px;transform-origin:0 0}
#cam,.pessoa-atras,.ultimo{position:absolute;left:${vx}px;top:${vy}px;width:${vw}px;height:${vh}px;object-fit:cover}
#atras,#pecas,#fundos{position:absolute;inset:0}
.plfundo{position:absolute;inset:0;transform-origin:50% 50%}
.faixa{position:absolute;inset:0;pointer-events:none}'''


class _Canonico(HTMLParser):
    """o que importa num HTML: tags, atributos e texto. O estúdio regrava o arquivo quando abre (DOCTYPE em
    maiúscula, sem a barra do <meta/>) e carimba data-hf-id em cada elemento: isso não é mudança da pessoa."""

    def __init__(self):
        super().__init__(convert_charrefs=True)
        self.partes = []

    def handle_starttag(self, tag, attrs):
        limpos = sorted((k, v or '') for k, v in attrs if not k.startswith('data-hf-'))
        self.partes.append('<' + tag + ''.join(f' {k}={v!r}' for k, v in limpos) + '>')

    def handle_startendtag(self, tag, attrs):
        self.handle_starttag(tag, attrs)

    def handle_endtag(self, tag):
        self.partes.append('</' + tag + '>')

    def handle_data(self, data):
        if data.strip():
            self.partes.append(' '.join(data.split()))


def assinatura(caminho):
    with open(caminho, encoding='utf-8', errors='replace') as f:
        texto = f.read()
    c = _Canonico()
    try:
        c.feed(texto)
        c.close()
        base = '\n'.join(c.partes)
    except Exception:  # noqa: BLE001  (HTML quebrado: compara o texto cru)
        base = texto
    return hashlib.sha256(base.encode('utf-8')).hexdigest()


def guardar_mexidos():
    """mudança feita no estúdio (arrastar, esticar, trocar texto) fica no index.html e nas compositions/: o build
    reescreve tudo, então guarda uma cópia do que foi mexido e avisa (a mudança tem que ir pro projeto.json)"""
    meta_p = os.path.join(AQUI, '.build.json')
    if not os.path.exists(meta_p):
        return
    try:
        with open(meta_p, encoding='utf-8') as f:
            meta = json.load(f)
    except (OSError, ValueError):
        return
    mexidos = []
    for rel, h in (meta.get('arquivos') or {}).items():
        p = os.path.join(AQUI, rel)
        if os.path.exists(p) and assinatura(p) != h:
            mexidos.append(rel)
    if mexidos:
        destino = os.path.join(AQUI, PASTA_MEXIDOS)     # pasta com ponto: o estúdio não lista como composição
        os.makedirs(destino, exist_ok=True)
        for rel in mexidos:
            shutil.copy2(os.path.join(AQUI, rel), os.path.join(destino, rel.replace('/', '__')))
        aviso(f'{", ".join(mexidos)} foi mudado fora do build (no estúdio?). Cópia em {PASTA_MEXIDOS}/. '
              f'O build reescreve esses arquivos: passe a mudança pro projeto.json (ou pra cena em compositions/cenas/).')


def limpar_pecas_geradas():
    pasta = os.path.join(AQUI, PASTA_PECAS)
    if not os.path.isdir(pasta):
        return
    for nome in os.listdir(pasta):
        p = os.path.join(pasta, nome)
        if nome.endswith('.html') and os.path.isfile(p):
            with open(p, encoding='utf-8', errors='replace') as f:
                if f.read(200).lstrip().startswith(('<!doctype html>\n' + MARCA_GERADO, MARCA_GERADO)):
                    os.remove(p)


def escrever_faixas(M):
    """cada peça vira uma sub-composição em compositions/<id>.html e um lugar (host) no index.html"""
    escritos = []
    fontes = FONTES_CSS
    for F in M.faixas:
        ini, fim = janela_registros(F.R)
        if ini is None:
            continue
        if F.saida:
            fim = max(fim, F.saida[1] + 0.05)
        org = math.floor(ini * 1000 + 1e-6) / 1000      # tempo 0 da sub-composição, em segundos do corte
        a, z = max(org, M.T0), min(fim, M.T0 + M.DUR)
        if z - a < 1 / FPS:
            continue
        local = round(fim - org, 3)
        pb = round(a - org, 3)
        rel = f'{PASTA_PECAS}/{F.pid}.html'
        linhas = M.js(F.R, org, local)
        if F.ajuste_fontes:
            # lettering: a timeline é montada depois que a fonte carregou (document.fonts.ready), e antes disso cada
            # linha é medida com a letra de verdade; se passar da largura, a letra desce o que falta. Registra a
            # timeline só no fim (regra do HyperFrames para montagem assíncrona).
            dados = json.dumps([[i, round(w)] for i, w in F.ajuste_fontes])
            linhas = ([f'const ajustar = () => {{ for (const [id, max] of {dados}) {{',
                       '  const el = document.getElementById(id); if (!el) continue;',
                       '  let w = 0; for (const c of el.children) w += c.offsetWidth + (parseFloat(c.style.marginLeft) || 0);',
                       '  if (w > max) el.style.fontSize = (parseFloat(el.style.fontSize) * max / w).toFixed(1) + "px";',
                       '} };',
                       'const montar = () => {', 'ajustar();', 'const tl = gsap.timeline({ paused: true });']
                      + linhas + [f'window.__timelines["{F.cid}"] = tl;', '};',
                                  '(document.fonts && document.fonts.ready ? document.fonts.ready : Promise.resolve()).then(montar);'])
        corpo = '\n'.join(x for x in F.HT if isinstance(x, str))
        if F.ajuste_fontes:
            script = chr(10).join(linhas)
        else:
            script = ('const tl = gsap.timeline({ paused: true });\n' + chr(10).join(linhas)
                      + f'\nwindow.__timelines["{F.cid}"] = tl;')
        css = fontes + '\n#root{position:absolute;inset:0;font-family:Montserrat}\n' + '\n'.join(F.CSS.values())
        html = f'''<!doctype html>
{MARCA_GERADO} a partir do projeto.json: não edite à mão (rode o build de novo) -->
<html lang="pt-BR"><head><meta charset="UTF-8"/><title>{escape(F.rotulo)}</title></head>
<body>
<template id="{F.cid}-template">
<style>
{css}
</style>
<div id="root" data-composition-id="{F.cid}" data-width="{M.W}" data-height="{M.H}" data-duration="{local}">
{corpo}
</div>
<script>
(function () {{
{script}
}})();
</script>
</template>
</body></html>
'''
        os.makedirs(os.path.join(AQUI, PASTA_PECAS), exist_ok=True)
        with open(os.path.join(AQUI, rel), 'w', encoding='utf-8') as f:
            f.write(html)
        escritos.append(rel)
        F.host = (f'<div id="faixa-{F.pid}" class="faixa" data-composition-id="{F.cid}" data-composition-src="{rel}" '
                  f'data-start="{round(a - M.T0, 3)}" data-duration="{round(z - a, 3)}"'
                  + (f' data-playback-start="{pb}"' if pb > 0 else '')
                  + f' data-track-index="{F.trilha}" data-timeline-label="{escape(F.rotulo)}" data-width="{M.W}" data-height="{M.H}"></div>')
    return escritos


def vozes(M, VD):
    """a voz do corte num <audio> só, ou em pedaços quando um bloco comparar toca o som de um lado no lugar dela"""
    fora = sorted((max(a, M.T0), min(z, M.VFIM)) for a, z in M.voz_fora if z > M.T0 and a < M.VFIM)
    trechos, t = [], M.T0
    for a, z in fora:
        if a > t + 0.02:
            trechos.append((t, a))
        t = max(t, z)
    if M.VFIM > t + 0.02:
        trechos.append((t, M.VFIM))
    out = []
    for n, (a, z) in enumerate(trechos):
        vid = 'voz' if n == 0 else f'voz{n + 1}'
        ini = ('%.3f' % M.rr(a)).rstrip('0').rstrip('.')
        out.append(f'<audio id="{vid}" src="assets/cut.mp4" data-start="{ini}" data-duration="{round(z - a, 3)}" '
                   f'data-media-start="{round(a, 3)}" data-track-index="{TRILHA_VOZ}" data-timeline-label="Voz"></audio>')
    return '\n'.join(out)


def juntar(lista):
    return '\n'.join(x.host if isinstance(x, Faixa) else x for x in lista if (x.host if isinstance(x, Faixa) else x))


def montar(P, modo, T0, T1):
    cf = P.cfg.get('cartao_final', {})
    no_fim = modo != 'corte' and T1 >= P.VDUR - 0.01
    cartao = float(cf.get('segundos', 1.8)) if no_fim and cf.get('ligado') else 0.0
    segura = float(P.cfg.get('segura_fim', 0) or 0) if no_fim else 0.0
    if segura < 0 or segura > 6:
        raise Erro('"segura_fim" é quantos segundos o último quadro fica parado no fim (de 0 a 6; 1 a 2 s é o comum)')
    if segura > 3:
        aviso(f'segura_fim de {segura:g} s: mais de 3 s parado parece que o vídeo travou')
    M = Montagem(P, T0, T1, modo, segura, cartao)
    guardar_mexidos()
    video = os.path.join(AQUI, 'assets', 'cut.mp4')
    # borrão queimado no arquivo: o assets/cut.mp4 do projeto nunca leva o dado legível (o original fica no edit/)
    blocos_todos = M.blocos if modo != 'corte' else (resolver_blocos(P) if any(b.get('borrar') for b in P.cfg.get('blocos', [])) else [])
    cx_corte, cx_midias = caixas_borrar(P, blocos_todos)
    preparar_video(P, video, cx_corte)
    M.midias_borradas = {} if modo == 'corte' else {i: borrar_midia(blocos_todos[i], lst) for i, lst in cx_midias.items()}
    M.ultimo = gerar_ultimo_quadro() if segura else None
    cenas(M)
    if modo != 'corte':
        conferir_maos(M)
    ncenas = 0
    if modo != 'corte':
        k = 0
        for i, p in enumerate(P.cfg.get('animacoes', [])):
            tipo = p.get('tipo')
            if tipo not in PECAS:
                raise Erro(f'animação {i + 1}: tipo "{tipo}" não existe. Tipos: {", ".join(PECAS)}')
            pid = p.get('id') or f'a{i + 1}'
            if not re.match(r'^[A-Za-z][A-Za-z0-9_-]*$', pid):
                raise Erro(f'animação {i + 1}: id "{pid}" só pode ter letras, números, - e _ (e começar com letra)')
            t_in = P.tempo(p, 'em') if 'em' in p else None
            if t_in is None:
                sub = (p.get('itens') or p.get('passos') or p.get('notas') or p.get('fotos') or [{}])[0]
                t_in = P.tempo(sub, 'em', depois=float(p.get('depois_de', 0))) if 'em' in sub else 0
            t_out = P.tempo(p, 'sai', depois=t_in) if 'sai' in p else t_in + 8
            if not M.dentro(t_in - 0.5, t_out):
                continue
            camada = 'atras' if tipo == 'atras_da_pessoa' else 'pecas'
            M.ult_atras = None
            with M.faixa(pid, rotulo_peca(p, tipo, pid), TRILHA_PECAS + k, camada):
                PECAS[tipo](M, p, pid)
            k += 1
            M.n_pecas += 1
            if tipo == 'atras_da_pessoa' and p.get('frente') and M.ult_atras:
                fr = p['frente']
                img = fr if isinstance(fr, str) else (fr.get('imagem') or fr.get('arquivo') or '')
                with M.faixa(f'{pid}-frente', f'Frente · {os.path.basename(str(img))}', TRILHA_PECAS + k, 'pecas'):
                    peca_frente(M, p, pid)
                k += 1
        ncenas = cenas_proprias(M)
        if cartao:
            with M.faixa('cartao_final', 'Cartão final', TRILHA_PECAS + 18):
                cartao_final(M, cf)
    nleg = legendas(M)
    destaques_espacados(M)
    nsons = sons(M) if modo != 'corte' else 0
    limpar_pecas_geradas()
    escritos = escrever_faixas(M)
    VD = round(M.VFIM - M.T0, 3)
    tv = {'mx': M.MOL[0], 'my': M.MOL[1], 'mw': M.MOL[2], 'mh': M.MOL[3], 'raio': M.F['raio'], 'W': M.W, 'H': M.H,
          'vx': f'{round(M.ox, 1):g}', 'vy': f'{round(M.oy, 1):g}', 'vw': f'{round(M.dw, 1):g}', 'vh': f'{round(M.dh, 1):g}'}
    tv.update(M.E)
    css = (FONTES_CSS + '\n' + Template(BASE_CSS).substitute(tv) + '\n'
           + '\n'.join(M._CSS.values()))
    gsap = 'assets/gsap.min.js' if os.path.exists(os.path.join(AQUI, 'assets', 'gsap.min.js')) else GSAP_URL
    titulo = escape(P.cfg.get('titulo', 'Meu vídeo'))
    dados_cenas = ''
    if M.cenas_js:
        dados_cenas = ('<script>window.__cenas = ' + json.dumps(M.cenas_js, ensure_ascii=False, separators=(',', ':'))
                       .replace('</', '<\\/') + ';</script>\n')
    fundos = ''
    if M.FUNDOS:                    # moldura com o vídeo desfocado atrás (no lugar do papel)
        fundos = f'\n<div id="fundos" {faixa_estrutura("fundos")}>\n' + '\n'.join(M.FUNDOS) + '\n</div>'
    ultimo = ''
    if M.ultimo:                    # segura_fim: o último quadro parado por baixo do vídeo, que acaba antes
        ultimo = (f'\n  <img id="ultimo" class="clip ultimo" src="{M.ultimo}" alt="" data-start="{round(max(0.0, VD - 2 / FPS), 3)}" '
                  f'data-duration="{round(M.segura + 2 / FPS, 3)}" data-track-index="{TRILHA_ULTIMO}" '
                  f'data-timeline-label="Fim parado (segura_fim {M.segura:g} s)"/>')
    atras = ''
    # 2,5D com o fundo andando: o vídeo e a pessoa recortada ganham uma caixa .plfundo cada (o mesmo movimento nas
    # duas: a pessoa sai da mesma gravação e não pode descolar dela)
    pf_a, pf_z = ('<div class="plfundo">', '</div>') if M.PLANOS else ('', '')
    if M.ATRAS or M.PESSOA_ATRAS:   # texto atrás da pessoa: entre o vídeo e a pessoa recortada, com o mesmo enquadramento
        atras = (f'\n  <div id="atras" {faixa_estrutura("atras")}>\n{juntar(M.ATRAS)}\n  </div>\n'
                 f'  {pf_a}<div id="innerp" class="camera" {faixa_estrutura("innerp")}>\n'
                 + '\n'.join(M.PESSOA_ATRAS) + f'\n  </div>{pf_z}')
    html = f'''<!doctype html>
<html lang="pt-BR"><head>
<meta charset="UTF-8"/><meta name="viewport" content="width={M.W}, height={M.H}"/>
<title>{titulo} ({modo})</title>
<script src="{gsap}"></script>
{dados_cenas}<style>
{css}
</style></head>
<body>
<div id="root" data-composition-id="main" data-start="0" data-duration="{M.DUR}" data-width="{M.W}" data-height="{M.H}" data-fps="{FPS}">
<div id="palco" {faixa_estrutura("palco")}>
<div id="bg" {faixa_estrutura("bg")}></div>{fundos}
<div id="borda" {faixa_estrutura("borda")}></div>
<div id="frame" {faixa_estrutura("frame")}>{pf_a}<div id="inner" class="camera" {faixa_estrutura("inner")}>{ultimo}
  <video id="cam" src="assets/cut.mp4" muted playsinline data-start="0" data-duration="{VD}" data-media-start="{round(M.T0, 3)}" data-track-index="{TRILHA_VIDEO}" data-timeline-label="Vídeo do corte"></video>
</div>{pf_z}{atras}</div>
<div id="pecas" {faixa_estrutura("pecas")}>
{chr(10).join(M.COMPARA_HT)}{chr(10) if M.COMPARA_HT else ''}{juntar(M._HT)}
</div>
</div>
{vozes(M, VD)}
{chr(10).join(M.AUD)}
</div>
<script>
const tl = gsap.timeline({{ paused: true }});
{chr(10).join(M.js(M.R, M.T0, M.DUR))}
window.__timelines["main"] = tl;
</script>
</body></html>
'''
    with open(os.path.join(AQUI, 'index.html'), 'w', encoding='utf-8') as f:
        f.write(html)
    with open(os.path.join(AQUI, 'sons_sugeridos.json'), 'w', encoding='utf-8') as f:
        json.dump(dict(janela=[round(M.T0, 3), round(M.T0 + M.DUR, 3)], sons=sorted(M.sons_sug, key=lambda s: s['em'])),
                  f, ensure_ascii=False, indent=1)
    arquivos = {rel: assinatura(os.path.join(AQUI, rel)) for rel in ['index.html'] + escritos}
    with open(os.path.join(AQUI, '.build.json'), 'w', encoding='utf-8') as f:
        json.dump(dict(modo=modo, janela=[round(M.T0, 3), round(M.T1, 3)], arquivos=arquivos), f, ensure_ascii=False, indent=1)
    blocos = ', '.join(f'{b["cena"]} {max(b["de"], M.T0):.2f}-{min(b["ate"], M.VFIM):.2f}' for b in M.blocos if M.dentro(b['de'], b['ate']))
    print(f'ok: index.html ({modo}) · {M.DUR:.2f} s · {M.W}x{M.H}')
    print(f'  blocos: {blocos}')
    faixas = [F.rotulo for F in M.faixas if F.host]
    if modo == 'corte':
        print(f'  legendas: {nleg} (animações, cenas e sons ficam de fora no corte)')
    else:
        print(f'  legendas: {nleg} · animações: {M.n_pecas} · cenas próprias: {ncenas} · sons: {nsons} '
              f'(sugeridos: {len(M.sons_sug)} em sons_sugeridos.json)')
    if faixas:
        print(f'  faixas das peças: {" | ".join(faixas)}')
    if cx_corte:
        print(f'  borrão queimado em assets/cut.mp4: {len(cx_corte)} caixa(s) (o original sem borrão fica em {P.video_src})')
    if M.segura:
        print(f'  fim parado: {M.segura:g} s com o último quadro (segura_fim)')
    for a in dict.fromkeys(NOTAS):
        print(f'  nota: {a}')
    for a in dict.fromkeys(AVISOS):
        print(f'  aviso: {a}')
    print('  próximo: python3 build.py hf lint  ·  python3 build.py hf check  ·  python3 build.py estudio  ·  render só no fim')


def destaques_espacados(M):
    """som em nível de destaque no máximo um a cada DESTAQUE_A_CADA s (se tudo é destaque, nada é): o que vem logo
    depois de outro destaque desce para médio no sons_sugeridos.json"""
    ult = None
    for sg in sorted(M.sons_sug, key=lambda x: x['em']):
        if sg['db'] < SOM_CHAVE - 1.5:
            continue
        if ult is not None and sg['em'] - ult < DESTAQUE_A_CADA:
            sg['db'] = SOM_MEDIO
            sg['nota'] = f'{sg.get("peca") or sg["tipo"]} (médio: já tem destaque em {ult:.2f} s)'
        else:
            ult = sg['em']


def copiar_video(src, dst):
    if not os.path.exists(src):
        raise Erro(f'não achei o vídeo cortado: {src}')
    os.makedirs(os.path.dirname(dst), exist_ok=True)
    if os.path.exists(dst) or os.path.islink(dst):
        a, b = os.stat(src), os.stat(dst)
        if a.st_size == b.st_size and int(a.st_mtime) <= int(b.st_mtime):
            return
        os.remove(dst)
    try:
        os.link(src, dst)                 # mesmo disco: não ocupa espaço
    except OSError:
        shutil.copy2(src, dst)


# ---------------------------------------------------------------------------------------- mídia gerada pelo build
def assinatura_arquivo(p):
    st = os.stat(p)
    return f'{st.st_size}-{int(st.st_mtime)}'


def ffprobe_valor(p, entradas, stream='v:0'):
    r = subprocess.run(['ffprobe', '-v', 'error', '-select_streams', stream, '-show_entries', entradas, '-of', 'csv=p=0', p],
                       capture_output=True, text=True)
    return r.stdout.strip().split('\n')[0].split(',') if r.returncode == 0 else []


def tamanho_midia(p):
    v = ffprobe_valor(p, 'stream=width,height')
    try:
        return int(v[0]), int(v[1])
    except (ValueError, IndexError):
        return None, None


IMG_REDUZ = ('.png', '.jpg', '.jpeg', '.webp')


def imagem_no_tamanho(rel, w, h, encaixe='contain'):
    """a imagem de uma peça já no tamanho em que aparece (cópia em assets/gerado; a original não muda). Imagem maior
    que a caixa, reduzida pelo navegador, sai com a borda de um jeito que depende do que a página já desenhou: dois
    renders do mesmo projeto com outro número de workers saíam com o logo na mão diferente. Reduzida aqui (área,
    com a transparência pré-multiplicada, sem franja escura), o navegador desenha 1 para 1. encaixe: "contain"
    (inteira na caixa) ou "cover" (cobre a caixa; o object-position continua valendo). Sem ffmpeg, arquivo que
    não existe, svg ou gif, ou imagem que já cabe: devolve a original."""
    src = os.path.join(AQUI, rel)
    ext = os.path.splitext(rel)[1].lower()
    if ext not in IMG_REDUZ or not os.path.isfile(src) or not shutil.which('ffmpeg') or w < 1 or h < 1:
        return rel
    iw, ih = tamanho_midia(src)
    if not iw or not ih:
        return rel
    k = (max if encaixe == 'cover' else min)(w / iw, h / ih)
    if k >= 0.999:
        return rel
    # o lado que manda fica exato na caixa; o outro, no cover, arredonda para cima (nunca falta pixel)
    tw, th = iw * k, ih * k
    tw, th = (math.ceil(tw - 1e-6), math.ceil(th - 1e-6)) if encaixe == 'cover' else (max(1, round(tw)), max(1, round(th)))
    base = os.path.splitext(os.path.basename(rel))[0]
    jpg = ext in ('.jpg', '.jpeg')
    dst_rel = f'{PASTA_GERADO}/{base}_{tw}x{th}{".jpg" if jpg else ".png"}'.replace(os.sep, '/')
    dst = os.path.join(AQUI, dst_rel)
    if os.path.exists(dst) and os.path.getmtime(dst) >= os.path.getmtime(src):
        return dst_rel
    os.makedirs(os.path.dirname(dst), exist_ok=True)
    filtro = (f'scale={tw}:{th}:flags=area' if jpg else
              f'format=gbrap,premultiply=inplace=1,scale={tw}:{th}:flags=area,unpremultiply=inplace=1,format=rgba')
    cmd = ['ffmpeg', '-v', 'error', '-y', '-i', src, '-vf', filtro, '-frames:v', '1', '-update', '1']
    rodar_ffmpeg(cmd + (['-q:v', '2'] if jpg else []) + [dst], f'reduzir {rel} para {tw}x{th}')
    return dst_rel


def duracao_midia(p):
    r = subprocess.run(['ffprobe', '-v', 'error', '-show_entries', 'format=duration', '-of', 'csv=p=0', p],
                       capture_output=True, text=True)
    try:
        return float(r.stdout.strip())
    except ValueError:
        return None


def contar_quadros(p):
    v = ffprobe_valor(p, 'stream=nb_frames')
    try:
        return int(v[0])
    except (ValueError, IndexError):
        return None


def rodar_ffmpeg(cmd, oque):
    r = subprocess.run(cmd, capture_output=True, text=True)
    if r.returncode != 0:
        raise Erro(f'o ffmpeg falhou ao {oque}: ' + (r.stderr or '').strip()[-500:])


def caixas_borrar(P, blocos):
    """o "borrar" de cada bloco: [x, y, largura, altura] (o bloco todo) ou {"caixa": [...], "de": ..., "ate": ...}
    (segundos do corte ou a palavra). Fração do vídeo gravado (0 a 1); na prova com "arquivo", do arquivo.
    Devolve (caixas do corte, {bloco: caixas do arquivo da prova})"""
    corte, midias = [], {}
    for i, b in enumerate(blocos):
        lista = b.get('borrar') or []
        if not lista:
            continue
        de_b, ate_b = b['de'], min(b['ate'], P.VDUR)
        for k, c in enumerate(lista):
            nome = f'borrar {k + 1} do bloco {b["cena"]} em {de_b:.2f} s'
            if isinstance(c, dict):
                caixa = c.get('caixa')
                a = P.tempo(c, 'de', depois=max(0.0, de_b - 0.5))
                z = P.tempo(c, 'ate', depois=(a if a is not None else de_b) + 0.05)
            else:
                caixa, a, z = c, None, None
            if not isinstance(caixa, (list, tuple)) or len(caixa) != 4 or not all(0 <= float(v) <= 1 for v in caixa):
                raise Erro(f'{nome}: a caixa é [x, y, largura, altura] em fração do vídeo, de 0 a 1')
            a = de_b if a is None else a
            z = ate_b if z is None else z
            if z <= a:
                raise Erro(f'{nome}: "ate" ({z:.2f}) tem que vir depois do "de" ({a:.2f})')
            item = ([round(float(v), 4) for v in caixa], round(a, 3), round(z, 3))
            if b['cena'] == 'prova' and b.get('arquivo'):
                midias.setdefault(i, []).append(item + (isinstance(c, dict) and ('de' in c or 'ate' in c),))
            else:
                corte.append(item)
    return corte, midias


def filtro_borrao(caixas, vw, vh, fps=30.0):
    """cada caixa: recorta, desmancha (reduz, amplia e desfoca: nenhum texto sobra) e cola de volta no tempo dela,
    com um quadro de folga antes e depois. Sem boxblur: ele recusa raio maior que a caixa pequena."""
    n = len(caixas)
    partes = [f'[0:v]split={n + 1}[v0]' + ''.join(f'[c{k}]' for k in range(n))]
    for k, (cx, a, z) in enumerate(caixas):
        x = int(clamp(round(cx[0] * vw / 2) * 2, 0, vw - 4))
        y = int(clamp(round(cx[1] * vh / 2) * 2, 0, vh - 4))
        w = int(clamp(round(cx[2] * vw / 2) * 2, 4, vw - x))
        h = int(clamp(round(cx[3] * vh / 2) * 2, 4, vh - y))
        sw, sh = max(2, w // 20), max(2, h // 20)
        sig = max(3.0, min(w, h) / 10)
        partes.append(f'[c{k}]crop={w}:{h}:{x}:{y},scale={sw}:{sh}:flags=area,scale={w}:{h}:flags=bilinear,'
                      f'gblur=sigma={sig:.1f}[z{k}]')
        en = '' if a is None else f":enable='between(t,{max(0.0, a - 1 / fps):.3f},{z + 1 / fps:.3f})'"
        partes.append(f'[v{k}][z{k}]overlay={x}:{y}{en}[v{k + 1}]')
    return ';'.join(partes), f'[v{n}]'


def preparar_video(P, dst, caixas):
    """assets/cut.mp4: ligação (ou cópia) do corte; com "borrar", uma cópia com o borrão queimado no arquivo"""
    marca = os.path.join(AQUI, PASTA_GERADO, 'borrao.json')
    if not caixas:
        if os.path.exists(marca):            # o borrar saiu do projeto: volta a ligação com o original
            if os.path.exists(dst):
                os.remove(dst)
            os.remove(marca)
        copiar_video(P.video_src, dst)
        return
    if not os.path.exists(P.video_src):
        raise Erro(f'não achei o vídeo cortado: {P.video_src}')
    feito = dict(src=assinatura_arquivo(P.video_src), caixas=json.loads(json.dumps(caixas)))
    try:
        with open(marca, encoding='utf-8') as f:
            ant = json.load(f)
        if os.path.exists(dst) and ant.get('src') == feito['src'] and ant.get('caixas') == feito['caixas'] \
                and ant.get('dst') == assinatura_arquivo(dst):
            return
    except (OSError, ValueError):
        pass
    os.makedirs(os.path.dirname(marca), exist_ok=True)
    print(f'  queimando o borrão no corte ({len(caixas)} caixa(s))...', flush=True)
    filtro, saida = filtro_borrao(caixas, P.vw, P.vh, P.fps)
    tmp = dst + '.borrando.mp4'
    rodar_ffmpeg(['ffmpeg', '-v', 'error', '-y', '-i', P.video_src, '-filter_complex', filtro, '-map', saida, '-map', '0:a?',
                  '-c:v', 'libx264', '-crf', '16', '-preset', 'medium', '-pix_fmt', 'yuv420p', '-c:a', 'copy',
                  '-movflags', '+faststart', tmp], 'queimar o borrão no corte')
    q0, q1 = contar_quadros(P.video_src), contar_quadros(tmp)
    if q0 and q1 and q0 != q1:
        aviso(f'o corte borrado tem {q1} quadros e o original {q0}: confira a sincronia da voz')
    os.replace(tmp, dst)                     # troca a entrada da pasta: o original (ligado) continua intacto
    feito['dst'] = assinatura_arquivo(dst)
    with open(marca, 'w', encoding='utf-8') as f:
        json.dump(feito, f, ensure_ascii=False, indent=1)


def borrar_midia(b, caixas):
    """prova com "arquivo" e "borrar": uma cópia do arquivo com o borrão queimado (a página usa a cópia)"""
    src = os.path.join(AQUI, b['arquivo'])
    if not os.path.exists(src):
        raise Erro(f'não achei {b["arquivo"]} (copie para assets/)')
    video = b['arquivo'].lower().endswith(('.mp4', '.mov', '.webm', '.m4v'))
    base, ext = os.path.splitext(os.path.basename(b['arquivo']))
    rel = os.path.join(PASTA_GERADO, f'{base}_borrado{".mp4" if video else ext}').replace(os.sep, '/')
    dst = os.path.join(AQUI, rel)
    # tempo do corte -> tempo do arquivo (o bloco começa no "inicio" do arquivo)
    ini = float(b.get('inicio', 0))
    cx = [(c, (a - b['de'] + ini) if video else None, (z - b['de'] + ini) if video else None) for c, a, z, _e in caixas]
    if not video and any(e for *_x, e in caixas):
        aviso(f'{b["arquivo"]}: numa imagem o borrão vale o bloco todo ("de" e "ate" da caixa não mudam nada)')
    feito = dict(src=assinatura_arquivo(src), caixas=[[list(c), a, z] for c, a, z in cx])
    marca = dst + '.json'
    try:
        with open(marca, encoding='utf-8') as f:
            if os.path.exists(dst) and json.load(f) == feito:
                return rel
    except (OSError, ValueError):
        pass
    vw, vh = tamanho_midia(src)
    if not vw:
        raise Erro(f'não consegui ler o tamanho de {b["arquivo"]}')
    os.makedirs(os.path.dirname(dst), exist_ok=True)
    filtro, saida = filtro_borrao(cx, vw, vh)
    tmp = dst + ('.tmp.mp4' if video else '.tmp' + ext)
    if video:
        cmd = ['ffmpeg', '-v', 'error', '-y', '-i', src, '-filter_complex', filtro, '-map', saida, '-an', '-c:v', 'libx264',
               '-crf', '16', '-preset', 'medium', '-pix_fmt', 'yuv420p', '-movflags', '+faststart', tmp]
    else:
        cmd = ['ffmpeg', '-v', 'error', '-y', '-i', src, '-filter_complex', filtro, '-map', saida, '-frames:v', '1',
               '-update', '1', tmp]
    rodar_ffmpeg(cmd, f'borrar {b["arquivo"]}')
    os.replace(tmp, dst)
    with open(marca, 'w', encoding='utf-8') as f:
        json.dump(feito, f, ensure_ascii=False)
    aviso(f'{b["arquivo"]}: a página usa {rel} (com o borrão). O original continua legível em assets/: guarde-o fora '
          f'da pasta do projeto antes de mandar a pasta para alguém.')
    return rel


def gerar_ultimo_quadro():
    """o último quadro do assets/cut.mp4 (já com o borrão, se tiver) para o fim parado"""
    src = os.path.join(AQUI, 'assets', 'cut.mp4')
    rel = f'{PASTA_GERADO}/ultimo_quadro.jpg'.replace(os.sep, '/')
    dst = os.path.join(AQUI, rel)
    marca = dst + '.json'
    sig = assinatura_arquivo(src)
    try:
        with open(marca, encoding='utf-8') as f:
            if os.path.exists(dst) and json.load(f).get('src') == sig:
                return rel
    except (OSError, ValueError):
        pass
    os.makedirs(os.path.dirname(dst), exist_ok=True)
    tmp = dst + '.tmp.jpg'
    rodar_ffmpeg(['ffmpeg', '-v', 'error', '-y', '-sseof', '-0.6', '-i', src, '-update', '1', '-q:v', '2', tmp],
                 'tirar o último quadro do corte (segura_fim)')
    if not os.path.exists(tmp):
        raise Erro('não consegui tirar o último quadro do corte (segura_fim)')
    os.replace(tmp, dst)
    with open(marca, 'w', encoding='utf-8') as f:
        json.dump(dict(src=sig), f)
    return rel


def gerar_ultimo_desfocado(src_rel):
    """o último quadro do corte desfocado (fundo da moldura ou da contida no fim parado)"""
    src = os.path.join(AQUI, src_rel)
    rel = f'{PASTA_GERADO}/ultimo_fundo.jpg'.replace(os.sep, '/')
    dst = os.path.join(AQUI, rel)
    marca = dst + '.json'
    sig = assinatura_arquivo(src)
    try:
        with open(marca, encoding='utf-8') as f:
            if os.path.exists(dst) and json.load(f).get('src') == sig:
                return rel
    except (OSError, ValueError):
        pass
    tmp = dst + '.tmp.jpg'
    rodar_ffmpeg(['ffmpeg', '-v', 'error', '-y', '-sseof', '-0.6', '-i', src, '-update', '1', '-q:v', '3', tmp],
                 'tirar o último quadro do fundo desfocado (segura_fim)')
    os.replace(tmp, dst)
    with open(marca, 'w', encoding='utf-8') as f:
        json.dump(dict(src=sig), f)
    return rel


def gerar_fundo_desfocado():
    """o corte pequeno e desfocado (sem som) para o fundo da moldura: outro arquivo, então o lint não acusa mídia
    repetida"""
    src = os.path.join(AQUI, 'assets', 'cut.mp4')
    rel = f'{PASTA_GERADO}/cut_fundo.mp4'.replace(os.sep, '/')
    dst = os.path.join(AQUI, rel)
    marca = dst + '.json'
    sig = assinatura_arquivo(src)
    try:
        with open(marca, encoding='utf-8') as f:
            if os.path.exists(dst) and json.load(f).get('src') == sig:
                return rel
    except (OSError, ValueError):
        pass
    os.makedirs(os.path.dirname(dst), exist_ok=True)
    print('  desfocando o corte para o fundo da moldura...', flush=True)
    tmp = dst + '.tmp.mp4'
    rodar_ffmpeg(['ffmpeg', '-v', 'error', '-y', '-i', src, '-an', '-vf',
                  'scale=trunc(iw/8)*2:trunc(ih/8)*2:flags=area,gblur=sigma=7', '-c:v', 'libx264', '-crf', '30',
                  '-preset', 'veryfast', '-pix_fmt', 'yuv420p', '-movflags', '+faststart', tmp],
                 'desfocar o corte para o fundo da moldura')
    os.replace(tmp, dst)
    with open(marca, 'w', encoding='utf-8') as f:
        json.dump(dict(src=sig), f)
    return rel


def palavras(P, de, ate):
    cortes = [c for c in P.cortes if de <= c <= ate]
    linhas, ci = [], 0
    for w in P.words:
        if w['start'] < de or w['start'] > ate:
            continue
        while ci < len(cortes) and cortes[ci] <= w['start']:
            linhas.append(f'  ------- corte em {cortes[ci]:.3f}')
            ci += 1
        linhas.append(f'  {w["start"]:7.2f} {w["end"]:7.2f}  {w["text"]}')
    fonte = ('começo medido na onda (palavras_onda.json)' if P.tempo_fala == 'onda' else
             'tempo do Whisper, que adianta até 0,3 s; para a hora certa rode o helpers/palavras_na_onda.py')
    print(f'palavras de {de:.1f} a {ate:.1f} s (corte com {P.VDUR:.2f} s; {fonte}):')
    print('\n'.join(linhas) or '  (nenhuma)')


def preparar():
    dst = os.path.join(AQUI, 'assets', 'gsap.min.js')
    if os.path.exists(dst):
        print('ok: assets/gsap.min.js já existe')
        return
    os.makedirs(os.path.dirname(dst), exist_ok=True)
    urllib.request.urlretrieve(GSAP_URL, dst)
    print('ok: GSAP baixado em assets/gsap.min.js')


# ---------------------------------------------------------------------------------------- estúdio do HyperFrames
def achar_npx():
    """o npx do Node que o instalador da skill põe em ~/.editor-video/node; senão o do sistema"""
    base = os.path.join(os.path.expanduser('~'), '.editor-video')
    nomes = ('npx.cmd', 'npx.exe', 'npx') if os.name == 'nt' else ('npx',)
    for pasta in (os.path.join(base, 'node', 'bin'), os.path.join(base, 'node'), os.path.join(base, 'bin')):
        for nome in nomes:
            c = os.path.join(pasta, nome)
            if os.path.isfile(c):
                return c
    return shutil.which('npx')


def versao_hf():
    try:
        with open(os.path.join(AQUI, 'package.json'), encoding='utf-8') as f:
            m = re.search(r'hyperframes@(\d+\.\d+\.\d+)', f.read())
        return m.group(1) if m else HF_VERSAO
    except OSError:
        return HF_VERSAO


def ambiente_node(npx):
    env = dict(os.environ)
    env['PATH'] = os.path.dirname(npx) + os.pathsep + env.get('PATH', '')   # o npx chama o node da mesma pasta
    env.setdefault('NO_COLOR', '1')
    return env


def hf(npx, args, timeout=600):
    cmd = [npx, '--yes', f'hyperframes@{versao_hf()}'] + list(args)
    try:
        return subprocess.run(cmd, cwd=AQUI, env=ambiente_node(npx), capture_output=True, text=True, encoding='utf-8',
                              errors='replace', timeout=timeout)
    except subprocess.TimeoutExpired:
        raise Erro(f'o HyperFrames não respondeu em {timeout} s ({" ".join(args)})')


def json_da_saida(texto):
    for linha in reversed((texto or '').splitlines()):
        linha = linha.strip()
        if linha.startswith('{'):
            try:
                return json.loads(linha)
            except ValueError:
                continue
    return None


def porta_livre(p):
    for porta in range(p, p + 200):
        s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
        if os.name != 'nt':
            # como o servidor do Node: conexão velha fechando (TIME_WAIT, aba do navegador) não prende a porta.
            # No Windows essa opção deixaria usar uma porta ocupada de verdade, então lá fica sem.
            s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
        try:
            s.bind(('127.0.0.1', porta))
            return porta
        except OSError:
            continue
        finally:
            s.close()
    raise Erro(f'nenhuma porta livre entre {p} e {p + 199}')


def responde(url, espera=60.0):
    fim = time.time() + espera
    while True:
        try:
            with urllib.request.urlopen(url, timeout=5) as r:
                if r.status == 200:
                    return True
        except (urllib.error.URLError, OSError, ValueError):
            pass
        if time.time() > fim:
            return False
        time.sleep(0.5)


def conferir_node(npx):
    pasta = os.path.dirname(npx)
    node = None
    for nome in (('node.exe', 'node') if os.name == 'nt' else ('node',)):
        if os.path.isfile(os.path.join(pasta, nome)):
            node = os.path.join(pasta, nome)
    node = node or shutil.which('node')
    if not node:
        return
    try:
        v = subprocess.run([node, '--version'], capture_output=True, text=True, timeout=20).stdout.strip()
    except (OSError, subprocess.SubprocessError):
        return
    m = re.match(r'v(\d+)', v)
    if m and int(m.group(1)) < 22:
        raise Erro(f'o HyperFrames pede o Node 22 ou mais novo e achei o {v}. Rode o instalador da skill de novo '
                   f'(ele põe o Node em ~/.editor-video/node).')


PORTA_INICIAL = 3002
ESTADO_ESTUDIO = '.estudio.json'


def mesma_pasta(a, b):
    na, nb = os.path.normcase(os.path.realpath(a or '')), os.path.normcase(os.path.realpath(b or ''))
    if sys.platform == 'darwin':
        na, nb = na.lower(), nb.lower()
    return na == nb


def porta_aberta(porta, espera=0.4):
    try:
        with socket.create_connection(('127.0.0.1', int(porta)), timeout=espera):
            return True
    except (OSError, ValueError):
        return False


def config_estudio(porta, espera=8.0):
    """o que o próprio estúdio diz dele (pasta do projeto, versão, processo), com calma: o HyperFrames desiste em
    0,3 s, e com a máquina carregada ele acha que não tem estúdio nenhum"""
    try:
        with urllib.request.urlopen(f'http://127.0.0.1:{int(porta)}/__hyperframes_config', timeout=espera) as r:
            d = json.loads(r.read().decode('utf-8', 'replace'))
        return d if isinstance(d, dict) and d.get('isHyperframes') else None
    except (urllib.error.URLError, OSError, ValueError):
        return None


def ler_estado():
    try:
        with open(os.path.join(AQUI, ESTADO_ESTUDIO), encoding='utf-8') as f:
            return json.load(f)
    except (OSError, ValueError):
        return {}


def gravar_estado(d):
    with open(os.path.join(AQUI, ESTADO_ESTUDIO), 'w', encoding='utf-8') as f:
        json.dump(d, f, ensure_ascii=False, indent=1)


def apagar_estado():
    try:
        os.remove(os.path.join(AQUI, ESTADO_ESTUDIO))
    except OSError:
        pass


def estudios_deste_projeto(extra=(), espera=8.0):
    """[(porta, config)] dos estúdios no ar que servem ESTA pasta (os de outros projetos ficam de fora)"""
    portas = [int(p) for p in extra if p] + list(range(PORTA_INICIAL, PORTA_INICIAL + 100))
    achados = []
    for p in dict.fromkeys(portas):
        if not porta_aberta(p):
            continue
        cfg = config_estudio(p, espera)
        if cfg and mesma_pasta(cfg.get('projectDir'), AQUI):
            achados.append((p, cfg))
    return achados


def url_estudio(porta, nome):
    return f'http://127.0.0.1:{porta}/#project/{urllib.parse.quote(nome)}'


def ainda_deste(porta):
    """a porta continua com um estúdio desta pasta? (porta fechada, ou de outro projeto, é não)"""
    if not porta_aberta(porta):
        return False
    cfg = config_estudio(porta, 5.0)
    if cfg is None:
        return porta_aberta(porta)          # abriu mas não respondeu: ainda não dá para dizer que parou
    return mesma_pasta(cfg.get('projectDir'), AQUI)


def parar_estudio(npx, nome, silencioso=False):
    est = ler_estado()
    vivos = estudios_deste_projeto([est.get('porta')])
    if not vivos:
        hf(npx, ['preview', '--stop', '--json'], timeout=120)     # o HyperFrames limpa o registro dele
        apagar_estado()
        if not silencioso:
            print(f'ok: nenhum estúdio de "{nome}" estava no ar (conferido nas portas {PORTA_INICIAL} a '
                  f'{PORTA_INICIAL + 99}{", " + str(est["porta"]) if est.get("porta") else ""})')
        return
    portas = ', '.join(str(p) for p, _c in vivos)
    for tentativa in range(3):
        for porta, _cfg in vivos:
            hf(npx, ['preview', '--stop', '--json', '--port', str(porta)], timeout=120)
        fim = time.time() + 3 + 3 * tentativa
        while time.time() < fim and any(ainda_deste(p) for p, _c in vivos):
            time.sleep(0.5)
        vivos = [(p, c) for p, c in vivos if ainda_deste(p)]
        if not vivos:
            break
    if vivos:
        # o HyperFrames não conseguiu (acontece com a máquina carregada). O próprio servidor disse que é desta
        # pasta e qual é o processo dele: só esse processo é encerrado.
        for porta, cfg in vivos:
            pid = int(cfg.get('pid') or 0)
            if pid > 0 and pid != os.getpid():
                try:
                    os.kill(pid, signal.SIGTERM)
                except OSError:
                    pass
        fim = time.time() + 10
        while time.time() < fim and any(ainda_deste(p) for p, _c in vivos):
            time.sleep(0.5)
        vivos = [(p, c) for p, c in vivos if ainda_deste(p)]
    if vivos:
        p, c = vivos[0]
        raise Erro(f'o estúdio deste projeto continua no ar na porta {p} (processo {c.get("pid")}). Feche com: '
                   f'kill {c.get("pid")}')
    apagar_estado()
    if not silencioso:
        print(f'ok: estúdio de "{nome}" parado (conferido: a porta {portas} fechou)')


def estudio(porta=None, parar=False, abrir=True):
    npx = achar_npx()
    if not npx:
        raise Erro('não achei o Node (npx). O instalador da skill põe o Node em ~/.editor-video/node: rode o instalador '
                   'de novo (ou instale o Node 22 ou mais novo).')
    nome = os.path.basename(AQUI.rstrip('/\\'))
    if parar:
        parar_estudio(npx, nome)
        return
    if not os.path.exists(os.path.join(AQUI, 'index.html')):
        raise Erro('ainda não tem index.html: rode python3 build.py corte (ou amostra/completo) antes do estúdio')
    conferir_node(npx)
    versao = versao_hf()
    vivos = estudios_deste_projeto([ler_estado().get('porta'), porta])
    for p, cfg in vivos:
        if (porta is None or p == porta) and cfg.get('version') in (None, versao):
            url = url_estudio(p, cfg.get('projectName') or nome)
            gravar_estado(dict(porta=p, url=url, versao=versao, pid=cfg.get('pid')))
            print(f'ok: o estúdio já estava no ar · {url}')
            return
    if vivos:                # em outra porta ou em outra versão do HyperFrames: derruba e sobe de novo
        p, cfg = vivos[0]
        if cfg.get('version') not in (None, versao):
            print(f'  o estúdio no ar é o HyperFrames {cfg.get("version")}; subindo de novo no {versao} (o do package.json)')
        parar_estudio(npx, nome, silencioso=True)
    if porta is not None and porta_livre(porta) != porta:
        raise Erro(f'a porta {porta} está ocupada. Use outra: python3 build.py estudio --porta {porta_livre(porta + 1)}')
    if porta is None:
        porta = porta_livre(PORTA_INICIAL)
    args = ['preview', '--background', '--port', str(porta), '--json'] + ([] if abrir else ['--no-open'])
    r = hf(npx, args, timeout=600)
    d = (json_da_saida(r.stdout) or {}).get('result') or {}
    if r.returncode != 0 or not d:
        raise Erro('o estúdio não subiu: ' + (r.stderr or r.stdout).strip()[-600:])
    porta = int(d.get('port', porta))
    servidor = d.get('serverUrl') or f'http://127.0.0.1:{porta}'
    url = d.get('studioUrl') or url_estudio(porta, nome)
    if not responde(servidor, 120):
        raise Erro(f'o estúdio subiu mas não respondeu em {servidor} (máquina carregada?). Veja o log: {d.get("logPath", "")}')
    gravar_estado(dict(porta=porta, url=url, versao=versao, pid=d.get('pid')))
    print(f'ok: estúdio no ar (HyperFrames {versao}) · {url}')
    print('  a linha do tempo mostra cada camada (vídeo, legenda, peças, cenas, voz e sons). O estúdio recarrega sozinho')
    print('  a cada build. O que for mexido no estúdio some no próximo build: passe pro projeto.json.')
    print('  parar: python3 build.py estudio --parar')


CHAVE_DE_IA = re.compile(r'(GEMINI|GOOGLE|GENAI|VERTEX|OPENAI|ANTHROPIC|CLAUDE|HEYGEN|HYPERFRAMES|AZURE|GROQ|MISTRAL|'
                         r'OPENROUTER|REPLICATE|FAL|XAI|DEEPSEEK).*(KEY|TOKEN|SECRET|CREDENTIAL)', re.I)


def sem_chaves_de_ia(env):
    """tira do ambiente as chaves de serviço de IA (e parecidas); devolve os nomes tirados"""
    fora = sorted(k for k in env if CHAVE_DE_IA.search(k) or k.upper() in ('GOOGLE_APPLICATION_CREDENTIALS',))
    for k in fora:
        env.pop(k, None)
    return fora


def cmd_hf(args):
    """python3 build.py hf lint|check|render|snapshot ...: o HyperFrames na versão do package.json"""
    if not args:
        raise Erro('use: python3 build.py hf lint  (ou check, render --quality draft --output renders/amostra.mp4, '
                   'snapshot ...)')
    if args[0] in ('preview', 'studio') and not any(x in args for x in ('--context', '--status', '--list')):
        # só leitura passa (o --context do estúdio no ar); subir e parar é com o build.py estudio
        raise Erro('para o estúdio use python3 build.py estudio (sobe, diz a URL e para só o deste projeto)')
    if '--kill-all' in args:
        raise Erro('--kill-all derruba os estúdios de outros projetos. Use python3 build.py estudio --parar')
    npx = achar_npx()
    if not npx:
        raise Erro('não achei o Node (npx). O instalador da skill põe o Node em ~/.editor-video/node: rode o instalador '
                   'de novo (ou instale o Node 22 ou mais novo).')
    if args[0] in ('lint', 'check', 'render', 'snapshot') and not os.path.exists(os.path.join(AQUI, 'index.html')):
        raise Erro('ainda não tem index.html: rode python3 build.py corte, amostra ou completo antes')
    conferir_node(npx)
    v = versao_hf()
    env = ambiente_node(npx)
    args = list(args)
    if args[0] == 'snapshot' or (args[0] == 'check' and '--snapshots' in args):
        # privacidade: com uma chave de IA no ambiente (GEMINI_API_KEY e parecidas), o snapshot manda os quadros
        # para o serviço de fora. Aqui roda sem essas chaves (também no check --snapshots, que tira quadros), a
        # não ser que a pessoa peça (--com-ia).
        if '--com-ia' in args:
            args.remove('--com-ia')
            print('  snapshot com as chaves de IA do ambiente (--com-ia): os quadros podem ir para o serviço de fora')
        else:
            tiradas = sem_chaves_de_ia(env)
            if tiradas:
                print(f'  snapshot sem as chaves de IA do ambiente ({", ".join(tiradas)}): os quadros ficam no computador. '
                      f'Para mandar para a IA, só com o pedido da pessoa: --com-ia')
    print(f'hyperframes@{v} {" ".join(args)}', flush=True)
    try:
        return subprocess.run([npx, '--yes', f'hyperframes@{v}'] + args, cwd=AQUI, env=env).returncode
    except KeyboardInterrupt:
        return 130


def novo(destino, edit=None, estilo=None):
    """copia o modelo (rodado dentro da skill) para uma pasta nova e anota a skill no projeto.json"""
    skill = skill_do_modelo()
    if not skill:
        raise Erro('o "novo" roda no modelo que está dentro da skill: python3 "<SKILL>/modelo-projeto/build.py" novo '
                   '<PASTA>. Este projeto já é uma cópia: copie a pasta dele se quiser uma versão nova.')
    dst = os.path.abspath(os.path.expanduser(destino))
    if os.path.isdir(dst) and os.listdir(dst):
        raise Erro(f'{dst} já existe e tem arquivos: escolha outra pasta (versão nova nunca substitui a antiga)')
    ignorar = shutil.ignore_patterns('__pycache__', 'index.html', '.build.json', '.cache_sons.json', ESTADO_ESTUDIO,
                                     PASTA_MEXIDOS, 'renders', 'node_modules', 'sons_sugeridos.json', 'gerado')
    shutil.copytree(AQUI, dst, ignore=ignorar, dirs_exist_ok=True)
    gravar_campo_projeto('skill', skill, pasta=dst)
    if estilo:
        if estilo not in ESTILOS:
            raise Erro(f'estilo "{estilo}" não existe: use ' + ', '.join(ESTILOS))
        gravar_campo_projeto('estilo', estilo, pasta=dst)
    if edit:
        e = os.path.abspath(os.path.expanduser(edit))
        try:
            e = os.path.relpath(e, dst)
        except ValueError:                  # outro disco no Windows
            pass
        gravar_campo_projeto('edit', e.replace(os.sep, '/'), pasta=dst)
    print(f'ok: projeto novo em {dst}')
    print(f'  skill anotada no projeto.json: {skill}')
    print(f'  próximo: cd \'{dst}\' && python3 build.py corte && python3 build.py estudio')


# o que o `atualizar` traz do modelo (o resto do projeto fica como está)
DO_MODELO = ('build.py', 'LEIA.md', 'package.json', 'hyperframes.json', 'ferramentas', 'compositions/cenas/LEIA.md',
             'compositions/cenas/2d', 'compositions/cenas/3d', 'assets/fontes', 'assets/three')
PASTA_ANTES = '.antes-de-atualizar'


def arquivos_do_modelo():
    """[caminho relativo] de tudo que o atualizar traz do modelo"""
    out = []
    for rel in DO_MODELO:
        src = os.path.join(AQUI, rel)
        if os.path.isfile(src):
            out.append(rel)
        elif os.path.isdir(src):
            for raiz, pastas, arqs in os.walk(src):
                pastas[:] = sorted(d for d in pastas if d != '__pycache__')
                for a in sorted(arqs):
                    if a.endswith('.pyc') or a == '.DS_Store':
                        continue
                    out.append(os.path.relpath(os.path.join(raiz, a), AQUI).replace(os.sep, '/'))
    return out


def versao_do_build(caminho):
    try:
        with open(caminho, encoding='utf-8') as f:
            m = re.search(r"^MODELO_VERSAO = '([^']+)'", f.read(), re.M)
        return m.group(1) if m else None
    except OSError:
        return None


def atualizar(destino):
    """leva um projeto (cópia antiga do modelo) para o modelo desta skill: troca o build.py, o LEIA, o package.json, as
    ferramentas, os kits de cena e as fontes; guarda o que trocou em .antes-de-atualizar/<data>/; o projeto.json, os
    assets do projeto, as cenas próprias, os renders e o resto ficam como estão"""
    skill = skill_do_modelo()
    if not skill:
        raise Erro('o "atualizar" roda no modelo que está dentro da skill: python3 "<SKILL>/modelo-projeto/build.py" '
                   'atualizar <PASTA_DO_PROJETO>')
    dst = os.path.abspath(os.path.expanduser(destino))
    if mesma_pasta(dst, AQUI):
        raise Erro('essa pasta é o próprio modelo da skill: passe a pasta do projeto')
    if not (os.path.isfile(os.path.join(dst, 'projeto.json')) and os.path.isfile(os.path.join(dst, 'build.py'))):
        raise Erro(f'{dst} não é um projeto do editor (falta projeto.json ou build.py)')
    v_antes = versao_do_build(os.path.join(dst, 'build.py'))
    carimbo = time.strftime('%Y-%m-%d_%H%M%S')
    guardados, novos, trocados, iguais = os.path.join(dst, PASTA_ANTES, carimbo), [], [], 0
    hf_antes = hf_depois = None
    for rel in arquivos_do_modelo():
        src, alvo = os.path.join(AQUI, rel), os.path.join(dst, rel)
        if os.path.isfile(alvo):
            with open(src, 'rb') as a, open(alvo, 'rb') as b:
                if a.read() == b.read():
                    iguais += 1
                    continue
            if rel == 'package.json':
                with open(alvo, encoding='utf-8') as f:
                    m = re.search(r'hyperframes@(\d+\.\d+\.\d+)', f.read())
                hf_antes, hf_depois = (m.group(1) if m else '?'), versao_hf()
            copia = os.path.join(guardados, rel)
            os.makedirs(os.path.dirname(copia), exist_ok=True)
            shutil.copy2(alvo, copia)
            trocados.append(rel)
        else:
            novos.append(rel)
        os.makedirs(os.path.dirname(alvo), exist_ok=True)
        shutil.copy2(src, alvo)
    # o que o projeto tem nas pastas do modelo e o modelo não tem mais: fica
    so_no_projeto = []
    modelo = set(arquivos_do_modelo())
    for rel in DO_MODELO:
        pasta = os.path.join(dst, rel)
        if os.path.isdir(pasta):
            for raiz, pastas, arqs in os.walk(pasta):
                pastas[:] = [d for d in pastas if d != '__pycache__']
                for a in arqs:
                    r = os.path.relpath(os.path.join(raiz, a), dst).replace(os.sep, '/')
                    if r not in modelo and not a.endswith('.pyc') and a != '.DS_Store':
                        so_no_projeto.append(r)
    cfg = ler_json_ou_nada(os.path.join(dst, 'projeto.json')) or {}
    skill_antes = str(cfg.get('skill') or '').strip()
    if not skill_antes or not mesma_pasta(skill_antes, skill):
        gravar_campo_projeto('skill', skill, pasta=dst)
    print(f'ok: projeto atualizado para o modelo {MODELO_VERSAO} ({dst})')
    print(f'  de: {"modelo " + v_antes if v_antes else "modelo antigo (sem versão)"}')
    if trocados:
        print(f'  trocados ({len(trocados)}): {", ".join(trocados)}')
        print(f'  os de antes ficaram em {os.path.join(PASTA_ANTES, carimbo)}/ (volte um deles copiando de lá)')
    if novos:
        print(f'  novos ({len(novos)}): {", ".join(novos)}')
    print(f'  iguais: {iguais}')
    if so_no_projeto:
        print(f'  só no projeto (ficaram): {", ".join(sorted(so_no_projeto))}')
    if not skill_antes or not mesma_pasta(skill_antes, skill):
        print(f'  "skill" do projeto.json: {skill_antes or "vazio"} -> {skill}')
    if hf_antes and hf_antes != hf_depois:
        print(f'  HyperFrames {hf_antes} -> {hf_depois}: o estúdio sobe de novo nessa versão no próximo build.py estudio')
    novidades = [t for v, t in NOVIDADES if not v_antes or v > v_antes]
    if novidades:
        print('  o que mudou no modelo:')
        for t in novidades:
            print(f'    - {t}')
    elif not trocados and not novos:
        print('  nada mudou: o projeto já estava no modelo desta skill')
    print('  ficaram como estavam: projeto.json (menos o "skill"), assets do projeto, cenas próprias, sons, renders')
    print(f'  próximo: cd \'{dst}\' && python3 build.py completo (ou a amostra), hf lint e hf check')


def main(argv):
    cmds = ('palavras', 'corte', 'amostra', 'completo', 'estudio', 'preparar', 'hf', 'novo', 'atualizar')
    if not argv or argv[0] not in cmds:
        print(__doc__)
        return 2
    try:
        if argv[0] == 'preparar':
            preparar()
            return 0
        if argv[0] == 'hf':
            return cmd_hf(argv[1:])
        if argv[0] == 'novo':
            uso = 'use: python3 build.py novo <PASTA> [--edit <PASTA_EDIT>] [--estilo papelaria|noite|laranja|limpo]'
            resto, opcoes = argv[1:], {}
            for flag in ('--edit', '--estilo'):
                if flag in resto:
                    k = resto.index(flag)
                    if k + 1 >= len(resto):
                        raise Erro(uso)
                    opcoes[flag] = resto[k + 1]
                    resto = resto[:k] + resto[k + 2:]
            if len(resto) != 1:
                raise Erro(uso)
            novo(resto[0], opcoes.get('--edit'), opcoes.get('--estilo'))
            return 0
        if argv[0] == 'atualizar':
            if len(argv) != 2:
                raise Erro("use: python3 '<SKILL>/modelo-projeto/build.py' atualizar <PASTA_DO_PROJETO>")
            atualizar(argv[1])
            return 0
        if argv[0] == 'estudio':
            porta, parar, abrir, resto = None, False, True, argv[1:]
            while resto:
                a = resto.pop(0)
                if a == '--parar':
                    parar = True
                elif a == '--sem-abrir':
                    abrir = False
                elif a == '--porta' and resto:
                    try:
                        porta = int(resto.pop(0))
                    except ValueError:
                        raise Erro('use: python3 build.py estudio --porta 3002')
                elif a.startswith('--porta='):
                    try:
                        porta = int(a.split('=', 1)[1])
                    except ValueError:
                        raise Erro('use: python3 build.py estudio --porta 3002')
                else:
                    raise Erro(f'não entendi "{a}". Use: python3 build.py estudio [--porta N] [--sem-abrir] [--parar]')
            estudio(porta, parar, abrir)
            return 0
        P = Projeto()
        if argv[0] == 'palavras':
            de = float(argv[1]) if len(argv) > 1 else 0.0
            ate = float(argv[2]) if len(argv) > 2 else P.VDUR
            palavras(P, de, ate)
        elif argv[0] == 'corte':
            montar(P, 'corte', 0.0, P.VDUR)
        elif argv[0] == 'amostra':
            if len(argv) < 3:
                raise Erro('use: python3 build.py amostra <inicio> <fim>  (em segundos do corte)')
            a, b = float(argv[1].replace(',', '.')), float(argv[2].replace(',', '.'))
            if not 0 <= a < b:
                raise Erro('o início tem que ser menor que o fim')
            if b > P.VDUR + 5:
                raise Erro(f'o corte tem {P.VDUR:.2f} s')
            if a <= 0.5 and b >= P.VDUR - 0.5:
                nota(f'o vídeo inteiro ({P.VDUR:.2f} s) cabe na amostra: esta amostra já é o vídeo todo. Confira e mostre '
                     f'esta mesma; depois do ok, o completo sai igual (sem outra rodada de amostra)')
            montar(P, 'amostra', a, b)
        else:
            montar(P, 'completo', 0.0, P.VDUR)
        return 0
    except Erro as e:
        print(f'erro: {e}', file=sys.stderr)
        return 2


if __name__ == '__main__':
    sys.exit(main(sys.argv[1:]))
