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,820 @@
|
|
|
1
|
+
"""Numeric self-check of a rendered cut. Prints a short PT-BR report and writes
|
|
2
|
+
edit/verify/cut_report.json.
|
|
3
|
+
|
|
4
|
+
Usage:
|
|
5
|
+
uv run python helpers/verify_cut.py <edit>/edl.json <edit>/cut.mp4 [--min-silence 0.7] [--ask-approval]
|
|
6
|
+
|
|
7
|
+
Checks (each problem becomes a FLAG with a timestamp on the cut):
|
|
8
|
+
DURATION video/audio/timeline durations and frame counts disagree
|
|
9
|
+
STALE-TIMELINE the EDL changed after the render (edl_sig differs) or no timeline found
|
|
10
|
+
PREVIEW the file is a low-resolution --preview render
|
|
11
|
+
SOURCE-MISSING a source of the EDL cannot be found or its audio cannot be read (edges unchecked)
|
|
12
|
+
SILENT the cut has no sound at all
|
|
13
|
+
CLICK sample jump at a junction boundary far above the local signal
|
|
14
|
+
EDGE-IN-SPEECH a range starts or ends while the source is still sounding (cut inside a word)
|
|
15
|
+
EDGE-IN-WORD a range edge falls inside a transcribed word and the gap there is only a
|
|
16
|
+
consonant closure (t, k, p): the rest of the word is cut off
|
|
17
|
+
REPEAT a range replays speech of the previous range of the same source
|
|
18
|
+
DEAD-AIR silence >= --min-silence inside the cut (with a concrete start/end to use)
|
|
19
|
+
BLACK black frames
|
|
20
|
+
CLIPPING clipped samples or true peak above -1.0 dBTP (ebur128 or the 2-decimal loudnorm reading)
|
|
21
|
+
LOUDNESS integrated loudness more than 1.5 LU away from -14 LUFS
|
|
22
|
+
LOW-LEVEL a range more than 4 dB under the reference level (speech-weighted median of the
|
|
23
|
+
heard range levels), or the farther one (from the reference) of two neighbours, or of
|
|
24
|
+
the quietest and loudest ranges, more than 6 dB apart. The message gives the final
|
|
25
|
+
gain_db to write in the EDL: it closes the gap between the SOURCE levels (gain_db
|
|
26
|
+
works before loudnorm, whose dynamic mode squeezes the gap heard in the cut)
|
|
27
|
+
HIGH-LEVEL the same, over the reference level
|
|
28
|
+
Notices that are not flags (HDR source, crop without a face) go to "warnings".
|
|
29
|
+
|
|
30
|
+
state.json: when the file is the cut of the edit dir, verify asks for the approval
|
|
31
|
+
(awaiting approval_cut) only when there are no flags, or with --ask-approval after the flags
|
|
32
|
+
were explained. Otherwise awaiting stays null.
|
|
33
|
+
Exit code 0 even with flags; 2 when the EDL or the video cannot be read.
|
|
34
|
+
"""
|
|
35
|
+
|
|
36
|
+
from __future__ import annotations
|
|
37
|
+
|
|
38
|
+
import argparse
|
|
39
|
+
import json
|
|
40
|
+
import math
|
|
41
|
+
import re
|
|
42
|
+
import subprocess
|
|
43
|
+
import sys
|
|
44
|
+
from concurrent.futures import ThreadPoolExecutor
|
|
45
|
+
from pathlib import Path
|
|
46
|
+
|
|
47
|
+
import numpy as np
|
|
48
|
+
|
|
49
|
+
from common import OUTPUT_FORMATS, file_signature, fmt_time, load_json, now_iso, probe_video, save_json
|
|
50
|
+
from contact_sheet import abspath, find_timeline
|
|
51
|
+
|
|
52
|
+
SR = 48000
|
|
53
|
+
TARGET_I = -14.0
|
|
54
|
+
TRUE_PEAK_LIMIT = -1.0
|
|
55
|
+
LEVEL_DELTA_DB = 4.0
|
|
56
|
+
ADJACENT_JUMP_DB = 6.0
|
|
57
|
+
GAIN_CAP_DB = 12.0
|
|
58
|
+
CLICK_RATIO = 12.0
|
|
59
|
+
CLICK_MIN_JUMP = 0.02
|
|
60
|
+
EDGE_FLOOR_DB = -50.0
|
|
61
|
+
EDGE_ABOVE_NOISE_DB = 15.0 # an edge is on speech only this far above the source's own noise floor:
|
|
62
|
+
# breath tails and room hiss 10 dB over the floor flooded the report, and
|
|
63
|
+
# a report full of false alarms got real cut words ignored (Criativo 01)
|
|
64
|
+
WORD_MARGIN_S = 0.08 # the quiet run must sit at least this far inside the word on both sides
|
|
65
|
+
WORD_GAP_MAX_S = 0.12 # a quiet run shorter than this around the edge is a closure, not a pause
|
|
66
|
+
WORD_REACH_S = 0.15 # sound must exist on both sides within this distance
|
|
67
|
+
SILENT_DB = -60.0
|
|
68
|
+
SEAMLESS_S = 0.02 # next range restarts the source this close to where the previous ended
|
|
69
|
+
BLOCKS_APPROVAL = {"STALE-TIMELINE", "DURATION", "PREVIEW"}
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def db(x: float) -> float:
|
|
73
|
+
return 20 * math.log10(max(float(x), 1e-9))
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def decode_audio(path: Path, start: float = 0.0, dur: float | None = None, mono: bool = True,
|
|
77
|
+
af: str | None = None) -> np.ndarray:
|
|
78
|
+
cmd = ["ffmpeg", "-v", "error", "-nostdin"]
|
|
79
|
+
if start > 0:
|
|
80
|
+
cmd += ["-ss", f"{start:.6f}"]
|
|
81
|
+
cmd += ["-i", str(path)]
|
|
82
|
+
if dur is not None:
|
|
83
|
+
cmd += ["-t", f"{dur:.6f}"]
|
|
84
|
+
if af:
|
|
85
|
+
cmd += ["-af", af]
|
|
86
|
+
cmd += ["-vn", "-ac", "1" if mono else "2", "-ar", str(SR), "-f", "f32le", "-"]
|
|
87
|
+
proc = subprocess.run(cmd, capture_output=True)
|
|
88
|
+
if proc.returncode != 0:
|
|
89
|
+
return np.zeros(0 if mono else (0, 2), dtype=np.float32)
|
|
90
|
+
data = np.frombuffer(proc.stdout, dtype=np.float32)
|
|
91
|
+
return data if mono else data.reshape(-1, 2)
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def rms_frames(x: np.ndarray, win_s: float) -> np.ndarray:
|
|
95
|
+
win = max(1, int(win_s * SR))
|
|
96
|
+
n = x.size // win
|
|
97
|
+
if n == 0:
|
|
98
|
+
return np.zeros(0)
|
|
99
|
+
return np.sqrt(np.mean(x[: n * win].reshape(n, win) ** 2, axis=1))
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def power_mean_db(values_db: np.ndarray) -> float:
|
|
103
|
+
return float(10 * np.log10(np.mean(10 ** (values_db / 10)))) if values_db.size else -120.0
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def weighted_median(values: list[float], weights: list[float]) -> float:
|
|
107
|
+
"""Level that half of the speech time sits at or above (ties go to the louder level)."""
|
|
108
|
+
pairs = sorted(zip(values, weights), key=lambda p: -p[0])
|
|
109
|
+
total = sum(max(w, 1e-6) for _, w in pairs)
|
|
110
|
+
acc = 0.0
|
|
111
|
+
for v, w in pairs:
|
|
112
|
+
acc += max(w, 1e-6)
|
|
113
|
+
if acc >= total / 2 - 1e-9:
|
|
114
|
+
return v
|
|
115
|
+
return pairs[-1][0]
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def ffmpeg_stderr(cmd: list[str]) -> str:
|
|
119
|
+
return subprocess.run(cmd, capture_output=True, text=True).stderr
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def fmt_db(value: float | None) -> str:
|
|
123
|
+
return "--" if value is None else f"{value:.1f}"
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
# ------------------------------------------------------------------ checks
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def loudness_stats(video: Path) -> dict:
|
|
130
|
+
err = ffmpeg_stderr(["ffmpeg", "-hide_banner", "-nostats", "-nostdin", "-i", str(video), "-vn",
|
|
131
|
+
"-af", "ebur128=peak=true:framelog=quiet", "-f", "null", "-"])
|
|
132
|
+
tail = err[err.rfind("Summary:"):] if "Summary:" in err else err
|
|
133
|
+
|
|
134
|
+
def grab(pattern: str) -> float | None:
|
|
135
|
+
m = re.search(pattern, tail)
|
|
136
|
+
if not m:
|
|
137
|
+
return None
|
|
138
|
+
return -120.0 if "inf" in m.group(1) else float(m.group(1))
|
|
139
|
+
|
|
140
|
+
return {"integrated": grab(r"I:\s+(-?inf|-?[\d.]+) LUFS"), "lra": grab(r"LRA:\s+(-?inf|-?[\d.]+) LU"),
|
|
141
|
+
"true_peak": grab(r"Peak:\s+(-?inf|-?[\d.]+) dBFS")}
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def black_frames(video: Path) -> tuple[list[dict], int]:
|
|
145
|
+
"""Black intervals and the number of frames that really decode (full decode pass)."""
|
|
146
|
+
proc = subprocess.run(["ffmpeg", "-hide_banner", "-nostats", "-nostdin", "-i", str(video), "-an",
|
|
147
|
+
"-vf", "scale=180:-2,blackdetect=d=0.08:pic_th=0.97:pix_th=0.10",
|
|
148
|
+
"-progress", "pipe:1", "-f", "null", "-"], capture_output=True, text=True)
|
|
149
|
+
out = []
|
|
150
|
+
for m in re.finditer(r"black_start:(-?[\d.]+) black_end:(-?[\d.]+) black_duration:([\d.]+)", proc.stderr):
|
|
151
|
+
out.append({"start": float(m.group(1)), "end": float(m.group(2)), "duration": float(m.group(3))})
|
|
152
|
+
decoded = [int(x) for x in re.findall(r"^frame=(\d+)", proc.stdout, flags=re.M)]
|
|
153
|
+
return out, (decoded[-1] if decoded else 0)
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def dead_air(mono: np.ndarray, noise_db: float, min_silence: float) -> list[dict]:
|
|
157
|
+
"""Silence runs >= min_silence on 20 ms RMS windows (a 3-window median ignores single bumps).
|
|
158
|
+
|
|
159
|
+
ffmpeg's silencedetect tests every sample's amplitude, so room noise peaks split real pauses
|
|
160
|
+
into short pieces after loudness normalization; RMS windows measure what the ear hears.
|
|
161
|
+
"""
|
|
162
|
+
win = int(0.02 * SR)
|
|
163
|
+
n = mono.size // win
|
|
164
|
+
if n < 3:
|
|
165
|
+
return []
|
|
166
|
+
vals = 20 * np.log10(np.maximum(np.sqrt(np.mean(mono[: n * win].reshape(n, win) ** 2, axis=1)), 1e-9))
|
|
167
|
+
smooth = np.median(np.stack([np.r_[vals[:1], vals[:-1]], vals, np.r_[vals[1:], vals[-1:]]]), axis=0)
|
|
168
|
+
quiet = smooth < noise_db
|
|
169
|
+
out, i = [], 0
|
|
170
|
+
while i < n:
|
|
171
|
+
if not quiet[i]:
|
|
172
|
+
i += 1
|
|
173
|
+
continue
|
|
174
|
+
j = i
|
|
175
|
+
while j < n and quiet[j]:
|
|
176
|
+
j += 1
|
|
177
|
+
a, b = i * 0.02, min(j * 0.02, mono.size / SR)
|
|
178
|
+
if b - a >= min_silence - 1e-3:
|
|
179
|
+
out.append({"start": round(a, 3), "end": round(b, 3), "duration": round(b - a, 3)})
|
|
180
|
+
i = j
|
|
181
|
+
return out
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def click_probe(audio: np.ndarray, t: float) -> dict:
|
|
185
|
+
"""Max first-difference in +-10 ms around t vs the RMS of the first difference in +-300 ms."""
|
|
186
|
+
c = int(round(t * SR))
|
|
187
|
+
lo, hi = max(1, c - int(0.3 * SR)), min(audio.shape[0], c + int(0.3 * SR))
|
|
188
|
+
if hi - lo < int(0.05 * SR):
|
|
189
|
+
return {"jump": 0.0, "ratio": 0.0}
|
|
190
|
+
seg = audio[lo - 1:hi]
|
|
191
|
+
d = np.abs(np.diff(seg, axis=0))
|
|
192
|
+
if d.ndim == 2:
|
|
193
|
+
d = d.max(axis=1)
|
|
194
|
+
k0, k1 = max(0, c - lo - int(0.01 * SR)), min(d.size, c - lo + int(0.01 * SR))
|
|
195
|
+
near = d[k0:k1]
|
|
196
|
+
mask = np.ones(d.size, dtype=bool)
|
|
197
|
+
mask[k0:k1] = False
|
|
198
|
+
base = float(np.sqrt(np.mean(d[mask] ** 2))) if mask.any() else 0.0
|
|
199
|
+
jump = float(near.max()) if near.size else 0.0
|
|
200
|
+
return {"jump": round(jump, 5), "ratio": round(jump / max(base, 1e-5), 2)}
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def loudest_channel(x: np.ndarray) -> np.ndarray:
|
|
204
|
+
"""Stereo -> the louder channel (a phase-cancelled pair would vanish in an L+R mix)."""
|
|
205
|
+
if x.ndim == 1 or x.shape[0] == 0:
|
|
206
|
+
return x.reshape(-1)
|
|
207
|
+
return x[:, int(np.argmax(np.mean(x ** 2, axis=0)))]
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
RUMBLE_AF = "highpass=f=300,highpass=f=300" # same filter as bordas_precisas: car, road, AC rumble out
|
|
211
|
+
RUMBLE_RANGE_DB = 20.0 # a source whose voice stands less than this over its floor
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
def noise_floor_db(src: Path) -> tuple[float | None, str | None]:
|
|
215
|
+
"""(room noise of a whole source, audio filter its edges are measured with). The floor is the 10th
|
|
216
|
+
percentile of the 10 ms level of the louder channel. A source with rumble (voice less than
|
|
217
|
+
RUMBLE_RANGE_DB over the floor) is measured without the lows, or the rumble reads as speech at every edge."""
|
|
218
|
+
for af in (None, RUMBLE_AF):
|
|
219
|
+
x = loudest_channel(decode_audio(src, mono=False, af=af))
|
|
220
|
+
vals = rms_frames(x, 0.01)
|
|
221
|
+
if not vals.size:
|
|
222
|
+
return None, None
|
|
223
|
+
lo, hi = db(np.percentile(vals, 10)), db(np.percentile(vals, 90))
|
|
224
|
+
if hi - lo >= RUMBLE_RANGE_DB or af:
|
|
225
|
+
return float(lo), af
|
|
226
|
+
return None, None
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
def edge_probe(src: Path, t_edge: float, side: str, ref_db: float, floor_db: float | None = None,
|
|
230
|
+
af: str | None = None) -> dict:
|
|
231
|
+
"""Level just inside and just outside a source cut edge (40 ms each)."""
|
|
232
|
+
w = 0.04
|
|
233
|
+
x = loudest_channel(decode_audio(src, max(0.0, t_edge - w), 2 * w, mono=False, af=af))
|
|
234
|
+
half = x.size // 2
|
|
235
|
+
before = db(np.sqrt(np.mean(x[:half] ** 2))) if half else -120.0
|
|
236
|
+
after = db(np.sqrt(np.mean(x[half:] ** 2))) if x.size - half > 0 else -120.0
|
|
237
|
+
inside, outside = (after, before) if side == "start" else (before, after)
|
|
238
|
+
gate = max(ref_db - 20.0, EDGE_FLOOR_DB,
|
|
239
|
+
floor_db + EDGE_ABOVE_NOISE_DB if floor_db is not None else EDGE_FLOOR_DB)
|
|
240
|
+
return {"inside_db": round(inside, 1), "outside_db": round(outside, 1), "gate_db": round(gate, 1),
|
|
241
|
+
"in_speech": bool(inside > gate and outside > gate and t_edge > w)}
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
def word_at(transcript: dict | None, t: float) -> dict | None:
|
|
245
|
+
if not transcript:
|
|
246
|
+
return None
|
|
247
|
+
for w in transcript.get("words", []):
|
|
248
|
+
if float(w.get("start", 0)) < t < float(w.get("end", 0)):
|
|
249
|
+
return w
|
|
250
|
+
return None
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
def word_edge_probe(src: Path, t_edge: float, ref_db: float, transcript: dict | None,
|
|
254
|
+
af: str | None = None) -> dict | None:
|
|
255
|
+
"""Edge inside a transcribed word: is the quiet run around it only a consonant closure?
|
|
256
|
+
|
|
257
|
+
A stop consonant (t, k, p) makes 50-60 ms of near silence inside a word, so the 40 ms level
|
|
258
|
+
probe reads one side as quiet. Here the 10 ms level shows a quiet run shorter than
|
|
259
|
+
WORD_GAP_MAX_S with sound on both sides, and that run sits inside a transcribed word with
|
|
260
|
+
WORD_MARGIN_S of the word on each side: the word goes on past the edge. A pause between
|
|
261
|
+
words is longer, or lies at a word boundary (Whisper word starts drift into short pauses,
|
|
262
|
+
hence the margin). Calibrated on the test footage: 90% of mid-word edges caught, no hit on
|
|
263
|
+
pause edges with the SKILL padding nor on the edges of the real sessions.
|
|
264
|
+
"""
|
|
265
|
+
w = word_at(transcript, t_edge)
|
|
266
|
+
if w is None:
|
|
267
|
+
return None
|
|
268
|
+
t0 = max(0.0, t_edge - WORD_REACH_S)
|
|
269
|
+
x = loudest_channel(decode_audio(src, t0, t_edge + WORD_REACH_S - t0, mono=False, af=af))
|
|
270
|
+
vals = np.array([db(v) for v in rms_frames(x, 0.01)])
|
|
271
|
+
if vals.size < 4:
|
|
272
|
+
return None
|
|
273
|
+
gate = max(ref_db - 20.0, EDGE_FLOOR_DB)
|
|
274
|
+
loud = vals > gate
|
|
275
|
+
c = min(max(int((t_edge - t0) / 0.01), 0), vals.size - 1)
|
|
276
|
+
lo = hi = c
|
|
277
|
+
if not loud[c]:
|
|
278
|
+
while lo > 0 and not loud[lo - 1]:
|
|
279
|
+
lo -= 1
|
|
280
|
+
while hi < vals.size - 1 and not loud[hi + 1]:
|
|
281
|
+
hi += 1
|
|
282
|
+
gap = (hi - lo + 1) * 0.01
|
|
283
|
+
else:
|
|
284
|
+
gap = 0.0
|
|
285
|
+
g0, g1 = t0 + lo * 0.01, t0 + (hi + 1) * 0.01
|
|
286
|
+
hit = bool(gap < WORD_GAP_MAX_S and loud[:lo].any() and loud[hi + 1:].any()
|
|
287
|
+
and float(w["start"]) + WORD_MARGIN_S <= g0 and g1 <= float(w["end"]) - WORD_MARGIN_S)
|
|
288
|
+
return {"word": str(w.get("text", "")).strip(), "word_start": float(w["start"]), "word_end": float(w["end"]),
|
|
289
|
+
"gap_s": round(gap, 2), "in_word": hit}
|
|
290
|
+
|
|
291
|
+
|
|
292
|
+
# ------------------------------------------------------------------ state
|
|
293
|
+
|
|
294
|
+
|
|
295
|
+
def update_state(edit_dir: Path, video: Path, flags: list[dict], ask_approval: bool) -> tuple[str | None, bool | None]:
|
|
296
|
+
"""Ask for the approval of the cut in state.json only after this check.
|
|
297
|
+
|
|
298
|
+
Returns (PT-BR line, approval requested) or (None, None) when the video is not the cut of the edit dir."""
|
|
299
|
+
state_path = edit_dir / "state.json"
|
|
300
|
+
state = load_json(state_path, default={}) or {}
|
|
301
|
+
if not isinstance(state, dict):
|
|
302
|
+
state = {}
|
|
303
|
+
if video.parent != edit_dir or video.name != (state.get("cut_video") or "cut.mp4"):
|
|
304
|
+
return None, None
|
|
305
|
+
blocking = sorted({f["code"] for f in flags if f["code"] in BLOCKS_APPROVAL})
|
|
306
|
+
ready = not blocking and (not flags or ask_approval)
|
|
307
|
+
if ready:
|
|
308
|
+
state.update(phase="cut", awaiting="approval_cut",
|
|
309
|
+
message="Corte pronto. Assista e aprove ou marque o que mudar.")
|
|
310
|
+
line = "Preview: aprovação do corte pedida."
|
|
311
|
+
else:
|
|
312
|
+
n = len(flags)
|
|
313
|
+
state.update(phase="cut", awaiting=None,
|
|
314
|
+
message=f"Conferindo o corte: {n} {'ponto' if n == 1 else 'pontos'} para ajustar antes de mostrar.")
|
|
315
|
+
if blocking:
|
|
316
|
+
line = f"Preview: aprovação NÃO pedida ({', '.join(blocking)}): renderize de novo."
|
|
317
|
+
else:
|
|
318
|
+
line = ("Preview: aprovação ainda não pedida. Corrija e renderize de novo, ou rode com "
|
|
319
|
+
"--ask-approval depois de explicar os avisos que vão ficar.")
|
|
320
|
+
state["updated_at"] = now_iso()
|
|
321
|
+
save_json(state_path, state)
|
|
322
|
+
return line, ready
|
|
323
|
+
|
|
324
|
+
|
|
325
|
+
# ------------------------------------------------------------------ main
|
|
326
|
+
|
|
327
|
+
|
|
328
|
+
def verify(edl_path: Path, video: Path, min_silence: float) -> tuple[dict, list[str]]:
|
|
329
|
+
from render_cut import ASPECT_WARN, edl_signature, resolve_source, true_peak_db
|
|
330
|
+
|
|
331
|
+
edl = load_json(edl_path)
|
|
332
|
+
edit_dir = edl_path.parent
|
|
333
|
+
info = probe_video(video)
|
|
334
|
+
if not info.get("has_video") or not info.get("duration"):
|
|
335
|
+
raise ValueError("o vídeo não tem imagem ou duração")
|
|
336
|
+
flags: list[dict] = []
|
|
337
|
+
warnings: list[str] = []
|
|
338
|
+
|
|
339
|
+
def flag(code: str, t: float | None, message: str, rid: str | None = None, end: float | None = None) -> None:
|
|
340
|
+
item = {"code": code, "time": None if t is None else round(t, 3), "message": message}
|
|
341
|
+
if end is not None:
|
|
342
|
+
item["end"] = round(end, 3)
|
|
343
|
+
if rid:
|
|
344
|
+
item["range"] = rid
|
|
345
|
+
flags.append(item)
|
|
346
|
+
|
|
347
|
+
# ---- duration vs timeline
|
|
348
|
+
tl_path = find_timeline(video)
|
|
349
|
+
if tl_path is None and (edit_dir / "cut_timeline.json").exists():
|
|
350
|
+
tl_path = edit_dir / "cut_timeline.json"
|
|
351
|
+
timeline = load_json(tl_path) if tl_path else None
|
|
352
|
+
fps = float((timeline or {}).get("fps") or info.get("fps") or 30)
|
|
353
|
+
streams = json.loads(subprocess.run(
|
|
354
|
+
["ffprobe", "-v", "error", "-show_entries", "stream=codec_type,duration,nb_frames", "-of", "json", str(video)],
|
|
355
|
+
capture_output=True, text=True).stdout or "{}").get("streams", [])
|
|
356
|
+
v_dur = next((float(s.get("duration") or 0) for s in streams if s.get("codec_type") == "video"), info["duration"])
|
|
357
|
+
a_dur = next((float(s.get("duration") or 0) for s in streams if s.get("codec_type") == "audio"), None)
|
|
358
|
+
v_frames = next((int(s.get("nb_frames") or 0) for s in streams if s.get("codec_type") == "video"), 0)
|
|
359
|
+
dur_info = {"video": round(v_dur, 3), "audio": None if a_dur is None else round(a_dur, 3), "frames": v_frames,
|
|
360
|
+
"fps": fps}
|
|
361
|
+
|
|
362
|
+
if timeline:
|
|
363
|
+
segs = timeline.get("segments") or []
|
|
364
|
+
dur_info.update(timeline=timeline.get("duration"), timeline_frames=timeline.get("frames"))
|
|
365
|
+
if timeline.get("edl_sig") and edl_signature(edl) != timeline["edl_sig"]:
|
|
366
|
+
flag("STALE-TIMELINE", None, "o EDL mudou depois do render: renderize de novo antes de aprovar")
|
|
367
|
+
if abs(v_dur - float(timeline.get("duration") or 0)) > 1.0 / fps + 1e-3:
|
|
368
|
+
flag("DURATION", v_dur, f"vídeo tem {v_dur:.3f}s e o timeline diz {float(timeline['duration']):.3f}s")
|
|
369
|
+
if timeline.get("frames") and v_frames and int(timeline["frames"]) != v_frames:
|
|
370
|
+
flag("DURATION", None, f"vídeo tem {v_frames} quadros e o timeline diz {timeline['frames']}")
|
|
371
|
+
else:
|
|
372
|
+
flag("STALE-TIMELINE", None, "timeline não encontrado: medições por trecho usam a conta do EDL")
|
|
373
|
+
segs, pos = [], 0.0
|
|
374
|
+
for r in edl.get("ranges", []):
|
|
375
|
+
d = float(r["end"]) - float(r["start"])
|
|
376
|
+
segs.append({"id": r.get("id"), "source": r["source"], "src_start": float(r["start"]),
|
|
377
|
+
"src_end": float(r["end"]), "out_start": pos, "out_end": pos + d,
|
|
378
|
+
"audio_out_start": pos, "audio_out_end": pos + d, "audio_src_start": float(r["start"])})
|
|
379
|
+
pos += d
|
|
380
|
+
if a_dur is None:
|
|
381
|
+
flag("DURATION", None, "o corte não tem áudio")
|
|
382
|
+
elif abs(a_dur - v_dur) > 0.05:
|
|
383
|
+
flag("DURATION", v_dur, f"áudio ({a_dur:.3f}s) e vídeo ({v_dur:.3f}s) com durações diferentes")
|
|
384
|
+
|
|
385
|
+
# ---- preview render (half resolution) passing as the approval file
|
|
386
|
+
out_cfg = edl.get("output") or {}
|
|
387
|
+
fmt_wh = OUTPUT_FORMATS.get(out_cfg.get("format") or "vertical", (0, 0))
|
|
388
|
+
want_w, want_h = int(out_cfg.get("width") or fmt_wh[0]), int(out_cfg.get("height") or fmt_wh[1])
|
|
389
|
+
got_w, got_h = int(info.get("display_width") or 0), int(info.get("display_height") or 0)
|
|
390
|
+
if (timeline or {}).get("preview") or (want_w and got_w and (got_w < want_w - 2 or got_h < want_h - 2)):
|
|
391
|
+
flag("PREVIEW", None, f"prévia em baixa resolução ({got_w}x{got_h}, o EDL pede {want_w}x{want_h}): "
|
|
392
|
+
"renderize sem --preview antes de pedir aprovação")
|
|
393
|
+
|
|
394
|
+
# ---- full decode: black frames + how many frames really decode
|
|
395
|
+
blacks, decoded_frames = black_frames(video)
|
|
396
|
+
dur_info["decoded_frames"] = decoded_frames
|
|
397
|
+
if decoded_frames == 0:
|
|
398
|
+
raise ValueError("nenhum quadro decodifica (arquivo incompleto ou corrompido)")
|
|
399
|
+
if v_frames and decoded_frames < v_frames - 1:
|
|
400
|
+
flag("DURATION", decoded_frames / fps, f"só {decoded_frames} de {v_frames} quadros decodificam: "
|
|
401
|
+
"arquivo incompleto ou corrompido, renderize de novo")
|
|
402
|
+
|
|
403
|
+
# ---- audio decode (stereo for peaks/clicks, mono mix for levels)
|
|
404
|
+
stereo = decode_audio(video, mono=False) if a_dur else np.zeros((0, 2), dtype=np.float32)
|
|
405
|
+
mono = stereo.mean(axis=1) if stereo.size else np.zeros(0, dtype=np.float32)
|
|
406
|
+
if a_dur and stereo.shape[0] < (a_dur - 0.1) * SR:
|
|
407
|
+
flag("DURATION", stereo.shape[0] / SR, f"só {stereo.shape[0] / SR:.2f}s de {a_dur:.2f}s de áudio "
|
|
408
|
+
"decodificam: arquivo incompleto ou corrompido")
|
|
409
|
+
|
|
410
|
+
loud = loudness_stats(video) if a_dur else {}
|
|
411
|
+
peak = float(np.abs(stereo).max()) if stereo.size else 0.0
|
|
412
|
+
clipped = int((np.abs(stereo) >= 0.999).sum()) if stereo.size else 0
|
|
413
|
+
# ebur128 prints the true peak with 1 decimal (-0.96 reads -1.0); the loudnorm reading used by
|
|
414
|
+
# render_cut's peak guard has 2. Either one above the limit is a flag.
|
|
415
|
+
loud.update(sample_peak_db=round(db(peak), 2), clipped_samples=clipped,
|
|
416
|
+
true_peak_loudnorm=true_peak_db(video) if a_dur else None)
|
|
417
|
+
silent = a_dur is not None and (db(peak) < SILENT_DB or (loud.get("integrated") is not None
|
|
418
|
+
and loud["integrated"] <= -69.0))
|
|
419
|
+
if silent:
|
|
420
|
+
flag("SILENT", None, "o corte está sem som: confira se as fontes têm áudio")
|
|
421
|
+
if clipped > 2:
|
|
422
|
+
flag("CLIPPING", float(np.argmax(np.abs(stereo).max(axis=1) >= 0.999)) / SR,
|
|
423
|
+
f"{clipped} amostras estouradas (clipping)")
|
|
424
|
+
tp_worst = max((v for v in (loud.get("true_peak"), loud.get("true_peak_loudnorm")) if v is not None), default=None)
|
|
425
|
+
if not silent and tp_worst is not None and tp_worst > TRUE_PEAK_LIMIT:
|
|
426
|
+
flag("CLIPPING", None, f"pico real {tp_worst:.2f} dBTP, acima de {TRUE_PEAK_LIMIT:.1f}: "
|
|
427
|
+
"renderize de novo (o render baixa o volume para caber)")
|
|
428
|
+
if not silent and loud.get("integrated") is not None and abs(loud["integrated"] - TARGET_I) > 1.5:
|
|
429
|
+
flag("LOUDNESS", None, f"loudness {loud['integrated']:.1f} LUFS, alvo {TARGET_I:.0f}")
|
|
430
|
+
|
|
431
|
+
# ---- speech reference and adaptive silence threshold
|
|
432
|
+
frames_db = np.array([db(v) for v in rms_frames(mono, 0.05)]) if mono.size else np.zeros(0)
|
|
433
|
+
speech_ref = float(np.percentile(frames_db, 90)) if frames_db.size else -20.0
|
|
434
|
+
floor_db = float(np.percentile(frames_db, 10)) if frames_db.size else -60.0
|
|
435
|
+
noise_db = min(-20.0, max(floor_db + 10.0, speech_ref - 25.0))
|
|
436
|
+
|
|
437
|
+
# ---- junction click probe
|
|
438
|
+
junctions = []
|
|
439
|
+
for a, b in zip(segs, segs[1:]):
|
|
440
|
+
probes = [click_probe(stereo, float(b["out_start"]))]
|
|
441
|
+
if float(b.get("audio_out_start", b["out_start"])) < float(b["out_start"]) - 1e-6:
|
|
442
|
+
probes.append(click_probe(stereo, float(b["audio_out_start"])))
|
|
443
|
+
worst = max(probes, key=lambda p: p["ratio"])
|
|
444
|
+
click = worst["ratio"] > CLICK_RATIO and worst["jump"] > CLICK_MIN_JUMP
|
|
445
|
+
junctions.append({"time": round(float(b["out_start"]), 3), "between": [a["id"], b["id"]], **worst,
|
|
446
|
+
"click": click})
|
|
447
|
+
if click:
|
|
448
|
+
flag("CLICK", float(b["out_start"]), f"estalo na junção {a['id']} -> {b['id']} "
|
|
449
|
+
f"(salto {worst['jump']:.3f}, {worst['ratio']:.0f}x o normal)", b["id"])
|
|
450
|
+
if segs and stereo.size and not silent:
|
|
451
|
+
for t, label in ((0.0, "início"), (v_dur, "fim")):
|
|
452
|
+
p = click_probe(stereo, min(max(t, 0.012), v_dur - 0.012))
|
|
453
|
+
if p["ratio"] > CLICK_RATIO and p["jump"] > CLICK_MIN_JUMP:
|
|
454
|
+
flag("CLICK", t, f"estalo no {label} do vídeo")
|
|
455
|
+
|
|
456
|
+
# ---- sources: resolved like render_cut, probed once
|
|
457
|
+
sources = edl.get("sources") or {}
|
|
458
|
+
src_info: dict[str, dict] = {}
|
|
459
|
+
for key in dict.fromkeys(s["source"] for s in segs):
|
|
460
|
+
value = sources.get(key)
|
|
461
|
+
if not value:
|
|
462
|
+
flag("SOURCE-MISSING", None, f"a fonte {key!r} não está em \"sources\" do EDL: bordas não conferidas")
|
|
463
|
+
continue
|
|
464
|
+
path = resolve_source(str(value), edit_dir)
|
|
465
|
+
if not path.exists():
|
|
466
|
+
flag("SOURCE-MISSING", None, f"a fonte {key} não foi encontrada ({path}): as bordas dos trechos dela "
|
|
467
|
+
"não foram conferidas. Corrija o caminho no EDL")
|
|
468
|
+
continue
|
|
469
|
+
try:
|
|
470
|
+
pinfo = probe_video(path)
|
|
471
|
+
except Exception: # noqa: BLE001
|
|
472
|
+
flag("SOURCE-MISSING", None, f"não consegui ler a fonte {key} ({path.name}): bordas não conferidas")
|
|
473
|
+
continue
|
|
474
|
+
sig = file_signature(path)
|
|
475
|
+
tr = load_json(edit_dir / "transcripts" / f"{path.stem}.json")
|
|
476
|
+
if isinstance(tr, dict) and tr.get("source_sig") and tr["source_sig"] != sig:
|
|
477
|
+
tr = None # the source changed after the transcription: word times are not trustworthy
|
|
478
|
+
reg = load_json(edit_dir / "regions" / f"{path.stem}.json")
|
|
479
|
+
speech = ([[float(a), float(b)] for a, b in reg["speech"]]
|
|
480
|
+
if isinstance(reg, dict) and isinstance(reg.get("speech"), list)
|
|
481
|
+
and reg.get("source_sig") in (None, sig) and "start" not in reg else None)
|
|
482
|
+
src_info[key] = {"path": path, "info": pinfo, "transcript": tr if isinstance(tr, dict) else None,
|
|
483
|
+
"speech": speech}
|
|
484
|
+
src_info[key]["floor"], src_info[key]["af"] = noise_floor_db(path) if pinfo.get("has_audio") else (None, None)
|
|
485
|
+
if pinfo.get("is_hdr"):
|
|
486
|
+
warnings.append(f"fonte HDR ({key}): as cores foram convertidas para SDR. Confira a cor no preview "
|
|
487
|
+
"e, nos próximos vídeos, grave com o HDR do celular desligado.")
|
|
488
|
+
for s in segs:
|
|
489
|
+
crop = s.get("crop") if isinstance(s.get("crop"), dict) else None
|
|
490
|
+
if crop and crop.get("focus_from") == "centro" and float(crop.get("kept") or 1) < 1 / ASPECT_WARN:
|
|
491
|
+
warnings.append(f"trecho {s['id']}: a imagem foi cortada no centro ({float(crop['kept']):.0%} guardado) "
|
|
492
|
+
"sem rosto encontrado. Confira o enquadramento; ajuste focus_x/focus_y no trecho.")
|
|
493
|
+
|
|
494
|
+
# ---- cut edges inside speech or inside a word (source side)
|
|
495
|
+
edge_jobs = []
|
|
496
|
+
for s in segs:
|
|
497
|
+
si = src_info.get(s["source"])
|
|
498
|
+
if si is None or not si["info"].get("has_audio"):
|
|
499
|
+
continue
|
|
500
|
+
a0 = float(s.get("audio_src_start", s["src_start"]))
|
|
501
|
+
a1 = float(s["src_end"])
|
|
502
|
+
edge_jobs.append((s, si, a0, a1))
|
|
503
|
+
|
|
504
|
+
def edges_for(job):
|
|
505
|
+
s, si, a0, a1 = job
|
|
506
|
+
src = si["path"]
|
|
507
|
+
body = loudest_channel(decode_audio(src, a0, a1 - a0, mono=False))
|
|
508
|
+
if body.size == 0:
|
|
509
|
+
return s, si, None
|
|
510
|
+
body_db = np.array([db(v) for v in rms_frames(body, 0.05)])
|
|
511
|
+
ref = float(np.percentile(body_db, 90)) if body_db.size else -20.0
|
|
512
|
+
active = body_db[body_db > ref - 20.0]
|
|
513
|
+
src_level = power_mean_db(active) if active.size >= 4 else None # the voice level: unfiltered
|
|
514
|
+
edge_ref = ref
|
|
515
|
+
if si.get("af"): # the edge gates read the same filtered audio the edge probes read
|
|
516
|
+
fb = loudest_channel(decode_audio(src, a0, a1 - a0, mono=False, af=si["af"]))
|
|
517
|
+
fdb = np.array([db(v) for v in rms_frames(fb, 0.05)])
|
|
518
|
+
edge_ref = float(np.percentile(fdb, 90)) if fdb.size else ref
|
|
519
|
+
probes = {side: (edge_probe(src, t, side, edge_ref, si.get("floor"), si.get("af")),
|
|
520
|
+
word_edge_probe(src, t, edge_ref, si["transcript"], si.get("af")))
|
|
521
|
+
for side, t in (("start", a0), ("end", a1))}
|
|
522
|
+
return s, si, (ref, src_level, probes)
|
|
523
|
+
|
|
524
|
+
# a range that picks up where the previous one of the same source stopped plays as one take
|
|
525
|
+
seamless: set[tuple[str, str]] = set()
|
|
526
|
+
for a, b in zip(segs, segs[1:]):
|
|
527
|
+
if a["source"] == b["source"] and \
|
|
528
|
+
abs(float(b.get("audio_src_start", b["src_start"])) - float(a["src_end"])) <= SEAMLESS_S:
|
|
529
|
+
seamless |= {(a["id"], "end"), (b["id"], "start")}
|
|
530
|
+
|
|
531
|
+
edges = []
|
|
532
|
+
source_levels: dict[str, float | None] = {}
|
|
533
|
+
gains = {str(r.get("id")): float(r.get("gain_db") or 0.0) for r in edl.get("ranges", [])}
|
|
534
|
+
# corte_por_onda.py ended these ranges in the valley between two words said together: the next word's attack
|
|
535
|
+
# is right there, so "still sounding" at that end is expected (the transcription of the cut is the check)
|
|
536
|
+
valley_ends = {str(r.get("id")) for r in edl.get("ranges", []) if r.get("fim_no_vale")}
|
|
537
|
+
unreadable: set[str] = set()
|
|
538
|
+
with ThreadPoolExecutor(max_workers=4) as pool:
|
|
539
|
+
for s, si, result in pool.map(edges_for, edge_jobs):
|
|
540
|
+
if result is None:
|
|
541
|
+
if s["source"] not in unreadable:
|
|
542
|
+
unreadable.add(s["source"])
|
|
543
|
+
flag("SOURCE-MISSING", None, f"não consegui ler o áudio da fonte {s['source']} "
|
|
544
|
+
f"({si['path'].name}): as bordas dos trechos dela não foram conferidas")
|
|
545
|
+
continue
|
|
546
|
+
ref, src_level, probes = result
|
|
547
|
+
source_levels[s["id"]] = None if src_level is None else src_level + gains.get(str(s["id"]), 0.0)
|
|
548
|
+
for side, t_src, t_out in (("start", float(s.get("audio_src_start", s["src_start"])),
|
|
549
|
+
float(s.get("audio_out_start", s["out_start"]))),
|
|
550
|
+
("end", float(s["src_end"]), float(s["out_end"]))):
|
|
551
|
+
e, wp = probes[side]
|
|
552
|
+
if (s["id"], side) in seamless: # the audio runs on into the next range: no cut heard
|
|
553
|
+
e = {**e, "in_speech": False}
|
|
554
|
+
wp = None
|
|
555
|
+
if side == "end" and str(s["id"]) in valley_ends:
|
|
556
|
+
e = {**e, "in_speech": False, "fim_no_vale": True}
|
|
557
|
+
wp = None
|
|
558
|
+
in_word = bool(wp and wp["in_word"])
|
|
559
|
+
edges.append({"range": s["id"], "side": side, "src_time": round(t_src, 3), "out_time": round(t_out, 3),
|
|
560
|
+
**e, "word": wp["word"] if wp else None, "in_word": in_word,
|
|
561
|
+
"word_gap_s": wp["gap_s"] if wp else None})
|
|
562
|
+
what = "start" if side == "start" else "end"
|
|
563
|
+
where = "começa" if side == "start" else "termina"
|
|
564
|
+
if e["in_speech"]:
|
|
565
|
+
extra = f" na palavra \"{wp['word']}\"" if wp else ""
|
|
566
|
+
flag("EDGE-IN-SPEECH", t_out, f"{s['id']} {where} com som ainda saindo{extra} "
|
|
567
|
+
f"(fonte {t_src:.2f}s, {e['inside_db']:.0f}/{e['outside_db']:.0f} dB): ajuste o "
|
|
568
|
+
f"{what} para uma pausa", s["id"])
|
|
569
|
+
elif in_word:
|
|
570
|
+
edge_to = wp["word_start"] if side == "start" else wp["word_end"]
|
|
571
|
+
flag("EDGE-IN-WORD", t_out, f"{s['id']} {where} no meio da palavra \"{wp['word']}\" "
|
|
572
|
+
f"(fonte {t_src:.2f}s, palavra {wp['word_start']:.2f}-{wp['word_end']:.2f}s): mova o "
|
|
573
|
+
f"{what} para perto de {edge_to:.2f}s e confira no speech_regions.py", s["id"])
|
|
574
|
+
|
|
575
|
+
# ---- the same speech played twice (consecutive ranges of one source that overlap)
|
|
576
|
+
for a, b in zip(segs, segs[1:]):
|
|
577
|
+
if a["source"] != b["source"]:
|
|
578
|
+
continue
|
|
579
|
+
a0, a1 = float(a.get("audio_src_start", a["src_start"])), float(a["src_end"])
|
|
580
|
+
b0, b1 = float(b.get("audio_src_start", b["src_start"])), float(b["src_end"])
|
|
581
|
+
overlap = min(a1, b1) - max(a0, b0)
|
|
582
|
+
if b0 < a1 and overlap > 1.0 / fps + 0.005:
|
|
583
|
+
flag("REPEAT", float(b.get("audio_out_start", b["out_start"])),
|
|
584
|
+
f"{b['id']} repete {overlap:.2f}s da fala de {a['id']} (mesma fonte, {max(a0, b0):.2f}-"
|
|
585
|
+
f"{min(a1, b1):.2f}s): mova o start de {b['id']} para {a1:.2f}s ou mais, ou encurte o end de {a['id']}",
|
|
586
|
+
b["id"])
|
|
587
|
+
|
|
588
|
+
# ---- dead air, placed on each range's own audio span (up to where the next range's audio starts)
|
|
589
|
+
def own_span(i: int) -> tuple[float, float]:
|
|
590
|
+
s = segs[i]
|
|
591
|
+
a = float(s.get("audio_out_start", s["out_start"]))
|
|
592
|
+
b = float(s["out_end"])
|
|
593
|
+
if i + 1 < len(segs):
|
|
594
|
+
b = min(b, float(segs[i + 1].get("audio_out_start", segs[i + 1]["out_start"])))
|
|
595
|
+
return a, b
|
|
596
|
+
|
|
597
|
+
def to_src(i: int, t_out: float) -> float:
|
|
598
|
+
s = segs[i]
|
|
599
|
+
return float(s.get("audio_src_start", s["src_start"])) + (t_out - float(s.get("audio_out_start", s["out_start"])))
|
|
600
|
+
|
|
601
|
+
def src_speech(i: int) -> list[list[float]] | None:
|
|
602
|
+
return (src_info.get(segs[i]["source"]) or {}).get("speech")
|
|
603
|
+
|
|
604
|
+
def new_end_for(i: int, t_out: float) -> float:
|
|
605
|
+
"""Where the speech before a silence ends in the source (regions/<stem>.json), plus 0.15 s.
|
|
606
|
+
The cut's own silence starts late on soft word endings, so the regions win when present."""
|
|
607
|
+
t = to_src(i, t_out)
|
|
608
|
+
ends = [b for a, b in (src_speech(i) or []) if t - 1.0 < b <= t + 0.3]
|
|
609
|
+
return (max(ends) if ends else t) + 0.15
|
|
610
|
+
|
|
611
|
+
def new_start_for(i: int, t_out: float) -> float:
|
|
612
|
+
"""Where the speech after a silence starts in the source, minus 0.10 s (soft onsets like 'g'
|
|
613
|
+
are heard in the cut later than they start)."""
|
|
614
|
+
t = to_src(i, t_out)
|
|
615
|
+
starts = [a for a, b in (src_speech(i) or []) if t - 0.3 <= a < t + 1.0]
|
|
616
|
+
return (min(starts) if starts else t - 0.15) - 0.10
|
|
617
|
+
|
|
618
|
+
silences = dead_air(mono, noise_db, min_silence) if mono.size and not silent else []
|
|
619
|
+
for sil in silences:
|
|
620
|
+
over = [i for i in range(len(segs)) if min(own_span(i)[1], sil["end"]) - max(own_span(i)[0], sil["start"]) > 0.02]
|
|
621
|
+
mid = (sil["start"] + sil["end"]) / 2
|
|
622
|
+
rid = next((s["id"] for s in segs if float(s["out_start"]) <= mid < float(s["out_end"])), None)
|
|
623
|
+
sil["range"] = rid
|
|
624
|
+
new_end = lambda i: new_end_for(i, sil["start"]) # noqa: E731
|
|
625
|
+
new_start = lambda i: new_start_for(i, sil["end"]) # noqa: E731
|
|
626
|
+
if not over:
|
|
627
|
+
msg = "encurte a pausa"
|
|
628
|
+
elif len(over) >= 2:
|
|
629
|
+
i, j = over[0], over[-1]
|
|
630
|
+
msg = (f"na junção {segs[i]['id']} -> {segs[j]['id']}: encurte o end de {segs[i]['id']} para "
|
|
631
|
+
f"{new_end(i):.2f}s e/ou mova o start de {segs[j]['id']} para {new_start(j):.2f}s")
|
|
632
|
+
else:
|
|
633
|
+
i = over[0]
|
|
634
|
+
a, b = own_span(i)
|
|
635
|
+
sid = segs[i]["id"]
|
|
636
|
+
if sil["end"] >= b - 0.1:
|
|
637
|
+
where = "no fim do vídeo" if sil["end"] >= v_dur - 0.05 else f"no fim do trecho {sid}"
|
|
638
|
+
msg = f"{where}: encurte o end de {sid} para {new_end(i):.2f}s"
|
|
639
|
+
elif sil["start"] <= a + 0.1:
|
|
640
|
+
where = "no começo do vídeo" if sil["start"] <= 0.05 else f"no começo do trecho {sid}"
|
|
641
|
+
msg = f"{where}: mova o start de {sid} para {new_start(i):.2f}s"
|
|
642
|
+
else:
|
|
643
|
+
msg = (f"no meio do trecho {sid}: divida em dois, um terminando em {new_end(i):.2f}s e o outro "
|
|
644
|
+
f"começando em {new_start(i):.2f}s")
|
|
645
|
+
flag("DEAD-AIR", sil["start"], f"silêncio de {sil['duration']:.2f}s {msg}", rid, sil["end"])
|
|
646
|
+
|
|
647
|
+
# ---- black frames
|
|
648
|
+
for b in blacks:
|
|
649
|
+
flag("BLACK", b["start"], f"tela preta por {b['duration']:.2f}s", end=b["end"])
|
|
650
|
+
|
|
651
|
+
# ---- range level balance against a reference level (speech-weighted median of the heard levels)
|
|
652
|
+
ranges_out = []
|
|
653
|
+
reference = None
|
|
654
|
+
if mono.size and segs and not silent:
|
|
655
|
+
win = int(0.05 * SR)
|
|
656
|
+
for i, s in enumerate(segs):
|
|
657
|
+
a, b = float(s["out_start"]), own_span(i)[1]
|
|
658
|
+
x = mono[int(a * SR):int(b * SR)]
|
|
659
|
+
vals = np.array([db(v) for v in rms_frames(x, 0.05)]) if x.size >= win else np.zeros(0)
|
|
660
|
+
gate = max(float(np.percentile(vals, 90)) - 20.0, floor_db + 6.0) if vals.size else 0.0
|
|
661
|
+
active = vals[vals > gate]
|
|
662
|
+
level = power_mean_db(active) if active.size >= 4 else None
|
|
663
|
+
src_level = source_levels.get(s["id"])
|
|
664
|
+
ranges_out.append({"id": s["id"], "level_db": None if level is None else round(level, 1),
|
|
665
|
+
"source_level_db": None if src_level is None else round(src_level, 1),
|
|
666
|
+
"speech_seconds": round(active.size * 0.05, 2),
|
|
667
|
+
"gain_db": gains.get(str(s["id"]), 0.0)})
|
|
668
|
+
measured = [r for r in ranges_out if r["level_db"] is not None]
|
|
669
|
+
if len(measured) >= 2:
|
|
670
|
+
reference = weighted_median([r["level_db"] for r in measured], [r["speech_seconds"] for r in measured])
|
|
671
|
+
ref_range = next(r for r in measured if r["level_db"] == reference)
|
|
672
|
+
for r in measured:
|
|
673
|
+
r["delta_db"] = round(r["level_db"] - reference, 1)
|
|
674
|
+
# A jump between neighbours is heard even when both sit near the reference, and so is a
|
|
675
|
+
# drift from the first ranges to the last in small steps: fix the one farther from the reference.
|
|
676
|
+
jump_fix: dict[str, str] = {}
|
|
677
|
+
farther = lambda a, b: (a, b) if abs(a["level_db"] - reference) >= abs(b["level_db"] - reference) else (b, a) # noqa: E731
|
|
678
|
+
for a, b in zip(measured, measured[1:]):
|
|
679
|
+
if abs(a["level_db"] - b["level_db"]) > ADJACENT_JUMP_DB:
|
|
680
|
+
fix, other = farther(a, b)
|
|
681
|
+
jump_fix.setdefault(fix["id"], other["id"])
|
|
682
|
+
lo, hi = min(measured, key=lambda r: r["level_db"]), max(measured, key=lambda r: r["level_db"])
|
|
683
|
+
if hi["level_db"] - lo["level_db"] > ADJACENT_JUMP_DB:
|
|
684
|
+
fix, other = farther(lo, hi)
|
|
685
|
+
jump_fix.setdefault(fix["id"], other["id"])
|
|
686
|
+
src_ref = ref_range.get("source_level_db")
|
|
687
|
+
for r, s in zip(ranges_out, segs):
|
|
688
|
+
if r["level_db"] is None:
|
|
689
|
+
continue
|
|
690
|
+
delta = r["level_db"] - reference
|
|
691
|
+
if abs(delta) <= LEVEL_DELTA_DB and r["id"] not in jump_fix:
|
|
692
|
+
continue
|
|
693
|
+
# gain_db acts before loudnorm, which in its dynamic mode squeezes the gap heard in the cut.
|
|
694
|
+
# The gap between the sources (gain included) is the one gain_db closes; it is used when it
|
|
695
|
+
# points the same way and is not smaller than the heard one.
|
|
696
|
+
change = delta
|
|
697
|
+
src_gap = None if src_ref is None or r.get("source_level_db") is None else r["source_level_db"] - src_ref
|
|
698
|
+
if src_gap is not None and src_gap * delta > 0 and abs(src_gap) >= abs(delta):
|
|
699
|
+
change = src_gap
|
|
700
|
+
final = round(min(GAIN_CAP_DB, max(-GAIN_CAP_DB, r["gain_db"] - change)), 1)
|
|
701
|
+
r["suggested_gain_db"] = final
|
|
702
|
+
code, how = ("LOW-LEVEL", "abaixo") if delta < 0 else ("HIGH-LEVEL", "acima")
|
|
703
|
+
why = (f"{abs(delta):.1f} dB {how} do nível de referência ({reference:.1f} dB)"
|
|
704
|
+
if abs(delta) > LEVEL_DELTA_DB else
|
|
705
|
+
f"mais de {ADJACENT_JUMP_DB:.0f} dB diferente do trecho {jump_fix[r['id']]} "
|
|
706
|
+
f"({abs(delta):.1f} dB {how} da referência)")
|
|
707
|
+
flag(code, float(s["out_start"]), f"{r['id']} está {why}: use gain_db {final:+.1f} nele no EDL "
|
|
708
|
+
f"(hoje {r['gain_db']:+.1f})", r["id"], float(s["out_end"]))
|
|
709
|
+
|
|
710
|
+
flags.sort(key=lambda f: (f["time"] is None, f["time"] or 0.0))
|
|
711
|
+
report = {
|
|
712
|
+
"video": str(video), "edl": str(edl_path), "timeline": str(tl_path) if tl_path else None,
|
|
713
|
+
"created_at": now_iso(), "min_silence": min_silence,
|
|
714
|
+
"duration": dur_info, "loudness": loud,
|
|
715
|
+
"speech_ref_db": round(speech_ref, 1), "silence_noise_db": round(noise_db, 1),
|
|
716
|
+
"junctions": junctions, "edges": edges, "dead_air": silences, "black": blacks,
|
|
717
|
+
"reference_level_db": None if reference is None else round(reference, 1),
|
|
718
|
+
"ranges": ranges_out, "warnings": warnings, "flags": flags, "ok": not flags,
|
|
719
|
+
}
|
|
720
|
+
|
|
721
|
+
# ---- PT-BR summary
|
|
722
|
+
lines = [f"Verificação do corte · {video.name}"]
|
|
723
|
+
tl_txt = f" · timeline {float(timeline['duration']):.2f}s" if timeline else " · sem timeline"
|
|
724
|
+
a_txt = f" · áudio {a_dur:.2f}s" if a_dur is not None else " · sem áudio"
|
|
725
|
+
lines.append(f"Duração: {v_dur:.2f}s ({v_frames} quadros){a_txt}{tl_txt}")
|
|
726
|
+
if silent:
|
|
727
|
+
lines.append("Loudness: sem som")
|
|
728
|
+
elif loud.get("integrated") is not None:
|
|
729
|
+
tp_ln = loud.get("true_peak_loudnorm")
|
|
730
|
+
tp_txt = f"{tp_ln:.2f}" if tp_ln is not None else fmt_db(loud.get("true_peak"))
|
|
731
|
+
lines.append(f"Loudness: {loud['integrated']:.1f} LUFS · pico real {tp_txt} dBTP"
|
|
732
|
+
f" · LRA {fmt_db(loud.get('lra'))} · amostras estouradas {clipped}")
|
|
733
|
+
n_click = sum(1 for j in junctions if j["click"])
|
|
734
|
+
lines.append(f"Junções: {len(junctions)} · estalos {n_click}"
|
|
735
|
+
+ (f" · pior {max(j['ratio'] for j in junctions):.1f}x" if junctions else ""))
|
|
736
|
+
n_edge = sum(1 for e in edges if e["in_speech"])
|
|
737
|
+
n_word = sum(1 for e in edges if e["in_word"] and not e["in_speech"])
|
|
738
|
+
lines.append(f"Bordas dos trechos: {len(edges)} · dentro da fala {n_edge} · no meio de palavra {n_word}")
|
|
739
|
+
if reference is not None:
|
|
740
|
+
spread = [r["delta_db"] for r in ranges_out if r.get("delta_db") is not None]
|
|
741
|
+
lines.append(f"Nível dos trechos: de {min(spread):+.1f} a {max(spread):+.1f} dB da referência ({reference:.1f} dB)")
|
|
742
|
+
lines.append(f"Silêncio >= {min_silence:g}s: {len(silences)} · tela preta: {len(blacks)}")
|
|
743
|
+
for w in warnings:
|
|
744
|
+
lines.append(f"aviso: {w}")
|
|
745
|
+
if flags:
|
|
746
|
+
lines.append("")
|
|
747
|
+
lines.append(f"FLAGS ({len(flags)}):")
|
|
748
|
+
for f in flags:
|
|
749
|
+
t = fmt_time(f["time"]) if f["time"] is not None else " -- "
|
|
750
|
+
lines.append(f" {t} {f['code']:15s} {f['message']}")
|
|
751
|
+
else:
|
|
752
|
+
lines.append("")
|
|
753
|
+
lines.append("Nenhum problema encontrado.")
|
|
754
|
+
return report, lines
|
|
755
|
+
|
|
756
|
+
|
|
757
|
+
def main() -> int:
|
|
758
|
+
ap = argparse.ArgumentParser(description="Confere o corte renderizado e lista problemas com o tempo de cada um.")
|
|
759
|
+
ap.add_argument("edl", type=Path, help="edl.json usado no render")
|
|
760
|
+
ap.add_argument("video", type=Path, help="cut.mp4")
|
|
761
|
+
ap.add_argument("--min-silence", type=float, default=0.7, help="silêncio mínimo para marcar DEAD-AIR (padrão 0.7s)")
|
|
762
|
+
ap.add_argument("--ask-approval", action="store_true",
|
|
763
|
+
help="pede a aprovação no preview mesmo com avisos (use depois de explicar à pessoa os que ficam)")
|
|
764
|
+
ap.add_argument("-o", "--output", type=Path, help="JSON do relatório (padrão: edit/verify/cut_report.json)")
|
|
765
|
+
ap.add_argument("--sem-aprovacao", action="store_true",
|
|
766
|
+
help="só confere, não mexe no preview (o conferir_corte.py roda assim, junto da fala)")
|
|
767
|
+
ap.add_argument("--aprovar", action="store_true",
|
|
768
|
+
help="não confere de novo: pede a aprovação a partir do relatório já feito deste cut.mp4")
|
|
769
|
+
args = ap.parse_args()
|
|
770
|
+
|
|
771
|
+
edl_path, video = abspath(args.edl), abspath(args.video)
|
|
772
|
+
if args.aprovar:
|
|
773
|
+
out = abspath(args.output) if args.output else edl_path.parent / "verify" / "cut_report.json"
|
|
774
|
+
report = load_json(out)
|
|
775
|
+
if (not isinstance(report, dict) or not video.exists() or report.get("video") != str(video)
|
|
776
|
+
or video.stat().st_mtime > out.stat().st_mtime):
|
|
777
|
+
print(f"erro: {out.name} não é deste {video.name} (ou o corte mudou depois). Rode o verify sem --aprovar.",
|
|
778
|
+
file=sys.stderr)
|
|
779
|
+
return 2
|
|
780
|
+
state_line, report["approval_requested"] = update_state(edl_path.parent, video, report["flags"],
|
|
781
|
+
args.ask_approval)
|
|
782
|
+
save_json(out, report)
|
|
783
|
+
print(state_line or "Esse vídeo não é o corte da pasta: nada a aprovar.")
|
|
784
|
+
return 0
|
|
785
|
+
if not edl_path.exists():
|
|
786
|
+
print(f"erro: EDL não encontrado: {edl_path}", file=sys.stderr)
|
|
787
|
+
return 2
|
|
788
|
+
if not video.exists():
|
|
789
|
+
print(f"erro: vídeo não encontrado: {video}", file=sys.stderr)
|
|
790
|
+
return 2
|
|
791
|
+
try:
|
|
792
|
+
edl = json.loads(edl_path.read_text(encoding="utf-8"))
|
|
793
|
+
except (json.JSONDecodeError, UnicodeDecodeError) as exc:
|
|
794
|
+
print(f"erro: o EDL não é um JSON válido: {exc}", file=sys.stderr)
|
|
795
|
+
return 2
|
|
796
|
+
if not isinstance(edl, dict) or not isinstance(edl.get("ranges"), list):
|
|
797
|
+
print("erro: o EDL não tem trechos em \"ranges\"", file=sys.stderr)
|
|
798
|
+
return 2
|
|
799
|
+
try:
|
|
800
|
+
report, lines = verify(edl_path, video, args.min_silence)
|
|
801
|
+
except (ValueError, RuntimeError, json.JSONDecodeError) as exc:
|
|
802
|
+
print(f"erro: não consegui ler {video.name}: {exc}", file=sys.stderr)
|
|
803
|
+
return 2
|
|
804
|
+
except (TypeError, KeyError) as exc:
|
|
805
|
+
print(f"erro: o EDL ou o timeline tem um campo faltando ou inválido ({exc.__class__.__name__}: {exc})",
|
|
806
|
+
file=sys.stderr)
|
|
807
|
+
return 2
|
|
808
|
+
out = abspath(args.output) if args.output else edl_path.parent / "verify" / "cut_report.json"
|
|
809
|
+
state_line, report["approval_requested"] = (None, None) if args.sem_aprovacao else \
|
|
810
|
+
update_state(edl_path.parent, video, report["flags"], args.ask_approval)
|
|
811
|
+
save_json(out, report)
|
|
812
|
+
print("\n".join(lines))
|
|
813
|
+
if state_line:
|
|
814
|
+
print(state_line)
|
|
815
|
+
print(f"\nRelatório: {out}")
|
|
816
|
+
return 0
|
|
817
|
+
|
|
818
|
+
|
|
819
|
+
if __name__ == "__main__":
|
|
820
|
+
sys.exit(main())
|