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,433 @@
1
+ """Dry cut by the waveform: split every EDL range at its inner breaths, leaving ~0.12 s at each joint.
2
+
3
+ This is the cut André approved on the DJI reels and the hook/body tests (27-28/09/2026). The agent
4
+ writes rough ranges, one per sentence and always the LAST good take, into `<EDIT>/edl_manual.json`
5
+ (same format as edl.json). This helper measures each source on the waveform (mono RMS in 10 ms
6
+ windows, cached in `<EDIT>/regions/onda_<name>.npy`) and, for every range:
7
+
8
+ 1. starts PAD_S before the first sound that lasts ONSET_S (a mouth click before the sentence does not
9
+ count as its start), then walks back while a weak first consonant still sounds (the S of "Sem"),
10
+ and trims the silence at the end down to PAD_S, however short it is (the written end has 0.1 to
11
+ 0.3 s of slack: left there, it made joints of 0.29 s with the head of the next range);
12
+ 2. splits the range at every pause of at least PAUSE_MIN_S, keeping PAD_S of silence on each side,
13
+ so a joint ends up with about 2 * PAD_S. Shorter pauses stay: splitting a micro-pause inside a
14
+ sentence bites the consonant next to it ("token" came out as "Tolkien");
15
+ 3. walks the end of each piece out, up to TAIL_MAX_S, while a weak final consonant still sounds
16
+ (the S of "demais", the E of "YouTube"), stopping if the sound rises again (click, breath, word)
17
+ or falls TAIL_UNDER_TH_DB under the threshold (on a phone with noise suppression the room decays
18
+ slowly toward -72 dB, and the walk used to go through 0.07 s of that near-silence). When the last
19
+ sentence is glued to the next word ("computador e", no pause between them) the walk stops where the
20
+ next word rises; the end then goes back to the valley between the two words (the lowest 10 ms of the
21
+ walked decay, after the final consonant), and the range gets "fim_no_vale": true. A valley shallower
22
+ than VALLEY_DB is reported: the end may carry the start of the next word;
23
+ 4. drops what is left of a breath or a click: pieces shorter than 0.08 s, and pieces shorter than
24
+ 0.35 s that never get PEAK_OVER_TH_DB above the threshold.
25
+
26
+ Ranges with "dry": false stay whole, with the edges exactly as written (a video playing on screen, sound
27
+ coming out of a phone, the last frames held after the last word). The tail of a range never walks into the
28
+ next range of the same source (a take split in two, or a "dry": false range right after it): that audio would
29
+ play twice.
30
+ Threshold per source: noise floor (10th percentile) + FLOOR_TO_TH_DB, never lower than SPEECH_TO_TH_MAX_DB
31
+ under the loud speech (90th percentile: a phone with noise suppression has a near-digital floor and breaths
32
+ far above it) and at least SPEECH_TO_TH_DB under it. `--limiar` forces one value for every source (the DJI mic without a
33
+ noise gate used -38; a quiet OBS mic needed -44).
34
+
35
+ A manual edge that sits on speech is reported: it may cut a word. Run `bordas_precisas.py --apply`
36
+ on edl_manual.json first, or move the edge to a pause.
37
+
38
+ Camera switch ("imagem_de", see render_cut.py): ranges in a row from the same source that touch (the next start
39
+ within 0.02 s of the end) and change the picture are measured as ONE range on the source's waveform, then split
40
+ at the switch. A switch that falls in a pause of PAUSE_MIN_S or more just goes with the pause that is cut; one in
41
+ the middle of speech keeps both pieces touching, so render_cut plays the voice straight through and only the
42
+ picture changes. A piece shorter than MIN_SWITCH_S on either side of a switch goes to the neighbour (no flash of
43
+ the other camera). A switch on speech is listed, not reported as an edge that may cut a word.
44
+
45
+ Usage:
46
+ uv run python helpers/corte_por_onda.py <edit>/edl_manual.json -o <edit>/edl.json [--limiar -38]
47
+ Then render with render_cut.py as usual.
48
+ """
49
+
50
+ from __future__ import annotations
51
+
52
+ import argparse
53
+ import copy
54
+ import subprocess
55
+ import sys
56
+ from pathlib import Path
57
+
58
+ import numpy as np
59
+
60
+ sys.path.insert(0, str(Path(__file__).resolve().parent))
61
+ from common import ffprobe_json, load_json, require_binary, save_json # noqa: E402
62
+ from icloud import SoNaNuvem, garantir_local # noqa: E402
63
+
64
+ HOP_S = 0.01
65
+ PAUSE_MIN_S = 0.22
66
+ PAD_S = 0.06
67
+ TAIL_MAX_S = 0.20
68
+ FLOOR_TO_TH_DB = 14.0
69
+ SPEECH_TO_TH_DB = 20.0
70
+ SPEECH_TO_TH_MAX_DB = 28.0
71
+ TAIL_FLOOR_DB = 6.0
72
+ TAIL_UNDER_TH_DB = 12.0
73
+ TAIL_RISE_DB = 3.0
74
+ MIN_PIECE_S = 0.08
75
+ WEAK_PIECE_S = 0.35
76
+ PEAK_OVER_TH_DB = 13.0
77
+ ONSET_S = 0.04
78
+ JOIN_S = 0.02
79
+ MIN_SWITCH_S = 0.30
80
+ VALLEY_DB = 6.0 # the valley between two glued words is at least this far under both of them
81
+
82
+
83
+ def envelope(path: Path, cache: Path | None) -> np.ndarray:
84
+ """dB of the mono mix in 10 ms windows, index 0 = time 0 of the file."""
85
+ if cache and cache.is_file() and cache.stat().st_mtime >= path.stat().st_mtime:
86
+ return np.load(cache)
87
+ ffmpeg = require_binary("ffmpeg")
88
+ raw = subprocess.run([ffmpeg, "-v", "error", "-nostdin", "-i", str(path), "-vn", "-ac", "1", "-ar", "48000",
89
+ "-f", "f32le", "-"], capture_output=True).stdout
90
+ x = np.frombuffer(raw, dtype=np.float32).astype(np.float64)
91
+ w = int(48000 * HOP_S)
92
+ n = len(x) // w
93
+ if n == 0:
94
+ raise SystemExit(f"ERRO: {path.name} não tem áudio que dê pra medir")
95
+ db = 10 * np.log10((x[: n * w].reshape(n, w) ** 2).mean(axis=1) + 1e-12)
96
+ offset = audio_offset(path)
97
+ if offset > 0: # audio starts after the video: the first windows are silence
98
+ db = np.concatenate([np.full(int(round(offset / HOP_S)), -120.0), db])
99
+ db = db.astype(np.float32)
100
+ if cache:
101
+ cache.parent.mkdir(parents=True, exist_ok=True)
102
+ np.save(cache, db)
103
+ return db
104
+
105
+
106
+ def audio_offset(path: Path) -> float:
107
+ info = ffprobe_json(path)
108
+ starts = {s.get("codec_type"): float(s.get("start_time") or 0) for s in info.get("streams", [])
109
+ if s.get("codec_type") in ("audio", "video")}
110
+ return max(0.0, starts.get("audio", 0.0) - starts.get("video", 0.0))
111
+
112
+
113
+ def threshold(db: np.ndarray) -> float:
114
+ floor = float(np.percentile(db, 10))
115
+ loud = float(np.percentile(db, 90))
116
+ # a phone with noise suppression leaves the gaps near digital silence (-72 dB) and the breaths at -45:
117
+ # the floor alone would count a breath as speech, so the threshold never goes lower than loud - 28
118
+ return round(min(max(floor + FLOOR_TO_TH_DB, loud - SPEECH_TO_TH_MAX_DB), loud - SPEECH_TO_TH_DB), 1)
119
+
120
+
121
+ class Source:
122
+ def __init__(self, db: np.ndarray, th: float):
123
+ self.db, self.th = db, th
124
+ self.floor = float(np.percentile(db, 10))
125
+ # a weak consonant still sounds above this; below it is the room (or the decay of a noise gate)
126
+ self.weak = max(self.floor + TAIL_FLOOR_DB, th - TAIL_UNDER_TH_DB)
127
+
128
+ def at(self, t: float) -> float:
129
+ # the epsilon: 11.45 + 4 hops is 11.489999..., which read the window before and walked one hop too far
130
+ return float(self.db[min(len(self.db) - 1, max(0, int(t / HOP_S + 1e-6)))])
131
+
132
+ def pauses(self, a: float, b: float) -> list[tuple[float, float]]:
133
+ out, run = [], None
134
+ i0, i1 = int(round(a / HOP_S)), min(len(self.db), int(round(b / HOP_S)))
135
+ for i in range(max(0, i0), i1):
136
+ quiet = self.db[i] < self.th
137
+ if quiet and run is None:
138
+ run = i
139
+ if not quiet and run is not None:
140
+ if (i - run) * HOP_S >= PAUSE_MIN_S:
141
+ out.append((run * HOP_S, i * HOP_S))
142
+ run = None
143
+ if run is not None and (i1 - run) * HOP_S >= PAUSE_MIN_S:
144
+ out.append((run * HOP_S, i1 * HOP_S))
145
+ return out
146
+
147
+ def first_sustained(self, a: float, b: float) -> float | None:
148
+ need = int(round(ONSET_S / HOP_S))
149
+ above = self.db[max(0, int(round(a / HOP_S))): min(len(self.db), int(round(b / HOP_S)))] >= self.th
150
+ run = 0
151
+ for k, up in enumerate(above):
152
+ run = run + 1 if up else 0
153
+ if run >= need:
154
+ return (int(round(a / HOP_S)) + k - need + 1) * HOP_S
155
+ return None
156
+
157
+ def last_sustained_end(self, a: float, b: float) -> float | None:
158
+ """end of the last sound that lasts ONSET_S inside [a, b] (None: no sound)"""
159
+ need = int(round(ONSET_S / HOP_S))
160
+ i0, i1 = max(0, int(round(a / HOP_S))), min(len(self.db), int(round(b / HOP_S)))
161
+ run = 0
162
+ for i in range(i1 - 1, i0 - 1, -1):
163
+ run = run + 1 if self.db[i] >= self.th else 0
164
+ if run >= need:
165
+ return (i + need) * HOP_S
166
+ return None
167
+
168
+ def extend_head(self, x0: float, limit: float) -> float:
169
+ """weak first consonant (the S of "Sem"): walk back while it still sounds, never into a click"""
170
+ x, ref = x0, self.at(x0)
171
+ while x - HOP_S > max(limit, x0 - TAIL_MAX_S):
172
+ v = self.at(x - HOP_S)
173
+ if v <= self.weak or v > ref + TAIL_RISE_DB:
174
+ break
175
+ ref = min(ref, v)
176
+ x -= HOP_S
177
+ return round(x, 3)
178
+
179
+ def extend_tail(self, x1: float, limit: float) -> float:
180
+ return self.extend_tail_why(x1, limit)[0]
181
+
182
+ def extend_tail_why(self, x1: float, limit: float) -> tuple[float, str]:
183
+ """(where the walk stopped, why): "fraco" (the room), "subiu" (a click, a breath or the next word),
184
+ "limite" (TAIL_MAX_S or the next range)"""
185
+ x, ref = x1, self.at(x1)
186
+ while x < min(limit, x1 + TAIL_MAX_S):
187
+ v = self.at(x)
188
+ if v <= self.weak:
189
+ return round(x, 3), "fraco"
190
+ if v > ref + TAIL_RISE_DB:
191
+ return round(x, 3), "subiu"
192
+ ref = min(ref, v)
193
+ x += HOP_S
194
+ return round(x, 3), "limite"
195
+
196
+ def sustained_from(self, t: float, within: float = 0.03) -> bool:
197
+ """a sound of ONSET_S or more (a word, not a click) starts within `within` s after t"""
198
+ onset = self.first_sustained(t, t + within + ONSET_S + HOP_S)
199
+ return onset is not None and onset <= t + within + 1e-6
200
+
201
+ def valley(self, a: float, b: float) -> tuple[float, float] | None:
202
+ """(end time, depth in dB) of the lowest 10 ms of [a, b): the end goes right after that window. The depth is
203
+ how far it is under the louder of the two sides (0.1 s before and 0.1 s after), taking the quieter side."""
204
+ i0 = max(1, int(a / HOP_S + 1e-6))
205
+ i1 = min(len(self.db) - 1, int(b / HOP_S + 1e-6))
206
+ if i1 <= i0:
207
+ i0 = max(1, i1 - 1)
208
+ if i1 <= i0:
209
+ return None
210
+ seg = self.db[i0:i1]
211
+ k = i0 + int(len(seg) - 1 - np.argmin(seg[::-1])) # the lowest; on a tie, the later one
212
+ left = float(self.db[max(0, k - 10):k].max())
213
+ right = float(self.db[k + 1:k + 11].max()) if k + 1 < len(self.db) else float(self.db[k])
214
+ return round((k + 1) * HOP_S, 3), round(min(left, right) - float(self.db[k]), 1)
215
+
216
+ def peak(self, a: float, b: float) -> float:
217
+ seg = self.db[max(0, int(a / HOP_S)): int(b / HOP_S)]
218
+ return float(seg.max()) if len(seg) else -120.0
219
+
220
+
221
+ def next_in_source(ranges: list[dict], i: int) -> float | None:
222
+ """Earliest start of another range of the same source that begins at or after the end of range i."""
223
+ r = ranges[i]
224
+ end = float(r["end"])
225
+ starts = [float(o["start"]) for k, o in enumerate(ranges)
226
+ if k != i and o.get("source") == r.get("source") and float(o["start"]) >= end - 0.05]
227
+ return min(starts) if starts else None
228
+
229
+
230
+ def split_range(r: dict, src: Source, limit: float | None = None, notes: list | None = None) -> list[dict]:
231
+ """limit: the tail never goes past it (the start of the next range of the same source). notes: the end glued to
232
+ the next word is reported there."""
233
+ a, b = float(r["start"]), float(r["end"])
234
+ onset = src.first_sustained(a, b)
235
+ if onset is not None and onset - PAD_S > a:
236
+ a = src.extend_head(round(onset - PAD_S, 3), a)
237
+ cur, pieces = a, []
238
+ for s0, s1 in src.pauses(a, b):
239
+ if s0 <= a + 0.02: # silence at the start: trim it
240
+ cur = max(cur, s1 - PAD_S)
241
+ continue
242
+ if s1 >= b - 0.02: # silence at the end: trim it
243
+ b = min(b, s0 + PAD_S)
244
+ break
245
+ pieces.append((cur, s0 + PAD_S))
246
+ cur = s1 - PAD_S
247
+ last = src.last_sustained_end(cur, b) # a trailing silence shorter than a pause is trimmed too
248
+ if last is not None and b - last > PAD_S + 0.02:
249
+ b = round(last + PAD_S, 3)
250
+ pieces.append((cur, b))
251
+ out = []
252
+ for k, (x0, x1) in enumerate(pieces):
253
+ last_piece = k + 1 == len(pieces)
254
+ nxt = pieces[k + 1][0] if not last_piece else x1 + TAIL_MAX_S
255
+ if limit is not None and last_piece:
256
+ nxt = min(nxt, max(x1, limit))
257
+ walked, why = src.extend_tail_why(x1, nxt)
258
+ walked = min(walked, max(x1, round(nxt, 3))) # the 10 ms walk may step past the limit
259
+ glued = None
260
+ if last_piece and why == "subiu" and src.sustained_from(walked):
261
+ # the sentence ends glued to the next word: stop in the valley between them, after the final consonant
262
+ # (the walked decay only goes down, so its lowest point is past the consonant and before the next word)
263
+ v = src.valley(x1, walked)
264
+ if v is not None and x0 + MIN_PIECE_S < v[0] <= walked + 1e-6:
265
+ walked, glued = v[0], v[1]
266
+ x1 = walked
267
+ if x1 - x0 < MIN_PIECE_S:
268
+ continue
269
+ if x1 - x0 < WEAK_PIECE_S and src.peak(x0, x1) < src.th + PEAK_OVER_TH_DB:
270
+ continue
271
+ n = copy.deepcopy(r)
272
+ n.update(start=round(x0, 3), end=round(x1, 3), reason=(r.get("reason") or "").strip() + " + corte por onda")
273
+ if glued is not None:
274
+ n["fim_no_vale"] = True
275
+ quem = f"{r.get('id')} ({(r.get('quote') or '')[:40]!r})"
276
+ if notes is not None and glued >= VALLEY_DB:
277
+ notes.append(f"{quem}: o fim estava colado na palavra seguinte; parou no vale entre as duas em {x1:.2f} s "
278
+ f"({glued:.0f} dB abaixo delas), depois da consoante final. O verify_cut ainda marca "
279
+ f"EDGE-IN-SPEECH nessa borda (a palavra seguinte começa logo depois): é esperado, o fade de "
280
+ f"30 ms do render cobre; confira ouvindo")
281
+ elif notes is not None:
282
+ notes.append(f"{quem}: o fim está colado na palavra seguinte e não há vale entre as duas (só {glued:.0f} "
283
+ f"dB): ficou em {x1:.2f} s, no ponto mais baixo, e pode levar o começo da palavra seguinte. "
284
+ f"Use outra tomada, ou termine o trecho uma palavra depois")
285
+ out.append(n)
286
+ return out
287
+
288
+
289
+ def picture_of(r: dict) -> str:
290
+ return r.get("imagem_de") or r.get("source")
291
+
292
+
293
+ def switch_groups(ranges: list[dict]) -> list[list[int]]:
294
+ """Indices of the ranges, grouped where a camera switch continues the same speech (same source, touching,
295
+ picture changes, none of them "dry": false)."""
296
+ groups: list[list[int]] = []
297
+ for i, r in enumerate(ranges):
298
+ if groups:
299
+ prev = ranges[groups[-1][-1]]
300
+ if (prev.get("source") == r.get("source") and prev.get("dry", True) and r.get("dry", True)
301
+ and abs(float(r["start"]) - float(prev["end"])) <= JOIN_S and picture_of(prev) != picture_of(r)):
302
+ groups[-1].append(i)
303
+ continue
304
+ groups.append([i])
305
+ return groups
306
+
307
+
308
+ def split_at_switches(pieces: list[dict], group: list[dict]) -> list[dict]:
309
+ """Pieces of a merged range, back to the range each part belongs to, split at the switch times."""
310
+ seams = [float(r["start"]) for r in group[1:]]
311
+ out = []
312
+ for piece in pieces:
313
+ x0, x1 = float(piece["start"]), float(piece["end"])
314
+ owner = sum(1 for t in seams if t <= x0 + 1e-9)
315
+ parts = []
316
+ a = x0
317
+ for k, t in enumerate(seams):
318
+ if x0 < t < x1:
319
+ parts.append([a, t, owner])
320
+ a, owner = t, k + 1
321
+ parts.append([a, x1, owner])
322
+ while len(parts) > 1:
323
+ short = min(range(len(parts)), key=lambda j: parts[j][1] - parts[j][0])
324
+ if parts[short][1] - parts[short][0] >= MIN_SWITCH_S:
325
+ break
326
+ if short == 0:
327
+ parts[1][0] = parts[0][0]
328
+ elif short == len(parts) - 1:
329
+ parts[-2][1] = parts[-1][1]
330
+ else:
331
+ left, right = parts[short - 1], parts[short + 1]
332
+ if left[1] - left[0] >= right[1] - right[0]:
333
+ left[1] = parts[short][1]
334
+ else:
335
+ right[0] = parts[short][0]
336
+ del parts[short]
337
+ for x, y, k in parts:
338
+ n = copy.deepcopy(group[k])
339
+ n.update(start=round(x, 3), end=round(y, 3),
340
+ reason=(group[k].get("reason") or "").strip() + " + corte por onda")
341
+ out.append(n)
342
+ return out
343
+
344
+
345
+ def main(argv: list[str] | None = None) -> int:
346
+ ap = argparse.ArgumentParser(description="Corte seco pela onda: parte cada trecho nos respiros de dentro.")
347
+ ap.add_argument("edl_manual", type=Path, help="EDL com um trecho por frase (a última tomada boa)")
348
+ ap.add_argument("-o", "--saida", type=Path, help="EDL cortado (padrão: edl.json na mesma pasta)")
349
+ ap.add_argument("--limiar", type=float, help="dB abaixo do qual é pausa, pra todas as fontes (padrão: medido)")
350
+ args = ap.parse_args(argv)
351
+
352
+ edl = load_json(args.edl_manual)
353
+ if not edl or not edl.get("ranges"):
354
+ print(f"ERRO: {args.edl_manual} não tem trechos", file=sys.stderr)
355
+ return 2
356
+ saida = args.saida or args.edl_manual.with_name("edl.json")
357
+ if saida.resolve() == args.edl_manual.resolve():
358
+ print("ERRO: a saída não pode ser o próprio edl_manual.json", file=sys.stderr)
359
+ return 2
360
+ edit = args.edl_manual.resolve().parent
361
+ try:
362
+ garantir_local([Path(p) for p in edl.get("sources", {}).values()])
363
+ except SoNaNuvem as exc:
364
+ print(f"ERRO: {exc}", file=sys.stderr)
365
+ return 2
366
+ sources: dict[str, Source] = {}
367
+ for name, p in edl.get("sources", {}).items():
368
+ path = Path(p)
369
+ if not path.is_file():
370
+ print(f"ERRO: a fonte {name} não existe: {path}", file=sys.stderr)
371
+ return 2
372
+ db = envelope(path, edit / "regions" / f"onda_{name}.npy")
373
+ th = args.limiar if args.limiar is not None else threshold(db)
374
+ sources[name] = Source(db, th)
375
+ print(f"{name}: chão {sources[name].floor:.0f} dB, fala forte {np.percentile(db, 90):.0f} dB, "
376
+ f"pausa abaixo de {th:.0f} dB")
377
+
378
+ new, avisos, inteiros, trocas, vales = [], [], [], [], []
379
+ for r in edl["ranges"]:
380
+ if r.get("source") not in sources:
381
+ print(f"ERRO: o trecho {r.get('id')} usa a fonte {r.get('source')!r}, que não está em sources",
382
+ file=sys.stderr)
383
+ return 2
384
+ if r.get("imagem_de") and r.get("imagem_de") not in sources:
385
+ print(f"ERRO: o trecho {r.get('id')} tira a imagem de {r.get('imagem_de')!r}, que não está em sources",
386
+ file=sys.stderr)
387
+ return 2
388
+ for group in switch_groups(edl["ranges"]):
389
+ rs = [edl["ranges"][i] for i in group]
390
+ src = sources[rs[0]["source"]]
391
+ for k, r in enumerate(rs):
392
+ sides = (("começo", r["start"], k > 0), ("fim", r["end"], k < len(rs) - 1))
393
+ for lado, t, troca in sides:
394
+ if troca:
395
+ if lado == "começo" and src.at(t) >= src.th:
396
+ trocas.append(f"{rs[k - 1].get('id')} -> {r.get('id')} em {float(t):.2f} s: a imagem passa "
397
+ f"de {picture_of(rs[k - 1])} para {picture_of(r)} no meio da fala, a voz segue")
398
+ continue
399
+ if src.at(t) >= src.th + 6:
400
+ avisos.append((r.get("id"), lado, f"{r.get('id')} ({r.get('quote', '')[:40]!r}): o {lado} em "
401
+ f"{t:.2f} s está em cima de fala; pode cortar palavra"))
402
+ r = rs[0]
403
+ if not r.get("dry", True):
404
+ new.append(copy.deepcopy(r))
405
+ inteiros.append(f"{r.get('id')} (\"dry\": false): {float(r['start']):.2f} a {float(r['end']):.2f} s, "
406
+ "bordas como escritas")
407
+ continue
408
+ limit = next_in_source(edl["ranges"], group[-1])
409
+ if len(rs) == 1:
410
+ new.extend(split_range(r, src, limit, vales))
411
+ continue
412
+ merged = dict(copy.deepcopy(r), end=rs[-1]["end"])
413
+ new.extend(split_at_switches(split_range(merged, src, limit, vales), rs))
414
+
415
+ no_vale = {n.get("id") for n in new if n.get("fim_no_vale")} # the valley note says it better
416
+ avisos = [txt for rid, lado, txt in avisos if not (lado == "fim" and rid in no_vale)]
417
+ for i, n in enumerate(new):
418
+ n["id"] = f"r{i + 1:03d}"
419
+ edl = dict(edl, ranges=new)
420
+ save_json(saida, edl)
421
+ total = sum(n["end"] - n["start"] for n in new)
422
+ print(f"{len(new)} trechos, {total:.2f} s → {saida}")
423
+ for t in inteiros:
424
+ print(" inteiro: " + t)
425
+ for t in trocas:
426
+ print(" troca de câmera: " + t)
427
+ for a in avisos + vales:
428
+ print(" aviso: " + a)
429
+ return 0
430
+
431
+
432
+ if __name__ == "__main__":
433
+ raise SystemExit(main())