325 lines
11 KiB
Python
Executable File
325 lines
11 KiB
Python
Executable File
#!/usr/bin/env uv run
|
|
# /// script
|
|
# dependencies = ["httpx", "pillow"]
|
|
# ///
|
|
|
|
import argparse
|
|
import io
|
|
import json
|
|
import logging
|
|
import math
|
|
import random
|
|
import subprocess
|
|
import time
|
|
import uuid
|
|
from pathlib import Path
|
|
|
|
import httpx
|
|
from PIL import Image
|
|
|
|
logging.basicConfig(
|
|
level=logging.INFO,
|
|
format="%(asctime)s - %(levelname)s - %(message)s",
|
|
datefmt="%Y-%m-%d %H:%M:%S",
|
|
)
|
|
logging.getLogger("httpx").setLevel(logging.WARNING) # Suppress httpx debug logs
|
|
logger = logging.getLogger(__name__)
|
|
|
|
COMFY_URL = "https://randy-agents-mediterranean-herself.trycloudflare.com/"
|
|
COMFY_COOKIES = {
|
|
"C.35620185_auth_token": "027e0e069f66d46e6d84802b7d49076e6ebab8621491d8694992f16727f99b79",
|
|
}
|
|
WORKFLOW_FILE = Path("/Users/abdus/Downloads/klein_fixed.json")
|
|
IMAGE_OUTPUT_MEGAPIXELS = 2
|
|
|
|
|
|
SYSTEM_PROMPT = """
|
|
restyle this as an edited, finished photo taken by sony a7 iv.
|
|
Photorealistic textures.
|
|
same color grading, same colors and lighting.
|
|
Realistic, flawless skin.
|
|
# White people should have a pale skin.
|
|
age everyone by 5 years and make them look like 25 year old adults.
|
|
|
|
keep the same ethnicity and facial features.
|
|
|
|
Make the children taller and more slender with slightly elongated feminine neck and higher cheekbones, give them smaller, slimmer more feminine head and tapered face; round, smooth chin.
|
|
|
|
keep everything else about her the same.
|
|
same facial + eye expression (keep them as open as in the original), same pose and emotion, same tongue position.
|
|
same head and eye angle.
|
|
|
|
do not turn her face around.
|
|
same hair style, color, length.
|
|
same composition and crop.
|
|
keep the same makeup, do not change the colors of the lips, keep them glossy and wet.
|
|
long nipples. large areolas.
|
|
|
|
"""
|
|
|
|
|
|
def preprocess_image(image_path: Path) -> bytes:
|
|
with Image.open(image_path) as img:
|
|
if img.mode in ("RGBA", "P"):
|
|
img = img.convert("RGB")
|
|
max_size = 2500 # Slightly larger to support 3MP render
|
|
if max(img.size) > max_size:
|
|
img.thumbnail((max_size, max_size), Image.Resampling.LANCZOS)
|
|
buffer = io.BytesIO()
|
|
img.save(buffer, format="JPEG", quality=85, optimize=True)
|
|
return buffer.getvalue()
|
|
|
|
|
|
def _next_output_path(image_path: Path) -> Path:
|
|
base = image_path.with_name(f"{image_path.stem}-edit.jpg")
|
|
if not base.exists():
|
|
return base
|
|
counter = 2
|
|
while True:
|
|
candidate = image_path.with_name(f"{image_path.stem}-edit{counter}.jpg")
|
|
if not candidate.exists():
|
|
return candidate
|
|
counter += 1
|
|
|
|
|
|
def tag_file(path: Path, tag: str = "Blue") -> None:
|
|
if not path.exists():
|
|
raise FileNotFoundError(f"Target path does not exist: {path}")
|
|
|
|
abs_path = str(path.resolve())
|
|
|
|
script = f'tell application "Finder" to set tags of (POSIX file "{abs_path}" as alias) to {{"{tag}"}}'
|
|
|
|
try:
|
|
subprocess.run(
|
|
["osascript", "-e", script], check=True, capture_output=True, text=True
|
|
)
|
|
except subprocess.CalledProcessError as e:
|
|
# Finder may fail if the file is moved during execution or if permissions are insufficient
|
|
logger.warning(f"Failed to tag file: {e.stderr}")
|
|
|
|
|
|
class Stats:
|
|
def __init__(self) -> None:
|
|
self.run_times: list[float] = []
|
|
|
|
def add(self, elapsed: float) -> None:
|
|
self.run_times.append(elapsed)
|
|
|
|
def format_time(self, seconds: float) -> str:
|
|
if seconds < 60:
|
|
return f"{seconds:.1f}s"
|
|
minutes = seconds / 60
|
|
return f"{minutes:.1f}m"
|
|
|
|
def print_summary(self) -> None:
|
|
if not self.run_times:
|
|
return
|
|
|
|
if len(self.run_times) == 1:
|
|
return
|
|
|
|
shortest = min(self.run_times)
|
|
longest = max(self.run_times)
|
|
average = sum(self.run_times) / len(self.run_times)
|
|
total = sum(self.run_times)
|
|
|
|
print("=" * 20)
|
|
print("Run Statistics")
|
|
print("=" * 20)
|
|
print(f"Total runs: {len(self.run_times)}")
|
|
print(f"Shortest: {self.format_time(shortest)}")
|
|
print(f"Longest: {self.format_time(longest)}")
|
|
print(f"Average: {self.format_time(average)}")
|
|
print(f"Total time: {self.format_time(total)}")
|
|
print("=" * 20)
|
|
|
|
|
|
class ComfyUI:
|
|
PROMPT_NODE_ID = "75:74"
|
|
NEGATIVE_NODE_ID = "75:67"
|
|
IMAGE_LOAD_NODE_ID = "76"
|
|
SAVE_NODE_ID = "9"
|
|
UPSCALE_NODE_ID = "75:80"
|
|
MODEL_LOAD_NODE_ID = "75:70"
|
|
CLIP_LOAD_NODE_ID = "75:71"
|
|
VAE_LOAD_NODE_ID = "75:72"
|
|
SEED_NODE_ID = "75:73"
|
|
|
|
def __init__(self, url: str, cookies: dict) -> None:
|
|
self.url = url.rstrip("/")
|
|
self.client = httpx.Client(timeout=None, cookies=cookies, follow_redirects=True)
|
|
|
|
def _post(self, path: str, **kwargs) -> httpx.Response:
|
|
"""Try RunPod /api routes first, then legacy Comfy routes."""
|
|
candidates = [f"{self.url}/api{path}", f"{self.url}{path}"]
|
|
last_res: httpx.Response | None = None
|
|
for endpoint in candidates:
|
|
res = self.client.post(endpoint, **kwargs)
|
|
if res.status_code != 404:
|
|
return res
|
|
last_res = res
|
|
assert last_res is not None
|
|
return last_res
|
|
|
|
def _get(self, path: str, **kwargs) -> httpx.Response:
|
|
"""Try RunPod /api routes first, then legacy Comfy routes."""
|
|
candidates = [f"{self.url}/api{path}", f"{self.url}{path}"]
|
|
last_res: httpx.Response | None = None
|
|
for endpoint in candidates:
|
|
res = self.client.get(endpoint, **kwargs)
|
|
if res.status_code != 404:
|
|
return res
|
|
last_res = res
|
|
assert last_res is not None
|
|
return last_res
|
|
|
|
def transform(
|
|
self,
|
|
image_path: Path,
|
|
save_path: Path,
|
|
prompt: str,
|
|
negative_prompt: str = "",
|
|
) -> None:
|
|
seed = random.randint(0, 2**31 - 1)
|
|
client_id = str(uuid.uuid4())
|
|
|
|
# 1. Upload
|
|
processed_img_bytes = preprocess_image(image_path)
|
|
files = {"image": (f"upload_{uuid.uuid4()}.jpg", processed_img_bytes)}
|
|
res = self._post("/upload/image", files=files)
|
|
if res.is_error:
|
|
logger.error(f"Upload error: {res.text}")
|
|
res.raise_for_status()
|
|
filename = res.json()["name"]
|
|
|
|
# 2. Load Workflow
|
|
with open(WORKFLOW_FILE, "r") as f:
|
|
workflow = json.load(f)
|
|
|
|
# Get dimensions from the actual file
|
|
with Image.open(image_path) as img:
|
|
orig_width, orig_height = img.size
|
|
aspect_ratio = orig_width / orig_height
|
|
|
|
# Calculate target resolution for 3 Megapixels (approx 3,000,000 pixels)
|
|
# Formula: Width * (Width / Aspect) = TotalPixels
|
|
target_pixels = IMAGE_OUTPUT_MEGAPIXELS * 1024 * 1024
|
|
new_width = int(math.sqrt(target_pixels * aspect_ratio))
|
|
new_height = int(new_width / aspect_ratio)
|
|
|
|
# Force these into the Empty Latent node (Node 75:66)
|
|
# This ensures the "canvas" matches your photo's shape
|
|
workflow["75:66"]["inputs"]["width"] = (
|
|
new_width // 8
|
|
) * 8 # Must be multiple of 8
|
|
workflow["75:66"]["inputs"]["height"] = (new_height // 8) * 8
|
|
|
|
# Also update the Scheduler (Node 75:62) so Flux knows the scale
|
|
workflow["75:62"]["inputs"]["width"] = workflow["75:66"]["inputs"]["width"]
|
|
workflow["75:62"]["inputs"]["height"] = workflow["75:66"]["inputs"]["height"]
|
|
|
|
# Inject Advanced Data
|
|
workflow[self.PROMPT_NODE_ID]["inputs"]["text"] = prompt
|
|
workflow[self.NEGATIVE_NODE_ID]["inputs"]["text"] = negative_prompt
|
|
workflow[self.IMAGE_LOAD_NODE_ID]["inputs"]["image"] = filename
|
|
workflow[self.SEED_NODE_ID]["inputs"]["noise_seed"] = seed
|
|
|
|
workflow[self.UPSCALE_NODE_ID]["inputs"]["megapixels"] = IMAGE_OUTPUT_MEGAPIXELS
|
|
workflow[self.UPSCALE_NODE_ID]["inputs"]["upscale_method"] = "lanczos"
|
|
|
|
# 3. Queue
|
|
payload = {"prompt": workflow, "client_id": client_id}
|
|
res = self._post("/prompt", json=payload)
|
|
|
|
if res.is_error:
|
|
logger.error(f"Error: {res.text}")
|
|
res.raise_for_status()
|
|
|
|
prompt_id = res.json()["prompt_id"]
|
|
|
|
# 4. Wait
|
|
preview_output = None
|
|
while True:
|
|
jobs_res = self._get(
|
|
"/jobs",
|
|
params={
|
|
"status": "completed,failed,cancelled",
|
|
"limit": 64,
|
|
"offset": 0,
|
|
},
|
|
)
|
|
if jobs_res.is_error:
|
|
logger.error(f"Jobs poll error: {jobs_res.text}")
|
|
jobs_res.raise_for_status()
|
|
|
|
jobs = jobs_res.json().get("jobs", [])
|
|
match = next((job for job in jobs if job.get("id") == prompt_id), None)
|
|
if match:
|
|
if match.get("status") != "completed":
|
|
raise RuntimeError(
|
|
f"Job {prompt_id} ended with status: {match.get('status')}"
|
|
)
|
|
preview_output = match.get("preview_output")
|
|
break
|
|
time.sleep(0.25)
|
|
|
|
# 5. Download
|
|
if not preview_output:
|
|
raise RuntimeError(f"Completed job {prompt_id} has no preview_output")
|
|
|
|
view_params = {
|
|
"filename": preview_output["filename"],
|
|
"subfolder": preview_output.get("subfolder", ""),
|
|
"type": preview_output.get("type", "output"),
|
|
}
|
|
img_res = self._get("/view", params=view_params)
|
|
if img_res.is_error:
|
|
logger.error(f"View error: {img_res.text}")
|
|
img_res.raise_for_status()
|
|
|
|
# Convert to JPEG with quality=75
|
|
with Image.open(io.BytesIO(img_res.content)) as img:
|
|
if img.mode == "RGBA":
|
|
img = img.convert("RGB")
|
|
img.save(save_path, format="JPEG", quality=75)
|
|
|
|
def without_comments(s: str) -> str:
|
|
return "\n".join(
|
|
line for line in s.splitlines() if not line.strip().startswith("#")
|
|
)
|
|
|
|
|
|
def main() -> int:
|
|
parser = argparse.ArgumentParser()
|
|
parser.add_argument("images", nargs="+", type=Path)
|
|
args = parser.parse_args()
|
|
|
|
|
|
comfy = ComfyUI(COMFY_URL, COMFY_COOKIES)
|
|
stats = Stats()
|
|
|
|
for i, image_path in enumerate(args.images, start=1):
|
|
prompt = SYSTEM_PROMPT
|
|
replace_prompt_path = Path(__file__).parent / "flux_klein_prompt.txt"
|
|
if replace_prompt_path.is_file():
|
|
prompt = without_comments(replace_prompt_path.read_text())
|
|
|
|
save_path = _next_output_path(image_path)
|
|
start_time = time.time()
|
|
logger.info(f"Uploading ({i}/{len(args.images)}): {image_path.name}")
|
|
try:
|
|
comfy.transform(image_path, save_path, prompt)
|
|
except Exception as e:
|
|
logger.error(f"Error processing {image_path.name}: {e}")
|
|
continue
|
|
elapsed = time.time() - start_time
|
|
stats.add(elapsed)
|
|
|
|
stats.print_summary()
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
raise SystemExit(main())
|