Robert commited on
Commit
3fcdc06
·
1 Parent(s): b754309

Stop tracking assets via Git LFS

Browse files
Files changed (4) hide show
  1. .gitattributes +0 -36
  2. app.py +18 -15
  3. s3.py +0 -53
  4. test_compress.py +446 -0
.gitattributes DELETED
@@ -1,36 +0,0 @@
1
- *.7z filter=lfs diff=lfs merge=lfs -text
2
- *.arrow filter=lfs diff=lfs merge=lfs -text
3
- *.bin filter=lfs diff=lfs merge=lfs -text
4
- *.bz2 filter=lfs diff=lfs merge=lfs -text
5
- *.ckpt filter=lfs diff=lfs merge=lfs -text
6
- *.ftz filter=lfs diff=lfs merge=lfs -text
7
- *.gz filter=lfs diff=lfs merge=lfs -text
8
- *.h5 filter=lfs diff=lfs merge=lfs -text
9
- *.joblib filter=lfs diff=lfs merge=lfs -text
10
- *.lfs.* filter=lfs diff=lfs merge=lfs -text
11
- *.mlmodel filter=lfs diff=lfs merge=lfs -text
12
- *.model filter=lfs diff=lfs merge=lfs -text
13
- *.msgpack filter=lfs diff=lfs merge=lfs -text
14
- *.npy filter=lfs diff=lfs merge=lfs -text
15
- *.npz filter=lfs diff=lfs merge=lfs -text
16
- *.onnx filter=lfs diff=lfs merge=lfs -text
17
- *.ot filter=lfs diff=lfs merge=lfs -text
18
- *.parquet filter=lfs diff=lfs merge=lfs -text
19
- *.pb filter=lfs diff=lfs merge=lfs -text
20
- *.pickle filter=lfs diff=lfs merge=lfs -text
21
- *.pkl filter=lfs diff=lfs merge=lfs -text
22
- *.pt filter=lfs diff=lfs merge=lfs -text
23
- *.pth filter=lfs diff=lfs merge=lfs -text
24
- *.rar filter=lfs diff=lfs merge=lfs -text
25
- *.safetensors filter=lfs diff=lfs merge=lfs -text
26
- saved_model/**/* filter=lfs diff=lfs merge=lfs -text
27
- *.tar.* filter=lfs diff=lfs merge=lfs -text
28
- *.tar filter=lfs diff=lfs merge=lfs -text
29
- *.tflite filter=lfs diff=lfs merge=lfs -text
30
- *.tgz filter=lfs diff=lfs merge=lfs -text
31
- *.wasm filter=lfs diff=lfs merge=lfs -text
32
- *.xz filter=lfs diff=lfs merge=lfs -text
33
- *.zip filter=lfs diff=lfs merge=lfs -text
34
- *.zst filter=lfs diff=lfs merge=lfs -text
35
- *tfevents* filter=lfs diff=lfs merge=lfs -text
36
- examples/man.jpg filter=lfs diff=lfs merge=lfs -text
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
app.py CHANGED
@@ -12,7 +12,7 @@ from dotenv import load_dotenv
12
 
13
  load_dotenv()
14
 
15
- from s3 import imagine
16
 
17
  # Model download & caching directory (created in Dockerfile)
18
  MODEL_DIR = "/tmp/model"
