scadustats 1.0.1__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (68) hide show
  1. scadustats-1.0.1/.github/dependabot.yml +11 -0
  2. scadustats-1.0.1/.github/workflows/ci.yml +37 -0
  3. scadustats-1.0.1/.github/workflows/release.yml +68 -0
  4. scadustats-1.0.1/.gitignore +18 -0
  5. scadustats-1.0.1/CLAUDE.md +119 -0
  6. scadustats-1.0.1/LICENSE +21 -0
  7. scadustats-1.0.1/PKG-INFO +112 -0
  8. scadustats-1.0.1/README.md +81 -0
  9. scadustats-1.0.1/pyproject.toml +79 -0
  10. scadustats-1.0.1/scadustats/__init__.py +1 -0
  11. scadustats-1.0.1/scadustats/__main__.py +4 -0
  12. scadustats-1.0.1/scadustats/cli/__init__.py +1 -0
  13. scadustats-1.0.1/scadustats/cli/app.py +960 -0
  14. scadustats-1.0.1/scadustats/cli/display.py +303 -0
  15. scadustats-1.0.1/scadustats/models.py +344 -0
  16. scadustats-1.0.1/scadustats/overlay/__init__.py +5 -0
  17. scadustats-1.0.1/scadustats/overlay/board.py +139 -0
  18. scadustats-1.0.1/scadustats/overlay/commentators.py +95 -0
  19. scadustats-1.0.1/scadustats/overlay/game_type_label.py +42 -0
  20. scadustats-1.0.1/scadustats/overlay/layout.py +59 -0
  21. scadustats-1.0.1/scadustats/overlay/ocr.py +99 -0
  22. scadustats-1.0.1/scadustats/overlay/scoreboard.py +38 -0
  23. scadustats-1.0.1/scadustats/overlay/timer.py +25 -0
  24. scadustats-1.0.1/scadustats/pipeline/__init__.py +3 -0
  25. scadustats-1.0.1/scadustats/pipeline/consolidate.py +555 -0
  26. scadustats-1.0.1/scadustats/pipeline/extract.py +567 -0
  27. scadustats-1.0.1/scadustats/pipeline/segmentation.py +102 -0
  28. scadustats-1.0.1/scadustats/pipeline/squares.py +62 -0
  29. scadustats-1.0.1/scadustats/rules/__init__.py +3 -0
  30. scadustats-1.0.1/scadustats/rules/validation.py +308 -0
  31. scadustats-1.0.1/scadustats/rules/winner.py +145 -0
  32. scadustats-1.0.1/scadustats/storage/__init__.py +1 -0
  33. scadustats-1.0.1/scadustats/storage/db.py +299 -0
  34. scadustats-1.0.1/scadustats/storage/json_export.py +314 -0
  35. scadustats-1.0.1/scadustats/storage/player_export.py +167 -0
  36. scadustats-1.0.1/scadustats/video/__init__.py +1 -0
  37. scadustats-1.0.1/scadustats/video/download.py +82 -0
  38. scadustats-1.0.1/scadustats/video/frames.py +63 -0
  39. scadustats-1.0.1/scripts/calibrate_layout.py +62 -0
  40. scadustats-1.0.1/scripts/redact_fixture.py +147 -0
  41. scadustats-1.0.1/testfile +0 -0
  42. scadustats-1.0.1/tests/cli/test_app.py +1682 -0
  43. scadustats-1.0.1/tests/cli/test_display.py +258 -0
  44. scadustats-1.0.1/tests/fixtures/clip_base_game.mp4 +0 -0
  45. scadustats-1.0.1/tests/fixtures/clip_claim.mp4 +0 -0
  46. scadustats-1.0.1/tests/fixtures/clip_dlc_game.mp4 +0 -0
  47. scadustats-1.0.1/tests/fixtures/clip_game_boundary.mp4 +0 -0
  48. scadustats-1.0.1/tests/fixtures/hud_commentators.png +0 -0
  49. scadustats-1.0.1/tests/fixtures/hud_partial.png +0 -0
  50. scadustats-1.0.1/tests/fixtures/hud_start.png +0 -0
  51. scadustats-1.0.1/tests/overlay/test_board.py +36 -0
  52. scadustats-1.0.1/tests/overlay/test_commentators.py +80 -0
  53. scadustats-1.0.1/tests/overlay/test_game_type_label.py +81 -0
  54. scadustats-1.0.1/tests/overlay/test_timer.py +31 -0
  55. scadustats-1.0.1/tests/pipeline/test_consolidate.py +702 -0
  56. scadustats-1.0.1/tests/pipeline/test_extract.py +322 -0
  57. scadustats-1.0.1/tests/pipeline/test_extract_integration.py +445 -0
  58. scadustats-1.0.1/tests/pipeline/test_segmentation.py +108 -0
  59. scadustats-1.0.1/tests/pipeline/test_squares.py +66 -0
  60. scadustats-1.0.1/tests/rules/test_validation.py +380 -0
  61. scadustats-1.0.1/tests/rules/test_winner.py +268 -0
  62. scadustats-1.0.1/tests/storage/test_db.py +585 -0
  63. scadustats-1.0.1/tests/storage/test_json_export.py +572 -0
  64. scadustats-1.0.1/tests/storage/test_player_export.py +135 -0
  65. scadustats-1.0.1/tests/test_models.py +83 -0
  66. scadustats-1.0.1/tests/video/test_download.py +131 -0
  67. scadustats-1.0.1/tests/video/test_frames.py +25 -0
  68. scadustats-1.0.1/uv.lock +469 -0
