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,134 @@
1
+ """How much background noise each source has, and whether to offer the noise removal (audio.denoise).
2
+
3
+ For every video: the noise floor in its pauses (RMS of edit/regions/<stem>.json silences trimmed 60 ms,
4
+ measured now when the regions file is missing or belongs to another file), the speech level (RMS in the
5
+ speech regions) and their difference. With almost no pause (under 0.1 s) the noise is estimated on the
6
+ quietest 50 ms windows (+3 dB, they catch the dips of the noise). The channels are folded like render_cut.
7
+
8
+ difference >= 30 dB "limpo, não precisa"
9
+ 20 to 30 dB "ruído leve" (offer Remover ruído de fundo: Leve)
10
+ under 20 dB "ruído forte" (offer Forte)
11
+
12
+ Prints one PT-BR line per video plus a recommendation, and writes edit/verify/noise_check.json.
13
+
14
+ It also measures, per video, saturation and room echo of the voice (helpers/voz_check.py): one "Voz" line per
15
+ video and the key "voz" in the JSON. Saturated voice does not come back whole; echo is treated lightly and judged
16
+ at the same perceived loudness (references/revisao.md, Voz). `--sem-voz` skips it.
17
+
18
+ Usage:
19
+ uv run python helpers/noise_check.py <videos_dir | video ...> [--edit-dir DIR] [--json]
20
+ """
21
+
22
+ from __future__ import annotations
23
+
24
+ import argparse
25
+ import json
26
+ import sys
27
+ from pathlib import Path
28
+
29
+ sys.path.insert(0, str(Path(__file__).resolve().parent))
30
+ from common import now_iso, require_binary, save_json # noqa: E402
31
+ import denoise # noqa: E402
32
+ import voz_check # noqa: E402
33
+
34
+ VIDEO_EXT = {".mov", ".mp4", ".m4v", ".mkv", ".webm"}
35
+ ORDER = {"limpo": 0, "leve": 1, "forte": 2}
36
+
37
+
38
+ def utf8_console() -> None:
39
+ for stream in (sys.stdout, sys.stderr):
40
+ try:
41
+ stream.reconfigure(encoding="utf-8", errors="replace") # type: ignore[attr-defined]
42
+ except (AttributeError, ValueError, OSError):
43
+ pass
44
+
45
+
46
+ def collect(paths: list[Path]) -> list[Path]:
47
+ out: list[Path] = []
48
+ for p in paths:
49
+ if p.is_dir():
50
+ out += sorted(f for f in p.iterdir() if f.is_file() and f.suffix.lower() in VIDEO_EXT
51
+ and not f.name.startswith("."))
52
+ elif p.is_file():
53
+ out.append(p)
54
+ else:
55
+ raise FileNotFoundError(f"não encontrado: {p}")
56
+ return list(dict.fromkeys(out))
57
+
58
+
59
+ def line_for(r: dict) -> str:
60
+ name = r.get("stem", "?")
61
+ if r.get("error"):
62
+ return f" {name}: {r['error']}"
63
+ extra = " (quase sem pausa: fundo estimado nas partes mais quietas)" if r.get("noise_from") != "pausas" else ""
64
+ return (f" {name} · fundo {r['noise_db']:.1f} dB · voz {r['speech_db']:.1f} dB · diferença {r['snr_db']:.0f} dB · "
65
+ f"{r['advice']}{extra}")
66
+
67
+
68
+ def recommendation(results: list[dict]) -> dict:
69
+ measured = [r for r in results if r.get("level")]
70
+ if not measured:
71
+ return {"suggest": None, "text": "Não deu para medir o ruído (sem áudio ou sem fala)."}
72
+ worst = max(measured, key=lambda r: (ORDER[r["level"]], -r["snr_db"]))
73
+ if worst["level"] == "limpo":
74
+ return {"suggest": "off", "worst": worst["stem"],
75
+ "text": "Recomendação: nenhum vídeo precisa de limpeza de ruído (deixe Desligado)."}
76
+ label = "Leve" if worst["level"] == "leve" else "Forte"
77
+ return {"suggest": worst["suggest"], "worst": worst["stem"],
78
+ "text": (f"Recomendação: ofereça Remover ruído de fundo {label} (o pior é {worst['stem']}, "
79
+ f"diferença de {worst['snr_db']:.0f} dB entre voz e fundo).")}
80
+
81
+
82
+ def main() -> int:
83
+ ap = argparse.ArgumentParser(description="Mede o ruído de fundo de cada vídeo e diz se vale limpar a voz.")
84
+ ap.add_argument("paths", nargs="+", type=Path, help="pasta dos vídeos ou arquivos de vídeo")
85
+ ap.add_argument("--edit-dir", type=Path, default=None,
86
+ help="pasta edit (padrão: <pasta dos vídeos>/edit); usa regions/<nome>.json de lá")
87
+ ap.add_argument("--json", action="store_true", help="imprime o JSON em vez das linhas")
88
+ ap.add_argument("--sem-voz", action="store_true", help="não mede saturação nem eco da voz")
89
+ args = ap.parse_args()
90
+ utf8_console()
91
+ require_binary("ffmpeg")
92
+ require_binary("ffprobe")
93
+ paths = [p.expanduser().absolute() for p in args.paths]
94
+ try:
95
+ videos = collect(paths)
96
+ except FileNotFoundError as e:
97
+ print(f"erro: {e}", file=sys.stderr)
98
+ return 2
99
+ if not videos:
100
+ print("erro: nenhum vídeo encontrado (.mov, .mp4, .m4v, .mkv, .webm)", file=sys.stderr)
101
+ return 2
102
+ first = paths[0] if paths[0].is_dir() else videos[0].parent
103
+ edit_dir = (args.edit_dir.expanduser().absolute() if args.edit_dir else first / "edit")
104
+ results = [denoise.measure_source(v, edit_dir if edit_dir.is_dir() else None) for v in videos]
105
+ if not args.sem_voz:
106
+ for v, r in zip(videos, results):
107
+ try:
108
+ r["voz"] = voz_check.measure_voice(v)
109
+ except Exception as exc: # noqa: BLE001
110
+ r["voz"] = {"error": f"não consegui medir a voz ({exc.__class__.__name__})"}
111
+ rec = recommendation(results)
112
+ doc = {"created_at": now_iso(), "edit_dir": str(edit_dir), "videos": results, "recommendation": rec,
113
+ "thresholds_db": {"limpo": 30, "leve": 20}}
114
+ if edit_dir.is_dir():
115
+ try:
116
+ save_json(edit_dir / "verify" / "noise_check.json", doc)
117
+ except OSError:
118
+ pass
119
+ if args.json:
120
+ print(json.dumps(doc, ensure_ascii=False, indent=2))
121
+ return 0
122
+ print("Ruído de fundo (fundo = volume nas pausas, voz = volume da fala):")
123
+ for r in results:
124
+ print(line_for(r))
125
+ print(rec["text"])
126
+ if not args.sem_voz:
127
+ print("Voz (saturação e eco de sala, medidos na onda; confirme ouvindo os tempos citados):")
128
+ for r in results:
129
+ print(voz_check.line_for(r.get("stem", "?"), r.get("voz") or {"error": "sem medida"}))
130
+ return 0
131
+
132
+
133
+ if __name__ == "__main__":
134
+ sys.exit(main())
@@ -0,0 +1,223 @@
1
+ # Adapted from browser-use/video-use (MIT, (c) 2026 Browser Use). See THIRD_PARTY_NOTICES.md.
2
+ """Pack the word-level transcripts of <edit>/transcripts/ into one phrase-level reading view.
3
+
4
+ Words are grouped into phrases, breaking on any gap >= --silence seconds between words.
5
+ Each phrase gets a [start-end] prefix in source seconds. This is the file the agent reads
6
+ to pick cuts: a fraction of the tokens of the raw JSON, with word-boundary precision.
7
+
8
+ Only source transcripts are packed; cut.json (the transcript of the cut) is skipped. With `--corte`,
9
+ or when cut.json is the only transcript in the folder, the speech of the cut is packed instead: short
10
+ phrases (a pause of 0.3 s, a sentence end or 12 words) with the time each one starts in cut.mp4, to read
11
+ against the `quote` of every range.
12
+
13
+ Output: <edit>/takes_packed.md (the cut: <edit>/fala_do_corte.md)
14
+
15
+ Usage:
16
+ uv run --directory '<SKILL>' python helpers/pack_transcripts.py --edit-dir <edit_dir> [--silence 0.5] [-o FILE]
17
+ uv run --directory '<SKILL>' python helpers/pack_transcripts.py --edit-dir <edit_dir> --corte
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ import argparse
23
+ import json
24
+ import sys
25
+ from pathlib import Path
26
+
27
+ SKIP_STEMS = {"cut"}
28
+ SKILL_ROOT = Path(__file__).resolve().parent.parent
29
+ RODE_ANTES = (f"Rode antes: uv run --directory '{SKILL_ROOT}' python helpers/transcribe_batch.py '<pasta dos vídeos>' "
30
+ "--edit-dir '<EDIT>'")
31
+ CUT_STEM = "cut"
32
+ CUT_SILENCE_S = 0.3 # the cut has joints of ~0.12 s: a shorter pause breaks the phrase
33
+ CUT_MAX_WORDS = 12
34
+
35
+
36
+ def format_time(seconds: float) -> str:
37
+ """Seconds as zero-padded "NNN.NN" (fixed width for alignment)."""
38
+ return f"{max(0.0, seconds):06.2f}"
39
+
40
+
41
+ def format_duration(seconds: float) -> str:
42
+ """Seconds with one decimal, as in the contract ("70.8s")."""
43
+ return f"{seconds:.1f}s"
44
+
45
+
46
+ def _plural(n: int, one: str, many: str) -> str:
47
+ return f"{n} {one if n == 1 else many}"
48
+
49
+
50
+ _SENTENCE_END = (".", "!", "?", "…")
51
+
52
+
53
+ def group_into_phrases(words: list[dict], silence: float = 0.5, segment_starts: set[float] | None = None,
54
+ sentence_breaks: bool = True) -> list[dict]:
55
+ """Break the word list into phrases. Returns [{start, end, text, words}].
56
+
57
+ Breaks on a gap >= silence between words. Fluent ad speech rarely pauses that long,
58
+ so it also breaks after a sentence end (. ! ? …) and where Whisper started a new
59
+ segment (segment_starts, times rounded to 3 decimals).
60
+ """
61
+ phrases: list[dict] = []
62
+ current: list[dict] = []
63
+ prev_end: float | None = None
64
+ seg_starts = segment_starts or set()
65
+
66
+ def flush() -> None:
67
+ nonlocal current
68
+ parts = [(w.get("text") or "").strip() for w in current]
69
+ parts = [p for p in parts if p]
70
+ if parts:
71
+ phrases.append({
72
+ "start": float(current[0]["start"]),
73
+ "end": float(max(w.get("end", w["start"]) for w in current)),
74
+ "text": " ".join(parts),
75
+ "words": current,
76
+ })
77
+ current = []
78
+
79
+ for w in words:
80
+ start = w.get("start")
81
+ if start is None:
82
+ continue
83
+ if current and prev_end is not None:
84
+ last_text = (current[-1].get("text") or "").strip()
85
+ if (start - prev_end >= silence
86
+ or (sentence_breaks and last_text.endswith(_SENTENCE_END))
87
+ or round(float(start), 3) in seg_starts):
88
+ flush()
89
+ current.append(w)
90
+ prev_end = max(prev_end or 0.0, w.get("end", start))
91
+ flush()
92
+ return phrases
93
+
94
+
95
+ def split_long(phrases: list[dict], max_words: int = CUT_MAX_WORDS) -> list[dict]:
96
+ """Phrases of at most max_words, broken after a comma when one is near the limit (a line you read at once)."""
97
+ out: list[dict] = []
98
+ for p in phrases:
99
+ words = [w for w in p["words"] if (w.get("text") or "").strip()]
100
+ while len(words) > max_words:
101
+ cut = max_words
102
+ for k in range(max_words, max(1, max_words // 2) - 1, -1):
103
+ if (words[k - 1].get("text") or "").strip().endswith((",", ";", ":")):
104
+ cut = k
105
+ break
106
+ out.append(_phrase(words[:cut]))
107
+ words = words[cut:]
108
+ if words:
109
+ out.append(_phrase(words))
110
+ return out
111
+
112
+
113
+ def _phrase(words: list[dict]) -> dict:
114
+ return {"start": float(words[0]["start"]), "end": float(max(w.get("end", w["start"]) for w in words)),
115
+ "text": " ".join((w.get("text") or "").strip() for w in words), "words": words}
116
+
117
+
118
+ def cut_phrases(data: dict, silence: float = CUT_SILENCE_S, max_words: int = CUT_MAX_WORDS) -> list[dict]:
119
+ """Short phrases of the transcript of the cut (used by --corte and by conferir_corte.py)."""
120
+ return split_long(phrases_for_transcript(data, silence), max_words)
121
+
122
+
123
+ def render_cut_markdown(data: dict, phrases: list[dict]) -> str:
124
+ n_words = len(data.get("words", []))
125
+ lines = [f"# Fala do corte · {Path(str(data.get('source') or 'cut.mp4')).name}, "
126
+ f"{format_duration(float(data.get('duration') or 0))}, {_plural(n_words, 'palavra', 'palavras')}",
127
+ "Tempo = começo da frase no corte (a transcrição adianta até 0,3 s; animação se mede na onda).", ""]
128
+ if not phrases:
129
+ lines.append(" _sem fala detectada_")
130
+ for p in phrases:
131
+ lines.append(f" [{format_time(p['start'])}] {p['text']}")
132
+ return "\n".join(lines) + "\n"
133
+
134
+
135
+ def source_transcripts(transcripts_dir: Path) -> list[Path]:
136
+ return sorted((p for p in transcripts_dir.glob("*.json")
137
+ if p.stem not in SKIP_STEMS and not p.name.startswith(".")),
138
+ key=lambda p: p.stem.lower())
139
+
140
+
141
+ def phrases_for_transcript(data: dict, silence: float = 0.5) -> list[dict]:
142
+ """Phrases of a Contract 1 transcript dict (used by pack and voice_levels)."""
143
+ words = sorted(data.get("words", []), key=lambda w: w.get("start", 0.0))
144
+ seg_starts = {round(float(s["start"]), 3) for s in data.get("segments", []) if "start" in s}
145
+ return group_into_phrases(words, silence, seg_starts)
146
+
147
+
148
+ def pack_one_file(json_path: Path, silence: float) -> tuple[str, float, list[dict]]:
149
+ """(stem, source duration, phrases) for one transcript file."""
150
+ data = json.loads(json_path.read_text(encoding="utf-8"))
151
+ phrases = phrases_for_transcript(data, silence)
152
+ duration = float(data.get("duration") or (phrases[-1]["end"] if phrases else 0.0))
153
+ return data.get("stem") or json_path.stem, duration, phrases
154
+
155
+
156
+ def render_markdown(entries: list[tuple[str, float, list[dict]]]) -> str:
157
+ total = sum(e[1] for e in entries)
158
+ lines = [f"# Tomadas · {_plural(len(entries), 'fonte', 'fontes')}, {format_duration(total)} no total", ""]
159
+ for name, duration, phrases in entries:
160
+ lines.append(f"## {name} ({format_duration(duration)}, {_plural(len(phrases), 'frase', 'frases')})")
161
+ if not phrases:
162
+ lines.append(" _sem fala detectada_")
163
+ for p in phrases:
164
+ lines.append(f" [{format_time(p['start'])}-{format_time(p['end'])}] {p['text']}")
165
+ lines.append("")
166
+ return "\n".join(lines)
167
+
168
+
169
+ def main() -> None:
170
+ ap = argparse.ArgumentParser(description="Junta as transcrições em takes_packed.md, uma frase por linha.")
171
+ ap.add_argument("--edit-dir", type=Path, required=True, help="pasta edit com transcripts/")
172
+ ap.add_argument("--silence", type=float, default=0.5,
173
+ help="quebra a frase em pausas maiores ou iguais a isso, em segundos (padrão 0.5)")
174
+ ap.add_argument("-o", "--output", type=Path, default=None, help="arquivo de saída (padrão: <edit-dir>/takes_packed.md)")
175
+ ap.add_argument("--corte", action="store_true",
176
+ help="junta a fala do corte (transcripts/cut.json) em fala_do_corte.md, com o tempo de cada frase")
177
+ args = ap.parse_args()
178
+
179
+ edit_dir = args.edit_dir.absolute()
180
+ transcripts_dir = edit_dir / "transcripts"
181
+ if not transcripts_dir.is_dir():
182
+ sys.exit(f"não achei a pasta de transcrições: {transcripts_dir}\n{RODE_ANTES}")
183
+ if args.silence <= 0:
184
+ sys.exit("--silence precisa ser maior que zero")
185
+ files = source_transcripts(transcripts_dir)
186
+ cut_json = transcripts_dir / f"{CUT_STEM}.json"
187
+ if args.corte or (not files and cut_json.is_file()):
188
+ if not cut_json.is_file():
189
+ sys.exit(f"não achei {cut_json}\nRode antes: uv run --directory '{SKILL_ROOT}' python "
190
+ f"helpers/conferir_corte.py '{edit_dir}'")
191
+ if not args.corte:
192
+ print("só tem a transcrição do corte nesta pasta: juntando a fala do corte (as fontes precisam do "
193
+ "transcribe_batch.py na pasta dos vídeos)", file=sys.stderr)
194
+ data = json.loads(cut_json.read_text(encoding="utf-8"))
195
+ silence = args.silence if args.silence != 0.5 else CUT_SILENCE_S
196
+ phrases = cut_phrases(data, silence)
197
+ out_path = (args.output or edit_dir / "fala_do_corte.md").absolute()
198
+ out_path.write_text(render_cut_markdown(data, phrases), encoding="utf-8")
199
+ print(f"fala do corte: {_plural(len(phrases), 'frase', 'frases')}, "
200
+ f"{format_duration(float(data.get('duration') or 0))} · {out_path}")
201
+ return
202
+ if not files:
203
+ sys.exit(f"nenhuma transcrição em {transcripts_dir}\n{RODE_ANTES}")
204
+
205
+ entries = []
206
+ for path in files:
207
+ try:
208
+ entries.append(pack_one_file(path, args.silence))
209
+ except (json.JSONDecodeError, OSError, KeyError, TypeError) as exc:
210
+ print(f"aviso: ignorei {path.name} (arquivo inválido: {exc})", file=sys.stderr)
211
+ if not entries:
212
+ sys.exit("nenhuma transcrição válida para juntar")
213
+
214
+ out_path = (args.output or edit_dir / "takes_packed.md").absolute()
215
+ out_path.write_text(render_markdown(entries), encoding="utf-8")
216
+ n_phrases = sum(len(e[2]) for e in entries)
217
+ total = sum(e[1] for e in entries)
218
+ print(f"{_plural(len(entries), 'fonte', 'fontes')}, {_plural(n_phrases, 'frase', 'frases')}, "
219
+ f"{format_duration(total)} no total · {out_path} ({out_path.stat().st_size / 1024:.1f} KB)")
220
+
221
+
222
+ if __name__ == "__main__":
223
+ main()
@@ -0,0 +1,176 @@
1
+ """When each word really starts, measured on the waveform (Whisper puts the start up to 0.3 s early).
2
+
3
+ The transcription of the cut marks word starts early, most of all after a joint or a pause: "éééé" came 0.22 s
4
+ early and "Eu" at 0.00 when the voice starts at 0.58 (exemplos, 29/09/2026). A caption can live with that; an
5
+ animation, a sound or a cut that must land ON the word cannot. This helper takes the cut (or any video) and its
6
+ transcript and, for every word, looks at the waveform around the Whisper start (0.10 s before to 0.40 s after,
7
+ never into the previous word):
8
+
9
+ pausa the voice comes back after a quiet run: the start is the first sustained sound after it (sharp,
10
+ ~0.01 s). The usual case after a joint of the cut or a breath.
11
+ vale no pause (the words run together): the start is the rise after the deepest dip (10 dB or more) near
12
+ the Whisper start (about 0.03 s).
13
+ whisper nothing to measure (a dip too shallow): the Whisper time is kept.
14
+
15
+ Same measure as corte_por_onda.py (mono RMS in 10 ms windows; pause threshold from the floor of the file).
16
+
17
+ Output: a table of the words that moved 0.05 s or more (or the ones asked for with --palavra / --de --ate) and
18
+ <edit>/palavras_onda.json: [{"i", "text", "whisper", "start", "end", "dif", "como"}], times in seconds of the video.
19
+
20
+ Use: to fire an animation, a sound or a cut on a word, put the `start` of this file in the project as a time in
21
+ seconds (`"em": 3.42` instead of `"em": "éééé"`); the word name uses the Whisper time. Rerun after every new cut.
22
+
23
+ Usage:
24
+ uv run --directory '<SKILL>' python helpers/palavras_na_onda.py '<EDIT>' [--palavra éééé] [--de 2 --ate 6] [--todas]
25
+ uv run --directory '<SKILL>' python helpers/palavras_na_onda.py --video X.mp4 --transcricao X.json [...]
26
+ """
27
+
28
+ from __future__ import annotations
29
+
30
+ import argparse
31
+ import re
32
+ import sys
33
+ import unicodedata
34
+ from pathlib import Path
35
+
36
+ import numpy as np
37
+
38
+ sys.path.insert(0, str(Path(__file__).resolve().parent))
39
+ import corte_por_onda as onda # noqa: E402
40
+ from common import fmt_time, load_json, save_json # noqa: E402
41
+
42
+ ANTES_S = 0.10 # Whisper can also be a little late
43
+ DEPOIS_S = 0.40 # ... and is up to 0.3 s early
44
+ QUIETO_MIN_S = 0.03 # a quiet run this long before the word is a pause
45
+ SOM_MIN_S = 0.04 # sound this long after it is the word (not a click)
46
+ VALE_DB = 10.0 # a dip this deep between two words marks the start of the second
47
+ MOSTRAR_S = 0.05 # the table shows words that moved at least this much
48
+
49
+
50
+ def _norm(texto: str) -> str:
51
+ t = unicodedata.normalize("NFKD", (texto or "").lower())
52
+ return re.sub(r"[^\w]+", "", "".join(c for c in t if not unicodedata.combining(c)))
53
+
54
+
55
+ def medir(db: np.ndarray, th: float, whisper: float, depois_de: float, ate: float) -> tuple[float, str]:
56
+ """(start, como) of one word, searched between `depois_de` (the previous word has started) and `ate` (where the
57
+ next word starts: its onset must not be taken for this one)."""
58
+ hop = onda.HOP_S
59
+ lo = max(depois_de, whisper - ANTES_S, 0.0)
60
+ hi = max(lo + hop, ate)
61
+ i0, i1 = int(round(lo / hop)), min(len(db), int(round(hi / hop)))
62
+ if i1 - i0 < 2:
63
+ return round(whisper, 3), "whisper"
64
+ seg = db[i0:i1]
65
+ quiet_need, som_need = int(round(QUIETO_MIN_S / hop)), int(round(SOM_MIN_S / hop))
66
+ # 1. pause -> sound: first sustained sound after a quiet run; the one nearest after the Whisper start wins
67
+ cands = []
68
+ run = 0
69
+ for k in range(len(seg)):
70
+ if seg[k] < th:
71
+ run += 1
72
+ continue
73
+ if run >= quiet_need and k + som_need <= len(seg) and bool((seg[k:k + som_need] >= th).all()):
74
+ cands.append(k)
75
+ run = 0
76
+ if cands:
77
+ w = int(round(whisper / hop)) - i0
78
+ depois = [k for k in cands if k >= w - int(round(0.05 / hop))]
79
+ k = depois[0] if depois else cands[-1]
80
+ return round((i0 + k) * hop, 3), "pausa"
81
+ # 2. no pause: the rise after the deepest dip near the Whisper start
82
+ j0 = max(0, int(round((whisper - 0.08) / hop)) - i0)
83
+ j1 = min(len(seg), int(round((whisper + 0.20) / hop)) - i0)
84
+ if j1 - j0 >= 3:
85
+ k = j0 + int(np.argmin(seg[j0:j1]))
86
+ fundo = float(seg[k])
87
+ adiante = seg[k:min(len(seg), k + int(round(0.15 / hop)))]
88
+ if len(adiante) and float(adiante.max()) - fundo >= VALE_DB:
89
+ meio = fundo + (float(adiante.max()) - fundo) / 2
90
+ sobe = int(np.argmax(adiante >= meio))
91
+ return round((i0 + k + sobe) * hop, 3), "vale"
92
+ return round(whisper, 3), "whisper"
93
+
94
+
95
+ def medir_palavras(db: np.ndarray, th: float, words: list[dict]) -> list[dict]:
96
+ out = []
97
+ ws = sorted(words, key=lambda w: float(w["start"]))
98
+ for i, w in enumerate(ws):
99
+ s, e = float(w["start"]), float(w.get("end", w["start"]))
100
+ depois_de = out[-1]["start"] + 0.05 if out else 0.0
101
+ # up to 0.4 s after the Whisper start (more when Whisper stretched the word over the silence before it,
102
+ # as the first word of a cut), never into the next word
103
+ ate = max(s + DEPOIS_S, e - 0.02)
104
+ if i + 1 < len(ws):
105
+ ate = min(ate, float(ws[i + 1]["start"]))
106
+ ate = max(ate, s + 0.05)
107
+ start, como = medir(db, th, s, depois_de, ate)
108
+ out.append({"i": i, "text": w.get("text", ""), "whisper": round(s, 3), "start": start,
109
+ "end": round(max(e, start), 3), "dif": round(start - s, 3), "como": como})
110
+ return out
111
+
112
+
113
+ def main(argv: list[str] | None = None) -> int:
114
+ ap = argparse.ArgumentParser(description="Começo de cada palavra medido na onda (o Whisper adianta até 0,3 s).")
115
+ ap.add_argument("edit", nargs="?", type=Path, help="pasta edit do corte (usa cut.mp4 e transcripts/cut.json)")
116
+ ap.add_argument("--video", type=Path, help="outro vídeo (com --transcricao)")
117
+ ap.add_argument("--transcricao", type=Path, help="transcrição do vídeo (transcripts/<nome>.json)")
118
+ ap.add_argument("--palavra", action="append", default=[], help="só esta palavra (pode repetir; sem acento conta)")
119
+ ap.add_argument("--de", type=float, help="só palavras a partir deste segundo")
120
+ ap.add_argument("--ate", type=float, help="só palavras até este segundo")
121
+ ap.add_argument("--todas", action="store_true", help="mostra todas as palavras, não só as que mudaram")
122
+ ap.add_argument("--limiar", type=float, help="dB abaixo do qual é pausa (padrão: medido, como no corte pela onda)")
123
+ ap.add_argument("-o", "--saida", type=Path, help="JSON de saída (padrão: <edit>/palavras_onda.json)")
124
+ a = ap.parse_args(argv)
125
+
126
+ if a.edit:
127
+ edit = a.edit.resolve()
128
+ video = a.video or edit / "cut.mp4"
129
+ tr = a.transcricao or edit / "transcripts" / f"{Path(video).stem}.json"
130
+ saida = a.saida or edit / "palavras_onda.json"
131
+ elif a.video and a.transcricao:
132
+ video, tr = a.video, a.transcricao
133
+ saida = a.saida or Path(tr).with_name(Path(tr).stem + "_onda.json")
134
+ else:
135
+ ap.error("passe a pasta edit, ou --video e --transcricao")
136
+ video, tr = Path(video), Path(tr)
137
+ for p in (video, tr):
138
+ if not p.is_file():
139
+ print(f"ERRO: não achei {p}" + (" (rode o conferir_corte.py antes: ele transcreve o corte)"
140
+ if p == tr else ""), file=sys.stderr)
141
+ return 2
142
+ data = load_json(tr)
143
+ words = data.get("words", []) if isinstance(data, dict) else []
144
+ if not words:
145
+ print(f"ERRO: {tr.name} não tem palavras", file=sys.stderr)
146
+ return 2
147
+ db = onda.envelope(video, None)
148
+ th = a.limiar if a.limiar is not None else onda.threshold(db)
149
+ res = medir_palavras(db, th, words)
150
+ save_json(saida, {"video": str(video), "transcricao": str(tr), "limiar_db": th, "palavras": res})
151
+
152
+ alvo = {_norm(x) for x in a.palavra}
153
+ def quer(r: dict) -> bool:
154
+ if alvo and _norm(r["text"]) not in alvo:
155
+ return False
156
+ if a.de is not None and r["start"] < a.de - 0.3:
157
+ return False
158
+ if a.ate is not None and r["start"] > a.ate:
159
+ return False
160
+ return a.todas or bool(alvo) or a.de is not None or a.ate is not None or abs(r["dif"]) >= MOSTRAR_S
161
+ linhas = [r for r in res if quer(r)]
162
+ n_mov = sum(1 for r in res if abs(r["dif"]) >= MOSTRAR_S)
163
+ print(f"{video.name}: {len(res)} palavras, {n_mov} com o começo 0,05 s ou mais longe do Whisper "
164
+ f"(pausa abaixo de {th:.0f} dB) · {saida}")
165
+ if linhas:
166
+ print(f" {'onda':>8s} {'whisper':>8s} {'dif':>6s} {'como':7s} palavra")
167
+ for r in linhas:
168
+ print(f" {fmt_time(r['start']):>8s} {fmt_time(r['whisper']):>8s} {r['dif']:+.2f} {r['como']:7s} {r['text']}")
169
+ if alvo and not linhas:
170
+ print(" nenhuma palavra com esse texto (confira a grafia na fala do corte)")
171
+ print(" para animação, som ou corte na palavra: use o tempo da coluna onda (\"em\": segundos) no projeto.")
172
+ return 0
173
+
174
+
175
+ if __name__ == "__main__":
176
+ raise SystemExit(main())