primocode 10.16.0 → 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 +11 -2
- 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/nuvem.js +11 -1
- package/lib/passagem.js +50 -0
- package/lib/perigo.js +19 -11
- package/lib/protecao.js +134 -0
- 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,570 @@
|
|
|
1
|
+
"""Where the speaker's face is in the cut, and where text fits without covering it.
|
|
2
|
+
|
|
3
|
+
Runs OpenCV's YuNet face detector (assets/models/face_detection_yunet_2023mar.onnx, MIT) on
|
|
4
|
+
several frames of every cut_timeline.json segment (one decoding pass) and writes
|
|
5
|
+
edit/face_layout.json. A segment keeps its face when at least one sampled frame finds it; the
|
|
6
|
+
protected zone is the union of the boxes found in that segment, so a face that moves inside a
|
|
7
|
+
take stays covered.
|
|
8
|
+
|
|
9
|
+
face_layout.json (coordinates: fractions of the video size; centers: pixels on the cut canvas):
|
|
10
|
+
{"version": 1, "video": "cut.mp4", "video_sig": "...", "segments_key": "...", "width": 1080,
|
|
11
|
+
"height": 1920, "fps": 30, "duration": 25.77, "model": "face_detection_yunet_2023mar.onnx",
|
|
12
|
+
"assumed_zoom": 1.15,
|
|
13
|
+
"segments": [{"id": "r1", "start": 0.0, "end": 2.8, "samples": 7, "detected": 7,
|
|
14
|
+
"face": {"x0", "y0", "x1", "y1", "eyes_y", "mouth_y", "score"} | null,
|
|
15
|
+
"zone": {"x0", "y0", "x1", "y1"} | null, # never put text here
|
|
16
|
+
"core": {"x0", "y0", "x1", "y1"} | null, # eyes to lower lip
|
|
17
|
+
"focus_y": 0.61,
|
|
18
|
+
"recommended": {"hook_center_y", "captions_center_y", "cta_center_y",
|
|
19
|
+
"disclaimer_center_y", "split_top_speaker_y",
|
|
20
|
+
"split_bottom_speaker_y"}}],
|
|
21
|
+
"recommended": {"camera": {"focus_y"}, "hook": {"center_y"}, "captions": {"center_y"},
|
|
22
|
+
"cta": {"center_y"}, "disclaimer": {"center_y"}},
|
|
23
|
+
"summary": "..."}
|
|
24
|
+
|
|
25
|
+
The recommended centers use the default styles (manchete hook, impacto captions, CTA with a sub
|
|
26
|
+
line, default disclaimer) and a zoom up to `assumed_zoom`. compose_final.py and style_samples.py
|
|
27
|
+
recompute them with the real sizes whenever a center_y (or camera.focus_y) is omitted or "auto".
|
|
28
|
+
The split speaker offsets are the source row (pixels of the camera frame) shown at the top of the
|
|
29
|
+
speaker band for the default split 0.46, with the face slid away from the seam so a caption there
|
|
30
|
+
misses the eyes and the mouth (compose_final.py does the same per segment with the real style).
|
|
31
|
+
|
|
32
|
+
Usage:
|
|
33
|
+
uv run python helpers/face_layout.py --edit-dir DIR [--video cut.mp4] [--force] [--json]
|
|
34
|
+
"""
|
|
35
|
+
|
|
36
|
+
from __future__ import annotations
|
|
37
|
+
|
|
38
|
+
import argparse
|
|
39
|
+
import hashlib
|
|
40
|
+
import json
|
|
41
|
+
import math
|
|
42
|
+
import os
|
|
43
|
+
import subprocess
|
|
44
|
+
import sys
|
|
45
|
+
import time
|
|
46
|
+
from pathlib import Path
|
|
47
|
+
|
|
48
|
+
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
|
49
|
+
from common import ASSETS_DIR, file_signature, load_json, now_iso, probe_video, require_binary, save_json # noqa: E402
|
|
50
|
+
|
|
51
|
+
LAYOUT_VERSION = 1
|
|
52
|
+
RULES = 3 # bump when the recommendations change, so cached files are redone
|
|
53
|
+
MODEL_PATH = ASSETS_DIR / "models" / "face_detection_yunet_2023mar.onnx"
|
|
54
|
+
SCORE_MIN = 0.7 # YuNet confidence for the speaker's face
|
|
55
|
+
MIN_FACE_H = 0.06 # smaller boxes are background faces or false hits (fraction of the frame height)
|
|
56
|
+
DETECT_SHORT_SIDE = 540 # frames are scaled to this short side before detection
|
|
57
|
+
PAD_TOP = 0.08 # zone margin above the box (hair line), fraction of the box height
|
|
58
|
+
PAD_BOTTOM = 0.07 # zone margin under the box (chin, beard)
|
|
59
|
+
ASSUMED_ZOOM = 1.15 # recommendations stay valid with zoom_levels up to this
|
|
60
|
+
MAX_SAMPLES = 40
|
|
61
|
+
SAMPLE_EVERY = 0.25 # seconds between sampled frames inside a segment (heads move: sample often)
|
|
62
|
+
DECODE_EVERY = 0.125 # seconds between decoded frames (the samples are picked from these)
|
|
63
|
+
GAP_FRAC = 0.012 # breathing room between text and a face or another text (fraction of the height)
|
|
64
|
+
DISCLAIMER_MIN_CENTER_FRAC = 300.0 / 1920.0
|
|
65
|
+
DEFAULT_FOCUS_Y = 0.40
|
|
66
|
+
FACE_MOVES = 0.15 # eye lines further apart than this (fraction of the height): one number does not fit every take
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
class FaceLayoutError(Exception):
|
|
70
|
+
pass
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
# ---------------------------------------------------------------- detection
|
|
74
|
+
|
|
75
|
+
_DETECTORS: dict[tuple[int, int], object] = {}
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def _cv2():
|
|
79
|
+
os.environ.setdefault("OPENCV_LOG_LEVEL", "ERROR") # no engine notes on the user's terminal
|
|
80
|
+
try:
|
|
81
|
+
import cv2 # noqa: PLC0415
|
|
82
|
+
except ImportError as e: # pragma: no cover - depends on the install
|
|
83
|
+
raise FaceLayoutError(f"OpenCV não está instalado ({e}). Rode 'uv sync' na pasta da skill.")
|
|
84
|
+
if not hasattr(cv2, "FaceDetectorYN"):
|
|
85
|
+
raise FaceLayoutError("esta versão do OpenCV não tem o detector de rosto YuNet. Rode 'uv sync' na pasta da skill.")
|
|
86
|
+
if not MODEL_PATH.exists():
|
|
87
|
+
raise FaceLayoutError(f"modelo de rosto não encontrado: {MODEL_PATH}")
|
|
88
|
+
return cv2
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def _detector(w: int, h: int):
|
|
92
|
+
cv2 = _cv2()
|
|
93
|
+
det = _DETECTORS.get((w, h))
|
|
94
|
+
if det is None:
|
|
95
|
+
det = cv2.FaceDetectorYN.create(str(MODEL_PATH), "", (w, h), SCORE_MIN, 0.3, 50)
|
|
96
|
+
_DETECTORS[(w, h)] = det
|
|
97
|
+
return det
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def detect_array(bgr) -> dict | None:
|
|
101
|
+
"""The speaker's face in one BGR frame (numpy HxWx3): the largest confident box.
|
|
102
|
+
|
|
103
|
+
-> {"x0", "y0", "x1", "y1", "eyes_y", "mouth_y", "score"} as fractions of the frame, or None.
|
|
104
|
+
"""
|
|
105
|
+
import numpy as np # noqa: PLC0415
|
|
106
|
+
|
|
107
|
+
h, w = bgr.shape[:2]
|
|
108
|
+
scale = DETECT_SHORT_SIDE / min(w, h) if min(w, h) > DETECT_SHORT_SIDE else 1.0
|
|
109
|
+
if scale != 1.0:
|
|
110
|
+
cv2 = _cv2()
|
|
111
|
+
dw, dh = max(2, int(round(w * scale))), max(2, int(round(h * scale)))
|
|
112
|
+
img = cv2.resize(bgr, (dw, dh), interpolation=cv2.INTER_AREA)
|
|
113
|
+
else:
|
|
114
|
+
dw, dh, img = w, h, np.ascontiguousarray(bgr)
|
|
115
|
+
det = _detector(dw, dh)
|
|
116
|
+
_, faces = det.detect(img)
|
|
117
|
+
if faces is None:
|
|
118
|
+
return None
|
|
119
|
+
best = None
|
|
120
|
+
for f in faces:
|
|
121
|
+
x, y, fw, fh, score = float(f[0]), float(f[1]), float(f[2]), float(f[3]), float(f[14])
|
|
122
|
+
if score < SCORE_MIN or fh < MIN_FACE_H * dh:
|
|
123
|
+
continue
|
|
124
|
+
if best is None or fw * fh > best[2] * best[3]:
|
|
125
|
+
best = (x, y, fw, fh, score, (f[5] + f[7]) / 2.0, (f[11] + f[13]) / 2.0)
|
|
126
|
+
if best is None:
|
|
127
|
+
return None
|
|
128
|
+
x, y, fw, fh, score, eyes, mouth = best
|
|
129
|
+
return {"x0": max(0.0, x / dw), "y0": max(0.0, y / dh), "x1": min(1.0, (x + fw) / dw),
|
|
130
|
+
"y1": min(1.0, (y + fh) / dh), "eyes_y": float(eyes) / dh, "mouth_y": float(mouth) / dh, "score": score}
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def detect_image(img) -> dict | None:
|
|
134
|
+
"""detect_array for a PIL image."""
|
|
135
|
+
import numpy as np # noqa: PLC0415
|
|
136
|
+
|
|
137
|
+
rgb = np.asarray(img.convert("RGB"))
|
|
138
|
+
return detect_array(np.ascontiguousarray(rgb[:, :, ::-1]))
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def _read_frames(video: Path, frames: list[int], width: int, height: int, step: int) -> dict[int, object]:
|
|
142
|
+
"""Decode every `step`-th frame in one streamed pass (scaled for detection) and keep the listed ones.
|
|
143
|
+
|
|
144
|
+
A short `not(mod(n,step))` select keeps the ffmpeg expression tiny (a long list of frame
|
|
145
|
+
numbers overflows its parser) and streaming keeps memory flat on long videos."""
|
|
146
|
+
import numpy as np # noqa: PLC0415
|
|
147
|
+
|
|
148
|
+
wanted = set(f for f in frames if f >= 0)
|
|
149
|
+
if not wanted:
|
|
150
|
+
return {}
|
|
151
|
+
scale = min(1.0, DETECT_SHORT_SIDE / max(1, min(width, height)))
|
|
152
|
+
dw, dh = max(2, int(round(width * scale / 2)) * 2), max(2, int(round(height * scale / 2)) * 2)
|
|
153
|
+
cmd = ["ffmpeg", "-hide_banner", "-nostdin", "-v", "error", "-i", str(video),
|
|
154
|
+
"-vf", f"select='not(mod(n\\,{step}))',scale={dw}:{dh}:flags=area", "-fps_mode", "passthrough",
|
|
155
|
+
"-an", "-f", "rawvideo", "-pix_fmt", "bgr24", "-"]
|
|
156
|
+
size = dw * dh * 3
|
|
157
|
+
out = {}
|
|
158
|
+
last = max(wanted)
|
|
159
|
+
proc = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
|
|
160
|
+
try:
|
|
161
|
+
k = 0
|
|
162
|
+
while True:
|
|
163
|
+
buf = proc.stdout.read(size) if proc.stdout else b""
|
|
164
|
+
if len(buf) < size:
|
|
165
|
+
break
|
|
166
|
+
n = k * step
|
|
167
|
+
if n in wanted:
|
|
168
|
+
out[n] = np.frombuffer(buf, np.uint8).reshape(dh, dw, 3)
|
|
169
|
+
k += 1
|
|
170
|
+
if n >= last:
|
|
171
|
+
break
|
|
172
|
+
finally:
|
|
173
|
+
if proc.poll() is None:
|
|
174
|
+
proc.kill()
|
|
175
|
+
err = proc.stderr.read().decode("utf-8", "replace") if proc.stderr else ""
|
|
176
|
+
proc.wait()
|
|
177
|
+
if not out and err.strip():
|
|
178
|
+
raise FaceLayoutError(f"não consegui ler os quadros de {video.name}: {' '.join(err.strip().splitlines()[-3:])}")
|
|
179
|
+
return out
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def _summarize(dets: list[dict]) -> tuple[dict | None, dict | None, dict | None]:
|
|
183
|
+
"""-> (typical face, protected zone, core zone) for the faces found in one segment."""
|
|
184
|
+
if not dets:
|
|
185
|
+
return None, None, None
|
|
186
|
+
tallest = max(d["y1"] - d["y0"] for d in dets)
|
|
187
|
+
dets = [d for d in dets if d["y1"] - d["y0"] >= 0.4 * tallest]
|
|
188
|
+
|
|
189
|
+
def med(key):
|
|
190
|
+
vals = sorted(d[key] for d in dets)
|
|
191
|
+
n = len(vals)
|
|
192
|
+
return vals[n // 2] if n % 2 else (vals[n // 2 - 1] + vals[n // 2]) / 2.0
|
|
193
|
+
|
|
194
|
+
face = {k: round(med(k), 4) for k in ("x0", "y0", "x1", "y1", "eyes_y", "mouth_y", "score")}
|
|
195
|
+
zone = {"x0": round(min(d["x0"] for d in dets), 4),
|
|
196
|
+
"y0": round(max(0.0, min(d["y0"] - PAD_TOP * (d["y1"] - d["y0"]) for d in dets)), 4),
|
|
197
|
+
"x1": round(max(d["x1"] for d in dets), 4),
|
|
198
|
+
"y1": round(min(1.0, max(d["y1"] + PAD_BOTTOM * (d["y1"] - d["y0"]) for d in dets)), 4)}
|
|
199
|
+
core = {"x0": zone["x0"], "x1": zone["x1"],
|
|
200
|
+
"y0": round(max(0.0, min(d["eyes_y"] - 0.4 * max(0.02, d["mouth_y"] - d["eyes_y"]) for d in dets)), 4),
|
|
201
|
+
"y1": round(min(1.0, max(d["mouth_y"] + 0.3 * max(0.02, d["mouth_y"] - d["eyes_y"]) for d in dets)), 4)}
|
|
202
|
+
return face, zone, core
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def _segments_from_timeline(timeline: dict | None, duration: float, fps: float) -> list[dict]:
|
|
206
|
+
segs = []
|
|
207
|
+
for s in (timeline or {}).get("segments", []) or []:
|
|
208
|
+
try:
|
|
209
|
+
a, b = float(s["out_start"]), float(s["out_end"])
|
|
210
|
+
except (KeyError, TypeError, ValueError):
|
|
211
|
+
continue
|
|
212
|
+
if b > a:
|
|
213
|
+
segs.append({"id": str(s.get("id") or f"t{len(segs) + 1}"), "start": a, "end": b})
|
|
214
|
+
segs.sort(key=lambda s: s["start"])
|
|
215
|
+
if not segs and duration > 0:
|
|
216
|
+
# no timeline: pieces of about 3 s, so a face that moves along the video is followed
|
|
217
|
+
n = max(1, int(round(duration / 3.0)))
|
|
218
|
+
segs = [{"id": f"p{i + 1}", "start": round(duration * i / n, 3), "end": round(duration * (i + 1) / n, 3)}
|
|
219
|
+
for i in range(n)]
|
|
220
|
+
return segs
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
def _segments_key(segs: list[dict]) -> str:
|
|
224
|
+
"""Changes with the segments and with the detection settings, so an old face_layout.json is redone."""
|
|
225
|
+
params = (SCORE_MIN, MIN_FACE_H, DETECT_SHORT_SIDE, PAD_TOP, PAD_BOTTOM, ASSUMED_ZOOM, MAX_SAMPLES, SAMPLE_EVERY,
|
|
226
|
+
DECODE_EVERY, GAP_FRAC, RULES)
|
|
227
|
+
rows = [(round(s["start"], 3), round(s["end"], 3)) for s in segs]
|
|
228
|
+
return hashlib.sha1(repr((params, rows)).encode()).hexdigest()[:16]
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
def detect_segments(video: Path, segs: list[dict], fps: float, width: int, height: int) -> list[dict]:
|
|
232
|
+
step = max(1, int(round(fps * DECODE_EVERY)))
|
|
233
|
+
plan = []
|
|
234
|
+
for s in segs:
|
|
235
|
+
f0 = int(round(s["start"] * fps))
|
|
236
|
+
f1 = max(f0 + 1, int(round(s["end"] * fps)))
|
|
237
|
+
grid = [f for f in range(((f0 + step - 1) // step) * step, f1, step)]
|
|
238
|
+
if not grid: # segment shorter than the decoding step: the nearest decoded frame
|
|
239
|
+
grid = [int(round((f0 + f1) / 2.0 / step)) * step]
|
|
240
|
+
n = max(3, min(MAX_SAMPLES, int(math.ceil((s["end"] - s["start"]) / SAMPLE_EVERY))))
|
|
241
|
+
if len(grid) > n:
|
|
242
|
+
grid = [grid[int(len(grid) * (k + 0.5) / n)] for k in range(n)]
|
|
243
|
+
plan.append(sorted(set(grid)))
|
|
244
|
+
decoded = _read_frames(video, [f for fr in plan for f in fr], width, height, step)
|
|
245
|
+
out = []
|
|
246
|
+
for s, frames in zip(segs, plan):
|
|
247
|
+
dets = [d for d in (detect_array(decoded[f]) for f in frames if f in decoded) if d]
|
|
248
|
+
face, zone, core = _summarize(dets)
|
|
249
|
+
out.append(dict(s, samples=len([f for f in frames if f in decoded]), detected=len(dets),
|
|
250
|
+
face=face, zone=zone, core=core,
|
|
251
|
+
focus_y=round(face["eyes_y"], 4) if face else None))
|
|
252
|
+
return out
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
# ---------------------------------------------------------------- placement primitives
|
|
256
|
+
|
|
257
|
+
def free_center(pref: float, above: float, below: float, blocked: list[tuple[float, float]],
|
|
258
|
+
lo: float, hi: float, gap: float = 0.0) -> float | None:
|
|
259
|
+
"""Center closest to `pref` for a layer reaching `above` px up and `below` px down, with its
|
|
260
|
+
edges inside [lo, hi] and at least `gap` px away from every blocked (y0, y1) band. None when
|
|
261
|
+
nothing fits."""
|
|
262
|
+
cmin, cmax = lo + above, hi - below
|
|
263
|
+
if cmax < cmin:
|
|
264
|
+
return None
|
|
265
|
+
bad = sorted((y0 - below - gap, y1 + above + gap) for y0, y1 in blocked if y1 > y0)
|
|
266
|
+
merged: list[list[float]] = []
|
|
267
|
+
for a, b in bad:
|
|
268
|
+
if merged and a <= merged[-1][1]:
|
|
269
|
+
merged[-1][1] = max(merged[-1][1], b)
|
|
270
|
+
else:
|
|
271
|
+
merged.append([a, b])
|
|
272
|
+
free, cur = [], cmin
|
|
273
|
+
for a, b in merged:
|
|
274
|
+
if b <= cur:
|
|
275
|
+
continue
|
|
276
|
+
if a > cmax:
|
|
277
|
+
break
|
|
278
|
+
if a > cur:
|
|
279
|
+
free.append((cur, min(a, cmax)))
|
|
280
|
+
cur = max(cur, b)
|
|
281
|
+
if cur <= cmax:
|
|
282
|
+
free.append((cur, cmax))
|
|
283
|
+
if not free:
|
|
284
|
+
return None
|
|
285
|
+
best = min(free, key=lambda iv: 0.0 if iv[0] <= pref <= iv[1] else min(abs(pref - iv[0]), abs(pref - iv[1])))
|
|
286
|
+
return min(max(pref, best[0]), best[1])
|
|
287
|
+
|
|
288
|
+
|
|
289
|
+
def place(prefs: list[float], above: float, below: float, blocked: list[tuple[float, float]],
|
|
290
|
+
lo: float, hi: float, gap: float) -> float | None:
|
|
291
|
+
"""First preferred center that is free as is; otherwise the free center closest to prefs[0]."""
|
|
292
|
+
for p in prefs:
|
|
293
|
+
if free_center(p, above, below, blocked, lo, hi, gap) == p:
|
|
294
|
+
return p
|
|
295
|
+
return free_center(prefs[0], above, below, blocked, lo, hi, gap)
|
|
296
|
+
|
|
297
|
+
|
|
298
|
+
def zoom_rect(rect: dict, zoom: float, anchor_y: float) -> dict:
|
|
299
|
+
"""A normalized rect after the camera zoom (crop centered on x, anchored at anchor_y)."""
|
|
300
|
+
return {"x0": max(0.0, 0.5 + (rect["x0"] - 0.5) * zoom), "x1": min(1.0, 0.5 + (rect["x1"] - 0.5) * zoom),
|
|
301
|
+
"y0": max(0.0, anchor_y + (rect["y0"] - anchor_y) * zoom), "y1": min(1.0, anchor_y + (rect["y1"] - anchor_y) * zoom)}
|
|
302
|
+
|
|
303
|
+
|
|
304
|
+
def speaker_src_y(typ: str, split: float, eyes_px: float, W: int, H: int, safe_top: float, safe_bottom: float,
|
|
305
|
+
core: tuple[float, float] | None = None, clear: float = 0.0) -> int:
|
|
306
|
+
"""Row of the (camera) frame shown at the top of the speaker band of a split, putting the eyes
|
|
307
|
+
at 45% of the visible band (split_top) or in its middle (split_bottom).
|
|
308
|
+
|
|
309
|
+
With `core` (top and bottom of eyes-to-mouth in px of the camera frame) and `clear` (how far the
|
|
310
|
+
caption on the seam reaches into the speaker band), the face slides away from the seam until
|
|
311
|
+
that caption misses it, as long as the eyes stay well inside the visible band."""
|
|
312
|
+
hb = max(2, int(round(split * H / 2.0)) * 2)
|
|
313
|
+
hs = H - hb
|
|
314
|
+
if typ == "split_top":
|
|
315
|
+
band_y, vis0, vis1 = hb, hb, min(H, safe_bottom)
|
|
316
|
+
target = vis0 + 0.45 * max(0.0, vis1 - vis0)
|
|
317
|
+
else:
|
|
318
|
+
band_y, vis0, vis1 = 0, safe_top, hs
|
|
319
|
+
target = vis0 + 0.5 * max(0.0, vis1 - vis0)
|
|
320
|
+
src = eyes_px - (target - band_y)
|
|
321
|
+
if core is not None and clear > 0:
|
|
322
|
+
span = max(0.0, vis1 - vis0)
|
|
323
|
+
if typ == "split_top": # the core top must sit `clear` under the seam; eyes not in the last 15% of the band
|
|
324
|
+
eyes_limit = eyes_px + band_y - (vis1 - 0.15 * span)
|
|
325
|
+
src = max(eyes_limit, min(src, core[0] - clear))
|
|
326
|
+
else: # the core bottom must sit `clear` above the seam; eyes not in the first 15%
|
|
327
|
+
eyes_limit = eyes_px - band_y - (vis0 + 0.15 * span)
|
|
328
|
+
src = min(eyes_limit, max(src, core[1] - hs + clear))
|
|
329
|
+
return int(round(min(max(src, 0), H - hs)))
|
|
330
|
+
|
|
331
|
+
|
|
332
|
+
# ---------------------------------------------------------------- recommendations
|
|
333
|
+
|
|
334
|
+
def _extents(canvas: tuple[int, int]) -> dict[str, tuple[float, float]]:
|
|
335
|
+
"""(above, below) px of the default text layers, measured on rendered art."""
|
|
336
|
+
import text_render as tr # noqa: PLC0415
|
|
337
|
+
|
|
338
|
+
W, H = canvas
|
|
339
|
+
lay = tr.layout_defaults(canvas)
|
|
340
|
+
|
|
341
|
+
def ext(img, cy, grow=1.0):
|
|
342
|
+
bb = img.getchannel("A").getbbox()
|
|
343
|
+
if not bb:
|
|
344
|
+
return (0.0, 0.0)
|
|
345
|
+
return ((cy - bb[1]) * grow, (bb[3] - cy) * grow)
|
|
346
|
+
|
|
347
|
+
return {
|
|
348
|
+
"hook": ext(tr.render_hook("Você está perdendo venda assim", "manchete", tr.DEFAULT_ACCENT, canvas,
|
|
349
|
+
center_y=lay["hook"], max_width=int(900 * W / 1080)), lay["hook"], 1.05),
|
|
350
|
+
"captions": tr.caption_extent([], "impacto", canvas),
|
|
351
|
+
"cta": ext(tr.render_cta("Toque em Saiba Mais", "Acesso na hora", tr.DEFAULT_ACCENT, canvas,
|
|
352
|
+
center_y=lay["cta"]), lay["cta"], 1.03),
|
|
353
|
+
"disclaimer": ext(tr.render_disclaimer("Resultado individual. Depende de execução. Não é promessa de ganho.",
|
|
354
|
+
canvas, center_y=lay["disclaimer"]), lay["disclaimer"]),
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
|
|
358
|
+
def _bands(segs: list[dict], t0: float, t1: float, H: int, zoom: float, core: bool = False) -> list[tuple[float, float]]:
|
|
359
|
+
out = []
|
|
360
|
+
for s in segs:
|
|
361
|
+
z = s.get("core" if core else "zone")
|
|
362
|
+
if not z or s["end"] <= t0 or s["start"] >= t1:
|
|
363
|
+
continue
|
|
364
|
+
r = zoom_rect(z, zoom, s["focus_y"] if s.get("focus_y") is not None else DEFAULT_FOCUS_Y)
|
|
365
|
+
out.append((r["y0"] * H, r["y1"] * H))
|
|
366
|
+
return out
|
|
367
|
+
|
|
368
|
+
|
|
369
|
+
def recommend(segs: list[dict], canvas: tuple[int, int], duration: float, zoom: float = ASSUMED_ZOOM) -> dict:
|
|
370
|
+
import text_render as tr # noqa: PLC0415
|
|
371
|
+
|
|
372
|
+
W, H = canvas
|
|
373
|
+
lay = tr.layout_defaults(canvas)
|
|
374
|
+
ext = _extents(canvas)
|
|
375
|
+
gap = GAP_FRAC * H
|
|
376
|
+
lo, hi = lay["top"], lay["bottom"]
|
|
377
|
+
disc_hi = min(H - 8.0 * H / 1920.0, lay["bottom"] + 0.035 * H)
|
|
378
|
+
# The disclaimer center never goes above 300 px on a 1920 canvas (Reels top bar), same rule as
|
|
379
|
+
# references/phase2.md gives for manual numbers.
|
|
380
|
+
disc_lo = max(lo, DISCLAIMER_MIN_CENTER_FRAC * H - ext["disclaimer"][0])
|
|
381
|
+
|
|
382
|
+
def centers(t0: float, t1: float, captions_band: tuple[float, float] | None = None) -> dict:
|
|
383
|
+
faces = _bands(segs, t0, t1, H, zoom)
|
|
384
|
+
cores = _bands(segs, t0, t1, H, zoom, core=True)
|
|
385
|
+
r = {}
|
|
386
|
+
c = place([lay["cta"]], *ext["cta"], faces, lo, hi, gap)
|
|
387
|
+
r["cta_center_y"] = c
|
|
388
|
+
r["hook_center_y"] = place([lay["hook"]], *ext["hook"], faces, lo, hi, gap)
|
|
389
|
+
cap = place([lay["caption"]], *ext["captions"], faces, lo, hi, gap)
|
|
390
|
+
r["captions_center_y"] = cap
|
|
391
|
+
cap_band = captions_band or ((cap - ext["captions"][0], cap + ext["captions"][1]) if cap is not None else None)
|
|
392
|
+
top_anchor = disc_lo + ext["disclaimer"][0] + gap
|
|
393
|
+
blocked = faces + ([cap_band] if cap_band else [])
|
|
394
|
+
d = place([lay["disclaimer"], top_anchor], *ext["disclaimer"], blocked, disc_lo, disc_hi, gap)
|
|
395
|
+
if d is None: # small print may sit on the chin or the forehead, never on the eyes or the mouth
|
|
396
|
+
d = place([lay["disclaimer"], top_anchor], *ext["disclaimer"], cores + ([cap_band] if cap_band else []),
|
|
397
|
+
disc_lo, disc_hi, 3 * gap)
|
|
398
|
+
r["disclaimer_center_y"] = d
|
|
399
|
+
return {k: (round(v) if v is not None else None) for k, v in r.items()}
|
|
400
|
+
|
|
401
|
+
safe_top, safe_bottom = lay["top"], lay["bottom"]
|
|
402
|
+
for s in segs:
|
|
403
|
+
rec = centers(s["start"], s["end"])
|
|
404
|
+
if s.get("face"):
|
|
405
|
+
eyes = s["face"]["eyes_y"] * H
|
|
406
|
+
core = (s["core"]["y0"] * H, s["core"]["y1"] * H) if s.get("core") else None
|
|
407
|
+
above, below = ext["captions"]
|
|
408
|
+
rec["split_top_speaker_y"] = speaker_src_y("split_top", 0.46, eyes, W, H, safe_top, safe_bottom,
|
|
409
|
+
core=core, clear=below + gap)
|
|
410
|
+
rec["split_bottom_speaker_y"] = speaker_src_y("split_bottom", 0.46, eyes, W, H, safe_top, safe_bottom,
|
|
411
|
+
core=core, clear=above + gap)
|
|
412
|
+
else:
|
|
413
|
+
rec["split_top_speaker_y"] = rec["split_bottom_speaker_y"] = None
|
|
414
|
+
s["recommended"] = rec
|
|
415
|
+
|
|
416
|
+
with_face = [s for s in segs if s.get("face")]
|
|
417
|
+
if with_face:
|
|
418
|
+
weights = sorted((s["face"]["eyes_y"], s["end"] - s["start"]) for s in with_face)
|
|
419
|
+
half, acc, focus = sum(w for _, w in weights) / 2.0, 0.0, weights[-1][0]
|
|
420
|
+
for v, w in weights:
|
|
421
|
+
acc += w
|
|
422
|
+
if acc >= half:
|
|
423
|
+
focus = v
|
|
424
|
+
break
|
|
425
|
+
else:
|
|
426
|
+
focus = DEFAULT_FOCUS_Y
|
|
427
|
+
hook_end = min(duration, 3.0)
|
|
428
|
+
cta_start = max(0.0, duration - 2.5)
|
|
429
|
+
faces_all = _bands(segs, 0.0, duration, H, zoom)
|
|
430
|
+
faces_after_hook = _bands(segs, hook_end, duration, H, zoom) or faces_all
|
|
431
|
+
cap = place([lay["caption"]], *ext["captions"], faces_after_hook, lo, hi, gap)
|
|
432
|
+
cap_band = (cap - ext["captions"][0], cap + ext["captions"][1]) if cap is not None else None
|
|
433
|
+
top_anchor = disc_lo + ext["disclaimer"][0] + gap
|
|
434
|
+
disc = place([lay["disclaimer"], top_anchor], *ext["disclaimer"], faces_all + ([cap_band] if cap_band else []),
|
|
435
|
+
disc_lo, disc_hi, gap)
|
|
436
|
+
if disc is None:
|
|
437
|
+
disc = place([lay["disclaimer"], top_anchor], *ext["disclaimer"],
|
|
438
|
+
_bands(segs, 0.0, duration, H, zoom, core=True) + ([cap_band] if cap_band else []), disc_lo, disc_hi, 3 * gap)
|
|
439
|
+
rec = {
|
|
440
|
+
"camera": {"focus_y": round(focus, 3)},
|
|
441
|
+
"hook": {"center_y": place([lay["hook"]], *ext["hook"], _bands(segs, 0.0, hook_end, H, zoom), lo, hi, gap)},
|
|
442
|
+
"captions": {"center_y": cap},
|
|
443
|
+
"cta": {"center_y": (place([lay["cta"]], *ext["cta"],
|
|
444
|
+
_bands(segs, cta_start, duration, H, zoom) + ([cap_band] if cap_band else []), lo, hi, gap)
|
|
445
|
+
or place([lay["cta"]], *ext["cta"], _bands(segs, cta_start, duration, H, zoom), lo, hi, gap))},
|
|
446
|
+
"disclaimer": {"center_y": disc},
|
|
447
|
+
}
|
|
448
|
+
for sec in rec.values():
|
|
449
|
+
for k, v in list(sec.items()):
|
|
450
|
+
if k == "center_y" and v is not None:
|
|
451
|
+
sec[k] = int(round(v))
|
|
452
|
+
return rec
|
|
453
|
+
|
|
454
|
+
|
|
455
|
+
def _summary(layout: dict) -> str:
|
|
456
|
+
segs = layout["segments"]
|
|
457
|
+
found = [s for s in segs if s.get("face")]
|
|
458
|
+
rec = layout["recommended"]
|
|
459
|
+
if not found:
|
|
460
|
+
return (f"nenhum rosto encontrado em {len(segs)} trechos: os textos ficam nas posições padrão")
|
|
461
|
+
eyes = [s["face"]["eyes_y"] for s in found]
|
|
462
|
+
moves = max(eyes) - min(eyes) > FACE_MOVES
|
|
463
|
+
if moves:
|
|
464
|
+
where = "o rosto muda de altura entre os trechos"
|
|
465
|
+
else:
|
|
466
|
+
where = "rosto baixo" if rec["camera"]["focus_y"] > 0.5 else "rosto alto" if rec["camera"]["focus_y"] < 0.3 else "rosto no meio"
|
|
467
|
+
key = {"hook": "hook_center_y", "captions": "captions_center_y", "cta": "cta_center_y", "disclaimer": "disclaimer_center_y"}
|
|
468
|
+
|
|
469
|
+
def v(sec):
|
|
470
|
+
c = rec[sec]["center_y"]
|
|
471
|
+
if c is not None:
|
|
472
|
+
return str(c)
|
|
473
|
+
per = sorted({s["recommended"][key[sec]] for s in segs if (s.get("recommended") or {}).get(key[sec]) is not None})
|
|
474
|
+
return f"muda por trecho ({per[0]} a {per[-1]})" if per else "sem lugar livre"
|
|
475
|
+
|
|
476
|
+
tip = (". Deixe camera.focus_y e os center_y em \"auto\": o compose ajusta trecho por trecho" if moves else "")
|
|
477
|
+
return (f"rosto em {len(found)} de {len(segs)} trechos, olhos entre {min(eyes):.2f} e {max(eyes):.2f} da altura "
|
|
478
|
+
f"({where}). Sugestão: camera.focus_y {'auto' if moves else rec['camera']['focus_y']}, gancho {v('hook')}, "
|
|
479
|
+
f"legenda {v('captions')}, chamada {v('cta')}, aviso {v('disclaimer')}{tip}")
|
|
480
|
+
|
|
481
|
+
|
|
482
|
+
# ---------------------------------------------------------------- build / cache
|
|
483
|
+
|
|
484
|
+
def build(edit_dir: Path, video: Path | None = None, force: bool = False, timeline: dict | None = None) -> dict:
|
|
485
|
+
"""Load edit/face_layout.json when it matches the video and the timeline, else detect and write it."""
|
|
486
|
+
edit_dir = Path(edit_dir).resolve()
|
|
487
|
+
video = Path(video).resolve() if video else edit_dir / "cut.mp4"
|
|
488
|
+
if not video.exists():
|
|
489
|
+
raise FaceLayoutError(f"vídeo não encontrado: {video}")
|
|
490
|
+
if timeline is None and video == edit_dir / "cut.mp4":
|
|
491
|
+
timeline = load_json(edit_dir / "cut_timeline.json") # its segments are positions in cut.mp4
|
|
492
|
+
info = probe_video(video)
|
|
493
|
+
width = int(info.get("display_width") or info.get("width") or 0)
|
|
494
|
+
height = int(info.get("display_height") or info.get("height") or 0)
|
|
495
|
+
fps = float(info.get("fps") or 30.0)
|
|
496
|
+
duration = float(info.get("duration") or 0.0)
|
|
497
|
+
if width <= 0 or height <= 0:
|
|
498
|
+
raise FaceLayoutError(f"não consegui ler o tamanho de {video.name}")
|
|
499
|
+
segs = _segments_from_timeline(timeline, duration, fps)
|
|
500
|
+
key = _segments_key(segs)
|
|
501
|
+
out_path = edit_dir / "face_layout.json"
|
|
502
|
+
sig = file_signature(video)
|
|
503
|
+
old = load_json(out_path) if out_path.exists() and not force else None
|
|
504
|
+
if (isinstance(old, dict) and old.get("version") == LAYOUT_VERSION and old.get("video_sig") == sig
|
|
505
|
+
and old.get("segments_key") == key and old.get("video") == _rel(video, edit_dir)):
|
|
506
|
+
try:
|
|
507
|
+
old["summary"] = _summary(old) # the wording may be newer than the cached file
|
|
508
|
+
except (KeyError, TypeError, ValueError):
|
|
509
|
+
pass
|
|
510
|
+
return old
|
|
511
|
+
require_binary("ffmpeg")
|
|
512
|
+
started = time.time()
|
|
513
|
+
detected = detect_segments(video, segs, fps, width, height)
|
|
514
|
+
layout = {
|
|
515
|
+
"version": LAYOUT_VERSION,
|
|
516
|
+
"video": _rel(video, edit_dir),
|
|
517
|
+
"video_sig": sig,
|
|
518
|
+
"segments_key": key,
|
|
519
|
+
"width": width, "height": height, "fps": round(fps, 3), "duration": round(duration, 3),
|
|
520
|
+
"model": MODEL_PATH.name,
|
|
521
|
+
"assumed_zoom": ASSUMED_ZOOM,
|
|
522
|
+
"segments": detected,
|
|
523
|
+
}
|
|
524
|
+
layout["recommended"] = recommend(detected, (width, height), duration)
|
|
525
|
+
layout["summary"] = _summary(layout)
|
|
526
|
+
layout["seconds"] = round(time.time() - started, 2)
|
|
527
|
+
layout["created_at"] = now_iso()
|
|
528
|
+
save_json(out_path, layout)
|
|
529
|
+
return layout
|
|
530
|
+
|
|
531
|
+
|
|
532
|
+
def _rel(p: Path, base: Path) -> str:
|
|
533
|
+
try:
|
|
534
|
+
return str(p.resolve().relative_to(base))
|
|
535
|
+
except ValueError:
|
|
536
|
+
return str(p.resolve())
|
|
537
|
+
|
|
538
|
+
|
|
539
|
+
def main(argv: list[str] | None = None) -> int:
|
|
540
|
+
ap = argparse.ArgumentParser(description="Acha o rosto em cada trecho do corte e sugere onde pôr gancho, legenda, chamada e aviso.")
|
|
541
|
+
ap.add_argument("--edit-dir", required=True, help="pasta edit da sessão")
|
|
542
|
+
ap.add_argument("--video", help="vídeo a analisar (padrão: edit/cut.mp4)")
|
|
543
|
+
ap.add_argument("--force", action="store_true", help="analisa de novo mesmo com o face_layout.json em dia")
|
|
544
|
+
ap.add_argument("--json", action="store_true", help="imprime o face_layout.json")
|
|
545
|
+
args = ap.parse_args(argv)
|
|
546
|
+
edit = Path(args.edit_dir).expanduser().resolve()
|
|
547
|
+
if not edit.is_dir():
|
|
548
|
+
print(f"erro: pasta de edição não encontrada: {edit}", file=sys.stderr)
|
|
549
|
+
return 2
|
|
550
|
+
try:
|
|
551
|
+
layout = build(edit, Path(args.video).expanduser() if args.video else None, force=args.force)
|
|
552
|
+
except FaceLayoutError as e:
|
|
553
|
+
print(f"erro: {e}", file=sys.stderr)
|
|
554
|
+
return 2
|
|
555
|
+
if args.json:
|
|
556
|
+
print(json.dumps(layout, ensure_ascii=False, indent=2))
|
|
557
|
+
return 0
|
|
558
|
+
for s in layout["segments"]:
|
|
559
|
+
if s.get("face"):
|
|
560
|
+
z = s["zone"]
|
|
561
|
+
print(f" {s['id']:>5} {s['start']:6.2f}-{s['end']:6.2f}s rosto {s['detected']}/{s['samples']} "
|
|
562
|
+
f"altura {z['y0']:.2f}-{z['y1']:.2f} olhos {s['face']['eyes_y']:.2f}")
|
|
563
|
+
else:
|
|
564
|
+
print(f" {s['id']:>5} {s['start']:6.2f}-{s['end']:6.2f}s sem rosto (0/{s['samples']})")
|
|
565
|
+
print(f"ok: {layout['summary']} -> {edit / 'face_layout.json'}")
|
|
566
|
+
return 0
|
|
567
|
+
|
|
568
|
+
|
|
569
|
+
if __name__ == "__main__":
|
|
570
|
+
sys.exit(main())
|