#!/usr/bin/env python3
# Baixa a BIBLIOTECA DE EDIÇÃO grátis no teu computador (Windows ou Mac): fontes, sons, músicas, animações Lottie,
# vídeos de apoio, LUTs de cor, grão, papel e vinheta. Tudo com licença de uso comercial liberado.
#
# Como usar (no Mac troque "python" por "python3"):
#   python baixar_biblioteca.py                  baixa tudo
#   python baixar_biblioteca.py --essenciais     só o que os sentimentos usam (rápido, rode primeiro)
#   python baixar_biblioteca.py --sons --musicas escolhe as partes
#   python baixar_biblioteca.py --testar         só conta o que baixaria, sem baixar nada
#   --destino PASTA (ou a variável SOGRAVA_BIBLIOTECA) troca a pasta da biblioteca.
#
# Pode rodar quantas vezes quiser: o que já existe é pulado. Se um arquivo falhar, o resto continua.
# Usa só o que já vem com o Python. O numpy e o ffmpeg são opcionais: sem eles, papel, vinheta e grão ficam de fora.
import argparse, io, json, os, re, shutil, ssl, struct, subprocess, sys, urllib.parse, urllib.request, zipfile, zlib

RAIZ = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
UA = {"User-Agent": "Mozilla/5.0"}

FONTES = "01 FONTES"
SONS = "02 SONS (whoosh, pop, clique, impacto, subida)"
MUSICAS = "03 MÚSICAS"
OVERLAYS = "04 OVERLAYS (grão, luz, papel, transições)"
LUTS = "05 LUTS (cor)"
LOTTIE = "06 LOTTIE (animações)"
BROLL = "07 B-ROLL (teus vídeos de apoio)"
PASTAS = [FONTES, SONS, MUSICAS, OVERLAYS, LUTS, LOTTIE, BROLL, "08 FOTOS (você recortado, produtos, prints)",
          "09 LOGOS", "10 REFERÊNCIAS (vídeos que você quer copiar)"]
GERADOS = OVERLAYS + "/gerados aqui"
BANCO = BROLL + "/banco grátis (não é teu, usar com moderação)"

# Google Fonts (licença OFL)
FAMILIAS = ["montserrat", "poppins", "bebasneue", "anton", "oswald", "archivoblack", "dmsans", "instrumentserif",
            "playfairdisplay", "spacegrotesk", "manrope", "sora", "outfit", "plusjakartasans", "caveat", "permanentmarker",
            "kalam", "intertight", "barlowcondensed", "syne"]
FONTES_RAW = "https://raw.githubusercontent.com/google/fonts/main"

# Mixkit: categoria do site e nome da pasta
CATEGORIAS_SONS = {"whoosh": "whoosh", "transition": "transicao", "pop": "pop", "click": "clique",
                   "notification": "notificacao", "interface": "interface", "money": "dinheiro e venda",
                   "typing": "digitando", "magic": "brilho e magia", "impact": "impacto", "swoosh": "swoosh",
                   "camera": "camera"}
KENNEY = ["interface-sounds", "ui-audio", "impact-sounds", "digital-audio"]
# link do zip caso a página do Kenney mude (só o pacote que tem arquivo essencial)
KENNEY_RESERVA = {"interface-sounds":
                  "https://kenney.nl/media/pages/assets/interface-sounds/fa43c1dd4d-1677589452/kenney_interface-sounds.zip"}
ESTILOS = {"lo-fi-beats": "lo-fi (fundo de tutorial)", "corporate-music": "corporativo (negócio, produto)",
           "hip-hop": "hip-hop (reels com ritmo)", "deep-house": "deep house (premium, lançamento)",
           "cinematic": "cinematográfico (gancho, história)", "future-bass": "future bass (energia, virada)",
           "chillout": "chill (fala calma)"}
MUSICAS_POR_ESTILO = 10
SFX_RE = r"https://assets\.mixkit\.co/active_storage/sfx/\d+/\d+-preview\.mp3"
MUSICA_RE = r"https://assets\.mixkit\.co/music/\d+/\d+\.mp3"

# LottieFiles (Lottie Simple License)
CATEGORIAS_LOTTIE = {"arrow": "setas", "underline": "sublinhado", "circle": "círculo marcando", "checkmark": "check",
                     "success": "sucesso", "confetti": "confete", "click": "clique e cursor", "hand": "mão apontando",
                     "money": "dinheiro", "notification": "notificação", "sparkle": "brilho", "fire": "fogo",
                     "like": "like", "subscribe": "inscreva-se", "bell": "sino", "rocket": "foguete",
                     "growth": "crescimento e gráfico", "loading": "carregando", "scribble": "rabisco", "emoji": "emoji",
                     "phone": "celular", "shopping": "compra", "ai": "inteligência artificial"}
LOTTIE_POR_CATEGORIA = 12

# Vídeos do Mixkit: overlays e B-roll
GRUPOS_VIDEO = {OVERLAYS: {"light-leak": "luz vazando", "smoke": "fumaça", "particles": "partículas", "bokeh": "bokeh",
                           "dust": "poeira e grão"},
                BANCO: {"typing": "digitando", "smartphone": "celular", "laptop": "notebook", "office": "escritório",
                        "money": "dinheiro", "credit-card": "cartão e pagamento", "social-media": "redes sociais"}}
VIDEOS_POR_CATEGORIA = 6

