primocode 10.16.0 → 10.17.1

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 (237) 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 +28 -8
  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/mcp.js +105 -7
  222. package/lib/nuvem.js +11 -1
  223. package/lib/outra-pasta.js +1 -1
  224. package/lib/passagem.js +50 -0
  225. package/lib/perigo.js +29 -12
  226. package/lib/protecao.js +134 -0
  227. package/lib/sandbox.js +11 -4
  228. package/lib/titulo-conversa.js +46 -0
  229. package/lib/tools.js +3 -0
  230. package/lib/ui.js +1 -1
  231. package/package.json +3 -2
  232. package/skills/editar-meu-video/SKILL.md +6 -2
  233. package/skills/editar-video/SKILL.md +113 -0
  234. package/studio/pessoa.py +52 -0
  235. package/voz/primo_voz/app.py +43 -4
  236. package/voz/primo_voz/chat.html +17 -6
  237. package/voz/primo_voz/painel_dados.py +134 -0
@@ -0,0 +1,1256 @@
1
+ # Adapted from browser-use/video-use (MIT, (c) 2026 Browser Use). See THIRD_PARTY_NOTICES.md.
2
+ """EDL -> cut.mp4 + cut_timeline.json (Contract 5 of SPEC.md).
3
+
4
+ Usage:
5
+ uv run python helpers/render_cut.py <edit>/edl.json [-o cut.mp4] [--jobs N] [--no-jcut] [--preview]
6
+
7
+ Pipeline
8
+ 1. Plan: the audio of a range starts exactly at the EDL start. The picture starts on the
9
+ nearest source frame when one is within half an output frame, else at the exact time
10
+ (sparse VFR sources such as screen recordings: the frame on screen at that time is used).
11
+ Lengths are whole output frames. J-cut per junction: the incoming picture skips
12
+ `lead_frames` while its audio keeps them (so it starts earlier, over the outgoing
13
+ picture); the outgoing tail loses up to `tail_trim_frames` only where there is no
14
+ measured speech (edit/regions/<stem>.json, or an inline silencedetect when missing).
15
+ A range with "dry": false keeps its written edges: no lead and no tail trim on it (a video playing
16
+ on screen starts on its first frame).
17
+ A source whose shape differs from the output is cropped around `focus_x`/`focus_y` of
18
+ the range, or around the face found by YuNet when the crop is large.
19
+ "imagem_de": "CAM2" on a range: the audio still comes from the range's source and the picture
20
+ from CAM2 at the same instant, through sincronia.json (sincronizar_cameras.py; the EDL key
21
+ "sincronia" points to another file). A source used only as audio (a lapela .wav) needs no video.
22
+ Two ranges in a row from the same audio source that touch (start of the second within half a
23
+ frame of the end of the first) are one continuous voice: no J-cut and no 30 ms fades there, and
24
+ the second one's audio starts on the exact sample where the first one's ends (a camera switch in
25
+ the middle of a sentence does not dip or skip). Their gain_db must match, or the fades stay.
26
+ 2. Extract, in parallel, one picture clip (H.264, final quality, upright, scaled+cropped,
27
+ fps, HDR->SDR, 8-bit guard, grade, bt709/tv tags) and one float WAV (48k stereo,
28
+ gain+limiter, 30 ms fades, sample-exact length) per range into edit/clips/ (cached).
29
+ 3. Count the encoded frames of each clip -> out_start/out_end (frame-accurate).
30
+ 4. Picture: concat demuxer, stream copy. Audio: adelay in samples + amix normalize=0,
31
+ loudnorm two-pass (I=-14, TP=-1, LRA=11), AAC 192k, length pinned with -t. The true peak
32
+ of the encoded file is measured; above -1.05 dBTP (a margin for verify_cut's ebur128 reading)
33
+ the audio is lowered and encoded again.
34
+ 5. Write <output stem>_timeline.json (cut_timeline.json by default). A full render inside
35
+ the edit dir also merges state.json (awaiting stays null: verify_cut asks for approval).
36
+ `--preview` writes cut_preview.mp4 and leaves cut.mp4 and state.json alone.
37
+ """
38
+
39
+ from __future__ import annotations
40
+
41
+ import argparse
42
+ import hashlib
43
+ import json
44
+ import math
45
+ import os
46
+ import re
47
+ import subprocess
48
+ import sys
49
+ import time
50
+ from concurrent.futures import ThreadPoolExecutor
51
+ from dataclasses import dataclass, field
52
+ from fractions import Fraction
53
+ from pathlib import Path
54
+
55
+ from functools import lru_cache
56
+
57
+ from common import (ASSETS_DIR, OUTPUT_FORMATS, file_signature, fmt_time, load_json, nominal_fps, now_iso,
58
+ output_fps_for, probe_video, require_binary, save_json)
59
+ from common import has_ffmpeg_filter as _has_ffmpeg_filter
60
+ from grade import check_filter, graded_chain, hdr_to_sdr_chain, resolve_grade
61
+
62
+ SR = 48000
63
+ FADE_SAMPLES = 1440 # 30 ms
64
+ SPEECH_GUARD_S = 0.10 # the tail trim never gets closer than this to measured speech (a weak
65
+ # final consonant sits under the speech regions; 0.04 bit it)
66
+ MIN_JOIN_GAP_S = 0.10 # silence kept between outgoing and incoming speech after a J-cut
67
+ SILENCE_NOISE_DB = -35
68
+ MIN_PIC_FRAMES = 6
69
+ DEFAULT_JCUT = {"lead_frames": 4, "tail_trim_frames": 2}
70
+ LOUDNORM = {"I": -14.0, "TP": -1.0, "LRA": 11.0}
71
+ TRUE_PEAK_LIMIT = -1.0 # delivered cut.mp4 (after AAC) must not go above this
72
+ TRUE_PEAK_MARGIN = 0.05 # loudnorm (here) and ebur128 (verify_cut) read the true peak a few hundredths apart
73
+ CACHE_VERSION = "render_cut/2"
74
+ SYNC_FILE = "sincronia.json"
75
+ BT709_TAGS = "setparams=range=tv:color_primaries=bt709:color_trc=bt709:colorspace=bt709"
76
+ ASPECT_WARN = 1.15 # source/output shape ratio beyond this: warn and frame on the face
77
+ CROP_MIN = 0.02 # crops smaller than this are just "fill", not recorded
78
+ REPEAT_TOL_S = 0.02
79
+ FACE_MODEL = ASSETS_DIR / "models" / "face_detection_yunet_2023mar.onnx"
80
+ FACE_SAMPLES = 5
81
+
82
+
83
+ @lru_cache(maxsize=None)
84
+ def has_ffmpeg_filter(name: str) -> bool:
85
+ return _has_ffmpeg_filter(name)
86
+
87
+
88
+ class CutError(Exception):
89
+ def __init__(self, message: str, code: int = 1):
90
+ super().__init__(message)
91
+ self.code = code
92
+
93
+
94
+ def log(msg: str = "") -> None:
95
+ print(msg, flush=True)
96
+
97
+
98
+ def warn(msg: str) -> None:
99
+ print(f"aviso: {msg}", file=sys.stderr, flush=True)
100
+
101
+
102
+ def ffmpeg(cmd: list[str], what: str) -> subprocess.CompletedProcess:
103
+ proc = subprocess.run([str(c) for c in cmd], capture_output=True, text=True)
104
+ if proc.returncode != 0:
105
+ raise CutError(f"o ffmpeg falhou ao {what}:\n{(proc.stderr or '').strip()[-1500:]}")
106
+ return proc
107
+
108
+
109
+ def edl_signature(edl: dict) -> str:
110
+ return hashlib.sha1(json.dumps(edl, sort_keys=True, ensure_ascii=False).encode("utf-8")).hexdigest()[:16]
111
+
112
+
113
+ # ------------------------------------------------------------------ EDL loading
114
+
115
+
116
+ def resolve_source(value: str, edit_dir: Path) -> Path:
117
+ p = Path(value).expanduser()
118
+ if p.is_absolute():
119
+ return p
120
+ for base in (edit_dir, edit_dir.parent):
121
+ cand = Path(os.path.abspath(base / p))
122
+ if cand.exists():
123
+ return cand
124
+ return Path(os.path.abspath(edit_dir / p))
125
+
126
+
127
+ def load_edl(path: Path) -> dict:
128
+ if not path.exists():
129
+ raise CutError(f"EDL não encontrado: {path}", 2)
130
+ try:
131
+ edl = json.loads(path.read_text(encoding="utf-8"))
132
+ except json.JSONDecodeError as exc:
133
+ raise CutError(f"o EDL não é um JSON válido ({path.name}, linha {exc.lineno}): {exc.msg}", 2)
134
+ if not isinstance(edl, dict):
135
+ raise CutError("o EDL precisa ser um objeto JSON", 2)
136
+ ranges = edl.get("ranges")
137
+ if not isinstance(ranges, list) or not ranges:
138
+ raise CutError("o EDL não tem trechos em \"ranges\"", 2)
139
+ sources = edl.get("sources") or {}
140
+ if not isinstance(sources, dict) or not sources:
141
+ raise CutError("o EDL não tem \"sources\" (mapa nome -> caminho do vídeo)", 2)
142
+ seen: set[str] = set()
143
+ for i, r in enumerate(ranges):
144
+ rid = str(r.get("id") or f"r{i + 1}")
145
+ r["id"] = rid
146
+ if rid in seen:
147
+ raise CutError(f"id repetido no EDL: {rid}", 2)
148
+ seen.add(rid)
149
+ if r.get("source") not in sources:
150
+ raise CutError(f"trecho {rid}: fonte {r.get('source')!r} não está em \"sources\"", 2)
151
+ try:
152
+ r["start"], r["end"] = float(r["start"]), float(r["end"])
153
+ except (KeyError, TypeError, ValueError):
154
+ raise CutError(f"trecho {rid}: start/end precisam ser números em segundos", 2)
155
+ if r["start"] < 0 or r["end"] <= r["start"]:
156
+ raise CutError(f"trecho {rid}: end ({r['end']}) precisa ser maior que start ({r['start']})", 2)
157
+ try:
158
+ r["gain_db"] = float(r.get("gain_db") or 0.0)
159
+ except (TypeError, ValueError):
160
+ raise CutError(f"trecho {rid}: gain_db inválido", 2)
161
+ for k in ("focus_x", "focus_y"):
162
+ if r.get(k) is None:
163
+ continue
164
+ try:
165
+ r[k] = float(r[k])
166
+ except (TypeError, ValueError):
167
+ raise CutError(f"trecho {rid}: {k} precisa ser um número de 0 a 1", 2)
168
+ if not 0.0 <= r[k] <= 1.0:
169
+ raise CutError(f"trecho {rid}: {k} precisa ficar entre 0 e 1 (0 = esquerda/topo, 1 = direita/base)", 2)
170
+ pic = r.get("imagem_de")
171
+ if pic in (None, "", False) or pic == r["source"]:
172
+ r["imagem_de"] = None
173
+ elif not isinstance(pic, str) or pic not in sources:
174
+ raise CutError(f"trecho {rid}: imagem_de {pic!r} não está em \"sources\" (ponha a câmera lá, com o caminho)", 2)
175
+ return edl
176
+
177
+
178
+ def picture_key(r: dict) -> str:
179
+ """The source the picture of a range comes from."""
180
+ return r.get("imagem_de") or r["source"]
181
+
182
+
183
+ def load_sync(edl: dict, edit_dir: Path, probes: dict) -> tuple[dict[str, tuple[float, float]], Path | None]:
184
+ """{source: (offset, scale)} from sincronia.json for every source a range with imagem_de touches.
185
+
186
+ Rule of the file: time in the source = deslocamento + ajuste_s + escala x time in the main one."""
187
+ needed = sorted({k for r in edl["ranges"] if r.get("imagem_de") for k in (r["source"], r["imagem_de"])})
188
+ if not needed:
189
+ return {}, None
190
+ raw = edl.get("sincronia") or SYNC_FILE
191
+ path = resolve_source(str(raw), edit_dir)
192
+ if not path.exists():
193
+ raise CutError(f"trechos com imagem_de precisam da sincronia das câmeras e {path} não existe. Rode "
194
+ f"helpers/sincronizar_cameras.py --edl <EDIT>/edl_manual.json (ou ponha \"sincronia\" no EDL)", 2)
195
+ doc = load_json(path)
196
+ fontes = doc.get("fontes") if isinstance(doc, dict) else None
197
+ if not isinstance(fontes, dict):
198
+ raise CutError(f"{path.name} não é um arquivo de sincronia (falta \"fontes\")", 2)
199
+ out: dict[str, tuple[float, float]] = {}
200
+ for key in needed:
201
+ f = fontes.get(key)
202
+ if not isinstance(f, dict):
203
+ raise CutError(f"a fonte {key} não está em {path.name}: rode sincronizar_cameras.py com ela junto", 2)
204
+ try:
205
+ off = float(f.get("deslocamento") or 0.0) + float(f.get("ajuste_s") or 0.0)
206
+ scale = float(f.get("escala") or 1.0)
207
+ except (TypeError, ValueError):
208
+ raise CutError(f"{path.name}: deslocamento, ajuste_s ou escala de {key} não é número", 2)
209
+ if not 0.9 < scale < 1.1:
210
+ raise CutError(f"{path.name}: escala {scale} de {key} fora do razoável", 2)
211
+ src = probes[key]["path"]
212
+ measured = Path(str(f.get("arquivo") or ""))
213
+ same_path = measured.exists() and os.path.samefile(measured, src)
214
+ same_sig = f.get("assinatura") == file_signature(src)
215
+ if not same_path and not same_sig:
216
+ raise CutError(f"{path.name} mediu {key} em outro arquivo ({measured}), não em {src}: rode "
217
+ "sincronizar_cameras.py de novo", 2)
218
+ if same_path and not same_sig and f.get("assinatura"):
219
+ warn(f"{src.name} mudou depois da sincronia ({path.name}): se foi reexportado, rode sincronizar_cameras.py de novo")
220
+ if f.get("confianca") == "fraca":
221
+ warn(f"a sincronia de {key} saiu fraca em {path.name}: confira no olho (sincronizar_cameras.py --prova)")
222
+ out[key] = (off, scale)
223
+ return out, path
224
+
225
+
226
+ def jcut_settings(edl: dict, no_jcut: bool) -> dict:
227
+ raw = edl.get("jcut", None)
228
+ if no_jcut or raw is False:
229
+ return {"enabled": False, "lead_frames": 0, "tail_trim_frames": 0}
230
+ cfg = dict(DEFAULT_JCUT)
231
+ if isinstance(raw, dict):
232
+ for k in cfg:
233
+ if raw.get(k) is not None:
234
+ cfg[k] = max(0, int(raw[k]))
235
+ return {"enabled": cfg["lead_frames"] > 0 or cfg["tail_trim_frames"] > 0, **cfg}
236
+
237
+
238
+ # ------------------------------------------------------------------ source analysis
239
+
240
+
241
+ def source_frame_times(src: Path, t: float, before: float = 3.0, after: float = 3.0) -> list[float]:
242
+ """Presentation times of the source frames around t (packets, no decoding).
243
+
244
+ The window is wide and the list is sorted: with B-frames the packets come in decode order,
245
+ and ffprobe stops reading at the first packet past the window end, so a narrow window
246
+ loses frames that sit just before t.
247
+ """
248
+ proc = subprocess.run(
249
+ ["ffprobe", "-v", "error", "-select_streams", "v:0", "-read_intervals",
250
+ f"{max(0.0, t - before):.3f}%{t + after:.3f}", "-show_entries", "packet=pts_time",
251
+ "-of", "csv=p=0", str(src)], capture_output=True, text=True)
252
+ out = set()
253
+ for line in proc.stdout.splitlines():
254
+ try:
255
+ out.add(float(line.strip().strip(",")))
256
+ except ValueError:
257
+ continue
258
+ return sorted(out)
259
+
260
+
261
+ def picture_start(src: Path, t: float, fps: int) -> tuple[float, bool]:
262
+ """(source time of the first picture frame, snapped?).
263
+
264
+ Snaps to the nearest real frame only when it is within half an output frame. A sparse VFR
265
+ source (static screen recording) has no frame near t: the exact time is kept and the
266
+ extraction shows the frame that is on screen at t.
267
+ """
268
+ times = source_frame_times(src, t)
269
+ if times:
270
+ near = min(times, key=lambda x: abs(x - t))
271
+ if abs(near - t) <= 0.5 / fps + 1e-6:
272
+ return near, True
273
+ return t, False
274
+
275
+
276
+ def nominal_rate(src: Path, info: dict) -> float | None:
277
+ """Nominal fps of a source for choosing the output fps; None for sparse or variable sources
278
+ (screen recordings write frames only when the screen changes) that must not drag it down."""
279
+ avg = float(info.get("fps") or 0.0)
280
+ proc = subprocess.run(["ffprobe", "-v", "error", "-select_streams", "v:0", "-show_entries",
281
+ "stream=r_frame_rate", "-of", "csv=p=0", str(src)], capture_output=True, text=True)
282
+ try:
283
+ r = float(Fraction(proc.stdout.strip().split(",")[0]))
284
+ except (ValueError, ZeroDivisionError):
285
+ r = 0.0
286
+ if avg <= 0 or (r > 0 and avg < 0.8 * r):
287
+ return None
288
+ n = nominal_fps(avg)
289
+ return n if n >= 23.0 else None
290
+
291
+
292
+ def choose_output_fps(probes: dict) -> int:
293
+ rates = [nominal_rate(pr["path"], pr["info"]) for pr in probes.values()]
294
+ rates = [r for r in rates if r]
295
+ return output_fps_for(max(rates)) if rates else 30
296
+
297
+
298
+ def crop_geometry(info: dict, W: int, H: int) -> tuple[str | None, float, float]:
299
+ """(axis cropped "x"/"y" or None, kept fraction of that axis, source/output shape ratio)."""
300
+ dw, dh = info.get("display_width") or W, info.get("display_height") or H
301
+ ratio = (dw / dh) / (W / H)
302
+ if ratio > 1.0 + CROP_MIN:
303
+ return "x", 1.0 / ratio, ratio
304
+ if ratio < 1.0 - CROP_MIN:
305
+ return "y", ratio, ratio
306
+ return None, 1.0, ratio
307
+
308
+
309
+ def face_focus(src: Path, t0: float, t1: float) -> tuple[float, float, int] | None:
310
+ """Median center (0..1, upright frame) of the biggest face on FACE_SAMPLES frames of [t0, t1].
311
+
312
+ Returns (x, y, frames with a face), or None when OpenCV or the model is missing."""
313
+ if not FACE_MODEL.exists():
314
+ return None
315
+ os.environ.setdefault("OPENCV_LOG_LEVEL", "ERROR")
316
+ try:
317
+ import cv2
318
+ import numpy as np
319
+ except ImportError:
320
+ return None
321
+ xs, ys = [], []
322
+ for i in range(FACE_SAMPLES):
323
+ t = t0 + (i + 0.5) * (t1 - t0) / FACE_SAMPLES
324
+ proc = subprocess.run(["ffmpeg", "-v", "error", "-nostdin", "-ss", f"{t:.3f}", "-i", str(src), "-frames:v", "1",
325
+ "-an", "-vf", "scale=640:-2", "-f", "image2pipe", "-c:v", "png", "-"], capture_output=True)
326
+ if proc.returncode != 0 or not proc.stdout:
327
+ continue
328
+ img = cv2.imdecode(np.frombuffer(proc.stdout, np.uint8), cv2.IMREAD_COLOR)
329
+ if img is None:
330
+ continue
331
+ h, w = img.shape[:2]
332
+ try:
333
+ det = cv2.FaceDetectorYN.create(str(FACE_MODEL), "", (w, h), 0.6, 0.3, 50)
334
+ _, faces = det.detect(img)
335
+ except cv2.error:
336
+ return None
337
+ if faces is None or not len(faces):
338
+ continue
339
+ x, y, fw, fh = max(faces, key=lambda f: f[2] * f[3])[:4]
340
+ xs.append(min(1.0, max(0.0, (x + fw / 2) / w)))
341
+ ys.append(min(1.0, max(0.0, (y + fh / 2) / h)))
342
+ if not xs:
343
+ return 0.5, 0.5, 0
344
+ return float(np.median(xs)), float(np.median(ys)), len(xs)
345
+
346
+
347
+ def parse_silences(stderr: str, t0: float, t1: float) -> list[list[float]]:
348
+ sil: list[list[float]] = []
349
+ cur = None
350
+ for line in stderr.splitlines():
351
+ m = re.search(r"silence_start: (-?[\d.]+)", line)
352
+ if m:
353
+ cur = max(0.0, float(m.group(1)))
354
+ continue
355
+ m = re.search(r"silence_end: (-?[\d.]+)", line)
356
+ if m and cur is not None:
357
+ sil.append([t0 + cur, t0 + float(m.group(1))])
358
+ cur = None
359
+ if cur is not None:
360
+ sil.append([t0 + cur, t1])
361
+ return sil
362
+
363
+
364
+ def _intersect(a: list, b: list) -> list:
365
+ out, i, j = [], 0, 0
366
+ while i < len(a) and j < len(b):
367
+ lo, hi = max(a[i][0], b[j][0]), min(a[i][1], b[j][1])
368
+ if hi > lo:
369
+ out.append([lo, hi])
370
+ if a[i][1] < b[j][1]:
371
+ i += 1
372
+ else:
373
+ j += 1
374
+ return out
375
+
376
+
377
+ def speech_in_window(edit_dir: Path, stem: str, src: Path, info: dict, t0: float, t1: float) -> tuple[list, str]:
378
+ """Measured speech intervals inside [t0, t1] of a source.
379
+
380
+ edit/regions/<stem>.json is the authority when present and made from this very file (same
381
+ source_sig); its coarse min_silence (0.25 s)
382
+ hides the short pauses editors cut at, so the edge window is refined with a fine
383
+ silencedetect (same noise floor, 0.08 s) and the two are intersected. Without a regions
384
+ file only the fine pass is used (noise -35 dB).
385
+ """
386
+ t0 = max(0.0, t0)
387
+ if not info.get("has_audio") or t1 - t0 <= 0.01:
388
+ return [], "sem áudio"
389
+ reg = load_json(edit_dir / "regions" / f"{stem}.json")
390
+ reg_spans = None
391
+ noise = SILENCE_NOISE_DB
392
+ if isinstance(reg, dict) and isinstance(reg.get("speech"), list) and "start" not in reg:
393
+ # regions of another file with the same name (video.mov + video.mp4) or of an older copy do not count
394
+ same_file = reg.get("source_sig") in (None, file_signature(src))
395
+ if same_file and abs(float(reg.get("duration") or info["duration"]) - info["duration"]) < 0.2:
396
+ reg_spans = [[max(t0, float(a)), min(t1, float(b))] for a, b in reg["speech"]
397
+ if float(b) > t0 and float(a) < t1]
398
+ try:
399
+ noise = float(reg.get("noise_db", noise))
400
+ except (TypeError, ValueError):
401
+ pass
402
+ proc = subprocess.run(
403
+ ["ffmpeg", "-hide_banner", "-nostats", "-nostdin", "-ss", f"{t0:.3f}", "-t", f"{t1 - t0:.3f}",
404
+ "-i", str(src), "-vn", "-af", f"silencedetect=n={noise:g}dB:d=0.08", "-f", "null", "-"],
405
+ capture_output=True, text=True)
406
+ sil = parse_silences(proc.stderr, t0, t1)
407
+ fine, cur = [], t0
408
+ for a, b in sil:
409
+ if a > cur + 1e-3:
410
+ fine.append([cur, a])
411
+ cur = max(cur, b)
412
+ if cur < t1 - 1e-3:
413
+ fine.append([cur, t1])
414
+ if reg_spans is None:
415
+ return fine, "silencedetect"
416
+ return _intersect(sorted(reg_spans), fine), "regions+silencedetect"
417
+
418
+
419
+ def channel_mode(src: Path, info: dict) -> str:
420
+ """stereo | mono | left | right | multi. Phase-cancelled stereo -> use the stronger channel."""
421
+ ch = info.get("audio_channels") or 0
422
+ if ch == 1:
423
+ return "mono"
424
+ if ch != 2:
425
+ return "multi"
426
+ proc = subprocess.run(
427
+ ["ffmpeg", "-hide_banner", "-nostats", "-nostdin", "-t", "120", "-i", str(src), "-vn", "-af",
428
+ "pan=3c|c0=c0|c1=c1|c2=0.5*c0+0.5*c1,astats=metadata=0:measure_perchannel=RMS_level:measure_overall=none",
429
+ "-f", "null", "-"], capture_output=True, text=True)
430
+ levels = [float(x) if x not in ("-inf", "inf", "nan") else -120.0
431
+ for x in re.findall(r"RMS level dB: (-?[\w.]+)", proc.stderr)]
432
+ if len(levels) < 3:
433
+ return "stereo"
434
+ left, right, both = levels[:3]
435
+ best = max(left, right)
436
+ if best > -90 and both < best - 6:
437
+ warn(f"{src.name}: estéreo com fase invertida (L+R {both:.1f} dB contra {best:.1f} dB). Usando um canal só.")
438
+ return "left" if left >= right else "right"
439
+ return "stereo"
440
+
441
+
442
+ _HWACCEL_CACHE: dict[str, bool] = {}
443
+
444
+
445
+ def hwaccel_for(src: Path, info: dict) -> bool:
446
+ if os.environ.get("EDITOR_VIDEO_HWACCEL", "1") == "0" or sys.platform != "darwin":
447
+ return False
448
+ codec = info.get("codec", "")
449
+ if codec not in ("hevc", "h264", "prores"):
450
+ return False
451
+ if codec not in _HWACCEL_CACHE:
452
+ hw = subprocess.run(["ffmpeg", "-hide_banner", "-hwaccels"], capture_output=True, text=True).stdout
453
+ ok = "videotoolbox" in hw
454
+ if ok:
455
+ ok = subprocess.run(["ffmpeg", "-v", "error", "-nostdin", "-hwaccel", "videotoolbox", "-i", str(src),
456
+ "-frames:v", "2", "-an", "-f", "null", "-"], capture_output=True).returncode == 0
457
+ _HWACCEL_CACHE[codec] = ok
458
+ return _HWACCEL_CACHE[codec]
459
+
460
+
461
+ # ------------------------------------------------------------------ planning
462
+
463
+
464
+ @dataclass
465
+ class Plan:
466
+ idx: int
467
+ rid: str
468
+ source: str
469
+ src: Path
470
+ info: dict
471
+ edl_start: float
472
+ edl_end: float
473
+ gain_db: float
474
+ beat: str = ""
475
+ quote: str = ""
476
+ pic_source: str = "" # source key of the picture ("imagem_de", else the audio source)
477
+ pic_src: Path = field(default_factory=Path)
478
+ pic_info: dict = field(default_factory=dict)
479
+ pic_a: float = 0.0 # picture time = pic_a + pic_b x audio time (identity without imagem_de)
480
+ pic_b: float = 1.0
481
+ audio_start_s: float | None = None # set when the audio continues the previous range sample for sample
482
+ joined: bool = False # the audio continues from the previous range (no J-cut, no fades between them)
483
+ fade_in: bool = True
484
+ fade_out: bool = True
485
+ lead: int = 0
486
+ tail: int = 0
487
+ pic_start: float = 0.0 # source time of the first picture frame
488
+ pic_snapped: bool = True # pic_start is a real source frame (False: sparse VFR, exact time)
489
+ frames: int = 0 # picture frames
490
+ crop: dict | None = None # {"axis", "kept", "focus_x", "focus_y", "focus_from"} when the shape differs
491
+ speech_src: str = ""
492
+ gap_in: float = 0.0
493
+ gap_out: float = 0.0
494
+ lead_note: str = ""
495
+ key: str = ""
496
+ video_path: Path = field(default_factory=Path)
497
+ audio_path: Path = field(default_factory=Path)
498
+ meta_path: Path = field(default_factory=Path)
499
+ cached: bool = False
500
+ seconds: float = 0.0
501
+
502
+ @property
503
+ def audio_frames(self) -> int:
504
+ return self.lead + self.frames
505
+
506
+ @property
507
+ def audio_start(self) -> float:
508
+ """The audio of a range starts where the EDL says (lead frames included), or where the previous
509
+ range's audio ends when the two are one continuous voice."""
510
+ return self.edl_start if self.audio_start_s is None else self.audio_start_s
511
+
512
+ @property
513
+ def other_picture(self) -> bool:
514
+ return self.pic_source != self.source
515
+
516
+ def to_pic(self, t: float) -> float:
517
+ """Audio-source time -> picture-source time."""
518
+ return self.pic_a + self.pic_b * t
519
+
520
+
521
+ def plan_crops(plans: list[Plan], ranges: list[dict], W: int, H: int, edit_dir: Path, jobs: int) -> None:
522
+ """Crop position of every range whose source shape differs from the output.
523
+
524
+ focus_x/focus_y from the EDL win; a large crop (shape ratio beyond ASPECT_WARN) is framed on
525
+ the median face center of the range; otherwise the crop stays centered.
526
+ """
527
+ cache_path = edit_dir / "clips" / "focus_cache.json"
528
+ cache = load_json(cache_path, default={}) or {}
529
+ warned: set[str] = set()
530
+ todo = []
531
+ for p, r in zip(plans, ranges):
532
+ axis, kept, ratio = crop_geometry(p.pic_info, W, H)
533
+ if axis is None:
534
+ continue
535
+ fx, fy = r.get("focus_x"), r.get("focus_y")
536
+ big = ratio > ASPECT_WARN or ratio < 1.0 / ASPECT_WARN
537
+ if big and p.pic_source not in warned:
538
+ warned.add(p.pic_source)
539
+ dw, dh = p.pic_info.get("display_width"), p.pic_info.get("display_height")
540
+ part = "largura" if axis == "x" else "altura"
541
+ warn(f"{p.pic_src.name} é {dw}x{dh} e a saída é {W}x{H}: o corte guarda só {kept:.0%} da {part}. "
542
+ "Enquadro pelo rosto (ou pelo focus_x/focus_y do trecho); confira na folha de quadros.")
543
+ given = fx if axis == "x" else fy
544
+ p.crop = {"axis": axis, "kept": round(kept, 3), "focus_x": 0.5 if fx is None else fx,
545
+ "focus_y": 0.5 if fy is None else fy, "focus_from": "edl" if given is not None else "centro"}
546
+ if given is None and big:
547
+ key = f"{file_signature(p.pic_src)}|{p.to_pic(p.edl_start):.3f}|{p.to_pic(p.edl_end):.3f}"
548
+ todo.append((p, key, fx, fy))
549
+
550
+ def detect(job):
551
+ p, key, fx, fy = job
552
+ hit = cache.get(key)
553
+ if not (isinstance(hit, list) and len(hit) == 3):
554
+ found = face_focus(p.pic_src, p.to_pic(p.edl_start), p.to_pic(p.edl_end))
555
+ hit = list(found) if found else None
556
+ return job, hit
557
+
558
+ with ThreadPoolExecutor(max_workers=max(1, jobs)) as pool:
559
+ results = list(pool.map(detect, todo))
560
+ for (p, key, fx, fy), hit in results:
561
+ if hit is None:
562
+ warn(f"trecho {p.rid}: não deu para procurar rosto (OpenCV indisponível); corte no centro")
563
+ continue
564
+ cache[key] = hit
565
+ if hit[2] == 0:
566
+ warn(f"trecho {p.rid}: não achei rosto em {p.pic_src.name}; corte no centro. "
567
+ "Ajuste focus_x/focus_y no trecho se precisar.")
568
+ continue
569
+ if p.crop["axis"] == "x":
570
+ p.crop["focus_x"] = round(hit[0], 3)
571
+ else:
572
+ p.crop["focus_y"] = round(hit[1], 3)
573
+ p.crop["focus_from"] = "rosto"
574
+ if results:
575
+ save_json(cache_path, cache)
576
+
577
+
578
+ def plan_cut(edl: dict, edit_dir: Path, fps: int, jcut: dict, probes: dict, jobs: int,
579
+ W: int = 1080, H: int = 1920, sync: dict | None = None) -> list[Plan]:
580
+ ranges = edl["ranges"]
581
+ n = len(ranges)
582
+ sync = sync or {}
583
+ plans: list[Plan] = []
584
+ for i, r in enumerate(ranges):
585
+ src = probes[r["source"]]["path"]
586
+ info = probes[r["source"]]["info"]
587
+ dur = info["duration"]
588
+ if r["start"] >= dur - 1.0 / fps:
589
+ raise CutError(f"trecho {r['id']}: start {r['start']:.2f}s passa do fim de {src.name} ({dur:.2f}s)", 2)
590
+ end = r["end"]
591
+ if end > dur:
592
+ warn(f"trecho {r['id']}: end {end:.2f}s passa do fim de {src.name}; usando {dur:.2f}s")
593
+ end = dur
594
+ p = Plan(i, r["id"], r["source"], src, info, r["start"], end, r["gain_db"],
595
+ str(r.get("beat") or ""), str(r.get("quote") or ""))
596
+ p.pic_source = picture_key(r)
597
+ p.pic_src, p.pic_info = probes[p.pic_source]["path"], probes[p.pic_source]["info"]
598
+ if p.other_picture:
599
+ (oa, sa), (ob, sb) = sync[p.source], sync[p.pic_source]
600
+ p.pic_b = sb / sa
601
+ p.pic_a = ob - p.pic_b * oa
602
+ t0, t1 = p.to_pic(p.edl_start), p.to_pic(end)
603
+ pdur = p.pic_info["duration"]
604
+ if t0 < -0.5 / fps or t1 > pdur + 0.5 / fps:
605
+ raise CutError(f"trecho {p.rid}: a {p.pic_source} não tem imagem de {p.edl_start:.2f} a {end:.2f}s da "
606
+ f"{p.source} (pela sincronia isso é {t0:.2f} a {t1:.2f}s dela, que tem {pdur:.2f}s). "
607
+ "Use outra câmera nesse trecho ou encurte", 2)
608
+ plans.append(p)
609
+ for a, b in zip(plans, plans[1:]):
610
+ if a.source == b.source and b.edl_start < a.edl_end - REPEAT_TOL_S and b.edl_end > a.edl_start:
611
+ warn(f"trecho {b.rid} repete {a.edl_end - b.edl_start:.2f}s do trecho {a.rid} (mesma fonte): "
612
+ f"o start {b.edl_start:.2f}s fica antes do end {a.edl_end:.2f}s do anterior")
613
+ # One continuous voice: the next range picks up the same audio source where this one stops.
614
+ for a, b in zip(plans, plans[1:]):
615
+ if a.source != b.source or abs(b.edl_start - a.edl_end) > 0.5 / fps:
616
+ continue
617
+ if abs(a.gain_db - b.gain_db) > 0.05:
618
+ warn(f"trechos {a.rid} e {b.rid} continuam a mesma fala mas têm gain_db diferente "
619
+ f"({a.gain_db:+.1f} e {b.gain_db:+.1f}): a voz leva um corte de 30 ms ali. Use o mesmo gain_db "
620
+ "nos dois para ela seguir inteira")
621
+ continue
622
+ b.joined = True
623
+ a.fade_out = b.fade_in = False
624
+ plan_crops(plans, ranges, W, H, edit_dir, jobs)
625
+
626
+ lead_req = jcut["lead_frames"] if jcut["enabled"] else 0
627
+ tail_req = jcut["tail_trim_frames"] if jcut["enabled"] else 0
628
+
629
+ # Measured silence at both edges of every range (only needed around junctions).
630
+ def edge_gaps(p: Plan) -> tuple[float, float, str]:
631
+ if not jcut["enabled"] or n == 1:
632
+ return 0.0, 0.0, ""
633
+ stem = Path(p.src).stem
634
+ gap_in = gap_out = 0.0
635
+ how = ""
636
+ if p.idx > 0:
637
+ w1 = min(p.edl_end, p.edl_start + 1.0)
638
+ spans, how = speech_in_window(edit_dir, stem, p.src, p.info, p.edl_start, w1)
639
+ first = min((max(a, p.edl_start) for a, b in spans if b > p.edl_start), default=None)
640
+ gap_in = (w1 - p.edl_start) if first is None else max(0.0, first - p.edl_start)
641
+ if p.idx < n - 1:
642
+ w0 = max(p.edl_start, p.edl_end - 1.0)
643
+ spans, how = speech_in_window(edit_dir, stem, p.src, p.info, w0, p.edl_end)
644
+ last = max((min(b, p.edl_end) for a, b in spans if a < p.edl_end), default=None)
645
+ gap_out = (p.edl_end - w0) if last is None else max(0.0, p.edl_end - last)
646
+ return gap_in, gap_out, how
647
+
648
+ with ThreadPoolExecutor(max_workers=max(2, jobs * 2)) as pool:
649
+ gaps = list(pool.map(edge_gaps, plans))
650
+
651
+ prev: Plan | None = None
652
+ for p, (gap_in, gap_out, how) in zip(plans, gaps):
653
+ p.gap_in, p.gap_out, p.speech_src = gap_in, gap_out, how
654
+ dur = p.pic_info["duration"]
655
+ # "dry": false (a video on screen, a held ending): the edges are the ones written, no J-cut on it.
656
+ whole = not ranges[p.idx].get("dry", True)
657
+ nxt_joined = p.idx < n - 1 and plans[p.idx + 1].joined
658
+ # Outgoing tail: trim only silence, keeping SPEECH_GUARD_S after the last word.
659
+ p.tail = 0
660
+ if tail_req and p.idx < n - 1 and not whole and not nxt_joined:
661
+ p.tail = max(0, min(tail_req, math.floor((gap_out - SPEECH_GUARD_S) * fps + 1e-6)))
662
+ # Incoming lead: the early audio overlaps the outgoing tail, but the two voices must not
663
+ # meet: keep MIN_JOIN_GAP_S of measured silence between them. It also may not reach back
664
+ # before the outgoing picture starts, nor before the source start.
665
+ p.lead = 0
666
+ if lead_req and prev is not None and not whole and not p.joined:
667
+ room = prev.gap_out - prev.tail / fps + gap_in - MIN_JOIN_GAP_S
668
+ p.lead = max(0, min(lead_req, math.floor(room * fps + 1e-6), prev.frames))
669
+ if p.lead < lead_req:
670
+ p.lead_note = "pouco silêncio na junção" if room * fps < lead_req else "trecho anterior curto"
671
+ if p.joined and prev is not None:
672
+ # the audio goes on from the exact sample where the previous range's audio stops
673
+ p.audio_start_s = prev.audio_start + (prev.lead + prev.frames) / fps
674
+ p.pic_start, p.pic_snapped = picture_start(p.pic_src, p.to_pic(p.audio_start + p.lead / fps), fps)
675
+ frames = 0
676
+ for _ in range(4):
677
+ total = min(round((p.to_pic(p.edl_end) - p.pic_start) * fps),
678
+ math.floor((dur - p.pic_start) * fps + 1e-6))
679
+ frames = total - p.tail
680
+ if frames >= MIN_PIC_FRAMES or (p.tail == 0 and p.lead == 0):
681
+ break
682
+ if p.tail:
683
+ p.tail = max(0, p.tail - (MIN_PIC_FRAMES - frames))
684
+ elif p.lead:
685
+ p.lead = 0
686
+ p.pic_start, p.pic_snapped = picture_start(p.pic_src, p.to_pic(p.audio_start), fps)
687
+ if frames < 1:
688
+ raise CutError(f"trecho {p.rid} é curto demais ({p.edl_end - p.edl_start:.3f}s)", 2)
689
+ p.frames = frames
690
+ prev = p
691
+ return plans
692
+
693
+
694
+ # ------------------------------------------------------------------ extraction
695
+
696
+
697
+ def crop_filter(W: int, H: int, crop: dict | None) -> str:
698
+ """crop to WxH, centered, or around the focus point (0..1 of the scaled frame), clamped inside."""
699
+ if not crop or (abs(crop["focus_x"] - 0.5) < 1e-6 and abs(crop["focus_y"] - 0.5) < 1e-6):
700
+ return f"crop={W}:{H}"
701
+ fx, fy = crop["focus_x"], crop["focus_y"]
702
+ return (f"crop={W}:{H}:x=max(0\\,min(iw-ow\\,iw*{fx:.4f}-ow/2))"
703
+ f":y=max(0\\,min(ih-oh\\,ih*{fy:.4f}-oh/2))")
704
+
705
+
706
+ def build_vf(info: dict, W: int, H: int, fps: int, grade_filter: str, crop: dict | None = None) -> str:
707
+ # SDR: convert any matrix (e.g. BT.601 phones) and full range to BT.709 tv while scaling.
708
+ # HDR keeps its matrix here; the shared HDR->SDR chain (grade.hdr_to_sdr_chain) converts it.
709
+ # start_time=0: with -noaccurate_seek the frames before the seek point fill the first slot,
710
+ # so a sparse VFR source shows the frame that is on screen at the start time.
711
+ matrix = "" if info.get("is_hdr") else ":out_color_matrix=bt709"
712
+ parts = [f"fps={fps}:start_time=0",
713
+ f"scale={W}:{H}:force_original_aspect_ratio=increase:flags=lanczos:out_range=tv{matrix}",
714
+ crop_filter(W, H, crop), "setsar=1"]
715
+ hdr = hdr_to_sdr_chain(info)
716
+ if hdr:
717
+ parts.append(hdr)
718
+ parts.append(graded_chain(grade_filter))
719
+ # ffmpeg takes the stream color tags from the frames: stamp them so untagged sources are tagged too.
720
+ parts.append(BT709_TAGS)
721
+ return ",".join(parts)
722
+
723
+
724
+ AUDIO_RESAMPLE = f"aresample={SR}:async=1:first_pts=0"
725
+
726
+
727
+ def channel_filter(mode: str) -> str:
728
+ """How a source's channels become stereo (mode from channel_mode)."""
729
+ return {"mono": "pan=stereo|c0=c0|c1=c0", "left": "pan=stereo|c0=c0|c1=c0",
730
+ "right": "pan=stereo|c0=c1|c1=c1"}.get(mode, "aformat=channel_layouts=stereo")
731
+
732
+
733
+ def af_tail(gain_db: float, samples: int, fade_in: bool = True, fade_out: bool = True) -> list[str]:
734
+ """Gain + limiter, exact length and 30 ms fades of a range (after resampling and channel choice).
735
+ No fade on a side where the voice goes on into the next range (or comes from the previous one).
736
+ compose_final's background-noise removal runs its denoiser before this same tail."""
737
+ parts = []
738
+ if gain_db:
739
+ parts.append(f"volume={gain_db:.2f}dB")
740
+ if gain_db > 0:
741
+ parts.append("alimiter=limit=0.891:attack=5:release=60:level=false:latency=true")
742
+ fade = max(1, min(FADE_SAMPLES, samples // 4))
743
+ parts += [f"apad=whole_len={samples}", f"atrim=end_sample={samples}"]
744
+ if fade_in:
745
+ parts.append(f"afade=t=in:start_sample=0:nb_samples={fade}")
746
+ if fade_out:
747
+ parts.append(f"afade=t=out:start_sample={samples - fade}:nb_samples={fade}")
748
+ parts.append("aformat=sample_fmts=flt:channel_layouts=stereo")
749
+ return parts
750
+
751
+
752
+ def build_af(p: Plan, mode: str, samples: int) -> str:
753
+ return ",".join([AUDIO_RESAMPLE, channel_filter(mode), *af_tail(p.gain_db, samples, p.fade_in, p.fade_out)])
754
+
755
+
756
+ def count_frames(path: Path) -> int:
757
+ proc = subprocess.run(["ffprobe", "-v", "error", "-select_streams", "v:0", "-show_entries", "stream=nb_frames",
758
+ "-of", "csv=p=0", str(path)], capture_output=True, text=True)
759
+ try:
760
+ return int(proc.stdout.strip().split(",")[0])
761
+ except ValueError:
762
+ proc = subprocess.run(["ffprobe", "-v", "error", "-select_streams", "v:0", "-count_packets",
763
+ "-show_entries", "stream=nb_read_packets", "-of", "csv=p=0", str(path)],
764
+ capture_output=True, text=True)
765
+ return int(proc.stdout.strip().split(",")[0] or 0)
766
+
767
+
768
+ def wav_samples(path: Path) -> int:
769
+ """Sample frames in a WAV by walking its chunks (the stdlib `wave` rejects float WAVs)."""
770
+ try:
771
+ with open(path, "rb") as fh:
772
+ if fh.read(4) != b"RIFF":
773
+ return -1
774
+ fh.read(4)
775
+ if fh.read(4) != b"WAVE":
776
+ return -1
777
+ block_align = 0
778
+ while True:
779
+ head = fh.read(8)
780
+ if len(head) < 8:
781
+ return -1
782
+ cid, size = head[:4], int.from_bytes(head[4:], "little")
783
+ if cid == b"fmt ":
784
+ fmt = fh.read(size)
785
+ block_align = int.from_bytes(fmt[12:14], "little")
786
+ continue
787
+ if cid == b"data":
788
+ if size in (0, 0xFFFFFFFF): # streamed header: use the file size
789
+ size = os.path.getsize(path) - fh.tell()
790
+ return size // block_align if block_align else -1
791
+ fh.seek(size + (size & 1), 1)
792
+ except OSError:
793
+ return -1
794
+
795
+
796
+ def extract(p: Plan, cfg: dict) -> Plan:
797
+ t0 = time.time()
798
+ fps, W, H = cfg["fps"], cfg["width"], cfg["height"]
799
+ spf = SR / fps
800
+ src_fps = p.pic_info.get("fps") or fps
801
+ vf = build_vf(p.pic_info, W, H, fps, cfg["grade_filter"], p.crop)
802
+ samples = int(round(p.audio_frames * spf))
803
+ audio_start = p.audio_start
804
+ mode = cfg["channel_modes"].get(p.source, "stereo") if p.info.get("has_audio") else "silent"
805
+ af = build_af(p, mode, samples)
806
+ enc = ["-c:v", "libx264", "-profile:v", "high", "-preset", cfg["x264_preset"], "-crf", str(cfg["crf"]),
807
+ "-pix_fmt", "yuv420p", "-color_primaries", "bt709", "-color_trc", "bt709", "-colorspace", "bt709",
808
+ "-color_range", "tv", "-video_track_timescale", str(fps * 1000), "-movflags", "+faststart"]
809
+ key_src = {"v": CACHE_VERSION, "src": str(p.src), "sig": file_signature(p.src), "pic": round(p.pic_start, 6),
810
+ "snap": p.pic_snapped, "frames": p.frames, "astart": round(audio_start, 6), "samples": samples, "vf": vf, "af": af,
811
+ "enc": enc, "mode": mode}
812
+ if p.other_picture:
813
+ key_src.update(pic_src=str(p.pic_src), pic_sig=file_signature(p.pic_src))
814
+ p.key = hashlib.sha1(json.dumps(key_src, sort_keys=True).encode()).hexdigest()[:12]
815
+ stem = f"{cfg['mode']}_{p.idx:03d}_{re.sub(r'[^A-Za-z0-9_-]', '_', p.rid)}_{p.key}"
816
+ clips = cfg["clips_dir"]
817
+ p.video_path, p.audio_path, p.meta_path = clips / f"{stem}.mp4", clips / f"{stem}.wav", clips / f"{stem}.json"
818
+ meta = load_json(p.meta_path)
819
+ if (isinstance(meta, dict) and meta.get("key") == p.key and p.video_path.exists() and p.audio_path.exists()):
820
+ p.frames = int(meta["frames"])
821
+ p.cached = True
822
+ return p
823
+
824
+ # A snapped start seeks a quarter frame early so that frame lands on the first output slot.
825
+ pic_seek = max(0.0, p.pic_start - (0.25 / max(src_fps, fps) if p.pic_snapped else 0.0))
826
+ tmp_v, tmp_a = p.video_path.with_suffix(".part.mp4"), p.audio_path.with_suffix(".part.wav")
827
+
828
+ def run_extract(hw: bool) -> subprocess.CompletedProcess:
829
+ cmd = ["ffmpeg", "-y", "-v", "error", "-nostdin"]
830
+ if hw:
831
+ cmd += ["-hwaccel", "videotoolbox"]
832
+ cmd += ["-noaccurate_seek", "-ss", f"{pic_seek:.6f}", "-i", str(p.pic_src)]
833
+ if p.info.get("has_audio"):
834
+ cmd += ["-ss", f"{max(0.0, audio_start):.6f}", "-i", str(p.src)]
835
+ amap = "1:a:0"
836
+ else:
837
+ cmd += ["-f", "lavfi", "-i", f"anullsrc=r={SR}:cl=stereo"]
838
+ amap = "1:a:0"
839
+ cmd += ["-map", "0:v:0", "-vf", vf, "-frames:v", str(p.frames), "-an", *enc, "-f", "mp4", str(tmp_v)]
840
+ cmd += ["-map", amap, "-af", af, "-vn", "-c:a", "pcm_f32le", "-ar", str(SR), "-ac", "2",
841
+ "-f", "wav", str(tmp_a)]
842
+ return subprocess.run(cmd, capture_output=True, text=True)
843
+
844
+ use_hw = hwaccel_for(p.pic_src, p.pic_info)
845
+ proc = run_extract(use_hw)
846
+ if proc.returncode != 0 and use_hw:
847
+ warn(f"trecho {p.rid}: decodificação por hardware falhou, tentando por software")
848
+ proc = run_extract(False)
849
+ if proc.returncode != 0:
850
+ tmp_v.unlink(missing_ok=True)
851
+ tmp_a.unlink(missing_ok=True)
852
+ raise CutError(f"o ffmpeg falhou ao extrair o trecho {p.rid}:\n{proc.stderr.strip()[-1500:]}")
853
+
854
+ got = count_frames(tmp_v)
855
+ if got <= 0:
856
+ raise CutError(f"trecho {p.rid}: nenhum quadro extraído de {p.pic_src.name}")
857
+ if got != p.frames:
858
+ warn(f"trecho {p.rid}: esperava {p.frames} quadros e saíram {got}; ajustando o áudio")
859
+ p.frames = got
860
+ samples = int(round(p.audio_frames * spf))
861
+ af = build_af(p, mode, samples)
862
+ cmd = ["ffmpeg", "-y", "-v", "error", "-nostdin"]
863
+ if p.info.get("has_audio"):
864
+ cmd += ["-ss", f"{max(0.0, audio_start):.6f}", "-i", str(p.src), "-map", "0:a:0"]
865
+ else:
866
+ cmd += ["-f", "lavfi", "-i", f"anullsrc=r={SR}:cl=stereo", "-map", "0:a:0"]
867
+ cmd += ["-af", af, "-vn", "-c:a", "pcm_f32le", "-ar", str(SR), "-ac", "2", "-f", "wav", str(tmp_a)]
868
+ ffmpeg(cmd, f"refazer o áudio do trecho {p.rid}")
869
+ got_samples = wav_samples(tmp_a)
870
+ if got_samples >= 0 and got_samples != samples:
871
+ raise CutError(f"trecho {p.rid}: o áudio saiu com {got_samples} amostras, esperava {samples}")
872
+ os.replace(tmp_v, p.video_path)
873
+ os.replace(tmp_a, p.audio_path)
874
+ save_json(p.meta_path, {"key": p.key, "frames": p.frames, "samples": samples, "id": p.rid,
875
+ "source": str(p.src), "picture": str(p.pic_src), "pic_start": p.pic_start,
876
+ "pic_snapped": p.pic_snapped, "audio_start": audio_start})
877
+ p.seconds = time.time() - t0
878
+ return p
879
+
880
+
881
+ # ------------------------------------------------------------------ assembly
882
+
883
+
884
+ def concat_escape(path: Path) -> str:
885
+ return str(path).replace("'", "'\\''")
886
+
887
+
888
+ def mix_audio(plans: list[Plan], fps: int, total_samples: int, out_wav: Path) -> None:
889
+ spf = SR / fps
890
+ cmd = ["ffmpeg", "-y", "-v", "error", "-nostdin"]
891
+ for p in plans:
892
+ cmd += ["-i", str(p.audio_path)]
893
+ chains, labels = [], []
894
+ pos = 0
895
+ for i, p in enumerate(plans):
896
+ delay = int(round((pos - p.lead) * spf))
897
+ if delay < 0:
898
+ raise CutError(f"trecho {p.rid}: atraso de áudio negativo ({delay}); J-cut inconsistente")
899
+ chains.append(f"[{i}:a]adelay=delays={delay}S:all=1[a{i}]")
900
+ labels.append(f"[a{i}]")
901
+ pos += p.frames
902
+ if len(plans) == 1:
903
+ graph = f"{chains[0]};[a0]apad=whole_len={total_samples},atrim=end_sample={total_samples}[out]"
904
+ else:
905
+ graph = (";".join(chains) + ";" + "".join(labels)
906
+ + f"amix=inputs={len(plans)}:normalize=0:duration=longest:dropout_transition=0,"
907
+ + f"apad=whole_len={total_samples},atrim=end_sample={total_samples}[out]")
908
+ cmd += ["-filter_complex", graph, "-map", "[out]", "-c:a", "pcm_f32le", "-ar", str(SR), "-f", "wav", str(out_wav)]
909
+ ffmpeg(cmd, "mixar o áudio")
910
+
911
+
912
+ def loudnorm_measure(wav_path: Path) -> dict | None:
913
+ proc = subprocess.run(
914
+ ["ffmpeg", "-hide_banner", "-nostats", "-nostdin", "-i", str(wav_path), "-af",
915
+ f"loudnorm=I={LOUDNORM['I']}:TP={LOUDNORM['TP']}:LRA={LOUDNORM['LRA']}:print_format=json",
916
+ "-f", "null", "-"], capture_output=True, text=True)
917
+ text = proc.stderr
918
+ start, end = text.rfind("{"), text.rfind("}")
919
+ if start < 0 or end <= start:
920
+ return None
921
+ try:
922
+ data = json.loads(text[start:end + 1])
923
+ except json.JSONDecodeError:
924
+ return None
925
+ try:
926
+ if not math.isfinite(float(data["input_i"])) or float(data["input_i"]) < -70:
927
+ return None
928
+ except (KeyError, ValueError):
929
+ return None
930
+ return data
931
+
932
+
933
+ def loudnorm_modo(measured: dict | None) -> str | None:
934
+ """O modo que o loudnorm de 2 passadas vai usar com estas medidas (a regra do ffmpeg, af_loudnorm.c): linear só
935
+ quando o pico real medido mais o ganho que falta até o alvo não passa do TP. Senão ele cai calado no dinâmico,
936
+ que comprime a voz. (PrimoCode: o portão reprova o dinâmico; ver helpers/ganho_por_trecho.py.)"""
937
+ if not measured:
938
+ return None
939
+ try:
940
+ offset_tp = float(measured["input_tp"]) + (LOUDNORM["I"] - float(measured["input_i"]))
941
+ except (KeyError, ValueError):
942
+ return None
943
+ return "linear" if offset_tp <= LOUDNORM["TP"] else "dinamico"
944
+
945
+
946
+ def true_peak_db(path: Path) -> float | None:
947
+ """True peak (dBTP) of the first audio track with 2 decimals (loudnorm's measurement; ebur128,
948
+ used by verify_cut, prints 1 decimal and hides a -0.96 as -1.0). -120 for silence."""
949
+ proc = subprocess.run(["ffmpeg", "-hide_banner", "-nostats", "-nostdin", "-i", str(path), "-vn", "-af",
950
+ f"loudnorm=I={LOUDNORM['I']}:TP={LOUDNORM['TP']}:LRA={LOUDNORM['LRA']}:print_format=json",
951
+ "-f", "null", "-"], capture_output=True, text=True)
952
+ m = re.search(r'"input_tp"\s*:\s*"(-?inf|-?[\d.]+)"', proc.stderr or "")
953
+ if not m:
954
+ return None
955
+ return -120.0 if "inf" in m.group(1) else float(m.group(1))
956
+
957
+
958
+ # ------------------------------------------------------------------ main
959
+
960
+
961
+ def acquire_lock(clips_dir: Path):
962
+ """One render per edit dir at a time (clips cache and pruning are shared)."""
963
+ fh = open(clips_dir / ".render.lock", "a+")
964
+ try:
965
+ import fcntl
966
+ fcntl.flock(fh.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB)
967
+ except ImportError: # Windows: no advisory lock, run anyway
968
+ pass
969
+ except OSError:
970
+ fh.close()
971
+ raise CutError("já tem um render_cut rodando nesta pasta edit. Espere ele terminar.", 2)
972
+ return fh
973
+
974
+
975
+ def render(args: argparse.Namespace) -> int:
976
+ t_all = time.time()
977
+ require_binary("ffmpeg")
978
+ require_binary("ffprobe")
979
+ edl_path = Path(os.path.abspath(args.edl.expanduser()))
980
+ edl = load_edl(edl_path)
981
+ edl_sig = edl_signature(json.loads(edl_path.read_text(encoding="utf-8"))) # raw file, before normalizing
982
+ edit_dir = edl_path.parent
983
+ jcut = jcut_settings(edl, args.no_jcut)
984
+
985
+ probes: dict[str, dict] = {}
986
+ audio_keys = {r["source"] for r in edl["ranges"]}
987
+ picture_keys = {picture_key(r) for r in edl["ranges"]}
988
+ for key, value in edl["sources"].items():
989
+ if key not in audio_keys and key not in picture_keys:
990
+ continue
991
+ src = resolve_source(str(value), edit_dir)
992
+ if not src.exists():
993
+ raise CutError(f"vídeo da fonte {key!r} não encontrado: {src}", 2)
994
+ try:
995
+ info = probe_video(src)
996
+ except Exception as exc: # noqa: BLE001
997
+ raise CutError(f"não consegui ler {src.name}: {exc}", 2)
998
+ if key in picture_keys and not info.get("has_video"):
999
+ if key in audio_keys:
1000
+ raise CutError(f"{src.name} não tem trilha de vídeo: nos trechos dela, ponha \"imagem_de\" com a "
1001
+ "câmera de onde vem a imagem", 2)
1002
+ raise CutError(f"{src.name} não tem trilha de vídeo", 2)
1003
+ probes[key] = {"path": src, "info": info}
1004
+ sync, sync_path = load_sync(edl, edit_dir, probes)
1005
+
1006
+ out_cfg = edl.get("output") or {}
1007
+ fmt = out_cfg.get("format") or "vertical"
1008
+ if fmt not in OUTPUT_FORMATS and not (out_cfg.get("width") and out_cfg.get("height")):
1009
+ raise CutError(f"output.format {fmt!r} desconhecido (use {', '.join(OUTPUT_FORMATS)})", 2)
1010
+ W = int(out_cfg.get("width") or OUTPUT_FORMATS[fmt][0])
1011
+ H = int(out_cfg.get("height") or OUTPUT_FORMATS[fmt][1])
1012
+ fps = int(round(float(out_cfg.get("fps") or choose_output_fps({k: v for k, v in probes.items()
1013
+ if k in picture_keys}))))
1014
+ if fps <= 0 or SR % fps:
1015
+ raise CutError(f"output.fps {fps} não suportado (use 24, 25, 30, 48, 50 ou 60)", 2)
1016
+ if args.preview:
1017
+ W, H = (W // 2) - (W // 2) % 2, (H // 2) - (H // 2) % 2
1018
+ W, H = W - W % 2, H - H % 2
1019
+
1020
+ try:
1021
+ grade_filter = resolve_grade(edl.get("grade"))
1022
+ except ValueError as exc:
1023
+ raise CutError(str(exc), 2)
1024
+ err = check_filter(grade_filter)
1025
+ if err:
1026
+ raise CutError(f"o grade do EDL não funciona no ffmpeg: {err}", 2)
1027
+
1028
+ jobs = args.jobs if args.jobs and args.jobs > 0 else min(4, max(1, (os.cpu_count() or 4) // 3))
1029
+ out_path = (Path(os.path.abspath(args.output.expanduser())) if args.output
1030
+ else edit_dir / ("cut_preview.mp4" if args.preview else "cut.mp4"))
1031
+ timeline_path = out_path.with_name(f"{out_path.stem}_timeline.json")
1032
+ clips_dir = edit_dir / "clips"
1033
+ clips_dir.mkdir(parents=True, exist_ok=True)
1034
+ out_path.parent.mkdir(parents=True, exist_ok=True)
1035
+ lock = acquire_lock(clips_dir)
1036
+
1037
+ mode = "prev" if args.preview else "full"
1038
+ log(f"Corte: {len(edl['ranges'])} trechos de {len(probes)} fonte(s) -> {W}x{H} {fps} fps"
1039
+ f"{' (prévia)' if args.preview else ''}")
1040
+ if jcut["enabled"]:
1041
+ log(f"J-cut: áudio entra {jcut['lead_frames']} quadros antes, cauda aparada até "
1042
+ f"{jcut['tail_trim_frames']} quadros fora da fala")
1043
+ else:
1044
+ log("Junções secas (sem J-cut)")
1045
+ if sync:
1046
+ log(f"Imagem de outra câmera em {sum(1 for r in edl['ranges'] if r.get('imagem_de'))} trecho(s), "
1047
+ f"sincronia de {sync_path.name}")
1048
+ if any(pr["info"].get("is_hdr") for pr in probes.values()) and not has_ffmpeg_filter("zscale"):
1049
+ warn("fonte HDR e o ffmpeg não tem zscale: conversão para SDR aproximada, confira as cores")
1050
+
1051
+ t_plan = time.time()
1052
+ plans = plan_cut(edl, edit_dir, fps, jcut, probes, jobs, W, H, sync)
1053
+ channel_modes = {}
1054
+ for key, pr in probes.items():
1055
+ if key in audio_keys:
1056
+ channel_modes[key] = channel_mode(pr["path"], pr["info"]) if pr["info"].get("has_audio") else "silent"
1057
+ t_plan = time.time() - t_plan
1058
+
1059
+ cfg = {"fps": fps, "width": W, "height": H, "grade_filter": grade_filter, "channel_modes": channel_modes,
1060
+ "x264_preset": "veryfast" if args.preview else "medium", "crf": 26 if args.preview else 18,
1061
+ "clips_dir": clips_dir, "mode": mode}
1062
+ t_ext = time.time()
1063
+ log(f"Extraindo {len(plans)} trechos ({jobs} em paralelo)...")
1064
+ with ThreadPoolExecutor(max_workers=jobs) as pool:
1065
+ futures = [pool.submit(extract, p, cfg) for p in plans]
1066
+ plans = [f.result() for f in futures]
1067
+ t_ext = time.time() - t_ext
1068
+ for a, b in zip(plans, plans[1:]):
1069
+ if b.joined and abs(a.audio_start + (a.lead + a.frames) / fps - b.audio_start) > 0.5 / SR:
1070
+ warn(f"a fala que continua de {a.rid} para {b.rid} saiu fora do lugar (o trecho {a.rid} mudou de "
1071
+ "quadros na extração): a voz pode pular ali. Rode de novo")
1072
+
1073
+ # Frame-accurate positions from the encoded clips.
1074
+ pos = 0
1075
+ total_frames = sum(p.frames for p in plans)
1076
+ total_samples = int(round(total_frames * SR / fps))
1077
+ duration = total_frames / fps
1078
+
1079
+ t_mix = time.time()
1080
+ mix_wav = clips_dir / f"{mode}_mix.wav"
1081
+ mix_audio(plans, fps, total_samples, mix_wav)
1082
+ measured = loudnorm_measure(mix_wav)
1083
+ t_mix = time.time() - t_mix
1084
+
1085
+ concat_list = clips_dir / f"{mode}_concat.txt"
1086
+ lines = ["ffconcat version 1.0"]
1087
+ for p in plans:
1088
+ lines.append(f"file '{concat_escape(p.video_path)}'")
1089
+ lines.append(f"duration {p.frames / fps:.6f}")
1090
+ concat_list.write_text("\n".join(lines) + "\n", encoding="utf-8")
1091
+
1092
+ norm_wav = clips_dir / f"{mode}_norm.wav"
1093
+ if measured:
1094
+ af = (f"loudnorm=I={LOUDNORM['I']}:TP={LOUDNORM['TP']}:LRA={LOUDNORM['LRA']}"
1095
+ f":measured_I={measured['input_i']}:measured_TP={measured['input_tp']}"
1096
+ f":measured_LRA={measured['input_lra']}:measured_thresh={measured['input_thresh']}"
1097
+ f":offset={measured['target_offset']}:linear=true,aresample={SR}")
1098
+ ffmpeg(["ffmpeg", "-y", "-v", "error", "-nostdin", "-i", str(mix_wav), "-af", af, "-c:a", "pcm_f32le",
1099
+ "-ar", str(SR), "-ac", "2", "-f", "wav", str(norm_wav)], "normalizar o volume")
1100
+ audio_in = norm_wav
1101
+ else:
1102
+ warn("não deu para medir a loudness (áudio mudo?); o áudio sai sem normalizar")
1103
+ audio_in = mix_wav
1104
+
1105
+ t_mux = time.time()
1106
+ tmp_out = out_path.with_name(f".{out_path.stem}.part.mp4")
1107
+
1108
+ def mux(gain_db: float) -> None:
1109
+ af_mux = ["-af", f"volume={gain_db:.2f}dB"] if abs(gain_db) > 0.001 else []
1110
+ ffmpeg(["ffmpeg", "-y", "-v", "error", "-nostdin", "-f", "concat", "-safe", "0", "-i", str(concat_list),
1111
+ "-i", str(audio_in), "-map", "0:v:0", "-map", "1:a:0", "-c:v", "copy", *af_mux,
1112
+ "-c:a", "aac", "-b:a", "192k", "-ar", str(SR), "-ac", "2", "-t", f"{duration:.6f}",
1113
+ "-movflags", "+faststart", "-f", "mp4", str(tmp_out)], f"montar o {out_path.name}")
1114
+
1115
+ peak_gain = 0.0
1116
+ try:
1117
+ mux(peak_gain)
1118
+ out_tp = true_peak_db(tmp_out)
1119
+ # The AAC encoder adds inter-sample peaks over the loudnorm target: lower just enough and encode again.
1120
+ peak_goal = TRUE_PEAK_LIMIT - TRUE_PEAK_MARGIN
1121
+ for _ in range(3):
1122
+ if out_tp is None or out_tp <= peak_goal:
1123
+ break
1124
+ peak_gain += peak_goal - out_tp - 0.15
1125
+ mux(peak_gain)
1126
+ out_tp = true_peak_db(tmp_out)
1127
+ if out_tp is not None and out_tp > TRUE_PEAK_LIMIT:
1128
+ warn(f"o pico real ficou em {out_tp:.2f} dBTP, acima de {TRUE_PEAK_LIMIT:.0f}, mesmo baixando "
1129
+ f"{peak_gain:.1f} dB: diminua o gain_db do trecho mais alto")
1130
+ os.replace(tmp_out, out_path)
1131
+ finally:
1132
+ tmp_out.unlink(missing_ok=True)
1133
+ norm_wav.unlink(missing_ok=True)
1134
+ t_mux = time.time() - t_mux
1135
+
1136
+ got_frames = count_frames(out_path)
1137
+ if got_frames != total_frames:
1138
+ warn(f"o arquivo final tem {got_frames} quadros, o timeline espera {total_frames}")
1139
+
1140
+ segments = []
1141
+ for p in plans:
1142
+ out_start_f, out_end_f = pos, pos + p.frames
1143
+ src_start = p.pic_start
1144
+ src_end = p.pic_start + p.frames / fps
1145
+ imagem = {"imagem_de": None}
1146
+ if p.other_picture:
1147
+ # src_start/src_end stay on the audio source's clock (the voice tools read them); the picture has its own
1148
+ imagem = {"imagem_de": p.pic_source, "imagem_path": str(p.pic_src),
1149
+ "imagem_src_start": round(src_start, 6), "imagem_src_end": round(src_end, 6)}
1150
+ src_start = p.audio_start + p.lead / fps
1151
+ src_end = p.audio_start + (p.lead + p.frames) / fps
1152
+ segments.append({
1153
+ "id": p.rid, "source": p.source, "source_path": str(p.src),
1154
+ "audio_mode": channel_modes.get(p.source, "stereo"),
1155
+ "src_start": round(src_start, 6), "src_end": round(src_end, 6), **imagem,
1156
+ "audio_continua": p.joined, "audio_fade_in": p.fade_in, "audio_fade_out": p.fade_out,
1157
+ "out_start": round(out_start_f / fps, 6), "out_end": round(out_end_f / fps, 6),
1158
+ "audio_out_start": round((out_start_f - p.lead) / fps, 6), "audio_out_end": round(out_end_f / fps, 6),
1159
+ "beat": p.beat, "quote": p.quote,
1160
+ "out_start_frame": out_start_f, "out_end_frame": out_end_f, "frames": p.frames,
1161
+ "lead_frames": p.lead, "tail_trim_frames": p.tail,
1162
+ "audio_src_start": round(p.audio_start, 6), "pic_snapped": p.pic_snapped,
1163
+ "edl_start": p.edl_start, "edl_end": p.edl_end, "gain_db": p.gain_db,
1164
+ "crop": p.crop,
1165
+ })
1166
+ pos = out_end_f
1167
+
1168
+ timeline = {
1169
+ "edl_sig": edl_sig, "fps": fps, "width": W, "height": H,
1170
+ "duration": round(duration, 6), "frames": total_frames,
1171
+ "video": out_path.name, "preview": bool(args.preview),
1172
+ "jcut": jcut, "grade": edl.get("grade") or "",
1173
+ "loudnorm_input": ({k: float(measured[k]) for k in ("input_i", "input_tp", "input_lra")} if measured else None),
1174
+ "loudnorm_modo": loudnorm_modo(measured),
1175
+ "true_peak_db": out_tp, "peak_guard_db": round(peak_gain, 2),
1176
+ "hdr_sources": sorted(k for k, pr in probes.items() if pr["info"].get("is_hdr")),
1177
+ "sincronia": ({"arquivo": str(sync_path), "fontes": {k: {"deslocamento": v[0], "escala": v[1]}
1178
+ for k, v in sync.items()}} if sync else None),
1179
+ "rendered_at": now_iso(),
1180
+ "segments": segments,
1181
+ }
1182
+ save_json(timeline_path, timeline)
1183
+
1184
+ # Only a full render inside the edit dir drives the preview. It does not ask for approval yet:
1185
+ # verify_cut.py does that after checking the file.
1186
+ state_note = ""
1187
+ if out_path.parent == edit_dir and not args.preview:
1188
+ state_path = edit_dir / "state.json"
1189
+ state = load_json(state_path, default={}) or {}
1190
+ state.update({
1191
+ "phase": "cut", "awaiting": None,
1192
+ "message": "Corte renderizado. Conferindo antes de mostrar...",
1193
+ "cut_video": out_path.name, "cut_timeline": timeline_path.name, "updated_at": now_iso(),
1194
+ })
1195
+ save_json(state_path, state)
1196
+ state_note = " · state.json atualizado (aprovação só depois do verify_cut)"
1197
+
1198
+ # Drop stale cached clips of this quality mode and the temp mix.
1199
+ keep = {q.video_path.name for q in plans} | {q.audio_path.name for q in plans} | {q.meta_path.name for q in plans}
1200
+ for f in clips_dir.glob(f"{mode}_*"):
1201
+ if f.name not in keep:
1202
+ f.unlink(missing_ok=True)
1203
+
1204
+ log("")
1205
+ log(f"{'id':6s} {'fonte':10s} {'origem':>15s} {'no corte':>17s} {'quadros':>7s} lead tail")
1206
+ for s, p in zip(segments, plans):
1207
+ pic = f" imagem da {p.pic_source} em {p.pic_start:.2f}s" if p.other_picture else ""
1208
+ log(f"{s['id']:6s} {s['source'][:10]:10s} {s['src_start']:6.2f}-{s['src_end']:6.2f}s "
1209
+ f"{fmt_time(s['out_start'])}-{fmt_time(s['out_end'])} {s['frames']:7d} {p.lead:4d} {p.tail:4d}"
1210
+ f"{' (cache)' if p.cached else ''}{' lead reduzido: ' + p.lead_note if p.lead_note else ''}"
1211
+ f"{' voz continua do anterior' if p.joined else ''}{pic}")
1212
+ for s in segments:
1213
+ if s["crop"] and s["crop"]["focus_from"] != "centro":
1214
+ c = s["crop"]
1215
+ log(f"Enquadramento {s['id']}: focus_x {c['focus_x']:.2f} focus_y {c['focus_y']:.2f} ({c['focus_from']})")
1216
+ if measured:
1217
+ log(f"Loudness antes: {float(measured['input_i']):.1f} LUFS, pico {float(measured['input_tp']):.1f} dBTP"
1218
+ f" -> alvo {LOUDNORM['I']:.0f} LUFS (ajuste {loudnorm_modo(measured)})")
1219
+ if loudnorm_modo(measured) == "dinamico":
1220
+ warn("o loudnorm vai cair no modo DINÂMICO (comprime a voz): rode helpers/ganho_por_trecho.py no EDL antes")
1221
+ if out_tp is not None and out_tp <= -100:
1222
+ log("Pico real no arquivo: sem som")
1223
+ elif out_tp is not None:
1224
+ guard =f" (volume {peak_gain:+.1f} dB para não passar de {TRUE_PEAK_LIMIT:.0f})" if peak_gain else ""
1225
+ log(f"Pico real no arquivo: {out_tp:.2f} dBTP{guard}")
1226
+ log(f"Pronto: {out_path} · {fmt_time(duration)} ({total_frames} quadros){state_note}")
1227
+ log(f"Timeline: {timeline_path}")
1228
+ log(f"Tempo: total {time.time() - t_all:.1f}s (plano {t_plan:.1f}s, extração {t_ext:.1f}s, "
1229
+ f"áudio {t_mix:.1f}s, montagem {t_mux:.1f}s)")
1230
+ mix_wav.unlink(missing_ok=True)
1231
+ concat_list.unlink(missing_ok=True)
1232
+ lock.close()
1233
+ return 0
1234
+
1235
+
1236
+ def main() -> int:
1237
+ ap = argparse.ArgumentParser(description="Renderiza o corte (cut.mp4 + cut_timeline.json) a partir do edl.json.")
1238
+ ap.add_argument("edl", type=Path, help="caminho do edl.json (a pasta dele é a pasta edit/)")
1239
+ ap.add_argument("-o", "--output", type=Path, help="vídeo de saída (padrão: cut.mp4 ao lado do EDL)")
1240
+ ap.add_argument("--jobs", type=int, default=0, help="trechos extraídos em paralelo (padrão automático)")
1241
+ ap.add_argument("--no-jcut", action="store_true", help="junções secas, sem J-cut")
1242
+ ap.add_argument("--preview", action="store_true",
1243
+ help="prévia rápida: metade da resolução, CRF 26, grava cut_preview.mp4 (não mexe no cut.mp4 nem no state.json)")
1244
+ args = ap.parse_args()
1245
+ try:
1246
+ return render(args)
1247
+ except CutError as exc:
1248
+ print(f"erro: {exc}", file=sys.stderr)
1249
+ return exc.code
1250
+ except KeyboardInterrupt:
1251
+ print("interrompido", file=sys.stderr)
1252
+ return 130
1253
+
1254
+
1255
+ if __name__ == "__main__":
1256
+ sys.exit(main())