Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
254 lines
12 KiB
Python
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
|