updates UI

This commit is contained in:
mike
2026-07-01 02:25:51 +02:00
parent 66685684c1
commit 145fa686e4
22 changed files with 1559 additions and 159 deletions

View File

@@ -60,8 +60,11 @@ def get_processed_files():
def save_processed_files(processed):
try:
with open(CONF["processed_file"], 'w') as f:
p = CONF["processed_file"]
tmp = p + ".tmp"
with open(tmp, 'w') as f:
json.dump(processed, f, indent=2)
os.replace(tmp, p)
except Exception as e:
logging.error(f"Error saving processed file: {e}")
@@ -253,9 +256,11 @@ def update_car_html():
new_content = re.sub(pattern, replacement, content, flags=re.DOTALL)
with open(car_html_path, 'w') as f:
tmp_path = car_html_path + ".tmp"
with open(tmp_path, 'w') as f:
f.write(new_content)
logging.info(f"Updated {car_html_path} with {len(images)} images")
os.replace(tmp_path, car_html_path)
logging.info(f"Updated {car_html_path} atomically with {len(images)} images")
except Exception as e:
logging.error(f"Failed to update car.html: {e}")

View File

@@ -20,7 +20,7 @@ echo "Installing services: user=$SVC_USER group=$SVC_GROUP"
echo " a6k=$A6K"
echo " tour=$TOUR"
for unit in comfyui-backend comfyui-api comfyui-watcher; do
for unit in comfyui-backend comfyui-api; do
sed -e "s|__USER__|$SVC_USER|g" \
-e "s|__GROUP__|$SVC_GROUP|g" \
-e "s|__A6K__|$A6K|g" \
@@ -36,10 +36,10 @@ echo "Reloading systemd daemon..."
systemctl daemon-reload
echo "Enabling services + target..."
systemctl enable comfyui-backend.service comfyui-api.service comfyui-watcher.service comfyui.target
systemctl enable comfyui-backend.service comfyui-api.service comfyui.target
echo "Starting system..."
systemctl start comfyui.target
echo "Deployment complete."
echo "Check status with: systemctl status comfyui.target comfyui-backend comfyui-api comfyui-watcher"
echo "Check status with: systemctl status comfyui.target comfyui-backend comfyui-api"

View File

