chore: Update everything

This commit is contained in:
2026-07-24 15:58:31 +02:00
parent 5100ad8365
commit 6bd4d58373
34 changed files with 8829 additions and 1 deletions
+523
View File
@@ -0,0 +1,523 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Prompter</title>
<!-- Persist plugin must load before Alpine core -->
<script defer src="https://cdn.jsdelivr.net/npm/@alpinejs/persist@3.x.x/dist/cdn.min.js"></script>
<script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3.x.x/dist/cdn.min.js"></script>
<style>
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
body {
display: flex;
height: 100vh;
overflow: hidden;
font-family: system-ui, -apple-system, sans-serif;
background: #0f0f0f;
color: #e0e0e0;
}
/* ── Image panel (left 50%) ──────────────────────────────────────────── */
.image-panel {
width: 50%;
height: 100vh;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
background: #111;
padding: 20px;
gap: 10px;
border-right: 1px solid #1e1e1e;
}
.image-panel img {
max-width: 100%;
max-height: calc(100vh - 72px);
object-fit: contain;
}
.image-meta {
display: flex;
flex-direction: column;
align-items: center;
gap: 4px;
}
.image-filename {
font-size: 12px;
color: #666;
word-break: break-all;
text-align: center;
max-width: 100%;
}
.image-counter {
display: flex;
align-items: center;
gap: 4px;
font-size: 12px;
color: #555;
}
.image-counter input[type="number"] {
background: transparent;
border: none;
border-bottom: 1px solid #444;
color: #999;
font-family: inherit;
font-size: 12px;
outline: none;
padding: 0 2px;
text-align: right;
width: 3.5ch;
-moz-appearance: textfield;
}
.image-counter input[type="number"]:focus {
border-bottom-color: #4a6cf7;
color: #e0e0e0;
}
.image-counter input::-webkit-outer-spin-button,
.image-counter input::-webkit-inner-spin-button { -webkit-appearance: none; }
/* ── Options panel (right 50%) ───────────────────────────────────────── */
.options-panel {
width: 50%;
height: 100vh;
display: flex;
flex-direction: column;
overflow: hidden;
}
.panel-top,
.panel-bottom {
flex: 1 1 0;
min-height: 0;
overflow-y: auto;
padding: 24px;
display: flex;
flex-direction: column;
gap: 8px;
}
.panel-bottom {
border-top: 1px solid #1e1e1e;
}
.section-title {
font-size: 11px;
color: #555;
text-transform: uppercase;
letter-spacing: 0.08em;
margin-bottom: 4px;
}
.option {
border: 1px solid #2a2a2a;
border-radius: 8px;
padding: 12px 14px;
cursor: pointer;
transition: border-color 0.1s;
}
.option:hover { border-color: #3a3a3a; }
.option.active { border-color: #4a6cf7; background: rgba(74, 108, 247, 0.06); }
.option-meta {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 10px;
font-size: 12px;
color: #666;
}
.saved-badge { color: #4caf50; margin-left: auto; font-size: 11px; }
.kbd {
display: inline-block;
background: #1e1e1e;
border: 1px solid #333;
border-radius: 3px;
padding: 1px 6px;
font-size: 11px;
font-family: monospace;
color: #888;
white-space: nowrap;
}
textarea {
width: 100%;
background: #1a1a1a;
border: 1px solid #333;
border-radius: 6px;
color: #e0e0e0;
font-size: 14px;
line-height: 1.5;
padding: 10px;
resize: vertical;
min-height: 90px;
font-family: inherit;
outline: none;
transition: border-color 0.1s;
}
textarea:focus { border-color: #4a6cf7; }
.btn-save {
margin-top: 10px;
padding: 8px 18px;
background: #4a6cf7;
color: #fff;
border: none;
border-radius: 6px;
font-size: 14px;
cursor: pointer;
transition: background 0.1s;
}
.btn-save:hover { background: #3b5de8; }
.history-section { margin-top: 8px; }
.history-item { margin-top: 8px; }
.history-text {
font-size: 13px;
color: #bbb;
white-space: pre-wrap;
word-break: break-word;
line-height: 1.4;
}
.blocks-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 6px;
margin-top: 4px;
}
.block-pill {
background: #1e1e1e;
border: 1px solid #333;
border-radius: 8px;
color: #ccc;
cursor: pointer;
font-family: inherit;
font-size: 12px;
line-height: 1.4;
padding: 6px 10px;
text-align: left;
transition: border-color 0.1s, background 0.1s;
white-space: pre-wrap;
word-break: break-word;
}
.block-pill:hover {
background: #2a2a2a;
border-color: #4a6cf7;
color: #e0e0e0;
}
</style>
</head>
<body x-data="prompter" @keydown.window="handleKey($event)">
<!-- ── Left: image ───────────────────────────────────────────────────── -->
<div class="image-panel">
<img :src="`/image/${currentIndex}`" :alt="currentImage" />
<div class="image-meta">
<div class="image-filename" x-text="currentImage.split('/').pop()"></div>
<div class="image-counter">
<input
type="number"
min="1"
:max="images.length"
:value="currentIndex + 1"
@change="jumpTo($event.target.valueAsNumber - 1)"
@keydown.stop
@click.stop
/>
<span>/ <span x-text="images.length"></span></span>
</div>
</div>
</div>
<!-- ── Right: options ────────────────────────────────────────────────── -->
<div class="options-panel">
<!-- ── Top half: prompt + history ──────────────────────────────────── -->
<div class="panel-top">
<div class="section-title">Prompt</div>
<!-- Custom prompt -->
<div class="option" :class="{ active: selectedOption === 'custom' }" @click="selectCustom()">
<div class="option-meta">
<span class="kbd">alt+1</span>
<span>Custom prompt</span>
<span class="saved-badge" x-show="selections[currentImage]">&#10003;&nbsp;saved</span>
</div>
<textarea
x-ref="textarea"
x-model="customPrompt"
@focus="selectedOption = 'custom'"
placeholder="Enter a prompt&#8230;"
rows="5"
></textarea>
<button class="btn-save" @click.stop="saveAndNext()">Save &amp; Next</button>
</div>
<!-- History -->
<div class="history-section" x-show="promptHistory.length > 0">
<div class="section-title">Previously used</div>
<template x-for="(prompt, i) in promptHistory" :key="prompt">
<div
class="option history-item"
:class="{ active: selectedOption === i }"
@click="selectHistory(i)"
>
<div class="option-meta">
<span class="kbd" x-text="`alt+${i + 2}`"></span>
</div>
<div class="history-text" x-text="prompt"></div>
</div>
</template>
</div>
</div>
<!-- ── Bottom half: building blocks ────────────────────────────────── -->
<div class="panel-bottom" x-show="buildingBlocks.length > 0">
<div class="section-title">Building blocks</div>
<div class="blocks-grid">
<template x-for="block in buildingBlocks" :key="block">
<button class="block-pill" @click="appendBlock(block)" x-text="block"></button>
</template>
</div>
</div>
</div>
<script>
/**
* @typedef {{ images: string[], canned_prompts: string[], building_blocks: string[], existing_prompts: Record<string, string> }} ApiImages
*/
document.addEventListener('alpine:init', () => {
Alpine.data('prompter', () => ({
/** @type {string[]} Ordered list of absolute image paths from the server */
images: [],
/** @type {number} Zero-based index of the currently displayed image */
currentIndex: 0,
/** @type {Record<string, string>} Absolute image path → saved prompt text */
selections: {},
/** @type {string} Current value of the custom-prompt textarea */
customPrompt: '',
/** @type {'custom' | number | null} Which option row is highlighted */
selectedOption: null,
/** @type {string[]} Reusable text fragments loaded from the server */
buildingBlocks: [],
/**
* Prompt history persisted to localStorage via Alpine Persist.
* Ordered most-recent-first; no duplicates.
* @type {string[]}
*/
promptHistory: Alpine.$persist([]).as('prompter_history'),
/**
* Absolute path of the image currently on screen.
* @returns {string}
*/
get currentImage() {
return this.images[this.currentIndex] ?? '';
},
/**
* Fetch the image list from the server, merge canned prompts into
* history, and restore any previously saved prompt for the first image.
*/
async init() {
/** @type {ApiImages} */
const data = await fetch('/api/images').then(r => r.json());
this.images = data.images;
this.selections = { ...data.existing_prompts };
this.buildingBlocks = data.building_blocks ?? [];
// Append canned prompts not already present in the persisted history.
const inHistory = new Set(this.promptHistory);
for (const p of data.canned_prompts) {
if (!inHistory.has(p)) {
this.promptHistory.push(p);
}
}
// Restore a previously saved prompt for the first image (if any).
const saved = this.selections[this.currentImage];
if (saved) {
this.customPrompt = saved;
this.selectedOption = 'custom';
}
},
/**
* Append a building block to the textarea, separated by a blank line.
* If the textarea is empty the block becomes the entire content.
* @param {string} block
*/
appendBlock(block) {
const cur = this.customPrompt.trimEnd();
this.customPrompt = cur ? cur + '\n' + block : block;
this.selectedOption = 'custom';
this.$nextTick(() => this.$refs.textarea.focus());
},
/** Highlight the custom-prompt option and focus the textarea. */
selectCustom() {
this.selectedOption = 'custom';
this.$nextTick(() => this.$refs.textarea.focus());
},
/**
* Copy a history entry into the textarea and focus it.
* @param {number} i - Index into promptHistory
*/
selectHistory(i) {
this.customPrompt = this.promptHistory[i];
this.selectedOption = 'custom';
this.$nextTick(() => this.$refs.textarea.focus());
},
/**
* POST the current textarea content to the server and update local
* state. Does nothing if the textarea is blank.
*/
async saveCurrentIfNeeded() {
const prompt = this.customPrompt.trim();
if (!prompt) return;
const filePath = this.currentImage;
this.selections[filePath] = prompt;
this._addToHistory(prompt);
await fetch('/api/save', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ file_path: filePath, prompt }),
});
},
/**
* Prepend a prompt to history, removing any earlier duplicate entry.
* @param {string} prompt
*/
_addToHistory(prompt) {
this.promptHistory = [
prompt,
...this.promptHistory.filter(p => p !== prompt),
];
},
/**
* Populate the textarea with the saved prompt for the current image,
* or clear it if no prompt has been saved yet.
*/
_loadCurrentSelection() {
const saved = this.selections[this.currentImage];
this.customPrompt = saved ?? '';
this.selectedOption = saved ? 'custom' : null;
},
/** Save the current prompt (if any) then advance to the next image. */
async navigateNext() {
if (this.currentIndex >= this.images.length - 1) return;
await this.saveCurrentIfNeeded();
this.currentIndex++;
this._loadCurrentSelection();
},
/** Save the current prompt (if any) then go back to the previous image. */
async navigatePrev() {
if (this.currentIndex <= 0) return;
await this.saveCurrentIfNeeded();
this.currentIndex--;
this._loadCurrentSelection();
},
/** Save the current prompt (if any) and advance to the next image. */
async saveAndNext() {
await this.saveCurrentIfNeeded();
if (this.currentIndex < this.images.length - 1) {
this.currentIndex++;
this._loadCurrentSelection();
}
},
/**
* Save the current prompt (if any) then jump to a specific index.
* Out-of-range values are clamped silently.
* @param {number} idx - Zero-based target index
*/
async jumpTo(idx) {
const target = Math.max(0, Math.min(idx, this.images.length - 1));
if (target === this.currentIndex) return;
await this.saveCurrentIfNeeded();
this.currentIndex = target;
this._loadCurrentSelection();
},
/**
* Global keyboard handler (attached to window via @keydown.window).
*
* Shortcuts:
* Alt+1 → focus the custom-prompt textarea
* Alt+N (N≥2) → load history[N-2] into the textarea and focus it
* → / k → next image (k ignored while textarea is focused)
* ← / j → prev image (j ignored while textarea is focused)
*
* Arrow keys navigate from anywhere, including inside the textarea,
* and auto-save the current prompt if non-empty.
*
* @param {KeyboardEvent} e
*/
handleKey(e) {
const inTextarea =
this.$refs.textarea &&
document.activeElement === this.$refs.textarea;
if (inTextarea) return;
if (e.altKey) {
const m = e.code.match(/^Digit(\d)$/);
if (m) {
const n = parseInt(m[1], 10);
e.preventDefault();
if (n === 1) {
this.selectCustom();
} else if (n >= 2 && n - 2 < this.promptHistory.length) {
this.selectHistory(n - 2);
}
return;
}
}
if (e.key === 'ArrowRight' || e.key === 'k') {
e.preventDefault();
this.navigateNext();
} else if (e.key === 'ArrowLeft' || e.key === 'j') {
e.preventDefault();
this.navigatePrev();
}
},
}));
});
</script>
</body>
</html>
+205
View File
@@ -0,0 +1,205 @@
#!/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()