primocode 10.16.0 → 10.17.1

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 (237) 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 +28 -8
  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 +36 -8
  198. package/lib/catalogo.js +4 -0
  199. package/lib/chamada-torta.js +81 -0
  200. package/lib/editor-pro.js +786 -0
  201. package/lib/entrega.js +58 -0
  202. package/lib/estudio/conferencia-visual.js +76 -0
  203. package/lib/estudio/diretor.js +6 -1
  204. package/lib/estudio/exportar.js +3 -1
  205. package/lib/estudio/gosto.js +45 -4
  206. package/lib/estudio/index.js +8 -5
  207. package/lib/estudio/kit-ae/base.jsx +119 -13
  208. package/lib/estudio/kit-ae/cenas.jsx +47 -15
  209. package/lib/estudio/kit-ae/guarda.jsx +274 -0
  210. package/lib/estudio/kit-ae/primitivas.jsx +180 -24
  211. package/lib/estudio/maquina-diretor.js +61 -7
  212. package/lib/estudio/maquina-foto.js +177 -0
  213. package/lib/estudio/maquina-guarda.js +207 -0
  214. package/lib/estudio/maquina-layout.js +22 -1
  215. package/lib/estudio/maquina.js +60 -11
  216. package/lib/estudio/referencias.js +1 -1
  217. package/lib/estudio/visao/visao.swift +97 -0
  218. package/lib/estudio/visao.js +60 -0
  219. package/lib/local/cerebro.json +192 -6
  220. package/lib/local/motor.js +5 -2
  221. package/lib/mcp.js +105 -7
  222. package/lib/nuvem.js +11 -1
  223. package/lib/outra-pasta.js +1 -1
  224. package/lib/passagem.js +50 -0
  225. package/lib/perigo.js +29 -12
  226. package/lib/protecao.js +134 -0
  227. package/lib/sandbox.js +11 -4
  228. package/lib/titulo-conversa.js +46 -0
  229. package/lib/tools.js +3 -0
  230. package/lib/ui.js +1 -1
  231. package/package.json +3 -2
  232. package/skills/editar-meu-video/SKILL.md +6 -2
  233. package/skills/editar-video/SKILL.md +113 -0
  234. package/studio/pessoa.py +52 -0
  235. package/voz/primo_voz/app.py +43 -4
  236. package/voz/primo_voz/chat.html +17 -6
  237. package/voz/primo_voz/painel_dados.py +134 -0
