primocode 10.16.0 → 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 (234) 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 +11 -2
  12. package/editor/LICENSE.md +11 -0
  13. package/editor/README.md +321 -0
  14. package/editor/SKILL.md +307 -0
  15. package/editor/THIRD_PARTY_NOTICES.md +228 -0
  16. package/editor/assets/baixar.json +591 -0
  17. package/editor/assets/fonts/anton/OFL.txt +93 -0
  18. package/editor/assets/fonts/caveat/OFL.txt +93 -0
  19. package/editor/assets/fonts/figtree/OFL.txt +93 -0
  20. package/editor/assets/fonts/fonts.json +10 -0
  21. package/editor/assets/fonts/montserrat/OFL.txt +93 -0
  22. package/editor/assets/fonts/permanentmarker/LICENSE.txt +202 -0
  23. package/editor/assets/models/SELFIE_MULTICLASS_LICENSE.txt +227 -0
  24. package/editor/assets/models/YUNET_LICENSE.txt +21 -0
  25. package/editor/assets/sfx/licencas/bass_drop.txt +32 -0
  26. package/editor/assets/sfx/licencas/cash.txt +76 -0
  27. package/editor/assets/sfx/licencas/charge.txt +31 -0
  28. package/editor/assets/sfx/licencas/click.txt +46 -0
  29. package/editor/assets/sfx/licencas/coin.txt +50 -0
  30. package/editor/assets/sfx/licencas/count.txt +47 -0
  31. package/editor/assets/sfx/licencas/data.txt +31 -0
  32. package/editor/assets/sfx/licencas/error.txt +32 -0
  33. package/editor/assets/sfx/licencas/flash.txt +31 -0
  34. package/editor/assets/sfx/licencas/gears.txt +82 -0
  35. package/editor/assets/sfx/licencas/keyboard.txt +48 -0
  36. package/editor/assets/sfx/licencas/metal_hit.txt +62 -0
  37. package/editor/assets/sfx/licencas/metal_riser.txt +61 -0
  38. package/editor/assets/sfx/licencas/mouse_click.txt +50 -0
  39. package/editor/assets/sfx/licencas/notification.txt +54 -0
  40. package/editor/assets/sfx/licencas/pop.txt +38 -0
  41. package/editor/assets/sfx/licencas/rewind.txt +32 -0
  42. package/editor/assets/sfx/licencas/rise.txt +46 -0
  43. package/editor/assets/sfx/licencas/sinteticos.txt +21 -0
  44. package/editor/assets/sfx/licencas/snap.txt +50 -0
  45. package/editor/assets/sfx/licencas/success.txt +40 -0
  46. package/editor/assets/sfx/licencas/synth_hit.txt +32 -0
  47. package/editor/assets/sfx/licencas/tick.txt +14 -0
  48. package/editor/assets/sfx/licencas/whoosh.txt +47 -0
  49. package/editor/assets/sfx/licencas/wind.txt +31 -0
  50. package/editor/assets/sfx/sfx.json +832 -0
  51. package/editor/assets/sfx/sfx_sinteticos.json +317 -0
  52. package/editor/assets/sfx/sfx_woven.json +387 -0
  53. package/editor/assets/sfx/woven/LICENCA.md +16 -0
  54. package/editor/fichas/briefing-do-video.md +45 -0
  55. package/editor/fichas/conferencia-da-amostra.md +56 -0
  56. package/editor/fichas/matriz-de-ganchos.csv +3 -0
  57. package/editor/fichas/padrao.json +16 -0
  58. package/editor/fichas/planilha-de-cenas.csv +4 -0
  59. package/editor/fichas/planilha-de-versoes.csv +4 -0
  60. package/editor/fichas/plano-em-blocos.md +32 -0
  61. package/editor/fichas/registro-de-materiais.json +27 -0
  62. package/editor/helpers/assets_ocr/ocr_vision.swift +41 -0
  63. package/editor/helpers/bordas_precisas.py +272 -0
  64. package/editor/helpers/camera_viva.py +426 -0
  65. package/editor/helpers/captions.py +601 -0
  66. package/editor/helpers/common.py +308 -0
  67. package/editor/helpers/compose_final.py +3555 -0
  68. package/editor/helpers/conferir_corte.py +234 -0
  69. package/editor/helpers/conferir_dados.py +405 -0
  70. package/editor/helpers/contact_sheet.py +282 -0
  71. package/editor/helpers/corte_por_onda.py +433 -0
  72. package/editor/helpers/corte_seco.py +595 -0
  73. package/editor/helpers/denoise.py +971 -0
  74. package/editor/helpers/efeitos.py +408 -0
  75. package/editor/helpers/efeitos_gerar.py +159 -0
  76. package/editor/helpers/elevenlabs_api.py +442 -0
  77. package/editor/helpers/face_layout.py +570 -0
  78. package/editor/helpers/fetch_images.py +423 -0
  79. package/editor/helpers/ganho_por_trecho.py +214 -0
  80. package/editor/helpers/gen_image.py +312 -0
  81. package/editor/helpers/gen_music.py +316 -0
  82. package/editor/helpers/gen_sfx.py +148 -0
  83. package/editor/helpers/grade.py +307 -0
  84. package/editor/helpers/icloud.py +119 -0
  85. package/editor/helpers/make_sfx.py +940 -0
  86. package/editor/helpers/matte.py +1342 -0
  87. package/editor/helpers/medir_referencia.py +2232 -0
  88. package/editor/helpers/mixar_trilha.py +354 -0
  89. package/editor/helpers/montar_corte.py +295 -0
  90. package/editor/helpers/motion/__init__.py +13 -0
  91. package/editor/helpers/motion/engine.py +1915 -0
  92. package/editor/helpers/motion/registry.py +510 -0
  93. package/editor/helpers/motion/render.py +665 -0
  94. package/editor/helpers/motion/templates/barra_progresso.py +370 -0
  95. package/editor/helpers/motion/templates/carimbo.py +132 -0
  96. package/editor/helpers/motion/templates/checklist.py +271 -0
  97. package/editor/helpers/motion/templates/comentario_cta.py +380 -0
  98. package/editor/helpers/motion/templates/contador.py +222 -0
  99. package/editor/helpers/motion/templates/destaque.py +263 -0
  100. package/editor/helpers/motion/templates/etiqueta_preco.py +461 -0
  101. package/editor/helpers/motion/templates/grafico_barras.py +510 -0
  102. package/editor/helpers/motion/templates/grafico_linha.py +462 -0
  103. package/editor/helpers/motion/templates/notificacao.py +266 -0
  104. package/editor/helpers/motion/templates/palavra_impacto.py +410 -0
  105. package/editor/helpers/motion/templates/passos.py +321 -0
  106. package/editor/helpers/motion/templates/riscado.py +298 -0
  107. package/editor/helpers/motion/templates/selo_garantia.py +150 -0
  108. package/editor/helpers/motion_render.py +241 -0
  109. package/editor/helpers/motion_sugerir.py +961 -0
  110. package/editor/helpers/noise_check.py +134 -0
  111. package/editor/helpers/pack_transcripts.py +223 -0
  112. package/editor/helpers/palavras_na_onda.py +176 -0
  113. package/editor/helpers/plano_sfx.py +839 -0
  114. package/editor/helpers/portao.py +427 -0
  115. package/editor/helpers/preview_server.py +1177 -0
  116. package/editor/helpers/rastrear_rosto.py +298 -0
  117. package/editor/helpers/render_cut.py +1256 -0
  118. package/editor/helpers/respiros.py +89 -0
  119. package/editor/helpers/sincronizar_cameras.py +571 -0
  120. package/editor/helpers/speech_regions.py +423 -0
  121. package/editor/helpers/style_catalog.json +231 -0
  122. package/editor/helpers/style_samples.py +246 -0
  123. package/editor/helpers/tela_verde.py +2113 -0
  124. package/editor/helpers/text_render.py +2059 -0
  125. package/editor/helpers/texto_na_tela.py +366 -0
  126. package/editor/helpers/timeline_view.py +292 -0
  127. package/editor/helpers/tira_de_quadros.py +113 -0
  128. package/editor/helpers/transcribe.py +734 -0
  129. package/editor/helpers/transcribe_batch.py +129 -0
  130. package/editor/helpers/trilhas.py +1146 -0
  131. package/editor/helpers/trilhas_humores.json +66 -0
  132. package/editor/helpers/valores.py +187 -0
  133. package/editor/helpers/verify_cut.py +820 -0
  134. package/editor/helpers/voice_levels.py +233 -0
  135. package/editor/helpers/voz_check.py +386 -0
  136. package/editor/helpers/voz_limpa.py +81 -0
  137. package/editor/helpers/watch_preview.py +372 -0
  138. package/editor/modelo-projeto/LEIA.md +402 -0
  139. package/editor/modelo-projeto/assets/fontes/OFL-Caveat.txt +93 -0
  140. package/editor/modelo-projeto/assets/fontes/OFL-JetBrainsMono.txt +93 -0
  141. package/editor/modelo-projeto/assets/fontes/OFL-Montserrat.txt +93 -0
  142. package/editor/modelo-projeto/assets/fontes/larguras.json +1 -0
  143. package/editor/modelo-projeto/assets/three/BufferGeometryUtils.js +1438 -0
  144. package/editor/modelo-projeto/assets/three/GLTFLoader.js +4895 -0
  145. package/editor/modelo-projeto/assets/three/LICENSE +21 -0
  146. package/editor/modelo-projeto/assets/three/ORIGEM.txt +17 -0
  147. package/editor/modelo-projeto/assets/three/RoomEnvironment.js +185 -0
  148. package/editor/modelo-projeto/assets/three/three.core.min.js +6 -0
  149. package/editor/modelo-projeto/assets/three/three.module.min.js +6 -0
  150. package/editor/modelo-projeto/build.py +4828 -0
  151. package/editor/modelo-projeto/compositions/cenas/2d/k2d_antes_depois.html +199 -0
  152. package/editor/modelo-projeto/compositions/cenas/2d/k2d_arquivo.html +232 -0
  153. package/editor/modelo-projeto/compositions/cenas/2d/k2d_busca.html +315 -0
  154. package/editor/modelo-projeto/compositions/cenas/2d/k2d_comando.html +304 -0
  155. package/editor/modelo-projeto/compositions/cenas/2d/k2d_conversa.html +217 -0
  156. package/editor/modelo-projeto/compositions/cenas/2d/k2d_esquema.html +289 -0
  157. package/editor/modelo-projeto/compositions/cenas/2d/k2d_medidor.html +249 -0
  158. package/editor/modelo-projeto/compositions/cenas/2d/k2d_notas.html +233 -0
  159. package/editor/modelo-projeto/compositions/cenas/3d/k3d_caminho.html +197 -0
  160. package/editor/modelo-projeto/compositions/cenas/3d/k3d_explodida.html +152 -0
  161. package/editor/modelo-projeto/compositions/cenas/3d/k3d_tela.html +167 -0
  162. package/editor/modelo-projeto/compositions/cenas/3d/k3d_volume.html +124 -0
  163. package/editor/modelo-projeto/compositions/cenas/3d/motor-three.js +728 -0
  164. package/editor/modelo-projeto/compositions/cenas/LEIA.md +177 -0
  165. package/editor/modelo-projeto/ferramentas/medir_fontes.py +65 -0
  166. package/editor/modelo-projeto/ferramentas/pessoa_recortada.py +176 -0
  167. package/editor/modelo-projeto/ferramentas/rastrear_maos.py +189 -0
  168. package/editor/modelo-projeto/hyperframes.json +12 -0
  169. package/editor/modelo-projeto/package.json +11 -0
  170. package/editor/modelo-projeto/projeto.json +16 -0
  171. package/editor/preview/app.css +1009 -0
  172. package/editor/preview/app.js +2122 -0
  173. package/editor/preview/index.html +307 -0
  174. package/editor/pyproject.toml +23 -0
  175. package/editor/references/3d.md +236 -0
  176. package/editor/references/animacao-catalogo.md +672 -0
  177. package/editor/references/animacao.md +498 -0
  178. package/editor/references/comandos.md +92 -0
  179. package/editor/references/corte-seco-como-funciona.md +50 -0
  180. package/editor/references/corte-seco.md +88 -0
  181. package/editor/references/corte.md +290 -0
  182. package/editor/references/direcao-por-blocos.md +218 -0
  183. package/editor/references/enquadramento.md +654 -0
  184. package/editor/references/estilos-prontos.md +355 -0
  185. package/editor/references/estudio.md +100 -0
  186. package/editor/references/exemplo-eventos.json +67 -0
  187. package/editor/references/lettering.md +252 -0
  188. package/editor/references/modelos.md +494 -0
  189. package/editor/references/phase2.md +1007 -0
  190. package/editor/references/referencia-aplicar.md +203 -0
  191. package/editor/references/referencia-medir.md +375 -0
  192. package/editor/references/revisao.md +335 -0
  193. package/editor/references/som-e-trilha.md +146 -0
  194. package/editor/references/tabela-de-sons.md +130 -0
  195. package/editor/references/variacoes.md +234 -0
  196. package/editor/uv.lock +1141 -0
  197. package/lib/act.js +36 -8
  198. package/lib/catalogo.js +4 -0
  199. package/lib/chamada-torta.js +81 -0
  200. package/lib/editor-pro.js +786 -0
  201. package/lib/entrega.js +58 -0
  202. package/lib/estudio/conferencia-visual.js +76 -0
  203. package/lib/estudio/diretor.js +6 -1
  204. package/lib/estudio/exportar.js +3 -1
  205. package/lib/estudio/gosto.js +45 -4
  206. package/lib/estudio/index.js +8 -5
  207. package/lib/estudio/kit-ae/base.jsx +119 -13
  208. package/lib/estudio/kit-ae/cenas.jsx +47 -15
  209. package/lib/estudio/kit-ae/guarda.jsx +274 -0
  210. package/lib/estudio/kit-ae/primitivas.jsx +180 -24
  211. package/lib/estudio/maquina-diretor.js +61 -7
  212. package/lib/estudio/maquina-foto.js +177 -0
  213. package/lib/estudio/maquina-guarda.js +207 -0
  214. package/lib/estudio/maquina-layout.js +22 -1
  215. package/lib/estudio/maquina.js +60 -11
  216. package/lib/estudio/referencias.js +1 -1
  217. package/lib/estudio/visao/visao.swift +97 -0
  218. package/lib/estudio/visao.js +60 -0
  219. package/lib/local/cerebro.json +192 -6
  220. package/lib/local/motor.js +5 -2
  221. package/lib/nuvem.js +11 -1
  222. package/lib/passagem.js +50 -0
  223. package/lib/perigo.js +19 -11
  224. package/lib/protecao.js +134 -0
  225. package/lib/titulo-conversa.js +46 -0
  226. package/lib/tools.js +3 -0
  227. package/lib/ui.js +1 -1
  228. package/package.json +3 -2
  229. package/skills/editar-meu-video/SKILL.md +6 -2
  230. package/skills/editar-video/SKILL.md +113 -0
  231. package/studio/pessoa.py +52 -0
  232. package/voz/primo_voz/app.py +43 -4
  233. package/voz/primo_voz/chat.html +17 -6
  234. package/voz/primo_voz/painel_dados.py +134 -0
