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