Files

254 lines
12 KiB
Python

"""Timeline graphics (images and text) composited over the montage.
Text is rasterised to a transparent PNG with Pillow and then composited like any
other image, so export does not depend on ffmpeg having the `drawtext` filter
(minimal builds do not). Flips are baked into that PNG for the same reason.
"""
from __future__ import annotations
import os
import re
from typing import Callable
_FONTS = {
"arial": ["/System/Library/Fonts/Supplemental/Arial.ttf", "/Library/Fonts/Arial.ttf",
"/usr/share/fonts/truetype/msttcorefonts/Arial.ttf",
"/usr/share/fonts/truetype/liberation/LiberationSans-Regular.ttf"],
"helvetica": ["/System/Library/Fonts/Helvetica.ttc", "/System/Library/Fonts/Supplemental/Arial.ttf",
"/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf"],
"georgia": ["/System/Library/Fonts/Supplemental/Georgia.ttf", "/Library/Fonts/Georgia.ttf",
"/usr/share/fonts/truetype/msttcorefonts/Georgia.ttf"],
"times": ["/System/Library/Fonts/Supplemental/Times New Roman.ttf", "/Library/Fonts/Times New Roman.ttf",
"/usr/share/fonts/truetype/msttcorefonts/Times_New_Roman.ttf",
"/usr/share/fonts/truetype/liberation/LiberationSerif-Regular.ttf"],
"courier": ["/System/Library/Fonts/Supplemental/Courier New.ttf", "/Library/Fonts/Courier New.ttf",
"/usr/share/fonts/truetype/msttcorefonts/Courier_New.ttf",
"/usr/share/fonts/truetype/liberation/LiberationMono-Regular.ttf"],
"verdana": ["/System/Library/Fonts/Supplemental/Verdana.ttf", "/Library/Fonts/Verdana.ttf",
"/usr/share/fonts/truetype/msttcorefonts/Verdana.ttf"],
"impact": ["/System/Library/Fonts/Supplemental/Impact.ttf", "/Library/Fonts/Impact.ttf"],
}
_FALLBACK = ["/System/Library/Fonts/Supplemental/Arial.ttf", "/Library/Fonts/Arial.ttf",
"/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf",
"/usr/share/fonts/truetype/liberation/LiberationSans-Regular.ttf"]
def _num(value, default, lo=None, hi=None) -> float:
"""A finite float from whatever the editor stored; junk reads as `default`."""
try:
v = float(value)
except (TypeError, ValueError):
return default
if v != v or v in (float("inf"), float("-inf")):
return default
if lo is not None:
v = max(lo, v)
return min(hi, v) if hi is not None else v
def _fontfile(family) -> str | None:
key = str(family or "system-ui").strip().lower()
if key in ("", "system-ui", "default"):
key = "arial"
for path in _FONTS.get(key, []) + _FALLBACK:
if os.path.isfile(path):
return path
return None
def _rgba(color, opacity) -> tuple[int, int, int, int]:
c = str(color or "#ffffff").strip().lstrip("#")
if len(c) in (3, 4): # #rgb / #rgba
c = "".join(ch * 2 for ch in c)
alpha = 1.0
if len(c) == 8: # #rrggbbaa: its alpha scales the overlay's opacity
try:
alpha, c = int(c[6:], 16) / 255, c[:6]
except ValueError:
c = "ffffff"
opacity = _num(opacity, 1.0, 0.0, 1.0) * alpha
try:
r, g, b = (int(c[i:i + 2], 16) for i in (0, 2, 4)) if len(c) == 6 else (255, 255, 255)
except ValueError:
r, g, b = 255, 255, 255
return r, g, b, int(opacity * 255)
def _variant(regular: str, bold: bool, italic: bool) -> str | None:
"""A bold/italic sibling of a regular font file, by the usual naming patterns."""
if not regular or not (bold or italic):
return None
folder, (base, ext) = os.path.dirname(regular), os.path.splitext(os.path.basename(regular))
if ext.lower() == ".ttc":
return None
suffixes = ([" Bold Italic", "-BoldItalic", "-BoldOblique", " BdIt"] if bold and italic else
[" Bold", "-Bold", " Bd"] if bold else [" Italic", "-Italic", "-Oblique", " It"])
stem = re.sub(r"[ -]?Regular$", "", base)
for suffix in suffixes:
cand = os.path.join(folder, f"{stem}{suffix}{ext}")
if os.path.isfile(cand):
return cand
return None
def _font(family, size: int, bold: bool, italic: bool):
"""(font, has real bold, has real italic): the caller fakes the faces that were not found."""
from PIL import ImageFont
size = max(8, int(size))
path = _fontfile(family)
if path:
variant = _variant(path, bold, italic)
for cand, got in ((variant, (bold, italic)), (path, (False, False))):
if cand:
try:
return ImageFont.truetype(cand, size), got[0], got[1]
except OSError:
pass
for cand in _FALLBACK:
try:
return ImageFont.truetype(cand, size), False, False
except OSError:
continue
return ImageFont.load_default(), False, False
def text_png(ov: dict, out_path: str) -> tuple[int, int]:
"""Rasterise a text overlay to a transparent PNG; returns its (width, height)."""
from PIL import Image, ImageDraw
text = re.sub(r"\r\n?", "\n", str(ov.get("text") or "Text")).strip() or "Text"
size = max(8, int(_num(ov.get("font_size"), 42)))
opacity = _num(ov.get("opacity"), 1.0, 0.0, 1.0)
fill = _rgba(ov.get("color"), opacity)
bold, italic = bool(ov.get("bold")), bool(ov.get("italic"))
font, has_bold, has_italic = _font(ov.get("font_family"), size, bold, italic)
align = str(ov.get("text_align") or "center").lower()
align = align if align in ("left", "center", "right") else "center"
spacing = max(0, int(round(size * (_num(ov.get("line_spacing"), 1.2) - 1))))
stroke = max(0, int(round(_num(ov.get("stroke_width"), 0))))
faux_bold = max(1, int(round(size * 0.045))) if bold and not has_bold else 0
eff_stroke = stroke + faux_bold
stroke_fill = _rgba(ov.get("stroke_color") or "#000000", opacity) if stroke > 0 else (fill if faux_bold else None)
shadow = bool(ov.get("shadow"))
shadow_off = max(1, int(round(size * 0.06)))
shadow_fill = _rgba(ov.get("shadow_color") or "#000000", opacity * 0.85) if shadow else None
draw = ImageDraw.Draw(Image.new("RGBA", (4, 4)))
bx0, by0, bx1, by1 = (int(round(v)) for v in draw.multiline_textbbox(
(0, 0), text, font=font, spacing=spacing, align=align, stroke_width=eff_stroke))
margin = eff_stroke + (shadow_off if shadow else 0) + max(4, size // 8)
layer = Image.new("RGBA", (max(1, bx1 - bx0) + margin * 2 + shadow_off,
max(1, by1 - by0) + margin * 2 + shadow_off), (0, 0, 0, 0))
draw = ImageDraw.Draw(layer)
ox, oy = margin - bx0, margin - by0
if shadow:
draw.multiline_text((ox + shadow_off, oy + shadow_off), text, font=font, fill=shadow_fill, spacing=spacing,
align=align, stroke_width=eff_stroke, stroke_fill=shadow_fill)
draw.multiline_text((ox, oy), text, font=font, fill=fill, spacing=spacing, align=align,
stroke_width=eff_stroke, stroke_fill=stroke_fill)
if italic and not has_italic: # no italic face: shear the layer
shear = 0.21
layer = layer.transform((layer.width + int(shear * layer.height), layer.height), Image.Transform.AFFINE,
(1, shear, -shear * layer.height, 0, 1, 0), resample=Image.Resampling.BICUBIC)
crop = layer.getbbox()
if crop:
layer = layer.crop(crop)
if ov.get("bg_enabled"):
px, py = max(2, int(round(size * 0.4))), max(2, int(round(size * 0.22)))
bg = Image.new("RGBA", (layer.width + px * 2, layer.height + py * 2), (0, 0, 0, 0))
bg_op = _num(ov.get("bg_opacity"), 0.5)
ImageDraw.Draw(bg).rounded_rectangle(
[0, 0, bg.width - 1, bg.height - 1], radius=max(0, int(round(size * 0.1))),
fill=_rgba(ov.get("bg_color") or "#000000", bg_op * opacity))
bg.alpha_composite(layer, (px, py))
layer = bg
if ov.get("flip_h"):
layer = layer.transpose(Image.Transpose.FLIP_LEFT_RIGHT)
if ov.get("flip_v"):
layer = layer.transpose(Image.Transpose.FLIP_TOP_BOTTOM)
os.makedirs(os.path.dirname(out_path) or ".", exist_ok=True)
layer.save(out_path, "PNG")
return layer.size
def in_stacking_order(overlays: list[dict], lanes: list[dict] | None) -> list[dict]:
"""Bottom lanes first, then by start time within a lane."""
lanes = [lane for lane in (lanes or []) if isinstance(lane, dict)]
if not lanes:
return list(overlays or [])
order = {str(lane.get("id") or ""): i for i, lane in enumerate(lanes)}
first = str(lanes[0].get("id") or "")
return sorted(overlays or [], key=lambda ov: (order.get(str(ov.get("lane_id") or first), 0),
_num(ov.get("start_sec"), 0.0)))
def prepare(overlays: list[dict], lanes: list[dict] | None, *, tempdir: str,
image_path: Callable[[str | None], str | None]) -> tuple[list[dict], list[str]]:
"""(overlays ready to composite, the image files they read -- one per overlay, in order).
A text overlay becomes an image overlay at its rendered size; an image overlay whose file is
gone is left out (the caller says so by comparing counts)."""
ready, paths = [], []
for i, ov in enumerate(in_stacking_order(overlays, lanes)):
kind = ov.get("kind") or "image"
if kind == "text":
png = os.path.join(tempdir, f"ov_text_{re.sub(r'[^A-Za-z0-9_-]', '', str(ov.get('id') or i))}.png")
w, h = text_png(ov, png)
paths.append(png)
ready.append({**ov, "kind": "image", "width_px": w, "height_px": h, "keep_aspect": False,
"flip_h": False, "flip_v": False})
elif kind == "image":
src = image_path(ov.get("media_ref"))
if src and os.path.isfile(src):
paths.append(src)
ready.append(dict(ov))
return ready, paths
def _size(ov: dict, canvas_w: int) -> tuple[int, int | None]:
"""(width px, height px or None to keep the aspect ratio)."""
wpx = ov.get("width_px")
if wpx is not None:
tw = max(8, int(_num(wpx, 8)))
if ov.get("keep_aspect", True) is not False:
return tw, None
return tw, max(8, int(_num(ov.get("height_px"), tw)))
return max(8, int(_num(ov.get("scale"), 0.35, 0.05, 1.5) * canvas_w)), None
def composite(base: str, overlays: list[dict], *, canvas_w: int, image_labels: list[str]) -> tuple[list[str], str]:
"""Filter lines laying each image overlay over `base`; returns (lines, final label). Each overlay
reads its own input, so an overlay that is not drawn leaves its input unused (ffmpeg allows that)."""
parts: list[str] = []
cur = base
for seq, ov in enumerate(overlays):
# image_labels[i] is overlay i's picture (prepare() keeps them 1:1); a skipped overlay
# must not shift the pictures of the ones after it.
if (ov.get("kind") or "image") != "image" or seq >= len(image_labels):
continue
start, dur = _num(ov.get("start_sec"), 0.0), _num(ov.get("duration_sec"), 0.0)
if dur <= 0:
continue
nx, ny = _num(ov.get("x"), 0.5, 0.0, 1.0), _num(ov.get("y"), 0.5, 0.0, 1.0)
opacity = _num(ov.get("opacity"), 1.0, 0.0, 1.0)
tw, th = _size(ov, max(1, int(canvas_w)))
scale = f"scale={tw}:-1" if th is None else f"scale={tw}:{th}"
scaled = f"[ovs{seq}]"
src = image_labels[seq]
if opacity < 0.999:
parts.append(f"{src}{scale},format=rgba,colorchannelmixer=aa={opacity:.3f}{scaled}")
else:
parts.append(f"{src}{scale}{scaled}")
flipped = scaled
for flag, name in (("flip_h", "hflip"), ("flip_v", "vflip")):
if ov.get(flag):
nxt = f"[ov{name}{seq}]"
parts.append(f"{flipped}{name}{nxt}")
flipped = nxt
out = f"[vov{seq}]"
parts.append(f"{cur}{flipped}overlay=x={nx:.6f}*W-w/2:y={ny:.6f}*H-h/2:"
f"enable='between(t,{start:.3f},{start + dur:.3f})'{out}")
cur = out
return parts, cur