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,961 @@
|
|
|
1
|
+
"""Suggests animations for edit_data.json "motion" from what the person says in the cut (SPEC Contract 12).
|
|
2
|
+
|
|
3
|
+
Reads <edit>/transcripts/cut.json (the words of cut.mp4 with their times) and, when they exist,
|
|
4
|
+
cut_timeline.json and edit_data.json (hook and CTA times). Finds in the speech:
|
|
5
|
+
- a comment call ("comenta IDEIA") -> comentario_cta with that keyword
|
|
6
|
+
- a price ("por apenas 37 reais", "de 97 por 37") -> etiqueta_preco (old price when spoken)
|
|
7
|
+
- "você não precisa de X, Y, nem Z" -> riscado (each item crossed when it is said)
|
|
8
|
+
- what the person gets, or "sem X, sem Y" -> checklist (each item checked when it is said)
|
|
9
|
+
- a short sequence ("primeiro..., depois..."; "Compra. Conecta. Usa.") -> passos
|
|
10
|
+
- a percentage (progress words: barra_progresso), a count with a unit ("1.200 alunos") or money that is not
|
|
11
|
+
a price ("faturei 50 mil reais por mês") -> contador
|
|
12
|
+
and prints them as "motion" items with times from the speech. SUGGESTIONS ONLY: nothing is written to
|
|
13
|
+
edit_data.json. The agent reads, edits, asks the person and copies what she wants.
|
|
14
|
+
|
|
15
|
+
Rules the suggestions follow: at most one animation every ~6 s, never during the hook, never over the CTA
|
|
16
|
+
(except the comment call, with a note), region "auto" (the compose keeps it off the face). Numbers and values
|
|
17
|
+
come only from what the person says: nothing is made up.
|
|
18
|
+
|
|
19
|
+
Usage:
|
|
20
|
+
uv run python helpers/motion_sugerir.py --edit-dir EDIT [--json] [--intervalo 6] [-o sugestoes.json]
|
|
21
|
+
|
|
22
|
+
The last stdout line is a JSON object: {"motion": [...], "skipped": [...], ...}. Each item has a "note"
|
|
23
|
+
(the phrase and why), which compose_final.py accepts and ignores.
|
|
24
|
+
Exit 2 for things to fix (missing transcript, bad folder).
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
from __future__ import annotations
|
|
28
|
+
|
|
29
|
+
import argparse
|
|
30
|
+
import json
|
|
31
|
+
import re
|
|
32
|
+
import sys
|
|
33
|
+
from dataclasses import dataclass, field
|
|
34
|
+
from pathlib import Path
|
|
35
|
+
|
|
36
|
+
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
|
37
|
+
import valores as vl # noqa: E402
|
|
38
|
+
from common import file_signature, fmt_time, load_json # noqa: E402
|
|
39
|
+
from motion import engine as ME # noqa: E402
|
|
40
|
+
from motion import registry as Rg # noqa: E402
|
|
41
|
+
from motion.engine import MotionError # noqa: E402
|
|
42
|
+
|
|
43
|
+
MIN_GAP = 6.0 # s between two suggestions (starts)
|
|
44
|
+
HOOK_DEFAULT = 3.0 # s kept free at the start when edit_data has no hook yet
|
|
45
|
+
CTA_DEFAULT = 2.5 # s at the end taken by the CTA card when edit_data does not say
|
|
46
|
+
LEAD = 0.25 # s the animation starts before the first word it shows
|
|
47
|
+
READ_AFTER = 1.2 # s kept on screen after the last word it shows
|
|
48
|
+
MIN_LENGTH = 1.5 # s: a shorter suggestion is dropped
|
|
49
|
+
SENTENCE_GAP = 0.7 # s of silence that ends a sentence
|
|
50
|
+
PRIORITY = {"comentario_cta": 100, "etiqueta_preco": 90, "riscado": 80, "checklist": 70, "passos": 65,
|
|
51
|
+
"barra_progresso": 50, "contador": 40}
|
|
52
|
+
|
|
53
|
+
FUNCTION_WORDS = {"de", "do", "da", "dos", "das", "o", "a", "os", "as", "um", "uma", "uns", "umas", "nem", "e",
|
|
54
|
+
"ou", "que", "pra", "para", "com", "no", "na", "em", "tambem", "mais", "nenhum", "nenhuma",
|
|
55
|
+
"isso", "so", "ai", "aqui", "tipo", "ne", "entao", "voce"}
|
|
56
|
+
KEYWORD_STOP = FUNCTION_WORDS | {"embaixo", "comentario", "comentarios", "mensagem", "direct", "dm", "eu", "la",
|
|
57
|
+
"palavra", "seu", "sua", "ele", "ela", "gente", "agora", "ja", "se", "me", "te"}
|
|
58
|
+
UNITS = ("alunos", "alunas", "aluno", "pessoas", "clientes", "seguidores", "dias", "horas", "minutos", "aulas",
|
|
59
|
+
"modulos", "videos", "produtos", "fornecedores", "lojas", "vezes", "anos", "meses", "semanas",
|
|
60
|
+
"vagas", "passos", "etapas", "cursos", "ferramentas", "bonus", "mentorias", "encontros", "lives")
|
|
61
|
+
UNIT_LABEL = {"alunas": "alunas", "aluno": "aluno", "modulos": "módulos", "videos": "vídeos", "bonus": "bônus"}
|
|
62
|
+
PROGRESS_WORDS = re.compile(r"\b(conclu\w*|complet\w*|pront\w*|preench\w*|vagas?|do curso|das aulas|meta|"
|
|
63
|
+
r"progresso|falta\w*|lotad\w*|esgotad\w*|acabando)\b")
|
|
64
|
+
PRICE_CONTEXT = re.compile(r"\b(por\s+(apenas|so)|apenas|custa\w*|investimento|valor|preco|paga\w*|sai\s+por|"
|
|
65
|
+
r"parcela\w*|\d+\s*x\s+de|a\s+vista|promocao|desconto|oferta|por\s+menos\s+de)\b")
|
|
66
|
+
# money said as what someone made (not what something costs): "faturei 50 mil", "30 mil de faturamento"
|
|
67
|
+
GAIN_BEFORE = re.compile(r"\b(?:fatur\w*|ganh(?:ei|ou|amos|aram|ava|avam|ando|e|ar)|lucr\w*|fiz|fez|fizemos|fizeram|"
|
|
68
|
+
r"fazendo|tirei|tirou|tiramos|tiro|tirando|caiu|cairam|recebi|recebeu|recebemos|vendi|vendeu|"
|
|
69
|
+
r"vendemos|renda|salario|comiss\w*|saldo)\b")
|
|
70
|
+
GAIN_AFTER = re.compile(r"\s*(?:de|em|no|na)?\s*(?:faturamento|lucro|renda|comiss\w*|vendas|salario)\b")
|
|
71
|
+
PERIOD_AFTER = re.compile(r"\s*(?:/\s*|por\s+|ao\s+|no\s+|na\s+|em\s+(?:um|uma)\s+)(?:mes|dia|semana|ano|hora)\b")
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
@dataclass
|
|
75
|
+
class Word:
|
|
76
|
+
text: str # as transcribed
|
|
77
|
+
norm: str # lower case, no accents, no punctuation
|
|
78
|
+
start: float
|
|
79
|
+
end: float
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
@dataclass
|
|
83
|
+
class Sentence:
|
|
84
|
+
words: list[Word]
|
|
85
|
+
text: str = ""
|
|
86
|
+
ntext: str = ""
|
|
87
|
+
spans: list[tuple[int, int]] = field(default_factory=list) # char span of each word in ntext
|
|
88
|
+
|
|
89
|
+
def __post_init__(self) -> None:
|
|
90
|
+
parts, pos = [], 0
|
|
91
|
+
for w in self.words:
|
|
92
|
+
t = vl.norm(w.text)
|
|
93
|
+
parts.append(t)
|
|
94
|
+
self.spans.append((pos, pos + len(t)))
|
|
95
|
+
pos += len(t) + 1
|
|
96
|
+
self.ntext = " ".join(parts)
|
|
97
|
+
self.text = " ".join(w.text for w in self.words)
|
|
98
|
+
|
|
99
|
+
def word_at(self, char: int) -> int:
|
|
100
|
+
for i, (a, b) in enumerate(self.spans):
|
|
101
|
+
if char < b:
|
|
102
|
+
return i
|
|
103
|
+
return len(self.words) - 1
|
|
104
|
+
|
|
105
|
+
def words_in(self, a: int, b: int) -> list[Word]:
|
|
106
|
+
return [w for w, (x, y) in zip(self.words, self.spans) if x < b and a < y]
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
@dataclass
|
|
110
|
+
class Candidate:
|
|
111
|
+
template: str
|
|
112
|
+
start: float # first word it shows
|
|
113
|
+
last: float # last word it shows (end of that word)
|
|
114
|
+
params: dict
|
|
115
|
+
quote: str
|
|
116
|
+
note: str = ""
|
|
117
|
+
times: list[float] = field(default_factory=list) # absolute seconds of each item, when it has items
|
|
118
|
+
score: int | None = None # a moment's own weight (None = the template's PRIORITY)
|
|
119
|
+
marked: bool = False # asked for at this point in the preview: always kept
|
|
120
|
+
|
|
121
|
+
@property
|
|
122
|
+
def priority(self) -> int:
|
|
123
|
+
if self.marked:
|
|
124
|
+
return 1000
|
|
125
|
+
return PRIORITY[self.template] if self.score is None else self.score
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
class SuggestError(Exception):
|
|
129
|
+
pass
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
# ------------------------------------------------------------------ reading
|
|
133
|
+
|
|
134
|
+
def clean_word(text: str) -> str:
|
|
135
|
+
return re.sub(r"[^\w%$]+", "", vl.norm(text))
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def load_words(edit_dir: Path) -> tuple[list[Word], list[str]]:
|
|
139
|
+
path = edit_dir / "transcripts" / "cut.json"
|
|
140
|
+
notes: list[str] = []
|
|
141
|
+
if not path.is_file():
|
|
142
|
+
raise SuggestError(f"não achei {path}. Transcreva o corte antes: "
|
|
143
|
+
f"transcribe.py '{edit_dir / 'cut.mp4'}' --edit-dir '{edit_dir}'")
|
|
144
|
+
try:
|
|
145
|
+
data = load_json(path)
|
|
146
|
+
except ValueError as e:
|
|
147
|
+
raise SuggestError(f"{path.name} inválido ({e})") from None
|
|
148
|
+
raw = (data or {}).get("words") if isinstance(data, dict) else None
|
|
149
|
+
if not isinstance(raw, list) or not raw:
|
|
150
|
+
raise SuggestError(f"{path.name} não tem palavras com tempo (transcreva o corte de novo)")
|
|
151
|
+
cut = edit_dir / "cut.mp4"
|
|
152
|
+
if cut.is_file() and data.get("source_sig") and data.get("source_sig") != file_signature(cut):
|
|
153
|
+
notes.append("transcripts/cut.json é de outra versão do corte: os tempos podem estar errados. "
|
|
154
|
+
"Transcreva o cut.mp4 de novo")
|
|
155
|
+
words = []
|
|
156
|
+
for w in raw:
|
|
157
|
+
try:
|
|
158
|
+
text, a, b = str(w["text"]).strip(), float(w["start"]), float(w["end"])
|
|
159
|
+
except (KeyError, TypeError, ValueError):
|
|
160
|
+
continue
|
|
161
|
+
if text:
|
|
162
|
+
words.append(Word(text, clean_word(text), a, max(a, b)))
|
|
163
|
+
words.sort(key=lambda w: w.start)
|
|
164
|
+
return words, notes
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def sentences_of(words: list[Word]) -> list[Sentence]:
|
|
168
|
+
out: list[Sentence] = []
|
|
169
|
+
cur: list[Word] = []
|
|
170
|
+
for i, w in enumerate(words):
|
|
171
|
+
cur.append(w)
|
|
172
|
+
nxt = words[i + 1] if i + 1 < len(words) else None
|
|
173
|
+
if nxt is None or re.search(r"[.!?]$", w.text) or nxt.start - w.end > SENTENCE_GAP:
|
|
174
|
+
out.append(Sentence(cur))
|
|
175
|
+
cur = []
|
|
176
|
+
return out
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def edit_windows(edit_dir: Path, duration: float) -> tuple[float, float | None, dict]:
|
|
180
|
+
"""(hook end, CTA start or None, facts) from edit_data.json, with defaults for an edit not written yet."""
|
|
181
|
+
data = None
|
|
182
|
+
path = edit_dir / "edit_data.json"
|
|
183
|
+
if path.is_file():
|
|
184
|
+
try:
|
|
185
|
+
data = load_json(path)
|
|
186
|
+
except ValueError:
|
|
187
|
+
data = None
|
|
188
|
+
if not isinstance(data, dict):
|
|
189
|
+
return HOOK_DEFAULT, max(0.0, duration - CTA_DEFAULT), {"edit_data": False}
|
|
190
|
+
|
|
191
|
+
def on(key: str) -> dict | None:
|
|
192
|
+
sec = data.get(key)
|
|
193
|
+
return sec if isinstance(sec, dict) and sec and sec.get("enabled", True) is not False else None
|
|
194
|
+
|
|
195
|
+
hook_end = 0.3
|
|
196
|
+
hook = on("hook")
|
|
197
|
+
if hook:
|
|
198
|
+
try:
|
|
199
|
+
hook_end = float(hook.get("end") if hook.get("end") is not None else 3.0)
|
|
200
|
+
except (TypeError, ValueError):
|
|
201
|
+
hook_end = 3.0
|
|
202
|
+
cta_start = None
|
|
203
|
+
cta = on("cta")
|
|
204
|
+
if cta:
|
|
205
|
+
try:
|
|
206
|
+
dur = float(cta.get("duration", 2.5) or 2.5)
|
|
207
|
+
cta_start = float(cta["start"]) if cta.get("start") is not None else max(0.0, duration - dur)
|
|
208
|
+
except (TypeError, ValueError):
|
|
209
|
+
cta_start = max(0.0, duration - CTA_DEFAULT)
|
|
210
|
+
return hook_end, cta_start, {"edit_data": True}
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
# ------------------------------------------------------------------ small text helpers
|
|
214
|
+
|
|
215
|
+
def title_case(text: str) -> str:
|
|
216
|
+
text = text.strip(" ,.;:!?-")
|
|
217
|
+
return text[:1].upper() + text[1:] if text else text
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
def original_span(s: Sentence, a: int, b: int) -> str:
|
|
221
|
+
"""The transcribed words for a char range of the normalized sentence, without trailing punctuation."""
|
|
222
|
+
return " ".join(w.text for w in s.words_in(a, b)).strip(" ,.;:!?")
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
def short_item(s: Sentence, a: int, b: int, max_words: int, max_len: int) -> tuple[str, float] | None:
|
|
226
|
+
"""A list item from a char range: leading function words dropped, at most max_words words and max_len letters.
|
|
227
|
+
Returns (text, start of its first word)."""
|
|
228
|
+
ws = s.words_in(a, b)
|
|
229
|
+
while ws and ws[0].norm in FUNCTION_WORDS:
|
|
230
|
+
ws = ws[1:]
|
|
231
|
+
ws = ws[:max_words]
|
|
232
|
+
while ws and ws[-1].norm in FUNCTION_WORDS:
|
|
233
|
+
ws = ws[:-1]
|
|
234
|
+
if not ws:
|
|
235
|
+
return None
|
|
236
|
+
text = title_case(" ".join(w.text for w in ws))
|
|
237
|
+
while len(text) > max_len and len(ws) > 1:
|
|
238
|
+
ws = ws[:-1]
|
|
239
|
+
text = title_case(" ".join(w.text for w in ws))
|
|
240
|
+
if len(text) > max_len or not re.search(r"\w", text):
|
|
241
|
+
return None
|
|
242
|
+
return text, ws[0].start
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
def split_list(s: Sentence, a: int, b: int) -> list[tuple[int, int]]:
|
|
246
|
+
"""Char ranges of the pieces of an enumeration ("x, y e z", "x, nem y")."""
|
|
247
|
+
seg = s.ntext[a:b]
|
|
248
|
+
cuts = [0]
|
|
249
|
+
for m in re.finditer(r",|\s(?:e|nem|ou)\s(?!sim\b)", seg):
|
|
250
|
+
if m.group(0) != ",":
|
|
251
|
+
said = s.words[s.word_at(a + m.start() + 1)].text.lower().strip(",.;:!?")
|
|
252
|
+
if said not in ("e", "nem", "ou"):
|
|
253
|
+
continue # "é" (the verb) loses its accent in the normalized text
|
|
254
|
+
cuts.append(m.start())
|
|
255
|
+
cuts.append(m.end())
|
|
256
|
+
cuts.append(len(seg))
|
|
257
|
+
out = []
|
|
258
|
+
for x, y in zip(cuts[::2], cuts[1::2]):
|
|
259
|
+
if y > x and seg[x:y].strip(" ,"):
|
|
260
|
+
out.append((a + x, a + y))
|
|
261
|
+
return out
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
def is_gain(s: Sentence, m: vl.Money) -> bool:
|
|
265
|
+
"""The money is what someone made ("faturei 50 mil", "30 mil de faturamento"), not a price."""
|
|
266
|
+
return bool(GAIN_BEFORE.search(s.ntext[max(0, m.start - 45):m.start]) or GAIN_AFTER.match(s.ntext, m.end))
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
# ------------------------------------------------------------------ detectors
|
|
270
|
+
|
|
271
|
+
COMMENT_RE = re.compile(r"\b(?:comenta|comente|comentem|comentar|digita|digite|escreve|escreva|"
|
|
272
|
+
r"(?:manda|mande)(?=\s+(?:a|essa)\s+palavra))\b((?:\s+(?:a|essa|esta|aqui|ai|embaixo|nos|"
|
|
273
|
+
r"comentarios|palavra|so|apenas))*)\s+[\"']?([a-z0-9]{2,20})\b")
|
|
274
|
+
|
|
275
|
+
|
|
276
|
+
def find_comment(sents: list[Sentence]) -> list[Candidate]:
|
|
277
|
+
out = []
|
|
278
|
+
for s in sents:
|
|
279
|
+
for m in COMMENT_RE.finditer(s.ntext):
|
|
280
|
+
key_i = s.word_at(m.start(2))
|
|
281
|
+
word = s.words[key_i]
|
|
282
|
+
keyword = re.sub(r"[^\w]", "", word.text).upper()
|
|
283
|
+
if word.norm == "eu" and key_i + 1 < len(s.words) and s.words[key_i + 1].norm not in KEYWORD_STOP:
|
|
284
|
+
word = s.words[key_i + 1] # "comenta EU QUERO"
|
|
285
|
+
keyword = keyword + " " + re.sub(r"[^\w]", "", word.text).upper()
|
|
286
|
+
elif word.norm in KEYWORD_STOP:
|
|
287
|
+
continue
|
|
288
|
+
keyword = keyword[:20].strip()
|
|
289
|
+
if not keyword:
|
|
290
|
+
continue
|
|
291
|
+
trigger = s.words[s.word_at(m.start())]
|
|
292
|
+
params = {"keyword": keyword, "prompt_text": "Comente *{palavra}*",
|
|
293
|
+
"delay": round(min(5.0, max(0.3, word.start - (trigger.start - LEAD))), 2)}
|
|
294
|
+
out.append(Candidate("comentario_cta", trigger.start, word.end, params,
|
|
295
|
+
original_span(s, m.start(), m.end())))
|
|
296
|
+
return out
|
|
297
|
+
|
|
298
|
+
|
|
299
|
+
def price_value(expr: str) -> tuple[float | None, str]:
|
|
300
|
+
value = vl.parse_money(expr)
|
|
301
|
+
prefix = "US$" if re.search(r"us\$|u\$|dolar", vl.norm(expr)) else "€" if "€" in expr or "euro" in vl.norm(expr) \
|
|
302
|
+
else "R$"
|
|
303
|
+
return value, prefix
|
|
304
|
+
|
|
305
|
+
|
|
306
|
+
def find_price(sents: list[Sentence]) -> list[Candidate]:
|
|
307
|
+
out = []
|
|
308
|
+
for s in sents:
|
|
309
|
+
monies = vl.find_money(s.ntext)
|
|
310
|
+
if not monies:
|
|
311
|
+
continue
|
|
312
|
+
if not PRICE_CONTEXT.search(s.ntext):
|
|
313
|
+
continue
|
|
314
|
+
if any(is_gain(s, m) for m in monies):
|
|
315
|
+
continue # money someone made is not a price: find_numbers makes it a contador
|
|
316
|
+
used_old = None
|
|
317
|
+
# "de 97 por 37": the old price and the new one
|
|
318
|
+
for a, b in zip(monies, monies[1:]):
|
|
319
|
+
between = s.ntext[a.end:b.start]
|
|
320
|
+
before = s.ntext[max(0, a.start - 4):a.start]
|
|
321
|
+
if re.search(r"\bde\s*$", before) and re.fullmatch(r"\s+(?:por|para|pra)(?:\s+(?:apenas|so))?\s+", between):
|
|
322
|
+
used_old = (a, b)
|
|
323
|
+
break
|
|
324
|
+
if used_old:
|
|
325
|
+
old_m, new_m = used_old
|
|
326
|
+
else:
|
|
327
|
+
new_m, old_m = next((m for m in monies if not m.bare), monies[0]), None
|
|
328
|
+
if new_m.bare and not re.search(r"\b(?:por\s+(?:apenas|so)|apenas|custa\w*)\s+$",
|
|
329
|
+
s.ntext[max(0, new_m.start - 16):new_m.start]):
|
|
330
|
+
continue
|
|
331
|
+
price, prefix = price_value(s.ntext[new_m.start:new_m.end])
|
|
332
|
+
if price is None or price <= 0 or price > 1e7:
|
|
333
|
+
continue
|
|
334
|
+
w_new = s.words_in(new_m.start, new_m.end)
|
|
335
|
+
params: dict = {"price": round(price, 2), "prefix": prefix,
|
|
336
|
+
"label": "por apenas" if re.search(r"\b(apenas|so)\b", s.ntext[:new_m.start]) else ""}
|
|
337
|
+
first = w_new[0].start
|
|
338
|
+
if old_m is not None:
|
|
339
|
+
old, _ = price_value(s.ntext[old_m.start:old_m.end])
|
|
340
|
+
if old is not None and old > price:
|
|
341
|
+
w_old = s.words_in(old_m.start, old_m.end)
|
|
342
|
+
params["old_price"] = round(old, 2)
|
|
343
|
+
first = w_old[0].start
|
|
344
|
+
params["tag_delay"] = round(min(5.0, max(0.45, w_new[0].start - first)), 2)
|
|
345
|
+
cut = max(new_m.end, old_m.end if old_m else 0)
|
|
346
|
+
out.append(Candidate("etiqueta_preco", first, w_new[-1].end, params,
|
|
347
|
+
original_span(s, min(new_m.start, old_m.start if old_m else new_m.start), cut),
|
|
348
|
+
note="o preço antigo precisa ser o de verdade"))
|
|
349
|
+
return out
|
|
350
|
+
|
|
351
|
+
|
|
352
|
+
NOT_NEED_RE = re.compile(r"\b(?:nao\s+(?:vai\s+)?(?:precisa|precisar|e\s+preciso)(?:\s+mais)?|sem\s+precisar)"
|
|
353
|
+
r"(?:\s+(?:de|ter\s+que))?\b")
|
|
354
|
+
|
|
355
|
+
|
|
356
|
+
def find_not_needed(sents: list[Sentence]) -> list[Candidate]:
|
|
357
|
+
"""'Você não precisa de X, Y, nem Z' (also one trigger per sentence, in sentences close together)."""
|
|
358
|
+
hits = [] # (sentence, trigger match, end of its clause)
|
|
359
|
+
for s in sents:
|
|
360
|
+
ms = list(NOT_NEED_RE.finditer(s.ntext))
|
|
361
|
+
for k, m in enumerate(ms):
|
|
362
|
+
end = ms[k + 1].start() if k + 1 < len(ms) else len(s.ntext)
|
|
363
|
+
hits.append((s, m, end))
|
|
364
|
+
groups: list[list] = []
|
|
365
|
+
for h in hits:
|
|
366
|
+
t = h[0].words[h[0].word_at(h[1].start())].start
|
|
367
|
+
if groups and t - groups[-1][-1][3] <= 4.0:
|
|
368
|
+
groups[-1].append((*h, t))
|
|
369
|
+
else:
|
|
370
|
+
groups.append([(*h, t)])
|
|
371
|
+
out = []
|
|
372
|
+
for g in groups:
|
|
373
|
+
items: list[tuple[str, float]] = []
|
|
374
|
+
last = 0.0
|
|
375
|
+
for s, m, end, _t in g:
|
|
376
|
+
pieces = split_list(s, m.end(), end)
|
|
377
|
+
for a, b in pieces:
|
|
378
|
+
it = short_item(s, a, b, 4, 32)
|
|
379
|
+
if it and it[0].lower() not in (x[0].lower() for x in items):
|
|
380
|
+
items.append(it)
|
|
381
|
+
last = max(last, s.words_in(a, b)[-1].end)
|
|
382
|
+
items = items[:6]
|
|
383
|
+
if len(items) < 2:
|
|
384
|
+
continue
|
|
385
|
+
s0, m0 = g[0][0], g[0][1]
|
|
386
|
+
start = s0.words[s0.word_at(m0.start())].start
|
|
387
|
+
quote = " ".join(original_span(s, m.start(), end) for s, m, end, _ in g)
|
|
388
|
+
out.append(Candidate("riscado", start, last,
|
|
389
|
+
{"items": [x[0] for x in items], "title": "Você *não* precisa de:"},
|
|
390
|
+
quote, times=[x[1] for x in items]))
|
|
391
|
+
return out
|
|
392
|
+
|
|
393
|
+
|
|
394
|
+
GET_RE = re.compile(r"\b(?:voce\s+)?(?:vai\s+)?(?:recebe|receber|ganha|ganhar|leva|levar|inclui|incluso|encontra|"
|
|
395
|
+
r"encontrar|"
|
|
396
|
+
r"tem\s+acesso\s+(?:a|ao|as|aos)|vai\s+ter|tem)\b(?:\s+(?:tambem|ainda|na\s+hora))?")
|
|
397
|
+
SEM_RE = re.compile(r"\bsem\s+\w+")
|
|
398
|
+
|
|
399
|
+
|
|
400
|
+
def find_checklist(sents: list[Sentence]) -> list[Candidate]:
|
|
401
|
+
out = []
|
|
402
|
+
for s in sents:
|
|
403
|
+
sems = list(SEM_RE.finditer(s.ntext))
|
|
404
|
+
if len(sems) >= 2:
|
|
405
|
+
items = []
|
|
406
|
+
for k, m in enumerate(sems):
|
|
407
|
+
end = sems[k + 1].start() if k + 1 < len(sems) else len(s.ntext)
|
|
408
|
+
piece = split_list(s, m.start(), end)
|
|
409
|
+
if not piece:
|
|
410
|
+
continue
|
|
411
|
+
ws = s.words_in(*piece[0])[:3]
|
|
412
|
+
if ws:
|
|
413
|
+
items.append((title_case(" ".join(w.text for w in ws))[:32], ws[0].start, ws[-1].end))
|
|
414
|
+
if len(items) >= 2:
|
|
415
|
+
out.append(Candidate("checklist", items[0][1], items[-1][2],
|
|
416
|
+
{"items": [x[0] for x in items[:6]]},
|
|
417
|
+
original_span(s, sems[0].start(), len(s.ntext)), times=[x[1] for x in items[:6]]))
|
|
418
|
+
continue
|
|
419
|
+
m = GET_RE.search(s.ntext)
|
|
420
|
+
if not m:
|
|
421
|
+
generic = find_enumeration(s)
|
|
422
|
+
if generic:
|
|
423
|
+
out.append(generic)
|
|
424
|
+
continue
|
|
425
|
+
pieces = split_list(s, m.end(), len(s.ntext))
|
|
426
|
+
if len(pieces) < 2:
|
|
427
|
+
continue
|
|
428
|
+
items = []
|
|
429
|
+
for a, b in pieces:
|
|
430
|
+
rel = re.search(r"\s(?:que|pra|para|onde)\s", s.ntext[a:b])
|
|
431
|
+
if rel and rel.start() > 0: # "um mecanismo ... que ajuda a ...": the item ends before the clause
|
|
432
|
+
b = a + rel.start()
|
|
433
|
+
if len(s.words_in(a, b)) > 9: # a long piece is shortened by short_item; longer is prose
|
|
434
|
+
break
|
|
435
|
+
it = short_item(s, a, b, 4, 32)
|
|
436
|
+
if it:
|
|
437
|
+
items.append((it[0], it[1], s.words_in(a, b)[-1].end))
|
|
438
|
+
if len(items) < 2:
|
|
439
|
+
continue
|
|
440
|
+
title = "O que você *recebe*:" if re.search(r"recebe|ganha|leva|inclui|acesso", m.group(0)) else ""
|
|
441
|
+
out.append(Candidate("checklist", items[0][1], items[-1][2],
|
|
442
|
+
{"items": [x[0] for x in items[:6]], "title": title},
|
|
443
|
+
original_span(s, m.start(), len(s.ntext)), times=[x[1] for x in items[:6]]))
|
|
444
|
+
return out
|
|
445
|
+
|
|
446
|
+
|
|
447
|
+
def find_enumeration(s: Sentence) -> Candidate | None:
|
|
448
|
+
"""A plain list said in one sentence ("o tamanho, o acabamento, os detalhes e o material"): at least two
|
|
449
|
+
commas and three short pieces."""
|
|
450
|
+
if s.ntext.count(",") < 2:
|
|
451
|
+
return None
|
|
452
|
+
items = []
|
|
453
|
+
for a, b in split_list(s, 0, len(s.ntext)):
|
|
454
|
+
ws = s.words_in(a, b)
|
|
455
|
+
if len(ws) > 4:
|
|
456
|
+
return None
|
|
457
|
+
it = short_item(s, a, b, 4, 32)
|
|
458
|
+
if it:
|
|
459
|
+
items.append((it[0], it[1], ws[-1].end))
|
|
460
|
+
if len(items) < 3:
|
|
461
|
+
return None
|
|
462
|
+
items = items[:6]
|
|
463
|
+
return Candidate("checklist", items[0][1], items[-1][2], {"items": [x[0] for x in items]}, s.text.strip(),
|
|
464
|
+
note="lista falada: confira se cada item faz sentido sozinho", times=[x[1] for x in items])
|
|
465
|
+
|
|
466
|
+
|
|
467
|
+
STEP_RE = re.compile(r"\b(?:primeiro|primeira\s+coisa|passo\s+(?:um|1)|depois|em\s+seguida|segundo|passo\s+(?:dois|2)|"
|
|
468
|
+
r"terceiro|passo\s+(?:tres|3)|por\s+(?:fim|ultimo)|no\s+final|e\s+pronto)\b")
|
|
469
|
+
|
|
470
|
+
|
|
471
|
+
def find_steps(sents: list[Sentence]) -> list[Candidate]:
|
|
472
|
+
out = []
|
|
473
|
+
# 1. very short sentences in a row: "Compra. Conecta. Usa."
|
|
474
|
+
run: list[Sentence] = []
|
|
475
|
+
|
|
476
|
+
def flush() -> None:
|
|
477
|
+
if 2 <= len(run) <= 4:
|
|
478
|
+
items = [title_case(" ".join(w.text for w in x.words))[:20] for x in run]
|
|
479
|
+
if all(items):
|
|
480
|
+
out.append(Candidate("passos", run[0].words[0].start, run[-1].words[-1].end, {"items": items},
|
|
481
|
+
" ".join(x.text for x in run), times=[x.words[0].start for x in run]))
|
|
482
|
+
|
|
483
|
+
for s in sents:
|
|
484
|
+
short = len(s.words) <= 2 and all(w.norm not in FUNCTION_WORDS for w in s.words)
|
|
485
|
+
if short and (not run or s.words[0].start - run[-1].words[-1].end <= 1.2):
|
|
486
|
+
run.append(s)
|
|
487
|
+
continue
|
|
488
|
+
flush()
|
|
489
|
+
run = [s] if short else []
|
|
490
|
+
flush()
|
|
491
|
+
# 2. "primeiro ..., depois ..., por fim ..."
|
|
492
|
+
words = [w for s in sents for w in s.words]
|
|
493
|
+
whole = Sentence(words) if words else None
|
|
494
|
+
if whole is None:
|
|
495
|
+
return out
|
|
496
|
+
marks = list(STEP_RE.finditer(whole.ntext))
|
|
497
|
+
i = 0
|
|
498
|
+
while i < len(marks):
|
|
499
|
+
if not re.match(r"primeir|passo", marks[i].group(0)):
|
|
500
|
+
i += 1
|
|
501
|
+
continue
|
|
502
|
+
chain = [marks[i]]
|
|
503
|
+
j = i + 1
|
|
504
|
+
while j < len(marks) and len(chain) < 4:
|
|
505
|
+
t_prev = whole.words[whole.word_at(chain[-1].start())].start
|
|
506
|
+
t_here = whole.words[whole.word_at(marks[j].start())].start
|
|
507
|
+
if t_here - t_prev > 5.0:
|
|
508
|
+
break
|
|
509
|
+
chain.append(marks[j])
|
|
510
|
+
j += 1
|
|
511
|
+
if len(chain) >= 2:
|
|
512
|
+
items = []
|
|
513
|
+
for k, m in enumerate(chain):
|
|
514
|
+
end = chain[k + 1].start() if k + 1 < len(chain) else min(len(whole.ntext), m.end() + 40)
|
|
515
|
+
ws = [w for w in whole.words_in(m.end(), end) if w.norm not in FUNCTION_WORDS][:2]
|
|
516
|
+
if not ws:
|
|
517
|
+
break
|
|
518
|
+
items.append((title_case(" ".join(w.text for w in ws))[:20], ws[0].start, ws[-1].end))
|
|
519
|
+
if len(items) == len(chain):
|
|
520
|
+
out.append(Candidate("passos", whole.words[whole.word_at(chain[0].start())].start, items[-1][2],
|
|
521
|
+
{"items": [x[0] for x in items]},
|
|
522
|
+
original_span(whole, chain[0].start(), chain[-1].end() + 30),
|
|
523
|
+
times=[x[1] for x in items]))
|
|
524
|
+
i = j if len(chain) >= 2 else i + 1
|
|
525
|
+
return out
|
|
526
|
+
|
|
527
|
+
|
|
528
|
+
PERCENT_RE = re.compile(rf"(?<![\w,.])({vl.DIGITS_RE}|{vl.WORDNUM_RE})\s*(?:%|por\s*cento\b)")
|
|
529
|
+
COUNT_RE = re.compile(rf"(?<![\w,.$])(?:mais\s+de\s+)?({vl.DIGITS_RE}|{vl.WORDNUM_RE})(?:\s+({vl.MULT_RE}))?"
|
|
530
|
+
rf"\s+(?:de\s+)?({'|'.join(UNITS)})\b((?:\s+(?!e\b|que\b|com\b|pra\b|para\b)\w+){{0,3}})")
|
|
531
|
+
|
|
532
|
+
|
|
533
|
+
def find_numbers(sents: list[Sentence]) -> list[Candidate]:
|
|
534
|
+
"""Percentages, counts with a unit and money that is not a price (what someone made, spent or has)."""
|
|
535
|
+
out = []
|
|
536
|
+
for s in sents:
|
|
537
|
+
found = []
|
|
538
|
+
for m in PERCENT_RE.finditer(s.ntext):
|
|
539
|
+
value = vl.parse_number(m.group(1))
|
|
540
|
+
if value is None or not 0 < value <= 1000:
|
|
541
|
+
continue
|
|
542
|
+
found.append(("pct", m, value))
|
|
543
|
+
for m in COUNT_RE.finditer(s.ntext):
|
|
544
|
+
value = vl.parse_number(m.group(1))
|
|
545
|
+
if value is None:
|
|
546
|
+
continue
|
|
547
|
+
if m.group(2):
|
|
548
|
+
value *= vl.MULTIPLIERS[m.group(2)]
|
|
549
|
+
if value < 2 or value > 1e9:
|
|
550
|
+
continue
|
|
551
|
+
found.append(("count", m, value))
|
|
552
|
+
monies = vl.find_money(s.ntext)
|
|
553
|
+
price_talk = PRICE_CONTEXT.search(s.ntext) and not any(is_gain(s, mo) for mo in monies)
|
|
554
|
+
for mo in monies if not price_talk else []:
|
|
555
|
+
if mo.value is None or not 0 < mo.value <= 1e12 or (mo.bare and not is_gain(s, mo)):
|
|
556
|
+
continue # a bare "10 mil" is money only when said as what someone made
|
|
557
|
+
found.append(("money", mo, mo.value))
|
|
558
|
+
if not found:
|
|
559
|
+
continue
|
|
560
|
+
kind, m, value = found[0]
|
|
561
|
+
if kind == "money":
|
|
562
|
+
ws = s.words_in(m.start, m.end)
|
|
563
|
+
_, prefix = price_value(s.ntext[m.start:m.end])
|
|
564
|
+
params = {"value": round(value, 2), "prefix": prefix}
|
|
565
|
+
period = PERIOD_AFTER.match(s.ntext, m.end)
|
|
566
|
+
if period:
|
|
567
|
+
params["label"] = original_span(s, m.end, period.end())[:40]
|
|
568
|
+
out.append(Candidate("contador", ws[0].start, ws[-1].end, params, original_span(s, m.start, m.end)))
|
|
569
|
+
continue
|
|
570
|
+
ws = s.words_in(m.start(), m.end())
|
|
571
|
+
if kind == "pct":
|
|
572
|
+
if value <= 100 and PROGRESS_WORDS.search(s.ntext):
|
|
573
|
+
label = ""
|
|
574
|
+
pm = PROGRESS_WORDS.search(s.ntext)
|
|
575
|
+
if pm:
|
|
576
|
+
label = title_case(original_span(s, pm.start(), pm.end()))[:30]
|
|
577
|
+
cand = Candidate("barra_progresso", ws[0].start, ws[-1].end,
|
|
578
|
+
{"value": value, "label": label or "Concluído"}, original_span(s, m.start(), m.end()))
|
|
579
|
+
else:
|
|
580
|
+
cand = Candidate("contador", ws[0].start, ws[-1].end, {"value": value, "suffix": "%"},
|
|
581
|
+
original_span(s, m.start(), m.end()))
|
|
582
|
+
else:
|
|
583
|
+
if m.group(3) == "anos" and re.search(r"\b(meus|minha\s+idade|aos|tinha|tenho|com)\s*$",
|
|
584
|
+
s.ntext[:m.start()].rstrip()):
|
|
585
|
+
continue # "antes dos meus 24 anos", "aos 19 anos": an age, not a result
|
|
586
|
+
unit = UNIT_LABEL.get(m.group(3), m.group(3))
|
|
587
|
+
label = original_span(s, m.end(3), m.end(4)).strip()[:40] if m.group(4).strip() else ""
|
|
588
|
+
params = {"value": value, "suffix": unit}
|
|
589
|
+
if label:
|
|
590
|
+
params["label"] = label
|
|
591
|
+
if re.match(r"mais\s+de", m.group(0)):
|
|
592
|
+
params["prefix"] = "+"
|
|
593
|
+
cand = Candidate("contador", ws[0].start, ws[-1].end, params, original_span(s, m.start(), m.end()))
|
|
594
|
+
out.append(cand)
|
|
595
|
+
return out
|
|
596
|
+
|
|
597
|
+
|
|
598
|
+
# ------------------------------------------------------------------ strong moments (no list, price or number)
|
|
599
|
+
|
|
600
|
+
# the turn of the story: what comes after these words is the point the viewer must keep
|
|
601
|
+
TURN_RE = re.compile(r"\b(?:foi\s+ai\s+que|ate\s+que|so\s+que|mas|porem|o\s+segredo\s+(?:e|foi)|a\s+verdade\s+e|"
|
|
602
|
+
r"o\s+caminho\s+que|(?:eu\s+)?descobri|a\s+virada|mudou\s+tudo|o\s+problema\s+e)\b")
|
|
603
|
+
# the offer: free, unlocked, limited time, bonus, guarantee
|
|
604
|
+
OFFER_RE = re.compile(r"\b(gratuit\w*|gratis|de\s+graca|liberad\w*|bonus|exclusiv\w*|"
|
|
605
|
+
r"(\d+|vinte\s+e\s+quatro)\s+horas|garantia\s+de\s+(\d+|sete|trinta)\s+dias|ultimas?\s+vagas?)\b")
|
|
606
|
+
EMPHASIS_RE = re.compile(r"\b(nunca|sempre|ninguem|zero|nada|todos\s+os\s+dias|de\s+verdade|simples|facil|"
|
|
607
|
+
r"rapido|sozinh\w*)\b")
|
|
608
|
+
NUMWORDS = {"sete": "7", "trinta": "30", "vinte e quatro": "24"}
|
|
609
|
+
MOMENT_MAX_WORDS = 3
|
|
610
|
+
OUTRANKS = 90 # only a moment this strong (a free offer) replaces what the patterns found there
|
|
611
|
+
|
|
612
|
+
|
|
613
|
+
def key_words(s: Sentence, from_word: int, limit: int = MOMENT_MAX_WORDS) -> tuple[list[Word], int] | None:
|
|
614
|
+
"""Up to `limit` content words from word index `from_word` (function words skipped at the edges)."""
|
|
615
|
+
ws = [w for w in s.words[from_word:] if w.norm]
|
|
616
|
+
while ws and ws[0].norm in FUNCTION_WORDS | {"eu", "que", "mas", "foi", "ai", "descobri"}:
|
|
617
|
+
ws = ws[1:]
|
|
618
|
+
ws = ws[:limit]
|
|
619
|
+
while ws and ws[-1].norm in FUNCTION_WORDS:
|
|
620
|
+
ws = ws[:-1]
|
|
621
|
+
if not ws:
|
|
622
|
+
return None
|
|
623
|
+
return ws, s.words.index(ws[0])
|
|
624
|
+
|
|
625
|
+
|
|
626
|
+
def impact_text(ws: list[Word]) -> str:
|
|
627
|
+
text = " ".join(w.text for w in ws).strip(" ,.;:!?…").upper()
|
|
628
|
+
words = text.split()
|
|
629
|
+
while len(words) >= 2 and words[0] in ("O", "A", "OS", "AS", "UM", "UMA", "E"):
|
|
630
|
+
words = words[1:] # "O CAMINHO" -> "CAMINHO": a lone article on its own line reads as a symbol
|
|
631
|
+
if len(words) >= 2: # the last word (usually the noun) in the accent color
|
|
632
|
+
words[-1] = f"*{words[-1]}*"
|
|
633
|
+
elif words:
|
|
634
|
+
words[0] = f"*{words[0]}*"
|
|
635
|
+
return " ".join(words)[:32]
|
|
636
|
+
|
|
637
|
+
|
|
638
|
+
OFFER_NOUNS = {"aula": "AULA", "aulas": "AULAS", "acesso": "ACESSO", "curso": "CURSO", "mentoria": "MENTORIA",
|
|
639
|
+
"bonus": "BÔNUS", "treinamento": "TREINAMENTO", "material": "MATERIAL", "lista": "LISTA",
|
|
640
|
+
"live": "LIVE", "masterclass": "MASTERCLASS", "ebook": "E-BOOK"}
|
|
641
|
+
FREE_RE = re.compile(r"\b(gratuit\w*|gratis|de\s+graca)\b")
|
|
642
|
+
HOURS_RE = re.compile(r"\b(\d+|vinte\s+e\s+quatro)\s+horas\b")
|
|
643
|
+
|
|
644
|
+
|
|
645
|
+
def find_offer(s: Sentence) -> Candidate | None:
|
|
646
|
+
"""The offer said in the sentence: guarantee -> selo_garantia; free / unlocked / for N hours -> carimbo with
|
|
647
|
+
what is offered ("AULA GRÁTIS", "por 24 horas" underneath). A free offer outranks a price."""
|
|
648
|
+
m = OFFER_RE.search(s.ntext)
|
|
649
|
+
if not m:
|
|
650
|
+
return None
|
|
651
|
+
g = re.search(r"garantia\s+de\s+(\d+|sete|trinta)\s+dias", s.ntext)
|
|
652
|
+
if g:
|
|
653
|
+
k = s.word_at(g.start())
|
|
654
|
+
num = NUMWORDS.get(g.group(1), g.group(1))
|
|
655
|
+
return Candidate("selo_garantia", s.words[k].start, s.words[min(len(s.words) - 1, k + 3)].end,
|
|
656
|
+
{"numero": num, "unidade": "dias", "titulo": "GARANTIA"}, s.text, score=78)
|
|
657
|
+
noun = next((OFFER_NOUNS[w.norm] for w in s.words if w.norm in OFFER_NOUNS), "")
|
|
658
|
+
free, hours = FREE_RE.search(s.ntext), HOURS_RE.search(s.ntext)
|
|
659
|
+
if free:
|
|
660
|
+
texto, key = (f"{noun} GRÁTIS" if noun else "GRÁTIS"), free
|
|
661
|
+
elif re.search(r"\bliberad\w*", s.ntext):
|
|
662
|
+
texto, key = (f"{noun} LIBERADA" if noun in ("AULA", "AULAS", "LISTA", "MENTORIA", "LIVE") else
|
|
663
|
+
f"{noun} LIBERADO" if noun else "LIBERADO"), re.search(r"\bliberad\w*", s.ntext)
|
|
664
|
+
elif re.search(r"\bexclusiv\w*|\bultimas?\s+vagas?", s.ntext):
|
|
665
|
+
key = re.search(r"\bexclusiv\w*|\bultimas?\s+vagas?", s.ntext)
|
|
666
|
+
texto = "EXCLUSIVO" if "exclusiv" in key.group(0) else "ÚLTIMAS VAGAS"
|
|
667
|
+
elif re.search(r"\bbonus\b", s.ntext):
|
|
668
|
+
texto, key = "BÔNUS", re.search(r"\bbonus\b", s.ntext)
|
|
669
|
+
else:
|
|
670
|
+
return None
|
|
671
|
+
sub = ""
|
|
672
|
+
if hours:
|
|
673
|
+
n = NUMWORDS.get(hours.group(1), hours.group(1))
|
|
674
|
+
sub = f"por {n} horas"
|
|
675
|
+
ends = [x.end() for x in (free, hours) if x] + [key.end()]
|
|
676
|
+
w0, w1 = s.words[s.word_at(key.start())], s.words[s.word_at(max(ends) - 1)]
|
|
677
|
+
return Candidate("carimbo", min(w0.start, w1.start), max(w0.end, w1.end),
|
|
678
|
+
{"texto": texto[:22], "subtexto": sub}, s.text, score=92 if free else 75,
|
|
679
|
+
note="oferta: confira se é isso mesmo que está sendo oferecido")
|
|
680
|
+
|
|
681
|
+
|
|
682
|
+
def find_moments(sents: list[Sentence]) -> list[Candidate]:
|
|
683
|
+
"""The strong points of a talk that has no list, price or number: the offer (carimbo / selo_garantia),
|
|
684
|
+
the turn of the story (palavra_impacto with what comes after "mas", "descobri"...) and short emphatic
|
|
685
|
+
lines."""
|
|
686
|
+
out: list[Candidate] = []
|
|
687
|
+
for s in sents:
|
|
688
|
+
if len(s.words) < 2:
|
|
689
|
+
continue
|
|
690
|
+
offer = find_offer(s)
|
|
691
|
+
if offer:
|
|
692
|
+
out.append(offer)
|
|
693
|
+
continue
|
|
694
|
+
m = TURN_RE.search(s.ntext)
|
|
695
|
+
if m and re.match(r"(o|a)\s", m.group(0)): # "o caminho que", "o segredo é": the marker is the idea
|
|
696
|
+
ws = [w for w in s.words_in(m.start(), m.end()) if w.norm not in ("que", "e", "foi")]
|
|
697
|
+
if ws:
|
|
698
|
+
out.append(Candidate("palavra_impacto", ws[0].start, ws[-1].end, {"text": impact_text(ws)}, s.text,
|
|
699
|
+
score=60, note="virada da fala: reescreva o texto se a ideia pedir"))
|
|
700
|
+
continue
|
|
701
|
+
if m:
|
|
702
|
+
got = key_words(s, s.word_at(m.end() - 1) + 1)
|
|
703
|
+
if got and len(got[0]) >= 1:
|
|
704
|
+
ws = got[0]
|
|
705
|
+
out.append(Candidate("palavra_impacto", ws[0].start, ws[-1].end, {"text": impact_text(ws)}, s.text,
|
|
706
|
+
score=60, note="virada da fala: reescreva o texto se a ideia pedir"))
|
|
707
|
+
continue
|
|
708
|
+
if len(s.words) <= 8:
|
|
709
|
+
m = EMPHASIS_RE.search(s.ntext)
|
|
710
|
+
if m:
|
|
711
|
+
k = s.word_at(m.start())
|
|
712
|
+
a = max(0, k - 1)
|
|
713
|
+
ws = [w for w in s.words[a:a + MOMENT_MAX_WORDS] if w.norm]
|
|
714
|
+
while ws and ws[0].norm in FUNCTION_WORDS:
|
|
715
|
+
ws = ws[1:]
|
|
716
|
+
if ws:
|
|
717
|
+
out.append(Candidate("palavra_impacto", ws[0].start, ws[-1].end, {"text": impact_text(ws)},
|
|
718
|
+
s.text, score=52, note="frase curta e forte"))
|
|
719
|
+
return out
|
|
720
|
+
|
|
721
|
+
|
|
722
|
+
def find_marked(sents: list[Sentence], marks: list[tuple[float, str]], detectors) -> tuple[list[Candidate], list[str]]:
|
|
723
|
+
"""One candidate per point marked in the preview: what the detectors find in that sentence, else the key
|
|
724
|
+
words of the sentence as palavra_impacto. Marked candidates are always kept."""
|
|
725
|
+
out, notes = [], []
|
|
726
|
+
for t, asked in marks:
|
|
727
|
+
s = next((x for x in sents if x.words[0].start - 0.4 <= t <= x.words[-1].end + 0.4), None)
|
|
728
|
+
if s is None:
|
|
729
|
+
s = min(sents, key=lambda x: min(abs(x.words[0].start - t), abs(x.words[-1].end - t)), default=None)
|
|
730
|
+
if s is None:
|
|
731
|
+
continue
|
|
732
|
+
found = [c for det in detectors for c in det([s])]
|
|
733
|
+
if found:
|
|
734
|
+
c = max(found, key=lambda c: c.priority)
|
|
735
|
+
else:
|
|
736
|
+
k = min(range(len(s.words)), key=lambda i: abs(s.words[i].start - t))
|
|
737
|
+
got = key_words(s, k) or key_words(s, 0)
|
|
738
|
+
if not got:
|
|
739
|
+
continue
|
|
740
|
+
ws = got[0]
|
|
741
|
+
c = Candidate("palavra_impacto", ws[0].start, ws[-1].end, {"text": impact_text(ws)}, s.text,
|
|
742
|
+
note="ponto marcado no preview: ajuste o texto e o modelo à ideia da frase")
|
|
743
|
+
c.marked = True
|
|
744
|
+
c.note = ("marcado no preview; " + c.note) if c.note else "marcado no preview"
|
|
745
|
+
if asked:
|
|
746
|
+
c.note += f". PEDIDO: \"{asked}\" (faça o que a pessoa pediu, mesmo que troque o modelo)"
|
|
747
|
+
out.append(c)
|
|
748
|
+
notes.append(f"ponto marcado em {fmt_time(t)} -> {c.template}")
|
|
749
|
+
return out, notes
|
|
750
|
+
|
|
751
|
+
|
|
752
|
+
def spread(items: list[dict], prio: list[int], duration: float, hook_end: float, n: int) -> list[int]:
|
|
753
|
+
"""Indexes of the n strongest items plus every marked one (priority 1000), one automatic item per part of
|
|
754
|
+
the video when possible: the time after the hook is split in n equal parts."""
|
|
755
|
+
marked = [k for k, p in enumerate(prio) if p >= 1000]
|
|
756
|
+
auto = [k for k in range(len(items)) if k not in marked]
|
|
757
|
+
if n <= 0:
|
|
758
|
+
return marked
|
|
759
|
+
if len(auto) <= n:
|
|
760
|
+
return sorted(marked + auto)
|
|
761
|
+
span = max(1e-6, duration - hook_end)
|
|
762
|
+
part = lambda k: min(n - 1, max(0, int((items[k]["start"] - hook_end) / span * n))) # noqa: E731
|
|
763
|
+
keep: list[int] = []
|
|
764
|
+
used: set[int] = set()
|
|
765
|
+
for k in sorted(auto, key=lambda k: -prio[k]):
|
|
766
|
+
if len(keep) >= n:
|
|
767
|
+
break
|
|
768
|
+
if part(k) not in used:
|
|
769
|
+
keep.append(k)
|
|
770
|
+
used.add(part(k))
|
|
771
|
+
for k in sorted(auto, key=lambda k: -prio[k]): # parts without a candidate: fill by priority
|
|
772
|
+
if len(keep) >= n:
|
|
773
|
+
break
|
|
774
|
+
if k not in keep:
|
|
775
|
+
keep.append(k)
|
|
776
|
+
return sorted(marked + keep)
|
|
777
|
+
|
|
778
|
+
|
|
779
|
+
# ------------------------------------------------------------------ choosing and timing
|
|
780
|
+
|
|
781
|
+
def relative_params(c: Candidate, start: float) -> dict:
|
|
782
|
+
p = dict(c.params)
|
|
783
|
+
if c.times:
|
|
784
|
+
rel = [round(max(0.0, t - start), 2) for t in c.times]
|
|
785
|
+
for k in range(1, len(rel)):
|
|
786
|
+
rel[k] = max(rel[k], round(rel[k - 1] + 0.1, 2))
|
|
787
|
+
p["times"] = rel
|
|
788
|
+
if c.template == "riscado":
|
|
789
|
+
p["cross_times"] = [round(t + 0.45, 2) for t in rel]
|
|
790
|
+
return p
|
|
791
|
+
|
|
792
|
+
|
|
793
|
+
def choose(cands: list[Candidate], duration: float, hook_end: float, cta_start: float | None,
|
|
794
|
+
gap: float, skipped: list[dict]) -> list[dict]:
|
|
795
|
+
picked: list[tuple[Candidate, float, float, dict, str]] = []
|
|
796
|
+
for c in sorted(cands, key=lambda c: (-c.priority, c.start)):
|
|
797
|
+
start = round(max(0.0, c.start - LEAD), 2)
|
|
798
|
+
where = {"time": round(c.start, 2), "quote": c.quote, "template": c.template}
|
|
799
|
+
if start < hook_end:
|
|
800
|
+
skipped.append({**where, "why": f"fica no gancho (até {fmt_time(hook_end)})"})
|
|
801
|
+
continue
|
|
802
|
+
if any(abs(start - other[1]) < gap for other in picked):
|
|
803
|
+
skipped.append({**where, "why": f"perto demais de outra animação (no máximo uma a cada {gap:g} s)"})
|
|
804
|
+
continue
|
|
805
|
+
params = relative_params(c, start)
|
|
806
|
+
tpl = Rg.get(c.template)
|
|
807
|
+
try:
|
|
808
|
+
resolved = Rg.resolve_params(tpl, params)
|
|
809
|
+
except MotionError as e:
|
|
810
|
+
skipped.append({**where, "why": f"parâmetros recusados: {e}"})
|
|
811
|
+
continue
|
|
812
|
+
end = max(c.last + READ_AFTER, start + tpl.suggested_duration(resolved))
|
|
813
|
+
if c.times:
|
|
814
|
+
end = max(end, c.times[-1] + 1.6)
|
|
815
|
+
note = c.note
|
|
816
|
+
limit = duration
|
|
817
|
+
if cta_start is not None:
|
|
818
|
+
if c.template == "comentario_cta":
|
|
819
|
+
if end > cta_start and start < duration:
|
|
820
|
+
note = (note + "; " if note else "") + ("a chamada (cta) aparece junto no fim: termine antes dela "
|
|
821
|
+
"ou desligue o card de chamada")
|
|
822
|
+
else:
|
|
823
|
+
limit = min(limit, cta_start - 0.2)
|
|
824
|
+
end = round(min(end, limit), 2)
|
|
825
|
+
if end - start < MIN_LENGTH:
|
|
826
|
+
skipped.append({**where, "why": "não sobra tempo antes da chamada ou do fim do vídeo"})
|
|
827
|
+
continue
|
|
828
|
+
picked.append((c, start, end, params, note))
|
|
829
|
+
picked.sort(key=lambda x: x[1])
|
|
830
|
+
# an item never runs into the next one
|
|
831
|
+
out = []
|
|
832
|
+
for k, (c, start, end, params, note) in enumerate(picked):
|
|
833
|
+
if k + 1 < len(picked):
|
|
834
|
+
end = min(end, round(picked[k + 1][1] - 0.3, 2))
|
|
835
|
+
item = {"template": c.template, "start": start, "end": end, "region": "auto", "sfx": True,
|
|
836
|
+
"params": params,
|
|
837
|
+
"note": f"fala em {fmt_time(c.start)}: \"{c.quote}\"" + (f". {note}" if note else "")}
|
|
838
|
+
out.append((item, c.priority))
|
|
839
|
+
return out
|
|
840
|
+
|
|
841
|
+
|
|
842
|
+
def load_marks(edit_dir: Path) -> list[tuple[float, str]]:
|
|
843
|
+
"""Points asked for in the preview with "Motion aqui" (marks with "kind": "motion" in
|
|
844
|
+
<edit>/preview_marks.json) and in <edit>/preview_motion.json: [(time, note)]."""
|
|
845
|
+
out: list[tuple[float, str]] = []
|
|
846
|
+
for name, only_motion in (("preview_marks.json", True), ("preview_motion.json", False)):
|
|
847
|
+
path = edit_dir / name
|
|
848
|
+
data = load_json(path) if path.is_file() else None
|
|
849
|
+
for m in (data or {}).get("marks") or [] if isinstance(data, dict) else []:
|
|
850
|
+
if isinstance(m, dict):
|
|
851
|
+
if only_motion and m.get("kind") != "motion":
|
|
852
|
+
continue
|
|
853
|
+
t = m.get("in", m.get("time"))
|
|
854
|
+
note = str(m.get("note") or "").strip()
|
|
855
|
+
else:
|
|
856
|
+
t, note = m, ""
|
|
857
|
+
try:
|
|
858
|
+
out.append((float(t), note))
|
|
859
|
+
except (TypeError, ValueError):
|
|
860
|
+
continue
|
|
861
|
+
return sorted(set(out))
|
|
862
|
+
|
|
863
|
+
|
|
864
|
+
def suggest(edit_dir: Path, gap: float = MIN_GAP, quantidade: int | None = None, tema: str | None = None) -> dict:
|
|
865
|
+
edit_dir = Path(edit_dir).expanduser().resolve()
|
|
866
|
+
if not edit_dir.is_dir():
|
|
867
|
+
raise SuggestError(f"pasta não encontrada: {edit_dir}")
|
|
868
|
+
words, notes = load_words(edit_dir)
|
|
869
|
+
timeline = load_json(edit_dir / "cut_timeline.json") if (edit_dir / "cut_timeline.json").is_file() else None
|
|
870
|
+
duration = float((timeline or {}).get("duration") or 0.0) or max(w.end for w in words)
|
|
871
|
+
hook_end, cta_start, facts = edit_windows(edit_dir, duration)
|
|
872
|
+
sents = sentences_of(words)
|
|
873
|
+
skipped: list[dict] = []
|
|
874
|
+
detectors = [find_comment, find_price, find_not_needed, find_checklist, find_steps, find_numbers, find_moments]
|
|
875
|
+
pattern = (find_comment(sents) + find_price(sents) + find_not_needed(sents) + find_checklist(sents)
|
|
876
|
+
+ find_steps(sents) + find_numbers(sents))
|
|
877
|
+
# a strong moment only where the patterns found nothing in the same stretch, unless it outranks them
|
|
878
|
+
# (a free offer beats the price said right before it; a list with a guarantee stays a list)
|
|
879
|
+
cands = list(pattern)
|
|
880
|
+
for mo in find_moments(sents):
|
|
881
|
+
rivals = [p for p in cands if p.start - 0.5 <= mo.last and mo.start <= p.last + 0.5]
|
|
882
|
+
if not rivals or (mo.priority >= OUTRANKS and all(mo.priority > p.priority for p in rivals)):
|
|
883
|
+
cands = [p for p in cands if p not in rivals] + [mo]
|
|
884
|
+
marks = load_marks(edit_dir)
|
|
885
|
+
marked, mark_notes = find_marked(sents, marks, detectors)
|
|
886
|
+
notes += mark_notes
|
|
887
|
+
# a marked point replaces what the detectors found in the same sentence
|
|
888
|
+
cands = [c for c in cands if not any(abs(c.start - m.start) < 0.05 or m.start <= c.start <= m.last
|
|
889
|
+
for m in marked)] + marked
|
|
890
|
+
chosen = choose(cands, duration, hook_end, cta_start, gap, skipped)
|
|
891
|
+
if quantidade is not None:
|
|
892
|
+
keep = spread([it for it, _ in chosen], [p for _, p in chosen], duration, hook_end, quantidade)
|
|
893
|
+
for k, (it, _) in enumerate(chosen):
|
|
894
|
+
if k not in keep:
|
|
895
|
+
skipped.append({"time": it["start"], "quote": it["note"][:80], "template": it["template"],
|
|
896
|
+
"why": f"sobrou: você pediu {quantidade} animações e as outras são mais fortes"})
|
|
897
|
+
chosen = [chosen[k] for k in keep]
|
|
898
|
+
motion = [it for it, _ in chosen]
|
|
899
|
+
if tema:
|
|
900
|
+
for it in motion:
|
|
901
|
+
it["params"].setdefault("tema", tema)
|
|
902
|
+
return {"edit_dir": str(edit_dir), "duration": round(duration, 3), "hook_end": round(hook_end, 3),
|
|
903
|
+
"cta_start": round(cta_start, 3) if cta_start is not None else None, "edit_data": facts["edit_data"],
|
|
904
|
+
"marks": [{"time": t, "note": n} for t, n in marks], "motion": motion, "skipped": sorted(skipped, key=lambda x: x["time"]), "notes": notes}
|
|
905
|
+
|
|
906
|
+
|
|
907
|
+
def main(argv: list[str] | None = None) -> int:
|
|
908
|
+
for stream in (sys.stdout, sys.stderr):
|
|
909
|
+
try:
|
|
910
|
+
stream.reconfigure(encoding="utf-8", errors="replace") # type: ignore[attr-defined]
|
|
911
|
+
except (AttributeError, ValueError):
|
|
912
|
+
pass
|
|
913
|
+
ap = argparse.ArgumentParser(description="Sugere animações (motion) pelo que a pessoa fala no corte. Só sugere: "
|
|
914
|
+
"não muda o edit_data.json.")
|
|
915
|
+
ap.add_argument("--edit-dir", required=True, help="pasta edit (com transcripts/cut.json)")
|
|
916
|
+
ap.add_argument("--json", action="store_true", help="só o JSON, sem as linhas de leitura")
|
|
917
|
+
ap.add_argument("--intervalo", type=float, default=MIN_GAP, help=f"segundos entre duas animações (padrão {MIN_GAP:g})")
|
|
918
|
+
ap.add_argument("--quantidade", type=int, default=None,
|
|
919
|
+
help="no máximo N animações, as mais fortes, espalhadas pelo vídeo (os pontos marcados no "
|
|
920
|
+
"preview entram sempre). Padrão: o \"quantidade\" do motion no edit_data, senão todas")
|
|
921
|
+
ap.add_argument("--tema", choices=list(ME.THEME_NAMES), default=None,
|
|
922
|
+
help="visual das animações (padrão: o \"tema\" do motion no edit_data)")
|
|
923
|
+
ap.add_argument("-o", "--saida", help="também grava o JSON neste arquivo")
|
|
924
|
+
args = ap.parse_args(argv)
|
|
925
|
+
try:
|
|
926
|
+
if not 1.0 <= args.intervalo <= 60.0:
|
|
927
|
+
raise SuggestError("--intervalo precisa ficar entre 1 e 60 segundos")
|
|
928
|
+
ed = load_json(Path(args.edit_dir) / "edit_data.json") if (Path(args.edit_dir) / "edit_data.json").is_file() else None
|
|
929
|
+
mcfg = ed.get("motion") if isinstance(ed, dict) and isinstance(ed.get("motion"), dict) else {}
|
|
930
|
+
qtd = args.quantidade if args.quantidade is not None else mcfg.get("quantidade")
|
|
931
|
+
result = suggest(Path(args.edit_dir), args.intervalo, int(qtd) if qtd else None,
|
|
932
|
+
args.tema or mcfg.get("tema"))
|
|
933
|
+
except SuggestError as e:
|
|
934
|
+
print(f"erro: {e}", file=sys.stderr)
|
|
935
|
+
return 2
|
|
936
|
+
if not args.json:
|
|
937
|
+
for note in result["notes"]:
|
|
938
|
+
print(f"aviso: {note}")
|
|
939
|
+
if not result["motion"]:
|
|
940
|
+
print("nenhuma animação sugerida: a fala não tem lista, preço, passo a passo, número, oferta, virada ou "
|
|
941
|
+
"chamada para comentar")
|
|
942
|
+
for k, item in enumerate(result["motion"], start=1):
|
|
943
|
+
print(f"sugestão {k}: {item['template']} de {fmt_time(item['start'])} a {fmt_time(item['end'])} · "
|
|
944
|
+
f"{item['note']}")
|
|
945
|
+
for sk in result["skipped"]:
|
|
946
|
+
print(f"fora: {fmt_time(sk['time'])} \"{sk['quote'][:70]}\" · {sk['why']}")
|
|
947
|
+
print("São só sugestões: confira o texto de cada item, pergunte à pessoa e copie para \"motion\" no "
|
|
948
|
+
"edit_data.json o que ela quiser.")
|
|
949
|
+
text = json.dumps(result, ensure_ascii=False)
|
|
950
|
+
if args.saida:
|
|
951
|
+
out = Path(args.saida).expanduser()
|
|
952
|
+
out.parent.mkdir(parents=True, exist_ok=True)
|
|
953
|
+
out.write_text(json.dumps(result, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
|
|
954
|
+
if not args.json:
|
|
955
|
+
print(f"ok: {out}")
|
|
956
|
+
print(text)
|
|
957
|
+
return 0
|
|
958
|
+
|
|
959
|
+
|
|
960
|
+
if __name__ == "__main__":
|
|
961
|
+
sys.exit(main())
|