diff --git a/.claude/rules/documentation.md b/.claude/rules/documentation.md new file mode 100644 index 0000000..54885de --- /dev/null +++ b/.claude/rules/documentation.md @@ -0,0 +1,29 @@ +--- +globs: ["docs/**", "README.md", "CHANGELOG.md"] +--- + +# Documentation Rules + +**README and docs must be updated with each user-facing phase. No feature ships without documentation.** + +## What goes where + +| Content | Location | +|---|---| +| Project overview, install, quick start | `README.md` | +| Detailed guides, tutorials | `docs/guide/*.md` | +| CLI command reference | `docs/cli/*.md` | +| Install instructions | `docs/install.md` | +| Getting started tutorial | `docs/quickstart.md` | +| Changelog | `CHANGELOG.md` (included in docs via `docs/changelog.md`) | + +## Conventions + +- All docs in Markdown (MyST parser for Sphinx) +- Use `:::{note}` / `:::{warning}` for admonitions +- Cross-reference with `{doc}` role: `{doc}`/guide/configuration`` +- Code blocks with language specifier: ````bash`, ````python`, ````json` +- Every new CLI command gets a docs page entry in the appropriate `docs/cli/*.md` file +- Every new config option gets documented in `docs/guide/configuration.md` +- Every new sport gets added to `docs/guide/sports.md` +- Run `make docs` to build locally, `make docs-serve` to preview at `http://localhost:8000` diff --git a/.claude/rules/plugin-development.md b/.claude/rules/plugin-development.md new file mode 100644 index 0000000..d52168a --- /dev/null +++ b/.claude/rules/plugin-development.md @@ -0,0 +1,267 @@ +--- +globs: ["reeln/plugins/**", "reeln/models/plugin_schema.py", "registry/**", "tests/**/test_plugin*.py", "tests/**/test_registry.py", "tests/**/test_hooks.py", "tests/**/test_capabilities.py"] +--- + +# Plugin Development + +Complete reference for building reeln-cli plugins. + +## Plugin Anatomy + +A plugin is a Python package with an entry point in the `reeln.plugins` group. + +```python +# pyproject.toml +[project.entry-points."reeln.plugins"] +myplugin = "reeln_myplugin:MyPlugin" +``` + +The plugin class must expose these attributes and a `register()` method: + +```python +class MyPlugin: + name: str = "myplugin" # unique plugin name + version: str = "0.1.0" # semver, kept in sync with __init__.__version__ + api_version: int = 1 # plugin API version (currently 1) + config_schema: PluginConfigSchema = PluginConfigSchema(fields=(...)) + + def __init__(self, config: dict[str, Any] | None = None) -> None: + self._config: dict[str, Any] = config or {} + + def register(self, registry: HookRegistry) -> None: + registry.register(Hook.ON_GAME_INIT, self.on_game_init) + + def on_game_init(self, context: HookContext) -> None: + ... +``` + +## Hook System + +**Hook enum** (`reeln.plugins.hooks.Hook`) — 13 lifecycle hooks: + +| Hook | Emitted when | +|------|-------------| +| `PRE_RENDER` | Before a render operation starts | +| `POST_RENDER` | After a render completes | +| `ON_CLIP_AVAILABLE` | A new clip file is ready | +| `ON_EVENT_CREATED` | A new event is created | +| `ON_EVENT_TAGGED` | An event is tagged/categorized | +| `ON_GAME_INIT` | `reeln game init` sets up a new game | +| `ON_GAME_READY` | After all `ON_GAME_INIT` handlers complete — plugins read shared context from init phase | +| `ON_GAME_FINISH` | `reeln game finish` finalizes a game | +| `ON_HIGHLIGHTS_MERGED` | Segment highlights are merged into a reel | +| `ON_SEGMENT_START` | A new segment begins | +| `ON_SEGMENT_COMPLETE` | A segment finishes | +| `ON_FRAMES_EXTRACTED` | Frames extracted from a clip for smart zoom analysis | +| `ON_ERROR` | An error occurs during any operation | + +**HookContext** — frozen dataclass passed to every handler: + +```python +@dataclass(frozen=True) +class HookContext: + hook: Hook # which hook fired + data: dict[str, Any] = field(...) # hook-specific payload (e.g. game_info) + shared: dict[str, Any] = field(...) # mutable cross-plugin communication +``` + +**Handler signature:** `def on_(self, context: HookContext) -> None` + +Handlers are auto-discovered by `on_` naming convention (e.g., `on_game_init` for `ON_GAME_INIT`). + +## Shared Context Convention + +Plugins communicate via `context.shared` — a mutable dict on the frozen dataclass: + +```python +# Writer (e.g., google plugin) +context.shared["livestreams"] = context.shared.get("livestreams", {}) +context.shared["livestreams"]["google"] = "https://youtube.com/live/abc123" + +# Reader (e.g., OBS plugin) +url = context.shared.get("livestreams", {}).get("google") +``` + +## Capability Protocols + +Plugins can implement typed protocols for specific capabilities (`reeln.plugins.capabilities`): + +| Protocol | Method | Purpose | +|----------|--------|---------| +| `Uploader` | `upload(path, *, metadata) -> str` | Upload rendered media to external services | +| `MetadataEnricher` | `enrich(event_data) -> dict` | Enrich event metadata | +| `Notifier` | `notify(message, *, metadata) -> None` | Send notifications | +| `Generator` | `generate(context) -> GeneratorResult` | Generate media assets | + +## Config Schema + +Declare plugin config with `PluginConfigSchema` and `ConfigField` (`reeln.models.plugin_schema`): + +```python +from reeln.models.plugin_schema import ConfigField, PluginConfigSchema + +config_schema = PluginConfigSchema( + fields=( + ConfigField( + name="api_key", + field_type="str", # str, int, float, bool, list + required=True, + description="API key for the service", + secret=True, # masked in `reeln config show` + ), + ConfigField( + name="timeout", + field_type="int", + default=30, + description="Request timeout in seconds", + ), + ) +) +``` + +## Plugin Discovery + +The plugin loader discovers plugins via `importlib.metadata` entry points in the `reeln.plugins` group. Each entry point maps a plugin name to a class: + +```toml +[project.entry-points."reeln.plugins"] +google = "reeln_google_plugin:GooglePlugin" +``` + +Users enable/disable plugins via `reeln plugins enable ` / `reeln plugins disable `. + +## Registry + +Plugin registry lives at `registry/plugins.json`. Format: + +```json +{ + "registry_version": 1, + "plugins": [ + { + "name": "myplugin", + "package": "reeln-plugin-myplugin", + "description": "What the plugin does", + "capabilities": ["hook:ON_GAME_INIT"], + "homepage": "https://github.com/StreamnDad/reeln-plugin-myplugin", + "min_reeln_version": "0.0.19", + "author": "StreamnDad", + "license": "AGPL-3.0", + "ui_contributions": { ... } + } + ] +} +``` + +When adding a new plugin, append to the `plugins` array. + +## UI Contributions (reeln-dock) + +Plugins can declare UI fields that appear in the reeln-dock desktop app. Fields only +render when the plugin is installed **and** enabled. Add `ui_contributions` to the +registry entry. + +### Screens + +| Screen | Where it appears | +|--------|-----------------| +| `render_options` | ClipReviewPanel overrides section (below crop/scale/speed) | +| `settings` | Settings > Rendering > Plugin Defaults section | +| `clip_review` | ClipReviewPanel metadata section | + +### Field Schema + +```json +{ + "ui_contributions": { + "render_options": { + "fields": [ + { + "id": "smart", + "label": "Smart Zoom", + "type": "boolean", + "default": false, + "description": "AI-powered smart crop tracking", + "maps_to": "smart" + }, + { + "id": "zoom_frames", + "label": "Zoom Frames", + "type": "number", + "min": 1, + "max": 30, + "step": 1, + "description": "Keyframes for smart zoom path", + "maps_to": "zoom_frames" + } + ] + } + } +} +``` + +### Field Properties + +| Property | Type | Required | Description | +|----------|------|----------|-------------| +| `id` | string | yes | Unique field identifier | +| `label` | string | yes | Display label | +| `type` | string | yes | `boolean`, `number`, `string`, or `select` | +| `default` | any | no | Default value | +| `description` | string | no | Help text shown below the field | +| `min` | number | no | Minimum (number fields) | +| `max` | number | no | Maximum (number fields) | +| `step` | number | no | Step increment (number fields) | +| `options` | array | no | `[{value, label}]` for select fields | +| `maps_to` | string | no | Key in `RenderOverrides` this value maps to. Defaults to `id` | + +### How Values Flow + +- **`render_options`** fields → `RenderOverrides` object → passed to render backend +- **`settings`** fields → `DockSettings.rendering.plugin_field_defaults` → auto-applied as override defaults +- **`clip_review`** fields → event metadata + +The `maps_to` field controls which override key the value is stored under. For example, +`"maps_to": "smart"` maps to `RenderOverrides.smart`. Use this when the backend already +has a named field. For new plugin-specific fields, the value passes through via the +`RenderOverrides` index signature (TS) / `serde(flatten)` (Rust). + +## Standard Boilerplate + +Use the reeln-plugin-template repo to scaffold new plugins. It provides: + +- `Makefile` — dev-install, test, lint, format, check targets +- `.github/workflows/ci.yml` — Python 3.11/3.12/3.13 matrix CI +- `.github/workflows/release.yml` — tag-triggered OIDC PyPI publish +- `pyproject.toml` — hatchling build, ruff/mypy config +- Plugin skeleton with `__init__.py` and `plugin.py` +- Test skeleton with conftest fixtures and basic tests +- `CHANGELOG.md`, `README.md`, `CLAUDE.md` + +## Feature Flags + +Every capability a plugin provides **must** be feature-flagged in the plugin config and **default to `false`**. Users explicitly opt in to each capability. Hook handlers check the flag before executing. + +```python +ConfigField(name="create_livestream", field_type="bool", default=False, description="Enable livestream creation on game init"), +``` + +```python +def on_game_init(self, context: HookContext) -> None: + if not self._config.get("create_livestream", False): + return + ... +``` + +## Plugin Conventions + +- **Feature flags:** every capability defaults to `false` — users opt in explicitly +- **Coverage:** 100% line + branch — no exceptions +- **Versioning:** semver, update `__version__` (in `__init__.py`), `version` (in `plugin.py`), and `CHANGELOG.md` in lockstep +- **Style:** `from __future__ import annotations` in every module, 4-space indent, snake_case, type hints on all signatures +- **Paths:** `pathlib.Path` everywhere +- **License:** AGPL-3.0-only +- **Tests:** use `tmp_path` for file I/O, mock external API clients +- **Package naming:** `reeln-plugin-` (PyPI), `reeln__plugin` (Python package) +- **Entry point:** `reeln.plugins` group, plugin name as key +- **No CLI arg registration:** plugins do not register CLI arguments. Use feature flags in plugin config (`smart_zoom_enabled`, etc.). Core CLI flags (`--smart`) trigger hooks; plugins decide behavior via their own config diff --git a/.claude/rules/render-architecture.md b/.claude/rules/render-architecture.md new file mode 100644 index 0000000..1c7ca1f --- /dev/null +++ b/.claude/rules/render-architecture.md @@ -0,0 +1,208 @@ +--- +globs: ["reeln/core/shorts.py", "reeln/core/zoom.py", "reeln/core/zoom_debug.py", "reeln/core/ffmpeg.py", "reeln/core/profiles.py", "reeln/core/iterations.py", "reeln/core/branding.py", "reeln/models/short.py", "reeln/models/zoom.py", "reeln/models/profile.py", "reeln/models/branding.py", "reeln/commands/render.py", "reeln/data/templates/**", "tests/**/test_shorts.py", "tests/**/test_zoom*.py", "tests/**/test_ffmpeg.py", "tests/**/test_profiles.py", "tests/**/test_iterations.py", "tests/**/test_branding.py", "tests/**/test_overlay.py", "tests/**/test_render.py"] +--- + +# Render Architecture + +Short-form rendering (`reeln render short`) converts landscape source clips into +portrait (9:16) output. The system has several independent axes that compose freely. + +## CLI Flags vs Profile Config — Scope Rules + +CLI flags and render profile fields fall into two categories: + +- **CLI flags** apply globally to the entire render, including all iterations. + They are set once on the command line and cannot be overridden per-iteration. +- **Profile fields** can vary per iteration. Each profile in the iteration list + can set different values. + +| Parameter | CLI flag | Profile field | Scope | +|-----------|----------|---------------|-------| +| Framing | `--crop pad\|crop` | `crop_mode` | Per-profile | +| Scale | `--scale 0.5-3.0` | `scale` | Per-profile | +| Tracking | `--smart` | `smart` | **Global** — applies to all iterations | +| Zoom frames | `--zoom-frames 1-20` | — | Global (frame extraction happens once) | +| Speed | `--speed 0.5-2.0` | `speed` | Per-profile | +| Speed segments | — | `speed_segments` | Per-profile (config only, no CLI flag) | +| LUT | `--lut path.cube` | `lut` | Per-profile | +| Subtitle | `--subtitle path.ass` | `subtitle_template` | Per-profile | +| Anchor | `--anchor center` | `anchor_x`, `anchor_y` | Per-profile | +| Pad colour | `--pad-color black` | `pad_color` | Per-profile | +| Encoding | — | `codec`, `preset`, `crf` | Per-profile | + +**Key rule:** `--smart` is a CLI flag that enables smart tracking for the entire +render. When iterating, every iteration gets smart tracking if `--smart` is set, +regardless of what profiles are configured. The vision plugin runs once, produces +a single `ZoomPath`, and that path is used (possibly remapped) for each iteration. + +## Framing Modes + +Two framing modes control how landscape source fits the 9:16 target: + +- **PAD** (default) — scale source to fit width, pad top/bottom with solid colour. + Content is fully visible but letterboxed. +- **CROP** — scale source to fill height, crop sides. Content fills the frame but + edges are lost. + +## Scale + +`--scale` (0.5-3.0, default 1.0) zooms the content before framing: + +- **Crop + scale > 1.0** — zoom in further, then crop to target. +- **Pad + scale > 1.0** — zoom in, overflow crop to target, then pad remaining space. + +## Smart Tracking + +`--smart` enables vision-based tracking. A plugin analyses extracted frames +and returns a `ZoomPath` (ordered `(timestamp, center_x, center_y)` points). +The filter chain uses `t`-based ffmpeg expressions to dynamically follow +the action. + +- **Smart crop** — dynamic `crop=w:h:x:y` with `t`-based x/y from piecewise lerp. + Both horizontal and vertical axes track the action. +- **Smart pad** — `overlay` on a generated `color` background with `t`-based x + positioning. Only horizontal (center_x) tracks; vertical stays centred. + ffmpeg's `pad` filter cannot evaluate the `t` variable, so overlay is required. + +**Piecewise lerp** (`build_piecewise_lerp()`) builds flat sum-of-products +ffmpeg expressions with pre-computed `A*t+B` coefficients, downsampled to +8 segments max to stay within ffmpeg's expression parser limits. + +**Fallback:** `--smart` without a vision plugin falls back to static centre +with a warning. + +**Deprecated crop modes:** `--crop smart` -> `--crop crop --smart`, +`--crop smart_pad` -> `--crop pad --smart`. Old values still work but emit +a deprecation warning. + +**Debug output:** `--debug --smart` creates `debug/zoom/` with +`frame_NNNN.png`, `annotated_NNNN.png` (crosshair + crop box), and +`zoom_path.json` (full zoom data + generated ffmpeg expressions). + +## Filter Chain Order + +Standard (single speed): +``` +LUT -> speed (setpts) -> scale -> overflow_crop (pad + scale>1.0) -> crop/pad -> final_scale (crop only) -> subtitle +``` + +Smart pad replaces the crop/pad step with a multi-stream graph: +``` +[0:v] LUT, speed, scale [_fg] +color=... [_bg] +[_bg][_fg] overlay(t-based x) -> subtitle +``` + +## Speed Segments + +Variable speed within a single clip — e.g., normal for 5s, slowmo at 0.5x +for 3s, then back to normal. Configured as a profile field (no CLI flag): + +```json +{ + "render_profiles": { + "slowmo-middle": { + "speed_segments": [ + {"until": 5.0, "speed": 1.0}, + {"until": 8.0, "speed": 0.5}, + {"speed": 1.0} + ] + } + } +} +``` + +**Validation rules:** +- At least 2 segments (otherwise use scalar `speed`) +- All except last must have `until` set; last must have `until=None` +- `until` values strictly increasing and positive +- All speeds in [0.25, 4.0] +- Mutually exclusive with scalar `speed` (cannot use both) + +**ffmpeg pattern:** `split=N -> trim per segment -> setpts=PTS-STARTPTS -> +setpts=PTS/{speed} -> concat`. Audio uses `asplit -> atrim -> asetpts -> +atempo -> concat`. Both video and audio go through `-filter_complex` +(no separate `-af`). Output streams labelled `[vfinal]` and `[afinal]` +with explicit `-map` flags. + +**Audio atempo:** ffmpeg's `atempo` accepts [0.5, 100.0]. Speeds below 0.5 +chain multiple `atempo=0.5` filters (e.g., 0.25 = `atempo=0.5,atempo=0.5`). + +**Speed segments + smart tracking:** Fully supported. The zoom path timestamps +(in source time) are remapped to output time via `remap_zoom_path_for_speed_segments()` +so `t`-based ffmpeg expressions align with the stretched timeline. For smart pad, +the overlay is wired after the speed-segments concat: +``` +[0:v] LUT, split=N -> trim/speed -> concat -> scale [_fg] +color=... [_bg] +[_bg][_fg] overlay(t-based x, remapped timestamps) [vfinal] +[0:a] asplit=N -> atrim/atempo -> concat [afinal] +``` + +**Speed segments + pad (static):** Uses height-based scaling (same as smart pad) +so landscape sources fill the frame vertically, then overflow crop + static pad. + +**`compute_speed_segments_duration()`** calculates the output duration after +applying speed segments — used for per-iteration subtitle timing. + +## Iterations + +Multi-iteration rendering runs a single clip through N profiles sequentially +and concatenates the results. Configured via `iterations` in config: + +```json +{ + "iterations": { + "default": ["player-overlay", "slowmo-ten-second-clip"] + } +} +``` + +Triggered with `--iterate` CLI flag. `render_iterations()` orchestrates: + +1. Resolve all profiles up-front (fail fast on missing) +2. For each profile: apply overrides to ShortConfig, plan render, execute +3. Concatenate outputs (re-encode, not stream-copy, for filter compatibility) + +**Per-iteration behaviour:** +- Subtitle templates are resolved per-iteration with duration adjusted for + speed_segments (`compute_speed_segments_duration()`) +- Zoom path is remapped per-iteration when speed_segments are present +- Smart tracking applies to all iterations (CLI flag scope) +- Each profile can independently set crop_mode, scale, speed, LUT, subtitle + +**Concatenation:** Uses `copy=False` (re-encode) because iterations may have +different filter chains (e.g., smart pad overlay vs speed_segments split/concat) +that produce incompatible codec parameters for stream-copy concat. + +## Overlay / Subtitle Timing + +Subtitle templates (`.ass` files) use absolute timestamps. When rendering: + +- **Single render:** Duration probed from source clip +- **Iterations:** Duration computed per-iteration, accounting for speed_segments + time stretch. `build_overlay_context()` sets `end_time = duration + 1.0` + to ensure the overlay covers the full output. + +## ZoomPath and fps + +- `ZoomPath` — `(points, duration, source_width, source_height)`. Points are + `(timestamp, center_x, center_y)` where x/y are normalised 0-1. +- Source fps is probed from extracted frames. Used for the `color` filter's + `r=` parameter in smart pad to avoid fps mismatch (which causes black output). +- `_fps_to_fraction()` converts float fps to exact fractions via + `Fraction.limit_denominator(10000)` — recovers NTSC rates like 60000/1001. + +## Key Files + +| File | Role | +|------|------| +| `reeln/models/short.py` | `ShortConfig`, `CropMode`, `OutputFormat` | +| `reeln/models/profile.py` | `RenderProfile`, `SpeedSegment`, `IterationConfig` | +| `reeln/models/zoom.py` | `ZoomPath`, `ZoomPoint`, `ExtractedFrames` | +| `reeln/core/shorts.py` | Filter builders, validation, `plan_short()`, `plan_preview()` | +| `reeln/core/zoom.py` | Piecewise lerp, smart crop/pad filters, zoom path remapping | +| `reeln/core/profiles.py` | Profile resolution, `apply_profile_to_short()`, `plan_full_frame()` | +| `reeln/core/iterations.py` | `render_iterations()` — multi-profile orchestration | +| `reeln/core/ffmpeg.py` | ffmpeg command builder, `-map` handling for `[vfinal]`/`[afinal]` | +| `reeln/commands/render.py` | CLI entry point, flag parsing, `_do_short()` | diff --git a/.claude/rules/teams-and-rosters.md b/.claude/rules/teams-and-rosters.md new file mode 100644 index 0000000..36975b8 --- /dev/null +++ b/.claude/rules/teams-and-rosters.md @@ -0,0 +1,80 @@ +--- +globs: ["reeln/models/team.py", "reeln/core/teams.py", "reeln/commands/game.py", "tests/**/test_team*.py", "tests/**/test_game*.py"] +--- + +# Team Profiles & Rosters + +Teams are managed as reusable JSON profiles stored in the config directory, organized by level. + +## Storage Layout + +``` +~/Library/Application Support/reeln/teams/ (macOS) or ~/.config/reeln/teams/ (Linux) +├── 2016/ # level = birth year tournament +│ ├── north.json +│ ├── south.json +│ ├── east.json +│ └── west.json +├── bantam/ # level = league division +│ ├── roseville.json +│ └── mahtomedi.json +└── varsity/ + └── ... +``` + +## TeamProfile Model (`reeln/models/team.py`) + +```python +@dataclass +class TeamProfile: + team_name: str # Full name (e.g., "North") + short_name: str # Abbreviation (e.g., "NOR") + level: str # Competition level / birth year + logo_path: str = "" # Path to team logo PNG + roster_path: str = "" # Path to roster JSON file + colors: list[str] = [] # Brand colors (hex, e.g., ["#C8102E"]) + jersey_colors: list[str] = [] # Jersey colors (e.g., ["white", "red"]) + metadata: dict[str, Any] = {} # Free-form (conference, mascot, etc.) +``` + +## Team Management (`reeln/core/teams.py`) + +- `slugify(name)` — team name -> filesystem slug (e.g., "St. Louis Park" -> `st_louis_park`) +- `save_team_profile(profile, slug)` — atomic write to `{config_dir}/teams/{level}/{slug}.json` +- `load_team_profile(level, slug)` — load from disk, raises `ConfigError` on missing/invalid +- `list_team_profiles(level)` — sorted slugs for a level +- `list_levels()` — all available levels +- `delete_team_profile(level, slug)` — remove a profile + +## Roster Files + +Roster files are **external to reeln core** — the `roster_path` field in `TeamProfile` is a pointer to a CSV file. Rosters are consumed by plugins and rendering overlays, not by core logic. + +**Roster CSV format** (all three columns required, `position` may be empty): + +```csv +number,name,position +10,First Last,C +22,First Last,D +7,First Last, +``` + +Rosters commonly come from screenshots (game sheets, tournament apps, etc.) that need to be transcribed into CSV. + +## Common Workflow: Tournament Setup + +Tournaments often use birth-year levels (e.g., "2016") with multiple teams. Typical setup: + +1. Create team profiles for each team in the tournament level +2. Set `logo_path` to point to each team's logo PNG +3. Create roster JSON files (often transcribed from screenshots of game sheets) +4. Set `roster_path` in each profile to point to the roster file +5. Use `reeln game init --level 2016` to init games with profiles + +## CLI Usage + +```bash +reeln game init north south --sport hockey --level 2016 +``` + +When `--level` is specified, team names are slugified and profiles are loaded from disk. Profiles (including logo and roster paths) are passed to plugins via the `ON_GAME_INIT` hook context. diff --git a/.claude/rules/workflow.md b/.claude/rules/workflow.md new file mode 100644 index 0000000..0c8ed2a --- /dev/null +++ b/.claude/rules/workflow.md @@ -0,0 +1,18 @@ +# Workflow: Task-to-Agent Mapping + +Use these agents proactively — no user prompt needed. + +| Task | Agent | +|------|-------| +| After any Python code change | `python-reviewer` | +| New feature or bug fix | `tdd-guide` | +| Build / `make check` failure | `build-error-resolver` | +| Plugin config, secrets, auth | `security-reviewer` | +| Dead code, refactoring | `refactor-cleaner` | +| Render pipeline, ffmpeg filters | `ffmpeg-expert` | +| `.ass` overlays, subtitle templates | `ass-subtitle-expert` | +| OBS plugin / scripting | `obs-studio-expert` | +| YouTube uploads / API | `youtube-api-expert` | +| Meta / Instagram / Threads API | `meta-api-expert` | +| Complex feature planning | `planner` | +| Architectural decisions | `architect` | diff --git a/.gitignore b/.gitignore index 5957336..f6338af 100644 --- a/.gitignore +++ b/.gitignore @@ -28,6 +28,7 @@ htmlcov/ secrets/ output/ .tmp/ +ffmpeg2pass-* # Sphinx docs build docs/_build/ diff --git a/CHANGELOG.md b/CHANGELOG.md index 4d877e0..0e17886 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,23 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/), and this project adheres to [Semantic Versioning](https://semver.org/). +## [0.0.34] - 2026-04-02 + +### Added +- `reeln hooks run` and `reeln hooks list` CLI commands for non-interactive hook execution (JSON-in/JSON-out, designed for reeln-dock integration) +- `reeln-native` v0.2.0 as a required dependency (Rust-powered acceleration) +- `native-dev` and `plugins` Makefile targets for local development + +### Changed +- Goal overlay layout: dynamic box height when assists are present, adjusted assist Y-coordinates for better spacing +- `probe_duration()` now accepts single-arg form with auto-discovery (`probe_duration(path)`) +- Overlay template documentation now covers ASS templates only (JSON templates deferred to native migration) + +### Removed +- Dead PNG overlay pipeline (`ResolvedOverlay`, `resolve_overlay_for_profile`, `composite_video_overlay`) — unreachable code from incomplete JSON template integration +- `goal_overlay.json` template (nothing loaded it) +- Shell completion Makefile targets (replaced by symlink install) + ## [0.0.33] - 2026-03-23 ### Added diff --git a/Makefile b/Makefile index 52d6021..0770f88 100644 --- a/Makefile +++ b/Makefile @@ -1,11 +1,26 @@ -.PHONY: dev-install test test-coverage test-integration lint format check install docs docs-serve completion-zsh completion-bash completion-fish +.PHONY: dev-install native-dev plugins test test-coverage test-integration lint format check install docs docs-serve VENV := .venv/bin +CORE_DIR := ../reeln-core +PLUGIN_DIR := .. + +PLUGINS := \ + reeln-plugin-google \ + reeln-plugin-meta \ + reeln-plugin-openai \ + reeln-plugin-streamn-scoreboard \ + reeln-plugin-cloudflare dev-install: uv venv --clear uv pip install -e ".[dev,interactive,docs]" - uv tool install --force --no-cache --from ".[interactive]" reeln + ln -sf $(CURDIR)/.venv/bin/reeln $(HOME)/.local/bin/reeln + +plugins: + uv pip install $(foreach p,$(PLUGINS),-e $(PLUGIN_DIR)/$(p)) + +native-dev: + cd $(CORE_DIR)/crates/reeln-python && VIRTUAL_ENV=$(CURDIR)/.venv maturin develop --release test: $(VENV)/python -m pytest tests/ -n auto --cov=reeln --cov-branch --cov-fail-under=100 -m "not integration" -q @@ -27,19 +42,10 @@ check: lint $(MAKE) test install: - uv tool install --force --no-cache --from ".[interactive]" reeln + ln -sf $(CURDIR)/.venv/bin/reeln $(HOME)/.local/bin/reeln docs: $(VENV)/python -m sphinx docs/ docs/_build/html docs-serve: docs $(VENV)/python -m http.server -d docs/_build/html 8000 - -completion-zsh: - $(VENV)/reeln --install-completion zsh - -completion-bash: - $(VENV)/reeln --install-completion bash - -completion-fish: - $(VENV)/reeln --install-completion fish diff --git a/docs/cli/plugins.md b/docs/cli/plugins.md index e5a459e..b23f7b1 100644 --- a/docs/cli/plugins.md +++ b/docs/cli/plugins.md @@ -251,3 +251,62 @@ Per-plugin settings can be provided in the config file: } } ``` + +## Desktop UI contributions + +Plugins can declare UI fields that appear in the **reeln-dock** desktop application. +Add a `ui_contributions` object to your plugin's registry entry in `registry/plugins.json`. + +Fields only appear when the plugin is installed and enabled. When disabled, the fields +disappear from the UI automatically. + +### Supported screens + +| Screen | Where fields appear | +|--------|-------------------| +| `render_options` | Render overrides section in Clip Review | +| `settings` | Plugin Defaults section in Settings > Rendering | +| `clip_review` | Clip Review metadata section | + +### Field types + +| Type | Renders as | +|------|-----------| +| `boolean` | Checkbox | +| `number` | Range slider (if `min`/`max` set) or number input | +| `string` | Text input | +| `select` | Dropdown (requires `options: [{value, label}]`) | + +### Example + +```json +{ + "name": "openai", + "ui_contributions": { + "render_options": { + "fields": [ + { + "id": "smart", + "label": "Smart Zoom", + "type": "boolean", + "default": false, + "description": "AI-powered smart crop tracking", + "maps_to": "smart" + }, + { + "id": "zoom_frames", + "label": "Zoom Frames", + "type": "number", + "min": 1, + "max": 30, + "step": 1, + "maps_to": "zoom_frames" + } + ] + } + } +} +``` + +The `maps_to` property controls which `RenderOverrides` key the field value flows into. +If omitted, the field `id` is used as the key. diff --git a/docs/guide/configuration.md b/docs/guide/configuration.md index 7d65d0f..feadac8 100644 --- a/docs/guide/configuration.md +++ b/docs/guide/configuration.md @@ -103,7 +103,7 @@ Named render profiles define reusable rendering parameter overrides. Add a `rend |---|---|---| | `speed` | float | Playback speed (e.g. 0.5 for slow motion) | | `lut` | string | Path to `.cube` LUT file for color grading | -| `subtitle_template` | string | Path to `.ass` subtitle template, or `builtin:` for bundled templates | +| `subtitle_template` | string | Path to `.ass` subtitle template, or `builtin:` for bundled templates. See {doc}`/guide/overlay-templates`. | | `width` | int | Target width (short-form only, ignored for full-frame) | | `height` | int | Target height (short-form only, ignored for full-frame) | | `crop_mode` | string | `"pad"` or `"crop"` (short-form only) | diff --git a/docs/guide/overlay-templates.md b/docs/guide/overlay-templates.md new file mode 100644 index 0000000..2400b52 --- /dev/null +++ b/docs/guide/overlay-templates.md @@ -0,0 +1,75 @@ +# Overlay templates + +reeln supports ASS overlay templates for rendering text, graphics, and branding onto video clips. Templates use `{{variable}}` placeholders that are populated from the template context (game info, event data, player names, etc.). + +## ASS templates + +ASS (Advanced SubStation Alpha) is a subtitle format that ffmpeg can render directly. reeln loads the `.ass` file, substitutes `{{variables}}`, writes a temp file, and burns it into the video as part of the ffmpeg filter chain. + +### How it works + +1. A render profile sets `subtitle_template` to an `.ass` file path (or `builtin:`) +2. reeln builds a template context from game info, event data, and CLI flags +3. The `overlay.py` context builder adds computed values: ASS-formatted colors, timestamps, font sizes, and pixel coordinates +4. Variables like `{{goal_scorer_text}}` and `{{ass_primary_color}}` are substituted into the `.ass` template +5. The rendered `.ass` file is applied via ffmpeg's `subtitles` filter + +### Builtin ASS templates + +reeln ships with two ASS templates: + +- **`goal_overlay`** — a lower-third banner showing scorer name, up to two assists, and team name +- **`branding`** — a top-of-frame "reeln" watermark + +Reference them with the `builtin:` prefix: + +```json +{ + "render_profiles": { + "player-overlay": { + "speed": 0.5, + "subtitle_template": "builtin:goal_overlay" + } + } +} +``` + +### Template variables (ASS) + +The overlay context builder populates these variables for ASS templates: + +| Variable | Description | Example | +|---|---|---| +| `goal_scorer_text` | Player name | `#17 Smith` | +| `goal_scorer_team` | Team name (uppercase) | `ROSEVILLE` | +| `team_level` | Level or division | `BANTAM` | +| `goal_assist_1` | First assist | `#22 Jones` | +| `goal_assist_2` | Second assist | `#5 Brown` | +| `goal_scorer_fs` | Computed font size for scorer | `46` | +| `goal_assist_fs` | Computed font size for assists | `24` | +| `scorer_start` / `scorer_end` | ASS timestamps | `0:00:00.00` | +| `assist_start` / `assist_end` | ASS timestamps (hidden when no assists) | `0:00:00.00` | +| `box_end` | ASS timestamp for overlay duration | `0:00:11.00` | +| `ass_primary_color` | Team primary color in ASS format | `&H001E1E1E` | +| `ass_secondary_color` | Team secondary color in ASS format | `&H00C8C8C8` | +| `ass_name_color` | Name text color | `&H00FFFFFF` | +| `ass_name_outline_color` | Name outline color | `&H00000000` | +| `goal_overlay_*_x` / `*_y` | Pixel coordinates for each element | `83` | + +Plus all base context variables: `home_team`, `away_team`, `date`, `sport`, `player`, `event_type`, etc. + +### Writing custom ASS templates + +You can write your own `.ass` file using any `{{variable}}` from the context. Place it anywhere and reference it by path: + +```json +{ + "render_profiles": { + "my-overlay": { + "subtitle_template": "~/.config/reeln/templates/my_overlay.ass" + } + } +} +``` + +Refer to the [ASS format specification](https://fileformats.fandom.com/wiki/SubStation_Alpha) for syntax. The bundled `goal_overlay.ass` in `reeln/data/templates/` is a good starting point. diff --git a/docs/index.md b/docs/index.md index 3f7b7ef..b2183b1 100644 --- a/docs/index.md +++ b/docs/index.md @@ -28,6 +28,7 @@ quickstart :maxdepth: 2 guide/configuration +guide/overlay-templates guide/sports ``` diff --git a/docs/install.md b/docs/install.md index 9f066a7..5864575 100644 --- a/docs/install.md +++ b/docs/install.md @@ -3,7 +3,7 @@ ## Requirements - **Python 3.11+** -- **ffmpeg 5.0+** — reeln uses ffmpeg for all video processing +- **ffmpeg 5.0+** — reeln uses the ffmpeg binary for rendering video with complex filter chains, subtitle overlays (requires libass), and codec support (libx264, aac) ## Install reeln @@ -19,6 +19,8 @@ pip install reeln uv tool install reeln ``` +This installs the `reeln` CLI and `reeln-native` (a Rust extension that handles media probing, concatenation, frame extraction, and overlay rendering using ffmpeg libraries). Pre-built wheels are available for Linux (x86_64) and macOS (arm64) — other platforms build from source and require ffmpeg development headers. + ### Development install ```bash @@ -31,7 +33,13 @@ This creates a virtual environment and installs reeln in editable mode with dev ## Install ffmpeg -reeln requires ffmpeg 5.0 or later. After installing, run `reeln doctor` to verify your setup. +reeln requires the ffmpeg binary (5.0 or later) with at least these capabilities: + +- **libx264** — h264 video encoding +- **aac** — audio encoding +- **libass** — ASS subtitle rendering (used for overlay templates) + +Most standard ffmpeg packages include all of these. After installing, run `reeln doctor` to verify your setup. ### macOS @@ -57,7 +65,7 @@ choco install ffmpeg ```bash ffmpeg -version # should show 5.0+ -reeln --version # confirms reeln is installed +reeln doctor # checks ffmpeg, codecs, config, permissions, plugins ``` ## Shell completion diff --git a/pyproject.toml b/pyproject.toml index db7eebe..23d2357 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -12,6 +12,7 @@ requires-python = ">=3.11" dependencies = [ "typer>=0.12.3", "filelock>=3.0", + "reeln-native>=0.2.3,<1.0", ] [project.optional-dependencies] @@ -69,3 +70,7 @@ implicit_reexport = true [[tool.mypy.overrides]] module = "questionary.*" ignore_missing_imports = true + +[[tool.mypy.overrides]] +module = "reeln_native.*" +ignore_missing_imports = true diff --git a/reeln/__init__.py b/reeln/__init__.py index fb11a43..6c75c49 100644 --- a/reeln/__init__.py +++ b/reeln/__init__.py @@ -2,4 +2,4 @@ from __future__ import annotations -__version__ = "0.0.33" +__version__ = "0.0.34" diff --git a/reeln/cli.py b/reeln/cli.py index 4ef44ea..b95f35d 100644 --- a/reeln/cli.py +++ b/reeln/cli.py @@ -7,7 +7,7 @@ import typer from reeln import __version__ -from reeln.commands import config_cmd, game, media, plugins_cmd, render +from reeln.commands import config_cmd, game, hooks_cmd, media, plugins_cmd, render from reeln.core.log import setup_logging app = typer.Typer( @@ -23,6 +23,7 @@ app.add_typer(media.app, name="media") app.add_typer(config_cmd.app, name="config") app.add_typer(plugins_cmd.app, name="plugins") +app.add_typer(hooks_cmd.app, name="hooks") def _build_version_lines() -> list[str]: diff --git a/reeln/commands/config_cmd.py b/reeln/commands/config_cmd.py index 7f5039c..5d25a4c 100644 --- a/reeln/commands/config_cmd.py +++ b/reeln/commands/config_cmd.py @@ -7,6 +7,7 @@ import typer +from reeln.commands import event_types_cmd from reeln.core.config import ( config_to_dict, default_config_path, @@ -16,6 +17,7 @@ from reeln.core.errors import ConfigError app = typer.Typer(no_args_is_help=True, help="Configuration commands.") +app.add_typer(event_types_cmd.app, name="event-types") @app.command() diff --git a/reeln/commands/event_types_cmd.py b/reeln/commands/event_types_cmd.py new file mode 100644 index 0000000..d4fdda6 --- /dev/null +++ b/reeln/commands/event_types_cmd.py @@ -0,0 +1,128 @@ +"""Event types configuration subcommands: list, add, remove, set, defaults.""" + +from __future__ import annotations + +from pathlib import Path + +import typer + +from reeln.core.config import load_config, save_config +from reeln.core.errors import ConfigError +from reeln.core.event_types import default_event_types +from reeln.models.config import EventTypeEntry + +app = typer.Typer(no_args_is_help=True, help="Manage configured event types.") + + +@app.command("list") +def list_cmd( + profile: str | None = typer.Option(None, "--profile", help="Named config profile."), + config_path: Path | None = typer.Option(None, "--config", help="Explicit config file path."), +) -> None: + """Show configured event types (or sport defaults if none configured).""" + try: + config = load_config(path=config_path, profile=profile) + except ConfigError as exc: + typer.echo(f"Error: {exc}", err=True) + raise typer.Exit(code=1) from exc + + if config.event_types: + for et in config.event_types: + label = f"{et.name} (team)" if et.team_specific else et.name + typer.echo(label) + else: + defaults = default_event_types(config.sport) + if defaults: + typer.echo(f"No event types configured. Defaults for {config.sport}:") + for t in defaults: + typer.echo(f" {t}") + else: + typer.echo("No event types configured.") + + +@app.command() +def add( + event_type: str = typer.Argument(..., help="Event type to add."), + team: bool = typer.Option(False, "--team", help="Mark as team-specific (Home/Away variants)."), + profile: str | None = typer.Option(None, "--profile", help="Named config profile."), + config_path: Path | None = typer.Option(None, "--config", help="Explicit config file path."), +) -> None: + """Add an event type to the configuration.""" + try: + config = load_config(path=config_path, profile=profile) + except ConfigError as exc: + typer.echo(f"Error: {exc}", err=True) + raise typer.Exit(code=1) from exc + + existing_names = [et.name for et in config.event_types] + if event_type in existing_names: + typer.echo(f"Event type '{event_type}' already configured.") + return + + config.event_types.append(EventTypeEntry(name=event_type, team_specific=team)) + save_config(config, path=config_path) + names = ", ".join(et.name for et in config.event_types) + typer.echo(f"Added '{event_type}'{' (team)' if team else ''}. Event types: {names}") + + +@app.command() +def remove( + event_type: str = typer.Argument(..., help="Event type to remove."), + profile: str | None = typer.Option(None, "--profile", help="Named config profile."), + config_path: Path | None = typer.Option(None, "--config", help="Explicit config file path."), +) -> None: + """Remove an event type from the configuration.""" + try: + config = load_config(path=config_path, profile=profile) + except ConfigError as exc: + typer.echo(f"Error: {exc}", err=True) + raise typer.Exit(code=1) from exc + + match = next((et for et in config.event_types if et.name == event_type), None) + if match is None: + typer.echo(f"Error: Event type '{event_type}' not found in configuration.", err=True) + raise typer.Exit(code=1) + + config.event_types.remove(match) + save_config(config, path=config_path) + remaining = ", ".join(et.name for et in config.event_types) if config.event_types else "(empty)" + typer.echo(f"Removed '{event_type}'. Event types: {remaining}") + + +@app.command("set") +def set_cmd( + event_types: list[str] = typer.Argument(..., help="Event types to set (replaces existing)."), + profile: str | None = typer.Option(None, "--profile", help="Named config profile."), + config_path: Path | None = typer.Option(None, "--config", help="Explicit config file path."), +) -> None: + """Replace all configured event types.""" + try: + config = load_config(path=config_path, profile=profile) + except ConfigError as exc: + typer.echo(f"Error: {exc}", err=True) + raise typer.Exit(code=1) from exc + + config.event_types = [EventTypeEntry(name=t) for t in event_types] + save_config(config, path=config_path) + typer.echo(f"Event types set: {', '.join(et.name for et in config.event_types)}") + + +@app.command() +def defaults( + profile: str | None = typer.Option(None, "--profile", help="Named config profile."), + config_path: Path | None = typer.Option(None, "--config", help="Explicit config file path."), +) -> None: + """Show default event types for the configured sport.""" + try: + config = load_config(path=config_path, profile=profile) + except ConfigError as exc: + typer.echo(f"Error: {exc}", err=True) + raise typer.Exit(code=1) from exc + + types = default_event_types(config.sport) + if types: + typer.echo(f"Default event types for {config.sport}:") + for t in types: + typer.echo(f" {t}") + else: + typer.echo(f"No default event types for {config.sport}.") diff --git a/reeln/commands/hooks_cmd.py b/reeln/commands/hooks_cmd.py new file mode 100644 index 0000000..2ffcbb2 --- /dev/null +++ b/reeln/commands/hooks_cmd.py @@ -0,0 +1,201 @@ +"""Non-interactive hook execution for external callers (e.g. reeln-dock).""" + +from __future__ import annotations + +import json +import logging +import sys +from pathlib import Path +from types import SimpleNamespace +from typing import Any + +import typer + +from reeln.core.config import load_config +from reeln.core.errors import ReelnError +from reeln.plugins.hooks import Hook, HookContext +from reeln.plugins.loader import activate_plugins +from reeln.plugins.registry import get_registry + +app = typer.Typer(no_args_is_help=True, help="Hook execution commands (JSON-in/JSON-out).") + + +# --------------------------------------------------------------------------- +# In-memory log capture +# --------------------------------------------------------------------------- + + +class _LogCapture(logging.Handler): + """Collects log records emitted during hook execution.""" + + def __init__(self) -> None: + super().__init__() + self.records: list[str] = [] + self.errors: list[str] = [] + + def emit(self, record: logging.LogRecord) -> None: + msg = self.format(record) + if record.levelno >= logging.ERROR: + self.errors.append(msg) + else: + self.records.append(msg) + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + +_HOOK_LOOKUP: dict[str, Hook] = {h.value: h for h in Hook} + + +def _resolve_hook(name: str) -> Hook: + """Resolve a hook name string to a Hook enum, case-insensitive.""" + normalised = name.lower().removeprefix("hook.").strip() + hook = _HOOK_LOOKUP.get(normalised) + if hook is None: + valid = ", ".join(sorted(_HOOK_LOOKUP)) + raise typer.BadParameter(f"Unknown hook: {name!r}. Valid hooks: {valid}") + return hook + + +def _dicts_to_namespaces(data: dict[str, Any]) -> dict[str, Any]: + """Convert nested dicts to SimpleNamespace objects. + + Plugins use ``getattr(context.data["game_info"], "home_team", "")`` + which requires attribute-style access. JSON input produces plain dicts + where ``getattr`` doesn't find keys. Converting to SimpleNamespace + bridges the gap. + """ + result: dict[str, Any] = {} + for key, value in data.items(): + if isinstance(value, dict): + result[key] = SimpleNamespace(**_dicts_to_namespaces(value)) + else: + result[key] = value + return result + + +def _parse_json_arg(value: str | None, label: str) -> dict[str, Any]: + """Parse a JSON string or @file reference into a dict.""" + if not value: + return {} + text = value + if value.startswith("@"): + file_path = Path(value[1:]) + if not file_path.is_file(): + raise typer.BadParameter(f"{label} file not found: {file_path}") + text = file_path.read_text(encoding="utf-8") + try: + parsed = json.loads(text) + except json.JSONDecodeError as exc: + raise typer.BadParameter(f"Invalid JSON for {label}: {exc}") from exc + if not isinstance(parsed, dict): + raise typer.BadParameter(f"{label} must be a JSON object, got {type(parsed).__name__}") + return parsed + + +# --------------------------------------------------------------------------- +# Command +# --------------------------------------------------------------------------- + + +@app.command() +def run( + hook_name: str = typer.Argument(..., help="Hook to emit (e.g. on_game_init, on_game_ready)."), + context_json: str | None = typer.Option( + None, + "--context-json", + help="Hook context data as JSON string or @file path.", + ), + shared_json: str | None = typer.Option( + None, + "--shared-json", + help="Shared dict from a previous hook (for chaining). JSON string or @file path.", + ), + profile: str | None = typer.Option(None, "--profile", help="Named config profile."), + config_path: Path | None = typer.Option(None, "--config", help="Explicit config file path."), +) -> None: + """Execute a single hook and return results as JSON. + + Loads enabled plugins from config, emits the specified hook with the + provided context, and prints the resulting shared dict as JSON to stdout. + Designed for machine consumption — no interactive prompts, no ANSI output. + + \b + Examples: + reeln hooks run on_game_init --context-json '{"game_dir": "/path", "game_info": {...}}' + reeln hooks run on_game_ready --context-json '{"game_dir": "/path"}' --shared-json '@/tmp/shared.json' + """ + # Resolve hook enum + hook = _resolve_hook(hook_name) + + # Parse JSON inputs + context_data = _parse_json_arg(context_json, "context-json") + shared_data = _parse_json_arg(shared_json, "shared-json") + + # Install log capture before plugin activation + capture = _LogCapture() + capture.setFormatter(logging.Formatter("%(name)s: %(message)s")) + root_logger = logging.getLogger() + root_logger.addHandler(capture) + # Ensure plugin-level logs are captured + root_logger.setLevel(min(root_logger.level, logging.INFO)) + + success = True + result_shared: dict[str, Any] = {} + + try: + # Load config and activate plugins + try: + config = load_config(path=config_path, profile=profile) + except ReelnError as exc: + raise typer.Exit(code=1) from _emit_error(f"Config load failed: {exc}") + + activate_plugins(config.plugins) + + # Convert nested dicts to SimpleNamespace for getattr-based plugin access + enriched_data = _dicts_to_namespaces(context_data) + + # Build context and emit + ctx = HookContext(hook=hook, data=enriched_data, shared=dict(shared_data)) + get_registry().emit(hook, ctx) + + result_shared = dict(ctx.shared) + + except typer.Exit: + raise + except Exception as exc: + success = False + capture.errors.append(f"Hook execution failed: {exc}") + finally: + root_logger.removeHandler(capture) + + # Emit JSON result to stdout + output = { + "success": success, + "hook": hook.value, + "shared": result_shared, + "logs": capture.records, + "errors": capture.errors, + } + sys.stdout.write(json.dumps(output, default=str) + "\n") + + +def _emit_error(message: str) -> Exception: + """Write an error JSON response and return an exception for chaining.""" + output = { + "success": False, + "hook": "", + "shared": {}, + "logs": [], + "errors": [message], + } + sys.stdout.write(json.dumps(output) + "\n") + return ReelnError(message) + + +@app.command(name="list") +def list_hooks() -> None: + """List all available hook names.""" + for hook in Hook: + typer.echo(hook.value) diff --git a/reeln/commands/render.py b/reeln/commands/render.py index fcc0057..2f65466 100644 --- a/reeln/commands/render.py +++ b/reeln/commands/render.py @@ -9,7 +9,7 @@ import typer from reeln.core.config import load_config -from reeln.core.errors import ReelnError +from reeln.core.errors import ReelnError, RenderError from reeln.core.shorts import plan_preview, plan_short from reeln.models.short import ( ANCHOR_POSITIONS, @@ -402,11 +402,10 @@ def _do_short( event_meta["assists"] = assists if event_meta is not None: - from reeln.core.ffmpeg import discover_ffmpeg as _disc from reeln.core.ffmpeg import probe_duration as _probe_dur from reeln.core.overlay import build_overlay_context - dur = _probe_dur(_disc(), clip) or 10.0 + dur = _probe_dur(clip) or 10.0 ctx = build_overlay_context( ctx, duration=dur, @@ -500,6 +499,11 @@ def _do_short( smart_zoom_data = shared.get("smart_zoom") if isinstance(smart_zoom_data, dict): + zoom_error = smart_zoom_data.get("error") + if zoom_error is not None: + raise RenderError( + f"Smart zoom analysis failed after retries: {zoom_error}" + ) zoom_path = smart_zoom_data.get("zoom_path") debug_from_plugin = smart_zoom_data.get("debug") if isinstance(debug_from_plugin, dict): @@ -916,12 +920,7 @@ def reel( dry_run: bool = typer.Option(False, "--dry-run", help="Show plan without executing."), ) -> None: """Assemble rendered shorts into a concatenated reel.""" - from reeln.core.ffmpeg import ( - build_concat_command, - discover_ffmpeg, - run_ffmpeg, - write_concat_file, - ) + from reeln.core.ffmpeg import concat_files from reeln.core.highlights import load_game_state from reeln.core.segment import segment_dir_name @@ -981,21 +980,14 @@ def reel( return try: - ffmpeg_path = discover_ffmpeg() - concat_file = write_concat_file(files, game_dir) - try: - cmd = build_concat_command( - ffmpeg_path, - concat_file, - out, - copy=copy, - video_codec=config.video.codec, - crf=config.video.crf, - audio_codec=config.video.audio_codec, - ) - run_ffmpeg(cmd) - finally: - concat_file.unlink(missing_ok=True) + concat_files( + files, + out, + copy=copy, + video_codec=config.video.codec, + crf=config.video.crf, + audio_codec=config.video.audio_codec, + ) except ReelnError as exc: typer.echo(f"Error: {exc}", err=True) raise typer.Exit(code=1) from exc @@ -1134,11 +1126,10 @@ def apply_profile( event_meta["assists"] = assists_str if event_meta is not None: - from reeln.core.ffmpeg import discover_ffmpeg as _disc from reeln.core.ffmpeg import probe_duration as _probe_dur from reeln.core.overlay import build_overlay_context - dur = _probe_dur(_disc(), clip) or 10.0 + dur = _probe_dur(clip) or 10.0 ctx = build_overlay_context( ctx, duration=dur, @@ -1162,7 +1153,7 @@ def apply_profile( if rp.lut is not None: typer.echo(f"LUT: {rp.lut}") if rendered_subtitle is not None: - typer.echo(f"Subtitle: {rp.subtitle_template}") + typer.echo(f"Overlay: {rp.subtitle_template}") if dry_run: typer.echo("Dry run — no files written") diff --git a/reeln/core/config.py b/reeln/core/config.py index 54d6b15..58a8981 100644 --- a/reeln/core/config.py +++ b/reeln/core/config.py @@ -13,7 +13,7 @@ from reeln.core.errors import ConfigError from reeln.core.log import get_logger from reeln.models.branding import BrandingConfig -from reeln.models.config import AppConfig, PathConfig, PluginsConfig, VideoConfig +from reeln.models.config import AppConfig, EventTypeEntry, PathConfig, PluginsConfig, VideoConfig from reeln.models.plugin import OrchestrationConfig from reeln.models.profile import ( IterationConfig, @@ -131,20 +131,29 @@ def config_to_dict(config: AppConfig, *, full: bool = False) -> dict[str, Any]: d: dict[str, Any] = { "config_version": config.config_version, "sport": config.sport, - "video": { - "ffmpeg_path": config.video.ffmpeg_path, - "codec": config.video.codec, - "preset": config.video.preset, - "crf": config.video.crf, - "audio_codec": config.video.audio_codec, - "audio_bitrate": config.video.audio_bitrate, - }, - "paths": { - "source_dir": str(config.paths.source_dir) if config.paths.source_dir else None, - "source_glob": config.paths.source_glob, - "output_dir": str(config.paths.output_dir) if config.paths.output_dir else None, - "temp_dir": str(config.paths.temp_dir) if config.paths.temp_dir else None, - }, + } + + if full or config.event_types: + d["event_types"] = [ + {"name": et.name, "team_specific": et.team_specific} + if et.team_specific + else et.name + for et in config.event_types + ] + + d["video"] = { + "ffmpeg_path": config.video.ffmpeg_path, + "codec": config.video.codec, + "preset": config.video.preset, + "crf": config.video.crf, + "audio_codec": config.video.audio_codec, + "audio_bitrate": config.video.audio_bitrate, + } + d["paths"] = { + "source_dir": str(config.paths.source_dir) if config.paths.source_dir else None, + "source_glob": config.paths.source_glob, + "output_dir": str(config.paths.output_dir) if config.paths.output_dir else None, + "temp_dir": str(config.paths.temp_dir) if config.paths.temp_dir else None, } has_branding = ( @@ -266,9 +275,25 @@ def dict_to_config(data: dict[str, Any]) -> AppConfig: duration=float(raw_branding.get("duration", 5.0)), ) + # Event types + raw_event_types = data.get("event_types", []) + event_types: list[EventTypeEntry] = [] + if isinstance(raw_event_types, list): + for item in raw_event_types: + if isinstance(item, str): + event_types.append(EventTypeEntry(name=item)) + elif isinstance(item, dict) and "name" in item: + event_types.append( + EventTypeEntry( + name=str(item["name"]), + team_specific=bool(item.get("team_specific", False)), + ) + ) + return AppConfig( config_version=int(data.get("config_version", CURRENT_CONFIG_VERSION)), sport=str(data.get("sport", "generic")), + event_types=event_types, video=_dict_to_video_config(data.get("video", {})), paths=_dict_to_path_config(data.get("paths", {})), render_profiles=profiles, @@ -378,6 +403,28 @@ def validate_config(data: dict[str, Any]) -> list[str]: if plugins is not None and not isinstance(plugins, dict): issues.append("'plugins' section must be a dict") + event_types = data.get("event_types") + if event_types is not None: + if not isinstance(event_types, list): + issues.append("'event_types' must be a list") + else: + for i, t in enumerate(event_types): + if not isinstance(t, str): + issues.append(f"event_types[{i}] must be a string") + + # Cross-validate: iterations referencing types not in event_types + if isinstance(event_types, list) and event_types: + type_set = {str(t) for t in event_types if isinstance(t, str)} + iter_data = data.get("iterations") + if isinstance(iter_data, dict): + mappings = iter_data.get("mappings", iter_data) + if isinstance(mappings, dict): + for key in mappings: + if key != "default" and key not in type_set: + issues.append( + f"iterations references type '{key}' not listed in event_types" + ) + return issues diff --git a/reeln/core/event_types.py b/reeln/core/event_types.py new file mode 100644 index 0000000..6306c92 --- /dev/null +++ b/reeln/core/event_types.py @@ -0,0 +1,28 @@ +"""Default event type definitions per sport.""" + +from __future__ import annotations + +from reeln.models.config import EventTypeEntry + +_DEFAULT_EVENT_TYPES: dict[str, list[tuple[str, bool]]] = { + "hockey": [("goal", True), ("save", True), ("penalty", True), ("assist", False)], + "basketball": [("basket", True), ("foul", True), ("turnover", True), ("block", True)], + "soccer": [("goal", True), ("foul", True), ("corner", False), ("offside", False), ("save", True)], + "football": [("touchdown", True), ("field-goal", True), ("interception", True), ("sack", True)], + "nfl": [("touchdown", True), ("field-goal", True), ("interception", True), ("sack", True)], + "american-football": [("touchdown", True), ("field-goal", True), ("interception", True), ("sack", True)], + "baseball": [("hit", True), ("strikeout", True), ("home-run", True), ("catch", True)], + "lacrosse": [("goal", True), ("save", True), ("penalty", True), ("ground-ball", False)], +} + + +def default_event_types(sport: str) -> list[str]: + """Return default event type names for a sport.""" + entries = _DEFAULT_EVENT_TYPES.get(sport.lower(), []) + return [name for name, _ in entries] + + +def default_event_type_entries(sport: str) -> list[EventTypeEntry]: + """Return default event types with team-specific flags for a sport.""" + entries = _DEFAULT_EVENT_TYPES.get(sport.lower(), []) + return [EventTypeEntry(name=name, team_specific=team) for name, team in entries] diff --git a/reeln/core/ffmpeg.py b/reeln/core/ffmpeg.py index bcf67b3..2f1a37c 100644 --- a/reeln/core/ffmpeg.py +++ b/reeln/core/ffmpeg.py @@ -135,9 +135,18 @@ def check_version(ffmpeg_path: Path) -> str: # --------------------------------------------------------------------------- -def probe_duration(ffmpeg_path: Path, input_path: Path) -> float | None: - """Probe the duration of a media file in seconds.""" - ffprobe = derive_ffprobe(ffmpeg_path) +def probe_duration(ffmpeg_path_or_input: Path, input_path: Path | None = None) -> float | None: + """Probe the duration of a media file in seconds. + + Can be called as ``probe_duration(input)`` (auto-discovers ffmpeg) + or ``probe_duration(ffmpeg_path, input)`` for backwards compatibility. + """ + if input_path is None: + actual_input = ffmpeg_path_or_input + ffprobe = derive_ffprobe(discover_ffmpeg()) + else: + actual_input = input_path + ffprobe = derive_ffprobe(ffmpeg_path_or_input) cmd = [ str(ffprobe), "-v", @@ -146,7 +155,7 @@ def probe_duration(ffmpeg_path: Path, input_path: Path) -> float | None: "format=duration", "-of", "default=noprint_wrappers=1:nokey=1", - str(input_path), + str(actual_input), ] return _run_probe_float(cmd) @@ -473,6 +482,38 @@ def run_ffmpeg(cmd: list[str], *, timeout: int = 600) -> subprocess.CompletedPro return proc +def concat_files( + files: list[Path], + output: Path, + *, + copy: bool = True, + video_codec: str = "libx264", + crf: int = 18, + audio_codec: str = "aac", +) -> None: + """Concatenate media files via the ffmpeg concat demuxer. + + Convenience wrapper around ``build_concat_command`` + ``write_concat_file`` + + ``run_ffmpeg``. + """ + ffmpeg_path = discover_ffmpeg() + concat_file = write_concat_file(files, output.parent) + try: + cmd = build_concat_command( + ffmpeg_path, + concat_file, + output, + copy=copy, + video_codec=video_codec, + crf=crf, + audio_codec=audio_codec, + ) + run_ffmpeg(cmd) + finally: + concat_file.unlink(missing_ok=True) + + + def build_extract_frame_command( ffmpeg_path: Path, input_path: Path, diff --git a/reeln/core/overlay.py b/reeln/core/overlay.py index d8e2c61..6f4521a 100644 --- a/reeln/core/overlay.py +++ b/reeln/core/overlay.py @@ -113,7 +113,7 @@ def build_overlay_context( scorer_base = 46 if has_assists else 54 scorer_min = 32 if has_assists else 38 goal_scorer_fs = str(overlay_font_size(scorer_text, base=scorer_base, min_size=scorer_min, max_chars=24)) - goal_assist_fs = str(overlay_font_size(f"{assist_1} {assist_2}".strip(), base=24, min_size=18, max_chars=30)) + goal_assist_fs = str(overlay_font_size(f"{assist_1} {assist_2}".strip(), base=20, min_size=16, max_chars=30)) # Colors primary_rgb = _DEFAULT_PRIMARY @@ -174,19 +174,19 @@ def build_overlay_context( "goal_overlay_border_x": "0", "goal_overlay_border_y": str(817 + y_offset), "goal_overlay_border_w": "1920", - "goal_overlay_border_h": "141", + "goal_overlay_border_h": str(148 if has_assists else 141), "goal_overlay_box_x": "3", "goal_overlay_box_y": str(820 + y_offset), "goal_overlay_box_w": "1914", - "goal_overlay_box_h": "135", + "goal_overlay_box_h": str(142 if has_assists else 135), "goal_overlay_team_x": "83", "goal_overlay_team_y": str(828 + y_offset), "goal_overlay_scorer_x": "113", "goal_overlay_scorer_y": str(852 + y_offset), "goal_overlay_assist_1_x": "140", - "goal_overlay_assist_1_y": str(892 + y_offset), + "goal_overlay_assist_1_y": str(895 + y_offset), "goal_overlay_assist_2_x": "140", - "goal_overlay_assist_2_y": str(914 + y_offset), + "goal_overlay_assist_2_y": str(921 + y_offset), } return base.merge(TemplateContext(variables=variables)) diff --git a/reeln/models/config.py b/reeln/models/config.py index 31bf9f1..3af65d2 100644 --- a/reeln/models/config.py +++ b/reeln/models/config.py @@ -44,12 +44,21 @@ class PluginsConfig: enforce_hooks: bool = True +@dataclass +class EventTypeEntry: + """A configured event type with optional team-specific flag.""" + + name: str + team_specific: bool = False + + @dataclass class AppConfig: """Top-level application configuration.""" config_version: int = 1 sport: str = "generic" + event_types: list[EventTypeEntry] = field(default_factory=list) video: VideoConfig = field(default_factory=VideoConfig) paths: PathConfig = field(default_factory=PathConfig) render_profiles: dict[str, RenderProfile] = field(default_factory=dict) diff --git a/reeln/native.py b/reeln/native.py new file mode 100644 index 0000000..9d66b95 --- /dev/null +++ b/reeln/native.py @@ -0,0 +1,27 @@ +"""Bridge to the reeln_native Rust extension. + +``reeln-native`` is a required dependency. This module provides a +``get_native()`` accessor for the compiled Rust functions. + +Usage:: + + from reeln.native import get_native + + mod = get_native() + result = mod.probe("/path/to/video.mkv") +""" + +from __future__ import annotations + +import logging +from types import ModuleType + +import reeln_native as _mod # type: ignore[import-untyped,unused-ignore] + +_log = logging.getLogger(__name__) +_log.debug("reeln_native %s loaded", getattr(_mod, "__version__", "?")) + + +def get_native() -> ModuleType: + """Return the ``reeln_native`` module.""" + return _mod # type: ignore[no-any-return] diff --git a/registry/plugins.json b/registry/plugins.json index a7a4512..692f8b1 100644 --- a/registry/plugins.json +++ b/registry/plugins.json @@ -24,22 +24,57 @@ { "name": "meta", "package": "reeln-plugin-meta", - "description": "Meta platform integration — Facebook Live, Instagram, and Threads", - "capabilities": ["hook:ON_GAME_INIT", "hook:ON_GAME_READY", "hook:ON_GAME_FINISH"], + "description": "Meta platform integration — Facebook Live, Instagram Reels, Facebook Reels, and Threads", + "capabilities": ["hook:ON_GAME_INIT", "hook:ON_GAME_READY", "hook:ON_GAME_FINISH", "hook:POST_RENDER"], "homepage": "https://github.com/StreamnDad/reeln-plugin-meta", "min_reeln_version": "0.0.31", "author": "StreamnDad", "license": "AGPL-3.0" }, + { + "name": "cloudflare", + "package": "reeln-plugin-cloudflare", + "description": "Cloudflare R2 integration — video uploads to R2 with CDN URL sharing and post-game cleanup", + "capabilities": ["hook:POST_RENDER", "hook:ON_GAME_FINISH", "hook:ON_POST_GAME_FINISH"], + "homepage": "https://github.com/StreamnDad/reeln-plugin-cloudflare", + "min_reeln_version": "0.0.31", + "author": "StreamnDad", + "license": "AGPL-3.0" + }, { "name": "openai", "package": "reeln-plugin-openai", - "description": "OpenAI-powered LLM integration — livestream metadata, short metadata, game thumbnails, and translation", + "description": "OpenAI-powered LLM integration — livestream metadata, render metadata, game thumbnails, and translation", "capabilities": ["hook:ON_GAME_INIT", "hook:POST_RENDER", "hook:ON_FRAMES_EXTRACTED"], "homepage": "https://github.com/StreamnDad/reeln-plugin-openai", "min_reeln_version": "0.0.33", "author": "StreamnDad", - "license": "AGPL-3.0" + "license": "AGPL-3.0", + "ui_contributions": { + "render_options": { + "fields": [ + { + "id": "smart", + "label": "Smart Zoom", + "type": "boolean", + "default": false, + "description": "AI-powered smart crop tracking via OpenAI vision", + "maps_to": "smart" + }, + { + "id": "zoom_frames", + "label": "Zoom Frames", + "type": "number", + "default": null, + "min": 1, + "max": 30, + "step": 1, + "description": "Number of keyframes for smart zoom path", + "maps_to": "zoom_frames" + } + ] + } + } } ] } diff --git a/tests/unit/commands/test_event_types_cmd.py b/tests/unit/commands/test_event_types_cmd.py new file mode 100644 index 0000000..db0ea5c --- /dev/null +++ b/tests/unit/commands/test_event_types_cmd.py @@ -0,0 +1,191 @@ +"""Tests for the config event-types subcommands.""" + +from __future__ import annotations + +import json +from pathlib import Path + +from typer.testing import CliRunner + +from reeln.cli import app + +runner = CliRunner() + + +def _write_config(path: Path, *, sport: str = "hockey", event_types: list[str] | None = None) -> Path: + cfg = {"config_version": 1, "sport": sport} + if event_types is not None: + cfg["event_types"] = event_types + path.write_text(json.dumps(cfg)) + return path + + +# --------------------------------------------------------------------------- +# list +# --------------------------------------------------------------------------- + + +def test_event_types_list_shows_configured(tmp_path: Path) -> None: + cfg = _write_config(tmp_path / "config.json", event_types=["goal", "save"]) + result = runner.invoke(app, ["config", "event-types", "list", "--config", str(cfg)]) + assert result.exit_code == 0 + assert "goal" in result.output + assert "save" in result.output + + +def test_event_types_list_empty_shows_defaults(tmp_path: Path) -> None: + cfg = _write_config(tmp_path / "config.json", sport="hockey") + result = runner.invoke(app, ["config", "event-types", "list", "--config", str(cfg)]) + assert result.exit_code == 0 + assert "Defaults for hockey" in result.output + assert "goal" in result.output + + +def test_event_types_list_empty_generic_no_defaults(tmp_path: Path) -> None: + cfg = _write_config(tmp_path / "config.json", sport="generic") + result = runner.invoke(app, ["config", "event-types", "list", "--config", str(cfg)]) + assert result.exit_code == 0 + assert "No event types configured." in result.output + + +def test_event_types_list_missing_config(tmp_path: Path) -> None: + result = runner.invoke(app, ["config", "event-types", "list", "--config", str(tmp_path / "nope.json")]) + assert result.exit_code == 1 + + +# --------------------------------------------------------------------------- +# add +# --------------------------------------------------------------------------- + + +def test_event_types_add(tmp_path: Path) -> None: + cfg = _write_config(tmp_path / "config.json", event_types=["goal"]) + result = runner.invoke(app, ["config", "event-types", "add", "penalty", "--config", str(cfg)]) + assert result.exit_code == 0 + assert "Added 'penalty'" in result.output + + # Verify persisted + data = json.loads(cfg.read_text()) + assert "penalty" in data["event_types"] + assert "goal" in data["event_types"] + + +def test_event_types_add_to_empty(tmp_path: Path) -> None: + cfg = _write_config(tmp_path / "config.json") + result = runner.invoke(app, ["config", "event-types", "add", "goal", "--config", str(cfg)]) + assert result.exit_code == 0 + assert "Added 'goal'" in result.output + + data = json.loads(cfg.read_text()) + assert data["event_types"] == ["goal"] + + +def test_event_types_add_duplicate(tmp_path: Path) -> None: + cfg = _write_config(tmp_path / "config.json", event_types=["goal"]) + result = runner.invoke(app, ["config", "event-types", "add", "goal", "--config", str(cfg)]) + assert result.exit_code == 0 + assert "already configured" in result.output + + +# --------------------------------------------------------------------------- +# remove +# --------------------------------------------------------------------------- + + +def test_event_types_remove(tmp_path: Path) -> None: + cfg = _write_config(tmp_path / "config.json", event_types=["goal", "save", "penalty"]) + result = runner.invoke(app, ["config", "event-types", "remove", "save", "--config", str(cfg)]) + assert result.exit_code == 0 + assert "Removed 'save'" in result.output + + data = json.loads(cfg.read_text()) + assert data["event_types"] == ["goal", "penalty"] + + +def test_event_types_remove_nonexistent(tmp_path: Path) -> None: + cfg = _write_config(tmp_path / "config.json", event_types=["goal"]) + result = runner.invoke(app, ["config", "event-types", "remove", "penalty", "--config", str(cfg)]) + assert result.exit_code == 1 + assert "not found" in result.output + + +def test_event_types_remove_last(tmp_path: Path) -> None: + cfg = _write_config(tmp_path / "config.json", event_types=["goal"]) + result = runner.invoke(app, ["config", "event-types", "remove", "goal", "--config", str(cfg)]) + assert result.exit_code == 0 + assert "(empty)" in result.output + + +# --------------------------------------------------------------------------- +# set +# --------------------------------------------------------------------------- + + +def test_event_types_set(tmp_path: Path) -> None: + cfg = _write_config(tmp_path / "config.json", event_types=["goal"]) + result = runner.invoke(app, ["config", "event-types", "set", "penalty", "assist", "--config", str(cfg)]) + assert result.exit_code == 0 + assert "penalty" in result.output + assert "assist" in result.output + + data = json.loads(cfg.read_text()) + assert data["event_types"] == ["penalty", "assist"] + + +def test_event_types_set_replaces_existing(tmp_path: Path) -> None: + cfg = _write_config(tmp_path / "config.json", event_types=["goal", "save"]) + result = runner.invoke(app, ["config", "event-types", "set", "foul", "--config", str(cfg)]) + assert result.exit_code == 0 + + data = json.loads(cfg.read_text()) + assert data["event_types"] == ["foul"] + + +# --------------------------------------------------------------------------- +# defaults +# --------------------------------------------------------------------------- + + +def test_event_types_defaults_hockey(tmp_path: Path) -> None: + cfg = _write_config(tmp_path / "config.json", sport="hockey") + result = runner.invoke(app, ["config", "event-types", "defaults", "--config", str(cfg)]) + assert result.exit_code == 0 + assert "hockey" in result.output + assert "goal" in result.output + assert "save" in result.output + + +def test_event_types_defaults_generic(tmp_path: Path) -> None: + cfg = _write_config(tmp_path / "config.json", sport="generic") + result = runner.invoke(app, ["config", "event-types", "defaults", "--config", str(cfg)]) + assert result.exit_code == 0 + assert "No default event types" in result.output + + +def test_event_types_defaults_soccer(tmp_path: Path) -> None: + cfg = _write_config(tmp_path / "config.json", sport="soccer") + result = runner.invoke(app, ["config", "event-types", "defaults", "--config", str(cfg)]) + assert result.exit_code == 0 + assert "soccer" in result.output + assert "corner" in result.output + + +def test_event_types_defaults_missing_config(tmp_path: Path) -> None: + result = runner.invoke(app, ["config", "event-types", "defaults", "--config", str(tmp_path / "nope.json")]) + assert result.exit_code == 1 + assert "Error" in result.output + + +def test_default_event_type_entries() -> None: + from reeln.core.event_types import default_event_type_entries + + entries = default_event_type_entries("hockey") + assert len(entries) > 0 + assert entries[0].name == "goal" + assert entries[0].team_specific is True + + +def test_default_event_type_entries_unknown_sport() -> None: + from reeln.core.event_types import default_event_type_entries + + assert default_event_type_entries("curling") == [] diff --git a/tests/unit/commands/test_hooks_cmd.py b/tests/unit/commands/test_hooks_cmd.py new file mode 100644 index 0000000..bf7b51f --- /dev/null +++ b/tests/unit/commands/test_hooks_cmd.py @@ -0,0 +1,496 @@ +"""Tests for the hooks CLI commands.""" + +from __future__ import annotations + +import json +import logging +from pathlib import Path +from unittest.mock import MagicMock, patch + +from typer.testing import CliRunner + +from reeln.cli import app +from reeln.commands.hooks_cmd import ( + _dicts_to_namespaces, + _LogCapture, + _parse_json_arg, + _resolve_hook, +) +from reeln.core.errors import ReelnError +from reeln.models.config import AppConfig +from reeln.plugins.hooks import Hook, HookContext + +runner = CliRunner() + + +# --------------------------------------------------------------------------- +# _LogCapture +# --------------------------------------------------------------------------- + + +def test_log_capture_info_goes_to_records() -> None: + capture = _LogCapture() + capture.setFormatter(logging.Formatter("%(message)s")) + record = logging.LogRecord( + name="test", level=logging.INFO, pathname="", lineno=0, + msg="hello", args=(), exc_info=None, + ) + capture.emit(record) + assert capture.records == ["hello"] + assert capture.errors == [] + + +def test_log_capture_warning_goes_to_records() -> None: + capture = _LogCapture() + capture.setFormatter(logging.Formatter("%(message)s")) + record = logging.LogRecord( + name="test", level=logging.WARNING, pathname="", lineno=0, + msg="warn", args=(), exc_info=None, + ) + capture.emit(record) + assert capture.records == ["warn"] + assert capture.errors == [] + + +def test_log_capture_error_goes_to_errors() -> None: + capture = _LogCapture() + capture.setFormatter(logging.Formatter("%(message)s")) + record = logging.LogRecord( + name="test", level=logging.ERROR, pathname="", lineno=0, + msg="bad", args=(), exc_info=None, + ) + capture.emit(record) + assert capture.records == [] + assert capture.errors == ["bad"] + + +def test_log_capture_critical_goes_to_errors() -> None: + capture = _LogCapture() + capture.setFormatter(logging.Formatter("%(message)s")) + record = logging.LogRecord( + name="test", level=logging.CRITICAL, pathname="", lineno=0, + msg="fatal", args=(), exc_info=None, + ) + capture.emit(record) + assert capture.errors == ["fatal"] + + +# --------------------------------------------------------------------------- +# _dicts_to_namespaces +# --------------------------------------------------------------------------- + + +def test_dicts_to_namespaces_flat() -> None: + data = {"key": "value", "num": 42} + result = _dicts_to_namespaces(data) + assert result["key"] == "value" + assert result["num"] == 42 + + +def test_dicts_to_namespaces_nested_dict() -> None: + data = {"game_info": {"home_team": "East", "sport": "hockey"}} + result = _dicts_to_namespaces(data) + ns = result["game_info"] + assert ns.home_team == "East" + assert ns.sport == "hockey" + + +def test_dicts_to_namespaces_deeply_nested() -> None: + data = {"a": {"b": {"c": "deep"}}} + result = _dicts_to_namespaces(data) + assert result["a"].b.c == "deep" + + +def test_dicts_to_namespaces_non_dict_values_preserved() -> None: + data = {"items": [1, 2, 3], "flag": True, "name": "test"} + result = _dicts_to_namespaces(data) + assert result["items"] == [1, 2, 3] + assert result["flag"] is True + + +def test_dicts_to_namespaces_empty() -> None: + assert _dicts_to_namespaces({}) == {} + + +def test_dicts_to_namespaces_getattr_fallback() -> None: + """Verify getattr works with defaults on converted namespace.""" + data = {"game_info": {"home_team": "East"}} + result = _dicts_to_namespaces(data) + ns = result["game_info"] + assert getattr(ns, "home_team", "") == "East" + assert getattr(ns, "away_team", "default") == "default" + + +# --------------------------------------------------------------------------- +# _resolve_hook +# --------------------------------------------------------------------------- + + +def test_resolve_hook_valid() -> None: + assert _resolve_hook("on_game_init") == Hook.ON_GAME_INIT + + +def test_resolve_hook_case_insensitive() -> None: + assert _resolve_hook("ON_GAME_INIT") == Hook.ON_GAME_INIT + + +def test_resolve_hook_strips_prefix() -> None: + assert _resolve_hook("hook.on_game_init") == Hook.ON_GAME_INIT + + +def test_resolve_hook_strips_whitespace() -> None: + assert _resolve_hook(" on_game_ready ") == Hook.ON_GAME_READY + + +def test_resolve_hook_unknown() -> None: + import typer + + try: + _resolve_hook("not_a_hook") + msg = "Expected BadParameter" + raise AssertionError(msg) + except typer.BadParameter as exc: + assert "Unknown hook" in str(exc) + assert "not_a_hook" in str(exc) + + +# --------------------------------------------------------------------------- +# _parse_json_arg +# --------------------------------------------------------------------------- + + +def test_parse_json_arg_none_returns_empty() -> None: + assert _parse_json_arg(None, "test") == {} + + +def test_parse_json_arg_empty_string_returns_empty() -> None: + assert _parse_json_arg("", "test") == {} + + +def test_parse_json_arg_valid_json() -> None: + result = _parse_json_arg('{"key": "value"}', "test") + assert result == {"key": "value"} + + +def test_parse_json_arg_invalid_json() -> None: + import typer + + try: + _parse_json_arg("not-json", "ctx") + msg = "Expected BadParameter" + raise AssertionError(msg) + except typer.BadParameter as exc: + assert "Invalid JSON for ctx" in str(exc) + + +def test_parse_json_arg_non_dict_json() -> None: + import typer + + try: + _parse_json_arg("[1, 2, 3]", "ctx") + msg = "Expected BadParameter" + raise AssertionError(msg) + except typer.BadParameter as exc: + assert "must be a JSON object" in str(exc) + assert "list" in str(exc) + + +def test_parse_json_arg_file_reference(tmp_path: Path) -> None: + f = tmp_path / "data.json" + f.write_text('{"from_file": true}') + result = _parse_json_arg(f"@{f}", "test") + assert result == {"from_file": True} + + +def test_parse_json_arg_file_not_found() -> None: + import typer + + try: + _parse_json_arg("@/nonexistent/path.json", "ctx") + msg = "Expected BadParameter" + raise AssertionError(msg) + except typer.BadParameter as exc: + assert "file not found" in str(exc) + + +def test_parse_json_arg_file_invalid_json(tmp_path: Path) -> None: + import typer + + f = tmp_path / "bad.json" + f.write_text("not-json") + try: + _parse_json_arg(f"@{f}", "ctx") + msg = "Expected BadParameter" + raise AssertionError(msg) + except typer.BadParameter as exc: + assert "Invalid JSON for ctx" in str(exc) + + +def test_parse_json_arg_file_non_dict(tmp_path: Path) -> None: + import typer + + f = tmp_path / "arr.json" + f.write_text("[1, 2]") + try: + _parse_json_arg(f"@{f}", "ctx") + msg = "Expected BadParameter" + raise AssertionError(msg) + except typer.BadParameter as exc: + assert "must be a JSON object" in str(exc) + + +# --------------------------------------------------------------------------- +# hooks list +# --------------------------------------------------------------------------- + + +def test_list_hooks() -> None: + result = runner.invoke(app, ["hooks", "list"]) + assert result.exit_code == 0 + for hook in Hook: + assert hook.value in result.output + + +# --------------------------------------------------------------------------- +# hooks run — happy path +# --------------------------------------------------------------------------- + + +def test_run_happy_path_no_context() -> None: + with patch("reeln.commands.hooks_cmd.load_config", return_value=AppConfig()): + result = runner.invoke(app, ["hooks", "run", "on_game_init"]) + + assert result.exit_code == 0 + output = json.loads(result.output.strip()) + assert output["success"] is True + assert output["hook"] == "on_game_init" + assert isinstance(output["shared"], dict) + assert isinstance(output["logs"], list) + assert isinstance(output["errors"], list) + + +def test_run_with_context_json() -> None: + ctx = '{"game_dir": "/tmp/test", "game_info": {"sport": "hockey"}}' + with patch("reeln.commands.hooks_cmd.load_config", return_value=AppConfig()): + result = runner.invoke(app, ["hooks", "run", "on_game_init", "--context-json", ctx]) + + assert result.exit_code == 0 + output = json.loads(result.output.strip()) + assert output["success"] is True + + +def test_run_with_shared_json() -> None: + shared = '{"livestream_metadata": {"title": "Test"}}' + with patch("reeln.commands.hooks_cmd.load_config", return_value=AppConfig()): + result = runner.invoke(app, ["hooks", "run", "on_game_ready", "--shared-json", shared]) + + assert result.exit_code == 0 + output = json.loads(result.output.strip()) + assert output["success"] is True + assert output["hook"] == "on_game_ready" + + +def test_run_shared_dict_preserved_through_emission() -> None: + """Shared data passed via --shared-json should be available after hook emission.""" + shared = '{"existing_key": "preserved"}' + + with patch("reeln.commands.hooks_cmd.load_config", return_value=AppConfig()): + result = runner.invoke(app, ["hooks", "run", "on_game_ready", "--shared-json", shared]) + + assert result.exit_code == 0 + output = json.loads(result.output.strip()) + assert output["shared"]["existing_key"] == "preserved" + + +def test_run_with_profile_option() -> None: + mock_config = MagicMock(return_value=AppConfig()) + with patch("reeln.commands.hooks_cmd.load_config", mock_config): + result = runner.invoke(app, ["hooks", "run", "on_game_init", "--profile", "test-profile"]) + + assert result.exit_code == 0 + mock_config.assert_called_once_with(path=None, profile="test-profile") + + +def test_run_with_config_option(tmp_path: Path) -> None: + cfg_file = tmp_path / "config.json" + cfg_file.write_text("{}") + mock_config = MagicMock(return_value=AppConfig()) + with patch("reeln.commands.hooks_cmd.load_config", mock_config): + result = runner.invoke(app, ["hooks", "run", "on_game_init", "--config", str(cfg_file)]) + + assert result.exit_code == 0 + mock_config.assert_called_once_with(path=cfg_file, profile=None) + + +def test_run_with_file_references(tmp_path: Path) -> None: + ctx_file = tmp_path / "ctx.json" + ctx_file.write_text('{"game_dir": "/tmp"}') + shared_file = tmp_path / "shared.json" + shared_file.write_text('{"key": "val"}') + + with patch("reeln.commands.hooks_cmd.load_config", return_value=AppConfig()): + result = runner.invoke(app, [ + "hooks", "run", "on_game_init", + "--context-json", f"@{ctx_file}", + "--shared-json", f"@{shared_file}", + ]) + + assert result.exit_code == 0 + output = json.loads(result.output.strip()) + assert output["success"] is True + assert output["shared"]["key"] == "val" + + +# --------------------------------------------------------------------------- +# hooks run — plugin interaction +# --------------------------------------------------------------------------- + + +def test_run_plugin_writes_to_shared() -> None: + """A plugin handler that writes to context.shared should appear in output.""" + + def fake_handler(ctx: HookContext) -> None: + ctx.shared["generated"] = {"title": "Test Title"} + + def fake_activate(plugins_config: object) -> dict[str, object]: + from reeln.plugins.registry import get_registry + registry = get_registry() + registry.register(Hook.ON_GAME_INIT, fake_handler) + return {} + + with ( + patch("reeln.commands.hooks_cmd.load_config", return_value=AppConfig()), + patch("reeln.commands.hooks_cmd.activate_plugins", side_effect=fake_activate), + ): + result = runner.invoke(app, ["hooks", "run", "on_game_init"]) + + assert result.exit_code == 0 + output = json.loads(result.output.strip()) + assert output["success"] is True + assert output["shared"]["generated"]["title"] == "Test Title" + + +def _parse_json_output(raw: str) -> dict[str, object]: + """Extract the JSON line from CliRunner output (may include stderr lines).""" + for line in reversed(raw.strip().splitlines()): + line = line.strip() + if line.startswith("{"): + return json.loads(line) # type: ignore[return-value] + msg = f"No JSON found in output: {raw!r}" + raise ValueError(msg) + + +def test_run_plugin_logs_captured() -> None: + """Log messages from plugin handlers should appear in logs.""" + plugin_logger = logging.getLogger("test_plugin") + + def logging_handler(ctx: HookContext) -> None: + plugin_logger.info("plugin did something") + + def fake_activate(plugins_config: object) -> dict[str, object]: + from reeln.plugins.registry import get_registry + registry = get_registry() + registry.register(Hook.ON_GAME_INIT, logging_handler) + return {} + + with ( + patch("reeln.commands.hooks_cmd.load_config", return_value=AppConfig()), + patch("reeln.commands.hooks_cmd.activate_plugins", side_effect=fake_activate), + ): + result = runner.invoke(app, ["hooks", "run", "on_game_init"]) + + assert result.exit_code == 0 + output = _parse_json_output(result.output) + assert any("plugin did something" in log for log in output["logs"]) + + +def test_run_plugin_error_logged_not_fatal() -> None: + """A plugin that raises should not crash the hook; error appears in logs.""" + + def bad_handler(ctx: HookContext) -> None: + msg = "plugin exploded" + raise RuntimeError(msg) + + def fake_activate(plugins_config: object) -> dict[str, object]: + from reeln.plugins.registry import get_registry + registry = get_registry() + registry.register(Hook.ON_GAME_INIT, bad_handler) + return {} + + with ( + patch("reeln.commands.hooks_cmd.load_config", return_value=AppConfig()), + patch("reeln.commands.hooks_cmd.activate_plugins", side_effect=fake_activate), + ): + result = runner.invoke(app, ["hooks", "run", "on_game_init"]) + + assert result.exit_code == 0 + output = _parse_json_output(result.output) + # Hook registry catches exceptions, so success is still True + assert output["success"] is True + + +# --------------------------------------------------------------------------- +# hooks run — error paths +# --------------------------------------------------------------------------- + + +def test_run_unknown_hook() -> None: + result = runner.invoke(app, ["hooks", "run", "invalid_hook"]) + assert result.exit_code == 2 + + +def test_run_invalid_context_json() -> None: + result = runner.invoke(app, ["hooks", "run", "on_game_init", "--context-json", "not-json"]) + assert result.exit_code == 2 + + +def test_run_invalid_shared_json() -> None: + result = runner.invoke(app, ["hooks", "run", "on_game_init", "--shared-json", "[1,2]"]) + assert result.exit_code == 2 + + +def test_run_config_load_failure() -> None: + with patch( + "reeln.commands.hooks_cmd.load_config", + side_effect=ReelnError("config broken"), + ): + result = runner.invoke(app, ["hooks", "run", "on_game_init"]) + + assert result.exit_code == 1 + # _emit_error writes JSON to stdout before exit + output = json.loads(result.output.strip()) + assert output["success"] is False + assert any("config broken" in e for e in output["errors"]) + + +def test_run_unexpected_exception_during_activation() -> None: + """An unexpected exception (not ReelnError) during activation is caught.""" + with ( + patch("reeln.commands.hooks_cmd.load_config", return_value=AppConfig()), + patch( + "reeln.commands.hooks_cmd.activate_plugins", + side_effect=RuntimeError("unexpected"), + ), + ): + result = runner.invoke(app, ["hooks", "run", "on_game_init"]) + + assert result.exit_code == 0 # Command completes, but success=false in JSON + output = json.loads(result.output.strip()) + assert output["success"] is False + assert any("unexpected" in e for e in output["errors"]) + + +# --------------------------------------------------------------------------- +# _emit_error +# --------------------------------------------------------------------------- + + +def test_emit_error_writes_json(capsys: object) -> None: + from reeln.commands.hooks_cmd import _emit_error + + exc = _emit_error("something went wrong") + assert isinstance(exc, ReelnError) + + # _emit_error writes directly to sys.stdout, but in test context + # it's verified through the config_load_failure test above + assert str(exc) == "something went wrong" diff --git a/tests/unit/commands/test_render.py b/tests/unit/commands/test_render.py index 6ad4c02..5077ac7 100644 --- a/tests/unit/commands/test_render.py +++ b/tests/unit/commands/test_render.py @@ -1359,7 +1359,7 @@ def test_apply_player_flag_without_game_dir(tmp_path: Path) -> None: ], ) assert result.exit_code == 0 - assert "Subtitle:" in result.output + assert "Overlay:" in result.output def test_apply_player_flag_overrides_event(tmp_path: Path) -> None: @@ -1419,7 +1419,7 @@ def test_apply_player_flag_overrides_event(tmp_path: Path) -> None: ], ) assert result.exit_code == 0 - assert "Subtitle:" in result.output + assert "Overlay:" in result.output def test_short_subtitle_temp_cleanup_after_render(tmp_path: Path) -> None: @@ -1533,7 +1533,7 @@ def test_apply_subtitle_without_game_info_uses_empty_context(tmp_path: Path) -> ], ) assert result.exit_code == 0 - assert "Subtitle:" in result.output + assert "Overlay:" in result.output # --------------------------------------------------------------------------- @@ -2463,7 +2463,7 @@ def test_render_apply_with_game_dir_and_subtitle(tmp_path: Path) -> None: ], ) assert result.exit_code == 0 - assert "Subtitle:" in result.output + assert "Overlay:" in result.output def test_render_apply_with_event_context(tmp_path: Path) -> None: @@ -2525,7 +2525,7 @@ def test_render_apply_with_event_context(tmp_path: Path) -> None: ], ) assert result.exit_code == 0 - assert "Subtitle:" in result.output + assert "Overlay:" in result.output def test_render_apply_game_dir_not_found_nonfatal(tmp_path: Path) -> None: @@ -3790,6 +3790,57 @@ def _activate_with_zoom_handler(plugins_config: object) -> dict[str, object]: assert "Dry run" in result.output +def test_render_short_smart_crop_plugin_zoom_error(tmp_path: Path) -> None: + """Smart zoom error from plugin raises RenderError and exits 1.""" + clip = tmp_path / "clip.mkv" + clip.touch() + + from reeln.models.zoom import ExtractedFrames + from reeln.plugins.hooks import Hook + from reeln.plugins.registry import get_registry + + frames = ExtractedFrames( + frame_paths=(tmp_path / "f.png",), + timestamps=(5.0,), + source_width=1920, + source_height=1080, + duration=10.0, + fps=60.0, + ) + + def _provide_error(context: object) -> None: + from reeln.plugins.hooks import HookContext + + assert isinstance(context, HookContext) + context.shared["smart_zoom"] = {"error": "vision API timed out"} + + def _activate_with_error_handler(plugins_config: object) -> dict[str, object]: + get_registry().register(Hook.ON_FRAMES_EXTRACTED, _provide_error) + return {} + + with ( + patch("reeln.core.ffmpeg.discover_ffmpeg", return_value=Path("/usr/bin/ffmpeg")), + patch("reeln.core.renderer.FFmpegRenderer") as mock_renderer_cls, + patch("reeln.commands.render.activate_plugins", side_effect=_activate_with_error_handler), + ): + mock_renderer_cls.return_value.extract_frames.return_value = frames + result = runner.invoke( + app, + [ + "render", + "short", + str(clip), + "--crop", + "crop", + "--smart", + ], + ) + + assert result.exit_code != 0 + assert result.exception is not None + assert "Smart zoom analysis failed" in str(result.exception) + + def test_render_short_smart_crop_extract_error(tmp_path: Path) -> None: """Smart crop errors when frame extraction fails.""" clip = tmp_path / "clip.mkv" diff --git a/tests/unit/core/test_config.py b/tests/unit/core/test_config.py index f0c4d62..f4d7b99 100644 --- a/tests/unit/core/test_config.py +++ b/tests/unit/core/test_config.py @@ -807,6 +807,83 @@ def test_validate_config_iterations_valid() -> None: assert issues == [] +def test_validate_config_event_types_not_list() -> None: + issues = validate_config({"config_version": 1, "event_types": "bad"}) + assert any("event_types" in i for i in issues) + + +def test_validate_config_event_types_entry_not_string() -> None: + issues = validate_config({"config_version": 1, "event_types": [123]}) + assert any("event_types[0]" in i for i in issues) + + +def test_validate_config_event_types_valid() -> None: + issues = validate_config({"config_version": 1, "event_types": ["goal", "save"]}) + assert issues == [] + + +def test_validate_config_iterations_references_unknown_event_type() -> None: + issues = validate_config({ + "config_version": 1, + "event_types": ["goal"], + "iterations": {"mappings": {"goal": ["slowmo"], "save": ["replay"]}}, + }) + assert any("save" in i for i in issues) + + +def test_validate_config_iterations_nested_mappings_key() -> None: + """iterations dict without a 'mappings' key uses the dict itself as mappings.""" + issues = validate_config({ + "config_version": 1, + "event_types": ["goal"], + "iterations": {"goal": ["slowmo"], "default": ["normal"]}, + }) + assert issues == [] + + +def test_validate_config_iterations_mappings_not_dict() -> None: + """mappings value that is not a dict is silently skipped.""" + issues = validate_config({ + "config_version": 1, + "event_types": ["goal"], + "iterations": {"mappings": ["not", "a", "dict"]}, + }) + assert issues == [] + + +# --------------------------------------------------------------------------- +# dict_to_config event types +# --------------------------------------------------------------------------- + + +def test_dict_to_config_event_types_string_entries() -> None: + """Plain string entries in event_types are parsed into EventTypeEntry.""" + cfg = dict_to_config({"event_types": ["goal", "save"]}) + assert len(cfg.event_types) == 2 + assert cfg.event_types[0].name == "goal" + assert cfg.event_types[0].team_specific is False + + +def test_dict_to_config_event_types_dict_entries() -> None: + """Dict entries with name/team_specific are parsed correctly.""" + cfg = dict_to_config({"event_types": [{"name": "goal", "team_specific": True}]}) + assert len(cfg.event_types) == 1 + assert cfg.event_types[0].name == "goal" + assert cfg.event_types[0].team_specific is True + + +def test_dict_to_config_event_types_dict_missing_name_ignored() -> None: + """Dict entry without 'name' key is silently skipped.""" + cfg = dict_to_config({"event_types": [{"team_specific": True}]}) + assert cfg.event_types == [] + + +def test_dict_to_config_event_types_not_list_ignored() -> None: + """Non-list event_types is silently ignored.""" + cfg = dict_to_config({"event_types": "bad"}) + assert cfg.event_types == [] + + # --------------------------------------------------------------------------- # Load # --------------------------------------------------------------------------- diff --git a/tests/unit/core/test_ffmpeg.py b/tests/unit/core/test_ffmpeg.py index 745b1e1..9757218 100644 --- a/tests/unit/core/test_ffmpeg.py +++ b/tests/unit/core/test_ffmpeg.py @@ -229,6 +229,16 @@ def test_probe_duration_timeout() -> None: assert probe_duration(Path("/usr/bin/ffmpeg"), Path("video.mkv")) is None +def test_probe_duration_single_arg() -> None: + """Single-arg form auto-discovers ffmpeg.""" + with ( + patch("reeln.core.ffmpeg.discover_ffmpeg", return_value=Path("/usr/bin/ffmpeg")), + patch("reeln.core.ffmpeg.subprocess.run", return_value=_mock_probe_proc("42.5\n")), + ): + result = probe_duration(Path("video.mkv")) + assert result == 42.5 + + def test_probe_fps_fractional() -> None: with patch("reeln.core.ffmpeg.subprocess.run", return_value=_mock_probe_proc("60000/1001\n")): result = probe_fps(Path("/usr/bin/ffmpeg"), Path("video.mkv")) diff --git a/tests/unit/core/test_overlay.py b/tests/unit/core/test_overlay.py index 73462dd..d2438f2 100644 --- a/tests/unit/core/test_overlay.py +++ b/tests/unit/core/test_overlay.py @@ -270,8 +270,8 @@ def test_y_offset(self) -> None: assert result.get("goal_overlay_box_y") == str(820 + 50) assert result.get("goal_overlay_team_y") == str(828 + 50) assert result.get("goal_overlay_scorer_y") == str(852 + 50) - assert result.get("goal_overlay_assist_1_y") == str(892 + 50) - assert result.get("goal_overlay_assist_2_y") == str(914 + 50) + assert result.get("goal_overlay_assist_1_y") == str(895 + 50) + assert result.get("goal_overlay_assist_2_y") == str(921 + 50) def test_zero_y_offset(self) -> None: ctx = self._base_ctx() diff --git a/tests/unit/test_native.py b/tests/unit/test_native.py new file mode 100644 index 0000000..04a79ac --- /dev/null +++ b/tests/unit/test_native.py @@ -0,0 +1,20 @@ +"""Tests for the native bridge module.""" + +from __future__ import annotations + + +def test_import_succeeds() -> None: + """reeln_native should be importable as a required dependency.""" + from reeln.native import get_native + + mod = get_native() + assert hasattr(mod, "__version__") + + +def test_get_native_returns_module() -> None: + """get_native() returns the reeln_native module.""" + from reeln.native import get_native + + mod = get_native() + # Verify it exposes expected functions + assert callable(getattr(mod, "probe", None)) diff --git a/uv.lock b/uv.lock index 4e00a62..28dbd36 100644 --- a/uv.lock +++ b/uv.lock @@ -757,6 +757,7 @@ name = "reeln" source = { editable = "." } dependencies = [ { name = "filelock" }, + { name = "reeln-native" }, { name = "typer" }, ] @@ -791,6 +792,7 @@ requires-dist = [ { name = "pytest-xdist", marker = "extra == 'dev'" }, { name = "questionary", marker = "extra == 'dev'", specifier = ">=2.0.1" }, { name = "questionary", marker = "extra == 'interactive'", specifier = ">=2.0.1" }, + { name = "reeln-native", specifier = ">=0.2.3,<1.0" }, { name = "ruff", marker = "extra == 'dev'" }, { name = "sphinx", marker = "extra == 'docs'", specifier = ">=7.0" }, { name = "sphinx-copybutton", marker = "extra == 'docs'", specifier = ">=0.5" }, @@ -798,6 +800,20 @@ requires-dist = [ ] provides-extras = ["dev", "docs", "interactive"] +[[package]] +name = "reeln-native" +version = "0.2.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ea/9f/30cf53542612e88f6be8d4bd8297e92bea5f343b9ca2fc3611e6d7c8e395/reeln_native-0.2.3.tar.gz", hash = "sha256:18731ed8991554f6c93972e0295b3adb693ed8df23ecc1d3c3766b87872f858e", size = 95511, upload-time = "2026-04-02T13:29:51.369Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f0/37/92874e53c6c88e85eeefc066ca3880771d9b5c5d3a13d97adcc6180543a6/reeln_native-0.2.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:e2f1ec69c8117501105450220071ac281d20847003ed88d7531b340d5993e102", size = 1939152, upload-time = "2026-04-02T13:29:39.237Z" }, + { url = "https://files.pythonhosted.org/packages/2b/45/f8b4d6dd3e5e8f81a292c72361e0e93b7ec241af73510feaeeb8e8ea9da6/reeln_native-0.2.3-cp311-cp311-manylinux_2_28_x86_64.whl", hash = "sha256:ca74de7d0287209c7cb8953fe8e3e3bd696506a8dd913b37f687c37ec984a541", size = 14030239, upload-time = "2026-04-02T13:29:40.918Z" }, + { url = "https://files.pythonhosted.org/packages/b3/ea/aa6c5daa8ec15ea8f5feb72df61858b3966d093a937bfe2c6e9aa8feb44c/reeln_native-0.2.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:3aa8e3c92737d3d9635aa942b5d7a7c6845042e9850260bafd7ab8c35c459692", size = 1937281, upload-time = "2026-04-02T13:29:43.272Z" }, + { url = "https://files.pythonhosted.org/packages/01/a3/4e2f3e7566106a6a6c03d8946db5bd8a133096231d3532b631b0c05632b2/reeln_native-0.2.3-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:45fdcd9913a9e62727c8a254e9964b0684797ae1146f04de2bd28022fd183059", size = 14029355, upload-time = "2026-04-02T13:29:44.946Z" }, + { url = "https://files.pythonhosted.org/packages/f4/6c/971eaeaf6d6dd84e401f49ff1fb6676ab5392973b116e5d3186700c9ddd7/reeln_native-0.2.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:32d41796db7fa9d5026726161f29a2e141d74f5fa4422e23268fa978505da1c2", size = 1937027, upload-time = "2026-04-02T13:29:47.162Z" }, + { url = "https://files.pythonhosted.org/packages/0d/23/f083f434e834df9175cf994a4f881b86585397583fd7dc11c76f5a31f11e/reeln_native-0.2.3-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:42addf1993e1f6ee07453bb54bba8e76af94ef801652735aaac3280d5538dcf6", size = 14028812, upload-time = "2026-04-02T13:29:49.173Z" }, +] + [[package]] name = "requests" version = "2.32.5"