demo video: caption each stage with the MCP tool it calls

A band under the window names the tool behind each stage (open, mesh,
batch, check_model, solve, show_results). caption.py draws the bars in the
standard library from the interface's Inter SemiBold (OFL): a minimal
TrueType glyf reader, a non-zero scanline rasteriser and a zlib PNG
writer, unit-tested with python3 -m unittest. ffmpeg pads each picture
and composites the bar with its overlay filter.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01NmAttHhUZTokgPeEJia8sm
This commit is contained in:
jyvikoandClaude Opus 5.5 authored and Irakli Kourtis committed 2026-10-14 00:40:45 -04:00
1 parent 5665cc3d8c
commit 30812250d3
5 files changed
+544 -18

No files matched your search

+10 -2
View File
@@ -8,6 +8,7 @@ desktop app shows each step.
python3 tools/demo-video/make.py # build, run, encode into docs/media
python3 tools/demo-video/make.py --no-build # with the release binaries already built
python3 tools/demo-video/make.py --frames /tmp/f # also keep the captured pictures
cd tools/demo-video && python3 -m unittest # test the caption drawing
```
Needs Python 3 (standard library only), a display for the app window (the pictures are drawn
@@ -28,8 +29,15 @@ What it does:
stepped from 1 to 150 and back. Every picture is the MCP `screenshot` tool with
`source: window` (the whole window drawn offscreen, `ui.capture`), turned to the `iso` or
`front` view by its `view` argument.
4. Encodes the pictures at 10 frames per second, each held for its stage, into the MP4 (H.264,
1280 px) and the GIF (960 px, its own palette), and prints the tip deflection and the peak
4. Captions each stage with the MCP tool it calls and what it does (`open · tests/corpus/…`,
`mesh · hex sweep 4 mm`, `solve · static`, …) in a band under the window. `caption.py`
draws the bars in the standard library: a minimal TrueType reader (`cmap` format 4, `loca`,
simple and composite `glyf` outlines, `hmtx` advances), a scanline rasteriser (non-zero
winding, anti-aliased) and a PNG writer, with the interface's own font
(`crates/ui-egui/fonts/Inter-SemiBold.ttf`, SIL OFL 1.1). ffmpeg pads each window picture
with the band and lays the bar over it with its `overlay` filter.
5. Encodes the pictures at 10 frames per second, each held for its stage, into the MP4 (H.264,
1280 x 848) and the GIF (960 px, its own palette), and prints the tip deflection and the peak
von Mises stress.
The theme is the app's default (dark).
+352
View File
@@ -0,0 +1,352 @@
"""Caption pictures for the demo video, drawn in the Python standard library.
A minimal TrueType reader (the `cmap` format 4, `loca`, `glyf` simple and composite outlines and
`hmtx` advances of the TrueType specification), a scanline rasteriser (non-zero winding,
sub-scanlines with exact horizontal coverage) and a PNG writer (`zlib`, `struct`). It draws the
caption bar under the app window with the interface's own font, `crates/ui-egui/fonts/Inter-SemiBold.ttf` (SIL OFL
1.1, ATTRIBUTION.md), and ffmpeg composites it with its `overlay` filter.
No hinting, kerning or shaping: captions are short ASCII-and-punctuation labels.
"""
import math
import struct
import zlib
# Points per quadratic segment when flattening an outline.
CURVE_STEPS = 8
# Sub-scanlines per pixel row (vertical anti-aliasing; horizontal coverage is exact).
SUBSAMPLES = 5
# Nesting of composite glyphs a font may use before we refuse it.
MAX_COMPOSITE_DEPTH = 8
class FontError(ValueError):
pass
class Font:
"""The outlines and advances of a TrueType (`glyf`) font."""
def __init__(self, data):
self.data = data
if len(data) < 12:
raise FontError("not a font file")
(num_tables,) = struct.unpack_from(">H", data, 4)
self.tables = {}
for i in range(num_tables):
tag, _checksum, offset, length = struct.unpack_from(">4sIII", data, 12 + 16 * i)
if offset + length > len(data):
raise FontError(f"table {tag!r} runs past the end of the file")
self.tables[tag.decode("latin-1")] = (offset, length)
for tag in ("head", "hhea", "hmtx", "maxp", "cmap", "loca", "glyf"):
if tag not in self.tables:
raise FontError(f"no {tag} table (only TrueType outlines are supported)")
head = self.tables["head"][0]
(self.units_per_em,) = struct.unpack_from(">H", data, head + 18)
(loc_format,) = struct.unpack_from(">h", data, head + 50)
(self.num_glyphs,) = struct.unpack_from(">H", data, self.tables["maxp"][0] + 4)
hhea = self.tables["hhea"][0]
(self.num_h_metrics,) = struct.unpack_from(">H", data, hhea + 34)
loca = self.tables["loca"][0]
if loc_format == 0:
self.loca = [2 * v for v in struct.unpack_from(f">{self.num_glyphs + 1}H", data, loca)]
else:
self.loca = list(struct.unpack_from(f">{self.num_glyphs + 1}I", data, loca))
self.cmap = self._read_cmap()
@classmethod
def load(cls, path):
with open(path, "rb") as f:
return cls(f.read())
def _read_cmap(self):
"""The Unicode BMP subtable (format 4) as a code point -> glyph map."""
data = self.data
base = self.tables["cmap"][0]
(count,) = struct.unpack_from(">H", data, base + 2)
for i in range(count):
platform, encoding, offset = struct.unpack_from(">HHI", data, base + 4 + 8 * i)
sub = base + offset
if (platform, encoding) in ((3, 1), (0, 3)) and struct.unpack_from(">H", data, sub)[0] == 4:
return self._read_cmap4(sub)
raise FontError("no Unicode BMP (format 4) cmap subtable")
def _read_cmap4(self, sub):
data = self.data
(seg_x2,) = struct.unpack_from(">H", data, sub + 6)
segs = seg_x2 // 2
ends = struct.unpack_from(f">{segs}H", data, sub + 14)
starts = struct.unpack_from(f">{segs}H", data, sub + 16 + seg_x2)
deltas = struct.unpack_from(f">{segs}h", data, sub + 16 + 2 * seg_x2)
range_at = sub + 16 + 3 * seg_x2
ranges = struct.unpack_from(f">{segs}H", data, range_at)
table = {}
for i in range(segs):
for code in range(starts[i], ends[i] + 1):
if code == 0xFFFF:
continue
if ranges[i] == 0:
glyph = (code + deltas[i]) & 0xFFFF
else:
at = range_at + 2 * i + ranges[i] + 2 * (code - starts[i])
(glyph,) = struct.unpack_from(">H", data, at)
if glyph:
glyph = (glyph + deltas[i]) & 0xFFFF
if glyph:
table[code] = glyph
return table
def glyph(self, char):
"""The glyph index of a character (0, the missing glyph, when the font has none)."""
return self.cmap.get(ord(char), 0)
def advance(self, glyph):
hmtx = self.tables["hmtx"][0]
index = min(glyph, self.num_h_metrics - 1)
return struct.unpack_from(">H", self.data, hmtx + 4 * index)[0]
def contours(self, glyph, depth=0):
"""The glyph's outline as closed polylines in font units (y up)."""
if depth > MAX_COMPOSITE_DEPTH:
raise FontError("composite glyphs nested too deeply")
if not 0 <= glyph < self.num_glyphs:
raise FontError(f"glyph {glyph} out of range")
start, end = self.loca[glyph], self.loca[glyph + 1]
if start == end:
return [] # no outline (a space)
at = self.tables["glyf"][0] + start
(n,) = struct.unpack_from(">h", self.data, at)
if n >= 0:
return self._simple(at, n)
return self._composite(at + 10, depth)
def _simple(self, at, n):
data = self.data
ends = struct.unpack_from(f">{n}H", data, at + 10)
points = ends[-1] + 1 if n else 0
at += 10 + 2 * n
(instructions,) = struct.unpack_from(">H", data, at)
at += 2 + instructions
flags = []
while len(flags) < points:
flag = data[at]
at += 1
repeat = 1
if flag & 8:
repeat += data[at]
at += 1
flags.extend([flag] * repeat)
flags = flags[:points]
def coords(short_bit, same_bit):
nonlocal at
values, value = [], 0
for flag in flags:
if flag & short_bit:
delta = data[at]
at += 1
value += delta if flag & same_bit else -delta
elif not flag & same_bit:
value += struct.unpack_from(">h", data, at)[0]
at += 2
values.append(value)
return values
xs = coords(2, 16)
ys = coords(4, 32)
contours, first = [], 0
for last in ends:
pts = [(xs[i], ys[i], bool(flags[i] & 1)) for i in range(first, last + 1)]
first = last + 1
if pts:
contours.append(_flatten(pts))
return contours
def _composite(self, at, depth):
data = self.data
contours = []
while True:
flags, glyph = struct.unpack_from(">HH", data, at)
at += 4
if flags & 1:
arg1, arg2 = struct.unpack_from(">hh", data, at)
at += 4
else:
arg1, arg2 = struct.unpack_from(">bb", data, at)
at += 2
if not flags & 2:
raise FontError("composite glyphs placed by point matching are not supported")
a, b, c, d = 1.0, 0.0, 0.0, 1.0
if flags & 8:
a = d = _f2dot14(data, at)
at += 2
elif flags & 0x40:
a, d = _f2dot14(data, at), _f2dot14(data, at + 2)
at += 4
elif flags & 0x80:
a, b, c, d = (_f2dot14(data, at + 2 * k) for k in range(4))
at += 8
for contour in self.contours(glyph, depth + 1):
contours.append([(a * x + c * y + arg1, b * x + d * y + arg2) for x, y in contour])
if not flags & 0x20:
return contours
def _f2dot14(data, at):
return struct.unpack_from(">h", data, at)[0] / 16384.0
def _flatten(pts):
"""A closed quadratic B-spline contour (on- and off-curve points) as a polyline."""
# Start on an on-curve point; with none, at the midpoint of the first two off-curve ones.
start = next((i for i, p in enumerate(pts) if p[2]), None)
if start is None:
(x0, y0, _), (x1, y1, _) = pts[0], pts[1 % len(pts)]
pts = [((x0 + x1) / 2, (y0 + y1) / 2, True)] + pts
start = 0
pts = pts[start:] + pts[:start]
out = [(pts[0][0], pts[0][1])]
control = None
for x, y, on in pts[1:] + pts[:1]:
if on:
if control is None:
out.append((x, y))
else:
out.extend(_quad(out[-1], control, (x, y)))
control = None
else:
if control is not None:
mid = ((control[0] + x) / 2, (control[1] + y) / 2)
out.extend(_quad(out[-1], control, mid))
control = (x, y)
return out
def _quad(p0, p1, p2):
pts = []
for k in range(1, CURVE_STEPS + 1):
t = k / CURVE_STEPS
u = 1 - t
pts.append((u * u * p0[0] + 2 * u * t * p1[0] + t * t * p2[0], u * u * p0[1] + 2 * u * t * p1[1] + t * t * p2[1]))
return pts
def fill(polygons, width, height):
"""Coverage (0..1, row-major) of closed polygons in pixel coordinates (y down), non-zero rule."""
cover = [0.0] * (width * height)
edges = []
for poly in polygons:
for (x0, y0), (x1, y1) in zip(poly, poly[1:] + poly[:1]):
if y0 != y1:
edges.append((x0, y0, x1, y1, 1 if y1 > y0 else -1))
if not edges:
return cover
top = max(0, int(math.floor(min(min(e[1], e[3]) for e in edges))))
bottom = min(height, int(math.ceil(max(max(e[1], e[3]) for e in edges))))
weight = 1.0 / SUBSAMPLES
for row in range(top, bottom):
base = row * width
for s in range(SUBSAMPLES):
y = row + (s + 0.5) / SUBSAMPLES
crossings = []
for x0, y0, x1, y1, wind in edges:
if (y0 <= y < y1) or (y1 <= y < y0):
crossings.append((x0 + (y - y0) * (x1 - x0) / (y1 - y0), wind))
crossings.sort()
winding = 0
for (xa, wind), (xb, _) in zip(crossings, crossings[1:]):
winding += wind
if winding:
_span(cover, base, width, xa, xb, weight)
return cover
def _span(cover, base, width, xa, xb, weight):
"""Add `weight` times the covered fraction of each pixel in [xa, xb) of one row."""
xa, xb = max(xa, 0.0), min(xb, float(width))
if xb <= xa:
return
first, last = int(xa), min(int(math.ceil(xb)) - 1, width - 1)
if first == last:
cover[base + first] += weight * (xb - xa)
return
cover[base + first] += weight * (first + 1 - xa)
for px in range(first + 1, last):
cover[base + px] += weight
cover[base + last] += weight * (xb - last)
def text_width(font, text, size):
return sum(font.advance(font.glyph(ch)) for ch in text) * size / font.units_per_em
def text_polygons(font, text, size, x, baseline):
"""The outlines of a line of text at `size` pixels per em, its baseline at `baseline`."""
scale = size / font.units_per_em
polygons = []
for ch in text:
glyph = font.glyph(ch)
for contour in font.contours(glyph):
polygons.append([(x + px * scale, baseline - py * scale) for px, py in contour])
x += font.advance(glyph) * scale
return polygons, x
class Canvas:
"""An opaque RGB picture that shapes are painted over, anti-aliased by their coverage."""
def __init__(self, width, height, background):
self.width, self.height = width, height
self.rgb = [list(background) for _ in range(width * height)]
def paint(self, polygons, color):
"""Paint polygons in an sRGB colour (r, g, b bytes)."""
cover = fill(polygons, self.width, self.height)
for i, c in enumerate(cover):
if c > 0.0:
c = min(c, 1.0)
self.rgb[i] = [d + (s - d) * c for d, s in zip(self.rgb[i], color)]
def png(self):
rows = []
for y in range(self.height):
row = bytearray(b"\0") # filter: none
for px in self.rgb[y * self.width:(y + 1) * self.width]:
row.extend(min(255, int(round(v))) for v in px)
rows.append(bytes(row))
return png(self.width, self.height, b"".join(rows))
def png(width, height, scanlines):
"""An 8-bit RGB PNG from filtered scanlines (each row prefixed by its filter byte)."""
def chunk(tag, body):
return struct.pack(">I", len(body)) + tag + body + struct.pack(">I", zlib.crc32(tag + body) & 0xFFFFFFFF)
header = struct.pack(">IIBBBBB", width, height, 8, 2, 0, 0, 0)
return b"\x89PNG\r\n\x1a\n" + chunk(b"IHDR", header) + chunk(b"IDAT", zlib.compress(scanlines, 9)) + chunk(b"IEND", b"")
# The caption bar's look: the app's dark panel colour, the tool name in its accent blue.
BAR = (22, 26, 34)
TOOL = (110, 168, 255)
TEXT = (236, 239, 244)
SEPARATOR = " \u00b7 "
def caption_bar(font, size, tool, detail, font_size=22):
"""A caption bar of `size` (width, height) pixels: the MCP tool's name, then what it does,
centred."""
width, height = size
rest = SEPARATOR + detail if detail else ""
text_w = text_width(font, tool + rest, font_size)
canvas = Canvas(width, height, BAR)
# The baseline centres the font's cap height (about 0.73 em for Inter) in the bar.
baseline = height / 2 + 0.73 * font_size / 2
polygons, x = text_polygons(font, tool, font_size, (width - text_w) / 2, baseline)
canvas.paint(polygons, TOOL)
if rest:
polygons, _ = text_polygons(font, rest, font_size, x, baseline)
canvas.paint(polygons, TEXT)
return canvas.png()
+62 -16
View File
@@ -3,7 +3,8 @@
Every analysis step goes through the MCP server (`fieldcraft-cli mcp --connect`), bridged to
the desktop app, and every frame is the app window captured offscreen through the MCP
`screenshot` tool (`source: window`). See README.md beside this file.
`screenshot` tool (`source: window`). A caption bar names the tool behind each stage
(caption.py draws it; ffmpeg's `overlay` filter composites it). See README.md beside this file.
Python standard library only; ffmpeg encodes the frames.
"""
@@ -20,6 +21,8 @@ import tempfile
import time
from pathlib import Path
import caption
ROOT = Path(__file__).resolve().parents[2]
# The analysis: a cantilever I-beam lying along x (tests/corpus/cantilever-i-beam: 40 x 40 mm,
@@ -47,9 +50,13 @@ HOLD_LONG = 20 # the stress result
SWING = 12 # pictures from undeformed to the largest exaggeration
MAX_SCALE = 150.0 # the largest displacement (0.18 mm) drawn about 26 mm on the 200 mm beam
# The captured window, in points (the app's own pixels-per-point scale on top).
# The captured window, in points (the app's own pixels-per-point scale on top); the video's size.
WINDOW = (1280, 800)
# The caption bar, a band under the window (so it never hides the model, the legend or a panel).
CAPTION_FONT = ROOT / "crates" / "ui-egui" / "fonts" / "Inter-SemiBold.ttf"
CAPTION_HEIGHT = 48
def log(msg):
print(f"demo-video: {msg}", file=sys.stderr, flush=True)
@@ -140,30 +147,54 @@ class Film:
def __init__(self, mcp, frames_dir):
self.mcp = mcp
self.dir = frames_dir
self.shots = [] # (file, frames)
self.font = caption.Font.load(CAPTION_FONT)
self.captions = {} # (tool, detail) -> its picture
self.caption = None
self.shots = [] # (file, frames, caption picture)
def shoot(self, frames, view=None):
def say(self, tool, detail):
"""Caption the next pictures with the MCP tool being called and what it does."""
key = (tool, detail)
if key not in self.captions:
path = self.dir / f"caption{len(self.captions):03d}.png"
path.write_bytes(caption.caption_bar(self.font, (WINDOW[0], CAPTION_HEIGHT), tool, detail))
self.captions[key] = path
self.caption = self.captions[key]
def shoot(self, frames, view=None, camera=None):
if self.caption is None:
raise SystemExit("film: a picture without a caption")
args = {"source": "window", "width": WINDOW[0], "height": WINDOW[1]}
if view:
args["view"] = view
if camera:
args["camera"] = camera
content = self.mcp.tool("screenshot", args)
png = next((c["data"] for c in content if c.get("type") == "image"), None)
if png is None:
raise SystemExit("screenshot: no image in the reply")
path = self.dir / f"shot{len(self.shots):04d}.png"
path.write_bytes(base64.b64decode(png))
self.shots.append((path, frames))
self.shots.append((path, frames, self.caption))
def hold(self, frames):
"""Hold the last picture, under the current caption, when the window has not changed."""
if not self.shots:
raise SystemExit("film: nothing to hold")
self.shots.append((self.shots[-1][0], frames, self.caption))
def sequence(self, folder):
"""Lay the video out as numbered frames (links to the shots); return their pattern and count."""
"""Lay the video out as numbered frames and their captions (links to the pictures);
return the two patterns and the frame count."""
shutil.rmtree(folder, ignore_errors=True)
folder.mkdir(parents=True)
(folder / "captions").mkdir(parents=True)
n = 0
for shot, frames in self.shots:
for shot, frames, bar in self.shots:
for _ in range(frames):
os.link(shot, folder / f"{n:05d}.png")
os.link(bar, folder / "captions" / f"{n:05d}.png")
n += 1
return str(folder / "%05d.png"), n
return str(folder / "%05d.png"), str(folder / "captions" / "%05d.png"), n
def hand_calculation():
@@ -231,16 +262,19 @@ def batch(mcp, commands):
def analyse(mcp, film, design, work):
"""The analysis, every step an MCP tool call, with the pictures a viewer follows."""
# 1. Open the part.
film.say("open", f"tests/corpus/{PART}")
mcp.call("open", {"path": str(design)})
film.shoot(HOLD, view="iso")
# 2. Hex mesh: the extruded section sweeps into structured hexahedra.
film.say("mesh", f"hex sweep {SIZE_MM:g} mm")
mesh = mcp.call("mesh", {"method": "hex_sweep", "params": {"body": BODY, "size": SIZE_MM}})
log(f"mesh: {mesh['mesh']['elements']} elements, {mesh['mesh']['nodes']} nodes")
film.shoot(HOLD)
# 3. Material and section, then supports, then the load and the step: one picture each, so
# the browser's counts grow as the model is defined.
film.say("batch", "steel, solid section")
batch(mcp, [
{"command": "model.units", "params": {"system": "mm-t-s-N-MPa"}},
{"command": "model.set.create", "params": {"name": "beam", "geometry": [{"body": BODY}]}},
@@ -248,11 +282,13 @@ def analyse(mcp, film, design, work):
{"command": "model.section.create", "params": {"name": "solid", "set": "beam", "material": "Steel", "kind": {"type": "solid"}, "formulation": {"incompatible_modes": True}}},
])
film.shoot(HOLD_SHORT)
film.say("batch", "clamp at x = 0")
batch(mcp, [
{"command": "model.set.create", "params": {"name": "root", "geometry": [{"body": BODY, "face": ROOT_FACE}]}},
{"command": "model.boundary_condition.create", "params": {"name": "clamp", "type": "fixed", "target": {"set": "root"}}},
])
film.shoot(HOLD_SHORT)
film.say("batch", f"{LOAD_N:g} N tip load, static step")
batch(mcp, [
# The end section stays plane: a kinematic coupling to a point on the beam's axis carries
# the load, so no load-introduction peak hides the root stress.
@@ -266,15 +302,20 @@ def analyse(mcp, film, design, work):
if not check.get("ready"):
raise SystemExit(f"the model is not ready: {check}")
film.shoot(HOLD_SHORT)
film.say("check_model", "ready")
film.hold(HOLD_SHORT)
# 4. Solve.
film.say("solve", "static")
solve = mcp.call("solve", {"analysis": "static", "step": STEP})
log(f"solve: {solve.get('equations')} equations")
film.shoot(HOLD_SHORT)
# 5. Results: the tip deflection, then von Mises contours on the true (scale 1) shape.
disp = mcp.call("show_results", {"step": STEP, "field": "displacement"})
tip = disp["contour"]["range"]["max"]["value"]
root_stress = root_bending_stress(mcp, work)
film.say("show_results", "von Mises")
vm = mcp.call("show_results", {"step": STEP, "field": "von_mises", "bands": 12, "deformed": True, "scale": 1})
peak = vm["contour"]["range"]["max"]["value"]
log(f"tip deflection {tip:.4f} mm, peak von Mises {peak:.1f} MPa")
@@ -282,6 +323,7 @@ def analyse(mcp, film, design, work):
# 6. The deformed shape, swung from the true shape to an exaggeration and back, in the iso
# view and then from the side, ending held at the exaggeration.
film.say("show_results", f"deformed, up to \u00d7{MAX_SCALE:g}")
ramp = [max(1.0, MAX_SCALE * i / SWING) for i in range(SWING + 1)]
for view, scales in (("iso", ramp[1:] + ramp[-2::-1]), ("front", ramp[1:])):
for i, scale in enumerate(scales):
@@ -293,21 +335,25 @@ def analyse(mcp, film, design, work):
def encode(film, frames_dir, mp4, gif):
ffmpeg = os.environ.get("FFMPEG", shutil.which("ffmpeg") or "ffmpeg")
pattern, count = film.sequence(frames_dir / "sequence")
pattern, captions, count = film.sequence(frames_dir / "sequence")
log(f"encoding {len(film.shots)} pictures as {count} frames, {count / FPS:.1f} s")
src = ["-framerate", str(FPS), "-i", pattern]
# The window pictures scaled to the window's size in points, with their caption bars laid
# over a band added below.
src = ["-framerate", str(FPS), "-i", pattern, "-framerate", str(FPS), "-i", captions]
width, height = WINDOW
captioned = (f"[0:v]scale={width}:{height}:flags=lanczos,pad={width}:{height + CAPTION_HEIGHT}:0:0[w];"
f"[w][1:v]overlay=0:{height}")
common = [ffmpeg, "-hide_banner", "-loglevel", "error", "-y"]
mp4.parent.mkdir(parents=True, exist_ok=True)
subprocess.run(
[*common, *src, "-vf", "scale=1280:-2:flags=lanczos", "-c:v", "libx264", "-preset", "veryslow", "-crf", "26",
[*common, *src, "-filter_complex", captioned, "-c:v", "libx264", "-preset", "veryslow", "-crf", "26",
"-pix_fmt", "yuv420p", "-movflags", "+faststart", str(mp4)],
check=True,
)
palette = frames_dir / "palette.png"
subprocess.run([*common, *src, "-vf", "scale=960:-1:flags=lanczos,palettegen=stats_mode=diff", str(palette)], check=True)
subprocess.run(
[*common, *src, "-i", str(palette), "-lavfi",
"scale=960:-1:flags=lanczos[v];[v][1:v]paletteuse=dither=bayer:bayer_scale=5:diff_mode=rectangle", str(gif)],
[*common, *src, "-filter_complex",
f"{captioned},scale=960:-1:flags=lanczos,split[a][b];[a]palettegen=stats_mode=diff[p];"
"[b][p]paletteuse=dither=bayer:bayer_scale=5:diff_mode=rectangle", str(gif)],
check=True,
)
+119
View File
@@ -0,0 +1,119 @@
"""Tests of the caption pictures: `python3 -m unittest` in tools/demo-video."""
import struct
import unittest
import zlib
from pathlib import Path
import caption
FONT = Path(__file__).resolve().parents[2] / "crates" / "ui-egui" / "fonts" / "Inter-SemiBold.ttf"
def decode(data):
"""Width, height and RGB rows of a PNG written by `caption.png` (filter 0, one IDAT)."""
assert data[:8] == b"\x89PNG\r\n\x1a\n"
at, chunks = 8, {}
while at < len(data):
(length,) = struct.unpack_from(">I", data, at)
tag, body = data[at + 4:at + 8], data[at + 8:at + 8 + length]
(crc,) = struct.unpack_from(">I", data, at + 8 + length)
assert crc == zlib.crc32(tag + body) & 0xFFFFFFFF, tag
chunks[tag] = body
at += 12 + length
width, height, depth, kind = struct.unpack_from(">IIBB", chunks[b"IHDR"])
assert (depth, kind) == (8, 2)
raw = zlib.decompress(chunks[b"IDAT"])
stride = 1 + 3 * width
rows = [raw[y * stride + 1:(y + 1) * stride] for y in range(height)]
assert all(raw[y * stride] == 0 for y in range(height))
return width, height, rows
class Fill(unittest.TestCase):
def test_square_covers_its_pixels_and_partial_edges(self):
cover = caption.fill([[(1, 1), (3.5, 1), (3.5, 3), (1, 3)]], 5, 4)
at = lambda x, y: cover[y * 5 + x]
self.assertAlmostEqual(at(1, 1), 1.0)
self.assertAlmostEqual(at(2, 2), 1.0)
self.assertAlmostEqual(at(3, 1), 0.5)
self.assertAlmostEqual(at(0, 1), 0.0)
self.assertAlmostEqual(at(4, 2), 0.0)
self.assertAlmostEqual(at(1, 0), 0.0)
self.assertAlmostEqual(sum(cover), 2.5 * 2)
def test_nonzero_winding_fills_overlaps_once_and_leaves_holes(self):
outer = [(0, 0), (6, 0), (6, 6), (0, 6)]
hole = [(2, 2), (2, 4), (4, 4), (4, 2)] # opposite direction
cover = caption.fill([outer, hole], 6, 6)
self.assertAlmostEqual(cover[3 * 6 + 3], 0.0)
self.assertAlmostEqual(cover[1 * 6 + 1], 1.0)
twice = caption.fill([outer, outer], 6, 6)
self.assertAlmostEqual(max(twice), 1.0)
def test_shapes_outside_the_picture_are_clipped(self):
cover = caption.fill([[(-5, -5), (20, -5), (20, 20), (-5, 20)]], 3, 2)
self.assertEqual(cover, [1.0] * 6)
class Png(unittest.TestCase):
def test_round_trip(self):
canvas = caption.Canvas(3, 2, (0, 0, 0))
canvas.paint([[(1, 1), (2, 1), (2, 2), (1, 2)]], (255, 0, 0))
canvas.paint([[(2, 1), (2.5, 1), (2.5, 2), (2, 2)]], (0, 0, 255)) # half a pixel
width, height, rows = decode(canvas.png())
self.assertEqual((width, height), (3, 2))
self.assertEqual(rows[0], bytes(9))
self.assertEqual(rows[1], bytes((0, 0, 0, 255, 0, 0, 0, 0, 128)))
class Font(unittest.TestCase):
@classmethod
def setUpClass(cls):
cls.font = caption.Font.load(FONT)
def test_metrics(self):
self.assertGreater(self.font.units_per_em, 0)
self.assertGreater(self.font.advance(self.font.glyph("m")), self.font.advance(self.font.glyph("i")))
self.assertEqual(self.font.contours(self.font.glyph(" ")), [])
def test_outlines_have_their_holes(self):
size = 100
polygons, _ = caption.text_polygons(self.font, "o", size, 0, size)
self.assertEqual(len(polygons), 2)
cover = caption.fill(polygons, size, size)
xs = [p[0] for p in polygons[0]]
ys = [p[1] for p in polygons[0]]
cx, cy = int((min(xs) + max(xs)) / 2), int((min(ys) + max(ys)) / 2)
self.assertAlmostEqual(cover[cy * size + cx], 0.0) # the counter
self.assertGreater(cover[cy * size + int(min(xs)) + 3], 0.99) # the stroke
def test_composite_glyphs(self):
# An accented letter is a base glyph plus a mark placed by an offset.
e = self.font.contours(self.font.glyph("e"))
e_acute = self.font.contours(self.font.glyph("é"))
self.assertGreater(len(e_acute), len(e))
self.assertGreater(max(y for c in e_acute for _, y in c), max(y for c in e for _, y in c))
def test_hostile_data_is_refused(self):
with self.assertRaises(caption.FontError):
caption.Font(b"")
with self.assertRaises(caption.FontError):
self.font.contours(self.font.num_glyphs)
class CaptionBar(unittest.TestCase):
def test_text_is_centred_on_the_bar(self):
font = caption.Font.load(FONT)
width, height, rows = decode(caption.caption_bar(font, (400, 48), "solve", "static", font_size=20))
self.assertEqual((width, height), (400, 48))
bar = bytes(caption.BAR)
text = [(x, y) for y, row in enumerate(rows) for x in range(width) if row[3 * x:3 * x + 3] != bar]
xs, ys = [p[0] for p in text], [p[1] for p in text]
self.assertLessEqual(abs((min(xs) + max(xs)) / 2 - 200), 1.5)
self.assertLessEqual(abs((min(ys) + max(ys)) / 2 - 24), 1.5)
self.assertIn(bytes(caption.TOOL), {row[3 * x:3 * x + 3] for row in rows for x in range(width)})
if __name__ == "__main__":
unittest.main()