primocode 10.15.2 → 10.17.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (245) hide show
  1. package/LICENSES/edit-ia-pro.txt +32 -0
  2. package/LICENSES/fontes-OFL.txt +476 -0
  3. package/LICENSES/hyperframes-Apache-2.0.txt +205 -0
  4. package/LICENSES/permanent-marker-Apache-2.0.txt +204 -0
  5. package/LICENSES/selfie-multiclass-Apache-2.0.txt +227 -0
  6. package/LICENSES/three-MIT.txt +23 -0
  7. package/LICENSES/video-use-MIT.txt +24 -0
  8. package/LICENSES/woven-sfx-CC0.txt +16 -0
  9. package/LICENSES/yunet-MIT.txt +21 -0
  10. package/README.md +2 -2
  11. package/bin/primocode.js +74 -9
  12. package/editor/LICENSE.md +11 -0
  13. package/editor/README.md +321 -0
  14. package/editor/SKILL.md +307 -0
  15. package/editor/THIRD_PARTY_NOTICES.md +228 -0
  16. package/editor/assets/baixar.json +591 -0
  17. package/editor/assets/fonts/anton/OFL.txt +93 -0
  18. package/editor/assets/fonts/caveat/OFL.txt +93 -0
  19. package/editor/assets/fonts/figtree/OFL.txt +93 -0
  20. package/editor/assets/fonts/fonts.json +10 -0
  21. package/editor/assets/fonts/montserrat/OFL.txt +93 -0
  22. package/editor/assets/fonts/permanentmarker/LICENSE.txt +202 -0
  23. package/editor/assets/models/SELFIE_MULTICLASS_LICENSE.txt +227 -0
  24. package/editor/assets/models/YUNET_LICENSE.txt +21 -0
  25. package/editor/assets/sfx/licencas/bass_drop.txt +32 -0
  26. package/editor/assets/sfx/licencas/cash.txt +76 -0
  27. package/editor/assets/sfx/licencas/charge.txt +31 -0
  28. package/editor/assets/sfx/licencas/click.txt +46 -0
  29. package/editor/assets/sfx/licencas/coin.txt +50 -0
  30. package/editor/assets/sfx/licencas/count.txt +47 -0
  31. package/editor/assets/sfx/licencas/data.txt +31 -0
  32. package/editor/assets/sfx/licencas/error.txt +32 -0
  33. package/editor/assets/sfx/licencas/flash.txt +31 -0
  34. package/editor/assets/sfx/licencas/gears.txt +82 -0
  35. package/editor/assets/sfx/licencas/keyboard.txt +48 -0
  36. package/editor/assets/sfx/licencas/metal_hit.txt +62 -0
  37. package/editor/assets/sfx/licencas/metal_riser.txt +61 -0
  38. package/editor/assets/sfx/licencas/mouse_click.txt +50 -0
  39. package/editor/assets/sfx/licencas/notification.txt +54 -0
  40. package/editor/assets/sfx/licencas/pop.txt +38 -0
  41. package/editor/assets/sfx/licencas/rewind.txt +32 -0
  42. package/editor/assets/sfx/licencas/rise.txt +46 -0
  43. package/editor/assets/sfx/licencas/sinteticos.txt +21 -0
  44. package/editor/assets/sfx/licencas/snap.txt +50 -0
  45. package/editor/assets/sfx/licencas/success.txt +40 -0
  46. package/editor/assets/sfx/licencas/synth_hit.txt +32 -0
  47. package/editor/assets/sfx/licencas/tick.txt +14 -0
  48. package/editor/assets/sfx/licencas/whoosh.txt +47 -0
  49. package/editor/assets/sfx/licencas/wind.txt +31 -0
  50. package/editor/assets/sfx/sfx.json +832 -0
  51. package/editor/assets/sfx/sfx_sinteticos.json +317 -0
  52. package/editor/assets/sfx/sfx_woven.json +387 -0
  53. package/editor/assets/sfx/woven/LICENCA.md +16 -0
  54. package/editor/fichas/briefing-do-video.md +45 -0
  55. package/editor/fichas/conferencia-da-amostra.md +56 -0
  56. package/editor/fichas/matriz-de-ganchos.csv +3 -0
  57. package/editor/fichas/padrao.json +16 -0
  58. package/editor/fichas/planilha-de-cenas.csv +4 -0
  59. package/editor/fichas/planilha-de-versoes.csv +4 -0
  60. package/editor/fichas/plano-em-blocos.md +32 -0
  61. package/editor/fichas/registro-de-materiais.json +27 -0
  62. package/editor/helpers/assets_ocr/ocr_vision.swift +41 -0
  63. package/editor/helpers/bordas_precisas.py +272 -0
  64. package/editor/helpers/camera_viva.py +426 -0
  65. package/editor/helpers/captions.py +601 -0
  66. package/editor/helpers/common.py +308 -0
  67. package/editor/helpers/compose_final.py +3555 -0
  68. package/editor/helpers/conferir_corte.py +234 -0
  69. package/editor/helpers/conferir_dados.py +405 -0
  70. package/editor/helpers/contact_sheet.py +282 -0
  71. package/editor/helpers/corte_por_onda.py +433 -0
  72. package/editor/helpers/corte_seco.py +595 -0
  73. package/editor/helpers/denoise.py +971 -0
  74. package/editor/helpers/efeitos.py +408 -0
  75. package/editor/helpers/efeitos_gerar.py +159 -0
  76. package/editor/helpers/elevenlabs_api.py +442 -0
  77. package/editor/helpers/face_layout.py +570 -0
  78. package/editor/helpers/fetch_images.py +423 -0
  79. package/editor/helpers/ganho_por_trecho.py +214 -0
  80. package/editor/helpers/gen_image.py +312 -0
  81. package/editor/helpers/gen_music.py +316 -0
  82. package/editor/helpers/gen_sfx.py +148 -0
  83. package/editor/helpers/grade.py +307 -0
  84. package/editor/helpers/icloud.py +119 -0
  85. package/editor/helpers/make_sfx.py +940 -0
  86. package/editor/helpers/matte.py +1342 -0
  87. package/editor/helpers/medir_referencia.py +2232 -0
  88. package/editor/helpers/mixar_trilha.py +354 -0
  89. package/editor/helpers/montar_corte.py +295 -0
  90. package/editor/helpers/motion/__init__.py +13 -0
  91. package/editor/helpers/motion/engine.py +1915 -0
  92. package/editor/helpers/motion/registry.py +510 -0
  93. package/editor/helpers/motion/render.py +665 -0
  94. package/editor/helpers/motion/templates/barra_progresso.py +370 -0
  95. package/editor/helpers/motion/templates/carimbo.py +132 -0
  96. package/editor/helpers/motion/templates/checklist.py +271 -0
  97. package/editor/helpers/motion/templates/comentario_cta.py +380 -0
  98. package/editor/helpers/motion/templates/contador.py +222 -0
  99. package/editor/helpers/motion/templates/destaque.py +263 -0
  100. package/editor/helpers/motion/templates/etiqueta_preco.py +461 -0
  101. package/editor/helpers/motion/templates/grafico_barras.py +510 -0
  102. package/editor/helpers/motion/templates/grafico_linha.py +462 -0
  103. package/editor/helpers/motion/templates/notificacao.py +266 -0
  104. package/editor/helpers/motion/templates/palavra_impacto.py +410 -0
  105. package/editor/helpers/motion/templates/passos.py +321 -0
  106. package/editor/helpers/motion/templates/riscado.py +298 -0
  107. package/editor/helpers/motion/templates/selo_garantia.py +150 -0
  108. package/editor/helpers/motion_render.py +241 -0
  109. package/editor/helpers/motion_sugerir.py +961 -0
  110. package/editor/helpers/noise_check.py +134 -0
  111. package/editor/helpers/pack_transcripts.py +223 -0
  112. package/editor/helpers/palavras_na_onda.py +176 -0
  113. package/editor/helpers/plano_sfx.py +839 -0
  114. package/editor/helpers/portao.py +427 -0
  115. package/editor/helpers/preview_server.py +1177 -0
  116. package/editor/helpers/rastrear_rosto.py +298 -0
  117. package/editor/helpers/render_cut.py +1256 -0
  118. package/editor/helpers/respiros.py +89 -0
  119. package/editor/helpers/sincronizar_cameras.py +571 -0
  120. package/editor/helpers/speech_regions.py +423 -0
  121. package/editor/helpers/style_catalog.json +231 -0
  122. package/editor/helpers/style_samples.py +246 -0
  123. package/editor/helpers/tela_verde.py +2113 -0
  124. package/editor/helpers/text_render.py +2059 -0
  125. package/editor/helpers/texto_na_tela.py +366 -0
  126. package/editor/helpers/timeline_view.py +292 -0
  127. package/editor/helpers/tira_de_quadros.py +113 -0
  128. package/editor/helpers/transcribe.py +734 -0
  129. package/editor/helpers/transcribe_batch.py +129 -0
  130. package/editor/helpers/trilhas.py +1146 -0
  131. package/editor/helpers/trilhas_humores.json +66 -0
  132. package/editor/helpers/valores.py +187 -0
  133. package/editor/helpers/verify_cut.py +820 -0
  134. package/editor/helpers/voice_levels.py +233 -0
  135. package/editor/helpers/voz_check.py +386 -0
  136. package/editor/helpers/voz_limpa.py +81 -0
  137. package/editor/helpers/watch_preview.py +372 -0
  138. package/editor/modelo-projeto/LEIA.md +402 -0
  139. package/editor/modelo-projeto/assets/fontes/OFL-Caveat.txt +93 -0
  140. package/editor/modelo-projeto/assets/fontes/OFL-JetBrainsMono.txt +93 -0
  141. package/editor/modelo-projeto/assets/fontes/OFL-Montserrat.txt +93 -0
  142. package/editor/modelo-projeto/assets/fontes/larguras.json +1 -0
  143. package/editor/modelo-projeto/assets/three/BufferGeometryUtils.js +1438 -0
  144. package/editor/modelo-projeto/assets/three/GLTFLoader.js +4895 -0
  145. package/editor/modelo-projeto/assets/three/LICENSE +21 -0
  146. package/editor/modelo-projeto/assets/three/ORIGEM.txt +17 -0
  147. package/editor/modelo-projeto/assets/three/RoomEnvironment.js +185 -0
  148. package/editor/modelo-projeto/assets/three/three.core.min.js +6 -0
  149. package/editor/modelo-projeto/assets/three/three.module.min.js +6 -0
  150. package/editor/modelo-projeto/build.py +4828 -0
  151. package/editor/modelo-projeto/compositions/cenas/2d/k2d_antes_depois.html +199 -0
  152. package/editor/modelo-projeto/compositions/cenas/2d/k2d_arquivo.html +232 -0
  153. package/editor/modelo-projeto/compositions/cenas/2d/k2d_busca.html +315 -0
  154. package/editor/modelo-projeto/compositions/cenas/2d/k2d_comando.html +304 -0
  155. package/editor/modelo-projeto/compositions/cenas/2d/k2d_conversa.html +217 -0
  156. package/editor/modelo-projeto/compositions/cenas/2d/k2d_esquema.html +289 -0
  157. package/editor/modelo-projeto/compositions/cenas/2d/k2d_medidor.html +249 -0
  158. package/editor/modelo-projeto/compositions/cenas/2d/k2d_notas.html +233 -0
  159. package/editor/modelo-projeto/compositions/cenas/3d/k3d_caminho.html +197 -0
  160. package/editor/modelo-projeto/compositions/cenas/3d/k3d_explodida.html +152 -0
  161. package/editor/modelo-projeto/compositions/cenas/3d/k3d_tela.html +167 -0
  162. package/editor/modelo-projeto/compositions/cenas/3d/k3d_volume.html +124 -0
  163. package/editor/modelo-projeto/compositions/cenas/3d/motor-three.js +728 -0
  164. package/editor/modelo-projeto/compositions/cenas/LEIA.md +177 -0
  165. package/editor/modelo-projeto/ferramentas/medir_fontes.py +65 -0
  166. package/editor/modelo-projeto/ferramentas/pessoa_recortada.py +176 -0
  167. package/editor/modelo-projeto/ferramentas/rastrear_maos.py +189 -0
  168. package/editor/modelo-projeto/hyperframes.json +12 -0
  169. package/editor/modelo-projeto/package.json +11 -0
  170. package/editor/modelo-projeto/projeto.json +16 -0
  171. package/editor/preview/app.css +1009 -0
  172. package/editor/preview/app.js +2122 -0
  173. package/editor/preview/index.html +307 -0
  174. package/editor/pyproject.toml +23 -0
  175. package/editor/references/3d.md +236 -0
  176. package/editor/references/animacao-catalogo.md +672 -0
  177. package/editor/references/animacao.md +498 -0
  178. package/editor/references/comandos.md +92 -0
  179. package/editor/references/corte-seco-como-funciona.md +50 -0
  180. package/editor/references/corte-seco.md +88 -0
  181. package/editor/references/corte.md +290 -0
  182. package/editor/references/direcao-por-blocos.md +218 -0
  183. package/editor/references/enquadramento.md +654 -0
  184. package/editor/references/estilos-prontos.md +355 -0
  185. package/editor/references/estudio.md +100 -0
  186. package/editor/references/exemplo-eventos.json +67 -0
  187. package/editor/references/lettering.md +252 -0
  188. package/editor/references/modelos.md +494 -0
  189. package/editor/references/phase2.md +1007 -0
  190. package/editor/references/referencia-aplicar.md +203 -0
  191. package/editor/references/referencia-medir.md +375 -0
  192. package/editor/references/revisao.md +335 -0
  193. package/editor/references/som-e-trilha.md +146 -0
  194. package/editor/references/tabela-de-sons.md +130 -0
  195. package/editor/references/variacoes.md +234 -0
  196. package/editor/uv.lock +1141 -0
  197. package/lib/act.js +38 -8
  198. package/lib/app-chat.js +97 -0
  199. package/lib/catalogo.js +7 -1
  200. package/lib/chamada-torta.js +81 -0
  201. package/lib/editor-pro.js +786 -0
  202. package/lib/entrega.js +58 -0
  203. package/lib/estudio/conferencia-visual.js +76 -0
  204. package/lib/estudio/diretor.js +6 -1
  205. package/lib/estudio/exportar.js +3 -1
  206. package/lib/estudio/gosto/tela.html +1349 -0
  207. package/lib/estudio/gosto.js +305 -0
  208. package/lib/estudio/index.js +12 -1
  209. package/lib/estudio/kit-ae/base.jsx +119 -13
  210. package/lib/estudio/kit-ae/cenas.jsx +47 -15
  211. package/lib/estudio/kit-ae/guarda.jsx +274 -0
  212. package/lib/estudio/kit-ae/primitivas.jsx +180 -24
  213. package/lib/estudio/maquina-diretor.js +70 -12
  214. package/lib/estudio/maquina-foto.js +177 -0
  215. package/lib/estudio/maquina-guarda.js +207 -0
  216. package/lib/estudio/maquina-layout.js +22 -1
  217. package/lib/estudio/maquina.js +64 -11
  218. package/lib/estudio/referencias.js +1 -1
  219. package/lib/estudio/visao/visao.swift +97 -0
  220. package/lib/estudio/visao.js +60 -0
  221. package/lib/headless.js +15 -3
  222. package/lib/local/cerebro.json +285 -7
  223. package/lib/local/motor.js +5 -2
  224. package/lib/memoria-usuario.js +164 -0
  225. package/lib/nuvem.js +33 -2
  226. package/lib/passagem.js +50 -0
  227. package/lib/perigo.js +19 -11
  228. package/lib/protecao.js +134 -0
  229. package/lib/repeticao.js +1 -1
  230. package/lib/sandbox.js +57 -9
  231. package/lib/titulo-conversa.js +46 -0
  232. package/lib/tools.js +23 -4
  233. package/lib/ui.js +2 -2
  234. package/package.json +3 -2
  235. package/skills/editar-meu-video/SKILL.md +6 -2
  236. package/skills/editar-video/SKILL.md +113 -0
  237. package/studio/pessoa.py +52 -0
  238. package/voz/primo_voz/__main__.py +55 -40
  239. package/voz/primo_voz/app.py +500 -4
  240. package/voz/primo_voz/chat.html +1773 -0
  241. package/voz/primo_voz/conversa.py +0 -26
  242. package/voz/primo_voz/hud.html +8 -2
  243. package/voz/primo_voz/hud.py +129 -5
  244. package/voz/primo_voz/identidade.py +1 -1
  245. package/voz/primo_voz/painel_dados.py +134 -0
