pixelcue 0.8.7__py3-none-any.whl

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.
pixelcue/__init__.py ADDED
@@ -0,0 +1 @@
1
+ __version__ = "0.8.7"
@@ -0,0 +1,232 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import os
5
+ import sqlite3
6
+ import threading
7
+ import time
8
+ from pathlib import Path
9
+ from typing import Any
10
+
11
+ from platformdirs import user_data_dir
12
+
13
+ from .logging_utils import log_event, log_exception
14
+
15
+
16
+ SCHEMA_VERSION = 1
17
+
18
+
19
+ def default_analysis_db_path() -> Path:
20
+ override = os.environ.get("PIXELCUE_ANALYSIS_DB", "").strip()
21
+ if override:
22
+ return Path(override).expanduser()
23
+ return Path(user_data_dir("PixelCue", "DanceFlow")) / "analysis-cache.sqlite3"
24
+
25
+
26
+ def _jsonable(value: Any) -> Any:
27
+ if value is None or isinstance(value, (str, int, float, bool)):
28
+ return value
29
+ if isinstance(value, Path):
30
+ return str(value)
31
+ if isinstance(value, dict):
32
+ return {str(k): _jsonable(v) for k, v in value.items()}
33
+ if isinstance(value, (list, tuple, set)):
34
+ return [_jsonable(v) for v in value]
35
+ item = getattr(value, "item", None)
36
+ if callable(item):
37
+ try:
38
+ return _jsonable(item())
39
+ except Exception:
40
+ pass
41
+ return str(value)
42
+
43
+
44
+ def normalized_path(path: str | Path) -> str:
45
+ return os.path.normcase(os.path.abspath(os.fspath(path)))
46
+
47
+
48
+ def file_signature(path: str | Path) -> tuple[str, int, int]:
49
+ p = Path(path)
50
+ st = p.stat()
51
+ return normalized_path(p), int(st.st_size), int(st.st_mtime_ns)
52
+
53
+
54
+ class AnalysisStore:
55
+ """Permanent stage-oriented analysis cache backed by SQLite.
56
+
57
+ Results are immutable by file signature: if a path changes size or mtime, a
58
+ new row is written while the old result remains as historical analysis.
59
+ """
60
+
61
+ def __init__(self, db_path: str | Path | None = None) -> None:
62
+ self.db_path = Path(db_path or default_analysis_db_path()).expanduser()
63
+ self.db_path.parent.mkdir(parents=True, exist_ok=True)
64
+ self._lock = threading.RLock()
65
+ self._conn = sqlite3.connect(
66
+ self.db_path,
67
+ timeout=30.0,
68
+ check_same_thread=False,
69
+ )
70
+ self._conn.execute("PRAGMA journal_mode=WAL")
71
+ self._conn.execute("PRAGMA synchronous=NORMAL")
72
+ self._conn.execute("PRAGMA busy_timeout=30000")
73
+ self._create_schema()
74
+ log_event(
75
+ "analysis-store",
76
+ "ready",
77
+ "persistent analysis cache opened",
78
+ db_path=str(self.db_path),
79
+ )
80
+
81
+ def _create_schema(self) -> None:
82
+ with self._lock, self._conn:
83
+ self._conn.executescript(
84
+ """
85
+ CREATE TABLE IF NOT EXISTS analysis_results (
86
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
87
+ path TEXT NOT NULL,
88
+ size_bytes INTEGER NOT NULL,
89
+ mtime_ns INTEGER NOT NULL,
90
+ kind TEXT NOT NULL,
91
+ engine TEXT NOT NULL,
92
+ payload_json TEXT NOT NULL,
93
+ created_at REAL NOT NULL,
94
+ updated_at REAL NOT NULL,
95
+ UNIQUE(path, size_bytes, mtime_ns, kind, engine)
96
+ );
97
+ CREATE INDEX IF NOT EXISTS idx_analysis_lookup
98
+ ON analysis_results(path, kind, engine, size_bytes, mtime_ns);
99
+ CREATE TABLE IF NOT EXISTS metadata (
100
+ key TEXT PRIMARY KEY,
101
+ value TEXT NOT NULL
102
+ );
103
+ """
104
+ )
105
+ self._conn.execute(
106
+ "INSERT OR REPLACE INTO metadata(key, value) VALUES('schema_version', ?)",
107
+ (str(SCHEMA_VERSION),),
108
+ )
109
+
110
+ def load(
111
+ self,
112
+ path: str | Path,
113
+ *,
114
+ kind: str,
115
+ engine: str,
116
+ ) -> dict[str, Any] | None:
117
+ try:
118
+ normalized, size_bytes, mtime_ns = file_signature(path)
119
+ with self._lock:
120
+ row = self._conn.execute(
121
+ """
122
+ SELECT payload_json
123
+ FROM analysis_results
124
+ WHERE path=? AND size_bytes=? AND mtime_ns=?
125
+ AND kind=? AND engine=?
126
+ ORDER BY updated_at DESC
127
+ LIMIT 1
128
+ """,
129
+ (normalized, size_bytes, mtime_ns, kind, engine),
130
+ ).fetchone()
131
+ if row is None:
132
+ return None
133
+ payload = json.loads(row[0])
134
+ log_event(
135
+ "analysis-store",
136
+ "cache-hit",
137
+ "restored persisted analysis",
138
+ path=normalized,
139
+ kind=kind,
140
+ engine=engine,
141
+ )
142
+ return payload
143
+ except FileNotFoundError:
144
+ return None
145
+ except Exception as exc:
146
+ log_exception(
147
+ "analysis-store",
148
+ "load-failure",
149
+ exc,
150
+ path=str(path),
151
+ kind=kind,
152
+ engine=engine,
153
+ )
154
+ return None
155
+
156
+ def save(
157
+ self,
158
+ path: str | Path,
159
+ *,
160
+ kind: str,
161
+ engine: str,
162
+ payload: dict[str, Any],
163
+ ) -> bool:
164
+ try:
165
+ normalized, size_bytes, mtime_ns = file_signature(path)
166
+ encoded = json.dumps(
167
+ _jsonable(payload),
168
+ ensure_ascii=False,
169
+ separators=(",", ":"),
170
+ sort_keys=True,
171
+ )
172
+ now = time.time()
173
+ with self._lock, self._conn:
174
+ self._conn.execute(
175
+ """
176
+ INSERT INTO analysis_results(
177
+ path, size_bytes, mtime_ns, kind, engine,
178
+ payload_json, created_at, updated_at
179
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?)
180
+ ON CONFLICT(path, size_bytes, mtime_ns, kind, engine)
181
+ DO UPDATE SET payload_json=excluded.payload_json,
182
+ updated_at=excluded.updated_at
183
+ """,
184
+ (
185
+ normalized,
186
+ size_bytes,
187
+ mtime_ns,
188
+ kind,
189
+ engine,
190
+ encoded,
191
+ now,
192
+ now,
193
+ ),
194
+ )
195
+ log_event(
196
+ "analysis-store",
197
+ "cache-save",
198
+ "persisted analysis result",
199
+ path=normalized,
200
+ kind=kind,
201
+ engine=engine,
202
+ )
203
+ return True
204
+ except FileNotFoundError:
205
+ return False
206
+ except Exception as exc:
207
+ log_exception(
208
+ "analysis-store",
209
+ "save-failure",
210
+ exc,
211
+ path=str(path),
212
+ kind=kind,
213
+ engine=engine,
214
+ )
215
+ return False
216
+
217
+ def count(self) -> int:
218
+ with self._lock:
219
+ row = self._conn.execute("SELECT COUNT(*) FROM analysis_results").fetchone()
220
+ return int(row[0] if row else 0)
221
+
222
+ def close(self) -> None:
223
+ with self._lock:
224
+ try:
225
+ self._conn.close()
226
+ finally:
227
+ log_event(
228
+ "analysis-store",
229
+ "finish",
230
+ "persistent analysis cache closed",
231
+ db_path=str(self.db_path),
232
+ )
pixelcue/archives.py ADDED
@@ -0,0 +1,319 @@
1
+ from __future__ import annotations
2
+
3
+ import random
4
+ import shutil
5
+ import tarfile
6
+ import tempfile
7
+ import zipfile
8
+ from dataclasses import dataclass
9
+ from pathlib import Path, PurePosixPath
10
+ from typing import BinaryIO, Callable
11
+
12
+ from .media import is_image, is_video
13
+ from .metadata import MagicDetector
14
+
15
+ ARCHIVE_SUFFIXES = (
16
+ ".zip", ".7z", ".rar", ".tar", ".tgz", ".tbz2", ".txz",
17
+ ".tar.gz", ".tar.bz2", ".tar.xz",
18
+ )
19
+
20
+ ARCHIVE_MIMES = {
21
+ "application/zip",
22
+ "application/x-7z-compressed",
23
+ "application/vnd.rar",
24
+ "application/x-rar",
25
+ "application/x-rar-compressed",
26
+ "application/x-tar",
27
+ "application/gzip",
28
+ "application/x-gzip",
29
+ "application/x-bzip2",
30
+ "application/x-xz",
31
+ }
32
+
33
+ PROBE_BYTES = 128 * 1024
34
+
35
+
36
+ def is_archive(path: Path, mime: str = "") -> bool:
37
+ """Content MIME wins completely whenever it is available."""
38
+ mime = (mime or "").strip().lower()
39
+ if mime:
40
+ return mime in ARCHIVE_MIMES
41
+
42
+ # Suffix fallback exists only for callers that genuinely have no MIME result.
43
+ name = path.name.lower()
44
+ return any(name.endswith(s) for s in ARCHIVE_SUFFIXES)
45
+
46
+
47
+ def _archive_kind(path: Path, mime: str = "") -> str:
48
+ mime = (mime or "").strip().lower()
49
+ if mime == "application/zip":
50
+ return "zip"
51
+ if mime == "application/x-7z-compressed":
52
+ return "7z"
53
+ if mime in {
54
+ "application/vnd.rar",
55
+ "application/x-rar",
56
+ "application/x-rar-compressed",
57
+ }:
58
+ return "rar"
59
+ if mime in {
60
+ "application/x-tar",
61
+ "application/gzip",
62
+ "application/x-gzip",
63
+ "application/x-bzip2",
64
+ "application/x-xz",
65
+ }:
66
+ return "tar"
67
+
68
+ if mime:
69
+ return "unknown"
70
+
71
+ # Only callers with no content MIME may fall back to the suffix.
72
+ lower = path.name.lower()
73
+ if lower.endswith(".zip"):
74
+ return "zip"
75
+ if lower.endswith(".7z"):
76
+ return "7z"
77
+ if lower.endswith(".rar"):
78
+ return "rar"
79
+ if lower.endswith((
80
+ ".tar", ".tgz", ".tbz2", ".txz",
81
+ ".tar.gz", ".tar.bz2", ".tar.xz",
82
+ )):
83
+ return "tar"
84
+ return "unknown"
85
+
86
+
87
+ def _safe_member_name(name: str) -> bool:
88
+ pp = PurePosixPath(name.replace("\\", "/"))
89
+ return not pp.is_absolute() and ".." not in pp.parts
90
+
91
+
92
+ def _is_media_bytes(detector: MagicDetector, data: bytes) -> tuple[bool, str]:
93
+ """Return whether member bytes are image/video according to libmagic."""
94
+ mime, _desc = detector.identify_buffer(data)
95
+ # No filename is passed here: extensions have zero influence.
96
+ return is_image(Path("member"), mime) or is_video(Path("member"), mime), mime
97
+
98
+
99
+ @dataclass
100
+ class SampledArchive:
101
+ tempdir: tempfile.TemporaryDirectory
102
+ paths: list[Path]
103
+ member_names: list[str]
104
+ total_media_members: int
105
+ total_members: int
106
+
107
+ def cleanup(self) -> None:
108
+ self.tempdir.cleanup()
109
+
110
+
111
+ def _safe_output(td: Path, idx: int, member_name: str) -> Path:
112
+ # Keep the original suffix only so downstream tools have a convenient temp
113
+ # filename. It has already passed content-based MIME classification.
114
+ suffix = Path(member_name).suffix.lower()
115
+ return td / f"member-{idx:02d}{suffix}"
116
+
117
+
118
+ def _choose(items: list, sample_size: int) -> list:
119
+ if len(items) <= sample_size:
120
+ return list(items)
121
+ return random.SystemRandom().sample(items, sample_size)
122
+
123
+
124
+ def sample_archive(
125
+ path: Path,
126
+ sample_size: int = 5,
127
+ mime: str = "",
128
+ detector: MagicDetector | None = None,
129
+ ) -> SampledArchive:
130
+ """Sample actual media members from an archive using content MIME.
131
+
132
+ Archive type is selected from libmagic MIME when supplied. Member filenames
133
+ are never used to decide whether an entry is image/video.
134
+ """
135
+ detector = detector or MagicDetector()
136
+ archive_kind = _archive_kind(path, mime)
137
+
138
+ if archive_kind == "unknown":
139
+ raise ValueError(
140
+ f"Unsupported archive content type: {mime or 'unknown'} ({path.name})"
141
+ )
142
+
143
+ td_obj = tempfile.TemporaryDirectory(prefix="pixelcue-archive-")
144
+ td = Path(td_obj.name)
145
+
146
+ extracted: list[Path] = []
147
+ sampled_names: list[str] = []
148
+ total_members = 0
149
+ total_media = 0
150
+
151
+ try:
152
+ if archive_kind == "zip":
153
+ with zipfile.ZipFile(path) as zf:
154
+ infos = [
155
+ i for i in zf.infolist()
156
+ if not i.is_dir() and _safe_member_name(i.filename)
157
+ ]
158
+ total_members = len(infos)
159
+
160
+ media_infos = []
161
+ for info in infos:
162
+ try:
163
+ with zf.open(info) as src:
164
+ header = src.read(PROBE_BYTES)
165
+ ok, _mime = _is_media_bytes(detector, header)
166
+ if ok:
167
+ media_infos.append(info)
168
+ except Exception:
169
+ continue
170
+
171
+ total_media = len(media_infos)
172
+ for idx, info in enumerate(_choose(media_infos, sample_size)):
173
+ out = _safe_output(td, idx, info.filename)
174
+ with zf.open(info) as src, out.open("wb") as dst:
175
+ shutil.copyfileobj(src, dst)
176
+ extracted.append(out)
177
+ sampled_names.append(info.filename)
178
+
179
+ elif archive_kind == "tar":
180
+ with tarfile.open(path, mode="r:*") as tf:
181
+ infos = [
182
+ i for i in tf.getmembers()
183
+ if i.isfile() and _safe_member_name(i.name)
184
+ ]
185
+ total_members = len(infos)
186
+
187
+ media_infos = []
188
+ for info in infos:
189
+ try:
190
+ src = tf.extractfile(info)
191
+ if src is None:
192
+ continue
193
+ with src:
194
+ header = src.read(PROBE_BYTES)
195
+ ok, _mime = _is_media_bytes(detector, header)
196
+ if ok:
197
+ media_infos.append(info)
198
+ except Exception:
199
+ continue
200
+
201
+ total_media = len(media_infos)
202
+ for idx, info in enumerate(_choose(media_infos, sample_size)):
203
+ src = tf.extractfile(info)
204
+ if src is None:
205
+ continue
206
+ out = _safe_output(td, idx, info.name)
207
+ with src, out.open("wb") as dst:
208
+ shutil.copyfileobj(src, dst)
209
+ extracted.append(out)
210
+ sampled_names.append(info.name)
211
+
212
+ elif archive_kind == "rar":
213
+ import rarfile
214
+
215
+ with rarfile.RarFile(path) as rf:
216
+ infos = [
217
+ i for i in rf.infolist()
218
+ if not i.isdir() and _safe_member_name(i.filename)
219
+ ]
220
+ total_members = len(infos)
221
+
222
+ media_infos = []
223
+ for info in infos:
224
+ try:
225
+ with rf.open(info) as src:
226
+ header = src.read(PROBE_BYTES)
227
+ ok, _mime = _is_media_bytes(detector, header)
228
+ if ok:
229
+ media_infos.append(info)
230
+ except Exception:
231
+ continue
232
+
233
+ total_media = len(media_infos)
234
+ for idx, info in enumerate(_choose(media_infos, sample_size)):
235
+ out = _safe_output(td, idx, info.filename)
236
+ with rf.open(info) as src, out.open("wb") as dst:
237
+ shutil.copyfileobj(src, dst)
238
+ extracted.append(out)
239
+ sampled_names.append(info.filename)
240
+
241
+ elif archive_kind == "7z":
242
+ # py7zr does not expose a stable streaming member API across the
243
+ # supported versions, so probe members one at a time into a private
244
+ # temp directory. Crucially, the decision is still based on libmagic
245
+ # bytes, never the member suffix.
246
+ import py7zr
247
+
248
+ with py7zr.SevenZipFile(path, mode="r") as zf:
249
+ names = [
250
+ n for n in zf.getnames()
251
+ if _safe_member_name(n) and not n.endswith("/")
252
+ ]
253
+ total_members = len(names)
254
+
255
+ media_names: list[str] = []
256
+ probe_root = td / "_probe"
257
+ probe_root.mkdir()
258
+
259
+ for probe_idx, name in enumerate(names):
260
+ one = probe_root / f"p{probe_idx:08d}"
261
+ one.mkdir()
262
+ try:
263
+ # Reopen for each target: some py7zr versions cannot perform
264
+ # repeated extract() calls on one exhausted archive object.
265
+ with py7zr.SevenZipFile(path, mode="r") as zf:
266
+ zf.extract(path=one, targets=[name])
267
+
268
+ candidate = (one / name).resolve()
269
+ if (
270
+ one.resolve() not in candidate.parents
271
+ or not candidate.is_file()
272
+ ):
273
+ continue
274
+
275
+ with candidate.open("rb") as src:
276
+ header = src.read(PROBE_BYTES)
277
+ ok, _mime = _is_media_bytes(detector, header)
278
+ if ok:
279
+ media_names.append(name)
280
+ except Exception:
281
+ continue
282
+ finally:
283
+ shutil.rmtree(one, ignore_errors=True)
284
+
285
+ total_media = len(media_names)
286
+ chosen = _choose(media_names, sample_size)
287
+ for idx, name in enumerate(chosen):
288
+ one = probe_root / f"chosen-{idx:04d}"
289
+ one.mkdir()
290
+ try:
291
+ with py7zr.SevenZipFile(path, mode="r") as zf:
292
+ zf.extract(path=one, targets=[name])
293
+ candidate = (one / name).resolve()
294
+ if (
295
+ one.resolve() not in candidate.parents
296
+ or not candidate.is_file()
297
+ ):
298
+ continue
299
+
300
+ out = _safe_output(td, idx, name)
301
+ shutil.copy2(candidate, out)
302
+ extracted.append(out)
303
+ sampled_names.append(name)
304
+ finally:
305
+ shutil.rmtree(one, ignore_errors=True)
306
+
307
+ shutil.rmtree(probe_root, ignore_errors=True)
308
+
309
+ return SampledArchive(
310
+ td_obj,
311
+ extracted,
312
+ sampled_names,
313
+ total_media,
314
+ total_members,
315
+ )
316
+
317
+ except Exception:
318
+ td_obj.cleanup()
319
+ raise
pixelcue/cli.py ADDED
@@ -0,0 +1,40 @@
1
+ from __future__ import annotations
2
+
3
+ import os
4
+ from pathlib import Path
5
+
6
+ from .model import TAGGER_PROFILES, DEFAULT_TAGGER_PROFILE
7
+
8
+
9
+ def model_profile_from_argv(argv: list[str]) -> str:
10
+ """Read --model ID / --model=ID, falling back to configured default."""
11
+ for index, arg in enumerate(argv[1:], start=1):
12
+ if arg.startswith("--model="):
13
+ candidate = arg.split("=", 1)[1].strip()
14
+ return candidate if candidate in TAGGER_PROFILES else DEFAULT_TAGGER_PROFILE
15
+ if arg == "--model" and index + 1 < len(argv):
16
+ candidate = argv[index + 1].strip()
17
+ return candidate if candidate in TAGGER_PROFILES else DEFAULT_TAGGER_PROFILE
18
+ return DEFAULT_TAGGER_PROFILE
19
+
20
+
21
+ def startup_path_from_argv(argv: list[str]) -> str | None:
22
+ """Return the first non-option command-line argument that is a valid path."""
23
+ skip_next = False
24
+ for index, arg in enumerate(argv[1:], start=1):
25
+ if skip_next:
26
+ skip_next = False
27
+ continue
28
+ if arg == "--model":
29
+ skip_next = True
30
+ continue
31
+ if arg.startswith("--model=") or arg.startswith("-"):
32
+ continue
33
+
34
+ candidate = Path(os.path.expandvars(os.path.expanduser(arg)))
35
+ if os.path.lexists(candidate):
36
+ try:
37
+ return str(candidate.absolute())
38
+ except OSError:
39
+ return str(candidate)
40
+ return None
@@ -0,0 +1,96 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Any
4
+
5
+ from .face import (
6
+ FaceResult,
7
+ cluster_embeddings,
8
+ CLUSTER_COSINE_DISTANCE,
9
+ CLUSTER_MERGE_MARGIN,
10
+ )
11
+ from .logging_utils import log_event, log_exception
12
+
13
+
14
+ def face_cluster_process_main(request_queue, result_queue) -> None:
15
+ """Persistent clustering subprocess; state is incrementally updated over IPC."""
16
+ worker = "face-cluster-process"
17
+ embeddings: dict[str, list[float]] = {}
18
+ log_event(worker, "start", "clustering subprocess started")
19
+ finish_reason = "normal stop"
20
+ success = True
21
+ try:
22
+ while True:
23
+ message = request_queue.get()
24
+ command = message.get("command")
25
+ if command == "stop":
26
+ finish_reason = str(message.get("reason") or "stop requested")
27
+ break
28
+ if command != "cluster":
29
+ continue
30
+
31
+ job_id = int(message.get("job_id", 0))
32
+ items = list(message.get("items") or [])
33
+ threshold = float(message.get("threshold", CLUSTER_COSINE_DISTANCE))
34
+ log_event(
35
+ worker,
36
+ "job-start",
37
+ "clustering embeddings",
38
+ job_id=job_id,
39
+ new_embeddings=len(items),
40
+ known_embeddings=len(embeddings),
41
+ assignment_threshold=threshold,
42
+ merge_threshold=min(
43
+ 1.0,
44
+ threshold + max(0.0, CLUSTER_MERGE_MARGIN),
45
+ ),
46
+ )
47
+ try:
48
+ for item in items:
49
+ path = str(item["path"])
50
+ embeddings[path] = [float(x) for x in item["embedding"]]
51
+
52
+ results = [
53
+ FaceResult(path=path, embedding=embedding, attributes={})
54
+ for path, embedding in embeddings.items()
55
+ ]
56
+ clusters = cluster_embeddings(results, threshold=threshold)
57
+ payload = [
58
+ {
59
+ "cluster_id": int(cluster["cluster_id"]),
60
+ "centroid": [float(x) for x in cluster["centroid"]],
61
+ "representative_path": str(cluster["representative_path"]),
62
+ "paths": list(cluster["paths"]),
63
+ "size": len(cluster["paths"]),
64
+ }
65
+ for cluster in clusters
66
+ ]
67
+ result_queue.put({"job_id": job_id, "ok": True, "clusters": payload})
68
+ log_event(
69
+ worker,
70
+ "job-finish",
71
+ "clustering succeeded",
72
+ job_id=job_id,
73
+ clusters=len(payload),
74
+ embeddings=len(embeddings),
75
+ )
76
+ except BaseException as exc:
77
+ log_exception(worker, "job-failure", exc, job_id=job_id)
78
+ result_queue.put(
79
+ {
80
+ "job_id": job_id,
81
+ "ok": False,
82
+ "error": f"{type(exc).__name__}: {exc}",
83
+ }
84
+ )
85
+ except BaseException as exc:
86
+ success = False
87
+ finish_reason = f"{type(exc).__name__}: {exc}"
88
+ log_exception(worker, "failure", exc)
89
+ finally:
90
+ log_event(
91
+ worker,
92
+ "finish",
93
+ finish_reason,
94
+ level="INFO" if success else "ERROR",
95
+ success=success,
96
+ )