chore: Update everything
This commit is contained in:
Executable
+939
@@ -0,0 +1,939 @@
|
||||
#!/usr/bin/env -S uv run
|
||||
# /// script
|
||||
# requires-python = ">=3.14"
|
||||
# dependencies = [
|
||||
# "bottle>=0.13.0",
|
||||
# ]
|
||||
# ///
|
||||
|
||||
import argparse
|
||||
import functools
|
||||
import os
|
||||
import re
|
||||
import socket
|
||||
import sys
|
||||
import threading
|
||||
import time
|
||||
import webbrowser
|
||||
from pathlib import Path
|
||||
from datetime import datetime
|
||||
from typing import TypedDict
|
||||
import bottle
|
||||
|
||||
IMAGE_EXTENSIONS: set[str] = {".jpg", ".jpeg", ".png", ".webp", ".webm"}
|
||||
|
||||
|
||||
class ImageGroup(TypedDict):
|
||||
"""Type definition for grouped images."""
|
||||
|
||||
base: str
|
||||
original: Path
|
||||
edits: list[Path]
|
||||
|
||||
|
||||
def find_available_port() -> int:
|
||||
"""Find an available random port."""
|
||||
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
|
||||
s.bind(("127.0.0.1", 0))
|
||||
s.listen(1)
|
||||
port = s.getsockname()[1]
|
||||
return port
|
||||
|
||||
|
||||
def is_image(path: str | Path) -> bool:
|
||||
"""Check if file is a supported image."""
|
||||
return Path(path).suffix.lower() in IMAGE_EXTENSIONS
|
||||
|
||||
|
||||
def discover_images(paths: list[str]) -> list[Path]:
|
||||
"""Discover all images from mixed list of files and folders."""
|
||||
images = []
|
||||
for path_str in paths:
|
||||
path = Path(path_str)
|
||||
if path.is_file() and is_image(path):
|
||||
images.append(path.resolve())
|
||||
elif path.is_dir():
|
||||
for item in path.iterdir():
|
||||
if item.is_file() and is_image(item):
|
||||
images.append(item.resolve())
|
||||
return sorted(set(images))
|
||||
|
||||
|
||||
def group_images(images: list[Path]) -> list[ImageGroup]:
|
||||
"""
|
||||
Group images by original + edits.
|
||||
Original: image.jpg
|
||||
Edits: image-edit1.jpg, image-edit2.jpg, etc.
|
||||
Returns list of dicts: {original: Path, edits: [Path, ...]}
|
||||
"""
|
||||
groups = {}
|
||||
|
||||
for img in images:
|
||||
stem = img.stem
|
||||
|
||||
# Check if this is an edit
|
||||
match = re.match(r"^(.+?)-edit", stem)
|
||||
if match:
|
||||
base = match.group(1)
|
||||
if base not in groups:
|
||||
groups[base] = {"original": None, "edits": []}
|
||||
groups[base]["edits"].append(img)
|
||||
else:
|
||||
# This is an original
|
||||
if stem not in groups:
|
||||
groups[stem] = {"original": None, "edits": []}
|
||||
groups[stem]["original"] = img
|
||||
|
||||
# Filter out groups without originals, convert to list
|
||||
result = []
|
||||
for base, group in groups.items():
|
||||
if group["original"]:
|
||||
result.append(ImageGroup(base=base, original=group["original"], edits=sorted(group["edits"])))
|
||||
|
||||
return sorted(result, key=lambda g: g["original"].name)
|
||||
|
||||
|
||||
def safe_image_path(filepath: str) -> Path:
|
||||
"""Verify image path is safe (prevent directory traversal)."""
|
||||
# Search for the image in all_images by filename
|
||||
for img in all_images:
|
||||
if img.name == filepath:
|
||||
return img
|
||||
raise ValueError(f"Unauthorized path: {filepath}")
|
||||
|
||||
|
||||
def hardlink_images(src_paths: list[Path | str], dest_dir: str | Path) -> None:
|
||||
"""Hard link multiple images to destination directory."""
|
||||
dest_path = Path(dest_dir)
|
||||
make_dirs(dest_path)
|
||||
|
||||
for src in src_paths:
|
||||
src = Path(src)
|
||||
dest = dest_path / src.name
|
||||
try:
|
||||
# Remove existing file if present
|
||||
if dest.exists():
|
||||
dest.unlink()
|
||||
os.link(src, dest)
|
||||
except Exception as e:
|
||||
print(f"Error hard linking {src} to {dest}: {e}", file=sys.stderr)
|
||||
raise
|
||||
|
||||
|
||||
# Global state
|
||||
app = bottle.Bottle()
|
||||
all_images = set()
|
||||
image_groups = []
|
||||
current_group_idx = 0
|
||||
last_heartbeat = datetime.now()
|
||||
should_exit = False
|
||||
picks_dir = Path()
|
||||
flags = []
|
||||
flag_dirs = {}
|
||||
|
||||
HTML_TEMPLATE = """
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Image Culler</title>
|
||||
<script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3.x.x/dist/cdn.min.js"></script>
|
||||
<style>
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
||||
background: #1e1e1e;
|
||||
color: #e0e0e0;
|
||||
overflow: hidden;
|
||||
height: 100vh;
|
||||
}
|
||||
.container {
|
||||
display: flex;
|
||||
height: calc(100vh - 46px);
|
||||
gap: 20px;
|
||||
padding: 20px;
|
||||
}
|
||||
.panel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 15px;
|
||||
}
|
||||
.left-panel {
|
||||
flex: 0 0 40%;
|
||||
}
|
||||
.right-panel {
|
||||
flex: 0 0 60%;
|
||||
overflow-y: auto;
|
||||
}
|
||||
.image-display {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: #2a2a2a;
|
||||
border-radius: 8px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
overflow: hidden;
|
||||
border: 2px solid transparent;
|
||||
transition: border-color 0.2s;
|
||||
}
|
||||
.image-display:hover {
|
||||
border-color: #4a9eff;
|
||||
}
|
||||
.image-display.picked {
|
||||
border-color: #4ade80;
|
||||
background: rgba(74, 222, 128, 0.1);
|
||||
}
|
||||
.image-display img {
|
||||
max-width: 100%;
|
||||
max-height: 100%;
|
||||
object-fit: contain;
|
||||
}
|
||||
.image-info {
|
||||
background: #2a2a2a;
|
||||
padding: 12px;
|
||||
border-radius: 8px;
|
||||
font-size: 14px;
|
||||
}
|
||||
dialog {
|
||||
background: #2a2a2a;
|
||||
color: #e0e0e0;
|
||||
border: 1px solid #404040;
|
||||
border-radius: 8px;
|
||||
padding: 20px;
|
||||
max-width: 400px;
|
||||
}
|
||||
dialog::backdrop {
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
}
|
||||
.grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: 10px;
|
||||
}
|
||||
.grid[data-image-count="1"] {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
.grid-item {
|
||||
position: relative;
|
||||
background: #2a2a2a;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
cursor: pointer;
|
||||
border: 2px solid transparent;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
.grid-item:hover {
|
||||
border-color: #4a9eff;
|
||||
}
|
||||
.grid-item img {
|
||||
width: 100%;
|
||||
height: auto;
|
||||
display: block;
|
||||
object-fit: contain;
|
||||
}
|
||||
.grid[data-image-count="1"] .grid-item img {
|
||||
max-height: 90vh;
|
||||
}
|
||||
.grid-item.picked {
|
||||
border-color: #4ade80;
|
||||
background: rgba(74, 222, 128, 0.1);
|
||||
}
|
||||
.flag-pill {
|
||||
background: #404040;
|
||||
color: #e0e0e0;
|
||||
border: 1px solid #555;
|
||||
}
|
||||
.flag-pill:hover {
|
||||
background: #505050;
|
||||
border-color: #666;
|
||||
}
|
||||
.flag-pill-active {
|
||||
background: #4a9eff;
|
||||
color: #000;
|
||||
border: 1px solid #4a9eff;
|
||||
font-weight: 500;
|
||||
}
|
||||
.flag-pill-active:hover {
|
||||
background: #6ab0ff;
|
||||
border-color: #6ab0ff;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div x-data="app()" x-init="init()" @keydown.window="handleKeydown($event)">
|
||||
<div style="background: #2a2a2a; padding: 12px 20px; border-bottom: 1px solid #404040;">
|
||||
<div style="font-size: 16px; font-weight: 500; color: #e0e0e0; word-break: break-all;">
|
||||
<span x-text="currentGroup ? currentGroup.original.name : 'No images'"></span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="container">
|
||||
<!-- Left Panel: Original Image -->
|
||||
<div class="panel left-panel">
|
||||
<div class="image-display" :class="{ picked: originalPicked }" style="position: relative;">
|
||||
<template x-if="currentGroup">
|
||||
<img :src="`/image/${currentGroup.original.name}`" :alt="currentGroup.original.name">
|
||||
<template x-if="imageFlagAssignments[currentGroup.original.name]">
|
||||
<div style="position: absolute; bottom: 12px; left: 12px; background: #4a9eff; color: #000; padding: 4px 8px; border-radius: 4px; font-size: 12px; font-weight: bold;">
|
||||
<span x-text="imageFlagAssignments[currentGroup.original.name]"></span>
|
||||
</div>
|
||||
</template>
|
||||
</template>
|
||||
<template x-if="!currentGroup">
|
||||
<div style="text-align: center; color: #888;">No images</div>
|
||||
</template>
|
||||
</div>
|
||||
<div class="image-info">
|
||||
<template x-if="currentGroup">
|
||||
<div>
|
||||
<div style="display: flex; align-items: center; gap: 8px;">
|
||||
<strong>Position:</strong>
|
||||
<input
|
||||
type="number"
|
||||
:value="currentGroupIdx + 1"
|
||||
@keyup.enter="jumpToIndex($event)"
|
||||
@change="jumpToIndex($event)"
|
||||
min="1"
|
||||
:max="imageGroups.length"
|
||||
style="width: 90px; background: #404040; color: #e0e0e0; border: 1px solid #555; padding: 4px 8px; border-radius: 4px; font-size: 14px;"
|
||||
>
|
||||
<span style="color: #888;">/ <span x-text="imageGroups.length"></span></span>
|
||||
</div>
|
||||
<template x-if="availableFlags.length > 0">
|
||||
<div style="margin-top: 10px;">
|
||||
<strong style="font-size: 13px;">Flags:</strong>
|
||||
<div style="display: flex; flex-wrap: wrap; gap: 6px; margin-top: 6px;">
|
||||
<template x-for="(flag, idx) in availableFlags" :key="idx">
|
||||
<div
|
||||
@click="handleFlagAssignment(flag)"
|
||||
:class="imageFlagAssignments[currentGroup.original.name] === flag ? 'flag-pill-active' : 'flag-pill'"
|
||||
style="cursor: pointer; padding: 4px 8px; border-radius: 4px; font-size: 12px; white-space: nowrap; transition: all 0.2s;"
|
||||
>
|
||||
<span x-text="`${flag} (${idx + 1})`"></span>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Right Panel: Edit Variations Grid -->
|
||||
<div class="panel right-panel">
|
||||
<template x-if="currentGroup && currentGroup.edits.length > 0">
|
||||
<div class="grid" :data-image-count="currentGroup.edits.length">
|
||||
<template x-for="(edit, idx) in currentGroup.edits" :key="idx">
|
||||
<div
|
||||
class="grid-item"
|
||||
:class="{ picked: pickedEdits.includes(idx) }"
|
||||
@click="toggleEditPick(idx)"
|
||||
>
|
||||
<img :src="`/image/${edit.name}`" :alt="`Edit ${idx + 1}`">
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
<template x-if="!currentGroup || currentGroup.edits.length === 0">
|
||||
<div style="color: #888; text-align: center; padding: 40px;">No variations</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Help Dialog -->
|
||||
<dialog id="helpDialog">
|
||||
<h2>Keyboard Shortcuts</h2>
|
||||
<div style="margin-top: 15px; font-size: 14px; line-height: 1.8;">
|
||||
<div style="margin-bottom: 10px;">
|
||||
<span style="background: #404040; padding: 2px 6px; border-radius: 3px; font-weight: bold;">P</span>
|
||||
Toggle pick (all edits or hover one)
|
||||
</div>
|
||||
<div style="margin-bottom: 10px;">
|
||||
<span style="background: #404040; padding: 2px 6px; border-radius: 3px; font-weight: bold;">←→</span>
|
||||
Navigate any image
|
||||
</div>
|
||||
<div style="margin-bottom: 10px;">
|
||||
<span style="background: #404040; padding: 2px 6px; border-radius: 3px; font-weight: bold;">K</span>
|
||||
Skip to next pending
|
||||
</div>
|
||||
<div style="margin-top: 15px; font-size: 13px; color: #aaa;">
|
||||
<strong>Flags:</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span style="background: #404040; padding: 2px 6px; border-radius: 3px; font-weight: bold;">1-9</span>
|
||||
Assign/unassign to flag
|
||||
</div>
|
||||
<div>
|
||||
<span style="background: #404040; padding: 2px 6px; border-radius: 3px; font-weight: bold;">/</span>
|
||||
Toggle reject flag
|
||||
</div>
|
||||
</div>
|
||||
<div style="margin-top: 20px; text-align: right;">
|
||||
<button @click="closeHelp()" style="background: #404040; color: #e0e0e0; border: none; padding: 8px 16px; border-radius: 4px; cursor: pointer;">Close</button>
|
||||
</div>
|
||||
</dialog>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
function app() {
|
||||
return {
|
||||
imageGroups: [],
|
||||
currentGroupIdx: 0,
|
||||
currentGroup: null,
|
||||
pickedEdits: [],
|
||||
originalPicked: false,
|
||||
groupStatus: {},
|
||||
availableFlags: [],
|
||||
imageFlagAssignments: {},
|
||||
|
||||
async init() {
|
||||
// Start heartbeat
|
||||
this.startHeartbeat();
|
||||
|
||||
// Track mouse position globally for hover detection
|
||||
document.addEventListener('mousemove', (e) => {
|
||||
window.lastMouseEvent = e;
|
||||
});
|
||||
|
||||
// Notify server when tab/window is closed
|
||||
window.addEventListener('beforeunload', () => {
|
||||
fetch('/api/shutdown', { method: 'POST' }).catch(() => {});
|
||||
});
|
||||
|
||||
// Load images
|
||||
await this.loadImages();
|
||||
|
||||
// Load first group
|
||||
this.loadGroup(0);
|
||||
},
|
||||
|
||||
async loadImages() {
|
||||
try {
|
||||
const resp = await fetch('/api/images');
|
||||
const data = await resp.json();
|
||||
this.imageGroups = data.groups;
|
||||
this.availableFlags = data.flags_list || [];
|
||||
this.groupStatus = {};
|
||||
this.imageFlagAssignments = data.flag_assignments || {};
|
||||
this.imageGroups.forEach((_, idx) => {
|
||||
this.groupStatus[idx] = 'pending';
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('Error loading images:', err.message);
|
||||
}
|
||||
},
|
||||
|
||||
loadGroup(idx) {
|
||||
if (idx < 0 || idx >= this.imageGroups.length) return;
|
||||
this.currentGroupIdx = idx;
|
||||
this.currentGroup = this.imageGroups[idx];
|
||||
this.pickedEdits = [];
|
||||
this.originalPicked = false;
|
||||
},
|
||||
|
||||
getGroupStatus(idx) {
|
||||
return this.groupStatus[idx] || 'pending';
|
||||
},
|
||||
|
||||
jumpToIndex(event) {
|
||||
const value = parseInt(event.target.value);
|
||||
if (!isNaN(value) && value >= 1 && value <= this.imageGroups.length) {
|
||||
this.loadGroup(value - 1);
|
||||
}
|
||||
},
|
||||
|
||||
toggleEditPick(idx) {
|
||||
if (this.pickedEdits.includes(idx)) {
|
||||
this.pickedEdits = this.pickedEdits.filter(i => i !== idx);
|
||||
} else {
|
||||
this.pickedEdits.push(idx);
|
||||
}
|
||||
},
|
||||
|
||||
getHoveredElement() {
|
||||
// Find the element currently under the mouse pointer
|
||||
const mouseEvent = window.lastMouseEvent;
|
||||
if (!mouseEvent) return null;
|
||||
return document.elementFromPoint(mouseEvent.clientX, mouseEvent.clientY);
|
||||
},
|
||||
|
||||
async handleKeydown(e) {
|
||||
// Skip keybindings if focused on input/textarea
|
||||
const target = e.target;
|
||||
const isInput = target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' || target.contentEditable === 'true';
|
||||
if (isInput) {
|
||||
return;
|
||||
}
|
||||
|
||||
const key = e.key.toLowerCase();
|
||||
|
||||
if (key === 'p') {
|
||||
e.preventDefault();
|
||||
await this.handlePick();
|
||||
} else if (key === '?') {
|
||||
e.preventDefault();
|
||||
this.toggleHelp();
|
||||
} else if (key === '/') {
|
||||
e.preventDefault();
|
||||
await this.handleFlagAssignment('reject');
|
||||
} else if (key === 'arrowleft') {
|
||||
e.preventDefault();
|
||||
let nextIdx = this.currentGroupIdx - 1;
|
||||
if (nextIdx < 0) nextIdx = this.imageGroups.length - 1;
|
||||
this.loadGroup(nextIdx);
|
||||
} else if (key === 'arrowright') {
|
||||
e.preventDefault();
|
||||
let nextIdx = this.currentGroupIdx + 1;
|
||||
if (nextIdx >= this.imageGroups.length) nextIdx = 0;
|
||||
this.loadGroup(nextIdx);
|
||||
} else if (key === 'k') {
|
||||
e.preventDefault();
|
||||
for (let i = this.currentGroupIdx + 1; i < this.imageGroups.length; i++) {
|
||||
if (this.getGroupStatus(i) === 'pending') {
|
||||
this.loadGroup(i);
|
||||
return;
|
||||
}
|
||||
}
|
||||
} else if (key >= '1' && key <= '9') {
|
||||
e.preventDefault();
|
||||
const flagIdx = parseInt(key) - 1;
|
||||
if (flagIdx < this.availableFlags.length) {
|
||||
const flagName = this.availableFlags[flagIdx];
|
||||
await this.handleFlagAssignment(flagName);
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
async handlePick() {
|
||||
if (!this.currentGroup) return;
|
||||
|
||||
const currentStatus = this.getGroupStatus(this.currentGroupIdx);
|
||||
|
||||
// If already picked, unpick it
|
||||
if (currentStatus === 'picked') {
|
||||
try {
|
||||
const resp = await fetch('/api/unpick', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
original: this.currentGroup.original.name
|
||||
})
|
||||
});
|
||||
if (resp.ok) {
|
||||
this.groupStatus[this.currentGroupIdx] = 'pending';
|
||||
this.originalPicked = false;
|
||||
this.pickedEdits = [];
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Error unpicking:', err);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const hoveredEl = this.getHoveredElement();
|
||||
const isHoveringOriginal = hoveredEl?.closest('.image-display:not(.right-panel *)');
|
||||
|
||||
// If hovering over original, pick just the original
|
||||
if (isHoveringOriginal) {
|
||||
try {
|
||||
const resp = await fetch('/api/pick', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
original: this.currentGroup.original.name,
|
||||
edits: [],
|
||||
includeOriginal: true
|
||||
})
|
||||
});
|
||||
if (resp.ok) {
|
||||
this.originalPicked = true;
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Error picking original:', err);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// If hovering on edit, pick just that edit
|
||||
const hoveredGridItem = hoveredEl?.closest('.grid-item');
|
||||
if (hoveredGridItem && this.currentGroup.edits.length > 0) {
|
||||
const idx = Array.from(hoveredGridItem.parentElement.querySelectorAll('.grid-item')).indexOf(hoveredGridItem);
|
||||
if (idx !== -1) {
|
||||
const editName = this.currentGroup.edits[idx].name;
|
||||
try {
|
||||
const resp = await fetch('/api/pick', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
original: this.currentGroup.original.name,
|
||||
edits: [editName],
|
||||
includeOriginal: false
|
||||
})
|
||||
});
|
||||
if (resp.ok) {
|
||||
this.pickedEdits.push(idx);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Error picking edit:', err);
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// If not hovering, pick all selected edits and advance
|
||||
let editsToPick = this.pickedEdits.map(i => this.currentGroup.edits[i].name);
|
||||
|
||||
// If no edits selected but there's exactly 1 edit, pick it automatically
|
||||
if (editsToPick.length === 0 && this.currentGroup.edits.length === 1) {
|
||||
editsToPick = [this.currentGroup.edits[0].name];
|
||||
}
|
||||
|
||||
// Only proceed if there are edits to pick
|
||||
if (editsToPick.length === 0) return;
|
||||
|
||||
try {
|
||||
const resp = await fetch('/api/pick', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
original: this.currentGroup.original.name,
|
||||
edits: editsToPick,
|
||||
includeOriginal: false
|
||||
})
|
||||
});
|
||||
if (resp.ok) {
|
||||
this.groupStatus[this.currentGroupIdx] = 'picked';
|
||||
this.loadGroup(this.currentGroupIdx + 1);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Error picking edits:', err);
|
||||
}
|
||||
},
|
||||
|
||||
startHeartbeat() {
|
||||
setInterval(async () => {
|
||||
try {
|
||||
await fetch('/api/heartbeat', { method: 'POST' });
|
||||
} catch (err) {
|
||||
console.error('Heartbeat failed:', err);
|
||||
}
|
||||
}, 1000);
|
||||
},
|
||||
|
||||
toggleHelp() {
|
||||
const dialog = document.getElementById('helpDialog');
|
||||
if (dialog.open) {
|
||||
dialog.close();
|
||||
} else {
|
||||
dialog.showModal();
|
||||
}
|
||||
},
|
||||
|
||||
closeHelp() {
|
||||
document.getElementById('helpDialog').close();
|
||||
},
|
||||
|
||||
async handleFlagAssignment(flagName) {
|
||||
if (!this.currentGroup) return;
|
||||
|
||||
const imageKey = this.currentGroup.original.name;
|
||||
|
||||
// Toggle: if already assigned to this flag, unassign
|
||||
if (this.imageFlagAssignments[imageKey] === flagName) {
|
||||
try {
|
||||
await fetch('/api/unassign-flag', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
original: imageKey,
|
||||
flag: flagName
|
||||
})
|
||||
});
|
||||
delete this.imageFlagAssignments[imageKey];
|
||||
} catch (err) {
|
||||
console.error('Error unassigning flag:', err);
|
||||
}
|
||||
} else {
|
||||
// Assign to new flag
|
||||
try {
|
||||
const resp = await fetch('/api/assign-flag', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
original: imageKey,
|
||||
flag: flagName
|
||||
})
|
||||
});
|
||||
if (resp.ok) {
|
||||
this.imageFlagAssignments[imageKey] = flagName;
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Error assigning flag:', err);
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
"""
|
||||
|
||||
|
||||
@app.get("/")
|
||||
def index() -> str:
|
||||
return HTML_TEMPLATE
|
||||
|
||||
|
||||
@app.get("/api/images")
|
||||
def api_images():
|
||||
"""Return grouped images and current flag assignments."""
|
||||
global image_groups, flags, flag_dirs
|
||||
result = []
|
||||
for group in image_groups:
|
||||
result.append(
|
||||
{
|
||||
"base": group["base"],
|
||||
"original": {"name": group["original"].name, "path": str(group["original"])},
|
||||
"edits": [{"name": e.name, "path": str(e)} for e in group["edits"]],
|
||||
}
|
||||
)
|
||||
|
||||
# Build flag assignments by checking which flag directories contain which original images
|
||||
flag_assignments = {}
|
||||
for flag, flag_dir in flag_dirs.items():
|
||||
if flag_dir.exists():
|
||||
for item in flag_dir.iterdir():
|
||||
if item.is_file():
|
||||
# Map the file name to its flag
|
||||
flag_assignments[item.name] = flag
|
||||
|
||||
return {"groups": result, "flags_list": flags, "flag_assignments": flag_assignments}
|
||||
|
||||
|
||||
@app.get("/image/<filename>")
|
||||
def serve_image(filename: str):
|
||||
"""Serve an image file safely."""
|
||||
try:
|
||||
target = safe_image_path(filename)
|
||||
return bottle.static_file(target.name, root=str(target.parent), mimetype="image/jpeg")
|
||||
except ValueError:
|
||||
bottle.response.status = 403
|
||||
return {"error": "Unauthorized path"}
|
||||
|
||||
|
||||
@app.post("/api/pick")
|
||||
def api_pick():
|
||||
"""Hard link picked images to _picks directory."""
|
||||
global image_groups, current_group_idx, picks_dir
|
||||
|
||||
# Create directory before writing
|
||||
make_dirs(picks_dir)
|
||||
|
||||
data = bottle.request.json
|
||||
original_name = data.get("original")
|
||||
edits_names = data.get("edits", [])
|
||||
include_original = data.get("includeOriginal", False)
|
||||
|
||||
files_to_pick = []
|
||||
|
||||
# Add original if requested
|
||||
if include_original:
|
||||
for img in all_images:
|
||||
if img.name == original_name:
|
||||
files_to_pick.append(img)
|
||||
break
|
||||
|
||||
# Add edits by finding them in all_images
|
||||
for edit_name in edits_names:
|
||||
for img in all_images:
|
||||
if img.name == edit_name:
|
||||
files_to_pick.append(img)
|
||||
break
|
||||
|
||||
try:
|
||||
hardlink_images(files_to_pick, picks_dir)
|
||||
return {"status": "ok"}
|
||||
except Exception as e:
|
||||
bottle.response.status = 500
|
||||
return {"error": str(e)}
|
||||
|
||||
|
||||
@app.post("/api/unpick")
|
||||
def api_unpick():
|
||||
"""Remove picked images from _picks directory."""
|
||||
global picks_dir
|
||||
|
||||
data = bottle.request.json
|
||||
original_name = data.get("original")
|
||||
|
||||
try:
|
||||
# Find and remove the original and any edits from picks directory
|
||||
if picks_dir.exists():
|
||||
for item in picks_dir.iterdir():
|
||||
if item.is_file() and (item.name == original_name or item.stem.startswith(original_name.rsplit(".", 1)[0] + "-edit")):
|
||||
item.unlink()
|
||||
return {"status": "ok"}
|
||||
except Exception as e:
|
||||
bottle.response.status = 500
|
||||
return {"error": str(e)}
|
||||
|
||||
|
||||
@app.post("/api/assign-flag")
|
||||
def api_assign_flag():
|
||||
"""Assign an image to a flag."""
|
||||
global flag_dirs, all_images
|
||||
|
||||
data = bottle.request.json
|
||||
original_name = data.get("original")
|
||||
flag_name = data.get("flag")
|
||||
|
||||
# Find original file
|
||||
original_path = None
|
||||
for img in all_images:
|
||||
if img.name == original_name:
|
||||
original_path = img
|
||||
break
|
||||
|
||||
if not original_path or flag_name not in flag_dirs:
|
||||
bottle.response.status = 400
|
||||
return {"error": "Invalid image or flag"}
|
||||
|
||||
# Create directory before writing
|
||||
make_dirs(flag_dirs[flag_name])
|
||||
|
||||
try:
|
||||
hardlink_images([original_path], flag_dirs[flag_name])
|
||||
return {"status": "ok"}
|
||||
except Exception as e:
|
||||
bottle.response.status = 500
|
||||
return {"error": str(e)}
|
||||
|
||||
|
||||
@app.post("/api/unassign-flag")
|
||||
def api_unassign_flag():
|
||||
"""Remove an image from a flag."""
|
||||
global flag_dirs
|
||||
|
||||
data = bottle.request.json
|
||||
original_name = data.get("original")
|
||||
flag_name = data.get("flag")
|
||||
|
||||
if flag_name not in flag_dirs:
|
||||
bottle.response.status = 400
|
||||
return {"error": "Invalid flag"}
|
||||
|
||||
try:
|
||||
target_file = flag_dirs[flag_name] / original_name
|
||||
if target_file.exists():
|
||||
target_file.unlink()
|
||||
return {"status": "ok"}
|
||||
except Exception as e:
|
||||
bottle.response.status = 500
|
||||
return {"error": str(e)}
|
||||
|
||||
|
||||
@app.post("/api/heartbeat")
|
||||
def api_heartbeat():
|
||||
"""Update last heartbeat timestamp."""
|
||||
global last_heartbeat
|
||||
last_heartbeat = datetime.now()
|
||||
return {"status": "ok"}
|
||||
|
||||
|
||||
@app.post("/api/shutdown")
|
||||
def api_shutdown():
|
||||
"""Shutdown the server."""
|
||||
global should_exit
|
||||
should_exit = True
|
||||
return {"status": "ok"}
|
||||
|
||||
|
||||
@functools.cache
|
||||
def make_dirs(path: Path) -> None:
|
||||
"""Create directory if it doesn't exist."""
|
||||
path.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
|
||||
def heartbeat_monitor() -> None:
|
||||
"""Monitor for shutdown signal."""
|
||||
global should_exit
|
||||
|
||||
while not should_exit:
|
||||
time.sleep(0.5)
|
||||
|
||||
print("\nShutting down server.", file=sys.stderr)
|
||||
os._exit(0)
|
||||
|
||||
|
||||
def main() -> None:
|
||||
global all_images, image_groups, last_heartbeat, picks_dir, flags, flag_dirs
|
||||
|
||||
parser = argparse.ArgumentParser(description="Image culling app for AI image2image transforms")
|
||||
parser.add_argument("paths", nargs="+", help="Image files or folders to cull")
|
||||
parser.add_argument("--picks-dir", default=None, help="Directory for picked images (default: $cwd/_picks)")
|
||||
parser.add_argument("--rejects-dir", default=None, help="Directory for rejected images (default: $cwd/_rejects)")
|
||||
parser.add_argument("--flags", default=None, help="Comma-separated flag names (e.g., 'a,b,c,d')")
|
||||
parser.add_argument("--flags-dir", default=None, help="Directory for flag subdirectories (default: $cwd)")
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
# Set directories
|
||||
cwd = Path.cwd()
|
||||
picks_dir = Path(args.picks_dir or (cwd / "_picks"))
|
||||
rejects_dir = Path(args.rejects_dir or (cwd / "_rejects"))
|
||||
flags_dir = Path(args.flags_dir or cwd)
|
||||
|
||||
# Set up flags
|
||||
if args.flags:
|
||||
flags = [f.strip() for f in args.flags.split(",")]
|
||||
for flag in flags:
|
||||
flag_dir = flags_dir / f"_picks_flag_{flag}"
|
||||
flag_dirs[flag] = flag_dir
|
||||
|
||||
# Always add reject as a flag at the end
|
||||
flags.append("reject")
|
||||
flag_dirs["reject"] = rejects_dir
|
||||
|
||||
# Discover images
|
||||
images = discover_images(args.paths)
|
||||
all_images = set(images)
|
||||
|
||||
if not images:
|
||||
print("No images found in provided paths.", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
# Group images
|
||||
image_groups = group_images(images)
|
||||
|
||||
if not image_groups:
|
||||
print("No original images found (looking for files matching naming convention).", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
print(f"Found {len(image_groups)} original image(s) with variations", file=sys.stderr)
|
||||
|
||||
# Find available port
|
||||
port = find_available_port()
|
||||
url = f"http://127.0.0.1:{port}"
|
||||
|
||||
# Start heartbeat monitor
|
||||
monitor_thread = threading.Thread(target=heartbeat_monitor, daemon=True)
|
||||
monitor_thread.start()
|
||||
|
||||
# Open browser
|
||||
print(f"Opening browser at {url}", file=sys.stderr)
|
||||
webbrowser.open(url)
|
||||
|
||||
# Start Flask server
|
||||
last_heartbeat = datetime.now()
|
||||
bottle.run(app, host="127.0.0.1", port=port, quiet=True)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in New Issue
Block a user