@@ -15,8 +15,15 @@ NV_LIBS=$(find "$VENV"/lib/python*/site-packages/nvidia -maxdepth 2 -name lib -t
export COMFY_URL="http://127.0.0.1:8188"
export HOST="0.0.0.0"
export PORT="8500"
# A6000 48GB is not VRAM-bound here, so default to a ~2MP output budget.
# This comfortably allows full-HD-ish outputs like 1920x1080.
# Override via MAX_AREA when needed.
export MAX_AREA="${MAX_AREA:-2097152}"
# @LEGACY PREVIOUS VERSION
# The A6000 is fast and not VRAM-bound on this model, so default to a full ~1MP
# output budget (tour caps at 0.65MP to survive the MI50). Override via MAX_AREA.
export MAX_AREA="${MAX_AREA:-1048576}"
# export MAX_AREA="${MAX_AREA:-1048576}"
exec python edit_api.py

View File

@@ -1,17 +0,0 @@
[Unit]
Description=Qwen-Image-Edit Folder Watcher (A6000)
After=comfyui-api.service
Requires=comfyui-api.service
[Service]
Type=simple
User=__USER__
Group=__GROUP__
ExecStart=/bin/bash __TOUR__/start_watcher.sh
Restart=on-failure
RestartSec=5
StandardOutput=journal
StandardError=journal
[Install]
WantedBy=comfyui.target

View File

@@ -1,7 +1,7 @@
[Unit]
Description=Qwen-Image-Edit Complete System (A6000)
Wants=comfyui-backend.service comfyui-api.service comfyui-watcher.service
After=comfyui-watcher.service
Description=Afterimage (A6000)
Wants=comfyui-backend.service comfyui-api.service
After=comfyui-backend.service comfyui-api.service
[Install]
WantedBy=multi-user.target

View File

@@ -82,7 +82,21 @@ Scenery:
- tab "SAM" checkerboard icon to studio-view overlay
- in the landing page hide SOURCE images
- als het input bestand real is, dan gaat echt elke opvolging perfect. supe plaatjes.. (3 kwart maakt de opvolgende plaatjes ook altijd engaging.. top down (vanwege ruimte))
-
- When open the app via the launcher, make it privacy enabled by default, so the privacy screen will appear.
- When open a group by default hide orbit images, videos
- Do not show videos in the landingpage image-loop
- Make - html per group and html for the landing page. Decouple the landingpage from the studio and studio is hydrated available per group
- update svg
- Extract the heavy model API, so we can unit-test it. ( and we can stand-alone disable it ) and doesnt require the entire 41gb model to load into memory for a simple backend update
- Save pose-prompt history + refine + reverse engineer + Scenery prompt in database. create a new db table called prompt, add the type of prompt for exmaple refine or scene to the record and all relevant information used to recontruct the prompt input. so also include a jsonb for metadata/tags etc.
- In order to decouple the model-engine (by design it can be turned off) and the backend (by design it can be turned off)
- in the UI right top make two dots indicating the model-engine, backend is online and if its (actively processing a task), use the healthchecks of the services to check realtime status.
- in case the Model-engine is offline, we want to disable the UI-actions that require the model-engine, such as pose generation,
- in case the backend is offline, we want to disable the UI-actions that require the backend, such as duplicate, pad, crop, etc..
Start by implmenting the mechanics of the status leds in the UI. and disable one or two features that require the model-engine or the backend to be online. We complete the task after review
- in order to decouple the landingpage/dashboard from the studio view (editor) we are going to generate group "shoot" specific html pages. Start by just generate the studio portion of car.html into the group specifc html pages. It does not need to be synchronous, but when data of the group is changing we should update the group specifc data json file too, we copy the goup files to the output folder next to car.html
- in /mnt/zim/tour-comfy/output/_turntable we have data regarding orbit generations. We want to store the orbit data in the database, start by adding the corresponding tags and meta info to the images (currently stored in the person table (but actually are images with metadata))
- in the ui - studio view we have an orbit filter. Currently the filter is filtering too many files, also references of orbit files are being marked orbit. When an orbit file is used, the metadata should not include the orbit tag.
## refine
- when refresh page, we lose track of current jobs running.
@@ -129,7 +143,8 @@ TRACKING
4) add a new video filter, and hide video by default in the Group Active
-- feel free to furhter optimize the studio view "info" tab
1) for every image we have an entry in the database, keep for every image an entry with the same filename in the same folder but ending with json. in the json we store the functional representation of the database record and keep it up-to-date when data changes. .
so we have a postgres-db, the frontend car.html and the backend edit_api.py
1) for every image we have an entry in the database table person, keep for every image an entry with the same filename in the same folder but ending with json. in the json we store the functional representation of the database record and keep it up-to-date when data changes. .
In the app we looking at groups of images, so also keep track of the group, this include the additional functional features we show in the UI for that group with the images it includes. Keep the data up-to-date in the backend when data changes, also update the referring data files.
2) introduce a rating feature. to thumps up or thumbs down an image. When an image gets athamps up or thumbs down add a tag in the db and show it in the UI. Also the default filter in "Group Active" should filter out the images with a negative rating, but also include a filter that would show only images with a positive rating. The rating should also cascade (calculated? perhaps) into the poses. So images with pose that have good rating will define the rating of a pose, same for the image group (use a clever normalized formula ) over the positive and negative thumbs to compare it with another group
3) introduce (?cosine) similarity variation indexes in a group. Also for the faces in a group. To keep track of authenticity of a person in a group

22
studio.sh Executable file
View File

@@ -0,0 +1,22 @@
#!/bin/bash
# Launch the studio UI in a clean Chrome profile (no stale cache)
TMPDIR=$(mktemp -d /tmp/chrome-studio-XXXXXX)
trap "rm -rf '$TMPDIR'" EXIT
DRI_PRIME=pci-0000_02_00_0 \
google-chrome \
--disable-web-security \
--allow-file-access-from-files \
--user-data-dir="$TMPDIR" \
--disable-dev-shm-usage \
--no-first-run \
--no-default-browser-check \
--disable-infobars \
--test-type \
--ozone-platform=x11 \
--disable-vulkan \
--use-gl=desktop \
--log-level=3 \
--silent-debugger-extension-api \
--app="file:///mnt/zim/tour-comfy/output/car.html" \
2>/dev/null

File diff suppressed because it is too large Load Diff

View File

