manual submit
This commit is contained in:
14
bot.py
14
bot.py
@@ -143,6 +143,14 @@ def _try_autoload_last_workflow(client: ComfyClient) -> None:
|
||||
if not last_wf:
|
||||
return
|
||||
wf_path = _PROJECT_ROOT / "workflows" / last_wf
|
||||
# Guard against path traversal in the persisted state file
|
||||
try:
|
||||
safe_root = (_PROJECT_ROOT / "workflows").resolve()
|
||||
if not wf_path.resolve().is_relative_to(safe_root):
|
||||
logger.warning("Blocked path traversal attempt in last_workflow_file: %r", last_wf)
|
||||
return
|
||||
except Exception:
|
||||
return
|
||||
if not wf_path.exists():
|
||||
logger.warning("Last workflow file not found: %s", wf_path)
|
||||
return
|
||||
@@ -190,6 +198,12 @@ async def main() -> None:
|
||||
|
||||
init_db()
|
||||
generation_db.init_db(_PROJECT_ROOT / "generation_history.db")
|
||||
try:
|
||||
import face_db as _face_db
|
||||
_face_db.init_db()
|
||||
logger.info("Face DB initialized")
|
||||
except Exception as _exc:
|
||||
logger.warning("Face DB init failed (non-fatal): %s", _exc)
|
||||
register_all_commands(bot, config)
|
||||
logger.info("All commands registered")
|
||||
|
||||
|
||||
@@ -265,7 +265,8 @@ class ComfyClient:
|
||||
prompt_id, source, user_label, overrides, seed
|
||||
)
|
||||
for i, img_data in enumerate(images):
|
||||
generation_db.record_file(gen_id, f"image_{i:04d}.png", img_data)
|
||||
file_id = generation_db.record_file(gen_id, f"image_{i:04d}.png", img_data)
|
||||
self._schedule_face_scan("image", file_id, img_data)
|
||||
if videos and self.output_path:
|
||||
for vid in videos:
|
||||
vname = vid.get("video_name", "")
|
||||
@@ -276,7 +277,9 @@ class ComfyClient:
|
||||
else _Path(self.output_path) / vname
|
||||
)
|
||||
try:
|
||||
generation_db.record_file(gen_id, vname, vpath.read_bytes())
|
||||
vid_data = vpath.read_bytes()
|
||||
file_id = generation_db.record_file(gen_id, vname, vid_data)
|
||||
self._schedule_face_scan("video", file_id, vid_data)
|
||||
except OSError as exc:
|
||||
self.logger.warning(
|
||||
"Could not read video for DB storage: %s: %s", vpath, exc
|
||||
@@ -284,6 +287,21 @@ class ComfyClient:
|
||||
except Exception as exc:
|
||||
self.logger.warning("Failed to record generation to DB: %s", exc)
|
||||
|
||||
def _schedule_face_scan(self, media_type: str, file_id: int, data: bytes) -> None:
|
||||
"""Fire-and-forget background face scan for a generated output file."""
|
||||
try:
|
||||
from face_service import get_face_service
|
||||
svc = get_face_service()
|
||||
if not svc.available:
|
||||
return
|
||||
loop = asyncio.get_running_loop()
|
||||
if media_type == "image":
|
||||
loop.create_task(svc.scan_output_image(file_id, data))
|
||||
elif media_type == "video":
|
||||
loop.create_task(svc.scan_video(file_id, data))
|
||||
except Exception as exc:
|
||||
self.logger.warning("Could not schedule face scan: %s", exc)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Public generation API
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
@@ -14,6 +14,7 @@ stored in the SQLite database.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import io
|
||||
import logging
|
||||
from pathlib import Path
|
||||
@@ -30,6 +31,127 @@ from input_image_db import (
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def _identify_faces_discord(
|
||||
bot,
|
||||
message: discord.Message,
|
||||
row_id: int,
|
||||
image_bytes: bytes,
|
||||
) -> None:
|
||||
"""
|
||||
After an input image is registered, scan it for faces and prompt the
|
||||
uploader to identify any that weren't auto-matched.
|
||||
|
||||
Only the original uploader's replies are accepted (author check on wait_for).
|
||||
The loop runs at most 3 rounds to resolve deduplication conflicts.
|
||||
"""
|
||||
try:
|
||||
from face_service import get_face_service
|
||||
import face_db
|
||||
|
||||
svc = get_face_service()
|
||||
if not svc.available:
|
||||
return
|
||||
|
||||
face_db.init_db()
|
||||
results = await svc.scan_input_image(row_id, image_bytes)
|
||||
unknown = [r for r in results if r.matched_person_id is None]
|
||||
if not unknown:
|
||||
return
|
||||
|
||||
# Build initial prompt with face crops as attachments
|
||||
files = []
|
||||
for r in unknown:
|
||||
crop = svc.get_face_crop(r.detection_id)
|
||||
if crop:
|
||||
files.append(discord.File(io.BytesIO(crop), filename=f"face_{r.face_index}.jpg"))
|
||||
|
||||
n = len(unknown)
|
||||
prompt_text = (
|
||||
f"\U0001f50d Found {n} new face(s) in your image. "
|
||||
f"Reply with names in order (comma-separated): `Name1, Name2, ...`\n"
|
||||
f"_(or ignore to skip identification)_"
|
||||
)
|
||||
bot_msg = await message.channel.send(prompt_text, files=files)
|
||||
|
||||
def _check(m: discord.Message) -> bool:
|
||||
return (
|
||||
m.reference is not None
|
||||
and m.reference.message_id == bot_msg.id
|
||||
and m.author.id == message.author.id
|
||||
)
|
||||
|
||||
pending = list(unknown) # detections still needing names
|
||||
|
||||
for _round in range(3):
|
||||
try:
|
||||
reply = await bot.wait_for("message", check=_check, timeout=120)
|
||||
except asyncio.TimeoutError:
|
||||
return
|
||||
|
||||
raw_names = [n.strip() for n in reply.content.split(",")]
|
||||
if len(raw_names) < len(pending):
|
||||
raw_names += [""] * (len(pending) - len(raw_names))
|
||||
|
||||
# Check for conflicts (name exists but user said a new name)
|
||||
conflicts: list[tuple[int, str]] = [] # (index in pending, name)
|
||||
for idx, (det, name) in enumerate(zip(pending, raw_names)):
|
||||
if name and face_db.person_name_exists(name):
|
||||
conflicts.append((idx, name))
|
||||
|
||||
if conflicts:
|
||||
conflict_lines = "\n".join(
|
||||
f"Face {pending[idx].face_index + 1} → `{name}`"
|
||||
for idx, name in conflicts
|
||||
)
|
||||
warn_msg = await reply.reply(
|
||||
f"\u26a0\ufe0f These names already exist:\n{conflict_lines}\n\n"
|
||||
f"Reply `same` for any that should link to the **existing** person, "
|
||||
f"or provide a different name — one value per conflicting face "
|
||||
f"(comma-separated, in the same order as listed above)."
|
||||
)
|
||||
bot_msg = warn_msg
|
||||
|
||||
# Update check to listen for reply to the new warning message
|
||||
def _check_conflict(m: discord.Message) -> bool:
|
||||
return (
|
||||
m.reference is not None
|
||||
and m.reference.message_id == warn_msg.id
|
||||
and m.author.id == message.author.id
|
||||
)
|
||||
|
||||
try:
|
||||
conflict_reply = await bot.wait_for(
|
||||
"message", check=_check_conflict, timeout=120
|
||||
)
|
||||
except asyncio.TimeoutError:
|
||||
return
|
||||
|
||||
resolved = [v.strip() for v in conflict_reply.content.split(",")]
|
||||
# Apply resolved names back to the original name list
|
||||
for list_pos, (pending_idx, old_name) in enumerate(conflicts):
|
||||
if list_pos < len(resolved):
|
||||
val = resolved[list_pos]
|
||||
raw_names[pending_idx] = old_name if val.lower() == "same" else val
|
||||
|
||||
# Apply names — skip blanks
|
||||
confirmed: list[str] = []
|
||||
for det, name in zip(pending, raw_names):
|
||||
if not name:
|
||||
continue
|
||||
use_existing = face_db.person_name_exists(name)
|
||||
person_id, _ = face_db.get_or_create_person(name)
|
||||
face_db.link_detection_to_person(det.detection_id, person_id)
|
||||
status = "linked to existing" if use_existing else "new"
|
||||
confirmed.append(f"{name} ({status})")
|
||||
|
||||
if confirmed:
|
||||
await reply.reply(f"\u2705 Identified: {', '.join(confirmed)}")
|
||||
return
|
||||
|
||||
except Exception as exc:
|
||||
logger.warning("Face identification flow failed: %s", exc)
|
||||
|
||||
IMAGE_EXTENSIONS = {".png", ".jpg", ".jpeg", ".webp", ".gif", ".bmp"}
|
||||
|
||||
|
||||
@@ -92,6 +214,11 @@ async def _register_attachment(bot, config, message: discord.Message, attachment
|
||||
logger.info("[_register_attachment] Done")
|
||||
await reply.edit(view=view)
|
||||
|
||||
# Background face scan + optional identification prompt
|
||||
asyncio.create_task(
|
||||
_identify_faces_discord(bot, message, row_id, original_data)
|
||||
)
|
||||
|
||||
|
||||
def setup_input_image_commands(bot, config=None):
|
||||
"""Register input image commands and the on_message listener."""
|
||||
|
||||
715
face_db.py
Normal file
715
face_db.py
Normal file
@@ -0,0 +1,715 @@
|
||||
"""
|
||||
face_db.py
|
||||
==========
|
||||
|
||||
SQLite persistence for face detection and identity data.
|
||||
|
||||
Two tables:
|
||||
persons : one row per named person
|
||||
face_detections : one detected face per row, linked to source media
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import sqlite3
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
|
||||
import numpy as np
|
||||
|
||||
_DB_PATH: Path = Path(__file__).parent / "faces.db"
|
||||
|
||||
_SCHEMA = """
|
||||
CREATE TABLE IF NOT EXISTS persons (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL
|
||||
);
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_persons_name ON persons(LOWER(name));
|
||||
|
||||
CREATE TABLE IF NOT EXISTS person_aliases (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
person_id INTEGER NOT NULL REFERENCES persons(id) ON DELETE CASCADE,
|
||||
alias TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL
|
||||
);
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_aliases_alias ON person_aliases(LOWER(alias));
|
||||
|
||||
CREATE TABLE IF NOT EXISTS face_groups (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
label TEXT,
|
||||
threshold REAL NOT NULL,
|
||||
is_manual INTEGER NOT NULL DEFAULT 0,
|
||||
created_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS face_detections (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
person_id INTEGER REFERENCES persons(id) ON DELETE SET NULL,
|
||||
source_type TEXT NOT NULL,
|
||||
source_id INTEGER NOT NULL,
|
||||
frame_index INTEGER NOT NULL DEFAULT 0,
|
||||
face_index INTEGER NOT NULL DEFAULT 0,
|
||||
embedding BLOB,
|
||||
bbox_json TEXT,
|
||||
created_at TEXT NOT NULL,
|
||||
identified_at TEXT
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_fd_source ON face_detections(source_type, source_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_fd_person ON face_detections(person_id);
|
||||
"""
|
||||
|
||||
|
||||
def _apply_migrations(conn: sqlite3.Connection) -> None:
|
||||
"""Apply schema migrations that cannot be expressed in CREATE TABLE IF NOT EXISTS."""
|
||||
# Migration 0: Add group_id column
|
||||
try:
|
||||
conn.execute(
|
||||
"ALTER TABLE face_detections ADD COLUMN group_id INTEGER REFERENCES face_groups(id) ON DELETE SET NULL"
|
||||
)
|
||||
conn.commit()
|
||||
except sqlite3.OperationalError:
|
||||
pass # column already exists
|
||||
conn.execute("CREATE INDEX IF NOT EXISTS idx_fd_group ON face_detections(group_id)")
|
||||
conn.commit()
|
||||
|
||||
# Migration A: Make embedding nullable (rebuild table if NOT NULL constraint exists)
|
||||
ddl_row = conn.execute(
|
||||
"SELECT sql FROM sqlite_master WHERE type='table' AND name='face_detections'"
|
||||
).fetchone()
|
||||
import re as _re
|
||||
_ddl_normalized = " ".join((ddl_row["sql"] or "").split()) if ddl_row else ""
|
||||
if "embedding BLOB NOT NULL" in _ddl_normalized:
|
||||
conn.execute("""
|
||||
CREATE TABLE face_detections_new (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
person_id INTEGER REFERENCES persons(id) ON DELETE SET NULL,
|
||||
source_type TEXT NOT NULL,
|
||||
source_id INTEGER NOT NULL,
|
||||
frame_index INTEGER NOT NULL DEFAULT 0,
|
||||
face_index INTEGER NOT NULL DEFAULT 0,
|
||||
embedding BLOB,
|
||||
bbox_json TEXT,
|
||||
created_at TEXT NOT NULL,
|
||||
identified_at TEXT,
|
||||
group_id INTEGER REFERENCES face_groups(id) ON DELETE SET NULL
|
||||
)
|
||||
""")
|
||||
conn.execute(
|
||||
"INSERT INTO face_detections_new "
|
||||
"SELECT id, person_id, source_type, source_id, frame_index, face_index, "
|
||||
"embedding, bbox_json, created_at, identified_at, group_id FROM face_detections"
|
||||
)
|
||||
conn.execute("DROP TABLE face_detections")
|
||||
conn.execute("ALTER TABLE face_detections_new RENAME TO face_detections")
|
||||
conn.execute("CREATE INDEX IF NOT EXISTS idx_fd_source ON face_detections(source_type, source_id)")
|
||||
conn.execute("CREATE INDEX IF NOT EXISTS idx_fd_person ON face_detections(person_id)")
|
||||
conn.execute("CREATE INDEX IF NOT EXISTS idx_fd_group ON face_detections(group_id)")
|
||||
conn.commit()
|
||||
|
||||
# Migration B: NULL out all existing output embeddings (optimize storage)
|
||||
result = conn.execute(
|
||||
"UPDATE face_detections SET embedding = NULL WHERE source_type = 'output' AND embedding IS NOT NULL"
|
||||
)
|
||||
conn.commit()
|
||||
if result.rowcount:
|
||||
import logging as _logging
|
||||
_logging.getLogger(__name__).info("Migration B: cleared %d output embedding(s)", result.rowcount)
|
||||
|
||||
|
||||
def _connect(db_path: Path | None = None) -> sqlite3.Connection:
|
||||
path = db_path if db_path is not None else _DB_PATH
|
||||
conn = sqlite3.connect(str(path), check_same_thread=False)
|
||||
conn.row_factory = sqlite3.Row
|
||||
return conn
|
||||
|
||||
|
||||
def init_db(db_path: Path = _DB_PATH) -> None:
|
||||
"""Create tables if they don't exist and apply schema migrations."""
|
||||
global _DB_PATH
|
||||
_DB_PATH = db_path
|
||||
with _connect(db_path) as conn:
|
||||
conn.executescript(_SCHEMA)
|
||||
conn.commit()
|
||||
_apply_migrations(conn)
|
||||
|
||||
|
||||
def get_or_create_person(name: str) -> tuple[int, bool]:
|
||||
"""Find or create a person by name (case-insensitive). Resolves aliases. Returns (person_id, created)."""
|
||||
with _connect() as conn:
|
||||
row = conn.execute(
|
||||
"SELECT id FROM persons WHERE LOWER(name) = LOWER(?)", (name,)
|
||||
).fetchone()
|
||||
if row:
|
||||
return row["id"], False
|
||||
row = conn.execute(
|
||||
"SELECT person_id FROM person_aliases WHERE LOWER(alias) = LOWER(?)", (name,)
|
||||
).fetchone()
|
||||
if row:
|
||||
return row["person_id"], False
|
||||
created_at = datetime.now(timezone.utc).isoformat()
|
||||
cur = conn.execute(
|
||||
"INSERT INTO persons (name, created_at) VALUES (?, ?)",
|
||||
(name, created_at),
|
||||
)
|
||||
conn.commit()
|
||||
return cur.lastrowid, True # type: ignore[return-value]
|
||||
|
||||
|
||||
def person_name_exists(name: str) -> bool:
|
||||
"""Return True if a person with that name or alias (case-insensitive) already exists."""
|
||||
with _connect() as conn:
|
||||
row = conn.execute(
|
||||
"""SELECT 1 FROM persons WHERE LOWER(name) = LOWER(?)
|
||||
UNION
|
||||
SELECT 1 FROM person_aliases WHERE LOWER(alias) = LOWER(?)
|
||||
LIMIT 1""",
|
||||
(name, name),
|
||||
).fetchone()
|
||||
return row is not None
|
||||
|
||||
|
||||
def get_person_by_name(name: str) -> dict | None:
|
||||
"""Return person row dict by name (case-insensitive), or None."""
|
||||
with _connect() as conn:
|
||||
row = conn.execute(
|
||||
"SELECT id, name, created_at FROM persons WHERE LOWER(name) = LOWER(?)", (name,)
|
||||
).fetchone()
|
||||
return dict(row) if row else None
|
||||
|
||||
|
||||
def list_persons() -> list[dict]:
|
||||
"""Return all persons sorted by name, each with their aliases list and face_count."""
|
||||
with _connect() as conn:
|
||||
persons = conn.execute(
|
||||
"""SELECT p.id, p.name, p.created_at,
|
||||
(SELECT COUNT(*) FROM face_detections WHERE person_id = p.id) AS face_count
|
||||
FROM persons p ORDER BY p.name"""
|
||||
).fetchall()
|
||||
result = []
|
||||
for p in persons:
|
||||
d = dict(p)
|
||||
aliases = conn.execute(
|
||||
"SELECT id, alias FROM person_aliases WHERE person_id = ? ORDER BY alias",
|
||||
(d["id"],),
|
||||
).fetchall()
|
||||
d["aliases"] = [dict(a) for a in aliases]
|
||||
result.append(d)
|
||||
return result
|
||||
|
||||
|
||||
def get_unidentified_input_detections(limit: int = 50, offset: int = 0) -> tuple[list[dict], int]:
|
||||
"""Return paginated ungrouped unidentified face detections from input images, plus total count."""
|
||||
with _connect() as conn:
|
||||
total = conn.execute(
|
||||
"SELECT COUNT(*) FROM face_detections WHERE person_id IS NULL AND group_id IS NULL AND source_type = 'input'"
|
||||
).fetchone()[0]
|
||||
rows = conn.execute(
|
||||
"""SELECT id, source_id, face_index, bbox_json, created_at
|
||||
FROM face_detections
|
||||
WHERE person_id IS NULL AND group_id IS NULL AND source_type = 'input'
|
||||
ORDER BY id
|
||||
LIMIT ? OFFSET ?""",
|
||||
(limit, offset),
|
||||
).fetchall()
|
||||
return [dict(r) for r in rows], total
|
||||
|
||||
|
||||
def add_alias(person_id: int, alias: str) -> tuple[int, bool]:
|
||||
"""Add an alias to a person. Returns (alias_id, created). Raises ValueError if alias taken."""
|
||||
if person_name_exists(alias):
|
||||
raise ValueError(f"Name or alias '{alias}' is already taken")
|
||||
created_at = datetime.now(timezone.utc).isoformat()
|
||||
with _connect() as conn:
|
||||
cur = conn.execute(
|
||||
"INSERT INTO person_aliases (person_id, alias, created_at) VALUES (?, ?, ?)",
|
||||
(person_id, alias, created_at),
|
||||
)
|
||||
conn.commit()
|
||||
return cur.lastrowid, True # type: ignore[return-value]
|
||||
|
||||
|
||||
def remove_alias(alias_id: int) -> None:
|
||||
"""Delete an alias row by id."""
|
||||
with _connect() as conn:
|
||||
conn.execute("DELETE FROM person_aliases WHERE id = ?", (alias_id,))
|
||||
conn.commit()
|
||||
|
||||
|
||||
def count_detections_for_person(person_id: int) -> int:
|
||||
"""Return number of detections linked to this person."""
|
||||
with _connect() as conn:
|
||||
row = conn.execute(
|
||||
"SELECT COUNT(*) FROM face_detections WHERE person_id = ?", (person_id,)
|
||||
).fetchone()
|
||||
return row[0]
|
||||
|
||||
|
||||
def insert_detection(
|
||||
source_type: str,
|
||||
source_id: int,
|
||||
embedding: "np.ndarray | None",
|
||||
bbox: dict,
|
||||
frame_index: int = 0,
|
||||
face_index: int = 0,
|
||||
person_id: int | None = None,
|
||||
) -> int:
|
||||
"""Insert a face detection row. Returns the new row id."""
|
||||
embedding_bytes = embedding.astype(np.float32).tobytes() if embedding is not None else None
|
||||
bbox_json = json.dumps(bbox)
|
||||
created_at = datetime.now(timezone.utc).isoformat()
|
||||
identified_at = created_at if person_id is not None else None
|
||||
with _connect() as conn:
|
||||
cur = conn.execute(
|
||||
"""
|
||||
INSERT INTO face_detections
|
||||
(person_id, source_type, source_id, frame_index, face_index,
|
||||
embedding, bbox_json, created_at, identified_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
""",
|
||||
(person_id, source_type, source_id, frame_index, face_index,
|
||||
embedding_bytes, bbox_json, created_at, identified_at),
|
||||
)
|
||||
conn.commit()
|
||||
return cur.lastrowid # type: ignore[return-value]
|
||||
|
||||
|
||||
def link_detection_to_person(detection_id: int, person_id: int) -> None:
|
||||
"""Associate a detection with a named person."""
|
||||
identified_at = datetime.now(timezone.utc).isoformat()
|
||||
with _connect() as conn:
|
||||
conn.execute(
|
||||
"UPDATE face_detections SET person_id = ?, identified_at = ? WHERE id = ?",
|
||||
(person_id, identified_at, detection_id),
|
||||
)
|
||||
conn.commit()
|
||||
prune_person_output_embeddings(person_id)
|
||||
|
||||
|
||||
def prune_person_output_embeddings(person_id: int, max_keep: int = 5) -> int:
|
||||
"""Delete oldest output embeddings for a person if they exceed max_keep.
|
||||
Input embeddings are never pruned. Returns number deleted."""
|
||||
with _connect() as conn:
|
||||
rows = conn.execute(
|
||||
"""SELECT id FROM face_detections
|
||||
WHERE person_id = ? AND source_type = 'output' AND embedding IS NOT NULL
|
||||
ORDER BY id ASC""",
|
||||
(person_id,),
|
||||
).fetchall()
|
||||
excess = rows[:max(0, len(rows) - max_keep)]
|
||||
if not excess:
|
||||
return 0
|
||||
ids = [r["id"] for r in excess]
|
||||
placeholders = ",".join("?" * len(ids))
|
||||
conn.execute(
|
||||
f"UPDATE face_detections SET embedding = NULL WHERE id IN ({placeholders})", ids
|
||||
)
|
||||
conn.commit()
|
||||
return len(excess)
|
||||
|
||||
|
||||
def get_detection(detection_id: int) -> dict | None:
|
||||
"""Return a single face_detection row by id, or None."""
|
||||
with _connect() as conn:
|
||||
row = conn.execute(
|
||||
"SELECT * FROM face_detections WHERE id = ?", (detection_id,)
|
||||
).fetchone()
|
||||
return dict(row) if row else None
|
||||
|
||||
|
||||
def get_detections_for_source(source_type: str, source_id: int) -> list[dict]:
|
||||
"""Return all face detections for a given source."""
|
||||
with _connect() as conn:
|
||||
rows = conn.execute(
|
||||
"SELECT * FROM face_detections WHERE source_type = ? AND source_id = ?",
|
||||
(source_type, source_id),
|
||||
).fetchall()
|
||||
return [dict(r) for r in rows]
|
||||
|
||||
|
||||
def get_all_embeddings() -> list[dict]:
|
||||
"""Return all identified detections with their embeddings as np.ndarray."""
|
||||
with _connect() as conn:
|
||||
rows = conn.execute(
|
||||
"SELECT id, person_id, embedding FROM face_detections "
|
||||
"WHERE person_id IS NOT NULL AND embedding IS NOT NULL"
|
||||
).fetchall()
|
||||
result = []
|
||||
for row in rows:
|
||||
emb = np.frombuffer(bytes(row["embedding"]), dtype=np.float32).copy()
|
||||
result.append({"id": row["id"], "person_id": row["person_id"], "embedding": emb})
|
||||
return result
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Face group CRUD
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def create_group(threshold: float, label: str | None = None, is_manual: bool = False) -> int:
|
||||
"""Insert a new face_groups row and return its id."""
|
||||
created_at = datetime.now(timezone.utc).isoformat()
|
||||
with _connect() as conn:
|
||||
cur = conn.execute(
|
||||
"INSERT INTO face_groups (label, threshold, is_manual, created_at) VALUES (?, ?, ?, ?)",
|
||||
(label, threshold, int(is_manual), created_at),
|
||||
)
|
||||
conn.commit()
|
||||
return cur.lastrowid # type: ignore[return-value]
|
||||
|
||||
|
||||
def assign_detection_to_group(detection_id: int, group_id: int | None) -> None:
|
||||
"""Set or clear the group_id on a face_detections row."""
|
||||
with _connect() as conn:
|
||||
conn.execute(
|
||||
"UPDATE face_detections SET group_id = ? WHERE id = ?",
|
||||
(group_id, detection_id),
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
|
||||
def clear_all_groups() -> None:
|
||||
"""Remove all groups: NULL all group_id references then DELETE all face_groups rows."""
|
||||
with _connect() as conn:
|
||||
conn.execute("UPDATE face_detections SET group_id = NULL WHERE group_id IS NOT NULL")
|
||||
conn.execute("DELETE FROM face_groups")
|
||||
conn.commit()
|
||||
|
||||
|
||||
def get_groups_with_detections() -> list[dict]:
|
||||
"""Return groups that have ≥ 2 unidentified detections, with detection_ids and preview_ids."""
|
||||
with _connect() as conn:
|
||||
groups = conn.execute(
|
||||
"SELECT id, label, threshold, is_manual, created_at FROM face_groups ORDER BY id"
|
||||
).fetchall()
|
||||
result = []
|
||||
for g in groups:
|
||||
det_rows = conn.execute(
|
||||
"SELECT id FROM face_detections WHERE group_id = ? AND person_id IS NULL ORDER BY id",
|
||||
(g["id"],),
|
||||
).fetchall()
|
||||
det_ids = [r["id"] for r in det_rows]
|
||||
if len(det_ids) < 2:
|
||||
continue
|
||||
result.append({
|
||||
**dict(g),
|
||||
"count": len(det_ids),
|
||||
"detection_ids": det_ids,
|
||||
"preview_ids": det_ids[:4],
|
||||
})
|
||||
return result
|
||||
|
||||
|
||||
def get_group_detections(group_id: int) -> list[dict]:
|
||||
"""Return unidentified detections belonging to a group."""
|
||||
with _connect() as conn:
|
||||
rows = conn.execute(
|
||||
"""SELECT id, source_type, face_index, created_at
|
||||
FROM face_detections
|
||||
WHERE group_id = ? AND person_id IS NULL
|
||||
ORDER BY id""",
|
||||
(group_id,),
|
||||
).fetchall()
|
||||
return [dict(r) for r in rows]
|
||||
|
||||
|
||||
def merge_groups(keep_id: int, discard_id: int) -> None:
|
||||
"""Move all detections from discard_id to keep_id and delete the discard group."""
|
||||
with _connect() as conn:
|
||||
conn.execute(
|
||||
"UPDATE face_detections SET group_id = ? WHERE group_id = ?",
|
||||
(keep_id, discard_id),
|
||||
)
|
||||
conn.execute("DELETE FROM face_groups WHERE id = ?", (discard_id,))
|
||||
conn.commit()
|
||||
|
||||
|
||||
def remove_detection_from_group(detection_id: int) -> None:
|
||||
"""Unassign a detection from its group (set group_id = NULL)."""
|
||||
assign_detection_to_group(detection_id, None)
|
||||
|
||||
|
||||
def delete_singleton_groups() -> None:
|
||||
"""Delete groups that have fewer than 2 unidentified members, NULL-ing their refs first."""
|
||||
with _connect() as conn:
|
||||
rows = conn.execute(
|
||||
"""SELECT fg.id FROM face_groups fg
|
||||
LEFT JOIN face_detections fd ON fd.group_id = fg.id AND fd.person_id IS NULL
|
||||
GROUP BY fg.id
|
||||
HAVING COUNT(fd.id) < 2"""
|
||||
).fetchall()
|
||||
for row in rows:
|
||||
gid = row["id"]
|
||||
conn.execute("UPDATE face_detections SET group_id = NULL WHERE group_id = ?", (gid,))
|
||||
conn.execute("DELETE FROM face_groups WHERE id = ?", (gid,))
|
||||
conn.commit()
|
||||
|
||||
|
||||
def delete_group(group_id: int) -> None:
|
||||
"""Delete a single face_groups row (does not NULL detections — caller must have already cleared them)."""
|
||||
with _connect() as conn:
|
||||
conn.execute("DELETE FROM face_groups WHERE id = ?", (group_id,))
|
||||
conn.commit()
|
||||
|
||||
|
||||
def get_unidentified_embeddings() -> list[dict]:
|
||||
"""Return embeddings for all unidentified detections (both source types)."""
|
||||
with _connect() as conn:
|
||||
rows = conn.execute(
|
||||
"SELECT id, embedding FROM face_detections WHERE person_id IS NULL AND embedding IS NOT NULL"
|
||||
).fetchall()
|
||||
result = []
|
||||
for row in rows:
|
||||
emb = np.frombuffer(bytes(row["embedding"]), dtype=np.float32).copy()
|
||||
result.append({"id": row["id"], "embedding": emb})
|
||||
return result
|
||||
|
||||
|
||||
def get_all_group_embeddings_with_threshold() -> list[dict]:
|
||||
"""Return per-group embedding lists along with the group threshold."""
|
||||
with _connect() as conn:
|
||||
groups = conn.execute("SELECT id, threshold FROM face_groups").fetchall()
|
||||
result = []
|
||||
for g in groups:
|
||||
rows = conn.execute(
|
||||
"SELECT embedding FROM face_detections "
|
||||
"WHERE group_id = ? AND person_id IS NULL AND embedding IS NOT NULL",
|
||||
(g["id"],),
|
||||
).fetchall()
|
||||
embeddings = [
|
||||
np.frombuffer(bytes(r["embedding"]), dtype=np.float32).copy()
|
||||
for r in rows
|
||||
]
|
||||
if embeddings:
|
||||
result.append({
|
||||
"group_id": g["id"],
|
||||
"threshold": g["threshold"],
|
||||
"embeddings": embeddings,
|
||||
})
|
||||
return result
|
||||
|
||||
|
||||
def get_person_embeddings(person_id: int) -> list[np.ndarray]:
|
||||
"""Return all embedding arrays for a given person (only rows with non-null embedding)."""
|
||||
with _connect() as conn:
|
||||
rows = conn.execute(
|
||||
"SELECT embedding FROM face_detections WHERE person_id = ? AND embedding IS NOT NULL",
|
||||
(person_id,),
|
||||
).fetchall()
|
||||
return [np.frombuffer(bytes(r["embedding"]), dtype=np.float32).copy() for r in rows]
|
||||
|
||||
|
||||
def get_ungrouped_unidentified_embeddings() -> list[dict]:
|
||||
"""Return embeddings for unidentified detections that have no group assigned."""
|
||||
with _connect() as conn:
|
||||
rows = conn.execute(
|
||||
"SELECT id, embedding FROM face_detections "
|
||||
"WHERE person_id IS NULL AND group_id IS NULL AND embedding IS NOT NULL"
|
||||
).fetchall()
|
||||
result = []
|
||||
for row in rows:
|
||||
emb = np.frombuffer(bytes(row["embedding"]), dtype=np.float32).copy()
|
||||
result.append({"id": row["id"], "embedding": emb})
|
||||
return result
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# New person management functions
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def get_person(person_id: int) -> dict | None:
|
||||
"""Return {id, name, created_at, aliases: [{id, alias}]} for a person, or None."""
|
||||
with _connect() as conn:
|
||||
row = conn.execute(
|
||||
"SELECT id, name, created_at FROM persons WHERE id = ?", (person_id,)
|
||||
).fetchone()
|
||||
if row is None:
|
||||
return None
|
||||
d = dict(row)
|
||||
aliases = conn.execute(
|
||||
"SELECT id, alias FROM person_aliases WHERE person_id = ? ORDER BY alias",
|
||||
(person_id,),
|
||||
).fetchall()
|
||||
d["aliases"] = [dict(a) for a in aliases]
|
||||
return d
|
||||
|
||||
|
||||
def get_detections_for_person(
|
||||
person_id: int, limit: int = 50, offset: int = 0
|
||||
) -> tuple[list[dict], int]:
|
||||
"""Return paginated detections for a person and total count (no embedding column)."""
|
||||
with _connect() as conn:
|
||||
total = conn.execute(
|
||||
"SELECT COUNT(*) FROM face_detections WHERE person_id = ?", (person_id,)
|
||||
).fetchone()[0]
|
||||
rows = conn.execute(
|
||||
"""SELECT id, source_type, source_id, face_index, frame_index, bbox_json,
|
||||
created_at, identified_at
|
||||
FROM face_detections
|
||||
WHERE person_id = ?
|
||||
ORDER BY id
|
||||
LIMIT ? OFFSET ?""",
|
||||
(person_id, limit, offset),
|
||||
).fetchall()
|
||||
return [dict(r) for r in rows], total
|
||||
|
||||
|
||||
def unidentify_detection(detection_id: int) -> None:
|
||||
"""Remove the person association from a detection."""
|
||||
with _connect() as conn:
|
||||
conn.execute(
|
||||
"UPDATE face_detections SET person_id = NULL, identified_at = NULL WHERE id = ?",
|
||||
(detection_id,),
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
|
||||
def rename_person(person_id: int, new_name: str) -> None:
|
||||
"""
|
||||
Rename a person. Allows case-only renames for the same person.
|
||||
Raises ValueError if new_name is taken by a different person or any alias.
|
||||
"""
|
||||
with _connect() as conn:
|
||||
# Check if name is taken by a different person
|
||||
row = conn.execute(
|
||||
"SELECT id FROM persons WHERE LOWER(name) = LOWER(?)", (new_name,)
|
||||
).fetchone()
|
||||
if row and row["id"] != person_id:
|
||||
raise ValueError(f"Name '{new_name}' is already taken by another person")
|
||||
# Check if name is taken by any alias (aliases belong to any person)
|
||||
alias_row = conn.execute(
|
||||
"SELECT person_id FROM person_aliases WHERE LOWER(alias) = LOWER(?)", (new_name,)
|
||||
).fetchone()
|
||||
if alias_row:
|
||||
raise ValueError(f"Name '{new_name}' is already used as an alias")
|
||||
conn.execute("UPDATE persons SET name = ? WHERE id = ?", (new_name, person_id))
|
||||
conn.commit()
|
||||
|
||||
|
||||
def delete_person(person_id: int) -> None:
|
||||
"""
|
||||
Delete a person: NULL all their detections, delete aliases, delete person row.
|
||||
Does not rely on FK pragma (which is not enabled by default).
|
||||
"""
|
||||
with _connect() as conn:
|
||||
conn.execute(
|
||||
"UPDATE face_detections SET person_id = NULL, identified_at = NULL WHERE person_id = ?",
|
||||
(person_id,),
|
||||
)
|
||||
conn.execute("DELETE FROM person_aliases WHERE person_id = ?", (person_id,))
|
||||
conn.execute("DELETE FROM persons WHERE id = ?", (person_id,))
|
||||
conn.commit()
|
||||
|
||||
|
||||
def remove_person_from_source_ids(source_ids: list[int], person_id: int, source_type: str = "output") -> int:
|
||||
"""Delete face_detections linking person_id to any of the given source_ids. Returns deleted count."""
|
||||
if not source_ids:
|
||||
return 0
|
||||
placeholders = ",".join("?" * len(source_ids))
|
||||
with _connect() as conn:
|
||||
cur = conn.execute(
|
||||
f"DELETE FROM face_detections WHERE source_type = ? AND person_id = ? AND source_id IN ({placeholders})",
|
||||
(source_type, person_id, *source_ids),
|
||||
)
|
||||
conn.commit()
|
||||
return cur.rowcount
|
||||
|
||||
|
||||
def get_persons_for_source_ids(source_ids: list[int], source_type: str = "output") -> list[dict]:
|
||||
"""Return distinct [{id, name}] for persons detected in the given source IDs."""
|
||||
if not source_ids:
|
||||
return []
|
||||
placeholders = ",".join("?" * len(source_ids))
|
||||
with _connect() as conn:
|
||||
rows = conn.execute(
|
||||
f"""SELECT DISTINCT p.id, p.name
|
||||
FROM face_detections fd JOIN persons p ON p.id = fd.person_id
|
||||
WHERE fd.source_type = ? AND fd.source_id IN ({placeholders}) AND fd.person_id IS NOT NULL
|
||||
ORDER BY p.name""",
|
||||
(source_type, *source_ids),
|
||||
).fetchall()
|
||||
return [dict(r) for r in rows]
|
||||
|
||||
|
||||
def get_source_ids_for_person_query(name_query: str, source_type: str) -> list[int]:
|
||||
"""Return distinct source_id values where a person/alias matching the substring appears."""
|
||||
if not name_query.strip():
|
||||
return []
|
||||
pattern = f"%{name_query}%"
|
||||
with _connect() as conn:
|
||||
rows = conn.execute(
|
||||
"""SELECT DISTINCT fd.source_id
|
||||
FROM face_detections fd
|
||||
JOIN persons p ON p.id = fd.person_id
|
||||
LEFT JOIN person_aliases pa ON pa.person_id = p.id
|
||||
WHERE fd.source_type = ? AND fd.person_id IS NOT NULL
|
||||
AND (LOWER(p.name) LIKE LOWER(?) OR LOWER(pa.alias) LIKE LOWER(?))""",
|
||||
(source_type, pattern, pattern),
|
||||
).fetchall()
|
||||
return [r["source_id"] for r in rows]
|
||||
|
||||
|
||||
def get_persons_for_source_id_map(source_ids: list[int], source_type: str) -> dict[int, list[str]]:
|
||||
"""Return {source_id: [person_name, …]} for the given source IDs (identified faces only)."""
|
||||
if not source_ids:
|
||||
return {}
|
||||
placeholders = ",".join("?" * len(source_ids))
|
||||
with _connect() as conn:
|
||||
rows = conn.execute(
|
||||
f"""SELECT fd.source_id, p.name
|
||||
FROM face_detections fd JOIN persons p ON p.id = fd.person_id
|
||||
WHERE fd.source_type = ? AND fd.source_id IN ({placeholders}) AND fd.person_id IS NOT NULL
|
||||
ORDER BY fd.source_id, p.name""",
|
||||
(source_type, *source_ids),
|
||||
).fetchall()
|
||||
result: dict[int, list[str]] = {sid: [] for sid in source_ids}
|
||||
for row in rows:
|
||||
sid = row["source_id"]
|
||||
name = row["name"]
|
||||
if name not in result[sid]:
|
||||
result[sid].append(name)
|
||||
return result
|
||||
|
||||
|
||||
def update_detection_embedding(detection_id: int, embedding: "np.ndarray") -> None:
|
||||
"""Update the embedding for an existing detection row."""
|
||||
embedding_bytes = embedding.astype(np.float32).tobytes()
|
||||
with _connect() as conn:
|
||||
conn.execute(
|
||||
"UPDATE face_detections SET embedding = ? WHERE id = ?",
|
||||
(embedding_bytes, detection_id),
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
|
||||
def get_null_embedding_output_source_ids() -> list[int]:
|
||||
"""Distinct source_ids of output detections with NULL embeddings and stored bbox."""
|
||||
with _connect() as conn:
|
||||
rows = conn.execute(
|
||||
"""SELECT DISTINCT source_id FROM face_detections
|
||||
WHERE source_type = 'output' AND embedding IS NULL
|
||||
AND bbox_json IS NOT NULL AND bbox_json != '{}'"""
|
||||
).fetchall()
|
||||
return [r["source_id"] for r in rows]
|
||||
|
||||
|
||||
def merge_persons(survivor_id: int, other_id: int) -> None:
|
||||
"""
|
||||
Absorb other_id into survivor_id: move all detections and aliases, then delete other_id.
|
||||
Raises ValueError if both ids are the same.
|
||||
"""
|
||||
if survivor_id == other_id:
|
||||
raise ValueError("Cannot merge person into themselves")
|
||||
with _connect() as conn:
|
||||
conn.execute(
|
||||
"UPDATE face_detections SET person_id = ? WHERE person_id = ?",
|
||||
(survivor_id, other_id),
|
||||
)
|
||||
conn.execute(
|
||||
"UPDATE person_aliases SET person_id = ? WHERE person_id = ?",
|
||||
(survivor_id, other_id),
|
||||
)
|
||||
conn.execute("DELETE FROM persons WHERE id = ?", (other_id,))
|
||||
conn.commit()
|
||||
565
face_service.py
Normal file
565
face_service.py
Normal file
@@ -0,0 +1,565 @@
|
||||
"""
|
||||
face_service.py
|
||||
===============
|
||||
|
||||
FaceService: wrapper around insightface for face detection and recognition.
|
||||
|
||||
Runs CPU-bound work in a ThreadPoolExecutor(max_workers=1).
|
||||
Falls back gracefully if insightface is not installed (available=False).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import tempfile
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
from dataclasses import dataclass
|
||||
from typing import Optional
|
||||
|
||||
import numpy as np
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
try:
|
||||
from insightface.app import FaceAnalysis as _FaceAnalysis
|
||||
_INSIGHTFACE_AVAILABLE = True
|
||||
except ImportError:
|
||||
_FaceAnalysis = None # type: ignore
|
||||
_INSIGHTFACE_AVAILABLE = False
|
||||
|
||||
_SIMILARITY_THRESHOLD = 0.4
|
||||
|
||||
|
||||
@dataclass
|
||||
class DetectedFace:
|
||||
face_index: int
|
||||
bbox: dict # {x1, y1, x2, y2}
|
||||
embedding: np.ndarray
|
||||
crop_bytes: bytes # JPEG bytes of the face crop
|
||||
|
||||
|
||||
@dataclass
|
||||
class ScanResult:
|
||||
detection_id: int
|
||||
face_index: int
|
||||
bbox: dict
|
||||
matched_person_id: Optional[int]
|
||||
matched_person_name: Optional[str]
|
||||
|
||||
|
||||
class FaceService:
|
||||
available: bool
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.available = _INSIGHTFACE_AVAILABLE
|
||||
self._executor = ThreadPoolExecutor(max_workers=1)
|
||||
self._app = None
|
||||
if self.available:
|
||||
try:
|
||||
self._app = _FaceAnalysis(providers=["CPUExecutionProvider"])
|
||||
self._app.prepare(ctx_id=0, det_size=(640, 640))
|
||||
logger.info("FaceService: insightface ready")
|
||||
except Exception as exc:
|
||||
logger.warning("FaceService: failed to init insightface: %s", exc)
|
||||
self.available = False
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Low-level detection
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _detect_sync(self, image_bytes: bytes) -> list[DetectedFace]:
|
||||
"""CPU-bound: detect faces in image bytes."""
|
||||
import cv2
|
||||
arr = np.frombuffer(image_bytes, dtype=np.uint8)
|
||||
try:
|
||||
img = cv2.imdecode(arr, cv2.IMREAD_COLOR)
|
||||
except Exception as exc:
|
||||
logger.warning("FaceService: cv2.imdecode failed: %s", exc)
|
||||
return []
|
||||
if img is None:
|
||||
return []
|
||||
try:
|
||||
faces = self._app.get(img)
|
||||
except Exception as exc:
|
||||
logger.warning("FaceService: face detection failed: %s", exc)
|
||||
return []
|
||||
|
||||
results = []
|
||||
for i, face in enumerate(faces):
|
||||
x1, y1, x2, y2 = (int(v) for v in face.bbox)
|
||||
bbox = {"x1": x1, "y1": y1, "x2": x2, "y2": y2}
|
||||
emb = face.normed_embedding.astype(np.float32)
|
||||
|
||||
# Crop with padding
|
||||
pad = 20
|
||||
h, w = img.shape[:2]
|
||||
cx1 = max(0, x1 - pad)
|
||||
cy1 = max(0, y1 - pad)
|
||||
cx2 = min(w, x2 + pad)
|
||||
cy2 = min(h, y2 + pad)
|
||||
crop = img[cy1:cy2, cx1:cx2]
|
||||
_, buf = cv2.imencode(".jpg", crop, [cv2.IMWRITE_JPEG_QUALITY, 85])
|
||||
crop_bytes = buf.tobytes()
|
||||
|
||||
results.append(DetectedFace(
|
||||
face_index=i,
|
||||
bbox=bbox,
|
||||
embedding=emb,
|
||||
crop_bytes=crop_bytes,
|
||||
))
|
||||
return results
|
||||
|
||||
async def detect(self, image_bytes: bytes) -> list[DetectedFace]:
|
||||
"""Async face detection wrapper."""
|
||||
loop = asyncio.get_event_loop()
|
||||
return await loop.run_in_executor(self._executor, self._detect_sync, image_bytes)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Matching
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def find_best_match(
|
||||
self,
|
||||
embedding: np.ndarray,
|
||||
known_list: list[dict],
|
||||
) -> tuple[Optional[int], float]:
|
||||
"""Return (person_id, similarity) of the best cosine-similarity match, or (None, 0.0)."""
|
||||
if not known_list:
|
||||
return None, 0.0
|
||||
best_sim = 0.0
|
||||
best_id = None
|
||||
for entry in known_list:
|
||||
sim = float(np.dot(embedding, entry["embedding"]))
|
||||
if sim > best_sim:
|
||||
best_sim = sim
|
||||
best_id = entry["person_id"]
|
||||
if best_sim >= _SIMILARITY_THRESHOLD:
|
||||
return best_id, best_sim
|
||||
return None, best_sim
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Clustering
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _cluster_sync(self, embeddings: list[dict], threshold: float) -> list[list[int]]:
|
||||
"""
|
||||
Union-find clustering of face embeddings by cosine similarity.
|
||||
|
||||
O(n²) memory — suitable for up to ~10k faces (1000 faces ≈ 4 MB float32).
|
||||
Returns list of detection-id lists, one per cluster with ≥ 2 members.
|
||||
"""
|
||||
n = len(embeddings)
|
||||
parent = list(range(n))
|
||||
|
||||
def find(x: int) -> int:
|
||||
while parent[x] != x:
|
||||
parent[x] = parent[parent[x]]
|
||||
x = parent[x]
|
||||
return x
|
||||
|
||||
def union(x: int, y: int) -> None:
|
||||
px, py = find(x), find(y)
|
||||
if px != py:
|
||||
parent[py] = px
|
||||
|
||||
M = np.stack([e["embedding"] for e in embeddings])
|
||||
norms = np.linalg.norm(M, axis=1, keepdims=True)
|
||||
M_norm = M / (norms + 1e-8)
|
||||
sim_matrix = M_norm @ M_norm.T
|
||||
|
||||
pairs = np.argwhere(sim_matrix >= threshold)
|
||||
for i, j in pairs:
|
||||
if i < j:
|
||||
union(int(i), int(j))
|
||||
|
||||
groups: dict[int, list[int]] = {}
|
||||
for idx, e in enumerate(embeddings):
|
||||
root = find(idx)
|
||||
groups.setdefault(root, []).append(e["id"])
|
||||
return [ids for ids in groups.values() if len(ids) >= 2]
|
||||
|
||||
async def cluster_unidentified_faces(self, threshold: float = 0.45) -> list[list[int]]:
|
||||
"""
|
||||
Cluster all unidentified detections by embedding similarity and persist groups to face_db.
|
||||
Clears existing groups before recomputing.
|
||||
"""
|
||||
if not self.available:
|
||||
return []
|
||||
import face_db
|
||||
embeddings = face_db.get_unidentified_embeddings()
|
||||
if len(embeddings) < 2:
|
||||
face_db.clear_all_groups()
|
||||
return []
|
||||
loop = asyncio.get_event_loop()
|
||||
groups = await loop.run_in_executor(
|
||||
self._executor, self._cluster_sync, embeddings, threshold
|
||||
)
|
||||
face_db.clear_all_groups()
|
||||
for det_ids in groups:
|
||||
gid = face_db.create_group(threshold)
|
||||
for det_id in det_ids:
|
||||
face_db.assign_detection_to_group(det_id, gid)
|
||||
return groups
|
||||
|
||||
def _assign_to_nearest_group_sync(self, embedding: np.ndarray) -> int | None:
|
||||
"""
|
||||
Compare embedding against existing group centroids and return the best matching group_id,
|
||||
or None if no group exceeds its threshold.
|
||||
Fast enough to call synchronously (< 50 groups × < 50 members).
|
||||
"""
|
||||
import face_db
|
||||
groups = face_db.get_all_group_embeddings_with_threshold()
|
||||
if not groups:
|
||||
return None
|
||||
norm_emb = embedding / (np.linalg.norm(embedding) + 1e-8)
|
||||
best_gid: int | None = None
|
||||
best_sim = -1.0
|
||||
for g in groups:
|
||||
M = np.stack(g["embeddings"])
|
||||
norms = np.linalg.norm(M, axis=1, keepdims=True)
|
||||
M_norm = M / (norms + 1e-8)
|
||||
mean_sim = float(np.mean(M_norm @ norm_emb))
|
||||
if mean_sim >= g["threshold"] and mean_sim > best_sim:
|
||||
best_sim = mean_sim
|
||||
best_gid = g["group_id"]
|
||||
return best_gid
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# High-level pipelines
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
async def scan_input_image(self, source_id: int, image_bytes: bytes) -> list[ScanResult]:
|
||||
"""Detect faces in an input image, auto-link if known, store to face_db."""
|
||||
if not self.available:
|
||||
return []
|
||||
import face_db
|
||||
faces = await self.detect(image_bytes)
|
||||
if not faces:
|
||||
return []
|
||||
known = face_db.get_all_embeddings()
|
||||
persons_cache: dict[int, str] = {p["id"]: p["name"] for p in face_db.list_persons()}
|
||||
results = []
|
||||
for face in faces:
|
||||
person_id, _ = self.find_best_match(face.embedding, known)
|
||||
person_name = persons_cache.get(person_id) if person_id is not None else None
|
||||
det_id = face_db.insert_detection(
|
||||
source_type="input",
|
||||
source_id=source_id,
|
||||
embedding=face.embedding,
|
||||
bbox=face.bbox,
|
||||
frame_index=0,
|
||||
face_index=face.face_index,
|
||||
person_id=person_id,
|
||||
)
|
||||
if person_id is None:
|
||||
gid = self._assign_to_nearest_group_sync(face.embedding)
|
||||
if gid is not None:
|
||||
face_db.assign_detection_to_group(det_id, gid)
|
||||
results.append(ScanResult(
|
||||
detection_id=det_id,
|
||||
face_index=face.face_index,
|
||||
bbox=face.bbox,
|
||||
matched_person_id=person_id,
|
||||
matched_person_name=person_name,
|
||||
))
|
||||
return results
|
||||
|
||||
async def scan_output_image(self, source_id: int, image_bytes: bytes) -> list[ScanResult]:
|
||||
"""Detect faces in a generated output image. Silent background scan."""
|
||||
if not self.available:
|
||||
return []
|
||||
import face_db
|
||||
faces = await self.detect(image_bytes)
|
||||
if not faces:
|
||||
return []
|
||||
known = face_db.get_all_embeddings()
|
||||
persons_cache: dict[int, str] = {p["id"]: p["name"] for p in face_db.list_persons()}
|
||||
results = []
|
||||
for face in faces:
|
||||
person_id, _ = self.find_best_match(face.embedding, known)
|
||||
det_id = face_db.insert_detection(
|
||||
source_type="output",
|
||||
source_id=source_id,
|
||||
embedding=None, # discard; saves space; rescan fills on demand
|
||||
bbox=face.bbox,
|
||||
frame_index=0,
|
||||
face_index=face.face_index,
|
||||
person_id=person_id,
|
||||
)
|
||||
if person_id is None:
|
||||
gid = self._assign_to_nearest_group_sync(face.embedding)
|
||||
if gid is not None:
|
||||
face_db.assign_detection_to_group(det_id, gid)
|
||||
if person_id is not None:
|
||||
person_name = persons_cache.get(person_id)
|
||||
results.append(ScanResult(
|
||||
detection_id=det_id,
|
||||
face_index=face.face_index,
|
||||
bbox=face.bbox,
|
||||
matched_person_id=person_id,
|
||||
matched_person_name=person_name,
|
||||
))
|
||||
logger.info(
|
||||
"Face scan [output image source_id=%d]: %d face(s) detected, %d matched",
|
||||
source_id, len(faces), sum(1 for r in results if r.matched_person_id is not None),
|
||||
)
|
||||
return results
|
||||
|
||||
def _extract_keyframes_sync(self, video_bytes: bytes, max_frames: int = 20) -> list:
|
||||
"""Extract evenly-spaced keyframes from video bytes. Returns list of BGR numpy arrays."""
|
||||
import cv2
|
||||
with tempfile.NamedTemporaryFile(suffix=".mp4", delete=False) as f:
|
||||
f.write(video_bytes)
|
||||
tmp_path = f.name
|
||||
try:
|
||||
cap = cv2.VideoCapture(tmp_path)
|
||||
total = int(cap.get(cv2.CAP_PROP_FRAME_COUNT))
|
||||
if total <= 0:
|
||||
cap.release()
|
||||
return []
|
||||
n = min(max_frames, total)
|
||||
indices = [int(i * total / n) for i in range(n)]
|
||||
frames = []
|
||||
for idx in indices:
|
||||
cap.set(cv2.CAP_PROP_POS_FRAMES, idx)
|
||||
ret, frame = cap.read()
|
||||
if ret:
|
||||
frames.append(frame)
|
||||
cap.release()
|
||||
return frames
|
||||
finally:
|
||||
try:
|
||||
os.unlink(tmp_path)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
async def scan_video(self, source_id: int, video_bytes: bytes, max_frames: int = 20) -> list[ScanResult]:
|
||||
"""Detect faces across video keyframes. Silent background scan."""
|
||||
if not self.available:
|
||||
return []
|
||||
import cv2
|
||||
import face_db
|
||||
loop = asyncio.get_event_loop()
|
||||
frames = await loop.run_in_executor(
|
||||
self._executor, self._extract_keyframes_sync, video_bytes, max_frames
|
||||
)
|
||||
if not frames:
|
||||
return []
|
||||
known = face_db.get_all_embeddings()
|
||||
persons_cache: dict[int, str] = {p["id"]: p["name"] for p in face_db.list_persons()}
|
||||
results = []
|
||||
seen_det_ids: set[int] = set()
|
||||
for frame_idx, frame in enumerate(frames):
|
||||
_, buf = cv2.imencode(".jpg", frame)
|
||||
frame_bytes = buf.tobytes()
|
||||
faces = await self.detect(frame_bytes)
|
||||
for face in faces:
|
||||
person_id, _ = self.find_best_match(face.embedding, known)
|
||||
det_id = face_db.insert_detection(
|
||||
source_type="output",
|
||||
source_id=source_id,
|
||||
embedding=None, # discard; saves space; rescan fills on demand
|
||||
bbox=face.bbox,
|
||||
frame_index=frame_idx,
|
||||
face_index=face.face_index,
|
||||
person_id=person_id,
|
||||
)
|
||||
if det_id not in seen_det_ids:
|
||||
seen_det_ids.add(det_id)
|
||||
if person_id is not None:
|
||||
person_name = persons_cache.get(person_id)
|
||||
results.append(ScanResult(
|
||||
detection_id=det_id,
|
||||
face_index=face.face_index,
|
||||
bbox=face.bbox,
|
||||
matched_person_id=person_id,
|
||||
matched_person_name=person_name,
|
||||
))
|
||||
logger.info(
|
||||
"Face scan [output video source_id=%d]: %d frame(s), %d result(s) matched",
|
||||
source_id, len(frames), len(results),
|
||||
)
|
||||
return results
|
||||
|
||||
async def rescan_output_embedding(self, source_id: int) -> int:
|
||||
"""
|
||||
Re-detect faces in a stored output image and update NULL embeddings
|
||||
for existing detections by bbox proximity matching.
|
||||
Returns count of detections updated.
|
||||
"""
|
||||
if not self.available:
|
||||
return 0
|
||||
import sqlite3
|
||||
import face_db
|
||||
import generation_db
|
||||
conn = sqlite3.connect(str(generation_db._DB_PATH), check_same_thread=False)
|
||||
conn.row_factory = sqlite3.Row
|
||||
row = conn.execute(
|
||||
"SELECT file_data, mime_type FROM generation_files WHERE id = ?", (source_id,)
|
||||
).fetchone()
|
||||
conn.close()
|
||||
if row is None:
|
||||
return 0
|
||||
file_bytes = bytes(row["file_data"])
|
||||
mime = (row["mime_type"] or "").lower()
|
||||
if mime.startswith("video/"):
|
||||
return 0 # skip videos — too expensive for backfill
|
||||
|
||||
faces = await self.detect(file_bytes)
|
||||
if not faces:
|
||||
return 0
|
||||
|
||||
existing = [
|
||||
d for d in face_db.get_detections_for_source("output", source_id)
|
||||
if d.get("embedding") is None and d.get("bbox_json") not in (None, "{}")
|
||||
]
|
||||
if not existing:
|
||||
return 0
|
||||
|
||||
updated = 0
|
||||
for face in faces:
|
||||
fx = (face.bbox["x1"] + face.bbox["x2"]) / 2
|
||||
fy = (face.bbox["y1"] + face.bbox["y2"]) / 2
|
||||
best_det = None
|
||||
best_dist = float("inf")
|
||||
for det in existing:
|
||||
b = json.loads(det["bbox_json"])
|
||||
dx = fx - (b["x1"] + b["x2"]) / 2
|
||||
dy = fy - (b["y1"] + b["y2"]) / 2
|
||||
dist = (dx * dx + dy * dy) ** 0.5
|
||||
if dist < best_dist:
|
||||
best_dist = dist
|
||||
best_det = det
|
||||
if best_det is not None and best_dist <= 50:
|
||||
face_db.update_detection_embedding(best_det["id"], face.embedding)
|
||||
existing = [d for d in existing if d["id"] != best_det["id"]]
|
||||
updated += 1
|
||||
if best_det.get("person_id") is None:
|
||||
known = face_db.get_all_embeddings()
|
||||
matched_pid, _ = self.find_best_match(face.embedding, known)
|
||||
if matched_pid is not None:
|
||||
face_db.link_detection_to_person(best_det["id"], matched_pid)
|
||||
|
||||
return updated
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Utility
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _extract_frame_at_sync(
|
||||
self, video_bytes: bytes, frame_index: int, max_frames: int = 20,
|
||||
suffix: str = ".mp4",
|
||||
) -> "np.ndarray | None":
|
||||
"""
|
||||
Re-extract the specific video frame that was used during scan_video.
|
||||
|
||||
frame_index is the enumeration index (0…n-1) used by scan_video, NOT the raw
|
||||
video frame number. We reconstruct the same sampling formula:
|
||||
actual_frame = int(frame_index * total / n) where n = min(max_frames, total)
|
||||
"""
|
||||
import cv2
|
||||
with tempfile.NamedTemporaryFile(suffix=suffix, delete=False) as f:
|
||||
f.write(video_bytes)
|
||||
tmp_path = f.name
|
||||
try:
|
||||
cap = cv2.VideoCapture(tmp_path)
|
||||
total = int(cap.get(cv2.CAP_PROP_FRAME_COUNT))
|
||||
if total <= 0:
|
||||
cap.release()
|
||||
return None
|
||||
n = min(max_frames, total)
|
||||
if frame_index >= n:
|
||||
cap.release()
|
||||
return None
|
||||
actual_idx = int(frame_index * total / n)
|
||||
cap.set(cv2.CAP_PROP_POS_FRAMES, actual_idx)
|
||||
ret, frame = cap.read()
|
||||
cap.release()
|
||||
return frame if ret else None
|
||||
except Exception:
|
||||
return None
|
||||
finally:
|
||||
try:
|
||||
os.unlink(tmp_path)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def get_face_crop(self, detection_id: int) -> bytes | None:
|
||||
"""Re-derive the face crop from the stored source image or video frame. Returns JPEG bytes or None."""
|
||||
import cv2
|
||||
import face_db
|
||||
det = face_db.get_detection(detection_id)
|
||||
if det is None:
|
||||
return None
|
||||
source_type = det["source_type"]
|
||||
source_id = det["source_id"]
|
||||
bbox_raw = det["bbox_json"]
|
||||
if not bbox_raw:
|
||||
return None
|
||||
bbox = json.loads(bbox_raw)
|
||||
|
||||
img = None
|
||||
if source_type == "input":
|
||||
from input_image_db import get_image_data
|
||||
image_bytes = get_image_data(source_id)
|
||||
if image_bytes is None:
|
||||
return None
|
||||
arr = np.frombuffer(image_bytes, dtype=np.uint8)
|
||||
try:
|
||||
img = cv2.imdecode(arr, cv2.IMREAD_COLOR)
|
||||
except Exception:
|
||||
return None
|
||||
elif source_type == "output":
|
||||
import sqlite3
|
||||
import generation_db
|
||||
conn = sqlite3.connect(str(generation_db._DB_PATH), check_same_thread=False)
|
||||
conn.row_factory = sqlite3.Row
|
||||
row = conn.execute(
|
||||
"SELECT file_data, mime_type FROM generation_files WHERE id = ?", (source_id,)
|
||||
).fetchone()
|
||||
conn.close()
|
||||
if row is None:
|
||||
return None
|
||||
file_bytes = bytes(row["file_data"])
|
||||
mime = (row["mime_type"] or "").lower()
|
||||
if mime.startswith("video/"):
|
||||
frame_index = det.get("frame_index", 0) or 0
|
||||
# Pick a matching temp-file suffix so OpenCV selects the right codec
|
||||
_mime_to_ext = {"video/mp4": ".mp4", "video/webm": ".webm",
|
||||
"video/avi": ".avi", "video/quicktime": ".mov"}
|
||||
vsuffix = _mime_to_ext.get(mime, ".mp4")
|
||||
img = self._extract_frame_at_sync(file_bytes, frame_index, suffix=vsuffix)
|
||||
else:
|
||||
arr = np.frombuffer(file_bytes, dtype=np.uint8)
|
||||
try:
|
||||
img = cv2.imdecode(arr, cv2.IMREAD_COLOR)
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
if img is None:
|
||||
return None
|
||||
|
||||
x1, y1, x2, y2 = bbox["x1"], bbox["y1"], bbox["x2"], bbox["y2"]
|
||||
pad = 20
|
||||
h, w = img.shape[:2]
|
||||
cx1 = max(0, x1 - pad)
|
||||
cy1 = max(0, y1 - pad)
|
||||
cx2 = min(w, x2 + pad)
|
||||
cy2 = min(h, y2 + pad)
|
||||
crop = img[cy1:cy2, cx1:cx2]
|
||||
_, buf = cv2.imencode(".jpg", crop, [cv2.IMWRITE_JPEG_QUALITY, 85])
|
||||
return buf.tobytes()
|
||||
|
||||
|
||||
# Module-level singleton
|
||||
_face_service: FaceService | None = None
|
||||
|
||||
|
||||
def get_face_service() -> FaceService:
|
||||
global _face_service
|
||||
if _face_service is None:
|
||||
_face_service = FaceService()
|
||||
return _face_service
|
||||
59
frontend/package-lock.json
generated
59
frontend/package-lock.json
generated
@@ -9,6 +9,8 @@
|
||||
"version": "0.1.0",
|
||||
"dependencies": {
|
||||
"@tanstack/react-query": "^5.62.0",
|
||||
"framer-motion": "^12.35.0",
|
||||
"lucide-react": "^0.577.0",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-router-dom": "^6.27.0"
|
||||
@@ -1729,6 +1731,33 @@
|
||||
"url": "https://github.com/sponsors/rawify"
|
||||
}
|
||||
},
|
||||
"node_modules/framer-motion": {
|
||||
"version": "12.35.0",
|
||||
"resolved": "https://artifactory.ubisoft.org/api/npm/npm/framer-motion/-/framer-motion-12.35.0.tgz",
|
||||
"integrity": "sha512-w8hghCMQ4oq10j6aZh3U2yeEQv5K69O/seDI/41PK4HtgkLrcBovUNc0ayBC3UyyU7V1mrY2yLzvYdWJX9pGZQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"motion-dom": "^12.35.0",
|
||||
"motion-utils": "^12.29.2",
|
||||
"tslib": "^2.4.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@emotion/is-prop-valid": "*",
|
||||
"react": "^18.0.0 || ^19.0.0",
|
||||
"react-dom": "^18.0.0 || ^19.0.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@emotion/is-prop-valid": {
|
||||
"optional": true
|
||||
},
|
||||
"react": {
|
||||
"optional": true
|
||||
},
|
||||
"react-dom": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/fsevents": {
|
||||
"version": "2.3.3",
|
||||
"resolved": "https://artifactory.ubisoft.org/api/npm/npm/fsevents/-/fsevents-2.3.3.tgz",
|
||||
@@ -1936,6 +1965,15 @@
|
||||
"yallist": "^3.0.2"
|
||||
}
|
||||
},
|
||||
"node_modules/lucide-react": {
|
||||
"version": "0.577.0",
|
||||
"resolved": "https://artifactory.ubisoft.org/api/npm/npm/lucide-react/-/lucide-react-0.577.0.tgz",
|
||||
"integrity": "sha512-4LjoFv2eEPwYDPg/CUdBJQSDfPyzXCRrVW1X7jrx/trgxnxkHFjnVZINbzvzxjN70dxychOfg+FTYwBiS3pQ5A==",
|
||||
"license": "ISC",
|
||||
"peerDependencies": {
|
||||
"react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/merge2": {
|
||||
"version": "1.4.1",
|
||||
"resolved": "https://artifactory.ubisoft.org/api/npm/npm/merge2/-/merge2-1.4.1.tgz",
|
||||
@@ -1960,6 +1998,21 @@
|
||||
"node": ">=8.6"
|
||||
}
|
||||
},
|
||||
"node_modules/motion-dom": {
|
||||
"version": "12.35.0",
|
||||
"resolved": "https://artifactory.ubisoft.org/api/npm/npm/motion-dom/-/motion-dom-12.35.0.tgz",
|
||||
"integrity": "sha512-FFMLEnIejK/zDABn+vqGVAUN4T0+3fw+cVAY8MMT65yR+j5uMuvWdd4npACWhh94OVWQs79CrBBuwOwGRZAQiA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"motion-utils": "^12.29.2"
|
||||
}
|
||||
},
|
||||
"node_modules/motion-utils": {
|
||||
"version": "12.29.2",
|
||||
"resolved": "https://artifactory.ubisoft.org/api/npm/npm/motion-utils/-/motion-utils-12.29.2.tgz",
|
||||
"integrity": "sha512-G3kc34H2cX2gI63RqU+cZq+zWRRPSsNIOjpdl9TN4AQwC4sgwYPl/Q/Obf/d53nOm569T0fYK+tcoSV50BWx8A==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/ms": {
|
||||
"version": "2.1.3",
|
||||
"resolved": "https://artifactory.ubisoft.org/api/npm/npm/ms/-/ms-2.1.3.tgz",
|
||||
@@ -2651,6 +2704,12 @@
|
||||
"dev": true,
|
||||
"license": "Apache-2.0"
|
||||
},
|
||||
"node_modules/tslib": {
|
||||
"version": "2.8.1",
|
||||
"resolved": "https://artifactory.ubisoft.org/api/npm/npm/tslib/-/tslib-2.8.1.tgz",
|
||||
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
|
||||
"license": "0BSD"
|
||||
},
|
||||
"node_modules/typescript": {
|
||||
"version": "5.9.3",
|
||||
"resolved": "https://artifactory.ubisoft.org/api/npm/npm/typescript/-/typescript-5.9.3.tgz",
|
||||
|
||||
@@ -9,10 +9,12 @@
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@tanstack/react-query": "^5.62.0",
|
||||
"framer-motion": "^12.35.0",
|
||||
"lucide-react": "^0.577.0",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-router-dom": "^6.27.0",
|
||||
"@tanstack/react-query": "^5.62.0"
|
||||
"react-router-dom": "^6.27.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "^18.3.12",
|
||||
|
||||
@@ -12,6 +12,7 @@ import StatusPage from './pages/StatusPage'
|
||||
import ServerPage from './pages/ServerPage'
|
||||
import HistoryPage from './pages/HistoryPage'
|
||||
import AdminPage from './pages/AdminPage'
|
||||
import FacesPage from './pages/FacesPage'
|
||||
import SharePage from './pages/SharePage'
|
||||
|
||||
function RequireAuth({ children }: { children: React.ReactNode }) {
|
||||
@@ -59,6 +60,14 @@ export default function App() {
|
||||
</RequireAdmin>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="faces"
|
||||
element={
|
||||
<RequireAdmin>
|
||||
<FacesPage />
|
||||
</RequireAdmin>
|
||||
}
|
||||
/>
|
||||
</Route>
|
||||
<Route path="/share/:token" element={<SharePage />} />
|
||||
<Route path="*" element={<Navigate to="/generate" replace />} />
|
||||
|
||||
@@ -93,8 +93,14 @@ export interface InputImage {
|
||||
filename: string
|
||||
is_active: number
|
||||
active_slot_key: string | null
|
||||
detected_persons?: string[]
|
||||
}
|
||||
export const listInputs = (persons?: string[]) => {
|
||||
const params = new URLSearchParams()
|
||||
persons?.forEach(p => params.append('persons', p))
|
||||
const qs = params.toString()
|
||||
return _fetch<InputImage[]>(qs ? `/api/inputs?${qs}` : '/api/inputs')
|
||||
}
|
||||
export const listInputs = () => _fetch<InputImage[]>('/api/inputs')
|
||||
|
||||
export const uploadInput = (file: File, slotKey = 'input_image') => {
|
||||
const form = new FormData()
|
||||
@@ -142,11 +148,44 @@ export const tailLogs = (lines = 100) =>
|
||||
_fetch<{ lines: string[] }>(`/api/logs/tail?lines=${lines}`)
|
||||
|
||||
// History
|
||||
export const getHistory = (q?: string) =>
|
||||
_fetch<{ history: Array<Record<string, unknown>> }>(q ? `/api/history?q=${encodeURIComponent(q)}` : '/api/history')
|
||||
export const getHistory = (q?: string, persons?: string[]) => {
|
||||
const params = new URLSearchParams()
|
||||
if (q) params.set('q', q)
|
||||
persons?.forEach(p => params.append('persons', p))
|
||||
const qs = params.toString()
|
||||
return _fetch<{ history: Array<Record<string, unknown>> }>(qs ? `/api/history?${qs}` : '/api/history')
|
||||
}
|
||||
|
||||
export const createHistoryShare = (promptId: string) =>
|
||||
_fetch<{ share_token: string }>(`/api/history/${promptId}/share`, { method: 'POST' })
|
||||
export const getGenerationPersons = (promptId: string) =>
|
||||
_fetch<{ persons: Array<{ id: number; name: string }> }>(`/api/history/${promptId}/persons`)
|
||||
|
||||
export const addGenerationPerson = (promptId: string, name: string) =>
|
||||
_fetch<{ person_id: number; name: string }>(`/api/history/${promptId}/persons`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ name }),
|
||||
})
|
||||
|
||||
export const removeGenerationPerson = (promptId: string, personId: number) =>
|
||||
_fetch<{ ok: boolean }>(`/api/history/${promptId}/persons/${personId}`, { method: 'DELETE' })
|
||||
|
||||
export interface ShareOptions {
|
||||
is_public?: boolean
|
||||
expires_in_hours?: number
|
||||
max_views?: number
|
||||
}
|
||||
|
||||
export interface ShareResult {
|
||||
share_token: string
|
||||
is_public: boolean
|
||||
expires_at: string | null
|
||||
max_views: number | null
|
||||
}
|
||||
|
||||
export const createHistoryShare = (promptId: string, options?: ShareOptions) =>
|
||||
_fetch<ShareResult>(`/api/history/${promptId}/share`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(options ?? {}),
|
||||
})
|
||||
|
||||
export const revokeHistoryShare = (promptId: string) =>
|
||||
_fetch<{ ok: boolean }>(`/api/history/${promptId}/share`, { method: 'DELETE' })
|
||||
@@ -196,3 +235,165 @@ export const loadWorkflow = (filename: string) => {
|
||||
export const getModels = (type: 'checkpoints' | 'loras') =>
|
||||
_fetch<{ type: string; models: string[] }>(`/api/workflow/models?type=${type}`)
|
||||
|
||||
// Faces
|
||||
export interface PendingFace {
|
||||
detection_id: number
|
||||
face_index: number
|
||||
bbox: Record<string, number>
|
||||
}
|
||||
|
||||
export interface Alias {
|
||||
id: number
|
||||
alias: string
|
||||
}
|
||||
|
||||
export interface Person {
|
||||
id: number
|
||||
name: string
|
||||
created_at: string
|
||||
aliases: Alias[]
|
||||
face_count: number
|
||||
}
|
||||
|
||||
export interface UnidentifiedDetection {
|
||||
id: number
|
||||
source_id: number
|
||||
face_index: number
|
||||
bbox_json: string
|
||||
created_at: string
|
||||
}
|
||||
|
||||
export const listPersons = () =>
|
||||
_fetch<{ persons: Person[] }>('/api/faces/persons')
|
||||
|
||||
export const checkPersonName = (name: string) =>
|
||||
_fetch<{ exists: boolean }>(`/api/faces/persons/check?name=${encodeURIComponent(name)}`)
|
||||
|
||||
export const identifyFaces = (
|
||||
identifications: Array<{ detection_id: number; name: string; use_existing: boolean }>
|
||||
) =>
|
||||
_fetch<{ identifications: Array<{ detection_id: number; person_id: number; person_name: string; is_new: boolean }>; auto_linked_count: number }>(
|
||||
'/api/faces/identify',
|
||||
{ method: 'POST', body: JSON.stringify({ identifications }) }
|
||||
)
|
||||
|
||||
export const faceCropUrl = (detectionId: number) => `/api/faces/crop/${detectionId}`
|
||||
|
||||
export const getUnidentifiedDetections = (limit = 50, offset = 0) =>
|
||||
_fetch<{ detections: UnidentifiedDetection[]; total: number }>(
|
||||
`/api/faces/detections/unidentified?limit=${limit}&offset=${offset}`
|
||||
)
|
||||
|
||||
export const addPersonAlias = (personId: number, alias: string) =>
|
||||
_fetch<{ id: number; alias: string }>(`/api/faces/persons/${personId}/aliases`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ alias }),
|
||||
})
|
||||
|
||||
export const removePersonAlias = (personId: number, aliasId: number) =>
|
||||
_fetch<{ ok: boolean }>(`/api/faces/persons/${personId}/aliases/${aliasId}`, {
|
||||
method: 'DELETE',
|
||||
})
|
||||
|
||||
export interface FaceGroup {
|
||||
id: number
|
||||
label: string | null
|
||||
threshold: number
|
||||
is_manual: boolean
|
||||
created_at: string
|
||||
count: number
|
||||
detection_ids: number[]
|
||||
preview_ids: number[]
|
||||
}
|
||||
|
||||
export interface GroupDetection {
|
||||
id: number
|
||||
source_type: 'input' | 'output'
|
||||
face_index: number
|
||||
created_at: string
|
||||
}
|
||||
|
||||
export const listFaceGroups = () =>
|
||||
_fetch<{ groups: FaceGroup[]; total: number }>('/api/faces/groups')
|
||||
|
||||
export const getFaceGroupDetections = (groupId: number) =>
|
||||
_fetch<{ detections: GroupDetection[] }>(`/api/faces/groups/${groupId}/detections`)
|
||||
|
||||
export const computeFaceGroups = (threshold: number) =>
|
||||
_fetch<{ groups_created: number; total_detections_clustered: number; threshold: number }>(
|
||||
'/api/faces/groups/compute',
|
||||
{ method: 'POST', body: JSON.stringify({ threshold }) }
|
||||
)
|
||||
|
||||
export const mergeFaceGroups = (keepId: number, discardId: number) =>
|
||||
_fetch<{ ok: boolean; surviving_group_id: number }>('/api/faces/groups/merge', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ keep_group_id: keepId, discard_group_id: discardId }),
|
||||
})
|
||||
|
||||
export const identifyFaceGroup = (groupId: number, name: string, useExisting = false) =>
|
||||
_fetch<{
|
||||
person_id: number; person_name: string; is_new: boolean
|
||||
identified_count: number; auto_linked_count: number
|
||||
}>(`/api/faces/groups/${groupId}/identify`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ name, use_existing: useExisting }),
|
||||
})
|
||||
|
||||
export const removeDetectionFromGroup = (groupId: number, detectionId: number) =>
|
||||
_fetch<{ ok: boolean }>(
|
||||
`/api/faces/groups/${groupId}/detections/${detectionId}`,
|
||||
{ method: 'DELETE' }
|
||||
)
|
||||
|
||||
export const rescanOutputEmbeddings = () =>
|
||||
_fetch<{ processed: number; updated: number }>('/api/faces/rescan/outputs', { method: 'POST' })
|
||||
|
||||
export interface PersonDetection {
|
||||
id: number
|
||||
source_type: 'input' | 'output'
|
||||
source_id: number
|
||||
face_index: number
|
||||
frame_index: number
|
||||
bbox_json: string | null
|
||||
created_at: string
|
||||
identified_at: string | null
|
||||
}
|
||||
|
||||
export const getPerson = (personId: number) =>
|
||||
_fetch<Person>(`/api/faces/persons/${personId}`)
|
||||
|
||||
export const getPersonDetections = (personId: number, limit = 50, offset = 0) =>
|
||||
_fetch<{ detections: PersonDetection[]; total: number }>(
|
||||
`/api/faces/persons/${personId}/detections?limit=${limit}&offset=${offset}`
|
||||
)
|
||||
|
||||
export const searchPersons = (q: string, limit = 10) =>
|
||||
_fetch<{ persons: Person[] }>(
|
||||
`/api/faces/persons/search?q=${encodeURIComponent(q)}&limit=${limit}`
|
||||
)
|
||||
|
||||
export const reassignDetection = (
|
||||
detectionId: number,
|
||||
personName: string | null,
|
||||
useExisting = false,
|
||||
) =>
|
||||
_fetch<{ detection_id: number; person_id: number | null; is_new?: boolean; unidentified?: boolean }>(
|
||||
`/api/faces/detections/${detectionId}/reassign`,
|
||||
{ method: 'POST', body: JSON.stringify({ person_name: personName, use_existing: useExisting }) }
|
||||
)
|
||||
|
||||
export const renamePerson = (personId: number, name: string) =>
|
||||
_fetch<{ ok: boolean; person_id: number; name: string }>(`/api/faces/persons/${personId}`, {
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify({ name }),
|
||||
})
|
||||
|
||||
export const deletePerson = (personId: number) =>
|
||||
_fetch<{ ok: boolean }>(`/api/faces/persons/${personId}`, { method: 'DELETE' })
|
||||
|
||||
export const mergePersons = (survivorId: number, otherId: number) =>
|
||||
_fetch<{ ok: boolean; survivor_id: number; absorbed_id: number }>(
|
||||
`/api/faces/persons/${survivorId}/merge`,
|
||||
{ method: 'POST', body: JSON.stringify({ other_person_id: otherId }) }
|
||||
)
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
getInputMid,
|
||||
} from '../api/client'
|
||||
import LazyImage from './LazyImage'
|
||||
import { X } from 'lucide-react'
|
||||
|
||||
interface Props {
|
||||
/** Called when the Generate button is clicked with the current overrides */
|
||||
@@ -47,20 +48,18 @@ export default function DynamicWorkflowForm({ onGenerate, lastSeed, generating,
|
||||
})
|
||||
const { data: inputImages } = useQuery({
|
||||
queryKey: ['inputs'],
|
||||
queryFn: listInputs,
|
||||
queryFn: () => listInputs(),
|
||||
})
|
||||
|
||||
const [localValues, setLocalValues] = useState<Record<string, unknown>>({})
|
||||
const [randomSeeds, setRandomSeeds] = useState<Record<string, boolean>>({})
|
||||
const [imagePicker, setImagePicker] = useState<string | null>(null) // key of slot being picked
|
||||
const [imagePicker, setImagePicker] = useState<string | null>(null)
|
||||
const [count, setCount] = useState(1)
|
||||
|
||||
// Sync local values from state when stateData arrives
|
||||
useEffect(() => {
|
||||
if (stateData) setLocalValues(stateData as Record<string, unknown>)
|
||||
}, [stateData])
|
||||
|
||||
// Update seed field when WS reports completed seed
|
||||
useEffect(() => {
|
||||
if (lastSeed != null) {
|
||||
setLocalValues(v => ({ ...v, seed: lastSeed }))
|
||||
@@ -114,7 +113,7 @@ export default function DynamicWorkflowForm({ onGenerate, lastSeed, generating,
|
||||
return (
|
||||
<textarea
|
||||
rows={3}
|
||||
className="w-full border border-gray-300 dark:border-gray-600 rounded px-2 py-1 text-sm bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 resize-y focus:outline-none focus:ring-1 focus:ring-blue-500"
|
||||
className="glass-input w-full resize-y"
|
||||
value={String(val ?? '')}
|
||||
onChange={e => setValue(inp.key, e.target.value)}
|
||||
/>
|
||||
@@ -127,7 +126,7 @@ export default function DynamicWorkflowForm({ onGenerate, lastSeed, generating,
|
||||
<div className="flex gap-2 items-center">
|
||||
<input
|
||||
type="number"
|
||||
className="flex-1 border border-gray-300 dark:border-gray-600 rounded px-2 py-1 text-sm bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 focus:outline-none focus:ring-1 focus:ring-blue-500 disabled:opacity-40"
|
||||
className="glass-input flex-1 disabled:opacity-40"
|
||||
value={isRandom ? '' : String(val ?? '')}
|
||||
placeholder={isRandom ? 'Random' : undefined}
|
||||
disabled={isRandom}
|
||||
@@ -136,7 +135,11 @@ export default function DynamicWorkflowForm({ onGenerate, lastSeed, generating,
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setRandomSeeds(r => ({ ...r, [inp.key]: !isRandom }))}
|
||||
className={`text-xs px-2 py-1 rounded border ${isRandom ? 'bg-blue-600 text-white border-blue-600' : 'border-gray-400 text-gray-600 dark:text-gray-300'}`}
|
||||
className={`text-xs px-2.5 py-1.5 rounded-xl border transition-all ${
|
||||
isRandom
|
||||
? 'bg-indigo-600 text-white border-indigo-600 shadow-md shadow-indigo-500/20'
|
||||
: 'border-white/20 dark:border-white/10 text-gray-600 dark:text-gray-300 hover:bg-white/10'
|
||||
}`}
|
||||
>
|
||||
🎲 Random
|
||||
</button>
|
||||
@@ -154,17 +157,21 @@ export default function DynamicWorkflowForm({ onGenerate, lastSeed, generating,
|
||||
<img
|
||||
src={getInputThumb(activeImg.id)}
|
||||
alt={activeImg.filename}
|
||||
className="w-16 h-16 object-cover rounded border border-gray-300 dark:border-gray-600"
|
||||
className="w-16 h-16 object-cover rounded-xl border border-white/20 dark:border-white/10"
|
||||
/>
|
||||
) : (
|
||||
<div className="w-16 h-16 rounded border border-dashed border-gray-400 flex items-center justify-center text-xs text-gray-400">none</div>
|
||||
<div className="w-16 h-16 rounded-xl border border-dashed border-white/20 dark:border-white/10 flex items-center justify-center text-xs text-gray-400">
|
||||
none
|
||||
</div>
|
||||
)}
|
||||
<div className="flex flex-col gap-1">
|
||||
<span className="text-xs text-gray-500 dark:text-gray-400 truncate max-w-[12rem]">{activeFilename || 'No image active'}</span>
|
||||
<span className="text-xs text-gray-500 dark:text-gray-400 truncate max-w-[12rem]">
|
||||
{activeFilename || 'No image active'}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setImagePicker(imagePicker === inp.key ? null : inp.key)}
|
||||
className="text-xs bg-gray-200 dark:bg-gray-600 hover:bg-gray-300 dark:hover:bg-gray-500 rounded px-2 py-0.5"
|
||||
className="btn-secondary text-xs py-0.5 px-2"
|
||||
>
|
||||
Browse
|
||||
</button>
|
||||
@@ -176,7 +183,7 @@ export default function DynamicWorkflowForm({ onGenerate, lastSeed, generating,
|
||||
if (inp.input_type === 'checkpoint') {
|
||||
return (
|
||||
<select
|
||||
className="w-full border border-gray-300 dark:border-gray-600 rounded px-2 py-1 text-sm bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 focus:outline-none focus:ring-1 focus:ring-blue-500"
|
||||
className="glass-input w-full"
|
||||
value={String(val ?? '')}
|
||||
onChange={e => setValue(inp.key, e.target.value)}
|
||||
>
|
||||
@@ -189,7 +196,7 @@ export default function DynamicWorkflowForm({ onGenerate, lastSeed, generating,
|
||||
if (inp.input_type === 'lora') {
|
||||
return (
|
||||
<select
|
||||
className="w-full border border-gray-300 dark:border-gray-600 rounded px-2 py-1 text-sm bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 focus:outline-none focus:ring-1 focus:ring-blue-500"
|
||||
className="glass-input w-full"
|
||||
value={String(val ?? '')}
|
||||
onChange={e => setValue(inp.key, e.target.value)}
|
||||
>
|
||||
@@ -204,7 +211,7 @@ export default function DynamicWorkflowForm({ onGenerate, lastSeed, generating,
|
||||
<input
|
||||
type={inp.input_type === 'integer' || inp.input_type === 'float' ? 'number' : 'text'}
|
||||
step={inp.input_type === 'float' ? 'any' : undefined}
|
||||
className="w-full border border-gray-300 dark:border-gray-600 rounded px-2 py-1 text-sm bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 focus:outline-none focus:ring-1 focus:ring-blue-500"
|
||||
className="glass-input w-full"
|
||||
value={String(val ?? '')}
|
||||
placeholder={String(inp.current_value ?? '')}
|
||||
onChange={e => {
|
||||
@@ -223,7 +230,9 @@ export default function DynamicWorkflowForm({ onGenerate, lastSeed, generating,
|
||||
{/* Common inputs */}
|
||||
{inputsData.common.map(inp => (
|
||||
<div key={inp.key}>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">{inp.label}</label>
|
||||
<label className="block text-sm font-medium text-indigo-400/80 dark:text-indigo-300/80 mb-1.5">
|
||||
{inp.label}
|
||||
</label>
|
||||
{renderField(inp)}
|
||||
{imagePicker === inp.key && (
|
||||
<ImagePickerGrid
|
||||
@@ -238,14 +247,16 @@ export default function DynamicWorkflowForm({ onGenerate, lastSeed, generating,
|
||||
|
||||
{/* Advanced inputs */}
|
||||
{inputsData.advanced.length > 0 && (
|
||||
<details className="border border-gray-200 dark:border-gray-700 rounded">
|
||||
<summary className="px-3 py-2 text-sm font-medium cursor-pointer select-none text-gray-700 dark:text-gray-300">
|
||||
<details className="border border-white/10 dark:border-white/5 rounded-2xl">
|
||||
<summary className="px-3 py-2 text-sm font-medium cursor-pointer select-none text-gray-600 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-200">
|
||||
Advanced ({inputsData.advanced.length} inputs)
|
||||
</summary>
|
||||
<div className="px-3 pb-3 space-y-3 mt-2">
|
||||
{inputsData.advanced.map(inp => (
|
||||
<div key={inp.key}>
|
||||
<label className="block text-xs font-medium text-gray-600 dark:text-gray-400 mb-1">{inp.label}</label>
|
||||
<label className="block text-xs font-medium text-gray-500 dark:text-gray-400 mb-1">
|
||||
{inp.label}
|
||||
</label>
|
||||
{renderField(inp)}
|
||||
{imagePicker === inp.key && (
|
||||
<ImagePickerGrid
|
||||
@@ -268,14 +279,14 @@ export default function DynamicWorkflowForm({ onGenerate, lastSeed, generating,
|
||||
max={20}
|
||||
value={count}
|
||||
onChange={e => setCount(Math.max(1, Math.min(20, Number(e.target.value))))}
|
||||
className="w-16 border border-gray-300 dark:border-gray-600 rounded px-2 py-2 text-sm text-center bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 focus:outline-none focus:ring-1 focus:ring-blue-500"
|
||||
className="glass-input w-16 text-center"
|
||||
title="Number of generations to queue"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleGenerate}
|
||||
disabled={generating}
|
||||
className="flex-1 bg-blue-600 hover:bg-blue-700 disabled:opacity-50 text-white rounded px-4 py-2 text-sm font-semibold transition-colors"
|
||||
className="btn-primary flex-1"
|
||||
>
|
||||
{generating ? '⏳ Generating…' : count > 1 ? `Generate ×${count}` : 'Generate'}
|
||||
</button>
|
||||
@@ -296,21 +307,23 @@ function ImagePickerGrid({
|
||||
onClose: () => void
|
||||
}) {
|
||||
return (
|
||||
<div className="mt-2 border border-gray-300 dark:border-gray-600 rounded p-2 bg-gray-50 dark:bg-gray-800">
|
||||
<div className="mt-2 glass-card p-3">
|
||||
<div className="flex justify-between items-center mb-2">
|
||||
<span className="text-xs text-gray-500 dark:text-gray-400">Select image for slot: {slotKey}</span>
|
||||
<button onClick={onClose} className="text-xs text-gray-400 hover:text-gray-600 dark:hover:text-gray-200">✕</button>
|
||||
<button onClick={onClose} className="text-gray-400 hover:text-gray-300 transition-colors">
|
||||
<X size={14} />
|
||||
</button>
|
||||
</div>
|
||||
{images.length === 0 ? (
|
||||
<p className="text-xs text-gray-400">No images available. Upload some first.</p>
|
||||
) : (
|
||||
<div className="grid grid-cols-3 sm:grid-cols-4 gap-1 max-h-48 overflow-y-auto">
|
||||
<div className="grid grid-cols-3 sm:grid-cols-4 gap-1.5 max-h-48 overflow-y-auto">
|
||||
{images.map(img => (
|
||||
<button
|
||||
key={img.id}
|
||||
type="button"
|
||||
onClick={() => onPick(img.id, slotKey)}
|
||||
className="relative aspect-square overflow-hidden rounded border border-gray-200 dark:border-gray-600 hover:border-blue-500"
|
||||
className="relative aspect-square overflow-hidden rounded-xl border border-white/10 hover:border-indigo-400 hover:shadow-[0_0_8px_rgba(99,102,241,0.4)] transition-all"
|
||||
title={img.filename}
|
||||
>
|
||||
<LazyImage
|
||||
|
||||
183
frontend/src/components/FaceIdentifyModal.tsx
Normal file
183
frontend/src/components/FaceIdentifyModal.tsx
Normal file
@@ -0,0 +1,183 @@
|
||||
import React, { useCallback, useEffect, useState } from 'react'
|
||||
import { checkPersonName, faceCropUrl, identifyFaces, PendingFace } from '../api/client'
|
||||
|
||||
interface FaceState {
|
||||
name: string
|
||||
nameConflicts: boolean
|
||||
useExisting: boolean
|
||||
}
|
||||
|
||||
interface Props {
|
||||
pendingFaces: PendingFace[]
|
||||
inputId: number
|
||||
onDone: () => void
|
||||
}
|
||||
|
||||
export default function FaceIdentifyModal({ pendingFaces, onDone }: Props) {
|
||||
const [faces, setFaces] = useState<FaceState[]>(() =>
|
||||
pendingFaces.map(() => ({ name: '', nameConflicts: false, useExisting: false }))
|
||||
)
|
||||
const [submitting, setSubmitting] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
// Debounced name-conflict check per face index
|
||||
useEffect(() => {
|
||||
const timers: ReturnType<typeof setTimeout>[] = []
|
||||
faces.forEach((f, i) => {
|
||||
const name = f.name.trim()
|
||||
if (!name) {
|
||||
setFaces(prev => {
|
||||
const next = [...prev]
|
||||
next[i] = { ...next[i], nameConflicts: false, useExisting: false }
|
||||
return next
|
||||
})
|
||||
return
|
||||
}
|
||||
const t = setTimeout(async () => {
|
||||
try {
|
||||
const { exists } = await checkPersonName(name)
|
||||
setFaces(prev => {
|
||||
const next = [...prev]
|
||||
// If name changed while we were waiting, discard stale result
|
||||
if (prev[i].name.trim() !== name) return prev
|
||||
next[i] = { ...next[i], nameConflicts: exists, useExisting: exists ? prev[i].useExisting : false }
|
||||
return next
|
||||
})
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}, 300)
|
||||
timers.push(t)
|
||||
})
|
||||
return () => timers.forEach(clearTimeout)
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [faces.map(f => f.name).join('|')])
|
||||
|
||||
const setName = useCallback((i: number, name: string) => {
|
||||
setFaces(prev => {
|
||||
const next = [...prev]
|
||||
next[i] = { ...next[i], name, useExisting: false }
|
||||
return next
|
||||
})
|
||||
}, [])
|
||||
|
||||
const setUseExisting = useCallback((i: number, val: boolean) => {
|
||||
setFaces(prev => {
|
||||
const next = [...prev]
|
||||
next[i] = { ...next[i], useExisting: val }
|
||||
return next
|
||||
})
|
||||
}, [])
|
||||
|
||||
const canSubmit = faces.every(f => !f.nameConflicts || f.useExisting) && !submitting
|
||||
|
||||
const handleSubmit = async () => {
|
||||
setSubmitting(true)
|
||||
setError(null)
|
||||
try {
|
||||
const identifications = faces
|
||||
.map((f, i) => ({
|
||||
detection_id: pendingFaces[i].detection_id,
|
||||
name: f.name.trim(),
|
||||
use_existing: f.useExisting,
|
||||
}))
|
||||
.filter(item => item.name.length > 0)
|
||||
|
||||
if (identifications.length > 0) {
|
||||
await identifyFaces(identifications)
|
||||
}
|
||||
onDone()
|
||||
} catch (e: unknown) {
|
||||
setError(e instanceof Error ? e.message : 'Failed to save identifications')
|
||||
setSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 z-50 bg-black/80 flex items-center justify-center p-4"
|
||||
onClick={e => { if (e.target === e.currentTarget) onDone() }}
|
||||
>
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-xl w-full max-w-md">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between px-4 py-3 border-b border-gray-200 dark:border-gray-700">
|
||||
<h2 className="text-base font-semibold text-gray-800 dark:text-gray-100">
|
||||
Identify Faces
|
||||
</h2>
|
||||
<button
|
||||
onClick={onDone}
|
||||
className="text-gray-400 hover:text-gray-600 dark:hover:text-gray-200 text-xl leading-none"
|
||||
aria-label="Close"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Face rows */}
|
||||
<div className="p-4 space-y-4 max-h-[60vh] overflow-y-auto">
|
||||
{pendingFaces.map((face, i) => {
|
||||
const fState = faces[i]
|
||||
return (
|
||||
<div key={face.detection_id} className="flex items-start gap-3">
|
||||
{/* Face crop */}
|
||||
<img
|
||||
src={faceCropUrl(face.detection_id)}
|
||||
alt={`Face ${i + 1}`}
|
||||
className="w-14 h-14 rounded object-cover flex-shrink-0 bg-gray-200 dark:bg-gray-700"
|
||||
/>
|
||||
{/* Name input */}
|
||||
<div className="flex-1 space-y-1">
|
||||
<input
|
||||
type="text"
|
||||
maxLength={100}
|
||||
placeholder={`Face ${i + 1} name`}
|
||||
value={fState.name}
|
||||
onChange={e => setName(i, e.target.value)}
|
||||
className="w-full text-sm rounded border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-800 dark:text-gray-100 px-2 py-1 focus:outline-none focus:ring-1 focus:ring-blue-500"
|
||||
/>
|
||||
{fState.nameConflicts && (
|
||||
<div className="space-y-1">
|
||||
<p className="text-xs text-amber-600 dark:text-amber-400">
|
||||
⚠ “{fState.name.trim()}” already exists
|
||||
</p>
|
||||
<label className="flex items-center gap-1.5 text-xs text-gray-600 dark:text-gray-300 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={fState.useExisting}
|
||||
onChange={e => setUseExisting(i, e.target.checked)}
|
||||
className="accent-blue-600"
|
||||
/>
|
||||
Use existing “{fState.name.trim()}”
|
||||
</label>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<p className="px-4 text-xs text-red-500 dark:text-red-400">{error}</p>
|
||||
)}
|
||||
|
||||
{/* Footer */}
|
||||
<div className="flex items-center justify-end gap-2 px-4 py-3 border-t border-gray-200 dark:border-gray-700">
|
||||
<button
|
||||
onClick={onDone}
|
||||
className="text-sm px-3 py-1.5 rounded border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700"
|
||||
>
|
||||
Skip
|
||||
</button>
|
||||
<button
|
||||
onClick={handleSubmit}
|
||||
disabled={!canSubmit}
|
||||
className="text-sm px-3 py-1.5 rounded bg-blue-600 hover:bg-blue-700 disabled:opacity-40 text-white"
|
||||
>
|
||||
{submitting ? 'Saving…' : 'Save Identifications'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
21
frontend/src/components/GlassCard.tsx
Normal file
21
frontend/src/components/GlassCard.tsx
Normal file
@@ -0,0 +1,21 @@
|
||||
import React from 'react'
|
||||
|
||||
export function GlassCard({
|
||||
children,
|
||||
className = '',
|
||||
padding = 'p-4',
|
||||
}: {
|
||||
children: React.ReactNode
|
||||
className?: string
|
||||
padding?: string
|
||||
}) {
|
||||
return <div className={`glass-card ${padding} ${className}`}>{children}</div>
|
||||
}
|
||||
|
||||
export function CardTitle({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<p className="text-xs font-semibold uppercase tracking-widest text-indigo-400/80 dark:text-indigo-300/80 mb-3">
|
||||
{children}
|
||||
</p>
|
||||
)
|
||||
}
|
||||
@@ -1,18 +1,23 @@
|
||||
import React, { useEffect, useRef, useState, useCallback } from 'react'
|
||||
import { NavLink, Outlet, useLocation } from 'react-router-dom'
|
||||
import { useQueryClient } from '@tanstack/react-query'
|
||||
import { AnimatePresence, motion } from 'framer-motion'
|
||||
import {
|
||||
Sparkles, Images, GitBranch, BookMarked, Activity, Server,
|
||||
Clock, Users, Shield, Sun, Moon, LogOut, Menu,
|
||||
} from 'lucide-react'
|
||||
import { useAuth } from '../hooks/useAuth'
|
||||
import { useStatus } from '../hooks/useStatus'
|
||||
import { useGeneration } from '../context/GenerationContext'
|
||||
|
||||
const navItems = [
|
||||
{ to: '/generate', label: 'Generate' },
|
||||
{ to: '/inputs', label: 'Input Images' },
|
||||
{ to: '/workflow', label: 'Workflow' },
|
||||
{ to: '/presets', label: 'Presets' },
|
||||
{ to: '/status', label: 'Status' },
|
||||
{ to: '/server', label: 'Server' },
|
||||
{ to: '/history', label: 'History' },
|
||||
{ to: '/generate', label: 'Generate', icon: Sparkles },
|
||||
{ to: '/inputs', label: 'Input Images', icon: Images },
|
||||
{ to: '/workflow', label: 'Workflow', icon: GitBranch },
|
||||
{ to: '/presets', label: 'Presets', icon: BookMarked },
|
||||
{ to: '/status', label: 'Status', icon: Activity },
|
||||
{ to: '/server', label: 'Server', icon: Server },
|
||||
{ to: '/history', label: 'History', icon: Clock },
|
||||
]
|
||||
|
||||
export default function Layout() {
|
||||
@@ -26,13 +31,11 @@ export default function Layout() {
|
||||
return window.matchMedia('(prefers-color-scheme: dark)').matches
|
||||
})
|
||||
|
||||
// Apply dark class on mount and changes
|
||||
useEffect(() => {
|
||||
document.documentElement.classList.toggle('dark', dark)
|
||||
localStorage.setItem('dark-mode', String(dark))
|
||||
}, [dark])
|
||||
|
||||
// Auto-close sidebar on navigation
|
||||
useEffect(() => {
|
||||
setSidebarOpen(false)
|
||||
}, [location.pathname])
|
||||
@@ -67,79 +70,182 @@ export default function Layout() {
|
||||
|
||||
const toggleDark = () => setDark(d => !d)
|
||||
|
||||
return (
|
||||
<div className="flex h-screen overflow-hidden">
|
||||
{/* Mobile backdrop */}
|
||||
{sidebarOpen && (
|
||||
<div
|
||||
className="fixed inset-0 bg-black/40 z-30 md:hidden"
|
||||
onClick={() => setSidebarOpen(false)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Sidebar */}
|
||||
<aside
|
||||
className={`fixed md:static inset-y-0 left-0 z-40 w-48 flex-none bg-gray-800 text-gray-100 flex flex-col transition-transform duration-200 ${
|
||||
sidebarOpen ? 'translate-x-0' : '-translate-x-full md:translate-x-0'
|
||||
}`}
|
||||
>
|
||||
<div className="p-4 font-bold text-lg border-b border-gray-700 flex items-center gap-2">
|
||||
<span>ComfyUI Bot</span>
|
||||
const sidebarContent = (
|
||||
<>
|
||||
{/* Header */}
|
||||
<div className="px-4 py-4 border-b border-white/10 dark:border-white/5 flex items-center gap-2.5">
|
||||
<span className="font-bold text-base bg-gradient-to-r from-indigo-400 to-violet-400 bg-clip-text text-transparent">
|
||||
ComfyUI Bot
|
||||
</span>
|
||||
<span
|
||||
title={comfyReachable == null ? 'Connecting…' : comfyReachable ? 'ComfyUI reachable' : 'ComfyUI unreachable'}
|
||||
className={`ml-auto w-2 h-2 rounded-full flex-none ${comfyReachable == null ? 'bg-gray-500' : comfyReachable ? 'bg-green-400' : 'bg-red-400'}`}
|
||||
className={`ml-auto w-2 h-2 rounded-full flex-none ${
|
||||
comfyReachable == null ? 'bg-gray-400' : comfyReachable ? 'bg-green-400' : 'bg-red-400'
|
||||
}`}
|
||||
/>
|
||||
</div>
|
||||
<nav className="flex-1 overflow-y-auto py-2">
|
||||
{navItems.map(({ to, label }) => (
|
||||
<NavLink
|
||||
|
||||
{/* Nav */}
|
||||
<nav className="flex-1 overflow-y-auto py-2 px-0 space-y-0.5">
|
||||
{navItems.map(({ to, label, icon: Icon }) => (
|
||||
<motion.div
|
||||
key={to}
|
||||
whileHover={{ x: 6 }}
|
||||
transition={{ type: 'spring', stiffness: 400, damping: 25 }}
|
||||
>
|
||||
<NavLink
|
||||
to={to}
|
||||
className={({ isActive }) =>
|
||||
`flex items-center px-4 py-2 text-sm hover:bg-gray-700 transition-colors ${isActive ? 'bg-gray-700 font-medium' : ''}`
|
||||
`relative flex items-center gap-2.5 px-3 py-2.5 text-sm rounded-xl mx-2 transition-colors ${
|
||||
isActive
|
||||
? 'bg-indigo-500/20 text-indigo-100 font-medium ring-1 ring-inset ring-indigo-400/30 shadow-[0_0_12px_rgba(99,102,241,0.2)]'
|
||||
: 'text-gray-300 hover:text-white hover:bg-white/5'
|
||||
}`
|
||||
}
|
||||
>
|
||||
{({ isActive }) => (
|
||||
<>
|
||||
{isActive && (
|
||||
<span className="absolute left-0 top-1/2 -translate-y-1/2 w-0.5 h-4 bg-indigo-400 rounded-full" />
|
||||
)}
|
||||
<Icon size={15} className="flex-none" />
|
||||
<span className="flex-1">{label}</span>
|
||||
{to === '/generate' && pendingCount > 0 && (
|
||||
<span className="ml-2 text-xs bg-yellow-400 text-gray-900 rounded-full px-1.5 py-0.5 leading-none">
|
||||
<span className="ml-auto text-[10px] bg-yellow-400 text-gray-900 rounded-full px-1.5 py-0.5 leading-none font-semibold">
|
||||
{pendingCount}
|
||||
</span>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</NavLink>
|
||||
</motion.div>
|
||||
))}
|
||||
{isAdmin && (
|
||||
<motion.div whileHover={{ x: 6 }} transition={{ type: 'spring', stiffness: 400, damping: 25 }}>
|
||||
<NavLink
|
||||
to="/faces"
|
||||
className={({ isActive }) =>
|
||||
`relative flex items-center gap-2.5 px-3 py-2.5 text-sm rounded-xl mx-2 transition-colors ${
|
||||
isActive
|
||||
? 'bg-indigo-500/20 text-indigo-100 font-medium ring-1 ring-inset ring-indigo-400/30 shadow-[0_0_12px_rgba(99,102,241,0.2)]'
|
||||
: 'text-gray-300 hover:text-white hover:bg-white/5'
|
||||
}`
|
||||
}
|
||||
>
|
||||
{({ isActive }) => (
|
||||
<>
|
||||
{isActive && <span className="absolute left-0 top-1/2 -translate-y-1/2 w-0.5 h-4 bg-indigo-400 rounded-full" />}
|
||||
<Users size={15} className="flex-none" />
|
||||
<span className="flex-1">Faces</span>
|
||||
</>
|
||||
)}
|
||||
</NavLink>
|
||||
</motion.div>
|
||||
)}
|
||||
{isAdmin && (
|
||||
<motion.div whileHover={{ x: 6 }} transition={{ type: 'spring', stiffness: 400, damping: 25 }}>
|
||||
<NavLink
|
||||
to="/admin"
|
||||
className={({ isActive }) =>
|
||||
`block px-4 py-2 text-sm hover:bg-gray-700 transition-colors ${isActive ? 'bg-gray-700 font-medium' : ''}`
|
||||
`relative flex items-center gap-2.5 px-3 py-2.5 text-sm rounded-xl mx-2 transition-colors ${
|
||||
isActive
|
||||
? 'bg-indigo-500/20 text-indigo-100 font-medium ring-1 ring-inset ring-indigo-400/30 shadow-[0_0_12px_rgba(99,102,241,0.2)]'
|
||||
: 'text-gray-300 hover:text-white hover:bg-white/5'
|
||||
}`
|
||||
}
|
||||
>
|
||||
Admin
|
||||
{({ isActive }) => (
|
||||
<>
|
||||
{isActive && <span className="absolute left-0 top-1/2 -translate-y-1/2 w-0.5 h-4 bg-indigo-400 rounded-full" />}
|
||||
<Shield size={15} className="flex-none" />
|
||||
<span className="flex-1">Admin</span>
|
||||
</>
|
||||
)}
|
||||
</NavLink>
|
||||
</motion.div>
|
||||
)}
|
||||
</nav>
|
||||
<div className="p-3 border-t border-gray-700 text-xs flex items-center gap-2">
|
||||
<span className="flex-1 truncate">{user?.label ?? '...'}</span>
|
||||
<button onClick={toggleDark} className="hover:text-yellow-300" title="Toggle dark mode">
|
||||
{dark ? '☀' : '🌙'}
|
||||
|
||||
{/* Footer */}
|
||||
<div className="px-3 py-3 border-t border-white/10 dark:border-white/5 flex items-center gap-2">
|
||||
<span className="flex-1 truncate text-xs text-gray-400">{user?.label ?? '...'}</span>
|
||||
<button
|
||||
onClick={toggleDark}
|
||||
className="p-1.5 rounded-lg hover:bg-white/10 transition-colors text-gray-400 hover:text-yellow-300"
|
||||
title="Toggle dark mode"
|
||||
>
|
||||
{dark ? <Sun size={14} /> : <Moon size={14} />}
|
||||
</button>
|
||||
<button onClick={logout} className="hover:text-red-400" title="Logout">
|
||||
⏏
|
||||
<button
|
||||
onClick={logout}
|
||||
className="p-1.5 rounded-lg hover:bg-white/10 transition-colors text-gray-400 hover:text-red-400"
|
||||
title="Logout"
|
||||
>
|
||||
<LogOut size={14} />
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
|
||||
return (
|
||||
<div className="flex h-screen overflow-hidden">
|
||||
{/* Mobile backdrop */}
|
||||
<AnimatePresence>
|
||||
{sidebarOpen && (
|
||||
<motion.div
|
||||
key="backdrop"
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
className="fixed inset-0 bg-black/60 backdrop-blur-sm z-30 md:hidden"
|
||||
onClick={() => setSidebarOpen(false)}
|
||||
/>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
{/* Sidebar — mobile (animated) */}
|
||||
<AnimatePresence>
|
||||
{sidebarOpen && (
|
||||
<motion.aside
|
||||
key="mobile-sidebar"
|
||||
initial={{ x: -192 }}
|
||||
animate={{ x: 0 }}
|
||||
exit={{ x: -192 }}
|
||||
transition={{ type: 'spring', stiffness: 300, damping: 30 }}
|
||||
className="fixed inset-y-0 left-0 z-40 w-48 flex-none glass border-r border-white/10 dark:border-white/5 flex flex-col md:hidden"
|
||||
>
|
||||
{sidebarContent}
|
||||
</motion.aside>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
{/* Sidebar — desktop (static) */}
|
||||
<aside className="hidden md:flex w-48 flex-none glass border-r border-white/10 dark:border-white/5 flex-col">
|
||||
{sidebarContent}
|
||||
</aside>
|
||||
|
||||
{/* Main */}
|
||||
<main className="flex-1 overflow-y-auto p-3 sm:p-6 bg-gray-50 dark:bg-gray-900">
|
||||
{/* Hamburger button — mobile only */}
|
||||
<main className="flex-1 overflow-y-auto p-3 sm:p-6">
|
||||
{/* Hamburger — mobile only */}
|
||||
<button
|
||||
className="md:hidden mb-3 p-1.5 rounded bg-gray-200 dark:bg-gray-700 text-gray-700 dark:text-gray-300 hover:bg-gray-300 dark:hover:bg-gray-600"
|
||||
className="md:hidden mb-3 glass rounded-lg p-1.5 text-gray-700 dark:text-gray-300"
|
||||
onClick={() => setSidebarOpen(true)}
|
||||
aria-label="Open menu"
|
||||
>
|
||||
☰
|
||||
<Menu size={18} />
|
||||
</button>
|
||||
|
||||
<AnimatePresence mode="wait">
|
||||
<motion.div
|
||||
key={location.pathname}
|
||||
initial={{ opacity: 0, y: 8 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -4 }}
|
||||
transition={{ duration: 0.2, ease: 'easeOut' }}
|
||||
>
|
||||
<Outlet />
|
||||
</motion.div>
|
||||
</AnimatePresence>
|
||||
</main>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -13,7 +13,7 @@ export interface StatusSnapshot {
|
||||
total_generated: number
|
||||
}
|
||||
overrides?: Record<string, unknown>
|
||||
service?: { state: string }
|
||||
service?: { state: string; http_reachable?: boolean }
|
||||
upload?: { configured: boolean; running: boolean; total_ok: number; total_fail: number }
|
||||
}
|
||||
|
||||
@@ -39,11 +39,17 @@ export function useStatus({
|
||||
}: UseStatusOptions) {
|
||||
const [status, setStatus] = useState<StatusSnapshot>({})
|
||||
const [executingNode, setExecutingNode] = useState<string | null>(null)
|
||||
const [lastUpdatedAt, setLastUpdatedAt] = useState<number | null>(null)
|
||||
|
||||
const handleMessage = useCallback(
|
||||
(msg: { type: string; data: unknown; ts: number }) => {
|
||||
if (msg.type === 'status_snapshot') {
|
||||
setStatus(msg.data as StatusSnapshot)
|
||||
setStatus(prev => {
|
||||
const snap = msg.data as StatusSnapshot
|
||||
// Preserve service (set by server_state events) since status_snapshot never includes it
|
||||
return { ...snap, service: snap.service ?? prev.service }
|
||||
})
|
||||
setLastUpdatedAt(Date.now())
|
||||
} else if (msg.type === 'node_executing') {
|
||||
const d = msg.data as { node: string; prompt_id: string }
|
||||
setExecutingNode(d.node)
|
||||
@@ -58,14 +64,14 @@ export function useStatus({
|
||||
const d = msg.data as { state: string; http_reachable: boolean }
|
||||
setStatus(prev => ({
|
||||
...prev,
|
||||
service: { state: d.state },
|
||||
service: { state: d.state, http_reachable: d.http_reachable },
|
||||
}))
|
||||
}
|
||||
},
|
||||
[onGenerationComplete, onGenerationError, onNodeExecuting],
|
||||
)
|
||||
|
||||
useWebSocket({ onMessage: handleMessage, enabled })
|
||||
const { connected } = useWebSocket({ onMessage: handleMessage, enabled })
|
||||
|
||||
return { status, executingNode }
|
||||
return { status, executingNode, connected, lastUpdatedAt }
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useRef, useCallback } from 'react'
|
||||
import { useState, useEffect, useRef, useCallback } from 'react'
|
||||
|
||||
interface WSOptions {
|
||||
onMessage: (data: { type: string; data: unknown; ts: number }) => void
|
||||
@@ -13,6 +13,8 @@ export function useWebSocket({ onMessage, enabled = true }: WSOptions) {
|
||||
const onMessageRef = useRef(onMessage)
|
||||
onMessageRef.current = onMessage
|
||||
|
||||
const [connected, setConnected] = useState(false)
|
||||
|
||||
const connect = useCallback(() => {
|
||||
if (!enabled) return
|
||||
const proto = window.location.protocol === 'https:' ? 'wss' : 'ws'
|
||||
@@ -31,9 +33,11 @@ export function useWebSocket({ onMessage, enabled = true }: WSOptions) {
|
||||
|
||||
ws.onopen = () => {
|
||||
backoffRef.current = 1000
|
||||
setConnected(true)
|
||||
}
|
||||
|
||||
ws.onclose = () => {
|
||||
setConnected(false)
|
||||
if (enabled) {
|
||||
timerRef.current = setTimeout(() => {
|
||||
backoffRef.current = Math.min(backoffRef.current * 2, 30_000)
|
||||
@@ -52,4 +56,6 @@ export function useWebSocket({ onMessage, enabled = true }: WSOptions) {
|
||||
if (timerRef.current) clearTimeout(timerRef.current)
|
||||
}
|
||||
}, [connect, enabled])
|
||||
|
||||
return { connected }
|
||||
}
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap');
|
||||
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
@@ -6,7 +8,44 @@
|
||||
color-scheme: light dark;
|
||||
}
|
||||
|
||||
body {
|
||||
@apply bg-gray-50 text-gray-900 dark:bg-gray-900 dark:text-gray-100;
|
||||
font-family: system-ui, -apple-system, sans-serif;
|
||||
@keyframes gradient-drift {
|
||||
0%, 100% { background-position: 0% 50%; }
|
||||
50% { background-position: 100% 50%; }
|
||||
}
|
||||
|
||||
/* Light mode background */
|
||||
body {
|
||||
background: linear-gradient(-45deg, #e0e7ff, #ede9fe, #f1f5f9, #ddd6fe);
|
||||
background-size: 400% 400%;
|
||||
animation: gradient-drift 18s ease infinite;
|
||||
font-family: 'Inter', system-ui, -apple-system, sans-serif;
|
||||
@apply text-gray-900 dark:text-gray-100;
|
||||
}
|
||||
|
||||
/* Dark mode background */
|
||||
html.dark body {
|
||||
background: linear-gradient(-45deg, #1e1b4b, #3b0764, #0f172a, #1e1b4b);
|
||||
background-size: 400% 400%;
|
||||
animation: gradient-drift 18s ease infinite;
|
||||
}
|
||||
|
||||
@layer components {
|
||||
.glass {
|
||||
@apply bg-white/60 dark:bg-white/5 backdrop-blur-xl border border-white/80 dark:border-white/10 shadow-xl;
|
||||
}
|
||||
.glass-card {
|
||||
@apply glass rounded-2xl;
|
||||
}
|
||||
.glass-input {
|
||||
@apply bg-white/70 dark:bg-white/5 backdrop-blur-sm border border-white/80 dark:border-white/10 rounded-xl px-3 py-2 text-sm text-gray-900 dark:text-gray-100 focus:outline-none focus:ring-2 focus:ring-indigo-400/50;
|
||||
}
|
||||
.btn-primary {
|
||||
@apply bg-indigo-600 hover:bg-indigo-500 text-white rounded-xl px-4 py-2 text-sm font-semibold transition-all duration-200 shadow-md shadow-indigo-500/20 hover:shadow-indigo-500/40 hover:scale-[1.02] disabled:opacity-50 disabled:cursor-not-allowed disabled:hover:scale-100;
|
||||
}
|
||||
.btn-secondary {
|
||||
@apply bg-white/20 dark:bg-white/5 hover:bg-white/30 dark:hover:bg-white/10 border border-white/30 dark:border-white/10 rounded-xl px-3 py-2 text-sm font-medium transition-all duration-200;
|
||||
}
|
||||
.btn-danger {
|
||||
@apply bg-red-600/80 hover:bg-red-600 text-white rounded-xl px-3 py-1.5 text-sm font-medium transition-all duration-200;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -33,51 +33,57 @@ export default function AdminPage() {
|
||||
|
||||
return (
|
||||
<div className="max-w-xl mx-auto space-y-6">
|
||||
<h1 className="text-xl font-bold text-gray-800 dark:text-gray-100">Admin — Token Management</h1>
|
||||
<h1 className="text-2xl font-bold tracking-tight">
|
||||
<span className="bg-gradient-to-r from-indigo-400 to-violet-400 bg-clip-text text-transparent">
|
||||
Admin
|
||||
</span>
|
||||
</h1>
|
||||
|
||||
{/* Create token */}
|
||||
<div className="bg-white dark:bg-gray-800 rounded border border-gray-200 dark:border-gray-700 p-4 space-y-3">
|
||||
<p className="text-sm font-medium text-gray-700 dark:text-gray-300">Create invite token</p>
|
||||
<div className="glass-card p-4 space-y-3">
|
||||
<p className="text-xs font-semibold uppercase tracking-widest text-indigo-400/80 dark:text-indigo-300/80">
|
||||
Create invite token
|
||||
</p>
|
||||
<div className="flex flex-col sm:flex-row gap-2">
|
||||
<input
|
||||
type="text"
|
||||
value={label}
|
||||
onChange={e => setLabel(e.target.value)}
|
||||
placeholder="Label (e.g. alice)"
|
||||
className="flex-1 border border-gray-300 dark:border-gray-600 rounded px-3 py-2 text-sm bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
className="glass-input flex-1"
|
||||
/>
|
||||
<label className="flex items-center gap-1 text-sm text-gray-600 dark:text-gray-400 cursor-pointer">
|
||||
<label className="flex items-center gap-1.5 text-sm text-gray-600 dark:text-gray-400 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={isAdmin}
|
||||
onChange={e => setIsAdmin(e.target.checked)}
|
||||
className="rounded"
|
||||
className="rounded accent-indigo-500"
|
||||
/>
|
||||
Admin
|
||||
</label>
|
||||
<button
|
||||
onClick={() => createMut.mutate({ label, admin: isAdmin })}
|
||||
disabled={!label.trim() || createMut.isPending}
|
||||
className="bg-blue-600 hover:bg-blue-700 disabled:opacity-50 text-white rounded px-3 py-2 text-sm font-medium"
|
||||
className="btn-primary"
|
||||
>
|
||||
Create
|
||||
</button>
|
||||
</div>
|
||||
{createError && <p className="text-red-500 text-sm">{createError}</p>}
|
||||
{createError && <p className="text-red-400 text-sm">{createError}</p>}
|
||||
</div>
|
||||
|
||||
{/* New token display — one-time */}
|
||||
{newToken && (
|
||||
<div className="bg-yellow-50 dark:bg-yellow-900/30 border border-yellow-300 dark:border-yellow-700 rounded p-4 space-y-2">
|
||||
<p className="text-sm font-medium text-yellow-800 dark:text-yellow-200">
|
||||
<div className="glass border-amber-400/30 rounded-2xl p-4 space-y-2">
|
||||
<p className="text-sm font-medium text-amber-300">
|
||||
New token (copy now — shown only once):
|
||||
</p>
|
||||
<code className="block text-xs break-all bg-yellow-100 dark:bg-yellow-900/50 rounded p-2 text-yellow-900 dark:text-yellow-100 select-all">
|
||||
<code className="block text-xs break-all bg-white/5 rounded-xl p-2 text-amber-200 select-all">
|
||||
{newToken}
|
||||
</code>
|
||||
<button
|
||||
onClick={() => setNewToken(null)}
|
||||
className="text-xs text-yellow-600 dark:text-yellow-400 hover:underline"
|
||||
className="text-xs text-amber-400/70 hover:text-amber-300 transition-colors"
|
||||
>
|
||||
Dismiss
|
||||
</button>
|
||||
@@ -90,17 +96,21 @@ export default function AdminPage() {
|
||||
) : tokens.length === 0 ? (
|
||||
<p className="text-sm text-gray-400">No tokens yet.</p>
|
||||
) : (
|
||||
<div className="divide-y divide-gray-200 dark:divide-gray-700 border border-gray-200 dark:border-gray-700 rounded">
|
||||
<div className="space-y-2">
|
||||
{tokens.map(t => (
|
||||
<div key={t.id} className="flex items-center justify-between px-3 py-2 text-sm">
|
||||
<div key={t.id} className="glass-card p-3 flex items-center justify-between text-sm">
|
||||
<div>
|
||||
<span className="font-medium text-gray-700 dark:text-gray-300">{t.label}</span>
|
||||
{t.admin && <span className="ml-1 text-xs text-purple-600 dark:text-purple-400">(admin)</span>}
|
||||
<span className="ml-2 text-xs text-gray-400">{new Date(t.created_at).toLocaleDateString()}</span>
|
||||
{t.admin && (
|
||||
<span className="ml-1.5 text-xs text-indigo-400 dark:text-indigo-300">(admin)</span>
|
||||
)}
|
||||
<span className="ml-2 text-xs text-gray-400">
|
||||
{new Date(t.created_at).toLocaleDateString()}
|
||||
</span>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => revokeMut.mutate(t.id)}
|
||||
className="text-xs text-red-500 hover:text-red-700 dark:hover:text-red-400"
|
||||
className="btn-danger"
|
||||
>
|
||||
Revoke
|
||||
</button>
|
||||
|
||||
1240
frontend/src/pages/FacesPage.tsx
Normal file
1240
frontend/src/pages/FacesPage.tsx
Normal file
File diff suppressed because it is too large
Load Diff
@@ -5,6 +5,7 @@ import { useStatus, GenerationResult } from '../hooks/useStatus'
|
||||
import { useAuth } from '../hooks/useAuth'
|
||||
import { useGeneration } from '../context/GenerationContext'
|
||||
import DynamicWorkflowForm from '../components/DynamicWorkflowForm'
|
||||
import { X } from 'lucide-react'
|
||||
|
||||
interface Notification {
|
||||
id: number
|
||||
@@ -114,27 +115,39 @@ export default function GeneratePage() {
|
||||
const queueRunning = (status.comfy?.queue_running ?? 0)
|
||||
|
||||
return (
|
||||
<div className="max-w-2xl mx-auto space-y-6">
|
||||
<div className="max-w-2xl mx-auto space-y-5">
|
||||
<div className="flex items-center justify-between">
|
||||
<h1 className="text-xl font-bold text-gray-800 dark:text-gray-100">Generate</h1>
|
||||
<div className="text-xs text-gray-400">
|
||||
ComfyUI: {queueRunning} running, {queuePending} pending
|
||||
<h1 className="text-2xl font-bold tracking-tight">
|
||||
<span className="bg-gradient-to-r from-indigo-400 to-violet-400 bg-clip-text text-transparent">
|
||||
Generate
|
||||
</span>
|
||||
</h1>
|
||||
<div className="text-xs text-gray-400 dark:text-gray-500">
|
||||
{queueRunning} running · {queuePending} pending
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Mode toggle */}
|
||||
<div className="flex flex-wrap gap-2 text-sm">
|
||||
<div className="glass-card p-1 flex gap-1 w-fit">
|
||||
<button
|
||||
onClick={() => setMode('workflow')}
|
||||
className={`px-3 py-1.5 rounded ${mode === 'workflow' ? 'bg-blue-600 text-white' : 'bg-gray-200 dark:bg-gray-700 text-gray-700 dark:text-gray-300'}`}
|
||||
className={`px-3 py-1.5 text-sm rounded-xl transition-all font-medium ${
|
||||
mode === 'workflow'
|
||||
? 'bg-indigo-600 text-white shadow-md shadow-indigo-500/20'
|
||||
: 'text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-200'
|
||||
}`}
|
||||
>
|
||||
Workflow mode
|
||||
Workflow
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setMode('prompt')}
|
||||
className={`px-3 py-1.5 rounded ${mode === 'prompt' ? 'bg-blue-600 text-white' : 'bg-gray-200 dark:bg-gray-700 text-gray-700 dark:text-gray-300'}`}
|
||||
className={`px-3 py-1.5 text-sm rounded-xl transition-all font-medium ${
|
||||
mode === 'prompt'
|
||||
? 'bg-indigo-600 text-white shadow-md shadow-indigo-500/20'
|
||||
: 'text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-200'
|
||||
}`}
|
||||
>
|
||||
Prompt mode
|
||||
Prompt
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -149,7 +162,7 @@ export default function GeneratePage() {
|
||||
e.target.value = ''
|
||||
if (v) handlePresetLoad(v)
|
||||
}}
|
||||
className="flex-1 border border-gray-300 dark:border-gray-600 rounded px-3 py-2 text-sm bg-white dark:bg-gray-700 text-gray-700 dark:text-gray-300 focus:outline-none focus:ring-2 focus:ring-blue-500 disabled:opacity-50"
|
||||
className="glass-input flex-1 disabled:opacity-50"
|
||||
>
|
||||
<option value="" disabled>Load a preset…</option>
|
||||
{(presetsData?.presets ?? []).map(p => (
|
||||
@@ -164,13 +177,14 @@ export default function GeneratePage() {
|
||||
|
||||
{/* Progress banner */}
|
||||
{(pendingCount > 0 || executingNodeDisplay) && (
|
||||
<div className="bg-blue-50 dark:bg-blue-900/30 border border-blue-200 dark:border-blue-700 rounded p-3 text-sm text-blue-700 dark:text-blue-300">
|
||||
<div className="glass border-indigo-400/30 rounded-2xl p-3 text-sm text-indigo-300">
|
||||
{pendingCount > 0 && `${pendingCount} generation(s) in progress`}
|
||||
{executingNodeDisplay && ` · running: ${executingNodeDisplay}`}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Form */}
|
||||
<div className="glass-card p-5">
|
||||
{mode === 'workflow' ? (
|
||||
<DynamicWorkflowForm
|
||||
onGenerate={handleWorkflowGenerate}
|
||||
@@ -181,20 +195,20 @@ export default function GeneratePage() {
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Prompt</label>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1.5">Prompt</label>
|
||||
<textarea
|
||||
rows={3}
|
||||
className="w-full border border-gray-300 dark:border-gray-600 rounded px-3 py-2 text-sm bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 resize-y focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
className="glass-input w-full resize-y"
|
||||
value={prompt}
|
||||
onChange={e => setPrompt(e.target.value)}
|
||||
placeholder="Describe what you want to generate"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Negative prompt</label>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1.5">Negative prompt</label>
|
||||
<textarea
|
||||
rows={2}
|
||||
className="w-full border border-gray-300 dark:border-gray-600 rounded px-3 py-2 text-sm bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 resize-y focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
className="glass-input w-full resize-y"
|
||||
value={negPrompt}
|
||||
onChange={e => setNegPrompt(e.target.value)}
|
||||
placeholder="What to avoid"
|
||||
@@ -207,31 +221,32 @@ export default function GeneratePage() {
|
||||
max={20}
|
||||
value={promptCount}
|
||||
onChange={e => setPromptCount(Math.max(1, Math.min(20, Number(e.target.value))))}
|
||||
className="w-16 border border-gray-300 dark:border-gray-600 rounded px-2 py-2 text-sm text-center bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 focus:outline-none focus:ring-1 focus:ring-blue-500"
|
||||
className="glass-input w-16 text-center"
|
||||
title="Number of generations to queue"
|
||||
/>
|
||||
<button
|
||||
onClick={handlePromptGenerate}
|
||||
disabled={generating || !prompt.trim()}
|
||||
className="flex-1 bg-blue-600 hover:bg-blue-700 disabled:opacity-50 text-white rounded px-4 py-2 text-sm font-semibold transition-colors"
|
||||
className="btn-primary flex-1"
|
||||
>
|
||||
{generating ? '⏳ Queuing…' : promptCount > 1 ? `Generate ×${promptCount}` : 'Generate'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Toast notification stack */}
|
||||
<div className="fixed bottom-4 right-4 z-50 space-y-2 max-w-sm">
|
||||
<div className="fixed bottom-4 right-4 z-50 space-y-2 max-w-sm w-full">
|
||||
{notifications.map(n => (
|
||||
<div
|
||||
key={n.id}
|
||||
className={`flex items-start gap-2 rounded border p-3 text-sm shadow-md ${
|
||||
className={`glass rounded-xl p-3 text-sm shadow-lg flex items-start gap-2 ${
|
||||
n.type === 'success'
|
||||
? 'bg-green-50 dark:bg-green-900/40 border-green-200 dark:border-green-700 text-green-700 dark:text-green-300'
|
||||
? 'border-green-400/30 text-green-300'
|
||||
: n.type === 'error'
|
||||
? 'bg-red-50 dark:bg-red-900/40 border-red-200 dark:border-red-700 text-red-700 dark:text-red-300'
|
||||
: 'bg-blue-50 dark:bg-blue-900/40 border-blue-200 dark:border-blue-700 text-blue-700 dark:text-blue-300'
|
||||
? 'border-red-400/30 text-red-300'
|
||||
: 'border-indigo-400/30 text-indigo-300'
|
||||
}`}
|
||||
>
|
||||
<span className="flex-1">{n.msg}</span>
|
||||
@@ -240,7 +255,7 @@ export default function GeneratePage() {
|
||||
className="flex-none opacity-60 hover:opacity-100 leading-none"
|
||||
aria-label="Dismiss"
|
||||
>
|
||||
✕
|
||||
<X size={14} />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import React, { useState, useEffect, useRef } from 'react'
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { getHistory, createHistoryShare, revokeHistoryShare, savePresetFromHistory } from '../api/client'
|
||||
import { getHistory, getGenerationPersons, addGenerationPerson, removeGenerationPerson, createHistoryShare, revokeHistoryShare, savePresetFromHistory, listPersons } from '../api/client'
|
||||
import { useAuth } from '../hooks/useAuth'
|
||||
import { X } from 'lucide-react'
|
||||
|
||||
interface HistoryRow {
|
||||
id: number
|
||||
@@ -13,6 +14,11 @@ interface HistoryRow {
|
||||
file_paths?: string[]
|
||||
created_at: string
|
||||
share_token?: string | null
|
||||
share_is_public?: boolean | null
|
||||
share_expires_at?: string | null
|
||||
share_max_views?: number | null
|
||||
share_view_count?: number | null
|
||||
detected_persons?: string[]
|
||||
}
|
||||
|
||||
/** Debounce a value by `delay` ms. */
|
||||
@@ -25,21 +31,133 @@ function useDebounce<T>(value: T, delay: number): T {
|
||||
return debounced
|
||||
}
|
||||
|
||||
/** Chip-based multi-person tag input with a filterable combobox dropdown. */
|
||||
function PersonTagInput({
|
||||
selected,
|
||||
onChange,
|
||||
allPersons,
|
||||
}: {
|
||||
selected: string[]
|
||||
onChange: (persons: string[]) => void
|
||||
allPersons: Array<{ id: number; name: string }>
|
||||
}) {
|
||||
const [inputVal, setInputVal] = useState('')
|
||||
const [showDropdown, setShowDropdown] = useState(false)
|
||||
|
||||
const available = allPersons.filter(
|
||||
p => !selected.includes(p.name) &&
|
||||
(!inputVal || p.name.toLowerCase().includes(inputVal.toLowerCase()))
|
||||
)
|
||||
|
||||
const add = (name: string) => {
|
||||
if (!selected.includes(name)) onChange([...selected, name])
|
||||
setInputVal('')
|
||||
setShowDropdown(false)
|
||||
}
|
||||
|
||||
const remove = (name: string) => onChange(selected.filter(p => p !== name))
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
if (e.key === 'Enter' && available.length > 0) add(available[0].name)
|
||||
if (e.key === 'Backspace' && !inputVal && selected.length > 0) {
|
||||
remove(selected[selected.length - 1])
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-1.5">
|
||||
{selected.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{selected.map(p => (
|
||||
<span
|
||||
key={p}
|
||||
className="flex items-center gap-1 text-xs px-2 py-0.5 rounded-full bg-indigo-500/20 text-indigo-300"
|
||||
>
|
||||
{p}
|
||||
<button onClick={() => remove(p)} className="hover:text-white ml-0.5">
|
||||
<X size={10} />
|
||||
</button>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{allPersons.length > selected.length && (
|
||||
<div className="relative">
|
||||
<input
|
||||
type="text"
|
||||
value={inputVal}
|
||||
onChange={e => setInputVal(e.target.value)}
|
||||
onFocus={() => setShowDropdown(true)}
|
||||
onBlur={() => setTimeout(() => setShowDropdown(false), 150)}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder={selected.length === 0 ? 'Filter by person…' : 'Add person…'}
|
||||
className="glass-input w-full text-sm"
|
||||
/>
|
||||
{showDropdown && available.length > 0 && (
|
||||
<div className="absolute z-20 top-full mt-1 w-full bg-gray-900/95 backdrop-blur-xl border border-white/10 rounded-2xl p-1 max-h-48 overflow-y-auto shadow-xl">
|
||||
{available.map(p => (
|
||||
<button
|
||||
key={p.id}
|
||||
onMouseDown={() => add(p.name)}
|
||||
className="w-full text-left text-xs px-3 py-1.5 rounded-lg hover:bg-white/10 text-gray-300 transition-colors"
|
||||
>
|
||||
{p.name}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/** Return only the selected persons that appear in the row's detected_persons array. */
|
||||
function getComboKey(row: HistoryRow, selected: string[]): string {
|
||||
const matched = selected.filter(p => row.detected_persons?.includes(p))
|
||||
return matched.join('|')
|
||||
}
|
||||
|
||||
interface SectionGroup { key: string; combo: string[]; rows: HistoryRow[] }
|
||||
|
||||
/** Group rows by their exclusive combination of selected persons (ascending combo size). */
|
||||
function groupByCombo(rows: HistoryRow[], selected: string[]): SectionGroup[] {
|
||||
const groups = new Map<string, HistoryRow[]>()
|
||||
for (const row of rows) {
|
||||
const key = getComboKey(row, selected)
|
||||
if (!key) continue // matched none of the selected persons
|
||||
if (!groups.has(key)) groups.set(key, [])
|
||||
groups.get(key)!.push(row)
|
||||
}
|
||||
return Array.from(groups.entries())
|
||||
.map(([key, sRows]) => ({ key, combo: key.split('|'), rows: sRows }))
|
||||
.sort((a, b) =>
|
||||
a.combo.length !== b.combo.length
|
||||
? a.combo.length - b.combo.length
|
||||
: a.key.localeCompare(b.key)
|
||||
)
|
||||
}
|
||||
|
||||
export default function HistoryPage() {
|
||||
const [lightbox, setLightbox] = useState<string | null>(null)
|
||||
const [expandedId, setExpandedId] = useState<string | null>(null)
|
||||
const [searchInput, setSearchInput] = useState('')
|
||||
const { user } = useAuth()
|
||||
const [selectedPersons, setSelectedPersons] = useState<string[]>([])
|
||||
const { user, isAdmin } = useAuth()
|
||||
|
||||
const debouncedQ = useDebounce(searchInput.trim(), 300)
|
||||
|
||||
const { data: personsData } = useQuery({ queryKey: ['faces', 'persons'], queryFn: listPersons })
|
||||
const persons = personsData?.persons ?? []
|
||||
|
||||
const { data, isLoading } = useQuery({
|
||||
queryKey: ['history', debouncedQ],
|
||||
queryFn: () => getHistory(debouncedQ || undefined),
|
||||
queryKey: ['history', debouncedQ, selectedPersons],
|
||||
queryFn: () => getHistory(debouncedQ || undefined, selectedPersons.length ? selectedPersons : undefined),
|
||||
refetchInterval: 10_000,
|
||||
})
|
||||
|
||||
const rows: HistoryRow[] = ((data?.history ?? []) as unknown as HistoryRow[])
|
||||
const grouped: SectionGroup[] | null = selectedPersons.length >= 2 ? groupByCombo(rows, selectedPersons) : null
|
||||
|
||||
const isSearching = searchInput.trim() !== debouncedQ
|
||||
|
||||
@@ -47,26 +165,39 @@ export default function HistoryPage() {
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<h1 className="text-xl font-bold text-gray-800 dark:text-gray-100">History</h1>
|
||||
<h1 className="text-2xl font-bold tracking-tight">
|
||||
<span className="bg-gradient-to-r from-indigo-400 to-violet-400 bg-clip-text text-transparent">
|
||||
History
|
||||
</span>
|
||||
</h1>
|
||||
|
||||
{/* Search */}
|
||||
{/* Search + person filter */}
|
||||
<div className="glass-card p-3 space-y-2 relative z-30">
|
||||
<div className="relative">
|
||||
<input
|
||||
type="text"
|
||||
value={searchInput}
|
||||
onChange={e => setSearchInput(e.target.value)}
|
||||
placeholder="Search by prompt, checkpoint, seed…"
|
||||
className="w-full border border-gray-300 dark:border-gray-600 rounded px-3 py-2 text-sm bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
className="glass-input w-full pr-8"
|
||||
/>
|
||||
{searchInput && (
|
||||
<button
|
||||
onClick={() => setSearchInput('')}
|
||||
className="absolute right-2 top-1/2 -translate-y-1/2 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 text-xs px-1"
|
||||
className="absolute right-2 top-1/2 -translate-y-1/2 text-gray-400 hover:text-gray-300 transition-colors"
|
||||
>
|
||||
✕
|
||||
<X size={14} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{persons.length > 0 && (
|
||||
<PersonTagInput
|
||||
selected={selectedPersons}
|
||||
onChange={setSelectedPersons}
|
||||
allPersons={persons}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
{isSearching && <p className="text-xs text-gray-400">Searching…</p>}
|
||||
|
||||
{isLoading ? (
|
||||
@@ -75,88 +206,42 @@ export default function HistoryPage() {
|
||||
<p className="text-sm text-gray-400">
|
||||
{debouncedQ ? `No results for "${debouncedQ}".` : 'No generation history yet.'}
|
||||
</p>
|
||||
) : grouped ? (
|
||||
/* Grouped view: selectedPersons.length >= 2 */
|
||||
<div className="space-y-6">
|
||||
{grouped.length === 0 ? (
|
||||
<p className="text-sm text-gray-400">No results match the selected persons.</p>
|
||||
) : (
|
||||
<>
|
||||
{/* Desktop table — hidden on mobile */}
|
||||
<div className="hidden sm:block overflow-x-auto">
|
||||
<table className="w-full text-sm border-collapse">
|
||||
<thead>
|
||||
<tr className="text-left text-xs text-gray-400 border-b border-gray-200 dark:border-gray-700">
|
||||
<th className="pb-2 pr-4">Time</th>
|
||||
<th className="pb-2 pr-4">Source</th>
|
||||
<th className="pb-2 pr-4">User</th>
|
||||
<th className="pb-2 pr-4">Seed</th>
|
||||
<th className="pb-2">Files</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows.map(row => (
|
||||
<React.Fragment key={row.prompt_id}>
|
||||
<tr
|
||||
className="border-b border-gray-100 dark:border-gray-800 hover:bg-gray-50 dark:hover:bg-gray-800/50 cursor-pointer"
|
||||
onClick={() => setExpandedId(expandedId === row.prompt_id ? null : row.prompt_id)}
|
||||
>
|
||||
<td className="py-2 pr-4 text-gray-500 dark:text-gray-400 whitespace-nowrap">
|
||||
{new Date(row.created_at).toLocaleString()}
|
||||
</td>
|
||||
<td className="py-2 pr-4">
|
||||
<span className={`text-xs px-1.5 py-0.5 rounded ${row.source === 'web' ? 'bg-blue-100 dark:bg-blue-900 text-blue-700 dark:text-blue-300' : 'bg-purple-100 dark:bg-purple-900 text-purple-700 dark:text-purple-300'}`}>
|
||||
{row.source}
|
||||
</span>
|
||||
</td>
|
||||
<td className="py-2 pr-4 text-gray-600 dark:text-gray-400">{row.user_label ?? '—'}</td>
|
||||
<td className="py-2 pr-4 font-mono text-gray-700 dark:text-gray-300">{row.seed ?? '—'}</td>
|
||||
<td className="py-2 text-gray-500 dark:text-gray-400">{(row.file_paths ?? []).length} file(s)</td>
|
||||
</tr>
|
||||
{expandedId === row.prompt_id && (
|
||||
<tr className="bg-gray-50 dark:bg-gray-800/50">
|
||||
<td colSpan={5} className="px-3 py-3">
|
||||
<ExpandedRow row={row} onLightbox={setLightbox} currentUserLabel={user?.label ?? null} />
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</React.Fragment>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
grouped.map(section => (
|
||||
<div key={section.key} className="space-y-2">
|
||||
<div className="glass-card p-3">
|
||||
<h2 className="text-sm font-semibold text-indigo-300">
|
||||
{section.combo.join(' & ')}
|
||||
<span className="ml-2 text-xs text-gray-400">{section.rows.length} result(s)</span>
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
{/* Mobile card list — hidden on sm+ */}
|
||||
<div className="sm:hidden space-y-2">
|
||||
{rows.map(row => {
|
||||
const isExpanded = expandedId === row.prompt_id
|
||||
const dt = new Date(row.created_at)
|
||||
const timeStr = dt.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })
|
||||
const dateStr = dt.toLocaleDateString([], { month: 'short', day: 'numeric' })
|
||||
return (
|
||||
<div key={row.prompt_id} className="border border-gray-200 dark:border-gray-700 rounded bg-white dark:bg-gray-800">
|
||||
<button
|
||||
className="w-full text-left px-3 py-2 space-y-1"
|
||||
onClick={() => setExpandedId(isExpanded ? null : row.prompt_id)}
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-xs text-gray-500 dark:text-gray-400">{timeStr} · {dateStr}</span>
|
||||
<span className={`text-xs px-1.5 py-0.5 rounded ${row.source === 'web' ? 'bg-blue-100 dark:bg-blue-900 text-blue-700 dark:text-blue-300' : 'bg-purple-100 dark:bg-purple-900 text-purple-700 dark:text-purple-300'}`}>
|
||||
{row.source}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-3 text-xs text-gray-600 dark:text-gray-400">
|
||||
<span>User: {row.user_label ?? '—'}</span>
|
||||
<span>Seed: {row.seed ?? '—'}</span>
|
||||
<span>{(row.file_paths ?? []).length} file(s)</span>
|
||||
<span className="ml-auto text-gray-400">{isExpanded ? '▲' : '▼'}</span>
|
||||
</div>
|
||||
</button>
|
||||
{isExpanded && (
|
||||
<div className="px-3 pb-3 border-t border-gray-100 dark:border-gray-700 pt-2">
|
||||
<ExpandedRow row={row} onLightbox={setLightbox} currentUserLabel={user?.label ?? null} />
|
||||
<HistoryList
|
||||
rows={section.rows}
|
||||
expandedId={expandedId}
|
||||
setExpandedId={setExpandedId}
|
||||
onLightbox={setLightbox}
|
||||
currentUserLabel={user?.label ?? null}
|
||||
isAdmin={isAdmin}
|
||||
/>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
/* Flat view */
|
||||
<HistoryList
|
||||
rows={rows}
|
||||
expandedId={expandedId}
|
||||
setExpandedId={setExpandedId}
|
||||
onLightbox={setLightbox}
|
||||
currentUserLabel={user?.label ?? null}
|
||||
isAdmin={isAdmin}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Lightbox */}
|
||||
@@ -166,27 +251,147 @@ export default function HistoryPage() {
|
||||
onClick={() => setLightbox(null)}
|
||||
>
|
||||
<button
|
||||
className="absolute top-3 right-3 text-white text-2xl leading-none hover:text-gray-300"
|
||||
className="absolute top-3 right-3 text-white hover:text-gray-300 transition-colors"
|
||||
onClick={() => setLightbox(null)}
|
||||
aria-label="Close"
|
||||
>
|
||||
✕
|
||||
<X size={24} />
|
||||
</button>
|
||||
<img src={lightbox} alt="preview" className="max-w-[90vw] max-h-[90vh] object-contain rounded" />
|
||||
<img
|
||||
src={lightbox}
|
||||
alt="preview"
|
||||
className="max-w-[90vw] max-h-[90vh] object-contain rounded-2xl shadow-2xl"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function HistoryList({
|
||||
rows,
|
||||
expandedId,
|
||||
setExpandedId,
|
||||
onLightbox,
|
||||
currentUserLabel,
|
||||
isAdmin,
|
||||
}: {
|
||||
rows: HistoryRow[]
|
||||
expandedId: string | null
|
||||
setExpandedId: (id: string | null) => void
|
||||
onLightbox: (url: string) => void
|
||||
currentUserLabel: string | null
|
||||
isAdmin: boolean
|
||||
}) {
|
||||
return (
|
||||
<>
|
||||
{/* Desktop table */}
|
||||
<div className="hidden sm:block glass-card overflow-hidden p-0">
|
||||
<table className="w-full text-sm border-collapse">
|
||||
<thead>
|
||||
<tr className="bg-white/20 dark:bg-white/5 text-left text-xs text-gray-500 dark:text-gray-400">
|
||||
<th className="pb-2 pt-2.5 px-4 border-b border-white/10 dark:border-white/5">Time</th>
|
||||
<th className="pb-2 pt-2.5 px-4 border-b border-white/10 dark:border-white/5">Source</th>
|
||||
<th className="pb-2 pt-2.5 px-4 border-b border-white/10 dark:border-white/5">User</th>
|
||||
<th className="pb-2 pt-2.5 px-4 border-b border-white/10 dark:border-white/5">Seed</th>
|
||||
<th className="pb-2 pt-2.5 px-4 border-b border-white/10 dark:border-white/5">Files</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows.map(row => (
|
||||
<React.Fragment key={row.prompt_id}>
|
||||
<tr
|
||||
className="border-b border-white/5 dark:border-white/5 hover:bg-white/10 dark:hover:bg-white/5 transition-colors cursor-pointer"
|
||||
onClick={() => setExpandedId(expandedId === row.prompt_id ? null : row.prompt_id)}
|
||||
>
|
||||
<td className="py-2.5 px-4 text-gray-500 dark:text-gray-400 whitespace-nowrap">
|
||||
{new Date(row.created_at).toLocaleString()}
|
||||
</td>
|
||||
<td className="py-2.5 px-4">
|
||||
<span className={`text-xs px-2 py-0.5 rounded-full ${
|
||||
row.source === 'web'
|
||||
? 'bg-blue-500/20 text-blue-300'
|
||||
: 'bg-violet-500/20 text-violet-300'
|
||||
}`}>
|
||||
{row.source}
|
||||
</span>
|
||||
</td>
|
||||
<td className="py-2.5 px-4 text-gray-500 dark:text-gray-400">{row.user_label ?? '—'}</td>
|
||||
<td className="py-2.5 px-4 font-mono text-gray-600 dark:text-gray-300">{row.seed ?? '—'}</td>
|
||||
<td className="py-2.5 px-4 text-gray-500 dark:text-gray-400">{(row.file_paths ?? []).length} file(s)</td>
|
||||
</tr>
|
||||
{expandedId === row.prompt_id && (
|
||||
<tr className="bg-white/5 dark:bg-white/5">
|
||||
<td colSpan={5} className="px-4 py-3">
|
||||
<ExpandedRow row={row} onLightbox={onLightbox} currentUserLabel={currentUserLabel} isAdmin={isAdmin} />
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</React.Fragment>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{/* Mobile card list */}
|
||||
<div className="sm:hidden space-y-2">
|
||||
{rows.map(row => {
|
||||
const isExpanded = expandedId === row.prompt_id
|
||||
const dt = new Date(row.created_at)
|
||||
const timeStr = dt.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })
|
||||
const dateStr = dt.toLocaleDateString([], { month: 'short', day: 'numeric' })
|
||||
return (
|
||||
<div key={row.prompt_id} className="glass-card p-0 overflow-hidden">
|
||||
<button
|
||||
className="w-full text-left px-3 py-2.5 space-y-1"
|
||||
onClick={() => setExpandedId(isExpanded ? null : row.prompt_id)}
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-xs text-gray-500 dark:text-gray-400">{timeStr} · {dateStr}</span>
|
||||
<span className={`text-xs px-2 py-0.5 rounded-full ${
|
||||
row.source === 'web'
|
||||
? 'bg-blue-500/20 text-blue-300'
|
||||
: 'bg-violet-500/20 text-violet-300'
|
||||
}`}>
|
||||
{row.source}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-3 text-xs text-gray-500 dark:text-gray-400">
|
||||
<span>User: {row.user_label ?? '—'}</span>
|
||||
<span>Seed: {row.seed ?? '—'}</span>
|
||||
<span>{(row.file_paths ?? []).length} file(s)</span>
|
||||
<span className="ml-auto">{isExpanded ? '▲' : '▼'}</span>
|
||||
</div>
|
||||
</button>
|
||||
{isExpanded && (
|
||||
<div className="px-3 pb-3 border-t border-white/10 dark:border-white/5 pt-2">
|
||||
<ExpandedRow row={row} onLightbox={onLightbox} currentUserLabel={currentUserLabel} isAdmin={isAdmin} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function formatExpiry(s: string | null | undefined): string {
|
||||
if (!s) return 'Never'
|
||||
const d = new Date(s)
|
||||
return d < new Date() ? 'Expired' : d.toLocaleString()
|
||||
}
|
||||
|
||||
function ExpandedRow({
|
||||
row,
|
||||
onLightbox,
|
||||
currentUserLabel,
|
||||
isAdmin,
|
||||
}: {
|
||||
row: HistoryRow
|
||||
onLightbox: (url: string) => void
|
||||
currentUserLabel: string | null
|
||||
isAdmin: boolean
|
||||
}) {
|
||||
const qc = useQueryClient()
|
||||
const [copied, setCopied] = useState(false)
|
||||
@@ -195,6 +400,12 @@ function ExpandedRow({
|
||||
const [presetDesc, setPresetDesc] = useState('')
|
||||
const [presetMsg, setPresetMsg] = useState<{ ok: boolean; text: string } | null>(null)
|
||||
const presetNameRef = useRef<HTMLInputElement>(null)
|
||||
const [addPersonInput, setAddPersonInput] = useState('')
|
||||
const [showPersonDrop, setShowPersonDrop] = useState(false)
|
||||
const [showSharePanel, setShowSharePanel] = useState(false)
|
||||
const [shareIsPublic, setShareIsPublic] = useState(false)
|
||||
const [shareExpiryHours, setShareExpiryHours] = useState<number | null>(null)
|
||||
const [shareMaxViews, setShareMaxViews] = useState<number | null>(null)
|
||||
|
||||
const { data: imagesData, isLoading } = useQuery({
|
||||
queryKey: ['history', row.prompt_id, 'images'],
|
||||
@@ -205,9 +416,51 @@ function ExpandedRow({
|
||||
},
|
||||
})
|
||||
|
||||
const { data: personsData } = useQuery({
|
||||
queryKey: ['history', row.prompt_id, 'persons'],
|
||||
queryFn: () => getGenerationPersons(row.prompt_id),
|
||||
})
|
||||
const rowPersons = personsData?.persons ?? []
|
||||
|
||||
const { data: allPersonsData } = useQuery({
|
||||
queryKey: ['faces', 'persons'],
|
||||
queryFn: listPersons,
|
||||
enabled: isAdmin,
|
||||
})
|
||||
const allPersons = allPersonsData?.persons ?? []
|
||||
|
||||
const addPersonMut = useMutation({
|
||||
mutationFn: (name: string) => addGenerationPerson(row.prompt_id, name),
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: ['history', row.prompt_id, 'persons'] })
|
||||
qc.invalidateQueries({ queryKey: ['history'] })
|
||||
setAddPersonInput('')
|
||||
},
|
||||
})
|
||||
|
||||
const removePersonMut = useMutation({
|
||||
mutationFn: (personId: number) => removeGenerationPerson(row.prompt_id, personId),
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: ['history', row.prompt_id, 'persons'] })
|
||||
qc.invalidateQueries({ queryKey: ['history'] })
|
||||
},
|
||||
})
|
||||
|
||||
const personDropOptions = allPersons.filter(
|
||||
p => !rowPersons.some(rp => rp.id === p.id) &&
|
||||
(!addPersonInput || p.name.toLowerCase().includes(addPersonInput.toLowerCase()))
|
||||
)
|
||||
|
||||
const shareMut = useMutation({
|
||||
mutationFn: () => createHistoryShare(row.prompt_id),
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: ['history'] }),
|
||||
mutationFn: () => createHistoryShare(row.prompt_id, {
|
||||
is_public: shareIsPublic,
|
||||
expires_in_hours: shareExpiryHours ?? undefined,
|
||||
max_views: shareMaxViews ?? undefined,
|
||||
}),
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: ['history'] })
|
||||
setShowSharePanel(false)
|
||||
},
|
||||
})
|
||||
|
||||
const revokeMut = useMutation({
|
||||
@@ -244,15 +497,13 @@ function ExpandedRow({
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
{/* Overrides */}
|
||||
<details>
|
||||
<summary className="text-xs text-gray-400 cursor-pointer">Overrides</summary>
|
||||
<pre className="text-xs bg-gray-100 dark:bg-gray-700 rounded p-2 mt-1 overflow-auto max-h-32 text-gray-600 dark:text-gray-400">
|
||||
<summary className="text-xs text-gray-400 cursor-pointer hover:text-gray-300">Overrides</summary>
|
||||
<pre className="text-xs bg-white/5 rounded-xl p-2 mt-1 overflow-auto max-h-32 text-gray-400">
|
||||
{JSON.stringify(row.overrides, null, 2)}
|
||||
</pre>
|
||||
</details>
|
||||
|
||||
{/* Images */}
|
||||
{isLoading ? (
|
||||
<p className="text-xs text-gray-400">Loading images…</p>
|
||||
) : (imagesData?.images ?? []).length > 0 ? (
|
||||
@@ -261,14 +512,7 @@ function ExpandedRow({
|
||||
const isVideo = img.mime_type.startsWith('video/')
|
||||
if (isVideo) {
|
||||
const videoSrc = `/api/history/${row.prompt_id}/file/${encodeURIComponent(img.filename)}`
|
||||
return (
|
||||
<video
|
||||
key={i}
|
||||
src={videoSrc}
|
||||
controls
|
||||
className="rounded max-h-40"
|
||||
/>
|
||||
)
|
||||
return <video key={i} src={videoSrc} controls className="rounded-xl max-h-40" />
|
||||
}
|
||||
const src = `data:${img.mime_type};base64,${img.data}`
|
||||
return (
|
||||
@@ -276,7 +520,7 @@ function ExpandedRow({
|
||||
key={i}
|
||||
src={src}
|
||||
alt={img.filename}
|
||||
className="rounded max-h-40 cursor-pointer border border-gray-200 dark:border-gray-700"
|
||||
className="rounded-xl max-h-40 cursor-pointer shadow-md hover:shadow-lg transition-shadow"
|
||||
onClick={() => onLightbox(src)}
|
||||
/>
|
||||
)
|
||||
@@ -286,54 +530,205 @@ function ExpandedRow({
|
||||
<p className="text-xs text-gray-400">Files not available (may have been moved or deleted).</p>
|
||||
)}
|
||||
|
||||
{/* Owner-only actions */}
|
||||
{isOwner && (
|
||||
<div className="pt-1 border-t border-gray-100 dark:border-gray-700 space-y-2">
|
||||
{/* Share section */}
|
||||
{shareUrl ? (
|
||||
<div className="space-y-1">
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400">Share link</p>
|
||||
{(isAdmin || rowPersons.length > 0) && (
|
||||
<div className="space-y-1.5">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<code className="text-xs bg-gray-100 dark:bg-gray-700 rounded px-2 py-1 text-gray-700 dark:text-gray-300 break-all select-all flex-1 min-w-0">
|
||||
<span className="text-xs text-gray-500 dark:text-gray-400">People:</span>
|
||||
{rowPersons.map(p => (
|
||||
<span key={p.id} className="flex items-center gap-1 text-xs px-2 py-0.5 rounded-full bg-blue-500/20 text-blue-300">
|
||||
{p.name}
|
||||
{isAdmin && (
|
||||
<button
|
||||
onClick={() => removePersonMut.mutate(p.id)}
|
||||
disabled={removePersonMut.isPending}
|
||||
className="hover:text-white ml-0.5 disabled:opacity-50"
|
||||
aria-label={`Remove ${p.name}`}
|
||||
>
|
||||
<X size={10} />
|
||||
</button>
|
||||
)}
|
||||
</span>
|
||||
))}
|
||||
{rowPersons.length === 0 && !isAdmin && <span className="text-xs text-gray-600">None detected</span>}
|
||||
</div>
|
||||
{isAdmin && (
|
||||
<div className="relative">
|
||||
<input
|
||||
type="text"
|
||||
value={addPersonInput}
|
||||
onChange={e => setAddPersonInput(e.target.value)}
|
||||
onFocus={() => setShowPersonDrop(true)}
|
||||
onBlur={() => setTimeout(() => setShowPersonDrop(false), 150)}
|
||||
onKeyDown={e => {
|
||||
if (e.key === 'Enter' && personDropOptions.length > 0) {
|
||||
addPersonMut.mutate(personDropOptions[0].name)
|
||||
setShowPersonDrop(false)
|
||||
}
|
||||
}}
|
||||
placeholder="Tag a person…"
|
||||
className="glass-input w-full text-xs py-1"
|
||||
disabled={addPersonMut.isPending}
|
||||
/>
|
||||
{showPersonDrop && personDropOptions.length > 0 && (
|
||||
<div className="absolute z-20 top-full mt-1 w-full bg-gray-900/95 backdrop-blur-xl border border-white/10 rounded-2xl p-1 max-h-40 overflow-y-auto shadow-xl">
|
||||
{personDropOptions.map(p => (
|
||||
<button
|
||||
key={p.id}
|
||||
onMouseDown={() => { addPersonMut.mutate(p.name); setShowPersonDrop(false) }}
|
||||
className="w-full text-left text-xs px-3 py-1.5 rounded-lg hover:bg-white/10 text-gray-300 transition-colors"
|
||||
>
|
||||
{p.name}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isOwner && (
|
||||
<div className="pt-1 border-t border-white/10 dark:border-white/5 space-y-2">
|
||||
{shareUrl ? (
|
||||
<div className="space-y-1.5">
|
||||
<div className="flex items-center gap-2">
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400">Share link</p>
|
||||
<span className={`text-xs px-1.5 py-0.5 rounded-full ${
|
||||
row.share_is_public
|
||||
? 'bg-green-500/20 text-green-300'
|
||||
: 'bg-gray-500/20 text-gray-400'
|
||||
}`}>
|
||||
{row.share_is_public ? 'Public' : 'Private'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<code className="text-xs bg-white/5 rounded-xl px-2 py-1 text-gray-300 break-all select-all flex-1 min-w-0">
|
||||
{shareUrl}
|
||||
</code>
|
||||
<button
|
||||
onClick={handleCopy}
|
||||
className="shrink-0 text-xs px-2 py-1 rounded bg-blue-100 dark:bg-blue-900 text-blue-700 dark:text-blue-300 hover:bg-blue-200 dark:hover:bg-blue-800 transition-colors"
|
||||
className="shrink-0 text-xs px-2 py-1 rounded-lg bg-indigo-500/20 text-indigo-300 hover:bg-indigo-500/30 transition-colors"
|
||||
>
|
||||
{copied ? 'Copied!' : 'Copy'}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => revokeMut.mutate()}
|
||||
disabled={revokeMut.isPending}
|
||||
className="shrink-0 text-xs px-2 py-1 rounded bg-red-100 dark:bg-red-900 text-red-700 dark:text-red-300 hover:bg-red-200 dark:hover:bg-red-800 disabled:opacity-50 transition-colors"
|
||||
className="btn-danger shrink-0"
|
||||
>
|
||||
{revokeMut.isPending ? 'Revoking…' : 'Revoke'}
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex gap-4 text-xs text-gray-500">
|
||||
<span>Expires: {formatExpiry(row.share_expires_at)}</span>
|
||||
{row.share_max_views != null ? (
|
||||
<span>Views: {row.share_view_count ?? 0} / {row.share_max_views}</span>
|
||||
) : (row.share_view_count ?? 0) > 0 ? (
|
||||
<span>Views: {row.share_view_count}</span>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
) : showSharePanel ? (
|
||||
<div className="space-y-2">
|
||||
<p className="text-xs text-gray-400">Create share link</p>
|
||||
<div className="flex gap-2 items-center flex-wrap">
|
||||
<span className="text-xs text-gray-500">Visibility:</span>
|
||||
<button
|
||||
onClick={() => setShareIsPublic(false)}
|
||||
className={`text-xs px-2 py-0.5 rounded-full transition-colors ${
|
||||
!shareIsPublic
|
||||
? 'bg-indigo-500/30 text-indigo-300'
|
||||
: 'bg-white/10 text-gray-400 hover:bg-white/15'
|
||||
}`}
|
||||
>
|
||||
Private
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setShareIsPublic(true)}
|
||||
className={`text-xs px-2 py-0.5 rounded-full transition-colors ${
|
||||
shareIsPublic
|
||||
? 'bg-green-500/30 text-green-300'
|
||||
: 'bg-white/10 text-gray-400 hover:bg-white/15'
|
||||
}`}
|
||||
>
|
||||
Public
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex gap-4 flex-wrap">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="text-xs text-gray-500">Expires after:</span>
|
||||
<input
|
||||
type="number"
|
||||
min="0"
|
||||
step="any"
|
||||
value={shareExpiryHours ?? ''}
|
||||
onChange={e => setShareExpiryHours(e.target.value ? parseFloat(e.target.value) : null)}
|
||||
placeholder="hours"
|
||||
className="glass-input w-20 py-0.5 text-xs"
|
||||
/>
|
||||
<span className="text-xs text-gray-500">hrs</span>
|
||||
{shareExpiryHours !== null && (
|
||||
<button onClick={() => setShareExpiryHours(null)} className="text-xs text-gray-500 hover:text-gray-300">×</button>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="text-xs text-gray-500">Max views:</span>
|
||||
<input
|
||||
type="number"
|
||||
min="1"
|
||||
step="1"
|
||||
value={shareMaxViews ?? ''}
|
||||
onChange={e => setShareMaxViews(e.target.value ? parseInt(e.target.value, 10) : null)}
|
||||
placeholder="unlimited"
|
||||
className="glass-input w-24 py-0.5 text-xs"
|
||||
/>
|
||||
{shareMaxViews !== null && (
|
||||
<button onClick={() => setShareMaxViews(null)} className="text-xs text-gray-500 hover:text-gray-300">×</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{shareIsPublic && !shareExpiryHours && !shareMaxViews && (
|
||||
<p className="text-xs text-amber-400">⚠ Public shares require at least one expiry condition.</p>
|
||||
)}
|
||||
{shareMut.isError && (
|
||||
<p className="text-xs text-red-400">{shareMut.error instanceof Error ? shareMut.error.message : 'Failed to create share'}</p>
|
||||
)}
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={() => shareMut.mutate()}
|
||||
disabled={shareMut.isPending || (shareIsPublic && !shareExpiryHours && !shareMaxViews)}
|
||||
className="text-xs px-2 py-1 rounded-lg bg-indigo-500/20 text-indigo-300 hover:bg-indigo-500/30 disabled:opacity-40 transition-colors"
|
||||
>
|
||||
{shareMut.isPending ? 'Creating…' : 'Create Link'}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => { setShowSharePanel(false); shareMut.reset() }}
|
||||
className="text-xs px-2 py-1 rounded-lg bg-white/10 hover:bg-white/15 text-gray-400 hover:text-gray-300 transition-colors"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
onClick={() => shareMut.mutate()}
|
||||
disabled={shareMut.isPending}
|
||||
className="text-xs px-2 py-1 rounded bg-gray-100 dark:bg-gray-700 text-gray-600 dark:text-gray-400 hover:bg-gray-200 dark:hover:bg-gray-600 disabled:opacity-50 transition-colors"
|
||||
onClick={() => setShowSharePanel(true)}
|
||||
className="text-xs px-2 py-1 rounded-lg bg-white/10 hover:bg-white/15 text-gray-400 hover:text-gray-300 transition-colors"
|
||||
>
|
||||
{shareMut.isPending ? 'Creating link…' : 'Share'}
|
||||
Share
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Save-as-preset section */}
|
||||
{!showSavePreset ? (
|
||||
<button
|
||||
onClick={() => { setShowSavePreset(true); setPresetMsg(null); setTimeout(() => presetNameRef.current?.focus(), 50) }}
|
||||
className="text-xs px-2 py-1 rounded bg-gray-100 dark:bg-gray-700 text-gray-600 dark:text-gray-400 hover:bg-gray-200 dark:hover:bg-gray-600 transition-colors"
|
||||
className="text-xs px-2 py-1 rounded-lg bg-white/10 hover:bg-white/15 text-gray-400 hover:text-gray-300 transition-colors"
|
||||
>
|
||||
Save as preset
|
||||
</button>
|
||||
) : (
|
||||
<div className="space-y-1.5">
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400">Save overrides as preset</p>
|
||||
<p className="text-xs text-amber-600 dark:text-amber-400">
|
||||
<p className="text-xs text-amber-400">
|
||||
Note: workflow template is not saved — load it separately before using this preset.
|
||||
</p>
|
||||
<div className="flex gap-2 flex-wrap">
|
||||
@@ -343,33 +738,33 @@ function ExpandedRow({
|
||||
value={presetName}
|
||||
onChange={e => setPresetName(e.target.value)}
|
||||
placeholder="Preset name"
|
||||
className="flex-1 min-w-0 border border-gray-300 dark:border-gray-600 rounded px-2 py-1 text-xs bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 focus:outline-none focus:ring-1 focus:ring-blue-500"
|
||||
className="glass-input flex-1 min-w-0 py-1 text-xs"
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
value={presetDesc}
|
||||
onChange={e => setPresetDesc(e.target.value)}
|
||||
placeholder="Description (optional)"
|
||||
className="flex-1 min-w-0 border border-gray-300 dark:border-gray-600 rounded px-2 py-1 text-xs bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 focus:outline-none focus:ring-1 focus:ring-blue-500"
|
||||
className="glass-input flex-1 min-w-0 py-1 text-xs"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={() => savePresetMut.mutate()}
|
||||
disabled={!presetName.trim() || savePresetMut.isPending}
|
||||
className="text-xs px-2 py-1 rounded bg-blue-600 hover:bg-blue-700 disabled:opacity-50 text-white transition-colors"
|
||||
className="btn-primary py-1 px-2 text-xs disabled:opacity-50"
|
||||
>
|
||||
{savePresetMut.isPending ? 'Saving…' : 'Save'}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => { setShowSavePreset(false); setPresetMsg(null); setPresetName(''); setPresetDesc('') }}
|
||||
className="text-xs px-2 py-1 rounded bg-gray-100 dark:bg-gray-700 text-gray-600 dark:text-gray-400 hover:bg-gray-200 dark:hover:bg-gray-600 transition-colors"
|
||||
className="text-xs px-2 py-1 rounded-lg bg-white/10 hover:bg-white/15 text-gray-400 hover:text-gray-300 transition-colors"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
{presetMsg && (
|
||||
<p className={`text-xs ${presetMsg.ok ? 'text-green-600 dark:text-green-400' : 'text-red-500'}`}>
|
||||
<p className={`text-xs ${presetMsg.ok ? 'text-green-400' : 'text-red-400'}`}>
|
||||
{presetMsg.text}
|
||||
</p>
|
||||
)}
|
||||
|
||||
@@ -10,9 +10,120 @@ import {
|
||||
getInputMid,
|
||||
getWorkflowInputs,
|
||||
getState,
|
||||
listPersons,
|
||||
InputImage,
|
||||
PendingFace,
|
||||
} from '../api/client'
|
||||
import LazyImage from '../components/LazyImage'
|
||||
import FaceIdentifyModal from '../components/FaceIdentifyModal'
|
||||
import { X } from 'lucide-react'
|
||||
|
||||
/** Chip-based multi-person tag input with a filterable combobox dropdown. */
|
||||
function PersonTagInput({
|
||||
selected,
|
||||
onChange,
|
||||
allPersons,
|
||||
}: {
|
||||
selected: string[]
|
||||
onChange: (persons: string[]) => void
|
||||
allPersons: Array<{ id: number; name: string }>
|
||||
}) {
|
||||
const [inputVal, setInputVal] = useState('')
|
||||
const [showDropdown, setShowDropdown] = useState(false)
|
||||
|
||||
const available = allPersons.filter(
|
||||
p => !selected.includes(p.name) &&
|
||||
(!inputVal || p.name.toLowerCase().includes(inputVal.toLowerCase()))
|
||||
)
|
||||
|
||||
const add = (name: string) => {
|
||||
if (!selected.includes(name)) onChange([...selected, name])
|
||||
setInputVal('')
|
||||
setShowDropdown(false)
|
||||
}
|
||||
|
||||
const remove = (name: string) => onChange(selected.filter(p => p !== name))
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
if (e.key === 'Enter' && available.length > 0) add(available[0].name)
|
||||
if (e.key === 'Backspace' && !inputVal && selected.length > 0) {
|
||||
remove(selected[selected.length - 1])
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-1.5">
|
||||
{selected.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{selected.map(p => (
|
||||
<span
|
||||
key={p}
|
||||
className="flex items-center gap-1 text-xs px-2 py-0.5 rounded-full bg-indigo-500/20 text-indigo-300"
|
||||
>
|
||||
{p}
|
||||
<button onClick={() => remove(p)} className="hover:text-white ml-0.5">
|
||||
<X size={10} />
|
||||
</button>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{allPersons.length > selected.length && (
|
||||
<div className="relative">
|
||||
<input
|
||||
type="text"
|
||||
value={inputVal}
|
||||
onChange={e => setInputVal(e.target.value)}
|
||||
onFocus={() => setShowDropdown(true)}
|
||||
onBlur={() => setTimeout(() => setShowDropdown(false), 150)}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder={selected.length === 0 ? 'Filter by person…' : 'Add person…'}
|
||||
className="glass-input w-full text-sm"
|
||||
/>
|
||||
{showDropdown && available.length > 0 && (
|
||||
<div className="absolute z-20 top-full mt-1 w-full bg-gray-900/95 backdrop-blur-xl border border-white/10 rounded-2xl p-1 max-h-48 overflow-y-auto shadow-xl">
|
||||
{available.map(p => (
|
||||
<button
|
||||
key={p.id}
|
||||
onMouseDown={() => add(p.name)}
|
||||
className="w-full text-left text-xs px-3 py-1.5 rounded-lg hover:bg-white/10 text-gray-300 transition-colors"
|
||||
>
|
||||
{p.name}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/** Return only the selected persons that appear in the image's detected_persons array. */
|
||||
function getImgComboKey(img: InputImage, selected: string[]): string {
|
||||
const matched = selected.filter(p => img.detected_persons?.includes(p))
|
||||
return matched.join('|')
|
||||
}
|
||||
|
||||
interface ImgSection { key: string; combo: string[]; images: InputImage[] }
|
||||
|
||||
/** Group images by their exclusive combination of selected persons (ascending combo size). */
|
||||
function groupImagesByCombo(images: InputImage[], selected: string[]): ImgSection[] {
|
||||
const groups = new Map<string, InputImage[]>()
|
||||
for (const img of images) {
|
||||
const key = getImgComboKey(img, selected)
|
||||
if (!key) continue
|
||||
if (!groups.has(key)) groups.set(key, [])
|
||||
groups.get(key)!.push(img)
|
||||
}
|
||||
return Array.from(groups.entries())
|
||||
.map(([key, imgs]) => ({ key, combo: key.split('|'), images: imgs }))
|
||||
.sort((a, b) =>
|
||||
a.combo.length !== b.combo.length
|
||||
? a.combo.length - b.combo.length
|
||||
: a.key.localeCompare(b.key)
|
||||
)
|
||||
}
|
||||
|
||||
export default function InputImagesPage() {
|
||||
const qc = useQueryClient()
|
||||
@@ -20,8 +131,16 @@ export default function InputImagesPage() {
|
||||
const [uploading, setUploading] = useState(false)
|
||||
const [uploadSlot, setUploadSlot] = useState<string | null>(null)
|
||||
const [lightbox, setLightbox] = useState<string | null>(null)
|
||||
const [pendingFaces, setPendingFaces] = useState<{ faces: PendingFace[]; inputId: number } | null>(null)
|
||||
const [selectedPersons, setSelectedPersons] = useState<string[]>([])
|
||||
|
||||
const { data: images = [], isLoading } = useQuery({ queryKey: ['inputs'], queryFn: listInputs })
|
||||
const { data: personsData } = useQuery({ queryKey: ['faces', 'persons'], queryFn: listPersons })
|
||||
const persons = personsData?.persons ?? []
|
||||
|
||||
const { data: images = [], isLoading } = useQuery({
|
||||
queryKey: ['inputs', selectedPersons],
|
||||
queryFn: () => listInputs(selectedPersons.length ? selectedPersons : undefined),
|
||||
})
|
||||
const { data: inputsData } = useQuery({ queryKey: ['workflow', 'inputs'], queryFn: getWorkflowInputs })
|
||||
const { data: stateData } = useQuery({ queryKey: ['state'], queryFn: getState })
|
||||
|
||||
@@ -47,10 +166,13 @@ export default function InputImagesPage() {
|
||||
setUploading(true)
|
||||
setUploadSlot(slotKey)
|
||||
try {
|
||||
await uploadInput(fileRef.current.files[0], slotKey)
|
||||
const result = await uploadInput(fileRef.current.files[0], slotKey)
|
||||
qc.invalidateQueries({ queryKey: ['inputs'] })
|
||||
qc.invalidateQueries({ queryKey: ['state'] })
|
||||
if (fileRef.current) fileRef.current.value = ''
|
||||
if (result?.pending_faces?.length > 0) {
|
||||
setPendingFaces({ faces: result.pending_faces, inputId: result.id })
|
||||
}
|
||||
} finally {
|
||||
setUploading(false)
|
||||
setUploadSlot(null)
|
||||
@@ -62,26 +184,90 @@ export default function InputImagesPage() {
|
||||
return String(st?.[slotKey] ?? '')
|
||||
}
|
||||
|
||||
const isPersonFiltered = selectedPersons.length > 0
|
||||
const grouped: ImgSection[] | null =
|
||||
selectedPersons.length >= 2 ? groupImagesByCombo(images, selectedPersons) : null
|
||||
|
||||
if (isLoading) return <div className="text-sm text-gray-400">Loading images…</div>
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<h1 className="text-xl font-bold text-gray-800 dark:text-gray-100">Input Images</h1>
|
||||
<h1 className="text-2xl font-bold tracking-tight">
|
||||
<span className="bg-gradient-to-r from-indigo-400 to-violet-400 bg-clip-text text-transparent">
|
||||
Input Images
|
||||
</span>
|
||||
</h1>
|
||||
|
||||
{/* Per-slot sections */}
|
||||
{imageSlots.length > 0 ? (
|
||||
{/* Person filter */}
|
||||
{persons.length > 0 && (
|
||||
<div className="glass-card p-3 max-w-sm relative z-30">
|
||||
<PersonTagInput
|
||||
selected={selectedPersons}
|
||||
onChange={setSelectedPersons}
|
||||
allPersons={persons}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Grouped sections: 2+ selected persons */}
|
||||
{grouped ? (
|
||||
<div className="space-y-6">
|
||||
{grouped.length === 0 ? (
|
||||
<p className="text-sm text-gray-400">No images match the selected persons.</p>
|
||||
) : (
|
||||
grouped.map(section => (
|
||||
<section key={section.key} className="space-y-2">
|
||||
<div className="glass-card p-3">
|
||||
<h2 className="text-sm font-semibold text-indigo-300">
|
||||
{section.combo.join(' & ')}
|
||||
<span className="ml-2 text-xs text-gray-400">{section.images.length} image(s)</span>
|
||||
</h2>
|
||||
</div>
|
||||
<ImageGrid
|
||||
images={section.images}
|
||||
activeFilename=""
|
||||
slotKey="input_image"
|
||||
onActivate={(id) => activateMut.mutate({ id, slotKey: 'input_image' })}
|
||||
onDelete={(id) => deleteMut.mutate(id)}
|
||||
onLightbox={setLightbox}
|
||||
/>
|
||||
</section>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
) : isPersonFiltered ? (
|
||||
/* Single-person filter: flat section */
|
||||
<section className="glass-card p-4 space-y-3">
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">
|
||||
Filtered by: <strong className="text-gray-800 dark:text-gray-100">{selectedPersons[0]}</strong>
|
||||
</p>
|
||||
{images.length === 0 ? (
|
||||
<p className="text-sm text-gray-400">No images found for this person.</p>
|
||||
) : (
|
||||
<ImageGrid
|
||||
images={images}
|
||||
activeFilename=""
|
||||
slotKey="input_image"
|
||||
onActivate={(id) => activateMut.mutate({ id, slotKey: 'input_image' })}
|
||||
onDelete={(id) => deleteMut.mutate(id)}
|
||||
onLightbox={setLightbox}
|
||||
/>
|
||||
)}
|
||||
</section>
|
||||
) : imageSlots.length > 0 ? (
|
||||
/* Per-slot sections */
|
||||
imageSlots.map(slot => {
|
||||
const activeFilename = activeForSlot(slot.key)
|
||||
return (
|
||||
<section key={slot.key} className="space-y-2">
|
||||
<section key={slot.key} className="glass-card p-4 space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="text-sm font-semibold text-gray-700 dark:text-gray-300">
|
||||
<h2 className="text-sm font-semibold text-gray-700 dark:text-gray-200">
|
||||
{slot.label}
|
||||
{activeFilename && (
|
||||
<span className="ml-2 text-xs font-normal text-blue-500">(active: {activeFilename})</span>
|
||||
<span className="ml-2 text-xs font-normal text-indigo-400">(active: {activeFilename})</span>
|
||||
)}
|
||||
</h2>
|
||||
<label className="cursor-pointer text-xs bg-blue-600 hover:bg-blue-700 text-white rounded px-2 py-1">
|
||||
<label className="cursor-pointer btn-primary py-1 px-2.5 text-xs">
|
||||
{uploading && uploadSlot === slot.key ? 'Uploading…' : 'Upload'}
|
||||
<input
|
||||
ref={fileRef}
|
||||
@@ -104,11 +290,11 @@ export default function InputImagesPage() {
|
||||
)
|
||||
})
|
||||
) : (
|
||||
/* No workflow loaded — show flat list */
|
||||
<section className="space-y-2">
|
||||
/* No slots defined */
|
||||
<section className="glass-card p-4 space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="text-sm font-semibold text-gray-700 dark:text-gray-300">All images</h2>
|
||||
<label className="cursor-pointer text-xs bg-blue-600 hover:bg-blue-700 text-white rounded px-2 py-1">
|
||||
<h2 className="text-sm font-semibold text-gray-700 dark:text-gray-200">All images</h2>
|
||||
<label className="cursor-pointer btn-primary py-1 px-2.5 text-xs">
|
||||
{uploading ? 'Uploading…' : 'Upload'}
|
||||
<input
|
||||
ref={fileRef}
|
||||
@@ -130,7 +316,7 @@ export default function InputImagesPage() {
|
||||
</section>
|
||||
)}
|
||||
|
||||
{images.length === 0 && (
|
||||
{images.length === 0 && !isPersonFiltered && (
|
||||
<p className="text-sm text-gray-400">No images yet. Upload one to get started.</p>
|
||||
)}
|
||||
|
||||
@@ -141,15 +327,24 @@ export default function InputImagesPage() {
|
||||
onClick={() => setLightbox(null)}
|
||||
>
|
||||
<button
|
||||
className="absolute top-3 right-3 text-white text-2xl leading-none hover:text-gray-300"
|
||||
className="absolute top-3 right-3 text-white hover:text-gray-300 transition-colors"
|
||||
onClick={() => setLightbox(null)}
|
||||
aria-label="Close"
|
||||
>
|
||||
✕
|
||||
<X size={24} />
|
||||
</button>
|
||||
<img src={lightbox} alt="preview" className="max-w-[90vw] max-h-[90vh] object-contain rounded" />
|
||||
<img src={lightbox} alt="preview" className="max-w-[90vw] max-h-[90vh] object-contain rounded-2xl shadow-2xl" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Face identification modal */}
|
||||
{pendingFaces && (
|
||||
<FaceIdentifyModal
|
||||
pendingFaces={pendingFaces.faces}
|
||||
inputId={pendingFaces.inputId}
|
||||
onDone={() => setPendingFaces(null)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -177,8 +372,10 @@ function ImageGrid({
|
||||
return (
|
||||
<div
|
||||
key={img.id}
|
||||
className={`relative group aspect-square rounded border-2 overflow-hidden cursor-pointer ${
|
||||
isActive ? 'border-blue-500' : 'border-transparent'
|
||||
className={`relative group aspect-square rounded-xl border-2 overflow-hidden cursor-pointer transition-all ${
|
||||
isActive
|
||||
? 'border-indigo-400 shadow-[0_0_12px_rgba(99,102,241,0.5)]'
|
||||
: 'border-transparent'
|
||||
}`}
|
||||
>
|
||||
<LazyImage
|
||||
@@ -190,20 +387,41 @@ function ImageGrid({
|
||||
onClick={() => onLightbox(getInputImage(img.id))}
|
||||
/>
|
||||
{isActive && (
|
||||
<div className="absolute top-0.5 left-0.5 bg-blue-500 text-white text-[9px] px-1 rounded">active</div>
|
||||
<div className="absolute top-0.5 left-0.5 bg-indigo-500 text-white text-[9px] px-1 rounded">
|
||||
active
|
||||
</div>
|
||||
)}
|
||||
<div className="absolute inset-0 bg-black/60 opacity-0 group-hover:opacity-100 [@media(hover:none)]:opacity-100 transition-opacity flex flex-col items-center justify-center gap-1">
|
||||
{/* Desktop hover overlay */}
|
||||
<div className="absolute inset-0 bg-black/60 opacity-0 group-hover:opacity-100 transition-opacity hidden [@media(hover:hover)]:flex flex-col items-center justify-center gap-1">
|
||||
{!isActive && (
|
||||
<button
|
||||
onClick={() => onActivate(img.id)}
|
||||
className="text-[10px] bg-blue-600 text-white rounded px-1.5 py-0.5 hover:bg-blue-700"
|
||||
className="text-[10px] bg-indigo-600 text-white rounded-lg px-1.5 py-0.5 hover:bg-indigo-500"
|
||||
>
|
||||
Activate
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={() => onDelete(img.id)}
|
||||
className="text-[10px] bg-red-600 text-white rounded px-1.5 py-0.5 hover:bg-red-700"
|
||||
className="text-[10px] bg-red-600 text-white rounded-lg px-1.5 py-0.5 hover:bg-red-500"
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Mobile bottom strip */}
|
||||
<div className="absolute bottom-0 inset-x-0 bg-black/70 flex items-center justify-center gap-1 py-0.5 [@media(hover:hover)]:hidden">
|
||||
{!isActive && (
|
||||
<button
|
||||
onClick={() => onActivate(img.id)}
|
||||
className="text-[10px] bg-indigo-600 text-white rounded-lg px-1.5 py-0.5 active:bg-indigo-500"
|
||||
>
|
||||
Activate
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={() => onDelete(img.id)}
|
||||
className="text-[10px] bg-red-600 text-white rounded-lg px-1.5 py-0.5 active:bg-red-500"
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
|
||||
@@ -38,37 +38,46 @@ export default function LoginPage() {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-gray-100 dark:bg-gray-900">
|
||||
<div className="bg-white dark:bg-gray-800 shadow rounded-lg p-8 w-full max-w-sm">
|
||||
<h1 className="text-xl font-bold mb-6 text-gray-800 dark:text-gray-100">ComfyUI Bot</h1>
|
||||
<div className="min-h-screen flex items-center justify-center px-4">
|
||||
<div className="glass-card p-8 w-full max-w-sm space-y-6">
|
||||
<div className="text-center">
|
||||
<h1 className="text-2xl font-bold tracking-tight">
|
||||
<span className="bg-gradient-to-r from-indigo-400 to-violet-400 bg-clip-text text-transparent">
|
||||
ComfyUI Bot
|
||||
</span>
|
||||
</h1>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400 mt-1">
|
||||
{isAdmin ? 'Admin login' : 'Sign in with your invite token'}
|
||||
</p>
|
||||
</div>
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1.5">
|
||||
{isAdmin ? 'Admin password' : 'Invite token'}
|
||||
</label>
|
||||
<input
|
||||
type="password"
|
||||
value={token}
|
||||
onChange={e => setToken(e.target.value)}
|
||||
className="w-full border border-gray-300 dark:border-gray-600 rounded px-3 py-2 text-sm bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
className="glass-input w-full"
|
||||
placeholder={isAdmin ? 'Password' : 'Paste your invite token'}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
{error && <p className="text-red-500 text-sm">{error}</p>}
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="w-full bg-blue-600 hover:bg-blue-700 disabled:opacity-50 text-white rounded px-4 py-2 text-sm font-medium transition-colors"
|
||||
>
|
||||
{error && (
|
||||
<p className="text-red-400 text-sm bg-red-500/10 border border-red-400/20 rounded-xl px-3 py-2">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
<button type="submit" disabled={loading} className="btn-primary w-full justify-center">
|
||||
{loading ? 'Logging in…' : 'Log in'}
|
||||
</button>
|
||||
</form>
|
||||
<button
|
||||
onClick={() => { setIsAdmin(a => !a); setError('') }}
|
||||
className="mt-4 text-xs text-gray-400 hover:text-gray-600 dark:hover:text-gray-200 w-full text-center"
|
||||
className="w-full text-center text-xs text-gray-400 hover:text-indigo-400 transition-colors"
|
||||
>
|
||||
{isAdmin ? 'Use invite token instead' : 'Admin login'}
|
||||
{isAdmin ? '← Use invite token instead' : 'Admin login →'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -50,28 +50,35 @@ export default function PresetsPage() {
|
||||
|
||||
return (
|
||||
<div className="max-w-xl mx-auto space-y-6">
|
||||
<h1 className="text-xl font-bold text-gray-800 dark:text-gray-100">Presets</h1>
|
||||
<h1 className="text-2xl font-bold tracking-tight">
|
||||
<span className="bg-gradient-to-r from-indigo-400 to-violet-400 bg-clip-text text-transparent">
|
||||
Presets
|
||||
</span>
|
||||
</h1>
|
||||
|
||||
{message && (
|
||||
<div className="text-sm text-blue-600 dark:text-blue-400">{message}</div>
|
||||
<div className="text-sm text-indigo-400 dark:text-indigo-300">{message}</div>
|
||||
)}
|
||||
|
||||
{/* Save current state */}
|
||||
<div className="space-y-2">
|
||||
<div className="glass-card p-4 space-y-3">
|
||||
<p className="text-xs font-semibold uppercase tracking-widest text-indigo-400/80 dark:text-indigo-300/80">
|
||||
Save current state
|
||||
</p>
|
||||
<div className="flex flex-col sm:flex-row gap-2">
|
||||
<input
|
||||
type="text"
|
||||
value={newName}
|
||||
onChange={e => setNewName(e.target.value)}
|
||||
placeholder="Preset name"
|
||||
className="flex-1 border border-gray-300 dark:border-gray-600 rounded px-3 py-2 text-sm bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
className="glass-input flex-1"
|
||||
/>
|
||||
<button
|
||||
onClick={() => { setSavingError(''); saveMut.mutate({ name: newName, description: newDescription }) }}
|
||||
disabled={!newName.trim() || saveMut.isPending}
|
||||
className="bg-blue-600 hover:bg-blue-700 disabled:opacity-50 text-white rounded px-3 py-2 text-sm font-medium"
|
||||
className="btn-primary"
|
||||
>
|
||||
Save current state
|
||||
Save
|
||||
</button>
|
||||
</div>
|
||||
<input
|
||||
@@ -79,10 +86,10 @@ export default function PresetsPage() {
|
||||
value={newDescription}
|
||||
onChange={e => setNewDescription(e.target.value)}
|
||||
placeholder="Description (optional)"
|
||||
className="w-full border border-gray-300 dark:border-gray-600 rounded px-3 py-2 text-sm bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
className="glass-input w-full"
|
||||
/>
|
||||
{savingError && <p className="text-red-400 text-sm">{savingError}</p>}
|
||||
</div>
|
||||
{savingError && <p className="text-red-500 text-sm">{savingError}</p>}
|
||||
|
||||
{/* Preset list */}
|
||||
{isLoading ? (
|
||||
@@ -90,32 +97,30 @@ export default function PresetsPage() {
|
||||
) : (data?.presets ?? []).length === 0 ? (
|
||||
<p className="text-sm text-gray-400">No presets saved yet.</p>
|
||||
) : (
|
||||
<ul className="divide-y divide-gray-200 dark:divide-gray-700 border border-gray-200 dark:border-gray-700 rounded">
|
||||
<div className="space-y-2">
|
||||
{(data?.presets ?? []).map((preset: PresetMeta) => (
|
||||
<li key={preset.name} className="px-3 py-2 space-y-1">
|
||||
<div key={preset.name} className="glass-card p-3 space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<button
|
||||
onClick={() => setExpanded(expanded === preset.name ? null : preset.name)}
|
||||
className="text-sm font-medium text-gray-700 dark:text-gray-300 hover:text-blue-600 dark:hover:text-blue-400 text-left"
|
||||
className="text-sm font-medium text-gray-700 dark:text-gray-200 hover:text-indigo-400 dark:hover:text-indigo-400 transition-colors text-left"
|
||||
>
|
||||
{preset.name}
|
||||
{preset.owner && (
|
||||
<span className="ml-2 text-xs text-gray-400 dark:text-gray-500 font-normal">
|
||||
{preset.owner}
|
||||
</span>
|
||||
<span className="ml-2 text-xs text-gray-400 font-normal">{preset.owner}</span>
|
||||
)}
|
||||
</button>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={() => { setMessage(null); loadMut.mutate(preset.name) }}
|
||||
disabled={loadMut.isPending}
|
||||
className="text-xs bg-green-600 hover:bg-green-700 disabled:opacity-50 text-white rounded px-2 py-1"
|
||||
className="btn-primary py-1 px-2.5 text-xs disabled:opacity-50"
|
||||
>
|
||||
Load
|
||||
</button>
|
||||
<button
|
||||
onClick={() => { setMessage(null); deleteMut.mutate(preset.name) }}
|
||||
className="text-xs bg-red-600 hover:bg-red-700 text-white rounded px-2 py-1"
|
||||
className="btn-danger"
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
@@ -127,9 +132,9 @@ export default function PresetsPage() {
|
||||
{expanded === preset.name && presetDetail && (
|
||||
<PresetDetail data={presetDetail} />
|
||||
)}
|
||||
</li>
|
||||
</div>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
@@ -141,11 +146,11 @@ function PresetDetail({ data }: { data: Record<string, unknown> }) {
|
||||
const hasOther = Object.keys(otherOverrides).length > 0
|
||||
|
||||
return (
|
||||
<div className="mt-2 space-y-2 text-xs">
|
||||
<div className="mt-2 space-y-2 text-xs border-t border-white/10 dark:border-white/5 pt-2">
|
||||
{prompt != null && (
|
||||
<div>
|
||||
<span className="font-semibold text-gray-600 dark:text-gray-400">Prompt</span>
|
||||
<p className="mt-0.5 bg-gray-50 dark:bg-gray-800 rounded p-2 text-gray-700 dark:text-gray-300 whitespace-pre-wrap break-words">
|
||||
<p className="mt-0.5 bg-white/10 dark:bg-white/5 rounded-xl p-2 text-gray-700 dark:text-gray-300 whitespace-pre-wrap break-words">
|
||||
{String(prompt)}
|
||||
</p>
|
||||
</div>
|
||||
@@ -153,7 +158,7 @@ function PresetDetail({ data }: { data: Record<string, unknown> }) {
|
||||
{negative_prompt != null && (
|
||||
<div>
|
||||
<span className="font-semibold text-gray-600 dark:text-gray-400">Negative prompt</span>
|
||||
<p className="mt-0.5 bg-gray-50 dark:bg-gray-800 rounded p-2 text-gray-700 dark:text-gray-300 whitespace-pre-wrap break-words">
|
||||
<p className="mt-0.5 bg-white/10 dark:bg-white/5 rounded-xl p-2 text-gray-700 dark:text-gray-300 whitespace-pre-wrap break-words">
|
||||
{String(negative_prompt)}
|
||||
</p>
|
||||
</div>
|
||||
@@ -173,7 +178,7 @@ function PresetDetail({ data }: { data: Record<string, unknown> }) {
|
||||
<table className="mt-0.5 w-full text-xs border-collapse">
|
||||
<tbody>
|
||||
{Object.entries(otherOverrides).map(([k, v]) => (
|
||||
<tr key={k} className="border-b border-gray-100 dark:border-gray-700">
|
||||
<tr key={k} className="border-b border-white/5 dark:border-white/5">
|
||||
<td className="py-0.5 pr-3 font-mono text-gray-500 dark:text-gray-400 whitespace-nowrap">{k}</td>
|
||||
<td className="py-0.5 text-gray-700 dark:text-gray-300 break-all">{JSON.stringify(v)}</td>
|
||||
</tr>
|
||||
@@ -183,11 +188,11 @@ function PresetDetail({ data }: { data: Record<string, unknown> }) {
|
||||
</div>
|
||||
)}
|
||||
{!!data.workflow && (
|
||||
<div className="text-green-600 dark:text-green-400">Includes workflow template</div>
|
||||
<div className="text-green-400">Includes workflow template</div>
|
||||
)}
|
||||
<details>
|
||||
<summary className="cursor-pointer text-gray-400 hover:text-gray-600 dark:hover:text-gray-300">Raw JSON</summary>
|
||||
<pre className="mt-1 text-xs bg-gray-100 dark:bg-gray-800 rounded p-2 overflow-auto max-h-48 text-gray-600 dark:text-gray-400">
|
||||
<summary className="cursor-pointer text-gray-400 hover:text-gray-300">Raw JSON</summary>
|
||||
<pre className="mt-1 text-xs bg-white/5 rounded-xl p-2 overflow-auto max-h-48 text-gray-400">
|
||||
{JSON.stringify(data, null, 2)}
|
||||
</pre>
|
||||
</details>
|
||||
|
||||
@@ -20,7 +20,6 @@ export default function ServerPage() {
|
||||
refetchInterval: 2000,
|
||||
})
|
||||
|
||||
// Auto-scroll log to bottom
|
||||
useEffect(() => {
|
||||
if (logRef.current) {
|
||||
logRef.current.scrollTop = logRef.current.scrollHeight
|
||||
@@ -37,22 +36,30 @@ export default function ServerPage() {
|
||||
})
|
||||
|
||||
const stateColor = srv?.service_state === 'SERVICE_RUNNING'
|
||||
? 'text-green-600 dark:text-green-400'
|
||||
? 'text-green-400'
|
||||
: srv?.service_state === 'SERVICE_STOPPED'
|
||||
? 'text-red-500 dark:text-red-400'
|
||||
: 'text-yellow-500'
|
||||
? 'text-red-400'
|
||||
: 'text-yellow-400'
|
||||
|
||||
return (
|
||||
<div className="max-w-2xl mx-auto space-y-6">
|
||||
<h1 className="text-xl font-bold text-gray-800 dark:text-gray-100">Server</h1>
|
||||
<h1 className="text-2xl font-bold tracking-tight">
|
||||
<span className="bg-gradient-to-r from-indigo-400 to-violet-400 bg-clip-text text-transparent">
|
||||
Server
|
||||
</span>
|
||||
</h1>
|
||||
|
||||
{/* Status */}
|
||||
<div className="bg-white dark:bg-gray-800 rounded border border-gray-200 dark:border-gray-700 p-4 text-sm space-y-1">
|
||||
<div className="glass-card p-4 text-sm space-y-2">
|
||||
<p className="text-xs font-semibold uppercase tracking-widest text-indigo-400/80 dark:text-indigo-300/80 mb-3">
|
||||
Service status
|
||||
</p>
|
||||
<p>
|
||||
State: <span className={`font-medium ${stateColor}`}>{srv?.service_state ?? '—'}</span>
|
||||
</p>
|
||||
<p>
|
||||
HTTP: <span className={srv?.http_reachable ? 'text-green-600 dark:text-green-400' : 'text-red-500'}>
|
||||
HTTP:{' '}
|
||||
<span className={srv?.http_reachable ? 'text-green-400' : 'text-red-400'}>
|
||||
{srv == null ? '—' : srv.http_reachable ? '✅ reachable' : '❌ unreachable'}
|
||||
</span>
|
||||
</p>
|
||||
@@ -65,32 +72,36 @@ export default function ServerPage() {
|
||||
key={a}
|
||||
onClick={() => { setActionMsg(null); actionMut.mutate(a) }}
|
||||
disabled={actionMut.isPending}
|
||||
className={`text-sm rounded px-3 py-2 font-medium disabled:opacity-50 transition-colors ${
|
||||
a === 'stop' ? 'bg-red-600 hover:bg-red-700 text-white'
|
||||
: a === 'restart' ? 'bg-yellow-500 hover:bg-yellow-600 text-white'
|
||||
: 'bg-green-600 hover:bg-green-700 text-white'
|
||||
className={`text-sm rounded-xl px-4 py-2 font-semibold transition-all duration-200 disabled:opacity-50 ${
|
||||
a === 'stop'
|
||||
? 'bg-red-600/80 hover:bg-red-600 text-white'
|
||||
: a === 'restart'
|
||||
? 'bg-yellow-500/80 hover:bg-yellow-500 text-white'
|
||||
: 'bg-green-600/80 hover:bg-green-600 text-white'
|
||||
}`}
|
||||
>
|
||||
{a.charAt(0).toUpperCase() + a.slice(1)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
{actionMsg && <p className="text-sm text-blue-600 dark:text-blue-400">{actionMsg}</p>}
|
||||
{actionMsg && <p className="text-sm text-indigo-400">{actionMsg}</p>}
|
||||
|
||||
{/* Log tail */}
|
||||
<div className="space-y-1">
|
||||
<div className="glass-card p-4 space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-sm font-medium text-gray-700 dark:text-gray-300">Log tail (last 200 lines)</p>
|
||||
<p className="text-xs font-semibold uppercase tracking-widest text-indigo-400/80 dark:text-indigo-300/80">
|
||||
Log tail (last 200 lines)
|
||||
</p>
|
||||
<button
|
||||
onClick={() => refetchLogs()}
|
||||
className="text-xs text-gray-400 hover:text-gray-600 dark:hover:text-gray-200"
|
||||
className="text-xs text-gray-400 hover:text-gray-300 transition-colors"
|
||||
>
|
||||
Refresh
|
||||
</button>
|
||||
</div>
|
||||
<pre
|
||||
ref={logRef}
|
||||
className="bg-gray-900 text-gray-100 text-xs rounded p-3 h-72 overflow-y-auto whitespace-pre-wrap font-mono"
|
||||
className="bg-black/40 text-gray-100 text-xs rounded-xl p-3 h-72 overflow-y-auto whitespace-pre-wrap font-mono"
|
||||
>
|
||||
{(logsData?.lines ?? []).join('\n') || 'No log lines available.'}
|
||||
</pre>
|
||||
|
||||
@@ -4,10 +4,6 @@ import { useQuery } from '@tanstack/react-query'
|
||||
import { getShareFileUrl } from '../api/client'
|
||||
|
||||
interface ShareData {
|
||||
prompt_id: string
|
||||
created_at: string
|
||||
overrides: Record<string, unknown>
|
||||
seed?: number
|
||||
images: Array<{ filename: string; data: string | null; mime_type: string }>
|
||||
}
|
||||
|
||||
@@ -29,7 +25,7 @@ export default function SharePage() {
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-gray-100 dark:bg-gray-900">
|
||||
<div className="min-h-screen flex items-center justify-center">
|
||||
<p className="text-sm text-gray-400">Loading…</p>
|
||||
</div>
|
||||
)
|
||||
@@ -39,13 +35,12 @@ export default function SharePage() {
|
||||
|
||||
if (status === 401) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-gray-100 dark:bg-gray-900">
|
||||
<div className="bg-white dark:bg-gray-800 shadow rounded-lg p-8 w-full max-w-sm text-center space-y-4">
|
||||
<p className="text-gray-700 dark:text-gray-300">You need to be logged in to view this shared link.</p>
|
||||
<Link
|
||||
to="/login"
|
||||
className="inline-block bg-blue-600 hover:bg-blue-700 text-white rounded px-4 py-2 text-sm font-medium transition-colors"
|
||||
>
|
||||
<div className="min-h-screen flex items-center justify-center px-4">
|
||||
<div className="glass-card p-8 w-full max-w-sm text-center space-y-4">
|
||||
<p className="text-gray-700 dark:text-gray-300">
|
||||
You need to log in to view this page.
|
||||
</p>
|
||||
<Link to={`/login?redirect=/share/${token}`} className="btn-primary inline-block">
|
||||
Log in
|
||||
</Link>
|
||||
</div>
|
||||
@@ -55,38 +50,19 @@ export default function SharePage() {
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-gray-100 dark:bg-gray-900">
|
||||
<div className="bg-white dark:bg-gray-800 shadow rounded-lg p-8 w-full max-w-sm text-center">
|
||||
<p className="text-gray-700 dark:text-gray-300">This share link has been revoked or does not exist.</p>
|
||||
<div className="min-h-screen flex items-center justify-center px-4">
|
||||
<div className="glass-card p-8 w-full max-w-sm text-center">
|
||||
<p className="text-gray-700 dark:text-gray-300">
|
||||
This share link has expired, been revoked, or does not exist.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-100 dark:bg-gray-900 py-8 px-4">
|
||||
<div className="max-w-3xl mx-auto space-y-6">
|
||||
<div className="bg-white dark:bg-gray-800 shadow rounded-lg p-6 space-y-4">
|
||||
<h1 className="text-xl font-bold text-gray-800 dark:text-gray-100">Shared Generation</h1>
|
||||
|
||||
<div className="text-sm text-gray-500 dark:text-gray-400 space-y-1">
|
||||
<p>Generated: {data && new Date(data.created_at).toLocaleString()}</p>
|
||||
{data?.seed != null && (
|
||||
<p>Seed: <span className="font-mono">{data.seed}</span></p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{data && Object.keys(data.overrides).length > 0 && (
|
||||
<details>
|
||||
<summary className="text-xs text-gray-400 cursor-pointer">Overrides</summary>
|
||||
<pre className="text-xs bg-gray-100 dark:bg-gray-700 rounded p-2 mt-1 overflow-auto max-h-40 text-gray-600 dark:text-gray-400">
|
||||
{JSON.stringify(data.overrides, null, 2)}
|
||||
</pre>
|
||||
</details>
|
||||
)}
|
||||
|
||||
{/* Images / Videos */}
|
||||
<div className="flex gap-3 flex-wrap">
|
||||
<div className="min-h-screen flex items-center justify-center py-8 px-4">
|
||||
<div className="max-w-3xl w-full flex flex-col items-center gap-4">
|
||||
{(data?.images ?? []).map((img, i) => {
|
||||
if (img.mime_type.startsWith('video/')) {
|
||||
return (
|
||||
@@ -94,7 +70,7 @@ export default function SharePage() {
|
||||
key={i}
|
||||
src={getShareFileUrl(token!, img.filename)}
|
||||
controls
|
||||
className="rounded max-h-80 max-w-full"
|
||||
className="rounded-xl max-w-full shadow-lg"
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -102,18 +78,12 @@ export default function SharePage() {
|
||||
<img
|
||||
key={i}
|
||||
src={`data:${img.mime_type};base64,${img.data}`}
|
||||
alt={img.filename}
|
||||
className="rounded max-h-80 max-w-full object-contain border border-gray-200 dark:border-gray-700"
|
||||
alt=""
|
||||
className="rounded-xl max-w-full object-contain shadow-lg"
|
||||
/>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p className="text-center text-xs text-gray-400">
|
||||
<Link to="/login" className="hover:underline">ComfyUI Bot</Link>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,45 +1,147 @@
|
||||
import React from 'react'
|
||||
import React, { useState, useEffect } from 'react'
|
||||
import { useAuth } from '../hooks/useAuth'
|
||||
import { useStatus } from '../hooks/useStatus'
|
||||
import { useGeneration } from '../context/GenerationContext'
|
||||
|
||||
export default function StatusPage() {
|
||||
const { user } = useAuth()
|
||||
const { status, executingNode } = useStatus({ enabled: !!user })
|
||||
const { status, executingNode, connected, lastUpdatedAt } = useStatus({ enabled: !!user })
|
||||
const { pendingCount } = useGeneration()
|
||||
|
||||
const { bot, comfy, service, upload } = status
|
||||
|
||||
const [secondsAgo, setSecondsAgo] = useState<number | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
const t = setInterval(() => {
|
||||
if (lastUpdatedAt !== null) {
|
||||
setSecondsAgo(Math.floor((Date.now() - lastUpdatedAt) / 1000))
|
||||
}
|
||||
}, 1000)
|
||||
return () => clearInterval(t)
|
||||
}, [lastUpdatedAt])
|
||||
|
||||
const isGenerating = executingNode !== null || pendingCount > 0
|
||||
const queueTotal = (comfy?.queue_running ?? 0) + (comfy?.queue_pending ?? 0)
|
||||
|
||||
const latencyColor =
|
||||
!bot ? 'text-gray-400' :
|
||||
bot.latency_ms < 200 ? 'text-green-400' :
|
||||
bot.latency_ms < 500 ? 'text-yellow-400' : 'text-red-400'
|
||||
|
||||
const comfyReachable = service?.http_reachable
|
||||
const promptRaw = status.overrides?.prompt ? String(status.overrides.prompt) : null
|
||||
const promptPreview = promptRaw
|
||||
? promptRaw.slice(0, 80) + (promptRaw.length > 80 ? '…' : '')
|
||||
: null
|
||||
|
||||
return (
|
||||
<div className="max-w-2xl mx-auto space-y-6">
|
||||
<h1 className="text-xl font-bold text-gray-800 dark:text-gray-100">Status</h1>
|
||||
<div className="max-w-2xl mx-auto space-y-4">
|
||||
<h1 className="text-2xl font-bold tracking-tight">
|
||||
<span className="bg-gradient-to-r from-indigo-400 to-violet-400 bg-clip-text text-transparent">
|
||||
Status
|
||||
</span>
|
||||
</h1>
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
{/* Freshness bar */}
|
||||
<div className="glass-card p-3 flex items-center justify-between">
|
||||
<span className={`text-sm font-medium ${connected ? 'text-green-400' : 'text-amber-400'}`}>
|
||||
{connected ? '● Connected' : '○ Reconnecting…'}
|
||||
</span>
|
||||
<span className="text-xs text-gray-500">
|
||||
{secondsAgo !== null ? `Updated ${secondsAgo}s ago` : 'Waiting for data…'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Bot */}
|
||||
<Card title="Bot">
|
||||
<Row label="Latency" value={bot ? `${bot.latency_ms} ms` : '—'} />
|
||||
<Row label="Uptime" value={bot?.uptime ?? '—'} />
|
||||
</Card>
|
||||
{/* Active generation card */}
|
||||
{isGenerating && (
|
||||
<div className="glass-card p-4 border-l-4 border-indigo-400">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<svg
|
||||
className="w-4 h-4 text-indigo-400 animate-spin shrink-0"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
|
||||
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
|
||||
</svg>
|
||||
<span className="text-sm font-semibold text-indigo-300">Generating…</span>
|
||||
</div>
|
||||
<p className="font-mono text-lg text-gray-200 break-all">
|
||||
{executingNode ?? 'Waiting in queue'}
|
||||
</p>
|
||||
{pendingCount > 0 && (
|
||||
<p className="text-xs text-gray-400 mt-1">{pendingCount} pending in queue</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 3 big-number tiles */}
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
{/* Queue */}
|
||||
<div className="glass-card p-4 text-center">
|
||||
<p className="text-xs font-semibold uppercase tracking-widest text-gray-500 mb-2">Queue</p>
|
||||
<p className={`text-4xl font-bold ${queueTotal > 0 ? 'text-indigo-400' : 'text-gray-500'}`}>
|
||||
{queueTotal}
|
||||
</p>
|
||||
<p className="text-xs text-gray-500 mt-2">
|
||||
{comfy?.queue_running ?? 0}r / {comfy?.queue_pending ?? 0}p
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Latency */}
|
||||
<div className="glass-card p-4 text-center">
|
||||
<p className="text-xs font-semibold uppercase tracking-widest text-gray-500 mb-2">Latency</p>
|
||||
<p className={`text-4xl font-bold ${latencyColor}`}>
|
||||
{bot ? bot.latency_ms : '—'}
|
||||
</p>
|
||||
<p className="text-xs text-gray-500 mt-2">ms · bot</p>
|
||||
</div>
|
||||
|
||||
{/* ComfyUI */}
|
||||
<Card title="ComfyUI">
|
||||
<Row label="Server" value={comfy?.server ?? '—'} />
|
||||
<Row
|
||||
label="Reachable"
|
||||
value={comfy?.reachable == null ? '—' : comfy.reachable ? '✅ yes' : '❌ no'}
|
||||
/>
|
||||
<Row label="Queue running" value={String(comfy?.queue_running ?? 0)} />
|
||||
<Row label="Queue pending" value={String(comfy?.queue_pending ?? 0)} />
|
||||
<Row label="Workflow loaded" value={comfy?.workflow_loaded ? '✓' : '✗'} />
|
||||
<Row label="Last seed" value={comfy?.last_seed != null ? String(comfy.last_seed) : '—'} />
|
||||
<div className="glass-card p-4 text-center">
|
||||
<p className="text-xs font-semibold uppercase tracking-widest text-gray-500 mb-2">ComfyUI</p>
|
||||
{comfyReachable == null ? (
|
||||
<p className="text-lg font-bold text-gray-500 mt-1">—</p>
|
||||
) : comfyReachable ? (
|
||||
<p className="text-base font-bold text-green-400 mt-1">● reachable</p>
|
||||
) : (
|
||||
<p className="text-base font-bold text-red-400 mt-1">● unreachable</p>
|
||||
)}
|
||||
<p className="text-xs text-gray-500 mt-2 truncate">{comfy?.server ?? '—'}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Workflow context card */}
|
||||
{(comfy != null || promptPreview) && (
|
||||
<div className="glass-card p-4 space-y-2">
|
||||
<p className="text-xs font-semibold uppercase tracking-widest text-indigo-400/80">Workflow</p>
|
||||
<p className="text-sm text-gray-300">
|
||||
{comfy?.workflow_loaded ? '✓ Workflow loaded' : '✗ No workflow loaded'}
|
||||
</p>
|
||||
{promptPreview && (
|
||||
<p className="text-sm text-gray-400 font-mono break-all">
|
||||
<span className="text-gray-500 mr-1">prompt:</span>
|
||||
{promptPreview}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Detail cards */}
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<Card title="Bot">
|
||||
<Row label="Uptime" value={bot?.uptime ?? '—'} />
|
||||
<Row label="Total generated" value={String(comfy?.total_generated ?? 0)} />
|
||||
<Row label="Last seed" value={comfy?.last_seed != null ? String(comfy.last_seed) : '—'} />
|
||||
</Card>
|
||||
|
||||
{/* Service */}
|
||||
<Card title="Service">
|
||||
<Row label="State" value={service?.state ?? '—'} />
|
||||
<Row label="Workflow" value={comfy?.workflow_loaded ? '✓ loaded' : '✗ not loaded'} />
|
||||
<Row label="Server" value={comfy?.server ?? '—'} />
|
||||
</Card>
|
||||
|
||||
{/* Auto-upload */}
|
||||
<Card title="Auto-upload">
|
||||
<Row label="Configured" value={upload?.configured ? '✓' : '✗'} />
|
||||
<Row label="Running" value={upload?.running ? '⏳ yes' : 'idle'} />
|
||||
@@ -47,21 +149,16 @@ export default function StatusPage() {
|
||||
<Row label="Total fail" value={String(upload?.total_fail ?? 0)} />
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Executing node */}
|
||||
{executingNode && (
|
||||
<div className="bg-blue-50 dark:bg-blue-900/30 border border-blue-200 dark:border-blue-700 rounded p-3 text-sm text-blue-700 dark:text-blue-300">
|
||||
Executing node: <strong>{executingNode}</strong>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function Card({ title, children }: { title: string; children: React.ReactNode }) {
|
||||
return (
|
||||
<div className="bg-white dark:bg-gray-800 rounded border border-gray-200 dark:border-gray-700 p-4">
|
||||
<p className="text-xs font-semibold uppercase tracking-wide text-gray-400 dark:text-gray-500 mb-2">{title}</p>
|
||||
<div className="glass-card p-4">
|
||||
<p className="text-xs font-semibold uppercase tracking-widest text-indigo-400/80 dark:text-indigo-300/80 mb-3">
|
||||
{title}
|
||||
</p>
|
||||
<dl className="space-y-1">{children}</dl>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -60,15 +60,21 @@ export default function WorkflowPage() {
|
||||
|
||||
return (
|
||||
<div className="max-w-2xl mx-auto space-y-6">
|
||||
<h1 className="text-xl font-bold text-gray-800 dark:text-gray-100">Workflow</h1>
|
||||
<h1 className="text-2xl font-bold tracking-tight">
|
||||
<span className="bg-gradient-to-r from-indigo-400 to-violet-400 bg-clip-text text-transparent">
|
||||
Workflow
|
||||
</span>
|
||||
</h1>
|
||||
|
||||
{/* Current workflow */}
|
||||
<div className="bg-white dark:bg-gray-800 rounded border border-gray-200 dark:border-gray-700 p-4 text-sm space-y-1">
|
||||
<p className="font-medium text-gray-700 dark:text-gray-300">Current workflow</p>
|
||||
<div className="glass-card p-4 text-sm space-y-1">
|
||||
<p className="text-xs font-semibold uppercase tracking-widest text-indigo-400/80 dark:text-indigo-300/80 mb-2">
|
||||
Current workflow
|
||||
</p>
|
||||
{wf?.loaded ? (
|
||||
<>
|
||||
<p className="text-gray-500 dark:text-gray-400">{wf.last_workflow_file ?? '(loaded from state)'}</p>
|
||||
<p className="text-gray-500 dark:text-gray-400">{wf.node_count} node(s) detected</p>
|
||||
<p className="text-gray-600 dark:text-gray-400">{wf.last_workflow_file ?? '(loaded from state)'}</p>
|
||||
<p className="text-gray-500 dark:text-gray-500">{wf.node_count} node(s) detected</p>
|
||||
</>
|
||||
) : (
|
||||
<p className="text-gray-400">No workflow loaded</p>
|
||||
@@ -76,12 +82,12 @@ export default function WorkflowPage() {
|
||||
</div>
|
||||
|
||||
{message && (
|
||||
<div className="text-sm text-blue-600 dark:text-blue-400">{message}</div>
|
||||
<div className="text-sm text-indigo-400 dark:text-indigo-300">{message}</div>
|
||||
)}
|
||||
|
||||
{/* Upload */}
|
||||
<div className="flex items-center gap-3">
|
||||
<label className="cursor-pointer text-sm bg-blue-600 hover:bg-blue-700 text-white rounded px-3 py-2">
|
||||
<label className="cursor-pointer btn-primary">
|
||||
{uploading ? 'Uploading…' : 'Upload workflow JSON'}
|
||||
<input
|
||||
ref={fileRef}
|
||||
@@ -91,54 +97,74 @@ export default function WorkflowPage() {
|
||||
onChange={handleUpload}
|
||||
/>
|
||||
</label>
|
||||
<span className="text-xs text-gray-400">Uploads to workflows/ folder</span>
|
||||
<span className="text-xs text-gray-400">Saves to workflows/ folder</span>
|
||||
</div>
|
||||
|
||||
{/* Available files */}
|
||||
{(filesData?.files ?? []).length > 0 && (
|
||||
<div className="space-y-2">
|
||||
<p className="text-sm font-medium text-gray-700 dark:text-gray-300">Available workflows</p>
|
||||
<ul className="divide-y divide-gray-200 dark:divide-gray-700 border border-gray-200 dark:border-gray-700 rounded">
|
||||
{(filesData?.files ?? []).map(f => (
|
||||
<li key={f} className="flex items-center justify-between px-3 py-2 text-sm">
|
||||
<span className={`text-gray-700 dark:text-gray-300 ${wf?.last_workflow_file === f ? 'font-semibold text-blue-600 dark:text-blue-400' : ''}`}>
|
||||
<p className="text-xs font-semibold uppercase tracking-widest text-indigo-400/80 dark:text-indigo-300/80">
|
||||
Available workflows
|
||||
</p>
|
||||
<div className="glass-card overflow-hidden p-0">
|
||||
{(filesData?.files ?? []).map((f, idx) => (
|
||||
<div
|
||||
key={f}
|
||||
className={`flex items-center justify-between px-4 py-2.5 text-sm ${
|
||||
idx < (filesData?.files ?? []).length - 1
|
||||
? 'border-b border-white/10 dark:border-white/5'
|
||||
: ''
|
||||
}`}
|
||||
>
|
||||
<span className={`text-gray-700 dark:text-gray-300 ${
|
||||
wf?.last_workflow_file === f ? 'font-semibold text-indigo-400' : ''
|
||||
}`}>
|
||||
{f}
|
||||
{wf?.last_workflow_file === f && <span className="ml-1 text-xs">(active)</span>}
|
||||
{wf?.last_workflow_file === f && (
|
||||
<span className="ml-1.5 text-xs text-indigo-400/70">(active)</span>
|
||||
)}
|
||||
</span>
|
||||
<button
|
||||
onClick={() => { setLoadingFile(f); setMessage(null); loadMut.mutate(f) }}
|
||||
disabled={loadingFile === f}
|
||||
className="text-xs bg-gray-200 dark:bg-gray-700 hover:bg-gray-300 dark:hover:bg-gray-600 rounded px-2 py-1 disabled:opacity-50"
|
||||
className="btn-secondary text-xs py-1 px-2 disabled:opacity-50"
|
||||
>
|
||||
{loadingFile === f ? 'Loading…' : 'Load'}
|
||||
</button>
|
||||
</li>
|
||||
</div>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Discovered inputs summary */}
|
||||
{inputs && allInputs.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
<p className="text-sm font-medium text-gray-700 dark:text-gray-300">Discovered inputs ({allInputs.length})</p>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-xs border-collapse border border-gray-200 dark:border-gray-700 rounded">
|
||||
<p className="text-xs font-semibold uppercase tracking-widest text-indigo-400/80 dark:text-indigo-300/80">
|
||||
Discovered inputs ({allInputs.length})
|
||||
</p>
|
||||
<div className="glass-card overflow-hidden p-0 overflow-x-auto">
|
||||
<table className="w-full text-xs border-collapse">
|
||||
<thead>
|
||||
<tr className="bg-gray-100 dark:bg-gray-700">
|
||||
<th className="text-left p-2 border-b border-gray-200 dark:border-gray-600">Key</th>
|
||||
<th className="text-left p-2 border-b border-gray-200 dark:border-gray-600">Label</th>
|
||||
<th className="text-left p-2 border-b border-gray-200 dark:border-gray-600">Type</th>
|
||||
<th className="text-left p-2 border-b border-gray-200 dark:border-gray-600">Common</th>
|
||||
<tr className="bg-white/20 dark:bg-white/5">
|
||||
<th className="text-left p-2.5 border-b border-white/10 dark:border-white/5 font-medium text-gray-600 dark:text-gray-400">Key</th>
|
||||
<th className="text-left p-2.5 border-b border-white/10 dark:border-white/5 font-medium text-gray-600 dark:text-gray-400">Label</th>
|
||||
<th className="text-left p-2.5 border-b border-white/10 dark:border-white/5 font-medium text-gray-600 dark:text-gray-400">Type</th>
|
||||
<th className="text-left p-2.5 border-b border-white/10 dark:border-white/5 font-medium text-gray-600 dark:text-gray-400">Common</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{allInputs.map(inp => (
|
||||
<tr key={inp.key} className="border-b border-gray-100 dark:border-gray-700 last:border-0">
|
||||
<td className="p-2 font-mono text-gray-600 dark:text-gray-400">{inp.key}</td>
|
||||
<td className="p-2 text-gray-700 dark:text-gray-300">{inp.label}</td>
|
||||
<td className="p-2 text-gray-500 dark:text-gray-400">{inp.input_type}</td>
|
||||
<td className="p-2 text-gray-500 dark:text-gray-400">{inp.is_common ? '✓' : ''}</td>
|
||||
{allInputs.map((inp, idx) => (
|
||||
<tr
|
||||
key={inp.key}
|
||||
className={`hover:bg-white/10 dark:hover:bg-white/5 transition-colors ${
|
||||
idx < allInputs.length - 1 ? 'border-b border-white/5 dark:border-white/5' : ''
|
||||
}`}
|
||||
>
|
||||
<td className="p-2.5 font-mono text-gray-600 dark:text-gray-400">{inp.key}</td>
|
||||
<td className="p-2.5 text-gray-700 dark:text-gray-300">{inp.label}</td>
|
||||
<td className="p-2.5 text-gray-500 dark:text-gray-400">{inp.input_type}</td>
|
||||
<td className="p-2.5 text-gray-500 dark:text-gray-400">{inp.is_common ? '✓' : ''}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
|
||||
@@ -3,7 +3,22 @@ export default {
|
||||
content: ['./index.html', './src/**/*.{js,ts,jsx,tsx}'],
|
||||
darkMode: 'class',
|
||||
theme: {
|
||||
extend: {},
|
||||
extend: {
|
||||
animation: {
|
||||
'fade-in': 'fade-in 0.25s ease-out',
|
||||
'slide-up': 'slide-up 0.3s ease-out',
|
||||
},
|
||||
keyframes: {
|
||||
'fade-in': {
|
||||
from: { opacity: '0', transform: 'translateY(8px)' },
|
||||
to: { opacity: '1', transform: 'translateY(0)' },
|
||||
},
|
||||
'slide-up': {
|
||||
from: { opacity: '0', transform: 'translateY(16px)' },
|
||||
to: { opacity: '1', transform: 'translateY(0)' },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
plugins: [],
|
||||
}
|
||||
|
||||
218
generation_db.py
218
generation_db.py
@@ -48,11 +48,30 @@ CREATE TABLE IF NOT EXISTS generation_shares (
|
||||
share_token TEXT UNIQUE NOT NULL,
|
||||
prompt_id TEXT NOT NULL,
|
||||
owner_label TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL
|
||||
created_at TEXT NOT NULL,
|
||||
is_public INTEGER NOT NULL DEFAULT 0,
|
||||
expires_at TEXT,
|
||||
max_views INTEGER,
|
||||
view_count INTEGER NOT NULL DEFAULT 0
|
||||
);
|
||||
"""
|
||||
|
||||
|
||||
def _migrate_shares_table(conn: sqlite3.Connection) -> None:
|
||||
migrations = [
|
||||
"ALTER TABLE generation_shares ADD COLUMN is_public INTEGER NOT NULL DEFAULT 0",
|
||||
"ALTER TABLE generation_shares ADD COLUMN expires_at TEXT",
|
||||
"ALTER TABLE generation_shares ADD COLUMN max_views INTEGER",
|
||||
"ALTER TABLE generation_shares ADD COLUMN view_count INTEGER NOT NULL DEFAULT 0",
|
||||
]
|
||||
for sql in migrations:
|
||||
try:
|
||||
conn.execute(sql)
|
||||
except sqlite3.OperationalError:
|
||||
pass # column already exists
|
||||
conn.commit()
|
||||
|
||||
|
||||
def _connect(db_path: Path | None = None) -> sqlite3.Connection:
|
||||
path = db_path if db_path is not None else _DB_PATH
|
||||
conn = sqlite3.connect(str(path), check_same_thread=False)
|
||||
@@ -84,6 +103,7 @@ def init_db(db_path: Path = _DB_PATH) -> None:
|
||||
with _connect(db_path) as conn:
|
||||
conn.executescript(_SCHEMA)
|
||||
conn.commit()
|
||||
_migrate_shares_table(conn)
|
||||
|
||||
|
||||
def record_generation(
|
||||
@@ -109,11 +129,11 @@ def record_generation(
|
||||
return cur.lastrowid # type: ignore[return-value]
|
||||
|
||||
|
||||
def record_file(generation_id: int, filename: str, file_data: bytes) -> None:
|
||||
"""Insert a file BLOB row, auto-detecting MIME type from magic bytes."""
|
||||
def record_file(generation_id: int, filename: str, file_data: bytes) -> int:
|
||||
"""Insert a file BLOB row, auto-detecting MIME type from magic bytes. Returns the row id."""
|
||||
mime_type = _detect_mime(file_data)
|
||||
with _connect() as conn:
|
||||
conn.execute(
|
||||
cur = conn.execute(
|
||||
"""
|
||||
INSERT INTO generation_files (generation_id, filename, file_data, mime_type)
|
||||
VALUES (?, ?, ?, ?)
|
||||
@@ -121,6 +141,7 @@ def record_file(generation_id: int, filename: str, file_data: bytes) -> None:
|
||||
(generation_id, filename, file_data, mime_type),
|
||||
)
|
||||
conn.commit()
|
||||
return cur.lastrowid # type: ignore[return-value]
|
||||
|
||||
|
||||
def _rows_to_history(conn: sqlite3.Connection, rows) -> list[dict]:
|
||||
@@ -151,7 +172,9 @@ def get_history(limit: int = 50) -> list[dict]:
|
||||
rows = conn.execute(
|
||||
"""
|
||||
SELECT h.id, h.prompt_id, h.source, h.user_label, h.overrides, h.seed, h.created_at,
|
||||
s.share_token
|
||||
s.share_token, s.is_public AS share_is_public,
|
||||
s.expires_at AS share_expires_at, s.max_views AS share_max_views,
|
||||
s.view_count AS share_view_count
|
||||
FROM generation_history h
|
||||
LEFT JOIN generation_shares s ON h.prompt_id = s.prompt_id AND s.owner_label = h.user_label
|
||||
ORDER BY h.id DESC LIMIT ?
|
||||
@@ -167,7 +190,9 @@ def get_history_for_user(user_label: str, limit: int = 50) -> list[dict]:
|
||||
rows = conn.execute(
|
||||
"""
|
||||
SELECT h.id, h.prompt_id, h.source, h.user_label, h.overrides, h.seed, h.created_at,
|
||||
s.share_token
|
||||
s.share_token, s.is_public AS share_is_public,
|
||||
s.expires_at AS share_expires_at, s.max_views AS share_max_views,
|
||||
s.view_count AS share_view_count
|
||||
FROM generation_history h
|
||||
LEFT JOIN generation_shares s ON h.prompt_id = s.prompt_id AND s.owner_label = ?
|
||||
WHERE h.user_label = ?
|
||||
@@ -214,7 +239,9 @@ def search_history_for_user(user_label: str, query: str, limit: int = 50) -> lis
|
||||
rows = conn.execute(
|
||||
"""
|
||||
SELECT h.id, h.prompt_id, h.source, h.user_label, h.overrides, h.seed, h.created_at,
|
||||
s.share_token
|
||||
s.share_token, s.is_public AS share_is_public,
|
||||
s.expires_at AS share_expires_at, s.max_views AS share_max_views,
|
||||
s.view_count AS share_view_count
|
||||
FROM generation_history h
|
||||
LEFT JOIN generation_shares s ON h.prompt_id = s.prompt_id AND s.owner_label = ?
|
||||
WHERE h.user_label = ? AND LOWER(h.overrides) LIKE LOWER(?)
|
||||
@@ -231,7 +258,9 @@ def search_history(query: str, limit: int = 50) -> list[dict]:
|
||||
rows = conn.execute(
|
||||
"""
|
||||
SELECT h.id, h.prompt_id, h.source, h.user_label, h.overrides, h.seed, h.created_at,
|
||||
s.share_token
|
||||
s.share_token, s.is_public AS share_is_public,
|
||||
s.expires_at AS share_expires_at, s.max_views AS share_max_views,
|
||||
s.view_count AS share_view_count
|
||||
FROM generation_history h
|
||||
LEFT JOIN generation_shares s ON h.prompt_id = s.prompt_id AND s.owner_label = h.user_label
|
||||
WHERE LOWER(h.overrides) LIKE LOWER(?)
|
||||
@@ -242,43 +271,111 @@ def search_history(query: str, limit: int = 50) -> list[dict]:
|
||||
return _rows_to_history(conn, rows)
|
||||
|
||||
|
||||
def create_share(prompt_id: str, owner_label: str) -> str:
|
||||
"""Create a share token for *prompt_id*. Idempotent — returns the same token if one exists."""
|
||||
def _is_share_expired(share_row: dict) -> bool:
|
||||
"""Return True if the share has passed its time or view limits."""
|
||||
if share_row["expires_at"] and datetime.fromisoformat(share_row["expires_at"]) <= datetime.now(timezone.utc):
|
||||
return True
|
||||
if share_row["max_views"] is not None and share_row["view_count"] >= share_row["max_views"]:
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def _is_share_streaming_expired(share_row: dict) -> bool:
|
||||
"""Expiry check for file-streaming calls (no view_count increment).
|
||||
Uses strict-greater-than so that files remain accessible within the
|
||||
same page view that just consumed the last allowed view."""
|
||||
if share_row["expires_at"] and datetime.fromisoformat(share_row["expires_at"]) <= datetime.now(timezone.utc):
|
||||
return True
|
||||
if share_row["max_views"] is not None and share_row["view_count"] > share_row["max_views"]:
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def get_active_share_for_prompt(prompt_id: str, owner_label: str) -> dict | None:
|
||||
"""Return the active (non-expired) share row for *prompt_id*+*owner_label*, or None.
|
||||
|
||||
If a row exists but is already expired, it is auto-deleted and None is returned
|
||||
so the caller can create a new share immediately.
|
||||
"""
|
||||
with _connect() as conn:
|
||||
row = conn.execute(
|
||||
"""
|
||||
SELECT share_token, is_public, expires_at, max_views, view_count
|
||||
FROM generation_shares
|
||||
WHERE prompt_id = ? AND owner_label = ?
|
||||
""",
|
||||
(prompt_id, owner_label),
|
||||
).fetchone()
|
||||
if row is None:
|
||||
return None
|
||||
d = dict(row)
|
||||
if _is_share_expired(d):
|
||||
conn.execute(
|
||||
"DELETE FROM generation_shares WHERE share_token = ?",
|
||||
(d["share_token"],),
|
||||
)
|
||||
conn.commit()
|
||||
return None
|
||||
return d
|
||||
|
||||
|
||||
def create_share(
|
||||
prompt_id: str,
|
||||
owner_label: str,
|
||||
*,
|
||||
is_public: bool = False,
|
||||
expires_at: str | None = None,
|
||||
max_views: int | None = None,
|
||||
) -> dict:
|
||||
"""Insert a fresh share row. Returns dict with share_token, is_public, expires_at, max_views."""
|
||||
token = secrets.token_urlsafe(32)
|
||||
created_at = datetime.now(timezone.utc).isoformat()
|
||||
with _connect() as conn:
|
||||
conn.execute(
|
||||
"""
|
||||
INSERT OR IGNORE INTO generation_shares (share_token, prompt_id, owner_label, created_at)
|
||||
VALUES (?, ?, ?, ?)
|
||||
INSERT INTO generation_shares
|
||||
(share_token, prompt_id, owner_label, created_at, is_public, expires_at, max_views, view_count)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, 0)
|
||||
""",
|
||||
(token, prompt_id, owner_label, created_at),
|
||||
(token, prompt_id, owner_label, created_at, int(is_public), expires_at, max_views),
|
||||
)
|
||||
conn.commit()
|
||||
row = conn.execute(
|
||||
"SELECT share_token FROM generation_shares WHERE prompt_id = ? AND owner_label = ?",
|
||||
(prompt_id, owner_label),
|
||||
"SELECT share_token, is_public, expires_at, max_views FROM generation_shares WHERE share_token = ?",
|
||||
(token,),
|
||||
).fetchone()
|
||||
return row["share_token"]
|
||||
return dict(row)
|
||||
|
||||
|
||||
def revoke_share(prompt_id: str, owner_label: str) -> bool:
|
||||
"""Delete the share token for *prompt_id*. Returns True if a row was deleted."""
|
||||
def revoke_share(prompt_id: str, owner_label: str | None = None) -> bool:
|
||||
"""Delete the share token for *prompt_id*.
|
||||
|
||||
If *owner_label* is provided, only delete that user's share.
|
||||
If None (admin), delete any share for the prompt_id.
|
||||
Returns True if a row was deleted.
|
||||
"""
|
||||
with _connect() as conn:
|
||||
if owner_label is not None:
|
||||
cur = conn.execute(
|
||||
"DELETE FROM generation_shares WHERE prompt_id = ? AND owner_label = ?",
|
||||
(prompt_id, owner_label),
|
||||
)
|
||||
else:
|
||||
cur = conn.execute(
|
||||
"DELETE FROM generation_shares WHERE prompt_id = ?",
|
||||
(prompt_id,),
|
||||
)
|
||||
conn.commit()
|
||||
return cur.rowcount > 0
|
||||
|
||||
|
||||
def get_share_by_token(token: str) -> dict | None:
|
||||
"""Return generation info for a share token, or None if not found/revoked."""
|
||||
"""Return generation info for a share token (incrementing view_count), or None if not found/expired."""
|
||||
with _connect() as conn:
|
||||
row = conn.execute(
|
||||
"""
|
||||
SELECT h.prompt_id, h.overrides, h.seed, h.created_at
|
||||
SELECT s.share_token, s.is_public, s.expires_at, s.max_views, s.view_count,
|
||||
h.prompt_id, h.overrides, h.seed, h.created_at
|
||||
FROM generation_shares s
|
||||
JOIN generation_history h ON h.prompt_id = s.prompt_id
|
||||
WHERE s.share_token = ?
|
||||
@@ -288,6 +385,16 @@ def get_share_by_token(token: str) -> dict | None:
|
||||
if row is None:
|
||||
return None
|
||||
d = dict(row)
|
||||
if _is_share_expired(d):
|
||||
conn.execute("DELETE FROM generation_shares WHERE share_token = ?", (token,))
|
||||
conn.commit()
|
||||
return None
|
||||
# Increment view count
|
||||
conn.execute(
|
||||
"UPDATE generation_shares SET view_count = view_count + 1 WHERE share_token = ?",
|
||||
(token,),
|
||||
)
|
||||
conn.commit()
|
||||
if d["overrides"]:
|
||||
try:
|
||||
d["overrides"] = json.loads(d["overrides"])
|
||||
@@ -298,6 +405,77 @@ def get_share_by_token(token: str) -> dict | None:
|
||||
return d
|
||||
|
||||
|
||||
def get_share_meta(token: str) -> dict | None:
|
||||
"""Return share metadata without incrementing view_count. Used by file-streaming endpoints."""
|
||||
with _connect() as conn:
|
||||
row = conn.execute(
|
||||
"""
|
||||
SELECT s.share_token, s.is_public, s.expires_at, s.max_views, s.view_count,
|
||||
h.prompt_id, h.overrides, h.seed, h.created_at
|
||||
FROM generation_shares s
|
||||
JOIN generation_history h ON h.prompt_id = s.prompt_id
|
||||
WHERE s.share_token = ?
|
||||
""",
|
||||
(token,),
|
||||
).fetchone()
|
||||
if row is None:
|
||||
return None
|
||||
d = dict(row)
|
||||
if _is_share_streaming_expired(d):
|
||||
conn.execute("DELETE FROM generation_shares WHERE share_token = ?", (token,))
|
||||
conn.commit()
|
||||
return None
|
||||
if d["overrides"]:
|
||||
try:
|
||||
d["overrides"] = json.loads(d["overrides"])
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
d["overrides"] = {}
|
||||
else:
|
||||
d["overrides"] = {}
|
||||
return d
|
||||
|
||||
|
||||
def get_file_ids_for_prompt(prompt_id: str) -> list[int]:
|
||||
"""Return generation_files.id values for all files belonging to prompt_id."""
|
||||
with _connect() as conn:
|
||||
rows = conn.execute(
|
||||
"""SELECT gf.id FROM generation_files gf
|
||||
JOIN generation_history gh ON gh.id = gf.generation_id
|
||||
WHERE gh.prompt_id = ?""",
|
||||
(prompt_id,),
|
||||
).fetchall()
|
||||
return [r["id"] for r in rows]
|
||||
|
||||
|
||||
def get_generation_ids_for_file_ids(file_ids: list[int]) -> list[int]:
|
||||
"""Return distinct generation_id values for the given generation_files row ids."""
|
||||
if not file_ids:
|
||||
return []
|
||||
placeholders = ",".join("?" * len(file_ids))
|
||||
with _connect() as conn:
|
||||
rows = conn.execute(
|
||||
f"SELECT DISTINCT generation_id FROM generation_files WHERE id IN ({placeholders})",
|
||||
tuple(file_ids),
|
||||
).fetchall()
|
||||
return [r["generation_id"] for r in rows]
|
||||
|
||||
|
||||
def get_file_ids_for_generation_ids(gen_ids: list[int]) -> dict[int, list[int]]:
|
||||
"""Return {gen_id: [file_id, …]} for the given generation_history row ids."""
|
||||
if not gen_ids:
|
||||
return {}
|
||||
placeholders = ",".join("?" * len(gen_ids))
|
||||
with _connect() as conn:
|
||||
rows = conn.execute(
|
||||
f"SELECT generation_id, id FROM generation_files WHERE generation_id IN ({placeholders})",
|
||||
tuple(gen_ids),
|
||||
).fetchall()
|
||||
result: dict[int, list[int]] = {gid: [] for gid in gen_ids}
|
||||
for row in rows:
|
||||
result[row["generation_id"]].append(row["id"])
|
||||
return result
|
||||
|
||||
|
||||
def get_files(prompt_id: str) -> list[dict]:
|
||||
"""Return all output files for *prompt_id* as ``[{filename, data, mime_type}]``."""
|
||||
with _connect() as conn:
|
||||
|
||||
@@ -213,6 +213,19 @@ def get_all_images() -> list[dict]:
|
||||
return [dict(r) for r in rows]
|
||||
|
||||
|
||||
def get_images_by_ids(ids: list[int]) -> list[dict]:
|
||||
"""Return image rows for the given ids (excluding image_data), ordered by id DESC."""
|
||||
if not ids:
|
||||
return []
|
||||
placeholders = ",".join("?" * len(ids))
|
||||
with _connect() as conn:
|
||||
rows = conn.execute(
|
||||
f"SELECT {_SAFE_COLS} FROM input_images WHERE id IN ({placeholders}) ORDER BY id DESC",
|
||||
tuple(ids),
|
||||
).fetchall()
|
||||
return [dict(r) for r in rows]
|
||||
|
||||
|
||||
def delete_image(row_id: int, comfy_input_path: str | None = None) -> None:
|
||||
"""
|
||||
Remove an image record from the database.
|
||||
|
||||
@@ -281,6 +281,6 @@ async def flush_pending(
|
||||
failed,
|
||||
)
|
||||
elif uploaded:
|
||||
logger.info("Auto-upload complete: %d file(s) uploaded and deleted.", uploaded)
|
||||
logger.info("Auto-upload complete: %d file(s) uploaded.", uploaded)
|
||||
|
||||
return uploaded
|
||||
|
||||
131
sync_faces.py
Normal file
131
sync_faces.py
Normal file
@@ -0,0 +1,131 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
sync_faces.py
|
||||
=============
|
||||
|
||||
One-time backfill script: scan existing input_images and generation_files
|
||||
for faces and store detections in faces.db.
|
||||
|
||||
Usage:
|
||||
python sync_faces.py [--dry-run] [--input-only] [--output-only]
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import asyncio
|
||||
import logging
|
||||
import sqlite3
|
||||
|
||||
logging.basicConfig(level=logging.INFO, format="%(levelname)s: %(message)s")
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def main(
|
||||
dry_run: bool, input_only: bool, output_only: bool,
|
||||
cluster: bool, cluster_threshold: float,
|
||||
) -> None:
|
||||
import face_db
|
||||
from face_service import get_face_service
|
||||
|
||||
face_db.init_db()
|
||||
svc = get_face_service()
|
||||
|
||||
if not svc.available:
|
||||
logger.error(
|
||||
"insightface is not available. "
|
||||
"Install: pip install insightface onnxruntime opencv-python"
|
||||
)
|
||||
return
|
||||
|
||||
import generation_db
|
||||
import input_image_db
|
||||
|
||||
total_faces = 0
|
||||
total_matched = 0
|
||||
total_unidentified = 0
|
||||
|
||||
# Scan input images
|
||||
if not output_only:
|
||||
logger.info("Scanning input images…")
|
||||
conn = sqlite3.connect(str(input_image_db.DB_PATH), check_same_thread=False)
|
||||
conn.row_factory = sqlite3.Row
|
||||
rows = conn.execute(
|
||||
"SELECT id, image_data FROM input_images WHERE image_data IS NOT NULL"
|
||||
).fetchall()
|
||||
conn.close()
|
||||
|
||||
for row in rows:
|
||||
row_id = row["id"]
|
||||
image_bytes = bytes(row["image_data"])
|
||||
logger.info(" input image id=%d (%d bytes)", row_id, len(image_bytes))
|
||||
if not dry_run:
|
||||
try:
|
||||
results = await svc.scan_input_image(row_id, image_bytes)
|
||||
for r in results:
|
||||
total_faces += 1
|
||||
if r.matched_person_id is not None:
|
||||
total_matched += 1
|
||||
else:
|
||||
total_unidentified += 1
|
||||
except Exception as exc:
|
||||
logger.warning(" Failed for input id=%d: %s", row_id, exc)
|
||||
|
||||
# Scan generated output files
|
||||
if not input_only:
|
||||
logger.info("Scanning generation output files…")
|
||||
conn = sqlite3.connect(str(generation_db._DB_PATH), check_same_thread=False)
|
||||
conn.row_factory = sqlite3.Row
|
||||
rows = conn.execute(
|
||||
"SELECT id, file_data, mime_type FROM generation_files"
|
||||
).fetchall()
|
||||
conn.close()
|
||||
|
||||
for row in rows:
|
||||
file_id = row["id"]
|
||||
file_data = bytes(row["file_data"])
|
||||
mime_type = row["mime_type"] or ""
|
||||
logger.info(
|
||||
" output file id=%d mime=%s (%d bytes)", file_id, mime_type, len(file_data)
|
||||
)
|
||||
if not dry_run:
|
||||
try:
|
||||
if mime_type.startswith("image/"):
|
||||
results = await svc.scan_output_image(file_id, file_data)
|
||||
total_faces += len(results)
|
||||
total_matched += sum(1 for r in results if r.matched_person_id is not None)
|
||||
elif mime_type.startswith("video/"):
|
||||
results = await svc.scan_video(file_id, file_data)
|
||||
total_faces += len(results)
|
||||
total_matched += sum(1 for r in results if r.matched_person_id is not None)
|
||||
except Exception as exc:
|
||||
logger.warning(" Failed for output id=%d: %s", file_id, exc)
|
||||
|
||||
if dry_run:
|
||||
logger.info("Dry run — no data written.")
|
||||
else:
|
||||
logger.info(
|
||||
"Done. %d faces detected, %d matched to known persons, %d unidentified",
|
||||
total_faces,
|
||||
total_matched,
|
||||
total_unidentified,
|
||||
)
|
||||
if cluster:
|
||||
logger.info("Clustering unidentified faces (threshold=%.2f)…", cluster_threshold)
|
||||
groups = await svc.cluster_unidentified_faces(cluster_threshold)
|
||||
logger.info("Clustering: %d groups created", len(groups))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
parser = argparse.ArgumentParser(description="Backfill face detections for existing media")
|
||||
parser.add_argument("--dry-run", action="store_true")
|
||||
parser.add_argument("--input-only", action="store_true")
|
||||
parser.add_argument("--output-only", action="store_true")
|
||||
parser.add_argument("--cluster", action="store_true", help="Run auto-clustering after scanning")
|
||||
parser.add_argument("--cluster-threshold", type=float, default=0.45, metavar="T",
|
||||
help="Cosine similarity threshold for clustering (default: 0.45)")
|
||||
args = parser.parse_args()
|
||||
asyncio.run(main(
|
||||
args.dry_run, args.input_only, args.output_only,
|
||||
args.cluster, args.cluster_threshold,
|
||||
))
|
||||
11
web/app.py
11
web/app.py
@@ -178,6 +178,7 @@ def create_app() -> FastAPI:
|
||||
from web.routers.share_router import router as share_router
|
||||
from web.routers.workflow_router import router as workflow_router
|
||||
from web.routers.ws_router import router as ws_router
|
||||
from web.routers.faces_router import router as faces_router
|
||||
|
||||
app.include_router(auth_router, prefix="/api/auth", tags=["auth"])
|
||||
app.include_router(admin_router, prefix="/api/admin", tags=["admin"])
|
||||
@@ -191,6 +192,7 @@ def create_app() -> FastAPI:
|
||||
app.include_router(share_router, prefix="/api/share", tags=["share"])
|
||||
app.include_router(workflow_router, prefix="/api/workflow", tags=["workflow"])
|
||||
app.include_router(ws_router, tags=["ws"])
|
||||
app.include_router(faces_router, prefix="/api/faces", tags=["faces"])
|
||||
|
||||
from web.routers.ws_router import websocket_endpoint as _ws_endpoint
|
||||
|
||||
@@ -212,6 +214,11 @@ def create_app() -> FastAPI:
|
||||
|
||||
@app.on_event("startup")
|
||||
async def _startup():
|
||||
try:
|
||||
import face_db
|
||||
face_db.init_db()
|
||||
except Exception as _exc:
|
||||
logger.warning("face_db.init_db() failed (non-fatal): %s", _exc)
|
||||
asyncio.create_task(_status_ticker())
|
||||
asyncio.create_task(_server_state_poller())
|
||||
logger.info("Web background tasks started")
|
||||
@@ -224,12 +231,12 @@ def create_app() -> FastAPI:
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
async def _status_ticker() -> None:
|
||||
"""Broadcast status_snapshot to all clients every 5 seconds."""
|
||||
"""Broadcast status_snapshot to all clients every 2 seconds."""
|
||||
from web.deps import get_bot, get_comfy, get_config
|
||||
bus = get_bus()
|
||||
|
||||
while True:
|
||||
await asyncio.sleep(5)
|
||||
await asyncio.sleep(2)
|
||||
try:
|
||||
bot = get_bot()
|
||||
comfy = get_comfy()
|
||||
|
||||
16
web/auth.py
16
web/auth.py
@@ -37,12 +37,17 @@ _COOKIE_NAME = "ttb_session"
|
||||
def _get_secret() -> str:
|
||||
from web.deps import get_config
|
||||
cfg = get_config()
|
||||
if cfg and cfg.web_secret_key:
|
||||
return cfg.web_secret_key
|
||||
key = cfg.web_secret_key if cfg else ""
|
||||
if not key:
|
||||
raise RuntimeError(
|
||||
"WEB_SECRET_KEY must be set in the environment — "
|
||||
"refusing to run with an insecure default."
|
||||
)
|
||||
if len(key) < 32:
|
||||
raise RuntimeError(
|
||||
"WEB_SECRET_KEY is too short (got %d chars, need ≥ 32)." % len(key)
|
||||
)
|
||||
return key
|
||||
|
||||
|
||||
def create_jwt(label: str, *, admin: bool = False, expire_hours: int = 8) -> str:
|
||||
@@ -102,6 +107,13 @@ def require_auth(ttb_session: Optional[str] = Cookie(default=None)) -> dict:
|
||||
return payload
|
||||
|
||||
|
||||
def optional_auth(ttb_session: Optional[str] = Cookie(default=None)) -> Optional[dict]:
|
||||
"""Returns decoded JWT payload or None. Never raises 401."""
|
||||
if not ttb_session:
|
||||
return None
|
||||
return decode_jwt(ttb_session)
|
||||
|
||||
|
||||
def require_admin(user: dict = Depends(require_auth)) -> dict:
|
||||
"""
|
||||
FastAPI dependency that requires an admin JWT.
|
||||
|
||||
451
web/routers/faces_router.py
Normal file
451
web/routers/faces_router.py
Normal file
@@ -0,0 +1,451 @@
|
||||
"""GET/POST /api/faces/* — face recognition endpoints."""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
import numpy as np
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from fastapi.responses import Response
|
||||
from pydantic import BaseModel
|
||||
|
||||
from face_service import _SIMILARITY_THRESHOLD
|
||||
from web.auth import require_admin
|
||||
|
||||
router = APIRouter()
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _auto_link_for_person(person_id: int, face_db_module, ungrouped: list[dict]) -> int:
|
||||
"""Auto-link ungrouped unidentified detections similar to person_id. Returns count linked."""
|
||||
ref_embeddings = face_db_module.get_person_embeddings(person_id)
|
||||
if not ref_embeddings or not ungrouped:
|
||||
return 0
|
||||
ref_M = np.stack(ref_embeddings)
|
||||
ref_M_norm = ref_M / (np.linalg.norm(ref_M, axis=1, keepdims=True) + 1e-8)
|
||||
count = 0
|
||||
for ue in ungrouped:
|
||||
norm_ue = ue["embedding"] / (np.linalg.norm(ue["embedding"]) + 1e-8)
|
||||
if float(np.max(ref_M_norm @ norm_ue)) >= _SIMILARITY_THRESHOLD:
|
||||
face_db_module.link_detection_to_person(ue["id"], person_id)
|
||||
count += 1
|
||||
return count
|
||||
|
||||
|
||||
@router.get("/persons")
|
||||
async def list_persons(_: dict = Depends(require_admin)):
|
||||
"""List all known persons."""
|
||||
import face_db
|
||||
return {"persons": face_db.list_persons()}
|
||||
|
||||
|
||||
@router.get("/persons/check")
|
||||
async def check_person_name(name: str, _: dict = Depends(require_admin)):
|
||||
"""Check whether a person name is already taken (case-insensitive)."""
|
||||
import face_db
|
||||
return {"exists": face_db.person_name_exists(name)}
|
||||
|
||||
|
||||
@router.get("/persons/search")
|
||||
async def search_persons(
|
||||
q: str = "",
|
||||
limit: int = Query(10, ge=1, le=50),
|
||||
_: dict = Depends(require_admin),
|
||||
):
|
||||
"""Search persons by name/alias substring."""
|
||||
import face_db
|
||||
all_persons = face_db.list_persons()
|
||||
q_lower = q.strip().lower()
|
||||
if q_lower:
|
||||
filtered = [
|
||||
p for p in all_persons
|
||||
if q_lower in p["name"].lower()
|
||||
or any(q_lower in a["alias"].lower() for a in p["aliases"])
|
||||
]
|
||||
else:
|
||||
filtered = all_persons
|
||||
return {"persons": filtered[:limit]}
|
||||
|
||||
|
||||
@router.get("/persons/{person_id}")
|
||||
async def get_person(person_id: int, _: dict = Depends(require_admin)):
|
||||
"""Get a single person with aliases."""
|
||||
import face_db
|
||||
person = face_db.get_person(person_id)
|
||||
if person is None:
|
||||
raise HTTPException(404, f"Person {person_id} not found")
|
||||
return person
|
||||
|
||||
|
||||
@router.get("/crop/{detection_id}")
|
||||
async def get_face_crop(detection_id: int, _: dict = Depends(require_admin)):
|
||||
"""Return a JPEG face crop for the given detection id."""
|
||||
import asyncio
|
||||
from face_service import get_face_service
|
||||
svc = get_face_service()
|
||||
if not svc.available:
|
||||
raise HTTPException(503, "Face service not available")
|
||||
loop = asyncio.get_event_loop()
|
||||
crop = await loop.run_in_executor(svc._executor, svc.get_face_crop, detection_id)
|
||||
if crop is None:
|
||||
raise HTTPException(404, "Face crop not found")
|
||||
return Response(content=crop, media_type="image/jpeg")
|
||||
|
||||
|
||||
class _IdentifyItem(BaseModel):
|
||||
detection_id: int
|
||||
name: str
|
||||
use_existing: bool = False
|
||||
|
||||
|
||||
class _IdentifyRequest(BaseModel):
|
||||
identifications: list[_IdentifyItem]
|
||||
|
||||
|
||||
@router.get("/detections/unidentified")
|
||||
async def list_unidentified_detections(
|
||||
limit: int = Query(50, ge=1, le=200),
|
||||
offset: int = Query(0, ge=0),
|
||||
_: dict = Depends(require_admin),
|
||||
):
|
||||
"""List unidentified face detections from input images (paginated)."""
|
||||
import face_db
|
||||
detections, total = face_db.get_unidentified_input_detections(limit=limit, offset=offset)
|
||||
return {"detections": detections, "total": total}
|
||||
|
||||
|
||||
class _AliasRequest(BaseModel):
|
||||
alias: str
|
||||
|
||||
|
||||
@router.post("/persons/{person_id}/aliases")
|
||||
async def add_person_alias(
|
||||
person_id: int,
|
||||
body: _AliasRequest,
|
||||
_: dict = Depends(require_admin),
|
||||
):
|
||||
"""Add an alias to a person."""
|
||||
import face_db
|
||||
alias = body.alias.strip()
|
||||
if not alias:
|
||||
raise HTTPException(400, "Alias cannot be empty")
|
||||
if len(alias) > 100:
|
||||
raise HTTPException(400, "Alias too long (max 100 chars)")
|
||||
try:
|
||||
alias_id, _ = face_db.add_alias(person_id, alias)
|
||||
except ValueError as e:
|
||||
raise HTTPException(409, str(e)) from e
|
||||
return {"id": alias_id, "alias": alias}
|
||||
|
||||
|
||||
@router.delete("/persons/{person_id}/aliases/{alias_id}")
|
||||
async def remove_person_alias(
|
||||
person_id: int,
|
||||
alias_id: int,
|
||||
_: dict = Depends(require_admin),
|
||||
):
|
||||
"""Remove an alias from a person."""
|
||||
import face_db
|
||||
face_db.remove_alias(alias_id)
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
class _RenameRequest(BaseModel):
|
||||
name: str
|
||||
|
||||
|
||||
@router.patch("/persons/{person_id}")
|
||||
async def rename_person(
|
||||
person_id: int,
|
||||
body: _RenameRequest,
|
||||
_: dict = Depends(require_admin),
|
||||
):
|
||||
"""Rename a person."""
|
||||
import face_db
|
||||
name = body.name.strip()
|
||||
if not name:
|
||||
raise HTTPException(400, "Name cannot be empty")
|
||||
if len(name) > 100:
|
||||
raise HTTPException(400, "Name too long (max 100 chars)")
|
||||
if face_db.get_person(person_id) is None:
|
||||
raise HTTPException(404, f"Person {person_id} not found")
|
||||
try:
|
||||
face_db.rename_person(person_id, name)
|
||||
except ValueError as e:
|
||||
raise HTTPException(409, str(e)) from e
|
||||
return {"ok": True, "person_id": person_id, "name": name}
|
||||
|
||||
|
||||
@router.delete("/persons/{person_id}")
|
||||
async def delete_person(person_id: int, _: dict = Depends(require_admin)):
|
||||
"""Delete a person and unidentify all their detections."""
|
||||
import face_db
|
||||
if face_db.get_person(person_id) is None:
|
||||
raise HTTPException(404, f"Person {person_id} not found")
|
||||
face_db.delete_person(person_id)
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
@router.get("/persons/{person_id}/detections")
|
||||
async def get_person_detections(
|
||||
person_id: int,
|
||||
limit: int = Query(50, ge=1, le=200),
|
||||
offset: int = Query(0, ge=0),
|
||||
_: dict = Depends(require_admin),
|
||||
):
|
||||
"""List detections for a person (paginated)."""
|
||||
import face_db
|
||||
detections, total = face_db.get_detections_for_person(person_id, limit=limit, offset=offset)
|
||||
return {"detections": detections, "total": total}
|
||||
|
||||
|
||||
class _MergePersonRequest(BaseModel):
|
||||
other_person_id: int
|
||||
|
||||
|
||||
@router.post("/persons/{person_id}/merge")
|
||||
async def merge_persons(
|
||||
person_id: int,
|
||||
body: _MergePersonRequest,
|
||||
_: dict = Depends(require_admin),
|
||||
):
|
||||
"""Merge another person into this one (survivor keeps their id)."""
|
||||
import face_db
|
||||
if person_id == body.other_person_id:
|
||||
raise HTTPException(400, "Cannot merge person into themselves")
|
||||
if face_db.get_person(person_id) is None:
|
||||
raise HTTPException(404, f"Person {person_id} not found")
|
||||
if face_db.get_person(body.other_person_id) is None:
|
||||
raise HTTPException(404, f"Person {body.other_person_id} not found")
|
||||
face_db.merge_persons(person_id, body.other_person_id)
|
||||
return {"ok": True, "survivor_id": person_id, "absorbed_id": body.other_person_id}
|
||||
|
||||
|
||||
class _ReassignRequest(BaseModel):
|
||||
person_name: str | None = None
|
||||
use_existing: bool = False
|
||||
|
||||
|
||||
@router.post("/detections/{detection_id}/reassign")
|
||||
async def reassign_detection(
|
||||
detection_id: int,
|
||||
body: _ReassignRequest,
|
||||
_: dict = Depends(require_admin),
|
||||
):
|
||||
"""
|
||||
Reassign or unidentify a detection.
|
||||
- person_name=null → unidentify (set person_id=NULL)
|
||||
- person_name=str → link to that person (create if needed, or use existing)
|
||||
"""
|
||||
import face_db
|
||||
det = face_db.get_detection(detection_id)
|
||||
if det is None:
|
||||
raise HTTPException(404, f"Detection {detection_id} not found")
|
||||
if body.person_name is None:
|
||||
face_db.unidentify_detection(detection_id)
|
||||
return {"detection_id": detection_id, "person_id": None, "unidentified": True}
|
||||
name = body.person_name.strip()
|
||||
if not name:
|
||||
raise HTTPException(400, "Name cannot be empty")
|
||||
if len(name) > 100:
|
||||
raise HTTPException(400, "Name too long (max 100 chars)")
|
||||
exists = face_db.person_name_exists(name)
|
||||
if exists and not body.use_existing:
|
||||
raise HTTPException(
|
||||
409, f"A person named '{name}' already exists. Set use_existing=true to link."
|
||||
)
|
||||
person_id, is_new = face_db.get_or_create_person(name)
|
||||
face_db.link_detection_to_person(detection_id, person_id)
|
||||
return {"detection_id": detection_id, "person_id": person_id, "is_new": is_new}
|
||||
|
||||
|
||||
class _ClusterRequest(BaseModel):
|
||||
threshold: float = 0.45
|
||||
|
||||
|
||||
class _MergeGroupsRequest(BaseModel):
|
||||
keep_group_id: int
|
||||
discard_group_id: int
|
||||
|
||||
|
||||
class _IdentifyGroupRequest(BaseModel):
|
||||
name: str
|
||||
use_existing: bool = False
|
||||
|
||||
|
||||
@router.get("/groups")
|
||||
async def list_face_groups(_: dict = Depends(require_admin)):
|
||||
"""List face groups with ≥ 2 unidentified detections."""
|
||||
import face_db
|
||||
groups = face_db.get_groups_with_detections()
|
||||
return {"groups": groups, "total": len(groups)}
|
||||
|
||||
|
||||
@router.get("/groups/{group_id}/detections")
|
||||
async def get_face_group_detections(group_id: int, _: dict = Depends(require_admin)):
|
||||
"""Return the unidentified detections belonging to a group (fetched on expand)."""
|
||||
import face_db
|
||||
detections = face_db.get_group_detections(group_id)
|
||||
return {"detections": detections}
|
||||
|
||||
|
||||
@router.post("/groups/compute")
|
||||
async def compute_face_groups(body: _ClusterRequest, _: dict = Depends(require_admin)):
|
||||
"""Run full re-cluster of all unidentified faces."""
|
||||
from face_service import get_face_service
|
||||
if not 0.3 <= body.threshold <= 0.7:
|
||||
raise HTTPException(422, "threshold must be between 0.3 and 0.7")
|
||||
svc = get_face_service()
|
||||
if not svc.available:
|
||||
raise HTTPException(503, "Face service not available")
|
||||
groups = await svc.cluster_unidentified_faces(body.threshold)
|
||||
total_detections = sum(len(g) for g in groups)
|
||||
return {
|
||||
"groups_created": len(groups),
|
||||
"total_detections_clustered": total_detections,
|
||||
"threshold": body.threshold,
|
||||
}
|
||||
|
||||
|
||||
@router.post("/groups/merge")
|
||||
async def merge_face_groups(body: _MergeGroupsRequest, _: dict = Depends(require_admin)):
|
||||
"""Merge two groups into one."""
|
||||
import face_db
|
||||
if body.keep_group_id == body.discard_group_id:
|
||||
raise HTTPException(400, "keep_group_id and discard_group_id must differ")
|
||||
groups_index = {g["id"] for g in face_db.get_groups_with_detections()}
|
||||
if body.keep_group_id not in groups_index:
|
||||
raise HTTPException(404, f"Group {body.keep_group_id} not found")
|
||||
if body.discard_group_id not in groups_index:
|
||||
raise HTTPException(404, f"Group {body.discard_group_id} not found")
|
||||
face_db.merge_groups(body.keep_group_id, body.discard_group_id)
|
||||
return {"ok": True, "surviving_group_id": body.keep_group_id}
|
||||
|
||||
|
||||
@router.post("/groups/{group_id}/identify")
|
||||
async def identify_face_group(
|
||||
group_id: int,
|
||||
body: _IdentifyGroupRequest,
|
||||
_: dict = Depends(require_admin),
|
||||
):
|
||||
"""
|
||||
Identify all detections in a group as one person.
|
||||
After identification, auto-links any similar ungrouped unidentified detections.
|
||||
Works for both input and output source types.
|
||||
"""
|
||||
import face_db
|
||||
name = body.name.strip()
|
||||
if not name:
|
||||
raise HTTPException(400, "Name cannot be empty")
|
||||
if len(name) > 100:
|
||||
raise HTTPException(400, "Name too long (max 100 chars)")
|
||||
|
||||
detections = face_db.get_group_detections(group_id)
|
||||
if not detections:
|
||||
raise HTTPException(404, f"Group {group_id} not found or has no unidentified detections")
|
||||
|
||||
exists = face_db.person_name_exists(name)
|
||||
if exists and not body.use_existing:
|
||||
raise HTTPException(
|
||||
409, f"A person named '{name}' already exists. Set use_existing=true to link."
|
||||
)
|
||||
|
||||
person_id, is_new = face_db.get_or_create_person(name)
|
||||
identified_count = 0
|
||||
for det in detections:
|
||||
face_db.link_detection_to_person(det["id"], person_id)
|
||||
identified_count += 1
|
||||
|
||||
face_db.delete_group(group_id)
|
||||
|
||||
# Post-identify: auto-link ungrouped unidentified detections similar to this person
|
||||
ungrouped = face_db.get_ungrouped_unidentified_embeddings()
|
||||
auto_linked_count = _auto_link_for_person(person_id, face_db, ungrouped)
|
||||
|
||||
return {
|
||||
"person_id": person_id,
|
||||
"person_name": name,
|
||||
"is_new": is_new,
|
||||
"identified_count": identified_count,
|
||||
"auto_linked_count": auto_linked_count,
|
||||
}
|
||||
|
||||
|
||||
@router.delete("/groups/{group_id}/detections/{detection_id}")
|
||||
async def remove_group_detection(
|
||||
group_id: int,
|
||||
detection_id: int,
|
||||
_: dict = Depends(require_admin),
|
||||
):
|
||||
"""Remove a single detection from its group. Cleans up singleton groups."""
|
||||
import face_db
|
||||
det = face_db.get_detection(detection_id)
|
||||
if det is None or det.get("group_id") != group_id:
|
||||
raise HTTPException(404, "Detection not found in this group")
|
||||
face_db.remove_detection_from_group(detection_id)
|
||||
face_db.delete_singleton_groups()
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
@router.post("/rescan/outputs")
|
||||
async def rescan_output_embeddings(_: dict = Depends(require_admin)):
|
||||
"""Re-detect faces in stored output images to rebuild NULL embeddings."""
|
||||
import face_db
|
||||
from face_service import get_face_service
|
||||
svc = get_face_service()
|
||||
if not svc.available:
|
||||
raise HTTPException(503, "Face service not available")
|
||||
source_ids = face_db.get_null_embedding_output_source_ids()
|
||||
total_updated = 0
|
||||
for source_id in source_ids:
|
||||
updated = await svc.rescan_output_embedding(source_id)
|
||||
total_updated += updated
|
||||
return {"processed": len(source_ids), "updated": total_updated}
|
||||
|
||||
|
||||
@router.post("/identify")
|
||||
async def identify_faces(body: _IdentifyRequest, _: dict = Depends(require_admin)):
|
||||
"""
|
||||
Identify one or more face detections by name.
|
||||
|
||||
- If the name is new → creates a new person and links the detection.
|
||||
- If the name exists and ``use_existing=true`` → links to the existing person.
|
||||
- If the name exists and ``use_existing=false`` → HTTP 409.
|
||||
- Only detections with ``source_type='input'`` may be identified via the web UI.
|
||||
"""
|
||||
import face_db
|
||||
results = []
|
||||
for item in body.identifications:
|
||||
name = item.name.strip()
|
||||
if not name:
|
||||
raise HTTPException(400, "Name cannot be empty")
|
||||
if len(name) > 100:
|
||||
raise HTTPException(400, "Name too long (max 100 chars)")
|
||||
|
||||
det = face_db.get_detection(item.detection_id)
|
||||
if det is None:
|
||||
raise HTTPException(404, f"Detection {item.detection_id} not found")
|
||||
if det["source_type"] != "input":
|
||||
raise HTTPException(403, "Only input-image detections may be identified via web UI")
|
||||
|
||||
exists = face_db.person_name_exists(name)
|
||||
if exists and not item.use_existing:
|
||||
raise HTTPException(
|
||||
409,
|
||||
f"A person named '{name}' already exists. Set use_existing=true to link.",
|
||||
)
|
||||
|
||||
person_id, is_new = face_db.get_or_create_person(name)
|
||||
face_db.link_detection_to_person(item.detection_id, person_id)
|
||||
|
||||
results.append({
|
||||
"detection_id": item.detection_id,
|
||||
"person_id": person_id,
|
||||
"person_name": name,
|
||||
"is_new": is_new,
|
||||
})
|
||||
|
||||
# Auto-link similar ungrouped faces for each person identified in this batch
|
||||
ungrouped = face_db.get_ungrouped_unidentified_embeddings()
|
||||
unique_pids = {r["person_id"] for r in results}
|
||||
auto_linked_count = sum(_auto_link_for_person(pid, face_db, ungrouped) for pid in unique_pids)
|
||||
return {"identifications": results, "auto_linked_count": auto_linked_count}
|
||||
@@ -17,6 +17,32 @@ router = APIRouter()
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _materialize_image_slots(
|
||||
overrides: dict, comfy_input_path: str
|
||||
) -> tuple[dict, list[str]]:
|
||||
"""
|
||||
For each override whose value is an existing ttb_* file, copy it to a
|
||||
unique name so concurrent jobs each have an immutable copy on disk.
|
||||
Returns (updated_overrides, paths_to_delete_after_generation).
|
||||
"""
|
||||
import shutil
|
||||
import uuid as _uuid
|
||||
if not comfy_input_path:
|
||||
return overrides, []
|
||||
updated = dict(overrides)
|
||||
cleanup: list[str] = []
|
||||
input_dir = Path(comfy_input_path)
|
||||
for key, val in overrides.items():
|
||||
if isinstance(val, str) and val.startswith("ttb_") and "." in val:
|
||||
src = input_dir / val
|
||||
if src.is_file():
|
||||
unique_name = f"{src.stem}_{_uuid.uuid4().hex[:8]}{src.suffix}"
|
||||
shutil.copy2(src, input_dir / unique_name)
|
||||
updated[key] = unique_name
|
||||
cleanup.append(str(input_dir / unique_name))
|
||||
return updated, cleanup
|
||||
|
||||
|
||||
class GenerateRequest(BaseModel):
|
||||
prompt: str
|
||||
negative_prompt: Optional[str] = None
|
||||
@@ -105,7 +131,8 @@ async def generate(body: GenerateRequest, user: dict = Depends(require_auth)):
|
||||
from generation_db import record_generation, record_file
|
||||
gen_id = record_generation(pid, "web", user_label, overrides_for_gen, seed_used)
|
||||
for i, img_data in enumerate(images):
|
||||
record_file(gen_id, f"image_{i:04d}.png", img_data)
|
||||
file_id = record_file(gen_id, f"image_{i:04d}.png", img_data)
|
||||
comfy._schedule_face_scan("image", file_id, img_data)
|
||||
if config and videos:
|
||||
for vid in videos:
|
||||
vsub = vid.get("video_subfolder", "")
|
||||
@@ -116,7 +143,9 @@ async def generate(body: GenerateRequest, user: dict = Depends(require_auth)):
|
||||
else Path(config.comfy_output_path) / vname
|
||||
)
|
||||
try:
|
||||
record_file(gen_id, vname, vpath.read_bytes())
|
||||
vid_data = vpath.read_bytes()
|
||||
file_id = record_file(gen_id, vname, vid_data)
|
||||
comfy._schedule_face_scan("video", file_id, vid_data)
|
||||
except OSError:
|
||||
pass
|
||||
except Exception as exc:
|
||||
@@ -163,25 +192,32 @@ async def workflow_gen(body: WorkflowGenRequest, user: dict = Depends(require_au
|
||||
registry = get_user_registry()
|
||||
count = max(1, min(body.count, 20)) # cap at 20
|
||||
|
||||
async def _run_one():
|
||||
# Use the user's own state and template
|
||||
# --- snapshot state at queue time, not at execution time ---
|
||||
if registry:
|
||||
user_sm = registry.get_state_manager(user_label)
|
||||
user_template = registry.get_workflow_template(user_label)
|
||||
_user_sm = registry.get_state_manager(user_label)
|
||||
_user_template = registry.get_workflow_template(user_label)
|
||||
else:
|
||||
user_sm = comfy.state_manager
|
||||
user_template = comfy.workflow_manager.get_workflow_template()
|
||||
_user_sm = comfy.state_manager
|
||||
_user_template = comfy.workflow_manager.get_workflow_template()
|
||||
|
||||
if not user_template:
|
||||
base_overrides = _user_sm.get_overrides()
|
||||
if body.overrides:
|
||||
base_overrides = {**base_overrides, **body.overrides}
|
||||
|
||||
_config = get_config()
|
||||
|
||||
async def _run_one(overrides: dict, cleanup_paths: list[str]):
|
||||
if not _user_template:
|
||||
await bus.broadcast_to_user(user_label, "generation_error", {
|
||||
"prompt_id": None, "error": "No workflow template loaded"
|
||||
})
|
||||
for p in cleanup_paths:
|
||||
try:
|
||||
Path(p).unlink(missing_ok=True)
|
||||
except Exception:
|
||||
pass
|
||||
return
|
||||
|
||||
overrides = user_sm.get_overrides()
|
||||
if body.overrides:
|
||||
overrides = {**overrides, **body.overrides}
|
||||
|
||||
import uuid
|
||||
pid = str(uuid.uuid4())
|
||||
|
||||
@@ -190,7 +226,7 @@ async def workflow_gen(body: WorkflowGenRequest, user: dict = Depends(require_au
|
||||
"node": node, "prompt_id": pid_
|
||||
}))
|
||||
|
||||
workflow, applied = comfy.inspector.inject_overrides(user_template, overrides)
|
||||
workflow, applied = comfy.inspector.inject_overrides(_user_template, overrides)
|
||||
seed_used = applied.get("seed")
|
||||
comfy.last_seed = seed_used
|
||||
|
||||
@@ -201,17 +237,23 @@ async def workflow_gen(body: WorkflowGenRequest, user: dict = Depends(require_au
|
||||
await bus.broadcast_to_user(user_label, "generation_error", {
|
||||
"prompt_id": None, "error": str(exc)
|
||||
})
|
||||
for p in cleanup_paths:
|
||||
try:
|
||||
Path(p).unlink(missing_ok=True)
|
||||
except Exception:
|
||||
pass
|
||||
return
|
||||
|
||||
comfy.last_prompt_id = pid
|
||||
comfy.total_generated += 1
|
||||
|
||||
config = get_config()
|
||||
config = _config
|
||||
try:
|
||||
from generation_db import record_generation, record_file
|
||||
gen_id = record_generation(pid, "web", user_label, overrides, seed_used)
|
||||
for i, img_data in enumerate(images):
|
||||
record_file(gen_id, f"image_{i:04d}.png", img_data)
|
||||
file_id = record_file(gen_id, f"image_{i:04d}.png", img_data)
|
||||
comfy._schedule_face_scan("image", file_id, img_data)
|
||||
if config and videos:
|
||||
for vid in videos:
|
||||
vsub = vid.get("video_subfolder", "")
|
||||
@@ -222,7 +264,9 @@ async def workflow_gen(body: WorkflowGenRequest, user: dict = Depends(require_au
|
||||
else Path(config.comfy_output_path) / vname
|
||||
)
|
||||
try:
|
||||
record_file(gen_id, vname, vpath.read_bytes())
|
||||
vid_data = vpath.read_bytes()
|
||||
file_id = record_file(gen_id, vname, vid_data)
|
||||
comfy._schedule_face_scan("video", file_id, vid_data)
|
||||
except OSError:
|
||||
pass
|
||||
except Exception as exc:
|
||||
@@ -236,6 +280,13 @@ async def workflow_gen(body: WorkflowGenRequest, user: dict = Depends(require_au
|
||||
config.media_upload_pass,
|
||||
))
|
||||
|
||||
# Clean up unique image copies now that ComfyUI has ingested them
|
||||
for p in cleanup_paths:
|
||||
try:
|
||||
Path(p).unlink(missing_ok=True)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
await bus.broadcast("queue_update", {"prompt_id": pid, "status": "complete"})
|
||||
await bus.broadcast_to_user(user_label, "generation_complete", {
|
||||
"prompt_id": pid,
|
||||
@@ -246,7 +297,10 @@ async def workflow_gen(body: WorkflowGenRequest, user: dict = Depends(require_au
|
||||
|
||||
depth = await comfy.get_queue_depth()
|
||||
for _ in range(count):
|
||||
asyncio.create_task(_run_one())
|
||||
job_overrides, cleanup = _materialize_image_slots(
|
||||
base_overrides, _config.comfy_input_path if _config else ""
|
||||
)
|
||||
asyncio.create_task(_run_one(job_overrides, cleanup))
|
||||
|
||||
return {
|
||||
"queued": True,
|
||||
|
||||
@@ -3,12 +3,14 @@ POST /api/history/{prompt_id}/share; DELETE /api/history/{prompt_id}/share"""
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, Request
|
||||
from fastapi import APIRouter, Body, Depends, HTTPException, Query, Request
|
||||
from fastapi.responses import Response
|
||||
from pydantic import BaseModel
|
||||
|
||||
from web.auth import require_auth
|
||||
from web.auth import require_admin, require_auth
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
@@ -31,22 +33,135 @@ def _assert_owns(prompt_id: str, user: dict) -> None:
|
||||
async def get_history(
|
||||
user: dict = Depends(require_auth),
|
||||
q: Optional[str] = Query(None, description="Keyword to search in overrides JSON"),
|
||||
persons: list[str] = Query(default=[], alias="persons", description="Filter by person name/alias substring (repeatable)"),
|
||||
):
|
||||
"""Return generation history. Admins see all; regular users see only their own.
|
||||
Pass ?q=keyword to filter by prompt text or any override field."""
|
||||
Pass ?q=keyword to filter by prompt text or any override field.
|
||||
Pass ?persons=name (repeatable) to filter by persons detected in output images."""
|
||||
import face_db as face_db_mod
|
||||
from generation_db import (
|
||||
get_history as db_get_history,
|
||||
get_history_for_user,
|
||||
search_history,
|
||||
search_history_for_user,
|
||||
get_generation_ids_for_file_ids,
|
||||
get_file_ids_for_generation_ids,
|
||||
)
|
||||
|
||||
# Person filter: OR union across all named persons, then filter rows
|
||||
gen_id_filter: list[int] | None = None
|
||||
all_file_ids: set[int] = set()
|
||||
active_persons = [p.strip() for p in persons if p.strip()]
|
||||
if active_persons:
|
||||
for p in active_persons:
|
||||
ids = face_db_mod.get_source_ids_for_person_query(p, "output")
|
||||
all_file_ids.update(ids)
|
||||
if not all_file_ids:
|
||||
return {"history": []}
|
||||
gen_ids = get_generation_ids_for_file_ids(list(all_file_ids))
|
||||
if not gen_ids:
|
||||
return {"history": []}
|
||||
gen_id_filter = gen_ids
|
||||
|
||||
if q and q.strip():
|
||||
if user.get("admin"):
|
||||
return {"history": search_history(q.strip(), limit=50)}
|
||||
return {"history": search_history_for_user(user["sub"], q.strip(), limit=50)}
|
||||
rows = search_history(q.strip(), limit=50)
|
||||
else:
|
||||
rows = search_history_for_user(user["sub"], q.strip(), limit=50)
|
||||
else:
|
||||
if user.get("admin"):
|
||||
return {"history": db_get_history(limit=50)}
|
||||
return {"history": get_history_for_user(user["sub"], limit=50)}
|
||||
rows = db_get_history(limit=50)
|
||||
else:
|
||||
rows = get_history_for_user(user["sub"], limit=50)
|
||||
|
||||
if gen_id_filter is not None:
|
||||
gen_id_set = set(gen_id_filter)
|
||||
rows = [r for r in rows if r["id"] in gen_id_set]
|
||||
|
||||
# Annotate each row with detected_persons when person filtering is active
|
||||
if active_persons and rows:
|
||||
row_ids = [r["id"] for r in rows]
|
||||
file_ids_per_gen = get_file_ids_for_generation_ids(row_ids)
|
||||
all_annotate_ids = [fid for fids in file_ids_per_gen.values() for fid in fids]
|
||||
person_map = face_db_mod.get_persons_for_source_id_map(all_annotate_ids, "output") if all_annotate_ids else {}
|
||||
for row in rows:
|
||||
row["detected_persons"] = list({
|
||||
name
|
||||
for fid in file_ids_per_gen.get(row["id"], [])
|
||||
for name in person_map.get(fid, [])
|
||||
})
|
||||
|
||||
return {"history": rows}
|
||||
|
||||
|
||||
@router.get("/{prompt_id}/persons")
|
||||
async def get_generation_persons(prompt_id: str, user: dict = Depends(require_auth)):
|
||||
"""Return persons detected in the output images of a generation."""
|
||||
_assert_owns(prompt_id, user)
|
||||
import generation_db as gen_db
|
||||
import face_db as face_db_mod
|
||||
file_ids = gen_db.get_file_ids_for_prompt(prompt_id)
|
||||
persons = face_db_mod.get_persons_for_source_ids(file_ids, "output")
|
||||
return {"persons": persons}
|
||||
|
||||
|
||||
class _AddPersonRequest(BaseModel):
|
||||
name: str
|
||||
|
||||
|
||||
@router.post("/{prompt_id}/persons")
|
||||
async def add_generation_person(
|
||||
prompt_id: str,
|
||||
body: _AddPersonRequest,
|
||||
_: dict = Depends(require_admin),
|
||||
):
|
||||
"""Manually tag a person to a generation's output files."""
|
||||
import generation_db as gen_db
|
||||
import face_db as face_db_mod
|
||||
|
||||
file_ids = gen_db.get_file_ids_for_prompt(prompt_id)
|
||||
if not file_ids:
|
||||
raise HTTPException(404, f"Generation {prompt_id!r} not found")
|
||||
|
||||
name = body.name.strip()
|
||||
if not name:
|
||||
raise HTTPException(400, "Name cannot be empty")
|
||||
if len(name) > 100:
|
||||
raise HTTPException(400, "Name too long (max 100 chars)")
|
||||
|
||||
person_id, _ = face_db_mod.get_or_create_person(name)
|
||||
|
||||
# Only add if not already tagged on any file of this generation
|
||||
existing = face_db_mod.get_persons_for_source_ids(file_ids, "output")
|
||||
if not any(p["id"] == person_id for p in existing):
|
||||
face_db_mod.insert_detection(
|
||||
source_type="output",
|
||||
source_id=file_ids[0],
|
||||
embedding=None,
|
||||
bbox={},
|
||||
face_index=0,
|
||||
person_id=person_id,
|
||||
)
|
||||
|
||||
return {"person_id": person_id, "name": name}
|
||||
|
||||
|
||||
@router.delete("/{prompt_id}/persons/{person_id}")
|
||||
async def remove_generation_person(
|
||||
prompt_id: str,
|
||||
person_id: int,
|
||||
_: dict = Depends(require_admin),
|
||||
):
|
||||
"""Remove all face detections linking a person to this generation's output files."""
|
||||
import generation_db as gen_db
|
||||
import face_db as face_db_mod
|
||||
|
||||
file_ids = gen_db.get_file_ids_for_prompt(prompt_id)
|
||||
if not file_ids:
|
||||
raise HTTPException(404, f"Generation {prompt_id!r} not found")
|
||||
|
||||
face_db_mod.remove_person_from_source_ids(file_ids, person_id, "output")
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
@router.get("/{prompt_id}/images")
|
||||
@@ -101,6 +216,11 @@ async def get_history_file(
|
||||
start = int(start_str) if start_str else 0
|
||||
end = int(end_str) if end_str else total - 1
|
||||
end = min(end, total - 1)
|
||||
if start < 0 or start > end:
|
||||
return Response(
|
||||
status_code=416,
|
||||
headers={"Content-Range": f"bytes */{total}"},
|
||||
)
|
||||
chunk = data[start : end + 1]
|
||||
return Response(
|
||||
content=chunk,
|
||||
@@ -110,6 +230,7 @@ async def get_history_file(
|
||||
"Content-Range": f"bytes {start}-{end}/{total}",
|
||||
"Accept-Ranges": "bytes",
|
||||
"Content-Length": str(len(chunk)),
|
||||
"Cache-Control": "private",
|
||||
},
|
||||
)
|
||||
|
||||
@@ -119,25 +240,62 @@ async def get_history_file(
|
||||
headers={
|
||||
"Accept-Ranges": "bytes",
|
||||
"Content-Length": str(total),
|
||||
"Cache-Control": "private",
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
class _ShareRequest(BaseModel):
|
||||
is_public: bool = False
|
||||
expires_in_hours: Optional[float] = None
|
||||
max_views: Optional[int] = None
|
||||
|
||||
|
||||
@router.post("/{prompt_id}/share")
|
||||
async def create_generation_share(prompt_id: str, user: dict = Depends(require_auth)):
|
||||
async def create_generation_share(
|
||||
prompt_id: str,
|
||||
body: _ShareRequest = Body(default_factory=_ShareRequest),
|
||||
user: dict = Depends(require_auth),
|
||||
):
|
||||
"""Create a share token for a generation. Only the owner may share."""
|
||||
# Use the same 404-for-everything helper to avoid leaking prompt_id existence
|
||||
_assert_owns(prompt_id, user)
|
||||
from generation_db import create_share as db_create_share
|
||||
token = db_create_share(prompt_id, user["sub"])
|
||||
return {"share_token": token}
|
||||
from generation_db import (
|
||||
create_share as db_create_share,
|
||||
get_active_share_for_prompt as db_get_active_share,
|
||||
)
|
||||
existing = db_get_active_share(prompt_id, user["sub"])
|
||||
if existing:
|
||||
raise HTTPException(409, "A share already exists — revoke it first")
|
||||
if body.is_public and body.expires_in_hours is None and body.max_views is None:
|
||||
raise HTTPException(400, "Public shares must have at least one expiry condition (time or view limit)")
|
||||
if body.max_views is not None and body.max_views < 1:
|
||||
raise HTTPException(400, "max_views must be >= 1")
|
||||
if body.expires_in_hours is not None and body.expires_in_hours <= 0:
|
||||
raise HTTPException(400, "expires_in_hours must be > 0")
|
||||
expires_at = None
|
||||
if body.expires_in_hours is not None:
|
||||
expires_at = (datetime.now(timezone.utc) + timedelta(hours=body.expires_in_hours)).isoformat()
|
||||
share = db_create_share(
|
||||
prompt_id, user["sub"],
|
||||
is_public=body.is_public,
|
||||
expires_at=expires_at,
|
||||
max_views=body.max_views,
|
||||
)
|
||||
return {
|
||||
"share_token": share["share_token"],
|
||||
"is_public": bool(share["is_public"]),
|
||||
"expires_at": share["expires_at"],
|
||||
"max_views": share["max_views"],
|
||||
}
|
||||
|
||||
|
||||
@router.delete("/{prompt_id}/share")
|
||||
async def revoke_generation_share(prompt_id: str, user: dict = Depends(require_auth)):
|
||||
"""Revoke a share token for a generation. Only the owner may revoke."""
|
||||
"""Revoke a share token for a generation. Admins can revoke any share."""
|
||||
from generation_db import revoke_share as db_revoke_share
|
||||
deleted = db_revoke_share(prompt_id, user["sub"])
|
||||
# Admins pass None for owner_label to delete by prompt_id alone
|
||||
owner_label = None if user.get("admin") else user["sub"]
|
||||
deleted = db_revoke_share(prompt_id, owner_label)
|
||||
if not deleted:
|
||||
raise HTTPException(404, "No active share found for this generation")
|
||||
return {"ok": True}
|
||||
|
||||
@@ -3,10 +3,11 @@ from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import mimetypes
|
||||
import re
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import APIRouter, Body, Depends, File, Form, HTTPException, UploadFile
|
||||
from fastapi import APIRouter, Body, Depends, File, Form, HTTPException, Query, UploadFile
|
||||
from fastapi.responses import Response
|
||||
|
||||
from web.auth import require_auth
|
||||
@@ -15,10 +16,38 @@ from web.deps import get_config, get_user_registry
|
||||
router = APIRouter()
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_ALLOWED_EXTS = {".jpg", ".jpeg", ".png", ".gif", ".webp", ".bmp"}
|
||||
_MAX_UPLOAD_BYTES = 50 * 1024 * 1024 # 50 MB
|
||||
_SAFE_SLOT_RE = re.compile(r'^[a-zA-Z0-9_\-]+$')
|
||||
|
||||
|
||||
def _validate_slot_key(slot_key: str) -> None:
|
||||
if not _SAFE_SLOT_RE.match(slot_key):
|
||||
raise HTTPException(400, "slot_key may only contain letters, digits, hyphens, underscores")
|
||||
|
||||
|
||||
@router.get("")
|
||||
async def list_inputs(_: dict = Depends(require_auth)):
|
||||
"""List all input images (Discord + web uploads)."""
|
||||
async def list_inputs(
|
||||
_: dict = Depends(require_auth),
|
||||
persons: list[str] = Query(default=[], alias="persons", description="Filter by person name/alias substring (repeatable)"),
|
||||
):
|
||||
"""List all input images (Discord + web uploads). Optionally filter by persons."""
|
||||
active_persons = [p.strip() for p in persons if p.strip()]
|
||||
if active_persons:
|
||||
import face_db as face_db_mod
|
||||
from input_image_db import get_images_by_ids
|
||||
all_ids: set[int] = set()
|
||||
for p in active_persons:
|
||||
ids = face_db_mod.get_source_ids_for_person_query(p, "input")
|
||||
all_ids.update(ids)
|
||||
images = list(get_images_by_ids(list(all_ids))) if all_ids else []
|
||||
if images:
|
||||
person_map = face_db_mod.get_persons_for_source_id_map(
|
||||
[img["id"] for img in images], "input"
|
||||
)
|
||||
for img in images:
|
||||
img["detected_persons"] = person_map.get(img["id"], [])
|
||||
return images
|
||||
from input_image_db import get_all_images
|
||||
rows = get_all_images()
|
||||
return [dict(r) for r in rows]
|
||||
@@ -44,8 +73,16 @@ async def upload_input(
|
||||
if config is None:
|
||||
raise HTTPException(503, "Config not available")
|
||||
|
||||
if slot_key:
|
||||
_validate_slot_key(slot_key)
|
||||
|
||||
data = await file.read()
|
||||
filename = file.filename or "upload.png"
|
||||
ext = Path(filename).suffix.lower()
|
||||
if ext not in _ALLOWED_EXTS:
|
||||
raise HTTPException(415, f"Unsupported file type '{ext}'. Allowed: {sorted(_ALLOWED_EXTS)}")
|
||||
if len(data) > _MAX_UPLOAD_BYTES:
|
||||
raise HTTPException(413, "File too large (max 50 MB)")
|
||||
|
||||
from input_image_db import upsert_image, activate_image_for_slot
|
||||
row_id = upsert_image(
|
||||
@@ -72,7 +109,30 @@ async def upload_input(
|
||||
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}
|
||||
# Face scan — runs synchronously here (~1-2 s); unknown faces returned to UI
|
||||
pending_faces: list[dict] = []
|
||||
try:
|
||||
from face_service import get_face_service
|
||||
import face_db as _face_db
|
||||
_face_db.init_db()
|
||||
svc = get_face_service()
|
||||
if svc.available:
|
||||
results = await svc.scan_input_image(row_id, data)
|
||||
pending_faces = [
|
||||
{"detection_id": r.detection_id, "face_index": r.face_index, "bbox": r.bbox}
|
||||
for r in results
|
||||
if r.matched_person_id is None
|
||||
]
|
||||
except Exception as exc:
|
||||
logger.warning("Face scan failed for upload row_id=%d: %s", row_id, exc)
|
||||
|
||||
return {
|
||||
"id": row_id,
|
||||
"filename": filename,
|
||||
"slot_key": slot_key,
|
||||
"activated_filename": activated_filename,
|
||||
"pending_faces": pending_faces,
|
||||
}
|
||||
|
||||
|
||||
@router.post("/{row_id}/activate")
|
||||
@@ -92,6 +152,7 @@ async def activate_input(
|
||||
raise HTTPException(404, "Image not found")
|
||||
|
||||
user_label: str = user["sub"]
|
||||
_validate_slot_key(slot_key)
|
||||
namespaced_key = f"{user_label}_{slot_key}"
|
||||
|
||||
try:
|
||||
|
||||
@@ -2,28 +2,40 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request
|
||||
from fastapi.responses import Response
|
||||
|
||||
from web.auth import require_auth
|
||||
from web.auth import optional_auth
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
def _auth_gate(gen: dict, user: Optional[dict]) -> None:
|
||||
"""Raise 401 if the share is private and the user is not authenticated."""
|
||||
if not gen.get("is_public") and user is None:
|
||||
raise HTTPException(401, "Authentication required")
|
||||
|
||||
|
||||
@router.get("/{token}")
|
||||
async def get_share(token: str, _: dict = Depends(require_auth)):
|
||||
"""Fetch share metadata and images. Any authenticated user may view a valid share link."""
|
||||
async def get_share(token: str, user: Optional[dict] = Depends(optional_auth)):
|
||||
"""Fetch share metadata and images. Public shares require no login; private shares require auth."""
|
||||
from generation_db import get_share_by_token, get_files
|
||||
gen = get_share_by_token(token)
|
||||
if gen is None:
|
||||
raise HTTPException(404, "Share not found or revoked")
|
||||
raise HTTPException(404, "Share not found, expired, or revoked")
|
||||
_auth_gate(gen, user)
|
||||
files = get_files(gen["prompt_id"])
|
||||
return {
|
||||
"prompt_id": gen["prompt_id"],
|
||||
"created_at": gen["created_at"],
|
||||
"overrides": gen["overrides"],
|
||||
"seed": gen["seed"],
|
||||
"is_public": bool(gen["is_public"]),
|
||||
"expires_at": gen["expires_at"],
|
||||
"max_views": gen["max_views"],
|
||||
"view_count": gen["view_count"],
|
||||
"images": [
|
||||
{
|
||||
"filename": f["filename"],
|
||||
@@ -40,13 +52,14 @@ async def get_share_file(
|
||||
token: str,
|
||||
filename: str,
|
||||
request: Request,
|
||||
_: dict = Depends(require_auth),
|
||||
user: Optional[dict] = Depends(optional_auth),
|
||||
):
|
||||
"""Stream a single output file via share token, with HTTP range support for video seeking."""
|
||||
from generation_db import get_share_by_token, get_files
|
||||
gen = get_share_by_token(token)
|
||||
from generation_db import get_share_meta, get_files
|
||||
gen = get_share_meta(token)
|
||||
if gen is None:
|
||||
raise HTTPException(404, "Share not found or revoked")
|
||||
raise HTTPException(404, "Share not found, expired, or revoked")
|
||||
_auth_gate(gen, user)
|
||||
files = get_files(gen["prompt_id"])
|
||||
matched = next((f for f in files if f["filename"] == filename), None)
|
||||
if matched is None:
|
||||
@@ -63,6 +76,11 @@ async def get_share_file(
|
||||
start = int(start_str) if start_str else 0
|
||||
end = int(end_str) if end_str else total - 1
|
||||
end = min(end, total - 1)
|
||||
if start < 0 or start > end:
|
||||
return Response(
|
||||
status_code=416,
|
||||
headers={"Content-Range": f"bytes */{total}"},
|
||||
)
|
||||
chunk = data[start : end + 1]
|
||||
return Response(
|
||||
content=chunk,
|
||||
@@ -72,6 +90,7 @@ async def get_share_file(
|
||||
"Content-Range": f"bytes {start}-{end}/{total}",
|
||||
"Accept-Ranges": "bytes",
|
||||
"Content-Length": str(len(chunk)),
|
||||
"Cache-Control": "public, max-age=3600" if gen.get("is_public") else "private",
|
||||
},
|
||||
)
|
||||
|
||||
@@ -81,5 +100,6 @@ async def get_share_file(
|
||||
headers={
|
||||
"Accept-Ranges": "bytes",
|
||||
"Content-Length": str(total),
|
||||
"Cache-Control": "public, max-age=3600" if gen.get("is_public") else "private",
|
||||
},
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user