manual submit

This commit is contained in:
Khoa (Revenovich) Tran Gia
2026-03-07 21:49:16 +07:00
parent 1748cbf8d2
commit 6004b000a7
39 changed files with 5794 additions and 614 deletions

14
bot.py
View File

@@ -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")

View File

@@ -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
# ------------------------------------------------------------------

View File

@@ -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
View 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
View 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

View File

@@ -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",

View File

@@ -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",

View File

@@ -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 />} />

View File

@@ -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 }) }
)

View File

@@ -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

View 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">
&ldquo;{fState.name.trim()}&rdquo; 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 &ldquo;{fState.name.trim()}&rdquo;
</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>
)
}

View 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>
)
}

View File

@@ -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>
)

View File

@@ -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 }
}

View File

@@ -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 }
}

View File

@@ -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;
}
}

View File

@@ -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>

File diff suppressed because it is too large Load Diff

View File

@@ -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>
))}

View File

@@ -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>
)}

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>
)
}

View File

@@ -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>
)

View File

@@ -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>

View File

@@ -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: [],
}

View File

@@ -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:

View File

@@ -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.

View File

@@ -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
View 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,
))

View File

@@ -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()

View File

@@ -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
View 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}

View File

@@ -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,

View File

@@ -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}

View File

@@ -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:

View File

@@ -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",
},
)