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,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
|