# ESSENCIAIS: os arquivos que os sentimentos (motor/sentimentos.json) usam. Caminho fixo dentro da biblioteca e
# link direto do Mixkit, conferidos em 19/10/2026. Se a lista do site mudar de ordem, o arquivo cai no mesmo lugar.
ESSENCIAIS = {
    "03 MÚSICAS/cinematográfico (gancho, história)/04 Epical Drums 01.mp3": "https://assets.mixkit.co/music/676/676.mp3",
    "03 MÚSICAS/future bass (energia, virada)/01 Can39t Get You Off My Mind.mp3": "https://assets.mixkit.co/music/1210/1210.mp3",
    "03 MÚSICAS/chill (fala calma)/09 See Line Funk.mp3": "https://assets.mixkit.co/music/105/105.mp3",
    "03 MÚSICAS/corporativo (negócio, produto)/03 Piano Reflections.mp3": "https://assets.mixkit.co/music/22/22.mp3",
    "03 MÚSICAS/cinematográfico (gancho, história)/01 Silent Descent.mp3": "https://assets.mixkit.co/music/614/614.mp3",
    "03 MÚSICAS/deep house (premium, lançamento)/02 Autofahren.mp3": "https://assets.mixkit.co/music/770/770.mp3",
    "03 MÚSICAS/hip-hop (reels com ritmo)/06 Need for speed.mp3": "https://assets.mixkit.co/music/369/369.mp3",
    "03 MÚSICAS/lo-fi (fundo de tutorial)/02 Sleepy Cat.mp3": "https://assets.mixkit.co/music/135/135.mp3",
    SONS + "/mixkit impacto/12 Impact of a blow.mp3": "https://assets.mixkit.co/active_storage/sfx/2150/2150-preview.mp3",
    SONS + "/mixkit impacto/16 Golem stomp c.mp3": "https://assets.mixkit.co/active_storage/sfx/3046/3046-preview.mp3",
    SONS + "/mixkit transicao/36 Warfare horn trailer hit.mp3": "https://assets.mixkit.co/active_storage/sfx/3111/3111-preview.mp3",
    SONS + "/mixkit whoosh/05 Fast whoosh transition.mp3": "https://assets.mixkit.co/active_storage/sfx/1490/1490-preview.mp3",
    SONS + "/mixkit pop/02 Hard pop click.mp3": "https://assets.mixkit.co/active_storage/sfx/2364/2364-preview.mp3",
    SONS + "/mixkit whoosh/02 Cinematic whoosh fast transition.mp3": "https://assets.mixkit.co/active_storage/sfx/1492/1492-preview.mp3",
    SONS + "/mixkit impacto/30 Fast impact blow.mp3": "https://assets.mixkit.co/active_storage/sfx/2655/2655-preview.mp3",
    SONS + "/mixkit clique/22 Arcade game jump coin.mp3": "https://assets.mixkit.co/active_storage/sfx/216/216-preview.mp3",
    SONS + "/mixkit swoosh/06 Short wind swoosh.mp3": "https://assets.mixkit.co/active_storage/sfx/1461/1461-preview.mp3",
    SONS + "/mixkit clique/16 Quick win video game notification.mp3": "https://assets.mixkit.co/active_storage/sfx/269/269-preview.mp3",
    SONS + "/mixkit brilho e magia/03 Fairy magic sparkle.mp3": "https://assets.mixkit.co/active_storage/sfx/871/871-preview.mp3",
    SONS + "/mixkit swoosh/05 Cinematic wind swoosh.mp3": "https://assets.mixkit.co/active_storage/sfx/1471/1471-preview.mp3",
    SONS + "/mixkit brilho e magia/10 Sweeping sparkle presentation intro.mp3": "https://assets.mixkit.co/active_storage/sfx/2633/2633-preview.mp3",
    SONS + "/mixkit clique/19 Hard typewriter click.mp3": "https://assets.mixkit.co/active_storage/sfx/1119/1119-preview.mp3",
    SONS + "/mixkit clique/13 Typewriter soft click.mp3": "https://assets.mixkit.co/active_storage/sfx/1125/1125-preview.mp3",
    SONS + "/mixkit transicao/31 Mighty mysterious swoosh.mp3": "https://assets.mixkit.co/active_storage/sfx/2615/2615-preview.mp3",
    SONS + "/mixkit impacto/07 Deep heartbeat impact.mp3": "https://assets.mixkit.co/active_storage/sfx/498/498-preview.mp3",
    SONS + "/mixkit clique/07 Cool interface click tone.mp3": "https://assets.mixkit.co/active_storage/sfx/2568/2568-preview.mp3",
    SONS + "/mixkit transicao/16 Short transition sweep.mp3": "https://assets.mixkit.co/active_storage/sfx/175/175-preview.mp3",
    SONS + "/mixkit brilho e magia/22 Magic sparkle poof hit.mp3": "https://assets.mixkit.co/active_storage/sfx/3082/3082-preview.mp3",
    SONS + "/mixkit notificacao/13 Dry pop up notification alert.mp3": "https://assets.mixkit.co/active_storage/sfx/2356/2356-preview.mp3",
    SONS + "/mixkit clique/30 Positive interface beep.mp3": "https://assets.mixkit.co/active_storage/sfx/221/221-preview.mp3",
    SONS + "/mixkit transicao/17 Fast transitions swoosh.mp3": "https://assets.mixkit.co/active_storage/sfx/3115/3115-preview.mp3",
    SONS + "/mixkit transicao/35 Technology alert transition.mp3": "https://assets.mixkit.co/active_storage/sfx/3121/3121-preview.mp3",
    SONS + "/mixkit transicao/32 Television news report transition.mp3": "https://assets.mixkit.co/active_storage/sfx/3089/3089-preview.mp3",
    SONS + "/mixkit clique/21 Modern click box check.mp3": "https://assets.mixkit.co/active_storage/sfx/1120/1120-preview.mp3",
    # pop de desenho animado, curtinho (0,4 s), pra texto que salta na tela
    SONS + "/mixkit pop/05 Bubble pop up alert notification.mp3": "https://assets.mixkit.co/active_storage/sfx/2357/2357-preview.mp3",
}
# esses vêm do zip do Kenney (pacote interface-sounds)
ESSENCIAIS_KENNEY = (SONS + "/kenney interface-sounds/click_001.ogg",
                     SONS + "/kenney interface-sounds/click_002.ogg")

