q6 commited on
Commit
ea72418
·
1 Parent(s): 4fab6a2

Encrypt saved images and add explorer

Browse files
Files changed (5) hide show
  1. app.py +181 -10
  2. explorer.css +140 -0
  3. explorer.html +14 -0
  4. explorer.js +149 -0
  5. 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
- image_dir = DATA_DIR / "img"
155
- if image_dir.is_dir():
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(image_dir)}")
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 = DATA_DIR / "img" / datetime.now(TIMEZONE).date().isoformat()
589
  path.mkdir(parents=True, exist_ok=True)
590
  number = (
591
  sum(
592
- file.suffix.casefold() in (".epng", ".png")
593
  for file in path.iterdir()
594
  )
595
  + 1
596
  )
597
- name = f"{number}{tag}.png"
598
  output = path / name
599
- temp = output.with_suffix(".png.part")
600
- temp.write_bytes(image_png_bytes(image))
 
 
 
 
 
 
 
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