@@ -0,0 +1,423 @@
1
+ """Acoustic speech/silence intervals of a video's audio (Contract 3).
2
+
3
+ Speech = complement of the silences found by ffmpeg `silencedetect`. This is the
4
+ source of truth for cut EDGES: Whisper word times drift, these do not.
5
+
6
+ Phone recordings carry room noise, so a fixed threshold is wrong for most files.
7
+ `--noise auto` (default) measures the audio in 50 ms windows, finds the noise
8
+ floor, and sets the threshold just above the PEAKS of that noise. silencedetect
9
+ compares every single sample against the threshold, so it has to clear the
10
+ noise peaks, not the noise RMS. The threshold is also kept a safe distance below
11
+ the typical speech level.
12
+
13
+ This module also holds the audio utilities shared by transcribe.py and
14
+ voice_levels.py (decode, channel choice, noise estimate, silencedetect).
15
+
16
+ Usage:
17
+ uv run python helpers/speech_regions.py <video> [--edit-dir DIR] [--noise auto|-35]
18
+ [--min-silence 0.25] [--start S --end E] [--json]
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ import argparse
24
+ import re
25
+ import subprocess
26
+ import sys
27
+ from pathlib import Path
28
+
29
+ import numpy as np
30
+
31
+ from common import file_signature, load_json, probe_video, require_binary, save_json
32
+
33
+ SAMPLE_RATE = 16000
34
+ WINDOW_S = 0.05 # analysis window for the noise estimate
35
+ MIN_SPEECH_S = 0.06 # speech islands shorter than this (clicks, taps) become silence
36
+ DEFAULT_NOISE_DB = -35.0 # used only when the audio is too short to measure
37
+ SILENT_CHANNEL_DB = 20.0 # a channel this far below the loudest one counts as mute
38
+ PHASE_DROP_DB = 6.0 # mix this far below the best channel = phase cancellation
39
+
40
+
41
+ class AudioError(RuntimeError):
42
+ """Readable (PT-BR) problem with a file's audio."""
43
+
44
+
45
+ # ---------------------------------------------------------------- decoding
46
+
47
+ def probe_media(path: Path | str) -> dict:
48
+ """common.probe_video with a readable error for files ffprobe cannot open."""
49
+ require_binary("ffprobe")
50
+ try:
51
+ return probe_video(path)
52
+ except (RuntimeError, ValueError) as exc:
53
+ raise AudioError(f"não consegui ler o arquivo (é um vídeo válido?): {path}") from exc
54
+
55
+
56
+ def decode_audio(path: Path | str, sr: int = SAMPLE_RATE, start: float | None = None,
57
+ end: float | None = None, af: str | None = None) -> np.ndarray:
58
+ """Decode the first audio track to int16, shape (samples, channels)."""
59
+ require_binary("ffmpeg")
60
+ info = probe_media(path)
61
+ if not info.get("has_audio"):
62
+ raise AudioError(f"o arquivo não tem faixa de áudio: {path}")
63
+ channels = max(1, int(info.get("audio_channels") or 1))
64
+ cmd = ["ffmpeg", "-v", "error", "-nostdin"]
65
+ if start is not None and start > 0:
66
+ cmd += ["-ss", f"{start:.3f}"]
67
+ cmd += ["-i", str(path)]
68
+ if end is not None:
69
+ dur = end - (start or 0.0)
70
+ if dur <= 0:
71
+ raise AudioError(f"trecho vazio: início {start} e fim {end}")
72
+ cmd += ["-t", f"{dur:.3f}"]
73
+ cmd += ["-map", "0:a:0", "-vn", "-ac", str(channels), "-ar", str(sr)]
74
+ if af:
75
+ cmd += ["-af", af]
76
+ cmd += ["-f", "s16le", "-acodec", "pcm_s16le", "-"]
77
+ proc = subprocess.run(cmd, capture_output=True)
78
+ if proc.returncode != 0:
79
+ tail = proc.stderr.decode("utf-8", "replace")[-800:]
80
+ raise AudioError(f"ffmpeg não conseguiu ler o áudio de {path}:\n{tail}")
81
+ data = np.frombuffer(proc.stdout, dtype=np.int16)
82
+ usable = (len(data) // channels) * channels
83
+ return data[:usable].reshape(-1, channels)
84
+
85
+
86
+ def _rms_db(x: np.ndarray) -> float:
87
+ if x.size == 0:
88
+ return -120.0
89
+ xf = x.astype(np.float64) / 32768.0
90
+ return float(10.0 * np.log10(np.mean(xf * xf) + 1e-12))
91
+
92
+
93
+ def choose_mono(data: np.ndarray) -> tuple[np.ndarray, dict]:
94
+ """Pick how to fold the channels to mono.
95
+
96
+ Measures each channel and the mix of the audible ones. A mute channel is left
97
+ out; if the mix is more than 6 dB below the best channel (one mic in inverted
98
+ phase cancels the other), only the best channel is used.
99
+ Returns float32 mono in [-1, 1] and a description dict.
100
+ """
101
+ n_ch = data.shape[1] if data.ndim == 2 else 1
102
+ if n_ch == 1:
103
+ mono = data.reshape(-1).astype(np.float32) / 32768.0
104
+ return mono, {"channels": 1, "used": "mono", "levels_db": [round(_rms_db(data), 1)]}
105
+ levels = [_rms_db(data[:, c]) for c in range(n_ch)]
106
+ best = int(np.argmax(levels))
107
+ names = ["left", "right"] if n_ch == 2 else [f"ch{c}" for c in range(n_ch)]
108
+ audible = [c for c in range(n_ch) if levels[c] >= levels[best] - SILENT_CHANNEL_DB]
109
+ mix = np.zeros(data.shape[0], dtype=np.float32)
110
+ for c in audible:
111
+ mix += data[:, c].astype(np.float32)
112
+ mix /= (32768.0 * len(audible))
113
+ mix_db = float(10.0 * np.log10(np.mean(mix.astype(np.float64) ** 2) + 1e-12))
114
+ desc = {"channels": n_ch, "levels_db": [round(v, 1) for v in levels], "mix_db": round(mix_db, 1)}
115
+ if levels[best] - mix_db > PHASE_DROP_DB:
116
+ desc.update(used=names[best], reason="fase invertida entre os canais")
117
+ return data[:, best].astype(np.float32) / 32768.0, desc
118
+ if len(audible) == 1:
119
+ desc.update(used=names[best], reason="canal mudo")
120
+ return data[:, best].astype(np.float32) / 32768.0, desc
121
+ desc["used"] = "mix" if len(audible) == n_ch else "mix:" + ",".join(names[c] for c in audible)
122
+ return mix, desc
123
+
124
+
125
+ def load_mono(path: Path | str, sr: int = SAMPLE_RATE, start: float | None = None,
126
+ end: float | None = None, af: str | None = None) -> tuple[np.ndarray, dict]:
127
+ return choose_mono(decode_audio(path, sr=sr, start=start, end=end, af=af))
128
+
129
+
130
+ # ---------------------------------------------------------------- noise estimate
131
+
132
+ def window_levels(mono: np.ndarray, sr: int = SAMPLE_RATE, win_s: float = WINDOW_S) -> tuple[np.ndarray, np.ndarray]:
133
+ """Per-window RMS dB and peak dB (non-overlapping windows)."""
134
+ w = max(1, int(round(win_s * sr)))
135
+ n = len(mono) // w
136
+ if n == 0:
137
+ return np.zeros(0), np.zeros(0)
138
+ blocks = mono[: n * w].reshape(n, w).astype(np.float64)
139
+ rms_db = 10.0 * np.log10(np.mean(blocks * blocks, axis=1) + 1e-12)
140
+ peak_db = 20.0 * np.log10(np.max(np.abs(blocks), axis=1) + 1e-6)
141
+ return rms_db, peak_db
142
+
143
+
144
+ def _long_runs(mask: np.ndarray, min_len: int) -> np.ndarray:
145
+ """Keep only True runs at least min_len long."""
146
+ out = np.zeros_like(mask)
147
+ i, n = 0, len(mask)
148
+ while i < n:
149
+ if mask[i]:
150
+ j = i
151
+ while j < n and mask[j]:
152
+ j += 1
153
+ if j - i >= min_len:
154
+ out[i:j] = True
155
+ i = j
156
+ else:
157
+ i += 1
158
+ return out
159
+
160
+
161
+ def estimate_noise(mono: np.ndarray, sr: int = SAMPLE_RATE) -> dict:
162
+ """Estimate the silencedetect threshold for this audio.
163
+
164
+ floor = 10th percentile of 50 ms RMS (ignoring digital silence)
165
+ noise = windows within 6 dB of the floor, in runs of >= 200 ms
166
+ threshold = 90th percentile of the noise PEAKS + 3 dB,
167
+ never closer than 6 dB to the median speech level.
168
+ """
169
+ rms_db, peak_db = window_levels(mono, sr)
170
+ out = {"floor_db": None, "noise_peak_db": None, "speech_db": None, "threshold_db": DEFAULT_NOISE_DB}
171
+ valid = rms_db > -90.0
172
+ if valid.sum() < 8:
173
+ out["threshold_db"] = -50.0 if len(rms_db) >= 8 else DEFAULT_NOISE_DB
174
+ return out
175
+ floor = float(np.percentile(rms_db[valid], 10))
176
+ loud = valid & (rms_db >= floor + 15.0)
177
+ speech = float(np.median(rms_db[loud])) if loud.sum() >= 4 else None
178
+ quiet = valid & (rms_db <= floor + 6.0)
179
+ noise = _long_runs(quiet, 4)
180
+ if noise.sum() >= 4:
181
+ noise_peak = float(np.percentile(peak_db[noise], 90))
182
+ thr = noise_peak + 3.0
183
+ if speech is not None:
184
+ thr = min(thr, speech - 6.0)
185
+ out["noise_peak_db"] = round(noise_peak, 1)
186
+ else:
187
+ thr = (speech - 18.0) if speech is not None else floor + 10.0
188
+ out.update(floor_db=round(floor, 1), speech_db=None if speech is None else round(speech, 1),
189
+ threshold_db=round(float(np.clip(thr, -70.0, -12.0)), 1))
190
+ return out
191
+
192
+
193
+ # ---------------------------------------------------------------- silencedetect
194
+
195
+ _RE_START = re.compile(r"silence_start:\s*(-?[\d.]+(?:e-?\d+)?)")
196
+ _RE_END = re.compile(r"silence_end:\s*(-?[\d.]+(?:e-?\d+)?)")
197
+
198
+
199
+ def detect_silences(mono: np.ndarray, noise_db: float, min_silence: float, sr: int = SAMPLE_RATE) -> list[list[float]]:
200
+ """Run ffmpeg silencedetect on a float32 mono array. Times relative to the array start.
201
+
202
+ A silence still open at the end of the audio (no silence_end) is closed at the end.
203
+ """
204
+ require_binary("ffmpeg")
205
+ duration = len(mono) / sr
206
+ if len(mono) == 0:
207
+ return []
208
+ cmd = ["ffmpeg", "-hide_banner", "-nostats", "-nostdin", "-f", "f32le", "-ar", str(sr), "-ac", "1",
209
+ "-i", "pipe:0", "-af", f"silencedetect=noise={noise_db:.1f}dB:d={min_silence:.3f}",
210
+ "-f", "null", "-"]
211
+ proc = subprocess.run(cmd, input=np.ascontiguousarray(mono, dtype=np.float32).tobytes(), capture_output=True)
212
+ if proc.returncode != 0:
213
+ raise AudioError("silencedetect falhou:\n" + proc.stderr.decode("utf-8", "replace")[-800:])
214
+ silences: list[list[float]] = []
215
+ open_start: float | None = None
216
+ for line in proc.stderr.decode("utf-8", "replace").splitlines():
217
+ m = _RE_START.search(line)
218
+ if m:
219
+ open_start = min(max(0.0, float(m.group(1))), duration)
220
+ continue
221
+ m = _RE_END.search(line)
222
+ if m and open_start is not None:
223
+ silences.append([open_start, min(float(m.group(1)), duration)])
224
+ open_start = None
225
+ if open_start is not None and duration - open_start >= min_silence * 0.5:
226
+ silences.append([open_start, duration])
227
+ return _clean_silences(silences, duration)
228
+
229
+
230
+ def _clean_silences(silences: list[list[float]], duration: float) -> list[list[float]]:
231
+ """Sort, merge overlaps, and absorb speech islands shorter than MIN_SPEECH_S."""
232
+ merged: list[list[float]] = []
233
+ for s, e in sorted(silences):
234
+ if e <= s:
235
+ continue
236
+ if merged and s - merged[-1][1] < MIN_SPEECH_S:
237
+ merged[-1][1] = max(merged[-1][1], e)
238
+ else:
239
+ merged.append([s, e])
240
+ if merged and merged[0][0] < MIN_SPEECH_S:
241
+ merged[0][0] = 0.0
242
+ if merged and duration - merged[-1][1] < MIN_SPEECH_S:
243
+ merged[-1][1] = duration
244
+ return merged
245
+
246
+
247
+ def complement(intervals: list[list[float]], start: float, end: float) -> list[list[float]]:
248
+ out: list[list[float]] = []
249
+ t = start
250
+ for s, e in intervals:
251
+ if s > t:
252
+ out.append([t, s])
253
+ t = max(t, e)
254
+ if t < end:
255
+ out.append([t, end])
256
+ return out
257
+
258
+
259
+ def _r(x: float) -> float:
260
+ return round(float(x), 3)
261
+
262
+
263
+ def regions_from_mono(mono: np.ndarray, noise: str | float = "auto", min_silence: float = 0.25,
264
+ offset: float = 0.0, sr: int = SAMPLE_RATE, noise_mono: np.ndarray | None = None,
265
+ total: float | None = None) -> dict:
266
+ """Silences and speech for an already decoded mono array. Times get `offset` added.
267
+
268
+ `total` is the container duration when it is a little longer than the decoded audio:
269
+ a trailing silence is stretched to it and speech is measured up to it.
270
+ """
271
+ est = None
272
+ if isinstance(noise, str):
273
+ est = estimate_noise(noise_mono if noise_mono is not None else mono, sr)
274
+ noise_db = est["threshold_db"]
275
+ else:
276
+ noise_db = float(noise)
277
+ audio_len = len(mono) / sr
278
+ duration = float(total) if total else audio_len
279
+ sil = [[min(a, duration), min(b, duration)] for a, b in detect_silences(mono, noise_db, min_silence, sr)]
280
+ sil = [x for x in sil if x[1] > x[0]]
281
+ if sil and sil[-1][1] >= min(audio_len, duration) - 0.02:
282
+ sil[-1][1] = duration
283
+ speech = complement(sil, 0.0, duration)
284
+ return {
285
+ "noise_db": noise_db,
286
+ "noise_mode": "auto" if est else "manual",
287
+ "noise_estimate": est,
288
+ "min_silence": min_silence,
289
+ "speech": [[_r(s + offset), _r(e + offset)] for s, e in speech],
290
+ "silences": [[_r(s + offset), _r(e + offset)] for s, e in sil],
291
+ }
292
+
293
+
294
+ def regions_path(edit_dir: Path | str, stem: str) -> Path:
295
+ return Path(edit_dir) / "regions" / f"{stem}.json"
296
+
297
+
298
+ def compute_regions(video: Path | str, noise: str | float = "auto", min_silence: float = 0.25,
299
+ start: float | None = None, end: float | None = None,
300
+ edit_dir: Path | str | None = None) -> dict:
301
+ """Contract 3 dict for a video (or a [start, end] part of it, times stay absolute).
302
+
303
+ For a part with --noise auto, the threshold comes from the whole file (a short
304
+ part is a bad sample of the noise); a cached regions JSON is reused for that.
305
+ """
306
+ video = Path(video).absolute()
307
+ info = probe_media(video)
308
+ duration = float(info.get("duration") or 0.0)
309
+ partial = start is not None or end is not None
310
+ s0 = max(0.0, start or 0.0)
311
+ s1 = min(duration, end) if end is not None else duration
312
+ if partial and s1 <= s0:
313
+ raise AudioError(f"trecho inválido: {s0:.2f}s até {s1:.2f}s (duração {duration:.2f}s)")
314
+ mono, audio_desc = load_mono(video, start=s0 if partial else None, end=s1 if partial else None)
315
+ noise_arg: str | float = noise
316
+ if partial and isinstance(noise, str):
317
+ cached = load_json(regions_path(edit_dir, video.stem)) if edit_dir else None
318
+ if cached and cached.get("source_sig") == file_signature(video) and cached.get("noise_mode") == "auto":
319
+ noise_arg = float(cached["noise_db"])
320
+ else:
321
+ full, _ = load_mono(video)
322
+ noise_arg = estimate_noise(full)["threshold_db"]
323
+ reg = regions_from_mono(mono, noise_arg, min_silence, offset=s0 if partial else 0.0,
324
+ total=(s1 - s0) if partial else duration)
325
+ if partial and isinstance(noise, str):
326
+ reg["noise_mode"] = "auto"
327
+ out = {
328
+ "source": str(video),
329
+ "source_sig": file_signature(video),
330
+ "stem": video.stem,
331
+ "duration": _r(duration),
332
+ "noise_db": reg["noise_db"],
333
+ "noise_mode": reg["noise_mode"],
334
+ "min_silence": min_silence,
335
+ "speech": reg["speech"],
336
+ "silences": reg["silences"],
337
+ "audio": audio_desc,
338
+ }
339
+ if reg["noise_estimate"]:
340
+ out["noise_estimate"] = reg["noise_estimate"]
341
+ if partial:
342
+ out["start"], out["end"] = _r(s0), _r(s1)
343
+ return out
344
+
345
+
346
+ def load_or_compute_regions(video: Path | str, edit_dir: Path | str | None = None,
347
+ min_silence: float = 0.25) -> dict:
348
+ """Cached auto regions for a whole file (edit/regions/<stem>.json), computed if missing or stale."""
349
+ video = Path(video).absolute()
350
+ edit_dir = Path(edit_dir) if edit_dir else video.parent / "edit"
351
+ path = regions_path(edit_dir, video.stem)
352
+ cached = load_json(path)
353
+ if (cached and cached.get("source_sig") == file_signature(video) and cached.get("noise_mode") == "auto"
354
+ and abs(float(cached.get("min_silence", -1)) - min_silence) < 1e-9 and "start" not in cached):
355
+ return cached
356
+ reg = compute_regions(video, "auto", min_silence)
357
+ save_json(path, reg)
358
+ return reg
359
+
360
+
361
+ # ---------------------------------------------------------------- CLI
362
+
363
+ def _parse_noise(value: str) -> str | float:
364
+ if value.strip().lower() == "auto":
365
+ return "auto"
366
+ try:
367
+ v = float(value.lower().replace("db", ""))
368
+ except ValueError:
369
+ raise argparse.ArgumentTypeError("use 'auto' ou um número em dB, por exemplo -35")
370
+ return v if v <= 0 else -v
371
+
372
+
373
+ def print_regions(reg: dict) -> None:
374
+ speech_total = sum(e - s for s, e in reg["speech"])
375
+ est = reg.get("noise_estimate") or {}
376
+ how = f"auto, ruído {est['floor_db']} dB" if reg["noise_mode"] == "auto" and est.get("floor_db") is not None else reg["noise_mode"]
377
+ span = f"{reg['start']:.2f}-{reg['end']:.2f}s" if "start" in reg else f"{reg['duration']:.2f}s"
378
+ print(f"{reg['stem']} ({span}) · limiar {reg['noise_db']} dB ({how}) · pausa mínima {reg['min_silence']}s · "
379
+ f"{len(reg['speech'])} trechos de fala, {speech_total:.1f}s de fala")
380
+ items = [("fala ", s, e) for s, e in reg["speech"]] + [("pausa", s, e) for s, e in reg["silences"]]
381
+ for kind, s, e in sorted(items, key=lambda x: x[1]):
382
+ print(f" {kind} [{s:06.2f}-{e:06.2f}] {e - s:5.2f}s")
383
+
384
+
385
+ def main() -> None:
386
+ ap = argparse.ArgumentParser(
387
+ description="Mede trechos de fala e de pausa no áudio de um vídeo (ffmpeg silencedetect).")
388
+ ap.add_argument("video", type=Path, help="arquivo de vídeo ou áudio")
389
+ ap.add_argument("--edit-dir", type=Path, default=None, help="pasta edit (padrão: <pasta do vídeo>/edit)")
390
+ ap.add_argument("--noise", type=_parse_noise, default="auto",
391
+ help="limiar de silêncio: 'auto' (padrão, mede o ruído do arquivo) ou dB, ex. -35")
392
+ ap.add_argument("--min-silence", type=float, default=0.25, help="pausa mínima em segundos (padrão 0.25)")
393
+ ap.add_argument("--start", type=float, default=None, help="início do trecho em segundos (opcional)")
394
+ ap.add_argument("--end", type=float, default=None, help="fim do trecho em segundos (opcional)")
395
+ ap.add_argument("--json", action="store_true", help="imprime o JSON em vez da lista")
396
+ args = ap.parse_args()
397
+
398
+ video = args.video.absolute()
399
+ if not video.exists():
400
+ sys.exit(f"arquivo não encontrado: {video}")
401
+ if args.min_silence <= 0:
402
+ sys.exit("--min-silence precisa ser maior que zero")
403
+ edit_dir = (args.edit_dir or video.parent / "edit").absolute()
404
+ try:
405
+ reg = compute_regions(video, args.noise, args.min_silence, args.start, args.end, edit_dir=edit_dir)
406
+ except AudioError as exc:
407
+ sys.exit(str(exc))
408
+ partial = args.start is not None or args.end is not None
409
+ if not partial:
410
+ save_json(regions_path(edit_dir, video.stem), reg)
411
+ if args.json:
412
+ import json
413
+ print(json.dumps(reg, ensure_ascii=False, indent=2))
414
+ else:
415
+ print_regions(reg)
416
+ if partial:
417
+ print(" (trecho: não salvo em regions/, só o arquivo inteiro é salvo)")
418
+ else:
419
+ print(f" salvo em {regions_path(edit_dir, video.stem)}")
420
+
421
+
422
+ if __name__ == "__main__":
423
+ main()
@@ -0,0 +1,231 @@
1
+ {
2
+ "version": 1,
3
+ "edit_types": [
4
+ {
5
+ "id": "clean",
6
+ "label": "Limpa",
7
+ "desc": "Só você falando, tela cheia."
8
+ },
9
+ {
10
+ "id": "split_top",
11
+ "label": "Tela dividida · imagem em cima",
12
+ "desc": "Imagem ou vídeo na parte de cima e você embaixo nos trechos que pedem prova."
13
+ },
14
+ {
15
+ "id": "split_bottom",
16
+ "label": "Tela dividida · imagem embaixo",
17
+ "desc": "Você em cima e a imagem ou vídeo embaixo nos trechos que pedem prova."
18
+ },
19
+ {
20
+ "id": "tela_verde",
21
+ "label": "Tela verde",
22
+ "desc": "Você fica recortado na frente e as imagens e vídeos da edição passam no fundo, sem tela dividida. Bom para comentar uma página, reagir a um vídeo ou mostrar um produto.",
23
+ "positions": [
24
+ {
25
+ "id": "esquerda",
26
+ "label": "Canto esquerdo"
27
+ },
28
+ {
29
+ "id": "direita",
30
+ "label": "Canto direito"
31
+ },
32
+ {
33
+ "id": "centro",
34
+ "label": "Meio"
35
+ }
36
+ ]
37
+ }
38
+ ],
39
+ "caption_styles": [
40
+ {
41
+ "id": "impacto",
42
+ "label": "Impacto",
43
+ "desc": "Letra alta e condensada com contorno preto grosso. A palavra falada acende na cor de destaque e cada frase entra com um pulinho.",
44
+ "animated": true,
45
+ "max_words": 3,
46
+ "lines": 1,
47
+ "fonts": [
48
+ "display_condensed"
49
+ ]
50
+ },
51
+ {
52
+ "id": "caixa_viva",
53
+ "label": "Caixa na palavra",
54
+ "desc": "Letra reta e forte. Uma caixa na cor de destaque corre de palavra em palavra seguindo a fala.",
55
+ "animated": true,
56
+ "max_words": 3,
57
+ "lines": 1,
58
+ "fonts": [
59
+ "sans_black"
60
+ ]
61
+ },
62
+ {
63
+ "id": "palavra_gigante",
64
+ "label": "Palavra gigante",
65
+ "desc": "Uma palavra por vez, enorme, com sombra 3D na cor de destaque. Ritmo acelerado, bom para ganchos rápidos.",
66
+ "animated": true,
67
+ "max_words": 1,
68
+ "lines": 1,
69
+ "fonts": [
70
+ "display_condensed"
71
+ ]
72
+ },
73
+ {
74
+ "id": "pulso",
75
+ "label": "Pulso",
76
+ "desc": "Letra redonda e grossa. A palavra falada cresce e ganha a cor de destaque; a frase sobe suave ao entrar.",
77
+ "animated": true,
78
+ "max_words": 3,
79
+ "lines": 1,
80
+ "fonts": [
81
+ "sans_black"
82
+ ]
83
+ },
84
+ {
85
+ "id": "fita",
86
+ "label": "Fita",
87
+ "desc": "Frase numa fita preta levemente inclinada, que abre da esquerda. Palavra falada na cor de destaque. Lê bem em qualquer fundo.",
88
+ "animated": true,
89
+ "max_words": 3,
90
+ "lines": 1,
91
+ "fonts": [
92
+ "sans_black"
93
+ ]
94
+ },
95
+ {
96
+ "id": "legenda_limpa",
97
+ "label": "Legenda discreta",
98
+ "desc": "Legenda de TV: frase inteira em até duas linhas sobre fundo escuro translúcido. Sem animação.",
99
+ "animated": false,
100
+ "max_words": 8,
101
+ "lines": 2,
102
+ "fonts": [
103
+ "text_bold"
104
+ ]
105
+ },
106
+ {
107
+ "id": "papelaria",
108
+ "label": "Papelaria",
109
+ "desc": "A frase vai se escrevendo palavra por palavra, com contorno escuro grosso. A palavra falada acende na cor de destaque e cresce um pouco.",
110
+ "animated": true,
111
+ "max_words": 3,
112
+ "lines": 1,
113
+ "fonts": [
114
+ "sans_black"
115
+ ]
116
+ },
117
+ {
118
+ "id": "papelaria_papel",
119
+ "label": "Papelaria em fundo claro",
120
+ "desc": "A mesma legenda para gráfico de fundo claro: letra escura com halo branco, para não sumir no papel.",
121
+ "animated": true,
122
+ "max_words": 3,
123
+ "lines": 1,
124
+ "fonts": [
125
+ "sans_black"
126
+ ]
127
+ },
128
+ {
129
+ "id": "depoimento",
130
+ "label": "Depoimento",
131
+ "desc": "Marca a fala de outra pessoa: a frase fica na cor de destaque e a palavra falada, branca.",
132
+ "animated": true,
133
+ "max_words": 3,
134
+ "lines": 1,
135
+ "fonts": [
136
+ "sans_black"
137
+ ]
138
+ }
139
+ ],
140
+ "hook_styles": [
141
+ {
142
+ "id": "manchete",
143
+ "label": "Manchete",
144
+ "desc": "Título grande e condensado com contorno preto. A segunda linha vem na cor de destaque.",
145
+ "fonts": [
146
+ "display_condensed"
147
+ ]
148
+ },
149
+ {
150
+ "id": "tarja",
151
+ "label": "Tarjas",
152
+ "desc": "Cada linha numa tarja sólida, uma branca e outra na cor de destaque, levemente inclinadas.",
153
+ "fonts": [
154
+ "sans_black"
155
+ ]
156
+ },
157
+ {
158
+ "id": "balao",
159
+ "label": "Balão",
160
+ "desc": "Balão branco de conversa com texto escuro, como uma pergunta que o público faria.",
161
+ "fonts": [
162
+ "sans_extrabold"
163
+ ]
164
+ },
165
+ {
166
+ "id": "marcador",
167
+ "label": "Marca-texto",
168
+ "desc": "Letra de pincel sobre faixas de marca-texto na cor de destaque. Visual feito à mão.",
169
+ "fonts": [
170
+ "marker"
171
+ ]
172
+ },
173
+ {
174
+ "id": "vidro",
175
+ "label": "Vidro escuro",
176
+ "desc": "Painel escuro translúcido com texto branco e um detalhe na cor de destaque. Visual limpo e premium.",
177
+ "fonts": [
178
+ "sans_extrabold"
179
+ ]
180
+ }
181
+ ],
182
+ "elements": [
183
+ {
184
+ "id": "zoom_cuts",
185
+ "label": "Zoom nos cortes",
186
+ "default": true
187
+ },
188
+ {
189
+ "id": "push_in",
190
+ "label": "Aproximação lenta",
191
+ "default": false
192
+ },
193
+ {
194
+ "id": "flash",
195
+ "label": "Flash nas trocas de imagem",
196
+ "default": false
197
+ },
198
+ {
199
+ "id": "motion",
200
+ "label": "Motion: anima os momentos fortes da fala",
201
+ "default": true
202
+ },
203
+ {
204
+ "id": "sfx",
205
+ "label": "Efeitos sonoros",
206
+ "default": true
207
+ },
208
+ {
209
+ "id": "music",
210
+ "label": "Trilha de fundo",
211
+ "default": false
212
+ },
213
+ {
214
+ "id": "cta",
215
+ "label": "Card de chamada no final",
216
+ "default": true
217
+ },
218
+ {
219
+ "id": "disclaimer",
220
+ "label": "Aviso de resultado individual",
221
+ "default": false
222
+ }
223
+ ],
224
+ "hook_markup": "Palavras entre asteriscos no texto do gancho ganham a cor de destaque: \"Você está *perdendo venda* assim\". Palavra tocada pelo destaque sai inteira colorida. Use \\* para um asterisco de verdade. Chamada e aviso não têm destaque. Emoji e símbolos que a fonte não tem são tirados.",
225
+ "defaults": {
226
+ "edit_type": "clean",
227
+ "caption_style": "impacto",
228
+ "hook_style": "manchete",
229
+ "accent": "#FFC400"
230
+ }
231
+ }