diff --git a/.env.example b/.env.example index 3659c26..ab770ea 100644 --- a/.env.example +++ b/.env.example @@ -7,7 +7,7 @@ # Without it the app boots but every generation returns 502. # ── Required for any generation to work ── -GEMINI_API_KEY=AIza-XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX +GEMINI_API_KEY= # ── Server-side fallback (BYOK is the default) ── # When set to "true" (1/true/yes), the server's GEMINI_API_KEY above is used @@ -18,6 +18,27 @@ GEMINI_API_KEY=AIza-XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX # you don't want users to need their own key. Production = false. CREATIVE_ALLOW_SERVER_FALLBACK=false +# ── Photoshoot image provider (/shoot) ── +# auto (default): Higgsfield when both Higgsfield values below are set, else Gemini. +# higgsfield: Higgsfield only (the /shoot page returns 503 until keys are set). +# gemini: the existing Gemini composite pipeline. +PHOTOGEN_IMAGE_PROVIDER=auto +# Higgsfield API credentials (key ID + secret) from https://console.higgsfield.ai. +# Server-side only. Higgsfield shoots always charge the user's Photogen credits. +HIGGSFIELD_API_KEY_ID= +HIGGSFIELD_API_KEY_SECRET= +# Per-image USD spend estimates (not provider price guarantees). +# Defaults: Balanced 0.08, Quality 0.12, Ultra 0.72 (Ultra = 4k/high; Higgsfield's +# published standard rate for that is $0.7219/image). Confirm with POST /estimate. +# Set positive finite overrides to your provider account rates. +HIGGSFIELD_COST_USD_BALANCED= +HIGGSFIELD_COST_USD_QUALITY= +HIGGSFIELD_COST_USD_ULTRA= +# Optional overrides. Endpoint options: marketing-studio/image (2.0 Alpha), +# marketing-studio/image/flare, marketing-studio/image/sunburst (2.5). +# HIGGSFIELD_API_BASE=https://api.higgsfield.ai +# HIGGSFIELD_IMAGE_ENDPOINT=marketing-studio/image + # ── Data and output directories ── # Defaults shown — only override if mounting volumes elsewhere. CREATIVE_DATA_DIR=/app/data @@ -37,7 +58,7 @@ CREATIVE_VERSION_GRAPH_ENABLED=true # python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())" # FIGMA_TOKEN_ENCRYPTION_KEY=your-generated-fernet-key -# ── Daily cost limit per IP, in USD ── +# ── Shared daily provider spend limit, in USD (default 5) ── # Generations beyond this get a 429. Set to 0 to disable. CREATIVE_DAILY_LIMIT=5 diff --git a/README.md b/README.md index 7fde00f..d588ff2 100644 --- a/README.md +++ b/README.md @@ -123,3 +123,40 @@ More detail: [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md), [docs/QC.md](docs/QC. ## License MIT. See [LICENSE](LICENSE). + +### Product photoshoots (`/shoot`) + +Upload a PNG, JPG or WebP (up to 16 MB), choose a vibe and press Go. The pack +contains six channel formats by default; Customise changes quality, shots and +a short brand note. Progress resumes when you return. Review each label against +the real packaging before publishing; download individual shots or the ZIP. + +`PHOTOGEN_IMAGE_PROVIDER` selects `auto` (default), `higgsfield` or `gemini`. +Only Gemini accepts a user's own key. Higgsfield uses the server's account and +always requires a signed-in user with enough PhotoGen credits. + +Successful Higgsfield renders enter the shared `costs.json` daily spend ledger. +Per-render USD estimates default to $0.08 Balanced, $0.12 High and $0.72 Ultra; +set `HIGGSFIELD_COST_USD_BALANCED`, `HIGGSFIELD_COST_USD_QUALITY` and +`HIGGSFIELD_COST_USD_ULTRA` to your account's actual rates. These are planning +estimates, not provider price guarantees. Invalid or nonpositive overrides use +the defaults. Local white-background cutouts and failed renders add no provider +spend. `CREATIVE_DAILY_LIMIT` checks this ledger using the same estimates. + +Failed-shot refunds use per-pack/per-shot keys in `photoshoot_refunds` inside +the existing auth SQLite database. The credit return and guard commit together; +recovery safely retries the key before settling the pack JSON. Keep both the +auth database and `data/packs/` in persistent backups. + +The shoot page uses the PhotoGen assets in `static/photogen/` and a self-hosted +Archivo variable Latin font (weight and width axes). The app currently sets +no CSP. No external font origins are needed. + +Refresh the mocked screenshot set with: + +```sh +CI=1 SHOOT_SCREENSHOTS=1 SHOOT_SCREENSHOT_DIR=/workspace/photogen-rebuild/screenshots npx playwright test tests/browser/shoot.spec.js +``` + +The run writes the same PNGs to `docs/screenshots/` and the requested directory. +Sample product photos are illustrative brand assets, not real customer outputs. diff --git a/RUNBOOK.md b/RUNBOOK.md index a46f724..dd330c0 100644 --- a/RUNBOOK.md +++ b/RUNBOOK.md @@ -281,3 +281,22 @@ docker start photogen This legacy copy is local-only and is not a substitute for the encrypted off-host workflow or an isolated restore drill. + +## Photoshoot spend and recovery + +For `/shoot`, configure the server image provider with `PHOTOGEN_IMAGE_PROVIDER` +(`auto`, `higgsfield`, or `gemini`). Keep all credentials outside the repository. +Higgsfield accepts no user Gemini key; it requires an account and PhotoGen credits. + +The shared `CREATIVE_DAILY_LIMIT` ledger now includes each successful paid +Higgsfield output. Its estimates default to $0.08 / $0.12 / $0.72 USD per image +at Balanced / High / Ultra. Override with `HIGGSFIELD_COST_USD_BALANCED`, +`HIGGSFIELD_COST_USD_QUALITY`, `HIGGSFIELD_COST_USD_ULTRA` to match your provider +account. Only positive finite estimates are accepted; invalid values fall back +to defaults. Failed renders and local cutouts do not add spend. + +Pack JSON files live under `data/packs/`. Restart recovery fails unfinished +outputs and returns their credits. `photoshoot_refunds` in the auth database +records a unique pack/shot refund key atomically with the balance update, so a +crash before JSON settlement cannot duplicate the refund. Preserve that table +with the existing auth DB backup. Polls and ZIP downloads remain free. diff --git a/creative_studio_app/photoshoot.py b/creative_studio_app/photoshoot.py new file mode 100644 index 0000000..9b29235 --- /dev/null +++ b/creative_studio_app/photoshoot.py @@ -0,0 +1,438 @@ +"""One-button product photoshoot: vibes, the pack shot list, and the pack runner. + +A brand uploads one product photo, optionally picks a vibe, and gets a pack: +hero banner, lifestyle shot, square / portrait / story ads and a clean +white-background cutout. Each output is charged at the tier weight +(billing.credits_for) up front and refunded one by one if it fails. +""" + +import json +import re +import threading +import time +import uuid +import zipfile +from collections.abc import Callable +from concurrent.futures import ThreadPoolExecutor +from dataclasses import dataclass +from pathlib import Path + +from PIL import Image, ImageDraw, ImageFilter + +from .compositing import CompositeInputError, prepare_foreground_image +from .providers import ShotRequest, failure + +DEFAULT_VIBE = "clean" +DEFAULT_TIER = "balanced" +PACK_TIERS = ("balanced", "quality", "ultra") +MAX_NOTE_CHARS = 300 +PARALLEL_SHOTS = 3 + + +@dataclass(frozen=True) +class Vibe: + id: str + label: str + blurb: str + swatch: tuple[str, str, str] + set_design: str + + +VIBES = { + vibe.id: vibe + for vibe in ( + Vibe("clean", "Clean studio", "Bright and minimal. Lets the product speak.", + ("#f6f5f2", "#e4e1da", "#c9c4ba"), + "a bright, minimal studio set with a soft warm-grey seamless backdrop, " + "large diffused key light, gentle natural shadows, calm neutral palette"), + Vibe("sunlit", "Sunlit natural", "Warm daylight, linen and wood.", + ("#f3e3c7", "#d9b27c", "#8a6a43"), + "warm morning sunlight through a window, light oak and linen surfaces, " + "soft dappled shadows, airy natural palette"), + Vibe("bold", "Bold colour", "Punchy colour-blocked sets that stop the scroll.", + ("#ff5a36", "#ffd23f", "#3a86ff"), + "a bold colour-blocked set with saturated complementary backdrop and plinths, " + "crisp hard light with clean graphic shadows, playful modern palette"), + Vibe("luxe", "Moody luxe", "Dark, rich tones with dramatic light.", + ("#1c1a1f", "#4a3b35", "#b8925a"), + "a dark premium set with deep charcoal and stone textures, warm rim light, " + "dramatic falloff, subtle brass accents, rich low-key palette"), + Vibe("fresh", "Fresh outdoors", "Open air, greenery and natural light.", + ("#e8f1e4", "#8fbf7f", "#3e6b48"), + "an outdoor setting with fresh greenery, natural stone or wood surface, " + "bright open shade daylight, fresh botanical palette"), + ) +} + + +@dataclass(frozen=True) +class Shot: + id: str + label: str + use: str + aspect: str + composition: str + + +SHOTS = ( + Shot("hero", "Hero banner", "Website & email header", "16:9", + "wide hero banner composition, product as the clear focal point placed " + "slightly off-centre, generous clean negative space on one side for a headline"), + Shot("lifestyle", "Lifestyle", "Social posts & product pages", "4:5", + "an authentic lifestyle scene in a real-world setting where this kind of product " + "is naturally used, lived-in details, editorial feel"), + Shot("square", "Square ad", "Instagram & Facebook feed", "1:1", + "centred, simple and bold square composition, clear space above the product " + "for a short headline"), + Shot("portrait", "Portrait ad", "Feed ads (4:5)", "4:5", + "vertical feed-ad composition, product large in frame, uncluttered background"), + Shot("story", "Story ad", "Stories, Reels & TikTok", "9:16", + "tall full-screen vertical composition, product in the middle third, clear " + "space at the top and bottom for captions and app buttons"), + Shot("cutout", "White background", "Amazon, Shopify & marketplaces", "1:1", + "pure white seamless background (#FFFFFF), product centred, soft contact " + "shadow only, e-commerce packshot"), +) +SHOT_IDS = tuple(shot.id for shot in SHOTS) +SHOTS_BY_ID = {shot.id: shot for shot in SHOTS} + + +def build_prompt(shot: Shot, vibe: Vibe, note: str, *, product_in_scene: bool) -> str: + """Write the prompt so the brand never has to. + + product_in_scene=True: the model edits the product reference into the + scene (Higgsfield). False: the model paints an empty set and the real + product is composited on top (Gemini pipeline). + """ + style = "" if shot.id == "cutout" else f" Set design: {vibe.set_design}." + extra = f" Brand note: {note}." if note else "" + if product_in_scene: + return ( + f"Professional commercial product photograph of the product in the reference " + f"image. {shot.composition[0].upper()}{shot.composition[1:]}.{style}{extra} " + "Keep the product exactly as it appears in the reference: same shape, colours, " + "label, text and proportions. Do not redesign or relabel it. Photorealistic, " + "sharp focus on the product, high-end advertising quality. No added text, " + "logos or watermarks." + ) + return ( + f"Empty set for a premium product photo: {shot.composition}.{style}{extra} " + "A flat, level surface where a product will be placed. Photorealistic, " + "high-end advertising quality. No text, logos or watermarks." + ) + + +def clean_note(raw: str) -> str: + note = re.sub(r"\s+", " ", str(raw or "")).strip() + return note[:MAX_NOTE_CHARS] + + +def parse_shots(raw) -> list[str] | None: + """Return the requested shot ids in pack order, or None if invalid/empty.""" + if raw is None: + return list(SHOT_IDS) + requested = raw if isinstance(raw, list) else str(raw).split(",") + requested = {str(item).strip() for item in requested if str(item).strip()} + if not requested or not requested <= set(SHOT_IDS): + return None + return [shot_id for shot_id in SHOT_IDS if shot_id in requested] + + +def make_white_cutout(product_path: Path, destination: Path, size: int = 2000) -> bool: + """Place the product on pure white locally. False if the background can't be removed. + + Uses the same deterministic background removal as the composite pipeline: + supplied transparency, or near-white background touching the edges. + """ + try: + with Image.open(product_path) as source: + source_size = source.size + foreground = prepare_foreground_image(source) + except (CompositeInputError, OSError, ValueError): + return False + if foreground.size == source_size and foreground.getchannel("A").getextrema() == (255, 255): + return False # opaque photo: no background was removed, so not a real cutout + scale = min(size * 0.76 / foreground.width, size * 0.76 / foreground.height) + width = max(1, round(foreground.width * scale)) + height = max(1, round(foreground.height * scale)) + product = foreground.resize((width, height), Image.Resampling.LANCZOS) + canvas = Image.new("RGBA", (size, size), (255, 255, 255, 255)) + x = (size - width) // 2 + y = (size - height) // 2 + shadow = Image.new("RGBA", (size, size), (0, 0, 0, 0)) + ellipse = Image.new("L", (size, size), 0) + ImageDraw.Draw(ellipse).ellipse( + (x + width * 0.1, y + height - height * 0.03, x + width * 0.9, y + height + height * 0.04), + fill=70, + ) + shadow.putalpha(ellipse.filter(ImageFilter.GaussianBlur(size // 80))) + canvas.alpha_composite(shadow) + canvas.alpha_composite(product, (x, y)) + destination.parent.mkdir(parents=True, exist_ok=True) + canvas.convert("RGB").save(destination, "PNG") + return True + + +# ── Pack state ───────────────────────────────────────────────────────── + +ACTIVE = ("queued", "running") + + +class PackStore: + """One JSON file per pack, guarded by a process lock (gunicorn runs -w 1).""" + + def __init__(self, data_dir: Path): + self._dir = data_dir / "packs" + self._dir.mkdir(parents=True, exist_ok=True) + self._lock = threading.RLock() + self.live: set[str] = set() + + def _path(self, pack_id: str) -> Path | None: + if not re.fullmatch(r"pk_[0-9a-f]{24}", pack_id or ""): + return None + return self._dir / f"{pack_id}.json" + + def save(self, pack: dict) -> None: + with self._lock: + path = self._path(pack["id"]) + temporary = path.with_suffix(".tmp") + temporary.write_text(json.dumps(pack), encoding="utf-8") + temporary.replace(path) + + def load(self, pack_id: str) -> dict | None: + path = self._path(pack_id) + if path is None or not path.exists(): + return None + with self._lock: + return json.loads(path.read_text(encoding="utf-8")) + + def get(self, pack_id: str, owner_id: str) -> dict | None: + pack = self.load(pack_id) + if not pack or pack.get("owner_id") != owner_id: + return None + return pack + + def update(self, pack_id: str, change: Callable[[dict], None]) -> dict: + with self._lock: + pack = self.load(pack_id) + change(pack) + self.save(pack) + return pack + + def owner_has_active(self, owner_id: str) -> bool: + with self._lock: + return any( + (pack := self.load(pack_id)) and pack["owner_id"] == owner_id + and pack["status"] in ACTIVE + for pack_id in self.live + ) + + +def new_pack(*, owner_id: str, user_id: str | None, vibe: str, tier: str, note: str, + shot_ids: list[str], provider: str, credits_each: int, charged: bool) -> dict: + return { + "id": "pk_" + uuid.uuid4().hex[:24], + "owner_id": owner_id, + "user_id": user_id, + "created_at": time.time(), + "vibe": vibe, + "tier": tier, + "note": note, + "provider": provider, + "credits_each": credits_each, + "credits_charged": credits_each * len(shot_ids) if charged else 0, + "credits_refunded": 0, + "status": "queued", + "outputs": [ + {"id": shot_id, "label": SHOTS_BY_ID[shot_id].label, + "use": SHOTS_BY_ID[shot_id].use, "aspect": SHOTS_BY_ID[shot_id].aspect, + "status": "pending"} + for shot_id in shot_ids + ], + } + + +def finish_status(outputs: list[dict]) -> str: + done = sum(1 for output in outputs if output["status"] == "done") + if done == len(outputs): + return "done" + return "partial" if done else "failed" + + +def public_view(pack: dict) -> dict: + """Pack JSON for the browser: no paths, owner keys or user ids.""" + outputs = [ + {key: output.get(key) for key in ("id", "label", "use", "aspect", "status", "url", "error")} + for output in pack["outputs"] + ] + done = sum(1 for output in outputs if output["status"] == "done") + return { + "pack_id": pack["id"], + "created_at": pack["created_at"], + "status": pack["status"], + "vibe": pack["vibe"], + "tier": pack["tier"], + "provider": pack["provider"], + "credits_charged": pack["credits_charged"], + "credits_refunded": pack["credits_refunded"], + "completed": done, + "total": len(outputs), + "outputs": outputs, + "download_url": f"/api/shoot/{pack['id']}/download" if done else None, + } + + +FRIENDLY_ERRORS = { + "moderated": "This shot was blocked by the image service's safety filter.", + "timeout": "This shot took too long and was stopped.", + "interrupted": "The server restarted before this shot finished.", + "provider_out_of_credits": "The image service is temporarily unavailable.", +} + + +def friendly_error(code: str | None) -> str: + return FRIENDLY_ERRORS.get(code or "", "This shot didn't come out. Its credits were refunded.") + + +def refund_output_once(auth_db: Callable, pack: dict, output: dict) -> None: + """Commit the per-output guard and credit refund in the same SQLite transaction. + + The JSON pack can lag this transaction after a crash. Recovery retries the + same key, then repairs the JSON settlement without returning credits twice. + This journal belongs to photoshoots; other billing flows remain unchanged. + """ + if not pack.get("user_id") or not pack.get("credits_charged"): + return + key = f"{pack['id']}:{output['id']}" + with auth_db() as database: + database.execute("BEGIN IMMEDIATE") + database.execute("""CREATE TABLE IF NOT EXISTS photoshoot_refunds ( + refund_key TEXT PRIMARY KEY, user_id TEXT NOT NULL, credits INTEGER NOT NULL + )""") + claimed = database.execute( + "INSERT OR IGNORE INTO photoshoot_refunds VALUES (?, ?, ?)", + (key, pack["user_id"], pack["credits_each"]), + ) + if claimed.rowcount: + database.execute( + """UPDATE users SET credits_remaining = credits_remaining + ?, + credits_used_today = MAX(0, credits_used_today - ?) WHERE id = ?""", + (pack["credits_each"], pack["credits_each"], pack["user_id"]), + ) + database.commit() + + +def run_pack( + store: PackStore, + pack_id: str, + *, + provider, + api_key: str, + product_path: Path, + output_dir: Path, + to_image_url: Callable[[str], str], + refund_output: Callable[[dict, dict], None], + on_output_done: Callable[[dict, dict], None] = lambda *_args: None, +) -> dict: + """Render every output, refunding each failed one as soon as it fails.""" + pack = store.load(pack_id) + vibe = VIBES[pack["vibe"]] + pack_dir = output_dir / "packs" / pack_id + pack_dir.mkdir(parents=True, exist_ok=True) + store.update(pack_id, lambda state: state.update(status="running")) + + def render_one(index: int, output: dict) -> None: + shot = SHOTS_BY_ID[output["id"]] + target = pack_dir / f"{index + 1:02d}-{shot.id}-{shot.aspect.replace(':', 'x')}.png" + + def mark_running(state): + state["outputs"][index]["status"] = "running" + store.update(pack_id, mark_running) + + if shot.id == "cutout" and make_white_cutout(product_path, target): + result = {"path": str(target), "model": "local-cutout"} + else: + try: + result = provider.render( + ShotRequest( + prompt=build_prompt(shot, vibe, pack.get("note", ""), + product_in_scene=not provider.accepts_user_key), + aspect=shot.aspect, + tier=pack["tier"], + product_path=product_path, + output_path=target, + ), + api_key, + ) + except Exception: + result = failure("Image service unavailable", "service_unavailable") + ok = "error" not in result and Path(result.get("path", "")).is_file() + if not ok: + refund_output(pack, output) + + def record(state): + entry = state["outputs"][index] + if ok: + entry.update(status="done", path=result["path"], + url=to_image_url(result["path"]), model=result.get("model")) + else: + entry.update(status="failed", error=friendly_error(result.get("error_code")), + error_code=result.get("error_code") or "failed", refunded=True, + refund_key=f"{pack_id}:{output['id']}") + state["credits_refunded"] += state["credits_each"] if state["credits_charged"] else 0 + updated = store.update(pack_id, record) + if ok: + on_output_done(updated, updated["outputs"][index]) + + try: + with ThreadPoolExecutor(max_workers=PARALLEL_SHOTS) as pool: + list(pool.map(lambda pair: render_one(*pair), enumerate(pack["outputs"]))) + finally: + final = store.update(pack_id, lambda state: state.update( + status=("running" if any(output["status"] in ("pending", "running") + for output in state["outputs"]) + else finish_status(state["outputs"])), finished_at=time.time())) + store.live.discard(pack_id) + return final + + +def recover_if_orphaned(store: PackStore, pack: dict, refund_output: Callable[[dict, dict], None]) -> dict: + """A pack marked active with no live runner was cut off by a restart. + + Retry the atomic refund keys before repairing unfinished JSON settlements. + """ + if pack["status"] not in ACTIVE or pack["id"] in store.live: + return pack + def settle(state): + if state["status"] not in ACTIVE: + return + for output in state["outputs"]: + if output["status"] in ("pending", "running"): + if not output.get("refunded"): + refund_output(state, output) + output.update(status="failed", error=friendly_error("interrupted"), + error_code="interrupted", refund_key=f"{state['id']}:{output['id']}") + if state["credits_charged"] and not output.get("refunded"): + state["credits_refunded"] += state["credits_each"] + output["refunded"] = True + state["status"] = finish_status(state["outputs"]) + settled = store.update(pack["id"], settle) + return settled + + +def build_zip(pack: dict) -> Path | None: + """Zip the delivered images next to them; rebuilt only if missing.""" + done = [output for output in pack["outputs"] if output["status"] == "done" and output.get("path")] + if not done: + return None + first = Path(done[0]["path"]) + archive = first.parent / f"photoshoot-{pack['vibe']}-{pack['id'][-6:]}-{len(done)}.zip" + if not archive.exists(): + temporary = archive.with_suffix(".tmp") + with zipfile.ZipFile(temporary, "w", compression=zipfile.ZIP_STORED) as bundle: + for output in done: + path = Path(output["path"]) + if path.is_file(): + bundle.write(path, arcname=path.name) + temporary.replace(archive) + return archive diff --git a/creative_studio_app/photoshoot_routes.py b/creative_studio_app/photoshoot_routes.py new file mode 100644 index 0000000..92ec3fc --- /dev/null +++ b/creative_studio_app/photoshoot_routes.py @@ -0,0 +1,243 @@ +"""One-button photoshoot pack: page, options, start, progress and download.""" + +import logging +import math +import os +import threading +from collections.abc import Callable +from pathlib import Path + +from flask import Blueprint, jsonify, render_template, request, send_file + +from . import photoshoot +from .billing import CREDIT_WEIGHT_BY_TIER, credits_for +from .compositing import CompositeInputError, validate_foreground_file +from .costs import check_daily_limit, track_cost + +logger = logging.getLogger("creative_studio.photoshoot") + +HIGGSFIELD_COST_DEFAULTS = {"balanced": 0.08, "quality": 0.12, "ultra": 0.72} + + +def higgsfield_cost(tier: str) -> float: + default = HIGGSFIELD_COST_DEFAULTS[tier] + try: + estimate = float(os.environ.get(f"HIGGSFIELD_COST_USD_{tier.upper()}") or default) + return estimate if math.isfinite(estimate) and estimate > 0 else default + except ValueError: + return default + +def _spawn(work: Callable[[], None]) -> None: + threading.Thread(target=work, daemon=True, name="photoshoot-pack").start() + + +def create_blueprint( + *, + store: photoshoot.PackStore, + select_provider: Callable, + require_api_key: Callable, + require_access: Callable, + current_session: Callable[[], dict | None], + current_actor_id: Callable[[], str | None], + spend_credits: Callable[[str, int], tuple[bool, int]], + auth_db: Callable, + cost_db: Callable[[], Path], + cost_lock, + save_upload: Callable, + enforce_daily_limit: Callable, + get_output_dir: Callable[[], Path], + to_image_url: Callable[[str], str], + rate_limited: Callable, + add_entry: Callable = lambda *_args, **_kwargs: None, + spawn: Callable[[Callable[[], None]], None] = _spawn, +) -> Blueprint: + blueprint = Blueprint("photoshoot", __name__) + + def options() -> dict: + try: + provider = select_provider() + provider_name = provider.name + accepts_user_key = provider.name == "gemini" and provider.accepts_user_key + except ValueError: + provider_name, accepts_user_key = "higgsfield", False + return { + "provider": provider_name, + "accepts_user_key": accepts_user_key, + "vibes": [ + {"id": vibe.id, "label": vibe.label, "blurb": vibe.blurb, "swatch": list(vibe.swatch)} + for vibe in photoshoot.VIBES.values() + ], + "shots": [ + {"id": shot.id, "label": shot.label, "use": shot.use, "aspect": shot.aspect} + for shot in photoshoot.SHOTS + ], + "tiers": {tier: CREDIT_WEIGHT_BY_TIER[tier] for tier in photoshoot.PACK_TIERS}, + "default_vibe": photoshoot.DEFAULT_VIBE, + "default_tier": photoshoot.DEFAULT_TIER, + } + + def refund_pack(pack: dict, output: dict) -> None: + photoshoot.refund_output_once(auth_db, pack, output) + + @blueprint.get("/shoot") + def shoot_page(): + return render_template("shoot.html", options=options()) + + @blueprint.get("/api/shoot/options") + def shoot_options(): + return jsonify(options()) + + @blueprint.post("/api/shoot") + @rate_limited + def start_shoot(): + access_error = require_access() + if access_error is not None: + return access_error + owner_id = current_actor_id() + form = request.form + + # ── Validate everything before any credit moves ───────────────── + vibe = form.get("vibe") or photoshoot.DEFAULT_VIBE + if vibe not in photoshoot.VIBES: + return jsonify({"error": "Unknown vibe", "valid_vibes": list(photoshoot.VIBES)}), 400 + tier = form.get("tier") or photoshoot.DEFAULT_TIER + if tier not in photoshoot.PACK_TIERS: + return jsonify({"error": "Unknown quality", "valid_tiers": list(photoshoot.PACK_TIERS)}), 400 + shot_ids = photoshoot.parse_shots(form.get("shots")) + if shot_ids is None: + return jsonify({"error": "Choose at least one shot", "valid_shots": list(photoshoot.SHOT_IDS)}), 400 + note = photoshoot.clean_note(form.get("note", "")) + if "image" not in request.files: + return jsonify({"error": "Add a product photo to start"}), 400 + if store.owner_has_active(owner_id): + return jsonify({ + "error": "A shoot is already running", + "message": "Wait for your current shoot to finish, then start the next one.", + }), 409 + try: + provider = select_provider() + except ValueError: + return jsonify({ + "error": "Photoshoot is not set up yet", + "message": "The image service isn't configured on this server. No credits were charged.", + }), 503 + if provider.name == "higgsfield": + try: + daily_limit = float(os.environ.get("CREATIVE_DAILY_LIMIT") or "5") + except ValueError: + daily_limit = 5.0 + rejection = check_daily_limit( + cost_db(), estimated_count=len(shot_ids), tier=tier, daily_limit=daily_limit, + tier_models={tier: ("higgsfield", tier)}, + price_card={"higgsfield": {tier: higgsfield_cost(tier)}}, lock=cost_lock, + ) + limit_error = (jsonify(rejection), 429) if rejection else None + else: + limit_error = enforce_daily_limit(len(shot_ids), tier) + if limit_error is not None: + return limit_error + try: + product_path = save_upload(request.files["image"], "photoshoot") + validate_foreground_file(product_path) + except (ValueError, CompositeInputError, OSError) as error: + message = str(error) if isinstance(error, (ValueError, CompositeInputError)) else "" + return jsonify({ + "error": "We couldn't use that photo", + "message": message or "Try a PNG or JPG of the product on a plain background.", + }), 400 + + # ── Charge: outputs x tier weight, all-or-nothing ─────────────── + outputs = len(shot_ids) + cost = credits_for(tier, outputs) + session = current_session() + if provider.accepts_user_key: + # Gemini: the caller's own key pays, else credits (same rule as /api/generate). + api_key, error, charged = require_api_key(credits=cost) + if error is not None: + return error + else: + # Higgsfield runs on the server's account, so it always costs credits. + if not session: + return jsonify({ + "error": "Sign in to start a shoot", + "message": "Create a free account to get trial credits.", + }), 401 + ok, _remaining = spend_credits(session["user_id"], cost) + if not ok: + balance = (current_session() or {}).get("credits_remaining", 0) + return jsonify({ + "error": "Out of credits" if balance == 0 else "Not enough credits", + "message": f"This shoot needs {cost} credits and you have {balance}.", + "credits_required": cost, + "credits_remaining": balance, + }), 402 + api_key, charged = "", True + user_id = session["user_id"] if (session and charged) else None + + pack = photoshoot.new_pack( + owner_id=owner_id, + user_id=user_id, + vibe=vibe, tier=tier, note=note, shot_ids=shot_ids, + provider=provider.name, + credits_each=CREDIT_WEIGHT_BY_TIER[tier], + charged=charged, + ) + store.save(pack) + store.live.add(pack["id"]) + + def record_in_library(state: dict, output: dict) -> None: + if state["provider"] == "higgsfield" and output.get("model") != "local-cutout": + track_cost(cost_db(), {"higgsfield": {tier: higgsfield_cost(tier)}}, + "higgsfield", tier, 1, cost_lock) + # Shows the shot in History/Library; the pack itself never depends on it. + try: + add_entry("sess_" + state["id"][3:11], { + "type": "photoshoot", + "prompt": f"{photoshoot.VIBES[state['vibe']].label} · {output['label']}", + "image_url": output["url"], + "model": output.get("model", ""), + "note": output["aspect"], + }, state["owner_id"]) + except Exception: + logger.exception("photoshoot: could not add %s to the library", state["id"]) + + def work(): + photoshoot.run_pack( + store, pack["id"], + provider=provider, api_key=api_key, product_path=Path(product_path), + output_dir=get_output_dir(), to_image_url=to_image_url, + refund_output=refund_pack, on_output_done=record_in_library, + ) + + spawn(work) + return jsonify(photoshoot.public_view(store.load(pack["id"]))), 202 + + @blueprint.get("/api/shoot/") + @rate_limited + def shoot_status(pack_id): + # Polling is free: never spend credits here. + access_error = require_access() + if access_error is not None: + return access_error + pack = store.get(pack_id, current_actor_id()) + if not pack: + return jsonify({"error": "Shoot not found"}), 404 + pack = photoshoot.recover_if_orphaned(store, pack, refund_pack) + return jsonify(photoshoot.public_view(pack)) + + @blueprint.get("/api/shoot//download") + @rate_limited + def shoot_download(pack_id): + access_error = require_access() + if access_error is not None: + return access_error + pack = store.get(pack_id, current_actor_id()) + if not pack: + return jsonify({"error": "Shoot not found"}), 404 + archive = photoshoot.build_zip(pack) if pack["status"] not in photoshoot.ACTIVE else None + if archive is None: + return jsonify({"error": "Nothing to download yet"}), 409 + return send_file(archive, mimetype="application/zip", as_attachment=True, + download_name=archive.name) + + return blueprint diff --git a/creative_studio_app/providers/__init__.py b/creative_studio_app/providers/__init__.py new file mode 100644 index 0000000..7e2cf14 --- /dev/null +++ b/creative_studio_app/providers/__init__.py @@ -0,0 +1,26 @@ +"""Image providers for the photoshoot pack. + +PHOTOGEN_IMAGE_PROVIDER picks one: + auto (default) Higgsfield when its key ID and secret are set, else Gemini + higgsfield Higgsfield only + gemini the existing Gemini composite pipeline +""" + +import os +from collections.abc import Callable + +from . import higgsfield +from .base import ImageProvider, ShotRequest, failure, save_at_aspect +from .gemini import GeminiCompositeProvider + +__all__ = [ + "ImageProvider", "ShotRequest", "failure", "save_at_aspect", + "GeminiCompositeProvider", "select_provider", +] + + +def select_provider(run_composite: Callable) -> ImageProvider: + choice = os.environ.get("PHOTOGEN_IMAGE_PROVIDER", "auto").strip().lower() + if choice == "higgsfield" or (choice == "auto" and higgsfield.configured()): + return higgsfield.HiggsfieldProvider.from_env() + return GeminiCompositeProvider(run_composite) diff --git a/creative_studio_app/providers/base.py b/creative_studio_app/providers/base.py new file mode 100644 index 0000000..d1a37e8 --- /dev/null +++ b/creative_studio_app/providers/base.py @@ -0,0 +1,59 @@ +"""Image provider interface for the one-button photoshoot pack. + +A provider renders one scene shot of the brand's product to a local file. +Results are plain dicts so they match the rest of the app's image entries: +success -> {"path": str, "model": str} +failure -> {"error": str, "error_code": str} +""" + +from dataclasses import dataclass +from pathlib import Path +from typing import Protocol + + +@dataclass(frozen=True) +class ShotRequest: + prompt: str + aspect: str # "16:9", "4:5", "1:1", "9:16" + tier: str # billing tier: fast / balanced / quality / ultra + product_path: Path # validated product upload + output_path: Path # where the finished PNG must be written + + +class ImageProvider(Protocol): + name: str + # True when a caller's own Gemini key (X-API-Key) can pay for the render. + # Providers that only run on the server's account always charge credits. + accepts_user_key: bool + + def render(self, request: ShotRequest, api_key: str) -> dict: + ... + + +def failure(message: str, code: str) -> dict: + return {"error": message, "error_code": code} + + +def save_at_aspect(source: Path, destination: Path, aspect: str) -> None: + """Center-crop `source` to the exact `aspect` and save it as a PNG. + + Models don't always honour a requested ratio (and Higgsfield has no 4:5), + so every scene is normalised here; ad slots need exact sizes. + """ + from PIL import Image + + width_ratio, height_ratio = (int(part) for part in aspect.split(":")) + target = width_ratio / height_ratio + with Image.open(source) as image: + image = image.convert("RGB") + width, height = image.size + if width / height > target: + new_width = round(height * target) + left = (width - new_width) // 2 + image = image.crop((left, 0, left + new_width, height)) + elif width / height < target: + new_height = round(width / target) + top = (height - new_height) // 2 + image = image.crop((0, top, width, top + new_height)) + destination.parent.mkdir(parents=True, exist_ok=True) + image.save(destination, "PNG") diff --git a/creative_studio_app/providers/gemini.py b/creative_studio_app/providers/gemini.py new file mode 100644 index 0000000..460a695 --- /dev/null +++ b/creative_studio_app/providers/gemini.py @@ -0,0 +1,45 @@ +"""Gemini provider: the existing composite pipeline (scripts/creative_studio.py). + +Gemini paints an empty scene and the real product pixels are pasted on top, +so labels and packaging stay exact. A user's own Gemini key can pay for it. +""" + +from collections.abc import Callable +from pathlib import Path + +from .base import ShotRequest, failure, save_at_aspect + + +class GeminiCompositeProvider: + name = "gemini" + accepts_user_key = True + + def __init__(self, run_composite: Callable): + # run_composite(prompt, product_path, api_key, aspect, tier=, name_suffix=) + # -> list[dict]; see run_cli_composite in the web script. + self._run_composite = run_composite + + def render(self, request: ShotRequest, api_key: str) -> dict: + try: + results = self._run_composite( + request.prompt, + str(request.product_path), + api_key, + request.aspect, + tier=request.tier, + name_suffix=request.output_path.stem, + ) + except Exception: + return failure("Image service unavailable", "service_unavailable") + result = next((item for item in results if "error" not in item), None) + if result is None: + first = results[0] if results else {} + return failure( + first.get("error", "Image service returned nothing"), + first.get("error_code", "no_output"), + ) + try: + save_at_aspect(Path(result["path"]), request.output_path, request.aspect) + except (OSError, ValueError, KeyError): + return failure("Generated image could not be read", "bad_output") + return {"path": str(request.output_path), "model": result.get("model", "gemini")} diff --git a/creative_studio_app/providers/higgsfield.py b/creative_studio_app/providers/higgsfield.py new file mode 100644 index 0000000..3835e03 --- /dev/null +++ b/creative_studio_app/providers/higgsfield.py @@ -0,0 +1,266 @@ +"""Higgsfield provider (https://docs.higgsfield.ai), Marketing Studio Image. + +Every Higgsfield HTTP call lives in this module. Flow per shot: + 1. upload the product photo once per pack (presigned URL, cached by hash) + 2. POST the model endpoint with an Idempotency-Key + 3. poll status_url with backoff until a terminal status + 4. download the result and crop it to the exact ad ratio + +Docs checked 2026-10-04: + auth Authorization: Key : /docs/authentication + uploads POST /files/generate-upload-url, then PUT bytes /docs/concepts/file-uploads + model POST /marketing-studio/image (2.0 Alpha; also /flare, /sunburst) + body: prompt, image_urls[], aspect_ratio, resolution, quality, + enhance_prompt /docs/models/marketing-studio-image + status queued | in_progress | completed | failed | nsfw | canceled + billing failed and nsfw requests are not charged by Higgsfield +Higgsfield has no 4:5 ratio, so 4:5 shots render at 3:4 and are cropped. + +Credentials come from the environment and are never logged or returned. +""" + +import hashlib +import json +import os +import random +import tempfile +import time +import urllib.error +import urllib.request +import uuid +from collections.abc import Callable +from io import BytesIO +from pathlib import Path + +from .base import ShotRequest, failure, save_at_aspect + +DEFAULT_BASE_URL = "https://api.higgsfield.ai" +DEFAULT_IMAGE_ENDPOINT = "marketing-studio/image" +TERMINAL = {"completed", "failed", "nsfw", "canceled"} + +# Higgsfield aspect_ratio enum: auto 1:1 3:2 2:3 4:3 3:4 16:9 9:16 21:9. +ASPECT = {"1:1": "1:1", "16:9": "16:9", "9:16": "9:16", "4:5": "3:4"} + +# Billing tier -> (resolution, quality). TODO(Cameron): confirm the per-image +# Higgsfield cost for each pair with POST /estimate/ on the real +# account, then revisit CREDIT_WEIGHT_BY_TIER in billing.py. +TIER = { + "fast": ("1k", "medium"), + "balanced": ("1k", "high"), + "quality": ("2k", "high"), + "ultra": ("4k", "high"), +} + + +class HiggsfieldError(Exception): + def __init__(self, code: str, status: int | None = None): + super().__init__(code) + self.code = code + self.status = status + + +def urllib_transport(method: str, url: str, headers: dict, body: bytes | None, timeout: float): + """Default transport: returns (status, body bytes); never raises on HTTP errors.""" + request = urllib.request.Request(url, data=body, method=method, headers=headers) + try: + with urllib.request.urlopen(request, timeout=timeout) as response: + return response.status, response.read() + except urllib.error.HTTPError as error: + return error.code, error.read() + + +def credentials_from_env() -> tuple[str, str]: + return ( + os.environ.get("HIGGSFIELD_API_KEY_ID", "").strip(), + os.environ.get("HIGGSFIELD_API_KEY_SECRET", "").strip(), + ) + + +def configured() -> bool: + key_id, secret = credentials_from_env() + return bool(key_id and secret) + + +class HiggsfieldProvider: + name = "higgsfield" + accepts_user_key = False # always runs on the server's Higgsfield account + + def __init__( + self, + key_id: str, + key_secret: str, + *, + base_url: str = DEFAULT_BASE_URL, + endpoint: str = DEFAULT_IMAGE_ENDPOINT, + transport: Callable = urllib_transport, + sleep: Callable[[float], None] = time.sleep, + clock: Callable[[], float] = time.monotonic, + timeout_seconds: float = 300, + ): + if not key_id or not key_secret: + raise ValueError("Higgsfield key ID and secret are required") + self._auth = f"Key {key_id}:{key_secret}" + self._base = base_url.rstrip("/") + self._endpoint = endpoint.strip("/") + self._transport = transport + self._sleep = sleep + self._clock = clock + self._timeout = timeout_seconds + self._uploads: dict[str, str] = {} + + @classmethod + def from_env(cls, **kwargs) -> "HiggsfieldProvider": + key_id, secret = credentials_from_env() + return cls( + key_id, + secret, + base_url=os.environ.get("HIGGSFIELD_API_BASE", DEFAULT_BASE_URL), + endpoint=os.environ.get("HIGGSFIELD_IMAGE_ENDPOINT", DEFAULT_IMAGE_ENDPOINT), + **kwargs, + ) + + # ── HTTP ──────────────────────────────────────────────────────────── + + def _api(self, method: str, url: str, payload: dict | None = None, + extra_headers: dict | None = None) -> dict: + headers = {"Authorization": self._auth, "Accept": "application/json"} + body = None + if payload is not None: + headers["Content-Type"] = "application/json" + body = json.dumps(payload).encode() + headers.update(extra_headers or {}) + try: + status, raw = self._transport(method, url, headers, body, 30) + except OSError as error: + raise HiggsfieldError("network_error") from error + if status >= 400: + raise HiggsfieldError(_status_code(status), status) + try: + return json.loads(raw or b"{}") + except ValueError as error: + raise HiggsfieldError("bad_response", status) from error + + def _upload_product(self, product_path: Path) -> str: + png = _as_png(product_path) + digest = hashlib.sha256(png).hexdigest() + if digest in self._uploads: + return self._uploads[digest] + ticket = self._api("POST", f"{self._base}/files/generate-upload-url", + {"content_type": "image/png"}) + upload_url, public_url = ticket.get("upload_url"), ticket.get("public_url") + if not upload_url or not public_url: + raise HiggsfieldError("bad_response") + # Presigned storage URL: send only the returned headers, never our credentials. + headers = dict(ticket.get("upload_headers") or {"Content-Type": "image/png"}) + try: + status, _ = self._transport("PUT", upload_url, headers, png, 60) + except OSError as error: + raise HiggsfieldError("upload_failed") from error + if status >= 300: + raise HiggsfieldError("upload_failed", status) + self._uploads[digest] = public_url + return public_url + + def _submit(self, payload: dict) -> str: + # Same Idempotency-Key on retry, so a lost response never double-charges. + idempotency = {"Idempotency-Key": str(uuid.uuid4())} + url = f"{self._base}/{self._endpoint}" + for attempt in range(3): + try: + receipt = self._api("POST", url, payload, idempotency) + break + except HiggsfieldError as error: + retryable = error.code in ("network_error", "provider_error") + if not retryable or attempt == 2: + raise + self._sleep(2 ** attempt) + status_url = receipt.get("status_url") + if not status_url and receipt.get("request_id"): + status_url = f"{self._base}/requests/{receipt['request_id']}/status" + if not status_url: + raise HiggsfieldError("bad_response") + return status_url + + def _wait(self, status_url: str) -> dict: + deadline = self._clock() + self._timeout + delay = 2.0 + while True: + try: + result = self._api("GET", status_url) + except HiggsfieldError as error: + if error.code not in ("network_error", "provider_error"): + raise + result = {} + if result.get("status") in TERMINAL: + return result + if self._clock() >= deadline: + raise HiggsfieldError("timeout") + self._sleep(delay + random.uniform(0, 0.5)) + delay = min(delay * 1.5, 10.0) + + def _download(self, url: str, destination: Path) -> None: + if not url.startswith("https://"): + raise HiggsfieldError("bad_response") + try: + status, raw = self._transport("GET", url, {}, None, 60) + except OSError as error: + raise HiggsfieldError("download_failed") from error + if status >= 300 or not raw: + raise HiggsfieldError("download_failed", status) + destination.write_bytes(raw) + + # ── ImageProvider ────────────────────────────────────────────────── + + def render(self, request: ShotRequest, api_key: str = "") -> dict: + resolution, quality = TIER.get(request.tier, TIER["balanced"]) + try: + image_url = self._upload_product(request.product_path) + status_url = self._submit({ + "prompt": request.prompt[:5000], + "image_urls": [image_url], + "aspect_ratio": ASPECT.get(request.aspect, "1:1"), + "resolution": resolution, + "quality": quality, + "enhance_prompt": False, + }) + result = self._wait(status_url) + if result.get("status") != "completed": + code = "moderated" if result.get("status") == "nsfw" else "provider_failed" + return failure("The image service couldn't make this shot", code) + images = result.get("images") or [] + if not images or not images[0].get("url"): + return failure("The image service returned no image", "no_output") + with tempfile.TemporaryDirectory() as scratch: + raw_path = Path(scratch) / "raw" + self._download(images[0]["url"], raw_path) + save_at_aspect(raw_path, request.output_path, request.aspect) + except HiggsfieldError as error: + return failure("The image service couldn't make this shot", error.code) + except (OSError, ValueError): + return failure("Generated image could not be read", "bad_output") + return {"path": str(request.output_path), "model": f"higgsfield/{self._endpoint}"} + + +def _status_code(status: int) -> str: + return { + 400: "rejected", + 401: "auth_failed", + 403: "provider_out_of_credits", + 404: "model_unavailable", + 422: "rejected", + 423: "model_unavailable", + 429: "rate_limited", + 503: "model_unavailable", + }.get(status, "provider_error") + + +def _as_png(path: Path) -> bytes: + """Re-encode as PNG: one upload content type, and EXIF/GPS metadata is dropped.""" + from PIL import Image + + with Image.open(path) as image: + if image.mode not in ("RGB", "RGBA"): + image = image.convert("RGBA") + buffer = BytesIO() + image.save(buffer, "PNG") + return buffer.getvalue() diff --git a/docs/screenshots/shoot-customise-desktop.png b/docs/screenshots/shoot-customise-desktop.png new file mode 100644 index 0000000..ea40520 Binary files /dev/null and b/docs/screenshots/shoot-customise-desktop.png differ diff --git a/docs/screenshots/shoot-done-desktop.png b/docs/screenshots/shoot-done-desktop.png new file mode 100644 index 0000000..06dfa1c Binary files /dev/null and b/docs/screenshots/shoot-done-desktop.png differ diff --git a/docs/screenshots/shoot-done-phone.png b/docs/screenshots/shoot-done-phone.png new file mode 100644 index 0000000..fc16a13 Binary files /dev/null and b/docs/screenshots/shoot-done-phone.png differ diff --git a/docs/screenshots/shoot-empty-desktop.png b/docs/screenshots/shoot-empty-desktop.png new file mode 100644 index 0000000..9cb8347 Binary files /dev/null and b/docs/screenshots/shoot-empty-desktop.png differ diff --git a/docs/screenshots/shoot-empty-phone.png b/docs/screenshots/shoot-empty-phone.png new file mode 100644 index 0000000..0cc7821 Binary files /dev/null and b/docs/screenshots/shoot-empty-phone.png differ diff --git a/docs/screenshots/shoot-out-of-credits-phone.png b/docs/screenshots/shoot-out-of-credits-phone.png new file mode 100644 index 0000000..72a1446 Binary files /dev/null and b/docs/screenshots/shoot-out-of-credits-phone.png differ diff --git a/docs/screenshots/shoot-progress-desktop.png b/docs/screenshots/shoot-progress-desktop.png new file mode 100644 index 0000000..4e057c7 Binary files /dev/null and b/docs/screenshots/shoot-progress-desktop.png differ diff --git a/docs/screenshots/shoot-progress-phone.png b/docs/screenshots/shoot-progress-phone.png new file mode 100644 index 0000000..1d52829 Binary files /dev/null and b/docs/screenshots/shoot-progress-phone.png differ diff --git a/docs/screenshots/shoot-ready-desktop.png b/docs/screenshots/shoot-ready-desktop.png new file mode 100644 index 0000000..36907fa Binary files /dev/null and b/docs/screenshots/shoot-ready-desktop.png differ diff --git a/docs/screenshots/shoot-ready-phone.png b/docs/screenshots/shoot-ready-phone.png new file mode 100644 index 0000000..498af1c Binary files /dev/null and b/docs/screenshots/shoot-ready-phone.png differ diff --git a/docs/screenshots/shoot-signed-out-desktop.png b/docs/screenshots/shoot-signed-out-desktop.png new file mode 100644 index 0000000..139ed46 Binary files /dev/null and b/docs/screenshots/shoot-signed-out-desktop.png differ diff --git a/docs/screenshots/shoot-signed-out-phone.png b/docs/screenshots/shoot-signed-out-phone.png new file mode 100644 index 0000000..7ce0e3b Binary files /dev/null and b/docs/screenshots/shoot-signed-out-phone.png differ diff --git a/scripts/creative-studio-web.py b/scripts/creative-studio-web.py index 3f93ab2..0219be1 100644 --- a/scripts/creative-studio-web.py +++ b/scripts/creative-studio-web.py @@ -65,6 +65,9 @@ from creative_studio_app.delivery_routes import create_blueprint as _create_delivery_blueprint from creative_studio_app.generation_routes import create_blueprint as _create_generation_blueprint from creative_studio_app.iteration_routes import create_blueprint as _create_iteration_blueprint +from creative_studio_app import photoshoot as _photoshoot +from creative_studio_app.photoshoot_routes import create_blueprint as _create_photoshoot_blueprint +from creative_studio_app.providers import select_provider as _select_image_provider from creative_studio_app.jobs import ( DurableJobStore, evict_old_jobs as _evict_jobs, @@ -1916,6 +1919,38 @@ def _fetch_owner_figma(owner_id, file_key, node_id): refund_credits=_refund_credits, ) ) +_pack_store = _photoshoot.PackStore(DATA_DIR) + + +def _select_photoshoot_provider(): + return _select_image_provider(lambda *args, **kwargs: run_cli_composite(*args, **kwargs)) + + +def _spawn_photoshoot(work): + threading.Thread(target=work, daemon=True, name="photoshoot-pack").start() + + +app.register_blueprint( + _create_photoshoot_blueprint( + store=_pack_store, + select_provider=lambda: _select_photoshoot_provider(), + require_api_key=_require_api_key, + require_access=_require_access, + current_session=_current_session, + current_actor_id=_current_actor_id, + spend_credits=_spend_credits, + auth_db=_auth_db, + cost_db=lambda: COST_DB, + cost_lock=_json_lock, + save_upload=_save_upload, + enforce_daily_limit=enforce_daily_limit, + get_output_dir=lambda: OUTPUT_DIR, + to_image_url=image_url, + rate_limited=rate_limited, + add_entry=add_entry, + spawn=lambda work: _spawn_photoshoot(work), + ) +) app.register_blueprint( _create_account_blueprint( create_magic_link_token=_create_magic_link_token, diff --git a/static/campaigns-fidelity.css b/static/campaigns-fidelity.css index 48b2baf..be2ffc7 100644 --- a/static/campaigns-fidelity.css +++ b/static/campaigns-fidelity.css @@ -48,3 +48,6 @@ .exception-row label { margin-top: 6px; } + +/* Phone: let the nav wrap so the Photoshoot link never causes horizontal overflow. */ +@media (max-width:560px){.topbar{height:auto;min-height:68px;flex-wrap:wrap;row-gap:6px;padding-top:10px;padding-bottom:10px}.topbar nav{flex-wrap:wrap;gap:4px 12px}} diff --git a/static/photogen/Archivo-OFL.txt b/static/photogen/Archivo-OFL.txt new file mode 100644 index 0000000..8597481 --- /dev/null +++ b/static/photogen/Archivo-OFL.txt @@ -0,0 +1,93 @@ +Copyright 2020 The Archivo Project Authors (https://github.com/Omnibus-Type/Archivo) + +This Font Software is licensed under the SIL Open Font License, Version 1.1. +This license is copied below, and is also available with a FAQ at: +http://scripts.sil.org/OFL + + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/static/photogen/apple-touch-icon.png b/static/photogen/apple-touch-icon.png new file mode 100644 index 0000000..b74c8d3 Binary files /dev/null and b/static/photogen/apple-touch-icon.png differ diff --git a/static/photogen/archivo-latin-variable.woff2 b/static/photogen/archivo-latin-variable.woff2 new file mode 100644 index 0000000..e2448d0 Binary files /dev/null and b/static/photogen/archivo-latin-variable.woff2 differ diff --git a/static/photogen/brand-passport.svg b/static/photogen/brand-passport.svg new file mode 100644 index 0000000..0d7010e --- /dev/null +++ b/static/photogen/brand-passport.svg @@ -0,0 +1,7 @@ + + + + + + + \ No newline at end of file diff --git a/static/photogen/channel-email-16x9.webp b/static/photogen/channel-email-16x9.webp new file mode 100644 index 0000000..e1e5f45 Binary files /dev/null and b/static/photogen/channel-email-16x9.webp differ diff --git a/static/photogen/channels.svg b/static/photogen/channels.svg new file mode 100644 index 0000000..98105fc --- /dev/null +++ b/static/photogen/channels.svg @@ -0,0 +1,7 @@ + + + + + + + \ No newline at end of file diff --git a/static/photogen/favicon-32.png b/static/photogen/favicon-32.png new file mode 100644 index 0000000..108dc4f Binary files /dev/null and b/static/photogen/favicon-32.png differ diff --git a/static/photogen/go.svg b/static/photogen/go.svg new file mode 100644 index 0000000..a06f2e8 --- /dev/null +++ b/static/photogen/go.svg @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/static/photogen/icon-192.png b/static/photogen/icon-192.png new file mode 100644 index 0000000..dadbdc4 Binary files /dev/null and b/static/photogen/icon-192.png differ diff --git a/static/photogen/icon-512.png b/static/photogen/icon-512.png new file mode 100644 index 0000000..edd0548 Binary files /dev/null and b/static/photogen/icon-512.png differ diff --git a/static/photogen/logo-a-aperture-go.svg b/static/photogen/logo-a-aperture-go.svg new file mode 100644 index 0000000..f2d7800 --- /dev/null +++ b/static/photogen/logo-a-aperture-go.svg @@ -0,0 +1,11 @@ + + + + + + + + + + + \ No newline at end of file diff --git a/static/photogen/product-truth.svg b/static/photogen/product-truth.svg new file mode 100644 index 0000000..2e802f2 --- /dev/null +++ b/static/photogen/product-truth.svg @@ -0,0 +1,12 @@ + + + + + + + + + + + + \ No newline at end of file diff --git a/static/photogen/review.svg b/static/photogen/review.svg new file mode 100644 index 0000000..4df2a02 --- /dev/null +++ b/static/photogen/review.svg @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/static/photogen/work-order.svg b/static/photogen/work-order.svg new file mode 100644 index 0000000..670d0d1 --- /dev/null +++ b/static/photogen/work-order.svg @@ -0,0 +1,10 @@ + + + + + + + + + + \ No newline at end of file diff --git a/static/photogen/yuzu-can-studio.webp b/static/photogen/yuzu-can-studio.webp new file mode 100644 index 0000000..3f924f0 Binary files /dev/null and b/static/photogen/yuzu-can-studio.webp differ diff --git a/static/shoot.css b/static/shoot.css new file mode 100644 index 0000000..c3394dc --- /dev/null +++ b/static/shoot.css @@ -0,0 +1,198 @@ +/* The studio in a button. Keep the page quiet; put colour behind the product. */ +@font-face { + font-family: 'Archivo'; font-style: normal; font-weight: 400 800; + font-stretch: 100% 125%; font-display: swap; + src: url('/static/photogen/archivo-latin-variable.woff2') format('woff2'); +} +:root { + --text: #16181D; --muted: #595E69; --accent: #2340FF; --line: #E4E6EA; + --mist: #F3F4F6; --butter: #FFE07A; --coral: #FF8A6B; --mint: #8EE0C1; + color-scheme: light; +} +*, *::before, *::after { box-sizing: border-box; } +[hidden] { display: none !important; } +html { -webkit-text-size-adjust: 100%; scroll-padding-top: 24px; } +body { margin: 0; background: #FFF; color: var(--text); font: 15px/1.5 'Archivo', Arial, sans-serif; font-variation-settings: 'wdth' 100; -webkit-font-smoothing: antialiased; } +a { color: var(--accent); text-underline-offset: 3px; } +button, input, textarea { font: inherit; color: inherit; } +img { display: block; max-width: 100%; } +button { cursor: pointer; } +:focus-visible { outline: 3px solid var(--accent); outline-offset: 4px; } +.visually-hidden { position: absolute; width: 1px; height: 1px; margin: -1px; padding: 0; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; border: 0; } +.skip { position: absolute; left: 16px; top: -80px; z-index: 10; background: #FFF; padding: 14px 20px; border: 1px solid var(--line); border-radius: 24px; } +.skip:focus { top: 12px; } +.topbar { display: flex; align-items: center; gap: 32px; max-width: 1320px; margin: auto; padding: 16px 36px; border-bottom: 1px solid var(--line); } +.brand { display: inline-flex; align-items: center; gap: 9px; text-decoration: none; color: var(--text); } +.brand-mark { width: 36px; height: 36px; object-fit: contain; } +.brand-name { font-size: 23px; font-weight: 800; font-variation-settings: 'wdth' 118; letter-spacing: -.04em; } +.topnav { display: flex; gap: 8px; } +.topnav a { display: inline-flex; align-items: center; min-height: 48px; padding: 0 14px; text-decoration: none; color: var(--muted); border-radius: 24px; font-size: 14px; } +.topnav a:hover { background: var(--mist); } +.topnav a[aria-current] { color: var(--text); background: var(--mist); font-weight: 500; } +.account { margin-left: auto; } +.account-link { display: inline-flex; align-items: center; gap: 8px; min-height: 48px; padding: 0 18px; border: 1px solid var(--line); border-radius: 24px; text-decoration: none; color: var(--text); font-size: 14px; font-weight: 500; white-space: nowrap; } +.credit-dot { width: 7px; height: 7px; background: var(--accent); border-radius: 50%; } +.studio { max-width: 1320px; padding: 28px 36px 0; margin: auto; } +.intro { margin: 0 0 28px; } +.intro h1 { font-size: clamp(30px, 3vw, 40px); line-height: 1.12; font-weight: 800; font-variation-settings: 'wdth' 118; letter-spacing: -.02em; margin: 0 0 12px; } +.intro p { margin: 0; color: var(--muted); font-size: 16px; } +.layout { display: grid; grid-template-columns: minmax(0, 390px) minmax(0, 1fr); gap: 48px; align-items: start; } +.setup { min-width: 0; } +.step { border: 0; min-width: 0; padding: 0; margin: 0 0 22px; } +.step-title { display: flex; gap: 9px; align-items: center; font-size: 16px; font-weight: 500; margin: 0 0 12px; padding: 0; } +.step-num { display: grid; place-items: center; width: 24px; height: 24px; border: 1px solid var(--line); border-radius: 50%; font-size: 12px; } +.optional, .hint { color: var(--muted); font-size: 12px; } +.hint { margin: 10px 0 0; line-height: 1.5; } +.drop { display: block; position: relative; cursor: pointer; border-radius: 20px; border: 1px dashed #9196A1; background: var(--butter); overflow: hidden; transition: background .2s; } +.drop:hover, .drop.dragging { outline: 2px solid var(--accent); outline-offset: 3px; } +.drop:has(input:focus-visible) { outline: 3px solid var(--accent); outline-offset: 4px; } +.drop-empty { display: flex; flex-direction: column; justify-content: center; align-items: center; gap: 5px; min-height: 174px; padding: 20px; text-align: center; } +.drop-icon { width: 40px; height: 40px; margin-bottom: 10px; } +.drop-title { font-weight: 500; font-size: 16px; } +.drop-sub { font-size: 13px; } +.drop-link { color: inherit; text-decoration: underline; text-underline-offset: 3px; } +.drop-filled { display: flex; flex-direction: column; align-items: center; justify-content: center; position: relative; min-height: 174px; padding: 12px; gap: 8px; } +.drop-filled img { height: 120px; width: 180px; object-fit: contain; border-radius: 12px; } +.drop-file { display: flex; justify-content: center; align-items: baseline; gap: 10px; width: 100%; min-width: 0; font-size: 12px; } +.drop-file-name { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; max-width: 60%; } +.drop-change { text-decoration: underline; text-underline-offset: 3px; font-weight: 500; } +.vibes { display: grid; grid-template-columns: repeat(5, minmax(0, 1fr)); gap: 8px; } +.vibe { position: relative; cursor: pointer; min-width: 0; } +.vibe input, .seg input, .pick input { position: absolute; opacity: 0; inset: 0; width: 100%; height: 100%; margin: 0; z-index: 1; cursor: pointer; } +.vibe-swatch { display: block; position: relative; height: 64px; background: var(--butter); border: 2px solid transparent; border-radius: 12px; } +.vibe-swatch[data-vibe="sunlit"] { background: #F4DEC0; } +.vibe-swatch[data-vibe="bold"] { background: var(--coral); } +.vibe-swatch[data-vibe="luxe"] { background: #41424B; } +.vibe-swatch[data-vibe="fresh"] { background: var(--mint); } +.vibe-swatch > span { position: absolute; width: 25%; height: 46%; bottom: 15%; left: 38%; border-radius: 5px 5px 3px 3px; background: #FFF; box-shadow: 3px 4px 0 rgba(22,24,29,.14); } +.vibe-name { display: block; font-size: 12px; line-height: 1.3; min-height: 40px; font-weight: 500; padding-top: 8px; } +.vibe input:checked + .vibe-swatch { border-color: var(--accent); outline: 2px solid var(--accent); outline-offset: 2px; } +.vibe input:checked + .vibe-swatch::after { content: '✓'; position: absolute; right: -4px; top: -5px; display: grid; place-items: center; width: 20px; height: 20px; border-radius: 50%; background: var(--accent); color: #FFF; font-size: 12px; } +.vibe input:focus-visible + .vibe-swatch, .seg input:focus-visible + span, .pick input:focus-visible + span { outline: 3px solid var(--accent); outline-offset: 4px; } +.advanced { border-top: 1px solid var(--line); border-bottom: 1px solid var(--line); margin-bottom: 20px; } +.advanced summary { display: flex; align-items: center; gap: 12px; min-height: 52px; font-weight: 500; cursor: pointer; list-style: none; } +.advanced summary::-webkit-details-marker { display: none; } +.advanced summary::after { content: '+'; font-size: 22px; font-weight: 400; margin-left: auto; } +.advanced[open] summary::after { content: '−'; } +.advanced-body { display: grid; gap: 18px; padding: 4px 0 20px; } +.adv-group { min-width: 0; padding: 0; margin: 0; border: 0; } +.adv-group legend, .adv-label { display: block; font-weight: 500; font-size: 13px; margin-bottom: 8px; padding: 0; } +.segmented { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 8px; } +.seg, .pick { position: relative; cursor: pointer; } +.seg span, .pick span { display: flex; flex-direction: column; justify-content: center; min-height: 52px; border: 1px solid #9196A1; padding: 8px 10px; border-radius: 16px; font-size: 13px; font-weight: 500; } +.seg small, .pick small { color: var(--muted); font-weight: 400; font-size: 10px; } +.seg input:checked + span, .pick input:checked + span { border-color: var(--accent); background: #F0F2FF; box-shadow: inset 0 0 0 1px var(--accent); } +.shot-picks { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 8px; } +.pick span { flex-direction: row; align-items: center; justify-content: space-between; gap: 8px; min-height: 48px; } +.pick input:not(:checked) + span { border-style: dashed; color: var(--muted); } +textarea { width: 100%; min-height: 76px; resize: vertical; padding: 12px; border: 1px solid #9196A1; border-radius: 12px; background: #FFF; font-size: 13px; } +textarea::placeholder { color: var(--muted); } +.go { display: grid; grid-template-columns: 124px minmax(0, 1fr); align-items: center; gap: 16px; padding-bottom: 8px; } +.go-btn { display: grid; place-items: center; position: relative; width: 124px; height: 124px; border: 0; border-radius: 50%; background: var(--accent); color: #FFF; transition: transform .16s; } +.go-btn:hover { background: #1933E0; } +.go-btn:active { transform: scale(.96); } +.go-face { font-size: 40px; line-height: 1; letter-spacing: -.03em; font-weight: 800; font-variation-settings: 'wdth' 118; } +.go-btn[aria-disabled="true"] { cursor: not-allowed; } +.go-btn.ready { animation: ready-pulse .65s ease-out 1; } +@keyframes ready-pulse { 50% { box-shadow: 0 0 0 8px #2340FF16; } } +.go-label { font-size: 15px; font-weight: 500; margin: 0 0 6px; } +.cost { margin: 0; font-size: 13px; line-height: 1.6; } +.cost strong { font-weight: 500; } +.cost span { display: block; color: var(--muted); } +.balance { font-size: 12px; color: var(--muted); margin: 6px 0 0; } +.notice { grid-column: 1 / -1; padding: 12px 14px; border-radius: 12px; border: 1px solid var(--line); background: var(--mist); font-size: 13px; line-height: 1.6; } +.notice a { display: flex; align-items: center; width: fit-content; min-height: 48px; font-weight: 500; } +.notice.error { border-color: #BA382B; background: #FFF2F0; } +.spinner { display: none; position: absolute; inset: 22%; border-radius: 50%; border: 3px solid rgba(255,255,255,.28); border-top-color: #FFFFFF; animation: spin 1s linear infinite; } +.go-btn.busy .spinner { display: block; } +.go-btn.busy .go-face { opacity: 0; } +@keyframes spin { to { transform: rotate(360deg); } } +.results { min-width: 0; border-left: 1px solid var(--line); padding-left: 32px; } +.results-head { display: flex; align-items: center; justify-content: space-between; gap: 16px; flex-wrap: wrap; margin-bottom: 20px; } +.results-head h2 { font-size: 22px; line-height: 1.25; letter-spacing: -.02em; font-weight: 800; font-variation-settings: 'wdth' 118; margin: 0; } +.results-sub { margin: 6px 0 0; color: var(--muted); font-size: 13px; } +.results-actions { display: flex; gap: 8px; flex-wrap: wrap; } +.btn-primary, .btn-quiet, .tile-btn { display: inline-flex; align-items: center; justify-content: center; min-height: 48px; padding: 0 20px; border-radius: 24px; font-size: 13px; font-weight: 500; text-decoration: none; } +.btn-primary { color: #FFF; background: var(--accent); border: 1px solid var(--accent); } +.btn-quiet, .tile-btn { color: var(--text); background: #FFF; border: 1px solid var(--line); } +.btn-quiet:hover, .tile-btn:hover { border-color: var(--accent); } +.progress { display: grid; grid-template-columns: 1fr auto; gap: 8px; margin-bottom: 18px; } +.progress-bar { grid-column: 1 / -1; height: 6px; border-radius: 6px; overflow: hidden; background: var(--mist); } +.progress-bar span { height: 100%; display: block; background: var(--accent); border-radius: 6px; transition: width .3s; } +.progress-text { margin: 0; font-size: 13px; } +.elapsed { font-size: 12px; color: var(--muted); white-space: nowrap; } +.pack { display: grid; grid-template-columns: repeat(12, minmax(0, 1fr)); align-items: start; gap: 22px 16px; list-style: none; padding: 0; margin: 0; } +.tile { grid-column: span 3; min-width: 0; } +.tile[data-shot="hero"] { grid-column: span 8; } +.tile[data-shot="lifestyle"] { grid-column: span 4; } +.frame { width: 100%; position: relative; overflow: hidden; border-radius: 20px; background: var(--mist); border: 1px solid var(--line); } +.tile[data-state="idle"] .frame { border-style: dashed; } +.frame-ratio { position: absolute; inset: 0; display: grid; place-items: center; color: var(--muted); font-size: 14px; font-weight: 500; } +.frame img { width: 100%; height: 100%; object-fit: cover; animation: develop .45s ease-out; } +.tile[data-shot="cutout"] .frame img { object-fit: contain; background: #FFF; } +.tile-open { position: absolute; inset: 0; } +.tile[data-state="running"] .frame { background: linear-gradient(100deg, #F3F4F6 25%, #FFF 50%, #F3F4F6 75%) 0 0 / 250% 100%; animation: shimmer 2.5s ease-in-out infinite; } +@keyframes shimmer { from { background-position: 100% 0; } to { background-position: 0 0; } } +@keyframes develop { from { opacity: 0; } to { opacity: 1; } } +.tile-state { position: absolute; left: 10px; bottom: 10px; padding: 4px 8px; background: #FFF; color: var(--muted); border-radius: 16px; font-size: 11px; } +.tile-error { position: absolute; inset: 0; display: grid; place-items: center; padding: 16px; text-align: center; font-size: 12px; } +.tile[data-state="failed"] .frame { background: #FFF2F0; } +.tile-meta { padding: 10px 2px 0; } +.tile-name { display: flex; justify-content: space-between; gap: 5px; font-weight: 500; font-size: 14px; line-height: 1.35; } +.tile-aspect { color: var(--muted); font-size: 12px; font-weight: 400; white-space: nowrap; padding-top: 1px; } +.tile-use { display: block; color: var(--muted); font-size: 12px; line-height: 1.4; margin-top: 4px; } +.tile-actions { margin-top: 10px; } +.tile-btn { min-height: 48px; font-size: 12px; padding: 0 15px; } +.label-check { display: flex; align-items: center; gap: 10px; font-size: 12px; color: var(--muted); background: var(--mist); padding: 14px; border-radius: 12px; margin: 22px 0 0; } +.studio-footer { display: flex; align-items: center; gap: 20px; border-top: 1px solid var(--line); margin-top: 42px; font-size: 12px; color: var(--muted); padding: 12px 0; } +.studio-footer span { margin-right: auto; } +.studio-footer a { display: inline-flex; min-height: 48px; align-items: center; } +@media (max-width: 1100px) { + .layout { grid-template-columns: minmax(0, 340px) minmax(0, 1fr); gap: 24px; } + .results { padding-left: 24px; } + .pack { grid-template-columns: repeat(6, minmax(0, 1fr)); } + .tile { grid-column: span 2; } + .tile[data-shot="hero"] { grid-column: span 6; } + .tile[data-shot="lifestyle"] { grid-column: span 2; } +} +@media (max-width: 900px) { + .topbar { padding: 12px 24px; gap: 20px; flex-wrap: wrap; } + .topnav { order: 3; width: 100%; } + .topnav a { padding: 0 14px; } + .studio { max-width: 700px; padding: 28px 24px 0; } + .intro h1 { font-size: 34px; } + .layout { grid-template-columns: minmax(0, 1fr); gap: 40px; } + .results { padding: 28px 0 0; border-left: 0; border-top: 1px solid var(--line); } + .vibe-swatch { height: 76px; } + .vibe-name { font-size: 12px; } +} +@media (max-width: 480px) { + .topbar { padding: 10px 20px 8px; gap: 6px; } + .brand-name { font-size: 21px; } + .brand-mark { width: 32px; height: 32px; } + .account-link { font-size: 12px; padding: 0 14px; } + .topnav { gap: 2px; justify-content: space-between; } + .topnav a { font-size: 12px; padding: 0 10px; } + .studio { padding: 24px 20px 0; } + .intro { margin-bottom: 26px; } + .intro h1 { font-size: 30px; line-height: 1.14; } + .intro p { font-size: 14px; max-width: 34ch; } + .vibes { gap: 7px; } + .vibe-swatch { height: 62px; } + .vibe-name { font-size: 11px; } + .go { grid-template-columns: 112px minmax(0, 1fr); gap: 18px; } + .go-btn { width: 112px; height: 112px; } + .go-label { font-size: 14px; } + .pack { grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 22px 12px; } + .tile, .tile[data-shot="lifestyle"] { grid-column: span 1; } + .tile[data-shot="hero"] { grid-column: span 2; } + .tile-name { font-size: 13px; } + .optional { font-size: 11px; } + .progress { grid-template-columns: 1fr; } + .elapsed { grid-row: 2; justify-self: end; } + .progress-text { grid-column: 1; } + .studio-footer { gap: 14px; } +} +@media (prefers-reduced-motion: reduce) { + *, *::before, *::after { animation: none !important; transition: none !important; scroll-behavior: auto !important; } +} diff --git a/static/shoot.js b/static/shoot.js new file mode 100644 index 0000000..344465d --- /dev/null +++ b/static/shoot.js @@ -0,0 +1,411 @@ +/* Photoshoot page: upload -> vibe -> Go -> live pack -> download. */ +(() => { + 'use strict'; + + const options = JSON.parse(document.getElementById('shootOptions').textContent); + const $ = (id) => document.getElementById(id); + const form = $('setup'); + const photo = $('photo'); + const drop = $('drop'); + const goBtn = $('goBtn'); + const goLabel = $('goLabel'); + const notice = $('notice'); + const PACK_KEY = 'photogen_pack'; + const MAX_BYTES = 16 * 1024 * 1024; + const TYPES = ['image/png', 'image/jpeg', 'image/webp']; + const ACTIVE = ['queued', 'running']; + + const state = { file: null, me: null, pack: null, timer: null, elapsedTimer: null, starting: false, block: null, loadingAccount: Boolean(localStorage.getItem('photogen_session')), ready: false }; + + const sessionToken = () => localStorage.getItem('photogen_session') || ''; + const ownKey = () => localStorage.getItem('cs_api_key') || ''; + const usesOwnKey = () => options.provider === 'gemini' && options.accepts_user_key === true && Boolean(ownKey()); + const reducedMotion = () => window.matchMedia('(prefers-reduced-motion: reduce)').matches; + const scrollTo = (element) => element.scrollIntoView({ behavior: reducedMotion() ? 'auto' : 'smooth', block: 'start' }); + const authHeaders = () => { + const headers = {}; + if (sessionToken()) headers['X-Session-Token'] = sessionToken(); + if (usesOwnKey()) headers['X-API-Key'] = ownKey(); + return headers; + }; + const plural = (count, word) => `${count} ${word}${count === 1 ? '' : 's'}`; + const escapeHtml = (text) => String(text ?? '').replace(/[&<>"']/g, (c) => ( + { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c])); + + // ── Account & credits ──────────────────────────────────────────────── + async function loadMe() { + if (!sessionToken()) { state.me = null; state.loadingAccount = false; renderAccount(); refresh(); return; } + try { + const response = await fetch('/api/me', { headers: authHeaders() }); + state.me = response.ok ? await response.json() : null; + } catch (_) { /* offline: keep the last known balance */ } + state.loadingAccount = false; + renderAccount(); + refresh(); + } + + function renderAccount() { + const link = $('accountLink'); + if (state.me) { + const credits = state.me.credits_remaining; + link.href = '/billing'; + link.innerHTML = `${escapeHtml(plural(credits, 'credit'))}`; + link.setAttribute('aria-label', `${plural(credits, 'credit')} left. Manage plan`); + } else { + link.href = '/login'; + link.textContent = 'Sign in'; + link.removeAttribute('aria-label'); + } + } + + // ── Selection & cost ───────────────────────────────────────────────── + const selectedShots = () => [...form.querySelectorAll('input[name="shots"]:checked')].map((box) => box.value); + const selectedTier = () => (form.querySelector('input[name="tier"]:checked') || {}).value || options.default_tier; + const selectedVibe = () => (form.querySelector('input[name="vibe"]:checked') || {}).value || options.default_vibe; + const packCost = () => selectedShots().length * (options.tiers[selectedTier()] || 1); + + function refresh() { + const shots = selectedShots(); + const cost = packCost(); + const minutes = Math.max(1, Math.ceil((Math.ceil(shots.length / 3) * 50) / 60)); + $('costCredits').textContent = usesOwnKey() ? 'Uses your Gemini key' : plural(cost, 'credit'); + $('costDetail').textContent = `${plural(shots.length, 'image')}, about ${plural(minutes, 'minute')}`; + + const vibe = options.vibes.find((item) => item.id === selectedVibe()) || options.vibes[0]; + $('vibeHint').textContent = vibe.blurb; + const backdrops = { clean: '#FFE07A', sunlit: '#F4DEC0', bold: '#FF8A6B', luxe: '#41424B', fresh: '#8EE0C1' }; + drop.style.background = backdrops[vibe.id]; + drop.style.color = vibe.id === 'luxe' ? '#FFFFFF' : '#16181D'; + + const balance = $('balance'); + if (state.me) { + balance.hidden = false; + balance.textContent = `You have ${plural(state.me.credits_remaining, 'credit')}.`; + } else { + balance.hidden = true; + } + + if (!state.pack) { + // Before a shoot, the results panel previews exactly the shots that will be made. + document.querySelectorAll('.tile').forEach((tile) => { tile.hidden = !shots.includes(tile.dataset.shot); }); + } + updateGo(); + } + + function updateGo() { + const running = state.starting || (state.pack && ACTIVE.includes(state.pack.status)); + const shots = selectedShots(); + const cost = packCost(); + let block = null; + if (state.loadingAccount && !usesOwnKey()) { + block = { kind: 'loading', html: 'Checking your balance…' }; + } else if (!state.me && !usesOwnKey()) { + block = { kind: 'signin', html: 'Get 5 trial credits when you sign up. Start with five shots or add credits for the full pack.' }; + } else if (!shots.length) { + block = { kind: 'shots', html: 'Choose at least one shot under Customise.' }; + } else if (state.me && state.me.credits_remaining === 0 && !usesOwnKey()) { + block = { kind: 'credits', html: 'You\'re out of credits. Add credits to keep shooting. See plans' }; + } else if (state.me && state.me.credits_remaining < cost && !usesOwnKey()) { + block = { + kind: 'credits', + html: `This pack needs ${plural(cost, 'credit')} and you have ${state.me.credits_remaining}. Choose fewer shots under Customise, or add credits. See plans`, + }; + } else if (!state.file) { + block = { kind: 'photo', html: '' }; + } + state.block = block; + + goBtn.classList.toggle('busy', Boolean(running)); + if (running) { + goLabel.textContent = state.starting ? 'Starting…' : 'Shooting your pack…'; + goBtn.setAttribute('aria-label', goLabel.textContent); + goBtn.setAttribute('aria-disabled', 'true'); + } else if (block && block.kind === 'signin') { + goLabel.textContent = 'Sign up free to start'; + goBtn.setAttribute('aria-label', 'Sign up free'); + goBtn.removeAttribute('aria-disabled'); + } else { + goLabel.textContent = block?.kind === 'photo' ? 'Add a photo, then press Go' : block?.kind === 'loading' ? 'Checking your balance…' : block ? 'One more thing before Go' : 'Ready when you are'; + goBtn.setAttribute('aria-label', 'Create my photoshoot'); + if (block) goBtn.setAttribute('aria-disabled', 'true'); + else goBtn.removeAttribute('aria-disabled'); + } + const ready = !block && !running; + if (ready && !state.ready) { + goBtn.classList.remove('ready'); + void goBtn.offsetWidth; + goBtn.classList.add('ready'); + } + state.ready = ready; + form.querySelectorAll('input, textarea, summary').forEach((input) => { + if (input.tagName !== 'SUMMARY') input.disabled = Boolean(running); + }); + if (block?.html && !running && notice.dataset.kind !== 'error') showNotice(block.html, block.kind === 'signin' ? 'info' : 'warn'); + else if (!running && notice.dataset.kind !== 'error') hideNotice(); + } + + function showNotice(html, kind = 'info') { + notice.innerHTML = html; + notice.dataset.kind = kind; + notice.classList.toggle('error', kind === 'error'); + notice.hidden = false; + } + function hideNotice() { notice.hidden = true; notice.dataset.kind = ''; } + + // ── Photo ──────────────────────────────────────────────────────────── + function setFile(file) { + if (!file || state.starting || ACTIVE.includes(state.pack?.status)) return; + if (!TYPES.includes(file.type)) { + showNotice('That file type won\'t work. Use a PNG, JPG or WebP photo.', 'error'); + return; + } + if (file.size > MAX_BYTES) { + showNotice('That photo is over 16 MB. Try a smaller export.', 'error'); + return; + } + state.file = file; + const preview = $('photoPreview'); + if (preview.dataset.url) URL.revokeObjectURL(preview.dataset.url); + preview.dataset.url = URL.createObjectURL(file); + preview.src = preview.dataset.url; + $('photoName').textContent = file.name; + $('dropEmpty').hidden = true; + $('dropFilled').hidden = false; + if (notice.dataset.kind === 'error') hideNotice(); + updateGo(); + } + + photo.addEventListener('change', () => setFile(photo.files[0])); + ['dragenter', 'dragover'].forEach((type) => drop.addEventListener(type, (event) => { + event.preventDefault(); + drop.classList.add('dragging'); + })); + ['dragleave', 'drop'].forEach((type) => drop.addEventListener(type, () => drop.classList.remove('dragging'))); + drop.addEventListener('drop', (event) => { + event.preventDefault(); + setFile(event.dataTransfer.files[0]); + }); + document.addEventListener('paste', (event) => { + if (event.target.closest('textarea, input:not([type="file"])')) return; + const file = [...(event.clipboardData?.files || [])].find((item) => TYPES.includes(item.type)); + if (file && !state.starting && !ACTIVE.includes(state.pack?.status)) { event.preventDefault(); setFile(file); } + }); + form.addEventListener('change', (event) => { if (event.target.name !== 'image') refresh(); }); + + // ── Go ─────────────────────────────────────────────────────────────── + form.addEventListener('submit', async (event) => { + event.preventDefault(); + if (state.starting || (state.pack && ACTIVE.includes(state.pack.status))) return; + if (state.block && state.block.kind === 'signin') { window.location.href = '/signup'; return; } + if (state.block) return; // the notice above the button already explains why + if (!state.file) { + showNotice('Add a product photo first, in step 1 above.', 'error'); + photo.focus(); + return; + } + const body = new FormData(); + body.append('image', state.file, state.file.name); + body.append('vibe', selectedVibe()); + body.append('tier', selectedTier()); + body.append('shots', selectedShots().join(',')); + body.append('note', $('note').value.trim()); + + state.starting = true; + hideNotice(); + updateGo(); + try { + const response = await fetch('/api/shoot', { method: 'POST', headers: authHeaders(), body }); + const data = await response.json().catch(() => ({})); + if (response.status === 202) { + localStorage.setItem(PACK_KEY, data.pack_id); + startPack(data); + scrollTo($('results')); + loadMe(); + } else { + showError(response.status, data); + } + } catch (_) { + showNotice('We couldn\'t reach PhotoGen. Check your connection before trying again. If the shoot started, it will appear here when you return.', 'error'); + } finally { + state.starting = false; + updateGo(); + } + }); + + function showError(status, data) { + const message = escapeHtml(data.message || data.error || 'Something went wrong. You weren\'t charged.'); + if (status === 402) { + showNotice(`${message} See plans`, 'error'); + loadMe(); + } else if (status === 401) { + showNotice(`${message} Sign up free`, 'error'); + } else if (status === 429) { + showNotice('You\'re going a little fast. Wait a minute and try again. You weren\'t charged.', 'error'); + } else { + showNotice(message, 'error'); + } + } + + // ── Pack progress ──────────────────────────────────────────────────── + function startPack(pack) { + state.pack = pack; + state.startedAt = pack.created_at ? pack.created_at * 1000 : Date.now(); + clearInterval(state.elapsedTimer); + state.elapsedTimer = setInterval(updateElapsed, 1000); + renderPack(pack); + clearTimeout(state.timer); + if (ACTIVE.includes(pack.status)) state.timer = setTimeout(poll, 2500); + } + + function updateElapsed() { + if (!state.pack) return; + const seconds = Math.max(0, Math.floor((Date.now() - state.startedAt) / 1000)); + $('elapsed').textContent = `${Math.floor(seconds / 60)}:${String(seconds % 60).padStart(2, '0')} elapsed`; + } + + async function poll() { + const id = state.pack && state.pack.pack_id; + if (!id) return; + try { + const response = await fetch(`/api/shoot/${encodeURIComponent(id)}`, { headers: authHeaders() }); + if (response.status === 404) { resetPack(); return; } + if (response.ok) { + state.pack = await response.json(); + renderPack(state.pack); + } + } catch (_) { /* keep polling through a blip */ } + if (state.pack && ACTIVE.includes(state.pack.status)) { + state.timer = setTimeout(poll, 2500); + } else { + loadMe(); + updateGo(); + } + } + + function renderPack(pack) { + const outputs = Object.fromEntries(pack.outputs.map((output) => [output.id, output])); + const finished = pack.outputs.filter((output) => ['done', 'failed'].includes(output.status)).length; + const failed = pack.outputs.filter((output) => output.status === 'failed').length; + const active = ACTIVE.includes(pack.status); + + $('progress').hidden = false; + $('progressFill').style.width = `${Math.round((finished / pack.total) * 100)}%`; + $('progressBar').setAttribute('aria-valuenow', String(Math.round((finished / pack.total) * 100))); + updateElapsed(); + if (!active) clearInterval(state.elapsedTimer); + let text; + let title; + if (active) { + title = 'Shooting your pack'; + text = `${pack.completed} of ${pack.total} ready. You can leave this page; we'll keep going.`; + } else if (pack.status === 'done') { + title = 'Your pack is ready'; + text = `All ${plural(pack.total, 'image')} are ready to download.`; + } else if (pack.status === 'partial') { + title = 'Your pack is ready'; + text = `${pack.completed} of ${pack.total} ready. ${plural(failed, 'shot')} didn't come out, so ${plural(pack.credits_refunded, 'credit')} went back to your balance.`; + } else { + title = 'This shoot didn\'t work'; + text = pack.credits_refunded + ? `None of the shots came out. Your ${plural(pack.credits_refunded, 'credit')} are back in your balance. Try a photo of the product on a plain background.` + : 'None of the shots came out. Try a photo of the product on a plain background.'; + } + $('resultsTitle').textContent = title; + $('resultsSub').textContent = active ? 'Each image appears here as soon as it\'s done.' : 'Click an image to view it full size.'; + $('progressText').textContent = text; + $('resultsActions').hidden = active; + $('zipBtn').hidden = !pack.download_url; + $('elapsed').hidden = !active; + $('labelCheck').hidden = active || !pack.completed; + + const vibe = (options.vibes.find((item) => item.id === pack.vibe) || {}).label || ''; + document.querySelectorAll('.tile').forEach((tile) => { + const output = outputs[tile.dataset.shot]; + tile.hidden = !output; + if (!output || tile.dataset.state === output.status) return; + tile.dataset.state = output.status; + const frame = tile.querySelector('.frame'); + tile.querySelector('.tile-actions')?.remove(); + if (output.status === 'done') { + frame.innerHTML = `${escapeHtml(`${output.label}, ${vibe}`)}`; + const actions = document.createElement('div'); + actions.className = 'tile-actions'; + actions.innerHTML = `Download ${escapeHtml(output.label)}`; + tile.append(actions); + } else if (output.status === 'failed') { + frame.innerHTML = `
${escapeHtml(output.error || 'This shot didn\'t come out.')}
`; + } else { + frame.innerHTML = `${output.status === 'running' ? 'Shooting…' : 'Up next'}`; + } + }); + updateGo(); + } + + function resetPack() { + clearTimeout(state.timer); + clearInterval(state.elapsedTimer); + state.pack = null; + localStorage.removeItem(PACK_KEY); + sessionStorage.removeItem(PACK_KEY); + $('progress').hidden = true; + $('resultsActions').hidden = true; + $('labelCheck').hidden = true; + $('resultsTitle').textContent = 'Your pack'; + $('resultsSub').textContent = 'Six shots. Sized for where you sell.'; + document.querySelectorAll('.tile').forEach((tile) => { + tile.dataset.state = 'idle'; + tile.querySelector('.tile-actions')?.remove(); + const ratio = (options.shots.find((shot) => shot.id === tile.dataset.shot) || {}).aspect || ''; + tile.querySelector('.frame').innerHTML = ``; + }); + refresh(); + } + + $('againBtn').addEventListener('click', () => { + resetPack(); + scrollTo(form); + form.querySelector('input[name="vibe"]:checked')?.focus(); + }); + + $('zipBtn').addEventListener('click', async () => { + const pack = state.pack; + if (!pack || !pack.download_url) return; + const button = $('zipBtn'); + if (button.getAttribute('aria-busy') === 'true') return; + button.setAttribute('aria-busy', 'true'); + button.textContent = 'Preparing ZIP…'; + try { + const response = await fetch(pack.download_url, { headers: authHeaders() }); + if (!response.ok) throw new Error('download failed'); + const blob = await response.blob(); + const match = /filename="?([^";]+)"?/.exec(response.headers.get('Content-Disposition') || ''); + const link = document.createElement('a'); + link.href = URL.createObjectURL(blob); + link.download = match ? match[1] : 'photogen-photoshoot.zip'; + document.body.append(link); + link.click(); + link.remove(); + setTimeout(() => URL.revokeObjectURL(link.href), 10000); + } catch (_) { + showNotice('The ZIP didn\'t download. Try again, or save the images one by one.', 'error'); + } finally { + button.removeAttribute('aria-busy'); + button.textContent = 'Download all (ZIP)'; + } + }); + + // ── Start ──────────────────────────────────────────────────────────── + async function resume() { + const id = localStorage.getItem(PACK_KEY) || sessionStorage.getItem(PACK_KEY); + if (!id || (!sessionToken() && !usesOwnKey())) return; + try { + const response = await fetch(`/api/shoot/${encodeURIComponent(id)}`, { headers: authHeaders() }); + if (response.ok) { localStorage.setItem(PACK_KEY, id); startPack(await response.json()); } + else if ([401, 402, 404].includes(response.status)) { localStorage.removeItem(PACK_KEY); sessionStorage.removeItem(PACK_KEY); } + } catch (_) { /* nothing to resume */ } + } + + refresh(); + loadMe(); + resume(); +})(); diff --git a/templates/app.html b/templates/app.html index c768f96..54e5ad2 100644 --- a/templates/app.html +++ b/templates/app.html @@ -17,6 +17,7 @@ Creative Studio