LEIA_ME = """BIBLIOTECA DE EDIÇÃO
O Claude usa o que estiver aqui em toda edição. Não precisa organizar perfeito: joga o arquivo na pasta certa.

01 FONTES
  Arquivos .ttf ou .otf. Se gostou da letra de uma referência e não sabe o nome, manda a referência que eu identifico.
  Grátis: fonts.google.com

02 SONS
  Efeitos curtos (whoosh, pop, clique, impacto, subida). Formato .wav ou .mp3.
  Grátis: pixabay.com/sound-effects   Pagos (melhor qualidade): Epidemic Sound, Artlist

03 MÚSICAS
  Trilhas com direito de uso.
  Grátis: YouTube Audio Library (no YouTube Studio)   Pagos: Epidemic Sound, Artlist

04 OVERLAYS
  Grão de filme, luz vazando, papel, transições. De preferência vídeo com fundo transparente (.mov ou .webm) ou fundo preto.
  Pagos: Envato Elements, Motion Array

05 LUTS
  Arquivos .cube de cor (aquele "visual de cinema").

06 LOTTIE
  Animações profissionais em arquivo .json ou .lottie (ícones animados, setas, sublinhados, explosões, check).
  lottiefiles.com (tem grátis e pago)

07 B-ROLL
  Teus vídeos de apoio gravados no celular: mão no teclado, tela do celular, escritório, rua, produto.
  É o que mais tira a cara de IA do vídeo.

08 FOTOS
  Você recortado (PNG sem fundo), prints de venda, produtos, telas.

09 LOGOS
  Logos oficiais em PNG ou SVG.

10 REFERÊNCIAS
  Vídeos que você quer que eu copie (arquivo ou um .txt com o link). Se quiser, escreve no .txt o que te pegou
  ("a legenda", "o ritmo", "do 0:12 ao 0:20").
"""
LICENCA_FONTES = "LICENÇA-OFL (todas estas fontes são livres pra uso comercial).txt"
LICENCA_FONTES_RESERVA = ("Fontes do Google Fonts com a licença SIL Open Font License 1.1: livres pra uso comercial, "
                          "só não pode vender a fonte sozinha (openfontlicense.org).\n")
LICENCA_SONS = ("Mixkit: Mixkit Sound Effects Free License, uso comercial liberado em vídeos (mixkit.co/license/#sfxFree).\n"
                "Kenney: CC0 (domínio público), uso livre (kenney.nl).\n")
LICENCA_MUSICAS = ("Mixkit Stock Music Free License: uso livre em vídeos, inclusive comerciais e no YouTube (mixkit.co/license/#musicFree).\n"
                   "Pra vídeo monetizado no YouTube, a opção mais segura continua sendo a YouTube Audio Library (no YouTube Studio).\n")
LICENCA_LOTTIE = ("Animações grátis do LottieFiles: Lottie Simple License, uso comercial liberado sem crédito obrigatório (lottiefiles.com/page/license).\n"
                  "Cada uma tem o .lottie (original) e o .json (o que o Remotion e a maioria dos editores leem).\n")
LICENCA_VIDEOS = "Vídeos do Mixkit: Mixkit Stock Video Free License, uso comercial liberado (mixkit.co/license/#videoFree).\n"

CONTA = {"novos": 0, "falhas": 0}


def pasta_biblioteca(destino=None):
    """Pasta da BIBLIOTECA DE EDIÇÃO. Vale nesta ordem: o destino pedido, a variável SOGRAVA_BIBLIOTECA, a Área de
    Trabalho (Desktop, OneDrive/Desktop ou OneDrive/Área de Trabalho, a primeira que existir) e, por último,
    ~/so-grava/biblioteca."""
    pedido = destino or os.environ.get("SOGRAVA_BIBLIOTECA")
    if pedido:
        return os.path.abspath(os.path.expanduser(pedido))
    casa = os.path.expanduser("~")
    for mesa in (("Desktop",), ("OneDrive", "Desktop"), ("OneDrive", "Área de Trabalho")):
        if os.path.isdir(os.path.join(casa, *mesa)):
            return os.path.join(casa, *mesa, "BIBLIOTECA DE EDIÇÃO")
    return os.path.join(casa, "so-grava", "biblioteca")


# ---------- internet ----------

def _ssl():
    try:                                      # no Mac, o certifi resolve o erro de certificado quando está instalado
        import certifi
        return ssl.create_default_context(cafile=certifi.where())
    except Exception:
        return ssl.create_default_context()


SSL = _ssl()
_avisou_ssl = []


def _motivo(e):
    texto = str(getattr(e, "reason", "") or e)
    if "CERTIFICATE_VERIFY_FAILED" in texto and not _avisou_ssl:
        _avisou_ssl.append(1)
        print("  dica: erro de certificado. No Mac, abra Aplicativos > Python 3 e rode o Install Certificates.command,"
              " depois rode este script de novo.")
    return texto[:120]


def abre(url, metodo="GET", tempo=120):
    pedido = urllib.request.Request(url, headers=UA, method=metodo)
    return urllib.request.urlopen(pedido, timeout=tempo, context=SSL)


def baixa(url, tempo=120):
    with abre(url, tempo=tempo) as r:
        return r.read()


_paginas = {}


def pagina(url):
    """HTML de uma página (guarda na memória pra não abrir duas vezes). Devolve None se não abrir."""
    if url not in _paginas:
        try:
            _paginas[url] = baixa(url, 60).decode("utf-8", "replace")
        except Exception as e:
            print(f"  não abriu {url}: {_motivo(e)}")
            _paginas[url] = None
    return _paginas[url]


