Full source for the-third-rev: Discord bot (discord.py), FastAPI web UI (React/TS/Vite/Tailwind), ComfyUI integration, generation history DB, preset manager, workflow inspector, and all supporting modules. Excluded from tracking: .env, invite_tokens.json, *.db (SQLite), current-workflow-changes.json, user_settings/, presets/, logs/, web-static/ (build output), frontend/node_modules/. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
195 lines
6.7 KiB
Python
195 lines
6.7 KiB
Python
"""GET/POST/DELETE /api/inputs; GET /api/inputs/{id}/image; POST /api/inputs/{id}/activate"""
|
|
from __future__ import annotations
|
|
|
|
import logging
|
|
import mimetypes
|
|
from pathlib import Path
|
|
from typing import Optional
|
|
|
|
from fastapi import APIRouter, Body, Depends, File, Form, HTTPException, UploadFile
|
|
from fastapi.responses import Response
|
|
|
|
from web.auth import require_auth
|
|
from web.deps import get_config, get_user_registry
|
|
|
|
router = APIRouter()
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
@router.get("")
|
|
async def list_inputs(_: dict = Depends(require_auth)):
|
|
"""List all input images (Discord + web uploads)."""
|
|
from input_image_db import get_all_images
|
|
rows = get_all_images()
|
|
return [dict(r) for r in rows]
|
|
|
|
|
|
@router.post("")
|
|
async def upload_input(
|
|
file: UploadFile = File(...),
|
|
slot_key: Optional[str] = Form(default=None),
|
|
user: dict = Depends(require_auth),
|
|
):
|
|
"""
|
|
Upload an input image.
|
|
|
|
Stores image bytes directly in SQLite. If *slot_key* is provided the
|
|
image is immediately activated for that slot (writes to ComfyUI input
|
|
folder and updates the user's state override).
|
|
|
|
The physical slot file uses a namespaced key ``<user_label>_<slot_key>``
|
|
so concurrent users each get their own active image file.
|
|
"""
|
|
config = get_config()
|
|
if config is None:
|
|
raise HTTPException(503, "Config not available")
|
|
|
|
data = await file.read()
|
|
filename = file.filename or "upload.png"
|
|
|
|
from input_image_db import upsert_image, activate_image_for_slot
|
|
row_id = upsert_image(
|
|
original_message_id=0, # sentinel for web uploads
|
|
bot_reply_id=0,
|
|
channel_id=0,
|
|
filename=filename,
|
|
image_data=data,
|
|
)
|
|
|
|
activated_filename: str | None = None
|
|
if slot_key:
|
|
user_label: str = user["sub"]
|
|
namespaced_key = f"{user_label}_{slot_key}"
|
|
activated_filename = activate_image_for_slot(
|
|
row_id, namespaced_key, config.comfy_input_path
|
|
)
|
|
registry = get_user_registry()
|
|
if registry:
|
|
registry.get_state_manager(user_label).set_override(slot_key, activated_filename)
|
|
else:
|
|
from web.deps import get_comfy
|
|
comfy = get_comfy()
|
|
if comfy:
|
|
comfy.state_manager.set_override(slot_key, activated_filename)
|
|
|
|
return {"id": row_id, "filename": filename, "slot_key": slot_key, "activated_filename": activated_filename}
|
|
|
|
|
|
@router.post("/{row_id}/activate")
|
|
async def activate_input(
|
|
row_id: int,
|
|
slot_key: str = Body(default="input_image", embed=True),
|
|
user: dict = Depends(require_auth),
|
|
):
|
|
"""Write the stored image to the ComfyUI input folder and set the user's slot override."""
|
|
config = get_config()
|
|
if config is None:
|
|
raise HTTPException(503, "Config not available")
|
|
|
|
from input_image_db import get_image, activate_image_for_slot
|
|
row = get_image(row_id)
|
|
if row is None:
|
|
raise HTTPException(404, "Image not found")
|
|
|
|
user_label: str = user["sub"]
|
|
namespaced_key = f"{user_label}_{slot_key}"
|
|
|
|
try:
|
|
filename = activate_image_for_slot(row_id, namespaced_key, config.comfy_input_path)
|
|
except ValueError as exc:
|
|
raise HTTPException(409, str(exc))
|
|
|
|
registry = get_user_registry()
|
|
if registry:
|
|
registry.get_state_manager(user_label).set_override(slot_key, filename)
|
|
else:
|
|
from web.deps import get_comfy
|
|
comfy = get_comfy()
|
|
if comfy is None:
|
|
raise HTTPException(503, "State manager not available")
|
|
comfy.state_manager.set_override(slot_key, filename)
|
|
|
|
return {"ok": True, "slot_key": slot_key, "filename": filename}
|
|
|
|
|
|
@router.delete("/{row_id}")
|
|
async def delete_input(row_id: int, _: dict = Depends(require_auth)):
|
|
"""Delete an input image record (and its active slot file if applicable)."""
|
|
from input_image_db import get_image, delete_image
|
|
row = get_image(row_id)
|
|
if row is None:
|
|
raise HTTPException(404, "Image not found")
|
|
|
|
config = get_config()
|
|
delete_image(row_id, comfy_input_path=config.comfy_input_path if config else None)
|
|
return {"ok": True}
|
|
|
|
|
|
@router.get("/{row_id}/image")
|
|
async def get_input_image(row_id: int, _: dict = Depends(require_auth)):
|
|
"""Serve the raw image bytes stored in the database for a given input image row."""
|
|
from input_image_db import get_image, get_image_data
|
|
row = get_image(row_id)
|
|
if row is None:
|
|
raise HTTPException(404, "Image not found")
|
|
|
|
data = get_image_data(row_id)
|
|
if data is None:
|
|
raise HTTPException(404, "Image data not available — re-upload to backfill")
|
|
|
|
mime, _ = mimetypes.guess_type(row["filename"])
|
|
return Response(content=data, media_type=mime or "application/octet-stream")
|
|
|
|
|
|
def _pil_resize_response(data: bytes, filename: str, max_size: int, quality: int) -> Response:
|
|
"""Resize image bytes with Pillow and return a JPEG Response. Raises on failure."""
|
|
import io
|
|
from PIL import Image as _PIL
|
|
img = _PIL.open(io.BytesIO(data))
|
|
img.thumbnail((max_size, max_size), _PIL.LANCZOS)
|
|
buf = io.BytesIO()
|
|
img.convert("RGB").save(buf, "JPEG", quality=quality, optimize=True)
|
|
return Response(
|
|
content=buf.getvalue(),
|
|
media_type="image/jpeg",
|
|
headers={"Cache-Control": "public, max-age=86400"},
|
|
)
|
|
|
|
|
|
@router.get("/{row_id}/thumb")
|
|
async def get_input_thumb(row_id: int, _: dict = Depends(require_auth)):
|
|
"""Serve a small compressed thumbnail (max 200 px, JPEG 65 %) for fast previews."""
|
|
from input_image_db import get_image, get_image_data
|
|
row = get_image(row_id)
|
|
if row is None:
|
|
raise HTTPException(404, "Image not found")
|
|
|
|
data = get_image_data(row_id)
|
|
if data is None:
|
|
raise HTTPException(404, "Image data not available — re-upload to backfill")
|
|
|
|
try:
|
|
return _pil_resize_response(data, row["filename"], max_size=200, quality=65)
|
|
except Exception:
|
|
mime, _ = mimetypes.guess_type(row["filename"])
|
|
return Response(content=data, media_type=mime or "application/octet-stream")
|
|
|
|
|
|
@router.get("/{row_id}/mid")
|
|
async def get_input_mid(row_id: int, _: dict = Depends(require_auth)):
|
|
"""Serve a medium compressed image (max 800 px, JPEG 80 %) for progressive loading."""
|
|
from input_image_db import get_image, get_image_data
|
|
row = get_image(row_id)
|
|
if row is None:
|
|
raise HTTPException(404, "Image not found")
|
|
|
|
data = get_image_data(row_id)
|
|
if data is None:
|
|
raise HTTPException(404, "Image data not available — re-upload to backfill")
|
|
|
|
try:
|
|
return _pil_resize_response(data, row["filename"], max_size=800, quality=80)
|
|
except Exception:
|
|
mime, _ = mimetypes.guess_type(row["filename"])
|
|
return Response(content=data, media_type=mime or "application/octet-stream")
|