primocode 10.15.2 → 10.17.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSES/edit-ia-pro.txt +32 -0
- package/LICENSES/fontes-OFL.txt +476 -0
- package/LICENSES/hyperframes-Apache-2.0.txt +205 -0
- package/LICENSES/permanent-marker-Apache-2.0.txt +204 -0
- package/LICENSES/selfie-multiclass-Apache-2.0.txt +227 -0
- package/LICENSES/three-MIT.txt +23 -0
- package/LICENSES/video-use-MIT.txt +24 -0
- package/LICENSES/woven-sfx-CC0.txt +16 -0
- package/LICENSES/yunet-MIT.txt +21 -0
- package/README.md +2 -2
- package/bin/primocode.js +74 -9
- package/editor/LICENSE.md +11 -0
- package/editor/README.md +321 -0
- package/editor/SKILL.md +307 -0
- package/editor/THIRD_PARTY_NOTICES.md +228 -0
- package/editor/assets/baixar.json +591 -0
- package/editor/assets/fonts/anton/OFL.txt +93 -0
- package/editor/assets/fonts/caveat/OFL.txt +93 -0
- package/editor/assets/fonts/figtree/OFL.txt +93 -0
- package/editor/assets/fonts/fonts.json +10 -0
- package/editor/assets/fonts/montserrat/OFL.txt +93 -0
- package/editor/assets/fonts/permanentmarker/LICENSE.txt +202 -0
- package/editor/assets/models/SELFIE_MULTICLASS_LICENSE.txt +227 -0
- package/editor/assets/models/YUNET_LICENSE.txt +21 -0
- package/editor/assets/sfx/licencas/bass_drop.txt +32 -0
- package/editor/assets/sfx/licencas/cash.txt +76 -0
- package/editor/assets/sfx/licencas/charge.txt +31 -0
- package/editor/assets/sfx/licencas/click.txt +46 -0
- package/editor/assets/sfx/licencas/coin.txt +50 -0
- package/editor/assets/sfx/licencas/count.txt +47 -0
- package/editor/assets/sfx/licencas/data.txt +31 -0
- package/editor/assets/sfx/licencas/error.txt +32 -0
- package/editor/assets/sfx/licencas/flash.txt +31 -0
- package/editor/assets/sfx/licencas/gears.txt +82 -0
- package/editor/assets/sfx/licencas/keyboard.txt +48 -0
- package/editor/assets/sfx/licencas/metal_hit.txt +62 -0
- package/editor/assets/sfx/licencas/metal_riser.txt +61 -0
- package/editor/assets/sfx/licencas/mouse_click.txt +50 -0
- package/editor/assets/sfx/licencas/notification.txt +54 -0
- package/editor/assets/sfx/licencas/pop.txt +38 -0
- package/editor/assets/sfx/licencas/rewind.txt +32 -0
- package/editor/assets/sfx/licencas/rise.txt +46 -0
- package/editor/assets/sfx/licencas/sinteticos.txt +21 -0
- package/editor/assets/sfx/licencas/snap.txt +50 -0
- package/editor/assets/sfx/licencas/success.txt +40 -0
- package/editor/assets/sfx/licencas/synth_hit.txt +32 -0
- package/editor/assets/sfx/licencas/tick.txt +14 -0
- package/editor/assets/sfx/licencas/whoosh.txt +47 -0
- package/editor/assets/sfx/licencas/wind.txt +31 -0
- package/editor/assets/sfx/sfx.json +832 -0
- package/editor/assets/sfx/sfx_sinteticos.json +317 -0
- package/editor/assets/sfx/sfx_woven.json +387 -0
- package/editor/assets/sfx/woven/LICENCA.md +16 -0
- package/editor/fichas/briefing-do-video.md +45 -0
- package/editor/fichas/conferencia-da-amostra.md +56 -0
- package/editor/fichas/matriz-de-ganchos.csv +3 -0
- package/editor/fichas/padrao.json +16 -0
- package/editor/fichas/planilha-de-cenas.csv +4 -0
- package/editor/fichas/planilha-de-versoes.csv +4 -0
- package/editor/fichas/plano-em-blocos.md +32 -0
- package/editor/fichas/registro-de-materiais.json +27 -0
- package/editor/helpers/assets_ocr/ocr_vision.swift +41 -0
- package/editor/helpers/bordas_precisas.py +272 -0
- package/editor/helpers/camera_viva.py +426 -0
- package/editor/helpers/captions.py +601 -0
- package/editor/helpers/common.py +308 -0
- package/editor/helpers/compose_final.py +3555 -0
- package/editor/helpers/conferir_corte.py +234 -0
- package/editor/helpers/conferir_dados.py +405 -0
- package/editor/helpers/contact_sheet.py +282 -0
- package/editor/helpers/corte_por_onda.py +433 -0
- package/editor/helpers/corte_seco.py +595 -0
- package/editor/helpers/denoise.py +971 -0
- package/editor/helpers/efeitos.py +408 -0
- package/editor/helpers/efeitos_gerar.py +159 -0
- package/editor/helpers/elevenlabs_api.py +442 -0
- package/editor/helpers/face_layout.py +570 -0
- package/editor/helpers/fetch_images.py +423 -0
- package/editor/helpers/ganho_por_trecho.py +214 -0
- package/editor/helpers/gen_image.py +312 -0
- package/editor/helpers/gen_music.py +316 -0
- package/editor/helpers/gen_sfx.py +148 -0
- package/editor/helpers/grade.py +307 -0
- package/editor/helpers/icloud.py +119 -0
- package/editor/helpers/make_sfx.py +940 -0
- package/editor/helpers/matte.py +1342 -0
- package/editor/helpers/medir_referencia.py +2232 -0
- package/editor/helpers/mixar_trilha.py +354 -0
- package/editor/helpers/montar_corte.py +295 -0
- package/editor/helpers/motion/__init__.py +13 -0
- package/editor/helpers/motion/engine.py +1915 -0
- package/editor/helpers/motion/registry.py +510 -0
- package/editor/helpers/motion/render.py +665 -0
- package/editor/helpers/motion/templates/barra_progresso.py +370 -0
- package/editor/helpers/motion/templates/carimbo.py +132 -0
- package/editor/helpers/motion/templates/checklist.py +271 -0
- package/editor/helpers/motion/templates/comentario_cta.py +380 -0
- package/editor/helpers/motion/templates/contador.py +222 -0
- package/editor/helpers/motion/templates/destaque.py +263 -0
- package/editor/helpers/motion/templates/etiqueta_preco.py +461 -0
- package/editor/helpers/motion/templates/grafico_barras.py +510 -0
- package/editor/helpers/motion/templates/grafico_linha.py +462 -0
- package/editor/helpers/motion/templates/notificacao.py +266 -0
- package/editor/helpers/motion/templates/palavra_impacto.py +410 -0
- package/editor/helpers/motion/templates/passos.py +321 -0
- package/editor/helpers/motion/templates/riscado.py +298 -0
- package/editor/helpers/motion/templates/selo_garantia.py +150 -0
- package/editor/helpers/motion_render.py +241 -0
- package/editor/helpers/motion_sugerir.py +961 -0
- package/editor/helpers/noise_check.py +134 -0
- package/editor/helpers/pack_transcripts.py +223 -0
- package/editor/helpers/palavras_na_onda.py +176 -0
- package/editor/helpers/plano_sfx.py +839 -0
- package/editor/helpers/portao.py +427 -0
- package/editor/helpers/preview_server.py +1177 -0
- package/editor/helpers/rastrear_rosto.py +298 -0
- package/editor/helpers/render_cut.py +1256 -0
- package/editor/helpers/respiros.py +89 -0
- package/editor/helpers/sincronizar_cameras.py +571 -0
- package/editor/helpers/speech_regions.py +423 -0
- package/editor/helpers/style_catalog.json +231 -0
- package/editor/helpers/style_samples.py +246 -0
- package/editor/helpers/tela_verde.py +2113 -0
- package/editor/helpers/text_render.py +2059 -0
- package/editor/helpers/texto_na_tela.py +366 -0
- package/editor/helpers/timeline_view.py +292 -0
- package/editor/helpers/tira_de_quadros.py +113 -0
- package/editor/helpers/transcribe.py +734 -0
- package/editor/helpers/transcribe_batch.py +129 -0
- package/editor/helpers/trilhas.py +1146 -0
- package/editor/helpers/trilhas_humores.json +66 -0
- package/editor/helpers/valores.py +187 -0
- package/editor/helpers/verify_cut.py +820 -0
- package/editor/helpers/voice_levels.py +233 -0
- package/editor/helpers/voz_check.py +386 -0
- package/editor/helpers/voz_limpa.py +81 -0
- package/editor/helpers/watch_preview.py +372 -0
- package/editor/modelo-projeto/LEIA.md +402 -0
- package/editor/modelo-projeto/assets/fontes/OFL-Caveat.txt +93 -0
- package/editor/modelo-projeto/assets/fontes/OFL-JetBrainsMono.txt +93 -0
- package/editor/modelo-projeto/assets/fontes/OFL-Montserrat.txt +93 -0
- package/editor/modelo-projeto/assets/fontes/larguras.json +1 -0
- package/editor/modelo-projeto/assets/three/BufferGeometryUtils.js +1438 -0
- package/editor/modelo-projeto/assets/three/GLTFLoader.js +4895 -0
- package/editor/modelo-projeto/assets/three/LICENSE +21 -0
- package/editor/modelo-projeto/assets/three/ORIGEM.txt +17 -0
- package/editor/modelo-projeto/assets/three/RoomEnvironment.js +185 -0
- package/editor/modelo-projeto/assets/three/three.core.min.js +6 -0
- package/editor/modelo-projeto/assets/three/three.module.min.js +6 -0
- package/editor/modelo-projeto/build.py +4828 -0
- package/editor/modelo-projeto/compositions/cenas/2d/k2d_antes_depois.html +199 -0
- package/editor/modelo-projeto/compositions/cenas/2d/k2d_arquivo.html +232 -0
- package/editor/modelo-projeto/compositions/cenas/2d/k2d_busca.html +315 -0
- package/editor/modelo-projeto/compositions/cenas/2d/k2d_comando.html +304 -0
- package/editor/modelo-projeto/compositions/cenas/2d/k2d_conversa.html +217 -0
- package/editor/modelo-projeto/compositions/cenas/2d/k2d_esquema.html +289 -0
- package/editor/modelo-projeto/compositions/cenas/2d/k2d_medidor.html +249 -0
- package/editor/modelo-projeto/compositions/cenas/2d/k2d_notas.html +233 -0
- package/editor/modelo-projeto/compositions/cenas/3d/k3d_caminho.html +197 -0
- package/editor/modelo-projeto/compositions/cenas/3d/k3d_explodida.html +152 -0
- package/editor/modelo-projeto/compositions/cenas/3d/k3d_tela.html +167 -0
- package/editor/modelo-projeto/compositions/cenas/3d/k3d_volume.html +124 -0
- package/editor/modelo-projeto/compositions/cenas/3d/motor-three.js +728 -0
- package/editor/modelo-projeto/compositions/cenas/LEIA.md +177 -0
- package/editor/modelo-projeto/ferramentas/medir_fontes.py +65 -0
- package/editor/modelo-projeto/ferramentas/pessoa_recortada.py +176 -0
- package/editor/modelo-projeto/ferramentas/rastrear_maos.py +189 -0
- package/editor/modelo-projeto/hyperframes.json +12 -0
- package/editor/modelo-projeto/package.json +11 -0
- package/editor/modelo-projeto/projeto.json +16 -0
- package/editor/preview/app.css +1009 -0
- package/editor/preview/app.js +2122 -0
- package/editor/preview/index.html +307 -0
- package/editor/pyproject.toml +23 -0
- package/editor/references/3d.md +236 -0
- package/editor/references/animacao-catalogo.md +672 -0
- package/editor/references/animacao.md +498 -0
- package/editor/references/comandos.md +92 -0
- package/editor/references/corte-seco-como-funciona.md +50 -0
- package/editor/references/corte-seco.md +88 -0
- package/editor/references/corte.md +290 -0
- package/editor/references/direcao-por-blocos.md +218 -0
- package/editor/references/enquadramento.md +654 -0
- package/editor/references/estilos-prontos.md +355 -0
- package/editor/references/estudio.md +100 -0
- package/editor/references/exemplo-eventos.json +67 -0
- package/editor/references/lettering.md +252 -0
- package/editor/references/modelos.md +494 -0
- package/editor/references/phase2.md +1007 -0
- package/editor/references/referencia-aplicar.md +203 -0
- package/editor/references/referencia-medir.md +375 -0
- package/editor/references/revisao.md +335 -0
- package/editor/references/som-e-trilha.md +146 -0
- package/editor/references/tabela-de-sons.md +130 -0
- package/editor/references/variacoes.md +234 -0
- package/editor/uv.lock +1141 -0
- package/lib/act.js +38 -8
- package/lib/app-chat.js +97 -0
- package/lib/catalogo.js +7 -1
- package/lib/chamada-torta.js +81 -0
- package/lib/editor-pro.js +786 -0
- package/lib/entrega.js +58 -0
- package/lib/estudio/conferencia-visual.js +76 -0
- package/lib/estudio/diretor.js +6 -1
- package/lib/estudio/exportar.js +3 -1
- package/lib/estudio/gosto/tela.html +1349 -0
- package/lib/estudio/gosto.js +305 -0
- package/lib/estudio/index.js +12 -1
- package/lib/estudio/kit-ae/base.jsx +119 -13
- package/lib/estudio/kit-ae/cenas.jsx +47 -15
- package/lib/estudio/kit-ae/guarda.jsx +274 -0
- package/lib/estudio/kit-ae/primitivas.jsx +180 -24
- package/lib/estudio/maquina-diretor.js +70 -12
- package/lib/estudio/maquina-foto.js +177 -0
- package/lib/estudio/maquina-guarda.js +207 -0
- package/lib/estudio/maquina-layout.js +22 -1
- package/lib/estudio/maquina.js +64 -11
- package/lib/estudio/referencias.js +1 -1
- package/lib/estudio/visao/visao.swift +97 -0
- package/lib/estudio/visao.js +60 -0
- package/lib/headless.js +15 -3
- package/lib/local/cerebro.json +285 -7
- package/lib/local/motor.js +5 -2
- package/lib/memoria-usuario.js +164 -0
- package/lib/nuvem.js +33 -2
- package/lib/passagem.js +50 -0
- package/lib/perigo.js +19 -11
- package/lib/protecao.js +134 -0
- package/lib/repeticao.js +1 -1
- package/lib/sandbox.js +57 -9
- package/lib/titulo-conversa.js +46 -0
- package/lib/tools.js +23 -4
- package/lib/ui.js +2 -2
- package/package.json +3 -2
- package/skills/editar-meu-video/SKILL.md +6 -2
- package/skills/editar-video/SKILL.md +113 -0
- package/studio/pessoa.py +52 -0
- package/voz/primo_voz/__main__.py +55 -40
- package/voz/primo_voz/app.py +500 -4
- package/voz/primo_voz/chat.html +1773 -0
- package/voz/primo_voz/conversa.py +0 -26
- package/voz/primo_voz/hud.html +8 -2
- package/voz/primo_voz/hud.py +129 -5
- package/voz/primo_voz/identidade.py +1 -1
- package/voz/primo_voz/painel_dados.py +134 -0
|
@@ -0,0 +1,665 @@
|
|
|
1
|
+
"""Renderer: one motion item (template + params + place + length) -> frames, an alpha clip for the
|
|
2
|
+
compose, previews and a contact sheet (SPEC Contract 12).
|
|
3
|
+
|
|
4
|
+
Transport into compose_final (chosen by measurement, see SPEC): one QuickTime Animation (qtrle, ARGB,
|
|
5
|
+
lossless) .mov per item, sized to the item's rectangle, placed with overlay at even x/y and started
|
|
6
|
+
with `setpts=N+<start frame>` like the insert clips. Frame-exact, cacheable by content, tiny for held
|
|
7
|
+
frames (qtrle only stores the lines that changed).
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import hashlib
|
|
13
|
+
import json
|
|
14
|
+
import math
|
|
15
|
+
import os
|
|
16
|
+
import subprocess
|
|
17
|
+
import sys
|
|
18
|
+
from dataclasses import dataclass, field
|
|
19
|
+
from pathlib import Path
|
|
20
|
+
from typing import Any, Iterator, Sequence
|
|
21
|
+
|
|
22
|
+
import numpy as np
|
|
23
|
+
from PIL import Image, ImageDraw, ImageFilter, ImageOps
|
|
24
|
+
|
|
25
|
+
from . import engine as E
|
|
26
|
+
from . import registry as Rg
|
|
27
|
+
from .engine import MotionError
|
|
28
|
+
|
|
29
|
+
RENDER_VERSION = 2
|
|
30
|
+
EXIT_FRAMES = 6 # generic exit (fade + 3% shrink) of templates with exit "fade"
|
|
31
|
+
EXIT_SHRINK = 0.03
|
|
32
|
+
INK_ALPHA = 24 # a pixel counts as drawn ("ink") above this alpha: faint glows and shadows do not
|
|
33
|
+
_INK_LUT = [0] * (INK_ALPHA + 1) + [255] * (255 - INK_ALPHA)
|
|
34
|
+
_OWN_HASH = hashlib.sha1(b"".join(p.read_bytes() for p in (Path(E.__file__), Path(__file__)))).hexdigest()[:12]
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@dataclass
|
|
38
|
+
class MotionItem:
|
|
39
|
+
template: Rg.Template
|
|
40
|
+
params: dict # resolved (defaults filled)
|
|
41
|
+
box: E.Box
|
|
42
|
+
frames: int
|
|
43
|
+
region: list[float] # [x, y, w, h] design px actually used
|
|
44
|
+
region_spec: Any
|
|
45
|
+
moved_by: float = 0.0 # design px the region moved to clear a face / text
|
|
46
|
+
warnings: list[str] = field(default_factory=list)
|
|
47
|
+
ink: tuple[float, float, float, float] | None = None # box-local design px (measured when placing)
|
|
48
|
+
|
|
49
|
+
@property
|
|
50
|
+
def fps(self) -> int:
|
|
51
|
+
return self.box.fps
|
|
52
|
+
|
|
53
|
+
@property
|
|
54
|
+
def canvas(self) -> tuple[int, int]:
|
|
55
|
+
return self.box.canvas
|
|
56
|
+
|
|
57
|
+
@property
|
|
58
|
+
def key(self) -> str:
|
|
59
|
+
b = self.box
|
|
60
|
+
blob = json.dumps({
|
|
61
|
+
"engine": [E.ENGINE_VERSION, RENDER_VERSION, _OWN_HASH],
|
|
62
|
+
"template": [self.template.id, self.template.version, self.template.source_hash],
|
|
63
|
+
"params": self.params, "box": [round(b.x, 4), round(b.y, 4), round(b.w, 4), round(b.h, 4), b.bleed],
|
|
64
|
+
"canvas": list(b.canvas), "fps": b.fps, "frames": self.frames,
|
|
65
|
+
"theme": [b.theme.name, list(b.theme.accent)], "exit": self.template.exit,
|
|
66
|
+
}, sort_keys=True, ensure_ascii=False)
|
|
67
|
+
return hashlib.sha1(blob.encode("utf-8")).hexdigest()[:16]
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
@dataclass
|
|
71
|
+
class Frame:
|
|
72
|
+
image: Image.Image # RGBA
|
|
73
|
+
x: int # render px of the image's top-left on the output canvas
|
|
74
|
+
y: int
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
# ------------------------------------------------------------------ planning
|
|
78
|
+
|
|
79
|
+
def _accent_for(params: dict, accent: str) -> str:
|
|
80
|
+
cor = params.get("cor")
|
|
81
|
+
if cor in (None, ""):
|
|
82
|
+
return accent
|
|
83
|
+
if isinstance(cor, str) and not cor.startswith("#"):
|
|
84
|
+
c = E.make_theme(accent).get(E.COLOR_NAMES[cor])
|
|
85
|
+
return "#%02X%02X%02X" % c[:3]
|
|
86
|
+
return str(cor)[:7]
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def plan_item(template_id: str, params: dict | None = None, *, fps: int = 30,
|
|
90
|
+
canvas: Sequence[int] = (1080, 1920), region=None, duration: float | None = None,
|
|
91
|
+
frames: int | None = None, accent: str = E.DEFAULT_ACCENT,
|
|
92
|
+
blocked: Sequence[tuple[float, float]] | None = None,
|
|
93
|
+
dirs: list[Path] | None = None) -> MotionItem:
|
|
94
|
+
"""Check everything and decide where and how long the item is. `blocked` = vertical bands (y0, y1) in
|
|
95
|
+
design px of the canvas that the content must not touch (faces, other texts); the region slides
|
|
96
|
+
vertically inside the safe area to the free place closest to where it was."""
|
|
97
|
+
tpl = Rg.get(template_id, dirs)
|
|
98
|
+
resolved = Rg.resolve_params(tpl, params)
|
|
99
|
+
fps = int(fps)
|
|
100
|
+
if fps <= 0:
|
|
101
|
+
raise MotionError("fps precisa ser maior que 0")
|
|
102
|
+
canvas = (int(canvas[0]), int(canvas[1]))
|
|
103
|
+
if canvas[0] < 16 or canvas[1] < 16:
|
|
104
|
+
raise MotionError(f"tamanho de tela inválido: {canvas}")
|
|
105
|
+
if frames is None:
|
|
106
|
+
secs = tpl.suggested_duration(resolved) if duration is None else float(duration)
|
|
107
|
+
if not math.isfinite(secs) or secs <= 0:
|
|
108
|
+
raise MotionError(f"duração inválida: {duration!r}")
|
|
109
|
+
frames = E.frame_of(secs, fps)
|
|
110
|
+
frames = int(frames)
|
|
111
|
+
if frames < 2:
|
|
112
|
+
raise MotionError(f"'{tpl.id}' precisa durar pelo menos 2 quadros")
|
|
113
|
+
if frames > Rg.MAX_DURATION * fps:
|
|
114
|
+
raise MotionError(f"'{tpl.id}' pode durar no máximo {Rg.MAX_DURATION:g} s")
|
|
115
|
+
spec = tpl.default_region if region in (None, "", "auto") else region
|
|
116
|
+
x, y, w, h = E.region_box(spec, canvas)
|
|
117
|
+
warnings: list[str] = []
|
|
118
|
+
sx0, sy0, sx1, sy1 = E.safe_area(canvas)
|
|
119
|
+
if not isinstance(spec, str) and (y < sy0 - 0.5 or y + h > sy1 + 0.5):
|
|
120
|
+
warnings.append(f"'{tpl.id}': a região [{x:g}, {y:g}, {w:g}, {h:g}] passa da área segura "
|
|
121
|
+
f"(de {sy0:.0f} a {sy1:.0f} na altura): a interface do Reels pode cobrir parte dela")
|
|
122
|
+
# without a card the text sits on the video: white letters with a dark outline read there, the dark letters
|
|
123
|
+
# of the light card do not, so "tema" only matters when there is a card
|
|
124
|
+
tema = resolved.get("tema", "escuro") if resolved.get("card", True) is not False else "escuro"
|
|
125
|
+
# a look with its own palette (papel, pop, neon) keeps its colors even when the video has another accent;
|
|
126
|
+
# only an item's own "cor" changes them
|
|
127
|
+
own_palette = tema in E.PAPEL or tema in ("pop", "neon")
|
|
128
|
+
base_accent = E.DEFAULT_ACCENT if own_palette and resolved.get("cor") in (None, "") else accent
|
|
129
|
+
theme = E.make_theme(_accent_for(resolved, base_accent), tema)
|
|
130
|
+
box = E.Box(x=x, y=y, w=w, h=h, scale=min(canvas) / E.REF, canvas=canvas, fps=fps, frames=frames,
|
|
131
|
+
theme=theme, bleed=tpl.bleed)
|
|
132
|
+
item = MotionItem(template=tpl, params=resolved, box=box, frames=frames, region=[x, y, w, h],
|
|
133
|
+
region_spec=spec, warnings=warnings)
|
|
134
|
+
hook = getattr(tpl.module, "warnings", None)
|
|
135
|
+
if callable(hook):
|
|
136
|
+
with E.theme_fonts(theme):
|
|
137
|
+
texts = hook(resolved, box) or []
|
|
138
|
+
for text in texts:
|
|
139
|
+
item.warnings.append(f"'{tpl.id}': {text}")
|
|
140
|
+
if blocked:
|
|
141
|
+
place(item, blocked)
|
|
142
|
+
return item
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def _ink_hook(item: MotionItem) -> tuple[float, float, float, float] | None | bool:
|
|
146
|
+
"""The template's own `ink(params, box)` (box-local design px), or False when it has none."""
|
|
147
|
+
fn = getattr(item.template.module, "ink", None)
|
|
148
|
+
if not callable(fn):
|
|
149
|
+
return False
|
|
150
|
+
with E.theme_fonts(item.box.theme):
|
|
151
|
+
box = fn(item.params, item.box)
|
|
152
|
+
if box is None:
|
|
153
|
+
return None
|
|
154
|
+
x0, y0, x1, y1 = (float(v) for v in box)
|
|
155
|
+
return (min(x0, x1), min(y0, y1), max(x0, x1), max(y0, y1))
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def measure_ink(item: MotionItem, samples: int = 6) -> tuple[float, float, float, float] | None:
|
|
159
|
+
"""Union of the drawn pixels (alpha above INK_ALPHA) over a few frames (without the exit), in box-local
|
|
160
|
+
design px. A template with `ink(params, box)` gives it directly (a full-screen flash is not "ink")."""
|
|
161
|
+
hooked = _ink_hook(item)
|
|
162
|
+
if hooked is not False:
|
|
163
|
+
item.ink = hooked # type: ignore[assignment]
|
|
164
|
+
return item.ink
|
|
165
|
+
n = item.frames
|
|
166
|
+
last = max(0, n - 1 - (EXIT_FRAMES if item.template.exit == "fade" else 0))
|
|
167
|
+
picks = sorted({int(round(last * k / max(1, samples - 1))) for k in range(samples)})
|
|
168
|
+
s = item.box.scale
|
|
169
|
+
union = None
|
|
170
|
+
for i in picks:
|
|
171
|
+
fr = render_frame(item, i, with_exit=False)
|
|
172
|
+
bb = fr.image.getchannel("A").point(_INK_LUT).getbbox()
|
|
173
|
+
if not bb:
|
|
174
|
+
continue
|
|
175
|
+
x0, y0 = (fr.x + bb[0]) / s - item.box.x, (fr.y + bb[1]) / s - item.box.y
|
|
176
|
+
x1, y1 = (fr.x + bb[2]) / s - item.box.x, (fr.y + bb[3]) / s - item.box.y
|
|
177
|
+
union = (x0, y0, x1, y1) if union is None else (min(union[0], x0), min(union[1], y0),
|
|
178
|
+
max(union[2], x1), max(union[3], y1))
|
|
179
|
+
item.ink = union
|
|
180
|
+
return union
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def free_offset(item: MotionItem, blocked: Sequence[tuple[float, float]], gap: float = 23.0) -> float | None:
|
|
184
|
+
"""Vertical move (design px, from where the item is now) that puts its drawn part at the free place
|
|
185
|
+
closest to where it is, inside the safe band and `gap` away from every blocked (y0, y1) band. 0 when it
|
|
186
|
+
draws nothing, None when there is no free place. The item is not changed."""
|
|
187
|
+
import face_layout # noqa: PLC0415 (helpers/ is on sys.path through engine)
|
|
188
|
+
|
|
189
|
+
ink = item.ink if item.ink is not None else measure_ink(item)
|
|
190
|
+
if ink is None:
|
|
191
|
+
return 0.0
|
|
192
|
+
b = item.box
|
|
193
|
+
sx0, sy0, sx1, sy1 = E.safe_area(b.canvas)
|
|
194
|
+
top, bottom = b.y + ink[1], b.y + ink[3]
|
|
195
|
+
ref = (top + bottom) / 2.0
|
|
196
|
+
above, below = ref - top, bottom - ref
|
|
197
|
+
bands = [(float(a), float(c)) for a, c in blocked if c > a]
|
|
198
|
+
center = face_layout.free_center(ref, above, below, bands, sy0, sy1, gap)
|
|
199
|
+
return None if center is None else center - ref
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
def move_item(item: MotionItem, dy: float) -> MotionItem:
|
|
203
|
+
"""Slide the item `dy` design px down (negative: up)."""
|
|
204
|
+
if abs(dy) >= 0.5:
|
|
205
|
+
b = item.box
|
|
206
|
+
item.box = E.with_box(b, y=b.y + dy)
|
|
207
|
+
item.region = [b.x, b.y + dy, b.w, b.h]
|
|
208
|
+
item.moved_by += dy
|
|
209
|
+
return item
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
def place(item: MotionItem, blocked: Sequence[tuple[float, float]], gap: float = 23.0) -> MotionItem:
|
|
213
|
+
"""Slide the item vertically so its drawn part clears every blocked band (face_layout.free_center)."""
|
|
214
|
+
if not item.template.movable:
|
|
215
|
+
return item
|
|
216
|
+
dy = free_offset(item, blocked, gap)
|
|
217
|
+
if dy is None:
|
|
218
|
+
item.warnings.append(f"'{item.template.id}' não achou lugar livre do rosto e dos textos: ficou na região pedida")
|
|
219
|
+
return item
|
|
220
|
+
return move_item(item, dy)
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
# ------------------------------------------------------------------ frames
|
|
224
|
+
|
|
225
|
+
def _normalize(item: MotionItem, out: Any, i: int) -> Frame:
|
|
226
|
+
b = item.box
|
|
227
|
+
if isinstance(out, E.Canvas):
|
|
228
|
+
return Frame(out.image, out.origin_x, out.origin_y)
|
|
229
|
+
if isinstance(out, Image.Image):
|
|
230
|
+
img = out if out.mode == "RGBA" else out.convert("RGBA")
|
|
231
|
+
if img.size == tuple(b.canvas):
|
|
232
|
+
return Frame(img, 0, 0)
|
|
233
|
+
bw, bh = int(round(b.w * b.scale)), int(round(b.h * b.scale))
|
|
234
|
+
if abs(img.width - bw) <= 1 and abs(img.height - bh) <= 1:
|
|
235
|
+
return Frame(img, int(round(b.x * b.scale)), int(round(b.y * b.scale)))
|
|
236
|
+
raise MotionError(f"'{item.template.id}': render do quadro {i} devolveu {img.size}; o esperado é a tela "
|
|
237
|
+
f"{tuple(b.canvas)}, a caixa ({bw}, {bh}) ou um Canvas")
|
|
238
|
+
raise MotionError(f"'{item.template.id}': render precisa devolver um Canvas ou uma imagem RGBA (veio {type(out).__name__})")
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
def _exit(item: MotionItem, fr: Frame, i: int) -> Frame:
|
|
242
|
+
"""Generic exit: the last frames fade out and shrink 3% around the box center. Only the drawn part is
|
|
243
|
+
resampled (premultiplied, so transparent pixels never darken the edges)."""
|
|
244
|
+
n = item.frames
|
|
245
|
+
k = min(EXIT_FRAMES, n // 3)
|
|
246
|
+
j = i - (n - k)
|
|
247
|
+
if item.template.exit != "fade" or k <= 0 or j < 0:
|
|
248
|
+
return fr
|
|
249
|
+
p = E.in_quad((j + 1) / (k + 1))
|
|
250
|
+
img = fr.image
|
|
251
|
+
bb = img.getchannel("A").getbbox()
|
|
252
|
+
if bb is None:
|
|
253
|
+
return fr
|
|
254
|
+
b = item.box
|
|
255
|
+
cx = (b.x + b.w / 2.0) * b.scale - fr.x
|
|
256
|
+
cy = (b.y + b.h / 2.0) * b.scale - fr.y
|
|
257
|
+
sc = 1.0 - EXIT_SHRINK * p
|
|
258
|
+
# the shrunk drawing stays between the drawn area and the center: resample only that rectangle
|
|
259
|
+
x0 = int(math.floor(min(bb[0], cx + (bb[0] - cx) * sc))) - 2
|
|
260
|
+
y0 = int(math.floor(min(bb[1], cy + (bb[1] - cy) * sc))) - 2
|
|
261
|
+
x1 = int(math.ceil(max(bb[2], cx + (bb[2] - cx) * sc))) + 2
|
|
262
|
+
y1 = int(math.ceil(max(bb[3], cy + (bb[3] - cy) * sc))) + 2
|
|
263
|
+
x0, y0, x1, y1 = max(0, x0), max(0, y0), min(img.width, x1), min(img.height, y1)
|
|
264
|
+
part = img.crop((x0, y0, x1, y1)).convert("RGBa")
|
|
265
|
+
inv = 1.0 / sc
|
|
266
|
+
lx, ly = cx - x0, cy - y0
|
|
267
|
+
part = part.transform(part.size, Image.AFFINE, (inv, 0.0, lx - lx * inv, 0.0, inv, ly - ly * inv),
|
|
268
|
+
resample=Image.BICUBIC).convert("RGBA")
|
|
269
|
+
a = np.asarray(part.getchannel("A"), np.float32) * (1.0 - p)
|
|
270
|
+
part.putalpha(Image.fromarray(np.clip(a + 0.5, 0, 255).astype(np.uint8), "L"))
|
|
271
|
+
out = Image.new("RGBA", img.size, (0, 0, 0, 0))
|
|
272
|
+
out.paste(part, (x0, y0))
|
|
273
|
+
return Frame(out, fr.x, fr.y)
|
|
274
|
+
|
|
275
|
+
|
|
276
|
+
def render_frame(item: MotionItem, i: int, with_exit: bool = True) -> Frame:
|
|
277
|
+
if not 0 <= i < item.frames:
|
|
278
|
+
raise MotionError(f"quadro {i} fora do item (0 a {item.frames - 1})")
|
|
279
|
+
with E.theme_fonts(item.box.theme):
|
|
280
|
+
out = item.template.render(int(i), int(item.fps), item.params, item.box)
|
|
281
|
+
fr = _normalize(item, out, i)
|
|
282
|
+
return _exit(item, fr, i) if with_exit else fr
|
|
283
|
+
|
|
284
|
+
|
|
285
|
+
def iter_frames(item: MotionItem) -> Iterator[tuple[int, Frame, bool]]:
|
|
286
|
+
"""(index, frame, repeated) for every frame; frames after the template's settle time reuse the last one."""
|
|
287
|
+
settle = item.template.settle(item.params, item.fps)
|
|
288
|
+
n = item.frames
|
|
289
|
+
exit_from = n - min(EXIT_FRAMES, n // 3) if item.template.exit == "fade" else n
|
|
290
|
+
still_from = E.frame_of(settle, item.fps) if settle is not None else None
|
|
291
|
+
held: Frame | None = None
|
|
292
|
+
for i in range(n):
|
|
293
|
+
if still_from is not None and still_from <= i < exit_from and held is not None:
|
|
294
|
+
yield i, held, True
|
|
295
|
+
continue
|
|
296
|
+
fr = render_frame(item, i)
|
|
297
|
+
if still_from is not None and i >= still_from and i < exit_from:
|
|
298
|
+
held = fr
|
|
299
|
+
yield i, fr, False
|
|
300
|
+
|
|
301
|
+
|
|
302
|
+
def clip_rect(item: MotionItem, first: Frame) -> tuple[int, int, int, int]:
|
|
303
|
+
"""(x, y, w, h) render px on the canvas that holds every frame: even origin and even size."""
|
|
304
|
+
W, H = item.canvas
|
|
305
|
+
x0, y0 = max(0, first.x), max(0, first.y)
|
|
306
|
+
x1, y1 = min(W, first.x + first.image.width), min(H, first.y + first.image.height)
|
|
307
|
+
x0, y0 = x0 // 2 * 2, y0 // 2 * 2
|
|
308
|
+
x1, y1 = min(W, (x1 + 1) // 2 * 2), min(H, (y1 + 1) // 2 * 2)
|
|
309
|
+
if x1 - x0 < 2 or y1 - y0 < 2:
|
|
310
|
+
raise MotionError(f"'{item.template.id}' desenha fora da tela")
|
|
311
|
+
return x0, y0, x1 - x0, y1 - y0
|
|
312
|
+
|
|
313
|
+
|
|
314
|
+
def _place_on(fr: Frame, rect: tuple[int, int, int, int]) -> Image.Image:
|
|
315
|
+
x, y, w, h = rect
|
|
316
|
+
if (fr.x, fr.y, fr.image.width, fr.image.height) == rect:
|
|
317
|
+
return fr.image
|
|
318
|
+
out = Image.new("RGBA", (w, h), (0, 0, 0, 0))
|
|
319
|
+
out.paste(fr.image, (fr.x - x, fr.y - y))
|
|
320
|
+
return out
|
|
321
|
+
|
|
322
|
+
|
|
323
|
+
def _check_inside(item: MotionItem, fr: Frame, rect: tuple[int, int, int, int], i: int) -> None:
|
|
324
|
+
"""Every frame must fit the rectangle of the first one (same kind of Canvas on every frame)."""
|
|
325
|
+
W, H = item.canvas
|
|
326
|
+
x0, y0 = max(0, fr.x), max(0, fr.y)
|
|
327
|
+
x1, y1 = min(W, fr.x + fr.image.width), min(H, fr.y + fr.image.height)
|
|
328
|
+
if x0 < rect[0] or y0 < rect[1] or x1 > rect[0] + rect[2] or y1 > rect[1] + rect[3]:
|
|
329
|
+
raise MotionError(f"'{item.template.id}': o quadro {i} desenha fora da área do quadro 0. Use sempre o mesmo "
|
|
330
|
+
f"tipo de Canvas em todos os quadros (Canvas(box, full=True) se algum precisar da tela toda)")
|
|
331
|
+
|
|
332
|
+
|
|
333
|
+
def full_frame(item: MotionItem, fr: Frame) -> Image.Image:
|
|
334
|
+
W, H = item.canvas
|
|
335
|
+
return _place_on(fr, (0, 0, W, H))
|
|
336
|
+
|
|
337
|
+
|
|
338
|
+
# ------------------------------------------------------------------ sounds
|
|
339
|
+
|
|
340
|
+
def sfx_cues(item: MotionItem, check_names: bool = False) -> list[dict]:
|
|
341
|
+
"""[{frame (from the item start), name, volume, why?}] inside the item, sorted by frame."""
|
|
342
|
+
tpl = item.template
|
|
343
|
+
fn = getattr(tpl.module, "sfx", None)
|
|
344
|
+
raw = fn(item.params, item.fps, item.frames) if callable(fn) else tpl.sfx
|
|
345
|
+
if not isinstance(raw, list):
|
|
346
|
+
raise MotionError(f"'{tpl.id}': sfx() precisa devolver uma lista")
|
|
347
|
+
out = []
|
|
348
|
+
for k, cue in enumerate(raw):
|
|
349
|
+
if not isinstance(cue, dict) or not isinstance(cue.get("name"), str):
|
|
350
|
+
raise MotionError(f"'{tpl.id}': som {k} sem 'name'")
|
|
351
|
+
if "frame" in cue:
|
|
352
|
+
frame = int(cue["frame"])
|
|
353
|
+
else:
|
|
354
|
+
frame = E.frame_of(float(cue.get("at", 0.0)), item.fps)
|
|
355
|
+
if not 0 <= frame < item.frames:
|
|
356
|
+
continue
|
|
357
|
+
vol = float(cue.get("volume", 1.0))
|
|
358
|
+
entry = {"frame": frame, "name": cue["name"], "volume": round(min(2.0, max(0.0, vol)), 3)}
|
|
359
|
+
if cue.get("length") is not None and float(cue["length"]) > 0:
|
|
360
|
+
entry["length"] = round(float(cue["length"]), 4) # seconds the sound plays after its strong point
|
|
361
|
+
if cue.get("why"):
|
|
362
|
+
entry["why"] = str(cue["why"])
|
|
363
|
+
out.append(entry)
|
|
364
|
+
if check_names:
|
|
365
|
+
import make_sfx # noqa: PLC0415
|
|
366
|
+
manifest = make_sfx.load_manifest()
|
|
367
|
+
for cue in out:
|
|
368
|
+
if make_sfx.resolve_name(cue["name"], manifest) is None:
|
|
369
|
+
raise MotionError(f"'{tpl.id}': efeito sonoro '{cue['name']}' não existe (use um de {', '.join(make_sfx.NAMES)})")
|
|
370
|
+
return sorted(out, key=lambda c: (c["frame"], c["name"]))
|
|
371
|
+
|
|
372
|
+
|
|
373
|
+
# ------------------------------------------------------------------ outputs
|
|
374
|
+
|
|
375
|
+
def _ffmpeg() -> str:
|
|
376
|
+
from shutil import which
|
|
377
|
+
exe = which("ffmpeg")
|
|
378
|
+
if not exe:
|
|
379
|
+
raise MotionError("'ffmpeg' não encontrado no PATH. macOS: brew install ffmpeg · Windows: winget install Gyan.FFmpeg")
|
|
380
|
+
return exe
|
|
381
|
+
|
|
382
|
+
|
|
383
|
+
class _Pipe:
|
|
384
|
+
def __init__(self, args: list[str], what: str):
|
|
385
|
+
self.what = what
|
|
386
|
+
self.proc = subprocess.Popen([_ffmpeg(), "-hide_banner", "-nostdin", "-v", "error", "-y", *args],
|
|
387
|
+
stdin=subprocess.PIPE, stderr=subprocess.PIPE)
|
|
388
|
+
|
|
389
|
+
def write(self, data: bytes) -> None:
|
|
390
|
+
try:
|
|
391
|
+
self.proc.stdin.write(data) # type: ignore[union-attr]
|
|
392
|
+
except BrokenPipeError:
|
|
393
|
+
self.close()
|
|
394
|
+
|
|
395
|
+
def close(self) -> None:
|
|
396
|
+
try:
|
|
397
|
+
self.proc.stdin.close() # type: ignore[union-attr]
|
|
398
|
+
except OSError:
|
|
399
|
+
pass
|
|
400
|
+
err = self.proc.stderr.read().decode("utf-8", "replace") if self.proc.stderr else ""
|
|
401
|
+
if self.proc.wait() != 0:
|
|
402
|
+
raise MotionError(f"o ffmpeg falhou ao {self.what}: {err.strip()[-400:]}", code=1)
|
|
403
|
+
|
|
404
|
+
def kill(self) -> None:
|
|
405
|
+
try:
|
|
406
|
+
self.proc.kill()
|
|
407
|
+
self.proc.wait()
|
|
408
|
+
except OSError:
|
|
409
|
+
pass
|
|
410
|
+
|
|
411
|
+
|
|
412
|
+
def _qtrle_args(w: int, h: int, fps: int, n: int, out: Path) -> list[str]:
|
|
413
|
+
return ["-f", "rawvideo", "-pix_fmt", "rgba", "-s", f"{w}x{h}", "-framerate", str(fps), "-i", "-",
|
|
414
|
+
"-c:v", "qtrle", "-pix_fmt", "argb", "-g", str(max(1, n)), "-frames:v", str(n),
|
|
415
|
+
"-f", "mov", str(out)]
|
|
416
|
+
|
|
417
|
+
|
|
418
|
+
def _atomic_json(path: Path, obj: dict) -> None:
|
|
419
|
+
tmp = path.with_name(".tmp-" + path.name)
|
|
420
|
+
tmp.write_text(json.dumps(obj, ensure_ascii=False, indent=2), encoding="utf-8")
|
|
421
|
+
os.replace(tmp, path)
|
|
422
|
+
|
|
423
|
+
|
|
424
|
+
def render_clip(item: MotionItem, out_dir: Path | str, cache: bool = True) -> dict:
|
|
425
|
+
"""The alpha clip compose_final overlays: renders/motion/<id>_<key>.mov + .json (cached by content).
|
|
426
|
+
|
|
427
|
+
Returns {file, json, template, x, y, w, h (render px on the canvas), frames, fps, canvas, region
|
|
428
|
+
(design px), moved_by, ink (render px on the canvas or null), sfx (cues from the item start), params,
|
|
429
|
+
key, cached, warnings}."""
|
|
430
|
+
out_dir = Path(out_dir)
|
|
431
|
+
out_dir.mkdir(parents=True, exist_ok=True)
|
|
432
|
+
key = item.key
|
|
433
|
+
clip = out_dir / f"{item.template.id}_{key}.mov"
|
|
434
|
+
meta_path = clip.with_suffix(".json")
|
|
435
|
+
if cache and clip.exists() and meta_path.exists():
|
|
436
|
+
try:
|
|
437
|
+
meta = json.loads(meta_path.read_text(encoding="utf-8"))
|
|
438
|
+
if meta.get("key") == key and clip.stat().st_size > 0:
|
|
439
|
+
meta.update({"file": str(clip), "json": str(meta_path), "cached": True,
|
|
440
|
+
"warnings": list(item.warnings), "region": [round(v, 2) for v in item.region],
|
|
441
|
+
"moved_by": round(item.moved_by, 2)})
|
|
442
|
+
return meta
|
|
443
|
+
except (OSError, ValueError):
|
|
444
|
+
pass
|
|
445
|
+
tmp = clip.with_name(".tmp-" + clip.name)
|
|
446
|
+
pipe = None
|
|
447
|
+
rect = None
|
|
448
|
+
ink = None
|
|
449
|
+
last_bytes = None
|
|
450
|
+
hooked = _ink_hook(item)
|
|
451
|
+
try:
|
|
452
|
+
for i, fr, repeated in iter_frames(item):
|
|
453
|
+
if rect is None:
|
|
454
|
+
rect = clip_rect(item, fr)
|
|
455
|
+
pipe = _Pipe(_qtrle_args(rect[2], rect[3], item.fps, item.frames, tmp), "gravar a animação")
|
|
456
|
+
if not repeated or last_bytes is None:
|
|
457
|
+
_check_inside(item, fr, rect, i)
|
|
458
|
+
img = _place_on(fr, rect)
|
|
459
|
+
if hooked is False:
|
|
460
|
+
bb = img.getchannel("A").point(_INK_LUT).getbbox()
|
|
461
|
+
if bb:
|
|
462
|
+
ink = bb if ink is None else (min(ink[0], bb[0]), min(ink[1], bb[1]),
|
|
463
|
+
max(ink[2], bb[2]), max(ink[3], bb[3]))
|
|
464
|
+
last_bytes = img.tobytes()
|
|
465
|
+
pipe.write(last_bytes) # type: ignore[union-attr]
|
|
466
|
+
pipe.close() # type: ignore[union-attr]
|
|
467
|
+
except BaseException:
|
|
468
|
+
if pipe is not None:
|
|
469
|
+
pipe.kill()
|
|
470
|
+
if tmp.exists():
|
|
471
|
+
tmp.unlink()
|
|
472
|
+
raise
|
|
473
|
+
os.replace(tmp, clip)
|
|
474
|
+
x, y, w, h = rect # type: ignore[misc]
|
|
475
|
+
if hooked is False:
|
|
476
|
+
ink_px = [x + ink[0], y + ink[1], x + ink[2], y + ink[3]] if ink else None
|
|
477
|
+
elif hooked is None:
|
|
478
|
+
ink_px = None
|
|
479
|
+
else:
|
|
480
|
+
s, b = item.box.scale, item.box
|
|
481
|
+
ink_px = [int(math.floor((b.x + hooked[0]) * s)), int(math.floor((b.y + hooked[1]) * s)),
|
|
482
|
+
int(math.ceil((b.x + hooked[2]) * s)), int(math.ceil((b.y + hooked[3]) * s))]
|
|
483
|
+
meta = {
|
|
484
|
+
"file": str(clip), "json": str(meta_path), "template": item.template.id, "x": x, "y": y, "w": w, "h": h,
|
|
485
|
+
"frames": item.frames, "fps": item.fps, "canvas": list(item.canvas),
|
|
486
|
+
"region": [round(v, 2) for v in item.region], "moved_by": round(item.moved_by, 2),
|
|
487
|
+
"ink": ink_px,
|
|
488
|
+
"sfx": sfx_cues(item), "params": item.params, "key": key, "cached": False, "warnings": list(item.warnings),
|
|
489
|
+
}
|
|
490
|
+
_atomic_json(meta_path, meta)
|
|
491
|
+
return meta
|
|
492
|
+
|
|
493
|
+
|
|
494
|
+
def overlay_filters(clip: dict, input_index: int, base_label: str, out_label: str, start_frame: int,
|
|
495
|
+
windows: Sequence[tuple[int, int]] | None = None) -> list[str]:
|
|
496
|
+
"""Filter-graph lines that put a render_clip() result on the video from `start_frame` on (frame-exact).
|
|
497
|
+
`windows` = [(first, end)] output frames where it shows (default: the whole item); the clip keeps its own
|
|
498
|
+
timing, so a hidden stretch just skips those frames."""
|
|
499
|
+
fps, n = clip["fps"], clip["frames"]
|
|
500
|
+
lab = f"mo{input_index}"
|
|
501
|
+
spans = [(start_frame, start_frame + n)] if windows is None else list(windows)
|
|
502
|
+
enable = "+".join(f"between(n,{a},{b - 1})" for a, b in spans if b > a) or "0"
|
|
503
|
+
return [
|
|
504
|
+
f"[{input_index}:v]settb=1/{fps},setpts=N+{start_frame},format=rgba,"
|
|
505
|
+
f"scale=out_color_matrix=bt709:out_range=tv,format=yuva420p[{lab}]",
|
|
506
|
+
f"[{base_label}][{lab}]overlay=x={clip['x']}:y={clip['y']}:eof_action=pass:format=auto:"
|
|
507
|
+
f"enable='{enable}'[{out_label}]",
|
|
508
|
+
]
|
|
509
|
+
|
|
510
|
+
|
|
511
|
+
def write_mov(item: MotionItem, path: Path) -> Path:
|
|
512
|
+
"""Transparent full-canvas QuickTime Animation .mov (for a video editor)."""
|
|
513
|
+
W, H = item.canvas
|
|
514
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
515
|
+
tmp = path.with_name(".tmp-" + path.name)
|
|
516
|
+
pipe = _Pipe(_qtrle_args(W, H, item.fps, item.frames, tmp), "gravar o .mov")
|
|
517
|
+
try:
|
|
518
|
+
last = None
|
|
519
|
+
for _, fr, repeated in iter_frames(item):
|
|
520
|
+
if not repeated or last is None:
|
|
521
|
+
last = full_frame(item, fr).tobytes()
|
|
522
|
+
pipe.write(last)
|
|
523
|
+
pipe.close()
|
|
524
|
+
except BaseException:
|
|
525
|
+
pipe.kill()
|
|
526
|
+
if tmp.exists():
|
|
527
|
+
tmp.unlink()
|
|
528
|
+
raise
|
|
529
|
+
os.replace(tmp, path)
|
|
530
|
+
return path
|
|
531
|
+
|
|
532
|
+
|
|
533
|
+
def write_pngs(item: MotionItem, folder: Path) -> list[Path]:
|
|
534
|
+
folder.mkdir(parents=True, exist_ok=True)
|
|
535
|
+
out = []
|
|
536
|
+
for i, fr, _ in iter_frames(item):
|
|
537
|
+
p = folder / f"{item.template.id}_{i:04d}.png"
|
|
538
|
+
full_frame(item, fr).save(p, "PNG", compress_level=3)
|
|
539
|
+
out.append(p)
|
|
540
|
+
return out
|
|
541
|
+
|
|
542
|
+
|
|
543
|
+
def background(canvas: Sequence[int], image: str | Path | None = None) -> Image.Image:
|
|
544
|
+
"""A frame to judge the animation on: the given picture (cover) or a neutral talking-head mock."""
|
|
545
|
+
W, H = int(canvas[0]), int(canvas[1])
|
|
546
|
+
if image:
|
|
547
|
+
p = Path(image)
|
|
548
|
+
if not p.exists():
|
|
549
|
+
raise MotionError(f"fundo não encontrado: {p}")
|
|
550
|
+
with Image.open(p) as im:
|
|
551
|
+
im = ImageOps.exif_transpose(im).convert("RGB")
|
|
552
|
+
return ImageOps.fit(im, (W, H), Image.LANCZOS).convert("RGBA")
|
|
553
|
+
y = np.linspace(0.0, 1.0, H, dtype=np.float32)[:, None]
|
|
554
|
+
x = np.linspace(0.0, 1.0, W, dtype=np.float32)[None, :]
|
|
555
|
+
top, bot = np.array([62, 70, 84], np.float32), np.array([142, 128, 114], np.float32)
|
|
556
|
+
rgb = top[None, None, :] * (1 - y[..., None]) + bot[None, None, :] * y[..., None]
|
|
557
|
+
rgb = rgb + 14 * np.sin(x * 3.1)[..., None]
|
|
558
|
+
img = Image.fromarray(np.clip(rgb, 0, 255).astype(np.uint8), "RGB").convert("RGBA")
|
|
559
|
+
person = Image.new("L", (W, H), 0)
|
|
560
|
+
d = ImageDraw.Draw(person)
|
|
561
|
+
k = min(W, H) / 1080.0
|
|
562
|
+
cx = W / 2
|
|
563
|
+
head_cy = H * 0.36
|
|
564
|
+
d.ellipse((cx - 175 * k, head_cy - 230 * k, cx + 175 * k, head_cy + 230 * k), fill=255)
|
|
565
|
+
d.ellipse((cx - 470 * k, head_cy + 300 * k, cx + 470 * k, head_cy + 1300 * k), fill=255)
|
|
566
|
+
person = person.filter(ImageFilter.GaussianBlur(10 * k))
|
|
567
|
+
shade = Image.new("RGBA", (W, H), (40, 34, 32, 255))
|
|
568
|
+
shade.putalpha(person.point(lambda v: v * 150 // 255))
|
|
569
|
+
img.alpha_composite(shade)
|
|
570
|
+
return img
|
|
571
|
+
|
|
572
|
+
|
|
573
|
+
def _dashed(d: ImageDraw.ImageDraw, x0, y0, x1, y1, fill, width=1, dash=10):
|
|
574
|
+
length = math.hypot(x1 - x0, y1 - y0)
|
|
575
|
+
if length <= 0:
|
|
576
|
+
return
|
|
577
|
+
n = max(1, int(length // (2 * dash)))
|
|
578
|
+
for k in range(n + 1):
|
|
579
|
+
a = min(1.0, k * 2 * dash / length)
|
|
580
|
+
b = min(1.0, (k * 2 * dash + dash) / length)
|
|
581
|
+
d.line((x0 + (x1 - x0) * a, y0 + (y1 - y0) * a, x0 + (x1 - x0) * b, y0 + (y1 - y0) * b), fill=fill, width=width)
|
|
582
|
+
|
|
583
|
+
|
|
584
|
+
def contact_sheet(item: MotionItem, path: Path, count: int = 8, bg: str | Path | None = None,
|
|
585
|
+
guides: bool = True, tile_width: int | None = None, cols: int = 4) -> Path:
|
|
586
|
+
"""`count` frames spread over the item on one PNG, with the safe area and the region drawn."""
|
|
587
|
+
n = item.frames
|
|
588
|
+
picks = sorted({int(round(v)) for v in np.linspace(0, n - 1, max(2, min(count, n)))})
|
|
589
|
+
W, H = item.canvas
|
|
590
|
+
base = background(item.canvas, bg)
|
|
591
|
+
tw = tile_width or (270 if H >= W else 480)
|
|
592
|
+
th = int(round(tw * H / W))
|
|
593
|
+
rows = (len(picks) + cols - 1) // cols
|
|
594
|
+
label_h = 26
|
|
595
|
+
sheet = Image.new("RGB", (cols * tw + (cols + 1) * 8, rows * (th + label_h) + (rows + 1) * 8), (24, 24, 26))
|
|
596
|
+
d_sheet = ImageDraw.Draw(sheet)
|
|
597
|
+
lab_font = E.font("text_bold", 17)
|
|
598
|
+
s = item.box.scale
|
|
599
|
+
sx0, sy0, sx1, sy1 = (v * s for v in E.safe_area(item.canvas))
|
|
600
|
+
rx, ry, rw, rh = (v * s for v in item.region)
|
|
601
|
+
for k, i in enumerate(picks):
|
|
602
|
+
frame = base.copy()
|
|
603
|
+
frame.alpha_composite(full_frame(item, render_frame(item, i)))
|
|
604
|
+
if guides:
|
|
605
|
+
d = ImageDraw.Draw(frame)
|
|
606
|
+
lw = max(1, int(round(2 * s)))
|
|
607
|
+
for yy in (sy0, sy1):
|
|
608
|
+
_dashed(d, 0, yy, W, yy, (255, 80, 80, 255), lw, int(14 * s) + 2)
|
|
609
|
+
if H > W:
|
|
610
|
+
rail = W - E.SAFE_ZONE_VERTICAL["right_rail"] * s
|
|
611
|
+
_dashed(d, rail, H * 0.5, rail, sy1, (255, 80, 80, 255), lw, int(14 * s) + 2)
|
|
612
|
+
for (a, b_), (c, e) in (((rx, ry), (rx + rw, ry)), ((rx + rw, ry), (rx + rw, ry + rh)),
|
|
613
|
+
((rx + rw, ry + rh), (rx, ry + rh)), ((rx, ry + rh), (rx, ry))):
|
|
614
|
+
_dashed(d, a, b_, c, e, (120, 220, 255, 255), lw, int(10 * s) + 2)
|
|
615
|
+
tile = frame.convert("RGB").resize((tw, th), Image.LANCZOS)
|
|
616
|
+
col, row = k % cols, k // cols
|
|
617
|
+
ox, oy = 8 + col * (tw + 8), 8 + row * (th + label_h + 8)
|
|
618
|
+
sheet.paste(tile, (ox, oy + label_h))
|
|
619
|
+
d_sheet.text((ox + 2, oy + 3), f"quadro {i} · {E.format_number(i / item.fps, 2)} s", font=lab_font,
|
|
620
|
+
fill=(235, 235, 235))
|
|
621
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
622
|
+
sheet.save(path, "PNG")
|
|
623
|
+
return path
|
|
624
|
+
|
|
625
|
+
|
|
626
|
+
def write_mp4(item: MotionItem, path: Path, bg: str | Path | None = None) -> Path:
|
|
627
|
+
"""H.264 preview of the animation over a background (to watch it, not to compose)."""
|
|
628
|
+
W, H = item.canvas
|
|
629
|
+
base = background(item.canvas, bg)
|
|
630
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
631
|
+
tmp = path.with_name(".tmp-" + path.name)
|
|
632
|
+
pipe = _Pipe(["-f", "rawvideo", "-pix_fmt", "rgb24", "-s", f"{W}x{H}", "-framerate", str(item.fps), "-i", "-",
|
|
633
|
+
"-vf", "scale=out_color_matrix=bt709:out_range=tv,format=yuv420p", "-c:v", "libx264",
|
|
634
|
+
"-preset", "veryfast", "-crf", "18", "-colorspace", "bt709", "-color_primaries", "bt709",
|
|
635
|
+
"-color_trc", "bt709", "-color_range", "tv", "-frames:v", str(item.frames),
|
|
636
|
+
"-movflags", "+faststart", "-f", "mp4", str(tmp)], "gravar a prévia .mp4")
|
|
637
|
+
try:
|
|
638
|
+
last = None
|
|
639
|
+
for _, fr, repeated in iter_frames(item):
|
|
640
|
+
if not repeated or last is None:
|
|
641
|
+
img = base.copy()
|
|
642
|
+
img.alpha_composite(full_frame(item, fr))
|
|
643
|
+
last = img.convert("RGB").tobytes()
|
|
644
|
+
pipe.write(last)
|
|
645
|
+
pipe.close()
|
|
646
|
+
except BaseException:
|
|
647
|
+
pipe.kill()
|
|
648
|
+
if tmp.exists():
|
|
649
|
+
tmp.unlink()
|
|
650
|
+
raise
|
|
651
|
+
os.replace(tmp, path)
|
|
652
|
+
return path
|
|
653
|
+
|
|
654
|
+
|
|
655
|
+
def decode_clip(path: Path | str, w: int, h: int) -> np.ndarray:
|
|
656
|
+
"""Frames of a clip as (n, h, w, 4) RGBA uint8 (tests and checks)."""
|
|
657
|
+
proc = subprocess.run([_ffmpeg(), "-v", "error", "-i", str(path), "-f", "rawvideo", "-pix_fmt", "rgba", "-"],
|
|
658
|
+
capture_output=True)
|
|
659
|
+
if proc.returncode != 0:
|
|
660
|
+
raise MotionError(f"não consegui ler {path}: {proc.stderr.decode('utf-8', 'replace')[-300:]}", code=1)
|
|
661
|
+
return np.frombuffer(proc.stdout, np.uint8).reshape(-1, h, w, 4)
|
|
662
|
+
|
|
663
|
+
|
|
664
|
+
if __name__ == "__main__": # pragma: no cover
|
|
665
|
+
sys.exit("use helpers/motion_render.py")
|