diff --git a/AGENTS.md b/AGENTS.md index bff193c..46d3d69 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -45,7 +45,10 @@ The decision tree: bag tracking (`/newbag`, multiple bags, `/tossbag`), smart plug hooks for cold-start `/wake` (README "Smart plug cold start"), shot-video camera module (README "Camera module" — MATEBOT_CAMERA=1, needs ffmpeg + HTTPS in front of the - camera page; getUserMedia refuses plain HTTP). + camera page; getUserMedia refuses plain HTTP). Camera shots auto-calibrate + their chart-sync offset from the pump's audio onset (calibrate.py) and get + a rendered clip+chart "shot reel" sent to the chat (render.py, + MATEBOT_REEL=0 to disable; needs matplotlib). Common failure modes: token/chat id swapped or quoted wrong; bot and machine on different networks/VLANs; machine powered off at a dumb power strip; diff --git a/README.md b/README.md index a623daf..1c8f99d 100644 --- a/README.md +++ b/README.md @@ -144,11 +144,17 @@ MATEbot itself serves plain HTTP; put it behind whatever TLS you have: `matebot.example.com` → `127.0.0.1:8877` (WebSocket support needed) - or zero-config on a tailnet: `tailscale serve https / http://127.0.0.1:8877` -Sync calibration: detection and stream latency mean the video usually starts -~1 s after the true shot start. `MATEBOT_CAMERA_OFFSET` (default `-1.0`) -corrects this globally; watch one replay and nudge per shot with -`/vsync +0.5` if needed. Videos rotate out after `MATEBOT_VIDEO_KEEP` shots -(git history keeps every clip recoverable). +Sync is calibrated automatically: the pump is loud, so MATEbot aligns its +audio onset in the clip with the pump command in the shot data and writes the +exact per-shot offset. If a clip has no usable audio the fallback is +`MATEBOT_CAMERA_OFFSET` (default `-1.0`), and `/vsync +0.5` still nudges any +shot manually. Videos rotate out after `MATEBOT_VIDEO_KEEP` shots (git +history keeps every clip recoverable). + +After each recorded shot MATEbot also renders a **shot reel** — a portrait +video with the clip on top and the chart animating below it, playhead +tracking the x axis — and sends it to the chat (ready for sharing). Disable +with `MATEBOT_REEL=0`; it needs ffmpeg and the `plots` extra (matplotlib). ## Configuration diff --git a/src/matebot/calibrate.py b/src/matebot/calibrate.py new file mode 100644 index 0000000..0b6900b --- /dev/null +++ b/src/matebot/calibrate.py @@ -0,0 +1,90 @@ +"""Auto-calibrate the video/chart sync offset from the pump's audio onset. + +A vibratory pump is loud. The moment it kicks in is visible in the shot data +(``tp`` — target pressure — leaves zero) and audible in the clip, so aligning +the two gives the exact offset with no manual ``/vsync`` needed: + + offset = t_pump_in_video - t_pump_in_chart + +Pure python on 8 kHz mono PCM — no numpy. Returns ``None`` whenever the +signal is unclear; callers then keep the configured default. +""" + +from __future__ import annotations + +import asyncio +import logging +import struct +from pathlib import Path + +from .slog import parse_slog + +log = logging.getLogger(__name__) + +SAMPLE_RATE = 8000 +WINDOW_S = 0.05 +SUSTAIN_WINDOWS = 6 # onset must stay loud for 0.3 s (a clink won't) + + +async def audio_onset(clip: Path) -> float | None: + """Time (s) at which the clip's audio gets loud and stays loud.""" + try: + proc = await asyncio.create_subprocess_exec( + "ffmpeg", "-hide_banner", "-loglevel", "error", "-i", str(clip), + "-vn", "-ac", "1", "-ar", str(SAMPLE_RATE), "-f", "s16le", "pipe:1", + stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.DEVNULL, + ) + except FileNotFoundError: + return None + pcm, _ = await proc.communicate() + if proc.returncode != 0 or len(pcm) < SAMPLE_RATE: # needs ≥0.5 s of audio + return None + n = len(pcm) // 2 + samples = struct.unpack(f"<{n}h", pcm[: n * 2]) + win = int(WINDOW_S * SAMPLE_RATE) + rms = [ + (sum(x * x for x in samples[i : i + win]) / win) ** 0.5 + for i in range(0, n - win, win) + ] + if len(rms) < 30: + return None + head = sorted(rms[:20]) + base = head[len(head) // 2] # median of the first second + peak = max(rms) + if peak < max(base, 1.0) * 4: # no clear loud event (or mic muted) + return None + thresh = max(base * 5, peak * 0.25) + for i in range(len(rms) - SUSTAIN_WINDOWS): + if all(r > thresh for r in rms[i : i + SUSTAIN_WINDOWS]): + return round(i * WINDOW_S, 2) + return None + + +def pump_start(slog_bytes: bytes) -> float | None: + """Time (s) at which the shot data first commands the pump.""" + shot = parse_slog(slog_bytes) + step = shot.sample_interval_ms / 1000 + for key, thresh in (("tp", 0.1), ("fl", 0.3)): + for i, v in enumerate(shot.series.get(key, [])): + if v > thresh: + return round(i * step, 2) + return None + + +async def calibrate_offset(repo: str | Path, shot_id: int) -> float | None: + """Offset for the shot's sidecar (video t = chart t + offset), or None.""" + repo = Path(repo) + sid = f"{shot_id:06d}" + clip = repo / "shots" / f"{sid}.mp4" + slog = repo / "shots" / f"{sid}.slog" + if not clip.exists() or not slog.exists(): + return None + t_video = await audio_onset(clip) + t_chart = pump_start(slog.read_bytes()) + if t_video is None or t_chart is None: + return None + offset = round(t_video - t_chart, 2) + if not -5.0 <= offset <= 5.0: # implausible match, don't trust it + log.info("calibration rejected (offset %+.2fs out of range)", offset) + return None + return offset diff --git a/src/matebot/cli.py b/src/matebot/cli.py index 4e3b360..d581a32 100644 --- a/src/matebot/cli.py +++ b/src/matebot/cli.py @@ -174,13 +174,42 @@ async def try_attach_clip(shot_id: int | None = None) -> None: await attach_video(config.data_repo, sid, clip, offset=config.camera_offset) log.info("camera clip attached to shot %06d", sid) - schedule_sync(quiet=True) + asyncio.create_task(post_video(sid)) except VideoError as exc: log.warning("clip attach failed: %s", exc) await messenger.send(f"🎬 Couldn't process the shot video: {exc}") finally: pathlib.Path(clip).unlink(missing_ok=True) + async def post_video(sid: int) -> None: + """After a clip attaches: auto-calibrate its sync offset from the + pump's audio onset, then render + send the shot reel.""" + try: + from .calibrate import calibrate_offset + from .video import set_offset + + offset = await calibrate_offset(config.data_repo, sid) + if offset is not None: + set_offset(config.data_repo, sid, offset) + log.info("shot %06d video offset auto-calibrated to %+.2fs", sid, offset) + except Exception as exc: # noqa: BLE001 - calibration is best-effort + log.warning("offset calibration failed: %s", exc) + schedule_sync(quiet=True) + if not config.reel_enabled: + return + try: + from .render import RenderError, render_reel + + reel = await render_reel(config.data_repo, sid, title=f"Shot #{sid}") + try: + await messenger.send_video(reel.read_bytes(), f"🎬 Shot #{sid}") + finally: + reel.unlink(missing_ok=True) + except RenderError as exc: + log.info("no reel for shot %06d: %s", sid, exc) + except Exception as exc: # noqa: BLE001 - never let the reel kill the bot + log.warning("reel for shot %06d failed: %s", sid, exc) + if config.camera_enabled and config.data_repo: import shutil as _shutil import time as _time diff --git a/src/matebot/config.py b/src/matebot/config.py index 09bae90..2429a63 100644 --- a/src/matebot/config.py +++ b/src/matebot/config.py @@ -35,6 +35,7 @@ "camera_enabled": "MATEBOT_CAMERA", "camera_port": "MATEBOT_CAMERA_PORT", "camera_offset": "MATEBOT_CAMERA_OFFSET", + "reel_enabled": "MATEBOT_REEL", "plots_enabled": "MATEBOT_PLOTS", "wake_hook": "MATEBOT_WAKE_HOOK", "sleep_hook": "MATEBOT_SLEEP_HOOK", @@ -71,6 +72,7 @@ class Config: camera_enabled: bool = False # opt-in: serve the phone-camera page + record shots camera_port: int = 8877 camera_offset: float = -1.0 # shot t=0 relative to video t=0 (detection+stream latency) + reel_enabled: bool = True # send a composed clip+chart reel after camera shots plots_enabled: bool = True # send the shot chart as a photo (needs matplotlib) wake_hook: str = "" # shell command to power the machine's smart plug ON sleep_hook: str = "" # shell command to power the smart plug OFF @@ -94,7 +96,7 @@ def load(cls, path: str | Path | None = None) -> Config: elif f.name in ("clean_every", "water_warn_pct", "video_keep", "camera_port"): value = int(value) elif f.name in ("sync_enabled", "hints_enabled", "digest_enabled", "plots_enabled", - "camera_enabled"): + "camera_enabled", "reel_enabled"): value = str(value).lower() in ("1", "true", "yes", "on") kwargs[f.name] = value config = cls(**kwargs) diff --git a/src/matebot/messengers/base.py b/src/matebot/messengers/base.py index 2b29756..2403621 100644 --- a/src/matebot/messengers/base.py +++ b/src/matebot/messengers/base.py @@ -53,6 +53,11 @@ async def send_photo(self, image: bytes, caption: str) -> str: back to the caption text.""" return await self.send(caption) + async def send_video(self, video: bytes, caption: str) -> str: + """Send a video with caption; backends without video support fall + back to the caption text.""" + return await self.send(caption) + @abstractmethod def events(self) -> AsyncIterator[Event]: """User interactions, in order. Runs until stop().""" diff --git a/src/matebot/messengers/discord.py b/src/matebot/messengers/discord.py index cd4b62b..418d128 100644 --- a/src/matebot/messengers/discord.py +++ b/src/matebot/messengers/discord.py @@ -75,6 +75,15 @@ async def send_photo(self, image: bytes, caption: str) -> str: msg = await channel.send(caption, file=discord.File(_io.BytesIO(image), "shot.png")) return str(msg.id) + async def send_video(self, video: bytes, caption: str) -> str: + import io as _io + + import discord + + channel = self.client.get_channel(self.channel_id) + msg = await channel.send(caption, file=discord.File(_io.BytesIO(video), "shot.mp4")) + return str(msg.id) + async def edit(self, ref: str, text: str, options: list[Option] | None = None) -> None: try: channel = self.client.get_channel(self.channel_id) diff --git a/src/matebot/messengers/telegram.py b/src/matebot/messengers/telegram.py index c1ba8ae..f54aef9 100644 --- a/src/matebot/messengers/telegram.py +++ b/src/matebot/messengers/telegram.py @@ -106,6 +106,20 @@ async def send_photo(self, image: bytes, caption: str) -> str: await asyncio.sleep(2 * (attempt + 1)) raise last_exc + async def send_video(self, video: bytes, caption: str) -> str: + last_exc = None + for attempt in range(5): + try: + msg = await self.app.bot.send_video( + self.chat_id, video=video, caption=caption, supports_streaming=True + ) + return str(msg.message_id) + except Exception as exc: # noqa: BLE001 + last_exc = exc + log.warning("video send attempt %d failed: %s", attempt + 1, exc) + await asyncio.sleep(2 * (attempt + 1)) + raise last_exc + async def edit(self, ref: str, text: str, options: list[Option] | None = None) -> None: try: await self.app.bot.edit_message_text( diff --git a/src/matebot/plot.py b/src/matebot/plot.py index 0783f9f..01b2b64 100644 --- a/src/matebot/plot.py +++ b/src/matebot/plot.py @@ -26,6 +26,14 @@ def render_shot_png(shot: Shot, *, title: str | None = None) -> bytes: + return render_shot_chart(shot, title=title)[0] + + +def render_shot_chart(shot: Shot, *, title: str | None = None) -> tuple[bytes, dict]: + """Render the chart and return ``(png_bytes, geometry)``. + + ``geometry`` holds the pixel bounds of the data area (see render.py). + """ import matplotlib matplotlib.use("Agg") @@ -96,5 +104,20 @@ def render_shot_png(shot: Shot, *, title: str | None = None) -> bytes: fig.tight_layout(rect=(0, 0.08, 1, 1)) buf = io.BytesIO() fig.savefig(buf, format="png") + + # pixel geometry of the data area (needed by render.py to animate a wipe + # that tracks the x axis, not the image edges) + fig.canvas.draw() + width, height = fig.canvas.get_width_height() + end = t[-1] if t else 1.0 + x0 = float(ax_temp.transData.transform((0, 0))[0]) + x1 = float(ax_temp.transData.transform((end, 0))[0]) + bbox = ax_temp.get_window_extent() + geometry = { + "img_w": width, "img_h": height, + "t_end": end, "x0": x0, "x1": x1, + "y_top": float(height - bbox.y1), "plot_h": float(bbox.y1 - bbox.y0), + } + plt.close(fig) - return buf.getvalue() + return buf.getvalue(), geometry diff --git a/src/matebot/render.py b/src/matebot/render.py new file mode 100644 index 0000000..61fb565 --- /dev/null +++ b/src/matebot/render.py @@ -0,0 +1,130 @@ +"""Render a shareable "shot reel": camera clip on top, animated chart below. + +The chart is drawn once (``plot.render_shot_chart``) and animated purely in +ffmpeg: a semi-transparent panel wipes across the data area in sync with the +shot while a playhead bar tracks the x axis (the axis pixel geometry comes +from matplotlib, so the playhead crosses "8" exactly at t=8). The clip is +delayed or trimmed by the sidecar offset, giving sync by construction. +Portrait layout, made for messengers. + +Requires ffmpeg and the ``plots`` extra (matplotlib); callers should treat +``RenderError`` as "no reel this time", never as fatal. +""" + +from __future__ import annotations + +import asyncio +import logging +import tempfile +from pathlib import Path + +from .slog import parse_slog +from .video import get_offset + +log = logging.getLogger(__name__) + +WIDTH = 720 +CHART_H = 406 +PLAYHEAD = "0xF0561D" # GaggiMate orange +TAIL_S = 2.0 + + +class RenderError(Exception): + """Reel could not be rendered (missing deps, data or ffmpeg failure).""" + + +async def render_reel(repo: str | Path, shot_id: int, *, title: str | None = None) -> Path: + """Compose ``shots/NNNNNN.mp4`` + chart into a reel; returns a temp mp4 path. + + The caller owns (and should unlink) the returned file. + """ + repo = Path(repo) + sid = f"{shot_id:06d}" + clip = repo / "shots" / f"{sid}.mp4" + slog = repo / "shots" / f"{sid}.slog" + if not clip.exists(): + raise RenderError(f"no clip for shot {sid}") + if not slog.exists(): + raise RenderError(f"no shot data for {sid}") + + shot = parse_slog(slog.read_bytes()) + try: + from .plot import render_shot_chart + except ImportError as exc: # pragma: no cover - import always works, mpl may not + raise RenderError("the plots extra (matplotlib) is not installed") from exc + try: + chart_png, geom = render_shot_chart(shot, title=title or f"Shot #{shot_id}") + except ImportError as exc: + raise RenderError("the plots extra (matplotlib) is not installed") from exc + + offset = get_offset(repo, shot_id) or 0.0 + delay = max(0.0, -offset) # video starts this long after chart t=0 + trim = max(0.0, offset) # positive offset: drop the clip's head instead + dur = geom["t_end"] # animate across the actual data range + total = dur + TAIL_S + + # scale axis geometry from the rendered PNG to the reel's chart panel + sx, sy = WIDTH / geom["img_w"], CHART_H / geom["img_h"] + left = geom["x0"] * sx + span = (geom["x1"] - geom["x0"]) * sx + y_top = int(geom["y_top"] * sy) + plot_h = max(2, int(geom["plot_h"] * sy) // 2 * 2) + + with tempfile.NamedTemporaryFile(suffix=".png", delete=False) as f: + f.write(chart_png) + chart_path = Path(f.name) + out = Path(tempfile.mkstemp(suffix=".mp4")[1]) + + vid_in = ["-i", str(clip)] if trim == 0 else ["-ss", f"{trim:.2f}", "-i", str(clip)] + delay_ms = int(delay * 1000) + x_expr = f"{left:.1f}+{span:.1f}*min(1\\,t/{dur})" + fc = ( + f"[0:v]tpad=start_duration={delay:.2f}:start_mode=clone," + f"scale={WIDTH}:-2,fps=30[vid];" + f"[1:v]scale={WIDTH}:{CHART_H},fps=30,format=rgba[ch];" + f"[ch][2:v]overlay=x='{x_expr}':y={y_top}[rev];" + f"[rev][3:v]overlay=x='{x_expr}':y={y_top}:enable='lte(t,{dur})'[chart];" + f"[vid][chart]vstack=inputs=2,format=yuv420p[v];" + f"[0:a]adelay={delay_ms}|{delay_ms}[a]" + ) + args = [ + "ffmpeg", "-hide_banner", "-loglevel", "error", "-y", + *vid_in, + "-loop", "1", "-i", str(chart_path), + "-f", "lavfi", "-i", f"color=c=white@0.88:s={int(span)}x{plot_h},format=rgba", + "-f", "lavfi", "-i", f"color=c={PLAYHEAD}:s=3x{plot_h}", + "-filter_complex", fc, + "-map", "[v]", "-map", "[a]", "-t", f"{total:.2f}", "-r", "30", + "-c:v", "libx264", "-preset", "veryfast", "-crf", "25", + "-c:a", "aac", "-b:a", "128k", "-movflags", "+faststart", + str(out), + ] + try: + rc, err = await _run(args) + if rc != 0: + # most likely a clip without an audio track: retry silent once + silent = list(args) + silent[silent.index(fc)] = fc.rsplit(";", 1)[0] + j = silent.index("[a]") + del silent[j - 1 : j + 1] # drop -map [a] + rc, err = await _run(silent) + if rc != 0: + raise RenderError(f"ffmpeg failed: {err.decode(errors='replace')[-300:]}") + except FileNotFoundError as exc: + raise RenderError("ffmpeg is not installed") from exc + finally: + chart_path.unlink(missing_ok=True) + if not out.exists() or out.stat().st_size == 0: + out.unlink(missing_ok=True) + if not out.exists(): + raise RenderError("ffmpeg produced no output") + log.info("reel rendered for shot %s (%.1f MB)", sid, out.stat().st_size / 1e6) + return out + + +async def _run(args: list[str]) -> tuple[int, bytes]: + proc = await asyncio.create_subprocess_exec( + *args, stdout=asyncio.subprocess.DEVNULL, stderr=asyncio.subprocess.PIPE + ) + _, err = await proc.communicate() + return proc.returncode or 0, err or b"" diff --git a/tests/test_calibrate.py b/tests/test_calibrate.py new file mode 100644 index 0000000..933283e --- /dev/null +++ b/tests/test_calibrate.py @@ -0,0 +1,65 @@ +"""Audio-onset offset calibration.""" + +import asyncio +import math +import pathlib +import shutil +import struct + +import pytest + +from matebot import calibrate + +FIXTURE = pathlib.Path(__file__).parent / "fixtures" / "000004.slog" + + +def pcm(silence_s: float, loud_s: float) -> bytes: + sr = calibrate.SAMPLE_RATE + quiet = [int(60 * math.sin(i / 7)) for i in range(int(silence_s * sr))] + loud = [int(9000 * math.sin(i / 3)) for i in range(int(loud_s * sr))] + return struct.pack(f"<{len(quiet) + len(loud)}h", *quiet, *loud) + + +def fake_ffmpeg(monkeypatch, data: bytes, rc: int = 0): + class Proc: + returncode = rc + + async def communicate(self): + return data, b"" + + async def fake_exec(*args, **kwargs): + return Proc() + + monkeypatch.setattr(asyncio, "create_subprocess_exec", fake_exec) + + +def test_audio_onset_finds_pump(monkeypatch, tmp_path): + fake_ffmpeg(monkeypatch, pcm(4.1, 10)) + onset = asyncio.run(calibrate.audio_onset(tmp_path / "clip.mp4")) + assert onset == pytest.approx(4.1, abs=0.15) + + +def test_audio_onset_none_when_quiet(monkeypatch, tmp_path): + fake_ffmpeg(monkeypatch, pcm(20, 0)) + assert asyncio.run(calibrate.audio_onset(tmp_path / "clip.mp4")) is None + + +def test_pump_start_from_fixture(): + t = calibrate.pump_start(FIXTURE.read_bytes()) + assert t is not None and 0 <= t < 60 + + +def test_calibrate_offset_end_to_end(monkeypatch, tmp_path): + shots = tmp_path / "shots" + shots.mkdir() + shutil.copy(FIXTURE, shots / "000004.slog") + (shots / "000004.mp4").write_bytes(b"clip") + t_chart = calibrate.pump_start(FIXTURE.read_bytes()) + fake_ffmpeg(monkeypatch, pcm(t_chart + 0.6, 8)) + offset = asyncio.run(calibrate.calibrate_offset(tmp_path, 4)) + assert offset == pytest.approx(0.6, abs=0.15) + + +def test_calibrate_offset_missing_files(tmp_path): + (tmp_path / "shots").mkdir() + assert asyncio.run(calibrate.calibrate_offset(tmp_path, 4)) is None diff --git a/tests/test_render.py b/tests/test_render.py new file mode 100644 index 0000000..94bebb4 --- /dev/null +++ b/tests/test_render.py @@ -0,0 +1,88 @@ +"""Shot reel rendering: ffmpeg arg construction, offset handling, fallbacks.""" + +import asyncio +import json +import pathlib +import shutil + +import pytest + +from matebot import render + +FIXTURE = pathlib.Path(__file__).parent / "fixtures" / "000004.slog" +GEOM = {"img_w": 880, "img_h": 495, "t_end": 16.0, "x0": 93.0, "x1": 772.0, + "y_top": 37.0, "plot_h": 358.0} + + +@pytest.fixture +def repo(tmp_path, monkeypatch): + shots = tmp_path / "shots" + shots.mkdir() + shutil.copy(FIXTURE, shots / "000004.slog") + (shots / "000004.mp4").write_bytes(b"fake clip") + import matebot.plot as plot + + monkeypatch.setattr(plot, "render_shot_chart", lambda shot, title=None: (b"png", GEOM)) + return tmp_path + + +def run_capture(monkeypatch, results): + calls = [] + + async def fake_run(args): + calls.append(args) + rc, err = results[min(len(calls), len(results)) - 1] + if rc == 0: + pathlib.Path(args[-1]).write_bytes(b"reel") + return rc, err + + monkeypatch.setattr(render, "_run", fake_run) + return calls + + +def test_positive_offset_trims_clip(repo, monkeypatch): + (repo / "shots" / "000004.video.json").write_text(json.dumps({"offset": 0.6})) + calls = run_capture(monkeypatch, [(0, b"")]) + out = asyncio.run(render.render_reel(repo, 4)) + args = calls[0] + assert args[args.index("-ss") + 1] == "0.60" + assert "adelay=0|0" in args[args.index("-filter_complex") + 1] + assert out.read_bytes() == b"reel" + out.unlink() + + +def test_negative_offset_delays_clip(repo, monkeypatch): + (repo / "shots" / "000004.video.json").write_text(json.dumps({"offset": -1.0})) + calls = run_capture(monkeypatch, [(0, b"")]) + out = asyncio.run(render.render_reel(repo, 4)) + fc = calls[0][calls[0].index("-filter_complex") + 1] + assert "-ss" not in calls[0] + assert "tpad=start_duration=1.00" in fc + assert "adelay=1000|1000" in fc + out.unlink() + + +def test_wipe_tracks_plot_area(repo, monkeypatch): + calls = run_capture(monkeypatch, [(0, b"")]) + out = asyncio.run(render.render_reel(repo, 4)) + fc = calls[0][calls[0].index("-filter_complex") + 1] + # x expression starts at the scaled axis origin, not the image edge + left = GEOM["x0"] * render.WIDTH / GEOM["img_w"] + assert f"{left:.1f}+" in fc + assert f"t/{GEOM['t_end']}" in fc + out.unlink() + + +def test_silent_retry_without_audio(repo, monkeypatch): + calls = run_capture(monkeypatch, [(1, b"Stream map '[a]' matches no streams"), (0, b"")]) + out = asyncio.run(render.render_reel(repo, 4)) + assert len(calls) == 2 + assert "[a]" not in calls[1] + assert "adelay" not in calls[1][calls[1].index("-filter_complex") + 1] + out.unlink() + + +def test_missing_clip_raises(repo): + (repo / "shots" / "000004.mp4").unlink() + with pytest.raises(render.RenderError): + asyncio.run(render.render_reel(repo, 4))