chore: Update everything
This commit is contained in:
@@ -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]">✓ saved</span>
|
||||
</div>
|
||||
<textarea
|
||||
x-ref="textarea"
|
||||
x-model="customPrompt"
|
||||
@focus="selectedOption = 'custom'"
|
||||
placeholder="Enter a prompt…"
|
||||
rows="5"
|
||||
></textarea>
|
||||
<button class="btn-save" @click.stop="saveAndNext()">Save & 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>
|
||||
Executable
+205
@@ -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()
|
||||
Reference in New Issue
Block a user