HailXD commited on
Commit ·
feafa1f
1
Parent(s): 4367113
Process encrypted bucket jobs and deliver results from the Space
Browse files- app.py +19 -0
- bucket_jobs.py +211 -0
- requirements.txt +4 -0
app.py
CHANGED
|
@@ -21,6 +21,7 @@ from ignored_preview import save_ignored_preview
|
|
| 21 |
from clean import load_clean, clean_image
|
| 22 |
from illustrious import load_illustrious, prepare_illustrious, infer_illustrious, save_illustrious
|
| 23 |
from watermark_remover import WatermarkRemover
|
|
|
|
| 24 |
|
| 25 |
INPUT_IMAGE_HEIGHT = 600
|
| 26 |
TEXT_WORKER = [sys.executable, "-m", "text_detection"]
|
|
@@ -267,6 +268,10 @@ def remove_watermark(file_obj, strength, steps, model, ignore_eyes=False, ignore
|
|
| 267 |
return make_terminal_html(cmd_html + f"\n\nDone!{preserved}{cleaned}", cursor=False), str(output_path), str(preview_path)
|
| 268 |
|
| 269 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 270 |
def check_ai_metadata(file_obj):
|
| 271 |
if file_obj is None:
|
| 272 |
raise gr.Error("Please upload an image first.")
|
|
@@ -327,6 +332,19 @@ MODELS = load_models()
|
|
| 327 |
|
| 328 |
with gr.Blocks(title="noai-watermark") as demo:
|
| 329 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 330 |
gr.HTML("""
|
| 331 |
<h1 style="text-align:center;">🛡️ noai-watermark</h1>
|
| 332 |
<p style="text-align:center; color:#888;">
|
|
@@ -394,6 +412,7 @@ with gr.Blocks(title="noai-watermark") as demo:
|
|
| 394 |
fn=remove_watermark,
|
| 395 |
inputs=[upload_wm, strength, steps, model, ignore_eyes, ignore_hands, ignore_feet, clean_after, ignore_text],
|
| 396 |
outputs=[wm_terminal, output_image, ignored_image],
|
|
|
|
| 397 |
concurrency_limit=1,
|
| 398 |
)
|
| 399 |
|
|
|
|
| 21 |
from clean import load_clean, clean_image
|
| 22 |
from illustrious import load_illustrious, prepare_illustrious, infer_illustrious, save_illustrious
|
| 23 |
from watermark_remover import WatermarkRemover
|
| 24 |
+
from bucket_jobs import run_bucket_job
|
| 25 |
|
| 26 |
INPUT_IMAGE_HEIGHT = 600
|
| 27 |
TEXT_WORKER = [sys.executable, "-m", "text_detection"]
|
|
|
|
| 268 |
return make_terminal_html(cmd_html + f"\n\nDone!{preserved}{cleaned}", cursor=False), str(output_path), str(preview_path)
|
| 269 |
|
| 270 |
|
| 271 |
+
def process_bucket_job(payload):
|
| 272 |
+
return run_bucket_job(payload, remove_watermark)
|
| 273 |
+
|
| 274 |
+
|
| 275 |
def check_ai_metadata(file_obj):
|
| 276 |
if file_obj is None:
|
| 277 |
raise gr.Error("Please upload an image first.")
|
|
|
|
| 332 |
|
| 333 |
with gr.Blocks(title="noai-watermark") as demo:
|
| 334 |
|
| 335 |
+
bucket_payload = gr.JSON(visible=False)
|
| 336 |
+
bucket_result = gr.JSON(visible=False)
|
| 337 |
+
bucket_submit = gr.Button(visible=False)
|
| 338 |
+
bucket_submit.click(
|
| 339 |
+
fn=process_bucket_job,
|
| 340 |
+
inputs=[bucket_payload],
|
| 341 |
+
outputs=[bucket_result],
|
| 342 |
+
api_name="process_bucket_job",
|
| 343 |
+
api_visibility="undocumented",
|
| 344 |
+
concurrency_id="watermark",
|
| 345 |
+
concurrency_limit=1,
|
| 346 |
+
)
|
| 347 |
+
|
| 348 |
gr.HTML("""
|
| 349 |
<h1 style="text-align:center;">🛡️ noai-watermark</h1>
|
| 350 |
<p style="text-align:center; color:#888;">
|
|
|
|
| 412 |
fn=remove_watermark,
|
| 413 |
inputs=[upload_wm, strength, steps, model, ignore_eyes, ignore_hands, ignore_feet, clean_after, ignore_text],
|
| 414 |
outputs=[wm_terminal, output_image, ignored_image],
|
| 415 |
+
concurrency_id="watermark",
|
| 416 |
concurrency_limit=1,
|
| 417 |
)
|
| 418 |
|
bucket_jobs.py
ADDED
|
@@ -0,0 +1,211 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import hashlib
|
| 2 |
+
import hmac
|
| 3 |
+
import json
|
| 4 |
+
import math
|
| 5 |
+
import re
|
| 6 |
+
import sqlite3
|
| 7 |
+
import time
|
| 8 |
+
from contextlib import closing
|
| 9 |
+
from html.parser import HTMLParser
|
| 10 |
+
from pathlib import Path
|
| 11 |
+
from secrets import token_bytes
|
| 12 |
+
from tempfile import TemporaryDirectory
|
| 13 |
+
from urllib.parse import urlencode
|
| 14 |
+
|
| 15 |
+
import httpx
|
| 16 |
+
import xxhash
|
| 17 |
+
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
|
| 18 |
+
from huggingface_hub import batch_bucket_files
|
| 19 |
+
|
| 20 |
+
API_URL = "https://hailxd.vercel.app"
|
| 21 |
+
DISCORD_API = "https://discord.com/api/v9"
|
| 22 |
+
DEV_CHANNEL = "1352311755682091111"
|
| 23 |
+
BOT_USER_AGENT = f"DiscordBot ({API_URL}, 1.0.0)"
|
| 24 |
+
JOB_DB = (Path("/data") if Path("/data").is_dir() else Path.home() / ".cache/no-watermark") / "bucket-jobs.sqlite3"
|
| 25 |
+
MAX_BYTES = 128 * 1024 * 1024
|
| 26 |
+
NONCE_BYTES = 12
|
| 27 |
+
TAG_BYTES = 16
|
| 28 |
+
RETRY_DELAYS = (1, 2, 4, 8, 16)
|
| 29 |
+
MODELS = {"CtrlRegen", "CtrlRegen Illustrious (SDXL)", "Default"}
|
| 30 |
+
IMAGE_TYPES = {"image/png": ".png", "image/jpeg": ".jpg", "image/webp": ".webp"}
|
| 31 |
+
CREATE_SQL = (
|
| 32 |
+
"CREATE TABLE IF NOT EXISTS jobs (id TEXT PRIMARY KEY, fingerprint TEXT NOT NULL, "
|
| 33 |
+
"output_hash TEXT NOT NULL DEFAULT '', discord_id TEXT NOT NULL DEFAULT '', error TEXT NOT NULL DEFAULT '')"
|
| 34 |
+
)
|
| 35 |
+
|
| 36 |
+
|
| 37 |
+
class JobError(ValueError):
|
| 38 |
+
pass
|
| 39 |
+
|
| 40 |
+
|
| 41 |
+
class PlainText(HTMLParser):
|
| 42 |
+
def __init__(self):
|
| 43 |
+
super().__init__()
|
| 44 |
+
self.parts = []
|
| 45 |
+
|
| 46 |
+
def handle_data(self, data):
|
| 47 |
+
self.parts.append(data)
|
| 48 |
+
|
| 49 |
+
|
| 50 |
+
def _validate(payload):
|
| 51 |
+
if not isinstance(payload, dict):
|
| 52 |
+
raise JobError("Invalid job")
|
| 53 |
+
job = dict(payload)
|
| 54 |
+
for name in ("id", "input_hash", "input_url", "filename", "content_type", "bucket", "suffix", "hf_token", "discord_token", "channel"):
|
| 55 |
+
if not isinstance(job.get(name), str) or not job[name]:
|
| 56 |
+
raise JobError("Invalid job settings")
|
| 57 |
+
if not re.fullmatch(r"[a-f0-9-]{36}", job["id"]):
|
| 58 |
+
raise JobError("Invalid job ID")
|
| 59 |
+
if not re.fullmatch(r"[A-Za-z0-9_-]{8}", job["suffix"]):
|
| 60 |
+
raise JobError("Invalid encryption suffix")
|
| 61 |
+
if not re.fullmatch(r"[a-f0-9]{32}" + re.escape(job["suffix"]), job["input_hash"]):
|
| 62 |
+
raise JobError("Invalid encrypted image hash")
|
| 63 |
+
if not re.fullmatch(r"[A-Za-z0-9_-]+/[A-Za-z0-9_.-]+", job["bucket"]):
|
| 64 |
+
raise JobError("Invalid Hugging Face bucket")
|
| 65 |
+
expected_url = f"https://huggingface.co/buckets/{job['bucket']}/resolve/{job['input_hash']}"
|
| 66 |
+
if job["input_url"] != expected_url:
|
| 67 |
+
raise JobError("Invalid input bucket URL")
|
| 68 |
+
if job["channel"] != DEV_CHANNEL:
|
| 69 |
+
raise JobError("Delivery must use the Discord Dev channel")
|
| 70 |
+
if job["content_type"] not in IMAGE_TYPES or len(job["filename"]) > 240 or any(ord(char) < 32 for char in job["filename"]):
|
| 71 |
+
raise JobError("Invalid image metadata")
|
| 72 |
+
key = job.get("key")
|
| 73 |
+
if not isinstance(key, list) or len(key) != 16 or any(type(value) is not int or not 0 <= value <= 255 for value in key):
|
| 74 |
+
raise JobError("Invalid encryption key")
|
| 75 |
+
settings = job.get("options")
|
| 76 |
+
if not isinstance(settings, list) or len(settings) != 8:
|
| 77 |
+
raise JobError("Invalid image settings")
|
| 78 |
+
strength, steps, model, *flags = settings
|
| 79 |
+
if type(strength) not in (int, float) or not math.isfinite(strength) or not 0.01 <= strength <= 1:
|
| 80 |
+
raise JobError("Invalid strength")
|
| 81 |
+
if type(steps) is not int or not 10 <= steps <= 100 or not isinstance(model, str) or model not in MODELS:
|
| 82 |
+
raise JobError("Invalid model or steps")
|
| 83 |
+
if any(type(flag) is not bool for flag in flags):
|
| 84 |
+
raise JobError("Invalid image options")
|
| 85 |
+
return job
|
| 86 |
+
|
| 87 |
+
|
| 88 |
+
def _record(job):
|
| 89 |
+
fingerprint = hashlib.sha256(json.dumps(
|
| 90 |
+
{name: value for name, value in job.items() if name not in {"hf_token", "discord_token"}},
|
| 91 |
+
sort_keys=True, separators=(",", ":"),
|
| 92 |
+
).encode()).hexdigest()
|
| 93 |
+
JOB_DB.parent.mkdir(parents=True, exist_ok=True)
|
| 94 |
+
with closing(sqlite3.connect(JOB_DB)) as db, db:
|
| 95 |
+
db.execute(CREATE_SQL)
|
| 96 |
+
db.execute("INSERT INTO jobs (id, fingerprint) VALUES (?, ?) ON CONFLICT(id) DO NOTHING", (job["id"], fingerprint))
|
| 97 |
+
row = db.execute("SELECT fingerprint, output_hash, discord_id, error FROM jobs WHERE id = ?", (job["id"],)).fetchone()
|
| 98 |
+
if row[0] != fingerprint:
|
| 99 |
+
raise JobError("Job ID already belongs to another image or settings")
|
| 100 |
+
return {"output_hash": row[1], "discord_id": row[2], "error": row[3]}
|
| 101 |
+
|
| 102 |
+
|
| 103 |
+
def _save(job_id, **values):
|
| 104 |
+
if not values or not set(values) <= {"output_hash", "discord_id", "error"}:
|
| 105 |
+
raise JobError("Invalid job state")
|
| 106 |
+
with closing(sqlite3.connect(JOB_DB)) as db, db:
|
| 107 |
+
db.execute("UPDATE jobs SET " + ", ".join(f"{name} = ?" for name in values) + " WHERE id = ?", (*values.values(), job_id))
|
| 108 |
+
|
| 109 |
+
|
| 110 |
+
def _retry(action):
|
| 111 |
+
for attempt in range(len(RETRY_DELAYS) + 1):
|
| 112 |
+
try:
|
| 113 |
+
return action()
|
| 114 |
+
except Exception:
|
| 115 |
+
if attempt == len(RETRY_DELAYS):
|
| 116 |
+
raise
|
| 117 |
+
time.sleep(RETRY_DELAYS[attempt])
|
| 118 |
+
|
| 119 |
+
|
| 120 |
+
def _download(job):
|
| 121 |
+
with httpx.stream("GET", job["input_url"], headers={"Authorization": f"Bearer {job['hf_token']}"}, follow_redirects=True, timeout=60) as response:
|
| 122 |
+
if not response.is_success:
|
| 123 |
+
raise JobError(f"Input download failed ({response.status_code})")
|
| 124 |
+
data = bytearray()
|
| 125 |
+
for chunk in response.iter_bytes():
|
| 126 |
+
data.extend(chunk)
|
| 127 |
+
if len(data) > MAX_BYTES + NONCE_BYTES + TAG_BYTES:
|
| 128 |
+
raise JobError("Input image exceeds 128 MB")
|
| 129 |
+
try:
|
| 130 |
+
image = AESGCM(bytes(job["key"])).decrypt(bytes(data[:NONCE_BYTES]), bytes(data[NONCE_BYTES:]), None)
|
| 131 |
+
except Exception as exc:
|
| 132 |
+
raise JobError("Unable to decrypt the input image") from exc
|
| 133 |
+
if xxhash.xxh3_128_hexdigest(image) + job["suffix"] != job["input_hash"]:
|
| 134 |
+
raise JobError("Input image hash mismatch")
|
| 135 |
+
return image
|
| 136 |
+
|
| 137 |
+
|
| 138 |
+
def _upload(job, content):
|
| 139 |
+
file_hash = xxhash.xxh3_128_hexdigest(content) + job["suffix"]
|
| 140 |
+
nonce = token_bytes(NONCE_BYTES)
|
| 141 |
+
encrypted = nonce + AESGCM(bytes(job["key"])).encrypt(nonce, content, None)
|
| 142 |
+
_retry(lambda: batch_bucket_files(job["bucket"], add=[(encrypted, file_hash)], token=job["hf_token"]))
|
| 143 |
+
return file_hash
|
| 144 |
+
|
| 145 |
+
|
| 146 |
+
def _notify(job, record):
|
| 147 |
+
if record["output_hash"]:
|
| 148 |
+
file_hash = record["output_hash"]
|
| 149 |
+
signature = hmac.new(bytes(job["key"]), f"no-watermark:{file_hash}".encode(), hashlib.sha256).hexdigest()
|
| 150 |
+
url = API_URL + "/no-watermark/?" + urlencode({"file": file_hash, "signature": signature})
|
| 151 |
+
content = f"No Watermark — {job['filename']}\n{url}"
|
| 152 |
+
else:
|
| 153 |
+
content = f"No Watermark failed — {job['filename']}\n{record['error']}"
|
| 154 |
+
payload = {
|
| 155 |
+
"content": content,
|
| 156 |
+
"allowed_mentions": {"parse": []},
|
| 157 |
+
"nonce": hashlib.blake2s(job["id"].encode(), digest_size=12).hexdigest(),
|
| 158 |
+
"enforce_nonce": True,
|
| 159 |
+
}
|
| 160 |
+
|
| 161 |
+
def send():
|
| 162 |
+
response = httpx.post(
|
| 163 |
+
f"{DISCORD_API}/channels/{job['channel']}/messages",
|
| 164 |
+
headers={"Authorization": f"Bot {job['discord_token']}", "User-Agent": BOT_USER_AGENT},
|
| 165 |
+
json=payload, timeout=30,
|
| 166 |
+
)
|
| 167 |
+
if response.status_code == 429:
|
| 168 |
+
time.sleep(min(float(response.json().get("retry_after", 1)), 60))
|
| 169 |
+
if not response.is_success:
|
| 170 |
+
raise JobError(f"Discord delivery failed ({response.status_code})")
|
| 171 |
+
return response.json()["id"]
|
| 172 |
+
|
| 173 |
+
return _retry(send)
|
| 174 |
+
|
| 175 |
+
|
| 176 |
+
def run_bucket_job(payload, remove_watermark):
|
| 177 |
+
try:
|
| 178 |
+
job = _validate(payload)
|
| 179 |
+
record = _record(job)
|
| 180 |
+
except Exception:
|
| 181 |
+
return {"status": "rejected", "error": "Invalid job or unavailable job storage"}
|
| 182 |
+
if record["discord_id"]:
|
| 183 |
+
return {"id": job["id"], "status": "failed" if record["error"] else "completed"}
|
| 184 |
+
if not record["output_hash"] and not record["error"]:
|
| 185 |
+
try:
|
| 186 |
+
content = _retry(lambda: _download(job))
|
| 187 |
+
with TemporaryDirectory(prefix="bucket-input-") as directory:
|
| 188 |
+
input_path = Path(directory) / ("image" + IMAGE_TYPES[job["content_type"]])
|
| 189 |
+
input_path.write_bytes(content)
|
| 190 |
+
terminal, output, _ = remove_watermark(str(input_path), *job["options"])
|
| 191 |
+
if not output:
|
| 192 |
+
parser = PlainText()
|
| 193 |
+
parser.feed(str(terminal))
|
| 194 |
+
raise JobError("".join(parser.parts).strip() or "Image processing failed")
|
| 195 |
+
output_path = Path(output)
|
| 196 |
+
if output_path.stat().st_size > MAX_BYTES:
|
| 197 |
+
raise JobError("Completed image exceeds 128 MB")
|
| 198 |
+
record["output_hash"] = _upload(job, output_path.read_bytes())
|
| 199 |
+
_save(job["id"], output_hash=record["output_hash"])
|
| 200 |
+
except Exception as exc:
|
| 201 |
+
error = str(exc) if isinstance(exc, JobError) else "Image processing or encrypted upload failed"
|
| 202 |
+
for secret in (job["hf_token"], job["discord_token"]):
|
| 203 |
+
error = error.replace(secret, "[redacted]")
|
| 204 |
+
record["error"] = error[:1000]
|
| 205 |
+
_save(job["id"], error=record["error"])
|
| 206 |
+
try:
|
| 207 |
+
discord_id = _notify(job, record)
|
| 208 |
+
_save(job["id"], discord_id=discord_id)
|
| 209 |
+
except Exception:
|
| 210 |
+
return {"id": job["id"], "status": "delivery_failed"}
|
| 211 |
+
return {"id": job["id"], "status": "failed" if record["error"] else "completed"}
|
requirements.txt
CHANGED
|
@@ -8,3 +8,7 @@ ultralytics
|
|
| 8 |
spandrel
|
| 9 |
paddleocr>=3.0,<4
|
| 10 |
paddlepaddle==3.2.2
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 8 |
spandrel
|
| 9 |
paddleocr>=3.0,<4
|
| 10 |
paddlepaddle==3.2.2
|
| 11 |
+
cryptography
|
| 12 |
+
httpx
|
| 13 |
+
huggingface_hub>=1.6.0
|
| 14 |
+
xxhash
|