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,1256 @@
|
|
|
1
|
+
# Adapted from browser-use/video-use (MIT, (c) 2026 Browser Use). See THIRD_PARTY_NOTICES.md.
|
|
2
|
+
"""EDL -> cut.mp4 + cut_timeline.json (Contract 5 of SPEC.md).
|
|
3
|
+
|
|
4
|
+
Usage:
|
|
5
|
+
uv run python helpers/render_cut.py <edit>/edl.json [-o cut.mp4] [--jobs N] [--no-jcut] [--preview]
|
|
6
|
+
|
|
7
|
+
Pipeline
|
|
8
|
+
1. Plan: the audio of a range starts exactly at the EDL start. The picture starts on the
|
|
9
|
+
nearest source frame when one is within half an output frame, else at the exact time
|
|
10
|
+
(sparse VFR sources such as screen recordings: the frame on screen at that time is used).
|
|
11
|
+
Lengths are whole output frames. J-cut per junction: the incoming picture skips
|
|
12
|
+
`lead_frames` while its audio keeps them (so it starts earlier, over the outgoing
|
|
13
|
+
picture); the outgoing tail loses up to `tail_trim_frames` only where there is no
|
|
14
|
+
measured speech (edit/regions/<stem>.json, or an inline silencedetect when missing).
|
|
15
|
+
A range with "dry": false keeps its written edges: no lead and no tail trim on it (a video playing
|
|
16
|
+
on screen starts on its first frame).
|
|
17
|
+
A source whose shape differs from the output is cropped around `focus_x`/`focus_y` of
|
|
18
|
+
the range, or around the face found by YuNet when the crop is large.
|
|
19
|
+
"imagem_de": "CAM2" on a range: the audio still comes from the range's source and the picture
|
|
20
|
+
from CAM2 at the same instant, through sincronia.json (sincronizar_cameras.py; the EDL key
|
|
21
|
+
"sincronia" points to another file). A source used only as audio (a lapela .wav) needs no video.
|
|
22
|
+
Two ranges in a row from the same audio source that touch (start of the second within half a
|
|
23
|
+
frame of the end of the first) are one continuous voice: no J-cut and no 30 ms fades there, and
|
|
24
|
+
the second one's audio starts on the exact sample where the first one's ends (a camera switch in
|
|
25
|
+
the middle of a sentence does not dip or skip). Their gain_db must match, or the fades stay.
|
|
26
|
+
2. Extract, in parallel, one picture clip (H.264, final quality, upright, scaled+cropped,
|
|
27
|
+
fps, HDR->SDR, 8-bit guard, grade, bt709/tv tags) and one float WAV (48k stereo,
|
|
28
|
+
gain+limiter, 30 ms fades, sample-exact length) per range into edit/clips/ (cached).
|
|
29
|
+
3. Count the encoded frames of each clip -> out_start/out_end (frame-accurate).
|
|
30
|
+
4. Picture: concat demuxer, stream copy. Audio: adelay in samples + amix normalize=0,
|
|
31
|
+
loudnorm two-pass (I=-14, TP=-1, LRA=11), AAC 192k, length pinned with -t. The true peak
|
|
32
|
+
of the encoded file is measured; above -1.05 dBTP (a margin for verify_cut's ebur128 reading)
|
|
33
|
+
the audio is lowered and encoded again.
|
|
34
|
+
5. Write <output stem>_timeline.json (cut_timeline.json by default). A full render inside
|
|
35
|
+
the edit dir also merges state.json (awaiting stays null: verify_cut asks for approval).
|
|
36
|
+
`--preview` writes cut_preview.mp4 and leaves cut.mp4 and state.json alone.
|
|
37
|
+
"""
|
|
38
|
+
|
|
39
|
+
from __future__ import annotations
|
|
40
|
+
|
|
41
|
+
import argparse
|
|
42
|
+
import hashlib
|
|
43
|
+
import json
|
|
44
|
+
import math
|
|
45
|
+
import os
|
|
46
|
+
import re
|
|
47
|
+
import subprocess
|
|
48
|
+
import sys
|
|
49
|
+
import time
|
|
50
|
+
from concurrent.futures import ThreadPoolExecutor
|
|
51
|
+
from dataclasses import dataclass, field
|
|
52
|
+
from fractions import Fraction
|
|
53
|
+
from pathlib import Path
|
|
54
|
+
|
|
55
|
+
from functools import lru_cache
|
|
56
|
+
|
|
57
|
+
from common import (ASSETS_DIR, OUTPUT_FORMATS, file_signature, fmt_time, load_json, nominal_fps, now_iso,
|
|
58
|
+
output_fps_for, probe_video, require_binary, save_json)
|
|
59
|
+
from common import has_ffmpeg_filter as _has_ffmpeg_filter
|
|
60
|
+
from grade import check_filter, graded_chain, hdr_to_sdr_chain, resolve_grade
|
|
61
|
+
|
|
62
|
+
SR = 48000
|
|
63
|
+
FADE_SAMPLES = 1440 # 30 ms
|
|
64
|
+
SPEECH_GUARD_S = 0.10 # the tail trim never gets closer than this to measured speech (a weak
|
|
65
|
+
# final consonant sits under the speech regions; 0.04 bit it)
|
|
66
|
+
MIN_JOIN_GAP_S = 0.10 # silence kept between outgoing and incoming speech after a J-cut
|
|
67
|
+
SILENCE_NOISE_DB = -35
|
|
68
|
+
MIN_PIC_FRAMES = 6
|
|
69
|
+
DEFAULT_JCUT = {"lead_frames": 4, "tail_trim_frames": 2}
|
|
70
|
+
LOUDNORM = {"I": -14.0, "TP": -1.0, "LRA": 11.0}
|
|
71
|
+
TRUE_PEAK_LIMIT = -1.0 # delivered cut.mp4 (after AAC) must not go above this
|
|
72
|
+
TRUE_PEAK_MARGIN = 0.05 # loudnorm (here) and ebur128 (verify_cut) read the true peak a few hundredths apart
|
|
73
|
+
CACHE_VERSION = "render_cut/2"
|
|
74
|
+
SYNC_FILE = "sincronia.json"
|
|
75
|
+
BT709_TAGS = "setparams=range=tv:color_primaries=bt709:color_trc=bt709:colorspace=bt709"
|
|
76
|
+
ASPECT_WARN = 1.15 # source/output shape ratio beyond this: warn and frame on the face
|
|
77
|
+
CROP_MIN = 0.02 # crops smaller than this are just "fill", not recorded
|
|
78
|
+
REPEAT_TOL_S = 0.02
|
|
79
|
+
FACE_MODEL = ASSETS_DIR / "models" / "face_detection_yunet_2023mar.onnx"
|
|
80
|
+
FACE_SAMPLES = 5
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
@lru_cache(maxsize=None)
|
|
84
|
+
def has_ffmpeg_filter(name: str) -> bool:
|
|
85
|
+
return _has_ffmpeg_filter(name)
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
class CutError(Exception):
|
|
89
|
+
def __init__(self, message: str, code: int = 1):
|
|
90
|
+
super().__init__(message)
|
|
91
|
+
self.code = code
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def log(msg: str = "") -> None:
|
|
95
|
+
print(msg, flush=True)
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def warn(msg: str) -> None:
|
|
99
|
+
print(f"aviso: {msg}", file=sys.stderr, flush=True)
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def ffmpeg(cmd: list[str], what: str) -> subprocess.CompletedProcess:
|
|
103
|
+
proc = subprocess.run([str(c) for c in cmd], capture_output=True, text=True)
|
|
104
|
+
if proc.returncode != 0:
|
|
105
|
+
raise CutError(f"o ffmpeg falhou ao {what}:\n{(proc.stderr or '').strip()[-1500:]}")
|
|
106
|
+
return proc
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def edl_signature(edl: dict) -> str:
|
|
110
|
+
return hashlib.sha1(json.dumps(edl, sort_keys=True, ensure_ascii=False).encode("utf-8")).hexdigest()[:16]
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
# ------------------------------------------------------------------ EDL loading
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def resolve_source(value: str, edit_dir: Path) -> Path:
|
|
117
|
+
p = Path(value).expanduser()
|
|
118
|
+
if p.is_absolute():
|
|
119
|
+
return p
|
|
120
|
+
for base in (edit_dir, edit_dir.parent):
|
|
121
|
+
cand = Path(os.path.abspath(base / p))
|
|
122
|
+
if cand.exists():
|
|
123
|
+
return cand
|
|
124
|
+
return Path(os.path.abspath(edit_dir / p))
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def load_edl(path: Path) -> dict:
|
|
128
|
+
if not path.exists():
|
|
129
|
+
raise CutError(f"EDL não encontrado: {path}", 2)
|
|
130
|
+
try:
|
|
131
|
+
edl = json.loads(path.read_text(encoding="utf-8"))
|
|
132
|
+
except json.JSONDecodeError as exc:
|
|
133
|
+
raise CutError(f"o EDL não é um JSON válido ({path.name}, linha {exc.lineno}): {exc.msg}", 2)
|
|
134
|
+
if not isinstance(edl, dict):
|
|
135
|
+
raise CutError("o EDL precisa ser um objeto JSON", 2)
|
|
136
|
+
ranges = edl.get("ranges")
|
|
137
|
+
if not isinstance(ranges, list) or not ranges:
|
|
138
|
+
raise CutError("o EDL não tem trechos em \"ranges\"", 2)
|
|
139
|
+
sources = edl.get("sources") or {}
|
|
140
|
+
if not isinstance(sources, dict) or not sources:
|
|
141
|
+
raise CutError("o EDL não tem \"sources\" (mapa nome -> caminho do vídeo)", 2)
|
|
142
|
+
seen: set[str] = set()
|
|
143
|
+
for i, r in enumerate(ranges):
|
|
144
|
+
rid = str(r.get("id") or f"r{i + 1}")
|
|
145
|
+
r["id"] = rid
|
|
146
|
+
if rid in seen:
|
|
147
|
+
raise CutError(f"id repetido no EDL: {rid}", 2)
|
|
148
|
+
seen.add(rid)
|
|
149
|
+
if r.get("source") not in sources:
|
|
150
|
+
raise CutError(f"trecho {rid}: fonte {r.get('source')!r} não está em \"sources\"", 2)
|
|
151
|
+
try:
|
|
152
|
+
r["start"], r["end"] = float(r["start"]), float(r["end"])
|
|
153
|
+
except (KeyError, TypeError, ValueError):
|
|
154
|
+
raise CutError(f"trecho {rid}: start/end precisam ser números em segundos", 2)
|
|
155
|
+
if r["start"] < 0 or r["end"] <= r["start"]:
|
|
156
|
+
raise CutError(f"trecho {rid}: end ({r['end']}) precisa ser maior que start ({r['start']})", 2)
|
|
157
|
+
try:
|
|
158
|
+
r["gain_db"] = float(r.get("gain_db") or 0.0)
|
|
159
|
+
except (TypeError, ValueError):
|
|
160
|
+
raise CutError(f"trecho {rid}: gain_db inválido", 2)
|
|
161
|
+
for k in ("focus_x", "focus_y"):
|
|
162
|
+
if r.get(k) is None:
|
|
163
|
+
continue
|
|
164
|
+
try:
|
|
165
|
+
r[k] = float(r[k])
|
|
166
|
+
except (TypeError, ValueError):
|
|
167
|
+
raise CutError(f"trecho {rid}: {k} precisa ser um número de 0 a 1", 2)
|
|
168
|
+
if not 0.0 <= r[k] <= 1.0:
|
|
169
|
+
raise CutError(f"trecho {rid}: {k} precisa ficar entre 0 e 1 (0 = esquerda/topo, 1 = direita/base)", 2)
|
|
170
|
+
pic = r.get("imagem_de")
|
|
171
|
+
if pic in (None, "", False) or pic == r["source"]:
|
|
172
|
+
r["imagem_de"] = None
|
|
173
|
+
elif not isinstance(pic, str) or pic not in sources:
|
|
174
|
+
raise CutError(f"trecho {rid}: imagem_de {pic!r} não está em \"sources\" (ponha a câmera lá, com o caminho)", 2)
|
|
175
|
+
return edl
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def picture_key(r: dict) -> str:
|
|
179
|
+
"""The source the picture of a range comes from."""
|
|
180
|
+
return r.get("imagem_de") or r["source"]
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def load_sync(edl: dict, edit_dir: Path, probes: dict) -> tuple[dict[str, tuple[float, float]], Path | None]:
|
|
184
|
+
"""{source: (offset, scale)} from sincronia.json for every source a range with imagem_de touches.
|
|
185
|
+
|
|
186
|
+
Rule of the file: time in the source = deslocamento + ajuste_s + escala x time in the main one."""
|
|
187
|
+
needed = sorted({k for r in edl["ranges"] if r.get("imagem_de") for k in (r["source"], r["imagem_de"])})
|
|
188
|
+
if not needed:
|
|
189
|
+
return {}, None
|
|
190
|
+
raw = edl.get("sincronia") or SYNC_FILE
|
|
191
|
+
path = resolve_source(str(raw), edit_dir)
|
|
192
|
+
if not path.exists():
|
|
193
|
+
raise CutError(f"trechos com imagem_de precisam da sincronia das câmeras e {path} não existe. Rode "
|
|
194
|
+
f"helpers/sincronizar_cameras.py --edl <EDIT>/edl_manual.json (ou ponha \"sincronia\" no EDL)", 2)
|
|
195
|
+
doc = load_json(path)
|
|
196
|
+
fontes = doc.get("fontes") if isinstance(doc, dict) else None
|
|
197
|
+
if not isinstance(fontes, dict):
|
|
198
|
+
raise CutError(f"{path.name} não é um arquivo de sincronia (falta \"fontes\")", 2)
|
|
199
|
+
out: dict[str, tuple[float, float]] = {}
|
|
200
|
+
for key in needed:
|
|
201
|
+
f = fontes.get(key)
|
|
202
|
+
if not isinstance(f, dict):
|
|
203
|
+
raise CutError(f"a fonte {key} não está em {path.name}: rode sincronizar_cameras.py com ela junto", 2)
|
|
204
|
+
try:
|
|
205
|
+
off = float(f.get("deslocamento") or 0.0) + float(f.get("ajuste_s") or 0.0)
|
|
206
|
+
scale = float(f.get("escala") or 1.0)
|
|
207
|
+
except (TypeError, ValueError):
|
|
208
|
+
raise CutError(f"{path.name}: deslocamento, ajuste_s ou escala de {key} não é número", 2)
|
|
209
|
+
if not 0.9 < scale < 1.1:
|
|
210
|
+
raise CutError(f"{path.name}: escala {scale} de {key} fora do razoável", 2)
|
|
211
|
+
src = probes[key]["path"]
|
|
212
|
+
measured = Path(str(f.get("arquivo") or ""))
|
|
213
|
+
same_path = measured.exists() and os.path.samefile(measured, src)
|
|
214
|
+
same_sig = f.get("assinatura") == file_signature(src)
|
|
215
|
+
if not same_path and not same_sig:
|
|
216
|
+
raise CutError(f"{path.name} mediu {key} em outro arquivo ({measured}), não em {src}: rode "
|
|
217
|
+
"sincronizar_cameras.py de novo", 2)
|
|
218
|
+
if same_path and not same_sig and f.get("assinatura"):
|
|
219
|
+
warn(f"{src.name} mudou depois da sincronia ({path.name}): se foi reexportado, rode sincronizar_cameras.py de novo")
|
|
220
|
+
if f.get("confianca") == "fraca":
|
|
221
|
+
warn(f"a sincronia de {key} saiu fraca em {path.name}: confira no olho (sincronizar_cameras.py --prova)")
|
|
222
|
+
out[key] = (off, scale)
|
|
223
|
+
return out, path
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
def jcut_settings(edl: dict, no_jcut: bool) -> dict:
|
|
227
|
+
raw = edl.get("jcut", None)
|
|
228
|
+
if no_jcut or raw is False:
|
|
229
|
+
return {"enabled": False, "lead_frames": 0, "tail_trim_frames": 0}
|
|
230
|
+
cfg = dict(DEFAULT_JCUT)
|
|
231
|
+
if isinstance(raw, dict):
|
|
232
|
+
for k in cfg:
|
|
233
|
+
if raw.get(k) is not None:
|
|
234
|
+
cfg[k] = max(0, int(raw[k]))
|
|
235
|
+
return {"enabled": cfg["lead_frames"] > 0 or cfg["tail_trim_frames"] > 0, **cfg}
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
# ------------------------------------------------------------------ source analysis
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
def source_frame_times(src: Path, t: float, before: float = 3.0, after: float = 3.0) -> list[float]:
|
|
242
|
+
"""Presentation times of the source frames around t (packets, no decoding).
|
|
243
|
+
|
|
244
|
+
The window is wide and the list is sorted: with B-frames the packets come in decode order,
|
|
245
|
+
and ffprobe stops reading at the first packet past the window end, so a narrow window
|
|
246
|
+
loses frames that sit just before t.
|
|
247
|
+
"""
|
|
248
|
+
proc = subprocess.run(
|
|
249
|
+
["ffprobe", "-v", "error", "-select_streams", "v:0", "-read_intervals",
|
|
250
|
+
f"{max(0.0, t - before):.3f}%{t + after:.3f}", "-show_entries", "packet=pts_time",
|
|
251
|
+
"-of", "csv=p=0", str(src)], capture_output=True, text=True)
|
|
252
|
+
out = set()
|
|
253
|
+
for line in proc.stdout.splitlines():
|
|
254
|
+
try:
|
|
255
|
+
out.add(float(line.strip().strip(",")))
|
|
256
|
+
except ValueError:
|
|
257
|
+
continue
|
|
258
|
+
return sorted(out)
|
|
259
|
+
|
|
260
|
+
|
|
261
|
+
def picture_start(src: Path, t: float, fps: int) -> tuple[float, bool]:
|
|
262
|
+
"""(source time of the first picture frame, snapped?).
|
|
263
|
+
|
|
264
|
+
Snaps to the nearest real frame only when it is within half an output frame. A sparse VFR
|
|
265
|
+
source (static screen recording) has no frame near t: the exact time is kept and the
|
|
266
|
+
extraction shows the frame that is on screen at t.
|
|
267
|
+
"""
|
|
268
|
+
times = source_frame_times(src, t)
|
|
269
|
+
if times:
|
|
270
|
+
near = min(times, key=lambda x: abs(x - t))
|
|
271
|
+
if abs(near - t) <= 0.5 / fps + 1e-6:
|
|
272
|
+
return near, True
|
|
273
|
+
return t, False
|
|
274
|
+
|
|
275
|
+
|
|
276
|
+
def nominal_rate(src: Path, info: dict) -> float | None:
|
|
277
|
+
"""Nominal fps of a source for choosing the output fps; None for sparse or variable sources
|
|
278
|
+
(screen recordings write frames only when the screen changes) that must not drag it down."""
|
|
279
|
+
avg = float(info.get("fps") or 0.0)
|
|
280
|
+
proc = subprocess.run(["ffprobe", "-v", "error", "-select_streams", "v:0", "-show_entries",
|
|
281
|
+
"stream=r_frame_rate", "-of", "csv=p=0", str(src)], capture_output=True, text=True)
|
|
282
|
+
try:
|
|
283
|
+
r = float(Fraction(proc.stdout.strip().split(",")[0]))
|
|
284
|
+
except (ValueError, ZeroDivisionError):
|
|
285
|
+
r = 0.0
|
|
286
|
+
if avg <= 0 or (r > 0 and avg < 0.8 * r):
|
|
287
|
+
return None
|
|
288
|
+
n = nominal_fps(avg)
|
|
289
|
+
return n if n >= 23.0 else None
|
|
290
|
+
|
|
291
|
+
|
|
292
|
+
def choose_output_fps(probes: dict) -> int:
|
|
293
|
+
rates = [nominal_rate(pr["path"], pr["info"]) for pr in probes.values()]
|
|
294
|
+
rates = [r for r in rates if r]
|
|
295
|
+
return output_fps_for(max(rates)) if rates else 30
|
|
296
|
+
|
|
297
|
+
|
|
298
|
+
def crop_geometry(info: dict, W: int, H: int) -> tuple[str | None, float, float]:
|
|
299
|
+
"""(axis cropped "x"/"y" or None, kept fraction of that axis, source/output shape ratio)."""
|
|
300
|
+
dw, dh = info.get("display_width") or W, info.get("display_height") or H
|
|
301
|
+
ratio = (dw / dh) / (W / H)
|
|
302
|
+
if ratio > 1.0 + CROP_MIN:
|
|
303
|
+
return "x", 1.0 / ratio, ratio
|
|
304
|
+
if ratio < 1.0 - CROP_MIN:
|
|
305
|
+
return "y", ratio, ratio
|
|
306
|
+
return None, 1.0, ratio
|
|
307
|
+
|
|
308
|
+
|
|
309
|
+
def face_focus(src: Path, t0: float, t1: float) -> tuple[float, float, int] | None:
|
|
310
|
+
"""Median center (0..1, upright frame) of the biggest face on FACE_SAMPLES frames of [t0, t1].
|
|
311
|
+
|
|
312
|
+
Returns (x, y, frames with a face), or None when OpenCV or the model is missing."""
|
|
313
|
+
if not FACE_MODEL.exists():
|
|
314
|
+
return None
|
|
315
|
+
os.environ.setdefault("OPENCV_LOG_LEVEL", "ERROR")
|
|
316
|
+
try:
|
|
317
|
+
import cv2
|
|
318
|
+
import numpy as np
|
|
319
|
+
except ImportError:
|
|
320
|
+
return None
|
|
321
|
+
xs, ys = [], []
|
|
322
|
+
for i in range(FACE_SAMPLES):
|
|
323
|
+
t = t0 + (i + 0.5) * (t1 - t0) / FACE_SAMPLES
|
|
324
|
+
proc = subprocess.run(["ffmpeg", "-v", "error", "-nostdin", "-ss", f"{t:.3f}", "-i", str(src), "-frames:v", "1",
|
|
325
|
+
"-an", "-vf", "scale=640:-2", "-f", "image2pipe", "-c:v", "png", "-"], capture_output=True)
|
|
326
|
+
if proc.returncode != 0 or not proc.stdout:
|
|
327
|
+
continue
|
|
328
|
+
img = cv2.imdecode(np.frombuffer(proc.stdout, np.uint8), cv2.IMREAD_COLOR)
|
|
329
|
+
if img is None:
|
|
330
|
+
continue
|
|
331
|
+
h, w = img.shape[:2]
|
|
332
|
+
try:
|
|
333
|
+
det = cv2.FaceDetectorYN.create(str(FACE_MODEL), "", (w, h), 0.6, 0.3, 50)
|
|
334
|
+
_, faces = det.detect(img)
|
|
335
|
+
except cv2.error:
|
|
336
|
+
return None
|
|
337
|
+
if faces is None or not len(faces):
|
|
338
|
+
continue
|
|
339
|
+
x, y, fw, fh = max(faces, key=lambda f: f[2] * f[3])[:4]
|
|
340
|
+
xs.append(min(1.0, max(0.0, (x + fw / 2) / w)))
|
|
341
|
+
ys.append(min(1.0, max(0.0, (y + fh / 2) / h)))
|
|
342
|
+
if not xs:
|
|
343
|
+
return 0.5, 0.5, 0
|
|
344
|
+
return float(np.median(xs)), float(np.median(ys)), len(xs)
|
|
345
|
+
|
|
346
|
+
|
|
347
|
+
def parse_silences(stderr: str, t0: float, t1: float) -> list[list[float]]:
|
|
348
|
+
sil: list[list[float]] = []
|
|
349
|
+
cur = None
|
|
350
|
+
for line in stderr.splitlines():
|
|
351
|
+
m = re.search(r"silence_start: (-?[\d.]+)", line)
|
|
352
|
+
if m:
|
|
353
|
+
cur = max(0.0, float(m.group(1)))
|
|
354
|
+
continue
|
|
355
|
+
m = re.search(r"silence_end: (-?[\d.]+)", line)
|
|
356
|
+
if m and cur is not None:
|
|
357
|
+
sil.append([t0 + cur, t0 + float(m.group(1))])
|
|
358
|
+
cur = None
|
|
359
|
+
if cur is not None:
|
|
360
|
+
sil.append([t0 + cur, t1])
|
|
361
|
+
return sil
|
|
362
|
+
|
|
363
|
+
|
|
364
|
+
def _intersect(a: list, b: list) -> list:
|
|
365
|
+
out, i, j = [], 0, 0
|
|
366
|
+
while i < len(a) and j < len(b):
|
|
367
|
+
lo, hi = max(a[i][0], b[j][0]), min(a[i][1], b[j][1])
|
|
368
|
+
if hi > lo:
|
|
369
|
+
out.append([lo, hi])
|
|
370
|
+
if a[i][1] < b[j][1]:
|
|
371
|
+
i += 1
|
|
372
|
+
else:
|
|
373
|
+
j += 1
|
|
374
|
+
return out
|
|
375
|
+
|
|
376
|
+
|
|
377
|
+
def speech_in_window(edit_dir: Path, stem: str, src: Path, info: dict, t0: float, t1: float) -> tuple[list, str]:
|
|
378
|
+
"""Measured speech intervals inside [t0, t1] of a source.
|
|
379
|
+
|
|
380
|
+
edit/regions/<stem>.json is the authority when present and made from this very file (same
|
|
381
|
+
source_sig); its coarse min_silence (0.25 s)
|
|
382
|
+
hides the short pauses editors cut at, so the edge window is refined with a fine
|
|
383
|
+
silencedetect (same noise floor, 0.08 s) and the two are intersected. Without a regions
|
|
384
|
+
file only the fine pass is used (noise -35 dB).
|
|
385
|
+
"""
|
|
386
|
+
t0 = max(0.0, t0)
|
|
387
|
+
if not info.get("has_audio") or t1 - t0 <= 0.01:
|
|
388
|
+
return [], "sem áudio"
|
|
389
|
+
reg = load_json(edit_dir / "regions" / f"{stem}.json")
|
|
390
|
+
reg_spans = None
|
|
391
|
+
noise = SILENCE_NOISE_DB
|
|
392
|
+
if isinstance(reg, dict) and isinstance(reg.get("speech"), list) and "start" not in reg:
|
|
393
|
+
# regions of another file with the same name (video.mov + video.mp4) or of an older copy do not count
|
|
394
|
+
same_file = reg.get("source_sig") in (None, file_signature(src))
|
|
395
|
+
if same_file and abs(float(reg.get("duration") or info["duration"]) - info["duration"]) < 0.2:
|
|
396
|
+
reg_spans = [[max(t0, float(a)), min(t1, float(b))] for a, b in reg["speech"]
|
|
397
|
+
if float(b) > t0 and float(a) < t1]
|
|
398
|
+
try:
|
|
399
|
+
noise = float(reg.get("noise_db", noise))
|
|
400
|
+
except (TypeError, ValueError):
|
|
401
|
+
pass
|
|
402
|
+
proc = subprocess.run(
|
|
403
|
+
["ffmpeg", "-hide_banner", "-nostats", "-nostdin", "-ss", f"{t0:.3f}", "-t", f"{t1 - t0:.3f}",
|
|
404
|
+
"-i", str(src), "-vn", "-af", f"silencedetect=n={noise:g}dB:d=0.08", "-f", "null", "-"],
|
|
405
|
+
capture_output=True, text=True)
|
|
406
|
+
sil = parse_silences(proc.stderr, t0, t1)
|
|
407
|
+
fine, cur = [], t0
|
|
408
|
+
for a, b in sil:
|
|
409
|
+
if a > cur + 1e-3:
|
|
410
|
+
fine.append([cur, a])
|
|
411
|
+
cur = max(cur, b)
|
|
412
|
+
if cur < t1 - 1e-3:
|
|
413
|
+
fine.append([cur, t1])
|
|
414
|
+
if reg_spans is None:
|
|
415
|
+
return fine, "silencedetect"
|
|
416
|
+
return _intersect(sorted(reg_spans), fine), "regions+silencedetect"
|
|
417
|
+
|
|
418
|
+
|
|
419
|
+
def channel_mode(src: Path, info: dict) -> str:
|
|
420
|
+
"""stereo | mono | left | right | multi. Phase-cancelled stereo -> use the stronger channel."""
|
|
421
|
+
ch = info.get("audio_channels") or 0
|
|
422
|
+
if ch == 1:
|
|
423
|
+
return "mono"
|
|
424
|
+
if ch != 2:
|
|
425
|
+
return "multi"
|
|
426
|
+
proc = subprocess.run(
|
|
427
|
+
["ffmpeg", "-hide_banner", "-nostats", "-nostdin", "-t", "120", "-i", str(src), "-vn", "-af",
|
|
428
|
+
"pan=3c|c0=c0|c1=c1|c2=0.5*c0+0.5*c1,astats=metadata=0:measure_perchannel=RMS_level:measure_overall=none",
|
|
429
|
+
"-f", "null", "-"], capture_output=True, text=True)
|
|
430
|
+
levels = [float(x) if x not in ("-inf", "inf", "nan") else -120.0
|
|
431
|
+
for x in re.findall(r"RMS level dB: (-?[\w.]+)", proc.stderr)]
|
|
432
|
+
if len(levels) < 3:
|
|
433
|
+
return "stereo"
|
|
434
|
+
left, right, both = levels[:3]
|
|
435
|
+
best = max(left, right)
|
|
436
|
+
if best > -90 and both < best - 6:
|
|
437
|
+
warn(f"{src.name}: estéreo com fase invertida (L+R {both:.1f} dB contra {best:.1f} dB). Usando um canal só.")
|
|
438
|
+
return "left" if left >= right else "right"
|
|
439
|
+
return "stereo"
|
|
440
|
+
|
|
441
|
+
|
|
442
|
+
_HWACCEL_CACHE: dict[str, bool] = {}
|
|
443
|
+
|
|
444
|
+
|
|
445
|
+
def hwaccel_for(src: Path, info: dict) -> bool:
|
|
446
|
+
if os.environ.get("EDITOR_VIDEO_HWACCEL", "1") == "0" or sys.platform != "darwin":
|
|
447
|
+
return False
|
|
448
|
+
codec = info.get("codec", "")
|
|
449
|
+
if codec not in ("hevc", "h264", "prores"):
|
|
450
|
+
return False
|
|
451
|
+
if codec not in _HWACCEL_CACHE:
|
|
452
|
+
hw = subprocess.run(["ffmpeg", "-hide_banner", "-hwaccels"], capture_output=True, text=True).stdout
|
|
453
|
+
ok = "videotoolbox" in hw
|
|
454
|
+
if ok:
|
|
455
|
+
ok = subprocess.run(["ffmpeg", "-v", "error", "-nostdin", "-hwaccel", "videotoolbox", "-i", str(src),
|
|
456
|
+
"-frames:v", "2", "-an", "-f", "null", "-"], capture_output=True).returncode == 0
|
|
457
|
+
_HWACCEL_CACHE[codec] = ok
|
|
458
|
+
return _HWACCEL_CACHE[codec]
|
|
459
|
+
|
|
460
|
+
|
|
461
|
+
# ------------------------------------------------------------------ planning
|
|
462
|
+
|
|
463
|
+
|
|
464
|
+
@dataclass
|
|
465
|
+
class Plan:
|
|
466
|
+
idx: int
|
|
467
|
+
rid: str
|
|
468
|
+
source: str
|
|
469
|
+
src: Path
|
|
470
|
+
info: dict
|
|
471
|
+
edl_start: float
|
|
472
|
+
edl_end: float
|
|
473
|
+
gain_db: float
|
|
474
|
+
beat: str = ""
|
|
475
|
+
quote: str = ""
|
|
476
|
+
pic_source: str = "" # source key of the picture ("imagem_de", else the audio source)
|
|
477
|
+
pic_src: Path = field(default_factory=Path)
|
|
478
|
+
pic_info: dict = field(default_factory=dict)
|
|
479
|
+
pic_a: float = 0.0 # picture time = pic_a + pic_b x audio time (identity without imagem_de)
|
|
480
|
+
pic_b: float = 1.0
|
|
481
|
+
audio_start_s: float | None = None # set when the audio continues the previous range sample for sample
|
|
482
|
+
joined: bool = False # the audio continues from the previous range (no J-cut, no fades between them)
|
|
483
|
+
fade_in: bool = True
|
|
484
|
+
fade_out: bool = True
|
|
485
|
+
lead: int = 0
|
|
486
|
+
tail: int = 0
|
|
487
|
+
pic_start: float = 0.0 # source time of the first picture frame
|
|
488
|
+
pic_snapped: bool = True # pic_start is a real source frame (False: sparse VFR, exact time)
|
|
489
|
+
frames: int = 0 # picture frames
|
|
490
|
+
crop: dict | None = None # {"axis", "kept", "focus_x", "focus_y", "focus_from"} when the shape differs
|
|
491
|
+
speech_src: str = ""
|
|
492
|
+
gap_in: float = 0.0
|
|
493
|
+
gap_out: float = 0.0
|
|
494
|
+
lead_note: str = ""
|
|
495
|
+
key: str = ""
|
|
496
|
+
video_path: Path = field(default_factory=Path)
|
|
497
|
+
audio_path: Path = field(default_factory=Path)
|
|
498
|
+
meta_path: Path = field(default_factory=Path)
|
|
499
|
+
cached: bool = False
|
|
500
|
+
seconds: float = 0.0
|
|
501
|
+
|
|
502
|
+
@property
|
|
503
|
+
def audio_frames(self) -> int:
|
|
504
|
+
return self.lead + self.frames
|
|
505
|
+
|
|
506
|
+
@property
|
|
507
|
+
def audio_start(self) -> float:
|
|
508
|
+
"""The audio of a range starts where the EDL says (lead frames included), or where the previous
|
|
509
|
+
range's audio ends when the two are one continuous voice."""
|
|
510
|
+
return self.edl_start if self.audio_start_s is None else self.audio_start_s
|
|
511
|
+
|
|
512
|
+
@property
|
|
513
|
+
def other_picture(self) -> bool:
|
|
514
|
+
return self.pic_source != self.source
|
|
515
|
+
|
|
516
|
+
def to_pic(self, t: float) -> float:
|
|
517
|
+
"""Audio-source time -> picture-source time."""
|
|
518
|
+
return self.pic_a + self.pic_b * t
|
|
519
|
+
|
|
520
|
+
|
|
521
|
+
def plan_crops(plans: list[Plan], ranges: list[dict], W: int, H: int, edit_dir: Path, jobs: int) -> None:
|
|
522
|
+
"""Crop position of every range whose source shape differs from the output.
|
|
523
|
+
|
|
524
|
+
focus_x/focus_y from the EDL win; a large crop (shape ratio beyond ASPECT_WARN) is framed on
|
|
525
|
+
the median face center of the range; otherwise the crop stays centered.
|
|
526
|
+
"""
|
|
527
|
+
cache_path = edit_dir / "clips" / "focus_cache.json"
|
|
528
|
+
cache = load_json(cache_path, default={}) or {}
|
|
529
|
+
warned: set[str] = set()
|
|
530
|
+
todo = []
|
|
531
|
+
for p, r in zip(plans, ranges):
|
|
532
|
+
axis, kept, ratio = crop_geometry(p.pic_info, W, H)
|
|
533
|
+
if axis is None:
|
|
534
|
+
continue
|
|
535
|
+
fx, fy = r.get("focus_x"), r.get("focus_y")
|
|
536
|
+
big = ratio > ASPECT_WARN or ratio < 1.0 / ASPECT_WARN
|
|
537
|
+
if big and p.pic_source not in warned:
|
|
538
|
+
warned.add(p.pic_source)
|
|
539
|
+
dw, dh = p.pic_info.get("display_width"), p.pic_info.get("display_height")
|
|
540
|
+
part = "largura" if axis == "x" else "altura"
|
|
541
|
+
warn(f"{p.pic_src.name} é {dw}x{dh} e a saída é {W}x{H}: o corte guarda só {kept:.0%} da {part}. "
|
|
542
|
+
"Enquadro pelo rosto (ou pelo focus_x/focus_y do trecho); confira na folha de quadros.")
|
|
543
|
+
given = fx if axis == "x" else fy
|
|
544
|
+
p.crop = {"axis": axis, "kept": round(kept, 3), "focus_x": 0.5 if fx is None else fx,
|
|
545
|
+
"focus_y": 0.5 if fy is None else fy, "focus_from": "edl" if given is not None else "centro"}
|
|
546
|
+
if given is None and big:
|
|
547
|
+
key = f"{file_signature(p.pic_src)}|{p.to_pic(p.edl_start):.3f}|{p.to_pic(p.edl_end):.3f}"
|
|
548
|
+
todo.append((p, key, fx, fy))
|
|
549
|
+
|
|
550
|
+
def detect(job):
|
|
551
|
+
p, key, fx, fy = job
|
|
552
|
+
hit = cache.get(key)
|
|
553
|
+
if not (isinstance(hit, list) and len(hit) == 3):
|
|
554
|
+
found = face_focus(p.pic_src, p.to_pic(p.edl_start), p.to_pic(p.edl_end))
|
|
555
|
+
hit = list(found) if found else None
|
|
556
|
+
return job, hit
|
|
557
|
+
|
|
558
|
+
with ThreadPoolExecutor(max_workers=max(1, jobs)) as pool:
|
|
559
|
+
results = list(pool.map(detect, todo))
|
|
560
|
+
for (p, key, fx, fy), hit in results:
|
|
561
|
+
if hit is None:
|
|
562
|
+
warn(f"trecho {p.rid}: não deu para procurar rosto (OpenCV indisponível); corte no centro")
|
|
563
|
+
continue
|
|
564
|
+
cache[key] = hit
|
|
565
|
+
if hit[2] == 0:
|
|
566
|
+
warn(f"trecho {p.rid}: não achei rosto em {p.pic_src.name}; corte no centro. "
|
|
567
|
+
"Ajuste focus_x/focus_y no trecho se precisar.")
|
|
568
|
+
continue
|
|
569
|
+
if p.crop["axis"] == "x":
|
|
570
|
+
p.crop["focus_x"] = round(hit[0], 3)
|
|
571
|
+
else:
|
|
572
|
+
p.crop["focus_y"] = round(hit[1], 3)
|
|
573
|
+
p.crop["focus_from"] = "rosto"
|
|
574
|
+
if results:
|
|
575
|
+
save_json(cache_path, cache)
|
|
576
|
+
|
|
577
|
+
|
|
578
|
+
def plan_cut(edl: dict, edit_dir: Path, fps: int, jcut: dict, probes: dict, jobs: int,
|
|
579
|
+
W: int = 1080, H: int = 1920, sync: dict | None = None) -> list[Plan]:
|
|
580
|
+
ranges = edl["ranges"]
|
|
581
|
+
n = len(ranges)
|
|
582
|
+
sync = sync or {}
|
|
583
|
+
plans: list[Plan] = []
|
|
584
|
+
for i, r in enumerate(ranges):
|
|
585
|
+
src = probes[r["source"]]["path"]
|
|
586
|
+
info = probes[r["source"]]["info"]
|
|
587
|
+
dur = info["duration"]
|
|
588
|
+
if r["start"] >= dur - 1.0 / fps:
|
|
589
|
+
raise CutError(f"trecho {r['id']}: start {r['start']:.2f}s passa do fim de {src.name} ({dur:.2f}s)", 2)
|
|
590
|
+
end = r["end"]
|
|
591
|
+
if end > dur:
|
|
592
|
+
warn(f"trecho {r['id']}: end {end:.2f}s passa do fim de {src.name}; usando {dur:.2f}s")
|
|
593
|
+
end = dur
|
|
594
|
+
p = Plan(i, r["id"], r["source"], src, info, r["start"], end, r["gain_db"],
|
|
595
|
+
str(r.get("beat") or ""), str(r.get("quote") or ""))
|
|
596
|
+
p.pic_source = picture_key(r)
|
|
597
|
+
p.pic_src, p.pic_info = probes[p.pic_source]["path"], probes[p.pic_source]["info"]
|
|
598
|
+
if p.other_picture:
|
|
599
|
+
(oa, sa), (ob, sb) = sync[p.source], sync[p.pic_source]
|
|
600
|
+
p.pic_b = sb / sa
|
|
601
|
+
p.pic_a = ob - p.pic_b * oa
|
|
602
|
+
t0, t1 = p.to_pic(p.edl_start), p.to_pic(end)
|
|
603
|
+
pdur = p.pic_info["duration"]
|
|
604
|
+
if t0 < -0.5 / fps or t1 > pdur + 0.5 / fps:
|
|
605
|
+
raise CutError(f"trecho {p.rid}: a {p.pic_source} não tem imagem de {p.edl_start:.2f} a {end:.2f}s da "
|
|
606
|
+
f"{p.source} (pela sincronia isso é {t0:.2f} a {t1:.2f}s dela, que tem {pdur:.2f}s). "
|
|
607
|
+
"Use outra câmera nesse trecho ou encurte", 2)
|
|
608
|
+
plans.append(p)
|
|
609
|
+
for a, b in zip(plans, plans[1:]):
|
|
610
|
+
if a.source == b.source and b.edl_start < a.edl_end - REPEAT_TOL_S and b.edl_end > a.edl_start:
|
|
611
|
+
warn(f"trecho {b.rid} repete {a.edl_end - b.edl_start:.2f}s do trecho {a.rid} (mesma fonte): "
|
|
612
|
+
f"o start {b.edl_start:.2f}s fica antes do end {a.edl_end:.2f}s do anterior")
|
|
613
|
+
# One continuous voice: the next range picks up the same audio source where this one stops.
|
|
614
|
+
for a, b in zip(plans, plans[1:]):
|
|
615
|
+
if a.source != b.source or abs(b.edl_start - a.edl_end) > 0.5 / fps:
|
|
616
|
+
continue
|
|
617
|
+
if abs(a.gain_db - b.gain_db) > 0.05:
|
|
618
|
+
warn(f"trechos {a.rid} e {b.rid} continuam a mesma fala mas têm gain_db diferente "
|
|
619
|
+
f"({a.gain_db:+.1f} e {b.gain_db:+.1f}): a voz leva um corte de 30 ms ali. Use o mesmo gain_db "
|
|
620
|
+
"nos dois para ela seguir inteira")
|
|
621
|
+
continue
|
|
622
|
+
b.joined = True
|
|
623
|
+
a.fade_out = b.fade_in = False
|
|
624
|
+
plan_crops(plans, ranges, W, H, edit_dir, jobs)
|
|
625
|
+
|
|
626
|
+
lead_req = jcut["lead_frames"] if jcut["enabled"] else 0
|
|
627
|
+
tail_req = jcut["tail_trim_frames"] if jcut["enabled"] else 0
|
|
628
|
+
|
|
629
|
+
# Measured silence at both edges of every range (only needed around junctions).
|
|
630
|
+
def edge_gaps(p: Plan) -> tuple[float, float, str]:
|
|
631
|
+
if not jcut["enabled"] or n == 1:
|
|
632
|
+
return 0.0, 0.0, ""
|
|
633
|
+
stem = Path(p.src).stem
|
|
634
|
+
gap_in = gap_out = 0.0
|
|
635
|
+
how = ""
|
|
636
|
+
if p.idx > 0:
|
|
637
|
+
w1 = min(p.edl_end, p.edl_start + 1.0)
|
|
638
|
+
spans, how = speech_in_window(edit_dir, stem, p.src, p.info, p.edl_start, w1)
|
|
639
|
+
first = min((max(a, p.edl_start) for a, b in spans if b > p.edl_start), default=None)
|
|
640
|
+
gap_in = (w1 - p.edl_start) if first is None else max(0.0, first - p.edl_start)
|
|
641
|
+
if p.idx < n - 1:
|
|
642
|
+
w0 = max(p.edl_start, p.edl_end - 1.0)
|
|
643
|
+
spans, how = speech_in_window(edit_dir, stem, p.src, p.info, w0, p.edl_end)
|
|
644
|
+
last = max((min(b, p.edl_end) for a, b in spans if a < p.edl_end), default=None)
|
|
645
|
+
gap_out = (p.edl_end - w0) if last is None else max(0.0, p.edl_end - last)
|
|
646
|
+
return gap_in, gap_out, how
|
|
647
|
+
|
|
648
|
+
with ThreadPoolExecutor(max_workers=max(2, jobs * 2)) as pool:
|
|
649
|
+
gaps = list(pool.map(edge_gaps, plans))
|
|
650
|
+
|
|
651
|
+
prev: Plan | None = None
|
|
652
|
+
for p, (gap_in, gap_out, how) in zip(plans, gaps):
|
|
653
|
+
p.gap_in, p.gap_out, p.speech_src = gap_in, gap_out, how
|
|
654
|
+
dur = p.pic_info["duration"]
|
|
655
|
+
# "dry": false (a video on screen, a held ending): the edges are the ones written, no J-cut on it.
|
|
656
|
+
whole = not ranges[p.idx].get("dry", True)
|
|
657
|
+
nxt_joined = p.idx < n - 1 and plans[p.idx + 1].joined
|
|
658
|
+
# Outgoing tail: trim only silence, keeping SPEECH_GUARD_S after the last word.
|
|
659
|
+
p.tail = 0
|
|
660
|
+
if tail_req and p.idx < n - 1 and not whole and not nxt_joined:
|
|
661
|
+
p.tail = max(0, min(tail_req, math.floor((gap_out - SPEECH_GUARD_S) * fps + 1e-6)))
|
|
662
|
+
# Incoming lead: the early audio overlaps the outgoing tail, but the two voices must not
|
|
663
|
+
# meet: keep MIN_JOIN_GAP_S of measured silence between them. It also may not reach back
|
|
664
|
+
# before the outgoing picture starts, nor before the source start.
|
|
665
|
+
p.lead = 0
|
|
666
|
+
if lead_req and prev is not None and not whole and not p.joined:
|
|
667
|
+
room = prev.gap_out - prev.tail / fps + gap_in - MIN_JOIN_GAP_S
|
|
668
|
+
p.lead = max(0, min(lead_req, math.floor(room * fps + 1e-6), prev.frames))
|
|
669
|
+
if p.lead < lead_req:
|
|
670
|
+
p.lead_note = "pouco silêncio na junção" if room * fps < lead_req else "trecho anterior curto"
|
|
671
|
+
if p.joined and prev is not None:
|
|
672
|
+
# the audio goes on from the exact sample where the previous range's audio stops
|
|
673
|
+
p.audio_start_s = prev.audio_start + (prev.lead + prev.frames) / fps
|
|
674
|
+
p.pic_start, p.pic_snapped = picture_start(p.pic_src, p.to_pic(p.audio_start + p.lead / fps), fps)
|
|
675
|
+
frames = 0
|
|
676
|
+
for _ in range(4):
|
|
677
|
+
total = min(round((p.to_pic(p.edl_end) - p.pic_start) * fps),
|
|
678
|
+
math.floor((dur - p.pic_start) * fps + 1e-6))
|
|
679
|
+
frames = total - p.tail
|
|
680
|
+
if frames >= MIN_PIC_FRAMES or (p.tail == 0 and p.lead == 0):
|
|
681
|
+
break
|
|
682
|
+
if p.tail:
|
|
683
|
+
p.tail = max(0, p.tail - (MIN_PIC_FRAMES - frames))
|
|
684
|
+
elif p.lead:
|
|
685
|
+
p.lead = 0
|
|
686
|
+
p.pic_start, p.pic_snapped = picture_start(p.pic_src, p.to_pic(p.audio_start), fps)
|
|
687
|
+
if frames < 1:
|
|
688
|
+
raise CutError(f"trecho {p.rid} é curto demais ({p.edl_end - p.edl_start:.3f}s)", 2)
|
|
689
|
+
p.frames = frames
|
|
690
|
+
prev = p
|
|
691
|
+
return plans
|
|
692
|
+
|
|
693
|
+
|
|
694
|
+
# ------------------------------------------------------------------ extraction
|
|
695
|
+
|
|
696
|
+
|
|
697
|
+
def crop_filter(W: int, H: int, crop: dict | None) -> str:
|
|
698
|
+
"""crop to WxH, centered, or around the focus point (0..1 of the scaled frame), clamped inside."""
|
|
699
|
+
if not crop or (abs(crop["focus_x"] - 0.5) < 1e-6 and abs(crop["focus_y"] - 0.5) < 1e-6):
|
|
700
|
+
return f"crop={W}:{H}"
|
|
701
|
+
fx, fy = crop["focus_x"], crop["focus_y"]
|
|
702
|
+
return (f"crop={W}:{H}:x=max(0\\,min(iw-ow\\,iw*{fx:.4f}-ow/2))"
|
|
703
|
+
f":y=max(0\\,min(ih-oh\\,ih*{fy:.4f}-oh/2))")
|
|
704
|
+
|
|
705
|
+
|
|
706
|
+
def build_vf(info: dict, W: int, H: int, fps: int, grade_filter: str, crop: dict | None = None) -> str:
|
|
707
|
+
# SDR: convert any matrix (e.g. BT.601 phones) and full range to BT.709 tv while scaling.
|
|
708
|
+
# HDR keeps its matrix here; the shared HDR->SDR chain (grade.hdr_to_sdr_chain) converts it.
|
|
709
|
+
# start_time=0: with -noaccurate_seek the frames before the seek point fill the first slot,
|
|
710
|
+
# so a sparse VFR source shows the frame that is on screen at the start time.
|
|
711
|
+
matrix = "" if info.get("is_hdr") else ":out_color_matrix=bt709"
|
|
712
|
+
parts = [f"fps={fps}:start_time=0",
|
|
713
|
+
f"scale={W}:{H}:force_original_aspect_ratio=increase:flags=lanczos:out_range=tv{matrix}",
|
|
714
|
+
crop_filter(W, H, crop), "setsar=1"]
|
|
715
|
+
hdr = hdr_to_sdr_chain(info)
|
|
716
|
+
if hdr:
|
|
717
|
+
parts.append(hdr)
|
|
718
|
+
parts.append(graded_chain(grade_filter))
|
|
719
|
+
# ffmpeg takes the stream color tags from the frames: stamp them so untagged sources are tagged too.
|
|
720
|
+
parts.append(BT709_TAGS)
|
|
721
|
+
return ",".join(parts)
|
|
722
|
+
|
|
723
|
+
|
|
724
|
+
AUDIO_RESAMPLE = f"aresample={SR}:async=1:first_pts=0"
|
|
725
|
+
|
|
726
|
+
|
|
727
|
+
def channel_filter(mode: str) -> str:
|
|
728
|
+
"""How a source's channels become stereo (mode from channel_mode)."""
|
|
729
|
+
return {"mono": "pan=stereo|c0=c0|c1=c0", "left": "pan=stereo|c0=c0|c1=c0",
|
|
730
|
+
"right": "pan=stereo|c0=c1|c1=c1"}.get(mode, "aformat=channel_layouts=stereo")
|
|
731
|
+
|
|
732
|
+
|
|
733
|
+
def af_tail(gain_db: float, samples: int, fade_in: bool = True, fade_out: bool = True) -> list[str]:
|
|
734
|
+
"""Gain + limiter, exact length and 30 ms fades of a range (after resampling and channel choice).
|
|
735
|
+
No fade on a side where the voice goes on into the next range (or comes from the previous one).
|
|
736
|
+
compose_final's background-noise removal runs its denoiser before this same tail."""
|
|
737
|
+
parts = []
|
|
738
|
+
if gain_db:
|
|
739
|
+
parts.append(f"volume={gain_db:.2f}dB")
|
|
740
|
+
if gain_db > 0:
|
|
741
|
+
parts.append("alimiter=limit=0.891:attack=5:release=60:level=false:latency=true")
|
|
742
|
+
fade = max(1, min(FADE_SAMPLES, samples // 4))
|
|
743
|
+
parts += [f"apad=whole_len={samples}", f"atrim=end_sample={samples}"]
|
|
744
|
+
if fade_in:
|
|
745
|
+
parts.append(f"afade=t=in:start_sample=0:nb_samples={fade}")
|
|
746
|
+
if fade_out:
|
|
747
|
+
parts.append(f"afade=t=out:start_sample={samples - fade}:nb_samples={fade}")
|
|
748
|
+
parts.append("aformat=sample_fmts=flt:channel_layouts=stereo")
|
|
749
|
+
return parts
|
|
750
|
+
|
|
751
|
+
|
|
752
|
+
def build_af(p: Plan, mode: str, samples: int) -> str:
|
|
753
|
+
return ",".join([AUDIO_RESAMPLE, channel_filter(mode), *af_tail(p.gain_db, samples, p.fade_in, p.fade_out)])
|
|
754
|
+
|
|
755
|
+
|
|
756
|
+
def count_frames(path: Path) -> int:
|
|
757
|
+
proc = subprocess.run(["ffprobe", "-v", "error", "-select_streams", "v:0", "-show_entries", "stream=nb_frames",
|
|
758
|
+
"-of", "csv=p=0", str(path)], capture_output=True, text=True)
|
|
759
|
+
try:
|
|
760
|
+
return int(proc.stdout.strip().split(",")[0])
|
|
761
|
+
except ValueError:
|
|
762
|
+
proc = subprocess.run(["ffprobe", "-v", "error", "-select_streams", "v:0", "-count_packets",
|
|
763
|
+
"-show_entries", "stream=nb_read_packets", "-of", "csv=p=0", str(path)],
|
|
764
|
+
capture_output=True, text=True)
|
|
765
|
+
return int(proc.stdout.strip().split(",")[0] or 0)
|
|
766
|
+
|
|
767
|
+
|
|
768
|
+
def wav_samples(path: Path) -> int:
|
|
769
|
+
"""Sample frames in a WAV by walking its chunks (the stdlib `wave` rejects float WAVs)."""
|
|
770
|
+
try:
|
|
771
|
+
with open(path, "rb") as fh:
|
|
772
|
+
if fh.read(4) != b"RIFF":
|
|
773
|
+
return -1
|
|
774
|
+
fh.read(4)
|
|
775
|
+
if fh.read(4) != b"WAVE":
|
|
776
|
+
return -1
|
|
777
|
+
block_align = 0
|
|
778
|
+
while True:
|
|
779
|
+
head = fh.read(8)
|
|
780
|
+
if len(head) < 8:
|
|
781
|
+
return -1
|
|
782
|
+
cid, size = head[:4], int.from_bytes(head[4:], "little")
|
|
783
|
+
if cid == b"fmt ":
|
|
784
|
+
fmt = fh.read(size)
|
|
785
|
+
block_align = int.from_bytes(fmt[12:14], "little")
|
|
786
|
+
continue
|
|
787
|
+
if cid == b"data":
|
|
788
|
+
if size in (0, 0xFFFFFFFF): # streamed header: use the file size
|
|
789
|
+
size = os.path.getsize(path) - fh.tell()
|
|
790
|
+
return size // block_align if block_align else -1
|
|
791
|
+
fh.seek(size + (size & 1), 1)
|
|
792
|
+
except OSError:
|
|
793
|
+
return -1
|
|
794
|
+
|
|
795
|
+
|
|
796
|
+
def extract(p: Plan, cfg: dict) -> Plan:
|
|
797
|
+
t0 = time.time()
|
|
798
|
+
fps, W, H = cfg["fps"], cfg["width"], cfg["height"]
|
|
799
|
+
spf = SR / fps
|
|
800
|
+
src_fps = p.pic_info.get("fps") or fps
|
|
801
|
+
vf = build_vf(p.pic_info, W, H, fps, cfg["grade_filter"], p.crop)
|
|
802
|
+
samples = int(round(p.audio_frames * spf))
|
|
803
|
+
audio_start = p.audio_start
|
|
804
|
+
mode = cfg["channel_modes"].get(p.source, "stereo") if p.info.get("has_audio") else "silent"
|
|
805
|
+
af = build_af(p, mode, samples)
|
|
806
|
+
enc = ["-c:v", "libx264", "-profile:v", "high", "-preset", cfg["x264_preset"], "-crf", str(cfg["crf"]),
|
|
807
|
+
"-pix_fmt", "yuv420p", "-color_primaries", "bt709", "-color_trc", "bt709", "-colorspace", "bt709",
|
|
808
|
+
"-color_range", "tv", "-video_track_timescale", str(fps * 1000), "-movflags", "+faststart"]
|
|
809
|
+
key_src = {"v": CACHE_VERSION, "src": str(p.src), "sig": file_signature(p.src), "pic": round(p.pic_start, 6),
|
|
810
|
+
"snap": p.pic_snapped, "frames": p.frames, "astart": round(audio_start, 6), "samples": samples, "vf": vf, "af": af,
|
|
811
|
+
"enc": enc, "mode": mode}
|
|
812
|
+
if p.other_picture:
|
|
813
|
+
key_src.update(pic_src=str(p.pic_src), pic_sig=file_signature(p.pic_src))
|
|
814
|
+
p.key = hashlib.sha1(json.dumps(key_src, sort_keys=True).encode()).hexdigest()[:12]
|
|
815
|
+
stem = f"{cfg['mode']}_{p.idx:03d}_{re.sub(r'[^A-Za-z0-9_-]', '_', p.rid)}_{p.key}"
|
|
816
|
+
clips = cfg["clips_dir"]
|
|
817
|
+
p.video_path, p.audio_path, p.meta_path = clips / f"{stem}.mp4", clips / f"{stem}.wav", clips / f"{stem}.json"
|
|
818
|
+
meta = load_json(p.meta_path)
|
|
819
|
+
if (isinstance(meta, dict) and meta.get("key") == p.key and p.video_path.exists() and p.audio_path.exists()):
|
|
820
|
+
p.frames = int(meta["frames"])
|
|
821
|
+
p.cached = True
|
|
822
|
+
return p
|
|
823
|
+
|
|
824
|
+
# A snapped start seeks a quarter frame early so that frame lands on the first output slot.
|
|
825
|
+
pic_seek = max(0.0, p.pic_start - (0.25 / max(src_fps, fps) if p.pic_snapped else 0.0))
|
|
826
|
+
tmp_v, tmp_a = p.video_path.with_suffix(".part.mp4"), p.audio_path.with_suffix(".part.wav")
|
|
827
|
+
|
|
828
|
+
def run_extract(hw: bool) -> subprocess.CompletedProcess:
|
|
829
|
+
cmd = ["ffmpeg", "-y", "-v", "error", "-nostdin"]
|
|
830
|
+
if hw:
|
|
831
|
+
cmd += ["-hwaccel", "videotoolbox"]
|
|
832
|
+
cmd += ["-noaccurate_seek", "-ss", f"{pic_seek:.6f}", "-i", str(p.pic_src)]
|
|
833
|
+
if p.info.get("has_audio"):
|
|
834
|
+
cmd += ["-ss", f"{max(0.0, audio_start):.6f}", "-i", str(p.src)]
|
|
835
|
+
amap = "1:a:0"
|
|
836
|
+
else:
|
|
837
|
+
cmd += ["-f", "lavfi", "-i", f"anullsrc=r={SR}:cl=stereo"]
|
|
838
|
+
amap = "1:a:0"
|
|
839
|
+
cmd += ["-map", "0:v:0", "-vf", vf, "-frames:v", str(p.frames), "-an", *enc, "-f", "mp4", str(tmp_v)]
|
|
840
|
+
cmd += ["-map", amap, "-af", af, "-vn", "-c:a", "pcm_f32le", "-ar", str(SR), "-ac", "2",
|
|
841
|
+
"-f", "wav", str(tmp_a)]
|
|
842
|
+
return subprocess.run(cmd, capture_output=True, text=True)
|
|
843
|
+
|
|
844
|
+
use_hw = hwaccel_for(p.pic_src, p.pic_info)
|
|
845
|
+
proc = run_extract(use_hw)
|
|
846
|
+
if proc.returncode != 0 and use_hw:
|
|
847
|
+
warn(f"trecho {p.rid}: decodificação por hardware falhou, tentando por software")
|
|
848
|
+
proc = run_extract(False)
|
|
849
|
+
if proc.returncode != 0:
|
|
850
|
+
tmp_v.unlink(missing_ok=True)
|
|
851
|
+
tmp_a.unlink(missing_ok=True)
|
|
852
|
+
raise CutError(f"o ffmpeg falhou ao extrair o trecho {p.rid}:\n{proc.stderr.strip()[-1500:]}")
|
|
853
|
+
|
|
854
|
+
got = count_frames(tmp_v)
|
|
855
|
+
if got <= 0:
|
|
856
|
+
raise CutError(f"trecho {p.rid}: nenhum quadro extraído de {p.pic_src.name}")
|
|
857
|
+
if got != p.frames:
|
|
858
|
+
warn(f"trecho {p.rid}: esperava {p.frames} quadros e saíram {got}; ajustando o áudio")
|
|
859
|
+
p.frames = got
|
|
860
|
+
samples = int(round(p.audio_frames * spf))
|
|
861
|
+
af = build_af(p, mode, samples)
|
|
862
|
+
cmd = ["ffmpeg", "-y", "-v", "error", "-nostdin"]
|
|
863
|
+
if p.info.get("has_audio"):
|
|
864
|
+
cmd += ["-ss", f"{max(0.0, audio_start):.6f}", "-i", str(p.src), "-map", "0:a:0"]
|
|
865
|
+
else:
|
|
866
|
+
cmd += ["-f", "lavfi", "-i", f"anullsrc=r={SR}:cl=stereo", "-map", "0:a:0"]
|
|
867
|
+
cmd += ["-af", af, "-vn", "-c:a", "pcm_f32le", "-ar", str(SR), "-ac", "2", "-f", "wav", str(tmp_a)]
|
|
868
|
+
ffmpeg(cmd, f"refazer o áudio do trecho {p.rid}")
|
|
869
|
+
got_samples = wav_samples(tmp_a)
|
|
870
|
+
if got_samples >= 0 and got_samples != samples:
|
|
871
|
+
raise CutError(f"trecho {p.rid}: o áudio saiu com {got_samples} amostras, esperava {samples}")
|
|
872
|
+
os.replace(tmp_v, p.video_path)
|
|
873
|
+
os.replace(tmp_a, p.audio_path)
|
|
874
|
+
save_json(p.meta_path, {"key": p.key, "frames": p.frames, "samples": samples, "id": p.rid,
|
|
875
|
+
"source": str(p.src), "picture": str(p.pic_src), "pic_start": p.pic_start,
|
|
876
|
+
"pic_snapped": p.pic_snapped, "audio_start": audio_start})
|
|
877
|
+
p.seconds = time.time() - t0
|
|
878
|
+
return p
|
|
879
|
+
|
|
880
|
+
|
|
881
|
+
# ------------------------------------------------------------------ assembly
|
|
882
|
+
|
|
883
|
+
|
|
884
|
+
def concat_escape(path: Path) -> str:
|
|
885
|
+
return str(path).replace("'", "'\\''")
|
|
886
|
+
|
|
887
|
+
|
|
888
|
+
def mix_audio(plans: list[Plan], fps: int, total_samples: int, out_wav: Path) -> None:
|
|
889
|
+
spf = SR / fps
|
|
890
|
+
cmd = ["ffmpeg", "-y", "-v", "error", "-nostdin"]
|
|
891
|
+
for p in plans:
|
|
892
|
+
cmd += ["-i", str(p.audio_path)]
|
|
893
|
+
chains, labels = [], []
|
|
894
|
+
pos = 0
|
|
895
|
+
for i, p in enumerate(plans):
|
|
896
|
+
delay = int(round((pos - p.lead) * spf))
|
|
897
|
+
if delay < 0:
|
|
898
|
+
raise CutError(f"trecho {p.rid}: atraso de áudio negativo ({delay}); J-cut inconsistente")
|
|
899
|
+
chains.append(f"[{i}:a]adelay=delays={delay}S:all=1[a{i}]")
|
|
900
|
+
labels.append(f"[a{i}]")
|
|
901
|
+
pos += p.frames
|
|
902
|
+
if len(plans) == 1:
|
|
903
|
+
graph = f"{chains[0]};[a0]apad=whole_len={total_samples},atrim=end_sample={total_samples}[out]"
|
|
904
|
+
else:
|
|
905
|
+
graph = (";".join(chains) + ";" + "".join(labels)
|
|
906
|
+
+ f"amix=inputs={len(plans)}:normalize=0:duration=longest:dropout_transition=0,"
|
|
907
|
+
+ f"apad=whole_len={total_samples},atrim=end_sample={total_samples}[out]")
|
|
908
|
+
cmd += ["-filter_complex", graph, "-map", "[out]", "-c:a", "pcm_f32le", "-ar", str(SR), "-f", "wav", str(out_wav)]
|
|
909
|
+
ffmpeg(cmd, "mixar o áudio")
|
|
910
|
+
|
|
911
|
+
|
|
912
|
+
def loudnorm_measure(wav_path: Path) -> dict | None:
|
|
913
|
+
proc = subprocess.run(
|
|
914
|
+
["ffmpeg", "-hide_banner", "-nostats", "-nostdin", "-i", str(wav_path), "-af",
|
|
915
|
+
f"loudnorm=I={LOUDNORM['I']}:TP={LOUDNORM['TP']}:LRA={LOUDNORM['LRA']}:print_format=json",
|
|
916
|
+
"-f", "null", "-"], capture_output=True, text=True)
|
|
917
|
+
text = proc.stderr
|
|
918
|
+
start, end = text.rfind("{"), text.rfind("}")
|
|
919
|
+
if start < 0 or end <= start:
|
|
920
|
+
return None
|
|
921
|
+
try:
|
|
922
|
+
data = json.loads(text[start:end + 1])
|
|
923
|
+
except json.JSONDecodeError:
|
|
924
|
+
return None
|
|
925
|
+
try:
|
|
926
|
+
if not math.isfinite(float(data["input_i"])) or float(data["input_i"]) < -70:
|
|
927
|
+
return None
|
|
928
|
+
except (KeyError, ValueError):
|
|
929
|
+
return None
|
|
930
|
+
return data
|
|
931
|
+
|
|
932
|
+
|
|
933
|
+
def loudnorm_modo(measured: dict | None) -> str | None:
|
|
934
|
+
"""O modo que o loudnorm de 2 passadas vai usar com estas medidas (a regra do ffmpeg, af_loudnorm.c): linear só
|
|
935
|
+
quando o pico real medido mais o ganho que falta até o alvo não passa do TP. Senão ele cai calado no dinâmico,
|
|
936
|
+
que comprime a voz. (PrimoCode: o portão reprova o dinâmico; ver helpers/ganho_por_trecho.py.)"""
|
|
937
|
+
if not measured:
|
|
938
|
+
return None
|
|
939
|
+
try:
|
|
940
|
+
offset_tp = float(measured["input_tp"]) + (LOUDNORM["I"] - float(measured["input_i"]))
|
|
941
|
+
except (KeyError, ValueError):
|
|
942
|
+
return None
|
|
943
|
+
return "linear" if offset_tp <= LOUDNORM["TP"] else "dinamico"
|
|
944
|
+
|
|
945
|
+
|
|
946
|
+
def true_peak_db(path: Path) -> float | None:
|
|
947
|
+
"""True peak (dBTP) of the first audio track with 2 decimals (loudnorm's measurement; ebur128,
|
|
948
|
+
used by verify_cut, prints 1 decimal and hides a -0.96 as -1.0). -120 for silence."""
|
|
949
|
+
proc = subprocess.run(["ffmpeg", "-hide_banner", "-nostats", "-nostdin", "-i", str(path), "-vn", "-af",
|
|
950
|
+
f"loudnorm=I={LOUDNORM['I']}:TP={LOUDNORM['TP']}:LRA={LOUDNORM['LRA']}:print_format=json",
|
|
951
|
+
"-f", "null", "-"], capture_output=True, text=True)
|
|
952
|
+
m = re.search(r'"input_tp"\s*:\s*"(-?inf|-?[\d.]+)"', proc.stderr or "")
|
|
953
|
+
if not m:
|
|
954
|
+
return None
|
|
955
|
+
return -120.0 if "inf" in m.group(1) else float(m.group(1))
|
|
956
|
+
|
|
957
|
+
|
|
958
|
+
# ------------------------------------------------------------------ main
|
|
959
|
+
|
|
960
|
+
|
|
961
|
+
def acquire_lock(clips_dir: Path):
|
|
962
|
+
"""One render per edit dir at a time (clips cache and pruning are shared)."""
|
|
963
|
+
fh = open(clips_dir / ".render.lock", "a+")
|
|
964
|
+
try:
|
|
965
|
+
import fcntl
|
|
966
|
+
fcntl.flock(fh.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB)
|
|
967
|
+
except ImportError: # Windows: no advisory lock, run anyway
|
|
968
|
+
pass
|
|
969
|
+
except OSError:
|
|
970
|
+
fh.close()
|
|
971
|
+
raise CutError("já tem um render_cut rodando nesta pasta edit. Espere ele terminar.", 2)
|
|
972
|
+
return fh
|
|
973
|
+
|
|
974
|
+
|
|
975
|
+
def render(args: argparse.Namespace) -> int:
|
|
976
|
+
t_all = time.time()
|
|
977
|
+
require_binary("ffmpeg")
|
|
978
|
+
require_binary("ffprobe")
|
|
979
|
+
edl_path = Path(os.path.abspath(args.edl.expanduser()))
|
|
980
|
+
edl = load_edl(edl_path)
|
|
981
|
+
edl_sig = edl_signature(json.loads(edl_path.read_text(encoding="utf-8"))) # raw file, before normalizing
|
|
982
|
+
edit_dir = edl_path.parent
|
|
983
|
+
jcut = jcut_settings(edl, args.no_jcut)
|
|
984
|
+
|
|
985
|
+
probes: dict[str, dict] = {}
|
|
986
|
+
audio_keys = {r["source"] for r in edl["ranges"]}
|
|
987
|
+
picture_keys = {picture_key(r) for r in edl["ranges"]}
|
|
988
|
+
for key, value in edl["sources"].items():
|
|
989
|
+
if key not in audio_keys and key not in picture_keys:
|
|
990
|
+
continue
|
|
991
|
+
src = resolve_source(str(value), edit_dir)
|
|
992
|
+
if not src.exists():
|
|
993
|
+
raise CutError(f"vídeo da fonte {key!r} não encontrado: {src}", 2)
|
|
994
|
+
try:
|
|
995
|
+
info = probe_video(src)
|
|
996
|
+
except Exception as exc: # noqa: BLE001
|
|
997
|
+
raise CutError(f"não consegui ler {src.name}: {exc}", 2)
|
|
998
|
+
if key in picture_keys and not info.get("has_video"):
|
|
999
|
+
if key in audio_keys:
|
|
1000
|
+
raise CutError(f"{src.name} não tem trilha de vídeo: nos trechos dela, ponha \"imagem_de\" com a "
|
|
1001
|
+
"câmera de onde vem a imagem", 2)
|
|
1002
|
+
raise CutError(f"{src.name} não tem trilha de vídeo", 2)
|
|
1003
|
+
probes[key] = {"path": src, "info": info}
|
|
1004
|
+
sync, sync_path = load_sync(edl, edit_dir, probes)
|
|
1005
|
+
|
|
1006
|
+
out_cfg = edl.get("output") or {}
|
|
1007
|
+
fmt = out_cfg.get("format") or "vertical"
|
|
1008
|
+
if fmt not in OUTPUT_FORMATS and not (out_cfg.get("width") and out_cfg.get("height")):
|
|
1009
|
+
raise CutError(f"output.format {fmt!r} desconhecido (use {', '.join(OUTPUT_FORMATS)})", 2)
|
|
1010
|
+
W = int(out_cfg.get("width") or OUTPUT_FORMATS[fmt][0])
|
|
1011
|
+
H = int(out_cfg.get("height") or OUTPUT_FORMATS[fmt][1])
|
|
1012
|
+
fps = int(round(float(out_cfg.get("fps") or choose_output_fps({k: v for k, v in probes.items()
|
|
1013
|
+
if k in picture_keys}))))
|
|
1014
|
+
if fps <= 0 or SR % fps:
|
|
1015
|
+
raise CutError(f"output.fps {fps} não suportado (use 24, 25, 30, 48, 50 ou 60)", 2)
|
|
1016
|
+
if args.preview:
|
|
1017
|
+
W, H = (W // 2) - (W // 2) % 2, (H // 2) - (H // 2) % 2
|
|
1018
|
+
W, H = W - W % 2, H - H % 2
|
|
1019
|
+
|
|
1020
|
+
try:
|
|
1021
|
+
grade_filter = resolve_grade(edl.get("grade"))
|
|
1022
|
+
except ValueError as exc:
|
|
1023
|
+
raise CutError(str(exc), 2)
|
|
1024
|
+
err = check_filter(grade_filter)
|
|
1025
|
+
if err:
|
|
1026
|
+
raise CutError(f"o grade do EDL não funciona no ffmpeg: {err}", 2)
|
|
1027
|
+
|
|
1028
|
+
jobs = args.jobs if args.jobs and args.jobs > 0 else min(4, max(1, (os.cpu_count() or 4) // 3))
|
|
1029
|
+
out_path = (Path(os.path.abspath(args.output.expanduser())) if args.output
|
|
1030
|
+
else edit_dir / ("cut_preview.mp4" if args.preview else "cut.mp4"))
|
|
1031
|
+
timeline_path = out_path.with_name(f"{out_path.stem}_timeline.json")
|
|
1032
|
+
clips_dir = edit_dir / "clips"
|
|
1033
|
+
clips_dir.mkdir(parents=True, exist_ok=True)
|
|
1034
|
+
out_path.parent.mkdir(parents=True, exist_ok=True)
|
|
1035
|
+
lock = acquire_lock(clips_dir)
|
|
1036
|
+
|
|
1037
|
+
mode = "prev" if args.preview else "full"
|
|
1038
|
+
log(f"Corte: {len(edl['ranges'])} trechos de {len(probes)} fonte(s) -> {W}x{H} {fps} fps"
|
|
1039
|
+
f"{' (prévia)' if args.preview else ''}")
|
|
1040
|
+
if jcut["enabled"]:
|
|
1041
|
+
log(f"J-cut: áudio entra {jcut['lead_frames']} quadros antes, cauda aparada até "
|
|
1042
|
+
f"{jcut['tail_trim_frames']} quadros fora da fala")
|
|
1043
|
+
else:
|
|
1044
|
+
log("Junções secas (sem J-cut)")
|
|
1045
|
+
if sync:
|
|
1046
|
+
log(f"Imagem de outra câmera em {sum(1 for r in edl['ranges'] if r.get('imagem_de'))} trecho(s), "
|
|
1047
|
+
f"sincronia de {sync_path.name}")
|
|
1048
|
+
if any(pr["info"].get("is_hdr") for pr in probes.values()) and not has_ffmpeg_filter("zscale"):
|
|
1049
|
+
warn("fonte HDR e o ffmpeg não tem zscale: conversão para SDR aproximada, confira as cores")
|
|
1050
|
+
|
|
1051
|
+
t_plan = time.time()
|
|
1052
|
+
plans = plan_cut(edl, edit_dir, fps, jcut, probes, jobs, W, H, sync)
|
|
1053
|
+
channel_modes = {}
|
|
1054
|
+
for key, pr in probes.items():
|
|
1055
|
+
if key in audio_keys:
|
|
1056
|
+
channel_modes[key] = channel_mode(pr["path"], pr["info"]) if pr["info"].get("has_audio") else "silent"
|
|
1057
|
+
t_plan = time.time() - t_plan
|
|
1058
|
+
|
|
1059
|
+
cfg = {"fps": fps, "width": W, "height": H, "grade_filter": grade_filter, "channel_modes": channel_modes,
|
|
1060
|
+
"x264_preset": "veryfast" if args.preview else "medium", "crf": 26 if args.preview else 18,
|
|
1061
|
+
"clips_dir": clips_dir, "mode": mode}
|
|
1062
|
+
t_ext = time.time()
|
|
1063
|
+
log(f"Extraindo {len(plans)} trechos ({jobs} em paralelo)...")
|
|
1064
|
+
with ThreadPoolExecutor(max_workers=jobs) as pool:
|
|
1065
|
+
futures = [pool.submit(extract, p, cfg) for p in plans]
|
|
1066
|
+
plans = [f.result() for f in futures]
|
|
1067
|
+
t_ext = time.time() - t_ext
|
|
1068
|
+
for a, b in zip(plans, plans[1:]):
|
|
1069
|
+
if b.joined and abs(a.audio_start + (a.lead + a.frames) / fps - b.audio_start) > 0.5 / SR:
|
|
1070
|
+
warn(f"a fala que continua de {a.rid} para {b.rid} saiu fora do lugar (o trecho {a.rid} mudou de "
|
|
1071
|
+
"quadros na extração): a voz pode pular ali. Rode de novo")
|
|
1072
|
+
|
|
1073
|
+
# Frame-accurate positions from the encoded clips.
|
|
1074
|
+
pos = 0
|
|
1075
|
+
total_frames = sum(p.frames for p in plans)
|
|
1076
|
+
total_samples = int(round(total_frames * SR / fps))
|
|
1077
|
+
duration = total_frames / fps
|
|
1078
|
+
|
|
1079
|
+
t_mix = time.time()
|
|
1080
|
+
mix_wav = clips_dir / f"{mode}_mix.wav"
|
|
1081
|
+
mix_audio(plans, fps, total_samples, mix_wav)
|
|
1082
|
+
measured = loudnorm_measure(mix_wav)
|
|
1083
|
+
t_mix = time.time() - t_mix
|
|
1084
|
+
|
|
1085
|
+
concat_list = clips_dir / f"{mode}_concat.txt"
|
|
1086
|
+
lines = ["ffconcat version 1.0"]
|
|
1087
|
+
for p in plans:
|
|
1088
|
+
lines.append(f"file '{concat_escape(p.video_path)}'")
|
|
1089
|
+
lines.append(f"duration {p.frames / fps:.6f}")
|
|
1090
|
+
concat_list.write_text("\n".join(lines) + "\n", encoding="utf-8")
|
|
1091
|
+
|
|
1092
|
+
norm_wav = clips_dir / f"{mode}_norm.wav"
|
|
1093
|
+
if measured:
|
|
1094
|
+
af = (f"loudnorm=I={LOUDNORM['I']}:TP={LOUDNORM['TP']}:LRA={LOUDNORM['LRA']}"
|
|
1095
|
+
f":measured_I={measured['input_i']}:measured_TP={measured['input_tp']}"
|
|
1096
|
+
f":measured_LRA={measured['input_lra']}:measured_thresh={measured['input_thresh']}"
|
|
1097
|
+
f":offset={measured['target_offset']}:linear=true,aresample={SR}")
|
|
1098
|
+
ffmpeg(["ffmpeg", "-y", "-v", "error", "-nostdin", "-i", str(mix_wav), "-af", af, "-c:a", "pcm_f32le",
|
|
1099
|
+
"-ar", str(SR), "-ac", "2", "-f", "wav", str(norm_wav)], "normalizar o volume")
|
|
1100
|
+
audio_in = norm_wav
|
|
1101
|
+
else:
|
|
1102
|
+
warn("não deu para medir a loudness (áudio mudo?); o áudio sai sem normalizar")
|
|
1103
|
+
audio_in = mix_wav
|
|
1104
|
+
|
|
1105
|
+
t_mux = time.time()
|
|
1106
|
+
tmp_out = out_path.with_name(f".{out_path.stem}.part.mp4")
|
|
1107
|
+
|
|
1108
|
+
def mux(gain_db: float) -> None:
|
|
1109
|
+
af_mux = ["-af", f"volume={gain_db:.2f}dB"] if abs(gain_db) > 0.001 else []
|
|
1110
|
+
ffmpeg(["ffmpeg", "-y", "-v", "error", "-nostdin", "-f", "concat", "-safe", "0", "-i", str(concat_list),
|
|
1111
|
+
"-i", str(audio_in), "-map", "0:v:0", "-map", "1:a:0", "-c:v", "copy", *af_mux,
|
|
1112
|
+
"-c:a", "aac", "-b:a", "192k", "-ar", str(SR), "-ac", "2", "-t", f"{duration:.6f}",
|
|
1113
|
+
"-movflags", "+faststart", "-f", "mp4", str(tmp_out)], f"montar o {out_path.name}")
|
|
1114
|
+
|
|
1115
|
+
peak_gain = 0.0
|
|
1116
|
+
try:
|
|
1117
|
+
mux(peak_gain)
|
|
1118
|
+
out_tp = true_peak_db(tmp_out)
|
|
1119
|
+
# The AAC encoder adds inter-sample peaks over the loudnorm target: lower just enough and encode again.
|
|
1120
|
+
peak_goal = TRUE_PEAK_LIMIT - TRUE_PEAK_MARGIN
|
|
1121
|
+
for _ in range(3):
|
|
1122
|
+
if out_tp is None or out_tp <= peak_goal:
|
|
1123
|
+
break
|
|
1124
|
+
peak_gain += peak_goal - out_tp - 0.15
|
|
1125
|
+
mux(peak_gain)
|
|
1126
|
+
out_tp = true_peak_db(tmp_out)
|
|
1127
|
+
if out_tp is not None and out_tp > TRUE_PEAK_LIMIT:
|
|
1128
|
+
warn(f"o pico real ficou em {out_tp:.2f} dBTP, acima de {TRUE_PEAK_LIMIT:.0f}, mesmo baixando "
|
|
1129
|
+
f"{peak_gain:.1f} dB: diminua o gain_db do trecho mais alto")
|
|
1130
|
+
os.replace(tmp_out, out_path)
|
|
1131
|
+
finally:
|
|
1132
|
+
tmp_out.unlink(missing_ok=True)
|
|
1133
|
+
norm_wav.unlink(missing_ok=True)
|
|
1134
|
+
t_mux = time.time() - t_mux
|
|
1135
|
+
|
|
1136
|
+
got_frames = count_frames(out_path)
|
|
1137
|
+
if got_frames != total_frames:
|
|
1138
|
+
warn(f"o arquivo final tem {got_frames} quadros, o timeline espera {total_frames}")
|
|
1139
|
+
|
|
1140
|
+
segments = []
|
|
1141
|
+
for p in plans:
|
|
1142
|
+
out_start_f, out_end_f = pos, pos + p.frames
|
|
1143
|
+
src_start = p.pic_start
|
|
1144
|
+
src_end = p.pic_start + p.frames / fps
|
|
1145
|
+
imagem = {"imagem_de": None}
|
|
1146
|
+
if p.other_picture:
|
|
1147
|
+
# src_start/src_end stay on the audio source's clock (the voice tools read them); the picture has its own
|
|
1148
|
+
imagem = {"imagem_de": p.pic_source, "imagem_path": str(p.pic_src),
|
|
1149
|
+
"imagem_src_start": round(src_start, 6), "imagem_src_end": round(src_end, 6)}
|
|
1150
|
+
src_start = p.audio_start + p.lead / fps
|
|
1151
|
+
src_end = p.audio_start + (p.lead + p.frames) / fps
|
|
1152
|
+
segments.append({
|
|
1153
|
+
"id": p.rid, "source": p.source, "source_path": str(p.src),
|
|
1154
|
+
"audio_mode": channel_modes.get(p.source, "stereo"),
|
|
1155
|
+
"src_start": round(src_start, 6), "src_end": round(src_end, 6), **imagem,
|
|
1156
|
+
"audio_continua": p.joined, "audio_fade_in": p.fade_in, "audio_fade_out": p.fade_out,
|
|
1157
|
+
"out_start": round(out_start_f / fps, 6), "out_end": round(out_end_f / fps, 6),
|
|
1158
|
+
"audio_out_start": round((out_start_f - p.lead) / fps, 6), "audio_out_end": round(out_end_f / fps, 6),
|
|
1159
|
+
"beat": p.beat, "quote": p.quote,
|
|
1160
|
+
"out_start_frame": out_start_f, "out_end_frame": out_end_f, "frames": p.frames,
|
|
1161
|
+
"lead_frames": p.lead, "tail_trim_frames": p.tail,
|
|
1162
|
+
"audio_src_start": round(p.audio_start, 6), "pic_snapped": p.pic_snapped,
|
|
1163
|
+
"edl_start": p.edl_start, "edl_end": p.edl_end, "gain_db": p.gain_db,
|
|
1164
|
+
"crop": p.crop,
|
|
1165
|
+
})
|
|
1166
|
+
pos = out_end_f
|
|
1167
|
+
|
|
1168
|
+
timeline = {
|
|
1169
|
+
"edl_sig": edl_sig, "fps": fps, "width": W, "height": H,
|
|
1170
|
+
"duration": round(duration, 6), "frames": total_frames,
|
|
1171
|
+
"video": out_path.name, "preview": bool(args.preview),
|
|
1172
|
+
"jcut": jcut, "grade": edl.get("grade") or "",
|
|
1173
|
+
"loudnorm_input": ({k: float(measured[k]) for k in ("input_i", "input_tp", "input_lra")} if measured else None),
|
|
1174
|
+
"loudnorm_modo": loudnorm_modo(measured),
|
|
1175
|
+
"true_peak_db": out_tp, "peak_guard_db": round(peak_gain, 2),
|
|
1176
|
+
"hdr_sources": sorted(k for k, pr in probes.items() if pr["info"].get("is_hdr")),
|
|
1177
|
+
"sincronia": ({"arquivo": str(sync_path), "fontes": {k: {"deslocamento": v[0], "escala": v[1]}
|
|
1178
|
+
for k, v in sync.items()}} if sync else None),
|
|
1179
|
+
"rendered_at": now_iso(),
|
|
1180
|
+
"segments": segments,
|
|
1181
|
+
}
|
|
1182
|
+
save_json(timeline_path, timeline)
|
|
1183
|
+
|
|
1184
|
+
# Only a full render inside the edit dir drives the preview. It does not ask for approval yet:
|
|
1185
|
+
# verify_cut.py does that after checking the file.
|
|
1186
|
+
state_note = ""
|
|
1187
|
+
if out_path.parent == edit_dir and not args.preview:
|
|
1188
|
+
state_path = edit_dir / "state.json"
|
|
1189
|
+
state = load_json(state_path, default={}) or {}
|
|
1190
|
+
state.update({
|
|
1191
|
+
"phase": "cut", "awaiting": None,
|
|
1192
|
+
"message": "Corte renderizado. Conferindo antes de mostrar...",
|
|
1193
|
+
"cut_video": out_path.name, "cut_timeline": timeline_path.name, "updated_at": now_iso(),
|
|
1194
|
+
})
|
|
1195
|
+
save_json(state_path, state)
|
|
1196
|
+
state_note = " · state.json atualizado (aprovação só depois do verify_cut)"
|
|
1197
|
+
|
|
1198
|
+
# Drop stale cached clips of this quality mode and the temp mix.
|
|
1199
|
+
keep = {q.video_path.name for q in plans} | {q.audio_path.name for q in plans} | {q.meta_path.name for q in plans}
|
|
1200
|
+
for f in clips_dir.glob(f"{mode}_*"):
|
|
1201
|
+
if f.name not in keep:
|
|
1202
|
+
f.unlink(missing_ok=True)
|
|
1203
|
+
|
|
1204
|
+
log("")
|
|
1205
|
+
log(f"{'id':6s} {'fonte':10s} {'origem':>15s} {'no corte':>17s} {'quadros':>7s} lead tail")
|
|
1206
|
+
for s, p in zip(segments, plans):
|
|
1207
|
+
pic = f" imagem da {p.pic_source} em {p.pic_start:.2f}s" if p.other_picture else ""
|
|
1208
|
+
log(f"{s['id']:6s} {s['source'][:10]:10s} {s['src_start']:6.2f}-{s['src_end']:6.2f}s "
|
|
1209
|
+
f"{fmt_time(s['out_start'])}-{fmt_time(s['out_end'])} {s['frames']:7d} {p.lead:4d} {p.tail:4d}"
|
|
1210
|
+
f"{' (cache)' if p.cached else ''}{' lead reduzido: ' + p.lead_note if p.lead_note else ''}"
|
|
1211
|
+
f"{' voz continua do anterior' if p.joined else ''}{pic}")
|
|
1212
|
+
for s in segments:
|
|
1213
|
+
if s["crop"] and s["crop"]["focus_from"] != "centro":
|
|
1214
|
+
c = s["crop"]
|
|
1215
|
+
log(f"Enquadramento {s['id']}: focus_x {c['focus_x']:.2f} focus_y {c['focus_y']:.2f} ({c['focus_from']})")
|
|
1216
|
+
if measured:
|
|
1217
|
+
log(f"Loudness antes: {float(measured['input_i']):.1f} LUFS, pico {float(measured['input_tp']):.1f} dBTP"
|
|
1218
|
+
f" -> alvo {LOUDNORM['I']:.0f} LUFS (ajuste {loudnorm_modo(measured)})")
|
|
1219
|
+
if loudnorm_modo(measured) == "dinamico":
|
|
1220
|
+
warn("o loudnorm vai cair no modo DINÂMICO (comprime a voz): rode helpers/ganho_por_trecho.py no EDL antes")
|
|
1221
|
+
if out_tp is not None and out_tp <= -100:
|
|
1222
|
+
log("Pico real no arquivo: sem som")
|
|
1223
|
+
elif out_tp is not None:
|
|
1224
|
+
guard =f" (volume {peak_gain:+.1f} dB para não passar de {TRUE_PEAK_LIMIT:.0f})" if peak_gain else ""
|
|
1225
|
+
log(f"Pico real no arquivo: {out_tp:.2f} dBTP{guard}")
|
|
1226
|
+
log(f"Pronto: {out_path} · {fmt_time(duration)} ({total_frames} quadros){state_note}")
|
|
1227
|
+
log(f"Timeline: {timeline_path}")
|
|
1228
|
+
log(f"Tempo: total {time.time() - t_all:.1f}s (plano {t_plan:.1f}s, extração {t_ext:.1f}s, "
|
|
1229
|
+
f"áudio {t_mix:.1f}s, montagem {t_mux:.1f}s)")
|
|
1230
|
+
mix_wav.unlink(missing_ok=True)
|
|
1231
|
+
concat_list.unlink(missing_ok=True)
|
|
1232
|
+
lock.close()
|
|
1233
|
+
return 0
|
|
1234
|
+
|
|
1235
|
+
|
|
1236
|
+
def main() -> int:
|
|
1237
|
+
ap = argparse.ArgumentParser(description="Renderiza o corte (cut.mp4 + cut_timeline.json) a partir do edl.json.")
|
|
1238
|
+
ap.add_argument("edl", type=Path, help="caminho do edl.json (a pasta dele é a pasta edit/)")
|
|
1239
|
+
ap.add_argument("-o", "--output", type=Path, help="vídeo de saída (padrão: cut.mp4 ao lado do EDL)")
|
|
1240
|
+
ap.add_argument("--jobs", type=int, default=0, help="trechos extraídos em paralelo (padrão automático)")
|
|
1241
|
+
ap.add_argument("--no-jcut", action="store_true", help="junções secas, sem J-cut")
|
|
1242
|
+
ap.add_argument("--preview", action="store_true",
|
|
1243
|
+
help="prévia rápida: metade da resolução, CRF 26, grava cut_preview.mp4 (não mexe no cut.mp4 nem no state.json)")
|
|
1244
|
+
args = ap.parse_args()
|
|
1245
|
+
try:
|
|
1246
|
+
return render(args)
|
|
1247
|
+
except CutError as exc:
|
|
1248
|
+
print(f"erro: {exc}", file=sys.stderr)
|
|
1249
|
+
return exc.code
|
|
1250
|
+
except KeyboardInterrupt:
|
|
1251
|
+
print("interrompido", file=sys.stderr)
|
|
1252
|
+
return 130
|
|
1253
|
+
|
|
1254
|
+
|
|
1255
|
+
if __name__ == "__main__":
|
|
1256
|
+
sys.exit(main())
|