framesig 0.1.0__tar.gz → 0.1.2__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.
- {framesig-0.1.0/src/framesig.egg-info → framesig-0.1.2}/PKG-INFO +13 -5
- {framesig-0.1.0 → framesig-0.1.2}/README.md +10 -2
- {framesig-0.1.0 → framesig-0.1.2}/pyproject.toml +6 -3
- {framesig-0.1.0 → framesig-0.1.2}/src/framesig/__init__.py +1 -1
- {framesig-0.1.0 → framesig-0.1.2}/src/framesig/cache.py +51 -8
- {framesig-0.1.0 → framesig-0.1.2/src/framesig.egg-info}/PKG-INFO +13 -5
- {framesig-0.1.0 → framesig-0.1.2}/src/framesig.egg-info/SOURCES.txt +1 -0
- framesig-0.1.2/src/framesig.egg-info/requires.txt +6 -0
- framesig-0.1.2/tests/test_cache_resilience.py +114 -0
- {framesig-0.1.0 → framesig-0.1.2}/tests/test_end_to_end.py +29 -0
- framesig-0.1.0/src/framesig.egg-info/requires.txt +0 -6
- {framesig-0.1.0 → framesig-0.1.2}/LICENSE +0 -0
- {framesig-0.1.0 → framesig-0.1.2}/setup.cfg +0 -0
- {framesig-0.1.0 → framesig-0.1.2}/src/framesig/_coerce.py +0 -0
- {framesig-0.1.0 → framesig-0.1.2}/src/framesig/cli.py +0 -0
- {framesig-0.1.0 → framesig-0.1.2}/src/framesig/config.py +0 -0
- {framesig-0.1.0 → framesig-0.1.2}/src/framesig/detectors.py +0 -0
- {framesig-0.1.0 → framesig-0.1.2}/src/framesig/errors.py +0 -0
- {framesig-0.1.0 → framesig-0.1.2}/src/framesig/events.py +0 -0
- {framesig-0.1.0 → framesig-0.1.2}/src/framesig/regions.py +0 -0
- {framesig-0.1.0 → framesig-0.1.2}/src/framesig/scanner.py +0 -0
- {framesig-0.1.0 → framesig-0.1.2}/src/framesig/videogen.py +0 -0
- {framesig-0.1.0 → framesig-0.1.2}/src/framesig/viz.py +0 -0
- {framesig-0.1.0 → framesig-0.1.2}/src/framesig.egg-info/dependency_links.txt +0 -0
- {framesig-0.1.0 → framesig-0.1.2}/src/framesig.egg-info/entry_points.txt +0 -0
- {framesig-0.1.0 → framesig-0.1.2}/src/framesig.egg-info/top_level.txt +0 -0
- {framesig-0.1.0 → framesig-0.1.2}/tests/test_cache.py +0 -0
- {framesig-0.1.0 → framesig-0.1.2}/tests/test_cli.py +0 -0
- {framesig-0.1.0 → framesig-0.1.2}/tests/test_config.py +0 -0
- {framesig-0.1.0 → framesig-0.1.2}/tests/test_detectors.py +0 -0
- {framesig-0.1.0 → framesig-0.1.2}/tests/test_events.py +0 -0
- {framesig-0.1.0 → framesig-0.1.2}/tests/test_regions.py +0 -0
- {framesig-0.1.0 → framesig-0.1.2}/tests/test_scanner.py +0 -0
- {framesig-0.1.0 → framesig-0.1.2}/tests/test_videogen.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: framesig
|
|
3
|
-
Version: 0.1.
|
|
3
|
+
Version: 0.1.2
|
|
4
4
|
Summary: Detect on-screen events in any video by pixel signature — declarative YAML, cached scores, game-agnostic.
|
|
5
5
|
Author-email: Fernando Aporta Franco <ferinazumaDEV@users.noreply.github.com>
|
|
6
6
|
License: MIT
|
|
@@ -17,14 +17,16 @@ Requires-Python: >=3.9
|
|
|
17
17
|
Description-Content-Type: text/markdown
|
|
18
18
|
License-File: LICENSE
|
|
19
19
|
Requires-Dist: numpy<3,>=1.24
|
|
20
|
-
Requires-Dist: opencv-python-headless<
|
|
20
|
+
Requires-Dist: opencv-python-headless<6,>=4.8
|
|
21
21
|
Requires-Dist: PyYAML<7,>=6
|
|
22
22
|
Provides-Extra: dev
|
|
23
|
-
Requires-Dist: pytest<
|
|
23
|
+
Requires-Dist: pytest<10,>=7; extra == "dev"
|
|
24
24
|
Dynamic: license-file
|
|
25
25
|
|
|
26
26
|
# framesig
|
|
27
27
|
|
|
28
|
+
[English](README.md) · **Español**: [README.es.md](README.es.md)
|
|
29
|
+
|
|
28
30
|
**Find *when* something happens on screen — in any video, from any game or source — by its pixel signature.**
|
|
29
31
|
|
|
30
32
|
  