@@ -0,0 +1,11 @@
1
+ version: 2
2
+ updates:
3
+ - package-ecosystem: "uv"
4
+ directory: "/"
5
+ schedule:
6
+ interval: "weekly"
7
+
8
+ - package-ecosystem: "github-actions"
9
+ directory: "/"
10
+ schedule:
11
+ interval: "weekly"
@@ -0,0 +1,37 @@
1
+ name: CI
2
+
3
+ on:
4
+ push:
5
+ branches: [main]
6
+ pull_request:
7
+
8
+ jobs:
9
+ build-and-test:
10
+ runs-on: ubuntu-latest
11
+ steps:
12
+ - uses: actions/checkout@v7
13
+ with:
14
+ # Full history, not just the latest commit: hatch-vcs (see
15
+ # [tool.hatch.version] in pyproject.toml) derives the package version from the
16
+ # nearest git tag, which a shallow clone can't see.
17
+ fetch-depth: 0
18
+
19
+ - name: Install ffmpeg, tesseract and tesserocr requirements
20
+ run: sudo apt-get update && sudo apt-get install -y ffmpeg tesseract-ocr libtesseract-dev libleptonica-dev pkg-config
21
+
22
+ - name: Install uv
23
+ uses: astral-sh/setup-uv@v7
24
+ with:
25
+ enable-cache: true
26
+
27
+ - name: Install dependencies
28
+ run: uv sync --locked
29
+
30
+ - name: Build package
31
+ run: uv build
32
+
33
+ - name: Run tests
34
+ run: uv run pytest
35
+
36
+ - name: Run integration tests (excluding the YouTube download test)
37
+ run: uv run pytest -m integration --deselect tests/video/test_download.py::test_download_video
@@ -0,0 +1,68 @@
1
+ name: Release
2
+
3
+ # Semantic-versioning release: pushing a `vMAJOR.MINOR.PATCH` tag builds the package (its
4
+ # version is derived from that tag by hatch-vcs, see [tool.hatch.version] in
5
+ # pyproject.toml) and publishes it as a GitHub release with the wheel/sdist attached.
6
+ on:
7
+ push:
8
+ tags:
9
+ - "v[0-9]+.[0-9]+.[0-9]+"
10
+
11
+ permissions:
12
+ contents: write
13
+
14
+ jobs:
15
+ release:
16
+ runs-on: ubuntu-latest
17
+ steps:
18
+ - uses: actions/checkout@v7
19
+ with:
20
+ # Full history, not just the tag's commit: hatch-vcs needs to see the tag
21
+ # itself (and prior tags) to derive a version at all.
22
+ fetch-depth: 0
23
+
24
+ - name: Install ffmpeg, tesseract and tesserocr requirements
25
+ run: sudo apt-get update && sudo apt-get install -y ffmpeg tesseract-ocr libtesseract-dev libleptonica-dev pkg-config
26
+
27
+ - name: Install uv
28
+ uses: astral-sh/setup-uv@v7
29
+ with:
30
+ enable-cache: true
31
+
32
+ - name: Install dependencies
33
+ run: uv sync --locked
34
+
35
+ - name: Run tests
36
+ run: uv run pytest
37
+
38
+ - name: Build package
39
+ run: uv build
40
+
41
+ - name: Publish GitHub release
42
+ uses: softprops/action-gh-release@v2
43
+ with:
44
+ files: dist/*
45
+ generate_release_notes: true
46
+
47
+ - name: Upload build artifacts
48
+ uses: actions/upload-artifact@v4
49
+ with:
50
+ name: dist
51
+ path: dist/
52
+
53
+ publish-pypi:
54
+ needs: release
55
+ runs-on: ubuntu-latest
56
+ environment: pypi
57
+ permissions:
58
+ # Required for PyPI trusted publishing (OIDC) — no API token stored as a secret.
59
+ id-token: write
60
+ steps:
61
+ - name: Download build artifacts
62
+ uses: actions/download-artifact@v4
63
+ with:
64
+ name: dist
65
+ path: dist/
66
+
67
+ - name: Publish to PyPI
68
+ uses: pypa/gh-action-pypi-publish@release/v1
@@ -0,0 +1,18 @@
1
+ __pycache__/
2
+ *.py[cod]
3
+ *.egg-info/
4
+ .venv/
5
+ build/
6
+ dist/
7
+ .pytest_cache/
8
+ .mypy_cache/
9
+ .ruff_cache/
10
+ downloads/
11
+ .DS_Store
12
+ .vscode
13
+ *.duckdb
14
+ calibration.png
15
+ data/
16
+ youtube_cookies.txt
17
+ .envrc
18
+ .claude
@@ -0,0 +1,119 @@
1
+ # CLAUDE.md
2
+
3
+ This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
4
+
5
+ ## Project purpose
6
+
7
+ scadustats extracts statistics from Elden Ring "Bingo Brawlers" match videos.
8
+
9
+ ## Setup and commands
10
+
11
+ - `ffmpeg` must be installed on the system (e.g. `brew install ffmpeg`) — yt-dlp needs it to merge downloaded video/audio streams; without it, downloads fail with `DownloadError: ... ffmpeg is not installed`.
12
+ - `tesseract` must be installed on the system (e.g. `brew install tesseract`) — `tesserocr` binds directly to Tesseract's C++ engine for OCR (goal text, player names, the BASE GAME/DLC label, and the timer) during extraction, but still needs a local `tesseract` install for its `tessdata/` language files (see `ocr._resolve_tessdata_prefix`); `tesserocr` itself installs from a prebuilt wheel on common platforms (bundling its own compiled Tesseract/Leptonica, independent of your system's `tesseract` version — see below), so this doesn't otherwise need to match. If `uv sync` ever has to build `tesserocr` from source instead (no matching wheel for your platform/Python version), it additionally needs `pkg-config` and Tesseract/Leptonica's dev headers: `brew install pkg-config` on macOS (Homebrew's `tesseract` formula already ships its headers), or `sudo apt install libtesseract-dev libleptonica-dev pkg-config` on Debian/Ubuntu.
13
+ - Dependency management and running: `uv` (not pip/venv directly).
14
+ - Install/sync environment: `uv sync`
15
+ - Run a script in the project environment: `uv run python -c "..."` or `uv run <entry point>`
16
+ - Build distributable (wheel/sdist): `uv build` (backed by hatchling)
17
+
18
+ ## Architecture
19
+
20
+ - Build backend: `hatchling`, configured in `pyproject.toml`.
21
+ - Package layout is flat (no `src/` directory) — the importable package `scadustats` lives at the repo root, alongside `pyproject.toml`.
22
+ - `pyproject.toml` explicitly declares `[tool.hatch.build.targets.wheel] packages = ["scadustats"]` since the flat layout requires hatchling to be told which directory is the package. Subpackages come along automatically; nothing needs listing per namespace.
23
+ - The package's modules are grouped into six namespaces, with `models.py` left at the root because every namespace imports it and nesting the shared vocabulary under any one of them would be arbitrary:
24
+
25
+ | namespace | modules | what it is |
26
+ | --- | --- | --- |
27
+ | `video/` | `download`, `frames` | acquiring footage (yt-dlp) and decoding it into frames |
28
+ | `overlay/` | `layout`, `ocr`, `board`, `timer`, `scoreboard`, `commentators`, `game_type_label` | turning one frame into a reading; the only modules that touch `cv2`/`tesserocr` |
29
+ | `rules/` | `winner`, `validation` | board replay, win conditions, tournament-rule checks — pure Python, no frames or I/O |
30
+ | `pipeline/` | `extract`, `segmentation`, `squares`, `consolidate` | assembling readings into a `VideoExtraction` |
31
+ | `storage/` | `json_export`, `player_export`, `db` | persistence (the JSON file is the source of truth, see below) |
32
+ | `cli/` | `app` (the Typer entry point, `scadustats.cli.app:main`), `display` | flags, prompts, and the rendering they print |
33
+
34
+ Imports run strictly one way — `cli` → `pipeline` → {`overlay`, `video`, `rules`, `storage`} → `models` — with one deliberate exception: `pipeline/extract.py` imports `storage/json_export.py`, because writing the JSON is part of `extract_video`'s contract (see below). Every `__init__.py` is a docstring only: nothing is re-exported, so a call site always names the namespace it's reaching into (`from scadustats.overlay import board, ocr`) and there's no import cycle to trip over. `tests/` mirrors the same six directories, and `overlay/game_type_label.py` keeps its `_label` suffix (rather than shortening to `game_type` alongside its siblings) because `extract.py` binds a local `game_type` at the very call site that uses the module — the shorter name would be shadowed exactly where it's needed.
35
+
36
+ ### Downloading videos (`scadustats/video/download.py`)
37
+
38
+ - `download_video` shells out to the `yt-dlp` CLI binary via `subprocess.run` rather than driving the `yt_dlp` Python API. This keeps the invocation a direct, readable equivalent of the documented CLI command (`yt-dlp -f "bestvideo[height<=720]" <URL>`).
39
+ - Format is fixed to `bestvideo[height<=720]` — video-only, no audio track, capped at 720p — since this project only needs footage for stats extraction, not playback.
40
+ - The final path is recovered from `--print after_move:filepath`, which yt-dlp resolves after its internal move-to-destination step, rather than reconstructing the filename manually (extensions can vary by source format).
41
+
42
+ ### Extracting stats (`scadustats/pipeline/extract.py` and friends)
43
+
44
+ The Bingo Brawlers broadcast overlay (5x5 goal grid with per-cell claim color, a score bar per player, a stopwatch timer, a "GAME N" label) is a fixed template that scales proportionally with output resolution — confirmed by comparing a 1280x720 and a 1920x1080 recording of the same template. This shapes most of the design below:
45
+
46
+ - `layout.py` stores every overlay region (grid, timer, game-type subtitle, score bars) as **fractional** bounding boxes (0-1 of frame width/height), not pixels, so one calibrated config works across resolutions instead of needing per-resolution constants. Recalibrate with `scripts/calibrate_layout.py <video_path>` (draws the configured boxes on a frame for visual verification) if a future season changes the template.
47
+ - A single video isn't *only* live gameplay footage: it also contains **"POST GAME" recap segments** that reuse the exact same grid/score-bar coordinates to cycle through a slideshow of other completed games' final boards, but replace the colored score bars with plain background and move the timer elsewhere. `board.is_gameplay_frame` gates every sample on the score bars actually being red/blue before doing any cell or timer reading — during a recap this reliably reads as plain background on *both* sides, unlike any real gameplay state. Frames that fail this check are skipped entirely in `extract.py`, rather than trying to interpret whatever the recap happens to be showing at the sampled coordinates. There's a second non-gameplay state that check can't see, though: the **splash between two games**, where the score bars stay colored but the grid and the timer are both torn down for a handful of samples. An unreadable timer is the tell, so `extract._collect_observations` drops any sample whose timer OCR came back `None` — later than `is_gameplay_frame` does, necessarily, since it needs the OCR result in hand. Reading cell colors off that splash used to produce a burst of spurious unmarks that wiped the just-finished game's winning line out of its final board state, costing game 1 of a real two-game video its winner. Genuinely unreadable timers are otherwise vanishingly rare (8 of 6280 gameplay samples on that video — all 8 of them this exact transition), so this drops essentially nothing else; if more than `_UNREADABLE_TIMER_WARN_FRACTION` (5%) of samples go this way it's a sign the timer isn't being read at all (a miscalibrated `TIMER_BOX`, too low-bitrate a source) and a warning is logged, since the failure would otherwise look like a quiet, successful extraction of a near-empty game.
48
+ - `board.py` classifies each cell's claim color by **raw BGR channel dominance** (`red - blue`, `blue - red`, with a `red - green` contrast check to reject an unrelated "orange flash" artifact), not HSV hue. Hue was tried first and looked solid on a handful of calibration frames (red ≈177°, blue ≈104°), but a full ~4-hour-video scan showed a genuinely, continuously red cell drifting from hue 177 down to 152 within seconds of real compressed footage while saturation stayed high — any hue band tight enough to reject a progress-badge contamination artifact was also tight enough to intermittently drop that genuine reading, causing claim-detection flicker. Raw R-vs-B dominance stayed rock-stable (~90-98 margin) across that same drift window. A pure RGB-distance-to-centroid approach was tried even earlier and was separately fooled by a whole-frame brightness swing (a stream fade-in/flash before the overlay appears); dominance-plus-contrast rejects that case too. If reclassification issues resurface, re-derive thresholds empirically from a full-video scan (sample every few seconds, dump per-cell BGR/HSV, look at real distributions) rather than calibrating from a couple of frames again — a handful of frames looked deceptively clean here.
49
+ - Color is sampled from a small patch inset at each cell's **corner**, not its center, specifically to dodge the centered goal text and a numeric progress-count badge that appears near the center/corner for multi-count goals.
50
+ - A cell can be **unmarked after being marked**: a player can inadvertently claim the wrong square and undo it. `extract.py`'s claim detection therefore treats `UNCLAIMED -> RED/BLUE` as a claim and `RED/BLUE -> UNCLAIMED` as an equally legitimate unclaim (`GameEvent.event_type`), not as a data-quality anomaly. Only a *direct* `RED <-> BLUE` swap with no intervening unclaim is still logged as a warning, since real game rules don't seem to support a square directly changing hands. A color change is only accepted once the same new color has been observed on **two consecutive samples** — this debounce is what stops transient single-frame classification flicker (e.g. a stream wipe/flash whose color transiently matches a reference) from being recorded as a real claim or unclaim. Unclaiming needs more than that, though: `_UNMARK_DEBOUNCE_SAMPLES` requires **twice** the baseline run (4 consecutive samples) to confirm a `RED/BLUE -> UNCLAIMED` transition, while a claim (or the anomalous direct swap) still only needs the baseline `_MARK_DEBOUNCE_SAMPLES` (2). Unclaiming is the direction real flicker falls into by default — a dulled or compression-damaged color patch reads as `UNCLAIMED` far more readily than a stray couple of frames invent a false `RED`/`BLUE` out of nothing (this is exactly the failure mode the splash-transition regression below produced) — and it's also the rarer, more consequential direction: a genuine unclaim is a deliberate undo that then persists for the rest of the game, so demanding a longer run costs real detections next to nothing while meaningfully cutting the false positives a couple of misread frames used to produce.
51
+ - Not every event is about a square. `EventType.GAME_START` marks the moment the overlay's stopwatch stops being a **pre-game countdown** and becomes the actual ascending game clock: `extract._detect_game_start` scans a segment's timer readings for the first strict local minimum (previous reading higher, next reading higher again) and emits a `GameEvent` there with `row`/`col`/`color` all `None`. A minimum only counts if the reading *continues* out of it by at most `_CLOCK_STEP_TOLERANCE_S` (30s, generous slack over the ~1s/sample a running clock shows): a segment after the first can open on the tail of the previous screen's short countdown, observed on a real match as `...3, 2, 1` and then straight to `180` — the game's own 3-minute countdown, which only then descends to 0 and ascends as the game clock. That `1` is a genuine strict local minimum, so without the continuity check game 2's start was reported ~3 minutes early, at the wrong dip. `GameEvent` (models.py; the `models.ClaimEvent`/`GameResult.claims`/DB `claims` table/`claim_id` names from earlier in the project's history were renamed to `GameEvent`/`GameResult.events`/`events`/`event_id` once a non-square event type existed) carries `row`/`col`/`color` as optional specifically so a game-level event can omit them; `db.py`'s `events` table and `json_export.py`'s per-event dict serialize them as SQL `NULL`/JSON `null` in that case. Returns `None` (no event emitted) if the segment never shows a descend-then-ascend dip — e.g. footage that starts mid-game with no countdown captured.
52
+ - `EventType.GAME_END` is the symmetric marker at the other end of a game: the moment the game's own recorded result — a completed line, or a majority once it's mathematically unbeatable, see `winner.determine_winner` — first became locked in for the rest of the game, rather than merely reached and later undone by an unclaim. `extract._detect_game_end` finds it the same way `_detect_game_start` finds its own moment — by locating a signal in the events already extracted, not inventing a new timestamp — via `winner.settled_result_index`, walked backward from the last event over `winner.board_states` (every intermediate board state, not just the final one) until `determine_winner` on that state stops agreeing with the game's own `(winner_color, win_type)`. The event at that index is where the outcome locked in; a synthetic `GameEvent` is emitted there with `row`/`col`/`color` all `None`, the same shape as `GAME_START`, timestamped off that settling event rather than carrying its own reading. Only emitted when there's a winner to settle on at all (`winner_color is not None`, i.e. `WinType.LINE` or `WinType.MAJORITY` — a `TIE` or an undetermined game has nothing to lock in) and only when the settling event is itself a `MARK` — an `UNMARK` can't complete a line, and while it could in principle tip a majority swing, that isn't a square being *marked* to end the game, so it isn't reported as one. `board_states`/`settled_result_index` live in `winner.py`, not `extract.py` or `validation.py`, for the same reason `replay`/`apply_event`/`empty_board` do: they're pure board logic with no frame/OCR/cv2 involvement, next to the win conditions they're evaluated over. Nothing in `validation.py` requires a `GAME_END` event to be present — it's an addition on the extraction side, and a JSON file extracted before it existed (or one whose game never settled to a winner) is a perfectly ordinary file, not a defect to flag.
53
+ - `commentators.py` reads the two commentators' names off the nameplates the overlay prints under the two *inner* webcams (`layout.COMMENTATOR_BOX_LEFT`/`RIGHT`) — the outer pair are the players' own cams and carry no nameplate (their names are read from the score strip instead, see `scoreboard.py`). Symmetric geometry (x 220-450/830-1060, y 671-706 on a 720p frame) confirmed against two independent real matches. A blank crop — every pre-existing fixture, since the nameplate boxes weren't in the redacted set until this was added — isn't simply unreadable: Tesseract hallucinates a plausible-looking token (`"re"`) off a uniform black image, which would otherwise be recorded as somebody's name. `commentators._has_nameplate` gates on the share of near-white pixels in the crop (a plate is bright text on a dark bar; real plates measured 8-10% versus exactly 0% for a blank region) before OCR runs at all — the same "check the precondition, skip entirely if it fails" shape as `board.is_gameplay_frame`. The name itself is recovered as the longest run of `[A-Za-z0-9_.-]` in the reading (`commentators._clean_name`) rather than reusing `scoreboard._clean_name`'s letters-only rule, which would cut a handle like `star0chris` down to `chris` — a streaming handle can contain digits and underscores that a player's display name doesn't need to. `extract_video` reads this once per video (not once per game, same reasoning as player names) off the frames already grabbed for square-text majority voting, majority-voted the same way (`commentators.majority_commentator_names`) since a single frame can misread and a nameplate is static for the whole broadcast. The result is a flat, ordered `list[str]` (`VideoExtraction.commentators`), not two named seats: which webcam a commentator happened to sit in isn't a property of the match, and a plate that never reads is dropped rather than left as a gap or a `None`. `db.py`'s `commentators` table (`match_id`, `position`, `name`) mirrors that shape — one row per name, ordered by `position` — rather than a fixed-width pair of columns, since nothing about the overlay guarantees exactly two commentators going forward. It's a brand-new table, so it needs no `ALTER TABLE ... ADD COLUMN` fixup the way `duration_s`/`win_line` did: `CREATE TABLE IF NOT EXISTS` already handles a database file that predates it.
54
+ - Winner determination (`extract._determine_winner`) replays *all* events, including unclaims, to the **final** board state and evaluates the win condition once there, rather than stopping at the first claim that completes a line — a mistaken claim can complete a line and be immediately unclaimed, and the schema doesn't record a win timestamp anyway, so nothing is lost by only checking the end state.
55
+ - A MAJORITY win is declared once a color's claimed-square count is **mathematically unbeatable** — `winner._majority_settled` checks that the leader's tally already exceeds whatever the trailer could reach by claiming every remaining unclaimed square (or that there are no unclaimed squares left), rather than the board being full or every line containing both colors. That line-based check (issue #63) missed real majority wins: on `2026-07-25-TwistieT-vs-Grey`, one goal square went unattempted by either player for a whole game, leaving one row blue-or-unclaimed right through to the game's end — a line resting on a square nobody will ever claim looks permanently "open" the same way a genuinely contested one does, so the old check withheld the majority verdict even though the other 24 squares had already made the outcome unbeatable. Cross-checking every real extracted match at the time of the fix showed every one of the tournament's own majority calls landed on a leader claiming *exactly* 13 of the board's 25 squares — the smallest tally no comeback can catch — strongly suggesting the tournament calls the game the instant that happens, rather than truly running the clock to zero; `_majority_settled` mirrors that directly, and no longer needs to reason about lines at all. One consequence: `WinType.TIE` can't actually occur through `determine_winner` on a 5x5 (25-square, odd) board any more — the leader/trailer counts can only be exactly equal while unclaimed squares remain (any all-claimed board has an odd total, so a 12.5/12.5 split is impossible), and an unsettled tie with squares still open is correctly `WinType.NONE`, not `TIE`. The enum value stays for a hand-edited JSON file to use if a contributor knows better.
56
+ - A LINE win also records **which** line it was won on, as `GameResult.win_line` (`models.WinLine`: `row_1`-`row_5`, `col_1`-`col_5`, `diagonal_tl_br`, `diagonal_bl_tr`; `None` for every non-line outcome, since there's no line to name). Row/column indices are **1-based**, matching `GameEvent.row`/`col` and the `squares` table — a JSON file shows `"win_line": "row_1"` a few lines from a claim's `"row": 1`, so a contributor hand-reviewing the file sees one consistent numbering throughout rather than the board's visual first row being labeled `0`. Internal board/list indexing (`winner.Board`, `overlay/layout.py`'s pixel math, `overlay/board.py`'s cell iteration) stays plain 0-based Python list indices regardless — the offset is applied only at the points where a 0-based loop index becomes a stored `GameEvent.row`/`col`, DB `row`/`col` column, or `WinLine` value (`extract._extract_events`, `db.write_extraction`'s `squares` insert, `winner.LINES`), and reversed wherever a 1-based value is used to index back into a 0-based list (`winner.apply_event`, `json_export._event_square_text`, `display._square_text`). `winner.LINES` is therefore a `dict[WinLine, list[Coord]]` built from the indices rather than written out twelve times (that's what keeps each key's coordinates guaranteed to match the name it's filed under, `Coord` itself staying a 0-based board index even though the `WinLine` key built alongside it is 1-based), and `winner.winning_line` — the old `check_line_winner`, renamed now that it returns more than a color — returns the color and the line *together* so a caller can't attribute a line to the wrong player. `determine_winner` returns a `(color, win_type, win_line)` triple. `WinLine.label` renders the value as prose ("row 1", "diagonal (top-left to bottom-right)") for `match show` and validation messages; the stored value stays the machine-readable one.
57
+ - `segmentation.py` requires **both of its two independent signals** to agree before declaring a new game boundary, since a single video can contain multiple back-to-back games and each signal alone is individually noisy: (1) the board resetting to fully unclaimed; (2) the timer jumping *backward by a large amount* (not just "any decrease" — a game apparently has its own multi-minute pre-game countdown that legitimately ticks down by ~1/sample, which would otherwise look like a reset on every single sample), compared against the most recently *seen* reading rather than the strictly-adjacent one, since the timer is unreadable (`None`) for a handful of samples during exactly the overlay transition a reset happens in — observed on a real match as a frozen 3123s final reading, ~8 unreadable samples, then a fresh countdown at 7s, a drop an adjacent-pair comparison never sees at all. There used to be a third signal, the "GAME N" label text changing, and the rule used to be "at least 2 of 3". It was removed because it never earned its keep: on a real two-game video it produced 18 "confirmed" changes, 17 of them OCR noise (`GARE Zz`, `GANIC Zz`, … for a board reading "GAME 2"), and the one genuine change was only confirmed 15 samples after the fact — well outside the agreement window — so it contributed nothing to detecting the real boundary it was watching for, while costing one throttled `tesseract` call every 5 samples. `detect_boundaries` returns **positions within the observation list**, not `Observation.sample_index` values — the two only coincide when every sampled frame was a gameplay frame, and they diverge in practice because non-gameplay frames (the "POST GAME" recap *between* games) never become observations. Returning sample indices made `extract._segment_games` slice the list at the wrong offsets — past its end, in the video that surfaced this — so every game after the first was silently dropped and one segment spanned the whole video. The agreement/collapse windows are still measured in `sample_index`, so "within 5 samples" means 5 sampled frames of real footage rather than 5 surviving observations with an arbitrary stretch of skipped recap in between.
58
+ - The live claim count from `board.py` is cross-checked against the OCR'd scoreboard numbers on every count change, logging a warning on mismatch — free validation since the broadcast already renders that number.
59
+ - `match_id` (`extract._match_id`) is `<match-date>-<red player>-vs-<blue-player>` (e.g. `2026-03-05-blanxz-vs-Serious`), not the video file's name — a file's name is often a descriptive title (see the pre-existing files that used to live under `matches/`, before the `data/matches/` restructure below), not a stable/parseable identifier, whereas this format is both human-readable and sorts/groups sensibly. It's built from `match_metadata`'s match date plus the first detected game's OCR'd player names. `match_metadata` is therefore a required argument to `extract_video`, not optional — match_id can't be formed without it. Missing/empty player names (`scoreboard.read_player_names` can return `""` on a bad OCR read, or there's no game at all) fall back to `"unknown"`; a `/` in a name is replaced with `-` since it would otherwise split into a spurious path segment when the id is used as a filename. Season and match type are still collected and stored (in the JSON body and, via `load-db`, the DB) — they're just not part of the id.
60
+ - One JSON file represents one *video* (`models.VideoExtraction`), not one game: `extract_video` reads player names once, from the first detected game, rather than once per game, since one video is one match and the same two players hold for every game in it -- re-reading per game would just be redundant OCR cost. `extract_video` only ever writes JSON — it never touches DuckDB. `json_export.write_video` writes one pretty-printed JSON file per video, grouped one directory per season (`<data_dir>/matches/season-<season>/<match_id>.json`, `data/` by default, see `json_export.video_path`) rather than flat across every season a tournament has run, with video-level fields (match_id, match_date, season, match_type, player_red_name, player_blue_name, commentators, metadata, num_games, red_score, blue_score, winner) alongside a `games` list holding every game the video contains (game_index, timestamps, game_type, winner, win_line, square_texts, events — a game has no name of its own, see `models.GameResult`; a file written before the "GAME N" label was dropped also carries a `label` key per game, which `read_video` simply ignores — no need to strip it by hand) — this exists so a contributor can hand-review/correct one video's extraction results (all its games at once) in a plain text editor. `video_url` is prompted for interactively alongside match_date/season/match_type (`cli._prompt_video_url`, empty allowed — a contributor extracting from a video someone else downloaded may not have the link handy) and `extracted_at` is the extraction date (`date.today()`), not a DB-write timestamp. A non-blank `video_url` is checked against `cli._is_youtube_url` (hostname in `{youtube.com, www.youtube.com, m.youtube.com, youtu.be}`) — light on purpose, no network call to confirm the video actually exists, just enough to catch a pasted-wrong-link mistake. `--video-url` is validated *before* `extract` starts the (multi-minute) extraction pipeline, not inside `_prompt_match_metadata` (which runs on a background thread overlapping extraction) — otherwise a bad flag value wouldn't surface as an error until everything else had already finished. A match is unique by player names + match date, which `match_id` already encodes (see `_match_id` above) — so re-running `extract` on the same match (not necessarily the same video file: a re-download of the same match hits this too) collides on the same `<data_dir>/matches/season-<season>/<match_id>.json` path. `--if-exists` left unset (the default) asks interactively whether to replace it, via `extract_video`'s `on_duplicate: Callable[[Path], bool] | None` callback — the same deferred-resolution pattern as `on_missing_game_type` (see below), for the same reason: `match_id` isn't known until deep into the pipeline (it needs the first game's OCR'd player names), so the check can't happen upfront in the CLI before extraction starts. The CLI's `on_duplicate` wraps `typer.confirm`, guarded by the same `progress_lock` as `on_missing_game_type` so it can't interleave with the progress bar or the metadata prompts. Declining leaves the existing JSON file untouched and `extract_video` returns an `ExtractionSummary` with `skipped=True` (`num_games`/`num_claims` meaningless at 0) rather than raising — it's a normal user choice, not a failure. `--if-exists replace`/`--if-exists error` bypass the prompt entirely (an explicit answer given upfront), falling through to `json_export.write_video`'s existing unconditional-overwrite/raise behavior. A caller of `extract_video` that passes neither an explicit `if_exists` nor an `on_duplicate` callback (there's no one to ask) gets a `ValueError` on a genuine duplicate, rather than silently overwriting — the old implicit `if_exists="replace"` default was judged too easy to accidentally clobber a prior extraction with, now that duplicates are meant to be caught rather than iterated over.
61
+ - Each event in a game's `events` list also carries a `square_text` key, e.g. `"square_text": "Kill 3 enemies"` a couple of lines from that same event's `"row": 1, "col": 1` — a contributor hand-reviewing the JSON directly (the common case this file format exists for) would otherwise have to cross-reference every claim's row/col against the game's `square_texts` grid by hand to know what was actually claimed. It's write-only, not a `models.GameEvent` field: `json_export._event_to_dict` looks it up from the enclosing game's `square_texts` at serialize time (`_event_square_text`, returning `None` for a game-level event with no row/col, or for a square outside a hand-edited/ragged grid) the same way `display._square_text` already does for `match show --events`, and `read_video` doesn't read the key back — storing it on `GameEvent` itself would let it drift from `square_texts` if a contributor corrected an OCR misread there without also updating every event that touched that square.
62
+
63
+ - The four fields that describe the *video file* rather than the match it records — `video_url`, `duration_s`, `published_at`, `extracted_at` — are grouped under their own `"metadata"` key in the JSON (`json_export._metadata_to_dict`, issue #47), rather than sitting at the top level alongside `match_id`/`match_date`/`player_red_name`/etc: a contributor skimming a file for the match-identifying fields would otherwise have to pick them out from among provenance/technical detail that isn't about the match itself. `models.VideoExtraction` keeps these as its own flat dataclass fields regardless — the grouping is a JSON presentation choice, not a model change, so every other module (`extract.py`, `db.py`, `display.py`) reads `extraction.duration_s` etc. exactly as before. `read_video` therefore can't just index `data["metadata"][...]`: a file written before #47 has these fields at the top level with no `"metadata"` key at all, so it resolves `metadata = data.get("metadata", data)` first — falling back to the whole document for the old flat layout — and reads every field off that, unchanged from before otherwise. `VideoExtraction` deliberately doesn't record the local filesystem path an extraction ran against (a field named `source_path` used to fill this role, removed in issue #90) — a local path isn't provenance about the *match*, and `cli.app.extract`'s `--video-url` option already covers giving a locally-supplied video a YouTube URL for provenance without needing to also record its on-disk path.
64
+ - `VideoExtraction.duration_s` is the **whole video's** length in raw seconds (`frames.probe`), not the sum of its games': a broadcast also contains intros, between-game "POST GAME" recaps and post-match talk, so it can't be recovered from the game segments after the fact — `extract_video` probes for it upfront. It's stored raw (like `start_video_ts_s`/`end_video_ts_s`, unlike an event's human-readable `game_timer`) and only formatted as `HH:MM:SS` for display, by `display.format_duration` in `match show`. The field is optional/defaulted to `None` throughout — dataclass, JSON, and the nullable `videos.duration_s` DB column — for two reasons: a video whose fps can't be read has no duration to record (`frames.probe` returns `0.0` there, which `extract_video` converts to `None` rather than writing a fabricated zero), and JSON files written before the field existed must still read back. That last case is why `json_export.read_video` reads this key off `metadata` (see above) with `.get`: a `KeyError` would make `match list` report an otherwise-valid extraction as an unparseable old-format file. `db.write_extraction` prefers `extraction.duration_s` (the JSON is the source of truth, hand corrections included) and only falls back to `video_info.duration_s` when the JSON has none. `db.py`'s schema also carries an `ALTER TABLE videos ADD COLUMN IF NOT EXISTS duration_s DOUBLE` after the `CREATE TABLE IF NOT EXISTS` block, since the latter leaves an already-created `videos` table alone and every insert into a pre-existing database file would otherwise fail on an unknown column. `games.win_line` is added the same way, for the same reason — note a column added by `ALTER` carries no `CHECK` constraint, unlike one declared in the `CREATE TABLE`; the values come from `models.WinLine` either way, so the constraint is a backstop rather than the thing keeping them valid (`test_schema_accepts_every_win_line_value` is what catches the SQL list drifting from the enum).
65
+ - `VideoExtraction.published_at` is when the video was uploaded to YouTube (yt-dlp's `upload_date`), distinct from `match_date` (when the match was actually played, user-supplied) and `extracted_at` (when this extraction ran) — three different dates that can legitimately disagree (a match played one day, uploaded the next, extracted weeks later). It's read off the same yt-dlp invocation that downloads the video, not a separate lookup: `download_video`'s single `--print-to-file` call already asks yt-dlp for `after_move:filepath`, so it costs nothing extra to have that one template also print `%(upload_date)s` on the same line (tab-separated — a filesystem path and an 8-digit date can't contain one, so splitting on it is unambiguous without a second temp file). `video.download.DownloadResult` carries both `path` and `published_at` back to the caller as a result of this. That means it's only ever known when *this run* did the downloading: `extract`'s URL branch in `cli/app.py` passes the downloaded result's `published_at` straight through to `extract_video`'s own `published_at` parameter, while a locally-supplied video (or a URL typed in at the `--video-url` prompt for a video someone else already downloaded) was never fetched by this run and so has no metadata to read it from — `extract_video` leaves the field as whatever was passed (`None` by default) rather than making its own separate network call to look it up after the fact, which would cost an extra yt-dlp invocation just for one supplementary field. Optional/defaulted to `None` throughout for the same two reasons as `duration_s` — no download this run means nothing to record, and a JSON file written before this field existed must still read back — including the same `data.get` treatment in `json_export.read_video` and the same `ALTER TABLE videos ADD COLUMN IF NOT EXISTS published_at DATE` in `db.py`.
66
+ - The match's outcome is summarized by three more derived properties alongside `num_games`, all four working the same way: `red_score`/`blue_score` (how many games each player won) and `winner` (`models.MatchWinner`: `red`/`blue`/`draw`). `MatchWinner` is its own enum rather than a reuse of `CellColor` because `draw` is a real *match* outcome that no single game has — a round robin match is two games and is allowed to end 1-1. `winner` is `None` ("undetermined") in two cases rather than guessing: a match with no games, and a match where any game has no winner recorded — in the latter the scores don't account for every game, so naming the current leader (or calling an even split a draw) would invent a result from incomplete data. That's exactly what `validation`'s `match_outcome_undetermined` rule already reports, and `_check_match_outcome`'s draw rule is now written in terms of `extraction.winner is MatchWinner.DRAW` rather than its own `Counter`. `display.format_outcome` renders the value as prose for `match list`'s `RESULT` column and `match show`'s header, naming the winning *player* rather than just their color (`WinLine.label` is the same idea one level down). It's the only thing that reads the score directly, and only for the `None` case: an undetermined match is shown as a standing ("alice leads 1-0, 1 undecided") rather than a win, so the CLI can't end up calling a match for someone the property itself declined to.
67
+ - How many games a match consists of is exposed as `VideoExtraction.num_games`, but it is a **property** (`len(self.games)`), not a stored dataclass field — the games list is the only place that count lives, so the two can't drift apart the way a persisted copy could when a contributor hand-edits a JSON file to add or drop a game. It's still *written* in both persisted forms — a `num_games` key sitting directly above the JSON's `games` list (with `red_score`/`blue_score`/`winner` next to it, same deal), and a denormalized non-null `videos.num_games` DB column (so "how many games did this match go?" is answerable off `videos` alone, no join and `GROUP BY` against `games`; `videos.red_score`/`blue_score`/`winner` join it, the last nullable since an undetermined outcome has nothing to name) — but none of them is ever *read* back: `json_export.read_video` ignores those keys entirely, exactly like a pre-`GAME N`-removal file's per-game `label`, and re-derives the count from the games actually present. That also means a JSON file predating the field needs no special handling on read (unlike `duration_s`/`win_line`, which are genuinely unrecoverable when absent), and that `load-db` backfills `videos.num_games` correctly from such a file. `db.py` carries the usual `ALTER TABLE videos ADD COLUMN IF NOT EXISTS num_games INTEGER` for pre-existing database files — nullable there, since an `ALTER` can't invent a value for rows already written, unlike the `NOT NULL` in the `CREATE TABLE`. `display.render_match_table`/`display.render_match` and `validation._check_game_count` all read the property rather than calling `len()` themselves, so the count they print/check is the same one that's persisted.
68
+ - Reflecting that JSON into DuckDB is a separate, optional step: the `load-db` CLI command (`db.load_json_dir`) reads every `*.json` file under `<data_dir>/matches`, including its season subdirectories (as written by `write_video`, see `json_export.video_path`), and calls `db.write_extraction` once per file — no cross-file grouping needed, since one file already is one video. `json_export.read_video` is the inverse of `write_video`. Since `write_extraction` can now be called from JSON alone with no source video file in hand, `video_info` (resolution/fps) is optional and stored as DB `NULL` in that case. This split (extract → JSON always; JSON → DB only on request) means the JSON file, not the database, is the source of truth: a contributor's manual correction to a JSON file is what `load-db` re-reflects, and the database is disposable/regeneratable from it at any time. The three pre-existing files that used to live directly under `matches/` predate this one-file-per-video structure (they're one-file-per-game) and won't load — re-run `extract` on those videos to regenerate them in the current format.
69
+ - Every command that reads or writes extracted matches takes a single `--data-dir` (default `data`, issue #73), not a bare directory of match JSON — `data_dir` is a root a contributor can also point other entities at, alongside `matches`: `db.load_json_dir`, `cli._read_matches` and `cli._match_path` all walk `<data_dir>/matches` specifically (via a shared `cli._matches_dir`/`json_export.video_path`), rather than `data_dir` itself, precisely so that another entity living under the same root — currently just `<data_dir>/squares/`, see below — never gets mistaken for a match file during that walk (`db.load_json_dir` has no per-file tolerance for a bad parse the way `cli._read_matches` does, so a stray non-match `*.json` reaching it would be a hard crash, not a skip-with-warning). The whole of `data/` is gitignored — every entity under it (`matches/`, `squares/`) is regenerable output over locally-run `extract`/`square consolidate`, not something to commit (issue #75; before that, `data/squares/squares.json` was a hand-maintained exception, see below).
70
+ - `extract`'s `video_path_or_url` argument accepts either a local file (as before) or a youtube.com/youtu.be URL, so a match can be downloaded and extracted in one command instead of two — this is why it's typed `str`, not `Path`, at the CLI level: `Path("https://...")` silently collapses the double slash after the scheme (`Path("https://x").`→`"https:/x"`), so converting it before checking whether it's a URL would corrupt it. `cli._is_youtube_url` (the same check used for `--video-url`) decides which case applies. When it's a URL: it's downloaded via `download_video` into `--download-dir` first, extraction runs against the downloaded file, and that file is deleted afterward *only* if extraction succeeded — this cleanup logic explicitly tracks whether *this run* downloaded the file (`downloaded_path`), so a locally-supplied video is never touched regardless of outcome. If extraction raises, the user is asked (`typer.confirm`) whether to keep the downloaded video before the original exception is re-raised unchanged (a bare `raise`, not wrapped) — so a failure still surfaces with its real traceback, just with one extra prompt first. `--keep-video` skips deletion outright on both paths (including the failure-path confirm prompt, not just the success-path delete) for a contributor who wants the footage kept around regardless of outcome — e.g. to re-run extraction locally after a calibration fix rather than re-downloading. As a bonus consequence of accepting a URL here, if `--video-url` (the provenance field stored in the JSON) wasn't separately given and `video_path_or_url` itself was a YouTube URL, that URL is reused for it too — no reason to make a contributor paste the same link twice.
71
+ - The overlay itself literally prints the answer for each game's type: a small "BASE GAME"/"DLC" subtitle directly under the "GAME N" label. `game_type_label.py` OCRs that box (`layout.GAME_TYPE_BOX`) and is `extract_video`'s first choice for `GameResult.game_type` — it's read off the same frames already grabbed for square-text majority voting (`_SQUARE_TEXT_FRAME_COUNT`, no extra decode cost), majority-voted the same way for the same reason (a single frame's OCR can misread, or land on a transition moment where the subtitle isn't rendered at all). `game_type_label.parse_game_type_label` only accepts an exact "BASE GAME"/"DLC" match, returning `None` for anything else, including a misread — guessing wrong here would silently mislabel a game, so a `None` just means "no vote", not "guess". This small a subtitle is sensitive to source bitrate: it reads reliably off full-quality downloaded match footage, but not off this repo's intentionally recompressed test fixtures (`hud_start.png`/`hud_partial.png`/`clip_claim.mp4`) — which is why those fixtures still exercise the `squares.py`/`on_missing_game_type` fallback path in tests, never the direct read.
72
+ - Each game is either a **base game** or **DLC** (Shadow of the Erdtree) game, per its goal squares — `squares.py` is now only `extract_video`'s fallback for `game_type`, used when the direct overlay read above comes back `None` for every sampled frame. It infers this from `<data_dir>/squares/base_game.json` and `<data_dir>/squares/dlc.json` (`squares.load_known_squares`, via `storage.json_export.read_squares`) — the consolidated, per-game-type reference `pipeline/consolidate.py` builds from every already-extracted match (issue #71), flattened into a single text->`GameType` map. There's no programmatic way to know a square's game type otherwise, so the reference only knows what previously extracted matches have already resolved (whether from the overlay's own subtitle or a user's prompted answer, see below) and rolled in via `square consolidate`. This used to be a single, hand-maintained `<data_dir>/squares/squares.json` file (and, before that, bundled inside the installed package as `scadustats/pipeline/squares.json`); it was retired once `consolidate.py` started deriving the same knowledge automatically from match history, making a separately hand-maintained file redundant (issue #75). Unlike that file, `base_game.json`/`dlc.json` are wholly regenerated by `square consolidate` from local match history each run and gitignored rather than committed (the whole of `data/` is, see above) — so a data directory that's never had `square consolidate` run against it (a fresh checkout, or one that's never run `extract`) still **ships empty** the same way the old hand-maintained file did when starting out, meaning every game fails to infer until someone runs it, rather than crashing extraction outright. `squares.infer_game_type` returns `None` (not a guess) when none of a board's 25 squares are in the reference; if the matched squares disagree (which shouldn't happen — a board's squares come from one pool, not a mix), the majority wins and a warning is logged rather than failing outright. `extract_video` calls this per game *during* the main per-game loop (cheap, no I/O), but only resolves a `None` result — via the `on_missing_game_type: Callable[[GameResult], GameType] | None` callback, or a `ValueError` if none was given — in a second pass *after* `match_metadata` is awaited, not inline: both can ultimately require interactive prompting (`cli._prompt_game_type`, always asked per game — there used to be a `--game-type` flag to answer it once for every game in a video, removed in issue #62, since a video is expected to hold more than one game and a real match's first two games are base-then-DLC, so one answer for the whole video was wrong as often as it was right), and resolving game type first would mean two prompt sequences racing each other on the terminal at once. `GameResult.game_type` defaults to `None` in the dataclass (not enforced non-`None` by the type system) since a hand-edited JSON file can legitimately clear it back to unknown — `db.py`'s `games.game_type` column is nullable for the same reason, unlike the video-level fields in `write_extraction` that `VideoExtraction` always guarantees.
73
+ - `pipeline/consolidate.py` builds that reference — `<data_dir>/squares/base_game.json` and `<data_dir>/squares/dlc.json`, one entry per distinct square text with a short `id` and its `game_type` — from every already-extracted match. It's a one-shot bootstrap/refresh (`square consolidate`), not something the extraction pipeline calls itself. The only operation the module actually performs is `consolidate_match_squares` (issue #76): given one already-extracted match's own OCR'd `square_texts` and a reference built so far, it reconciles that match against it. A cell whose text is already an exact match in its game's pool needs nothing and isn't reported; a blank cell (a ragged, hand-edited grid) is skipped, and a game with no resolved `game_type` is skipped entirely — the same tolerances `squares.infer_game_type` already has for reading a grid. Everything else is one of two things: an OCR misread of a square the reference already knows, fuzzy-matched with stdlib `difflib` (`SequenceMatcher.ratio`, via `get_close_matches`) rather than pulling in a Levenshtein dependency — the project has none, and `difflib` needs nothing new — and corrected in place in the match's own JSON; or a square the reference has never seen at all, which is *added* to it with a freshly assigned id (`_slugify`/`_unique_id`: the text's first goal-identifying word, filler verbs/connectors like "Acquire"/"Kill"/"or" stripped, plus its first number if any — e.g. "Complete 3 Tunnels or Precipices" → `tunnels_3`; a collision, most often two different counts of the same item, is disambiguated with a plain numeric suffix rather than trying to pick a smarter second word) rather than left unresolved. `_best_match` never crosses game types: a `BASE` game's squares are only ever checked against (and, if new, added to) the `BASE` half of the reference, for the same "one pool per board" reason `squares.infer_game_type` keeps the two separate. A candidate is also rejected outright — falling through to the "add as new" path rather than being force-fit — if its goal-count digits disagree with the OCR'd text's own, however similar the surrounding wording scores: "Kill 3 Friendly NPCs" and "Kill 5 Friendly NPCs" can both be real, distinct squares, and text-similarity alone scores that pair at 0.976, comfortably above the cutoff a genuine OCR typo needs. `_FUZZY_MATCH_CUTOFF` (0.85) was picked by running the ratio against issue #76's own reported failures (a dropped letter, a stray leading token, a missing apostrophe, a swapped letter, a missing space — all ≥0.92) versus two real, distinct squares that happen to share most of their wording ("Kill a Crystalian" vs. "Kill a Crucible Knight", 0.513): there's a wide, easy margin between "OCR typo" and "different square" in this corpus, and 0.85 sits well inside it either way. `validate_squares` is a final, separate pass over each game type's list checking no `id` or `text` repeats — construction already can't produce either (ids are disambiguated as they're assigned; texts come from a dict keyed by text), so this is a regression backstop the issue asked for outright, not a check expected to ever fire. `storage.json_export.write_squares`/`read_squares` are the persistence half, alongside `write_video`/`read_video` — unlike `write_video`, `write_squares` always overwrites unconditionally and takes no `if_exists`, since the whole reference is regenerated from the current match history every run rather than incrementally built up; `read_squares` is what `squares.load_known_squares` calls to turn the two files back into `Square` lists, treating either missing file as an empty list rather than raising, the same tolerance `squares.py` has always had. `square consolidate` (`cli/app.py`) is its own top-level `square_app` sub-app rather than folded into `match_app`: it produces/overwrites a file the way `extract`/`load-db` do, unlike `match`'s read-only lookups. Like every other command that reads extracted matches (issue #73), it takes a single `--data-dir` (default `data`) rather than its own separate flags for the matches directory and the output directory.
74
+ - `square consolidate <match_id>` (issue #76) is `cli.app._consolidate_one_match` wrapping `consolidate_match_squares`: it reads the reference already on disk (`storage.json_export.read_squares`), reconciles just that one match against it, writes the corrected `VideoExtraction` back with `json_export.write_video(..., if_exists="replace")` only when at least one cell was actually fixed, and the grown reference with `json_export.write_squares` only when at least one square was actually added — a match needing neither leaves both files untouched rather than rewriting either to a byte-identical copy. Every newly added square is echoed at the end under whichever `base_game.json`/`dlc.json` it landed in (`SquareTextChange.game_type`), so it's clear at a glance what just grew the reference. `cli.app._square_consolidate_match(data_dir, match_id)` is a thin wrapper around this same lookup-and-reconcile path (look up the match, load the reference, call `_consolidate_one_match`) -- the entry point `match consolidate`/`extract --consolidate` (issue #79, below) need to reconcile one match_id's squares without going through the CLI command itself.
75
+ - `square consolidate` with no `match_id` (issue #86) is that same `_consolidate_one_match` helper called once per match under `data_dir` in turn — in the same chronological-by-filename order `cli._read_matches` already returns them in — seeded from whatever `storage.json_export.read_squares(squares_dir)` already holds on disk (empty if `squares_dir` doesn't exist yet), the same starting point the single-`match_id` form itself reads from. This is deliberately *not* a from-scratch rebuild that discards the existing reference first: the issue's own wording ("instead of overwriting all squares") is about exactly that -- the no-`match_id` form used to wipe and regenerate `base_game.json`/`dlc.json` from nothing on every run (see below), and this replaces that with the same incremental grow-or-fix operation `square consolidate <match_id>` already performs, just run across every match in one command instead of one. This also means a misread square anywhere in the match history gets corrected in that match's own JSON along the way, exactly as running `square consolidate <match_id>` on it individually would -- something the old implementation could never do, since it only ever produced the reference file and never touched a match's own JSON. The earlier implementation, `consolidate_squares`, instead took one flat majority vote across every match's `square_texts` at once (the same text seen under more than one game type was filed under whichever it was seen under most, with a warning logged); a typo just became its own extra vote for a barely-different text, indistinguishable at that point from a genuinely different square, so a match's own `square_texts` carried the same misread through every future rebuild no matter how many times the whole history was reconsolidated — only ever running `square consolidate <match_id>` by hand on the affected match could fix it. Running `consolidate_match_squares` over every match against a shared, on-disk-seeded reference instead means the reference and every match's own recorded squares stay in sync with each other and with disk however the command is invoked. The match order matters here in a way it never did for the vote-based approach: whichever spelling of a square is encountered first (already on disk, or the first match to introduce it) becomes the reference's canonical one, and every match after it is corrected/measured against that — not because a different order would produce a *wrong* result, but because a re-run over an unchanged match history should always reach the same one, which is why this reuses `_read_matches`' existing chronological ordering rather than whatever order a directory walk happens to return.
76
+ - `pipeline/consolidate.consolidate_players` (issue #72) builds the analogous per-player reference -- one `models.PlayerProfile` per player, covering every match they've appeared in -- for `player consolidate`, its own top-level `player_app` sub-app (like `square consolidate`, not folded into `match_app`: it produces/overwrites files, unlike `match`'s read-only lookups). Two OCR'd name strings are treated as the same player if they slugify the same (`slugify_name`: lowercased, any run of non-alphanumeric characters collapsed to one underscore, e.g. "TwistieT" -> "twistiet") -- a real casing/spacing difference between two readings of the same overlay is expected, two different players sharing a slug isn't. `display_name` is that player's most commonly seen exact spelling, the same majority-vote shape `overlay.commentators.majority_commentator_names` uses for its own static per-broadcast text reads, just voted across matches instead of across frames. A profile carries: a `MatchRecord` (wins/losses/draws) per season, read off `VideoExtraction.winner`/`.season` -- a match with no determinable winner contributes nothing, the same "don't guess" stance `VideoExtraction.winner` itself takes; a `WinLoss` (wins/losses, no draws -- a single game can't draw, see `WinType.TIE`'s note above) for individual games, both overall and split by `GameType`, read off each `GameResult.winner_color` compared against the player's own color in that match; `all_matches`, every `match_id` they appear in ordered by `match_date` descending; and `top_squares_base_game`/`top_squares_dlc`, the 5 squares (as `models.SquareMarks`: `text` plus `marks`, its count) they've personally `MARK`ed most often in that game type (ranked by count, then text, for the same stable-diffable-output reason `storage.json_export.write_squares` sorts by id), counting only `EventType.MARK` events whose `color` is their own -- an opponent's claim on the same board, or a later `UNMARK`, isn't this player's own mark. A game with no `winner_color` contributes nothing to the win/loss tallies, and one with no resolved `game_type` still counts toward the overall `WinLoss` but not the per-type breakdown or top-squares tally (nothing to file it under) -- the same tolerance `consolidate_match_squares` already has for an unresolved `game_type`. Unlike a from-scratch `square consolidate` rebuild, this isn't a from-scratch-only rebuild: `slug`/`display_name`/every tallied field really is wholly regenerated each run, but `id` -- assigned once, the first time a player is seen, then kept stable -- and `twitch`/`avatar`/`bio` -- free-text fields the tool never sets at all -- have to survive a re-run, so `consolidate_players` takes the previous run's profiles (`existing: dict[str, PlayerProfile] | None`, `storage.player_export.read_players`' output) and carries those four fields forward by slug; a slug not already in `existing` is a new player, assigned the next id after whatever's already in use. `twitch` holds just the handle (e.g. "blanxz"), not the full `https://twitch.tv/...` URL -- `storage.player_export._twitch_handle` recovers the handle from a legacy `twitch_url` field (via `urlparse(...).path`) so a handle already filled in by hand under that old field name survives the rename rather than being silently dropped on the first re-run after it. `storage/player_export.py` is a new `storage/` module rather than folding into `json_export.py`: a player file is YAML, not JSON, specifically so the hand-written `bio` field can be an ordinary multi-line block instead of an escaped JSON string -- `write_player` uses a `yaml.SafeDumper` subclass with its own string representer so a multi-line value round-trips as a literal block (`|`) rather than PyYAML's default quoted-with-`\n` scalar, which would otherwise flatten a contributor's own block-style edit back into a much less readable form on every re-run. `read_players` (keyed by slug, empty for a `players/` directory that doesn't exist yet -- the same "ships empty" tolerance `read_squares` has) is what supplies `consolidate_players`' `existing` argument. `player consolidate` also takes an optional `match_id`, the same shape `square consolidate` already had (issue #76): given one, it writes just the two players who played that match rather than every profile under `data_dir`. Unlike `square consolidate`'s scoped path, this doesn't save any real work -- a player's win/loss record and top squares are tallied across their *whole* match history, not just one match, so `cli.app._player_consolidate_match` still reads every match under `data_dir` and runs the full `consolidate_players` computation; only which files get *written* at the end is narrowed. `pipeline.consolidate.slugify_name` was made public (dropping its leading underscore) for this, since `_player_consolidate_match` needs it too, to know which of `consolidate_players`' output profiles -- keyed by slug -- belong to the match's two players.
77
+ - `match consolidate <match_id>` (issue #79) is, for now, exactly `square consolidate <match_id>` followed by `player consolidate <match_id>` -- a `match_app` sub-command since it's genuinely both operations at once, not filed under either `square_app` or `player_app`. `cli.app._square_consolidate_match`/`_player_consolidate_match` are the match_id-scoped bodies of `square consolidate`/`player consolidate` themselves, factored out so both the plain commands and `match consolidate` share one reporting implementation rather than three copies of it. Running the square step first is deliberate: it can rewrite the match's own `square_texts` in place (an OCR misread corrected against the reference), and the player step re-reads that JSON file off disk afterward (see `_read_matches`), so a corrected goal text -- not the pre-correction OCR reading -- is what ends up in either player's top-squares tally. `extract --consolidate` runs this same `match consolidate` step on the match it just wrote, but only when `rules.validation.validate_extraction` finds nothing wrong with it first (the same check `match show`'s "validation" section runs) -- consolidating a match with a real issue would feed the squares/player references data that's probably an OCR misread or a missed game boundary, which is exactly the kind of mistake `square consolidate`'s fuzzy-matching and `player consolidate`'s majority-voting can't tell apart from a genuine new square or record once it's in there. It's checked, not skipped outright, so a clean extraction still gets the convenience of not needing a separate `match consolidate` call; an unclean one prints a note explaining why it didn't run rather than silently doing nothing. Consolidation is skipped the same way when the extraction itself was skipped (a declined duplicate) -- there's nothing new on disk yet to reconcile against the reference.
78
+ - The CLI shows a progress bar (`typer.progressbar`) for `extract`, since a full video can take minutes to process at ~1 sample/second. `extract._collect_observations` accepts an `on_progress` callback fired once per sampled frame (including frames later skipped as non-gameplay, since decode cost is paid either way), and `extract.estimate_sample_count` gives the CLI an upfront total (`ceil(duration_s * sample_rate_hz)`, an estimate — exact count depends on where decoding stops near the end) to size the bar before extraction starts.
79
+ - `frames.sample_frames` decodes sequentially with `cap.grab()`/`cap.retrieve()` rather than seeking to each target timestamp with `CAP_PROP_POS_MSEC` — profiling showed the seek-per-sample pattern cost ~2.5x more than sequential grab+skip on a real clip, since `CAP_PROP_POS_MSEC` forces a jump back to the nearest preceding keyframe and a forward decode to the target on *every single sample*. This was a genuinely free win (same yielded timestamps/frames, no behavior change) — but it's a minority of extraction's total runtime: profiling the full pipeline on the committed fixture showed OCR at ~80-90% of wall time (true of both OCR backends this project has used, see below), with the per-sample `timer.read_timer` OCR call alone responsible for roughly half of it. It is now the *only* per-sample OCR call: the game-label read that used to run alongside it (throttled to every 5th sample) is gone with the label itself.
80
+ - OCR calls that don't depend on each other run concurrently via a `ThreadPoolExecutor`. Two call sites: `extract._collect_observations` submits each sample's timer OCR to a shared pool bounded by `_OCR_WORKERS` (`min(8, os.cpu_count())`), gated by a `threading.Semaphore` acquired before every `submit()` — without that bound, decoding (now fast, see above) would race far ahead of OCR and queue the entire video's frames in memory before any of them finished processing; results are still collected in original sample order afterward, so downstream ordering (segmentation, event diffing) is unaffected. `board.cell_square_texts` parallelizes its 25 independent per-cell reads the same way, but as one unbounded one-shot batch (`max_workers=len(crops)`) since it only runs once per detected game, not once per video sample. Neither call site changes *what* gets OCR'd or how often — only wall-clock time — so there's no accuracy tradeoff here, unlike throttling the timer read would be. Concurrency here is worth less than it used to be: `ocr.py` now runs Tesseract in-process (see below), and a real A/B on this same call site measured only ~1.4x from 8-way concurrency, since the engine only releases the GIL for part of its C++ work rather than a subprocess's full blocking wait — but it's still a net positive with no accuracy cost, so it stays as-is for a now-smaller win.
81
+ - `overlay/ocr.py` binds directly to Tesseract's C++ engine via `tesserocr` instead of shelling out to the `tesseract` CLI per call, the way it originally did — benchmarking found spawn+re-init (reloading language data from disk) accounted for ~97% of a call's ~58ms on a real crop, with actual recognition only ~3%, so a persistent engine paying that init cost once had a very high ceiling. A real A/B on `timer.read_timer`'s crop across every sampled frame of `clip_claim.mp4` confirmed it: identical readings on all 13 frames, ~11.6x faster per call (57.5ms → 4.9ms median), and the full `extract_video` pipeline on that fixture dropped from a ~7.7s to a ~3.0s median (3 runs each) end to end. `ocr._engine` keeps one `tesserocr.PyTessBaseAPI` per thread in a `threading.local()`, created lazily on first use and reused for every later call on that thread, rather than one shared instance — `PyTessBaseAPI` isn't thread-safe (tesserocr's own docs recommend exactly this thread-local pattern), and the whole point of the switch is not re-initializing per call, which a shared-instance-plus-lock would still do serially. Because the engine persists across calls on a thread, every call now sets (or explicitly clears to `""`) the character whitelist rather than only setting it when one is given — otherwise a whitelist from an earlier call on that thread would silently leak into a later one that never asked for it (verified empirically: a cleared-to-`""` whitelist reads normal unrestricted text, not nothing). `ocr._resolve_tessdata_prefix` exists because a Python process embedding libtesseract has no way to find the language-data directory the way the `tesseract` CLI binary does (it locates its own tessdata relative to `argv[0]`, which has nothing to anchor to from a `python3` process) — confirmed empirically as `tesserocr.get_languages()` silently coming back empty, having searched `./`, on a real Homebrew install with `TESSDATA_PREFIX` unset. It honors `TESSDATA_PREFIX` if the environment already sets it, otherwise resolves the `tesseract` binary via `shutil.which` and checks the tessdata locations both a Homebrew install (`<prefix>/share/tessdata`) and a Debian/Ubuntu one (`<prefix>/share/tesseract-ocr/<version>/tessdata`, matching what CI's `apt install tesseract-ocr` lays out) actually use, rather than guessing a single hardcoded path. The `tesserocr` wheel installed on this project's supported platforms (macOS and manylinux, both x86_64/arm64, per its published PyPI files) bundles its own compiled Tesseract/Leptonica rather than dynamically linking the system's — confirmed on a real machine, where it reported engine 5.5.1 against a Homebrew `tesseract` CLI reporting 5.5.3. This is harmless for accuracy: a controlled comparison across engine versions and the old vs. new OCR backend produced byte-identical square-text OCR output (mismatches included) in every combination, so this version difference isn't something to chase if a future recognition-quality question comes up here.
82
+ - Square (goal) text is read from `_SQUARE_TEXT_FRAME_COUNT` (10) frames spread evenly across a game's segment, not a single frame — a single frame's OCR occasionally misreads a cell (motion blur, a transient compression artifact), and unlike cell color/claims there's no debounce available here since square text is static for the whole game, so `extract._select_square_text_observations` picks up to 10 evenly-spaced observations from the segment, `extract._grab_frames` decodes each (sharing one `VideoCapture` rather than reopening the file per frame), and `board.cell_square_texts_majority` OCRs all 25 cells on every one of those frames and keeps, per cell, whichever exact text string was read most often. This only runs once per detected game, so the ~10x OCR cost over the old single-frame read is a one-time-per-game expense, not a per-sample one.
83
+ - `tests/fixtures/clip_claim.mp4` is a short (13s), re-encoded, full-resolution clip trimmed from a real match, covering exactly one known claim transition — downscaling it for size once broke goal-text OCR (crops became too small to read reliably), so it's kept at source resolution instead and just trimmed/re-encoded to stay small.
84
+ - Every committed fixture (`hud_start.png`, `hud_partial.png`, `hud_commentators.png`, `clip_claim.mp4`, `clip_base_game.mp4`, `clip_dlc_game.mp4`, `clip_game_boundary.mp4`) has the webcam/face regions blacked out (players' and commentators' likenesses aren't needed for testing the overlay-reading logic) — done by zeroing everything outside the grid column and score-bar strip except the timer, game-type and commentator-nameplate boxes, which sit inside those columns and are still needed (a nameplate is printed text, not a likeness, so keeping it doesn't reintroduce what the redaction is for). (Fixtures committed before the "GAME N" label was dropped as a signal still show it — they were generated while `GAME_LABEL_BOX` was in the restored set, and there's no reason to regenerate them over it. Likewise, `hud_start.png`/`hud_partial.png` predate the commentator boxes being added to the restored set and so read back as no commentators found — see `commentators.py` — rather than being regenerated just for this.) `hud_commentators.png` is a dedicated fixture for `commentators.py`'s real-OCR path, the same reason `clip_base_game.mp4`/`clip_dlc_game.mp4` exist for `game_type_label.py`. `scripts/redact_fixture.py` is the tool that does this (and trims, for a clip); it derives the kept regions from `layout.py`'s boxes rather than hand-measured pixels, so recalibrating the template keeps fixture generation correct for free. Always run new footage through it rather than committing raw frames.
85
+ - `clip_base_game.mp4` and `clip_dlc_game.mp4` are 5s (16-frame) clips trimmed from the base-game and DLC halves of one real match, and exist specifically to cover `game_type_label.py`'s real OCR path — the older fixtures can't, since their recompression destroys the subtitle (see above). They're deliberately encoded at a much higher quality (`redact_fixture.py` uses CRF 16) for that reason; a redacted frame is overwhelmingly black, so this still costs only ~390KB each. Both are also short enough to run the *whole* `extract_video` pipeline on, which is what `test_extract_video_reads_game_type_from_the_overlay_subtitle` does — end-to-end, with no known squares and no prompt callback. Note `test_extract_video_raises_without_a_way_to_resolve_game_type` depends on the *opposite* property of `clip_claim.mp4` (its subtitle not OCR'ing), so re-encoding that fixture at higher quality would break it.
86
+ - `clip_game_boundary.mp4` is a 28s clip trimmed around a real *game boundary* — the tail of game 1 (claimed board, stopped clock), the splash between the two games, then game 2's empty board and pre-game countdown — cut from the same 3fps source as the two above, so it's the same ~450KB. It's the only fixture with more than one game in it, and exists to cover segmentation end-to-end (`test_extract_video_splits_a_clip_spanning_two_games`): every earlier fixture is single-game, which is why a bug that collapsed every video into one game could ship green. Both of segmentation's board-reset and timer-reset signals fire inside it; 28s is about the shortest window that holds enough of both games for that.
87
+ - Every Typer group in `cli/app.py` (the top-level `app` and the `match_app` sub-app) is constructed with `no_args_is_help=True`, so a bare `scadustats` or `scadustats match` prints that group's full command list instead of Typer's default `Missing command` error, which names none of the commands it's complaining about. The exit code stays Click's usage-error 2 — no command was given, that just gets a useful answer now, unlike an explicit `--help`, which is a successful 0.
88
+ - `match list`, `match show` and `match validate` (`cli/app.py`) are sub-commands of a `match_app` Typer sub-app (`app.add_typer(match_app, name="match")`), grouped together since all are read-only lookups over already-extracted matches, as opposed to `extract`/`load-db` which produce/mutate them. All read JSON files directly via `json_export.read_video`, not the database — consistent with the JSON being this project's source of truth (see above), they work whether or not `load-db` has ever been run, and all three take the directory the same way, as `--data-dir` (`match list` used to take it as a positional argument instead, which meant the one command you'd reach for first was also the one spelled differently from its siblings; it was also `--json-dir` before the `data/matches` restructure, issue #73). `cli._read_matches` is the shared walk of that directory for the two commands that take no `match_id`: it globs `<data_dir>/matches/**/*.json` (every season subdirectory — see `json_export.video_path`), not `<data_dir>/**/*.json`, so another entity data_dir may hold (currently just `<data_dir>/squares/`) is never picked up as if it were a match file, and skips a file that fails to parse as a current-format extraction — e.g. one of the pre-existing one-file-per-game files that predate the one-file-per-video format — with a warning on stderr (`KeyError`/`ValueError`, which also covers `json.JSONDecodeError`, a `ValueError` subclass) rather than aborting the whole run. Sorting is by filename alone, not the full path or an explicit sort-by-date: a filename is `<match_id>.json` and `match_id` starts with the match date (see `extract._match_id`), so filename order already is chronological order for free — sorting by the full path instead would sort first by season subdirectory name, and a season is free text rather than a sortable number (`"10"` sorts before `"6"`, and a season needn't even be numbered at all), so that would put a later season's matches ahead of an earlier one's. `cli._match_path` is the other shared piece — `match show`/`match validate` both turn a `match_id` into a path by searching every season subdirectory for `<match_id>.json` (a match_id doesn't say which season it's filed under on its own), and a missing file is a `typer.BadParameter` (a mistyped id is a user mistake, not a traceback).
89
+ - Everything those commands print is rendered by `display.py`, which cli/app.py only echoes: every function there takes models and returns lines, printing nothing, so the formatting is testable without a `CliRunner` (`tests/cli/test_display.py`) and cli/app.py stays about flags and prompts. Nothing in it interprets an extraction — a game's result is shown exactly as the JSON records it, never recomputed from its events, since `match validate` is where the two are cross-checked and a silently "corrected" display would hide the very disagreement that command exists to surface. `match list` renders a padded table (`render_table`, shared with the `--events` listing) of `MATCH / SEASON / TYPE / GAMES / RESULT`: the match date and both player names are deliberately *not* their own columns, since `match_id` already spells out `<date>-<red>-vs-<blue>` and repeating them is what crowded out the one thing the old ragged one-line-per-match listing didn't say at all — how each match ended. That result (`format_outcome`) says "leads" rather than "wins" while any game has no winner recorded, so a score that only looks final isn't presented as one: an undecided game is usually an extraction problem. `match show` prints, per game, the recorded result in prose plus the **final board** — replayed from the game's own events by `winner.replay`, with the winning line's cells bracketed (`render_board` renders every cell three characters wide, bracketed or not, so the grid stays square and a completed line is visible as a shape). Showing the replayed board next to the recorded result is deliberate: that's exactly the pair `validation._check_recorded_winner_matches_board` compares, so a mismatch is visible where it happened. `--events` (off by default — 25+ rows per game buries the summary) adds a per-game table of every event with its video timestamp, game clock, player, square and goal text, ordered by `video_ts_s` for the same reason `validation._ordered_events` is: the clock counts *down* through the pre-game countdown, so ordering by it would file `GAME_START` in among the claims. Goal texts are truncated to keep rows scannable (the JSON has the full text), and a `square_texts` grid too short to index — OCR output, hand-editable — yields `""` rather than taking down a read-only listing.
90
+ - `winner.replay`/`winner.apply_event`/`winner.empty_board`/`winner.board_states` are the one event-replay implementation, shared by `extract._determine_winner` (final board only, via `replay`), `extract._detect_game_end` and `validation.validate_game` (every intermediate state, via `board_states`, for locating when a result settled) and `display.render_game` (final board, to draw it). They live in `winner.py` rather than `board.py` or `extract.py`/`validation.py` because they're pure board logic with no frame/OCR/cv2 involvement, next to the win conditions evaluated over the same board — `board_states` and `winner.settled_result_index` (which of those states is the earliest still agreeing with a given `(color, win_type)` through to the end) used to be private, single-purpose copies inside `validation.py` until `extract._detect_game_end` needed the identical logic for a second, non-LINE-only case. `apply_event` is a no-op for an event carrying no `row`/`col` (`GAME_START`/`GAME_END`), so replaying a game means walking its whole event list rather than a filtered copy of it.
91
+ - `validation.py` checks an extracted match against the tournament's own rules, surfaced as `match validate` (one `match_id`, or every file under `data_dir` when omitted — same skip-with-a-warning tolerance as `match list`, and a non-zero exit when anything was found, so a batch of extractions can be gated on it). It's a **review aid, not a gate on extraction**: nothing in the pipeline calls it, and a failing rule doesn't mean the video was bad — it means the JSON says something the rules say can't happen, so an OCR misread or a missed game boundary probably slipped through and a contributor should look at that file. Checks are therefore written to point at *what* looks wrong, never to guess a correction. `validate_extraction` returns a flat `list[ValidationIssue]` (`code`, `message`, `game_index`) rather than raising or returning a bool, since one file can violate several rules at once and all of them are worth printing; `code` is a stable greppable rule id, `game_index` is `None` for a match-level rule *including* one whose message names a game (an undetermined match outcome is a defect of the match, not of the game that lacked a winner). Match-level rules (game count per match type, base-then-DLC for the first two games, a determinable outcome, a double-elimination decider present exactly when the first two games split) run before the per-game ones, in game order. Two deliberate non-cascades: an undecided game suppresses the decider/draw rules (with the score unknown they'd be guesses piled on top of the one real problem), and "no marks after the win" is skipped when the recorded winner already disagrees with the board. That last rule also only applies to a **line** win, and locates the win by walking the replayed board states *backwards* from the end: a line ends the game the moment it completes, but a line can also be completed and immediately undone (see `extract._extract_events`), so only the win that persists to the end counts. A **majority** win is exempt entirely: it's decided the instant a lead becomes mathematically unbeatable (see `winner._majority_settled`), and unlike a LINE win that moment isn't a structural stopping point — the board needn't be full and no line need be blocked, so there's no guarantee play couldn't continue past it the way there is once a line completes. In every majority game checked against this rule so far the settling mark was in fact the last event recorded (the broadcast appears to call the game the moment it happens), but that's an observation about this corpus, not something the rule can rely on, so majority stays exempt rather than risk flagging a legitimate later claim as a defect. Replay order is by `video_ts_s`, not `game_elapsed_s`, since the latter counts *down* during the pre-game countdown and would misplace `GAME_START` among the claims. `win_line` is checked as part of `winner_board_mismatch` rather than as a rule of its own (a win recorded on a line the board doesn't hold is the same defect as one recorded for the wrong color), with one exception: a result that agrees with the board in every respect *except* that `win_line` is absent gets its own `win_line_not_recorded` code, since that's a JSON file predating the field rather than the board contradicting the result — every match extracted before it reports this until re-extracted. `match_validate` itself (unlike `match list`/`match show`) builds its own output directly in `cli/app.py` rather than through `display.py`, since it's a diagnostic report rather than a presentation of the JSON — each match's line is colored green "ok" or red "N issues", and each issue below it names its scope in cyan and its rule `code` dimmed, via `typer.style`/`typer.colors`. No `--no-color` flag is needed for a script piping the output: `typer.echo` (via click) already strips the ANSI codes whenever the destination isn't a terminal, which is also why `tests/cli/test_app.py`'s validate tests can assert on the plain text through `CliRunner` — only `test_validate_output_is_colorized_when_color_is_supported` forces `color=True` to check the codes are actually emitted. This per-match rendering (one "ok"/"N issues" line plus its issue list) is factored out as `cli.app._echo_validation`, since `match show` also runs it — on just the one match it printed, appended after its own report — so a contributor reading a match doesn't have to separately run `match validate` to know whether that match holds up (issue #57). `match show` always prints the report regardless of what validation finds, and always exits 0: unlike `match validate`, showing one match isn't a gate any batch of extractions is checked against, so there's nothing to fail *for*. The validation section is preceded by its own bold `"validation"` label, and `display.py`'s other `match show` sections got matching bold headers at the same time — the `match_id` line, each `"Game N"` line, and a `"event log"` label ahead of the `--events` table — so the report reads as one set of consistently-marked sections rather than singling validation out. This is the one place `display.py` imports `typer`: only for `typer.style(..., bold=True)` wrapped around an otherwise-unchanged line, which is still just string formatting (no I/O, no interpretation of the extraction), so the module's "returns lines, prints nothing, testable without a CliRunner" contract holds — a test that asserts on a styled line's plain-text substring still passes, since `style()` wraps a line's text without splitting it, and `typer.echo` strips the codes automatically outside a terminal, same as `match_validate`'s coloring.
92
+
93
+ ## Testing
94
+
95
+ - Test runner: `pytest` (dev dependency, declared under `[dependency-groups] dev` in `pyproject.toml`).
96
+ - Tests that hit real external services (currently: downloading an actual YouTube video) are marked `@pytest.mark.integration`.
97
+ - `[tool.pytest.ini_options] addopts = "-m 'not integration'"` excludes integration tests by default, so a plain `uv run pytest` (or local dev loop) stays fast and offline.
98
+ - Run integration tests explicitly with `uv run pytest -m integration`.
99
+
100
+ ## CI
101
+
102
+ - GitHub Actions workflow at `.github/workflows/ci.yml`, triggered on push to `main` and on pull requests.
103
+ - Steps: install `ffmpeg`, `tesseract` (for its `tessdata/`, see above), and `tesserocr`'s from-source build dependencies (`libtesseract-dev`, `libleptonica-dev`, `pkg-config`) as a defensive fallback in case `uv sync` can't find a matching prebuilt `tesserocr` wheel for the runner's platform/Python version, install `uv` (`astral-sh/setup-uv`), `uv sync --locked`, `uv build`, `uv run pytest` (unit tests, default marker filter applies), then `uv run pytest -m integration --deselect tests/video/test_download.py::test_download_video`.
104
+ - Only `tests/video/test_download.py::test_download_video` is excluded from CI: it hits real YouTube via `yt-dlp` to download a video, and GitHub Actions runner IPs get throttled/bot-detected by YouTube badly enough to cause CI timeouts. Every other test marked `integration` (currently just `test_square_text_majority_vote_reads_all_cells_from_real_clip`, which only decodes a committed fixture clip — no network involved) runs in CI as its own step, deselecting just that one test. Run `test_download_video` locally instead (see Testing section).
105
+ - Both `ci.yml` and `release.yml` (see Versioning and releases below) check out with `fetch-depth: 0` rather than the default shallow clone — `hatch-vcs` needs the actual git tag history to derive a version, which a shallow clone doesn't carry.
106
+
107
+ ## Versioning and releases
108
+
109
+ - The project follows [Semantic Versioning](https://semver.org/): `MAJOR.MINOR.PATCH`, incrementing MAJOR for a breaking CLI/JSON-schema/DB-schema change, MINOR for a backwards-compatible feature (a new command, a new field), and PATCH for a backwards-compatible fix.
110
+ - The package has no hand-maintained version number: `pyproject.toml` declares `dynamic = ["version"]` and `[tool.hatch.version] source = "vcs"` (via the `hatch-vcs` build-time dependency), so the version is derived from the nearest git tag (`git describe`) at build/install time — `uv sync`/`uv build` on an untagged commit produces a `0.1.devN+g<hash>` development version rather than erroring, so day-to-day work is unaffected by there being no tag yet. `scadustats --version` (`cli.app._version_callback`) reports whatever version this resolved to, read back via `importlib.metadata.version("scadustats")` rather than hand-imported from anywhere, so it always matches what was actually installed.
111
+ - A release is cut by pushing an **annotated** tag matching `vMAJOR.MINOR.PATCH` (the `v` prefix is required — matched literally by `release.yml`'s trigger, and stripped automatically by `hatch-vcs` when it reads the tag) on a commit on `main`, e.g.:
112
+ ```
113
+ git checkout main && git pull
114
+ git tag -a v1.2.3 -m "v1.2.3"
115
+ git push origin v1.2.3
116
+ ```
117
+ That push triggers `.github/workflows/release.yml`, which re-runs the unit test suite, builds the wheel/sdist (now reporting that exact tagged version), and publishes them as a GitHub release via `softprops/action-gh-release`, with `generate_release_notes: true` producing the release body from merged PRs since the previous tag rather than a hand-maintained `CHANGELOG.md`.
118
+ - Untag/delete-and-repush is the escape hatch for a mistaken tag (wrong version number, tagged the wrong commit) — there's no in-place "retag" since the workflow reads the tag once, at push time.
119
+ - The same workflow also publishes the built wheel/sdist to PyPI, in a separate `publish-pypi` job that `needs: release` and runs after the GitHub release is published. It uses [PyPI trusted publishing](https://docs.pypi.org/trusted-publishers/) (`pypa/gh-action-pypi-publish`, OIDC-based) rather than a long-lived API token stored as a repo secret — this requires a one-time setup on PyPI itself (Publishing → add a GitHub Actions publisher naming this repo, the `release.yml` workflow filename, and a `pypi` environment name) before the first automated publish can succeed; PyPI's "pending publisher" flow supports doing this before the project has ever been published. The job runs under a `pypi` GitHub Actions environment (`environment: pypi`) matching that configuration, with `permissions: id-token: write` for the OIDC token exchange. The `release` job uploads its `dist/` output as a build artifact (`actions/upload-artifact`) so `publish-pypi` can download the exact same wheel/sdist (`actions/download-artifact`) that was just tested and attached to the GitHub release, rather than rebuilding it a second time.
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Adriano Oliveira
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,112 @@
1
+ Metadata-Version: 2.5
2
+ Name: scadustats
3
+ Version: 1.0.1
4
+ Summary: Extract match data and statistics from Elden Ring Bingo Brawlers match videos
5
+ Project-URL: Repository, https://github.com/adri0/scadustats
6
+ Project-URL: Issues, https://github.com/adri0/scadustats/issues
7
+ Author: Adriano Oliveira
8
+ License-Expression: MIT
9
+ License-File: LICENSE
10
+ Classifier: Development Status :: 3 - Alpha
11
+ Classifier: Environment :: Console
12
+ Classifier: Intended Audience :: End Users/Desktop
13
+ Classifier: Operating System :: OS Independent
14
+ Classifier: Programming Language :: Python :: 3
15
+ Classifier: Programming Language :: Python :: 3.13
16
+ Classifier: Programming Language :: Python :: 3.14
17
+ Classifier: Topic :: Games/Entertainment
18
+ Classifier: Topic :: Multimedia :: Video
19
+ Requires-Python: >=3.13
20
+ Requires-Dist: duckdb>=1.1
21
+ Requires-Dist: numpy>=2.0
22
+ Requires-Dist: opencv-python>=4.10
23
+ Requires-Dist: pillow>=10.0
24
+ Requires-Dist: pyyaml>=6.0.3
25
+ Requires-Dist: tesserocr>=2.7
26
+ Requires-Dist: typer>=0.27.2
27
+ Requires-Dist: yt-dlp>=2026.8
28
+ Provides-Extra: ejs
29
+ Requires-Dist: yt-dlp-ejs; extra == 'ejs'
30
+ Description-Content-Type: text/markdown
31
+
32
+ # scadustats
33
+
34
+ Extract match data from [Bingo Brawlers](https://bingobrawlers.com/) match VODs. Such as:
35
+
36
+ - Board layout & square texts
37
+ - Square mark/unmark events
38
+ - Start/end events for all games in a match video
39
+ - Game type (base game / DLC)
40
+ - Game result (winner of each game)
41
+ - Game duration
42
+ - Win type (by line [row, column, diagonal], by majority)
43
+ - Players (names and colours)
44
+ - Commentators
45
+ - Timer
46
+
47
+ From that data, derive consolidated views and statistics for each player, squares and game types.
48
+
49
+ > **Disclaimer**: This project is fan initiative, built out of appreciation for Bingo Brawlers and Elden Ring. Its only purpose is to provide more insights into the tournement. It is not affiliated with or endorsed by Bingo Brawlers, FromSoftware, or Bandai Namco.
50
+
51
+ ## Requirements
52
+
53
+ - `ffmpeg` - required by yt-dlp to merge downloaded video/audio streams (e.g. `brew install ffmpeg` or `sudo apt install ffmpeg`)
54
+ - `tesseract` - OCR engine for extracting square text, player names, commentator names, the game label, and the timer (e.g. `brew install tesseract`)
55
+ - `leptonica`, `pkgconfig` - requirements for installing `tesserocr`, Python tesseract binding. (e.g. `brew install pkgconfig leptonica` on macOS, or `sudo apt install libtesseract-dev libleptonica-dev pkg-config` on Debian/Ubuntu).
56
+
57
+ ## Installation
58
+
59
+ Requires Python 3.13+ and [`uv`](https://docs.astral.sh/uv/getting-started/installation/).
60
+
61
+ ```
62
+ git clone https://github.com/adri0/scadustats.git
63
+ cd scadustats
64
+ uv sync
65
+ ```
66
+
67
+ This installs `scadustats` and its dependencies into a local `.venv`. Run commands with `uv run scadustats ...` (see Usage below), or activate the venv (`source .venv/bin/activate`) to run `scadustats` directly.
68
+
69
+ ## Usage
70
+
71
+ ```
72
+ scadustats extract <video_path_or_url> [--data-dir data] [--if-exists replace|append|error] [--consolidate]
73
+ scadustats match list [--data-dir data]
74
+ scadustats match show <match_id> [--events] [--data-dir data]
75
+ scadustats match validate [match_id] [--data-dir data]
76
+ scadustats match consolidate <match_id> [--data-dir data]
77
+ scadustats square consolidate [match_id] [--data-dir data]
78
+ scadustats player consolidate [match_id] [--data-dir data]
79
+ ```
80
+
81
+ ### Extract match data
82
+
83
+ `scadustats extract <video_path_or_url>` reads match data from a video and writes one JSON file per video (all its games, players, commentators, match date, and video link included) under `<data_dir>/matches`. `<video_path_or_url>` can be a local file, or a youtube.com/youtu.be URL to download and extract in one step (deleted afterward on success; kept, on request, if extraction fails).
84
+
85
+ At the end of each extraction a validation is displayed for any inconsistencies. Equivalent of running `scadustats match validate <match_id>` for the recently ingested match.
86
+
87
+ ### View extracted matches
88
+
89
+ The `match` sub-commands are read-only lookups over what's already been extracted, straight from the JSON files (no database needed) — except `consolidate`, which writes.
90
+
91
+ - `list` prints a table of every match: who played, the format, how many games, and how the match ended.
92
+ - `show` prints a summarised version of a match — per game its recorded result, how the squares ended up split, and the final board with the winning line marked. `--events` adds every mark and unmark with its timestamp, player, and goal text.
93
+ - `validate` checks if data of a match is consistent — game counts and order, a winner the board actually supports, one game-start per game, nothing claimed after a line was completed — printing every issue it finds and exiting non-zero if there were any. A reported issue means an extraction mistake probably slipped through and that file needs a look; with no `match_id` given, it checks every match.
94
+ - `consolidate` reconciles one match's squares against the squares reference and refreshes its two players' profiles — shorthand for running `square consolidate` and `player consolidate` scoped to that match.
95
+
96
+ ### Square data
97
+
98
+ `square consolidate` reconciles squares against `<data_dir>/squares/base_game.json` and `<data_dir>/squares/dlc.json`: every distinct square text seen, split by game type and tagged with unique slug id (e.g. `{"id": "tunnels_3", "text": "Complete 3 Tunnels or Precipices", "game_type": "base"}`).
99
+
100
+ The goal is that square texts can be manually fixed for inconsistencies. Then future extractions use `squares/base_game.json` and `squares/dlc.json` for correcting OCR misreads in matches along the way.
101
+
102
+ Given a `match_id`, it reconciles just that match; with none, it walks every match under `data_dir`, growing and correcting the reference incrementally rather than rebuilding it from scratch.
103
+
104
+ ### Player data
105
+
106
+ `player consolidate` rebuilds one YAML file per player under `<data_dir>/players/<slug>.yaml`: their win/loss record (per season, and overall/per game type for individual games), every match they've played (most recent first), and their 5 most-claimed squares per game type, each with its mark count and other statistics. `id` is assigned once, the first time a player is seen, and kept stable after that; `twitch`/`avatar`/`bio` are never set by the tool — fill those in by hand and they survive every later re-run. Given a `match_id`, only that match's two players are written.
107
+
108
+ ## Troubleshooting
109
+
110
+ ### `DownloadError`
111
+
112
+ Sometimes `extract` fails with a `DownloadError` mentioning that YouTube requires sign-in/authentication. Pass a cookies file with `--cookies` (a Netscape-format `cookies.txt`, e.g. exported from a browser) — see [yt-dlp's guide to exporting YouTube cookies](https://github.com/yt-dlp/yt-dlp/wiki/extractors#exporting-youtube-cookies). Installing the optional `ejs` extra (`uv sync --extra ejs`, or `pip install scadustats[ejs]`), which pulls in [`yt-dlp-ejs`](https://github.com/yt-dlp/yt-dlp-ejs), can also help resolve this class of failure.