A South African men's apparel store on Spree 5.6 + Next.js 15. 160 products
live, three markets, live Malaysian courier rates, customer-owned carts.
What is here:
storefront/ the Next.js app
backend/ ONLY the custom Spree code. Spree itself is a prebuilt image
that mounts no app code, so every file here needs an explicit
volume entry in docker-compose.yml or it is silently ignored.
catalogue/ the scripts that loaded and maintain the products
docs/ manifest, mock registry, napkins
Real .env files are committed deliberately. This lives on the house's private
Gitea, and a clone that runs beats a clone that needs a secrets handover.
LANDMINE.md maps every key, what it opens, and what must be rotated before this
repo goes anywhere less private.
The admin API key used to be a literal in six catalogue scripts. They now read
SPREE_ADMIN_KEY from the environment and refuse to run without it.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
187 lines
6.5 KiB
Python
187 lines
6.5 KiB
Python
#!/usr/bin/env python3
|
|
"""
|
|
Pak Jabit — Product Image Pipeline
|
|
PhotoRoom API: remove background + studio-quality packshot
|
|
|
|
Usage:
|
|
python3 photoroom_pipeline.py --input products/01-gshock-*/image-1.jpg
|
|
python3 photoroom_pipeline.py --input-dir products/ --all
|
|
python3 photoroom_pipeline.py --input products/01-gshock-*/image-1.jpg --dry-run
|
|
|
|
Env:
|
|
PHOTOROOM_API_KEY — sandbox_ prefix for testing (watermarked), real key for production
|
|
"""
|
|
|
|
import argparse, glob, json, os, sys, time
|
|
from pathlib import Path
|
|
import urllib.request, urllib.error
|
|
|
|
API_KEY = os.environ.get("PHOTOROOM_API_KEY")
|
|
EDIT_URL = "https://image-api.photoroom.com/v2/edit"
|
|
SEGMENT_URL = "https://sdk.photoroom.com/v1/segment"
|
|
|
|
def check_account():
|
|
req = urllib.request.Request(
|
|
"https://image-api.photoroom.com/v2/account",
|
|
headers={"x-api-key": API_KEY},
|
|
)
|
|
with urllib.request.urlopen(req, timeout=15) as r:
|
|
data = json.loads(r.read())
|
|
print(f"Plan: {data['plan']} | Credits: {data['images']['available']}/{data['images']['subscription']}")
|
|
return data
|
|
|
|
def remove_background(image_path: str, output_path: str):
|
|
boundary = "----PakJabitBoundary"
|
|
filename = os.path.basename(image_path)
|
|
with open(image_path, "rb") as f:
|
|
image_data = f.read()
|
|
|
|
body = (
|
|
f"--{boundary}\r\n"
|
|
f'Content-Disposition: form-data; name="image_file"; filename="{filename}"\r\n'
|
|
f"Content-Type: image/jpeg\r\n\r\n"
|
|
).encode() + image_data + (
|
|
f"\r\n--{boundary}\r\n"
|
|
f'Content-Disposition: form-data; name="format"\r\n\r\npng\r\n'
|
|
f"--{boundary}\r\n"
|
|
f'Content-Disposition: form-data; name="size"\r\n\r\nhd\r\n'
|
|
f"--{boundary}--\r\n"
|
|
).encode()
|
|
|
|
req = urllib.request.Request(SEGMENT_URL, data=body, method="POST", headers={
|
|
"x-api-key": API_KEY,
|
|
"Content-Type": f"multipart/form-data; boundary={boundary}",
|
|
})
|
|
with urllib.request.urlopen(req, timeout=60) as r:
|
|
with open(output_path, "wb") as out:
|
|
out.write(r.read())
|
|
return output_path
|
|
|
|
def studio_packshot(image_path: str, output_path: str, bg_color: str = "FFFFFF"):
|
|
boundary = "----PakJabitBoundary"
|
|
filename = os.path.basename(image_path)
|
|
with open(image_path, "rb") as f:
|
|
image_data = f.read()
|
|
|
|
fields = [
|
|
("background.color", bg_color),
|
|
("padding", "0.1"),
|
|
("shadow.mode", "ai.soft"),
|
|
("outputSize", "1200x1600"),
|
|
("export.format", "jpeg"),
|
|
("removeBackground", "true"),
|
|
]
|
|
|
|
body = b""
|
|
for name, value in fields:
|
|
body += (
|
|
f"--{boundary}\r\n"
|
|
f'Content-Disposition: form-data; name="{name}"\r\n\r\n{value}\r\n'
|
|
).encode()
|
|
|
|
body += (
|
|
f"--{boundary}\r\n"
|
|
f'Content-Disposition: form-data; name="imageFile"; filename="{filename}"\r\n'
|
|
f"Content-Type: image/jpeg\r\n\r\n"
|
|
).encode() + image_data + f"\r\n--{boundary}--\r\n".encode()
|
|
|
|
req = urllib.request.Request(EDIT_URL, data=body, method="POST", headers={
|
|
"x-api-key": API_KEY,
|
|
"Content-Type": f"multipart/form-data; boundary={boundary}",
|
|
})
|
|
with urllib.request.urlopen(req, timeout=90) as r:
|
|
with open(output_path, "wb") as out:
|
|
out.write(r.read())
|
|
return output_path
|
|
|
|
def process_image(image_path: str, output_dir: str, mode: str = "packshot"):
|
|
stem = Path(image_path).stem
|
|
parent = Path(image_path).parent.name
|
|
|
|
out_subdir = os.path.join(output_dir, parent)
|
|
os.makedirs(out_subdir, exist_ok=True)
|
|
|
|
if mode == "remove-bg":
|
|
out_path = os.path.join(out_subdir, f"{stem}-nobg.png")
|
|
remove_background(image_path, out_path)
|
|
elif mode == "packshot":
|
|
out_path = os.path.join(out_subdir, f"{stem}-studio.jpg")
|
|
studio_packshot(image_path, out_path)
|
|
else:
|
|
raise ValueError(f"Unknown mode: {mode}")
|
|
|
|
print(f" ✅ {os.path.basename(image_path)} → {os.path.basename(out_path)}")
|
|
return out_path
|
|
|
|
def main():
|
|
parser = argparse.ArgumentParser(description="PhotoRoom product image pipeline")
|
|
parser.add_argument("--input", nargs="+", help="Image file(s) or glob pattern")
|
|
parser.add_argument("--input-dir", help="Process all images in product subdirs")
|
|
parser.add_argument("--all", action="store_true", help="Process all image-*.jpg in input-dir")
|
|
parser.add_argument("--first-only", action="store_true", help="Only process image-1.jpg per product")
|
|
parser.add_argument("--output-dir", default="products-enhanced", help="Output directory")
|
|
parser.add_argument("--mode", choices=["remove-bg", "packshot"], default="packshot")
|
|
parser.add_argument("--dry-run", action="store_true", help="List files without processing")
|
|
parser.add_argument("--account", action="store_true", help="Check account credits")
|
|
parser.add_argument("--delay", type=float, default=1.0, help="Delay between API calls (seconds)")
|
|
args = parser.parse_args()
|
|
|
|
if not API_KEY:
|
|
print("❌ Set PHOTOROOM_API_KEY env var first")
|
|
print(" Sandbox key (free, watermarked): starts with sandbox_")
|
|
print(" Get it from: https://app.photoroom.com/api-dashboard")
|
|
sys.exit(1)
|
|
|
|
if args.account:
|
|
check_account()
|
|
return
|
|
|
|
images = []
|
|
if args.input:
|
|
for pattern in args.input:
|
|
images.extend(glob.glob(pattern))
|
|
elif args.input_dir and args.all:
|
|
pattern = "image-1.jpg" if args.first_only else "image-*.jpg"
|
|
images = sorted(glob.glob(os.path.join(args.input_dir, "**", pattern), recursive=True))
|
|
else:
|
|
parser.print_help()
|
|
return
|
|
|
|
if not images:
|
|
print("No images found")
|
|
return
|
|
|
|
print(f"📸 {len(images)} images | mode: {args.mode} | output: {args.output_dir}")
|
|
|
|
if args.dry_run:
|
|
for img in images:
|
|
print(f" would process: {img}")
|
|
return
|
|
|
|
check_account()
|
|
print()
|
|
|
|
ok, fail = 0, 0
|
|
for i, img in enumerate(images, 1):
|
|
print(f"[{i}/{len(images)}] {img}")
|
|
try:
|
|
process_image(img, args.output_dir, args.mode)
|
|
ok += 1
|
|
except urllib.error.HTTPError as e:
|
|
print(f" ❌ HTTP {e.code}: {e.read().decode()[:200]}")
|
|
fail += 1
|
|
if e.code == 429:
|
|
print(" ⏳ Rate limited, waiting 30s...")
|
|
time.sleep(30)
|
|
except Exception as e:
|
|
print(f" ❌ {e}")
|
|
fail += 1
|
|
|
|
if i < len(images):
|
|
time.sleep(args.delay)
|
|
|
|
print(f"\n✅ Done: {ok} ok, {fail} failed")
|
|
|
|
if __name__ == "__main__":
|
|
main()
|