Resume reclass jobs and expand metadata detection
Browse files- backend/app.py +367 -56
- frontend/app.js +3 -3
backend/app.py
CHANGED
|
@@ -25,7 +25,7 @@ from fastapi import BackgroundTasks, FastAPI, HTTPException, Request
|
|
| 25 |
from fastapi.responses import FileResponse, Response, StreamingResponse
|
| 26 |
from fastapi.staticfiles import StaticFiles
|
| 27 |
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
|
| 28 |
-
from PIL import Image
|
| 29 |
from pydantic import BaseModel
|
| 30 |
|
| 31 |
TURSO_DB_URL = os.getenv("TURSO_DB_URL", "").strip()
|
|
@@ -49,7 +49,18 @@ AI_TAGS = {
|
|
| 49 |
"ai",
|
| 50 |
"comfyui",
|
| 51 |
}
|
| 52 |
-
EXIF_TYPE_ORDER = (
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 53 |
EXIF_TYPE_TO_CODE = {name: idx + 1 for idx, name in enumerate(EXIF_TYPE_ORDER)}
|
| 54 |
EXIF_CODE_TO_TYPE = {idx + 1: name for idx, name in enumerate(EXIF_TYPE_ORDER)}
|
| 55 |
POST_SCAN_LIMIT = 64
|
|
@@ -58,6 +69,7 @@ EXIF_RANGE_BYTES = 16384
|
|
| 58 |
FULL_IMAGE_LIMIT = 32
|
| 59 |
PAGE_SIZE = 60
|
| 60 |
SEARCH_PAGE_SIZE = 10
|
|
|
|
| 61 |
SEARCH_LIST_WHERE = "post_ids != '[]' AND found_exif > 0"
|
| 62 |
POST_SEARCH_LIMIT = 1000
|
| 63 |
RECLASS_LOG_KEY = "TF#g9FA@UgKdN'usVtm3dTz*yoqbria`"
|
|
@@ -179,6 +191,8 @@ async def init_db():
|
|
| 179 |
|
| 180 |
async def migrate_exif_order():
|
| 181 |
order_value = ",".join(EXIF_TYPE_ORDER)
|
|
|
|
|
|
|
| 182 |
resp = await turso_execute(
|
| 183 |
[
|
| 184 |
{
|
|
@@ -187,22 +201,31 @@ async def migrate_exif_order():
|
|
| 187 |
]
|
| 188 |
)
|
| 189 |
rows = first_rows(resp)
|
| 190 |
-
|
|
|
|
| 191 |
return
|
| 192 |
-
|
| 193 |
-
|
| 194 |
-
|
| 195 |
-
|
| 196 |
-
|
| 197 |
-
|
| 198 |
-
|
| 199 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 200 |
{
|
| 201 |
-
"sql": "INSERT OR REPLACE INTO pi_meta
|
|
|
|
| 202 |
"args": [{"type": "text", "value": order_value}],
|
| 203 |
-
}
|
| 204 |
-
|
| 205 |
-
|
|
|
|
| 206 |
|
| 207 |
|
| 208 |
async def discord_notify(msg):
|
|
@@ -688,6 +711,8 @@ def decode_meta_text(data):
|
|
| 688 |
|
| 689 |
|
| 690 |
def parse_png_metadata(data):
|
|
|
|
|
|
|
| 691 |
fields = {}
|
| 692 |
index = 8
|
| 693 |
while index < len(data):
|
|
@@ -723,6 +748,99 @@ def parse_png_metadata(data):
|
|
| 723 |
return fields or None
|
| 724 |
|
| 725 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 726 |
def parse_json_maybe(value):
|
| 727 |
try:
|
| 728 |
return json.loads(value)
|
|
@@ -771,6 +889,76 @@ def has_comfy_metadata(fields):
|
|
| 771 |
return False
|
| 772 |
|
| 773 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 774 |
def determine_exif_type(fields):
|
| 775 |
if not fields:
|
| 776 |
return None
|
|
@@ -789,7 +977,13 @@ def determine_exif_type(fields):
|
|
| 789 |
return "novelai"
|
| 790 |
if has_comfy_metadata(fields):
|
| 791 |
return "comfy"
|
| 792 |
-
if
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 793 |
return "sd"
|
| 794 |
if "midjourney" in joined:
|
| 795 |
return "mj"
|
|
@@ -829,7 +1023,12 @@ async def scan_post(post_id, session, post_sem, exif_sem, img_sem):
|
|
| 829 |
async with post_sem:
|
| 830 |
try:
|
| 831 |
pages = await get_post_pages(post_id, session)
|
| 832 |
-
image_urls = [
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 833 |
for url in image_urls:
|
| 834 |
metadata = await get_exif_range(url, session, exif_sem)
|
| 835 |
exif_type = determine_exif_type(metadata)
|
|
@@ -839,7 +1038,7 @@ async def scan_post(post_id, session, post_sem, exif_sem, img_sem):
|
|
| 839 |
for url in image_urls:
|
| 840 |
img_data = await fetch_image(session, url, img_sem)
|
| 841 |
if img_data and has_stealth_png_bytes(img_data):
|
| 842 |
-
return post_id, url, EXIF_TYPE_TO_CODE.get("
|
| 843 |
return post_id, None, None
|
| 844 |
except Exception:
|
| 845 |
return post_id, None, None
|
|
@@ -857,7 +1056,7 @@ async def get_exif_range(url, session, sem):
|
|
| 857 |
else:
|
| 858 |
async with session.get(url, headers=hdrs) as r:
|
| 859 |
data = await r.read()
|
| 860 |
-
return
|
| 861 |
|
| 862 |
|
| 863 |
async def fetch_image(session, url, sem):
|
|
@@ -869,7 +1068,7 @@ async def fetch_image(session, url, sem):
|
|
| 869 |
return await r.read()
|
| 870 |
|
| 871 |
|
| 872 |
-
async def run_scan(post_ids, phpsessid, task_id=None, save_live=False):
|
| 873 |
post_sem = asyncio.Semaphore(POST_SCAN_LIMIT)
|
| 874 |
exif_sem = asyncio.Semaphore(EXIF_RANGE_LIMIT)
|
| 875 |
img_sem = asyncio.Semaphore(FULL_IMAGE_LIMIT)
|
|
@@ -889,7 +1088,7 @@ async def run_scan(post_ids, phpsessid, task_id=None, save_live=False):
|
|
| 889 |
results.append(result)
|
| 890 |
pending.append(result)
|
| 891 |
if task_id and task_id in ACTIVE_TASKS:
|
| 892 |
-
ACTIVE_TASKS[task_id]["done"] = len(results)
|
| 893 |
if save_live and len(pending) >= 20:
|
| 894 |
if task_canceled(task_id):
|
| 895 |
raise TaskCanceled()
|
|
@@ -952,6 +1151,12 @@ async def get_reclass_targets(allowed):
|
|
| 952 |
|
| 953 |
|
| 954 |
async def save_reclass_job(job_id, post_ids, filters):
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 955 |
await turso_execute(
|
| 956 |
[
|
| 957 |
{
|
|
@@ -963,7 +1168,7 @@ async def save_reclass_job(job_id, post_ids, filters):
|
|
| 963 |
{"type": "text", "value": json.dumps(post_ids)},
|
| 964 |
{"type": "text", "value": json.dumps(filters)},
|
| 965 |
{"type": "integer", "value": str(len(post_ids))},
|
| 966 |
-
{"type": "text", "value": json.dumps(
|
| 967 |
{"type": "text", "value": encrypt_log([])},
|
| 968 |
{"type": "integer", "value": str(int(time.time()))},
|
| 969 |
],
|
|
@@ -1252,51 +1457,75 @@ async def bg_search_and_scan_task(search_id, url, pages, mode, phpsessid):
|
|
| 1252 |
await finish_task(search_id)
|
| 1253 |
|
| 1254 |
|
| 1255 |
-
async def bg_reclass_task(
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1256 |
ACTIVE_TASKS[job_id] = {
|
| 1257 |
"type": "reclass",
|
| 1258 |
"phase": "scanning",
|
| 1259 |
-
"total":
|
| 1260 |
-
"done":
|
|
|
|
| 1261 |
}
|
| 1262 |
-
|
| 1263 |
-
|
| 1264 |
-
changed_results = []
|
| 1265 |
try:
|
| 1266 |
-
|
| 1267 |
-
|
| 1268 |
-
|
| 1269 |
-
|
| 1270 |
-
|
| 1271 |
-
|
| 1272 |
-
|
| 1273 |
-
|
| 1274 |
-
|
| 1275 |
-
|
| 1276 |
-
|
| 1277 |
-
|
| 1278 |
-
|
| 1279 |
-
|
| 1280 |
-
|
| 1281 |
-
|
| 1282 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1283 |
)
|
| 1284 |
-
|
| 1285 |
-
|
| 1286 |
-
|
| 1287 |
-
await
|
| 1288 |
-
await update_searches_for_post_ids([str(pid) for pid, _, _ in changed_results])
|
| 1289 |
-
await finish_reclass_job(
|
| 1290 |
-
job_id, len(results), changes, len(changed_results), filters
|
| 1291 |
-
)
|
| 1292 |
await discord_notify(f"`{job_id}` Done")
|
| 1293 |
except TaskCanceled:
|
| 1294 |
done = ACTIVE_TASKS.get(job_id, {}).get("done", 0)
|
| 1295 |
-
await finish_reclass_job(job_id, done, changes,
|
| 1296 |
await discord_notify(f"`{job_id}` reclass canceled")
|
| 1297 |
except Exception as e:
|
| 1298 |
done = ACTIVE_TASKS.get(job_id, {}).get("done", 0)
|
| 1299 |
-
await finish_reclass_job(job_id, done, changes,
|
| 1300 |
await discord_notify(f"`{job_id}` reclass failed: {e}")
|
| 1301 |
finally:
|
| 1302 |
await finish_task(job_id)
|
|
@@ -1339,6 +1568,7 @@ async def startup():
|
|
| 1339 |
await init_db()
|
| 1340 |
asyncio.create_task(backfill_search_stats())
|
| 1341 |
asyncio.create_task(resume_incomplete_scans())
|
|
|
|
| 1342 |
except Exception as e:
|
| 1343 |
print(f"WARN: DB init failed ({e}), will retry on first request")
|
| 1344 |
|
|
@@ -1384,6 +1614,85 @@ async def resume_incomplete_scans():
|
|
| 1384 |
print(f"WARN: scan resume failed: {e}")
|
| 1385 |
|
| 1386 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1387 |
@app.post("/api/submit")
|
| 1388 |
async def submit_search(req: SearchRequest, bg: BackgroundTasks):
|
| 1389 |
search_id = base26_time()
|
|
@@ -1488,9 +1797,11 @@ async def get_reclass(job_id: str):
|
|
| 1488 |
active = ACTIVE_TASKS.get(job_id)
|
| 1489 |
total = int(cell_value(row, 3, "0"))
|
| 1490 |
scanned = int(cell_value(row, 4, "0"))
|
|
|
|
| 1491 |
if active:
|
| 1492 |
total = int(active.get("total") or total)
|
| 1493 |
scanned = int(active.get("done") or scanned)
|
|
|
|
| 1494 |
return {
|
| 1495 |
"id": cell_value(row, 0, ""),
|
| 1496 |
"kind": "reclass",
|
|
@@ -1498,7 +1809,7 @@ async def get_reclass(job_id: str):
|
|
| 1498 |
"filters": json.loads(cell_value(row, 2, "[]")),
|
| 1499 |
"total": total,
|
| 1500 |
"scanned": scanned,
|
| 1501 |
-
"changed":
|
| 1502 |
"summary": summary,
|
| 1503 |
"log": decrypt_log(cell_value(row, 7, "")),
|
| 1504 |
"created_at": int(cell_value(row, 8, "0")),
|
|
|
|
| 25 |
from fastapi.responses import FileResponse, Response, StreamingResponse
|
| 26 |
from fastapi.staticfiles import StaticFiles
|
| 27 |
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
|
| 28 |
+
from PIL import ExifTags, Image
|
| 29 |
from pydantic import BaseModel
|
| 30 |
|
| 31 |
TURSO_DB_URL = os.getenv("TURSO_DB_URL", "").strip()
|
|
|
|
| 49 |
"ai",
|
| 50 |
"comfyui",
|
| 51 |
}
|
| 52 |
+
EXIF_TYPE_ORDER = (
|
| 53 |
+
"novelai",
|
| 54 |
+
"comfy",
|
| 55 |
+
"sd",
|
| 56 |
+
"mj",
|
| 57 |
+
"celsys",
|
| 58 |
+
"photoshop",
|
| 59 |
+
"stealth",
|
| 60 |
+
"invoke",
|
| 61 |
+
"fooocus",
|
| 62 |
+
"swarm",
|
| 63 |
+
)
|
| 64 |
EXIF_TYPE_TO_CODE = {name: idx + 1 for idx, name in enumerate(EXIF_TYPE_ORDER)}
|
| 65 |
EXIF_CODE_TO_TYPE = {idx + 1: name for idx, name in enumerate(EXIF_TYPE_ORDER)}
|
| 66 |
POST_SCAN_LIMIT = 64
|
|
|
|
| 69 |
FULL_IMAGE_LIMIT = 32
|
| 70 |
PAGE_SIZE = 60
|
| 71 |
SEARCH_PAGE_SIZE = 10
|
| 72 |
+
RECLASS_BATCH_SIZE = 64
|
| 73 |
SEARCH_LIST_WHERE = "post_ids != '[]' AND found_exif > 0"
|
| 74 |
POST_SEARCH_LIMIT = 1000
|
| 75 |
RECLASS_LOG_KEY = "TF#g9FA@UgKdN'usVtm3dTz*yoqbria`"
|
|
|
|
| 191 |
|
| 192 |
async def migrate_exif_order():
|
| 193 |
order_value = ",".join(EXIF_TYPE_ORDER)
|
| 194 |
+
swapped_order = "novelai,comfy,sd,mj,celsys,photoshop,stealth"
|
| 195 |
+
legacy_order = "novelai,sd,comfy,mj,celsys,photoshop,stealth"
|
| 196 |
resp = await turso_execute(
|
| 197 |
[
|
| 198 |
{
|
|
|
|
| 201 |
]
|
| 202 |
)
|
| 203 |
rows = first_rows(resp)
|
| 204 |
+
prev_order = cell_value(rows[0], 0, "") if rows else ""
|
| 205 |
+
if prev_order == order_value:
|
| 206 |
return
|
| 207 |
+
stmts = []
|
| 208 |
+
if prev_order == legacy_order:
|
| 209 |
+
stmts.extend(
|
| 210 |
+
[
|
| 211 |
+
{
|
| 212 |
+
"sql": "UPDATE pi_scans SET exif_type = CASE exif_type "
|
| 213 |
+
"WHEN 2 THEN -2 WHEN 3 THEN -3 ELSE exif_type END"
|
| 214 |
+
},
|
| 215 |
+
{"sql": "UPDATE pi_scans SET exif_type = 3 WHERE exif_type = -2"},
|
| 216 |
+
{"sql": "UPDATE pi_scans SET exif_type = 2 WHERE exif_type = -3"},
|
| 217 |
+
]
|
| 218 |
+
)
|
| 219 |
+
if prev_order in ("", swapped_order, legacy_order):
|
| 220 |
+
stmts.append(
|
| 221 |
{
|
| 222 |
+
"sql": "INSERT OR REPLACE INTO pi_meta "
|
| 223 |
+
"(key, value) VALUES ('exif_order', ?)",
|
| 224 |
"args": [{"type": "text", "value": order_value}],
|
| 225 |
+
}
|
| 226 |
+
)
|
| 227 |
+
if stmts:
|
| 228 |
+
await turso_execute(stmts)
|
| 229 |
|
| 230 |
|
| 231 |
async def discord_notify(msg):
|
|
|
|
| 711 |
|
| 712 |
|
| 713 |
def parse_png_metadata(data):
|
| 714 |
+
if not data.startswith(b"\x89PNG\r\n\x1a\n"):
|
| 715 |
+
return None
|
| 716 |
fields = {}
|
| 717 |
index = 8
|
| 718 |
while index < len(data):
|
|
|
|
| 748 |
return fields or None
|
| 749 |
|
| 750 |
|
| 751 |
+
def decode_exif_text(value):
|
| 752 |
+
if value is None:
|
| 753 |
+
return ""
|
| 754 |
+
if isinstance(value, bytes):
|
| 755 |
+
prefixes = (
|
| 756 |
+
(b"ASCII\x00\x00\x00", "utf-8"),
|
| 757 |
+
(b"UNICODE\x00", "utf-16-be"),
|
| 758 |
+
(b"JIS\x00\x00\x00\x00\x00", "shift_jis"),
|
| 759 |
+
)
|
| 760 |
+
for prefix, enc in prefixes:
|
| 761 |
+
if value.startswith(prefix):
|
| 762 |
+
try:
|
| 763 |
+
return value[len(prefix) :].rstrip(b"\0").decode(enc).strip()
|
| 764 |
+
except UnicodeDecodeError:
|
| 765 |
+
break
|
| 766 |
+
return decode_meta_text(value.rstrip(b"\0"))
|
| 767 |
+
return str(value).strip()
|
| 768 |
+
|
| 769 |
+
|
| 770 |
+
def parse_pil_metadata(data):
|
| 771 |
+
fields = {}
|
| 772 |
+
try:
|
| 773 |
+
image = Image.open(io.BytesIO(data))
|
| 774 |
+
for key, value in (image.info or {}).items():
|
| 775 |
+
if isinstance(value, (str, bytes)):
|
| 776 |
+
fields[str(key)] = decode_exif_text(value)
|
| 777 |
+
for tag, value in image.getexif().items():
|
| 778 |
+
name = ExifTags.TAGS.get(tag, str(tag))
|
| 779 |
+
fields[name] = decode_exif_text(value)
|
| 780 |
+
except Exception:
|
| 781 |
+
pass
|
| 782 |
+
return {k: v for k, v in fields.items() if v} or None
|
| 783 |
+
|
| 784 |
+
|
| 785 |
+
def parse_jpeg_comments(data):
|
| 786 |
+
if not data.startswith(b"\xff\xd8"):
|
| 787 |
+
return None
|
| 788 |
+
fields = {}
|
| 789 |
+
index = 2
|
| 790 |
+
n = 1
|
| 791 |
+
while index + 4 <= len(data):
|
| 792 |
+
if data[index] != 0xFF:
|
| 793 |
+
index += 1
|
| 794 |
+
continue
|
| 795 |
+
marker = data[index + 1]
|
| 796 |
+
index += 2
|
| 797 |
+
if marker == 0xDA:
|
| 798 |
+
break
|
| 799 |
+
if marker in (0x01, *range(0xD0, 0xD9)):
|
| 800 |
+
continue
|
| 801 |
+
size = int.from_bytes(data[index : index + 2], "big")
|
| 802 |
+
if size < 2 or index + size > len(data):
|
| 803 |
+
break
|
| 804 |
+
content = data[index + 2 : index + size]
|
| 805 |
+
if marker == 0xFE:
|
| 806 |
+
fields[f"Comment{n}"] = decode_meta_text(content)
|
| 807 |
+
n += 1
|
| 808 |
+
index += size
|
| 809 |
+
return fields or None
|
| 810 |
+
|
| 811 |
+
|
| 812 |
+
def parse_xmp_metadata(data):
|
| 813 |
+
start = data.find(b"<x:xmpmeta")
|
| 814 |
+
if start < 0:
|
| 815 |
+
start = data.find(b"<?xpacket")
|
| 816 |
+
if start < 0:
|
| 817 |
+
return None
|
| 818 |
+
end = data.find(b"</x:xmpmeta>", start)
|
| 819 |
+
if end >= 0:
|
| 820 |
+
end += len(b"</x:xmpmeta>")
|
| 821 |
+
else:
|
| 822 |
+
end = data.find(b"?>", start)
|
| 823 |
+
if end >= 0:
|
| 824 |
+
end += 2
|
| 825 |
+
if end < 0:
|
| 826 |
+
return None
|
| 827 |
+
return {"XMP": decode_meta_text(data[start:end])}
|
| 828 |
+
|
| 829 |
+
|
| 830 |
+
def parse_image_metadata(data):
|
| 831 |
+
fields = {}
|
| 832 |
+
for parser in (
|
| 833 |
+
parse_png_metadata,
|
| 834 |
+
parse_pil_metadata,
|
| 835 |
+
parse_jpeg_comments,
|
| 836 |
+
parse_xmp_metadata,
|
| 837 |
+
):
|
| 838 |
+
parsed = parser(data)
|
| 839 |
+
if parsed:
|
| 840 |
+
fields.update(parsed)
|
| 841 |
+
return fields or None
|
| 842 |
+
|
| 843 |
+
|
| 844 |
def parse_json_maybe(value):
|
| 845 |
try:
|
| 846 |
return json.loads(value)
|
|
|
|
| 889 |
return False
|
| 890 |
|
| 891 |
|
| 892 |
+
def field_json_values(fields):
|
| 893 |
+
for value in fields.values():
|
| 894 |
+
data = parse_json_maybe(value)
|
| 895 |
+
if isinstance(data, dict):
|
| 896 |
+
yield data
|
| 897 |
+
|
| 898 |
+
|
| 899 |
+
def has_fooocus_metadata(fields):
|
| 900 |
+
scheme = fields.get("fooocus_scheme") or fields.get("MakerNote")
|
| 901 |
+
if str(scheme).casefold() in ("fooocus", "a1111"):
|
| 902 |
+
return True
|
| 903 |
+
software = fields.get("Software", "").casefold()
|
| 904 |
+
if "fooocus" in software:
|
| 905 |
+
return True
|
| 906 |
+
for data in field_json_values(fields):
|
| 907 |
+
keys = set(data)
|
| 908 |
+
if {
|
| 909 |
+
"adaptive_cfg",
|
| 910 |
+
"base_model_hash",
|
| 911 |
+
"full_negative_prompt",
|
| 912 |
+
"full_prompt",
|
| 913 |
+
"loras",
|
| 914 |
+
"refiner_model_hash",
|
| 915 |
+
} & keys:
|
| 916 |
+
return True
|
| 917 |
+
return False
|
| 918 |
+
|
| 919 |
+
|
| 920 |
+
def has_invoke_metadata(fields):
|
| 921 |
+
if any(k.casefold() in ("dream", "sd-metadata") for k in fields):
|
| 922 |
+
return True
|
| 923 |
+
joined = "\n".join(str(v) for v in fields.values()).casefold()
|
| 924 |
+
if "invokeai" in joined or "invoke ai" in joined:
|
| 925 |
+
return True
|
| 926 |
+
for data in field_json_values(fields):
|
| 927 |
+
app = str(data.get("app_id", "")).casefold()
|
| 928 |
+
if "invoke" in app:
|
| 929 |
+
return True
|
| 930 |
+
return False
|
| 931 |
+
|
| 932 |
+
|
| 933 |
+
def has_swarm_metadata(fields):
|
| 934 |
+
for data in field_json_values(fields):
|
| 935 |
+
if {"sui_image_params", "sui_extra_data", "sui_models"} & set(data):
|
| 936 |
+
return True
|
| 937 |
+
return "sui_image_params" in "\n".join(str(v) for v in fields.values()).casefold()
|
| 938 |
+
|
| 939 |
+
|
| 940 |
+
def has_sd_metadata(fields):
|
| 941 |
+
if "parameters" in fields:
|
| 942 |
+
return True
|
| 943 |
+
for value in fields.values():
|
| 944 |
+
text = str(value)
|
| 945 |
+
folded = text.casefold()
|
| 946 |
+
if (
|
| 947 |
+
"steps:" in folded
|
| 948 |
+
and "seed:" in folded
|
| 949 |
+
and ("sampler:" in folded or "cfg scale:" in folded or "model:" in folded)
|
| 950 |
+
):
|
| 951 |
+
return True
|
| 952 |
+
data = parse_json_maybe(value)
|
| 953 |
+
if isinstance(data, dict):
|
| 954 |
+
keys = set(data)
|
| 955 |
+
if "prompt" in keys and "seed" in keys and (
|
| 956 |
+
"steps" in keys or "sampler" in keys or "cfg_scale" in keys
|
| 957 |
+
):
|
| 958 |
+
return True
|
| 959 |
+
return False
|
| 960 |
+
|
| 961 |
+
|
| 962 |
def determine_exif_type(fields):
|
| 963 |
if not fields:
|
| 964 |
return None
|
|
|
|
| 977 |
return "novelai"
|
| 978 |
if has_comfy_metadata(fields):
|
| 979 |
return "comfy"
|
| 980 |
+
if has_fooocus_metadata(fields):
|
| 981 |
+
return "fooocus"
|
| 982 |
+
if has_invoke_metadata(fields):
|
| 983 |
+
return "invoke"
|
| 984 |
+
if has_swarm_metadata(fields):
|
| 985 |
+
return "swarm"
|
| 986 |
+
if has_sd_metadata(fields) or joined.startswith("parameters"):
|
| 987 |
return "sd"
|
| 988 |
if "midjourney" in joined:
|
| 989 |
return "mj"
|
|
|
|
| 1023 |
async with post_sem:
|
| 1024 |
try:
|
| 1025 |
pages = await get_post_pages(post_id, session)
|
| 1026 |
+
image_urls = [
|
| 1027 |
+
p["original"]
|
| 1028 |
+
for p in pages
|
| 1029 |
+
if urlsplit(p["original"]).path.rsplit(".", 1)[-1].casefold()
|
| 1030 |
+
in ("png", "jpg", "jpeg", "webp")
|
| 1031 |
+
]
|
| 1032 |
for url in image_urls:
|
| 1033 |
metadata = await get_exif_range(url, session, exif_sem)
|
| 1034 |
exif_type = determine_exif_type(metadata)
|
|
|
|
| 1038 |
for url in image_urls:
|
| 1039 |
img_data = await fetch_image(session, url, img_sem)
|
| 1040 |
if img_data and has_stealth_png_bytes(img_data):
|
| 1041 |
+
return post_id, url, EXIF_TYPE_TO_CODE.get("stealth")
|
| 1042 |
return post_id, None, None
|
| 1043 |
except Exception:
|
| 1044 |
return post_id, None, None
|
|
|
|
| 1056 |
else:
|
| 1057 |
async with session.get(url, headers=hdrs) as r:
|
| 1058 |
data = await r.read()
|
| 1059 |
+
return parse_image_metadata(data)
|
| 1060 |
|
| 1061 |
|
| 1062 |
async def fetch_image(session, url, sem):
|
|
|
|
| 1068 |
return await r.read()
|
| 1069 |
|
| 1070 |
|
| 1071 |
+
async def run_scan(post_ids, phpsessid, task_id=None, save_live=False, done_offset=0):
|
| 1072 |
post_sem = asyncio.Semaphore(POST_SCAN_LIMIT)
|
| 1073 |
exif_sem = asyncio.Semaphore(EXIF_RANGE_LIMIT)
|
| 1074 |
img_sem = asyncio.Semaphore(FULL_IMAGE_LIMIT)
|
|
|
|
| 1088 |
results.append(result)
|
| 1089 |
pending.append(result)
|
| 1090 |
if task_id and task_id in ACTIVE_TASKS:
|
| 1091 |
+
ACTIVE_TASKS[task_id]["done"] = done_offset + len(results)
|
| 1092 |
if save_live and len(pending) >= 20:
|
| 1093 |
if task_canceled(task_id):
|
| 1094 |
raise TaskCanceled()
|
|
|
|
| 1151 |
|
| 1152 |
|
| 1153 |
async def save_reclass_job(job_id, post_ids, filters):
|
| 1154 |
+
summary = {
|
| 1155 |
+
"scanned": 0,
|
| 1156 |
+
"changed": 0,
|
| 1157 |
+
"updated": 0,
|
| 1158 |
+
"filters": [exif_label(code) for code in filters],
|
| 1159 |
+
}
|
| 1160 |
await turso_execute(
|
| 1161 |
[
|
| 1162 |
{
|
|
|
|
| 1168 |
{"type": "text", "value": json.dumps(post_ids)},
|
| 1169 |
{"type": "text", "value": json.dumps(filters)},
|
| 1170 |
{"type": "integer", "value": str(len(post_ids))},
|
| 1171 |
+
{"type": "text", "value": json.dumps(summary)},
|
| 1172 |
{"type": "text", "value": encrypt_log([])},
|
| 1173 |
{"type": "integer", "value": str(int(time.time()))},
|
| 1174 |
],
|
|
|
|
| 1457 |
await finish_task(search_id)
|
| 1458 |
|
| 1459 |
|
| 1460 |
+
async def bg_reclass_task(
|
| 1461 |
+
job_id,
|
| 1462 |
+
targets,
|
| 1463 |
+
filters,
|
| 1464 |
+
phpsessid,
|
| 1465 |
+
total=None,
|
| 1466 |
+
scanned=0,
|
| 1467 |
+
changes=None,
|
| 1468 |
+
updated=0,
|
| 1469 |
+
):
|
| 1470 |
+
total = total or len(targets)
|
| 1471 |
+
changes = list(changes or [])
|
| 1472 |
ACTIVE_TASKS[job_id] = {
|
| 1473 |
"type": "reclass",
|
| 1474 |
"phase": "scanning",
|
| 1475 |
+
"total": total,
|
| 1476 |
+
"done": scanned,
|
| 1477 |
+
"changed": len(changes),
|
| 1478 |
}
|
| 1479 |
+
action = "resumed" if scanned else "started"
|
| 1480 |
+
await discord_notify(f"`{job_id}` reclass {action} ({total - scanned} posts)")
|
|
|
|
| 1481 |
try:
|
| 1482 |
+
for i in range(0, len(targets), RECLASS_BATCH_SIZE):
|
| 1483 |
+
if task_canceled(job_id):
|
| 1484 |
+
raise TaskCanceled()
|
| 1485 |
+
batch = targets[i : i + RECLASS_BATCH_SIZE]
|
| 1486 |
+
old = {item["post_id"]: item for item in batch}
|
| 1487 |
+
results = await run_scan(
|
| 1488 |
+
list(old),
|
| 1489 |
+
phpsessid,
|
| 1490 |
+
task_id=job_id,
|
| 1491 |
+
save_live=False,
|
| 1492 |
+
done_offset=scanned,
|
| 1493 |
+
)
|
| 1494 |
+
changed_results = []
|
| 1495 |
+
for post_id, url, exif_type in results:
|
| 1496 |
+
item = old.get(str(post_id))
|
| 1497 |
+
if not item:
|
| 1498 |
+
continue
|
| 1499 |
+
short_url = url.replace(IMG_BASE, "", 1) if url else ""
|
| 1500 |
+
old_type = item["exif_type"]
|
| 1501 |
+
old_url = item["url"]
|
| 1502 |
+
if old_type != exif_type:
|
| 1503 |
+
changes.append(
|
| 1504 |
+
{
|
| 1505 |
+
"post_id": str(post_id),
|
| 1506 |
+
"from": exif_label(old_type),
|
| 1507 |
+
"to": exif_label(exif_type),
|
| 1508 |
+
}
|
| 1509 |
+
)
|
| 1510 |
+
if old_type != exif_type or old_url != short_url:
|
| 1511 |
+
changed_results.append((post_id, url, exif_type))
|
| 1512 |
+
if changed_results:
|
| 1513 |
+
await save_scan_results(changed_results)
|
| 1514 |
+
await update_searches_for_post_ids(
|
| 1515 |
+
[str(pid) for pid, _, _ in changed_results]
|
| 1516 |
)
|
| 1517 |
+
updated += len(changed_results)
|
| 1518 |
+
scanned += len(results)
|
| 1519 |
+
ACTIVE_TASKS[job_id].update({"done": scanned, "changed": len(changes)})
|
| 1520 |
+
await finish_reclass_job(job_id, scanned, changes, updated, filters)
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1521 |
await discord_notify(f"`{job_id}` Done")
|
| 1522 |
except TaskCanceled:
|
| 1523 |
done = ACTIVE_TASKS.get(job_id, {}).get("done", 0)
|
| 1524 |
+
await finish_reclass_job(job_id, done, changes, updated, filters)
|
| 1525 |
await discord_notify(f"`{job_id}` reclass canceled")
|
| 1526 |
except Exception as e:
|
| 1527 |
done = ACTIVE_TASKS.get(job_id, {}).get("done", 0)
|
| 1528 |
+
await finish_reclass_job(job_id, done, changes, updated, filters)
|
| 1529 |
await discord_notify(f"`{job_id}` reclass failed: {e}")
|
| 1530 |
finally:
|
| 1531 |
await finish_task(job_id)
|
|
|
|
| 1568 |
await init_db()
|
| 1569 |
asyncio.create_task(backfill_search_stats())
|
| 1570 |
asyncio.create_task(resume_incomplete_scans())
|
| 1571 |
+
asyncio.create_task(resume_incomplete_reclasses())
|
| 1572 |
except Exception as e:
|
| 1573 |
print(f"WARN: DB init failed ({e}), will retry on first request")
|
| 1574 |
|
|
|
|
| 1614 |
print(f"WARN: scan resume failed: {e}")
|
| 1615 |
|
| 1616 |
|
| 1617 |
+
async def get_incomplete_reclass_jobs():
|
| 1618 |
+
resp = await turso_execute(
|
| 1619 |
+
[
|
| 1620 |
+
{
|
| 1621 |
+
"sql": "SELECT id, post_ids, filters, total, scanned, summary, log_enc "
|
| 1622 |
+
"FROM pi_reclass_jobs WHERE scanned < total ORDER BY id",
|
| 1623 |
+
}
|
| 1624 |
+
]
|
| 1625 |
+
)
|
| 1626 |
+
jobs = []
|
| 1627 |
+
for row in first_rows(resp):
|
| 1628 |
+
job_id = cell_value(row, 0, "")
|
| 1629 |
+
post_ids = json.loads(cell_value(row, 1, "[]"))
|
| 1630 |
+
filters = json.loads(cell_value(row, 2, "[]"))
|
| 1631 |
+
total = int(cell_value(row, 3, "0"))
|
| 1632 |
+
scanned = min(int(cell_value(row, 4, "0")), total)
|
| 1633 |
+
summary = parse_json_maybe(cell_value(row, 5, "{}")) or {}
|
| 1634 |
+
changes = decrypt_log(cell_value(row, 6, ""))
|
| 1635 |
+
remaining = post_ids[scanned:]
|
| 1636 |
+
current = await get_scanned_post_ids(remaining)
|
| 1637 |
+
targets = []
|
| 1638 |
+
for post_id in remaining:
|
| 1639 |
+
item = current.get(str(post_id), {})
|
| 1640 |
+
targets.append(
|
| 1641 |
+
{
|
| 1642 |
+
"post_id": str(post_id),
|
| 1643 |
+
"url": item.get("url", ""),
|
| 1644 |
+
"exif_type": item.get("exif_type"),
|
| 1645 |
+
}
|
| 1646 |
+
)
|
| 1647 |
+
jobs.append(
|
| 1648 |
+
{
|
| 1649 |
+
"id": job_id,
|
| 1650 |
+
"targets": targets,
|
| 1651 |
+
"filters": filters,
|
| 1652 |
+
"total": total or len(post_ids),
|
| 1653 |
+
"scanned": scanned,
|
| 1654 |
+
"changes": changes,
|
| 1655 |
+
"updated": int(summary.get("updated") or 0),
|
| 1656 |
+
}
|
| 1657 |
+
)
|
| 1658 |
+
return jobs
|
| 1659 |
+
|
| 1660 |
+
|
| 1661 |
+
async def resume_incomplete_reclasses():
|
| 1662 |
+
try:
|
| 1663 |
+
started = 0
|
| 1664 |
+
for job in await get_incomplete_reclass_jobs():
|
| 1665 |
+
job_id = job["id"]
|
| 1666 |
+
if job_id in ACTIVE_TASKS:
|
| 1667 |
+
continue
|
| 1668 |
+
if job["targets"]:
|
| 1669 |
+
asyncio.create_task(
|
| 1670 |
+
bg_reclass_task(
|
| 1671 |
+
job_id,
|
| 1672 |
+
job["targets"],
|
| 1673 |
+
job["filters"],
|
| 1674 |
+
PHPSESSID,
|
| 1675 |
+
total=job["total"],
|
| 1676 |
+
scanned=job["scanned"],
|
| 1677 |
+
changes=job["changes"],
|
| 1678 |
+
updated=job["updated"],
|
| 1679 |
+
)
|
| 1680 |
+
)
|
| 1681 |
+
started += 1
|
| 1682 |
+
else:
|
| 1683 |
+
await finish_reclass_job(
|
| 1684 |
+
job_id,
|
| 1685 |
+
job["scanned"],
|
| 1686 |
+
job["changes"],
|
| 1687 |
+
job["updated"],
|
| 1688 |
+
job["filters"],
|
| 1689 |
+
)
|
| 1690 |
+
if started:
|
| 1691 |
+
print(f"Resumed {started} incomplete reclasses")
|
| 1692 |
+
except Exception as e:
|
| 1693 |
+
print(f"WARN: reclass resume failed: {e}")
|
| 1694 |
+
|
| 1695 |
+
|
| 1696 |
@app.post("/api/submit")
|
| 1697 |
async def submit_search(req: SearchRequest, bg: BackgroundTasks):
|
| 1698 |
search_id = base26_time()
|
|
|
|
| 1797 |
active = ACTIVE_TASKS.get(job_id)
|
| 1798 |
total = int(cell_value(row, 3, "0"))
|
| 1799 |
scanned = int(cell_value(row, 4, "0"))
|
| 1800 |
+
changed = int(cell_value(row, 5, "0"))
|
| 1801 |
if active:
|
| 1802 |
total = int(active.get("total") or total)
|
| 1803 |
scanned = int(active.get("done") or scanned)
|
| 1804 |
+
changed = int(active.get("changed") or changed)
|
| 1805 |
return {
|
| 1806 |
"id": cell_value(row, 0, ""),
|
| 1807 |
"kind": "reclass",
|
|
|
|
| 1809 |
"filters": json.loads(cell_value(row, 2, "[]")),
|
| 1810 |
"total": total,
|
| 1811 |
"scanned": scanned,
|
| 1812 |
+
"changed": changed,
|
| 1813 |
"summary": summary,
|
| 1814 |
"log": decrypt_log(cell_value(row, 7, "")),
|
| 1815 |
"created_at": int(cell_value(row, 8, "0")),
|
frontend/app.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
| 1 |
const $ = s => document.querySelector(s)
|
| 2 |
const $$ = s => document.querySelectorAll(s)
|
| 3 |
-
const EXIF_NAMES = { 1: "novelai", 2: "comfy", 3: "sd", 4: "mj", 5: "celsys", 6: "photoshop", 7: "stealth" }
|
| 4 |
-
const EXIF_CODES = [1, 2, 3, 4, 5, 6, 7]
|
| 5 |
const CATEGORY_NAMES = { ...EXIF_NAMES, 0: "None" }
|
| 6 |
const FILTER_CODES = [...EXIF_CODES, 0]
|
| 7 |
const EXIF_FILTER_KEY = "pixif2-exif-types"
|
|
@@ -225,7 +225,7 @@ async function loadSearches(page = 1, silent = false) {
|
|
| 225 |
const savedHtml = searches.map(s => {
|
| 226 |
const d = new Date(parseInt(s.created_at) * 1000)
|
| 227 |
const ts = d.toLocaleString()
|
| 228 |
-
const count = s.kind === "reclass" ? `${s.
|
| 229 |
const kind = s.kind === "reclass" ? "reclass " : ""
|
| 230 |
return `<div class="search-item" data-id="${esc(s.id)}">
|
| 231 |
<span class="id">${esc(s.id)}</span>
|
|
|
|
| 1 |
const $ = s => document.querySelector(s)
|
| 2 |
const $$ = s => document.querySelectorAll(s)
|
| 3 |
+
const EXIF_NAMES = { 1: "novelai", 2: "comfy", 3: "sd", 4: "mj", 5: "celsys", 6: "photoshop", 7: "stealth", 8: "invoke", 9: "fooocus", 10: "swarm" }
|
| 4 |
+
const EXIF_CODES = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]
|
| 5 |
const CATEGORY_NAMES = { ...EXIF_NAMES, 0: "None" }
|
| 6 |
const FILTER_CODES = [...EXIF_CODES, 0]
|
| 7 |
const EXIF_FILTER_KEY = "pixif2-exif-types"
|
|
|
|
| 225 |
const savedHtml = searches.map(s => {
|
| 226 |
const d = new Date(parseInt(s.created_at) * 1000)
|
| 227 |
const ts = d.toLocaleString()
|
| 228 |
+
const count = s.kind === "reclass" ? `${s.total_searched || 0}/${s.total || 0}` : `${s.found_exif || 0}/${s.total_searched || 0}`
|
| 229 |
const kind = s.kind === "reclass" ? "reclass " : ""
|
| 230 |
return `<div class="search-item" data-id="${esc(s.id)}">
|
| 231 |
<span class="id">${esc(s.id)}</span>
|