diff --git a/src/matebot/cli.py b/src/matebot/cli.py index aed5f7f..9e42cd7 100644 --- a/src/matebot/cli.py +++ b/src/matebot/cli.py @@ -76,7 +76,10 @@ async def _sync(config: Config) -> int: return 1 async with GaggiMateClient(config.machine_host) as client: # WS connection (for profiles) is optional here; HTTP does the rest. - pushed = await sync(client, config.data_repo, site_title=config.site_title) + pushed = await sync( + client, config.data_repo, + site_title=config.site_title, video_keep=config.video_keep, + ) print("synced" if pushed else "nothing new") return 0 @@ -117,7 +120,8 @@ def schedule_sync(*, quiet: bool = False): asyncio.create_task( sync_soon( client, config.data_repo, messenger.send, - site_title=config.site_title, state=state, quiet=quiet, + site_title=config.site_title, video_keep=config.video_keep, + state=state, quiet=quiet, ) ) diff --git a/src/matebot/commands.py b/src/matebot/commands.py index c5ed19a..71a880b 100644 --- a/src/matebot/commands.py +++ b/src/matebot/commands.py @@ -30,6 +30,7 @@ "/newbag [name] — start tracking a bean bag (optional feature)\n" "/bag — how much is left in the open bags\n" "/tossbag [name] — close out a bag (emptied, binned, or gifted)\n" + "/vsync <±seconds> — nudge the latest shot video's sync (e.g. /vsync +0.5)\n" "/digest — your last 7 days in espresso\n" "/help — this list" ) @@ -246,6 +247,28 @@ async def _cmd_tossbag(self) -> None: await self.messenger.send(bags.toss_bag(self.state, " ".join(self._args) or None)) + async def _cmd_vsync(self) -> None: + from . import video + + if not self.config.data_repo: + await self.messenger.send("No journal repo configured — nothing to sync videos in.") + return + shot = video.latest_video_shot(self.config.data_repo) + if shot is None: + await self.messenger.send("No shot videos yet.") + return + try: + delta = float(self._args[0].replace("+", "")) if self._args else 0.0 + except ValueError: + await self.messenger.send("Usage: /vsync <±seconds> — e.g. /vsync +0.5 or /vsync -1") + return + current = video.get_offset(self.config.data_repo, shot) or 0.0 + video.set_offset(self.config.data_repo, shot, current + delta) + await self.messenger.send( + f"🎬 Shot #{shot} video offset: {current:+.2f}s → {current + delta:+.2f}s. " + "Journal updates on the next sync." + ) + async def _cmd_digest(self) -> None: text = await build_digest(self.client, self.config) await self.messenger.send(text or "No shots in the last 7 days. The machine misses you.") diff --git a/src/matebot/config.py b/src/matebot/config.py index 55d6624..1708945 100644 --- a/src/matebot/config.py +++ b/src/matebot/config.py @@ -31,6 +31,7 @@ "clean_every": "MATEBOT_CLEAN_EVERY", "water_warn_pct": "MATEBOT_WATER_WARN", "autoheat_window": "MATEBOT_AUTOHEAT", + "video_keep": "MATEBOT_VIDEO_KEEP", "plots_enabled": "MATEBOT_PLOTS", "wake_hook": "MATEBOT_WAKE_HOOK", "sleep_hook": "MATEBOT_SLEEP_HOOK", @@ -63,6 +64,7 @@ class Config: water_warn_pct: int = 15 # warn when the tank drops below this percent; 0 = off # e.g. "06:30-07:00": machine powered on in this window switches to brew, once a day autoheat_window: str = "" + video_keep: int = 15 # newest N shot videos kept in the journal; 0 = keep all 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 @@ -83,7 +85,7 @@ def load(cls, path: str | Path | None = None) -> Config: continue if f.name == "min_shot_s": value = float(value) - elif f.name in ("clean_every", "water_warn_pct"): + elif f.name in ("clean_every", "water_warn_pct", "video_keep"): value = int(value) elif f.name in ("sync_enabled", "hints_enabled", "digest_enabled", "plots_enabled"): value = str(value).lower() in ("1", "true", "yes", "on") diff --git a/src/matebot/messengers/telegram.py b/src/matebot/messengers/telegram.py index 8fae88e..c1ba8ae 100644 --- a/src/matebot/messengers/telegram.py +++ b/src/matebot/messengers/telegram.py @@ -54,6 +54,7 @@ async def start(self) -> None: BotCommand("bag", "how much is left in the bean bag"), BotCommand("newbag", "start tracking a bean bag"), BotCommand("tossbag", "close out a bag"), + BotCommand("vsync", "nudge shot video sync"), BotCommand("help", "list commands"), ]) await self.app.updater.start_polling(drop_pending_updates=True) diff --git a/src/matebot/sitegen.py b/src/matebot/sitegen.py index e615168..2cbff4f 100644 --- a/src/matebot/sitegen.py +++ b/src/matebot/sitegen.py @@ -74,6 +74,23 @@ def _load_notes(path: Path) -> dict | None: return None +def _video_info(shots_dir: Path, out_dir: Path, sid: str) -> dict: + """Copy the shot's video into the site (if any) and return payload fields.""" + mp4 = shots_dir / f"{sid}.mp4" + if not mp4.exists(): + return {} + dest = out_dir / "video" / f"{sid}.mp4" + dest.parent.mkdir(parents=True, exist_ok=True) + if not dest.exists() or dest.stat().st_mtime < mp4.stat().st_mtime: + dest.write_bytes(mp4.read_bytes()) + offset = 0.0 + try: + offset = float(json.loads((shots_dir / f"{sid}.video.json").read_text())["offset"]) + except (OSError, ValueError, KeyError): + pass + return {"video": f"video/{sid}.mp4", "video_offset": offset} + + def generate(shots_dir: str | Path, out_dir: str | Path, *, title: str = "Shot Journal") -> int: """Build the site; returns the number of shots included.""" shots_dir, out_dir = Path(shots_dir), Path(out_dir) @@ -89,6 +106,7 @@ def generate(shots_dir: str | Path, out_dir: str | Path, *, title: str = "Shot J sid = slog_path.stem notes = _load_notes(slog_path.with_suffix(".json")) payload = _shot_payload(shot, notes) + payload.update(_video_info(shots_dir, out_dir, sid)) (out_dir / "shots" / f"{sid}.json").write_text( json.dumps(payload, separators=(",", ":")) ) @@ -102,6 +120,7 @@ def generate(shots_dir: str | Path, out_dir: str | Path, *, title: str = "Shot J "final_g": shot.final_weight_g, "peak_bar": max(cp) if cp else 0, "rating": (notes or {}).get("rating") or 0, + "has_video": (shots_dir / f"{sid}.mp4").exists(), "bean": (notes or {}).get("beanType", ""), "ratio": (notes or {}).get("ratio", ""), "taste": (notes or {}).get("balanceTaste", ""), diff --git a/src/matebot/sync.py b/src/matebot/sync.py index df0443a..375fc39 100644 --- a/src/matebot/sync.py +++ b/src/matebot/sync.py @@ -39,7 +39,8 @@ def _safe_name(label: str) -> str: async def sync( - client: GaggiMateClient, repo: str | Path, *, site_title: str = "Shot Journal" + client: GaggiMateClient, repo: str | Path, *, + site_title: str = "Shot Journal", video_keep: int = 15, ) -> bool: """Pull, mirror machine state into the repo, regenerate site, commit, push. @@ -109,6 +110,9 @@ async def sync( log.info("settings skipped (%s)", exc) # --- site --- + from .video import prune_videos + + prune_videos(repo, keep=video_keep) generate(shots_dir, repo / "docs", title=site_title) # --- commit + push --- @@ -132,7 +136,7 @@ async def sync( async def sync_soon( client: GaggiMateClient, repo: str | Path, notify, *, - site_title: str = "Shot Journal", state=None, quiet: bool = False, + site_title: str = "Shot Journal", video_keep: int = 15, state=None, quiet: bool = False, ) -> None: """Post-shot sync wrapper: run, report problems, never raise. @@ -140,7 +144,7 @@ async def sync_soon( can retry when the machine comes back online. """ try: - await sync(client, repo, site_title=site_title) + await sync(client, repo, site_title=site_title, video_keep=video_keep) except SyncConflict as exc: await notify(f"⚠️ Shot journal sync hit a git conflict — fix it manually:\n{exc}") except (TimeoutError, aiohttp.ClientError, OSError) as exc: diff --git a/src/matebot/video.py b/src/matebot/video.py new file mode 100644 index 0000000..cd4b4e0 --- /dev/null +++ b/src/matebot/video.py @@ -0,0 +1,88 @@ +"""Shot video handling: transcode, sidecar metadata, retention pruning. + +Videos live next to the shot files as ``shots/NNNNNN.mp4`` with a sidecar +``shots/NNNNNN.video.json`` (currently ``{"offset": seconds}`` — where shot +t=0 sits relative to video t=0). The sync pipeline copies them into +``docs/video/`` for GitHub Pages and prunes to the newest N so the repo +doesn't grow unbounded (~3 MB per clip). +""" + +from __future__ import annotations + +import asyncio +import json +import logging +from pathlib import Path + +log = logging.getLogger(__name__) + +FFMPEG_ARGS = [ + "-c:v", "libx264", "-preset", "veryfast", "-crf", "26", + "-vf", "scale='min(1280,iw)':-2", + "-c:a", "aac", "-b:a", "96k", + "-movflags", "+faststart", +] + + +class VideoError(RuntimeError): + pass + + +async def attach_video( + repo: str | Path, shot_id: int, source: str | Path, *, offset: float = 0.0 +) -> Path: + """Transcode *source* into the repo as the shot's video; returns the mp4 path.""" + repo = Path(repo) + out = repo / "shots" / f"{shot_id:06d}.mp4" + out.parent.mkdir(parents=True, exist_ok=True) + proc = await asyncio.create_subprocess_exec( + "ffmpeg", "-hide_banner", "-loglevel", "error", "-y", + "-i", str(source), *FFMPEG_ARGS, str(out), + stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.STDOUT, + ) + stdout, _ = await proc.communicate() + if proc.returncode != 0 or not out.exists() or out.stat().st_size == 0: + out.unlink(missing_ok=True) + raise VideoError(f"ffmpeg failed ({proc.returncode}): {stdout.decode()[-300:]}") + set_offset(repo, shot_id, offset) + log.info("video attached to shot %06d (%.1f MB)", shot_id, out.stat().st_size / 1e6) + return out + + +def sidecar_path(repo: str | Path, shot_id: int) -> Path: + return Path(repo) / "shots" / f"{shot_id:06d}.video.json" + + +def set_offset(repo: str | Path, shot_id: int, offset: float) -> None: + sidecar_path(repo, shot_id).write_text(json.dumps({"offset": round(offset, 2)})) + + +def get_offset(repo: str | Path, shot_id: int) -> float | None: + """Offset for the shot's video, or None if the shot has no video.""" + if not (Path(repo) / "shots" / f"{shot_id:06d}.mp4").exists(): + return None + try: + return float(json.loads(sidecar_path(repo, shot_id).read_text())["offset"]) + except (OSError, ValueError, KeyError): + return 0.0 + + +def latest_video_shot(repo: str | Path) -> int | None: + videos = sorted((Path(repo) / "shots").glob("[0-9]*.mp4")) + return int(videos[-1].stem) if videos else None + + +def prune_videos(repo: str | Path, keep: int) -> int: + """Delete all but the newest *keep* videos (shots/ and docs/video/); returns count removed.""" + repo = Path(repo) + videos = sorted((repo / "shots").glob("[0-9]*.mp4")) + removed = 0 + for mp4 in videos[:-keep] if keep > 0 else []: + shot = mp4.stem + mp4.unlink(missing_ok=True) + (repo / "shots" / f"{shot}.video.json").unlink(missing_ok=True) + (repo / "docs" / "video" / f"{shot}.mp4").unlink(missing_ok=True) + removed += 1 + if removed: + log.info("pruned %d old shot videos (keeping %d)", removed, keep) + return removed diff --git a/src/matebot/web/app.js b/src/matebot/web/app.js index a6f3298..f0e84a3 100644 --- a/src/matebot/web/app.js +++ b/src/matebot/web/app.js @@ -88,6 +88,21 @@ function renderDetail(id, shot, compareId, compare) { const charts = $("#charts"); charts.innerHTML = ""; + VIDEO = null; + if (shot.video) { + const holder = document.createElement("div"); + holder.className = "chart-card video-card"; + holder.innerHTML = ` + `; + charts.appendChild(holder); + VIDEO = holder.querySelector("video"); + VIDEO_OFFSET = shot.video_offset || 0; + const mute = holder.querySelector(".vunmute"); + mute.addEventListener("click", () => { + VIDEO.muted = !VIDEO.muted; + mute.textContent = VIDEO.muted ? "🔇" : "🔊"; + }); + } const t = shot.series.t; const S = (key) => shot.series[key] && shot.series[key].some(v => v !== 0) ? shot.series[key] : null; @@ -131,6 +146,16 @@ function rgba(hex, alpha) { } let CHART = null; +let VIDEO = null; +let VIDEO_OFFSET = 0; + +function videoSeek(time) { + if (!VIDEO) return; + const vt = time + VIDEO_OFFSET; + if (VIDEO.readyState >= 1 && Math.abs(VIDEO.currentTime - vt) > 0.25) { + VIDEO.currentTime = Math.max(0, vt); + } +} function combinedChart(parent, t, phases, S, overlay) { if (!t || t.length < 2 || typeof Chart === "undefined") return; @@ -321,6 +346,7 @@ function attachPlayer(card, t, xMax, S, overlay) { ph.display = playing || time < xMax; ph.xMin = ph.xMax = time; CHART.update("none"); + if (!playing) videoSeek(time); const i = idxAt(time); for (const [k] of TILES) { const el = $(`#tile-${k}`); @@ -338,6 +364,7 @@ function attachPlayer(card, t, xMax, S, overlay) { if (raf) cancelAnimationFrame(raf); raf = null; lastTs = null; playBtn.innerHTML = ICON_PLAY; + VIDEO?.pause(); } function tick(ts) { @@ -350,6 +377,7 @@ function attachPlayer(card, t, xMax, S, overlay) { return; } renderAt(playT, true); + videoSeek(playT); } lastTs = ts; raf = requestAnimationFrame(tick); @@ -359,8 +387,16 @@ function attachPlayer(card, t, xMax, S, overlay) { if (raf) { stop(); return; } if (playT >= xMax) playT = 0; playBtn.innerHTML = ICON_PAUSE; + if (VIDEO) { + VIDEO.playbackRate = Number($("#speed").value); + videoSeek(playT); + VIDEO.play().catch(() => {}); + } raf = requestAnimationFrame(tick); }); + $("#speed").addEventListener("change", () => { + if (VIDEO) VIDEO.playbackRate = Number($("#speed").value); + }); $("#scrub").addEventListener("input", () => { stop(); playT = Number($("#scrub").value); diff --git a/src/matebot/web/style.css b/src/matebot/web/style.css index 9c65d4c..d730b4c 100644 --- a/src/matebot/web/style.css +++ b/src/matebot/web/style.css @@ -164,3 +164,8 @@ body.theater-open { overflow: hidden; } .ptime { display: none; } .chart-card.theater .chart-holder { height: calc(100vh - 230px); } } + +/* ---- shot video ---- */ +.video-card { padding: 0; overflow: hidden; position: relative; } +.video-card video { display: block; width: 100%; max-height: 420px; background: #000; } +.video-card .vunmute { position: absolute; right: 10px; bottom: 10px; opacity: 0.85; } diff --git a/tests/test_commands.py b/tests/test_commands.py index a06e750..871b472 100644 --- a/tests/test_commands.py +++ b/tests/test_commands.py @@ -350,3 +350,22 @@ async def test_brew_enforcer_gives_up_loudly(setup, monkeypatch): clock["t"] += 9 await router.on_frame({"tp": "evt:status", "m": 0, "ct": 25.0, "tt": 0}) assert len(client.requests) == 1 + + +@pytest.mark.asyncio +async def test_vsync_adjusts_latest_video_offset(setup, tmp_path): + from matebot import video as videomod + + router, client, state, convo, fm, cache = setup + router.config.data_repo = str(tmp_path) + await router.handle("/vsync +0.5") + assert "No shot videos yet" in fm.sent[-1] + + (tmp_path / "shots").mkdir() + (tmp_path / "shots" / "000080.mp4").write_bytes(b"v") + videomod.set_offset(tmp_path, 80, -1.0) + await router.handle("/vsync +0.5") + assert videomod.get_offset(tmp_path, 80) == -0.5 + assert "#80" in fm.sent[-1] + await router.handle("/vsync -0.25") + assert videomod.get_offset(tmp_path, 80) == -0.75 diff --git a/tests/test_sync_retry.py b/tests/test_sync_retry.py index 58de77a..a308210 100644 --- a/tests/test_sync_retry.py +++ b/tests/test_sync_retry.py @@ -16,7 +16,7 @@ async def __call__(self, text): @pytest.mark.asyncio async def test_machine_offline_sets_pending_flag(tmp_path, monkeypatch): - async def boom(client, repo, site_title="x"): + async def boom(client, repo, site_title="x", video_keep=15): raise aiohttp.ClientConnectionError("machine off") monkeypatch.setattr(sync_mod, "sync", boom) @@ -33,7 +33,7 @@ async def boom(client, repo, site_title="x"): @pytest.mark.asyncio async def test_success_clears_pending_flag(tmp_path, monkeypatch): - async def ok(client, repo, site_title="x"): + async def ok(client, repo, site_title="x", video_keep=15): return True monkeypatch.setattr(sync_mod, "sync", ok) diff --git a/tests/test_video.py b/tests/test_video.py new file mode 100644 index 0000000..d63e50f --- /dev/null +++ b/tests/test_video.py @@ -0,0 +1,86 @@ +import json +import pathlib +import shutil + +import pytest + +from matebot import video + +FIXTURES = pathlib.Path(__file__).parent / "fixtures" + + +class FakeProc: + def __init__(self, returncode=0): + self.returncode = returncode + + async def communicate(self): + return b"", None + + +@pytest.mark.asyncio +async def test_attach_video_transcodes_and_writes_sidecar(tmp_path, monkeypatch): + async def fake_exec(*argv, **kw): + # ffmpeg is mocked: "transcode" by writing the output path (last arg) + pathlib.Path(argv[-1]).write_bytes(b"MP4!") + return FakeProc(0) + + monkeypatch.setattr(video.asyncio, "create_subprocess_exec", fake_exec) + out = await video.attach_video(tmp_path, 77, tmp_path / "in.webm", offset=-1.2) + assert out == tmp_path / "shots" / "000077.mp4" + assert out.read_bytes() == b"MP4!" + assert json.loads(video.sidecar_path(tmp_path, 77).read_text()) == {"offset": -1.2} + assert video.get_offset(tmp_path, 77) == -1.2 + assert video.latest_video_shot(tmp_path) == 77 + + +@pytest.mark.asyncio +async def test_attach_video_ffmpeg_failure(tmp_path, monkeypatch): + async def fake_exec(*argv, **kw): + return FakeProc(1) + + monkeypatch.setattr(video.asyncio, "create_subprocess_exec", fake_exec) + with pytest.raises(video.VideoError): + await video.attach_video(tmp_path, 78, tmp_path / "in.webm") + assert video.get_offset(tmp_path, 78) is None # no partial artifacts + + +def test_get_offset_defaults_and_absence(tmp_path): + assert video.get_offset(tmp_path, 5) is None # no video at all + (tmp_path / "shots").mkdir() + (tmp_path / "shots" / "000005.mp4").write_bytes(b"x") + assert video.get_offset(tmp_path, 5) == 0.0 # video without sidecar + + +def test_prune_keeps_newest(tmp_path): + shots = tmp_path / "shots" + docs = tmp_path / "docs" / "video" + shots.mkdir(parents=True) + docs.mkdir(parents=True) + for sid in range(1, 6): + (shots / f"{sid:06d}.mp4").write_bytes(b"v") + (shots / f"{sid:06d}.video.json").write_text("{}") + (docs / f"{sid:06d}.mp4").write_bytes(b"v") + removed = video.prune_videos(tmp_path, keep=2) + assert removed == 3 + assert sorted(p.name for p in shots.glob("*.mp4")) == ["000004.mp4", "000005.mp4"] + assert sorted(p.name for p in docs.glob("*.mp4")) == ["000004.mp4", "000005.mp4"] + assert video.prune_videos(tmp_path, keep=0) == 0 # 0 = keep everything + + +def test_sitegen_includes_video(tmp_path): + from matebot.sitegen import generate + + shots = tmp_path / "shots" + shots.mkdir() + shutil.copy(FIXTURES / "000004.slog", shots / "000004.slog") + (shots / "000004.mp4").write_bytes(b"MP4DATA") + (shots / "000004.video.json").write_text('{"offset": -0.8}') + out = tmp_path / "docs" + generate(shots, out, title="T") + + payload = json.loads((out / "shots" / "000004.json").read_text()) + assert payload["video"] == "video/000004.mp4" + assert payload["video_offset"] == -0.8 + assert (out / "video" / "000004.mp4").read_bytes() == b"MP4DATA" + index = json.loads((out / "index.json").read_text()) + assert index["shots"][0]["has_video"] is True