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,282 @@
1
+ """N labeled frames of a video in one PNG (contact sheet).
2
+
3
+ Usage:
4
+ uv run python helpers/contact_sheet.py <video> --times 1.5 3 4.25 -o sheet.png [--cols 4]
5
+ uv run python helpers/contact_sheet.py edit/cut.mp4 --junctions -o edit/verify/junctions.png
6
+
7
+ `--junctions` reads the cut timeline next to the video (cut.mp4 -> cut_timeline.json) and
8
+ shows, for every junction, the last two frames of the outgoing range and the first two
9
+ frames of the incoming range, frame-exact.
10
+
11
+ Also the shared frame utilities for the cut helpers (grade.py, timeline_view.py,
12
+ verify_cut.py import `extract_frame`, `ui_font`, `find_timeline`, `default_verify_dir`).
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import argparse
18
+ import io
19
+ import math
20
+ import os
21
+ import re
22
+ import subprocess
23
+ import sys
24
+ from concurrent.futures import ThreadPoolExecutor
25
+ from pathlib import Path
26
+
27
+ from PIL import Image, ImageDraw, ImageFont
28
+
29
+ from common import fmt_time, load_json, load_font, probe_video
30
+
31
+ BG = (20, 20, 24)
32
+ PANEL = (32, 32, 38)
33
+ FG = (238, 238, 240)
34
+ DIM = (150, 150, 160)
35
+ ACCENT = (255, 196, 0)
36
+
37
+ _SYSTEM_FONTS = [
38
+ "/System/Library/Fonts/Helvetica.ttc",
39
+ "/System/Library/Fonts/SFNS.ttf",
40
+ "/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf",
41
+ "/usr/share/fonts/dejavu/DejaVuSans.ttf",
42
+ "C:/Windows/Fonts/arial.ttf",
43
+ ]
44
+
45
+
46
+ def ui_font(size: int, bold: bool = False):
47
+ """Font for labels on analysis images: bundled Figtree, then system, then PIL default."""
48
+ for key in (("text_bold", "sans_bold") if bold else ("text_semibold", "text_bold")):
49
+ try:
50
+ return load_font(key, size)
51
+ except Exception:
52
+ continue
53
+ for fp in _SYSTEM_FONTS:
54
+ if Path(fp).exists():
55
+ try:
56
+ return ImageFont.truetype(fp, size)
57
+ except Exception:
58
+ continue
59
+ try:
60
+ return ImageFont.load_default(size=size)
61
+ except TypeError:
62
+ return ImageFont.load_default()
63
+
64
+
65
+ def parse_time(value: str) -> float:
66
+ """'12.5', '00:12.50' or '1:02:03.5' -> seconds."""
67
+ text = str(value).strip().replace(",", ".")
68
+ if re.fullmatch(r"-?\d+(\.\d+)?", text):
69
+ return float(text)
70
+ parts = text.split(":")
71
+ if not all(re.fullmatch(r"\d+(\.\d+)?", p) for p in parts) or len(parts) > 3:
72
+ raise argparse.ArgumentTypeError(f"tempo inválido: {value!r} (use segundos, ex. 12.5, ou mm:ss.cc)")
73
+ total = 0.0
74
+ for p in parts:
75
+ total = total * 60 + float(p)
76
+ return total
77
+
78
+
79
+ def extract_frame(video: Path | str, t: float, width: int | None = None, height: int | None = None,
80
+ vf: str | None = None, fps: float | None = None, duration: float | None = None,
81
+ hdr: bool | None = None) -> Image.Image:
82
+ """Decode the frame shown at time `t` (nearest frame), upright, as an RGB PIL image.
83
+
84
+ The seek lands half a frame early so the frame whose timestamp is closest to `t`
85
+ is the first one kept (ffmpeg drops frames that start before the seek point).
86
+ `vf` is an extra filter chain applied before scaling (e.g. a grade).
87
+ `hdr`: True converts an HDR source to SDR with the same chain as render_cut (so colors match
88
+ the cut); None probes the file; False means the caller already handled it (or SDR).
89
+ """
90
+ info = None
91
+ if fps is None or duration is None or hdr is None:
92
+ info = probe_video(video)
93
+ fps = fps or info.get("fps") or 30.0
94
+ duration = duration if duration is not None else info.get("duration") or 0.0
95
+ if hdr is None:
96
+ hdr = bool(info.get("is_hdr"))
97
+ t = max(0.0, float(t))
98
+ if duration:
99
+ t = min(t, max(0.0, duration - 1.0 / fps))
100
+ seek = max(0.0, t - 0.5 / fps)
101
+ chain: list[str] = []
102
+ if hdr:
103
+ from grade import hdr_to_sdr_chain
104
+ chain.append(hdr_to_sdr_chain({"is_hdr": True}))
105
+ if vf:
106
+ chain.append(vf)
107
+ if width or height:
108
+ chain.append(f"scale={width or -2}:{height or -2}:flags=bicubic")
109
+ chain.append("format=rgb24")
110
+ cmd = ["ffmpeg", "-v", "error", "-nostdin", "-ss", f"{seek:.4f}", "-i", str(video),
111
+ "-frames:v", "1", "-an", "-vf", ",".join(chain), "-f", "image2pipe", "-c:v", "png", "-"]
112
+ proc = subprocess.run(cmd, capture_output=True)
113
+ if proc.returncode != 0 or not proc.stdout:
114
+ err = proc.stderr.decode("utf-8", "replace")[-400:]
115
+ raise RuntimeError(f"não consegui extrair o quadro em {t:.3f}s de {Path(video).name}: {err}")
116
+ return Image.open(io.BytesIO(proc.stdout)).convert("RGB")
117
+
118
+
119
+ def extract_frames(video: Path | str, times: list[float], width: int | None = None, height: int | None = None,
120
+ vf: str | None = None, workers: int = 4) -> list[Image.Image]:
121
+ info = probe_video(video)
122
+ fps, dur, hdr = info.get("fps") or 30.0, info.get("duration") or 0.0, bool(info.get("is_hdr"))
123
+ with ThreadPoolExecutor(max_workers=max(1, workers)) as pool:
124
+ return list(pool.map(lambda t: extract_frame(video, t, width, height, vf, fps, dur, hdr), times))
125
+
126
+
127
+ def abspath(path: Path | str) -> Path:
128
+ """Absolute path WITHOUT resolving symlinks (a symlinked source keeps its own folder)."""
129
+ return Path(os.path.abspath(os.path.expanduser(str(path))))
130
+
131
+
132
+ def default_verify_dir(video: Path) -> Path:
133
+ """<edit>/verify for a file inside an edit dir, else <video dir>/edit/verify."""
134
+ video = abspath(video)
135
+ if video.parent.name == "edit" or (video.parent / "state.json").exists() or (video.parent / "edl.json").exists():
136
+ return video.parent / "verify"
137
+ return video.parent / "edit" / "verify"
138
+
139
+
140
+ def find_timeline(video: Path) -> Path | None:
141
+ """cut.mp4 -> cut_timeline.json next to it; fallback edit/cut_timeline.json."""
142
+ video = abspath(video)
143
+ for cand in (video.with_name(f"{video.stem}_timeline.json"), video.parent / "cut_timeline.json"):
144
+ if cand.exists():
145
+ return cand
146
+ return None
147
+
148
+
149
+ def junction_times(timeline: dict) -> tuple[list[float], list[str]]:
150
+ fps = float(timeline.get("fps") or 30)
151
+ segs = timeline.get("segments") or []
152
+ times: list[float] = []
153
+ labels: list[str] = []
154
+ for a, b in zip(segs, segs[1:]):
155
+ a_end = int(a.get("out_end_frame", round(float(a["out_end"]) * fps)))
156
+ b_start = int(b.get("out_start_frame", round(float(b["out_start"]) * fps)))
157
+ for k, lab in ((a_end - 2, f"{a['id']} penúltimo"), (a_end - 1, f"{a['id']} último"),
158
+ (b_start, f"{b['id']} primeiro"), (b_start + 1, f"{b['id']} segundo")):
159
+ times.append(max(0, k) / fps)
160
+ labels.append(f"{lab} · q{max(0, k)}")
161
+ return times, labels
162
+
163
+
164
+ def fit_label(d: ImageDraw.ImageDraw, text: str, max_w: int, size: int = 16):
165
+ """Cut the middle of a label that does not fit, never the ' · qNNN' tail."""
166
+ font = ui_font(size)
167
+ if d.textlength(text, font=font) <= max_w:
168
+ return text, font
169
+ head, sep, tail = text.rpartition(" · ")
170
+ if not sep:
171
+ head, tail, sep = text, "", ""
172
+ while head and d.textlength(f"{head}…{sep}{tail}", font=font) > max_w:
173
+ head = head[:-1]
174
+ return (f"{head}…{sep}{tail}" if head else tail or text), font
175
+
176
+
177
+ def build_sheet(frames: list[Image.Image], labels: list[str], cols: int, title: str) -> Image.Image:
178
+ cols = max(1, min(cols, len(frames)))
179
+ rows = math.ceil(len(frames) / cols)
180
+ tw, th = frames[0].size
181
+ gap, pad, head, lab_h = 10, 18, 58, 34
182
+ f_title = ui_font(24, bold=True)
183
+ probe = ImageDraw.Draw(Image.new("RGB", (1, 1)))
184
+ W = max(pad * 2 + cols * tw + (cols - 1) * gap, int(probe.textlength(title, font=f_title)) + pad * 2)
185
+ H = head + pad + rows * (th + lab_h) + (rows - 1) * gap + pad
186
+ sheet = Image.new("RGB", (W, H), BG)
187
+ d = ImageDraw.Draw(sheet)
188
+ size = 16
189
+ while size > 12 and any(d.textlength(lab, font=ui_font(size)) > tw - 12 for lab in labels):
190
+ size -= 1
191
+ d.text((pad, 16), title, fill=FG, font=f_title)
192
+ for i, (img, lab) in enumerate(zip(frames, labels)):
193
+ r, c = divmod(i, cols)
194
+ x = pad + c * (tw + gap)
195
+ y = head + pad + r * (th + lab_h + gap)
196
+ sheet.paste(img, (x, y))
197
+ d.rectangle((x, y + th, x + tw - 1, y + th + lab_h - 1), fill=PANEL)
198
+ text, font = fit_label(d, lab, tw - 12, size)
199
+ d.text((x + 6, y + th + 8), text, fill=FG, font=font)
200
+ return sheet
201
+
202
+
203
+ def main() -> int:
204
+ ap = argparse.ArgumentParser(
205
+ description="Monta uma folha com vários quadros do vídeo, cada um com o tempo marcado.")
206
+ ap.add_argument("video", type=Path, help="vídeo (fonte ou cut.mp4)")
207
+ ap.add_argument("--times", nargs="+", type=parse_time, help="tempos em segundos (ou mm:ss.cc)")
208
+ ap.add_argument("--junctions", action="store_true",
209
+ help="usa o cut_timeline.json ao lado do vídeo: 2 quadros antes e 2 depois de cada junção")
210
+ ap.add_argument("--timeline", type=Path, help="caminho do timeline (padrão: <nome>_timeline.json ao lado do vídeo)")
211
+ ap.add_argument("-o", "--output", type=Path, help="PNG de saída (padrão: edit/verify/contact_<nome>.png)")
212
+ ap.add_argument("--cols", type=int, default=4, help="colunas (padrão 4)")
213
+ ap.add_argument("--tile-width", type=int, default=0, help="largura de cada quadro em px (padrão automático)")
214
+ args = ap.parse_args()
215
+
216
+ video = abspath(args.video)
217
+ if not video.exists():
218
+ print(f"erro: vídeo não encontrado: {video}", file=sys.stderr)
219
+ return 2
220
+ try:
221
+ info = probe_video(video)
222
+ except Exception as exc: # noqa: BLE001
223
+ print(f"erro: não consegui ler o vídeo {video.name}: {exc}", file=sys.stderr)
224
+ return 2
225
+ if not info.get("has_video"):
226
+ print(f"erro: {video.name} não tem trilha de vídeo", file=sys.stderr)
227
+ return 2
228
+
229
+ labels: list[str]
230
+ single = False
231
+ if args.junctions:
232
+ tl_path = args.timeline or find_timeline(video)
233
+ if not tl_path or not Path(tl_path).exists():
234
+ print("erro: timeline não encontrado. Rode render_cut.py antes ou passe --timeline.", file=sys.stderr)
235
+ return 2
236
+ timeline = load_json(tl_path)
237
+ if len(timeline.get("segments") or []) < 2:
238
+ # One range: no junction, but the cut still needs a look (black, crooked, cropped frames).
239
+ single = True
240
+ fps = float(timeline.get("fps") or info.get("fps") or 30.0)
241
+ last = max(0, int(timeline.get("frames") or round(float(info.get("duration") or 0) * fps)) - 1)
242
+ frame_ids = sorted({0, round(last * 0.25), round(last * 0.5), round(last * 0.75), last})
243
+ times = [k / fps for k in frame_ids]
244
+ labels = [f"{fmt_time(t)} · q{k}" for t, k in zip(times, frame_ids)]
245
+ print("o corte tem só um trecho, sem junções: mostrando o começo, o meio e o fim")
246
+ else:
247
+ times, labels = junction_times(timeline)
248
+ elif args.times:
249
+ times = list(args.times)
250
+ fps = info.get("fps") or 30.0
251
+ labels = [f"{fmt_time(t)} · q{int(round(t * fps))}" for t in times]
252
+ else:
253
+ print("erro: passe --times t1 t2 ... ou --junctions", file=sys.stderr)
254
+ return 2
255
+
256
+ dw, dh = info.get("display_width") or 1080, info.get("display_height") or 1920
257
+ tile_w = args.tile_width or (230 if dh > dw else 400)
258
+ tile_w -= tile_w % 2
259
+ try:
260
+ frames = extract_frames(video, times, width=tile_w)
261
+ except RuntimeError as exc:
262
+ print(f"erro: {exc}", file=sys.stderr)
263
+ return 1
264
+ th = max(f.size[1] for f in frames)
265
+ frames = [f if f.size == (tile_w, th) else f.resize((tile_w, th)) for f in frames]
266
+ title = f"{video.name} · {len(frames)} quadros"
267
+ if args.junctions and not single:
268
+ title = f"{video.name} · junções ({len(frames) // 4})"
269
+ elif single:
270
+ title = f"{video.name} · um trecho só: começo, meio e fim"
271
+ sheet = build_sheet(frames, labels, 5 if single else 4 if args.junctions else args.cols, title)
272
+
273
+ out = args.output or default_verify_dir(video) / f"contact_{video.stem}.png"
274
+ out = abspath(out)
275
+ out.parent.mkdir(parents=True, exist_ok=True)
276
+ sheet.save(out, "PNG", optimize=True)
277
+ print(f"folha salva: {out} ({len(frames)} quadros)")
278
+ return 0
279
+
280
+
281
+ if __name__ == "__main__":
282
+ sys.exit(main())