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,427 @@
|
|
|
1
|
+
"""Quality gate for a rendered video: the edit is only delivered when this prints "PASSOU".
|
|
2
|
+
|
|
3
|
+
Checks the exported file (not the studio):
|
|
4
|
+
1. black frames (0.1 s or more anywhere, the very first 0.1 s excepted);
|
|
5
|
+
2. loudness: integrated -14 LUFS with 1.5 of margin, true peak under -1 dBTP;
|
|
6
|
+
3. breaths: no silence of 0.25 s or more (helpers/respiros.py). The silence after the last spoken word does not
|
|
7
|
+
count when the project has a still end (`segura_fim`) or a closing card (`cartao_final`): it is the end of the
|
|
8
|
+
video, not a breath;
|
|
9
|
+
4. text outside the Reels safe zone (x 140-940, y 230-1530 on 1080x1920) or touching the edge of the frame,
|
|
10
|
+
read with the same reader as conferir_dados.py (Apple Vision on the Mac, Tesseract elsewhere). The stretches
|
|
11
|
+
of `prova`, `contida`, `comparar` and `tela_verde` blocks are not read: the text there is on the person's own
|
|
12
|
+
footage (a screen recording), not text of the edit. Without a reader the check says so and does not block;
|
|
13
|
+
4b. (PrimoCode) text glued to the face: in the full-screen stretches (the person fills the frame) a line of text
|
|
14
|
+
closer than ROSTO_PX to the face box fails (the box from the hair line down to just under the mouth: the chin is
|
|
15
|
+
where the captions sit by design). The face is found in the same frames the reader reads, with the YuNet
|
|
16
|
+
detector of face_layout.py (the face as it is on screen: zoom and camera included). Without the detector the
|
|
17
|
+
check says so and does not block;
|
|
18
|
+
5. a sheet of frames with the safe zone box drawn on it, saved next to the report;
|
|
19
|
+
6. (PrimoCode) the loudnorm of every step (cut, clean voice, soundtrack) stayed LINEAR: the dynamic mode is a
|
|
20
|
+
compressor ffmpeg turns on silently and the voice pumps (helpers/ganho_por_trecho.py prevents it);
|
|
21
|
+
7. (PrimoCode) no sample at the digital ceiling (|x| >= 0.999) in the exported audio.
|
|
22
|
+
|
|
23
|
+
Usage:
|
|
24
|
+
uv run python helpers/portao.py <final.mp4> --projeto <PROJ> [--fps 3] [--folha <png>] [--ajustar-volume] [--json]
|
|
25
|
+
|
|
26
|
+
`--ajustar-volume`: when only the loudness is off, rewrites the audio to -14 LUFS (two passes, the picture is not
|
|
27
|
+
re-encoded), keeps the original as `<nome>_antes_do_volume.mp4` and measures again. No new render needed.
|
|
28
|
+
Exit code 0 when everything passes, 1 when something failed, 2 on error.
|
|
29
|
+
"""
|
|
30
|
+
|
|
31
|
+
from __future__ import annotations
|
|
32
|
+
|
|
33
|
+
import argparse
|
|
34
|
+
import json
|
|
35
|
+
import re
|
|
36
|
+
import subprocess
|
|
37
|
+
import sys
|
|
38
|
+
import tempfile
|
|
39
|
+
from pathlib import Path
|
|
40
|
+
|
|
41
|
+
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
|
42
|
+
import conferir_dados as cd # noqa: E402
|
|
43
|
+
import corte_por_onda as onda # noqa: E402
|
|
44
|
+
import respiros # noqa: E402
|
|
45
|
+
from common import fmt_time, probe_video, require_binary # noqa: E402
|
|
46
|
+
|
|
47
|
+
ALVO_LUFS, MARGEM_LUFS, PICO_MAX_DB = -14.0, 1.5, -1.0
|
|
48
|
+
PRETO_MIN_S = 0.1
|
|
49
|
+
PRETO_INICIO_S = 0.1 # black this short at the very start (the first frame) is the video opening
|
|
50
|
+
BLOCOS_DE_MATERIAL = ("prova", "contida", "comparar", "tela_verde") # footage of the person: its text is not ours
|
|
51
|
+
ZONA = (140, 230, 940, 1530) # x0, y0, x1, y1 on 1080x1920
|
|
52
|
+
BORDA_PX = 24 # text closer than this to the edge of the frame is "touching"
|
|
53
|
+
CONF_MIN = 0.5
|
|
54
|
+
FOLGA_PX = 6 # the reader's box is not exact
|
|
55
|
+
ROSTO_PX = 40 # text closer than this to the face box is "glued to the face" (px on 1920 high)
|
|
56
|
+
CENAS_COM_ROSTO = ("tela_cheia",) # stretches where the person fills the frame (moldura/prova/...: the face is small or not ours)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def distancia_ao_rosto(caixa: tuple[float, float, float, float], rosto: tuple[float, float, float, float]) -> float:
|
|
60
|
+
"""Gap in px between a text box and the face box, both (x0, y0, x1, y1). Negative when they overlap (the depth of
|
|
61
|
+
the overlap on the shallower axis)."""
|
|
62
|
+
dx = max(rosto[0] - caixa[2], caixa[0] - rosto[2])
|
|
63
|
+
dy = max(rosto[1] - caixa[3], caixa[1] - rosto[3])
|
|
64
|
+
if dx < 0 and dy < 0:
|
|
65
|
+
return max(dx, dy)
|
|
66
|
+
return max(dx, dy) if (dx < 0 or dy < 0) else (dx * dx + dy * dy) ** 0.5
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def colado_no_rosto(caixa, rosto, h: int = 1920, px: float = ROSTO_PX) -> bool:
|
|
70
|
+
"""True when the text box is closer than `px` (scaled to the video height) to the face box."""
|
|
71
|
+
return rosto is not None and distancia_ao_rosto(caixa, rosto) < px * h / 1920
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def rostos_dos_quadros(pasta: Path, n: int, w: int, h: int) -> dict[int, tuple[float, float, float, float] | None] | None:
|
|
75
|
+
"""The face box (px) in each extracted frame f_00001.jpg..., with the YuNet detector of face_layout.py. None when the
|
|
76
|
+
detector is not available here (no OpenCV or no model)."""
|
|
77
|
+
try:
|
|
78
|
+
import cv2 # noqa: PLC0415
|
|
79
|
+
import face_layout as fl # noqa: PLC0415
|
|
80
|
+
fl._cv2()
|
|
81
|
+
except Exception: # noqa: BLE001
|
|
82
|
+
return None
|
|
83
|
+
out: dict[int, tuple[float, float, float, float] | None] = {}
|
|
84
|
+
for k in range(n):
|
|
85
|
+
img = cv2.imread(str(pasta / f"f_{k + 1:05d}.jpg"))
|
|
86
|
+
f = fl.detect_array(img) if img is not None else None
|
|
87
|
+
out[k] = caixa_do_rosto(f, w, h) if f else None
|
|
88
|
+
return out
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
QUEIXO = 0.08 # the face box for the rule ends this far (fraction of the face height) under the mouth line
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def caixa_do_rosto(f: dict, w: int, h: int) -> tuple[float, float, float, float]:
|
|
95
|
+
"""The face box (px) the text has to keep away from: the YuNet box from its top (hair line) down to just under the
|
|
96
|
+
mouth. The chin under the mouth is where the captions of the edit sit by design (face_layout: they miss the eyes and
|
|
97
|
+
the mouth), so it is not part of it."""
|
|
98
|
+
y0, y1 = f["y0"] * h, f["y1"] * h
|
|
99
|
+
boca = f.get("mouth_y")
|
|
100
|
+
if boca is not None:
|
|
101
|
+
y1 = min(y1, boca * h + QUEIXO * (y1 - y0))
|
|
102
|
+
return (f["x0"] * w, y0, f["x1"] * w, y1)
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def zona_para(w: int, h: int) -> tuple[int, int, int, int] | None:
|
|
106
|
+
"""The safe zone scaled to the video size; only defined for vertical 9:16 (other formats have wider margins)."""
|
|
107
|
+
if abs(w / h - 9 / 16) > 0.02:
|
|
108
|
+
return None
|
|
109
|
+
k = w / 1080
|
|
110
|
+
return tuple(round(v * k) for v in ZONA) # type: ignore[return-value]
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def quadros_pretos(video: Path, dur: float) -> list[tuple[float, float]]:
|
|
114
|
+
r = subprocess.run(["ffmpeg", "-nostdin", "-v", "info", "-i", str(video), "-vf",
|
|
115
|
+
f"blackdetect=d={PRETO_MIN_S - 0.02:.2f}:pix_th=0.06", "-an", "-f", "null", "-"],
|
|
116
|
+
capture_output=True, text=True)
|
|
117
|
+
out = []
|
|
118
|
+
for m in re.finditer(r"black_start:([\d.]+)\s+black_end:([\d.]+)", r.stderr):
|
|
119
|
+
a, b = float(m[1]), float(m[2])
|
|
120
|
+
if a < 0.05 and b - a <= PRETO_INICIO_S + 0.05:
|
|
121
|
+
continue
|
|
122
|
+
out.append((a, b))
|
|
123
|
+
return out
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def volume(video: Path, completo: bool = False) -> dict:
|
|
127
|
+
r = subprocess.run(["ffmpeg", "-nostdin", "-v", "info", "-i", str(video), "-vn", "-af",
|
|
128
|
+
"loudnorm=I=-14:TP=-1.5:LRA=11:print_format=json", "-f", "null", "-"],
|
|
129
|
+
capture_output=True, text=True)
|
|
130
|
+
m = re.search(r"\{[^{}]*\"input_i\"[^{}]*\}", r.stderr, re.S)
|
|
131
|
+
if not m:
|
|
132
|
+
raise RuntimeError("não consegui medir o volume (o arquivo tem áudio?)")
|
|
133
|
+
d = json.loads(m.group(0))
|
|
134
|
+
out = {"lufs": float(d["input_i"]), "pico_db": float(d["input_tp"])}
|
|
135
|
+
if completo:
|
|
136
|
+
out["_loudnorm"] = d
|
|
137
|
+
return out
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def ajustar_volume(video: Path) -> Path:
|
|
141
|
+
"""Two-pass loudnorm to -14 LUFS (true peak -1.5), picture copied. The original stays next to it."""
|
|
142
|
+
d = volume(video, completo=True)["_loudnorm"]
|
|
143
|
+
antes = video.with_name(video.stem + "_antes_do_volume" + video.suffix)
|
|
144
|
+
novo = video.with_name(video.stem + "_novo_volume" + video.suffix)
|
|
145
|
+
af = ("loudnorm=I=-14:TP=-1.5:LRA=11:linear=true:"
|
|
146
|
+
f"measured_I={d['input_i']}:measured_TP={d['input_tp']}:measured_LRA={d['input_lra']}:"
|
|
147
|
+
f"measured_thresh={d['input_thresh']}:offset={d['target_offset']}")
|
|
148
|
+
r = subprocess.run(["ffmpeg", "-nostdin", "-v", "error", "-y", "-i", str(video), "-map", "0:v", "-map", "0:a",
|
|
149
|
+
"-c:v", "copy", "-af", af, "-ar", "48000", "-c:a", "aac", "-b:a", "192k",
|
|
150
|
+
"-movflags", "+faststart", str(novo)], capture_output=True, text=True)
|
|
151
|
+
if r.returncode != 0 or not novo.is_file():
|
|
152
|
+
raise RuntimeError("não consegui ajustar o volume: " + r.stderr[-300:])
|
|
153
|
+
if not antes.exists():
|
|
154
|
+
video.rename(antes)
|
|
155
|
+
else:
|
|
156
|
+
video.unlink()
|
|
157
|
+
novo.rename(video)
|
|
158
|
+
return antes
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def texto_fora(video: Path, w: int, h: int, fps: float, ignorar: list[tuple[float, float]] | None = None,
|
|
162
|
+
com_rosto: list[tuple[float, float]] | None = None) -> dict:
|
|
163
|
+
"""Lines of text outside the safe zone, touching the edge or glued to the face (in `com_rosto` stretches).
|
|
164
|
+
{'leitor': ..., 'achados': [...], 'rosto': True | False | None}."""
|
|
165
|
+
zona = zona_para(w, h)
|
|
166
|
+
motor = cd.leitor()
|
|
167
|
+
if motor is None:
|
|
168
|
+
return {"leitor": None, "achados": [], "zona": zona, "ignorado": ignorar or [], "rosto": None}
|
|
169
|
+
info = probe_video(video)
|
|
170
|
+
fps_video = float(info.get("fps") or 30.0)
|
|
171
|
+
dur = float(info["duration"])
|
|
172
|
+
n_total = int(dur * fps - 1e-6) + 1
|
|
173
|
+
achados: list[dict] = []
|
|
174
|
+
with tempfile.TemporaryDirectory(prefix="portao-") as tmp:
|
|
175
|
+
pasta = Path(tmp)
|
|
176
|
+
n = cd.extrair(video, pasta, 0, n_total, fps, fps_video, True, "")
|
|
177
|
+
bruto = cd.ler_pasta(motor, pasta) if n else {}
|
|
178
|
+
rostos = rostos_dos_quadros(pasta, n, w, h) if (n and com_rosto) else None
|
|
179
|
+
ignorar = ignorar or []
|
|
180
|
+
com_rosto = com_rosto or []
|
|
181
|
+
for k in range(n):
|
|
182
|
+
t = k / fps
|
|
183
|
+
if any(x - 0.05 <= t <= y + 0.05 for x, y in ignorar):
|
|
184
|
+
continue
|
|
185
|
+
for it in bruto.get(f"f_{k + 1:05d}.jpg", []):
|
|
186
|
+
if float(it.get("c", 1)) < CONF_MIN or len(re.sub(r"\W", "", str(it.get("t", "")))) < 2:
|
|
187
|
+
continue
|
|
188
|
+
x0, y1 = float(it["x"]) * w, (1 - float(it["y"]) - float(it["h"])) * h
|
|
189
|
+
x1, y2 = x0 + float(it["w"]) * w, y1 + float(it["h"]) * h
|
|
190
|
+
motivo = None
|
|
191
|
+
if x0 < BORDA_PX or y1 < BORDA_PX or x1 > w - BORDA_PX or y2 > h - BORDA_PX:
|
|
192
|
+
motivo = "encostado na borda"
|
|
193
|
+
elif zona and (x0 < zona[0] - FOLGA_PX or x1 > zona[2] + FOLGA_PX
|
|
194
|
+
or y1 < zona[1] - FOLGA_PX or y2 > zona[3] + FOLGA_PX):
|
|
195
|
+
motivo = "fora da zona segura"
|
|
196
|
+
elif rostos and any(x - 0.05 <= t <= y + 0.05 for x, y in com_rosto) and \
|
|
197
|
+
colado_no_rosto((x0 + FOLGA_PX, y1 + FOLGA_PX, x1 - FOLGA_PX, y2 - FOLGA_PX), rostos.get(k), h):
|
|
198
|
+
motivo = f"colado no rosto (a menos de {round(ROSTO_PX * h / 1920)} px)"
|
|
199
|
+
if motivo:
|
|
200
|
+
achados.append({"t": round(t, 2), "texto": str(it["t"]).strip(), "motivo": motivo,
|
|
201
|
+
"caixa": [round(x0), round(y1), round(x1 - x0), round(y2 - y1)]})
|
|
202
|
+
# the same line seen in a row of samples is one finding
|
|
203
|
+
juntos: list[dict] = []
|
|
204
|
+
for a in sorted(achados, key=lambda a: (a["texto"], a["t"])):
|
|
205
|
+
if juntos and juntos[-1]["texto"] == a["texto"] and a["t"] - juntos[-1]["ate"] <= 1.5 / fps + 1e-6:
|
|
206
|
+
juntos[-1]["ate"] = a["t"]
|
|
207
|
+
else:
|
|
208
|
+
juntos.append(dict(a, ate=a["t"]))
|
|
209
|
+
return {"leitor": motor[0], "achados": sorted(juntos, key=lambda a: a["t"]), "zona": zona, "ignorado": ignorar,
|
|
210
|
+
"rosto": (rostos is not None) if com_rosto else None}
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
def faixas_de_material(cfg: dict, projeto: Path | None, dur: float, cenas=BLOCOS_DE_MATERIAL) -> list[tuple[float, float]]:
|
|
214
|
+
"""Time ranges (s) of the blocks that show the person's own footage (or of the `cenas` asked). A time written as a
|
|
215
|
+
word is looked up in the words of the cut; without the transcript only the blocks written in seconds are found."""
|
|
216
|
+
import camera_viva as cv # noqa: PLC0415
|
|
217
|
+
ws: list[dict] = []
|
|
218
|
+
vdur = dur
|
|
219
|
+
if projeto:
|
|
220
|
+
try:
|
|
221
|
+
edit = (projeto / cfg.get("edit", "../edit")).resolve()
|
|
222
|
+
ws = cv.palavras_do_corte(edit)
|
|
223
|
+
vdur = float(json.loads((edit / "cut_timeline.json").read_text(encoding="utf-8"))["duration"])
|
|
224
|
+
except Exception: # noqa: BLE001
|
|
225
|
+
ws = []
|
|
226
|
+
out = []
|
|
227
|
+
for b in cfg.get("blocos") or []:
|
|
228
|
+
if not isinstance(b, dict) or b.get("cena") not in cenas:
|
|
229
|
+
continue
|
|
230
|
+
de = cv.tempo_do_campo(b.get("de", 0.0), ws)
|
|
231
|
+
ate = cv.tempo_do_campo(b["ate"], ws, de or 0.0) if "ate" in b else vdur
|
|
232
|
+
if de is not None and ate is not None:
|
|
233
|
+
out.append((de, ate))
|
|
234
|
+
return out
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
def faixas_com_rosto(cfg: dict, projeto: Path | None, dur: float) -> list[tuple[float, float]]:
|
|
238
|
+
"""Time ranges (s) where the person fills the frame: everything outside the blocks that are not `tela_cheia`
|
|
239
|
+
(moldura, prova, contida, comparar, tela_verde). There the face box is the one that matters for the text."""
|
|
240
|
+
outros = sorted(faixas_de_material(cfg, projeto, dur, cenas=("moldura", "prova", "contida", "comparar", "tela_verde")))
|
|
241
|
+
return livres_entre(outros, dur)
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
def livres_entre(ocupados: list[tuple[float, float]], dur: float) -> list[tuple[float, float]]:
|
|
245
|
+
"""[0, dur] minus the sorted `ocupados` ranges."""
|
|
246
|
+
out, t = [], 0.0
|
|
247
|
+
for de, ate in sorted(ocupados):
|
|
248
|
+
if de > t + 0.05:
|
|
249
|
+
out.append((t, de))
|
|
250
|
+
t = max(t, ate)
|
|
251
|
+
if t < dur - 0.05:
|
|
252
|
+
out.append((t, dur))
|
|
253
|
+
return out
|
|
254
|
+
|
|
255
|
+
|
|
256
|
+
def fim_da_fala(cfg: dict, dur: float) -> float | None:
|
|
257
|
+
"""Where the voice ends in the final video: the duration minus the still end and the closing card. None when
|
|
258
|
+
the project has neither (then the last silence counts like any other)."""
|
|
259
|
+
segura = float(cfg.get("segura_fim") or 0)
|
|
260
|
+
cf = cfg.get("cartao_final") or {}
|
|
261
|
+
cartao = float(cf.get("segundos", 1.8)) if cf.get("ligado") else 0.0
|
|
262
|
+
return dur - segura - cartao if (segura > 0 or cartao > 0) else None
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
def folha(video: Path, w: int, h: int, tempos: list[float], saida: Path) -> Path:
|
|
266
|
+
from PIL import Image, ImageDraw # noqa: PLC0415
|
|
267
|
+
import contact_sheet as cs # noqa: PLC0415
|
|
268
|
+
tw = 270
|
|
269
|
+
th = round(tw * h / w)
|
|
270
|
+
quadros = cs.extract_frames(video, tempos, width=tw, height=th)
|
|
271
|
+
zona = zona_para(w, h)
|
|
272
|
+
for im in quadros:
|
|
273
|
+
d = ImageDraw.Draw(im)
|
|
274
|
+
if zona:
|
|
275
|
+
k = tw / w
|
|
276
|
+
d.rectangle([round(v * k) for v in zona], outline=(255, 60, 60), width=2)
|
|
277
|
+
rotulos = [fmt_time(t) for t in tempos]
|
|
278
|
+
cs.build_sheet(quadros, rotulos, 6, f"{video.name} · caixa vermelha = zona segura").save(saida)
|
|
279
|
+
return saida
|
|
280
|
+
|
|
281
|
+
|
|
282
|
+
def modos_do_volume(video: Path, projeto: Path | None, cfg: dict) -> list[tuple[str, str]]:
|
|
283
|
+
"""PrimoCode: o modo de cada loudnorm do caminho (corte, voz limpa, trilha). Dinâmico é um compressor que o
|
|
284
|
+
ffmpeg liga calado quando o ganho linear passaria do pico: a voz bombeia. Lista (etapa, modo)."""
|
|
285
|
+
out: list[tuple[str, str]] = []
|
|
286
|
+
def ler(p: Path) -> dict:
|
|
287
|
+
try:
|
|
288
|
+
return json.loads(p.read_text(encoding="utf-8"))
|
|
289
|
+
except (OSError, ValueError):
|
|
290
|
+
return {}
|
|
291
|
+
edit = None
|
|
292
|
+
if projeto and cfg.get("edit"):
|
|
293
|
+
edit = (projeto / cfg["edit"]).resolve()
|
|
294
|
+
if edit and edit.is_dir():
|
|
295
|
+
m = ler(edit / "cut_timeline.json").get("loudnorm_modo")
|
|
296
|
+
if m:
|
|
297
|
+
out.append(("corte", m))
|
|
298
|
+
m = (ler(edit / "verify" / "voz_limpa.json").get("relatorio") or {}).get("loudnorm_modo")
|
|
299
|
+
if m:
|
|
300
|
+
out.append(("voz limpa", m))
|
|
301
|
+
m = ler(video.with_name(video.name + ".trilha.json")).get("ajuste")
|
|
302
|
+
if m:
|
|
303
|
+
out.append(("trilha", m))
|
|
304
|
+
return out
|
|
305
|
+
|
|
306
|
+
|
|
307
|
+
def amostras_clipadas(video: Path) -> int:
|
|
308
|
+
"""PrimoCode: amostras no teto digital (|x| >= 0,999) no áudio exportado. Uma já é estalo."""
|
|
309
|
+
import numpy as np
|
|
310
|
+
raw = subprocess.run(["ffmpeg", "-nostdin", "-v", "error", "-i", str(video), "-vn", "-f", "f32le", "-acodec",
|
|
311
|
+
"pcm_f32le", "-ar", "48000", "-"], capture_output=True).stdout
|
|
312
|
+
if not raw:
|
|
313
|
+
return 0
|
|
314
|
+
x = np.frombuffer(raw[: len(raw) // 4 * 4], dtype=np.float32)
|
|
315
|
+
return int(np.count_nonzero(np.abs(x) >= 0.999))
|
|
316
|
+
|
|
317
|
+
|
|
318
|
+
def main(argv: list[str] | None = None) -> int:
|
|
319
|
+
ap = argparse.ArgumentParser(description="Portão de qualidade do vídeo exportado: só entrega com PASSOU.")
|
|
320
|
+
ap.add_argument("video", type=Path)
|
|
321
|
+
ap.add_argument("--projeto", type=Path, help="a pasta do projeto (lê o segura_fim e guarda a folha em renders/)")
|
|
322
|
+
ap.add_argument("--fps", type=float, default=3.0, help="quadros lidos por segundo para o texto (3)")
|
|
323
|
+
ap.add_argument("--folha", type=Path, help="PNG da folha de quadros (padrão: <projeto>/renders/portao.png)")
|
|
324
|
+
ap.add_argument("--ajustar-volume", action="store_true",
|
|
325
|
+
help="se só o volume estiver fora, regrava o áudio em -14 LUFS (sem novo render) e mede de novo")
|
|
326
|
+
ap.add_argument("--json", action="store_true")
|
|
327
|
+
a = ap.parse_args(argv)
|
|
328
|
+
if not a.video.is_file():
|
|
329
|
+
print(f"ERRO: não achei {a.video}", file=sys.stderr)
|
|
330
|
+
return 2
|
|
331
|
+
try:
|
|
332
|
+
require_binary("ffmpeg")
|
|
333
|
+
info = probe_video(a.video)
|
|
334
|
+
W, H = int(info.get("display_width") or info["width"]), int(info.get("display_height") or info["height"])
|
|
335
|
+
dur = float(info["duration"])
|
|
336
|
+
cfg = {}
|
|
337
|
+
if a.projeto and (a.projeto / "projeto.json").is_file():
|
|
338
|
+
cfg = json.loads((a.projeto / "projeto.json").read_text(encoding="utf-8"))
|
|
339
|
+
pretos = quadros_pretos(a.video, dur)
|
|
340
|
+
vol = volume(a.video)
|
|
341
|
+
volume_ajustado = None
|
|
342
|
+
if a.ajustar_volume and (abs(vol["lufs"] - ALVO_LUFS) > MARGEM_LUFS or vol["pico_db"] > PICO_MAX_DB):
|
|
343
|
+
volume_ajustado = ajustar_volume(a.video)
|
|
344
|
+
vol = volume(a.video)
|
|
345
|
+
db = onda.envelope(a.video, None)
|
|
346
|
+
th = onda.threshold(db)
|
|
347
|
+
resp = respiros.find(db, th, respiros.MIN_S)
|
|
348
|
+
fim_voz = fim_da_fala(cfg, dur)
|
|
349
|
+
resp_fim = []
|
|
350
|
+
if fim_voz is not None: # silence after the last word is the still end / the card, not a breath
|
|
351
|
+
resp_fim = [r for r in resp if r[0] >= fim_voz - 0.3]
|
|
352
|
+
resp = [r for r in resp if r not in resp_fim]
|
|
353
|
+
txt = texto_fora(a.video, W, H, a.fps, faixas_de_material(cfg, a.projeto, dur), faixas_com_rosto(cfg, a.projeto, dur))
|
|
354
|
+
modos = modos_do_volume(a.video, a.projeto, cfg)
|
|
355
|
+
clip = amostras_clipadas(a.video)
|
|
356
|
+
except (RuntimeError, OSError, ValueError) as e:
|
|
357
|
+
print(f"ERRO: {e}", file=sys.stderr)
|
|
358
|
+
return 2
|
|
359
|
+
|
|
360
|
+
falhas: list[str] = []
|
|
361
|
+
if pretos:
|
|
362
|
+
falhas.append("quadro preto: " + ", ".join(f"{fmt_time(x)} a {fmt_time(y)}" for x, y in pretos))
|
|
363
|
+
if abs(vol["lufs"] - ALVO_LUFS) > MARGEM_LUFS:
|
|
364
|
+
falhas.append(f"volume {vol['lufs']:.1f} LUFS (o alvo é {ALVO_LUFS:.0f}, margem {MARGEM_LUFS:g}): "
|
|
365
|
+
"ajuste o ganho do arquivo e confira de novo")
|
|
366
|
+
if vol["pico_db"] > PICO_MAX_DB:
|
|
367
|
+
falhas.append(f"pico {vol['pico_db']:.1f} dBTP (o limite é {PICO_MAX_DB:g}): baixe o ganho ou use o limitador")
|
|
368
|
+
dinamicos = [e for e, m in modos if str(m).lower() not in ("linear",)]
|
|
369
|
+
if dinamicos:
|
|
370
|
+
falhas.append("volume em modo dinâmico (" + ", ".join(dinamicos) + "): o loudnorm comprimiu a voz; rode o "
|
|
371
|
+
"helpers/ganho_por_trecho.py no edl_manual.json, refaça o corte e renderize")
|
|
372
|
+
if clip:
|
|
373
|
+
falhas.append(f"clipou: {clip} amostra(s) no teto digital; baixe o ganho do trecho ou o volume dos sons")
|
|
374
|
+
if resp:
|
|
375
|
+
falhas.append("respiro: " + ", ".join(f"{fmt_time(x)} ({y - x:.2f} s)" for x, y in resp))
|
|
376
|
+
for f in txt["achados"]:
|
|
377
|
+
falhas.append(f"texto {f['motivo']}: \"{f['texto'][:40]}\" em {fmt_time(f['t'])}"
|
|
378
|
+
+ (f" a {fmt_time(f['ate'])}" if f["ate"] > f["t"] else ""))
|
|
379
|
+
saida = a.folha or ((a.projeto / "renders" / "portao.png") if a.projeto else a.video.with_name("portao.png"))
|
|
380
|
+
tempos = sorted({round(min(dur - 0.05, x), 2) for x in
|
|
381
|
+
[0.1] + [f["t"] for f in txt["achados"]][:6] + [i * dur / 11 for i in range(1, 12)]})[:18]
|
|
382
|
+
try:
|
|
383
|
+
saida.parent.mkdir(parents=True, exist_ok=True)
|
|
384
|
+
folha(a.video, W, H, tempos, saida)
|
|
385
|
+
except Exception as e: # noqa: BLE001 (the sheet is a help to the eye, it never fails the gate)
|
|
386
|
+
saida = None
|
|
387
|
+
print(f"aviso: não montei a folha de quadros ({e})", file=sys.stderr)
|
|
388
|
+
|
|
389
|
+
if txt.get("rosto") is False:
|
|
390
|
+
print("aviso: o rosto não foi conferido (sem o detector YuNet aqui); texto colado no rosto não reprova", file=sys.stderr)
|
|
391
|
+
rel = {"passou": not falhas, "falhas": falhas, "duracao": round(dur, 2), "volume": vol, "rosto_conferido": txt.get("rosto"),
|
|
392
|
+
"modos_do_volume": dict(modos), "amostras_clipadas": clip,
|
|
393
|
+
"limiar_respiro_db": th, "leitor_de_texto": txt["leitor"], "folha": str(saida) if saida else None}
|
|
394
|
+
if a.json:
|
|
395
|
+
print(json.dumps(rel, ensure_ascii=False))
|
|
396
|
+
return 0 if not falhas else 1
|
|
397
|
+
print(f"portão · {a.video.name} · {dur:.1f} s · {W}x{H}")
|
|
398
|
+
print(f" quadro preto: {'nenhum' if not pretos else len(pretos)}")
|
|
399
|
+
print(f" volume: {vol['lufs']:.1f} LUFS, pico {vol['pico_db']:.1f} dBTP"
|
|
400
|
+
+ (f" (ajustado; o original ficou em {volume_ajustado.name})" if volume_ajustado else ""))
|
|
401
|
+
print(" ajuste de volume: " + (", ".join(f"{e} {m}" for e, m in modos) if modos else "sem registro")
|
|
402
|
+
+ f" · amostras clipadas: {clip}")
|
|
403
|
+
print(f" respiros de 0,25 s ou mais: {'nenhum' if not resp else len(resp)}"
|
|
404
|
+
+ (f" (o silêncio do fim, {resp_fim[-1][1] - resp_fim[0][0]:.1f} s, é o fim parado ou o cartão e não conta)" if resp_fim else ""))
|
|
405
|
+
if txt["leitor"] is None:
|
|
406
|
+
print(" texto na tela: NÃO conferido (sem leitor de texto neste computador; olhe a folha de quadros)")
|
|
407
|
+
elif txt["zona"] is None:
|
|
408
|
+
print(f" texto na tela: só a borda foi conferida (formato {W}x{H}; a zona segura vale para 9:16)")
|
|
409
|
+
else:
|
|
410
|
+
print(f" texto fora da zona segura, na borda ou colado no rosto: {'nenhum' if not txt['achados'] else len(txt['achados'])} "
|
|
411
|
+
f"(leitor {txt['leitor']})"
|
|
412
|
+
+ (f"; sem ler as telas gravadas em {', '.join(fmt_time(x) + '-' + fmt_time(y) for x, y in txt['ignorado'])}"
|
|
413
|
+
if txt.get("ignorado") else ""))
|
|
414
|
+
if saida:
|
|
415
|
+
print(f" folha de quadros: {saida}")
|
|
416
|
+
if falhas:
|
|
417
|
+
print("REPROVOU:")
|
|
418
|
+
for f in falhas:
|
|
419
|
+
print(f" - {f}")
|
|
420
|
+
print("Conserte o que está acima, renderize de novo e rode o portão outra vez. Não entregue assim.")
|
|
421
|
+
return 1
|
|
422
|
+
print("PASSOU")
|
|
423
|
+
return 0
|
|
424
|
+
|
|
425
|
+
|
|
426
|
+
if __name__ == "__main__":
|
|
427
|
+
raise SystemExit(main())
|