def responde(url):
    try:
        with abre(url, "HEAD", 30) as r:
            return r.status == 200
    except Exception:
        return False


def salva(url, arq, tempo=300):
    """Baixa url em arq. Grava num .parcial e só renomeia no fim, pra nunca sobrar arquivo pela metade.
    Tenta duas vezes. Devolve True se baixou."""
    tmp = arq + ".parcial"
    erro = None
    for _ in range(2):
        try:
            os.makedirs(os.path.dirname(arq), exist_ok=True)
            with abre(url, tempo=tempo) as r, open(tmp, "wb") as f:
                shutil.copyfileobj(r, f, 1 << 16)
            os.replace(tmp, arq)
            CONTA["novos"] += 1
            return True
        except Exception as e:
            erro = e
    try:
        os.remove(tmp)
    except OSError:
        pass
    CONTA["falhas"] += 1
    print(f"    falhou {os.path.basename(arq)}: {_motivo(erro)}")
    return False


# ---------- ajudantes ----------

def caminho(bib, rel):
    return os.path.join(bib, *rel.split("/"))


def limpa(s, padrao="som"):
    return re.sub(r"[^\w\- ]", "", s).strip()[:60] or padrao


def sem_numero(nome):
    return re.sub(r"^\d+\s+", "", nome).lower()


def escreve(arq, texto, so_se_faltar=False):
    if so_se_faltar and os.path.exists(arq):
        return
    try:
        os.makedirs(os.path.dirname(arq), exist_ok=True)
        with open(arq, "w", encoding="utf-8") as f:
            f.write(texto)
    except OSError as e:
        print(f"  não consegui escrever {os.path.basename(arq)}: {e}")


def cartoes_mixkit(html, padrao):
    """Lista (título limpo, link) de cada cartão de som ou música de uma página do Mixkit, na ordem do site."""
    saida, vistos = [], set()
    for bloco in (html or "").split('class="item-grid__item"')[1:]:
        u = re.search(r'data-audio-player-preview-url-value="(' + padrao + ')"', bloco)
        t = re.search(r"item-grid-card__title[^>]*>\s*([^<]+?)\s*<", bloco)
        if u and u.group(1) not in vistos:
            vistos.add(u.group(1))
            saida.append((t.group(1) if t else "", u.group(1)))
    return saida


def acha_ffmpeg():
    for n in ("ffmpeg", "ffmpeg.exe"):                # primeiro a pasta bin do kit, depois o PATH
        c = os.path.join(RAIZ, "bin", n)
        if os.path.isfile(c):
            return c
    return shutil.which("ffmpeg")


def acha_numpy():
    try:
        import numpy
        return numpy
    except Exception:
        return None


# ---------- partes ----------

def parte_essenciais(bib, testar):
    print("\n== ESSENCIAIS (o que os sentimentos usam) ==")
    faltam = [rel for rel in ESSENCIAIS if not os.path.exists(caminho(bib, rel))]
    faltam_k = [rel for rel in ESSENCIAIS_KENNEY if not os.path.exists(caminho(bib, rel))]
    if testar:
        vivos = sum(responde(u) for u in ESSENCIAIS.values())
        print(f"  mixkit: {len(ESSENCIAIS)} arquivos, {vivos} links respondendo, baixaria {len(faltam)}")
        print(f"  kenney: {len(ESSENCIAIS_KENNEY)} arquivos, baixaria {len(faltam_k)}"
              + (" (pacote interface-sounds)" if faltam_k else ""))
        return
    ok = 0
    for rel in faltam:
        if salva(ESSENCIAIS[rel], caminho(bib, rel)) or _pelo_titulo(bib, rel):
            ok += 1
    print(f"  mixkit: {len(ESSENCIAIS)} arquivos, {ok} novos, {len(ESSENCIAIS) - len(faltam)} já tinha")
    if faltam_k:
        kenney(bib, "interface-sounds", False)
    else:
        print(f"  kenney: {len(ESSENCIAIS_KENNEY)} arquivos, já tinha")
    falta_ainda = [r for r in list(ESSENCIAIS) + list(ESSENCIAIS_KENNEY) if not os.path.exists(caminho(bib, r))]
    for r in falta_ainda:
        print(f"  ainda falta: {r}")
    escreve(os.path.join(bib, SONS, "LICENÇAS.txt"), LICENCA_SONS)
    escreve(os.path.join(bib, MUSICAS, "LICENÇA.txt"), LICENCA_MUSICAS)


def _pelo_titulo(bib, rel):
    """Se o link guardado parou de funcionar, procura o mesmo título na página da categoria do Mixkit."""
    partes = rel.split("/")
    titulo = sem_numero(partes[-1])[:-4]
    if partes[0] == SONS:
        slug = {v: k for k, v in CATEGORIAS_SONS.items()}.get(partes[1].replace("mixkit ", "", 1))
        url, padrao = f"https://mixkit.co/free-sound-effects/{slug}/", SFX_RE
    else:
        slug = {v: k for k, v in ESTILOS.items()}.get(partes[1])
        url, padrao = f"https://mixkit.co/free-stock-music/{slug}/", MUSICA_RE
    if not slug:
        return False
    for t, u in cartoes_mixkit(pagina(url), padrao):
        if limpa(t).lower() == titulo:
            print(f"    achei {partes[-1]} pelo nome no site")
            if salva(u, caminho(bib, rel)):
                CONTA["falhas"] -= 1                  # a falha do link antigo foi resolvida
                return True
    return False


