diff --git a/.junie/models/local-devstral24b.json b/.junie/models/local-devstral24b.json new file mode 100644 index 0000000..95e549c --- /dev/null +++ b/.junie/models/local-devstral24b.json @@ -0,0 +1,14 @@ +{ + "id": "devstral24b-junie:latest", + "baseUrl": "http://localhost:11434/v1/chat/completions", + "apiType": "OpenAICompletion", + "temperature": 0.05, + "primaryModel": { + "id": "devstral24b-junie:latest", + "temperature": 0.05 + }, + "fasterModel": { + "id": "devstral24b-junie:latest", + "temperature": 0.05 + } +} diff --git a/.junie/models/local-ollama.json b/.junie/models/local-ollama.json index cbf518a..eb42966 100644 --- a/.junie/models/local-ollama.json +++ b/.junie/models/local-ollama.json @@ -1,11 +1,11 @@ { "baseUrl": "http://localhost:11434/v1/responses", - "id": "qwen3-coder:30b", + "id": "qwen2.5-coder:32b", "apiType": "OpenAIResponses", - "temperature": 0.3, + "temperature": 0.1, "primaryModel": { - "id": "qwen3-coder:30b", - "temperature": 0.3 + "id": "qwen2.5-coder:32b", + "temperature": 0.1 }, "fasterModel": { "id": "qwen2.5-coder:1.5b" diff --git a/.junie/models/local-qwen25.json b/.junie/models/local-qwen25.json index 304bf22..80d70dc 100644 --- a/.junie/models/local-qwen25.json +++ b/.junie/models/local-qwen25.json @@ -1,14 +1,13 @@ { - "baseUrl": "http://localhost:11434/v1/responses", - "id": "qwen25-coder-32b-64k:latest", + "baseUrl": "http://localhost:11440/v1/responses", + "id": "qwen3-coder:30b", "apiType": "OpenAIResponses", "temperature": 0.3, "primaryModel": { - "id": "qwen25-coder-32b-64k:latest", - "temperature": 0.2 + "id": "qwen3-coder:30b", + "temperature": 0.3 }, "fasterModel": { - "id": "qwen25-coder-32b-64k:latest", - "temperature": 0.2 + "id": "qwen2.5-coder:1.5b" } } diff --git a/Modelfile.qwen25-coder-32b-64k b/Modelfile.qwen25-coder-32b-64k deleted file mode 100644 index f63e476..0000000 --- a/Modelfile.qwen25-coder-32b-64k +++ /dev/null @@ -1,5 +0,0 @@ -FROM qwen2.5-coder:32b - -PARAMETER num_ctx 64000 -PARAMETER temperature 0.15 -PARAMETER top_p 0.9 diff --git a/optimize_clips.py b/optimize_clips.py new file mode 100644 index 0000000..e3a912a --- /dev/null +++ b/optimize_clips.py @@ -0,0 +1,152 @@ +import os +import glob +import shutil +import subprocess +import tempfile +import threading +import time +CLIPS_DIR = "/data/events/clips" +ARCHIVE_DIR = "/mnt/tour-big/clips" +SIZE_THRESHOLD_MB = 200 +SIZE_THRESHOLD_BYTES = SIZE_THRESHOLD_MB * 1024 * 1024 +MAX_PER_RUN = 20 + + +def has_nvenc(): + r = subprocess.run(["ffmpeg", "-hide_banner", "-encoders"], + stdout=subprocess.PIPE, stderr=subprocess.DEVNULL) + return b"hevc_nvenc" in r.stdout + + +def _ffmpeg(cmd): + return subprocess.run(cmd, stdout=subprocess.DEVNULL, stderr=subprocess.PIPE) + + +def _proxy_cmd(src, dst, hw): + """10fps H.265 proxy for quick local browsing.""" + if hw: + return ["ffmpeg", "-y", + "-hwaccel", "cuda", "-hwaccel_output_format", "cuda", + "-i", src, + "-vcodec", "hevc_nvenc", "-rc", "constqp", "-qp", "28", "-preset", "p4", + "-r", "10", "-tag:v", "hvc1", "-acodec", "copy", dst] + return ["ffmpeg", "-y", "-i", src, + "-vcodec", "libx265", "-crf", "28", "-preset", "medium", + "-r", "10", "-tag:v", "hvc1", "-acodec", "copy", dst] + + +def _archive_cmd(src, dst, hw): + """Full-fps high-quality H.265 for archive — small enough to pull back fast over slow SMB.""" + if hw: + return ["ffmpeg", "-y", + "-hwaccel", "cuda", "-hwaccel_output_format", "cuda", + "-i", src, + "-vcodec", "hevc_nvenc", "-rc", "vbr", "-cq", "19", "-b:v", "0", + "-preset", "p6", "-tune", "hq", + "-tag:v", "hvc1", "-acodec", "copy", dst] + return ["ffmpeg", "-y", "-i", src, + "-vcodec", "libx265", "-crf", "19", "-preset", "slow", + "-tag:v", "hvc1", "-acodec", "copy", dst] + + +def _encode_worker(label, cmd, hw, fallback_cmd, out): + proc = _ffmpeg(cmd) + if proc.returncode != 0 and hw: + print(f" [{label}] NVENC failed, retrying in software...") + proc = _ffmpeg(fallback_cmd) + out[label] = proc + + +def process(src, orig_size, hw): + name = os.path.basename(src) + archive_dst = os.path.join(ARCHIVE_DIR, name) + + if os.path.exists(archive_dst): + print(f" Skip {name} — already archived") + return 0 + + proxy_tmp = src + ".proxy.mp4" + # Encode archive to local /tmp first — never write directly to a slow SMB share + archive_tmp = os.path.join(tempfile.gettempdir(), name + ".archive.mp4") + + print(f"[{name}] {orig_size/(1024*1024):.0f} MB ({'NVENC' if hw else 'software'})") + t0 = time.time() + + results = {} + threads = [ + threading.Thread(target=_encode_worker, args=( + "proxy", + _proxy_cmd(src, proxy_tmp, hw), hw, + _proxy_cmd(src, proxy_tmp, False), + results, + )), + threading.Thread(target=_encode_worker, args=( + "archive", + _archive_cmd(src, archive_tmp, hw), hw, + _archive_cmd(src, archive_tmp, False), + results, + )), + ] + for t in threads: t.start() + for t in threads: t.join() + + failed = {k: v for k, v in results.items() if v.returncode != 0} + if failed: + for label, proc in failed.items(): + print(f" [{label}] error: {proc.stderr.decode()[-300:]}") + for f in (proxy_tmp, archive_tmp): + if os.path.exists(f): os.remove(f) + return 0 + + proxy_mb = os.path.getsize(proxy_tmp) / (1024*1024) + archive_mb = os.path.getsize(archive_tmp) / (1024*1024) + orig_mb = orig_size / (1024*1024) + print(f" proxy {orig_mb:.0f}->{proxy_mb:.0f} MB " + f"archive {orig_mb:.0f}->{archive_mb:.0f} MB " + f"({time.time()-t0:.1f}s) — pushing archive to share...") + + try: + shutil.move(archive_tmp, archive_dst) + except Exception as e: + print(f" Archive move failed: {e} — aborting, original intact") + for f in (proxy_tmp, archive_tmp): + if os.path.exists(f): os.remove(f) + return 0 + + # Atomic swap: original -> proxy (same filesystem) + os.replace(proxy_tmp, src) + freed = orig_size - os.path.getsize(src) + print(f" Done freed locally: {freed/(1024*1024):.0f} MB") + return freed + + +if __name__ == "__main__": + hw = has_nvenc() + print(f"Encoder: {'hevc_nvenc (A6000, 2x parallel)' if hw else 'libx265 (software)'}") + print(f"Clips: {CLIPS_DIR}") + print(f"Archive: {ARCHIVE_DIR}\n") + + if not os.path.isdir(ARCHIVE_DIR): + print(f"ERROR: archive not reachable: {ARCHIVE_DIR}") + raise SystemExit(1) + + files = sorted( + [(fp, os.path.getsize(fp)) + for fp in glob.glob(os.path.join(CLIPS_DIR, "*.mp4")) + if os.path.isfile(fp) and os.path.getsize(fp) > SIZE_THRESHOLD_BYTES], + key=lambda x: x[1], reverse=True, + ) + print(f"Found {len(files)} files > {SIZE_THRESHOLD_MB} MB\n") + + total_freed = 0 + done = 0 + for path, size in files: + freed = process(path, size, hw) + total_freed += freed + if freed: + done += 1 + if done >= MAX_PER_RUN: + print(f"Reached {MAX_PER_RUN}-file limit.") + break + + print(f"\nDone. Processed: {done} Freed locally: {total_freed/(1024*1024):.0f} MB") diff --git a/position.md b/position.md new file mode 100644 index 0000000..b0d114d --- /dev/null +++ b/position.md @@ -0,0 +1,59 @@ +token: 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 +how is a photo called where the person faces front, straight lookign into the camera +Een paar seconden nagedacht + +That’s usually called a front-facing portrait or frontal portrait. + +More specific terms: + +Head-on portrait: person faces directly toward the camera. +Straight-on portrait: neutral, camera directly in front. +Mugshot-style photo: front-facing, often neutral expression, but has a police/ID connotation. +Passport photo or ID photo: front-facing with a plain background and strict framing. + +For normal use, I’d say: a straight-on portrait. + +and with full top till down +Een seconde nagedacht + +That’s called a front-facing full-body portrait. + +Also common: + +Full-length portrait: shows the person from head to toe. +Straight-on full-body shot: person faces the camera directly. +Head-to-toe portrait: informal but clear. + +Best phrase: a straight-on full-body portrait. + +and when isotopic ( i think is the word) facing? + +I think you mean either isometric or three-quarter view. I’ll pin down the naming so you can use the right prompt/photo term. + +Nagedacht gedurende 4s + +You probably mean isometric, but for a person/photo that is usually not the right word. + +Use these terms: + +Three-quarter view +Person is turned partly sideways, often about 45°, but still visible from the front. This is the normal portrait term. + +Profile view +Person is fully sideways, seen from the side. + +Frontal view / straight-on view +Person faces camera directly. + +Isometric view +Mostly used for objects, architecture, games, diagrams: a 3D-looking view with equal axes/angles, not a normal human portrait pose. + +For your case, say: + +a full-body three-quarter portrait +or +a straight-on full-body portrait in three-quarter view if the body is angled but the person still looks at the camera. + +Head-on a full-body three-quarter full-nude-body portrait transparent background +Head-on straight-on full-nude-body portrait transparent background +Head-on straight-on full-body portrait no background \ No newline at end of file diff --git a/requirements.txt b/requirements.txt index 67d0fe3..4ce3f21 100644 --- a/requirements.txt +++ b/requirements.txt @@ -7,4 +7,5 @@ sentencepiece dashscope torchvision peft -torchao==0.11.0 \ No newline at end of file +torchao==0.11.0 +rembg[cpu] \ No newline at end of file diff --git a/tour-comfy/config.json b/tour-comfy/config.json index a8553c5..3def106 100644 --- a/tour-comfy/config.json +++ b/tour-comfy/config.json @@ -1,15 +1,23 @@ { "api_url": "http://127.0.0.1:8500/edit", - "prompt": "high quality, detailed, female nude", + "prompt": "high quality. detailed, female nude, high quality. realistic. detailed. female nude", + "base_prompts": [ + "Head-on a full-body three-quarter full-nude-body female portrait, realistic, transparent background", + "Head-on straight-on full-nude-body female portrait, realistic, transparent background", + "Head-on straight-on full-body female portrait, realistic, no background", + "high quality, full-nude-body, female, masterpiece, realistic, photo, looking at viewer", + "high quality, full-nude-body, female, masterpiece, realistic, photo, detailed skin, professional lighting, transparent background", + "high quality, full-nude-body, female, masterpiece, realistic, photo, cinematic lighting, dramatic shadows, sharp focus, transparent background" + ], "seed": -1, "max_area": 655360, "margin": 10, "top_margin": 20, "headroom": 0.05, "poll_interval": 2, - "stage_dir": "./tour-comfy/stage", - "output_dir": "./tour-comfy/output", - "failed_dir": "./tour-comfy/failed", + "stage_dir": "/mnt/zim/tour-comfy/stage", + "output_dir": "/mnt/zim/tour-comfy/output", + "failed_dir": "/mnt/zim/tour-comfy/failed", "processed_file": "./tour-comfy/processed.json", "log_file": "./tour-comfy/watcher.log" } diff --git a/tour-comfy/database.py b/tour-comfy/database.py new file mode 100644 index 0000000..4911af5 --- /dev/null +++ b/tour-comfy/database.py @@ -0,0 +1,101 @@ +import psycopg2 +import json + +DB_CONFIG = { + "host": "192.168.1.160", + "port": 5433, + "dbname": "dv", + "user": "dev", + "password": "dev" +} + +def get_db_connection(): + return psycopg2.connect(**DB_CONFIG) + +def upsert_person(filename, filepath=None, name=None, group_id=None, tags=None, embedding=None, clip_description=None): + conn = get_db_connection() + cur = conn.cursor() + try: + cur.execute(""" + INSERT INTO person (filename, filepath, name, group_id, tags, embedding, clip_description) + VALUES (%s, %s, %s, %s, %s, %s, %s) + ON CONFLICT (filename) DO UPDATE + SET filepath = COALESCE(EXCLUDED.filepath, person.filepath), + name = COALESCE(EXCLUDED.name, person.name), + group_id = COALESCE(EXCLUDED.group_id, person.group_id), + tags = COALESCE(EXCLUDED.tags, person.tags), + embedding = COALESCE(EXCLUDED.embedding, person.embedding), + clip_description = COALESCE(EXCLUDED.clip_description, person.clip_description); + """, (filename, filepath, name, group_id, json.dumps(tags) if tags else None, embedding, clip_description)) + conn.commit() + finally: + cur.close() + conn.close() + +def get_person(filename): + conn = get_db_connection() + cur = conn.cursor() + try: + cur.execute("SELECT name, group_id, tags, embedding, clip_description, filepath FROM person WHERE filename = %s", (filename,)) + return cur.fetchone() + finally: + cur.close() + conn.close() + +def list_persons(): + conn = get_db_connection() + cur = conn.cursor() + try: + cur.execute("SELECT filename, name, group_id, clip_description FROM person") + return cur.fetchall() + finally: + cur.close() + conn.close() + +def search_similar(embedding, limit=10): + conn = get_db_connection() + cur = conn.cursor() + try: + # Convert embedding to string format for pgvector + embedding_str = "[" + ",".join(map(str, embedding)) + "]" + cur.execute(""" + SELECT filename, name, group_id, clip_description, embedding <=> %s AS distance + FROM person + WHERE embedding IS NOT NULL + ORDER BY distance ASC + LIMIT %s; + """, (embedding_str, limit)) + return cur.fetchall() + finally: + cur.close() + conn.close() + +def delete_person(filename): + conn = get_db_connection() + cur = conn.cursor() + try: + cur.execute("DELETE FROM person WHERE filename = %s", (filename,)) + conn.commit() + finally: + cur.close() + conn.close() + +def delete_group(group_id): + conn = get_db_connection() + cur = conn.cursor() + try: + cur.execute("DELETE FROM person WHERE group_id = %s", (group_id,)) + conn.commit() + finally: + cur.close() + conn.close() + +def get_group_files(group_id): + conn = get_db_connection() + cur = conn.cursor() + try: + cur.execute("SELECT filename, filepath FROM person WHERE group_id = %s", (group_id,)) + return cur.fetchall() + finally: + cur.close() + conn.close() diff --git a/tour-comfy/edit_api.py b/tour-comfy/edit_api.py index 041ed56..0836aab 100644 --- a/tour-comfy/edit_api.py +++ b/tour-comfy/edit_api.py @@ -16,16 +16,31 @@ import uuid import random import copy import threading +import csv + +try: + from . import database + from . import embeddings + from . import naming +except ImportError: + import database + import embeddings + import naming import requests from PIL import Image -from fastapi import FastAPI, UploadFile, File, Form, HTTPException +from fastapi import FastAPI, UploadFile, File, Form, HTTPException, BackgroundTasks from fastapi.middleware.cors import CORSMiddleware from fastapi.responses import Response from pydantic import BaseModel +import shutil +import re # --- config ----------------------------------------------------------------- -CONFIG_PATH = os.path.join(os.path.dirname(os.path.abspath(__file__)), "config.json") +CONFIG_PATH = os.path.join(os.path.dirname(os.path.abspath(__file__)), "config.json") +NAMES_PATH = os.path.join(os.path.dirname(os.path.abspath(__file__)), "names.json") +GROUPS_PATH = os.path.join(os.path.dirname(os.path.abspath(__file__)), "groups.json") +WD_MODEL = os.environ.get("WD_MODEL", "SmilingWolf/wd-vit-tagger-v3") COMFY = os.environ.get("COMFY_URL", "http://127.0.0.1:8188").rstrip("/") WORKFLOW_PATH = os.environ.get( "WORKFLOW_PATH", @@ -39,6 +54,7 @@ GEN_TIMEOUT = int(os.environ.get("GEN_TIMEOUT", "600")) # seconds per request # Node ids in workflow_qwen_edit.json (kept stable on purpose). NODE_LOADIMAGE = "4" NODE_POSITIVE = "5" +NODE_NEGATIVE = "6" NODE_LATENT = "7" NODE_KSAMPLER = "8" NODE_SAVE = "10" @@ -52,7 +68,7 @@ app = FastAPI(title="Qwen-Image-Edit Rapid-AIO API", version="1.0") app.add_middleware( CORSMiddleware, allow_origins=["*"], - allow_methods=["GET", "POST"], + allow_methods=["GET", "POST", "DELETE"], allow_headers=["*"], ) @@ -167,8 +183,122 @@ def _comfy_fetch_image(outputs: dict) -> bytes: return r.content +# --- WD tagger (lazy) ------------------------------------------------------- + +_tagger = None # (model, transform, labels) once loaded +_tagger_lock = threading.Lock() + + +def _load_tagger(): + global _tagger + if _tagger is not None: + return _tagger + with _tagger_lock: + if _tagger is not None: + return _tagger + import torch + import timm + from timm.data import create_transform, resolve_data_config + import huggingface_hub + + model = timm.create_model(f"hf_hub:{WD_MODEL}", pretrained=True).eval() + if torch.cuda.is_available(): + model = model.cuda() + + cfg = resolve_data_config(model.pretrained_cfg, model=model) + transform = create_transform(**cfg) + + lpath = huggingface_hub.hf_hub_download(WD_MODEL, "selected_tags.csv") + with open(lpath, newline="") as f: + rows = list(csv.DictReader(f)) + # category 0=general 4=character 9=rating + labels = [(r["name"], int(r.get("category", 9))) for r in rows] + + _tagger = (model, transform, labels) + return _tagger + + +def _run_tagger(pil_img: Image.Image, threshold: float = 0.35): + import torch + model, transform, labels = _load_tagger() + tensor = transform(pil_img.convert("RGB")).unsqueeze(0) + if torch.cuda.is_available(): + tensor = tensor.cuda() + with torch.no_grad(): + scores = torch.sigmoid(model(tensor))[0].cpu().tolist() + tags = [ + {"tag": name, "score": round(score, 3), "cat": cat} + for (name, cat), score in zip(labels, scores) + if score >= threshold + ] + tags.sort(key=lambda x: -x["score"]) + return tags + + +def _tags_to_name(tags: list, max_tags: int = 8) -> str: + content = [t["tag"] for t in tags if t["cat"] in (0, 4)][:max_tags] + return " ".join(content).replace("_", " ") + + +def _load_json(path: str) -> dict: + if os.path.exists(path): + with open(path) as f: + return json.load(f) + return {} + + +def _save_json(path: str, data: dict): + with open(path, "w") as f: + json.dump(data, f, indent=2) + + +def _apply_transparency(png_bytes: bytes) -> bytes: + """Use rembg to remove background and return PNG bytes with Alpha channel.""" + try: + from rembg import remove + import io + from PIL import Image + img = Image.open(io.BytesIO(png_bytes)) + # rembg works best on RGB + if img.mode != "RGB": + img = img.convert("RGB") + out = remove(img) + buf = io.BytesIO() + out.save(buf, format="PNG") + return buf.getvalue() + except Exception as e: + print(f"Error in transparency post-processing: {e}") + return png_bytes + + # --- pipeline helper --------------------------------------------------------- +def _load_poses(): + poses_path = os.path.join(os.path.dirname(os.path.abspath(__file__)), "poses.md") + if not os.path.exists(poses_path): + return {} + + poses = {} + current_pose = None + current_desc = [] + + with open(poses_path, "r", encoding="utf-8") as f: + for line in f: + line = line.strip() + if line.startswith("# "): + if current_pose: + poses[current_pose] = " ".join(current_desc).strip() + current_pose = line[2:].rstrip(":").strip() + current_desc = [] + elif line and current_pose: + current_desc.append(line) + + if current_pose: + poses[current_pose] = " ".join(current_desc).strip() + + return poses + + def _run_pipeline( pil: Image.Image, prompt: str, @@ -189,6 +319,12 @@ def _run_pipeline( graph = copy.deepcopy(BASE_WORKFLOW) graph[NODE_LOADIMAGE]["inputs"]["image"] = stored graph[NODE_POSITIVE]["inputs"]["prompt"] = prompt + + # Transparency detection + is_transparent = any(kw in prompt.lower() for kw in ["transparent", "no background", "remove background", "alpha channel"]) + if is_transparent: + graph[NODE_NEGATIVE]["inputs"]["prompt"] = "checkerboard, grid, pattern, texture, background details" + graph[NODE_LATENT]["inputs"]["width"] = w graph[NODE_LATENT]["inputs"]["height"] = h ks = graph[NODE_KSAMPLER]["inputs"] @@ -196,7 +332,12 @@ def _run_pipeline( client_id = uuid.uuid4().hex prompt_id = _comfy_queue(graph, client_id) outputs = _comfy_wait(prompt_id, time.time() + GEN_TIMEOUT) - return _comfy_fetch_image(outputs) + png_bytes = _comfy_fetch_image(outputs) + + if is_transparent: + png_bytes = _apply_transparency(png_bytes) + + return png_bytes # --- batch state ------------------------------------------------------------- @@ -213,20 +354,76 @@ def _load_output_dir() -> str: return d -def _batch_worker(job_id: str, filenames: list, prompt: str, seed: int, max_area: int): +def _move_to_trash(filepath: str): + if not filepath or not os.path.exists(filepath): + return + output_dir = _load_output_dir() + trash_dir = os.path.join(output_dir, ".trash") + os.makedirs(trash_dir, exist_ok=True) + + filename = os.path.basename(filepath) + ts = time.strftime("%Y%m%d_%H%M%S") + trash_path = os.path.join(trash_dir, f"{ts}_{filename}") + + try: + shutil.move(filepath, trash_path) + except Exception as e: + print(f"Error moving {filepath} to trash: {e}") + + +def _batch_worker(job_id: str, filenames: list, prompts: list[str], seed: int, max_area: int, group_id: str | None = None): output_dir = _load_output_dir() for fname in filenames: + # If no group_id provided, try to inherit from source + actual_gid = group_id + if not actual_gid: + try: + person = database.get_person(fname) + if person and person[1]: # group_id is at index 1 + actual_gid = person[1] + else: + # Create a new group for this standalone image + actual_gid = naming.get_base_name(fname) + # Update source image to join this group + database.upsert_person(fname, group_id=actual_gid) + except Exception as e: + print(f"Error determining group for {fname}: {e}") + fpath = os.path.join(output_dir, fname) + if not os.path.exists(fpath): + jobs[job_id]["failed"] += len(prompts) + continue + try: pil = Image.open(fpath).convert("RGB") - png = _run_pipeline(pil, prompt, seed, max_area) - ts = time.strftime("%Y%m%d_%H%M%S") - out_name = f"{ts}_{fname}" - with open(os.path.join(output_dir, out_name), "wb") as f: - f.write(png) - jobs[job_id]["done"] += 1 + for prompt in prompts: + try: + png = _run_pipeline(pil, prompt, seed, max_area) + ts = time.strftime("%Y%m%d_%H%M%S") + + # Clean filename to avoid nested timestamps + clean_fname = naming.get_base_name(fname) + out_name = f"{ts}_{clean_fname}" + out_path = os.path.join(output_dir, out_name) + + with open(out_path, "wb") as f: + f.write(png) + + # Register in DB + try: + embedding = embeddings.generate_embedding(out_path) + database.upsert_person(out_name, filepath=out_path, embedding=embedding, group_id=actual_gid) + except Exception as db_err: + print(f"Database error in batch worker: {db_err}") + + jobs[job_id]["done"] += 1 + except Exception as e: + print(f"Error in batch for {fname} with prompt '{prompt}': {e}") + jobs[job_id]["failed"] += 1 except Exception as e: - jobs[job_id]["failed"] += 1 + print(f"Error opening {fname}: {e}") + jobs[job_id]["failed"] += len(prompts) + jobs[job_id]["status"] = "done" @@ -258,22 +455,31 @@ def update_config(update: ConfigUpdate): class BatchRequest(BaseModel): filenames: list[str] - prompt: str + prompt: str | list[str] seed: int = -1 max_area: int = 0 + group_id: str | None = None @app.post("/batch") def start_batch(req: BatchRequest): + prompts = [req.prompt] if isinstance(req.prompt, str) else req.prompt + total_tasks = len(req.filenames) * len(prompts) + job_id = uuid.uuid4().hex[:8] - jobs[job_id] = {"status": "running", "total": len(req.filenames), "done": 0, "failed": 0} + jobs[job_id] = {"status": "running", "total": total_tasks, "done": 0, "failed": 0} t = threading.Thread( target=_batch_worker, - args=(job_id, req.filenames, req.prompt, req.seed, req.max_area), + args=(job_id, req.filenames, prompts, req.seed, req.max_area, req.group_id), daemon=True, ) t.start() - return {"job_id": job_id, "total": len(req.filenames)} + return {"job_id": job_id, "total": total_tasks} + + +@app.get("/poses") +def get_poses(): + return _load_poses() @app.get("/batch/{job_id}") @@ -283,6 +489,186 @@ def get_batch(job_id: str): return jobs[job_id] +@app.get("/images") +def list_images(): + output_dir = _load_output_dir() + extensions = ('.jpg', '.jpeg', '.png', '.gif', '.webp', '.bmp', '.svg') + try: + # Try to get from DB first + try: + persons = database.list_persons() + # persons is (filename, name, group_id, clip_description) + db_images = [] + for p in persons: + db_images.append({ + "filename": p[0], + "name": p[1], + "group_id": p[2], + "clip_description": p[3] + }) + # Still sort by mtime for consistency with filesystem + db_images.sort(key=lambda x: os.path.getmtime(os.path.join(output_dir, x["filename"])) if os.path.exists(os.path.join(output_dir, x["filename"])) else 0, reverse=True) + return {"images": db_images} + except Exception as db_err: + print(f"DB error in list_images: {db_err}") + # Fallback to filesystem + files = [f for f in os.listdir(output_dir) if f.lower().endswith(extensions)] + files.sort(key=lambda x: os.path.getmtime(os.path.join(output_dir, x)), reverse=True) + return {"images": [{"filename": f} for f in files]} + except Exception as e: + raise HTTPException(500, str(e)) + + +# --- tagging routes ---------------------------------------------------------- + +class TagRequest(BaseModel): + filename: str + threshold: float = 0.35 + max_tags: int = 8 + group_id: str | None = None + + +@app.post("/tag") +def tag_image(req: TagRequest): + output_dir = _load_output_dir() + fpath = os.path.join(output_dir, req.filename) + if not os.path.exists(fpath): + raise HTTPException(404, "File not found in output dir") + try: + pil = Image.open(fpath) + tags = _run_tagger(pil, req.threshold) + clip_desc = _tags_to_name(tags, req.max_tags) + auto_name = naming.generate_associative_name(tags) + + # Save to DB + try: + embedding = embeddings.generate_embedding(fpath) + database.upsert_person(req.filename, filepath=fpath, name=auto_name, clip_description=clip_desc, tags=tags, embedding=embedding, group_id=req.group_id) + except Exception as db_err: + print(f"Database error during tag: {db_err}") + + # Legacy fallback + try: + names = _load_json(NAMES_PATH) + names[req.filename] = clip_desc + _save_json(NAMES_PATH, names) + except: pass + + return {"filename": req.filename, "clip_description": clip_desc, "tags": tags[:30]} + except Exception as e: + raise HTTPException(500, str(e)) + + +@app.get("/names") +def get_names(): + try: + persons = database.list_persons() + return {p[0]: p[1] for p in persons if p[1]} + except: + return _load_json(NAMES_PATH) + + +@app.post("/names/{filename}") +def set_name(filename: str, body: dict): + name = body.get("name", "") + try: + database.upsert_person(filename, name=name) + except Exception as db_err: + print(f"Database error in set_name: {db_err}") + + # Legacy fallback + try: + names = _load_json(NAMES_PATH) + names[filename] = name + _save_json(NAMES_PATH, names) + except: pass + + return {"filename": filename, "name": name} + + +# --- group routes ------------------------------------------------------------ + +@app.get("/groups") +def get_groups(): + try: + persons = database.list_persons() + return {p[0]: p[2] for p in persons if p[2]} + except: + return _load_json(GROUPS_PATH) + + +class MergeRequest(BaseModel): + filenames: list[str] + group_id: str | None = None + + +@app.post("/groups/merge") +def merge_groups(req: MergeRequest): + gid = req.group_id or f"cg_{uuid.uuid4().hex[:8]}" + for fname in req.filenames: + try: + database.upsert_person(fname, group_id=gid) + except Exception as db_err: + print(f"Database error in merge: {db_err}") + + # Legacy fallback + try: + groups = _load_json(GROUPS_PATH) + for fname in req.filenames: + groups[fname] = gid + _save_json(GROUPS_PATH, groups) + except: pass + + return {"group_id": gid, "files": req.filenames} + + +class ExtractRequest(BaseModel): + filename: str + + +@app.post("/groups/extract") +def extract_from_group(req: ExtractRequest): + gid = f"solo:{req.filename}" + try: + database.upsert_person(req.filename, group_id=gid) + except Exception as db_err: + print(f"Database error in extract: {db_err}") + + # Legacy fallback + try: + groups = _load_json(GROUPS_PATH) + groups[req.filename] = gid + _save_json(GROUPS_PATH, groups) + except: pass + + return {"filename": req.filename} + + +@app.get("/similar/{filename}") +def get_similar(filename: str, limit: int = 10): + person = database.get_person(filename) + if not person or person[3] is None: + raise HTTPException(404, "Image or embedding not found") + + embedding = person[3] + results = database.search_similar(embedding, limit=limit) + + similar = [] + for r in results: + # Avoid returning the same image as the most similar + if r[0] == filename: + continue + similar.append({ + "filename": r[0], + "name": r[1], + "group_id": r[2], + "clip_description": r[3], + "distance": float(r[4]) + }) + + return {"filename": filename, "similar": similar} + + @app.get("/health") def health(): try: @@ -292,6 +678,119 @@ def health(): raise HTTPException(503, f"ComfyUI unreachable at {COMFY}: {e}") +def _crop_to_bbox(pil_img: Image.Image, margin: int = 20, top_margin: int = 20, headroom: float = 0.05) -> Image.Image: + if pil_img.mode != 'RGBA': + return pil_img + + alpha = pil_img.split()[-1] + bbox = alpha.getbbox() + if not bbox: + return pil_img + + left, upper, right, lower = bbox + left = max(0, left - margin) + upper = max(0, upper - top_margin) + right = min(pil_img.width, right + margin) + lower = min(pil_img.height, lower + margin) + + cropped = pil_img.crop((left, upper, right, lower)) + if headroom > 0: + h_px = int(cropped.height * headroom) + if h_px > 0: + new_img = Image.new("RGBA", (cropped.width, cropped.height + h_px), (0, 0, 0, 0)) + new_img.paste(cropped, (0, h_px)) + return new_img + return cropped + + +def _process_upload(file_path: str, filename: str, prompts: list[str], name: str | None = None, group_id: str | None = None): + output_dir = _load_output_dir() + try: + pil = Image.open(file_path) + + # 1. CLIP tag the source + tags = _run_tagger(pil.convert("RGB")) + clip_desc = _tags_to_name(tags) + auto_name = name or naming.generate_associative_name(tags) + + # 2. Embedding for source + embedding = embeddings.generate_embedding(file_path) + + # 3. Register source in DB (optional, but good for tracking) + # We'll use the original filename or a timestamped one + database.upsert_person(filename, filepath=file_path, name=auto_name, clip_description=clip_desc, tags=tags, embedding=embedding, group_id=group_id) + + # 4. Default behavior: Crop if needed + # We'll use default values from watcher + cropped_pil = _crop_to_bbox(pil) + + # 5. Run prompts + for i, prompt in enumerate(prompts): + try: + # Use RGB for pipeline + png = _run_pipeline(cropped_pil.convert("RGB"), prompt) + + ts = time.strftime("%Y%m%d_%H%M%S") + out_name = f"{ts}_{i}_{filename}" + if not out_name.lower().endswith(".png"): + out_name += ".png" + out_path = os.path.join(output_dir, out_name) + + with open(out_path, "wb") as f: + f.write(png) + + # Register output in DB + out_embedding = embeddings.generate_embedding(out_path) + database.upsert_person(out_name, filepath=out_path, name=auto_name, clip_description=clip_desc, embedding=out_embedding, group_id=group_id) + except Exception as e: + print(f"Error processing prompt '{prompt}' for {filename}: {e}") + + except Exception as e: + print(f"Error in _process_upload for {filename}: {e}") + + +@app.post("/upload") +def upload_image( + background_tasks: BackgroundTasks, + image: UploadFile = File(...), + prompts: str = Form(""), + name: str = Form(None), +): + # Load config to get output_dir (we use output_dir for UI uploads to avoid watcher conflict) + with open(CONFIG_PATH, "r") as f: + conf = json.load(f) + output_dir = _load_output_dir() + os.makedirs(output_dir, exist_ok=True) + + ts = time.strftime("%Y%m%d_%H%M%S") + safe_filename = re.sub(r'[^a-zA-Z0-9_.-]', '_', image.filename) + # Ensure extension + if not safe_filename.lower().endswith(('.png', '.jpg', '.jpeg', '.webp')): + safe_filename += ".png" + + filename = f"{ts}_{safe_filename}" + file_path = os.path.join(output_dir, filename) + + with open(file_path, "wb") as f: + shutil.copyfileobj(image.file, f) + + prompt_list = [p.strip() for p in prompts.split(",") if p.strip()] + + # Add base-set prompts if defined in config + base_prompts = conf.get("base_prompts", []) + if isinstance(base_prompts, list): + prompt_list.extend(base_prompts) + + if not prompt_list: + # Use default prompt from config + prompt_list = [conf.get("prompt", "high quality, masterpiece")] + + group_id = naming.get_base_name(filename) + background_tasks.add_task(_process_upload, file_path, filename, prompt_list, name, group_id) + + return {"status": "processing", "filename": filename, "group_id": group_id, "prompts": prompt_list} + + @app.post("/edit") async def edit( image: UploadFile = File(...), @@ -313,6 +812,64 @@ async def edit( return Response(content=png, media_type="image/png") +@app.delete("/images/{filename}") +def delete_image(filename: str): + person = database.get_person(filename) + if person and person[5] and os.path.exists(person[5]): + _move_to_trash(person[5]) + + database.delete_person(filename) + return {"status": "deleted", "filename": filename} + + +@app.delete("/groups/{group_id}") +def delete_group(group_id: str): + files = database.get_group_files(group_id) + for filename, filepath in files: + if filepath and os.path.exists(filepath): + _move_to_trash(filepath) + + database.delete_group(group_id) + return {"status": "deleted", "group_id": group_id} + + +@app.post("/remove-background/{filename}") +def remove_background(filename: str): + person = database.get_person(filename) + if not person or not person[5] or not os.path.exists(person[5]): + raise HTTPException(404, "Image file not found") + + path = person[5] + with open(path, "rb") as f: + png_bytes = f.read() + + transparent_png = _apply_transparency(png_bytes) + + with open(path, "wb") as f: + f.write(transparent_png) + + return {"status": "success", "filename": filename} + + +@app.post("/remove-background/group/{group_id}") +def remove_background_group(group_id: str, background_tasks: BackgroundTasks): + def _bg_task(): + files = database.get_group_files(group_id) + for filename, filepath in files: + if filepath and os.path.exists(filepath): + try: + with open(filepath, "rb") as f: + png_bytes = f.read() + transparent_png = _apply_transparency(png_bytes) + with open(filepath, "wb") as f: + f.write(transparent_png) + except Exception as e: + print(f"Error removing background for {filename}: {e}") + + background_tasks.add_task(_bg_task) + return {"status": "processing", "group_id": group_id} + + if __name__ == "__main__": import uvicorn uvicorn.run(app, host=os.environ.get("HOST", "0.0.0.0"), diff --git a/tour-comfy/embeddings.py b/tour-comfy/embeddings.py new file mode 100644 index 0000000..e6551c3 --- /dev/null +++ b/tour-comfy/embeddings.py @@ -0,0 +1,30 @@ +import torch +import open_clip +from PIL import Image +import os + +_model = None +_preprocess = None +_device = None + +def get_model(): + global _model, _preprocess, _device + if _model is None: + _device = "cuda" if torch.cuda.is_available() else "cpu" + # ViT-H-14 is 1024-dim + _model, _, _preprocess = open_clip.create_model_and_transforms('ViT-H-14', pretrained='laion2b_s32b_b79k') + _model = _model.to(_device) + _model.eval() + return _model, _preprocess, _device + +def generate_embedding(image_path): + model, preprocess, device = get_model() + try: + image = preprocess(Image.open(image_path)).unsqueeze(0).to(device) + with torch.no_grad(): + image_features = model.encode_image(image) + image_features /= image_features.norm(dim=-1, keepdim=True) + return image_features.cpu().numpy()[0].tolist() + except Exception as e: + print(f"Error generating embedding for {image_path}: {e}") + return None diff --git a/tour-comfy/env.sh b/tour-comfy/env.sh index c0fdd8a..02c3265 100644 --- a/tour-comfy/env.sh +++ b/tour-comfy/env.sh @@ -19,7 +19,12 @@ COMFY="$BASE/ComfyUI" _basefs="$(stat -f -c %T "$BASE" 2>/dev/null || echo unknown)" case "$_basefs" in fuseblk|ntfs|ntfs3|exfat|vfat|msdos|9p|cifs|smb*) - VENV="${COMFY_VENV:-$HOME/comfyui-venv}" ;; # NTFS-ish BASE -> venv on home + VENV="${COMFY_VENV:-/home/mike/comfyui/venv}" ;; # NTFS-ish BASE -> venv on home *) - VENV="${COMFY_VENV:-$BASE/venv}" ;; # native fs -> venv beside code + if [ -d "/home/mike/comfyui/venv" ]; then + VENV="${COMFY_VENV:-/home/mike/comfyui/venv}" + else + VENV="${COMFY_VENV:-$BASE/venv}" + fi + ;; esac diff --git a/tour-comfy/groups.json b/tour-comfy/groups.json new file mode 100644 index 0000000..c775121 --- /dev/null +++ b/tour-comfy/groups.json @@ -0,0 +1,99 @@ +{ + "20260617_005040_img_56.png": "cg_077c3625", + "20260617_005026_img_55.png": "cg_077c3625", + "20260617_014351_img_66.png": "cg_9be4f76c", + "20260617_013150_img_66.png": "cg_9be4f76c", + "20260617_013327_img_67.png": "cg_9be4f76c", + "20260617_013211_img_65.png": "cg_9be4f76c", + "20260617_013035_img_64.png": "cg_9be4f76c", + "20260617_013111_img_63.png": "cg_9be4f76c", + "20260616_005752_img_21.png": "cg_07d742c0", + "20260616_005727_img_19.png": "cg_07d742c0", + "20260615_151614_img_93.png": "cg_74544975", + "20260615_145017_img_93.png": "cg_74544975", + "20260615_151829_img_92.png": "cg_74544975", + "img_9.png": "cg_74544975", + "20260617_133832_img_81.png": "cg_85873ed2", + "20260617_133917_img_82.png": "cg_85873ed2", + "20260617_134119_img_85.png": "cg_85873ed2", + "20260617_134229_img_83.png": "cg_85873ed2", + "20260618_004930_20260617_134041_img_84.png": "cg_85873ed2", + "20260618_004501_20260617_134041_img_84.png": "cg_85873ed2", + "20260617_134041_img_84.png": "cg_85873ed2", + "20260618_011507_20260617_134615_img_86.png": "cg_85873ed2", + "20260617_134615_img_86.png": "cg_85873ed2", + "20260618_011633_t159zr-1.png": "cg_85873ed2", + "t159zr-1.png": "cg_85873ed2", + "20260618_004919_kbk99v.png": "cg_4004e314", + "kbk99v.png": "cg_4004e314", + "20260618_004941_out7.png": "cg_4004e314", + "out7.png": "cg_4004e314", + "20260618_004334_Pasted image (3).png": "cg_67da7537", + "Pasted image (3).png": "cg_67da7537", + "20260618_002025_20260616_020020_img_35.png": "cg_67da7537", + "20260616_020020_img_35.png": "cg_67da7537", + "20260618_004428_20260616_015949_img_37.png": "cg_67da7537", + "20260618_002036_20260616_015949_img_37.png": "cg_67da7537", + "20260616_015949_img_37.png": "cg_67da7537", + "20260616_020059_img_38.png": "cg_67da7537", + "20260616_015919_img_33.png": "cg_4ae30667", + "20260616_015850_img_34.png": "cg_4ae30667", + "20260616_011823_imgxxxx.png": "cg_800abf94", + "20260615_152252_imgxxx.png": "cg_800abf94", + "tp236b.png": "cg_f55e9e4a", + "out.png": "cg_f55e9e4a", + "out2.png": "cg_f55e9e4a", + "p13.png": "cg_4e575e1d", + "pa0.png": "cg_4e575e1d", + "Pasted image (5).png": "cg_85873ed2", + "img_3.png": "cg_53eda359", + "Pasted image.png": "cg_53eda359", + "out3.png": "cg_53eda359", + "20260615_155354_others.jpeg": "cg_1c0c5074", + "20260615_154852_other.jpeg": "cg_1c0c5074", + "20260615_154333_other.jpeg": "cg_1c0c5074", + "20260618_004407_20260616_002456_test123.jpeg": "cg_1c0c5074", + "20260616_002456_test123.jpeg": "cg_1c0c5074", + "20260618_013512_Pasted image (9).png": "cg_809653a0", + "Pasted image (9).png": "cg_809653a0", + "20260615_155756_img_6v1.png": "cg_2b3ab0b0", + "20260616_002302_image.png": "cg_2b3ab0b0", + "20260618_011622_jb1.png": "cg_ee004a75", + "jb1.png": "cg_ee004a75", + "20260618_010649_20260615_150340_test.png": "cg_ee004a75", + "20260615_150340_test.png": "cg_ee004a75", + "20260618_045745_7_20260618_045549_test_clipboard.png": "cg_32d91763", + "20260618_045734_6_20260618_045549_test_clipboard.png": "cg_32d91763", + "20260618_045723_5_20260618_045549_test_clipboard.png": "cg_32d91763", + "20260618_045656_20260618_045450_test_clipboard.png": "cg_32d91763", + "20260618_045629_20260618_045234_test_clipboard.png": "cg_32d91763", + "20260618_045234_test_clipboard.png": "cg_32d91763", + "20260618_045549_test_clipboard.png": "cg_32d91763", + "20260618_045450_test_clipboard.png": "cg_32d91763", + "20260618_045703_4_20260618_045549_test_clipboard.png": "cg_32d91763", + "20260618_045652_3_20260618_045549_test_clipboard.png": "cg_32d91763", + "20260618_045631_2_20260618_045549_test_clipboard.png": "cg_32d91763", + "20260618_045620_1_20260618_045549_test_clipboard.png": "cg_32d91763", + "20260618_045608_0_20260618_045549_test_clipboard.png": "cg_32d91763", + "20260618_045539_3_20260618_045450_test_clipboard.png": "cg_32d91763", + "20260618_045528_2_20260618_045450_test_clipboard.png": "cg_32d91763", + "20260618_045500_0_20260618_045450_test_clipboard.png": "cg_32d91763", + "20260618_045450_4_20260618_045234_test_clipboard.png": "cg_32d91763", + "20260618_045439_3_20260618_045234_test_clipboard.png": "cg_32d91763", + "20260618_045428_2_20260618_045234_test_clipboard.png": "cg_32d91763", + "20260618_045418_1_20260618_045234_test_clipboard.png": "cg_32d91763", + "20260618_045407_0_20260618_045234_test_clipboard.png": "cg_32d91763", + "20260618_051052_9_20260618_050846_img_4.png": "cg_b5b937c7", + "20260618_051040_8_20260618_050846_img_4.png": "cg_b5b937c7", + "20260618_051029_7_20260618_050846_img_4.png": "cg_b5b937c7", + "20260618_051017_6_20260618_050846_img_4.png": "cg_b5b937c7", + "20260618_051006_5_20260618_050846_img_4.png": "cg_b5b937c7", + "20260618_050955_4_20260618_050846_img_4.png": "cg_b5b937c7", + "20260618_050929_20260618_050846_img_4.png": "cg_b5b937c7", + "20260618_050846_img_4.png": "cg_b5b937c7", + "20260618_050935_3_20260618_050846_img_4.png": "cg_b5b937c7", + "20260618_050902_0_20260618_050846_img_4.png": "cg_b5b937c7", + "20260618_050913_1_20260618_050846_img_4.png": "cg_b5b937c7", + "20260618_050923_2_20260618_050846_img_4.png": "cg_b5b937c7", + "20260618_053530_2_20260618_053458_image.png": "solo:20260618_053530_2_20260618_053458_image.png" +} \ No newline at end of file diff --git a/tour-comfy/names.json b/tour-comfy/names.json new file mode 100644 index 0000000..a52e546 --- /dev/null +++ b/tour-comfy/names.json @@ -0,0 +1,10 @@ +{ + "Pasted image (9).png": "1girl solo breasts nipples nude long hair blue skin navel", + "20260618_015217_20260618_010710_p12-2.png": "Jane Doe", + "20260618_045629_20260618_045234_test_clipboard.png": "1girl breasts solo nipples nude head out of frame navel pussy", + "20260618_045656_20260618_045450_test_clipboard.png": "1girl breasts solo nipples nude navel head out of frame pussy", + "20260618_045831_20260618_045549_test_clipboard.png": "1girl breasts nipples solo nude blue skin pussy head out of frame", + "20260618_050929_20260618_050846_img_4.png": "1girl solo blue skin nipples nude head out of frame breasts navel", + "20260618_051629_20260618_051435_test_group.png": "1girl breasts solo nipples realistic nude head out of frame navel", + "20260618_052008_20260618_051426_test_group.png": "1girl breasts solo nipples nude head out of frame navel realistic" +} \ No newline at end of file diff --git a/tour-comfy/naming.py b/tour-comfy/naming.py new file mode 100644 index 0000000..0a3c48b --- /dev/null +++ b/tour-comfy/naming.py @@ -0,0 +1,96 @@ +import random +import re + +def clean_tag(tag): + return tag.replace("_", " ").strip() + +def generate_associative_name(tags): + """ + Generate a 'real-alike' associative name based on WD tagger tags. + tags: list of dicts {'tag': str, 'score': float, 'cat': int} + """ + if not tags: + return None + + # Filter by score + high_score_tags = [t for t in tags if t['score'] > 0.4] + if not high_score_tags: + high_score_tags = tags[:5] + + # Categories: 0=general, 4=character + characters = [clean_tag(t['tag']) for t in high_score_tags if t['cat'] == 4] + general = [clean_tag(t['tag']) for t in high_score_tags if t['cat'] == 0] + + # Key attributes + subject = None + if characters: + subject = characters[0].title() + elif "1girl" in [t['tag'] for t in high_score_tags]: + subject = "Maiden" + elif "1boy" in [t['tag'] for t in high_score_tags]: + subject = "Youth" + else: + subject = "Subject" + + # Actions/Poses + actions = ["standing", "sitting", "lying", "running", "walking", "dancing", "sleeping"] + found_action = next((clean_tag(t['tag']) for t in high_score_tags if t['tag'] in actions), None) + + # Setting/Background + settings = ["forest", "beach", "city", "space", "room", "garden", "ocean", "mountain", "sky", "underwater", "street"] + found_setting = next((clean_tag(t['tag']) for t in high_score_tags if t['tag'] in settings), None) + + # Appearance + colors = ["red", "blue", "green", "white", "black", "gold", "silver", "pink", "purple", "yellow"] + found_color = next((clean_tag(t['tag']) for t in high_score_tags if clean_tag(t['tag']).split()[0] in colors), None) + if not found_color: + found_color = next((clean_tag(t['tag']) for t in high_score_tags if any(c in t['tag'] for c in colors)), None) + + # Styles/Atmosphere + styles = ["cyberpunk", "fantasy", "realistic", "ethereal", "dark", "bright", "sketch", "oil painting"] + found_style = next((clean_tag(t['tag']) for t in high_score_tags if t['tag'] in styles), None) + + # Build the name + templates = [] + + if found_style and subject: + templates.append(f"{found_style.title()} {subject}") + + if found_color and subject: + templates.append(f"The {found_color.title()} {subject}") + + if subject and found_action: + if found_action.endswith("ing"): + templates.append(f"{subject} {found_action.title()}") + else: + # Basic attempt at present participle + action_ing = found_action + if action_ing.endswith("e"): + action_ing = action_ing[:-1] + "ing" + else: + action_ing += "ing" + templates.append(f"{subject} {action_ing.title()}") + + if subject and found_setting: + templates.append(f"{subject} in the {found_setting.title()}") + + if found_style and found_setting: + templates.append(f"{found_style.title()} {found_setting.title()}") + + if not templates: + # Fallback: combine two random general tags + if len(general) >= 2: + return f"{general[0].title()} {general[1].title()}" + elif general: + return general[0].title() + else: + return "Untitled Artwork" + + # Return a random template result + return random.choice(templates) + + +def get_base_name(name: str) -> str: + """Remove timestamp prefixes from filename.""" + # Matches YYYYMMDD_HHMMSS_ + return re.sub(r'^(\d{8}_\d{6}_)+', '', name) diff --git a/tour-comfy/output/car.html b/tour-comfy/output/car.html deleted file mode 100644 index 1097258..0000000 --- a/tour-comfy/output/car.html +++ /dev/null @@ -1,931 +0,0 @@ - - -
- - -Place this HTML file in your image folder, or configure the path below.
-