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,308 @@
1
+ """Shared utilities for the editor-video skill helpers.
2
+
3
+ Every helper imports this module as `from common import ...` (helpers run with
4
+ `uv run python helpers/<name>.py`, so the helpers directory is on sys.path).
5
+
6
+ This file is the contract other helpers rely on: keep signatures stable.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import json
12
+ import os
13
+ import shutil
14
+ import subprocess
15
+ import sys
16
+ import tempfile
17
+ from datetime import datetime, timezone
18
+ from fractions import Fraction
19
+ from pathlib import Path
20
+ from typing import Any
21
+
22
+ SKILL_ROOT = Path(__file__).resolve().parent.parent
23
+ HELPERS_DIR = SKILL_ROOT / "helpers"
24
+ ASSETS_DIR = SKILL_ROOT / "assets"
25
+ FONTS_DIR = ASSETS_DIR / "fonts"
26
+ SFX_DIR = ASSETS_DIR / "sfx"
27
+ PREVIEW_DIR = SKILL_ROOT / "preview"
28
+ STYLE_CATALOG_PATH = HELPERS_DIR / "style_catalog.json"
29
+
30
+ OUTPUT_FORMATS: dict[str, tuple[int, int]] = {
31
+ "vertical": (1080, 1920),
32
+ "horizontal": (1920, 1080),
33
+ "square": (1080, 1080),
34
+ }
35
+
36
+ # Vertical 9:16 safe zones (pixels on a 1080x1920 canvas), validated on real
37
+ # Reels: platform UI covers the top ~210px, the bottom ~370px and a right rail.
38
+ SAFE_ZONE_VERTICAL = {
39
+ "top": 210,
40
+ "bottom": 1550, # nothing important below this line
41
+ "right_rail": 140, # like/comment/share column on Reels and TikTok
42
+ "caption_center_y": 1400,
43
+ "disclaimer_y": 1548,
44
+ }
45
+
46
+ EDIT_SUBDIRS = ("transcripts", "regions", "clips", "verify", "samples", "assets", "preview_cache", "renders")
47
+
48
+
49
+ # ---------------------------------------------------------------- processes
50
+
51
+ def run(cmd: list[str], quiet: bool = False, check: bool = True, capture: bool = False) -> subprocess.CompletedProcess:
52
+ """Run a command. quiet hides stdout/stderr unless it fails."""
53
+ if not quiet:
54
+ print("$ " + " ".join(str(c) for c in cmd), file=sys.stderr)
55
+ proc = subprocess.run(
56
+ [str(c) for c in cmd],
57
+ capture_output=quiet or capture,
58
+ text=True,
59
+ )
60
+ if check and proc.returncode != 0:
61
+ tail = (proc.stderr or "")[-2000:] if (quiet or capture) else ""
62
+ raise RuntimeError(f"command failed ({proc.returncode}): {' '.join(str(c) for c in cmd)}\n{tail}")
63
+ return proc
64
+
65
+
66
+ # O instalador do EDIT IA PRO baixa ffmpeg, ffprobe e Node para ~/.editor-video (sem senha de
67
+ # administrador). Essas pastas entram na frente do PATH para todo helper achar os programas.
68
+ _LOCAL = Path.home() / ".editor-video"
69
+ for _pasta in (_LOCAL / "node" / "bin", _LOCAL / "node", _LOCAL / "bin"):
70
+ if _pasta.is_dir() and str(_pasta) not in os.environ.get("PATH", "").split(os.pathsep):
71
+ os.environ["PATH"] = str(_pasta) + os.pathsep + os.environ.get("PATH", "")
72
+
73
+
74
+ def require_binary(name: str) -> str:
75
+ path = shutil.which(name)
76
+ if not path:
77
+ hint = {
78
+ "ffmpeg": "rode de novo o instalador do EDIT IA PRO (ele baixa o ffmpeg sem senha) · ou macOS: brew install ffmpeg · Windows: winget install Gyan.FFmpeg",
79
+ "ffprobe": "vem junto com o ffmpeg",
80
+ "node": "macOS: brew install node · Windows: winget install OpenJS.NodeJS.LTS",
81
+ }.get(name, "")
82
+ sys.exit(f"'{name}' não encontrado no PATH. {hint}")
83
+ return path
84
+
85
+
86
+ def has_ffmpeg_filter(name: str) -> bool:
87
+ proc = subprocess.run(["ffmpeg", "-hide_banner", "-filters"], capture_output=True, text=True)
88
+ for line in proc.stdout.splitlines():
89
+ parts = line.split()
90
+ if len(parts) >= 2 and parts[1] == name:
91
+ return True
92
+ return False
93
+
94
+
95
+ # ---------------------------------------------------------------- probing
96
+
97
+ def ffprobe_json(path: Path | str) -> dict:
98
+ proc = run(
99
+ ["ffprobe", "-v", "error", "-print_format", "json", "-show_format", "-show_streams", str(path)],
100
+ quiet=True,
101
+ )
102
+ return json.loads(proc.stdout or "{}")
103
+
104
+
105
+ def _rotation_of(stream: dict) -> int:
106
+ rot = 0
107
+ for sd in stream.get("side_data_list", []) or []:
108
+ if "rotation" in sd:
109
+ try:
110
+ rot = int(round(float(sd["rotation"])))
111
+ except (TypeError, ValueError):
112
+ pass
113
+ tags = stream.get("tags", {}) or {}
114
+ if not rot and "rotate" in tags:
115
+ try:
116
+ rot = -int(tags["rotate"])
117
+ except ValueError:
118
+ pass
119
+ return rot % 360 if rot else 0
120
+
121
+
122
+ def probe_video(path: Path | str) -> dict:
123
+ """Normalized facts about a media file.
124
+
125
+ Returns: duration, width, height (coded), display_width, display_height
126
+ (after rotation), rotation (0/90/180/270), fps (float), fps_str ("30000/1001"),
127
+ codec, pix_fmt, bit_depth, color_transfer, color_primaries, color_space,
128
+ is_hdr, has_video, has_audio, audio_tracks, audio_channels, sample_rate.
129
+ """
130
+ info = ffprobe_json(path)
131
+ streams = info.get("streams", [])
132
+ v = next((s for s in streams if s.get("codec_type") == "video" and not s.get("disposition", {}).get("attached_pic")), None)
133
+ audios = [s for s in streams if s.get("codec_type") == "audio"]
134
+ fmt = info.get("format", {})
135
+ duration = float(fmt.get("duration") or (v or {}).get("duration") or 0.0)
136
+ out: dict[str, Any] = {
137
+ "path": str(path),
138
+ "duration": duration,
139
+ "has_video": v is not None,
140
+ "has_audio": bool(audios),
141
+ "audio_tracks": len(audios),
142
+ "audio_channels": int(audios[0].get("channels", 0)) if audios else 0,
143
+ "sample_rate": int(audios[0].get("sample_rate", 0) or 0) if audios else 0,
144
+ }
145
+ if v is not None:
146
+ width, height = int(v.get("width", 0)), int(v.get("height", 0))
147
+ rotation = _rotation_of(v)
148
+ if rotation in (90, 270):
149
+ dw, dh = height, width
150
+ else:
151
+ dw, dh = width, height
152
+ fps_str = v.get("avg_frame_rate") or v.get("r_frame_rate") or "0/1"
153
+ if fps_str in ("0/0", "0/1"):
154
+ fps_str = v.get("r_frame_rate") or "30/1"
155
+ try:
156
+ fps = float(Fraction(fps_str))
157
+ except (ValueError, ZeroDivisionError):
158
+ fps, fps_str = 30.0, "30/1"
159
+ pix_fmt = v.get("pix_fmt", "")
160
+ bit_depth = 10 if ("10" in pix_fmt or "p010" in pix_fmt) else 12 if "12" in pix_fmt else 8
161
+ transfer = v.get("color_transfer", "") or ""
162
+ out.update(
163
+ width=width,
164
+ height=height,
165
+ display_width=dw,
166
+ display_height=dh,
167
+ rotation=rotation,
168
+ fps=fps,
169
+ fps_str=fps_str,
170
+ codec=v.get("codec_name", ""),
171
+ pix_fmt=pix_fmt,
172
+ bit_depth=bit_depth,
173
+ color_transfer=transfer,
174
+ color_primaries=v.get("color_primaries", "") or "",
175
+ color_space=v.get("color_space", "") or "",
176
+ is_hdr=transfer in ("arib-std-b67", "smpte2084"),
177
+ )
178
+ return out
179
+
180
+
181
+ _COMMON_RATES = (23.976, 24.0, 25.0, 29.97, 30.0, 48.0, 50.0, 59.94, 60.0, 120.0)
182
+
183
+
184
+ def nominal_fps(fps: float) -> float:
185
+ """Snap a measured average fps (e.g. 59.9971) to the nearest common rate."""
186
+ return min(_COMMON_RATES, key=lambda r: abs(r - fps)) if fps > 0 else 30.0
187
+
188
+
189
+ def output_fps_for(source_fps: float) -> int:
190
+ """Delivery fps for short-form: 30 when the source is ~30 or faster, else 24/25."""
191
+ n = nominal_fps(source_fps)
192
+ if n >= 29.0:
193
+ return 30
194
+ return 25 if abs(n - 25.0) < 0.5 else 24
195
+
196
+
197
+ def file_signature(path: Path | str) -> str:
198
+ """Cheap change detector for caches: size + mtime_ns."""
199
+ st = os.stat(path)
200
+ return f"{st.st_size}:{st.st_mtime_ns}"
201
+
202
+
203
+ # ---------------------------------------------------------------- paths & json
204
+
205
+ def edit_dir_for(videos_dir: Path | str) -> Path:
206
+ return Path(videos_dir).resolve() / "edit"
207
+
208
+
209
+ def ensure_edit_dirs(edit_dir: Path | str) -> Path:
210
+ edit = Path(edit_dir)
211
+ edit.mkdir(parents=True, exist_ok=True)
212
+ for sub in EDIT_SUBDIRS:
213
+ (edit / sub).mkdir(exist_ok=True)
214
+ return edit
215
+
216
+
217
+ def load_json(path: Path | str, default: Any = None) -> Any:
218
+ p = Path(path)
219
+ if not p.exists():
220
+ return default
221
+ with p.open("r", encoding="utf-8") as fh:
222
+ return json.load(fh)
223
+
224
+
225
+ def save_json(path: Path | str, obj: Any) -> Path:
226
+ """Atomic write (temp file + replace) so the preview never reads half a file."""
227
+ p = Path(path)
228
+ p.parent.mkdir(parents=True, exist_ok=True)
229
+ fd, tmp = tempfile.mkstemp(prefix=".tmp-", suffix=".json", dir=str(p.parent))
230
+ try:
231
+ with os.fdopen(fd, "w", encoding="utf-8") as fh:
232
+ json.dump(obj, fh, ensure_ascii=False, indent=2)
233
+ os.replace(tmp, p)
234
+ finally:
235
+ if os.path.exists(tmp):
236
+ os.unlink(tmp)
237
+ return p
238
+
239
+
240
+ def now_iso() -> str:
241
+ return datetime.now(timezone.utc).astimezone().isoformat(timespec="seconds")
242
+
243
+
244
+ def fmt_time(seconds: float) -> str:
245
+ """12.345 -> '00:12.34' (mm:ss.cc)."""
246
+ if seconds is None:
247
+ return "--:--"
248
+ s = max(0.0, float(seconds))
249
+ m = int(s // 60)
250
+ return f"{m:02d}:{s - 60 * m:05.2f}"
251
+
252
+
253
+ def hex_to_rgb(value: str) -> tuple[int, int, int]:
254
+ h = value.strip().lstrip("#")
255
+ if len(h) == 3:
256
+ h = "".join(c * 2 for c in h)
257
+ if len(h) != 6:
258
+ raise ValueError(f"cor inválida: {value!r}")
259
+ return int(h[0:2], 16), int(h[2:4], 16), int(h[4:6], 16)
260
+
261
+
262
+ # ---------------------------------------------------------------- fonts
263
+
264
+ def font_path(key: str) -> Path:
265
+ """Resolve a bundled font by key using assets/fonts/fonts.json.
266
+
267
+ fonts.json maps key -> {"file": "<file in assets/fonts>", "variation": <int|null>}.
268
+ Raises FileNotFoundError with the list of known keys when missing.
269
+ """
270
+ manifest = load_json(FONTS_DIR / "fonts.json", default={}) or {}
271
+ entry = manifest.get(key)
272
+ if not entry:
273
+ raise FileNotFoundError(f"fonte '{key}' não existe em assets/fonts/fonts.json (tem: {sorted(manifest)})")
274
+ path = FONTS_DIR / entry["file"]
275
+ if not path.exists():
276
+ raise FileNotFoundError(f"arquivo da fonte '{key}' não encontrado: {path}")
277
+ return path
278
+
279
+
280
+ def load_font(key: str, size: int):
281
+ """PIL ImageFont for a bundled font key, applying the variable-font weight if declared."""
282
+ from PIL import ImageFont
283
+
284
+ manifest = load_json(FONTS_DIR / "fonts.json", default={}) or {}
285
+ entry = manifest.get(key) or {}
286
+ font = ImageFont.truetype(str(font_path(key)), size=size)
287
+ variation = entry.get("variation")
288
+ if variation is not None:
289
+ try:
290
+ font.set_variation_by_axes([variation])
291
+ except Exception:
292
+ pass
293
+ return font
294
+
295
+
296
+ # ---------------------------------------------------------------- style catalog
297
+
298
+ def load_style_catalog() -> dict:
299
+ return load_json(STYLE_CATALOG_PATH, default={}) or {}
300
+
301
+
302
+ if __name__ == "__main__":
303
+ import pprint
304
+
305
+ if len(sys.argv) > 1:
306
+ pprint.pprint(probe_video(sys.argv[1]))
307
+ else:
308
+ print("uso: uv run python helpers/common.py <video>")