def parte_fontes(bib, testar):
    print("\n== FONTES (Google Fonts, licença OFL) ==")
    for fam in FAMILIAS:
        try:
            lic, arquivos = _arquivos_da_fonte(fam)
        except Exception as e:
            print(f"  {fam}: falhou ({_motivo(e)})")
            continue
        if not arquivos:
            print(f"  {fam}: não achei")
            continue
        pasta = os.path.join(bib, FONTES, fam)
        faltam = [a for a in arquivos if not os.path.exists(os.path.join(pasta, a))]
        if testar:
            print(f"  {fam}: {len(arquivos)} arquivos, baixaria {len(faltam)}")
            continue
        novos = sum(salva(f"{FONTES_RAW}/{lic}/{fam}/{urllib.parse.quote(a)}", os.path.join(pasta, a)) for a in faltam)
        print(f"  {fam}: {len(arquivos)} arquivos, {novos} novos", flush=True)
    if testar:
        return
    arq = os.path.join(bib, FONTES, LICENCA_FONTES)
    if not os.path.exists(arq):
        try:
            texto = baixa(f"{FONTES_RAW}/ofl/montserrat/OFL.txt", 60).decode("utf-8", "replace")
        except Exception:
            texto = LICENCA_FONTES_RESERVA
        escreve(arq, texto)


def _arquivos_da_fonte(fam):
    """(licença, lista de .ttf) de uma família. Lê o METADATA.pb do repositório do Google Fonts; se não der,
    pergunta pra API do GitHub."""
    for lic in ("ofl", "apache", "ufl"):
        try:
            meta = baixa(f"{FONTES_RAW}/{lic}/{fam}/METADATA.pb", 30).decode("utf-8", "replace")
        except Exception:
            continue
        nomes = list(dict.fromkeys(re.findall(r'filename:\s*"([^"]+\.ttf)"', meta)))
        if nomes:
            return lic, nomes
    for lic in ("ofl", "apache", "ufl"):
        try:
            itens = json.loads(baixa(f"https://api.github.com/repos/google/fonts/contents/{lic}/{fam}", 30))
        except Exception:
            continue
        if isinstance(itens, list):
            return lic, [i["name"] for i in itens if i.get("name", "").endswith(".ttf")]
    return None, []


def parte_sons(bib, testar):
    print("\n== SONS (Mixkit e Kenney) ==")
    for cat, nome in CATEGORIAS_SONS.items():
        cartoes = cartoes_mixkit(pagina(f"https://mixkit.co/free-sound-effects/{cat}/"), SFX_RE)
        _baixa_lista(os.path.join(bib, SONS, "mixkit " + nome), cartoes, "som", f"mixkit {nome}", testar)
    for pacote in KENNEY:
        try:
            kenney(bib, pacote, testar)
        except Exception as e:
            print(f"  kenney {pacote}: falhou ({_motivo(e)})")
    if not testar:
        escreve(os.path.join(bib, SONS, "LICENÇAS.txt"), LICENCA_SONS)


def _baixa_lista(pasta, cartoes, padrao, rotulo, testar):
    """Baixa cada (título, link) como "NN Título.mp3". Pula o que já existe, mesmo que com outro número na frente."""
    tem = {sem_numero(n) for n in os.listdir(pasta)} if os.path.isdir(pasta) else set()
    faltam = []
    for i, (t, u) in enumerate(cartoes):
        arq = f"{i + 1:02d} {limpa(t, padrao)}.mp3"
        if sem_numero(arq).lower() not in tem:
            faltam.append((arq, u))
    if testar:
        print(f"  {rotulo}: {len(cartoes)} na página, baixaria {len(faltam)}")
        return
    novos = sum(salva(u, os.path.join(pasta, arq)) for arq, u in faltam)
    print(f"  {rotulo}: {len(cartoes)} na página, {novos} novos", flush=True)


def kenney(bib, pacote, testar):
    """Baixa o zip de um pacote do Kenney e tira os sons de dentro. Pula o pacote se a pasta já tem arquivos
    (na parte essenciais, só se os essenciais já estão lá)."""
    pasta = os.path.join(bib, SONS, "kenney " + pacote)
    tem = set(os.listdir(pasta)) if os.path.isdir(pasta) else set()
    html = pagina(f"https://kenney.nl/assets/{pacote}")
    achado = re.search(r'https://kenney\.nl/media/pages/assets/[^"]+\.zip', html or "")
    link = achado.group(0) if achado else KENNEY_RESERVA.get(pacote)
    essenciais = [os.path.basename(r) for r in ESSENCIAIS_KENNEY if f"/kenney {pacote}/" in r]
    pronto = all(e in tem for e in essenciais) if essenciais else bool(tem)
    if testar:
        print(f"  kenney {pacote}: " + ("já tem" if pronto else "baixaria o pacote inteiro")
              + ("" if link else " (não achei o zip)"))
        return
    if pronto:
        print(f"  kenney {pacote}: já tem")
        return
    if not link:
        CONTA["falhas"] += 1
        print(f"  kenney {pacote}: não achei o zip")
        return
    try:
        dados = baixa(link, 300)
    except Exception as e:
        CONTA["falhas"] += 1
        print(f"  kenney {pacote}: falhou ({_motivo(e)})")
        return
    os.makedirs(pasta, exist_ok=True)
    novos = 0
    with zipfile.ZipFile(io.BytesIO(dados)) as zf:
        for n in zf.namelist():
            base = os.path.basename(n)
            if base.lower().endswith((".ogg", ".wav", ".mp3")) and base not in tem:
                with open(os.path.join(pasta, base), "wb") as f:
                    f.write(zf.read(n))
                tem.add(base)
                novos += 1
    CONTA["novos"] += novos
    print(f"  kenney {pacote}: {novos} novos", flush=True)


