primocode 10.16.0 → 10.17.1

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 (237) 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 +28 -8
  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/mcp.js +105 -7
  222. package/lib/nuvem.js +11 -1
  223. package/lib/outra-pasta.js +1 -1
  224. package/lib/passagem.js +50 -0
  225. package/lib/perigo.js +29 -12
  226. package/lib/protecao.js +134 -0
  227. package/lib/sandbox.js +11 -4
  228. package/lib/titulo-conversa.js +46 -0
  229. package/lib/tools.js +3 -0
  230. package/lib/ui.js +1 -1
  231. package/package.json +3 -2
  232. package/skills/editar-meu-video/SKILL.md +6 -2
  233. package/skills/editar-video/SKILL.md +113 -0
  234. package/studio/pessoa.py +52 -0
  235. package/voz/primo_voz/app.py +43 -4
  236. package/voz/primo_voz/chat.html +17 -6
  237. package/voz/primo_voz/painel_dados.py +134 -0
@@ -0,0 +1,601 @@
1
+ """Caption cues on the cut timeline (Contract 7).
2
+
3
+ Where the words come from (`--from`, default `auto`):
4
+ - sources: the word-level transcripts of the source videos (`transcripts/<stem>.json`, made with
5
+ the brand-name prompt and already read in Phase 1), mapped onto cut.mp4 through
6
+ `cut_timeline.json` (a word belongs to a segment when its middle falls inside the segment's
7
+ audio; it is shifted by audio_out_start - audio_src_start and kept inside that audio). No new
8
+ transcription is needed after a recut, and a word the cut chopped at a range edge is reported.
9
+ - cut: a transcript of cut.mp4 itself (`--transcript`, default `transcripts/cut.json`).
10
+ - auto: sources for every segment whose source transcript exists, cut.json for the rest.
11
+
12
+ Then `--replace "errado=certo"` (repeatable; several words allowed on each side) fixes words, and
13
+ the words are grouped into cues by measured width (with the chosen style's font), max words,
14
+ punctuation and pauses. A cue never spans a cut boundary. Words keep their original case; the cue
15
+ `text` is shown the way the style displays it (casing and punctuation), for the preview.
16
+
17
+ captions.json also records `words_from`, `timeline_key` (a fingerprint of the cut positions: the
18
+ composer refuses captions made for another cut), `duration`, `replacements`, `warnings` and
19
+ `differences` ([{"time", "sources", "cut"}]: places where the source transcripts and
20
+ transcripts/cut.json heard different words, printed as `confira:` lines so the agent picks the right
21
+ one with --replace). A cut.json whose duration does not match cut_timeline.json is from another cut
22
+ and is not used.
23
+
24
+ Usage:
25
+ uv run python helpers/captions.py --edit-dir DIR [-o DIR/captions.json] [--style ID]
26
+ [--from auto|sources|cut] [--transcript DIR/transcripts/cut.json]
27
+ [--replace "errado=certo" ...] [--max-words 3] [--pause 0.35]
28
+ (the older form `--transcript edit/transcripts/cut.json -o edit/captions.json` still works and
29
+ finds the edit dir from the transcript path; with --from auto it still prefers the sources)
30
+ """
31
+
32
+ from __future__ import annotations
33
+
34
+ import argparse
35
+ import difflib
36
+ import hashlib
37
+ import re
38
+ import sys
39
+ import unicodedata
40
+ from pathlib import Path
41
+
42
+ sys.path.insert(0, str(Path(__file__).resolve().parent))
43
+ from common import OUTPUT_FORMATS, fmt_time, load_json, load_style_catalog, now_iso, save_json # noqa: E402
44
+ import text_render as tr # noqa: E402
45
+
46
+ PAUSE_CLOSE = 0.35
47
+ SENTENCE_END = (".", "!", "?", "…")
48
+ CLAUSE_END = (",", ";", ":")
49
+ MIN_WORD = 0.04
50
+ MIN_ACTIVE = 0.07 # a word pushed to a cut still gets about 2 frames of highlight
51
+ SHORT_CUE = 0.25 # one-word cues shorter than this join a neighbour
52
+ WIDTH_TOLERANCE = 1.08 # up to 8% wider than the safe width: the renderer shrinks slightly
53
+ WEAK_PAIR_TOLERANCE = 1.4 # "o acabamento", "as influenciadoras" stay together
54
+
55
+
56
+ def _normalize_words(raw: list[dict]) -> list[dict]:
57
+ words = []
58
+ for w in raw or []:
59
+ text = str(w.get("text", "")).strip()
60
+ if not text:
61
+ continue
62
+ try:
63
+ start, end = float(w["start"]), float(w["end"])
64
+ except (KeyError, TypeError, ValueError):
65
+ continue
66
+ words.append({"text": text, "start": start, "end": end})
67
+ words.sort(key=lambda w: w["start"])
68
+ for i, w in enumerate(words):
69
+ if i and w["start"] < words[i - 1]["end"]:
70
+ # overlapping whisper times: split the difference, keep order
71
+ mid = (w["start"] + words[i - 1]["end"]) / 2.0
72
+ words[i - 1]["end"] = max(words[i - 1]["start"] + MIN_WORD, mid)
73
+ w["start"] = max(w["start"], words[i - 1]["end"])
74
+ if w["end"] < w["start"] + MIN_WORD:
75
+ w["end"] = w["start"] + MIN_WORD
76
+ return words
77
+
78
+
79
+ def timeline_key(timeline: dict | None) -> str | None:
80
+ """Fingerprint of where every segment sits in cut.mp4 and in its source. It changes when the
81
+ cut changes, and not when only the grade, gain or labels of the EDL change."""
82
+ if not timeline or not timeline.get("segments"):
83
+ return None
84
+ rows = []
85
+ for s in timeline.get("segments") or []:
86
+ try:
87
+ rows.append((str(s.get("source")), round(float(s.get("audio_src_start", s["src_start"])), 3),
88
+ round(float(s["src_end"]), 3), round(float(s["out_start"]), 3), round(float(s["out_end"]), 3),
89
+ round(float(s.get("audio_out_start", s["out_start"])), 3)))
90
+ except (KeyError, TypeError, ValueError):
91
+ continue
92
+ rows.sort(key=lambda r: r[3])
93
+ return hashlib.sha1(repr(rows).encode("utf-8")).hexdigest()[:16]
94
+
95
+
96
+ def _timeline_segments(timeline: dict | None) -> list[dict]:
97
+ segs = []
98
+ for s in (timeline or {}).get("segments", []) or []:
99
+ try:
100
+ a, b = float(s["out_start"]), float(s["out_end"])
101
+ aa = min(a, float(s.get("audio_out_start", a)))
102
+ ab = float(s.get("audio_out_end", b))
103
+ src_a = float(s.get("audio_src_start", float(s["src_start"]) - (a - aa)))
104
+ except (KeyError, TypeError, ValueError):
105
+ continue
106
+ segs.append({"id": str(s.get("id") or ""), "source": str(s.get("source") or ""), "out_start": a, "out_end": b,
107
+ "audio_out_start": aa, "audio_out_end": max(ab, b), "audio_src_start": src_a})
108
+ segs.sort(key=lambda s: s["out_start"])
109
+ return segs
110
+
111
+
112
+ CHOP_KEEP = 0.6 # a word with less than this share of its time inside the range was chopped by the cut
113
+ CUT_DURATION_TOLERANCE = 0.1 # s: a cut transcript whose duration differs more than this is from another cut
114
+ MAX_DIFFERENCES = 12
115
+
116
+
117
+ def load_cut_words(path: Path | None, timeline: dict | None) -> tuple[list[dict] | None, str | None]:
118
+ """Words of transcripts/cut.json, or (None, reason) when it is missing or was made for another cut
119
+ (its duration does not match cut_timeline.json)."""
120
+ data = load_json(path) if path and path.exists() else None
121
+ if not isinstance(data, dict) or not data.get("words"):
122
+ return None, None
123
+ dur, tl_dur = data.get("duration"), (timeline or {}).get("duration")
124
+ try:
125
+ if dur is not None and tl_dur is not None and abs(float(dur) - float(tl_dur)) > CUT_DURATION_TOLERANCE:
126
+ return None, (f"{path.name} é de outro corte ({float(dur):.2f}s e o corte tem {float(tl_dur):.2f}s): "
127
+ f"não usei essa transcrição. Transcreva o cut.mp4 de novo se precisar dela")
128
+ except (TypeError, ValueError):
129
+ pass
130
+ return _normalize_words(data["words"]), None
131
+
132
+
133
+ def transcript_differences(words: list[dict], cut_words: list[dict]) -> list[dict]:
134
+ """Places where the source transcripts and the cut transcript heard different words (swaps of up
135
+ to 3 words at about the same time). Either side may be the right one: the agent checks."""
136
+ a = [_norm_token(w["text"]) for w in words]
137
+ b = [_norm_token(w["text"]) for w in cut_words]
138
+ out = []
139
+ for tag, i1, i2, j1, j2 in difflib.SequenceMatcher(a=a, b=b, autojunk=False).get_opcodes():
140
+ if tag != "replace" or i2 - i1 > 3 or j2 - j1 > 3 or any(w.get("replaced") for w in words[i1:i2]):
141
+ continue # words fixed with --replace are already decided
142
+ if abs(words[i1]["start"] - cut_words[j1]["start"]) > 1.0:
143
+ continue
144
+ src = " ".join(w["text"].strip(".,;:!?…") for w in words[i1:i2])
145
+ cut = " ".join(w["text"].strip(".,;:!?…") for w in cut_words[j1:j2])
146
+ if _norm_token(src.replace(" ", "")) == _norm_token(cut.replace(" ", "")):
147
+ continue
148
+ out.append({"time": round(words[i1]["start"], 2), "sources": src, "cut": cut})
149
+ return out
150
+
151
+
152
+ def words_from_sources(edit_dir: Path, timeline: dict, cut_transcript: Path | None, mode: str
153
+ ) -> tuple[list[dict], str, list[str]]:
154
+ """Words on the cut timeline, each with its segment index ("seg").
155
+
156
+ -> (words, words_from, warnings). mode "sources" requires every source transcript; "auto" uses
157
+ the cut transcript for segments without one.
158
+ """
159
+ segs = _timeline_segments(timeline)
160
+ cache: dict[str, list[dict] | None] = {}
161
+ cut_words: list[dict] | None = None
162
+ cut_problem: str | None = None
163
+ words: list[dict] = []
164
+ warnings: list[str] = []
165
+ used = set()
166
+ for i, seg in enumerate(segs):
167
+ stem = seg["source"]
168
+ if stem not in cache:
169
+ data = load_json(edit_dir / "transcripts" / f"{stem}.json") if stem and stem != "cut" else None
170
+ cache[stem] = _normalize_words(data.get("words", [])) if isinstance(data, dict) and data.get("words") else None
171
+ src_words = cache[stem]
172
+ aa, ab = seg["audio_out_start"], seg["audio_out_end"]
173
+ if src_words is None:
174
+ if mode == "sources":
175
+ raise ValueError(f"falta a transcrição da fonte {stem!r} (transcripts/{stem}.json) do trecho {seg['id']}. "
176
+ f"Rode transcribe.py nessa fonte ou use --from auto")
177
+ if cut_words is None:
178
+ cut_words, cut_problem = load_cut_words(cut_transcript, timeline)
179
+ cut_words = cut_words or []
180
+ if cut_problem:
181
+ warnings.append(cut_problem)
182
+ if not cut_words:
183
+ warnings.append(f"trecho {seg['id']} ({fmt_time(seg['out_start'])}) ficou sem legenda: não há transcrição "
184
+ f"da fonte {stem!r} nem {'uma do corte atual' if cut_problem else 'do corte'}")
185
+ continue
186
+ used.add("cut")
187
+ for w in cut_words:
188
+ if aa <= (w["start"] + w["end"]) / 2.0 < ab:
189
+ words.append(dict(w, seg=i))
190
+ continue
191
+ used.add("sources")
192
+ shift = aa - seg["audio_src_start"]
193
+ s0, s1 = seg["audio_src_start"], seg["audio_src_start"] + (ab - aa)
194
+ for w in src_words:
195
+ ws, we = w["start"], w["end"]
196
+ if we <= s0 or ws >= s1:
197
+ continue
198
+ # Whisper stretches a word back over the pause before it (the first word of a file starts
199
+ # at 0.0, the word after a pause starts where the previous one ended). A word crossing an
200
+ # edge is judged by a plausible length for its letters, ending where Whisper ends it.
201
+ plausible = 0.12 + 0.07 * max(1, len(re.sub(r"\W", "", w["text"])))
202
+ es, ee = ws, we
203
+ if ws < s0 or we > s1:
204
+ es = max(ws, we - plausible)
205
+ inside = max(0.0, min(ee, s1) - max(es, s0)) / max(1e-3, ee - es)
206
+ if inside < 0.5:
207
+ if inside > 1.0 - CHOP_KEEP:
208
+ edge = "no começo" if es < s0 else "no fim"
209
+ warnings.append(f"a palavra \"{w['text']}\" ficou quase toda fora do trecho {seg['id']} ({edge}, "
210
+ f"{fmt_time(aa if es < s0 else ab)}): confira a borda no EDL")
211
+ continue
212
+ if inside < CHOP_KEEP:
213
+ edge = "no começo" if es < s0 else "no fim"
214
+ warnings.append(f"a palavra \"{w['text']}\" foi cortada {edge} do trecho {seg['id']} "
215
+ f"({fmt_time(aa if es < s0 else ab)}): confira a borda no EDL")
216
+ words.append({"text": w["text"], "start": min(max(aa, es + shift), ab - MIN_WORD),
217
+ "end": max(min(ab, ee + shift), aa + MIN_WORD), "seg": i})
218
+ words.sort(key=lambda w: (w["start"], w["seg"]))
219
+ return words, "+".join(sorted(used, reverse=True)) or "nenhuma", warnings
220
+
221
+
222
+ def _norm_token(text: str) -> str:
223
+ t = unicodedata.normalize("NFC", str(text)).lower()
224
+ return t.strip(".,;:!?…\"'“”‘’()[]{}«»-–—")
225
+
226
+
227
+ def parse_replacements(values: list[str] | None) -> list[tuple[list[str], list[str], str]]:
228
+ out = []
229
+ for raw in values or []:
230
+ if "=" not in raw:
231
+ raise ValueError(f"--replace precisa ser \"errado=certo\" (recebi {raw!r})")
232
+ left, right = raw.split("=", 1)
233
+ src = [t for t in (_norm_token(x) for x in left.split()) if t]
234
+ if not src:
235
+ raise ValueError(f"--replace sem a palavra errada antes do '=' ({raw!r})")
236
+ out.append((src, right.split(), raw))
237
+ return out
238
+
239
+
240
+ def apply_replacements(words: list[dict], reps: list[tuple[list[str], list[str], str]]) -> tuple[list[dict], list[dict]]:
241
+ """Swap word sequences (case and edge punctuation ignored when matching). The new words share
242
+ the time of the old ones, split by length; trailing punctuation of the last old word is kept."""
243
+ report = []
244
+ for src, dst, raw in reps:
245
+ count, i, out = 0, 0, []
246
+ n = len(src)
247
+ while i < len(words):
248
+ chunk = words[i:i + n]
249
+ if (len(chunk) == n and all(_norm_token(w["text"]) == t for w, t in zip(chunk, src))
250
+ and len({w.get("seg") for w in chunk}) == 1):
251
+ count += 1
252
+ if dst:
253
+ t0, t1 = chunk[0]["start"], chunk[-1]["end"]
254
+ tail = re.search(r"[.,;:!?…]+$", chunk[-1]["text"])
255
+ new = list(dst)
256
+ if tail and not re.search(r"[.,;:!?…]$", new[-1]):
257
+ new[-1] += tail.group(0)
258
+ total = sum(len(x) for x in new) or 1
259
+ t = t0
260
+ for k, token in enumerate(new):
261
+ t_end = t1 if k == len(new) - 1 else t + (t1 - t0) * len(token) / total
262
+ out.append(dict(chunk[0], text=token, start=t, end=t_end, replaced=True))
263
+ t = t_end
264
+ i += n
265
+ continue
266
+ out.append(words[i])
267
+ i += 1
268
+ words = out
269
+ report.append({"from": raw.split("=", 1)[0], "to": raw.split("=", 1)[1], "count": count})
270
+ return words, report
271
+
272
+
273
+ def _segments_from_timeline(timeline: dict | None) -> list[tuple[float, float, float]]:
274
+ """(out_start, out_end, audio_out_start) per segment. With a J-cut the audio of a segment
275
+ starts a few frames before its picture, so words spoken in that lead belong to it."""
276
+ if not timeline:
277
+ return []
278
+ segs = []
279
+ for s in timeline.get("segments", []) or []:
280
+ try:
281
+ a, b = float(s["out_start"]), float(s["out_end"])
282
+ except (KeyError, TypeError, ValueError):
283
+ continue
284
+ try:
285
+ aa = min(a, float(s.get("audio_out_start", a)))
286
+ except (TypeError, ValueError):
287
+ aa = a
288
+ segs.append((a, b, aa))
289
+ segs.sort()
290
+ return segs
291
+
292
+
293
+ def _segment_index(t: float, segs: list[tuple[float, float, float]]) -> int:
294
+ """Index of the segment whose audio contains time t. Inside a J-cut lead (two segments'
295
+ audio overlap) the incoming segment wins. Nearest segment when t falls in a gap."""
296
+ if not segs:
297
+ return 0
298
+ for i in range(len(segs) - 1, -1, -1):
299
+ a, b, aa = segs[i]
300
+ if aa <= t < b:
301
+ return i
302
+ best, best_d = 0, None
303
+ for i, (a, b, _aa) in enumerate(segs):
304
+ d = min(abs(t - a), abs(t - b))
305
+ if best_d is None or d < best_d:
306
+ best, best_d = i, d
307
+ return best
308
+
309
+
310
+ def _ends_with(text: str, marks: tuple[str, ...]) -> bool:
311
+ t = text.rstrip("\"'”’)]»")
312
+ return t.endswith(marks)
313
+
314
+
315
+ def build_cues(words: list[dict], style_id: str, max_words: int, canvas: tuple[int, int],
316
+ segs: list[tuple[float, float]], pause: float = PAUSE_CLOSE) -> list[dict]:
317
+ info = tr.caption_style_info(style_id)
318
+ lines = max(1, int(info["lines"]))
319
+ limit = tr.caption_width_limit(style_id, canvas)
320
+ # multi-line styles fill up to `lines` lines; keep a margin so the balanced split fits
321
+ width_budget = limit if lines == 1 else limit * lines * 0.88
322
+
323
+ def fits(group: list[dict]) -> bool:
324
+ if len(group) <= 1 or info["one_word"]:
325
+ return True
326
+ width = tr.measure_caption_width([w["text"] for w in group], style_id, canvas)
327
+ if width <= width_budget * WIDTH_TOLERANCE:
328
+ return True
329
+ # a lone function word reads badly: keep it with the next word and let the renderer shrink
330
+ return len(group) == 2 and tr.is_weak_word(group[0]["text"]) and width <= width_budget * WEAK_PAIR_TOLERANCE
331
+
332
+ cues: list[list[dict]] = []
333
+ cur: list[dict] = []
334
+ for w in words:
335
+ if not isinstance(w.get("seg"), int) or not 0 <= w["seg"] < max(1, len(segs)):
336
+ w["seg"] = _segment_index((w["start"] + w["end"]) / 2.0, segs)
337
+ if cur:
338
+ prev = cur[-1]
339
+ # one-line styles breathe on every comma; subtitle-like styles only on longer clauses
340
+ clause = _ends_with(prev["text"], CLAUSE_END) and (lines == 1 or len(cur) >= 4)
341
+ hard = (w["start"] - prev["end"] >= pause or w["seg"] != prev["seg"]
342
+ or _ends_with(prev["text"], SENTENCE_END) or clause)
343
+ if hard:
344
+ cues.append(cur)
345
+ cur = []
346
+ elif len(cur) >= max_words or not fits(cur + [w]):
347
+ # soft break: do not leave a function word hanging at the end of a cue
348
+ carry = []
349
+ if (len(cur) >= 2 and max_words >= 2 and tr.is_weak_word(cur[-1]["text"])
350
+ and not tr.is_weak_word(cur[-2]["text"]) and fits([cur[-1], w])):
351
+ carry = [cur.pop()]
352
+ cues.append(cur)
353
+ cur = carry
354
+ cur.append(w)
355
+ if cur:
356
+ cues.append(cur)
357
+
358
+ # an orphan word closing a sentence ("... na próxima | página.") pulls words from the cue before
359
+ for i in range(1, len(cues)):
360
+ prev, group = cues[i - 1], cues[i]
361
+ if len(group) != 1 or len(prev) < 3 or max_words < 2:
362
+ continue
363
+ if (group[0]["seg"] != prev[-1]["seg"] or group[0]["start"] - prev[-1]["end"] >= pause
364
+ or _ends_with(prev[-1]["text"], SENTENCE_END + CLAUSE_END)):
365
+ continue
366
+ moved = 0
367
+ while len(prev) > 2 and moved < 3 and len(group) < max_words:
368
+ trial = [prev[-1]] + group
369
+ if not fits(trial):
370
+ break
371
+ group.insert(0, prev.pop())
372
+ moved += 1
373
+ if not tr.is_weak_word(prev[-1]["text"]):
374
+ break
375
+
376
+ # a one-word cue shorter than SHORT_CUE ("ó,") flickers: join it to a neighbour in the same
377
+ # segment when that stays within max words and width
378
+ i = 0
379
+ while i < len(cues):
380
+ group = cues[i]
381
+ dur = group[-1]["end"] - group[0]["start"]
382
+ if len(group) == 1 and dur < SHORT_CUE and len(cues) > 1:
383
+ nxt = cues[i + 1] if i + 1 < len(cues) else None
384
+ prv = cues[i - 1] if i > 0 else None
385
+ if (nxt and nxt[0]["seg"] == group[0]["seg"] and nxt[0]["start"] - group[-1]["end"] < pause
386
+ and len(nxt) < max(2, max_words) and fits(group + nxt)
387
+ and not _ends_with(group[-1]["text"], SENTENCE_END)):
388
+ cues[i + 1] = group + nxt
389
+ del cues[i]
390
+ continue
391
+ if (prv and prv[-1]["seg"] == group[0]["seg"] and group[0]["start"] - prv[-1]["end"] < pause
392
+ and len(prv) < max(2, max_words) and fits(prv + group)):
393
+ prv.extend(group)
394
+ del cues[i]
395
+ continue
396
+ i += 1
397
+
398
+ out = []
399
+ for group in cues:
400
+ start, end = group[0]["start"], group[-1]["end"]
401
+ if segs:
402
+ a, b, _aa = segs[group[0]["seg"]]
403
+ start, end = max(start, a), min(end, b)
404
+ if end <= start:
405
+ end = start + MIN_WORD
406
+ # words spoken in a J-cut lead start before the picture cut: keep them inside the cue and
407
+ # give each at least MIN_ACTIVE so the highlight still lands on it
408
+ timed = []
409
+ floor = start
410
+ for k, w in enumerate(group):
411
+ ws, we = w["start"], w["end"]
412
+ if ws < floor:
413
+ ws = floor
414
+ we = max(we, ws + MIN_ACTIVE)
415
+ elif we - ws < MIN_ACTIVE and k + 1 < len(group):
416
+ # a near zero-length word ("ó,") borrows the gap before the next word
417
+ we = max(we, min(ws + MIN_ACTIVE, group[k + 1]["start"]))
418
+ ws = min(ws, end)
419
+ we = min(max(we, ws + MIN_WORD), end)
420
+ timed.append({"text": w["text"], "start": round(ws, 3), "end": round(we, 3)})
421
+ floor = we
422
+ disp = tr.display_words([w["text"] for w in group], style_id)
423
+ out.append({
424
+ "start": round(start, 3),
425
+ "end": round(end, 3),
426
+ "text": " ".join(disp),
427
+ "words": timed,
428
+ })
429
+ return out
430
+
431
+
432
+ def drop_ja_legendado(cues: list[dict], tela: dict | None, cobertura: float = 0.45) -> tuple[list[dict], list[dict]]:
433
+ """Tira as falas que já aparecem escritas no vídeo (legenda gravada, motion que soletra a frase)."""
434
+ if not tela or not tela.get("legenda_existente"):
435
+ return cues, []
436
+ import texto_na_tela as tnt # noqa: PLC0415
437
+
438
+ faixas = tela["legenda_existente"]
439
+ fica, sai = [], []
440
+ for cue in cues:
441
+ (sai if tnt.cobre(faixas, float(cue["start"]), float(cue["end"]), cobertura) else fica).append(cue)
442
+ return fica, sai
443
+
444
+
445
+ def main(argv: list[str] | None = None) -> int:
446
+ ap = argparse.ArgumentParser(description="Gera as legendas (captions.json) no tempo do corte, com as palavras das "
447
+ "transcrições das fontes (ou da transcrição do corte).")
448
+ ap.add_argument("--edit-dir", help="pasta edit da sessão (padrão: a pasta acima de transcripts/ do --transcript)")
449
+ ap.add_argument("--transcript", help="transcrição do cut.mp4, usada com --from cut ou nos trechos sem fonte "
450
+ "(padrão: edit/transcripts/cut.json)")
451
+ ap.add_argument("--from", dest="words_from", choices=("auto", "sources", "cut"), default="auto",
452
+ help="de onde vêm as palavras: auto (fontes e, se faltar, o corte), sources ou cut (padrão auto)")
453
+ ap.add_argument("--replace", action="append", metavar="ERRADO=CERTO",
454
+ help="troca palavra errada, ex: --replace 'Cacto=Cakto' (pode repetir; aceita várias palavras)")
455
+ ap.add_argument("-o", "--output", help="arquivo de saída (padrão: edit/captions.json)")
456
+ ap.add_argument("--style", help="estilo de legenda (padrão: o do catálogo)")
457
+ ap.add_argument("--max-words", type=int, help="máximo de palavras por legenda (padrão: o do estilo, 3 nos animados)")
458
+ ap.add_argument("--tela", help="mapa do que já está escrito na tela (padrão: edit/texto_na_tela.json, "
459
+ "gerado por texto_na_tela.py); use --tela nao para ignorar")
460
+ ap.add_argument("--pause", type=float, default=PAUSE_CLOSE, help="pausa (s) que fecha uma legenda (padrão 0.35)")
461
+ args = ap.parse_args(argv)
462
+
463
+ if not args.edit_dir and not args.transcript:
464
+ print("erro: passe --edit-dir (ou --transcript com a transcrição do corte)", file=sys.stderr)
465
+ return 2
466
+ tpath = Path(args.transcript).expanduser().resolve() if args.transcript else None
467
+ if args.edit_dir:
468
+ edit_dir = Path(args.edit_dir).expanduser().resolve()
469
+ else:
470
+ edit_dir = tpath.parent.parent if tpath.parent.name == "transcripts" else tpath.parent
471
+ if not edit_dir.is_dir():
472
+ print(f"erro: pasta de edição não encontrada: {edit_dir}", file=sys.stderr)
473
+ return 2
474
+ if tpath is None:
475
+ tpath = edit_dir / "transcripts" / "cut.json"
476
+ try:
477
+ reps = parse_replacements(args.replace)
478
+ except ValueError as e:
479
+ print(f"erro: {e}", file=sys.stderr)
480
+ return 2
481
+
482
+ timeline = load_json(edit_dir / "cut_timeline.json")
483
+ segs = _segments_from_timeline(timeline)
484
+ warnings: list[str] = []
485
+ if args.words_from != "cut" and timeline and segs:
486
+ try:
487
+ words, words_from, warnings = words_from_sources(edit_dir, timeline, tpath, args.words_from)
488
+ except ValueError as e:
489
+ print(f"erro: {e}", file=sys.stderr)
490
+ return 2
491
+ for w in words:
492
+ if w["end"] < w["start"] + MIN_WORD:
493
+ w["end"] = w["start"] + MIN_WORD
494
+ elif args.words_from == "sources":
495
+ print(f"erro: --from sources precisa do cut_timeline.json em {edit_dir}. Renderize o corte primeiro.", file=sys.stderr)
496
+ return 2
497
+ else:
498
+ if not tpath.exists():
499
+ print(f"erro: transcrição não encontrada: {tpath}", file=sys.stderr)
500
+ return 2
501
+ words = _normalize_words((load_json(tpath) or {}).get("words", []))
502
+ words_from = "cut"
503
+ _, problem = load_cut_words(tpath, timeline)
504
+ if problem:
505
+ warnings.append(problem.replace("não usei essa transcrição", "as legendas podem ficar fora do tempo"))
506
+ if not words:
507
+ print(f"erro: não achei palavras com tempo para legendar ({'fontes do corte' if words_from != 'cut' else tpath})",
508
+ file=sys.stderr)
509
+ return 2
510
+ words, rep_report = apply_replacements(words, reps)
511
+ for r in rep_report:
512
+ if not r["count"]:
513
+ warnings.append(f"--replace '{r['from']}={r['to']}': não achei \"{r['from']}\" na fala")
514
+ differences: list[dict] = []
515
+ if "sources" in words_from:
516
+ cut_words, _ = load_cut_words(tpath, timeline)
517
+ if cut_words:
518
+ differences = transcript_differences(words, cut_words)
519
+
520
+ catalog = load_style_catalog()
521
+ style_id = args.style or (catalog.get("defaults") or {}).get("caption_style") or tr.caption_style_ids()[0]
522
+ try:
523
+ info = tr.caption_style_info(style_id)
524
+ except ValueError as e:
525
+ print(f"erro: {e}", file=sys.stderr)
526
+ return 2
527
+ max_words = args.max_words if args.max_words else int(info["max_words"])
528
+ if info["one_word"] and not args.max_words:
529
+ max_words = 3 # the style shows one word at a time; cues still group phrases
530
+ if max_words < 1:
531
+ print("erro: --max-words precisa ser 1 ou mais", file=sys.stderr)
532
+ return 2
533
+
534
+ canvas = OUTPUT_FORMATS["vertical"]
535
+ if timeline and timeline.get("width") and timeline.get("height"):
536
+ canvas = (int(timeline["width"]), int(timeline["height"]))
537
+
538
+ cues = build_cues(words, style_id, max_words, canvas, segs, pause=args.pause)
539
+ tela = None
540
+ if (args.tela or "").lower() not in ("nao", "não", "no", "off"):
541
+ tela_path = Path(args.tela).expanduser().resolve() if args.tela else edit_dir / "texto_na_tela.json"
542
+ tela = load_json(tela_path) if tela_path.exists() else None
543
+ if tela and tela.get("video_sig") and timeline and timeline.get("video_sig") \
544
+ and tela["video_sig"] != timeline["video_sig"]:
545
+ warnings.append("texto_na_tela.json é de outro vídeo; rode texto_na_tela.py de novo")
546
+ tela = None
547
+ cues, ja_escritas = drop_ja_legendado(cues, tela)
548
+ boundaries = [round(seg[0], 3) for seg in segs[1:]]
549
+ out = {
550
+ "source": str((timeline or {}).get("video") or "cut.mp4"),
551
+ "style": style_id,
552
+ "max_words": max_words,
553
+ "canvas": list(canvas),
554
+ "words_from": words_from,
555
+ "timeline_key": timeline_key(timeline),
556
+ "duration": (timeline or {}).get("duration"),
557
+ "cut_boundaries": boundaries,
558
+ "replacements": rep_report,
559
+ "differences": differences,
560
+ "warnings": warnings,
561
+ "ja_escritas": [{"start": c["start"], "end": c["end"],
562
+ "text": " ".join(w["text"] for w in c["words"])} for c in ja_escritas],
563
+ "created_at": now_iso(),
564
+ "cues": cues,
565
+ }
566
+ opath = Path(args.output).expanduser().resolve() if args.output else edit_dir / "captions.json"
567
+ save_json(opath, out)
568
+
569
+ state_path = edit_dir / "state.json"
570
+ if state_path.exists() and opath.parent == edit_dir:
571
+ state = load_json(state_path, default={}) or {}
572
+ state["captions"] = opath.name
573
+ state["updated_at"] = now_iso()
574
+ save_json(state_path, state)
575
+
576
+ n_words = sum(len(c["words"]) for c in cues)
577
+ avg = n_words / len(cues) if cues else 0
578
+ extra = f", respeitando {len(boundaries)} cortes" if segs else ""
579
+ origin = {"sources": "transcrições das fontes", "cut": "transcrição do corte",
580
+ "sources+cut": "transcrições das fontes e do corte"}.get(words_from, words_from)
581
+ for r in rep_report:
582
+ if r["count"]:
583
+ print(f"troca: \"{r['from']}\" -> \"{r['to']}\" ({r['count']}x)")
584
+ for msg in warnings:
585
+ print(f"aviso: {msg}")
586
+ for d in differences[:MAX_DIFFERENCES]:
587
+ print(f"confira: {fmt_time(d['time'])} fontes \"{d['sources']}\", transcrição do corte \"{d['cut']}\" "
588
+ f"(se o corte estiver certo: --replace '{d['sources']}={d['cut']}')")
589
+ if len(differences) > MAX_DIFFERENCES:
590
+ print(f"confira: e mais {len(differences) - MAX_DIFFERENCES} diferenças (differences no captions.json)")
591
+ if ja_escritas:
592
+ segundos = sum(c["end"] - c["start"] for c in ja_escritas)
593
+ print(f"pulei {len(ja_escritas)} legenda(s) ({segundos:.0f}s) onde o vídeo já mostra a frase escrita "
594
+ f"(texto_na_tela.json)")
595
+ print(f"ok: {len(cues)} legendas, {n_words} palavras (média {avg:.1f} por legenda), estilo {style_id}{extra}, "
596
+ f"palavras: {origin} -> {opath}")
597
+ return 0
598
+
599
+
600
+ if __name__ == "__main__":
601
+ sys.exit(main())