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,510 @@
1
+ """Template registry: finds the templates in helpers/motion/templates/*.py, checks their TEMPLATE
2
+ description and turns the params written by the agent into complete, validated values (SPEC Contract 12).
3
+
4
+ A broken template never breaks the others: its problem goes to `errors()` (PT-BR) and it is left out.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import difflib
10
+ import hashlib
11
+ import importlib.util
12
+ import json
13
+ import math
14
+ import re
15
+ import sys
16
+ import threading
17
+ from dataclasses import dataclass, field
18
+ from pathlib import Path
19
+ from types import ModuleType
20
+ from typing import Any, Callable
21
+
22
+ from . import engine as E
23
+ from .engine import MotionError
24
+
25
+ TEMPLATES_DIR = Path(__file__).resolve().parent / "templates"
26
+ ID_RE = re.compile(r"^[a-z][a-z0-9_]{1,31}$")
27
+ PARAM_RE = re.compile(r"^[a-z][a-z0-9_]{0,31}$")
28
+ PARAM_TYPES = ("number", "integer", "text", "bool", "choice", "color", "box", "list", "object")
29
+ REQUIRED_KEYS = ("id", "label_pt", "description_pt", "params", "default_duration", "default_region", "sfx")
30
+ OPTIONAL_KEYS = ("version", "use_when_pt", "notes_pt", "example", "exit", "bleed", "category", "movable")
31
+ # Optional functions of a template module: sfx, suggested_duration, settle, validate(params) (raise MotionError
32
+ # for rules between params), warnings(params, box) -> [PT-BR texts], ink(params, box) -> (x0, y0, x1, y1)
33
+ # box-local design px of what counts as drawn (a full-screen flash is not).
34
+ EXIT_MODES = ("fade", "none")
35
+ # Params every template gets for free (a template must not declare them).
36
+ COMMON_PARAMS: dict[str, dict] = {
37
+ "tema": {"type": "choice", "choices": list(E.THEME_NAMES), "default": "escuro",
38
+ "label_pt": "Visual: escuro (padrão), claro, papelaria (lilás), papel_azul, papel_verde, "
39
+ "papel_laranja, papel_grafite, pop (contorno preto e sombra dura) ou neon"},
40
+ "cor": {"type": "color", "default": None, "nullable": True,
41
+ "label_pt": "Cor de destaque só deste item (padrão: a cor do vídeo)"},
42
+ }
43
+ MAX_DURATION = 60.0
44
+
45
+
46
+ @dataclass
47
+ class Template:
48
+ id: str
49
+ label: str
50
+ description: str
51
+ params: dict[str, dict]
52
+ default_duration: float
53
+ default_region: Any
54
+ sfx: list[dict]
55
+ module: ModuleType
56
+ path: Path
57
+ source_hash: str
58
+ version: int = 1
59
+ exit: str = "fade"
60
+ bleed: float = E.DEFAULT_BLEED
61
+ use_when: str = ""
62
+ notes: str = ""
63
+ example: dict = field(default_factory=dict)
64
+ category: str = ""
65
+ movable: bool = True # False: the place means something (a target, the top bar); never slid off a face
66
+
67
+ @property
68
+ def render(self) -> Callable:
69
+ return self.module.render
70
+
71
+ def suggested_duration(self, params: dict) -> float:
72
+ fn = getattr(self.module, "suggested_duration", None)
73
+ if callable(fn):
74
+ try:
75
+ v = float(fn(params))
76
+ if math.isfinite(v) and v > 0:
77
+ return min(MAX_DURATION, v)
78
+ except Exception: # noqa: BLE001
79
+ pass
80
+ return self.default_duration
81
+
82
+ def settle(self, params: dict, fps: int) -> float | None:
83
+ fn = getattr(self.module, "settle", None)
84
+ if not callable(fn):
85
+ return None
86
+ v = fn(params, fps)
87
+ return None if v is None else float(v)
88
+
89
+ def all_params(self) -> dict[str, dict]:
90
+ return {**self.params, **COMMON_PARAMS}
91
+
92
+ def describe(self) -> dict:
93
+ return {"id": self.id, "label": self.label, "description": self.description, "use_when": self.use_when,
94
+ "notes": self.notes, "default_duration": self.default_duration,
95
+ "default_region": self.default_region, "exit": self.exit,
96
+ "params": {k: {kk: vv for kk, vv in v.items()} for k, v in self.all_params().items()},
97
+ "sfx": self.sfx, "example": self.example, "category": self.category,
98
+ "movable": self.movable}
99
+
100
+
101
+ # ------------------------------------------------------------------ schema checks
102
+
103
+ def _check_spec(tid: str, name: str, spec: Any, where: str) -> list[str]:
104
+ probs = []
105
+ if not isinstance(spec, dict):
106
+ return [f"{tid}: {where}{name} precisa ser um objeto com 'type'"]
107
+ typ = spec.get("type")
108
+ if typ not in PARAM_TYPES:
109
+ return [f"{tid}: {where}{name}.type inválido ({typ!r}); use {', '.join(PARAM_TYPES)}"]
110
+ if not where and not isinstance(spec.get("label_pt"), str):
111
+ probs.append(f"{tid}: params.{name} precisa de 'label_pt' (texto em português para a pessoa)")
112
+ if typ == "choice" and (not isinstance(spec.get("choices"), list) or not spec["choices"]):
113
+ probs.append(f"{tid}: {where}{name} é choice e precisa de 'choices'")
114
+ if typ == "list":
115
+ if "item" not in spec:
116
+ probs.append(f"{tid}: {where}{name} é list e precisa de 'item'")
117
+ else:
118
+ probs += _check_spec(tid, name + ".item", spec["item"], where or "params.")
119
+ if typ == "object":
120
+ fields = spec.get("fields")
121
+ if not isinstance(fields, dict) or not fields:
122
+ probs.append(f"{tid}: {where}{name} é object e precisa de 'fields'")
123
+ else:
124
+ for k, v in fields.items():
125
+ probs += _check_spec(tid, f"{name}.{k}", v, where or "params.")
126
+ if "default" in spec and spec["default"] is not None:
127
+ try:
128
+ coerce(spec, spec["default"], f"{tid}.params.{name}")
129
+ except MotionError as e:
130
+ probs.append(f"{tid}: o padrão de {name} não passa na própria regra ({e})")
131
+ return probs
132
+
133
+
134
+ def check_description(tid: str, desc: Any, path: Path) -> list[str]:
135
+ if not isinstance(desc, dict):
136
+ return [f"{path.name}: falta TEMPLATE = {{...}}"]
137
+ probs = []
138
+ for key in REQUIRED_KEYS:
139
+ if key not in desc:
140
+ probs.append(f"{path.name}: TEMPLATE sem '{key}'")
141
+ unknown = sorted(set(desc) - set(REQUIRED_KEYS) - set(OPTIONAL_KEYS))
142
+ if unknown:
143
+ probs.append(f"{path.name}: TEMPLATE com chaves desconhecidas: {', '.join(unknown)}")
144
+ if probs:
145
+ return probs
146
+ if desc["id"] != tid or not ID_RE.match(str(desc["id"])):
147
+ probs.append(f"{path.name}: TEMPLATE['id'] precisa ser igual ao nome do arquivo ({tid}) e usar a-z, 0-9 e _")
148
+ for key in ("label_pt", "description_pt"):
149
+ if not isinstance(desc[key], str) or not desc[key].strip():
150
+ probs.append(f"{tid}: '{key}' precisa ser um texto")
151
+ elif "\u2014" in desc[key]:
152
+ probs.append(f"{tid}: '{key}' não pode ter travessão")
153
+ params = desc["params"]
154
+ if not isinstance(params, dict):
155
+ probs.append(f"{tid}: 'params' precisa ser um objeto")
156
+ else:
157
+ for name, spec in params.items():
158
+ if not PARAM_RE.match(str(name)):
159
+ probs.append(f"{tid}: nome de parâmetro inválido: {name!r}")
160
+ if name in COMMON_PARAMS:
161
+ probs.append(f"{tid}: '{name}' é um parâmetro comum de todos os modelos; não declare de novo")
162
+ probs += _check_spec(tid, name, spec, "")
163
+ dur = desc["default_duration"]
164
+ if isinstance(dur, bool) or not isinstance(dur, (int, float)) or not 0.2 <= float(dur) <= MAX_DURATION:
165
+ probs.append(f"{tid}: default_duration precisa ser um número de segundos entre 0.2 e {MAX_DURATION:g}")
166
+ try:
167
+ E.region_box(desc["default_region"])
168
+ except MotionError as e:
169
+ probs.append(f"{tid}: default_region inválida ({e})")
170
+ probs += check_sfx_list(tid, desc["sfx"])
171
+ if desc.get("exit", "fade") not in EXIT_MODES:
172
+ probs.append(f"{tid}: exit precisa ser {' ou '.join(EXIT_MODES)}")
173
+ bleed = desc.get("bleed", E.DEFAULT_BLEED)
174
+ if isinstance(bleed, bool) or not isinstance(bleed, (int, float)) or not 0 <= bleed <= 400:
175
+ probs.append(f"{tid}: bleed precisa ser um número de 0 a 400")
176
+ for key in ("use_when_pt", "notes_pt", "category"):
177
+ if key in desc and not isinstance(desc[key], str):
178
+ probs.append(f"{tid}: '{key}' precisa ser um texto")
179
+ if "movable" in desc and not isinstance(desc["movable"], bool):
180
+ probs.append(f"{tid}: 'movable' precisa ser true ou false")
181
+ if "example" in desc and not isinstance(desc["example"], dict):
182
+ probs.append(f"{tid}: 'example' precisa ser um objeto de params")
183
+ return probs
184
+
185
+
186
+ def check_sfx_list(tid: str, cues: Any) -> list[str]:
187
+ if not isinstance(cues, list):
188
+ return [f"{tid}: 'sfx' precisa ser uma lista (pode ser vazia)"]
189
+ probs = []
190
+ for i, cue in enumerate(cues):
191
+ if not isinstance(cue, dict) or not isinstance(cue.get("name"), str) or not cue["name"].strip():
192
+ probs.append(f"{tid}: sfx[{i}] precisa de 'name'")
193
+ continue
194
+ at = cue.get("at", 0.0)
195
+ if isinstance(at, bool) or not isinstance(at, (int, float)) or at < 0:
196
+ probs.append(f"{tid}: sfx[{i}].at precisa ser segundos (0 ou mais) desde o começo do item")
197
+ vol = cue.get("volume", 1.0)
198
+ if isinstance(vol, bool) or not isinstance(vol, (int, float)) or not 0 <= vol <= 2:
199
+ probs.append(f"{tid}: sfx[{i}].volume precisa ser de 0.0 a 2.0")
200
+ length = cue.get("length")
201
+ if length is not None and (isinstance(length, bool) or not isinstance(length, (int, float)) or length <= 0):
202
+ probs.append(f"{tid}: sfx[{i}].length precisa ser segundos (mais que 0)")
203
+ extra = set(cue) - {"at", "name", "volume", "why", "length"}
204
+ if extra:
205
+ probs.append(f"{tid}: sfx[{i}] com chaves desconhecidas: {', '.join(sorted(extra))}")
206
+ return probs
207
+
208
+
209
+ # ------------------------------------------------------------------ discovery
210
+
211
+ _LOCK = threading.Lock()
212
+ _CACHE: dict[tuple, tuple[dict[str, Template], list[str]]] = {}
213
+
214
+
215
+ def _load_module(path: Path, tid: str) -> ModuleType:
216
+ name = f"motion_template_{tid}_{hashlib.sha1(str(path).encode()).hexdigest()[:8]}"
217
+ spec = importlib.util.spec_from_file_location(name, str(path))
218
+ if spec is None or spec.loader is None:
219
+ raise ImportError(f"não consegui abrir {path}")
220
+ mod = importlib.util.module_from_spec(spec)
221
+ sys.modules[name] = mod
222
+ try:
223
+ spec.loader.exec_module(mod)
224
+ except BaseException:
225
+ sys.modules.pop(name, None)
226
+ raise
227
+ return mod
228
+
229
+
230
+ def discover(dirs: list[Path] | None = None, refresh: bool = False) -> tuple[dict[str, Template], list[str]]:
231
+ """{id: Template} from every `<dir>/*.py` (files starting with '_' are skipped) and the list of problems."""
232
+ folders = [Path(d).resolve() for d in (dirs if dirs is not None else [TEMPLATES_DIR])]
233
+ key_parts = []
234
+ for folder in folders:
235
+ files = sorted(folder.glob("*.py")) if folder.is_dir() else []
236
+ key_parts.append((str(folder), tuple((f.name, f.stat().st_mtime_ns, f.stat().st_size) for f in files)))
237
+ key = tuple(key_parts)
238
+ with _LOCK:
239
+ if not refresh and key in _CACHE:
240
+ return _CACHE[key]
241
+ found: dict[str, Template] = {}
242
+ errors: list[str] = []
243
+ for folder in folders:
244
+ if not folder.is_dir():
245
+ continue
246
+ for path in sorted(folder.glob("*.py")):
247
+ if path.name.startswith("_"):
248
+ continue
249
+ tid = path.stem
250
+ try:
251
+ mod = _load_module(path, tid)
252
+ except Exception as e: # noqa: BLE001
253
+ errors.append(f"{path.name}: não carregou ({e.__class__.__name__}: {e})")
254
+ continue
255
+ desc = getattr(mod, "TEMPLATE", None)
256
+ probs = check_description(tid, desc, path)
257
+ if not callable(getattr(mod, "render", None)):
258
+ probs.append(f"{path.name}: falta a função render(frame_index, fps, params, box)")
259
+ if tid in found:
260
+ probs.append(f"{path.name}: já existe um modelo '{tid}' em {found[tid].path}")
261
+ if probs:
262
+ errors.extend(probs)
263
+ continue
264
+ found[tid] = Template(
265
+ id=tid, label=desc["label_pt"].strip(), description=desc["description_pt"].strip(),
266
+ params=desc["params"], default_duration=float(desc["default_duration"]),
267
+ default_region=desc["default_region"], sfx=list(desc["sfx"]), module=mod, path=path,
268
+ source_hash=hashlib.sha1(path.read_bytes()).hexdigest()[:16],
269
+ version=int(desc.get("version", 1)), exit=desc.get("exit", "fade"),
270
+ bleed=float(desc.get("bleed", E.DEFAULT_BLEED)), use_when=desc.get("use_when_pt", ""),
271
+ notes=desc.get("notes_pt", ""), example=dict(desc.get("example", {})),
272
+ category=desc.get("category", ""), movable=desc.get("movable", True))
273
+ _CACHE[key] = (found, errors)
274
+ return found, errors
275
+
276
+
277
+ def templates(dirs: list[Path] | None = None) -> dict[str, Template]:
278
+ return discover(dirs)[0]
279
+
280
+
281
+ def errors(dirs: list[Path] | None = None) -> list[str]:
282
+ return discover(dirs)[1]
283
+
284
+
285
+ def get(tid: str, dirs: list[Path] | None = None) -> Template:
286
+ found, errs = discover(dirs)
287
+ if tid in found:
288
+ return found[tid]
289
+ broken = [e for e in errs if e.startswith(f"{tid}.py") or e.startswith(f"{tid}:")]
290
+ if broken:
291
+ raise MotionError(f"o modelo '{tid}' está com problema: {broken[0]}")
292
+ near = difflib.get_close_matches(str(tid), list(found), n=1, cutoff=0.6)
293
+ hint = f" Você quis dizer '{near[0]}'?" if near else ""
294
+ raise MotionError(f"modelo de animação '{tid}' não existe.{hint} Modelos: {', '.join(sorted(found)) or 'nenhum'}")
295
+
296
+
297
+ # ------------------------------------------------------------------ params
298
+
299
+ def _num(value) -> float | None:
300
+ if isinstance(value, bool):
301
+ return None
302
+ if isinstance(value, (int, float)):
303
+ v = float(value)
304
+ elif isinstance(value, str):
305
+ try:
306
+ v = float(value.strip())
307
+ except ValueError:
308
+ return None
309
+ else:
310
+ return None
311
+ return v if math.isfinite(v) else None
312
+
313
+
314
+ def _show(value) -> str:
315
+ try:
316
+ text = json.dumps(value, ensure_ascii=False)
317
+ except (TypeError, ValueError):
318
+ text = repr(value)
319
+ if isinstance(value, str) and "," in value and _num(value.replace(".", "").replace(",", ".")) is not None:
320
+ text += "; use ponto no lugar da vírgula e sem separador de milhar, ex.: 1904152.87"
321
+ return text[:120]
322
+
323
+
324
+ def _fmt(v: float) -> str:
325
+ """A number the way it is written in JSON (point for decimals, no thousands separator)."""
326
+ return str(int(v)) if float(v).is_integer() else f"{v:.6g}"
327
+
328
+
329
+ def coerce(spec: dict, value: Any, where: str) -> Any:
330
+ """One value checked against its spec (numeric text is accepted for numbers)."""
331
+ typ = spec["type"]
332
+ if value is None:
333
+ if spec.get("nullable"):
334
+ return None
335
+ raise MotionError(f"{where} não pode ser vazio")
336
+ if typ in ("number", "integer"):
337
+ v = _num(value)
338
+ if v is None or (typ == "integer" and abs(v - round(v)) > 1e-9):
339
+ kind = "um número inteiro" if typ == "integer" else "um número"
340
+ raise MotionError(f"{where} precisa ser {kind} (veio {_show(value)})")
341
+ lo, hi = spec.get("min"), spec.get("max")
342
+ if (lo is not None and v < lo) or (hi is not None and v > hi):
343
+ if lo is not None and hi is not None:
344
+ rule = f"ficar entre {_fmt(lo)} e {_fmt(hi)}"
345
+ elif lo is not None:
346
+ rule = f"ser {_fmt(lo)} ou mais"
347
+ else:
348
+ rule = f"ser {_fmt(hi)} ou menos"
349
+ raise MotionError(f"{where} precisa {rule} (veio {_fmt(v)})")
350
+ return int(round(v)) if typ == "integer" else v
351
+ if typ == "text":
352
+ if isinstance(value, (int, float)) and not isinstance(value, bool):
353
+ value = str(value)
354
+ if not isinstance(value, str):
355
+ raise MotionError(f"{where} precisa ser um texto (veio {_show(value)})")
356
+ text = " ".join(value.replace("\n", " ").split()) if not spec.get("multiline") else value.strip()
357
+ mx = spec.get("max_len")
358
+ if mx is not None and len(text) > mx:
359
+ raise MotionError(f"{where} tem {len(text)} letras; o máximo é {mx}. Encurte o texto")
360
+ if spec.get("required_text") and not text:
361
+ raise MotionError(f"{where} não pode ficar vazio")
362
+ return text
363
+ if typ == "bool":
364
+ if isinstance(value, bool):
365
+ return value
366
+ if isinstance(value, str) and value.strip().lower() in ("true", "false", "sim", "não", "nao"):
367
+ return value.strip().lower() in ("true", "sim")
368
+ raise MotionError(f"{where} precisa ser true ou false (veio {_show(value)})")
369
+ if typ == "choice":
370
+ choices = spec["choices"]
371
+ if value in choices:
372
+ return value
373
+ if isinstance(value, str):
374
+ low = value.strip().lower()
375
+ for c in choices:
376
+ if isinstance(c, str) and c.lower() == low:
377
+ return c
378
+ raise MotionError(f"{where} precisa ser {', '.join(map(str, choices))} (veio {_show(value)})")
379
+ if typ == "color":
380
+ if isinstance(value, str) and value.strip().lower() in E.COLOR_NAMES:
381
+ return value.strip().lower()
382
+ try:
383
+ r, g, b, a = E.rgba(value)
384
+ except (MotionError, ValueError):
385
+ raise MotionError(f"{where} precisa ser uma cor #RRGGBB ou um nome ({', '.join(sorted(E.COLOR_NAMES))}) "
386
+ f"(veio {_show(value)})") from None
387
+ return f"#{r:02X}{g:02X}{b:02X}" + (f"{a:02X}" if a != 255 else "")
388
+ if typ == "box":
389
+ if not isinstance(value, (list, tuple)) or len(value) != 4:
390
+ raise MotionError(f"{where} precisa ser [x, y, largura, altura] em pixels da tela 1080x1920 (veio {_show(value)})")
391
+ nums = [_num(v) for v in value]
392
+ if any(n is None for n in nums):
393
+ raise MotionError(f"{where} precisa de 4 números [x, y, largura, altura] (veio {_show(value)})")
394
+ if nums[2] <= 0 or nums[3] <= 0:
395
+ raise MotionError(f"{where}: largura e altura precisam ser maiores que 0 (veio {_show(value)})")
396
+ return [float(n) for n in nums]
397
+ if typ == "list":
398
+ if isinstance(value, tuple):
399
+ value = list(value)
400
+ if not isinstance(value, list):
401
+ raise MotionError(f"{where} precisa ser uma lista (veio {_show(value)})")
402
+ lo, hi = spec.get("min_items"), spec.get("max_items")
403
+ if lo is not None and len(value) < lo:
404
+ raise MotionError(f"{where} precisa de pelo menos {lo} itens (veio {len(value)})")
405
+ if hi is not None and len(value) > hi:
406
+ raise MotionError(f"{where} aceita no máximo {hi} itens (veio {len(value)})")
407
+ return [coerce(spec["item"], v, f"{where}[{i}]") for i, v in enumerate(value)]
408
+ if typ == "object":
409
+ if not isinstance(value, dict):
410
+ raise MotionError(f"{where} precisa ser um objeto (veio {_show(value)})")
411
+ return resolve_fields(spec["fields"], value, where)
412
+ raise MotionError(f"{where}: tipo desconhecido {typ!r}")
413
+
414
+
415
+ def resolve_fields(fields: dict[str, dict], given: dict, where: str) -> dict:
416
+ unknown = [k for k in given if k not in fields]
417
+ if unknown:
418
+ k = unknown[0]
419
+ near = difflib.get_close_matches(str(k), list(fields), n=1, cutoff=0.6)
420
+ hint = f" Você quis dizer '{near[0]}'?" if near else ""
421
+ raise MotionError(f"{where}.{k} não existe.{hint} Campos: {', '.join(fields)}")
422
+ out = {}
423
+ for name, spec in fields.items():
424
+ if name in given:
425
+ out[name] = coerce(spec, given[name], f"{where}.{name}")
426
+ elif "default" in spec:
427
+ default = spec["default"]
428
+ out[name] = json.loads(json.dumps(default)) if default is not None else None
429
+ else:
430
+ raise MotionError(f"{where}.{name} é obrigatório ({spec.get('label_pt', name)})")
431
+ return out
432
+
433
+
434
+ def resolve_params(tpl: Template, params: dict | None) -> dict:
435
+ """Complete params (defaults filled, values checked). Raises MotionError with a PT-BR message."""
436
+ if params is None:
437
+ params = {}
438
+ if not isinstance(params, dict):
439
+ raise MotionError(f"params de '{tpl.id}' precisa ser um objeto (ex: {json.dumps(tpl.example, ensure_ascii=False)})")
440
+ resolved = resolve_fields(tpl.all_params(), params, f"{tpl.id}.params")
441
+ check = getattr(tpl.module, "validate", None)
442
+ if callable(check):
443
+ # rules between params (one time per list item, at most 4 words...): refused before anything renders
444
+ check(json.loads(json.dumps(resolved)))
445
+ return resolved
446
+
447
+
448
+ # ------------------------------------------------------------------ contract check (used by the tests)
449
+
450
+ def check_template(tid: str, dirs: list[Path] | None = None, params: dict | None = None,
451
+ canvases=((1080, 1920), (540, 960))) -> list[str]:
452
+ """Problems of one template when it runs (empty list = ok): the render contract, determinism, alpha,
453
+ sfx names, the example params and the Portuguese texts. Tests of every template should call this."""
454
+ from . import render as R # noqa: PLC0415
455
+
456
+ try:
457
+ tpl = get(tid, dirs)
458
+ except MotionError as e:
459
+ return [str(e)]
460
+ probs: list[str] = []
461
+ for key in ("label_pt", "description_pt", "use_when_pt", "notes_pt"):
462
+ text = tpl.module.TEMPLATE.get(key, "")
463
+ if "\u2014" in text:
464
+ probs.append(f"{tid}: {key} tem travessão")
465
+ for name, spec in tpl.params.items():
466
+ if "\u2014" in str(spec.get("label_pt", "")):
467
+ probs.append(f"{tid}: params.{name}.label_pt tem travessão")
468
+ samples = [params if params is not None else tpl.example, {}]
469
+ for sample in samples:
470
+ try:
471
+ resolved = resolve_params(tpl, sample)
472
+ except MotionError as e:
473
+ probs.append(f"{tid}: params {sample!r} recusados: {e}")
474
+ continue
475
+ dur = tpl.suggested_duration(resolved)
476
+ for canvas in canvases:
477
+ try:
478
+ item = R.plan_item(tid, sample, fps=30, canvas=canvas, duration=dur, dirs=dirs)
479
+ except MotionError as e:
480
+ probs.append(f"{tid}: plan_item falhou em {canvas}: {e}")
481
+ continue
482
+ n = item.frames
483
+ picks = sorted({0, 1, n // 4, n // 2, (3 * n) // 4, n - 1})
484
+ try:
485
+ first = {i: R.render_frame(item, i) for i in picks}
486
+ again = {i: R.render_frame(item, i) for i in reversed(picks)}
487
+ except Exception as e: # noqa: BLE001
488
+ probs.append(f"{tid}: render falhou em {canvas} ({e.__class__.__name__}: {e})")
489
+ continue
490
+ for i in picks:
491
+ if first[i].image.tobytes() != again[i].image.tobytes():
492
+ probs.append(f"{tid}: quadro {i} muda entre duas chamadas (precisa ser determinístico)")
493
+ break
494
+ if first[0].image.getchannel("A").getbbox() is None:
495
+ probs.append(f"{tid}: o quadro 0 saiu vazio em {canvas} (o som de entrada cai nele: comece a "
496
+ f"entrada um quadro antes, t + 1/fps)")
497
+ mid = first[picks[len(picks) // 2]]
498
+ if mid.image.getchannel("A").getbbox() is None:
499
+ probs.append(f"{tid}: o quadro do meio saiu vazio em {canvas}")
500
+ full = first[picks[len(picks) // 2]].image
501
+ if full.mode != "RGBA":
502
+ probs.append(f"{tid}: o render precisa devolver RGBA")
503
+ try:
504
+ item = R.plan_item(tid, sample, fps=30, duration=dur, dirs=dirs)
505
+ for cue in R.sfx_cues(item, check_names=True):
506
+ if not 0 <= cue["frame"] < item.frames:
507
+ probs.append(f"{tid}: sfx fora do item ({cue})")
508
+ except MotionError as e:
509
+ probs.append(f"{tid}: {e}")
510
+ return probs