primocode 10.16.0 → 10.17.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSES/edit-ia-pro.txt +32 -0
- package/LICENSES/fontes-OFL.txt +476 -0
- package/LICENSES/hyperframes-Apache-2.0.txt +205 -0
- package/LICENSES/permanent-marker-Apache-2.0.txt +204 -0
- package/LICENSES/selfie-multiclass-Apache-2.0.txt +227 -0
- package/LICENSES/three-MIT.txt +23 -0
- package/LICENSES/video-use-MIT.txt +24 -0
- package/LICENSES/woven-sfx-CC0.txt +16 -0
- package/LICENSES/yunet-MIT.txt +21 -0
- package/README.md +2 -2
- package/bin/primocode.js +11 -2
- package/editor/LICENSE.md +11 -0
- package/editor/README.md +321 -0
- package/editor/SKILL.md +307 -0
- package/editor/THIRD_PARTY_NOTICES.md +228 -0
- package/editor/assets/baixar.json +591 -0
- package/editor/assets/fonts/anton/OFL.txt +93 -0
- package/editor/assets/fonts/caveat/OFL.txt +93 -0
- package/editor/assets/fonts/figtree/OFL.txt +93 -0
- package/editor/assets/fonts/fonts.json +10 -0
- package/editor/assets/fonts/montserrat/OFL.txt +93 -0
- package/editor/assets/fonts/permanentmarker/LICENSE.txt +202 -0
- package/editor/assets/models/SELFIE_MULTICLASS_LICENSE.txt +227 -0
- package/editor/assets/models/YUNET_LICENSE.txt +21 -0
- package/editor/assets/sfx/licencas/bass_drop.txt +32 -0
- package/editor/assets/sfx/licencas/cash.txt +76 -0
- package/editor/assets/sfx/licencas/charge.txt +31 -0
- package/editor/assets/sfx/licencas/click.txt +46 -0
- package/editor/assets/sfx/licencas/coin.txt +50 -0
- package/editor/assets/sfx/licencas/count.txt +47 -0
- package/editor/assets/sfx/licencas/data.txt +31 -0
- package/editor/assets/sfx/licencas/error.txt +32 -0
- package/editor/assets/sfx/licencas/flash.txt +31 -0
- package/editor/assets/sfx/licencas/gears.txt +82 -0
- package/editor/assets/sfx/licencas/keyboard.txt +48 -0
- package/editor/assets/sfx/licencas/metal_hit.txt +62 -0
- package/editor/assets/sfx/licencas/metal_riser.txt +61 -0
- package/editor/assets/sfx/licencas/mouse_click.txt +50 -0
- package/editor/assets/sfx/licencas/notification.txt +54 -0
- package/editor/assets/sfx/licencas/pop.txt +38 -0
- package/editor/assets/sfx/licencas/rewind.txt +32 -0
- package/editor/assets/sfx/licencas/rise.txt +46 -0
- package/editor/assets/sfx/licencas/sinteticos.txt +21 -0
- package/editor/assets/sfx/licencas/snap.txt +50 -0
- package/editor/assets/sfx/licencas/success.txt +40 -0
- package/editor/assets/sfx/licencas/synth_hit.txt +32 -0
- package/editor/assets/sfx/licencas/tick.txt +14 -0
- package/editor/assets/sfx/licencas/whoosh.txt +47 -0
- package/editor/assets/sfx/licencas/wind.txt +31 -0
- package/editor/assets/sfx/sfx.json +832 -0
- package/editor/assets/sfx/sfx_sinteticos.json +317 -0
- package/editor/assets/sfx/sfx_woven.json +387 -0
- package/editor/assets/sfx/woven/LICENCA.md +16 -0
- package/editor/fichas/briefing-do-video.md +45 -0
- package/editor/fichas/conferencia-da-amostra.md +56 -0
- package/editor/fichas/matriz-de-ganchos.csv +3 -0
- package/editor/fichas/padrao.json +16 -0
- package/editor/fichas/planilha-de-cenas.csv +4 -0
- package/editor/fichas/planilha-de-versoes.csv +4 -0
- package/editor/fichas/plano-em-blocos.md +32 -0
- package/editor/fichas/registro-de-materiais.json +27 -0
- package/editor/helpers/assets_ocr/ocr_vision.swift +41 -0
- package/editor/helpers/bordas_precisas.py +272 -0
- package/editor/helpers/camera_viva.py +426 -0
- package/editor/helpers/captions.py +601 -0
- package/editor/helpers/common.py +308 -0
- package/editor/helpers/compose_final.py +3555 -0
- package/editor/helpers/conferir_corte.py +234 -0
- package/editor/helpers/conferir_dados.py +405 -0
- package/editor/helpers/contact_sheet.py +282 -0
- package/editor/helpers/corte_por_onda.py +433 -0
- package/editor/helpers/corte_seco.py +595 -0
- package/editor/helpers/denoise.py +971 -0
- package/editor/helpers/efeitos.py +408 -0
- package/editor/helpers/efeitos_gerar.py +159 -0
- package/editor/helpers/elevenlabs_api.py +442 -0
- package/editor/helpers/face_layout.py +570 -0
- package/editor/helpers/fetch_images.py +423 -0
- package/editor/helpers/ganho_por_trecho.py +214 -0
- package/editor/helpers/gen_image.py +312 -0
- package/editor/helpers/gen_music.py +316 -0
- package/editor/helpers/gen_sfx.py +148 -0
- package/editor/helpers/grade.py +307 -0
- package/editor/helpers/icloud.py +119 -0
- package/editor/helpers/make_sfx.py +940 -0
- package/editor/helpers/matte.py +1342 -0
- package/editor/helpers/medir_referencia.py +2232 -0
- package/editor/helpers/mixar_trilha.py +354 -0
- package/editor/helpers/montar_corte.py +295 -0
- package/editor/helpers/motion/__init__.py +13 -0
- package/editor/helpers/motion/engine.py +1915 -0
- package/editor/helpers/motion/registry.py +510 -0
- package/editor/helpers/motion/render.py +665 -0
- package/editor/helpers/motion/templates/barra_progresso.py +370 -0
- package/editor/helpers/motion/templates/carimbo.py +132 -0
- package/editor/helpers/motion/templates/checklist.py +271 -0
- package/editor/helpers/motion/templates/comentario_cta.py +380 -0
- package/editor/helpers/motion/templates/contador.py +222 -0
- package/editor/helpers/motion/templates/destaque.py +263 -0
- package/editor/helpers/motion/templates/etiqueta_preco.py +461 -0
- package/editor/helpers/motion/templates/grafico_barras.py +510 -0
- package/editor/helpers/motion/templates/grafico_linha.py +462 -0
- package/editor/helpers/motion/templates/notificacao.py +266 -0
- package/editor/helpers/motion/templates/palavra_impacto.py +410 -0
- package/editor/helpers/motion/templates/passos.py +321 -0
- package/editor/helpers/motion/templates/riscado.py +298 -0
- package/editor/helpers/motion/templates/selo_garantia.py +150 -0
- package/editor/helpers/motion_render.py +241 -0
- package/editor/helpers/motion_sugerir.py +961 -0
- package/editor/helpers/noise_check.py +134 -0
- package/editor/helpers/pack_transcripts.py +223 -0
- package/editor/helpers/palavras_na_onda.py +176 -0
- package/editor/helpers/plano_sfx.py +839 -0
- package/editor/helpers/portao.py +427 -0
- package/editor/helpers/preview_server.py +1177 -0
- package/editor/helpers/rastrear_rosto.py +298 -0
- package/editor/helpers/render_cut.py +1256 -0
- package/editor/helpers/respiros.py +89 -0
- package/editor/helpers/sincronizar_cameras.py +571 -0
- package/editor/helpers/speech_regions.py +423 -0
- package/editor/helpers/style_catalog.json +231 -0
- package/editor/helpers/style_samples.py +246 -0
- package/editor/helpers/tela_verde.py +2113 -0
- package/editor/helpers/text_render.py +2059 -0
- package/editor/helpers/texto_na_tela.py +366 -0
- package/editor/helpers/timeline_view.py +292 -0
- package/editor/helpers/tira_de_quadros.py +113 -0
- package/editor/helpers/transcribe.py +734 -0
- package/editor/helpers/transcribe_batch.py +129 -0
- package/editor/helpers/trilhas.py +1146 -0
- package/editor/helpers/trilhas_humores.json +66 -0
- package/editor/helpers/valores.py +187 -0
- package/editor/helpers/verify_cut.py +820 -0
- package/editor/helpers/voice_levels.py +233 -0
- package/editor/helpers/voz_check.py +386 -0
- package/editor/helpers/voz_limpa.py +81 -0
- package/editor/helpers/watch_preview.py +372 -0
- package/editor/modelo-projeto/LEIA.md +402 -0
- package/editor/modelo-projeto/assets/fontes/OFL-Caveat.txt +93 -0
- package/editor/modelo-projeto/assets/fontes/OFL-JetBrainsMono.txt +93 -0
- package/editor/modelo-projeto/assets/fontes/OFL-Montserrat.txt +93 -0
- package/editor/modelo-projeto/assets/fontes/larguras.json +1 -0
- package/editor/modelo-projeto/assets/three/BufferGeometryUtils.js +1438 -0
- package/editor/modelo-projeto/assets/three/GLTFLoader.js +4895 -0
- package/editor/modelo-projeto/assets/three/LICENSE +21 -0
- package/editor/modelo-projeto/assets/three/ORIGEM.txt +17 -0
- package/editor/modelo-projeto/assets/three/RoomEnvironment.js +185 -0
- package/editor/modelo-projeto/assets/three/three.core.min.js +6 -0
- package/editor/modelo-projeto/assets/three/three.module.min.js +6 -0
- package/editor/modelo-projeto/build.py +4828 -0
- package/editor/modelo-projeto/compositions/cenas/2d/k2d_antes_depois.html +199 -0
- package/editor/modelo-projeto/compositions/cenas/2d/k2d_arquivo.html +232 -0
- package/editor/modelo-projeto/compositions/cenas/2d/k2d_busca.html +315 -0
- package/editor/modelo-projeto/compositions/cenas/2d/k2d_comando.html +304 -0
- package/editor/modelo-projeto/compositions/cenas/2d/k2d_conversa.html +217 -0
- package/editor/modelo-projeto/compositions/cenas/2d/k2d_esquema.html +289 -0
- package/editor/modelo-projeto/compositions/cenas/2d/k2d_medidor.html +249 -0
- package/editor/modelo-projeto/compositions/cenas/2d/k2d_notas.html +233 -0
- package/editor/modelo-projeto/compositions/cenas/3d/k3d_caminho.html +197 -0
- package/editor/modelo-projeto/compositions/cenas/3d/k3d_explodida.html +152 -0
- package/editor/modelo-projeto/compositions/cenas/3d/k3d_tela.html +167 -0
- package/editor/modelo-projeto/compositions/cenas/3d/k3d_volume.html +124 -0
- package/editor/modelo-projeto/compositions/cenas/3d/motor-three.js +728 -0
- package/editor/modelo-projeto/compositions/cenas/LEIA.md +177 -0
- package/editor/modelo-projeto/ferramentas/medir_fontes.py +65 -0
- package/editor/modelo-projeto/ferramentas/pessoa_recortada.py +176 -0
- package/editor/modelo-projeto/ferramentas/rastrear_maos.py +189 -0
- package/editor/modelo-projeto/hyperframes.json +12 -0
- package/editor/modelo-projeto/package.json +11 -0
- package/editor/modelo-projeto/projeto.json +16 -0
- package/editor/preview/app.css +1009 -0
- package/editor/preview/app.js +2122 -0
- package/editor/preview/index.html +307 -0
- package/editor/pyproject.toml +23 -0
- package/editor/references/3d.md +236 -0
- package/editor/references/animacao-catalogo.md +672 -0
- package/editor/references/animacao.md +498 -0
- package/editor/references/comandos.md +92 -0
- package/editor/references/corte-seco-como-funciona.md +50 -0
- package/editor/references/corte-seco.md +88 -0
- package/editor/references/corte.md +290 -0
- package/editor/references/direcao-por-blocos.md +218 -0
- package/editor/references/enquadramento.md +654 -0
- package/editor/references/estilos-prontos.md +355 -0
- package/editor/references/estudio.md +100 -0
- package/editor/references/exemplo-eventos.json +67 -0
- package/editor/references/lettering.md +252 -0
- package/editor/references/modelos.md +494 -0
- package/editor/references/phase2.md +1007 -0
- package/editor/references/referencia-aplicar.md +203 -0
- package/editor/references/referencia-medir.md +375 -0
- package/editor/references/revisao.md +335 -0
- package/editor/references/som-e-trilha.md +146 -0
- package/editor/references/tabela-de-sons.md +130 -0
- package/editor/references/variacoes.md +234 -0
- package/editor/uv.lock +1141 -0
- package/lib/act.js +36 -8
- package/lib/catalogo.js +4 -0
- package/lib/chamada-torta.js +81 -0
- package/lib/editor-pro.js +786 -0
- package/lib/entrega.js +58 -0
- package/lib/estudio/conferencia-visual.js +76 -0
- package/lib/estudio/diretor.js +6 -1
- package/lib/estudio/exportar.js +3 -1
- package/lib/estudio/gosto.js +45 -4
- package/lib/estudio/index.js +8 -5
- package/lib/estudio/kit-ae/base.jsx +119 -13
- package/lib/estudio/kit-ae/cenas.jsx +47 -15
- package/lib/estudio/kit-ae/guarda.jsx +274 -0
- package/lib/estudio/kit-ae/primitivas.jsx +180 -24
- package/lib/estudio/maquina-diretor.js +61 -7
- package/lib/estudio/maquina-foto.js +177 -0
- package/lib/estudio/maquina-guarda.js +207 -0
- package/lib/estudio/maquina-layout.js +22 -1
- package/lib/estudio/maquina.js +60 -11
- package/lib/estudio/referencias.js +1 -1
- package/lib/estudio/visao/visao.swift +97 -0
- package/lib/estudio/visao.js +60 -0
- package/lib/local/cerebro.json +192 -6
- package/lib/local/motor.js +5 -2
- package/lib/nuvem.js +11 -1
- package/lib/passagem.js +50 -0
- package/lib/perigo.js +19 -11
- package/lib/protecao.js +134 -0
- package/lib/titulo-conversa.js +46 -0
- package/lib/tools.js +3 -0
- package/lib/ui.js +1 -1
- package/package.json +3 -2
- package/skills/editar-meu-video/SKILL.md +6 -2
- package/skills/editar-video/SKILL.md +113 -0
- package/studio/pessoa.py +52 -0
- package/voz/primo_voz/app.py +43 -4
- package/voz/primo_voz/chat.html +17 -6
- package/voz/primo_voz/painel_dados.py +134 -0
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
"""Optional custom sound effect made in the buyer's OWN ElevenLabs account (Sound Effects, paid per use).
|
|
2
|
+
|
|
3
|
+
For a sound the built-in CC0 kit does not have (its 24 names are listed in references/phase2.md). Same rules as
|
|
4
|
+
gen_music.py: the buyer's own key (ELEVENLABS_API_KEY in the environment or in `.env` in the skill root),
|
|
5
|
+
never printed or saved; without it, exit 2 with the message that tells the agent to ask for it. The file goes
|
|
6
|
+
to <edit>/assets/sfx_<name>.mp3 with an entry in <edit>/assets/credits.json, and is used as a file path in
|
|
7
|
+
`sfx.events` (`{"at": 5.07, "name": "assets/sfx_<name>.mp3"}`) or in `inserts[].sfx`. The file starts on the
|
|
8
|
+
event frame; use `anchor_s` when its strong point is later in the file.
|
|
9
|
+
|
|
10
|
+
Usage:
|
|
11
|
+
uv run python helpers/gen_sfx.py "<sound in English>" --edit-dir DIR [--seconds 1.5]
|
|
12
|
+
[--out NAME] [--prompt-influence 0.5] [--loop] [--variations N] [--dry-run]
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import argparse
|
|
18
|
+
import math
|
|
19
|
+
import sys
|
|
20
|
+
from pathlib import Path
|
|
21
|
+
|
|
22
|
+
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
|
23
|
+
import elevenlabs_api as el # noqa: E402
|
|
24
|
+
|
|
25
|
+
MODEL = "eleven_text_to_sound_v2"
|
|
26
|
+
MIN_SECONDS = 0.5 # API: duration_seconds from 0.5 (up to 30); effects here stay short
|
|
27
|
+
MAX_SECONDS = 5.0
|
|
28
|
+
DEFAULT_SECONDS = 1.5
|
|
29
|
+
DEFAULT_INFLUENCE = 0.5 # API default 0.3; a short effect should follow the text closely
|
|
30
|
+
MAX_VARIATIONS = 3
|
|
31
|
+
PREFIX = "sfx_"
|
|
32
|
+
SOURCE = "ElevenLabs Sound Effects (conta do usuário)"
|
|
33
|
+
EXAMPLE = "soft paper page flip, close and clean, no music"
|
|
34
|
+
TIMEOUT = 120
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def pick_seconds(value: float) -> tuple[float, list[str]]:
|
|
38
|
+
if not math.isfinite(value) or value <= 0:
|
|
39
|
+
raise el.ElevenError("--seconds precisa ser um número maior que zero (ex.: 1.5)", 2)
|
|
40
|
+
notes = []
|
|
41
|
+
if value < MIN_SECONDS:
|
|
42
|
+
notes.append(f"efeito sonoro tem no mínimo {MIN_SECONDS:g} s: ajustei {value:g} s para {MIN_SECONDS:g} s")
|
|
43
|
+
value = MIN_SECONDS
|
|
44
|
+
elif value > MAX_SECONDS:
|
|
45
|
+
notes.append(f"efeito sonoro aqui vai até {MAX_SECONDS:g} s: ajustei {value:g} s para {MAX_SECONDS:g} s "
|
|
46
|
+
"(para som mais longo, use gen_music.py)")
|
|
47
|
+
value = MAX_SECONDS
|
|
48
|
+
return round(value, 2), notes
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def output_names(edit: Path, text: str, out: str | None, count: int) -> list[Path]:
|
|
52
|
+
stem = el.output_stem(out) if out else el.slugify(text)
|
|
53
|
+
if not stem.lower().startswith(PREFIX):
|
|
54
|
+
stem = PREFIX + stem
|
|
55
|
+
names = [f"{stem}.mp3"] if count == 1 else [f"{stem}_{i}.mp3" for i in range(1, count + 1)]
|
|
56
|
+
return el.free_paths(edit / "assets", names)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def main(argv: list[str] | None = None) -> int:
|
|
60
|
+
ap = argparse.ArgumentParser(description="Gera um efeito sonoro com IA na conta ElevenLabs da própria pessoa "
|
|
61
|
+
"(opcional, pago), para um som que o kit pronto não tem.")
|
|
62
|
+
ap.add_argument("text", help="descrição do som, de preferência em inglês")
|
|
63
|
+
ap.add_argument("--edit-dir", required=True, help="pasta edit da sessão")
|
|
64
|
+
ap.add_argument("--seconds", type=float, default=DEFAULT_SECONDS,
|
|
65
|
+
help=f"duração em segundos, de {MIN_SECONDS:g} a {MAX_SECONDS:g} (padrão {DEFAULT_SECONDS:g})")
|
|
66
|
+
ap.add_argument("--out", help=f"nome do arquivo em assets/ (padrão: {PREFIX}<texto>.mp3; o prefixo {PREFIX} "
|
|
67
|
+
"entra sozinho)")
|
|
68
|
+
ap.add_argument("--prompt-influence", type=float, default=DEFAULT_INFLUENCE,
|
|
69
|
+
help=f"de 0 a 1: quanto o som segue o texto (padrão {DEFAULT_INFLUENCE:g})")
|
|
70
|
+
ap.add_argument("--loop", action="store_true", help="som que emenda sem corte quando repete")
|
|
71
|
+
ap.add_argument("--variations", type=int, default=1,
|
|
72
|
+
help=f"quantas versões gerar, uma requisição cada (padrão 1, no máximo {MAX_VARIATIONS})")
|
|
73
|
+
ap.add_argument("--dry-run", action="store_true",
|
|
74
|
+
help="só mostra o pedido e o custo estimado, sem gerar e sem internet")
|
|
75
|
+
args = ap.parse_args(argv)
|
|
76
|
+
|
|
77
|
+
key = None
|
|
78
|
+
saved: list[str] = []
|
|
79
|
+
|
|
80
|
+
def say(*parts: str, flush: bool = False) -> None:
|
|
81
|
+
print(el.redact(" ".join(parts), key), flush=flush)
|
|
82
|
+
|
|
83
|
+
try:
|
|
84
|
+
text = (args.text or "").strip()
|
|
85
|
+
if not text:
|
|
86
|
+
raise el.ElevenError("a descrição do som está vazia", 2)
|
|
87
|
+
el.check_prompt(text, EXAMPLE)
|
|
88
|
+
if not 1 <= args.variations <= MAX_VARIATIONS:
|
|
89
|
+
raise el.ElevenError(f"--variations vai de 1 a {MAX_VARIATIONS} (cada uma é uma cobrança)", 2)
|
|
90
|
+
influence = args.prompt_influence
|
|
91
|
+
if not math.isfinite(influence) or not 0 <= influence <= 1:
|
|
92
|
+
raise el.ElevenError("--prompt-influence vai de 0 a 1 (ex.: 0.5)", 2)
|
|
93
|
+
edit = el.edit_folder(args.edit_dir)
|
|
94
|
+
seconds, notes = pick_seconds(args.seconds)
|
|
95
|
+
key = el.api_key()
|
|
96
|
+
el.check_no_key(text, key)
|
|
97
|
+
if not key and not args.dry_run:
|
|
98
|
+
raise el.ElevenError(el.missing_key_message(
|
|
99
|
+
"Gerar efeito sonoro", "os 24 efeitos prontos do kit (whoosh, cash, notification, error, grave, "
|
|
100
|
+
"teclado e os outros da lista em references/phase2.md) já vêm com a skill"), 2)
|
|
101
|
+
paths = output_names(edit, text, args.out, args.variations)
|
|
102
|
+
payload = {"text": text, "duration_seconds": seconds, "prompt_influence": round(influence, 3),
|
|
103
|
+
"loop": bool(args.loop), "model_id": MODEL}
|
|
104
|
+
for note in notes:
|
|
105
|
+
say(f"aviso: {note}")
|
|
106
|
+
if el.looks_portuguese(text):
|
|
107
|
+
say("aviso: a descrição parece estar em português. A ElevenLabs entende melhor em inglês "
|
|
108
|
+
f"(ex.: '{EXAMPLE}').")
|
|
109
|
+
|
|
110
|
+
each = el.credits_estimate("sfx", seconds)
|
|
111
|
+
if args.dry_run:
|
|
112
|
+
say(f"efeito: {seconds:g} s{', em loop' if args.loop else ''}, influência do texto {influence:g}")
|
|
113
|
+
say("arquivos: " + ", ".join(f"assets/{p.name}" for p in paths))
|
|
114
|
+
say(f"custo estimado: cerca de {each} créditos por versão, {each * len(paths)} no total "
|
|
115
|
+
f"(a ElevenLabs cobra cerca de {el.SFX_CREDITS_PER_SECOND} créditos por segundo de efeito; "
|
|
116
|
+
"o valor certo aparece no painel dela)")
|
|
117
|
+
say(el.PAID_PLAN_LINE)
|
|
118
|
+
if not key:
|
|
119
|
+
say(f"chave: ausente. Antes de gerar, peça a {el.KEY_NAME} "
|
|
120
|
+
"(veja a mensagem que sai sem --dry-run).")
|
|
121
|
+
say("pedido (não enviado):")
|
|
122
|
+
say(el.dry_run_request(el.SFX_URL, payload, key))
|
|
123
|
+
return 0
|
|
124
|
+
|
|
125
|
+
for i, path in enumerate(paths, 1):
|
|
126
|
+
say(f"gerando efeito {i}/{len(paths)} ({seconds:g} s) na conta ElevenLabs da pessoa...", flush=True)
|
|
127
|
+
data = el.post_audio(el.SFX_URL, payload, key or "", "Sound Effects", TIMEOUT)
|
|
128
|
+
rel = f"assets/{path.name}"
|
|
129
|
+
el.store(edit, path, data, el.new_credit(rel, SOURCE, text, seconds, loop=bool(args.loop),
|
|
130
|
+
prompt_influence=round(influence, 3), model=MODEL))
|
|
131
|
+
saved.append(rel)
|
|
132
|
+
say(f"ok: {rel} {seconds:g} s {len(data) // 1024} KB", flush=True)
|
|
133
|
+
say(f"créditos anotados em assets/credits.json ({el.COMMERCIAL_NOTE})")
|
|
134
|
+
say(f'em sfx.events: {{"at": 5.07, "name": "{saved[0]}"}} (o começo do arquivo cai no "at"; '
|
|
135
|
+
'use "anchor_s" se o ponto forte vier depois)')
|
|
136
|
+
return 0
|
|
137
|
+
except Exception as e: # noqa: BLE001 (never a traceback: its text could carry the key)
|
|
138
|
+
if not isinstance(e, el.ElevenError):
|
|
139
|
+
e = el.unexpected(e)
|
|
140
|
+
if saved:
|
|
141
|
+
e = el.ElevenError(f"{e} Já ficaram salvos: {', '.join(saved)}.", e.code)
|
|
142
|
+
el.print_error(e, key)
|
|
143
|
+
return e.code
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
if __name__ == "__main__":
|
|
147
|
+
el.utf8_console()
|
|
148
|
+
sys.exit(main())
|
|
@@ -0,0 +1,307 @@
|
|
|
1
|
+
# Adapted from browser-use/video-use (MIT, (c) 2026 Browser Use). See THIRD_PARTY_NOTICES.md.
|
|
2
|
+
"""Color grade presets, apply a grade to a video, or compare grades on one frame.
|
|
3
|
+
|
|
4
|
+
Usage:
|
|
5
|
+
uv run python helpers/grade.py --list
|
|
6
|
+
uv run python helpers/grade.py <in> -o <out> --preset vivo
|
|
7
|
+
uv run python helpers/grade.py <in> -o <out> --filter "eq=contrast=1.1:saturation=1.2"
|
|
8
|
+
uv run python helpers/grade.py <video> --candidates "a=eq=saturation=1.3;b=quente;original=" --frame 4.2 -o cmp.png
|
|
9
|
+
|
|
10
|
+
Imported by render_cut.py: `resolve_grade(value)` turns an EDL "grade" field (preset name or raw
|
|
11
|
+
ffmpeg filter) into a filter string, `check_filter(f)` validates it on a tiny test input.
|
|
12
|
+
Every grade runs after an 8-bit `format=yuv420p` guard (see `graded_chain`).
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import argparse
|
|
18
|
+
import math
|
|
19
|
+
import re
|
|
20
|
+
import subprocess
|
|
21
|
+
import sys
|
|
22
|
+
from functools import lru_cache
|
|
23
|
+
from pathlib import Path
|
|
24
|
+
|
|
25
|
+
from PIL import Image, ImageDraw
|
|
26
|
+
|
|
27
|
+
from common import has_ffmpeg_filter, probe_video
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _apple_log_curve() -> str:
|
|
31
|
+
"""Basic Apple Log -> Rec.709 curve (no LUT): decode the log curve, soft shoulder, 709 OETF."""
|
|
32
|
+
r0, rt, c = -0.05641088, 0.01, 47.28711236
|
|
33
|
+
beta, gamma, delta = 0.00964052, 0.08550479, 0.69336945
|
|
34
|
+
p_t = gamma * math.log2(rt + beta) + delta
|
|
35
|
+
|
|
36
|
+
def to_linear(x: float) -> float:
|
|
37
|
+
if x >= p_t:
|
|
38
|
+
return 2 ** ((x - delta) / gamma) - beta
|
|
39
|
+
return r0 + math.sqrt(max(0.0, x) / c)
|
|
40
|
+
|
|
41
|
+
def oetf(v: float) -> float:
|
|
42
|
+
v = min(1.0, max(0.0, v))
|
|
43
|
+
return 4.5 * v if v < 0.018 else 1.099 * v ** 0.45 - 0.099
|
|
44
|
+
|
|
45
|
+
pts = []
|
|
46
|
+
last = -1.0
|
|
47
|
+
for x in (0.0, 0.12, 0.16, 0.2, 0.25, 0.3, 0.35, 0.4, 0.45, 0.5, 0.55, 0.6, 0.65, 0.7, 0.8, 1.0):
|
|
48
|
+
lin = max(0.0, to_linear(x)) * 1.25 # exposure so 18% grey lands near 709 mid grey
|
|
49
|
+
tm = lin * (1 + lin / 100.0) / (1 + lin) # extended Reinhard, white point 10
|
|
50
|
+
y = round(max(last + 0.001 if last >= 0 else 0.0, oetf(tm)), 4)
|
|
51
|
+
y = min(y, 1.0)
|
|
52
|
+
pts.append(f"{x:g}/{y:g}")
|
|
53
|
+
last = y
|
|
54
|
+
return "curves=interp=pchip:all='" + " ".join(pts) + "'"
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
# name -> (filter, PT-BR description). Keep names stable: they are referenced by EDLs.
|
|
58
|
+
PRESETS: dict[str, tuple[str, str]] = {
|
|
59
|
+
"none": ("", "Sem ajuste de cor."),
|
|
60
|
+
"natural": (
|
|
61
|
+
"curves=interp=pchip:master='0/0 0.1/0.093 0.5/0.515 0.9/0.92 1/1',eq=saturation=1.05",
|
|
62
|
+
"Contraste leve, cor fiel. Seguro para qualquer vídeo.",
|
|
63
|
+
),
|
|
64
|
+
"vivo": (
|
|
65
|
+
"curves=interp=pchip:master='0/0 0.12/0.1 0.5/0.52 0.88/0.915 1/1',vibrance=intensity=0.2,eq=saturation=1.05",
|
|
66
|
+
"Mais contraste e cor, sem estourar a pele. Bom para anúncio.",
|
|
67
|
+
),
|
|
68
|
+
"quente": (
|
|
69
|
+
"colortemperature=temperature=4800:mix=0.75:pl=0.9,"
|
|
70
|
+
"curves=interp=pchip:master='0/0 0.1/0.093 0.5/0.515 0.9/0.92 1/1',eq=saturation=1.04",
|
|
71
|
+
"Tom mais quente e acolhedor, pele dourada.",
|
|
72
|
+
),
|
|
73
|
+
"frio": (
|
|
74
|
+
"colortemperature=temperature=8500:mix=0.7:pl=0.9,"
|
|
75
|
+
"curves=interp=pchip:master='0/0 0.1/0.093 0.5/0.515 0.9/0.92 1/1',eq=saturation=0.97",
|
|
76
|
+
"Tom mais frio e limpo, visual tech.",
|
|
77
|
+
),
|
|
78
|
+
"apple_log_basico": (
|
|
79
|
+
_apple_log_curve() + ",eq=saturation=1.22",
|
|
80
|
+
"Só para vídeo gravado em Apple Log (imagem cinza e lavada). Converte para cor normal.",
|
|
81
|
+
),
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def get_preset(name: str) -> str:
|
|
86
|
+
if name not in PRESETS:
|
|
87
|
+
raise KeyError(f"preset de cor desconhecido: {name!r}. Existem: {', '.join(PRESETS)}")
|
|
88
|
+
return PRESETS[name][0]
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def resolve_grade(value: str | None) -> str:
|
|
92
|
+
"""EDL grade field -> ffmpeg filter string ('' = no grade). Raises ValueError (PT-BR)."""
|
|
93
|
+
if value is None:
|
|
94
|
+
return ""
|
|
95
|
+
text = str(value).strip()
|
|
96
|
+
if not text or text == "none":
|
|
97
|
+
return ""
|
|
98
|
+
if text in PRESETS:
|
|
99
|
+
return PRESETS[text][0]
|
|
100
|
+
if "=" in text or "," in text:
|
|
101
|
+
return text
|
|
102
|
+
if re.fullmatch(r"[A-Za-z0-9_]+", text) and has_ffmpeg_filter(text):
|
|
103
|
+
return text
|
|
104
|
+
raise ValueError(f"grade {text!r} não é um preset ({', '.join(PRESETS)}) nem um filtro do ffmpeg")
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
@lru_cache(maxsize=None)
|
|
108
|
+
def _has_filter(name: str) -> bool:
|
|
109
|
+
return has_ffmpeg_filter(name)
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def hdr_to_sdr_chain(info: dict) -> str:
|
|
113
|
+
"""Filters that turn an HDR frame (HLG or PQ) into SDR BT.709 8-bit; '' for SDR sources.
|
|
114
|
+
|
|
115
|
+
The one conversion used by render_cut, the grade comparison, contact sheets and timeline
|
|
116
|
+
views, so the frame judged for color is the frame the cut shows. With zscale+tonemap it
|
|
117
|
+
tonemaps; without them (Homebrew ffmpeg) it converts the primaries and matrix with
|
|
118
|
+
`colorspace`, no extra contrast or saturation (a grade picked on the preview must not stack
|
|
119
|
+
on a hidden boost).
|
|
120
|
+
"""
|
|
121
|
+
if not info.get("is_hdr"):
|
|
122
|
+
return ""
|
|
123
|
+
if _has_filter("zscale") and _has_filter("tonemap"):
|
|
124
|
+
return ("zscale=t=linear:npl=100,format=gbrpf32le,zscale=p=bt709,"
|
|
125
|
+
"tonemap=tonemap=hable:desat=0,zscale=t=bt709:m=bt709:r=tv,format=yuv420p")
|
|
126
|
+
return ("format=yuv420p10le,colorspace=all=bt709:iall=bt2020:itrc=bt2020-10:"
|
|
127
|
+
"irange=tv:range=tv:fast=0:format=yuv420p")
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def graded_chain(filter_str: str) -> str:
|
|
131
|
+
"""8-bit guard before the grade and after it, so every grade sees and returns yuv420p."""
|
|
132
|
+
if not filter_str:
|
|
133
|
+
return "format=yuv420p"
|
|
134
|
+
return f"format=yuv420p,{filter_str},format=yuv420p"
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def check_filter(filter_str: str) -> str | None:
|
|
138
|
+
"""Run the filter on a tiny synthetic clip. Returns None when fine, else the error text."""
|
|
139
|
+
if not filter_str:
|
|
140
|
+
return None
|
|
141
|
+
proc = subprocess.run(
|
|
142
|
+
["ffmpeg", "-v", "error", "-nostdin", "-f", "lavfi", "-i", "testsrc2=s=64x64:d=0.1:r=10",
|
|
143
|
+
"-vf", graded_chain(filter_str), "-f", "null", "-"],
|
|
144
|
+
capture_output=True, text=True)
|
|
145
|
+
if proc.returncode != 0:
|
|
146
|
+
lines = [ln.split("] ", 1)[-1] for ln in (proc.stderr or "").splitlines() if ln.strip()]
|
|
147
|
+
useful = [ln for ln in lines if "Error applying" in ln or "No such filter" in ln or "Invalid" in ln]
|
|
148
|
+
return (useful or lines or ["erro desconhecido"])[0][:400]
|
|
149
|
+
return None
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def parse_candidates(spec: str) -> list[tuple[str, str]]:
|
|
153
|
+
"""'a=<filter>;b=<preset>;original=' -> [(label, filter)]."""
|
|
154
|
+
out: list[tuple[str, str]] = []
|
|
155
|
+
for part in spec.split(";"):
|
|
156
|
+
part = part.strip()
|
|
157
|
+
if not part:
|
|
158
|
+
continue
|
|
159
|
+
if "=" not in part:
|
|
160
|
+
label, value = part, part # bare preset name
|
|
161
|
+
else:
|
|
162
|
+
label, value = part.split("=", 1)
|
|
163
|
+
label, value = label.strip(), value.strip()
|
|
164
|
+
if not label:
|
|
165
|
+
raise ValueError(f"candidato sem nome em {part!r} (use nome=filtro)")
|
|
166
|
+
out.append((label, resolve_grade(value)))
|
|
167
|
+
if not out:
|
|
168
|
+
raise ValueError("nenhum candidato em --candidates")
|
|
169
|
+
return out
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def candidates_montage(video: Path, candidates: list[tuple[str, str]], t: float, out: Path,
|
|
173
|
+
tile_width: int = 0) -> Path:
|
|
174
|
+
from contact_sheet import BG, DIM, FG, PANEL, extract_frame, ui_font
|
|
175
|
+
|
|
176
|
+
info = probe_video(video)
|
|
177
|
+
vertical = (info.get("display_height") or 0) >= (info.get("display_width") or 0)
|
|
178
|
+
tw = tile_width or (420 if vertical else 640)
|
|
179
|
+
tw -= tw % 2
|
|
180
|
+
fps, dur = info.get("fps") or 30.0, info.get("duration") or 0.0
|
|
181
|
+
tiles: list[Image.Image] = []
|
|
182
|
+
for label, flt in candidates:
|
|
183
|
+
err = check_filter(flt)
|
|
184
|
+
if err:
|
|
185
|
+
raise ValueError(f"o candidato '{label}' tem filtro inválido: {err}")
|
|
186
|
+
# Grade at a decent size first (grades are resolution independent), then downscale.
|
|
187
|
+
# HDR sources go through the same SDR conversion as render_cut, before the grade.
|
|
188
|
+
work_w = min(1080, info.get("display_width") or 1080)
|
|
189
|
+
work_w -= work_w % 2
|
|
190
|
+
hdr = hdr_to_sdr_chain(info)
|
|
191
|
+
chain = f"scale={work_w}:-2:flags=lanczos,{hdr + ',' if hdr else ''}{graded_chain(flt)}"
|
|
192
|
+
tiles.append(extract_frame(video, t, width=tw, vf=chain, fps=fps, duration=dur, hdr=False))
|
|
193
|
+
th = tiles[0].size[1]
|
|
194
|
+
gap, pad, head, lab_h = 12, 20, (84 if info.get("is_hdr") else 60), 64
|
|
195
|
+
n = len(tiles)
|
|
196
|
+
W = pad * 2 + n * tw + (n - 1) * gap
|
|
197
|
+
H = head + th + lab_h + pad
|
|
198
|
+
sheet = Image.new("RGB", (W, H), BG)
|
|
199
|
+
d = ImageDraw.Draw(sheet)
|
|
200
|
+
d.text((pad, 18), f"{video.name} · comparação de cor em {t:.2f}s", fill=FG, font=ui_font(24, bold=True))
|
|
201
|
+
f_lab, f_small = ui_font(20, bold=True), ui_font(14)
|
|
202
|
+
if info.get("is_hdr"):
|
|
203
|
+
d.text((pad, 54), "fonte HDR: convertida para SDR do mesmo jeito que no corte", fill=DIM, font=f_small)
|
|
204
|
+
for i, ((label, flt), img) in enumerate(zip(candidates, tiles)):
|
|
205
|
+
x = pad + i * (tw + gap)
|
|
206
|
+
sheet.paste(img.resize((tw, th)) if img.size != (tw, th) else img, (x, head))
|
|
207
|
+
d.rectangle((x, head + th, x + tw - 1, head + th + lab_h - 1), fill=PANEL)
|
|
208
|
+
d.text((x + 10, head + th + 8), label, fill=FG, font=f_lab)
|
|
209
|
+
desc = flt or "sem filtro"
|
|
210
|
+
preset = next((k for k, (f, _) in PRESETS.items() if f == flt and f), None)
|
|
211
|
+
if preset:
|
|
212
|
+
desc = PRESETS[preset][1] if preset == label else f"preset {preset}"
|
|
213
|
+
while d.textlength(desc, font=f_small) > tw - 20 and len(desc) > 6:
|
|
214
|
+
desc = desc[:-4] + "…"
|
|
215
|
+
d.text((x + 10, head + th + 38), desc, fill=DIM, font=f_small)
|
|
216
|
+
out.parent.mkdir(parents=True, exist_ok=True)
|
|
217
|
+
sheet.save(out, "PNG", optimize=True)
|
|
218
|
+
return out
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
def apply_grade(src: Path, dst: Path, filter_str: str) -> None:
|
|
222
|
+
info = probe_video(src)
|
|
223
|
+
cmd = ["ffmpeg", "-y", "-v", "error", "-stats", "-nostdin", "-i", str(src), "-map", "0:v:0"]
|
|
224
|
+
if info.get("has_audio"):
|
|
225
|
+
cmd += ["-map", "0:a:0", "-c:a", "copy"]
|
|
226
|
+
hdr = hdr_to_sdr_chain(info)
|
|
227
|
+
cmd += ["-vf", (hdr + "," if hdr else "") + graded_chain(filter_str)
|
|
228
|
+
+ ",setparams=range=tv:color_primaries=bt709:color_trc=bt709:colorspace=bt709",
|
|
229
|
+
"-c:v", "libx264", "-profile:v", "high", "-preset", "medium", "-crf", "18",
|
|
230
|
+
"-pix_fmt", "yuv420p", "-color_primaries", "bt709", "-color_trc", "bt709",
|
|
231
|
+
"-colorspace", "bt709", "-color_range", "tv", "-movflags", "+faststart", str(dst)]
|
|
232
|
+
dst.parent.mkdir(parents=True, exist_ok=True)
|
|
233
|
+
proc = subprocess.run(cmd, stdout=subprocess.DEVNULL, stderr=subprocess.PIPE, text=True)
|
|
234
|
+
if proc.returncode != 0:
|
|
235
|
+
raise RuntimeError(proc.stderr[-800:])
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
def main() -> int:
|
|
239
|
+
ap = argparse.ArgumentParser(description="Presets de cor: listar, aplicar num vídeo ou comparar num quadro.")
|
|
240
|
+
ap.add_argument("input", type=Path, nargs="?", help="vídeo de entrada")
|
|
241
|
+
ap.add_argument("-o", "--output", type=Path, help="vídeo de saída (ou PNG com --candidates)")
|
|
242
|
+
ap.add_argument("--list", action="store_true", help="lista os presets")
|
|
243
|
+
ap.add_argument("--preset", help="nome do preset (veja --list)")
|
|
244
|
+
ap.add_argument("--filter", help="filtro ffmpeg cru, ex. eq=contrast=1.1")
|
|
245
|
+
ap.add_argument("--candidates", help='comparação lado a lado: "a=<filtro>;b=<preset>;original="')
|
|
246
|
+
ap.add_argument("--frame", default="0", help="tempo do quadro para --candidates (segundos, padrão 0)")
|
|
247
|
+
ap.add_argument("--tile-width", type=int, default=0, help="largura de cada quadro na comparação")
|
|
248
|
+
args = ap.parse_args()
|
|
249
|
+
|
|
250
|
+
if args.list:
|
|
251
|
+
for name, (flt, desc) in PRESETS.items():
|
|
252
|
+
print(f"{name:18s} {desc}")
|
|
253
|
+
if flt:
|
|
254
|
+
print(f"{'':18s} {flt}")
|
|
255
|
+
return 0
|
|
256
|
+
|
|
257
|
+
if not args.input:
|
|
258
|
+
ap.print_usage(sys.stderr)
|
|
259
|
+
print("erro: informe o vídeo de entrada (ou use --list)", file=sys.stderr)
|
|
260
|
+
return 2
|
|
261
|
+
from contact_sheet import abspath, default_verify_dir
|
|
262
|
+
|
|
263
|
+
src = abspath(args.input)
|
|
264
|
+
if not src.exists():
|
|
265
|
+
print(f"erro: vídeo não encontrado: {src}", file=sys.stderr)
|
|
266
|
+
return 2
|
|
267
|
+
|
|
268
|
+
if args.candidates is not None:
|
|
269
|
+
from contact_sheet import parse_time
|
|
270
|
+
try:
|
|
271
|
+
t = parse_time(args.frame)
|
|
272
|
+
cands = parse_candidates(args.candidates)
|
|
273
|
+
out = abspath(args.output) if args.output else default_verify_dir(src) / f"cores_{src.stem}.png"
|
|
274
|
+
candidates_montage(src, cands, t, out, args.tile_width)
|
|
275
|
+
except (ValueError, RuntimeError, argparse.ArgumentTypeError) as exc:
|
|
276
|
+
print(f"erro: {exc}", file=sys.stderr)
|
|
277
|
+
return 2
|
|
278
|
+
print(f"comparação salva: {out} ({len(cands)} candidatos)")
|
|
279
|
+
return 0
|
|
280
|
+
|
|
281
|
+
if not args.output:
|
|
282
|
+
print("erro: informe a saída com -o", file=sys.stderr)
|
|
283
|
+
return 2
|
|
284
|
+
if args.preset and args.filter:
|
|
285
|
+
print("erro: use --preset OU --filter, não os dois", file=sys.stderr)
|
|
286
|
+
return 2
|
|
287
|
+
try:
|
|
288
|
+
flt = get_preset(args.preset) if args.preset else resolve_grade(args.filter or "")
|
|
289
|
+
except (KeyError, ValueError) as exc:
|
|
290
|
+
print(f"erro: {exc.args[0] if exc.args else exc}", file=sys.stderr)
|
|
291
|
+
return 2
|
|
292
|
+
err = check_filter(flt)
|
|
293
|
+
if err:
|
|
294
|
+
print(f"erro: filtro inválido: {err}", file=sys.stderr)
|
|
295
|
+
return 2
|
|
296
|
+
print(f"aplicando cor em {src.name}: {flt or 'sem filtro (só recodifica)'}")
|
|
297
|
+
try:
|
|
298
|
+
apply_grade(src, abspath(args.output), flt)
|
|
299
|
+
except RuntimeError as exc:
|
|
300
|
+
print(f"erro: o ffmpeg falhou: {exc}", file=sys.stderr)
|
|
301
|
+
return 1
|
|
302
|
+
print(f"pronto: {abspath(args.output)}")
|
|
303
|
+
return 0
|
|
304
|
+
|
|
305
|
+
|
|
306
|
+
if __name__ == "__main__":
|
|
307
|
+
sys.exit(main())
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
"""Files that are only in iCloud (macOS "dataless"): check before opening them.
|
|
2
|
+
|
|
3
|
+
On a Mac with "Optimize Mac Storage", a file in iCloud Drive (Desktop and Documents too) can be only a
|
|
4
|
+
placeholder: `ls -lO` shows `compressed,dataless`. ffprobe and ffmpeg then hang for minutes without any
|
|
5
|
+
message while macOS tries to fetch it, and an ffmpeg that is killed leaves "moov atom not found". Seen on
|
|
6
|
+
29/09/2026 with the BODIES/*.MP4 of ~/Documents.
|
|
7
|
+
|
|
8
|
+
`garantir_local(paths)` asks macOS to download every such file (`brctl download`), waits a little and, if
|
|
9
|
+
something is still in the cloud, raises `SoNaNuvem` with a message in Portuguese that says what to do. On other
|
|
10
|
+
systems (or without the flag) it does nothing.
|
|
11
|
+
|
|
12
|
+
Usage from the command line (the transcribe helpers already call it):
|
|
13
|
+
uv run --directory '<SKILL>' python helpers/icloud.py <file or folder> [...] [--espera 90] [--nao-baixar]
|
|
14
|
+
Exit code 0 when everything is on the disk, 1 when something is still only in iCloud.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import argparse
|
|
20
|
+
import os
|
|
21
|
+
import shutil
|
|
22
|
+
import subprocess
|
|
23
|
+
import sys
|
|
24
|
+
import time
|
|
25
|
+
from pathlib import Path
|
|
26
|
+
|
|
27
|
+
SF_DATALESS = 0x40000000 # <sys/stat.h>: the file content is not on the disk (iCloud placeholder)
|
|
28
|
+
ESPERA_S = 90.0 # how long to wait for iCloud before giving up
|
|
29
|
+
PASSO_S = 2.0
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class SoNaNuvem(RuntimeError):
|
|
33
|
+
"""One or more files are still only in iCloud."""
|
|
34
|
+
|
|
35
|
+
def __init__(self, arquivos: list[Path], tentou: bool):
|
|
36
|
+
self.arquivos = arquivos
|
|
37
|
+
super().__init__(mensagem(arquivos, tentou))
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def so_na_nuvem(path: Path | str) -> bool:
|
|
41
|
+
"""True when the file is an iCloud placeholder (dataless) on macOS."""
|
|
42
|
+
try:
|
|
43
|
+
flags = getattr(os.stat(path), "st_flags", 0) or 0
|
|
44
|
+
except OSError:
|
|
45
|
+
return False
|
|
46
|
+
return bool(flags & SF_DATALESS)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def na_nuvem(paths) -> list[Path]:
|
|
50
|
+
return [Path(p) for p in paths if Path(p).is_file() and so_na_nuvem(p)]
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def mensagem(arquivos: list[Path], tentou: bool) -> str:
|
|
54
|
+
nomes = "\n".join(f" {p}" for p in arquivos[:12]) + (f"\n (+{len(arquivos) - 12})" if len(arquivos) > 12 else "")
|
|
55
|
+
pedido = ("Pedi ao macOS para baixar e ainda não terminou." if tentou
|
|
56
|
+
else "Nada foi aberto para não travar (o ffmpeg fica parado sem erro nesses arquivos).")
|
|
57
|
+
return (f"{len(arquivos)} arquivo(s) ainda está(ão) só no iCloud, não neste Mac:\n{nomes}\n{pedido}\n"
|
|
58
|
+
"Baixe antes de seguir: no Finder, botão direito no arquivo > Baixar Agora (espere a nuvem sumir do "
|
|
59
|
+
"ícone), ou no Terminal: brctl download '<arquivo>'. Depois rode de novo. Outro jeito: copie os vídeos "
|
|
60
|
+
"para uma pasta fora do iCloud (um disco externo, por exemplo).")
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def pedir_download(paths: list[Path]) -> bool:
|
|
64
|
+
brctl = shutil.which("brctl")
|
|
65
|
+
if not brctl:
|
|
66
|
+
return False
|
|
67
|
+
ok = False
|
|
68
|
+
for p in paths:
|
|
69
|
+
try:
|
|
70
|
+
r = subprocess.run([brctl, "download", str(p)], capture_output=True, text=True, timeout=30)
|
|
71
|
+
ok |= r.returncode == 0
|
|
72
|
+
except (OSError, subprocess.SubprocessError):
|
|
73
|
+
pass
|
|
74
|
+
return ok
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def garantir_local(paths, baixar: bool = True, espera: float = ESPERA_S, avisar=print) -> None:
|
|
78
|
+
"""Raise SoNaNuvem if any file is still only in iCloud after asking for it (at most `espera` seconds)."""
|
|
79
|
+
if sys.platform != "darwin":
|
|
80
|
+
return
|
|
81
|
+
faltam = na_nuvem(paths)
|
|
82
|
+
if not faltam:
|
|
83
|
+
return
|
|
84
|
+
if not baixar:
|
|
85
|
+
raise SoNaNuvem(faltam, tentou=False)
|
|
86
|
+
avisar(f"{len(faltam)} arquivo(s) só no iCloud (ex.: {faltam[0].name}): pedindo para baixar, espero até "
|
|
87
|
+
f"{espera:.0f} s...")
|
|
88
|
+
tentou = pedir_download(faltam)
|
|
89
|
+
if not tentou:
|
|
90
|
+
raise SoNaNuvem(faltam, tentou=False)
|
|
91
|
+
fim = time.monotonic() + espera
|
|
92
|
+
while faltam and time.monotonic() < fim:
|
|
93
|
+
time.sleep(PASSO_S)
|
|
94
|
+
faltam = [p for p in faltam if so_na_nuvem(p)]
|
|
95
|
+
if faltam:
|
|
96
|
+
raise SoNaNuvem(faltam, tentou=True)
|
|
97
|
+
avisar("baixados do iCloud; seguindo.")
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def main(argv: list[str] | None = None) -> int:
|
|
101
|
+
ap = argparse.ArgumentParser(description="Confere se os arquivos estão no disco ou só no iCloud (e pede para baixar).")
|
|
102
|
+
ap.add_argument("caminhos", nargs="+", type=Path, help="arquivos ou pastas (sem entrar nas subpastas)")
|
|
103
|
+
ap.add_argument("--espera", type=float, default=ESPERA_S, help=f"segundos esperando o iCloud (padrão {ESPERA_S:g})")
|
|
104
|
+
ap.add_argument("--nao-baixar", action="store_true", help="só confere, não pede para baixar")
|
|
105
|
+
a = ap.parse_args(argv)
|
|
106
|
+
arquivos: list[Path] = []
|
|
107
|
+
for c in a.caminhos:
|
|
108
|
+
arquivos += sorted(p for p in c.iterdir() if p.is_file()) if c.is_dir() else [c]
|
|
109
|
+
try:
|
|
110
|
+
garantir_local(arquivos, baixar=not a.nao_baixar, espera=a.espera)
|
|
111
|
+
except SoNaNuvem as exc:
|
|
112
|
+
print(str(exc), file=sys.stderr)
|
|
113
|
+
return 1
|
|
114
|
+
print(f"{len(arquivos)} arquivo(s) no disco, nenhum só no iCloud.")
|
|
115
|
+
return 0
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
if __name__ == "__main__":
|
|
119
|
+
raise SystemExit(main())
|