lenslet 0.2.1__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.
- lenslet/__init__.py +7 -0
- lenslet/api.py +157 -0
- lenslet/cli.py +121 -0
- lenslet/frontend/assets/index-B-0lZ7yu.js +44 -0
- lenslet/frontend/assets/index-c56aKxHZ.css +1 -0
- lenslet/frontend/favicon.ico +0 -0
- lenslet/frontend/index.html +14 -0
- lenslet/metadata.py +151 -0
- lenslet/server.py +520 -0
- lenslet/storage/__init__.py +6 -0
- lenslet/storage/base.py +35 -0
- lenslet/storage/dataset.py +591 -0
- lenslet/storage/local.py +69 -0
- lenslet/storage/memory.py +472 -0
- lenslet/storage/parquet.py +483 -0
- lenslet/workspace.py +60 -0
- lenslet-0.2.1.dist-info/METADATA +134 -0
- lenslet-0.2.1.dist-info/RECORD +20 -0
- lenslet-0.2.1.dist-info/WHEEL +4 -0
- lenslet-0.2.1.dist-info/entry_points.txt +2 -0
|
@@ -0,0 +1,591 @@
|
|
|
1
|
+
"""In-memory dataset storage for programmatic API."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
import os
|
|
4
|
+
import struct
|
|
5
|
+
import sys
|
|
6
|
+
import time
|
|
7
|
+
from concurrent.futures import ThreadPoolExecutor, as_completed
|
|
8
|
+
from dataclasses import dataclass, field
|
|
9
|
+
from datetime import datetime, timezone
|
|
10
|
+
from io import BytesIO
|
|
11
|
+
from urllib.parse import urlparse
|
|
12
|
+
from PIL import Image
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@dataclass
|
|
16
|
+
class CachedItem:
|
|
17
|
+
"""In-memory cached metadata for an image."""
|
|
18
|
+
path: str
|
|
19
|
+
name: str
|
|
20
|
+
mime: str
|
|
21
|
+
width: int
|
|
22
|
+
height: int
|
|
23
|
+
size: int
|
|
24
|
+
mtime: float
|
|
25
|
+
url: str | None = None # For S3 images, this is the presigned URL
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@dataclass
|
|
29
|
+
class CachedIndex:
|
|
30
|
+
"""In-memory cached folder index."""
|
|
31
|
+
path: str
|
|
32
|
+
generated_at: str
|
|
33
|
+
items: list[CachedItem] = field(default_factory=list)
|
|
34
|
+
dirs: list[str] = field(default_factory=list)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class DatasetStorage:
|
|
38
|
+
"""
|
|
39
|
+
In-memory storage for programmatic datasets.
|
|
40
|
+
Supports both local file paths and S3 URIs.
|
|
41
|
+
"""
|
|
42
|
+
|
|
43
|
+
IMAGE_EXTS = (".jpg", ".jpeg", ".png", ".webp")
|
|
44
|
+
REMOTE_HEADER_BYTES = 65536
|
|
45
|
+
REMOTE_DIM_WORKERS = 16
|
|
46
|
+
|
|
47
|
+
def __init__(
|
|
48
|
+
self,
|
|
49
|
+
datasets: dict[str, list[str]],
|
|
50
|
+
thumb_size: int = 256,
|
|
51
|
+
thumb_quality: int = 70,
|
|
52
|
+
):
|
|
53
|
+
"""
|
|
54
|
+
Initialize with datasets.
|
|
55
|
+
|
|
56
|
+
Args:
|
|
57
|
+
datasets: Dict of {dataset_name: [list of paths/URIs]}
|
|
58
|
+
thumb_size: Thumbnail short edge size
|
|
59
|
+
thumb_quality: WebP quality for thumbnails
|
|
60
|
+
"""
|
|
61
|
+
self.datasets = datasets
|
|
62
|
+
self.thumb_size = thumb_size
|
|
63
|
+
self.thumb_quality = thumb_quality
|
|
64
|
+
|
|
65
|
+
# Build flat path structure: /dataset_name/image_name
|
|
66
|
+
self._items: dict[str, CachedItem] = {} # path -> item
|
|
67
|
+
self._indexes: dict[str, CachedIndex] = {}
|
|
68
|
+
self._thumbnails: dict[str, bytes] = {}
|
|
69
|
+
self._metadata: dict[str, dict] = {}
|
|
70
|
+
self._dimensions: dict[str, tuple[int, int]] = {}
|
|
71
|
+
|
|
72
|
+
# Build initial index
|
|
73
|
+
self._build_all_indexes()
|
|
74
|
+
|
|
75
|
+
def _is_s3_uri(self, path: str) -> bool:
|
|
76
|
+
"""Check if path is an S3 URI."""
|
|
77
|
+
return path.startswith("s3://")
|
|
78
|
+
|
|
79
|
+
def _is_http_url(self, path: str) -> bool:
|
|
80
|
+
"""Check if path is an HTTP/HTTPS URL."""
|
|
81
|
+
return path.startswith("http://") or path.startswith("https://")
|
|
82
|
+
|
|
83
|
+
def _get_presigned_url(self, s3_uri: str, expires_in: int = 3600) -> str:
|
|
84
|
+
"""Convert S3 URI to a presigned HTTPS URL using boto3."""
|
|
85
|
+
try:
|
|
86
|
+
import boto3
|
|
87
|
+
from botocore.exceptions import BotoCoreError, ClientError, NoCredentialsError
|
|
88
|
+
except ImportError as exc: # pragma: no cover - optional dependency
|
|
89
|
+
raise ImportError(
|
|
90
|
+
"boto3 package required for S3 support. Install with: pip install lenslet[s3]"
|
|
91
|
+
) from exc
|
|
92
|
+
|
|
93
|
+
parsed = urlparse(s3_uri)
|
|
94
|
+
bucket = parsed.netloc
|
|
95
|
+
key = parsed.path.lstrip("/")
|
|
96
|
+
if not bucket or not key:
|
|
97
|
+
raise ValueError(f"Invalid S3 URI: {s3_uri}")
|
|
98
|
+
|
|
99
|
+
try:
|
|
100
|
+
s3_client = boto3.client("s3")
|
|
101
|
+
return s3_client.generate_presigned_url(
|
|
102
|
+
"get_object",
|
|
103
|
+
Params={"Bucket": bucket, "Key": key},
|
|
104
|
+
ExpiresIn=expires_in,
|
|
105
|
+
)
|
|
106
|
+
except (BotoCoreError, ClientError, NoCredentialsError) as e:
|
|
107
|
+
raise RuntimeError(f"Failed to presign S3 URI: {e}") from e
|
|
108
|
+
|
|
109
|
+
def _is_supported_image(self, name: str) -> bool:
|
|
110
|
+
"""Check if file is a supported image."""
|
|
111
|
+
return name.lower().endswith(self.IMAGE_EXTS)
|
|
112
|
+
|
|
113
|
+
def _extract_name(self, path: str) -> str:
|
|
114
|
+
"""Extract filename from local path or S3 URI."""
|
|
115
|
+
if self._is_s3_uri(path) or self._is_http_url(path):
|
|
116
|
+
parsed = urlparse(path)
|
|
117
|
+
return os.path.basename(parsed.path)
|
|
118
|
+
return os.path.basename(path)
|
|
119
|
+
|
|
120
|
+
def _guess_mime(self, name: str) -> str:
|
|
121
|
+
"""Guess MIME type from filename."""
|
|
122
|
+
n = name.lower()
|
|
123
|
+
if n.endswith(".webp"):
|
|
124
|
+
return "image/webp"
|
|
125
|
+
if n.endswith(".png"):
|
|
126
|
+
return "image/png"
|
|
127
|
+
return "image/jpeg"
|
|
128
|
+
|
|
129
|
+
def _read_dimensions_from_bytes(self, data: bytes, ext: str | None) -> tuple[int, int] | None:
|
|
130
|
+
"""Read image dimensions from in-memory bytes."""
|
|
131
|
+
if not data:
|
|
132
|
+
return None
|
|
133
|
+
|
|
134
|
+
kind = None
|
|
135
|
+
if ext in ("jpg", "jpeg"):
|
|
136
|
+
kind = "jpeg"
|
|
137
|
+
elif ext == "png":
|
|
138
|
+
kind = "png"
|
|
139
|
+
elif ext == "webp":
|
|
140
|
+
kind = "webp"
|
|
141
|
+
else:
|
|
142
|
+
if data.startswith(b"\xff\xd8"):
|
|
143
|
+
kind = "jpeg"
|
|
144
|
+
elif data.startswith(b"\x89PNG\r\n\x1a\n"):
|
|
145
|
+
kind = "png"
|
|
146
|
+
elif data.startswith(b"RIFF") and data[8:12] == b"WEBP":
|
|
147
|
+
kind = "webp"
|
|
148
|
+
|
|
149
|
+
try:
|
|
150
|
+
buf = BytesIO(data)
|
|
151
|
+
if kind == "jpeg":
|
|
152
|
+
return self._jpeg_dimensions(buf)
|
|
153
|
+
if kind == "png":
|
|
154
|
+
return self._png_dimensions(buf)
|
|
155
|
+
if kind == "webp":
|
|
156
|
+
return self._webp_dimensions(buf)
|
|
157
|
+
except Exception:
|
|
158
|
+
return None
|
|
159
|
+
return None
|
|
160
|
+
|
|
161
|
+
def _get_remote_header_bytes(self, url: str, max_bytes: int | None = None) -> bytes | None:
|
|
162
|
+
"""Fetch the first N bytes of a remote image via Range request."""
|
|
163
|
+
max_bytes = max_bytes or self.REMOTE_HEADER_BYTES
|
|
164
|
+
try:
|
|
165
|
+
import urllib.request
|
|
166
|
+
req = urllib.request.Request(
|
|
167
|
+
url,
|
|
168
|
+
headers={"Range": f"bytes=0-{max_bytes - 1}"},
|
|
169
|
+
)
|
|
170
|
+
with urllib.request.urlopen(req) as response:
|
|
171
|
+
return response.read(max_bytes)
|
|
172
|
+
except Exception:
|
|
173
|
+
return None
|
|
174
|
+
|
|
175
|
+
def _get_remote_dimensions(self, url: str, name: str) -> tuple[int, int] | None:
|
|
176
|
+
"""Try to read dimensions from a ranged remote request."""
|
|
177
|
+
header = self._get_remote_header_bytes(url)
|
|
178
|
+
if not header:
|
|
179
|
+
return None
|
|
180
|
+
ext = os.path.splitext(name)[1].lower().lstrip(".") or None
|
|
181
|
+
return self._read_dimensions_from_bytes(header, ext)
|
|
182
|
+
|
|
183
|
+
def _progress(self, done: int, total: int, label: str) -> None:
|
|
184
|
+
if total <= 0:
|
|
185
|
+
return
|
|
186
|
+
bar_len = 24
|
|
187
|
+
filled = int(bar_len * done / total)
|
|
188
|
+
bar = "#" * filled + "-" * (bar_len - filled)
|
|
189
|
+
pct = (done / total) * 100
|
|
190
|
+
label_part = f" ({label})" if label else ""
|
|
191
|
+
msg = f"[lenslet] Indexing{label_part}: [{bar}] {done}/{total} ({pct:5.1f}%)"
|
|
192
|
+
end = "\n" if done >= total else "\r"
|
|
193
|
+
print(msg, end=end, file=sys.stderr, flush=True)
|
|
194
|
+
|
|
195
|
+
def _effective_remote_workers(self, total: int) -> int:
|
|
196
|
+
if total <= 0:
|
|
197
|
+
return 0
|
|
198
|
+
cpu = os.cpu_count() or 1
|
|
199
|
+
return max(1, min(self.REMOTE_DIM_WORKERS, cpu, total))
|
|
200
|
+
|
|
201
|
+
def _probe_remote_dimensions(self, tasks: list[tuple[str, CachedItem, str, str]], label: str) -> None:
|
|
202
|
+
"""Fetch remote dimensions in parallel and update cached items."""
|
|
203
|
+
total = len(tasks)
|
|
204
|
+
if total == 0:
|
|
205
|
+
return
|
|
206
|
+
workers = self._effective_remote_workers(total)
|
|
207
|
+
done = 0
|
|
208
|
+
last_print = 0.0
|
|
209
|
+
progress_label = label
|
|
210
|
+
|
|
211
|
+
def _work(task: tuple[str, CachedItem, str, str]):
|
|
212
|
+
logical_path, item, url, name = task
|
|
213
|
+
dims = self._get_remote_dimensions(url, name)
|
|
214
|
+
return logical_path, item, dims
|
|
215
|
+
|
|
216
|
+
with ThreadPoolExecutor(max_workers=workers) as executor:
|
|
217
|
+
futures = [executor.submit(_work, task) for task in tasks]
|
|
218
|
+
for future in as_completed(futures):
|
|
219
|
+
logical_path, item, dims = future.result()
|
|
220
|
+
if dims:
|
|
221
|
+
self._dimensions[logical_path] = dims
|
|
222
|
+
item.width, item.height = dims
|
|
223
|
+
done += 1
|
|
224
|
+
now = time.monotonic()
|
|
225
|
+
if now - last_print > 0.1 or done == total:
|
|
226
|
+
self._progress(done, total, progress_label)
|
|
227
|
+
last_print = now
|
|
228
|
+
|
|
229
|
+
def _build_all_indexes(self):
|
|
230
|
+
"""Build indexes for all datasets."""
|
|
231
|
+
# Root index contains dataset folders
|
|
232
|
+
root_dirs = list(self.datasets.keys())
|
|
233
|
+
self._indexes["/"] = CachedIndex(
|
|
234
|
+
path="/",
|
|
235
|
+
generated_at=datetime.now(timezone.utc).isoformat(),
|
|
236
|
+
items=[],
|
|
237
|
+
dirs=root_dirs,
|
|
238
|
+
)
|
|
239
|
+
|
|
240
|
+
# Build index for each dataset
|
|
241
|
+
for dataset_name, paths in self.datasets.items():
|
|
242
|
+
items = []
|
|
243
|
+
remote_tasks: list[tuple[str, CachedItem, str, str]] = []
|
|
244
|
+
for source_path in paths:
|
|
245
|
+
name = self._extract_name(source_path)
|
|
246
|
+
if not self._is_supported_image(name):
|
|
247
|
+
continue
|
|
248
|
+
|
|
249
|
+
# Create logical path: /dataset_name/filename
|
|
250
|
+
logical_path = f"/{dataset_name}/{name}"
|
|
251
|
+
|
|
252
|
+
# Determine source type
|
|
253
|
+
is_s3 = self._is_s3_uri(source_path)
|
|
254
|
+
is_http = self._is_http_url(source_path)
|
|
255
|
+
url = None
|
|
256
|
+
size = 0
|
|
257
|
+
|
|
258
|
+
if is_s3:
|
|
259
|
+
# For S3, get presigned URL
|
|
260
|
+
try:
|
|
261
|
+
url = self._get_presigned_url(source_path)
|
|
262
|
+
# We can't easily get size without fetching, use 0 for now
|
|
263
|
+
size = 0
|
|
264
|
+
except Exception as e:
|
|
265
|
+
print(f"[lenslet] Warning: Failed to presign {source_path}: {e}")
|
|
266
|
+
continue
|
|
267
|
+
elif is_http:
|
|
268
|
+
# Plain HTTP/HTTPS URL — use as-is
|
|
269
|
+
url = source_path
|
|
270
|
+
size = 0
|
|
271
|
+
else:
|
|
272
|
+
# For local, validate and get size
|
|
273
|
+
if not os.path.exists(source_path):
|
|
274
|
+
print(f"[lenslet] Warning: File not found: {source_path}")
|
|
275
|
+
continue
|
|
276
|
+
try:
|
|
277
|
+
size = os.path.getsize(source_path)
|
|
278
|
+
except Exception:
|
|
279
|
+
size = 0
|
|
280
|
+
|
|
281
|
+
mime = self._guess_mime(name)
|
|
282
|
+
mtime = time.time()
|
|
283
|
+
width, height = 0, 0
|
|
284
|
+
|
|
285
|
+
# Create cached item
|
|
286
|
+
item = CachedItem(
|
|
287
|
+
path=logical_path,
|
|
288
|
+
name=name,
|
|
289
|
+
mime=mime,
|
|
290
|
+
width=width,
|
|
291
|
+
height=height,
|
|
292
|
+
size=size,
|
|
293
|
+
mtime=mtime,
|
|
294
|
+
url=url, # For S3 images
|
|
295
|
+
)
|
|
296
|
+
|
|
297
|
+
items.append(item)
|
|
298
|
+
self._items[logical_path] = item
|
|
299
|
+
if url:
|
|
300
|
+
remote_tasks.append((logical_path, item, url, name))
|
|
301
|
+
|
|
302
|
+
# Store source path mapping
|
|
303
|
+
if not hasattr(self, '_source_paths'):
|
|
304
|
+
self._source_paths = {}
|
|
305
|
+
self._source_paths[logical_path] = source_path
|
|
306
|
+
|
|
307
|
+
if remote_tasks:
|
|
308
|
+
self._probe_remote_dimensions(remote_tasks, f"remote headers: {dataset_name}")
|
|
309
|
+
|
|
310
|
+
# Create dataset index
|
|
311
|
+
dataset_path = f"/{dataset_name}"
|
|
312
|
+
self._indexes[dataset_path] = CachedIndex(
|
|
313
|
+
path=dataset_path,
|
|
314
|
+
generated_at=datetime.now(timezone.utc).isoformat(),
|
|
315
|
+
items=items,
|
|
316
|
+
dirs=[],
|
|
317
|
+
)
|
|
318
|
+
|
|
319
|
+
def _normalize_path(self, path: str) -> str:
|
|
320
|
+
"""Normalize path for consistent cache keys."""
|
|
321
|
+
p = path.strip("/")
|
|
322
|
+
return "/" + p if p else "/"
|
|
323
|
+
|
|
324
|
+
def get_index(self, path: str) -> CachedIndex:
|
|
325
|
+
"""Get cached index for a folder."""
|
|
326
|
+
norm = self._normalize_path(path)
|
|
327
|
+
if norm in self._indexes:
|
|
328
|
+
return self._indexes[norm]
|
|
329
|
+
raise FileNotFoundError(f"Dataset not found: {path}")
|
|
330
|
+
|
|
331
|
+
def validate_image_path(self, path: str) -> None:
|
|
332
|
+
"""Ensure path is valid and exists in our dataset."""
|
|
333
|
+
if not path or path not in self._items:
|
|
334
|
+
raise FileNotFoundError(path)
|
|
335
|
+
|
|
336
|
+
def get_source_path(self, logical_path: str) -> str:
|
|
337
|
+
"""Get original source path/URI for a logical path."""
|
|
338
|
+
if not hasattr(self, '_source_paths'):
|
|
339
|
+
raise FileNotFoundError(logical_path)
|
|
340
|
+
if logical_path not in self._source_paths:
|
|
341
|
+
raise FileNotFoundError(logical_path)
|
|
342
|
+
return self._source_paths[logical_path]
|
|
343
|
+
|
|
344
|
+
def read_bytes(self, path: str) -> bytes:
|
|
345
|
+
"""Read file contents. For S3, downloads from presigned URL. For local, reads file."""
|
|
346
|
+
source_path = self.get_source_path(path)
|
|
347
|
+
item = self._items[path]
|
|
348
|
+
|
|
349
|
+
if item.url: # S3 image
|
|
350
|
+
import urllib.request
|
|
351
|
+
try:
|
|
352
|
+
with urllib.request.urlopen(item.url) as response:
|
|
353
|
+
return response.read()
|
|
354
|
+
except Exception as e:
|
|
355
|
+
raise RuntimeError(f"Failed to download from S3: {e}")
|
|
356
|
+
else: # Local file
|
|
357
|
+
with open(source_path, "rb") as f:
|
|
358
|
+
return f.read()
|
|
359
|
+
|
|
360
|
+
def exists(self, path: str) -> bool:
|
|
361
|
+
"""Check if path exists in dataset."""
|
|
362
|
+
return path in self._items
|
|
363
|
+
|
|
364
|
+
def size(self, path: str) -> int:
|
|
365
|
+
"""Get file size."""
|
|
366
|
+
if path in self._items:
|
|
367
|
+
return self._items[path].size
|
|
368
|
+
return 0
|
|
369
|
+
|
|
370
|
+
def join(self, *parts: str) -> str:
|
|
371
|
+
"""Join path parts."""
|
|
372
|
+
return "/" + "/".join(p.strip("/") for p in parts if p.strip("/"))
|
|
373
|
+
|
|
374
|
+
def etag(self, path: str) -> str | None:
|
|
375
|
+
"""Get ETag for caching."""
|
|
376
|
+
if path in self._items:
|
|
377
|
+
item = self._items[path]
|
|
378
|
+
return f"{int(item.mtime)}-{item.size}"
|
|
379
|
+
return None
|
|
380
|
+
|
|
381
|
+
def get_thumbnail(self, path: str) -> bytes | None:
|
|
382
|
+
"""Get thumbnail, generating if needed."""
|
|
383
|
+
if path in self._thumbnails:
|
|
384
|
+
return self._thumbnails[path]
|
|
385
|
+
|
|
386
|
+
try:
|
|
387
|
+
raw = self.read_bytes(path)
|
|
388
|
+
thumb, dims = self._make_thumbnail(raw)
|
|
389
|
+
self._thumbnails[path] = thumb
|
|
390
|
+
if dims:
|
|
391
|
+
self._dimensions[path] = dims
|
|
392
|
+
return thumb
|
|
393
|
+
except Exception as e:
|
|
394
|
+
print(f"[lenslet] Failed to generate thumbnail for {path}: {e}")
|
|
395
|
+
return None
|
|
396
|
+
|
|
397
|
+
def _make_thumbnail(self, img_bytes: bytes) -> tuple[bytes, tuple[int, int] | None]:
|
|
398
|
+
"""Generate a WebP thumbnail. Returns (thumb_bytes, (w, h))."""
|
|
399
|
+
with Image.open(BytesIO(img_bytes)) as im:
|
|
400
|
+
w, h = im.size
|
|
401
|
+
short = min(w, h)
|
|
402
|
+
if short > self.thumb_size:
|
|
403
|
+
scale = self.thumb_size / short
|
|
404
|
+
new_w = max(1, int(w * scale))
|
|
405
|
+
new_h = max(1, int(h * scale))
|
|
406
|
+
im = im.convert("RGB").resize((new_w, new_h), Image.LANCZOS)
|
|
407
|
+
else:
|
|
408
|
+
im = im.convert("RGB")
|
|
409
|
+
out = BytesIO()
|
|
410
|
+
im.save(out, format="WEBP", quality=self.thumb_quality, method=6)
|
|
411
|
+
return out.getvalue(), (w, h)
|
|
412
|
+
|
|
413
|
+
def get_dimensions(self, path: str) -> tuple[int, int]:
|
|
414
|
+
"""Get image dimensions, loading lazily if needed."""
|
|
415
|
+
if path in self._dimensions:
|
|
416
|
+
return self._dimensions[path]
|
|
417
|
+
|
|
418
|
+
item = self._items.get(path)
|
|
419
|
+
if item and item.url:
|
|
420
|
+
dims = self._get_remote_dimensions(item.url, item.name)
|
|
421
|
+
if dims:
|
|
422
|
+
self._dimensions[path] = dims
|
|
423
|
+
self._items[path].width = dims[0]
|
|
424
|
+
self._items[path].height = dims[1]
|
|
425
|
+
return dims
|
|
426
|
+
|
|
427
|
+
try:
|
|
428
|
+
raw = self.read_bytes(path)
|
|
429
|
+
with Image.open(BytesIO(raw)) as im:
|
|
430
|
+
w, h = im.size
|
|
431
|
+
self._dimensions[path] = (w, h)
|
|
432
|
+
|
|
433
|
+
# Update item dimensions
|
|
434
|
+
if path in self._items:
|
|
435
|
+
self._items[path].width = w
|
|
436
|
+
self._items[path].height = h
|
|
437
|
+
|
|
438
|
+
return w, h
|
|
439
|
+
except Exception:
|
|
440
|
+
return 0, 0
|
|
441
|
+
|
|
442
|
+
def get_metadata(self, path: str) -> dict:
|
|
443
|
+
"""Get metadata for an image."""
|
|
444
|
+
if path in self._metadata:
|
|
445
|
+
return self._metadata[path]
|
|
446
|
+
|
|
447
|
+
# Get dimensions if available
|
|
448
|
+
w, h = self._dimensions.get(path, (0, 0))
|
|
449
|
+
if (w == 0 or h == 0) and path in self._items:
|
|
450
|
+
w = self._items[path].width
|
|
451
|
+
h = self._items[path].height
|
|
452
|
+
|
|
453
|
+
meta = {
|
|
454
|
+
"width": w,
|
|
455
|
+
"height": h,
|
|
456
|
+
"tags": [],
|
|
457
|
+
"notes": "",
|
|
458
|
+
"star": None,
|
|
459
|
+
}
|
|
460
|
+
self._metadata[path] = meta
|
|
461
|
+
return meta
|
|
462
|
+
|
|
463
|
+
def set_metadata(self, path: str, meta: dict) -> None:
|
|
464
|
+
"""Update in-memory metadata (session-only)."""
|
|
465
|
+
self._metadata[path] = meta
|
|
466
|
+
|
|
467
|
+
def search(self, query: str = "", path: str = "/", limit: int = 100) -> list[CachedItem]:
|
|
468
|
+
"""Simple in-memory search."""
|
|
469
|
+
q = (query or "").lower()
|
|
470
|
+
norm = self._normalize_path(path)
|
|
471
|
+
|
|
472
|
+
results = []
|
|
473
|
+
for item in self._items.values():
|
|
474
|
+
# Filter by path scope
|
|
475
|
+
if norm != "/" and not item.path.startswith(norm + "/"):
|
|
476
|
+
continue
|
|
477
|
+
|
|
478
|
+
# Search in name and metadata
|
|
479
|
+
meta = self.get_metadata(item.path)
|
|
480
|
+
haystack = " ".join([
|
|
481
|
+
item.name,
|
|
482
|
+
" ".join(meta.get("tags", [])),
|
|
483
|
+
meta.get("notes", ""),
|
|
484
|
+
]).lower()
|
|
485
|
+
|
|
486
|
+
if q in haystack:
|
|
487
|
+
results.append(item)
|
|
488
|
+
if len(results) >= limit:
|
|
489
|
+
break
|
|
490
|
+
|
|
491
|
+
return results
|
|
492
|
+
|
|
493
|
+
def _jpeg_dimensions(self, f) -> tuple[int, int] | None:
|
|
494
|
+
"""Read JPEG dimensions from SOF marker."""
|
|
495
|
+
f.seek(0)
|
|
496
|
+
if f.read(2) != b"\xff\xd8":
|
|
497
|
+
return None
|
|
498
|
+
while True:
|
|
499
|
+
marker = f.read(2)
|
|
500
|
+
if len(marker) < 2 or marker[0] != 0xff:
|
|
501
|
+
return None
|
|
502
|
+
if marker[1] == 0xd9: # EOI
|
|
503
|
+
return None
|
|
504
|
+
if 0xc0 <= marker[1] <= 0xcf and marker[1] not in (0xc4, 0xc8, 0xcc):
|
|
505
|
+
length_bytes = f.read(2)
|
|
506
|
+
if len(length_bytes) < 2:
|
|
507
|
+
return None
|
|
508
|
+
_ = struct.unpack(">H", length_bytes)[0]
|
|
509
|
+
f.read(1) # precision
|
|
510
|
+
size = f.read(4)
|
|
511
|
+
if len(size) < 4:
|
|
512
|
+
return None
|
|
513
|
+
h, w = struct.unpack(">HH", size)
|
|
514
|
+
return w, h
|
|
515
|
+
length_bytes = f.read(2)
|
|
516
|
+
if len(length_bytes) < 2:
|
|
517
|
+
return None
|
|
518
|
+
length = struct.unpack(">H", length_bytes)[0]
|
|
519
|
+
if length < 2:
|
|
520
|
+
return None
|
|
521
|
+
f.seek(length - 2, 1)
|
|
522
|
+
|
|
523
|
+
def _png_dimensions(self, f) -> tuple[int, int] | None:
|
|
524
|
+
"""Read PNG dimensions from IHDR chunk."""
|
|
525
|
+
f.seek(0)
|
|
526
|
+
if f.read(8) != b"\x89PNG\r\n\x1a\n":
|
|
527
|
+
return None
|
|
528
|
+
if len(f.read(4)) < 4:
|
|
529
|
+
return None
|
|
530
|
+
if f.read(4) != b"IHDR":
|
|
531
|
+
return None
|
|
532
|
+
data = f.read(8)
|
|
533
|
+
if len(data) < 8:
|
|
534
|
+
return None
|
|
535
|
+
w, h = struct.unpack(">II", data)
|
|
536
|
+
return w, h
|
|
537
|
+
|
|
538
|
+
def _webp_dimensions(self, f) -> tuple[int, int] | None:
|
|
539
|
+
"""Read WebP dimensions from header."""
|
|
540
|
+
f.seek(0)
|
|
541
|
+
if f.read(4) != b"RIFF":
|
|
542
|
+
return None
|
|
543
|
+
if len(f.read(4)) < 4:
|
|
544
|
+
return None
|
|
545
|
+
if f.read(4) != b"WEBP":
|
|
546
|
+
return None
|
|
547
|
+
chunk = f.read(4)
|
|
548
|
+
if chunk == b"VP8 ":
|
|
549
|
+
if len(f.read(4)) < 4:
|
|
550
|
+
return None
|
|
551
|
+
f.read(3)
|
|
552
|
+
if f.read(3) != b"\x9d\x01\x2a":
|
|
553
|
+
return None
|
|
554
|
+
data = f.read(4)
|
|
555
|
+
if len(data) < 4:
|
|
556
|
+
return None
|
|
557
|
+
w = (data[0] | (data[1] << 8)) & 0x3FFF
|
|
558
|
+
h = (data[2] | (data[3] << 8)) & 0x3FFF
|
|
559
|
+
return w, h
|
|
560
|
+
if chunk == b"VP8L":
|
|
561
|
+
if len(f.read(4)) < 4:
|
|
562
|
+
return None
|
|
563
|
+
if f.read(1) != b"\x2f":
|
|
564
|
+
return None
|
|
565
|
+
data = f.read(4)
|
|
566
|
+
if len(data) < 4:
|
|
567
|
+
return None
|
|
568
|
+
val = struct.unpack("<I", data)[0]
|
|
569
|
+
w = (val & 0x3FFF) + 1
|
|
570
|
+
h = ((val >> 14) & 0x3FFF) + 1
|
|
571
|
+
return w, h
|
|
572
|
+
if chunk == b"VP8X":
|
|
573
|
+
if len(f.read(4)) < 4:
|
|
574
|
+
return None
|
|
575
|
+
f.read(4)
|
|
576
|
+
data = f.read(6)
|
|
577
|
+
if len(data) < 6:
|
|
578
|
+
return None
|
|
579
|
+
w = (data[0] | (data[1] << 8) | (data[2] << 16)) + 1
|
|
580
|
+
h = (data[3] | (data[4] << 8) | (data[5] << 16)) + 1
|
|
581
|
+
return w, h
|
|
582
|
+
return None
|
|
583
|
+
|
|
584
|
+
def _guess_mime(self, name: str) -> str:
|
|
585
|
+
"""Guess MIME type from filename."""
|
|
586
|
+
n = name.lower()
|
|
587
|
+
if n.endswith(".webp"):
|
|
588
|
+
return "image/webp"
|
|
589
|
+
if n.endswith(".png"):
|
|
590
|
+
return "image/png"
|
|
591
|
+
return "image/jpeg"
|
lenslet/storage/local.py
ADDED
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
import os
|
|
3
|
+
from .base import Storage
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class LocalStorage(Storage):
|
|
7
|
+
"""Read-only local filesystem storage (does not write sidecars/indexes)."""
|
|
8
|
+
|
|
9
|
+
def __init__(self, root: str):
|
|
10
|
+
self.root = os.path.abspath(root)
|
|
11
|
+
self._root_real = os.path.realpath(self.root)
|
|
12
|
+
|
|
13
|
+
@property
|
|
14
|
+
def root_real(self) -> str:
|
|
15
|
+
"""Real path to the configured root (exposed for fast stat calls)."""
|
|
16
|
+
return self._root_real
|
|
17
|
+
|
|
18
|
+
def resolve_path(self, path: str) -> str:
|
|
19
|
+
"""Convert relative path to absolute, with security checks."""
|
|
20
|
+
candidate = os.path.abspath(os.path.join(self._root_real, path.lstrip("/")))
|
|
21
|
+
real = os.path.realpath(candidate)
|
|
22
|
+
try:
|
|
23
|
+
common = os.path.commonpath([self._root_real, real])
|
|
24
|
+
except Exception:
|
|
25
|
+
raise ValueError("invalid path")
|
|
26
|
+
if common != self._root_real:
|
|
27
|
+
raise ValueError("invalid path")
|
|
28
|
+
return real
|
|
29
|
+
|
|
30
|
+
def list_dir(self, path: str) -> tuple[list[str], list[str]]:
|
|
31
|
+
p = self.resolve_path(path)
|
|
32
|
+
files, dirs = [], []
|
|
33
|
+
for name in os.listdir(p):
|
|
34
|
+
# Skip hidden files and our own metadata files
|
|
35
|
+
if name.startswith("."):
|
|
36
|
+
continue
|
|
37
|
+
full = os.path.join(p, name)
|
|
38
|
+
if os.path.isdir(full):
|
|
39
|
+
dirs.append(name)
|
|
40
|
+
else:
|
|
41
|
+
files.append(name)
|
|
42
|
+
return files, dirs
|
|
43
|
+
|
|
44
|
+
def read_bytes(self, path: str) -> bytes:
|
|
45
|
+
with open(self.resolve_path(path), "rb") as f:
|
|
46
|
+
return f.read()
|
|
47
|
+
|
|
48
|
+
def write_bytes(self, path: str, data: bytes) -> None:
|
|
49
|
+
# No-op in clean mode - we don't write to the source directory
|
|
50
|
+
pass
|
|
51
|
+
|
|
52
|
+
def exists(self, path: str) -> bool:
|
|
53
|
+
try:
|
|
54
|
+
return os.path.exists(self.resolve_path(path))
|
|
55
|
+
except ValueError:
|
|
56
|
+
return False
|
|
57
|
+
|
|
58
|
+
def size(self, path: str) -> int:
|
|
59
|
+
return os.path.getsize(self.resolve_path(path))
|
|
60
|
+
|
|
61
|
+
def join(self, *parts: str) -> str:
|
|
62
|
+
return "/".join([p.strip("/") for p in parts if p])
|
|
63
|
+
|
|
64
|
+
def etag(self, path: str) -> str | None:
|
|
65
|
+
try:
|
|
66
|
+
st = os.stat(self.resolve_path(path))
|
|
67
|
+
return f"{st.st_mtime_ns}-{st.st_size}"
|
|
68
|
+
except FileNotFoundError:
|
|
69
|
+
return None
|