import os os.environ["HF_HUB_OFFLINE"] = "1" os.environ["TRANSFORMERS_OFFLINE"] = "1" import time import json import shutil import requests from PIL import Image import logging import hashlib import sys import fcntl import re try: from . import database from . import embeddings except ImportError: import database import embeddings # Load configuration CONFIG_PATH = os.path.join(os.path.dirname(__file__), "config.json") def load_config(): with open(CONFIG_PATH, 'r') as f: conf = json.load(f) # Resolve relative paths relative to this script's directory base_dir = os.path.dirname(os.path.abspath(__file__)) for key in ["stage_dir", "output_dir", "failed_dir", "processed_file", "log_file"]: if not os.path.isabs(conf[key]): conf[key] = os.path.normpath(os.path.join(base_dir, "..", conf[key])) return conf CONF = load_config() # Setup logging logging.basicConfig( level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s', handlers=[ logging.FileHandler(CONF["log_file"]), logging.StreamHandler() ] ) def get_processed_files(): if os.path.exists(CONF["processed_file"]): try: with open(CONF["processed_file"], 'r') as f: data = json.load(f) if isinstance(data, list): # Migration: convert old list format to dict return {name: None for name in data} return data except Exception as e: logging.error(f"Error reading processed file: {e}") return {} return {} def save_processed_files(processed): try: p = CONF["processed_file"] tmp = p + ".tmp" with open(tmp, 'w') as f: json.dump(processed, f, indent=2) os.replace(tmp, p) except Exception as e: logging.error(f"Error saving processed file: {e}") def calculate_hash(filepath): """Calculate MD5 hash of a file.""" hasher = hashlib.md5() try: with open(filepath, 'rb') as f: for chunk in iter(lambda: f.read(4096), b""): hasher.update(chunk) return hasher.hexdigest() except Exception as e: logging.error(f"Error calculating hash for {filepath}: {e}") return None def crop_to_bbox(image_path, margin, top_margin=None, headroom=0.0): try: img = Image.open(image_path) if img.mode != 'RGBA': logging.info(f"Image {image_path} is mode {img.mode}, not RGBA. Skipping crop.") return img alpha = img.split()[-1] bbox = alpha.getbbox() if not bbox: logging.info(f"No non-transparent bbox found for {image_path}. Returning original.") return img if top_margin is None: top_margin = margin # Add margin left, upper, right, lower = bbox left = max(0, left - margin) upper = max(0, upper - top_margin) right = min(img.width, right + margin) lower = min(img.height, lower + margin) logging.info(f"Cropping {image_path} to {left, upper, right, lower} (margin={margin}, top_margin={top_margin})") cropped = img.crop((left, upper, right, lower)) if headroom > 0: h_px = int(cropped.height * headroom) if h_px > 0: logging.info(f"Adding {h_px}px headroom to {image_path}") new_img = Image.new("RGBA", (cropped.width, cropped.height + h_px), (0, 0, 0, 0)) new_img.paste(cropped, (0, h_px)) return new_img return cropped except Exception as e: logging.error(f"Failed to crop {image_path}: {e}") raise def is_file_stable(filepath): """Check if file size is stable for at least 1 second.""" try: size1 = os.path.getsize(filepath) time.sleep(1) size2 = os.path.getsize(filepath) return size1 == size2 and size1 > 0 except OSError: return False def flag_image(filename): input_path = os.path.join(CONF["stage_dir"], filename) timestamp = time.strftime("%Y%m%d_%H%M%S") failed_filename = f"{timestamp}_{filename}" failed_path = os.path.join(CONF["failed_dir"], failed_filename) try: os.makedirs(CONF["failed_dir"], exist_ok=True) logging.info(f"Flagging image {filename} (moving to failed directory as {failed_filename})") shutil.move(input_path, failed_path) except Exception as e: logging.error(f"Failed to move {filename} to failed directory: {e}") def process_image(filename): # Reload config in case it changed global CONF try: CONF = load_config() except: pass input_path = os.path.join(CONF["stage_dir"], filename) timestamp = time.strftime("%Y%m%d_%H%M%S") output_filename = f"{timestamp}_{filename}" output_path = os.path.join(CONF["output_dir"], output_filename) temp_path = input_path + ".tmp.png" try: logging.info(f"Starting processing for {filename}...") cropped_img = crop_to_bbox( input_path, CONF["margin"], top_margin=CONF.get("top_margin"), headroom=CONF.get("headroom", 0.0) ) # Save temporary cropped image for upload cropped_img.save(temp_path, format="PNG") prompt = CONF.get("prompt") if not prompt: bp = CONF.get("base_prompts", []) if bp and isinstance(bp, list) and len(bp) > 0: prompt = bp[0] else: prompt = "high quality, masterpiece" with open(temp_path, 'rb') as f: files = {'image': (filename, f, 'image/png')} data = { 'prompt': prompt, 'seed': CONF.get("seed", -1), 'max_area': CONF.get("max_area", 0) } logging.info(f"Calling API for {filename} -> {output_filename} with prompt: {prompt}") response = requests.post(CONF["api_url"], files=files, data=data, timeout=600) if response.status_code == 200: with open(output_path, 'wb') as f: f.write(response.content) logging.info(f"Successfully processed {filename} -> {output_path}") # Register in DB try: embedding = embeddings.generate_embedding(output_path) gid = filename database.upsert_person(output_filename, filepath=output_path, embedding=embedding, group_id=gid, is_source=True) # Also trigger tagging to get auto-name and clip description tag_url = CONF["api_url"].replace("/edit", "/tag") try: requests.post(tag_url, json={"filename": output_filename, "group_id": gid}, timeout=30) except Exception as tag_err: logging.error(f"Error triggering tagging for {output_filename}: {tag_err}") except Exception as db_err: logging.error(f"Database error registering {output_filename}: {db_err}") if os.path.exists(temp_path): os.remove(temp_path) return True else: logging.error(f"API failed for {filename}: {response.status_code} - {response.text}") if os.path.exists(temp_path): os.remove(temp_path) return False except requests.exceptions.ConnectionError as e: logging.error(f"Connection error while processing {filename}: {e}") if os.path.exists(temp_path): os.remove(temp_path) return None except Exception as e: logging.error(f"Error processing {filename}: {str(e)}", exc_info=True) if os.path.exists(temp_path): os.remove(temp_path) return False def update_car_html(): output_dir = CONF["output_dir"] car_html_path = os.path.join(output_dir, "car.html") if not os.path.exists(car_html_path): logging.warning(f"car.html not found at {car_html_path}") return try: # Use database to list only non-archived images persons = database.list_persons(include_archived=False) db_images = {p[0] for p in persons} # List images in output_dir extensions = ('.jpg', '.jpeg', '.png', '.gif', '.webp', '.bmp', '.svg') images = [f for f in os.listdir(output_dir) if f.lower().endswith(extensions) and f != "car.html" and f in db_images] # Sort by mtime, newest first images.sort(key=lambda x: os.path.getmtime(os.path.join(output_dir, x)), reverse=True) with open(car_html_path, 'r') as f: content = f.read() images_json = json.dumps(images, indent=12).strip() # Ensure it looks nice in the JS images_json = images_json.replace('\n', '\n ') pattern = r'// --- HYDRATION_START ---.*?// --- HYDRATION_END ---' replacement = f'// --- HYDRATION_START ---\n const PRELOADED_IMAGES = {images_json};\n // --- HYDRATION_END ---' new_content = re.sub(pattern, replacement, content, flags=re.DOTALL) tmp_path = car_html_path + ".tmp" with open(tmp_path, 'w') as f: f.write(new_content) os.replace(tmp_path, car_html_path) logging.info(f"Updated {car_html_path} atomically with {len(images)} images") except Exception as e: logging.error(f"Failed to update car.html: {e}") def main(): # Prevent multiple instances lock_file = os.path.join(os.path.dirname(os.path.abspath(__file__)), "watcher.lock") fp = open(lock_file, 'w') try: fcntl.lockf(fp, fcntl.LOCK_EX | fcntl.LOCK_NB) except IOError: print("Another instance of watcher.py is already running. Exiting.") sys.exit(1) processed = get_processed_files() # Ensure directories exist os.makedirs(CONF["stage_dir"], exist_ok=True) os.makedirs(CONF["output_dir"], exist_ok=True) os.makedirs(CONF["failed_dir"], exist_ok=True) logging.info(f"Watcher started. Monitoring {CONF['stage_dir']}...") logging.info(f"Output directory: {CONF['output_dir']}") logging.info(f"API URL: {CONF['api_url']}") while True: try: files = [f for f in os.listdir(CONF["stage_dir"]) if f.lower().endswith(('.png', '.jpg', '.jpeg')) and not f.endswith('.tmp.png')] for f in files: input_path = os.path.join(CONF["stage_dir"], f) # Check if file is stable (not still being copied) if not is_file_stable(input_path): continue # Calculate current file hash current_hash = calculate_hash(input_path) if not current_hash: continue # Check if already processed if f in processed: stored_hash = processed[f] if stored_hash == current_hash: continue if stored_hash is None: # Migration case: filename exists but no hash. # Skip to avoid mass re-processing, but update the hash. logging.info(f"Updating hash for previously processed {f}") processed[f] = current_hash save_processed_files(processed) continue res = process_image(f) if res is True: processed[f] = current_hash save_processed_files(processed) update_car_html() elif res is False: flag_image(f) # We don't add to processed here so that if the user # moves the file back to stage, it will be retried. except Exception as e: logging.error(f"Main loop error: {e}") time.sleep(CONF["poll_interval"]) if __name__ == "__main__": main()