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.
- package/LICENSES/edit-ia-pro.txt +32 -0
- package/LICENSES/fontes-OFL.txt +476 -0
- package/LICENSES/hyperframes-Apache-2.0.txt +205 -0
- package/LICENSES/permanent-marker-Apache-2.0.txt +204 -0
- package/LICENSES/selfie-multiclass-Apache-2.0.txt +227 -0
- package/LICENSES/three-MIT.txt +23 -0
- package/LICENSES/video-use-MIT.txt +24 -0
- package/LICENSES/woven-sfx-CC0.txt +16 -0
- package/LICENSES/yunet-MIT.txt +21 -0
- package/README.md +2 -2
- package/bin/primocode.js +28 -8
- package/editor/LICENSE.md +11 -0
- package/editor/README.md +321 -0
- package/editor/SKILL.md +307 -0
- package/editor/THIRD_PARTY_NOTICES.md +228 -0
- package/editor/assets/baixar.json +591 -0
- package/editor/assets/fonts/anton/OFL.txt +93 -0
- package/editor/assets/fonts/caveat/OFL.txt +93 -0
- package/editor/assets/fonts/figtree/OFL.txt +93 -0
- package/editor/assets/fonts/fonts.json +10 -0
- package/editor/assets/fonts/montserrat/OFL.txt +93 -0
- package/editor/assets/fonts/permanentmarker/LICENSE.txt +202 -0
- package/editor/assets/models/SELFIE_MULTICLASS_LICENSE.txt +227 -0
- package/editor/assets/models/YUNET_LICENSE.txt +21 -0
- package/editor/assets/sfx/licencas/bass_drop.txt +32 -0
- package/editor/assets/sfx/licencas/cash.txt +76 -0
- package/editor/assets/sfx/licencas/charge.txt +31 -0
- package/editor/assets/sfx/licencas/click.txt +46 -0
- package/editor/assets/sfx/licencas/coin.txt +50 -0
- package/editor/assets/sfx/licencas/count.txt +47 -0
- package/editor/assets/sfx/licencas/data.txt +31 -0
- package/editor/assets/sfx/licencas/error.txt +32 -0
- package/editor/assets/sfx/licencas/flash.txt +31 -0
- package/editor/assets/sfx/licencas/gears.txt +82 -0
- package/editor/assets/sfx/licencas/keyboard.txt +48 -0
- package/editor/assets/sfx/licencas/metal_hit.txt +62 -0
- package/editor/assets/sfx/licencas/metal_riser.txt +61 -0
- package/editor/assets/sfx/licencas/mouse_click.txt +50 -0
- package/editor/assets/sfx/licencas/notification.txt +54 -0
- package/editor/assets/sfx/licencas/pop.txt +38 -0
- package/editor/assets/sfx/licencas/rewind.txt +32 -0
- package/editor/assets/sfx/licencas/rise.txt +46 -0
- package/editor/assets/sfx/licencas/sinteticos.txt +21 -0
- package/editor/assets/sfx/licencas/snap.txt +50 -0
- package/editor/assets/sfx/licencas/success.txt +40 -0
- package/editor/assets/sfx/licencas/synth_hit.txt +32 -0
- package/editor/assets/sfx/licencas/tick.txt +14 -0
- package/editor/assets/sfx/licencas/whoosh.txt +47 -0
- package/editor/assets/sfx/licencas/wind.txt +31 -0
- package/editor/assets/sfx/sfx.json +832 -0
- package/editor/assets/sfx/sfx_sinteticos.json +317 -0
- package/editor/assets/sfx/sfx_woven.json +387 -0
- package/editor/assets/sfx/woven/LICENCA.md +16 -0
- package/editor/fichas/briefing-do-video.md +45 -0
- package/editor/fichas/conferencia-da-amostra.md +56 -0
- package/editor/fichas/matriz-de-ganchos.csv +3 -0
- package/editor/fichas/padrao.json +16 -0
- package/editor/fichas/planilha-de-cenas.csv +4 -0
- package/editor/fichas/planilha-de-versoes.csv +4 -0
- package/editor/fichas/plano-em-blocos.md +32 -0
- package/editor/fichas/registro-de-materiais.json +27 -0
- package/editor/helpers/assets_ocr/ocr_vision.swift +41 -0
- package/editor/helpers/bordas_precisas.py +272 -0
- package/editor/helpers/camera_viva.py +426 -0
- package/editor/helpers/captions.py +601 -0
- package/editor/helpers/common.py +308 -0
- package/editor/helpers/compose_final.py +3555 -0
- package/editor/helpers/conferir_corte.py +234 -0
- package/editor/helpers/conferir_dados.py +405 -0
- package/editor/helpers/contact_sheet.py +282 -0
- package/editor/helpers/corte_por_onda.py +433 -0
- package/editor/helpers/corte_seco.py +595 -0
- package/editor/helpers/denoise.py +971 -0
- package/editor/helpers/efeitos.py +408 -0
- package/editor/helpers/efeitos_gerar.py +159 -0
- package/editor/helpers/elevenlabs_api.py +442 -0
- package/editor/helpers/face_layout.py +570 -0
- package/editor/helpers/fetch_images.py +423 -0
- package/editor/helpers/ganho_por_trecho.py +214 -0
- package/editor/helpers/gen_image.py +312 -0
- package/editor/helpers/gen_music.py +316 -0
- package/editor/helpers/gen_sfx.py +148 -0
- package/editor/helpers/grade.py +307 -0
- package/editor/helpers/icloud.py +119 -0
- package/editor/helpers/make_sfx.py +940 -0
- package/editor/helpers/matte.py +1342 -0
- package/editor/helpers/medir_referencia.py +2232 -0
- package/editor/helpers/mixar_trilha.py +354 -0
- package/editor/helpers/montar_corte.py +295 -0
- package/editor/helpers/motion/__init__.py +13 -0
- package/editor/helpers/motion/engine.py +1915 -0
- package/editor/helpers/motion/registry.py +510 -0
- package/editor/helpers/motion/render.py +665 -0
- package/editor/helpers/motion/templates/barra_progresso.py +370 -0
- package/editor/helpers/motion/templates/carimbo.py +132 -0
- package/editor/helpers/motion/templates/checklist.py +271 -0
- package/editor/helpers/motion/templates/comentario_cta.py +380 -0
- package/editor/helpers/motion/templates/contador.py +222 -0
- package/editor/helpers/motion/templates/destaque.py +263 -0
- package/editor/helpers/motion/templates/etiqueta_preco.py +461 -0
- package/editor/helpers/motion/templates/grafico_barras.py +510 -0
- package/editor/helpers/motion/templates/grafico_linha.py +462 -0
- package/editor/helpers/motion/templates/notificacao.py +266 -0
- package/editor/helpers/motion/templates/palavra_impacto.py +410 -0
- package/editor/helpers/motion/templates/passos.py +321 -0
- package/editor/helpers/motion/templates/riscado.py +298 -0
- package/editor/helpers/motion/templates/selo_garantia.py +150 -0
- package/editor/helpers/motion_render.py +241 -0
- package/editor/helpers/motion_sugerir.py +961 -0
- package/editor/helpers/noise_check.py +134 -0
- package/editor/helpers/pack_transcripts.py +223 -0
- package/editor/helpers/palavras_na_onda.py +176 -0
- package/editor/helpers/plano_sfx.py +839 -0
- package/editor/helpers/portao.py +427 -0
- package/editor/helpers/preview_server.py +1177 -0
- package/editor/helpers/rastrear_rosto.py +298 -0
- package/editor/helpers/render_cut.py +1256 -0
- package/editor/helpers/respiros.py +89 -0
- package/editor/helpers/sincronizar_cameras.py +571 -0
- package/editor/helpers/speech_regions.py +423 -0
- package/editor/helpers/style_catalog.json +231 -0
- package/editor/helpers/style_samples.py +246 -0
- package/editor/helpers/tela_verde.py +2113 -0
- package/editor/helpers/text_render.py +2059 -0
- package/editor/helpers/texto_na_tela.py +366 -0
- package/editor/helpers/timeline_view.py +292 -0
- package/editor/helpers/tira_de_quadros.py +113 -0
- package/editor/helpers/transcribe.py +734 -0
- package/editor/helpers/transcribe_batch.py +129 -0
- package/editor/helpers/trilhas.py +1146 -0
- package/editor/helpers/trilhas_humores.json +66 -0
- package/editor/helpers/valores.py +187 -0
- package/editor/helpers/verify_cut.py +820 -0
- package/editor/helpers/voice_levels.py +233 -0
- package/editor/helpers/voz_check.py +386 -0
- package/editor/helpers/voz_limpa.py +81 -0
- package/editor/helpers/watch_preview.py +372 -0
- package/editor/modelo-projeto/LEIA.md +402 -0
- package/editor/modelo-projeto/assets/fontes/OFL-Caveat.txt +93 -0
- package/editor/modelo-projeto/assets/fontes/OFL-JetBrainsMono.txt +93 -0
- package/editor/modelo-projeto/assets/fontes/OFL-Montserrat.txt +93 -0
- package/editor/modelo-projeto/assets/fontes/larguras.json +1 -0
- package/editor/modelo-projeto/assets/three/BufferGeometryUtils.js +1438 -0
- package/editor/modelo-projeto/assets/three/GLTFLoader.js +4895 -0
- package/editor/modelo-projeto/assets/three/LICENSE +21 -0
- package/editor/modelo-projeto/assets/three/ORIGEM.txt +17 -0
- package/editor/modelo-projeto/assets/three/RoomEnvironment.js +185 -0
- package/editor/modelo-projeto/assets/three/three.core.min.js +6 -0
- package/editor/modelo-projeto/assets/three/three.module.min.js +6 -0
- package/editor/modelo-projeto/build.py +4828 -0
- package/editor/modelo-projeto/compositions/cenas/2d/k2d_antes_depois.html +199 -0
- package/editor/modelo-projeto/compositions/cenas/2d/k2d_arquivo.html +232 -0
- package/editor/modelo-projeto/compositions/cenas/2d/k2d_busca.html +315 -0
- package/editor/modelo-projeto/compositions/cenas/2d/k2d_comando.html +304 -0
- package/editor/modelo-projeto/compositions/cenas/2d/k2d_conversa.html +217 -0
- package/editor/modelo-projeto/compositions/cenas/2d/k2d_esquema.html +289 -0
- package/editor/modelo-projeto/compositions/cenas/2d/k2d_medidor.html +249 -0
- package/editor/modelo-projeto/compositions/cenas/2d/k2d_notas.html +233 -0
- package/editor/modelo-projeto/compositions/cenas/3d/k3d_caminho.html +197 -0
- package/editor/modelo-projeto/compositions/cenas/3d/k3d_explodida.html +152 -0
- package/editor/modelo-projeto/compositions/cenas/3d/k3d_tela.html +167 -0
- package/editor/modelo-projeto/compositions/cenas/3d/k3d_volume.html +124 -0
- package/editor/modelo-projeto/compositions/cenas/3d/motor-three.js +728 -0
- package/editor/modelo-projeto/compositions/cenas/LEIA.md +177 -0
- package/editor/modelo-projeto/ferramentas/medir_fontes.py +65 -0
- package/editor/modelo-projeto/ferramentas/pessoa_recortada.py +176 -0
- package/editor/modelo-projeto/ferramentas/rastrear_maos.py +189 -0
- package/editor/modelo-projeto/hyperframes.json +12 -0
- package/editor/modelo-projeto/package.json +11 -0
- package/editor/modelo-projeto/projeto.json +16 -0
- package/editor/preview/app.css +1009 -0
- package/editor/preview/app.js +2122 -0
- package/editor/preview/index.html +307 -0
- package/editor/pyproject.toml +23 -0
- package/editor/references/3d.md +236 -0
- package/editor/references/animacao-catalogo.md +672 -0
- package/editor/references/animacao.md +498 -0
- package/editor/references/comandos.md +92 -0
- package/editor/references/corte-seco-como-funciona.md +50 -0
- package/editor/references/corte-seco.md +88 -0
- package/editor/references/corte.md +290 -0
- package/editor/references/direcao-por-blocos.md +218 -0
- package/editor/references/enquadramento.md +654 -0
- package/editor/references/estilos-prontos.md +355 -0
- package/editor/references/estudio.md +100 -0
- package/editor/references/exemplo-eventos.json +67 -0
- package/editor/references/lettering.md +252 -0
- package/editor/references/modelos.md +494 -0
- package/editor/references/phase2.md +1007 -0
- package/editor/references/referencia-aplicar.md +203 -0
- package/editor/references/referencia-medir.md +375 -0
- package/editor/references/revisao.md +335 -0
- package/editor/references/som-e-trilha.md +146 -0
- package/editor/references/tabela-de-sons.md +130 -0
- package/editor/references/variacoes.md +234 -0
- package/editor/uv.lock +1141 -0
- package/lib/act.js +36 -8
- package/lib/catalogo.js +4 -0
- package/lib/chamada-torta.js +81 -0
- package/lib/editor-pro.js +786 -0
- package/lib/entrega.js +58 -0
- package/lib/estudio/conferencia-visual.js +76 -0
- package/lib/estudio/diretor.js +6 -1
- package/lib/estudio/exportar.js +3 -1
- package/lib/estudio/gosto.js +45 -4
- package/lib/estudio/index.js +8 -5
- package/lib/estudio/kit-ae/base.jsx +119 -13
- package/lib/estudio/kit-ae/cenas.jsx +47 -15
- package/lib/estudio/kit-ae/guarda.jsx +274 -0
- package/lib/estudio/kit-ae/primitivas.jsx +180 -24
- package/lib/estudio/maquina-diretor.js +61 -7
- package/lib/estudio/maquina-foto.js +177 -0
- package/lib/estudio/maquina-guarda.js +207 -0
- package/lib/estudio/maquina-layout.js +22 -1
- package/lib/estudio/maquina.js +60 -11
- package/lib/estudio/referencias.js +1 -1
- package/lib/estudio/visao/visao.swift +97 -0
- package/lib/estudio/visao.js +60 -0
- package/lib/local/cerebro.json +192 -6
- package/lib/local/motor.js +5 -2
- package/lib/mcp.js +105 -7
- package/lib/nuvem.js +11 -1
- package/lib/outra-pasta.js +1 -1
- package/lib/passagem.js +50 -0
- package/lib/perigo.js +29 -12
- package/lib/protecao.js +134 -0
- package/lib/sandbox.js +11 -4
- package/lib/titulo-conversa.js +46 -0
- package/lib/tools.js +3 -0
- package/lib/ui.js +1 -1
- package/package.json +3 -2
- package/skills/editar-meu-video/SKILL.md +6 -2
- package/skills/editar-video/SKILL.md +113 -0
- package/studio/pessoa.py +52 -0
- package/voz/primo_voz/app.py +43 -4
- package/voz/primo_voz/chat.html +17 -6
- package/voz/primo_voz/painel_dados.py +134 -0
|
@@ -0,0 +1,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)))
|