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,271 @@
1
+ """checklist: a list on a card where each item slides in and gets a check mark, one after the other.
2
+
3
+ Frame 0 already shows the card, the title and the empty markers (the skeleton of the list), so the
4
+ entrance sound lands on something even when the first item comes later. Each item then slides in and its
5
+ marker fills with the accent and draws a check. After the last check the card border glows once.
6
+ Timing: `delay` + `stagger`, or `times` (one second per item, counted from the item start).
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import json
12
+ from functools import lru_cache
13
+
14
+ from motion import engine as E
15
+ from motion.engine import MotionError
16
+
17
+ TEMPLATE = {
18
+ "id": "checklist",
19
+ "label_pt": "Lista com check",
20
+ "description_pt": "Uma lista num cartão em que cada item entra deslizando e ganha um check na cor de destaque, "
21
+ "um por vez. Pode ter um título em cima e o cartão brilha quando o último item é marcado.",
22
+ "use_when_pt": "Quando a fala enumera o que a pessoa recebe, faz ou ganha: acesso na hora, suporte, garantia. "
23
+ "Para cada item entrar junto com a fala, use times (segundos desde o começo do item).",
24
+ "notes_pt": "Itens curtos leem melhor (até 32 letras; textos longos quebram em duas linhas e a letra diminui). "
25
+ "Com 5 ou 6 itens, prefira a região centro ou tela.",
26
+ "category": "listas",
27
+ "version": 1,
28
+ "params": {
29
+ "items": {"type": "list", "item": {"type": "text", "max_len": 32, "required_text": True},
30
+ "min_items": 1, "max_items": 6,
31
+ "default": ["Acesso na hora", "Suporte no WhatsApp", "Garantia de 7 dias"],
32
+ "label_pt": "Itens da lista, de cima para baixo (1 a 6, cada um com até 32 letras)"},
33
+ "title": {"type": "text", "default": "", "max_len": 36,
34
+ "label_pt": "Título em cima da lista (opcional). *Palavra* entre asteriscos fica na cor de destaque"},
35
+ "delay": {"type": "number", "default": 0.25, "min": 0, "max": 30,
36
+ "label_pt": "Segundos até o primeiro item entrar (o cartão já aparece no começo)"},
37
+ "stagger": {"type": "number", "default": 0.45, "min": 0.1, "max": 5,
38
+ "label_pt": "Segundos entre um item e o próximo"},
39
+ "times": {"type": "list", "item": {"type": "number", "min": 0, "max": 60}, "nullable": True,
40
+ "default": None, "min_items": 1, "max_items": 6,
41
+ "label_pt": "Segundo em que cada item entra, contado do começo do item (um por item, em ordem "
42
+ "crescente). Vazio: usa delay e stagger"},
43
+ "mode": {"type": "choice", "choices": ["circulo", "caixa"], "default": "circulo",
44
+ "label_pt": "Marcador do check: círculo ou caixinha"},
45
+ "size": {"type": "choice", "choices": ["normal", "grande"], "default": "normal",
46
+ "label_pt": "Tamanho do texto"},
47
+ "card": {"type": "bool", "default": True,
48
+ "label_pt": "Cartão atrás da lista (sem cartão: texto com contorno e sombra)"},
49
+ },
50
+ "default_duration": 3.0,
51
+ "default_region": "baixo",
52
+ "sfx": [{"at": 0.0, "name": "pop"}, {"at": 0.35, "name": "tick"}, {"at": 0.8, "name": "tick"},
53
+ {"at": 1.25, "name": "tick"}, {"at": 1.75, "name": "success", "volume": 0.8}],
54
+ "example": {"title": "O que você *recebe*:",
55
+ "items": ["Acesso na hora", "Suporte no WhatsApp", "Aulas curtas e diretas", "Garantia de 7 dias"]},
56
+ }
57
+
58
+ FONT = "text_bold"
59
+ TITLE_FONT = "sans_black"
60
+ BASE_SIZE = {"normal": 56.0, "grande": 68.0}
61
+ MIN_SIZE = 34.0
62
+
63
+ SKELETON = -0.08 # the empty markers start before frame 0, so frame 0 always shows them
64
+ ENTER = 0.5 # card entrance (seconds)
65
+ ROW_IN = 0.32 # item slide and fade
66
+ CHECK_AT = 0.10 # the marker fills this long after the item starts entering
67
+ CHECK_POP = 0.40 # marker pop (out_back)
68
+ CHECK_DRAW = 0.26 # the check stroke draws
69
+ GLOW_AFTER = 0.30 # card border glow starts this long after the last check
70
+ GLOW = 0.55
71
+
72
+
73
+ def _times(p: dict) -> list[float]:
74
+ """Entry second of every item (validated against the item count)."""
75
+ n = len(p["items"])
76
+ times = p.get("times")
77
+ if times is None:
78
+ return [p["delay"] + i * p["stagger"] for i in range(n)]
79
+ if len(times) != n:
80
+ raise MotionError(f"checklist.params.times precisa de um tempo para cada item ({n} itens, veio {len(times)})")
81
+ for i in range(1, n):
82
+ if times[i] < times[i - 1]:
83
+ raise MotionError(f"checklist.params.times precisa estar em ordem crescente (o item {i + 1} entra em "
84
+ f"{times[i]:g} s, antes do item {i} em {times[i - 1]:g} s)")
85
+ return [float(v) for v in times]
86
+
87
+
88
+ def validate(params: dict) -> None:
89
+ """One time per item, in order: refused when the item is planned, not only when it renders."""
90
+ _times(params)
91
+
92
+
93
+ @lru_cache(maxsize=64)
94
+ def _layout(params_json: str, box: E.Box) -> dict:
95
+ p = json.loads(params_json)
96
+ items = p["items"]
97
+ card = p["card"]
98
+ title = p["title"].strip()
99
+ title_plain = " ".join(w for w, _ in E.parse_accent(title))
100
+ pad_x = 54.0 if card else 6.0
101
+ pad_y = 48.0 if card else 6.0
102
+
103
+ def build(size: float) -> tuple[dict, bool]:
104
+ marker = size * 1.04
105
+ gap = size * 0.44
106
+ text_w = box.w - 2 * pad_x - marker - gap
107
+ m = E.measure("H", FONT, size)
108
+ line_h = size * 1.17
109
+ row_gap = size * 0.46
110
+ rows, fits, y = [], True, 0.0
111
+ title_size = title_h = title_w = 0.0
112
+ if title:
113
+ title_size = min(size * 0.94, E.fit_text(title_plain, TITLE_FONT, box.w - 2 * pad_x, size * 0.94, 26.0))
114
+ title_cap = E.measure("H", TITLE_FONT, title_size).cap
115
+ title_w = E.measure(title_plain, TITLE_FONT, title_size).width
116
+ title_h = title_cap + size * 0.74
117
+ y = title_h
118
+ widest = 0.0
119
+ for k, text in enumerate(items):
120
+ lines = E.wrap_text(text, FONT, size, text_w, 2)
121
+ sizes = []
122
+ for ln in lines:
123
+ w = E.measure(ln, FONT, size).width
124
+ if w > text_w + 0.5:
125
+ fits = False
126
+ sizes.append(E.fit_text(ln, FONT, text_w, size, 20.0))
127
+ else:
128
+ sizes.append(size)
129
+ widths = [E.measure(ln, FONT, s).width for ln, s in zip(lines, sizes)]
130
+ widest = max(widest, max(widths))
131
+ base0 = y + marker / 2.0 + m.cap / 2.0
132
+ bottom = max(y + marker, base0 + (len(lines) - 1) * line_h)
133
+ rows.append({"lines": lines, "sizes": sizes, "widths": widths, "top": y,
134
+ "cy": y + marker / 2.0, "base0": base0})
135
+ y = bottom + (row_gap if k < len(items) - 1 else 0.0)
136
+ content_h = y
137
+ card_h = content_h + 2 * pad_y
138
+ content_w = max(marker + gap + widest, title_w)
139
+ card_w = min(box.w, max(content_w + 2 * pad_x, 380.0 if card else 0.0))
140
+ L = {"size": size, "marker": marker, "gap": gap, "line_h": line_h, "rows": rows,
141
+ "title_size": title_size, "title_base": E.measure("H", TITLE_FONT, title_size).cap if title else 0.0,
142
+ "card_w": card_w, "card_h": card_h, "pad_x": pad_x, "pad_y": pad_y}
143
+ return L, fits and card_h <= box.h
144
+
145
+ size = BASE_SIZE[p["size"]]
146
+ L, ok = build(size)
147
+ while not ok and size > MIN_SIZE:
148
+ size = max(MIN_SIZE, size * 0.95)
149
+ L, ok = build(size)
150
+ card_x = (box.w - L["card_w"]) / 2.0
151
+ card_y = (box.h - L["card_h"]) / 2.0
152
+ L["card_box"] = (card_x, card_y, L["card_w"], L["card_h"])
153
+ L["x0"] = card_x + pad_x
154
+ L["y0"] = card_y + pad_y
155
+ L["radius"] = min(40.0, L["card_h"] * 0.2)
156
+ theme = box.theme
157
+ # a light accent on a white card gets a darker rim so the filled marker keeps its edge
158
+ L["rim"] = card and E.contrast(theme.accent, theme.card) < 1.6
159
+ L["times"] = _times(p)
160
+ L["title"] = title
161
+ L["card"] = card
162
+ L["mode"] = p["mode"]
163
+ return L
164
+
165
+
166
+ def _marker(cv: E.Canvas, mode: str, cx: float, cy: float, d: float, filled: bool, color,
167
+ halo: bool = False, rim: bool = False) -> None:
168
+ """Empty or filled marker. `halo` (no card): a dark edge so it reads on any video."""
169
+ w = d * 0.085
170
+ if mode == "caixa":
171
+ r = d * 0.26
172
+ if filled:
173
+ cv.rect(cx - d / 2, cy - d / 2, d, d, radius=r, fill=color,
174
+ shadow=E.Shadow(blur=10.0, dy=4.0, opacity=0.5) if halo else None,
175
+ stroke=(0, 0, 0, 40) if rim else None, stroke_width=2.0 if rim else 0.0)
176
+ return
177
+ if halo:
178
+ e = w * 0.9
179
+ cv.rect(cx - d / 2 - e, cy - d / 2 - e, d + 2 * e, d + 2 * e, radius=r + e, fill=None,
180
+ stroke=(0, 0, 0, 120), stroke_width=w + 2 * e)
181
+ cv.rect(cx - d / 2, cy - d / 2, d, d, radius=r, fill=None, stroke=color, stroke_width=w)
182
+ return
183
+ if filled:
184
+ cv.circle(cx, cy, d / 2, fill=color, shadow=E.Shadow(blur=10.0, dy=4.0, opacity=0.5) if halo else None,
185
+ stroke=(0, 0, 0, 40) if rim else None, stroke_width=2.0 if rim else 0.0)
186
+ return
187
+ if halo:
188
+ cv.ring(cx, cy, d / 2 - w / 2, w * 2.8, color=(0, 0, 0, 120))
189
+ cv.ring(cx, cy, d / 2 - w / 2, w, color=color)
190
+
191
+
192
+ def render(frame_index: int, fps: int, params: dict, box: E.Box) -> E.Canvas:
193
+ cv = E.Canvas(box)
194
+ t = frame_index / fps
195
+ t_in = t + 1.0 / fps
196
+ L = _layout(json.dumps(params, sort_keys=True), box)
197
+ card = L["card"]
198
+ size, d = L["size"], L["marker"]
199
+ times = L["times"]
200
+
201
+ alpha = E.eased(t_in, 0.0, 0.18, "out_quad")
202
+ scale = 0.9 + 0.1 * E.out_back(E.progress(t_in, 0.0, ENTER), 1.4)
203
+ rise = 32.0 * (1.0 - E.eased(t_in, 0.0, 0.45))
204
+ outline = {} if card else {"stroke": size * 0.07, "stroke_color": "black", "shadow": E.TEXT_SHADOW}
205
+ ink = "text" if card else "white" # no card: white letters with a black outline in both themes
206
+ halo = not card
207
+ empty_color = "track" if card else (255, 255, 255, 215)
208
+
209
+ with cv.group(opacity=alpha, scale=scale, dy=rise):
210
+ cx0, cy0, cw, ch = L["card_box"]
211
+ if card:
212
+ cv.card(cx0, cy0, cw, ch, radius=L["radius"])
213
+ glow = E.pulse(t, times[-1] + CHECK_AT + CHECK_POP * 0.5 + GLOW_AFTER, GLOW)
214
+ if glow > 0.01:
215
+ cv.rect(cx0, cy0, cw, ch, radius=L["radius"], fill=None, stroke="accent", stroke_width=3.0,
216
+ opacity=0.9 * glow)
217
+ x0, y0 = L["x0"], L["y0"]
218
+ if L["title"]:
219
+ cv.rich_text(x0, y0 + L["title_base"], L["title"], TITLE_FONT, L["title_size"], color=ink,
220
+ **outline)
221
+ mx = x0 + d / 2.0
222
+ tx = x0 + d + L["gap"]
223
+ for i, row in enumerate(L["rows"]):
224
+ ti = times[i]
225
+ cy = y0 + row["cy"]
226
+ # skeleton: the empty marker pops in with the card
227
+ sk = E.out_back(E.progress(t_in, SKELETON + 0.05 * i, 0.34), 1.8)
228
+ fill_p = E.progress(t, ti + CHECK_AT, CHECK_POP)
229
+ if sk > 0.01 and fill_p < 1.0:
230
+ with cv.group(scale=max(0.01, sk), pivot=(mx, cy)):
231
+ _marker(cv, L["mode"], mx, cy, d, False, empty_color, halo=halo)
232
+ if fill_p > 0:
233
+ pop = max(0.01, E.out_back(fill_p, 2.2))
234
+ with cv.group(scale=pop, pivot=(mx, cy)):
235
+ _marker(cv, L["mode"], mx, cy, d, True, "accent", halo=halo, rim=L["rim"])
236
+ draw = E.eased(t, ti + CHECK_AT + 0.05, CHECK_DRAW, "in_out_cubic")
237
+ if draw > 0:
238
+ cv.check(mx, cy + d * 0.02, d * 0.54, width=d * 0.12, color="on_accent", progress=draw)
239
+ # the text slides in from the marker
240
+ a = E.eased(t, ti, ROW_IN, "out_cubic")
241
+ if cv.visible(a):
242
+ dx = -min(L["gap"] * 0.8, 18.0) * (1.0 - a)
243
+ dy = 10.0 * (1.0 - a)
244
+ for k, (ln, sz) in enumerate(zip(row["lines"], row["sizes"])):
245
+ cv.text(tx + dx, y0 + row["base0"] + k * L["line_h"] + dy, ln, FONT, sz, color=ink,
246
+ opacity=a, **outline)
247
+ return cv
248
+
249
+
250
+ def sfx(params: dict, fps: int, frames: int) -> list[dict]:
251
+ times = _times(params)
252
+ cues = [{"at": 0.0, "name": "pop", "why": "entrada do cartão"}]
253
+ for i, ti in enumerate(times):
254
+ cues.append({"at": ti + CHECK_AT, "name": "tick", "why": f"check do item {i + 1}"})
255
+ cues.append({"at": times[-1] + CHECK_AT + CHECK_POP * 0.5 + GLOW_AFTER, "name": "success", "volume": 0.8,
256
+ "why": "lista completa"})
257
+ return [{"frame": E.frame_of(c.pop("at"), fps), **c} for c in cues]
258
+
259
+
260
+ def suggested_duration(params: dict) -> float:
261
+ """Last check, the glow and about 1.4 s to read the whole list."""
262
+ last = _times(params)[-1]
263
+ return round(max(TEMPLATE["default_duration"], last + CHECK_AT + CHECK_POP + 1.4 + 0.15 * len(params["items"])), 2)
264
+
265
+
266
+ def settle(params: dict, fps: int) -> float:
267
+ last = _times(params)[-1]
268
+ n = len(params["items"])
269
+ return max(ENTER, SKELETON + 0.05 * (n - 1) + 0.34,
270
+ last + CHECK_AT + CHECK_POP * 0.5 + GLOW_AFTER + GLOW,
271
+ last + CHECK_AT + CHECK_POP, last + CHECK_AT + 0.05 + CHECK_DRAW, last + ROW_IN) + 0.05
@@ -0,0 +1,380 @@
1
+ """comentario_cta: a generic comment bar where a keyword is typed letter by letter, then sent with a tap.
2
+
3
+ The look is a neutral social app (no logo, name or icon of a real platform): a round avatar with a generic
4
+ silhouette, a pill-shaped field with the placeholder, a blinking cursor, and a send button with a paper-plane
5
+ glyph (or the word "Enviar"). After the tap the comment shows up above the bar and the call-to-action text
6
+ moves up to make room, like a real comment list.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import json
12
+ import math
13
+ from functools import lru_cache
14
+
15
+ from motion import engine as E
16
+ from motion.engine import MotionError
17
+
18
+ TEMPLATE = {
19
+ "id": "comentario_cta",
20
+ "label_pt": "Comentário com palavra-chave",
21
+ "description_pt": "Uma barra de comentário genérica em que a palavra-chave (ex.: IDEIA) é digitada letra por "
22
+ "letra com cursor, o botão de enviar é tocado e o comentário aparece publicado acima.",
23
+ "use_when_pt": "Na chamada para comentar uma palavra e receber algo (link, material, aula) por mensagem. "
24
+ "Coloque no fim do vídeo, junto com a fala da chamada.",
25
+ "notes_pt": "Visual genérico de propósito: não imita o Instagram nem outro app (sem logo, nome ou ícone de "
26
+ "marca). Use {palavra} no texto de cima para repetir a palavra-chave.",
27
+ "category": "chamada",
28
+ "version": 1,
29
+ "params": {
30
+ "keyword": {"type": "text", "default": "IDEIA", "max_len": 20, "required_text": True,
31
+ "label_pt": "Palavra-chave digitada na barra (ex.: IDEIA, LINK, QUERO)"},
32
+ "prompt_text": {"type": "text", "default": "Comente a palavra", "max_len": 48,
33
+ "label_pt": "Texto acima da barra (vazio: sem texto). *palavra* na cor de destaque; "
34
+ "{palavra} vira a palavra-chave"},
35
+ "placeholder": {"type": "text", "default": "Adicione um comentário...", "max_len": 32,
36
+ "label_pt": "Texto apagado da barra antes de digitar"},
37
+ "user_name": {"type": "text", "default": "você", "max_len": 20,
38
+ "label_pt": "Nome que aparece no comentário publicado"},
39
+ "upper": {"type": "bool", "default": True, "label_pt": "Palavra-chave em letra maiúscula"},
40
+ "delay": {"type": "number", "default": 0.7, "min": 0.3, "max": 5,
41
+ "label_pt": "Segundos até começar a digitar"},
42
+ "typing_speed": {"type": "number", "default": 7.0, "min": 2, "max": 30,
43
+ "label_pt": "Letras por segundo (palavra longa é digitada mais rápido, em até 1,6 s)"},
44
+ "button": {"type": "choice", "choices": ["icone", "texto"], "default": "icone",
45
+ "label_pt": "Botão de enviar: ícone de aviãozinho ou a palavra Enviar"},
46
+ "show_sent": {"type": "bool", "default": True,
47
+ "label_pt": "Depois do toque, mostrar o comentário publicado acima da barra"},
48
+ "size": {"type": "choice", "choices": ["normal", "grande"], "default": "normal",
49
+ "label_pt": "Tamanho da barra"},
50
+ },
51
+ "default_duration": 3.8,
52
+ "default_region": "baixo",
53
+ "sfx": [{"at": 0.0, "name": "pop", "volume": 0.8, "why": "barra aparece"},
54
+ {"at": 0.7, "name": "keyboard", "volume": 0.9, "why": "digitando"},
55
+ {"at": 1.76, "name": "mouse_click", "why": "toque em enviar"},
56
+ {"at": 1.84, "name": "pop", "volume": 0.7, "why": "comentário publicado"}],
57
+ "example": {"keyword": "IDEIA", "prompt_text": "Comente *{palavra}* que eu te mando o link"},
58
+ }
59
+
60
+ ENTER = 0.40 # entrance of the bar
61
+ TAP_AFTER = 0.45 # pause between the last letter and the tap
62
+ SENT_AFTER = 0.08 # the comment starts this long after the tap
63
+ SENT_T = 0.42 # comment slide
64
+ PROMPT_MOVE = 0.30 # the prompt moves up first, so the comment never covers it
65
+ HOLD = 1.6 # seconds to read after the comment lands
66
+ BLINK = 1.0 # cursor period
67
+ MAX_TYPING = 1.6 # seconds
68
+ RIPPLE = 0.36 # the tap ripple grows to 1.36x the button
69
+
70
+
71
+ # ------------------------------------------------------------------ text and timing
72
+
73
+ def _keyword(p: dict) -> str:
74
+ kw = E.clean_text(p["keyword"], "text_bold").strip()
75
+ if not kw:
76
+ raise MotionError("comentario_cta.params.keyword precisa de letras ou números que a fonte desenha")
77
+ return kw.upper() if p["upper"] else kw
78
+
79
+
80
+ @lru_cache(maxsize=128)
81
+ def _timeline(params_json: str) -> dict:
82
+ """Seconds (item time) of every beat; typing has a small human rhythm, fixed by the keyword."""
83
+ p = json.loads(params_json)
84
+ kw = _keyword(p)
85
+ r = E.rng("comentario_cta", kw)
86
+ # long keywords are typed faster so the typing never takes much more than MAX_TYPING
87
+ base = 1.0 / max(p["typing_speed"], (len(kw) - 1) / MAX_TYPING if len(kw) > 1 else 1.0)
88
+ times = []
89
+ t = p["delay"]
90
+ for i, ch in enumerate(kw):
91
+ times.append(t)
92
+ step = base * r.uniform(0.7, 1.3)
93
+ if ch == " " or (i + 1 < len(kw) and kw[i + 1] == " "):
94
+ step *= 1.35
95
+ t += step
96
+ t_end = times[-1]
97
+ t_tap = t_end + TAP_AFTER
98
+ return {"keyword": kw, "chars": times, "focus": min(0.45, max(0.25, p["delay"] - 0.35)), "type": p["delay"],
99
+ "type_end": t_end, "tap": t_tap, "sent": t_tap + SENT_AFTER}
100
+
101
+
102
+ def _prompt(p: dict, kw: str) -> str:
103
+ return p["prompt_text"].replace("{palavra}", kw).strip()
104
+
105
+
106
+ def _prompt_lines(text: str, max_width: float, k: float) -> tuple[list[str], float]:
107
+ """The prompt on one line at a good size, or on two balanced lines (the *accent* marks are kept per word)."""
108
+ words = E.parse_accent(text)
109
+ if not words:
110
+ return [], 0.0
111
+ plain = " ".join(w for w, _ in words)
112
+ one = E.fit_text(plain, "sans_extrabold", max_width, 56.0 * k, 20.0)
113
+ marked = [("*" + w.replace("*", "\\*") + "*") if a else w.replace("*", "\\*") for w, a in words]
114
+ if one >= 46.0 * k or len(words) < 2:
115
+ return [" ".join(marked)], max(30.0, one)
116
+ size = 52.0 * k
117
+ lines = E.wrap_text(plain, "sans_extrabold", size, max_width, 2)
118
+ widest = max(E.measure(ln, "sans_extrabold", size).width for ln in lines)
119
+ size = max(30.0, min(size, size * max_width / widest))
120
+ out, i = [], 0
121
+ for ln in lines:
122
+ n = len(ln.split())
123
+ out.append(" ".join(marked[i:i + n]))
124
+ i += n
125
+ return out, size
126
+
127
+
128
+ def validate(params: dict) -> None:
129
+ """The keyword needs letters the font draws (refused when the item is planned)."""
130
+ _keyword(params)
131
+
132
+
133
+ @lru_cache(maxsize=64)
134
+ def _layout(params_json: str, box: E.Box) -> dict:
135
+ p = json.loads(params_json)
136
+ T = _timeline(params_json)
137
+ kw = T["keyword"]
138
+ k = 1.18 if p["size"] == "grande" else 1.0
139
+ theme = box.theme
140
+ W = box.w
141
+ bar_h = 116.0 * k
142
+ av_r = 44.0 * k
143
+ gap = 18.0 * k
144
+ field_x = 2 * av_r + gap
145
+ field_w = W - field_x
146
+ btn_r = 38.0 * k
147
+ pad = 34.0 * k
148
+ btn_cx = W - 14.0 * k - btn_r
149
+ text_w_max = field_w - pad - (2 * btn_r + 30.0 * k)
150
+ send_label = "Enviar"
151
+ if p["button"] == "texto":
152
+ send_size = 38.0 * k
153
+ send_w = E.measure(send_label, "text_bold", send_size).width
154
+ btn_cx = W - 26.0 * k - send_w / 2.0
155
+ text_w_max = field_w - pad - (send_w + 52.0 * k)
156
+ else:
157
+ send_size = 0.0
158
+ send_w = 0.0
159
+ kw_size = E.fit_text(kw, "text_bold", text_w_max - 14.0 * k, 50.0 * k, 26.0)
160
+ ph_size = E.fit_text(p["placeholder"], "text_semibold", text_w_max, 40.0 * k, 24.0) if p["placeholder"] else 0.0
161
+ kw_cap = E.measure("H", "text_bold", kw_size).cap
162
+
163
+ prompt_lines, pr_size = _prompt_lines(_prompt(p, kw), W - 20.0, k)
164
+ pr_cap = E.measure("H", "sans_extrabold", pr_size).cap if prompt_lines else 0.0
165
+ pr_pitch = pr_size * 1.22
166
+ pr_h = pr_cap + pr_pitch * (len(prompt_lines) - 1) if prompt_lines else 0.0
167
+ pr_gap = 34.0 * k
168
+
169
+ # the published comment (content-sized bubble)
170
+ row_h = 126.0 * k
171
+ cav_r = 34.0 * k
172
+ name_size, time_size, ckw_size = 31.0 * k, 27.0 * k, 50.0 * k
173
+ name = E.clean_text(p["user_name"], "text_bold").strip() or "você"
174
+ name_w = E.measure(name, "text_bold", name_size).width
175
+ time_w = E.measure("agora", "text_semibold", time_size).width
176
+ heart = 34.0 * k
177
+ ckw_size = E.fit_text(kw, "text_bold", field_w - 2 * pad - heart - 30.0 * k, ckw_size, 24.0)
178
+ ckw_w = E.measure(kw, "text_bold", ckw_size).width
179
+ bubble_w = min(field_w, max(name_w + 16.0 * k + time_w, ckw_w) + 2 * pad + heart + 34.0 * k)
180
+ bubble_w = max(bubble_w, min(field_w, 360.0 * k))
181
+
182
+ sent = p["show_sent"]
183
+ stack_h = (pr_h + pr_gap if prompt_lines else 0.0) + (row_h + gap if sent else 0.0) + bar_h
184
+ top = (box.h - stack_h) / 2.0
185
+ bar_y = top + stack_h - bar_h
186
+ row_y = bar_y - gap - row_h
187
+ prompt_base_end = (row_y if sent else bar_y) - pr_gap
188
+ prompt_base_start = bar_y - pr_gap
189
+
190
+ acc = theme.accent
191
+ card = theme.card
192
+ on_card_accent = "accent" if E.contrast(acc, card[:3]) >= 2.2 else "text"
193
+ dark = E.mix_color(acc, (0, 0, 0), 0.35)
194
+ light = E.mix_color(acc, (255, 255, 255), 0.25)
195
+ return {
196
+ "k": k, "kw": kw, "bar_y": bar_y, "bar_h": bar_h, "av_r": av_r, "field_x": field_x, "field_w": field_w,
197
+ "btn_r": btn_r, "btn_cx": btn_cx, "pad": pad,
198
+ "kw_size": kw_size, "kw_cap": kw_cap, "ph_size": ph_size, "send_size": send_size, "send_w": send_w,
199
+ "send_label": send_label, "prompt": prompt_lines, "pr_size": pr_size, "pr_pitch": pr_pitch,
200
+ "prompt_start": prompt_base_start, "prompt_end": prompt_base_end,
201
+ "row_y": row_y, "row_h": row_h, "cav_r": cav_r, "bubble_w": bubble_w, "name": name,
202
+ "name_size": name_size, "name_w": name_w, "time_size": time_size, "ckw_size": ckw_size, "heart": heart,
203
+ "accent_text": on_card_accent, "grad": (light, dark),
204
+ }
205
+
206
+
207
+ # ------------------------------------------------------------------ small drawings (mask helpers)
208
+
209
+ def _avatar(cv: E.Canvas, L: dict, cx: float, cy: float, r: float) -> None:
210
+ """Round avatar with a generic person silhouette."""
211
+ light, dark = L["grad"]
212
+ cv.circle(cx, cy, r, fill=E.Gradient(light, dark, (cx - r, cy - r), (cx + r, cy + r)),
213
+ stroke="card_border", stroke_width=2.0)
214
+ white = (255, 255, 255, 235)
215
+ cv.circle(cx, cy - 0.17 * r, 0.33 * r, fill=white)
216
+ body = E.intersect_masks(cv.circle_mask(cx, cy + 0.98 * r, 0.64 * r), cv.circle_mask(cx, cy, r - 1.0))
217
+ cv.fill_mask(body, white)
218
+
219
+
220
+ PLANE = [(2.01, 21.0), (23.0, 12.0), (2.01, 3.0), (2.0, 10.0), (17.0, 12.0), (2.0, 14.0)]
221
+
222
+
223
+ def _plane(cv: E.Canvas, cx: float, cy: float, size: float, color, opacity: float = 1.0) -> None:
224
+ """Generic 'send' glyph (paper plane), tilted up."""
225
+ s = size / 21.0
226
+ a = math.radians(-22.0)
227
+ ca, sa = math.cos(a), math.sin(a)
228
+ pts = []
229
+ for x, y in PLANE:
230
+ u, v = (x - 12.2) * s, (y - 12.0) * s
231
+ pts.append((cx + u * ca - v * sa + size * 0.04, cy + u * sa + v * ca))
232
+ cv.polygon(pts, fill=color, opacity=opacity)
233
+
234
+
235
+ # ------------------------------------------------------------------ render
236
+
237
+ def render(frame_index: int, fps: int, params: dict, box: E.Box) -> E.Canvas:
238
+ key = json.dumps(params, sort_keys=True)
239
+ L = _layout(key, box)
240
+ T = _timeline(key)
241
+ cv = E.Canvas(box)
242
+ t = frame_index / fps
243
+ t_in = t + 1.0 / fps
244
+ k = L["k"]
245
+
246
+ # --- the call-to-action text above (moves up when the comment arrives)
247
+ if L["prompt"]:
248
+ pa = E.eased(t_in, 0.08, 0.30, "out_quad")
249
+ if cv.visible(pa):
250
+ move = E.eased(t, T["sent"], PROMPT_MOVE, "out_cubic") if params["show_sent"] else 0.0
251
+ base = E.lerp(L["prompt_start"], L["prompt_end"], move) + 26.0 * (1.0 - E.eased(t_in, 0.08, 0.45))
252
+ last = len(L["prompt"]) - 1
253
+ for i, line in enumerate(L["prompt"]):
254
+ cv.rich_text(cv.cx, base - (last - i) * L["pr_pitch"], line, "sans_extrabold", L["pr_size"],
255
+ color="white", opacity=pa, anchor="ms", stroke=L["pr_size"] * 0.09,
256
+ stroke_color="black", shadow=E.TEXT_SHADOW)
257
+
258
+ # --- the published comment
259
+ if params["show_sent"]:
260
+ ca = E.eased(t, T["sent"] + 0.12, 0.20, "out_quad")
261
+ if cv.visible(ca):
262
+ rise = 24.0 * k * (1.0 - E.eased(t, T["sent"] + 0.06, SENT_T - 0.06, "out_cubic"))
263
+ y0 = L["row_y"] + rise
264
+ with cv.group(opacity=ca):
265
+ rcx = L["av_r"]
266
+ rcy = y0 + L["row_h"] / 2.0
267
+ _avatar(cv, L, rcx, rcy, L["cav_r"])
268
+ bx = L["field_x"]
269
+ cv.card(bx, y0, L["bubble_w"], L["row_h"], radius=30.0 * k)
270
+ name_base = y0 + L["pad"] * 0.62 + E.measure("H", "text_bold", L["name_size"]).cap
271
+ cv.text(bx + L["pad"], name_base, L["name"], "text_bold", L["name_size"], color="muted")
272
+ cv.text(bx + L["pad"] + L["name_w"] + 14.0 * k, name_base, "agora", "text_semibold",
273
+ L["time_size"], color="muted", opacity=0.7)
274
+ kw_base = y0 + L["row_h"] - L["pad"] * 0.62
275
+ cv.text(bx + L["pad"], kw_base, L["kw"], "text_bold", L["ckw_size"], color="text")
276
+ hx = bx + L["bubble_w"] - L["pad"] - L["heart"] / 2.0
277
+ cv.polyline(E.heart_points(hx, rcy, L["heart"]), 3.2 * k, color="muted")
278
+
279
+ # --- the bar
280
+ ba = E.eased(t_in, 0.0, 0.22, "out_quad")
281
+ rise = 40.0 * k * (1.0 - E.eased(t_in, 0.0, ENTER, "out_cubic"))
282
+ with cv.group(opacity=ba, dy=rise):
283
+ cy = L["bar_y"] + L["bar_h"] / 2.0
284
+ _avatar(cv, L, L["av_r"], cy, L["av_r"])
285
+ cv.card(L["field_x"], L["bar_y"], L["field_w"], L["bar_h"], radius=L["bar_h"] / 2.0)
286
+ tx = L["field_x"] + L["pad"]
287
+
288
+ n = sum(1 for c in T["chars"] if t >= c)
289
+ cleared = params["show_sent"] and t >= T["sent"]
290
+ typed = L["kw"][:n]
291
+ # placeholder: until the first letter, and back after the comment is sent
292
+ ph = 0.0
293
+ if L["ph_size"]:
294
+ if n == 0:
295
+ ph = 1.0
296
+ elif cleared:
297
+ ph = E.eased(t, T["sent"] + 0.06, 0.16, "out_quad")
298
+ if cv.visible(ph):
299
+ cv.text(tx, cy, params["placeholder"], "text_semibold", L["ph_size"], color="muted", opacity=0.62 * ph,
300
+ anchor="lc")
301
+ text_end = tx - 9.0 * k
302
+ if typed:
303
+ ka = 1.0 - E.eased(t, T["sent"], 0.10, "out_quad") if cleared else 1.0
304
+ if cv.visible(ka):
305
+ x0, _, x1, _ = cv.text(tx, cy, typed, "text_bold", L["kw_size"], color="text", opacity=ka,
306
+ anchor="lc")
307
+ text_end = x1
308
+ # cursor: blinks while waiting, stays on while typing, gone after the tap
309
+ if T["focus"] <= t < T["tap"]:
310
+ typing = T["type"] <= t <= T["type_end"] + 0.12
311
+ ref = T["focus"] if t < T["type"] else T["type_end"] + 0.12
312
+ on = typing or ((t - ref) % BLINK) < BLINK * 0.55
313
+ if on:
314
+ ch = L["kw_cap"] * 1.45
315
+ cv.rect(text_end + 3.0 * k, cy - ch / 2.0, 4.0 * k, ch, radius=2.0 * k, fill=L["accent_text"])
316
+
317
+ # send button: gray until the first letter, then the accent; gray again once the field is cleared
318
+ act = E.eased(t, T["chars"][0], 0.14, "out_quad") if n > 0 else 0.0
319
+ if cleared:
320
+ act *= 1.0 - E.eased(t, T["sent"], 0.14, "out_quad")
321
+ pop = E.pulse(t, T["chars"][0], 0.26) * 0.12
322
+ press = E.pulse(t, T["tap"] - 0.05, 0.22) * 0.16
323
+ bs = 1.0 + pop - press
324
+ bcx = L["btn_cx"]
325
+ with cv.group(scale=bs, pivot=(bcx, cy)):
326
+ if params["button"] == "texto":
327
+ col = L["accent_text"]
328
+ cv.text(bcx, cy, L["send_label"], "text_bold", L["send_size"], color="muted", opacity=0.45 * (1 - act),
329
+ anchor="mc")
330
+ if act > 0.001:
331
+ cv.text(bcx, cy, L["send_label"], "text_bold", L["send_size"], color=col, opacity=act,
332
+ anchor="mc")
333
+ else:
334
+ r = L["btn_r"]
335
+ cv.circle(bcx, cy, r, fill="track", opacity=1.0 - act)
336
+ if act > 0.001:
337
+ cv.circle(bcx, cy, r, fill="accent", opacity=act)
338
+ _plane(cv, bcx, cy, r * 1.02, "muted", opacity=0.55 * (1.0 - act))
339
+ if act > 0.001:
340
+ _plane(cv, bcx, cy, r * 1.02, "on_accent", opacity=act)
341
+
342
+ # the finger tap: a soft touch dot, then a ripple
343
+ tap = T["tap"]
344
+ # the touch reads as a light mark on the dark bar and a dark one on the light bar
345
+ touch, touch_k = ("white", 1.0) if box.theme.dark else ("ink", 0.55)
346
+ if tap - 0.2 <= t <= tap + 0.45:
347
+ dot = E.eased(t, tap - 0.2, 0.12, "out_quad") * (1.0 - E.eased(t, tap + 0.02, 0.2, "out_quad"))
348
+ if cv.visible(dot):
349
+ rr = L["btn_r"] * (1.25 - 0.25 * E.eased(t, tap - 0.2, 0.18))
350
+ cv.circle(bcx, cy, rr, fill=touch, opacity=0.38 * touch_k * dot)
351
+ rp = E.progress(t, tap, 0.4)
352
+ if 0 < rp < 1:
353
+ # the ripple stays inside the box (the right rail of the app starts right after it)
354
+ cv.ring(bcx, cy, L["btn_r"] * (1.0 + RIPPLE * E.out_cubic(rp)), max(1.0, 7.0 * k * (1 - rp)),
355
+ color=touch, opacity=0.7 * touch_k * (1 - rp))
356
+ return cv
357
+
358
+
359
+ def sfx(params: dict, fps: int, frames: int) -> list[dict]:
360
+ T = _timeline(json.dumps(params, sort_keys=True))
361
+ cues = [{"at": 0.0, "name": "pop", "volume": 0.8, "why": "barra aparece"},
362
+ {"at": T["type"], "name": "keyboard", "volume": 0.9, "why": "digitando",
363
+ "length": round(T["type_end"] - T["type"] + 0.2, 3)},
364
+ {"at": T["tap"], "name": "mouse_click", "volume": 1.0, "why": "toque em enviar"}]
365
+ if params["show_sent"]:
366
+ cues.append({"at": T["sent"], "name": "pop", "volume": 0.7, "why": "comentário publicado"})
367
+ return [{"frame": E.frame_of(c.pop("at"), fps), **c} for c in cues]
368
+
369
+
370
+ def suggested_duration(params: dict) -> float:
371
+ """Typing, the tap, the comment and about 1.6 s to read."""
372
+ T = _timeline(json.dumps(params, sort_keys=True))
373
+ return round(max(2.5, T["sent"] + SENT_T + HOLD), 2)
374
+
375
+
376
+ def settle(params: dict, fps: int) -> float:
377
+ """After the comment lands (or the tap ripple ends) nothing moves: the cursor is gone after the tap."""
378
+ T = _timeline(json.dumps(params, sort_keys=True))
379
+ end = T["sent"] + SENT_T if params["show_sent"] else T["tap"] + 0.45
380
+ return end + 1.5 / fps