primocode 10.15.2 → 10.17.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSES/edit-ia-pro.txt +32 -0
- package/LICENSES/fontes-OFL.txt +476 -0
- package/LICENSES/hyperframes-Apache-2.0.txt +205 -0
- package/LICENSES/permanent-marker-Apache-2.0.txt +204 -0
- package/LICENSES/selfie-multiclass-Apache-2.0.txt +227 -0
- package/LICENSES/three-MIT.txt +23 -0
- package/LICENSES/video-use-MIT.txt +24 -0
- package/LICENSES/woven-sfx-CC0.txt +16 -0
- package/LICENSES/yunet-MIT.txt +21 -0
- package/README.md +2 -2
- package/bin/primocode.js +74 -9
- package/editor/LICENSE.md +11 -0
- package/editor/README.md +321 -0
- package/editor/SKILL.md +307 -0
- package/editor/THIRD_PARTY_NOTICES.md +228 -0
- package/editor/assets/baixar.json +591 -0
- package/editor/assets/fonts/anton/OFL.txt +93 -0
- package/editor/assets/fonts/caveat/OFL.txt +93 -0
- package/editor/assets/fonts/figtree/OFL.txt +93 -0
- package/editor/assets/fonts/fonts.json +10 -0
- package/editor/assets/fonts/montserrat/OFL.txt +93 -0
- package/editor/assets/fonts/permanentmarker/LICENSE.txt +202 -0
- package/editor/assets/models/SELFIE_MULTICLASS_LICENSE.txt +227 -0
- package/editor/assets/models/YUNET_LICENSE.txt +21 -0
- package/editor/assets/sfx/licencas/bass_drop.txt +32 -0
- package/editor/assets/sfx/licencas/cash.txt +76 -0
- package/editor/assets/sfx/licencas/charge.txt +31 -0
- package/editor/assets/sfx/licencas/click.txt +46 -0
- package/editor/assets/sfx/licencas/coin.txt +50 -0
- package/editor/assets/sfx/licencas/count.txt +47 -0
- package/editor/assets/sfx/licencas/data.txt +31 -0
- package/editor/assets/sfx/licencas/error.txt +32 -0
- package/editor/assets/sfx/licencas/flash.txt +31 -0
- package/editor/assets/sfx/licencas/gears.txt +82 -0
- package/editor/assets/sfx/licencas/keyboard.txt +48 -0
- package/editor/assets/sfx/licencas/metal_hit.txt +62 -0
- package/editor/assets/sfx/licencas/metal_riser.txt +61 -0
- package/editor/assets/sfx/licencas/mouse_click.txt +50 -0
- package/editor/assets/sfx/licencas/notification.txt +54 -0
- package/editor/assets/sfx/licencas/pop.txt +38 -0
- package/editor/assets/sfx/licencas/rewind.txt +32 -0
- package/editor/assets/sfx/licencas/rise.txt +46 -0
- package/editor/assets/sfx/licencas/sinteticos.txt +21 -0
- package/editor/assets/sfx/licencas/snap.txt +50 -0
- package/editor/assets/sfx/licencas/success.txt +40 -0
- package/editor/assets/sfx/licencas/synth_hit.txt +32 -0
- package/editor/assets/sfx/licencas/tick.txt +14 -0
- package/editor/assets/sfx/licencas/whoosh.txt +47 -0
- package/editor/assets/sfx/licencas/wind.txt +31 -0
- package/editor/assets/sfx/sfx.json +832 -0
- package/editor/assets/sfx/sfx_sinteticos.json +317 -0
- package/editor/assets/sfx/sfx_woven.json +387 -0
- package/editor/assets/sfx/woven/LICENCA.md +16 -0
- package/editor/fichas/briefing-do-video.md +45 -0
- package/editor/fichas/conferencia-da-amostra.md +56 -0
- package/editor/fichas/matriz-de-ganchos.csv +3 -0
- package/editor/fichas/padrao.json +16 -0
- package/editor/fichas/planilha-de-cenas.csv +4 -0
- package/editor/fichas/planilha-de-versoes.csv +4 -0
- package/editor/fichas/plano-em-blocos.md +32 -0
- package/editor/fichas/registro-de-materiais.json +27 -0
- package/editor/helpers/assets_ocr/ocr_vision.swift +41 -0
- package/editor/helpers/bordas_precisas.py +272 -0
- package/editor/helpers/camera_viva.py +426 -0
- package/editor/helpers/captions.py +601 -0
- package/editor/helpers/common.py +308 -0
- package/editor/helpers/compose_final.py +3555 -0
- package/editor/helpers/conferir_corte.py +234 -0
- package/editor/helpers/conferir_dados.py +405 -0
- package/editor/helpers/contact_sheet.py +282 -0
- package/editor/helpers/corte_por_onda.py +433 -0
- package/editor/helpers/corte_seco.py +595 -0
- package/editor/helpers/denoise.py +971 -0
- package/editor/helpers/efeitos.py +408 -0
- package/editor/helpers/efeitos_gerar.py +159 -0
- package/editor/helpers/elevenlabs_api.py +442 -0
- package/editor/helpers/face_layout.py +570 -0
- package/editor/helpers/fetch_images.py +423 -0
- package/editor/helpers/ganho_por_trecho.py +214 -0
- package/editor/helpers/gen_image.py +312 -0
- package/editor/helpers/gen_music.py +316 -0
- package/editor/helpers/gen_sfx.py +148 -0
- package/editor/helpers/grade.py +307 -0
- package/editor/helpers/icloud.py +119 -0
- package/editor/helpers/make_sfx.py +940 -0
- package/editor/helpers/matte.py +1342 -0
- package/editor/helpers/medir_referencia.py +2232 -0
- package/editor/helpers/mixar_trilha.py +354 -0
- package/editor/helpers/montar_corte.py +295 -0
- package/editor/helpers/motion/__init__.py +13 -0
- package/editor/helpers/motion/engine.py +1915 -0
- package/editor/helpers/motion/registry.py +510 -0
- package/editor/helpers/motion/render.py +665 -0
- package/editor/helpers/motion/templates/barra_progresso.py +370 -0
- package/editor/helpers/motion/templates/carimbo.py +132 -0
- package/editor/helpers/motion/templates/checklist.py +271 -0
- package/editor/helpers/motion/templates/comentario_cta.py +380 -0
- package/editor/helpers/motion/templates/contador.py +222 -0
- package/editor/helpers/motion/templates/destaque.py +263 -0
- package/editor/helpers/motion/templates/etiqueta_preco.py +461 -0
- package/editor/helpers/motion/templates/grafico_barras.py +510 -0
- package/editor/helpers/motion/templates/grafico_linha.py +462 -0
- package/editor/helpers/motion/templates/notificacao.py +266 -0
- package/editor/helpers/motion/templates/palavra_impacto.py +410 -0
- package/editor/helpers/motion/templates/passos.py +321 -0
- package/editor/helpers/motion/templates/riscado.py +298 -0
- package/editor/helpers/motion/templates/selo_garantia.py +150 -0
- package/editor/helpers/motion_render.py +241 -0
- package/editor/helpers/motion_sugerir.py +961 -0
- package/editor/helpers/noise_check.py +134 -0
- package/editor/helpers/pack_transcripts.py +223 -0
- package/editor/helpers/palavras_na_onda.py +176 -0
- package/editor/helpers/plano_sfx.py +839 -0
- package/editor/helpers/portao.py +427 -0
- package/editor/helpers/preview_server.py +1177 -0
- package/editor/helpers/rastrear_rosto.py +298 -0
- package/editor/helpers/render_cut.py +1256 -0
- package/editor/helpers/respiros.py +89 -0
- package/editor/helpers/sincronizar_cameras.py +571 -0
- package/editor/helpers/speech_regions.py +423 -0
- package/editor/helpers/style_catalog.json +231 -0
- package/editor/helpers/style_samples.py +246 -0
- package/editor/helpers/tela_verde.py +2113 -0
- package/editor/helpers/text_render.py +2059 -0
- package/editor/helpers/texto_na_tela.py +366 -0
- package/editor/helpers/timeline_view.py +292 -0
- package/editor/helpers/tira_de_quadros.py +113 -0
- package/editor/helpers/transcribe.py +734 -0
- package/editor/helpers/transcribe_batch.py +129 -0
- package/editor/helpers/trilhas.py +1146 -0
- package/editor/helpers/trilhas_humores.json +66 -0
- package/editor/helpers/valores.py +187 -0
- package/editor/helpers/verify_cut.py +820 -0
- package/editor/helpers/voice_levels.py +233 -0
- package/editor/helpers/voz_check.py +386 -0
- package/editor/helpers/voz_limpa.py +81 -0
- package/editor/helpers/watch_preview.py +372 -0
- package/editor/modelo-projeto/LEIA.md +402 -0
- package/editor/modelo-projeto/assets/fontes/OFL-Caveat.txt +93 -0
- package/editor/modelo-projeto/assets/fontes/OFL-JetBrainsMono.txt +93 -0
- package/editor/modelo-projeto/assets/fontes/OFL-Montserrat.txt +93 -0
- package/editor/modelo-projeto/assets/fontes/larguras.json +1 -0
- package/editor/modelo-projeto/assets/three/BufferGeometryUtils.js +1438 -0
- package/editor/modelo-projeto/assets/three/GLTFLoader.js +4895 -0
- package/editor/modelo-projeto/assets/three/LICENSE +21 -0
- package/editor/modelo-projeto/assets/three/ORIGEM.txt +17 -0
- package/editor/modelo-projeto/assets/three/RoomEnvironment.js +185 -0
- package/editor/modelo-projeto/assets/three/three.core.min.js +6 -0
- package/editor/modelo-projeto/assets/three/three.module.min.js +6 -0
- package/editor/modelo-projeto/build.py +4828 -0
- package/editor/modelo-projeto/compositions/cenas/2d/k2d_antes_depois.html +199 -0
- package/editor/modelo-projeto/compositions/cenas/2d/k2d_arquivo.html +232 -0
- package/editor/modelo-projeto/compositions/cenas/2d/k2d_busca.html +315 -0
- package/editor/modelo-projeto/compositions/cenas/2d/k2d_comando.html +304 -0
- package/editor/modelo-projeto/compositions/cenas/2d/k2d_conversa.html +217 -0
- package/editor/modelo-projeto/compositions/cenas/2d/k2d_esquema.html +289 -0
- package/editor/modelo-projeto/compositions/cenas/2d/k2d_medidor.html +249 -0
- package/editor/modelo-projeto/compositions/cenas/2d/k2d_notas.html +233 -0
- package/editor/modelo-projeto/compositions/cenas/3d/k3d_caminho.html +197 -0
- package/editor/modelo-projeto/compositions/cenas/3d/k3d_explodida.html +152 -0
- package/editor/modelo-projeto/compositions/cenas/3d/k3d_tela.html +167 -0
- package/editor/modelo-projeto/compositions/cenas/3d/k3d_volume.html +124 -0
- package/editor/modelo-projeto/compositions/cenas/3d/motor-three.js +728 -0
- package/editor/modelo-projeto/compositions/cenas/LEIA.md +177 -0
- package/editor/modelo-projeto/ferramentas/medir_fontes.py +65 -0
- package/editor/modelo-projeto/ferramentas/pessoa_recortada.py +176 -0
- package/editor/modelo-projeto/ferramentas/rastrear_maos.py +189 -0
- package/editor/modelo-projeto/hyperframes.json +12 -0
- package/editor/modelo-projeto/package.json +11 -0
- package/editor/modelo-projeto/projeto.json +16 -0
- package/editor/preview/app.css +1009 -0
- package/editor/preview/app.js +2122 -0
- package/editor/preview/index.html +307 -0
- package/editor/pyproject.toml +23 -0
- package/editor/references/3d.md +236 -0
- package/editor/references/animacao-catalogo.md +672 -0
- package/editor/references/animacao.md +498 -0
- package/editor/references/comandos.md +92 -0
- package/editor/references/corte-seco-como-funciona.md +50 -0
- package/editor/references/corte-seco.md +88 -0
- package/editor/references/corte.md +290 -0
- package/editor/references/direcao-por-blocos.md +218 -0
- package/editor/references/enquadramento.md +654 -0
- package/editor/references/estilos-prontos.md +355 -0
- package/editor/references/estudio.md +100 -0
- package/editor/references/exemplo-eventos.json +67 -0
- package/editor/references/lettering.md +252 -0
- package/editor/references/modelos.md +494 -0
- package/editor/references/phase2.md +1007 -0
- package/editor/references/referencia-aplicar.md +203 -0
- package/editor/references/referencia-medir.md +375 -0
- package/editor/references/revisao.md +335 -0
- package/editor/references/som-e-trilha.md +146 -0
- package/editor/references/tabela-de-sons.md +130 -0
- package/editor/references/variacoes.md +234 -0
- package/editor/uv.lock +1141 -0
- package/lib/act.js +38 -8
- package/lib/app-chat.js +97 -0
- package/lib/catalogo.js +7 -1
- package/lib/chamada-torta.js +81 -0
- package/lib/editor-pro.js +786 -0
- package/lib/entrega.js +58 -0
- package/lib/estudio/conferencia-visual.js +76 -0
- package/lib/estudio/diretor.js +6 -1
- package/lib/estudio/exportar.js +3 -1
- package/lib/estudio/gosto/tela.html +1349 -0
- package/lib/estudio/gosto.js +305 -0
- package/lib/estudio/index.js +12 -1
- package/lib/estudio/kit-ae/base.jsx +119 -13
- package/lib/estudio/kit-ae/cenas.jsx +47 -15
- package/lib/estudio/kit-ae/guarda.jsx +274 -0
- package/lib/estudio/kit-ae/primitivas.jsx +180 -24
- package/lib/estudio/maquina-diretor.js +70 -12
- package/lib/estudio/maquina-foto.js +177 -0
- package/lib/estudio/maquina-guarda.js +207 -0
- package/lib/estudio/maquina-layout.js +22 -1
- package/lib/estudio/maquina.js +64 -11
- package/lib/estudio/referencias.js +1 -1
- package/lib/estudio/visao/visao.swift +97 -0
- package/lib/estudio/visao.js +60 -0
- package/lib/headless.js +15 -3
- package/lib/local/cerebro.json +285 -7
- package/lib/local/motor.js +5 -2
- package/lib/memoria-usuario.js +164 -0
- package/lib/nuvem.js +33 -2
- package/lib/passagem.js +50 -0
- package/lib/perigo.js +19 -11
- package/lib/protecao.js +134 -0
- package/lib/repeticao.js +1 -1
- package/lib/sandbox.js +57 -9
- package/lib/titulo-conversa.js +46 -0
- package/lib/tools.js +23 -4
- package/lib/ui.js +2 -2
- package/package.json +3 -2
- package/skills/editar-meu-video/SKILL.md +6 -2
- package/skills/editar-video/SKILL.md +113 -0
- package/studio/pessoa.py +52 -0
- package/voz/primo_voz/__main__.py +55 -40
- package/voz/primo_voz/app.py +500 -4
- package/voz/primo_voz/chat.html +1773 -0
- package/voz/primo_voz/conversa.py +0 -26
- package/voz/primo_voz/hud.html +8 -2
- package/voz/primo_voz/hud.py +129 -5
- package/voz/primo_voz/identidade.py +1 -1
- 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())
|