primocode 10.15.2 → 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.
Files changed (245) hide show
  1. package/LICENSES/edit-ia-pro.txt +32 -0
  2. package/LICENSES/fontes-OFL.txt +476 -0
  3. package/LICENSES/hyperframes-Apache-2.0.txt +205 -0
  4. package/LICENSES/permanent-marker-Apache-2.0.txt +204 -0
  5. package/LICENSES/selfie-multiclass-Apache-2.0.txt +227 -0
  6. package/LICENSES/three-MIT.txt +23 -0
  7. package/LICENSES/video-use-MIT.txt +24 -0
  8. package/LICENSES/woven-sfx-CC0.txt +16 -0
  9. package/LICENSES/yunet-MIT.txt +21 -0
  10. package/README.md +2 -2
  11. package/bin/primocode.js +74 -9
  12. package/editor/LICENSE.md +11 -0
  13. package/editor/README.md +321 -0
  14. package/editor/SKILL.md +307 -0
  15. package/editor/THIRD_PARTY_NOTICES.md +228 -0
  16. package/editor/assets/baixar.json +591 -0
  17. package/editor/assets/fonts/anton/OFL.txt +93 -0
  18. package/editor/assets/fonts/caveat/OFL.txt +93 -0
  19. package/editor/assets/fonts/figtree/OFL.txt +93 -0
  20. package/editor/assets/fonts/fonts.json +10 -0
  21. package/editor/assets/fonts/montserrat/OFL.txt +93 -0
  22. package/editor/assets/fonts/permanentmarker/LICENSE.txt +202 -0
  23. package/editor/assets/models/SELFIE_MULTICLASS_LICENSE.txt +227 -0
  24. package/editor/assets/models/YUNET_LICENSE.txt +21 -0
  25. package/editor/assets/sfx/licencas/bass_drop.txt +32 -0
  26. package/editor/assets/sfx/licencas/cash.txt +76 -0
  27. package/editor/assets/sfx/licencas/charge.txt +31 -0
  28. package/editor/assets/sfx/licencas/click.txt +46 -0
  29. package/editor/assets/sfx/licencas/coin.txt +50 -0
  30. package/editor/assets/sfx/licencas/count.txt +47 -0
  31. package/editor/assets/sfx/licencas/data.txt +31 -0
  32. package/editor/assets/sfx/licencas/error.txt +32 -0
  33. package/editor/assets/sfx/licencas/flash.txt +31 -0
  34. package/editor/assets/sfx/licencas/gears.txt +82 -0
  35. package/editor/assets/sfx/licencas/keyboard.txt +48 -0
  36. package/editor/assets/sfx/licencas/metal_hit.txt +62 -0
  37. package/editor/assets/sfx/licencas/metal_riser.txt +61 -0
  38. package/editor/assets/sfx/licencas/mouse_click.txt +50 -0
  39. package/editor/assets/sfx/licencas/notification.txt +54 -0
  40. package/editor/assets/sfx/licencas/pop.txt +38 -0
  41. package/editor/assets/sfx/licencas/rewind.txt +32 -0
  42. package/editor/assets/sfx/licencas/rise.txt +46 -0
  43. package/editor/assets/sfx/licencas/sinteticos.txt +21 -0
  44. package/editor/assets/sfx/licencas/snap.txt +50 -0
  45. package/editor/assets/sfx/licencas/success.txt +40 -0
  46. package/editor/assets/sfx/licencas/synth_hit.txt +32 -0
  47. package/editor/assets/sfx/licencas/tick.txt +14 -0
  48. package/editor/assets/sfx/licencas/whoosh.txt +47 -0
  49. package/editor/assets/sfx/licencas/wind.txt +31 -0
  50. package/editor/assets/sfx/sfx.json +832 -0
  51. package/editor/assets/sfx/sfx_sinteticos.json +317 -0
  52. package/editor/assets/sfx/sfx_woven.json +387 -0
  53. package/editor/assets/sfx/woven/LICENCA.md +16 -0
  54. package/editor/fichas/briefing-do-video.md +45 -0
  55. package/editor/fichas/conferencia-da-amostra.md +56 -0
  56. package/editor/fichas/matriz-de-ganchos.csv +3 -0
  57. package/editor/fichas/padrao.json +16 -0
  58. package/editor/fichas/planilha-de-cenas.csv +4 -0
  59. package/editor/fichas/planilha-de-versoes.csv +4 -0
  60. package/editor/fichas/plano-em-blocos.md +32 -0
  61. package/editor/fichas/registro-de-materiais.json +27 -0
  62. package/editor/helpers/assets_ocr/ocr_vision.swift +41 -0
  63. package/editor/helpers/bordas_precisas.py +272 -0
  64. package/editor/helpers/camera_viva.py +426 -0
  65. package/editor/helpers/captions.py +601 -0
  66. package/editor/helpers/common.py +308 -0
  67. package/editor/helpers/compose_final.py +3555 -0
  68. package/editor/helpers/conferir_corte.py +234 -0
  69. package/editor/helpers/conferir_dados.py +405 -0
  70. package/editor/helpers/contact_sheet.py +282 -0
  71. package/editor/helpers/corte_por_onda.py +433 -0
  72. package/editor/helpers/corte_seco.py +595 -0
  73. package/editor/helpers/denoise.py +971 -0
  74. package/editor/helpers/efeitos.py +408 -0
  75. package/editor/helpers/efeitos_gerar.py +159 -0
  76. package/editor/helpers/elevenlabs_api.py +442 -0
  77. package/editor/helpers/face_layout.py +570 -0
  78. package/editor/helpers/fetch_images.py +423 -0
  79. package/editor/helpers/ganho_por_trecho.py +214 -0
  80. package/editor/helpers/gen_image.py +312 -0
  81. package/editor/helpers/gen_music.py +316 -0
  82. package/editor/helpers/gen_sfx.py +148 -0
  83. package/editor/helpers/grade.py +307 -0
  84. package/editor/helpers/icloud.py +119 -0
  85. package/editor/helpers/make_sfx.py +940 -0
  86. package/editor/helpers/matte.py +1342 -0
  87. package/editor/helpers/medir_referencia.py +2232 -0
  88. package/editor/helpers/mixar_trilha.py +354 -0
  89. package/editor/helpers/montar_corte.py +295 -0
  90. package/editor/helpers/motion/__init__.py +13 -0
  91. package/editor/helpers/motion/engine.py +1915 -0
  92. package/editor/helpers/motion/registry.py +510 -0
  93. package/editor/helpers/motion/render.py +665 -0
  94. package/editor/helpers/motion/templates/barra_progresso.py +370 -0
  95. package/editor/helpers/motion/templates/carimbo.py +132 -0
  96. package/editor/helpers/motion/templates/checklist.py +271 -0
  97. package/editor/helpers/motion/templates/comentario_cta.py +380 -0
  98. package/editor/helpers/motion/templates/contador.py +222 -0
  99. package/editor/helpers/motion/templates/destaque.py +263 -0
  100. package/editor/helpers/motion/templates/etiqueta_preco.py +461 -0
  101. package/editor/helpers/motion/templates/grafico_barras.py +510 -0
  102. package/editor/helpers/motion/templates/grafico_linha.py +462 -0
  103. package/editor/helpers/motion/templates/notificacao.py +266 -0
  104. package/editor/helpers/motion/templates/palavra_impacto.py +410 -0
  105. package/editor/helpers/motion/templates/passos.py +321 -0
  106. package/editor/helpers/motion/templates/riscado.py +298 -0
  107. package/editor/helpers/motion/templates/selo_garantia.py +150 -0
  108. package/editor/helpers/motion_render.py +241 -0
  109. package/editor/helpers/motion_sugerir.py +961 -0
  110. package/editor/helpers/noise_check.py +134 -0
  111. package/editor/helpers/pack_transcripts.py +223 -0
  112. package/editor/helpers/palavras_na_onda.py +176 -0
  113. package/editor/helpers/plano_sfx.py +839 -0
  114. package/editor/helpers/portao.py +427 -0
  115. package/editor/helpers/preview_server.py +1177 -0
  116. package/editor/helpers/rastrear_rosto.py +298 -0
  117. package/editor/helpers/render_cut.py +1256 -0
  118. package/editor/helpers/respiros.py +89 -0
  119. package/editor/helpers/sincronizar_cameras.py +571 -0
  120. package/editor/helpers/speech_regions.py +423 -0
  121. package/editor/helpers/style_catalog.json +231 -0
  122. package/editor/helpers/style_samples.py +246 -0
  123. package/editor/helpers/tela_verde.py +2113 -0
  124. package/editor/helpers/text_render.py +2059 -0
  125. package/editor/helpers/texto_na_tela.py +366 -0
  126. package/editor/helpers/timeline_view.py +292 -0
  127. package/editor/helpers/tira_de_quadros.py +113 -0
  128. package/editor/helpers/transcribe.py +734 -0
  129. package/editor/helpers/transcribe_batch.py +129 -0
  130. package/editor/helpers/trilhas.py +1146 -0
  131. package/editor/helpers/trilhas_humores.json +66 -0
  132. package/editor/helpers/valores.py +187 -0
  133. package/editor/helpers/verify_cut.py +820 -0
  134. package/editor/helpers/voice_levels.py +233 -0
  135. package/editor/helpers/voz_check.py +386 -0
  136. package/editor/helpers/voz_limpa.py +81 -0
  137. package/editor/helpers/watch_preview.py +372 -0
  138. package/editor/modelo-projeto/LEIA.md +402 -0
  139. package/editor/modelo-projeto/assets/fontes/OFL-Caveat.txt +93 -0
  140. package/editor/modelo-projeto/assets/fontes/OFL-JetBrainsMono.txt +93 -0
  141. package/editor/modelo-projeto/assets/fontes/OFL-Montserrat.txt +93 -0
  142. package/editor/modelo-projeto/assets/fontes/larguras.json +1 -0
  143. package/editor/modelo-projeto/assets/three/BufferGeometryUtils.js +1438 -0
  144. package/editor/modelo-projeto/assets/three/GLTFLoader.js +4895 -0
  145. package/editor/modelo-projeto/assets/three/LICENSE +21 -0
  146. package/editor/modelo-projeto/assets/three/ORIGEM.txt +17 -0
  147. package/editor/modelo-projeto/assets/three/RoomEnvironment.js +185 -0
  148. package/editor/modelo-projeto/assets/three/three.core.min.js +6 -0
  149. package/editor/modelo-projeto/assets/three/three.module.min.js +6 -0
  150. package/editor/modelo-projeto/build.py +4828 -0
  151. package/editor/modelo-projeto/compositions/cenas/2d/k2d_antes_depois.html +199 -0
  152. package/editor/modelo-projeto/compositions/cenas/2d/k2d_arquivo.html +232 -0
  153. package/editor/modelo-projeto/compositions/cenas/2d/k2d_busca.html +315 -0
  154. package/editor/modelo-projeto/compositions/cenas/2d/k2d_comando.html +304 -0
  155. package/editor/modelo-projeto/compositions/cenas/2d/k2d_conversa.html +217 -0
  156. package/editor/modelo-projeto/compositions/cenas/2d/k2d_esquema.html +289 -0
  157. package/editor/modelo-projeto/compositions/cenas/2d/k2d_medidor.html +249 -0
  158. package/editor/modelo-projeto/compositions/cenas/2d/k2d_notas.html +233 -0
  159. package/editor/modelo-projeto/compositions/cenas/3d/k3d_caminho.html +197 -0
  160. package/editor/modelo-projeto/compositions/cenas/3d/k3d_explodida.html +152 -0
  161. package/editor/modelo-projeto/compositions/cenas/3d/k3d_tela.html +167 -0
  162. package/editor/modelo-projeto/compositions/cenas/3d/k3d_volume.html +124 -0
  163. package/editor/modelo-projeto/compositions/cenas/3d/motor-three.js +728 -0
  164. package/editor/modelo-projeto/compositions/cenas/LEIA.md +177 -0
  165. package/editor/modelo-projeto/ferramentas/medir_fontes.py +65 -0
  166. package/editor/modelo-projeto/ferramentas/pessoa_recortada.py +176 -0
  167. package/editor/modelo-projeto/ferramentas/rastrear_maos.py +189 -0
  168. package/editor/modelo-projeto/hyperframes.json +12 -0
  169. package/editor/modelo-projeto/package.json +11 -0
  170. package/editor/modelo-projeto/projeto.json +16 -0
  171. package/editor/preview/app.css +1009 -0
  172. package/editor/preview/app.js +2122 -0
  173. package/editor/preview/index.html +307 -0
  174. package/editor/pyproject.toml +23 -0
  175. package/editor/references/3d.md +236 -0
  176. package/editor/references/animacao-catalogo.md +672 -0
  177. package/editor/references/animacao.md +498 -0
  178. package/editor/references/comandos.md +92 -0
  179. package/editor/references/corte-seco-como-funciona.md +50 -0
  180. package/editor/references/corte-seco.md +88 -0
  181. package/editor/references/corte.md +290 -0
  182. package/editor/references/direcao-por-blocos.md +218 -0
  183. package/editor/references/enquadramento.md +654 -0
  184. package/editor/references/estilos-prontos.md +355 -0
  185. package/editor/references/estudio.md +100 -0
  186. package/editor/references/exemplo-eventos.json +67 -0
  187. package/editor/references/lettering.md +252 -0
  188. package/editor/references/modelos.md +494 -0
  189. package/editor/references/phase2.md +1007 -0
  190. package/editor/references/referencia-aplicar.md +203 -0
  191. package/editor/references/referencia-medir.md +375 -0
  192. package/editor/references/revisao.md +335 -0
  193. package/editor/references/som-e-trilha.md +146 -0
  194. package/editor/references/tabela-de-sons.md +130 -0
  195. package/editor/references/variacoes.md +234 -0
  196. package/editor/uv.lock +1141 -0
  197. package/lib/act.js +38 -8
  198. package/lib/app-chat.js +97 -0
  199. package/lib/catalogo.js +7 -1
  200. package/lib/chamada-torta.js +81 -0
  201. package/lib/editor-pro.js +786 -0
  202. package/lib/entrega.js +58 -0
  203. package/lib/estudio/conferencia-visual.js +76 -0
  204. package/lib/estudio/diretor.js +6 -1
  205. package/lib/estudio/exportar.js +3 -1
  206. package/lib/estudio/gosto/tela.html +1349 -0
  207. package/lib/estudio/gosto.js +305 -0
  208. package/lib/estudio/index.js +12 -1
  209. package/lib/estudio/kit-ae/base.jsx +119 -13
  210. package/lib/estudio/kit-ae/cenas.jsx +47 -15
  211. package/lib/estudio/kit-ae/guarda.jsx +274 -0
  212. package/lib/estudio/kit-ae/primitivas.jsx +180 -24
  213. package/lib/estudio/maquina-diretor.js +70 -12
  214. package/lib/estudio/maquina-foto.js +177 -0
  215. package/lib/estudio/maquina-guarda.js +207 -0
  216. package/lib/estudio/maquina-layout.js +22 -1
  217. package/lib/estudio/maquina.js +64 -11
  218. package/lib/estudio/referencias.js +1 -1
  219. package/lib/estudio/visao/visao.swift +97 -0
  220. package/lib/estudio/visao.js +60 -0
  221. package/lib/headless.js +15 -3
  222. package/lib/local/cerebro.json +285 -7
  223. package/lib/local/motor.js +5 -2
  224. package/lib/memoria-usuario.js +164 -0
  225. package/lib/nuvem.js +33 -2
  226. package/lib/passagem.js +50 -0
  227. package/lib/perigo.js +19 -11
  228. package/lib/protecao.js +134 -0
  229. package/lib/repeticao.js +1 -1
  230. package/lib/sandbox.js +57 -9
  231. package/lib/titulo-conversa.js +46 -0
  232. package/lib/tools.js +23 -4
  233. package/lib/ui.js +2 -2
  234. package/package.json +3 -2
  235. package/skills/editar-meu-video/SKILL.md +6 -2
  236. package/skills/editar-video/SKILL.md +113 -0
  237. package/studio/pessoa.py +52 -0
  238. package/voz/primo_voz/__main__.py +55 -40
  239. package/voz/primo_voz/app.py +500 -4
  240. package/voz/primo_voz/chat.html +1773 -0
  241. package/voz/primo_voz/conversa.py +0 -26
  242. package/voz/primo_voz/hud.html +8 -2
  243. package/voz/primo_voz/hud.py +129 -5
  244. package/voz/primo_voz/identidade.py +1 -1
  245. 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())