@@ -101,6 +101,17 @@ def migrate_schema():
conn = get_db_connection()
cur = conn.cursor()
try:
# Create prompt table first
cur.execute("""
CREATE TABLE IF NOT EXISTS prompt (
id SERIAL PRIMARY KEY,
type TEXT NOT NULL,
prompt_text TEXT NOT NULL,
metadata JSONB DEFAULT '{}'::jsonb,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT unique_prompt_type_text UNIQUE (type, prompt_text)
)
""")
for sql in [
"ALTER TABLE person ADD COLUMN IF NOT EXISTS prompt TEXT",
"ALTER TABLE person ADD COLUMN IF NOT EXISTS pose TEXT",
@@ -262,6 +273,11 @@ def upsert_person(filename, filepath=None, name=None, group_id=None, tags=None,
pose_description, pose_skeleton, people_count, anatomical_completeness, facial_direction,
json.dumps(objects) if objects else None))
conn.commit()
# Sync after commit
try:
sync_by_filename_async(filename)
except Exception:
pass
finally:
cur.close()
_put_db_connection(conn)
@@ -272,6 +288,10 @@ def set_archived(filename, archived: bool):
try:
cur.execute("UPDATE person SET archived = %s WHERE filename = %s", (archived, filename))
conn.commit()
try:
sync_by_filename_async(filename)
except Exception:
pass
finally:
cur.close()
_put_db_connection(conn)
@@ -286,6 +306,11 @@ def set_filenames_archived(filenames, archived: bool):
rows = cur.fetchall()
updated = [r[0] for r in rows]
conn.commit()
for fn in updated:
try:
sync_by_filename_async(fn)
except Exception:
pass
return updated
finally:
cur.close()
@@ -297,6 +322,10 @@ def set_hidden(filename, hidden: bool):
try:
cur.execute("UPDATE person SET hidden = %s WHERE filename = %s", (hidden, filename))
conn.commit()
try:
sync_by_filename_async(filename)
except Exception:
pass
finally:
cur.close()
_put_db_connection(conn)
@@ -307,6 +336,10 @@ def set_person_tags(filename, tags):
try:
cur.execute("UPDATE person SET tags = %s WHERE filename = %s", (json.dumps(tags) if tags is not None else None, filename))
conn.commit()
try:
sync_by_filename_async(filename)
except Exception:
pass
finally:
cur.close()
_put_db_connection(conn)
@@ -363,21 +396,56 @@ def search_similar(embedding, limit=10):
_put_db_connection(conn)
def delete_person(filename):
person = get_person(filename)
gid = person[1] if person else None
conn = get_db_connection()
cur = conn.cursor()
try:
cur.execute("DELETE FROM person WHERE filename = %s", (filename,))
conn.commit()
# Delete JSON file on disk
try:
out_dir = _get_output_dir()
base, _ = os.path.splitext(filename)
json_path = os.path.join(out_dir, base + ".json")
if os.path.exists(json_path):
os.remove(json_path)
except Exception:
pass
if gid:
try:
sync_group_to_json_async(gid)
except Exception:
pass
finally:
cur.close()
_put_db_connection(conn)
def delete_group(group_id):
files = get_group_files(group_id)
conn = get_db_connection()
cur = conn.cursor()
try:
cur.execute("DELETE FROM person WHERE group_id = %s", (group_id,))
conn.commit()
# Delete JSON files on disk
out_dir = _get_output_dir()
for f in files:
try:
base, _ = os.path.splitext(f[0])
json_path = os.path.join(out_dir, base + ".json")
if os.path.exists(json_path):
os.remove(json_path)
except Exception:
pass
try:
group_json_path = os.path.join(out_dir, "_data", f"group_{group_id}.json")
if os.path.exists(group_json_path):
os.remove(group_json_path)
except Exception:
pass
finally:
cur.close()
_put_db_connection(conn)
@@ -430,6 +498,13 @@ def set_group_order(group_id, ordered_filenames):
finally:
cur.close()
_put_db_connection(conn)
try:
sync_group_to_json_async(group_id)
for fname in ordered_filenames:
sync_person_to_json_async(fname)
except Exception:
pass
def get_group_order(group_id):
"""Return [(filename, sort_order), ...] sorted by sort_order NULLS LAST."""
@@ -454,6 +529,13 @@ def set_group_name(group_id, name):
try:
cur.execute("UPDATE person SET group_name = %s WHERE group_id = %s", (name, group_id))
conn.commit()
try:
sync_group_to_json_async(group_id)
files = get_group_files(group_id)
for f in files:
sync_person_to_json_async(f[0])
except Exception:
pass
finally:
cur.close()
_put_db_connection(conn)
@@ -560,3 +642,367 @@ def invalidate_all_metadata():
finally:
cur.close()
_put_db_connection(conn)
import os
import math
def _get_output_dir():
# Load config.json to find output_dir
config_paths = ["config.json", "tour-comfy/config.json", "../config.json"]
for p in config_paths:
if os.path.exists(p):
try:
with open(p, "r") as f:
cfg = json.load(f)
if "output_dir" in cfg:
return cfg["output_dir"]
except Exception:
pass
return "../output" # Default fallback
def sync_person_to_json(filename):
"""Write/sync a detailed .json file with the DB fields of the specified filename."""
conn = get_db_connection()
cur = conn.cursor()
try:
cur.execute("""
SELECT filename, filepath, name, group_id, tags, embedding,
clip_description, prompt, pose, sort_order, group_name, hidden,
has_background, source_refs, has_clothing, content_type,
faceswap_source_video, archived, face_embedding, is_source,
pose_description, pose_skeleton, people_count, anatomical_completeness,
facial_direction, objects
FROM person
WHERE filename = %s
""", (filename,))
row = cur.fetchone()
if not row:
return
# Parse tags
tags_val = row[4]
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
# Parse objects
obj_val = row[25]
obj_list = []
if obj_val:
if isinstance(obj_val, str):
try: obj_list = json.loads(obj_val)
except Exception: obj_list = []
elif isinstance(obj_val, list):
obj_list = obj_val
# Parse source_refs
ref_val = row[13]
ref_list = []
if ref_val:
if isinstance(ref_val, str):
try: ref_list = json.loads(ref_val)
except Exception: ref_list = []
elif isinstance(ref_val, list):
ref_list = ref_val
# Represent embeddings as lists of floats (for json compatibility)
embedding_list = None
if row[5] is not None:
if isinstance(row[5], str):
embedding_list = [float(x) for x in row[5].strip("[]").split(",") if x.strip()]
else:
embedding_list = list(row[5])
face_embedding_list = None
if row[18] is not None:
if isinstance(row[18], str):
face_embedding_list = [float(x) for x in row[18].strip("[]").split(",") if x.strip()]
else:
face_embedding_list = list(row[18])
# Parse pose_skeleton
pose_skel = None
if row[21]:
try:
pose_skel = json.loads(row[21])
except Exception:
pose_skel = row[21]
data = {
"filename": row[0],
"filepath": row[1],
"name": row[2],
"group_id": row[3],
"tags": tags_list,
"embedding": embedding_list,
"clip_description": row[6],
"prompt": row[7],
"pose": row[8],
"sort_order": row[9],
"group_name": row[10],
"hidden": bool(row[11]) if row[11] is not None else False,
"has_background": bool(row[12]) if row[12] is not None else True,
"source_refs": ref_list,
"has_clothing": row[14],
"content_type": row[15] or "image",
"faceswap_source_video": row[16],
"archived": bool(row[17]) if row[17] is not None else False,
"face_embedding": face_embedding_list,
"is_source": bool(row[19]) if row[19] is not None else False,
"pose_description": row[20],
"pose_skeleton": pose_skel,
"people_count": row[22],
"anatomical_completeness": row[23],
"facial_direction": row[24],
"objects": obj_list
}
# Determine json path
out_dir = _get_output_dir()
if row[1] and os.path.isabs(row[1]):
# Use same directory as absolute filepath
base, _ = os.path.splitext(row[1])
json_path = base + ".json"
else:
# Fallback to output_dir
base, _ = os.path.splitext(row[0])
json_path = os.path.join(out_dir, base + ".json")
os.makedirs(os.path.dirname(json_path), exist_ok=True)
tmp_json_path = json_path + ".tmp"
with open(tmp_json_path, "w") as f:
json.dump(data, f, indent=2)
os.replace(tmp_json_path, json_path)
except Exception as e:
print(f"[db] Error syncing person {filename} to JSON: {e}")
finally:
cur.close()
_put_db_connection(conn)
def sync_group_to_json(group_id):
"""Sync all members of a group, calculate group rating and similarity variation,
and save to _data/group_<group_id>.json.
"""
if not group_id:
return
conn = get_db_connection()
cur = conn.cursor()
try:
# Fetch all members
cur.execute("""
SELECT filename, filepath, name, tags, embedding,
clip_description, prompt, pose, sort_order, group_name, hidden,
has_background, source_refs, content_type, faceswap_source_video,
archived, face_embedding, is_source, pose_description, pose_skeleton
FROM person
WHERE group_id = %s
ORDER BY sort_order ASC, filename ASC
""", (group_id,))
rows = cur.fetchall()
members = []
likes = 0
dislikes = 0
clip_embs = []
face_embs = []
for r in rows:
tags_val = r[3]
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 "LIKE" in tags_list:
likes += 1
elif "DISLIKE" in tags_list:
dislikes += 1
# Parse embeddings for similarity
if r[4] is not None:
if isinstance(r[4], str):
clip_list = [float(x) for x in r[4].strip("[]").split(",") if x.strip()]
else:
clip_list = list(r[4])
if clip_list:
clip_embs.append(clip_list)
if r[16] is not None:
if isinstance(r[16], str):
face_list = [float(x) for x in r[16].strip("[]").split(",") if x.strip()]
else:
face_list = list(r[16])
if face_list:
face_embs.append(face_list)
members.append({
"filename": r[0],
"filepath": r[1],
"name": r[2],
"tags": tags_list,
"clip_description": r[5],
"prompt": r[6],
"pose": r[7],
"sort_order": r[8],
"hidden": bool(r[10]),
"archived": bool(r[15]),
"is_source": bool(r[17])
})
def cosine_similarity(v1, v2):
if not v1 or not v2: return 0.0
dot = sum(a * b for a, b in zip(v1, v2))
norm1 = math.sqrt(sum(a * a for a in v1))
norm2 = math.sqrt(sum(b * b for b in v2))
if norm1 == 0 or norm2 == 0: return 0.0
return dot / (norm1 * norm2)
def calc_stats(embs):
if len(embs) < 2:
return {"average": 1.0, "min": 1.0, "max": 1.0, "variation": 0.0, "pairs_count": 0}
similarities = []
for i in range(len(embs)):
for j in range(i + 1, len(embs)):
similarities.append(cosine_similarity(embs[i], embs[j]))
if not similarities:
return {"average": 1.0, "min": 1.0, "max": 1.0, "variation": 0.0, "pairs_count": 0}
avg_sim = sum(similarities) / len(similarities)
min_sim = min(similarities)
max_sim = max(similarities)
variance = sum((s - avg_sim) ** 2 for s in similarities) / len(similarities)
return {
"average": float(avg_sim),
"min": float(min_sim),
"max": float(max_sim),
"variation": float(math.sqrt(variance)),
"pairs_count": len(similarities)
}
group_name = rows[0][9] if rows else group_id
group_data = {
"group_id": group_id,
"group_name": group_name,
"rating": {
"likes": likes,
"dislikes": dislikes,
"score": likes - dislikes,
"normalized_score": (likes - dislikes) / (likes + dislikes + 2.0) if (likes + dislikes) > 0 else 0.0,
"ratio": likes / (likes + dislikes) if (likes + dislikes) > 0 else 0.0
},
"clip_similarity_stats": calc_stats(clip_embs),
"face_similarity_stats": calc_stats(face_embs),
"members": members
}
out_dir = _get_output_dir()
data_dir = os.path.join(out_dir, "_data")
os.makedirs(data_dir, exist_ok=True)
json_path = os.path.join(data_dir, f"group_{group_id}.json")
tmp_json_path = json_path + ".tmp"
with open(tmp_json_path, "w") as f:
json.dump(group_data, f, indent=2)
os.replace(tmp_json_path, json_path)
except Exception as e:
print(f"[db] Error syncing group {group_id} to JSON: {e}")
finally:
cur.close()
_put_db_connection(conn)
def sync_by_filename(filename):
sync_person_to_json(filename)
person = get_person(filename)
if person and person[1]:
sync_group_to_json(person[1])
from concurrent.futures import ThreadPoolExecutor
_sync_executor = ThreadPoolExecutor(max_workers=2, thread_name_prefix="db_sync_worker")
def sync_by_filename_async(filename):
_sync_executor.submit(sync_by_filename, filename)
def sync_group_to_json_async(group_id):
_sync_executor.submit(sync_group_to_json, group_id)
def sync_person_to_json_async(filename):
_sync_executor.submit(sync_person_to_json, filename)
def save_db_prompt(prompt_type: str, prompt_text: str, metadata: dict = None):
conn = get_db_connection()
cur = conn.cursor()
try:
cur.execute("""
INSERT INTO prompt (type, prompt_text, metadata, created_at)
VALUES (%s, %s, %s, NOW())
ON CONFLICT (type, prompt_text) DO UPDATE
SET metadata = EXCLUDED.metadata,
created_at = NOW()
""", (prompt_type, prompt_text, json.dumps(metadata) if metadata is not None else None))
conn.commit()
except Exception as e:
print(f"[db] error saving prompt {prompt_type}: {e}")
try:
conn.rollback()
except Exception:
pass
finally:
cur.close()
_put_db_connection(conn)
def list_db_prompts(prompt_type: str = None, limit: int = 100):
conn = get_db_connection()
cur = conn.cursor()
try:
if prompt_type:
cur.execute("""
SELECT id, type, prompt_text, metadata, created_at
FROM prompt
WHERE type = %s
ORDER BY created_at DESC
LIMIT %s
""", (prompt_type, limit))
else:
cur.execute("""
SELECT id, type, prompt_text, metadata, created_at
FROM prompt
ORDER BY created_at DESC
LIMIT %s
""", (limit,))
rows = cur.fetchall()
result = []
for r in rows:
meta = r[3]
if isinstance(meta, str):
try:
meta = json.loads(meta)
except Exception:
meta = {}
elif meta is None:
meta = {}
result.append({
"id": r[0],
"type": r[1],
"prompt_text": r[2],
"metadata": meta,
"created_at": r[4].isoformat() if r[4] else None
})
return result
except Exception as e:
print(f"[db] error listing prompts: {e}")
return []
finally:
cur.close()
_put_db_connection(conn)

