Spaces:
Running
Running
Sync from GitHub via hub-sync
Browse files- api/v1/analyze.py +10 -6
- api/v1/history.py +43 -3
- schemas/analyze.py +4 -0
- services/storage.py +21 -10
api/v1/analyze.py
CHANGED
|
@@ -433,8 +433,9 @@ async def analyze_image(
|
|
| 433 |
except Exception as e: # noqa: BLE001
|
| 434 |
logger.warning(f"media save failed: {e}")
|
| 435 |
media_path = None
|
| 436 |
-
thumbnail_url = make_image_thumbnail(pil, media_hash)
|
| 437 |
resp.thumbnail_url = thumbnail_url
|
|
|
|
| 438 |
if media_path:
|
| 439 |
resp.media_path = media_path
|
| 440 |
|
|
@@ -648,8 +649,9 @@ async def analyze_video_endpoint(
|
|
| 648 |
except Exception as e: # noqa: BLE001
|
| 649 |
logger.warning(f"video media save failed: {e}")
|
| 650 |
media_path = None
|
| 651 |
-
thumbnail_url = make_video_thumbnail(path, media_hash)
|
| 652 |
resp.thumbnail_url = thumbnail_url
|
|
|
|
| 653 |
|
| 654 |
record = AnalysisRecord(
|
| 655 |
user_id=user.id if user else None,
|
|
@@ -996,8 +998,9 @@ async def analyze_screenshot_endpoint(
|
|
| 996 |
except Exception as e: # noqa: BLE001
|
| 997 |
logger.warning(f"screenshot media save failed: {e}")
|
| 998 |
media_path = None
|
| 999 |
-
thumbnail_url = make_image_thumbnail(pil, media_hash)
|
| 1000 |
resp.thumbnail_url = thumbnail_url
|
|
|
|
| 1001 |
|
| 1002 |
record = AnalysisRecord(
|
| 1003 |
user_id=user.id if user else None,
|
|
@@ -1157,8 +1160,9 @@ async def analyze_video_async(
|
|
| 1157 |
except Exception as e: # noqa: BLE001
|
| 1158 |
logger.warning(f"async video media save failed: {e}")
|
| 1159 |
media_path = None
|
| 1160 |
-
|
| 1161 |
-
resp.thumbnail_url =
|
|
|
|
| 1162 |
|
| 1163 |
rec = AnalysisRecord(
|
| 1164 |
user_id=user_id,
|
|
@@ -1168,7 +1172,7 @@ async def analyze_video_async(
|
|
| 1168 |
result_json=json.dumps(resp.model_dump()),
|
| 1169 |
media_hash=media_hash,
|
| 1170 |
media_path=media_path,
|
| 1171 |
-
thumbnail_url=
|
| 1172 |
)
|
| 1173 |
local_db.add(rec)
|
| 1174 |
local_db.commit()
|
|
|
|
| 433 |
except Exception as e: # noqa: BLE001
|
| 434 |
logger.warning(f"media save failed: {e}")
|
| 435 |
media_path = None
|
| 436 |
+
thumbnail_url, thumbnail_b64 = make_image_thumbnail(pil, media_hash)
|
| 437 |
resp.thumbnail_url = thumbnail_url
|
| 438 |
+
resp.thumbnail_b64 = thumbnail_b64
|
| 439 |
if media_path:
|
| 440 |
resp.media_path = media_path
|
| 441 |
|
|
|
|
| 649 |
except Exception as e: # noqa: BLE001
|
| 650 |
logger.warning(f"video media save failed: {e}")
|
| 651 |
media_path = None
|
| 652 |
+
thumbnail_url, thumbnail_b64 = make_video_thumbnail(path, media_hash)
|
| 653 |
resp.thumbnail_url = thumbnail_url
|
| 654 |
+
resp.thumbnail_b64 = thumbnail_b64
|
| 655 |
|
| 656 |
record = AnalysisRecord(
|
| 657 |
user_id=user.id if user else None,
|
|
|
|
| 998 |
except Exception as e: # noqa: BLE001
|
| 999 |
logger.warning(f"screenshot media save failed: {e}")
|
| 1000 |
media_path = None
|
| 1001 |
+
thumbnail_url, thumbnail_b64 = make_image_thumbnail(pil, media_hash)
|
| 1002 |
resp.thumbnail_url = thumbnail_url
|
| 1003 |
+
resp.thumbnail_b64 = thumbnail_b64
|
| 1004 |
|
| 1005 |
record = AnalysisRecord(
|
| 1006 |
user_id=user.id if user else None,
|
|
|
|
| 1160 |
except Exception as e: # noqa: BLE001
|
| 1161 |
logger.warning(f"async video media save failed: {e}")
|
| 1162 |
media_path = None
|
| 1163 |
+
thumb_url, thumb_b64 = make_video_thumbnail(path, media_hash)
|
| 1164 |
+
resp.thumbnail_url = thumb_url
|
| 1165 |
+
resp.thumbnail_b64 = thumb_b64
|
| 1166 |
|
| 1167 |
rec = AnalysisRecord(
|
| 1168 |
user_id=user_id,
|
|
|
|
| 1172 |
result_json=json.dumps(resp.model_dump()),
|
| 1173 |
media_hash=media_hash,
|
| 1174 |
media_path=media_path,
|
| 1175 |
+
thumbnail_url=thumb_url,
|
| 1176 |
)
|
| 1177 |
local_db.add(rec)
|
| 1178 |
local_db.commit()
|
api/v1/history.py
CHANGED
|
@@ -26,6 +26,7 @@ class HistoryItem(BaseModel):
|
|
| 26 |
authenticity_score: float
|
| 27 |
created_at: datetime
|
| 28 |
thumbnail_url: str | None = None
|
|
|
|
| 29 |
media_path: str | None = None
|
| 30 |
text_preview: str | None = None
|
| 31 |
|
|
@@ -33,6 +34,7 @@ class HistoryItem(BaseModel):
|
|
| 33 |
class HistoryListResponse(BaseModel):
|
| 34 |
items: list[HistoryItem]
|
| 35 |
total: int
|
|
|
|
| 36 |
|
| 37 |
|
| 38 |
class HistoryDeleteAllResponse(BaseModel):
|
|
@@ -129,6 +131,42 @@ def _history_text_preview(record: AnalysisRecord, limit: int = 260) -> str | Non
|
|
| 129 |
return text[: limit - 3].rstrip() + "..."
|
| 130 |
|
| 131 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 132 |
@router.get("", response_model=HistoryListResponse)
|
| 133 |
def list_history(
|
| 134 |
limit: int = Query(default=50, ge=1, le=200),
|
|
@@ -136,10 +174,10 @@ def list_history(
|
|
| 136 |
user: User = Depends(get_current_user),
|
| 137 |
db: Session = Depends(get_db),
|
| 138 |
) -> HistoryListResponse:
|
| 139 |
-
from sqlalchemy.orm import defer
|
| 140 |
q = db.query(AnalysisRecord).filter(AnalysisRecord.user_id == user.id)
|
| 141 |
total = q.count()
|
| 142 |
-
|
|
|
|
| 143 |
items = [
|
| 144 |
HistoryItem(
|
| 145 |
id=r.id,
|
|
@@ -148,12 +186,14 @@ def list_history(
|
|
| 148 |
authenticity_score=r.authenticity_score,
|
| 149 |
created_at=r.created_at,
|
| 150 |
thumbnail_url=_make_asset_url(r, "thumbnail") if r.thumbnail_url else None,
|
|
|
|
| 151 |
media_path=_make_asset_url(r, "media") if r.media_path else None,
|
| 152 |
text_preview=_history_text_preview(r),
|
| 153 |
)
|
| 154 |
for r in rows
|
| 155 |
]
|
| 156 |
-
|
|
|
|
| 157 |
|
| 158 |
|
| 159 |
@router.get("/{record_id}")
|
|
|
|
| 26 |
authenticity_score: float
|
| 27 |
created_at: datetime
|
| 28 |
thumbnail_url: str | None = None
|
| 29 |
+
thumbnail_b64: str | None = None # inline data URL; preferred over thumbnail_url
|
| 30 |
media_path: str | None = None
|
| 31 |
text_preview: str | None = None
|
| 32 |
|
|
|
|
| 34 |
class HistoryListResponse(BaseModel):
|
| 35 |
items: list[HistoryItem]
|
| 36 |
total: int
|
| 37 |
+
cache_hits: int = 0
|
| 38 |
|
| 39 |
|
| 40 |
class HistoryDeleteAllResponse(BaseModel):
|
|
|
|
| 131 |
return text[: limit - 3].rstrip() + "..."
|
| 132 |
|
| 133 |
|
| 134 |
+
def _thumbnail_b64_from_record(r: AnalysisRecord) -> str | None:
|
| 135 |
+
"""Extract inline thumbnail base64 from result_json if present."""
|
| 136 |
+
try:
|
| 137 |
+
payload = json.loads(r.result_json)
|
| 138 |
+
b64 = payload.get("thumbnail_b64")
|
| 139 |
+
if b64 and str(b64).startswith("data:image"):
|
| 140 |
+
return b64
|
| 141 |
+
except Exception:
|
| 142 |
+
pass
|
| 143 |
+
return None
|
| 144 |
+
|
| 145 |
+
|
| 146 |
+
def _count_cache_hits(db: Session, user_id: int) -> int:
|
| 147 |
+
"""Count analyses whose media_hash matches an earlier record for this user.
|
| 148 |
+
Each duplicate re-submission that was served from cache is a cache hit.
|
| 149 |
+
"""
|
| 150 |
+
from sqlalchemy import func, text as sa_text
|
| 151 |
+
try:
|
| 152 |
+
result = db.execute(
|
| 153 |
+
sa_text(
|
| 154 |
+
"SELECT COUNT(*) FROM analyses a1 "
|
| 155 |
+
"WHERE a1.user_id = :uid AND a1.media_hash IS NOT NULL "
|
| 156 |
+
"AND EXISTS ("
|
| 157 |
+
" SELECT 1 FROM analyses a2 "
|
| 158 |
+
" WHERE a2.user_id = :uid AND a2.media_hash = a1.media_hash "
|
| 159 |
+
" AND a2.id < a1.id"
|
| 160 |
+
")"
|
| 161 |
+
),
|
| 162 |
+
{"uid": user_id},
|
| 163 |
+
)
|
| 164 |
+
row = result.fetchone()
|
| 165 |
+
return int(row[0]) if row else 0
|
| 166 |
+
except Exception:
|
| 167 |
+
return 0
|
| 168 |
+
|
| 169 |
+
|
| 170 |
@router.get("", response_model=HistoryListResponse)
|
| 171 |
def list_history(
|
| 172 |
limit: int = Query(default=50, ge=1, le=200),
|
|
|
|
| 174 |
user: User = Depends(get_current_user),
|
| 175 |
db: Session = Depends(get_db),
|
| 176 |
) -> HistoryListResponse:
|
|
|
|
| 177 |
q = db.query(AnalysisRecord).filter(AnalysisRecord.user_id == user.id)
|
| 178 |
total = q.count()
|
| 179 |
+
# Load result_json so we can extract thumbnail_b64
|
| 180 |
+
rows = q.order_by(AnalysisRecord.created_at.desc()).offset(offset).limit(limit).all()
|
| 181 |
items = [
|
| 182 |
HistoryItem(
|
| 183 |
id=r.id,
|
|
|
|
| 186 |
authenticity_score=r.authenticity_score,
|
| 187 |
created_at=r.created_at,
|
| 188 |
thumbnail_url=_make_asset_url(r, "thumbnail") if r.thumbnail_url else None,
|
| 189 |
+
thumbnail_b64=_thumbnail_b64_from_record(r),
|
| 190 |
media_path=_make_asset_url(r, "media") if r.media_path else None,
|
| 191 |
text_preview=_history_text_preview(r),
|
| 192 |
)
|
| 193 |
for r in rows
|
| 194 |
]
|
| 195 |
+
cache_hits = _count_cache_hits(db, user.id)
|
| 196 |
+
return HistoryListResponse(items=items, total=total, cache_hits=cache_hits)
|
| 197 |
|
| 198 |
|
| 199 |
@router.get("/{record_id}")
|
schemas/analyze.py
CHANGED
|
@@ -104,6 +104,7 @@ class ScreenshotAnalysisResponse(BaseModel):
|
|
| 104 |
record_id: int = 0
|
| 105 |
cached: bool = False
|
| 106 |
thumbnail_url: str | None = None
|
|
|
|
| 107 |
media_type: str = "screenshot"
|
| 108 |
timestamp: str
|
| 109 |
verdict: Verdict
|
|
@@ -179,6 +180,7 @@ class VideoAnalysisResponse(BaseModel):
|
|
| 179 |
record_id: int = 0
|
| 180 |
cached: bool = False
|
| 181 |
thumbnail_url: str | None = None
|
|
|
|
| 182 |
media_type: str = "video"
|
| 183 |
timestamp: str
|
| 184 |
verdict: Verdict
|
|
@@ -197,6 +199,7 @@ class ImageAnalysisResponse(BaseModel):
|
|
| 197 |
record_id: int = 0
|
| 198 |
cached: bool = False
|
| 199 |
thumbnail_url: str | None = None
|
|
|
|
| 200 |
media_path: str | None = None
|
| 201 |
media_type: str = "image"
|
| 202 |
timestamp: str
|
|
@@ -230,6 +233,7 @@ class AudioAnalysisResponse(BaseModel):
|
|
| 230 |
record_id: int = 0
|
| 231 |
cached: bool = False
|
| 232 |
thumbnail_url: str | None = None
|
|
|
|
| 233 |
media_type: str = "audio"
|
| 234 |
timestamp: str
|
| 235 |
verdict: Verdict
|
|
|
|
| 104 |
record_id: int = 0
|
| 105 |
cached: bool = False
|
| 106 |
thumbnail_url: str | None = None
|
| 107 |
+
thumbnail_b64: str | None = None
|
| 108 |
media_type: str = "screenshot"
|
| 109 |
timestamp: str
|
| 110 |
verdict: Verdict
|
|
|
|
| 180 |
record_id: int = 0
|
| 181 |
cached: bool = False
|
| 182 |
thumbnail_url: str | None = None
|
| 183 |
+
thumbnail_b64: str | None = None
|
| 184 |
media_type: str = "video"
|
| 185 |
timestamp: str
|
| 186 |
verdict: Verdict
|
|
|
|
| 199 |
record_id: int = 0
|
| 200 |
cached: bool = False
|
| 201 |
thumbnail_url: str | None = None
|
| 202 |
+
thumbnail_b64: str | None = None # inline data URL, survives without file storage
|
| 203 |
media_path: str | None = None
|
| 204 |
media_type: str = "image"
|
| 205 |
timestamp: str
|
|
|
|
| 233 |
record_id: int = 0
|
| 234 |
cached: bool = False
|
| 235 |
thumbnail_url: str | None = None
|
| 236 |
+
thumbnail_b64: str | None = None
|
| 237 |
media_type: str = "audio"
|
| 238 |
timestamp: str
|
| 239 |
verdict: Verdict
|
services/storage.py
CHANGED
|
@@ -11,6 +11,7 @@ from __future__ import annotations
|
|
| 11 |
|
| 12 |
import base64
|
| 13 |
import hashlib
|
|
|
|
| 14 |
import os
|
| 15 |
from pathlib import Path
|
| 16 |
|
|
@@ -115,15 +116,11 @@ def make_image_thumbnail(pil: Image.Image, sha: str) -> tuple[str | None, str |
|
|
| 115 |
return url_path, data_url
|
| 116 |
|
| 117 |
|
| 118 |
-
def make_video_thumbnail(video_path: str, sha: str) -> str | None:
|
| 119 |
-
"""Grab a frame ~1s in as the video thumbnail."""
|
| 120 |
try:
|
| 121 |
import cv2 # lazy import — heavy
|
| 122 |
|
| 123 |
-
_ensure_dirs()
|
| 124 |
-
dest = THUMB_DIR / f"{sha}_400.jpg"
|
| 125 |
-
if dest.exists():
|
| 126 |
-
return f"/media/thumbs/{sha}_400.jpg"
|
| 127 |
cap = cv2.VideoCapture(video_path)
|
| 128 |
try:
|
| 129 |
fps = cap.get(cv2.CAP_PROP_FPS) or 25
|
|
@@ -133,17 +130,31 @@ def make_video_thumbnail(video_path: str, sha: str) -> str | None:
|
|
| 133 |
cap.set(cv2.CAP_PROP_POS_FRAMES, 0)
|
| 134 |
ok, frame = cap.read()
|
| 135 |
if not ok:
|
| 136 |
-
return None
|
| 137 |
finally:
|
| 138 |
cap.release()
|
| 139 |
rgb = cv2.cvtColor(frame, cv2.COLOR_BGR2RGB)
|
| 140 |
im = Image.fromarray(rgb)
|
| 141 |
im.thumbnail((THUMB_MAX, THUMB_MAX))
|
| 142 |
-
|
| 143 |
-
|
|
|
|
|
|
|
| 144 |
except Exception as e: # noqa: BLE001
|
| 145 |
logger.warning(f"video thumbnail failed for {sha}: {e}")
|
| 146 |
-
return None
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 147 |
|
| 148 |
|
| 149 |
def save_overlay(data_url: str, sha: str, suffix: str) -> str | None:
|
|
|
|
| 11 |
|
| 12 |
import base64
|
| 13 |
import hashlib
|
| 14 |
+
import io
|
| 15 |
import os
|
| 16 |
from pathlib import Path
|
| 17 |
|
|
|
|
| 116 |
return url_path, data_url
|
| 117 |
|
| 118 |
|
| 119 |
+
def make_video_thumbnail(video_path: str, sha: str) -> tuple[str | None, str | None]:
|
| 120 |
+
"""Grab a frame ~1s in as the video thumbnail. Returns (url_path, data_url)."""
|
| 121 |
try:
|
| 122 |
import cv2 # lazy import — heavy
|
| 123 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 124 |
cap = cv2.VideoCapture(video_path)
|
| 125 |
try:
|
| 126 |
fps = cap.get(cv2.CAP_PROP_FPS) or 25
|
|
|
|
| 130 |
cap.set(cv2.CAP_PROP_POS_FRAMES, 0)
|
| 131 |
ok, frame = cap.read()
|
| 132 |
if not ok:
|
| 133 |
+
return None, None
|
| 134 |
finally:
|
| 135 |
cap.release()
|
| 136 |
rgb = cv2.cvtColor(frame, cv2.COLOR_BGR2RGB)
|
| 137 |
im = Image.fromarray(rgb)
|
| 138 |
im.thumbnail((THUMB_MAX, THUMB_MAX))
|
| 139 |
+
buf = io.BytesIO()
|
| 140 |
+
im.save(buf, "JPEG", quality=75, optimize=True)
|
| 141 |
+
b64 = base64.b64encode(buf.getvalue()).decode("ascii")
|
| 142 |
+
data_url = f"data:image/jpeg;base64,{b64}"
|
| 143 |
except Exception as e: # noqa: BLE001
|
| 144 |
logger.warning(f"video thumbnail failed for {sha}: {e}")
|
| 145 |
+
return None, None
|
| 146 |
+
|
| 147 |
+
url_path: str | None = None
|
| 148 |
+
try:
|
| 149 |
+
_ensure_dirs()
|
| 150 |
+
dest = THUMB_DIR / f"{sha}_400.jpg"
|
| 151 |
+
if not dest.exists():
|
| 152 |
+
dest.write_bytes(buf.getvalue())
|
| 153 |
+
url_path = f"/media/thumbs/{sha}_400.jpg"
|
| 154 |
+
except Exception as e: # noqa: BLE001
|
| 155 |
+
logger.warning(f"video thumbnail file save failed for {sha}: {e}")
|
| 156 |
+
|
| 157 |
+
return url_path, data_url
|
| 158 |
|
| 159 |
|
| 160 |
def save_overlay(data_url: str, sha: str, suffix: str) -> str | None:
|