framelift 0.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.
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 rysk-tanaka
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,134 @@
1
+ Metadata-Version: 2.4
2
+ Name: framelift
3
+ Version: 0.0.1
4
+ Summary: Extract structured data from screen recordings and screenshots with vision LLMs.
5
+ Keywords: screenshot,screen-recording,vision,llm,structured-output,pydantic-ai
6
+ Author: rysk-tanaka
7
+ License-Expression: MIT
8
+ License-File: LICENSE
9
+ Classifier: Development Status :: 2 - Pre-Alpha
10
+ Classifier: Intended Audience :: Developers
11
+ Classifier: Programming Language :: Python :: 3
12
+ Classifier: Programming Language :: Python :: 3.12
13
+ Classifier: Programming Language :: Python :: 3.13
14
+ Classifier: Programming Language :: Python :: 3.14
15
+ Classifier: Topic :: Multimedia :: Graphics
16
+ Classifier: Topic :: Scientific/Engineering :: Image Recognition
17
+ Classifier: Typing :: Typed
18
+ Requires-Dist: imagehash>=4.3
19
+ Requires-Dist: pillow>=12
20
+ Requires-Dist: pydantic>=2.14
21
+ Requires-Dist: pydantic-ai-slim>=2.57
22
+ Requires-Dist: typer>=0.27
23
+ Requires-Dist: pydantic-ai-slim[anthropic]>=2.57 ; extra == 'anthropic'
24
+ Requires-Dist: pydantic-evals>=2.57 ; extra == 'eval'
25
+ Requires-Dist: pydantic-ai-slim[google]>=2.57 ; extra == 'google'
26
+ Requires-Dist: pydantic-ai-slim[openai]>=2.57 ; extra == 'openai'
27
+ Requires-Dist: av>=19 ; extra == 'video'
28
+ Requires-Python: >=3.12
29
+ Project-URL: Repository, https://github.com/rysk-tanaka/framelift
30
+ Project-URL: Issues, https://github.com/rysk-tanaka/framelift/issues
31
+ Project-URL: Changelog, https://github.com/rysk-tanaka/framelift/blob/main/CHANGELOG.md
32
+ Provides-Extra: anthropic
33
+ Provides-Extra: eval
34
+ Provides-Extra: google
35
+ Provides-Extra: openai
36
+ Provides-Extra: video
37
+ Description-Content-Type: text/markdown
38
+
39
+ # framelift
40
+
41
+ Extract structured data from screen recordings and screenshots with vision LLMs.
42
+
43
+ You describe **what** to read as a Pydantic model and **how** to read it as a `Profile`;
44
+ framelift turns images into validated, provenance-tagged JSON Lines.
45
+
46
+ > **Status:** pre-alpha (0.x). The public API is limited to `Profile` and `Pipeline`
47
+ > and may change between minor versions.
48
+
49
+ ## Features (0.1)
50
+
51
+ - Image input (files, directories, glob patterns), each tagged with its SHA-256 and perceptual hash
52
+ - Structured extraction with [Pydantic AI](https://ai.pydantic.dev/): your model is the output type,
53
+ your validators trigger automatic retries
54
+ - Model-agnostic: pass any `provider:model` string supported by Pydantic AI
55
+ - `observations.jsonl` output with source, usage and retry information per image
56
+ - A synthetic sample generator (a fictional shop screen) for demos and tests
57
+
58
+ Planned: video input with frame sampling and de-duplication (0.2), cross-frame aggregation (0.2),
59
+ screen classification, cropping and redaction (0.3), and an evaluation harness built on
60
+ `pydantic-evals`.
61
+
62
+ ## Install
63
+
64
+ ```bash
65
+ pip install "framelift[anthropic]" # or [openai], [google]
66
+ ```
67
+
68
+ ## Quick start
69
+
70
+ ```python
71
+ from pydantic import BaseModel, Field
72
+ from framelift import Pipeline, Profile
73
+
74
+
75
+ class ShopItem(BaseModel):
76
+ name: str = Field(description="Item name as displayed")
77
+ price: int = Field(description="Price as an integer", gt=0)
78
+
79
+
80
+ class ShopScreen(BaseModel):
81
+ shop_name: str
82
+ items: list[ShopItem]
83
+
84
+
85
+ shop = Profile(
86
+ name="fantasy-shop",
87
+ version="1",
88
+ output_type=ShopScreen,
89
+ instructions="Read the shop's item list exactly as displayed.",
90
+ )
91
+
92
+ Pipeline(shop, model="anthropic:<model-name>", out_dir="out").run(["shots/*.png"])
93
+ ```
94
+
95
+ Or from the command line:
96
+
97
+ ```bash
98
+ framelift synth --out samples --n 3 # generate fictional shop screenshots
99
+ framelift run "samples/*.png" --profile examples.fantasy_shop:profile \
100
+ --model anthropic:<model-name> --out out
101
+ ```
102
+
103
+ Each line of `out/observations.jsonl` looks like:
104
+
105
+ ```json
106
+ {"schema": "framelift/observation/v1",
107
+ "profile": {"name": "fantasy-shop", "version": "1"},
108
+ "source": {"path": "samples/shop-000.png", "sha256": "…", "phash": "…"},
109
+ "data": {"shop_name": "…", "items": [{"name": "…", "price": 120}]},
110
+ "model": "anthropic:<model-name>",
111
+ "usage": {"input_tokens": 1534, "output_tokens": 96, "requests": 1, "latency_ms": 2104},
112
+ "retries": 0}
113
+ ```
114
+
115
+ ## Privacy
116
+
117
+ framelift runs locally and only sends the images you pass to the model provider you choose.
118
+ It has no telemetry. Cropping and redaction before sending are planned for 0.3.
119
+
120
+ ## Development
121
+
122
+ ```bash
123
+ uv sync --all-extras
124
+ uv run ruff check && uv run ruff format --check
125
+ uv run ty check
126
+ uv run pytest
127
+ ```
128
+
129
+ Tests never call a real model (`ALLOW_MODEL_REQUESTS = False`); tests marked `live` are skipped by
130
+ default.
131
+
132
+ ## License
133
+
134
+ MIT
@@ -0,0 +1,96 @@
1
+ # framelift
2
+
3
+ Extract structured data from screen recordings and screenshots with vision LLMs.
4
+
5
+ You describe **what** to read as a Pydantic model and **how** to read it as a `Profile`;
6
+ framelift turns images into validated, provenance-tagged JSON Lines.
7
+
8
+ > **Status:** pre-alpha (0.x). The public API is limited to `Profile` and `Pipeline`
9
+ > and may change between minor versions.
10
+
11
+ ## Features (0.1)
12
+
13
+ - Image input (files, directories, glob patterns), each tagged with its SHA-256 and perceptual hash
14
+ - Structured extraction with [Pydantic AI](https://ai.pydantic.dev/): your model is the output type,
15
+ your validators trigger automatic retries
16
+ - Model-agnostic: pass any `provider:model` string supported by Pydantic AI
17
+ - `observations.jsonl` output with source, usage and retry information per image
18
+ - A synthetic sample generator (a fictional shop screen) for demos and tests
19
+
20
+ Planned: video input with frame sampling and de-duplication (0.2), cross-frame aggregation (0.2),
21
+ screen classification, cropping and redaction (0.3), and an evaluation harness built on
22
+ `pydantic-evals`.
23
+
24
+ ## Install
25
+
26
+ ```bash
27
+ pip install "framelift[anthropic]" # or [openai], [google]
28
+ ```
29
+
30
+ ## Quick start
31
+
32
+ ```python
33
+ from pydantic import BaseModel, Field
34
+ from framelift import Pipeline, Profile
35
+
36
+
37
+ class ShopItem(BaseModel):
38
+ name: str = Field(description="Item name as displayed")
39
+ price: int = Field(description="Price as an integer", gt=0)
40
+
41
+
42
+ class ShopScreen(BaseModel):
43
+ shop_name: str
44
+ items: list[ShopItem]
45
+
46
+
47
+ shop = Profile(
48
+ name="fantasy-shop",
49
+ version="1",
50
+ output_type=ShopScreen,
51
+ instructions="Read the shop's item list exactly as displayed.",
52
+ )
53
+
54
+ Pipeline(shop, model="anthropic:<model-name>", out_dir="out").run(["shots/*.png"])
55
+ ```
56
+
57
+ Or from the command line:
58
+
59
+ ```bash
60
+ framelift synth --out samples --n 3 # generate fictional shop screenshots
61
+ framelift run "samples/*.png" --profile examples.fantasy_shop:profile \
62
+ --model anthropic:<model-name> --out out
63
+ ```
64
+
65
+ Each line of `out/observations.jsonl` looks like:
66
+
67
+ ```json
68
+ {"schema": "framelift/observation/v1",
69
+ "profile": {"name": "fantasy-shop", "version": "1"},
70
+ "source": {"path": "samples/shop-000.png", "sha256": "…", "phash": "…"},
71
+ "data": {"shop_name": "…", "items": [{"name": "…", "price": 120}]},
72
+ "model": "anthropic:<model-name>",
73
+ "usage": {"input_tokens": 1534, "output_tokens": 96, "requests": 1, "latency_ms": 2104},
74
+ "retries": 0}
75
+ ```
76
+
77
+ ## Privacy
78
+
79
+ framelift runs locally and only sends the images you pass to the model provider you choose.
80
+ It has no telemetry. Cropping and redaction before sending are planned for 0.3.
81
+
82
+ ## Development
83
+
84
+ ```bash
85
+ uv sync --all-extras
86
+ uv run ruff check && uv run ruff format --check
87
+ uv run ty check
88
+ uv run pytest
89
+ ```
90
+
91
+ Tests never call a real model (`ALLOW_MODEL_REQUESTS = False`); tests marked `live` are skipped by
92
+ default.
93
+
94
+ ## License
95
+
96
+ MIT
@@ -0,0 +1,101 @@
1
+ [project]
2
+ name = "framelift"
3
+ version = "0.0.1"
4
+ description = "Extract structured data from screen recordings and screenshots with vision LLMs."
5
+ readme = "README.md"
6
+ license = "MIT"
7
+ license-files = ["LICENSE"]
8
+ requires-python = ">=3.12"
9
+ keywords = [
10
+ "screenshot",
11
+ "screen-recording",
12
+ "vision",
13
+ "llm",
14
+ "structured-output",
15
+ "pydantic-ai",
16
+ ]
17
+ classifiers = [
18
+ "Development Status :: 2 - Pre-Alpha",
19
+ "Intended Audience :: Developers",
20
+ "Programming Language :: Python :: 3",
21
+ "Programming Language :: Python :: 3.12",
22
+ "Programming Language :: Python :: 3.13",
23
+ "Programming Language :: Python :: 3.14",
24
+ "Topic :: Multimedia :: Graphics",
25
+ "Topic :: Scientific/Engineering :: Image Recognition",
26
+ "Typing :: Typed",
27
+ ]
28
+ dependencies = [
29
+ "imagehash>=4.3",
30
+ "pillow>=12",
31
+ "pydantic>=2.14",
32
+ "pydantic-ai-slim>=2.57",
33
+ "typer>=0.27",
34
+ ]
35
+
36
+ [[project.authors]]
37
+ name = "rysk-tanaka"
38
+
39
+ [project.optional-dependencies]
40
+ video = ["av>=19"]
41
+ eval = ["pydantic-evals>=2.57"]
42
+ anthropic = ["pydantic-ai-slim[anthropic]>=2.57"]
43
+ openai = ["pydantic-ai-slim[openai]>=2.57"]
44
+ google = ["pydantic-ai-slim[google]>=2.57"]
45
+
46
+ [project.scripts]
47
+ framelift = "framelift.cli:app"
48
+
49
+ [project.urls]
50
+ Repository = "https://github.com/rysk-tanaka/framelift"
51
+ Issues = "https://github.com/rysk-tanaka/framelift/issues"
52
+ Changelog = "https://github.com/rysk-tanaka/framelift/blob/main/CHANGELOG.md"
53
+
54
+ [build-system]
55
+ requires = ["uv_build>=0.11.32,<0.12.0"]
56
+ build-backend = "uv_build"
57
+
58
+ [dependency-groups]
59
+ dev = [
60
+ "pytest>=9.1",
61
+ "ruff==0.17.1",
62
+ "ty==0.0.88",
63
+ ]
64
+
65
+ [tool.ruff]
66
+ line-length = 100
67
+ target-version = "py312"
68
+
69
+ [tool.ruff.lint]
70
+ select = [
71
+ "E",
72
+ "W",
73
+ "F",
74
+ "I",
75
+ "B",
76
+ "UP",
77
+ "SIM",
78
+ "RUF",
79
+ "PTH",
80
+ "TC",
81
+ ]
82
+
83
+ [tool.ruff.lint.per-file-ignores]
84
+ "tests/**" = ["TC"]
85
+
86
+ [tool.ty.environment]
87
+ python-version = "3.12"
88
+
89
+ [tool.ty.rules]
90
+ unused-ignore-comment = "warn"
91
+
92
+ [tool.pytest.ini_options]
93
+ testpaths = ["tests"]
94
+ pythonpath = ["."]
95
+ addopts = [
96
+ "-ra",
97
+ "--strict-markers",
98
+ "-m",
99
+ "not live",
100
+ ]
101
+ markers = ["live: calls a real model provider (needs API keys; not run in CI)"]
@@ -0,0 +1,88 @@
1
+ [project]
2
+ name = "framelift"
3
+ version = "0.0.1"
4
+ description = "Extract structured data from screen recordings and screenshots with vision LLMs."
5
+ readme = "README.md"
6
+ license = "MIT"
7
+ license-files = ["LICENSE"]
8
+ authors = [{ name = "rysk-tanaka" }]
9
+ requires-python = ">=3.12"
10
+ keywords = ["screenshot", "screen-recording", "vision", "llm", "structured-output", "pydantic-ai"]
11
+ classifiers = [
12
+ "Development Status :: 2 - Pre-Alpha",
13
+ "Intended Audience :: Developers",
14
+ "Programming Language :: Python :: 3",
15
+ "Programming Language :: Python :: 3.12",
16
+ "Programming Language :: Python :: 3.13",
17
+ "Programming Language :: Python :: 3.14",
18
+ "Topic :: Multimedia :: Graphics",
19
+ "Topic :: Scientific/Engineering :: Image Recognition",
20
+ "Typing :: Typed",
21
+ ]
22
+ dependencies = [
23
+ "imagehash>=4.3",
24
+ "pillow>=12",
25
+ "pydantic>=2.14",
26
+ "pydantic-ai-slim>=2.57",
27
+ "typer>=0.27",
28
+ ]
29
+
30
+ [project.optional-dependencies]
31
+ video = ["av>=19"]
32
+ eval = ["pydantic-evals>=2.57"]
33
+ anthropic = ["pydantic-ai-slim[anthropic]>=2.57"]
34
+ openai = ["pydantic-ai-slim[openai]>=2.57"]
35
+ google = ["pydantic-ai-slim[google]>=2.57"]
36
+
37
+ [project.scripts]
38
+ framelift = "framelift.cli:app"
39
+
40
+ [project.urls]
41
+ Repository = "https://github.com/rysk-tanaka/framelift"
42
+ Issues = "https://github.com/rysk-tanaka/framelift/issues"
43
+ Changelog = "https://github.com/rysk-tanaka/framelift/blob/main/CHANGELOG.md"
44
+
45
+ [build-system]
46
+ requires = ["uv_build>=0.11.32,<0.12.0"]
47
+ build-backend = "uv_build"
48
+
49
+ [dependency-groups]
50
+ dev = [
51
+ "pytest>=9.1",
52
+ "ruff==0.17.1",
53
+ "ty==0.0.88",
54
+ ]
55
+
56
+ [tool.ruff]
57
+ line-length = 100
58
+ target-version = "py312"
59
+
60
+ [tool.ruff.lint]
61
+ select = [
62
+ "E", "W", # pycodestyle
63
+ "F", # pyflakes
64
+ "I", # isort
65
+ "B", # flake8-bugbear
66
+ "UP", # pyupgrade
67
+ "SIM", # flake8-simplify
68
+ "RUF", # ruff-specific
69
+ "PTH", # use pathlib
70
+ "TC", # type-checking imports
71
+ ]
72
+
73
+ [tool.ruff.lint.per-file-ignores]
74
+ "tests/**" = ["TC"]
75
+
76
+ [tool.ty.environment]
77
+ python-version = "3.12"
78
+
79
+ [tool.ty.rules]
80
+ unused-ignore-comment = "warn"
81
+
82
+ [tool.pytest.ini_options]
83
+ testpaths = ["tests"]
84
+ pythonpath = ["."]
85
+ addopts = ["-ra", "--strict-markers", "-m", "not live"]
86
+ markers = [
87
+ "live: calls a real model provider (needs API keys; not run in CI)",
88
+ ]
@@ -0,0 +1,15 @@
1
+ """framelift: extract structured data from screen recordings and screenshots with vision LLMs.
2
+
3
+ The public API is `Profile` and `Pipeline` (plus the `Observation` record they produce).
4
+ Everything under ``framelift._internal`` may change in any release.
5
+ """
6
+
7
+ from importlib.metadata import version as _version
8
+
9
+ from framelift.observation import Observation
10
+ from framelift.pipeline import Pipeline, RunResult
11
+ from framelift.profile import Profile, Validator
12
+
13
+ __all__ = ["Observation", "Pipeline", "Profile", "RunResult", "Validator", "__version__"]
14
+
15
+ __version__ = _version("framelift")
@@ -0,0 +1 @@
1
+ """Internal building blocks. Not part of the public API; may change in any release."""
@@ -0,0 +1,80 @@
1
+ """Structured extraction from one image with Pydantic AI."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import time
6
+ from dataclasses import dataclass
7
+ from typing import TYPE_CHECKING
8
+
9
+ from pydantic import BaseModel
10
+ from pydantic_ai import Agent, AgentRetries, BinaryContent
11
+ from pydantic_ai.messages import RetryPromptPart
12
+
13
+ if TYPE_CHECKING:
14
+ from pydantic_ai.models import Model
15
+
16
+ from framelift._internal.sources import ImageSource
17
+ from framelift.profile import Profile, Validator
18
+
19
+ USER_PROMPT = "Extract the data shown in this screenshot."
20
+
21
+
22
+ @dataclass(frozen=True)
23
+ class Extraction[OutputT: BaseModel]:
24
+ output: OutputT
25
+ input_tokens: int
26
+ output_tokens: int
27
+ requests: int
28
+ retries: int
29
+ latency_ms: int
30
+
31
+
32
+ def _adapt[OutputT: BaseModel](validator: Validator[OutputT]):
33
+ def run(output: OutputT) -> OutputT:
34
+ result = validator(output)
35
+ return output if result is None else result
36
+
37
+ return run
38
+
39
+
40
+ def model_label(model: Model | str) -> str:
41
+ if isinstance(model, str):
42
+ return model
43
+ return f"{model.system}:{model.model_name}"
44
+
45
+
46
+ class Extractor[OutputT: BaseModel]:
47
+ """Reads one image into ``profile.output_type``, retrying when validation fails."""
48
+
49
+ def __init__(self, profile: Profile[OutputT], model: Model | str, *, max_retries: int) -> None:
50
+ self._model = model
51
+ self._agent: Agent[None, OutputT] = Agent(
52
+ output_type=profile.output_type,
53
+ instructions=profile.instructions,
54
+ retries=AgentRetries(output=max_retries),
55
+ name=f"framelift:{profile.name}",
56
+ )
57
+ for validator in profile.validators:
58
+ self._agent.output_validator(_adapt(validator))
59
+
60
+ def extract(self, image: ImageSource) -> Extraction[OutputT]:
61
+ started = time.perf_counter()
62
+ result = self._agent.run_sync(
63
+ [USER_PROMPT, BinaryContent(data=image.data, media_type=image.media_type)],
64
+ model=self._model,
65
+ )
66
+ latency_ms = round((time.perf_counter() - started) * 1000)
67
+ retries = sum(
68
+ isinstance(part, RetryPromptPart)
69
+ for message in result.all_messages()
70
+ for part in getattr(message, "parts", ())
71
+ )
72
+ usage = result.usage
73
+ return Extraction(
74
+ output=result.output,
75
+ input_tokens=usage.input_tokens,
76
+ output_tokens=usage.output_tokens,
77
+ requests=usage.requests,
78
+ retries=retries,
79
+ latency_ms=latency_ms,
80
+ )
@@ -0,0 +1,93 @@
1
+ """Resolve user inputs into image files and load them."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import hashlib
6
+ import io
7
+ from dataclasses import dataclass
8
+ from pathlib import Path
9
+ from typing import TYPE_CHECKING
10
+
11
+ import imagehash
12
+ from PIL import Image, UnidentifiedImageError
13
+
14
+ if TYPE_CHECKING:
15
+ from collections.abc import Iterable
16
+
17
+ IMAGE_SUFFIXES = frozenset({".png", ".jpg", ".jpeg", ".webp"})
18
+ _MEDIA_TYPES = {"PNG": "image/png", "JPEG": "image/jpeg", "WEBP": "image/webp"}
19
+ _GLOB_CHARS = frozenset("*?[")
20
+
21
+
22
+ @dataclass(frozen=True)
23
+ class ImageSource:
24
+ path: Path
25
+ data: bytes
26
+ media_type: str
27
+ sha256: str
28
+ phash: str
29
+
30
+
31
+ def _is_image(path: Path) -> bool:
32
+ return path.is_file() and path.suffix.lower() in IMAGE_SUFFIXES
33
+
34
+
35
+ def _glob(pattern: str) -> list[Path]:
36
+ p = Path(pattern)
37
+ if p.is_absolute():
38
+ root = Path(p.anchor)
39
+ relative = str(p.relative_to(root))
40
+ else:
41
+ root, relative = Path(), pattern
42
+ return sorted(root.glob(relative))
43
+
44
+
45
+ def expand_inputs(inputs: Iterable[str | Path]) -> list[Path]:
46
+ """Expand files, directories and glob patterns into a de-duplicated list of image paths.
47
+
48
+ Directories contribute their image files (non-recursive, sorted). Raises `FileNotFoundError`
49
+ when an input matches nothing, so typos are not silently ignored.
50
+ """
51
+ seen: set[Path] = set()
52
+ result: list[Path] = []
53
+ for item in inputs:
54
+ text = str(item)
55
+ if any(c in text for c in _GLOB_CHARS):
56
+ candidates = [p for p in _glob(text) if _is_image(p)]
57
+ else:
58
+ path = Path(text)
59
+ if path.is_dir():
60
+ candidates = sorted(p for p in path.iterdir() if _is_image(p))
61
+ elif path.is_file():
62
+ candidates = [path]
63
+ else:
64
+ candidates = []
65
+ if not candidates:
66
+ raise FileNotFoundError(f"No image matched input: {text}")
67
+ for candidate in candidates:
68
+ key = candidate.resolve()
69
+ if key not in seen:
70
+ seen.add(key)
71
+ result.append(candidate)
72
+ return result
73
+
74
+
75
+ def load_image(path: Path) -> ImageSource:
76
+ """Read an image, verify it decodes, and compute its SHA-256 and perceptual hash."""
77
+ data = path.read_bytes()
78
+ try:
79
+ with Image.open(io.BytesIO(data)) as image:
80
+ image.load()
81
+ media_type = _MEDIA_TYPES.get(image.format or "")
82
+ phash = str(imagehash.phash(image))
83
+ except UnidentifiedImageError as exc:
84
+ raise ValueError(f"Not a readable image: {path}") from exc
85
+ if media_type is None:
86
+ raise ValueError(f"Unsupported image format for {path}")
87
+ return ImageSource(
88
+ path=path,
89
+ data=data,
90
+ media_type=media_type,
91
+ sha256=hashlib.sha256(data).hexdigest(),
92
+ phash=phash,
93
+ )
@@ -0,0 +1,97 @@
1
+ """Synthetic sample generator: screenshots of a fictional shop with known ground truth.
2
+
3
+ The shop, item names and prices are invented, so the images can be shared, used in tests and
4
+ used as a public benchmark without any real game or app content.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import json
10
+ import random
11
+ from dataclasses import asdict, dataclass
12
+ from typing import TYPE_CHECKING, Literal
13
+
14
+ from PIL import Image, ImageDraw, ImageFont
15
+
16
+ if TYPE_CHECKING:
17
+ from pathlib import Path
18
+
19
+ Orientation = Literal["portrait", "landscape"]
20
+
21
+ _SHOP_PREFIXES = ["Moonlit", "Copper", "Wandering", "Silverleaf", "Lantern", "Hollow", "Amber"]
22
+ _SHOP_SUFFIXES = ["Emporium", "Bazaar", "Trading Post", "Market", "Workshop", "Pantry"]
23
+ _ITEM_ADJECTIVES = ["Moon", "Ember", "Frost", "Dew", "Iron", "Velvet", "Starlit", "Mossy", "Sun"]
24
+ _ITEM_NOUNS = ["Herb", "Ingot", "Thread", "Lantern", "Tonic", "Feather", "Pebble", "Bread", "Rope"]
25
+
26
+ _SIZES: dict[Orientation, tuple[int, int]] = {"portrait": (720, 1280), "landscape": (1280, 720)}
27
+ _HEADER_HEIGHT, _LIST_TOP, _ROW_HEIGHT = 110, 150, 84
28
+ _MIN_ITEMS, _MAX_ITEMS = 4, 7
29
+
30
+
31
+ def _max_rows(orientation: Orientation) -> int:
32
+ height = _SIZES[orientation][1]
33
+ return min(_MAX_ITEMS, (height - _LIST_TOP) // _ROW_HEIGHT)
34
+
35
+
36
+ @dataclass(frozen=True)
37
+ class SampleItem:
38
+ name: str
39
+ price: int
40
+
41
+
42
+ @dataclass(frozen=True)
43
+ class SampleTruth:
44
+ file: str
45
+ orientation: Orientation
46
+ shop_name: str
47
+ items: list[SampleItem]
48
+
49
+
50
+ def _random_truth(rng: random.Random, file: str, orientation: Orientation) -> SampleTruth:
51
+ shop = f"{rng.choice(_SHOP_PREFIXES)} {rng.choice(_SHOP_SUFFIXES)}"
52
+ names: set[str] = set()
53
+ count = rng.randint(_MIN_ITEMS, _max_rows(orientation))
54
+ while len(names) < count:
55
+ names.add(f"{rng.choice(_ITEM_ADJECTIVES)} {rng.choice(_ITEM_NOUNS)}")
56
+ items = [SampleItem(name=n, price=rng.randrange(10, 2000, 5)) for n in sorted(names)]
57
+ return SampleTruth(file=file, orientation=orientation, shop_name=shop, items=items)
58
+
59
+
60
+ def render(truth: SampleTruth) -> Image.Image:
61
+ """Draw a simple shop screen for ``truth``."""
62
+ width, height = _SIZES[truth.orientation]
63
+ image = Image.new("RGB", (width, height), (34, 30, 46))
64
+ draw = ImageDraw.Draw(image)
65
+ title = ImageFont.load_default(size=40)
66
+ body = ImageFont.load_default(size=30)
67
+
68
+ draw.rectangle((0, 0, width, _HEADER_HEIGHT), fill=(70, 52, 96))
69
+ draw.text((32, 34), truth.shop_name, font=title, fill=(250, 236, 200))
70
+
71
+ for index, item in enumerate(truth.items):
72
+ y = _LIST_TOP + index * _ROW_HEIGHT
73
+ fill = (52, 46, 70) if index % 2 == 0 else (44, 39, 60)
74
+ draw.rounded_rectangle((24, y, width - 24, y + _ROW_HEIGHT - 12), radius=12, fill=fill)
75
+ draw.text((48, y + 20), item.name, font=body, fill=(235, 235, 245))
76
+ price = f"{item.price} G"
77
+ price_width = draw.textlength(price, font=body)
78
+ draw.text((width - 48 - price_width, y + 20), price, font=body, fill=(255, 214, 102))
79
+ return image
80
+
81
+
82
+ def generate(out_dir: Path, *, n: int, seed: int = 0) -> list[SampleTruth]:
83
+ """Write ``n`` PNG screenshots and a ``truth.jsonl`` with their ground truth."""
84
+ if n < 1:
85
+ raise ValueError("n must be >= 1")
86
+ out_dir.mkdir(parents=True, exist_ok=True)
87
+ rng = random.Random(seed)
88
+ truths: list[SampleTruth] = []
89
+ for index in range(n):
90
+ orientation: Orientation = "portrait" if index % 2 == 0 else "landscape"
91
+ truth = _random_truth(rng, f"shop-{index:03d}.png", orientation)
92
+ render(truth).save(out_dir / truth.file, format="PNG")
93
+ truths.append(truth)
94
+ with (out_dir / "truth.jsonl").open("w", encoding="utf-8") as fh:
95
+ for truth in truths:
96
+ fh.write(json.dumps(asdict(truth), ensure_ascii=False) + "\n")
97
+ return truths
@@ -0,0 +1,87 @@
1
+ """Command-line interface: ``framelift run`` and ``framelift synth``."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import importlib
6
+ import sys
7
+ from pathlib import Path
8
+ from typing import Annotated
9
+
10
+ import typer
11
+
12
+ from framelift._internal import synth as _synth
13
+ from framelift.pipeline import Pipeline
14
+ from framelift.profile import Profile
15
+
16
+ app = typer.Typer(
17
+ help="Extract structured data from screen recordings and screenshots with vision LLMs.",
18
+ no_args_is_help=True,
19
+ add_completion=False,
20
+ )
21
+
22
+
23
+ def load_profile(spec: str) -> Profile:
24
+ """Load a `Profile` from ``"package.module:attribute"``.
25
+
26
+ The current directory is put on ``sys.path`` so that local profile modules can be used.
27
+ """
28
+ module_name, sep, attribute = spec.partition(":")
29
+ if not sep or not module_name or not attribute:
30
+ raise typer.BadParameter("expected 'package.module:attribute'", param_hint="--profile")
31
+ cwd = str(Path.cwd())
32
+ if cwd not in sys.path:
33
+ sys.path.insert(0, cwd)
34
+ try:
35
+ module = importlib.import_module(module_name)
36
+ except ImportError as exc:
37
+ raise typer.BadParameter(f"cannot import {module_name!r}: {exc}") from exc
38
+ profile = getattr(module, attribute, None)
39
+ if not isinstance(profile, Profile):
40
+ raise typer.BadParameter(f"{spec!r} is not a framelift Profile", param_hint="--profile")
41
+ return profile
42
+
43
+
44
+ @app.command()
45
+ def run(
46
+ inputs: Annotated[list[str], typer.Argument(help="Image files, directories or glob patterns")],
47
+ profile: Annotated[str, typer.Option(help="Profile to use, as 'package.module:attribute'")],
48
+ model: Annotated[str, typer.Option(help="Pydantic AI model, e.g. 'anthropic:<model-name>'")],
49
+ out: Annotated[Path, typer.Option(help="Output directory")] = Path("out"),
50
+ max_retries: Annotated[int, typer.Option(min=0, help="Retries when validation fails")] = 2,
51
+ anonymize_paths: Annotated[
52
+ bool, typer.Option(help="Write only hashes, not input paths, to the output")
53
+ ] = False,
54
+ ) -> None:
55
+ """Extract data from images and write observations.jsonl."""
56
+ pipeline = Pipeline(
57
+ load_profile(profile),
58
+ model=model,
59
+ out_dir=out,
60
+ max_retries=max_retries,
61
+ anonymize_paths=anonymize_paths,
62
+ )
63
+ try:
64
+ result = pipeline.run(inputs)
65
+ except FileNotFoundError as exc:
66
+ raise typer.BadParameter(str(exc), param_hint="INPUTS") from exc
67
+ typer.echo(
68
+ f"{len(result.succeeded)} ok, {len(result.failed)} failed -> {result.output_path}",
69
+ err=True,
70
+ )
71
+ if result.failed:
72
+ raise typer.Exit(code=1)
73
+
74
+
75
+ @app.command()
76
+ def synth(
77
+ out: Annotated[Path, typer.Option(help="Output directory")] = Path("samples"),
78
+ n: Annotated[int, typer.Option(min=1, help="Number of screenshots")] = 10,
79
+ seed: Annotated[int, typer.Option(help="Random seed")] = 0,
80
+ ) -> None:
81
+ """Generate screenshots of a fictional shop, with ground truth in truth.jsonl."""
82
+ truths = _synth.generate(out, n=n, seed=seed)
83
+ typer.echo(f"wrote {len(truths)} images and truth.jsonl to {out}", err=True)
84
+
85
+
86
+ if __name__ == "__main__":
87
+ app()
@@ -0,0 +1,50 @@
1
+ """The `framelift/observation/v1` record written to ``observations.jsonl``."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any, Literal
6
+
7
+ from pydantic import BaseModel, ConfigDict, Field
8
+
9
+ OBSERVATION_SCHEMA = "framelift/observation/v1"
10
+
11
+
12
+ class _Record(BaseModel):
13
+ model_config = ConfigDict(frozen=True, extra="forbid", populate_by_name=True)
14
+
15
+
16
+ class ProfileRef(_Record):
17
+ name: str
18
+ version: str
19
+
20
+
21
+ class SourceRef(_Record):
22
+ path: str | None = Field(description="Input path; omitted when paths are anonymized")
23
+ sha256: str
24
+ phash: str = Field(description="Perceptual hash (hex) of the image")
25
+
26
+
27
+ class Usage(_Record):
28
+ input_tokens: int = 0
29
+ output_tokens: int = 0
30
+ requests: int = 0
31
+ latency_ms: int = 0
32
+
33
+
34
+ class Observation(_Record):
35
+ """What was read from one image."""
36
+
37
+ schema_id: Literal["framelift/observation/v1"] = Field(
38
+ default=OBSERVATION_SCHEMA, alias="schema"
39
+ )
40
+ status: Literal["ok", "error"]
41
+ profile: ProfileRef
42
+ source: SourceRef
43
+ data: dict[str, Any] | None = Field(description="The validated output; null on error")
44
+ error: str | None = None
45
+ model: str
46
+ usage: Usage
47
+ retries: int = Field(description="How many times validation asked the model to try again")
48
+
49
+ def to_json(self) -> str:
50
+ return self.model_dump_json(by_alias=True)
@@ -0,0 +1,128 @@
1
+ """`Pipeline`: run a `Profile` over images and write ``observations.jsonl``."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import time
6
+ from dataclasses import dataclass, field
7
+ from pathlib import Path
8
+ from typing import TYPE_CHECKING
9
+
10
+ from pydantic import BaseModel
11
+ from pydantic_ai.exceptions import UnexpectedModelBehavior
12
+
13
+ from framelift._internal.extract import Extractor, model_label
14
+ from framelift._internal.sources import expand_inputs, load_image
15
+ from framelift.observation import Observation, ProfileRef, SourceRef, Usage
16
+
17
+ if TYPE_CHECKING:
18
+ from collections.abc import Iterable
19
+
20
+ from pydantic_ai.models import Model
21
+
22
+ from framelift._internal.sources import ImageSource
23
+ from framelift.profile import Profile
24
+
25
+ OBSERVATIONS_FILE = "observations.jsonl"
26
+
27
+
28
+ @dataclass
29
+ class RunResult:
30
+ observations: list[Observation] = field(default_factory=list)
31
+ output_path: Path | None = None
32
+
33
+ @property
34
+ def succeeded(self) -> list[Observation]:
35
+ return [o for o in self.observations if o.status == "ok"]
36
+
37
+ @property
38
+ def failed(self) -> list[Observation]:
39
+ return [o for o in self.observations if o.status == "error"]
40
+
41
+
42
+ class Pipeline[OutputT: BaseModel]:
43
+ """Extract ``profile.output_type`` from every input image.
44
+
45
+ Args:
46
+ profile: What to read.
47
+ model: A Pydantic AI model or a ``"provider:model"`` string.
48
+ out_dir: Where to write ``observations.jsonl``. ``None`` keeps results in memory only.
49
+ max_retries: How many times a failed validation may ask the model to try again.
50
+ anonymize_paths: Omit input paths from observations, keeping only hashes.
51
+ """
52
+
53
+ def __init__(
54
+ self,
55
+ profile: Profile[OutputT],
56
+ *,
57
+ model: Model | str,
58
+ out_dir: str | Path | None = None,
59
+ max_retries: int = 2,
60
+ anonymize_paths: bool = False,
61
+ ) -> None:
62
+ if max_retries < 0:
63
+ raise ValueError("max_retries must be >= 0")
64
+ self.profile = profile
65
+ self.model = model
66
+ self.out_dir = Path(out_dir) if out_dir is not None else None
67
+ self.anonymize_paths = anonymize_paths
68
+ self._extractor = Extractor(profile, model, max_retries=max_retries)
69
+
70
+ def run(self, inputs: str | Path | Iterable[str | Path]) -> RunResult:
71
+ """Process the inputs in order. A failure on one image does not stop the run."""
72
+ items = [inputs] if isinstance(inputs, str | Path) else list(inputs)
73
+ paths = expand_inputs(items)
74
+
75
+ result = RunResult()
76
+ writer = None
77
+ if self.out_dir is not None:
78
+ self.out_dir.mkdir(parents=True, exist_ok=True)
79
+ result.output_path = self.out_dir / OBSERVATIONS_FILE
80
+ writer = result.output_path.open("w", encoding="utf-8")
81
+ try:
82
+ for path in paths:
83
+ observation = self._observe(load_image(path))
84
+ result.observations.append(observation)
85
+ if writer is not None:
86
+ writer.write(observation.to_json() + "\n")
87
+ writer.flush()
88
+ finally:
89
+ if writer is not None:
90
+ writer.close()
91
+ return result
92
+
93
+ def _observe(self, image: ImageSource) -> Observation:
94
+ profile = ProfileRef(name=self.profile.name, version=self.profile.version)
95
+ source = SourceRef(
96
+ path=None if self.anonymize_paths else image.path.as_posix(),
97
+ sha256=image.sha256,
98
+ phash=image.phash,
99
+ )
100
+ model = model_label(self.model)
101
+ started = time.perf_counter()
102
+ try:
103
+ extraction = self._extractor.extract(image)
104
+ except UnexpectedModelBehavior as exc:
105
+ return Observation(
106
+ status="error",
107
+ profile=profile,
108
+ source=source,
109
+ data=None,
110
+ error=str(exc),
111
+ model=model,
112
+ usage=Usage(latency_ms=round((time.perf_counter() - started) * 1000)),
113
+ retries=0,
114
+ )
115
+ return Observation(
116
+ status="ok",
117
+ profile=profile,
118
+ source=source,
119
+ data=extraction.output.model_dump(mode="json"),
120
+ model=model,
121
+ usage=Usage(
122
+ input_tokens=extraction.input_tokens,
123
+ output_tokens=extraction.output_tokens,
124
+ requests=extraction.requests,
125
+ latency_ms=extraction.latency_ms,
126
+ ),
127
+ retries=extraction.retries,
128
+ )
@@ -0,0 +1,61 @@
1
+ """`Profile`: what to read from a screen and how to read it."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import hashlib
6
+ import json
7
+ from collections.abc import Callable, Sequence
8
+ from dataclasses import dataclass, field
9
+
10
+ from pydantic import BaseModel
11
+
12
+ type Validator[OutputT: BaseModel] = Callable[[OutputT], OutputT | None]
13
+ """A check run on every extracted output.
14
+
15
+ Raise `pydantic_ai.ModelRetry` with a message to make the model try again; return a (possibly
16
+ modified) output, or `None` to keep it as is.
17
+ """
18
+
19
+
20
+ @dataclass(frozen=True, kw_only=True)
21
+ class Profile[OutputT: BaseModel]:
22
+ """Defines what to extract from a screen.
23
+
24
+ Args:
25
+ name: Identifier written to every observation, e.g. ``"fantasy-shop"``.
26
+ output_type: The Pydantic model to fill in. Field descriptions are passed to the model.
27
+ instructions: How to read the screen.
28
+ version: Bump this whenever the instructions or the output model change, so that
29
+ observations, caches and evaluation results can be told apart.
30
+ validators: Extra checks run on every output; see `Validator`.
31
+ """
32
+
33
+ name: str
34
+ output_type: type[OutputT]
35
+ instructions: str
36
+ version: str = "1"
37
+ validators: Sequence[Validator[OutputT]] = field(default_factory=tuple)
38
+
39
+ def __post_init__(self) -> None:
40
+ if not self.name.strip():
41
+ raise ValueError("Profile.name must not be empty")
42
+ if not self.version.strip():
43
+ raise ValueError("Profile.version must not be empty")
44
+ if not (isinstance(self.output_type, type) and issubclass(self.output_type, BaseModel)):
45
+ raise TypeError("Profile.output_type must be a pydantic BaseModel subclass")
46
+ object.__setattr__(self, "validators", tuple(self.validators))
47
+
48
+ @property
49
+ def fingerprint(self) -> str:
50
+ """A stable hash of everything that affects extraction results."""
51
+ payload = json.dumps(
52
+ {
53
+ "name": self.name,
54
+ "version": self.version,
55
+ "instructions": self.instructions,
56
+ "schema": self.output_type.model_json_schema(),
57
+ },
58
+ sort_keys=True,
59
+ ensure_ascii=False,
60
+ )
61
+ return hashlib.sha256(payload.encode()).hexdigest()
File without changes