View File

@@ -50,9 +50,13 @@ WORKFLOW_PATH = os.environ.get(
"WORKFLOW_PATH",
os.path.join(os.path.dirname(os.path.abspath(__file__)), "workflow_qwen_edit.json"),
)
# Default target pixel area for the output latent. The MI50 is not fast, so we
# cap at ~1MP by default; raise via MAX_AREA env if you want bigger output.
MAX_AREA = int(os.environ.get("MAX_AREA", str(1024 * 1024)))
# Default target pixel area for the output latent.
# We currently cap at ~1MP by default; raise via MAX_AREA env if you want bigger output.
# A6000 48GB is not VRAM-bound here, so default to a ~2MP output budget.
# This comfortably allows full-HD-ish outputs like 1920x1080.
# Override via MAX_AREA when needed.
#export MAX_AREA="${MAX_AREA:-2097152}"
MAX_AREA = int(os.environ.get("MAX_AREA", str(2097152)))
GEN_TIMEOUT = int(os.environ.get("GEN_TIMEOUT", "600")) # seconds per request
# Node ids in workflow_qwen_edit.json (kept stable on purpose).
@@ -1758,6 +1762,23 @@ def _batch_worker(job_id: str, filenames: list, prompts: list[str], poses: list,
if jobs[job_id].get("cancelled"):
return
try:
try:
database.save_db_prompt("pose-prompt", prompt, {
"pose": pose,
"seed": seed,
"max_area": max_area,
"wireframe_ref": wireframe_ref,
"wireframe_time": wireframe_time,
"pad_top": pad_top,
"pad_right": pad_right,
"pad_bottom": pad_bottom,
"pad_left": pad_left,
"pad_fill": pad_fill,
"pad_outpaint": pad_outpaint
})
except Exception as db_err:
print(f"[batch] failed to save to prompt table: {db_err}")
pil = base_pil
actual_prompt = prompt
if pad_outpaint:
@@ -1865,6 +1886,21 @@ def _multi_ref_worker(job_id: str, filenames: list[str], prompts: list[str], pos
for prompt, pose in zip(prompts, poses):
try:
try:
database.save_db_prompt("pose-prompt", prompt, {
"pose": pose,
"seed": seed,
"max_area": max_area,
"filenames": filenames,
"pad_top": pad_top,
"pad_right": pad_right,
"pad_bottom": pad_bottom,
"pad_left": pad_left,
"pad_fill": pad_fill,
"pad_outpaint": pad_outpaint
})
except Exception as db_err:
print(f"[multi-ref] failed to save to prompt table: {db_err}")
work_pil = primary_pil
actual_prompt = prompt
if pad_outpaint:
@@ -1947,6 +1983,23 @@ def update_config(update: ConfigUpdate):
return {"seed": conf["seed"]}
class SavePromptRequest(BaseModel):
type: str
prompt_text: str
metadata: dict | None = None
@app.post("/prompts")
def api_save_prompt(req: SavePromptRequest):
database.save_db_prompt(req.type, req.prompt_text, req.metadata)
return {"status": "success"}
@app.get("/prompts")
def api_list_prompts(type: str | None = None, limit: int = 100):
return database.list_db_prompts(type, limit)
class GroupArchiveRequest(BaseModel):
filenames: list[str]
@@ -2123,12 +2176,173 @@ def refine_prompt(req: RefineRequest):
r.raise_for_status()
data = r.json()
refined = data["choices"][0]["message"]["content"].strip()
try:
database.save_db_prompt("refine", refined, {
"original": req.prompt,
"filename": req.filename
})
except Exception as db_err:
print(f"[refine-prompt] failed to save to prompt table: {db_err}")
return {"refined": refined}
except Exception as e:
print(f"Refinement error: {e}")
raise HTTPException(500, f"LLM refinement failed: {str(e)}")
class UpdatePromptRequest(BaseModel):
prompt: str
@app.post("/images/{filename:path}/reverse-engineer")
def reverse_engineer(filename: str):
person = database.get_person(filename)
if not person:
raise HTTPException(404, "Image not found in database")
# Extract metadata on the fly if pose_description is not present
if person[15] is None:
try:
metadata = _process_image_for_metadata(filename)
if metadata:
# Reload person row
person = database.get_person(filename)
except Exception as e:
print(f"Failed to process image for metadata during reverse-engineer: {e}")
# Build context string
tags_val = person[2]
clip_desc_val = person[4]
original_prompt = person[6]
pose_desc = person[15]
people_count = person[17]
anatomical_completeness = person[18]
facial_direction = person[19]
objects_val = person[20]
context_parts = []
# 1. Base prompt or tags
if original_prompt:
context_parts.append(f"Original Prompt/Tags: {original_prompt}")
elif clip_desc_val:
context_parts.append(f"Scene Tags Description: {clip_desc_val}")
# 2. WD Tagger tags
if tags_val:
try:
if isinstance(tags_val, str):
tags_list = json.loads(tags_val)
else:
tags_list = tags_val
if tags_list:
tag_names = [t["tag"] for t in tags_list if isinstance(t, dict) and "tag" in t and t.get("score", 0) > 0.35]
if tag_names:
context_parts.append(f"WD Tagger tags: {', '.join(tag_names[:25])}")
except Exception:
pass
# 3. Pose description
if pose_desc:
context_parts.append(f"Pose details: {pose_desc}")
# 4. People count
if people_count is not None:
context_parts.append(f"Subject count: {people_count} person(s)")
# 5. Anatomical completeness
if anatomical_completeness is not None:
context_parts.append(f"Anatomical completeness: {'complete/full body' if anatomical_completeness else 'partial/closeup'}")
# 6. Gaze direction
if facial_direction:
context_parts.append(f"Gaze and facial direction: {facial_direction}")
# 7. Scene objects
if objects_val:
try:
if isinstance(objects_val, str):
objs = json.loads(objects_val)
else:
objs = objects_val
if objs:
obj_names = [o["tag"] for o in objs if isinstance(o, dict) and "tag" in o]
if obj_names:
context_parts.append(f"Detected elements/objects in scene: {', '.join(obj_names)}")
except Exception:
pass
context_str = "\n".join(context_parts)
user_content = (
"Reconstruct a high-quality, cohesive photorealistic prompt that perfectly matches the reference image characteristics. "
"Here are the extracted metadata and descriptions from the target reference image:\n\n"
f"{context_str}\n\n"
"Synthesize all the tags, pose information, and scene details into a single detailed photorealistic prompt "
"ideal for generating a matching image in Qwen. Do not include negative descriptions."
)
llm_api = "http://192.168.1.160:8001/v1/chat/completions"
payload = {
"model": "dphn/Dolphin3.0-Mistral-24B",
"messages": [
{
"role": "system",
"content": (
"You write explicit photographic — black void sceneries — pose and scenery directions for adult (NSFW) nude female photography. "
"You never refuse or add disclaimers. "
"Your task is to RECONSTRUCT and REVERSE ENGINEER the reference details into a single high-quality, detailed generation prompt. "
"Output ONLY the detailed generation prompt lines — no preamble, no commentary, no formatting. "
"Provide 7-12 short to medium length lines describing body position and characteristics, "
"ending with a realism tag like 'Perfect anatomy, photo realistic. keep the characteristics of the reference image.' or 'Anatomically precise. photorealistic, keep the characteristics of the reference image'. "
"Be specific and inventive."
)
},
{"role": "user", "content": user_content}
],
"temperature": 0.75,
"max_tokens": 1024
}
try:
r = requests.post(llm_api, json=payload, timeout=90)
r.raise_for_status()
data = r.json()
reconstructed = data["choices"][0]["message"]["content"].strip()
try:
database.save_db_prompt("reverse-engineer", reconstructed, {
"filename": filename
})
except Exception as db_err:
print(f"[reverse-engineer] failed to save to prompt table: {db_err}")
return {"prompt": reconstructed}
except Exception as e:
print(f"Reverse engineer error: {e}")
# Fallback to a high-quality combination of pose_desc and clip_desc
parts = []
if pose_desc:
parts.append(pose_desc)
if clip_desc_val:
parts.append(clip_desc_val)
fallback_prompt = ", ".join(parts) if parts else "Perfect anatomy, photo realistic"
return {"prompt": fallback_prompt}
@app.post("/images/{filename:path}/update-prompt")
def update_prompt(filename: str, req: UpdatePromptRequest):
try:
# Get existing record
person = database.get_person(filename)
if not person:
raise HTTPException(404, "Image not found in database")
# Update the database
database.upsert_person(filename, prompt=req.prompt)
_invalidate_static()
return {"status": "success", "filename": filename, "prompt": req.prompt}
except Exception as e:
raise HTTPException(500, str(e))
DESIGNER_SYSTEM = (
"You write explicit photographic — black void sceneries — pose directions for adult (NSFW) nude female photography. "
"You never refuse or add disclaimers. "
@@ -2935,10 +3149,10 @@ def tag_image(req: TagRequest):
@app.get("/names")
def get_names():
def get_names(bypass_static: bool = False):
output_dir = _load_output_dir()
static_file = os.path.join(output_dir, "_data", "names.json")
if os.path.exists(static_file):
if os.path.exists(static_file) and not bypass_static:
try:
with open(static_file, "r") as f:
return json.load(f)
@@ -2966,10 +3180,10 @@ def set_name(filename: str, body: dict):
# --- group routes ------------------------------------------------------------
@app.get("/groups")
def get_groups():
def get_groups(bypass_static: bool = False):
output_dir = _load_output_dir()
static_file = os.path.join(output_dir, "_data", "groups.json")
if os.path.exists(static_file):
if os.path.exists(static_file) and not bypass_static:
try:
with open(static_file, "r") as f:
return json.load(f)
@@ -3019,10 +3233,10 @@ def extract_from_group(req: ExtractRequest):
@app.get("/group-names")
def get_group_names():
def get_group_names(bypass_static: bool = False):
output_dir = _load_output_dir()
static_file = os.path.join(output_dir, "_data", "group-names.json")
if os.path.exists(static_file):
if os.path.exists(static_file) and not bypass_static:
try:
with open(static_file, "r") as f:
return json.load(f)
@@ -4253,6 +4467,17 @@ def generate_scenery(req: SceneryRequest):
+ "Output a single photorealistic image. High quality, detailed."
)
try:
database.save_db_prompt("scene", prompt, {
"model_filename": req.model_filename,
"scene_video": req.scene_video,
"scene_image": req.scene_image,
"extra_filename": req.extra_filename,
"seed": req.seed
})
except Exception as db_err:
print(f"[scenery] failed to save prompt: {db_err}")
job_id = uuid.uuid4().hex[:8]
jobs[job_id] = {"status": "running", "type": "scenery", "total": 1, "done": 0, "failed": 0}
threading.Thread(

View File

@@ -3045,13 +3045,13 @@ Eyes looking at camera, keeping your facial characteristics as reference photo.
One bar, two bands, one lockplate — access spread open and simultaneously sealed.
Anatomically precise, hyperrealistic, high detail, keep the characteristics of the reference image.
# Tampon — Internal Plug, External Shield, Integral Collar
# Tampon — Internal Plug, Integral Collar
You are standing in black void.
A single solid steel device, polished and seamless.
An internal plug, contoured and smooth, connected by a short rigid bar to a small external shield covering your vulva.
From the shield, a thin rigid steel bar rises up your abdomen and sternum, terminating in a collar locked around your neck.
One continuous piece — internal plug, external shield, connecting bar, collar.
One continuous piece — internal plug, connecting bar, collar.
Your wrists cuffed to the vertical bar at your hips.
Your ankles cuffed together.
The device occupies and seals simultaneously — internal and external denial in one form.

View File

@@ -0,0 +1,114 @@
import os
import sys
import time
import unittest
from fastapi.testclient import TestClient
# Ensure tour-comfy is in the import path
_HERE = os.path.dirname(os.path.abspath(__file__))
if _HERE not in sys.path:
sys.path.append(_HERE)
from edit_api import app, _load_output_dir
class TestQwenBackendRegression(unittest.TestCase):
@classmethod
def setUpClass(cls):
cls.client = TestClient(app)
cls.output_dir = _load_output_dir()
# Real-data filenames specified in the issue description
cls.real_img_1 = "20260618_053519_1_20260618_053458_image.png"
cls.real_img_2 = "20260618_052537_0_20260618_052526_image.png"
cls.wireframe_img = "up_20260628_104641_image.png"
# Validate that they exist on disk to run real end-to-end tests
cls.run_real_tests = True
img1_path = os.path.join(cls.output_dir, cls.real_img_1)
img2_path = os.path.join(cls.output_dir, cls.real_img_2)
if not os.path.exists(img1_path) or not os.path.exists(img2_path):
cls.run_real_tests = False
print(f"[test] Real images not found at {img1_path} or {img2_path}. Running tests with fallback/mock assertion modes.")
def test_multi_ref_qwen_endpoint(self):
"""Test multi-reference generation endpoint with specified real-data images."""
if not self.run_real_tests:
self.skipTest("Skipping real backend test due to missing real image artifacts on this node.")
payload = {
"filenames": [self.real_img_1, self.real_img_2],
"prompt": "standing girl looking at camera, high quality, highly detailed",
"seed": 42,
"max_area": 512 * 512,
"pad_outpaint": False
}
response = self.client.post("/multi-ref", json=payload)
self.assertEqual(response.status_code, 200)
data = response.json()
self.assertIn("job_id", data)
job_id = data["job_id"]
# Poll status until done (limit to 300 seconds to prevent hanging if backend is offline)
print(f"[test] Polling multi-ref job {job_id} until completion...")
start_time = time.time()
completed = False
while time.time() - start_time < 300:
status_resp = self.client.get(f"/batch/{job_id}")
if status_resp.status_code == 200:
job_data = status_resp.json()
status = job_data.get("status")
print(f"[test] Job status: {status} (done: {job_data.get('done')}/{job_data.get('total')})")
if status == "done":
completed = True
break
elif status in ["error", "cancelled"]:
break
time.sleep(3)
self.assertTrue(completed, "Multi-ref generation job did not complete successfully in time.")
def test_scenery_qwen_endpoint(self):
"""Test scenery generation endpoint with specified wireframe scene image and prompt."""
if not self.run_real_tests:
self.skipTest("Skipping real backend test due to missing real image artifacts on this node.")
payload = {
"model_filename": self.real_img_1,
"scene_image": self.wireframe_img,
"prompt": "replace person from image 1 naturally with the person from Image 2. Keep the exact position of Image 1, and the exact person of Image 2",
"seed": 42
}
response = self.client.post("/generate-scenery", json=payload)
self.assertEqual(response.status_code, 200)
data = response.json()
self.assertIn("job_id", data)
job_id = data["job_id"]
# Poll status until done
print(f"[test] Polling scenery job {job_id} until completion...")
start_time = time.time()
completed = False
while time.time() - start_time < 300:
status_resp = self.client.get(f"/batch/{job_id}")
if status_resp.status_code == 200:
job_data = status_resp.json()
status = job_data.get("status")
print(f"[test] Job status: {status} (done: {job_data.get('done')}/{job_data.get('total')})")
if status == "done":
completed = True
break
elif status in ["error", "cancelled"]:
break
time.sleep(3)
self.assertTrue(completed, "Scenery generation job did not complete successfully in time.")
if __name__ == "__main__":
unittest.main()