@@ -0,0 +1,461 @@
1
+ """etiqueta_preco: a price tag that pops in with a spring and settles slightly tilted, with the cents raised
2
+ like a shop price, an optional old price crossed out above it and a shine that sweeps the tag once
3
+ (SPEC Contract 12). Two looks: "etiqueta" (hang tag with a hole) and "selo" (round scalloped seal).
4
+
5
+ `_add_shine` is a soft diagonal light band on an image and `_transform` scales and rotates the small cached
6
+ tag sprite in one resampling step (a rotated group would redraw and resample its layer on every frame).
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import json
12
+ import math
13
+ from functools import lru_cache
14
+
15
+ import numpy as np
16
+ from PIL import Image
17
+
18
+ from motion import engine as E
19
+
20
+ TEMPLATE = {
21
+ "id": "etiqueta_preco",
22
+ "label_pt": "Etiqueta de preço",
23
+ "description_pt": "Uma etiqueta de preço na cor do vídeo que entra com um pulo e fica levemente inclinada, "
24
+ "com os centavos pequenos no alto. Opcional: o preço antigo riscado em cima e uma frase "
25
+ "curta embaixo do preço.",
26
+ "use_when_pt": "Quando a fala diz o preço da oferta (\"por apenas 37 reais\") ou compara com o preço antigo. "
27
+ "O preço antigo precisa ser o de verdade.",
28
+ "category": "oferta",
29
+ "version": 1,
30
+ "params": {
31
+ "price": {"type": "number", "default": 37, "min": 0, "max": 1e9,
32
+ "label_pt": "Preço (use ponto para os centavos: 37.90)"},
33
+ "old_price": {"type": "number", "default": None, "nullable": True, "min": 0, "max": 1e9,
34
+ "label_pt": "Preço antigo, riscado em cima (vazio: sem preço antigo)"},
35
+ "prefix": {"type": "text", "default": "R$", "max_len": 4, "label_pt": "Moeda antes do preço, ex.: R$ ou US$"},
36
+ "decimals": {"type": "integer", "default": None, "nullable": True, "min": 0, "max": 2,
37
+ "label_pt": "Casas decimais (vazio: centavos só quando o preço tem centavos)"},
38
+ "label": {"type": "text", "default": "por apenas", "max_len": 20,
39
+ "label_pt": "Texto pequeno em cima do preço (vazio: sem texto)"},
40
+ "note": {"type": "text", "default": "", "max_len": 26,
41
+ "label_pt": "Frase curta embaixo do preço, ex.: \"à vista no PIX\" ou \"12x de R$ 3,70\""},
42
+ "old_label": {"type": "text", "default": "de", "max_len": 8, "label_pt": "Palavra antes do preço antigo"},
43
+ "mode": {"type": "choice", "choices": ["etiqueta", "selo"], "default": "etiqueta",
44
+ "label_pt": "etiqueta (com furinho) ou selo (redondo com borda ondulada)"},
45
+ "tilt": {"type": "number", "default": -4, "min": -12, "max": 12,
46
+ "label_pt": "Inclinação final em graus (negativo sobe para a direita)"},
47
+ "shine": {"type": "bool", "default": True, "label_pt": "Brilho que passa pela etiqueta uma vez"},
48
+ "tag_delay": {"type": "number", "default": 0.62, "min": 0.45, "max": 5,
49
+ "label_pt": "Com preço antigo: segundos entre o preço antigo aparecer e a etiqueta pular "
50
+ "(para bater com a fala \"de 97... por 37\")"},
51
+ },
52
+ "default_duration": 3.0,
53
+ "default_region": "baixo",
54
+ "sfx": [{"at": 0.0, "name": "cash"}],
55
+ "example": {"price": 37.9, "old_price": 97, "note": "à vista no PIX"},
56
+ }
57
+
58
+ SPRING = (0.5, 2.4) # damping, frequency of the pop (about 16% overshoot)
59
+ SPRING_T = E.spring_settle(*SPRING) # seconds until it rests
60
+ STRIKE_AT, STRIKE_DUR = 0.30, 0.28 # the old price gets crossed
61
+ SHINE_AT, SHINE_DUR = 0.42, 0.62 # after the pop
62
+ POP_SWING = -9.0 # extra degrees at the start of the pop
63
+
64
+
65
+ def _tag_start(p: dict) -> float:
66
+ """The tag pops at the item start, or `tag_delay` after the old price (which appears at the start)."""
67
+ return p["tag_delay"] if p["old_price"] is not None else 0.0
68
+
69
+
70
+ def _decimals(v: float, fixed) -> int:
71
+ if fixed is not None:
72
+ return int(fixed)
73
+ return 2 if abs(v - round(v)) > 1e-9 else 0
74
+
75
+
76
+ def _price_parts(p: dict) -> tuple[str, str, str]:
77
+ dec = _decimals(p["price"], p["decimals"])
78
+ s = E.format_number(p["price"], dec)
79
+ whole, _, cents = s.partition(",")
80
+ return p["prefix"].strip(), whole, ("," + cents) if cents else ""
81
+
82
+
83
+ # ------------------------------------------------------------------ layout (once per item)
84
+
85
+ @lru_cache(maxsize=64)
86
+ def _layout(params_json: str, box: E.Box) -> dict:
87
+ p = json.loads(params_json)
88
+ prefix, whole, cents = _price_parts(p)
89
+ label = p["label"].strip()
90
+ note = p["note"].strip()
91
+ selo = p["mode"] == "selo"
92
+ theme = box.theme
93
+
94
+ def price_row(P: float) -> dict:
95
+ pre_s, cen_s = P * 0.30, P * 0.42
96
+ pre_w = E.measure(prefix, "sans_black", pre_s).width if prefix else 0.0
97
+ gap_p = P * 0.08 if prefix else 0.0
98
+ num_w = E.measure(whole, "sans_black", P).width
99
+ gap_c = P * 0.03 if cents else 0.0
100
+ cen_w = E.measure(cents, "sans_black", cen_s).width if cents else 0.0
101
+ return {"P": P, "pre_s": pre_s, "cen_s": cen_s, "pre_w": pre_w, "gap_p": gap_p, "num_w": num_w,
102
+ "gap_c": gap_c, "cen_w": cen_w, "w": pre_w + gap_p + num_w + gap_c + cen_w,
103
+ "cap": E.measure("0", "sans_black", P).cap}
104
+
105
+ # old price pill (above the tag)
106
+ old = None
107
+ if p["old_price"] is not None:
108
+ dec_o = _decimals(p["old_price"], p["decimals"])
109
+ o_label = p["old_label"].strip()
110
+ o_price = (prefix + " " if prefix else "") + E.format_number(p["old_price"], dec_o)
111
+ o_size = E.fit_text(f"{o_label} {o_price}", "text_bold", box.w - 80.0, 44.0, 28.0)
112
+ lab_w = E.measure(o_label, "text_bold", o_size).width if o_label else 0.0
113
+ gap = o_size * 0.28 if o_label else 0.0
114
+ pr_w = E.measure(o_price, "text_bold", o_size).width
115
+ cap = E.measure("H", "text_bold", o_size).cap
116
+ pad_x, pad_y = 30.0, 22.0
117
+ old = {"size": o_size, "label": o_label, "price": o_price, "lab_w": lab_w, "gap": gap, "pr_w": pr_w,
118
+ "cap": cap, "w": lab_w + gap + pr_w + 2 * pad_x, "h": cap + 2 * pad_y, "pad_x": pad_x}
119
+ pill_block = (old["h"] + 30.0) if old else 0.0
120
+
121
+ lab_size, note_size = 32.0, 34.0
122
+ lab_cap = E.measure("H", "text_bold", lab_size).cap if label else 0.0
123
+ note_cap = E.measure("H", "text_semibold", note_size).cap if note else 0.0
124
+ lab_w = E.measure(label.upper(), "text_bold", lab_size, tracking=2.5).width if label else 0.0
125
+ note_w = E.measure(note, "text_semibold", note_size).width if note else 0.0
126
+ gap1 = 20.0 if label else 0.0
127
+ gap2 = 24.0 if note else 0.0
128
+
129
+ avail_h = box.h - pill_block - 12.0
130
+ if selo:
131
+ # round seal: the content sits in the middle 70% of the circle
132
+ P = 150.0
133
+ row = price_row(P)
134
+ for _ in range(3):
135
+ stack_h = lab_cap + gap1 + row["cap"] + gap2 + note_cap
136
+ need = max(row["w"] / 1.28, lab_w / 1.2, note_w / 1.2) # inside the inner ring
137
+ R = max(need, stack_h / 1.25 + 10.0, 150.0)
138
+ R_max = min(box.w * 0.5 - 30.0, avail_h / 2.0)
139
+ if R <= R_max:
140
+ break
141
+ k = R_max / R
142
+ P = max(60.0, P * k)
143
+ row = price_row(P)
144
+ if label:
145
+ lab_size = max(24.0, lab_size * k)
146
+ if note:
147
+ note_size = max(24.0, note_size * k)
148
+ lab_cap = E.measure("H", "text_bold", lab_size).cap if label else 0.0
149
+ note_cap = E.measure("H", "text_semibold", note_size).cap if note else 0.0
150
+ lab_w = E.measure(label.upper(), "text_bold", lab_size, tracking=2.5).width if label else 0.0
151
+ note_w = E.measure(note, "text_semibold", note_size).width if note else 0.0
152
+ R = min(R, R_max)
153
+ tag_w = tag_h = 2 * R
154
+ stack_h = lab_cap + gap1 + row["cap"] + gap2 + note_cap
155
+ block_h = pill_block + tag_h
156
+ top = (box.h - block_h) / 2.0
157
+ tag_x = (box.w - tag_w) / 2.0
158
+ tag_y = top + pill_block
159
+ content_cx = box.w / 2.0
160
+ content_top = tag_y + R - stack_h / 2.0
161
+ shape = {"kind": "selo", "cx": content_cx, "cy": tag_y + R, "R": R}
162
+ else:
163
+ pad_t, pad_b = (34.0, 36.0) if (label or note) else (44.0, 44.0)
164
+ pad_l, pad_r = 36.0, 52.0
165
+ P = 180.0
166
+ row = price_row(P)
167
+ # fit the tilted tag (its rotated bounding box) in the box
168
+ ca, sa = math.cos(math.radians(abs(p["tilt"]))), math.sin(math.radians(abs(p["tilt"])))
169
+ for _ in range(6):
170
+ tag_h = pad_t + lab_cap + gap1 + row["cap"] + gap2 + note_cap + pad_b
171
+ point_w = tag_h * 0.30
172
+ inner = max(row["w"], lab_w, note_w)
173
+ tag_w = max(point_w + pad_l + inner + pad_r, 420.0)
174
+ rot_w, rot_h = tag_w * ca + tag_h * sa, tag_w * sa + tag_h * ca
175
+ k = min((box.w - 24.0) / rot_w, avail_h / rot_h, 1.0)
176
+ if k >= 0.995:
177
+ break
178
+ k = max(k, 0.5) * 0.99
179
+ P = max(60.0, P * k)
180
+ row = price_row(P)
181
+ if label:
182
+ lab_size = max(24.0, lab_size * k)
183
+ lab_cap = E.measure("H", "text_bold", lab_size).cap
184
+ lab_w = E.measure(label.upper(), "text_bold", lab_size, tracking=2.5).width
185
+ if note:
186
+ note_size = max(24.0, note_size * k)
187
+ note_cap = E.measure("H", "text_semibold", note_size).cap
188
+ note_w = E.measure(note, "text_semibold", note_size).width
189
+ inner = tag_w - point_w - pad_l - pad_r
190
+ rot_h = tag_w * sa + tag_h * ca
191
+ block_h = pill_block + rot_h
192
+ top = (box.h - block_h) / 2.0
193
+ tag_x = (box.w - tag_w) / 2.0
194
+ tag_y = top + pill_block + rot_h / 2.0 - tag_h / 2.0
195
+ body_x = tag_x + point_w
196
+ content_cx = body_x + pad_l + inner / 2.0
197
+ content_top = tag_y + pad_t
198
+ r = 16.0
199
+ pts = [(body_x, tag_y), (tag_x + tag_w, tag_y), (tag_x + tag_w, tag_y + tag_h), (body_x, tag_y + tag_h),
200
+ (tag_x, tag_y + tag_h / 2.0)]
201
+ shape = {"kind": "etiqueta", "pts": pts, "radius": r,
202
+ "hole": (tag_x + point_w * 0.62, tag_y + tag_h / 2.0), "hole_r": max(9.0, tag_h * 0.055)}
203
+
204
+ y = content_top
205
+ lab_base = y + lab_cap
206
+ price_top = lab_base + gap1 if label else y
207
+ price_base = price_top + row["cap"]
208
+ note_base = price_base + gap2 + note_cap
209
+ tag_cx, tag_cy = tag_x + tag_w / 2.0, tag_y + tag_h / 2.0
210
+ out = {
211
+ "prefix": prefix, "whole": whole, "cents": cents, "label": label.upper(), "note": note,
212
+ "lab_size": lab_size, "note_size": note_size, "lab_base": lab_base, "note_base": note_base,
213
+ "row": row, "price_x": content_cx - row["w"] / 2.0, "price_top": price_top, "price_base": price_base,
214
+ "content_cx": content_cx, "tag": (tag_x, tag_y, tag_w, tag_h), "pivot": (tag_cx, tag_cy),
215
+ "shape": shape, "old": old, "on_accent": theme.on_accent,
216
+ }
217
+ if old:
218
+ ox = tag_cx - old["w"] / 2.0 if selo else min(max(tag_x + 10.0, tag_cx - old["w"] / 2.0),
219
+ box.w - old["w"])
220
+ oy = top
221
+ out["old_box"] = (ox, oy, old["w"], old["h"])
222
+ tx = ox + old["pad_x"]
223
+ out["old_lab_x"] = tx
224
+ out["old_pr_x"] = tx + old["lab_w"] + old["gap"]
225
+ out["old_base"] = oy + (old["h"] + old["cap"]) / 2.0
226
+ return out
227
+
228
+
229
+ # ------------------------------------------------------------------ drawing
230
+ #
231
+ # The tag is drawn flat once, on a small canvas of its own (vector, scale 1, no tilt). Each frame of the pop
232
+ # scales and rotates that sprite in one resampling step and casts its shadow; the resting result (scale 1,
233
+ # final tilt, shadow) is cached, so the shine frames only add the light band. The engine's rotated group
234
+ # resamples its whole layer instead (about 40 ms a frame for this item).
235
+
236
+ TAG_SHADOW = E.Shadow(blur=26.0, dy=16.0, opacity=0.40)
237
+
238
+
239
+ def _draw_shape(cv: E.Canvas, L: dict) -> None:
240
+ sh = L["shape"]
241
+ ink = L["on_accent"]
242
+ if sh["kind"] == "etiqueta":
243
+ cv.fill_mask(cv.polygon_mask(sh["pts"], radius=sh["radius"]), "accent")
244
+ # a stitched line inside the edge
245
+ tx, ty, tw, th = L["tag"]
246
+ inset = 14.0
247
+ body_x = sh["pts"][0][0]
248
+ ip = [(body_x + inset * 0.45, ty + inset), (tx + tw - inset, ty + inset), (tx + tw - inset, ty + th - inset),
249
+ (body_x + inset * 0.45, ty + th - inset), (tx + inset * 2.1, ty + th / 2.0)]
250
+ ip.append(ip[0])
251
+ total = E.path_length(ip)
252
+ dash, gap = 16.0, 11.0
253
+ k = 0.0
254
+ while k < total - 2.0:
255
+ cv.polyline(E.trim_points(ip, k / total, min(total, k + dash) / total), width=3.0,
256
+ color=(ink[0], ink[1], ink[2], 60))
257
+ k += dash + gap
258
+ hx, hy = sh["hole"]
259
+ cv.circle(hx, hy, sh["hole_r"] + 7.0, fill=(ink[0], ink[1], ink[2], 40))
260
+ else:
261
+ cx, cy, R = sh["cx"], sh["cy"], sh["R"]
262
+ bumps = 20
263
+ parts = [cv.circle_mask(cx, cy, R * 0.90)]
264
+ for i in range(bumps):
265
+ a = 2 * math.pi * i / bumps
266
+ parts.append(cv.circle_mask(cx + math.cos(a) * R * 0.86, cy + math.sin(a) * R * 0.86, R * 0.14))
267
+ cv.fill_mask(E.union_masks(parts), "accent")
268
+ cv.ring(cx, cy, R * 0.74, 3.0, color=(ink[0], ink[1], ink[2], 60))
269
+
270
+
271
+ def _draw_text(cv: E.Canvas, L: dict) -> None:
272
+ row = L["row"]
273
+ ink = "on_accent"
274
+ if L["label"]:
275
+ cv.text(L["content_cx"], L["lab_base"], L["label"], "text_bold", L["lab_size"], color=ink, opacity=0.78,
276
+ anchor="ms", tracking=2.5)
277
+ x = L["price_x"]
278
+ top = L["price_top"]
279
+ if L["prefix"]:
280
+ cv.text(x, top, L["prefix"], "sans_black", row["pre_s"], color=ink, anchor="lt")
281
+ x += row["pre_w"] + row["gap_p"]
282
+ cv.text(x, L["price_base"], L["whole"], "sans_black", row["P"], color=ink)
283
+ x += row["num_w"] + row["gap_c"]
284
+ if L["cents"]:
285
+ cv.text(x, top, L["cents"], "sans_black", row["cen_s"], color=ink, anchor="lt")
286
+ if L["note"]:
287
+ cv.text(L["content_cx"], L["note_base"], L["note"], "text_semibold", L["note_size"], color=ink,
288
+ opacity=0.86, anchor="ms")
289
+
290
+
291
+ @lru_cache(maxsize=8)
292
+ def _flat_sprite(params_json: str, box: E.Box) -> tuple[Image.Image, int, int]:
293
+ """The flat tag (no tilt, no shadow, scale 1): (RGBA image, x, y) in render px of the output canvas."""
294
+ L = _layout(params_json, box)
295
+ tx, ty, tw, th = L["tag"]
296
+ m = 6.0
297
+ sub = E.Canvas(E.with_box(box, x=box.x + tx - m, y=box.y + ty - m, w=tw + 2 * m, h=th + 2 * m, bleed=0.0))
298
+ with sub.group(dx=-(tx - m), dy=-(ty - m)): # main-box coordinates inside the sprite
299
+ _draw_shape(sub, L)
300
+ _draw_text(sub, L)
301
+ if L["shape"]["kind"] == "etiqueta":
302
+ hx, hy = L["shape"]["hole"]
303
+ sub.erase_circle(hx, hy, L["shape"]["hole_r"])
304
+ return sub.image, sub.origin_x, sub.origin_y
305
+
306
+
307
+ def _transform(sprite: Image.Image, ax: int, ay: int, pivot: tuple[float, float], scale: float, angle: float,
308
+ resample) -> tuple[Image.Image, int, int]:
309
+ """Scale and rotate (clockwise degrees) a sprite around a render-px point of the output canvas.
310
+ Returns the new image and its render-px position."""
311
+ px, py = pivot
312
+ if scale < 0.85: # shrink with a proper filter first (no aliasing on the small first frames)
313
+ w = max(1, int(round(sprite.width * scale)))
314
+ h = max(1, int(round(sprite.height * scale)))
315
+ sx, sy = w / sprite.width, h / sprite.height
316
+ sprite = sprite.convert("RGBa").resize((w, h), Image.LANCZOS).convert("RGBA")
317
+ ax, ay = px + (ax - px) * sx, py + (ay - py) * sy
318
+ scale = scale / ((sx + sy) / 2.0)
319
+ th = math.radians(angle)
320
+ cs, sn = math.cos(th), math.sin(th)
321
+ corners = [(ax, ay), (ax + sprite.width, ay), (ax, ay + sprite.height), (ax + sprite.width, ay + sprite.height)]
322
+ rx = [px + scale * ((x - px) * cs - (y - py) * sn) for x, y in corners]
323
+ ry = [py + scale * ((x - px) * sn + (y - py) * cs) for x, y in corners]
324
+ bx0, by0 = int(math.floor(min(rx))) - 1, int(math.floor(min(ry))) - 1
325
+ bx1, by1 = int(math.ceil(max(rx))) + 1, int(math.ceil(max(ry))) + 1
326
+ if abs(angle) < 1e-6 and abs(scale - 1.0) < 1e-9 and float(ax).is_integer() and float(ay).is_integer():
327
+ return sprite, int(ax), int(ay)
328
+ # output pixel (X, Y) -> sprite pixel: undo the rotation and the scale around the pivot
329
+ k = 1.0 / scale
330
+ coeffs = (cs * k, sn * k, px + (cs * (bx0 - px) + sn * (by0 - py)) * k - ax,
331
+ -sn * k, cs * k, py + (-sn * (bx0 - px) + cs * (by0 - py)) * k - ay)
332
+ out = sprite.convert("RGBa").transform((bx1 - bx0, by1 - by0), Image.AFFINE, coeffs, resample=resample)
333
+ return out.convert("RGBA"), bx0, by0
334
+
335
+
336
+ def _shadow(cv_like: E.Canvas, img: Image.Image, x: int, y: int):
337
+ """Blurred alpha of a placed image, moved down (a Mask in the canvas' render px)."""
338
+ alpha = np.asarray(img.getchannel("A"), np.float32) / 255.0
339
+ sh = E.blur_mask(E.Mask(alpha, x - cv_like.origin_x, y - cv_like.origin_y), cv_like.len(TAG_SHADOW.blur),
340
+ cv_like.W, cv_like.H)
341
+ sh.y += int(round(cv_like.len(TAG_SHADOW.dy)))
342
+ return sh
343
+
344
+
345
+ @lru_cache(maxsize=8)
346
+ def _resting(params_json: str, box: E.Box):
347
+ """The tag at rest (scale 1, final tilt, high quality resampling) and its shadow mask."""
348
+ L = _layout(params_json, box)
349
+ p = json.loads(params_json)
350
+ sprite, ax, ay = _flat_sprite(params_json, box)
351
+ pv = ((box.x + L["pivot"][0]) * box.scale, (box.y + L["pivot"][1]) * box.scale)
352
+ img, x, y = _transform(sprite, ax, ay, pv, 1.0, p["tilt"], Image.BICUBIC)
353
+ return img, x, y, _shadow(E.Canvas(box), img, x, y)
354
+
355
+
356
+ def _add_shine(img: Image.Image, progress: float, width_px: float, opacity: float, accent) -> Image.Image:
357
+ """A soft light band tilted 20 degrees crossing the tag, straight alpha. It lights the accent surface only:
358
+ the printed text and the hole stay as they are."""
359
+ arr = np.asarray(img)
360
+ h, w = arr.shape[:2]
361
+ xs = np.arange(w, dtype=np.float32)[None, :] + 0.5
362
+ ys = np.arange(h, dtype=np.float32)[:, None] + 0.5
363
+ half = max(1.0, width_px / 2.0)
364
+ u = xs + (ys - h / 2.0) * math.tan(math.radians(20))
365
+ c = -half * 2 + (w + half * 4) * E.in_out_sine(progress)
366
+ band = np.clip(1.0 - np.abs(u - c) / half, 0.0, 1.0) ** 1.5
367
+ out = arr.astype(np.float32)
368
+ a_dst = out[..., 3] / 255.0
369
+ dist = np.abs(out[..., :3] - np.array(accent[:3], np.float32)).max(axis=2)
370
+ surface = np.clip(1.0 - dist / 90.0, 0.0, 1.0) # 1 on the tag color, 0 on the text
371
+ a_src = band * opacity * a_dst * surface
372
+ a_out = a_src + a_dst * (1.0 - a_src)
373
+ safe = np.where(a_out > 0, a_out, 1.0)
374
+ for c_i in range(3):
375
+ out[..., c_i] = (255.0 * a_src + out[..., c_i] * a_dst * (1.0 - a_src)) / safe
376
+ out[..., 3] = a_out * 255.0
377
+ return Image.fromarray(np.clip(out + 0.5, 0, 255).astype(np.uint8), "RGBA")
378
+
379
+
380
+ def render(frame_index: int, fps: int, params: dict, box: E.Box) -> E.Canvas:
381
+ cv = E.Canvas(box)
382
+ t = frame_index / fps
383
+ key = json.dumps(params, sort_keys=True)
384
+ L = _layout(key, box)
385
+ t_in = t + 1.0 / fps
386
+ d = 0.0
387
+ T = _tag_start(params)
388
+
389
+ # the tag: spring pop and swing into its tilt
390
+ local = t_in - T
391
+ if local > 0:
392
+ sp = 1.0 if local >= SPRING_T else E.spring(local, *SPRING)
393
+ alpha = E.clamp01(local / 0.08)
394
+ scale = 0.5 + 0.5 * sp # peaks near 1.08: a small bounce
395
+ angle = params["tilt"] + POP_SWING * (1.0 - sp)
396
+ if sp == 1.0:
397
+ img, x, y, shadow = _resting(key, box)
398
+ else:
399
+ sprite, ax, ay = _flat_sprite(key, box)
400
+ pv = ((box.x + L["pivot"][0]) * box.scale, (box.y + L["pivot"][1]) * box.scale)
401
+ img, x, y = _transform(sprite, ax, ay, pv, scale, angle, Image.BILINEAR)
402
+ shadow = _shadow(cv, img, x, y)
403
+ shine_p = E.progress(t, T + SHINE_AT, SHINE_DUR)
404
+ if params["shine"] and 0.0 < shine_p < 1.0:
405
+ img = _add_shine(img, shine_p, cv.len(L["tag"][3] * 0.55), 0.42, box.theme.accent)
406
+ cv.fill_mask(shadow, (0, 0, 0), TAG_SHADOW.opacity * alpha)
407
+ if L["shape"]["kind"] == "etiqueta": # the hole shows the video, not the shadow
408
+ hx, hy = L["shape"]["hole"]
409
+ pvx, pvy = L["pivot"]
410
+ ca, sa = math.cos(math.radians(angle)), math.sin(math.radians(angle))
411
+ dx, dy = hx - pvx, hy - pvy
412
+ cv.erase_circle(pvx + scale * (dx * ca - dy * sa), pvy + scale * (dx * sa + dy * ca),
413
+ L["shape"]["hole_r"] * scale)
414
+ s = box.scale
415
+ cv.paste_image(img, x / s - box.x, y / s - box.y, opacity=alpha)
416
+
417
+ # the old price: slides in, gets crossed, then steps back a little
418
+ old = L["old"]
419
+ if old:
420
+ q = t_in - d
421
+ if q > 0:
422
+ a = E.eased(q, 0.0, 0.18, "out_quad") * (1.0 - 0.22 * E.eased(t, T, 0.3))
423
+ s = 0.9 + 0.1 * E.out_back(E.progress(q, 0.0, 0.4), 1.6)
424
+ ox, oy, ow, oh = L["old_box"]
425
+ with cv.group(opacity=a, scale=s, dy=18.0 * (1.0 - E.eased(q, 0.0, 0.35)),
426
+ pivot=(ox + ow / 2.0, oy + oh / 2.0)):
427
+ cv.card(ox, oy, ow, oh, radius=oh / 2.0)
428
+ base = L["old_base"]
429
+ if old["label"]:
430
+ cv.text(L["old_lab_x"], base, old["label"], "text_bold", old["size"], color="muted")
431
+ cv.text(L["old_pr_x"], base, old["price"], "text_bold", old["size"], color="text")
432
+ k = E.eased(t, d + STRIKE_AT, STRIKE_DUR, "in_out_cubic")
433
+ if k > 0:
434
+ x0 = L["old_pr_x"] - 8.0
435
+ x1 = L["old_pr_x"] + old["pr_w"] + 8.0
436
+ mid = base - old["cap"] * 0.42
437
+ cv.line(x0, mid + 7.0, x1, mid - 7.0, width=7.0, color="danger", progress=k)
438
+ return cv
439
+
440
+
441
+ def sfx(params: dict, fps: int, frames: int) -> list[dict]:
442
+ d = 0.0
443
+ cues = []
444
+ if params["old_price"] is not None:
445
+ cues.append({"at": d, "name": "pop", "volume": 0.9, "why": "preço antigo"})
446
+ cues.append({"at": d + STRIKE_AT, "name": "tick", "why": "risco no preço antigo"})
447
+ cues.append({"at": _tag_start(params), "name": "cash", "why": "preço novo"})
448
+ return [{"frame": E.frame_of(c.pop("at"), fps), **c} for c in cues]
449
+
450
+
451
+ def suggested_duration(params: dict) -> float:
452
+ """The pop, the shine and about 1.6 s to read the price."""
453
+ return round(max(TEMPLATE["default_duration"], settle(params, 30) + 1.6), 2)
454
+
455
+
456
+ def settle(params: dict, fps: int) -> float:
457
+ T = _tag_start(params)
458
+ end = T + max(SPRING_T, SHINE_AT + SHINE_DUR)
459
+ if params["old_price"] is not None:
460
+ end = max(end, STRIKE_AT + STRIKE_DUR, T + 0.3)
461
+ return end + 0.05