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,321 @@
1
+ """passos: a 2 to 4 step flow. Numbered circles light up one by one, joined by arrows that draw themselves.
2
+
3
+ Frame 0 already shows the card and the steps as faint placeholders (the shape of the flow). Each step then
4
+ pops in the accent color with a pulse ring and its name rises underneath (or beside it, vertically). The
5
+ arrow into a step draws during the moment before that step lights up, so the eye travels along the flow.
6
+ Timing: `delay` + `stagger`, or `times` (one second per step, 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": "passos",
19
+ "label_pt": "Passo a passo",
20
+ "description_pt": "De 2 a 4 passos em círculos numerados, ligados por setas que se desenham de um para o outro. "
21
+ "Cada passo acende na cor de destaque, com o nome embaixo (ou ao lado, na vertical).",
22
+ "use_when_pt": "Quando a fala descreve um processo curto: comprou, acessou, usou. Compra, conecta, usa. "
23
+ "Para cada passo acender junto com a fala, use times (segundos desde o começo do item).",
24
+ "notes_pt": "Nomes curtos (uma ou duas palavras) leem melhor. A região padrão é uma faixa no alto da tela, "
25
+ "acima do rosto; na vertical os passos ficam mais altos: use a região centro ou baixo.",
26
+ "category": "fluxos",
27
+ "version": 1,
28
+ "params": {
29
+ "items": {"type": "list", "item": {"type": "text", "max_len": 20, "required_text": True},
30
+ "min_items": 2, "max_items": 4, "default": ["Comprou", "Acessou", "Usou"],
31
+ "label_pt": "Nome de cada passo, na ordem (2 a 4, cada um com até 20 letras)"},
32
+ "title": {"type": "text", "default": "", "max_len": 36,
33
+ "label_pt": "Título em cima dos passos (opcional). *Palavra* entre asteriscos fica na cor de "
34
+ "destaque"},
35
+ "direction": {"type": "choice", "choices": ["horizontal", "vertical"], "default": "horizontal",
36
+ "label_pt": "Passos lado a lado (horizontal) ou um embaixo do outro (vertical)"},
37
+ "icon": {"type": "choice", "choices": ["numero", "check"], "default": "numero",
38
+ "label_pt": "Dentro do círculo: o número do passo ou um check"},
39
+ "delay": {"type": "number", "default": 0.1, "min": 0, "max": 30,
40
+ "label_pt": "Segundos até o primeiro passo acender"},
41
+ "stagger": {"type": "number", "default": 0.6, "min": 0.15, "max": 5,
42
+ "label_pt": "Segundos entre um passo e o próximo"},
43
+ "times": {"type": "list", "item": {"type": "number", "min": 0, "max": 60}, "nullable": True,
44
+ "default": None, "min_items": 2, "max_items": 4,
45
+ "label_pt": "Segundo em que cada passo acende, contado do começo do item (um por passo, em "
46
+ "ordem crescente). Vazio: usa delay e stagger"},
47
+ "size": {"type": "choice", "choices": ["normal", "grande"], "default": "normal",
48
+ "label_pt": "Tamanho dos círculos e dos nomes"},
49
+ "card": {"type": "bool", "default": True,
50
+ "label_pt": "Cartão atrás dos passos (sem cartão: contorno e sombra)"},
51
+ },
52
+ "default_duration": 3.0,
53
+ "default_region": [90, 240, 900, 330],
54
+ "sfx": [{"at": 0.1, "name": "pop"}, {"at": 0.7, "name": "pop"}, {"at": 1.3, "name": "pop"}],
55
+ "example": {"items": ["Compra", "Conecta", "Usa"], "title": "Simples *assim*:"},
56
+ }
57
+
58
+ LABEL_FONT = "text_bold"
59
+ TITLE_FONT = "sans_black"
60
+ NUM_FONT = "sans_black"
61
+ BASE_R = {"normal": 56.0, "grande": 66.0}
62
+ BASE_LABEL = {"normal": 46.0, "grande": 54.0}
63
+ MIN_LABEL = 30.0
64
+ MAX_COL = 270.0
65
+
66
+ SKELETON = -0.08 # the empty markers start before frame 0, so frame 0 always shows them
67
+ ENTER = 0.5
68
+ POP = 0.42 # circle pop (out_back)
69
+ ARROW = 0.34 # longest arrow draw, ending when the next step lights up
70
+ RING = 0.6 # pulse ring after a pop
71
+ LABEL_AT = 0.06
72
+ LABEL_IN = 0.34
73
+
74
+
75
+ def _times(p: dict) -> list[float]:
76
+ n = len(p["items"])
77
+ times = p.get("times")
78
+ if times is None:
79
+ return [p["delay"] + i * p["stagger"] for i in range(n)]
80
+ if len(times) != n:
81
+ raise MotionError(f"passos.params.times precisa de um tempo para cada passo ({n} passos, veio {len(times)})")
82
+ for i in range(1, n):
83
+ if times[i] < times[i - 1]:
84
+ raise MotionError(f"passos.params.times precisa estar em ordem crescente (o passo {i + 1} acende em "
85
+ f"{times[i]:g} s, antes do passo {i} em {times[i - 1]:g} s)")
86
+ return [float(v) for v in times]
87
+
88
+
89
+ def validate(params: dict) -> None:
90
+ """One time per item, in order: refused when the item is planned, not only when it renders."""
91
+ _times(params)
92
+
93
+
94
+ def warnings(params: dict, box: E.Box) -> list[str]:
95
+ L = _layout(json.dumps(params, sort_keys=True), box)
96
+ if L["vertical"] and L["R"] < BASE_R[params["size"]] * 0.8:
97
+ return [f"os passos na vertical ficaram menores porque a região tem só {box.h:.0f} px de altura; "
98
+ f"use a região tela, uma região mais alta ou menos passos"]
99
+ return []
100
+
101
+
102
+ def _arrow_window(times: list[float], i: int) -> tuple[float, float]:
103
+ """(start, duration) of the arrow that leads into step i (i >= 1)."""
104
+ end = times[i]
105
+ start = max(times[i - 1] + 0.12, end - ARROW)
106
+ return min(start, end), max(0.0, end - start)
107
+
108
+
109
+ def _title_block(title: str, width: float, size: float) -> tuple[float, float, float]:
110
+ """(size, cap, width) of the title line."""
111
+ if not title:
112
+ return 0.0, 0.0, 0.0
113
+ plain = " ".join(w for w, _ in E.parse_accent(title))
114
+ ts = E.fit_text(plain, TITLE_FONT, width, size, 26.0)
115
+ return ts, E.measure("H", TITLE_FONT, ts).cap, E.measure(plain, TITLE_FONT, ts).width
116
+
117
+
118
+ def _fit_labels(items: list[str], size: float, width: float, max_lines: int) -> tuple[float, list[list[str]]]:
119
+ """Largest common label size (<= size) where every label fits `width` in at most max_lines."""
120
+ s = size
121
+ while True:
122
+ wraps = [E.wrap_text(it, LABEL_FONT, s, width, max_lines) for it in items]
123
+ widest = max(E.measure(ln, LABEL_FONT, s).width for lines in wraps for ln in lines)
124
+ if widest <= width + 0.5 or s <= MIN_LABEL:
125
+ break
126
+ s = max(MIN_LABEL, s * 0.95)
127
+ if widest > width + 0.5: # one long word: shrink below the floor rather than overflow
128
+ s = min(E.fit_text(ln, LABEL_FONT, width, s, 20.0) for lines in wraps for ln in lines)
129
+ wraps = [E.wrap_text(it, LABEL_FONT, s, width, max_lines) for it in items]
130
+ return s, wraps
131
+
132
+
133
+ @lru_cache(maxsize=64)
134
+ def _layout(params_json: str, box: E.Box) -> dict:
135
+ p = json.loads(params_json)
136
+ items = p["items"]
137
+ n = len(items)
138
+ card = p["card"]
139
+ title = p["title"].strip()
140
+ pad_x = 36.0 if card else 4.0
141
+ pad_y = 38.0 if card else 4.0
142
+ R = BASE_R[p["size"]]
143
+ lab = BASE_LABEL[p["size"]]
144
+ steps = []
145
+ if p["direction"] == "horizontal":
146
+ col = min(MAX_COL, (box.w - 2 * pad_x) / n)
147
+ R = min(R, col * 0.30)
148
+ ts, tcap, tw = _title_block(title, box.w - 2 * pad_x, lab * 1.12)
149
+ title_h = tcap + lab * 0.62 if title else 0.0
150
+ lab_size, wraps = _fit_labels(items, lab, col - 14.0, 2)
151
+ cap = E.measure("H", LABEL_FONT, lab_size).cap
152
+ line_h = lab_size * 1.14
153
+ lines = max(len(w) for w in wraps)
154
+ gap = R * 0.40
155
+ # a label block leaves room for descenders (p, g, ç)
156
+ lab_h = cap + (lines - 1) * line_h + lab_size * 0.22
157
+ content_h = title_h + 2 * R + gap + lab_h
158
+ needed = content_h + 2 * pad_y
159
+ if needed > box.h: # a short region: shrink circles and labels together
160
+ k = max(0.55, (box.h - 2 * pad_y) / content_h)
161
+ R *= k
162
+ gap *= k
163
+ lab_size, wraps = _fit_labels(items, max(MIN_LABEL * 0.9, lab_size * k), col - 14.0, 2)
164
+ cap = E.measure("H", LABEL_FONT, lab_size).cap
165
+ line_h = lab_size * 1.14
166
+ lab_h = cap + (lines - 1) * line_h + lab_size * 0.22
167
+ ts, tcap, tw = _title_block(title, box.w - 2 * pad_x, min(ts, lab_size * 1.12))
168
+ title_h = tcap + lab_size * 0.62 if title else 0.0
169
+ content_h = title_h + 2 * R + gap + lab_h
170
+ content_w = col * n
171
+ card_w = min(box.w, max(content_w, tw) + 2 * pad_x)
172
+ card_h = content_h + 2 * pad_y
173
+ cx0 = (box.w - card_w) / 2.0
174
+ cy0 = (box.h - card_h) / 2.0
175
+ top = cy0 + pad_y
176
+ left = (box.w - content_w) / 2.0
177
+ cy = top + title_h + R
178
+ for i in range(n):
179
+ x = left + col * (i + 0.5)
180
+ steps.append({"cx": x, "cy": cy, "lines": wraps[i], "anchor": "ms",
181
+ "lx": x, "ly": cy + R + gap + cap})
182
+ g = R * 0.30
183
+ arrows = [((steps[i - 1]["cx"] + R + g, cy), (steps[i]["cx"] - R - g, cy)) for i in range(1, n)]
184
+ title_pos = (box.w / 2.0, top + tcap, "ms")
185
+ else:
186
+ ts, tcap, tw = _title_block(title, box.w - 2 * pad_x, lab * 1.12)
187
+ title_h = tcap + lab * 0.70 if title else 0.0
188
+ ratio = 1.2 # arrow length in circle radii
189
+ content_h = title_h + n * 2 * R + (n - 1) * ratio * R
190
+ if content_h > box.h - 2 * pad_y:
191
+ # a short region: tighter card padding, then shorter arrows, then smaller circles (never below 45%)
192
+ pad_y = min(pad_y, 18.0)
193
+ avail = box.h - 2 * pad_y - title_h
194
+ k = avail / ((n * 2 + (n - 1) * ratio) * R)
195
+ if k < 0.6:
196
+ ratio = 0.6
197
+ k = avail / ((n * 2 + (n - 1) * ratio) * R)
198
+ R *= max(0.45, min(1.0, k))
199
+ arrow_len = ratio * R
200
+ content_h = title_h + n * 2 * R + (n - 1) * arrow_len
201
+ gap = max(R * 0.45, 20.0)
202
+ lab_size = min(lab * 1.12, max(MIN_LABEL, R * 1.05))
203
+ lab_w = box.w - 2 * pad_x - 2 * R - gap
204
+ lab_size, wraps = _fit_labels(items, lab_size, lab_w, 1)
205
+ cap = E.measure("H", LABEL_FONT, lab_size).cap
206
+ widest = max(E.measure(w[0], LABEL_FONT, lab_size).width for w in wraps)
207
+ content_w = 2 * R + gap + widest
208
+ card_w = min(box.w, max(content_w, tw) + 2 * pad_x, box.w)
209
+ card_w = max(card_w, min(box.w, 420.0 if card else 0.0))
210
+ card_h = content_h + 2 * pad_y
211
+ cx0 = (box.w - card_w) / 2.0
212
+ cy0 = (box.h - card_h) / 2.0
213
+ top = cy0 + pad_y
214
+ left = cx0 + pad_x
215
+ if not title or tw <= content_w:
216
+ left = (box.w - content_w) / 2.0
217
+ x = left + R
218
+ for i in range(n):
219
+ cy = top + title_h + R + i * (2 * R + arrow_len)
220
+ steps.append({"cx": x, "cy": cy, "lines": wraps[i], "anchor": "ls",
221
+ "lx": x + R + gap, "ly": cy + cap / 2.0})
222
+ g = R * 0.16
223
+ arrows = [((x, steps[i - 1]["cy"] + R + g), (x, steps[i]["cy"] - R - g)) for i in range(1, n)]
224
+ title_pos = (cx0 + card_w / 2.0, top + tcap, "ms")
225
+ return {
226
+ "steps": steps, "arrows": arrows, "R": R, "lab_size": lab_size, "line_h": lab_size * 1.14,
227
+ "card_box": (cx0, cy0, card_w, card_h), "radius": min(40.0, card_h * 0.24), "card": card,
228
+ "title": title, "title_size": ts, "title_pos": title_pos, "times": _times(p), "icon": p["icon"],
229
+ "vertical": p["direction"] == "vertical",
230
+ }
231
+
232
+
233
+ def render(frame_index: int, fps: int, params: dict, box: E.Box) -> E.Canvas:
234
+ cv = E.Canvas(box)
235
+ t = frame_index / fps
236
+ t_in = t + 1.0 / fps
237
+ L = _layout(json.dumps(params, sort_keys=True), box)
238
+ card = L["card"]
239
+ R = L["R"]
240
+ times = L["times"]
241
+ ink = "text" if card else "white"
242
+ outline = {} if card else {"stroke": L["lab_size"] * 0.07, "stroke_color": "black", "shadow": E.TEXT_SHADOW}
243
+ circle_shadow = E.Shadow(blur=12.0, dy=5.0, opacity=0.35 if card else 0.55)
244
+ arrow_shadow = None if card else E.Shadow(blur=6.0, dy=2.0, opacity=0.5)
245
+ arrow_color = "muted" if card else (255, 255, 255, 235)
246
+
247
+ alpha = E.eased(t_in, 0.0, 0.18, "out_quad")
248
+ scale = 0.92 + 0.08 * E.out_back(E.progress(t_in, 0.0, ENTER), 1.4)
249
+ rise = 26.0 * (1.0 - E.eased(t_in, 0.0, 0.45))
250
+ with cv.group(opacity=alpha, scale=scale, dy=rise):
251
+ if card:
252
+ x, y, w, h = L["card_box"]
253
+ cv.card(x, y, w, h, radius=L["radius"])
254
+ if L["title"]:
255
+ tx, ty, anchor = L["title_pos"]
256
+ cv.rich_text(tx, ty, L["title"], TITLE_FONT, L["title_size"], color=ink, anchor=anchor, **outline)
257
+ # arrows first: circles sit on top of their ends
258
+ for i, (a, b) in enumerate(L["arrows"], start=1):
259
+ start, dur = _arrow_window(times, i)
260
+ prog = E.eased(t, start, dur, "in_out_cubic") if dur > 0 else (1.0 if t >= start else 0.0)
261
+ if prog > 0:
262
+ cv.arrow(a[0], a[1], b[0], b[1], width=max(4.0, R * 0.13), color=arrow_color, progress=prog,
263
+ head=max(14.0, R * 0.34), shadow=arrow_shadow)
264
+ for i, st in enumerate(L["steps"]):
265
+ ti = times[i]
266
+ cx, cy = st["cx"], st["cy"]
267
+ pop_p = E.progress(t_in, ti, POP)
268
+ # placeholder: the faint step that is waiting
269
+ sk = E.out_back(E.progress(t_in, SKELETON + 0.06 * i, 0.36), 1.6)
270
+ if sk > 0.01 and pop_p < 1.0:
271
+ with cv.group(scale=max(0.01, sk), pivot=(cx, cy)):
272
+ if card:
273
+ cv.circle(cx, cy, R, fill="track")
274
+ else:
275
+ cv.circle(cx, cy, R, fill=(0, 0, 0, 120), stroke=(255, 255, 255, 200),
276
+ stroke_width=max(2.0, R * 0.06))
277
+ if L["icon"] == "numero":
278
+ cv.text(cx, cy, str(i + 1), NUM_FONT, R * 0.95, color=ink, opacity=0.45, anchor="mc")
279
+ if pop_p <= 0:
280
+ continue
281
+ # pulse ring
282
+ e = E.eased(t, ti, RING, "out_cubic")
283
+ if 0 < e < 1:
284
+ cv.ring(cx, cy, R * (1.0 + 0.5 * e), max(2.0, R * 0.08 * (1.0 - e) + 1.0), color="accent",
285
+ opacity=0.7 * (1.0 - e))
286
+ with cv.group(scale=max(0.01, E.out_back(pop_p, 2.0)), pivot=(cx, cy)):
287
+ cv.circle(cx, cy, R, fill="accent", shadow=circle_shadow)
288
+ if L["icon"] == "numero":
289
+ cv.text(cx, cy, str(i + 1), NUM_FONT, R * 0.95, color="on_accent", anchor="mc")
290
+ if L["icon"] == "check":
291
+ draw = E.eased(t, ti + 0.08, 0.26, "in_out_cubic")
292
+ if draw > 0:
293
+ cv.check(cx, cy + R * 0.03, R * 0.98, width=R * 0.19, color="on_accent", progress=draw)
294
+ la = E.eased(t, ti + LABEL_AT, LABEL_IN, "out_cubic")
295
+ if cv.visible(la):
296
+ off = 14.0 * (1.0 - la)
297
+ for k, ln in enumerate(st["lines"]):
298
+ if L["vertical"]:
299
+ cv.text(st["lx"] - off, st["ly"], ln, LABEL_FONT, L["lab_size"], color=ink, opacity=la,
300
+ anchor="ls", **outline)
301
+ else:
302
+ cv.text(st["lx"], st["ly"] + k * L["line_h"] + off, ln, LABEL_FONT, L["lab_size"],
303
+ color=ink, opacity=la, anchor="ms", **outline)
304
+ return cv
305
+
306
+
307
+ def sfx(params: dict, fps: int, frames: int) -> list[dict]:
308
+ return [{"frame": E.frame_of(ti, fps), "name": "pop", "why": f"passo {i + 1}"}
309
+ for i, ti in enumerate(_times(params))]
310
+
311
+
312
+ def suggested_duration(params: dict) -> float:
313
+ """Last step, its label and about 1.5 s to read the flow."""
314
+ return round(max(TEMPLATE["default_duration"], _times(params)[-1] + max(POP, LABEL_AT + LABEL_IN) + 1.5), 2)
315
+
316
+
317
+ def settle(params: dict, fps: int) -> float:
318
+ times = _times(params)
319
+ n = len(times)
320
+ return max(ENTER, SKELETON + 0.06 * (n - 1) + 0.36,
321
+ times[-1] + max(POP, RING, LABEL_AT + LABEL_IN, 0.08 + 0.26)) + 0.05
@@ -0,0 +1,298 @@
1
+ """riscado: items appear on a card, then each one gets crossed out (a red strike line or a red X).
2
+
3
+ For "você não precisa de...": the list builds first (`delay` + `stagger`, or `times`), then the items are
4
+ crossed one by one (`cross_delay` + `cross_stagger`, or `cross_times`). A crossed item fades to half.
5
+ Frame 0 already shows the card, the title and the empty markers.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import json
11
+ import math
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": "riscado",
19
+ "label_pt": "Lista riscada",
20
+ "description_pt": "Uma lista num cartão em que os itens aparecem e depois são riscados um a um, com uma linha "
21
+ "vermelha por cima do texto ou um X vermelho no lugar do marcador.",
22
+ "use_when_pt": "Quando a fala derruba objeções ou crenças: você não precisa de estoque, de aparecer, de "
23
+ "investir alto. Para riscar junto com a fala, use cross_times (segundos desde o começo do item).",
24
+ "notes_pt": "Itens curtos leem melhor (até 32 letras). Sem título, o cartão mostra só a lista.",
25
+ "category": "listas",
26
+ "version": 1,
27
+ "params": {
28
+ "items": {"type": "list", "item": {"type": "text", "max_len": 32, "required_text": True},
29
+ "min_items": 1, "max_items": 6,
30
+ "default": ["Ter estoque", "Aparecer nos vídeos", "Investir alto"],
31
+ "label_pt": "Itens que vão ser riscados, de cima para baixo (1 a 6, cada um com até 32 letras)"},
32
+ "title": {"type": "text", "default": "Você *não* precisa de:", "max_len": 36,
33
+ "label_pt": "Título em cima da lista (vazio: sem título). *Palavra* entre asteriscos fica na "
34
+ "cor de destaque"},
35
+ "mode": {"type": "choice", "choices": ["linha", "x"], "default": "linha",
36
+ "label_pt": "Como riscar: linha (traço vermelho por cima do texto) ou x (X vermelho no marcador)"},
37
+ "delay": {"type": "number", "default": 0.2, "min": 0, "max": 30,
38
+ "label_pt": "Segundos até o primeiro item aparecer (o cartão já aparece no começo)"},
39
+ "stagger": {"type": "number", "default": 0.3, "min": 0.1, "max": 5,
40
+ "label_pt": "Segundos entre um item aparecer e o próximo"},
41
+ "times": {"type": "list", "item": {"type": "number", "min": 0, "max": 60}, "nullable": True,
42
+ "default": None, "min_items": 1, "max_items": 6,
43
+ "label_pt": "Segundo em que cada item aparece, contado do começo do item (um por item, em ordem "
44
+ "crescente). Vazio: usa delay e stagger"},
45
+ "cross_delay": {"type": "number", "default": 0.55, "min": 0, "max": 30,
46
+ "label_pt": "Segundos entre o último item aparecer e o primeiro risco"},
47
+ "cross_stagger": {"type": "number", "default": 0.4, "min": 0.1, "max": 5,
48
+ "label_pt": "Segundos entre um risco e o próximo"},
49
+ "cross_times": {"type": "list", "item": {"type": "number", "min": 0, "max": 60}, "nullable": True,
50
+ "default": None, "min_items": 1, "max_items": 6,
51
+ "label_pt": "Segundo em que cada item é riscado, contado do começo do item (um por item, "
52
+ "depois de ele aparecer). Vazio: usa cross_delay e cross_stagger"},
53
+ "size": {"type": "choice", "choices": ["normal", "grande"], "default": "normal",
54
+ "label_pt": "Tamanho do texto"},
55
+ "card": {"type": "bool", "default": True,
56
+ "label_pt": "Cartão atrás da lista (sem cartão: texto com contorno e sombra)"},
57
+ },
58
+ "default_duration": 3.5,
59
+ "default_region": "baixo",
60
+ "sfx": [{"at": 0.0, "name": "pop"}, {"at": 1.47, "name": "tick", "volume": 1.2},
61
+ {"at": 1.87, "name": "tick", "volume": 1.2}, {"at": 2.27, "name": "tick", "volume": 1.2}],
62
+ "example": {"title": "Você *não* precisa de:",
63
+ "items": ["Ter estoque", "Aparecer nos vídeos", "Investir alto", "Experiência"]},
64
+ }
65
+
66
+ FONT = "text_bold"
67
+ TITLE_FONT = "sans_black"
68
+ BASE_SIZE = {"normal": 56.0, "grande": 68.0}
69
+ MIN_SIZE = 34.0
70
+
71
+ SKELETON = -0.08 # the empty markers start before frame 0, so frame 0 always shows them
72
+ ENTER = 0.5 # card entrance
73
+ ROW_IN = 0.32 # item slide and fade
74
+ STRIKE = 0.30 # the strike line draws (per item, all its lines)
75
+ X_POP = 0.38 # the red marker pops
76
+ X_DRAW = 0.24 # the X strokes draw
77
+ DIM_AT = 0.12 # the text starts fading this long after the cross starts
78
+ DIM = 0.30
79
+ DIM_TO = 0.5 # opacity of a crossed text
80
+ SOUND_AT = {"linha": 0.12, "x": 0.04} # the sound lands when the stroke is visibly under way
81
+
82
+
83
+ def _times(p: dict) -> tuple[list[float], list[float]]:
84
+ """(appear times, cross times), validated."""
85
+ n = len(p["items"])
86
+ times = p.get("times")
87
+ if times is None:
88
+ shown = [p["delay"] + i * p["stagger"] for i in range(n)]
89
+ else:
90
+ if len(times) != n:
91
+ raise MotionError(f"riscado.params.times precisa de um tempo para cada item ({n} itens, veio {len(times)})")
92
+ for i in range(1, n):
93
+ if times[i] < times[i - 1]:
94
+ raise MotionError(f"riscado.params.times precisa estar em ordem crescente (o item {i + 1} aparece "
95
+ f"em {times[i]:g} s, antes do item {i} em {times[i - 1]:g} s)")
96
+ shown = [float(v) for v in times]
97
+ crosses = p.get("cross_times")
98
+ if crosses is None:
99
+ first = shown[-1] + p["cross_delay"]
100
+ crossed = [first + i * p["cross_stagger"] for i in range(n)]
101
+ else:
102
+ if len(crosses) != n:
103
+ raise MotionError(f"riscado.params.cross_times precisa de um tempo para cada item ({n} itens, "
104
+ f"veio {len(crosses)})")
105
+ for i, (a, c) in enumerate(zip(shown, crosses)):
106
+ if c < a:
107
+ raise MotionError(f"riscado.params.cross_times: o item {i + 1} aparece em {a:g} s e seria riscado "
108
+ f"antes, em {c:g} s. Cada risco precisa vir depois de o item aparecer")
109
+ crossed = [float(v) for v in crosses]
110
+ return shown, crossed
111
+
112
+
113
+ def validate(params: dict) -> None:
114
+ """One time per item, in order: refused when the item is planned, not only when it renders."""
115
+ _times(params)
116
+
117
+
118
+ @lru_cache(maxsize=64)
119
+ def _layout(params_json: str, box: E.Box) -> dict:
120
+ p = json.loads(params_json)
121
+ items = p["items"]
122
+ card = p["card"]
123
+ mode = p["mode"]
124
+ title = p["title"].strip()
125
+ title_plain = " ".join(w for w, _ in E.parse_accent(title))
126
+ pad_x = 54.0 if card else 6.0
127
+ pad_y = 48.0 if card else 6.0
128
+
129
+ def build(size: float) -> tuple[dict, bool]:
130
+ marker = size * (1.04 if mode == "x" else 0.30)
131
+ gap = size * (0.44 if mode == "x" else 0.50)
132
+ text_w = box.w - 2 * pad_x - marker - gap - size * 0.12 # the strike runs a little past the text
133
+ m = E.measure("H", FONT, size)
134
+ line_h = size * 1.17
135
+ row_gap = size * 0.46
136
+ slot = size * 1.04 # row height of a one-line item (same rhythm in both modes)
137
+ rows, fits, y = [], True, 0.0
138
+ title_size = title_w = 0.0
139
+ if title:
140
+ title_size = min(size * 0.94, E.fit_text(title_plain, TITLE_FONT, box.w - 2 * pad_x, size * 0.94, 26.0))
141
+ title_w = E.measure(title_plain, TITLE_FONT, title_size).width
142
+ y = E.measure("H", TITLE_FONT, title_size).cap + size * 0.74
143
+ widest = 0.0
144
+ for k, text in enumerate(items):
145
+ lines = E.wrap_text(text, FONT, size, text_w, 2)
146
+ sizes = []
147
+ for ln in lines:
148
+ if E.measure(ln, FONT, size).width > text_w + 0.5:
149
+ fits = False
150
+ sizes.append(E.fit_text(ln, FONT, text_w, size, 20.0))
151
+ else:
152
+ sizes.append(size)
153
+ widths = [E.measure(ln, FONT, s).width for ln, s in zip(lines, sizes)]
154
+ widest = max(widest, max(widths))
155
+ base0 = y + slot / 2.0 + m.cap / 2.0
156
+ bottom = max(y + slot, base0 + (len(lines) - 1) * line_h)
157
+ rows.append({"lines": lines, "sizes": sizes, "widths": widths, "cy": y + slot / 2.0, "base0": base0})
158
+ y = bottom + (row_gap if k < len(items) - 1 else 0.0)
159
+ card_h = y + 2 * pad_y
160
+ content_w = max(marker + gap + widest + size * 0.12, title_w)
161
+ card_w = min(box.w, max(content_w + 2 * pad_x, 380.0 if card else 0.0))
162
+ L = {"size": size, "marker": marker, "gap": gap, "line_h": line_h, "rows": rows, "x_height": m.x_height,
163
+ "title_size": title_size, "title_base": E.measure("H", TITLE_FONT, title_size).cap if title else 0.0,
164
+ "card_w": card_w, "card_h": card_h}
165
+ return L, fits and card_h <= box.h
166
+
167
+ size = BASE_SIZE[p["size"]]
168
+ L, ok = build(size)
169
+ while not ok and size > MIN_SIZE:
170
+ size = max(MIN_SIZE, size * 0.95)
171
+ L, ok = build(size)
172
+ card_x = (box.w - L["card_w"]) / 2.0
173
+ card_y = (box.h - L["card_h"]) / 2.0
174
+ L.update({"card_box": (card_x, card_y, L["card_w"], L["card_h"]), "x0": card_x + pad_x, "y0": card_y + pad_y,
175
+ "radius": min(40.0, L["card_h"] * 0.2), "title": title, "card": card, "mode": mode})
176
+ L["shown"], L["crossed"] = _times(p)
177
+ # strike geometry per item: one stroke per line, a slight upward slant and bow like a marker
178
+ for k, row in enumerate(L["rows"]):
179
+ strokes = []
180
+ for j, (w, sz) in enumerate(zip(row["widths"], row["sizes"])):
181
+ xh = E.measure("x", FONT, sz).x_height
182
+ y = row["base0"] + j * L["line_h"] - xh * 0.52
183
+ x_a = -sz * 0.04
184
+ x_b = w + sz * 0.10
185
+ slant = min(sz * 0.07, (x_b - x_a) * 0.04) * (1 if (k + j) % 2 == 0 else 0.6)
186
+ pts = E.bezier_points((x_a, y + slant), ((x_a + x_b) / 2.0, y + sz * 0.025), (x_b, y - slant), n=16)
187
+ strokes.append({"points": pts, "width": sz * 0.16, "length": E.path_length(pts)})
188
+ row["strokes"] = strokes
189
+ return L
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, mode = L["card"], L["mode"]
198
+ size, d = L["size"], L["marker"]
199
+
200
+ alpha = E.eased(t_in, 0.0, 0.18, "out_quad")
201
+ scale = 0.9 + 0.1 * E.out_back(E.progress(t_in, 0.0, ENTER), 1.4)
202
+ rise = 32.0 * (1.0 - E.eased(t_in, 0.0, 0.45))
203
+ outline = {} if card else {"stroke": size * 0.07, "stroke_color": "black", "shadow": E.TEXT_SHADOW}
204
+ ink = "text" if card else "white" # no card: white letters with a black outline in both themes
205
+ empty = "track" if card else (255, 255, 255, 215)
206
+ strike_shadow = E.Shadow(blur=6.0, dy=2.0, opacity=0.35 if card else 0.55)
207
+
208
+ with cv.group(opacity=alpha, scale=scale, dy=rise):
209
+ cx0, cy0, cw, ch = L["card_box"]
210
+ if card:
211
+ cv.card(cx0, cy0, cw, ch, radius=L["radius"])
212
+ x0, y0 = L["x0"], L["y0"]
213
+ if L["title"]:
214
+ cv.rich_text(x0, y0 + L["title_base"], L["title"], TITLE_FONT, L["title_size"], color=ink, **outline)
215
+ mx = x0 + d / 2.0
216
+ tx = x0 + d + L["gap"]
217
+ for i, row in enumerate(L["rows"]):
218
+ ts, tc = L["shown"][i], L["crossed"][i]
219
+ cy = y0 + row["cy"]
220
+ sk = E.out_back(E.progress(t_in, SKELETON + 0.05 * i, 0.34), 1.8)
221
+ if mode == "x":
222
+ pop_p = E.progress(t, tc, X_POP)
223
+ if sk > 0.01 and pop_p < 1.0:
224
+ with cv.group(scale=max(0.01, sk), pivot=(mx, cy)):
225
+ w = d * 0.085
226
+ if not card:
227
+ cv.ring(mx, cy, d / 2 - w / 2, w * 2.8, color=(0, 0, 0, 120))
228
+ cv.ring(mx, cy, d / 2 - w / 2, w, color=empty)
229
+ if pop_p > 0:
230
+ with cv.group(scale=max(0.01, E.out_back(pop_p, 2.2)), pivot=(mx, cy)):
231
+ cv.circle(mx, cy, d / 2, fill="danger",
232
+ shadow=None if card else E.Shadow(blur=10.0, dy=4.0, opacity=0.5))
233
+ draw = E.eased(t, tc + 0.05, X_DRAW, "in_out_cubic")
234
+ if draw > 0:
235
+ cv.cross(mx, cy, d * 0.36, width=d * 0.12, color="white", progress=draw)
236
+ else:
237
+ if sk > 0.01:
238
+ dot = d / 2.0 * max(0.01, sk)
239
+ turn = E.eased(t, tc, STRIKE * 0.6)
240
+ if not card:
241
+ cv.circle(mx, cy, dot + 2.5, fill=(0, 0, 0, 120))
242
+ if turn < 1:
243
+ cv.circle(mx, cy, dot, fill=empty if card else (255, 255, 255, 230))
244
+ if turn > 0:
245
+ cv.circle(mx, cy, dot, fill="danger", opacity=turn)
246
+ # the text: slides in, then fades to half once crossed
247
+ a = E.eased(t, ts, ROW_IN, "out_cubic")
248
+ if cv.visible(a):
249
+ dim = 1.0 - (1.0 - DIM_TO) * E.eased(t, tc + DIM_AT, DIM, "out_quad")
250
+ dx = -min(L["gap"] * 0.8, 18.0) * (1.0 - a)
251
+ dy = 10.0 * (1.0 - a)
252
+ for k, (ln, sz) in enumerate(zip(row["lines"], row["sizes"])):
253
+ cv.text(tx + dx, y0 + row["base0"] + k * L["line_h"] + dy, ln, FONT, sz, color=ink,
254
+ opacity=a * dim, **outline)
255
+ if mode == "linha" and t >= tc:
256
+ p = E.eased(t, tc, STRIKE, "in_out_cubic")
257
+ total = sum(s["length"] for s in row["strokes"]) or 1.0
258
+ done = 0.0
259
+ for s in row["strokes"]:
260
+ part = E.clamp01((p * total - done) / max(1e-6, s["length"]))
261
+ done += s["length"]
262
+ if part <= 0:
263
+ break
264
+ pts = [(tx + u, y0 + v) for u, v in s["points"]]
265
+ n = len(pts)
266
+ widths = [s["width"] * (0.78 + 0.22 * math.sin(math.pi * min(1.0, k / (n - 1) * 1.15)))
267
+ for k in range(n)]
268
+ cv.polyline(pts, s["width"], color="danger", progress=part, widths=widths, shadow=strike_shadow)
269
+ return cv
270
+
271
+
272
+ def sfx(params: dict, fps: int, frames: int) -> list[dict]:
273
+ _, crossed = _times(params)
274
+ mode = params["mode"]
275
+ cues = [{"at": 0.0, "name": "pop", "why": "entrada do cartão"}]
276
+ for i, tc in enumerate(crossed):
277
+ if mode == "x":
278
+ cues.append({"at": tc + SOUND_AT["x"], "name": "error", "volume": 0.7, "why": f"X no item {i + 1}"})
279
+ else:
280
+ cues.append({"at": tc + SOUND_AT["linha"], "name": "tick", "volume": 1.2, "why": f"risco no item {i + 1}"})
281
+ return [{"frame": E.frame_of(c.pop("at"), fps), **c} for c in cues]
282
+
283
+
284
+ def _end(params: dict) -> float:
285
+ shown, crossed = _times(params)
286
+ n = len(params["items"])
287
+ last_cross = max(crossed)
288
+ return max(ENTER, SKELETON + 0.05 * (n - 1) + 0.34, shown[-1] + ROW_IN,
289
+ last_cross + max(STRIKE, X_POP, 0.05 + X_DRAW, DIM_AT + DIM))
290
+
291
+
292
+ def suggested_duration(params: dict) -> float:
293
+ """Every item crossed and about 1.2 s to take it in."""
294
+ return round(max(TEMPLATE["default_duration"], _end(params) + 1.2), 2)
295
+
296
+
297
+ def settle(params: dict, fps: int) -> float:
298
+ return _end(params) + 0.05