From 30812250d3cffeb5afb6d6eb1535e0d8618f366a Mon Sep 17 00:00:00 2001 From: jyviko <2501648+jyviko@users.noreply.github.com> Date: Wed, 14 Oct 2026 00:25:41 -0400 Subject: [PATCH] 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 Claude-Session: https://claude.ai/code/session_01NmAttHhUZTokgPeEJia8sm --- .gitignore | 1 + tools/demo-video/README.md | 12 +- tools/demo-video/caption.py | 352 +++++++++++++++++++++++++++++++ tools/demo-video/make.py | 78 +++++-- tools/demo-video/test_caption.py | 119 +++++++++++ 5 files changed, 544 insertions(+), 18 deletions(-) create mode 100644 tools/demo-video/caption.py create mode 100644 tools/demo-video/test_caption.py diff --git a/.gitignore b/.gitignore index 6ccb83c..86f89c9 100644 --- a/.gitignore +++ b/.gitignore @@ -6,3 +6,4 @@ *~ .DS_Store *.swp +__pycache__/ diff --git a/tools/demo-video/README.md b/tools/demo-video/README.md index 8c50fc4..f871662 100644 --- a/tools/demo-video/README.md +++ b/tools/demo-video/README.md @@ -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). diff --git a/tools/demo-video/caption.py b/tools/demo-video/caption.py new file mode 100644 index 0000000..3dcc2cd --- /dev/null +++ b/tools/demo-video/caption.py @@ -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() diff --git a/tools/demo-video/make.py b/tools/demo-video/make.py index f2dc90c..af82429 100755 --- a/tools/demo-video/make.py +++ b/tools/demo-video/make.py @@ -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, ) diff --git a/tools/demo-video/test_caption.py b/tools/demo-video/test_caption.py new file mode 100644 index 0000000..5a4ada2 --- /dev/null +++ b/tools/demo-video/test_caption.py @@ -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()