primocode 10.16.0 → 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 (234) 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 +11 -2
  12. package/editor/LICENSE.md +11 -0
  13. package/editor/README.md +321 -0
  14. package/editor/SKILL.md +307 -0
  15. package/editor/THIRD_PARTY_NOTICES.md +228 -0
  16. package/editor/assets/baixar.json +591 -0
  17. package/editor/assets/fonts/anton/OFL.txt +93 -0
  18. package/editor/assets/fonts/caveat/OFL.txt +93 -0
  19. package/editor/assets/fonts/figtree/OFL.txt +93 -0
  20. package/editor/assets/fonts/fonts.json +10 -0
  21. package/editor/assets/fonts/montserrat/OFL.txt +93 -0
  22. package/editor/assets/fonts/permanentmarker/LICENSE.txt +202 -0
  23. package/editor/assets/models/SELFIE_MULTICLASS_LICENSE.txt +227 -0
  24. package/editor/assets/models/YUNET_LICENSE.txt +21 -0
  25. package/editor/assets/sfx/licencas/bass_drop.txt +32 -0
  26. package/editor/assets/sfx/licencas/cash.txt +76 -0
  27. package/editor/assets/sfx/licencas/charge.txt +31 -0
  28. package/editor/assets/sfx/licencas/click.txt +46 -0
  29. package/editor/assets/sfx/licencas/coin.txt +50 -0
  30. package/editor/assets/sfx/licencas/count.txt +47 -0
  31. package/editor/assets/sfx/licencas/data.txt +31 -0
  32. package/editor/assets/sfx/licencas/error.txt +32 -0
  33. package/editor/assets/sfx/licencas/flash.txt +31 -0
  34. package/editor/assets/sfx/licencas/gears.txt +82 -0
  35. package/editor/assets/sfx/licencas/keyboard.txt +48 -0
  36. package/editor/assets/sfx/licencas/metal_hit.txt +62 -0
  37. package/editor/assets/sfx/licencas/metal_riser.txt +61 -0
  38. package/editor/assets/sfx/licencas/mouse_click.txt +50 -0
  39. package/editor/assets/sfx/licencas/notification.txt +54 -0
  40. package/editor/assets/sfx/licencas/pop.txt +38 -0
  41. package/editor/assets/sfx/licencas/rewind.txt +32 -0
  42. package/editor/assets/sfx/licencas/rise.txt +46 -0
  43. package/editor/assets/sfx/licencas/sinteticos.txt +21 -0
  44. package/editor/assets/sfx/licencas/snap.txt +50 -0
  45. package/editor/assets/sfx/licencas/success.txt +40 -0
  46. package/editor/assets/sfx/licencas/synth_hit.txt +32 -0
  47. package/editor/assets/sfx/licencas/tick.txt +14 -0
  48. package/editor/assets/sfx/licencas/whoosh.txt +47 -0
  49. package/editor/assets/sfx/licencas/wind.txt +31 -0
  50. package/editor/assets/sfx/sfx.json +832 -0
  51. package/editor/assets/sfx/sfx_sinteticos.json +317 -0
  52. package/editor/assets/sfx/sfx_woven.json +387 -0
  53. package/editor/assets/sfx/woven/LICENCA.md +16 -0
  54. package/editor/fichas/briefing-do-video.md +45 -0
  55. package/editor/fichas/conferencia-da-amostra.md +56 -0
  56. package/editor/fichas/matriz-de-ganchos.csv +3 -0
  57. package/editor/fichas/padrao.json +16 -0
  58. package/editor/fichas/planilha-de-cenas.csv +4 -0
  59. package/editor/fichas/planilha-de-versoes.csv +4 -0
  60. package/editor/fichas/plano-em-blocos.md +32 -0
  61. package/editor/fichas/registro-de-materiais.json +27 -0
  62. package/editor/helpers/assets_ocr/ocr_vision.swift +41 -0
  63. package/editor/helpers/bordas_precisas.py +272 -0
  64. package/editor/helpers/camera_viva.py +426 -0
  65. package/editor/helpers/captions.py +601 -0
  66. package/editor/helpers/common.py +308 -0
  67. package/editor/helpers/compose_final.py +3555 -0
  68. package/editor/helpers/conferir_corte.py +234 -0
  69. package/editor/helpers/conferir_dados.py +405 -0
  70. package/editor/helpers/contact_sheet.py +282 -0
  71. package/editor/helpers/corte_por_onda.py +433 -0
  72. package/editor/helpers/corte_seco.py +595 -0
  73. package/editor/helpers/denoise.py +971 -0
  74. package/editor/helpers/efeitos.py +408 -0
  75. package/editor/helpers/efeitos_gerar.py +159 -0
  76. package/editor/helpers/elevenlabs_api.py +442 -0
  77. package/editor/helpers/face_layout.py +570 -0
  78. package/editor/helpers/fetch_images.py +423 -0
  79. package/editor/helpers/ganho_por_trecho.py +214 -0
  80. package/editor/helpers/gen_image.py +312 -0
  81. package/editor/helpers/gen_music.py +316 -0
  82. package/editor/helpers/gen_sfx.py +148 -0
  83. package/editor/helpers/grade.py +307 -0
  84. package/editor/helpers/icloud.py +119 -0
  85. package/editor/helpers/make_sfx.py +940 -0
  86. package/editor/helpers/matte.py +1342 -0
  87. package/editor/helpers/medir_referencia.py +2232 -0
  88. package/editor/helpers/mixar_trilha.py +354 -0
  89. package/editor/helpers/montar_corte.py +295 -0
  90. package/editor/helpers/motion/__init__.py +13 -0
  91. package/editor/helpers/motion/engine.py +1915 -0
  92. package/editor/helpers/motion/registry.py +510 -0
  93. package/editor/helpers/motion/render.py +665 -0
  94. package/editor/helpers/motion/templates/barra_progresso.py +370 -0
  95. package/editor/helpers/motion/templates/carimbo.py +132 -0
  96. package/editor/helpers/motion/templates/checklist.py +271 -0
  97. package/editor/helpers/motion/templates/comentario_cta.py +380 -0
  98. package/editor/helpers/motion/templates/contador.py +222 -0
  99. package/editor/helpers/motion/templates/destaque.py +263 -0
  100. package/editor/helpers/motion/templates/etiqueta_preco.py +461 -0
  101. package/editor/helpers/motion/templates/grafico_barras.py +510 -0
  102. package/editor/helpers/motion/templates/grafico_linha.py +462 -0
  103. package/editor/helpers/motion/templates/notificacao.py +266 -0
  104. package/editor/helpers/motion/templates/palavra_impacto.py +410 -0
  105. package/editor/helpers/motion/templates/passos.py +321 -0
  106. package/editor/helpers/motion/templates/riscado.py +298 -0
  107. package/editor/helpers/motion/templates/selo_garantia.py +150 -0
  108. package/editor/helpers/motion_render.py +241 -0
  109. package/editor/helpers/motion_sugerir.py +961 -0
  110. package/editor/helpers/noise_check.py +134 -0
  111. package/editor/helpers/pack_transcripts.py +223 -0
  112. package/editor/helpers/palavras_na_onda.py +176 -0
  113. package/editor/helpers/plano_sfx.py +839 -0
  114. package/editor/helpers/portao.py +427 -0
  115. package/editor/helpers/preview_server.py +1177 -0
  116. package/editor/helpers/rastrear_rosto.py +298 -0
  117. package/editor/helpers/render_cut.py +1256 -0
  118. package/editor/helpers/respiros.py +89 -0
  119. package/editor/helpers/sincronizar_cameras.py +571 -0
  120. package/editor/helpers/speech_regions.py +423 -0
  121. package/editor/helpers/style_catalog.json +231 -0
  122. package/editor/helpers/style_samples.py +246 -0
  123. package/editor/helpers/tela_verde.py +2113 -0
  124. package/editor/helpers/text_render.py +2059 -0
  125. package/editor/helpers/texto_na_tela.py +366 -0
  126. package/editor/helpers/timeline_view.py +292 -0
  127. package/editor/helpers/tira_de_quadros.py +113 -0
  128. package/editor/helpers/transcribe.py +734 -0
  129. package/editor/helpers/transcribe_batch.py +129 -0
  130. package/editor/helpers/trilhas.py +1146 -0
  131. package/editor/helpers/trilhas_humores.json +66 -0
  132. package/editor/helpers/valores.py +187 -0
  133. package/editor/helpers/verify_cut.py +820 -0
  134. package/editor/helpers/voice_levels.py +233 -0
  135. package/editor/helpers/voz_check.py +386 -0
  136. package/editor/helpers/voz_limpa.py +81 -0
  137. package/editor/helpers/watch_preview.py +372 -0
  138. package/editor/modelo-projeto/LEIA.md +402 -0
  139. package/editor/modelo-projeto/assets/fontes/OFL-Caveat.txt +93 -0
  140. package/editor/modelo-projeto/assets/fontes/OFL-JetBrainsMono.txt +93 -0
  141. package/editor/modelo-projeto/assets/fontes/OFL-Montserrat.txt +93 -0
  142. package/editor/modelo-projeto/assets/fontes/larguras.json +1 -0
  143. package/editor/modelo-projeto/assets/three/BufferGeometryUtils.js +1438 -0
  144. package/editor/modelo-projeto/assets/three/GLTFLoader.js +4895 -0
  145. package/editor/modelo-projeto/assets/three/LICENSE +21 -0
  146. package/editor/modelo-projeto/assets/three/ORIGEM.txt +17 -0
  147. package/editor/modelo-projeto/assets/three/RoomEnvironment.js +185 -0
  148. package/editor/modelo-projeto/assets/three/three.core.min.js +6 -0
  149. package/editor/modelo-projeto/assets/three/three.module.min.js +6 -0
  150. package/editor/modelo-projeto/build.py +4828 -0
  151. package/editor/modelo-projeto/compositions/cenas/2d/k2d_antes_depois.html +199 -0
  152. package/editor/modelo-projeto/compositions/cenas/2d/k2d_arquivo.html +232 -0
  153. package/editor/modelo-projeto/compositions/cenas/2d/k2d_busca.html +315 -0
  154. package/editor/modelo-projeto/compositions/cenas/2d/k2d_comando.html +304 -0
  155. package/editor/modelo-projeto/compositions/cenas/2d/k2d_conversa.html +217 -0
  156. package/editor/modelo-projeto/compositions/cenas/2d/k2d_esquema.html +289 -0
  157. package/editor/modelo-projeto/compositions/cenas/2d/k2d_medidor.html +249 -0
  158. package/editor/modelo-projeto/compositions/cenas/2d/k2d_notas.html +233 -0
  159. package/editor/modelo-projeto/compositions/cenas/3d/k3d_caminho.html +197 -0
  160. package/editor/modelo-projeto/compositions/cenas/3d/k3d_explodida.html +152 -0
  161. package/editor/modelo-projeto/compositions/cenas/3d/k3d_tela.html +167 -0
  162. package/editor/modelo-projeto/compositions/cenas/3d/k3d_volume.html +124 -0
  163. package/editor/modelo-projeto/compositions/cenas/3d/motor-three.js +728 -0
  164. package/editor/modelo-projeto/compositions/cenas/LEIA.md +177 -0
  165. package/editor/modelo-projeto/ferramentas/medir_fontes.py +65 -0
  166. package/editor/modelo-projeto/ferramentas/pessoa_recortada.py +176 -0
  167. package/editor/modelo-projeto/ferramentas/rastrear_maos.py +189 -0
  168. package/editor/modelo-projeto/hyperframes.json +12 -0
  169. package/editor/modelo-projeto/package.json +11 -0
  170. package/editor/modelo-projeto/projeto.json +16 -0
  171. package/editor/preview/app.css +1009 -0
  172. package/editor/preview/app.js +2122 -0
  173. package/editor/preview/index.html +307 -0
  174. package/editor/pyproject.toml +23 -0
  175. package/editor/references/3d.md +236 -0
  176. package/editor/references/animacao-catalogo.md +672 -0
  177. package/editor/references/animacao.md +498 -0
  178. package/editor/references/comandos.md +92 -0
  179. package/editor/references/corte-seco-como-funciona.md +50 -0
  180. package/editor/references/corte-seco.md +88 -0
  181. package/editor/references/corte.md +290 -0
  182. package/editor/references/direcao-por-blocos.md +218 -0
  183. package/editor/references/enquadramento.md +654 -0
  184. package/editor/references/estilos-prontos.md +355 -0
  185. package/editor/references/estudio.md +100 -0
  186. package/editor/references/exemplo-eventos.json +67 -0
  187. package/editor/references/lettering.md +252 -0
  188. package/editor/references/modelos.md +494 -0
  189. package/editor/references/phase2.md +1007 -0
  190. package/editor/references/referencia-aplicar.md +203 -0
  191. package/editor/references/referencia-medir.md +375 -0
  192. package/editor/references/revisao.md +335 -0
  193. package/editor/references/som-e-trilha.md +146 -0
  194. package/editor/references/tabela-de-sons.md +130 -0
  195. package/editor/references/variacoes.md +234 -0
  196. package/editor/uv.lock +1141 -0
  197. package/lib/act.js +36 -8
  198. package/lib/catalogo.js +4 -0
  199. package/lib/chamada-torta.js +81 -0
  200. package/lib/editor-pro.js +786 -0
  201. package/lib/entrega.js +58 -0
  202. package/lib/estudio/conferencia-visual.js +76 -0
  203. package/lib/estudio/diretor.js +6 -1
  204. package/lib/estudio/exportar.js +3 -1
  205. package/lib/estudio/gosto.js +45 -4
  206. package/lib/estudio/index.js +8 -5
  207. package/lib/estudio/kit-ae/base.jsx +119 -13
  208. package/lib/estudio/kit-ae/cenas.jsx +47 -15
  209. package/lib/estudio/kit-ae/guarda.jsx +274 -0
  210. package/lib/estudio/kit-ae/primitivas.jsx +180 -24
  211. package/lib/estudio/maquina-diretor.js +61 -7
  212. package/lib/estudio/maquina-foto.js +177 -0
  213. package/lib/estudio/maquina-guarda.js +207 -0
  214. package/lib/estudio/maquina-layout.js +22 -1
  215. package/lib/estudio/maquina.js +60 -11
  216. package/lib/estudio/referencias.js +1 -1
  217. package/lib/estudio/visao/visao.swift +97 -0
  218. package/lib/estudio/visao.js +60 -0
  219. package/lib/local/cerebro.json +192 -6
  220. package/lib/local/motor.js +5 -2
  221. package/lib/nuvem.js +11 -1
  222. package/lib/passagem.js +50 -0
  223. package/lib/perigo.js +19 -11
  224. package/lib/protecao.js +134 -0
  225. package/lib/titulo-conversa.js +46 -0
  226. package/lib/tools.js +3 -0
  227. package/lib/ui.js +1 -1
  228. package/package.json +3 -2
  229. package/skills/editar-meu-video/SKILL.md +6 -2
  230. package/skills/editar-video/SKILL.md +113 -0
  231. package/studio/pessoa.py +52 -0
  232. package/voz/primo_voz/app.py +43 -4
  233. package/voz/primo_voz/chat.html +17 -6
  234. package/voz/primo_voz/painel_dados.py +134 -0