@@ -0,0 +1,2059 @@
1
+ """Text rendering for editor-video: captions, hooks, CTA and disclaimer (Contract 7).
2
+
3
+ Everything is drawn with PIL on a transparent RGBA canvas of the output size and
4
+ composited later with ffmpeg `overlay` (the dev ffmpeg has no drawtext/subtitles/ass).
5
+
6
+ Public API (see SPEC.md, Contract 7):
7
+ render_hook, render_caption_track, render_cta, render_disclaimer,
8
+ render_sample_frame, write_ffconcat
9
+ Extras:
10
+ hook_fit (lines/size a hook gets, flags too-long text), render_caption_image (one steady
11
+ caption state), write_blank_png, caption_style_ids, hook_style_ids, caption_style_info,
12
+ display_words, measure_caption_width, caption_width_limit, is_weak_word, cover_image,
13
+ layout_defaults / canvas_shape (preferred text positions per canvas shape), caption_extent
14
+ (how far a caption track reaches above and below its center line), sanitize_text /
15
+ text_issues (symbols the bundled fonts cannot draw, *markup* problems)
16
+
17
+ Hook text may mark accent words with asterisks: "Você está *perdendo venda* assim". A word is
18
+ never split by the markup ("*Kiwi*fy" colors the whole word), `\\*` draws a real asterisk and an
19
+ unpaired `*` is ignored (text_issues reports it). The CTA and the disclaimer have no accent
20
+ markup: their asterisks are removed.
21
+
22
+ Characters a bundled font cannot draw (emoji, ★, ✓ ...) are never drawn as boxes: simple ones get
23
+ a look-alike (→ becomes ›), the rest are removed, and text_issues lists them.
24
+
25
+ CLI (quick checks, PT-BR messages):
26
+ text_render.py list
27
+ text_render.py hook "texto" --style ID [--accent HEX] [--bg frame.png] -o out.png
28
+ text_render.py caption "texto" --style ID [--active 1] [--bg frame.png] -o out.png
29
+ text_render.py cta "texto" [--sub "..."] [--bg frame.png] -o out.png
30
+ text_render.py disclaimer "texto" [--bg frame.png] -o out.png
31
+ text_render.py track captions.json --style ID -o DIR [--fps 30] [--duration S] [--width 1080 --height 1920]
32
+ """
33
+
34
+ from __future__ import annotations
35
+
36
+ import argparse
37
+ import hashlib
38
+ import json
39
+ import math
40
+ import os
41
+ import re
42
+ import sys
43
+ import threading
44
+ import time
45
+ import unicodedata
46
+ from concurrent.futures import ThreadPoolExecutor
47
+ from dataclasses import dataclass
48
+ from pathlib import Path
49
+
50
+ from PIL import Image, ImageDraw, ImageFilter
51
+
52
+ sys.path.insert(0, str(Path(__file__).resolve().parent))
53
+ from common import SAFE_ZONE_VERTICAL, hex_to_rgb, load_font, load_style_catalog # noqa: E402
54
+
55
+ REF_W, REF_H = 1080, 1920
56
+ SIDE_MARGIN = 90
57
+ DEFAULT_ACCENT = "#FFC400"
58
+
59
+ # Preferred text positions and the platform-safe band per canvas shape, as fractions of the
60
+ # canvas height. The vertical row is the validated 1080x1920 layout (SAFE_ZONE_VERTICAL). Type
61
+ # keeps its size on a horizontal or square canvas (it scales with the short side), so scaling the
62
+ # vertical y values down would squeeze full-size layers into each other: those shapes get their
63
+ # own rows.
64
+ LAYOUT_FRACTIONS: dict[str, dict[str, float]] = {
65
+ "vertical": {"top": SAFE_ZONE_VERTICAL["top"] / REF_H, "bottom": SAFE_ZONE_VERTICAL["bottom"] / REF_H,
66
+ "hook": 430 / REF_H, "caption": SAFE_ZONE_VERTICAL["caption_center_y"] / REF_H,
67
+ "cta": 1110 / REF_H, "cta_alone": 1180 / REF_H, "disclaimer": SAFE_ZONE_VERTICAL["disclaimer_y"] / REF_H},
68
+ "horizontal": {"top": 0.06, "bottom": 0.965, "hook": 0.2, "caption": 0.78, "cta": 0.45, "cta_alone": 0.5,
69
+ "disclaimer": 0.935},
70
+ "square": {"top": 0.07, "bottom": 0.955, "hook": 0.2, "caption": 0.76, "cta": 0.46, "cta_alone": 0.52,
71
+ "disclaimer": 0.93},
72
+ }
73
+
74
+
75
+ def canvas_shape(canvas) -> str:
76
+ w, h = int(canvas[0]), int(canvas[1])
77
+ if h > w * 1.05:
78
+ return "vertical"
79
+ if w > h * 1.05:
80
+ return "horizontal"
81
+ return "square"
82
+
83
+
84
+ def layout_defaults(canvas) -> dict[str, float]:
85
+ """Preferred centers and the safe band in pixels of `canvas`: top, bottom, hook, caption, cta
86
+ (with captions), cta_alone (without captions), disclaimer."""
87
+ h = float(canvas[1])
88
+ return {k: v * h for k, v in LAYOUT_FRACTIONS[canvas_shape(canvas)].items()}
89
+ # Hash of this file: part of every cached PNG name, so a code change never reuses stale art.
90
+ _SRC_HASH = hashlib.sha1(Path(__file__).read_bytes()).hexdigest()[:10]
91
+
92
+ WHITE = (255, 255, 255, 255)
93
+ BLACK = (0, 0, 0, 255)
94
+ INK = (17, 17, 17, 255)
95
+
96
+ # Portuguese function words that read badly at the end of a line or cue.
97
+ WEAK_WORDS = {
98
+ "a", "o", "as", "os", "um", "uma", "uns", "umas", "de", "da", "do", "das", "dos", "em", "no",
99
+ "na", "nos", "nas", "e", "ou", "que", "pra", "para", "pro", "pros", "pras", "por", "pelo",
100
+ "pela", "pelos", "pelas", "com", "sem", "se", "mas", "meu", "minha", "meus", "minhas", "seu",
101
+ "sua", "seus", "suas", "te", "me", "lhe", "não", "nao", "é", "ao", "aos", "à", "às", "num",
102
+ "numa", "tipo", "cada", "esse", "essa", "este", "esta", "isso", "the",
103
+ }
104
+
105
+
106
+ # ============================================================== small utilities
107
+
108
+ _tls = threading.local()
109
+
110
+
111
+ def _font(key: str, size: float):
112
+ """Per-thread cached bundled font (FreeType faces are not shared across threads)."""
113
+ cache = getattr(_tls, "fonts", None)
114
+ if cache is None:
115
+ cache = _tls.fonts = {}
116
+ px = max(6, int(round(size)))
117
+ f = cache.get((key, px))
118
+ if f is None:
119
+ f = cache[(key, px)] = load_font(key, px)
120
+ return f
121
+
122
+
123
+ def _cap_height(font) -> float:
124
+ cache = getattr(_tls, "caps", None)
125
+ if cache is None:
126
+ cache = _tls.caps = {}
127
+ key = (font.path, font.size, id(font))
128
+ v = cache.get(key)
129
+ if v is None:
130
+ _, top, _, _ = font.getbbox("H", anchor="ls")
131
+ v = cache[key] = float(-top)
132
+ return v
133
+
134
+
135
+ def _rgba(color, alpha: int = 255) -> tuple[int, int, int, int]:
136
+ if isinstance(color, str):
137
+ r, g, b = hex_to_rgb(color)
138
+ return (r, g, b, alpha)
139
+ if len(color) == 3:
140
+ return (color[0], color[1], color[2], alpha)
141
+ return tuple(color) # type: ignore[return-value]
142
+
143
+
144
+ def _luminance(rgb) -> float:
145
+ def lin(c):
146
+ c = c / 255.0
147
+ return c / 12.92 if c <= 0.03928 else ((c + 0.055) / 1.055) ** 2.4
148
+
149
+ r, g, b = rgb[:3]
150
+ return 0.2126 * lin(r) + 0.7152 * lin(g) + 0.0722 * lin(b)
151
+
152
+
153
+ def _on_color(bg) -> tuple[int, int, int, int]:
154
+ """Readable text color on top of a solid background color."""
155
+ lum = _luminance(bg)
156
+ contrast_black = (lum + 0.05) / 0.05
157
+ contrast_white = 1.05 / (lum + 0.05)
158
+ return INK if contrast_black >= contrast_white else WHITE
159
+
160
+
161
+ def _new(w: float, h: float) -> Image.Image:
162
+ return Image.new("RGBA", (max(1, int(math.ceil(w))), max(1, int(math.ceil(h)))), (0, 0, 0, 0))
163
+
164
+
165
+ def _mul_alpha(img: Image.Image, factor: float) -> Image.Image:
166
+ if factor >= 0.999:
167
+ return img
168
+ out = img.copy()
169
+ out.putalpha(img.getchannel("A").point(lambda v: int(v * factor)))
170
+ return out
171
+
172
+
173
+ def _rotate(img: Image.Image, angle: float) -> tuple[Image.Image, float, float]:
174
+ """Rotate around the center with premultiplied alpha (no dark fringes).
175
+
176
+ Returns (image, dx, dy): offset of the new center relative to the old one is zero, so an
177
+ anchor (ax, ay) maps to (ax' , ay') = rotate((ax-cx, ay-cy)) + new center.
178
+ """
179
+ if abs(angle) < 0.01:
180
+ return img, 0.0, 0.0
181
+ pm = img.convert("RGBa")
182
+ rot = pm.rotate(angle, resample=Image.BICUBIC, expand=True).convert("RGBA")
183
+ return rot, (rot.width - img.width) / 2.0, (rot.height - img.height) / 2.0
184
+
185
+
186
+ def _rotate_point(x: float, y: float, cx: float, cy: float, angle: float) -> tuple[float, float]:
187
+ a = math.radians(angle)
188
+ dx, dy = x - cx, y - cy
189
+ # PIL rotates counter-clockwise for positive angles (y axis pointing down).
190
+ return cx + dx * math.cos(a) + dy * math.sin(a), cy - dx * math.sin(a) + dy * math.cos(a)
191
+
192
+
193
+ def _shape(w: int, h: int, draw_fn, color, ss: int = 4) -> Image.Image:
194
+ """Anti-aliased filled shape: draw_fn(draw, scale) on a supersampled L mask."""
195
+ w, h = max(1, int(w)), max(1, int(h))
196
+ mask = Image.new("L", (w * ss, h * ss), 0)
197
+ draw_fn(ImageDraw.Draw(mask), ss)
198
+ mask = mask.resize((w, h), Image.LANCZOS)
199
+ rgba = _rgba(color)
200
+ solid = Image.new("RGBA", (w, h), rgba)
201
+ if rgba[3] < 255:
202
+ mask = mask.point(lambda v, a=rgba[3]: v * a // 255)
203
+ solid.putalpha(mask)
204
+ return solid
205
+
206
+
207
+ def _rounded_box(layer: Image.Image, box, radius: float, color) -> None:
208
+ x0, y0, x1, y1 = box
209
+ ix0, iy0 = int(math.floor(x0)), int(math.floor(y0))
210
+ w, h = int(math.ceil(x1 - ix0)), int(math.ceil(y1 - iy0))
211
+ fx0, fy0 = x0 - ix0, y0 - iy0
212
+ r = max(0.0, min(radius, (x1 - x0) / 2, (y1 - y0) / 2))
213
+
214
+ def fn(d, s):
215
+ d.rounded_rectangle((fx0 * s, fy0 * s, (x1 - ix0) * s - 1, (y1 - iy0) * s - 1), radius=r * s, fill=255)
216
+
217
+ _alpha_paste(layer, _shape(w, h, fn, color), ix0, iy0)
218
+
219
+
220
+ def _polygon(layer: Image.Image, points, color) -> None:
221
+ xs = [p[0] for p in points]
222
+ ys = [p[1] for p in points]
223
+ ix0, iy0 = int(math.floor(min(xs))), int(math.floor(min(ys)))
224
+ w, h = int(math.ceil(max(xs) - ix0)) + 1, int(math.ceil(max(ys) - iy0)) + 1
225
+
226
+ def fn(d, s):
227
+ d.polygon([((x - ix0) * s, (y - iy0) * s) for x, y in points], fill=255)
228
+
229
+ _alpha_paste(layer, _shape(w, h, fn, color), ix0, iy0)
230
+
231
+
232
+ def _alpha_paste(dst: Image.Image, src: Image.Image, x: int, y: int) -> None:
233
+ """alpha_composite that accepts negative / overflowing positions."""
234
+ x0, y0 = max(0, x), max(0, y)
235
+ x1, y1 = min(dst.width, x + src.width), min(dst.height, y + src.height)
236
+ if x1 <= x0 or y1 <= y0:
237
+ return
238
+ crop = src.crop((x0 - x, y0 - y, x1 - x, y1 - y))
239
+ dst.alpha_composite(crop, (x0, y0))
240
+
241
+
242
+ def _shadow_of(layer: Image.Image, blur: float, alpha: float, color=(0, 0, 0)) -> Image.Image:
243
+ """Soft shadow image (same size) from the alpha of `layer`."""
244
+ a = layer.getchannel("A")
245
+ if blur > 0.5:
246
+ a = a.filter(ImageFilter.GaussianBlur(blur))
247
+ if alpha < 0.999:
248
+ a = a.point(lambda v: int(v * alpha))
249
+ sh = Image.new("RGBA", layer.size, _rgba(color))
250
+ sh.putalpha(a)
251
+ return sh
252
+
253
+
254
+ # ============================================================== geometry
255
+
256
+ @dataclass(frozen=True)
257
+ class _Geo:
258
+ w: int
259
+ h: int
260
+
261
+ @property
262
+ def k(self) -> float: # type scale (1.0 on 1080 wide vertical)
263
+ return min(self.w, self.h) / REF_W
264
+
265
+ @property
266
+ def sy(self) -> float:
267
+ return self.h / REF_H
268
+
269
+ @property
270
+ def sx(self) -> float:
271
+ return self.w / REF_W
272
+
273
+ @property
274
+ def cx(self) -> float:
275
+ return self.w / 2.0
276
+
277
+ @property
278
+ def caption_max_w(self) -> float:
279
+ rail = SAFE_ZONE_VERTICAL["right_rail"] * self.sx
280
+ return min(self.w - 2 * SIDE_MARGIN * self.sx, 2 * (self.w / 2.0 - rail))
281
+
282
+ @property
283
+ def safe_top(self) -> float:
284
+ return LAYOUT_FRACTIONS[canvas_shape((self.w, self.h))]["top"] * self.h
285
+
286
+ @property
287
+ def safe_bottom(self) -> float:
288
+ return LAYOUT_FRACTIONS[canvas_shape((self.w, self.h))]["bottom"] * self.h
289
+
290
+
291
+ # ============================================================== text helpers
292
+
293
+ _TRAIL_STRIP = ",.;:…"
294
+ _QUOTES = "\"“”«»'‘’()[]{}"
295
+
296
+
297
+ def _clean_word(text: str, keep_punct: bool) -> str:
298
+ t = str(text).strip()
299
+ if keep_punct:
300
+ return t
301
+ t = t.strip(_QUOTES)
302
+ t = t.rstrip(_TRAIL_STRIP).lstrip("¿¡")
303
+ t = t.strip(_QUOTES)
304
+ if t in ("-", "–", "—"):
305
+ return ""
306
+ return t
307
+
308
+
309
+ def _case(text: str, upper: bool) -> str:
310
+ return text.upper() if upper else text
311
+
312
+
313
+ # ---------------------------------------------------------------- glyph coverage
314
+
315
+ _GLYPHS: dict[tuple[str, str], bool] = {}
316
+ _NOTDEF = "" # private use code point: no bundled font has it, so it shows the .notdef glyph
317
+ # look-alikes for symbols some fonts lack (first one the font has wins; nothing fits -> removed)
318
+ _SYMBOL_FALLBACKS = {
319
+ "→": ("›", ">"), "←": ("‹", "<"), "➜": ("›", ">"), "№": ("Nº", "No"), "…": ("...",),
320
+ "“": ('"',), "”": ('"',), "„": ('"',), "‘": ("'",), "’": ("'",), "—": ("-",), "–": ("-",),
321
+ "•": ("·", "-"), "×": ("x",),
322
+ }
323
+
324
+
325
+ def _has_glyph(font_key: str, ch: str) -> bool:
326
+ """True when the bundled font draws `ch` (its mask and advance differ from the .notdef glyph)."""
327
+ key = (font_key, ch)
328
+ v = _GLYPHS.get(key)
329
+ if v is None:
330
+ f = _font(font_key, 48)
331
+ nd, m = f.getmask(_NOTDEF), f.getmask(ch)
332
+ v = not (m.size == nd.size and bytes(m) == bytes(nd) and abs(f.getlength(ch) - f.getlength(_NOTDEF)) < 0.01)
333
+ _GLYPHS[key] = v
334
+ return v
335
+
336
+
337
+ def sanitize_text(text: str, font_key: str) -> tuple[str, list[str]]:
338
+ """Text the font can draw: look-alikes for simple symbols, unknown ones removed.
339
+
340
+ Returns (clean text, removed characters in order, no repeats). Line breaks are kept, other
341
+ spaces become plain spaces and invisible format characters (emoji joiners, variation
342
+ selectors) are dropped silently.
343
+ """
344
+ out: list[str] = []
345
+ removed: list[str] = []
346
+ for ch in unicodedata.normalize("NFC", str(text)):
347
+ if ch == "\n":
348
+ out.append(ch)
349
+ continue
350
+ if ch.isspace():
351
+ out.append(" ")
352
+ continue
353
+ cp = ord(ch)
354
+ if (unicodedata.category(ch) == "Cf" or 0xFE00 <= cp <= 0xFE0F or 0x1F3FB <= cp <= 0x1F3FF
355
+ or 0xE0020 <= cp <= 0xE007F):
356
+ continue # invisible parts of an emoji (joiner, variation selector, skin tone, tag)
357
+ if _has_glyph(font_key, ch):
358
+ out.append(ch)
359
+ continue
360
+ alt = next((a for a in _SYMBOL_FALLBACKS.get(ch, ()) if all(_has_glyph(font_key, c) for c in a)), None)
361
+ if alt is not None:
362
+ out.append(alt)
363
+ elif ch not in removed:
364
+ removed.append(ch)
365
+ clean = "\n".join(re.sub(r" {2,}", " ", ln).strip() for ln in "".join(out).split("\n"))
366
+ return clean.strip("\n"), removed
367
+
368
+
369
+ # ---------------------------------------------------------------- *accent* markup
370
+
371
+ def _markup_chars(text: str) -> tuple[list[tuple[str, bool]], bool, list[str]]:
372
+ """Characters with their accent flag. `\\*` is a literal asterisk; an unpaired `*` is ignored.
373
+ Returns (chars, has_accent_markup, issues)."""
374
+ s = str(text)
375
+ marks = [i for i, c in enumerate(s) if c == "*" and not (i and s[i - 1] == "\\")]
376
+ issues = []
377
+ if len(marks) % 2:
378
+ issues.append("tem um * sem par, então o destaque foi ignorado. Use *palavra* para destacar ou \\* para um asterisco")
379
+ marks = marks[:-1]
380
+ mark_set = set(marks)
381
+ chars: list[tuple[str, bool]] = []
382
+ accent = False
383
+ i = 0
384
+ while i < len(s):
385
+ c = s[i]
386
+ if c == "\\" and i + 1 < len(s) and s[i + 1] == "*":
387
+ chars.append(("*", accent))
388
+ i += 2
389
+ continue
390
+ if i in mark_set:
391
+ accent = not accent
392
+ elif c != "*":
393
+ chars.append((c, accent))
394
+ i += 1
395
+ return chars, bool(marks), issues
396
+
397
+
398
+ def strip_markup(text: str) -> tuple[str, bool]:
399
+ """Text without accent markers (for the CTA and the disclaimer). -> (text, had markers)."""
400
+ s = str(text)
401
+ had = bool(re.search(r"(?<!\\)\*", s))
402
+ return re.sub(r"(?<!\\)\*", "", s).replace("\\*", "*"), had
403
+
404
+
405
+ def is_weak_word(word: str) -> bool:
406
+ """True for Portuguese function words ("de", "que", "não"...) that should not end a line."""
407
+ return re.sub(r"[^\wÀ-ÿ]", "", word.lower()) in WEAK_WORDS
408
+
409
+
410
+ _is_weak = is_weak_word
411
+
412
+
413
+ def _lerp_keys(keys: list[tuple[float, dict]], p: float) -> dict:
414
+ """Linear interpolation of numeric params between keyframes [(p, {..}), ...]."""
415
+ keys = sorted(keys, key=lambda kv: kv[0])
416
+ if p <= keys[0][0]:
417
+ return dict(keys[0][1])
418
+ for (p0, v0), (p1, v1) in zip(keys, keys[1:]):
419
+ if p0 <= p <= p1:
420
+ t = 0.0 if p1 == p0 else (p - p0) / (p1 - p0)
421
+ return {k: v0[k] + (v1.get(k, v0[k]) - v0[k]) * t for k in v0}
422
+ return dict(keys[-1][1])
423
+
424
+
425
+ def _anim_frames(keys: list[tuple[float, dict]], dur: float, fps: float) -> list[dict]:
426
+ """Per-frame params for an entrance lasting `dur` seconds (last frame = steady state)."""
427
+ if not keys or dur <= 0:
428
+ return []
429
+ n = max(0, int(round(dur * fps)) - 1)
430
+ frames = []
431
+ for j in range(n):
432
+ vals = _lerp_keys(keys, (j + 1) / (n + 1))
433
+ frames.append({k: round(v, 3) for k, v in vals.items()})
434
+ return frames
435
+
436
+
437
+ # ============================================================== caption styles
438
+
439
+ @dataclass(frozen=True)
440
+ class CaptionStyle:
441
+ id: str
442
+ font: str
443
+ size: float # px on the 1080-wide reference canvas
444
+ upper: bool
445
+ max_words: int
446
+ lines: int
447
+ animated: bool
448
+ one_word: bool = False
449
+ keep_punct: bool = False
450
+ hpad: float = 0.0 # extra horizontal room per side, ratio of size
451
+ gap: float = 0.3 # extra word gap, ratio of size (added to a space)
452
+ active_scale: float = 1.0 # the spoken word is drawn this much bigger (grows into the gaps)
453
+ fit_margin: float = 0.0 # extra width counted when fitting (rotation, glow), ratio of size
454
+ enter: tuple = () # ((p, {param: value}), ...) keyframes
455
+ enter_dur: float = 0.0
456
+ change: tuple = () # keyframes when the active word changes
457
+ change_dur: float = 0.0
458
+ draw: str = ""
459
+
460
+
461
+ CAPTION_STYLES: dict[str, CaptionStyle] = {}
462
+
463
+
464
+ def _reg(style: CaptionStyle) -> None:
465
+ CAPTION_STYLES[style.id] = style
466
+
467
+
468
+ _reg(CaptionStyle(
469
+ id="impacto", font="display_condensed", size=134, upper=True, max_words=3, lines=1, animated=True,
470
+ hpad=0.09, gap=0.14,
471
+ enter=((0.0, {"scale": 0.70}), (0.34, {"scale": 0.86}), (0.67, {"scale": 1.07}), (1.0, {"scale": 1.0})),
472
+ enter_dur=0.10, draw="impacto"))
473
+
474
+ _reg(CaptionStyle(
475
+ id="caixa_viva", font="sans_black", size=86, upper=True, max_words=3, lines=1, animated=True,
476
+ hpad=0.26, gap=0.36,
477
+ enter=((0.0, {"dy": 34.0, "alpha": 0.0}), (0.34, {"dy": 20.0, "alpha": 0.45}),
478
+ (0.67, {"dy": 6.0, "alpha": 0.9}), (1.0, {"dy": 0.0, "alpha": 1.0})),
479
+ enter_dur=0.10,
480
+ change=((0.0, {"slide": 0.0}), (0.5, {"slide": 0.62}), (1.0, {"slide": 1.0})),
481
+ change_dur=0.10, draw="caixa_viva"))
482
+
483
+ _reg(CaptionStyle(
484
+ id="palavra_gigante", font="display_condensed", size=196, upper=True, max_words=1, lines=1,
485
+ animated=True, one_word=True, hpad=0.12, gap=0.2,
486
+ enter=((0.0, {"scale": 1.45}), (0.34, {"scale": 1.26}), (0.67, {"scale": 1.07}), (1.0, {"scale": 1.0})),
487
+ enter_dur=0.10, draw="palavra_gigante"))
488
+
489
+ _reg(CaptionStyle(
490
+ id="pulso", font="sans_black", size=78, upper=True, max_words=3, lines=1, animated=True,
491
+ hpad=0.2, gap=0.42, active_scale=1.12,
492
+ enter=((0.0, {"dy": 40.0, "alpha": 0.0}), (0.34, {"dy": 22.0, "alpha": 0.5}),
493
+ (0.67, {"dy": 7.0, "alpha": 0.9}), (1.0, {"dy": 0.0, "alpha": 1.0})),
494
+ enter_dur=0.10, draw="pulso"))
495
+
496
+ _reg(CaptionStyle(
497
+ id="fita", font="sans_black", size=78, upper=True, max_words=3, lines=1, animated=True,
498
+ hpad=0.42, gap=0.3, fit_margin=0.08,
499
+ enter=((0.0, {"wipe": 0.15}), (0.34, {"wipe": 0.45}), (0.67, {"wipe": 0.82}), (1.0, {"wipe": 1.0})),
500
+ enter_dur=0.10, draw="fita"))
501
+
502
+ _reg(CaptionStyle(
503
+ id="legenda_limpa", font="text_bold", size=60, upper=False, max_words=8, lines=2, animated=False,
504
+ keep_punct=True, hpad=0.45, gap=0.0, draw="legenda_limpa"))
505
+
506
+ # Papelaria: a frase vai se escrevendo palavra por palavra, com contorno escuro grosso; a palavra
507
+ # falada acende na cor de destaque e cresce um pouco. `papelaria_papel` é a mesma coisa para fundo
508
+ # claro (letra escura com halo branco), e `depoimento` marca a fala de outra pessoa.
509
+ _reg(CaptionStyle(
510
+ id="papelaria", font="sans_black", size=88, upper=False, max_words=3, lines=1, animated=True,
511
+ hpad=0.18, gap=0.34, active_scale=1.08,
512
+ enter=((0.0, {"dy": 26.0, "alpha": 0.0}), (0.4, {"dy": 10.0, "alpha": 0.7}),
513
+ (1.0, {"dy": 0.0, "alpha": 1.0})),
514
+ enter_dur=0.09,
515
+ change=((0.0, {"pop": 0.0}), (0.45, {"pop": 1.0}), (1.0, {"pop": 0.0})),
516
+ change_dur=0.16, draw="papelaria"))
517
+
518
+ _reg(CaptionStyle(
519
+ id="papelaria_papel", font="sans_black", size=88, upper=False, max_words=3, lines=1, animated=True,
520
+ hpad=0.18, gap=0.34, active_scale=1.08,
521
+ enter=((0.0, {"dy": 26.0, "alpha": 0.0}), (0.4, {"dy": 10.0, "alpha": 0.7}),
522
+ (1.0, {"dy": 0.0, "alpha": 1.0})),
523
+ enter_dur=0.09,
524
+ change=((0.0, {"pop": 0.0}), (0.45, {"pop": 1.0}), (1.0, {"pop": 0.0})),
525
+ change_dur=0.16, draw="papelaria_papel"))
526
+
527
+ _reg(CaptionStyle(
528
+ id="depoimento", font="sans_black", size=82, upper=False, max_words=3, lines=1, animated=True,
529
+ hpad=0.18, gap=0.34, active_scale=1.06,
530
+ enter=((0.0, {"dy": 22.0, "alpha": 0.0}), (0.5, {"dy": 8.0, "alpha": 0.8}),
531
+ (1.0, {"dy": 0.0, "alpha": 1.0})),
532
+ enter_dur=0.09, draw="depoimento"))
533
+
534
+
535
+ # ---------------------------------------------------------------- measuring
536
+
537
+ def caption_style_ids() -> list[str]:
538
+ return list(CAPTION_STYLES)
539
+
540
+
541
+ def _caption_spec(style_id: str) -> CaptionStyle:
542
+ spec = CAPTION_STYLES.get(style_id)
543
+ if spec is None:
544
+ raise ValueError(f"estilo de legenda '{style_id}' não existe. Opções: {', '.join(CAPTION_STYLES)}")
545
+ return spec
546
+
547
+
548
+ def caption_style_info(style_id: str) -> dict:
549
+ s = _caption_spec(style_id)
550
+ return {"id": s.id, "max_words": s.max_words, "lines": s.lines, "animated": s.animated,
551
+ "upper": s.upper, "one_word": s.one_word, "keep_punct": s.keep_punct}
552
+
553
+
554
+ def display_words(words: list[str], style_id: str) -> list[str]:
555
+ """Words as a style shows them (casing + punctuation cleanup). Empty words are dropped."""
556
+ s = _caption_spec(style_id)
557
+ out = []
558
+ for w in words:
559
+ t = _display_word(w, s)
560
+ if t:
561
+ out.append(t)
562
+ return _trim_cue_end(out, s)
563
+
564
+
565
+ def _display_word(word: str, spec: CaptionStyle) -> str:
566
+ t = _case(_clean_word(word, spec.keep_punct), spec.upper)
567
+ return " ".join(sanitize_text(t, spec.font)[0].split()) if t else t
568
+
569
+
570
+ def _trim_cue_end(words: list[str], spec: CaptionStyle) -> list[str]:
571
+ """Subtitle-like styles keep punctuation, but a cue should not end on a dangling comma."""
572
+ if spec.keep_punct and words:
573
+ last = words[-1].rstrip(",;:")
574
+ words = words[:-1] + [last] if last else words[:-1]
575
+ return words
576
+
577
+
578
+ def _line_width(font, words: list[str], size: float, spec: CaptionStyle) -> float:
579
+ if not words:
580
+ return 0.0
581
+ space = font.getlength(" ")
582
+ widths = [font.getlength(w) for w in words]
583
+ grow = (spec.active_scale - 1.0) * max(widths)
584
+ return (sum(widths) + (len(words) - 1) * (space + spec.gap * size) + 2 * spec.hpad * size
585
+ + grow + 2 * spec.fit_margin * size)
586
+
587
+
588
+ def caption_width_limit(style_id: str, canvas=(REF_W, REF_H)) -> float:
589
+ return _Geo(*canvas).caption_max_w
590
+
591
+
592
+ def measure_caption_width(words: list[str], style_id: str, canvas=(REF_W, REF_H),
593
+ font_scale: float = 1.0, already_display: bool = False) -> float:
594
+ """Single-line width in px of `words` at the style's base size (before any shrink-to-fit)."""
595
+ spec = _caption_spec(style_id)
596
+ geo = _Geo(*canvas)
597
+ disp = words if already_display else display_words(words, style_id)
598
+ size = spec.size * geo.k * font_scale
599
+ return _line_width(_font(spec.font, size), disp, size, spec)
600
+
601
+
602
+ def _fit_size(spec: CaptionStyle, lines: list[list[str]], base: float, max_w: float) -> float:
603
+ size = base
604
+ for _ in range(3):
605
+ f = _font(spec.font, size)
606
+ widest = max(_line_width(f, ln, size, spec) for ln in lines)
607
+ if widest <= max_w:
608
+ break
609
+ size = max(8.0, math.floor(size * max_w / widest * 0.995))
610
+ return size
611
+
612
+
613
+ def _balanced_split(words: list[str], width_fn) -> list[list[str]]:
614
+ if len(words) < 2:
615
+ return [words]
616
+ best, best_cost = None, None
617
+ for s in range(1, len(words)):
618
+ a, b = words[:s], words[s:]
619
+ wa, wb = width_fn(a), width_fn(b)
620
+ cost = max(wa, wb)
621
+ if _is_weak(a[-1]):
622
+ cost *= 1.12
623
+ if wa > wb:
624
+ cost *= 1.01
625
+ if best_cost is None or cost < best_cost:
626
+ best, best_cost = [a, b], cost
627
+ return best # type: ignore[return-value]
628
+
629
+
630
+ # ---------------------------------------------------------------- block drawing
631
+
632
+ @dataclass
633
+ class _Block:
634
+ layer: Image.Image
635
+ ax: float # anchor inside the layer that goes to (cx, cy)
636
+ ay: float
637
+ half_h: float # nominal half height (for safe-zone clamping)
638
+ half_w: float = 0.0 # nominal half width of the ink (for scale clamping)
639
+
640
+
641
+ def _text_layer_1line(font, words, xs, size, cap_h, pad, colors, stroke, stroke_fill, total_w):
642
+ above = 0.36 * size
643
+ below = 0.34 * size
644
+ w = total_w + 2 * pad
645
+ h = cap_h + above + below + 2 * pad
646
+ layer = _new(w, h)
647
+ base_y = pad + above + cap_h
648
+ d = ImageDraw.Draw(layer)
649
+ for x, word, col in zip(xs, words, colors):
650
+ d.text((pad + x, base_y), word, font=font, fill=col, anchor="ls",
651
+ stroke_width=stroke, stroke_fill=stroke_fill)
652
+ return layer, base_y
653
+
654
+
655
+ def _word_xs(font, words, size, spec):
656
+ space = font.getlength(" ") + spec.gap * size
657
+ xs, x = [], spec.hpad * size
658
+ widths = []
659
+ for w in words:
660
+ xs.append(x)
661
+ wl = font.getlength(w)
662
+ widths.append(wl)
663
+ x += wl + space
664
+ total = x - space + spec.hpad * size
665
+ return xs, widths, total
666
+
667
+
668
+ def _draw_impacto(spec, geo, words, active, accent, size, variant, anim) -> _Block:
669
+ f = _font(spec.font, size)
670
+ cap_h = _cap_height(f)
671
+ stroke = max(2, int(round(size * 0.085)))
672
+ xs, widths, total = _word_xs(f, words, size, spec)
673
+ pad = stroke + int(size * 0.16)
674
+ colors = [_rgba(accent) if i == active else WHITE for i in range(len(words))]
675
+ layer, base_y = _text_layer_1line(f, words, xs, size, cap_h, pad, colors, stroke, BLACK, total)
676
+ sh = _shadow_of(layer, blur=size * 0.07, alpha=0.55)
677
+ out = _new(layer.width, layer.height)
678
+ _alpha_paste(out, sh, 0, int(size * 0.05))
679
+ out.alpha_composite(layer)
680
+ return _Block(out, pad + total / 2.0, base_y - cap_h / 2.0, cap_h / 2.0 + stroke + size * 0.12, total / 2.0 + stroke)
681
+
682
+
683
+ def _draw_caixa_viva(spec, geo, words, active, accent, size, variant, anim) -> _Block:
684
+ f = _font(spec.font, size)
685
+ cap_h = _cap_height(f)
686
+ stroke = max(2, int(round(size * 0.075)))
687
+ xs, widths, total = _word_xs(f, words, size, spec)
688
+ pad = stroke + int(size * 0.2)
689
+ above, below = 0.36 * size, 0.34 * size
690
+ layer = _new(total + 2 * pad, cap_h + above + below + 2 * pad)
691
+ base_y = pad + above + cap_h
692
+ acc = _rgba(accent)
693
+ padx, pad_top, pad_bot = 0.2 * size, 0.3 * size, 0.24 * size
694
+
695
+ def box_for(i):
696
+ return (pad + xs[i] - padx, base_y - cap_h - pad_top, pad + xs[i] + widths[i] + padx, base_y + pad_bot)
697
+
698
+ slide = anim.get("slide", 1.0)
699
+ prev = variant.get("prev", -1)
700
+ settled = slide >= 0.999 or prev < 0
701
+ # text ink (white + stroke) for non-active words, shadow from it
702
+ ink = _new(layer.width, layer.height)
703
+ d = ImageDraw.Draw(ink)
704
+ for i, word in enumerate(words):
705
+ if i == active and settled:
706
+ continue
707
+ d.text((pad + xs[i], base_y), word, font=f, fill=WHITE, anchor="ls", stroke_width=stroke, stroke_fill=BLACK)
708
+ box_layer = _new(layer.width, layer.height)
709
+ if 0 <= active < len(words):
710
+ b1 = box_for(active)
711
+ if not settled and 0 <= prev < len(words):
712
+ b0 = box_for(prev)
713
+ t = slide
714
+ b1 = tuple(b0[j] + (b1[j] - b0[j]) * t for j in range(4))
715
+ _rounded_box(box_layer, b1, radius=size * 0.16, color=acc)
716
+ shadow_src = _new(layer.width, layer.height)
717
+ shadow_src.alpha_composite(box_layer)
718
+ shadow_src.alpha_composite(ink)
719
+ sh = _shadow_of(shadow_src, blur=size * 0.09, alpha=0.5)
720
+ _alpha_paste(layer, sh, 0, int(size * 0.06))
721
+ layer.alpha_composite(box_layer)
722
+ layer.alpha_composite(ink)
723
+ if 0 <= active < len(words) and settled:
724
+ ImageDraw.Draw(layer).text((pad + xs[active], base_y), words[active], font=f, fill=_on_color(acc), anchor="ls")
725
+ return _Block(layer, pad + total / 2.0, base_y - cap_h / 2.0, cap_h / 2.0 + pad_top + size * 0.05)
726
+
727
+
728
+ def _draw_palavra_gigante(spec, geo, words, active, accent, size, variant, anim) -> _Block:
729
+ f = _font(spec.font, size)
730
+ cap_h = _cap_height(f)
731
+ stroke = max(2, int(round(size * 0.07)))
732
+ xs, widths, total = _word_xs(f, words, size, spec)
733
+ off = max(3, int(round(size * 0.055)))
734
+ pad = stroke + off + int(size * 0.14)
735
+ above, below = 0.36 * size, 0.34 * size
736
+ layer = _new(total + 2 * pad, cap_h + above + below + 2 * pad)
737
+ base_y = pad + above + cap_h
738
+ acc = _rgba(accent)
739
+ d = ImageDraw.Draw(layer)
740
+ for x, word in zip(xs, words):
741
+ # hard offset "sticker" shadow in the accent color, then the white word on top
742
+ for step in range(off, 0, -max(1, off // 4)):
743
+ d.text((pad + x + step, base_y + step), word, font=f, fill=acc, anchor="ls",
744
+ stroke_width=stroke, stroke_fill=BLACK if step == off else acc)
745
+ d.text((pad + x, base_y), word, font=f, fill=WHITE, anchor="ls", stroke_width=stroke, stroke_fill=BLACK)
746
+ sh = _shadow_of(layer, blur=size * 0.08, alpha=0.45)
747
+ out = _new(layer.width, layer.height)
748
+ _alpha_paste(out, sh, 0, int(size * 0.04))
749
+ out.alpha_composite(layer)
750
+ return _Block(out, pad + total / 2.0, base_y - cap_h / 2.0, cap_h / 2.0 + stroke + off + size * 0.1,
751
+ total / 2.0 + stroke + off)
752
+
753
+
754
+ def _draw_pulso(spec, geo, words, active, accent, size, variant, anim) -> _Block:
755
+ f = _font(spec.font, size)
756
+ big = _font(spec.font, size * spec.active_scale)
757
+ cap_h, cap_big = _cap_height(f), _cap_height(big)
758
+ stroke = max(2, int(round(size * 0.075)))
759
+ stroke_big = max(2, int(round(size * spec.active_scale * 0.075)))
760
+ xs, widths, total = _word_xs(f, words, size, spec)
761
+ pad = stroke_big + int(size * 0.3)
762
+ above, below = 0.36 * size * spec.active_scale, 0.34 * size * spec.active_scale
763
+ layer = _new(total + 2 * pad, cap_big + above + below + 2 * pad)
764
+ center_y = pad + above + cap_big / 2.0
765
+ acc = _rgba(accent)
766
+ d = ImageDraw.Draw(layer)
767
+ for i, (word, x, wl) in enumerate(zip(words, xs, widths)):
768
+ if i == active:
769
+ continue
770
+ d.text((pad + x, center_y + cap_h / 2.0), word, font=f, fill=WHITE, anchor="ls",
771
+ stroke_width=stroke, stroke_fill=BLACK)
772
+ if 0 <= active < len(words):
773
+ # the spoken word grows around its own center and may eat into the word gaps
774
+ wl_big = big.getlength(words[active])
775
+ cx_word = pad + xs[active] + widths[active] / 2.0
776
+ d.text((cx_word - wl_big / 2.0, center_y + cap_big / 2.0), words[active], font=big, fill=acc, anchor="ls",
777
+ stroke_width=stroke_big, stroke_fill=BLACK)
778
+ out = _new(layer.width, layer.height)
779
+ _alpha_paste(out, _shadow_of(layer, blur=size * 0.1, alpha=0.8), 0, int(size * 0.07))
780
+ out.alpha_composite(layer)
781
+ return _Block(out, pad + total / 2.0, center_y, cap_big / 2.0 + stroke_big + size * 0.14)
782
+
783
+
784
+ def _papelaria_block(spec, geo, words, active, accent, size, variant, anim, *,
785
+ base_color, halo, futuro_alpha: float, ativa_color=None) -> _Block:
786
+ """Frase que se escreve palavra por palavra: o que ainda não foi falado fica de fora."""
787
+ f = _font(spec.font, size)
788
+ pop = float(anim.get("pop", 0.0) or 0.0)
789
+ escala = spec.active_scale + 0.05 * pop
790
+ big = _font(spec.font, size * escala)
791
+ cap_h, cap_big = _cap_height(f), _cap_height(big)
792
+ stroke = max(2, int(round(size * 0.085)))
793
+ stroke_big = max(2, int(round(size * escala * 0.085)))
794
+ xs, widths, total = _word_xs(f, words, size, spec)
795
+ pad = stroke_big + int(size * 0.3)
796
+ above, below = 0.36 * size * escala, 0.34 * size * escala
797
+ layer = _new(total + 2 * pad, cap_big + above + below + 2 * pad)
798
+ center_y = pad + above + cap_big / 2.0
799
+ acc = _rgba(ativa_color or accent)
800
+ d = ImageDraw.Draw(layer)
801
+ for i, (word, x, wl) in enumerate(zip(words, xs, widths)):
802
+ if i == active:
803
+ continue
804
+ if i > active >= 0 and futuro_alpha <= 0.0:
805
+ continue # ainda não foi falada: não aparece
806
+ cor = base_color if i < active or active < 0 else _fade(base_color, futuro_alpha)
807
+ d.text((pad + x, center_y + cap_h / 2.0), word, font=f, fill=cor, anchor="ls",
808
+ stroke_width=stroke, stroke_fill=halo)
809
+ if 0 <= active < len(words):
810
+ wl_big = big.getlength(words[active])
811
+ cx_word = pad + xs[active] + widths[active] / 2.0
812
+ d.text((cx_word - wl_big / 2.0, center_y + cap_big / 2.0), words[active], font=big, fill=acc,
813
+ anchor="ls", stroke_width=stroke_big, stroke_fill=halo)
814
+ out = _new(layer.width, layer.height)
815
+ _alpha_paste(out, _shadow_of(layer, blur=size * 0.09, alpha=0.75 if halo == BLACK else 0.35),
816
+ 0, int(size * 0.06))
817
+ out.alpha_composite(layer)
818
+ return _Block(out, pad + total / 2.0, center_y, cap_big / 2.0 + stroke_big + size * 0.14,
819
+ total / 2.0 + stroke_big)
820
+
821
+
822
+ def _fade(color, alpha: float):
823
+ r, g, b, a = _rgba(color)
824
+ return (r, g, b, int(round(a * max(0.0, min(1.0, alpha)))))
825
+
826
+
827
+ def _draw_papelaria(spec, geo, words, active, accent, size, variant, anim) -> _Block:
828
+ return _papelaria_block(spec, geo, words, active, accent, size, variant, anim,
829
+ base_color=WHITE, halo=BLACK, futuro_alpha=0.0)
830
+
831
+
832
+ def _draw_papelaria_papel(spec, geo, words, active, accent, size, variant, anim) -> _Block:
833
+ return _papelaria_block(spec, geo, words, active, accent, size, variant, anim,
834
+ base_color=(53, 44, 64, 255), halo=(255, 253, 253, 255), futuro_alpha=0.0)
835
+
836
+
837
+ def _draw_depoimento(spec, geo, words, active, accent, size, variant, anim) -> _Block:
838
+ # a fala de outra pessoa: o corpo da frase fica na cor de destaque e a palavra falada, branca
839
+ return _papelaria_block(spec, geo, words, active, accent, size, variant, anim,
840
+ base_color=_rgba(accent), halo=BLACK, futuro_alpha=0.0, ativa_color="#FFFFFF")
841
+
842
+
843
+ def _draw_fita(spec, geo, words, active, accent, size, variant, anim) -> _Block:
844
+ f = _font(spec.font, size)
845
+ cap_h = _cap_height(f)
846
+ xs, widths, total = _word_xs(f, words, size, spec)
847
+ pad_top, pad_bot = 0.42 * size, 0.36 * size
848
+ tape_h = cap_h + pad_top + pad_bot
849
+ margin = int(size * 0.3)
850
+ layer = _new(total + 2 * margin, tape_h + 2 * margin)
851
+ x0, y0 = margin, margin
852
+ base_y = y0 + pad_top + cap_h
853
+ acc = _rgba(accent)
854
+ wipe = anim.get("wipe", 1.0)
855
+ tape = _new(layer.width, layer.height)
856
+ _rounded_box(tape, (x0, y0, x0 + total, y0 + tape_h), radius=size * 0.1, color=(12, 12, 12, 242))
857
+ d = ImageDraw.Draw(tape)
858
+ for i, (x, word) in enumerate(zip(xs, words)):
859
+ d.text((x0 + x, base_y), word, font=f, fill=acc if i == active else WHITE, anchor="ls")
860
+ # accent edge on the left, clipped to the tape shape
861
+ edge = Image.new("RGBA", tape.size, acc)
862
+ edge_a = tape.getchannel("A").point(lambda v: 255 if v > 0 else 0)
863
+ ImageDraw.Draw(edge_a).rectangle((int(x0 + size * 0.16), 0, tape.width, tape.height), fill=0)
864
+ edge.putalpha(Image.composite(tape.getchannel("A"), Image.new("L", tape.size, 0), edge_a))
865
+ tape.alpha_composite(edge)
866
+ if wipe < 0.999:
867
+ cut = int(x0 + total * max(0.0, wipe))
868
+ a = tape.getchannel("A")
869
+ ImageDraw.Draw(a).rectangle((cut, 0, tape.width, tape.height), fill=0)
870
+ tape.putalpha(a)
871
+ sh = _shadow_of(tape, blur=size * 0.14, alpha=0.45)
872
+ _alpha_paste(layer, sh, 0, int(size * 0.08))
873
+ layer.alpha_composite(tape)
874
+ ax, ay = x0 + total / 2.0, y0 + tape_h / 2.0
875
+ angle = variant.get("angle", 0.0)
876
+ rot, dx, dy = _rotate(layer, angle)
877
+ cx, cy = layer.width / 2.0, layer.height / 2.0
878
+ rax, ray = _rotate_point(ax, ay, cx, cy, angle)
879
+ return _Block(rot, rax + dx, ray + dy, tape_h / 2.0 + abs(math.sin(math.radians(angle))) * total / 2.0)
880
+
881
+
882
+ def _draw_legenda_limpa(spec, geo, lines, active, accent, size, variant, anim) -> _Block:
883
+ f = _font(spec.font, size)
884
+ asc, desc = f.getmetrics()
885
+ pitch = size * 1.22
886
+ widths = [f.getlength(" ".join(ln)) for ln in lines]
887
+ padx, pady = spec.hpad * size, 0.3 * size
888
+ box_w = max(widths) + 2 * padx
889
+ box_h = pitch * (len(lines) - 1) + asc + desc + 2 * pady - 0.12 * size
890
+ margin = int(size * 0.3)
891
+ layer = _new(box_w + 2 * margin, box_h + 2 * margin)
892
+ bx0, by0 = margin, margin
893
+ _rounded_box(layer, (bx0, by0, bx0 + box_w, by0 + box_h), radius=size * 0.32, color=(0, 0, 0, 160))
894
+ d = ImageDraw.Draw(layer)
895
+ for i, (ln, wl) in enumerate(zip(lines, widths)):
896
+ y = by0 + pady + asc - 0.06 * size + pitch * i
897
+ d.text((bx0 + (box_w - wl) / 2.0, y), " ".join(ln), font=f, fill=WHITE, anchor="ls")
898
+ return _Block(layer, bx0 + box_w / 2.0, by0 + box_h / 2.0, box_h / 2.0)
899
+
900
+
901
+ _DRAWERS = {
902
+ "impacto": _draw_impacto,
903
+ "caixa_viva": _draw_caixa_viva,
904
+ "palavra_gigante": _draw_palavra_gigante,
905
+ "pulso": _draw_pulso,
906
+ "fita": _draw_fita,
907
+ "legenda_limpa": _draw_legenda_limpa,
908
+ "papelaria": _draw_papelaria,
909
+ "papelaria_papel": _draw_papelaria_papel,
910
+ "depoimento": _draw_depoimento,
911
+ }
912
+
913
+
914
+ def _caption_block(spec: CaptionStyle, geo: _Geo, words: tuple, active: int, accent: str,
915
+ font_scale: float, variant: dict, anim: dict) -> _Block:
916
+ base = spec.size * geo.k * font_scale
917
+ max_w = geo.caption_max_w
918
+ wl = list(words)
919
+ if spec.lines >= 2:
920
+ f0 = _font(spec.font, base)
921
+ if _line_width(f0, wl, base, spec) <= max_w:
922
+ lines = [wl]
923
+ else:
924
+ lines = _balanced_split(wl, lambda ws: _line_width(f0, ws, base, spec))
925
+ size = _fit_size(spec, lines, base, max_w)
926
+ return _DRAWERS[spec.draw](spec, geo, lines, active, accent, size, variant, anim)
927
+ size = _fit_size(spec, [wl], base, max_w)
928
+ return _DRAWERS[spec.draw](spec, geo, wl, active, accent, size, variant, anim)
929
+
930
+
931
+ def _place(canvas, block: _Block, cx: float, cy: float, anim: dict, geo: _Geo, clamp=True) -> Image.Image:
932
+ img = Image.new("RGBA", canvas, (0, 0, 0, 0))
933
+ if clamp:
934
+ lo = geo.safe_top + block.half_h
935
+ hi = min(geo.h, geo.safe_bottom) - block.half_h
936
+ if hi >= lo:
937
+ cy = min(max(cy, lo), hi)
938
+ layer, ax, ay = block.layer, block.ax, block.ay
939
+ s = anim.get("scale", 1.0)
940
+ if s > 1.0 and block.half_w > 0:
941
+ # a scaled-up entrance frame must stay inside the caption safe width (right rail too)
942
+ room = geo.caption_max_w / 2.0
943
+ s = max(1.0, min(s, room / block.half_w))
944
+ if abs(s - 1.0) > 1e-3:
945
+ nw, nh = max(1, int(round(layer.width * s))), max(1, int(round(layer.height * s)))
946
+ layer = layer.resize((nw, nh), Image.BICUBIC)
947
+ ax, ay = ax * nw / block.layer.width, ay * nh / block.layer.height
948
+ a = anim.get("alpha", 1.0)
949
+ if a < 0.999:
950
+ layer = _mul_alpha(layer, max(0.0, a))
951
+ dy = anim.get("dy", 0.0) * geo.k
952
+ _alpha_paste(img, layer, int(round(cx - ax)), int(round(cy - ay + dy)))
953
+ return img
954
+
955
+
956
+ # ---------------------------------------------------------------- caption track
957
+
958
+ def _cue_words(cue: dict) -> list[dict]:
959
+ ws = cue.get("words") or []
960
+ if ws:
961
+ return [{"text": str(w.get("text", "")), "start": float(w["start"]), "end": float(w["end"])} for w in ws]
962
+ toks = str(cue.get("text", "")).split()
963
+ if not toks:
964
+ return []
965
+ s, e = float(cue["start"]), float(cue["end"])
966
+ total = sum(len(t) for t in toks) or 1
967
+ out, t = [], s
968
+ for tok in toks:
969
+ d = (e - s) * len(tok) / total
970
+ out.append({"text": tok, "start": t, "end": t + d})
971
+ t += d
972
+ return out
973
+
974
+
975
+ HOLD_GAP = 0.6 # a cue stays up until the next one when the silence between them is shorter
976
+ TAIL_HOLD = 0.3 # otherwise it lingers this long after the last word
977
+ INK_ALPHA = 48 # alpha above this counts as drawn when measuring boxes (soft shadow edges do not)
978
+ INK_LUT = [255 if v > INK_ALPHA else 0 for v in range(256)]
979
+ ONE_WORD_MIN_S = 0.16 # one-word styles: shortest time a word state stays on screen (5 frames at 30 fps)
980
+ ONE_WORD_MAX_MERGE = 3 # at most this many words share one state when they are spoken too fast
981
+
982
+
983
+ def _track_states(cues: list[dict], spec: CaptionStyle, fps: float, boundaries: list[float]) -> list[tuple]:
984
+ """Frame-level states: (f0, f1, words_tuple, active, variant_items, anim_items)."""
985
+ def fr(t: float) -> int:
986
+ return int(round(t * fps + 1e-6))
987
+
988
+ prepared = []
989
+ for cue in cues:
990
+ raw = _cue_words(cue)
991
+ words = []
992
+ for w in raw:
993
+ disp = _display_word(w["text"], spec)
994
+ if not disp:
995
+ if words:
996
+ words[-1]["end"] = max(words[-1]["end"], w["end"])
997
+ continue
998
+ words.append({"text": disp, "start": w["start"], "end": max(w["end"], w["start"])})
999
+ if not words:
1000
+ continue
1001
+ start = float(cue.get("start", words[0]["start"]))
1002
+ end = float(cue.get("end", words[-1]["end"]))
1003
+ prepared.append({"start": min(start, words[0]["start"]), "end": max(end, words[-1]["end"]), "words": words})
1004
+ prepared.sort(key=lambda c: c["start"])
1005
+
1006
+ states: list[tuple] = []
1007
+ enter = _anim_frames(list(spec.enter), spec.enter_dur, fps)
1008
+ change = _anim_frames(list(spec.change), spec.change_dur, fps)
1009
+ for ci, cue in enumerate(prepared):
1010
+ nxt = prepared[ci + 1]["start"] if ci + 1 < len(prepared) else None
1011
+ disp_end = cue["end"] + TAIL_HOLD
1012
+ if nxt is not None:
1013
+ if nxt - cue["end"] <= HOLD_GAP:
1014
+ disp_end = nxt
1015
+ disp_end = min(disp_end, nxt)
1016
+ for b in boundaries:
1017
+ if cue["end"] - 1e-3 <= b < disp_end:
1018
+ disp_end = max(cue["end"], b)
1019
+ break
1020
+ cs, ce = fr(cue["start"]), fr(disp_end)
1021
+ if nxt is not None:
1022
+ ce = min(ce, fr(nxt))
1023
+ if ce <= cs:
1024
+ continue
1025
+ words = cue["words"]
1026
+ variant: dict = {}
1027
+ if spec.draw == "fita":
1028
+ variant["angle"] = -2.6 if ci % 2 == 0 else 1.8
1029
+
1030
+ def emit(f0, f1, wt, act, var, entry_frames, entry_start):
1031
+ """Split [f0, f1) into animated entrance frames + one steady state."""
1032
+ if f1 <= f0:
1033
+ return
1034
+ g = f0
1035
+ while g < f1 and entry_frames and g - entry_start < len(entry_frames):
1036
+ j = g - entry_start
1037
+ if j >= 0:
1038
+ states.append((g, g + 1, wt, act, tuple(sorted(var.items())), tuple(sorted(entry_frames[j].items()))))
1039
+ g += 1
1040
+ if g < f1:
1041
+ states.append((g, f1, wt, act, tuple(sorted(var.items())), ()))
1042
+
1043
+ if not spec.animated:
1044
+ wt = tuple(_trim_cue_end([w["text"] for w in words], spec))
1045
+ emit(cs, ce, wt, -1, variant, [], cs)
1046
+ continue
1047
+
1048
+ starts = [cs] + [min(max(fr(w["start"]), cs), ce) for w in words[1:]]
1049
+ starts = [max(starts[i], starts[i - 1]) if i else starts[i] for i in range(len(starts))]
1050
+ if spec.one_word:
1051
+ # a word shorter than ONE_WORD_MIN_S flickers (1-3 frames, never seen at full size):
1052
+ # it shares the screen with the next word (or the one before, at the end of a cue)
1053
+ min_frames = max(3, int(round(ONE_WORD_MIN_S * fps)))
1054
+ groups: list[list] = []
1055
+ for i, w in enumerate(words):
1056
+ f0, f1 = starts[i], (starts[i + 1] if i + 1 < len(words) else ce)
1057
+ if groups and groups[-1][1] - groups[-1][0] < min_frames and len(groups[-1][2]) < ONE_WORD_MAX_MERGE:
1058
+ groups[-1][1] = max(groups[-1][1], f1)
1059
+ groups[-1][2].append(w["text"])
1060
+ else:
1061
+ groups.append([f0, f1, [w["text"]]])
1062
+ if (len(groups) >= 2 and groups[-1][1] - groups[-1][0] < min_frames
1063
+ and len(groups[-2][2]) + len(groups[-1][2]) <= ONE_WORD_MAX_MERGE):
1064
+ last = groups.pop()
1065
+ groups[-1][1] = last[1]
1066
+ groups[-1][2].extend(last[2])
1067
+ for f0, f1, texts in groups:
1068
+ if f1 <= f0:
1069
+ continue
1070
+ # no entrance bounce on a state too short to settle at its final size
1071
+ entry = enter if f1 - f0 >= len(enter) + 3 else []
1072
+ emit(f0, f1, tuple(texts), 0, variant, entry, f0)
1073
+ continue
1074
+ for i, w in enumerate(words):
1075
+ f0 = starts[i]
1076
+ f1 = starts[i + 1] if i + 1 < len(words) else ce
1077
+ if f1 <= f0:
1078
+ continue
1079
+ wt = tuple(x["text"] for x in words)
1080
+ if f0 < cs + len(enter):
1081
+ # cue entrance frames (take priority over the word-change slide)
1082
+ head_end = min(f1, cs + len(enter))
1083
+ emit(f0, head_end, wt, i, variant, enter, cs)
1084
+ f0 = head_end
1085
+ if f0 >= f1:
1086
+ continue
1087
+ emit(f0, f1, wt, i, variant, [], f0)
1088
+ elif change and i > 0:
1089
+ var = dict(variant, prev=i - 1)
1090
+ g = f0
1091
+ for j, params in enumerate(change):
1092
+ if g >= f1:
1093
+ break
1094
+ states.append((g, g + 1, wt, i, tuple(sorted(var.items())), tuple(sorted(params.items()))))
1095
+ g += 1
1096
+ if g < f1:
1097
+ states.append((g, f1, wt, i, tuple(sorted(variant.items())), ()))
1098
+ else:
1099
+ emit(f0, f1, wt, i, variant, [], f0)
1100
+ states.sort(key=lambda s: s[0])
1101
+ # safety: never overlap
1102
+ clean: list[tuple] = []
1103
+ for st in states:
1104
+ if clean and st[0] < clean[-1][1]:
1105
+ st = (clean[-1][1],) + st[1:]
1106
+ if st[1] > st[0]:
1107
+ clean.append(st)
1108
+ return clean
1109
+
1110
+
1111
+ def _apply_windows(states: list[tuple], fps: float, default_cy: float,
1112
+ layout: list[dict] | None, hide: list[list[float]] | None) -> list[tuple]:
1113
+ """-> (f0, f1, words, active, variant, anim, center_y) with hide windows removed."""
1114
+ def fr(t: float) -> int:
1115
+ return int(round(float(t) * fps + 1e-6))
1116
+
1117
+ lay = sorted(((fr(w["start"]), fr(w["end"]), float(w["center_y"])) for w in (layout or [])), key=lambda x: x[0])
1118
+ hid = sorted((fr(a), fr(b)) for a, b in (hide or []))
1119
+ cuts = sorted({p for a, b, _ in lay for p in (a, b)} | {p for a, b in hid for p in (a, b)})
1120
+ out = []
1121
+ for f0, f1, *rest in states:
1122
+ pts = [f0] + [c for c in cuts if f0 < c < f1] + [f1]
1123
+ for a, b in zip(pts, pts[1:]):
1124
+ if any(h0 <= a and b <= h1 for h0, h1 in hid):
1125
+ continue
1126
+ cy = default_cy
1127
+ for l0, l1, lcy in lay:
1128
+ if l0 <= a and b <= l1:
1129
+ cy = lcy
1130
+ out.append((a, b, *rest, cy))
1131
+ return out
1132
+
1133
+
1134
+ def render_caption_track(cues: dict, style_id: str, accent: str, out_dir: str,
1135
+ canvas=(1080, 1920), fps: int = 30,
1136
+ layout: list[dict] | None = None, hide: list[list[float]] | None = None,
1137
+ font_scale: float = 1.0) -> list[dict]:
1138
+ """Render every distinct visual state of a caption track to PNGs.
1139
+
1140
+ Returns [{"file": "/abs/png", "start": s, "end": e, "bbox": [x0, y0, x1, y1]}] sorted,
1141
+ non-overlapping, only where a caption is visible. `bbox` is the drawn area of that PNG (alpha
1142
+ above INK_ALPHA; null when empty). Times are on the frame grid of `fps`. Identical states share one PNG (named by a
1143
+ content hash, so re-running with the same input reuses the files; their boxes are kept in
1144
+ `bboxes.json` next to them). Default center: layout_defaults(canvas)["caption"].
1145
+ """
1146
+ spec = _caption_spec(style_id)
1147
+ geo = _Geo(int(canvas[0]), int(canvas[1]))
1148
+ out = Path(out_dir).resolve()
1149
+ out.mkdir(parents=True, exist_ok=True)
1150
+ cue_list = cues.get("cues", []) if isinstance(cues, dict) else list(cues)
1151
+ boundaries = sorted(float(b) for b in (cues.get("cut_boundaries") or [])) if isinstance(cues, dict) else []
1152
+ default_cy = layout_defaults((geo.w, geo.h))["caption"]
1153
+ hex_accent = accent or DEFAULT_ACCENT
1154
+ _rgba(hex_accent) # validate early
1155
+
1156
+ states = _track_states(cue_list, spec, float(fps), boundaries)
1157
+ placed = _apply_windows(states, float(fps), default_cy, layout, hide)
1158
+
1159
+ # merge consecutive identical states
1160
+ merged: list[list] = []
1161
+ for st in placed:
1162
+ if merged and merged[-1][1] == st[0] and tuple(merged[-1][2:]) == tuple(st[2:]):
1163
+ merged[-1][1] = st[1]
1164
+ else:
1165
+ merged.append(list(st))
1166
+
1167
+ jobs: dict[str, tuple] = {}
1168
+ entries = []
1169
+ for f0, f1, words, active, variant, anim, cy in merged:
1170
+ key_src = repr((_SRC_HASH, style_id, hex_accent.upper(), geo.w, geo.h, round(font_scale, 4),
1171
+ words, active, variant, anim, round(cy, 1)))
1172
+ name = "cap_" + hashlib.sha1(key_src.encode("utf-8")).hexdigest()[:16] + ".png"
1173
+ path = out / name
1174
+ jobs.setdefault(name, (path, words, active, dict(variant), dict(anim), cy))
1175
+ entries.append({"file": str(path), "start": round(f0 / fps, 6), "end": round(f1 / fps, 6)})
1176
+
1177
+ bbox_path = out / "bboxes.json"
1178
+ try:
1179
+ known = json.loads(bbox_path.read_text(encoding="utf-8")) if bbox_path.exists() else {}
1180
+ except (OSError, ValueError):
1181
+ known = {}
1182
+ boxes: dict[str, list | None] = {}
1183
+
1184
+ def work(job):
1185
+ path, words, active, variant, anim, cy = job
1186
+ if path.exists():
1187
+ if path.name in known:
1188
+ boxes[path.name] = known[path.name]
1189
+ return
1190
+ with Image.open(path) as im:
1191
+ bb = ink_bbox(im)
1192
+ boxes[path.name] = list(bb) if bb else None
1193
+ return
1194
+ block = _caption_block(spec, geo, words, active, hex_accent, font_scale, variant, anim)
1195
+ img = _place((geo.w, geo.h), block, geo.cx, cy, anim, geo)
1196
+ bb = ink_bbox(img)
1197
+ boxes[path.name] = list(bb) if bb else None
1198
+ tmp = path.with_name(".tmp-" + path.name)
1199
+ img.save(tmp, "PNG", compress_level=3)
1200
+ os.replace(tmp, path)
1201
+
1202
+ workers = max(1, min(8, (os.cpu_count() or 4)))
1203
+ with ThreadPoolExecutor(max_workers=workers) as ex:
1204
+ list(ex.map(work, jobs.values()))
1205
+ if any(known.get(n, 0) != b for n, b in boxes.items()):
1206
+ known.update(boxes)
1207
+ tmp = bbox_path.with_name(".tmp-" + bbox_path.name)
1208
+ tmp.write_text(json.dumps(known), encoding="utf-8")
1209
+ os.replace(tmp, bbox_path)
1210
+ for e in entries:
1211
+ e["bbox"] = boxes.get(Path(e["file"]).name)
1212
+ return entries
1213
+
1214
+
1215
+ def ink_bbox(img: Image.Image) -> tuple[int, int, int, int] | None:
1216
+ """Box of what is really drawn: alpha above INK_ALPHA (the faint edge of soft shadows is left out)."""
1217
+ return img.getchannel("A").point(INK_LUT).getbbox()
1218
+
1219
+
1220
+ _REACH_ABOVE = set("ÁÀÂÃÄÉÈÊËÍÌÎÏÓÒÔÕÖÚÙÛÜÑbdfhklt")
1221
+ _REACH_BELOW = set("ÇQJgjpqyç,;")
1222
+
1223
+
1224
+ def caption_extent(cues: dict | list, style_id: str, canvas=(1080, 1920), font_scale: float = 1.0,
1225
+ fps: float = 30.0) -> tuple[float, float]:
1226
+ """(above, below): how many px a caption track reaches above and below its center line.
1227
+
1228
+ Measured on drawn blocks of the real cues (the widest one, plus one with accents on top and one
1229
+ with a cedilla or descender), times the biggest entrance scale and slide of the style, so a
1230
+ track can be placed before it is rendered. Empty cues: a sample phrase with accents.
1231
+ """
1232
+ spec = _caption_spec(style_id)
1233
+ geo = _Geo(int(canvas[0]), int(canvas[1]))
1234
+ cue_list = cues.get("cues", []) if isinstance(cues, dict) else list(cues or [])
1235
+ groups: list[list[str]] = []
1236
+ for cue in cue_list:
1237
+ words = display_words([w["text"] for w in _cue_words(cue)], style_id)
1238
+ groups += [[w] for w in words] if spec.one_word else ([words] if words else [])
1239
+ if not groups:
1240
+ groups = [display_words(["ÉÇ", "SÓ"], style_id)]
1241
+ picks: dict[tuple, list[str]] = {}
1242
+ widest, best = groups[0], -1.0
1243
+ for g in groups:
1244
+ chars = set("".join(g))
1245
+ sig = (bool(chars & _REACH_ABOVE), bool(chars & _REACH_BELOW))
1246
+ picks.setdefault(sig, g)
1247
+ width = measure_caption_width(g, style_id, canvas, font_scale, already_display=True)
1248
+ if width > best:
1249
+ best, widest = width, g
1250
+ picks[("widest",)] = widest
1251
+ variant = {"angle": -2.6} if spec.draw == "fita" else {}
1252
+ above = below = 0.0
1253
+ for words in picks.values():
1254
+ block = _caption_block(spec, geo, tuple(words), 0, DEFAULT_ACCENT, font_scale, variant, {})
1255
+ bb = ink_bbox(block.layer) or (0, 0, block.layer.width, block.layer.height)
1256
+ above, below = max(above, block.ay - bb[1]), max(below, bb[3] - block.ay)
1257
+ frames = _anim_frames(list(spec.enter), spec.enter_dur, fps)
1258
+ scale = max([1.0] + [float(f.get("scale", 1.0)) for f in frames])
1259
+ slide = max([0.0] + [float(f.get("dy", 0.0)) for f in frames if f.get("alpha", 1.0) >= 0.5]) * geo.k
1260
+ return above * scale, below * scale + slide
1261
+
1262
+
1263
+ def write_blank_png(path: str | Path, canvas=(1080, 1920)) -> Path:
1264
+ p = Path(path)
1265
+ p.parent.mkdir(parents=True, exist_ok=True)
1266
+ Image.new("RGBA", (int(canvas[0]), int(canvas[1])), (0, 0, 0, 0)).save(p, "PNG")
1267
+ return p
1268
+
1269
+
1270
+ def write_ffconcat(states: list[dict], path: str, total_duration: float, blank_png: str,
1271
+ fps: float | None = None) -> Path:
1272
+ """Write an ffconcat playlist that plays the caption states over [0, total_duration].
1273
+
1274
+ Gaps use `blank_png` (created, fully transparent, when it does not exist yet, with the
1275
+ size of the first state PNG). The last entry is repeated without duration, as the concat
1276
+ demuxer requires. Each entry carries `option framerate` so timestamps are not rounded to
1277
+ the image demuxer's default 1/25 time base: pass the output fps when known (exact frame
1278
+ grid); otherwise a 1 microsecond time base is used. Durations are derived from
1279
+ microsecond-floored cumulative positions, so rounding never drifts.
1280
+
1281
+ Use in ffmpeg (frame-accurate, verified):
1282
+ ffmpeg -i base.mp4 -f concat -safe 0 -i captions.ffconcat \\
1283
+ -filter_complex "[1:v]format=rgba[cap];[0:v][cap]overlay=0:0:eof_action=pass[v]" \\
1284
+ -map "[v]" -map 0:a? ...
1285
+ """
1286
+ p = Path(path).resolve()
1287
+ p.parent.mkdir(parents=True, exist_ok=True)
1288
+ blank = Path(blank_png).resolve()
1289
+ if not blank.exists():
1290
+ size = (REF_W, REF_H)
1291
+ if states:
1292
+ with Image.open(states[0]["file"]) as im:
1293
+ size = im.size
1294
+ write_blank_png(blank, size)
1295
+ total = max(0.0, float(total_duration))
1296
+ rate = f"{int(fps)}" if fps and float(fps).is_integer() else (f"{fps}" if fps else "1000000")
1297
+
1298
+ def us(t: float) -> int:
1299
+ return int(math.floor(float(t) * 1_000_000 + 1e-3))
1300
+
1301
+ items: list[tuple[str, int, int]] = [] # (file, start_us, end_us)
1302
+ cursor = 0
1303
+ for st in sorted(states, key=lambda s: s["start"]):
1304
+ a, b = us(st["start"]), us(min(st["end"], total) if total else st["end"])
1305
+ a = max(a, cursor)
1306
+ if b <= a:
1307
+ continue
1308
+ if a > cursor:
1309
+ items.append((str(blank), cursor, a))
1310
+ items.append((str(Path(st["file"]).resolve()), a, b))
1311
+ cursor = b
1312
+ end_us = us(total) if total else cursor
1313
+ if end_us > cursor:
1314
+ items.append((str(blank), cursor, end_us))
1315
+ if not items:
1316
+ items.append((str(blank), 0, max(end_us, 1)))
1317
+
1318
+ def q(f: str) -> str:
1319
+ return "'" + f.replace("'", "'\\''") + "'"
1320
+
1321
+ lines = ["ffconcat version 1.0"]
1322
+ for f, a, b in items:
1323
+ lines += [f"file {q(f)}", f"option framerate {rate}", f"duration {(b - a) / 1_000_000:.6f}"]
1324
+ last = items[-1][0]
1325
+ lines += [f"file {q(last)}", f"option framerate {rate}"]
1326
+ p.write_text("\n".join(lines) + "\n", encoding="utf-8")
1327
+ return p
1328
+
1329
+
1330
+ # ============================================================== hooks
1331
+
1332
+ @dataclass
1333
+ class _Tok:
1334
+ text: str
1335
+ accent: bool
1336
+
1337
+
1338
+ def _parse_marked(text: str, upper: bool, font_key: str | None = None) -> tuple[list[_Tok], bool]:
1339
+ """Words with their accent flag. A word touched by the markup is accented as a whole, so
1340
+ "*Kiwi*fy" stays one word. With `font_key`, symbols the font cannot draw are removed."""
1341
+ chars, marked, _ = _markup_chars(text)
1342
+ words: list[list] = [] # [text, accent]
1343
+ cur: list[str] = []
1344
+ cur_acc = False
1345
+ for c, acc in chars + [(" ", False)]:
1346
+ if c.isspace():
1347
+ if cur:
1348
+ words.append(["".join(cur), cur_acc])
1349
+ cur, cur_acc = [], False
1350
+ continue
1351
+ cur.append(c)
1352
+ cur_acc = cur_acc or acc
1353
+ toks: list[_Tok] = []
1354
+ for w, acc in words:
1355
+ if font_key:
1356
+ w, _ = sanitize_text(w, font_key)
1357
+ w = w.replace("\n", " ").strip()
1358
+ if not w:
1359
+ continue
1360
+ w = _case(w, upper)
1361
+ if toks and re.fullmatch(r"[,.;:!?…]+", w):
1362
+ toks[-1].text += w
1363
+ else:
1364
+ toks.append(_Tok(w, acc))
1365
+ return toks, marked and any(t.accent for t in toks)
1366
+
1367
+
1368
+ _TEXT_FONTS = {"cta": "sans_black", "cta_sub": "text_bold", "disclaimer": "text_semibold"}
1369
+ _TEXT_LABELS = {"hook": "gancho", "cta": "chamada", "cta_sub": "linha de apoio da chamada",
1370
+ "disclaimer": "texto do aviso", "caption": "legenda"}
1371
+
1372
+
1373
+ def text_issues(kind: str, text: str, style_id: str | None = None) -> list[str]:
1374
+ """PT-BR warnings for text that will not show up exactly as typed.
1375
+
1376
+ kind: "hook" (needs style_id), "caption" (needs style_id), "cta", "cta_sub", "disclaimer".
1377
+ Lists symbols the font cannot draw (they are removed when rendering) and markup problems.
1378
+ """
1379
+ if not str(text or "").strip():
1380
+ return []
1381
+ label = _TEXT_LABELS.get(kind, kind)
1382
+ if kind == "hook":
1383
+ if style_id not in HOOK_STYLES:
1384
+ raise ValueError(f"estilo de gancho '{style_id}' não existe. Opções: {', '.join(HOOK_STYLES)}")
1385
+ font_key = HOOK_STYLES[style_id]["font"]
1386
+ elif kind == "caption":
1387
+ font_key = _caption_spec(str(style_id)).font
1388
+ else:
1389
+ font_key = _TEXT_FONTS[kind]
1390
+ issues = []
1391
+ if kind == "hook":
1392
+ chars, _, problems = _markup_chars(text)
1393
+ plain = "".join(c for c, _ in chars)
1394
+ issues += [f"{label}: {p}" for p in problems]
1395
+ elif kind in ("cta", "cta_sub", "disclaimer"):
1396
+ plain, had = strip_markup(text)
1397
+ if had:
1398
+ issues.append(f"{label}: não tem destaque com *, os asteriscos foram tirados")
1399
+ else:
1400
+ plain = str(text)
1401
+ probe = plain.upper() if kind == "cta" else plain
1402
+ _, removed = sanitize_text(probe, font_key)
1403
+ if removed:
1404
+ issues.append(f"{label}: tirei símbolos que a fonte não desenha ({' '.join(removed)}). Evite emoji e símbolos no texto")
1405
+ return issues
1406
+
1407
+
1408
+ HOOK_STYLES: dict[str, dict] = {
1409
+ "manchete": {"font": "display_condensed", "upper": True, "max": 158, "min": 84, "side": 0.08},
1410
+ "tarja": {"font": "sans_black", "upper": True, "max": 94, "min": 50, "side": 0.34},
1411
+ "balao": {"font": "sans_extrabold", "upper": False, "max": 82, "min": 46, "side": 0.62},
1412
+ "marcador": {"font": "marker", "upper": True, "max": 104, "min": 56, "side": 0.34},
1413
+ "vidro": {"font": "sans_extrabold", "upper": False, "max": 78, "min": 44, "side": 0.66},
1414
+ }
1415
+
1416
+
1417
+ def hook_style_ids() -> list[str]:
1418
+ return list(HOOK_STYLES)
1419
+
1420
+
1421
+ def _hook_lines(toks: list[_Tok], font_key: str, content_w: float, max_size: float, min_size: float,
1422
+ side_ratio: float) -> tuple[list[list[_Tok]], float]:
1423
+ """Pick 1 or 2 lines (balanced by measured width) and the fitted font size."""
1424
+ ref = 100.0
1425
+ f = _font(font_key, ref)
1426
+ space = f.getlength(" ")
1427
+
1428
+ def width(ts):
1429
+ return sum(f.getlength(t.text) for t in ts) + space * (len(ts) - 1)
1430
+
1431
+ def size_for(lines):
1432
+ widest = max(width(ln) for ln in lines)
1433
+ per_px = widest / ref + 2 * side_ratio
1434
+ return content_w / per_px if per_px > 0 else max_size
1435
+
1436
+ if not toks:
1437
+ return [[]], max_size
1438
+ one = [toks]
1439
+ s1 = min(max_size, size_for(one))
1440
+ if len(toks) == 1:
1441
+ return one, max(min(s1, max_size), 1.0)
1442
+ best, best_cost = None, None
1443
+ for s in range(1, len(toks)):
1444
+ a, b = toks[:s], toks[s:]
1445
+ wa, wb = width(a), width(b)
1446
+ cost = max(wa, wb)
1447
+ if _is_weak(a[-1].text):
1448
+ cost *= 1.1
1449
+ if len(a) == 1 and len(toks) > 3 and len(a[0].text) <= 3:
1450
+ cost *= 1.15
1451
+ if best_cost is None or cost < best_cost:
1452
+ best, best_cost = [a, b], cost
1453
+ two = best
1454
+ s2 = min(max_size, size_for(two))
1455
+ if s1 >= 0.86 * s2:
1456
+ lines, size = one, s1
1457
+ else:
1458
+ lines, size = two, s2
1459
+ return lines, max(1.0, size)
1460
+
1461
+
1462
+ def _refit(font_key: str, lines: list[list[_Tok]], size: float, content_w: float, side_ratio: float) -> float:
1463
+ for _ in range(4):
1464
+ f = _font(font_key, size)
1465
+ space = f.getlength(" ")
1466
+ widest = max(sum(f.getlength(t.text) for t in ln) + space * (len(ln) - 1) for ln in lines)
1467
+ if widest + 2 * side_ratio * size <= content_w:
1468
+ return size
1469
+ size = math.floor(size * content_w / (widest + 2 * side_ratio * size) * 0.995)
1470
+ return size
1471
+
1472
+
1473
+ def _line_colors(lines, marked, accent_rgba, default_rgba, rule: str):
1474
+ """Per token colors: marked words get the accent; otherwise a style rule."""
1475
+ cols = []
1476
+ for li, ln in enumerate(lines):
1477
+ row = []
1478
+ for ti, t in enumerate(ln):
1479
+ if marked:
1480
+ row.append(accent_rgba if t.accent else default_rgba)
1481
+ elif rule == "second_line" and len(lines) == 2:
1482
+ row.append(accent_rgba if li == 1 else default_rgba)
1483
+ elif rule in ("second_line", "last_word") and len(lines) == 1 and len(ln) >= 2:
1484
+ row.append(accent_rgba if ti == len(ln) - 1 else default_rgba)
1485
+ else:
1486
+ row.append(default_rgba)
1487
+ cols.append(row)
1488
+ return cols
1489
+
1490
+
1491
+ def _draw_token_line(d, f, x, base_y, toks, colors, stroke=0, stroke_fill=BLACK):
1492
+ space = f.getlength(" ")
1493
+ for t, col in zip(toks, colors):
1494
+ d.text((x, base_y), t.text, font=f, fill=col, anchor="ls", stroke_width=stroke, stroke_fill=stroke_fill)
1495
+ x += f.getlength(t.text) + space
1496
+
1497
+
1498
+ def _tok_width(f, toks) -> float:
1499
+ return sum(f.getlength(t.text) for t in toks) + f.getlength(" ") * (len(toks) - 1)
1500
+
1501
+
1502
+ def _hook_manchete(toks, marked, accent, geo, max_width):
1503
+ st = HOOK_STYLES["manchete"]
1504
+ k = geo.k
1505
+ side = st["side"]
1506
+ lines, size = _hook_lines(toks, st["font"], max_width, st["max"] * k, st["min"] * k, side)
1507
+ size = _refit(st["font"], lines, size, max_width, side)
1508
+ f = _font(st["font"], size)
1509
+ cap = _cap_height(f)
1510
+ stroke = max(2, int(round(size * 0.075)))
1511
+ pitch = cap + size * 0.38
1512
+ widths = [_tok_width(f, ln) for ln in lines]
1513
+ block_w = max(widths)
1514
+ block_h = cap + pitch * (len(lines) - 1)
1515
+ pad = int(stroke + size * 0.4)
1516
+ layer = _new(block_w + 2 * pad, block_h + 2 * pad)
1517
+ acc = _rgba(accent)
1518
+ cols = _line_colors(lines, marked, acc, WHITE, "second_line")
1519
+ d = ImageDraw.Draw(layer)
1520
+ for i, (ln, wl) in enumerate(zip(lines, widths)):
1521
+ base_y = pad + cap + pitch * i
1522
+ _draw_token_line(d, f, pad + (block_w - wl) / 2.0, base_y, ln, cols[i], stroke, BLACK)
1523
+ out = _new(layer.width, layer.height)
1524
+ _alpha_paste(out, _shadow_of(layer, blur=size * 0.09, alpha=0.6), 0, int(size * 0.06))
1525
+ out.alpha_composite(layer)
1526
+ return out, pad + block_w / 2.0, pad + block_h / 2.0, block_h / 2.0 + stroke + size * 0.3
1527
+
1528
+
1529
+ def _hook_tarja(toks, marked, accent, geo, max_width):
1530
+ st = HOOK_STYLES["tarja"]
1531
+ k = geo.k
1532
+ side = st["side"]
1533
+ lines, size = _hook_lines(toks, st["font"], max_width, st["max"] * k, st["min"] * k, side)
1534
+ size = _refit(st["font"], lines, size, max_width, side)
1535
+ f = _font(st["font"], size)
1536
+ cap = _cap_height(f)
1537
+ padx, pad_top, pad_bot = side * size, 0.36 * size, 0.3 * size
1538
+ box_h = cap + pad_top + pad_bot
1539
+ gap = 0.1 * size
1540
+ widths = [_tok_width(f, ln) for ln in lines]
1541
+ block_w = max(widths) + 2 * padx
1542
+ block_h = box_h * len(lines) + gap * (len(lines) - 1)
1543
+ margin = int(size * 0.35)
1544
+ layer = _new(block_w + 2 * margin, block_h + 2 * margin)
1545
+ acc = _rgba(accent)
1546
+ has_accent_line = [any(t.accent for t in ln) for ln in lines]
1547
+ boxes = _new(layer.width, layer.height)
1548
+ texts = []
1549
+ for i, (ln, wl) in enumerate(zip(lines, widths)):
1550
+ if marked:
1551
+ bg = acc if has_accent_line[i] else WHITE
1552
+ else:
1553
+ bg = acc if i == len(lines) - 1 else WHITE
1554
+ bw = wl + 2 * padx
1555
+ x0 = margin + (block_w - bw) / 2.0
1556
+ y0 = margin + i * (box_h + gap)
1557
+ _rounded_box(boxes, (x0, y0, x0 + bw, y0 + box_h), radius=size * 0.14, color=bg)
1558
+ texts.append((x0 + padx, y0 + pad_top + cap, ln, _on_color(bg)))
1559
+ _alpha_paste(layer, _shadow_of(boxes, blur=size * 0.16, alpha=0.4), 0, int(size * 0.08))
1560
+ layer.alpha_composite(boxes)
1561
+ d = ImageDraw.Draw(layer)
1562
+ for x, base_y, ln, col in texts:
1563
+ _draw_token_line(d, f, x, base_y, ln, [col] * len(ln))
1564
+ angle = -2.0
1565
+ ax, ay = margin + block_w / 2.0, margin + block_h / 2.0
1566
+ rot, dx, dy = _rotate(layer, angle)
1567
+ rax, ray = _rotate_point(ax, ay, layer.width / 2.0, layer.height / 2.0, angle)
1568
+ half = block_h / 2.0 + abs(math.sin(math.radians(angle))) * block_w / 2.0
1569
+ return rot, rax + dx, ray + dy, half
1570
+
1571
+
1572
+ def _hook_balao(toks, marked, accent, geo, max_width):
1573
+ st = HOOK_STYLES["balao"]
1574
+ k = geo.k
1575
+ side = st["side"]
1576
+ lines, size = _hook_lines(toks, st["font"], max_width, st["max"] * k, st["min"] * k, side)
1577
+ size = _refit(st["font"], lines, size, max_width, side)
1578
+ f = _font(st["font"], size)
1579
+ asc, desc = f.getmetrics()
1580
+ cap = _cap_height(f)
1581
+ pitch = size * 1.24
1582
+ padx, pady = side * size, 0.5 * size
1583
+ widths = [_tok_width(f, ln) for ln in lines]
1584
+ card_w = max(widths) + 2 * padx
1585
+ text_h = cap + pitch * (len(lines) - 1)
1586
+ card_h = text_h + 2 * pady + 0.22 * size
1587
+ tail = size * 0.42
1588
+ margin = int(size * 0.5)
1589
+ layer = _new(card_w + 2 * margin, card_h + tail + 2 * margin)
1590
+ acc = _rgba(accent)
1591
+ x0, y0 = margin, margin
1592
+ card = _new(layer.width, layer.height)
1593
+ # speech-bubble tail (bottom left), then the card
1594
+ tx = x0 + card_w * 0.2
1595
+ _polygon(card, [(tx, y0 + card_h - 2), (tx + tail * 1.25, y0 + card_h - 2), (tx - tail * 0.1, y0 + card_h + tail)], WHITE)
1596
+ _rounded_box(card, (x0, y0, x0 + card_w, y0 + card_h), radius=size * 0.46, color=WHITE)
1597
+ _alpha_paste(layer, _shadow_of(card, blur=size * 0.22, alpha=0.38), 0, int(size * 0.1))
1598
+ layer.alpha_composite(card)
1599
+ d = ImageDraw.Draw(layer)
1600
+ top_text = y0 + pady + 0.16 * size
1601
+ space = f.getlength(" ")
1602
+ on_acc = _on_color(acc)
1603
+ for i, (ln, wl) in enumerate(zip(lines, widths)):
1604
+ base_y = top_text + cap + pitch * i
1605
+ x = x0 + (card_w - wl) / 2.0
1606
+ for t in ln:
1607
+ tw = f.getlength(t.text)
1608
+ col = INK
1609
+ if marked and t.accent:
1610
+ _rounded_box(layer, (x - size * 0.12, base_y - cap - size * 0.2, x + tw + size * 0.12, base_y + size * 0.2),
1611
+ radius=size * 0.12, color=acc)
1612
+ d = ImageDraw.Draw(layer)
1613
+ col = on_acc
1614
+ d.text((x, base_y), t.text, font=f, fill=col, anchor="ls")
1615
+ x += tw + space
1616
+ return layer, x0 + card_w / 2.0, y0 + card_h / 2.0, card_h / 2.0 + tail
1617
+
1618
+
1619
+ def _hook_marcador(toks, marked, accent, geo, max_width):
1620
+ st = HOOK_STYLES["marcador"]
1621
+ k = geo.k
1622
+ side = st["side"]
1623
+ lines, size = _hook_lines(toks, st["font"], max_width, st["max"] * k, st["min"] * k, side)
1624
+ size = _refit(st["font"], lines, size, max_width, side)
1625
+ f = _font(st["font"], size)
1626
+ cap = _cap_height(f)
1627
+ pitch = cap + size * 0.46
1628
+ widths = [_tok_width(f, ln) for ln in lines]
1629
+ block_w = max(widths) + 2 * side * size
1630
+ block_h = cap + pitch * (len(lines) - 1) + 0.5 * size
1631
+ margin = int(size * 0.4)
1632
+ layer = _new(block_w + 2 * margin, block_h + 2 * margin)
1633
+ acc = _rgba(accent)
1634
+ strokes = _new(layer.width, layer.height)
1635
+ jit = [(0.03, -0.05, 0.06, 0.02), (-0.04, 0.04, -0.02, 0.05)]
1636
+ texts = []
1637
+ for i, (ln, wl) in enumerate(zip(lines, widths)):
1638
+ base_y = margin + 0.25 * size + cap + pitch * i
1639
+ sx0 = margin + (block_w - wl) / 2.0 - side * size
1640
+ sx1 = sx0 + wl + 2 * side * size
1641
+ top, bot = base_y - cap - 0.2 * size, base_y + 0.2 * size
1642
+ j = jit[i % 2]
1643
+ pts = [(sx0 + j[0] * size, top + j[1] * size), (sx1 + j[2] * size, top + j[3] * size + 0.04 * size),
1644
+ (sx1 - 0.06 * size, bot + j[1] * size), (sx0 - 0.04 * size, bot - j[3] * size)]
1645
+ _polygon(strokes, pts, acc)
1646
+ texts.append((margin + (block_w - wl) / 2.0, base_y, ln))
1647
+ _alpha_paste(layer, _shadow_of(strokes, blur=size * 0.14, alpha=0.35), 0, int(size * 0.06))
1648
+ layer.alpha_composite(strokes)
1649
+ d = ImageDraw.Draw(layer)
1650
+ ink = _on_color(acc)
1651
+ for x, base_y, ln in texts:
1652
+ _draw_token_line(d, f, x, base_y, ln, [ink] * len(ln))
1653
+ angle = 2.2
1654
+ ax, ay = margin + block_w / 2.0, margin + block_h / 2.0
1655
+ rot, dx, dy = _rotate(layer, angle)
1656
+ rax, ray = _rotate_point(ax, ay, layer.width / 2.0, layer.height / 2.0, angle)
1657
+ return rot, rax + dx, ray + dy, block_h / 2.0 + abs(math.sin(math.radians(angle))) * block_w / 2.0
1658
+
1659
+
1660
+ def _hook_vidro(toks, marked, accent, geo, max_width):
1661
+ st = HOOK_STYLES["vidro"]
1662
+ k = geo.k
1663
+ side = st["side"]
1664
+ lines, size = _hook_lines(toks, st["font"], max_width, st["max"] * k, st["min"] * k, side)
1665
+ size = _refit(st["font"], lines, size, max_width, side)
1666
+ f = _font(st["font"], size)
1667
+ cap = _cap_height(f)
1668
+ pitch = size * 1.24
1669
+ padx, pady = side * size, 0.62 * size
1670
+ widths = [_tok_width(f, ln) for ln in lines]
1671
+ panel_w = max(widths) + 2 * padx
1672
+ text_h = cap + pitch * (len(lines) - 1) + 0.22 * size
1673
+ panel_h = text_h + 2 * pady
1674
+ margin = int(size * 0.4)
1675
+ layer = _new(panel_w + 2 * margin, panel_h + 2 * margin)
1676
+ acc = _rgba(accent)
1677
+ x0, y0 = margin, margin
1678
+ border = max(2, int(round(size * 0.035)))
1679
+ _rounded_box(layer, (x0, y0, x0 + panel_w, y0 + panel_h), radius=size * 0.42, color=(255, 255, 255, 58))
1680
+ _rounded_box(layer, (x0 + border, y0 + border, x0 + panel_w - border, y0 + panel_h - border),
1681
+ radius=size * 0.42 - border, color=(14, 14, 16, 196))
1682
+ bar_w, bar_h = size * 1.1, size * 0.12
1683
+ _rounded_box(layer, (x0 + (panel_w - bar_w) / 2.0, y0 + pady * 0.42, x0 + (panel_w + bar_w) / 2.0, y0 + pady * 0.42 + bar_h),
1684
+ radius=bar_h / 2.0, color=acc)
1685
+ cols = _line_colors(lines, marked, acc, WHITE, "none")
1686
+ d = ImageDraw.Draw(layer)
1687
+ for i, (ln, wl) in enumerate(zip(lines, widths)):
1688
+ base_y = y0 + pady + 0.2 * size + cap + pitch * i
1689
+ _draw_token_line(d, f, x0 + (panel_w - wl) / 2.0, base_y, ln, cols[i])
1690
+ return layer, x0 + panel_w / 2.0, y0 + panel_h / 2.0, panel_h / 2.0
1691
+
1692
+
1693
+ _HOOK_DRAWERS = {
1694
+ "manchete": _hook_manchete,
1695
+ "tarja": _hook_tarja,
1696
+ "balao": _hook_balao,
1697
+ "marcador": _hook_marcador,
1698
+ "vidro": _hook_vidro,
1699
+ }
1700
+
1701
+
1702
+ def hook_fit(text: str, style_id: str, canvas=(1080, 1920), max_width: int = 900) -> dict:
1703
+ """How a hook would be laid out: {"lines": [...], "size": px, "min_size": px, "too_long": bool}.
1704
+
1705
+ Hooks never wrap to 3 lines, so a long text only gets smaller; `too_long` tells the agent to
1706
+ shorten it.
1707
+ """
1708
+ if style_id not in HOOK_STYLES:
1709
+ raise ValueError(f"estilo de gancho '{style_id}' não existe. Opções: {', '.join(HOOK_STYLES)}")
1710
+ st = HOOK_STYLES[style_id]
1711
+ geo = _Geo(int(canvas[0]), int(canvas[1]))
1712
+ width = min(float(max_width), geo.w - 2 * SIDE_MARGIN * geo.sx)
1713
+ toks, _ = _parse_marked(text, st["upper"], st["font"])
1714
+ side = st["side"]
1715
+ lines, size = _hook_lines(toks, st["font"], width, st["max"] * geo.k, st["min"] * geo.k, side)
1716
+ size = _refit(st["font"], lines, size, width, side) if toks else size
1717
+ return {"lines": [" ".join(t.text for t in ln) for ln in lines], "size": int(size),
1718
+ "min_size": int(st["min"] * geo.k), "too_long": size < st["min"] * geo.k}
1719
+
1720
+
1721
+ def render_hook(text: str, style_id: str, accent: str, canvas=(1080, 1920), center_y: int = 430,
1722
+ max_width: int = 900) -> Image.Image:
1723
+ """Hook headline: always 1-2 lines, balanced by measured width, font size fitted."""
1724
+ if style_id not in HOOK_STYLES:
1725
+ raise ValueError(f"estilo de gancho '{style_id}' não existe. Opções: {', '.join(HOOK_STYLES)}")
1726
+ geo = _Geo(int(canvas[0]), int(canvas[1]))
1727
+ max_width = min(float(max_width), geo.w - 2 * SIDE_MARGIN * geo.sx)
1728
+ toks, marked = _parse_marked(text, HOOK_STYLES[style_id]["upper"], HOOK_STYLES[style_id]["font"])
1729
+ if not toks:
1730
+ return Image.new("RGBA", (geo.w, geo.h), (0, 0, 0, 0))
1731
+ layer, ax, ay, half = _HOOK_DRAWERS[style_id](toks, marked, accent or DEFAULT_ACCENT, geo, max_width)
1732
+ return _place((geo.w, geo.h), _Block(layer, ax, ay, half), geo.cx, float(center_y), {}, geo)
1733
+
1734
+
1735
+ # ============================================================== CTA and disclaimer
1736
+
1737
+ def render_cta(text: str, sub: str | None, accent: str, canvas=(1080, 1920),
1738
+ center_y: float | None = None) -> Image.Image:
1739
+ """Call-to-action button (accent pill + arrow) with an optional line below.
1740
+
1741
+ Default center y: layout_defaults(canvas)["cta_alone"] (1180 on 1080x1920). Asterisks and
1742
+ symbols the font cannot draw are removed (text_issues reports them)."""
1743
+ geo = _Geo(int(canvas[0]), int(canvas[1]))
1744
+ k = geo.k
1745
+ acc = _rgba(accent or DEFAULT_ACCENT)
1746
+ on = _on_color(acc)
1747
+ label, _ = sanitize_text(strip_markup(str(text or "").strip())[0].upper(), _TEXT_FONTS["cta"])
1748
+ label = " ".join(label.split())
1749
+ if sub is not None:
1750
+ sub = " ".join(sanitize_text(strip_markup(str(sub))[0], _TEXT_FONTS["cta_sub"])[0].split())
1751
+ max_w = min(860 * geo.sx, geo.w - 2 * SIDE_MARGIN * geo.sx)
1752
+ size = 66 * k
1753
+ for _ in range(6):
1754
+ f = _font("sans_black", size)
1755
+ h = size * 2.05
1756
+ icon = h * 0.64
1757
+ w = f.getlength(label) + size * 1.1 + icon + size * 0.5
1758
+ if w <= max_w or size <= 30 * k:
1759
+ break
1760
+ size = math.floor(size * max_w / w * 0.99)
1761
+ f = _font("sans_black", size)
1762
+ cap = _cap_height(f)
1763
+ h = size * 2.05
1764
+ icon = h * 0.64
1765
+ text_w = f.getlength(label)
1766
+ w = text_w + size * 1.1 + icon + size * 0.5
1767
+ margin = int(size * 0.6)
1768
+ layer = _new(w + 2 * margin, h + 2 * margin)
1769
+ x0, y0 = margin, margin
1770
+ pill = _new(layer.width, layer.height)
1771
+ _rounded_box(pill, (x0, y0, x0 + w, y0 + h), radius=h / 2.0, color=acc)
1772
+ _alpha_paste(layer, _shadow_of(pill, blur=size * 0.3, alpha=0.45), 0, int(size * 0.14))
1773
+ layer.alpha_composite(pill)
1774
+ # inner highlight line for a pressed-button feel
1775
+ _rounded_box(layer, (x0 + h * 0.3, y0 + h * 0.1, x0 + w - h * 0.3, y0 + h * 0.1 + max(2, size * 0.05)),
1776
+ radius=size * 0.03, color=(255, 255, 255, 70))
1777
+ d = ImageDraw.Draw(layer)
1778
+ tx = x0 + size * 0.75
1779
+ d.text((tx, y0 + h / 2.0 + cap / 2.0), label, font=f, fill=on, anchor="ls")
1780
+ # round icon with an arrow
1781
+ icx = x0 + w - size * 0.32 - icon / 2.0
1782
+ icy = y0 + h / 2.0
1783
+ _rounded_box(layer, (icx - icon / 2, icy - icon / 2, icx + icon / 2, icy + icon / 2), radius=icon / 2, color=on)
1784
+ a = icon * 0.24
1785
+ shaft = icon * 0.09
1786
+ arrow = [(icx - a, icy - shaft), (icx + a * 0.1, icy - shaft), (icx + a * 0.1, icy - a * 0.75), (icx + a * 1.05, icy),
1787
+ (icx + a * 0.1, icy + a * 0.75), (icx + a * 0.1, icy + shaft), (icx - a, icy + shaft)]
1788
+ _polygon(layer, arrow, acc)
1789
+ img = Image.new("RGBA", (geo.w, geo.h), (0, 0, 0, 0))
1790
+ cy = layout_defaults((geo.w, geo.h))["cta_alone"] if center_y is None else float(center_y)
1791
+ _alpha_paste(img, layer, int(round(geo.cx - (x0 + w / 2.0))), int(round(cy - (y0 + h / 2.0))))
1792
+ if sub and str(sub).strip():
1793
+ s_size = 42 * k
1794
+ s_text = str(sub).strip()
1795
+ for _ in range(4):
1796
+ sf = _font("text_bold", s_size)
1797
+ if sf.getlength(s_text) <= max_w or s_size <= 24 * k:
1798
+ break
1799
+ s_size = math.floor(s_size * max_w / sf.getlength(s_text) * 0.99)
1800
+ sf = _font("text_bold", s_size)
1801
+ s_cap = _cap_height(sf)
1802
+ stroke = max(1, int(round(s_size * 0.06)))
1803
+ sw = sf.getlength(s_text)
1804
+ pad = int(s_size * 0.6)
1805
+ sl = _new(sw + 2 * pad, s_size * 1.6 + 2 * pad)
1806
+ ImageDraw.Draw(sl).text((pad, pad + s_size * 0.3 + s_cap), s_text, font=sf, fill=WHITE, anchor="ls",
1807
+ stroke_width=stroke, stroke_fill=(0, 0, 0, 255))
1808
+ out = _new(sl.width, sl.height)
1809
+ _alpha_paste(out, _shadow_of(sl, blur=s_size * 0.25, alpha=0.75), 0, int(s_size * 0.06))
1810
+ out.alpha_composite(sl)
1811
+ sy = cy + h / 2.0 + s_size * 0.55 + s_cap / 2.0
1812
+ _alpha_paste(img, out, int(round(geo.cx - (pad + sw / 2.0))), int(round(sy - (pad + s_size * 0.3 + s_cap / 2.0))))
1813
+ return img
1814
+
1815
+
1816
+ def render_disclaimer(text: str, canvas=(1080, 1920), center_y: float | None = None) -> Image.Image:
1817
+ """Small dark pill with the result disclaimer (up to 2 lines).
1818
+
1819
+ Default center y: layout_defaults(canvas)["disclaimer"] (1548 on 1080x1920)."""
1820
+ geo = _Geo(int(canvas[0]), int(canvas[1]))
1821
+ k = geo.k
1822
+ msg = " ".join(sanitize_text(strip_markup(str(text or ""))[0], _TEXT_FONTS["disclaimer"])[0].split())
1823
+ if center_y is None:
1824
+ center_y = layout_defaults((geo.w, geo.h))["disclaimer"]
1825
+ img = Image.new("RGBA", (geo.w, geo.h), (0, 0, 0, 0))
1826
+ if not msg:
1827
+ return img
1828
+ max_w = geo.w - 2 * SIDE_MARGIN * geo.sx
1829
+ size = 31 * k
1830
+ padx, pady = size * 0.7, size * 0.36
1831
+ words = msg.split()
1832
+ for _ in range(6):
1833
+ f = _font("text_semibold", size)
1834
+ one_w = f.getlength(msg)
1835
+ if one_w + 2 * padx <= max_w:
1836
+ lines = [words]
1837
+ else:
1838
+ lines = _balanced_split(words, lambda ws: f.getlength(" ".join(ws)))
1839
+ widest = max(f.getlength(" ".join(ln)) for ln in lines)
1840
+ if widest + 2 * padx <= max_w or size <= 20 * k:
1841
+ break
1842
+ size = math.floor(size * (max_w - 2 * padx) / widest * 0.99)
1843
+ padx, pady = size * 0.7, size * 0.36
1844
+ f = _font("text_semibold", size)
1845
+ asc, desc = f.getmetrics()
1846
+ pitch = size * 1.25
1847
+ widths = [f.getlength(" ".join(ln)) for ln in lines]
1848
+ box_w = max(widths) + 2 * padx
1849
+ box_h = asc + desc + pitch * (len(lines) - 1) + 2 * pady
1850
+ layer = _new(box_w + 4, box_h + 4)
1851
+ _rounded_box(layer, (2, 2, 2 + box_w, 2 + box_h), radius=min(size * 0.6, box_h / 2), color=(0, 0, 0, 128))
1852
+ d = ImageDraw.Draw(layer)
1853
+ for i, (ln, wl) in enumerate(zip(lines, widths)):
1854
+ d.text((2 + (box_w - wl) / 2.0, 2 + pady + asc + pitch * i), " ".join(ln), font=f,
1855
+ fill=(255, 255, 255, 240), anchor="ls")
1856
+ cy = float(center_y)
1857
+ cy = min(cy, geo.h - box_h / 2.0 - 8 * geo.sy)
1858
+ _alpha_paste(img, layer, int(round(geo.cx - (2 + box_w / 2.0))), int(round(cy - (2 + box_h / 2.0))))
1859
+ return img
1860
+
1861
+
1862
+ # ============================================================== samples
1863
+
1864
+ def cover_image(img: Image.Image, size: tuple[int, int]) -> Image.Image:
1865
+ """Scale to fill `size` and center-crop (like CSS object-fit: cover)."""
1866
+ w, h = size
1867
+ s = max(w / img.width, h / img.height)
1868
+ nw, nh = max(1, int(round(img.width * s))), max(1, int(round(img.height * s)))
1869
+ r = img.resize((nw, nh), Image.LANCZOS)
1870
+ x, y = (nw - w) // 2, (nh - h) // 2
1871
+ return r.crop((x, y, x + w, y + h))
1872
+
1873
+
1874
+ def sample_caption_words(text: str, style_id: str) -> tuple[list[str], int]:
1875
+ """Words shown in a caption sample and which one is active."""
1876
+ spec = _caption_spec(style_id)
1877
+ words = display_words(str(text).split(), style_id)
1878
+ if not words:
1879
+ words = display_words("Olha só isso aqui".split(), style_id)
1880
+ limit = _Geo(REF_W, REF_H).caption_max_w
1881
+ chosen: list[str] = []
1882
+ for w in words:
1883
+ if len(chosen) >= spec.max_words:
1884
+ break
1885
+ trial = chosen + [w]
1886
+ if chosen and spec.lines == 1 and measure_caption_width(trial, style_id, already_display=True) > limit:
1887
+ break
1888
+ if chosen and spec.lines >= 2 and measure_caption_width(trial, style_id, already_display=True) > limit * spec.lines * 0.92:
1889
+ break
1890
+ chosen.append(w)
1891
+ active = -1 if not spec.animated else (0 if spec.one_word else min(1, len(chosen) - 1))
1892
+ return chosen, active
1893
+
1894
+
1895
+ def render_caption_image(words: list[str], style_id: str, accent: str, canvas=(1080, 1920),
1896
+ active: int = 0, center_y: float | None = None, font_scale: float = 1.0) -> Image.Image:
1897
+ """One steady caption state (display words already cased) on a transparent canvas."""
1898
+ spec = _caption_spec(style_id)
1899
+ geo = _Geo(int(canvas[0]), int(canvas[1]))
1900
+ cy = layout_defaults((geo.w, geo.h))["caption"] if center_y is None else center_y
1901
+ variant = {"angle": -2.6} if spec.draw == "fita" else {}
1902
+ block = _caption_block(spec, geo, tuple(words), active, accent or DEFAULT_ACCENT, font_scale, variant, {})
1903
+ return _place((geo.w, geo.h), block, geo.cx, cy, {}, geo)
1904
+
1905
+
1906
+ def sample_canvas(background_size) -> tuple[int, int]:
1907
+ """Full-size canvas a style sample is composed on, for a frame of this size."""
1908
+ bw, bh = background_size
1909
+ if bw < bh:
1910
+ return (REF_W, REF_H)
1911
+ if bw > bh:
1912
+ return (REF_H, REF_W)
1913
+ return (REF_W, REF_W)
1914
+
1915
+
1916
+ def render_sample_frame(background: Image.Image, kind: str, style_id: str, accent: str,
1917
+ text: str, center_y: float | None = None) -> Image.Image:
1918
+ """Style preview over a real frame. Returns an RGB image with the background's size.
1919
+
1920
+ `center_y` (pixels on sample_canvas(background.size)) moves the text, e.g. off the face;
1921
+ default: layout_defaults of that canvas."""
1922
+ bw, bh = background.size
1923
+ canvas = sample_canvas((bw, bh))
1924
+ base = cover_image(background.convert("RGB"), canvas).convert("RGBA")
1925
+ lay = layout_defaults(canvas)
1926
+ if kind == "caption":
1927
+ words, active = sample_caption_words(text, style_id)
1928
+ over = render_caption_image(words, style_id, accent, canvas, active=active,
1929
+ center_y=lay["caption"] if center_y is None else center_y)
1930
+ elif kind == "hook":
1931
+ over = render_hook(text, style_id, accent, canvas, center_y=lay["hook"] if center_y is None else center_y)
1932
+ else:
1933
+ raise ValueError("kind precisa ser 'caption' ou 'hook'")
1934
+ base.alpha_composite(over)
1935
+ out = base.convert("RGB")
1936
+ if out.size != (bw, bh):
1937
+ out = out.resize((bw, bh), Image.LANCZOS)
1938
+ return out
1939
+
1940
+
1941
+ # ============================================================== CLI
1942
+
1943
+ def _catalog_check() -> list[str]:
1944
+ cat = load_style_catalog()
1945
+ problems = []
1946
+ for kind, ids in (("caption_styles", set(CAPTION_STYLES)), ("hook_styles", set(HOOK_STYLES))):
1947
+ cat_ids = {s.get("id") for s in cat.get(kind, [])}
1948
+ if cat_ids != ids:
1949
+ problems.append(f"{kind}: catálogo {sorted(cat_ids)} x código {sorted(ids)}")
1950
+ return problems
1951
+
1952
+
1953
+ def _save_with_bg(img: Image.Image, out: str, bg: str | None) -> None:
1954
+ if bg:
1955
+ with Image.open(bg) as b:
1956
+ base = cover_image(b.convert("RGB"), img.size).convert("RGBA")
1957
+ base.alpha_composite(img)
1958
+ img = base.convert("RGB")
1959
+ Path(out).parent.mkdir(parents=True, exist_ok=True)
1960
+ img.save(out)
1961
+ print(f"ok: {out}")
1962
+
1963
+
1964
+ def main(argv: list[str] | None = None) -> int:
1965
+ ap = argparse.ArgumentParser(description="Renderiza textos (legendas, gancho, chamada, aviso) em PNG transparente.")
1966
+ sub = ap.add_subparsers(dest="cmd", required=True)
1967
+ sub.add_parser("list", help="lista os estilos disponíveis")
1968
+
1969
+ def common_args(p):
1970
+ p.add_argument("--accent", default=DEFAULT_ACCENT, help="cor de destaque em hex (padrão #FFC400)")
1971
+ p.add_argument("--width", type=int, default=REF_W)
1972
+ p.add_argument("--height", type=int, default=REF_H)
1973
+ p.add_argument("--bg", help="imagem de fundo para conferir (opcional)")
1974
+ p.add_argument("-o", "--output", required=True, help="PNG de saída")
1975
+
1976
+ p = sub.add_parser("hook", help="gancho (1 ou 2 linhas)")
1977
+ p.add_argument("text")
1978
+ p.add_argument("--style", required=True)
1979
+ p.add_argument("--center-y", type=float, help="centro vertical em pixels (padrão: 430 na tela vertical)")
1980
+ common_args(p)
1981
+ p = sub.add_parser("caption", help="um estado de legenda")
1982
+ p.add_argument("text")
1983
+ p.add_argument("--style", required=True)
1984
+ p.add_argument("--active", type=int, default=1)
1985
+ common_args(p)
1986
+ p = sub.add_parser("cta", help="botão de chamada")
1987
+ p.add_argument("text")
1988
+ p.add_argument("--sub")
1989
+ p.add_argument("--center-y", type=float, help="centro vertical em pixels (padrão: 1180 na tela vertical)")
1990
+ common_args(p)
1991
+ p = sub.add_parser("disclaimer", help="aviso de resultado")
1992
+ p.add_argument("text")
1993
+ p.add_argument("--center-y", type=float, help="centro vertical em pixels (padrão: 1548 na tela vertical)")
1994
+ common_args(p)
1995
+ p = sub.add_parser("track", help="trilha de legendas: PNGs + captions.ffconcat")
1996
+ p.add_argument("captions", help="captions.json")
1997
+ p.add_argument("--style", required=True)
1998
+ p.add_argument("--accent", default=DEFAULT_ACCENT)
1999
+ p.add_argument("--fps", type=int, default=30)
2000
+ p.add_argument("--width", type=int, default=REF_W)
2001
+ p.add_argument("--height", type=int, default=REF_H)
2002
+ p.add_argument("--duration", type=float, help="duração total do vídeo (padrão: fim da última legenda)")
2003
+ p.add_argument("--font-scale", type=float, default=1.0)
2004
+ p.add_argument("-o", "--out-dir", required=True)
2005
+ args = ap.parse_args(argv)
2006
+
2007
+ try:
2008
+ if args.cmd == "list":
2009
+ print("legendas: " + ", ".join(CAPTION_STYLES))
2010
+ print("ganchos: " + ", ".join(HOOK_STYLES))
2011
+ for msg in _catalog_check():
2012
+ print("AVISO: " + msg)
2013
+ return 0
2014
+ canvas = (args.width, args.height)
2015
+ issues: list[str] = []
2016
+ if args.cmd == "hook":
2017
+ fit = hook_fit(args.text, args.style, canvas)
2018
+ issues = text_issues("hook", args.text, args.style)
2019
+ cy = layout_defaults(canvas)["hook"] if args.center_y is None else args.center_y
2020
+ _save_with_bg(render_hook(args.text, args.style, args.accent, canvas, cy), args.output, args.bg)
2021
+ print(f"linhas: {' / '.join(fit['lines'])} · letra {fit['size']}px")
2022
+ if fit["too_long"]:
2023
+ print(f"aviso: gancho longo demais, a letra ficou pequena ({fit['size']}px, ideal a partir de "
2024
+ f"{fit['min_size']}px). Encurte o texto.")
2025
+ elif args.cmd == "caption":
2026
+ spec = _caption_spec(args.style)
2027
+ words = display_words(args.text.split(), args.style)
2028
+ active = -1 if not spec.animated else max(-1, min(args.active, len(words) - 1))
2029
+ _save_with_bg(render_caption_image(words, args.style, args.accent, canvas, active=active), args.output, args.bg)
2030
+ elif args.cmd == "cta":
2031
+ issues = text_issues("cta", args.text) + text_issues("cta_sub", args.sub or "")
2032
+ _save_with_bg(render_cta(args.text, args.sub, args.accent, canvas, args.center_y), args.output, args.bg)
2033
+ elif args.cmd == "disclaimer":
2034
+ issues = text_issues("disclaimer", args.text)
2035
+ _save_with_bg(render_disclaimer(args.text, canvas, args.center_y), args.output, args.bg)
2036
+ elif args.cmd == "track":
2037
+ src = Path(args.captions)
2038
+ if not src.exists():
2039
+ print(f"erro: arquivo de legendas não encontrado: {src}", file=sys.stderr)
2040
+ return 2
2041
+ data = json.loads(src.read_text(encoding="utf-8"))
2042
+ t0 = time.time()
2043
+ states = render_caption_track(data, args.style, args.accent, args.out_dir, canvas, args.fps,
2044
+ font_scale=args.font_scale)
2045
+ total = args.duration if args.duration else (states[-1]["end"] if states else 0.0)
2046
+ out_dir = Path(args.out_dir).resolve()
2047
+ ff = write_ffconcat(states, str(out_dir / "captions.ffconcat"), total, str(out_dir / "blank.png"), fps=args.fps)
2048
+ uniq = len({s["file"] for s in states})
2049
+ print(f"ok: {len(states)} estados, {uniq} PNGs, {time.time() - t0:.1f}s -> {ff}")
2050
+ for msg in issues:
2051
+ print(f"aviso: {msg}")
2052
+ except (ValueError, FileNotFoundError) as e:
2053
+ print(f"erro: {e}", file=sys.stderr)
2054
+ return 2
2055
+ return 0
2056
+
2057
+
2058
+ if __name__ == "__main__":
2059
+ sys.exit(main())