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,3555 @@
1
+ """Phase 2 composer: edit_data.json -> final.mp4 (Contract 8).
2
+
3
+ Pipeline (all layers frame-accurate on the output frame grid):
4
+ 1. Camera on the base (cut.mp4): per timeline segment zoom level (hard change on the cut
5
+ frame) anchored at focus_y, optional eased push-in. Done with ffmpeg `perspective`
6
+ (sub-pixel sampling, so a slow push-in has no integer-crop jitter).
7
+ 2. Inserts: every window is pre-rendered once to renders/inserts/ (images: eased Ken Burns in
8
+ PIL with sub-pixel affine sampling; videos: loop from frame 0). Split windows re-frame the
9
+ speaker in the remaining band; `full` covers; `pip` is a rounded white card with a shadow.
10
+ 3. Overlays: inserts -> mid track (pip card + hook) -> captions (text_render track, seam
11
+ aware) -> top track (disclaimer, CTA, flashes). Text tracks are PNG states played by the
12
+ concat demuxer (`option framerate`), each overlay limited with enable windows on `n`.
13
+ 4. Audio: base + SFX placed in samples + music (loudness matched, sidechain ducked, faded)
14
+ -> loudnorm two-pass -> length pinned to the video.
15
+ 5. H.264 High CRF 18 (preview: 540 wide, CRF 26), yuv420p, bt709/tv, AAC 192k 48k, faststart.
16
+ Writes edit/verify/final_report.json and updates state.json.
17
+
18
+ Usage:
19
+ uv run python helpers/compose_final.py edit/edit_data.json [-o final.mp4] [--preview] [--keep-temp]
20
+
21
+ Defaults and optional keys beyond SPEC Contract 8 (all optional):
22
+ - a missing section is off (no camera move, no hook, ...); `loudness` defaults to -14 / -1.
23
+ - inserts[].sfx: effect name or a sound file; null = none. Names: whoosh, pop, click, tick, rise, cash, coin,
24
+ notification, success, error, count, keyboard, snap, mouse_click, bass_drop, metal_hit, synth_hit, flash,
25
+ charge, metal_riser, wind, rewind, gears, data, or the Portuguese label / alias of one of them from
26
+ assets/sfx/sfx.json ("grave", "notificacao", "estalo de dedo"...; accents, case and spaces do not matter).
27
+ inserts[].corner (pip only): "top_right" (default) or "top_left".
28
+ - hook.sfx (default "pop"), transitions[].sfx (default "click"), cta.sfx (default none).
29
+ - Built-in effects come from assets/sfx/sfx.json (make_sfx.builtin_sfx): each has an anchor (`anchor_s`,
30
+ the moment it hits full strength) that lands on the event frame, so the sound starts anchor_s earlier
31
+ (the CTA "rise" peaks when the CTA appears). A start before 0 is clamped to 0, with a warning when the
32
+ anchor then misses its frame. A sound file keeps anchor 0: it starts on the event frame (2 frames
33
+ early on an insert), exactly as before.
34
+ - sfx.events: [{"at": seconds, "name": effect or file, "volume": 0.0-2.0, "anchor_s": seconds}] sounds at
35
+ a chosen time, not tied to a hook, insert, flash or CTA (e.g. "cash" when the speech talks about a sale).
36
+ The anchor lands on the frame of `at`; `volume` multiplies sfx.volume; `anchor_s` (optional, >= 0)
37
+ replaces the anchor (a file's default is 0). A negative or non-numeric `at` or `anchor_s` is an error;
38
+ an `at` past the end is dropped with a warning, and a sound that loses more than 10% of its energy to
39
+ the video end plays cut with a warning. Only plays when sfx is on.
40
+ - camera.focus_y, hook.center_y, captions.center_y, cta.center_y, disclaimer.center_y: a number
41
+ (focus: fraction of the height; centers: pixels on the output canvas) or omitted / "auto".
42
+ Auto uses the speaker's face found by face_layout.py (edit/face_layout.json, made on the fly):
43
+ the zoom anchors on the eyes of each segment, splits frame each segment's face, and every text
44
+ goes to the free spot closest to its usual place (hook 430, captions 1400, CTA 1110 / 1180,
45
+ disclaimer 1548 on 1080x1920; horizontal and square canvases have their own table), never on
46
+ the face and never on a text shown at the same time. Manual numbers are kept as they are
47
+ (captions.center_y moves the captions for the whole video; split seams still win).
48
+ - Text layers on screen together never overlap: priority CTA > hook > captions > disclaimer. The
49
+ lower one moves (when auto) or hides while they would touch. Every move and hide is listed in
50
+ verify/final_report.json "adjustments"; text still over a face goes to "face_checks" and to
51
+ an `aviso:` line with a suggested center_y.
52
+ - captions.json made for another cut (timeline_key, cut boundaries or length that do not match
53
+ cut_timeline.json) is refused: run captions.py again.
54
+ - Inserts that overlap: the one later in the list is drawn on top. An HEIC/HEIF photo is converted
55
+ to JPG once with `sips` on macOS (renders/heic/); elsewhere it is refused with a hint.
56
+ - music.volume_db is relative to the voice loudness (the track is measured first). Default -15
57
+ (MUSIC_VOLUME_DB): with continuous speech the duck takes about 4 dB more off, so the bed sits about
58
+ 19 LU under the voice; the old -22 left it about 26 LU under, nearly inaudible (measured 16/09/2026 on
59
+ two real cuts with a pop and a piano bed; the owner approved about -15 on a real ad).
60
+ - music.mood (instead of music.file): a mood of the personal library (helpers/trilhas.py). The choice
61
+ recorded in assets/trilhas_escolhidas.json is reused while its file is intact; otherwise the favorite of
62
+ the mood, else the best fit for the cut, is copied to assets/trilha_<mood>.<ext> and recorded.
63
+ music.track pins a library track id. When music.file is given it wins and mood is ignored. Without
64
+ mood the audio is exactly the one of a plain music.file.
65
+ - audio.denoise: "off" (default), "leve" or "forte" removes background noise from the voice (see
66
+ helpers/denoise.py). The voice is rebuilt from the sources range by range (cut_timeline.json) with
67
+ each source's own noise profile, placed on the same samples as in cut.mp4, loudnormed like
68
+ render_cut and used instead of cut.mp4's audio; cut.mp4 is not touched. Absent or "off": the audio
69
+ graph is exactly the one without this option. The report gets "denoise" (mode, noise floor before and
70
+ after, warnings) and the log one "ruído de fundo: ..." line.
71
+ - --preview writes final_preview.mp4 (540 px short side, CRF 26) unless -o is given.
72
+ - "edit_type": "tela_verde" + "tela_verde": {...}: the speaker cut out over images, videos or a color
73
+ (helpers/matte.py + helpers/tela_verde.py, SPEC Contract 13). The person matte of each cut range is made
74
+ once and cached in renders/matte/; the tela verde clip replaces cut.mp4 as the base (the camera zoom goes
75
+ into the person layer), faces and bodies are placed where the person lands and texts avoid them. Any
76
+ other edit_type, or none, renders exactly as before.
77
+ """
78
+
79
+ from __future__ import annotations
80
+
81
+ import argparse
82
+ import hashlib
83
+ import json
84
+ import math
85
+ import os
86
+ import re
87
+ import shutil
88
+ import subprocess
89
+ import sys
90
+ import threading
91
+ import time
92
+ from concurrent.futures import ThreadPoolExecutor
93
+ from dataclasses import dataclass, field
94
+ from pathlib import Path
95
+
96
+ import numpy as np
97
+ from PIL import Image, ImageDraw, ImageFilter, ImageOps
98
+
99
+ sys.path.insert(0, str(Path(__file__).resolve().parent))
100
+ from common import ( # noqa: E402
101
+ ffprobe_json,
102
+ file_signature,
103
+ fmt_time,
104
+ hex_to_rgb,
105
+ load_json,
106
+ load_style_catalog,
107
+ now_iso,
108
+ output_fps_for,
109
+ require_binary,
110
+ save_json,
111
+ )
112
+ import captions as captions_mod # noqa: E402
113
+ import denoise as denoise_mod # noqa: E402
114
+ import face_layout # noqa: E402
115
+ import make_sfx # noqa: E402
116
+ import text_render as tr # noqa: E402
117
+
118
+ RENDER_VERSION = "c2" # bump to invalidate cached insert clips / overlay PNGs
119
+ IMAGE_EXT = {".jpg", ".jpeg", ".png", ".webp", ".bmp", ".tif", ".tiff"}
120
+ HEIC_EXT = {".heic", ".heif", ".hif"}
121
+ VIDEO_EXT = {".mp4", ".mov", ".m4v", ".mkv", ".webm", ".avi", ".gif", ".mts"}
122
+ INSERT_TYPES = ("split_top", "split_bottom", "full", "pip")
123
+ SFX_FILE_EXT = (".wav", ".mp3", ".m4a", ".aac", ".ogg", ".flac")
124
+ SFX_EVENT_MAX_VOLUME = 2.0
125
+ SFX_EVENT_MIN_KEPT = 0.9 # an sfx.events sound with less of its energy before the video end gets a warning
126
+ SFX_TRIM_FADE = 0.08 # s: fade at the end of a sound cut to its "length" (animation cues)
127
+ SR = 48000
128
+ MUSIC_VOLUME_DB = -15.0 # music.volume_db default: about 19 LU under continuous speech once ducked
129
+ KB_DRIFT = 0.06 # Ken Burns scale drift (SPEC: at most 6%)
130
+ PREVIEW_SHORT_SIDE = 540
131
+
132
+ DEFAULT_DISCLAIMER = "Resultado individual. Depende de execução. Não é promessa de ganho."
133
+ DEFAULT_CTA = "Toque em Saiba Mais"
134
+
135
+
136
+ class ComposeError(Exception):
137
+ def __init__(self, message: str, code: int = 1):
138
+ super().__init__(message)
139
+ self.code = code
140
+
141
+
142
+ def log(msg: str) -> None:
143
+ print(msg, flush=True)
144
+
145
+
146
+ def even(v: float) -> int:
147
+ return max(2, int(round(v / 2.0)) * 2)
148
+
149
+
150
+ def ease_in_out(p: float) -> float:
151
+ p = min(1.0, max(0.0, p))
152
+ return 0.5 - 0.5 * math.cos(math.pi * p)
153
+
154
+
155
+ def short_hash(obj) -> str:
156
+ return hashlib.sha1(repr(obj).encode("utf-8")).hexdigest()[:16]
157
+
158
+
159
+ def ffmpeg_cmd(args: list, what: str) -> subprocess.CompletedProcess:
160
+ proc = subprocess.run(["ffmpeg", "-hide_banner", "-nostdin", *[str(a) for a in args]],
161
+ capture_output=True, text=True)
162
+ if proc.returncode != 0:
163
+ tail = "\n".join((proc.stderr or "").strip().splitlines()[-12:])
164
+ raise ComposeError(f"o ffmpeg falhou ao {what}:\n{tail}")
165
+ return proc
166
+
167
+
168
+ _FF_MAJOR: int | None = None
169
+
170
+
171
+ def ffmpeg_major() -> int:
172
+ global _FF_MAJOR
173
+ if _FF_MAJOR is None:
174
+ out = subprocess.run(["ffmpeg", "-version"], capture_output=True, text=True).stdout
175
+ m = re.search(r"ffmpeg version n?(\d+)", out)
176
+ _FF_MAJOR = int(m.group(1)) if m else 7
177
+ return _FF_MAJOR
178
+
179
+
180
+ def graph_args(script: Path) -> list[str]:
181
+ return ["-/filter_complex", str(script)] if ffmpeg_major() >= 7 else ["-filter_complex_script", str(script)]
182
+
183
+
184
+ # ============================================================== plan
185
+
186
+ @dataclass
187
+ class Insert:
188
+ idx: int
189
+ type: str
190
+ src: Path
191
+ kind: str # image | video
192
+ f0: int
193
+ f1: int
194
+ ken_burns: bool
195
+ split: float
196
+ sfx: str | None
197
+ content: tuple[int, int, int, int] = (0, 0, 0, 0) # x, y, w, h of the picture
198
+ speaker: tuple[int, int, int, int] | None = None # x, y, w, h of the speaker band
199
+ speaker_src_y: int = 0
200
+ speaker_pieces: list[tuple[int, int, int]] = field(default_factory=list) # (f0, f1, source row) per camera segment
201
+ seam_y: int | None = None
202
+ card: tuple[int, int, int, int] | None = None # pip outer card
203
+ clip: Path | None = None
204
+ to_bg: bool = False # tela verde: a split_top / split_bottom insert shown as the background (no panel)
205
+ item: dict | None = None # the raw edit_data item (tela verde reads escurecer / desfocar / encaixe / corner)
206
+
207
+
208
+ @dataclass
209
+ class Plan:
210
+ edit_dir: Path
211
+ edit_data_path: Path
212
+ data: dict
213
+ base: Path
214
+ out_path: Path
215
+ preview: bool
216
+ full_w: int
217
+ full_h: int
218
+ W: int
219
+ H: int
220
+ fps: int
221
+ base_fps: float
222
+ base_w: int
223
+ base_h: int
224
+ nf: int
225
+ has_audio: bool
226
+ segments: list[tuple[int, int]]
227
+ accent: str
228
+ renders: Path
229
+ warnings: list[str] = field(default_factory=list)
230
+ layers: list[dict] = field(default_factory=list)
231
+ timeline: dict | None = None
232
+ faces: dict | None = None # face_layout.json
233
+ cam_segs: list[dict] = field(default_factory=list) # per segment: f0, f1, zoom, zoom_max, focus_y, face
234
+ zones: list[tuple] = field(default_factory=list) # (f0, f1, face zones, face cores) in render px
235
+ obstacles: list[tuple] = field(default_factory=list) # (f0, f1, (x0, y0, x1, y1)): pip cards texts avoid
236
+ adjustments: list[dict] = field(default_factory=list)
237
+ face_checks: list[dict] = field(default_factory=list)
238
+ music_choice: dict | None = None # music.mood resolved through the library
239
+ tv: dict | None = None # edit_type "tela_verde" (prepare_tela_verde)
240
+ person_zones: list[tuple] = field(default_factory=list) # (f0, f1, body rects) in render px, tela verde only
241
+ image_zones: list[tuple] = field(default_factory=list) # (f0, f1, rect) main area of a whole background picture
242
+
243
+ @property
244
+ def duration(self) -> float:
245
+ return self.nf / self.fps
246
+
247
+ @property
248
+ def s(self) -> float: # render scale vs the full output canvas
249
+ return self.W / self.full_w
250
+
251
+ def fr(self, t, default: int = 0) -> int:
252
+ if t is None:
253
+ return default
254
+ return max(0, min(self.nf, int(round(float(t) * self.fps + 1e-6))))
255
+
256
+ def rel(self, p: Path) -> str:
257
+ try:
258
+ return str(p.resolve().relative_to(self.edit_dir))
259
+ except ValueError:
260
+ return str(p.resolve())
261
+
262
+
263
+ def _section(data: dict, key: str) -> dict:
264
+ v = data.get(key)
265
+ return v if isinstance(v, dict) else {}
266
+
267
+
268
+ def _enabled(sec: dict) -> bool:
269
+ return bool(sec) and bool(sec.get("enabled", True))
270
+
271
+
272
+ def _resolve(edit_dir: Path, value: str, what: str) -> Path:
273
+ if not value or not str(value).strip():
274
+ raise ComposeError(f"{what}: caminho vazio", 2)
275
+ p = Path(str(value)).expanduser()
276
+ if not p.is_absolute():
277
+ p = edit_dir / p
278
+ p = p.resolve()
279
+ if not p.exists():
280
+ raise ComposeError(f"{what}: arquivo não encontrado: {p}", 2)
281
+ return p
282
+
283
+
284
+ def _base_facts(base: Path) -> dict:
285
+ try:
286
+ info = ffprobe_json(base)
287
+ except Exception:
288
+ raise ComposeError(f"não consegui ler o vídeo base: {base}", 2)
289
+ streams = info.get("streams", [])
290
+ v = next((s for s in streams if s.get("codec_type") == "video"), None)
291
+ if v is None:
292
+ raise ComposeError(f"o vídeo base não tem imagem: {base}", 2)
293
+ from fractions import Fraction
294
+ rate = v.get("avg_frame_rate") or v.get("r_frame_rate") or "30/1"
295
+ try:
296
+ fps = float(Fraction(rate))
297
+ except (ValueError, ZeroDivisionError):
298
+ fps = 30.0
299
+ if fps <= 0:
300
+ fps = 30.0
301
+ dur = float(v.get("duration") or (info.get("format") or {}).get("duration") or 0.0)
302
+ nb = int(v.get("nb_frames") or 0)
303
+ if nb <= 0:
304
+ proc = subprocess.run(["ffprobe", "-v", "error", "-select_streams", "v:0", "-count_packets",
305
+ "-show_entries", "stream=nb_read_packets", "-of", "csv=p=0", str(base)],
306
+ capture_output=True, text=True)
307
+ try:
308
+ nb = int(proc.stdout.strip().split(",")[0])
309
+ except ValueError:
310
+ nb = int(round(dur * fps))
311
+ rot = 0
312
+ for sd in v.get("side_data_list", []) or []:
313
+ if "rotation" in sd:
314
+ rot = abs(int(round(float(sd["rotation"])))) % 180
315
+ w, h = int(v.get("width", 0)), int(v.get("height", 0))
316
+ if rot == 90:
317
+ w, h = h, w
318
+ return {"fps": fps, "duration": dur, "frames": nb, "width": w, "height": h,
319
+ "has_audio": any(s.get("codec_type") == "audio" for s in streams)}
320
+
321
+
322
+ def build_plan(edit_data_path: Path, out: str | None, preview: bool) -> Plan:
323
+ if not edit_data_path.exists():
324
+ raise ComposeError(f"edit_data.json não encontrado: {edit_data_path}", 2)
325
+ try:
326
+ data = json.loads(edit_data_path.read_text(encoding="utf-8"))
327
+ except (ValueError, OSError) as e:
328
+ raise ComposeError(f"edit_data.json inválido ({e})", 2)
329
+ if not isinstance(data, dict):
330
+ raise ComposeError("edit_data.json precisa ser um objeto JSON", 2)
331
+ edit_dir = edit_data_path.parent.resolve()
332
+ base = _resolve(edit_dir, data.get("base") or "cut.mp4", "base")
333
+ facts = _base_facts(base)
334
+
335
+ timeline_path = edit_dir / (data.get("timeline") or "cut_timeline.json")
336
+ timeline = load_json(timeline_path) if timeline_path.exists() else None
337
+
338
+ outcfg = _section(data, "output")
339
+ full_w = int(outcfg.get("width") or (timeline or {}).get("width") or facts["width"])
340
+ full_h = int(outcfg.get("height") or (timeline or {}).get("height") or facts["height"])
341
+ fps_val = outcfg.get("fps") or (timeline or {}).get("fps") or output_fps_for(facts["fps"])
342
+ fps = int(round(float(fps_val)))
343
+ if fps <= 0 or full_w < 16 or full_h < 16:
344
+ raise ComposeError("output inválido em edit_data.json (width/height/fps)", 2)
345
+ if preview:
346
+ k = min(1.0, PREVIEW_SHORT_SIDE / min(full_w, full_h))
347
+ W, H = even(full_w * k), even(full_h * k)
348
+ else:
349
+ W, H = even(full_w), even(full_h)
350
+
351
+ if abs(facts["fps"] - fps) < 0.01:
352
+ nf = facts["frames"]
353
+ else:
354
+ nf = int(round(facts["duration"] * fps))
355
+ if nf <= 0:
356
+ raise ComposeError("o vídeo base não tem quadros", 2)
357
+
358
+ accent = str(data.get("accent") or (load_style_catalog().get("defaults") or {}).get("accent") or "#FFC400")
359
+ try:
360
+ hex_to_rgb(accent)
361
+ except ValueError:
362
+ raise ComposeError(f"cor de destaque inválida: {accent!r} (use #RRGGBB)", 2)
363
+
364
+ if out:
365
+ out_path = Path(out).expanduser()
366
+ if not out_path.is_absolute():
367
+ out_path = (Path.cwd() / out_path)
368
+ else:
369
+ out_path = edit_dir / ("final_preview.mp4" if preview else "final.mp4")
370
+ out_path = out_path.resolve()
371
+
372
+ plan = Plan(edit_dir=edit_dir, edit_data_path=edit_data_path.resolve(), data=data, base=base,
373
+ out_path=out_path, preview=preview, full_w=full_w, full_h=full_h, W=W, H=H, fps=fps,
374
+ base_fps=facts["fps"], base_w=facts["width"], base_h=facts["height"], nf=nf,
375
+ has_audio=facts["has_audio"], segments=[], accent=accent,
376
+ renders=edit_dir / "renders" / ("preview" if preview else "full"), timeline=timeline)
377
+
378
+ # timeline segments on the output frame grid
379
+ segs = []
380
+ for s in (timeline or {}).get("segments", []) or []:
381
+ try:
382
+ segs.append(plan.fr(float(s["out_start"])))
383
+ except (KeyError, TypeError, ValueError):
384
+ continue
385
+ starts = sorted({f for f in segs if 0 < f < nf})
386
+ bounds = [0] + starts + [nf]
387
+ plan.segments = [(a, b) for a, b in zip(bounds, bounds[1:]) if b > a]
388
+ music = _section(data, "music")
389
+ if _enabled(music) and music.get("file"):
390
+ _resolve(edit_dir, music.get("file"), "music.file")
391
+ elif _enabled(music) and music.get("mood") not in (None, ""):
392
+ resolve_music_mood(plan, music)
393
+ elif _enabled(music):
394
+ raise ComposeError("music está ligado mas sem 'file' nem 'mood' (ex: \"file\": \"assets/trilha.mp3\" "
395
+ "ou \"mood\": \"triste\")", 2)
396
+ if timeline is None:
397
+ plan.warnings.append("cut_timeline.json não encontrado: o vídeo inteiro conta como um trecho só")
398
+ if timeline and abs(float(timeline.get("duration") or 0) - nf / fps) > 1.5 / fps:
399
+ plan.warnings.append(f"cut_timeline.json diz {timeline.get('duration')}s, mas o vídeo base tem {nf / fps:.3f}s")
400
+ return plan
401
+
402
+
403
+ def resolve_music_mood(plan: Plan, music: dict) -> None:
404
+ """music.mood without music.file: pick the track from the personal library (helpers/trilhas.py).
405
+
406
+ The chosen file goes to assets/ and into plan.data["music"]["file"] (in memory only), so the audio graph is
407
+ the one of a plain music.file; plan.music_choice keeps the track id and why for the report."""
408
+ import trilhas
409
+
410
+ mood, track = music.get("mood"), music.get("track")
411
+ if not isinstance(mood, str) or not mood.strip():
412
+ raise ComposeError("music.mood precisa ser o nome de um humor, ex.: \"triste\"", 2)
413
+ if track is not None and (not isinstance(track, str) or not track.strip()):
414
+ raise ComposeError("music.track precisa ser o id de uma trilha da biblioteca, ex.: \"triste-2\"", 2)
415
+ try:
416
+ choice = trilhas.resolve_for_edit(plan.edit_dir, mood.strip(), plan.duration,
417
+ track.strip() if track else None)
418
+ except trilhas.TrilhaError as e:
419
+ raise ComposeError(f"music.mood: {e}", 2)
420
+ _resolve(plan.edit_dir, choice["file"], "music.file")
421
+ plan.data["music"] = {**music, "file": choice["file"]}
422
+ plan.music_choice = choice
423
+ if choice.get("warning"):
424
+ plan.warnings.append(choice["warning"])
425
+
426
+
427
+ def _plain_number(value) -> float | None:
428
+ """A finite number (or numeric text); None for bools, text that is not a number, NaN and inf."""
429
+ if isinstance(value, bool):
430
+ return None
431
+ try:
432
+ v = float(value)
433
+ except (TypeError, ValueError):
434
+ return None
435
+ return v if math.isfinite(v) else None
436
+
437
+
438
+ def _as_json(value) -> str:
439
+ """A value the way it is written in edit_data.json (null, true, "texto"), for error messages."""
440
+ try:
441
+ text = json.dumps(value, ensure_ascii=False)
442
+ except (TypeError, ValueError):
443
+ return repr(value)
444
+ if isinstance(value, str) and "," in value and _plain_number(value.replace(",", ".")) is not None:
445
+ text += ", use ponto no lugar da vírgula"
446
+ return text
447
+
448
+
449
+ def plan_sfx_events(plan: Plan) -> list[dict]:
450
+ """sfx.events: effects at a chosen time on the cut, not tied to a hook, insert, flash or CTA.
451
+
452
+ Checked before anything renders. Returns events in the same shape build_tracks gives run_audio
453
+ ({name, frame, lead_frames 0, why "efeito"}) plus at, event (index), and volume and anchor_s when given.
454
+ """
455
+ sec = _section(plan.data, "sfx")
456
+ if not _enabled(sec) or sec.get("events") is None:
457
+ return []
458
+ raw = sec.get("events")
459
+ example = '{"at": 5.07, "name": "cash"}'
460
+ if not isinstance(raw, list):
461
+ raise ComposeError(f"sfx.events precisa ser uma lista (ex: [{example}])", 2)
462
+ out: list[dict] = []
463
+ for i, item in enumerate(raw):
464
+ where = f"sfx.events[{i}]"
465
+ if not isinstance(item, dict):
466
+ raise ComposeError(f"{where} precisa ser um objeto (ex: {example})", 2)
467
+ at = _plain_number(item.get("at"))
468
+ if at is None or at < 0:
469
+ if "at" not in item:
470
+ raise ComposeError(f"{where} precisa de \"at\": o segundo do corte em que o som toca (ex: {example})", 2)
471
+ raise ComposeError(f"{where}.at precisa ser um número de segundos, 0 ou mais (veio {_as_json(item.get('at'))})", 2)
472
+ name = item.get("name")
473
+ if not isinstance(name, str) or not name.strip():
474
+ raise ComposeError(f"{where} precisa de \"name\": um efeito ({', '.join(make_sfx.NAMES)}, ou o nome em "
475
+ f"português, ex.: \"grave\") ou um arquivo (ex: \"assets/meu_som.wav\")", 2)
476
+ name = name.strip()
477
+ ev: dict = {"name": name, "frame": 0, "lead_frames": 0, "why": "efeito", "at": round(at, 4), "event": i}
478
+ if item.get("volume") is not None:
479
+ vol = _plain_number(item.get("volume"))
480
+ if vol is None or vol < 0:
481
+ raise ComposeError(f"{where}.volume precisa ser um número de 0.0 a {SFX_EVENT_MAX_VOLUME:.1f} "
482
+ f"(veio {_as_json(item.get('volume'))})", 2)
483
+ if vol > SFX_EVENT_MAX_VOLUME:
484
+ plan.warnings.append(f"{where}.volume {vol:g} passou do máximo e ficou em {SFX_EVENT_MAX_VOLUME:.1f}")
485
+ vol = SFX_EVENT_MAX_VOLUME
486
+ ev["volume"] = vol
487
+ if item.get("anchor_s") is not None:
488
+ anchor = _plain_number(item.get("anchor_s"))
489
+ if anchor is None or anchor < 0:
490
+ raise ComposeError(f"{where}.anchor_s precisa ser um número de segundos, 0 ou mais: onde fica o ponto "
491
+ f"forte dentro do som (veio {_as_json(item.get('anchor_s'))})", 2)
492
+ ev["anchor_s"] = round(anchor, 4)
493
+ if Path(name).suffix.lower() in SFX_FILE_EXT:
494
+ _resolve(plan.edit_dir, name, f"{where}.name")
495
+ if at * plan.fps >= plan.nf - 1e-6:
496
+ plan.warnings.append(f"{where} ('{name}' em {fmt_time(at)}) passa do fim do vídeo (o vídeo acaba em "
497
+ f"{fmt_time(plan.duration)} e o último quadro começa em "
498
+ f"{fmt_time((plan.nf - 1) / plan.fps)}) e foi ignorado")
499
+ continue
500
+ # same rounding as the other events (Plan.fr); a time in the last half frame stays on the last frame
501
+ ev["frame"] = min(plan.fr(at), plan.nf - 1)
502
+ out.append(ev)
503
+ return out
504
+
505
+
506
+ def plan_denoise(plan: Plan) -> str:
507
+ """audio.denoise: "off" | "leve" | "forte" (a missing section or "enabled": false is off). Checked before rendering."""
508
+ raw = plan.data.get("audio")
509
+ if raw is None:
510
+ return "off"
511
+ if not isinstance(raw, dict):
512
+ raise ComposeError('audio precisa ser um objeto (ex: "audio": {"denoise": "leve"})', 2)
513
+ if raw.get("enabled", True) is False:
514
+ return "off"
515
+ try:
516
+ return denoise_mod.normalize_mode(raw.get("denoise"))
517
+ except ValueError as e:
518
+ raise ComposeError(str(e), 2)
519
+
520
+
521
+ def run_denoise(plan: Plan, mode: str, keep_temp: bool) -> tuple[Path | None, dict]:
522
+ """The cleaned voice for run_audio (None keeps cut.mp4's audio). A failure never stops the render."""
523
+ if mode == "off":
524
+ return None, {"mode": "off", "applied": "off"}
525
+ if not plan.has_audio:
526
+ msg = "o vídeo base não tem áudio: não há ruído para limpar"
527
+ plan.warnings.append(msg)
528
+ return None, {"mode": mode, "applied": "off", "warnings": [msg]}
529
+ work = plan.edit_dir / "renders" / "denoise"
530
+ try:
531
+ res = denoise_mod.build_voice(plan.edit_dir, plan.base, plan.timeline, plan.fps, plan.nf * SR // plan.fps, mode,
532
+ work, keep_temp=keep_temp)
533
+ except Exception as e: # noqa: BLE001 (the cleaning is optional: the render goes on with the cut's audio)
534
+ msg = f"a limpeza de ruído falhou ({e}). O vídeo sai com o som original"
535
+ plan.warnings.append(msg)
536
+ return None, {"mode": mode, "applied": "off", "warnings": [msg]}
537
+ plan.warnings.extend(res.warnings)
538
+ return res.path, res.report
539
+
540
+
541
+ def denoise_line(rep: dict) -> str | None:
542
+ """One PT-BR log line about the background-noise removal (None when it is off)."""
543
+ mode = rep.get("mode") or "off"
544
+ if mode == "off":
545
+ return None
546
+ applied = rep.get("applied") or "off"
547
+ if applied == "off":
548
+ return f"ruído de fundo: sem limpeza (pedido {denoise_mod.LABEL[mode]}, veja o aviso)"
549
+ if applied == "misto":
550
+ lighter = [e["source"] for e in rep.get("sources") or [] if e.get("applied") == "leve"]
551
+ what = f"{denoise_mod.LABEL[mode]}, leve em {', '.join(lighter)}"
552
+ elif applied != mode:
553
+ what = f"{denoise_mod.LABEL[applied]} no lugar de {denoise_mod.LABEL[mode]}"
554
+ else:
555
+ what = denoise_mod.LABEL[applied]
556
+ if rep.get("method") == "corte":
557
+ what += ", no som do corte"
558
+ before, after = rep.get("noise_floor_before_db"), rep.get("noise_floor_after_db")
559
+ if before is not None and after is not None:
560
+ return f"ruído de fundo: {what} (fundo de {before:.0f} dB para {after:.0f} dB)"
561
+ return f"ruído de fundo: {what}"
562
+
563
+
564
+ # ============================================================== camera
565
+
566
+ def _is_auto(value) -> bool:
567
+ return value is None or (isinstance(value, str) and value.strip().lower() == "auto")
568
+
569
+
570
+ def load_faces(plan: Plan) -> None:
571
+ """Face per timeline segment (face_layout.json, detected on the first compose and cached)."""
572
+ try:
573
+ plan.faces = face_layout.build(plan.edit_dir, plan.base, timeline=plan.timeline)
574
+ except face_layout.FaceLayoutError as e:
575
+ plan.warnings.append(f"não consegui procurar o rosto ({e}): textos e zoom nas posições padrão")
576
+ except Exception as e: # noqa: BLE001 (a detector failure must not stop the render)
577
+ plan.warnings.append(f"a detecção de rosto falhou ({e.__class__.__name__}): textos e zoom nas posições padrão")
578
+
579
+
580
+ def camera_segments(plan: Plan, levels: list[float], zoom_cuts: bool, push_in: bool, amount: float) -> list[dict]:
581
+ cam = _section(plan.data, "camera")
582
+ manual = None
583
+ if not _is_auto(cam.get("focus_y")):
584
+ try:
585
+ manual = min(0.9, max(0.1, float(cam.get("focus_y"))))
586
+ except (TypeError, ValueError):
587
+ raise ComposeError("camera.focus_y precisa ser um número entre 0.1 e 0.9 ou \"auto\"", 2)
588
+ face_segs = [f for f in ((plan.faces or {}).get("segments") or []) if isinstance(f, dict)]
589
+ found = any(f.get("face") for f in face_segs)
590
+ fallback = ((plan.faces or {}).get("recommended") or {}).get("camera", {}).get("focus_y") if found else None
591
+ fallback = float(fallback) if fallback is not None else 0.40
592
+ out = []
593
+ for i, (a, b) in enumerate(plan.segments):
594
+ z = levels[i % len(levels)] if zoom_cuts else 1.0
595
+ t0, t1 = a / plan.fps, b / plan.fps
596
+ best, best_ov = None, 0.0
597
+ for f in face_segs:
598
+ ov = min(t1, float(f.get("end", 0))) - max(t0, float(f.get("start", 0)))
599
+ if ov > best_ov:
600
+ best, best_ov = f, ov
601
+ face = best if best and best.get("face") else None
602
+ fy = manual if manual is not None else (float(face["focus_y"]) if face and face.get("focus_y") is not None else fallback)
603
+ out.append({"f0": a, "f1": b, "zoom": z, "zoom_max": z * (1 + amount) if push_in else z,
604
+ "focus_y": min(0.9, max(0.1, fy)), "face": face, "manual_focus": manual is not None})
605
+ return out
606
+
607
+
608
+ def camera_filter(plan: Plan) -> str | None:
609
+ cam = _section(plan.data, "camera")
610
+ zoom_cuts = bool(cam.get("zoom_cuts", False))
611
+ push_in = bool(cam.get("push_in", False))
612
+ levels = cam.get("zoom_levels") or [1.0, 1.14]
613
+ try:
614
+ levels = [min(1.6, max(1.0, float(z))) for z in levels] or [1.0]
615
+ except (TypeError, ValueError):
616
+ raise ComposeError("camera.zoom_levels precisa ser uma lista de números (ex: [1.0, 1.14])", 2)
617
+ amount = min(0.25, max(0.0, float(cam.get("push_in_amount", 0.05) or 0.0)))
618
+ plan.cam_segs = camera_segments(plan, levels, zoom_cuts, push_in and amount > 0, amount)
619
+ segs_info = []
620
+ parts = []
621
+ for cs in plan.cam_segs:
622
+ a, b, z = cs["f0"], cs["f1"], cs["zoom"]
623
+ n = b - a
624
+ if push_in and amount > 0 and n > 1:
625
+ expr = f"{z:.5f}*(1+{amount:.5f}*(0.5-0.5*cos(PI*clip((in-1-{a})/{n - 1},0,1))))"
626
+ else:
627
+ expr = f"{z:.5f}"
628
+ parts.append((b, expr, cs["focus_y"]))
629
+ segs_info.append({"start": round(a / plan.fps, 3), "end": round(b / plan.fps, 3), "zoom": z,
630
+ "push_in": amount if push_in else 0.0, "focus_y": round(cs["focus_y"], 4),
631
+ "face": bool(cs["face"])})
632
+ manual = next((cs["focus_y"] for cs in plan.cam_segs if cs["manual_focus"]), None)
633
+ plan.layers.append({"type": "camera", "zoom_cuts": zoom_cuts, "push_in": push_in,
634
+ "focus_y": manual if manual is not None else "auto", "segments": segs_info})
635
+ if not push_in and all(abs(info["zoom"] - 1.0) < 1e-6 for info in segs_info):
636
+ return None
637
+
638
+ def piecewise(fn) -> str:
639
+ expr = fn(parts[-1])
640
+ for part in reversed(parts[:-1]):
641
+ expr = f"if(lt(in-1,{part[0]}),{fn(part)},{expr})"
642
+ return f"({expr})"
643
+
644
+ Z = piecewise(lambda p: p[1])
645
+ # each segment's crop keeps its own eye line still (focus_y per segment)
646
+ T = piecewise(lambda p: f"{p[2]:.5f}*(1-1/({p[1]}))")
647
+ left = f"W*0.5*(1-1/{Z})"
648
+ top = f"H*{T}"
649
+ right = f"W*0.5*(1-1/{Z})+W/{Z}"
650
+ bottom = f"H*{T}+H/{Z}"
651
+ return (f"perspective=x0='{left}':y0='{top}':x1='{right}':y1='{top}':"
652
+ f"x2='{left}':y2='{bottom}':x3='{right}':y3='{bottom}':interpolation=cubic:eval=frame")
653
+
654
+
655
+ def segment_for(plan: Plan, f0: int, f1: int) -> dict | None:
656
+ """Camera segment covering most of [f0, f1)."""
657
+ best, best_ov = None, 0
658
+ for cs in plan.cam_segs:
659
+ ov = min(f1, cs["f1"]) - max(f0, cs["f0"])
660
+ if ov > best_ov:
661
+ best, best_ov = cs, ov
662
+ return best
663
+
664
+
665
+ # ============================================================== inserts
666
+
667
+ def plan_inserts(plan: Plan, tela_verde: bool = False) -> list[Insert]:
668
+ """tela_verde: split_top / split_bottom items become background items (to_bg, no panel, no seam)."""
669
+ raw = plan.data.get("inserts") or []
670
+ if not isinstance(raw, list):
671
+ raise ComposeError("inserts precisa ser uma lista", 2)
672
+ sfx_on = _enabled(_section(plan.data, "sfx"))
673
+ W, H, s = plan.W, plan.H, plan.s
674
+ lay = tr.layout_defaults((W, H))
675
+ safe_top, safe_bottom = lay["top"], lay["bottom"]
676
+ out: list[Insert] = []
677
+ seam_reach = (0.0, 0.0) # (above, below) px the captions reach from a split seam
678
+ cap = _section(plan.data, "captions")
679
+ if _enabled(cap) and not tela_verde and any(isinstance(it, dict) and it.get("type") in ("split_top", "split_bottom")
680
+ for it in raw):
681
+ style = cap.get("style")
682
+ if not style: # the same fallback build_tracks uses: the style captions.json was grouped for
683
+ try:
684
+ cues = load_json(plan.edit_dir / str(cap.get("file") or "captions.json"))
685
+ except (ValueError, OSError):
686
+ cues = None
687
+ style = (cues.get("style") if isinstance(cues, dict) else None) \
688
+ or (load_style_catalog().get("defaults") or {}).get("caption_style") or "impacto"
689
+ try:
690
+ seam_reach = tr.caption_extent([], style, (W, H), float(cap.get("font_scale", 1.0) or 1.0), plan.fps)
691
+ except (ValueError, TypeError):
692
+ seam_reach = (0.0, 0.0) # unknown style: build_tracks reports it
693
+ for i, item in enumerate(raw):
694
+ if not isinstance(item, dict):
695
+ raise ComposeError(f"inserts[{i}] precisa ser um objeto", 2)
696
+ typ = item.get("type")
697
+ if typ not in INSERT_TYPES:
698
+ raise ComposeError(f"inserts[{i}].type inválido: {typ!r}. Use {', '.join(INSERT_TYPES)}", 2)
699
+ src = _resolve(plan.edit_dir, item.get("src"), f"inserts[{i}].src")
700
+ ext = src.suffix.lower()
701
+ if ext in HEIC_EXT:
702
+ src, ext = heic_to_jpg(plan, src, i), ".jpg"
703
+ kind = "image" if ext in IMAGE_EXT else "video" if ext in VIDEO_EXT else None
704
+ if kind is None:
705
+ try:
706
+ Image.open(src).close()
707
+ kind = "image"
708
+ except Exception:
709
+ kind = "video"
710
+ try:
711
+ t0, t1 = float(item["start"]), float(item["end"])
712
+ except (KeyError, TypeError, ValueError):
713
+ raise ComposeError(f"inserts[{i}] precisa de start e end em segundos", 2)
714
+ f0, f1 = plan.fr(t0), plan.fr(t1)
715
+ if f0 >= plan.nf:
716
+ plan.warnings.append(f"insert {i + 1} começa depois do fim do vídeo e foi ignorado")
717
+ continue
718
+ if t1 * plan.fps > plan.nf + 0.5:
719
+ plan.warnings.append(f"insert {i + 1} passava do fim do vídeo e foi cortado em {fmt_time(plan.duration)}")
720
+ if f1 - f0 < 2:
721
+ plan.warnings.append(f"insert {i + 1} dura menos de 2 quadros e foi ignorado")
722
+ continue
723
+ kb_default = kind == "image"
724
+ split = item.get("split", 0.46)
725
+ try:
726
+ split = min(0.75, max(0.25, float(split if split is not None else 0.46)))
727
+ except (TypeError, ValueError):
728
+ raise ComposeError(f"inserts[{i}].split precisa ser um número entre 0.25 e 0.75", 2)
729
+ sfx_name = item.get("sfx", "whoosh")
730
+ if sfx_name in (None, False, "", "none"):
731
+ sfx_name = None
732
+ ins = Insert(idx=i, type=typ, src=src, kind=kind, f0=f0, f1=f1,
733
+ ken_burns=bool(item.get("ken_burns", kb_default)), split=split,
734
+ sfx=str(sfx_name) if (sfx_name and sfx_on) else None)
735
+ if tela_verde:
736
+ ins.item = item
737
+ if tela_verde and typ in ("split_top", "split_bottom"):
738
+ ins.to_bg = True # its "split" does not apply: it is the background of [f0, f1)
739
+ ins.content = (0, 0, W, H)
740
+ elif typ in ("split_top", "split_bottom"):
741
+ hb = even(split * H)
742
+ hs = H - hb
743
+ # eyes of the segment under this window, where the camera left them (focus_y is the eye line)
744
+ seg = segment_for(plan, f0, min(f1, plan.nf))
745
+ face = (seg["focus_y"] if seg else 0.40) * H
746
+ if typ == "split_top":
747
+ ins.content = (0, 0, W, hb)
748
+ ins.speaker = (0, hb, W, hs)
749
+ ins.seam_y = hb
750
+ else:
751
+ ins.content = (0, hs, W, hb)
752
+ ins.speaker = (0, 0, W, hs)
753
+ ins.seam_y = hs
754
+ ins.speaker_src_y = face_layout.speaker_src_y(typ, hb / H, face, W, H, safe_top, safe_bottom)
755
+ # the face can sit at another height in each take the window covers: re-frame per segment
756
+ pieces: list[tuple[int, int, int]] = []
757
+ for cs in plan.cam_segs:
758
+ a, b = max(f0, cs["f0"]), min(f1, cs["f1"])
759
+ if b <= a:
760
+ continue
761
+ core = None
762
+ if cs.get("face") and cs["face"].get("core"):
763
+ rects = [face_layout.zoom_rect(cs["face"]["core"], z, cs["focus_y"]) for z in {cs["zoom"], cs["zoom_max"]}]
764
+ core = (min(r["y0"] for r in rects) * H, max(r["y1"] for r in rects) * H)
765
+ reach = seam_reach[0] if typ == "split_bottom" else seam_reach[1]
766
+ y = face_layout.speaker_src_y(typ, hb / H, cs["focus_y"] * H, W, H, safe_top, safe_bottom,
767
+ core=core, clear=reach + GAP_FACE * H if reach else 0.0)
768
+ if pieces and pieces[-1][2] == y and pieces[-1][1] == a:
769
+ pieces[-1] = (pieces[-1][0], b, y)
770
+ else:
771
+ pieces.append((a, b, y))
772
+ ins.speaker_pieces = pieces or [(f0, f1, ins.speaker_src_y)]
773
+ ins.speaker_src_y = max(ins.speaker_pieces, key=lambda p: p[1] - p[0])[2]
774
+ elif typ == "full":
775
+ ins.content = (0, 0, W, H)
776
+ else: # pip
777
+ aspect = 1.0
778
+ if kind == "image":
779
+ with Image.open(src) as im:
780
+ im = ImageOps.exif_transpose(im)
781
+ aspect = im.width / max(1, im.height)
782
+ else:
783
+ try:
784
+ info = ffprobe_json(src)
785
+ v = next(st for st in info.get("streams", []) if st.get("codec_type") == "video")
786
+ aspect = int(v["width"]) / max(1, int(v["height"]))
787
+ except Exception:
788
+ aspect = 1.0
789
+ aspect = min(1.25, max(0.8, aspect))
790
+ border = max(2, int(round(10 * s)))
791
+ cw = even(380 * s)
792
+ ch = even(cw / aspect)
793
+ ow, oh = cw + 2 * border, ch + 2 * border
794
+ corner = str(item.get("corner") or "top_right")
795
+ if corner not in ("top_right", "top_left"):
796
+ raise ComposeError(f"inserts[{i}].corner inválido: {corner!r}. Use top_right ou top_left", 2)
797
+ margin_x = int(round(48 * s))
798
+ ox = W - margin_x - ow if corner == "top_right" else margin_x
799
+ oy = int(round(safe_top + 26 * s))
800
+ ins.card = (ox, oy, ow, oh)
801
+ ins.content = (ox + border, oy + border, cw, ch)
802
+ out.append(ins)
803
+ for k, a in enumerate(out):
804
+ for b in out[k + 1:]:
805
+ if b.f0 < a.f1 and a.f0 < b.f1 and a.to_bg == b.to_bg:
806
+ plan.warnings.append(f"inserts {a.idx + 1} e {b.idx + 1} se sobrepõem ({fmt_time(max(a.f0, b.f0) / plan.fps)}): "
807
+ f"o {b.idx + 1}, que vem depois na lista, fica por cima")
808
+ return out # list order = drawing order (a later insert is drawn over an earlier one)
809
+
810
+
811
+ def heic_to_jpg(plan: Plan, src: Path, i: int, where: str | None = None) -> Path:
812
+ """iPhone photos (HEIC/HEIF) -> JPG once, with macOS `sips` (cached by the file signature)."""
813
+ where = where or f"inserts[{i}].src"
814
+ sips = shutil.which("sips") if sys.platform == "darwin" else None
815
+ if not sips:
816
+ raise ComposeError(f"{where} é uma foto HEIC do iPhone ({src.name}), que não abre aqui. "
817
+ f"Converta para JPG (no iPhone: Ajustes > Câmera > Formatos > Mais Compatível; ou abra e "
818
+ f"exporte como JPG) e use o arquivo .jpg", 2)
819
+ out_dir = plan.edit_dir / "renders" / "heic"
820
+ out_dir.mkdir(parents=True, exist_ok=True)
821
+ out = out_dir / f"{src.stem}_{short_hash((str(src), file_signature(src)))[:10]}.jpg"
822
+ if out.exists() and out.stat().st_size > 0:
823
+ return out
824
+ tmp = out.with_name(".tmp-" + out.name)
825
+ proc = subprocess.run([sips, "-s", "format", "jpeg", "-s", "formatOptions", "93", str(src), "--out", str(tmp)],
826
+ capture_output=True, text=True)
827
+ if proc.returncode != 0 or not tmp.exists():
828
+ raise ComposeError(f"não consegui converter a foto HEIC {where} ({src.name}) para JPG. "
829
+ f"Exporte a foto como JPG e use o arquivo .jpg", 2)
830
+ os.replace(tmp, out)
831
+ return out
832
+
833
+
834
+ # ============================================================== tela verde (SPEC Contract 13)
835
+
836
+ EDIT_TYPES = ("clean", "split_top", "split_bottom", "tela_verde")
837
+
838
+
839
+ def resolve_media(plan: Plan, value, where: str) -> tuple[Path, str]:
840
+ """An image or video file of the edit -> (path, "image" | "video"), HEIC converted like the inserts."""
841
+ if not isinstance(value, str):
842
+ raise ComposeError(f"{where} precisa ser o caminho de um arquivo (ex: \"assets/foto.jpg\")", 2)
843
+ src = _resolve(plan.edit_dir, value, where)
844
+ ext = src.suffix.lower()
845
+ if ext in HEIC_EXT:
846
+ src, ext = heic_to_jpg(plan, src, 0, where), ".jpg"
847
+ kind = "image" if ext in IMAGE_EXT else "video" if ext in VIDEO_EXT else None
848
+ if kind is None:
849
+ try:
850
+ Image.open(src).close()
851
+ kind = "image"
852
+ except Exception:
853
+ kind = "video"
854
+ return src, kind
855
+
856
+
857
+ def plan_tela_verde(plan: Plan):
858
+ """edit_type "tela_verde": check the section before anything renders (exit 2). None for the other types."""
859
+ et = plan.data.get("edit_type")
860
+ if et is None or et in EDIT_TYPES[:3]:
861
+ if plan.data.get("tela_verde") is not None:
862
+ plan.warnings.append("a seção tela_verde ficou de fora: para usar, ponha \"edit_type\": \"tela_verde\"")
863
+ return None
864
+ if et != "tela_verde":
865
+ raise ComposeError(f"edit_type {_as_json(et)} não existe. Use {', '.join(EDIT_TYPES)}", 2)
866
+ import matte as MT
867
+ import tela_verde as TV
868
+
869
+ try:
870
+ cfg = TV.parse_config(plan.data.get("tela_verde"), plan.fps, plan.nf,
871
+ lambda value, where: resolve_media(plan, value, where), plan.fr,
872
+ screen=(plan.full_w, plan.full_h))
873
+ MT.default_engine_ready()
874
+ except TV.TelaVerdeError as e:
875
+ raise ComposeError(str(e), e.code)
876
+ except MT.MatteError as e:
877
+ raise ComposeError(str(e), e.code)
878
+ return cfg # its warnings go to the report after the split inserts join it (insert_backgrounds)
879
+
880
+
881
+ def insert_backgrounds(plan: Plan, cfg, inserts: list[Insert]) -> None:
882
+ """Tela verde: the split_top / split_bottom inserts become background items (they win over tela_verde.fundo
883
+ during their own frames); then the warnings of the section go to the report."""
884
+ import tela_verde as TV
885
+
886
+ moved = [i for i in inserts if i.to_bg]
887
+ try:
888
+ TV.add_insert_backgrounds(cfg, [{"insert": i.idx + 1, "type": i.type, "src": i.src, "kind": i.kind,
889
+ "f0": i.f0, "f1": i.f1, "ken_burns": i.ken_burns, "item": i.item}
890
+ for i in moved], plan.nf, plan.fps)
891
+ except TV.TelaVerdeError as e:
892
+ raise ComposeError(str(e), e.code)
893
+ for ins in inserts:
894
+ bad = [k for k in ("escurecer", "desfocar", "encaixe") if ins.item and k in ins.item and not ins.to_bg]
895
+ if bad:
896
+ plan.warnings.append(f"inserts[{ins.idx}].{bad[0]} só vale para imagem em cima ou embaixo na tela verde "
897
+ f"(que vira fundo): no {ins.type} ficou de fora")
898
+ plan.warnings.extend(cfg.warnings)
899
+ if moved:
900
+ names = ", ".join(str(i.idx + 1) for i in moved)
901
+ kinds = sorted({TV.SPLIT_LABEL[i.type] for i in moved})
902
+ log(f"tela verde: {'o insert' if len(moved) == 1 else 'os inserts'} {names} ({' e '.join(kinds)}) "
903
+ f"{'vai' if len(moved) == 1 else 'vão'} para o fundo, atrás de você (sem tela dividida)")
904
+
905
+
906
+ def prepare_tela_verde(plan: Plan, cfg) -> None:
907
+ """Person segments, their mattes (computed now when not cached), placement, and the face and body of each
908
+ segment where they land on the screen (cam_segs switch to screen coordinates without zoom)."""
909
+ import matte as MT
910
+ import tela_verde as TV
911
+
912
+ t0 = time.time()
913
+ segs = []
914
+ for cs in plan.cam_segs:
915
+ has_face = bool(cs.get("face"))
916
+ mode = "pessoa" if (has_face or cfg.no_face == "recorte" or plan.faces is None) else "original"
917
+ push = cs["zoom_max"] / cs["zoom"] - 1.0 if cs["zoom_max"] > cs["zoom"] + 1e-9 else 0.0
918
+ segs.append(TV.Segment(f0=cs["f0"], f1=cs["f1"], mode=mode, zoom=cs["zoom"], push=push,
919
+ focus_y=cs["focus_y"], face=cs.get("face") if has_face else None))
920
+ people = [i for i, sg in enumerate(segs) if sg.mode == "pessoa"]
921
+ mset = None
922
+ mattes: dict[int, object] = {}
923
+ if people:
924
+ try:
925
+ mset = MT.ensure(plan.edit_dir, plan.base, [(segs[i].f0, segs[i].f1) for i in people], plan.fps,
926
+ (plan.full_w, plan.full_h), plan.timeline, progress=log,
927
+ base_fps=plan.base_fps, base_size=(plan.base_w, plan.base_h))
928
+ except MT.MatteError as e:
929
+ raise ComposeError(f"recorte da pessoa: {e}", e.code)
930
+ plan.warnings.extend(mset.warnings)
931
+ for i, job in zip(people, mset.jobs):
932
+ mattes[i] = job
933
+ segs[i].stats = job.meta.get("stats") or {}
934
+ if cfg.mirror: # the person segments are drawn flipped: place them as they will be seen
935
+ segs[i].stats = TV.mirror_stats(segs[i].stats)
936
+ segs[i].face = TV.mirror_face(segs[i].face)
937
+ TV.plan_segments(cfg, segs)
938
+ for sg in segs:
939
+ if sg.note:
940
+ plan.warnings.append(f"tela verde, trecho {fmt_time(sg.f0 / plan.fps)}-{fmt_time(sg.f1 / plan.fps)}: {sg.note}")
941
+ filled = merge_windows([(sg.f0, sg.f1) for sg in segs if sg.fill])
942
+ if filled:
943
+ spans = ", ".join(f"{fmt_time(a / plan.fps)}-{fmt_time(b / plan.fps)}" for a, b in filled)
944
+ plan.warnings.append(f"tela verde: em {spans} a câmera corta os dois lados do seu corpo, então ali você "
945
+ f"ocupa a largura toda da tela (um pouco mais baixo), para o corte não aparecer no meio "
946
+ f"da tela")
947
+ inner = TV.inner_cut(cfg, segs, plan.W, plan.H)
948
+ if inner:
949
+ plan.warnings.append(inner["aviso"])
950
+ for cs, sg in zip(plan.cam_segs, segs):
951
+ cs["tv_zoom"], cs["tv_zoom_max"], cs["tv_mode"] = cs["zoom"], cs["zoom_max"], sg.mode
952
+ if sg.mode == "pessoa":
953
+ z = TV.screen_zones(cfg, sg, plan.W, plan.H)
954
+ face = z["face"]
955
+ cs["face"] = ({"zone": face["zone"], "core": face["core"], "face": {"eyes_y": face["eyes_y"]}}
956
+ if face else None)
957
+ if face:
958
+ cs["focus_y"] = min(0.9, max(0.1, face["eyes_y"]))
959
+ cs["person"] = z["person"]
960
+ cs["zoom"] = cs["zoom_max"] = 1.0 # the zoom is already in the person layer
961
+ for b in cfg.backgrounds: # the main area of a still picture shown whole (its empty margin may be covered)
962
+ if b.fit == "inteiro" and b.kind == "image" and b.size:
963
+ try:
964
+ b.content = TV.content_box(TV.read_image(b.src))
965
+ except Exception as e: # noqa: BLE001
966
+ raise ComposeError(f"não consegui abrir a imagem do fundo ({b.name}): {b.src} ({e})", 2)
967
+ elif b.kind == "video" and b.src is not None:
968
+ # empty frames at its start and end; shown whole: its main area over the clip and a plain border
969
+ try:
970
+ info = TV.scan_video(b.src, plan.fps, plan.renders / "telaverde")
971
+ except Exception as e: # noqa: BLE001
972
+ raise ComposeError(f"não consegui ler o vídeo do fundo ({b.name}): {b.src} ({e})", 1)
973
+ b.pass_frames, b.lead, b.tail = int(info["frames"]), int(info["lead"]), int(info["tail"])
974
+ if b.fit == "inteiro" and b.size:
975
+ b.content = tuple(float(v) for v in info["content"])
976
+ b.quiet = bool(info["quiet"])
977
+ TV.plan_stages(cfg, segs, plan.W, plan.H, caption_room(plan))
978
+ plan.image_zones = TV.image_zones(cfg, segs, plan.W, plan.H)
979
+ plates, plate_rows = room_plates(plan, mattes)
980
+ plan.tv = {"cfg": cfg, "segments": segs, "mattes": mattes, "mset": mset, "inner_cut": inner,
981
+ "plates": plates, "plate_rows": plate_rows,
982
+ "matte_seconds": round(mset.seconds, 2) if mset else 0.0, "prepare_seconds": round(time.time() - t0, 2)}
983
+ cam = next((layer for layer in plan.layers if layer["type"] == "camera"), None)
984
+ if cam is not None:
985
+ cam["applied_to"] = "tela_verde"
986
+
987
+
988
+ def room_plates(plan: Plan, mattes: dict) -> tuple[dict, list[dict]]:
989
+ """Tela verde: the room plate of each take (the ranges of one source video with the same framing), so the
990
+ render can take out the room a matte keeps. -> ({segment index: plate}, report rows)."""
991
+ import matte as MT
992
+ import tela_verde as TV
993
+
994
+ if not mattes or not plan.timeline or os.environ.get(TV.PLATE_ENV, "1") == "0":
995
+ return {}, []
996
+ groups: dict[str, list] = {}
997
+ names: dict[str, str] = {}
998
+ for i, job in mattes.items():
999
+ tseg = MT._timeline_segment(plan.timeline, job.f0, job.f1, plan.fps)
1000
+ if not tseg or tseg.get("source") is None:
1001
+ continue
1002
+ cam = str(tseg.get("imagem_de") or tseg.get("source")) # the camera the picture came from
1003
+ g = json.dumps([cam, tseg.get("crop")], sort_keys=True, ensure_ascii=False)
1004
+ groups.setdefault(g, []).append((i, job))
1005
+ names[g] = cam
1006
+ if not groups:
1007
+ return {}, []
1008
+ try:
1009
+ found, notes = TV.room_plates(plan.base, _matte_pre(plan), plan.base_fps, plan.fps,
1010
+ (plan.full_w, plan.full_h),
1011
+ {g: [(j.f0, j.f1, j) for _, j in parts] for g, parts in groups.items()},
1012
+ plan.edit_dir / MT.CACHE_DIR, progress=log)
1013
+ except (OSError, ValueError, MT.MatteError) as e:
1014
+ plan.warnings.append(f"tela verde: não consegui aprender o fundo do cenário ({e}); o recorte ficou como o "
1015
+ f"modelo fez")
1016
+ return {}, []
1017
+ plates, rows = {}, []
1018
+ for g, parts in groups.items():
1019
+ plate = found.get(g)
1020
+ for i, _ in parts:
1021
+ if plate is not None:
1022
+ plates[i] = plate
1023
+ rows.append({"tomada": names[g], "trechos": len(parts), "usado": plate is not None,
1024
+ "conhece": plate.known if plate is not None else None,
1025
+ "motivo": None if plate is not None else (notes.get(g) or None)})
1026
+ return plates, rows
1027
+
1028
+
1029
+ def compute_person_zones(plan: Plan, inserts: list[Insert]) -> None:
1030
+ """plan.person_zones: where the cut-out body is on the screen, per frame interval (tela verde only)."""
1031
+ if not plan.tv:
1032
+ return
1033
+ W, H = plan.W, plan.H
1034
+ pieces = []
1035
+ for cs in plan.cam_segs:
1036
+ r = cs.get("person")
1037
+ rects = [(r["x0"] * W, r["y0"] * H, r["x1"] * W, r["y1"] * H)] if r else []
1038
+ pieces.append((cs["f0"], cs["f1"], rects))
1039
+ cuts = sorted({0, plan.nf} | {p[0] for p in pieces} | {p[1] for p in pieces}
1040
+ | {min(plan.nf, i.f0) for i in inserts} | {min(plan.nf, i.f1) for i in inserts})
1041
+ out: list[tuple] = []
1042
+ for a, b in zip(cuts, cuts[1:]):
1043
+ if b <= a:
1044
+ continue
1045
+ base = next((p for p in pieces if p[0] <= a < p[1]), None)
1046
+ rects = base[2] if base else []
1047
+ top = None
1048
+ for ins in inserts:
1049
+ if ins.type != "pip" and ins.f0 <= a and b <= ins.f1:
1050
+ top = ins
1051
+ if top is not None and top.type == "full":
1052
+ rects = []
1053
+ elif top is not None and top.speaker is not None:
1054
+ _, band_y, _, hs = top.speaker
1055
+ src_y = next((y for p0, p1, y in top.speaker_pieces if p0 <= a < p1), top.speaker_src_y)
1056
+ moved = []
1057
+ for r in rects:
1058
+ y0, y1 = max(band_y, r[1] - src_y + band_y), min(band_y + hs, r[3] - src_y + band_y)
1059
+ if y1 > y0:
1060
+ moved.append((r[0], y0, r[2], y1))
1061
+ rects = moved
1062
+ if out and out[-1][1] == a and out[-1][2] == rects:
1063
+ out[-1] = (out[-1][0], b, rects)
1064
+ else:
1065
+ out.append((a, b, rects))
1066
+ plan.person_zones = out
1067
+
1068
+
1069
+ def person_bands(plan: Plan, f0: int, f1: int, gap: float = 0.0,
1070
+ skip: list[tuple[int, int]] | tuple = ()) -> list[tuple[float, float]]:
1071
+ """Vertical bands taken by the cut-out body during [f0, f1) (empty unless tela verde)."""
1072
+ out = []
1073
+ for a, b, rects in plan.person_zones:
1074
+ if b <= f0 or a >= f1:
1075
+ continue
1076
+ lo_f, hi_f = max(a, f0), min(b, f1)
1077
+ if any(h0 <= lo_f and hi_f <= h1 for h0, h1 in skip):
1078
+ continue
1079
+ for r in rects:
1080
+ out.append((r[1] - gap, r[3] + gap))
1081
+ return out
1082
+
1083
+
1084
+ def image_bands(plan: Plan, f0: int, f1: int, gap: float = 0.0,
1085
+ skip: list[tuple[int, int]] | tuple = ()) -> list[tuple[float, float]]:
1086
+ """Vertical bands taken by the main area of a whole background picture during [f0, f1) (tela verde only)."""
1087
+ out = []
1088
+ for a, b, r in plan.image_zones:
1089
+ if b <= f0 or a >= f1:
1090
+ continue
1091
+ lo_f, hi_f = max(a, f0), min(b, f1)
1092
+ if any(h0 <= lo_f and hi_f <= h1 for h0, h1 in skip):
1093
+ continue
1094
+ out.append((r[1] - gap, r[3] + gap))
1095
+ return out
1096
+
1097
+
1098
+ def caption_room(plan: Plan):
1099
+ """Tela verde: the height auto captions need between a whole background picture and the head, for the frame
1100
+ spans of that picture (0 when the captions are off, manual or silent there)."""
1101
+ cap = _section(plan.data, "captions")
1102
+ if not _enabled(cap):
1103
+ return None
1104
+ try:
1105
+ manual = None if _is_auto(cap.get("center_y")) else float(cap.get("center_y")) * plan.s
1106
+ cues = load_json(plan.edit_dir / str(cap.get("file") or "captions.json")) or {}
1107
+ style = cap.get("style") or (cues.get("style") if isinstance(cues, dict) else None) \
1108
+ or (load_style_catalog().get("defaults") or {}).get("caption_style") or "impacto"
1109
+ above, below = tr.caption_extent(cues, style, (plan.W, plan.H), float(cap.get("font_scale", 1.0) or 1.0),
1110
+ plan.fps)
1111
+ except (ValueError, TypeError, OSError, AttributeError):
1112
+ return None # build_tracks reports what is wrong with the captions
1113
+ if manual is not None:
1114
+ # a height written by hand keeps its place unless it lands on the face (then it is placed like auto)
1115
+ gap = GAP_FACE * plan.H
1116
+ faces = [(cs["face"]["zone"]["y0"] * plan.H - gap, cs["face"]["zone"]["y1"] * plan.H + gap)
1117
+ for cs in plan.cam_segs if cs.get("tv_mode") == "pessoa" and (cs.get("face") or {}).get("zone")]
1118
+ if not any(manual - above < b and a < manual + below for a, b in faces):
1119
+ return None
1120
+ spans = []
1121
+ for cue in (cues.get("cues") if isinstance(cues, dict) else cues) or []:
1122
+ try:
1123
+ spans.append((plan.fr(float(cue["start"])), plan.fr(float(cue["end"]))))
1124
+ except (KeyError, TypeError, ValueError):
1125
+ continue
1126
+ need = above + below + GAP_FACE * plan.H
1127
+
1128
+ def room(item_spans: list[tuple[int, int]]) -> float:
1129
+ heard = any(a < f1 and f0 < b for f0, f1 in item_spans for a, b in spans)
1130
+ return need if heard else 0.0
1131
+ return room
1132
+
1133
+
1134
+ def place_pips_tela_verde(plan: Plan, inserts: list[Insert]) -> None:
1135
+ """Tela verde: a pip card goes to the top corner away from the person (the one asked, when it is free of the
1136
+ face), and to the other corner when the face would be under it."""
1137
+ cfg = plan.tv["cfg"]
1138
+ W, s = plan.W, plan.s
1139
+ for ins in inserts:
1140
+ if ins.type != "pip" or not ins.card:
1141
+ continue
1142
+ ox, oy, ow, oh = ins.card
1143
+ margin_x = int(round(48 * s))
1144
+ corners = {"top_right": W - margin_x - ow, "top_left": margin_x}
1145
+ asked = (ins.item or {}).get("corner")
1146
+ away = "top_left" if cfg.position == "direita" else "top_right"
1147
+ order = [asked, "top_right" if asked == "top_left" else "top_left"] if asked else \
1148
+ [away, "top_right" if away == "top_left" else "top_left"]
1149
+ faces = []
1150
+ for cs in plan.cam_segs:
1151
+ face = cs.get("face")
1152
+ if cs.get("tv_mode") != "pessoa" or not face or not face.get("zone") or cs["f1"] <= ins.f0 or cs["f0"] >= ins.f1:
1153
+ continue
1154
+ z = face["zone"]
1155
+ faces.append((z["x0"] * W, z["y0"] * plan.H, z["x1"] * W, z["y1"] * plan.H))
1156
+ pad = GAP_FACE * plan.H
1157
+ chosen = None
1158
+ for corner in order:
1159
+ rect = (corners[corner] - pad, oy - pad, corners[corner] + ow + pad, oy + oh + pad)
1160
+ if not any(boxes_hit(rect, f) for f in faces):
1161
+ chosen = corner
1162
+ break
1163
+ if chosen is None:
1164
+ chosen = order[0]
1165
+ plan.warnings.append(f"o cartão do insert {ins.idx + 1} (pip) encosta no rosto nos dois cantos de cima: "
1166
+ f"mude o tempo dele ou use a tela verde em outra posição")
1167
+ elif asked and chosen != asked:
1168
+ plan.adjustments.append({"layer": f"insert {ins.idx + 1}", "action": "moved", "start": round(ins.f0 / plan.fps, 3),
1169
+ "end": round(ins.f1 / plan.fps, 3), "because": "rosto (tela verde)",
1170
+ "corner_asked": asked, "corner": chosen})
1171
+ plan.warnings.append(f"o cartão do insert {ins.idx + 1} (pip) foi para o canto "
1172
+ f"{'esquerdo' if chosen == 'top_left' else 'direito'} para não cobrir o rosto")
1173
+ x = corners[chosen]
1174
+ border = ins.content[0] - ox
1175
+ ins.card = (x, oy, ow, oh)
1176
+ ins.content = (x + border, ins.content[1], ins.content[2], ins.content[3])
1177
+
1178
+
1179
+ def render_tela_verde(plan: Plan) -> dict:
1180
+ import tela_verde as TV
1181
+
1182
+ tv = plan.tv
1183
+ job = TV.Job(cfg=tv["cfg"], segments=tv["segments"], mattes=tv["mattes"], base=plan.base,
1184
+ base_pre=_matte_pre(plan), base_fps=plan.base_fps, fps=plan.fps, size=(plan.W, plan.H),
1185
+ full_size=(plan.full_w, plan.full_h), nf=plan.nf, out_dir=plan.renders / "telaverde",
1186
+ plates=tv.get("plates") or {})
1187
+ try:
1188
+ return TV.render(job, progress=log)
1189
+ except TV.TelaVerdeError as e:
1190
+ raise ComposeError(str(e), e.code)
1191
+
1192
+
1193
+ def _matte_pre(plan: Plan) -> list[str]:
1194
+ import matte as MT
1195
+ return MT.base_pre_filters(plan.base_fps, (plan.base_w, plan.base_h), plan.fps, (plan.full_w, plan.full_h))
1196
+
1197
+
1198
+ def tela_verde_layer(plan: Plan, clip: dict) -> dict:
1199
+ import tela_verde as TV
1200
+
1201
+ tv = plan.tv
1202
+ cfg = tv["cfg"]
1203
+ fps = plan.fps
1204
+ fundo = TV.report_backgrounds(cfg, fps, plan.W, plan.H, 1.0 / plan.s, plan.rel)
1205
+ moved = [{"insert": b.insert, "type": b.insert_type, "src": plan.rel(b.src), "kind": b.kind,
1206
+ "start": round(b.f0 / fps, 3), "end": round(b.f1 / fps, 3), "encaixe": b.fit,
1207
+ "encaixe_pedido": b.fit_asked, "ken_burns": b.ken_burns, "escurecer": b.darken, "desfocar": b.blur,
1208
+ "rect": [int(round(v / plan.s)) for v in b.place] if b.place else None,
1209
+ "stage": [int(round(v / plan.s)) for v in b.stage] if b.stage else None}
1210
+ for b in cfg.from_inserts]
1211
+ mset = tv["mset"]
1212
+ return {"type": "tela_verde", "posicao": cfg.position, "altura": cfg.height,
1213
+ "contorno": ({"cor": "#%02X%02X%02X" % tuple(cfg.outline[0]), "largura": cfg.outline[1]}
1214
+ if cfg.outline else None),
1215
+ "sombra": cfg.shadow, "fundo_original_desfocado": cfg.blurred_original, "sem_rosto": cfg.no_face,
1216
+ "espelhar": cfg.mirror,
1217
+ "corte_dentro": ({k: v for k, v in tv["inner_cut"].items() if k != "aviso"} if tv.get("inner_cut") else None),
1218
+ "fundo": fundo, "inserts_no_fundo": moved,
1219
+ "segments": TV.report_segments(cfg, tv["segments"], fps, plan.W, plan.H, 1.0 / plan.s),
1220
+ "matte": mset.report(plan.rel) if mset else None,
1221
+ "fundo_do_cenario": tv.get("plate_rows") or [],
1222
+ "clip": plan.rel(Path(clip["file"])), "cached": bool(clip.get("cached")),
1223
+ "seconds": {"matte": tv["matte_seconds"], "compose": clip.get("render_seconds", 0.0)}}
1224
+
1225
+
1226
+ def _kb_path(ins: Insert, n: int, pw: float, ph: float, cw: int, ch: int):
1227
+ """Per-frame (window_w, window_h, center_x, center_y) in prescaled source pixels."""
1228
+ zoom_in = ins.idx % 2 == 0
1229
+ z0, z1 = (1.0, 1.0 + KB_DRIFT) if zoom_in else (1.0 + KB_DRIFT, 1.0)
1230
+ base_w, base_h = cw * (1.0 + KB_DRIFT), ch * (1.0 + KB_DRIFT)
1231
+ dirs = [(1, -0.5), (-1, 0.5), (0.6, 1), (-0.6, -1)]
1232
+ dx, dy = dirs[ins.idx % 4]
1233
+ frames = []
1234
+ for j in range(n):
1235
+ p = ease_in_out(j / max(1, n - 1))
1236
+ z = z0 + (z1 - z0) * p
1237
+ ww, wh = base_w / z, base_h / z
1238
+ # pan inside whatever room the window leaves (at most 3% of the window)
1239
+ room_x = max(0.0, (pw - base_w) / 2.0) + (base_w - ww) / 2.0
1240
+ room_y = max(0.0, (ph - base_h) / 2.0) + (base_h - wh) / 2.0
1241
+ span_x = min(room_x, 0.03 * ww) * dx
1242
+ span_y = min(room_y, 0.03 * wh) * dy
1243
+ cx = pw / 2.0 + span_x * (2 * p - 1)
1244
+ cy = ph / 2.0 + span_y * (2 * p - 1)
1245
+ cx = min(max(cx, ww / 2.0), pw - ww / 2.0)
1246
+ cy = min(max(cy, wh / 2.0), ph - wh / 2.0)
1247
+ frames.append((ww, wh, cx, cy))
1248
+ return frames
1249
+
1250
+
1251
+ def _encoder(out_tmp: Path, w: int, h: int, fps: int, n: int) -> subprocess.Popen:
1252
+ return subprocess.Popen(
1253
+ ["ffmpeg", "-hide_banner", "-nostdin", "-v", "error", "-y", "-f", "rawvideo", "-pix_fmt", "rgb24",
1254
+ "-s", f"{w}x{h}", "-framerate", str(fps), "-i", "-",
1255
+ "-vf", "scale=out_color_matrix=bt709:out_range=tv,format=yuv420p",
1256
+ "-c:v", "libx264", "-preset", "veryfast", "-crf", "12", "-g", str(fps),
1257
+ "-colorspace", "bt709", "-color_primaries", "bt709", "-color_trc", "bt709", "-color_range", "tv",
1258
+ "-frames:v", str(n), "-f", "mp4", str(out_tmp)],
1259
+ stdin=subprocess.PIPE, stderr=subprocess.PIPE)
1260
+
1261
+
1262
+ def render_insert_clip(plan: Plan, ins: Insert) -> Path:
1263
+ cw, ch = ins.content[2], ins.content[3]
1264
+ n = ins.f1 - ins.f0
1265
+ st = ins.src.stat()
1266
+ key = short_hash((RENDER_VERSION, str(ins.src), st.st_size, st.st_mtime_ns, ins.kind, cw, ch, n, plan.fps,
1267
+ ins.ken_burns, ins.idx % 4))
1268
+ out_dir = plan.renders / "inserts"
1269
+ out_dir.mkdir(parents=True, exist_ok=True)
1270
+ out = out_dir / f"ins_{key}.mp4"
1271
+ if out.exists() and out.stat().st_size > 0:
1272
+ return out
1273
+ tmp = out.with_name(".tmp-" + out.name)
1274
+ margin = 1.0 + KB_DRIFT if ins.ken_burns else 1.0
1275
+
1276
+ if ins.kind == "video" and not ins.ken_burns:
1277
+ ffmpeg_cmd(["-v", "error", "-y", "-stream_loop", "-1", "-i", ins.src, "-an",
1278
+ "-vf", (f"fps={plan.fps},scale={cw}:{ch}:force_original_aspect_ratio=increase:flags=lanczos,"
1279
+ f"crop={cw}:{ch},setsar=1,scale=out_color_matrix=bt709:out_range=tv,format=yuv420p"),
1280
+ "-c:v", "libx264", "-preset", "veryfast", "-crf", "12", "-g", str(plan.fps),
1281
+ "-colorspace", "bt709", "-color_primaries", "bt709", "-color_trc", "bt709", "-color_range", "tv",
1282
+ "-frames:v", n, "-f", "mp4", tmp], f"preparar o vídeo do insert {ins.idx + 1}")
1283
+ os.replace(tmp, out)
1284
+ return out
1285
+
1286
+ pw_target, ph_target = cw * margin, ch * margin
1287
+ if ins.kind == "image":
1288
+ with Image.open(ins.src) as im:
1289
+ im = ImageOps.exif_transpose(im)
1290
+ if im.mode in ("RGBA", "LA", "P"):
1291
+ rgba = im.convert("RGBA")
1292
+ bg = Image.new("RGB", rgba.size, (18, 18, 18))
1293
+ bg.paste(rgba, mask=rgba.getchannel("A"))
1294
+ src_img = bg
1295
+ else:
1296
+ src_img = im.convert("RGB")
1297
+ sc = max(pw_target / src_img.width, ph_target / src_img.height)
1298
+ pw, ph = max(cw, int(math.ceil(src_img.width * sc))), max(ch, int(math.ceil(src_img.height * sc)))
1299
+ pre = src_img.resize((pw, ph), Image.LANCZOS)
1300
+
1301
+ def frames_iter():
1302
+ while True:
1303
+ yield pre
1304
+ else:
1305
+ pw, ph = int(math.ceil(pw_target)), int(math.ceil(ph_target))
1306
+ dec = subprocess.Popen(
1307
+ ["ffmpeg", "-hide_banner", "-nostdin", "-v", "error", "-stream_loop", "-1", "-i", str(ins.src), "-an",
1308
+ "-vf", f"fps={plan.fps},scale={pw}:{ph}:force_original_aspect_ratio=increase:flags=lanczos,crop={pw}:{ph},format=rgb24",
1309
+ "-frames:v", str(n), "-f", "rawvideo", "-pix_fmt", "rgb24", "-"],
1310
+ stdout=subprocess.PIPE, stderr=subprocess.DEVNULL)
1311
+
1312
+ def frames_iter():
1313
+ size = pw * ph * 3
1314
+ last = None
1315
+ while True:
1316
+ buf = dec.stdout.read(size) if dec.stdout else b""
1317
+ if len(buf) == size:
1318
+ last = Image.frombuffer("RGB", (pw, ph), buf, "raw", "RGB", 0, 1)
1319
+ if last is None:
1320
+ raise ComposeError(f"não consegui ler o vídeo do insert {ins.idx + 1}: {ins.src}")
1321
+ yield last
1322
+
1323
+ enc = _encoder(tmp, cw, ch, plan.fps, n)
1324
+ try:
1325
+ path = _kb_path(ins, n, pw, ph, cw, ch) if ins.ken_burns else None
1326
+ it = frames_iter()
1327
+ for j in range(n):
1328
+ frame = next(it)
1329
+ if path is None:
1330
+ x0, y0 = (frame.width - cw) // 2, (frame.height - ch) // 2
1331
+ img = frame.crop((x0, y0, x0 + cw, y0 + ch))
1332
+ else:
1333
+ ww, wh, cx, cy = path[j]
1334
+ qx, qy = ww / cw, wh / ch
1335
+ img = frame.transform((cw, ch), Image.AFFINE,
1336
+ (qx, 0.0, cx - ww / 2.0, 0.0, qy, cy - wh / 2.0),
1337
+ resample=Image.BICUBIC)
1338
+ enc.stdin.write(img.tobytes())
1339
+ enc.stdin.close()
1340
+ err = enc.stderr.read().decode("utf-8", "replace") if enc.stderr else ""
1341
+ if enc.wait() != 0:
1342
+ raise ComposeError(f"falha ao codificar o insert {ins.idx + 1}: {err[-500:]}")
1343
+ except BrokenPipeError:
1344
+ err = enc.stderr.read().decode("utf-8", "replace") if enc.stderr else ""
1345
+ raise ComposeError(f"falha ao codificar o insert {ins.idx + 1}: {err[-500:]}")
1346
+ finally:
1347
+ if ins.kind == "video":
1348
+ try:
1349
+ dec.kill() # type: ignore[possibly-undefined]
1350
+ except Exception:
1351
+ pass
1352
+ os.replace(tmp, out)
1353
+ return out
1354
+
1355
+
1356
+ # ============================================================== motion (animated texts and numbers, SPEC Contract 12)
1357
+
1358
+ MOTION_KEYS = ("template", "start", "end", "duration", "params", "region", "sfx", "enabled", "id", "note")
1359
+ MOTION_REGIONS = {"auto": None, "top": "topo", "middle": "centro", "center": "centro", "bottom": "baixo",
1360
+ "topo": "topo", "centro": "centro", "baixo": "baixo", "faixa_topo": "faixa_topo", "tela": "tela"}
1361
+ MOTION_EXAMPLE = '{"template": "checklist", "start": 12.3, "end": 15.0, "params": {"items": ["Acesso na hora"]}}'
1362
+ MOTION_CAPTION_SHIFT = 150.0 # design px: an auto item steps aside from the captions only when that moves it this little
1363
+ MOTION_GAP = 23.0 # design px between an auto item and a face or a text above it in priority
1364
+
1365
+
1366
+ def _of_names(names: list[str]) -> str:
1367
+ """['chamada', 'gancho', 'animação 2'] -> 'da chamada, do gancho e da animação 2' (each name once)."""
1368
+ seen = list(dict.fromkeys(names))
1369
+ parts = [("do " if n == "gancho" else "da ") + n for n in seen]
1370
+ return parts[0] if len(parts) == 1 else ", ".join(parts[:-1]) + " e " + parts[-1]
1371
+
1372
+
1373
+ @dataclass
1374
+ class Motion:
1375
+ idx: int # 1-based position in the list (as the report and the warnings call it)
1376
+ template: str
1377
+ item: object # motion.render.MotionItem
1378
+ f0: int
1379
+ f1: int
1380
+ auto: bool # region "auto": the template's place, slid off faces and texts
1381
+ sfx: bool
1382
+ region_asked: object
1383
+ clip: dict | None = None
1384
+ ink: tuple | None = None # render px on the output canvas (what the clip really draws)
1385
+ shown: list = field(default_factory=list)
1386
+ cues: list = field(default_factory=list)
1387
+
1388
+ @property
1389
+ def name(self) -> str:
1390
+ return f"animação {self.idx} ({self.template})"
1391
+
1392
+
1393
+ def _motion_items(data: dict) -> list | None:
1394
+ """The list under "motion": a list, or {"enabled": bool, "items": [...]}. None when it is off or absent."""
1395
+ raw = data.get("motion")
1396
+ if raw is None:
1397
+ return None
1398
+ if isinstance(raw, dict):
1399
+ if raw.get("enabled", True) is False:
1400
+ return None
1401
+ extra = sorted(set(raw) - {"enabled", "items", "tema", "quantidade"})
1402
+ if extra:
1403
+ raise ComposeError(f"motion tem campos desconhecidos: {', '.join(extra)} (use \"items\", \"enabled\", "
1404
+ f"\"tema\" e \"quantidade\")", 2)
1405
+ raw = raw.get("items", [])
1406
+ if not isinstance(raw, list):
1407
+ raise ComposeError(f"motion precisa ser uma lista de animações (ex: \"motion\": [{MOTION_EXAMPLE}])", 2)
1408
+ return raw
1409
+
1410
+
1411
+ def _motion_region(value, where: str):
1412
+ """(region spec for the engine or None for the template's own place, auto?)"""
1413
+ if value is None:
1414
+ return None, True
1415
+ if isinstance(value, str):
1416
+ key = value.strip().lower()
1417
+ if key not in MOTION_REGIONS:
1418
+ import difflib
1419
+ near = difflib.get_close_matches(key, list(MOTION_REGIONS), n=1, cutoff=0.6)
1420
+ hint = f" Você quis dizer \"{near[0]}\"?" if near else ""
1421
+ raise ComposeError(f"{where}.region \"{value}\" não existe.{hint} Use \"auto\", \"top\", \"middle\", \"bottom\" "
1422
+ f"ou {{\"x\", \"y\", \"w\", \"h\"}} em pixels da tela 1080x1920", 2)
1423
+ spec = MOTION_REGIONS[key]
1424
+ return spec, spec is None
1425
+ box = None
1426
+ if isinstance(value, dict) and set(value) == {"x", "y", "w", "h"}:
1427
+ box = [value["x"], value["y"], value["w"], value["h"]]
1428
+ elif isinstance(value, list) and len(value) == 4:
1429
+ box = value
1430
+ nums = [_plain_number(v) for v in box] if box is not None else None
1431
+ if not nums or any(v is None for v in nums) or nums[2] < 20 or nums[3] < 20:
1432
+ raise ComposeError(f"{where}.region precisa ser \"auto\", \"top\", \"middle\", \"bottom\" ou {{\"x\": 140, \"y\": 1000, "
1433
+ f"\"w\": 800, \"h\": 520}} (pixels da tela 1080x1920, largura e altura de pelo menos 20) "
1434
+ f"(veio {_as_json(value)})", 2)
1435
+ return nums, False
1436
+
1437
+
1438
+ def _motion_theme(data: dict) -> str | None:
1439
+ """The look chosen for all the animations ("motion": {"tema": "papelaria"}), or None."""
1440
+ raw = data.get("motion")
1441
+ tema = raw.get("tema") if isinstance(raw, dict) else None
1442
+ if tema is None:
1443
+ return None
1444
+ from motion import engine as ME
1445
+ if tema not in ME.THEME_NAMES:
1446
+ raise ComposeError(f"motion.tema \"{tema}\" não existe (use {', '.join(ME.THEME_NAMES)})", 2)
1447
+ return str(tema)
1448
+
1449
+
1450
+ def plan_motion(plan: Plan) -> list[Motion]:
1451
+ """edit_data "motion": check every item and decide its frames before anything renders (exit 2 on a mistake)."""
1452
+ raw = _motion_items(plan.data)
1453
+ if not raw:
1454
+ return []
1455
+ from motion import engine as ME
1456
+ from motion import registry as MRg
1457
+ from motion import render as MR
1458
+
1459
+ out: list[Motion] = []
1460
+ for i, entry in enumerate(raw):
1461
+ where = f"motion[{i}]"
1462
+ n = i + 1
1463
+ if not isinstance(entry, dict):
1464
+ raise ComposeError(f"{where} precisa ser um objeto (ex: {MOTION_EXAMPLE})", 2)
1465
+ if entry.get("enabled", True) is False:
1466
+ continue
1467
+ unknown = [k for k in entry if k not in MOTION_KEYS]
1468
+ if unknown:
1469
+ import difflib
1470
+ near = difflib.get_close_matches(str(unknown[0]), list(MOTION_KEYS), n=1, cutoff=0.6)
1471
+ hint = f" Você quis dizer \"{near[0]}\"?" if near else ""
1472
+ raise ComposeError(f"{where}.{unknown[0]} não existe.{hint} Campos: {', '.join(MOTION_KEYS[:7])}", 2)
1473
+ tid = entry.get("template")
1474
+ if not isinstance(tid, str) or not tid.strip():
1475
+ names = ", ".join(sorted(MRg.templates()))
1476
+ raise ComposeError(f"{where} precisa de \"template\": o modelo da animação ({names})", 2)
1477
+ tid = tid.strip()
1478
+ start = _plain_number(entry.get("start"))
1479
+ if start is None or start < 0:
1480
+ if "start" not in entry:
1481
+ raise ComposeError(f"{where} precisa de \"start\": o segundo do corte em que a animação começa "
1482
+ f"(ex: {MOTION_EXAMPLE})", 2)
1483
+ raise ComposeError(f"{where}.start precisa ser um número de segundos, 0 ou mais "
1484
+ f"(veio {_as_json(entry.get('start'))})", 2)
1485
+ end = dur = None
1486
+ if entry.get("end") is not None and entry.get("duration") is not None:
1487
+ raise ComposeError(f"{where}: use \"end\" ou \"duration\", não os dois", 2)
1488
+ if entry.get("end") is not None:
1489
+ end = _plain_number(entry.get("end"))
1490
+ if end is None or end <= start:
1491
+ raise ComposeError(f"{where}.end precisa ser um número de segundos maior que o start ({start:g}) "
1492
+ f"(veio {_as_json(entry.get('end'))})", 2)
1493
+ if entry.get("duration") is not None:
1494
+ dur = _plain_number(entry.get("duration"))
1495
+ if dur is None or dur <= 0:
1496
+ raise ComposeError(f"{where}.duration precisa ser um número de segundos maior que 0 "
1497
+ f"(veio {_as_json(entry.get('duration'))})", 2)
1498
+ params = entry.get("params")
1499
+ if params is None:
1500
+ params = {}
1501
+ tema = _motion_theme(plan.data)
1502
+ if tema and isinstance(params, dict) and "tema" not in params:
1503
+ params = {**params, "tema": tema} # "motion": {"tema": ...} is the look of every item
1504
+ if not isinstance(params, dict):
1505
+ raise ComposeError(f"{where}.params precisa ser um objeto com os parâmetros do modelo "
1506
+ f"(ex: {{\"items\": [\"Acesso na hora\"]}}; veja motion_render.py --describe {tid})", 2)
1507
+ spec, auto = _motion_region(entry.get("region", "auto"), where)
1508
+ sfx_on = entry.get("sfx", True)
1509
+ if not isinstance(sfx_on, bool):
1510
+ raise ComposeError(f"{where}.sfx precisa ser true ou false (veio {_as_json(sfx_on)})", 2)
1511
+ try:
1512
+ tpl = MRg.get(tid)
1513
+ f0 = plan.fr(start)
1514
+ if f0 >= plan.nf:
1515
+ plan.warnings.append(f"animação {n} ('{tid}' em {fmt_time(start)}) começa depois do fim do vídeo "
1516
+ f"({fmt_time(plan.duration)}) e foi ignorada")
1517
+ continue
1518
+ if end is not None:
1519
+ f1 = plan.fr(end)
1520
+ if end * plan.fps > plan.nf + 0.5:
1521
+ plan.warnings.append(f"animação {n} ('{tid}') passava do fim do vídeo e foi cortada em "
1522
+ f"{fmt_time(plan.duration)}")
1523
+ else:
1524
+ secs = dur if dur is not None else tpl.suggested_duration(MRg.resolve_params(tpl, params))
1525
+ f1 = f0 + ME.frame_of(secs, plan.fps)
1526
+ if f1 > plan.nf:
1527
+ plan.warnings.append(f"animação {n} ('{tid}') passava do fim do vídeo e foi cortada em "
1528
+ f"{fmt_time(plan.duration)}: comece antes ou use uma duration menor")
1529
+ f1 = min(f1, plan.nf)
1530
+ if f1 - f0 < 2:
1531
+ plan.warnings.append(f"animação {n} ('{tid}' em {fmt_time(start)}) dura menos de 2 quadros e foi ignorada")
1532
+ continue
1533
+ item = MR.plan_item(tid, params, fps=plan.fps, canvas=(plan.W, plan.H), region=spec, frames=f1 - f0,
1534
+ accent=plan.accent)
1535
+ MR.sfx_cues(item)
1536
+ MR.render_frame(item, 0) # a mistake only the drawing sees (a target off the screen) stops here too
1537
+ except ME.MotionError as e:
1538
+ raise ComposeError(f"{where}: {e}", 2 if e.code == 2 else 1)
1539
+ for w in item.warnings:
1540
+ plan.warnings.append(f"animação {n}: {w}")
1541
+ item.warnings.clear()
1542
+ settle = tpl.settle(item.params, plan.fps)
1543
+ if settle is not None:
1544
+ # an end earlier than the drawing: the last items would never show (or never get their check / cross)
1545
+ frames = f1 - f0
1546
+ exit_frames = min(MR.EXIT_FRAMES, frames // 3) if tpl.exit == "fade" else 0
1547
+ need = settle + exit_frames / plan.fps
1548
+ if need * plan.fps > frames + 1:
1549
+ plan.warnings.append(f"animação {n} ('{tid}' em {fmt_time(start)}) acaba antes de terminar o desenho: "
1550
+ f"ela precisa de {ME.format_number(need, 1)} s e ficou com "
1551
+ f"{ME.format_number(frames / plan.fps, 1)} s. Aumente o end ou tire o end para usar "
1552
+ f"a duração sugerida ({ME.format_number(tpl.suggested_duration(item.params), 1)} s)")
1553
+ out.append(Motion(idx=n, template=tid, item=item, f0=f0, f1=f1, auto=auto, sfx=sfx_on,
1554
+ region_asked=entry.get("region", "auto")))
1555
+ for k, a in enumerate(out):
1556
+ for b in out[k + 1:]:
1557
+ if b.f0 < a.f1 and a.f0 < b.f1:
1558
+ plan.warnings.append(f"as animações {a.idx} e {b.idx} aparecem juntas ({fmt_time(max(a.f0, b.f0) / plan.fps)}): "
1559
+ f"deixe uma animação por vez na tela")
1560
+ return out
1561
+
1562
+
1563
+ def place_motion(plan: Plan, motions: list[Motion], highs: list[tuple[str, int, int, tuple]],
1564
+ caption_bands) -> None:
1565
+ """Auto items slide off the faces, the CTA, the hook and the animations before them (and off the captions
1566
+ when that costs little); the others stay where they were asked. Then every clip is rendered."""
1567
+ if not motions:
1568
+ return
1569
+ from motion import render as MR
1570
+
1571
+ k = min(plan.W, plan.H) / 1080.0
1572
+ fps = plan.fps
1573
+ for m in motions:
1574
+ item = m.item
1575
+ if not (m.auto and item.template.movable):
1576
+ continue
1577
+ faces: list[tuple[str, tuple[float, float]]] = [] # (why, band in design px)
1578
+ faces += [("rosto", (a / k, b / k)) for a, b in face_bands(plan, m.f0, m.f1)]
1579
+ texts = []
1580
+ for why, h0, h1, band in highs:
1581
+ if h0 < m.f1 and m.f0 < h1:
1582
+ texts.append((why, (band[0] / k, band[1] / k)))
1583
+ others = []
1584
+ for other in motions:
1585
+ if other.idx >= m.idx or other.item.ink is None or not (other.f0 < m.f1 and m.f0 < other.f1):
1586
+ continue
1587
+ ob = other.item.box
1588
+ others.append((f"animação {other.idx}", (ob.y + other.item.ink[1], ob.y + other.item.ink[3])))
1589
+ # no place free of everything: the face still wins. First give up the other animations (they only share a
1590
+ # moment), then the CTA and the hook (they win anyway: the animation is hidden where it touches them).
1591
+ # Tela verde: off the whole cut-out body first, when there is room
1592
+ levels = [faces + texts + others, faces + texts, faces]
1593
+ gave = [[], others, texts + others]
1594
+ body = [("pessoa", (a / k, b / k)) for a, b in person_bands(plan, m.f0, m.f1)]
1595
+ if body:
1596
+ levels = [faces + body + texts + others] + levels
1597
+ gave = [[]] + gave
1598
+ dy, named, relaxed = None, levels[0], 0
1599
+ for lvl, cand in enumerate(levels):
1600
+ if lvl and len(cand) == len(levels[lvl - 1]):
1601
+ continue
1602
+ dy = MR.free_offset(item, [band for _, band in cand], MOTION_GAP)
1603
+ if dy is not None:
1604
+ named, relaxed = cand, lvl
1605
+ break
1606
+ bands = [band for _, band in named]
1607
+ caps = [(a / k, b / k) for a, b in caption_bands(m.f0, m.f1)]
1608
+ if caps:
1609
+ around = MR.free_offset(item, bands + caps, MOTION_GAP)
1610
+ if around is not None and (dy is None or abs(around - dy) <= MOTION_CAPTION_SHIFT):
1611
+ dy = around
1612
+ named += [("legenda", band) for band in caps]
1613
+ if dy is None:
1614
+ plan.warnings.append(f"a {m.name} não achou lugar livre do rosto e dos textos entre "
1615
+ f"{fmt_time(m.f0 / fps)} e {fmt_time(m.f1 / fps)}: ficou no lugar padrão. "
1616
+ f"Use outra region ou mude o tempo")
1617
+ continue
1618
+ given_up = [name for name, _ in gave[relaxed]]
1619
+ if given_up:
1620
+ plan.warnings.append(f"a {m.name} não achou lugar livre de tudo entre {fmt_time(m.f0 / fps)} e "
1621
+ f"{fmt_time(m.f1 / fps)}: ficou fora do rosto, mas perto {_of_names(given_up)}. "
1622
+ f"Mude o tempo para ela não dividir a tela")
1623
+ if abs(dy) >= 0.5:
1624
+ ink = item.ink
1625
+ before = (item.box.y + (ink[1] + ink[3]) / 2.0) if ink else item.box.y
1626
+ top, bottom = (item.box.y + ink[1], item.box.y + ink[3]) if ink else (item.box.y, item.box.y + item.box.h)
1627
+ why = []
1628
+ for name, (b0, b1) in named: # what the item touched where it was asked
1629
+ if b0 - MOTION_GAP < bottom and top < b1 + MOTION_GAP and name not in why:
1630
+ why.append(name)
1631
+ MR.move_item(item, dy)
1632
+ ko = min(plan.full_w, plan.full_h) / 1080.0 # design px -> px of the full output
1633
+ plan.adjustments.append({"layer": f"motion {m.idx}", "action": "moved", "start": round(m.f0 / fps, 3),
1634
+ "end": round(m.f1 / fps, 3), "because": " e ".join(why) or "rosto ou texto",
1635
+ "from_center_y": int(round(before * ko)),
1636
+ "center_y": int(round((before + dy) * ko))})
1637
+
1638
+ out_dir = plan.renders / "motion"
1639
+ workers = max(1, min(4, len(motions), os.cpu_count() or 2))
1640
+
1641
+ def work(m: Motion) -> dict:
1642
+ return MR.render_clip(m.item, out_dir)
1643
+
1644
+ try:
1645
+ with ThreadPoolExecutor(max_workers=workers) as ex:
1646
+ clips = list(ex.map(work, motions))
1647
+ except Exception as e: # noqa: BLE001
1648
+ from motion.engine import MotionError
1649
+ if isinstance(e, MotionError):
1650
+ raise ComposeError(f"falha ao desenhar uma animação: {e}", 2 if e.code == 2 else 1)
1651
+ raise
1652
+ for m, clip in zip(motions, clips):
1653
+ m.clip = clip
1654
+ m.ink = tuple(clip["ink"]) if clip.get("ink") else None
1655
+ if m.ink is None:
1656
+ plan.warnings.append(f"a {m.name} não desenhou nada e foi ignorada")
1657
+
1658
+
1659
+ # ============================================================== overlay tracks (PNG states)
1660
+
1661
+ class Track:
1662
+ """Frame-grid layer states composited in PIL, played through an ffconcat playlist."""
1663
+
1664
+ def __init__(self, plan: Plan, name: str):
1665
+ self.plan = plan
1666
+ self.name = name
1667
+ self.images: dict[str, Image.Image] = {}
1668
+ self.bboxes: dict[str, tuple | None] = {}
1669
+ self.content: dict[str, str] = {}
1670
+ self.states: list[tuple[int, int, int, tuple]] = [] # f0, f1, z, recipe
1671
+ self.z = 0
1672
+
1673
+ def add_image(self, key: str, img: Image.Image) -> None:
1674
+ self.images[key] = img
1675
+ self.bboxes[key] = img.getchannel("A").getbbox()
1676
+ # content hash: cached PNG names must change whenever the art changes
1677
+ self.content[key] = hashlib.sha1(img.tobytes()).hexdigest()[:16]
1678
+
1679
+ def add(self, f0: int, f1: int, recipe: tuple, z: int | None = None) -> None:
1680
+ f0, f1 = max(0, f0), min(self.plan.nf, f1)
1681
+ if f1 > f0:
1682
+ self.states.append((f0, f1, self.z if z is None else z, recipe))
1683
+
1684
+ def next_z(self) -> int:
1685
+ self.z += 1
1686
+ return self.z
1687
+
1688
+ def _transformed(self, key: str, scale: float, alpha: float, dy: float) -> Image.Image | None:
1689
+ img = self.images[key]
1690
+ bbox = self.bboxes[key]
1691
+ if bbox is None or alpha <= 0.001:
1692
+ return None
1693
+ if abs(scale - 1.0) < 1e-4 and alpha >= 0.999 and abs(dy) < 0.5:
1694
+ return img
1695
+ W, H = img.size
1696
+ x0, y0, x1, y1 = bbox
1697
+ part = img.crop(bbox)
1698
+ if abs(scale - 1.0) >= 1e-4:
1699
+ nw, nh = max(1, int(round(part.width * scale))), max(1, int(round(part.height * scale)))
1700
+ part = part.resize((nw, nh), Image.BICUBIC)
1701
+ if alpha < 0.999:
1702
+ part.putalpha(part.getchannel("A").point(lambda v, a=alpha: int(v * a)))
1703
+ cx, cy = (x0 + x1) / 2.0, (y0 + y1) / 2.0 + dy
1704
+ canvas = Image.new("RGBA", (W, H), (0, 0, 0, 0))
1705
+ tr._alpha_paste(canvas, part, int(round(cx - part.width / 2.0)), int(round(cy - part.height / 2.0)))
1706
+ return canvas
1707
+
1708
+ def build(self, out_dir: Path) -> tuple[Path | None, list[dict], list[tuple[int, int]]]:
1709
+ plan = self.plan
1710
+ if not self.states:
1711
+ return None, [], []
1712
+ cuts = sorted({f for a, b, _, _ in self.states for f in (a, b)})
1713
+ intervals = []
1714
+ for u, v in zip(cuts, cuts[1:]):
1715
+ active = sorted((z, r) for a, b, z, r in self.states if a <= u and v <= b)
1716
+ if not active:
1717
+ continue
1718
+ key = tuple(r for _, r in active)
1719
+ if intervals and intervals[-1][1] == u and intervals[-1][2] == key:
1720
+ intervals[-1][1] = v
1721
+ else:
1722
+ intervals.append([u, v, key])
1723
+ out_dir.mkdir(parents=True, exist_ok=True)
1724
+ jobs = {}
1725
+ entries = []
1726
+ for u, v, key in intervals:
1727
+ ids = tuple(self.content.get(r[1], "") if r[0] == "img" else "" for r in key)
1728
+ name = f"{self.name}_{short_hash((RENDER_VERSION, plan.W, plan.H, key, ids))}.png"
1729
+ jobs.setdefault(name, key)
1730
+ entries.append({"file": str(out_dir / name), "start": u / plan.fps, "end": v / plan.fps})
1731
+
1732
+ def work(item):
1733
+ name, key = item
1734
+ path = out_dir / name
1735
+ if path.exists():
1736
+ return
1737
+ canvas = Image.new("RGBA", (plan.W, plan.H), (0, 0, 0, 0))
1738
+ for recipe in key:
1739
+ if recipe[0] == "flash":
1740
+ canvas.alpha_composite(Image.new("RGBA", (plan.W, plan.H), (255, 255, 255, int(round(255 * recipe[1])))))
1741
+ continue
1742
+ _, img_key, scale, alpha, dy = recipe
1743
+ layer = self._transformed(img_key, scale, alpha, dy)
1744
+ if layer is not None:
1745
+ canvas.alpha_composite(layer)
1746
+ tmp = path.with_name(".tmp-" + path.name)
1747
+ canvas.save(tmp, "PNG", compress_level=1)
1748
+ os.replace(tmp, path)
1749
+
1750
+ with ThreadPoolExecutor(max_workers=min(8, os.cpu_count() or 4)) as ex:
1751
+ list(ex.map(work, jobs.items()))
1752
+ ff = out_dir / f"{self.name}.ffconcat"
1753
+ tr.write_ffconcat(entries, str(ff), total_duration=plan.duration,
1754
+ blank_png=str(out_dir / f"blank_{plan.W}x{plan.H}.png"), fps=plan.fps)
1755
+ windows = merge_windows([(plan.fr(e["start"]), plan.fr(e["end"])) for e in entries])
1756
+ return ff, entries, windows
1757
+
1758
+
1759
+ def merge_windows(spans: list[tuple[int, int]]) -> list[tuple[int, int]]:
1760
+ out: list[list[int]] = []
1761
+ for a, b in sorted(spans):
1762
+ if out and a <= out[-1][1]:
1763
+ out[-1][1] = max(out[-1][1], b)
1764
+ else:
1765
+ out.append([a, b])
1766
+ return [(a, b) for a, b in out]
1767
+
1768
+
1769
+ def enable_expr(windows: list[tuple[int, int]], nf: int) -> str | None:
1770
+ if not windows:
1771
+ return None
1772
+ if len(windows) == 1 and windows[0][0] <= 0 and windows[0][1] >= nf:
1773
+ return None
1774
+ if len(windows) > 120:
1775
+ return None
1776
+ return "+".join(f"between(n,{a},{b - 1})" for a, b in windows)
1777
+
1778
+
1779
+ def seam_shadow(plan: Plan, ins: Insert) -> Image.Image:
1780
+ """Soft shadow the picture band casts on the speaker band, so the split reads as layers."""
1781
+ W, H, s = plan.W, plan.H, plan.s
1782
+ depth = max(4, int(round(34 * s)))
1783
+ canvas = Image.new("RGBA", (W, H), (0, 0, 0, 0))
1784
+ ramp = Image.new("L", (1, depth))
1785
+ for y in range(depth):
1786
+ ramp.putpixel((0, y), int(round(255 * 0.42 * (1.0 - y / depth) ** 2)))
1787
+ ramp = ramp.resize((W, depth))
1788
+ band = Image.new("RGBA", (W, depth), (0, 0, 0, 255))
1789
+ seam = int(ins.seam_y or 0)
1790
+ if ins.type == "split_top": # speaker below the seam
1791
+ band.putalpha(ramp)
1792
+ canvas.alpha_composite(band, (0, min(H - depth, seam)))
1793
+ else: # speaker above the seam
1794
+ band.putalpha(ramp.transpose(Image.FLIP_TOP_BOTTOM))
1795
+ canvas.alpha_composite(band, (0, max(0, seam - depth)))
1796
+ return canvas
1797
+
1798
+
1799
+ def pip_decoration(plan: Plan, ins: Insert) -> Image.Image:
1800
+ W, H, s = plan.W, plan.H, plan.s
1801
+ ox, oy, ow, oh = ins.card # type: ignore[misc]
1802
+ border = ins.content[0] - ox
1803
+ radius = border * 2.8
1804
+ ss = 3
1805
+ canvas = Image.new("RGBA", (W, H), (0, 0, 0, 0))
1806
+ # soft shadow below the card (never inside it: the card content sits under this layer)
1807
+ pad = int(round(60 * s))
1808
+ sw, sh = ow + 2 * pad, oh + 2 * pad
1809
+ mask = Image.new("L", (sw, sh), 0)
1810
+ off = int(round(12 * s))
1811
+ ImageDraw.Draw(mask).rounded_rectangle((pad, pad + off, pad + ow, pad + oh + off), radius=radius, fill=int(255 * 0.55))
1812
+ mask = mask.filter(ImageFilter.GaussianBlur(20 * s))
1813
+ card_mask = Image.new("L", (sw * ss, sh * ss), 0)
1814
+ ImageDraw.Draw(card_mask).rounded_rectangle((pad * ss, pad * ss, (pad + ow) * ss - 1, (pad + oh) * ss - 1),
1815
+ radius=radius * ss, fill=255)
1816
+ card_mask = card_mask.resize((sw, sh), Image.LANCZOS)
1817
+ mask = Image.composite(Image.new("L", (sw, sh), 0), mask, card_mask)
1818
+ shadow = Image.new("RGBA", (sw, sh), (0, 0, 0, 255))
1819
+ shadow.putalpha(mask)
1820
+ tr._alpha_paste(canvas, shadow, ox - pad, oy - pad)
1821
+ # white ring: outer rounded rect minus inner rounded rect (covers the content corners)
1822
+ ring = Image.new("L", (ow * ss, oh * ss), 0)
1823
+ d = ImageDraw.Draw(ring)
1824
+ d.rounded_rectangle((0, 0, ow * ss - 1, oh * ss - 1), radius=radius * ss, fill=255)
1825
+ d.rounded_rectangle((border * ss, border * ss, (ow - border) * ss - 1, (oh - border) * ss - 1),
1826
+ radius=max(1.0, (radius - border)) * ss, fill=0)
1827
+ ring = ring.resize((ow, oh), Image.LANCZOS)
1828
+ white = Image.new("RGBA", (ow, oh), (255, 255, 255, 255))
1829
+ white.putalpha(ring)
1830
+ tr._alpha_paste(canvas, white, ox, oy)
1831
+ return canvas
1832
+
1833
+
1834
+ HOOK_IN = [(0.80, 0.55), (0.95, 0.92), (1.045, 1.0), (1.015, 1.0)] # (scale, alpha) per frame
1835
+ HOOK_OUT = [0.66, 0.33]
1836
+ HOOK_MAX_SCALE = 1.05
1837
+ CTA_IN = [(0.90, 0.3, 60), (0.94, 0.6, 34), (0.98, 0.85, 14), (1.015, 1.0, 4), (1.02, 1.0, 0), (1.005, 1.0, 0)]
1838
+ CTA_MAX_SCALE = 1.03 # entrance and pulse
1839
+ CTA_SLIDE = 14 # px (1080 wide) the CTA still sits lower on its first solid entrance frame
1840
+ SFX_CALIBRATION = make_sfx.SFX_CALIBRATION # 1.4: gain at sfx.volume 1.0 (levels per effect in assets/sfx/sfx.json)
1841
+ AAC_TP_HEADROOM = 1.0 # AAC encoding adds inter-sample peaks: normalize a bit under the target
1842
+ FLASH = [(-1, 0.35), (0, 0.9), (1, 0.6), (2, 0.32), (3, 0.12)]
1843
+
1844
+ # text placement (fractions of the canvas height)
1845
+ GAP_FACE = 0.012 # room between a text and a face
1846
+ GAP_TEXT = 0.005 # room between two texts
1847
+ SOFT_SHIFT = 60 / 1920 # captions may move this much so the disclaimer keeps its usual place
1848
+ FACE_COVER_WARN = 0.05 # share of the face (brows to lower lip) a text may cover before a warning
1849
+ PRIORITY = ("cta", "hook", "motion", "captions", "disclaimer") # motion = the animations of edit_data "motion"
1850
+ TEXT_LAYERS = ("cta", "hook", "captions", "disclaimer")
1851
+ LABELS = {"cta": "a chamada", "hook": "o gancho", "captions": "a legenda", "disclaimer": "o aviso",
1852
+ "motion": "a animação"}
1853
+ OF_LABELS = {"cta": "da chamada", "hook": "do gancho", "captions": "da legenda", "disclaimer": "do aviso",
1854
+ "motion": "da animação"}
1855
+ MIN_PIECE = {"hook": 0.3, "motion": 0.5, "captions": 0.13, "disclaimer": 1.0} # s: shorter leftovers of a hidden layer are hidden too
1856
+ DISCLAIMER_HIDDEN_WARN = 0.2 # share of its time the disclaimer may lose to other texts before a warning
1857
+ CENTER_KEYS = {"cta": "cta.center_y", "hook": "hook.center_y", "captions": "captions.center_y",
1858
+ "disclaimer": "disclaimer.center_y"}
1859
+
1860
+
1861
+ def clamp_cue_words(cues):
1862
+ """Copy of the cues with word times kept inside their cue (a cue clamped to a cut must not
1863
+ start its first word a frame before that cut)."""
1864
+ if not isinstance(cues, dict):
1865
+ return cues
1866
+ out = dict(cues)
1867
+ fixed = []
1868
+ for cue in cues.get("cues") or []:
1869
+ try:
1870
+ c0, c1 = float(cue["start"]), float(cue["end"])
1871
+ except (KeyError, TypeError, ValueError):
1872
+ fixed.append(cue)
1873
+ continue
1874
+ c = dict(cue)
1875
+ words = []
1876
+ for w in cue.get("words") or []:
1877
+ try:
1878
+ ws, we = float(w["start"]), float(w["end"])
1879
+ except (KeyError, TypeError, ValueError):
1880
+ continue
1881
+ ws = min(max(ws, c0), c1)
1882
+ words.append(dict(w, start=ws, end=min(max(we, ws), c1)))
1883
+ c["words"] = words
1884
+ fixed.append(c)
1885
+ out["cues"] = fixed
1886
+ return out
1887
+
1888
+
1889
+ def check_captions_cut(plan: Plan, cues, cap_file: Path) -> None:
1890
+ """Refuse captions made for another cut: every caption after a changed cut would be out of sync."""
1891
+ if not isinstance(cues, dict):
1892
+ return
1893
+ name = plan.rel(cap_file)
1894
+ hint = "Rode captions.py de novo para este corte."
1895
+ tl = plan.timeline
1896
+ if tl:
1897
+ key, current = cues.get("timeline_key"), captions_mod.timeline_key(tl)
1898
+ if key and current and key != current:
1899
+ raise ComposeError(f"{name} é de outro corte: o cut_timeline.json mudou depois das legendas. {hint}", 2)
1900
+ bounds = cues.get("cut_boundaries")
1901
+ if isinstance(bounds, list):
1902
+ starts = []
1903
+ for seg in tl.get("segments") or []:
1904
+ try:
1905
+ starts.append(float(seg["out_start"]))
1906
+ except (KeyError, TypeError, ValueError):
1907
+ continue
1908
+ want = sorted(starts)[1:]
1909
+ try:
1910
+ got = sorted(float(b) for b in bounds)
1911
+ except (TypeError, ValueError):
1912
+ got = []
1913
+ if len(want) != len(got) or any(abs(a - b) > 0.02 for a, b in zip(want, got)):
1914
+ show = lambda xs: ", ".join(f"{x:.2f}" for x in xs[:5]) + (" ..." if len(xs) > 5 else "") # noqa: E731
1915
+ raise ComposeError(f"{name} é de outro corte: as legendas têm cortes em {show(got) or 'nenhum'} s e o corte "
1916
+ f"atual em {show(want) or 'nenhum'} s. {hint}", 2)
1917
+ ends = []
1918
+ for cue in cues.get("cues") or []:
1919
+ try:
1920
+ ends.append(float(cue["end"]))
1921
+ except (KeyError, TypeError, ValueError):
1922
+ continue
1923
+ if ends and max(ends) > plan.duration + 0.5:
1924
+ raise ComposeError(f"{name} é de outro corte: a última legenda termina em {fmt_time(max(ends))} e o vídeo tem "
1925
+ f"{fmt_time(plan.duration)}. {hint}", 2)
1926
+
1927
+
1928
+ def _center_value(sec: dict, key: str, what: str) -> float | None:
1929
+ v = sec.get(key)
1930
+ if _is_auto(v):
1931
+ return None
1932
+ try:
1933
+ return float(v)
1934
+ except (TypeError, ValueError):
1935
+ raise ComposeError(f"{what} precisa ser um número (pixels na tela de saída) ou \"auto\"", 2)
1936
+
1937
+
1938
+ def grow_box(bb, scale: float = 1.0, extra_below: float = 0.0):
1939
+ if not bb:
1940
+ return None
1941
+ cx, cy = (bb[0] + bb[2]) / 2.0, (bb[1] + bb[3]) / 2.0
1942
+ hw, hh = (bb[2] - bb[0]) / 2.0 * scale, (bb[3] - bb[1]) / 2.0 * scale
1943
+ return (cx - hw, cy - hh, cx + hw, cy + hh + extra_below)
1944
+
1945
+
1946
+ def boxes_hit(a, b) -> bool:
1947
+ return bool(a) and bool(b) and a[0] < b[2] and b[0] < a[2] and a[1] < b[3] and b[1] < a[3]
1948
+
1949
+
1950
+ def cover_share(box, zone) -> float:
1951
+ if not box or not zone:
1952
+ return 0.0
1953
+ w = min(box[2], zone[2]) - max(box[0], zone[0])
1954
+ h = min(box[3], zone[3]) - max(box[1], zone[1])
1955
+ if w <= 0 or h <= 0:
1956
+ return 0.0
1957
+ return w * h / max(1e-6, (zone[2] - zone[0]) * (zone[3] - zone[1]))
1958
+
1959
+
1960
+ def subtract_windows(spans: list[tuple], windows: list[tuple[int, int]]) -> list[tuple]:
1961
+ """(f0, f1, *payload) spans minus frame windows."""
1962
+ out = []
1963
+ for f0, f1, *rest in spans:
1964
+ pieces = [(f0, f1)]
1965
+ for a, b in windows:
1966
+ nxt = []
1967
+ for p0, p1 in pieces:
1968
+ if b <= p0 or a >= p1:
1969
+ nxt.append((p0, p1))
1970
+ continue
1971
+ if p0 < a:
1972
+ nxt.append((p0, a))
1973
+ if b < p1:
1974
+ nxt.append((b, p1))
1975
+ pieces = nxt
1976
+ out += [(p0, p1, *rest) for p0, p1 in pieces if p1 > p0]
1977
+ return out
1978
+
1979
+
1980
+ def compute_face_zones(plan: Plan, inserts: list[Insert]) -> None:
1981
+ """Face zones as drawn in the output (after the camera and the inserts), per frame interval."""
1982
+ W, H = plan.W, plan.H
1983
+ pieces = []
1984
+ for cs in plan.cam_segs:
1985
+ zones, cores = [], []
1986
+ face = cs.get("face")
1987
+ if face and face.get("zone"):
1988
+ for key, dst in (("zone", zones), ("core", cores)):
1989
+ if not face.get(key):
1990
+ continue
1991
+ rects = [face_layout.zoom_rect(face[key], z, cs["focus_y"]) for z in {cs["zoom"], cs["zoom_max"]}]
1992
+ dst.append((min(r["x0"] for r in rects) * W, min(r["y0"] for r in rects) * H,
1993
+ max(r["x1"] for r in rects) * W, max(r["y1"] for r in rects) * H))
1994
+ pieces.append((cs["f0"], cs["f1"], zones, cores))
1995
+ cuts = sorted({0, plan.nf} | {p[0] for p in pieces} | {p[1] for p in pieces}
1996
+ | {min(plan.nf, i.f0) for i in inserts} | {min(plan.nf, i.f1) for i in inserts})
1997
+ out: list[tuple] = []
1998
+ for a, b in zip(cuts, cuts[1:]):
1999
+ if b <= a:
2000
+ continue
2001
+ base = next((p for p in pieces if p[0] <= a < p[1]), None)
2002
+ zones, cores = (base[2], base[3]) if base else ([], [])
2003
+ top = None
2004
+ for ins in inserts: # list order: the last one covering is drawn on top
2005
+ if ins.type != "pip" and ins.f0 <= a and b <= ins.f1:
2006
+ top = ins
2007
+ if top is not None and top.type == "full":
2008
+ zones, cores = [], []
2009
+ elif top is not None and top.speaker is not None:
2010
+ _, band_y, _, hs = top.speaker
2011
+ src_y = next((y for p0, p1, y in top.speaker_pieces if p0 <= a < p1), top.speaker_src_y)
2012
+
2013
+ def moved(r, src_y=src_y, band_y=band_y, hs=hs):
2014
+ y0 = max(band_y, r[1] - src_y + band_y)
2015
+ y1 = min(band_y + hs, r[3] - src_y + band_y)
2016
+ return (r[0], y0, r[2], y1) if y1 > y0 else None
2017
+
2018
+ zones = [m for m in map(moved, zones) if m]
2019
+ cores = [m for m in map(moved, cores) if m]
2020
+ if out and out[-1][1] == a and out[-1][2] == zones and out[-1][3] == cores:
2021
+ out[-1] = (out[-1][0], b, zones, cores)
2022
+ else:
2023
+ out.append((a, b, zones, cores))
2024
+ plan.zones = out
2025
+ pad = 12 * plan.s
2026
+ plan.obstacles = [(i.f0, i.f1, (i.card[0] - pad, i.card[1] - pad, i.card[0] + i.card[2] + pad, i.card[1] + i.card[3] + pad))
2027
+ for i in inserts if i.type == "pip" and i.card]
2028
+ plan.obstacles += tela_obstacles(plan)
2029
+
2030
+
2031
+ def tela_obstacles(plan: Plan) -> list[tuple[int, int, tuple[float, float, float, float]]]:
2032
+ """Texto/desenho que já está no vídeo (edit/texto_na_tela.json) vira obstáculo: os textos desviam dele."""
2033
+ doc = load_json(plan.edit_dir / "texto_na_tela.json")
2034
+ if not isinstance(doc, dict) or not doc.get("amostras"):
2035
+ return []
2036
+ passo = 1.0 / float(doc.get("fps") or 2.0)
2037
+ pad = 10 * plan.s
2038
+ out: list[tuple[int, int, tuple[float, float, float, float]]] = []
2039
+ for am in doc["amostras"]:
2040
+ itens = am.get("itens") or []
2041
+ if not itens:
2042
+ continue
2043
+ f0 = int(round(float(am["t"]) * plan.fps))
2044
+ f1 = int(round((float(am["t"]) + passo) * plan.fps))
2045
+ for it in itens:
2046
+ centro = float(it.get("centro_y", 0.5)) * plan.H
2047
+ meia = max(6.0, float(it.get("altura", 0.02)) * plan.H / 2)
2048
+ cx = float(it.get("centro_x", 0.5)) * plan.W
2049
+ meia_x = max(20.0, float(it.get("largura", 0.4)) * plan.W / 2)
2050
+ out.append((f0, f1, (cx - meia_x - pad, centro - meia - pad, cx + meia_x + pad, centro + meia + pad)))
2051
+ # junta amostras seguidas com a mesma caixa, para a lista não explodir
2052
+ out.sort(key=lambda o: (round(o[2][1]), round(o[2][3]), o[0]))
2053
+ juntos: list[tuple[int, int, tuple[float, float, float, float]]] = []
2054
+ for f0, f1, r in out:
2055
+ if juntos and juntos[-1][2] == r and f0 - juntos[-1][1] <= plan.fps * 0.6:
2056
+ juntos[-1] = (juntos[-1][0], f1, r)
2057
+ else:
2058
+ juntos.append((f0, f1, r))
2059
+ return juntos
2060
+
2061
+
2062
+ def face_bands(plan: Plan, f0: int, f1: int, core: bool = False, gap: float = 0.0,
2063
+ skip: list[tuple[int, int]] | tuple = ()) -> list[tuple[float, float]]:
2064
+ """Vertical bands taken by faces (and pip cards) during [f0, f1), minus intervals fully inside `skip` windows."""
2065
+ out = []
2066
+ for a, b, zones, cores in plan.zones:
2067
+ if b <= f0 or a >= f1:
2068
+ continue
2069
+ lo_f, hi_f = max(a, f0), min(b, f1)
2070
+ if any(h0 <= lo_f and hi_f <= h1 for h0, h1 in skip):
2071
+ continue
2072
+ for r in (cores if core else zones):
2073
+ out.append((r[1] - gap, r[3] + gap))
2074
+ for a, b, r in plan.obstacles:
2075
+ if a < f1 and f0 < b:
2076
+ out.append((r[1], r[3]))
2077
+ return out
2078
+
2079
+
2080
+ def layout_spans(plan: Plan) -> list[tuple[int, int]]:
2081
+ """The frame spans the captions are placed in when one height does not fit the whole video: the face zone
2082
+ pieces, split where a whole background picture comes or goes (tela verde)."""
2083
+ spans = [(a, b) for a, b, _, _ in plan.zones]
2084
+ if not plan.image_zones:
2085
+ return spans
2086
+ cuts = sorted({p for a, b, _ in plan.image_zones for p in (a, b)})
2087
+ out = []
2088
+ for a, b in spans:
2089
+ pts = [a] + [c for c in cuts if a < c < b] + [b]
2090
+ out += list(zip(pts, pts[1:]))
2091
+ return out
2092
+
2093
+
2094
+ def manual_off_face(plan: Plan, layer: str, manual: float, above: float, below: float, f0: int, f1: int,
2095
+ lo: float, hi: float, to_out) -> float:
2096
+ """Tela verde: a hook or CTA height written by hand stays where it is free of the (moved) face, else it goes to
2097
+ the free height closest to it, with a warning."""
2098
+ c = pick_center([manual], above, below, face_bands(plan, f0, f1, gap=GAP_FACE * plan.H), lo, hi, manual)
2099
+ if c is None or abs(c - manual) <= 0.5:
2100
+ return manual
2101
+ plan.adjustments.append({"layer": layer, "action": "moved", "start": round(f0 / plan.fps, 3),
2102
+ "end": round(f1 / plan.fps, 3), "because": "rosto (tela verde)",
2103
+ "from_center_y": to_out(manual), "center_y": to_out(c)})
2104
+ plan.warnings.append(f"{CENTER_KEYS[layer]} {to_out(manual)} cairia no rosto na tela verde (você mudou de lugar): "
2105
+ f"{LABELS[layer]} foi para {to_out(c)}. Tire o center_y para ela se ajustar sozinha")
2106
+ return c
2107
+
2108
+
2109
+ def pick_center(prefs: list, above: float, below: float, blocked: list[tuple[float, float]], lo: float, hi: float,
2110
+ fallback: float | None = None) -> float | None:
2111
+ """First preference that is free as it is, else the free center closest to `fallback` (or prefs[0])."""
2112
+ prefs = [p for p in prefs if p is not None]
2113
+ for p in prefs:
2114
+ if face_layout.free_center(p, above, below, blocked, lo, hi) == p:
2115
+ return p
2116
+ target = fallback if fallback is not None else (prefs[0] if prefs else (lo + hi) / 2.0)
2117
+ return face_layout.free_center(target, above, below, blocked, lo, hi)
2118
+
2119
+
2120
+ def layout_pieces(windows: list[dict], fps: int, f0: int, f1: int, default_cy: float) -> list[tuple[int, int, float]]:
2121
+ """Caption center per frame interval, with the rule text_render applies to `layout` windows
2122
+ (the window that starts later wins)."""
2123
+ def fr(t):
2124
+ return int(round(float(t) * fps + 1e-6))
2125
+
2126
+ lay = sorted(((fr(w["start"]), fr(w["end"]), float(w["center_y"])) for w in windows), key=lambda x: x[0])
2127
+ cuts = sorted({f0, f1} | {p for a, b, _ in lay for p in (a, b) if f0 < p < f1})
2128
+ out: list[tuple[int, int, float]] = []
2129
+ for a, b in zip(cuts, cuts[1:]):
2130
+ cy = default_cy
2131
+ for l0, l1, lcy in lay:
2132
+ if l0 <= a and b <= l1:
2133
+ cy = lcy
2134
+ if out and out[-1][1] == a and out[-1][2] == cy:
2135
+ out[-1] = (out[-1][0], b, cy)
2136
+ else:
2137
+ out.append((a, b, cy))
2138
+ return out
2139
+
2140
+
2141
+ def add_decorations(plan: Plan, mid: "Track", inserts: list[Insert]) -> None:
2142
+ """Pip cards and split seam shadows, left out while an insert later in the list covers them."""
2143
+ W, H, s = plan.W, plan.H, plan.s
2144
+ for ins in inserts:
2145
+ if ins.type == "pip":
2146
+ cx, cy_, cw, ch = ins.card # type: ignore[misc]
2147
+ key, rect = f"pip{ins.idx}", (cx, cy_, cx + cw, cy_ + ch)
2148
+ mid.add_image(key, pip_decoration(plan, ins))
2149
+ elif ins.seam_y is not None:
2150
+ key = f"seam{ins.type}{ins.seam_y}"
2151
+ depth = max(4, int(round(34 * s)))
2152
+ rect = (0, max(0, ins.seam_y - depth), W, min(H, ins.seam_y + depth))
2153
+ if key not in mid.images:
2154
+ mid.add_image(key, seam_shadow(plan, ins))
2155
+ else:
2156
+ continue
2157
+ covered = []
2158
+ for other in inserts:
2159
+ if other.idx <= ins.idx or other.f1 <= ins.f0 or other.f0 >= ins.f1:
2160
+ continue
2161
+ ocard = other.card if other.type == "pip" else None
2162
+ if other.type != "pip" or boxes_hit(rect, (ocard[0], ocard[1], ocard[0] + ocard[2], ocard[1] + ocard[3])):
2163
+ covered.append((max(ins.f0, other.f0), min(ins.f1, other.f1)))
2164
+ z = mid.next_z()
2165
+ for a, b in subtract_windows([(ins.f0, ins.f1)], merge_windows(covered)):
2166
+ mid.add(a, b, ("img", key, 1.0, 1.0, 0.0), z=z)
2167
+ if covered:
2168
+ plan.adjustments.append({"layer": f"insert {ins.idx + 1}", "action": "decoration_hidden",
2169
+ "windows": [[round(a / plan.fps, 3), round(b / plan.fps, 3)] for a, b in merge_windows(covered)],
2170
+ "because": "um insert depois na lista fica por cima"})
2171
+
2172
+
2173
+ def build_tracks(plan: Plan, inserts: list[Insert], motions: list[Motion] | None = None) -> dict:
2174
+ s = plan.s
2175
+ W, H, fps, nf = plan.W, plan.H, plan.fps, plan.nf
2176
+ data = plan.data
2177
+ motions = list(motions or [])
2178
+ catalog_defaults = load_style_catalog().get("defaults") or {}
2179
+ result: dict = {"mid": None, "captions": None, "top": None, "sfx_events": [], "motion": []}
2180
+ sfx_on = _enabled(_section(data, "sfx"))
2181
+ lay = tr.layout_defaults((W, H))
2182
+ lo, hi = lay["top"], lay["bottom"]
2183
+ disc_hi = min(H - 8.0 * H / 1920.0, lay["bottom"] + 0.035 * H)
2184
+ g_face, g_text = GAP_FACE * H, GAP_TEXT * H
2185
+ out_dir = plan.renders / "overlays"
2186
+
2187
+ def to_out(px):
2188
+ return None if px is None else int(round(px / s))
2189
+
2190
+ def overlaps(a0, a1, b0, b1):
2191
+ return a0 < b1 and b0 < a1
2192
+
2193
+ # ---------------- settings of each text layer
2194
+ hook = _section(data, "hook")
2195
+ hk = None
2196
+ if _enabled(hook) and str(hook.get("text") or "").strip():
2197
+ style = hook.get("style") or catalog_defaults.get("hook_style") or "manchete"
2198
+ if style not in tr.HOOK_STYLES:
2199
+ raise ComposeError(f"estilo de gancho '{style}' não existe. Opções: {', '.join(tr.HOOK_STYLES)}", 2)
2200
+ h0 = plan.fr(hook.get("start", 0.0))
2201
+ h1 = plan.fr(hook.get("end"), default=min(nf, plan.fr(3.0)))
2202
+ if h1 - h0 < 2:
2203
+ plan.warnings.append("gancho com menos de 2 quadros foi ignorado")
2204
+ else:
2205
+ text = str(hook["text"])
2206
+ maxw = int(round(900 * s))
2207
+ fit = tr.hook_fit(text, style, (W, H), max_width=maxw)
2208
+ if fit["too_long"]:
2209
+ plan.warnings.append(f"gancho longo demais, a letra ficou pequena ({fit['size']}px). Encurte o texto.")
2210
+ plan.warnings.extend(tr.text_issues("hook", text, style))
2211
+ manual = _center_value(hook, "center_y", "hook.center_y")
2212
+ hk = {"style": style, "text": text, "f0": h0, "f1": h1, "fit": fit, "maxw": maxw,
2213
+ "manual": manual * s if manual is not None else None}
2214
+
2215
+ cap = _section(data, "captions")
2216
+ cp = None
2217
+ if _enabled(cap):
2218
+ cap_file = _resolve(plan.edit_dir, cap.get("file") or "captions.json", "captions.file")
2219
+ try:
2220
+ cues = load_json(cap_file) or {}
2221
+ except ValueError as e:
2222
+ raise ComposeError(f"captions.json inválido ({e})", 2)
2223
+ check_captions_cut(plan, cues, cap_file)
2224
+ cues = clamp_cue_words(cues)
2225
+ has_cues = bool(cues.get("cues") if isinstance(cues, dict) else cues)
2226
+ if not has_cues:
2227
+ plan.warnings.append("captions.json não tem legendas")
2228
+ style = cap.get("style") or (cues.get("style") if isinstance(cues, dict) else None) \
2229
+ or catalog_defaults.get("caption_style") or "impacto"
2230
+ try:
2231
+ tr.caption_style_info(style)
2232
+ except ValueError as e:
2233
+ raise ComposeError(str(e), 2)
2234
+ if isinstance(cues, dict) and cues.get("style") and cues.get("style") != style:
2235
+ plan.warnings.append(f"captions.json foi agrupado para '{cues.get('style')}' e o vídeo usa '{style}'. "
2236
+ f"Se ficar estranho, rode captions.py --style {style}")
2237
+ font_scale = float(cap.get("font_scale", 1.0) or 1.0)
2238
+ manual = _center_value(cap, "center_y", "captions.center_y")
2239
+ hide = [(hk["f0"], hk["f1"])] if hk and cap.get("hide_during_hook", True) else []
2240
+ seams = [{"start": i.f0 / fps, "end": i.f1 / fps, "center_y": float(i.seam_y)} for i in inserts if i.seam_y is not None]
2241
+ cp = {"file": cap_file, "cues": cues, "style": style, "font_scale": font_scale, "has_cues": has_cues,
2242
+ "manual": manual * s if manual is not None else None, "hide": hide, "seams": seams, "moves": [],
2243
+ "base": [], "ext": tr.caption_extent(cues, style, (W, H), font_scale, fps)}
2244
+
2245
+ disc = _section(data, "disclaimer")
2246
+ dc = None
2247
+ if _enabled(disc):
2248
+ text = str(disc.get("text") or DEFAULT_DISCLAIMER)
2249
+ d0 = plan.fr(disc.get("start", 0.0))
2250
+ d1 = plan.fr(disc.get("end"), default=nf)
2251
+ if d1 > d0:
2252
+ plan.warnings.extend(tr.text_issues("disclaimer", text))
2253
+ manual = _center_value(disc, "center_y", "disclaimer.center_y")
2254
+ bb = tr.ink_bbox(tr.render_disclaimer(text, canvas=(W, H), center_y=lay["disclaimer"]))
2255
+ dc = {"text": text, "f0": d0, "f1": d1, "manual": manual * s if manual is not None else None,
2256
+ "ext": (lay["disclaimer"] - bb[1], bb[3] - lay["disclaimer"]) if bb else (0.0, 0.0)}
2257
+
2258
+ cta = _section(data, "cta")
2259
+ ct = None
2260
+ if _enabled(cta):
2261
+ text = str(cta.get("text") or DEFAULT_CTA)
2262
+ dur = float(cta.get("duration", 2.5) or 2.5)
2263
+ c1 = nf
2264
+ c0 = plan.fr(cta.get("start")) if cta.get("start") is not None else max(0, nf - plan.fr(dur))
2265
+ if cta.get("start") is not None:
2266
+ c1 = min(nf, c0 + plan.fr(dur))
2267
+ if c1 - c0 >= 2:
2268
+ sub = cta.get("sub")
2269
+ plan.warnings.extend(tr.text_issues("cta", text) + tr.text_issues("cta_sub", str(sub or "")))
2270
+ manual = _center_value(cta, "center_y", "cta.center_y")
2271
+ pref = lay["cta"] if cp and cp["has_cues"] else lay["cta_alone"]
2272
+ bb = tr.ink_bbox(tr.render_cta(text, sub, plan.accent, canvas=(W, H), center_y=pref))
2273
+ ext = (((pref - bb[1]) * CTA_MAX_SCALE, (bb[3] - pref) * CTA_MAX_SCALE + CTA_SLIDE * s) if bb else (0.0, 0.0))
2274
+ ct = {"text": text, "sub": sub, "f0": c0, "f1": c1, "pref": pref, "ext": ext,
2275
+ "manual": manual * s if manual is not None else None}
2276
+
2277
+ for ins in inserts:
2278
+ if hk and overlaps(ins.f0, ins.f1, hk["f0"], hk["f1"]):
2279
+ plan.warnings.append(f"insert {ins.idx + 1} aparece junto com o gancho ({fmt_time(max(ins.f0, hk['f0']) / fps)} a "
2280
+ f"{fmt_time(min(ins.f1, hk['f1']) / fps)}): comece o insert depois do fim do gancho")
2281
+ if ct and overlaps(ins.f0, ins.f1, ct["f0"], ct["f1"]):
2282
+ plan.warnings.append(f"insert {ins.idx + 1} aparece junto com a chamada ({fmt_time(max(ins.f0, ct['f0']) / fps)} a "
2283
+ f"{fmt_time(min(ins.f1, ct['f1']) / fps)}): termine o insert antes da chamada")
2284
+
2285
+ for m in motions:
2286
+ if hk and overlaps(m.f0, m.f1, hk["f0"], hk["f1"]):
2287
+ plan.warnings.append(f"a {m.name} aparece junto com o gancho ({fmt_time(max(m.f0, hk['f0']) / fps)} a "
2288
+ f"{fmt_time(min(m.f1, hk['f1']) / fps)}): comece a animação depois do fim do gancho")
2289
+ if ct and overlaps(m.f0, m.f1, ct["f0"], ct["f1"]):
2290
+ plan.warnings.append(f"a {m.name} aparece junto com a chamada ({fmt_time(max(m.f0, ct['f0']) / fps)} a "
2291
+ f"{fmt_time(min(m.f1, ct['f1']) / fps)}): termine a animação antes da chamada")
2292
+
2293
+ def caption_windows() -> list[dict]:
2294
+ return ([{"start": a / fps, "end": b / fps, "center_y": cy} for a, b, cy in cp["base"]] + cp["seams"] + cp["moves"])
2295
+
2296
+ def caption_pieces(f0, f1):
2297
+ return [(a, b, cy) for a, b, cy in layout_pieces(caption_windows(), fps, f0, f1, lay["caption"])
2298
+ if not any(h0 <= a and b <= h1 for h0, h1 in cp["hide"])]
2299
+
2300
+ def caption_bands(f0, f1):
2301
+ return [(cy - cp["ext"][0] - g_text, cy + cp["ext"][1] + g_text) for _, _, cy in caption_pieces(f0, f1)]
2302
+
2303
+ # ---------------- 1. captions: base height, off the faces (and, if it is cheap, off the disclaimer)
2304
+ if cp:
2305
+ above, below = cp["ext"]
2306
+ # tela verde: a height written by hand for the recording may land on the face, which moved; there it goes
2307
+ # to the free height closest to it
2308
+ tv_manual = cp["manual"] is not None and plan.tv is not None
2309
+ if cp["manual"] is not None and not tv_manual:
2310
+ cp["base"] = [(0, nf, cp["manual"])]
2311
+ else:
2312
+ pref = lay["caption"] if not tv_manual else cp["manual"]
2313
+ soft = []
2314
+ if dc and not tv_manual:
2315
+ dcy = dc["manual"] if dc["manual"] is not None else lay["disclaimer"]
2316
+ soft = [(dcy - dc["ext"][0] - g_text, dcy + dc["ext"][1] + g_text)]
2317
+
2318
+ def choose_caption(f0, f1, prev=None, whole=False):
2319
+ faces = face_bands(plan, f0, f1, gap=g_face, skip=cp["hide"])
2320
+ if tv_manual and face_layout.free_center(pref, above, below, faces, lo, hi) == pref:
2321
+ return pref # the height written by hand is free of the face here: it stays
2322
+ body = person_bands(plan, f0, f1, gap=g_face, skip=cp["hide"])
2323
+ if body: # tela verde: the captions go off the cut-out body (above it) when there is room
2324
+ # and off the main area of a whole background picture (between it and the head)
2325
+ imgs = image_bands(plan, f0, f1, gap=g_face, skip=cp["hide"])
2326
+ c = pick_center([prev, pref], above, below, faces + body + imgs, lo, hi, pref)
2327
+ if c is not None:
2328
+ return c
2329
+ if imgs and whole:
2330
+ return None # one height for the whole video only when it is free of everything
2331
+ if imgs: # else below the face, before covering the picture
2332
+ c = pick_center([prev, pref], above, below, faces + imgs, lo, hi, pref)
2333
+ if c is not None:
2334
+ return c
2335
+ c = pick_center([prev, pref], above, below, faces + body, lo, hi, pref)
2336
+ if c is not None:
2337
+ return c
2338
+ if soft:
2339
+ c = pick_center([prev, pref], above, below, faces + soft, lo, hi, pref)
2340
+ if c is not None and abs(c - pref) <= SOFT_SHIFT * H:
2341
+ return c
2342
+ return pick_center([prev, pref], above, below, faces, lo, hi, pref)
2343
+
2344
+ c = choose_caption(0, nf, whole=True)
2345
+ if c is not None:
2346
+ cp["base"] = [(0, nf, c)]
2347
+ else:
2348
+ prev = None
2349
+ no_room = []
2350
+ for a, b in layout_spans(plan):
2351
+ c = choose_caption(a, b, prev)
2352
+ if c is None:
2353
+ c = pref
2354
+ no_room.append((a, b))
2355
+ if cp["base"] and cp["base"][-1][1] == a and cp["base"][-1][2] == c:
2356
+ cp["base"][-1] = (cp["base"][-1][0], b, c)
2357
+ else:
2358
+ cp["base"].append((a, b, c))
2359
+ prev = c
2360
+ if no_room:
2361
+ total = sum(b - a for a, b in no_room) / fps
2362
+ plan.warnings.append(f"a legenda não achou lugar livre do rosto em {len(no_room)} trecho(s) "
2363
+ f"({total:.1f}s, o primeiro em {fmt_time(no_room[0][0] / fps)}): ficou na altura padrão")
2364
+ if tv_manual:
2365
+ moved = [(a, b, c) for a, b, c in cp["base"] if abs(c - cp["manual"]) > 0.5]
2366
+ for a, b, c in moved:
2367
+ plan.adjustments.append({"layer": "captions", "action": "moved", "start": round(a / fps, 3),
2368
+ "end": round(b / fps, 3), "because": "rosto (tela verde)",
2369
+ "from_center_y": to_out(cp["manual"]), "center_y": to_out(c)})
2370
+ if moved:
2371
+ total = sum(b - a for a, b, _ in moved) / fps
2372
+ plan.warnings.append(f"captions.center_y {to_out(cp['manual'])} cairia no rosto na tela verde (você "
2373
+ f"mudou de lugar): em {total:.1f}s a legenda foi para a altura livre mais perto "
2374
+ f"({to_out(moved[0][2])}). Tire o center_y para ela se ajustar sozinha")
2375
+
2376
+ # ---------------- 2. CTA: off the faces, and off the captions when there is room
2377
+ if ct:
2378
+ above, below = ct["ext"]
2379
+ if ct["manual"] is not None:
2380
+ ct["cy"] = ct["manual"]
2381
+ if plan.tv is not None:
2382
+ ct["cy"] = manual_off_face(plan, "cta", ct["manual"], above, below, ct["f0"], ct["f1"], lo, hi, to_out)
2383
+ else:
2384
+ faces = face_bands(plan, ct["f0"], ct["f1"], gap=g_face)
2385
+ caps = caption_bands(ct["f0"], ct["f1"]) if cp and cp["has_cues"] else []
2386
+ body = person_bands(plan, ct["f0"], ct["f1"], gap=g_face)
2387
+ c = None
2388
+ imgs = image_bands(plan, ct["f0"], ct["f1"], gap=g_face) if body else []
2389
+ if imgs:
2390
+ c = pick_center([ct["pref"]], above, below, faces + body + imgs + caps, lo, hi) if caps else None
2391
+ if c is None:
2392
+ c = pick_center([ct["pref"]], above, below, faces + body + imgs, lo, hi)
2393
+ if body and c is None:
2394
+ c = pick_center([ct["pref"]], above, below, faces + body + caps, lo, hi) if caps else None
2395
+ if c is None:
2396
+ c = pick_center([ct["pref"]], above, below, faces + body, lo, hi)
2397
+ if c is None:
2398
+ c = pick_center([ct["pref"]], above, below, faces + caps, lo, hi) if caps else None
2399
+ if c is None:
2400
+ c = pick_center([ct["pref"]], above, below, faces, lo, hi)
2401
+ if c is None:
2402
+ c = ct["pref"]
2403
+ plan.warnings.append("a chamada não achou lugar livre do rosto: ficou na posição padrão")
2404
+ ct["cy"] = c
2405
+ ct["img"] = tr.render_cta(ct["text"], ct["sub"], plan.accent, canvas=(W, H), center_y=ct["cy"])
2406
+ ct["band"] = (ct["cy"] - above, ct["cy"] + below)
2407
+
2408
+ # ---------------- 3. hook: off the faces and the CTA
2409
+ if hk:
2410
+ img0 = tr.render_hook(hk["text"], hk["style"], plan.accent, canvas=(W, H), center_y=int(round(lay["hook"])),
2411
+ max_width=hk["maxw"])
2412
+ bb = tr.ink_bbox(img0)
2413
+ above, below = ((lay["hook"] - bb[1]) * HOOK_MAX_SCALE, (bb[3] - lay["hook"]) * HOOK_MAX_SCALE) if bb else (0.0, 0.0)
2414
+ if hk["manual"] is not None:
2415
+ cy = hk["manual"]
2416
+ if plan.tv is not None:
2417
+ cy = manual_off_face(plan, "hook", hk["manual"], above, below, hk["f0"], hk["f1"], lo, hi, to_out)
2418
+ else:
2419
+ faces = face_bands(plan, hk["f0"], hk["f1"], gap=g_face)
2420
+ highs = [(ct["band"][0] - g_text, ct["band"][1] + g_text)] if ct and overlaps(hk["f0"], hk["f1"], ct["f0"], ct["f1"]) else []
2421
+ caps = caption_bands(hk["f0"], hk["f1"]) if cp and cp["has_cues"] else []
2422
+ body = person_bands(plan, hk["f0"], hk["f1"], gap=g_face)
2423
+ cy = None
2424
+ imgs = image_bands(plan, hk["f0"], hk["f1"], gap=g_face) if body else []
2425
+ if imgs:
2426
+ cy = pick_center([lay["hook"]], above, below, faces + body + imgs + highs + caps, lo, hi) if caps else None
2427
+ if cy is None:
2428
+ cy = pick_center([lay["hook"]], above, below, faces + body + imgs + highs, lo, hi)
2429
+ if body and cy is None:
2430
+ cy = pick_center([lay["hook"]], above, below, faces + body + highs + caps, lo, hi) if caps else None
2431
+ if cy is None:
2432
+ cy = pick_center([lay["hook"]], above, below, faces + body + highs, lo, hi)
2433
+ if cy is None:
2434
+ cy = pick_center([lay["hook"]], above, below, faces + highs + caps, lo, hi) if caps else None
2435
+ if cy is None:
2436
+ cy = pick_center([lay["hook"]], above, below, faces + highs, lo, hi)
2437
+ if cy is None:
2438
+ cy = lay["hook"]
2439
+ plan.warnings.append("o gancho não achou lugar livre do rosto: ficou na posição padrão")
2440
+ hk["cy"] = cy
2441
+ hk["img"] = img0 if int(round(cy)) == int(round(lay["hook"])) else tr.render_hook(
2442
+ hk["text"], hk["style"], plan.accent, canvas=(W, H), center_y=int(round(cy)), max_width=hk["maxw"])
2443
+ hk["band"] = (cy - above, cy + below)
2444
+
2445
+ # ---------------- 3b. animations: off the faces, the CTA and the hook (and the captions when it is cheap)
2446
+ if motions:
2447
+ t_mo = time.time()
2448
+ highs_mo = [(why, cfg["f0"], cfg["f1"], (cfg["band"][0] - g_text, cfg["band"][1] + g_text))
2449
+ for why, cfg in (("chamada", ct), ("gancho", hk)) if cfg]
2450
+ place_motion(plan, motions, highs_mo,
2451
+ lambda a, b: caption_bands(a, b) if cp and cp["has_cues"] else [])
2452
+ cached = sum(1 for m in motions if m.clip and m.clip.get("cached"))
2453
+ log(f"animações prontas ({len(motions)}{f', {cached} do cache' if cached else ''}) em {time.time() - t_mo:.1f}s")
2454
+ motions = [m for m in motions if m.ink is not None]
2455
+ above_motion = []
2456
+ if ct:
2457
+ above_motion.append(("cta", ct["f0"], ct["f1"],
2458
+ grow_box(tr.ink_bbox(ct["img"]), CTA_MAX_SCALE, CTA_SLIDE * s)))
2459
+ if hk:
2460
+ above_motion.append(("hook", hk["f0"], hk["f1"], grow_box(tr.ink_bbox(hk["img"]), HOOK_MAX_SCALE)))
2461
+ resolve_motion_windows(plan, motions, above_motion)
2462
+
2463
+ # ---------------- 4. captions step aside (auto) or hide (manual, or no room) for the CTA, the hook and the animations
2464
+ if cp and cp["has_cues"]:
2465
+ above, below = cp["ext"]
2466
+ highs = []
2467
+ if ct:
2468
+ highs.append(("cta", ct["f0"], ct["f1"], ct["band"]))
2469
+ if hk:
2470
+ highs.append(("hook", hk["f0"], hk["f1"], hk["band"]))
2471
+ for m in motions:
2472
+ highs += [("motion", a, b, (m.ink[1], m.ink[3])) for a, b in m.shown]
2473
+ for name, f0, f1, band in highs:
2474
+ for a, b, cyc in caption_pieces(f0, f1):
2475
+ if cyc + below + g_text <= band[0] or cyc - above - g_text >= band[1]:
2476
+ continue
2477
+ new = None
2478
+ if cp["manual"] is None:
2479
+ blocked = face_bands(plan, a, b, gap=g_face) + [
2480
+ (hb[0] - g_text, hb[1] + g_text) for _, x0, x1, hb in highs if overlaps(a, b, x0, x1)]
2481
+ body = person_bands(plan, a, b, gap=g_face)
2482
+ if body:
2483
+ new = pick_center([cyc], above, below, blocked + body, lo, hi)
2484
+ if new is None:
2485
+ new = pick_center([cyc], above, below, blocked, lo, hi)
2486
+ if new is not None:
2487
+ cp["moves"].append({"start": a / fps, "end": b / fps, "center_y": new})
2488
+ plan.adjustments.append({"layer": "captions", "action": "moved", "start": round(a / fps, 3),
2489
+ "end": round(b / fps, 3), "because": name, "from_center_y": to_out(cyc),
2490
+ "center_y": to_out(new)})
2491
+ else:
2492
+ cp["hide"].append((a, b))
2493
+ plan.adjustments.append({"layer": "captions", "action": "hidden", "start": round(a / fps, 3),
2494
+ "end": round(b / fps, 3), "because": name})
2495
+
2496
+ # ---------------- 5. disclaimer: off the faces and every text on screen with it
2497
+ if dc:
2498
+ above, below = dc["ext"]
2499
+ spans: list[tuple[int, int, float]] = []
2500
+ if dc["manual"] is not None:
2501
+ spans = [(dc["f0"], dc["f1"], dc["manual"])]
2502
+ else:
2503
+ pref = lay["disclaimer"]
2504
+ top_anchor = lo + above + g_text
2505
+
2506
+ def text_bands(a, b):
2507
+ out = []
2508
+ for cfg in (hk, ct):
2509
+ if cfg and overlaps(a, b, cfg["f0"], cfg["f1"]):
2510
+ out.append((cfg["band"][0] - g_text, cfg["band"][1] + g_text))
2511
+ for m in motions:
2512
+ if any(overlaps(a, b, x0, x1) for x0, x1 in m.shown):
2513
+ out.append((m.ink[1] - g_text, m.ink[3] + g_text))
2514
+ if cp and cp["has_cues"]:
2515
+ out += caption_bands(a, b)
2516
+ return out
2517
+
2518
+ def choose_disc(a, b, prev=None):
2519
+ txt = text_bands(a, b)
2520
+ body = person_bands(plan, a, b, gap=g_face)
2521
+ c = None
2522
+ if body:
2523
+ c = pick_center([prev, pref, top_anchor], above, below,
2524
+ face_bands(plan, a, b, gap=g_face) + body + txt, lo, disc_hi, pref)
2525
+ if c is None:
2526
+ c = pick_center([prev, pref, top_anchor], above, below, face_bands(plan, a, b, gap=g_face) + txt,
2527
+ lo, disc_hi, pref)
2528
+ if c is None: # small print may sit on the chin or the forehead, never on the eyes or the mouth
2529
+ c = pick_center([prev, pref, top_anchor], above, below,
2530
+ face_bands(plan, a, b, core=True, gap=3 * g_face) + txt, lo, disc_hi, pref)
2531
+ return c
2532
+
2533
+ c = choose_disc(dc["f0"], dc["f1"])
2534
+ if c is not None:
2535
+ spans = [(dc["f0"], dc["f1"], c)]
2536
+ else:
2537
+ cuts = {dc["f0"], dc["f1"]}
2538
+ for a, b, _, _ in plan.zones:
2539
+ cuts |= {a, b}
2540
+ for cfg in (hk, ct):
2541
+ if cfg:
2542
+ cuts |= {cfg["f0"], cfg["f1"]}
2543
+ for m in motions:
2544
+ for x0, x1 in m.shown:
2545
+ cuts |= {x0, x1}
2546
+ if cp:
2547
+ for a, b, _ in layout_pieces(caption_windows(), fps, 0, nf, lay["caption"]):
2548
+ cuts |= {a, b}
2549
+ for a, b in cp["hide"]:
2550
+ cuts |= {a, b}
2551
+ points = sorted(f for f in cuts if dc["f0"] <= f <= dc["f1"])
2552
+ prev = None
2553
+ for a, b in zip(points, points[1:]):
2554
+ if b <= a:
2555
+ continue
2556
+ c = choose_disc(a, b, prev)
2557
+ if c is None:
2558
+ plan.adjustments.append({"layer": "disclaimer", "action": "hidden", "start": round(a / fps, 3),
2559
+ "end": round(b / fps, 3), "because": "sem lugar livre"})
2560
+ prev = None
2561
+ continue
2562
+ if spans and spans[-1][1] == a and spans[-1][2] == c:
2563
+ spans[-1] = (spans[-1][0], b, c)
2564
+ else:
2565
+ spans.append((a, b, c))
2566
+ prev = c
2567
+ for a, b, c in spans:
2568
+ if abs(c - pref) > 0.5:
2569
+ plan.adjustments.append({"layer": "disclaimer", "action": "moved", "start": round(a / fps, 3),
2570
+ "end": round(b / fps, 3), "because": "rosto ou outro texto",
2571
+ "from_center_y": to_out(pref), "center_y": to_out(c)})
2572
+ dc["imgs"] = {}
2573
+ for _, _, c in spans:
2574
+ key = f"disc{int(round(c))}"
2575
+ if key not in dc["imgs"]:
2576
+ dc["imgs"][key] = (c, tr.render_disclaimer(dc["text"], canvas=(W, H), center_y=c))
2577
+ dc["spans"] = [(a, b, f"disc{int(round(c))}") for a, b, c in spans]
2578
+
2579
+ # ---------------- 6. caption track at the chosen heights
2580
+ states: list[dict] = []
2581
+ if cp:
2582
+ layout = caption_windows()
2583
+ hide_s = [[a / fps, b / fps] for a, b in merge_windows(cp["hide"])]
2584
+ cap_dir = plan.renders / f"captions_{cp['style']}_{W}x{H}"
2585
+ states = tr.render_caption_track(cp["cues"], cp["style"], plan.accent, str(cap_dir), canvas=(W, H), fps=fps,
2586
+ layout=layout or None, hide=hide_s or None, font_scale=cp["font_scale"])
2587
+ states = [st for st in states if st["start"] < plan.duration]
2588
+
2589
+ # ---------------- 7. what is really drawn: overlapping texts resolved by priority
2590
+ drawn: dict[str, list[tuple]] = {"cta": [], "hook": [], "motion": [], "captions": [], "disclaimer": []}
2591
+ if ct:
2592
+ drawn["cta"] = [(ct["f0"], ct["f1"], grow_box(tr.ink_bbox(ct["img"]), CTA_MAX_SCALE, CTA_SLIDE * s), None)]
2593
+ if hk:
2594
+ drawn["hook"] = [(hk["f0"], hk["f1"], grow_box(tr.ink_bbox(hk["img"]), HOOK_MAX_SCALE), None)]
2595
+ drawn["motion"] = [(a, b, m.ink, m.idx) for m in motions for a, b in m.shown] # resolved in 3b
2596
+ if cp:
2597
+ drawn["captions"] = [(plan.fr(st["start"]), plan.fr(st["end"]), tuple(st["bbox"]) if st.get("bbox") else None,
2598
+ st["file"]) for st in states]
2599
+ if dc:
2600
+ boxes = {k: tr.ink_bbox(img) for k, (_, img) in dc["imgs"].items()}
2601
+ drawn["disclaimer"] = [(a, b, boxes[k], k) for a, b, k in dc["spans"]]
2602
+ for i, low in enumerate(PRIORITY):
2603
+ if low == "motion":
2604
+ continue
2605
+ hits: dict[str, list[tuple[int, int]]] = {}
2606
+ for high in PRIORITY[:i]:
2607
+ for h0, h1, hbox, _ in drawn[high]:
2608
+ for l0, l1, lbox, _ in drawn[low]:
2609
+ a, b = max(h0, l0), min(h1, l1)
2610
+ if b > a and boxes_hit(hbox, lbox):
2611
+ hits.setdefault(high, []).append((a, b))
2612
+ if hits:
2613
+ windows = merge_windows([w for ws in hits.values() for w in ws])
2614
+ min_f = max(1, int(round(MIN_PIECE.get(low, 0.0) * fps)))
2615
+ before = sum(span[1] - span[0] for span in drawn[low])
2616
+ kept = []
2617
+ for span in drawn[low]:
2618
+ for piece in subtract_windows([span], windows):
2619
+ if (piece[0], piece[1]) != (span[0], span[1]) and piece[1] - piece[0] < min_f:
2620
+ # a sliver left between two hides would blink on screen: hide it with its neighbour
2621
+ high = next((h for h, ws in hits.items() for a, b in ws if b == piece[0] or a == piece[1]),
2622
+ next(iter(hits)))
2623
+ hits[high].append((piece[0], piece[1]))
2624
+ else:
2625
+ kept.append(piece)
2626
+ drawn[low] = kept
2627
+ for high, ws in hits.items():
2628
+ for a, b in merge_windows(ws):
2629
+ plan.adjustments.append({"layer": low, "action": "hidden", "start": round(a / fps, 3),
2630
+ "end": round(b / fps, 3), "because": high})
2631
+ hidden_share = 1.0 - sum(span[1] - span[0] for span in kept) / max(1, before)
2632
+ if low == "disclaimer" and hidden_share > DISCLAIMER_HIDDEN_WARN:
2633
+ names = [OF_LABELS[h] for h in PRIORITY if h in hits]
2634
+ behind = names[0] if len(names) == 1 else ", ".join(names[:-1]) + " e " + names[-1]
2635
+ plan.warnings.append(f"o aviso ficou escondido em {round(hidden_share * 100)}% do tempo para não ficar em cima "
2636
+ f"{behind}. Deixe disclaimer.center_y em \"auto\" ou escolha outra altura")
2637
+
2638
+ # ---------------- 8. text still over a face (manual heights, or no free spot)
2639
+ face_report(plan, drawn, {"cta": ct, "hook": hk, "captions": cp, "disclaimer": dc}, lay, disc_hi)
2640
+ motion_face_report(plan, motions)
2641
+
2642
+ # ---------------- 9. tracks
2643
+ for m in motions:
2644
+ if not m.shown:
2645
+ plan.warnings.append(f"a {m.name} ficou escondida o tempo todo e não entrou no vídeo")
2646
+ continue
2647
+ result["motion"].append((m.clip, m.f0, m.shown))
2648
+ m.cues = motion_cues(m)
2649
+ for cue in m.cues:
2650
+ if m.sfx and sfx_on and any(a <= cue["frame"] < b for a, b in m.shown):
2651
+ result["sfx_events"].append(cue)
2652
+ mid = Track(plan, "mid")
2653
+ add_decorations(plan, mid, inserts)
2654
+ if hk:
2655
+ mid.add_image("hook", hk["img"])
2656
+ z = mid.next_z()
2657
+ shown = drawn["hook"]
2658
+ h0, h1 = hk["f0"], hk["f1"]
2659
+ n = h1 - h0
2660
+ animate = n >= len(HOOK_IN) + len(HOOK_OUT) + 2
2661
+ for j in range(n):
2662
+ f = h0 + j
2663
+ if not any(a <= f < b for a, b, _, _ in shown):
2664
+ continue
2665
+ sc, al = 1.0, 1.0
2666
+ if animate and j < len(HOOK_IN):
2667
+ sc, al = HOOK_IN[j]
2668
+ elif animate and j >= n - len(HOOK_OUT):
2669
+ al = HOOK_OUT[j - (n - len(HOOK_OUT))]
2670
+ mid.add(f, f + 1, ("img", "hook", sc, al, 0.0), z=z)
2671
+ plan.layers.append({"type": "hook", "style": hk["style"], "text": hk["text"], "start": h0 / fps, "end": h1 / fps,
2672
+ "frames": [h0, h1], "lines": hk["fit"]["lines"], "center_y": to_out(hk["cy"]),
2673
+ "auto": hk["manual"] is None})
2674
+ snd = hook.get("sfx", "pop")
2675
+ if sfx_on and snd:
2676
+ result["sfx_events"].append({"name": str(snd), "frame": h0, "lead_frames": 0, "why": "gancho"})
2677
+ result["mid"] = mid.build(out_dir)
2678
+
2679
+ if cp:
2680
+ by_file = {}
2681
+ final_states = []
2682
+ for a, b, bbox, file in drawn["captions"]:
2683
+ final_states.append({"file": file, "start": round(a / fps, 6), "end": round(b / fps, 6),
2684
+ "bbox": list(bbox) if bbox else None})
2685
+ by_file[file] = True
2686
+ cap_dir = plan.renders / f"captions_{cp['style']}_{W}x{H}"
2687
+ ff = tr.write_ffconcat(final_states, str(cap_dir / "captions.ffconcat"), total_duration=plan.duration,
2688
+ blank_png=str(cap_dir / "blank.png"), fps=fps)
2689
+ windows = merge_windows([(plan.fr(st["start"]), plan.fr(st["end"])) for st in final_states])
2690
+ result["captions"] = (ff, final_states, windows)
2691
+ plan.layers.append({"type": "captions", "style": cp["style"], "file": plan.rel(cp["file"]),
2692
+ "words_from": cp["cues"].get("words_from") if isinstance(cp["cues"], dict) else None,
2693
+ "states": len(final_states), "png_files": len(by_file),
2694
+ "start": round(final_states[0]["start"], 3) if final_states else None,
2695
+ "end": round(final_states[-1]["end"], 3) if final_states else None,
2696
+ "hide": [[round(a / fps, 3), round(b / fps, 3)] for a, b in merge_windows(cp["hide"])],
2697
+ "layout": [{"start": round(w["start"], 3), "end": round(w["end"], 3),
2698
+ "center_y": to_out(w["center_y"])} for w in caption_windows()],
2699
+ "auto": cp["manual"] is None, "font_scale": cp["font_scale"]})
2700
+
2701
+ top = Track(plan, "top")
2702
+ if dc and dc["spans"]:
2703
+ for key, (_, img) in dc["imgs"].items():
2704
+ top.add_image(key, img)
2705
+ z = top.next_z()
2706
+ d0 = dc["f0"]
2707
+ for a, b, _, key in drawn["disclaimer"]:
2708
+ fade = 4 if (a == d0 and d0 > 0) else 0
2709
+ for j in range(min(fade, b - a)):
2710
+ top.add(a + j, a + j + 1, ("img", key, 1.0, round((j + 1) / (fade + 1), 3), 0.0), z=z)
2711
+ top.add(a + min(fade, b - a), b, ("img", key, 1.0, 1.0, 0.0), z=z)
2712
+ centers = sorted({to_out(c) for c, _ in dc["imgs"].values()})
2713
+ plan.layers.append({"type": "disclaimer", "text": dc["text"], "start": dc["f0"] / fps, "end": dc["f1"] / fps,
2714
+ "frames": [dc["f0"], dc["f1"]], "center_y": centers[0] if len(centers) == 1 else centers,
2715
+ "auto": dc["manual"] is None,
2716
+ "shown": [[round(a / fps, 3), round(b / fps, 3)] for a, b in
2717
+ merge_windows([(a, b) for a, b, _, _ in drawn["disclaimer"]])]})
2718
+ if ct:
2719
+ top.add_image("cta", ct["img"])
2720
+ z = top.next_z()
2721
+ c0, c1 = ct["f0"], ct["f1"]
2722
+ n_in = min(len(CTA_IN), c1 - c0)
2723
+ for j in range(n_in):
2724
+ sc, al, dy = CTA_IN[j]
2725
+ top.add(c0 + j, c0 + j + 1, ("img", "cta", sc, al, round(dy * s, 1)), z=z)
2726
+ # gentle pulse after the entrance (quantized so PNGs repeat)
2727
+ for f in range(c0 + n_in, c1):
2728
+ tt = (f - c0 - n_in) / fps
2729
+ sc = 1.0 + 0.028 * (0.5 - 0.5 * math.cos(2 * math.pi * tt / 1.1))
2730
+ sc = round(round(sc / 0.004) * 0.004, 3)
2731
+ top.add(f, f + 1, ("img", "cta", sc, 1.0, 0.0), z=z)
2732
+ plan.layers.append({"type": "cta", "text": ct["text"], "sub": ct["sub"], "start": c0 / fps, "end": c1 / fps,
2733
+ "frames": [c0, c1], "center_y": to_out(ct["cy"]), "auto": ct["manual"] is None})
2734
+ snd = cta.get("sfx")
2735
+ if sfx_on and snd:
2736
+ result["sfx_events"].append({"name": str(snd), "frame": c0, "lead_frames": 0, "why": "chamada"})
2737
+ for k, trn in enumerate(data.get("transitions") or []):
2738
+ if not isinstance(trn, dict):
2739
+ continue
2740
+ if (trn.get("type") or "flash") != "flash":
2741
+ plan.warnings.append(f"transição '{trn.get('type')}' não existe (só 'flash') e foi ignorada")
2742
+ continue
2743
+ try:
2744
+ f = plan.fr(float(trn["at"]))
2745
+ except (KeyError, TypeError, ValueError):
2746
+ plan.warnings.append(f"transitions[{k}] sem 'at' válido foi ignorada")
2747
+ continue
2748
+ z = top.next_z()
2749
+ for off, al in FLASH:
2750
+ top.add(f + off, f + off + 1, ("flash", al), z=z)
2751
+ plan.layers.append({"type": "flash", "at": f / fps, "frame": f,
2752
+ "frames": [max(0, f - 1), min(nf, f + 4)]})
2753
+ snd = trn.get("sfx", "click")
2754
+ if sfx_on and snd:
2755
+ result["sfx_events"].append({"name": str(snd), "frame": f, "lead_frames": 0, "why": "flash"})
2756
+ result["top"] = top.build(out_dir)
2757
+ return result
2758
+
2759
+
2760
+ def face_report(plan: Plan, drawn: dict, cfgs: dict, lay: dict, disc_hi: float) -> None:
2761
+ """List texts still covering the eyes, nose or mouth, with a center_y that would free them."""
2762
+ fps, H = plan.fps, plan.H
2763
+ g_face = GAP_FACE * H
2764
+ for layer in TEXT_LAYERS:
2765
+ hits = []
2766
+ for f0, f1, box, _ in drawn[layer]:
2767
+ for a, b, _, cores in plan.zones:
2768
+ lo_f, hi_f = max(f0, a), min(f1, b)
2769
+ if hi_f <= lo_f:
2770
+ continue
2771
+ share = max([cover_share(box, core) for core in cores] or [0.0])
2772
+ if share > FACE_COVER_WARN:
2773
+ hits.append((lo_f, hi_f, share))
2774
+ if not hits:
2775
+ continue
2776
+ hits.sort()
2777
+ merged: list[list] = []
2778
+ for a, b, share in hits:
2779
+ if merged and a <= merged[-1][1] + 1:
2780
+ merged[-1][1] = max(merged[-1][1], b)
2781
+ merged[-1][2] = max(merged[-1][2], share)
2782
+ else:
2783
+ merged.append([a, b, share])
2784
+ cfg = cfgs.get(layer) or {}
2785
+ worst = max(merged, key=lambda m: m[2])
2786
+ pref = {"cta": cfg.get("pref"), "hook": lay["hook"], "captions": lay["caption"], "disclaimer": lay["disclaimer"]}[layer]
2787
+ ext = cfg.get("ext")
2788
+ if layer == "hook" and cfg.get("band") and cfg.get("cy") is not None:
2789
+ ext = (cfg["cy"] - cfg["band"][0], cfg["band"][1] - cfg["cy"])
2790
+ suggest = None
2791
+ if ext:
2792
+ hi = disc_hi if layer == "disclaimer" else lay["bottom"]
2793
+ c = pick_center([pref], ext[0], ext[1], face_bands(plan, worst[0], worst[1], gap=g_face), lay["top"], hi)
2794
+ suggest = int(round(c / plan.s)) if c is not None else None
2795
+ for a, b, share in merged[:20]:
2796
+ plan.face_checks.append({"layer": layer, "start": round(a / fps, 3), "end": round(b / fps, 3),
2797
+ "cover": round(share, 3), "suggest_center_y": suggest})
2798
+ where = (f"em {fmt_time(worst[0] / fps)}" if worst[1] - worst[0] <= fps
2799
+ else f"entre {fmt_time(worst[0] / fps)} e {fmt_time(worst[1] / fps)}")
2800
+ on_seam = layer == "captions" and any(
2801
+ plan.fr(sm["start"]) < worst[1] and worst[0] < plan.fr(sm["end"]) for sm in cfg.get("seams") or [])
2802
+ if on_seam:
2803
+ tip = ("Nesse trecho a legenda fica na divisão da tela dividida: troque split_top por split_bottom (ou o "
2804
+ "contrário) ou mude o split desse insert")
2805
+ elif cfg.get("manual") is None:
2806
+ tip = ("Não sobrou lugar livre do rosto com os outros textos desse trecho: encurte um texto ou tire um "
2807
+ "deles desse trecho")
2808
+ elif suggest is not None:
2809
+ tip = f"Use {CENTER_KEYS[layer]} perto de {suggest} (ou \"auto\")"
2810
+ else:
2811
+ tip = "Não há lugar livre do rosto: encurte o texto ou use tela dividida"
2812
+ plan.warnings.append(f"{LABELS[layer]} cobre o rosto {where} ({round(worst[2] * 100)}%). {tip}")
2813
+
2814
+
2815
+ def motion_cues(m: Motion) -> list[dict]:
2816
+ """The template's sound cues on the output frames, in the sfx.events shape (why "animação N")."""
2817
+ out = []
2818
+ for cue in m.clip.get("sfx") or []:
2819
+ ev = {"name": str(cue["name"]), "frame": m.f0 + int(cue["frame"]), "lead_frames": 0,
2820
+ "why": f"animação {m.idx}", "motion": m.idx, "volume": float(cue.get("volume", 1.0))}
2821
+ if cue.get("length"):
2822
+ ev["length"] = float(cue["length"])
2823
+ out.append(ev)
2824
+ return out
2825
+
2826
+
2827
+ def resolve_motion_windows(plan: Plan, motions: list[Motion], highs: list[tuple]) -> None:
2828
+ """m.shown: where each animation really shows. The CTA and the hook (above it in PRIORITY) hide it where their
2829
+ boxes touch it, and a leftover shorter than MIN_PIECE["motion"] is hidden with its neighbour. Done before the
2830
+ captions and the disclaimer are placed, so they only give way to what is really on screen."""
2831
+ fps = plan.fps
2832
+ min_f = max(1, int(round(MIN_PIECE["motion"] * fps)))
2833
+ for m in motions:
2834
+ hits: dict[str, list[tuple[int, int]]] = {}
2835
+ for name, h0, h1, hbox in highs:
2836
+ a, b = max(h0, m.f0), min(h1, m.f1)
2837
+ if b > a and boxes_hit(hbox, m.ink):
2838
+ hits.setdefault(name, []).append((a, b))
2839
+ if not hits:
2840
+ m.shown = [(m.f0, m.f1)]
2841
+ continue
2842
+ kept = []
2843
+ for p0, p1 in subtract_windows([(m.f0, m.f1)], merge_windows([w for ws in hits.values() for w in ws])):
2844
+ if p1 - p0 < min_f:
2845
+ # a sliver left between two hides would blink on screen: hide it with its neighbour
2846
+ high = next((h for h, ws in hits.items() for a, b in ws if b == p0 or a == p1), next(iter(hits)))
2847
+ hits[high].append((p0, p1))
2848
+ else:
2849
+ kept.append((p0, p1))
2850
+ m.shown = kept
2851
+ for high in PRIORITY:
2852
+ for a, b in merge_windows(hits.get(high, [])):
2853
+ plan.adjustments.append({"layer": f"motion {m.idx}", "action": "hidden", "start": round(a / fps, 3),
2854
+ "end": round(b / fps, 3), "because": high})
2855
+ plan.warnings.append(f"a {m.name} ficou escondida de {fmt_time(a / fps)} a {fmt_time(b / fps)} "
2856
+ f"para não ficar em cima {OF_LABELS[high]}. Mude o start, o end ou a region")
2857
+
2858
+
2859
+ def motion_face_report(plan: Plan, motions: list[Motion]) -> None:
2860
+ """An animation still over the eyes, nose or mouth goes to face_checks and to a warning."""
2861
+ fps = plan.fps
2862
+ for m in motions:
2863
+ hits = []
2864
+ for a, b, _, cores in plan.zones:
2865
+ for w0, w1 in m.shown:
2866
+ lo_f, hi_f = max(a, w0), min(b, w1)
2867
+ if hi_f <= lo_f:
2868
+ continue
2869
+ share = max([cover_share(m.ink, core) for core in cores] or [0.0])
2870
+ if share > FACE_COVER_WARN:
2871
+ hits.append((lo_f, hi_f, share))
2872
+ if not hits:
2873
+ continue
2874
+ hits.sort()
2875
+ worst = max(hits, key=lambda h: h[2])
2876
+ for a, b, share in hits[:20]:
2877
+ plan.face_checks.append({"layer": f"motion {m.idx}", "start": round(a / fps, 3), "end": round(b / fps, 3),
2878
+ "cover": round(share, 3), "suggest_center_y": None,
2879
+ "suggest_region": "auto" if not m.auto else None})
2880
+ if not m.item.template.movable:
2881
+ tip = "Esse modelo fica sempre no mesmo lugar: mude o tempo ou use num trecho sem o rosto ali"
2882
+ elif m.auto:
2883
+ tip = "Não sobrou lugar livre do rosto: use menos itens, outra region ou outro trecho"
2884
+ else:
2885
+ tip = "Use \"region\": \"auto\" para ela fugir do rosto"
2886
+ plan.warnings.append(f"a {m.name} cobre o rosto entre {fmt_time(hits[0][0] / fps)} e {fmt_time(hits[-1][1] / fps)} "
2887
+ f"({round(worst[2] * 100)}%). {tip}")
2888
+
2889
+
2890
+ def motion_layers(plan: Plan, motions: list[Motion]) -> None:
2891
+ """layers[type=motion] of the report."""
2892
+ fps = plan.fps
2893
+ ko = min(plan.full_w, plan.full_h) / 1080.0
2894
+ for m in motions:
2895
+ if m.clip is None:
2896
+ continue
2897
+ item = m.item
2898
+ asked = m.region_asked
2899
+ layer = {"type": "motion", "index": m.idx, "template": m.template, "label": item.template.label,
2900
+ "start": m.f0 / fps, "end": m.f1 / fps, "frames": [m.f0, m.f1],
2901
+ "shown": [[round(a / fps, 3), round(b / fps, 3)] for a, b in m.shown],
2902
+ "region_asked": asked if asked is not None else "auto", "auto": m.auto,
2903
+ "region": [int(round(v * ko)) for v in item.region], "moved_by": int(round(item.moved_by * ko)),
2904
+ "rect": [m.clip["x"], m.clip["y"], m.clip["w"], m.clip["h"]],
2905
+ "ink": list(m.ink) if m.ink else None,
2906
+ "clip": plan.rel(Path(m.clip["file"])), "cached": bool(m.clip.get("cached")),
2907
+ "params": item.params, "sfx_on": m.sfx,
2908
+ "sfx": [{"name": c["name"], "frame": c["frame"], "time": round(c["frame"] / fps, 3),
2909
+ "volume": c["volume"]} for c in m.cues]}
2910
+ plan.layers.append(layer)
2911
+
2912
+
2913
+ # ============================================================== video graph
2914
+
2915
+ def run_video(plan: Plan, inserts: list[Insert], tracks: dict, cam: str | None, tmp_video: Path,
2916
+ base_clip: Path | None = None) -> None:
2917
+ """base_clip: the tela verde clip, already on the output grid and size, used in place of the base."""
2918
+ W, H, fps, nf = plan.W, plan.H, plan.fps, plan.nf
2919
+ inputs: list[str] = ["-i", str(base_clip or plan.base)]
2920
+ chains: list[str] = []
2921
+ n_in = 1
2922
+
2923
+ pre = []
2924
+ if base_clip is None and abs(plan.base_fps - fps) > 0.01:
2925
+ pre.append(f"fps={fps}")
2926
+ if base_clip is None and (plan.base_w, plan.base_h) != (W, H):
2927
+ pre.append(f"scale={W}:{H}:force_original_aspect_ratio=increase:flags=bicubic,crop={W}:{H},setsar=1")
2928
+ pre += [f"settb=1/{fps}", "setpts=N", "format=yuv420p"]
2929
+ if cam:
2930
+ pre.append(cam)
2931
+ splits = [i for i in inserts if i.speaker is not None]
2932
+ if splits:
2933
+ labels = "[cur0]" + "".join(f"[spk{i.idx}]" for i in splits)
2934
+ chains.append(f"[0:v]{','.join(pre)},split={len(splits) + 1}{labels}")
2935
+ else:
2936
+ chains.append(f"[0:v]{','.join(pre)}[cur0]")
2937
+ cur = "cur0"
2938
+ step = 0
2939
+
2940
+ def nxt() -> str:
2941
+ nonlocal step
2942
+ step += 1
2943
+ return f"cur{step}"
2944
+
2945
+ for ins in inserts:
2946
+ en = f"between(n,{ins.f0},{ins.f1 - 1})"
2947
+ if ins.speaker is not None:
2948
+ _, sy, sw, sh = ins.speaker
2949
+ lab = nxt()
2950
+ pieces = ins.speaker_pieces or [(ins.f0, ins.f1, ins.speaker_src_y)]
2951
+ ypos = str(pieces[-1][2])
2952
+ for p0, p1, y in reversed(pieces[:-1]): # crop evaluates y on every frame (n = frame of the base)
2953
+ ypos = f"if(lt(n,{p1}),{y},{ypos})"
2954
+ chains.append(f"[spk{ins.idx}]crop={sw}:{sh}:0:'{ypos}'[spkc{ins.idx}]")
2955
+ chains.append(f"[{cur}][spkc{ins.idx}]overlay=x=0:y={sy}:enable='{en}'[{lab}]")
2956
+ cur = lab
2957
+ inputs += ["-i", str(ins.clip)]
2958
+ x, y = ins.content[0], ins.content[1]
2959
+ lab = nxt()
2960
+ chains.append(f"[{n_in}:v]settb=1/{fps},setpts=N+{ins.f0},format=yuv420p[ins{ins.idx}]")
2961
+ chains.append(f"[{cur}][ins{ins.idx}]overlay=x={x}:y={y}:eof_action=pass:enable='{en}'[{lab}]")
2962
+ cur = lab
2963
+ n_in += 1
2964
+
2965
+ motion_tracks = tracks.get("motion") or []
2966
+ if motion_tracks:
2967
+ from motion import render as MR
2968
+ for clip, f0, windows in motion_tracks:
2969
+ inputs += ["-i", str(clip["file"])]
2970
+ lab = nxt()
2971
+ chains.extend(MR.overlay_filters(clip, n_in, cur, lab, f0, windows))
2972
+ cur = lab
2973
+ n_in += 1
2974
+
2975
+ for name in ("mid", "captions", "top"):
2976
+ track = tracks.get(name)
2977
+ if not track or not track[0] or not track[1]:
2978
+ continue
2979
+ ff, _, windows = track
2980
+ inputs += ["-f", "concat", "-safe", "0", "-i", str(ff)]
2981
+ en = enable_expr(windows, nf)
2982
+ lab = nxt()
2983
+ chains.append(f"[{n_in}:v]format=rgba,scale=out_color_matrix=bt709:out_range=tv,format=yuva420p[t{name}]")
2984
+ opt = f":enable='{en}'" if en else ""
2985
+ chains.append(f"[{cur}][t{name}]overlay=0:0:eof_action=pass:format=auto{opt}[{lab}]")
2986
+ cur = lab
2987
+ n_in += 1
2988
+ chains.append(f"[{cur}]format=yuv420p[vout]")
2989
+
2990
+ script = plan.renders / "final_graph.txt"
2991
+ script.write_text(";\n".join(chains) + "\n", encoding="utf-8")
2992
+ crf, preset = ("26", "veryfast") if plan.preview else ("18", "medium")
2993
+ args = ["-v", "error", "-y", *inputs, *graph_args(script), "-map", "[vout]",
2994
+ "-frames:v", str(nf), "-fps_mode", "cfr", "-r", str(fps),
2995
+ "-c:v", "libx264", "-preset", preset, "-crf", crf, "-profile:v", "high", "-pix_fmt", "yuv420p",
2996
+ "-colorspace", "bt709", "-color_primaries", "bt709", "-color_trc", "bt709", "-color_range", "tv",
2997
+ "-x264-params", "colorprim=bt709:transfer=bt709:colormatrix=bt709",
2998
+ "-an", "-f", "mp4", str(tmp_video)]
2999
+ ffmpeg_cmd(args, "montar o vídeo final")
3000
+
3001
+
3002
+ # ============================================================== audio
3003
+
3004
+ def loudnorm_measure(path: Path, extra: list[str] | None = None, i=-14.0, tp=-1.0, lra=11.0) -> dict | None:
3005
+ proc = subprocess.run(["ffmpeg", "-hide_banner", "-nostdin", "-v", "info", *(extra or []), "-i", str(path),
3006
+ "-vn", "-af", f"loudnorm=I={i}:TP={tp}:LRA={lra}:print_format=json", "-f", "null", "-"],
3007
+ capture_output=True, text=True)
3008
+ m = re.search(r"\{[^{}]*\"input_i\"[^{}]*\}", proc.stderr or "", re.S)
3009
+ if not m:
3010
+ return None
3011
+ try:
3012
+ d = json.loads(m.group(0))
3013
+ vals = {k: float(d[k]) for k in ("input_i", "input_tp", "input_lra", "input_thresh", "target_offset")}
3014
+ except (ValueError, KeyError):
3015
+ return None
3016
+ if not all(math.isfinite(v) for v in vals.values()):
3017
+ return None
3018
+ return vals
3019
+
3020
+
3021
+ def _sound_share_kept(path: Path, n_keep: int) -> tuple[float, float] | None:
3022
+ """(share of the sound's energy inside its first n_keep samples at SR, its length in seconds).
3023
+
3024
+ None when the file cannot be read or is silent."""
3025
+ proc = subprocess.run(["ffmpeg", "-hide_banner", "-nostdin", "-v", "error", "-i", str(path), "-vn", "-ac", "1",
3026
+ "-ar", str(SR), "-f", "f32le", "-"], capture_output=True)
3027
+ if proc.returncode != 0 or not proc.stdout:
3028
+ return None
3029
+ x = np.frombuffer(proc.stdout[: len(proc.stdout) // 4 * 4], dtype="<f4").astype(np.float64)
3030
+ whole = float(np.sum(x * x))
3031
+ if whole <= 0:
3032
+ return None
3033
+ return float(np.sum(x[: max(0, n_keep)] ** 2)) / whole, len(x) / SR
3034
+
3035
+
3036
+ SFX_WHY_LABELS = {"gancho": "do gancho", "chamada": "da chamada", "flash": "do flash"}
3037
+
3038
+
3039
+ def _anchor_warning(e: dict, anchor: float, late: int, fps: int) -> str:
3040
+ """Warning for an anchored effect that had to start at 0 and so hits after its frame."""
3041
+ if e["why"] == "efeito":
3042
+ label = f"sfx.events[{e['event']}] ('{e['name']}' em {fmt_time(e['at'])})"
3043
+ else:
3044
+ of = SFX_WHY_LABELS.get(e["why"]) or (("da " if e["why"].startswith("animação") else "do ") + e["why"])
3045
+ label = f"efeito '{e['name']}' {of} em {fmt_time(e['frame'] / fps)}"
3046
+ return (f"{label}: o ponto forte fica {anchor:.2f} s depois do começo do som, então ele teria que começar antes "
3047
+ f"do vídeo. Começou no início e o ponto forte chega {late / SR:.2f} s atrasado")
3048
+
3049
+
3050
+ def run_audio(plan: Plan, inserts: list[Insert], sfx_events: list[dict], out_wav: Path, report: dict,
3051
+ voice: Path | None = None) -> None:
3052
+ """voice: a cleaned voice track (audio.denoise) used instead of the base's audio; None = the base's audio."""
3053
+ data = plan.data
3054
+ fps, total = plan.fps, plan.nf * SR // plan.fps
3055
+ loud = _section(data, "loudness")
3056
+ target_i = float(loud.get("i", -14))
3057
+ target_tp = float(loud.get("tp", -1.0))
3058
+ target_lra = float(loud.get("lra", 11))
3059
+ work = plan.renders / "audio"
3060
+ work.mkdir(parents=True, exist_ok=True)
3061
+ voice_in = voice if voice is not None else plan.base
3062
+
3063
+ voice_i = None
3064
+ if plan.has_audio:
3065
+ m = loudnorm_measure(voice_in)
3066
+ voice_i = m["input_i"] if m else None
3067
+ ref_i = voice_i if voice_i is not None else target_i
3068
+
3069
+ inputs: list[str] = []
3070
+ chains: list[str] = []
3071
+ if plan.has_audio:
3072
+ inputs += ["-i", str(voice_in)]
3073
+ voice_src = "[0:a]"
3074
+ else:
3075
+ inputs += ["-f", "lavfi", "-t", f"{plan.duration:.6f}", "-i", f"anullsrc=r={SR}:cl=stereo"]
3076
+ voice_src = "[0:a]"
3077
+ n_in = 1
3078
+ music = _section(data, "music")
3079
+ music_on = _enabled(music) and music.get("file")
3080
+ duck = bool(music.get("duck", True)) if music_on else False
3081
+ chains.append(f"{voice_src}aresample={SR},aformat=sample_fmts=fltp:channel_layouts=stereo,"
3082
+ f"apad=whole_len={total},atrim=end_sample={total},asetpts=N/SR/TB[voice]")
3083
+ mix_inputs = []
3084
+ if duck:
3085
+ chains.append("[voice]asplit=2[vmix][vkey]")
3086
+ mix_inputs.append("[vmix]")
3087
+ else:
3088
+ mix_inputs.append("[voice]")
3089
+
3090
+ # SFX: gain calibrated for a voice at the target loudness
3091
+ sfx_sec = _section(data, "sfx")
3092
+ placed = []
3093
+ if _enabled(sfx_sec):
3094
+ vol = float(sfx_sec.get("volume", 0.6) or 0.0)
3095
+ adj_db = max(-10.0, min(10.0, ref_i - target_i))
3096
+ gain = vol * SFX_CALIBRATION * (10 ** (adj_db / 20.0))
3097
+ events = list(sfx_events)
3098
+ for ins in inserts:
3099
+ if ins.sfx:
3100
+ events.append({"name": ins.sfx, "frame": ins.f0, "lead_frames": 2, "why": f"insert {ins.idx + 1}"})
3101
+ names = {e["name"] for e in events}
3102
+ custom = {}
3103
+ for nm in names:
3104
+ p = Path(nm)
3105
+ if p.suffix.lower() in SFX_FILE_EXT:
3106
+ custom[nm] = _resolve(plan.edit_dir, nm, "sfx")
3107
+ # a category of the effects library ("transicao", "humor") or one of its takes ("humor-012"): a category
3108
+ # hands out a different take to every event, in a fixed order per edit. A category name wins over a
3109
+ # built-in alias that happens to match it ("impacto" is also an alias of metal_hit)
3110
+ import efeitos
3111
+ library = efeitos.resolve([nm for nm in names if nm not in custom], seed=plan.edit_dir.name)
3112
+ builtin = make_sfx.builtin_sfx([nm for nm in names if nm not in custom and nm not in library])
3113
+ manifest = make_sfx.load_manifest()
3114
+ handed: dict[str, int] = {}
3115
+ for e in sorted(events, key=lambda e: e["frame"]):
3116
+ known = builtin.get(e["name"])
3117
+ if known is None and e["name"] in library:
3118
+ pool = library[e["name"]]["takes"]
3119
+ k = handed.get(e["name"], 0)
3120
+ handed[e["name"]] = k + 1
3121
+ known = pool[k % len(pool)]
3122
+ path = custom.get(e["name"]) or (known["path"] if known else None)
3123
+ if path is None:
3124
+ if make_sfx.resolve_name(e["name"], manifest) is not None or e["name"] in make_sfx.NAMES:
3125
+ plan.warnings.append(f"o arquivo do efeito sonoro '{e['name']}' está faltando na pasta assets/sfx da "
3126
+ f"skill e ele não tocou. Instale a skill de novo")
3127
+ else:
3128
+ plan.warnings.append(f"efeito sonoro '{e['name']}' não existe (use {', '.join(make_sfx.NAMES)}, o nome "
3129
+ f"em português de um deles, como grave ou notificacao, uma categoria da "
3130
+ f"biblioteca de efeitos ({', '.join(efeitos.CATEGORIES)}), um som dela como "
3131
+ f"humor-012, ou um arquivo)")
3132
+ continue
3133
+ ev_gain = gain * float(e.get("volume", 1.0)) # sfx.events can carry their own multiplier
3134
+ anchor = e.get("anchor_s")
3135
+ if anchor is None and known:
3136
+ anchor = known["anchor_s"]
3137
+ late = 0
3138
+ if anchor is None:
3139
+ # a sound file with no anchor: its t=0 on the event frame (lead_frames early), as always
3140
+ start = max(0, (e["frame"] - e["lead_frames"]) * SR // fps)
3141
+ else:
3142
+ # the anchor (the moment the sound hits full strength) lands on the event frame
3143
+ start = e["frame"] * SR // fps - int(round(anchor * SR))
3144
+ if start < 0:
3145
+ late, start = -start, 0
3146
+ if start >= total or ev_gain <= 0:
3147
+ continue
3148
+ kept = _sound_share_kept(path, total - start) if "event" in e else None
3149
+ # an anchor_s past the end of the sound gets its own warning below, not a "late" one
3150
+ past_end = kept is not None and e.get("anchor_s") is not None and e["anchor_s"] > kept[1]
3151
+ if late and anchor >= (e["frame"] + 1) / fps and not past_end:
3152
+ plan.warnings.append(_anchor_warning(e, anchor, late, fps))
3153
+ inputs += ["-i", str(path)]
3154
+ lab = f"sfx{n_in}"
3155
+ trim = ""
3156
+ if e.get("length"):
3157
+ # a cue that lasts as long as its animation (typing): cut the sound after its strong point + length
3158
+ cut = (anchor or 0.0) + float(e["length"])
3159
+ trim = f"atrim=end={cut:.4f},afade=t=out:st={max(0.0, cut - SFX_TRIM_FADE):.4f}:d={SFX_TRIM_FADE:.3f},"
3160
+ chains.append(f"[{n_in}:a]aresample={SR},aformat=sample_fmts=fltp:channel_layouts=stereo,"
3161
+ f"{trim}volume={ev_gain:.4f},adelay=delays={start}S:all=1[{lab}]")
3162
+ mix_inputs.append(f"[{lab}]")
3163
+ entry = {"name": e["name"], "why": e["why"], "sample": start, "time": round(start / SR, 4),
3164
+ "file": str(path)}
3165
+ if known and known.get("id") and known["id"] != e["name"]:
3166
+ entry["effect"] = known["id"] # a Portuguese name: the sfx.json id it played
3167
+ for key in ("at", "volume", "event", "motion", "length"):
3168
+ if key in e:
3169
+ entry[key] = e[key]
3170
+ if anchor:
3171
+ entry["anchor_s"] = anchor
3172
+ if late:
3173
+ entry["late_s"] = round(late / SR, 4)
3174
+ if "event" in e:
3175
+ if kept is not None and kept[0] < SFX_EVENT_MIN_KEPT:
3176
+ plan.warnings.append(f"sfx.events[{e['event']}] ('{e['name']}' em {fmt_time(e['at'])}) é cortado "
3177
+ f"pelo fim do vídeo: toca só {(total - start) / SR:.2f} s dos "
3178
+ f"{kept[1]:.2f} s do som. Adiante o \"at\" para ouvir o som inteiro")
3179
+ if past_end:
3180
+ plan.warnings.append(f"sfx.events[{e['event']}].anchor_s {e['anchor_s']:g} passa do fim do som "
3181
+ f"({kept[1]:.2f} s): o som inteiro tocou antes de {fmt_time(e['at'])}")
3182
+ placed.append(entry)
3183
+ n_in += 1
3184
+ report["sfx"] = placed
3185
+
3186
+ if music_on:
3187
+ mpath = _resolve(plan.edit_dir, music.get("file"), "music.file")
3188
+ mm = loudnorm_measure(mpath, extra=["-t", f"{plan.duration + 1:.3f}"])
3189
+ vol_db = float(music.get("volume_db", MUSIC_VOLUME_DB))
3190
+ if mm:
3191
+ gain_db = (ref_i - mm["input_i"]) + vol_db
3192
+ else:
3193
+ gain_db = vol_db
3194
+ plan.warnings.append("não consegui medir o volume da trilha; usei volume_db direto")
3195
+ gain_db = max(-60.0, min(12.0, gain_db))
3196
+ fade = max(0.0, min(plan.duration, float(music.get("fade_out", 1.5) or 0.0)))
3197
+ inputs += ["-stream_loop", "-1", "-i", str(mpath)]
3198
+ chains.append(f"[{n_in}:a]aresample={SR},aformat=sample_fmts=fltp:channel_layouts=stereo,"
3199
+ f"atrim=end_sample={total},asetpts=N/SR/TB,volume={gain_db:.2f}dB,afade=t=in:d=0.35[mus0]")
3200
+ cur = "mus0"
3201
+ if duck:
3202
+ # sidechaincompress ends when either input ends, even with samples of the other still waiting, and
3203
+ # ffmpeg 8 feeds the inputs from their own threads: endless silence after both, cut at total after
3204
+ chains.append("[mus0]apad[mus0p]")
3205
+ chains.append("[vkey]apad[vkeyp]")
3206
+ chains.append(f"[mus0p][vkeyp]sidechaincompress=threshold=0.1:ratio=3:attack=20:release=300:knee=3:"
3207
+ f"makeup=1,atrim=end_sample={total}[mus1]")
3208
+ cur = "mus1"
3209
+ if fade > 0:
3210
+ chains.append(f"[{cur}]afade=t=out:st={plan.duration - fade:.4f}:d={fade:.4f}[mus]")
3211
+ else:
3212
+ chains.append(f"[{cur}]anull[mus]")
3213
+ mix_inputs.append("[mus]")
3214
+ report["music"] = {"file": plan.rel(mpath), "measured_i": mm["input_i"] if mm else None,
3215
+ "volume_db": vol_db, "gain_db": round(gain_db, 2), "duck": duck, "fade_out": fade}
3216
+ layer = {"type": "music", "file": plan.rel(mpath), "gain_db": round(gain_db, 2), "duck": duck,
3217
+ "fade_out": fade}
3218
+ choice = plan.music_choice
3219
+ if choice:
3220
+ picked = {"mood": choice.get("mood"), "track_id": choice.get("track_id"), "title": choice.get("title"),
3221
+ "chosen_from": choice.get("from"), "why": choice.get("why"), "source": choice.get("source"),
3222
+ "license": choice.get("license"), "sha256": choice.get("sha256")}
3223
+ report["music"].update(picked)
3224
+ layer.update({"mood": picked["mood"], "track_id": picked["track_id"]})
3225
+ elif music.get("mood") not in (None, ""):
3226
+ report["music"]["mood_ignored"] = music.get("mood") # music.file wins over music.mood
3227
+ plan.layers.append(layer)
3228
+ n_in += 1
3229
+
3230
+ if len(mix_inputs) > 1:
3231
+ # every input exactly total samples long: where amix stops never depends on which input arrived first
3232
+ fitted = []
3233
+ for k, lab in enumerate(mix_inputs):
3234
+ chains.append(f"{lab}apad=whole_len={total},atrim=end_sample={total}[fit{k}]")
3235
+ fitted.append(f"[fit{k}]")
3236
+ chains.append(f"{''.join(fitted)}amix=inputs={len(fitted)}:normalize=0:duration=longest:"
3237
+ f"dropout_transition=0[mixraw]")
3238
+ else:
3239
+ chains.append(f"{mix_inputs[0]}anull[mixraw]")
3240
+ chains.append(f"[mixraw]apad=whole_len={total},atrim=end_sample={total}[aout]")
3241
+ script = work / "audio_graph.txt"
3242
+ script.write_text(";\n".join(chains) + "\n", encoding="utf-8")
3243
+ mix = work / "mix.wav"
3244
+ ffmpeg_cmd(["-v", "error", "-y", *inputs, *graph_args(script), "-map", "[aout]", "-c:a", "pcm_f32le",
3245
+ "-ar", SR, "-ac", 2, str(mix)], "mixar o áudio")
3246
+
3247
+ norm_tp = target_tp - AAC_TP_HEADROOM
3248
+ if not plan.has_audio and not music_on:
3249
+ # no voice and no music: only effects. Normalizing them to -14 LUFS would make them blast.
3250
+ measured = None
3251
+ os.replace(mix, out_wav)
3252
+ plan.warnings.append("o vídeo base não tem áudio: só os efeitos entram, sem normalizar o volume")
3253
+ else:
3254
+ measured = loudnorm_measure(mix, i=target_i, tp=norm_tp, lra=target_lra)
3255
+ if measured:
3256
+ af = (f"loudnorm=I={target_i}:TP={norm_tp}:LRA={target_lra}:measured_I={measured['input_i']}:"
3257
+ f"measured_TP={measured['input_tp']}:measured_LRA={measured['input_lra']}:"
3258
+ f"measured_thresh={measured['input_thresh']}:offset={measured['target_offset']}:linear=true")
3259
+ else:
3260
+ af = f"loudnorm=I={target_i}:TP={norm_tp}:LRA={target_lra}"
3261
+ plan.warnings.append("medição de loudness falhou (áudio mudo?); normalização em uma passada")
3262
+ ffmpeg_cmd(["-v", "error", "-y", "-i", mix, "-af",
3263
+ f"{af},aresample={SR},apad=whole_len={total},atrim=end_sample={total}",
3264
+ "-c:a", "pcm_f32le", "-ar", SR, "-ac", 2, str(out_wav)], "normalizar o volume")
3265
+ report["loudnorm_input"] = measured
3266
+ report["voice_i"] = voice_i
3267
+ if not plan.data.get("_keep_temp") and mix.exists():
3268
+ try:
3269
+ mix.unlink()
3270
+ except OSError:
3271
+ pass
3272
+
3273
+
3274
+ # ============================================================== main
3275
+
3276
+ def probe_output(path: Path) -> dict:
3277
+ info = ffprobe_json(path)
3278
+ v = next((s for s in info.get("streams", []) if s.get("codec_type") == "video"), {})
3279
+ a = next((s for s in info.get("streams", []) if s.get("codec_type") == "audio"), {})
3280
+ from fractions import Fraction
3281
+ try:
3282
+ fps = float(Fraction(v.get("avg_frame_rate") or "0/1"))
3283
+ except (ValueError, ZeroDivisionError):
3284
+ fps = 0.0
3285
+ return {"duration": float((info.get("format") or {}).get("duration") or 0), "width": int(v.get("width") or 0),
3286
+ "height": int(v.get("height") or 0), "fps": round(fps, 3), "frames": int(v.get("nb_frames") or 0),
3287
+ "video_codec": v.get("codec_name"), "profile": v.get("profile"), "pix_fmt": v.get("pix_fmt"),
3288
+ "color_space": v.get("color_space"), "color_primaries": v.get("color_primaries"),
3289
+ "color_transfer": v.get("color_transfer"), "color_range": v.get("color_range"),
3290
+ "audio_codec": a.get("codec_name"), "sample_rate": int(a.get("sample_rate") or 0),
3291
+ "channels": int(a.get("channels") or 0), "audio_duration": float(a.get("duration") or 0)}
3292
+
3293
+
3294
+ def update_state(plan: Plan) -> None:
3295
+ state_path = plan.edit_dir / "state.json"
3296
+ state = load_json(state_path, default={}) or {}
3297
+ if not isinstance(state, dict):
3298
+ state = {}
3299
+ if plan.preview:
3300
+ # The low-res preview is for the agent to check frames; it never asks the person to approve.
3301
+ state.update({
3302
+ "phase": "final",
3303
+ "awaiting": None,
3304
+ "edit_data": plan.rel(plan.edit_data_path),
3305
+ "message": "Montando o vídeo final...",
3306
+ "updated_at": now_iso(),
3307
+ })
3308
+ else:
3309
+ state.update({
3310
+ "phase": "final",
3311
+ "awaiting": "approval_final",
3312
+ "final_video": plan.rel(plan.out_path),
3313
+ "edit_data": plan.rel(plan.edit_data_path),
3314
+ "message": "Vídeo final pronto. Assista e aprove ou marque o que mudar.",
3315
+ "updated_at": now_iso(),
3316
+ })
3317
+ save_json(state_path, state)
3318
+
3319
+
3320
+ def compose(edit_data_path: Path, out: str | None, preview: bool, keep_temp: bool = False) -> int:
3321
+ t_start = time.time()
3322
+ require_binary("ffmpeg")
3323
+ require_binary("ffprobe")
3324
+ plan = build_plan(edit_data_path, out, preview)
3325
+ timed_sfx = plan_sfx_events(plan)
3326
+ denoise = plan_denoise(plan)
3327
+ motions = plan_motion(plan)
3328
+ tv_cfg = plan_tela_verde(plan)
3329
+ if keep_temp:
3330
+ plan.data["_keep_temp"] = True
3331
+ plan.renders.mkdir(parents=True, exist_ok=True)
3332
+ log(f"compondo {plan.rel(plan.out_path)}: {plan.W}x{plan.H} {plan.fps}fps, {plan.nf} quadros "
3333
+ f"({fmt_time(plan.duration)}){' · prévia' if preview else ''}")
3334
+ if plan.music_choice:
3335
+ c = plan.music_choice
3336
+ why = "a mesma da última montagem desta edição" if c.get("from") == "escolha anterior" else c.get("why")
3337
+ log(f"trilha do humor {c.get('mood')}: {c.get('track_id')} \"{c.get('title')}\" em {c.get('file')} ({why})")
3338
+
3339
+ load_faces(plan)
3340
+ cam = camera_filter(plan)
3341
+ if tv_cfg is not None:
3342
+ # the inserts first: a wrong one stops before the cut-out; split_top / split_bottom become the background
3343
+ inserts = plan_inserts(plan, tela_verde=True)
3344
+ insert_backgrounds(plan, tv_cfg, inserts)
3345
+ prepare_tela_verde(plan, tv_cfg)
3346
+ place_pips_tela_verde(plan, inserts)
3347
+ cam = None
3348
+ else:
3349
+ inserts = plan_inserts(plan)
3350
+ drawn = [i for i in inserts if not i.to_bg] # every insert when the edit is not tela verde
3351
+ compute_face_zones(plan, drawn)
3352
+ compute_person_zones(plan, drawn)
3353
+ if plan.image_zones:
3354
+ covered = [(i.f0, i.f1) for i in drawn if i.type == "full"]
3355
+ plan.image_zones = subtract_windows(plan.image_zones, merge_windows(covered)) if covered else plan.image_zones
3356
+ audio_report: dict = {}
3357
+ denoise_report: dict = {"mode": "off", "applied": "off"}
3358
+ audio_wav = plan.renders / "audio" / "final_audio.wav"
3359
+ audio_error: list[BaseException] = []
3360
+
3361
+ t0 = time.time()
3362
+ tv_clip = None
3363
+ with ThreadPoolExecutor(max_workers=3) as ex:
3364
+ tv_job = ex.submit(render_tela_verde, plan) if plan.tv else None
3365
+ clips = list(ex.map(lambda i: render_insert_clip(plan, i), drawn))
3366
+ if tv_job is not None:
3367
+ tv_clip = tv_job.result()
3368
+ plan.tv["compose_seconds"] = tv_clip.get("render_seconds", 0.0)
3369
+ layer = tela_verde_layer(plan, tv_clip)
3370
+ plan.layers.insert(0, layer)
3371
+ log(f"tela verde pronta{' (do cache)' if tv_clip.get('cached') else ''}: recorte {plan.tv['matte_seconds']:.1f}s, "
3372
+ f"montagem {tv_clip.get('render_seconds', 0.0):.1f}s ({plan.nf} quadros)")
3373
+ for ins, clip in zip(drawn, clips):
3374
+ ins.clip = clip
3375
+ bg_rows = {b.insert: b for b in plan.tv["cfg"].from_inserts} if plan.tv else {}
3376
+ for ins in inserts:
3377
+ if ins.to_bg:
3378
+ b = bg_rows.get(ins.idx + 1)
3379
+ plan.layers.append({"type": "insert", "insert_type": ins.type, "as": "fundo", "src": plan.rel(ins.src),
3380
+ "kind": ins.kind, "start": ins.f0 / plan.fps, "end": ins.f1 / plan.fps,
3381
+ "frames": [ins.f0, ins.f1], "ken_burns": ins.ken_burns,
3382
+ "encaixe": b.fit if b else None,
3383
+ "rect": ([int(round(v / plan.s)) for v in b.place] if b and b.place
3384
+ else [0, 0, plan.full_w, plan.full_h]),
3385
+ "speaker_rect": None, "speaker_offsets": None, "seam_y": None, "sfx": ins.sfx})
3386
+ continue
3387
+ plan.layers.append({"type": "insert", "insert_type": ins.type, "src": plan.rel(ins.src), "kind": ins.kind,
3388
+ "start": ins.f0 / plan.fps, "end": ins.f1 / plan.fps, "frames": [ins.f0, ins.f1],
3389
+ "ken_burns": ins.ken_burns, "rect": list(ins.content),
3390
+ "speaker_rect": list(ins.speaker) if ins.speaker else None,
3391
+ "speaker_offsets": ([{"start": round(p0 / plan.fps, 3), "end": round(p1 / plan.fps, 3),
3392
+ "src_y": int(round(y / plan.s))} for p0, p1, y in ins.speaker_pieces]
3393
+ if ins.speaker else None),
3394
+ "seam_y": ins.seam_y, "sfx": ins.sfx})
3395
+ if drawn:
3396
+ log(f"inserts prontos ({len(drawn)}) em {time.time() - t0:.1f}s")
3397
+
3398
+ t0 = time.time()
3399
+ tracks = build_tracks(plan, drawn, motions)
3400
+ motion_layers(plan, motions)
3401
+ log(f"camadas de texto prontas em {time.time() - t0:.1f}s")
3402
+
3403
+ def audio_job():
3404
+ try:
3405
+ voice = None
3406
+ if denoise != "off":
3407
+ t_dn = time.time()
3408
+ voice, rep = run_denoise(plan, denoise, keep_temp)
3409
+ denoise_report.clear()
3410
+ denoise_report.update(rep)
3411
+ denoise_report["seconds"] = round(time.time() - t_dn, 2)
3412
+ run_audio(plan, inserts, tracks["sfx_events"] + timed_sfx, audio_wav, audio_report, voice=voice)
3413
+ except BaseException as e: # noqa: BLE001
3414
+ audio_error.append(e)
3415
+
3416
+ th = threading.Thread(target=audio_job, daemon=True)
3417
+ th.start()
3418
+
3419
+ t0 = time.time()
3420
+ tmp_video = plan.renders / "final_video_only.mp4"
3421
+ run_video(plan, drawn, tracks, cam, tmp_video, Path(tv_clip["file"]) if tv_clip else None)
3422
+ log(f"vídeo codificado em {time.time() - t0:.1f}s")
3423
+ th.join()
3424
+ if audio_error:
3425
+ err = audio_error[0]
3426
+ raise err if isinstance(err, ComposeError) else ComposeError(f"falha no áudio: {err}")
3427
+
3428
+ plan.out_path.parent.mkdir(parents=True, exist_ok=True)
3429
+ loud = _section(plan.data, "loudness")
3430
+ target_i, target_tp = float(loud.get("i", -14)), float(loud.get("tp", -1.0))
3431
+ tmp_out = plan.out_path.with_name(".tmp-" + plan.out_path.name)
3432
+
3433
+ def mux(gain_db: float) -> None:
3434
+ af = ["-af", f"volume={gain_db:.2f}dB"] if abs(gain_db) > 0.001 else []
3435
+ ffmpeg_cmd(["-v", "error", "-y", "-i", tmp_video, "-i", audio_wav, "-map", "0:v:0", "-map", "1:a:0",
3436
+ "-c:v", "copy", *af, "-c:a", "aac", "-b:a", "192k", "-ar", SR, "-ac", 2,
3437
+ "-t", f"{plan.duration:.6f}", "-movflags", "+faststart", "-f", "mp4", tmp_out],
3438
+ "juntar vídeo e áudio")
3439
+
3440
+ mux(0.0)
3441
+ final_loud = loudnorm_measure(tmp_out, i=target_i, tp=target_tp)
3442
+ if final_loud and final_loud["input_tp"] > target_tp + 0.05:
3443
+ # the AAC encoder pushed a true peak over the target: trim the gain just enough and encode again
3444
+ mux(target_tp - final_loud["input_tp"] - 0.1)
3445
+ final_loud = loudnorm_measure(tmp_out, i=target_i, tp=target_tp)
3446
+ os.replace(tmp_out, plan.out_path)
3447
+ for p in (tmp_video, audio_wav):
3448
+ if keep_temp:
3449
+ break
3450
+ try:
3451
+ p.unlink()
3452
+ except OSError:
3453
+ pass
3454
+
3455
+ facts = probe_output(plan.out_path)
3456
+ elapsed = time.time() - t_start
3457
+ for e in audio_report.get("sfx", []):
3458
+ layer = {"type": "sfx", "name": e["name"], "why": e["why"], "time": e["time"], "sample": e["sample"]}
3459
+ for key in ("effect", "at", "volume", "event", "motion", "length", "anchor_s", "late_s"):
3460
+ if key in e:
3461
+ layer[key] = e[key]
3462
+ plan.layers.append(layer)
3463
+ report = {
3464
+ "output": plan.rel(plan.out_path),
3465
+ "edit_data": plan.rel(plan.edit_data_path),
3466
+ "base": plan.rel(plan.base),
3467
+ "preview": plan.preview,
3468
+ "duration": round(plan.duration, 6),
3469
+ "frames": plan.nf,
3470
+ "fps": plan.fps,
3471
+ "width": plan.W,
3472
+ "height": plan.H,
3473
+ "probe": facts,
3474
+ "loudness": {
3475
+ "target_i": target_i, "target_tp": target_tp,
3476
+ "i": final_loud["input_i"] if final_loud else None,
3477
+ "tp": final_loud["input_tp"] if final_loud else None,
3478
+ "lra": final_loud["input_lra"] if final_loud else None,
3479
+ "voice_before_i": audio_report.get("voice_i"),
3480
+ },
3481
+ "music": audio_report.get("music"),
3482
+ "denoise": denoise_report,
3483
+ "layers": plan.layers,
3484
+ "faces": {"file": "face_layout.json" if plan.faces else None,
3485
+ "segments_with_face": sum(1 for cs in plan.cam_segs if cs.get("face")),
3486
+ "segments": len(plan.cam_segs)},
3487
+ "adjustments": plan.adjustments,
3488
+ "face_checks": plan.face_checks,
3489
+ "warnings": plan.warnings,
3490
+ "render_seconds": round(elapsed, 2),
3491
+ "realtime_factor": round(elapsed / max(plan.duration, 1e-6), 3),
3492
+ "created_at": now_iso(),
3493
+ }
3494
+ report_path = plan.edit_dir / "verify" / "final_report.json"
3495
+ save_json(report_path, report)
3496
+ update_state(plan)
3497
+
3498
+ counts = {}
3499
+ for layer in plan.layers:
3500
+ counts[layer["type"]] = counts.get(layer["type"], 0) + 1
3501
+ parts = [f"{v} {k}" for k, v in counts.items() if k != "camera"]
3502
+ cam_layer = next((layer for layer in plan.layers if layer["type"] == "camera"), {})
3503
+ if cam_layer.get("zoom_cuts"):
3504
+ parts.append("zoom nos cortes")
3505
+ if cam_layer.get("push_in"):
3506
+ parts.append("aproximação lenta")
3507
+ log(f"camadas: {', '.join(parts) if parts else 'nenhuma além da base'}")
3508
+ moved = [a for a in plan.adjustments if a.get("action") == "moved"]
3509
+ hidden = [a for a in plan.adjustments if a.get("action") == "hidden"]
3510
+ if moved or hidden:
3511
+ names = {"captions": ("legenda", "escondida"), "disclaimer": ("aviso", "escondido"), "hook": ("gancho", "escondido"),
3512
+ "cta": ("chamada", "escondida")}
3513
+ for a in moved + hidden:
3514
+ if a["layer"].startswith("motion ") and a["layer"] not in names:
3515
+ names[a["layer"]] = (a["layer"].replace("motion", "animação"), "escondida")
3516
+ bits = [f"{names.get(a['layer'], (a['layer'], 'escondido'))[0]} "
3517
+ f"{'mudou de altura' if a['action'] == 'moved' else names.get(a['layer'], ('', 'escondido'))[1]} "
3518
+ f"{fmt_time(a['start'])}-{fmt_time(a['end'])}" for a in (moved + hidden)[:6]]
3519
+ more = f" e mais {len(moved) + len(hidden) - 6}" if len(moved) + len(hidden) > 6 else ""
3520
+ log(f"ajustes para não sobrepor textos: {'; '.join(bits)}{more} (detalhes em adjustments no relatório)")
3521
+ if final_loud:
3522
+ log(f"volume final: {final_loud['input_i']:.1f} LUFS, pico {final_loud['input_tp']:.1f} dBTP")
3523
+ line = denoise_line(denoise_report)
3524
+ if line:
3525
+ log(line)
3526
+ for w in plan.warnings:
3527
+ log(f"aviso: {w}")
3528
+ log(f"ok: {plan.out_path} ({facts['width']}x{facts['height']}, {facts['fps']:g}fps, {facts['duration']:.2f}s)")
3529
+ log(f"relatório: {report_path}")
3530
+ log(f"tempo total de render: {elapsed:.1f}s ({elapsed / max(plan.duration, 1e-6):.2f}x a duração do vídeo)")
3531
+ return 0
3532
+
3533
+
3534
+ def main(argv: list[str] | None = None) -> int:
3535
+ ap = argparse.ArgumentParser(description="Monta o vídeo final a partir do edit_data.json (zoom, inserts, textos, efeitos, trilha).")
3536
+ ap.add_argument("edit_data", help="caminho do edit_data.json (fica na pasta edit)")
3537
+ ap.add_argument("-o", "--output", help="arquivo de saída (padrão: final.mp4 na pasta edit; com --preview, final_preview.mp4)")
3538
+ ap.add_argument("--preview", action="store_true", help="prévia rápida em 540 de largura, CRF 26")
3539
+ ap.add_argument("--keep-temp", action="store_true", help="mantém os arquivos intermediários em renders/ (para investigar)")
3540
+ args = ap.parse_args(argv)
3541
+ try:
3542
+ return compose(Path(args.edit_data).expanduser().resolve(), args.output, args.preview, args.keep_temp)
3543
+ except ComposeError as e:
3544
+ print(f"erro: {e}", file=sys.stderr)
3545
+ return e.code
3546
+ except ValueError as e:
3547
+ print(f"erro: {e}", file=sys.stderr)
3548
+ return 2
3549
+ except KeyboardInterrupt:
3550
+ print("interrompido", file=sys.stderr)
3551
+ return 130
3552
+
3553
+
3554
+ if __name__ == "__main__":
3555
+ sys.exit(main())