def parte_musicas(bib, testar):
    print(f"\n== MÚSICAS (Mixkit, até {MUSICAS_POR_ESTILO} por estilo) ==")
    for slug, nome in ESTILOS.items():
        cartoes = cartoes_mixkit(pagina(f"https://mixkit.co/free-stock-music/{slug}/"), MUSICA_RE)[:MUSICAS_POR_ESTILO]
        _baixa_lista(os.path.join(bib, MUSICAS, nome), cartoes, "musica", nome, testar)
    if not testar:
        escreve(os.path.join(bib, MUSICAS, "LICENÇA.txt"), LICENCA_MUSICAS)


def parte_lottie(bib, testar):
    print(f"\n== LOTTIE (LottieFiles, {LOTTIE_POR_CATEGORIA} por categoria) ==")
    for slug, nome in CATEGORIAS_LOTTIE.items():
        html = pagina(f"https://lottiefiles.com/free-animations/{slug}")
        links = list(dict.fromkeys(re.findall(r"https://assets-v2\.lottiefiles\.com/a/[\w-]+/\w+\.lottie", html or "")))
        links = links[:LOTTIE_POR_CATEGORIA]
        pasta = os.path.join(bib, LOTTIE, nome)
        faltam = [(i, u) for i, u in enumerate(links) if not os.path.exists(os.path.join(pasta, f"{slug}-{i + 1:02d}.json"))]
        if testar:
            print(f"  {nome}: {len(links)} na página, baixaria {len(faltam)}")
            continue
        novos = 0
        for i, u in faltam:
            base = os.path.join(pasta, f"{slug}-{i + 1:02d}")
            if not os.path.exists(base + ".lottie") and not salva(u, base + ".lottie"):
                continue
            try:                                       # o .lottie é um zip; o .json de dentro é o que o Remotion lê
                with zipfile.ZipFile(base + ".lottie") as zf:
                    js = [x for x in zf.namelist() if x.startswith("animations/") and x.endswith(".json")]
                    if js:
                        with open(base + ".json.parcial", "wb") as f:
                            f.write(zf.read(js[0]))
                        os.replace(base + ".json.parcial", base + ".json")
                        novos += 1
            except Exception as e:
                CONTA["falhas"] += 1
                print(f"    falhou {os.path.basename(base)}.json: {e}")
        print(f"  {nome}: {len(links)} na página, {novos} novos", flush=True)
    if not testar:
        escreve(os.path.join(bib, LOTTIE, "LICENÇA.txt"), LICENCA_LOTTIE)


def parte_videos(bib, testar):
    print(f"\n== VÍDEOS (Mixkit, overlays e B-roll, {VIDEOS_POR_CATEGORIA} por categoria) ==")
    for grupo, cats in GRUPOS_VIDEO.items():
        for slug, nome in cats.items():
            html = pagina(f"https://mixkit.co/free-stock-video/{slug}/") or ""
            nomes = {i: t for t, i in re.findall(r"/free-stock-video/([a-z0-9-]+)-(\d+)/", html)}
            ids = list(dict.fromkeys(re.findall(r"https://assets\.mixkit\.co/videos/(\d+)/\d+-(?:360|720)\.mp4", html)))
            ids = ids[:VIDEOS_POR_CATEGORIA]
            pasta = caminho(bib, grupo + "/" + nome)
            faltam = [(k, i) for k, i in enumerate(ids)
                      if not os.path.exists(os.path.join(pasta, f"{k + 1:02d} {nomes.get(i, i)[:50]}.mp4"))]
            if testar:
                print(f"  {nome}: {len(ids)} na página, baixaria {len(faltam)}")
                continue
            novos = 0
            for k, i in faltam:
                arq = os.path.join(pasta, f"{k + 1:02d} {nomes.get(i, i)[:50]}.mp4")
                for q in ("1080", "720"):              # tenta 1080p e cai pra 720p
                    u = f"https://assets.mixkit.co/videos/{i}/{i}-{q}.mp4"
                    if responde(u):
                        novos += salva(u, arq, 600)
                        break
                else:
                    CONTA["falhas"] += 1
                    print(f"    falhou {os.path.basename(arq)}: link fora do ar")
            print(f"  {nome}: {len(ids)} na página, {novos} novos", flush=True)
    if not testar:
        for grupo in (OVERLAYS, BROLL):
            escreve(os.path.join(bib, grupo, "LICENÇA mixkit.txt"), LICENCA_VIDEOS)


# ---------- gerados aqui: LUTs, grão, papel e vinheta ----------

N_LUT = 33


def _lum(c):
    return 0.2126 * c[0] + 0.7152 * c[1] + 0.0722 * c[2]


def _sat(c, s):
    y = _lum(c)
    return [y + (v - y) * s for v in c]


def _curva(v, k):                                     # curva em S: mais contraste no meio
    return 0.5 + (v - 0.5) * (1 + k) - k * 4 * (v - 0.5) ** 3


def _curva_s(c, k):
    return [_curva(v, k) for v in c]


def _lavado(c, nivel):                                # pretos lavados, cara de filme
    return [nivel + v * (1 - nivel) for v in c]


def _tom(c, sombra, luz):                             # tinge as sombras e as luzes
    y = _lum(c)
    return [v + s * (1 - y) + l * y for v, s, l in zip(c, sombra, luz)]


LOOKS = {
    "01 quente suave": lambda c: _sat(_tom(c, (0.02, 0.0, -0.02), (0.04, 0.015, -0.03)), 1.08),
    "02 frio moderno": lambda c: _sat(_tom(c, (-0.02, 0.0, 0.03), (-0.01, 0.0, 0.02)), 1.02),
    "03 teal e laranja (cinema)": lambda c: _sat(_curva_s(_tom(c, (-0.04, 0.01, 0.05), (0.05, 0.01, -0.04)), 0.15), 1.12),
    "04 contraste forte": lambda c: _sat(_curva_s(c, 0.3), 1.1),
    "05 filme desbotado": lambda c: _sat(_lavado(_curva_s(c, 0.1), 0.06), 0.88),
    "06 preto e branco contrastado": lambda c: [_curva(_lum(c), 0.35)] * 3,
    "07 vibrante (redes sociais)": lambda c: _sat(_curva_s(c, 0.12), 1.3),
    "08 noite neon": lambda c: _sat(_tom([v * 0.92 for v in _curva_s(c, 0.2)], (0.03, -0.01, 0.06), (0.02, 0.0, 0.03)), 1.15),
}


