Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
261 lines
9.4 KiB
Python
261 lines
9.4 KiB
Python
"""Media: files the user brought in, rather than something a run produced.
|
|
|
|
A reference image, an imported clip -- anything meant to feed INTO a
|
|
generation instead of coming out of one. Files live under config.MEDIA_DIR as
|
|
<id><ext>; a sibling index.json records the display name, kind, size and
|
|
timestamp. Pure file/JSON I/O, same shape as projects.py, deliberately not
|
|
importing it: a media id and a project id look alike but mean different
|
|
things, and a store that borrowed the other's helpers would make that
|
|
harder to see, not easier.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import mimetypes
|
|
import re
|
|
import threading
|
|
import time
|
|
import uuid
|
|
from pathlib import Path
|
|
|
|
from . import config
|
|
|
|
#: Guards the index's read-modify-write. Nothing in save_upload/delete awaits,
|
|
#: so on today's single request per event-loop-turn this is never contended --
|
|
#: but that safety is an accident of the call path, not something the index
|
|
#: itself enforces, and the first `asyncio.to_thread` wrapped around a big
|
|
#: upload (core/routes.py already does this for other slow I/O) turns two
|
|
#: concurrent writers into a lost update or a crash on the shared tmp file.
|
|
_LOCK = threading.Lock()
|
|
|
|
_ID = re.compile(r"\A[0-9a-f]{12}\Z")
|
|
|
|
#: What a name is allowed to become on disk. Not a sanitiser: a name that
|
|
#: merely survives cleaning can still be "..", so path safety comes from the
|
|
#: generated id, never from this.
|
|
MAX_NAME = 120
|
|
|
|
_IMAGE_EXT = frozenset({".png", ".jpg", ".jpeg", ".webp", ".gif", ".bmp", ".tiff"})
|
|
_VIDEO_EXT = frozenset({".mp4", ".webm", ".mov", ".mkv", ".avi"})
|
|
_AUDIO_EXT = frozenset({".mp3", ".wav", ".m4a", ".aac", ".ogg", ".flac", ".opus", ".weba"})
|
|
|
|
#: What core will accept onto disk at all. A media route is a place a browser
|
|
#: can send arbitrary bytes; this is the refusal, not a UI hint -- anything
|
|
#: not on this list is rejected before it reaches a filename.
|
|
ALLOWED_EXT = _IMAGE_EXT | _VIDEO_EXT | _AUDIO_EXT
|
|
|
|
#: Matches ComfyUI's own --max-upload-size default (100MB): FunPack's routes
|
|
#: ride on ComfyUI's Application in production, which already refuses a bigger
|
|
#: body before this ever runs. A larger number here would be a limit this
|
|
#: store claims to enforce but never gets the chance to.
|
|
MAX_BYTES = 100 * 1024 * 1024
|
|
|
|
|
|
def is_id(value) -> bool:
|
|
return isinstance(value, str) and bool(_ID.match(value))
|
|
|
|
|
|
def _kind(ext: str) -> str:
|
|
if ext in _IMAGE_EXT:
|
|
return "image"
|
|
if ext in _VIDEO_EXT:
|
|
return "video"
|
|
if ext in _AUDIO_EXT:
|
|
return "audio"
|
|
return "other"
|
|
|
|
|
|
def _dir():
|
|
config.MEDIA_DIR.mkdir(parents=True, exist_ok=True)
|
|
return config.MEDIA_DIR
|
|
|
|
|
|
def _index_path() -> Path:
|
|
return _dir() / "index.json"
|
|
|
|
|
|
def _load_index() -> list[dict]:
|
|
p = _index_path()
|
|
if not p.exists():
|
|
return []
|
|
try:
|
|
data = json.loads(p.read_text(encoding="utf-8"))
|
|
except (json.JSONDecodeError, OSError, UnicodeDecodeError):
|
|
return [] # an unreadable index is not a reason to have no list
|
|
return data if isinstance(data, list) else []
|
|
|
|
|
|
def _save_index(items: list[dict]) -> None:
|
|
path = _index_path()
|
|
tmp = path.with_suffix(".tmp")
|
|
tmp.write_text(json.dumps(items, indent=2), encoding="utf-8")
|
|
tmp.replace(path) # a concurrent read never sees a half-written index
|
|
|
|
|
|
def listing() -> list[dict]:
|
|
"""Every upload, newest first. An entry whose file vanished is dropped
|
|
rather than shown -- there is nothing behind it to open."""
|
|
out = [it for it in _load_index() if (config.MEDIA_DIR / it.get("filename", "")).is_file()]
|
|
out.sort(key=lambda i: i.get("added", 0), reverse=True)
|
|
return out
|
|
|
|
|
|
def get(mid: str) -> dict | None:
|
|
if not is_id(mid):
|
|
return None
|
|
return next((it for it in _load_index() if it.get("id") == mid), None)
|
|
|
|
|
|
def save_upload(orig_name: str, data: bytes, limit=...) -> dict:
|
|
"""Write one uploaded file and record it. Raises ValueError for anything
|
|
this store refuses -- an empty upload, an extension not on the allowlist,
|
|
or a file over MAX_BYTES -- so the route can turn that into a 400 naming
|
|
why, rather than a file silently written and then never explained."""
|
|
if not data:
|
|
raise ValueError("that file is empty")
|
|
limit = MAX_BYTES if limit is ... else limit # an upload's limit (read now); a file the server made itself has none
|
|
if limit is not None and len(data) > limit:
|
|
raise ValueError(f"that file is over the {limit // (1024 * 1024)}MB limit")
|
|
ext = Path(orig_name or "").suffix.lower()
|
|
if ext not in ALLOWED_EXT:
|
|
raise ValueError(f"{ext or 'that file type'} is not something FunPack imports")
|
|
|
|
mid = uuid.uuid4().hex[:12]
|
|
filename = mid + ext
|
|
_dir()
|
|
(config.MEDIA_DIR / filename).write_bytes(data)
|
|
entry = {
|
|
"id": mid,
|
|
"name": (Path(orig_name or "").name or filename)[:MAX_NAME],
|
|
"filename": filename,
|
|
"kind": _kind(ext),
|
|
"size": len(data),
|
|
"added": time.time(),
|
|
}
|
|
# The write above needs no lock -- filename is this call's own uuid, so two
|
|
# uploads never touch the same file. The index below is shared, and a
|
|
# read here that raced another upload's write would silently drop it.
|
|
with _LOCK:
|
|
items = _load_index()
|
|
items.append(entry)
|
|
_save_index(items)
|
|
return entry
|
|
|
|
|
|
def path_for(mid: str) -> Path | None:
|
|
it = get(mid)
|
|
if not it:
|
|
return None
|
|
p = config.MEDIA_DIR / it["filename"]
|
|
return p if p.is_file() else None
|
|
|
|
|
|
def content_type(mid: str) -> str:
|
|
it = get(mid)
|
|
if not it:
|
|
return "application/octet-stream"
|
|
return mimetypes.guess_type(it["filename"])[0] or "application/octet-stream"
|
|
|
|
|
|
def delete(mid: str) -> bool:
|
|
with _LOCK:
|
|
items = _load_index()
|
|
keep, removed = [], None
|
|
for it in items:
|
|
if it.get("id") == mid:
|
|
removed = it
|
|
else:
|
|
keep.append(it)
|
|
if removed is None:
|
|
return False
|
|
_save_index(keep)
|
|
for gone in (config.MEDIA_DIR / removed["filename"], _thumb_file(mid)):
|
|
try:
|
|
gone.unlink(missing_ok=True)
|
|
except OSError:
|
|
pass
|
|
return True
|
|
|
|
|
|
def rename(mid: str, name) -> dict | None:
|
|
"""Change the display name only: the file on disk is addressed by id and never moves."""
|
|
name = " ".join(str(name or "").split())[:MAX_NAME]
|
|
if not name:
|
|
raise ValueError("a name is needed")
|
|
with _LOCK:
|
|
items = _load_index()
|
|
for it in items:
|
|
if it.get("id") == mid:
|
|
it["name"] = name
|
|
_save_index(items)
|
|
return it
|
|
return None
|
|
|
|
|
|
MAX_SUBJECT = 600
|
|
|
|
|
|
def set_subject(mid: str, text) -> dict | None:
|
|
"""What this picture MEANS when it is a reference ("<Subject 1> is the woman in <Picture 1>..."). Empty clears it."""
|
|
text = " ".join(str(text or "").split())[:MAX_SUBJECT]
|
|
with _LOCK:
|
|
items = _load_index()
|
|
for it in items:
|
|
if it.get("id") == mid:
|
|
if text:
|
|
it["subject"] = text
|
|
else:
|
|
it.pop("subject", None)
|
|
_save_index(items)
|
|
return it
|
|
return None
|
|
|
|
|
|
#: Long edge of a grid thumbnail in px: headroom over the largest cell (240) for retina, and
|
|
#: far below the 4K original the grid used to download and decode in full just to draw a square.
|
|
THUMB_MAX_DIM = 320
|
|
|
|
|
|
def _thumb_file(mid: str) -> Path:
|
|
return config.MEDIA_DIR / "thumbnails" / f"{mid}.jpg"
|
|
|
|
|
|
def thumb_path_for(mid: str) -> Path | None:
|
|
"""A small cached JPEG for an image or video, made on first request. None for audio, an
|
|
unreadable source, or no ffmpeg for a video: the grid then draws its placeholder. Blocking."""
|
|
it = get(mid)
|
|
src = path_for(mid) if it and it.get("kind") in ("image", "video") else None
|
|
if src is None:
|
|
return None
|
|
out = _thumb_file(mid)
|
|
try:
|
|
if out.is_file() and out.stat().st_mtime >= src.stat().st_mtime:
|
|
return out
|
|
out.parent.mkdir(parents=True, exist_ok=True)
|
|
# Unique per attempt: two requests for a not-yet-cached id must not interleave in one temp file.
|
|
tmp = out.with_name(f"{out.stem}.{uuid.uuid4().hex[:8]}.tmp")
|
|
try:
|
|
if it["kind"] == "image":
|
|
from PIL import Image
|
|
from PIL import ImageOps
|
|
img = ImageOps.exif_transpose(Image.open(src)) # the browser shows it turned upright
|
|
img.thumbnail((THUMB_MAX_DIM, THUMB_MAX_DIM)) # in place, keeps aspect, never upscales
|
|
(img if img.mode in ("RGB", "L") else img.convert("RGB")).save(tmp, "JPEG", quality=82)
|
|
else:
|
|
import shutil
|
|
import subprocess
|
|
ff = shutil.which("ffmpeg")
|
|
if not ff:
|
|
return None
|
|
box = f"scale='min({THUMB_MAX_DIM},iw)':'min({THUMB_MAX_DIM},ih)':force_original_aspect_ratio=decrease"
|
|
# -f mjpeg: the temp name's extension gives ffmpeg nothing to infer a container from
|
|
subprocess.run([ff, "-y", "-i", str(src), "-vframes", "1", "-vf", box, "-f", "mjpeg", str(tmp)],
|
|
capture_output=True, check=True)
|
|
tmp.replace(out)
|
|
finally:
|
|
tmp.unlink(missing_ok=True)
|
|
return out if out.is_file() else None
|
|
except Exception: # noqa: BLE001 -- a thumbnail is never worth an error
|
|
return None
|