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,2059 @@
|
|
|
1
|
+
"""Text rendering for editor-video: captions, hooks, CTA and disclaimer (Contract 7).
|
|
2
|
+
|
|
3
|
+
Everything is drawn with PIL on a transparent RGBA canvas of the output size and
|
|
4
|
+
composited later with ffmpeg `overlay` (the dev ffmpeg has no drawtext/subtitles/ass).
|
|
5
|
+
|
|
6
|
+
Public API (see SPEC.md, Contract 7):
|
|
7
|
+
render_hook, render_caption_track, render_cta, render_disclaimer,
|
|
8
|
+
render_sample_frame, write_ffconcat
|
|
9
|
+
Extras:
|
|
10
|
+
hook_fit (lines/size a hook gets, flags too-long text), render_caption_image (one steady
|
|
11
|
+
caption state), write_blank_png, caption_style_ids, hook_style_ids, caption_style_info,
|
|
12
|
+
display_words, measure_caption_width, caption_width_limit, is_weak_word, cover_image,
|
|
13
|
+
layout_defaults / canvas_shape (preferred text positions per canvas shape), caption_extent
|
|
14
|
+
(how far a caption track reaches above and below its center line), sanitize_text /
|
|
15
|
+
text_issues (symbols the bundled fonts cannot draw, *markup* problems)
|
|
16
|
+
|
|
17
|
+
Hook text may mark accent words with asterisks: "Você está *perdendo venda* assim". A word is
|
|
18
|
+
never split by the markup ("*Kiwi*fy" colors the whole word), `\\*` draws a real asterisk and an
|
|
19
|
+
unpaired `*` is ignored (text_issues reports it). The CTA and the disclaimer have no accent
|
|
20
|
+
markup: their asterisks are removed.
|
|
21
|
+
|
|
22
|
+
Characters a bundled font cannot draw (emoji, ★, ✓ ...) are never drawn as boxes: simple ones get
|
|
23
|
+
a look-alike (→ becomes ›), the rest are removed, and text_issues lists them.
|
|
24
|
+
|
|
25
|
+
CLI (quick checks, PT-BR messages):
|
|
26
|
+
text_render.py list
|
|
27
|
+
text_render.py hook "texto" --style ID [--accent HEX] [--bg frame.png] -o out.png
|
|
28
|
+
text_render.py caption "texto" --style ID [--active 1] [--bg frame.png] -o out.png
|
|
29
|
+
text_render.py cta "texto" [--sub "..."] [--bg frame.png] -o out.png
|
|
30
|
+
text_render.py disclaimer "texto" [--bg frame.png] -o out.png
|
|
31
|
+
text_render.py track captions.json --style ID -o DIR [--fps 30] [--duration S] [--width 1080 --height 1920]
|
|
32
|
+
"""
|
|
33
|
+
|
|
34
|
+
from __future__ import annotations
|
|
35
|
+
|
|
36
|
+
import argparse
|
|
37
|
+
import hashlib
|
|
38
|
+
import json
|
|
39
|
+
import math
|
|
40
|
+
import os
|
|
41
|
+
import re
|
|
42
|
+
import sys
|
|
43
|
+
import threading
|
|
44
|
+
import time
|
|
45
|
+
import unicodedata
|
|
46
|
+
from concurrent.futures import ThreadPoolExecutor
|
|
47
|
+
from dataclasses import dataclass
|
|
48
|
+
from pathlib import Path
|
|
49
|
+
|
|
50
|
+
from PIL import Image, ImageDraw, ImageFilter
|
|
51
|
+
|
|
52
|
+
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
|
53
|
+
from common import SAFE_ZONE_VERTICAL, hex_to_rgb, load_font, load_style_catalog # noqa: E402
|
|
54
|
+
|
|
55
|
+
REF_W, REF_H = 1080, 1920
|
|
56
|
+
SIDE_MARGIN = 90
|
|
57
|
+
DEFAULT_ACCENT = "#FFC400"
|
|
58
|
+
|
|
59
|
+
# Preferred text positions and the platform-safe band per canvas shape, as fractions of the
|
|
60
|
+
# canvas height. The vertical row is the validated 1080x1920 layout (SAFE_ZONE_VERTICAL). Type
|
|
61
|
+
# keeps its size on a horizontal or square canvas (it scales with the short side), so scaling the
|
|
62
|
+
# vertical y values down would squeeze full-size layers into each other: those shapes get their
|
|
63
|
+
# own rows.
|
|
64
|
+
LAYOUT_FRACTIONS: dict[str, dict[str, float]] = {
|
|
65
|
+
"vertical": {"top": SAFE_ZONE_VERTICAL["top"] / REF_H, "bottom": SAFE_ZONE_VERTICAL["bottom"] / REF_H,
|
|
66
|
+
"hook": 430 / REF_H, "caption": SAFE_ZONE_VERTICAL["caption_center_y"] / REF_H,
|
|
67
|
+
"cta": 1110 / REF_H, "cta_alone": 1180 / REF_H, "disclaimer": SAFE_ZONE_VERTICAL["disclaimer_y"] / REF_H},
|
|
68
|
+
"horizontal": {"top": 0.06, "bottom": 0.965, "hook": 0.2, "caption": 0.78, "cta": 0.45, "cta_alone": 0.5,
|
|
69
|
+
"disclaimer": 0.935},
|
|
70
|
+
"square": {"top": 0.07, "bottom": 0.955, "hook": 0.2, "caption": 0.76, "cta": 0.46, "cta_alone": 0.52,
|
|
71
|
+
"disclaimer": 0.93},
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def canvas_shape(canvas) -> str:
|
|
76
|
+
w, h = int(canvas[0]), int(canvas[1])
|
|
77
|
+
if h > w * 1.05:
|
|
78
|
+
return "vertical"
|
|
79
|
+
if w > h * 1.05:
|
|
80
|
+
return "horizontal"
|
|
81
|
+
return "square"
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def layout_defaults(canvas) -> dict[str, float]:
|
|
85
|
+
"""Preferred centers and the safe band in pixels of `canvas`: top, bottom, hook, caption, cta
|
|
86
|
+
(with captions), cta_alone (without captions), disclaimer."""
|
|
87
|
+
h = float(canvas[1])
|
|
88
|
+
return {k: v * h for k, v in LAYOUT_FRACTIONS[canvas_shape(canvas)].items()}
|
|
89
|
+
# Hash of this file: part of every cached PNG name, so a code change never reuses stale art.
|
|
90
|
+
_SRC_HASH = hashlib.sha1(Path(__file__).read_bytes()).hexdigest()[:10]
|
|
91
|
+
|
|
92
|
+
WHITE = (255, 255, 255, 255)
|
|
93
|
+
BLACK = (0, 0, 0, 255)
|
|
94
|
+
INK = (17, 17, 17, 255)
|
|
95
|
+
|
|
96
|
+
# Portuguese function words that read badly at the end of a line or cue.
|
|
97
|
+
WEAK_WORDS = {
|
|
98
|
+
"a", "o", "as", "os", "um", "uma", "uns", "umas", "de", "da", "do", "das", "dos", "em", "no",
|
|
99
|
+
"na", "nos", "nas", "e", "ou", "que", "pra", "para", "pro", "pros", "pras", "por", "pelo",
|
|
100
|
+
"pela", "pelos", "pelas", "com", "sem", "se", "mas", "meu", "minha", "meus", "minhas", "seu",
|
|
101
|
+
"sua", "seus", "suas", "te", "me", "lhe", "não", "nao", "é", "ao", "aos", "à", "às", "num",
|
|
102
|
+
"numa", "tipo", "cada", "esse", "essa", "este", "esta", "isso", "the",
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
# ============================================================== small utilities
|
|
107
|
+
|
|
108
|
+
_tls = threading.local()
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def _font(key: str, size: float):
|
|
112
|
+
"""Per-thread cached bundled font (FreeType faces are not shared across threads)."""
|
|
113
|
+
cache = getattr(_tls, "fonts", None)
|
|
114
|
+
if cache is None:
|
|
115
|
+
cache = _tls.fonts = {}
|
|
116
|
+
px = max(6, int(round(size)))
|
|
117
|
+
f = cache.get((key, px))
|
|
118
|
+
if f is None:
|
|
119
|
+
f = cache[(key, px)] = load_font(key, px)
|
|
120
|
+
return f
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def _cap_height(font) -> float:
|
|
124
|
+
cache = getattr(_tls, "caps", None)
|
|
125
|
+
if cache is None:
|
|
126
|
+
cache = _tls.caps = {}
|
|
127
|
+
key = (font.path, font.size, id(font))
|
|
128
|
+
v = cache.get(key)
|
|
129
|
+
if v is None:
|
|
130
|
+
_, top, _, _ = font.getbbox("H", anchor="ls")
|
|
131
|
+
v = cache[key] = float(-top)
|
|
132
|
+
return v
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def _rgba(color, alpha: int = 255) -> tuple[int, int, int, int]:
|
|
136
|
+
if isinstance(color, str):
|
|
137
|
+
r, g, b = hex_to_rgb(color)
|
|
138
|
+
return (r, g, b, alpha)
|
|
139
|
+
if len(color) == 3:
|
|
140
|
+
return (color[0], color[1], color[2], alpha)
|
|
141
|
+
return tuple(color) # type: ignore[return-value]
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def _luminance(rgb) -> float:
|
|
145
|
+
def lin(c):
|
|
146
|
+
c = c / 255.0
|
|
147
|
+
return c / 12.92 if c <= 0.03928 else ((c + 0.055) / 1.055) ** 2.4
|
|
148
|
+
|
|
149
|
+
r, g, b = rgb[:3]
|
|
150
|
+
return 0.2126 * lin(r) + 0.7152 * lin(g) + 0.0722 * lin(b)
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def _on_color(bg) -> tuple[int, int, int, int]:
|
|
154
|
+
"""Readable text color on top of a solid background color."""
|
|
155
|
+
lum = _luminance(bg)
|
|
156
|
+
contrast_black = (lum + 0.05) / 0.05
|
|
157
|
+
contrast_white = 1.05 / (lum + 0.05)
|
|
158
|
+
return INK if contrast_black >= contrast_white else WHITE
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def _new(w: float, h: float) -> Image.Image:
|
|
162
|
+
return Image.new("RGBA", (max(1, int(math.ceil(w))), max(1, int(math.ceil(h)))), (0, 0, 0, 0))
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def _mul_alpha(img: Image.Image, factor: float) -> Image.Image:
|
|
166
|
+
if factor >= 0.999:
|
|
167
|
+
return img
|
|
168
|
+
out = img.copy()
|
|
169
|
+
out.putalpha(img.getchannel("A").point(lambda v: int(v * factor)))
|
|
170
|
+
return out
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def _rotate(img: Image.Image, angle: float) -> tuple[Image.Image, float, float]:
|
|
174
|
+
"""Rotate around the center with premultiplied alpha (no dark fringes).
|
|
175
|
+
|
|
176
|
+
Returns (image, dx, dy): offset of the new center relative to the old one is zero, so an
|
|
177
|
+
anchor (ax, ay) maps to (ax' , ay') = rotate((ax-cx, ay-cy)) + new center.
|
|
178
|
+
"""
|
|
179
|
+
if abs(angle) < 0.01:
|
|
180
|
+
return img, 0.0, 0.0
|
|
181
|
+
pm = img.convert("RGBa")
|
|
182
|
+
rot = pm.rotate(angle, resample=Image.BICUBIC, expand=True).convert("RGBA")
|
|
183
|
+
return rot, (rot.width - img.width) / 2.0, (rot.height - img.height) / 2.0
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def _rotate_point(x: float, y: float, cx: float, cy: float, angle: float) -> tuple[float, float]:
|
|
187
|
+
a = math.radians(angle)
|
|
188
|
+
dx, dy = x - cx, y - cy
|
|
189
|
+
# PIL rotates counter-clockwise for positive angles (y axis pointing down).
|
|
190
|
+
return cx + dx * math.cos(a) + dy * math.sin(a), cy - dx * math.sin(a) + dy * math.cos(a)
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def _shape(w: int, h: int, draw_fn, color, ss: int = 4) -> Image.Image:
|
|
194
|
+
"""Anti-aliased filled shape: draw_fn(draw, scale) on a supersampled L mask."""
|
|
195
|
+
w, h = max(1, int(w)), max(1, int(h))
|
|
196
|
+
mask = Image.new("L", (w * ss, h * ss), 0)
|
|
197
|
+
draw_fn(ImageDraw.Draw(mask), ss)
|
|
198
|
+
mask = mask.resize((w, h), Image.LANCZOS)
|
|
199
|
+
rgba = _rgba(color)
|
|
200
|
+
solid = Image.new("RGBA", (w, h), rgba)
|
|
201
|
+
if rgba[3] < 255:
|
|
202
|
+
mask = mask.point(lambda v, a=rgba[3]: v * a // 255)
|
|
203
|
+
solid.putalpha(mask)
|
|
204
|
+
return solid
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def _rounded_box(layer: Image.Image, box, radius: float, color) -> None:
|
|
208
|
+
x0, y0, x1, y1 = box
|
|
209
|
+
ix0, iy0 = int(math.floor(x0)), int(math.floor(y0))
|
|
210
|
+
w, h = int(math.ceil(x1 - ix0)), int(math.ceil(y1 - iy0))
|
|
211
|
+
fx0, fy0 = x0 - ix0, y0 - iy0
|
|
212
|
+
r = max(0.0, min(radius, (x1 - x0) / 2, (y1 - y0) / 2))
|
|
213
|
+
|
|
214
|
+
def fn(d, s):
|
|
215
|
+
d.rounded_rectangle((fx0 * s, fy0 * s, (x1 - ix0) * s - 1, (y1 - iy0) * s - 1), radius=r * s, fill=255)
|
|
216
|
+
|
|
217
|
+
_alpha_paste(layer, _shape(w, h, fn, color), ix0, iy0)
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
def _polygon(layer: Image.Image, points, color) -> None:
|
|
221
|
+
xs = [p[0] for p in points]
|
|
222
|
+
ys = [p[1] for p in points]
|
|
223
|
+
ix0, iy0 = int(math.floor(min(xs))), int(math.floor(min(ys)))
|
|
224
|
+
w, h = int(math.ceil(max(xs) - ix0)) + 1, int(math.ceil(max(ys) - iy0)) + 1
|
|
225
|
+
|
|
226
|
+
def fn(d, s):
|
|
227
|
+
d.polygon([((x - ix0) * s, (y - iy0) * s) for x, y in points], fill=255)
|
|
228
|
+
|
|
229
|
+
_alpha_paste(layer, _shape(w, h, fn, color), ix0, iy0)
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
def _alpha_paste(dst: Image.Image, src: Image.Image, x: int, y: int) -> None:
|
|
233
|
+
"""alpha_composite that accepts negative / overflowing positions."""
|
|
234
|
+
x0, y0 = max(0, x), max(0, y)
|
|
235
|
+
x1, y1 = min(dst.width, x + src.width), min(dst.height, y + src.height)
|
|
236
|
+
if x1 <= x0 or y1 <= y0:
|
|
237
|
+
return
|
|
238
|
+
crop = src.crop((x0 - x, y0 - y, x1 - x, y1 - y))
|
|
239
|
+
dst.alpha_composite(crop, (x0, y0))
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
def _shadow_of(layer: Image.Image, blur: float, alpha: float, color=(0, 0, 0)) -> Image.Image:
|
|
243
|
+
"""Soft shadow image (same size) from the alpha of `layer`."""
|
|
244
|
+
a = layer.getchannel("A")
|
|
245
|
+
if blur > 0.5:
|
|
246
|
+
a = a.filter(ImageFilter.GaussianBlur(blur))
|
|
247
|
+
if alpha < 0.999:
|
|
248
|
+
a = a.point(lambda v: int(v * alpha))
|
|
249
|
+
sh = Image.new("RGBA", layer.size, _rgba(color))
|
|
250
|
+
sh.putalpha(a)
|
|
251
|
+
return sh
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
# ============================================================== geometry
|
|
255
|
+
|
|
256
|
+
@dataclass(frozen=True)
|
|
257
|
+
class _Geo:
|
|
258
|
+
w: int
|
|
259
|
+
h: int
|
|
260
|
+
|
|
261
|
+
@property
|
|
262
|
+
def k(self) -> float: # type scale (1.0 on 1080 wide vertical)
|
|
263
|
+
return min(self.w, self.h) / REF_W
|
|
264
|
+
|
|
265
|
+
@property
|
|
266
|
+
def sy(self) -> float:
|
|
267
|
+
return self.h / REF_H
|
|
268
|
+
|
|
269
|
+
@property
|
|
270
|
+
def sx(self) -> float:
|
|
271
|
+
return self.w / REF_W
|
|
272
|
+
|
|
273
|
+
@property
|
|
274
|
+
def cx(self) -> float:
|
|
275
|
+
return self.w / 2.0
|
|
276
|
+
|
|
277
|
+
@property
|
|
278
|
+
def caption_max_w(self) -> float:
|
|
279
|
+
rail = SAFE_ZONE_VERTICAL["right_rail"] * self.sx
|
|
280
|
+
return min(self.w - 2 * SIDE_MARGIN * self.sx, 2 * (self.w / 2.0 - rail))
|
|
281
|
+
|
|
282
|
+
@property
|
|
283
|
+
def safe_top(self) -> float:
|
|
284
|
+
return LAYOUT_FRACTIONS[canvas_shape((self.w, self.h))]["top"] * self.h
|
|
285
|
+
|
|
286
|
+
@property
|
|
287
|
+
def safe_bottom(self) -> float:
|
|
288
|
+
return LAYOUT_FRACTIONS[canvas_shape((self.w, self.h))]["bottom"] * self.h
|
|
289
|
+
|
|
290
|
+
|
|
291
|
+
# ============================================================== text helpers
|
|
292
|
+
|
|
293
|
+
_TRAIL_STRIP = ",.;:…"
|
|
294
|
+
_QUOTES = "\"“”«»'‘’()[]{}"
|
|
295
|
+
|
|
296
|
+
|
|
297
|
+
def _clean_word(text: str, keep_punct: bool) -> str:
|
|
298
|
+
t = str(text).strip()
|
|
299
|
+
if keep_punct:
|
|
300
|
+
return t
|
|
301
|
+
t = t.strip(_QUOTES)
|
|
302
|
+
t = t.rstrip(_TRAIL_STRIP).lstrip("¿¡")
|
|
303
|
+
t = t.strip(_QUOTES)
|
|
304
|
+
if t in ("-", "–", "—"):
|
|
305
|
+
return ""
|
|
306
|
+
return t
|
|
307
|
+
|
|
308
|
+
|
|
309
|
+
def _case(text: str, upper: bool) -> str:
|
|
310
|
+
return text.upper() if upper else text
|
|
311
|
+
|
|
312
|
+
|
|
313
|
+
# ---------------------------------------------------------------- glyph coverage
|
|
314
|
+
|
|
315
|
+
_GLYPHS: dict[tuple[str, str], bool] = {}
|
|
316
|
+
_NOTDEF = "" # private use code point: no bundled font has it, so it shows the .notdef glyph
|
|
317
|
+
# look-alikes for symbols some fonts lack (first one the font has wins; nothing fits -> removed)
|
|
318
|
+
_SYMBOL_FALLBACKS = {
|
|
319
|
+
"→": ("›", ">"), "←": ("‹", "<"), "➜": ("›", ">"), "№": ("Nº", "No"), "…": ("...",),
|
|
320
|
+
"“": ('"',), "”": ('"',), "„": ('"',), "‘": ("'",), "’": ("'",), "—": ("-",), "–": ("-",),
|
|
321
|
+
"•": ("·", "-"), "×": ("x",),
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
|
|
325
|
+
def _has_glyph(font_key: str, ch: str) -> bool:
|
|
326
|
+
"""True when the bundled font draws `ch` (its mask and advance differ from the .notdef glyph)."""
|
|
327
|
+
key = (font_key, ch)
|
|
328
|
+
v = _GLYPHS.get(key)
|
|
329
|
+
if v is None:
|
|
330
|
+
f = _font(font_key, 48)
|
|
331
|
+
nd, m = f.getmask(_NOTDEF), f.getmask(ch)
|
|
332
|
+
v = not (m.size == nd.size and bytes(m) == bytes(nd) and abs(f.getlength(ch) - f.getlength(_NOTDEF)) < 0.01)
|
|
333
|
+
_GLYPHS[key] = v
|
|
334
|
+
return v
|
|
335
|
+
|
|
336
|
+
|
|
337
|
+
def sanitize_text(text: str, font_key: str) -> tuple[str, list[str]]:
|
|
338
|
+
"""Text the font can draw: look-alikes for simple symbols, unknown ones removed.
|
|
339
|
+
|
|
340
|
+
Returns (clean text, removed characters in order, no repeats). Line breaks are kept, other
|
|
341
|
+
spaces become plain spaces and invisible format characters (emoji joiners, variation
|
|
342
|
+
selectors) are dropped silently.
|
|
343
|
+
"""
|
|
344
|
+
out: list[str] = []
|
|
345
|
+
removed: list[str] = []
|
|
346
|
+
for ch in unicodedata.normalize("NFC", str(text)):
|
|
347
|
+
if ch == "\n":
|
|
348
|
+
out.append(ch)
|
|
349
|
+
continue
|
|
350
|
+
if ch.isspace():
|
|
351
|
+
out.append(" ")
|
|
352
|
+
continue
|
|
353
|
+
cp = ord(ch)
|
|
354
|
+
if (unicodedata.category(ch) == "Cf" or 0xFE00 <= cp <= 0xFE0F or 0x1F3FB <= cp <= 0x1F3FF
|
|
355
|
+
or 0xE0020 <= cp <= 0xE007F):
|
|
356
|
+
continue # invisible parts of an emoji (joiner, variation selector, skin tone, tag)
|
|
357
|
+
if _has_glyph(font_key, ch):
|
|
358
|
+
out.append(ch)
|
|
359
|
+
continue
|
|
360
|
+
alt = next((a for a in _SYMBOL_FALLBACKS.get(ch, ()) if all(_has_glyph(font_key, c) for c in a)), None)
|
|
361
|
+
if alt is not None:
|
|
362
|
+
out.append(alt)
|
|
363
|
+
elif ch not in removed:
|
|
364
|
+
removed.append(ch)
|
|
365
|
+
clean = "\n".join(re.sub(r" {2,}", " ", ln).strip() for ln in "".join(out).split("\n"))
|
|
366
|
+
return clean.strip("\n"), removed
|
|
367
|
+
|
|
368
|
+
|
|
369
|
+
# ---------------------------------------------------------------- *accent* markup
|
|
370
|
+
|
|
371
|
+
def _markup_chars(text: str) -> tuple[list[tuple[str, bool]], bool, list[str]]:
|
|
372
|
+
"""Characters with their accent flag. `\\*` is a literal asterisk; an unpaired `*` is ignored.
|
|
373
|
+
Returns (chars, has_accent_markup, issues)."""
|
|
374
|
+
s = str(text)
|
|
375
|
+
marks = [i for i, c in enumerate(s) if c == "*" and not (i and s[i - 1] == "\\")]
|
|
376
|
+
issues = []
|
|
377
|
+
if len(marks) % 2:
|
|
378
|
+
issues.append("tem um * sem par, então o destaque foi ignorado. Use *palavra* para destacar ou \\* para um asterisco")
|
|
379
|
+
marks = marks[:-1]
|
|
380
|
+
mark_set = set(marks)
|
|
381
|
+
chars: list[tuple[str, bool]] = []
|
|
382
|
+
accent = False
|
|
383
|
+
i = 0
|
|
384
|
+
while i < len(s):
|
|
385
|
+
c = s[i]
|
|
386
|
+
if c == "\\" and i + 1 < len(s) and s[i + 1] == "*":
|
|
387
|
+
chars.append(("*", accent))
|
|
388
|
+
i += 2
|
|
389
|
+
continue
|
|
390
|
+
if i in mark_set:
|
|
391
|
+
accent = not accent
|
|
392
|
+
elif c != "*":
|
|
393
|
+
chars.append((c, accent))
|
|
394
|
+
i += 1
|
|
395
|
+
return chars, bool(marks), issues
|
|
396
|
+
|
|
397
|
+
|
|
398
|
+
def strip_markup(text: str) -> tuple[str, bool]:
|
|
399
|
+
"""Text without accent markers (for the CTA and the disclaimer). -> (text, had markers)."""
|
|
400
|
+
s = str(text)
|
|
401
|
+
had = bool(re.search(r"(?<!\\)\*", s))
|
|
402
|
+
return re.sub(r"(?<!\\)\*", "", s).replace("\\*", "*"), had
|
|
403
|
+
|
|
404
|
+
|
|
405
|
+
def is_weak_word(word: str) -> bool:
|
|
406
|
+
"""True for Portuguese function words ("de", "que", "não"...) that should not end a line."""
|
|
407
|
+
return re.sub(r"[^\wÀ-ÿ]", "", word.lower()) in WEAK_WORDS
|
|
408
|
+
|
|
409
|
+
|
|
410
|
+
_is_weak = is_weak_word
|
|
411
|
+
|
|
412
|
+
|
|
413
|
+
def _lerp_keys(keys: list[tuple[float, dict]], p: float) -> dict:
|
|
414
|
+
"""Linear interpolation of numeric params between keyframes [(p, {..}), ...]."""
|
|
415
|
+
keys = sorted(keys, key=lambda kv: kv[0])
|
|
416
|
+
if p <= keys[0][0]:
|
|
417
|
+
return dict(keys[0][1])
|
|
418
|
+
for (p0, v0), (p1, v1) in zip(keys, keys[1:]):
|
|
419
|
+
if p0 <= p <= p1:
|
|
420
|
+
t = 0.0 if p1 == p0 else (p - p0) / (p1 - p0)
|
|
421
|
+
return {k: v0[k] + (v1.get(k, v0[k]) - v0[k]) * t for k in v0}
|
|
422
|
+
return dict(keys[-1][1])
|
|
423
|
+
|
|
424
|
+
|
|
425
|
+
def _anim_frames(keys: list[tuple[float, dict]], dur: float, fps: float) -> list[dict]:
|
|
426
|
+
"""Per-frame params for an entrance lasting `dur` seconds (last frame = steady state)."""
|
|
427
|
+
if not keys or dur <= 0:
|
|
428
|
+
return []
|
|
429
|
+
n = max(0, int(round(dur * fps)) - 1)
|
|
430
|
+
frames = []
|
|
431
|
+
for j in range(n):
|
|
432
|
+
vals = _lerp_keys(keys, (j + 1) / (n + 1))
|
|
433
|
+
frames.append({k: round(v, 3) for k, v in vals.items()})
|
|
434
|
+
return frames
|
|
435
|
+
|
|
436
|
+
|
|
437
|
+
# ============================================================== caption styles
|
|
438
|
+
|
|
439
|
+
@dataclass(frozen=True)
|
|
440
|
+
class CaptionStyle:
|
|
441
|
+
id: str
|
|
442
|
+
font: str
|
|
443
|
+
size: float # px on the 1080-wide reference canvas
|
|
444
|
+
upper: bool
|
|
445
|
+
max_words: int
|
|
446
|
+
lines: int
|
|
447
|
+
animated: bool
|
|
448
|
+
one_word: bool = False
|
|
449
|
+
keep_punct: bool = False
|
|
450
|
+
hpad: float = 0.0 # extra horizontal room per side, ratio of size
|
|
451
|
+
gap: float = 0.3 # extra word gap, ratio of size (added to a space)
|
|
452
|
+
active_scale: float = 1.0 # the spoken word is drawn this much bigger (grows into the gaps)
|
|
453
|
+
fit_margin: float = 0.0 # extra width counted when fitting (rotation, glow), ratio of size
|
|
454
|
+
enter: tuple = () # ((p, {param: value}), ...) keyframes
|
|
455
|
+
enter_dur: float = 0.0
|
|
456
|
+
change: tuple = () # keyframes when the active word changes
|
|
457
|
+
change_dur: float = 0.0
|
|
458
|
+
draw: str = ""
|
|
459
|
+
|
|
460
|
+
|
|
461
|
+
CAPTION_STYLES: dict[str, CaptionStyle] = {}
|
|
462
|
+
|
|
463
|
+
|
|
464
|
+
def _reg(style: CaptionStyle) -> None:
|
|
465
|
+
CAPTION_STYLES[style.id] = style
|
|
466
|
+
|
|
467
|
+
|
|
468
|
+
_reg(CaptionStyle(
|
|
469
|
+
id="impacto", font="display_condensed", size=134, upper=True, max_words=3, lines=1, animated=True,
|
|
470
|
+
hpad=0.09, gap=0.14,
|
|
471
|
+
enter=((0.0, {"scale": 0.70}), (0.34, {"scale": 0.86}), (0.67, {"scale": 1.07}), (1.0, {"scale": 1.0})),
|
|
472
|
+
enter_dur=0.10, draw="impacto"))
|
|
473
|
+
|
|
474
|
+
_reg(CaptionStyle(
|
|
475
|
+
id="caixa_viva", font="sans_black", size=86, upper=True, max_words=3, lines=1, animated=True,
|
|
476
|
+
hpad=0.26, gap=0.36,
|
|
477
|
+
enter=((0.0, {"dy": 34.0, "alpha": 0.0}), (0.34, {"dy": 20.0, "alpha": 0.45}),
|
|
478
|
+
(0.67, {"dy": 6.0, "alpha": 0.9}), (1.0, {"dy": 0.0, "alpha": 1.0})),
|
|
479
|
+
enter_dur=0.10,
|
|
480
|
+
change=((0.0, {"slide": 0.0}), (0.5, {"slide": 0.62}), (1.0, {"slide": 1.0})),
|
|
481
|
+
change_dur=0.10, draw="caixa_viva"))
|
|
482
|
+
|
|
483
|
+
_reg(CaptionStyle(
|
|
484
|
+
id="palavra_gigante", font="display_condensed", size=196, upper=True, max_words=1, lines=1,
|
|
485
|
+
animated=True, one_word=True, hpad=0.12, gap=0.2,
|
|
486
|
+
enter=((0.0, {"scale": 1.45}), (0.34, {"scale": 1.26}), (0.67, {"scale": 1.07}), (1.0, {"scale": 1.0})),
|
|
487
|
+
enter_dur=0.10, draw="palavra_gigante"))
|
|
488
|
+
|
|
489
|
+
_reg(CaptionStyle(
|
|
490
|
+
id="pulso", font="sans_black", size=78, upper=True, max_words=3, lines=1, animated=True,
|
|
491
|
+
hpad=0.2, gap=0.42, active_scale=1.12,
|
|
492
|
+
enter=((0.0, {"dy": 40.0, "alpha": 0.0}), (0.34, {"dy": 22.0, "alpha": 0.5}),
|
|
493
|
+
(0.67, {"dy": 7.0, "alpha": 0.9}), (1.0, {"dy": 0.0, "alpha": 1.0})),
|
|
494
|
+
enter_dur=0.10, draw="pulso"))
|
|
495
|
+
|
|
496
|
+
_reg(CaptionStyle(
|
|
497
|
+
id="fita", font="sans_black", size=78, upper=True, max_words=3, lines=1, animated=True,
|
|
498
|
+
hpad=0.42, gap=0.3, fit_margin=0.08,
|
|
499
|
+
enter=((0.0, {"wipe": 0.15}), (0.34, {"wipe": 0.45}), (0.67, {"wipe": 0.82}), (1.0, {"wipe": 1.0})),
|
|
500
|
+
enter_dur=0.10, draw="fita"))
|
|
501
|
+
|
|
502
|
+
_reg(CaptionStyle(
|
|
503
|
+
id="legenda_limpa", font="text_bold", size=60, upper=False, max_words=8, lines=2, animated=False,
|
|
504
|
+
keep_punct=True, hpad=0.45, gap=0.0, draw="legenda_limpa"))
|
|
505
|
+
|
|
506
|
+
# Papelaria: a frase vai se escrevendo palavra por palavra, com contorno escuro grosso; a palavra
|
|
507
|
+
# falada acende na cor de destaque e cresce um pouco. `papelaria_papel` é a mesma coisa para fundo
|
|
508
|
+
# claro (letra escura com halo branco), e `depoimento` marca a fala de outra pessoa.
|
|
509
|
+
_reg(CaptionStyle(
|
|
510
|
+
id="papelaria", font="sans_black", size=88, upper=False, max_words=3, lines=1, animated=True,
|
|
511
|
+
hpad=0.18, gap=0.34, active_scale=1.08,
|
|
512
|
+
enter=((0.0, {"dy": 26.0, "alpha": 0.0}), (0.4, {"dy": 10.0, "alpha": 0.7}),
|
|
513
|
+
(1.0, {"dy": 0.0, "alpha": 1.0})),
|
|
514
|
+
enter_dur=0.09,
|
|
515
|
+
change=((0.0, {"pop": 0.0}), (0.45, {"pop": 1.0}), (1.0, {"pop": 0.0})),
|
|
516
|
+
change_dur=0.16, draw="papelaria"))
|
|
517
|
+
|
|
518
|
+
_reg(CaptionStyle(
|
|
519
|
+
id="papelaria_papel", font="sans_black", size=88, upper=False, max_words=3, lines=1, animated=True,
|
|
520
|
+
hpad=0.18, gap=0.34, active_scale=1.08,
|
|
521
|
+
enter=((0.0, {"dy": 26.0, "alpha": 0.0}), (0.4, {"dy": 10.0, "alpha": 0.7}),
|
|
522
|
+
(1.0, {"dy": 0.0, "alpha": 1.0})),
|
|
523
|
+
enter_dur=0.09,
|
|
524
|
+
change=((0.0, {"pop": 0.0}), (0.45, {"pop": 1.0}), (1.0, {"pop": 0.0})),
|
|
525
|
+
change_dur=0.16, draw="papelaria_papel"))
|
|
526
|
+
|
|
527
|
+
_reg(CaptionStyle(
|
|
528
|
+
id="depoimento", font="sans_black", size=82, upper=False, max_words=3, lines=1, animated=True,
|
|
529
|
+
hpad=0.18, gap=0.34, active_scale=1.06,
|
|
530
|
+
enter=((0.0, {"dy": 22.0, "alpha": 0.0}), (0.5, {"dy": 8.0, "alpha": 0.8}),
|
|
531
|
+
(1.0, {"dy": 0.0, "alpha": 1.0})),
|
|
532
|
+
enter_dur=0.09, draw="depoimento"))
|
|
533
|
+
|
|
534
|
+
|
|
535
|
+
# ---------------------------------------------------------------- measuring
|
|
536
|
+
|
|
537
|
+
def caption_style_ids() -> list[str]:
|
|
538
|
+
return list(CAPTION_STYLES)
|
|
539
|
+
|
|
540
|
+
|
|
541
|
+
def _caption_spec(style_id: str) -> CaptionStyle:
|
|
542
|
+
spec = CAPTION_STYLES.get(style_id)
|
|
543
|
+
if spec is None:
|
|
544
|
+
raise ValueError(f"estilo de legenda '{style_id}' não existe. Opções: {', '.join(CAPTION_STYLES)}")
|
|
545
|
+
return spec
|
|
546
|
+
|
|
547
|
+
|
|
548
|
+
def caption_style_info(style_id: str) -> dict:
|
|
549
|
+
s = _caption_spec(style_id)
|
|
550
|
+
return {"id": s.id, "max_words": s.max_words, "lines": s.lines, "animated": s.animated,
|
|
551
|
+
"upper": s.upper, "one_word": s.one_word, "keep_punct": s.keep_punct}
|
|
552
|
+
|
|
553
|
+
|
|
554
|
+
def display_words(words: list[str], style_id: str) -> list[str]:
|
|
555
|
+
"""Words as a style shows them (casing + punctuation cleanup). Empty words are dropped."""
|
|
556
|
+
s = _caption_spec(style_id)
|
|
557
|
+
out = []
|
|
558
|
+
for w in words:
|
|
559
|
+
t = _display_word(w, s)
|
|
560
|
+
if t:
|
|
561
|
+
out.append(t)
|
|
562
|
+
return _trim_cue_end(out, s)
|
|
563
|
+
|
|
564
|
+
|
|
565
|
+
def _display_word(word: str, spec: CaptionStyle) -> str:
|
|
566
|
+
t = _case(_clean_word(word, spec.keep_punct), spec.upper)
|
|
567
|
+
return " ".join(sanitize_text(t, spec.font)[0].split()) if t else t
|
|
568
|
+
|
|
569
|
+
|
|
570
|
+
def _trim_cue_end(words: list[str], spec: CaptionStyle) -> list[str]:
|
|
571
|
+
"""Subtitle-like styles keep punctuation, but a cue should not end on a dangling comma."""
|
|
572
|
+
if spec.keep_punct and words:
|
|
573
|
+
last = words[-1].rstrip(",;:")
|
|
574
|
+
words = words[:-1] + [last] if last else words[:-1]
|
|
575
|
+
return words
|
|
576
|
+
|
|
577
|
+
|
|
578
|
+
def _line_width(font, words: list[str], size: float, spec: CaptionStyle) -> float:
|
|
579
|
+
if not words:
|
|
580
|
+
return 0.0
|
|
581
|
+
space = font.getlength(" ")
|
|
582
|
+
widths = [font.getlength(w) for w in words]
|
|
583
|
+
grow = (spec.active_scale - 1.0) * max(widths)
|
|
584
|
+
return (sum(widths) + (len(words) - 1) * (space + spec.gap * size) + 2 * spec.hpad * size
|
|
585
|
+
+ grow + 2 * spec.fit_margin * size)
|
|
586
|
+
|
|
587
|
+
|
|
588
|
+
def caption_width_limit(style_id: str, canvas=(REF_W, REF_H)) -> float:
|
|
589
|
+
return _Geo(*canvas).caption_max_w
|
|
590
|
+
|
|
591
|
+
|
|
592
|
+
def measure_caption_width(words: list[str], style_id: str, canvas=(REF_W, REF_H),
|
|
593
|
+
font_scale: float = 1.0, already_display: bool = False) -> float:
|
|
594
|
+
"""Single-line width in px of `words` at the style's base size (before any shrink-to-fit)."""
|
|
595
|
+
spec = _caption_spec(style_id)
|
|
596
|
+
geo = _Geo(*canvas)
|
|
597
|
+
disp = words if already_display else display_words(words, style_id)
|
|
598
|
+
size = spec.size * geo.k * font_scale
|
|
599
|
+
return _line_width(_font(spec.font, size), disp, size, spec)
|
|
600
|
+
|
|
601
|
+
|
|
602
|
+
def _fit_size(spec: CaptionStyle, lines: list[list[str]], base: float, max_w: float) -> float:
|
|
603
|
+
size = base
|
|
604
|
+
for _ in range(3):
|
|
605
|
+
f = _font(spec.font, size)
|
|
606
|
+
widest = max(_line_width(f, ln, size, spec) for ln in lines)
|
|
607
|
+
if widest <= max_w:
|
|
608
|
+
break
|
|
609
|
+
size = max(8.0, math.floor(size * max_w / widest * 0.995))
|
|
610
|
+
return size
|
|
611
|
+
|
|
612
|
+
|
|
613
|
+
def _balanced_split(words: list[str], width_fn) -> list[list[str]]:
|
|
614
|
+
if len(words) < 2:
|
|
615
|
+
return [words]
|
|
616
|
+
best, best_cost = None, None
|
|
617
|
+
for s in range(1, len(words)):
|
|
618
|
+
a, b = words[:s], words[s:]
|
|
619
|
+
wa, wb = width_fn(a), width_fn(b)
|
|
620
|
+
cost = max(wa, wb)
|
|
621
|
+
if _is_weak(a[-1]):
|
|
622
|
+
cost *= 1.12
|
|
623
|
+
if wa > wb:
|
|
624
|
+
cost *= 1.01
|
|
625
|
+
if best_cost is None or cost < best_cost:
|
|
626
|
+
best, best_cost = [a, b], cost
|
|
627
|
+
return best # type: ignore[return-value]
|
|
628
|
+
|
|
629
|
+
|
|
630
|
+
# ---------------------------------------------------------------- block drawing
|
|
631
|
+
|
|
632
|
+
@dataclass
|
|
633
|
+
class _Block:
|
|
634
|
+
layer: Image.Image
|
|
635
|
+
ax: float # anchor inside the layer that goes to (cx, cy)
|
|
636
|
+
ay: float
|
|
637
|
+
half_h: float # nominal half height (for safe-zone clamping)
|
|
638
|
+
half_w: float = 0.0 # nominal half width of the ink (for scale clamping)
|
|
639
|
+
|
|
640
|
+
|
|
641
|
+
def _text_layer_1line(font, words, xs, size, cap_h, pad, colors, stroke, stroke_fill, total_w):
|
|
642
|
+
above = 0.36 * size
|
|
643
|
+
below = 0.34 * size
|
|
644
|
+
w = total_w + 2 * pad
|
|
645
|
+
h = cap_h + above + below + 2 * pad
|
|
646
|
+
layer = _new(w, h)
|
|
647
|
+
base_y = pad + above + cap_h
|
|
648
|
+
d = ImageDraw.Draw(layer)
|
|
649
|
+
for x, word, col in zip(xs, words, colors):
|
|
650
|
+
d.text((pad + x, base_y), word, font=font, fill=col, anchor="ls",
|
|
651
|
+
stroke_width=stroke, stroke_fill=stroke_fill)
|
|
652
|
+
return layer, base_y
|
|
653
|
+
|
|
654
|
+
|
|
655
|
+
def _word_xs(font, words, size, spec):
|
|
656
|
+
space = font.getlength(" ") + spec.gap * size
|
|
657
|
+
xs, x = [], spec.hpad * size
|
|
658
|
+
widths = []
|
|
659
|
+
for w in words:
|
|
660
|
+
xs.append(x)
|
|
661
|
+
wl = font.getlength(w)
|
|
662
|
+
widths.append(wl)
|
|
663
|
+
x += wl + space
|
|
664
|
+
total = x - space + spec.hpad * size
|
|
665
|
+
return xs, widths, total
|
|
666
|
+
|
|
667
|
+
|
|
668
|
+
def _draw_impacto(spec, geo, words, active, accent, size, variant, anim) -> _Block:
|
|
669
|
+
f = _font(spec.font, size)
|
|
670
|
+
cap_h = _cap_height(f)
|
|
671
|
+
stroke = max(2, int(round(size * 0.085)))
|
|
672
|
+
xs, widths, total = _word_xs(f, words, size, spec)
|
|
673
|
+
pad = stroke + int(size * 0.16)
|
|
674
|
+
colors = [_rgba(accent) if i == active else WHITE for i in range(len(words))]
|
|
675
|
+
layer, base_y = _text_layer_1line(f, words, xs, size, cap_h, pad, colors, stroke, BLACK, total)
|
|
676
|
+
sh = _shadow_of(layer, blur=size * 0.07, alpha=0.55)
|
|
677
|
+
out = _new(layer.width, layer.height)
|
|
678
|
+
_alpha_paste(out, sh, 0, int(size * 0.05))
|
|
679
|
+
out.alpha_composite(layer)
|
|
680
|
+
return _Block(out, pad + total / 2.0, base_y - cap_h / 2.0, cap_h / 2.0 + stroke + size * 0.12, total / 2.0 + stroke)
|
|
681
|
+
|
|
682
|
+
|
|
683
|
+
def _draw_caixa_viva(spec, geo, words, active, accent, size, variant, anim) -> _Block:
|
|
684
|
+
f = _font(spec.font, size)
|
|
685
|
+
cap_h = _cap_height(f)
|
|
686
|
+
stroke = max(2, int(round(size * 0.075)))
|
|
687
|
+
xs, widths, total = _word_xs(f, words, size, spec)
|
|
688
|
+
pad = stroke + int(size * 0.2)
|
|
689
|
+
above, below = 0.36 * size, 0.34 * size
|
|
690
|
+
layer = _new(total + 2 * pad, cap_h + above + below + 2 * pad)
|
|
691
|
+
base_y = pad + above + cap_h
|
|
692
|
+
acc = _rgba(accent)
|
|
693
|
+
padx, pad_top, pad_bot = 0.2 * size, 0.3 * size, 0.24 * size
|
|
694
|
+
|
|
695
|
+
def box_for(i):
|
|
696
|
+
return (pad + xs[i] - padx, base_y - cap_h - pad_top, pad + xs[i] + widths[i] + padx, base_y + pad_bot)
|
|
697
|
+
|
|
698
|
+
slide = anim.get("slide", 1.0)
|
|
699
|
+
prev = variant.get("prev", -1)
|
|
700
|
+
settled = slide >= 0.999 or prev < 0
|
|
701
|
+
# text ink (white + stroke) for non-active words, shadow from it
|
|
702
|
+
ink = _new(layer.width, layer.height)
|
|
703
|
+
d = ImageDraw.Draw(ink)
|
|
704
|
+
for i, word in enumerate(words):
|
|
705
|
+
if i == active and settled:
|
|
706
|
+
continue
|
|
707
|
+
d.text((pad + xs[i], base_y), word, font=f, fill=WHITE, anchor="ls", stroke_width=stroke, stroke_fill=BLACK)
|
|
708
|
+
box_layer = _new(layer.width, layer.height)
|
|
709
|
+
if 0 <= active < len(words):
|
|
710
|
+
b1 = box_for(active)
|
|
711
|
+
if not settled and 0 <= prev < len(words):
|
|
712
|
+
b0 = box_for(prev)
|
|
713
|
+
t = slide
|
|
714
|
+
b1 = tuple(b0[j] + (b1[j] - b0[j]) * t for j in range(4))
|
|
715
|
+
_rounded_box(box_layer, b1, radius=size * 0.16, color=acc)
|
|
716
|
+
shadow_src = _new(layer.width, layer.height)
|
|
717
|
+
shadow_src.alpha_composite(box_layer)
|
|
718
|
+
shadow_src.alpha_composite(ink)
|
|
719
|
+
sh = _shadow_of(shadow_src, blur=size * 0.09, alpha=0.5)
|
|
720
|
+
_alpha_paste(layer, sh, 0, int(size * 0.06))
|
|
721
|
+
layer.alpha_composite(box_layer)
|
|
722
|
+
layer.alpha_composite(ink)
|
|
723
|
+
if 0 <= active < len(words) and settled:
|
|
724
|
+
ImageDraw.Draw(layer).text((pad + xs[active], base_y), words[active], font=f, fill=_on_color(acc), anchor="ls")
|
|
725
|
+
return _Block(layer, pad + total / 2.0, base_y - cap_h / 2.0, cap_h / 2.0 + pad_top + size * 0.05)
|
|
726
|
+
|
|
727
|
+
|
|
728
|
+
def _draw_palavra_gigante(spec, geo, words, active, accent, size, variant, anim) -> _Block:
|
|
729
|
+
f = _font(spec.font, size)
|
|
730
|
+
cap_h = _cap_height(f)
|
|
731
|
+
stroke = max(2, int(round(size * 0.07)))
|
|
732
|
+
xs, widths, total = _word_xs(f, words, size, spec)
|
|
733
|
+
off = max(3, int(round(size * 0.055)))
|
|
734
|
+
pad = stroke + off + int(size * 0.14)
|
|
735
|
+
above, below = 0.36 * size, 0.34 * size
|
|
736
|
+
layer = _new(total + 2 * pad, cap_h + above + below + 2 * pad)
|
|
737
|
+
base_y = pad + above + cap_h
|
|
738
|
+
acc = _rgba(accent)
|
|
739
|
+
d = ImageDraw.Draw(layer)
|
|
740
|
+
for x, word in zip(xs, words):
|
|
741
|
+
# hard offset "sticker" shadow in the accent color, then the white word on top
|
|
742
|
+
for step in range(off, 0, -max(1, off // 4)):
|
|
743
|
+
d.text((pad + x + step, base_y + step), word, font=f, fill=acc, anchor="ls",
|
|
744
|
+
stroke_width=stroke, stroke_fill=BLACK if step == off else acc)
|
|
745
|
+
d.text((pad + x, base_y), word, font=f, fill=WHITE, anchor="ls", stroke_width=stroke, stroke_fill=BLACK)
|
|
746
|
+
sh = _shadow_of(layer, blur=size * 0.08, alpha=0.45)
|
|
747
|
+
out = _new(layer.width, layer.height)
|
|
748
|
+
_alpha_paste(out, sh, 0, int(size * 0.04))
|
|
749
|
+
out.alpha_composite(layer)
|
|
750
|
+
return _Block(out, pad + total / 2.0, base_y - cap_h / 2.0, cap_h / 2.0 + stroke + off + size * 0.1,
|
|
751
|
+
total / 2.0 + stroke + off)
|
|
752
|
+
|
|
753
|
+
|
|
754
|
+
def _draw_pulso(spec, geo, words, active, accent, size, variant, anim) -> _Block:
|
|
755
|
+
f = _font(spec.font, size)
|
|
756
|
+
big = _font(spec.font, size * spec.active_scale)
|
|
757
|
+
cap_h, cap_big = _cap_height(f), _cap_height(big)
|
|
758
|
+
stroke = max(2, int(round(size * 0.075)))
|
|
759
|
+
stroke_big = max(2, int(round(size * spec.active_scale * 0.075)))
|
|
760
|
+
xs, widths, total = _word_xs(f, words, size, spec)
|
|
761
|
+
pad = stroke_big + int(size * 0.3)
|
|
762
|
+
above, below = 0.36 * size * spec.active_scale, 0.34 * size * spec.active_scale
|
|
763
|
+
layer = _new(total + 2 * pad, cap_big + above + below + 2 * pad)
|
|
764
|
+
center_y = pad + above + cap_big / 2.0
|
|
765
|
+
acc = _rgba(accent)
|
|
766
|
+
d = ImageDraw.Draw(layer)
|
|
767
|
+
for i, (word, x, wl) in enumerate(zip(words, xs, widths)):
|
|
768
|
+
if i == active:
|
|
769
|
+
continue
|
|
770
|
+
d.text((pad + x, center_y + cap_h / 2.0), word, font=f, fill=WHITE, anchor="ls",
|
|
771
|
+
stroke_width=stroke, stroke_fill=BLACK)
|
|
772
|
+
if 0 <= active < len(words):
|
|
773
|
+
# the spoken word grows around its own center and may eat into the word gaps
|
|
774
|
+
wl_big = big.getlength(words[active])
|
|
775
|
+
cx_word = pad + xs[active] + widths[active] / 2.0
|
|
776
|
+
d.text((cx_word - wl_big / 2.0, center_y + cap_big / 2.0), words[active], font=big, fill=acc, anchor="ls",
|
|
777
|
+
stroke_width=stroke_big, stroke_fill=BLACK)
|
|
778
|
+
out = _new(layer.width, layer.height)
|
|
779
|
+
_alpha_paste(out, _shadow_of(layer, blur=size * 0.1, alpha=0.8), 0, int(size * 0.07))
|
|
780
|
+
out.alpha_composite(layer)
|
|
781
|
+
return _Block(out, pad + total / 2.0, center_y, cap_big / 2.0 + stroke_big + size * 0.14)
|
|
782
|
+
|
|
783
|
+
|
|
784
|
+
def _papelaria_block(spec, geo, words, active, accent, size, variant, anim, *,
|
|
785
|
+
base_color, halo, futuro_alpha: float, ativa_color=None) -> _Block:
|
|
786
|
+
"""Frase que se escreve palavra por palavra: o que ainda não foi falado fica de fora."""
|
|
787
|
+
f = _font(spec.font, size)
|
|
788
|
+
pop = float(anim.get("pop", 0.0) or 0.0)
|
|
789
|
+
escala = spec.active_scale + 0.05 * pop
|
|
790
|
+
big = _font(spec.font, size * escala)
|
|
791
|
+
cap_h, cap_big = _cap_height(f), _cap_height(big)
|
|
792
|
+
stroke = max(2, int(round(size * 0.085)))
|
|
793
|
+
stroke_big = max(2, int(round(size * escala * 0.085)))
|
|
794
|
+
xs, widths, total = _word_xs(f, words, size, spec)
|
|
795
|
+
pad = stroke_big + int(size * 0.3)
|
|
796
|
+
above, below = 0.36 * size * escala, 0.34 * size * escala
|
|
797
|
+
layer = _new(total + 2 * pad, cap_big + above + below + 2 * pad)
|
|
798
|
+
center_y = pad + above + cap_big / 2.0
|
|
799
|
+
acc = _rgba(ativa_color or accent)
|
|
800
|
+
d = ImageDraw.Draw(layer)
|
|
801
|
+
for i, (word, x, wl) in enumerate(zip(words, xs, widths)):
|
|
802
|
+
if i == active:
|
|
803
|
+
continue
|
|
804
|
+
if i > active >= 0 and futuro_alpha <= 0.0:
|
|
805
|
+
continue # ainda não foi falada: não aparece
|
|
806
|
+
cor = base_color if i < active or active < 0 else _fade(base_color, futuro_alpha)
|
|
807
|
+
d.text((pad + x, center_y + cap_h / 2.0), word, font=f, fill=cor, anchor="ls",
|
|
808
|
+
stroke_width=stroke, stroke_fill=halo)
|
|
809
|
+
if 0 <= active < len(words):
|
|
810
|
+
wl_big = big.getlength(words[active])
|
|
811
|
+
cx_word = pad + xs[active] + widths[active] / 2.0
|
|
812
|
+
d.text((cx_word - wl_big / 2.0, center_y + cap_big / 2.0), words[active], font=big, fill=acc,
|
|
813
|
+
anchor="ls", stroke_width=stroke_big, stroke_fill=halo)
|
|
814
|
+
out = _new(layer.width, layer.height)
|
|
815
|
+
_alpha_paste(out, _shadow_of(layer, blur=size * 0.09, alpha=0.75 if halo == BLACK else 0.35),
|
|
816
|
+
0, int(size * 0.06))
|
|
817
|
+
out.alpha_composite(layer)
|
|
818
|
+
return _Block(out, pad + total / 2.0, center_y, cap_big / 2.0 + stroke_big + size * 0.14,
|
|
819
|
+
total / 2.0 + stroke_big)
|
|
820
|
+
|
|
821
|
+
|
|
822
|
+
def _fade(color, alpha: float):
|
|
823
|
+
r, g, b, a = _rgba(color)
|
|
824
|
+
return (r, g, b, int(round(a * max(0.0, min(1.0, alpha)))))
|
|
825
|
+
|
|
826
|
+
|
|
827
|
+
def _draw_papelaria(spec, geo, words, active, accent, size, variant, anim) -> _Block:
|
|
828
|
+
return _papelaria_block(spec, geo, words, active, accent, size, variant, anim,
|
|
829
|
+
base_color=WHITE, halo=BLACK, futuro_alpha=0.0)
|
|
830
|
+
|
|
831
|
+
|
|
832
|
+
def _draw_papelaria_papel(spec, geo, words, active, accent, size, variant, anim) -> _Block:
|
|
833
|
+
return _papelaria_block(spec, geo, words, active, accent, size, variant, anim,
|
|
834
|
+
base_color=(53, 44, 64, 255), halo=(255, 253, 253, 255), futuro_alpha=0.0)
|
|
835
|
+
|
|
836
|
+
|
|
837
|
+
def _draw_depoimento(spec, geo, words, active, accent, size, variant, anim) -> _Block:
|
|
838
|
+
# a fala de outra pessoa: o corpo da frase fica na cor de destaque e a palavra falada, branca
|
|
839
|
+
return _papelaria_block(spec, geo, words, active, accent, size, variant, anim,
|
|
840
|
+
base_color=_rgba(accent), halo=BLACK, futuro_alpha=0.0, ativa_color="#FFFFFF")
|
|
841
|
+
|
|
842
|
+
|
|
843
|
+
def _draw_fita(spec, geo, words, active, accent, size, variant, anim) -> _Block:
|
|
844
|
+
f = _font(spec.font, size)
|
|
845
|
+
cap_h = _cap_height(f)
|
|
846
|
+
xs, widths, total = _word_xs(f, words, size, spec)
|
|
847
|
+
pad_top, pad_bot = 0.42 * size, 0.36 * size
|
|
848
|
+
tape_h = cap_h + pad_top + pad_bot
|
|
849
|
+
margin = int(size * 0.3)
|
|
850
|
+
layer = _new(total + 2 * margin, tape_h + 2 * margin)
|
|
851
|
+
x0, y0 = margin, margin
|
|
852
|
+
base_y = y0 + pad_top + cap_h
|
|
853
|
+
acc = _rgba(accent)
|
|
854
|
+
wipe = anim.get("wipe", 1.0)
|
|
855
|
+
tape = _new(layer.width, layer.height)
|
|
856
|
+
_rounded_box(tape, (x0, y0, x0 + total, y0 + tape_h), radius=size * 0.1, color=(12, 12, 12, 242))
|
|
857
|
+
d = ImageDraw.Draw(tape)
|
|
858
|
+
for i, (x, word) in enumerate(zip(xs, words)):
|
|
859
|
+
d.text((x0 + x, base_y), word, font=f, fill=acc if i == active else WHITE, anchor="ls")
|
|
860
|
+
# accent edge on the left, clipped to the tape shape
|
|
861
|
+
edge = Image.new("RGBA", tape.size, acc)
|
|
862
|
+
edge_a = tape.getchannel("A").point(lambda v: 255 if v > 0 else 0)
|
|
863
|
+
ImageDraw.Draw(edge_a).rectangle((int(x0 + size * 0.16), 0, tape.width, tape.height), fill=0)
|
|
864
|
+
edge.putalpha(Image.composite(tape.getchannel("A"), Image.new("L", tape.size, 0), edge_a))
|
|
865
|
+
tape.alpha_composite(edge)
|
|
866
|
+
if wipe < 0.999:
|
|
867
|
+
cut = int(x0 + total * max(0.0, wipe))
|
|
868
|
+
a = tape.getchannel("A")
|
|
869
|
+
ImageDraw.Draw(a).rectangle((cut, 0, tape.width, tape.height), fill=0)
|
|
870
|
+
tape.putalpha(a)
|
|
871
|
+
sh = _shadow_of(tape, blur=size * 0.14, alpha=0.45)
|
|
872
|
+
_alpha_paste(layer, sh, 0, int(size * 0.08))
|
|
873
|
+
layer.alpha_composite(tape)
|
|
874
|
+
ax, ay = x0 + total / 2.0, y0 + tape_h / 2.0
|
|
875
|
+
angle = variant.get("angle", 0.0)
|
|
876
|
+
rot, dx, dy = _rotate(layer, angle)
|
|
877
|
+
cx, cy = layer.width / 2.0, layer.height / 2.0
|
|
878
|
+
rax, ray = _rotate_point(ax, ay, cx, cy, angle)
|
|
879
|
+
return _Block(rot, rax + dx, ray + dy, tape_h / 2.0 + abs(math.sin(math.radians(angle))) * total / 2.0)
|
|
880
|
+
|
|
881
|
+
|
|
882
|
+
def _draw_legenda_limpa(spec, geo, lines, active, accent, size, variant, anim) -> _Block:
|
|
883
|
+
f = _font(spec.font, size)
|
|
884
|
+
asc, desc = f.getmetrics()
|
|
885
|
+
pitch = size * 1.22
|
|
886
|
+
widths = [f.getlength(" ".join(ln)) for ln in lines]
|
|
887
|
+
padx, pady = spec.hpad * size, 0.3 * size
|
|
888
|
+
box_w = max(widths) + 2 * padx
|
|
889
|
+
box_h = pitch * (len(lines) - 1) + asc + desc + 2 * pady - 0.12 * size
|
|
890
|
+
margin = int(size * 0.3)
|
|
891
|
+
layer = _new(box_w + 2 * margin, box_h + 2 * margin)
|
|
892
|
+
bx0, by0 = margin, margin
|
|
893
|
+
_rounded_box(layer, (bx0, by0, bx0 + box_w, by0 + box_h), radius=size * 0.32, color=(0, 0, 0, 160))
|
|
894
|
+
d = ImageDraw.Draw(layer)
|
|
895
|
+
for i, (ln, wl) in enumerate(zip(lines, widths)):
|
|
896
|
+
y = by0 + pady + asc - 0.06 * size + pitch * i
|
|
897
|
+
d.text((bx0 + (box_w - wl) / 2.0, y), " ".join(ln), font=f, fill=WHITE, anchor="ls")
|
|
898
|
+
return _Block(layer, bx0 + box_w / 2.0, by0 + box_h / 2.0, box_h / 2.0)
|
|
899
|
+
|
|
900
|
+
|
|
901
|
+
_DRAWERS = {
|
|
902
|
+
"impacto": _draw_impacto,
|
|
903
|
+
"caixa_viva": _draw_caixa_viva,
|
|
904
|
+
"palavra_gigante": _draw_palavra_gigante,
|
|
905
|
+
"pulso": _draw_pulso,
|
|
906
|
+
"fita": _draw_fita,
|
|
907
|
+
"legenda_limpa": _draw_legenda_limpa,
|
|
908
|
+
"papelaria": _draw_papelaria,
|
|
909
|
+
"papelaria_papel": _draw_papelaria_papel,
|
|
910
|
+
"depoimento": _draw_depoimento,
|
|
911
|
+
}
|
|
912
|
+
|
|
913
|
+
|
|
914
|
+
def _caption_block(spec: CaptionStyle, geo: _Geo, words: tuple, active: int, accent: str,
|
|
915
|
+
font_scale: float, variant: dict, anim: dict) -> _Block:
|
|
916
|
+
base = spec.size * geo.k * font_scale
|
|
917
|
+
max_w = geo.caption_max_w
|
|
918
|
+
wl = list(words)
|
|
919
|
+
if spec.lines >= 2:
|
|
920
|
+
f0 = _font(spec.font, base)
|
|
921
|
+
if _line_width(f0, wl, base, spec) <= max_w:
|
|
922
|
+
lines = [wl]
|
|
923
|
+
else:
|
|
924
|
+
lines = _balanced_split(wl, lambda ws: _line_width(f0, ws, base, spec))
|
|
925
|
+
size = _fit_size(spec, lines, base, max_w)
|
|
926
|
+
return _DRAWERS[spec.draw](spec, geo, lines, active, accent, size, variant, anim)
|
|
927
|
+
size = _fit_size(spec, [wl], base, max_w)
|
|
928
|
+
return _DRAWERS[spec.draw](spec, geo, wl, active, accent, size, variant, anim)
|
|
929
|
+
|
|
930
|
+
|
|
931
|
+
def _place(canvas, block: _Block, cx: float, cy: float, anim: dict, geo: _Geo, clamp=True) -> Image.Image:
|
|
932
|
+
img = Image.new("RGBA", canvas, (0, 0, 0, 0))
|
|
933
|
+
if clamp:
|
|
934
|
+
lo = geo.safe_top + block.half_h
|
|
935
|
+
hi = min(geo.h, geo.safe_bottom) - block.half_h
|
|
936
|
+
if hi >= lo:
|
|
937
|
+
cy = min(max(cy, lo), hi)
|
|
938
|
+
layer, ax, ay = block.layer, block.ax, block.ay
|
|
939
|
+
s = anim.get("scale", 1.0)
|
|
940
|
+
if s > 1.0 and block.half_w > 0:
|
|
941
|
+
# a scaled-up entrance frame must stay inside the caption safe width (right rail too)
|
|
942
|
+
room = geo.caption_max_w / 2.0
|
|
943
|
+
s = max(1.0, min(s, room / block.half_w))
|
|
944
|
+
if abs(s - 1.0) > 1e-3:
|
|
945
|
+
nw, nh = max(1, int(round(layer.width * s))), max(1, int(round(layer.height * s)))
|
|
946
|
+
layer = layer.resize((nw, nh), Image.BICUBIC)
|
|
947
|
+
ax, ay = ax * nw / block.layer.width, ay * nh / block.layer.height
|
|
948
|
+
a = anim.get("alpha", 1.0)
|
|
949
|
+
if a < 0.999:
|
|
950
|
+
layer = _mul_alpha(layer, max(0.0, a))
|
|
951
|
+
dy = anim.get("dy", 0.0) * geo.k
|
|
952
|
+
_alpha_paste(img, layer, int(round(cx - ax)), int(round(cy - ay + dy)))
|
|
953
|
+
return img
|
|
954
|
+
|
|
955
|
+
|
|
956
|
+
# ---------------------------------------------------------------- caption track
|
|
957
|
+
|
|
958
|
+
def _cue_words(cue: dict) -> list[dict]:
|
|
959
|
+
ws = cue.get("words") or []
|
|
960
|
+
if ws:
|
|
961
|
+
return [{"text": str(w.get("text", "")), "start": float(w["start"]), "end": float(w["end"])} for w in ws]
|
|
962
|
+
toks = str(cue.get("text", "")).split()
|
|
963
|
+
if not toks:
|
|
964
|
+
return []
|
|
965
|
+
s, e = float(cue["start"]), float(cue["end"])
|
|
966
|
+
total = sum(len(t) for t in toks) or 1
|
|
967
|
+
out, t = [], s
|
|
968
|
+
for tok in toks:
|
|
969
|
+
d = (e - s) * len(tok) / total
|
|
970
|
+
out.append({"text": tok, "start": t, "end": t + d})
|
|
971
|
+
t += d
|
|
972
|
+
return out
|
|
973
|
+
|
|
974
|
+
|
|
975
|
+
HOLD_GAP = 0.6 # a cue stays up until the next one when the silence between them is shorter
|
|
976
|
+
TAIL_HOLD = 0.3 # otherwise it lingers this long after the last word
|
|
977
|
+
INK_ALPHA = 48 # alpha above this counts as drawn when measuring boxes (soft shadow edges do not)
|
|
978
|
+
INK_LUT = [255 if v > INK_ALPHA else 0 for v in range(256)]
|
|
979
|
+
ONE_WORD_MIN_S = 0.16 # one-word styles: shortest time a word state stays on screen (5 frames at 30 fps)
|
|
980
|
+
ONE_WORD_MAX_MERGE = 3 # at most this many words share one state when they are spoken too fast
|
|
981
|
+
|
|
982
|
+
|
|
983
|
+
def _track_states(cues: list[dict], spec: CaptionStyle, fps: float, boundaries: list[float]) -> list[tuple]:
|
|
984
|
+
"""Frame-level states: (f0, f1, words_tuple, active, variant_items, anim_items)."""
|
|
985
|
+
def fr(t: float) -> int:
|
|
986
|
+
return int(round(t * fps + 1e-6))
|
|
987
|
+
|
|
988
|
+
prepared = []
|
|
989
|
+
for cue in cues:
|
|
990
|
+
raw = _cue_words(cue)
|
|
991
|
+
words = []
|
|
992
|
+
for w in raw:
|
|
993
|
+
disp = _display_word(w["text"], spec)
|
|
994
|
+
if not disp:
|
|
995
|
+
if words:
|
|
996
|
+
words[-1]["end"] = max(words[-1]["end"], w["end"])
|
|
997
|
+
continue
|
|
998
|
+
words.append({"text": disp, "start": w["start"], "end": max(w["end"], w["start"])})
|
|
999
|
+
if not words:
|
|
1000
|
+
continue
|
|
1001
|
+
start = float(cue.get("start", words[0]["start"]))
|
|
1002
|
+
end = float(cue.get("end", words[-1]["end"]))
|
|
1003
|
+
prepared.append({"start": min(start, words[0]["start"]), "end": max(end, words[-1]["end"]), "words": words})
|
|
1004
|
+
prepared.sort(key=lambda c: c["start"])
|
|
1005
|
+
|
|
1006
|
+
states: list[tuple] = []
|
|
1007
|
+
enter = _anim_frames(list(spec.enter), spec.enter_dur, fps)
|
|
1008
|
+
change = _anim_frames(list(spec.change), spec.change_dur, fps)
|
|
1009
|
+
for ci, cue in enumerate(prepared):
|
|
1010
|
+
nxt = prepared[ci + 1]["start"] if ci + 1 < len(prepared) else None
|
|
1011
|
+
disp_end = cue["end"] + TAIL_HOLD
|
|
1012
|
+
if nxt is not None:
|
|
1013
|
+
if nxt - cue["end"] <= HOLD_GAP:
|
|
1014
|
+
disp_end = nxt
|
|
1015
|
+
disp_end = min(disp_end, nxt)
|
|
1016
|
+
for b in boundaries:
|
|
1017
|
+
if cue["end"] - 1e-3 <= b < disp_end:
|
|
1018
|
+
disp_end = max(cue["end"], b)
|
|
1019
|
+
break
|
|
1020
|
+
cs, ce = fr(cue["start"]), fr(disp_end)
|
|
1021
|
+
if nxt is not None:
|
|
1022
|
+
ce = min(ce, fr(nxt))
|
|
1023
|
+
if ce <= cs:
|
|
1024
|
+
continue
|
|
1025
|
+
words = cue["words"]
|
|
1026
|
+
variant: dict = {}
|
|
1027
|
+
if spec.draw == "fita":
|
|
1028
|
+
variant["angle"] = -2.6 if ci % 2 == 0 else 1.8
|
|
1029
|
+
|
|
1030
|
+
def emit(f0, f1, wt, act, var, entry_frames, entry_start):
|
|
1031
|
+
"""Split [f0, f1) into animated entrance frames + one steady state."""
|
|
1032
|
+
if f1 <= f0:
|
|
1033
|
+
return
|
|
1034
|
+
g = f0
|
|
1035
|
+
while g < f1 and entry_frames and g - entry_start < len(entry_frames):
|
|
1036
|
+
j = g - entry_start
|
|
1037
|
+
if j >= 0:
|
|
1038
|
+
states.append((g, g + 1, wt, act, tuple(sorted(var.items())), tuple(sorted(entry_frames[j].items()))))
|
|
1039
|
+
g += 1
|
|
1040
|
+
if g < f1:
|
|
1041
|
+
states.append((g, f1, wt, act, tuple(sorted(var.items())), ()))
|
|
1042
|
+
|
|
1043
|
+
if not spec.animated:
|
|
1044
|
+
wt = tuple(_trim_cue_end([w["text"] for w in words], spec))
|
|
1045
|
+
emit(cs, ce, wt, -1, variant, [], cs)
|
|
1046
|
+
continue
|
|
1047
|
+
|
|
1048
|
+
starts = [cs] + [min(max(fr(w["start"]), cs), ce) for w in words[1:]]
|
|
1049
|
+
starts = [max(starts[i], starts[i - 1]) if i else starts[i] for i in range(len(starts))]
|
|
1050
|
+
if spec.one_word:
|
|
1051
|
+
# a word shorter than ONE_WORD_MIN_S flickers (1-3 frames, never seen at full size):
|
|
1052
|
+
# it shares the screen with the next word (or the one before, at the end of a cue)
|
|
1053
|
+
min_frames = max(3, int(round(ONE_WORD_MIN_S * fps)))
|
|
1054
|
+
groups: list[list] = []
|
|
1055
|
+
for i, w in enumerate(words):
|
|
1056
|
+
f0, f1 = starts[i], (starts[i + 1] if i + 1 < len(words) else ce)
|
|
1057
|
+
if groups and groups[-1][1] - groups[-1][0] < min_frames and len(groups[-1][2]) < ONE_WORD_MAX_MERGE:
|
|
1058
|
+
groups[-1][1] = max(groups[-1][1], f1)
|
|
1059
|
+
groups[-1][2].append(w["text"])
|
|
1060
|
+
else:
|
|
1061
|
+
groups.append([f0, f1, [w["text"]]])
|
|
1062
|
+
if (len(groups) >= 2 and groups[-1][1] - groups[-1][0] < min_frames
|
|
1063
|
+
and len(groups[-2][2]) + len(groups[-1][2]) <= ONE_WORD_MAX_MERGE):
|
|
1064
|
+
last = groups.pop()
|
|
1065
|
+
groups[-1][1] = last[1]
|
|
1066
|
+
groups[-1][2].extend(last[2])
|
|
1067
|
+
for f0, f1, texts in groups:
|
|
1068
|
+
if f1 <= f0:
|
|
1069
|
+
continue
|
|
1070
|
+
# no entrance bounce on a state too short to settle at its final size
|
|
1071
|
+
entry = enter if f1 - f0 >= len(enter) + 3 else []
|
|
1072
|
+
emit(f0, f1, tuple(texts), 0, variant, entry, f0)
|
|
1073
|
+
continue
|
|
1074
|
+
for i, w in enumerate(words):
|
|
1075
|
+
f0 = starts[i]
|
|
1076
|
+
f1 = starts[i + 1] if i + 1 < len(words) else ce
|
|
1077
|
+
if f1 <= f0:
|
|
1078
|
+
continue
|
|
1079
|
+
wt = tuple(x["text"] for x in words)
|
|
1080
|
+
if f0 < cs + len(enter):
|
|
1081
|
+
# cue entrance frames (take priority over the word-change slide)
|
|
1082
|
+
head_end = min(f1, cs + len(enter))
|
|
1083
|
+
emit(f0, head_end, wt, i, variant, enter, cs)
|
|
1084
|
+
f0 = head_end
|
|
1085
|
+
if f0 >= f1:
|
|
1086
|
+
continue
|
|
1087
|
+
emit(f0, f1, wt, i, variant, [], f0)
|
|
1088
|
+
elif change and i > 0:
|
|
1089
|
+
var = dict(variant, prev=i - 1)
|
|
1090
|
+
g = f0
|
|
1091
|
+
for j, params in enumerate(change):
|
|
1092
|
+
if g >= f1:
|
|
1093
|
+
break
|
|
1094
|
+
states.append((g, g + 1, wt, i, tuple(sorted(var.items())), tuple(sorted(params.items()))))
|
|
1095
|
+
g += 1
|
|
1096
|
+
if g < f1:
|
|
1097
|
+
states.append((g, f1, wt, i, tuple(sorted(variant.items())), ()))
|
|
1098
|
+
else:
|
|
1099
|
+
emit(f0, f1, wt, i, variant, [], f0)
|
|
1100
|
+
states.sort(key=lambda s: s[0])
|
|
1101
|
+
# safety: never overlap
|
|
1102
|
+
clean: list[tuple] = []
|
|
1103
|
+
for st in states:
|
|
1104
|
+
if clean and st[0] < clean[-1][1]:
|
|
1105
|
+
st = (clean[-1][1],) + st[1:]
|
|
1106
|
+
if st[1] > st[0]:
|
|
1107
|
+
clean.append(st)
|
|
1108
|
+
return clean
|
|
1109
|
+
|
|
1110
|
+
|
|
1111
|
+
def _apply_windows(states: list[tuple], fps: float, default_cy: float,
|
|
1112
|
+
layout: list[dict] | None, hide: list[list[float]] | None) -> list[tuple]:
|
|
1113
|
+
"""-> (f0, f1, words, active, variant, anim, center_y) with hide windows removed."""
|
|
1114
|
+
def fr(t: float) -> int:
|
|
1115
|
+
return int(round(float(t) * fps + 1e-6))
|
|
1116
|
+
|
|
1117
|
+
lay = sorted(((fr(w["start"]), fr(w["end"]), float(w["center_y"])) for w in (layout or [])), key=lambda x: x[0])
|
|
1118
|
+
hid = sorted((fr(a), fr(b)) for a, b in (hide or []))
|
|
1119
|
+
cuts = sorted({p for a, b, _ in lay for p in (a, b)} | {p for a, b in hid for p in (a, b)})
|
|
1120
|
+
out = []
|
|
1121
|
+
for f0, f1, *rest in states:
|
|
1122
|
+
pts = [f0] + [c for c in cuts if f0 < c < f1] + [f1]
|
|
1123
|
+
for a, b in zip(pts, pts[1:]):
|
|
1124
|
+
if any(h0 <= a and b <= h1 for h0, h1 in hid):
|
|
1125
|
+
continue
|
|
1126
|
+
cy = default_cy
|
|
1127
|
+
for l0, l1, lcy in lay:
|
|
1128
|
+
if l0 <= a and b <= l1:
|
|
1129
|
+
cy = lcy
|
|
1130
|
+
out.append((a, b, *rest, cy))
|
|
1131
|
+
return out
|
|
1132
|
+
|
|
1133
|
+
|
|
1134
|
+
def render_caption_track(cues: dict, style_id: str, accent: str, out_dir: str,
|
|
1135
|
+
canvas=(1080, 1920), fps: int = 30,
|
|
1136
|
+
layout: list[dict] | None = None, hide: list[list[float]] | None = None,
|
|
1137
|
+
font_scale: float = 1.0) -> list[dict]:
|
|
1138
|
+
"""Render every distinct visual state of a caption track to PNGs.
|
|
1139
|
+
|
|
1140
|
+
Returns [{"file": "/abs/png", "start": s, "end": e, "bbox": [x0, y0, x1, y1]}] sorted,
|
|
1141
|
+
non-overlapping, only where a caption is visible. `bbox` is the drawn area of that PNG (alpha
|
|
1142
|
+
above INK_ALPHA; null when empty). Times are on the frame grid of `fps`. Identical states share one PNG (named by a
|
|
1143
|
+
content hash, so re-running with the same input reuses the files; their boxes are kept in
|
|
1144
|
+
`bboxes.json` next to them). Default center: layout_defaults(canvas)["caption"].
|
|
1145
|
+
"""
|
|
1146
|
+
spec = _caption_spec(style_id)
|
|
1147
|
+
geo = _Geo(int(canvas[0]), int(canvas[1]))
|
|
1148
|
+
out = Path(out_dir).resolve()
|
|
1149
|
+
out.mkdir(parents=True, exist_ok=True)
|
|
1150
|
+
cue_list = cues.get("cues", []) if isinstance(cues, dict) else list(cues)
|
|
1151
|
+
boundaries = sorted(float(b) for b in (cues.get("cut_boundaries") or [])) if isinstance(cues, dict) else []
|
|
1152
|
+
default_cy = layout_defaults((geo.w, geo.h))["caption"]
|
|
1153
|
+
hex_accent = accent or DEFAULT_ACCENT
|
|
1154
|
+
_rgba(hex_accent) # validate early
|
|
1155
|
+
|
|
1156
|
+
states = _track_states(cue_list, spec, float(fps), boundaries)
|
|
1157
|
+
placed = _apply_windows(states, float(fps), default_cy, layout, hide)
|
|
1158
|
+
|
|
1159
|
+
# merge consecutive identical states
|
|
1160
|
+
merged: list[list] = []
|
|
1161
|
+
for st in placed:
|
|
1162
|
+
if merged and merged[-1][1] == st[0] and tuple(merged[-1][2:]) == tuple(st[2:]):
|
|
1163
|
+
merged[-1][1] = st[1]
|
|
1164
|
+
else:
|
|
1165
|
+
merged.append(list(st))
|
|
1166
|
+
|
|
1167
|
+
jobs: dict[str, tuple] = {}
|
|
1168
|
+
entries = []
|
|
1169
|
+
for f0, f1, words, active, variant, anim, cy in merged:
|
|
1170
|
+
key_src = repr((_SRC_HASH, style_id, hex_accent.upper(), geo.w, geo.h, round(font_scale, 4),
|
|
1171
|
+
words, active, variant, anim, round(cy, 1)))
|
|
1172
|
+
name = "cap_" + hashlib.sha1(key_src.encode("utf-8")).hexdigest()[:16] + ".png"
|
|
1173
|
+
path = out / name
|
|
1174
|
+
jobs.setdefault(name, (path, words, active, dict(variant), dict(anim), cy))
|
|
1175
|
+
entries.append({"file": str(path), "start": round(f0 / fps, 6), "end": round(f1 / fps, 6)})
|
|
1176
|
+
|
|
1177
|
+
bbox_path = out / "bboxes.json"
|
|
1178
|
+
try:
|
|
1179
|
+
known = json.loads(bbox_path.read_text(encoding="utf-8")) if bbox_path.exists() else {}
|
|
1180
|
+
except (OSError, ValueError):
|
|
1181
|
+
known = {}
|
|
1182
|
+
boxes: dict[str, list | None] = {}
|
|
1183
|
+
|
|
1184
|
+
def work(job):
|
|
1185
|
+
path, words, active, variant, anim, cy = job
|
|
1186
|
+
if path.exists():
|
|
1187
|
+
if path.name in known:
|
|
1188
|
+
boxes[path.name] = known[path.name]
|
|
1189
|
+
return
|
|
1190
|
+
with Image.open(path) as im:
|
|
1191
|
+
bb = ink_bbox(im)
|
|
1192
|
+
boxes[path.name] = list(bb) if bb else None
|
|
1193
|
+
return
|
|
1194
|
+
block = _caption_block(spec, geo, words, active, hex_accent, font_scale, variant, anim)
|
|
1195
|
+
img = _place((geo.w, geo.h), block, geo.cx, cy, anim, geo)
|
|
1196
|
+
bb = ink_bbox(img)
|
|
1197
|
+
boxes[path.name] = list(bb) if bb else None
|
|
1198
|
+
tmp = path.with_name(".tmp-" + path.name)
|
|
1199
|
+
img.save(tmp, "PNG", compress_level=3)
|
|
1200
|
+
os.replace(tmp, path)
|
|
1201
|
+
|
|
1202
|
+
workers = max(1, min(8, (os.cpu_count() or 4)))
|
|
1203
|
+
with ThreadPoolExecutor(max_workers=workers) as ex:
|
|
1204
|
+
list(ex.map(work, jobs.values()))
|
|
1205
|
+
if any(known.get(n, 0) != b for n, b in boxes.items()):
|
|
1206
|
+
known.update(boxes)
|
|
1207
|
+
tmp = bbox_path.with_name(".tmp-" + bbox_path.name)
|
|
1208
|
+
tmp.write_text(json.dumps(known), encoding="utf-8")
|
|
1209
|
+
os.replace(tmp, bbox_path)
|
|
1210
|
+
for e in entries:
|
|
1211
|
+
e["bbox"] = boxes.get(Path(e["file"]).name)
|
|
1212
|
+
return entries
|
|
1213
|
+
|
|
1214
|
+
|
|
1215
|
+
def ink_bbox(img: Image.Image) -> tuple[int, int, int, int] | None:
|
|
1216
|
+
"""Box of what is really drawn: alpha above INK_ALPHA (the faint edge of soft shadows is left out)."""
|
|
1217
|
+
return img.getchannel("A").point(INK_LUT).getbbox()
|
|
1218
|
+
|
|
1219
|
+
|
|
1220
|
+
_REACH_ABOVE = set("ÁÀÂÃÄÉÈÊËÍÌÎÏÓÒÔÕÖÚÙÛÜÑbdfhklt")
|
|
1221
|
+
_REACH_BELOW = set("ÇQJgjpqyç,;")
|
|
1222
|
+
|
|
1223
|
+
|
|
1224
|
+
def caption_extent(cues: dict | list, style_id: str, canvas=(1080, 1920), font_scale: float = 1.0,
|
|
1225
|
+
fps: float = 30.0) -> tuple[float, float]:
|
|
1226
|
+
"""(above, below): how many px a caption track reaches above and below its center line.
|
|
1227
|
+
|
|
1228
|
+
Measured on drawn blocks of the real cues (the widest one, plus one with accents on top and one
|
|
1229
|
+
with a cedilla or descender), times the biggest entrance scale and slide of the style, so a
|
|
1230
|
+
track can be placed before it is rendered. Empty cues: a sample phrase with accents.
|
|
1231
|
+
"""
|
|
1232
|
+
spec = _caption_spec(style_id)
|
|
1233
|
+
geo = _Geo(int(canvas[0]), int(canvas[1]))
|
|
1234
|
+
cue_list = cues.get("cues", []) if isinstance(cues, dict) else list(cues or [])
|
|
1235
|
+
groups: list[list[str]] = []
|
|
1236
|
+
for cue in cue_list:
|
|
1237
|
+
words = display_words([w["text"] for w in _cue_words(cue)], style_id)
|
|
1238
|
+
groups += [[w] for w in words] if spec.one_word else ([words] if words else [])
|
|
1239
|
+
if not groups:
|
|
1240
|
+
groups = [display_words(["ÉÇ", "SÓ"], style_id)]
|
|
1241
|
+
picks: dict[tuple, list[str]] = {}
|
|
1242
|
+
widest, best = groups[0], -1.0
|
|
1243
|
+
for g in groups:
|
|
1244
|
+
chars = set("".join(g))
|
|
1245
|
+
sig = (bool(chars & _REACH_ABOVE), bool(chars & _REACH_BELOW))
|
|
1246
|
+
picks.setdefault(sig, g)
|
|
1247
|
+
width = measure_caption_width(g, style_id, canvas, font_scale, already_display=True)
|
|
1248
|
+
if width > best:
|
|
1249
|
+
best, widest = width, g
|
|
1250
|
+
picks[("widest",)] = widest
|
|
1251
|
+
variant = {"angle": -2.6} if spec.draw == "fita" else {}
|
|
1252
|
+
above = below = 0.0
|
|
1253
|
+
for words in picks.values():
|
|
1254
|
+
block = _caption_block(spec, geo, tuple(words), 0, DEFAULT_ACCENT, font_scale, variant, {})
|
|
1255
|
+
bb = ink_bbox(block.layer) or (0, 0, block.layer.width, block.layer.height)
|
|
1256
|
+
above, below = max(above, block.ay - bb[1]), max(below, bb[3] - block.ay)
|
|
1257
|
+
frames = _anim_frames(list(spec.enter), spec.enter_dur, fps)
|
|
1258
|
+
scale = max([1.0] + [float(f.get("scale", 1.0)) for f in frames])
|
|
1259
|
+
slide = max([0.0] + [float(f.get("dy", 0.0)) for f in frames if f.get("alpha", 1.0) >= 0.5]) * geo.k
|
|
1260
|
+
return above * scale, below * scale + slide
|
|
1261
|
+
|
|
1262
|
+
|
|
1263
|
+
def write_blank_png(path: str | Path, canvas=(1080, 1920)) -> Path:
|
|
1264
|
+
p = Path(path)
|
|
1265
|
+
p.parent.mkdir(parents=True, exist_ok=True)
|
|
1266
|
+
Image.new("RGBA", (int(canvas[0]), int(canvas[1])), (0, 0, 0, 0)).save(p, "PNG")
|
|
1267
|
+
return p
|
|
1268
|
+
|
|
1269
|
+
|
|
1270
|
+
def write_ffconcat(states: list[dict], path: str, total_duration: float, blank_png: str,
|
|
1271
|
+
fps: float | None = None) -> Path:
|
|
1272
|
+
"""Write an ffconcat playlist that plays the caption states over [0, total_duration].
|
|
1273
|
+
|
|
1274
|
+
Gaps use `blank_png` (created, fully transparent, when it does not exist yet, with the
|
|
1275
|
+
size of the first state PNG). The last entry is repeated without duration, as the concat
|
|
1276
|
+
demuxer requires. Each entry carries `option framerate` so timestamps are not rounded to
|
|
1277
|
+
the image demuxer's default 1/25 time base: pass the output fps when known (exact frame
|
|
1278
|
+
grid); otherwise a 1 microsecond time base is used. Durations are derived from
|
|
1279
|
+
microsecond-floored cumulative positions, so rounding never drifts.
|
|
1280
|
+
|
|
1281
|
+
Use in ffmpeg (frame-accurate, verified):
|
|
1282
|
+
ffmpeg -i base.mp4 -f concat -safe 0 -i captions.ffconcat \\
|
|
1283
|
+
-filter_complex "[1:v]format=rgba[cap];[0:v][cap]overlay=0:0:eof_action=pass[v]" \\
|
|
1284
|
+
-map "[v]" -map 0:a? ...
|
|
1285
|
+
"""
|
|
1286
|
+
p = Path(path).resolve()
|
|
1287
|
+
p.parent.mkdir(parents=True, exist_ok=True)
|
|
1288
|
+
blank = Path(blank_png).resolve()
|
|
1289
|
+
if not blank.exists():
|
|
1290
|
+
size = (REF_W, REF_H)
|
|
1291
|
+
if states:
|
|
1292
|
+
with Image.open(states[0]["file"]) as im:
|
|
1293
|
+
size = im.size
|
|
1294
|
+
write_blank_png(blank, size)
|
|
1295
|
+
total = max(0.0, float(total_duration))
|
|
1296
|
+
rate = f"{int(fps)}" if fps and float(fps).is_integer() else (f"{fps}" if fps else "1000000")
|
|
1297
|
+
|
|
1298
|
+
def us(t: float) -> int:
|
|
1299
|
+
return int(math.floor(float(t) * 1_000_000 + 1e-3))
|
|
1300
|
+
|
|
1301
|
+
items: list[tuple[str, int, int]] = [] # (file, start_us, end_us)
|
|
1302
|
+
cursor = 0
|
|
1303
|
+
for st in sorted(states, key=lambda s: s["start"]):
|
|
1304
|
+
a, b = us(st["start"]), us(min(st["end"], total) if total else st["end"])
|
|
1305
|
+
a = max(a, cursor)
|
|
1306
|
+
if b <= a:
|
|
1307
|
+
continue
|
|
1308
|
+
if a > cursor:
|
|
1309
|
+
items.append((str(blank), cursor, a))
|
|
1310
|
+
items.append((str(Path(st["file"]).resolve()), a, b))
|
|
1311
|
+
cursor = b
|
|
1312
|
+
end_us = us(total) if total else cursor
|
|
1313
|
+
if end_us > cursor:
|
|
1314
|
+
items.append((str(blank), cursor, end_us))
|
|
1315
|
+
if not items:
|
|
1316
|
+
items.append((str(blank), 0, max(end_us, 1)))
|
|
1317
|
+
|
|
1318
|
+
def q(f: str) -> str:
|
|
1319
|
+
return "'" + f.replace("'", "'\\''") + "'"
|
|
1320
|
+
|
|
1321
|
+
lines = ["ffconcat version 1.0"]
|
|
1322
|
+
for f, a, b in items:
|
|
1323
|
+
lines += [f"file {q(f)}", f"option framerate {rate}", f"duration {(b - a) / 1_000_000:.6f}"]
|
|
1324
|
+
last = items[-1][0]
|
|
1325
|
+
lines += [f"file {q(last)}", f"option framerate {rate}"]
|
|
1326
|
+
p.write_text("\n".join(lines) + "\n", encoding="utf-8")
|
|
1327
|
+
return p
|
|
1328
|
+
|
|
1329
|
+
|
|
1330
|
+
# ============================================================== hooks
|
|
1331
|
+
|
|
1332
|
+
@dataclass
|
|
1333
|
+
class _Tok:
|
|
1334
|
+
text: str
|
|
1335
|
+
accent: bool
|
|
1336
|
+
|
|
1337
|
+
|
|
1338
|
+
def _parse_marked(text: str, upper: bool, font_key: str | None = None) -> tuple[list[_Tok], bool]:
|
|
1339
|
+
"""Words with their accent flag. A word touched by the markup is accented as a whole, so
|
|
1340
|
+
"*Kiwi*fy" stays one word. With `font_key`, symbols the font cannot draw are removed."""
|
|
1341
|
+
chars, marked, _ = _markup_chars(text)
|
|
1342
|
+
words: list[list] = [] # [text, accent]
|
|
1343
|
+
cur: list[str] = []
|
|
1344
|
+
cur_acc = False
|
|
1345
|
+
for c, acc in chars + [(" ", False)]:
|
|
1346
|
+
if c.isspace():
|
|
1347
|
+
if cur:
|
|
1348
|
+
words.append(["".join(cur), cur_acc])
|
|
1349
|
+
cur, cur_acc = [], False
|
|
1350
|
+
continue
|
|
1351
|
+
cur.append(c)
|
|
1352
|
+
cur_acc = cur_acc or acc
|
|
1353
|
+
toks: list[_Tok] = []
|
|
1354
|
+
for w, acc in words:
|
|
1355
|
+
if font_key:
|
|
1356
|
+
w, _ = sanitize_text(w, font_key)
|
|
1357
|
+
w = w.replace("\n", " ").strip()
|
|
1358
|
+
if not w:
|
|
1359
|
+
continue
|
|
1360
|
+
w = _case(w, upper)
|
|
1361
|
+
if toks and re.fullmatch(r"[,.;:!?…]+", w):
|
|
1362
|
+
toks[-1].text += w
|
|
1363
|
+
else:
|
|
1364
|
+
toks.append(_Tok(w, acc))
|
|
1365
|
+
return toks, marked and any(t.accent for t in toks)
|
|
1366
|
+
|
|
1367
|
+
|
|
1368
|
+
_TEXT_FONTS = {"cta": "sans_black", "cta_sub": "text_bold", "disclaimer": "text_semibold"}
|
|
1369
|
+
_TEXT_LABELS = {"hook": "gancho", "cta": "chamada", "cta_sub": "linha de apoio da chamada",
|
|
1370
|
+
"disclaimer": "texto do aviso", "caption": "legenda"}
|
|
1371
|
+
|
|
1372
|
+
|
|
1373
|
+
def text_issues(kind: str, text: str, style_id: str | None = None) -> list[str]:
|
|
1374
|
+
"""PT-BR warnings for text that will not show up exactly as typed.
|
|
1375
|
+
|
|
1376
|
+
kind: "hook" (needs style_id), "caption" (needs style_id), "cta", "cta_sub", "disclaimer".
|
|
1377
|
+
Lists symbols the font cannot draw (they are removed when rendering) and markup problems.
|
|
1378
|
+
"""
|
|
1379
|
+
if not str(text or "").strip():
|
|
1380
|
+
return []
|
|
1381
|
+
label = _TEXT_LABELS.get(kind, kind)
|
|
1382
|
+
if kind == "hook":
|
|
1383
|
+
if style_id not in HOOK_STYLES:
|
|
1384
|
+
raise ValueError(f"estilo de gancho '{style_id}' não existe. Opções: {', '.join(HOOK_STYLES)}")
|
|
1385
|
+
font_key = HOOK_STYLES[style_id]["font"]
|
|
1386
|
+
elif kind == "caption":
|
|
1387
|
+
font_key = _caption_spec(str(style_id)).font
|
|
1388
|
+
else:
|
|
1389
|
+
font_key = _TEXT_FONTS[kind]
|
|
1390
|
+
issues = []
|
|
1391
|
+
if kind == "hook":
|
|
1392
|
+
chars, _, problems = _markup_chars(text)
|
|
1393
|
+
plain = "".join(c for c, _ in chars)
|
|
1394
|
+
issues += [f"{label}: {p}" for p in problems]
|
|
1395
|
+
elif kind in ("cta", "cta_sub", "disclaimer"):
|
|
1396
|
+
plain, had = strip_markup(text)
|
|
1397
|
+
if had:
|
|
1398
|
+
issues.append(f"{label}: não tem destaque com *, os asteriscos foram tirados")
|
|
1399
|
+
else:
|
|
1400
|
+
plain = str(text)
|
|
1401
|
+
probe = plain.upper() if kind == "cta" else plain
|
|
1402
|
+
_, removed = sanitize_text(probe, font_key)
|
|
1403
|
+
if removed:
|
|
1404
|
+
issues.append(f"{label}: tirei símbolos que a fonte não desenha ({' '.join(removed)}). Evite emoji e símbolos no texto")
|
|
1405
|
+
return issues
|
|
1406
|
+
|
|
1407
|
+
|
|
1408
|
+
HOOK_STYLES: dict[str, dict] = {
|
|
1409
|
+
"manchete": {"font": "display_condensed", "upper": True, "max": 158, "min": 84, "side": 0.08},
|
|
1410
|
+
"tarja": {"font": "sans_black", "upper": True, "max": 94, "min": 50, "side": 0.34},
|
|
1411
|
+
"balao": {"font": "sans_extrabold", "upper": False, "max": 82, "min": 46, "side": 0.62},
|
|
1412
|
+
"marcador": {"font": "marker", "upper": True, "max": 104, "min": 56, "side": 0.34},
|
|
1413
|
+
"vidro": {"font": "sans_extrabold", "upper": False, "max": 78, "min": 44, "side": 0.66},
|
|
1414
|
+
}
|
|
1415
|
+
|
|
1416
|
+
|
|
1417
|
+
def hook_style_ids() -> list[str]:
|
|
1418
|
+
return list(HOOK_STYLES)
|
|
1419
|
+
|
|
1420
|
+
|
|
1421
|
+
def _hook_lines(toks: list[_Tok], font_key: str, content_w: float, max_size: float, min_size: float,
|
|
1422
|
+
side_ratio: float) -> tuple[list[list[_Tok]], float]:
|
|
1423
|
+
"""Pick 1 or 2 lines (balanced by measured width) and the fitted font size."""
|
|
1424
|
+
ref = 100.0
|
|
1425
|
+
f = _font(font_key, ref)
|
|
1426
|
+
space = f.getlength(" ")
|
|
1427
|
+
|
|
1428
|
+
def width(ts):
|
|
1429
|
+
return sum(f.getlength(t.text) for t in ts) + space * (len(ts) - 1)
|
|
1430
|
+
|
|
1431
|
+
def size_for(lines):
|
|
1432
|
+
widest = max(width(ln) for ln in lines)
|
|
1433
|
+
per_px = widest / ref + 2 * side_ratio
|
|
1434
|
+
return content_w / per_px if per_px > 0 else max_size
|
|
1435
|
+
|
|
1436
|
+
if not toks:
|
|
1437
|
+
return [[]], max_size
|
|
1438
|
+
one = [toks]
|
|
1439
|
+
s1 = min(max_size, size_for(one))
|
|
1440
|
+
if len(toks) == 1:
|
|
1441
|
+
return one, max(min(s1, max_size), 1.0)
|
|
1442
|
+
best, best_cost = None, None
|
|
1443
|
+
for s in range(1, len(toks)):
|
|
1444
|
+
a, b = toks[:s], toks[s:]
|
|
1445
|
+
wa, wb = width(a), width(b)
|
|
1446
|
+
cost = max(wa, wb)
|
|
1447
|
+
if _is_weak(a[-1].text):
|
|
1448
|
+
cost *= 1.1
|
|
1449
|
+
if len(a) == 1 and len(toks) > 3 and len(a[0].text) <= 3:
|
|
1450
|
+
cost *= 1.15
|
|
1451
|
+
if best_cost is None or cost < best_cost:
|
|
1452
|
+
best, best_cost = [a, b], cost
|
|
1453
|
+
two = best
|
|
1454
|
+
s2 = min(max_size, size_for(two))
|
|
1455
|
+
if s1 >= 0.86 * s2:
|
|
1456
|
+
lines, size = one, s1
|
|
1457
|
+
else:
|
|
1458
|
+
lines, size = two, s2
|
|
1459
|
+
return lines, max(1.0, size)
|
|
1460
|
+
|
|
1461
|
+
|
|
1462
|
+
def _refit(font_key: str, lines: list[list[_Tok]], size: float, content_w: float, side_ratio: float) -> float:
|
|
1463
|
+
for _ in range(4):
|
|
1464
|
+
f = _font(font_key, size)
|
|
1465
|
+
space = f.getlength(" ")
|
|
1466
|
+
widest = max(sum(f.getlength(t.text) for t in ln) + space * (len(ln) - 1) for ln in lines)
|
|
1467
|
+
if widest + 2 * side_ratio * size <= content_w:
|
|
1468
|
+
return size
|
|
1469
|
+
size = math.floor(size * content_w / (widest + 2 * side_ratio * size) * 0.995)
|
|
1470
|
+
return size
|
|
1471
|
+
|
|
1472
|
+
|
|
1473
|
+
def _line_colors(lines, marked, accent_rgba, default_rgba, rule: str):
|
|
1474
|
+
"""Per token colors: marked words get the accent; otherwise a style rule."""
|
|
1475
|
+
cols = []
|
|
1476
|
+
for li, ln in enumerate(lines):
|
|
1477
|
+
row = []
|
|
1478
|
+
for ti, t in enumerate(ln):
|
|
1479
|
+
if marked:
|
|
1480
|
+
row.append(accent_rgba if t.accent else default_rgba)
|
|
1481
|
+
elif rule == "second_line" and len(lines) == 2:
|
|
1482
|
+
row.append(accent_rgba if li == 1 else default_rgba)
|
|
1483
|
+
elif rule in ("second_line", "last_word") and len(lines) == 1 and len(ln) >= 2:
|
|
1484
|
+
row.append(accent_rgba if ti == len(ln) - 1 else default_rgba)
|
|
1485
|
+
else:
|
|
1486
|
+
row.append(default_rgba)
|
|
1487
|
+
cols.append(row)
|
|
1488
|
+
return cols
|
|
1489
|
+
|
|
1490
|
+
|
|
1491
|
+
def _draw_token_line(d, f, x, base_y, toks, colors, stroke=0, stroke_fill=BLACK):
|
|
1492
|
+
space = f.getlength(" ")
|
|
1493
|
+
for t, col in zip(toks, colors):
|
|
1494
|
+
d.text((x, base_y), t.text, font=f, fill=col, anchor="ls", stroke_width=stroke, stroke_fill=stroke_fill)
|
|
1495
|
+
x += f.getlength(t.text) + space
|
|
1496
|
+
|
|
1497
|
+
|
|
1498
|
+
def _tok_width(f, toks) -> float:
|
|
1499
|
+
return sum(f.getlength(t.text) for t in toks) + f.getlength(" ") * (len(toks) - 1)
|
|
1500
|
+
|
|
1501
|
+
|
|
1502
|
+
def _hook_manchete(toks, marked, accent, geo, max_width):
|
|
1503
|
+
st = HOOK_STYLES["manchete"]
|
|
1504
|
+
k = geo.k
|
|
1505
|
+
side = st["side"]
|
|
1506
|
+
lines, size = _hook_lines(toks, st["font"], max_width, st["max"] * k, st["min"] * k, side)
|
|
1507
|
+
size = _refit(st["font"], lines, size, max_width, side)
|
|
1508
|
+
f = _font(st["font"], size)
|
|
1509
|
+
cap = _cap_height(f)
|
|
1510
|
+
stroke = max(2, int(round(size * 0.075)))
|
|
1511
|
+
pitch = cap + size * 0.38
|
|
1512
|
+
widths = [_tok_width(f, ln) for ln in lines]
|
|
1513
|
+
block_w = max(widths)
|
|
1514
|
+
block_h = cap + pitch * (len(lines) - 1)
|
|
1515
|
+
pad = int(stroke + size * 0.4)
|
|
1516
|
+
layer = _new(block_w + 2 * pad, block_h + 2 * pad)
|
|
1517
|
+
acc = _rgba(accent)
|
|
1518
|
+
cols = _line_colors(lines, marked, acc, WHITE, "second_line")
|
|
1519
|
+
d = ImageDraw.Draw(layer)
|
|
1520
|
+
for i, (ln, wl) in enumerate(zip(lines, widths)):
|
|
1521
|
+
base_y = pad + cap + pitch * i
|
|
1522
|
+
_draw_token_line(d, f, pad + (block_w - wl) / 2.0, base_y, ln, cols[i], stroke, BLACK)
|
|
1523
|
+
out = _new(layer.width, layer.height)
|
|
1524
|
+
_alpha_paste(out, _shadow_of(layer, blur=size * 0.09, alpha=0.6), 0, int(size * 0.06))
|
|
1525
|
+
out.alpha_composite(layer)
|
|
1526
|
+
return out, pad + block_w / 2.0, pad + block_h / 2.0, block_h / 2.0 + stroke + size * 0.3
|
|
1527
|
+
|
|
1528
|
+
|
|
1529
|
+
def _hook_tarja(toks, marked, accent, geo, max_width):
|
|
1530
|
+
st = HOOK_STYLES["tarja"]
|
|
1531
|
+
k = geo.k
|
|
1532
|
+
side = st["side"]
|
|
1533
|
+
lines, size = _hook_lines(toks, st["font"], max_width, st["max"] * k, st["min"] * k, side)
|
|
1534
|
+
size = _refit(st["font"], lines, size, max_width, side)
|
|
1535
|
+
f = _font(st["font"], size)
|
|
1536
|
+
cap = _cap_height(f)
|
|
1537
|
+
padx, pad_top, pad_bot = side * size, 0.36 * size, 0.3 * size
|
|
1538
|
+
box_h = cap + pad_top + pad_bot
|
|
1539
|
+
gap = 0.1 * size
|
|
1540
|
+
widths = [_tok_width(f, ln) for ln in lines]
|
|
1541
|
+
block_w = max(widths) + 2 * padx
|
|
1542
|
+
block_h = box_h * len(lines) + gap * (len(lines) - 1)
|
|
1543
|
+
margin = int(size * 0.35)
|
|
1544
|
+
layer = _new(block_w + 2 * margin, block_h + 2 * margin)
|
|
1545
|
+
acc = _rgba(accent)
|
|
1546
|
+
has_accent_line = [any(t.accent for t in ln) for ln in lines]
|
|
1547
|
+
boxes = _new(layer.width, layer.height)
|
|
1548
|
+
texts = []
|
|
1549
|
+
for i, (ln, wl) in enumerate(zip(lines, widths)):
|
|
1550
|
+
if marked:
|
|
1551
|
+
bg = acc if has_accent_line[i] else WHITE
|
|
1552
|
+
else:
|
|
1553
|
+
bg = acc if i == len(lines) - 1 else WHITE
|
|
1554
|
+
bw = wl + 2 * padx
|
|
1555
|
+
x0 = margin + (block_w - bw) / 2.0
|
|
1556
|
+
y0 = margin + i * (box_h + gap)
|
|
1557
|
+
_rounded_box(boxes, (x0, y0, x0 + bw, y0 + box_h), radius=size * 0.14, color=bg)
|
|
1558
|
+
texts.append((x0 + padx, y0 + pad_top + cap, ln, _on_color(bg)))
|
|
1559
|
+
_alpha_paste(layer, _shadow_of(boxes, blur=size * 0.16, alpha=0.4), 0, int(size * 0.08))
|
|
1560
|
+
layer.alpha_composite(boxes)
|
|
1561
|
+
d = ImageDraw.Draw(layer)
|
|
1562
|
+
for x, base_y, ln, col in texts:
|
|
1563
|
+
_draw_token_line(d, f, x, base_y, ln, [col] * len(ln))
|
|
1564
|
+
angle = -2.0
|
|
1565
|
+
ax, ay = margin + block_w / 2.0, margin + block_h / 2.0
|
|
1566
|
+
rot, dx, dy = _rotate(layer, angle)
|
|
1567
|
+
rax, ray = _rotate_point(ax, ay, layer.width / 2.0, layer.height / 2.0, angle)
|
|
1568
|
+
half = block_h / 2.0 + abs(math.sin(math.radians(angle))) * block_w / 2.0
|
|
1569
|
+
return rot, rax + dx, ray + dy, half
|
|
1570
|
+
|
|
1571
|
+
|
|
1572
|
+
def _hook_balao(toks, marked, accent, geo, max_width):
|
|
1573
|
+
st = HOOK_STYLES["balao"]
|
|
1574
|
+
k = geo.k
|
|
1575
|
+
side = st["side"]
|
|
1576
|
+
lines, size = _hook_lines(toks, st["font"], max_width, st["max"] * k, st["min"] * k, side)
|
|
1577
|
+
size = _refit(st["font"], lines, size, max_width, side)
|
|
1578
|
+
f = _font(st["font"], size)
|
|
1579
|
+
asc, desc = f.getmetrics()
|
|
1580
|
+
cap = _cap_height(f)
|
|
1581
|
+
pitch = size * 1.24
|
|
1582
|
+
padx, pady = side * size, 0.5 * size
|
|
1583
|
+
widths = [_tok_width(f, ln) for ln in lines]
|
|
1584
|
+
card_w = max(widths) + 2 * padx
|
|
1585
|
+
text_h = cap + pitch * (len(lines) - 1)
|
|
1586
|
+
card_h = text_h + 2 * pady + 0.22 * size
|
|
1587
|
+
tail = size * 0.42
|
|
1588
|
+
margin = int(size * 0.5)
|
|
1589
|
+
layer = _new(card_w + 2 * margin, card_h + tail + 2 * margin)
|
|
1590
|
+
acc = _rgba(accent)
|
|
1591
|
+
x0, y0 = margin, margin
|
|
1592
|
+
card = _new(layer.width, layer.height)
|
|
1593
|
+
# speech-bubble tail (bottom left), then the card
|
|
1594
|
+
tx = x0 + card_w * 0.2
|
|
1595
|
+
_polygon(card, [(tx, y0 + card_h - 2), (tx + tail * 1.25, y0 + card_h - 2), (tx - tail * 0.1, y0 + card_h + tail)], WHITE)
|
|
1596
|
+
_rounded_box(card, (x0, y0, x0 + card_w, y0 + card_h), radius=size * 0.46, color=WHITE)
|
|
1597
|
+
_alpha_paste(layer, _shadow_of(card, blur=size * 0.22, alpha=0.38), 0, int(size * 0.1))
|
|
1598
|
+
layer.alpha_composite(card)
|
|
1599
|
+
d = ImageDraw.Draw(layer)
|
|
1600
|
+
top_text = y0 + pady + 0.16 * size
|
|
1601
|
+
space = f.getlength(" ")
|
|
1602
|
+
on_acc = _on_color(acc)
|
|
1603
|
+
for i, (ln, wl) in enumerate(zip(lines, widths)):
|
|
1604
|
+
base_y = top_text + cap + pitch * i
|
|
1605
|
+
x = x0 + (card_w - wl) / 2.0
|
|
1606
|
+
for t in ln:
|
|
1607
|
+
tw = f.getlength(t.text)
|
|
1608
|
+
col = INK
|
|
1609
|
+
if marked and t.accent:
|
|
1610
|
+
_rounded_box(layer, (x - size * 0.12, base_y - cap - size * 0.2, x + tw + size * 0.12, base_y + size * 0.2),
|
|
1611
|
+
radius=size * 0.12, color=acc)
|
|
1612
|
+
d = ImageDraw.Draw(layer)
|
|
1613
|
+
col = on_acc
|
|
1614
|
+
d.text((x, base_y), t.text, font=f, fill=col, anchor="ls")
|
|
1615
|
+
x += tw + space
|
|
1616
|
+
return layer, x0 + card_w / 2.0, y0 + card_h / 2.0, card_h / 2.0 + tail
|
|
1617
|
+
|
|
1618
|
+
|
|
1619
|
+
def _hook_marcador(toks, marked, accent, geo, max_width):
|
|
1620
|
+
st = HOOK_STYLES["marcador"]
|
|
1621
|
+
k = geo.k
|
|
1622
|
+
side = st["side"]
|
|
1623
|
+
lines, size = _hook_lines(toks, st["font"], max_width, st["max"] * k, st["min"] * k, side)
|
|
1624
|
+
size = _refit(st["font"], lines, size, max_width, side)
|
|
1625
|
+
f = _font(st["font"], size)
|
|
1626
|
+
cap = _cap_height(f)
|
|
1627
|
+
pitch = cap + size * 0.46
|
|
1628
|
+
widths = [_tok_width(f, ln) for ln in lines]
|
|
1629
|
+
block_w = max(widths) + 2 * side * size
|
|
1630
|
+
block_h = cap + pitch * (len(lines) - 1) + 0.5 * size
|
|
1631
|
+
margin = int(size * 0.4)
|
|
1632
|
+
layer = _new(block_w + 2 * margin, block_h + 2 * margin)
|
|
1633
|
+
acc = _rgba(accent)
|
|
1634
|
+
strokes = _new(layer.width, layer.height)
|
|
1635
|
+
jit = [(0.03, -0.05, 0.06, 0.02), (-0.04, 0.04, -0.02, 0.05)]
|
|
1636
|
+
texts = []
|
|
1637
|
+
for i, (ln, wl) in enumerate(zip(lines, widths)):
|
|
1638
|
+
base_y = margin + 0.25 * size + cap + pitch * i
|
|
1639
|
+
sx0 = margin + (block_w - wl) / 2.0 - side * size
|
|
1640
|
+
sx1 = sx0 + wl + 2 * side * size
|
|
1641
|
+
top, bot = base_y - cap - 0.2 * size, base_y + 0.2 * size
|
|
1642
|
+
j = jit[i % 2]
|
|
1643
|
+
pts = [(sx0 + j[0] * size, top + j[1] * size), (sx1 + j[2] * size, top + j[3] * size + 0.04 * size),
|
|
1644
|
+
(sx1 - 0.06 * size, bot + j[1] * size), (sx0 - 0.04 * size, bot - j[3] * size)]
|
|
1645
|
+
_polygon(strokes, pts, acc)
|
|
1646
|
+
texts.append((margin + (block_w - wl) / 2.0, base_y, ln))
|
|
1647
|
+
_alpha_paste(layer, _shadow_of(strokes, blur=size * 0.14, alpha=0.35), 0, int(size * 0.06))
|
|
1648
|
+
layer.alpha_composite(strokes)
|
|
1649
|
+
d = ImageDraw.Draw(layer)
|
|
1650
|
+
ink = _on_color(acc)
|
|
1651
|
+
for x, base_y, ln in texts:
|
|
1652
|
+
_draw_token_line(d, f, x, base_y, ln, [ink] * len(ln))
|
|
1653
|
+
angle = 2.2
|
|
1654
|
+
ax, ay = margin + block_w / 2.0, margin + block_h / 2.0
|
|
1655
|
+
rot, dx, dy = _rotate(layer, angle)
|
|
1656
|
+
rax, ray = _rotate_point(ax, ay, layer.width / 2.0, layer.height / 2.0, angle)
|
|
1657
|
+
return rot, rax + dx, ray + dy, block_h / 2.0 + abs(math.sin(math.radians(angle))) * block_w / 2.0
|
|
1658
|
+
|
|
1659
|
+
|
|
1660
|
+
def _hook_vidro(toks, marked, accent, geo, max_width):
|
|
1661
|
+
st = HOOK_STYLES["vidro"]
|
|
1662
|
+
k = geo.k
|
|
1663
|
+
side = st["side"]
|
|
1664
|
+
lines, size = _hook_lines(toks, st["font"], max_width, st["max"] * k, st["min"] * k, side)
|
|
1665
|
+
size = _refit(st["font"], lines, size, max_width, side)
|
|
1666
|
+
f = _font(st["font"], size)
|
|
1667
|
+
cap = _cap_height(f)
|
|
1668
|
+
pitch = size * 1.24
|
|
1669
|
+
padx, pady = side * size, 0.62 * size
|
|
1670
|
+
widths = [_tok_width(f, ln) for ln in lines]
|
|
1671
|
+
panel_w = max(widths) + 2 * padx
|
|
1672
|
+
text_h = cap + pitch * (len(lines) - 1) + 0.22 * size
|
|
1673
|
+
panel_h = text_h + 2 * pady
|
|
1674
|
+
margin = int(size * 0.4)
|
|
1675
|
+
layer = _new(panel_w + 2 * margin, panel_h + 2 * margin)
|
|
1676
|
+
acc = _rgba(accent)
|
|
1677
|
+
x0, y0 = margin, margin
|
|
1678
|
+
border = max(2, int(round(size * 0.035)))
|
|
1679
|
+
_rounded_box(layer, (x0, y0, x0 + panel_w, y0 + panel_h), radius=size * 0.42, color=(255, 255, 255, 58))
|
|
1680
|
+
_rounded_box(layer, (x0 + border, y0 + border, x0 + panel_w - border, y0 + panel_h - border),
|
|
1681
|
+
radius=size * 0.42 - border, color=(14, 14, 16, 196))
|
|
1682
|
+
bar_w, bar_h = size * 1.1, size * 0.12
|
|
1683
|
+
_rounded_box(layer, (x0 + (panel_w - bar_w) / 2.0, y0 + pady * 0.42, x0 + (panel_w + bar_w) / 2.0, y0 + pady * 0.42 + bar_h),
|
|
1684
|
+
radius=bar_h / 2.0, color=acc)
|
|
1685
|
+
cols = _line_colors(lines, marked, acc, WHITE, "none")
|
|
1686
|
+
d = ImageDraw.Draw(layer)
|
|
1687
|
+
for i, (ln, wl) in enumerate(zip(lines, widths)):
|
|
1688
|
+
base_y = y0 + pady + 0.2 * size + cap + pitch * i
|
|
1689
|
+
_draw_token_line(d, f, x0 + (panel_w - wl) / 2.0, base_y, ln, cols[i])
|
|
1690
|
+
return layer, x0 + panel_w / 2.0, y0 + panel_h / 2.0, panel_h / 2.0
|
|
1691
|
+
|
|
1692
|
+
|
|
1693
|
+
_HOOK_DRAWERS = {
|
|
1694
|
+
"manchete": _hook_manchete,
|
|
1695
|
+
"tarja": _hook_tarja,
|
|
1696
|
+
"balao": _hook_balao,
|
|
1697
|
+
"marcador": _hook_marcador,
|
|
1698
|
+
"vidro": _hook_vidro,
|
|
1699
|
+
}
|
|
1700
|
+
|
|
1701
|
+
|
|
1702
|
+
def hook_fit(text: str, style_id: str, canvas=(1080, 1920), max_width: int = 900) -> dict:
|
|
1703
|
+
"""How a hook would be laid out: {"lines": [...], "size": px, "min_size": px, "too_long": bool}.
|
|
1704
|
+
|
|
1705
|
+
Hooks never wrap to 3 lines, so a long text only gets smaller; `too_long` tells the agent to
|
|
1706
|
+
shorten it.
|
|
1707
|
+
"""
|
|
1708
|
+
if style_id not in HOOK_STYLES:
|
|
1709
|
+
raise ValueError(f"estilo de gancho '{style_id}' não existe. Opções: {', '.join(HOOK_STYLES)}")
|
|
1710
|
+
st = HOOK_STYLES[style_id]
|
|
1711
|
+
geo = _Geo(int(canvas[0]), int(canvas[1]))
|
|
1712
|
+
width = min(float(max_width), geo.w - 2 * SIDE_MARGIN * geo.sx)
|
|
1713
|
+
toks, _ = _parse_marked(text, st["upper"], st["font"])
|
|
1714
|
+
side = st["side"]
|
|
1715
|
+
lines, size = _hook_lines(toks, st["font"], width, st["max"] * geo.k, st["min"] * geo.k, side)
|
|
1716
|
+
size = _refit(st["font"], lines, size, width, side) if toks else size
|
|
1717
|
+
return {"lines": [" ".join(t.text for t in ln) for ln in lines], "size": int(size),
|
|
1718
|
+
"min_size": int(st["min"] * geo.k), "too_long": size < st["min"] * geo.k}
|
|
1719
|
+
|
|
1720
|
+
|
|
1721
|
+
def render_hook(text: str, style_id: str, accent: str, canvas=(1080, 1920), center_y: int = 430,
|
|
1722
|
+
max_width: int = 900) -> Image.Image:
|
|
1723
|
+
"""Hook headline: always 1-2 lines, balanced by measured width, font size fitted."""
|
|
1724
|
+
if style_id not in HOOK_STYLES:
|
|
1725
|
+
raise ValueError(f"estilo de gancho '{style_id}' não existe. Opções: {', '.join(HOOK_STYLES)}")
|
|
1726
|
+
geo = _Geo(int(canvas[0]), int(canvas[1]))
|
|
1727
|
+
max_width = min(float(max_width), geo.w - 2 * SIDE_MARGIN * geo.sx)
|
|
1728
|
+
toks, marked = _parse_marked(text, HOOK_STYLES[style_id]["upper"], HOOK_STYLES[style_id]["font"])
|
|
1729
|
+
if not toks:
|
|
1730
|
+
return Image.new("RGBA", (geo.w, geo.h), (0, 0, 0, 0))
|
|
1731
|
+
layer, ax, ay, half = _HOOK_DRAWERS[style_id](toks, marked, accent or DEFAULT_ACCENT, geo, max_width)
|
|
1732
|
+
return _place((geo.w, geo.h), _Block(layer, ax, ay, half), geo.cx, float(center_y), {}, geo)
|
|
1733
|
+
|
|
1734
|
+
|
|
1735
|
+
# ============================================================== CTA and disclaimer
|
|
1736
|
+
|
|
1737
|
+
def render_cta(text: str, sub: str | None, accent: str, canvas=(1080, 1920),
|
|
1738
|
+
center_y: float | None = None) -> Image.Image:
|
|
1739
|
+
"""Call-to-action button (accent pill + arrow) with an optional line below.
|
|
1740
|
+
|
|
1741
|
+
Default center y: layout_defaults(canvas)["cta_alone"] (1180 on 1080x1920). Asterisks and
|
|
1742
|
+
symbols the font cannot draw are removed (text_issues reports them)."""
|
|
1743
|
+
geo = _Geo(int(canvas[0]), int(canvas[1]))
|
|
1744
|
+
k = geo.k
|
|
1745
|
+
acc = _rgba(accent or DEFAULT_ACCENT)
|
|
1746
|
+
on = _on_color(acc)
|
|
1747
|
+
label, _ = sanitize_text(strip_markup(str(text or "").strip())[0].upper(), _TEXT_FONTS["cta"])
|
|
1748
|
+
label = " ".join(label.split())
|
|
1749
|
+
if sub is not None:
|
|
1750
|
+
sub = " ".join(sanitize_text(strip_markup(str(sub))[0], _TEXT_FONTS["cta_sub"])[0].split())
|
|
1751
|
+
max_w = min(860 * geo.sx, geo.w - 2 * SIDE_MARGIN * geo.sx)
|
|
1752
|
+
size = 66 * k
|
|
1753
|
+
for _ in range(6):
|
|
1754
|
+
f = _font("sans_black", size)
|
|
1755
|
+
h = size * 2.05
|
|
1756
|
+
icon = h * 0.64
|
|
1757
|
+
w = f.getlength(label) + size * 1.1 + icon + size * 0.5
|
|
1758
|
+
if w <= max_w or size <= 30 * k:
|
|
1759
|
+
break
|
|
1760
|
+
size = math.floor(size * max_w / w * 0.99)
|
|
1761
|
+
f = _font("sans_black", size)
|
|
1762
|
+
cap = _cap_height(f)
|
|
1763
|
+
h = size * 2.05
|
|
1764
|
+
icon = h * 0.64
|
|
1765
|
+
text_w = f.getlength(label)
|
|
1766
|
+
w = text_w + size * 1.1 + icon + size * 0.5
|
|
1767
|
+
margin = int(size * 0.6)
|
|
1768
|
+
layer = _new(w + 2 * margin, h + 2 * margin)
|
|
1769
|
+
x0, y0 = margin, margin
|
|
1770
|
+
pill = _new(layer.width, layer.height)
|
|
1771
|
+
_rounded_box(pill, (x0, y0, x0 + w, y0 + h), radius=h / 2.0, color=acc)
|
|
1772
|
+
_alpha_paste(layer, _shadow_of(pill, blur=size * 0.3, alpha=0.45), 0, int(size * 0.14))
|
|
1773
|
+
layer.alpha_composite(pill)
|
|
1774
|
+
# inner highlight line for a pressed-button feel
|
|
1775
|
+
_rounded_box(layer, (x0 + h * 0.3, y0 + h * 0.1, x0 + w - h * 0.3, y0 + h * 0.1 + max(2, size * 0.05)),
|
|
1776
|
+
radius=size * 0.03, color=(255, 255, 255, 70))
|
|
1777
|
+
d = ImageDraw.Draw(layer)
|
|
1778
|
+
tx = x0 + size * 0.75
|
|
1779
|
+
d.text((tx, y0 + h / 2.0 + cap / 2.0), label, font=f, fill=on, anchor="ls")
|
|
1780
|
+
# round icon with an arrow
|
|
1781
|
+
icx = x0 + w - size * 0.32 - icon / 2.0
|
|
1782
|
+
icy = y0 + h / 2.0
|
|
1783
|
+
_rounded_box(layer, (icx - icon / 2, icy - icon / 2, icx + icon / 2, icy + icon / 2), radius=icon / 2, color=on)
|
|
1784
|
+
a = icon * 0.24
|
|
1785
|
+
shaft = icon * 0.09
|
|
1786
|
+
arrow = [(icx - a, icy - shaft), (icx + a * 0.1, icy - shaft), (icx + a * 0.1, icy - a * 0.75), (icx + a * 1.05, icy),
|
|
1787
|
+
(icx + a * 0.1, icy + a * 0.75), (icx + a * 0.1, icy + shaft), (icx - a, icy + shaft)]
|
|
1788
|
+
_polygon(layer, arrow, acc)
|
|
1789
|
+
img = Image.new("RGBA", (geo.w, geo.h), (0, 0, 0, 0))
|
|
1790
|
+
cy = layout_defaults((geo.w, geo.h))["cta_alone"] if center_y is None else float(center_y)
|
|
1791
|
+
_alpha_paste(img, layer, int(round(geo.cx - (x0 + w / 2.0))), int(round(cy - (y0 + h / 2.0))))
|
|
1792
|
+
if sub and str(sub).strip():
|
|
1793
|
+
s_size = 42 * k
|
|
1794
|
+
s_text = str(sub).strip()
|
|
1795
|
+
for _ in range(4):
|
|
1796
|
+
sf = _font("text_bold", s_size)
|
|
1797
|
+
if sf.getlength(s_text) <= max_w or s_size <= 24 * k:
|
|
1798
|
+
break
|
|
1799
|
+
s_size = math.floor(s_size * max_w / sf.getlength(s_text) * 0.99)
|
|
1800
|
+
sf = _font("text_bold", s_size)
|
|
1801
|
+
s_cap = _cap_height(sf)
|
|
1802
|
+
stroke = max(1, int(round(s_size * 0.06)))
|
|
1803
|
+
sw = sf.getlength(s_text)
|
|
1804
|
+
pad = int(s_size * 0.6)
|
|
1805
|
+
sl = _new(sw + 2 * pad, s_size * 1.6 + 2 * pad)
|
|
1806
|
+
ImageDraw.Draw(sl).text((pad, pad + s_size * 0.3 + s_cap), s_text, font=sf, fill=WHITE, anchor="ls",
|
|
1807
|
+
stroke_width=stroke, stroke_fill=(0, 0, 0, 255))
|
|
1808
|
+
out = _new(sl.width, sl.height)
|
|
1809
|
+
_alpha_paste(out, _shadow_of(sl, blur=s_size * 0.25, alpha=0.75), 0, int(s_size * 0.06))
|
|
1810
|
+
out.alpha_composite(sl)
|
|
1811
|
+
sy = cy + h / 2.0 + s_size * 0.55 + s_cap / 2.0
|
|
1812
|
+
_alpha_paste(img, out, int(round(geo.cx - (pad + sw / 2.0))), int(round(sy - (pad + s_size * 0.3 + s_cap / 2.0))))
|
|
1813
|
+
return img
|
|
1814
|
+
|
|
1815
|
+
|
|
1816
|
+
def render_disclaimer(text: str, canvas=(1080, 1920), center_y: float | None = None) -> Image.Image:
|
|
1817
|
+
"""Small dark pill with the result disclaimer (up to 2 lines).
|
|
1818
|
+
|
|
1819
|
+
Default center y: layout_defaults(canvas)["disclaimer"] (1548 on 1080x1920)."""
|
|
1820
|
+
geo = _Geo(int(canvas[0]), int(canvas[1]))
|
|
1821
|
+
k = geo.k
|
|
1822
|
+
msg = " ".join(sanitize_text(strip_markup(str(text or ""))[0], _TEXT_FONTS["disclaimer"])[0].split())
|
|
1823
|
+
if center_y is None:
|
|
1824
|
+
center_y = layout_defaults((geo.w, geo.h))["disclaimer"]
|
|
1825
|
+
img = Image.new("RGBA", (geo.w, geo.h), (0, 0, 0, 0))
|
|
1826
|
+
if not msg:
|
|
1827
|
+
return img
|
|
1828
|
+
max_w = geo.w - 2 * SIDE_MARGIN * geo.sx
|
|
1829
|
+
size = 31 * k
|
|
1830
|
+
padx, pady = size * 0.7, size * 0.36
|
|
1831
|
+
words = msg.split()
|
|
1832
|
+
for _ in range(6):
|
|
1833
|
+
f = _font("text_semibold", size)
|
|
1834
|
+
one_w = f.getlength(msg)
|
|
1835
|
+
if one_w + 2 * padx <= max_w:
|
|
1836
|
+
lines = [words]
|
|
1837
|
+
else:
|
|
1838
|
+
lines = _balanced_split(words, lambda ws: f.getlength(" ".join(ws)))
|
|
1839
|
+
widest = max(f.getlength(" ".join(ln)) for ln in lines)
|
|
1840
|
+
if widest + 2 * padx <= max_w or size <= 20 * k:
|
|
1841
|
+
break
|
|
1842
|
+
size = math.floor(size * (max_w - 2 * padx) / widest * 0.99)
|
|
1843
|
+
padx, pady = size * 0.7, size * 0.36
|
|
1844
|
+
f = _font("text_semibold", size)
|
|
1845
|
+
asc, desc = f.getmetrics()
|
|
1846
|
+
pitch = size * 1.25
|
|
1847
|
+
widths = [f.getlength(" ".join(ln)) for ln in lines]
|
|
1848
|
+
box_w = max(widths) + 2 * padx
|
|
1849
|
+
box_h = asc + desc + pitch * (len(lines) - 1) + 2 * pady
|
|
1850
|
+
layer = _new(box_w + 4, box_h + 4)
|
|
1851
|
+
_rounded_box(layer, (2, 2, 2 + box_w, 2 + box_h), radius=min(size * 0.6, box_h / 2), color=(0, 0, 0, 128))
|
|
1852
|
+
d = ImageDraw.Draw(layer)
|
|
1853
|
+
for i, (ln, wl) in enumerate(zip(lines, widths)):
|
|
1854
|
+
d.text((2 + (box_w - wl) / 2.0, 2 + pady + asc + pitch * i), " ".join(ln), font=f,
|
|
1855
|
+
fill=(255, 255, 255, 240), anchor="ls")
|
|
1856
|
+
cy = float(center_y)
|
|
1857
|
+
cy = min(cy, geo.h - box_h / 2.0 - 8 * geo.sy)
|
|
1858
|
+
_alpha_paste(img, layer, int(round(geo.cx - (2 + box_w / 2.0))), int(round(cy - (2 + box_h / 2.0))))
|
|
1859
|
+
return img
|
|
1860
|
+
|
|
1861
|
+
|
|
1862
|
+
# ============================================================== samples
|
|
1863
|
+
|
|
1864
|
+
def cover_image(img: Image.Image, size: tuple[int, int]) -> Image.Image:
|
|
1865
|
+
"""Scale to fill `size` and center-crop (like CSS object-fit: cover)."""
|
|
1866
|
+
w, h = size
|
|
1867
|
+
s = max(w / img.width, h / img.height)
|
|
1868
|
+
nw, nh = max(1, int(round(img.width * s))), max(1, int(round(img.height * s)))
|
|
1869
|
+
r = img.resize((nw, nh), Image.LANCZOS)
|
|
1870
|
+
x, y = (nw - w) // 2, (nh - h) // 2
|
|
1871
|
+
return r.crop((x, y, x + w, y + h))
|
|
1872
|
+
|
|
1873
|
+
|
|
1874
|
+
def sample_caption_words(text: str, style_id: str) -> tuple[list[str], int]:
|
|
1875
|
+
"""Words shown in a caption sample and which one is active."""
|
|
1876
|
+
spec = _caption_spec(style_id)
|
|
1877
|
+
words = display_words(str(text).split(), style_id)
|
|
1878
|
+
if not words:
|
|
1879
|
+
words = display_words("Olha só isso aqui".split(), style_id)
|
|
1880
|
+
limit = _Geo(REF_W, REF_H).caption_max_w
|
|
1881
|
+
chosen: list[str] = []
|
|
1882
|
+
for w in words:
|
|
1883
|
+
if len(chosen) >= spec.max_words:
|
|
1884
|
+
break
|
|
1885
|
+
trial = chosen + [w]
|
|
1886
|
+
if chosen and spec.lines == 1 and measure_caption_width(trial, style_id, already_display=True) > limit:
|
|
1887
|
+
break
|
|
1888
|
+
if chosen and spec.lines >= 2 and measure_caption_width(trial, style_id, already_display=True) > limit * spec.lines * 0.92:
|
|
1889
|
+
break
|
|
1890
|
+
chosen.append(w)
|
|
1891
|
+
active = -1 if not spec.animated else (0 if spec.one_word else min(1, len(chosen) - 1))
|
|
1892
|
+
return chosen, active
|
|
1893
|
+
|
|
1894
|
+
|
|
1895
|
+
def render_caption_image(words: list[str], style_id: str, accent: str, canvas=(1080, 1920),
|
|
1896
|
+
active: int = 0, center_y: float | None = None, font_scale: float = 1.0) -> Image.Image:
|
|
1897
|
+
"""One steady caption state (display words already cased) on a transparent canvas."""
|
|
1898
|
+
spec = _caption_spec(style_id)
|
|
1899
|
+
geo = _Geo(int(canvas[0]), int(canvas[1]))
|
|
1900
|
+
cy = layout_defaults((geo.w, geo.h))["caption"] if center_y is None else center_y
|
|
1901
|
+
variant = {"angle": -2.6} if spec.draw == "fita" else {}
|
|
1902
|
+
block = _caption_block(spec, geo, tuple(words), active, accent or DEFAULT_ACCENT, font_scale, variant, {})
|
|
1903
|
+
return _place((geo.w, geo.h), block, geo.cx, cy, {}, geo)
|
|
1904
|
+
|
|
1905
|
+
|
|
1906
|
+
def sample_canvas(background_size) -> tuple[int, int]:
|
|
1907
|
+
"""Full-size canvas a style sample is composed on, for a frame of this size."""
|
|
1908
|
+
bw, bh = background_size
|
|
1909
|
+
if bw < bh:
|
|
1910
|
+
return (REF_W, REF_H)
|
|
1911
|
+
if bw > bh:
|
|
1912
|
+
return (REF_H, REF_W)
|
|
1913
|
+
return (REF_W, REF_W)
|
|
1914
|
+
|
|
1915
|
+
|
|
1916
|
+
def render_sample_frame(background: Image.Image, kind: str, style_id: str, accent: str,
|
|
1917
|
+
text: str, center_y: float | None = None) -> Image.Image:
|
|
1918
|
+
"""Style preview over a real frame. Returns an RGB image with the background's size.
|
|
1919
|
+
|
|
1920
|
+
`center_y` (pixels on sample_canvas(background.size)) moves the text, e.g. off the face;
|
|
1921
|
+
default: layout_defaults of that canvas."""
|
|
1922
|
+
bw, bh = background.size
|
|
1923
|
+
canvas = sample_canvas((bw, bh))
|
|
1924
|
+
base = cover_image(background.convert("RGB"), canvas).convert("RGBA")
|
|
1925
|
+
lay = layout_defaults(canvas)
|
|
1926
|
+
if kind == "caption":
|
|
1927
|
+
words, active = sample_caption_words(text, style_id)
|
|
1928
|
+
over = render_caption_image(words, style_id, accent, canvas, active=active,
|
|
1929
|
+
center_y=lay["caption"] if center_y is None else center_y)
|
|
1930
|
+
elif kind == "hook":
|
|
1931
|
+
over = render_hook(text, style_id, accent, canvas, center_y=lay["hook"] if center_y is None else center_y)
|
|
1932
|
+
else:
|
|
1933
|
+
raise ValueError("kind precisa ser 'caption' ou 'hook'")
|
|
1934
|
+
base.alpha_composite(over)
|
|
1935
|
+
out = base.convert("RGB")
|
|
1936
|
+
if out.size != (bw, bh):
|
|
1937
|
+
out = out.resize((bw, bh), Image.LANCZOS)
|
|
1938
|
+
return out
|
|
1939
|
+
|
|
1940
|
+
|
|
1941
|
+
# ============================================================== CLI
|
|
1942
|
+
|
|
1943
|
+
def _catalog_check() -> list[str]:
|
|
1944
|
+
cat = load_style_catalog()
|
|
1945
|
+
problems = []
|
|
1946
|
+
for kind, ids in (("caption_styles", set(CAPTION_STYLES)), ("hook_styles", set(HOOK_STYLES))):
|
|
1947
|
+
cat_ids = {s.get("id") for s in cat.get(kind, [])}
|
|
1948
|
+
if cat_ids != ids:
|
|
1949
|
+
problems.append(f"{kind}: catálogo {sorted(cat_ids)} x código {sorted(ids)}")
|
|
1950
|
+
return problems
|
|
1951
|
+
|
|
1952
|
+
|
|
1953
|
+
def _save_with_bg(img: Image.Image, out: str, bg: str | None) -> None:
|
|
1954
|
+
if bg:
|
|
1955
|
+
with Image.open(bg) as b:
|
|
1956
|
+
base = cover_image(b.convert("RGB"), img.size).convert("RGBA")
|
|
1957
|
+
base.alpha_composite(img)
|
|
1958
|
+
img = base.convert("RGB")
|
|
1959
|
+
Path(out).parent.mkdir(parents=True, exist_ok=True)
|
|
1960
|
+
img.save(out)
|
|
1961
|
+
print(f"ok: {out}")
|
|
1962
|
+
|
|
1963
|
+
|
|
1964
|
+
def main(argv: list[str] | None = None) -> int:
|
|
1965
|
+
ap = argparse.ArgumentParser(description="Renderiza textos (legendas, gancho, chamada, aviso) em PNG transparente.")
|
|
1966
|
+
sub = ap.add_subparsers(dest="cmd", required=True)
|
|
1967
|
+
sub.add_parser("list", help="lista os estilos disponíveis")
|
|
1968
|
+
|
|
1969
|
+
def common_args(p):
|
|
1970
|
+
p.add_argument("--accent", default=DEFAULT_ACCENT, help="cor de destaque em hex (padrão #FFC400)")
|
|
1971
|
+
p.add_argument("--width", type=int, default=REF_W)
|
|
1972
|
+
p.add_argument("--height", type=int, default=REF_H)
|
|
1973
|
+
p.add_argument("--bg", help="imagem de fundo para conferir (opcional)")
|
|
1974
|
+
p.add_argument("-o", "--output", required=True, help="PNG de saída")
|
|
1975
|
+
|
|
1976
|
+
p = sub.add_parser("hook", help="gancho (1 ou 2 linhas)")
|
|
1977
|
+
p.add_argument("text")
|
|
1978
|
+
p.add_argument("--style", required=True)
|
|
1979
|
+
p.add_argument("--center-y", type=float, help="centro vertical em pixels (padrão: 430 na tela vertical)")
|
|
1980
|
+
common_args(p)
|
|
1981
|
+
p = sub.add_parser("caption", help="um estado de legenda")
|
|
1982
|
+
p.add_argument("text")
|
|
1983
|
+
p.add_argument("--style", required=True)
|
|
1984
|
+
p.add_argument("--active", type=int, default=1)
|
|
1985
|
+
common_args(p)
|
|
1986
|
+
p = sub.add_parser("cta", help="botão de chamada")
|
|
1987
|
+
p.add_argument("text")
|
|
1988
|
+
p.add_argument("--sub")
|
|
1989
|
+
p.add_argument("--center-y", type=float, help="centro vertical em pixels (padrão: 1180 na tela vertical)")
|
|
1990
|
+
common_args(p)
|
|
1991
|
+
p = sub.add_parser("disclaimer", help="aviso de resultado")
|
|
1992
|
+
p.add_argument("text")
|
|
1993
|
+
p.add_argument("--center-y", type=float, help="centro vertical em pixels (padrão: 1548 na tela vertical)")
|
|
1994
|
+
common_args(p)
|
|
1995
|
+
p = sub.add_parser("track", help="trilha de legendas: PNGs + captions.ffconcat")
|
|
1996
|
+
p.add_argument("captions", help="captions.json")
|
|
1997
|
+
p.add_argument("--style", required=True)
|
|
1998
|
+
p.add_argument("--accent", default=DEFAULT_ACCENT)
|
|
1999
|
+
p.add_argument("--fps", type=int, default=30)
|
|
2000
|
+
p.add_argument("--width", type=int, default=REF_W)
|
|
2001
|
+
p.add_argument("--height", type=int, default=REF_H)
|
|
2002
|
+
p.add_argument("--duration", type=float, help="duração total do vídeo (padrão: fim da última legenda)")
|
|
2003
|
+
p.add_argument("--font-scale", type=float, default=1.0)
|
|
2004
|
+
p.add_argument("-o", "--out-dir", required=True)
|
|
2005
|
+
args = ap.parse_args(argv)
|
|
2006
|
+
|
|
2007
|
+
try:
|
|
2008
|
+
if args.cmd == "list":
|
|
2009
|
+
print("legendas: " + ", ".join(CAPTION_STYLES))
|
|
2010
|
+
print("ganchos: " + ", ".join(HOOK_STYLES))
|
|
2011
|
+
for msg in _catalog_check():
|
|
2012
|
+
print("AVISO: " + msg)
|
|
2013
|
+
return 0
|
|
2014
|
+
canvas = (args.width, args.height)
|
|
2015
|
+
issues: list[str] = []
|
|
2016
|
+
if args.cmd == "hook":
|
|
2017
|
+
fit = hook_fit(args.text, args.style, canvas)
|
|
2018
|
+
issues = text_issues("hook", args.text, args.style)
|
|
2019
|
+
cy = layout_defaults(canvas)["hook"] if args.center_y is None else args.center_y
|
|
2020
|
+
_save_with_bg(render_hook(args.text, args.style, args.accent, canvas, cy), args.output, args.bg)
|
|
2021
|
+
print(f"linhas: {' / '.join(fit['lines'])} · letra {fit['size']}px")
|
|
2022
|
+
if fit["too_long"]:
|
|
2023
|
+
print(f"aviso: gancho longo demais, a letra ficou pequena ({fit['size']}px, ideal a partir de "
|
|
2024
|
+
f"{fit['min_size']}px). Encurte o texto.")
|
|
2025
|
+
elif args.cmd == "caption":
|
|
2026
|
+
spec = _caption_spec(args.style)
|
|
2027
|
+
words = display_words(args.text.split(), args.style)
|
|
2028
|
+
active = -1 if not spec.animated else max(-1, min(args.active, len(words) - 1))
|
|
2029
|
+
_save_with_bg(render_caption_image(words, args.style, args.accent, canvas, active=active), args.output, args.bg)
|
|
2030
|
+
elif args.cmd == "cta":
|
|
2031
|
+
issues = text_issues("cta", args.text) + text_issues("cta_sub", args.sub or "")
|
|
2032
|
+
_save_with_bg(render_cta(args.text, args.sub, args.accent, canvas, args.center_y), args.output, args.bg)
|
|
2033
|
+
elif args.cmd == "disclaimer":
|
|
2034
|
+
issues = text_issues("disclaimer", args.text)
|
|
2035
|
+
_save_with_bg(render_disclaimer(args.text, canvas, args.center_y), args.output, args.bg)
|
|
2036
|
+
elif args.cmd == "track":
|
|
2037
|
+
src = Path(args.captions)
|
|
2038
|
+
if not src.exists():
|
|
2039
|
+
print(f"erro: arquivo de legendas não encontrado: {src}", file=sys.stderr)
|
|
2040
|
+
return 2
|
|
2041
|
+
data = json.loads(src.read_text(encoding="utf-8"))
|
|
2042
|
+
t0 = time.time()
|
|
2043
|
+
states = render_caption_track(data, args.style, args.accent, args.out_dir, canvas, args.fps,
|
|
2044
|
+
font_scale=args.font_scale)
|
|
2045
|
+
total = args.duration if args.duration else (states[-1]["end"] if states else 0.0)
|
|
2046
|
+
out_dir = Path(args.out_dir).resolve()
|
|
2047
|
+
ff = write_ffconcat(states, str(out_dir / "captions.ffconcat"), total, str(out_dir / "blank.png"), fps=args.fps)
|
|
2048
|
+
uniq = len({s["file"] for s in states})
|
|
2049
|
+
print(f"ok: {len(states)} estados, {uniq} PNGs, {time.time() - t0:.1f}s -> {ff}")
|
|
2050
|
+
for msg in issues:
|
|
2051
|
+
print(f"aviso: {msg}")
|
|
2052
|
+
except (ValueError, FileNotFoundError) as e:
|
|
2053
|
+
print(f"erro: {e}", file=sys.stderr)
|
|
2054
|
+
return 2
|
|
2055
|
+
return 0
|
|
2056
|
+
|
|
2057
|
+
|
|
2058
|
+
if __name__ == "__main__":
|
|
2059
|
+
sys.exit(main())
|