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,1915 @@
1
+ """Motion design engine: drawing, easing and timing for the animated templates (SPEC Contract 12).
2
+
3
+ Everything is drawn with Pillow + numpy. Shapes get analytic anti-aliasing from signed distance
4
+ fields (exact coverage per pixel, sub-pixel positions, no supersampling); text is FreeType through
5
+ Pillow with float sizes and sub-pixel origins, using only the fonts in assets/fonts. Output is
6
+ straight-alpha RGBA, deterministic (no clock, no global random state).
7
+
8
+ Coordinates: templates draw in DESIGN pixels, box-local (origin = top-left of the box, y down).
9
+ Design space is the output canvas divided by `k = min(W, H) / 1080`, so 1080x1920 for every vertical
10
+ render (full or 540x960 preview). The Canvas multiplies by the render scale itself.
11
+
12
+ Main pieces:
13
+ Box, Theme, Canvas (rect, circle, ring, line, polyline, polygon, arrow, check, cross, text,
14
+ rich_text, image, group), fonts (font, measure, fit_text, wrap_text), easing (EASINGS, ease, spring),
15
+ timing (seconds, frame_of, progress, eased, stagger, keyframes, pulse, shake, typewriter),
16
+ paths (ellipse_points, bezier_points, trim_points, path_length, wobble), numbers (format_number,
17
+ format_money, format_percent, format_compact, auto_decimals), regions (REGIONS, region_box, safe_area).
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ import hashlib
23
+ import math
24
+ import random
25
+ import re
26
+ import sys
27
+ import threading
28
+ from contextlib import contextmanager
29
+ from dataclasses import dataclass, field, replace
30
+ from decimal import ROUND_HALF_UP, Decimal, InvalidOperation
31
+ from pathlib import Path
32
+ from typing import Callable, Iterable, Sequence
33
+
34
+ import numpy as np
35
+ from PIL import Image, ImageDraw, ImageFilter, ImageFont
36
+
37
+ HELPERS = Path(__file__).resolve().parent.parent
38
+ if str(HELPERS) not in sys.path:
39
+ sys.path.insert(0, str(HELPERS))
40
+ from common import FONTS_DIR, SAFE_ZONE_VERTICAL, hex_to_rgb, load_json # noqa: E402
41
+
42
+ ENGINE_VERSION = 2 # part of every cache key: bump when drawing changes
43
+ REF = 1080.0 # design units: the short side of the canvas is 1080
44
+ DEFAULT_ACCENT = "#FFC400"
45
+ DEFAULT_BLEED = 80.0 # design px drawn around the box (shadows, overshoot)
46
+
47
+ RGBA = tuple # (r, g, b, a) ints 0-255
48
+
49
+
50
+ class MotionError(Exception):
51
+ """A problem with a PT-BR message. `code` 2 = something to fix in the request, 1 = ffmpeg or disk trouble."""
52
+
53
+ def __init__(self, message: str, code: int = 2):
54
+ super().__init__(message)
55
+ self.code = code
56
+
57
+
58
+ # ============================================================== colors and theme
59
+
60
+ def _clamp8(v: float) -> int:
61
+ return int(max(0, min(255, round(v))))
62
+
63
+
64
+ def rgba(value, alpha: float | None = None) -> RGBA:
65
+ """'#RRGGBB' | '#RRGGBBAA' | (r, g, b) | (r, g, b, a) -> (r, g, b, a). `alpha` (0-1) replaces the alpha."""
66
+ if isinstance(value, str):
67
+ h = value.strip().lstrip("#")
68
+ try:
69
+ if len(h) == 8:
70
+ r, g, b = hex_to_rgb(h[:6])
71
+ a = int(h[6:], 16)
72
+ else:
73
+ r, g, b = hex_to_rgb(h)
74
+ a = 255
75
+ except ValueError:
76
+ raise MotionError(f"cor inválida: {value!r} (use #RRGGBB)") from None
77
+ elif isinstance(value, (tuple, list)) and len(value) in (3, 4):
78
+ r, g, b = (_clamp8(c) for c in value[:3])
79
+ a = _clamp8(value[3]) if len(value) == 4 else 255
80
+ else:
81
+ raise MotionError(f"cor inválida: {value!r} (use #RRGGBB)")
82
+ if alpha is not None:
83
+ a = _clamp8(255 * alpha)
84
+ return (r, g, b, a)
85
+
86
+
87
+ def luminance(rgb) -> float:
88
+ def lin(c):
89
+ c = c / 255.0
90
+ return c / 12.92 if c <= 0.03928 else ((c + 0.055) / 1.055) ** 2.4
91
+
92
+ return 0.2126 * lin(rgb[0]) + 0.7152 * lin(rgb[1]) + 0.0722 * lin(rgb[2])
93
+
94
+
95
+ def readable_on(bg) -> RGBA:
96
+ """Text color (near-black or white) with the best contrast on a solid color."""
97
+ lum = luminance(bg)
98
+ return (17, 17, 17, 255) if (lum + 0.05) / 0.05 >= 1.05 / (lum + 0.05) else (255, 255, 255, 255)
99
+
100
+
101
+ def mix_color(a, b, p: float) -> RGBA:
102
+ a, b = rgba(a), rgba(b)
103
+ return tuple(_clamp8(x + (y - x) * p) for x, y in zip(a, b)) # type: ignore[return-value]
104
+
105
+
106
+ def contrast(a, b) -> float:
107
+ """WCAG contrast ratio of two solid colors (1 to 21). Below about 2.4 an accent does not read on a card."""
108
+ la, lb = luminance(rgba(a) if isinstance(a, str) else a), luminance(rgba(b) if isinstance(b, str) else b)
109
+ return (max(la, lb) + 0.05) / (min(la, lb) + 0.05)
110
+
111
+
112
+ @dataclass(frozen=True)
113
+ class Theme:
114
+ """Palette of one render. `dark` (default) is the glass card of the "vidro" hook; `light` a white card."""
115
+ name: str
116
+ accent: RGBA
117
+ on_accent: RGBA
118
+ card: RGBA
119
+ card_border: RGBA
120
+ text: RGBA
121
+ muted: RGBA
122
+ ink: RGBA
123
+ track: RGBA
124
+ danger: RGBA
125
+ success: RGBA
126
+ shadow: RGBA
127
+ white: RGBA = (255, 255, 255, 255)
128
+ black: RGBA = (0, 0, 0, 255)
129
+ fonts: tuple[tuple[str, str], ...] = () # font key -> key the theme draws it with
130
+ clay: RGBA | None = None # papel: the solid "clay" base under every card
131
+ style: str = "vidro" # how card() draws: vidro, papel, pop, neon
132
+ dark: bool = True # dark card (white text): templates pick their greys by it
133
+
134
+ def get(self, name: str) -> RGBA:
135
+ return getattr(self, name)
136
+
137
+
138
+ # Looks of the animations. "escuro" and "claro" are the original glass and white cards. The "papel" family is
139
+ # the Vit VSL look (off-white paper card on a solid colored "clay" base, dark ink, Montserrat) in several
140
+ # colors, so it is not only lilac: (clay base, ink, default accent, track, hand font). "pop" is a cream card
141
+ # with a thick black outline and a hard offset shadow (cut-out look); "neon" a dark card with a glowing
142
+ # border in the accent color.
143
+ PAPEL = {
144
+ "papelaria": ((199, 183, 239), (53, 44, 64), "#665080", (230, 221, 248), "hand"), # lilás (Vit)
145
+ "papel_azul": ((169, 199, 242), (31, 42, 68), "#2457C5", (219, 231, 250), "marker"),
146
+ "papel_verde": ((168, 220, 192), (30, 58, 47), "#1F8A5B", (217, 240, 227), "marker"),
147
+ "papel_laranja": ((246, 194, 155), (61, 42, 30), "#E0591F", (251, 227, 209), "hand"),
148
+ "papel_grafite": ((201, 204, 209), (29, 31, 35), "#D6452B", (228, 230, 233), "marker"),
149
+ }
150
+ THEME_NAMES = ("escuro", "claro", *PAPEL, "pop", "neon")
151
+ PAPELARIA_ACCENT = PAPEL["papelaria"][2]
152
+ PAPELARIA_FONTS = (("text_bold", "sans_extrabold"), ("text_semibold", "sans_bold"), ("marker", "hand"),
153
+ ("display_condensed", "sans_black"))
154
+ POP_ACCENT, NEON_ACCENT = "#E8412C", "#22D3EE"
155
+ COLOR_NAMES = { # names a template (or a "color" param) may use instead of #RRGGBB
156
+ "accent": "accent", "destaque": "accent", "on_accent": "on_accent", "card": "card", "card_border": "card_border",
157
+ "text": "text", "texto": "text", "muted": "muted", "ink": "ink", "track": "track", "danger": "danger",
158
+ "perigo": "danger", "vermelho": "danger", "success": "success", "sucesso": "success", "verde": "success",
159
+ "shadow": "shadow", "white": "white", "branco": "white", "black": "black", "preto": "black",
160
+ }
161
+
162
+
163
+ def make_theme(accent=DEFAULT_ACCENT, name: str = "escuro") -> Theme:
164
+ def own(default: str) -> RGBA: # the video's accent, or the look's own when the video keeps the default
165
+ return rgba(default if rgba(accent) == rgba(DEFAULT_ACCENT) else accent)
166
+
167
+ if name in PAPEL:
168
+ clay, ink, acc_default, track, hand = PAPEL[name]
169
+ acc = own(acc_default)
170
+ fonts = tuple((k, hand if k == "marker" else v) for k, v in PAPELARIA_FONTS)
171
+ return Theme(name=name, accent=acc, on_accent=readable_on(acc), card=(255, 253, 253, 250),
172
+ card_border=(*clay, 255), text=(*ink, 255), muted=(*ink, 190), ink=(*ink, 255),
173
+ track=(*track, 255), danger=(229, 62, 45, 255), success=(46, 158, 91, 255),
174
+ shadow=(*ink, 255), fonts=fonts, clay=(*clay, 255), style="papel", dark=False)
175
+ if name == "pop":
176
+ acc = own(POP_ACCENT)
177
+ return Theme(name="pop", accent=acc, on_accent=readable_on(acc), card=(255, 248, 231, 255),
178
+ card_border=(17, 17, 17, 255), text=(17, 17, 17, 255), muted=(17, 17, 17, 175),
179
+ ink=(17, 17, 17, 255), track=(17, 17, 17, 40), danger=(235, 45, 45, 255),
180
+ success=(22, 163, 74, 255), shadow=(17, 17, 17, 255), fonts=PAPELARIA_FONTS[:2] +
181
+ (("display_condensed", "sans_black"),), style="pop", dark=False)
182
+ if name == "neon":
183
+ acc = own(NEON_ACCENT)
184
+ return Theme(name="neon", accent=acc, on_accent=readable_on(acc), card=(10, 10, 18, 238),
185
+ card_border=acc, text=(255, 255, 255, 255), muted=(255, 255, 255, 190),
186
+ ink=(17, 17, 17, 255), track=(255, 255, 255, 38), danger=(255, 69, 58, 255),
187
+ success=(52, 211, 104, 255), shadow=(0, 0, 0, 255), style="neon", dark=True)
188
+ acc = rgba(accent)
189
+ if name == "claro":
190
+ return Theme(name="claro", accent=acc, on_accent=readable_on(acc), card=(255, 255, 255, 246),
191
+ card_border=(0, 0, 0, 22), text=(17, 17, 17, 255), muted=(17, 17, 17, 165),
192
+ ink=(17, 17, 17, 255), track=(17, 17, 17, 34), danger=(235, 45, 45, 255),
193
+ success=(22, 163, 74, 255), shadow=(0, 0, 0, 255), dark=False)
194
+ if name != "escuro":
195
+ raise MotionError(f"tema '{name}' não existe (use {' ou '.join(THEME_NAMES)})")
196
+ return Theme(name="escuro", accent=acc, on_accent=readable_on(acc), card=(18, 18, 20, 226),
197
+ card_border=(255, 255, 255, 30), text=(255, 255, 255, 255), muted=(255, 255, 255, 196),
198
+ ink=(17, 17, 17, 255), track=(255, 255, 255, 44), danger=(255, 69, 58, 255),
199
+ success=(52, 211, 104, 255), shadow=(0, 0, 0, 255))
200
+
201
+
202
+ # ============================================================== regions and the box
203
+
204
+ # Named regions on the vertical design canvas (1080x1920): x, y, w, h. They sit inside the platform-safe
205
+ # band (SAFE_ZONE_VERTICAL: top 210, bottom 1550) and clear of the right rail (140 px on each side
206
+ # keeps the content centered and off the like/comment column).
207
+ REGIONS: dict[str, tuple[float, float, float, float]] = {
208
+ "topo": (140.0, 250.0, 800.0, 520.0),
209
+ "centro": (140.0, 640.0, 800.0, 640.0),
210
+ "baixo": (140.0, 1000.0, 800.0, 520.0),
211
+ "faixa_topo": (90.0, 230.0, 900.0, 300.0),
212
+ "tela": (90.0, 230.0, 900.0, 1300.0),
213
+ }
214
+ REGION_LABELS = {"topo": "parte de cima", "centro": "meio da tela", "baixo": "parte de baixo",
215
+ "faixa_topo": "faixa no alto (notificação)", "tela": "área segura inteira"}
216
+ # English names accepted everywhere a region is given (edit_data.json "motion" uses them)
217
+ REGION_ALIASES = {"top": "topo", "middle": "centro", "center": "centro", "bottom": "baixo", "screen": "tela",
218
+ "top_band": "faixa_topo"}
219
+
220
+
221
+ def design_size(canvas: Sequence[int]) -> tuple[float, float]:
222
+ k = min(canvas[0], canvas[1]) / REF
223
+ return canvas[0] / k, canvas[1] / k
224
+
225
+
226
+ def canvas_shape(canvas: Sequence[int]) -> str:
227
+ w, h = canvas[0], canvas[1]
228
+ if h > w * 1.05:
229
+ return "vertical"
230
+ if w > h * 1.05:
231
+ return "horizontal"
232
+ return "square"
233
+
234
+
235
+ def safe_area(canvas: Sequence[int] = (1080, 1920)) -> tuple[float, float, float, float]:
236
+ """(x0, y0, x1, y1) in design px where content is safe from the platform UI."""
237
+ dw, dh = design_size(canvas)
238
+ if canvas_shape(canvas) == "vertical":
239
+ top = SAFE_ZONE_VERTICAL["top"] * dh / 1920.0
240
+ bottom = SAFE_ZONE_VERTICAL["bottom"] * dh / 1920.0
241
+ return 90.0, top, dw - 90.0, bottom
242
+ return 0.05 * dw, 0.06 * dh, 0.95 * dw, 0.94 * dh
243
+
244
+
245
+ def region_box(spec, canvas: Sequence[int] = (1080, 1920)) -> tuple[float, float, float, float]:
246
+ """A named region or [x, y, w, h] (design px of a vertical 1080x1920 canvas) -> (x, y, w, h) on `canvas`.
247
+
248
+ Named regions keep their place relative to the safe area on horizontal and square canvases."""
249
+ dw, dh = design_size(canvas)
250
+ if isinstance(spec, dict) and set(spec) == {"x", "y", "w", "h"}:
251
+ spec = [spec["x"], spec["y"], spec["w"], spec["h"]]
252
+ if isinstance(spec, str):
253
+ spec = REGION_ALIASES.get(spec.strip().lower(), spec.strip().lower())
254
+ if spec not in REGIONS:
255
+ raise MotionError(f"região '{spec}' não existe (use {', '.join(REGIONS)} ou [x, y, largura, altura])")
256
+ x, y, w, h = REGIONS[spec]
257
+ if canvas_shape(canvas) == "vertical":
258
+ return x, y * dh / 1920.0, w, h * dh / 1920.0
259
+ sx0, sy0, sx1, sy1 = safe_area(canvas)
260
+ v0, v1 = REGIONS["tela"][1], REGIONS["tela"][1] + REGIONS["tela"][3]
261
+ fy0 = (y - v0) / (v1 - v0)
262
+ fy1 = (y + h - v0) / (v1 - v0)
263
+ ww = min(w, sx1 - sx0)
264
+ return (dw - ww) / 2.0, sy0 + fy0 * (sy1 - sy0), ww, (fy1 - fy0) * (sy1 - sy0)
265
+ try:
266
+ x, y, w, h = (float(v) for v in spec)
267
+ except (TypeError, ValueError):
268
+ raise MotionError(f"região inválida: {spec!r} (use um nome ou [x, y, largura, altura])") from None
269
+ if not all(math.isfinite(v) for v in (x, y, w, h)) or w < 20 or h < 20:
270
+ raise MotionError(f"região inválida: {spec!r} (largura e altura de pelo menos 20)")
271
+ if x + w <= 0 or y + h <= 0 or x >= dw or y >= dh:
272
+ raise MotionError(f"região {spec!r} fica fora da tela ({dw:.0f}x{dh:.0f})")
273
+ return x, y, w, h
274
+
275
+
276
+ @dataclass(frozen=True)
277
+ class Box:
278
+ """Where and how one template instance is drawn. Geometry in design px of the output canvas."""
279
+ x: float
280
+ y: float
281
+ w: float
282
+ h: float
283
+ scale: float = 1.0 # render px per design px
284
+ canvas: tuple[int, int] = (1080, 1920) # render canvas in px
285
+ fps: int = 30
286
+ frames: int = 90 # length of the item on screen
287
+ theme: Theme = field(default_factory=make_theme)
288
+ bleed: float = DEFAULT_BLEED
289
+
290
+ @property
291
+ def cx(self) -> float: # box-local center
292
+ return self.w / 2.0
293
+
294
+ @property
295
+ def cy(self) -> float:
296
+ return self.h / 2.0
297
+
298
+ @property
299
+ def duration(self) -> float:
300
+ return self.frames / float(self.fps)
301
+
302
+ @property
303
+ def accent(self) -> RGBA:
304
+ return self.theme.accent
305
+
306
+ @property
307
+ def design(self) -> tuple[float, float]:
308
+ return design_size(self.canvas)
309
+
310
+ @property
311
+ def safe(self) -> tuple[float, float, float, float]:
312
+ """Safe area in BOX-LOCAL design px (x0, y0, x1, y1)."""
313
+ x0, y0, x1, y1 = safe_area(self.canvas)
314
+ return x0 - self.x, y0 - self.y, x1 - self.x, y1 - self.y
315
+
316
+ def to_canvas(self, u: float, v: float) -> tuple[float, float]:
317
+ """Box-local design point -> design point on the whole canvas."""
318
+ return self.x + u, self.y + v
319
+
320
+ def from_canvas(self, x: float, y: float) -> tuple[float, float]:
321
+ """Canvas design point (e.g. a target in 1080x1920) -> box-local design point."""
322
+ return x - self.x, y - self.y
323
+
324
+
325
+ def make_box(region, canvas: Sequence[int] = (1080, 1920), fps: int = 30, frames: int = 90,
326
+ accent=DEFAULT_ACCENT, theme: str = "escuro", bleed: float = DEFAULT_BLEED) -> Box:
327
+ canvas = (int(canvas[0]), int(canvas[1]))
328
+ x, y, w, h = region_box(region, canvas)
329
+ return Box(x=x, y=y, w=w, h=h, scale=min(canvas) / REF, canvas=canvas, fps=int(fps), frames=int(frames),
330
+ theme=make_theme(accent, theme), bleed=float(bleed))
331
+
332
+
333
+ # ============================================================== easing
334
+
335
+ def clamp01(x: float) -> float:
336
+ return 0.0 if x <= 0.0 else 1.0 if x >= 1.0 else float(x)
337
+
338
+
339
+ def lerp(a: float, b: float, p: float) -> float:
340
+ return a + (b - a) * p
341
+
342
+
343
+ def remap(x: float, a0: float, a1: float, b0: float = 0.0, b1: float = 1.0, clamp: bool = True) -> float:
344
+ p = (x - a0) / (a1 - a0) if a1 != a0 else (1.0 if x >= a1 else 0.0)
345
+ if clamp:
346
+ p = clamp01(p)
347
+ return b0 + (b1 - b0) * p
348
+
349
+
350
+ def linear(t: float) -> float:
351
+ return clamp01(t)
352
+
353
+
354
+ def in_quad(t: float) -> float:
355
+ t = clamp01(t)
356
+ return t * t
357
+
358
+
359
+ def out_quad(t: float) -> float:
360
+ t = clamp01(t)
361
+ return 1 - (1 - t) * (1 - t)
362
+
363
+
364
+ def in_out_quad(t: float) -> float:
365
+ t = clamp01(t)
366
+ return 2 * t * t if t < 0.5 else 1 - (-2 * t + 2) ** 2 / 2
367
+
368
+
369
+ def in_cubic(t: float) -> float:
370
+ return clamp01(t) ** 3
371
+
372
+
373
+ def out_cubic(t: float) -> float:
374
+ return 1 - (1 - clamp01(t)) ** 3
375
+
376
+
377
+ def in_out_cubic(t: float) -> float:
378
+ t = clamp01(t)
379
+ return 4 * t ** 3 if t < 0.5 else 1 - (-2 * t + 2) ** 3 / 2
380
+
381
+
382
+ def out_quart(t: float) -> float:
383
+ return 1 - (1 - clamp01(t)) ** 4
384
+
385
+
386
+ def out_quint(t: float) -> float:
387
+ return 1 - (1 - clamp01(t)) ** 5
388
+
389
+
390
+ def out_expo(t: float) -> float:
391
+ t = clamp01(t)
392
+ if t >= 1.0:
393
+ return 1.0
394
+ # normalized so out_expo(1) == 1 exactly
395
+ return (1 - 2 ** (-10 * t)) / (1 - 2 ** -10)
396
+
397
+
398
+ def in_out_sine(t: float) -> float:
399
+ return 0.5 - 0.5 * math.cos(math.pi * clamp01(t))
400
+
401
+
402
+ def out_sine(t: float) -> float:
403
+ return math.sin(math.pi / 2 * clamp01(t))
404
+
405
+
406
+ def out_back(t: float, overshoot: float = 1.70158) -> float:
407
+ t = clamp01(t) - 1.0
408
+ return 1 + (overshoot + 1) * t ** 3 + overshoot * t ** 2
409
+
410
+
411
+ def in_back(t: float, overshoot: float = 1.70158) -> float:
412
+ t = clamp01(t)
413
+ return (overshoot + 1) * t ** 3 - overshoot * t ** 2
414
+
415
+
416
+ def out_elastic(t: float) -> float:
417
+ t = clamp01(t)
418
+ if t in (0.0, 1.0):
419
+ return t
420
+ return 2 ** (-10 * t) * math.sin((t * 10 - 0.75) * (2 * math.pi / 3)) + 1
421
+
422
+
423
+ def out_bounce(t: float) -> float:
424
+ t = clamp01(t)
425
+ n1, d1 = 7.5625, 2.75
426
+ if t < 1 / d1:
427
+ return n1 * t * t
428
+ if t < 2 / d1:
429
+ t -= 1.5 / d1
430
+ return n1 * t * t + 0.75
431
+ if t < 2.5 / d1:
432
+ t -= 2.25 / d1
433
+ return n1 * t * t + 0.9375
434
+ t -= 2.625 / d1
435
+ return n1 * t * t + 0.984375
436
+
437
+
438
+ def spring(t: float, damping: float = 0.5, frequency: float = 2.2) -> float:
439
+ """Damped spring from 0 to 1. `t` in SECONDS since the start, `frequency` in Hz, `damping` 0-1
440
+ (0.3 bouncy, 0.5 lively, 0.8 soft, 1 no overshoot). spring(0) == 0; it settles on 1."""
441
+ if t <= 0:
442
+ return 0.0
443
+ w0 = 2 * math.pi * frequency
444
+ z = min(max(damping, 0.01), 1.0)
445
+ if z >= 0.999:
446
+ return 1 - math.exp(-w0 * t) * (1 + w0 * t)
447
+ wd = w0 * math.sqrt(1 - z * z)
448
+ return 1 - math.exp(-z * w0 * t) * (math.cos(wd * t) + z * w0 / wd * math.sin(wd * t))
449
+
450
+
451
+ def spring_settle(damping: float = 0.5, frequency: float = 2.2, tolerance: float = 0.002) -> float:
452
+ """Seconds until the spring stays within `tolerance` of 1 (envelope bound)."""
453
+ w0 = 2 * math.pi * frequency
454
+ z = min(max(damping, 0.01), 1.0)
455
+ if z >= 0.999:
456
+ t = 0.0
457
+ while abs(1 - spring(t, damping, frequency)) > tolerance and t < 30:
458
+ t += 0.005
459
+ return t
460
+ return math.log(1.0 / (tolerance * math.sqrt(1 - z * z))) / (z * w0)
461
+
462
+
463
+ EASINGS: dict[str, Callable[[float], float]] = {
464
+ "linear": linear, "in_quad": in_quad, "out_quad": out_quad, "in_out_quad": in_out_quad,
465
+ "in_cubic": in_cubic, "out_cubic": out_cubic, "in_out_cubic": in_out_cubic, "out_quart": out_quart,
466
+ "out_quint": out_quint, "out_expo": out_expo, "in_out_sine": in_out_sine, "out_sine": out_sine,
467
+ "out_back": out_back, "in_back": in_back, "out_elastic": out_elastic, "out_bounce": out_bounce,
468
+ }
469
+
470
+
471
+ def ease(name_or_fn, t: float) -> float:
472
+ fn = EASINGS[name_or_fn] if isinstance(name_or_fn, str) else name_or_fn
473
+ return fn(t)
474
+
475
+
476
+ # ============================================================== timing
477
+
478
+ def seconds(frame_index: int, fps: float) -> float:
479
+ return frame_index / float(fps)
480
+
481
+
482
+ def frame_of(t: float, fps: float) -> int:
483
+ """Seconds -> frame with the same rounding compose_final uses (Plan.fr)."""
484
+ return int(round(float(t) * fps + 1e-6))
485
+
486
+
487
+ def progress(t: float, start: float, duration: float) -> float:
488
+ """0 before `start`, 1 after `start + duration`, linear in between."""
489
+ if duration <= 0:
490
+ return 1.0 if t >= start else 0.0
491
+ return clamp01((t - start) / duration)
492
+
493
+
494
+ def eased(t: float, start: float, duration: float, fn="out_cubic") -> float:
495
+ return ease(fn, progress(t, start, duration))
496
+
497
+
498
+ def stagger(index: int, step: float, start: float = 0.0) -> float:
499
+ """Start time of the index-th element of a list that enters one by one."""
500
+ return start + index * step
501
+
502
+
503
+ def keyframes(t: float, keys: Sequence[tuple[float, float]], fn="in_out_cubic") -> float:
504
+ """Value at time t from [(time, value), ...] (sorted), eased between neighbours; held outside."""
505
+ if not keys:
506
+ return 0.0
507
+ if t <= keys[0][0]:
508
+ return float(keys[0][1])
509
+ for (t0, v0), (t1, v1) in zip(keys, keys[1:]):
510
+ if t <= t1:
511
+ return v0 + (v1 - v0) * ease(fn, (t - t0) / (t1 - t0) if t1 > t0 else 1.0)
512
+ return float(keys[-1][1])
513
+
514
+
515
+ def pulse(t: float, start: float, duration: float) -> float:
516
+ """0 -> 1 -> 0 (half sine) inside [start, start + duration]."""
517
+ p = progress(t, start, duration)
518
+ return math.sin(math.pi * p) if 0 < p < 1 else 0.0
519
+
520
+
521
+ def stable_seed(*parts) -> int:
522
+ return int.from_bytes(hashlib.sha1(repr(parts).encode("utf-8")).digest()[:8], "big")
523
+
524
+
525
+ def rng(*parts) -> random.Random:
526
+ """Deterministic random generator for one purpose (never the global one)."""
527
+ return random.Random(stable_seed(*parts))
528
+
529
+
530
+ def shake(t: float, start: float, duration: float, amplitude: float = 14.0, frequency: float = 18.0,
531
+ seed=0) -> tuple[float, float]:
532
+ """Decaying pseudo-random shake (dx, dy) in design px, deterministic by `seed`."""
533
+ p = progress(t, start, duration)
534
+ if p <= 0 or p >= 1:
535
+ return 0.0, 0.0
536
+ r = rng("shake", seed)
537
+ ph = [r.uniform(0, 2 * math.pi) for _ in range(4)]
538
+ env = (1 - p) ** 2
539
+ tt = (t - start) * 2 * math.pi * frequency
540
+ dx = math.sin(tt + ph[0]) * 0.7 + math.sin(tt * 1.7 + ph[1]) * 0.3
541
+ dy = math.sin(tt * 1.3 + ph[2]) * 0.7 + math.sin(tt * 2.1 + ph[3]) * 0.3
542
+ return amplitude * env * dx, amplitude * env * dy
543
+
544
+
545
+ def typewriter(text: str, t: float, start: float, chars_per_second: float = 14.0) -> str:
546
+ n = int(math.floor(max(0.0, t - start) * chars_per_second + 1e-6))
547
+ return text[:max(0, min(len(text), n))]
548
+
549
+
550
+ # ============================================================== paths
551
+
552
+ Point = tuple[float, float]
553
+
554
+
555
+ def path_length(points: Sequence[Point]) -> float:
556
+ return sum(math.dist(a, b) for a, b in zip(points, points[1:]))
557
+
558
+
559
+ def trim_points(points: Sequence[Point], start: float = 0.0, end: float = 1.0) -> list[Point]:
560
+ """The part of a polyline between two fractions of its length (for drawing it by progress)."""
561
+ pts = [tuple(map(float, p)) for p in points]
562
+ if len(pts) < 2:
563
+ return list(pts)
564
+ start, end = clamp01(start), clamp01(end)
565
+ if end <= start:
566
+ return []
567
+ total = path_length(pts)
568
+ if total <= 0:
569
+ return [pts[0], pts[0]]
570
+ a, b = start * total, end * total
571
+ out: list[Point] = []
572
+ run = 0.0
573
+ for p, q in zip(pts, pts[1:]):
574
+ seg = math.dist(p, q)
575
+ s0, s1 = run, run + seg
576
+ run = s1
577
+ if s1 < a or seg <= 0:
578
+ continue
579
+ if s0 > b:
580
+ break
581
+ u0 = max(0.0, (a - s0) / seg)
582
+ u1 = min(1.0, (b - s0) / seg)
583
+ p0 = (p[0] + (q[0] - p[0]) * u0, p[1] + (q[1] - p[1]) * u0)
584
+ p1 = (p[0] + (q[0] - p[0]) * u1, p[1] + (q[1] - p[1]) * u1)
585
+ if not out:
586
+ out.append(p0)
587
+ out.append(p1)
588
+ if len(out) == 1:
589
+ out.append(out[0])
590
+ return out
591
+
592
+
593
+ def point_at(points: Sequence[Point], fraction: float) -> tuple[Point, float]:
594
+ """(point, direction angle in radians) at a fraction of the polyline length."""
595
+ pts = trim_points(points, 0.0, max(1e-6, clamp01(fraction)))
596
+ if len(pts) < 2:
597
+ return (tuple(points[0]) if points else (0.0, 0.0)), 0.0
598
+ (x0, y0), (x1, y1) = pts[-2], pts[-1]
599
+ if (x0, y0) == (x1, y1) and len(points) >= 2:
600
+ (x0, y0), (x1, y1) = points[0], points[1]
601
+ return pts[-1], math.atan2(y1 - y0, x1 - x0)
602
+
603
+
604
+ def bezier_points(p0: Point, p1: Point, p2: Point, p3: Point | None = None, n: int = 32) -> list[Point]:
605
+ """Quadratic (3 points) or cubic (4 points) Bezier sampled into n+1 points."""
606
+ out = []
607
+ for i in range(n + 1):
608
+ u = i / n
609
+ if p3 is None:
610
+ x = (1 - u) ** 2 * p0[0] + 2 * (1 - u) * u * p1[0] + u * u * p2[0]
611
+ y = (1 - u) ** 2 * p0[1] + 2 * (1 - u) * u * p1[1] + u * u * p2[1]
612
+ else:
613
+ x = ((1 - u) ** 3 * p0[0] + 3 * (1 - u) ** 2 * u * p1[0] + 3 * (1 - u) * u * u * p2[0] + u ** 3 * p3[0])
614
+ y = ((1 - u) ** 3 * p0[1] + 3 * (1 - u) ** 2 * u * p1[1] + 3 * (1 - u) * u * u * p2[1] + u ** 3 * p3[1])
615
+ out.append((x, y))
616
+ return out
617
+
618
+
619
+ def ellipse_points(cx: float, cy: float, rx: float, ry: float, start_deg: float = -90.0, sweep_deg: float = 360.0,
620
+ n: int | None = None, wobble: float = 0.0, seed=0) -> list[Point]:
621
+ """Points on an ellipse (clockwise on screen), optionally hand-drawn: `wobble` = radius noise as a
622
+ fraction, and a sweep over 360 overlaps the start like a marker circle."""
623
+ if n is None:
624
+ n = max(24, int(abs(sweep_deg) / 6))
625
+ r = rng("ellipse", seed)
626
+ harm = [(r.uniform(0.6, 1.0), r.uniform(0, 2 * math.pi), k) for k in (2, 3, 5)]
627
+ out = []
628
+ for i in range(n + 1):
629
+ u = i / n
630
+ a = math.radians(start_deg + sweep_deg * u)
631
+ k = 1.0
632
+ if wobble:
633
+ k += wobble * sum(amp * math.sin(kk * a + ph) for amp, ph, kk in harm) / 2.2
634
+ k += wobble * 0.6 * u # the end drifts outwards, like a pen that overshoots
635
+ out.append((cx + rx * k * math.cos(a), cy + ry * k * math.sin(a)))
636
+ return out
637
+
638
+
639
+ def wobble_points(points: Sequence[Point], amount: float, seed=0, step: float = 18.0) -> list[Point]:
640
+ """Resample a polyline every `step` px and nudge it sideways (hand-drawn look), deterministic."""
641
+ total = path_length(points)
642
+ if total <= 0 or amount <= 0:
643
+ return [tuple(p) for p in points]
644
+ n = max(2, int(total / step))
645
+ r = rng("wobble", seed)
646
+ ph1, ph2 = r.uniform(0, 6.28), r.uniform(0, 6.28)
647
+ out = []
648
+ for i in range(n + 1):
649
+ (x, y), ang = point_at(points, i / n)
650
+ off = amount * (0.65 * math.sin(i * 0.9 + ph1) + 0.35 * math.sin(i * 2.3 + ph2))
651
+ out.append((x - math.sin(ang) * off, y + math.cos(ang) * off))
652
+ return out
653
+
654
+
655
+ # ============================================================== numbers (Brazilian style)
656
+
657
+ def auto_decimals(value: float, max_decimals: int = 2) -> int:
658
+ """0 for whole numbers, else up to max_decimals (1.5 -> 1, 1904152.87 -> 2)."""
659
+ try:
660
+ d = Decimal(repr(float(value))).normalize()
661
+ except (InvalidOperation, ValueError):
662
+ return 0
663
+ exp = d.as_tuple().exponent
664
+ return 0 if not isinstance(exp, int) or exp >= 0 else min(max_decimals, -exp)
665
+
666
+
667
+ def format_number(value: float, decimals: int = 0, thousands: str = ".", decimal: str = ",") -> str:
668
+ """1904152.87 -> '1.904.152,87' (half-up rounding, no '-0')."""
669
+ decimals = max(0, int(decimals))
670
+ try:
671
+ d = Decimal(repr(float(value)))
672
+ except (InvalidOperation, ValueError):
673
+ raise MotionError(f"número inválido: {value!r}") from None
674
+ if not d.is_finite():
675
+ raise MotionError(f"número inválido: {value!r}")
676
+ q = d.quantize(Decimal(1).scaleb(-decimals), rounding=ROUND_HALF_UP)
677
+ neg = q < 0
678
+ q = abs(q)
679
+ whole, _, frac = f"{q:f}".partition(".")
680
+ groups = []
681
+ while len(whole) > 3:
682
+ groups.insert(0, whole[-3:])
683
+ whole = whole[:-3]
684
+ groups.insert(0, whole)
685
+ out = thousands.join(groups)
686
+ if decimals:
687
+ out += decimal + frac.ljust(decimals, "0")[:decimals]
688
+ if neg and any(c not in "0.," for c in out):
689
+ out = "-" + out
690
+ return out
691
+
692
+
693
+ MONEY_SYMBOLS = ("R$", "US$", "$", "€", "£")
694
+
695
+
696
+ def looks_like_money(*texts: str) -> bool:
697
+ """True when a prefix or suffix is a currency (R$, US$, $, €, £): counters then show cents when they have them."""
698
+ return any(sym in str(t) for t in texts for sym in MONEY_SYMBOLS)
699
+
700
+
701
+ def format_money(value: float, decimals: int = 2, prefix: str = "R$ ") -> str:
702
+ s = format_number(value, decimals)
703
+ if s.startswith("-"):
704
+ return "-" + prefix + s[1:]
705
+ return prefix + s
706
+
707
+
708
+ def format_percent(value: float, decimals: int = 0) -> str:
709
+ return format_number(value, decimals) + "%"
710
+
711
+
712
+ _COMPACT_UNITS = ((1.0, ""), (1e3, "mil"), (1e6, "mi"), (1e9, "bi"), (1e12, "tri"))
713
+
714
+
715
+ def _compact_unit(v: float, decimals: int) -> int:
716
+ k = max(i for i, (limit, _) in enumerate(_COMPACT_UNITS) if i == 0 or abs(v) >= limit)
717
+ while k + 1 < len(_COMPACT_UNITS):
718
+ d = auto_decimals(v, decimals) if k == 0 else decimals
719
+ shown = format_number(abs(v) / _COMPACT_UNITS[k][0], d)
720
+ if len(shown.split(",")[0].replace(".", "")) < 4: # rounding did not reach 1000 of this unit
721
+ break
722
+ k += 1
723
+ return k
724
+
725
+
726
+ def format_compact(value: float, decimals: int = 1, like: float | None = None) -> str:
727
+ """12500 -> '12,5 mil', 1904152 -> '1,9 mi', 2.4e9 -> '2,4 bi', 999999.99 -> '1 mi' (never '1.000 mil').
728
+ `like` = use the unit of that other number (a count keeps the unit of its final value: '0,4 mi' -> '1,9 mi')."""
729
+ v = float(value)
730
+ k = _compact_unit(float(like) if like is not None else v, decimals)
731
+ if k == 0:
732
+ return format_number(v, auto_decimals(v, decimals))
733
+ limit, word = _COMPACT_UNITS[k]
734
+ s = format_number(v / limit, decimals)
735
+ if "," in s:
736
+ s = s.rstrip("0").rstrip(",")
737
+ return f"{s} {word}"
738
+
739
+
740
+ # ============================================================== fonts and text metrics
741
+
742
+ _tls = threading.local()
743
+ _FONT_MANIFEST: dict | None = None
744
+
745
+
746
+ def font_keys() -> list[str]:
747
+ return sorted(_manifest())
748
+
749
+
750
+ def _manifest() -> dict:
751
+ global _FONT_MANIFEST
752
+ if _FONT_MANIFEST is None:
753
+ _FONT_MANIFEST = load_json(FONTS_DIR / "fonts.json", default={}) or {}
754
+ return _FONT_MANIFEST
755
+
756
+
757
+ @contextmanager
758
+ def theme_fonts(theme: "Theme"):
759
+ """While drawing or measuring an item, font keys resolve through its theme (papelaria swaps them)."""
760
+ prev = getattr(_tls, "font_map", None)
761
+ _tls.font_map = dict(theme.fonts) if theme.fonts else None
762
+ try:
763
+ yield
764
+ finally:
765
+ _tls.font_map = prev
766
+
767
+
768
+ def font(key: str, size: float) -> ImageFont.FreeTypeFont:
769
+ """Bundled font (assets/fonts/fonts.json key) at a float pixel size, cached per thread."""
770
+ fmap = getattr(_tls, "font_map", None)
771
+ if fmap:
772
+ key = fmap.get(key, key)
773
+ cache = getattr(_tls, "fonts", None)
774
+ if cache is None:
775
+ cache = _tls.fonts = {}
776
+ px = max(4.0, round(float(size) * 8) / 8)
777
+ f = cache.get((key, px))
778
+ if f is None:
779
+ entry = _manifest().get(key)
780
+ if not entry:
781
+ raise MotionError(f"fonte '{key}' não existe (use {', '.join(font_keys())})")
782
+ f = ImageFont.truetype(str(FONTS_DIR / entry["file"]), size=px)
783
+ if entry.get("variation") is not None:
784
+ try:
785
+ f.set_variation_by_axes([entry["variation"]])
786
+ except Exception: # noqa: BLE001 (a static build of the font: keep its default weight)
787
+ pass
788
+ if len(cache) > 512:
789
+ cache.clear()
790
+ cache[(key, px)] = f
791
+ return f
792
+
793
+
794
+ @dataclass(frozen=True)
795
+ class TextMetrics:
796
+ width: float # advance width (design px)
797
+ cap: float # cap height
798
+ ascent: float
799
+ descent: float
800
+ x_height: float
801
+
802
+
803
+ def _digit_cell(f: ImageFont.FreeTypeFont) -> float:
804
+ return max(f.getlength(d) for d in "0123456789")
805
+
806
+
807
+ def _advance(f: ImageFont.FreeTypeFont, text: str, tabular: bool, tracking: float) -> float:
808
+ if not tabular and not tracking:
809
+ return f.getlength(text)
810
+ cell = _digit_cell(f) if tabular else 0.0
811
+ w = sum(cell if (tabular and ch.isdigit()) else f.getlength(ch) for ch in text)
812
+ return w + tracking * max(0, len(text) - 1)
813
+
814
+
815
+ def measure(text: str, font_key: str = "sans_black", size: float = 64.0, tabular: bool = False,
816
+ tracking: float = 0.0) -> TextMetrics:
817
+ """Metrics in design px (size is in design px too) of the text as the Canvas draws it."""
818
+ text = clean_text(text, font_key)
819
+ f = font(font_key, size)
820
+ cap = -f.getbbox("H", anchor="ls")[1]
821
+ xh = -f.getbbox("x", anchor="ls")[1]
822
+ asc, desc = f.getmetrics()
823
+ return TextMetrics(width=_advance(f, text, tabular, tracking * 1.0), cap=float(cap), ascent=float(asc),
824
+ descent=float(desc), x_height=float(xh))
825
+
826
+
827
+ def clean_text(text: str, font_key: str) -> str:
828
+ """Characters the font cannot draw (emoji...) become a look-alike or go away (text_render rules)."""
829
+ try:
830
+ import text_render as tr # noqa: PLC0415
831
+ fmap = getattr(_tls, "font_map", None)
832
+ return tr.sanitize_text(str(text), fmap.get(font_key, font_key) if fmap else font_key)[0]
833
+ except Exception: # noqa: BLE001
834
+ return str(text)
835
+
836
+
837
+ def fit_text(text: str, font_key: str, max_width: float, max_size: float, min_size: float = 12.0,
838
+ tabular: bool = False, tracking: float = 0.0) -> float:
839
+ """Largest size (<= max_size, >= min_size) whose advance fits max_width."""
840
+ w = measure(text, font_key, max_size, tabular, tracking).width
841
+ if w <= max_width or w <= 0:
842
+ return float(max_size)
843
+ size = max_size * max_width / w
844
+ for _ in range(4):
845
+ w = measure(text, font_key, size, tabular, tracking).width
846
+ if w <= max_width:
847
+ break
848
+ size *= max_width / w * 0.995
849
+ return float(max(min_size, size))
850
+
851
+
852
+ def wrap_text(text: str, font_key: str, size: float, max_width: float, max_lines: int = 2) -> list[str]:
853
+ """Balanced lines (the widest line as short as possible), at most max_lines; words never split."""
854
+ words = str(text).split()
855
+ if not words:
856
+ return []
857
+ f = font(font_key, size)
858
+
859
+ def width(ws):
860
+ return f.getlength(" ".join(ws))
861
+
862
+ if width(words) <= max_width or max_lines <= 1:
863
+ return [" ".join(words)]
864
+ best = None
865
+ n = len(words)
866
+
867
+ def splits(start, lines_left):
868
+ if lines_left == 1:
869
+ yield [words[start:]]
870
+ return
871
+ for cut in range(start + 1, n - lines_left + 2):
872
+ for rest in splits(cut, lines_left - 1):
873
+ yield [words[start:cut]] + rest
874
+
875
+ for count in range(2, min(max_lines, n) + 1):
876
+ for parts in splits(0, count):
877
+ widest = max(width(p) for p in parts)
878
+ score = (widest > max_width, widest, count)
879
+ if best is None or score < best[0]:
880
+ best = (score, parts)
881
+ if best and not best[0][0]:
882
+ break
883
+ return [" ".join(p) for p in best[1]] if best else [" ".join(words)]
884
+
885
+
886
+ def parse_accent(text: str) -> list[tuple[str, bool]]:
887
+ """'Compra. *Conecta.* Usa.' -> [('Compra.', False), ('Conecta.', True), ('Usa.', False)] (per word;
888
+ a word touched by the markup is accented whole; '\\*' is a real asterisk)."""
889
+ out = []
890
+ on = False
891
+ for raw in re.split(r"(\s+)", str(text)):
892
+ if not raw or raw.isspace():
893
+ continue
894
+ word, touched = [], False
895
+ i = 0
896
+ while i < len(raw):
897
+ ch = raw[i]
898
+ if ch == "\\" and i + 1 < len(raw) and raw[i + 1] == "*":
899
+ word.append("*")
900
+ i += 2
901
+ continue
902
+ if ch == "*":
903
+ on = not on
904
+ touched = True
905
+ i += 1
906
+ continue
907
+ if on:
908
+ touched = True
909
+ word.append(ch)
910
+ i += 1
911
+ if word:
912
+ out.append(("".join(word), touched))
913
+ return out
914
+
915
+
916
+ # ============================================================== rasterizing helpers (render px)
917
+
918
+ def _grid(x0: float, y0: float, x1: float, y1: float, W: int, H: int, pad: int = 2):
919
+ ix0, iy0 = max(0, int(math.floor(x0)) - pad), max(0, int(math.floor(y0)) - pad)
920
+ ix1, iy1 = min(W, int(math.ceil(x1)) + pad), min(H, int(math.ceil(y1)) + pad)
921
+ if ix1 <= ix0 or iy1 <= iy0:
922
+ return None
923
+ xs = np.arange(ix0, ix1, dtype=np.float32) + 0.5
924
+ ys = np.arange(iy0, iy1, dtype=np.float32) + 0.5
925
+ return ix0, iy0, xs[None, :], ys[:, None]
926
+
927
+
928
+ def _coverage(d: np.ndarray) -> np.ndarray:
929
+ """Signed distance (px, negative inside) -> coverage 0..1 (1 px wide anti-aliased edge)."""
930
+ return np.clip(0.5 - d, 0.0, 1.0)
931
+
932
+
933
+ def _rrect_sd(px, py, x0, y0, x1, y1, r):
934
+ cx, cy = (x0 + x1) / 2.0, (y0 + y1) / 2.0
935
+ hx, hy = (x1 - x0) / 2.0 - r, (y1 - y0) / 2.0 - r
936
+ qx = np.abs(px - cx) - hx
937
+ qy = np.abs(py - cy) - hy
938
+ outside = np.sqrt(np.maximum(qx, 0) ** 2 + np.maximum(qy, 0) ** 2)
939
+ inside = np.minimum(np.maximum(qx, qy), 0)
940
+ return outside + inside - r
941
+
942
+
943
+ def _seg_dist(px, py, ax, ay, bx, by):
944
+ vx, vy = bx - ax, by - ay
945
+ l2 = vx * vx + vy * vy
946
+ wx, wy = px - ax, py - ay
947
+ if l2 <= 1e-12:
948
+ return np.sqrt(wx * wx + wy * wy)
949
+ t = np.clip((wx * vx + wy * vy) / l2, 0.0, 1.0)
950
+ dx, dy = wx - t * vx, wy - t * vy
951
+ return np.sqrt(dx * dx + dy * dy)
952
+
953
+
954
+ def _inset_convex(pts: list[Point], r: float) -> list[Point] | None:
955
+ """A convex polygon moved inwards by r (None when it would vanish)."""
956
+ n = len(pts)
957
+ area = sum(pts[i][0] * pts[(i + 1) % n][1] - pts[(i + 1) % n][0] * pts[i][1] for i in range(n)) / 2.0
958
+ if abs(area) < 1e-9:
959
+ return None
960
+ sign = 1.0 if area > 0 else -1.0
961
+ lines = []
962
+ for i in range(n):
963
+ (ax, ay), (bx, by) = pts[i], pts[(i + 1) % n]
964
+ dx, dy = bx - ax, by - ay
965
+ ln = math.hypot(dx, dy)
966
+ if ln < 1e-9:
967
+ continue
968
+ nx, ny = -dy / ln * sign, dx / ln * sign # inward normal
969
+ lines.append((ax + nx * r, ay + ny * r, dx, dy))
970
+ out = []
971
+ for (ax, ay, adx, ady), (bx, by, bdx, bdy) in zip(lines, lines[1:] + lines[:1]):
972
+ den = adx * bdy - ady * bdx
973
+ if abs(den) < 1e-12:
974
+ continue
975
+ t = ((bx - ax) * bdy - (by - ay) * bdx) / den
976
+ out.append((ax + adx * t, ay + ady * t))
977
+ if len(out) < 3:
978
+ return None
979
+ new_area = sum(out[i][0] * out[(i + 1) % len(out)][1] - out[(i + 1) % len(out)][0] * out[i][1]
980
+ for i in range(len(out))) / 2.0
981
+ if new_area * area <= 0 or abs(new_area) < 1.0:
982
+ return None
983
+ return out
984
+
985
+
986
+ @dataclass
987
+ class Mask:
988
+ """Coverage (float32 0..1) placed at integer render px (x, y)."""
989
+ a: np.ndarray
990
+ x: int
991
+ y: int
992
+
993
+ @property
994
+ def bbox(self):
995
+ return self.x, self.y, self.x + self.a.shape[1], self.y + self.a.shape[0]
996
+
997
+
998
+ def blur_mask(m: Mask, radius: float, W: int, H: int) -> Mask:
999
+ """Gaussian-blurred copy of a mask (grown by 3 radii), computed at half resolution for big radii."""
1000
+ if radius < 0.5:
1001
+ return m
1002
+ pad = int(math.ceil(radius * 3)) + 2
1003
+ x0, y0 = max(0, m.x - pad), max(0, m.y - pad)
1004
+ x1, y1 = min(W, m.x + m.a.shape[1] + pad), min(H, m.y + m.a.shape[0] + pad)
1005
+ if x1 <= x0 or y1 <= y0:
1006
+ return Mask(np.zeros((1, 1), np.float32), 0, 0)
1007
+ buf = np.zeros((y1 - y0, x1 - x0), np.float32)
1008
+ # the mask may pass the canvas edge (a word scaled up near the border): copy only the part inside
1009
+ sx0, sy0 = max(m.x, x0), max(m.y, y0)
1010
+ sx1, sy1 = min(m.x + m.a.shape[1], x1), min(m.y + m.a.shape[0], y1)
1011
+ if sx1 <= sx0 or sy1 <= sy0:
1012
+ return Mask(np.zeros((1, 1), np.float32), 0, 0)
1013
+ buf[sy0 - y0:sy1 - y0, sx0 - x0:sx1 - x0] = m.a[sy0 - m.y:sy1 - m.y, sx0 - m.x:sx1 - m.x]
1014
+ img = Image.fromarray(np.clip(buf * 255.0 + 0.5, 0, 255).astype(np.uint8), "L")
1015
+ if radius >= 6:
1016
+ small = img.resize((max(1, img.width // 2), max(1, img.height // 2)), Image.BILINEAR)
1017
+ small = small.filter(ImageFilter.GaussianBlur(radius / 2.0))
1018
+ img = small.resize(img.size, Image.BILINEAR)
1019
+ else:
1020
+ img = img.filter(ImageFilter.GaussianBlur(radius))
1021
+ return Mask(np.asarray(img, np.float32) / 255.0, x0, y0)
1022
+
1023
+
1024
+ def clip_mask(m: Mask | None, x0: float | None = None, x1: float | None = None, y0: float | None = None,
1025
+ y1: float | None = None) -> Mask | None:
1026
+ """The part of a mask inside render-px limits (any of them may be None), with partial coverage on the cut
1027
+ lines. A bar rounded on its free end and flat on the axis is a rounded rect cut at the axis."""
1028
+ if m is None:
1029
+ return None
1030
+ a = m.a
1031
+ if x0 is not None or x1 is not None:
1032
+ cols = np.arange(a.shape[1], dtype=np.float32) + m.x
1033
+ k = np.ones_like(cols)
1034
+ if x0 is not None:
1035
+ k *= np.clip(cols + 1.0 - x0, 0.0, 1.0)
1036
+ if x1 is not None:
1037
+ k *= np.clip(x1 - cols, 0.0, 1.0)
1038
+ a = a * k[None, :]
1039
+ if y0 is not None or y1 is not None:
1040
+ rows = np.arange(a.shape[0], dtype=np.float32) + m.y
1041
+ k = np.ones_like(rows)
1042
+ if y0 is not None:
1043
+ k *= np.clip(rows + 1.0 - y0, 0.0, 1.0)
1044
+ if y1 is not None:
1045
+ k *= np.clip(y1 - rows, 0.0, 1.0)
1046
+ a = a * k[:, None]
1047
+ if not a.any():
1048
+ return None
1049
+ return Mask(a, m.x, m.y)
1050
+
1051
+
1052
+ def union_masks(masks: Iterable[Mask | None]) -> Mask | None:
1053
+ """Union (the highest coverage) of several masks, e.g. a scalloped seal made of circles."""
1054
+ ms = [m for m in masks if m is not None]
1055
+ if not ms:
1056
+ return None
1057
+ x0 = min(m.x for m in ms)
1058
+ y0 = min(m.y for m in ms)
1059
+ x1 = max(m.x + m.a.shape[1] for m in ms)
1060
+ y1 = max(m.y + m.a.shape[0] for m in ms)
1061
+ out = np.zeros((y1 - y0, x1 - x0), np.float32)
1062
+ for m in ms:
1063
+ sub = out[m.y - y0:m.y - y0 + m.a.shape[0], m.x - x0:m.x - x0 + m.a.shape[1]]
1064
+ np.maximum(sub, m.a, out=sub)
1065
+ return Mask(out, x0, y0)
1066
+
1067
+
1068
+ def intersect_masks(a: Mask | None, b: Mask | None) -> Mask | None:
1069
+ """Coverage inside both masks (clips a shape to another, e.g. a silhouette to its round avatar)."""
1070
+ if a is None or b is None:
1071
+ return None
1072
+ x0, y0 = max(a.x, b.x), max(a.y, b.y)
1073
+ x1 = min(a.x + a.a.shape[1], b.x + b.a.shape[1])
1074
+ y1 = min(a.y + a.a.shape[0], b.y + b.a.shape[0])
1075
+ if x1 <= x0 or y1 <= y0:
1076
+ return None
1077
+ return Mask(a.a[y0 - a.y:y1 - a.y, x0 - a.x:x1 - a.x] * b.a[y0 - b.y:y1 - b.y, x0 - b.x:x1 - b.x], x0, y0)
1078
+
1079
+
1080
+ # Masks and blurred shadows of plain shapes, reused while a card stands still (a card's shadow blur is
1081
+ # 10-20 ms at 1080x1920). Keyed by the exact render-px geometry; the arrays are read-only.
1082
+ _MASK_CACHE: "dict[tuple, Mask]" = {}
1083
+ _MASK_CACHE_LOCK = threading.Lock()
1084
+ _MASK_CACHE_MAX = 96
1085
+
1086
+
1087
+ def _cached_mask(key: tuple, make: Callable[[], "Mask | None"]) -> "Mask | None":
1088
+ with _MASK_CACHE_LOCK:
1089
+ hit = _MASK_CACHE.get(key)
1090
+ if hit is not None:
1091
+ _MASK_CACHE[key] = _MASK_CACHE.pop(key) # most recent last
1092
+ return Mask(hit.a, hit.x, hit.y)
1093
+ m = make()
1094
+ if m is None:
1095
+ return None
1096
+ m.a.flags.writeable = False
1097
+ with _MASK_CACHE_LOCK:
1098
+ _MASK_CACHE[key] = m
1099
+ while len(_MASK_CACHE) > _MASK_CACHE_MAX:
1100
+ _MASK_CACHE.pop(next(iter(_MASK_CACHE)))
1101
+ return Mask(m.a, m.x, m.y)
1102
+
1103
+
1104
+ def heart_points(cx: float, cy: float, size: float, n: int = 64, closed: bool = True) -> list[Point]:
1105
+ """Outline of a heart centered on (cx, cy), `size` wide (generic 'like' glyph)."""
1106
+ s = size / 34.0
1107
+ pts = []
1108
+ for i in range(n + 1 if closed else n):
1109
+ t = 2 * math.pi * i / n
1110
+ x = 16 * math.sin(t) ** 3
1111
+ y = 13 * math.cos(t) - 5 * math.cos(2 * t) - 2 * math.cos(3 * t) - math.cos(4 * t)
1112
+ pts.append((cx + x * s, cy - (y + 2.5) * s))
1113
+ return pts
1114
+
1115
+
1116
+ @dataclass(frozen=True)
1117
+ class Shadow:
1118
+ blur: float = 26.0 # design px
1119
+ dy: float = 14.0
1120
+ dx: float = 0.0
1121
+ opacity: float = 0.42
1122
+ color: tuple = (0, 0, 0)
1123
+
1124
+
1125
+ @dataclass(frozen=True)
1126
+ class Gradient:
1127
+ """Linear gradient fill: color c0 at `start`, c1 at `end` (box-local design px), clamped outside."""
1128
+ c0: object
1129
+ c1: object
1130
+ start: tuple = (0.0, 0.0)
1131
+ end: tuple = (0.0, 100.0)
1132
+
1133
+
1134
+ SOFT_SHADOW = Shadow()
1135
+ TEXT_SHADOW = Shadow(blur=10.0, dy=5.0, opacity=0.55)
1136
+
1137
+
1138
+ def _shadow_of(value) -> Shadow | None:
1139
+ if value is None or value is False:
1140
+ return None
1141
+ if value is True:
1142
+ return SOFT_SHADOW
1143
+ if isinstance(value, Shadow):
1144
+ return value
1145
+ if isinstance(value, dict):
1146
+ return Shadow(**value)
1147
+ raise MotionError(f"sombra inválida: {value!r}")
1148
+
1149
+
1150
+ # ============================================================== canvas
1151
+
1152
+ @dataclass
1153
+ class _Layer:
1154
+ image: Image.Image
1155
+ dirty: list[int] | None = None
1156
+
1157
+
1158
+ @dataclass
1159
+ class _Transform:
1160
+ a: float = 1.0 # uniform scale (design -> design)
1161
+ bx: float = 0.0
1162
+ by: float = 0.0
1163
+
1164
+ def point(self, u: float, v: float) -> tuple[float, float]:
1165
+ return self.a * u + self.bx, self.a * v + self.by
1166
+
1167
+
1168
+ class Canvas:
1169
+ """Transparent drawing surface for one frame of one box (the box plus `bleed` all around).
1170
+
1171
+ Every coordinate and size is in box-local DESIGN px. Colors: '#RRGGBB', (r, g, b[, a]) or a theme
1172
+ name ('accent', 'text', 'muted', 'card', 'danger', 'success', 'on_accent', 'track', 'white', 'black',
1173
+ and the Portuguese 'destaque', 'texto', 'perigo', 'sucesso', 'branco', 'preto').
1174
+ """
1175
+
1176
+ def __init__(self, box: Box, full: bool = False):
1177
+ self.box = box
1178
+ s = box.scale
1179
+ self.s = s
1180
+ self.full = bool(full)
1181
+ if self.full: # the whole output canvas (full-screen flash, drawing far from the box)
1182
+ self.origin_x = self.origin_y = 0
1183
+ self.W, self.H = int(box.canvas[0]), int(box.canvas[1])
1184
+ else:
1185
+ self.origin_x = int(math.floor((box.x - box.bleed) * s))
1186
+ self.origin_y = int(math.floor((box.y - box.bleed) * s))
1187
+ self.W = int(math.ceil((box.x + box.w + box.bleed) * s)) - self.origin_x
1188
+ self.H = int(math.ceil((box.y + box.h + box.bleed) * s)) - self.origin_y
1189
+ self.image = Image.new("RGBA", (self.W, self.H), (0, 0, 0, 0))
1190
+ self._targets: list[Image.Image] = [self.image]
1191
+ self._dirty: list[list[int] | None] = [None] # drawn area of each target (render px)
1192
+ self._tf = [_Transform()]
1193
+
1194
+ # ---------------------------------------------------------- basics
1195
+ @property
1196
+ def w(self) -> float:
1197
+ return self.box.w
1198
+
1199
+ @property
1200
+ def h(self) -> float:
1201
+ return self.box.h
1202
+
1203
+ @property
1204
+ def cx(self) -> float:
1205
+ return self.box.w / 2.0
1206
+
1207
+ @property
1208
+ def cy(self) -> float:
1209
+ return self.box.h / 2.0
1210
+
1211
+ @property
1212
+ def theme(self) -> Theme:
1213
+ return self.box.theme
1214
+
1215
+ @property
1216
+ def offset(self) -> tuple[int, int]:
1217
+ """Render px of this canvas' top-left on the output canvas."""
1218
+ return self.origin_x, self.origin_y
1219
+
1220
+ def color(self, value, opacity: float = 1.0) -> RGBA:
1221
+ if isinstance(value, str) and not value.startswith("#"):
1222
+ key = COLOR_NAMES.get(value.strip().lower())
1223
+ if key is None:
1224
+ raise MotionError(f"cor '{value}' não existe (use #RRGGBB ou {', '.join(sorted(set(COLOR_NAMES)))})")
1225
+ c = self.theme.get(key)
1226
+ else:
1227
+ c = rgba(value)
1228
+ return (c[0], c[1], c[2], _clamp8(c[3] * opacity))
1229
+
1230
+ def px(self, u: float, v: float) -> tuple[float, float]:
1231
+ """Box-local design point (through the group transform) -> render px of this canvas."""
1232
+ x, y = self._tf[-1].point(u, v)
1233
+ s = self.s
1234
+ return (self.box.x + x) * s - self.origin_x, (self.box.y + y) * s - self.origin_y
1235
+
1236
+ def len(self, d: float) -> float:
1237
+ """Design length -> render px (through the group scale)."""
1238
+ return d * self._tf[-1].a * self.s
1239
+
1240
+ @property
1241
+ def _target(self) -> Image.Image:
1242
+ return self._targets[-1]
1243
+
1244
+ def _mark(self, x0: int, y0: int, x1: int, y1: int) -> None:
1245
+ d = self._dirty[-1]
1246
+ if d is None:
1247
+ self._dirty[-1] = [x0, y0, x1, y1]
1248
+ else:
1249
+ d[0], d[1], d[2], d[3] = min(d[0], x0), min(d[1], y0), max(d[2], x1), max(d[3], y1)
1250
+
1251
+ def _paste_layer(self, img: Image.Image, x: int, y: int) -> None:
1252
+ """alpha_composite an image at (x, y) of the current target, clipped, marking the drawn area."""
1253
+ tgt = self._target
1254
+ x0, y0 = max(0, x), max(0, y)
1255
+ x1, y1 = min(tgt.width, x + img.width), min(tgt.height, y + img.height)
1256
+ if x1 <= x0 or y1 <= y0:
1257
+ return
1258
+ part = img if (x0, y0, x1, y1) == (x, y, x + img.width, y + img.height) else \
1259
+ img.crop((x0 - x, y0 - y, x1 - x, y1 - y))
1260
+ tgt.alpha_composite(part, (x0, y0))
1261
+ self._mark(x0, y0, x1, y1)
1262
+
1263
+ def fill_mask(self, m: Mask | None, color, opacity: float = 1.0) -> None:
1264
+ """Composite a color (or a Gradient) through a coverage mask (straight alpha, real blending)."""
1265
+ if m is None or opacity <= 0:
1266
+ return
1267
+ tgt = self._target
1268
+ x0, y0 = max(0, m.x), max(0, m.y)
1269
+ x1, y1 = min(tgt.width, m.x + m.a.shape[1]), min(tgt.height, m.y + m.a.shape[0])
1270
+ if x1 <= x0 or y1 <= y0:
1271
+ return
1272
+ a = m.a[y0 - m.y:y1 - m.y, x0 - m.x:x1 - m.x]
1273
+ if isinstance(color, Gradient):
1274
+ c0, c1 = self.color(color.c0, opacity), self.color(color.c1, opacity)
1275
+ sx, sy = self.px(*color.start)
1276
+ ex, ey = self.px(*color.end)
1277
+ vx, vy = ex - sx, ey - sy
1278
+ l2 = max(1e-9, vx * vx + vy * vy)
1279
+ xs = np.arange(x0, x1, dtype=np.float32)[None, :] + 0.5
1280
+ ys = np.arange(y0, y1, dtype=np.float32)[:, None] + 0.5
1281
+ t = np.clip(((xs - sx) * vx + (ys - sy) * vy) / l2, 0.0, 1.0)
1282
+ lo, hi = np.array(c0, np.float32), np.array(c1, np.float32)
1283
+ rgba_px = lo[None, None, :] + (hi - lo)[None, None, :] * t[..., None]
1284
+ rgba_px[..., 3] *= a
1285
+ arr = np.clip(rgba_px + 0.5, 0, 255).astype(np.uint8)
1286
+ if not arr[..., 3].any():
1287
+ return
1288
+ tgt.alpha_composite(Image.fromarray(np.ascontiguousarray(arr), "RGBA"), (x0, y0))
1289
+ self._mark(x0, y0, x1, y1)
1290
+ return
1291
+ c = self.color(color, opacity)
1292
+ if c[3] <= 0:
1293
+ return
1294
+ if c[3] < 255:
1295
+ a = a * (c[3] / 255.0)
1296
+ a8 = np.clip(a * 255.0 + 0.5, 0, 255).astype(np.uint8)
1297
+ if not a8.any():
1298
+ return
1299
+ src = Image.new("RGBA", (x1 - x0, y1 - y0), (c[0], c[1], c[2], 255))
1300
+ src.putalpha(Image.fromarray(np.ascontiguousarray(a8), "L"))
1301
+ tgt.alpha_composite(src, (x0, y0))
1302
+ self._mark(x0, y0, x1, y1)
1303
+
1304
+ def erase_mask(self, m: Mask | None, amount: float = 1.0) -> None:
1305
+ """Cut a hole: the drawing under the mask becomes transparent (amount 0-1)."""
1306
+ if m is None or amount <= 0:
1307
+ return
1308
+ tgt = self._target
1309
+ x0, y0 = max(0, m.x), max(0, m.y)
1310
+ x1, y1 = min(tgt.width, m.x + m.a.shape[1]), min(tgt.height, m.y + m.a.shape[0])
1311
+ if x1 <= x0 or y1 <= y0:
1312
+ return
1313
+ a = m.a[y0 - m.y:y1 - m.y, x0 - m.x:x1 - m.x] * min(1.0, amount)
1314
+ region = tgt.crop((x0, y0, x1, y1))
1315
+ al = np.asarray(region.getchannel("A"), np.float32) * (1.0 - a)
1316
+ region.putalpha(Image.fromarray(np.clip(al + 0.5, 0, 255).astype(np.uint8), "L"))
1317
+ tgt.paste(region, (x0, y0))
1318
+ self._mark(x0, y0, x1, y1)
1319
+
1320
+ def erase_circle(self, cx: float, cy: float, r: float, amount: float = 1.0) -> None:
1321
+ self.erase_mask(self.circle_mask(cx, cy, r), amount)
1322
+
1323
+ def erase_rect(self, x: float, y: float, w: float, h: float, radius: float = 0.0, amount: float = 1.0) -> None:
1324
+ self.erase_mask(self.rect_mask(x, y, w, h, radius), amount)
1325
+
1326
+ def fill(self, color="white", opacity: float = 1.0) -> None:
1327
+ """Paint the whole canvas (a full-screen flash needs Canvas(box, full=True))."""
1328
+ self.fill_mask(Mask(np.ones((self.H, self.W), np.float32), 0, 0), color, opacity)
1329
+
1330
+ def _cast_shadow(self, m: Mask | None, shadow: Shadow | None, opacity: float, key: tuple | None = None) -> None:
1331
+ """Soft shadow of a mask. `key` (the exact render-px geometry of a plain shape) reuses the blur."""
1332
+ if m is None or shadow is None or opacity <= 0:
1333
+ return
1334
+ radius = self.len(shadow.blur)
1335
+ if key is not None:
1336
+ b = _cached_mask(("blur", key, round(radius, 4), self.W, self.H), lambda: blur_mask(m, radius, self.W, self.H))
1337
+ if b is None:
1338
+ return
1339
+ else:
1340
+ b = blur_mask(m, radius, self.W, self.H)
1341
+ b = Mask(b.a, b.x + int(round(self.len(shadow.dx))), b.y + int(round(self.len(shadow.dy))))
1342
+ self.fill_mask(b, tuple(shadow.color), shadow.opacity * opacity)
1343
+
1344
+ # ---------------------------------------------------------- shape masks (render px)
1345
+ def _rect_geometry(self, x: float, y: float, w: float, h: float, radius: float, inset: float) -> tuple | None:
1346
+ X0, Y0 = self.px(x, y)
1347
+ X1, Y1 = self.px(x + w, y + h)
1348
+ ins = self.len(inset)
1349
+ X0, Y0, X1, Y1 = X0 + ins, Y0 + ins, X1 - ins, Y1 - ins
1350
+ if X1 - X0 <= 0.05 or Y1 - Y0 <= 0.05:
1351
+ return None
1352
+ r = max(0.0, min(self.len(radius) - ins, (X1 - X0) / 2.0, (Y1 - Y0) / 2.0))
1353
+ return ("rect", X0, Y0, X1, Y1, r, self.W, self.H)
1354
+
1355
+ @staticmethod
1356
+ def _rect_from(geo: tuple) -> Mask | None:
1357
+ _, X0, Y0, X1, Y1, r, W, H = geo
1358
+ g = _grid(X0, Y0, X1, Y1, W, H)
1359
+ if g is None:
1360
+ return None
1361
+ ix, iy, px, py = g
1362
+ return Mask(_coverage(_rrect_sd(px, py, X0, Y0, X1, Y1, r)).astype(np.float32), ix, iy)
1363
+
1364
+ def rect_mask(self, x: float, y: float, w: float, h: float, radius: float = 0.0, inset: float = 0.0) -> Mask | None:
1365
+ geo = self._rect_geometry(x, y, w, h, radius, inset)
1366
+ if geo is None:
1367
+ return None
1368
+ return _cached_mask(geo, lambda: self._rect_from(geo))
1369
+
1370
+ def circle_mask(self, cx: float, cy: float, r: float) -> Mask | None:
1371
+ X, Y = self.px(cx, cy)
1372
+ R = self.len(r)
1373
+ if R <= 0.05:
1374
+ return None
1375
+
1376
+ def make() -> Mask | None:
1377
+ g = _grid(X - R, Y - R, X + R, Y + R, self.W, self.H)
1378
+ if g is None:
1379
+ return None
1380
+ ix, iy, px, py = g
1381
+ d = np.sqrt((px - X) ** 2 + (py - Y) ** 2) - R
1382
+ return Mask(_coverage(d).astype(np.float32), ix, iy)
1383
+
1384
+ return _cached_mask(("circle", X, Y, R, self.W, self.H), make)
1385
+
1386
+ def stroke_mask(self, points: Sequence[Point], width: float, widths: Sequence[float] | None = None) -> Mask | None:
1387
+ """Round-capped, round-joined stroke through points (union, so crossings never double the alpha)."""
1388
+ pts = [self.px(*p) for p in points]
1389
+ if len(pts) == 1:
1390
+ pts = pts * 2
1391
+ if len(pts) < 2:
1392
+ return None
1393
+ rads = [self.len(wd) / 2.0 for wd in widths] if widths else [self.len(width) / 2.0] * len(pts)
1394
+ if len(rads) != len(pts):
1395
+ raise MotionError("widths precisa ter um valor por ponto")
1396
+ rmax = max(rads)
1397
+ if rmax <= 0.02:
1398
+ return None
1399
+ xs = [p[0] for p in pts]
1400
+ ys = [p[1] for p in pts]
1401
+ g = _grid(min(xs) - rmax, min(ys) - rmax, max(xs) + rmax, max(ys) + rmax, self.W, self.H)
1402
+ if g is None:
1403
+ return None
1404
+ ix, iy, gx, gy = g
1405
+ hgt, wid = gy.shape[0], gx.shape[1]
1406
+ best = np.full((hgt, wid), np.inf, np.float32)
1407
+ for (ax, ay), (bx, by), ra, rb in zip(pts, pts[1:], rads, rads[1:]):
1408
+ r = max(ra, rb)
1409
+ sx0 = max(0, int(math.floor(min(ax, bx) - r)) - 1 - ix)
1410
+ sx1 = min(wid, int(math.ceil(max(ax, bx) + r)) + 2 - ix)
1411
+ sy0 = max(0, int(math.floor(min(ay, by) - r)) - 1 - iy)
1412
+ sy1 = min(hgt, int(math.ceil(max(ay, by) + r)) + 2 - iy)
1413
+ if sx1 <= sx0 or sy1 <= sy0:
1414
+ continue
1415
+ px = gx[:, sx0:sx1]
1416
+ py = gy[sy0:sy1, :]
1417
+ if abs(ra - rb) < 1e-6:
1418
+ d = _seg_dist(px, py, ax, ay, bx, by) - ra
1419
+ else: # tapered: radius interpolated along the segment
1420
+ vx, vy = bx - ax, by - ay
1421
+ l2 = max(1e-12, vx * vx + vy * vy)
1422
+ t = np.clip(((px - ax) * vx + (py - ay) * vy) / l2, 0.0, 1.0)
1423
+ dx, dy = px - ax - t * vx, py - ay - t * vy
1424
+ d = np.sqrt(dx * dx + dy * dy) - (ra + (rb - ra) * t)
1425
+ sub = best[sy0:sy1, sx0:sx1]
1426
+ np.minimum(sub, d.astype(np.float32), out=sub)
1427
+ return Mask(_coverage(best).astype(np.float32), ix, iy)
1428
+
1429
+ def polygon_mask(self, points: Sequence[Point], radius: float = 0.0) -> Mask | None:
1430
+ """Filled polygon; `radius` rounds the corners of a CONVEX polygon."""
1431
+ pts = [self.px(*p) for p in points]
1432
+ if len(pts) < 3:
1433
+ return None
1434
+ grow = 0.0
1435
+ if radius > 0:
1436
+ r = self.len(radius)
1437
+ inner = _inset_convex(pts, r)
1438
+ if inner is not None:
1439
+ pts, grow = inner, r
1440
+ xs = [p[0] for p in pts]
1441
+ ys = [p[1] for p in pts]
1442
+ g = _grid(min(xs) - grow, min(ys) - grow, max(xs) + grow, max(ys) + grow, self.W, self.H)
1443
+ if g is None:
1444
+ return None
1445
+ ix, iy, px, py = g
1446
+ px = np.broadcast_to(px, (py.shape[0], px.shape[1]))
1447
+ py = np.broadcast_to(py, px.shape)
1448
+ d = np.full(px.shape, np.inf, np.float32)
1449
+ inside = np.zeros(px.shape, bool)
1450
+ for (ax, ay), (bx, by) in zip(pts, pts[1:] + pts[:1]):
1451
+ np.minimum(d, _seg_dist(px, py, ax, ay, bx, by).astype(np.float32), out=d)
1452
+ cond = (ay > py) != (by > py)
1453
+ with np.errstate(divide="ignore", invalid="ignore"):
1454
+ xint = (bx - ax) * (py - ay) / (by - ay) + ax
1455
+ inside ^= cond & (px < xint)
1456
+ sd = np.where(inside, -d, d) - grow
1457
+ return Mask(_coverage(sd).astype(np.float32), ix, iy)
1458
+
1459
+ # ---------------------------------------------------------- shapes
1460
+ def rect(self, x: float, y: float, w: float, h: float, radius: float = 0.0, fill="card", opacity: float = 1.0,
1461
+ stroke=None, stroke_width: float = 0.0, shadow=None) -> None:
1462
+ """Rounded rectangle; `stroke` draws an inner border of `stroke_width`. With `fill=None` the shadow is
1463
+ the border's own (an outline casts no filled shadow)."""
1464
+ geo = self._rect_geometry(x, y, w, h, radius, 0.0)
1465
+ m = self.rect_mask(x, y, w, h, radius)
1466
+ if m is None:
1467
+ return
1468
+ ring = None
1469
+ if stroke is not None and stroke_width > 0:
1470
+ inner = self.rect_mask(x, y, w, h, radius, inset=stroke_width)
1471
+ a = m.a.copy()
1472
+ if inner is not None:
1473
+ oy, ox = inner.y - m.y, inner.x - m.x
1474
+ a[oy:oy + inner.a.shape[0], ox:ox + inner.a.shape[1]] -= inner.a
1475
+ ring = Mask(np.clip(a, 0, 1), m.x, m.y)
1476
+ if fill is not None:
1477
+ self._cast_shadow(m, _shadow_of(shadow), opacity, key=geo)
1478
+ self.fill_mask(m, fill, opacity)
1479
+ elif ring is not None:
1480
+ self._cast_shadow(ring, _shadow_of(shadow), opacity, key=(geo, "ring", self.len(stroke_width)))
1481
+ if ring is not None:
1482
+ self.fill_mask(ring, stroke, opacity)
1483
+
1484
+ def pill(self, x: float, y: float, w: float, h: float, **kw) -> None:
1485
+ self.rect(x, y, w, h, radius=h / 2.0, **kw)
1486
+
1487
+ def card(self, x: float, y: float, w: float, h: float, radius: float = 40.0, opacity: float = 1.0,
1488
+ shadow=True, fill="card", border: bool = True) -> None:
1489
+ """The standard card of the templates: theme fill, hairline border, soft shadow. In papelaria the
1490
+ card sits on a solid lilac clay base 10 px lower, as in the VSL pieces."""
1491
+ th = self.theme
1492
+ if fill == "card" and th.style == "papel" and th.clay is not None:
1493
+ self.rect(x, y + 10.0, w, h, radius=radius, fill=th.clay, opacity=opacity,
1494
+ shadow=Shadow(blur=26.0, dy=14.0, opacity=0.22, color=tuple(th.ink[:3])) if shadow else None)
1495
+ shadow = False
1496
+ elif fill == "card" and th.style == "pop": # hard black shadow, thick black outline
1497
+ self.rect(x + 9.0, y + 9.0, w, h, radius=radius, fill=(17, 17, 17, 255), opacity=opacity)
1498
+ self.rect(x, y, w, h, radius=radius, fill=fill, opacity=opacity, stroke="card_border", stroke_width=4.5)
1499
+ return
1500
+ elif fill == "card" and th.style == "neon": # glow in the accent color around a thin bright border
1501
+ self.rect(x, y, w, h, radius=radius, fill=fill, opacity=opacity,
1502
+ shadow=Shadow(blur=34.0, dy=0.0, opacity=0.75, color=tuple(th.accent[:3])),
1503
+ stroke="accent", stroke_width=3.0)
1504
+ return
1505
+ self.rect(x, y, w, h, radius=radius, fill=fill, opacity=opacity, shadow=shadow,
1506
+ stroke="card_border" if border else None, stroke_width=2.0 if border else 0.0)
1507
+
1508
+ def circle(self, cx: float, cy: float, r: float, fill="accent", opacity: float = 1.0, stroke=None,
1509
+ stroke_width: float = 0.0, shadow=None) -> None:
1510
+ m = self.circle_mask(cx, cy, r)
1511
+ if m is None:
1512
+ return
1513
+ X, Y = self.px(cx, cy)
1514
+ if fill is not None:
1515
+ self._cast_shadow(m, _shadow_of(shadow), opacity, key=("circle", X, Y, self.len(r), self.W, self.H))
1516
+ self.fill_mask(m, fill, opacity)
1517
+ if stroke is not None and stroke_width > 0:
1518
+ if fill is None and shadow:
1519
+ ring = self._ring_mask(cx, cy, r - stroke_width / 2.0, stroke_width)
1520
+ self._cast_shadow(ring, _shadow_of(shadow), opacity,
1521
+ key=("ring", X, Y, self.len(r), self.len(stroke_width), self.W, self.H))
1522
+ self.ring(cx, cy, r - stroke_width / 2.0, stroke_width, color=stroke, opacity=opacity)
1523
+
1524
+ def _ring_mask(self, cx: float, cy: float, r: float, width: float) -> Mask | None:
1525
+ X, Y = self.px(cx, cy)
1526
+ R, half = self.len(r), self.len(width) / 2.0
1527
+
1528
+ def make() -> Mask | None:
1529
+ g = _grid(X - R - half, Y - R - half, X + R + half, Y + R + half, self.W, self.H)
1530
+ if g is None:
1531
+ return None
1532
+ ix, iy, px, py = g
1533
+ d = np.abs(np.sqrt((px - X) ** 2 + (py - Y) ** 2) - R) - half
1534
+ return Mask(_coverage(d).astype(np.float32), ix, iy)
1535
+
1536
+ return _cached_mask(("ringmask", X, Y, R, half, self.W, self.H), make)
1537
+
1538
+ def ring(self, cx: float, cy: float, r: float, width: float, color="accent", opacity: float = 1.0,
1539
+ progress: float = 1.0, start_deg: float = -90.0) -> None:
1540
+ """Circle outline (centered on r); progress < 1 draws an arc clockwise from start_deg with round caps."""
1541
+ if progress <= 0:
1542
+ return
1543
+ if progress >= 1:
1544
+ self.fill_mask(self._ring_mask(cx, cy, r, width), color, opacity)
1545
+ return
1546
+ pts = ellipse_points(cx, cy, r, r, start_deg, 360.0 * clamp01(progress))
1547
+ self.polyline(pts, width, color=color, opacity=opacity)
1548
+
1549
+ def line(self, x0: float, y0: float, x1: float, y1: float, width: float = 8.0, color="accent",
1550
+ opacity: float = 1.0, progress: float = 1.0, shadow=None) -> None:
1551
+ self.polyline([(x0, y0), (x1, y1)], width, color=color, opacity=opacity, progress=progress, shadow=shadow)
1552
+
1553
+ def polyline(self, points: Sequence[Point], width: float = 8.0, color="accent", opacity: float = 1.0,
1554
+ progress: float = 1.0, start: float = 0.0, widths: Sequence[float] | None = None,
1555
+ shadow=None) -> None:
1556
+ """Stroke with round caps; draws the [start, progress] part of the path (by length)."""
1557
+ if progress <= start or opacity <= 0:
1558
+ return
1559
+ pts = list(points)
1560
+ ws = None
1561
+ if progress < 1 or start > 0:
1562
+ if widths:
1563
+ # keep one width per kept point: resample widths along the trimmed path
1564
+ total = path_length(pts)
1565
+ cum = [0.0]
1566
+ for p, q in zip(pts, pts[1:]):
1567
+ cum.append(cum[-1] + math.dist(p, q))
1568
+ pts = trim_points(pts, start, progress)
1569
+ run = start * total
1570
+ ws = []
1571
+ for i, p in enumerate(pts):
1572
+ if i:
1573
+ run += math.dist(pts[i - 1], p)
1574
+ ws.append(float(np.interp(run, cum, list(widths))))
1575
+ else:
1576
+ pts = trim_points(pts, start, progress)
1577
+ elif widths:
1578
+ ws = list(widths)
1579
+ m = self.stroke_mask(pts, width, ws)
1580
+ self._cast_shadow(m, _shadow_of(shadow), opacity)
1581
+ self.fill_mask(m, color, opacity)
1582
+
1583
+ def polygon(self, points: Sequence[Point], fill="accent", opacity: float = 1.0, shadow=None,
1584
+ radius: float = 0.0) -> None:
1585
+ """Filled polygon (radius rounds the corners of a convex one)."""
1586
+ m = self.polygon_mask(points, radius)
1587
+ self._cast_shadow(m, _shadow_of(shadow), opacity)
1588
+ self.fill_mask(m, fill, opacity)
1589
+
1590
+ def arrow(self, x0: float, y0: float, x1: float, y1: float, width: float = 10.0, color="accent",
1591
+ opacity: float = 1.0, progress: float = 1.0, head: float | None = None, curve: float = 0.0,
1592
+ filled: bool = False, shadow=None) -> None:
1593
+ """Arrow from (x0, y0) to (x1, y1), drawn by progress; `curve` bends it sideways (fraction of its length).
1594
+ The head grows in over the last 25% of the progress."""
1595
+ if progress <= 0:
1596
+ return
1597
+ L = math.dist((x0, y0), (x1, y1))
1598
+ if curve:
1599
+ mx, my = (x0 + x1) / 2.0, (y0 + y1) / 2.0
1600
+ nx, ny = -(y1 - y0) / max(L, 1e-6), (x1 - x0) / max(L, 1e-6)
1601
+ pts = bezier_points((x0, y0), (mx + nx * curve * L, my + ny * curve * L), (x1, y1), n=40)
1602
+ else:
1603
+ pts = [(x0, y0), (x1, y1)]
1604
+ head = head if head is not None else max(width * 3.2, 26.0)
1605
+ self.polyline(pts, width, color=color, opacity=opacity, progress=progress, shadow=shadow)
1606
+ hp = remap(progress, 0.75, 1.0)
1607
+ if hp <= 0:
1608
+ return
1609
+ (tx, ty), ang = point_at(pts, progress)
1610
+ size = head * out_back(hp, 2.2)
1611
+ wing = math.radians(32)
1612
+ a1, a2 = ang + math.pi - wing, ang + math.pi + wing
1613
+ p1 = (tx + size * math.cos(a1), ty + size * math.sin(a1))
1614
+ p2 = (tx + size * math.cos(a2), ty + size * math.sin(a2))
1615
+ if filled:
1616
+ tip = (tx + width * 0.6 * math.cos(ang), ty + width * 0.6 * math.sin(ang))
1617
+ self.polygon([tip, p1, p2], fill=color, opacity=opacity)
1618
+ else:
1619
+ self.polyline([p1, (tx, ty), p2], width, color=color, opacity=opacity)
1620
+
1621
+ def check(self, cx: float, cy: float, size: float, width: float | None = None, color="white",
1622
+ opacity: float = 1.0, progress: float = 1.0) -> None:
1623
+ """Check mark centered on (cx, cy), `size` = its width; drawn stroke by stroke with progress."""
1624
+ s = size
1625
+ pts = [(cx - 0.40 * s, cy + 0.02 * s), (cx - 0.12 * s, cy + 0.28 * s), (cx + 0.42 * s, cy - 0.30 * s)]
1626
+ self.polyline(pts, width if width is not None else s * 0.16, color=color, opacity=opacity,
1627
+ progress=out_quad(progress) if progress < 1 else 1.0)
1628
+
1629
+ def cross(self, cx: float, cy: float, size: float, width: float | None = None, color="white",
1630
+ opacity: float = 1.0, progress: float = 1.0) -> None:
1631
+ """X mark: first stroke in the first half of the progress, the second in the other half."""
1632
+ q = size / 2.0
1633
+ wd = width if width is not None else size * 0.16
1634
+ self.polyline([(cx - q, cy - q), (cx + q, cy + q)], wd, color=color, opacity=opacity,
1635
+ progress=clamp01(progress * 2))
1636
+ if progress > 0.5:
1637
+ self.polyline([(cx + q, cy - q), (cx - q, cy + q)], wd, color=color, opacity=opacity,
1638
+ progress=clamp01(progress * 2 - 1))
1639
+
1640
+ def glow(self, draw: Callable[[], None], radius: float = 24.0, opacity: float = 0.6, color="accent") -> None:
1641
+ """Soft colored glow behind whatever `draw()` paints, then the sharp drawing on top."""
1642
+ with self._layer() as lay:
1643
+ draw()
1644
+ if lay.dirty is None:
1645
+ return
1646
+ x0, y0, x1, y1 = lay.dirty
1647
+ part = lay.image.crop((x0, y0, x1, y1))
1648
+ if opacity > 0:
1649
+ a = np.asarray(part.getchannel("A"), np.float32) / 255.0
1650
+ m = blur_mask(Mask(a, x0, y0), self.len(radius), self.W, self.H)
1651
+ self.fill_mask(Mask(np.clip(m.a * 1.6, 0, 1), m.x, m.y), color, opacity)
1652
+ self._paste_layer(part, x0, y0)
1653
+
1654
+ # ---------------------------------------------------------- text
1655
+ def _text_mask(self, x: float, y: float, text: str, f: ImageFont.FreeTypeFont, stroke_px: float,
1656
+ tabular: bool, tracking_px: float) -> Mask | None:
1657
+ """Mask of a text whose baseline-left origin is at render px (x, y)."""
1658
+ if not text:
1659
+ return None
1660
+ asc, desc = f.getmetrics()
1661
+ pad = int(math.ceil(stroke_px)) + 4
1662
+ adv = _advance(f, text, tabular, tracking_px)
1663
+ left = int(math.floor(x)) - pad - int(f.size * 0.25)
1664
+ top = int(math.floor(y - asc * 1.25)) - pad
1665
+ wpx = int(math.ceil(adv + f.size * 0.5)) + 2 * pad
1666
+ hpx = int(math.ceil((asc * 1.25) + desc * 1.4)) + 2 * pad
1667
+ if left >= self.W or top >= self.H or left + wpx <= 0 or top + hpx <= 0:
1668
+ return None
1669
+ img = Image.new("L", (wpx, hpx), 0)
1670
+ d = ImageDraw.Draw(img)
1671
+ ox, oy = x - left, y - top
1672
+ kw = {"stroke_width": stroke_px, "stroke_fill": 255} if stroke_px > 0 else {}
1673
+ if tabular or tracking_px:
1674
+ cell = _digit_cell(f) if tabular else 0.0
1675
+ for ch in text:
1676
+ wch = f.getlength(ch)
1677
+ if tabular and ch.isdigit():
1678
+ d.text((ox + (cell - wch) / 2.0, oy), ch, font=f, fill=255, anchor="ls", **kw)
1679
+ ox += cell
1680
+ else:
1681
+ d.text((ox, oy), ch, font=f, fill=255, anchor="ls", **kw)
1682
+ ox += wch
1683
+ ox += tracking_px
1684
+ else:
1685
+ d.text((ox, oy), text, font=f, fill=255, anchor="ls", **kw)
1686
+ return Mask(np.asarray(img, np.float32) / 255.0, left, top)
1687
+
1688
+ def text(self, x: float, y: float, text: str, font_key: str = "sans_black", size: float = 64.0, color="text",
1689
+ opacity: float = 1.0, anchor: str = "ls", stroke: float = 0.0, stroke_color="black",
1690
+ shadow=None, tabular: bool = False, tracking: float = 0.0, upper: bool = False) -> tuple[float, float, float, float]:
1691
+ """Draw one line. anchor = horizontal (l, m, r) + vertical (a ascender, t cap top, c cap middle,
1692
+ s baseline, d descender). Returns the laid-out box (x0, y0, x1, y1) in box-local design px:
1693
+ advance width by cap top to baseline."""
1694
+ s = clean_text(text, font_key)
1695
+ if upper:
1696
+ s = s.upper()
1697
+ m = measure(s, font_key, size, tabular, tracking)
1698
+ h, v = (anchor + "s")[0], (anchor + "s")[1]
1699
+ dx = {"l": 0.0, "m": -m.width / 2.0, "r": -m.width}.get(h)
1700
+ dy = {"a": m.ascent, "t": m.cap, "c": m.cap / 2.0, "s": 0.0, "d": -m.descent}.get(v)
1701
+ if dx is None or dy is None:
1702
+ raise MotionError(f"âncora de texto inválida: {anchor!r}")
1703
+ bx, by = x + dx, y + dy
1704
+ if opacity > 0 and s:
1705
+ k = self._tf[-1].a * self.s
1706
+ f = font(font_key, size * k)
1707
+ X, Y = self.px(bx, by)
1708
+ sh = _shadow_of(shadow)
1709
+ stroke_px = stroke * k
1710
+ body = self._text_mask(X, Y, s, f, 0.0, tabular, tracking * k)
1711
+ outline = self._text_mask(X, Y, s, f, stroke_px, tabular, tracking * k) if stroke_px > 0 else None
1712
+ self._cast_shadow(outline or body, sh, opacity)
1713
+ if outline is not None:
1714
+ self.fill_mask(outline, stroke_color, opacity)
1715
+ self.fill_mask(body, color, opacity)
1716
+ return bx, by - m.cap, bx + m.width, by
1717
+
1718
+ def rich_text(self, x: float, y: float, text: str, font_key: str = "sans_black", size: float = 64.0,
1719
+ color="text", accent_color="accent", opacity: float = 1.0, anchor: str = "ls",
1720
+ stroke: float = 0.0, stroke_color="black", shadow=None, upper: bool = False,
1721
+ highlight: bool = False) -> tuple[float, float, float, float]:
1722
+ """One line where *marked* words get the accent color, or (highlight=True) an accent box behind them
1723
+ with readable text on it."""
1724
+ parts = parse_accent(clean_text(text, font_key))
1725
+ if upper:
1726
+ parts = [(w.upper(), a) for w, a in parts]
1727
+ f = font(font_key, size)
1728
+ space = f.getlength(" ")
1729
+ widths = [f.getlength(w) for w, _ in parts]
1730
+ total = sum(widths) + space * max(0, len(parts) - 1)
1731
+ m = measure("H", font_key, size)
1732
+ h, v = (anchor + "s")[0], (anchor + "s")[1]
1733
+ dx = {"l": 0.0, "m": -total / 2.0, "r": -total}.get(h)
1734
+ dy = {"a": m.ascent, "t": m.cap, "c": m.cap / 2.0, "s": 0.0, "d": -m.descent}.get(v)
1735
+ if dx is None or dy is None:
1736
+ raise MotionError(f"âncora de texto inválida: {anchor!r}")
1737
+ bx, by = x + dx, y + dy
1738
+ cur = bx
1739
+ for (word, acc), wd in zip(parts, widths):
1740
+ col = color
1741
+ if acc and highlight:
1742
+ padx, pady = size * 0.14, size * 0.16
1743
+ self.rect(cur - padx, by - m.cap - pady, wd + 2 * padx, m.cap + 2 * pady, radius=size * 0.12,
1744
+ fill=accent_color, opacity=opacity)
1745
+ col = readable_on(self.color(accent_color))
1746
+ elif acc:
1747
+ col = accent_color
1748
+ self.text(cur, by, word, font_key, size, color=col, opacity=opacity, anchor="ls", stroke=stroke,
1749
+ stroke_color=stroke_color, shadow=shadow)
1750
+ cur += wd + space
1751
+ return bx, by - m.cap, bx + total, by
1752
+
1753
+ def paragraph(self, x: float, y: float, text: str, font_key: str = "text_bold", size: float = 44.0,
1754
+ max_width: float = 700.0, max_lines: int = 3, line_height: float = 1.22, align: str = "l",
1755
+ color="text", opacity: float = 1.0, min_size: float | None = None, **kw) -> tuple[float, float, float, float]:
1756
+ """Wrapped text (balanced lines) with its first cap top at y. `align` l/m/r relative to x. The size
1757
+ shrinks (down to min_size, default 55%) until the text fits max_lines x max_width; a text that still does
1758
+ not fit is drawn at min_size (limit the text length in the params). Returns (x0, y0, x1, y1)."""
1759
+ size = float(size)
1760
+ floor = float(min_size) if min_size is not None else size * 0.55
1761
+ clean = clean_text(text, font_key)
1762
+ lines = wrap_text(clean, font_key, size, max_width, max_lines)
1763
+ while size > floor and (len(lines) > max_lines or any(
1764
+ measure(ln, font_key, size).width > max_width + 0.5 for ln in lines)):
1765
+ size = max(floor, size * 0.94)
1766
+ lines = wrap_text(clean, font_key, size, max_width, max_lines)
1767
+ m = measure("H", font_key, size)
1768
+ pitch = size * line_height
1769
+ x0, x1 = float("inf"), float("-inf")
1770
+ for i, ln in enumerate(lines[:max(1, max_lines)]):
1771
+ bx0, _, bx1, _ = self.text(x, y + m.cap + pitch * i, ln, font_key, size, color=color, opacity=opacity,
1772
+ anchor=align + "s", **kw)
1773
+ x0, x1 = min(x0, bx0), max(x1, bx1)
1774
+ n = min(len(lines), max(1, max_lines))
1775
+ return (x0 if n else x), y, (x1 if n else x), y + m.cap + pitch * max(0, n - 1)
1776
+
1777
+ def paste_sprite(self, sprite: "Sprite | None", opacity: float = 1.0) -> None:
1778
+ """Paste a part drawn once (`static_sprite`) at its place. Outside a scaled group it is pixel-identical to
1779
+ drawing it again."""
1780
+ if sprite is None:
1781
+ return
1782
+ k = self.box.scale
1783
+ self.paste_image(sprite.image, sprite.x, sprite.y, w=sprite.image.width / k, h=sprite.image.height / k,
1784
+ opacity=opacity)
1785
+
1786
+ def paste_image(self, img: Image.Image, x: float, y: float, w: float | None = None, h: float | None = None,
1787
+ opacity: float = 1.0) -> None:
1788
+ """Paste an RGBA image with its top-left at (x, y), resized to w x h design px (keeps ratio if one is None).
1789
+ (`cv.image` is the frame itself, a PIL image.)"""
1790
+ if opacity <= 0:
1791
+ return
1792
+ src = img.convert("RGBA")
1793
+ if w is None and h is None:
1794
+ w, h = src.width / self.box.scale, src.height / self.box.scale
1795
+ elif w is None:
1796
+ w = h * src.width / src.height
1797
+ elif h is None:
1798
+ h = w * src.height / src.width
1799
+ X0, Y0 = self.px(x, y)
1800
+ tw, th = max(1, int(round(self.len(w)))), max(1, int(round(self.len(h))))
1801
+ if (tw, th) != src.size:
1802
+ src = src.resize((tw, th), Image.LANCZOS)
1803
+ if opacity < 1:
1804
+ a = np.asarray(src.getchannel("A"), np.float32) * opacity
1805
+ src.putalpha(Image.fromarray(np.clip(a + 0.5, 0, 255).astype(np.uint8), "L"))
1806
+ self._paste_layer(src, int(round(X0)), int(round(Y0)))
1807
+
1808
+ # ---------------------------------------------------------- groups
1809
+ @contextmanager
1810
+ def _layer(self):
1811
+ lay = _Layer(Image.new("RGBA", (self.W, self.H), (0, 0, 0, 0)))
1812
+ self._targets.append(lay.image)
1813
+ self._dirty.append(None)
1814
+ try:
1815
+ yield lay
1816
+ finally:
1817
+ self._targets.pop()
1818
+ lay.dirty = self._dirty.pop()
1819
+
1820
+ @contextmanager
1821
+ def group(self, opacity: float = 1.0, scale: float = 1.0, pivot: Point | None = None, dx: float = 0.0,
1822
+ dy: float = 0.0, rotate: float = 0.0):
1823
+ """Draw the block inside as one piece: moved by (dx, dy), scaled around `pivot` (default: the box
1824
+ center), rotated by `rotate` degrees (clockwise, as a picture) and faded as a whole."""
1825
+ px_, py_ = pivot if pivot is not None else (self.cx, self.cy)
1826
+ cur = self._tf[-1]
1827
+ # new(u) = cur(pivot + (u - pivot) * scale + d)
1828
+ nb = cur.point(px_ * (1 - scale) + dx, py_ * (1 - scale) + dy)
1829
+ self._tf.append(_Transform(cur.a * scale, nb[0], nb[1]))
1830
+ needs_layer = opacity < 0.999 or abs(rotate) > 1e-3
1831
+ try:
1832
+ if not needs_layer:
1833
+ yield self
1834
+ return
1835
+ with self._layer() as lay:
1836
+ yield self
1837
+ if opacity <= 0.001 or lay.dirty is None:
1838
+ return
1839
+ x0, y0, x1, y1 = lay.dirty
1840
+ part = lay.image.crop((x0, y0, x1, y1))
1841
+ if abs(rotate) > 1e-3:
1842
+ # rotate on a square around the pivot that holds the drawing in every angle
1843
+ cx, cy = self.px(px_, py_)
1844
+ reach = max(math.hypot(x - cx, y - cy) for x in (x0, x1) for y in (y0, y1))
1845
+ half = int(math.ceil(reach)) + 2
1846
+ sx0, sy0 = int(math.floor(cx)) - half, int(math.floor(cy)) - half
1847
+ square = Image.new("RGBA", (2 * half + 2, 2 * half + 2), (0, 0, 0, 0))
1848
+ square.paste(part, (x0 - sx0, y0 - sy0))
1849
+ part = square.convert("RGBa").rotate(-rotate, resample=Image.BICUBIC,
1850
+ center=(cx - sx0, cy - sy0)).convert("RGBA")
1851
+ x0, y0 = sx0, sy0
1852
+ if opacity < 0.999:
1853
+ a = np.asarray(part.getchannel("A"), np.float32) * opacity
1854
+ part.putalpha(Image.fromarray(np.clip(a + 0.5, 0, 255).astype(np.uint8), "L"))
1855
+ self._paste_layer(part, x0, y0)
1856
+ finally:
1857
+ self._tf.pop()
1858
+
1859
+ def visible(self, opacity: float) -> bool:
1860
+ return opacity > 0.001
1861
+
1862
+ def result(self) -> "Canvas":
1863
+ return self
1864
+
1865
+
1866
+ # ============================================================== parts drawn once
1867
+
1868
+ @dataclass(frozen=True)
1869
+ class Sprite:
1870
+ """A drawn part: RGBA image at render px, its top-left in box-local design px."""
1871
+ image: Image.Image
1872
+ x: float
1873
+ y: float
1874
+
1875
+
1876
+ _SPRITES: "dict[tuple, Sprite | None]" = {}
1877
+ _SPRITES_LOCK = threading.Lock()
1878
+ _SPRITES_MAX = 24
1879
+
1880
+
1881
+ def static_sprite(key: tuple, box: Box, draw: Callable[[Canvas], None]) -> Sprite | None:
1882
+ """Draw `draw(canvas)` once for this box and keep it (the card, title and axes of a chart that never move
1883
+ after the entrance). `key` must name everything the drawing depends on besides the box, e.g.
1884
+ ("grafico_barras", json.dumps(params, sort_keys=True)). Paste it with `cv.paste_sprite(sprite)`.
1885
+ None when nothing was drawn."""
1886
+ full_key = (key, box)
1887
+ with _SPRITES_LOCK:
1888
+ if full_key in _SPRITES:
1889
+ return _SPRITES[full_key]
1890
+ c = Canvas(box)
1891
+ draw(c)
1892
+ bb = c.image.getbbox()
1893
+ sprite = None
1894
+ if bb is not None:
1895
+ s = box.scale
1896
+ sprite = Sprite(c.image.crop(bb), (bb[0] + c.origin_x) / s - box.x, (bb[1] + c.origin_y) / s - box.y)
1897
+ with _SPRITES_LOCK:
1898
+ _SPRITES[full_key] = sprite
1899
+ while len(_SPRITES) > _SPRITES_MAX:
1900
+ _SPRITES.pop(next(iter(_SPRITES)))
1901
+ return sprite
1902
+
1903
+
1904
+ # ============================================================== frame helpers for the renderer
1905
+
1906
+ def blank_frame(canvas: Sequence[int]) -> Image.Image:
1907
+ return Image.new("RGBA", (int(canvas[0]), int(canvas[1])), (0, 0, 0, 0))
1908
+
1909
+
1910
+ def with_box(box: Box, **changes) -> Box:
1911
+ return replace(box, **changes)
1912
+
1913
+
1914
+ def iter_frames(n: int) -> Iterable[int]:
1915
+ return range(max(0, int(n)))