@@ -0,0 +1,665 @@
1
+ """Renderer: one motion item (template + params + place + length) -> frames, an alpha clip for the
2
+ compose, previews and a contact sheet (SPEC Contract 12).
3
+
4
+ Transport into compose_final (chosen by measurement, see SPEC): one QuickTime Animation (qtrle, ARGB,
5
+ lossless) .mov per item, sized to the item's rectangle, placed with overlay at even x/y and started
6
+ with `setpts=N+<start frame>` like the insert clips. Frame-exact, cacheable by content, tiny for held
7
+ frames (qtrle only stores the lines that changed).
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import hashlib
13
+ import json
14
+ import math
15
+ import os
16
+ import subprocess
17
+ import sys
18
+ from dataclasses import dataclass, field
19
+ from pathlib import Path
20
+ from typing import Any, Iterator, Sequence
21
+
22
+ import numpy as np
23
+ from PIL import Image, ImageDraw, ImageFilter, ImageOps
24
+
25
+ from . import engine as E
26
+ from . import registry as Rg
27
+ from .engine import MotionError
28
+
29
+ RENDER_VERSION = 2
30
+ EXIT_FRAMES = 6 # generic exit (fade + 3% shrink) of templates with exit "fade"
31
+ EXIT_SHRINK = 0.03
32
+ INK_ALPHA = 24 # a pixel counts as drawn ("ink") above this alpha: faint glows and shadows do not
33
+ _INK_LUT = [0] * (INK_ALPHA + 1) + [255] * (255 - INK_ALPHA)
34
+ _OWN_HASH = hashlib.sha1(b"".join(p.read_bytes() for p in (Path(E.__file__), Path(__file__)))).hexdigest()[:12]
35
+
36
+
37
+ @dataclass
38
+ class MotionItem:
39
+ template: Rg.Template
40
+ params: dict # resolved (defaults filled)
41
+ box: E.Box
42
+ frames: int
43
+ region: list[float] # [x, y, w, h] design px actually used
44
+ region_spec: Any
45
+ moved_by: float = 0.0 # design px the region moved to clear a face / text
46
+ warnings: list[str] = field(default_factory=list)
47
+ ink: tuple[float, float, float, float] | None = None # box-local design px (measured when placing)
48
+
49
+ @property
50
+ def fps(self) -> int:
51
+ return self.box.fps
52
+
53
+ @property
54
+ def canvas(self) -> tuple[int, int]:
55
+ return self.box.canvas
56
+
57
+ @property
58
+ def key(self) -> str:
59
+ b = self.box
60
+ blob = json.dumps({
61
+ "engine": [E.ENGINE_VERSION, RENDER_VERSION, _OWN_HASH],
62
+ "template": [self.template.id, self.template.version, self.template.source_hash],
63
+ "params": self.params, "box": [round(b.x, 4), round(b.y, 4), round(b.w, 4), round(b.h, 4), b.bleed],
64
+ "canvas": list(b.canvas), "fps": b.fps, "frames": self.frames,
65
+ "theme": [b.theme.name, list(b.theme.accent)], "exit": self.template.exit,
66
+ }, sort_keys=True, ensure_ascii=False)
67
+ return hashlib.sha1(blob.encode("utf-8")).hexdigest()[:16]
68
+
69
+
70
+ @dataclass
71
+ class Frame:
72
+ image: Image.Image # RGBA
73
+ x: int # render px of the image's top-left on the output canvas
74
+ y: int
75
+
76
+
77
+ # ------------------------------------------------------------------ planning
78
+
79
+ def _accent_for(params: dict, accent: str) -> str:
80
+ cor = params.get("cor")
81
+ if cor in (None, ""):
82
+ return accent
83
+ if isinstance(cor, str) and not cor.startswith("#"):
84
+ c = E.make_theme(accent).get(E.COLOR_NAMES[cor])
85
+ return "#%02X%02X%02X" % c[:3]
86
+ return str(cor)[:7]
87
+
88
+
89
+ def plan_item(template_id: str, params: dict | None = None, *, fps: int = 30,
90
+ canvas: Sequence[int] = (1080, 1920), region=None, duration: float | None = None,
91
+ frames: int | None = None, accent: str = E.DEFAULT_ACCENT,
92
+ blocked: Sequence[tuple[float, float]] | None = None,
93
+ dirs: list[Path] | None = None) -> MotionItem:
94
+ """Check everything and decide where and how long the item is. `blocked` = vertical bands (y0, y1) in
95
+ design px of the canvas that the content must not touch (faces, other texts); the region slides
96
+ vertically inside the safe area to the free place closest to where it was."""
97
+ tpl = Rg.get(template_id, dirs)
98
+ resolved = Rg.resolve_params(tpl, params)
99
+ fps = int(fps)
100
+ if fps <= 0:
101
+ raise MotionError("fps precisa ser maior que 0")
102
+ canvas = (int(canvas[0]), int(canvas[1]))
103
+ if canvas[0] < 16 or canvas[1] < 16:
104
+ raise MotionError(f"tamanho de tela inválido: {canvas}")
105
+ if frames is None:
106
+ secs = tpl.suggested_duration(resolved) if duration is None else float(duration)
107
+ if not math.isfinite(secs) or secs <= 0:
108
+ raise MotionError(f"duração inválida: {duration!r}")
109
+ frames = E.frame_of(secs, fps)
110
+ frames = int(frames)
111
+ if frames < 2:
112
+ raise MotionError(f"'{tpl.id}' precisa durar pelo menos 2 quadros")
113
+ if frames > Rg.MAX_DURATION * fps:
114
+ raise MotionError(f"'{tpl.id}' pode durar no máximo {Rg.MAX_DURATION:g} s")
115
+ spec = tpl.default_region if region in (None, "", "auto") else region
116
+ x, y, w, h = E.region_box(spec, canvas)
117
+ warnings: list[str] = []
118
+ sx0, sy0, sx1, sy1 = E.safe_area(canvas)
119
+ if not isinstance(spec, str) and (y < sy0 - 0.5 or y + h > sy1 + 0.5):
120
+ warnings.append(f"'{tpl.id}': a região [{x:g}, {y:g}, {w:g}, {h:g}] passa da área segura "
121
+ f"(de {sy0:.0f} a {sy1:.0f} na altura): a interface do Reels pode cobrir parte dela")
122
+ # without a card the text sits on the video: white letters with a dark outline read there, the dark letters
123
+ # of the light card do not, so "tema" only matters when there is a card
124
+ tema = resolved.get("tema", "escuro") if resolved.get("card", True) is not False else "escuro"
125
+ # a look with its own palette (papel, pop, neon) keeps its colors even when the video has another accent;
126
+ # only an item's own "cor" changes them
127
+ own_palette = tema in E.PAPEL or tema in ("pop", "neon")
128
+ base_accent = E.DEFAULT_ACCENT if own_palette and resolved.get("cor") in (None, "") else accent
129
+ theme = E.make_theme(_accent_for(resolved, base_accent), tema)
130
+ box = E.Box(x=x, y=y, w=w, h=h, scale=min(canvas) / E.REF, canvas=canvas, fps=fps, frames=frames,
131
+ theme=theme, bleed=tpl.bleed)
132
+ item = MotionItem(template=tpl, params=resolved, box=box, frames=frames, region=[x, y, w, h],
133
+ region_spec=spec, warnings=warnings)
134
+ hook = getattr(tpl.module, "warnings", None)
135
+ if callable(hook):
136
+ with E.theme_fonts(theme):
137
+ texts = hook(resolved, box) or []
138
+ for text in texts:
139
+ item.warnings.append(f"'{tpl.id}': {text}")
140
+ if blocked:
141
+ place(item, blocked)
142
+ return item
143
+
144
+
145
+ def _ink_hook(item: MotionItem) -> tuple[float, float, float, float] | None | bool:
146
+ """The template's own `ink(params, box)` (box-local design px), or False when it has none."""
147
+ fn = getattr(item.template.module, "ink", None)
148
+ if not callable(fn):
149
+ return False
150
+ with E.theme_fonts(item.box.theme):
151
+ box = fn(item.params, item.box)
152
+ if box is None:
153
+ return None
154
+ x0, y0, x1, y1 = (float(v) for v in box)
155
+ return (min(x0, x1), min(y0, y1), max(x0, x1), max(y0, y1))
156
+
157
+
158
+ def measure_ink(item: MotionItem, samples: int = 6) -> tuple[float, float, float, float] | None:
159
+ """Union of the drawn pixels (alpha above INK_ALPHA) over a few frames (without the exit), in box-local
160
+ design px. A template with `ink(params, box)` gives it directly (a full-screen flash is not "ink")."""
161
+ hooked = _ink_hook(item)
162
+ if hooked is not False:
163
+ item.ink = hooked # type: ignore[assignment]
164
+ return item.ink
165
+ n = item.frames
166
+ last = max(0, n - 1 - (EXIT_FRAMES if item.template.exit == "fade" else 0))
167
+ picks = sorted({int(round(last * k / max(1, samples - 1))) for k in range(samples)})
168
+ s = item.box.scale
169
+ union = None
170
+ for i in picks:
171
+ fr = render_frame(item, i, with_exit=False)
172
+ bb = fr.image.getchannel("A").point(_INK_LUT).getbbox()
173
+ if not bb:
174
+ continue
175
+ x0, y0 = (fr.x + bb[0]) / s - item.box.x, (fr.y + bb[1]) / s - item.box.y
176
+ x1, y1 = (fr.x + bb[2]) / s - item.box.x, (fr.y + bb[3]) / s - item.box.y
177
+ union = (x0, y0, x1, y1) if union is None else (min(union[0], x0), min(union[1], y0),
178
+ max(union[2], x1), max(union[3], y1))
179
+ item.ink = union
180
+ return union
181
+
182
+
183
+ def free_offset(item: MotionItem, blocked: Sequence[tuple[float, float]], gap: float = 23.0) -> float | None:
184
+ """Vertical move (design px, from where the item is now) that puts its drawn part at the free place
185
+ closest to where it is, inside the safe band and `gap` away from every blocked (y0, y1) band. 0 when it
186
+ draws nothing, None when there is no free place. The item is not changed."""
187
+ import face_layout # noqa: PLC0415 (helpers/ is on sys.path through engine)
188
+
189
+ ink = item.ink if item.ink is not None else measure_ink(item)
190
+ if ink is None:
191
+ return 0.0
192
+ b = item.box
193
+ sx0, sy0, sx1, sy1 = E.safe_area(b.canvas)
194
+ top, bottom = b.y + ink[1], b.y + ink[3]
195
+ ref = (top + bottom) / 2.0
196
+ above, below = ref - top, bottom - ref
197
+ bands = [(float(a), float(c)) for a, c in blocked if c > a]
198
+ center = face_layout.free_center(ref, above, below, bands, sy0, sy1, gap)
199
+ return None if center is None else center - ref
200
+
201
+
202
+ def move_item(item: MotionItem, dy: float) -> MotionItem:
203
+ """Slide the item `dy` design px down (negative: up)."""
204
+ if abs(dy) >= 0.5:
205
+ b = item.box
206
+ item.box = E.with_box(b, y=b.y + dy)
207
+ item.region = [b.x, b.y + dy, b.w, b.h]
208
+ item.moved_by += dy
209
+ return item
210
+
211
+
212
+ def place(item: MotionItem, blocked: Sequence[tuple[float, float]], gap: float = 23.0) -> MotionItem:
213
+ """Slide the item vertically so its drawn part clears every blocked band (face_layout.free_center)."""
214
+ if not item.template.movable:
215
+ return item
216
+ dy = free_offset(item, blocked, gap)
217
+ if dy is None:
218
+ item.warnings.append(f"'{item.template.id}' não achou lugar livre do rosto e dos textos: ficou na região pedida")
219
+ return item
220
+ return move_item(item, dy)
221
+
222
+
223
+ # ------------------------------------------------------------------ frames
224
+
225
+ def _normalize(item: MotionItem, out: Any, i: int) -> Frame:
226
+ b = item.box
227
+ if isinstance(out, E.Canvas):
228
+ return Frame(out.image, out.origin_x, out.origin_y)
229
+ if isinstance(out, Image.Image):
230
+ img = out if out.mode == "RGBA" else out.convert("RGBA")
231
+ if img.size == tuple(b.canvas):
232
+ return Frame(img, 0, 0)
233
+ bw, bh = int(round(b.w * b.scale)), int(round(b.h * b.scale))
234
+ if abs(img.width - bw) <= 1 and abs(img.height - bh) <= 1:
235
+ return Frame(img, int(round(b.x * b.scale)), int(round(b.y * b.scale)))
236
+ raise MotionError(f"'{item.template.id}': render do quadro {i} devolveu {img.size}; o esperado é a tela "
237
+ f"{tuple(b.canvas)}, a caixa ({bw}, {bh}) ou um Canvas")
238
+ raise MotionError(f"'{item.template.id}': render precisa devolver um Canvas ou uma imagem RGBA (veio {type(out).__name__})")
239
+
240
+
241
+ def _exit(item: MotionItem, fr: Frame, i: int) -> Frame:
242
+ """Generic exit: the last frames fade out and shrink 3% around the box center. Only the drawn part is
243
+ resampled (premultiplied, so transparent pixels never darken the edges)."""
244
+ n = item.frames
245
+ k = min(EXIT_FRAMES, n // 3)
246
+ j = i - (n - k)
247
+ if item.template.exit != "fade" or k <= 0 or j < 0:
248
+ return fr
249
+ p = E.in_quad((j + 1) / (k + 1))
250
+ img = fr.image
251
+ bb = img.getchannel("A").getbbox()
252
+ if bb is None:
253
+ return fr
254
+ b = item.box
255
+ cx = (b.x + b.w / 2.0) * b.scale - fr.x
256
+ cy = (b.y + b.h / 2.0) * b.scale - fr.y
257
+ sc = 1.0 - EXIT_SHRINK * p
258
+ # the shrunk drawing stays between the drawn area and the center: resample only that rectangle
259
+ x0 = int(math.floor(min(bb[0], cx + (bb[0] - cx) * sc))) - 2
260
+ y0 = int(math.floor(min(bb[1], cy + (bb[1] - cy) * sc))) - 2
261
+ x1 = int(math.ceil(max(bb[2], cx + (bb[2] - cx) * sc))) + 2
262
+ y1 = int(math.ceil(max(bb[3], cy + (bb[3] - cy) * sc))) + 2
263
+ x0, y0, x1, y1 = max(0, x0), max(0, y0), min(img.width, x1), min(img.height, y1)
264
+ part = img.crop((x0, y0, x1, y1)).convert("RGBa")
265
+ inv = 1.0 / sc
266
+ lx, ly = cx - x0, cy - y0
267
+ part = part.transform(part.size, Image.AFFINE, (inv, 0.0, lx - lx * inv, 0.0, inv, ly - ly * inv),
268
+ resample=Image.BICUBIC).convert("RGBA")
269
+ a = np.asarray(part.getchannel("A"), np.float32) * (1.0 - p)
270
+ part.putalpha(Image.fromarray(np.clip(a + 0.5, 0, 255).astype(np.uint8), "L"))
271
+ out = Image.new("RGBA", img.size, (0, 0, 0, 0))
272
+ out.paste(part, (x0, y0))
273
+ return Frame(out, fr.x, fr.y)
274
+
275
+
276
+ def render_frame(item: MotionItem, i: int, with_exit: bool = True) -> Frame:
277
+ if not 0 <= i < item.frames:
278
+ raise MotionError(f"quadro {i} fora do item (0 a {item.frames - 1})")
279
+ with E.theme_fonts(item.box.theme):
280
+ out = item.template.render(int(i), int(item.fps), item.params, item.box)
281
+ fr = _normalize(item, out, i)
282
+ return _exit(item, fr, i) if with_exit else fr
283
+
284
+
285
+ def iter_frames(item: MotionItem) -> Iterator[tuple[int, Frame, bool]]:
286
+ """(index, frame, repeated) for every frame; frames after the template's settle time reuse the last one."""
287
+ settle = item.template.settle(item.params, item.fps)
288
+ n = item.frames
289
+ exit_from = n - min(EXIT_FRAMES, n // 3) if item.template.exit == "fade" else n
290
+ still_from = E.frame_of(settle, item.fps) if settle is not None else None
291
+ held: Frame | None = None
292
+ for i in range(n):
293
+ if still_from is not None and still_from <= i < exit_from and held is not None:
294
+ yield i, held, True
295
+ continue
296
+ fr = render_frame(item, i)
297
+ if still_from is not None and i >= still_from and i < exit_from:
298
+ held = fr
299
+ yield i, fr, False
300
+
301
+
302
+ def clip_rect(item: MotionItem, first: Frame) -> tuple[int, int, int, int]:
303
+ """(x, y, w, h) render px on the canvas that holds every frame: even origin and even size."""
304
+ W, H = item.canvas
305
+ x0, y0 = max(0, first.x), max(0, first.y)
306
+ x1, y1 = min(W, first.x + first.image.width), min(H, first.y + first.image.height)
307
+ x0, y0 = x0 // 2 * 2, y0 // 2 * 2
308
+ x1, y1 = min(W, (x1 + 1) // 2 * 2), min(H, (y1 + 1) // 2 * 2)
309
+ if x1 - x0 < 2 or y1 - y0 < 2:
310
+ raise MotionError(f"'{item.template.id}' desenha fora da tela")
311
+ return x0, y0, x1 - x0, y1 - y0
312
+
313
+
314
+ def _place_on(fr: Frame, rect: tuple[int, int, int, int]) -> Image.Image:
315
+ x, y, w, h = rect
316
+ if (fr.x, fr.y, fr.image.width, fr.image.height) == rect:
317
+ return fr.image
318
+ out = Image.new("RGBA", (w, h), (0, 0, 0, 0))
319
+ out.paste(fr.image, (fr.x - x, fr.y - y))
320
+ return out
321
+
322
+
323
+ def _check_inside(item: MotionItem, fr: Frame, rect: tuple[int, int, int, int], i: int) -> None:
324
+ """Every frame must fit the rectangle of the first one (same kind of Canvas on every frame)."""
325
+ W, H = item.canvas
326
+ x0, y0 = max(0, fr.x), max(0, fr.y)
327
+ x1, y1 = min(W, fr.x + fr.image.width), min(H, fr.y + fr.image.height)
328
+ if x0 < rect[0] or y0 < rect[1] or x1 > rect[0] + rect[2] or y1 > rect[1] + rect[3]:
329
+ raise MotionError(f"'{item.template.id}': o quadro {i} desenha fora da área do quadro 0. Use sempre o mesmo "
330
+ f"tipo de Canvas em todos os quadros (Canvas(box, full=True) se algum precisar da tela toda)")
331
+
332
+
333
+ def full_frame(item: MotionItem, fr: Frame) -> Image.Image:
334
+ W, H = item.canvas
335
+ return _place_on(fr, (0, 0, W, H))
336
+
337
+
338
+ # ------------------------------------------------------------------ sounds
339
+
340
+ def sfx_cues(item: MotionItem, check_names: bool = False) -> list[dict]:
341
+ """[{frame (from the item start), name, volume, why?}] inside the item, sorted by frame."""
342
+ tpl = item.template
343
+ fn = getattr(tpl.module, "sfx", None)
344
+ raw = fn(item.params, item.fps, item.frames) if callable(fn) else tpl.sfx
345
+ if not isinstance(raw, list):
346
+ raise MotionError(f"'{tpl.id}': sfx() precisa devolver uma lista")
347
+ out = []
348
+ for k, cue in enumerate(raw):
349
+ if not isinstance(cue, dict) or not isinstance(cue.get("name"), str):
350
+ raise MotionError(f"'{tpl.id}': som {k} sem 'name'")
351
+ if "frame" in cue:
352
+ frame = int(cue["frame"])
353
+ else:
354
+ frame = E.frame_of(float(cue.get("at", 0.0)), item.fps)
355
+ if not 0 <= frame < item.frames:
356
+ continue
357
+ vol = float(cue.get("volume", 1.0))
358
+ entry = {"frame": frame, "name": cue["name"], "volume": round(min(2.0, max(0.0, vol)), 3)}
359
+ if cue.get("length") is not None and float(cue["length"]) > 0:
360
+ entry["length"] = round(float(cue["length"]), 4) # seconds the sound plays after its strong point
361
+ if cue.get("why"):
362
+ entry["why"] = str(cue["why"])
363
+ out.append(entry)
364
+ if check_names:
365
+ import make_sfx # noqa: PLC0415
366
+ manifest = make_sfx.load_manifest()
367
+ for cue in out:
368
+ if make_sfx.resolve_name(cue["name"], manifest) is None:
369
+ raise MotionError(f"'{tpl.id}': efeito sonoro '{cue['name']}' não existe (use um de {', '.join(make_sfx.NAMES)})")
370
+ return sorted(out, key=lambda c: (c["frame"], c["name"]))
371
+
372
+
373
+ # ------------------------------------------------------------------ outputs
374
+
375
+ def _ffmpeg() -> str:
376
+ from shutil import which
377
+ exe = which("ffmpeg")
378
+ if not exe:
379
+ raise MotionError("'ffmpeg' não encontrado no PATH. macOS: brew install ffmpeg · Windows: winget install Gyan.FFmpeg")
380
+ return exe
381
+
382
+
383
+ class _Pipe:
384
+ def __init__(self, args: list[str], what: str):
385
+ self.what = what
386
+ self.proc = subprocess.Popen([_ffmpeg(), "-hide_banner", "-nostdin", "-v", "error", "-y", *args],
387
+ stdin=subprocess.PIPE, stderr=subprocess.PIPE)
388
+
389
+ def write(self, data: bytes) -> None:
390
+ try:
391
+ self.proc.stdin.write(data) # type: ignore[union-attr]
392
+ except BrokenPipeError:
393
+ self.close()
394
+
395
+ def close(self) -> None:
396
+ try:
397
+ self.proc.stdin.close() # type: ignore[union-attr]
398
+ except OSError:
399
+ pass
400
+ err = self.proc.stderr.read().decode("utf-8", "replace") if self.proc.stderr else ""
401
+ if self.proc.wait() != 0:
402
+ raise MotionError(f"o ffmpeg falhou ao {self.what}: {err.strip()[-400:]}", code=1)
403
+
404
+ def kill(self) -> None:
405
+ try:
406
+ self.proc.kill()
407
+ self.proc.wait()
408
+ except OSError:
409
+ pass
410
+
411
+
412
+ def _qtrle_args(w: int, h: int, fps: int, n: int, out: Path) -> list[str]:
413
+ return ["-f", "rawvideo", "-pix_fmt", "rgba", "-s", f"{w}x{h}", "-framerate", str(fps), "-i", "-",
414
+ "-c:v", "qtrle", "-pix_fmt", "argb", "-g", str(max(1, n)), "-frames:v", str(n),
415
+ "-f", "mov", str(out)]
416
+
417
+
418
+ def _atomic_json(path: Path, obj: dict) -> None:
419
+ tmp = path.with_name(".tmp-" + path.name)
420
+ tmp.write_text(json.dumps(obj, ensure_ascii=False, indent=2), encoding="utf-8")
421
+ os.replace(tmp, path)
422
+
423
+
424
+ def render_clip(item: MotionItem, out_dir: Path | str, cache: bool = True) -> dict:
425
+ """The alpha clip compose_final overlays: renders/motion/<id>_<key>.mov + .json (cached by content).
426
+
427
+ Returns {file, json, template, x, y, w, h (render px on the canvas), frames, fps, canvas, region
428
+ (design px), moved_by, ink (render px on the canvas or null), sfx (cues from the item start), params,
429
+ key, cached, warnings}."""
430
+ out_dir = Path(out_dir)
431
+ out_dir.mkdir(parents=True, exist_ok=True)
432
+ key = item.key
433
+ clip = out_dir / f"{item.template.id}_{key}.mov"
434
+ meta_path = clip.with_suffix(".json")
435
+ if cache and clip.exists() and meta_path.exists():
436
+ try:
437
+ meta = json.loads(meta_path.read_text(encoding="utf-8"))
438
+ if meta.get("key") == key and clip.stat().st_size > 0:
439
+ meta.update({"file": str(clip), "json": str(meta_path), "cached": True,
440
+ "warnings": list(item.warnings), "region": [round(v, 2) for v in item.region],
441
+ "moved_by": round(item.moved_by, 2)})
442
+ return meta
443
+ except (OSError, ValueError):
444
+ pass
445
+ tmp = clip.with_name(".tmp-" + clip.name)
446
+ pipe = None
447
+ rect = None
448
+ ink = None
449
+ last_bytes = None
450
+ hooked = _ink_hook(item)
451
+ try:
452
+ for i, fr, repeated in iter_frames(item):
453
+ if rect is None:
454
+ rect = clip_rect(item, fr)
455
+ pipe = _Pipe(_qtrle_args(rect[2], rect[3], item.fps, item.frames, tmp), "gravar a animação")
456
+ if not repeated or last_bytes is None:
457
+ _check_inside(item, fr, rect, i)
458
+ img = _place_on(fr, rect)
459
+ if hooked is False:
460
+ bb = img.getchannel("A").point(_INK_LUT).getbbox()
461
+ if bb:
462
+ ink = bb if ink is None else (min(ink[0], bb[0]), min(ink[1], bb[1]),
463
+ max(ink[2], bb[2]), max(ink[3], bb[3]))
464
+ last_bytes = img.tobytes()
465
+ pipe.write(last_bytes) # type: ignore[union-attr]
466
+ pipe.close() # type: ignore[union-attr]
467
+ except BaseException:
468
+ if pipe is not None:
469
+ pipe.kill()
470
+ if tmp.exists():
471
+ tmp.unlink()
472
+ raise
473
+ os.replace(tmp, clip)
474
+ x, y, w, h = rect # type: ignore[misc]
475
+ if hooked is False:
476
+ ink_px = [x + ink[0], y + ink[1], x + ink[2], y + ink[3]] if ink else None
477
+ elif hooked is None:
478
+ ink_px = None
479
+ else:
480
+ s, b = item.box.scale, item.box
481
+ ink_px = [int(math.floor((b.x + hooked[0]) * s)), int(math.floor((b.y + hooked[1]) * s)),
482
+ int(math.ceil((b.x + hooked[2]) * s)), int(math.ceil((b.y + hooked[3]) * s))]
483
+ meta = {
484
+ "file": str(clip), "json": str(meta_path), "template": item.template.id, "x": x, "y": y, "w": w, "h": h,
485
+ "frames": item.frames, "fps": item.fps, "canvas": list(item.canvas),
486
+ "region": [round(v, 2) for v in item.region], "moved_by": round(item.moved_by, 2),
487
+ "ink": ink_px,
488
+ "sfx": sfx_cues(item), "params": item.params, "key": key, "cached": False, "warnings": list(item.warnings),
489
+ }
490
+ _atomic_json(meta_path, meta)
491
+ return meta
492
+
493
+
494
+ def overlay_filters(clip: dict, input_index: int, base_label: str, out_label: str, start_frame: int,
495
+ windows: Sequence[tuple[int, int]] | None = None) -> list[str]:
496
+ """Filter-graph lines that put a render_clip() result on the video from `start_frame` on (frame-exact).
497
+ `windows` = [(first, end)] output frames where it shows (default: the whole item); the clip keeps its own
498
+ timing, so a hidden stretch just skips those frames."""
499
+ fps, n = clip["fps"], clip["frames"]
500
+ lab = f"mo{input_index}"
501
+ spans = [(start_frame, start_frame + n)] if windows is None else list(windows)
502
+ enable = "+".join(f"between(n,{a},{b - 1})" for a, b in spans if b > a) or "0"
503
+ return [
504
+ f"[{input_index}:v]settb=1/{fps},setpts=N+{start_frame},format=rgba,"
505
+ f"scale=out_color_matrix=bt709:out_range=tv,format=yuva420p[{lab}]",
506
+ f"[{base_label}][{lab}]overlay=x={clip['x']}:y={clip['y']}:eof_action=pass:format=auto:"
507
+ f"enable='{enable}'[{out_label}]",
508
+ ]
509
+
510
+
511
+ def write_mov(item: MotionItem, path: Path) -> Path:
512
+ """Transparent full-canvas QuickTime Animation .mov (for a video editor)."""
513
+ W, H = item.canvas
514
+ path.parent.mkdir(parents=True, exist_ok=True)
515
+ tmp = path.with_name(".tmp-" + path.name)
516
+ pipe = _Pipe(_qtrle_args(W, H, item.fps, item.frames, tmp), "gravar o .mov")
517
+ try:
518
+ last = None
519
+ for _, fr, repeated in iter_frames(item):
520
+ if not repeated or last is None:
521
+ last = full_frame(item, fr).tobytes()
522
+ pipe.write(last)
523
+ pipe.close()
524
+ except BaseException:
525
+ pipe.kill()
526
+ if tmp.exists():
527
+ tmp.unlink()
528
+ raise
529
+ os.replace(tmp, path)
530
+ return path
531
+
532
+
533
+ def write_pngs(item: MotionItem, folder: Path) -> list[Path]:
534
+ folder.mkdir(parents=True, exist_ok=True)
535
+ out = []
536
+ for i, fr, _ in iter_frames(item):
537
+ p = folder / f"{item.template.id}_{i:04d}.png"
538
+ full_frame(item, fr).save(p, "PNG", compress_level=3)
539
+ out.append(p)
540
+ return out
541
+
542
+
543
+ def background(canvas: Sequence[int], image: str | Path | None = None) -> Image.Image:
544
+ """A frame to judge the animation on: the given picture (cover) or a neutral talking-head mock."""
545
+ W, H = int(canvas[0]), int(canvas[1])
546
+ if image:
547
+ p = Path(image)
548
+ if not p.exists():
549
+ raise MotionError(f"fundo não encontrado: {p}")
550
+ with Image.open(p) as im:
551
+ im = ImageOps.exif_transpose(im).convert("RGB")
552
+ return ImageOps.fit(im, (W, H), Image.LANCZOS).convert("RGBA")
553
+ y = np.linspace(0.0, 1.0, H, dtype=np.float32)[:, None]
554
+ x = np.linspace(0.0, 1.0, W, dtype=np.float32)[None, :]
555
+ top, bot = np.array([62, 70, 84], np.float32), np.array([142, 128, 114], np.float32)
556
+ rgb = top[None, None, :] * (1 - y[..., None]) + bot[None, None, :] * y[..., None]
557
+ rgb = rgb + 14 * np.sin(x * 3.1)[..., None]
558
+ img = Image.fromarray(np.clip(rgb, 0, 255).astype(np.uint8), "RGB").convert("RGBA")
559
+ person = Image.new("L", (W, H), 0)
560
+ d = ImageDraw.Draw(person)
561
+ k = min(W, H) / 1080.0
562
+ cx = W / 2
563
+ head_cy = H * 0.36
564
+ d.ellipse((cx - 175 * k, head_cy - 230 * k, cx + 175 * k, head_cy + 230 * k), fill=255)
565
+ d.ellipse((cx - 470 * k, head_cy + 300 * k, cx + 470 * k, head_cy + 1300 * k), fill=255)
566
+ person = person.filter(ImageFilter.GaussianBlur(10 * k))
567
+ shade = Image.new("RGBA", (W, H), (40, 34, 32, 255))
568
+ shade.putalpha(person.point(lambda v: v * 150 // 255))
569
+ img.alpha_composite(shade)
570
+ return img
571
+
572
+
573
+ def _dashed(d: ImageDraw.ImageDraw, x0, y0, x1, y1, fill, width=1, dash=10):
574
+ length = math.hypot(x1 - x0, y1 - y0)
575
+ if length <= 0:
576
+ return
577
+ n = max(1, int(length // (2 * dash)))
578
+ for k in range(n + 1):
579
+ a = min(1.0, k * 2 * dash / length)
580
+ b = min(1.0, (k * 2 * dash + dash) / length)
581
+ d.line((x0 + (x1 - x0) * a, y0 + (y1 - y0) * a, x0 + (x1 - x0) * b, y0 + (y1 - y0) * b), fill=fill, width=width)
582
+
583
+
584
+ def contact_sheet(item: MotionItem, path: Path, count: int = 8, bg: str | Path | None = None,
585
+ guides: bool = True, tile_width: int | None = None, cols: int = 4) -> Path:
586
+ """`count` frames spread over the item on one PNG, with the safe area and the region drawn."""
587
+ n = item.frames
588
+ picks = sorted({int(round(v)) for v in np.linspace(0, n - 1, max(2, min(count, n)))})
589
+ W, H = item.canvas
590
+ base = background(item.canvas, bg)
591
+ tw = tile_width or (270 if H >= W else 480)
592
+ th = int(round(tw * H / W))
593
+ rows = (len(picks) + cols - 1) // cols
594
+ label_h = 26
595
+ sheet = Image.new("RGB", (cols * tw + (cols + 1) * 8, rows * (th + label_h) + (rows + 1) * 8), (24, 24, 26))
596
+ d_sheet = ImageDraw.Draw(sheet)
597
+ lab_font = E.font("text_bold", 17)
598
+ s = item.box.scale
599
+ sx0, sy0, sx1, sy1 = (v * s for v in E.safe_area(item.canvas))
600
+ rx, ry, rw, rh = (v * s for v in item.region)
601
+ for k, i in enumerate(picks):
602
+ frame = base.copy()
603
+ frame.alpha_composite(full_frame(item, render_frame(item, i)))
604
+ if guides:
605
+ d = ImageDraw.Draw(frame)
606
+ lw = max(1, int(round(2 * s)))
607
+ for yy in (sy0, sy1):
608
+ _dashed(d, 0, yy, W, yy, (255, 80, 80, 255), lw, int(14 * s) + 2)
609
+ if H > W:
610
+ rail = W - E.SAFE_ZONE_VERTICAL["right_rail"] * s
611
+ _dashed(d, rail, H * 0.5, rail, sy1, (255, 80, 80, 255), lw, int(14 * s) + 2)
612
+ for (a, b_), (c, e) in (((rx, ry), (rx + rw, ry)), ((rx + rw, ry), (rx + rw, ry + rh)),
613
+ ((rx + rw, ry + rh), (rx, ry + rh)), ((rx, ry + rh), (rx, ry))):
614
+ _dashed(d, a, b_, c, e, (120, 220, 255, 255), lw, int(10 * s) + 2)
615
+ tile = frame.convert("RGB").resize((tw, th), Image.LANCZOS)
616
+ col, row = k % cols, k // cols
617
+ ox, oy = 8 + col * (tw + 8), 8 + row * (th + label_h + 8)
618
+ sheet.paste(tile, (ox, oy + label_h))
619
+ d_sheet.text((ox + 2, oy + 3), f"quadro {i} · {E.format_number(i / item.fps, 2)} s", font=lab_font,
620
+ fill=(235, 235, 235))
621
+ path.parent.mkdir(parents=True, exist_ok=True)
622
+ sheet.save(path, "PNG")
623
+ return path
624
+
625
+
626
+ def write_mp4(item: MotionItem, path: Path, bg: str | Path | None = None) -> Path:
627
+ """H.264 preview of the animation over a background (to watch it, not to compose)."""
628
+ W, H = item.canvas
629
+ base = background(item.canvas, bg)
630
+ path.parent.mkdir(parents=True, exist_ok=True)
631
+ tmp = path.with_name(".tmp-" + path.name)
632
+ pipe = _Pipe(["-f", "rawvideo", "-pix_fmt", "rgb24", "-s", f"{W}x{H}", "-framerate", str(item.fps), "-i", "-",
633
+ "-vf", "scale=out_color_matrix=bt709:out_range=tv,format=yuv420p", "-c:v", "libx264",
634
+ "-preset", "veryfast", "-crf", "18", "-colorspace", "bt709", "-color_primaries", "bt709",
635
+ "-color_trc", "bt709", "-color_range", "tv", "-frames:v", str(item.frames),
636
+ "-movflags", "+faststart", "-f", "mp4", str(tmp)], "gravar a prévia .mp4")
637
+ try:
638
+ last = None
639
+ for _, fr, repeated in iter_frames(item):
640
+ if not repeated or last is None:
641
+ img = base.copy()
642
+ img.alpha_composite(full_frame(item, fr))
643
+ last = img.convert("RGB").tobytes()
644
+ pipe.write(last)
645
+ pipe.close()
646
+ except BaseException:
647
+ pipe.kill()
648
+ if tmp.exists():
649
+ tmp.unlink()
650
+ raise
651
+ os.replace(tmp, path)
652
+ return path
653
+
654
+
655
+ def decode_clip(path: Path | str, w: int, h: int) -> np.ndarray:
656
+ """Frames of a clip as (n, h, w, 4) RGBA uint8 (tests and checks)."""
657
+ proc = subprocess.run([_ffmpeg(), "-v", "error", "-i", str(path), "-f", "rawvideo", "-pix_fmt", "rgba", "-"],
658
+ capture_output=True)
659
+ if proc.returncode != 0:
660
+ raise MotionError(f"não consegui ler {path}: {proc.stderr.decode('utf-8', 'replace')[-300:]}", code=1)
661
+ return np.frombuffer(proc.stdout, np.uint8).reshape(-1, h, w, 4)
662
+
663
+
664
+ if __name__ == "__main__": # pragma: no cover
665
+ sys.exit("use helpers/motion_render.py")