206 lines
6.6 KiB
Python
Executable File
206 lines
6.6 KiB
Python
Executable File
#!/usr/bin/env -S uv run --script
|
|
# /// script
|
|
# requires-python = ">=3.11"
|
|
# dependencies = [
|
|
# "bottle",
|
|
# ]
|
|
# ///
|
|
"""Browse images one-by-one and assign text prompts via a local web UI.
|
|
|
|
Saves results to a JSONL file: {"filename": "...", "prompt": "..."}
|
|
Images navigated past without a prompt produce no output line.
|
|
"""
|
|
|
|
import argparse
|
|
import json
|
|
import socket
|
|
import sys
|
|
import threading
|
|
import time
|
|
import webbrowser
|
|
from pathlib import Path
|
|
|
|
import bottle
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Configuration
|
|
# ---------------------------------------------------------------------------
|
|
|
|
CANNED_PROMPTS: list[str] = [
|
|
"photorealistic. skin must be flawless",
|
|
]
|
|
"""Pre-populated prompt history shown in the UI on first launch."""
|
|
|
|
BUILDING_BLOCKS: list[str] = [
|
|
"make her prettier",
|
|
"age her by 5 years and make her look like a 25 year old",
|
|
"keep the dark skin",
|
|
"wet skin",
|
|
"skin covered with baby oil, shiny skin",
|
|
"soaking wet skin and hair, wet clothes clinging to her body",
|
|
"long, flowy hair",
|
|
"japanese",
|
|
"greek",
|
|
"no extra or missing fingers. each hand must have 5 fingers with the same hand pose as the original image",
|
|
# expression
|
|
"direct her gaze at the camera",
|
|
"give her a serious and seductive look",
|
|
"give her a playful expression",
|
|
"slightly parted lips",
|
|
"french kiss, eyes closed, tongue out",
|
|
"closed eyes",
|
|
"lower her eyelids and slightly part her lips in a seductive fashion",
|
|
"exaggerated expression",
|
|
"soft dramatic lighting",
|
|
# outfit
|
|
"replace the outfit with glossy latex",
|
|
"remove the tan lines, she doesn't wear white bra",
|
|
]
|
|
"""Palette of text snippets the user can append to any prompt."""
|
|
|
|
VALID_EXTENSIONS: frozenset[str] = frozenset({".webp", ".jpg", ".jpeg", ".png"})
|
|
|
|
HTML: str = (Path(__file__).parent / "prompter.html").read_text(encoding="utf-8")
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# App
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def find_free_port() -> int:
|
|
"""Bind to port 0 and return the OS-assigned port number."""
|
|
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
|
|
s.bind(("127.0.0.1", 0))
|
|
return s.getsockname()[1]
|
|
|
|
|
|
class PrompterApp:
|
|
def __init__(self, images: list[str], output_path: Path) -> None:
|
|
self.images = images
|
|
self.output_path = output_path
|
|
self.app = bottle.Bottle()
|
|
self.app.route("/")(self.index)
|
|
self.app.route("/api/images")(self.api_images)
|
|
self.app.route("/image/<idx:int>")(self.serve_image)
|
|
self.app.route("/api/save", method="POST")(self.api_save)
|
|
|
|
def index(self) -> str:
|
|
return HTML
|
|
|
|
def api_images(self) -> str:
|
|
"""Return image list, canned prompts, building blocks, and existing prompts."""
|
|
stem_to_path: dict[str, str] = {Path(p).stem: p for p in self.images}
|
|
existing_by_stem = self._load_existing_prompts()
|
|
existing_prompts = {
|
|
stem_to_path[stem]: prompt
|
|
for stem, prompt in existing_by_stem.items()
|
|
if stem in stem_to_path
|
|
}
|
|
bottle.response.content_type = "application/json"
|
|
return json.dumps(
|
|
{
|
|
"images": self.images,
|
|
"canned_prompts": CANNED_PROMPTS,
|
|
"building_blocks": BUILDING_BLOCKS,
|
|
"existing_prompts": existing_prompts,
|
|
}
|
|
)
|
|
|
|
def serve_image(self, idx: int) -> bottle.HTTPResponse:
|
|
if idx < 0 or idx >= len(self.images):
|
|
bottle.abort(404, "Image not found")
|
|
path = Path(self.images[idx])
|
|
return bottle.static_file(path.name, root=str(path.parent))
|
|
|
|
def api_save(self) -> str:
|
|
data: dict = bottle.request.json or {}
|
|
file_path: str = data.get("file_path", "")
|
|
prompt: str = data.get("prompt", "")
|
|
if file_path and prompt:
|
|
self._save_entry(file_path, prompt)
|
|
bottle.response.content_type = "application/json"
|
|
return json.dumps({"ok": True})
|
|
|
|
def _load_existing_prompts(self) -> dict[str, str]:
|
|
"""Read the output JSONL and return a mapping of stem → prompt.
|
|
|
|
When a filename appears multiple times the last entry wins, so re-running
|
|
the tool and overwriting a previous prompt works naturally.
|
|
"""
|
|
if not self.output_path.exists():
|
|
return {}
|
|
prompts: dict[str, str] = {}
|
|
with open(self.output_path, encoding="utf-8") as f:
|
|
for line in f:
|
|
line = line.strip()
|
|
if not line:
|
|
continue
|
|
try:
|
|
entry = json.loads(line)
|
|
if "filename" in entry and "prompt" in entry:
|
|
prompts[entry["filename"]] = entry["prompt"]
|
|
except json.JSONDecodeError:
|
|
pass
|
|
return prompts
|
|
|
|
def _save_entry(self, file_path: str, prompt: str) -> None:
|
|
"""Append one {filename, prompt} record to the JSONL output file."""
|
|
with open(self.output_path, "a", encoding="utf-8") as f:
|
|
f.write(
|
|
json.dumps({"filename": Path(file_path).stem, "prompt": prompt}) + "\n"
|
|
)
|
|
|
|
def run(self, port: int) -> None:
|
|
bottle.run(self.app, host="localhost", port=port, quiet=True)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Entry point
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def main() -> None:
|
|
parser = argparse.ArgumentParser(
|
|
description="Browse images and assign text prompts via a web UI.",
|
|
)
|
|
parser.add_argument("images", nargs="+", help="Image paths to browse")
|
|
parser.add_argument(
|
|
"-o",
|
|
"--output-path",
|
|
default="prompts.jsonl",
|
|
metavar="FILE",
|
|
help="Output JSONL file path (default: prompts.jsonl)",
|
|
)
|
|
args = parser.parse_args()
|
|
|
|
images = [
|
|
str(Path(p).resolve())
|
|
for p in args.images
|
|
if Path(p).suffix.lower() in VALID_EXTENSIONS
|
|
]
|
|
|
|
if not images:
|
|
exts = ", ".join(sorted(VALID_EXTENSIONS))
|
|
print(
|
|
f"Error: no valid images found. Expected extensions: {exts}",
|
|
file=sys.stderr,
|
|
)
|
|
sys.exit(1)
|
|
|
|
print(f"Loaded {len(images)} image(s).")
|
|
|
|
port = find_free_port()
|
|
url = f"http://localhost:{port}"
|
|
print(f"Starting server at {url}")
|
|
|
|
threading.Thread(
|
|
target=lambda: (time.sleep(0.8), webbrowser.open(url)),
|
|
daemon=True,
|
|
).start()
|
|
|
|
PrompterApp(images, Path(args.output_path)).run(port)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|