Encrypt saved images and add explorer
Browse files- app.py +181 -10
- explorer.css +140 -0
- explorer.html +14 -0
- explorer.js +149 -0
- requirements.txt +2 -0
app.py
CHANGED
|
@@ -3,6 +3,7 @@ import random
|
|
| 3 |
import secrets
|
| 4 |
import shutil
|
| 5 |
import sys
|
|
|
|
| 6 |
import threading
|
| 7 |
import time
|
| 8 |
import urllib.parse
|
|
@@ -32,17 +33,21 @@ BaseEventLoop.__del__ = close_loop
|
|
| 32 |
|
| 33 |
import gradio as gr
|
| 34 |
import numpy as np
|
|
|
|
| 35 |
import spaces
|
| 36 |
import torch
|
|
|
|
|
|
|
| 37 |
from fastapi import Body, Depends, HTTPException, Query
|
| 38 |
from fastapi.middleware.cors import CORSMiddleware
|
| 39 |
-
from fastapi.responses import Response
|
| 40 |
from gradio_client import Client
|
| 41 |
from gradio.routes import App
|
| 42 |
from huggingface_hub import download_bucket_files, list_bucket_tree
|
| 43 |
from PIL import Image
|
| 44 |
from PIL.PngImagePlugin import PngInfo
|
| 45 |
from pydantic import BaseModel, Field
|
|
|
|
| 46 |
from workflow_api import image_metadata
|
| 47 |
|
| 48 |
|
|
@@ -53,9 +58,16 @@ DATA_DIR = Path(os.environ.get("DATA_DIR", DEFAULT_DATA_DIR))
|
|
| 53 |
LOCAL_MODEL_DIR = Path(os.environ.get("LOCAL_MODEL_DIR", "/tmp/models"))
|
| 54 |
COMFYUI_PATH = Path(os.environ.get("COMFYUI_PATH", Path.cwd() / "ComfyUI"))
|
| 55 |
OUTPUT_DIR = DATA_DIR / "output"
|
|
|
|
| 56 |
TIMEZONE = ZoneInfo("Asia/Singapore")
|
| 57 |
MIB = 1024 * 1024
|
| 58 |
COPY_SIZE = 16 * MIB
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 59 |
RETURN_SCALE = .5
|
| 60 |
PORT = int(os.environ.get("PORT", "7860"))
|
| 61 |
LOCAL_URL = os.environ.get("LOCAL_URL", f"http://127.0.0.1:{PORT}")
|
|
@@ -65,6 +77,9 @@ HF_TOKEN = os.environ.get("HF_TOKEN")
|
|
| 65 |
PASSWORD = os.environ.get("pass")
|
| 66 |
if not PASSWORD:
|
| 67 |
raise RuntimeError("pass environment variable is required")
|
|
|
|
|
|
|
|
|
|
| 68 |
|
| 69 |
BUCKET_ID = "q6/C-storage"
|
| 70 |
MODEL_KINDS = ("checkpoints", "vae", "loras")
|
|
@@ -151,12 +166,11 @@ def cleanup_mount():
|
|
| 151 |
shutil.rmtree(OUTPUT_DIR)
|
| 152 |
log("Deleted output directory")
|
| 153 |
|
| 154 |
-
|
| 155 |
-
|
| 156 |
-
for path in image_dir.rglob("*"):
|
| 157 |
if path.is_file() and path.suffix.casefold() == ".7z":
|
| 158 |
path.unlink()
|
| 159 |
-
log(f"Deleted img/{path.relative_to(
|
| 160 |
|
| 161 |
|
| 162 |
def download(url, target):
|
|
@@ -243,6 +257,10 @@ class DirectRequest(BaseModel):
|
|
| 243 |
denoise: float = Field(DEFAULT_DENOISE, ge=0, le=1)
|
| 244 |
|
| 245 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 246 |
def ensure_comfy():
|
| 247 |
if COMFYUI_PATH.is_dir():
|
| 248 |
log(f"Found ComfyUI at {COMFYUI_PATH}")
|
|
@@ -584,20 +602,37 @@ def image_png_bytes(image):
|
|
| 584 |
return png_bytes(image, pnginfo)
|
| 585 |
|
| 586 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 587 |
def save_image(image, tag):
|
| 588 |
-
path =
|
| 589 |
path.mkdir(parents=True, exist_ok=True)
|
| 590 |
number = (
|
| 591 |
sum(
|
| 592 |
-
file.suffix.casefold() in
|
| 593 |
for file in path.iterdir()
|
| 594 |
)
|
| 595 |
+ 1
|
| 596 |
)
|
| 597 |
-
name = f"{number}{tag}.
|
| 598 |
output = path / name
|
| 599 |
-
temp = output.with_suffix(".
|
| 600 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 601 |
temp.replace(output)
|
| 602 |
log(f"Saved img/{path.name}/{name}")
|
| 603 |
|
|
@@ -624,6 +659,53 @@ def png_bytes(image, pnginfo=None):
|
|
| 624 |
return data.getvalue()
|
| 625 |
|
| 626 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 627 |
def valid_pass(p):
|
| 628 |
return secrets.compare_digest(str(p), PASSWORD)
|
| 629 |
|
|
@@ -1023,6 +1105,95 @@ def archive(body: bytes = Body()):
|
|
| 1023 |
return {"status": True}
|
| 1024 |
|
| 1025 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1026 |
def login(p):
|
| 1027 |
if not valid_pass(p):
|
| 1028 |
raise gr.Error("Invalid password")
|
|
|
|
| 3 |
import secrets
|
| 4 |
import shutil
|
| 5 |
import sys
|
| 6 |
+
import tempfile
|
| 7 |
import threading
|
| 8 |
import time
|
| 9 |
import urllib.parse
|
|
|
|
| 33 |
|
| 34 |
import gradio as gr
|
| 35 |
import numpy as np
|
| 36 |
+
import py7zr
|
| 37 |
import spaces
|
| 38 |
import torch
|
| 39 |
+
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
|
| 40 |
+
from cryptography.hazmat.primitives.kdf.scrypt import Scrypt
|
| 41 |
from fastapi import Body, Depends, HTTPException, Query
|
| 42 |
from fastapi.middleware.cors import CORSMiddleware
|
| 43 |
+
from fastapi.responses import FileResponse, Response
|
| 44 |
from gradio_client import Client
|
| 45 |
from gradio.routes import App
|
| 46 |
from huggingface_hub import download_bucket_files, list_bucket_tree
|
| 47 |
from PIL import Image
|
| 48 |
from PIL.PngImagePlugin import PngInfo
|
| 49 |
from pydantic import BaseModel, Field
|
| 50 |
+
from starlette.background import BackgroundTask
|
| 51 |
from workflow_api import image_metadata
|
| 52 |
|
| 53 |
|
|
|
|
| 58 |
LOCAL_MODEL_DIR = Path(os.environ.get("LOCAL_MODEL_DIR", "/tmp/models"))
|
| 59 |
COMFYUI_PATH = Path(os.environ.get("COMFYUI_PATH", Path.cwd() / "ComfyUI"))
|
| 60 |
OUTPUT_DIR = DATA_DIR / "output"
|
| 61 |
+
IMAGE_DIR = DATA_DIR / "img"
|
| 62 |
TIMEZONE = ZoneInfo("Asia/Singapore")
|
| 63 |
MIB = 1024 * 1024
|
| 64 |
COPY_SIZE = 16 * MIB
|
| 65 |
+
SALT_SIZE = 16
|
| 66 |
+
NONCE_SIZE = 12
|
| 67 |
+
SCRYPT_N = 2**14
|
| 68 |
+
FILE_MAGIC = b"EPNG1"
|
| 69 |
+
IMAGE_SUFFIXES = (".epng", ".png")
|
| 70 |
+
PREVIEW_QUALITY = 75
|
| 71 |
RETURN_SCALE = .5
|
| 72 |
PORT = int(os.environ.get("PORT", "7860"))
|
| 73 |
LOCAL_URL = os.environ.get("LOCAL_URL", f"http://127.0.0.1:{PORT}")
|
|
|
|
| 77 |
PASSWORD = os.environ.get("pass")
|
| 78 |
if not PASSWORD:
|
| 79 |
raise RuntimeError("pass environment variable is required")
|
| 80 |
+
HAIP = os.environ.get("haip")
|
| 81 |
+
if not HAIP:
|
| 82 |
+
raise RuntimeError("haip environment variable is required")
|
| 83 |
|
| 84 |
BUCKET_ID = "q6/C-storage"
|
| 85 |
MODEL_KINDS = ("checkpoints", "vae", "loras")
|
|
|
|
| 166 |
shutil.rmtree(OUTPUT_DIR)
|
| 167 |
log("Deleted output directory")
|
| 168 |
|
| 169 |
+
if IMAGE_DIR.is_dir():
|
| 170 |
+
for path in IMAGE_DIR.rglob("*"):
|
|
|
|
| 171 |
if path.is_file() and path.suffix.casefold() == ".7z":
|
| 172 |
path.unlink()
|
| 173 |
+
log(f"Deleted img/{path.relative_to(IMAGE_DIR)}")
|
| 174 |
|
| 175 |
|
| 176 |
def download(url, target):
|
|
|
|
| 257 |
denoise: float = Field(DEFAULT_DENOISE, ge=0, le=1)
|
| 258 |
|
| 259 |
|
| 260 |
+
class DownloadRequest(BaseModel):
|
| 261 |
+
items: list[str]
|
| 262 |
+
|
| 263 |
+
|
| 264 |
def ensure_comfy():
|
| 265 |
if COMFYUI_PATH.is_dir():
|
| 266 |
log(f"Found ComfyUI at {COMFYUI_PATH}")
|
|
|
|
| 602 |
return png_bytes(image, pnginfo)
|
| 603 |
|
| 604 |
|
| 605 |
+
def image_key(salt):
|
| 606 |
+
return Scrypt(
|
| 607 |
+
salt=salt,
|
| 608 |
+
length=32,
|
| 609 |
+
n=SCRYPT_N,
|
| 610 |
+
r=8,
|
| 611 |
+
p=1,
|
| 612 |
+
).derive(HAIP.encode())
|
| 613 |
+
|
| 614 |
+
|
| 615 |
def save_image(image, tag):
|
| 616 |
+
path = IMAGE_DIR / datetime.now(TIMEZONE).date().isoformat()
|
| 617 |
path.mkdir(parents=True, exist_ok=True)
|
| 618 |
number = (
|
| 619 |
sum(
|
| 620 |
+
file.suffix.casefold() in IMAGE_SUFFIXES
|
| 621 |
for file in path.iterdir()
|
| 622 |
)
|
| 623 |
+ 1
|
| 624 |
)
|
| 625 |
+
name = f"{number}{tag}.epng"
|
| 626 |
output = path / name
|
| 627 |
+
temp = output.with_suffix(".epng.part")
|
| 628 |
+
salt = os.urandom(SALT_SIZE)
|
| 629 |
+
nonce = os.urandom(NONCE_SIZE)
|
| 630 |
+
encrypted = AESGCM(image_key(salt)).encrypt(
|
| 631 |
+
nonce,
|
| 632 |
+
image_png_bytes(image),
|
| 633 |
+
FILE_MAGIC,
|
| 634 |
+
)
|
| 635 |
+
temp.write_bytes(FILE_MAGIC + salt + nonce + encrypted)
|
| 636 |
temp.replace(output)
|
| 637 |
log(f"Saved img/{path.name}/{name}")
|
| 638 |
|
|
|
|
| 659 |
return data.getvalue()
|
| 660 |
|
| 661 |
|
| 662 |
+
def stored_path(value):
|
| 663 |
+
root = IMAGE_DIR.resolve()
|
| 664 |
+
path = (root / value).resolve()
|
| 665 |
+
try:
|
| 666 |
+
path.relative_to(root)
|
| 667 |
+
except ValueError as error:
|
| 668 |
+
raise HTTPException(404, "image not found") from error
|
| 669 |
+
if not path.is_file() or path.suffix.casefold() not in IMAGE_SUFFIXES:
|
| 670 |
+
raise HTTPException(404, "image not found")
|
| 671 |
+
return path
|
| 672 |
+
|
| 673 |
+
|
| 674 |
+
def stored_bytes(path):
|
| 675 |
+
data = path.read_bytes()
|
| 676 |
+
if path.suffix.casefold() == ".png":
|
| 677 |
+
return data
|
| 678 |
+
if not data.startswith(FILE_MAGIC):
|
| 679 |
+
raise HTTPException(500, "invalid encrypted image")
|
| 680 |
+
salt_start = len(FILE_MAGIC)
|
| 681 |
+
nonce_start = salt_start + SALT_SIZE
|
| 682 |
+
data_start = nonce_start + NONCE_SIZE
|
| 683 |
+
return AESGCM(image_key(data[salt_start:nonce_start])).decrypt(
|
| 684 |
+
data[nonce_start:data_start],
|
| 685 |
+
data[data_start:],
|
| 686 |
+
FILE_MAGIC,
|
| 687 |
+
)
|
| 688 |
+
|
| 689 |
+
|
| 690 |
+
def selected_paths(items):
|
| 691 |
+
root = IMAGE_DIR.resolve()
|
| 692 |
+
selected = {}
|
| 693 |
+
for value in items:
|
| 694 |
+
path = (root / value).resolve()
|
| 695 |
+
try:
|
| 696 |
+
path.relative_to(root)
|
| 697 |
+
except ValueError as error:
|
| 698 |
+
raise HTTPException(404, "image not found") from error
|
| 699 |
+
paths = path.iterdir() if path.is_dir() else (stored_path(value),)
|
| 700 |
+
for image in paths:
|
| 701 |
+
if image.is_file() and image.suffix.casefold() in IMAGE_SUFFIXES:
|
| 702 |
+
name = image.relative_to(root).with_suffix(".png").as_posix()
|
| 703 |
+
selected[name] = image
|
| 704 |
+
if not selected:
|
| 705 |
+
raise HTTPException(422, "no images selected")
|
| 706 |
+
return selected
|
| 707 |
+
|
| 708 |
+
|
| 709 |
def valid_pass(p):
|
| 710 |
return secrets.compare_digest(str(p), PASSWORD)
|
| 711 |
|
|
|
|
| 1105 |
return {"status": True}
|
| 1106 |
|
| 1107 |
|
| 1108 |
+
@api.get(
|
| 1109 |
+
"/explorer",
|
| 1110 |
+
dependencies=[Depends(require_pass)],
|
| 1111 |
+
)
|
| 1112 |
+
def explorer():
|
| 1113 |
+
return FileResponse(Path.cwd() / "explorer.html")
|
| 1114 |
+
|
| 1115 |
+
|
| 1116 |
+
@api.get("/explorer.css")
|
| 1117 |
+
def explorer_css():
|
| 1118 |
+
return FileResponse(Path.cwd() / "explorer.css", media_type="text/css")
|
| 1119 |
+
|
| 1120 |
+
|
| 1121 |
+
@api.get("/explorer.js")
|
| 1122 |
+
def explorer_js():
|
| 1123 |
+
return FileResponse(
|
| 1124 |
+
Path.cwd() / "explorer.js",
|
| 1125 |
+
media_type="text/javascript",
|
| 1126 |
+
)
|
| 1127 |
+
|
| 1128 |
+
|
| 1129 |
+
@api.get(
|
| 1130 |
+
"/api/images",
|
| 1131 |
+
dependencies=[Depends(require_pass)],
|
| 1132 |
+
)
|
| 1133 |
+
def image_list():
|
| 1134 |
+
root = IMAGE_DIR
|
| 1135 |
+
dates = []
|
| 1136 |
+
if root.is_dir():
|
| 1137 |
+
for folder in sorted(root.iterdir(), reverse=True):
|
| 1138 |
+
if not folder.is_dir():
|
| 1139 |
+
continue
|
| 1140 |
+
items = [
|
| 1141 |
+
{
|
| 1142 |
+
"name": path.with_suffix(".png").name,
|
| 1143 |
+
"path": path.relative_to(root).as_posix(),
|
| 1144 |
+
"size": path.stat().st_size,
|
| 1145 |
+
}
|
| 1146 |
+
for path in sorted(folder.iterdir(), reverse=True)
|
| 1147 |
+
if path.is_file()
|
| 1148 |
+
and path.suffix.casefold() in IMAGE_SUFFIXES
|
| 1149 |
+
]
|
| 1150 |
+
if items:
|
| 1151 |
+
dates.append({"name": folder.name, "images": items})
|
| 1152 |
+
return {"dates": dates}
|
| 1153 |
+
|
| 1154 |
+
|
| 1155 |
+
@api.get(
|
| 1156 |
+
"/api/images/preview",
|
| 1157 |
+
dependencies=[Depends(require_pass)],
|
| 1158 |
+
)
|
| 1159 |
+
def image_preview(path: str = Query(...)):
|
| 1160 |
+
with Image.open(BytesIO(stored_bytes(stored_path(path)))) as image:
|
| 1161 |
+
data = BytesIO()
|
| 1162 |
+
image.save(data, format="WEBP", quality=PREVIEW_QUALITY)
|
| 1163 |
+
return Response(
|
| 1164 |
+
content=data.getvalue(),
|
| 1165 |
+
media_type="image/webp",
|
| 1166 |
+
headers={"Cache-Control": "private, max-age=86400"},
|
| 1167 |
+
)
|
| 1168 |
+
|
| 1169 |
+
|
| 1170 |
+
@api.post(
|
| 1171 |
+
"/api/images/download",
|
| 1172 |
+
dependencies=[Depends(require_pass)],
|
| 1173 |
+
)
|
| 1174 |
+
def image_download(body: DownloadRequest):
|
| 1175 |
+
temp = tempfile.NamedTemporaryFile(suffix=".7z", delete=False)
|
| 1176 |
+
temp.close()
|
| 1177 |
+
try:
|
| 1178 |
+
with py7zr.SevenZipFile(
|
| 1179 |
+
temp.name,
|
| 1180 |
+
"w",
|
| 1181 |
+
password=PASSWORD,
|
| 1182 |
+
header_encryption=True,
|
| 1183 |
+
) as archive_file:
|
| 1184 |
+
for name, path in selected_paths(body.items).items():
|
| 1185 |
+
archive_file.writestr(stored_bytes(path), name)
|
| 1186 |
+
except Exception:
|
| 1187 |
+
Path(temp.name).unlink(missing_ok=True)
|
| 1188 |
+
raise
|
| 1189 |
+
return FileResponse(
|
| 1190 |
+
temp.name,
|
| 1191 |
+
filename="images.7z",
|
| 1192 |
+
media_type="application/x-7z-compressed",
|
| 1193 |
+
background=BackgroundTask(Path(temp.name).unlink, missing_ok=True),
|
| 1194 |
+
)
|
| 1195 |
+
|
| 1196 |
+
|
| 1197 |
def login(p):
|
| 1198 |
if not valid_pass(p):
|
| 1199 |
raise gr.Error("Invalid password")
|
explorer.css
ADDED
|
@@ -0,0 +1,140 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
@import url('https://fonts.googleapis.com/css2?family=Spline+Sans:wght@400;500;600&display=swap');
|
| 2 |
+
|
| 3 |
+
:root {
|
| 4 |
+
--bg: #222;
|
| 5 |
+
--panel: #333;
|
| 6 |
+
--text: #ddd;
|
| 7 |
+
--muted: #999;
|
| 8 |
+
--accent: #7dc;
|
| 9 |
+
--border: #555;
|
| 10 |
+
--gap: 1rem;
|
| 11 |
+
--width: 90%;
|
| 12 |
+
}
|
| 13 |
+
|
| 14 |
+
* {
|
| 15 |
+
box-sizing: border-box;
|
| 16 |
+
}
|
| 17 |
+
|
| 18 |
+
body {
|
| 19 |
+
background: var(--bg);
|
| 20 |
+
color: var(--text);
|
| 21 |
+
font-family: "Spline Sans", sans-serif;
|
| 22 |
+
margin: 0;
|
| 23 |
+
}
|
| 24 |
+
|
| 25 |
+
image-explorer {
|
| 26 |
+
display: block;
|
| 27 |
+
margin: 3rem auto;
|
| 28 |
+
width: var(--width);
|
| 29 |
+
}
|
| 30 |
+
|
| 31 |
+
h1 {
|
| 32 |
+
font-size: 1.5rem;
|
| 33 |
+
font-weight: 500;
|
| 34 |
+
margin: 0 0 var(--gap);
|
| 35 |
+
}
|
| 36 |
+
|
| 37 |
+
.tools {
|
| 38 |
+
display: flex;
|
| 39 |
+
flex-wrap: wrap;
|
| 40 |
+
gap: .5rem;
|
| 41 |
+
margin-bottom: var(--gap);
|
| 42 |
+
}
|
| 43 |
+
|
| 44 |
+
button,
|
| 45 |
+
input[type="search"] {
|
| 46 |
+
background: var(--panel);
|
| 47 |
+
border: 1px solid var(--border);
|
| 48 |
+
border-radius: 0;
|
| 49 |
+
color: var(--text);
|
| 50 |
+
font: inherit;
|
| 51 |
+
padding: .65rem .8rem;
|
| 52 |
+
}
|
| 53 |
+
|
| 54 |
+
input[type="search"] {
|
| 55 |
+
flex: 1 1 16rem;
|
| 56 |
+
}
|
| 57 |
+
|
| 58 |
+
button {
|
| 59 |
+
cursor: pointer;
|
| 60 |
+
}
|
| 61 |
+
|
| 62 |
+
button.primary {
|
| 63 |
+
background: var(--accent);
|
| 64 |
+
border-color: var(--accent);
|
| 65 |
+
color: var(--bg);
|
| 66 |
+
}
|
| 67 |
+
|
| 68 |
+
button:disabled {
|
| 69 |
+
cursor: wait;
|
| 70 |
+
opacity: .6;
|
| 71 |
+
}
|
| 72 |
+
|
| 73 |
+
.status,
|
| 74 |
+
.count,
|
| 75 |
+
.size {
|
| 76 |
+
color: var(--muted);
|
| 77 |
+
}
|
| 78 |
+
|
| 79 |
+
.status {
|
| 80 |
+
min-height: 1.5rem;
|
| 81 |
+
}
|
| 82 |
+
|
| 83 |
+
.date {
|
| 84 |
+
border-top: 1px solid var(--border);
|
| 85 |
+
margin-top: 1.5rem;
|
| 86 |
+
padding-top: 1rem;
|
| 87 |
+
}
|
| 88 |
+
|
| 89 |
+
.date-title {
|
| 90 |
+
align-items: center;
|
| 91 |
+
display: flex;
|
| 92 |
+
gap: .6rem;
|
| 93 |
+
margin-bottom: .8rem;
|
| 94 |
+
}
|
| 95 |
+
|
| 96 |
+
.grid {
|
| 97 |
+
display: grid;
|
| 98 |
+
gap: var(--gap);
|
| 99 |
+
grid-template-columns: repeat(auto-fill, minmax(12rem, 1fr));
|
| 100 |
+
}
|
| 101 |
+
|
| 102 |
+
.image {
|
| 103 |
+
background: var(--panel);
|
| 104 |
+
border: 1px solid var(--border);
|
| 105 |
+
cursor: pointer;
|
| 106 |
+
display: grid;
|
| 107 |
+
gap: .5rem;
|
| 108 |
+
padding: .5rem;
|
| 109 |
+
}
|
| 110 |
+
|
| 111 |
+
.image img {
|
| 112 |
+
aspect-ratio: 1;
|
| 113 |
+
background: var(--bg);
|
| 114 |
+
display: block;
|
| 115 |
+
object-fit: contain;
|
| 116 |
+
width: 100%;
|
| 117 |
+
}
|
| 118 |
+
|
| 119 |
+
.image-info {
|
| 120 |
+
display: grid;
|
| 121 |
+
gap: .2rem;
|
| 122 |
+
grid-template-columns: auto 1fr;
|
| 123 |
+
}
|
| 124 |
+
|
| 125 |
+
.name,
|
| 126 |
+
.size {
|
| 127 |
+
min-width: 0;
|
| 128 |
+
overflow: hidden;
|
| 129 |
+
text-overflow: ellipsis;
|
| 130 |
+
white-space: nowrap;
|
| 131 |
+
}
|
| 132 |
+
|
| 133 |
+
.size {
|
| 134 |
+
grid-column: 2;
|
| 135 |
+
font-size: .8rem;
|
| 136 |
+
}
|
| 137 |
+
|
| 138 |
+
[hidden] {
|
| 139 |
+
display: none !important;
|
| 140 |
+
}
|
explorer.html
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<!doctype html>
|
| 2 |
+
<html lang="en">
|
| 3 |
+
<head>
|
| 4 |
+
<meta charset="utf-8">
|
| 5 |
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
| 6 |
+
<title>Image Explorer</title>
|
| 7 |
+
<link rel="icon" href="data:,">
|
| 8 |
+
<link rel="stylesheet" href="/explorer.css">
|
| 9 |
+
<script type="module" src="/explorer.js"></script>
|
| 10 |
+
</head>
|
| 11 |
+
<body>
|
| 12 |
+
<image-explorer></image-explorer>
|
| 13 |
+
</body>
|
| 14 |
+
</html>
|
explorer.js
ADDED
|
@@ -0,0 +1,149 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
const $ = (selector, root = document) => root.querySelector(selector)
|
| 2 |
+
const $$ = (selector, root = document) => [...root.querySelectorAll(selector)]
|
| 3 |
+
const p = new URLSearchParams(location.search).get("p")
|
| 4 |
+
const api = (path, query = {}) => `${path}?${new URLSearchParams({ p, ...query })}`
|
| 5 |
+
const preview = path => api("/api/images/preview", { path })
|
| 6 |
+
const size = bytes => `${(bytes / 1024).toFixed(bytes < 10240 ? 1 : 0)} KiB`
|
| 7 |
+
|
| 8 |
+
class ImageExplorer extends HTMLElement {
|
| 9 |
+
connectedCallback() {
|
| 10 |
+
this.innerHTML = `
|
| 11 |
+
<main>
|
| 12 |
+
<h1>Images</h1>
|
| 13 |
+
<div class="tools">
|
| 14 |
+
<input type="search" placeholder="Filter images" aria-label="Filter images">
|
| 15 |
+
<button class="all" type="button">Select all</button>
|
| 16 |
+
<button class="clear" type="button">Clear</button>
|
| 17 |
+
<button class="download primary" type="button">Download 7z</button>
|
| 18 |
+
</div>
|
| 19 |
+
<div class="status">Loading</div>
|
| 20 |
+
<div class="dates"></div>
|
| 21 |
+
</main>`
|
| 22 |
+
this.load()
|
| 23 |
+
$(".all", this).addEventListener("click", () => this.select(true))
|
| 24 |
+
$(".clear", this).addEventListener("click", () => this.select(false))
|
| 25 |
+
$(".download", this).addEventListener("click", () => this.download())
|
| 26 |
+
$("input[type=search]", this).addEventListener("input", event => {
|
| 27 |
+
this.filter(event.target.value)
|
| 28 |
+
})
|
| 29 |
+
}
|
| 30 |
+
|
| 31 |
+
async load() {
|
| 32 |
+
const response = await fetch(api("/api/images"))
|
| 33 |
+
if (!response.ok) {
|
| 34 |
+
$(".status", this).textContent = "Unable to load images"
|
| 35 |
+
return
|
| 36 |
+
}
|
| 37 |
+
const data = await response.json()
|
| 38 |
+
for (const date of data.dates) this.addDate(date)
|
| 39 |
+
this.update()
|
| 40 |
+
}
|
| 41 |
+
|
| 42 |
+
addDate(date) {
|
| 43 |
+
const section = document.createElement("section")
|
| 44 |
+
section.className = "date"
|
| 45 |
+
const title = document.createElement("label")
|
| 46 |
+
title.className = "date-title"
|
| 47 |
+
const folder = document.createElement("input")
|
| 48 |
+
folder.type = "checkbox"
|
| 49 |
+
folder.className = "folder"
|
| 50 |
+
const name = document.createElement("strong")
|
| 51 |
+
name.textContent = date.name
|
| 52 |
+
const count = document.createElement("span")
|
| 53 |
+
count.className = "count"
|
| 54 |
+
count.textContent = `${date.images.length} images`
|
| 55 |
+
const grid = document.createElement("div")
|
| 56 |
+
grid.className = "grid"
|
| 57 |
+
title.append(folder, name, count)
|
| 58 |
+
section.append(title, grid)
|
| 59 |
+
for (const image of date.images) grid.append(this.card(image))
|
| 60 |
+
folder.addEventListener("change", () => {
|
| 61 |
+
for (const input of $$(".file", section)) input.checked = folder.checked
|
| 62 |
+
this.update()
|
| 63 |
+
})
|
| 64 |
+
$(".dates", this).append(section)
|
| 65 |
+
}
|
| 66 |
+
|
| 67 |
+
card(image) {
|
| 68 |
+
const card = document.createElement("label")
|
| 69 |
+
card.className = "image"
|
| 70 |
+
card.dataset.name = image.name.toLowerCase()
|
| 71 |
+
const picture = document.createElement("img")
|
| 72 |
+
picture.loading = "lazy"
|
| 73 |
+
picture.src = preview(image.path)
|
| 74 |
+
picture.alt = image.name
|
| 75 |
+
const info = document.createElement("span")
|
| 76 |
+
info.className = "image-info"
|
| 77 |
+
const input = document.createElement("input")
|
| 78 |
+
input.type = "checkbox"
|
| 79 |
+
input.className = "file"
|
| 80 |
+
input.dataset.path = image.path
|
| 81 |
+
const name = document.createElement("span")
|
| 82 |
+
name.className = "name"
|
| 83 |
+
name.textContent = image.name
|
| 84 |
+
const bytes = document.createElement("span")
|
| 85 |
+
bytes.className = "size"
|
| 86 |
+
bytes.textContent = size(image.size)
|
| 87 |
+
input.addEventListener("change", () => this.update())
|
| 88 |
+
info.append(input, name, bytes)
|
| 89 |
+
card.append(picture, info)
|
| 90 |
+
return card
|
| 91 |
+
}
|
| 92 |
+
|
| 93 |
+
select(checked) {
|
| 94 |
+
for (const input of $$(".file", this)) input.checked = checked
|
| 95 |
+
this.update()
|
| 96 |
+
}
|
| 97 |
+
|
| 98 |
+
update() {
|
| 99 |
+
for (const section of $$(".date", this)) {
|
| 100 |
+
const files = $$(".file", section)
|
| 101 |
+
const checked = files.filter(input => input.checked).length
|
| 102 |
+
const folder = $(".folder", section)
|
| 103 |
+
folder.checked = checked === files.length
|
| 104 |
+
folder.indeterminate = checked > 0 && checked < files.length
|
| 105 |
+
}
|
| 106 |
+
const count = $$(".file:checked", this).length
|
| 107 |
+
$(".status", this).textContent = `${count} selected`
|
| 108 |
+
}
|
| 109 |
+
|
| 110 |
+
filter(value) {
|
| 111 |
+
const query = value.trim().toLowerCase()
|
| 112 |
+
for (const section of $$(".date", this)) {
|
| 113 |
+
for (const card of $$(".image", section)) {
|
| 114 |
+
card.hidden = Boolean(query && !card.dataset.name.includes(query))
|
| 115 |
+
}
|
| 116 |
+
section.hidden = $$(".image:not([hidden])", section).length === 0
|
| 117 |
+
}
|
| 118 |
+
}
|
| 119 |
+
|
| 120 |
+
async download() {
|
| 121 |
+
const items = $$(".file:checked", this).map(input => input.dataset.path)
|
| 122 |
+
if (!items.length) return
|
| 123 |
+
const button = $(".download", this)
|
| 124 |
+
button.disabled = true
|
| 125 |
+
$(".status", this).textContent = "Building archive"
|
| 126 |
+
try {
|
| 127 |
+
const response = await fetch(api("/api/images/download"), {
|
| 128 |
+
method: "POST",
|
| 129 |
+
headers: { "Content-Type": "application/json" },
|
| 130 |
+
body: JSON.stringify({ items }),
|
| 131 |
+
})
|
| 132 |
+
if (!response.ok) throw new Error("Archive failed")
|
| 133 |
+
const url = URL.createObjectURL(await response.blob())
|
| 134 |
+
const link = document.createElement("a")
|
| 135 |
+
link.href = url
|
| 136 |
+
link.download = "images.7z"
|
| 137 |
+
link.click()
|
| 138 |
+
URL.revokeObjectURL(url)
|
| 139 |
+
} catch (error) {
|
| 140 |
+
$(".status", this).textContent = error.message
|
| 141 |
+
return
|
| 142 |
+
} finally {
|
| 143 |
+
button.disabled = false
|
| 144 |
+
}
|
| 145 |
+
this.update()
|
| 146 |
+
}
|
| 147 |
+
}
|
| 148 |
+
|
| 149 |
+
customElements.define("image-explorer", ImageExplorer)
|
requirements.txt
CHANGED
|
@@ -1,7 +1,9 @@
|
|
| 1 |
fastapi
|
|
|
|
| 2 |
gradio==6.20.0
|
| 3 |
huggingface_hub>=1.6
|
| 4 |
numpy
|
| 5 |
Pillow
|
|
|
|
| 6 |
uvicorn
|
| 7 |
-r https://raw.githubusercontent.com/Comfy-Org/ComfyUI/master/requirements.txt
|
|
|
|
| 1 |
fastapi
|
| 2 |
+
cryptography
|
| 3 |
gradio==6.20.0
|
| 4 |
huggingface_hub>=1.6
|
| 5 |
numpy
|
| 6 |
Pillow
|
| 7 |
+
py7zr==1.1.2
|
| 8 |
uvicorn
|
| 9 |
-r https://raw.githubusercontent.com/Comfy-Org/ComfyUI/master/requirements.txt
|