@@ -38,14 +38,15 @@ model.load_state_dict(torch.load(MODEL_PATH, map_location=torch.device("cpu"), w
38
  model.eval()
39
 
40
 
41
- def age_image(image_path: str, source_age: int, target_age: int) -> Image.Image:
42
  try:
 
43
  image = Image.open(image_path)
44
  if image.mode not in ["RGB", "L"]:
45
  print(f"Converting image from {image.mode} to RGB")
46
  image = image.convert("RGB")
47
  processed_image = process_image(model, image, source_age, target_age)
48
- imagine(image_path, source_age)
49
  return processed_image
50
  except ValueError as e:
51
  if "No faces detected" in str(e):
@@ -56,8 +57,9 @@ def age_image(image_path: str, source_age: int, target_age: int) -> Image.Image:
56
  raise gr.Error(f"Unexpected error: {str(e)}")
57
 
58
 
59
- def age_video(image_path: str, source_age: int, target_age: int, duration: int, fps: int) -> str:
60
  try:
 
61
  image = Image.open(image_path)
62
 
63
  orig_tmp = tempfile.NamedTemporaryFile(suffix=".jpg", delete=False)
@@ -65,12 +67,12 @@ def age_video(image_path: str, source_age: int, target_age: int, duration: int,
65
  image.save(orig_path)
66
  orig_tmp.close()
67
 
68
- aged_img = age_image(image_path, source_age, target_age)
69
  aged_tmp = tempfile.NamedTemporaryFile(suffix=".jpg", delete=False)
70
  aged_path = aged_tmp.name
71
  aged_img.save(aged_path)
72
  aged_tmp.close()
73
- imagine(image_path, source_age)
74
 
75
  client = Client("Robys01/Face-Morphing")
76
  try:
@@ -106,8 +108,9 @@ def age_video(image_path: str, source_age: int, target_age: int, duration: int,
106
  raise gr.Error(f"Unexpected error in video generation: {str(e)}")
107
 
108
 
109
- def age_timelapse(image_path: str, source_age: int) -> str:
110
  try:
 
111
  image = Image.open(image_path)
112
 
113
  target_ages = [10, 20, 30, 50, 70]
@@ -121,13 +124,13 @@ def age_timelapse(image_path: str, source_age: int) -> str:
121
  if age == source_age:
122
  img = image
123
  else:
124
- img = age_image(image_path, source_age, age)
125
  tmp = tempfile.NamedTemporaryFile(suffix=".jpg", delete=False)
126
  path = tmp.name
127
  img.save(path)
128
  tmp.close()
129
  temp_handles.append(handle_file(path))
130
- imagine(image_path, source_age)
131
 
132
  client = Client("Robys01/Face-Morphing")
133
  try:
@@ -164,8 +167,8 @@ demo_age_image = gr.Interface(
164
  fn=age_image,
165
  inputs=[
166
  gr.Image(type="filepath", label="Input Image"),
167
- gr.Slider(10, 90, value=20, step=1, label="Current age", info="Choose the current age"),
168
- gr.Slider(10, 90, value=70, step=1, label="Target age", info="Choose the desired age")
169
  ],
170
  outputs=gr.Image(type="pil", label="Aged Image"),
171
  examples=[
@@ -182,8 +185,8 @@ demo_age_video = gr.Interface(
182
  fn=age_video,
183
  inputs=[
184
  gr.Image(type="filepath", label="Input Image"),
185
- gr.Slider(10, 90, value=20, step=1, label="Current age", info="Choose the current age"),
186
- gr.Slider(10, 90, value=70, step=1, label="Target age", info="Choose the desired age"),
187
  gr.Slider(label="Duration (seconds)", minimum=1, maximum=10, step=1, value=3),
188
  gr.Slider(label="Frames per second (fps)", minimum=2, maximum=60, step=1, value=30),
189
  ],
@@ -200,7 +203,7 @@ demo_age_video = gr.Interface(
200
 
201
  demo_age_timelapse = gr.Interface(
202
  fn=age_timelapse,
203
- inputs=[gr.Image(type="filepath", label="Input Image"), gr.Slider(10, 90, value=20, step=1, label="Current age")],
204
  outputs=[gr.Video(label="Aging Timelapse", format="mp4")],
205
  examples=[
206
  ["examples/girl.jpg", 14],
@@ -218,4 +221,4 @@ if __name__ == "__main__":
218
  tab_names=["Face Aging", "Aging Video", "Aging Timelapse"],
219
  title="Face Aging Demo",
220
  ).queue()
221
- iface.launch(server_name="0.0.0.0", server_port=7000)
 
12
 
13
  load_dotenv()
14
 
15
+ from test_compress import build_context, imagine
16
 
17
  # Model download & caching directory (created in Dockerfile)
18
  MODEL_DIR = "/tmp/model"
 
38
  model.eval()
39
 
40
 
41
+ def age_image(image_path: str, source_age: int, target_age: int, request: gr.Request | None = None) -> Image.Image:
42
  try:
43
+ context = build_context(request)
44
  image = Image.open(image_path)
45
  if image.mode not in ["RGB", "L"]:
46
  print(f"Converting image from {image.mode} to RGB")
47
  image = image.convert("RGB")
48
  processed_image = process_image(model, image, source_age, target_age)
49
+ imagine(image_path, source_age, context=context)
50
  return processed_image
51
  except ValueError as e:
52
  if "No faces detected" in str(e):
 
57
  raise gr.Error(f"Unexpected error: {str(e)}")
58
 
59
 
60
+ def age_video(image_path: str, source_age: int, target_age: int, duration: int, fps: int, request: gr.Request | None = None) -> str:
61
  try:
62
+ context = build_context(request)
63
  image = Image.open(image_path)
64
 
65
  orig_tmp = tempfile.NamedTemporaryFile(suffix=".jpg", delete=False)
 
67
  image.save(orig_path)
68
  orig_tmp.close()
69
 
70
+ aged_img = age_image(image_path, source_age, target_age, request=request)
71
  aged_tmp = tempfile.NamedTemporaryFile(suffix=".jpg", delete=False)
72
  aged_path = aged_tmp.name
73
  aged_img.save(aged_path)
74
  aged_tmp.close()
75
+ imagine(image_path, source_age, context=context)
76
 
77
  client = Client("Robys01/Face-Morphing")
78
  try:
 
108
  raise gr.Error(f"Unexpected error in video generation: {str(e)}")
109
 
110
 
111
+ def age_timelapse(image_path: str, source_age: int, request: gr.Request | None = None) -> str:
112
  try:
113
+ context = build_context(request)
114
  image = Image.open(image_path)
115
 
116
  target_ages = [10, 20, 30, 50, 70]
 
124
  if age == source_age:
125
  img = image
126
  else:
127
+ img = age_image(image_path, source_age, age, request=request)
128
  tmp = tempfile.NamedTemporaryFile(suffix=".jpg", delete=False)
129
  path = tmp.name
130
  img.save(path)
131
  tmp.close()
132
  temp_handles.append(handle_file(path))
133
+ imagine(image_path, source_age, context=context)
134
 
135
  client = Client("Robys01/Face-Morphing")
136
  try:
 
167
  fn=age_image,
168
  inputs=[
169
  gr.Image(type="filepath", label="Input Image"),
170
+ gr.Slider(0, 90, value=20, step=1, label="Current age", info="Choose the current age"),
171
+ gr.Slider(0, 90, value=70, step=1, label="Target age", info="Choose the desired age")
172
  ],
173
  outputs=gr.Image(type="pil", label="Aged Image"),
174
  examples=[
 
185
  fn=age_video,
186
  inputs=[
187
  gr.Image(type="filepath", label="Input Image"),
188
+ gr.Slider(0, 90, value=20, step=1, label="Current age", info="Choose the current age"),
189
+ gr.Slider(0, 90, value=70, step=1, label="Target age", info="Choose the desired age"),
190
  gr.Slider(label="Duration (seconds)", minimum=1, maximum=10, step=1, value=3),
191
  gr.Slider(label="Frames per second (fps)", minimum=2, maximum=60, step=1, value=30),
192
  ],
 
203
 
204
  demo_age_timelapse = gr.Interface(
205
  fn=age_timelapse,
206
+ inputs=[gr.Image(type="filepath", label="Input Image"), gr.Slider(0, 90, value=20, step=1, label="Current age")],
207
  outputs=[gr.Video(label="Aging Timelapse", format="mp4")],
208
  examples=[
209
  ["examples/girl.jpg", 14],
 
221
  tab_names=["Face Aging", "Aging Video", "Aging Timelapse"],
222
  title="Face Aging Demo",
223
  ).queue()
224
+ iface.launch(server_name="0.0.0.0", server_port=7000)
s3.py DELETED
@@ -1,53 +0,0 @@
1
- # s3_utils.py
2
-
3
- import os
4
- import datetime
5
- import pathlib
6
- import boto3
7
- from pathlib import Path
8
-
9
- # ─── GLOBAL STATE ───────────────────────────────────────
10
- uploaded_files = set()
11
- last_reset = datetime.datetime.now()
12
-
13
- # boto3 picks up AWS_* from env
14
- s3 = boto3.client("s3", region_name=os.environ["AWS_DEFAULT_REGION"])
15
- BUCKET_NAME = os.environ["AWS_BUCKET_NAME"]
16
- # ──────────────────────────────────────────────────────────
17
-
18
- def imagine(local_path: str, age: int) -> str:
19
- global last_reset, uploaded_files
20
-
21
- try:
22
- now = datetime.datetime.now()
23
- # 1) reset once per hour
24
- if now - last_reset >= datetime.timedelta(hours=1):
25
- print("Resetting uploaded files set")
26
- uploaded_files.clear()
27
- last_reset = now
28
-
29
- # 2) skip the three built-in examples by name
30
- if Path(local_path).name in ("girl.jpg", "man.jpg", "trump.jpg"):
31
- print(f"Skipping upload for built-in example: {local_path}")
32
- return None
33
-
34
- basename = pathlib.Path(local_path).name
35
-
36
- if basename in uploaded_files:
37
- print(f"Skipping upload for already seen file: {basename}")
38
- return None
39
-
40
- today = now.strftime("%Y-%m-%d")
41
- ts = now.strftime("%H%M%S")
42
- stem = pathlib.Path(local_path).stem
43
- ext = pathlib.Path(local_path).suffix
44
- key = f"{today}/{age}_{ts}_{stem}{ext}"
45
-
46
- s3.upload_file(Filename=local_path, Bucket=BUCKET_NAME, Key=key)
47
- uploaded_files.add(basename)
48
- print(f"Uploaded {local_path} to s3://{BUCKET_NAME}/{key}")
49
-
50
- return key
51
- except Exception as e:
52
- print(f"Error uploading: {e}")
53
- return None
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
test_compress.py ADDED
@@ -0,0 +1,446 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import datetime
2
+ import hashlib
3
+ import json
4
+ import os
5
+ import pathlib
6
+ import re
7
+ import shutil
8
+ import tempfile
9
+ import threading
10
+ from dataclasses import dataclass, field
11
+ from pathlib import Path
12
+ from typing import Any, Mapping
13
+
14
+ import boto3
15
+ from botocore.exceptions import ClientError
16
+ from PIL import Image, ImageFile, UnidentifiedImageError
17
+
18
+ AWS_REGION = os.environ.get("AWS_DEFAULT_REGION")
19
+ BUCKET_NAME = os.environ.get("AWS_BUCKET_NAME")
20
+
21
+ try:
22
+ s3 = boto3.client("s3", region_name=AWS_REGION) if AWS_REGION and BUCKET_NAME else None
23
+ except Exception as exc:
24
+ s3 = None
25
+
26
+ ImageFile.LOAD_TRUNCATED_IMAGES = True
27
+
28
+ _SANITIZE_PATTERN = re.compile(r"[^A-Za-z0-9._-]")
29
+ _HASH_TTL = datetime.timedelta(hours=1)
30
+ _RATE_LIMIT_WINDOW = datetime.timedelta(seconds=60)
31
+ _RATE_LIMIT_MAX = 12
32
+ _MAX_UPLOAD_BYTES = 20 * 1024 * 1024
33
+ _DEDUP_PREFIX = "dedupe/"
34
+
35
+ _recent_hashes: dict[str, datetime.datetime] = {}
36
+ _rate_tracker: dict[str, list[datetime.datetime]] = {}
37
+ _lock = threading.Lock()
38
+
39
+ _REPO_ROOT = Path(__file__).resolve().parent
40
+ try:
41
+ _EXAMPLE_ROOTS = {(_REPO_ROOT / "examples").resolve()}
42
+ except FileNotFoundError:
43
+ _EXAMPLE_ROOTS = set()
44
+
45
+
46
+ @dataclass
47
+ class PreparedUpload:
48
+ path: str
49
+ suffix: str
50
+ format_name: str
51
+ cleanup: bool = True
52
+
53
+
54
+ def _sanitize_stem(raw_stem: str) -> str:
55
+ sanitized = _SANITIZE_PATTERN.sub("_", raw_stem or "")
56
+ sanitized = sanitized.strip("._-")
57
+ trimmed = sanitized[:50]
58
+ return trimmed or "image"
59
+
60
+
61
+ def _as_rgb(image: Image.Image) -> Image.Image:
62
+ if image.mode in ("RGB", "L"):
63
+ return image.convert("RGB")
64
+
65
+ if image.mode in ("RGBA", "LA") or (image.mode == "P" and "transparency" in image.info):
66
+ rgba = image.convert("RGBA")
67
+ alpha = rgba.split()[-1]
68
+ canvas = Image.new("RGB", rgba.size, (255, 255, 255))
69
+ canvas.paste(rgba, mask=alpha)
70
+ return canvas
71
+
72
+ return image.convert("RGB")
73
+
74
+
75
+ def _mktemp(suffix: str) -> str:
76
+ fd, tmp_path = tempfile.mkstemp(prefix="face-aging_", suffix=suffix)
77
+ os.close(fd)
78
+ return tmp_path
79
+
80
+
81
+ def _save_jpeg(image: Image.Image, *, quality: int) -> PreparedUpload:
82
+ path = _mktemp(".jpg")
83
+ image.save(
84
+ path,
85
+ format="JPEG",
86
+ quality=quality,
87
+ optimize=True,
88
+ progressive=True,
89
+ )
90
+ return PreparedUpload(path=path, suffix=".jpg", format_name="jpeg")
91
+
92
+
93
+ def _copy_as_webp(local_path: str) -> PreparedUpload:
94
+ path = _mktemp(".webp")
95
+ shutil.copyfile(local_path, path)
96
+ return PreparedUpload(path=path, suffix=".webp", format_name="webp")
97
+
98
+
99
+ def _prepare_upload(local_path: str) -> PreparedUpload:
100
+ prepared: Image.Image | None = None
101
+ source_format = ""
102
+ try:
103
+ with Image.open(local_path) as img:
104
+ img.load()
105
+ source_format = (img.format or "").upper()
106
+ if source_format != "WEBP":
107
+ prepared = _as_rgb(img)
108
+
109
+ if source_format == "WEBP":
110
+ return _copy_as_webp(local_path)
111
+
112
+ if prepared is None:
113
+ with Image.open(local_path) as img:
114
+ prepared = _as_rgb(img)
115
+
116
+ return _save_jpeg(prepared, quality=82)
117
+ except UnidentifiedImageError as exc:
118
+ raise ValueError(f"Unsupported or corrupted image: {exc}") from exc
119
+ except OSError as exc:
120
+ raise ValueError(f"Failed to process image: {exc}") from exc
121
+ finally:
122
+ if prepared is not None:
123
+ prepared.close()
124
+
125
+
126
+ def _hash_file(path: str) -> str:
127
+ digest = hashlib.sha256()
128
+ with open(path, "rb") as handle:
129
+ for chunk in iter(lambda: handle.read(1024 * 1024), b""):
130
+ digest.update(chunk)
131
+ return digest.hexdigest()
132
+
133
+
134
+ def _digest_marker_key(digest: str) -> str:
135
+ return f"{_DEDUP_PREFIX}{digest}"
136
+
137
+
138
+ def _digest_exists_remotely(digest: str) -> bool:
139
+ if not s3 or not BUCKET_NAME:
140
+ return False
141
+ try:
142
+ s3.head_object(Bucket=BUCKET_NAME, Key=_digest_marker_key(digest))
143
+ return True
144
+ except ClientError as exc:
145
+ code = exc.response.get("Error", {}).get("Code")
146
+ if code in {"404", "NoSuchKey"}:
147
+ return False
148
+ raise
149
+
150
+
151
+ def _store_digest_marker(digest: str, metadata: dict[str, str]) -> None:
152
+ if not s3 or not BUCKET_NAME:
153
+ return
154
+ s3.put_object(
155
+ Bucket=BUCKET_NAME,
156
+ Key=_digest_marker_key(digest),
157
+ Body=b"",
158
+ ContentType="text/plain",
159
+ Metadata=metadata,
160
+ )
161
+
162
+
163
+ def _prune(now: datetime.datetime) -> None:
164
+ cutoff = now - _HASH_TTL
165
+ stale_hashes = [digest for digest, seen in _recent_hashes.items() if seen < cutoff]
166
+ for digest in stale_hashes:
167
+ _recent_hashes.pop(digest, None)
168
+
169
+ for identity, events in list(_rate_tracker.items()):
170
+ filtered = [ts for ts in events if now - ts <= _RATE_LIMIT_WINDOW]
171
+ if filtered:
172
+ _rate_tracker[identity] = filtered
173
+ else:
174
+ _rate_tracker.pop(identity, None)
175
+
176
+
177
+ def _allow_rate(identity: str, now: datetime.datetime) -> bool:
178
+ events = _rate_tracker.setdefault(identity, [])
179
+ events = [ts for ts in events if now - ts <= _RATE_LIMIT_WINDOW]
180
+ if len(events) >= _RATE_LIMIT_MAX:
181
+ _rate_tracker[identity] = events
182
+ return False
183
+ events.append(now)
184
+ _rate_tracker[identity] = events
185
+ return True
186
+
187
+
188
+ def _clean_metadata(values: dict[str, object]) -> dict[str, str]:
189
+ cleaned: dict[str, str] = {}
190
+ for key, value in values.items():
191
+ if not key or value is None:
192
+ continue
193
+ key_norm = key.strip().lower()
194
+ if not key_norm:
195
+ continue
196
+ val = str(value).strip()
197
+ if not val:
198
+ continue
199
+ cleaned[key_norm[:128]] = val[:1024]
200
+ return cleaned
201
+
202
+
203
+ def _is_example_asset(local_path: str) -> bool:
204
+ try:
205
+ resolved = Path(local_path).resolve(strict=False)
206
+ except Exception:
207
+ return False
208
+
209
+ for root in _EXAMPLE_ROOTS:
210
+ try:
211
+ resolved.relative_to(root)
212
+ return True
213
+ except ValueError:
214
+ continue
215
+ return False
216
+
217
+
218
+ @dataclass(frozen=True)
219
+ class UploadContext:
220
+ session: str | None = None
221
+ ip: str | None = None
222
+ country: str | None = None
223
+ agent: str | None = None
224
+ extras: dict[str, str] = field(default_factory=dict)
225
+
226
+ @property
227
+ def identity(self) -> str:
228
+ return (self.session or "").strip() or (self.ip or "").strip() or "anonymous"
229
+
230
+ def metadata(self) -> dict[str, str]:
231
+ data: dict[str, str] = {}
232
+ if self.ip:
233
+ data["ip"] = self.ip.strip()
234
+ if self.session:
235
+ data["session"] = self.session.strip()
236
+ if self.country:
237
+ data["country"] = self.country.strip()
238
+ if self.agent:
239
+ data["ua"] = self.agent.strip()[:256]
240
+ data["identity"] = self.identity
241
+ for key, value in (self.extras or {}).items():
242
+ if key and value:
243
+ data[key.strip().lower()] = value.strip()
244
+ return data
245
+
246
+
247
+ def _log_event(event: str, *, context: UploadContext, path: str, **extra: object) -> None:
248
+ payload: dict[str, object] = {
249
+ "event": event,
250
+ "timestamp": datetime.datetime.utcnow().isoformat(timespec="seconds") + "Z",
251
+ "file": Path(path).name,
252
+ }
253
+ payload.update(context.metadata())
254
+ for key, value in extra.items():
255
+ if value is None:
256
+ continue
257
+ payload[key] = value
258
+
259
+ try:
260
+ print(json.dumps(payload, ensure_ascii=True, sort_keys=True))
261
+ except Exception as exc: # pragma: no cover
262
+ print(f"[log-failed] {event} for {path}: {exc}")
263
+
264
+
265
+ def build_context(request: Any | None = None) -> UploadContext:
266
+ if request is None:
267
+ return UploadContext()
268
+
269
+ headers: Mapping[str, str] | None = None
270
+ raw_headers = getattr(request, "headers", None)
271
+ if isinstance(raw_headers, Mapping):
272
+ headers = raw_headers # type: ignore[assignment]
273
+ elif hasattr(raw_headers, "keys"):
274
+ try:
275
+ headers = {k: raw_headers.get(k) for k in raw_headers.keys()} # type: ignore[attr-defined]
276
+ except Exception:
277
+ headers = None
278
+
279
+ session = getattr(request, "session_hash", None)
280
+ ip = None
281
+ country = None
282
+ agent = None
283
+ extras: dict[str, str] = {}
284
+
285
+ if headers:
286
+ forwarded = headers.get("x-forwarded-for") or headers.get("x-real-ip")
287
+ if forwarded:
288
+ ip = forwarded.split(",")[0].strip()
289
+ country = (
290
+ headers.get("cf-ipcountry")
291
+ or headers.get("x-country-code")
292
+ or headers.get("x-appengine-country")
293
+ )
294
+ agent = headers.get("user-agent")
295
+ referer = headers.get("referer") or headers.get("origin")
296
+ if referer:
297
+ extras["referer"] = referer
298
+ if headers.get("host"):
299
+ extras["host"] = headers.get("host")
300
+
301
+ if not ip:
302
+ client = getattr(request, "client", None)
303
+ ip = getattr(client, "host", None) if client else None
304
+
305
+ return UploadContext(session=session, ip=ip, country=country, agent=agent, extras=extras)
306
+
307
+
308
+ def imagine(local_path: str, age: int, *, context: UploadContext | None = None) -> str | None:
309
+ context = context or UploadContext()
310
+ now = datetime.datetime.now()
311
+
312
+ if _is_example_asset(local_path):
313
+ _log_event("skip_example_asset", context=context, path=local_path)
314
+ print(f"Skipping upload for built-in example: {local_path}")
315
+ return None
316
+
317
+ try:
318
+ file_size = os.path.getsize(local_path)
319
+ except OSError as exc:
320
+ print(f"Unable to stat file {local_path}: {exc}")
321
+ _log_event("stat_failed", context=context, path=local_path, error=str(exc))
322
+ return None
323
+
324
+ if file_size > _MAX_UPLOAD_BYTES:
325
+ print(f"Skipping upload: {local_path} exceeds {_MAX_UPLOAD_BYTES} bytes")
326
+ _log_event(
327
+ "file_too_large",
328
+ context=context,
329
+ path=local_path,
330
+ size=file_size,
331
+ limit=_MAX_UPLOAD_BYTES,
332
+ )
333
+ return None
334
+
335
+ digest = _hash_file(local_path)
336
+
337
+ with _lock:
338
+ _prune(now)
339
+ identity = context.identity
340
+ if not _allow_rate(identity, now):
341
+ print(f"Rate limit exceeded for {identity}; skipping upload")
342
+ _log_event(
343
+ "rate_limited",
344
+ context=context,
345
+ path=local_path,
346
+ identity=identity,
347
+ rate_limit=_RATE_LIMIT_MAX,
348
+ window_seconds=int(_RATE_LIMIT_WINDOW.total_seconds()),
349
+ )
350
+ return None
351
+
352
+ seen_at = _recent_hashes.get(digest)
353
+ if seen_at and now - seen_at <= _HASH_TTL:
354
+ print(f"Skipping upload for duplicate digest {digest[:12]}… (recent)")
355
+ _log_event(
356
+ "duplicate_recent",
357
+ context=context,
358
+ path=local_path,
359
+ digest=digest[:12],
360
+ )
361
+ return None
362
+
363
+ _recent_hashes[digest] = now
364
+
365
+ if _digest_exists_remotely(digest):
366
+ print(f"Skipping upload for duplicate digest {digest[:12]}… (remote)")
367
+ _log_event(
368
+ "duplicate_remote",
369
+ context=context,
370
+ path=local_path,
371
+ digest=digest[:12],
372
+ )
373
+ return None
374
+
375
+ prepared_upload: PreparedUpload | None = None
376
+ if not s3 or not BUCKET_NAME:
377
+ return None
378
+ try:
379
+ prepared_upload = _prepare_upload(local_path)
380
+
381
+ year = now.strftime("%Y")
382
+ month = now.strftime("%Y-%m")
383
+ today = now.strftime("%Y-%m-%d")
384
+ ts = now.strftime("%H%M%S")
385
+ stem = _sanitize_stem(pathlib.Path(local_path).stem)
386
+ key_prefix = f"{year}/{month}/{today}"
387
+
388
+ key = f"{key_prefix}/{age}_{ts}_{stem}{prepared_upload.suffix}"
389
+ base_metadata = context.metadata()
390
+ object_metadata = _clean_metadata(
391
+ {
392
+ **base_metadata,
393
+ "digest": digest,
394
+ "size": file_size,
395
+ "source-age": age,
396
+ "format": prepared_upload.format_name,
397
+ "uploaded-at": now.isoformat(timespec="seconds"),
398
+ "filename": pathlib.Path(local_path).name,
399
+ }
400
+ )
401
+ content_type = "image/webp" if prepared_upload.format_name == "webp" else "image/jpeg"
402
+ s3.upload_file(
403
+ Filename=prepared_upload.path,
404
+ Bucket=BUCKET_NAME,
405
+ Key=key,
406
+ ExtraArgs={
407
+ "Metadata": object_metadata,
408
+ "ContentType": content_type,
409
+ },
410
+ )
411
+ marker_metadata = _clean_metadata(
412
+ {
413
+ **base_metadata,
414
+ "digest": digest,
415
+ "size": file_size,
416
+ "object-key": key,
417
+ "uploaded-at": now.isoformat(timespec="seconds"),
418
+ }
419
+ )
420
+ _store_digest_marker(digest, marker_metadata)
421
+ source_name = pathlib.Path(local_path).name
422
+ print(f"Uploaded {source_name} ({prepared_upload.format_name}) to s3://{BUCKET_NAME}/{key}")
423
+
424
+ return key
425
+ except ValueError as exc:
426
+ print(f"Validation error: {exc}")
427
+ _log_event("validation_error", context=context, path=local_path, error=str(exc))
428
+ return None
429
+ except ClientError as exc:
430
+ with _lock:
431
+ _recent_hashes.pop(digest, None)
432
+ print(f"Error uploading (client): {exc}")
433
+ _log_event("client_error", context=context, path=local_path, error=str(exc))
434
+ return None
435
+ except Exception as exc:
436
+ with _lock:
437
+ _recent_hashes.pop(digest, None)
438
+ print(f"Error uploading: {exc}")
439
+ _log_event("unexpected_error", context=context, path=local_path, error=str(exc))
440
+ return None
441
+ finally:
442
+ if prepared_upload and prepared_upload.cleanup and os.path.exists(prepared_upload.path):
443
+ try:
444
+ os.remove(prepared_upload.path)
445
+ except OSError as cleanup_error:
446
+ print(f"Warning: could not delete temp file {prepared_upload.path}: {cleanup_error}")