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,3555 @@
|
|
|
1
|
+
"""Phase 2 composer: edit_data.json -> final.mp4 (Contract 8).
|
|
2
|
+
|
|
3
|
+
Pipeline (all layers frame-accurate on the output frame grid):
|
|
4
|
+
1. Camera on the base (cut.mp4): per timeline segment zoom level (hard change on the cut
|
|
5
|
+
frame) anchored at focus_y, optional eased push-in. Done with ffmpeg `perspective`
|
|
6
|
+
(sub-pixel sampling, so a slow push-in has no integer-crop jitter).
|
|
7
|
+
2. Inserts: every window is pre-rendered once to renders/inserts/ (images: eased Ken Burns in
|
|
8
|
+
PIL with sub-pixel affine sampling; videos: loop from frame 0). Split windows re-frame the
|
|
9
|
+
speaker in the remaining band; `full` covers; `pip` is a rounded white card with a shadow.
|
|
10
|
+
3. Overlays: inserts -> mid track (pip card + hook) -> captions (text_render track, seam
|
|
11
|
+
aware) -> top track (disclaimer, CTA, flashes). Text tracks are PNG states played by the
|
|
12
|
+
concat demuxer (`option framerate`), each overlay limited with enable windows on `n`.
|
|
13
|
+
4. Audio: base + SFX placed in samples + music (loudness matched, sidechain ducked, faded)
|
|
14
|
+
-> loudnorm two-pass -> length pinned to the video.
|
|
15
|
+
5. H.264 High CRF 18 (preview: 540 wide, CRF 26), yuv420p, bt709/tv, AAC 192k 48k, faststart.
|
|
16
|
+
Writes edit/verify/final_report.json and updates state.json.
|
|
17
|
+
|
|
18
|
+
Usage:
|
|
19
|
+
uv run python helpers/compose_final.py edit/edit_data.json [-o final.mp4] [--preview] [--keep-temp]
|
|
20
|
+
|
|
21
|
+
Defaults and optional keys beyond SPEC Contract 8 (all optional):
|
|
22
|
+
- a missing section is off (no camera move, no hook, ...); `loudness` defaults to -14 / -1.
|
|
23
|
+
- inserts[].sfx: effect name or a sound file; null = none. Names: whoosh, pop, click, tick, rise, cash, coin,
|
|
24
|
+
notification, success, error, count, keyboard, snap, mouse_click, bass_drop, metal_hit, synth_hit, flash,
|
|
25
|
+
charge, metal_riser, wind, rewind, gears, data, or the Portuguese label / alias of one of them from
|
|
26
|
+
assets/sfx/sfx.json ("grave", "notificacao", "estalo de dedo"...; accents, case and spaces do not matter).
|
|
27
|
+
inserts[].corner (pip only): "top_right" (default) or "top_left".
|
|
28
|
+
- hook.sfx (default "pop"), transitions[].sfx (default "click"), cta.sfx (default none).
|
|
29
|
+
- Built-in effects come from assets/sfx/sfx.json (make_sfx.builtin_sfx): each has an anchor (`anchor_s`,
|
|
30
|
+
the moment it hits full strength) that lands on the event frame, so the sound starts anchor_s earlier
|
|
31
|
+
(the CTA "rise" peaks when the CTA appears). A start before 0 is clamped to 0, with a warning when the
|
|
32
|
+
anchor then misses its frame. A sound file keeps anchor 0: it starts on the event frame (2 frames
|
|
33
|
+
early on an insert), exactly as before.
|
|
34
|
+
- sfx.events: [{"at": seconds, "name": effect or file, "volume": 0.0-2.0, "anchor_s": seconds}] sounds at
|
|
35
|
+
a chosen time, not tied to a hook, insert, flash or CTA (e.g. "cash" when the speech talks about a sale).
|
|
36
|
+
The anchor lands on the frame of `at`; `volume` multiplies sfx.volume; `anchor_s` (optional, >= 0)
|
|
37
|
+
replaces the anchor (a file's default is 0). A negative or non-numeric `at` or `anchor_s` is an error;
|
|
38
|
+
an `at` past the end is dropped with a warning, and a sound that loses more than 10% of its energy to
|
|
39
|
+
the video end plays cut with a warning. Only plays when sfx is on.
|
|
40
|
+
- camera.focus_y, hook.center_y, captions.center_y, cta.center_y, disclaimer.center_y: a number
|
|
41
|
+
(focus: fraction of the height; centers: pixels on the output canvas) or omitted / "auto".
|
|
42
|
+
Auto uses the speaker's face found by face_layout.py (edit/face_layout.json, made on the fly):
|
|
43
|
+
the zoom anchors on the eyes of each segment, splits frame each segment's face, and every text
|
|
44
|
+
goes to the free spot closest to its usual place (hook 430, captions 1400, CTA 1110 / 1180,
|
|
45
|
+
disclaimer 1548 on 1080x1920; horizontal and square canvases have their own table), never on
|
|
46
|
+
the face and never on a text shown at the same time. Manual numbers are kept as they are
|
|
47
|
+
(captions.center_y moves the captions for the whole video; split seams still win).
|
|
48
|
+
- Text layers on screen together never overlap: priority CTA > hook > captions > disclaimer. The
|
|
49
|
+
lower one moves (when auto) or hides while they would touch. Every move and hide is listed in
|
|
50
|
+
verify/final_report.json "adjustments"; text still over a face goes to "face_checks" and to
|
|
51
|
+
an `aviso:` line with a suggested center_y.
|
|
52
|
+
- captions.json made for another cut (timeline_key, cut boundaries or length that do not match
|
|
53
|
+
cut_timeline.json) is refused: run captions.py again.
|
|
54
|
+
- Inserts that overlap: the one later in the list is drawn on top. An HEIC/HEIF photo is converted
|
|
55
|
+
to JPG once with `sips` on macOS (renders/heic/); elsewhere it is refused with a hint.
|
|
56
|
+
- music.volume_db is relative to the voice loudness (the track is measured first). Default -15
|
|
57
|
+
(MUSIC_VOLUME_DB): with continuous speech the duck takes about 4 dB more off, so the bed sits about
|
|
58
|
+
19 LU under the voice; the old -22 left it about 26 LU under, nearly inaudible (measured 16/09/2026 on
|
|
59
|
+
two real cuts with a pop and a piano bed; the owner approved about -15 on a real ad).
|
|
60
|
+
- music.mood (instead of music.file): a mood of the personal library (helpers/trilhas.py). The choice
|
|
61
|
+
recorded in assets/trilhas_escolhidas.json is reused while its file is intact; otherwise the favorite of
|
|
62
|
+
the mood, else the best fit for the cut, is copied to assets/trilha_<mood>.<ext> and recorded.
|
|
63
|
+
music.track pins a library track id. When music.file is given it wins and mood is ignored. Without
|
|
64
|
+
mood the audio is exactly the one of a plain music.file.
|
|
65
|
+
- audio.denoise: "off" (default), "leve" or "forte" removes background noise from the voice (see
|
|
66
|
+
helpers/denoise.py). The voice is rebuilt from the sources range by range (cut_timeline.json) with
|
|
67
|
+
each source's own noise profile, placed on the same samples as in cut.mp4, loudnormed like
|
|
68
|
+
render_cut and used instead of cut.mp4's audio; cut.mp4 is not touched. Absent or "off": the audio
|
|
69
|
+
graph is exactly the one without this option. The report gets "denoise" (mode, noise floor before and
|
|
70
|
+
after, warnings) and the log one "ruído de fundo: ..." line.
|
|
71
|
+
- --preview writes final_preview.mp4 (540 px short side, CRF 26) unless -o is given.
|
|
72
|
+
- "edit_type": "tela_verde" + "tela_verde": {...}: the speaker cut out over images, videos or a color
|
|
73
|
+
(helpers/matte.py + helpers/tela_verde.py, SPEC Contract 13). The person matte of each cut range is made
|
|
74
|
+
once and cached in renders/matte/; the tela verde clip replaces cut.mp4 as the base (the camera zoom goes
|
|
75
|
+
into the person layer), faces and bodies are placed where the person lands and texts avoid them. Any
|
|
76
|
+
other edit_type, or none, renders exactly as before.
|
|
77
|
+
"""
|
|
78
|
+
|
|
79
|
+
from __future__ import annotations
|
|
80
|
+
|
|
81
|
+
import argparse
|
|
82
|
+
import hashlib
|
|
83
|
+
import json
|
|
84
|
+
import math
|
|
85
|
+
import os
|
|
86
|
+
import re
|
|
87
|
+
import shutil
|
|
88
|
+
import subprocess
|
|
89
|
+
import sys
|
|
90
|
+
import threading
|
|
91
|
+
import time
|
|
92
|
+
from concurrent.futures import ThreadPoolExecutor
|
|
93
|
+
from dataclasses import dataclass, field
|
|
94
|
+
from pathlib import Path
|
|
95
|
+
|
|
96
|
+
import numpy as np
|
|
97
|
+
from PIL import Image, ImageDraw, ImageFilter, ImageOps
|
|
98
|
+
|
|
99
|
+
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
|
100
|
+
from common import ( # noqa: E402
|
|
101
|
+
ffprobe_json,
|
|
102
|
+
file_signature,
|
|
103
|
+
fmt_time,
|
|
104
|
+
hex_to_rgb,
|
|
105
|
+
load_json,
|
|
106
|
+
load_style_catalog,
|
|
107
|
+
now_iso,
|
|
108
|
+
output_fps_for,
|
|
109
|
+
require_binary,
|
|
110
|
+
save_json,
|
|
111
|
+
)
|
|
112
|
+
import captions as captions_mod # noqa: E402
|
|
113
|
+
import denoise as denoise_mod # noqa: E402
|
|
114
|
+
import face_layout # noqa: E402
|
|
115
|
+
import make_sfx # noqa: E402
|
|
116
|
+
import text_render as tr # noqa: E402
|
|
117
|
+
|
|
118
|
+
RENDER_VERSION = "c2" # bump to invalidate cached insert clips / overlay PNGs
|
|
119
|
+
IMAGE_EXT = {".jpg", ".jpeg", ".png", ".webp", ".bmp", ".tif", ".tiff"}
|
|
120
|
+
HEIC_EXT = {".heic", ".heif", ".hif"}
|
|
121
|
+
VIDEO_EXT = {".mp4", ".mov", ".m4v", ".mkv", ".webm", ".avi", ".gif", ".mts"}
|
|
122
|
+
INSERT_TYPES = ("split_top", "split_bottom", "full", "pip")
|
|
123
|
+
SFX_FILE_EXT = (".wav", ".mp3", ".m4a", ".aac", ".ogg", ".flac")
|
|
124
|
+
SFX_EVENT_MAX_VOLUME = 2.0
|
|
125
|
+
SFX_EVENT_MIN_KEPT = 0.9 # an sfx.events sound with less of its energy before the video end gets a warning
|
|
126
|
+
SFX_TRIM_FADE = 0.08 # s: fade at the end of a sound cut to its "length" (animation cues)
|
|
127
|
+
SR = 48000
|
|
128
|
+
MUSIC_VOLUME_DB = -15.0 # music.volume_db default: about 19 LU under continuous speech once ducked
|
|
129
|
+
KB_DRIFT = 0.06 # Ken Burns scale drift (SPEC: at most 6%)
|
|
130
|
+
PREVIEW_SHORT_SIDE = 540
|
|
131
|
+
|
|
132
|
+
DEFAULT_DISCLAIMER = "Resultado individual. Depende de execução. Não é promessa de ganho."
|
|
133
|
+
DEFAULT_CTA = "Toque em Saiba Mais"
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
class ComposeError(Exception):
|
|
137
|
+
def __init__(self, message: str, code: int = 1):
|
|
138
|
+
super().__init__(message)
|
|
139
|
+
self.code = code
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def log(msg: str) -> None:
|
|
143
|
+
print(msg, flush=True)
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def even(v: float) -> int:
|
|
147
|
+
return max(2, int(round(v / 2.0)) * 2)
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def ease_in_out(p: float) -> float:
|
|
151
|
+
p = min(1.0, max(0.0, p))
|
|
152
|
+
return 0.5 - 0.5 * math.cos(math.pi * p)
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def short_hash(obj) -> str:
|
|
156
|
+
return hashlib.sha1(repr(obj).encode("utf-8")).hexdigest()[:16]
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def ffmpeg_cmd(args: list, what: str) -> subprocess.CompletedProcess:
|
|
160
|
+
proc = subprocess.run(["ffmpeg", "-hide_banner", "-nostdin", *[str(a) for a in args]],
|
|
161
|
+
capture_output=True, text=True)
|
|
162
|
+
if proc.returncode != 0:
|
|
163
|
+
tail = "\n".join((proc.stderr or "").strip().splitlines()[-12:])
|
|
164
|
+
raise ComposeError(f"o ffmpeg falhou ao {what}:\n{tail}")
|
|
165
|
+
return proc
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
_FF_MAJOR: int | None = None
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def ffmpeg_major() -> int:
|
|
172
|
+
global _FF_MAJOR
|
|
173
|
+
if _FF_MAJOR is None:
|
|
174
|
+
out = subprocess.run(["ffmpeg", "-version"], capture_output=True, text=True).stdout
|
|
175
|
+
m = re.search(r"ffmpeg version n?(\d+)", out)
|
|
176
|
+
_FF_MAJOR = int(m.group(1)) if m else 7
|
|
177
|
+
return _FF_MAJOR
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def graph_args(script: Path) -> list[str]:
|
|
181
|
+
return ["-/filter_complex", str(script)] if ffmpeg_major() >= 7 else ["-filter_complex_script", str(script)]
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
# ============================================================== plan
|
|
185
|
+
|
|
186
|
+
@dataclass
|
|
187
|
+
class Insert:
|
|
188
|
+
idx: int
|
|
189
|
+
type: str
|
|
190
|
+
src: Path
|
|
191
|
+
kind: str # image | video
|
|
192
|
+
f0: int
|
|
193
|
+
f1: int
|
|
194
|
+
ken_burns: bool
|
|
195
|
+
split: float
|
|
196
|
+
sfx: str | None
|
|
197
|
+
content: tuple[int, int, int, int] = (0, 0, 0, 0) # x, y, w, h of the picture
|
|
198
|
+
speaker: tuple[int, int, int, int] | None = None # x, y, w, h of the speaker band
|
|
199
|
+
speaker_src_y: int = 0
|
|
200
|
+
speaker_pieces: list[tuple[int, int, int]] = field(default_factory=list) # (f0, f1, source row) per camera segment
|
|
201
|
+
seam_y: int | None = None
|
|
202
|
+
card: tuple[int, int, int, int] | None = None # pip outer card
|
|
203
|
+
clip: Path | None = None
|
|
204
|
+
to_bg: bool = False # tela verde: a split_top / split_bottom insert shown as the background (no panel)
|
|
205
|
+
item: dict | None = None # the raw edit_data item (tela verde reads escurecer / desfocar / encaixe / corner)
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
@dataclass
|
|
209
|
+
class Plan:
|
|
210
|
+
edit_dir: Path
|
|
211
|
+
edit_data_path: Path
|
|
212
|
+
data: dict
|
|
213
|
+
base: Path
|
|
214
|
+
out_path: Path
|
|
215
|
+
preview: bool
|
|
216
|
+
full_w: int
|
|
217
|
+
full_h: int
|
|
218
|
+
W: int
|
|
219
|
+
H: int
|
|
220
|
+
fps: int
|
|
221
|
+
base_fps: float
|
|
222
|
+
base_w: int
|
|
223
|
+
base_h: int
|
|
224
|
+
nf: int
|
|
225
|
+
has_audio: bool
|
|
226
|
+
segments: list[tuple[int, int]]
|
|
227
|
+
accent: str
|
|
228
|
+
renders: Path
|
|
229
|
+
warnings: list[str] = field(default_factory=list)
|
|
230
|
+
layers: list[dict] = field(default_factory=list)
|
|
231
|
+
timeline: dict | None = None
|
|
232
|
+
faces: dict | None = None # face_layout.json
|
|
233
|
+
cam_segs: list[dict] = field(default_factory=list) # per segment: f0, f1, zoom, zoom_max, focus_y, face
|
|
234
|
+
zones: list[tuple] = field(default_factory=list) # (f0, f1, face zones, face cores) in render px
|
|
235
|
+
obstacles: list[tuple] = field(default_factory=list) # (f0, f1, (x0, y0, x1, y1)): pip cards texts avoid
|
|
236
|
+
adjustments: list[dict] = field(default_factory=list)
|
|
237
|
+
face_checks: list[dict] = field(default_factory=list)
|
|
238
|
+
music_choice: dict | None = None # music.mood resolved through the library
|
|
239
|
+
tv: dict | None = None # edit_type "tela_verde" (prepare_tela_verde)
|
|
240
|
+
person_zones: list[tuple] = field(default_factory=list) # (f0, f1, body rects) in render px, tela verde only
|
|
241
|
+
image_zones: list[tuple] = field(default_factory=list) # (f0, f1, rect) main area of a whole background picture
|
|
242
|
+
|
|
243
|
+
@property
|
|
244
|
+
def duration(self) -> float:
|
|
245
|
+
return self.nf / self.fps
|
|
246
|
+
|
|
247
|
+
@property
|
|
248
|
+
def s(self) -> float: # render scale vs the full output canvas
|
|
249
|
+
return self.W / self.full_w
|
|
250
|
+
|
|
251
|
+
def fr(self, t, default: int = 0) -> int:
|
|
252
|
+
if t is None:
|
|
253
|
+
return default
|
|
254
|
+
return max(0, min(self.nf, int(round(float(t) * self.fps + 1e-6))))
|
|
255
|
+
|
|
256
|
+
def rel(self, p: Path) -> str:
|
|
257
|
+
try:
|
|
258
|
+
return str(p.resolve().relative_to(self.edit_dir))
|
|
259
|
+
except ValueError:
|
|
260
|
+
return str(p.resolve())
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
def _section(data: dict, key: str) -> dict:
|
|
264
|
+
v = data.get(key)
|
|
265
|
+
return v if isinstance(v, dict) else {}
|
|
266
|
+
|
|
267
|
+
|
|
268
|
+
def _enabled(sec: dict) -> bool:
|
|
269
|
+
return bool(sec) and bool(sec.get("enabled", True))
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
def _resolve(edit_dir: Path, value: str, what: str) -> Path:
|
|
273
|
+
if not value or not str(value).strip():
|
|
274
|
+
raise ComposeError(f"{what}: caminho vazio", 2)
|
|
275
|
+
p = Path(str(value)).expanduser()
|
|
276
|
+
if not p.is_absolute():
|
|
277
|
+
p = edit_dir / p
|
|
278
|
+
p = p.resolve()
|
|
279
|
+
if not p.exists():
|
|
280
|
+
raise ComposeError(f"{what}: arquivo não encontrado: {p}", 2)
|
|
281
|
+
return p
|
|
282
|
+
|
|
283
|
+
|
|
284
|
+
def _base_facts(base: Path) -> dict:
|
|
285
|
+
try:
|
|
286
|
+
info = ffprobe_json(base)
|
|
287
|
+
except Exception:
|
|
288
|
+
raise ComposeError(f"não consegui ler o vídeo base: {base}", 2)
|
|
289
|
+
streams = info.get("streams", [])
|
|
290
|
+
v = next((s for s in streams if s.get("codec_type") == "video"), None)
|
|
291
|
+
if v is None:
|
|
292
|
+
raise ComposeError(f"o vídeo base não tem imagem: {base}", 2)
|
|
293
|
+
from fractions import Fraction
|
|
294
|
+
rate = v.get("avg_frame_rate") or v.get("r_frame_rate") or "30/1"
|
|
295
|
+
try:
|
|
296
|
+
fps = float(Fraction(rate))
|
|
297
|
+
except (ValueError, ZeroDivisionError):
|
|
298
|
+
fps = 30.0
|
|
299
|
+
if fps <= 0:
|
|
300
|
+
fps = 30.0
|
|
301
|
+
dur = float(v.get("duration") or (info.get("format") or {}).get("duration") or 0.0)
|
|
302
|
+
nb = int(v.get("nb_frames") or 0)
|
|
303
|
+
if nb <= 0:
|
|
304
|
+
proc = subprocess.run(["ffprobe", "-v", "error", "-select_streams", "v:0", "-count_packets",
|
|
305
|
+
"-show_entries", "stream=nb_read_packets", "-of", "csv=p=0", str(base)],
|
|
306
|
+
capture_output=True, text=True)
|
|
307
|
+
try:
|
|
308
|
+
nb = int(proc.stdout.strip().split(",")[0])
|
|
309
|
+
except ValueError:
|
|
310
|
+
nb = int(round(dur * fps))
|
|
311
|
+
rot = 0
|
|
312
|
+
for sd in v.get("side_data_list", []) or []:
|
|
313
|
+
if "rotation" in sd:
|
|
314
|
+
rot = abs(int(round(float(sd["rotation"])))) % 180
|
|
315
|
+
w, h = int(v.get("width", 0)), int(v.get("height", 0))
|
|
316
|
+
if rot == 90:
|
|
317
|
+
w, h = h, w
|
|
318
|
+
return {"fps": fps, "duration": dur, "frames": nb, "width": w, "height": h,
|
|
319
|
+
"has_audio": any(s.get("codec_type") == "audio" for s in streams)}
|
|
320
|
+
|
|
321
|
+
|
|
322
|
+
def build_plan(edit_data_path: Path, out: str | None, preview: bool) -> Plan:
|
|
323
|
+
if not edit_data_path.exists():
|
|
324
|
+
raise ComposeError(f"edit_data.json não encontrado: {edit_data_path}", 2)
|
|
325
|
+
try:
|
|
326
|
+
data = json.loads(edit_data_path.read_text(encoding="utf-8"))
|
|
327
|
+
except (ValueError, OSError) as e:
|
|
328
|
+
raise ComposeError(f"edit_data.json inválido ({e})", 2)
|
|
329
|
+
if not isinstance(data, dict):
|
|
330
|
+
raise ComposeError("edit_data.json precisa ser um objeto JSON", 2)
|
|
331
|
+
edit_dir = edit_data_path.parent.resolve()
|
|
332
|
+
base = _resolve(edit_dir, data.get("base") or "cut.mp4", "base")
|
|
333
|
+
facts = _base_facts(base)
|
|
334
|
+
|
|
335
|
+
timeline_path = edit_dir / (data.get("timeline") or "cut_timeline.json")
|
|
336
|
+
timeline = load_json(timeline_path) if timeline_path.exists() else None
|
|
337
|
+
|
|
338
|
+
outcfg = _section(data, "output")
|
|
339
|
+
full_w = int(outcfg.get("width") or (timeline or {}).get("width") or facts["width"])
|
|
340
|
+
full_h = int(outcfg.get("height") or (timeline or {}).get("height") or facts["height"])
|
|
341
|
+
fps_val = outcfg.get("fps") or (timeline or {}).get("fps") or output_fps_for(facts["fps"])
|
|
342
|
+
fps = int(round(float(fps_val)))
|
|
343
|
+
if fps <= 0 or full_w < 16 or full_h < 16:
|
|
344
|
+
raise ComposeError("output inválido em edit_data.json (width/height/fps)", 2)
|
|
345
|
+
if preview:
|
|
346
|
+
k = min(1.0, PREVIEW_SHORT_SIDE / min(full_w, full_h))
|
|
347
|
+
W, H = even(full_w * k), even(full_h * k)
|
|
348
|
+
else:
|
|
349
|
+
W, H = even(full_w), even(full_h)
|
|
350
|
+
|
|
351
|
+
if abs(facts["fps"] - fps) < 0.01:
|
|
352
|
+
nf = facts["frames"]
|
|
353
|
+
else:
|
|
354
|
+
nf = int(round(facts["duration"] * fps))
|
|
355
|
+
if nf <= 0:
|
|
356
|
+
raise ComposeError("o vídeo base não tem quadros", 2)
|
|
357
|
+
|
|
358
|
+
accent = str(data.get("accent") or (load_style_catalog().get("defaults") or {}).get("accent") or "#FFC400")
|
|
359
|
+
try:
|
|
360
|
+
hex_to_rgb(accent)
|
|
361
|
+
except ValueError:
|
|
362
|
+
raise ComposeError(f"cor de destaque inválida: {accent!r} (use #RRGGBB)", 2)
|
|
363
|
+
|
|
364
|
+
if out:
|
|
365
|
+
out_path = Path(out).expanduser()
|
|
366
|
+
if not out_path.is_absolute():
|
|
367
|
+
out_path = (Path.cwd() / out_path)
|
|
368
|
+
else:
|
|
369
|
+
out_path = edit_dir / ("final_preview.mp4" if preview else "final.mp4")
|
|
370
|
+
out_path = out_path.resolve()
|
|
371
|
+
|
|
372
|
+
plan = Plan(edit_dir=edit_dir, edit_data_path=edit_data_path.resolve(), data=data, base=base,
|
|
373
|
+
out_path=out_path, preview=preview, full_w=full_w, full_h=full_h, W=W, H=H, fps=fps,
|
|
374
|
+
base_fps=facts["fps"], base_w=facts["width"], base_h=facts["height"], nf=nf,
|
|
375
|
+
has_audio=facts["has_audio"], segments=[], accent=accent,
|
|
376
|
+
renders=edit_dir / "renders" / ("preview" if preview else "full"), timeline=timeline)
|
|
377
|
+
|
|
378
|
+
# timeline segments on the output frame grid
|
|
379
|
+
segs = []
|
|
380
|
+
for s in (timeline or {}).get("segments", []) or []:
|
|
381
|
+
try:
|
|
382
|
+
segs.append(plan.fr(float(s["out_start"])))
|
|
383
|
+
except (KeyError, TypeError, ValueError):
|
|
384
|
+
continue
|
|
385
|
+
starts = sorted({f for f in segs if 0 < f < nf})
|
|
386
|
+
bounds = [0] + starts + [nf]
|
|
387
|
+
plan.segments = [(a, b) for a, b in zip(bounds, bounds[1:]) if b > a]
|
|
388
|
+
music = _section(data, "music")
|
|
389
|
+
if _enabled(music) and music.get("file"):
|
|
390
|
+
_resolve(edit_dir, music.get("file"), "music.file")
|
|
391
|
+
elif _enabled(music) and music.get("mood") not in (None, ""):
|
|
392
|
+
resolve_music_mood(plan, music)
|
|
393
|
+
elif _enabled(music):
|
|
394
|
+
raise ComposeError("music está ligado mas sem 'file' nem 'mood' (ex: \"file\": \"assets/trilha.mp3\" "
|
|
395
|
+
"ou \"mood\": \"triste\")", 2)
|
|
396
|
+
if timeline is None:
|
|
397
|
+
plan.warnings.append("cut_timeline.json não encontrado: o vídeo inteiro conta como um trecho só")
|
|
398
|
+
if timeline and abs(float(timeline.get("duration") or 0) - nf / fps) > 1.5 / fps:
|
|
399
|
+
plan.warnings.append(f"cut_timeline.json diz {timeline.get('duration')}s, mas o vídeo base tem {nf / fps:.3f}s")
|
|
400
|
+
return plan
|
|
401
|
+
|
|
402
|
+
|
|
403
|
+
def resolve_music_mood(plan: Plan, music: dict) -> None:
|
|
404
|
+
"""music.mood without music.file: pick the track from the personal library (helpers/trilhas.py).
|
|
405
|
+
|
|
406
|
+
The chosen file goes to assets/ and into plan.data["music"]["file"] (in memory only), so the audio graph is
|
|
407
|
+
the one of a plain music.file; plan.music_choice keeps the track id and why for the report."""
|
|
408
|
+
import trilhas
|
|
409
|
+
|
|
410
|
+
mood, track = music.get("mood"), music.get("track")
|
|
411
|
+
if not isinstance(mood, str) or not mood.strip():
|
|
412
|
+
raise ComposeError("music.mood precisa ser o nome de um humor, ex.: \"triste\"", 2)
|
|
413
|
+
if track is not None and (not isinstance(track, str) or not track.strip()):
|
|
414
|
+
raise ComposeError("music.track precisa ser o id de uma trilha da biblioteca, ex.: \"triste-2\"", 2)
|
|
415
|
+
try:
|
|
416
|
+
choice = trilhas.resolve_for_edit(plan.edit_dir, mood.strip(), plan.duration,
|
|
417
|
+
track.strip() if track else None)
|
|
418
|
+
except trilhas.TrilhaError as e:
|
|
419
|
+
raise ComposeError(f"music.mood: {e}", 2)
|
|
420
|
+
_resolve(plan.edit_dir, choice["file"], "music.file")
|
|
421
|
+
plan.data["music"] = {**music, "file": choice["file"]}
|
|
422
|
+
plan.music_choice = choice
|
|
423
|
+
if choice.get("warning"):
|
|
424
|
+
plan.warnings.append(choice["warning"])
|
|
425
|
+
|
|
426
|
+
|
|
427
|
+
def _plain_number(value) -> float | None:
|
|
428
|
+
"""A finite number (or numeric text); None for bools, text that is not a number, NaN and inf."""
|
|
429
|
+
if isinstance(value, bool):
|
|
430
|
+
return None
|
|
431
|
+
try:
|
|
432
|
+
v = float(value)
|
|
433
|
+
except (TypeError, ValueError):
|
|
434
|
+
return None
|
|
435
|
+
return v if math.isfinite(v) else None
|
|
436
|
+
|
|
437
|
+
|
|
438
|
+
def _as_json(value) -> str:
|
|
439
|
+
"""A value the way it is written in edit_data.json (null, true, "texto"), for error messages."""
|
|
440
|
+
try:
|
|
441
|
+
text = json.dumps(value, ensure_ascii=False)
|
|
442
|
+
except (TypeError, ValueError):
|
|
443
|
+
return repr(value)
|
|
444
|
+
if isinstance(value, str) and "," in value and _plain_number(value.replace(",", ".")) is not None:
|
|
445
|
+
text += ", use ponto no lugar da vírgula"
|
|
446
|
+
return text
|
|
447
|
+
|
|
448
|
+
|
|
449
|
+
def plan_sfx_events(plan: Plan) -> list[dict]:
|
|
450
|
+
"""sfx.events: effects at a chosen time on the cut, not tied to a hook, insert, flash or CTA.
|
|
451
|
+
|
|
452
|
+
Checked before anything renders. Returns events in the same shape build_tracks gives run_audio
|
|
453
|
+
({name, frame, lead_frames 0, why "efeito"}) plus at, event (index), and volume and anchor_s when given.
|
|
454
|
+
"""
|
|
455
|
+
sec = _section(plan.data, "sfx")
|
|
456
|
+
if not _enabled(sec) or sec.get("events") is None:
|
|
457
|
+
return []
|
|
458
|
+
raw = sec.get("events")
|
|
459
|
+
example = '{"at": 5.07, "name": "cash"}'
|
|
460
|
+
if not isinstance(raw, list):
|
|
461
|
+
raise ComposeError(f"sfx.events precisa ser uma lista (ex: [{example}])", 2)
|
|
462
|
+
out: list[dict] = []
|
|
463
|
+
for i, item in enumerate(raw):
|
|
464
|
+
where = f"sfx.events[{i}]"
|
|
465
|
+
if not isinstance(item, dict):
|
|
466
|
+
raise ComposeError(f"{where} precisa ser um objeto (ex: {example})", 2)
|
|
467
|
+
at = _plain_number(item.get("at"))
|
|
468
|
+
if at is None or at < 0:
|
|
469
|
+
if "at" not in item:
|
|
470
|
+
raise ComposeError(f"{where} precisa de \"at\": o segundo do corte em que o som toca (ex: {example})", 2)
|
|
471
|
+
raise ComposeError(f"{where}.at precisa ser um número de segundos, 0 ou mais (veio {_as_json(item.get('at'))})", 2)
|
|
472
|
+
name = item.get("name")
|
|
473
|
+
if not isinstance(name, str) or not name.strip():
|
|
474
|
+
raise ComposeError(f"{where} precisa de \"name\": um efeito ({', '.join(make_sfx.NAMES)}, ou o nome em "
|
|
475
|
+
f"português, ex.: \"grave\") ou um arquivo (ex: \"assets/meu_som.wav\")", 2)
|
|
476
|
+
name = name.strip()
|
|
477
|
+
ev: dict = {"name": name, "frame": 0, "lead_frames": 0, "why": "efeito", "at": round(at, 4), "event": i}
|
|
478
|
+
if item.get("volume") is not None:
|
|
479
|
+
vol = _plain_number(item.get("volume"))
|
|
480
|
+
if vol is None or vol < 0:
|
|
481
|
+
raise ComposeError(f"{where}.volume precisa ser um número de 0.0 a {SFX_EVENT_MAX_VOLUME:.1f} "
|
|
482
|
+
f"(veio {_as_json(item.get('volume'))})", 2)
|
|
483
|
+
if vol > SFX_EVENT_MAX_VOLUME:
|
|
484
|
+
plan.warnings.append(f"{where}.volume {vol:g} passou do máximo e ficou em {SFX_EVENT_MAX_VOLUME:.1f}")
|
|
485
|
+
vol = SFX_EVENT_MAX_VOLUME
|
|
486
|
+
ev["volume"] = vol
|
|
487
|
+
if item.get("anchor_s") is not None:
|
|
488
|
+
anchor = _plain_number(item.get("anchor_s"))
|
|
489
|
+
if anchor is None or anchor < 0:
|
|
490
|
+
raise ComposeError(f"{where}.anchor_s precisa ser um número de segundos, 0 ou mais: onde fica o ponto "
|
|
491
|
+
f"forte dentro do som (veio {_as_json(item.get('anchor_s'))})", 2)
|
|
492
|
+
ev["anchor_s"] = round(anchor, 4)
|
|
493
|
+
if Path(name).suffix.lower() in SFX_FILE_EXT:
|
|
494
|
+
_resolve(plan.edit_dir, name, f"{where}.name")
|
|
495
|
+
if at * plan.fps >= plan.nf - 1e-6:
|
|
496
|
+
plan.warnings.append(f"{where} ('{name}' em {fmt_time(at)}) passa do fim do vídeo (o vídeo acaba em "
|
|
497
|
+
f"{fmt_time(plan.duration)} e o último quadro começa em "
|
|
498
|
+
f"{fmt_time((plan.nf - 1) / plan.fps)}) e foi ignorado")
|
|
499
|
+
continue
|
|
500
|
+
# same rounding as the other events (Plan.fr); a time in the last half frame stays on the last frame
|
|
501
|
+
ev["frame"] = min(plan.fr(at), plan.nf - 1)
|
|
502
|
+
out.append(ev)
|
|
503
|
+
return out
|
|
504
|
+
|
|
505
|
+
|
|
506
|
+
def plan_denoise(plan: Plan) -> str:
|
|
507
|
+
"""audio.denoise: "off" | "leve" | "forte" (a missing section or "enabled": false is off). Checked before rendering."""
|
|
508
|
+
raw = plan.data.get("audio")
|
|
509
|
+
if raw is None:
|
|
510
|
+
return "off"
|
|
511
|
+
if not isinstance(raw, dict):
|
|
512
|
+
raise ComposeError('audio precisa ser um objeto (ex: "audio": {"denoise": "leve"})', 2)
|
|
513
|
+
if raw.get("enabled", True) is False:
|
|
514
|
+
return "off"
|
|
515
|
+
try:
|
|
516
|
+
return denoise_mod.normalize_mode(raw.get("denoise"))
|
|
517
|
+
except ValueError as e:
|
|
518
|
+
raise ComposeError(str(e), 2)
|
|
519
|
+
|
|
520
|
+
|
|
521
|
+
def run_denoise(plan: Plan, mode: str, keep_temp: bool) -> tuple[Path | None, dict]:
|
|
522
|
+
"""The cleaned voice for run_audio (None keeps cut.mp4's audio). A failure never stops the render."""
|
|
523
|
+
if mode == "off":
|
|
524
|
+
return None, {"mode": "off", "applied": "off"}
|
|
525
|
+
if not plan.has_audio:
|
|
526
|
+
msg = "o vídeo base não tem áudio: não há ruído para limpar"
|
|
527
|
+
plan.warnings.append(msg)
|
|
528
|
+
return None, {"mode": mode, "applied": "off", "warnings": [msg]}
|
|
529
|
+
work = plan.edit_dir / "renders" / "denoise"
|
|
530
|
+
try:
|
|
531
|
+
res = denoise_mod.build_voice(plan.edit_dir, plan.base, plan.timeline, plan.fps, plan.nf * SR // plan.fps, mode,
|
|
532
|
+
work, keep_temp=keep_temp)
|
|
533
|
+
except Exception as e: # noqa: BLE001 (the cleaning is optional: the render goes on with the cut's audio)
|
|
534
|
+
msg = f"a limpeza de ruído falhou ({e}). O vídeo sai com o som original"
|
|
535
|
+
plan.warnings.append(msg)
|
|
536
|
+
return None, {"mode": mode, "applied": "off", "warnings": [msg]}
|
|
537
|
+
plan.warnings.extend(res.warnings)
|
|
538
|
+
return res.path, res.report
|
|
539
|
+
|
|
540
|
+
|
|
541
|
+
def denoise_line(rep: dict) -> str | None:
|
|
542
|
+
"""One PT-BR log line about the background-noise removal (None when it is off)."""
|
|
543
|
+
mode = rep.get("mode") or "off"
|
|
544
|
+
if mode == "off":
|
|
545
|
+
return None
|
|
546
|
+
applied = rep.get("applied") or "off"
|
|
547
|
+
if applied == "off":
|
|
548
|
+
return f"ruído de fundo: sem limpeza (pedido {denoise_mod.LABEL[mode]}, veja o aviso)"
|
|
549
|
+
if applied == "misto":
|
|
550
|
+
lighter = [e["source"] for e in rep.get("sources") or [] if e.get("applied") == "leve"]
|
|
551
|
+
what = f"{denoise_mod.LABEL[mode]}, leve em {', '.join(lighter)}"
|
|
552
|
+
elif applied != mode:
|
|
553
|
+
what = f"{denoise_mod.LABEL[applied]} no lugar de {denoise_mod.LABEL[mode]}"
|
|
554
|
+
else:
|
|
555
|
+
what = denoise_mod.LABEL[applied]
|
|
556
|
+
if rep.get("method") == "corte":
|
|
557
|
+
what += ", no som do corte"
|
|
558
|
+
before, after = rep.get("noise_floor_before_db"), rep.get("noise_floor_after_db")
|
|
559
|
+
if before is not None and after is not None:
|
|
560
|
+
return f"ruído de fundo: {what} (fundo de {before:.0f} dB para {after:.0f} dB)"
|
|
561
|
+
return f"ruído de fundo: {what}"
|
|
562
|
+
|
|
563
|
+
|
|
564
|
+
# ============================================================== camera
|
|
565
|
+
|
|
566
|
+
def _is_auto(value) -> bool:
|
|
567
|
+
return value is None or (isinstance(value, str) and value.strip().lower() == "auto")
|
|
568
|
+
|
|
569
|
+
|
|
570
|
+
def load_faces(plan: Plan) -> None:
|
|
571
|
+
"""Face per timeline segment (face_layout.json, detected on the first compose and cached)."""
|
|
572
|
+
try:
|
|
573
|
+
plan.faces = face_layout.build(plan.edit_dir, plan.base, timeline=plan.timeline)
|
|
574
|
+
except face_layout.FaceLayoutError as e:
|
|
575
|
+
plan.warnings.append(f"não consegui procurar o rosto ({e}): textos e zoom nas posições padrão")
|
|
576
|
+
except Exception as e: # noqa: BLE001 (a detector failure must not stop the render)
|
|
577
|
+
plan.warnings.append(f"a detecção de rosto falhou ({e.__class__.__name__}): textos e zoom nas posições padrão")
|
|
578
|
+
|
|
579
|
+
|
|
580
|
+
def camera_segments(plan: Plan, levels: list[float], zoom_cuts: bool, push_in: bool, amount: float) -> list[dict]:
|
|
581
|
+
cam = _section(plan.data, "camera")
|
|
582
|
+
manual = None
|
|
583
|
+
if not _is_auto(cam.get("focus_y")):
|
|
584
|
+
try:
|
|
585
|
+
manual = min(0.9, max(0.1, float(cam.get("focus_y"))))
|
|
586
|
+
except (TypeError, ValueError):
|
|
587
|
+
raise ComposeError("camera.focus_y precisa ser um número entre 0.1 e 0.9 ou \"auto\"", 2)
|
|
588
|
+
face_segs = [f for f in ((plan.faces or {}).get("segments") or []) if isinstance(f, dict)]
|
|
589
|
+
found = any(f.get("face") for f in face_segs)
|
|
590
|
+
fallback = ((plan.faces or {}).get("recommended") or {}).get("camera", {}).get("focus_y") if found else None
|
|
591
|
+
fallback = float(fallback) if fallback is not None else 0.40
|
|
592
|
+
out = []
|
|
593
|
+
for i, (a, b) in enumerate(plan.segments):
|
|
594
|
+
z = levels[i % len(levels)] if zoom_cuts else 1.0
|
|
595
|
+
t0, t1 = a / plan.fps, b / plan.fps
|
|
596
|
+
best, best_ov = None, 0.0
|
|
597
|
+
for f in face_segs:
|
|
598
|
+
ov = min(t1, float(f.get("end", 0))) - max(t0, float(f.get("start", 0)))
|
|
599
|
+
if ov > best_ov:
|
|
600
|
+
best, best_ov = f, ov
|
|
601
|
+
face = best if best and best.get("face") else None
|
|
602
|
+
fy = manual if manual is not None else (float(face["focus_y"]) if face and face.get("focus_y") is not None else fallback)
|
|
603
|
+
out.append({"f0": a, "f1": b, "zoom": z, "zoom_max": z * (1 + amount) if push_in else z,
|
|
604
|
+
"focus_y": min(0.9, max(0.1, fy)), "face": face, "manual_focus": manual is not None})
|
|
605
|
+
return out
|
|
606
|
+
|
|
607
|
+
|
|
608
|
+
def camera_filter(plan: Plan) -> str | None:
|
|
609
|
+
cam = _section(plan.data, "camera")
|
|
610
|
+
zoom_cuts = bool(cam.get("zoom_cuts", False))
|
|
611
|
+
push_in = bool(cam.get("push_in", False))
|
|
612
|
+
levels = cam.get("zoom_levels") or [1.0, 1.14]
|
|
613
|
+
try:
|
|
614
|
+
levels = [min(1.6, max(1.0, float(z))) for z in levels] or [1.0]
|
|
615
|
+
except (TypeError, ValueError):
|
|
616
|
+
raise ComposeError("camera.zoom_levels precisa ser uma lista de números (ex: [1.0, 1.14])", 2)
|
|
617
|
+
amount = min(0.25, max(0.0, float(cam.get("push_in_amount", 0.05) or 0.0)))
|
|
618
|
+
plan.cam_segs = camera_segments(plan, levels, zoom_cuts, push_in and amount > 0, amount)
|
|
619
|
+
segs_info = []
|
|
620
|
+
parts = []
|
|
621
|
+
for cs in plan.cam_segs:
|
|
622
|
+
a, b, z = cs["f0"], cs["f1"], cs["zoom"]
|
|
623
|
+
n = b - a
|
|
624
|
+
if push_in and amount > 0 and n > 1:
|
|
625
|
+
expr = f"{z:.5f}*(1+{amount:.5f}*(0.5-0.5*cos(PI*clip((in-1-{a})/{n - 1},0,1))))"
|
|
626
|
+
else:
|
|
627
|
+
expr = f"{z:.5f}"
|
|
628
|
+
parts.append((b, expr, cs["focus_y"]))
|
|
629
|
+
segs_info.append({"start": round(a / plan.fps, 3), "end": round(b / plan.fps, 3), "zoom": z,
|
|
630
|
+
"push_in": amount if push_in else 0.0, "focus_y": round(cs["focus_y"], 4),
|
|
631
|
+
"face": bool(cs["face"])})
|
|
632
|
+
manual = next((cs["focus_y"] for cs in plan.cam_segs if cs["manual_focus"]), None)
|
|
633
|
+
plan.layers.append({"type": "camera", "zoom_cuts": zoom_cuts, "push_in": push_in,
|
|
634
|
+
"focus_y": manual if manual is not None else "auto", "segments": segs_info})
|
|
635
|
+
if not push_in and all(abs(info["zoom"] - 1.0) < 1e-6 for info in segs_info):
|
|
636
|
+
return None
|
|
637
|
+
|
|
638
|
+
def piecewise(fn) -> str:
|
|
639
|
+
expr = fn(parts[-1])
|
|
640
|
+
for part in reversed(parts[:-1]):
|
|
641
|
+
expr = f"if(lt(in-1,{part[0]}),{fn(part)},{expr})"
|
|
642
|
+
return f"({expr})"
|
|
643
|
+
|
|
644
|
+
Z = piecewise(lambda p: p[1])
|
|
645
|
+
# each segment's crop keeps its own eye line still (focus_y per segment)
|
|
646
|
+
T = piecewise(lambda p: f"{p[2]:.5f}*(1-1/({p[1]}))")
|
|
647
|
+
left = f"W*0.5*(1-1/{Z})"
|
|
648
|
+
top = f"H*{T}"
|
|
649
|
+
right = f"W*0.5*(1-1/{Z})+W/{Z}"
|
|
650
|
+
bottom = f"H*{T}+H/{Z}"
|
|
651
|
+
return (f"perspective=x0='{left}':y0='{top}':x1='{right}':y1='{top}':"
|
|
652
|
+
f"x2='{left}':y2='{bottom}':x3='{right}':y3='{bottom}':interpolation=cubic:eval=frame")
|
|
653
|
+
|
|
654
|
+
|
|
655
|
+
def segment_for(plan: Plan, f0: int, f1: int) -> dict | None:
|
|
656
|
+
"""Camera segment covering most of [f0, f1)."""
|
|
657
|
+
best, best_ov = None, 0
|
|
658
|
+
for cs in plan.cam_segs:
|
|
659
|
+
ov = min(f1, cs["f1"]) - max(f0, cs["f0"])
|
|
660
|
+
if ov > best_ov:
|
|
661
|
+
best, best_ov = cs, ov
|
|
662
|
+
return best
|
|
663
|
+
|
|
664
|
+
|
|
665
|
+
# ============================================================== inserts
|
|
666
|
+
|
|
667
|
+
def plan_inserts(plan: Plan, tela_verde: bool = False) -> list[Insert]:
|
|
668
|
+
"""tela_verde: split_top / split_bottom items become background items (to_bg, no panel, no seam)."""
|
|
669
|
+
raw = plan.data.get("inserts") or []
|
|
670
|
+
if not isinstance(raw, list):
|
|
671
|
+
raise ComposeError("inserts precisa ser uma lista", 2)
|
|
672
|
+
sfx_on = _enabled(_section(plan.data, "sfx"))
|
|
673
|
+
W, H, s = plan.W, plan.H, plan.s
|
|
674
|
+
lay = tr.layout_defaults((W, H))
|
|
675
|
+
safe_top, safe_bottom = lay["top"], lay["bottom"]
|
|
676
|
+
out: list[Insert] = []
|
|
677
|
+
seam_reach = (0.0, 0.0) # (above, below) px the captions reach from a split seam
|
|
678
|
+
cap = _section(plan.data, "captions")
|
|
679
|
+
if _enabled(cap) and not tela_verde and any(isinstance(it, dict) and it.get("type") in ("split_top", "split_bottom")
|
|
680
|
+
for it in raw):
|
|
681
|
+
style = cap.get("style")
|
|
682
|
+
if not style: # the same fallback build_tracks uses: the style captions.json was grouped for
|
|
683
|
+
try:
|
|
684
|
+
cues = load_json(plan.edit_dir / str(cap.get("file") or "captions.json"))
|
|
685
|
+
except (ValueError, OSError):
|
|
686
|
+
cues = None
|
|
687
|
+
style = (cues.get("style") if isinstance(cues, dict) else None) \
|
|
688
|
+
or (load_style_catalog().get("defaults") or {}).get("caption_style") or "impacto"
|
|
689
|
+
try:
|
|
690
|
+
seam_reach = tr.caption_extent([], style, (W, H), float(cap.get("font_scale", 1.0) or 1.0), plan.fps)
|
|
691
|
+
except (ValueError, TypeError):
|
|
692
|
+
seam_reach = (0.0, 0.0) # unknown style: build_tracks reports it
|
|
693
|
+
for i, item in enumerate(raw):
|
|
694
|
+
if not isinstance(item, dict):
|
|
695
|
+
raise ComposeError(f"inserts[{i}] precisa ser um objeto", 2)
|
|
696
|
+
typ = item.get("type")
|
|
697
|
+
if typ not in INSERT_TYPES:
|
|
698
|
+
raise ComposeError(f"inserts[{i}].type inválido: {typ!r}. Use {', '.join(INSERT_TYPES)}", 2)
|
|
699
|
+
src = _resolve(plan.edit_dir, item.get("src"), f"inserts[{i}].src")
|
|
700
|
+
ext = src.suffix.lower()
|
|
701
|
+
if ext in HEIC_EXT:
|
|
702
|
+
src, ext = heic_to_jpg(plan, src, i), ".jpg"
|
|
703
|
+
kind = "image" if ext in IMAGE_EXT else "video" if ext in VIDEO_EXT else None
|
|
704
|
+
if kind is None:
|
|
705
|
+
try:
|
|
706
|
+
Image.open(src).close()
|
|
707
|
+
kind = "image"
|
|
708
|
+
except Exception:
|
|
709
|
+
kind = "video"
|
|
710
|
+
try:
|
|
711
|
+
t0, t1 = float(item["start"]), float(item["end"])
|
|
712
|
+
except (KeyError, TypeError, ValueError):
|
|
713
|
+
raise ComposeError(f"inserts[{i}] precisa de start e end em segundos", 2)
|
|
714
|
+
f0, f1 = plan.fr(t0), plan.fr(t1)
|
|
715
|
+
if f0 >= plan.nf:
|
|
716
|
+
plan.warnings.append(f"insert {i + 1} começa depois do fim do vídeo e foi ignorado")
|
|
717
|
+
continue
|
|
718
|
+
if t1 * plan.fps > plan.nf + 0.5:
|
|
719
|
+
plan.warnings.append(f"insert {i + 1} passava do fim do vídeo e foi cortado em {fmt_time(plan.duration)}")
|
|
720
|
+
if f1 - f0 < 2:
|
|
721
|
+
plan.warnings.append(f"insert {i + 1} dura menos de 2 quadros e foi ignorado")
|
|
722
|
+
continue
|
|
723
|
+
kb_default = kind == "image"
|
|
724
|
+
split = item.get("split", 0.46)
|
|
725
|
+
try:
|
|
726
|
+
split = min(0.75, max(0.25, float(split if split is not None else 0.46)))
|
|
727
|
+
except (TypeError, ValueError):
|
|
728
|
+
raise ComposeError(f"inserts[{i}].split precisa ser um número entre 0.25 e 0.75", 2)
|
|
729
|
+
sfx_name = item.get("sfx", "whoosh")
|
|
730
|
+
if sfx_name in (None, False, "", "none"):
|
|
731
|
+
sfx_name = None
|
|
732
|
+
ins = Insert(idx=i, type=typ, src=src, kind=kind, f0=f0, f1=f1,
|
|
733
|
+
ken_burns=bool(item.get("ken_burns", kb_default)), split=split,
|
|
734
|
+
sfx=str(sfx_name) if (sfx_name and sfx_on) else None)
|
|
735
|
+
if tela_verde:
|
|
736
|
+
ins.item = item
|
|
737
|
+
if tela_verde and typ in ("split_top", "split_bottom"):
|
|
738
|
+
ins.to_bg = True # its "split" does not apply: it is the background of [f0, f1)
|
|
739
|
+
ins.content = (0, 0, W, H)
|
|
740
|
+
elif typ in ("split_top", "split_bottom"):
|
|
741
|
+
hb = even(split * H)
|
|
742
|
+
hs = H - hb
|
|
743
|
+
# eyes of the segment under this window, where the camera left them (focus_y is the eye line)
|
|
744
|
+
seg = segment_for(plan, f0, min(f1, plan.nf))
|
|
745
|
+
face = (seg["focus_y"] if seg else 0.40) * H
|
|
746
|
+
if typ == "split_top":
|
|
747
|
+
ins.content = (0, 0, W, hb)
|
|
748
|
+
ins.speaker = (0, hb, W, hs)
|
|
749
|
+
ins.seam_y = hb
|
|
750
|
+
else:
|
|
751
|
+
ins.content = (0, hs, W, hb)
|
|
752
|
+
ins.speaker = (0, 0, W, hs)
|
|
753
|
+
ins.seam_y = hs
|
|
754
|
+
ins.speaker_src_y = face_layout.speaker_src_y(typ, hb / H, face, W, H, safe_top, safe_bottom)
|
|
755
|
+
# the face can sit at another height in each take the window covers: re-frame per segment
|
|
756
|
+
pieces: list[tuple[int, int, int]] = []
|
|
757
|
+
for cs in plan.cam_segs:
|
|
758
|
+
a, b = max(f0, cs["f0"]), min(f1, cs["f1"])
|
|
759
|
+
if b <= a:
|
|
760
|
+
continue
|
|
761
|
+
core = None
|
|
762
|
+
if cs.get("face") and cs["face"].get("core"):
|
|
763
|
+
rects = [face_layout.zoom_rect(cs["face"]["core"], z, cs["focus_y"]) for z in {cs["zoom"], cs["zoom_max"]}]
|
|
764
|
+
core = (min(r["y0"] for r in rects) * H, max(r["y1"] for r in rects) * H)
|
|
765
|
+
reach = seam_reach[0] if typ == "split_bottom" else seam_reach[1]
|
|
766
|
+
y = face_layout.speaker_src_y(typ, hb / H, cs["focus_y"] * H, W, H, safe_top, safe_bottom,
|
|
767
|
+
core=core, clear=reach + GAP_FACE * H if reach else 0.0)
|
|
768
|
+
if pieces and pieces[-1][2] == y and pieces[-1][1] == a:
|
|
769
|
+
pieces[-1] = (pieces[-1][0], b, y)
|
|
770
|
+
else:
|
|
771
|
+
pieces.append((a, b, y))
|
|
772
|
+
ins.speaker_pieces = pieces or [(f0, f1, ins.speaker_src_y)]
|
|
773
|
+
ins.speaker_src_y = max(ins.speaker_pieces, key=lambda p: p[1] - p[0])[2]
|
|
774
|
+
elif typ == "full":
|
|
775
|
+
ins.content = (0, 0, W, H)
|
|
776
|
+
else: # pip
|
|
777
|
+
aspect = 1.0
|
|
778
|
+
if kind == "image":
|
|
779
|
+
with Image.open(src) as im:
|
|
780
|
+
im = ImageOps.exif_transpose(im)
|
|
781
|
+
aspect = im.width / max(1, im.height)
|
|
782
|
+
else:
|
|
783
|
+
try:
|
|
784
|
+
info = ffprobe_json(src)
|
|
785
|
+
v = next(st for st in info.get("streams", []) if st.get("codec_type") == "video")
|
|
786
|
+
aspect = int(v["width"]) / max(1, int(v["height"]))
|
|
787
|
+
except Exception:
|
|
788
|
+
aspect = 1.0
|
|
789
|
+
aspect = min(1.25, max(0.8, aspect))
|
|
790
|
+
border = max(2, int(round(10 * s)))
|
|
791
|
+
cw = even(380 * s)
|
|
792
|
+
ch = even(cw / aspect)
|
|
793
|
+
ow, oh = cw + 2 * border, ch + 2 * border
|
|
794
|
+
corner = str(item.get("corner") or "top_right")
|
|
795
|
+
if corner not in ("top_right", "top_left"):
|
|
796
|
+
raise ComposeError(f"inserts[{i}].corner inválido: {corner!r}. Use top_right ou top_left", 2)
|
|
797
|
+
margin_x = int(round(48 * s))
|
|
798
|
+
ox = W - margin_x - ow if corner == "top_right" else margin_x
|
|
799
|
+
oy = int(round(safe_top + 26 * s))
|
|
800
|
+
ins.card = (ox, oy, ow, oh)
|
|
801
|
+
ins.content = (ox + border, oy + border, cw, ch)
|
|
802
|
+
out.append(ins)
|
|
803
|
+
for k, a in enumerate(out):
|
|
804
|
+
for b in out[k + 1:]:
|
|
805
|
+
if b.f0 < a.f1 and a.f0 < b.f1 and a.to_bg == b.to_bg:
|
|
806
|
+
plan.warnings.append(f"inserts {a.idx + 1} e {b.idx + 1} se sobrepõem ({fmt_time(max(a.f0, b.f0) / plan.fps)}): "
|
|
807
|
+
f"o {b.idx + 1}, que vem depois na lista, fica por cima")
|
|
808
|
+
return out # list order = drawing order (a later insert is drawn over an earlier one)
|
|
809
|
+
|
|
810
|
+
|
|
811
|
+
def heic_to_jpg(plan: Plan, src: Path, i: int, where: str | None = None) -> Path:
|
|
812
|
+
"""iPhone photos (HEIC/HEIF) -> JPG once, with macOS `sips` (cached by the file signature)."""
|
|
813
|
+
where = where or f"inserts[{i}].src"
|
|
814
|
+
sips = shutil.which("sips") if sys.platform == "darwin" else None
|
|
815
|
+
if not sips:
|
|
816
|
+
raise ComposeError(f"{where} é uma foto HEIC do iPhone ({src.name}), que não abre aqui. "
|
|
817
|
+
f"Converta para JPG (no iPhone: Ajustes > Câmera > Formatos > Mais Compatível; ou abra e "
|
|
818
|
+
f"exporte como JPG) e use o arquivo .jpg", 2)
|
|
819
|
+
out_dir = plan.edit_dir / "renders" / "heic"
|
|
820
|
+
out_dir.mkdir(parents=True, exist_ok=True)
|
|
821
|
+
out = out_dir / f"{src.stem}_{short_hash((str(src), file_signature(src)))[:10]}.jpg"
|
|
822
|
+
if out.exists() and out.stat().st_size > 0:
|
|
823
|
+
return out
|
|
824
|
+
tmp = out.with_name(".tmp-" + out.name)
|
|
825
|
+
proc = subprocess.run([sips, "-s", "format", "jpeg", "-s", "formatOptions", "93", str(src), "--out", str(tmp)],
|
|
826
|
+
capture_output=True, text=True)
|
|
827
|
+
if proc.returncode != 0 or not tmp.exists():
|
|
828
|
+
raise ComposeError(f"não consegui converter a foto HEIC {where} ({src.name}) para JPG. "
|
|
829
|
+
f"Exporte a foto como JPG e use o arquivo .jpg", 2)
|
|
830
|
+
os.replace(tmp, out)
|
|
831
|
+
return out
|
|
832
|
+
|
|
833
|
+
|
|
834
|
+
# ============================================================== tela verde (SPEC Contract 13)
|
|
835
|
+
|
|
836
|
+
EDIT_TYPES = ("clean", "split_top", "split_bottom", "tela_verde")
|
|
837
|
+
|
|
838
|
+
|
|
839
|
+
def resolve_media(plan: Plan, value, where: str) -> tuple[Path, str]:
|
|
840
|
+
"""An image or video file of the edit -> (path, "image" | "video"), HEIC converted like the inserts."""
|
|
841
|
+
if not isinstance(value, str):
|
|
842
|
+
raise ComposeError(f"{where} precisa ser o caminho de um arquivo (ex: \"assets/foto.jpg\")", 2)
|
|
843
|
+
src = _resolve(plan.edit_dir, value, where)
|
|
844
|
+
ext = src.suffix.lower()
|
|
845
|
+
if ext in HEIC_EXT:
|
|
846
|
+
src, ext = heic_to_jpg(plan, src, 0, where), ".jpg"
|
|
847
|
+
kind = "image" if ext in IMAGE_EXT else "video" if ext in VIDEO_EXT else None
|
|
848
|
+
if kind is None:
|
|
849
|
+
try:
|
|
850
|
+
Image.open(src).close()
|
|
851
|
+
kind = "image"
|
|
852
|
+
except Exception:
|
|
853
|
+
kind = "video"
|
|
854
|
+
return src, kind
|
|
855
|
+
|
|
856
|
+
|
|
857
|
+
def plan_tela_verde(plan: Plan):
|
|
858
|
+
"""edit_type "tela_verde": check the section before anything renders (exit 2). None for the other types."""
|
|
859
|
+
et = plan.data.get("edit_type")
|
|
860
|
+
if et is None or et in EDIT_TYPES[:3]:
|
|
861
|
+
if plan.data.get("tela_verde") is not None:
|
|
862
|
+
plan.warnings.append("a seção tela_verde ficou de fora: para usar, ponha \"edit_type\": \"tela_verde\"")
|
|
863
|
+
return None
|
|
864
|
+
if et != "tela_verde":
|
|
865
|
+
raise ComposeError(f"edit_type {_as_json(et)} não existe. Use {', '.join(EDIT_TYPES)}", 2)
|
|
866
|
+
import matte as MT
|
|
867
|
+
import tela_verde as TV
|
|
868
|
+
|
|
869
|
+
try:
|
|
870
|
+
cfg = TV.parse_config(plan.data.get("tela_verde"), plan.fps, plan.nf,
|
|
871
|
+
lambda value, where: resolve_media(plan, value, where), plan.fr,
|
|
872
|
+
screen=(plan.full_w, plan.full_h))
|
|
873
|
+
MT.default_engine_ready()
|
|
874
|
+
except TV.TelaVerdeError as e:
|
|
875
|
+
raise ComposeError(str(e), e.code)
|
|
876
|
+
except MT.MatteError as e:
|
|
877
|
+
raise ComposeError(str(e), e.code)
|
|
878
|
+
return cfg # its warnings go to the report after the split inserts join it (insert_backgrounds)
|
|
879
|
+
|
|
880
|
+
|
|
881
|
+
def insert_backgrounds(plan: Plan, cfg, inserts: list[Insert]) -> None:
|
|
882
|
+
"""Tela verde: the split_top / split_bottom inserts become background items (they win over tela_verde.fundo
|
|
883
|
+
during their own frames); then the warnings of the section go to the report."""
|
|
884
|
+
import tela_verde as TV
|
|
885
|
+
|
|
886
|
+
moved = [i for i in inserts if i.to_bg]
|
|
887
|
+
try:
|
|
888
|
+
TV.add_insert_backgrounds(cfg, [{"insert": i.idx + 1, "type": i.type, "src": i.src, "kind": i.kind,
|
|
889
|
+
"f0": i.f0, "f1": i.f1, "ken_burns": i.ken_burns, "item": i.item}
|
|
890
|
+
for i in moved], plan.nf, plan.fps)
|
|
891
|
+
except TV.TelaVerdeError as e:
|
|
892
|
+
raise ComposeError(str(e), e.code)
|
|
893
|
+
for ins in inserts:
|
|
894
|
+
bad = [k for k in ("escurecer", "desfocar", "encaixe") if ins.item and k in ins.item and not ins.to_bg]
|
|
895
|
+
if bad:
|
|
896
|
+
plan.warnings.append(f"inserts[{ins.idx}].{bad[0]} só vale para imagem em cima ou embaixo na tela verde "
|
|
897
|
+
f"(que vira fundo): no {ins.type} ficou de fora")
|
|
898
|
+
plan.warnings.extend(cfg.warnings)
|
|
899
|
+
if moved:
|
|
900
|
+
names = ", ".join(str(i.idx + 1) for i in moved)
|
|
901
|
+
kinds = sorted({TV.SPLIT_LABEL[i.type] for i in moved})
|
|
902
|
+
log(f"tela verde: {'o insert' if len(moved) == 1 else 'os inserts'} {names} ({' e '.join(kinds)}) "
|
|
903
|
+
f"{'vai' if len(moved) == 1 else 'vão'} para o fundo, atrás de você (sem tela dividida)")
|
|
904
|
+
|
|
905
|
+
|
|
906
|
+
def prepare_tela_verde(plan: Plan, cfg) -> None:
|
|
907
|
+
"""Person segments, their mattes (computed now when not cached), placement, and the face and body of each
|
|
908
|
+
segment where they land on the screen (cam_segs switch to screen coordinates without zoom)."""
|
|
909
|
+
import matte as MT
|
|
910
|
+
import tela_verde as TV
|
|
911
|
+
|
|
912
|
+
t0 = time.time()
|
|
913
|
+
segs = []
|
|
914
|
+
for cs in plan.cam_segs:
|
|
915
|
+
has_face = bool(cs.get("face"))
|
|
916
|
+
mode = "pessoa" if (has_face or cfg.no_face == "recorte" or plan.faces is None) else "original"
|
|
917
|
+
push = cs["zoom_max"] / cs["zoom"] - 1.0 if cs["zoom_max"] > cs["zoom"] + 1e-9 else 0.0
|
|
918
|
+
segs.append(TV.Segment(f0=cs["f0"], f1=cs["f1"], mode=mode, zoom=cs["zoom"], push=push,
|
|
919
|
+
focus_y=cs["focus_y"], face=cs.get("face") if has_face else None))
|
|
920
|
+
people = [i for i, sg in enumerate(segs) if sg.mode == "pessoa"]
|
|
921
|
+
mset = None
|
|
922
|
+
mattes: dict[int, object] = {}
|
|
923
|
+
if people:
|
|
924
|
+
try:
|
|
925
|
+
mset = MT.ensure(plan.edit_dir, plan.base, [(segs[i].f0, segs[i].f1) for i in people], plan.fps,
|
|
926
|
+
(plan.full_w, plan.full_h), plan.timeline, progress=log,
|
|
927
|
+
base_fps=plan.base_fps, base_size=(plan.base_w, plan.base_h))
|
|
928
|
+
except MT.MatteError as e:
|
|
929
|
+
raise ComposeError(f"recorte da pessoa: {e}", e.code)
|
|
930
|
+
plan.warnings.extend(mset.warnings)
|
|
931
|
+
for i, job in zip(people, mset.jobs):
|
|
932
|
+
mattes[i] = job
|
|
933
|
+
segs[i].stats = job.meta.get("stats") or {}
|
|
934
|
+
if cfg.mirror: # the person segments are drawn flipped: place them as they will be seen
|
|
935
|
+
segs[i].stats = TV.mirror_stats(segs[i].stats)
|
|
936
|
+
segs[i].face = TV.mirror_face(segs[i].face)
|
|
937
|
+
TV.plan_segments(cfg, segs)
|
|
938
|
+
for sg in segs:
|
|
939
|
+
if sg.note:
|
|
940
|
+
plan.warnings.append(f"tela verde, trecho {fmt_time(sg.f0 / plan.fps)}-{fmt_time(sg.f1 / plan.fps)}: {sg.note}")
|
|
941
|
+
filled = merge_windows([(sg.f0, sg.f1) for sg in segs if sg.fill])
|
|
942
|
+
if filled:
|
|
943
|
+
spans = ", ".join(f"{fmt_time(a / plan.fps)}-{fmt_time(b / plan.fps)}" for a, b in filled)
|
|
944
|
+
plan.warnings.append(f"tela verde: em {spans} a câmera corta os dois lados do seu corpo, então ali você "
|
|
945
|
+
f"ocupa a largura toda da tela (um pouco mais baixo), para o corte não aparecer no meio "
|
|
946
|
+
f"da tela")
|
|
947
|
+
inner = TV.inner_cut(cfg, segs, plan.W, plan.H)
|
|
948
|
+
if inner:
|
|
949
|
+
plan.warnings.append(inner["aviso"])
|
|
950
|
+
for cs, sg in zip(plan.cam_segs, segs):
|
|
951
|
+
cs["tv_zoom"], cs["tv_zoom_max"], cs["tv_mode"] = cs["zoom"], cs["zoom_max"], sg.mode
|
|
952
|
+
if sg.mode == "pessoa":
|
|
953
|
+
z = TV.screen_zones(cfg, sg, plan.W, plan.H)
|
|
954
|
+
face = z["face"]
|
|
955
|
+
cs["face"] = ({"zone": face["zone"], "core": face["core"], "face": {"eyes_y": face["eyes_y"]}}
|
|
956
|
+
if face else None)
|
|
957
|
+
if face:
|
|
958
|
+
cs["focus_y"] = min(0.9, max(0.1, face["eyes_y"]))
|
|
959
|
+
cs["person"] = z["person"]
|
|
960
|
+
cs["zoom"] = cs["zoom_max"] = 1.0 # the zoom is already in the person layer
|
|
961
|
+
for b in cfg.backgrounds: # the main area of a still picture shown whole (its empty margin may be covered)
|
|
962
|
+
if b.fit == "inteiro" and b.kind == "image" and b.size:
|
|
963
|
+
try:
|
|
964
|
+
b.content = TV.content_box(TV.read_image(b.src))
|
|
965
|
+
except Exception as e: # noqa: BLE001
|
|
966
|
+
raise ComposeError(f"não consegui abrir a imagem do fundo ({b.name}): {b.src} ({e})", 2)
|
|
967
|
+
elif b.kind == "video" and b.src is not None:
|
|
968
|
+
# empty frames at its start and end; shown whole: its main area over the clip and a plain border
|
|
969
|
+
try:
|
|
970
|
+
info = TV.scan_video(b.src, plan.fps, plan.renders / "telaverde")
|
|
971
|
+
except Exception as e: # noqa: BLE001
|
|
972
|
+
raise ComposeError(f"não consegui ler o vídeo do fundo ({b.name}): {b.src} ({e})", 1)
|
|
973
|
+
b.pass_frames, b.lead, b.tail = int(info["frames"]), int(info["lead"]), int(info["tail"])
|
|
974
|
+
if b.fit == "inteiro" and b.size:
|
|
975
|
+
b.content = tuple(float(v) for v in info["content"])
|
|
976
|
+
b.quiet = bool(info["quiet"])
|
|
977
|
+
TV.plan_stages(cfg, segs, plan.W, plan.H, caption_room(plan))
|
|
978
|
+
plan.image_zones = TV.image_zones(cfg, segs, plan.W, plan.H)
|
|
979
|
+
plates, plate_rows = room_plates(plan, mattes)
|
|
980
|
+
plan.tv = {"cfg": cfg, "segments": segs, "mattes": mattes, "mset": mset, "inner_cut": inner,
|
|
981
|
+
"plates": plates, "plate_rows": plate_rows,
|
|
982
|
+
"matte_seconds": round(mset.seconds, 2) if mset else 0.0, "prepare_seconds": round(time.time() - t0, 2)}
|
|
983
|
+
cam = next((layer for layer in plan.layers if layer["type"] == "camera"), None)
|
|
984
|
+
if cam is not None:
|
|
985
|
+
cam["applied_to"] = "tela_verde"
|
|
986
|
+
|
|
987
|
+
|
|
988
|
+
def room_plates(plan: Plan, mattes: dict) -> tuple[dict, list[dict]]:
|
|
989
|
+
"""Tela verde: the room plate of each take (the ranges of one source video with the same framing), so the
|
|
990
|
+
render can take out the room a matte keeps. -> ({segment index: plate}, report rows)."""
|
|
991
|
+
import matte as MT
|
|
992
|
+
import tela_verde as TV
|
|
993
|
+
|
|
994
|
+
if not mattes or not plan.timeline or os.environ.get(TV.PLATE_ENV, "1") == "0":
|
|
995
|
+
return {}, []
|
|
996
|
+
groups: dict[str, list] = {}
|
|
997
|
+
names: dict[str, str] = {}
|
|
998
|
+
for i, job in mattes.items():
|
|
999
|
+
tseg = MT._timeline_segment(plan.timeline, job.f0, job.f1, plan.fps)
|
|
1000
|
+
if not tseg or tseg.get("source") is None:
|
|
1001
|
+
continue
|
|
1002
|
+
cam = str(tseg.get("imagem_de") or tseg.get("source")) # the camera the picture came from
|
|
1003
|
+
g = json.dumps([cam, tseg.get("crop")], sort_keys=True, ensure_ascii=False)
|
|
1004
|
+
groups.setdefault(g, []).append((i, job))
|
|
1005
|
+
names[g] = cam
|
|
1006
|
+
if not groups:
|
|
1007
|
+
return {}, []
|
|
1008
|
+
try:
|
|
1009
|
+
found, notes = TV.room_plates(plan.base, _matte_pre(plan), plan.base_fps, plan.fps,
|
|
1010
|
+
(plan.full_w, plan.full_h),
|
|
1011
|
+
{g: [(j.f0, j.f1, j) for _, j in parts] for g, parts in groups.items()},
|
|
1012
|
+
plan.edit_dir / MT.CACHE_DIR, progress=log)
|
|
1013
|
+
except (OSError, ValueError, MT.MatteError) as e:
|
|
1014
|
+
plan.warnings.append(f"tela verde: não consegui aprender o fundo do cenário ({e}); o recorte ficou como o "
|
|
1015
|
+
f"modelo fez")
|
|
1016
|
+
return {}, []
|
|
1017
|
+
plates, rows = {}, []
|
|
1018
|
+
for g, parts in groups.items():
|
|
1019
|
+
plate = found.get(g)
|
|
1020
|
+
for i, _ in parts:
|
|
1021
|
+
if plate is not None:
|
|
1022
|
+
plates[i] = plate
|
|
1023
|
+
rows.append({"tomada": names[g], "trechos": len(parts), "usado": plate is not None,
|
|
1024
|
+
"conhece": plate.known if plate is not None else None,
|
|
1025
|
+
"motivo": None if plate is not None else (notes.get(g) or None)})
|
|
1026
|
+
return plates, rows
|
|
1027
|
+
|
|
1028
|
+
|
|
1029
|
+
def compute_person_zones(plan: Plan, inserts: list[Insert]) -> None:
|
|
1030
|
+
"""plan.person_zones: where the cut-out body is on the screen, per frame interval (tela verde only)."""
|
|
1031
|
+
if not plan.tv:
|
|
1032
|
+
return
|
|
1033
|
+
W, H = plan.W, plan.H
|
|
1034
|
+
pieces = []
|
|
1035
|
+
for cs in plan.cam_segs:
|
|
1036
|
+
r = cs.get("person")
|
|
1037
|
+
rects = [(r["x0"] * W, r["y0"] * H, r["x1"] * W, r["y1"] * H)] if r else []
|
|
1038
|
+
pieces.append((cs["f0"], cs["f1"], rects))
|
|
1039
|
+
cuts = sorted({0, plan.nf} | {p[0] for p in pieces} | {p[1] for p in pieces}
|
|
1040
|
+
| {min(plan.nf, i.f0) for i in inserts} | {min(plan.nf, i.f1) for i in inserts})
|
|
1041
|
+
out: list[tuple] = []
|
|
1042
|
+
for a, b in zip(cuts, cuts[1:]):
|
|
1043
|
+
if b <= a:
|
|
1044
|
+
continue
|
|
1045
|
+
base = next((p for p in pieces if p[0] <= a < p[1]), None)
|
|
1046
|
+
rects = base[2] if base else []
|
|
1047
|
+
top = None
|
|
1048
|
+
for ins in inserts:
|
|
1049
|
+
if ins.type != "pip" and ins.f0 <= a and b <= ins.f1:
|
|
1050
|
+
top = ins
|
|
1051
|
+
if top is not None and top.type == "full":
|
|
1052
|
+
rects = []
|
|
1053
|
+
elif top is not None and top.speaker is not None:
|
|
1054
|
+
_, band_y, _, hs = top.speaker
|
|
1055
|
+
src_y = next((y for p0, p1, y in top.speaker_pieces if p0 <= a < p1), top.speaker_src_y)
|
|
1056
|
+
moved = []
|
|
1057
|
+
for r in rects:
|
|
1058
|
+
y0, y1 = max(band_y, r[1] - src_y + band_y), min(band_y + hs, r[3] - src_y + band_y)
|
|
1059
|
+
if y1 > y0:
|
|
1060
|
+
moved.append((r[0], y0, r[2], y1))
|
|
1061
|
+
rects = moved
|
|
1062
|
+
if out and out[-1][1] == a and out[-1][2] == rects:
|
|
1063
|
+
out[-1] = (out[-1][0], b, rects)
|
|
1064
|
+
else:
|
|
1065
|
+
out.append((a, b, rects))
|
|
1066
|
+
plan.person_zones = out
|
|
1067
|
+
|
|
1068
|
+
|
|
1069
|
+
def person_bands(plan: Plan, f0: int, f1: int, gap: float = 0.0,
|
|
1070
|
+
skip: list[tuple[int, int]] | tuple = ()) -> list[tuple[float, float]]:
|
|
1071
|
+
"""Vertical bands taken by the cut-out body during [f0, f1) (empty unless tela verde)."""
|
|
1072
|
+
out = []
|
|
1073
|
+
for a, b, rects in plan.person_zones:
|
|
1074
|
+
if b <= f0 or a >= f1:
|
|
1075
|
+
continue
|
|
1076
|
+
lo_f, hi_f = max(a, f0), min(b, f1)
|
|
1077
|
+
if any(h0 <= lo_f and hi_f <= h1 for h0, h1 in skip):
|
|
1078
|
+
continue
|
|
1079
|
+
for r in rects:
|
|
1080
|
+
out.append((r[1] - gap, r[3] + gap))
|
|
1081
|
+
return out
|
|
1082
|
+
|
|
1083
|
+
|
|
1084
|
+
def image_bands(plan: Plan, f0: int, f1: int, gap: float = 0.0,
|
|
1085
|
+
skip: list[tuple[int, int]] | tuple = ()) -> list[tuple[float, float]]:
|
|
1086
|
+
"""Vertical bands taken by the main area of a whole background picture during [f0, f1) (tela verde only)."""
|
|
1087
|
+
out = []
|
|
1088
|
+
for a, b, r in plan.image_zones:
|
|
1089
|
+
if b <= f0 or a >= f1:
|
|
1090
|
+
continue
|
|
1091
|
+
lo_f, hi_f = max(a, f0), min(b, f1)
|
|
1092
|
+
if any(h0 <= lo_f and hi_f <= h1 for h0, h1 in skip):
|
|
1093
|
+
continue
|
|
1094
|
+
out.append((r[1] - gap, r[3] + gap))
|
|
1095
|
+
return out
|
|
1096
|
+
|
|
1097
|
+
|
|
1098
|
+
def caption_room(plan: Plan):
|
|
1099
|
+
"""Tela verde: the height auto captions need between a whole background picture and the head, for the frame
|
|
1100
|
+
spans of that picture (0 when the captions are off, manual or silent there)."""
|
|
1101
|
+
cap = _section(plan.data, "captions")
|
|
1102
|
+
if not _enabled(cap):
|
|
1103
|
+
return None
|
|
1104
|
+
try:
|
|
1105
|
+
manual = None if _is_auto(cap.get("center_y")) else float(cap.get("center_y")) * plan.s
|
|
1106
|
+
cues = load_json(plan.edit_dir / str(cap.get("file") or "captions.json")) or {}
|
|
1107
|
+
style = cap.get("style") or (cues.get("style") if isinstance(cues, dict) else None) \
|
|
1108
|
+
or (load_style_catalog().get("defaults") or {}).get("caption_style") or "impacto"
|
|
1109
|
+
above, below = tr.caption_extent(cues, style, (plan.W, plan.H), float(cap.get("font_scale", 1.0) or 1.0),
|
|
1110
|
+
plan.fps)
|
|
1111
|
+
except (ValueError, TypeError, OSError, AttributeError):
|
|
1112
|
+
return None # build_tracks reports what is wrong with the captions
|
|
1113
|
+
if manual is not None:
|
|
1114
|
+
# a height written by hand keeps its place unless it lands on the face (then it is placed like auto)
|
|
1115
|
+
gap = GAP_FACE * plan.H
|
|
1116
|
+
faces = [(cs["face"]["zone"]["y0"] * plan.H - gap, cs["face"]["zone"]["y1"] * plan.H + gap)
|
|
1117
|
+
for cs in plan.cam_segs if cs.get("tv_mode") == "pessoa" and (cs.get("face") or {}).get("zone")]
|
|
1118
|
+
if not any(manual - above < b and a < manual + below for a, b in faces):
|
|
1119
|
+
return None
|
|
1120
|
+
spans = []
|
|
1121
|
+
for cue in (cues.get("cues") if isinstance(cues, dict) else cues) or []:
|
|
1122
|
+
try:
|
|
1123
|
+
spans.append((plan.fr(float(cue["start"])), plan.fr(float(cue["end"]))))
|
|
1124
|
+
except (KeyError, TypeError, ValueError):
|
|
1125
|
+
continue
|
|
1126
|
+
need = above + below + GAP_FACE * plan.H
|
|
1127
|
+
|
|
1128
|
+
def room(item_spans: list[tuple[int, int]]) -> float:
|
|
1129
|
+
heard = any(a < f1 and f0 < b for f0, f1 in item_spans for a, b in spans)
|
|
1130
|
+
return need if heard else 0.0
|
|
1131
|
+
return room
|
|
1132
|
+
|
|
1133
|
+
|
|
1134
|
+
def place_pips_tela_verde(plan: Plan, inserts: list[Insert]) -> None:
|
|
1135
|
+
"""Tela verde: a pip card goes to the top corner away from the person (the one asked, when it is free of the
|
|
1136
|
+
face), and to the other corner when the face would be under it."""
|
|
1137
|
+
cfg = plan.tv["cfg"]
|
|
1138
|
+
W, s = plan.W, plan.s
|
|
1139
|
+
for ins in inserts:
|
|
1140
|
+
if ins.type != "pip" or not ins.card:
|
|
1141
|
+
continue
|
|
1142
|
+
ox, oy, ow, oh = ins.card
|
|
1143
|
+
margin_x = int(round(48 * s))
|
|
1144
|
+
corners = {"top_right": W - margin_x - ow, "top_left": margin_x}
|
|
1145
|
+
asked = (ins.item or {}).get("corner")
|
|
1146
|
+
away = "top_left" if cfg.position == "direita" else "top_right"
|
|
1147
|
+
order = [asked, "top_right" if asked == "top_left" else "top_left"] if asked else \
|
|
1148
|
+
[away, "top_right" if away == "top_left" else "top_left"]
|
|
1149
|
+
faces = []
|
|
1150
|
+
for cs in plan.cam_segs:
|
|
1151
|
+
face = cs.get("face")
|
|
1152
|
+
if cs.get("tv_mode") != "pessoa" or not face or not face.get("zone") or cs["f1"] <= ins.f0 or cs["f0"] >= ins.f1:
|
|
1153
|
+
continue
|
|
1154
|
+
z = face["zone"]
|
|
1155
|
+
faces.append((z["x0"] * W, z["y0"] * plan.H, z["x1"] * W, z["y1"] * plan.H))
|
|
1156
|
+
pad = GAP_FACE * plan.H
|
|
1157
|
+
chosen = None
|
|
1158
|
+
for corner in order:
|
|
1159
|
+
rect = (corners[corner] - pad, oy - pad, corners[corner] + ow + pad, oy + oh + pad)
|
|
1160
|
+
if not any(boxes_hit(rect, f) for f in faces):
|
|
1161
|
+
chosen = corner
|
|
1162
|
+
break
|
|
1163
|
+
if chosen is None:
|
|
1164
|
+
chosen = order[0]
|
|
1165
|
+
plan.warnings.append(f"o cartão do insert {ins.idx + 1} (pip) encosta no rosto nos dois cantos de cima: "
|
|
1166
|
+
f"mude o tempo dele ou use a tela verde em outra posição")
|
|
1167
|
+
elif asked and chosen != asked:
|
|
1168
|
+
plan.adjustments.append({"layer": f"insert {ins.idx + 1}", "action": "moved", "start": round(ins.f0 / plan.fps, 3),
|
|
1169
|
+
"end": round(ins.f1 / plan.fps, 3), "because": "rosto (tela verde)",
|
|
1170
|
+
"corner_asked": asked, "corner": chosen})
|
|
1171
|
+
plan.warnings.append(f"o cartão do insert {ins.idx + 1} (pip) foi para o canto "
|
|
1172
|
+
f"{'esquerdo' if chosen == 'top_left' else 'direito'} para não cobrir o rosto")
|
|
1173
|
+
x = corners[chosen]
|
|
1174
|
+
border = ins.content[0] - ox
|
|
1175
|
+
ins.card = (x, oy, ow, oh)
|
|
1176
|
+
ins.content = (x + border, ins.content[1], ins.content[2], ins.content[3])
|
|
1177
|
+
|
|
1178
|
+
|
|
1179
|
+
def render_tela_verde(plan: Plan) -> dict:
|
|
1180
|
+
import tela_verde as TV
|
|
1181
|
+
|
|
1182
|
+
tv = plan.tv
|
|
1183
|
+
job = TV.Job(cfg=tv["cfg"], segments=tv["segments"], mattes=tv["mattes"], base=plan.base,
|
|
1184
|
+
base_pre=_matte_pre(plan), base_fps=plan.base_fps, fps=plan.fps, size=(plan.W, plan.H),
|
|
1185
|
+
full_size=(plan.full_w, plan.full_h), nf=plan.nf, out_dir=plan.renders / "telaverde",
|
|
1186
|
+
plates=tv.get("plates") or {})
|
|
1187
|
+
try:
|
|
1188
|
+
return TV.render(job, progress=log)
|
|
1189
|
+
except TV.TelaVerdeError as e:
|
|
1190
|
+
raise ComposeError(str(e), e.code)
|
|
1191
|
+
|
|
1192
|
+
|
|
1193
|
+
def _matte_pre(plan: Plan) -> list[str]:
|
|
1194
|
+
import matte as MT
|
|
1195
|
+
return MT.base_pre_filters(plan.base_fps, (plan.base_w, plan.base_h), plan.fps, (plan.full_w, plan.full_h))
|
|
1196
|
+
|
|
1197
|
+
|
|
1198
|
+
def tela_verde_layer(plan: Plan, clip: dict) -> dict:
|
|
1199
|
+
import tela_verde as TV
|
|
1200
|
+
|
|
1201
|
+
tv = plan.tv
|
|
1202
|
+
cfg = tv["cfg"]
|
|
1203
|
+
fps = plan.fps
|
|
1204
|
+
fundo = TV.report_backgrounds(cfg, fps, plan.W, plan.H, 1.0 / plan.s, plan.rel)
|
|
1205
|
+
moved = [{"insert": b.insert, "type": b.insert_type, "src": plan.rel(b.src), "kind": b.kind,
|
|
1206
|
+
"start": round(b.f0 / fps, 3), "end": round(b.f1 / fps, 3), "encaixe": b.fit,
|
|
1207
|
+
"encaixe_pedido": b.fit_asked, "ken_burns": b.ken_burns, "escurecer": b.darken, "desfocar": b.blur,
|
|
1208
|
+
"rect": [int(round(v / plan.s)) for v in b.place] if b.place else None,
|
|
1209
|
+
"stage": [int(round(v / plan.s)) for v in b.stage] if b.stage else None}
|
|
1210
|
+
for b in cfg.from_inserts]
|
|
1211
|
+
mset = tv["mset"]
|
|
1212
|
+
return {"type": "tela_verde", "posicao": cfg.position, "altura": cfg.height,
|
|
1213
|
+
"contorno": ({"cor": "#%02X%02X%02X" % tuple(cfg.outline[0]), "largura": cfg.outline[1]}
|
|
1214
|
+
if cfg.outline else None),
|
|
1215
|
+
"sombra": cfg.shadow, "fundo_original_desfocado": cfg.blurred_original, "sem_rosto": cfg.no_face,
|
|
1216
|
+
"espelhar": cfg.mirror,
|
|
1217
|
+
"corte_dentro": ({k: v for k, v in tv["inner_cut"].items() if k != "aviso"} if tv.get("inner_cut") else None),
|
|
1218
|
+
"fundo": fundo, "inserts_no_fundo": moved,
|
|
1219
|
+
"segments": TV.report_segments(cfg, tv["segments"], fps, plan.W, plan.H, 1.0 / plan.s),
|
|
1220
|
+
"matte": mset.report(plan.rel) if mset else None,
|
|
1221
|
+
"fundo_do_cenario": tv.get("plate_rows") or [],
|
|
1222
|
+
"clip": plan.rel(Path(clip["file"])), "cached": bool(clip.get("cached")),
|
|
1223
|
+
"seconds": {"matte": tv["matte_seconds"], "compose": clip.get("render_seconds", 0.0)}}
|
|
1224
|
+
|
|
1225
|
+
|
|
1226
|
+
def _kb_path(ins: Insert, n: int, pw: float, ph: float, cw: int, ch: int):
|
|
1227
|
+
"""Per-frame (window_w, window_h, center_x, center_y) in prescaled source pixels."""
|
|
1228
|
+
zoom_in = ins.idx % 2 == 0
|
|
1229
|
+
z0, z1 = (1.0, 1.0 + KB_DRIFT) if zoom_in else (1.0 + KB_DRIFT, 1.0)
|
|
1230
|
+
base_w, base_h = cw * (1.0 + KB_DRIFT), ch * (1.0 + KB_DRIFT)
|
|
1231
|
+
dirs = [(1, -0.5), (-1, 0.5), (0.6, 1), (-0.6, -1)]
|
|
1232
|
+
dx, dy = dirs[ins.idx % 4]
|
|
1233
|
+
frames = []
|
|
1234
|
+
for j in range(n):
|
|
1235
|
+
p = ease_in_out(j / max(1, n - 1))
|
|
1236
|
+
z = z0 + (z1 - z0) * p
|
|
1237
|
+
ww, wh = base_w / z, base_h / z
|
|
1238
|
+
# pan inside whatever room the window leaves (at most 3% of the window)
|
|
1239
|
+
room_x = max(0.0, (pw - base_w) / 2.0) + (base_w - ww) / 2.0
|
|
1240
|
+
room_y = max(0.0, (ph - base_h) / 2.0) + (base_h - wh) / 2.0
|
|
1241
|
+
span_x = min(room_x, 0.03 * ww) * dx
|
|
1242
|
+
span_y = min(room_y, 0.03 * wh) * dy
|
|
1243
|
+
cx = pw / 2.0 + span_x * (2 * p - 1)
|
|
1244
|
+
cy = ph / 2.0 + span_y * (2 * p - 1)
|
|
1245
|
+
cx = min(max(cx, ww / 2.0), pw - ww / 2.0)
|
|
1246
|
+
cy = min(max(cy, wh / 2.0), ph - wh / 2.0)
|
|
1247
|
+
frames.append((ww, wh, cx, cy))
|
|
1248
|
+
return frames
|
|
1249
|
+
|
|
1250
|
+
|
|
1251
|
+
def _encoder(out_tmp: Path, w: int, h: int, fps: int, n: int) -> subprocess.Popen:
|
|
1252
|
+
return subprocess.Popen(
|
|
1253
|
+
["ffmpeg", "-hide_banner", "-nostdin", "-v", "error", "-y", "-f", "rawvideo", "-pix_fmt", "rgb24",
|
|
1254
|
+
"-s", f"{w}x{h}", "-framerate", str(fps), "-i", "-",
|
|
1255
|
+
"-vf", "scale=out_color_matrix=bt709:out_range=tv,format=yuv420p",
|
|
1256
|
+
"-c:v", "libx264", "-preset", "veryfast", "-crf", "12", "-g", str(fps),
|
|
1257
|
+
"-colorspace", "bt709", "-color_primaries", "bt709", "-color_trc", "bt709", "-color_range", "tv",
|
|
1258
|
+
"-frames:v", str(n), "-f", "mp4", str(out_tmp)],
|
|
1259
|
+
stdin=subprocess.PIPE, stderr=subprocess.PIPE)
|
|
1260
|
+
|
|
1261
|
+
|
|
1262
|
+
def render_insert_clip(plan: Plan, ins: Insert) -> Path:
|
|
1263
|
+
cw, ch = ins.content[2], ins.content[3]
|
|
1264
|
+
n = ins.f1 - ins.f0
|
|
1265
|
+
st = ins.src.stat()
|
|
1266
|
+
key = short_hash((RENDER_VERSION, str(ins.src), st.st_size, st.st_mtime_ns, ins.kind, cw, ch, n, plan.fps,
|
|
1267
|
+
ins.ken_burns, ins.idx % 4))
|
|
1268
|
+
out_dir = plan.renders / "inserts"
|
|
1269
|
+
out_dir.mkdir(parents=True, exist_ok=True)
|
|
1270
|
+
out = out_dir / f"ins_{key}.mp4"
|
|
1271
|
+
if out.exists() and out.stat().st_size > 0:
|
|
1272
|
+
return out
|
|
1273
|
+
tmp = out.with_name(".tmp-" + out.name)
|
|
1274
|
+
margin = 1.0 + KB_DRIFT if ins.ken_burns else 1.0
|
|
1275
|
+
|
|
1276
|
+
if ins.kind == "video" and not ins.ken_burns:
|
|
1277
|
+
ffmpeg_cmd(["-v", "error", "-y", "-stream_loop", "-1", "-i", ins.src, "-an",
|
|
1278
|
+
"-vf", (f"fps={plan.fps},scale={cw}:{ch}:force_original_aspect_ratio=increase:flags=lanczos,"
|
|
1279
|
+
f"crop={cw}:{ch},setsar=1,scale=out_color_matrix=bt709:out_range=tv,format=yuv420p"),
|
|
1280
|
+
"-c:v", "libx264", "-preset", "veryfast", "-crf", "12", "-g", str(plan.fps),
|
|
1281
|
+
"-colorspace", "bt709", "-color_primaries", "bt709", "-color_trc", "bt709", "-color_range", "tv",
|
|
1282
|
+
"-frames:v", n, "-f", "mp4", tmp], f"preparar o vídeo do insert {ins.idx + 1}")
|
|
1283
|
+
os.replace(tmp, out)
|
|
1284
|
+
return out
|
|
1285
|
+
|
|
1286
|
+
pw_target, ph_target = cw * margin, ch * margin
|
|
1287
|
+
if ins.kind == "image":
|
|
1288
|
+
with Image.open(ins.src) as im:
|
|
1289
|
+
im = ImageOps.exif_transpose(im)
|
|
1290
|
+
if im.mode in ("RGBA", "LA", "P"):
|
|
1291
|
+
rgba = im.convert("RGBA")
|
|
1292
|
+
bg = Image.new("RGB", rgba.size, (18, 18, 18))
|
|
1293
|
+
bg.paste(rgba, mask=rgba.getchannel("A"))
|
|
1294
|
+
src_img = bg
|
|
1295
|
+
else:
|
|
1296
|
+
src_img = im.convert("RGB")
|
|
1297
|
+
sc = max(pw_target / src_img.width, ph_target / src_img.height)
|
|
1298
|
+
pw, ph = max(cw, int(math.ceil(src_img.width * sc))), max(ch, int(math.ceil(src_img.height * sc)))
|
|
1299
|
+
pre = src_img.resize((pw, ph), Image.LANCZOS)
|
|
1300
|
+
|
|
1301
|
+
def frames_iter():
|
|
1302
|
+
while True:
|
|
1303
|
+
yield pre
|
|
1304
|
+
else:
|
|
1305
|
+
pw, ph = int(math.ceil(pw_target)), int(math.ceil(ph_target))
|
|
1306
|
+
dec = subprocess.Popen(
|
|
1307
|
+
["ffmpeg", "-hide_banner", "-nostdin", "-v", "error", "-stream_loop", "-1", "-i", str(ins.src), "-an",
|
|
1308
|
+
"-vf", f"fps={plan.fps},scale={pw}:{ph}:force_original_aspect_ratio=increase:flags=lanczos,crop={pw}:{ph},format=rgb24",
|
|
1309
|
+
"-frames:v", str(n), "-f", "rawvideo", "-pix_fmt", "rgb24", "-"],
|
|
1310
|
+
stdout=subprocess.PIPE, stderr=subprocess.DEVNULL)
|
|
1311
|
+
|
|
1312
|
+
def frames_iter():
|
|
1313
|
+
size = pw * ph * 3
|
|
1314
|
+
last = None
|
|
1315
|
+
while True:
|
|
1316
|
+
buf = dec.stdout.read(size) if dec.stdout else b""
|
|
1317
|
+
if len(buf) == size:
|
|
1318
|
+
last = Image.frombuffer("RGB", (pw, ph), buf, "raw", "RGB", 0, 1)
|
|
1319
|
+
if last is None:
|
|
1320
|
+
raise ComposeError(f"não consegui ler o vídeo do insert {ins.idx + 1}: {ins.src}")
|
|
1321
|
+
yield last
|
|
1322
|
+
|
|
1323
|
+
enc = _encoder(tmp, cw, ch, plan.fps, n)
|
|
1324
|
+
try:
|
|
1325
|
+
path = _kb_path(ins, n, pw, ph, cw, ch) if ins.ken_burns else None
|
|
1326
|
+
it = frames_iter()
|
|
1327
|
+
for j in range(n):
|
|
1328
|
+
frame = next(it)
|
|
1329
|
+
if path is None:
|
|
1330
|
+
x0, y0 = (frame.width - cw) // 2, (frame.height - ch) // 2
|
|
1331
|
+
img = frame.crop((x0, y0, x0 + cw, y0 + ch))
|
|
1332
|
+
else:
|
|
1333
|
+
ww, wh, cx, cy = path[j]
|
|
1334
|
+
qx, qy = ww / cw, wh / ch
|
|
1335
|
+
img = frame.transform((cw, ch), Image.AFFINE,
|
|
1336
|
+
(qx, 0.0, cx - ww / 2.0, 0.0, qy, cy - wh / 2.0),
|
|
1337
|
+
resample=Image.BICUBIC)
|
|
1338
|
+
enc.stdin.write(img.tobytes())
|
|
1339
|
+
enc.stdin.close()
|
|
1340
|
+
err = enc.stderr.read().decode("utf-8", "replace") if enc.stderr else ""
|
|
1341
|
+
if enc.wait() != 0:
|
|
1342
|
+
raise ComposeError(f"falha ao codificar o insert {ins.idx + 1}: {err[-500:]}")
|
|
1343
|
+
except BrokenPipeError:
|
|
1344
|
+
err = enc.stderr.read().decode("utf-8", "replace") if enc.stderr else ""
|
|
1345
|
+
raise ComposeError(f"falha ao codificar o insert {ins.idx + 1}: {err[-500:]}")
|
|
1346
|
+
finally:
|
|
1347
|
+
if ins.kind == "video":
|
|
1348
|
+
try:
|
|
1349
|
+
dec.kill() # type: ignore[possibly-undefined]
|
|
1350
|
+
except Exception:
|
|
1351
|
+
pass
|
|
1352
|
+
os.replace(tmp, out)
|
|
1353
|
+
return out
|
|
1354
|
+
|
|
1355
|
+
|
|
1356
|
+
# ============================================================== motion (animated texts and numbers, SPEC Contract 12)
|
|
1357
|
+
|
|
1358
|
+
MOTION_KEYS = ("template", "start", "end", "duration", "params", "region", "sfx", "enabled", "id", "note")
|
|
1359
|
+
MOTION_REGIONS = {"auto": None, "top": "topo", "middle": "centro", "center": "centro", "bottom": "baixo",
|
|
1360
|
+
"topo": "topo", "centro": "centro", "baixo": "baixo", "faixa_topo": "faixa_topo", "tela": "tela"}
|
|
1361
|
+
MOTION_EXAMPLE = '{"template": "checklist", "start": 12.3, "end": 15.0, "params": {"items": ["Acesso na hora"]}}'
|
|
1362
|
+
MOTION_CAPTION_SHIFT = 150.0 # design px: an auto item steps aside from the captions only when that moves it this little
|
|
1363
|
+
MOTION_GAP = 23.0 # design px between an auto item and a face or a text above it in priority
|
|
1364
|
+
|
|
1365
|
+
|
|
1366
|
+
def _of_names(names: list[str]) -> str:
|
|
1367
|
+
"""['chamada', 'gancho', 'animação 2'] -> 'da chamada, do gancho e da animação 2' (each name once)."""
|
|
1368
|
+
seen = list(dict.fromkeys(names))
|
|
1369
|
+
parts = [("do " if n == "gancho" else "da ") + n for n in seen]
|
|
1370
|
+
return parts[0] if len(parts) == 1 else ", ".join(parts[:-1]) + " e " + parts[-1]
|
|
1371
|
+
|
|
1372
|
+
|
|
1373
|
+
@dataclass
|
|
1374
|
+
class Motion:
|
|
1375
|
+
idx: int # 1-based position in the list (as the report and the warnings call it)
|
|
1376
|
+
template: str
|
|
1377
|
+
item: object # motion.render.MotionItem
|
|
1378
|
+
f0: int
|
|
1379
|
+
f1: int
|
|
1380
|
+
auto: bool # region "auto": the template's place, slid off faces and texts
|
|
1381
|
+
sfx: bool
|
|
1382
|
+
region_asked: object
|
|
1383
|
+
clip: dict | None = None
|
|
1384
|
+
ink: tuple | None = None # render px on the output canvas (what the clip really draws)
|
|
1385
|
+
shown: list = field(default_factory=list)
|
|
1386
|
+
cues: list = field(default_factory=list)
|
|
1387
|
+
|
|
1388
|
+
@property
|
|
1389
|
+
def name(self) -> str:
|
|
1390
|
+
return f"animação {self.idx} ({self.template})"
|
|
1391
|
+
|
|
1392
|
+
|
|
1393
|
+
def _motion_items(data: dict) -> list | None:
|
|
1394
|
+
"""The list under "motion": a list, or {"enabled": bool, "items": [...]}. None when it is off or absent."""
|
|
1395
|
+
raw = data.get("motion")
|
|
1396
|
+
if raw is None:
|
|
1397
|
+
return None
|
|
1398
|
+
if isinstance(raw, dict):
|
|
1399
|
+
if raw.get("enabled", True) is False:
|
|
1400
|
+
return None
|
|
1401
|
+
extra = sorted(set(raw) - {"enabled", "items", "tema", "quantidade"})
|
|
1402
|
+
if extra:
|
|
1403
|
+
raise ComposeError(f"motion tem campos desconhecidos: {', '.join(extra)} (use \"items\", \"enabled\", "
|
|
1404
|
+
f"\"tema\" e \"quantidade\")", 2)
|
|
1405
|
+
raw = raw.get("items", [])
|
|
1406
|
+
if not isinstance(raw, list):
|
|
1407
|
+
raise ComposeError(f"motion precisa ser uma lista de animações (ex: \"motion\": [{MOTION_EXAMPLE}])", 2)
|
|
1408
|
+
return raw
|
|
1409
|
+
|
|
1410
|
+
|
|
1411
|
+
def _motion_region(value, where: str):
|
|
1412
|
+
"""(region spec for the engine or None for the template's own place, auto?)"""
|
|
1413
|
+
if value is None:
|
|
1414
|
+
return None, True
|
|
1415
|
+
if isinstance(value, str):
|
|
1416
|
+
key = value.strip().lower()
|
|
1417
|
+
if key not in MOTION_REGIONS:
|
|
1418
|
+
import difflib
|
|
1419
|
+
near = difflib.get_close_matches(key, list(MOTION_REGIONS), n=1, cutoff=0.6)
|
|
1420
|
+
hint = f" Você quis dizer \"{near[0]}\"?" if near else ""
|
|
1421
|
+
raise ComposeError(f"{where}.region \"{value}\" não existe.{hint} Use \"auto\", \"top\", \"middle\", \"bottom\" "
|
|
1422
|
+
f"ou {{\"x\", \"y\", \"w\", \"h\"}} em pixels da tela 1080x1920", 2)
|
|
1423
|
+
spec = MOTION_REGIONS[key]
|
|
1424
|
+
return spec, spec is None
|
|
1425
|
+
box = None
|
|
1426
|
+
if isinstance(value, dict) and set(value) == {"x", "y", "w", "h"}:
|
|
1427
|
+
box = [value["x"], value["y"], value["w"], value["h"]]
|
|
1428
|
+
elif isinstance(value, list) and len(value) == 4:
|
|
1429
|
+
box = value
|
|
1430
|
+
nums = [_plain_number(v) for v in box] if box is not None else None
|
|
1431
|
+
if not nums or any(v is None for v in nums) or nums[2] < 20 or nums[3] < 20:
|
|
1432
|
+
raise ComposeError(f"{where}.region precisa ser \"auto\", \"top\", \"middle\", \"bottom\" ou {{\"x\": 140, \"y\": 1000, "
|
|
1433
|
+
f"\"w\": 800, \"h\": 520}} (pixels da tela 1080x1920, largura e altura de pelo menos 20) "
|
|
1434
|
+
f"(veio {_as_json(value)})", 2)
|
|
1435
|
+
return nums, False
|
|
1436
|
+
|
|
1437
|
+
|
|
1438
|
+
def _motion_theme(data: dict) -> str | None:
|
|
1439
|
+
"""The look chosen for all the animations ("motion": {"tema": "papelaria"}), or None."""
|
|
1440
|
+
raw = data.get("motion")
|
|
1441
|
+
tema = raw.get("tema") if isinstance(raw, dict) else None
|
|
1442
|
+
if tema is None:
|
|
1443
|
+
return None
|
|
1444
|
+
from motion import engine as ME
|
|
1445
|
+
if tema not in ME.THEME_NAMES:
|
|
1446
|
+
raise ComposeError(f"motion.tema \"{tema}\" não existe (use {', '.join(ME.THEME_NAMES)})", 2)
|
|
1447
|
+
return str(tema)
|
|
1448
|
+
|
|
1449
|
+
|
|
1450
|
+
def plan_motion(plan: Plan) -> list[Motion]:
|
|
1451
|
+
"""edit_data "motion": check every item and decide its frames before anything renders (exit 2 on a mistake)."""
|
|
1452
|
+
raw = _motion_items(plan.data)
|
|
1453
|
+
if not raw:
|
|
1454
|
+
return []
|
|
1455
|
+
from motion import engine as ME
|
|
1456
|
+
from motion import registry as MRg
|
|
1457
|
+
from motion import render as MR
|
|
1458
|
+
|
|
1459
|
+
out: list[Motion] = []
|
|
1460
|
+
for i, entry in enumerate(raw):
|
|
1461
|
+
where = f"motion[{i}]"
|
|
1462
|
+
n = i + 1
|
|
1463
|
+
if not isinstance(entry, dict):
|
|
1464
|
+
raise ComposeError(f"{where} precisa ser um objeto (ex: {MOTION_EXAMPLE})", 2)
|
|
1465
|
+
if entry.get("enabled", True) is False:
|
|
1466
|
+
continue
|
|
1467
|
+
unknown = [k for k in entry if k not in MOTION_KEYS]
|
|
1468
|
+
if unknown:
|
|
1469
|
+
import difflib
|
|
1470
|
+
near = difflib.get_close_matches(str(unknown[0]), list(MOTION_KEYS), n=1, cutoff=0.6)
|
|
1471
|
+
hint = f" Você quis dizer \"{near[0]}\"?" if near else ""
|
|
1472
|
+
raise ComposeError(f"{where}.{unknown[0]} não existe.{hint} Campos: {', '.join(MOTION_KEYS[:7])}", 2)
|
|
1473
|
+
tid = entry.get("template")
|
|
1474
|
+
if not isinstance(tid, str) or not tid.strip():
|
|
1475
|
+
names = ", ".join(sorted(MRg.templates()))
|
|
1476
|
+
raise ComposeError(f"{where} precisa de \"template\": o modelo da animação ({names})", 2)
|
|
1477
|
+
tid = tid.strip()
|
|
1478
|
+
start = _plain_number(entry.get("start"))
|
|
1479
|
+
if start is None or start < 0:
|
|
1480
|
+
if "start" not in entry:
|
|
1481
|
+
raise ComposeError(f"{where} precisa de \"start\": o segundo do corte em que a animação começa "
|
|
1482
|
+
f"(ex: {MOTION_EXAMPLE})", 2)
|
|
1483
|
+
raise ComposeError(f"{where}.start precisa ser um número de segundos, 0 ou mais "
|
|
1484
|
+
f"(veio {_as_json(entry.get('start'))})", 2)
|
|
1485
|
+
end = dur = None
|
|
1486
|
+
if entry.get("end") is not None and entry.get("duration") is not None:
|
|
1487
|
+
raise ComposeError(f"{where}: use \"end\" ou \"duration\", não os dois", 2)
|
|
1488
|
+
if entry.get("end") is not None:
|
|
1489
|
+
end = _plain_number(entry.get("end"))
|
|
1490
|
+
if end is None or end <= start:
|
|
1491
|
+
raise ComposeError(f"{where}.end precisa ser um número de segundos maior que o start ({start:g}) "
|
|
1492
|
+
f"(veio {_as_json(entry.get('end'))})", 2)
|
|
1493
|
+
if entry.get("duration") is not None:
|
|
1494
|
+
dur = _plain_number(entry.get("duration"))
|
|
1495
|
+
if dur is None or dur <= 0:
|
|
1496
|
+
raise ComposeError(f"{where}.duration precisa ser um número de segundos maior que 0 "
|
|
1497
|
+
f"(veio {_as_json(entry.get('duration'))})", 2)
|
|
1498
|
+
params = entry.get("params")
|
|
1499
|
+
if params is None:
|
|
1500
|
+
params = {}
|
|
1501
|
+
tema = _motion_theme(plan.data)
|
|
1502
|
+
if tema and isinstance(params, dict) and "tema" not in params:
|
|
1503
|
+
params = {**params, "tema": tema} # "motion": {"tema": ...} is the look of every item
|
|
1504
|
+
if not isinstance(params, dict):
|
|
1505
|
+
raise ComposeError(f"{where}.params precisa ser um objeto com os parâmetros do modelo "
|
|
1506
|
+
f"(ex: {{\"items\": [\"Acesso na hora\"]}}; veja motion_render.py --describe {tid})", 2)
|
|
1507
|
+
spec, auto = _motion_region(entry.get("region", "auto"), where)
|
|
1508
|
+
sfx_on = entry.get("sfx", True)
|
|
1509
|
+
if not isinstance(sfx_on, bool):
|
|
1510
|
+
raise ComposeError(f"{where}.sfx precisa ser true ou false (veio {_as_json(sfx_on)})", 2)
|
|
1511
|
+
try:
|
|
1512
|
+
tpl = MRg.get(tid)
|
|
1513
|
+
f0 = plan.fr(start)
|
|
1514
|
+
if f0 >= plan.nf:
|
|
1515
|
+
plan.warnings.append(f"animação {n} ('{tid}' em {fmt_time(start)}) começa depois do fim do vídeo "
|
|
1516
|
+
f"({fmt_time(plan.duration)}) e foi ignorada")
|
|
1517
|
+
continue
|
|
1518
|
+
if end is not None:
|
|
1519
|
+
f1 = plan.fr(end)
|
|
1520
|
+
if end * plan.fps > plan.nf + 0.5:
|
|
1521
|
+
plan.warnings.append(f"animação {n} ('{tid}') passava do fim do vídeo e foi cortada em "
|
|
1522
|
+
f"{fmt_time(plan.duration)}")
|
|
1523
|
+
else:
|
|
1524
|
+
secs = dur if dur is not None else tpl.suggested_duration(MRg.resolve_params(tpl, params))
|
|
1525
|
+
f1 = f0 + ME.frame_of(secs, plan.fps)
|
|
1526
|
+
if f1 > plan.nf:
|
|
1527
|
+
plan.warnings.append(f"animação {n} ('{tid}') passava do fim do vídeo e foi cortada em "
|
|
1528
|
+
f"{fmt_time(plan.duration)}: comece antes ou use uma duration menor")
|
|
1529
|
+
f1 = min(f1, plan.nf)
|
|
1530
|
+
if f1 - f0 < 2:
|
|
1531
|
+
plan.warnings.append(f"animação {n} ('{tid}' em {fmt_time(start)}) dura menos de 2 quadros e foi ignorada")
|
|
1532
|
+
continue
|
|
1533
|
+
item = MR.plan_item(tid, params, fps=plan.fps, canvas=(plan.W, plan.H), region=spec, frames=f1 - f0,
|
|
1534
|
+
accent=plan.accent)
|
|
1535
|
+
MR.sfx_cues(item)
|
|
1536
|
+
MR.render_frame(item, 0) # a mistake only the drawing sees (a target off the screen) stops here too
|
|
1537
|
+
except ME.MotionError as e:
|
|
1538
|
+
raise ComposeError(f"{where}: {e}", 2 if e.code == 2 else 1)
|
|
1539
|
+
for w in item.warnings:
|
|
1540
|
+
plan.warnings.append(f"animação {n}: {w}")
|
|
1541
|
+
item.warnings.clear()
|
|
1542
|
+
settle = tpl.settle(item.params, plan.fps)
|
|
1543
|
+
if settle is not None:
|
|
1544
|
+
# an end earlier than the drawing: the last items would never show (or never get their check / cross)
|
|
1545
|
+
frames = f1 - f0
|
|
1546
|
+
exit_frames = min(MR.EXIT_FRAMES, frames // 3) if tpl.exit == "fade" else 0
|
|
1547
|
+
need = settle + exit_frames / plan.fps
|
|
1548
|
+
if need * plan.fps > frames + 1:
|
|
1549
|
+
plan.warnings.append(f"animação {n} ('{tid}' em {fmt_time(start)}) acaba antes de terminar o desenho: "
|
|
1550
|
+
f"ela precisa de {ME.format_number(need, 1)} s e ficou com "
|
|
1551
|
+
f"{ME.format_number(frames / plan.fps, 1)} s. Aumente o end ou tire o end para usar "
|
|
1552
|
+
f"a duração sugerida ({ME.format_number(tpl.suggested_duration(item.params), 1)} s)")
|
|
1553
|
+
out.append(Motion(idx=n, template=tid, item=item, f0=f0, f1=f1, auto=auto, sfx=sfx_on,
|
|
1554
|
+
region_asked=entry.get("region", "auto")))
|
|
1555
|
+
for k, a in enumerate(out):
|
|
1556
|
+
for b in out[k + 1:]:
|
|
1557
|
+
if b.f0 < a.f1 and a.f0 < b.f1:
|
|
1558
|
+
plan.warnings.append(f"as animações {a.idx} e {b.idx} aparecem juntas ({fmt_time(max(a.f0, b.f0) / plan.fps)}): "
|
|
1559
|
+
f"deixe uma animação por vez na tela")
|
|
1560
|
+
return out
|
|
1561
|
+
|
|
1562
|
+
|
|
1563
|
+
def place_motion(plan: Plan, motions: list[Motion], highs: list[tuple[str, int, int, tuple]],
|
|
1564
|
+
caption_bands) -> None:
|
|
1565
|
+
"""Auto items slide off the faces, the CTA, the hook and the animations before them (and off the captions
|
|
1566
|
+
when that costs little); the others stay where they were asked. Then every clip is rendered."""
|
|
1567
|
+
if not motions:
|
|
1568
|
+
return
|
|
1569
|
+
from motion import render as MR
|
|
1570
|
+
|
|
1571
|
+
k = min(plan.W, plan.H) / 1080.0
|
|
1572
|
+
fps = plan.fps
|
|
1573
|
+
for m in motions:
|
|
1574
|
+
item = m.item
|
|
1575
|
+
if not (m.auto and item.template.movable):
|
|
1576
|
+
continue
|
|
1577
|
+
faces: list[tuple[str, tuple[float, float]]] = [] # (why, band in design px)
|
|
1578
|
+
faces += [("rosto", (a / k, b / k)) for a, b in face_bands(plan, m.f0, m.f1)]
|
|
1579
|
+
texts = []
|
|
1580
|
+
for why, h0, h1, band in highs:
|
|
1581
|
+
if h0 < m.f1 and m.f0 < h1:
|
|
1582
|
+
texts.append((why, (band[0] / k, band[1] / k)))
|
|
1583
|
+
others = []
|
|
1584
|
+
for other in motions:
|
|
1585
|
+
if other.idx >= m.idx or other.item.ink is None or not (other.f0 < m.f1 and m.f0 < other.f1):
|
|
1586
|
+
continue
|
|
1587
|
+
ob = other.item.box
|
|
1588
|
+
others.append((f"animação {other.idx}", (ob.y + other.item.ink[1], ob.y + other.item.ink[3])))
|
|
1589
|
+
# no place free of everything: the face still wins. First give up the other animations (they only share a
|
|
1590
|
+
# moment), then the CTA and the hook (they win anyway: the animation is hidden where it touches them).
|
|
1591
|
+
# Tela verde: off the whole cut-out body first, when there is room
|
|
1592
|
+
levels = [faces + texts + others, faces + texts, faces]
|
|
1593
|
+
gave = [[], others, texts + others]
|
|
1594
|
+
body = [("pessoa", (a / k, b / k)) for a, b in person_bands(plan, m.f0, m.f1)]
|
|
1595
|
+
if body:
|
|
1596
|
+
levels = [faces + body + texts + others] + levels
|
|
1597
|
+
gave = [[]] + gave
|
|
1598
|
+
dy, named, relaxed = None, levels[0], 0
|
|
1599
|
+
for lvl, cand in enumerate(levels):
|
|
1600
|
+
if lvl and len(cand) == len(levels[lvl - 1]):
|
|
1601
|
+
continue
|
|
1602
|
+
dy = MR.free_offset(item, [band for _, band in cand], MOTION_GAP)
|
|
1603
|
+
if dy is not None:
|
|
1604
|
+
named, relaxed = cand, lvl
|
|
1605
|
+
break
|
|
1606
|
+
bands = [band for _, band in named]
|
|
1607
|
+
caps = [(a / k, b / k) for a, b in caption_bands(m.f0, m.f1)]
|
|
1608
|
+
if caps:
|
|
1609
|
+
around = MR.free_offset(item, bands + caps, MOTION_GAP)
|
|
1610
|
+
if around is not None and (dy is None or abs(around - dy) <= MOTION_CAPTION_SHIFT):
|
|
1611
|
+
dy = around
|
|
1612
|
+
named += [("legenda", band) for band in caps]
|
|
1613
|
+
if dy is None:
|
|
1614
|
+
plan.warnings.append(f"a {m.name} não achou lugar livre do rosto e dos textos entre "
|
|
1615
|
+
f"{fmt_time(m.f0 / fps)} e {fmt_time(m.f1 / fps)}: ficou no lugar padrão. "
|
|
1616
|
+
f"Use outra region ou mude o tempo")
|
|
1617
|
+
continue
|
|
1618
|
+
given_up = [name for name, _ in gave[relaxed]]
|
|
1619
|
+
if given_up:
|
|
1620
|
+
plan.warnings.append(f"a {m.name} não achou lugar livre de tudo entre {fmt_time(m.f0 / fps)} e "
|
|
1621
|
+
f"{fmt_time(m.f1 / fps)}: ficou fora do rosto, mas perto {_of_names(given_up)}. "
|
|
1622
|
+
f"Mude o tempo para ela não dividir a tela")
|
|
1623
|
+
if abs(dy) >= 0.5:
|
|
1624
|
+
ink = item.ink
|
|
1625
|
+
before = (item.box.y + (ink[1] + ink[3]) / 2.0) if ink else item.box.y
|
|
1626
|
+
top, bottom = (item.box.y + ink[1], item.box.y + ink[3]) if ink else (item.box.y, item.box.y + item.box.h)
|
|
1627
|
+
why = []
|
|
1628
|
+
for name, (b0, b1) in named: # what the item touched where it was asked
|
|
1629
|
+
if b0 - MOTION_GAP < bottom and top < b1 + MOTION_GAP and name not in why:
|
|
1630
|
+
why.append(name)
|
|
1631
|
+
MR.move_item(item, dy)
|
|
1632
|
+
ko = min(plan.full_w, plan.full_h) / 1080.0 # design px -> px of the full output
|
|
1633
|
+
plan.adjustments.append({"layer": f"motion {m.idx}", "action": "moved", "start": round(m.f0 / fps, 3),
|
|
1634
|
+
"end": round(m.f1 / fps, 3), "because": " e ".join(why) or "rosto ou texto",
|
|
1635
|
+
"from_center_y": int(round(before * ko)),
|
|
1636
|
+
"center_y": int(round((before + dy) * ko))})
|
|
1637
|
+
|
|
1638
|
+
out_dir = plan.renders / "motion"
|
|
1639
|
+
workers = max(1, min(4, len(motions), os.cpu_count() or 2))
|
|
1640
|
+
|
|
1641
|
+
def work(m: Motion) -> dict:
|
|
1642
|
+
return MR.render_clip(m.item, out_dir)
|
|
1643
|
+
|
|
1644
|
+
try:
|
|
1645
|
+
with ThreadPoolExecutor(max_workers=workers) as ex:
|
|
1646
|
+
clips = list(ex.map(work, motions))
|
|
1647
|
+
except Exception as e: # noqa: BLE001
|
|
1648
|
+
from motion.engine import MotionError
|
|
1649
|
+
if isinstance(e, MotionError):
|
|
1650
|
+
raise ComposeError(f"falha ao desenhar uma animação: {e}", 2 if e.code == 2 else 1)
|
|
1651
|
+
raise
|
|
1652
|
+
for m, clip in zip(motions, clips):
|
|
1653
|
+
m.clip = clip
|
|
1654
|
+
m.ink = tuple(clip["ink"]) if clip.get("ink") else None
|
|
1655
|
+
if m.ink is None:
|
|
1656
|
+
plan.warnings.append(f"a {m.name} não desenhou nada e foi ignorada")
|
|
1657
|
+
|
|
1658
|
+
|
|
1659
|
+
# ============================================================== overlay tracks (PNG states)
|
|
1660
|
+
|
|
1661
|
+
class Track:
|
|
1662
|
+
"""Frame-grid layer states composited in PIL, played through an ffconcat playlist."""
|
|
1663
|
+
|
|
1664
|
+
def __init__(self, plan: Plan, name: str):
|
|
1665
|
+
self.plan = plan
|
|
1666
|
+
self.name = name
|
|
1667
|
+
self.images: dict[str, Image.Image] = {}
|
|
1668
|
+
self.bboxes: dict[str, tuple | None] = {}
|
|
1669
|
+
self.content: dict[str, str] = {}
|
|
1670
|
+
self.states: list[tuple[int, int, int, tuple]] = [] # f0, f1, z, recipe
|
|
1671
|
+
self.z = 0
|
|
1672
|
+
|
|
1673
|
+
def add_image(self, key: str, img: Image.Image) -> None:
|
|
1674
|
+
self.images[key] = img
|
|
1675
|
+
self.bboxes[key] = img.getchannel("A").getbbox()
|
|
1676
|
+
# content hash: cached PNG names must change whenever the art changes
|
|
1677
|
+
self.content[key] = hashlib.sha1(img.tobytes()).hexdigest()[:16]
|
|
1678
|
+
|
|
1679
|
+
def add(self, f0: int, f1: int, recipe: tuple, z: int | None = None) -> None:
|
|
1680
|
+
f0, f1 = max(0, f0), min(self.plan.nf, f1)
|
|
1681
|
+
if f1 > f0:
|
|
1682
|
+
self.states.append((f0, f1, self.z if z is None else z, recipe))
|
|
1683
|
+
|
|
1684
|
+
def next_z(self) -> int:
|
|
1685
|
+
self.z += 1
|
|
1686
|
+
return self.z
|
|
1687
|
+
|
|
1688
|
+
def _transformed(self, key: str, scale: float, alpha: float, dy: float) -> Image.Image | None:
|
|
1689
|
+
img = self.images[key]
|
|
1690
|
+
bbox = self.bboxes[key]
|
|
1691
|
+
if bbox is None or alpha <= 0.001:
|
|
1692
|
+
return None
|
|
1693
|
+
if abs(scale - 1.0) < 1e-4 and alpha >= 0.999 and abs(dy) < 0.5:
|
|
1694
|
+
return img
|
|
1695
|
+
W, H = img.size
|
|
1696
|
+
x0, y0, x1, y1 = bbox
|
|
1697
|
+
part = img.crop(bbox)
|
|
1698
|
+
if abs(scale - 1.0) >= 1e-4:
|
|
1699
|
+
nw, nh = max(1, int(round(part.width * scale))), max(1, int(round(part.height * scale)))
|
|
1700
|
+
part = part.resize((nw, nh), Image.BICUBIC)
|
|
1701
|
+
if alpha < 0.999:
|
|
1702
|
+
part.putalpha(part.getchannel("A").point(lambda v, a=alpha: int(v * a)))
|
|
1703
|
+
cx, cy = (x0 + x1) / 2.0, (y0 + y1) / 2.0 + dy
|
|
1704
|
+
canvas = Image.new("RGBA", (W, H), (0, 0, 0, 0))
|
|
1705
|
+
tr._alpha_paste(canvas, part, int(round(cx - part.width / 2.0)), int(round(cy - part.height / 2.0)))
|
|
1706
|
+
return canvas
|
|
1707
|
+
|
|
1708
|
+
def build(self, out_dir: Path) -> tuple[Path | None, list[dict], list[tuple[int, int]]]:
|
|
1709
|
+
plan = self.plan
|
|
1710
|
+
if not self.states:
|
|
1711
|
+
return None, [], []
|
|
1712
|
+
cuts = sorted({f for a, b, _, _ in self.states for f in (a, b)})
|
|
1713
|
+
intervals = []
|
|
1714
|
+
for u, v in zip(cuts, cuts[1:]):
|
|
1715
|
+
active = sorted((z, r) for a, b, z, r in self.states if a <= u and v <= b)
|
|
1716
|
+
if not active:
|
|
1717
|
+
continue
|
|
1718
|
+
key = tuple(r for _, r in active)
|
|
1719
|
+
if intervals and intervals[-1][1] == u and intervals[-1][2] == key:
|
|
1720
|
+
intervals[-1][1] = v
|
|
1721
|
+
else:
|
|
1722
|
+
intervals.append([u, v, key])
|
|
1723
|
+
out_dir.mkdir(parents=True, exist_ok=True)
|
|
1724
|
+
jobs = {}
|
|
1725
|
+
entries = []
|
|
1726
|
+
for u, v, key in intervals:
|
|
1727
|
+
ids = tuple(self.content.get(r[1], "") if r[0] == "img" else "" for r in key)
|
|
1728
|
+
name = f"{self.name}_{short_hash((RENDER_VERSION, plan.W, plan.H, key, ids))}.png"
|
|
1729
|
+
jobs.setdefault(name, key)
|
|
1730
|
+
entries.append({"file": str(out_dir / name), "start": u / plan.fps, "end": v / plan.fps})
|
|
1731
|
+
|
|
1732
|
+
def work(item):
|
|
1733
|
+
name, key = item
|
|
1734
|
+
path = out_dir / name
|
|
1735
|
+
if path.exists():
|
|
1736
|
+
return
|
|
1737
|
+
canvas = Image.new("RGBA", (plan.W, plan.H), (0, 0, 0, 0))
|
|
1738
|
+
for recipe in key:
|
|
1739
|
+
if recipe[0] == "flash":
|
|
1740
|
+
canvas.alpha_composite(Image.new("RGBA", (plan.W, plan.H), (255, 255, 255, int(round(255 * recipe[1])))))
|
|
1741
|
+
continue
|
|
1742
|
+
_, img_key, scale, alpha, dy = recipe
|
|
1743
|
+
layer = self._transformed(img_key, scale, alpha, dy)
|
|
1744
|
+
if layer is not None:
|
|
1745
|
+
canvas.alpha_composite(layer)
|
|
1746
|
+
tmp = path.with_name(".tmp-" + path.name)
|
|
1747
|
+
canvas.save(tmp, "PNG", compress_level=1)
|
|
1748
|
+
os.replace(tmp, path)
|
|
1749
|
+
|
|
1750
|
+
with ThreadPoolExecutor(max_workers=min(8, os.cpu_count() or 4)) as ex:
|
|
1751
|
+
list(ex.map(work, jobs.items()))
|
|
1752
|
+
ff = out_dir / f"{self.name}.ffconcat"
|
|
1753
|
+
tr.write_ffconcat(entries, str(ff), total_duration=plan.duration,
|
|
1754
|
+
blank_png=str(out_dir / f"blank_{plan.W}x{plan.H}.png"), fps=plan.fps)
|
|
1755
|
+
windows = merge_windows([(plan.fr(e["start"]), plan.fr(e["end"])) for e in entries])
|
|
1756
|
+
return ff, entries, windows
|
|
1757
|
+
|
|
1758
|
+
|
|
1759
|
+
def merge_windows(spans: list[tuple[int, int]]) -> list[tuple[int, int]]:
|
|
1760
|
+
out: list[list[int]] = []
|
|
1761
|
+
for a, b in sorted(spans):
|
|
1762
|
+
if out and a <= out[-1][1]:
|
|
1763
|
+
out[-1][1] = max(out[-1][1], b)
|
|
1764
|
+
else:
|
|
1765
|
+
out.append([a, b])
|
|
1766
|
+
return [(a, b) for a, b in out]
|
|
1767
|
+
|
|
1768
|
+
|
|
1769
|
+
def enable_expr(windows: list[tuple[int, int]], nf: int) -> str | None:
|
|
1770
|
+
if not windows:
|
|
1771
|
+
return None
|
|
1772
|
+
if len(windows) == 1 and windows[0][0] <= 0 and windows[0][1] >= nf:
|
|
1773
|
+
return None
|
|
1774
|
+
if len(windows) > 120:
|
|
1775
|
+
return None
|
|
1776
|
+
return "+".join(f"between(n,{a},{b - 1})" for a, b in windows)
|
|
1777
|
+
|
|
1778
|
+
|
|
1779
|
+
def seam_shadow(plan: Plan, ins: Insert) -> Image.Image:
|
|
1780
|
+
"""Soft shadow the picture band casts on the speaker band, so the split reads as layers."""
|
|
1781
|
+
W, H, s = plan.W, plan.H, plan.s
|
|
1782
|
+
depth = max(4, int(round(34 * s)))
|
|
1783
|
+
canvas = Image.new("RGBA", (W, H), (0, 0, 0, 0))
|
|
1784
|
+
ramp = Image.new("L", (1, depth))
|
|
1785
|
+
for y in range(depth):
|
|
1786
|
+
ramp.putpixel((0, y), int(round(255 * 0.42 * (1.0 - y / depth) ** 2)))
|
|
1787
|
+
ramp = ramp.resize((W, depth))
|
|
1788
|
+
band = Image.new("RGBA", (W, depth), (0, 0, 0, 255))
|
|
1789
|
+
seam = int(ins.seam_y or 0)
|
|
1790
|
+
if ins.type == "split_top": # speaker below the seam
|
|
1791
|
+
band.putalpha(ramp)
|
|
1792
|
+
canvas.alpha_composite(band, (0, min(H - depth, seam)))
|
|
1793
|
+
else: # speaker above the seam
|
|
1794
|
+
band.putalpha(ramp.transpose(Image.FLIP_TOP_BOTTOM))
|
|
1795
|
+
canvas.alpha_composite(band, (0, max(0, seam - depth)))
|
|
1796
|
+
return canvas
|
|
1797
|
+
|
|
1798
|
+
|
|
1799
|
+
def pip_decoration(plan: Plan, ins: Insert) -> Image.Image:
|
|
1800
|
+
W, H, s = plan.W, plan.H, plan.s
|
|
1801
|
+
ox, oy, ow, oh = ins.card # type: ignore[misc]
|
|
1802
|
+
border = ins.content[0] - ox
|
|
1803
|
+
radius = border * 2.8
|
|
1804
|
+
ss = 3
|
|
1805
|
+
canvas = Image.new("RGBA", (W, H), (0, 0, 0, 0))
|
|
1806
|
+
# soft shadow below the card (never inside it: the card content sits under this layer)
|
|
1807
|
+
pad = int(round(60 * s))
|
|
1808
|
+
sw, sh = ow + 2 * pad, oh + 2 * pad
|
|
1809
|
+
mask = Image.new("L", (sw, sh), 0)
|
|
1810
|
+
off = int(round(12 * s))
|
|
1811
|
+
ImageDraw.Draw(mask).rounded_rectangle((pad, pad + off, pad + ow, pad + oh + off), radius=radius, fill=int(255 * 0.55))
|
|
1812
|
+
mask = mask.filter(ImageFilter.GaussianBlur(20 * s))
|
|
1813
|
+
card_mask = Image.new("L", (sw * ss, sh * ss), 0)
|
|
1814
|
+
ImageDraw.Draw(card_mask).rounded_rectangle((pad * ss, pad * ss, (pad + ow) * ss - 1, (pad + oh) * ss - 1),
|
|
1815
|
+
radius=radius * ss, fill=255)
|
|
1816
|
+
card_mask = card_mask.resize((sw, sh), Image.LANCZOS)
|
|
1817
|
+
mask = Image.composite(Image.new("L", (sw, sh), 0), mask, card_mask)
|
|
1818
|
+
shadow = Image.new("RGBA", (sw, sh), (0, 0, 0, 255))
|
|
1819
|
+
shadow.putalpha(mask)
|
|
1820
|
+
tr._alpha_paste(canvas, shadow, ox - pad, oy - pad)
|
|
1821
|
+
# white ring: outer rounded rect minus inner rounded rect (covers the content corners)
|
|
1822
|
+
ring = Image.new("L", (ow * ss, oh * ss), 0)
|
|
1823
|
+
d = ImageDraw.Draw(ring)
|
|
1824
|
+
d.rounded_rectangle((0, 0, ow * ss - 1, oh * ss - 1), radius=radius * ss, fill=255)
|
|
1825
|
+
d.rounded_rectangle((border * ss, border * ss, (ow - border) * ss - 1, (oh - border) * ss - 1),
|
|
1826
|
+
radius=max(1.0, (radius - border)) * ss, fill=0)
|
|
1827
|
+
ring = ring.resize((ow, oh), Image.LANCZOS)
|
|
1828
|
+
white = Image.new("RGBA", (ow, oh), (255, 255, 255, 255))
|
|
1829
|
+
white.putalpha(ring)
|
|
1830
|
+
tr._alpha_paste(canvas, white, ox, oy)
|
|
1831
|
+
return canvas
|
|
1832
|
+
|
|
1833
|
+
|
|
1834
|
+
HOOK_IN = [(0.80, 0.55), (0.95, 0.92), (1.045, 1.0), (1.015, 1.0)] # (scale, alpha) per frame
|
|
1835
|
+
HOOK_OUT = [0.66, 0.33]
|
|
1836
|
+
HOOK_MAX_SCALE = 1.05
|
|
1837
|
+
CTA_IN = [(0.90, 0.3, 60), (0.94, 0.6, 34), (0.98, 0.85, 14), (1.015, 1.0, 4), (1.02, 1.0, 0), (1.005, 1.0, 0)]
|
|
1838
|
+
CTA_MAX_SCALE = 1.03 # entrance and pulse
|
|
1839
|
+
CTA_SLIDE = 14 # px (1080 wide) the CTA still sits lower on its first solid entrance frame
|
|
1840
|
+
SFX_CALIBRATION = make_sfx.SFX_CALIBRATION # 1.4: gain at sfx.volume 1.0 (levels per effect in assets/sfx/sfx.json)
|
|
1841
|
+
AAC_TP_HEADROOM = 1.0 # AAC encoding adds inter-sample peaks: normalize a bit under the target
|
|
1842
|
+
FLASH = [(-1, 0.35), (0, 0.9), (1, 0.6), (2, 0.32), (3, 0.12)]
|
|
1843
|
+
|
|
1844
|
+
# text placement (fractions of the canvas height)
|
|
1845
|
+
GAP_FACE = 0.012 # room between a text and a face
|
|
1846
|
+
GAP_TEXT = 0.005 # room between two texts
|
|
1847
|
+
SOFT_SHIFT = 60 / 1920 # captions may move this much so the disclaimer keeps its usual place
|
|
1848
|
+
FACE_COVER_WARN = 0.05 # share of the face (brows to lower lip) a text may cover before a warning
|
|
1849
|
+
PRIORITY = ("cta", "hook", "motion", "captions", "disclaimer") # motion = the animations of edit_data "motion"
|
|
1850
|
+
TEXT_LAYERS = ("cta", "hook", "captions", "disclaimer")
|
|
1851
|
+
LABELS = {"cta": "a chamada", "hook": "o gancho", "captions": "a legenda", "disclaimer": "o aviso",
|
|
1852
|
+
"motion": "a animação"}
|
|
1853
|
+
OF_LABELS = {"cta": "da chamada", "hook": "do gancho", "captions": "da legenda", "disclaimer": "do aviso",
|
|
1854
|
+
"motion": "da animação"}
|
|
1855
|
+
MIN_PIECE = {"hook": 0.3, "motion": 0.5, "captions": 0.13, "disclaimer": 1.0} # s: shorter leftovers of a hidden layer are hidden too
|
|
1856
|
+
DISCLAIMER_HIDDEN_WARN = 0.2 # share of its time the disclaimer may lose to other texts before a warning
|
|
1857
|
+
CENTER_KEYS = {"cta": "cta.center_y", "hook": "hook.center_y", "captions": "captions.center_y",
|
|
1858
|
+
"disclaimer": "disclaimer.center_y"}
|
|
1859
|
+
|
|
1860
|
+
|
|
1861
|
+
def clamp_cue_words(cues):
|
|
1862
|
+
"""Copy of the cues with word times kept inside their cue (a cue clamped to a cut must not
|
|
1863
|
+
start its first word a frame before that cut)."""
|
|
1864
|
+
if not isinstance(cues, dict):
|
|
1865
|
+
return cues
|
|
1866
|
+
out = dict(cues)
|
|
1867
|
+
fixed = []
|
|
1868
|
+
for cue in cues.get("cues") or []:
|
|
1869
|
+
try:
|
|
1870
|
+
c0, c1 = float(cue["start"]), float(cue["end"])
|
|
1871
|
+
except (KeyError, TypeError, ValueError):
|
|
1872
|
+
fixed.append(cue)
|
|
1873
|
+
continue
|
|
1874
|
+
c = dict(cue)
|
|
1875
|
+
words = []
|
|
1876
|
+
for w in cue.get("words") or []:
|
|
1877
|
+
try:
|
|
1878
|
+
ws, we = float(w["start"]), float(w["end"])
|
|
1879
|
+
except (KeyError, TypeError, ValueError):
|
|
1880
|
+
continue
|
|
1881
|
+
ws = min(max(ws, c0), c1)
|
|
1882
|
+
words.append(dict(w, start=ws, end=min(max(we, ws), c1)))
|
|
1883
|
+
c["words"] = words
|
|
1884
|
+
fixed.append(c)
|
|
1885
|
+
out["cues"] = fixed
|
|
1886
|
+
return out
|
|
1887
|
+
|
|
1888
|
+
|
|
1889
|
+
def check_captions_cut(plan: Plan, cues, cap_file: Path) -> None:
|
|
1890
|
+
"""Refuse captions made for another cut: every caption after a changed cut would be out of sync."""
|
|
1891
|
+
if not isinstance(cues, dict):
|
|
1892
|
+
return
|
|
1893
|
+
name = plan.rel(cap_file)
|
|
1894
|
+
hint = "Rode captions.py de novo para este corte."
|
|
1895
|
+
tl = plan.timeline
|
|
1896
|
+
if tl:
|
|
1897
|
+
key, current = cues.get("timeline_key"), captions_mod.timeline_key(tl)
|
|
1898
|
+
if key and current and key != current:
|
|
1899
|
+
raise ComposeError(f"{name} é de outro corte: o cut_timeline.json mudou depois das legendas. {hint}", 2)
|
|
1900
|
+
bounds = cues.get("cut_boundaries")
|
|
1901
|
+
if isinstance(bounds, list):
|
|
1902
|
+
starts = []
|
|
1903
|
+
for seg in tl.get("segments") or []:
|
|
1904
|
+
try:
|
|
1905
|
+
starts.append(float(seg["out_start"]))
|
|
1906
|
+
except (KeyError, TypeError, ValueError):
|
|
1907
|
+
continue
|
|
1908
|
+
want = sorted(starts)[1:]
|
|
1909
|
+
try:
|
|
1910
|
+
got = sorted(float(b) for b in bounds)
|
|
1911
|
+
except (TypeError, ValueError):
|
|
1912
|
+
got = []
|
|
1913
|
+
if len(want) != len(got) or any(abs(a - b) > 0.02 for a, b in zip(want, got)):
|
|
1914
|
+
show = lambda xs: ", ".join(f"{x:.2f}" for x in xs[:5]) + (" ..." if len(xs) > 5 else "") # noqa: E731
|
|
1915
|
+
raise ComposeError(f"{name} é de outro corte: as legendas têm cortes em {show(got) or 'nenhum'} s e o corte "
|
|
1916
|
+
f"atual em {show(want) or 'nenhum'} s. {hint}", 2)
|
|
1917
|
+
ends = []
|
|
1918
|
+
for cue in cues.get("cues") or []:
|
|
1919
|
+
try:
|
|
1920
|
+
ends.append(float(cue["end"]))
|
|
1921
|
+
except (KeyError, TypeError, ValueError):
|
|
1922
|
+
continue
|
|
1923
|
+
if ends and max(ends) > plan.duration + 0.5:
|
|
1924
|
+
raise ComposeError(f"{name} é de outro corte: a última legenda termina em {fmt_time(max(ends))} e o vídeo tem "
|
|
1925
|
+
f"{fmt_time(plan.duration)}. {hint}", 2)
|
|
1926
|
+
|
|
1927
|
+
|
|
1928
|
+
def _center_value(sec: dict, key: str, what: str) -> float | None:
|
|
1929
|
+
v = sec.get(key)
|
|
1930
|
+
if _is_auto(v):
|
|
1931
|
+
return None
|
|
1932
|
+
try:
|
|
1933
|
+
return float(v)
|
|
1934
|
+
except (TypeError, ValueError):
|
|
1935
|
+
raise ComposeError(f"{what} precisa ser um número (pixels na tela de saída) ou \"auto\"", 2)
|
|
1936
|
+
|
|
1937
|
+
|
|
1938
|
+
def grow_box(bb, scale: float = 1.0, extra_below: float = 0.0):
|
|
1939
|
+
if not bb:
|
|
1940
|
+
return None
|
|
1941
|
+
cx, cy = (bb[0] + bb[2]) / 2.0, (bb[1] + bb[3]) / 2.0
|
|
1942
|
+
hw, hh = (bb[2] - bb[0]) / 2.0 * scale, (bb[3] - bb[1]) / 2.0 * scale
|
|
1943
|
+
return (cx - hw, cy - hh, cx + hw, cy + hh + extra_below)
|
|
1944
|
+
|
|
1945
|
+
|
|
1946
|
+
def boxes_hit(a, b) -> bool:
|
|
1947
|
+
return bool(a) and bool(b) and a[0] < b[2] and b[0] < a[2] and a[1] < b[3] and b[1] < a[3]
|
|
1948
|
+
|
|
1949
|
+
|
|
1950
|
+
def cover_share(box, zone) -> float:
|
|
1951
|
+
if not box or not zone:
|
|
1952
|
+
return 0.0
|
|
1953
|
+
w = min(box[2], zone[2]) - max(box[0], zone[0])
|
|
1954
|
+
h = min(box[3], zone[3]) - max(box[1], zone[1])
|
|
1955
|
+
if w <= 0 or h <= 0:
|
|
1956
|
+
return 0.0
|
|
1957
|
+
return w * h / max(1e-6, (zone[2] - zone[0]) * (zone[3] - zone[1]))
|
|
1958
|
+
|
|
1959
|
+
|
|
1960
|
+
def subtract_windows(spans: list[tuple], windows: list[tuple[int, int]]) -> list[tuple]:
|
|
1961
|
+
"""(f0, f1, *payload) spans minus frame windows."""
|
|
1962
|
+
out = []
|
|
1963
|
+
for f0, f1, *rest in spans:
|
|
1964
|
+
pieces = [(f0, f1)]
|
|
1965
|
+
for a, b in windows:
|
|
1966
|
+
nxt = []
|
|
1967
|
+
for p0, p1 in pieces:
|
|
1968
|
+
if b <= p0 or a >= p1:
|
|
1969
|
+
nxt.append((p0, p1))
|
|
1970
|
+
continue
|
|
1971
|
+
if p0 < a:
|
|
1972
|
+
nxt.append((p0, a))
|
|
1973
|
+
if b < p1:
|
|
1974
|
+
nxt.append((b, p1))
|
|
1975
|
+
pieces = nxt
|
|
1976
|
+
out += [(p0, p1, *rest) for p0, p1 in pieces if p1 > p0]
|
|
1977
|
+
return out
|
|
1978
|
+
|
|
1979
|
+
|
|
1980
|
+
def compute_face_zones(plan: Plan, inserts: list[Insert]) -> None:
|
|
1981
|
+
"""Face zones as drawn in the output (after the camera and the inserts), per frame interval."""
|
|
1982
|
+
W, H = plan.W, plan.H
|
|
1983
|
+
pieces = []
|
|
1984
|
+
for cs in plan.cam_segs:
|
|
1985
|
+
zones, cores = [], []
|
|
1986
|
+
face = cs.get("face")
|
|
1987
|
+
if face and face.get("zone"):
|
|
1988
|
+
for key, dst in (("zone", zones), ("core", cores)):
|
|
1989
|
+
if not face.get(key):
|
|
1990
|
+
continue
|
|
1991
|
+
rects = [face_layout.zoom_rect(face[key], z, cs["focus_y"]) for z in {cs["zoom"], cs["zoom_max"]}]
|
|
1992
|
+
dst.append((min(r["x0"] for r in rects) * W, min(r["y0"] for r in rects) * H,
|
|
1993
|
+
max(r["x1"] for r in rects) * W, max(r["y1"] for r in rects) * H))
|
|
1994
|
+
pieces.append((cs["f0"], cs["f1"], zones, cores))
|
|
1995
|
+
cuts = sorted({0, plan.nf} | {p[0] for p in pieces} | {p[1] for p in pieces}
|
|
1996
|
+
| {min(plan.nf, i.f0) for i in inserts} | {min(plan.nf, i.f1) for i in inserts})
|
|
1997
|
+
out: list[tuple] = []
|
|
1998
|
+
for a, b in zip(cuts, cuts[1:]):
|
|
1999
|
+
if b <= a:
|
|
2000
|
+
continue
|
|
2001
|
+
base = next((p for p in pieces if p[0] <= a < p[1]), None)
|
|
2002
|
+
zones, cores = (base[2], base[3]) if base else ([], [])
|
|
2003
|
+
top = None
|
|
2004
|
+
for ins in inserts: # list order: the last one covering is drawn on top
|
|
2005
|
+
if ins.type != "pip" and ins.f0 <= a and b <= ins.f1:
|
|
2006
|
+
top = ins
|
|
2007
|
+
if top is not None and top.type == "full":
|
|
2008
|
+
zones, cores = [], []
|
|
2009
|
+
elif top is not None and top.speaker is not None:
|
|
2010
|
+
_, band_y, _, hs = top.speaker
|
|
2011
|
+
src_y = next((y for p0, p1, y in top.speaker_pieces if p0 <= a < p1), top.speaker_src_y)
|
|
2012
|
+
|
|
2013
|
+
def moved(r, src_y=src_y, band_y=band_y, hs=hs):
|
|
2014
|
+
y0 = max(band_y, r[1] - src_y + band_y)
|
|
2015
|
+
y1 = min(band_y + hs, r[3] - src_y + band_y)
|
|
2016
|
+
return (r[0], y0, r[2], y1) if y1 > y0 else None
|
|
2017
|
+
|
|
2018
|
+
zones = [m for m in map(moved, zones) if m]
|
|
2019
|
+
cores = [m for m in map(moved, cores) if m]
|
|
2020
|
+
if out and out[-1][1] == a and out[-1][2] == zones and out[-1][3] == cores:
|
|
2021
|
+
out[-1] = (out[-1][0], b, zones, cores)
|
|
2022
|
+
else:
|
|
2023
|
+
out.append((a, b, zones, cores))
|
|
2024
|
+
plan.zones = out
|
|
2025
|
+
pad = 12 * plan.s
|
|
2026
|
+
plan.obstacles = [(i.f0, i.f1, (i.card[0] - pad, i.card[1] - pad, i.card[0] + i.card[2] + pad, i.card[1] + i.card[3] + pad))
|
|
2027
|
+
for i in inserts if i.type == "pip" and i.card]
|
|
2028
|
+
plan.obstacles += tela_obstacles(plan)
|
|
2029
|
+
|
|
2030
|
+
|
|
2031
|
+
def tela_obstacles(plan: Plan) -> list[tuple[int, int, tuple[float, float, float, float]]]:
|
|
2032
|
+
"""Texto/desenho que já está no vídeo (edit/texto_na_tela.json) vira obstáculo: os textos desviam dele."""
|
|
2033
|
+
doc = load_json(plan.edit_dir / "texto_na_tela.json")
|
|
2034
|
+
if not isinstance(doc, dict) or not doc.get("amostras"):
|
|
2035
|
+
return []
|
|
2036
|
+
passo = 1.0 / float(doc.get("fps") or 2.0)
|
|
2037
|
+
pad = 10 * plan.s
|
|
2038
|
+
out: list[tuple[int, int, tuple[float, float, float, float]]] = []
|
|
2039
|
+
for am in doc["amostras"]:
|
|
2040
|
+
itens = am.get("itens") or []
|
|
2041
|
+
if not itens:
|
|
2042
|
+
continue
|
|
2043
|
+
f0 = int(round(float(am["t"]) * plan.fps))
|
|
2044
|
+
f1 = int(round((float(am["t"]) + passo) * plan.fps))
|
|
2045
|
+
for it in itens:
|
|
2046
|
+
centro = float(it.get("centro_y", 0.5)) * plan.H
|
|
2047
|
+
meia = max(6.0, float(it.get("altura", 0.02)) * plan.H / 2)
|
|
2048
|
+
cx = float(it.get("centro_x", 0.5)) * plan.W
|
|
2049
|
+
meia_x = max(20.0, float(it.get("largura", 0.4)) * plan.W / 2)
|
|
2050
|
+
out.append((f0, f1, (cx - meia_x - pad, centro - meia - pad, cx + meia_x + pad, centro + meia + pad)))
|
|
2051
|
+
# junta amostras seguidas com a mesma caixa, para a lista não explodir
|
|
2052
|
+
out.sort(key=lambda o: (round(o[2][1]), round(o[2][3]), o[0]))
|
|
2053
|
+
juntos: list[tuple[int, int, tuple[float, float, float, float]]] = []
|
|
2054
|
+
for f0, f1, r in out:
|
|
2055
|
+
if juntos and juntos[-1][2] == r and f0 - juntos[-1][1] <= plan.fps * 0.6:
|
|
2056
|
+
juntos[-1] = (juntos[-1][0], f1, r)
|
|
2057
|
+
else:
|
|
2058
|
+
juntos.append((f0, f1, r))
|
|
2059
|
+
return juntos
|
|
2060
|
+
|
|
2061
|
+
|
|
2062
|
+
def face_bands(plan: Plan, f0: int, f1: int, core: bool = False, gap: float = 0.0,
|
|
2063
|
+
skip: list[tuple[int, int]] | tuple = ()) -> list[tuple[float, float]]:
|
|
2064
|
+
"""Vertical bands taken by faces (and pip cards) during [f0, f1), minus intervals fully inside `skip` windows."""
|
|
2065
|
+
out = []
|
|
2066
|
+
for a, b, zones, cores in plan.zones:
|
|
2067
|
+
if b <= f0 or a >= f1:
|
|
2068
|
+
continue
|
|
2069
|
+
lo_f, hi_f = max(a, f0), min(b, f1)
|
|
2070
|
+
if any(h0 <= lo_f and hi_f <= h1 for h0, h1 in skip):
|
|
2071
|
+
continue
|
|
2072
|
+
for r in (cores if core else zones):
|
|
2073
|
+
out.append((r[1] - gap, r[3] + gap))
|
|
2074
|
+
for a, b, r in plan.obstacles:
|
|
2075
|
+
if a < f1 and f0 < b:
|
|
2076
|
+
out.append((r[1], r[3]))
|
|
2077
|
+
return out
|
|
2078
|
+
|
|
2079
|
+
|
|
2080
|
+
def layout_spans(plan: Plan) -> list[tuple[int, int]]:
|
|
2081
|
+
"""The frame spans the captions are placed in when one height does not fit the whole video: the face zone
|
|
2082
|
+
pieces, split where a whole background picture comes or goes (tela verde)."""
|
|
2083
|
+
spans = [(a, b) for a, b, _, _ in plan.zones]
|
|
2084
|
+
if not plan.image_zones:
|
|
2085
|
+
return spans
|
|
2086
|
+
cuts = sorted({p for a, b, _ in plan.image_zones for p in (a, b)})
|
|
2087
|
+
out = []
|
|
2088
|
+
for a, b in spans:
|
|
2089
|
+
pts = [a] + [c for c in cuts if a < c < b] + [b]
|
|
2090
|
+
out += list(zip(pts, pts[1:]))
|
|
2091
|
+
return out
|
|
2092
|
+
|
|
2093
|
+
|
|
2094
|
+
def manual_off_face(plan: Plan, layer: str, manual: float, above: float, below: float, f0: int, f1: int,
|
|
2095
|
+
lo: float, hi: float, to_out) -> float:
|
|
2096
|
+
"""Tela verde: a hook or CTA height written by hand stays where it is free of the (moved) face, else it goes to
|
|
2097
|
+
the free height closest to it, with a warning."""
|
|
2098
|
+
c = pick_center([manual], above, below, face_bands(plan, f0, f1, gap=GAP_FACE * plan.H), lo, hi, manual)
|
|
2099
|
+
if c is None or abs(c - manual) <= 0.5:
|
|
2100
|
+
return manual
|
|
2101
|
+
plan.adjustments.append({"layer": layer, "action": "moved", "start": round(f0 / plan.fps, 3),
|
|
2102
|
+
"end": round(f1 / plan.fps, 3), "because": "rosto (tela verde)",
|
|
2103
|
+
"from_center_y": to_out(manual), "center_y": to_out(c)})
|
|
2104
|
+
plan.warnings.append(f"{CENTER_KEYS[layer]} {to_out(manual)} cairia no rosto na tela verde (você mudou de lugar): "
|
|
2105
|
+
f"{LABELS[layer]} foi para {to_out(c)}. Tire o center_y para ela se ajustar sozinha")
|
|
2106
|
+
return c
|
|
2107
|
+
|
|
2108
|
+
|
|
2109
|
+
def pick_center(prefs: list, above: float, below: float, blocked: list[tuple[float, float]], lo: float, hi: float,
|
|
2110
|
+
fallback: float | None = None) -> float | None:
|
|
2111
|
+
"""First preference that is free as it is, else the free center closest to `fallback` (or prefs[0])."""
|
|
2112
|
+
prefs = [p for p in prefs if p is not None]
|
|
2113
|
+
for p in prefs:
|
|
2114
|
+
if face_layout.free_center(p, above, below, blocked, lo, hi) == p:
|
|
2115
|
+
return p
|
|
2116
|
+
target = fallback if fallback is not None else (prefs[0] if prefs else (lo + hi) / 2.0)
|
|
2117
|
+
return face_layout.free_center(target, above, below, blocked, lo, hi)
|
|
2118
|
+
|
|
2119
|
+
|
|
2120
|
+
def layout_pieces(windows: list[dict], fps: int, f0: int, f1: int, default_cy: float) -> list[tuple[int, int, float]]:
|
|
2121
|
+
"""Caption center per frame interval, with the rule text_render applies to `layout` windows
|
|
2122
|
+
(the window that starts later wins)."""
|
|
2123
|
+
def fr(t):
|
|
2124
|
+
return int(round(float(t) * fps + 1e-6))
|
|
2125
|
+
|
|
2126
|
+
lay = sorted(((fr(w["start"]), fr(w["end"]), float(w["center_y"])) for w in windows), key=lambda x: x[0])
|
|
2127
|
+
cuts = sorted({f0, f1} | {p for a, b, _ in lay for p in (a, b) if f0 < p < f1})
|
|
2128
|
+
out: list[tuple[int, int, float]] = []
|
|
2129
|
+
for a, b in zip(cuts, cuts[1:]):
|
|
2130
|
+
cy = default_cy
|
|
2131
|
+
for l0, l1, lcy in lay:
|
|
2132
|
+
if l0 <= a and b <= l1:
|
|
2133
|
+
cy = lcy
|
|
2134
|
+
if out and out[-1][1] == a and out[-1][2] == cy:
|
|
2135
|
+
out[-1] = (out[-1][0], b, cy)
|
|
2136
|
+
else:
|
|
2137
|
+
out.append((a, b, cy))
|
|
2138
|
+
return out
|
|
2139
|
+
|
|
2140
|
+
|
|
2141
|
+
def add_decorations(plan: Plan, mid: "Track", inserts: list[Insert]) -> None:
|
|
2142
|
+
"""Pip cards and split seam shadows, left out while an insert later in the list covers them."""
|
|
2143
|
+
W, H, s = plan.W, plan.H, plan.s
|
|
2144
|
+
for ins in inserts:
|
|
2145
|
+
if ins.type == "pip":
|
|
2146
|
+
cx, cy_, cw, ch = ins.card # type: ignore[misc]
|
|
2147
|
+
key, rect = f"pip{ins.idx}", (cx, cy_, cx + cw, cy_ + ch)
|
|
2148
|
+
mid.add_image(key, pip_decoration(plan, ins))
|
|
2149
|
+
elif ins.seam_y is not None:
|
|
2150
|
+
key = f"seam{ins.type}{ins.seam_y}"
|
|
2151
|
+
depth = max(4, int(round(34 * s)))
|
|
2152
|
+
rect = (0, max(0, ins.seam_y - depth), W, min(H, ins.seam_y + depth))
|
|
2153
|
+
if key not in mid.images:
|
|
2154
|
+
mid.add_image(key, seam_shadow(plan, ins))
|
|
2155
|
+
else:
|
|
2156
|
+
continue
|
|
2157
|
+
covered = []
|
|
2158
|
+
for other in inserts:
|
|
2159
|
+
if other.idx <= ins.idx or other.f1 <= ins.f0 or other.f0 >= ins.f1:
|
|
2160
|
+
continue
|
|
2161
|
+
ocard = other.card if other.type == "pip" else None
|
|
2162
|
+
if other.type != "pip" or boxes_hit(rect, (ocard[0], ocard[1], ocard[0] + ocard[2], ocard[1] + ocard[3])):
|
|
2163
|
+
covered.append((max(ins.f0, other.f0), min(ins.f1, other.f1)))
|
|
2164
|
+
z = mid.next_z()
|
|
2165
|
+
for a, b in subtract_windows([(ins.f0, ins.f1)], merge_windows(covered)):
|
|
2166
|
+
mid.add(a, b, ("img", key, 1.0, 1.0, 0.0), z=z)
|
|
2167
|
+
if covered:
|
|
2168
|
+
plan.adjustments.append({"layer": f"insert {ins.idx + 1}", "action": "decoration_hidden",
|
|
2169
|
+
"windows": [[round(a / plan.fps, 3), round(b / plan.fps, 3)] for a, b in merge_windows(covered)],
|
|
2170
|
+
"because": "um insert depois na lista fica por cima"})
|
|
2171
|
+
|
|
2172
|
+
|
|
2173
|
+
def build_tracks(plan: Plan, inserts: list[Insert], motions: list[Motion] | None = None) -> dict:
|
|
2174
|
+
s = plan.s
|
|
2175
|
+
W, H, fps, nf = plan.W, plan.H, plan.fps, plan.nf
|
|
2176
|
+
data = plan.data
|
|
2177
|
+
motions = list(motions or [])
|
|
2178
|
+
catalog_defaults = load_style_catalog().get("defaults") or {}
|
|
2179
|
+
result: dict = {"mid": None, "captions": None, "top": None, "sfx_events": [], "motion": []}
|
|
2180
|
+
sfx_on = _enabled(_section(data, "sfx"))
|
|
2181
|
+
lay = tr.layout_defaults((W, H))
|
|
2182
|
+
lo, hi = lay["top"], lay["bottom"]
|
|
2183
|
+
disc_hi = min(H - 8.0 * H / 1920.0, lay["bottom"] + 0.035 * H)
|
|
2184
|
+
g_face, g_text = GAP_FACE * H, GAP_TEXT * H
|
|
2185
|
+
out_dir = plan.renders / "overlays"
|
|
2186
|
+
|
|
2187
|
+
def to_out(px):
|
|
2188
|
+
return None if px is None else int(round(px / s))
|
|
2189
|
+
|
|
2190
|
+
def overlaps(a0, a1, b0, b1):
|
|
2191
|
+
return a0 < b1 and b0 < a1
|
|
2192
|
+
|
|
2193
|
+
# ---------------- settings of each text layer
|
|
2194
|
+
hook = _section(data, "hook")
|
|
2195
|
+
hk = None
|
|
2196
|
+
if _enabled(hook) and str(hook.get("text") or "").strip():
|
|
2197
|
+
style = hook.get("style") or catalog_defaults.get("hook_style") or "manchete"
|
|
2198
|
+
if style not in tr.HOOK_STYLES:
|
|
2199
|
+
raise ComposeError(f"estilo de gancho '{style}' não existe. Opções: {', '.join(tr.HOOK_STYLES)}", 2)
|
|
2200
|
+
h0 = plan.fr(hook.get("start", 0.0))
|
|
2201
|
+
h1 = plan.fr(hook.get("end"), default=min(nf, plan.fr(3.0)))
|
|
2202
|
+
if h1 - h0 < 2:
|
|
2203
|
+
plan.warnings.append("gancho com menos de 2 quadros foi ignorado")
|
|
2204
|
+
else:
|
|
2205
|
+
text = str(hook["text"])
|
|
2206
|
+
maxw = int(round(900 * s))
|
|
2207
|
+
fit = tr.hook_fit(text, style, (W, H), max_width=maxw)
|
|
2208
|
+
if fit["too_long"]:
|
|
2209
|
+
plan.warnings.append(f"gancho longo demais, a letra ficou pequena ({fit['size']}px). Encurte o texto.")
|
|
2210
|
+
plan.warnings.extend(tr.text_issues("hook", text, style))
|
|
2211
|
+
manual = _center_value(hook, "center_y", "hook.center_y")
|
|
2212
|
+
hk = {"style": style, "text": text, "f0": h0, "f1": h1, "fit": fit, "maxw": maxw,
|
|
2213
|
+
"manual": manual * s if manual is not None else None}
|
|
2214
|
+
|
|
2215
|
+
cap = _section(data, "captions")
|
|
2216
|
+
cp = None
|
|
2217
|
+
if _enabled(cap):
|
|
2218
|
+
cap_file = _resolve(plan.edit_dir, cap.get("file") or "captions.json", "captions.file")
|
|
2219
|
+
try:
|
|
2220
|
+
cues = load_json(cap_file) or {}
|
|
2221
|
+
except ValueError as e:
|
|
2222
|
+
raise ComposeError(f"captions.json inválido ({e})", 2)
|
|
2223
|
+
check_captions_cut(plan, cues, cap_file)
|
|
2224
|
+
cues = clamp_cue_words(cues)
|
|
2225
|
+
has_cues = bool(cues.get("cues") if isinstance(cues, dict) else cues)
|
|
2226
|
+
if not has_cues:
|
|
2227
|
+
plan.warnings.append("captions.json não tem legendas")
|
|
2228
|
+
style = cap.get("style") or (cues.get("style") if isinstance(cues, dict) else None) \
|
|
2229
|
+
or catalog_defaults.get("caption_style") or "impacto"
|
|
2230
|
+
try:
|
|
2231
|
+
tr.caption_style_info(style)
|
|
2232
|
+
except ValueError as e:
|
|
2233
|
+
raise ComposeError(str(e), 2)
|
|
2234
|
+
if isinstance(cues, dict) and cues.get("style") and cues.get("style") != style:
|
|
2235
|
+
plan.warnings.append(f"captions.json foi agrupado para '{cues.get('style')}' e o vídeo usa '{style}'. "
|
|
2236
|
+
f"Se ficar estranho, rode captions.py --style {style}")
|
|
2237
|
+
font_scale = float(cap.get("font_scale", 1.0) or 1.0)
|
|
2238
|
+
manual = _center_value(cap, "center_y", "captions.center_y")
|
|
2239
|
+
hide = [(hk["f0"], hk["f1"])] if hk and cap.get("hide_during_hook", True) else []
|
|
2240
|
+
seams = [{"start": i.f0 / fps, "end": i.f1 / fps, "center_y": float(i.seam_y)} for i in inserts if i.seam_y is not None]
|
|
2241
|
+
cp = {"file": cap_file, "cues": cues, "style": style, "font_scale": font_scale, "has_cues": has_cues,
|
|
2242
|
+
"manual": manual * s if manual is not None else None, "hide": hide, "seams": seams, "moves": [],
|
|
2243
|
+
"base": [], "ext": tr.caption_extent(cues, style, (W, H), font_scale, fps)}
|
|
2244
|
+
|
|
2245
|
+
disc = _section(data, "disclaimer")
|
|
2246
|
+
dc = None
|
|
2247
|
+
if _enabled(disc):
|
|
2248
|
+
text = str(disc.get("text") or DEFAULT_DISCLAIMER)
|
|
2249
|
+
d0 = plan.fr(disc.get("start", 0.0))
|
|
2250
|
+
d1 = plan.fr(disc.get("end"), default=nf)
|
|
2251
|
+
if d1 > d0:
|
|
2252
|
+
plan.warnings.extend(tr.text_issues("disclaimer", text))
|
|
2253
|
+
manual = _center_value(disc, "center_y", "disclaimer.center_y")
|
|
2254
|
+
bb = tr.ink_bbox(tr.render_disclaimer(text, canvas=(W, H), center_y=lay["disclaimer"]))
|
|
2255
|
+
dc = {"text": text, "f0": d0, "f1": d1, "manual": manual * s if manual is not None else None,
|
|
2256
|
+
"ext": (lay["disclaimer"] - bb[1], bb[3] - lay["disclaimer"]) if bb else (0.0, 0.0)}
|
|
2257
|
+
|
|
2258
|
+
cta = _section(data, "cta")
|
|
2259
|
+
ct = None
|
|
2260
|
+
if _enabled(cta):
|
|
2261
|
+
text = str(cta.get("text") or DEFAULT_CTA)
|
|
2262
|
+
dur = float(cta.get("duration", 2.5) or 2.5)
|
|
2263
|
+
c1 = nf
|
|
2264
|
+
c0 = plan.fr(cta.get("start")) if cta.get("start") is not None else max(0, nf - plan.fr(dur))
|
|
2265
|
+
if cta.get("start") is not None:
|
|
2266
|
+
c1 = min(nf, c0 + plan.fr(dur))
|
|
2267
|
+
if c1 - c0 >= 2:
|
|
2268
|
+
sub = cta.get("sub")
|
|
2269
|
+
plan.warnings.extend(tr.text_issues("cta", text) + tr.text_issues("cta_sub", str(sub or "")))
|
|
2270
|
+
manual = _center_value(cta, "center_y", "cta.center_y")
|
|
2271
|
+
pref = lay["cta"] if cp and cp["has_cues"] else lay["cta_alone"]
|
|
2272
|
+
bb = tr.ink_bbox(tr.render_cta(text, sub, plan.accent, canvas=(W, H), center_y=pref))
|
|
2273
|
+
ext = (((pref - bb[1]) * CTA_MAX_SCALE, (bb[3] - pref) * CTA_MAX_SCALE + CTA_SLIDE * s) if bb else (0.0, 0.0))
|
|
2274
|
+
ct = {"text": text, "sub": sub, "f0": c0, "f1": c1, "pref": pref, "ext": ext,
|
|
2275
|
+
"manual": manual * s if manual is not None else None}
|
|
2276
|
+
|
|
2277
|
+
for ins in inserts:
|
|
2278
|
+
if hk and overlaps(ins.f0, ins.f1, hk["f0"], hk["f1"]):
|
|
2279
|
+
plan.warnings.append(f"insert {ins.idx + 1} aparece junto com o gancho ({fmt_time(max(ins.f0, hk['f0']) / fps)} a "
|
|
2280
|
+
f"{fmt_time(min(ins.f1, hk['f1']) / fps)}): comece o insert depois do fim do gancho")
|
|
2281
|
+
if ct and overlaps(ins.f0, ins.f1, ct["f0"], ct["f1"]):
|
|
2282
|
+
plan.warnings.append(f"insert {ins.idx + 1} aparece junto com a chamada ({fmt_time(max(ins.f0, ct['f0']) / fps)} a "
|
|
2283
|
+
f"{fmt_time(min(ins.f1, ct['f1']) / fps)}): termine o insert antes da chamada")
|
|
2284
|
+
|
|
2285
|
+
for m in motions:
|
|
2286
|
+
if hk and overlaps(m.f0, m.f1, hk["f0"], hk["f1"]):
|
|
2287
|
+
plan.warnings.append(f"a {m.name} aparece junto com o gancho ({fmt_time(max(m.f0, hk['f0']) / fps)} a "
|
|
2288
|
+
f"{fmt_time(min(m.f1, hk['f1']) / fps)}): comece a animação depois do fim do gancho")
|
|
2289
|
+
if ct and overlaps(m.f0, m.f1, ct["f0"], ct["f1"]):
|
|
2290
|
+
plan.warnings.append(f"a {m.name} aparece junto com a chamada ({fmt_time(max(m.f0, ct['f0']) / fps)} a "
|
|
2291
|
+
f"{fmt_time(min(m.f1, ct['f1']) / fps)}): termine a animação antes da chamada")
|
|
2292
|
+
|
|
2293
|
+
def caption_windows() -> list[dict]:
|
|
2294
|
+
return ([{"start": a / fps, "end": b / fps, "center_y": cy} for a, b, cy in cp["base"]] + cp["seams"] + cp["moves"])
|
|
2295
|
+
|
|
2296
|
+
def caption_pieces(f0, f1):
|
|
2297
|
+
return [(a, b, cy) for a, b, cy in layout_pieces(caption_windows(), fps, f0, f1, lay["caption"])
|
|
2298
|
+
if not any(h0 <= a and b <= h1 for h0, h1 in cp["hide"])]
|
|
2299
|
+
|
|
2300
|
+
def caption_bands(f0, f1):
|
|
2301
|
+
return [(cy - cp["ext"][0] - g_text, cy + cp["ext"][1] + g_text) for _, _, cy in caption_pieces(f0, f1)]
|
|
2302
|
+
|
|
2303
|
+
# ---------------- 1. captions: base height, off the faces (and, if it is cheap, off the disclaimer)
|
|
2304
|
+
if cp:
|
|
2305
|
+
above, below = cp["ext"]
|
|
2306
|
+
# tela verde: a height written by hand for the recording may land on the face, which moved; there it goes
|
|
2307
|
+
# to the free height closest to it
|
|
2308
|
+
tv_manual = cp["manual"] is not None and plan.tv is not None
|
|
2309
|
+
if cp["manual"] is not None and not tv_manual:
|
|
2310
|
+
cp["base"] = [(0, nf, cp["manual"])]
|
|
2311
|
+
else:
|
|
2312
|
+
pref = lay["caption"] if not tv_manual else cp["manual"]
|
|
2313
|
+
soft = []
|
|
2314
|
+
if dc and not tv_manual:
|
|
2315
|
+
dcy = dc["manual"] if dc["manual"] is not None else lay["disclaimer"]
|
|
2316
|
+
soft = [(dcy - dc["ext"][0] - g_text, dcy + dc["ext"][1] + g_text)]
|
|
2317
|
+
|
|
2318
|
+
def choose_caption(f0, f1, prev=None, whole=False):
|
|
2319
|
+
faces = face_bands(plan, f0, f1, gap=g_face, skip=cp["hide"])
|
|
2320
|
+
if tv_manual and face_layout.free_center(pref, above, below, faces, lo, hi) == pref:
|
|
2321
|
+
return pref # the height written by hand is free of the face here: it stays
|
|
2322
|
+
body = person_bands(plan, f0, f1, gap=g_face, skip=cp["hide"])
|
|
2323
|
+
if body: # tela verde: the captions go off the cut-out body (above it) when there is room
|
|
2324
|
+
# and off the main area of a whole background picture (between it and the head)
|
|
2325
|
+
imgs = image_bands(plan, f0, f1, gap=g_face, skip=cp["hide"])
|
|
2326
|
+
c = pick_center([prev, pref], above, below, faces + body + imgs, lo, hi, pref)
|
|
2327
|
+
if c is not None:
|
|
2328
|
+
return c
|
|
2329
|
+
if imgs and whole:
|
|
2330
|
+
return None # one height for the whole video only when it is free of everything
|
|
2331
|
+
if imgs: # else below the face, before covering the picture
|
|
2332
|
+
c = pick_center([prev, pref], above, below, faces + imgs, lo, hi, pref)
|
|
2333
|
+
if c is not None:
|
|
2334
|
+
return c
|
|
2335
|
+
c = pick_center([prev, pref], above, below, faces + body, lo, hi, pref)
|
|
2336
|
+
if c is not None:
|
|
2337
|
+
return c
|
|
2338
|
+
if soft:
|
|
2339
|
+
c = pick_center([prev, pref], above, below, faces + soft, lo, hi, pref)
|
|
2340
|
+
if c is not None and abs(c - pref) <= SOFT_SHIFT * H:
|
|
2341
|
+
return c
|
|
2342
|
+
return pick_center([prev, pref], above, below, faces, lo, hi, pref)
|
|
2343
|
+
|
|
2344
|
+
c = choose_caption(0, nf, whole=True)
|
|
2345
|
+
if c is not None:
|
|
2346
|
+
cp["base"] = [(0, nf, c)]
|
|
2347
|
+
else:
|
|
2348
|
+
prev = None
|
|
2349
|
+
no_room = []
|
|
2350
|
+
for a, b in layout_spans(plan):
|
|
2351
|
+
c = choose_caption(a, b, prev)
|
|
2352
|
+
if c is None:
|
|
2353
|
+
c = pref
|
|
2354
|
+
no_room.append((a, b))
|
|
2355
|
+
if cp["base"] and cp["base"][-1][1] == a and cp["base"][-1][2] == c:
|
|
2356
|
+
cp["base"][-1] = (cp["base"][-1][0], b, c)
|
|
2357
|
+
else:
|
|
2358
|
+
cp["base"].append((a, b, c))
|
|
2359
|
+
prev = c
|
|
2360
|
+
if no_room:
|
|
2361
|
+
total = sum(b - a for a, b in no_room) / fps
|
|
2362
|
+
plan.warnings.append(f"a legenda não achou lugar livre do rosto em {len(no_room)} trecho(s) "
|
|
2363
|
+
f"({total:.1f}s, o primeiro em {fmt_time(no_room[0][0] / fps)}): ficou na altura padrão")
|
|
2364
|
+
if tv_manual:
|
|
2365
|
+
moved = [(a, b, c) for a, b, c in cp["base"] if abs(c - cp["manual"]) > 0.5]
|
|
2366
|
+
for a, b, c in moved:
|
|
2367
|
+
plan.adjustments.append({"layer": "captions", "action": "moved", "start": round(a / fps, 3),
|
|
2368
|
+
"end": round(b / fps, 3), "because": "rosto (tela verde)",
|
|
2369
|
+
"from_center_y": to_out(cp["manual"]), "center_y": to_out(c)})
|
|
2370
|
+
if moved:
|
|
2371
|
+
total = sum(b - a for a, b, _ in moved) / fps
|
|
2372
|
+
plan.warnings.append(f"captions.center_y {to_out(cp['manual'])} cairia no rosto na tela verde (você "
|
|
2373
|
+
f"mudou de lugar): em {total:.1f}s a legenda foi para a altura livre mais perto "
|
|
2374
|
+
f"({to_out(moved[0][2])}). Tire o center_y para ela se ajustar sozinha")
|
|
2375
|
+
|
|
2376
|
+
# ---------------- 2. CTA: off the faces, and off the captions when there is room
|
|
2377
|
+
if ct:
|
|
2378
|
+
above, below = ct["ext"]
|
|
2379
|
+
if ct["manual"] is not None:
|
|
2380
|
+
ct["cy"] = ct["manual"]
|
|
2381
|
+
if plan.tv is not None:
|
|
2382
|
+
ct["cy"] = manual_off_face(plan, "cta", ct["manual"], above, below, ct["f0"], ct["f1"], lo, hi, to_out)
|
|
2383
|
+
else:
|
|
2384
|
+
faces = face_bands(plan, ct["f0"], ct["f1"], gap=g_face)
|
|
2385
|
+
caps = caption_bands(ct["f0"], ct["f1"]) if cp and cp["has_cues"] else []
|
|
2386
|
+
body = person_bands(plan, ct["f0"], ct["f1"], gap=g_face)
|
|
2387
|
+
c = None
|
|
2388
|
+
imgs = image_bands(plan, ct["f0"], ct["f1"], gap=g_face) if body else []
|
|
2389
|
+
if imgs:
|
|
2390
|
+
c = pick_center([ct["pref"]], above, below, faces + body + imgs + caps, lo, hi) if caps else None
|
|
2391
|
+
if c is None:
|
|
2392
|
+
c = pick_center([ct["pref"]], above, below, faces + body + imgs, lo, hi)
|
|
2393
|
+
if body and c is None:
|
|
2394
|
+
c = pick_center([ct["pref"]], above, below, faces + body + caps, lo, hi) if caps else None
|
|
2395
|
+
if c is None:
|
|
2396
|
+
c = pick_center([ct["pref"]], above, below, faces + body, lo, hi)
|
|
2397
|
+
if c is None:
|
|
2398
|
+
c = pick_center([ct["pref"]], above, below, faces + caps, lo, hi) if caps else None
|
|
2399
|
+
if c is None:
|
|
2400
|
+
c = pick_center([ct["pref"]], above, below, faces, lo, hi)
|
|
2401
|
+
if c is None:
|
|
2402
|
+
c = ct["pref"]
|
|
2403
|
+
plan.warnings.append("a chamada não achou lugar livre do rosto: ficou na posição padrão")
|
|
2404
|
+
ct["cy"] = c
|
|
2405
|
+
ct["img"] = tr.render_cta(ct["text"], ct["sub"], plan.accent, canvas=(W, H), center_y=ct["cy"])
|
|
2406
|
+
ct["band"] = (ct["cy"] - above, ct["cy"] + below)
|
|
2407
|
+
|
|
2408
|
+
# ---------------- 3. hook: off the faces and the CTA
|
|
2409
|
+
if hk:
|
|
2410
|
+
img0 = tr.render_hook(hk["text"], hk["style"], plan.accent, canvas=(W, H), center_y=int(round(lay["hook"])),
|
|
2411
|
+
max_width=hk["maxw"])
|
|
2412
|
+
bb = tr.ink_bbox(img0)
|
|
2413
|
+
above, below = ((lay["hook"] - bb[1]) * HOOK_MAX_SCALE, (bb[3] - lay["hook"]) * HOOK_MAX_SCALE) if bb else (0.0, 0.0)
|
|
2414
|
+
if hk["manual"] is not None:
|
|
2415
|
+
cy = hk["manual"]
|
|
2416
|
+
if plan.tv is not None:
|
|
2417
|
+
cy = manual_off_face(plan, "hook", hk["manual"], above, below, hk["f0"], hk["f1"], lo, hi, to_out)
|
|
2418
|
+
else:
|
|
2419
|
+
faces = face_bands(plan, hk["f0"], hk["f1"], gap=g_face)
|
|
2420
|
+
highs = [(ct["band"][0] - g_text, ct["band"][1] + g_text)] if ct and overlaps(hk["f0"], hk["f1"], ct["f0"], ct["f1"]) else []
|
|
2421
|
+
caps = caption_bands(hk["f0"], hk["f1"]) if cp and cp["has_cues"] else []
|
|
2422
|
+
body = person_bands(plan, hk["f0"], hk["f1"], gap=g_face)
|
|
2423
|
+
cy = None
|
|
2424
|
+
imgs = image_bands(plan, hk["f0"], hk["f1"], gap=g_face) if body else []
|
|
2425
|
+
if imgs:
|
|
2426
|
+
cy = pick_center([lay["hook"]], above, below, faces + body + imgs + highs + caps, lo, hi) if caps else None
|
|
2427
|
+
if cy is None:
|
|
2428
|
+
cy = pick_center([lay["hook"]], above, below, faces + body + imgs + highs, lo, hi)
|
|
2429
|
+
if body and cy is None:
|
|
2430
|
+
cy = pick_center([lay["hook"]], above, below, faces + body + highs + caps, lo, hi) if caps else None
|
|
2431
|
+
if cy is None:
|
|
2432
|
+
cy = pick_center([lay["hook"]], above, below, faces + body + highs, lo, hi)
|
|
2433
|
+
if cy is None:
|
|
2434
|
+
cy = pick_center([lay["hook"]], above, below, faces + highs + caps, lo, hi) if caps else None
|
|
2435
|
+
if cy is None:
|
|
2436
|
+
cy = pick_center([lay["hook"]], above, below, faces + highs, lo, hi)
|
|
2437
|
+
if cy is None:
|
|
2438
|
+
cy = lay["hook"]
|
|
2439
|
+
plan.warnings.append("o gancho não achou lugar livre do rosto: ficou na posição padrão")
|
|
2440
|
+
hk["cy"] = cy
|
|
2441
|
+
hk["img"] = img0 if int(round(cy)) == int(round(lay["hook"])) else tr.render_hook(
|
|
2442
|
+
hk["text"], hk["style"], plan.accent, canvas=(W, H), center_y=int(round(cy)), max_width=hk["maxw"])
|
|
2443
|
+
hk["band"] = (cy - above, cy + below)
|
|
2444
|
+
|
|
2445
|
+
# ---------------- 3b. animations: off the faces, the CTA and the hook (and the captions when it is cheap)
|
|
2446
|
+
if motions:
|
|
2447
|
+
t_mo = time.time()
|
|
2448
|
+
highs_mo = [(why, cfg["f0"], cfg["f1"], (cfg["band"][0] - g_text, cfg["band"][1] + g_text))
|
|
2449
|
+
for why, cfg in (("chamada", ct), ("gancho", hk)) if cfg]
|
|
2450
|
+
place_motion(plan, motions, highs_mo,
|
|
2451
|
+
lambda a, b: caption_bands(a, b) if cp and cp["has_cues"] else [])
|
|
2452
|
+
cached = sum(1 for m in motions if m.clip and m.clip.get("cached"))
|
|
2453
|
+
log(f"animações prontas ({len(motions)}{f', {cached} do cache' if cached else ''}) em {time.time() - t_mo:.1f}s")
|
|
2454
|
+
motions = [m for m in motions if m.ink is not None]
|
|
2455
|
+
above_motion = []
|
|
2456
|
+
if ct:
|
|
2457
|
+
above_motion.append(("cta", ct["f0"], ct["f1"],
|
|
2458
|
+
grow_box(tr.ink_bbox(ct["img"]), CTA_MAX_SCALE, CTA_SLIDE * s)))
|
|
2459
|
+
if hk:
|
|
2460
|
+
above_motion.append(("hook", hk["f0"], hk["f1"], grow_box(tr.ink_bbox(hk["img"]), HOOK_MAX_SCALE)))
|
|
2461
|
+
resolve_motion_windows(plan, motions, above_motion)
|
|
2462
|
+
|
|
2463
|
+
# ---------------- 4. captions step aside (auto) or hide (manual, or no room) for the CTA, the hook and the animations
|
|
2464
|
+
if cp and cp["has_cues"]:
|
|
2465
|
+
above, below = cp["ext"]
|
|
2466
|
+
highs = []
|
|
2467
|
+
if ct:
|
|
2468
|
+
highs.append(("cta", ct["f0"], ct["f1"], ct["band"]))
|
|
2469
|
+
if hk:
|
|
2470
|
+
highs.append(("hook", hk["f0"], hk["f1"], hk["band"]))
|
|
2471
|
+
for m in motions:
|
|
2472
|
+
highs += [("motion", a, b, (m.ink[1], m.ink[3])) for a, b in m.shown]
|
|
2473
|
+
for name, f0, f1, band in highs:
|
|
2474
|
+
for a, b, cyc in caption_pieces(f0, f1):
|
|
2475
|
+
if cyc + below + g_text <= band[0] or cyc - above - g_text >= band[1]:
|
|
2476
|
+
continue
|
|
2477
|
+
new = None
|
|
2478
|
+
if cp["manual"] is None:
|
|
2479
|
+
blocked = face_bands(plan, a, b, gap=g_face) + [
|
|
2480
|
+
(hb[0] - g_text, hb[1] + g_text) for _, x0, x1, hb in highs if overlaps(a, b, x0, x1)]
|
|
2481
|
+
body = person_bands(plan, a, b, gap=g_face)
|
|
2482
|
+
if body:
|
|
2483
|
+
new = pick_center([cyc], above, below, blocked + body, lo, hi)
|
|
2484
|
+
if new is None:
|
|
2485
|
+
new = pick_center([cyc], above, below, blocked, lo, hi)
|
|
2486
|
+
if new is not None:
|
|
2487
|
+
cp["moves"].append({"start": a / fps, "end": b / fps, "center_y": new})
|
|
2488
|
+
plan.adjustments.append({"layer": "captions", "action": "moved", "start": round(a / fps, 3),
|
|
2489
|
+
"end": round(b / fps, 3), "because": name, "from_center_y": to_out(cyc),
|
|
2490
|
+
"center_y": to_out(new)})
|
|
2491
|
+
else:
|
|
2492
|
+
cp["hide"].append((a, b))
|
|
2493
|
+
plan.adjustments.append({"layer": "captions", "action": "hidden", "start": round(a / fps, 3),
|
|
2494
|
+
"end": round(b / fps, 3), "because": name})
|
|
2495
|
+
|
|
2496
|
+
# ---------------- 5. disclaimer: off the faces and every text on screen with it
|
|
2497
|
+
if dc:
|
|
2498
|
+
above, below = dc["ext"]
|
|
2499
|
+
spans: list[tuple[int, int, float]] = []
|
|
2500
|
+
if dc["manual"] is not None:
|
|
2501
|
+
spans = [(dc["f0"], dc["f1"], dc["manual"])]
|
|
2502
|
+
else:
|
|
2503
|
+
pref = lay["disclaimer"]
|
|
2504
|
+
top_anchor = lo + above + g_text
|
|
2505
|
+
|
|
2506
|
+
def text_bands(a, b):
|
|
2507
|
+
out = []
|
|
2508
|
+
for cfg in (hk, ct):
|
|
2509
|
+
if cfg and overlaps(a, b, cfg["f0"], cfg["f1"]):
|
|
2510
|
+
out.append((cfg["band"][0] - g_text, cfg["band"][1] + g_text))
|
|
2511
|
+
for m in motions:
|
|
2512
|
+
if any(overlaps(a, b, x0, x1) for x0, x1 in m.shown):
|
|
2513
|
+
out.append((m.ink[1] - g_text, m.ink[3] + g_text))
|
|
2514
|
+
if cp and cp["has_cues"]:
|
|
2515
|
+
out += caption_bands(a, b)
|
|
2516
|
+
return out
|
|
2517
|
+
|
|
2518
|
+
def choose_disc(a, b, prev=None):
|
|
2519
|
+
txt = text_bands(a, b)
|
|
2520
|
+
body = person_bands(plan, a, b, gap=g_face)
|
|
2521
|
+
c = None
|
|
2522
|
+
if body:
|
|
2523
|
+
c = pick_center([prev, pref, top_anchor], above, below,
|
|
2524
|
+
face_bands(plan, a, b, gap=g_face) + body + txt, lo, disc_hi, pref)
|
|
2525
|
+
if c is None:
|
|
2526
|
+
c = pick_center([prev, pref, top_anchor], above, below, face_bands(plan, a, b, gap=g_face) + txt,
|
|
2527
|
+
lo, disc_hi, pref)
|
|
2528
|
+
if c is None: # small print may sit on the chin or the forehead, never on the eyes or the mouth
|
|
2529
|
+
c = pick_center([prev, pref, top_anchor], above, below,
|
|
2530
|
+
face_bands(plan, a, b, core=True, gap=3 * g_face) + txt, lo, disc_hi, pref)
|
|
2531
|
+
return c
|
|
2532
|
+
|
|
2533
|
+
c = choose_disc(dc["f0"], dc["f1"])
|
|
2534
|
+
if c is not None:
|
|
2535
|
+
spans = [(dc["f0"], dc["f1"], c)]
|
|
2536
|
+
else:
|
|
2537
|
+
cuts = {dc["f0"], dc["f1"]}
|
|
2538
|
+
for a, b, _, _ in plan.zones:
|
|
2539
|
+
cuts |= {a, b}
|
|
2540
|
+
for cfg in (hk, ct):
|
|
2541
|
+
if cfg:
|
|
2542
|
+
cuts |= {cfg["f0"], cfg["f1"]}
|
|
2543
|
+
for m in motions:
|
|
2544
|
+
for x0, x1 in m.shown:
|
|
2545
|
+
cuts |= {x0, x1}
|
|
2546
|
+
if cp:
|
|
2547
|
+
for a, b, _ in layout_pieces(caption_windows(), fps, 0, nf, lay["caption"]):
|
|
2548
|
+
cuts |= {a, b}
|
|
2549
|
+
for a, b in cp["hide"]:
|
|
2550
|
+
cuts |= {a, b}
|
|
2551
|
+
points = sorted(f for f in cuts if dc["f0"] <= f <= dc["f1"])
|
|
2552
|
+
prev = None
|
|
2553
|
+
for a, b in zip(points, points[1:]):
|
|
2554
|
+
if b <= a:
|
|
2555
|
+
continue
|
|
2556
|
+
c = choose_disc(a, b, prev)
|
|
2557
|
+
if c is None:
|
|
2558
|
+
plan.adjustments.append({"layer": "disclaimer", "action": "hidden", "start": round(a / fps, 3),
|
|
2559
|
+
"end": round(b / fps, 3), "because": "sem lugar livre"})
|
|
2560
|
+
prev = None
|
|
2561
|
+
continue
|
|
2562
|
+
if spans and spans[-1][1] == a and spans[-1][2] == c:
|
|
2563
|
+
spans[-1] = (spans[-1][0], b, c)
|
|
2564
|
+
else:
|
|
2565
|
+
spans.append((a, b, c))
|
|
2566
|
+
prev = c
|
|
2567
|
+
for a, b, c in spans:
|
|
2568
|
+
if abs(c - pref) > 0.5:
|
|
2569
|
+
plan.adjustments.append({"layer": "disclaimer", "action": "moved", "start": round(a / fps, 3),
|
|
2570
|
+
"end": round(b / fps, 3), "because": "rosto ou outro texto",
|
|
2571
|
+
"from_center_y": to_out(pref), "center_y": to_out(c)})
|
|
2572
|
+
dc["imgs"] = {}
|
|
2573
|
+
for _, _, c in spans:
|
|
2574
|
+
key = f"disc{int(round(c))}"
|
|
2575
|
+
if key not in dc["imgs"]:
|
|
2576
|
+
dc["imgs"][key] = (c, tr.render_disclaimer(dc["text"], canvas=(W, H), center_y=c))
|
|
2577
|
+
dc["spans"] = [(a, b, f"disc{int(round(c))}") for a, b, c in spans]
|
|
2578
|
+
|
|
2579
|
+
# ---------------- 6. caption track at the chosen heights
|
|
2580
|
+
states: list[dict] = []
|
|
2581
|
+
if cp:
|
|
2582
|
+
layout = caption_windows()
|
|
2583
|
+
hide_s = [[a / fps, b / fps] for a, b in merge_windows(cp["hide"])]
|
|
2584
|
+
cap_dir = plan.renders / f"captions_{cp['style']}_{W}x{H}"
|
|
2585
|
+
states = tr.render_caption_track(cp["cues"], cp["style"], plan.accent, str(cap_dir), canvas=(W, H), fps=fps,
|
|
2586
|
+
layout=layout or None, hide=hide_s or None, font_scale=cp["font_scale"])
|
|
2587
|
+
states = [st for st in states if st["start"] < plan.duration]
|
|
2588
|
+
|
|
2589
|
+
# ---------------- 7. what is really drawn: overlapping texts resolved by priority
|
|
2590
|
+
drawn: dict[str, list[tuple]] = {"cta": [], "hook": [], "motion": [], "captions": [], "disclaimer": []}
|
|
2591
|
+
if ct:
|
|
2592
|
+
drawn["cta"] = [(ct["f0"], ct["f1"], grow_box(tr.ink_bbox(ct["img"]), CTA_MAX_SCALE, CTA_SLIDE * s), None)]
|
|
2593
|
+
if hk:
|
|
2594
|
+
drawn["hook"] = [(hk["f0"], hk["f1"], grow_box(tr.ink_bbox(hk["img"]), HOOK_MAX_SCALE), None)]
|
|
2595
|
+
drawn["motion"] = [(a, b, m.ink, m.idx) for m in motions for a, b in m.shown] # resolved in 3b
|
|
2596
|
+
if cp:
|
|
2597
|
+
drawn["captions"] = [(plan.fr(st["start"]), plan.fr(st["end"]), tuple(st["bbox"]) if st.get("bbox") else None,
|
|
2598
|
+
st["file"]) for st in states]
|
|
2599
|
+
if dc:
|
|
2600
|
+
boxes = {k: tr.ink_bbox(img) for k, (_, img) in dc["imgs"].items()}
|
|
2601
|
+
drawn["disclaimer"] = [(a, b, boxes[k], k) for a, b, k in dc["spans"]]
|
|
2602
|
+
for i, low in enumerate(PRIORITY):
|
|
2603
|
+
if low == "motion":
|
|
2604
|
+
continue
|
|
2605
|
+
hits: dict[str, list[tuple[int, int]]] = {}
|
|
2606
|
+
for high in PRIORITY[:i]:
|
|
2607
|
+
for h0, h1, hbox, _ in drawn[high]:
|
|
2608
|
+
for l0, l1, lbox, _ in drawn[low]:
|
|
2609
|
+
a, b = max(h0, l0), min(h1, l1)
|
|
2610
|
+
if b > a and boxes_hit(hbox, lbox):
|
|
2611
|
+
hits.setdefault(high, []).append((a, b))
|
|
2612
|
+
if hits:
|
|
2613
|
+
windows = merge_windows([w for ws in hits.values() for w in ws])
|
|
2614
|
+
min_f = max(1, int(round(MIN_PIECE.get(low, 0.0) * fps)))
|
|
2615
|
+
before = sum(span[1] - span[0] for span in drawn[low])
|
|
2616
|
+
kept = []
|
|
2617
|
+
for span in drawn[low]:
|
|
2618
|
+
for piece in subtract_windows([span], windows):
|
|
2619
|
+
if (piece[0], piece[1]) != (span[0], span[1]) and piece[1] - piece[0] < min_f:
|
|
2620
|
+
# a sliver left between two hides would blink on screen: hide it with its neighbour
|
|
2621
|
+
high = next((h for h, ws in hits.items() for a, b in ws if b == piece[0] or a == piece[1]),
|
|
2622
|
+
next(iter(hits)))
|
|
2623
|
+
hits[high].append((piece[0], piece[1]))
|
|
2624
|
+
else:
|
|
2625
|
+
kept.append(piece)
|
|
2626
|
+
drawn[low] = kept
|
|
2627
|
+
for high, ws in hits.items():
|
|
2628
|
+
for a, b in merge_windows(ws):
|
|
2629
|
+
plan.adjustments.append({"layer": low, "action": "hidden", "start": round(a / fps, 3),
|
|
2630
|
+
"end": round(b / fps, 3), "because": high})
|
|
2631
|
+
hidden_share = 1.0 - sum(span[1] - span[0] for span in kept) / max(1, before)
|
|
2632
|
+
if low == "disclaimer" and hidden_share > DISCLAIMER_HIDDEN_WARN:
|
|
2633
|
+
names = [OF_LABELS[h] for h in PRIORITY if h in hits]
|
|
2634
|
+
behind = names[0] if len(names) == 1 else ", ".join(names[:-1]) + " e " + names[-1]
|
|
2635
|
+
plan.warnings.append(f"o aviso ficou escondido em {round(hidden_share * 100)}% do tempo para não ficar em cima "
|
|
2636
|
+
f"{behind}. Deixe disclaimer.center_y em \"auto\" ou escolha outra altura")
|
|
2637
|
+
|
|
2638
|
+
# ---------------- 8. text still over a face (manual heights, or no free spot)
|
|
2639
|
+
face_report(plan, drawn, {"cta": ct, "hook": hk, "captions": cp, "disclaimer": dc}, lay, disc_hi)
|
|
2640
|
+
motion_face_report(plan, motions)
|
|
2641
|
+
|
|
2642
|
+
# ---------------- 9. tracks
|
|
2643
|
+
for m in motions:
|
|
2644
|
+
if not m.shown:
|
|
2645
|
+
plan.warnings.append(f"a {m.name} ficou escondida o tempo todo e não entrou no vídeo")
|
|
2646
|
+
continue
|
|
2647
|
+
result["motion"].append((m.clip, m.f0, m.shown))
|
|
2648
|
+
m.cues = motion_cues(m)
|
|
2649
|
+
for cue in m.cues:
|
|
2650
|
+
if m.sfx and sfx_on and any(a <= cue["frame"] < b for a, b in m.shown):
|
|
2651
|
+
result["sfx_events"].append(cue)
|
|
2652
|
+
mid = Track(plan, "mid")
|
|
2653
|
+
add_decorations(plan, mid, inserts)
|
|
2654
|
+
if hk:
|
|
2655
|
+
mid.add_image("hook", hk["img"])
|
|
2656
|
+
z = mid.next_z()
|
|
2657
|
+
shown = drawn["hook"]
|
|
2658
|
+
h0, h1 = hk["f0"], hk["f1"]
|
|
2659
|
+
n = h1 - h0
|
|
2660
|
+
animate = n >= len(HOOK_IN) + len(HOOK_OUT) + 2
|
|
2661
|
+
for j in range(n):
|
|
2662
|
+
f = h0 + j
|
|
2663
|
+
if not any(a <= f < b for a, b, _, _ in shown):
|
|
2664
|
+
continue
|
|
2665
|
+
sc, al = 1.0, 1.0
|
|
2666
|
+
if animate and j < len(HOOK_IN):
|
|
2667
|
+
sc, al = HOOK_IN[j]
|
|
2668
|
+
elif animate and j >= n - len(HOOK_OUT):
|
|
2669
|
+
al = HOOK_OUT[j - (n - len(HOOK_OUT))]
|
|
2670
|
+
mid.add(f, f + 1, ("img", "hook", sc, al, 0.0), z=z)
|
|
2671
|
+
plan.layers.append({"type": "hook", "style": hk["style"], "text": hk["text"], "start": h0 / fps, "end": h1 / fps,
|
|
2672
|
+
"frames": [h0, h1], "lines": hk["fit"]["lines"], "center_y": to_out(hk["cy"]),
|
|
2673
|
+
"auto": hk["manual"] is None})
|
|
2674
|
+
snd = hook.get("sfx", "pop")
|
|
2675
|
+
if sfx_on and snd:
|
|
2676
|
+
result["sfx_events"].append({"name": str(snd), "frame": h0, "lead_frames": 0, "why": "gancho"})
|
|
2677
|
+
result["mid"] = mid.build(out_dir)
|
|
2678
|
+
|
|
2679
|
+
if cp:
|
|
2680
|
+
by_file = {}
|
|
2681
|
+
final_states = []
|
|
2682
|
+
for a, b, bbox, file in drawn["captions"]:
|
|
2683
|
+
final_states.append({"file": file, "start": round(a / fps, 6), "end": round(b / fps, 6),
|
|
2684
|
+
"bbox": list(bbox) if bbox else None})
|
|
2685
|
+
by_file[file] = True
|
|
2686
|
+
cap_dir = plan.renders / f"captions_{cp['style']}_{W}x{H}"
|
|
2687
|
+
ff = tr.write_ffconcat(final_states, str(cap_dir / "captions.ffconcat"), total_duration=plan.duration,
|
|
2688
|
+
blank_png=str(cap_dir / "blank.png"), fps=fps)
|
|
2689
|
+
windows = merge_windows([(plan.fr(st["start"]), plan.fr(st["end"])) for st in final_states])
|
|
2690
|
+
result["captions"] = (ff, final_states, windows)
|
|
2691
|
+
plan.layers.append({"type": "captions", "style": cp["style"], "file": plan.rel(cp["file"]),
|
|
2692
|
+
"words_from": cp["cues"].get("words_from") if isinstance(cp["cues"], dict) else None,
|
|
2693
|
+
"states": len(final_states), "png_files": len(by_file),
|
|
2694
|
+
"start": round(final_states[0]["start"], 3) if final_states else None,
|
|
2695
|
+
"end": round(final_states[-1]["end"], 3) if final_states else None,
|
|
2696
|
+
"hide": [[round(a / fps, 3), round(b / fps, 3)] for a, b in merge_windows(cp["hide"])],
|
|
2697
|
+
"layout": [{"start": round(w["start"], 3), "end": round(w["end"], 3),
|
|
2698
|
+
"center_y": to_out(w["center_y"])} for w in caption_windows()],
|
|
2699
|
+
"auto": cp["manual"] is None, "font_scale": cp["font_scale"]})
|
|
2700
|
+
|
|
2701
|
+
top = Track(plan, "top")
|
|
2702
|
+
if dc and dc["spans"]:
|
|
2703
|
+
for key, (_, img) in dc["imgs"].items():
|
|
2704
|
+
top.add_image(key, img)
|
|
2705
|
+
z = top.next_z()
|
|
2706
|
+
d0 = dc["f0"]
|
|
2707
|
+
for a, b, _, key in drawn["disclaimer"]:
|
|
2708
|
+
fade = 4 if (a == d0 and d0 > 0) else 0
|
|
2709
|
+
for j in range(min(fade, b - a)):
|
|
2710
|
+
top.add(a + j, a + j + 1, ("img", key, 1.0, round((j + 1) / (fade + 1), 3), 0.0), z=z)
|
|
2711
|
+
top.add(a + min(fade, b - a), b, ("img", key, 1.0, 1.0, 0.0), z=z)
|
|
2712
|
+
centers = sorted({to_out(c) for c, _ in dc["imgs"].values()})
|
|
2713
|
+
plan.layers.append({"type": "disclaimer", "text": dc["text"], "start": dc["f0"] / fps, "end": dc["f1"] / fps,
|
|
2714
|
+
"frames": [dc["f0"], dc["f1"]], "center_y": centers[0] if len(centers) == 1 else centers,
|
|
2715
|
+
"auto": dc["manual"] is None,
|
|
2716
|
+
"shown": [[round(a / fps, 3), round(b / fps, 3)] for a, b in
|
|
2717
|
+
merge_windows([(a, b) for a, b, _, _ in drawn["disclaimer"]])]})
|
|
2718
|
+
if ct:
|
|
2719
|
+
top.add_image("cta", ct["img"])
|
|
2720
|
+
z = top.next_z()
|
|
2721
|
+
c0, c1 = ct["f0"], ct["f1"]
|
|
2722
|
+
n_in = min(len(CTA_IN), c1 - c0)
|
|
2723
|
+
for j in range(n_in):
|
|
2724
|
+
sc, al, dy = CTA_IN[j]
|
|
2725
|
+
top.add(c0 + j, c0 + j + 1, ("img", "cta", sc, al, round(dy * s, 1)), z=z)
|
|
2726
|
+
# gentle pulse after the entrance (quantized so PNGs repeat)
|
|
2727
|
+
for f in range(c0 + n_in, c1):
|
|
2728
|
+
tt = (f - c0 - n_in) / fps
|
|
2729
|
+
sc = 1.0 + 0.028 * (0.5 - 0.5 * math.cos(2 * math.pi * tt / 1.1))
|
|
2730
|
+
sc = round(round(sc / 0.004) * 0.004, 3)
|
|
2731
|
+
top.add(f, f + 1, ("img", "cta", sc, 1.0, 0.0), z=z)
|
|
2732
|
+
plan.layers.append({"type": "cta", "text": ct["text"], "sub": ct["sub"], "start": c0 / fps, "end": c1 / fps,
|
|
2733
|
+
"frames": [c0, c1], "center_y": to_out(ct["cy"]), "auto": ct["manual"] is None})
|
|
2734
|
+
snd = cta.get("sfx")
|
|
2735
|
+
if sfx_on and snd:
|
|
2736
|
+
result["sfx_events"].append({"name": str(snd), "frame": c0, "lead_frames": 0, "why": "chamada"})
|
|
2737
|
+
for k, trn in enumerate(data.get("transitions") or []):
|
|
2738
|
+
if not isinstance(trn, dict):
|
|
2739
|
+
continue
|
|
2740
|
+
if (trn.get("type") or "flash") != "flash":
|
|
2741
|
+
plan.warnings.append(f"transição '{trn.get('type')}' não existe (só 'flash') e foi ignorada")
|
|
2742
|
+
continue
|
|
2743
|
+
try:
|
|
2744
|
+
f = plan.fr(float(trn["at"]))
|
|
2745
|
+
except (KeyError, TypeError, ValueError):
|
|
2746
|
+
plan.warnings.append(f"transitions[{k}] sem 'at' válido foi ignorada")
|
|
2747
|
+
continue
|
|
2748
|
+
z = top.next_z()
|
|
2749
|
+
for off, al in FLASH:
|
|
2750
|
+
top.add(f + off, f + off + 1, ("flash", al), z=z)
|
|
2751
|
+
plan.layers.append({"type": "flash", "at": f / fps, "frame": f,
|
|
2752
|
+
"frames": [max(0, f - 1), min(nf, f + 4)]})
|
|
2753
|
+
snd = trn.get("sfx", "click")
|
|
2754
|
+
if sfx_on and snd:
|
|
2755
|
+
result["sfx_events"].append({"name": str(snd), "frame": f, "lead_frames": 0, "why": "flash"})
|
|
2756
|
+
result["top"] = top.build(out_dir)
|
|
2757
|
+
return result
|
|
2758
|
+
|
|
2759
|
+
|
|
2760
|
+
def face_report(plan: Plan, drawn: dict, cfgs: dict, lay: dict, disc_hi: float) -> None:
|
|
2761
|
+
"""List texts still covering the eyes, nose or mouth, with a center_y that would free them."""
|
|
2762
|
+
fps, H = plan.fps, plan.H
|
|
2763
|
+
g_face = GAP_FACE * H
|
|
2764
|
+
for layer in TEXT_LAYERS:
|
|
2765
|
+
hits = []
|
|
2766
|
+
for f0, f1, box, _ in drawn[layer]:
|
|
2767
|
+
for a, b, _, cores in plan.zones:
|
|
2768
|
+
lo_f, hi_f = max(f0, a), min(f1, b)
|
|
2769
|
+
if hi_f <= lo_f:
|
|
2770
|
+
continue
|
|
2771
|
+
share = max([cover_share(box, core) for core in cores] or [0.0])
|
|
2772
|
+
if share > FACE_COVER_WARN:
|
|
2773
|
+
hits.append((lo_f, hi_f, share))
|
|
2774
|
+
if not hits:
|
|
2775
|
+
continue
|
|
2776
|
+
hits.sort()
|
|
2777
|
+
merged: list[list] = []
|
|
2778
|
+
for a, b, share in hits:
|
|
2779
|
+
if merged and a <= merged[-1][1] + 1:
|
|
2780
|
+
merged[-1][1] = max(merged[-1][1], b)
|
|
2781
|
+
merged[-1][2] = max(merged[-1][2], share)
|
|
2782
|
+
else:
|
|
2783
|
+
merged.append([a, b, share])
|
|
2784
|
+
cfg = cfgs.get(layer) or {}
|
|
2785
|
+
worst = max(merged, key=lambda m: m[2])
|
|
2786
|
+
pref = {"cta": cfg.get("pref"), "hook": lay["hook"], "captions": lay["caption"], "disclaimer": lay["disclaimer"]}[layer]
|
|
2787
|
+
ext = cfg.get("ext")
|
|
2788
|
+
if layer == "hook" and cfg.get("band") and cfg.get("cy") is not None:
|
|
2789
|
+
ext = (cfg["cy"] - cfg["band"][0], cfg["band"][1] - cfg["cy"])
|
|
2790
|
+
suggest = None
|
|
2791
|
+
if ext:
|
|
2792
|
+
hi = disc_hi if layer == "disclaimer" else lay["bottom"]
|
|
2793
|
+
c = pick_center([pref], ext[0], ext[1], face_bands(plan, worst[0], worst[1], gap=g_face), lay["top"], hi)
|
|
2794
|
+
suggest = int(round(c / plan.s)) if c is not None else None
|
|
2795
|
+
for a, b, share in merged[:20]:
|
|
2796
|
+
plan.face_checks.append({"layer": layer, "start": round(a / fps, 3), "end": round(b / fps, 3),
|
|
2797
|
+
"cover": round(share, 3), "suggest_center_y": suggest})
|
|
2798
|
+
where = (f"em {fmt_time(worst[0] / fps)}" if worst[1] - worst[0] <= fps
|
|
2799
|
+
else f"entre {fmt_time(worst[0] / fps)} e {fmt_time(worst[1] / fps)}")
|
|
2800
|
+
on_seam = layer == "captions" and any(
|
|
2801
|
+
plan.fr(sm["start"]) < worst[1] and worst[0] < plan.fr(sm["end"]) for sm in cfg.get("seams") or [])
|
|
2802
|
+
if on_seam:
|
|
2803
|
+
tip = ("Nesse trecho a legenda fica na divisão da tela dividida: troque split_top por split_bottom (ou o "
|
|
2804
|
+
"contrário) ou mude o split desse insert")
|
|
2805
|
+
elif cfg.get("manual") is None:
|
|
2806
|
+
tip = ("Não sobrou lugar livre do rosto com os outros textos desse trecho: encurte um texto ou tire um "
|
|
2807
|
+
"deles desse trecho")
|
|
2808
|
+
elif suggest is not None:
|
|
2809
|
+
tip = f"Use {CENTER_KEYS[layer]} perto de {suggest} (ou \"auto\")"
|
|
2810
|
+
else:
|
|
2811
|
+
tip = "Não há lugar livre do rosto: encurte o texto ou use tela dividida"
|
|
2812
|
+
plan.warnings.append(f"{LABELS[layer]} cobre o rosto {where} ({round(worst[2] * 100)}%). {tip}")
|
|
2813
|
+
|
|
2814
|
+
|
|
2815
|
+
def motion_cues(m: Motion) -> list[dict]:
|
|
2816
|
+
"""The template's sound cues on the output frames, in the sfx.events shape (why "animação N")."""
|
|
2817
|
+
out = []
|
|
2818
|
+
for cue in m.clip.get("sfx") or []:
|
|
2819
|
+
ev = {"name": str(cue["name"]), "frame": m.f0 + int(cue["frame"]), "lead_frames": 0,
|
|
2820
|
+
"why": f"animação {m.idx}", "motion": m.idx, "volume": float(cue.get("volume", 1.0))}
|
|
2821
|
+
if cue.get("length"):
|
|
2822
|
+
ev["length"] = float(cue["length"])
|
|
2823
|
+
out.append(ev)
|
|
2824
|
+
return out
|
|
2825
|
+
|
|
2826
|
+
|
|
2827
|
+
def resolve_motion_windows(plan: Plan, motions: list[Motion], highs: list[tuple]) -> None:
|
|
2828
|
+
"""m.shown: where each animation really shows. The CTA and the hook (above it in PRIORITY) hide it where their
|
|
2829
|
+
boxes touch it, and a leftover shorter than MIN_PIECE["motion"] is hidden with its neighbour. Done before the
|
|
2830
|
+
captions and the disclaimer are placed, so they only give way to what is really on screen."""
|
|
2831
|
+
fps = plan.fps
|
|
2832
|
+
min_f = max(1, int(round(MIN_PIECE["motion"] * fps)))
|
|
2833
|
+
for m in motions:
|
|
2834
|
+
hits: dict[str, list[tuple[int, int]]] = {}
|
|
2835
|
+
for name, h0, h1, hbox in highs:
|
|
2836
|
+
a, b = max(h0, m.f0), min(h1, m.f1)
|
|
2837
|
+
if b > a and boxes_hit(hbox, m.ink):
|
|
2838
|
+
hits.setdefault(name, []).append((a, b))
|
|
2839
|
+
if not hits:
|
|
2840
|
+
m.shown = [(m.f0, m.f1)]
|
|
2841
|
+
continue
|
|
2842
|
+
kept = []
|
|
2843
|
+
for p0, p1 in subtract_windows([(m.f0, m.f1)], merge_windows([w for ws in hits.values() for w in ws])):
|
|
2844
|
+
if p1 - p0 < min_f:
|
|
2845
|
+
# a sliver left between two hides would blink on screen: hide it with its neighbour
|
|
2846
|
+
high = next((h for h, ws in hits.items() for a, b in ws if b == p0 or a == p1), next(iter(hits)))
|
|
2847
|
+
hits[high].append((p0, p1))
|
|
2848
|
+
else:
|
|
2849
|
+
kept.append((p0, p1))
|
|
2850
|
+
m.shown = kept
|
|
2851
|
+
for high in PRIORITY:
|
|
2852
|
+
for a, b in merge_windows(hits.get(high, [])):
|
|
2853
|
+
plan.adjustments.append({"layer": f"motion {m.idx}", "action": "hidden", "start": round(a / fps, 3),
|
|
2854
|
+
"end": round(b / fps, 3), "because": high})
|
|
2855
|
+
plan.warnings.append(f"a {m.name} ficou escondida de {fmt_time(a / fps)} a {fmt_time(b / fps)} "
|
|
2856
|
+
f"para não ficar em cima {OF_LABELS[high]}. Mude o start, o end ou a region")
|
|
2857
|
+
|
|
2858
|
+
|
|
2859
|
+
def motion_face_report(plan: Plan, motions: list[Motion]) -> None:
|
|
2860
|
+
"""An animation still over the eyes, nose or mouth goes to face_checks and to a warning."""
|
|
2861
|
+
fps = plan.fps
|
|
2862
|
+
for m in motions:
|
|
2863
|
+
hits = []
|
|
2864
|
+
for a, b, _, cores in plan.zones:
|
|
2865
|
+
for w0, w1 in m.shown:
|
|
2866
|
+
lo_f, hi_f = max(a, w0), min(b, w1)
|
|
2867
|
+
if hi_f <= lo_f:
|
|
2868
|
+
continue
|
|
2869
|
+
share = max([cover_share(m.ink, core) for core in cores] or [0.0])
|
|
2870
|
+
if share > FACE_COVER_WARN:
|
|
2871
|
+
hits.append((lo_f, hi_f, share))
|
|
2872
|
+
if not hits:
|
|
2873
|
+
continue
|
|
2874
|
+
hits.sort()
|
|
2875
|
+
worst = max(hits, key=lambda h: h[2])
|
|
2876
|
+
for a, b, share in hits[:20]:
|
|
2877
|
+
plan.face_checks.append({"layer": f"motion {m.idx}", "start": round(a / fps, 3), "end": round(b / fps, 3),
|
|
2878
|
+
"cover": round(share, 3), "suggest_center_y": None,
|
|
2879
|
+
"suggest_region": "auto" if not m.auto else None})
|
|
2880
|
+
if not m.item.template.movable:
|
|
2881
|
+
tip = "Esse modelo fica sempre no mesmo lugar: mude o tempo ou use num trecho sem o rosto ali"
|
|
2882
|
+
elif m.auto:
|
|
2883
|
+
tip = "Não sobrou lugar livre do rosto: use menos itens, outra region ou outro trecho"
|
|
2884
|
+
else:
|
|
2885
|
+
tip = "Use \"region\": \"auto\" para ela fugir do rosto"
|
|
2886
|
+
plan.warnings.append(f"a {m.name} cobre o rosto entre {fmt_time(hits[0][0] / fps)} e {fmt_time(hits[-1][1] / fps)} "
|
|
2887
|
+
f"({round(worst[2] * 100)}%). {tip}")
|
|
2888
|
+
|
|
2889
|
+
|
|
2890
|
+
def motion_layers(plan: Plan, motions: list[Motion]) -> None:
|
|
2891
|
+
"""layers[type=motion] of the report."""
|
|
2892
|
+
fps = plan.fps
|
|
2893
|
+
ko = min(plan.full_w, plan.full_h) / 1080.0
|
|
2894
|
+
for m in motions:
|
|
2895
|
+
if m.clip is None:
|
|
2896
|
+
continue
|
|
2897
|
+
item = m.item
|
|
2898
|
+
asked = m.region_asked
|
|
2899
|
+
layer = {"type": "motion", "index": m.idx, "template": m.template, "label": item.template.label,
|
|
2900
|
+
"start": m.f0 / fps, "end": m.f1 / fps, "frames": [m.f0, m.f1],
|
|
2901
|
+
"shown": [[round(a / fps, 3), round(b / fps, 3)] for a, b in m.shown],
|
|
2902
|
+
"region_asked": asked if asked is not None else "auto", "auto": m.auto,
|
|
2903
|
+
"region": [int(round(v * ko)) for v in item.region], "moved_by": int(round(item.moved_by * ko)),
|
|
2904
|
+
"rect": [m.clip["x"], m.clip["y"], m.clip["w"], m.clip["h"]],
|
|
2905
|
+
"ink": list(m.ink) if m.ink else None,
|
|
2906
|
+
"clip": plan.rel(Path(m.clip["file"])), "cached": bool(m.clip.get("cached")),
|
|
2907
|
+
"params": item.params, "sfx_on": m.sfx,
|
|
2908
|
+
"sfx": [{"name": c["name"], "frame": c["frame"], "time": round(c["frame"] / fps, 3),
|
|
2909
|
+
"volume": c["volume"]} for c in m.cues]}
|
|
2910
|
+
plan.layers.append(layer)
|
|
2911
|
+
|
|
2912
|
+
|
|
2913
|
+
# ============================================================== video graph
|
|
2914
|
+
|
|
2915
|
+
def run_video(plan: Plan, inserts: list[Insert], tracks: dict, cam: str | None, tmp_video: Path,
|
|
2916
|
+
base_clip: Path | None = None) -> None:
|
|
2917
|
+
"""base_clip: the tela verde clip, already on the output grid and size, used in place of the base."""
|
|
2918
|
+
W, H, fps, nf = plan.W, plan.H, plan.fps, plan.nf
|
|
2919
|
+
inputs: list[str] = ["-i", str(base_clip or plan.base)]
|
|
2920
|
+
chains: list[str] = []
|
|
2921
|
+
n_in = 1
|
|
2922
|
+
|
|
2923
|
+
pre = []
|
|
2924
|
+
if base_clip is None and abs(plan.base_fps - fps) > 0.01:
|
|
2925
|
+
pre.append(f"fps={fps}")
|
|
2926
|
+
if base_clip is None and (plan.base_w, plan.base_h) != (W, H):
|
|
2927
|
+
pre.append(f"scale={W}:{H}:force_original_aspect_ratio=increase:flags=bicubic,crop={W}:{H},setsar=1")
|
|
2928
|
+
pre += [f"settb=1/{fps}", "setpts=N", "format=yuv420p"]
|
|
2929
|
+
if cam:
|
|
2930
|
+
pre.append(cam)
|
|
2931
|
+
splits = [i for i in inserts if i.speaker is not None]
|
|
2932
|
+
if splits:
|
|
2933
|
+
labels = "[cur0]" + "".join(f"[spk{i.idx}]" for i in splits)
|
|
2934
|
+
chains.append(f"[0:v]{','.join(pre)},split={len(splits) + 1}{labels}")
|
|
2935
|
+
else:
|
|
2936
|
+
chains.append(f"[0:v]{','.join(pre)}[cur0]")
|
|
2937
|
+
cur = "cur0"
|
|
2938
|
+
step = 0
|
|
2939
|
+
|
|
2940
|
+
def nxt() -> str:
|
|
2941
|
+
nonlocal step
|
|
2942
|
+
step += 1
|
|
2943
|
+
return f"cur{step}"
|
|
2944
|
+
|
|
2945
|
+
for ins in inserts:
|
|
2946
|
+
en = f"between(n,{ins.f0},{ins.f1 - 1})"
|
|
2947
|
+
if ins.speaker is not None:
|
|
2948
|
+
_, sy, sw, sh = ins.speaker
|
|
2949
|
+
lab = nxt()
|
|
2950
|
+
pieces = ins.speaker_pieces or [(ins.f0, ins.f1, ins.speaker_src_y)]
|
|
2951
|
+
ypos = str(pieces[-1][2])
|
|
2952
|
+
for p0, p1, y in reversed(pieces[:-1]): # crop evaluates y on every frame (n = frame of the base)
|
|
2953
|
+
ypos = f"if(lt(n,{p1}),{y},{ypos})"
|
|
2954
|
+
chains.append(f"[spk{ins.idx}]crop={sw}:{sh}:0:'{ypos}'[spkc{ins.idx}]")
|
|
2955
|
+
chains.append(f"[{cur}][spkc{ins.idx}]overlay=x=0:y={sy}:enable='{en}'[{lab}]")
|
|
2956
|
+
cur = lab
|
|
2957
|
+
inputs += ["-i", str(ins.clip)]
|
|
2958
|
+
x, y = ins.content[0], ins.content[1]
|
|
2959
|
+
lab = nxt()
|
|
2960
|
+
chains.append(f"[{n_in}:v]settb=1/{fps},setpts=N+{ins.f0},format=yuv420p[ins{ins.idx}]")
|
|
2961
|
+
chains.append(f"[{cur}][ins{ins.idx}]overlay=x={x}:y={y}:eof_action=pass:enable='{en}'[{lab}]")
|
|
2962
|
+
cur = lab
|
|
2963
|
+
n_in += 1
|
|
2964
|
+
|
|
2965
|
+
motion_tracks = tracks.get("motion") or []
|
|
2966
|
+
if motion_tracks:
|
|
2967
|
+
from motion import render as MR
|
|
2968
|
+
for clip, f0, windows in motion_tracks:
|
|
2969
|
+
inputs += ["-i", str(clip["file"])]
|
|
2970
|
+
lab = nxt()
|
|
2971
|
+
chains.extend(MR.overlay_filters(clip, n_in, cur, lab, f0, windows))
|
|
2972
|
+
cur = lab
|
|
2973
|
+
n_in += 1
|
|
2974
|
+
|
|
2975
|
+
for name in ("mid", "captions", "top"):
|
|
2976
|
+
track = tracks.get(name)
|
|
2977
|
+
if not track or not track[0] or not track[1]:
|
|
2978
|
+
continue
|
|
2979
|
+
ff, _, windows = track
|
|
2980
|
+
inputs += ["-f", "concat", "-safe", "0", "-i", str(ff)]
|
|
2981
|
+
en = enable_expr(windows, nf)
|
|
2982
|
+
lab = nxt()
|
|
2983
|
+
chains.append(f"[{n_in}:v]format=rgba,scale=out_color_matrix=bt709:out_range=tv,format=yuva420p[t{name}]")
|
|
2984
|
+
opt = f":enable='{en}'" if en else ""
|
|
2985
|
+
chains.append(f"[{cur}][t{name}]overlay=0:0:eof_action=pass:format=auto{opt}[{lab}]")
|
|
2986
|
+
cur = lab
|
|
2987
|
+
n_in += 1
|
|
2988
|
+
chains.append(f"[{cur}]format=yuv420p[vout]")
|
|
2989
|
+
|
|
2990
|
+
script = plan.renders / "final_graph.txt"
|
|
2991
|
+
script.write_text(";\n".join(chains) + "\n", encoding="utf-8")
|
|
2992
|
+
crf, preset = ("26", "veryfast") if plan.preview else ("18", "medium")
|
|
2993
|
+
args = ["-v", "error", "-y", *inputs, *graph_args(script), "-map", "[vout]",
|
|
2994
|
+
"-frames:v", str(nf), "-fps_mode", "cfr", "-r", str(fps),
|
|
2995
|
+
"-c:v", "libx264", "-preset", preset, "-crf", crf, "-profile:v", "high", "-pix_fmt", "yuv420p",
|
|
2996
|
+
"-colorspace", "bt709", "-color_primaries", "bt709", "-color_trc", "bt709", "-color_range", "tv",
|
|
2997
|
+
"-x264-params", "colorprim=bt709:transfer=bt709:colormatrix=bt709",
|
|
2998
|
+
"-an", "-f", "mp4", str(tmp_video)]
|
|
2999
|
+
ffmpeg_cmd(args, "montar o vídeo final")
|
|
3000
|
+
|
|
3001
|
+
|
|
3002
|
+
# ============================================================== audio
|
|
3003
|
+
|
|
3004
|
+
def loudnorm_measure(path: Path, extra: list[str] | None = None, i=-14.0, tp=-1.0, lra=11.0) -> dict | None:
|
|
3005
|
+
proc = subprocess.run(["ffmpeg", "-hide_banner", "-nostdin", "-v", "info", *(extra or []), "-i", str(path),
|
|
3006
|
+
"-vn", "-af", f"loudnorm=I={i}:TP={tp}:LRA={lra}:print_format=json", "-f", "null", "-"],
|
|
3007
|
+
capture_output=True, text=True)
|
|
3008
|
+
m = re.search(r"\{[^{}]*\"input_i\"[^{}]*\}", proc.stderr or "", re.S)
|
|
3009
|
+
if not m:
|
|
3010
|
+
return None
|
|
3011
|
+
try:
|
|
3012
|
+
d = json.loads(m.group(0))
|
|
3013
|
+
vals = {k: float(d[k]) for k in ("input_i", "input_tp", "input_lra", "input_thresh", "target_offset")}
|
|
3014
|
+
except (ValueError, KeyError):
|
|
3015
|
+
return None
|
|
3016
|
+
if not all(math.isfinite(v) for v in vals.values()):
|
|
3017
|
+
return None
|
|
3018
|
+
return vals
|
|
3019
|
+
|
|
3020
|
+
|
|
3021
|
+
def _sound_share_kept(path: Path, n_keep: int) -> tuple[float, float] | None:
|
|
3022
|
+
"""(share of the sound's energy inside its first n_keep samples at SR, its length in seconds).
|
|
3023
|
+
|
|
3024
|
+
None when the file cannot be read or is silent."""
|
|
3025
|
+
proc = subprocess.run(["ffmpeg", "-hide_banner", "-nostdin", "-v", "error", "-i", str(path), "-vn", "-ac", "1",
|
|
3026
|
+
"-ar", str(SR), "-f", "f32le", "-"], capture_output=True)
|
|
3027
|
+
if proc.returncode != 0 or not proc.stdout:
|
|
3028
|
+
return None
|
|
3029
|
+
x = np.frombuffer(proc.stdout[: len(proc.stdout) // 4 * 4], dtype="<f4").astype(np.float64)
|
|
3030
|
+
whole = float(np.sum(x * x))
|
|
3031
|
+
if whole <= 0:
|
|
3032
|
+
return None
|
|
3033
|
+
return float(np.sum(x[: max(0, n_keep)] ** 2)) / whole, len(x) / SR
|
|
3034
|
+
|
|
3035
|
+
|
|
3036
|
+
SFX_WHY_LABELS = {"gancho": "do gancho", "chamada": "da chamada", "flash": "do flash"}
|
|
3037
|
+
|
|
3038
|
+
|
|
3039
|
+
def _anchor_warning(e: dict, anchor: float, late: int, fps: int) -> str:
|
|
3040
|
+
"""Warning for an anchored effect that had to start at 0 and so hits after its frame."""
|
|
3041
|
+
if e["why"] == "efeito":
|
|
3042
|
+
label = f"sfx.events[{e['event']}] ('{e['name']}' em {fmt_time(e['at'])})"
|
|
3043
|
+
else:
|
|
3044
|
+
of = SFX_WHY_LABELS.get(e["why"]) or (("da " if e["why"].startswith("animação") else "do ") + e["why"])
|
|
3045
|
+
label = f"efeito '{e['name']}' {of} em {fmt_time(e['frame'] / fps)}"
|
|
3046
|
+
return (f"{label}: o ponto forte fica {anchor:.2f} s depois do começo do som, então ele teria que começar antes "
|
|
3047
|
+
f"do vídeo. Começou no início e o ponto forte chega {late / SR:.2f} s atrasado")
|
|
3048
|
+
|
|
3049
|
+
|
|
3050
|
+
def run_audio(plan: Plan, inserts: list[Insert], sfx_events: list[dict], out_wav: Path, report: dict,
|
|
3051
|
+
voice: Path | None = None) -> None:
|
|
3052
|
+
"""voice: a cleaned voice track (audio.denoise) used instead of the base's audio; None = the base's audio."""
|
|
3053
|
+
data = plan.data
|
|
3054
|
+
fps, total = plan.fps, plan.nf * SR // plan.fps
|
|
3055
|
+
loud = _section(data, "loudness")
|
|
3056
|
+
target_i = float(loud.get("i", -14))
|
|
3057
|
+
target_tp = float(loud.get("tp", -1.0))
|
|
3058
|
+
target_lra = float(loud.get("lra", 11))
|
|
3059
|
+
work = plan.renders / "audio"
|
|
3060
|
+
work.mkdir(parents=True, exist_ok=True)
|
|
3061
|
+
voice_in = voice if voice is not None else plan.base
|
|
3062
|
+
|
|
3063
|
+
voice_i = None
|
|
3064
|
+
if plan.has_audio:
|
|
3065
|
+
m = loudnorm_measure(voice_in)
|
|
3066
|
+
voice_i = m["input_i"] if m else None
|
|
3067
|
+
ref_i = voice_i if voice_i is not None else target_i
|
|
3068
|
+
|
|
3069
|
+
inputs: list[str] = []
|
|
3070
|
+
chains: list[str] = []
|
|
3071
|
+
if plan.has_audio:
|
|
3072
|
+
inputs += ["-i", str(voice_in)]
|
|
3073
|
+
voice_src = "[0:a]"
|
|
3074
|
+
else:
|
|
3075
|
+
inputs += ["-f", "lavfi", "-t", f"{plan.duration:.6f}", "-i", f"anullsrc=r={SR}:cl=stereo"]
|
|
3076
|
+
voice_src = "[0:a]"
|
|
3077
|
+
n_in = 1
|
|
3078
|
+
music = _section(data, "music")
|
|
3079
|
+
music_on = _enabled(music) and music.get("file")
|
|
3080
|
+
duck = bool(music.get("duck", True)) if music_on else False
|
|
3081
|
+
chains.append(f"{voice_src}aresample={SR},aformat=sample_fmts=fltp:channel_layouts=stereo,"
|
|
3082
|
+
f"apad=whole_len={total},atrim=end_sample={total},asetpts=N/SR/TB[voice]")
|
|
3083
|
+
mix_inputs = []
|
|
3084
|
+
if duck:
|
|
3085
|
+
chains.append("[voice]asplit=2[vmix][vkey]")
|
|
3086
|
+
mix_inputs.append("[vmix]")
|
|
3087
|
+
else:
|
|
3088
|
+
mix_inputs.append("[voice]")
|
|
3089
|
+
|
|
3090
|
+
# SFX: gain calibrated for a voice at the target loudness
|
|
3091
|
+
sfx_sec = _section(data, "sfx")
|
|
3092
|
+
placed = []
|
|
3093
|
+
if _enabled(sfx_sec):
|
|
3094
|
+
vol = float(sfx_sec.get("volume", 0.6) or 0.0)
|
|
3095
|
+
adj_db = max(-10.0, min(10.0, ref_i - target_i))
|
|
3096
|
+
gain = vol * SFX_CALIBRATION * (10 ** (adj_db / 20.0))
|
|
3097
|
+
events = list(sfx_events)
|
|
3098
|
+
for ins in inserts:
|
|
3099
|
+
if ins.sfx:
|
|
3100
|
+
events.append({"name": ins.sfx, "frame": ins.f0, "lead_frames": 2, "why": f"insert {ins.idx + 1}"})
|
|
3101
|
+
names = {e["name"] for e in events}
|
|
3102
|
+
custom = {}
|
|
3103
|
+
for nm in names:
|
|
3104
|
+
p = Path(nm)
|
|
3105
|
+
if p.suffix.lower() in SFX_FILE_EXT:
|
|
3106
|
+
custom[nm] = _resolve(plan.edit_dir, nm, "sfx")
|
|
3107
|
+
# a category of the effects library ("transicao", "humor") or one of its takes ("humor-012"): a category
|
|
3108
|
+
# hands out a different take to every event, in a fixed order per edit. A category name wins over a
|
|
3109
|
+
# built-in alias that happens to match it ("impacto" is also an alias of metal_hit)
|
|
3110
|
+
import efeitos
|
|
3111
|
+
library = efeitos.resolve([nm for nm in names if nm not in custom], seed=plan.edit_dir.name)
|
|
3112
|
+
builtin = make_sfx.builtin_sfx([nm for nm in names if nm not in custom and nm not in library])
|
|
3113
|
+
manifest = make_sfx.load_manifest()
|
|
3114
|
+
handed: dict[str, int] = {}
|
|
3115
|
+
for e in sorted(events, key=lambda e: e["frame"]):
|
|
3116
|
+
known = builtin.get(e["name"])
|
|
3117
|
+
if known is None and e["name"] in library:
|
|
3118
|
+
pool = library[e["name"]]["takes"]
|
|
3119
|
+
k = handed.get(e["name"], 0)
|
|
3120
|
+
handed[e["name"]] = k + 1
|
|
3121
|
+
known = pool[k % len(pool)]
|
|
3122
|
+
path = custom.get(e["name"]) or (known["path"] if known else None)
|
|
3123
|
+
if path is None:
|
|
3124
|
+
if make_sfx.resolve_name(e["name"], manifest) is not None or e["name"] in make_sfx.NAMES:
|
|
3125
|
+
plan.warnings.append(f"o arquivo do efeito sonoro '{e['name']}' está faltando na pasta assets/sfx da "
|
|
3126
|
+
f"skill e ele não tocou. Instale a skill de novo")
|
|
3127
|
+
else:
|
|
3128
|
+
plan.warnings.append(f"efeito sonoro '{e['name']}' não existe (use {', '.join(make_sfx.NAMES)}, o nome "
|
|
3129
|
+
f"em português de um deles, como grave ou notificacao, uma categoria da "
|
|
3130
|
+
f"biblioteca de efeitos ({', '.join(efeitos.CATEGORIES)}), um som dela como "
|
|
3131
|
+
f"humor-012, ou um arquivo)")
|
|
3132
|
+
continue
|
|
3133
|
+
ev_gain = gain * float(e.get("volume", 1.0)) # sfx.events can carry their own multiplier
|
|
3134
|
+
anchor = e.get("anchor_s")
|
|
3135
|
+
if anchor is None and known:
|
|
3136
|
+
anchor = known["anchor_s"]
|
|
3137
|
+
late = 0
|
|
3138
|
+
if anchor is None:
|
|
3139
|
+
# a sound file with no anchor: its t=0 on the event frame (lead_frames early), as always
|
|
3140
|
+
start = max(0, (e["frame"] - e["lead_frames"]) * SR // fps)
|
|
3141
|
+
else:
|
|
3142
|
+
# the anchor (the moment the sound hits full strength) lands on the event frame
|
|
3143
|
+
start = e["frame"] * SR // fps - int(round(anchor * SR))
|
|
3144
|
+
if start < 0:
|
|
3145
|
+
late, start = -start, 0
|
|
3146
|
+
if start >= total or ev_gain <= 0:
|
|
3147
|
+
continue
|
|
3148
|
+
kept = _sound_share_kept(path, total - start) if "event" in e else None
|
|
3149
|
+
# an anchor_s past the end of the sound gets its own warning below, not a "late" one
|
|
3150
|
+
past_end = kept is not None and e.get("anchor_s") is not None and e["anchor_s"] > kept[1]
|
|
3151
|
+
if late and anchor >= (e["frame"] + 1) / fps and not past_end:
|
|
3152
|
+
plan.warnings.append(_anchor_warning(e, anchor, late, fps))
|
|
3153
|
+
inputs += ["-i", str(path)]
|
|
3154
|
+
lab = f"sfx{n_in}"
|
|
3155
|
+
trim = ""
|
|
3156
|
+
if e.get("length"):
|
|
3157
|
+
# a cue that lasts as long as its animation (typing): cut the sound after its strong point + length
|
|
3158
|
+
cut = (anchor or 0.0) + float(e["length"])
|
|
3159
|
+
trim = f"atrim=end={cut:.4f},afade=t=out:st={max(0.0, cut - SFX_TRIM_FADE):.4f}:d={SFX_TRIM_FADE:.3f},"
|
|
3160
|
+
chains.append(f"[{n_in}:a]aresample={SR},aformat=sample_fmts=fltp:channel_layouts=stereo,"
|
|
3161
|
+
f"{trim}volume={ev_gain:.4f},adelay=delays={start}S:all=1[{lab}]")
|
|
3162
|
+
mix_inputs.append(f"[{lab}]")
|
|
3163
|
+
entry = {"name": e["name"], "why": e["why"], "sample": start, "time": round(start / SR, 4),
|
|
3164
|
+
"file": str(path)}
|
|
3165
|
+
if known and known.get("id") and known["id"] != e["name"]:
|
|
3166
|
+
entry["effect"] = known["id"] # a Portuguese name: the sfx.json id it played
|
|
3167
|
+
for key in ("at", "volume", "event", "motion", "length"):
|
|
3168
|
+
if key in e:
|
|
3169
|
+
entry[key] = e[key]
|
|
3170
|
+
if anchor:
|
|
3171
|
+
entry["anchor_s"] = anchor
|
|
3172
|
+
if late:
|
|
3173
|
+
entry["late_s"] = round(late / SR, 4)
|
|
3174
|
+
if "event" in e:
|
|
3175
|
+
if kept is not None and kept[0] < SFX_EVENT_MIN_KEPT:
|
|
3176
|
+
plan.warnings.append(f"sfx.events[{e['event']}] ('{e['name']}' em {fmt_time(e['at'])}) é cortado "
|
|
3177
|
+
f"pelo fim do vídeo: toca só {(total - start) / SR:.2f} s dos "
|
|
3178
|
+
f"{kept[1]:.2f} s do som. Adiante o \"at\" para ouvir o som inteiro")
|
|
3179
|
+
if past_end:
|
|
3180
|
+
plan.warnings.append(f"sfx.events[{e['event']}].anchor_s {e['anchor_s']:g} passa do fim do som "
|
|
3181
|
+
f"({kept[1]:.2f} s): o som inteiro tocou antes de {fmt_time(e['at'])}")
|
|
3182
|
+
placed.append(entry)
|
|
3183
|
+
n_in += 1
|
|
3184
|
+
report["sfx"] = placed
|
|
3185
|
+
|
|
3186
|
+
if music_on:
|
|
3187
|
+
mpath = _resolve(plan.edit_dir, music.get("file"), "music.file")
|
|
3188
|
+
mm = loudnorm_measure(mpath, extra=["-t", f"{plan.duration + 1:.3f}"])
|
|
3189
|
+
vol_db = float(music.get("volume_db", MUSIC_VOLUME_DB))
|
|
3190
|
+
if mm:
|
|
3191
|
+
gain_db = (ref_i - mm["input_i"]) + vol_db
|
|
3192
|
+
else:
|
|
3193
|
+
gain_db = vol_db
|
|
3194
|
+
plan.warnings.append("não consegui medir o volume da trilha; usei volume_db direto")
|
|
3195
|
+
gain_db = max(-60.0, min(12.0, gain_db))
|
|
3196
|
+
fade = max(0.0, min(plan.duration, float(music.get("fade_out", 1.5) or 0.0)))
|
|
3197
|
+
inputs += ["-stream_loop", "-1", "-i", str(mpath)]
|
|
3198
|
+
chains.append(f"[{n_in}:a]aresample={SR},aformat=sample_fmts=fltp:channel_layouts=stereo,"
|
|
3199
|
+
f"atrim=end_sample={total},asetpts=N/SR/TB,volume={gain_db:.2f}dB,afade=t=in:d=0.35[mus0]")
|
|
3200
|
+
cur = "mus0"
|
|
3201
|
+
if duck:
|
|
3202
|
+
# sidechaincompress ends when either input ends, even with samples of the other still waiting, and
|
|
3203
|
+
# ffmpeg 8 feeds the inputs from their own threads: endless silence after both, cut at total after
|
|
3204
|
+
chains.append("[mus0]apad[mus0p]")
|
|
3205
|
+
chains.append("[vkey]apad[vkeyp]")
|
|
3206
|
+
chains.append(f"[mus0p][vkeyp]sidechaincompress=threshold=0.1:ratio=3:attack=20:release=300:knee=3:"
|
|
3207
|
+
f"makeup=1,atrim=end_sample={total}[mus1]")
|
|
3208
|
+
cur = "mus1"
|
|
3209
|
+
if fade > 0:
|
|
3210
|
+
chains.append(f"[{cur}]afade=t=out:st={plan.duration - fade:.4f}:d={fade:.4f}[mus]")
|
|
3211
|
+
else:
|
|
3212
|
+
chains.append(f"[{cur}]anull[mus]")
|
|
3213
|
+
mix_inputs.append("[mus]")
|
|
3214
|
+
report["music"] = {"file": plan.rel(mpath), "measured_i": mm["input_i"] if mm else None,
|
|
3215
|
+
"volume_db": vol_db, "gain_db": round(gain_db, 2), "duck": duck, "fade_out": fade}
|
|
3216
|
+
layer = {"type": "music", "file": plan.rel(mpath), "gain_db": round(gain_db, 2), "duck": duck,
|
|
3217
|
+
"fade_out": fade}
|
|
3218
|
+
choice = plan.music_choice
|
|
3219
|
+
if choice:
|
|
3220
|
+
picked = {"mood": choice.get("mood"), "track_id": choice.get("track_id"), "title": choice.get("title"),
|
|
3221
|
+
"chosen_from": choice.get("from"), "why": choice.get("why"), "source": choice.get("source"),
|
|
3222
|
+
"license": choice.get("license"), "sha256": choice.get("sha256")}
|
|
3223
|
+
report["music"].update(picked)
|
|
3224
|
+
layer.update({"mood": picked["mood"], "track_id": picked["track_id"]})
|
|
3225
|
+
elif music.get("mood") not in (None, ""):
|
|
3226
|
+
report["music"]["mood_ignored"] = music.get("mood") # music.file wins over music.mood
|
|
3227
|
+
plan.layers.append(layer)
|
|
3228
|
+
n_in += 1
|
|
3229
|
+
|
|
3230
|
+
if len(mix_inputs) > 1:
|
|
3231
|
+
# every input exactly total samples long: where amix stops never depends on which input arrived first
|
|
3232
|
+
fitted = []
|
|
3233
|
+
for k, lab in enumerate(mix_inputs):
|
|
3234
|
+
chains.append(f"{lab}apad=whole_len={total},atrim=end_sample={total}[fit{k}]")
|
|
3235
|
+
fitted.append(f"[fit{k}]")
|
|
3236
|
+
chains.append(f"{''.join(fitted)}amix=inputs={len(fitted)}:normalize=0:duration=longest:"
|
|
3237
|
+
f"dropout_transition=0[mixraw]")
|
|
3238
|
+
else:
|
|
3239
|
+
chains.append(f"{mix_inputs[0]}anull[mixraw]")
|
|
3240
|
+
chains.append(f"[mixraw]apad=whole_len={total},atrim=end_sample={total}[aout]")
|
|
3241
|
+
script = work / "audio_graph.txt"
|
|
3242
|
+
script.write_text(";\n".join(chains) + "\n", encoding="utf-8")
|
|
3243
|
+
mix = work / "mix.wav"
|
|
3244
|
+
ffmpeg_cmd(["-v", "error", "-y", *inputs, *graph_args(script), "-map", "[aout]", "-c:a", "pcm_f32le",
|
|
3245
|
+
"-ar", SR, "-ac", 2, str(mix)], "mixar o áudio")
|
|
3246
|
+
|
|
3247
|
+
norm_tp = target_tp - AAC_TP_HEADROOM
|
|
3248
|
+
if not plan.has_audio and not music_on:
|
|
3249
|
+
# no voice and no music: only effects. Normalizing them to -14 LUFS would make them blast.
|
|
3250
|
+
measured = None
|
|
3251
|
+
os.replace(mix, out_wav)
|
|
3252
|
+
plan.warnings.append("o vídeo base não tem áudio: só os efeitos entram, sem normalizar o volume")
|
|
3253
|
+
else:
|
|
3254
|
+
measured = loudnorm_measure(mix, i=target_i, tp=norm_tp, lra=target_lra)
|
|
3255
|
+
if measured:
|
|
3256
|
+
af = (f"loudnorm=I={target_i}:TP={norm_tp}:LRA={target_lra}:measured_I={measured['input_i']}:"
|
|
3257
|
+
f"measured_TP={measured['input_tp']}:measured_LRA={measured['input_lra']}:"
|
|
3258
|
+
f"measured_thresh={measured['input_thresh']}:offset={measured['target_offset']}:linear=true")
|
|
3259
|
+
else:
|
|
3260
|
+
af = f"loudnorm=I={target_i}:TP={norm_tp}:LRA={target_lra}"
|
|
3261
|
+
plan.warnings.append("medição de loudness falhou (áudio mudo?); normalização em uma passada")
|
|
3262
|
+
ffmpeg_cmd(["-v", "error", "-y", "-i", mix, "-af",
|
|
3263
|
+
f"{af},aresample={SR},apad=whole_len={total},atrim=end_sample={total}",
|
|
3264
|
+
"-c:a", "pcm_f32le", "-ar", SR, "-ac", 2, str(out_wav)], "normalizar o volume")
|
|
3265
|
+
report["loudnorm_input"] = measured
|
|
3266
|
+
report["voice_i"] = voice_i
|
|
3267
|
+
if not plan.data.get("_keep_temp") and mix.exists():
|
|
3268
|
+
try:
|
|
3269
|
+
mix.unlink()
|
|
3270
|
+
except OSError:
|
|
3271
|
+
pass
|
|
3272
|
+
|
|
3273
|
+
|
|
3274
|
+
# ============================================================== main
|
|
3275
|
+
|
|
3276
|
+
def probe_output(path: Path) -> dict:
|
|
3277
|
+
info = ffprobe_json(path)
|
|
3278
|
+
v = next((s for s in info.get("streams", []) if s.get("codec_type") == "video"), {})
|
|
3279
|
+
a = next((s for s in info.get("streams", []) if s.get("codec_type") == "audio"), {})
|
|
3280
|
+
from fractions import Fraction
|
|
3281
|
+
try:
|
|
3282
|
+
fps = float(Fraction(v.get("avg_frame_rate") or "0/1"))
|
|
3283
|
+
except (ValueError, ZeroDivisionError):
|
|
3284
|
+
fps = 0.0
|
|
3285
|
+
return {"duration": float((info.get("format") or {}).get("duration") or 0), "width": int(v.get("width") or 0),
|
|
3286
|
+
"height": int(v.get("height") or 0), "fps": round(fps, 3), "frames": int(v.get("nb_frames") or 0),
|
|
3287
|
+
"video_codec": v.get("codec_name"), "profile": v.get("profile"), "pix_fmt": v.get("pix_fmt"),
|
|
3288
|
+
"color_space": v.get("color_space"), "color_primaries": v.get("color_primaries"),
|
|
3289
|
+
"color_transfer": v.get("color_transfer"), "color_range": v.get("color_range"),
|
|
3290
|
+
"audio_codec": a.get("codec_name"), "sample_rate": int(a.get("sample_rate") or 0),
|
|
3291
|
+
"channels": int(a.get("channels") or 0), "audio_duration": float(a.get("duration") or 0)}
|
|
3292
|
+
|
|
3293
|
+
|
|
3294
|
+
def update_state(plan: Plan) -> None:
|
|
3295
|
+
state_path = plan.edit_dir / "state.json"
|
|
3296
|
+
state = load_json(state_path, default={}) or {}
|
|
3297
|
+
if not isinstance(state, dict):
|
|
3298
|
+
state = {}
|
|
3299
|
+
if plan.preview:
|
|
3300
|
+
# The low-res preview is for the agent to check frames; it never asks the person to approve.
|
|
3301
|
+
state.update({
|
|
3302
|
+
"phase": "final",
|
|
3303
|
+
"awaiting": None,
|
|
3304
|
+
"edit_data": plan.rel(plan.edit_data_path),
|
|
3305
|
+
"message": "Montando o vídeo final...",
|
|
3306
|
+
"updated_at": now_iso(),
|
|
3307
|
+
})
|
|
3308
|
+
else:
|
|
3309
|
+
state.update({
|
|
3310
|
+
"phase": "final",
|
|
3311
|
+
"awaiting": "approval_final",
|
|
3312
|
+
"final_video": plan.rel(plan.out_path),
|
|
3313
|
+
"edit_data": plan.rel(plan.edit_data_path),
|
|
3314
|
+
"message": "Vídeo final pronto. Assista e aprove ou marque o que mudar.",
|
|
3315
|
+
"updated_at": now_iso(),
|
|
3316
|
+
})
|
|
3317
|
+
save_json(state_path, state)
|
|
3318
|
+
|
|
3319
|
+
|
|
3320
|
+
def compose(edit_data_path: Path, out: str | None, preview: bool, keep_temp: bool = False) -> int:
|
|
3321
|
+
t_start = time.time()
|
|
3322
|
+
require_binary("ffmpeg")
|
|
3323
|
+
require_binary("ffprobe")
|
|
3324
|
+
plan = build_plan(edit_data_path, out, preview)
|
|
3325
|
+
timed_sfx = plan_sfx_events(plan)
|
|
3326
|
+
denoise = plan_denoise(plan)
|
|
3327
|
+
motions = plan_motion(plan)
|
|
3328
|
+
tv_cfg = plan_tela_verde(plan)
|
|
3329
|
+
if keep_temp:
|
|
3330
|
+
plan.data["_keep_temp"] = True
|
|
3331
|
+
plan.renders.mkdir(parents=True, exist_ok=True)
|
|
3332
|
+
log(f"compondo {plan.rel(plan.out_path)}: {plan.W}x{plan.H} {plan.fps}fps, {plan.nf} quadros "
|
|
3333
|
+
f"({fmt_time(plan.duration)}){' · prévia' if preview else ''}")
|
|
3334
|
+
if plan.music_choice:
|
|
3335
|
+
c = plan.music_choice
|
|
3336
|
+
why = "a mesma da última montagem desta edição" if c.get("from") == "escolha anterior" else c.get("why")
|
|
3337
|
+
log(f"trilha do humor {c.get('mood')}: {c.get('track_id')} \"{c.get('title')}\" em {c.get('file')} ({why})")
|
|
3338
|
+
|
|
3339
|
+
load_faces(plan)
|
|
3340
|
+
cam = camera_filter(plan)
|
|
3341
|
+
if tv_cfg is not None:
|
|
3342
|
+
# the inserts first: a wrong one stops before the cut-out; split_top / split_bottom become the background
|
|
3343
|
+
inserts = plan_inserts(plan, tela_verde=True)
|
|
3344
|
+
insert_backgrounds(plan, tv_cfg, inserts)
|
|
3345
|
+
prepare_tela_verde(plan, tv_cfg)
|
|
3346
|
+
place_pips_tela_verde(plan, inserts)
|
|
3347
|
+
cam = None
|
|
3348
|
+
else:
|
|
3349
|
+
inserts = plan_inserts(plan)
|
|
3350
|
+
drawn = [i for i in inserts if not i.to_bg] # every insert when the edit is not tela verde
|
|
3351
|
+
compute_face_zones(plan, drawn)
|
|
3352
|
+
compute_person_zones(plan, drawn)
|
|
3353
|
+
if plan.image_zones:
|
|
3354
|
+
covered = [(i.f0, i.f1) for i in drawn if i.type == "full"]
|
|
3355
|
+
plan.image_zones = subtract_windows(plan.image_zones, merge_windows(covered)) if covered else plan.image_zones
|
|
3356
|
+
audio_report: dict = {}
|
|
3357
|
+
denoise_report: dict = {"mode": "off", "applied": "off"}
|
|
3358
|
+
audio_wav = plan.renders / "audio" / "final_audio.wav"
|
|
3359
|
+
audio_error: list[BaseException] = []
|
|
3360
|
+
|
|
3361
|
+
t0 = time.time()
|
|
3362
|
+
tv_clip = None
|
|
3363
|
+
with ThreadPoolExecutor(max_workers=3) as ex:
|
|
3364
|
+
tv_job = ex.submit(render_tela_verde, plan) if plan.tv else None
|
|
3365
|
+
clips = list(ex.map(lambda i: render_insert_clip(plan, i), drawn))
|
|
3366
|
+
if tv_job is not None:
|
|
3367
|
+
tv_clip = tv_job.result()
|
|
3368
|
+
plan.tv["compose_seconds"] = tv_clip.get("render_seconds", 0.0)
|
|
3369
|
+
layer = tela_verde_layer(plan, tv_clip)
|
|
3370
|
+
plan.layers.insert(0, layer)
|
|
3371
|
+
log(f"tela verde pronta{' (do cache)' if tv_clip.get('cached') else ''}: recorte {plan.tv['matte_seconds']:.1f}s, "
|
|
3372
|
+
f"montagem {tv_clip.get('render_seconds', 0.0):.1f}s ({plan.nf} quadros)")
|
|
3373
|
+
for ins, clip in zip(drawn, clips):
|
|
3374
|
+
ins.clip = clip
|
|
3375
|
+
bg_rows = {b.insert: b for b in plan.tv["cfg"].from_inserts} if plan.tv else {}
|
|
3376
|
+
for ins in inserts:
|
|
3377
|
+
if ins.to_bg:
|
|
3378
|
+
b = bg_rows.get(ins.idx + 1)
|
|
3379
|
+
plan.layers.append({"type": "insert", "insert_type": ins.type, "as": "fundo", "src": plan.rel(ins.src),
|
|
3380
|
+
"kind": ins.kind, "start": ins.f0 / plan.fps, "end": ins.f1 / plan.fps,
|
|
3381
|
+
"frames": [ins.f0, ins.f1], "ken_burns": ins.ken_burns,
|
|
3382
|
+
"encaixe": b.fit if b else None,
|
|
3383
|
+
"rect": ([int(round(v / plan.s)) for v in b.place] if b and b.place
|
|
3384
|
+
else [0, 0, plan.full_w, plan.full_h]),
|
|
3385
|
+
"speaker_rect": None, "speaker_offsets": None, "seam_y": None, "sfx": ins.sfx})
|
|
3386
|
+
continue
|
|
3387
|
+
plan.layers.append({"type": "insert", "insert_type": ins.type, "src": plan.rel(ins.src), "kind": ins.kind,
|
|
3388
|
+
"start": ins.f0 / plan.fps, "end": ins.f1 / plan.fps, "frames": [ins.f0, ins.f1],
|
|
3389
|
+
"ken_burns": ins.ken_burns, "rect": list(ins.content),
|
|
3390
|
+
"speaker_rect": list(ins.speaker) if ins.speaker else None,
|
|
3391
|
+
"speaker_offsets": ([{"start": round(p0 / plan.fps, 3), "end": round(p1 / plan.fps, 3),
|
|
3392
|
+
"src_y": int(round(y / plan.s))} for p0, p1, y in ins.speaker_pieces]
|
|
3393
|
+
if ins.speaker else None),
|
|
3394
|
+
"seam_y": ins.seam_y, "sfx": ins.sfx})
|
|
3395
|
+
if drawn:
|
|
3396
|
+
log(f"inserts prontos ({len(drawn)}) em {time.time() - t0:.1f}s")
|
|
3397
|
+
|
|
3398
|
+
t0 = time.time()
|
|
3399
|
+
tracks = build_tracks(plan, drawn, motions)
|
|
3400
|
+
motion_layers(plan, motions)
|
|
3401
|
+
log(f"camadas de texto prontas em {time.time() - t0:.1f}s")
|
|
3402
|
+
|
|
3403
|
+
def audio_job():
|
|
3404
|
+
try:
|
|
3405
|
+
voice = None
|
|
3406
|
+
if denoise != "off":
|
|
3407
|
+
t_dn = time.time()
|
|
3408
|
+
voice, rep = run_denoise(plan, denoise, keep_temp)
|
|
3409
|
+
denoise_report.clear()
|
|
3410
|
+
denoise_report.update(rep)
|
|
3411
|
+
denoise_report["seconds"] = round(time.time() - t_dn, 2)
|
|
3412
|
+
run_audio(plan, inserts, tracks["sfx_events"] + timed_sfx, audio_wav, audio_report, voice=voice)
|
|
3413
|
+
except BaseException as e: # noqa: BLE001
|
|
3414
|
+
audio_error.append(e)
|
|
3415
|
+
|
|
3416
|
+
th = threading.Thread(target=audio_job, daemon=True)
|
|
3417
|
+
th.start()
|
|
3418
|
+
|
|
3419
|
+
t0 = time.time()
|
|
3420
|
+
tmp_video = plan.renders / "final_video_only.mp4"
|
|
3421
|
+
run_video(plan, drawn, tracks, cam, tmp_video, Path(tv_clip["file"]) if tv_clip else None)
|
|
3422
|
+
log(f"vídeo codificado em {time.time() - t0:.1f}s")
|
|
3423
|
+
th.join()
|
|
3424
|
+
if audio_error:
|
|
3425
|
+
err = audio_error[0]
|
|
3426
|
+
raise err if isinstance(err, ComposeError) else ComposeError(f"falha no áudio: {err}")
|
|
3427
|
+
|
|
3428
|
+
plan.out_path.parent.mkdir(parents=True, exist_ok=True)
|
|
3429
|
+
loud = _section(plan.data, "loudness")
|
|
3430
|
+
target_i, target_tp = float(loud.get("i", -14)), float(loud.get("tp", -1.0))
|
|
3431
|
+
tmp_out = plan.out_path.with_name(".tmp-" + plan.out_path.name)
|
|
3432
|
+
|
|
3433
|
+
def mux(gain_db: float) -> None:
|
|
3434
|
+
af = ["-af", f"volume={gain_db:.2f}dB"] if abs(gain_db) > 0.001 else []
|
|
3435
|
+
ffmpeg_cmd(["-v", "error", "-y", "-i", tmp_video, "-i", audio_wav, "-map", "0:v:0", "-map", "1:a:0",
|
|
3436
|
+
"-c:v", "copy", *af, "-c:a", "aac", "-b:a", "192k", "-ar", SR, "-ac", 2,
|
|
3437
|
+
"-t", f"{plan.duration:.6f}", "-movflags", "+faststart", "-f", "mp4", tmp_out],
|
|
3438
|
+
"juntar vídeo e áudio")
|
|
3439
|
+
|
|
3440
|
+
mux(0.0)
|
|
3441
|
+
final_loud = loudnorm_measure(tmp_out, i=target_i, tp=target_tp)
|
|
3442
|
+
if final_loud and final_loud["input_tp"] > target_tp + 0.05:
|
|
3443
|
+
# the AAC encoder pushed a true peak over the target: trim the gain just enough and encode again
|
|
3444
|
+
mux(target_tp - final_loud["input_tp"] - 0.1)
|
|
3445
|
+
final_loud = loudnorm_measure(tmp_out, i=target_i, tp=target_tp)
|
|
3446
|
+
os.replace(tmp_out, plan.out_path)
|
|
3447
|
+
for p in (tmp_video, audio_wav):
|
|
3448
|
+
if keep_temp:
|
|
3449
|
+
break
|
|
3450
|
+
try:
|
|
3451
|
+
p.unlink()
|
|
3452
|
+
except OSError:
|
|
3453
|
+
pass
|
|
3454
|
+
|
|
3455
|
+
facts = probe_output(plan.out_path)
|
|
3456
|
+
elapsed = time.time() - t_start
|
|
3457
|
+
for e in audio_report.get("sfx", []):
|
|
3458
|
+
layer = {"type": "sfx", "name": e["name"], "why": e["why"], "time": e["time"], "sample": e["sample"]}
|
|
3459
|
+
for key in ("effect", "at", "volume", "event", "motion", "length", "anchor_s", "late_s"):
|
|
3460
|
+
if key in e:
|
|
3461
|
+
layer[key] = e[key]
|
|
3462
|
+
plan.layers.append(layer)
|
|
3463
|
+
report = {
|
|
3464
|
+
"output": plan.rel(plan.out_path),
|
|
3465
|
+
"edit_data": plan.rel(plan.edit_data_path),
|
|
3466
|
+
"base": plan.rel(plan.base),
|
|
3467
|
+
"preview": plan.preview,
|
|
3468
|
+
"duration": round(plan.duration, 6),
|
|
3469
|
+
"frames": plan.nf,
|
|
3470
|
+
"fps": plan.fps,
|
|
3471
|
+
"width": plan.W,
|
|
3472
|
+
"height": plan.H,
|
|
3473
|
+
"probe": facts,
|
|
3474
|
+
"loudness": {
|
|
3475
|
+
"target_i": target_i, "target_tp": target_tp,
|
|
3476
|
+
"i": final_loud["input_i"] if final_loud else None,
|
|
3477
|
+
"tp": final_loud["input_tp"] if final_loud else None,
|
|
3478
|
+
"lra": final_loud["input_lra"] if final_loud else None,
|
|
3479
|
+
"voice_before_i": audio_report.get("voice_i"),
|
|
3480
|
+
},
|
|
3481
|
+
"music": audio_report.get("music"),
|
|
3482
|
+
"denoise": denoise_report,
|
|
3483
|
+
"layers": plan.layers,
|
|
3484
|
+
"faces": {"file": "face_layout.json" if plan.faces else None,
|
|
3485
|
+
"segments_with_face": sum(1 for cs in plan.cam_segs if cs.get("face")),
|
|
3486
|
+
"segments": len(plan.cam_segs)},
|
|
3487
|
+
"adjustments": plan.adjustments,
|
|
3488
|
+
"face_checks": plan.face_checks,
|
|
3489
|
+
"warnings": plan.warnings,
|
|
3490
|
+
"render_seconds": round(elapsed, 2),
|
|
3491
|
+
"realtime_factor": round(elapsed / max(plan.duration, 1e-6), 3),
|
|
3492
|
+
"created_at": now_iso(),
|
|
3493
|
+
}
|
|
3494
|
+
report_path = plan.edit_dir / "verify" / "final_report.json"
|
|
3495
|
+
save_json(report_path, report)
|
|
3496
|
+
update_state(plan)
|
|
3497
|
+
|
|
3498
|
+
counts = {}
|
|
3499
|
+
for layer in plan.layers:
|
|
3500
|
+
counts[layer["type"]] = counts.get(layer["type"], 0) + 1
|
|
3501
|
+
parts = [f"{v} {k}" for k, v in counts.items() if k != "camera"]
|
|
3502
|
+
cam_layer = next((layer for layer in plan.layers if layer["type"] == "camera"), {})
|
|
3503
|
+
if cam_layer.get("zoom_cuts"):
|
|
3504
|
+
parts.append("zoom nos cortes")
|
|
3505
|
+
if cam_layer.get("push_in"):
|
|
3506
|
+
parts.append("aproximação lenta")
|
|
3507
|
+
log(f"camadas: {', '.join(parts) if parts else 'nenhuma além da base'}")
|
|
3508
|
+
moved = [a for a in plan.adjustments if a.get("action") == "moved"]
|
|
3509
|
+
hidden = [a for a in plan.adjustments if a.get("action") == "hidden"]
|
|
3510
|
+
if moved or hidden:
|
|
3511
|
+
names = {"captions": ("legenda", "escondida"), "disclaimer": ("aviso", "escondido"), "hook": ("gancho", "escondido"),
|
|
3512
|
+
"cta": ("chamada", "escondida")}
|
|
3513
|
+
for a in moved + hidden:
|
|
3514
|
+
if a["layer"].startswith("motion ") and a["layer"] not in names:
|
|
3515
|
+
names[a["layer"]] = (a["layer"].replace("motion", "animação"), "escondida")
|
|
3516
|
+
bits = [f"{names.get(a['layer'], (a['layer'], 'escondido'))[0]} "
|
|
3517
|
+
f"{'mudou de altura' if a['action'] == 'moved' else names.get(a['layer'], ('', 'escondido'))[1]} "
|
|
3518
|
+
f"{fmt_time(a['start'])}-{fmt_time(a['end'])}" for a in (moved + hidden)[:6]]
|
|
3519
|
+
more = f" e mais {len(moved) + len(hidden) - 6}" if len(moved) + len(hidden) > 6 else ""
|
|
3520
|
+
log(f"ajustes para não sobrepor textos: {'; '.join(bits)}{more} (detalhes em adjustments no relatório)")
|
|
3521
|
+
if final_loud:
|
|
3522
|
+
log(f"volume final: {final_loud['input_i']:.1f} LUFS, pico {final_loud['input_tp']:.1f} dBTP")
|
|
3523
|
+
line = denoise_line(denoise_report)
|
|
3524
|
+
if line:
|
|
3525
|
+
log(line)
|
|
3526
|
+
for w in plan.warnings:
|
|
3527
|
+
log(f"aviso: {w}")
|
|
3528
|
+
log(f"ok: {plan.out_path} ({facts['width']}x{facts['height']}, {facts['fps']:g}fps, {facts['duration']:.2f}s)")
|
|
3529
|
+
log(f"relatório: {report_path}")
|
|
3530
|
+
log(f"tempo total de render: {elapsed:.1f}s ({elapsed / max(plan.duration, 1e-6):.2f}x a duração do vídeo)")
|
|
3531
|
+
return 0
|
|
3532
|
+
|
|
3533
|
+
|
|
3534
|
+
def main(argv: list[str] | None = None) -> int:
|
|
3535
|
+
ap = argparse.ArgumentParser(description="Monta o vídeo final a partir do edit_data.json (zoom, inserts, textos, efeitos, trilha).")
|
|
3536
|
+
ap.add_argument("edit_data", help="caminho do edit_data.json (fica na pasta edit)")
|
|
3537
|
+
ap.add_argument("-o", "--output", help="arquivo de saída (padrão: final.mp4 na pasta edit; com --preview, final_preview.mp4)")
|
|
3538
|
+
ap.add_argument("--preview", action="store_true", help="prévia rápida em 540 de largura, CRF 26")
|
|
3539
|
+
ap.add_argument("--keep-temp", action="store_true", help="mantém os arquivos intermediários em renders/ (para investigar)")
|
|
3540
|
+
args = ap.parse_args(argv)
|
|
3541
|
+
try:
|
|
3542
|
+
return compose(Path(args.edit_data).expanduser().resolve(), args.output, args.preview, args.keep_temp)
|
|
3543
|
+
except ComposeError as e:
|
|
3544
|
+
print(f"erro: {e}", file=sys.stderr)
|
|
3545
|
+
return e.code
|
|
3546
|
+
except ValueError as e:
|
|
3547
|
+
print(f"erro: {e}", file=sys.stderr)
|
|
3548
|
+
return 2
|
|
3549
|
+
except KeyboardInterrupt:
|
|
3550
|
+
print("interrompido", file=sys.stderr)
|
|
3551
|
+
return 130
|
|
3552
|
+
|
|
3553
|
+
|
|
3554
|
+
if __name__ == "__main__":
|
|
3555
|
+
sys.exit(main())
|