def _corta(v):
    return 1.0 if v >= 1 else (v if v > 0 else 0.0)


def gera_lut(arq, nome):
    """LUT .cube de 33 pontos. No .cube o vermelho varia mais rápido, depois o verde, depois o azul."""
    f = LOOKS[nome]
    g = [i / (N_LUT - 1) for i in range(N_LUT)]
    linhas = []
    for b in g:
        for gg in g:
            for r in g:
                x, y, z = (_corta(v) for v in f([r, gg, b]))
                linhas.append(f"{x:.6f} {y:.6f} {z:.6f}")
    tmp = arq + ".parcial"
    with open(tmp, "w", encoding="utf-8") as saida:
        saida.write(f'TITLE "{nome}"\nLUT_3D_SIZE {N_LUT}\nDOMAIN_MIN 0 0 0\nDOMAIN_MAX 1 1 1\n')
        saida.write("\n".join(linhas))
        saida.write("\n")
    os.replace(tmp, arq)


GRAOS = {"grão de filme leve (modo sobrepor).mp4": 28, "grão de filme forte (modo sobrepor).mp4": 55}
PAPEIS = ("papel creme.png", "papel branco.png")
VINHETA = "vinheta (PNG transparente).png"


def parte_gerados(bib, testar):
    print("\n== GERADOS AQUI (8 LUTs, grão, papel e vinheta) ==")
    pasta_lut = os.path.join(bib, LUTS)
    pasta = caminho(bib, GERADOS)
    luts = [n for n in LOOKS if not os.path.exists(os.path.join(pasta_lut, n + ".cube"))]
    graos = [n for n in GRAOS if not os.path.exists(os.path.join(pasta, n))]
    imagens = [n for n in PAPEIS + (VINHETA,) if not os.path.exists(os.path.join(pasta, n))]
    np, ffmpeg = acha_numpy(), acha_ffmpeg()
    if testar:
        print(f"  LUTs: {len(LOOKS)}, geraria {len(luts)}")
        print(f"  grão: geraria {len(graos)}" + ("" if ffmpeg else " (falta o ffmpeg, vai pular)"))
        print(f"  papel e vinheta: geraria {len(imagens)}" + ("" if np else " (falta o numpy, vai pular)"))
        return
    os.makedirs(pasta_lut, exist_ok=True)
    os.makedirs(pasta, exist_ok=True)
    for n in luts:
        try:
            gera_lut(os.path.join(pasta_lut, n + ".cube"), n)
            CONTA["novos"] += 1
        except Exception as e:
            CONTA["falhas"] += 1
            print(f"    falhou {n}.cube: {e}")
    print(f"  LUTs: {len(LOOKS)}, {len(luts)} novos", flush=True)
    if not graos and not imagens:
        print("  grão, papel e vinheta: já tinha")

    if graos and not ffmpeg:
        print("  grão: pulei, não achei o ffmpeg (rode o instalador do pacote e tente de novo)")
    for n in graos if ffmpeg else []:
        arq = os.path.join(pasta, n)
        tmp = arq[:-4] + ".parcial.mp4"
        fonte = f"color=c=0x808080:s=1920x1080:r=24,noise=alls={GRAOS[n]}:allf=t+u,format=yuv420p"
        r = subprocess.run([ffmpeg, "-v", "error", "-y", "-f", "lavfi", "-i", fonte, "-t", "4", "-c:v", "libx264",
                            "-crf", "30", "-preset", "slow", tmp], capture_output=True, encoding="utf-8", errors="replace")
        if r.returncode == 0 and os.path.exists(tmp):
            os.replace(tmp, arq)
            CONTA["novos"] += 1
            print(f"  {n}: ok", flush=True)
        else:
            CONTA["falhas"] += 1
            print(f"  {n}: falhou ({(r.stderr or '').strip()[:120]})")
            if os.path.exists(tmp):
                os.remove(tmp)

    if imagens and not np:
        print("  papel e vinheta: pulei, falta o numpy (instale com: pip install numpy)")
    elif imagens:
        try:
            _gera_imagens(np, pasta, imagens)
        except Exception as e:
            CONTA["falhas"] += 1
            print(f"  papel e vinheta: falhou ({e})")


def _desfoca(np, img, sigma):
    """Desfoque gaussiano (igual ao do OpenCV: núcleo de 8 sigmas, borda espelhada), feito com FFT do numpy."""
    k = int(round(sigma * 8 + 1)) | 1
    x = np.arange(k) - (k - 1) / 2
    nucleo = np.exp(-x * x / (2 * sigma * sigma))
    nucleo /= nucleo.sum()
    m = k // 2
    saida = img.astype(np.float64)
    for eixo in (0, 1):
        borda = [(0, 0), (0, 0)]
        borda[eixo] = (m, m)
        p = np.pad(saida, borda, mode="reflect")
        n = p.shape[eixo] + k - 1
        forma = (-1, 1) if eixo == 0 else (1, -1)
        conv = np.fft.irfft(np.fft.rfft(p, n=n, axis=eixo) * np.fft.rfft(nucleo, n=n).reshape(forma), n=n, axis=eixo)
        corte = slice(k - 1, k - 1 + saida.shape[eixo])
        saida = conv[corte, :] if eixo == 0 else conv[:, corte]
    return saida.astype(np.float32)