|
|
@@ -60,6 +62,12 @@ framesig doesn't know what a "kill" or a "death screen" looks like — and it do
|
|
|
60
62
|
|
|
61
63
|
framesig needs only `numpy`, `opencv-python-headless` and `PyYAML`. (`ffmpeg` on your `PATH` is optional — it's used only to *generate* the sample clip in `gen-sample` / `demo`; scanning real videos does not need it.)
|
|
62
64
|
|
|
65
|
+
```bash
|
|
66
|
+
pip install framesig
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
Or from a clone, to hack on it:
|
|
70
|
+
|
|
63
71
|
```bash
|
|
64
72
|
git clone https://github.com/ferinazumaDEV/framesig
|
|
65
73
|
cd framesig
|
|
@@ -245,8 +253,8 @@ The suite includes an end-to-end test that renders the synthetic clip with ffmpe
|
|
|
245
253
|
framesig is one of a set of small, dependency-light tools I build and maintain in the open — focused utilities that each do one job well and turn messy input into clean, structured output. If framesig fits into your pipeline, these siblings share the same engineering-first philosophy:
|
|
246
254
|
|
|
247
255
|
- [The GEO Handbook](https://github.com/ferinazumaDEV/generative-engine-optimization-handbook) — the open reference on getting content cited by AI answer engines (ChatGPT, Perplexity, Google AI Overviews, Gemini, Copilot).
|
|
248
|
-
- [typedout](https://github.com/ferinazumaDEV/typedout) — reliable structured output from
|
|
249
|
-
- [politeclient](https://github.com/ferinazumaDEV/politeclient) — a
|
|
256
|
+
- [typedout](https://github.com/ferinazumaDEV/typedout) — reliable structured output from OpenAI and Anthropic, with a provider interface for others: schema-validated JSON with tolerant repair and retries.
|
|
257
|
+
- [politeclient](https://github.com/ferinazumaDEV/politeclient) — a careful, well-behaved HTTP client for Python: retries with backoff, per-host rate-limiting, caching, pagination.
|
|
250
258
|
- [scaffld](https://github.com/ferinazumaDEV/scaffld) — scaffold fully-wired Python projects (tests, CI, pre-commit, license) from templates, with a TUI.
|
|
251
259
|
- Hub & writing: [zentimes.es](https://zentimes.es).
|
|
252
260
|
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
# framesig
|
|
2
2
|
|
|
3
|
+
[English](README.md) · **Español**: [README.es.md](README.es.md)
|
|
4
|
+
|
|
3
5
|
**Find *when* something happens on screen — in any video, from any game or source — by its pixel signature.**
|
|
4
6
|
|
|
5
7
|
  
|
|
@@ -35,6 +37,12 @@ framesig doesn't know what a "kill" or a "death screen" looks like — and it do
|
|
|
35
37
|
|
|
36
38
|
framesig needs only `numpy`, `opencv-python-headless` and `PyYAML`. (`ffmpeg` on your `PATH` is optional — it's used only to *generate* the sample clip in `gen-sample` / `demo`; scanning real videos does not need it.)
|
|
37
39
|
|
|
40
|
+
```bash
|
|
41
|
+
pip install framesig
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
Or from a clone, to hack on it:
|
|
45
|
+
|
|
38
46
|
```bash
|
|
39
47
|
git clone https://github.com/ferinazumaDEV/framesig
|
|
40
48
|
cd framesig
|
|
@@ -220,8 +228,8 @@ The suite includes an end-to-end test that renders the synthetic clip with ffmpe
|
|
|
220
228
|
framesig is one of a set of small, dependency-light tools I build and maintain in the open — focused utilities that each do one job well and turn messy input into clean, structured output. If framesig fits into your pipeline, these siblings share the same engineering-first philosophy:
|
|
221
229
|
|
|
222
230
|
- [The GEO Handbook](https://github.com/ferinazumaDEV/generative-engine-optimization-handbook) — the open reference on getting content cited by AI answer engines (ChatGPT, Perplexity, Google AI Overviews, Gemini, Copilot).
|
|
223
|
-
- [typedout](https://github.com/ferinazumaDEV/typedout) — reliable structured output from
|
|
224
|
-
- [politeclient](https://github.com/ferinazumaDEV/politeclient) — a
|
|
231
|
+
- [typedout](https://github.com/ferinazumaDEV/typedout) — reliable structured output from OpenAI and Anthropic, with a provider interface for others: schema-validated JSON with tolerant repair and retries.
|
|
232
|
+
- [politeclient](https://github.com/ferinazumaDEV/politeclient) — a careful, well-behaved HTTP client for Python: retries with backoff, per-host rate-limiting, caching, pagination.
|
|
225
233
|
- [scaffld](https://github.com/ferinazumaDEV/scaffld) — scaffold fully-wired Python projects (tests, CI, pre-commit, license) from templates, with a TUI.
|
|
226
234
|
- Hub & writing: [zentimes.es](https://zentimes.es).
|
|
227
235
|
|
|
@@ -4,9 +4,12 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "framesig"
|
|
7
|
-
version = "0.1.
|
|
7
|
+
version = "0.1.2"
|
|
8
8
|
description = "Detect on-screen events in any video by pixel signature — declarative YAML, cached scores, game-agnostic."
|
|
9
9
|
readme = "README.md"
|
|
10
|
+
# Version policy: this floor is a minimum, not a recommendation, and everything below 3.11 is
|
|
11
|
+
# compatibility rather than support. 0.2.0 raises the floor to 3.11 (3.10 reaches end of life in
|
|
12
|
+
# October 2026). Nothing changes within 0.1.x.
|
|
10
13
|
requires-python = ">=3.9"
|
|
11
14
|
license = { text = "MIT" }
|
|
12
15
|
authors = [{ name = "Fernando Aporta Franco", email = "ferinazumaDEV@users.noreply.github.com" }]
|
|
@@ -28,12 +31,12 @@ classifiers = [
|
|
|
28
31
|
]
|
|
29
32
|
dependencies = [
|
|
30
33
|
"numpy>=1.24,<3",
|
|
31
|
-
"opencv-python-headless>=4.8,<
|
|
34
|
+
"opencv-python-headless>=4.8,<6",
|
|
32
35
|
"PyYAML>=6,<7",
|
|
33
36
|
]
|
|
34
37
|
|
|
35
38
|
[project.optional-dependencies]
|
|
36
|
-
dev = ["pytest>=7,<
|
|
39
|
+
dev = ["pytest>=7,<10"]
|
|
37
40
|
|
|
38
41
|
[project.scripts]
|
|
39
42
|
framesig = "framesig.cli:main"
|
|
@@ -17,11 +17,19 @@ from __future__ import annotations
|
|
|
17
17
|
|
|
18
18
|
import hashlib
|
|
19
19
|
import json
|
|
20
|
+
import os
|
|
21
|
+
import tempfile
|
|
20
22
|
from pathlib import Path
|
|
21
23
|
from typing import Any
|
|
22
24
|
|
|
23
25
|
CACHE_VERSION = 2
|
|
24
26
|
|
|
27
|
+
#: Keys :meth:`ScanResult.from_payload` reads. A file missing any of them is not
|
|
28
|
+
#: a usable cache entry, however well-formed its JSON is -- validating only the
|
|
29
|
+
#: version used to let ``{"version": 2}`` through as a hit, and the consumer
|
|
30
|
+
#: then died on a KeyError far from the actual problem.
|
|
31
|
+
_REQUIRED_KEYS = ("video", "meta", "timestamps", "scores")
|
|
32
|
+
|
|
25
33
|
|
|
26
34
|
def video_fingerprint(path: str | Path) -> dict[str, Any]:
|
|
27
35
|
"""Cheap identity for a video file: absolute path, byte size and mtime."""
|
|
@@ -57,29 +65,64 @@ class ScoreCache:
|
|
|
57
65
|
"""Return the cached payload for ``key``, or ``None`` on a miss.
|
|
58
66
|
|
|
59
67
|
A corrupt or unreadable cache file is treated as a miss rather than an
|
|
60
|
-
error, so a bad cache can never break a scan.
|
|
68
|
+
error, so a bad cache can never break a scan. "Corrupt" covers bytes
|
|
69
|
+
that are not UTF-8, text that is not JSON, JSON that is not an object,
|
|
70
|
+
the wrong cache version, and an object that does not carry what the
|
|
71
|
+
consumer will ask for. Any of those means: recompute.
|
|
61
72
|
"""
|
|
62
73
|
path = self._path_for(key)
|
|
63
|
-
|
|
74
|
+
try:
|
|
75
|
+
raw = path.read_bytes()
|
|
76
|
+
except OSError:
|
|
64
77
|
return None
|
|
65
78
|
try:
|
|
66
|
-
payload = json.loads(
|
|
67
|
-
|
|
79
|
+
payload = json.loads(raw.decode("utf-8"))
|
|
80
|
+
# UnicodeDecodeError is a ValueError, not an OSError, so catching
|
|
81
|
+
# (OSError, JSONDecodeError) missed it entirely: a cache file holding
|
|
82
|
+
# arbitrary bytes -- a truncated write, a file from another tool, a disk
|
|
83
|
+
# error -- raised out of load() and killed the scan it was meant to
|
|
84
|
+
# protect. JSONDecodeError is itself a ValueError, so one clause covers
|
|
85
|
+
# both without widening to bare Exception.
|
|
86
|
+
except ValueError:
|
|
68
87
|
return None
|
|
69
88
|
if not isinstance(payload, dict) or payload.get("version") != CACHE_VERSION:
|
|
70
89
|
return None
|
|
90
|
+
if not all(key_name in payload for key_name in _REQUIRED_KEYS):
|
|
91
|
+
return None
|
|
92
|
+
if not isinstance(payload.get("scores"), dict):
|
|
93
|
+
return None
|
|
71
94
|
return payload
|
|
72
95
|
|
|
73
96
|
def store(self, key: str, payload: dict[str, Any]) -> Path:
|
|
74
97
|
"""Write ``payload`` under ``key`` and return the file path.
|
|
75
98
|
|
|
76
99
|
The write is atomic (temp file + replace) so a crash mid-write cannot
|
|
77
|
-
leave a half-written cache behind
|
|
100
|
+
leave a half-written cache behind, and each write uses a temporary of
|
|
101
|
+
its own so two processes sharing a cache directory cannot collide.
|
|
102
|
+
|
|
103
|
+
Concurrency beyond that is last-writer-wins. Two writers producing
|
|
104
|
+
scores for the same key produce the same scores -- the key is a digest
|
|
105
|
+
of the video and the score-relevant config -- so which one lands does
|
|
106
|
+
not change the answer.
|
|
78
107
|
"""
|
|
79
108
|
self.directory.mkdir(parents=True, exist_ok=True)
|
|
80
109
|
payload = {**payload, "version": CACHE_VERSION}
|
|
81
110
|
path = self._path_for(key)
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
111
|
+
|
|
112
|
+
# A shared name like scores_<key>.json.tmp meant two writers used the
|
|
113
|
+
# same temporary: the first replace() moved it away and the second
|
|
114
|
+
# failed with FileNotFoundError on a path it had legitimately written.
|
|
115
|
+
# mkstemp gives each writer its own, in the destination directory so
|
|
116
|
+
# os.replace stays a rename within one filesystem, and therefore atomic.
|
|
117
|
+
handle, tmp_name = tempfile.mkstemp(
|
|
118
|
+
dir=self.directory, prefix=f"scores_{key}.", suffix=".json.tmp"
|
|
119
|
+
)
|
|
120
|
+
tmp = Path(tmp_name)
|
|
121
|
+
try:
|
|
122
|
+
with os.fdopen(handle, "w", encoding="utf-8") as fh:
|
|
123
|
+
json.dump(payload, fh)
|
|
124
|
+
os.replace(tmp, path)
|
|
125
|
+
except BaseException:
|
|
126
|
+
tmp.unlink(missing_ok=True)
|
|
127
|
+
raise
|
|
85
128
|
return path
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: framesig
|
|
3
|
-
Version: 0.1.
|
|
3
|
+
Version: 0.1.2
|
|
4
4
|
Summary: Detect on-screen events in any video by pixel signature — declarative YAML, cached scores, game-agnostic.
|
|
5
5
|
Author-email: Fernando Aporta Franco <ferinazumaDEV@users.noreply.github.com>
|
|
6
6
|
License: MIT
|
|
@@ -17,14 +17,16 @@ Requires-Python: >=3.9
|
|
|
17
17
|
Description-Content-Type: text/markdown
|
|
18
18
|
License-File: LICENSE
|
|
19
19
|
Requires-Dist: numpy<3,>=1.24
|
|
20
|
-
Requires-Dist: opencv-python-headless<
|
|
20
|
+
Requires-Dist: opencv-python-headless<6,>=4.8
|
|
21
21
|
Requires-Dist: PyYAML<7,>=6
|
|
22
22
|
Provides-Extra: dev
|
|
23
|
-
Requires-Dist: pytest<
|
|
23
|
+
Requires-Dist: pytest<10,>=7; extra == "dev"
|
|
24
24
|
Dynamic: license-file
|
|
25
25
|
|
|
26
26
|
# framesig
|
|
27
27
|
|
|
28
|
+
[English](README.md) · **Español**: [README.es.md](README.es.md)
|
|
29
|
+
|
|
28
30
|
**Find *when* something happens on screen — in any video, from any game or source — by its pixel signature.**
|
|
29
31
|
|
|
30
32
|
  
|
|
@@ -60,6 +62,12 @@ framesig doesn't know what a "kill" or a "death screen" looks like — and it do
|
|
|
60
62
|
|
|
61
63
|
framesig needs only `numpy`, `opencv-python-headless` and `PyYAML`. (`ffmpeg` on your `PATH` is optional — it's used only to *generate* the sample clip in `gen-sample` / `demo`; scanning real videos does not need it.)
|
|
62
64
|
|
|
65
|
+
```bash
|
|
66
|
+
pip install framesig
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
Or from a clone, to hack on it:
|
|
70
|
+
|
|
63
71
|
```bash
|
|
64
72
|
git clone https://github.com/ferinazumaDEV/framesig
|
|
65
73
|
cd framesig
|
|
@@ -245,8 +253,8 @@ The suite includes an end-to-end test that renders the synthetic clip with ffmpe
|
|
|
245
253
|
framesig is one of a set of small, dependency-light tools I build and maintain in the open — focused utilities that each do one job well and turn messy input into clean, structured output. If framesig fits into your pipeline, these siblings share the same engineering-first philosophy:
|
|
246
254
|
|
|
247
255
|
- [The GEO Handbook](https://github.com/ferinazumaDEV/generative-engine-optimization-handbook) — the open reference on getting content cited by AI answer engines (ChatGPT, Perplexity, Google AI Overviews, Gemini, Copilot).
|
|
248
|
-
- [typedout](https://github.com/ferinazumaDEV/typedout) — reliable structured output from
|
|
249
|
-
- [politeclient](https://github.com/ferinazumaDEV/politeclient) — a
|
|
256
|
+
- [typedout](https://github.com/ferinazumaDEV/typedout) — reliable structured output from OpenAI and Anthropic, with a provider interface for others: schema-validated JSON with tolerant repair and retries.
|
|
257
|
+
- [politeclient](https://github.com/ferinazumaDEV/politeclient) — a careful, well-behaved HTTP client for Python: retries with backoff, per-host rate-limiting, caching, pagination.
|
|
250
258
|
- [scaffld](https://github.com/ferinazumaDEV/scaffld) — scaffold fully-wired Python projects (tests, CI, pre-commit, license) from templates, with a TUI.
|
|
251
259
|
- Hub & writing: [zentimes.es](https://zentimes.es).
|
|
252
260
|
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
"""A bad cache must never break a scan, and two writers must not collide.
|
|
2
|
+
|
|
3
|
+
Audit findings F04 and F05.
|
|
4
|
+
|
|
5
|
+
F04: ``ScoreCache.load`` documented that a corrupt or unreadable file is a miss,
|
|
6
|
+
but caught only ``(OSError, json.JSONDecodeError)``. ``UnicodeDecodeError`` is a
|
|
7
|
+
``ValueError``, so a cache file holding arbitrary bytes raised straight out of
|
|
8
|
+
``load()`` and killed the scan the cache exists to speed up. Validation was also
|
|
9
|
+
thin enough that ``{"version": 2}`` counted as a hit, and the consumer then died
|
|
10
|
+
on a ``KeyError`` far from the real problem.
|
|
11
|
+
|
|
12
|
+
F05: every write of a key used the same temporary name, so two writers sharing a
|
|
13
|
+
cache directory raced: the first ``replace()`` moved the shared temporary away
|
|
14
|
+
and the second failed with ``FileNotFoundError`` on a file it had written.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import json
|
|
20
|
+
import threading
|
|
21
|
+
|
|
22
|
+
import pytest
|
|
23
|
+
|
|
24
|
+
from framesig.cache import CACHE_VERSION, ScoreCache
|
|
25
|
+
|
|
26
|
+
GOOD = {
|
|
27
|
+
"video": "sample.mp4",
|
|
28
|
+
"meta": {"duration": 1.0},
|
|
29
|
+
"timestamps": [0.0, 0.1],
|
|
30
|
+
"scores": {"flash": [0.0, 1.0]},
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
CORRUPT = [
|
|
34
|
+
pytest.param(b"\xff\xfe", id="not-utf8"),
|
|
35
|
+
pytest.param(b"\x00\x01\x02\x03", id="binary-garbage"),
|
|
36
|
+
pytest.param(b'{"version": 2, "scores"', id="truncated-json"),
|
|
37
|
+
pytest.param(b"[1, 2, 3]", id="json-but-not-an-object"),
|
|
38
|
+
pytest.param(b'{"version": 99, "video": "v", "meta": {}, "timestamps": [], "scores": {}}',
|
|
39
|
+
id="wrong-version"),
|
|
40
|
+
pytest.param(b'{"version": 2}', id="no-payload-keys"),
|
|
41
|
+
pytest.param(b'{"version": 2, "video": "v", "meta": {}, "timestamps": []}',
|
|
42
|
+
id="missing-scores"),
|
|
43
|
+
pytest.param(b'{"version": 2, "video": "v", "meta": {}, "timestamps": [], "scores": []}',
|
|
44
|
+
id="scores-not-an-object"),
|
|
45
|
+
pytest.param(b"", id="empty-file"),
|
|
46
|
+
]
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
@pytest.mark.parametrize("contents", CORRUPT)
|
|
50
|
+
def test_corrupt_cache_is_a_miss_not_an_exception(tmp_path, contents):
|
|
51
|
+
(tmp_path / "scores_bad.json").write_bytes(contents)
|
|
52
|
+
assert ScoreCache(tmp_path).load("bad") is None
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def test_a_good_entry_still_loads(tmp_path):
|
|
56
|
+
"""The validation must not be so strict that nothing survives it."""
|
|
57
|
+
cache = ScoreCache(tmp_path)
|
|
58
|
+
cache.store("ok", GOOD)
|
|
59
|
+
loaded = cache.load("ok")
|
|
60
|
+
assert loaded is not None
|
|
61
|
+
assert loaded["scores"] == GOOD["scores"]
|
|
62
|
+
assert loaded["version"] == CACHE_VERSION
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def test_missing_file_is_a_miss(tmp_path):
|
|
66
|
+
assert ScoreCache(tmp_path).load("never-written") is None
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def test_two_writers_on_one_key_do_not_collide(tmp_path):
|
|
70
|
+
"""F05: a barrier after the temporary write forces the interleaving."""
|
|
71
|
+
cache = ScoreCache(tmp_path)
|
|
72
|
+
barrier = threading.Barrier(2, timeout=10)
|
|
73
|
+
errors: list[BaseException] = []
|
|
74
|
+
|
|
75
|
+
original_replace = __import__("os").replace
|
|
76
|
+
|
|
77
|
+
def slow_replace(src, dst):
|
|
78
|
+
barrier.wait() # both writers hold a temporary at this point
|
|
79
|
+
return original_replace(src, dst)
|
|
80
|
+
|
|
81
|
+
def writer(index):
|
|
82
|
+
try:
|
|
83
|
+
payload = {**GOOD, "scores": {"flash": [float(index)]}}
|
|
84
|
+
cache.store("shared", payload)
|
|
85
|
+
except BaseException as exc: # noqa: BLE001 - the test is about what escapes
|
|
86
|
+
errors.append(exc)
|
|
87
|
+
|
|
88
|
+
import framesig.cache as cache_module
|
|
89
|
+
monkeypatched = getattr(cache_module, "os")
|
|
90
|
+
real = monkeypatched.replace
|
|
91
|
+
monkeypatched.replace = slow_replace
|
|
92
|
+
try:
|
|
93
|
+
threads = [threading.Thread(target=writer, args=(i,)) for i in range(2)]
|
|
94
|
+
for thread in threads:
|
|
95
|
+
thread.start()
|
|
96
|
+
for thread in threads:
|
|
97
|
+
thread.join(timeout=15)
|
|
98
|
+
finally:
|
|
99
|
+
monkeypatched.replace = real
|
|
100
|
+
|
|
101
|
+
assert not errors, f"a writer raised: {errors!r}"
|
|
102
|
+
|
|
103
|
+
# Whichever landed, the file must be complete and valid -- not half-written.
|
|
104
|
+
written = json.loads((tmp_path / "scores_shared.json").read_text(encoding="utf-8"))
|
|
105
|
+
assert written["version"] == CACHE_VERSION
|
|
106
|
+
assert written["scores"]["flash"] in ([0.0], [1.0])
|
|
107
|
+
assert cache.load("shared") is not None
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def test_no_temporary_files_are_left_behind(tmp_path):
|
|
111
|
+
cache = ScoreCache(tmp_path)
|
|
112
|
+
for i in range(5):
|
|
113
|
+
cache.store(f"key{i}", GOOD)
|
|
114
|
+
assert list(tmp_path.glob("*.tmp")) == []
|
|
@@ -6,6 +6,7 @@ that framesig recovers exactly the events baked into it, at the right times.
|
|
|
6
6
|
|
|
7
7
|
from __future__ import annotations
|
|
8
8
|
|
|
9
|
+
import json
|
|
9
10
|
from pathlib import Path
|
|
10
11
|
|
|
11
12
|
import pytest
|
|
@@ -97,3 +98,31 @@ def test_detect_all_passes_the_scan_sample_period(sample_video, config, tmp_path
|
|
|
97
98
|
(cut,) = detect_all(config, result)["scene_cut"]
|
|
98
99
|
assert cut.samples == 1
|
|
99
100
|
assert cut.duration == pytest.approx(result.meta["sample_period"])
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def test_scan_recomputes_when_the_cache_file_is_corrupt(sample_video, config, tmp_path):
|
|
104
|
+
"""Audit finding F04, the half the unit tests do not cover.
|
|
105
|
+
|
|
106
|
+
tests/test_cache_resilience.py proves ScoreCache.load() treats corrupt
|
|
107
|
+
bytes and useless payloads as a miss. This proves the *consumer* survives
|
|
108
|
+
them: a scan over a poisoned cache must recompute, return the same scores as
|
|
109
|
+
a clean scan, and leave a valid cache file behind -- not raise, and not
|
|
110
|
+
report from_cache=True for data it could not have read.
|
|
111
|
+
"""
|
|
112
|
+
first = scan_video(sample_video.path, config, cache_dir=str(tmp_path))
|
|
113
|
+
assert first.from_cache is False
|
|
114
|
+
cache_files = list(Path(tmp_path).glob("scores_*.json"))
|
|
115
|
+
assert len(cache_files) == 1, cache_files
|
|
116
|
+
cache_file = cache_files[0]
|
|
117
|
+
|
|
118
|
+
for garbage in (b"\xff\xfe", b'{"version": 2}', b'{"version": 2, "scores"'):
|
|
119
|
+
cache_file.write_bytes(garbage)
|
|
120
|
+
again = scan_video(sample_video.path, config, cache_dir=str(tmp_path))
|
|
121
|
+
assert again.from_cache is False, f"served a hit from {garbage!r}"
|
|
122
|
+
assert again.timestamps == first.timestamps
|
|
123
|
+
assert again.scores == first.scores
|
|
124
|
+
rewritten = json.loads(cache_file.read_text(encoding="utf-8"))
|
|
125
|
+
assert rewritten["version"] == 2 and rewritten["scores"] == first.scores
|
|
126
|
+
|
|
127
|
+
# And the repaired cache is a real hit again.
|
|
128
|
+
assert scan_video(sample_video.path, config, cache_dir=str(tmp_path)).from_cache is True
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|