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,2113 @@
1
+ """Tela verde (green screen without a green screen): the speaker cut out of the cut and placed over a
2
+ background, as the base layer of compose_final.py (SPEC Contract 13).
3
+
4
+ edit_data.json:
5
+ "edit_type": "tela_verde",
6
+ "tela_verde": {"posicao": "direita" | "esquerda" | "centro", "altura": 0.55,
7
+ "contorno": {"cor": "#FFFFFF", "largura": 8} | null, "sombra": true,
8
+ "fundo": [{"src": "assets/x.mp4" | "assets/y.png" | "#101010", "start": 0, "end": 12.3,
9
+ "ken_burns": true, "escurecer": 0.15, "desfocar": 0,
10
+ "encaixe": "auto" | "cobrir" | "inteiro"}],
11
+ "fundo_original_desfocado": false, "sem_rosto": "original" | "recorte", "espelhar": false}
12
+ "inserts": [{"type": "split_top" | "split_bottom", ...}] -> backgrounds too (add_insert_backgrounds)
13
+
14
+ How a frame is made (numpy + OpenCV, several frames at once in threads):
15
+ - background track: the items of "fundo" are the base; the split_top / split_bottom inserts of the edit become
16
+ background items that win during their own [start, end) (no split panel). A later item wins where two of
17
+ the same kind overlap; a gap keeps the item before it; before the first item, the blurred original with
18
+ fundo_original_desfocado, else a dark color.
19
+ - encaixe: "cobrir" fills the screen (cropping; a page taller than the screen keeps its top), "inteiro" shows
20
+ the whole picture or video, sharp, scaled to the width and placed in the stage above the person (the part of
21
+ the screen the person does not cover, minus a band for auto captions when there is room) over a blurred and
22
+ darkened cover copy of itself; "auto" is cobrir when the shape is close to the screen's (or taller), else
23
+ inteiro. Ken Burns is an eased 6% zoom over the item's time (at most twice that time when a gap holds it,
24
+ then it holds). Videos start at their own frame 0 and play untouched, except empty frames at the start or
25
+ end of the clip (scan_video): the background right before it dissolves into an empty start while the clip
26
+ builds up (with nothing before it, the first full frame is held there) and an empty end holds the last full
27
+ frame; only a video held past its end loops, dissolving from its last frame
28
+ into the new pass (no jump, no freeze). A video shown whole with a plain border (a studio, a flat color)
29
+ stays wide (down to QUIET_MIN of the width, to leave the captions a band above the head), its main area
30
+ (the rows that hold most of its detail over the whole clip) placed in the stage, and its border is carried
31
+ out to the screen edges, so it has no box around it; the part that does not fit above the head goes behind
32
+ the captions and the person.
33
+ - person (cut ranges where face_layout found a face, or every range with "sem_rosto": "recorte"): the frame
34
+ of cut.mp4 times the cached matte (helpers/matte.py; on a tripod take, without the room its RoomPlate
35
+ recognizes where the matte is unsure, and with that room's color out of its soft edge), scaled so the
36
+ person, from the top of the head to
37
+ the bottom of the screen, is `altura` of the screen height (never wider than 72% of the screen in a
38
+ corner, or than the screen in the center, before the zoom; a centered tight shot cut on both sides grows up to
39
+ CENTER_FILL to fill the width), flush with the bottom. In a corner the side the camera cut sits on the screen
40
+ edge; in the center a cut side never lands inside the screen when the frame is wide enough; in a corner, a
41
+ segment cut on both sides fills the screen width (up to CORNER_FILL times its corner size), a little lower
42
+ (up to FILL_DROP of the camera frame goes below the screen), so no cut lands inside the screen. A segment whose
43
+ face is cut by a side of the camera goes to that side (a face is never sliced in the middle of the screen). A
44
+ cut edge that still falls inside the screen gets a fade only on the rows the body really touches (SIDE_FADE
45
+ on a side, so an arm or shoulder dissolves instead of ending in a straight line; a short one on the top; the
46
+ person, its outline and its shadow fade together, so the other rows stay solid and the outline never shows
47
+ through).
48
+ The outline follows the outer body only: a gap it encloses (between an arm and the body, counting the
49
+ bottom and the cut sides as closed) shows the background with no outline around it.
50
+ zoom_cuts and push_in scale the person (anchored on that edge, or on the head for centro). "espelhar" flips
51
+ the person segments (not the ones shown as recorded). Outline and shadow are drawn at half size and blended
52
+ with cv2.blendLinear.
53
+ - ranges without a face ("sem_rosto": "original", the default) show the cut frame itself, full screen,
54
+ with the zoom of the camera section (screen recordings, product close-ups).
55
+ The result is one H.264 clip (renders/<full|preview>/telaverde/tv_<key>.mp4, cached by everything that goes
56
+ into it) that compose_final.py uses in place of cut.mp4.
57
+ """
58
+
59
+ from __future__ import annotations
60
+
61
+ import hashlib
62
+ import json
63
+ import math
64
+ import os
65
+ import subprocess
66
+ import time
67
+ from collections import deque
68
+ from concurrent.futures import ThreadPoolExecutor
69
+ from dataclasses import dataclass, field
70
+ from pathlib import Path
71
+ from typing import Callable
72
+
73
+ import numpy as np
74
+
75
+ import matte as M
76
+
77
+ TV_VERSION = "tv5" # bump to redo cached composites
78
+ POSITIONS = ("direita", "esquerda", "centro")
79
+ POSITION_ALIASES = {"direita": "direita", "right": "direita", "canto direito": "direita", "canto_direito": "direita",
80
+ "esquerda": "esquerda", "left": "esquerda", "canto esquerdo": "esquerda",
81
+ "canto_esquerdo": "esquerda", "centro": "centro", "center": "centro", "meio": "centro"}
82
+ DEFAULT_HEIGHT = {"direita": 0.55, "esquerda": 0.55, "centro": 0.75}
83
+ HEIGHT_RANGE = (0.3, 1.0)
84
+ CORNER_MAX_WIDTH = 0.72 # a corner person is never wider than this share of the screen (before the zoom)
85
+ CENTER_MAX_WIDTH = 1.0 # a centered person is never wider than the screen (a tight shot is not enlarged)
86
+ CENTER_FILL = 1.15 # ...except a shot cut on both sides, grown up to this much to fill the width
87
+ CORNER_FILL = 1.6 # a corner person cut on both sides grows up to this much to fill the width
88
+ FILL_DROP = 0.2 # ...and loses up to this share of the bottom of the camera frame, so the head
89
+ # stays where the corner size puts it (the images above keep their room)
90
+ FACE_EDGE = 0.015 # a face box this close to a side of the camera frame is cut by it
91
+ INNER_CUT_WARN = 0.4 # share of the person time with a body cut inside the screen that gets advice
92
+ MAX_SCALE = 2.0 # never enlarge the camera frame more than this
93
+ SIDE_MARGIN = 40 # px (1080 wide) between a person that is not cut by the camera and the edge
94
+ EDGE_FADE = 28 # px (1080 wide) of fade where the top of the camera frame cuts the body
95
+ SIDE_FADE = 90 # px (1080 wide) of fade where a side of the camera frame cuts the body inside
96
+ # the screen (the arm or shoulder dissolves instead of ending in a straight line)
97
+ TOUCH_FLUSH = 0.2 # share of frames touching a side for that side to sit on the screen edge
98
+ OUTLINE_DEFAULT = {"cor": "#FFFFFF", "largura": 8}
99
+ OUTLINE_MAX = 40
100
+ SHADOW = {"alpha": 0.5, "blur": 10.0, "dx": 6.0, "dy": 12.0} # px on 1080 wide
101
+ DARK_DEFAULT = "#101010"
102
+ DARKEN_DEFAULT = 0.15
103
+ DARKEN_MAX = 0.9
104
+ BLUR_MAX = 80.0
105
+ ORIGINAL_BLUR = {"sigma": 40.0, "darken": 0.35}
106
+ BACKDROP = {"sigma": 70.0, "darken": 0.55, "saturation": 0.55, "feather": 36.0} # behind a picture shown whole
107
+ KB_DRIFT = 0.06
108
+ KB_HOLD_AFTER = 2.0 # a held gap stretches the Ken Burns to at most this many times the item's time
109
+ FIT_RATIO = 1.25 # auto: a shape this close to the screen's (or taller) fills it, else it is whole
110
+ FITS = ("auto", "cobrir", "inteiro")
111
+ FIT_ALIASES = {"auto": "auto", "automatico": "auto", "automático": "auto", "cobrir": "cobrir", "cobre": "cobrir",
112
+ "cover": "cobrir", "preencher": "cobrir", "encher": "cobrir", "fill": "cobrir", "inteiro": "inteiro",
113
+ "inteira": "inteiro", "contain": "inteiro", "whole": "inteiro", "fit": "inteiro"}
114
+ STAGE_TOP = 120 / 1920 # the stage for a whole picture starts this far down (the app bar sits above it)
115
+ STAGE_GAP = 0.012 # share of the height between a whole picture and the head (and the captions)
116
+ WHOLE_MIN = 0.5 # a whole picture shrinks to fit the stage down to this share of the width
117
+ RESERVE_KEEP = 0.75 # the stage leaves room for captions when the picture keeps this share of its size
118
+ LOOP_FADE = 0.4 # s of crossfade where a background video loops
119
+ SCAN_VERSION = "scan3" # bump when scan_video changes (its cache is redone)
120
+ SCAN_SIDE = 160 # px of the long side the videos are scanned at
121
+ SCAN_DETAIL = 24 # 0-255: a pixel this far from its blurred surroundings is detail
122
+ SCAN_EMPTY = (0.003, 0.15, 0.5) # a frame with less detail than max(abs, share of the clip's median) is empty;
123
+ # next to an empty first or last frame, the ones under the last share still
124
+ # build up or fade out (they count as empty too)
125
+ SCAN_FADE = (0.02, 0.65) # a clip that ends empty after a steady fall (noise allowed: this share of the
126
+ # median) holds the last frame that still has this share of the fall's peak
127
+ SCAN_ROW = 0.01 # share of detail (over the clip, and 10% of the busiest) a row or column
128
+ # needs to be main area
129
+ SCAN_MASS = 0.85 # ...and the main rows are the band that holds this share of the detail (a thin
130
+ # cable or a glow at the edge of the clip is not its main area)
131
+ QUIET_MIN = 0.75 # a plain-border video shrinks down to this share of the width (its border is
132
+ # carried out, so it has no box) to leave the captions a band above the head
133
+ QUIET_RING = (0.06, 0.04) # top and bottom bands (share of the height) with less detail: a plain border
134
+ EXTEND = {"blur": 48.0, "fall": 320.0, "darken": 0.3} # px on 1080 wide: the plain border carried to the screen
135
+ PLATE_VERSION = "placa1" # bump when the room plate changes (its cache is redone)
136
+ PLATE_ENV = "EDITOR_VIDEO_TELA_VERDE_PLACA" # =0 turns the room plate off (to compare)
137
+ PLATE = {
138
+ "step": 2, # every other frame of a take feeds its plate
139
+ "bg": 8, # matte (0-255) under this, `fringe` px away from the body, is the room
140
+ "fringe": 4,
141
+ "n": [2.0, 8.0], # frames that saw the room there: none trusted .. fully trusted
142
+ "std": [6.0, 12.0], # spread of the room color (noise, a 1 px drift, a screen): trusted .. not
143
+ "static": 6.0, # median spread above this: the camera moved, no plate
144
+ "known": 0.08, # share of the frame the plate must know
145
+ "blur": 0.7, "win": 5, # px (plate size): blur of the frame and the plate, window of the comparison
146
+ "tol": 4.0, "width": 8.0, # color distance (0-255) that still is the room (plus the spread there)
147
+ "unsure": [0.8, 0.98], # matte values the model was not sure of: only there the room goes
148
+ "near": 8.0, "unlike": [18.0, 32.0], # the body color nearby (px), how far a dark room color must be from it
149
+ "bright": [45.0, 70.0], # a room brighter than this needs no such distance
150
+ "halo": [30.0, 50.0], # a room this much brighter than the body next to it is a halo (per pixel)
151
+ "on": 0.5, # evidence that counts
152
+ "unsure_px": 7, "round_px": 5, "soft": 1.5, # narrowest piece (px), rounding of the pieces, blur of their
153
+ # edge
154
+ "edge": [8, 247], "edge_floor": 0.35, # soft edge pixels (0-255) whose color is cleaned of the room
155
+ }
156
+ FIELDS = ("posicao", "altura", "contorno", "sombra", "fundo", "fundo_original_desfocado", "sem_rosto", "espelhar")
157
+ BG_FIELDS = ("src", "start", "end", "ken_burns", "escurecer", "desfocar", "encaixe")
158
+ IMAGE_EXT = {".jpg", ".jpeg", ".png", ".webp", ".bmp", ".tif", ".tiff"}
159
+ VIDEO_EXT = {".mp4", ".mov", ".m4v", ".mkv", ".webm", ".avi", ".gif", ".mts"}
160
+ EXAMPLE = '"tela_verde": {"posicao": "direita", "fundo": [{"src": "assets/pagina.png"}]}'
161
+ ALIASES = {"position": "posicao", "posição": "posicao", "height": "altura", "outline": "contorno", "shadow": "sombra",
162
+ "background": "fundo", "backgrounds": "fundo", "fundos": "fundo", "blur": "desfocar", "desfoque": "desfocar",
163
+ "darken": "escurecer", "dim": "escurecer", "inicio": "start", "início": "start", "fim": "end",
164
+ "kenburns": "ken_burns", "arquivo": "src", "file": "src", "cor": "src", "color": "src",
165
+ "fit": "encaixe", "encaixar": "encaixe", "ajuste": "encaixe", "modo": "encaixe",
166
+ "mirror": "espelhar", "flip": "espelhar", "espelho": "espelhar", "espelhado": "espelhar",
167
+ "inverter": "espelhar"}
168
+ SPLIT_LABEL = {"split_top": "imagem em cima", "split_bottom": "imagem embaixo"}
169
+
170
+
171
+ def _hint(name: str, fields) -> str:
172
+ near = ALIASES.get(str(name).strip().lower())
173
+ if near not in fields:
174
+ import difflib # noqa: PLC0415
175
+ close = difflib.get_close_matches(str(name), list(fields), n=1, cutoff=0.6)
176
+ near = close[0] if close else None
177
+ return f" Você quis dizer \"{near}\"?" if near else ""
178
+
179
+
180
+ class TelaVerdeError(Exception):
181
+ def __init__(self, message: str, code: int = 2):
182
+ super().__init__(message)
183
+ self.code = code
184
+
185
+
186
+ def _cv2():
187
+ return M._cv2()
188
+
189
+
190
+ def hex_rgb(value) -> tuple[int, int, int] | None:
191
+ if not isinstance(value, str):
192
+ return None
193
+ h = value.strip().lstrip("#")
194
+ if len(h) == 3:
195
+ h = "".join(c * 2 for c in h)
196
+ if len(h) != 6 or not value.strip().startswith("#"):
197
+ return None
198
+ try:
199
+ return int(h[0:2], 16), int(h[2:4], 16), int(h[4:6], 16)
200
+ except ValueError:
201
+ return None
202
+
203
+
204
+ def _num(value) -> float | None:
205
+ if isinstance(value, bool):
206
+ return None
207
+ try:
208
+ v = float(value)
209
+ except (TypeError, ValueError):
210
+ return None
211
+ return v if math.isfinite(v) else None
212
+
213
+
214
+ def _json(value) -> str:
215
+ try:
216
+ return json.dumps(value, ensure_ascii=False)
217
+ except (TypeError, ValueError):
218
+ return repr(value)
219
+
220
+
221
+ def ease(p: float) -> float:
222
+ p = min(1.0, max(0.0, p))
223
+ return 0.5 - 0.5 * math.cos(math.pi * p)
224
+
225
+
226
+ # ================================================================== config
227
+
228
+ @dataclass
229
+ class Background:
230
+ idx: int # 1-based position in "fundo" (or in "inserts" when insert is set)
231
+ kind: str # color | image | video
232
+ src: Path | None
233
+ color: tuple[int, int, int] | None
234
+ f0: int
235
+ f1: int # as asked (a gap after it is still this item, see runs)
236
+ ken_burns: bool
237
+ darken: float
238
+ blur: float
239
+ asked: str = ""
240
+ fit_asked: str = "auto"
241
+ fit: str = "cobrir" # cobrir | inteiro (resolved)
242
+ size: tuple[int, int] | None = None # the source's width and height (upright)
243
+ insert: int | None = None # 1-based insert number: a split_top / split_bottom insert shown as background
244
+ insert_type: str | None = None
245
+ content: tuple[float, float, float, float] = (0.0, 0.0, 1.0, 1.0) # main area of the picture (fractions)
246
+ place: tuple[int, int, int, int] | None = None # inteiro: x, y, w, h of the sharp copy (render px)
247
+ stage: tuple[float, float] | None = None # inteiro: the band it was fitted into (render px)
248
+ quiet: bool = False # video with a plain border (a studio, a flat color): it melts into the screen
249
+ lead: int = 0 # video: empty frames at the start of a pass (the first full one shows instead)
250
+ tail: int = 0 # video: empty frames at the end of a pass (the last full one shows instead)
251
+ pass_frames: int = 0 # video: frames of one pass at the edit fps (0 = not scanned)
252
+
253
+ @property
254
+ def name(self) -> str:
255
+ return f"insert {self.insert}" if self.insert else f"fundo {self.idx}"
256
+
257
+ @property
258
+ def where(self) -> str:
259
+ return f"inserts[{self.idx - 1}]" if self.insert else f"tela_verde.fundo[{self.idx - 1}]"
260
+
261
+ def content_rect(self, W: int, H: int) -> tuple[float, float, float, float] | None:
262
+ """Where the main area of a whole picture lands (render px), grown by the Ken Burns zoom."""
263
+ if self.fit != "inteiro" or self.place is None:
264
+ return None
265
+ x, y, w, h = self.place
266
+ c = self.content
267
+ x0, y0, x1, y1 = x + c[0] * w, y + c[1] * h, x + c[2] * w, y + c[3] * h
268
+ if self.ken_burns:
269
+ g = KB_DRIFT / 2.0
270
+ dx, dy = (x1 - x0) * g, (y1 - y0) * g
271
+ x0, y0, x1, y1 = x0 - dx, y0 - dy, x1 + dx, y1 + dy
272
+ return (max(0.0, x0), max(0.0, y0), min(float(W), x1), min(float(H), y1))
273
+
274
+ def signature(self) -> list:
275
+ sig = None
276
+ if self.src is not None:
277
+ st = self.src.stat()
278
+ sig = [str(self.src), st.st_size, st.st_mtime_ns]
279
+ return [self.idx, self.insert, self.kind, sig, self.color, self.f0, self.f1, self.ken_burns,
280
+ round(self.darken, 4), round(self.blur, 3), self.fit, self.size,
281
+ [round(v, 4) for v in self.content], self.place, self.quiet, self.lead, self.tail,
282
+ self.pass_frames]
283
+
284
+
285
+ def media_size(path: Path | None, kind: str) -> tuple[int, int] | None:
286
+ """Upright width and height of an image or video (None when it cannot be read)."""
287
+ if path is None or kind == "color":
288
+ return None
289
+ try:
290
+ if kind == "image":
291
+ from PIL import Image # noqa: PLC0415
292
+ with Image.open(path) as im:
293
+ w, h = im.size
294
+ try:
295
+ orient = im.getexif().get(274, 1)
296
+ except Exception: # noqa: BLE001
297
+ orient = 1
298
+ return (h, w) if orient in (5, 6, 7, 8) else (w, h)
299
+ proc = subprocess.run(["ffprobe", "-v", "error", "-select_streams", "v:0", "-show_entries",
300
+ "stream=width,height:stream_side_data=rotation", "-of", "json", str(path)],
301
+ capture_output=True, text=True)
302
+ st = (json.loads(proc.stdout or "{}").get("streams") or [{}])[0]
303
+ w, h = int(st.get("width") or 0), int(st.get("height") or 0)
304
+ rot = 0
305
+ for sd in st.get("side_data_list") or []:
306
+ if "rotation" in sd:
307
+ rot = abs(int(round(float(sd["rotation"])))) % 180
308
+ if w <= 0 or h <= 0:
309
+ return None
310
+ return (h, w) if rot == 90 else (w, h)
311
+ except (OSError, ValueError, TypeError, IndexError):
312
+ return None
313
+
314
+
315
+ def choose_fit(asked: str, kind: str, size: tuple[int, int] | None, screen: tuple[int, int]) -> str:
316
+ """encaixe "auto": cobrir when the shape is close to the screen's (a page taller than a vertical screen too),
317
+ inteiro otherwise (a wide print, a square clip)."""
318
+ if kind == "color":
319
+ return "cobrir"
320
+ if asked in ("cobrir", "inteiro"):
321
+ return asked
322
+ if not size:
323
+ return "cobrir"
324
+ ar, car = size[0] / max(1, size[1]), screen[0] / max(1, screen[1])
325
+ if car / FIT_RATIO <= ar <= car * FIT_RATIO:
326
+ return "cobrir"
327
+ if ar < car / FIT_RATIO and car < 1.0:
328
+ return "cobrir" # a long page on a vertical screen: fill it and keep the top
329
+ return "inteiro"
330
+
331
+
332
+ def _fit_value(value, where: str) -> str:
333
+ if value is None:
334
+ return "auto"
335
+ fit = FIT_ALIASES.get(value.strip().lower()) if isinstance(value, str) else None
336
+ if fit is None:
337
+ raise TelaVerdeError(f"{where}.encaixe precisa ser \"auto\", \"cobrir\" (enche a tela, cortando) ou \"inteiro\" "
338
+ f"(aparece inteiro, acima de você) (veio {_json(value)})")
339
+ return fit
340
+
341
+
342
+ def _look_values(item: dict, where: str, kind: str, fit: str, warnings: list[str]) -> tuple[float, float]:
343
+ """escurecer and desfocar of a background item (the default darkening is 0 for a whole picture and a color)."""
344
+ default = 0.0 if (kind == "color" or fit == "inteiro") else DARKEN_DEFAULT
345
+ darken = item.get("escurecer", default)
346
+ dk = _num(darken)
347
+ if dk is None or dk < 0 or dk > 1:
348
+ raise TelaVerdeError(f"{where}.escurecer precisa ser um número de 0 a {DARKEN_MAX} (0 = como está; veio "
349
+ f"{_json(darken)})")
350
+ if dk > DARKEN_MAX:
351
+ warnings.append(f"{where}.escurecer {dk:g} ficou em {DARKEN_MAX}")
352
+ dk = DARKEN_MAX
353
+ blur = _num(item.get("desfocar", 0))
354
+ if blur is None or blur < 0:
355
+ raise TelaVerdeError(f"{where}.desfocar precisa ser um número de 0 a {BLUR_MAX:g} (pixels de desfoque; veio "
356
+ f"{_json(item.get('desfocar'))})")
357
+ return dk, min(BLUR_MAX, blur)
358
+
359
+
360
+ @dataclass
361
+ class Config:
362
+ position: str
363
+ height: float
364
+ height_asked: bool
365
+ outline: tuple[tuple[int, int, int], float] | None # (rgb, width px on 1080 wide)
366
+ shadow: bool
367
+ backgrounds: list[Background]
368
+ blurred_original: bool
369
+ no_face: str # original | recorte
370
+ mirror: bool = False # espelhar: the person segments flipped left-right
371
+ runs: list[tuple[int, int, int | None]] = field(default_factory=list) # (f0, f1, background index or None)
372
+ warnings: list[str] = field(default_factory=list)
373
+ run_notes: list[str] = field(default_factory=list) # the warnings about the runs (also in warnings)
374
+ screen: tuple[int, int] = (1080, 1920)
375
+
376
+ @property
377
+ def from_inserts(self) -> list[Background]:
378
+ return [b for b in self.backgrounds if b.insert]
379
+
380
+
381
+ def parse_config(section, fps: int, nf: int, resolve: Callable[[str, str], tuple[Path, str]],
382
+ fr: Callable[[float], int], screen: tuple[int, int] = (1080, 1920),
383
+ probe: Callable[[Path | None, str], tuple[int, int] | None] = media_size) -> Config:
384
+ """Check "tela_verde" (exit 2 on a mistake, before anything renders)."""
385
+ if section is None:
386
+ section = {}
387
+ if not isinstance(section, dict):
388
+ raise TelaVerdeError(f"tela_verde precisa ser um objeto (ex: {EXAMPLE})")
389
+ unknown = [k for k in section if k not in FIELDS]
390
+ if unknown:
391
+ raise TelaVerdeError(f"tela_verde.{unknown[0]} não existe.{_hint(unknown[0], FIELDS)} Campos: {', '.join(FIELDS)}")
392
+ warnings: list[str] = []
393
+ raw_pos = section.get("posicao", "direita")
394
+ key = raw_pos.strip().lower() if isinstance(raw_pos, str) else None
395
+ position = POSITION_ALIASES.get(key or "")
396
+ if position is None:
397
+ raise TelaVerdeError(f"tela_verde.posicao precisa ser \"direita\", \"esquerda\" ou \"centro\" (veio {_json(raw_pos)})")
398
+ height_asked = section.get("altura") is not None
399
+ height = DEFAULT_HEIGHT[position]
400
+ if height_asked:
401
+ h = _num(section.get("altura"))
402
+ if h is None or h <= 0:
403
+ raise TelaVerdeError(f"tela_verde.altura precisa ser a fração da altura da tela que a pessoa ocupa, de "
404
+ f"{HEIGHT_RANGE[0]} a {HEIGHT_RANGE[1]} (ex: 0.55; veio {_json(section.get('altura'))})")
405
+ if h > 1.0 and h <= 100:
406
+ h = h / 100.0 if h > 1.5 else 1.0 # "55" read as percent
407
+ if not HEIGHT_RANGE[0] <= h <= HEIGHT_RANGE[1]:
408
+ clipped = min(HEIGHT_RANGE[1], max(HEIGHT_RANGE[0], h))
409
+ warnings.append(f"tela_verde.altura {h:g} ficou em {clipped:g} (use de {HEIGHT_RANGE[0]} a {HEIGHT_RANGE[1]})")
410
+ h = clipped
411
+ height = h
412
+ outline_raw = section.get("contorno", OUTLINE_DEFAULT)
413
+ outline = None
414
+ if outline_raw is True:
415
+ outline_raw = OUTLINE_DEFAULT
416
+ if outline_raw not in (None, False):
417
+ if not isinstance(outline_raw, dict) or set(outline_raw) - {"cor", "largura"}:
418
+ raise TelaVerdeError(f"tela_verde.contorno precisa ser {{\"cor\": \"#FFFFFF\", \"largura\": 8}} ou null "
419
+ f"(veio {_json(outline_raw)})")
420
+ color = hex_rgb(outline_raw.get("cor", OUTLINE_DEFAULT["cor"]))
421
+ if color is None:
422
+ raise TelaVerdeError(f"tela_verde.contorno.cor precisa ser uma cor #RRGGBB (veio {_json(outline_raw.get('cor'))})")
423
+ width = _num(outline_raw.get("largura", OUTLINE_DEFAULT["largura"]))
424
+ if width is None or width < 0:
425
+ raise TelaVerdeError(f"tela_verde.contorno.largura precisa ser um número de pixels, de 0 a {OUTLINE_MAX} "
426
+ f"(veio {_json(outline_raw.get('largura'))})")
427
+ if width > OUTLINE_MAX:
428
+ warnings.append(f"tela_verde.contorno.largura {width:g} ficou em {OUTLINE_MAX}")
429
+ width = float(OUTLINE_MAX)
430
+ outline = (color, float(width)) if width > 0 else None
431
+ shadow = section.get("sombra", True)
432
+ if not isinstance(shadow, bool):
433
+ raise TelaVerdeError(f"tela_verde.sombra precisa ser true ou false (veio {_json(shadow)})")
434
+ blurred = section.get("fundo_original_desfocado", False)
435
+ if not isinstance(blurred, bool):
436
+ raise TelaVerdeError(f"tela_verde.fundo_original_desfocado precisa ser true ou false (veio {_json(blurred)})")
437
+ no_face = section.get("sem_rosto", "original")
438
+ if no_face not in ("original", "recorte"):
439
+ raise TelaVerdeError(f"tela_verde.sem_rosto precisa ser \"original\" (o trecho sem rosto aparece como foi "
440
+ f"gravado) ou \"recorte\" (veio {_json(no_face)})")
441
+ mirror = section.get("espelhar", False)
442
+ if not isinstance(mirror, bool):
443
+ raise TelaVerdeError(f"tela_verde.espelhar precisa ser true ou false (true vira você de lado, como num "
444
+ f"espelho; veio {_json(mirror)})")
445
+ fundo = section.get("fundo")
446
+ if fundo is None:
447
+ fundo = []
448
+ if isinstance(fundo, (str, dict)):
449
+ fundo = [fundo]
450
+ if not isinstance(fundo, list):
451
+ raise TelaVerdeError("tela_verde.fundo precisa ser uma lista (ex: [{\"src\": \"assets/pagina.png\", \"start\": 0, "
452
+ "\"end\": 8}])")
453
+ items: list[Background] = []
454
+ prev_end = 0.0
455
+ for i, item in enumerate(fundo):
456
+ where = f"tela_verde.fundo[{i}]"
457
+ if isinstance(item, str):
458
+ item = {"src": item}
459
+ if not isinstance(item, dict):
460
+ raise TelaVerdeError(f"{where} precisa ser um objeto (ex: {{\"src\": \"assets/pagina.png\", \"start\": 0}})")
461
+ bad = [k for k in item if k not in BG_FIELDS]
462
+ if bad:
463
+ raise TelaVerdeError(f"{where}.{bad[0]} não existe.{_hint(bad[0], BG_FIELDS)} Campos: {', '.join(BG_FIELDS)}")
464
+ src = item.get("src")
465
+ if not isinstance(src, str) or not src.strip():
466
+ raise TelaVerdeError(f"{where} precisa de \"src\": um arquivo (\"assets/foto.png\", \"assets/clipe.mp4\") "
467
+ f"ou uma cor (\"#101010\")")
468
+ color = hex_rgb(src) if src.strip().startswith("#") else None
469
+ if src.strip().startswith("#") and color is None:
470
+ raise TelaVerdeError(f"{where}.src: cor inválida {_json(src)} (use #RRGGBB)")
471
+ path, kind = (None, "color") if color else resolve(src, f"{where}.src")
472
+ start = item.get("start")
473
+ if start is None:
474
+ t0 = prev_end
475
+ else:
476
+ t0 = _num(start)
477
+ if t0 is None or t0 < 0:
478
+ raise TelaVerdeError(f"{where}.start precisa ser um número de segundos, 0 ou mais (veio {_json(start)})")
479
+ end = item.get("end")
480
+ t1 = None
481
+ if end is not None:
482
+ t1 = _num(end)
483
+ if t1 is None or t1 <= t0:
484
+ raise TelaVerdeError(f"{where}.end precisa ser um número de segundos maior que o start ({t0:g}) "
485
+ f"(veio {_json(end)})")
486
+ kb = item.get("ken_burns", kind == "image")
487
+ if not isinstance(kb, bool):
488
+ raise TelaVerdeError(f"{where}.ken_burns precisa ser true ou false (veio {_json(kb)})")
489
+ fit_asked = _fit_value(item.get("encaixe"), where)
490
+ size = probe(path, kind) if kind != "color" else None
491
+ fit = choose_fit(fit_asked, kind, size, screen)
492
+ dk, blur = _look_values(item, where, kind, fit, warnings)
493
+ f0 = fr(t0)
494
+ f1 = fr(t1) if t1 is not None else None
495
+ if f0 >= nf:
496
+ warnings.append(f"{where} começa depois do fim do vídeo e foi ignorado")
497
+ prev_end = t1 if t1 is not None else t0
498
+ continue
499
+ items.append(Background(idx=i + 1, kind=kind, src=path, color=color, f0=f0, f1=f1 if f1 is not None else -1,
500
+ ken_burns=kb and kind != "color", darken=dk, blur=blur, asked=src.strip(),
501
+ fit_asked=fit_asked, fit=fit, size=size))
502
+ prev_end = t1 if t1 is not None else t0
503
+ # an item without "end" lasts until the next item starts (or the end of the video)
504
+ for k, b in enumerate(items):
505
+ if b.f1 < 0:
506
+ later = [o.f0 for o in items[k + 1:] if o.f0 > b.f0]
507
+ b.f1 = min(later) if later else nf
508
+ b.f1 = min(nf, b.f1)
509
+ if b.f1 - b.f0 < 1:
510
+ warnings.append(f"tela_verde.fundo[{b.idx - 1}] dura menos de 1 quadro e foi ignorado")
511
+ items = [b for b in items if b.f1 > b.f0]
512
+ for k, a in enumerate(items):
513
+ for b in items[k + 1:]:
514
+ if b.f0 < a.f1 and a.f0 < b.f1:
515
+ warnings.append(f"os fundos {a.idx} e {b.idx} se sobrepõem: o {b.idx}, que vem depois na lista, fica por cima")
516
+ cfg = Config(position=position, height=height, height_asked=height_asked, outline=outline, shadow=shadow,
517
+ backgrounds=items, blurred_original=blurred, no_face=no_face, mirror=mirror, warnings=warnings,
518
+ screen=screen)
519
+ set_runs(cfg, nf, fps)
520
+ return cfg
521
+
522
+
523
+ def set_runs(cfg: Config, nf: int, fps: int) -> None:
524
+ """(Re)compute the runs and their warnings (after the split inserts joined the track)."""
525
+ for note in cfg.run_notes:
526
+ if note in cfg.warnings:
527
+ cfg.warnings.remove(note)
528
+ notes: list[str] = []
529
+ items = cfg.backgrounds
530
+ cfg.runs = background_runs(items, nf)
531
+ if not items and not cfg.blurred_original:
532
+ notes.append("tela_verde sem fundo: usei um fundo escuro. Planeje as imagens e vídeos como inserts "
533
+ "(split_top), que eles vão para o fundo, ou use tela_verde.fundo (ou "
534
+ "\"fundo_original_desfocado\": true)")
535
+ elif items and cfg.runs and cfg.runs[0][2] is None:
536
+ what = "o vídeo original desfocado" if cfg.blurred_original else "um fundo escuro"
537
+ notes.append(f"o primeiro fundo começa em {cfg.runs[0][1] / fps:.2f}s: antes dele entra {what}")
538
+ cfg.run_notes = notes
539
+ cfg.warnings.extend(notes)
540
+
541
+
542
+ def add_insert_backgrounds(cfg: Config, inserts: list[dict], nf: int, fps: int,
543
+ probe: Callable[[Path | None, str], tuple[int, int] | None] = media_size) -> list[Background]:
544
+ """split_top / split_bottom inserts -> background items that win over tela_verde.fundo during their own frames.
545
+
546
+ inserts: [{"insert": 1-based number, "type", "src" (Path), "kind", "f0", "f1", "ken_burns", "item" (the raw
547
+ dict, for escurecer / desfocar / encaixe)}]."""
548
+ added = []
549
+ for it in inserts:
550
+ where = f"inserts[{it['insert'] - 1}]"
551
+ raw = it.get("item") or {}
552
+ fit_asked = _fit_value(raw.get("encaixe"), where)
553
+ size = probe(it["src"], it["kind"])
554
+ fit = choose_fit(fit_asked, it["kind"], size, cfg.screen)
555
+ dk, blur = _look_values(raw, where, it["kind"], fit, cfg.warnings)
556
+ f0, f1 = int(it["f0"]), min(nf, int(it["f1"]))
557
+ if f1 <= f0:
558
+ continue
559
+ b = Background(idx=it["insert"], kind=it["kind"], src=it["src"], color=None, f0=f0, f1=f1,
560
+ ken_burns=bool(it["ken_burns"]), darken=dk, blur=blur, asked=str(raw.get("src") or it["src"]),
561
+ fit_asked=fit_asked, fit=fit, size=size, insert=it["insert"], insert_type=it["type"])
562
+ added.append(b)
563
+ # the fundo items first: an insert (drawn later in the list) wins during its own frames
564
+ cfg.backgrounds = [b for b in cfg.backgrounds if not b.insert] + added
565
+ set_runs(cfg, nf, fps)
566
+ return added
567
+
568
+
569
+ def background_runs(items: list[Background], nf: int) -> list[tuple[int, int, int | None]]:
570
+ """(f0, f1, index in items or None): a later item wins; a gap keeps the item before it."""
571
+ owner = np.full(nf, -1, np.int32)
572
+ for k, b in enumerate(items):
573
+ owner[b.f0:b.f1] = k
574
+ last = -1
575
+ for f in range(nf): # gaps: hold the previous background
576
+ if owner[f] < 0:
577
+ owner[f] = last
578
+ else:
579
+ last = owner[f]
580
+ runs: list[tuple[int, int, int | None]] = []
581
+ start = 0
582
+ for f in range(1, nf + 1):
583
+ if f == nf or owner[f] != owner[start]:
584
+ runs.append((start, f, int(owner[start]) if owner[start] >= 0 else None))
585
+ start = f
586
+ return runs
587
+
588
+
589
+ # ================================================================== placement
590
+
591
+ @dataclass
592
+ class Segment:
593
+ f0: int
594
+ f1: int
595
+ mode: str # pessoa | original
596
+ zoom: float = 1.0
597
+ zoom_max: float = 1.0
598
+ push: float = 0.0 # push_in amount (0 = none)
599
+ focus_y: float = 0.4 # eye line of the cut frame (zoom anchor of an original segment)
600
+ stats: dict | None = None
601
+ scale: float = 1.0 # camera frame -> screen, before the zoom
602
+ flush_left: bool = False
603
+ flush_right: bool = False
604
+ face: dict | None = None # face_layout face / zone / core (fractions of the cut frame)
605
+ side: str | None = None # esquerda | direita: this segment sits on that side (its face is cut there)
606
+ fill: bool = False # corner, cut on both sides: the camera frame fills the screen width
607
+ drop: float = 0.0 # fill: share of the camera frame below the bottom of the screen
608
+ note: str = ""
609
+
610
+ @property
611
+ def frames(self) -> int:
612
+ return self.f1 - self.f0
613
+
614
+ def zoom_at(self, f: int) -> float:
615
+ if self.push <= 0 or self.frames <= 1:
616
+ return self.zoom
617
+ return self.zoom * (1.0 + self.push * ease((f - self.f0) / (self.frames - 1)))
618
+
619
+
620
+ def base_scale(cfg: Config, st: dict) -> float:
621
+ top = float(st.get("top", 0.0))
622
+ s = cfg.height / max(0.05, 1.0 - top)
623
+ width = max(0.05, float(st.get("right", 1.0)) - float(st.get("left", 0.0)))
624
+ s = min(s, (CENTER_MAX_WIDTH if cfg.position == "centro" else CORNER_MAX_WIDTH) / width)
625
+ if (cfg.position == "centro" and float(st.get("touch_left", 0)) >= TOUCH_FLUSH
626
+ and float(st.get("touch_right", 0)) >= TOUCH_FLUSH and s < 1.0 and s * CENTER_FILL >= 1.0):
627
+ s = 1.0 # cut on both sides: fill the width instead of showing two cut edges
628
+ return min(MAX_SCALE, max(0.05, s))
629
+
630
+
631
+ def mirror_stats(st: dict | None) -> dict | None:
632
+ """Placement numbers of a flipped camera frame."""
633
+ if not st or not st.get("person_frames"):
634
+ return st
635
+ out = dict(st)
636
+ out["left"] = round(1.0 - float(st.get("right", 1.0)), 5)
637
+ out["right"] = round(1.0 - float(st.get("left", 0.0)), 5)
638
+ out["head_cx"] = round(1.0 - float(st.get("head_cx", 0.5)), 5)
639
+ out["touch_left"], out["touch_right"] = st.get("touch_right", 0), st.get("touch_left", 0)
640
+ return out
641
+
642
+
643
+ def mirror_face(face: dict | None) -> dict | None:
644
+ """face_layout segment (face / zone / core boxes) of a flipped camera frame."""
645
+ if not isinstance(face, dict):
646
+ return face
647
+ out = dict(face)
648
+ for key in ("face", "zone", "core"):
649
+ r = face.get(key)
650
+ if isinstance(r, dict) and "x0" in r and "x1" in r:
651
+ out[key] = {**r, "x0": round(1.0 - float(r["x1"]), 5), "x1": round(1.0 - float(r["x0"]), 5)}
652
+ return out
653
+
654
+
655
+ def face_cut_sides(seg: "Segment") -> tuple[bool, bool]:
656
+ """(left, right): the detected face touches that side of the camera frame."""
657
+ f = seg.face.get("face") if isinstance(seg.face, dict) else None
658
+ if not isinstance(f, dict):
659
+ return False, False
660
+ try:
661
+ return float(f.get("x0", 0.5)) <= FACE_EDGE, float(f.get("x1", 0.5)) >= 1.0 - FACE_EDGE
662
+ except (TypeError, ValueError):
663
+ return False, False
664
+
665
+
666
+ def plan_segments(cfg: Config, segs: list[Segment]) -> None:
667
+ """Scale and flush sides per person segment; segments close to the typical size share it (no jump at a cut
668
+ of the same take)."""
669
+ people = [s for s in segs if s.mode == "pessoa" and s.stats and s.stats.get("person_frames")]
670
+ for s in people:
671
+ st = s.stats
672
+ s.scale = base_scale(cfg, st)
673
+ cut_l, cut_r = face_cut_sides(s)
674
+ s.flush_left = float(st.get("touch_left", 0)) >= TOUCH_FLUSH or cut_l
675
+ s.flush_right = float(st.get("touch_right", 0)) >= TOUCH_FLUSH or cut_r
676
+ s.side = None
677
+ if cut_l != cut_r:
678
+ side = "esquerda" if cut_l else "direita"
679
+ if side != cfg.position:
680
+ s.side = side
681
+ s.note = (f"o rosto sai da câmera pela {side}: aqui você fica encostado na {side} "
682
+ f"da tela, para o rosto não aparecer cortado no meio")
683
+ if people:
684
+ weights = sorted((s.scale, s.frames) for s in people)
685
+ half, acc, med = sum(w for _, w in weights) / 2.0, 0.0, weights[-1][0]
686
+ for v, w in weights:
687
+ acc += w
688
+ if acc >= half:
689
+ med = v
690
+ break
691
+ for s in people:
692
+ if abs(s.scale - med) <= 0.08 * med:
693
+ s.scale = med
694
+ if cfg.position != "centro":
695
+ # cut on both sides: in a corner one of the cuts would land in the middle of the screen (an arm or a
696
+ # shoulder ending on a vertical line), so the person fills the width instead, a little lower
697
+ for s in people:
698
+ st = s.stats
699
+ if min(float(st.get("touch_left", 0)), float(st.get("touch_right", 0))) < TOUCH_FLUSH:
700
+ continue
701
+ corner = s.scale
702
+ if corner >= 1.0 or 1.0 / corner > CORNER_FILL:
703
+ continue
704
+ top = float(st.get("top", 0.0))
705
+ s.fill, s.scale = True, 1.0
706
+ s.flush_left = s.flush_right = True
707
+ s.drop = round(min(FILL_DROP, max(0.0, (1.0 - top) * (1.0 - corner))), 5)
708
+ fallback = people[0] if people else None
709
+ for s in segs:
710
+ if s.mode == "pessoa" and s not in people:
711
+ s.note = "não achei a pessoa neste trecho: só o fundo aparece"
712
+ if fallback is not None:
713
+ s.scale = fallback.scale
714
+
715
+
716
+ def layer_rect(cfg: Config, seg: Segment, z: float, W: int, H: int) -> tuple[float, float, float]:
717
+ """(scale, x0, y0) of the camera frame on the screen for zoom z (float px)."""
718
+ st = seg.stats or {}
719
+ s = seg.scale * z
720
+ lw, lh = W * s, H * s
721
+ y0 = H - lh
722
+ margin = SIDE_MARGIN * W / 1080.0
723
+ pos = seg.side or cfg.position
724
+ if seg.fill:
725
+ # both cut edges on or past the screen edges; the extra width goes to the side away from the corner
726
+ return s, (0.0 if pos == "direita" else W - lw), H - lh + seg.drop * lh
727
+ if pos == "direita":
728
+ x0 = W - lw if seg.flush_right else W - margin - float(st.get("right", 1.0)) * lw
729
+ elif pos == "esquerda":
730
+ x0 = 0.0 if seg.flush_left else margin - float(st.get("left", 0.0)) * lw
731
+ else:
732
+ x0 = W / 2.0 - float(st.get("head_cx", 0.5)) * lw
733
+ # a side the camera cut stays on (or past) the screen edge when the frame allows it
734
+ if seg.flush_left and seg.flush_right:
735
+ if lw >= W:
736
+ x0 = min(0.0, max(W - lw, x0))
737
+ elif seg.flush_left:
738
+ x0 = min(x0, 0.0)
739
+ elif seg.flush_right:
740
+ x0 = max(x0, W - lw)
741
+ return s, x0, y0
742
+
743
+
744
+ def inner_cut(cfg: Config, segs: list[Segment], W: int, H: int) -> dict | None:
745
+ """Corner positions: how much of the person time shows the body cut by the camera inside the screen, and
746
+ what would look more natural. None when it is little (or centro)."""
747
+ if cfg.position == "centro":
748
+ return None
749
+ total = inner = both = 0.0
750
+ for s in segs:
751
+ if s.mode != "pessoa" or not s.stats or not s.stats.get("person_frames"):
752
+ continue
753
+ st = s.stats
754
+ sc, x0, _ = layer_rect(cfg, s, s.zoom, W, H)
755
+ lw = W * sc
756
+ pos = s.side or cfg.position
757
+ if pos == "direita":
758
+ t_in = float(st.get("touch_left", 0)) if x0 > 0.5 else 0.0
759
+ t_out = float(st.get("touch_right", 0))
760
+ else:
761
+ t_in = float(st.get("touch_right", 0)) if x0 + lw < W - 0.5 else 0.0
762
+ t_out = float(st.get("touch_left", 0))
763
+ total += s.frames
764
+ inner += s.frames * t_in
765
+ both += s.frames * min(t_in, t_out)
766
+ if total <= 0 or inner / total < INNER_CUT_WARN:
767
+ return None
768
+ other = "esquerda" if cfg.position == "direita" else "direita"
769
+ corner = "canto direito" if cfg.position == "direita" else "canto esquerdo"
770
+ if both / total >= INNER_CUT_WARN:
771
+ tip = "\"posicao\": \"centro\" (a câmera corta os dois lados do corpo)"
772
+ better = "centro"
773
+ else:
774
+ tip = (f"\"posicao\": \"{other}\" ou com \"espelhar\": true (se não tiver texto na roupa ou nos objetos "
775
+ f"que aparecem)")
776
+ better = other
777
+ return {"lado": other, "parte": round(inner / total, 3), "melhor": better, "espelhar": better == other,
778
+ "aviso": (f"a câmera corta seu corpo pela {other} em {inner / total * 100:.0f}% do tempo: no {corner} esse "
779
+ f"corte aparece no meio da tela (braço ou ombro somem num degradê). Fica mais natural com {tip}")}
780
+
781
+
782
+ def _map_rect(rect: dict, s: float, x0: float, y0: float, W: int, H: int) -> tuple[float, float, float, float]:
783
+ return (x0 + rect["x0"] * W * s, y0 + rect["y0"] * H * s, x0 + rect["x1"] * W * s, y0 + rect["y1"] * H * s)
784
+
785
+
786
+ def _union(rects: list[tuple]) -> tuple:
787
+ return (min(r[0] for r in rects), min(r[1] for r in rects), max(r[2] for r in rects), max(r[3] for r in rects))
788
+
789
+
790
+ def _frac(rect: tuple, W: int, H: int) -> dict:
791
+ return {"x0": round(max(0.0, rect[0] / W), 5), "y0": round(max(0.0, rect[1] / H), 5),
792
+ "x1": round(min(1.0, rect[2] / W), 5), "y1": round(min(1.0, rect[3] / H), 5)}
793
+
794
+
795
+ def screen_zones(cfg: Config, seg: Segment, W: int, H: int) -> dict:
796
+ """Where the face and the body end up on the screen (fractions), over the zoom range of the segment.
797
+
798
+ -> {"face": {zone, core, eyes_y} | None, "person": rect | None}"""
799
+ if seg.mode != "pessoa" or not seg.stats or not seg.stats.get("person_frames"):
800
+ return {"face": None, "person": None}
801
+ st = seg.stats
802
+ zooms = {seg.zoom, seg.zoom * (1 + seg.push)}
803
+ places = [layer_rect(cfg, seg, z, W, H) for z in zooms]
804
+ body = {"x0": float(st.get("left", 0.0)), "y0": float(st.get("top", 0.0)),
805
+ "x1": float(st.get("right", 1.0)), "y1": 1.0}
806
+ person = _union([_map_rect(body, s, x, y, W, H) for s, x, y in places])
807
+ face = None
808
+ src = seg.face
809
+ if src and src.get("zone"):
810
+ zone = _union([_map_rect(src["zone"], s, x, y, W, H) for s, x, y in places])
811
+ core = _union([_map_rect(src.get("core") or src["zone"], s, x, y, W, H) for s, x, y in places])
812
+ eyes = src.get("face", {}).get("eyes_y") if isinstance(src.get("face"), dict) else None
813
+ eyes = eyes if eyes is not None else (src["zone"]["y0"] + src["zone"]["y1"]) / 2.0
814
+ s, _, y = places[0]
815
+ face = {"zone": _frac(zone, W, H), "core": _frac(core, W, H), "eyes_y": round((y + eyes * H * s) / H, 5)}
816
+ else:
817
+ # no face found by the detector: protect the top of the body (head) anyway
818
+ head_h = 0.28 * (1.0 - float(st.get("top", 0.0)))
819
+ cx = float(st.get("head_cx", 0.5))
820
+ head = {"x0": max(0.0, cx - 0.16), "y0": float(st.get("top", 0.0)), "x1": min(1.0, cx + 0.16),
821
+ "y1": float(st.get("top", 0.0)) + head_h}
822
+ zone = _union([_map_rect(head, s, x, y, W, H) for s, x, y in places])
823
+ s, _, y = places[0]
824
+ face = {"zone": _frac(zone, W, H), "core": _frac(zone, W, H),
825
+ "eyes_y": round((y + (head["y0"] + head_h * 0.45) * H * s) / H, 5)}
826
+ return {"face": face, "person": _frac(person, W, H)}
827
+
828
+
829
+ # ================================================================== backgrounds
830
+
831
+ def _darken(img: np.ndarray, amount: float) -> np.ndarray:
832
+ if amount <= 0:
833
+ return img
834
+ cv2 = _cv2()
835
+ return cv2.convertScaleAbs(img, alpha=1.0 - amount, beta=0)
836
+
837
+
838
+ def _blur(img: np.ndarray, sigma: float, W: int) -> np.ndarray:
839
+ """Gaussian blur of `sigma` px (1080 wide), done at a smaller size (fast and the same look)."""
840
+ if sigma <= 0.3:
841
+ return img
842
+ cv2 = _cv2()
843
+ s = sigma * W / 1080.0
844
+ h, w = img.shape[:2]
845
+ k = max(1, min(8, int(s // 3)))
846
+ small = cv2.resize(img, (max(2, w // k), max(2, h // k)), interpolation=cv2.INTER_AREA)
847
+ small = cv2.GaussianBlur(small, (0, 0), s / k)
848
+ return cv2.resize(small, (w, h), interpolation=cv2.INTER_LINEAR)
849
+
850
+
851
+ def _cover(img: np.ndarray, W: int, H: int, top: bool = False) -> np.ndarray:
852
+ cv2 = _cv2()
853
+ ih, iw = img.shape[:2]
854
+ sc = max(W / iw, H / ih)
855
+ nw, nh = max(W, int(math.ceil(iw * sc))), max(H, int(math.ceil(ih * sc)))
856
+ big = cv2.resize(img, (nw, nh), interpolation=cv2.INTER_AREA if sc < 1 else cv2.INTER_CUBIC)
857
+ x0 = (nw - W) // 2
858
+ y0 = 0 if top else (nh - H) // 2
859
+ return np.ascontiguousarray(big[y0:y0 + H, x0:x0 + W])
860
+
861
+
862
+ def content_box(img: np.ndarray) -> tuple[float, float, float, float]:
863
+ """The main area of a picture (fractions): where it differs from its border color, so the empty margin of a
864
+ print may go behind the captions or the person. A busy border (a photo) means the whole picture matters."""
865
+ cv2 = _cv2()
866
+ ih, iw = img.shape[:2]
867
+ k = max(1, int(round(max(iw, ih) / 320)))
868
+ small = cv2.resize(img, (max(8, iw // k), max(8, ih // k)), interpolation=cv2.INTER_AREA).astype(np.int16)
869
+ sh, sw = small.shape[:2]
870
+ ring = max(1, int(round(min(sw, sh) * 0.03)))
871
+ border = np.concatenate([small[:ring].reshape(-1, 3), small[-ring:].reshape(-1, 3),
872
+ small[:, :ring].reshape(-1, 3), small[:, -ring:].reshape(-1, 3)])
873
+ ref = np.median(border, axis=0)
874
+ spread = float(np.percentile(np.abs(border - ref).max(axis=1), 90))
875
+ whole = (0.0, 0.0, 1.0, 1.0)
876
+ if spread > 24:
877
+ return whole
878
+ diff = np.abs(small - ref).max(axis=2) > max(28.0, spread + 16.0)
879
+ rows = np.flatnonzero(diff.mean(axis=1) > 0.015)
880
+ cols = np.flatnonzero(diff.mean(axis=0) > 0.015)
881
+ if rows.size == 0 or cols.size == 0:
882
+ return whole
883
+ pad = 0.02
884
+ box = (max(0.0, cols[0] / sw - pad), max(0.0, rows[0] / sh - pad),
885
+ min(1.0, (cols[-1] + 1) / sw + pad), min(1.0, (rows[-1] + 1) / sh + pad))
886
+ if box[2] - box[0] > 0.9 and box[3] - box[1] > 0.9:
887
+ return whole
888
+ return tuple(round(v, 4) for v in box)
889
+
890
+
891
+ def whole_place(size: tuple[int, int], content: tuple[float, float, float, float], stage: tuple[float, float],
892
+ W: int, H: int, min_share: float = WHOLE_MIN) -> tuple[int, int, int, int]:
893
+ """x, y, w, h of a picture shown whole: as wide as the screen, smaller (down to min_share of the width) only
894
+ when its main area does not fit the stage, main area centered in the stage (or on its top when it still does
895
+ not fit)."""
896
+ iw, ih = size
897
+ top, bottom = stage
898
+ room = max(1.0, bottom - top)
899
+ s = W / iw
900
+ main = max(1e-6, (content[3] - content[1]) * ih)
901
+ if main * s > room:
902
+ s = max(room / main, min_share * W / iw)
903
+ w = min(W, max(2, int(round(iw * s))))
904
+ h = max(2, int(round(ih * s)))
905
+ x = (W - w) // 2
906
+ c0, c1 = content[1] * h, content[3] * h
907
+ y = (top + bottom) / 2.0 - (c0 + c1) / 2.0 if c1 - c0 <= room + 0.5 else top - c0
908
+ return (x, int(round(y)), w, h)
909
+
910
+
911
+ def backdrop(img: np.ndarray, W: int, H: int) -> np.ndarray:
912
+ """A blurred, desaturated and darkened cover copy of img, made at 1/8 size (behind a picture shown whole)."""
913
+ cv2 = _cv2()
914
+ sw, sh = max(2, int(round(W / 8))), max(2, int(round(H / 8)))
915
+ ih, iw = img.shape[:2]
916
+ sc = max(sw / iw, sh / ih)
917
+ nw, nh = max(sw, int(math.ceil(iw * sc))), max(sh, int(math.ceil(ih * sc)))
918
+ small = cv2.resize(img, (nw, nh), interpolation=cv2.INTER_AREA)
919
+ x0, y0 = (nw - sw) // 2, (nh - sh) // 2
920
+ small = cv2.GaussianBlur(np.ascontiguousarray(small[y0:y0 + sh, x0:x0 + sw]), (0, 0), BACKDROP["sigma"] * sw / 1080.0)
921
+ gray = cv2.cvtColor(cv2.cvtColor(small, cv2.COLOR_RGB2GRAY), cv2.COLOR_GRAY2RGB)
922
+ small = cv2.addWeighted(small, BACKDROP["saturation"], gray, 1.0 - BACKDROP["saturation"], 0)
923
+ small = cv2.convertScaleAbs(small, alpha=1.0 - BACKDROP["darken"], beta=0)
924
+ return cv2.resize(small, (W, H), interpolation=cv2.INTER_LINEAR)
925
+
926
+
927
+ def detail_map(img: np.ndarray) -> np.ndarray:
928
+ """0/1 per pixel of a small frame: detail (an edge, an object) against its blurred surroundings."""
929
+ cv2 = _cv2()
930
+ f = img.astype(np.float32)
931
+ sigma = 6.0 * max(img.shape[:2]) / SCAN_SIDE
932
+ return (np.abs(f - cv2.GaussianBlur(f, (0, 0), sigma)).max(axis=2) > SCAN_DETAIL).astype(np.float32)
933
+
934
+
935
+ def quiet_border(acc: np.ndarray) -> bool:
936
+ """acc: share of detail per pixel. A plain top and bottom border (a studio wall and floor, a flat color) has
937
+ almost none; those are the borders carried out to the screen (the sides of a full-width video are the
938
+ screen's)."""
939
+ h, w = acc.shape
940
+ r = max(1, int(round(min(h, w) * QUIET_RING[0])))
941
+ return max(float(acc[:r].mean()), float(acc[-r:].mean())) < QUIET_RING[1]
942
+
943
+
944
+ def scan_video(path: Path, fps: int, cache_dir: Path | None = None) -> dict:
945
+ """What a background video has where: {frames (one pass at fps), lead and tail (empty frames at its start and
946
+ end, at most a third of it each; a tail also covers a clip shrinking away after its last full frame),
947
+ content (main area, fractions, over the whole clip), quiet (plain border)}. Cached by the file signature."""
948
+ cv2 = _cv2()
949
+ st = path.stat()
950
+ key = hashlib.sha1(json.dumps([SCAN_VERSION, str(path), st.st_size, st.st_mtime_ns, fps, SCAN_SIDE, SCAN_DETAIL,
951
+ SCAN_EMPTY, SCAN_FADE, SCAN_ROW, SCAN_MASS,
952
+ QUIET_RING]).encode("utf-8")).hexdigest()[:16]
953
+ cached = cache_dir / f"scan_{key}.json" if cache_dir else None
954
+ if cached and cached.exists():
955
+ data = M.load_json(cached) or {}
956
+ if data.get("key") == key:
957
+ return data
958
+ size = media_size(path, "video") or (SCAN_SIDE, SCAN_SIDE)
959
+ k = SCAN_SIDE / max(size)
960
+ sw, sh = max(8, int(round(size[0] * k / 2)) * 2), max(8, int(round(size[1] * k / 2)) * 2)
961
+ cmd = M._ff() + ["-i", str(path), "-map", "0:v:0", "-an", "-vf",
962
+ f"fps={fps},scale={sw}:{sh}:flags=area,setsar=1,format=rgb24", "-f", "rawvideo", "-pix_fmt", "rgb24", "-"]
963
+ reader = M.FrameReader(cmd, (sw, sh), 3, "ler o vídeo do fundo", queue_len=8)
964
+ energy: list[float] = []
965
+ acc = np.zeros((sh, sw), np.float32)
966
+ try:
967
+ while True:
968
+ fr = reader.read()
969
+ if fr is None:
970
+ break
971
+ d = detail_map(fr)
972
+ energy.append(float(d.mean()))
973
+ acc += d
974
+ finally:
975
+ reader.close()
976
+ n = len(energy)
977
+ out = {"key": key, "frames": n, "lead": 0, "tail": 0, "content": [0.0, 0.0, 1.0, 1.0], "quiet": False}
978
+ if n:
979
+ acc /= n
980
+ e = np.array(energy)
981
+ med = float(np.median(e))
982
+ empty = max(SCAN_EMPTY[0], SCAN_EMPTY[1] * med)
983
+ thin = e < max(empty, SCAN_EMPTY[2] * med)
984
+
985
+ def run(flags: np.ndarray, edge: float) -> int:
986
+ """Frames from that end that are empty or still building up (0 unless the end frame is empty; 0 in a
987
+ clip with no detail at all, a flat color)."""
988
+ if edge >= empty or med < SCAN_EMPTY[0]:
989
+ return 0
990
+ full = np.flatnonzero(~flags)
991
+ return int(full[0]) if full.size else n
992
+ def fade_out() -> int:
993
+ """Frames after the last full one of a clip that shrinks away to nothing (an X that shrinks after it
994
+ landed): the fall from its last peak, past the frames with SCAN_FADE[1] of that peak."""
995
+ if float(e[-1]) >= empty or med < SCAN_EMPTY[0]:
996
+ return 0
997
+ i = n - 1
998
+ while i > 0 and e[i - 1] >= e[i] - SCAN_FADE[0] * med:
999
+ i -= 1
1000
+ fall = e[i:]
1001
+ kept = np.flatnonzero(fall >= SCAN_FADE[1] * float(fall.max()))
1002
+ return n - 1 - (i + int(kept[-1]))
1003
+ out["lead"] = int(min(run(thin, float(e[0])), n // 3))
1004
+ out["tail"] = int(min(max(run(thin[::-1], float(e[-1])), fade_out()), n // 3))
1005
+ rm, cm = acc.mean(axis=1), acc.mean(axis=0)
1006
+ rows = np.flatnonzero(rm > max(SCAN_ROW, 0.1 * float(rm.max())))
1007
+ cols = np.flatnonzero(cm > max(SCAN_ROW, 0.1 * float(cm.max())))
1008
+ if rows.size and cols.size:
1009
+ pad = 0.02
1010
+ box = [max(0.0, cols[0] / sw - pad), max(0.0, rows[0] / sh - pad),
1011
+ min(1.0, (cols[-1] + 1) / sw + pad), min(1.0, (rows[-1] + 1) / sh + pad)]
1012
+ if not (box[2] - box[0] > 0.9 and box[3] - box[1] > 0.9): # a busy clip is all main area
1013
+ cum = np.cumsum(rm) / max(1e-9, float(rm.sum()))
1014
+ cut = (1.0 - SCAN_MASS) / 2.0
1015
+ r0 = max(int(rows[0]), int(np.searchsorted(cum, cut)))
1016
+ r1 = min(int(rows[-1]) + 1, int(np.searchsorted(cum, 1.0 - cut)) + 1)
1017
+ box[1], box[3] = max(0.0, r0 / sh - pad), min(1.0, r1 / sh + pad)
1018
+ out["content"] = [round(float(v), 4) for v in box]
1019
+ out["quiet"] = quiet_border(acc)
1020
+ if cached:
1021
+ cached.parent.mkdir(parents=True, exist_ok=True)
1022
+ M.save_json(cached, out)
1023
+ return out
1024
+
1025
+
1026
+ def extend_weights(place: tuple[int, int, int, int], W: int, H: int) -> np.ndarray:
1027
+ """0 on a plain-border copy and 1 far from it (EXTEND fall), at 1/8 size: how far the border color has
1028
+ faded into its darker average."""
1029
+ sw, sh = max(2, int(round(W / 8))), max(2, int(round(H / 8)))
1030
+ x, y, w, h = (v / 8.0 for v in place)
1031
+ xs = np.arange(sw, dtype=np.float32) + 0.5
1032
+ ys = np.arange(sh, dtype=np.float32) + 0.5
1033
+ dx = np.maximum(0.0, np.maximum(x - xs, xs - (x + w)))
1034
+ dy = np.maximum(0.0, np.maximum(y - ys, ys - (y + h)))
1035
+ d = np.sqrt(dx[None, :] ** 2 + dy[:, None] ** 2) / max(1.0, EXTEND["fall"] * W / 1080.0 / 8.0)
1036
+ t = np.clip(d, 0, 1)
1037
+ return (t * t * (3 - 2 * t)).astype(np.float32)
1038
+
1039
+
1040
+ def extend_backdrop(sharp: np.ndarray, place: tuple[int, int, int, int], W: int, H: int,
1041
+ weights: np.ndarray) -> np.ndarray:
1042
+ """The screen around a plain-border copy: its own border carried outwards (blurred) and fading into its
1043
+ average color, a little darker, so the copy has no visible box."""
1044
+ cv2 = _cv2()
1045
+ sw, sh = weights.shape[1], weights.shape[0]
1046
+ x, y, w, h = place
1047
+ cw, ch = max(1, int(round(w / 8))), max(1, int(round(h / 8)))
1048
+ small = cv2.resize(sharp, (cw, ch), interpolation=cv2.INTER_AREA)
1049
+ x8, y8 = int(round(x / 8)), int(round(y / 8))
1050
+ # the part of the small copy on the screen, then its border carried to the screen edges
1051
+ cx0, cy0 = max(0, -x8), max(0, -y8)
1052
+ cx1, cy1 = min(cw, sw - x8), min(ch, sh - y8)
1053
+ if cx1 <= cx0 or cy1 <= cy0:
1054
+ part = small
1055
+ top = bottom = left = right = 0
1056
+ canvas = cv2.resize(small, (sw, sh), interpolation=cv2.INTER_AREA)
1057
+ else:
1058
+ part = np.ascontiguousarray(small[cy0:cy1, cx0:cx1])
1059
+ top, left = max(0, y8), max(0, x8)
1060
+ bottom, right = max(0, sh - top - part.shape[0]), max(0, sw - left - part.shape[1])
1061
+ canvas = cv2.copyMakeBorder(part, top, bottom, left, right, cv2.BORDER_REPLICATE)[:sh, :sw]
1062
+ sigma = EXTEND["blur"] * W / 1080.0 / 8.0
1063
+ canvas = cv2.GaussianBlur(canvas, (0, 0), sigma).astype(np.float32)
1064
+ ring = np.concatenate([part[:1].reshape(-1, 3), part[-1:].reshape(-1, 3), part[:, :1].reshape(-1, 3),
1065
+ part[:, -1:].reshape(-1, 3)]).astype(np.float32)
1066
+ flat = np.median(ring, axis=0) * (1.0 - EXTEND["darken"])
1067
+ t = weights[..., None]
1068
+ out = canvas * (1.0 - t) + flat[None, None, :] * t
1069
+ return cv2.resize(np.clip(out, 0, 255).astype(np.uint8), (W, H), interpolation=cv2.INTER_LINEAR)
1070
+
1071
+
1072
+ def feather_mask(w: int, h: int, x: int, y: int, W: int, H: int) -> np.ndarray:
1073
+ """Blend weights of a sharp copy at (x, y): smooth ramps on the sides that fall inside the screen."""
1074
+ f = max(2, int(round(BACKDROP["feather"] * W / 1080.0)))
1075
+
1076
+ def ramp(n: int, lead: bool, trail: bool) -> np.ndarray:
1077
+ r = np.ones(n, np.float32)
1078
+ m = min(f, n // 4)
1079
+ if m > 1:
1080
+ e = np.linspace(0.0, 1.0, m, dtype=np.float32)
1081
+ e = e * e * (3 - 2 * e)
1082
+ if lead:
1083
+ r[:m] = np.minimum(r[:m], e)
1084
+ if trail:
1085
+ r[n - m:] = np.minimum(r[n - m:], e[::-1])
1086
+ return r
1087
+
1088
+ return np.ascontiguousarray(ramp(h, y > 0, y + h < H)[:, None] * ramp(w, x > 0, x + w < W)[None, :])
1089
+
1090
+
1091
+ def whole_frame(sharp: np.ndarray, place: tuple[int, int, int, int], W: int, H: int, mask: np.ndarray,
1092
+ extend: np.ndarray | None = None) -> np.ndarray:
1093
+ """The sharp copy (already w x h) at its place over the backdrop made from it (its own border carried
1094
+ outwards when extend, the weights of extend_weights, is given)."""
1095
+ cv2 = _cv2()
1096
+ base = backdrop(sharp, W, H) if extend is None else extend_backdrop(sharp, place, W, H, extend)
1097
+ x, y, w, h = place
1098
+ if sharp.shape[1] != w or sharp.shape[0] != h:
1099
+ sharp = cv2.resize(sharp, (w, h), interpolation=cv2.INTER_AREA if w < sharp.shape[1] else cv2.INTER_CUBIC)
1100
+ X0, Y0, X1, Y1 = max(0, x), max(0, y), min(W, x + w), min(H, y + h)
1101
+ if X1 <= X0 or Y1 <= Y0:
1102
+ return base
1103
+ part = np.ascontiguousarray(sharp[Y0 - y:Y1 - y, X0 - x:X1 - x])
1104
+ m = np.ascontiguousarray(mask[Y0 - y:Y1 - y, X0 - x:X1 - x])
1105
+ base[Y0:Y1, X0:X1] = cv2.blendLinear(np.ascontiguousarray(base[Y0:Y1, X0:X1]), part, 1.0 - m, m)
1106
+ return base
1107
+
1108
+
1109
+ def picture_frame(img: np.ndarray, b: Background, W: int, H: int) -> tuple[np.ndarray, tuple[float, float]]:
1110
+ """A still picture on the screen -> (frame, Ken Burns center)."""
1111
+ cv2 = _cv2()
1112
+ ih, iw = img.shape[:2]
1113
+ if b.fit == "inteiro" and b.place:
1114
+ x, y, w, h = b.place
1115
+ sharp = cv2.resize(img, (w, h), interpolation=cv2.INTER_AREA if w < iw else cv2.INTER_CUBIC)
1116
+ frame = whole_frame(sharp, b.place, W, H, feather_mask(w, h, x, y, W, H))
1117
+ c = b.content
1118
+ return frame, (x + (c[0] + c[2]) / 2.0 * w, y + (c[1] + c[3]) / 2.0 * h)
1119
+ tall = iw / ih < (W / H) / FIT_RATIO and W < H
1120
+ return _cover(img, W, H, top=tall), (W / 2.0, H * 0.3 if tall else H / 2.0)
1121
+
1122
+
1123
+ def _kb_frame(base: np.ndarray, center: tuple[float, float], p: float, W: int, H: int) -> np.ndarray:
1124
+ cv2 = _cv2()
1125
+ z = 1.0 + KB_DRIFT * ease(p)
1126
+ cx, cy = center
1127
+ m = np.array([[z, 0.0, cx - z * cx], [0.0, z, cy - z * cy]], np.float32)
1128
+ return cv2.warpAffine(base, m, (W, H), flags=cv2.INTER_LINEAR, borderMode=cv2.BORDER_REFLECT)
1129
+
1130
+
1131
+ def read_image(path: Path) -> np.ndarray:
1132
+ from PIL import Image, ImageOps # noqa: PLC0415
1133
+ with Image.open(path) as im:
1134
+ im = ImageOps.exif_transpose(im)
1135
+ if im.mode in ("RGBA", "LA", "P"):
1136
+ rgba = im.convert("RGBA")
1137
+ bg = Image.new("RGB", rgba.size, (18, 18, 18))
1138
+ bg.paste(rgba, mask=rgba.getchannel("A"))
1139
+ im = bg
1140
+ return np.ascontiguousarray(np.asarray(im.convert("RGB")))
1141
+
1142
+
1143
+ class LoopReader:
1144
+ """Frames of a background video in order. Every pass plays untouched (an insert shows its clip frame by frame,
1145
+ as in the split screen), except its empty frames: the `lead` empty frames at the start show the first frame
1146
+ with something in it and the `tail` ones at the end the last (a pass never flashes an empty scene;
1147
+ first_lead replaces lead on the first pass, 0 when a dissolve covers the start). Only
1148
+ when the clip has to go on past its end does it start again (a new ffmpeg reader), and the first `fade`
1149
+ frames of the new pass dissolve from the last frame shown, so the loop neither jumps nor freezes."""
1150
+
1151
+ def __init__(self, cmd: list[str], size: tuple[int, int], what: str, fade: int, lead: int = 0, tail: int = 0,
1152
+ frames: int = 0, first_lead: int | None = None):
1153
+ self.cmd, self.size, self.what = cmd, size, what
1154
+ self.fade = max(0, int(fade))
1155
+ self.lead, self.tail, self.frames = max(0, int(lead)), max(0, int(tail)), max(0, int(frames))
1156
+ self.first_lead = self.lead if first_lead is None else max(0, int(first_lead)) # the first pass
1157
+ self.cur: M.FrameReader | None = self._open()
1158
+ self.cycle = 0 # frames given from the current pass
1159
+ self.length = 0 # frames of one pass (known when the first one ends)
1160
+ self.shown: np.ndarray | None = None # the last frame given (before a dissolve)
1161
+ self.ahead = 0 # frames of this pass already read (the empty start)
1162
+ self.hold: np.ndarray | None = None # the frame shown over the empty start or end
1163
+ self.blend_from: np.ndarray | None = None # the last frame of the pass before, while the new one dissolves in
1164
+ self.blend_n = 0
1165
+ self.loops = 0
1166
+
1167
+ def _open(self) -> "M.FrameReader":
1168
+ return M.FrameReader(self.cmd, self.size, 3, self.what, queue_len=4)
1169
+
1170
+ def read(self) -> np.ndarray | None:
1171
+ if self.cur is None:
1172
+ return None
1173
+ if self.ahead > 0:
1174
+ self.ahead -= 1
1175
+ return self._give(self.hold)
1176
+ fr = self.cur.read()
1177
+ if fr is None:
1178
+ if self.cycle == 0 or self.shown is None:
1179
+ self.close()
1180
+ return None
1181
+ self.length = self.length or self.cycle
1182
+ self.cur.close()
1183
+ self.cur = self._open()
1184
+ self.loops += 1
1185
+ self.cycle = 0
1186
+ self.blend_from, self.blend_n = self.shown, min(self.fade, self.length // 3)
1187
+ fr = self.cur.read()
1188
+ if fr is None:
1189
+ self.close()
1190
+ return None
1191
+ i = self.cycle
1192
+ lead = self.first_lead if self.loops == 0 else self.lead
1193
+ if i == 0 and lead > 0:
1194
+ for _ in range(lead):
1195
+ nxt = self.cur.read()
1196
+ if nxt is None:
1197
+ break
1198
+ fr = nxt
1199
+ self.ahead += 1
1200
+ self.hold = fr
1201
+ elif self.frames and self.tail > 0 and i >= self.frames - self.tail and self.hold is not None:
1202
+ fr = self.hold
1203
+ if self.frames and self.tail > 0 and i == self.frames - self.tail - 1:
1204
+ self.hold = fr
1205
+ return self._give(fr)
1206
+
1207
+ def _give(self, fr: np.ndarray) -> np.ndarray:
1208
+ self.cycle += 1
1209
+ self.shown = fr
1210
+ if self.blend_from is not None and self.cycle <= self.blend_n:
1211
+ w = self.cycle / (self.blend_n + 1)
1212
+ return _cv2().addWeighted(self.blend_from, 1.0 - w, fr, w, 0)
1213
+ self.blend_from = None
1214
+ return fr
1215
+
1216
+ def close(self) -> None:
1217
+ if self.cur is not None:
1218
+ self.cur.close()
1219
+ self.cur = None
1220
+
1221
+
1222
+ class BackgroundTrack:
1223
+ """Background frames in order: fetch(f) reads what has to be read in order (video frames) and returns a
1224
+ function that makes the frame (run in the worker threads)."""
1225
+
1226
+ def __init__(self, cfg: Config, W: int, H: int, fps: int):
1227
+ self.cfg, self.W, self.H, self.fps = cfg, W, H, fps
1228
+ self.runs = cfg.runs
1229
+ self.run_i = 0
1230
+ self.stills: dict[int, tuple[np.ndarray, tuple[float, float]]] = {}
1231
+ self.videos: dict[int, dict] = {}
1232
+ self.masks: dict[int, np.ndarray] = {}
1233
+ self.dark = np.empty((H, W, 3), np.uint8)
1234
+ self.dark[:] = np.array(hex_rgb(DARK_DEFAULT), np.uint8)
1235
+ self.ends: dict[int, int] = {}
1236
+ for f0, f1, k in cfg.runs: # a background lasts until the last run it owns (gaps included)
1237
+ if k is not None:
1238
+ self.ends[k] = max(self.ends.get(k, 0), f1)
1239
+ self.extends: dict[int, np.ndarray] = {}
1240
+ self.last: tuple[int, int | None, Callable[[], np.ndarray]] | None = None # (frame, owner, make)
1241
+ self.dissolve: tuple[int, np.ndarray | None] | None = None # (owner, frame it comes from)
1242
+ self.no_hold: set[int] = set() # videos whose empty start plays under a dissolve (first pass)
1243
+
1244
+ def kb_span(self, k: int) -> int:
1245
+ """Frames of the Ken Burns: the item's own time, stretched over a held gap up to KB_HOLD_AFTER times it."""
1246
+ b = self.cfg.backgrounds[k]
1247
+ own = max(1, b.f1 - b.f0)
1248
+ total = max(own, self.ends.get(k, b.f1) - b.f0)
1249
+ return max(1, min(total, int(round(own * KB_HOLD_AFTER))) - 1)
1250
+
1251
+ def _still(self, k: int) -> tuple[np.ndarray, tuple[float, float]]:
1252
+ if k not in self.stills:
1253
+ b = self.cfg.backgrounds[k]
1254
+ if b.kind == "color":
1255
+ frame = np.empty((self.H, self.W, 3), np.uint8)
1256
+ frame[:] = np.array(b.color, np.uint8)
1257
+ self.stills[k] = (_darken(frame, b.darken), (self.W / 2.0, self.H / 2.0))
1258
+ else:
1259
+ frame, center = picture_frame(read_image(b.src), b, self.W, self.H)
1260
+ frame = _darken(_blur(frame, b.blur, self.W), b.darken)
1261
+ self.stills[k] = (frame, center)
1262
+ return self.stills[k]
1263
+
1264
+ def _mask(self, k: int) -> np.ndarray:
1265
+ if k not in self.masks:
1266
+ b = self.cfg.backgrounds[k]
1267
+ x, y, w, h = b.place
1268
+ self.masks[k] = feather_mask(w, h, x, y, self.W, self.H)
1269
+ if b.quiet:
1270
+ self.extends[k] = extend_weights(b.place, self.W, self.H)
1271
+ return self.masks[k]
1272
+
1273
+ def _video_raw(self, k: int, local: int) -> np.ndarray:
1274
+ b = self.cfg.backgrounds[k]
1275
+ v = self.videos.get(k)
1276
+ if v is None:
1277
+ if b.fit == "inteiro" and b.place:
1278
+ size = (b.place[2], b.place[3])
1279
+ vf = f"fps={self.fps},scale={size[0]}:{size[1]}:flags=bicubic,setsar=1,format=rgb24"
1280
+ else:
1281
+ size = (self.W, self.H)
1282
+ vf = (f"fps={self.fps},scale={self.W}:{self.H}:force_original_aspect_ratio=increase:flags=bicubic,"
1283
+ f"crop={self.W}:{self.H},setsar=1,format=rgb24")
1284
+ cmd = M._ff() + ["-i", str(b.src), "-map", "0:v:0", "-an", "-vf", vf, "-f", "rawvideo", "-pix_fmt", "rgb24", "-"]
1285
+ v = {"reader": LoopReader(cmd, size, "ler o vídeo do fundo", int(round(LOOP_FADE * self.fps)),
1286
+ lead=b.lead, tail=b.tail, frames=b.pass_frames,
1287
+ first_lead=0 if k in self.no_hold else None),
1288
+ "pos": 0, "last": None}
1289
+ self.videos[k] = v
1290
+ while v["pos"] <= local:
1291
+ fr = v["reader"].read()
1292
+ if fr is None:
1293
+ break
1294
+ v["last"] = fr
1295
+ v["pos"] += 1
1296
+ if v["last"] is None:
1297
+ raise TelaVerdeError(f"não consegui ler o vídeo do fundo ({b.name}): {b.src}", 1)
1298
+ return v["last"]
1299
+
1300
+ def _video_frame(self, k: int, raw: np.ndarray, local: int) -> np.ndarray:
1301
+ b = self.cfg.backgrounds[k]
1302
+ frame, center = raw, (self.W / 2.0, self.H / 2.0)
1303
+ if b.fit == "inteiro" and b.place:
1304
+ frame = whole_frame(raw, b.place, self.W, self.H, self._mask(k), self.extends.get(k))
1305
+ x, y, w, h = b.place
1306
+ center = (x + w / 2.0, y + h / 2.0)
1307
+ if b.ken_burns:
1308
+ frame = _kb_frame(frame, center, min(1.0, local / self.kb_span(k)), self.W, self.H)
1309
+ frame = _darken(_blur(frame, b.blur, self.W), b.darken)
1310
+ return frame if frame is not raw else frame.copy()
1311
+
1312
+ def owner(self, f: int) -> int | None:
1313
+ while self.run_i < len(self.runs) - 1 and f >= self.runs[self.run_i][1]:
1314
+ self.run_i += 1
1315
+ return self.runs[self.run_i][2]
1316
+
1317
+ def fetch(self, f: int, original: np.ndarray | None) -> Callable[[], np.ndarray]:
1318
+ k = self.owner(f)
1319
+ src, w = None, 1.0
1320
+ if k is not None:
1321
+ b = self.cfg.backgrounds[k]
1322
+ local = f - b.f0
1323
+ if b.kind == "video" and b.lead > 0 and local < b.lead:
1324
+ # the clip starts empty: the background right before it dissolves into it while it builds up
1325
+ # (its own frames play under the dissolve); with nothing before it, its first full frame is held
1326
+ if self.dissolve is None or self.dissolve[0] != k:
1327
+ prev = self.last
1328
+ first = local == 0 and prev and prev[0] == f - 1 and prev[1] is not None and prev[1] != k
1329
+ self.dissolve = (k, prev[2]() if first else None)
1330
+ if first and k not in self.videos:
1331
+ self.no_hold.add(k)
1332
+ src = self.dissolve[1]
1333
+ w = (local + 1) / (b.lead + 1)
1334
+ make = self._make(f, k, original)
1335
+ if src is not None:
1336
+ inner = make
1337
+ make = lambda: _cv2().addWeighted(src, 1.0 - w, inner(), w, 0) # noqa: E731
1338
+ self.last = (f, k, make)
1339
+ return make
1340
+
1341
+ def _make(self, f: int, k: int | None, original: np.ndarray | None) -> Callable[[], np.ndarray]:
1342
+ W, H = self.W, self.H
1343
+ if k is None:
1344
+ if self.cfg.blurred_original and original is not None:
1345
+ return lambda: _darken(_blur(original, ORIGINAL_BLUR["sigma"], W), ORIGINAL_BLUR["darken"])
1346
+ return self.dark.copy
1347
+ b = self.cfg.backgrounds[k]
1348
+ local = f - b.f0
1349
+ if b.kind == "video":
1350
+ raw = self._video_raw(k, local)
1351
+ if k not in self.masks and b.fit == "inteiro" and b.place:
1352
+ self._mask(k) # made once, here, before the workers share it
1353
+ return lambda: self._video_frame(k, raw, local)
1354
+ base, center = self._still(k)
1355
+ if b.ken_burns:
1356
+ p = min(1.0, local / self.kb_span(k))
1357
+ return lambda: _kb_frame(base, center, p, W, H)
1358
+ return base.copy
1359
+
1360
+ def frame(self, f: int, original: np.ndarray | None) -> np.ndarray:
1361
+ return self.fetch(f, original)()
1362
+
1363
+ def close(self) -> None:
1364
+ for v in self.videos.values():
1365
+ v["reader"].close()
1366
+ self.videos.clear()
1367
+
1368
+
1369
+ # ================================================================== room plate (the room of a tripod shot)
1370
+
1371
+ def _ss(x: np.ndarray, a: float, b: float) -> np.ndarray:
1372
+ t = np.clip((x - a) / (b - a), 0.0, 1.0)
1373
+ return t * t * (3.0 - 2.0 * t)
1374
+
1375
+
1376
+ def plate_size(full: tuple[int, int]) -> tuple[int, int]:
1377
+ """The plate works at the matte work size (half the output)."""
1378
+ return max(2, (full[0] // 2) // 2 * 2), max(2, (full[1] // 2) // 2 * 2)
1379
+
1380
+
1381
+ @dataclass
1382
+ class RoomPlate:
1383
+ """What the room behind a take looks like, learned from every other frame of its ranges where the matte says
1384
+ room (the camera on a tripod). evidence() finds where a frame's matte is unsure (a haze, a patch the model
1385
+ flickers on) and the picture there is exactly that room: those pieces go (a dark room only when it is unlike
1386
+ the body next to it), and so does each pixel of a light halo of the room around a darker body. A matte that
1387
+ is sure is never touched (a kept object), nor is the rest of the soft edge of the body or a dark shirt
1388
+ blurred over a dark desk; clean_edge() takes the room's color out of that soft edge."""
1389
+ key: str
1390
+ size: tuple[int, int]
1391
+ P: np.ndarray # blurred room color (float32 h x w x 3)
1392
+ rel: np.ndarray # 0..1 how much the plate is trusted there
1393
+ tol: np.ndarray # color distance that still is the room there
1394
+ known: float = 0.0 # share of the frame the plate knows
1395
+ spread: float = 0.0 # median spread of the room color (0-255)
1396
+ mask: np.ndarray | None = None
1397
+
1398
+ def __post_init__(self):
1399
+ self.mask = self.rel > 0.02
1400
+
1401
+ def evidence(self, frame: np.ndarray, alpha: np.ndarray) -> np.ndarray | None:
1402
+ """frame: RGB uint8 at the plate size; alpha: 0..1. -> 0..1 map of room to take out of the matte (None
1403
+ when there is none)."""
1404
+ cv2 = _cv2()
1405
+ lo, hi = PLATE["unsure"]
1406
+ cand = self.mask & (alpha > 0.02) & (alpha < hi)
1407
+ rows = np.flatnonzero(cand.any(axis=1))
1408
+ if rows.size == 0:
1409
+ return None
1410
+ cols = np.flatnonzero(cand.any(axis=0))
1411
+ w, h = self.size
1412
+ pad = PLATE["unsure_px"] + 8
1413
+ y0, y1 = max(0, int(rows[0]) - pad), min(h, int(rows[-1]) + pad + 1)
1414
+ x0, x1 = max(0, int(cols[0]) - pad), min(w, int(cols[-1]) + pad + 1)
1415
+ sl = (slice(y0, y1), slice(x0, x1))
1416
+ img = cv2.GaussianBlur(np.ascontiguousarray(frame[sl]).astype(np.float32), (0, 0), PLATE["blur"])
1417
+ a = alpha[sl]
1418
+ room_P = self.P[sl]
1419
+ tol = self.tol[sl]
1420
+ win = PLATE["win"]
1421
+ d1 = cv2.absdiff(img, room_P).max(axis=2)
1422
+ d = cv2.blur(d1, (win, win))
1423
+ # a dark room must look unlike the body next to it (a dark shirt blurred over a dark desk is left alone)
1424
+ core = (a >= hi).astype(np.float32)
1425
+ sig = PLATE["near"]
1426
+ body = cv2.GaussianBlur(img * core[..., None], (0, 0), sig) / np.maximum(
1427
+ cv2.GaussianBlur(core, (0, 0), sig), 1e-3)[..., None]
1428
+ unlike = np.maximum(_ss(cv2.absdiff(body, room_P).max(axis=2), *PLATE["unlike"]),
1429
+ _ss(room_P.mean(axis=2), *PLATE["bright"]))
1430
+ base = self.rel[sl] * (1.0 - _ss(a, lo, hi)) * cand[sl]
1431
+ # a light halo of the room around a darker body goes pixel by pixel (only the pixels that are that room)
1432
+ halo = (base * _ss(room_P.mean(axis=2) - body.mean(axis=2), *PLATE["halo"])
1433
+ * (1.0 - _ss(d1, tol, tol + PLATE["width"])))
1434
+ halo = np.where(halo > 0.05, halo, 0.0).astype(np.float32)
1435
+ e = base * unlike * (1.0 - _ss(d, tol, tol + PLATE["width"]))
1436
+ m = (e > PLATE["on"]).astype(np.uint8)
1437
+ # only pieces wider than the soft edge of a body, rounded, and only when they reach the room around the
1438
+ # person or the frame edge (a piece inside the body would leave a hole)
1439
+ ker = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (PLATE["unsure_px"], PLATE["unsure_px"]))
1440
+ m = cv2.morphologyEx(m, cv2.MORPH_OPEN, ker)
1441
+ out_sl = halo
1442
+ if m.any():
1443
+ round_ = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (PLATE["round_px"], PLATE["round_px"]))
1444
+ m = cv2.morphologyEx(cv2.morphologyEx(m, cv2.MORPH_CLOSE, round_), cv2.MORPH_OPEN, round_)
1445
+ n, lab, st, _ = cv2.connectedComponentsWithStats(m, connectivity=8)
1446
+ room = cv2.dilate((a < 0.5).astype(np.uint8), np.ones((5, 5), np.uint8)) > 0
1447
+ keep = np.zeros(n, bool)
1448
+ keep[np.unique(lab[room & (m > 0)])] = True
1449
+ bx, by, bw, bh = st.T[:4]
1450
+ keep |= (bx + x0 == 0) | (by + y0 == 0) | (bx + bw + x0 == w) | (by + bh + y0 == h)
1451
+ keep[0] = False
1452
+ k = keep[lab]
1453
+ if k.any():
1454
+ whole = cv2.GaussianBlur(cv2.dilate(k.astype(np.uint8), np.ones((3, 3), np.uint8)).astype(np.float32),
1455
+ (0, 0), PLATE["soft"])
1456
+ out_sl = np.maximum(out_sl, whole)
1457
+ if not out_sl.any():
1458
+ return None
1459
+ out = np.zeros((h, w), np.float32)
1460
+ out[sl] = out_sl
1461
+ return out
1462
+
1463
+ def clean_edge(self, frame: np.ndarray, alpha: np.ndarray) -> np.ndarray:
1464
+ """The colors of the soft edge of the matte without the known room mixed into them (a light or blue
1465
+ fringe from the room behind): each partly transparent pixel is solved for the body color, where the plate
1466
+ knows the room. frame: RGB uint8, alpha: uint8, both at the layer size. -> a new frame (or frame)."""
1467
+ lo, hi = PLATE["edge"]
1468
+ h, w = alpha.shape
1469
+ pw, ph = self.size
1470
+ ys, xs = np.nonzero((alpha > lo) & (alpha < hi))
1471
+ if ys.size == 0:
1472
+ return frame
1473
+ py = np.minimum(ph - 1, (ys * ph) // h)
1474
+ px = np.minimum(pw - 1, (xs * pw) // w)
1475
+ rel = self.rel[py, px]
1476
+ use = rel > 0.3
1477
+ if not use.any():
1478
+ return frame
1479
+ ys, xs, py, px, rel = ys[use], xs[use], py[use], px[use], rel[use]
1480
+ a = (alpha[ys, xs].astype(np.float32) / 255.0)[:, None]
1481
+ img = frame[ys, xs].astype(np.float32)
1482
+ B = self.P[py, px]
1483
+ solved = (img - (1.0 - a) * B) / np.maximum(a, PLATE["edge_floor"])
1484
+ out = frame.copy()
1485
+ out[ys, xs] = np.clip(img + rel[:, None] * (solved - img), 0, 255).astype(np.uint8)
1486
+ return out
1487
+
1488
+ def save(self, path: Path) -> None:
1489
+ path.parent.mkdir(parents=True, exist_ok=True)
1490
+ tmp = path.with_name(".tmp-" + path.name)
1491
+ with open(tmp, "wb") as fh:
1492
+ np.savez_compressed(fh, P=self.P, rel=self.rel, tol=self.tol,
1493
+ meta=np.array(json.dumps({"key": self.key, "size": list(self.size),
1494
+ "known": self.known, "spread": self.spread})))
1495
+ os.replace(tmp, path)
1496
+
1497
+ @staticmethod
1498
+ def load(path: Path, key: str) -> RoomPlate | None:
1499
+ try:
1500
+ with np.load(path) as z:
1501
+ meta = json.loads(str(z["meta"]))
1502
+ if meta.get("key") != key:
1503
+ return None
1504
+ return RoomPlate(key=key, size=tuple(meta["size"]), P=z["P"], rel=z["rel"], tol=z["tol"],
1505
+ known=float(meta.get("known", 0.0)), spread=float(meta.get("spread", 0.0)))
1506
+ except (OSError, ValueError, KeyError):
1507
+ return None
1508
+
1509
+
1510
+ class _PlateAcc:
1511
+ def __init__(self, size: tuple[int, int]):
1512
+ w, h = size
1513
+ self.size = size
1514
+ self.sum = np.zeros((h, w, 3), np.float32)
1515
+ self.sq = np.zeros((h, w, 3), np.float32)
1516
+ self.n = np.zeros((h, w), np.float32)
1517
+ cv2 = _cv2()
1518
+ r = PLATE["fringe"]
1519
+ self.ker = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (2 * r + 1, 2 * r + 1))
1520
+
1521
+ def add(self, frame: np.ndarray, alpha: np.ndarray) -> None:
1522
+ cv2 = _cv2()
1523
+ room = (cv2.dilate((alpha >= PLATE["bg"]).astype(np.uint8), self.ker) == 0).astype(np.uint8)
1524
+ x = frame.astype(np.float32)
1525
+ cv2.accumulate(x, self.sum, room)
1526
+ cv2.accumulateSquare(x, self.sq, room)
1527
+ cv2.accumulate(room.astype(np.float32), self.n)
1528
+
1529
+ def finish(self, key: str) -> tuple[RoomPlate | None, str]:
1530
+ cv2 = _cv2()
1531
+ n = self.n
1532
+ nn = np.maximum(n, 1.0)[..., None]
1533
+ mean = self.sum / nn
1534
+ std = np.sqrt(np.maximum(self.sq / nn - mean * mean, 0.0)).max(axis=2)
1535
+ well = n >= PLATE["n"][1]
1536
+ known = float(well.mean())
1537
+ if known < PLATE["known"]:
1538
+ return None, "o fundo quase não aparece nesses trechos"
1539
+ spread = float(np.median(std[well]))
1540
+ if spread > PLATE["static"]:
1541
+ return None, "a câmera se mexe nesses trechos"
1542
+ P = cv2.GaussianBlur(mean, (0, 0), PLATE["blur"]).astype(np.float32)
1543
+ rel = (_ss(n, *PLATE["n"]) * (1.0 - _ss(std, *PLATE["std"]))).astype(np.float32)
1544
+ tol = (PLATE["tol"] + np.minimum(std, PLATE["std"][0] + 2.0)).astype(np.float32)
1545
+ return RoomPlate(key=key, size=self.size, P=P, rel=rel, tol=tol, known=round(known, 3),
1546
+ spread=round(spread, 2)), ""
1547
+
1548
+
1549
+ def room_plates(base: Path, base_pre: list[str], base_fps: float, fps: int, full_size: tuple[int, int],
1550
+ groups: dict[str, list[tuple[int, int, M.RangeJob]]], cache_dir: Path,
1551
+ progress: Callable[[str], None] | None = None) -> tuple[dict[str, RoomPlate], dict[str, str]]:
1552
+ """One plate per take (groups: take -> [(f0, f1, matte job)]), read from the cut at the plate size in one
1553
+ pass (only when a plate is not cached). -> ({take: plate}, {take: why there is none})."""
1554
+ size = plate_size(full_size)
1555
+ sig = M.file_signature(base)
1556
+ keys, plates, notes = {}, {}, {}
1557
+ for g, parts in groups.items():
1558
+ keys[g] = hashlib.sha1(json.dumps([PLATE_VERSION, PLATE, str(base), sig, base_pre, fps, list(full_size),
1559
+ sorted([a, b, j.key] for a, b, j in parts)],
1560
+ sort_keys=True).encode("utf-8")).hexdigest()[:16]
1561
+ todo = {}
1562
+ for g, key in keys.items():
1563
+ path = cache_dir / f"plate_{key}.npz"
1564
+ note_path = cache_dir / f"plate_{key}.json"
1565
+ if path.exists():
1566
+ plate = RoomPlate.load(path, key)
1567
+ if plate is not None:
1568
+ plates[g] = plate
1569
+ continue
1570
+ if note_path.exists():
1571
+ data = M.load_json(note_path) or {}
1572
+ if data.get("key") == key:
1573
+ notes[g] = str(data.get("note") or "")
1574
+ continue
1575
+ todo[g] = key
1576
+ if not todo:
1577
+ return plates, notes
1578
+ started = time.time()
1579
+ spans = sorted((a, b, g, j) for g in todo for a, b, j in groups[g])
1580
+ last = max(b for a, b, _, _ in spans)
1581
+ accs = {g: _PlateAcc(size) for g in todo}
1582
+ reader = M.FrameReader(M.base_cmd(base, base_pre, base_fps, fps, 0, last, size=size, full_size=full_size),
1583
+ size, 3, "ler o corte", queue_len=8)
1584
+ try:
1585
+ f = 0
1586
+ for a, b, g, job in spans:
1587
+ while f < a:
1588
+ if reader.read() is None:
1589
+ break
1590
+ f += 1
1591
+ mreader = M.FrameReader(M.matte_cmd(job.path, size, full_size), size, 1, "ler o recorte", queue_len=8)
1592
+ try:
1593
+ for k in range(b - a):
1594
+ frame = reader.read()
1595
+ alpha = mreader.read()
1596
+ f += 1
1597
+ if frame is None or alpha is None:
1598
+ break
1599
+ if k % PLATE["step"] == 0:
1600
+ accs[g].add(frame, alpha)
1601
+ finally:
1602
+ mreader.close()
1603
+ finally:
1604
+ reader.close()
1605
+ for g, key in todo.items():
1606
+ plate, note = accs[g].finish(key)
1607
+ if plate is not None:
1608
+ plate.save(cache_dir / f"plate_{key}.npz")
1609
+ plates[g] = plate
1610
+ else:
1611
+ notes[g] = note
1612
+ M.save_json(cache_dir / f"plate_{key}.json", {"key": key, "note": note})
1613
+ if progress:
1614
+ progress(f"tela verde: fundo do cenário aprendido em {time.time() - started:.1f}s")
1615
+ return plates, notes
1616
+
1617
+
1618
+ # ================================================================== the person layer
1619
+
1620
+ @dataclass
1621
+ class Look:
1622
+ W: int
1623
+ H: int
1624
+ outline: tuple[np.ndarray, float] | None # (rgb float, width px at this size)
1625
+ shadow: bool
1626
+ fade: float # px at this size (the top cut)
1627
+ side_fade: float | None = None # px at this size (a side cut; None = fade)
1628
+
1629
+
1630
+ def _edge_profile(edge: np.ndarray) -> np.ndarray:
1631
+ """0..1 along one edge of the camera frame: where the body touches it (widened and softened, so the fade
1632
+ has no steps and covers the whole cut)."""
1633
+ cv2 = _cv2()
1634
+ n = edge.size
1635
+ t = (edge > 127).astype(np.float32).reshape(-1, 1)
1636
+ if not t.any():
1637
+ return np.zeros(n, np.float32)
1638
+ k = max(3, n // 40) | 1
1639
+ t = cv2.dilate(t, np.ones((k, 1), np.uint8))
1640
+ if n > 4:
1641
+ t = cv2.GaussianBlur(t, (1, 0), sigmaX=0.1, sigmaY=k / 6.0)
1642
+ return np.clip(t[:, 0], 0, 1)
1643
+
1644
+
1645
+ def enclosed_gaps(inside: np.ndarray, wall: np.ndarray) -> np.ndarray | None:
1646
+ """0/1 map of the background pockets of `inside` (0/1) that do not reach the open border, or None. wall marks
1647
+ the edges where the body goes on past the camera frame (the bottom, a cut side): a pocket that only reaches
1648
+ the border through them is closed (the gap between an arm and the body, a hole in a hand)."""
1649
+ cv2 = _cv2()
1650
+ free = ((inside == 0) & (wall == 0)).astype(np.uint8)
1651
+ if not free.any():
1652
+ return None
1653
+ n, lab = cv2.connectedComponents(free, connectivity=4)
1654
+ border = np.concatenate([lab[0], lab[-1], lab[:, 0], lab[:, -1]])
1655
+ outside = np.zeros(n, bool)
1656
+ outside[np.unique(border)] = True
1657
+ outside[0] = False
1658
+ if not outside.any():
1659
+ return None # nothing is open (a body filling the frame): leave it as it is
1660
+ holes = (~outside[lab]) & (free > 0)
1661
+ if not holes.any():
1662
+ return None
1663
+ holes = holes.astype(np.uint8)
1664
+ return holes | (wall & (inside == 0) & cv2.dilate(holes, np.ones((3, 3), np.uint8)))
1665
+
1666
+
1667
+ def compose_person(bg: np.ndarray, person: np.ndarray, alpha: np.ndarray, s: float, x0: float, y0: float,
1668
+ look: Look) -> np.ndarray:
1669
+ """Draw the cut-out person (scaled by s, camera frame corner at x0, y0) with outline and shadow onto bg."""
1670
+ cv2 = _cv2()
1671
+ W, H = look.W, look.H
1672
+ ow = look.outline[1] if look.outline else 0.0
1673
+ sh = SHADOW
1674
+ k = W / 1080.0
1675
+ pad = int(math.ceil(ow + (3 * sh["blur"] + max(sh["dx"], sh["dy"])) * k if look.shadow else ow)) + 2
1676
+ lw, lh = int(round(W * s)), int(round(H * s))
1677
+ lx, ly = int(round(x0)), int(round(y0))
1678
+ if lw < 4 or lh < 4:
1679
+ return bg
1680
+ X0, Y0 = max(0, lx - pad), max(0, ly - pad)
1681
+ X1, Y1 = min(W, lx + lw + pad), min(H, ly + lh + pad)
1682
+ if X1 - X0 < 2 or Y1 - Y0 < 2:
1683
+ return bg
1684
+ interp = cv2.INTER_AREA if s < 1 else cv2.INTER_LINEAR
1685
+ # only the part of the camera frame that lands on the screen
1686
+ sx0, sx1 = max(0, X0 - lx), min(lw, X1 - lx)
1687
+ sy0, sy1 = max(0, Y0 - ly), min(lh, Y1 - ly)
1688
+ P = cv2.resize(person, (lw, lh), interpolation=interp)[sy0:sy1, sx0:sx1]
1689
+ A = cv2.resize(alpha, (lw, lh), interpolation=interp)[sy0:sy1, sx0:sx1]
1690
+ rw, rh = X1 - X0, Y1 - Y0
1691
+ ox, oy = lx + sx0 - X0, ly + sy0 - Y0 # where P starts inside the region
1692
+ A_reg = np.zeros((rh, rw), np.uint8)
1693
+ A_reg[oy:oy + A.shape[0], ox:ox + A.shape[1]] = A
1694
+ P_reg = np.zeros((rh, rw, 3), np.uint8)
1695
+ P_reg[oy:oy + P.shape[0], ox:ox + P.shape[1]] = P
1696
+
1697
+ # edges where the camera frame cuts the body inside the screen: left, right, top
1698
+ cuts = {}
1699
+ e = max(2, alpha.shape[1] // 270) # a body within this many px of the edge counts as cut by it
1700
+ if lx > 0 and sx0 == 0:
1701
+ cuts["left"] = _edge_profile(alpha[:, :e].max(axis=1))
1702
+ if lx + lw < W and sx1 == lw:
1703
+ cuts["right"] = _edge_profile(alpha[:, -e:].max(axis=1))
1704
+ if ly > 0 and sy0 == 0:
1705
+ cuts["top"] = _edge_profile(alpha[:e, :].max(axis=0))
1706
+ cuts = {name: prof for name, prof in cuts.items() if prof.any()}
1707
+ for name in list(cuts): # the rows/columns of the edge profile map to the layer, then to the region
1708
+ prof = cuts[name]
1709
+ if name in ("left", "right"):
1710
+ full = np.zeros(rh, np.float32)
1711
+ rows = cv2.resize(prof.reshape(-1, 1), (1, lh), interpolation=cv2.INTER_LINEAR)[:, 0][sy0:sy1]
1712
+ full[oy:oy + rows.size] = rows
1713
+ else:
1714
+ full = np.zeros(rw, np.float32)
1715
+ cols = cv2.resize(prof.reshape(1, -1), (lw, 1), interpolation=cv2.INTER_LINEAR)[0][sx0:sx1]
1716
+ full[ox:ox + cols.size] = cols
1717
+ cuts[name] = full
1718
+
1719
+ # outline and shadow at half size; the matte is carried past the bottom edge, so neither draws a line along it
1720
+ # (past a side or top cut the keep mask below removes them)
1721
+ hw, hh = max(2, rw // 2), max(2, rh // 2)
1722
+ Ah = cv2.resize(A_reg, (hw, hh), interpolation=cv2.INTER_AREA).astype(np.float32) / 255.0
1723
+ hox = int(ox // 2)
1724
+ hx1, hy1 = min(hw, int(math.ceil((ox + A.shape[1]) / 2))), min(hh, int(math.ceil((oy + A.shape[0]) / 2)))
1725
+ if hy1 < hh and ly + lh >= H - 1:
1726
+ Ah[hy1:, hox:hx1] = Ah[hy1 - 1:hy1, hox:hx1]
1727
+ D = None
1728
+ gaps = None
1729
+ if look.outline:
1730
+ oh = ow / 2.0
1731
+ sm = cv2.GaussianBlur(Ah, (0, 0), max(0.6, oh / 2.5))
1732
+ inside = ((sm > 0.5) | (Ah > 0.5)).astype(np.uint8)
1733
+ # the outline goes around the body only: a gap it encloses (with the edges the body goes on past) stays
1734
+ # see-through but gets no outline
1735
+ wall = np.zeros((hh, hw), np.uint8)
1736
+ hoy = int(oy // 2)
1737
+ if hy1 > hoy and hx1 > hox:
1738
+ if ly + lh >= H - 1:
1739
+ wall[hy1 - 1, hox:hx1] = 1
1740
+ if "top" in cuts:
1741
+ wall[hoy, hox:hx1] = 1
1742
+ if "left" in cuts:
1743
+ wall[hoy:hy1, hox] = 1
1744
+ elif lx <= 0:
1745
+ wall[hoy:hy1, 0] = 1
1746
+ if "right" in cuts:
1747
+ wall[hoy:hy1, hx1 - 1] = 1
1748
+ elif lx + lw >= W:
1749
+ wall[hoy:hy1, hw - 1] = 1
1750
+ gaps = enclosed_gaps(inside, wall)
1751
+ if gaps is not None:
1752
+ inside = inside | gaps
1753
+ dist = cv2.distanceTransform(1 - inside, cv2.DIST_L2, cv2.DIST_MASK_PRECISE)
1754
+ Dh = np.clip(oh + 0.5 - dist, 0, 1).astype(np.float32)
1755
+ Dh = cv2.GaussianBlur(Dh, (0, 0), 0.6)
1756
+ D = cv2.resize(Dh, (rw, rh), interpolation=cv2.INTER_LINEAR)
1757
+ S = None
1758
+ if look.shadow:
1759
+ src = Dh if D is not None else Ah
1760
+ q = cv2.resize(src, (max(2, hw // 2), max(2, hh // 2)), interpolation=cv2.INTER_AREA)
1761
+ q = cv2.GaussianBlur(q, (0, 0), max(0.5, sh["blur"] * k / 4.0))
1762
+ S = cv2.resize(q, (rw, rh), interpolation=cv2.INTER_LINEAR)
1763
+ mshift = np.float32([[1, 0, sh["dx"] * k], [0, 1, sh["dy"] * k]])
1764
+ S = cv2.warpAffine(S, mshift, (rw, rh), flags=cv2.INTER_LINEAR, borderMode=cv2.BORDER_REPLICATE)
1765
+ S *= sh["alpha"]
1766
+ if gaps is not None:
1767
+ # an enclosed gap shows the background as it is: no outline color, no shadow in it (2 px wider, so the
1768
+ # soft edge of the body around it blends with the background, not with the outline)
1769
+ g = cv2.resize(cv2.dilate(gaps, np.ones((5, 5), np.uint8)).astype(np.float32), (rw, rh),
1770
+ interpolation=cv2.INTER_LINEAR)
1771
+ gap_keep = 1.0 - g
1772
+ if S is not None:
1773
+ S = S * gap_keep
1774
+
1775
+ # keep mask: short fade on the cut rows near a cut edge, nothing past it
1776
+ K = None
1777
+ if cuts:
1778
+ top_fade = max(2.0, look.fade)
1779
+ side_fade = max(2.0, look.side_fade if look.side_fade is not None else look.fade)
1780
+ K = np.ones((rh, rw), np.float32)
1781
+ xs = np.arange(rw, dtype=np.float32)
1782
+ ys = np.arange(rh, dtype=np.float32)
1783
+
1784
+ def ramp(d: np.ndarray, fade: float) -> np.ndarray:
1785
+ r = np.clip(d / fade, 0, 1)
1786
+ return r * r * (3 - 2 * r)
1787
+ if "left" in cuts:
1788
+ K *= 1.0 - cuts["left"][:, None] * (1.0 - ramp(xs - float(ox), side_fade)[None, :])
1789
+ if "right" in cuts:
1790
+ K *= 1.0 - cuts["right"][:, None] * (1.0 - ramp(float(ox + A.shape[1]) - xs, side_fade)[None, :])
1791
+ if "top" in cuts:
1792
+ K *= 1.0 - cuts["top"][None, :] * (1.0 - ramp(ys - float(oy), top_fade)[:, None])
1793
+
1794
+ region0 = bg[Y0:Y1, X0:X1]
1795
+ region = region0
1796
+ Af = A_reg.astype(np.float32) / 255.0
1797
+ if D is not None:
1798
+ D = np.maximum(D, Af)
1799
+ if gaps is not None:
1800
+ D *= gap_keep
1801
+ if S is not None:
1802
+ region = cv2.blendLinear(region, np.zeros_like(region), 1.0 - S, S)
1803
+ if D is not None:
1804
+ color = np.empty_like(region)
1805
+ color[:] = look.outline[0].astype(np.uint8)
1806
+ region = cv2.blendLinear(region, color, 1.0 - D, D)
1807
+ region = cv2.blendLinear(region, P_reg, 1.0 - Af, Af)
1808
+ if K is not None:
1809
+ # shadow, outline and person fade together (the outline never shows through a fading arm)
1810
+ region = cv2.blendLinear(region0, region, 1.0 - K, K)
1811
+ bg[Y0:Y1, X0:X1] = region
1812
+ return bg
1813
+
1814
+
1815
+ def without_room(plate: RoomPlate, frame: np.ndarray, alpha: np.ndarray) -> tuple[np.ndarray, np.ndarray]:
1816
+ """(frame, matte) of a camera frame with the room its take's plate recognizes taken out of the matte and out
1817
+ of the colors of its soft edge (uint8, camera frame size)."""
1818
+ cv2 = _cv2()
1819
+ h, w = alpha.shape
1820
+ pw, ph = plate.size
1821
+ if (w, h) != (pw, ph):
1822
+ small = cv2.resize(frame, (pw, ph), interpolation=cv2.INTER_AREA)
1823
+ a = cv2.resize(alpha, (pw, ph), interpolation=cv2.INTER_AREA)
1824
+ else:
1825
+ small, a = frame, alpha
1826
+ e = plate.evidence(small, a.astype(np.float32) / 255.0)
1827
+ if e is not None:
1828
+ if (w, h) != (pw, ph):
1829
+ e = cv2.resize(e, (w, h), interpolation=cv2.INTER_LINEAR)
1830
+ alpha = (alpha.astype(np.float32) * (1.0 - e) + 0.5).astype(np.uint8)
1831
+ return plate.clean_edge(frame, alpha), alpha
1832
+
1833
+
1834
+ def zoom_original(frame: np.ndarray, z: float, focus_y: float) -> np.ndarray:
1835
+ """The camera zoom of compose_final (crop centered on x, eye line kept) on a full frame."""
1836
+ if abs(z - 1.0) < 1e-4:
1837
+ return frame
1838
+ cv2 = _cv2()
1839
+ H, W = frame.shape[:2]
1840
+ top = H * focus_y * (1 - 1 / z)
1841
+ left = W * 0.5 * (1 - 1 / z)
1842
+ m = np.array([[z, 0.0, -left * z], [0.0, z, -top * z]], np.float32)
1843
+ return cv2.warpAffine(frame, m, (W, H), flags=cv2.INTER_CUBIC, borderMode=cv2.BORDER_REPLICATE)
1844
+
1845
+
1846
+ # ================================================================== the clip
1847
+
1848
+ @dataclass
1849
+ class Job:
1850
+ cfg: Config
1851
+ segments: list[Segment]
1852
+ mattes: dict[int, "M.RangeJob"] # segment index -> matte job
1853
+ base: Path
1854
+ base_pre: list[str]
1855
+ base_fps: float
1856
+ fps: int
1857
+ size: tuple[int, int] # render size
1858
+ full_size: tuple[int, int] # output size (the mattes are stored at it)
1859
+ nf: int
1860
+ out_dir: Path
1861
+ extra_key: list = field(default_factory=list)
1862
+ plates: dict[int, RoomPlate] = field(default_factory=dict) # segment index -> the room plate of its take
1863
+
1864
+ def key(self) -> str:
1865
+ blob = [TV_VERSION, M.engine_version(), str(self.base), M.file_signature(self.base), self.base_pre,
1866
+ self.fps, self.size, self.full_size, self.nf, self.cfg.position, round(self.cfg.height, 5),
1867
+ self.cfg.outline and [list(self.cfg.outline[0]), self.cfg.outline[1]], self.cfg.shadow,
1868
+ self.cfg.blurred_original, self.cfg.no_face, self.cfg.mirror, self.cfg.runs, EDGE_FADE, SIDE_FADE,
1869
+ [b.signature() for b in self.cfg.backgrounds], LOOP_FADE, KB_HOLD_AFTER, EXTEND, BACKDROP,
1870
+ [[s.f0, s.f1, s.mode, round(s.zoom, 5), round(s.push, 5), round(s.scale, 6), s.flush_left,
1871
+ s.flush_right, round(s.focus_y, 5), s.stats, s.side, s.fill, s.drop] for s in self.segments],
1872
+ {k: j.key for k, j in self.mattes.items()}, {k: p.key for k, p in self.plates.items()},
1873
+ self.extra_key]
1874
+ return hashlib.sha1(json.dumps(blob, sort_keys=True, default=str).encode("utf-8")).hexdigest()[:16]
1875
+
1876
+
1877
+ def _encoder(tmp: Path, W: int, H: int, fps: int, n: int) -> subprocess.Popen:
1878
+ return subprocess.Popen(
1879
+ M._ff() + ["-y", "-f", "rawvideo", "-pix_fmt", "rgb24", "-s", f"{W}x{H}", "-framerate", str(fps), "-i", "-",
1880
+ "-vf", "scale=out_color_matrix=bt709:out_range=tv,format=yuv420p",
1881
+ "-c:v", "libx264", "-preset", "veryfast", "-crf", "12", "-g", str(fps),
1882
+ "-colorspace", "bt709", "-color_primaries", "bt709", "-color_trc", "bt709", "-color_range", "tv",
1883
+ "-frames:v", str(n), "-f", "mp4", str(tmp)],
1884
+ stdin=subprocess.PIPE, stderr=subprocess.PIPE)
1885
+
1886
+
1887
+ def render(job: Job, progress: Callable[[str], None] | None = None, workers: int | None = None) -> dict:
1888
+ """Write the tela verde clip (cached). -> {file, cached, seconds, frames, key}."""
1889
+ started = time.time()
1890
+ W, H = job.size
1891
+ key = job.key()
1892
+ job.out_dir.mkdir(parents=True, exist_ok=True)
1893
+ out = job.out_dir / f"tv_{key}.mp4"
1894
+ meta_path = out.with_suffix(".json")
1895
+ if out.exists() and meta_path.exists():
1896
+ meta = M.load_json(meta_path) or {}
1897
+ if meta.get("key") == key and out.stat().st_size > 0:
1898
+ return {**meta, "file": str(out), "cached": True, "render_seconds": round(time.time() - started, 2)}
1899
+ cfg = job.cfg
1900
+ k = W / 1080.0
1901
+ look = Look(W=W, H=H, shadow=cfg.shadow, fade=EDGE_FADE * k, side_fade=SIDE_FADE * k,
1902
+ outline=(np.array(cfg.outline[0], np.float32), cfg.outline[1] * k) if cfg.outline else None)
1903
+ base_reader = M.FrameReader(M.base_cmd(job.base, job.base_pre, job.base_fps, job.fps, 0, job.nf, size=(W, H),
1904
+ full_size=job.full_size), (W, H), 3, "ler o corte", queue_len=8)
1905
+ bgs = BackgroundTrack(cfg, W, H, job.fps)
1906
+ tmp = out.with_name(".tmp-" + out.name)
1907
+ enc = _encoder(tmp, W, H, job.fps, job.nf)
1908
+ workers = workers or max(2, min(6, (os.cpu_count() or 4) // 2))
1909
+ pool = ThreadPoolExecutor(max_workers=workers)
1910
+ pending: deque = deque()
1911
+ written = 0
1912
+ last_frame = None
1913
+ last_print = time.time()
1914
+
1915
+ def flush(block: bool) -> None:
1916
+ nonlocal written
1917
+ while pending and (block or pending[0].done()):
1918
+ fr = pending.popleft().result()
1919
+ enc.stdin.write(fr.tobytes())
1920
+ written += 1
1921
+
1922
+ def person_frame(make_bg, person, alpha, seg, f, plate):
1923
+ s, x0, y0 = layer_rect(cfg, seg, seg.zoom_at(f), W, H)
1924
+ if plate is not None:
1925
+ person, alpha = without_room(plate, person, alpha)
1926
+ if cfg.mirror:
1927
+ cv2 = _cv2()
1928
+ person, alpha = cv2.flip(person, 1), cv2.flip(alpha, 1)
1929
+ return compose_person(make_bg(), person, alpha, s, x0, y0, look)
1930
+
1931
+ try:
1932
+ f = 0
1933
+ for si, seg in enumerate(job.segments):
1934
+ mreader = None
1935
+ mjob = job.mattes.get(si)
1936
+ plate = job.plates.get(si)
1937
+ if seg.mode == "pessoa" and mjob is not None:
1938
+ mreader = M.FrameReader(M.matte_cmd(mjob.path, (W, H), job.full_size), (W, H), 1, "ler o recorte",
1939
+ queue_len=8)
1940
+ last_alpha = None
1941
+ try:
1942
+ for f in range(seg.f0, seg.f1):
1943
+ frame = base_reader.read()
1944
+ if frame is None:
1945
+ frame = last_frame
1946
+ if frame is None:
1947
+ raise TelaVerdeError("não consegui ler o corte para montar a tela verde", 1)
1948
+ last_frame = frame
1949
+ if seg.mode == "original":
1950
+ z, fy = seg.zoom_at(f), seg.focus_y
1951
+ pending.append(pool.submit(zoom_original, frame, z, fy))
1952
+ else:
1953
+ make_bg = bgs.fetch(f, frame)
1954
+ alpha = mreader.read() if mreader is not None else None
1955
+ if alpha is None:
1956
+ alpha = last_alpha
1957
+ if alpha is None:
1958
+ pending.append(pool.submit(make_bg))
1959
+ else:
1960
+ last_alpha = alpha
1961
+ pending.append(pool.submit(person_frame, make_bg, frame, alpha, seg, f, plate))
1962
+ flush(len(pending) >= 2 * workers)
1963
+ now = time.time()
1964
+ if progress and now - last_print >= M.PROGRESS_EVERY:
1965
+ last_print = now
1966
+ progress(f"tela verde: montando {100.0 * (f + 1) / job.nf:.0f}%")
1967
+ finally:
1968
+ if mreader is not None:
1969
+ mreader.close()
1970
+ flush(True)
1971
+ except BrokenPipeError:
1972
+ pass
1973
+ finally:
1974
+ pool.shutdown(wait=True)
1975
+ base_reader.close()
1976
+ bgs.close()
1977
+ try:
1978
+ enc.stdin.close()
1979
+ except OSError:
1980
+ pass
1981
+ err = enc.stderr.read().decode("utf-8", "replace") if enc.stderr else ""
1982
+ code = enc.wait()
1983
+ if code != 0 or written != job.nf:
1984
+ tmp.unlink(missing_ok=True)
1985
+ raise TelaVerdeError(f"falha ao gravar a tela verde ({written} de {job.nf} quadros): {err.strip()[-300:]}", 1)
1986
+ os.replace(tmp, out)
1987
+ seconds = time.time() - started
1988
+ meta = {"key": key, "frames": job.nf, "size": [W, H], "fps": job.fps, "seconds": round(seconds, 2),
1989
+ "created_at": M.now_iso()}
1990
+ M.save_json(meta_path, meta)
1991
+ return {**meta, "file": str(out), "cached": False, "render_seconds": round(seconds, 2)}
1992
+
1993
+
1994
+ def plan_stages(cfg: Config, segs: list[Segment], W: int, H: int,
1995
+ reserve: Callable[[list[tuple[int, int]]], float] | None = None) -> None:
1996
+ """Place every background shown whole (encaixe inteiro). Its stage is the band from STAGE_TOP to a little
1997
+ above the highest head top of the person segments it is shown with; when auto captions play over it and
1998
+ the picture keeps at least RESERVE_KEEP of the size it would have without them, the stage leaves room for
1999
+ them right above the head. Needs cfg.runs; sets b.place and b.stage (render px)."""
2000
+ gap = STAGE_GAP * H
2001
+ top = STAGE_TOP * H
2002
+ for k, b in enumerate(cfg.backgrounds):
2003
+ b.place, b.stage = None, None
2004
+ if b.fit != "inteiro" or not b.size:
2005
+ continue
2006
+ spans = [(f0, f1) for f0, f1, kk in cfg.runs if kk == k]
2007
+ if not spans:
2008
+ continue
2009
+ heads = []
2010
+ for s in segs:
2011
+ if s.mode != "pessoa" or not s.stats or not s.stats.get("person_frames"):
2012
+ continue
2013
+ if not any(s.f0 < b1 and a0 < s.f1 for a0, b1 in spans):
2014
+ continue
2015
+ z = screen_zones(cfg, s, W, H)
2016
+ if z["person"]:
2017
+ heads.append(z["person"]["y0"] * H)
2018
+ head = min(heads) if heads else H * (1.0 - cfg.height)
2019
+ bottom = max(top + 0.1 * H, head - gap)
2020
+ iw, ih = b.size
2021
+ need = (b.content[3] - b.content[1]) * ih * W / iw # main area height at the full width
2022
+ room = reserve(spans) if reserve else 0.0
2023
+ # a video with a plain border melts into the screen: it stays wide (down to QUIET_MIN, to leave the
2024
+ # captions their band; what still does not fit above the head goes behind the captions and the person)
2025
+ # instead of becoming a small box
2026
+ least = QUIET_MIN if (b.kind == "video" and b.quiet) else WHOLE_MIN
2027
+
2028
+ def size(h: float) -> float:
2029
+ return min(need, max(h, least * need))
2030
+ if room > 0 and size(bottom - room - top) >= RESERVE_KEEP * size(bottom - top):
2031
+ bottom -= room
2032
+ b.stage = (round(top, 1), round(bottom, 1))
2033
+ b.place = whole_place(b.size, b.content, b.stage, W, H, least)
2034
+
2035
+
2036
+ def image_zones(cfg: Config, segs: list[Segment], W: int, H: int) -> list[tuple[int, int, tuple]]:
2037
+ """(f0, f1, rect) where the main area of a whole picture is on the screen (render px), outside the segments
2038
+ shown as recorded (the background is not seen there)."""
2039
+ out = []
2040
+ shown_as_recorded = [(s.f0, s.f1) for s in segs if s.mode == "original"]
2041
+ for f0, f1, k in cfg.runs:
2042
+ if k is None:
2043
+ continue
2044
+ rect = cfg.backgrounds[k].content_rect(W, H)
2045
+ if rect is None:
2046
+ continue
2047
+ pieces = [(f0, f1)]
2048
+ for a, b in shown_as_recorded:
2049
+ nxt = []
2050
+ for p0, p1 in pieces:
2051
+ if b <= p0 or a >= p1:
2052
+ nxt.append((p0, p1))
2053
+ continue
2054
+ if p0 < a:
2055
+ nxt.append((p0, a))
2056
+ if b < p1:
2057
+ nxt.append((b, p1))
2058
+ pieces = nxt
2059
+ out += [(p0, p1, rect) for p0, p1 in pieces if p1 > p0]
2060
+ return out
2061
+
2062
+
2063
+ def report_backgrounds(cfg: Config, fps: int, W: int, H: int, scale_to_out: float,
2064
+ rel: Callable[[Path], str]) -> list[dict]:
2065
+ """The background runs for the report (what shows when, from fundo or from an insert, and where)."""
2066
+ out = []
2067
+ for f0, f1, k in cfg.runs:
2068
+ b = cfg.backgrounds[k] if k is not None else None
2069
+ row = {"start": round(f0 / fps, 3), "end": round(f1 / fps, 3),
2070
+ "src": (rel(b.src) if b and b.src else (b.asked if b else
2071
+ ("original desfocado" if cfg.blurred_original else DARK_DEFAULT))),
2072
+ "item": (b.idx if b and not b.insert else None), "insert": (b.insert if b else None),
2073
+ "kind": b.kind if b else ("original" if cfg.blurred_original else "color"),
2074
+ "encaixe": b.fit if b else None,
2075
+ "ken_burns": b.ken_burns if b else False, "escurecer": b.darken if b else None,
2076
+ "desfocar": b.blur if b else None}
2077
+ if b and b.place:
2078
+ row["rect"] = [int(round(v * scale_to_out)) for v in b.place]
2079
+ row["content"] = [int(round(v * scale_to_out)) for v in b.content_rect(W, H)]
2080
+ if b and b.kind == "video":
2081
+ row["borda_lisa"] = b.quiet
2082
+ row["quadros_vazios"] = {"inicio": b.lead, "fim": b.tail}
2083
+ out.append(row)
2084
+ return out
2085
+
2086
+
2087
+ def report_segments(cfg: Config, segs: list[Segment], fps: int, W: int, H: int, scale_to_out: float) -> list[dict]:
2088
+ out = []
2089
+ for s in segs:
2090
+ row = {"start": round(s.f0 / fps, 3), "end": round(s.f1 / fps, 3), "mode": s.mode}
2091
+ if s.mode == "pessoa" and cfg.mirror:
2092
+ row["espelhado"] = True
2093
+ if s.side:
2094
+ row["posicao"] = s.side
2095
+ if s.fill:
2096
+ row["largura_toda"] = True
2097
+ row["corte_embaixo"] = s.drop
2098
+ if s.mode == "pessoa" and s.stats and s.stats.get("person_frames"):
2099
+ sc, x0, y0 = layer_rect(cfg, s, s.zoom, W, H)
2100
+ lw = W * sc
2101
+ row.update({"scale": round(sc, 4), "x": int(round(x0 * scale_to_out)), "y": int(round(y0 * scale_to_out)),
2102
+ "zoom": s.zoom, "push_in": s.push,
2103
+ "on_screen_edge": [n for n, on in (("esquerda", s.flush_left), ("direita", s.flush_right)) if on],
2104
+ "height_share": round((H - (y0 + float(s.stats.get("top", 0)) * H * sc)) / H, 3),
2105
+ "width_share": round((float(s.stats.get("right", 1)) - float(s.stats.get("left", 0))) * lw / W, 3),
2106
+ "cut_edge_inside": [side for side, cond in (("esquerda", s.stats.get("touch_left", 0) > 0.05 and x0 > 0.5),
2107
+ ("direita", s.stats.get("touch_right", 0) > 0.05 and x0 + lw < W - 0.5))
2108
+ if cond]})
2109
+ if s.note:
2110
+ row["note"] = s.note
2111
+ out.append(row)
2112
+ return out
2113
+