def _grava_png(np, arq, img):
    """PNG de 8 bits (cinza, RGB ou RGBA) só com zlib, sem precisar de OpenCV nem Pillow."""
    h, w = img.shape[:2]
    canais = 1 if img.ndim == 2 else img.shape[2]
    linhas = img.reshape(h, w * canais)
    sub = linhas.copy()
    sub[:, canais:] = linhas[:, canais:] - linhas[:, :-canais]        # filtro "Sub": comprime melhor
    bruto = np.concatenate([np.ones((h, 1), np.uint8), sub], axis=1).tobytes()

    def bloco(tipo, dados):
        return struct.pack(">I", len(dados)) + tipo + dados + struct.pack(">I", zlib.crc32(tipo + dados) & 0xFFFFFFFF)

    cab = struct.pack(">IIBBBBB", w, h, 8, {1: 0, 3: 2, 4: 6}[canais], 0, 0, 0)
    tmp = arq + ".parcial"
    with open(tmp, "wb") as f:
        f.write(b"\x89PNG\r\n\x1a\n" + bloco(b"IHDR", cab) + bloco(b"IDAT", zlib.compress(bruto, 6)) + bloco(b"IEND", b""))
    os.replace(tmp, arq)


def _gera_imagens(np, pasta, faltam):
    h, w = 1080, 1920
    if any(n in faltam for n in PAPEIS):
        rng = np.random.default_rng(3)                 # papel: fibra fina + manchas suaves
        base = rng.normal(0, 1, (h, w)).astype(np.float32)
        fib = (_desfoca(np, base, 0.8) * 6
               + _desfoca(np, rng.normal(0, 1, (h, w)).astype(np.float32), 18) * 30
               + _desfoca(np, rng.normal(0, 1, (h, w)).astype(np.float32), 60) * 40)
        if "papel creme.png" in faltam:
            creme = np.array([242, 236, 228], np.float32)
            _grava_png(np, os.path.join(pasta, "papel creme.png"),
                       np.clip(creme[None, None, :] + fib[..., None], 0, 255).astype(np.uint8))
            CONTA["novos"] += 1
            print("  papel creme.png: ok", flush=True)
        if "papel branco.png" in faltam:
            _grava_png(np, os.path.join(pasta, "papel branco.png"), np.clip(240 + fib, 0, 255).astype(np.uint8))
            CONTA["novos"] += 1
            print("  papel branco.png: ok", flush=True)
    if VINHETA in faltam:                              # preto, transparente no meio e escurecendo só nas bordas
        yy, xx = np.mgrid[0:h, 0:w]
        r = np.sqrt(((xx - w / 2) / (w / 2)) ** 2 + ((yy - h / 2) / (h / 2)) ** 2)
        v = np.zeros((h, w, 4), np.uint8)
        v[..., 3] = (np.clip((r - 0.55) / 0.75, 0, 1) ** 1.6 * 235).astype(np.uint8)
        _grava_png(np, os.path.join(pasta, VINHETA), v)
        CONTA["novos"] += 1
        print(f"  {VINHETA}: ok", flush=True)


# ---------- principal ----------

PARTES = [("essenciais", parte_essenciais, "só os arquivos que os sentimentos usam (rode primeiro)"),
          ("gerados", parte_gerados, "8 LUTs de cor, grão de filme, papel e vinheta (feitos aqui, sem baixar)"),
          ("fontes", parte_fontes, "20 famílias do Google Fonts"),
          ("sons", parte_sons, "efeitos do Mixkit e pacotes do Kenney"),
          ("musicas", parte_musicas, "trilhas do Mixkit por estilo"),
          ("lottie", parte_lottie, "animações do LottieFiles"),
          ("videos", parte_videos, "overlays e B-roll do Mixkit (a parte mais pesada)")]


def main():
    try:
        sys.stdout.reconfigure(errors="replace")
    except Exception:
        pass
    ap = argparse.ArgumentParser(description="Baixa a BIBLIOTECA DE EDIÇÃO grátis. Sem nenhuma opção, baixa tudo.")
    for nome, _, ajuda in PARTES:
        ap.add_argument("--" + nome, action="store_true", help=ajuda)
    ap.add_argument("--destino", metavar="PASTA", help="pasta da biblioteca (padrão: BIBLIOTECA DE EDIÇÃO na Área de Trabalho)")
    ap.add_argument("--testar", action="store_true", help="só olha os sites e conta o que baixaria, sem baixar nada")
    a = ap.parse_args()
    escolhidas = [p for p in PARTES if getattr(a, p[0])] or PARTES
    bib = pasta_biblioteca(a.destino)
    print(("Testando (nada vai ser baixado). " if a.testar else "") + f"Biblioteca: {bib}", flush=True)
    if not a.testar:
        try:
            for p in PASTAS:
                os.makedirs(os.path.join(bib, p), exist_ok=True)
        except OSError as e:
            sys.exit(f"Não consegui criar a pasta {bib}: {e}\nEscolha outra com --destino PASTA.")
        escreve(os.path.join(bib, "LEIA-ME.txt"), LEIA_ME, so_se_faltar=True)
    for nome, funcao, _ in escolhidas:
        try:
            funcao(bib, a.testar)
        except Exception as e:
            CONTA["falhas"] += 1
            print(f"  a parte {nome} parou com erro: {_motivo(e)}")
    if a.testar:
        print("\nTeste pronto. Nada foi baixado.")
    else:
        print(f"\nPronto. {CONTA['novos']} arquivos novos, {CONTA['falhas']} falhas. Pasta: {bib}")
        if CONTA["falhas"]:
            print("Rode de novo pra tentar baixar o que falhou (o que já veio é pulado).")


if __name__ == "__main__":
    main()
