'
+ // On the Scenery tab the thumbs are draggable onto the slots above. Otherwise draggable for strip drop targets
+ const dragAttr = sceneryActive
+ ? ' draggable="true" ondragstart="sceneFilmDragStart(event,' + i + ')"'
+ : ' draggable="true" ondragstart="filmstripThumbDragStart(event,\'' + n.replace(/'/g, "\\'") + '\')"';
+ const titleStr = _multiSelectModeActive ? 'Click to select' : 'Click to view · Shift+Click to add as ref';
+ return '
'
+ '

'
+ (isVideo(n) ? '
▶
' : '')
+ (pose ? '
' + escHtml(pose) + '
' : '')
+ (badge > 0 ? '
#' + badge + '
' : '')
+ + (isSel ? '
✓
' : '')
+ '
';
}).join('');
const active = strip.querySelector('.lb-var-thumb.active');
@@ -9391,6 +9844,8 @@
fileSortOrders[img.filename] = img.sort_order ?? null;
fileHidden[img.filename] = !!img.hidden;
fileArchived[img.filename] = !!img.archived;
+ fileTags[img.filename] = img.tags || [];
+ fileIsSource[img.filename] = !!img.is_source;
fileHasBg[img.filename] = img.has_background !== false;
fileHasClothing[img.filename] = img.has_clothing ?? null;
fileContentType[img.filename] = img.content_type || 'image';
@@ -9419,9 +9874,9 @@
if (r.ok) {
const data = await r.json();
let imgs = data.images || [];
+ _hydrateImageMeta(imgs); // Hydrate everything first!
imgs = _archiveViewActive ? imgs.filter(i => i.archived) : imgs.filter(i => !i.archived);
files = imgs.map(img => img.filename);
- _hydrateImageMeta(imgs);
_staticOk = true;
}
} catch (e) { /* fall through */ }
@@ -9429,12 +9884,13 @@
// Fallback: live API (handles first startup before static file is written)
if (!files) {
try {
- const url = _archiveViewActive ? `${API}/images?archived=true` : `${API}/images`;
+ const url = `${API}/images?archived=true`;
const r = await fetch(url, { signal: AbortSignal.timeout(12000) });
if (r.ok) {
const data = await r.json();
- files = data.images.map(img => img.filename);
- _hydrateImageMeta(data.images);
+ _hydrateImageMeta(data.images); // Hydrate everything first!
+ let imgs = data.images.filter(i => _archiveViewActive ? i.archived : !i.archived);
+ files = imgs.map(img => img.filename);
}
} catch (e) { /* API not reachable, fall through */ }
}
@@ -10200,6 +10656,14 @@
lbSetPreferred();
e.preventDefault();
}
+ if ((e.key === 's' || e.key === 'S') && tag !== 'INPUT' && tag !== 'TEXTAREA') {
+ toggleSourceKeybind();
+ e.preventDefault();
+ }
+ if ((e.key === 'e' || e.key === 'E') && tag !== 'INPUT' && tag !== 'TEXTAREA') {
+ estimate21PlusKeybind();
+ e.preventDefault();
+ }
if (e.key === 'Delete') {
lbArchive();
e.preventDefault();
diff --git a/tour-comfy/database.py b/tour-comfy/database.py
index e3765d8..4eec61d 100644
--- a/tour-comfy/database.py
+++ b/tour-comfy/database.py
@@ -119,6 +119,87 @@ def migrate_schema():
finally:
cur.close()
_put_db_connection(conn)
+ try:
+ initialize_tags_in_db()
+ except Exception as e:
+ print(f"[db] initialize_tags_in_db error: {e}")
+
+def initialize_tags_in_db():
+ conn = get_db_connection()
+ cur = conn.cursor()
+ try:
+ cur.execute("""
+ SELECT filename, archived, hidden, is_source, sort_order, has_background, tags
+ FROM person
+ """)
+ rows = cur.fetchall()
+ for filename, archived, hidden, is_source, sort_order, has_background, tags_val in rows:
+ tags_list = []
+ if tags_val:
+ if isinstance(tags_val, str):
+ try:
+ tags_list = json.loads(tags_val)
+ except Exception:
+ tags_list = []
+ elif isinstance(tags_val, list):
+ tags_list = tags_val
+
+ is_archived = bool(archived) if archived is not None else False
+ is_hidden = bool(hidden) if hidden is not None else False
+ is_src = bool(is_source) if is_source is not None else False
+ has_bg = bool(has_background) if has_background is not None else True
+
+ def add_tag_if_missing(tag):
+ if tag not in tags_list:
+ tags_list.append(tag)
+
+ if is_archived:
+ add_tag_if_missing("ARCHIVED")
+ if "VISIBLE" in tags_list:
+ tags_list.remove("VISIBLE")
+ else:
+ if not is_hidden:
+ add_tag_if_missing("VISIBLE")
+ if "ARCHIVED" in tags_list:
+ tags_list.remove("ARCHIVED")
+
+ if is_hidden:
+ add_tag_if_missing("HIDDEN")
+ if "VISIBLE" in tags_list:
+ tags_list.remove("VISIBLE")
+ else:
+ if "HIDDEN" in tags_list:
+ tags_list.remove("HIDDEN")
+
+ if is_src:
+ add_tag_if_missing("SOURCE")
+ else:
+ if "SOURCE" in tags_list:
+ tags_list.remove("SOURCE")
+
+ if sort_order == 0:
+ add_tag_if_missing("GROUP_ANCHOR")
+ if "GROUP_MEMBER" in tags_list:
+ tags_list.remove("GROUP_MEMBER")
+ else:
+ add_tag_if_missing("GROUP_MEMBER")
+ if "GROUP_ANCHOR" in tags_list:
+ tags_list.remove("GROUP_ANCHOR")
+
+ if not has_bg:
+ add_tag_if_missing("BACKGROUND_REMOVED")
+ if "BACKGROUND" in tags_list:
+ tags_list.remove("BACKGROUND")
+ else:
+ add_tag_if_missing("BACKGROUND")
+ if "BACKGROUND_REMOVED" in tags_list:
+ tags_list.remove("BACKGROUND_REMOVED")
+
+ cur.execute("UPDATE person SET tags = %s WHERE filename = %s", (json.dumps(tags_list), filename))
+ conn.commit()
+ finally:
+ cur.close()
+ _put_db_connection(conn)
def upsert_person(filename, filepath=None, name=None, group_id=None, tags=None,
embedding=None, clip_description=None, prompt=None, pose=None,
@@ -201,6 +282,16 @@ def set_hidden(filename, hidden: bool):
cur.close()
_put_db_connection(conn)
+def set_person_tags(filename, tags):
+ conn = get_db_connection()
+ cur = conn.cursor()
+ try:
+ cur.execute("UPDATE person SET tags = %s WHERE filename = %s", (json.dumps(tags) if tags is not None else None, filename))
+ conn.commit()
+ finally:
+ cur.close()
+ _put_db_connection(conn)
+
def get_person(filename):
conn = get_db_connection()
cur = conn.cursor()
@@ -224,7 +315,7 @@ def list_persons(include_archived=False):
cur.execute(f"""
SELECT filename, name, group_id, clip_description,
prompt, pose, sort_order, group_name, hidden, has_background, source_refs,
- has_clothing, content_type, faceswap_source_video, archived, is_source
+ has_clothing, content_type, faceswap_source_video, archived, is_source, tags
FROM person
{where}
""")
diff --git a/tour-comfy/edit_api.py b/tour-comfy/edit_api.py
index d03afaa..72059b7 100644
--- a/tour-comfy/edit_api.py
+++ b/tour-comfy/edit_api.py
@@ -1300,6 +1300,17 @@ def _write_all_static() -> None:
if is_archived:
archived_count += 1
+ tags_val = p[16]
+ tags_list = []
+ if tags_val:
+ if isinstance(tags_val, str):
+ try:
+ tags_list = json.loads(tags_val)
+ except Exception:
+ tags_list = []
+ elif isinstance(tags_val, list):
+ tags_list = tags_val
+
db_images.append({
"filename": p[0],
"name": p[1],
@@ -1317,6 +1328,7 @@ def _write_all_static() -> None:
"faceswap_source_video": p[13],
"archived": is_archived,
"is_source": bool(p[15]) if p[15] else False,
+ "tags": tags_list,
})
print(f"[static] write_all: {len(db_images)} total images, {archived_count} archived")
try:
@@ -2132,6 +2144,11 @@ def generate_video(req: VideoStitchRequest):
if r.returncode != 0:
raise HTTPException(500, f"ffmpeg error: {r.stderr.decode(errors='replace')[:600]}")
+ try:
+ _make_video_poster(out_path)
+ except Exception as pe:
+ print(f"[generate-video] poster extraction error: {pe}")
+
# Register in DB so it shows up in the gallery
person = database.get_person(req.filenames[0])
group_id = person[1] if person and person[1] else naming.get_base_name(req.filenames[0])
@@ -2774,6 +2791,317 @@ def extract_face_endpoint(filename: str):
return {"status": "queued", "filename": filename}
+class ImageTagsRequest(BaseModel):
+ tags: list[str]
+
+class TagActionRequest(BaseModel):
+ action: str # "add" or "remove" or "toggle"
+ tag: str
+
+class BulkMoveRequest(BaseModel):
+ filenames: list[str]
+ target_strip: str
+
+def estimate_nsfw_21plus(filename: str) -> bool:
+ """Run WD tagger and return True if image is 21+ (NSFW/sensitive/explicit/nudity)."""
+ try:
+ output_dir = _load_output_dir()
+ fpath = os.path.join(output_dir, filename)
+ if not os.path.exists(fpath):
+ return False
+
+ pil_img = Image.open(fpath)
+ # Run tagger with 0.2 threshold to capture sensitive/explicit content
+ tags = _run_tagger(pil_img, threshold=0.2)
+
+ for t in tags:
+ tag_name = t["tag"].lower().replace("_", " ")
+ # Check rating category
+ if t["cat"] == 9 and tag_name in ("explicit", "questionable", "sensitive"):
+ if t["score"] >= 0.25:
+ return True
+ # Check other explicit keywords in name
+ if any(nsfw_word in tag_name for nsfw_word in ("nude", "naked", "breasts", "pussy", "nipples", "ass", "panties", "underwear", "lingerie", "sex", "erotic")):
+ if t["score"] >= 0.3:
+ return True
+ return False
+ except Exception as e:
+ print(f"[nsfw-estimator] Error estimating for {filename}: {e}")
+ return False
+
+@app.post("/images/{filename:path}/tags")
+def set_image_tags(filename: str, req: ImageTagsRequest):
+ person = database.get_person(filename)
+ if not person:
+ raise HTTPException(404, "Image not found")
+
+ tags_list = list(set([str(t).upper().strip() for t in req.tags if t]))
+
+ archived = "ARCHIVED" in tags_list
+ hidden = "HIDDEN" in tags_list
+ is_source = "SOURCE" in tags_list
+
+ if archived:
+ if "VISIBLE" in tags_list:
+ tags_list.remove("VISIBLE")
+ else:
+ if not hidden:
+ if "VISIBLE" not in tags_list:
+ tags_list.append("VISIBLE")
+
+ if hidden:
+ if "VISIBLE" in tags_list:
+ tags_list.remove("VISIBLE")
+ else:
+ if not archived:
+ if "VISIBLE" not in tags_list:
+ tags_list.append("VISIBLE")
+
+ conn = database.get_db_connection()
+ cur = conn.cursor()
+ try:
+ cur.execute("""
+ UPDATE person
+ SET tags = %s,
+ archived = %s,
+ hidden = %s,
+ is_source = %s
+ WHERE filename = %s
+ """, (json.dumps(tags_list), archived, hidden, is_source, filename))
+ conn.commit()
+ finally:
+ cur.close()
+ database._put_db_connection(conn)
+
+ _invalidate_static()
+ return {"filename": filename, "tags": tags_list}
+
+@app.post("/images/{filename:path}/tag-action")
+def tag_action_endpoint(filename: str, req: TagActionRequest):
+ person = database.get_person(filename)
+ if not person:
+ raise HTTPException(404, "not found")
+
+ tags_val = person[2]
+ tags_list = []
+ if tags_val:
+ if isinstance(tags_val, str):
+ try:
+ tags_list = json.loads(tags_val)
+ except Exception:
+ tags_list = []
+ elif isinstance(tags_val, list):
+ tags_list = tags_val
+
+ action = req.action.lower()
+ tag_name = req.tag.upper().strip()
+
+ if action == "add":
+ if tag_name not in tags_list:
+ tags_list.append(tag_name)
+ elif action == "remove":
+ if tag_name in tags_list:
+ tags_list.remove(tag_name)
+ elif action == "toggle":
+ if tag_name in tags_list:
+ tags_list.remove(tag_name)
+ else:
+ tags_list.append(tag_name)
+
+ if tag_name == "LIKE" and "LIKE" in tags_list:
+ if "DISLIKE" in tags_list:
+ tags_list.remove("DISLIKE")
+ elif tag_name == "DISLIKE" and "DISLIKE" in tags_list:
+ if "LIKE" in tags_list:
+ tags_list.remove("LIKE")
+
+ archived = "ARCHIVED" in tags_list
+ hidden = "HIDDEN" in tags_list
+ is_source = "SOURCE" in tags_list
+
+ if archived:
+ if "VISIBLE" in tags_list:
+ tags_list.remove("VISIBLE")
+ else:
+ if not hidden:
+ if "VISIBLE" not in tags_list:
+ tags_list.append("VISIBLE")
+
+ if hidden:
+ if "VISIBLE" in tags_list:
+ tags_list.remove("VISIBLE")
+ else:
+ if not archived:
+ if "VISIBLE" not in tags_list:
+ tags_list.append("VISIBLE")
+
+ conn = database.get_db_connection()
+ cur = conn.cursor()
+ try:
+ cur.execute("""
+ UPDATE person
+ SET tags = %s,
+ archived = %s,
+ hidden = %s,
+ is_source = %s
+ WHERE filename = %s
+ """, (json.dumps(tags_list), archived, hidden, is_source, filename))
+ conn.commit()
+ finally:
+ cur.close()
+ database._put_db_connection(conn)
+
+ _invalidate_static()
+ return {"filename": filename, "tags": tags_list}
+
+@app.post("/images/{filename:path}/source")
+def set_image_source(filename: str, body: dict):
+ is_source = bool(body.get("source", False))
+ person = database.get_person(filename)
+ if not person:
+ raise HTTPException(404, "not found")
+
+ tags_val = person[2]
+ tags_list = []
+ if tags_val:
+ if isinstance(tags_val, str):
+ try:
+ tags_list = json.loads(tags_val)
+ except Exception:
+ tags_list = []
+ elif isinstance(tags_val, list):
+ tags_list = tags_val
+
+ if is_source:
+ if "SOURCE" not in tags_list:
+ tags_list.append("SOURCE")
+ else:
+ if "SOURCE" in tags_list:
+ tags_list.remove("SOURCE")
+
+ conn = database.get_db_connection()
+ cur = conn.cursor()
+ try:
+ cur.execute("UPDATE person SET is_source = %s, tags = %s WHERE filename = %s", (is_source, json.dumps(tags_list), filename))
+ conn.commit()
+ finally:
+ cur.close()
+ database._put_db_connection(conn)
+
+ _invalidate_static()
+ return {"filename": filename, "is_source": is_source, "tags": tags_list}
+
+@app.post("/images/{filename:path}/estimate-21plus")
+def estimate_image_21plus(filename: str):
+ is_21plus = estimate_nsfw_21plus(filename)
+ person = database.get_person(filename)
+ if not person:
+ raise HTTPException(404, "not found")
+
+ tags_val = person[2]
+ tags_list = []
+ if tags_val:
+ if isinstance(tags_val, str):
+ try:
+ tags_list = json.loads(tags_val)
+ except Exception:
+ tags_list = []
+ elif isinstance(tags_val, list):
+ tags_list = tags_val
+
+ if is_21plus:
+ if "21+" not in tags_list:
+ tags_list.append("21+")
+ else:
+ if "21+" in tags_list:
+ tags_list.remove("21+")
+
+ conn = database.get_db_connection()
+ cur = conn.cursor()
+ try:
+ cur.execute("UPDATE person SET tags = %s WHERE filename = %s", (json.dumps(tags_list), filename))
+ conn.commit()
+ finally:
+ cur.close()
+ database._put_db_connection(conn)
+
+ _invalidate_static()
+ return {"filename": filename, "21+": is_21plus, "tags": tags_list}
+
+@app.post("/images/bulk-move")
+def bulk_move_endpoint(req: BulkMoveRequest):
+ if not req.filenames:
+ return {"status": "ok", "moved": 0}
+
+ target = req.target_strip.upper().strip()
+ conn = database.get_db_connection()
+ cur = conn.cursor()
+ try:
+ for filename in req.filenames:
+ cur.execute("SELECT tags, archived, hidden, is_source FROM person WHERE filename = %s", (filename,))
+ row = cur.fetchone()
+ if not row:
+ continue
+ tags_val, archived, hidden, is_source = row
+ tags_list = []
+ if tags_val:
+ if isinstance(tags_val, str):
+ try:
+ tags_list = json.loads(tags_val)
+ except Exception:
+ tags_list = []
+ elif isinstance(tags_val, list):
+ tags_list = tags_val
+
+ if target == "VISIBLE":
+ archived = False
+ hidden = False
+ if "VISIBLE" not in tags_list:
+ tags_list.append("VISIBLE")
+ if "ARCHIVED" in tags_list:
+ tags_list.remove("ARCHIVED")
+ if "HIDDEN" in tags_list:
+ tags_list.remove("HIDDEN")
+ elif target == "HIDDEN":
+ archived = False
+ hidden = True
+ if "HIDDEN" not in tags_list:
+ tags_list.append("HIDDEN")
+ if "VISIBLE" in tags_list:
+ tags_list.remove("VISIBLE")
+ if "ARCHIVED" in tags_list:
+ tags_list.remove("ARCHIVED")
+ elif target == "ARCHIVED":
+ archived = True
+ hidden = False
+ if "ARCHIVED" not in tags_list:
+ tags_list.append("ARCHIVED")
+ if "VISIBLE" in tags_list:
+ tags_list.remove("VISIBLE")
+ if "HIDDEN" in tags_list:
+ tags_list.remove("HIDDEN")
+ elif target == "SOURCE":
+ is_source = True
+ if "SOURCE" not in tags_list:
+ tags_list.append("SOURCE")
+
+ cur.execute("""
+ UPDATE person
+ SET tags = %s,
+ archived = %s,
+ hidden = %s,
+ is_source = %s
+ WHERE filename = %s
+ """, (json.dumps(tags_list), archived, hidden, is_source, filename))
+ conn.commit()
+ finally:
+ cur.close()
+ database._put_db_connection(conn)
+
+ _invalidate_static()
+ return {"status": "ok", "moved": len(req.filenames)}
+
+
class FaceSimilarRequest(BaseModel):
group_id: str
limit: int = 12
diff --git a/tour-comfy/poses.md b/tour-comfy/poses.md
index 55e7cac..b85a3e7 100644
--- a/tour-comfy/poses.md
+++ b/tour-comfy/poses.md
@@ -55,20 +55,20 @@ Keep full characteristics of reference image.
# Three-quarter (18)
-You are in a black empty void, transparent background.
+You are in a black empty void.
masterpiece, high quality, detailed, detailed skin.
Head-on a right-front.
Detailed full-nude-body.
Top-to-toe photo.
-female teenage, hyper realistic.
+female teenage, hyper-realistic.
Anatomically precise. Keep characteristics of reference image.
# Three-quarter
-You are in a black empty void.
-high quality, masterpiece, detailed, detailed skin.
-Head-on a right-front view. Top-to-toe.
-detailed full-nude-body, female portrait, photorealistic, transparent background.
+You are in a black empty void.
+high quality, masterpiece, detailed, detailed skin.
+Head-on a right-front view.
+Top-to-toe. detailed full-nude-body, female portrait, hyper-realistic.
Keep characteristics of reference image.
# Head-on undress (18)