import http.server, socketserver, json, base64, os, re, sys, argparse
from datetime import datetime

def parse_args():
    p = argparse.ArgumentParser(description="NovelAI autosave listener")
    p.add_argument('--port', type=int, default=8891)
    p.add_argument('--dir', default=r"A:\sd-webui-forge-neo\outputs")
    return p.parse_args()

args = parse_args()
PORT = args.port
SAVE_DIR = args.dir
os.makedirs(SAVE_DIR, exist_ok=True)

INVALID_CHARS = re.compile(r'[<>:"/\\|?*\x00-\x1f]')
COUNTER_RE = re.compile(r'^(\d+)-')
_counters: dict[str, int] = {}
port = 18891

def log(msg):
    print(f"[{datetime.now().strftime('%H:%M:%S %d-%m-%Y')}] {msg}")

def sanitize(text, max_len=100):
    text = INVALID_CHARS.sub('_', text).strip(' .')
    return text[:max_len] or 'untitled'

def next_counter(subfolder):
    if subfolder not in _counters:
        folder = os.path.join(SAVE_DIR, subfolder)
        os.makedirs(folder, exist_ok=True)
        nums = [int(m.group(1)) for f in os.listdir(folder) if (m := COUNTER_RE.match(f))]
        _counters[subfolder] = max(nums, default=0)
    _counters[subfolder] += 1
    return _counters[subfolder]

def fmt_num(n):
    if isinstance(n, float) and n.is_integer():
        return str(int(n))
    return str(n)

def format_character_prompts(comment):
    pos_root = comment.get('v4_prompt') or {}
    neg_root = comment.get('v4_negative_prompt') or {}
    pos_chars = ((pos_root.get('caption') or {}).get('char_captions')) or []
    neg_chars = ((neg_root.get('caption') or {}).get('char_captions')) or []
    use_coords = pos_root.get('use_coords', False)

    n = max(len(pos_chars), len(neg_chars))
    if n == 0:
        return []

    lines = []
    for i in range(n):
        pos_entry = pos_chars[i] if i < len(pos_chars) else {}
        neg_entry = neg_chars[i] if i < len(neg_chars) else {}
        pos_text = pos_entry.get('char_caption', '')
        neg_text = neg_entry.get('char_caption', '')
        if not pos_text and not neg_text:
            continue

        pos_str = ''
        if use_coords:
            centers = pos_entry.get('centers') or neg_entry.get('centers') or []
            if centers:
                c = centers[0]
                pos_str = f" (pos: {fmt_num(c.get('x'))}, {fmt_num(c.get('y'))})"

        label = f"Character {i + 1}{pos_str}:"
        lines.append(f"{label} {pos_text}" if pos_text else f"{label} (no caption)")
        if neg_text:
            lines.append(f"Character {i + 1} negative: {neg_text}")
    return lines

def build_plaintext_metadata(comment):
    prompt = comment.get('prompt', '')
    negative = comment.get('uc', '')
    schedule = str(comment.get('noise_schedule', '')).capitalize()

    params = [
        f"Steps: {comment.get('steps', '')}",
        f"Sampler: {comment.get('sampler', '')}",
        f"Schedule type: {schedule}",
        f"CFG scale: {fmt_num(comment.get('scale', ''))}",
        f"Seed: {comment.get('seed', '')}",
        f"Size: {comment.get('width', '')}x{comment.get('height', '')}",
        f"Model: {comment.get('model_name', '')}",
    ]
    if comment.get('model_hash'):
        params.append(f"Model hash: {comment['model_hash']}")

    lines = [prompt, f"Negative prompt: {negative}"]
    lines.extend(format_character_prompts(comment))
    lines.append(', '.join(params))
    return '\n'.join(lines)

class Handler(http.server.BaseHTTPRequestHandler):
    def do_GET(self):
        self.send_response(200)
        self.send_header('Access-Control-Allow-Origin', '*')
        self.send_header('Content-Type', 'text/plain')
        self.end_headers()
        self.wfile.write(b'ok')

    def do_POST(self):
        length = int(self.headers.get('Content-Length', 0))
        payload = json.loads(self.rfile.read(length))
        img_bytes = base64.b64decode(payload['data'])
        meta = payload.get('metadata', {})
        ext = payload.get('ext', 'png')
        if ext not in ('png', 'webp'):
            ext = 'png'

        subfolder = payload.get('subfolder', 'txt2img-images')
        if subfolder not in ('txt2img-images', 'img2img-images'):
            subfolder = 'txt2img-images'  # guard against unexpected values
        folder = os.path.join(SAVE_DIR, subfolder)
        os.makedirs(folder, exist_ok=True)

        try:
            comment = json.loads(meta.get('Comment', '{}'))
        except json.JSONDecodeError:
            comment = {}
        seed = comment.get('seed', 'noseed')
        prompt = meta.get('Description') or comment.get('prompt', '')

        n = next_counter(subfolder)
        base = f"{n}-{seed}-{sanitize(prompt)}"

        with open(os.path.join(folder, base + '.' + ext), 'wb') as f:
            f.write(img_bytes)
        with open(os.path.join(folder, base + '.txt'), 'w', encoding='utf-8') as f:
            f.write(build_plaintext_metadata(comment))

        log(f"saved {subfolder}/{base}.{ext}")
        self.send_response(200)
        self.send_header('Access-Control-Allow-Origin', '*')
        self.end_headers()

    def do_OPTIONS(self):
        self.send_response(200)
        self.send_header('Access-Control-Allow-Origin', '*')
        self.send_header('Access-Control-Allow-Methods', 'POST, OPTIONS')
        self.send_header('Access-Control-Allow-Headers', 'Content-Type')
        self.end_headers()

    def log_message(self, *a): pass

if __name__ == '__main__':
    try:
        with socketserver.TCPServer(("127.0.0.1", PORT), Handler) as httpd:
            log(f"listening on http://127.0.0.1:{PORT}, saving to {SAVE_DIR}")
            httpd.serve_forever()
    except OSError as e:
        log(f"Couldn't bind to port {PORT}: {e}")
        log(f"Something's already using it. Try: python {sys.argv[0]} --port {PORT + 1}")
Edit

Pub: 21 Aug 2026 12:14 UTC

Edit: 28 Aug 2026 14:14 UTC

Views: 12