From 30ca22b76f15cbddda13fc8cd06ee22e68ab8a2a Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 17 Oct 2026 05:41:51 +0000 Subject: [PATCH 1/4] docs: add launch-readiness audit for a public SLIDE beta Reconciles living sources of truth with what actually builds and plays. Ranks launch blockers, cuts, and a path that does not try to out-FPS Hood Warfare 2. Co-authored-by: BrandDead --- LAUNCH_READINESS.md | 218 ++++++++++++++++++++++++++++++++++++++++++++ docs/PROJECT_LOG.md | 6 ++ 2 files changed, 224 insertions(+) create mode 100644 LAUNCH_READINESS.md diff --git a/LAUNCH_READINESS.md b/LAUNCH_READINESS.md new file mode 100644 index 0000000..72a7ee3 --- /dev/null +++ b/LAUNCH_READINESS.md @@ -0,0 +1,218 @@ +# SLIDE / DEALT — Launch readiness audit + +**Date:** 2026-10-17 +**Audited revision:** `main-tL2525` at `9eef5aa`, plus the photo-likeness work on this branch +**Owner ask:** ship the best street-war game — live-action shooter plus territory/strategy, deeper than GTA, better than the recently released street-war FPS (treated here as **Hood Warfare 2**, Steam, 8 Oct 2026; there is no current “Street Warfare 2” store listing). + +This document reconciles the living sources of truth (`docs/AI_CONTRIBUTOR_START_HERE.md`, `docs/PROJECT_LOG.md`, `docs/RELEASE_CHECKLIST.md`, `docs/MVP_2026_SCOPE.md`, `docs/SAVED_GAME_READINESS_2026-09-25.md`, issues #45 / #175 / #195) with what is actually in the repo and what this run could execute. Older files such as `docs/BETA_LAUNCH_ASSESSMENT.md` (Jan 2025) and `docs/MISSING_COMPONENTS.md` are **stale** and should not be used as a launch plan. + +**Honest product verdict:** SLIDE is a **closed-alpha web command desktop** with a real single-player territory loop and several combat toys. It is **not** a public multiplayer shooter and **not** ready for a store or paying public launch. The path to a good first launch is a tight web beta around one saved empire, not a feature race against Hood Warfare 2. + +--- + +## Genre comparison (what players will judge) + +| Expectation | Hood Warfare 2 (live competitor) | What SLIDE actually is | Launch implication | +|---|---|---|---| +| Shooting feel | Unreal Engine 5 multiplayer FPS, six modes, four Chicago maps, weapon customization | Browser canvas/Phaser/Babylon encounters (Las Olas V3 drive-by, grid SLIDE, UnifiedEncounter). Readable, not a packaged FPS. | Do **not** market as a live-action shooter yet. Lead with crew + block strategy; keep one good defense fight. | +| Territory control | Session turf-war modes | Persistent fictional blocks, Block DNA (40 cards), claims, Strip desk, ghost-crew pressure | This is SLIDE’s actual differentiator. Launch on it. | +| Crew management | Loadout / gang pick before a match | Contacts, roles, hospital/bail, photo recruit, placement on a block | Must feel personal. Photo-friends-on-the-crew is the unique hook. | +| Progression | Match-to-match shooter loop | Levels, XP, morale, heat, stash, shoebox — locally rich, server proof incomplete | A returning empire is the GTA-like depth. Without save, there is no launch. | +| Economy | Match scoring / cosmetics | Dealing, cook, market, trap, shoebox, heat/raid costs | Keep one connected cash path. Hide side economies that do not write the same books. | +| Multiplayer / social | The whole product | Ghost crews + a Socket.IO client with no shipped realtime server | Ghost rivals are the launch stand-in. Live PvP is a later game. | +| Onboarding | Steam match flow | Age gate, Auth screen, gang onboarding, tutorial overlay | Fine for closed alpha. Public launch needs a five-minute first block. | +| Performance | Native UE5 | Vite/React web; Mapbox vendor chunk is large; combat is canvas | Web beta on phone + desktop. No native store until the loop is trusted. | +| Monetization | $5.99 Steam | Read-only entitlement API, no checkout/webhooks | Do not take money until save + refunds work. | +| Store / platform | Steam page, ratings, age disclaimer | Web app gated on Supabase. No iOS/Android/Steam packaging. | First launch = invite web URL. Stores are post-launch. | + +SLIDE cannot beat Hood Warfare 2 at “jump in and shoot.” It can beat it at **owning a block, putting your friends on the crew, and coming back to the same war.** That is the launch product. + +--- + +## (a) What works today + +Verified in this environment unless marked otherwise. + +### Verified in this run + +| Check | Result | Notes | +|---|---|---| +| Default branch checkout | `main-tL2525` @ `9eef5aa` | Clean working tree before this branch | +| Frontend unit tests (focused likeness + creator) | See PR verification | New photo-likeness contract | +| Backend likeness tests | See PR verification | Consent, no stock swap, source deleted | +| Avatar blueprint | Now registered on Flask | Previously written but **not mounted** — `/api/avatar/*` 404’d, so the client always fell back | +| Photo recruit UI | Contacts + Phone | Consent, name, role, style, outputs, add-to-roster. Live demo at 390×844: Generate stays disabled without consent; result is labeled photo-based likeness; Jay appears on the CREW card with the uploaded face. | +| Local API CORS | Flask default now allows :3000 and :5173 | Previously the documented Vite port 3000 was blocked, so the browser always took the offline path | + +### Product surfaces that exist and are playable in demo mode + +`VITE_DEMO_MODE=1` bypasses Supabase and seeds a Las Olas block. This is the only fully walkable path without credentials. The home screen exposes **every** app as available: + +**Core loop (keep):** + +- **Age gate** — versioned 18+ / fictional-content notice (`AgeGate.tsx`). Implemented and tested. +- **Auth shell** — email sign-in/up UI exists; production credentials are not proven here. +- **Onboarding** — gang name, tag, style, colors, optional logo, graffiti pick. +- **STRIP (`BlockLoopDesk`)** — the connected claim → place → earn → fight desk. This is the real game. +- **MAP** — recon, claims, tactical diorama, street imagery optional. Failure must leave the Strip playable (PR #128). **Do not regress this.** +- **CONTACTS / CREW / PHONE** — roster, roles, hospital/bail, photo recruit. +- **DEALT** — swipe dealing; v2 selector also present. +- **COOK** — alchemy lab with recipes. +- **DRIVE** — car loadout + canvas shooter. Seat rules exist; individual shot attribution is still #201. +- **SLIDE / UnifiedEncounter** — grid / encounter combat with persistent-looking receipts in demo. +- **SHOEBOX / TRAP / MARKET** — cash, stash, shop. Demo books work locally. +- **Ghost crews** — NPC rivals, banners, incidents. One visible Strip attack is still #163. +- **Block DNA** — 40 fictional archetypes. Claimed `dnaId` is authoritative. + +**Present but not launch-critical:** + +- Casino, Cocaine Crush, Bip N Dip, graffiti/Vandalize, leaderboard/Clout, Local News, Wanted/bounties, OPS planner, ATTACK top-down, RAID, Messages (routes to Phone). +- Experimental `unreal/DealtSlideShowdown` — not the ship target. +- Socket.IO client (`socket.service.ts`) — client only. + +### Persistence and backend + +| Layer | State | +|---|---| +| Flask API | Blocks, player, combat, world, inventory, drive-by, entitlements (read-only), NPC, art, **avatar (now registered)** | +| Offline / no Supabase | In-memory mock + `DEV_USER` auth bypass. `pytest` is designed for blank `SUPABASE_URL`. | +| Supabase schema | Migrations through 007 exist. Staging proof recorded. **Production must not be touched from this work.** | +| Authenticated return session | Code exists (`authPlayer`, `useEmpireHydration`, `useBlockSync`). **#175 player proof is still open.** | +| Payments | `GET /api/entitlements/me` only. No checkout, webhook, or grant UI. | + +### Visuals + +Cinematic 2.5D tactical-diorama direction is decided (#77). Runtime assets are on a 20 MB budget and audit-gated. Live renderers still mix strong Las Olas V3 work with role silhouettes, leftover emoji in some chrome, and stock portraits when a custom face is missing. Release checklist items for “no emoji / all actors through resolver” are **unchecked**. + +### Documents that are still true + +- `docs/AI_CONTRIBUTOR_START_HERE.md` — closed-alpha, not external/paid beta. +- `docs/RELEASE_CHECKLIST.md` — outside-tester gate; almost every box open. +- `docs/MVP_2026_SCOPE.md` / `docs/MVP_STATUS_AND_DEV_PLAN_2026-07-16.md` — paid MVP is one connected saved loop, not more mini-games. +- `docs/SAVED_GAME_READINESS_2026-09-25.md` — staging foundation ready; returning-player proof is not. + +### Documents to ignore for launch planning + +- `docs/BETA_LAUNCH_ASSESSMENT.md` — claims “no backend/auth” and a 3-week beta. Superseded. +- `docs/MISSING_COMPONENTS.md` — lists SlideGame / TerritoryMap / casino as missing. They exist. +- `docs/REALTIME_MULTIPLAYER_COMPLETION.md` — unfinished prompt dump, not a shipped system. +- Root `README.md` marketing copy (“multiplayer urban warfare RPG”) oversells live PvP. + +--- + +## (b) Launch blockers, ranked + +Effort is **focused engineering days** for one person who already knows the repo, not calendar time. + +| Rank | Blocker | Why it stops a public launch | Effort | Evidence | +|---:|---|---|---|---| +| 1 | **No proven returning account** | A public player cannot trust that cash, crew, block, wounds, and heat survive refresh / second device. | 8–15 days (ops + proof, not new architecture) | Issue #175; start-here §11 | +| 2 | **Core loop still forks** | Desktop ATTACK / OPS planner can bypass the canonical encounter (#192). Drive-by shots are not bound to the named passenger (#201). Ghost attack is not a single visible Strip fight (#163). Testers will say “the war doesn’t connect.” | 6–12 days | Issues #192, #201, #163, #188 | +| 3 | **Photo friends were stock art** | The headline social feature failed the founder’s own demo (#195). This branch fixes the local/offline path; durable private storage + optional AI vendor are still owner decisions. | 2–5 days remaining after this PR | #195; `docs/PHOTO_LIKENESS.md` | +| 4 | **Auth / hosting config for real users** | Fresh production builds throw without `VITE_SUPABASE_*`. Demo mode must never ship publicly. Password reset, email confirm, and support email are unverified. | 3–6 days | `frontend/src/services/supabase.ts`; `docs/ENV_SETUP.md` | +| 5 | **Economy is not server-authoritative** | Demo books are local Zustand. A public economy that can be edited in DevTools is not shippable. | 8–14 days | MVP status gates; entitlements are read-only | +| 6 | **No payment / refund / access lock** | Taking money would be unsafe. Founders pass is designed but not built. | 4–7 days **after** #1 and #5 | `api/entitlements.py`; MVP plan milestone 4 | +| 7 | **Onboarding does not finish a first war in five minutes** | Tutorial exists but is a tour of apps, not “claim → place dealer+shooter → one deal → one defense.” | 3–5 days | `TutorialContent.ts`; release checklist | +| 8 | **Visual / performance bar for outsiders** | Mixed emoji/chrome, large Mapbox chunk, phone combat readability uncertified. Outside testers will compare to UE5 screenshots. | 8–15 days of art+perf, not a rewrite | #45, #77, #78, #79; release checklist | +| 9 | **Ops / legal / store** | No verified error monitoring, backup restore drill, terms/privacy on the live URL, incident runbook, or store listing. 18+ crime fiction needs a qualified legal pass before ads. | 5–8 days + owner/legal | Release checklist; MVP ops gate | +| 10 | **Live PvP / presence** | Players expect to see other crews. Ghost crews are the stand-in; realtime docs are unfinished. | **Defer** (15–30+ days if started now) | #81; socket client only | + +**Not blockers if the first launch is an invite web beta:** native iOS/Android, Steam, extra cities, casino, graffiti, crush, Unreal showdown, voice chat, Mapbox-as-combat. + +--- + +## (c) Nice-to-haves to defer post-launch + +- Live PvP, presence, chat, alliances +- More cities / photogrammetry / Mapbox as the fight +- Casino, Cocaine Crush, Bip N Dip, graffiti as retention extras (only after they write the same books) +- Full street pose library per custom member (draft PR #169 Phase C) +- Nightfall production art package (#190) +- AI restyle of photo members (optional; local face cards are enough) +- Premium cosmetics / founder merch +- Native mobile wrappers and store review +- Unreal `DealtSlideShowdown` +- Leaderboards against real players +- Push notifications, voice, rewarded ads + +--- + +## (d) Things to cut (or hide) for launch + +Cut means **remove from the first-run home screen and marketing**, not delete from git. + +| Cut / hide | Why | +|---|---| +| Casino, Crush, Bip N Dip, Vandalize, Clout, Local News, Wanted | Extra apps that dilute the first session and look like unfinished GTA clones. | +| Dual combat entry points | One fight: Strip → canonical encounter. Hide ATTACK/OPS PLAN until #192 is closed. | +| “Multiplayer urban warfare RPG” README / store copy | False vs Hood Warfare 2 buyers. Say “asynchronous street-war strategy with shooter encounters.” | +| Demo mode in production | `VITE_DEMO_MODE=1` is a developer door. | +| Real gang / color names in onboarding (`crip` preset, Yakuza/Triad labels) | Conflicts with the fictional-content contract. Rename presets before public ads. | +| Unreal tree and old prompt packs as “the game” | Confusion for contributors and press. | +| Paid power items | Highest-risk monetization; excluded by MVP scope. | +| Broad drug-catalog expansion | Three base + one risky recipe is enough. | +| Replaying migration 007 / touching production Supabase | Explicitly forbidden until a separate release issue. | + +--- + +## (e) Recommended path to launch + +**Launch shape:** invite-only **web** closed beta. One city kit (Las Olas). Ghost crews, not live PvP. Founders access **only after** save works. Do not open Steam/App Store until the web loop retains people. + +1. **Lock the single-player war (this is the game).** + Finish the ordered stack already in flight: #188 integration, #192 one attack route, #163 one visible ghost hit, #201 truthful drive-by seats. Hide every other combat icon. + +2. **Prove a clean account comes back (#175).** + Isolated staging only. Age gate → sign up → gang → starter block → deal → defense → reload → second session. No new writer. No production migrate. + +3. **Ship photo friends as a real feature (#195, this PR).** + Local likeness cards now keep the uploaded face. Owner still chooses an AI vendor and private asset storage. Do not advertise “AI clones your friends” until a key is configured and privately QA’d. + +4. **First-run in five minutes.** + Replace the app-tour tutorial with: name crew → claim Strip → assign dealer + shooter → one deal → one defense → hospital/bail if needed. Hide Casino/Crush/etc. behind an “More” folder. + +5. **Make the desk look like one game.** + No new renderer. Finish resolver-backed roles on the Strip, kill remaining emoji on the first-run path, phone + desktop captures. Critic skill (`slide-game-critic`) on real play evidence. + +6. **Then** entitlements + checkout, support email, terms/privacy, Sentry, backup drill, legal pass, and a 50–100 person invite. + Only then talk about stores or live PvP. + +### Suggested public promise (once the above is true) + +> SLIDE is an 18+ fictional street-war strategy game. You claim a block, put your friends on the crew, deal, take heat, and fight to hold the Strip. The world keeps playing when you leave. + +That is more GTA than Hood Warfare 2 — **if** the empire is saved. If it is not, SLIDE is a folder of demos. + +--- + +## Photo-likeness state (this run) + +| | Before | After | +|---|---|---| +| Backend route | `api/avatar.py` existed, **not registered** in `app.py` | Registered. `GET /api/avatar/provider` reports config. | +| No API key | `MockAvatarProvider` returned stock dealer/enforcer/lookout art | Local compositor keeps the uploaded face on portrait / full-body / token cards | +| Backend down | Frontend catch → stock `/assets/runtime/` portraits labeled “stock” | Frontend compositor embeds the photo; labeled “photo-based likeness” | +| Consent | One checkbox | Adult + permission + processing; API rejects without them | +| Source photo | Temp file deleted; roster stored URLs | Unchanged contract, now tested: source deleted, roster gets cards only | +| AI | DALL·E 2 stub if `OPENAI_API_KEY` happened to be set | Still optional. Needs key + `SLIDE_AVATAR_PROVIDER=openai` + `openai` package. Failures fall back to the photo card, not stock. | + +Details: `docs/PHOTO_LIKENESS.md`. + +--- + +## Owner decisions still required + +1. First launch is **invite web beta**, not Steam/iOS. Confirm. +2. Complete #175 on `dealt-world-proof-staging` (or a new isolated target). Do not use production. +3. Whether to buy an image API for stylized restyles. Not required for likeness. +4. Private bucket for generated cards if you want them to survive server restart for signed-in players. +5. Founders-access price and processor (Stripe etc.) — only after save works. +6. Legal review of 18+ crime fiction + friend-photo consent copy before ads. +7. Rename onboarding presets that look like real gangs. +8. Who integrates the open PR stack (#188 / #192 / #206 / #209 / #169) so file reservations do not collide. + +--- + +## Open work this audit does **not** steal + +Do not treat this PR as owning `game.types.ts` visual profiles (draft PR #169), encounter session ownership (#207), Flask session headers (#206), inventory transfer (#209), or Nightfall art (#190). Photo member records still use the existing `portraitUrl` / `fullbodyUrl` / `topdownUrl` fields so #169 can land later. diff --git a/docs/PROJECT_LOG.md b/docs/PROJECT_LOG.md index e4373f7..fff931b 100644 --- a/docs/PROJECT_LOG.md +++ b/docs/PROJECT_LOG.md @@ -69,6 +69,12 @@ Payments/monetization P0s are separately listed in `docs/MVP_STATUS_AND_DEV_PLAN ## Log +### 2026-10-17 — Launch-readiness audit and truthful photo likeness (#195) + +- Added repo-root `LAUNCH_READINESS.md`: what is actually playable, ranked public-launch blockers, post-launch deferrals, launch cuts, and a short path that does not try to out-FPS Hood Warfare 2. Reconciles start-here / project log / #45 / #175 / #195 with stale Jan-2025 beta docs. +- Photo recruit no longer swaps stock role art for a friend’s face. Flask now mounts `/api/avatar/*`. The default provider composites the uploaded face onto portrait / full-body / token cards, requires adult + permission + processing consent, and deletes the source upload. Offline, the client does the same instead of `/assets/runtime` stock. Optional OpenAI restyle stays behind `OPENAI_API_KEY` + `SLIDE_AVATAR_PROVIDER=openai` and falls back to the photo card, never a fake identity. +- Docs: `docs/PHOTO_LIKENESS.md`. No founder photos, generated likenesses, migrations, or production secrets. Draft PR #169 still owns `visualProfile` persistence. + ### 2026-10-01 — Backend timestamps are timezone-aware UTC (#170) - Replaced all 17 `datetime.utcnow()` call sites in the live backend runtime with one shared helper, `backend/python/utils/utc.py` (`utc_now`, `to_iso_utc`, `utc_now_iso`). Python 3.12 deprecates `utcnow()` and the required CI runs the full backend suite, so the debt was visible on every run: the audited baseline emitted 153 warnings, all of them this single warning. The suite now runs with 0 warnings. From 04a11b2f07626b14b825461e23fd48f51139720b Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 17 Oct 2026 05:41:56 +0000 Subject: [PATCH 2/4] feat: keep uploaded faces on photo-recruited crew members Register /api/avatar, compose likeness cards from the photo instead of stock role art, require adult/permission/processing consent, and keep inline portrait bytes on the roster. Optional OpenAI restyle stays behind config and falls back to the photo card, never a fake identity. Co-authored-by: BrandDead --- backend/python/.env.example | 8 + backend/python/api/avatar.py | 275 +++++++---- backend/python/app.py | 13 +- backend/python/services/likeness.py | 451 ++++++++++++++++++ backend/python/tests/test_avatar_likeness.py | 142 ++++++ docs/PHOTO_LIKENESS.md | 58 +++ frontend/.env.example | 4 + frontend/src/components/contacts/Contacts.tsx | 2 +- .../components/gang/PhotoMemberCreator.css | 35 +- .../components/gang/PhotoMemberCreator.tsx | 106 +++- .../__tests__/PhotoMemberCreator.test.tsx | 54 ++- .../avatarGeneration.service.test.ts | 60 ++- .../__tests__/likenessCompositor.test.ts | 36 ++ .../src/services/avatarGeneration.service.ts | 99 ++-- frontend/src/services/likenessCompositor.ts | 128 +++++ frontend/src/types/avatar.types.ts | 39 +- 16 files changed, 1322 insertions(+), 188 deletions(-) create mode 100644 backend/python/services/likeness.py create mode 100644 backend/python/tests/test_avatar_likeness.py create mode 100644 docs/PHOTO_LIKENESS.md create mode 100644 frontend/src/services/__tests__/likenessCompositor.test.ts create mode 100644 frontend/src/services/likenessCompositor.ts diff --git a/backend/python/.env.example b/backend/python/.env.example index bb9a1a7..41bca29 100644 --- a/backend/python/.env.example +++ b/backend/python/.env.example @@ -31,3 +31,11 @@ GOOGLE_MAPS_API_KEY= ENABLE_STREET_VIEW=false ENABLE_3D_TILES=false ENABLE_WORLD_TICK=true + +# Photo-to-member likeness +# Default: local photo compositor (uses the uploaded face; no API key). +# Optional AI restyle — only when BOTH are set. Never required for likeness. +# OPENAI_API_KEY= +# SLIDE_AVATAR_PROVIDER=openai +# SLIDE_AVATAR_MODEL=gpt-image-1 +# pip install 'openai>=1.40.0' is also required for the optional path. diff --git a/backend/python/api/avatar.py b/backend/python/api/avatar.py index 224796f..872ade7 100644 --- a/backend/python/api/avatar.py +++ b/backend/python/api/avatar.py @@ -2,28 +2,57 @@ avatar.py — Photo-to-member generation API POST /api/avatar/generate GET /api/avatar/status/ +GET /api/avatar/asset// +GET /api/avatar/provider POST /api/avatar/approve -Sprint: morale-heat-photo-batch2 +The default path is a local photo-likeness compositor. Stock role art is +never returned as if it came from the uploaded photo. Optional OpenAI +restyle is used only when explicitly configured and the call succeeds. """ +import base64 import os import uuid -import json from utils.utc import utc_now_iso -from flask import Blueprint, request, jsonify +from flask import Blueprint, request, jsonify, send_file + +from services.likeness import ( + ALLOWED_MIME, + MAX_UPLOAD_BYTES, + build_identity_prompt, + compose_member_assets, + provider_status, +) avatar_bp = Blueprint("avatar", __name__, url_prefix="/api/avatar") -# In-memory job store (replace with DB in production) +# In-memory job store (replace with private object storage in production) _jobs: dict = {} +_ASSET_ROOT = "/tmp/slide_generated" + + +def _data_url(png_bytes: bytes) -> str: + return "data:image/png;base64," + base64.b64encode(png_bytes).decode("ascii") + +def _write_assets(job_id: str, files: dict[str, bytes]) -> dict[str, str]: + job_dir = os.path.join(_ASSET_ROOT, job_id) + os.makedirs(job_dir, exist_ok=True) + urls = {} + for kind, payload in files.items(): + path = os.path.join(job_dir, f"{kind}.png") + with open(path, "wb") as handle: + handle.write(payload) + urls[kind] = f"/api/avatar/asset/{job_id}/{kind}" + return urls -# ─── Provider abstraction ──────────────────────────────────── class AvatarGenerationProvider: """Base provider. Override generate_member_assets for real AI.""" + name = "base" + def generate_member_assets( self, source_image_path: str, @@ -34,130 +63,150 @@ def generate_member_assets( raise NotImplementedError -class MockAvatarProvider(AvatarGenerationProvider): - """Returns placeholder asset URLs immediately — no external API needed.""" +class LocalLikenessProvider(AvatarGenerationProvider): + """Compose roster cards that keep the uploaded face.""" - MOCK_PORTRAITS = { - "dealer": "/assets/generated/characters/portraits/character_dealer_male_blacktee_portrait_v001.png", - "enforcer": "/assets/generated/characters/portraits/character_enforcer_male_portrait_v001.png", - "lookout": "/assets/generated/characters/portraits/character_lookout_female_portrait_v001.png", - "driver": "/assets/generated/characters/portraits/character_driver_male_portrait_v001.png", - } - MOCK_FULLBODY = { - "dealer": "/assets/generated/characters/fullbody/character_dealer_male_blacktee_fullbody_front_v001.png", - "enforcer": "/assets/generated/characters/fullbody/character_enforcer_male_fullbody_front_v001.png", - "lookout": "/assets/generated/characters/fullbody/character_lookout_female_fullbody_front_v001.png", - "driver": "/assets/generated/characters/fullbody/character_driver_male_fullbody_front_v001.png", - } - MOCK_TOPDOWN = { - "dealer": "/assets/generated/characters/topdown/character_dealer_male_blacktee_topdown_v001.png", - "enforcer": "/assets/generated/characters/topdown/character_enforcer_male_topdown_v001.png", - } + name = "local_photo_likeness" def generate_member_assets(self, source_image_path, role, style, outputs): - result = {} - if "portrait" in outputs: - result["portraitUrl"] = self.MOCK_PORTRAITS.get(role, self.MOCK_PORTRAITS["dealer"]) - if "fullbody" in outputs: - result["fullbodyUrl"] = self.MOCK_FULLBODY.get(role, self.MOCK_FULLBODY["dealer"]) - if "topdown" in outputs: - result["topdownUrl"] = self.MOCK_TOPDOWN.get(role, self.MOCK_TOPDOWN.get("dealer")) - result["promptUsed"] = ( - f"Create a semi-realistic playable game character inspired by the uploaded reference image. " - f"Preserve general likeness, face shape, skin tone, and hairstyle when visible, " - f"but stylize into the SLIDE game universe. Outfit: {style}. Role: {role}. " - f"Output: {', '.join(outputs)}. Clean silhouette, transparent-background friendly, " - f"game-asset-ready, no scenery, no real brand logos." - ) - return result + composed = compose_member_assets(source_image_path, role, style, outputs) + traits = composed["likenessTraits"] + camera = "chest-up portrait, dramatic noir lighting" + prompt = build_identity_prompt(traits, role, style, camera) + return { + "files": composed["files"], + "likenessTraits": traits, + "promptUsed": prompt, + "provider": self.name, + } class OpenAIAvatarProvider(AvatarGenerationProvider): """ - Real OpenAI image-to-image provider. - Requires OPENAI_API_KEY in environment. + Optional OpenAI image edit. Requires OPENAI_API_KEY and + SLIDE_AVATAR_PROVIDER=openai. Failures must fall back to local likeness + rather than stock art. """ + name = "openai" + def generate_member_assets(self, source_image_path, role, style, outputs): - import openai # type: ignore + try: + import openai # type: ignore + except ImportError as exc: + raise RuntimeError( + "openai package is not installed. pip install 'openai>=1.40.0'" + ) from exc + + local = LocalLikenessProvider().generate_member_assets( + source_image_path, role, style, outputs + ) client = openai.OpenAI(api_key=os.environ.get("OPENAI_API_KEY")) - - style_map = { - "south_florida_streetwear": "South Florida streetwear, baggy jeans, sneakers, chain", - "luxury_noir": "luxury noir, dark designer clothing, gold accents", - "masked_tactical": "masked tactical streetwear, balaclava, tactical vest", - "baggy_2000s": "baggy 2000s streetwear, oversized tee, Timberlands", - "clean_designer": "clean designer streetwear, fitted clothes, luxury accessories", + model = os.environ.get("SLIDE_AVATAR_MODEL") or "gpt-image-1" + files = dict(local["files"]) + traits = local["likenessTraits"] + + cameras = { + "portrait": "chest-up portrait, dramatic noir lighting", + "fullbody": "full body, front-facing neutral pose, feet visible", + "topdown": "overhead 85-degree tactical token, full figure", } - style_desc = style_map.get(style, style) - result = {} - for output in outputs: - if output == "portrait": - camera = "chest-up portrait, dramatic noir lighting" - elif output == "fullbody": - camera = "full body, front-facing neutral pose, feet visible" - else: - camera = "overhead 85-degree tactical view, full figure" - - prompt = ( - f"Create a semi-realistic playable game character inspired by the uploaded reference image. " - f"Preserve general likeness, face shape, skin tone, and hairstyle when visible, " - f"but stylize into a 2.5D urban strategy game universe. " - f"Outfit: {style_desc}. Role: {role}. Camera: {camera}. " - f"Clean silhouette, transparent-background friendly, game-asset-ready, " - f"no scenery, no real brand logos, no watermarks." - ) - + prompt = build_identity_prompt(traits, role, style, cameras.get(output, output)) with open(source_image_path, "rb") as img_file: - response = client.images.edit( - model="dall-e-2", - image=img_file, - prompt=prompt, - n=1, - size="1024x1024", - ) - url = response.data[0].url - if output == "portrait": - result["portraitUrl"] = url - elif output == "fullbody": - result["fullbodyUrl"] = url + try: + response = client.images.edit( + model=model, + image=img_file, + prompt=prompt, + n=1, + size="1024x1024", + ) + except Exception: + # Older SDKs / dall-e-2 still accept this shape. + img_file.seek(0) + response = client.images.edit( + model="dall-e-2", + image=img_file, + prompt=prompt, + n=1, + size="1024x1024", + ) + payload = response.data[0] + if getattr(payload, "b64_json", None): + files[output] = base64.b64decode(payload.b64_json) + elif getattr(payload, "url", None): + import requests + downloaded = requests.get(payload.url, timeout=30) + downloaded.raise_for_status() + files[output] = downloaded.content else: - result["topdownUrl"] = url + raise RuntimeError("OpenAI image response had no bytes") - result["promptUsed"] = prompt - return result + return { + "files": files, + "likenessTraits": traits, + "promptUsed": prompt, + "provider": self.name, + } def get_provider() -> AvatarGenerationProvider: - """Select provider based on available API keys.""" - if os.environ.get("OPENAI_API_KEY"): + status = provider_status() + if status["aiConfigured"]: return OpenAIAvatarProvider() - return MockAvatarProvider() + return LocalLikenessProvider() + +def _require_consent(form) -> str | None: + permission = form.get("consentPermission") or form.get("consent_permission") + adult = form.get("consentAdult") or form.get("consent_adult") + processing = form.get("consentProcessing") or form.get("consent_processing") + if permission not in ("1", "true", "yes", "on"): + return "Permission from the person in the photo is required." + if adult not in ("1", "true", "yes", "on"): + return "This feature is only for photos of adults (18+)." + if processing not in ("1", "true", "yes", "on"): + return "AI/image processing consent is required." + return None + + +@avatar_bp.route("/provider", methods=["GET"]) +def provider(): + """Report whether optional AI restyle is configured.""" + return jsonify(provider_status()), 200 -# ─── Routes ────────────────────────────────────────────────── @avatar_bp.route("/generate", methods=["POST"]) def generate(): """ POST /api/avatar/generate - Accepts multipart/form-data: - - image: file - - role: str - - style: str - - outputs: list[str] (repeated field) + multipart/form-data: + image, role, style, outputs[], consentPermission, consentAdult, consentProcessing """ + consent_error = _require_consent(request.form) + if consent_error: + return jsonify({"error": consent_error, "code": "consent_required"}), 400 + if "image" not in request.files: return jsonify({"error": "No image uploaded"}), 400 image_file = request.files["image"] + mime = (image_file.mimetype or "").lower() + if mime not in ALLOWED_MIME: + return jsonify({"error": "Upload a JPG, PNG, WEBP, or GIF photo."}), 400 + + image_file.seek(0, os.SEEK_END) + size = image_file.tell() + image_file.seek(0) + if size > MAX_UPLOAD_BYTES: + return jsonify({"error": "Photo must be 8 MB or smaller."}), 400 + role = request.form.get("role", "dealer") style = request.form.get("style", "south_florida_streetwear") outputs = request.form.getlist("outputs") or ["portrait", "fullbody"] - # Save uploaded image temporarily upload_dir = "/tmp/slide_uploads" os.makedirs(upload_dir, exist_ok=True) ext = os.path.splitext(image_file.filename or "img.jpg")[1] or ".jpg" @@ -166,15 +215,25 @@ def generate(): job_id = str(uuid.uuid4()) provider = get_provider() + ai_error = None try: - assets = provider.generate_member_assets(tmp_path, role, style, outputs) - except Exception as e: - return jsonify({"error": str(e)}), 500 + try: + generated = provider.generate_member_assets(tmp_path, role, style, outputs) + except Exception as exc: + if provider.name != "local_photo_likeness": + ai_error = str(exc) + generated = LocalLikenessProvider().generate_member_assets( + tmp_path, role, style, outputs + ) + else: + return jsonify({"error": str(exc)}), 500 finally: if os.path.exists(tmp_path): os.remove(tmp_path) + files = generated.pop("files") + urls = _write_assets(job_id, files) asset = { "id": job_id, "role": role, @@ -182,16 +241,35 @@ def generate(): "outputs": outputs, "status": "ready", "createdAt": utc_now_iso(), - **assets, + "sourcePhotoRetained": False, + "stockPreview": False, + **generated, + "portraitUrl": urls.get("portrait") or _data_url(files["portrait"]) if "portrait" in files else None, + "fullbodyUrl": urls.get("fullbody") or _data_url(files["fullbody"]) if "fullbody" in files else None, + "topdownUrl": urls.get("topdown") or _data_url(files["topdown"]) if "topdown" in files else None, + "portraitDataUrl": _data_url(files["portrait"]) if "portrait" in files else None, + "fullbodyDataUrl": _data_url(files["fullbody"]) if "fullbody" in files else None, + "topdownDataUrl": _data_url(files["topdown"]) if "topdown" in files else None, } + if ai_error: + asset["aiProviderError"] = ai_error + asset["provider"] = "local_photo_likeness" _jobs[job_id] = asset - return jsonify({"asset": asset}), 200 +@avatar_bp.route("/asset//", methods=["GET"]) +def asset_file(job_id: str, kind: str): + if kind not in {"portrait", "fullbody", "topdown"}: + return jsonify({"error": "Unknown asset"}), 404 + path = os.path.join(_ASSET_ROOT, job_id, f"{kind}.png") + if not os.path.exists(path): + return jsonify({"error": "Asset not found"}), 404 + return send_file(path, mimetype="image/png") + + @avatar_bp.route("/status/", methods=["GET"]) def status(job_id: str): - """GET /api/avatar/status/""" job = _jobs.get(job_id) if not job: return jsonify({"error": "Job not found"}), 404 @@ -200,7 +278,6 @@ def status(job_id: str): @avatar_bp.route("/approve", methods=["POST"]) def approve(): - """POST /api/avatar/approve — marks an asset as approved""" data = request.get_json(silent=True) or {} asset_id = data.get("assetId") if not asset_id or asset_id not in _jobs: diff --git a/backend/python/app.py b/backend/python/app.py index 34040f5..f9a3e1e 100644 --- a/backend/python/app.py +++ b/backend/python/app.py @@ -31,7 +31,10 @@ def create_app(): app.config['DATABASE_URL'] = os.getenv('DATABASE_URL') # CORS configuration - cors_origins = os.getenv('CORS_ORIGINS', 'http://localhost:5173').split(',') + cors_origins = os.getenv( + 'CORS_ORIGINS', + 'http://localhost:5173,http://localhost:3000,http://127.0.0.1:5173,http://127.0.0.1:3000', + ).split(',') CORS(app, resources={ r"/api/*": { "origins": cors_origins, @@ -142,6 +145,14 @@ def index(): except Exception as e: logger.error(f"✗ Failed to register npc blueprint: {e}") + # Photo-to-member likeness (local compositor; optional AI restyle) + try: + from api.avatar import avatar_bp + app.register_blueprint(avatar_bp) + logger.info("✓ Registered avatar blueprint") + except Exception as e: + logger.error(f"✗ Failed to register avatar blueprint: {e}") + # Art generation blueprint try: from routes.art import art_bp diff --git a/backend/python/services/likeness.py b/backend/python/services/likeness.py new file mode 100644 index 0000000..fe2be5a --- /dev/null +++ b/backend/python/services/likeness.py @@ -0,0 +1,451 @@ +"""Local photo-likeness analysis and character-card compositor. + +This is the no-key path for custom crew members. It never invents a +different face. The uploaded photo is the identity; role/style only +change the fictional card around that face. + +Source photos are read from a temp path and must be deleted by the +caller after generation. Generated PNGs are either returned as data +URLs or written under a job directory — never the original upload. +""" + +from __future__ import annotations + +import io +import os +from typing import Iterable + +from PIL import Image, ImageDraw, ImageEnhance, ImageFilter, ImageOps + +ALLOWED_MIME = { + "image/jpeg", + "image/jpg", + "image/png", + "image/webp", + "image/gif", +} +MAX_UPLOAD_BYTES = 8 * 1024 * 1024 + +SKIN_BANDS = ( + ("ivory", (248, 228, 212)), + ("sand", (232, 196, 162)), + ("honey", (212, 165, 116)), + ("caramel", (181, 131, 90)), + ("toffee", (139, 101, 68)), + ("bronze", (110, 72, 48)), + ("chocolate", (93, 64, 55)), + ("espresso", (62, 39, 35)), +) + +HAIR_BANDS = ( + ("black", (18, 16, 14)), + ("dark_brown", (48, 32, 22)), + ("brown", (92, 58, 32)), + ("auburn", (120, 52, 28)), + ("blonde", (210, 176, 96)), + ("platinum", (220, 214, 200)), + ("gray", (140, 140, 138)), + ("red", (160, 48, 28)), +) + +STYLE_CLOTHING = { + "south_florida_streetwear": { + "label": "South Florida streetwear", + "top": (36, 36, 40), + "accent": (74, 222, 128), + "bottom": (28, 42, 64), + "notes": "oversized tee, baggy jeans, sneakers, thin chain", + }, + "luxury_noir": { + "label": "luxury noir", + "top": (18, 16, 20), + "accent": (212, 175, 55), + "bottom": (24, 22, 28), + "notes": "dark tailored layers, gold hardware, no real logos", + }, + "masked_tactical": { + "label": "tactical streetwear", + "top": (32, 38, 34), + "accent": (120, 140, 90), + "bottom": (28, 32, 28), + "notes": "utility vest, muted olive, pulled collar — face stays visible", + }, + "baggy_2000s": { + "label": "baggy 2000s streetwear", + "top": (48, 28, 72), + "accent": (250, 204, 21), + "bottom": (22, 22, 28), + "notes": "oversized jersey, baggy jeans, work boots", + }, + "clean_designer": { + "label": "clean designer streetwear", + "top": (232, 232, 228), + "accent": (96, 165, 250), + "bottom": (40, 40, 48), + "notes": "fitted knit, clean sneakers, quiet jewelry", + }, +} + +ROLE_COLORS = { + "dealer": (74, 222, 128), + "shooter": (248, 113, 113), + "driver": (96, 165, 250), + "enforcer": (251, 146, 60), + "lookout": (167, 139, 250), + "runner": (250, 204, 21), + "chemist": (45, 212, 191), +} + + +def _clamp(value: float, low: float, high: float) -> float: + return max(low, min(high, value)) + + +def _nearest_name(rgb: tuple[int, int, int], bands: Iterable[tuple[str, tuple[int, int, int]]]) -> str: + best_name = "unknown" + best_dist = 1e12 + for name, target in bands: + dist = sum((a - b) ** 2 for a, b in zip(rgb, target)) + if dist < best_dist: + best_dist = dist + best_name = name + return best_name + + +def _mean_rgb(image: Image.Image, box: tuple[int, int, int, int] | None = None) -> tuple[int, int, int]: + crop = image.crop(box) if box else image + if crop.mode != "RGB": + crop = crop.convert("RGB") + hist = crop.resize((1, 1), Image.Resampling.BOX).getpixel((0, 0)) + return (int(hist[0]), int(hist[1]), int(hist[2])) + + +def _is_skin_ycbcr(r: int, g: int, b: int) -> bool: + y = 0.299 * r + 0.587 * g + 0.114 * b + cb = 128 - 0.168736 * r - 0.331264 * g + 0.5 * b + cr = 128 + 0.5 * r - 0.418688 * g - 0.081312 * b + return 40 < y < 250 and 77 <= cb <= 132 and 128 <= cr <= 178 + + +def _face_box(image: Image.Image) -> tuple[int, int, int, int]: + """Find a face-ish bounding box via coarse YCbCr skin detection.""" + sample = image.convert("RGB") + w, h = sample.size + step_x = max(1, w // 80) + step_y = max(1, h // 80) + xs: list[int] = [] + ys: list[int] = [] + for y in range(0, h, step_y): + for x in range(0, w, step_x): + r, g, b = sample.getpixel((x, y))[:3] + if _is_skin_ycbcr(r, g, b): + xs.append(x) + ys.append(y) + if len(xs) < 12: + # Typical portrait fallback: upper-center. + return (int(w * 0.18), int(h * 0.08), int(w * 0.82), int(h * 0.72)) + pad_x = int(w * 0.08) + pad_y = int(h * 0.10) + left = max(0, min(xs) - pad_x) + right = min(w, max(xs) + pad_x) + top = max(0, min(ys) - int(pad_y * 1.4)) + bottom = min(h, max(ys) + pad_y) + if right - left < w * 0.22: + cx = (left + right) // 2 + half = int(w * 0.18) + left, right = max(0, cx - half), min(w, cx + half) + if bottom - top < h * 0.22: + cy = (top + bottom) // 2 + half = int(h * 0.18) + top, bottom = max(0, cy - half), min(h, cy + half) + return (left, top, right, bottom) + + +def analyze_photo(image: Image.Image) -> dict: + """Extract likeness traits used for prompts and the local card.""" + rgb = image.convert("RGB") + face = _face_box(rgb) + fw = face[2] - face[0] + fh = face[3] - face[1] + mid_face = ( + face[0] + int(fw * 0.25), + face[1] + int(fh * 0.28), + face[2] - int(fw * 0.25), + face[1] + int(fh * 0.62), + ) + lower_face = ( + face[0] + int(fw * 0.28), + face[1] + int(fh * 0.62), + face[2] - int(fw * 0.28), + min(rgb.size[1], face[3] + int(fh * 0.04)), + ) + hair_box = ( + face[0], + max(0, face[1] - int(fh * 0.28)), + face[2], + face[1] + int(fh * 0.18), + ) + clothes_box = ( + int(rgb.size[0] * 0.18), + min(rgb.size[1] - 4, face[3] + int(fh * 0.08)), + int(rgb.size[0] * 0.82), + rgb.size[1], + ) + + skin = _mean_rgb(rgb, mid_face) + hair = _mean_rgb(rgb, hair_box) + clothes = _mean_rgb(rgb, clothes_box) + cheek = _mean_rgb(rgb, mid_face) + chin = _mean_rgb(rgb, lower_face) + cheek_luma = 0.299 * cheek[0] + 0.587 * cheek[1] + 0.114 * cheek[2] + chin_luma = 0.299 * chin[0] + 0.587 * chin[1] + 0.114 * chin[2] + facial_hair = "visible" if (cheek_luma - chin_luma) > 18 else "none_or_light" + + notes = [ + f"face shape from photo crop {face[2] - face[0]}x{face[3] - face[1]}", + f"skin maps to { _nearest_name(skin, SKIN_BANDS) }", + f"hair maps to { _nearest_name(hair, HAIR_BANDS) }", + ] + if facial_hair == "visible": + notes.append("lower-face is darker than mid-face — keep facial hair") + + return { + "skinTone": _nearest_name(skin, SKIN_BANDS), + "skinRgb": list(skin), + "hairColor": _nearest_name(hair, HAIR_BANDS), + "hairRgb": list(hair), + "facialHair": facial_hair, + "clothingRgb": list(clothes), + "faceBox": list(face), + "notes": notes, + } + + +def _grade_portrait(crop: Image.Image) -> Image.Image: + graded = ImageEnhance.Contrast(crop).enhance(1.12) + graded = ImageEnhance.Color(graded).enhance(1.06) + graded = ImageEnhance.Sharpness(graded).enhance(1.15) + # Warm highlight / cool shadow grade without washing identity. + overlay = Image.new("RGB", graded.size, (18, 24, 36)) + graded = Image.blend(graded, overlay, 0.10) + return graded + + +def _vignette(image: Image.Image, strength: float = 0.42) -> Image.Image: + w, h = image.size + mask = Image.new("L", (w, h), 0) + draw = ImageDraw.Draw(mask) + inset = int(min(w, h) * 0.06) + draw.ellipse((-inset, -inset, w + inset, h + inset), fill=255) + mask = mask.filter(ImageFilter.GaussianBlur(radius=min(w, h) * 0.16)) + darkened = ImageEnhance.Brightness(image).enhance(1.0 - strength) + return Image.composite(image, darkened, mask) + + +def compose_portrait(source: Image.Image, traits: dict, role: str) -> Image.Image: + rgb = source.convert("RGB") + face = tuple(traits["faceBox"]) + # Include hair and a bit of shoulders so the crop still looks like the person. + w, h = rgb.size + fw = face[2] - face[0] + fh = face[3] - face[1] + box = ( + max(0, face[0] - int(fw * 0.18)), + max(0, face[1] - int(fh * 0.28)), + min(w, face[2] + int(fw * 0.18)), + min(h, face[3] + int(fh * 0.38)), + ) + crop = rgb.crop(box) + canvas = ImageOps.fit(crop, (512, 640), Image.Resampling.LANCZOS, centering=(0.5, 0.38)) + canvas = _grade_portrait(canvas) + canvas = _vignette(canvas, 0.34) + + frame = Image.new("RGB", (512, 640), (10, 10, 15)) + frame.paste(canvas, (0, 0)) + draw = ImageDraw.Draw(frame, "RGBA") + accent = ROLE_COLORS.get(role, (74, 222, 128)) + draw.rectangle((8, 8, 503, 631), outline=accent + (220,), width=4) + draw.rectangle((14, 14, 497, 625), outline=(245, 245, 247, 70), width=1) + return frame.convert("RGB") + + +def _draw_body( + draw: ImageDraw.ImageDraw, + traits: dict, + role: str, + style: str, + origin: tuple[int, int], +) -> None: + x, y = origin + clothing = STYLE_CLOTHING.get(style, STYLE_CLOTHING["south_florida_streetwear"]) + skin = tuple(traits["skinRgb"]) + top = clothing["top"] + bottom = clothing["bottom"] + accent = clothing["accent"] + role_color = ROLE_COLORS.get(role, (74, 222, 128)) + + # Neck + draw.rectangle((x + 86, y + 132, x + 118, y + 168), fill=skin) + # Torso + if style == "masked_tactical": + draw.rounded_rectangle((x + 48, y + 156, x + 156, y + 320), radius=10, fill=top) + draw.rounded_rectangle((x + 58, y + 170, x + 146, y + 250), radius=6, fill=(40, 46, 40)) + draw.rectangle((x + 70, y + 186, x + 134, y + 196), fill=accent) + elif style == "luxury_noir": + draw.rounded_rectangle((x + 52, y + 156, x + 152, y + 330), radius=8, fill=top) + draw.polygon([(x + 102, y + 156), (x + 70, y + 250), (x + 134, y + 250)], fill=accent) + else: + draw.rounded_rectangle((x + 44, y + 156, x + 160, y + 318), radius=16, fill=top) + draw.rectangle((x + 44, y + 300, x + 160, y + 318), fill=accent) + + # Arms + draw.rounded_rectangle((x + 16, y + 168, x + 50, y + 300), radius=12, fill=top) + draw.rounded_rectangle((x + 154, y + 168, x + 188, y + 300), radius=12, fill=top) + draw.ellipse((x + 14, y + 292, x + 52, y + 330), fill=skin) + draw.ellipse((x + 152, y + 292, x + 190, y + 330), fill=skin) + # Legs + draw.rounded_rectangle((x + 56, y + 314, x + 96, y + 470), radius=10, fill=bottom) + draw.rounded_rectangle((x + 108, y + 314, x + 148, y + 470), radius=10, fill=bottom) + # Shoes + draw.rounded_rectangle((x + 46, y + 462, x + 102, y + 492), radius=8, fill=role_color) + draw.rounded_rectangle((x + 102, y + 462, x + 158, y + 492), radius=8, fill=role_color) + + +def compose_fullbody(source: Image.Image, traits: dict, role: str, style: str) -> Image.Image: + card = Image.new("RGB", (512, 768), (10, 10, 15)) + draw = ImageDraw.Draw(card, "RGBA") + # Wet-night ground and horizon so the card matches the diorama grade. + draw.rectangle((0, 520, 512, 768), fill=(16, 18, 24)) + draw.rectangle((0, 0, 512, 520), fill=(14, 16, 22)) + for i in range(8): + y = 530 + i * 28 + draw.line((0, y, 512, y), fill=(24, 28, 36), width=1) + + origin = (154, 86) + _draw_body(draw, traits, role, style, origin) + + rgb = source.convert("RGB") + face = tuple(traits["faceBox"]) + w, h = rgb.size + fw = face[2] - face[0] + fh = face[3] - face[1] + head_box = ( + max(0, face[0] - int(fw * 0.12)), + max(0, face[1] - int(fh * 0.22)), + min(w, face[2] + int(fw * 0.12)), + min(h, face[3] + int(fh * 0.08)), + ) + head = ImageOps.fit(rgb.crop(head_box), (168, 176), Image.Resampling.LANCZOS, centering=(0.5, 0.35)) + head = _grade_portrait(head) + mask = Image.new("L", head.size, 0) + ImageDraw.Draw(mask).ellipse((4, 2, 164, 174), fill=255) + mask = mask.filter(ImageFilter.GaussianBlur(1.4)) + card.paste(head, (origin[0] + 18, origin[1] - 8), mask) + + accent = ROLE_COLORS.get(role, (74, 222, 128)) + draw.rectangle((16, 16, 495, 751), outline=accent, width=3) + draw.rectangle((24, 700, 488, 736), fill=(10, 10, 15)) + draw.rectangle((24, 700, 488, 736), outline=accent, width=1) + return card.convert("RGB") + + +def compose_topdown(source: Image.Image, traits: dict, role: str) -> Image.Image: + token = Image.new("RGBA", (256, 256), (0, 0, 0, 0)) + draw = ImageDraw.Draw(token, "RGBA") + accent = ROLE_COLORS.get(role, (74, 222, 128)) + draw.ellipse((8, 8, 247, 247), fill=(10, 10, 15, 255), outline=accent + (255,), width=10) + + rgb = source.convert("RGB") + face = tuple(traits["faceBox"]) + head = ImageOps.fit(rgb.crop(face), (196, 196), Image.Resampling.LANCZOS, centering=(0.5, 0.32)) + head = _grade_portrait(head) + mask = Image.new("L", head.size, 0) + ImageDraw.Draw(mask).ellipse((2, 2, 193, 193), fill=255) + token.paste(head, (30, 30), mask) + return token + + +def encode_png(image: Image.Image) -> bytes: + buffer = io.BytesIO() + image.save(buffer, format="PNG", optimize=True) + return buffer.getvalue() + + +def compose_member_assets( + source_path: str, + role: str, + style: str, + outputs: list[str], +) -> dict: + with Image.open(source_path) as raw: + source = raw.convert("RGB") + # Bound analysis cost on huge camera rolls. + max_edge = 1600 + if max(source.size) > max_edge: + source.thumbnail((max_edge, max_edge), Image.Resampling.LANCZOS) + traits = analyze_photo(source) + assets: dict = { + "likenessTraits": { + "skinTone": traits["skinTone"], + "hairColor": traits["hairColor"], + "facialHair": traits["facialHair"], + "clothingColors": [traits["skinTone"], _nearest_name(tuple(traits["clothingRgb"]), HAIR_BANDS)], + "notes": traits["notes"], + }, + "files": {}, + } + if "portrait" in outputs: + assets["files"]["portrait"] = encode_png(compose_portrait(source, traits, role)) + if "fullbody" in outputs: + assets["files"]["fullbody"] = encode_png(compose_fullbody(source, traits, role, style)) + if "topdown" in outputs: + assets["files"]["topdown"] = encode_png(compose_topdown(source, traits, role)) + return assets + + +def build_identity_prompt(traits: dict, role: str, style: str, camera: str) -> str: + clothing = STYLE_CLOTHING.get(style, STYLE_CLOTHING["south_florida_streetwear"]) + hair = traits.get("hairColor", "dark") + skin = traits.get("skinTone", "medium") + facial = "Keep the same facial hair." if traits.get("facialHair") == "visible" else "Do not invent heavy facial hair." + notes = "; ".join(traits.get("notes") or []) + return ( + "SCENE\n" + "SLIDE custom crew card. Stylized-realistic 2.5D urban strategy character, " + "cinematic tropical-noir grade, clean silhouette, transparent-friendly, no scenery.\n\n" + "SUBJECT\n" + f"A fictional playable {role} whose face is the same person as the reference photo. " + "This is a cosmetic game avatar, not a real-world accusation.\n\n" + "IMPORTANT DETAILS\n" + f"Preserve face shape, eye spacing, nose, jaw, skin tone ({skin}), hair color and " + f"hairline ({hair}). {facial} Clothing: {clothing['notes']}. Role readable as {role}. " + f"Camera: {camera}. {notes}\n\n" + "USE CASE\n" + "Crew roster portrait / full-body card / top-down token at phone and desktop sizes.\n\n" + "CONSTRAINTS\n" + "Preserve identity from the reference. No real brand logos, no watermarks, no baked UI " + "text, no real criminal organizations, no minors, no photoreal nude content." + ) + + +def provider_status() -> dict: + requested = (os.environ.get("SLIDE_AVATAR_PROVIDER") or "local").strip().lower() + has_key = bool(os.environ.get("OPENAI_API_KEY")) + model = os.environ.get("SLIDE_AVATAR_MODEL") or "gpt-image-1" + ai_ready = requested == "openai" and has_key + return { + "provider": "openai" if ai_ready else "local_photo_likeness", + "aiConfigured": ai_ready, + "requestedProvider": requested, + "model": model if ai_ready else None, + "requiredForAi": { + "env": ["OPENAI_API_KEY", "SLIDE_AVATAR_PROVIDER=openai"], + "optionalEnv": ["SLIDE_AVATAR_MODEL (default gpt-image-1)"], + "pythonPackage": "openai>=1.40.0", + "notes": ( + "Without those values the server composes a photo-based likeness card " + "from the upload. It will not silently swap in stock role art. " + "AI restyle is optional and only used when it succeeds." + ), + }, + } diff --git a/backend/python/tests/test_avatar_likeness.py b/backend/python/tests/test_avatar_likeness.py new file mode 100644 index 0000000..b73f920 --- /dev/null +++ b/backend/python/tests/test_avatar_likeness.py @@ -0,0 +1,142 @@ +"""Photo-likeness API: consent, no stock swap, source deletion, provider honesty.""" + +import io +import os +import sys + +import pytest +from PIL import Image, ImageDraw + +sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..')) + +from app import create_app +from services.likeness import analyze_photo, compose_member_assets + + +@pytest.fixture +def client(): + app = create_app() + app.config['TESTING'] = True + return app.test_client() + + +def _portrait_png(skin=(181, 131, 90), hair=(18, 16, 14), clothes=(36, 48, 80)) -> bytes: + """Synthetic adult-looking portrait with distinct face / hair / clothing.""" + image = Image.new("RGB", (320, 400), (24, 28, 36)) + draw = ImageDraw.Draw(image) + draw.rectangle((0, 0, 320, 90), fill=hair) + draw.ellipse((70, 70, 250, 280), fill=skin) + draw.rectangle((40, 270, 280, 400), fill=clothes) + draw.ellipse((120, 140, 150, 170), fill=(40, 32, 28)) + draw.ellipse((170, 140, 200, 170), fill=(40, 32, 28)) + buffer = io.BytesIO() + image.save(buffer, format="PNG") + return buffer.getvalue() + + +def _consent_form(): + return { + "role": "dealer", + "style": "south_florida_streetwear", + "outputs": ["portrait", "fullbody", "topdown"], + "consentPermission": "1", + "consentAdult": "1", + "consentProcessing": "1", + } + + +def test_avatar_allows_documented_frontend_dev_origin(client): + response = client.get('/api/avatar/provider', headers={'Origin': 'http://127.0.0.1:3000'}) + assert response.status_code == 200 + assert response.headers.get('Access-Control-Allow-Origin') == 'http://127.0.0.1:3000' + + +def test_provider_endpoint_reports_local_without_key(client, monkeypatch): + monkeypatch.delenv("OPENAI_API_KEY", raising=False) + monkeypatch.delenv("SLIDE_AVATAR_PROVIDER", raising=False) + response = client.get("/api/avatar/provider") + assert response.status_code == 200 + data = response.get_json() + assert data["provider"] == "local_photo_likeness" + assert data["aiConfigured"] is False + assert "OPENAI_API_KEY" in data["requiredForAi"]["env"] + + +def test_generate_requires_adult_and_permission_consent(client): + png = _portrait_png() + response = client.post( + "/api/avatar/generate", + data={ + "role": "dealer", + "image": (io.BytesIO(png), "friend.png", "image/png"), + }, + content_type="multipart/form-data", + ) + assert response.status_code == 400 + assert response.get_json()["code"] == "consent_required" + + +def test_generate_returns_photo_likeness_not_stock(client, tmp_path): + png = _portrait_png(skin=(93, 64, 55), hair=(18, 16, 14), clothes=(48, 28, 72)) + form = _consent_form() + form["image"] = (io.BytesIO(png), "friend.png", "image/png") + response = client.post( + "/api/avatar/generate", + data=form, + content_type="multipart/form-data", + ) + assert response.status_code == 200 + asset = response.get_json()["asset"] + assert asset["provider"] == "local_photo_likeness" + assert asset["stockPreview"] is False + assert asset["sourcePhotoRetained"] is False + assert asset["likenessTraits"]["skinTone"] in { + "chocolate", "espresso", "bronze", "toffee", "caramel", + } + assert asset["portraitUrl"].startswith("/api/avatar/asset/") + assert asset["portraitDataUrl"].startswith("data:image/png;base64,") + + portrait = client.get(asset["portraitUrl"]) + assert portrait.status_code == 200 + assert portrait.mimetype == "image/png" + assert not any( + stock in (asset["portraitUrl"] + asset.get("promptUsed", "")) + for stock in ( + "character_dealer_male_blacktee_portrait", + "/assets/generated/characters/portraits/", + ) + ) + + +def test_source_upload_is_deleted_after_generate(client, monkeypatch): + png = _portrait_png() + form = _consent_form() + form["image"] = (io.BytesIO(png), "friend.png", "image/png") + before = set(os.listdir("/tmp/slide_uploads")) if os.path.isdir("/tmp/slide_uploads") else set() + response = client.post( + "/api/avatar/generate", + data=form, + content_type="multipart/form-data", + ) + assert response.status_code == 200 + after = set(os.listdir("/tmp/slide_uploads")) if os.path.isdir("/tmp/slide_uploads") else set() + assert after <= before + + +def test_analyze_photo_reads_skin_and_hair_from_regions(): + image = Image.open(io.BytesIO(_portrait_png( + skin=(212, 165, 116), + hair=(210, 176, 96), + clothes=(36, 36, 40), + ))) + traits = analyze_photo(image) + assert traits["skinTone"] in {"honey", "sand", "caramel", "ivory"} + assert traits["hairColor"] in {"blonde", "platinum", "brown", "auburn"} + + +def test_compose_outputs_only_requested_kinds(tmp_path): + path = tmp_path / "in.png" + path.write_bytes(_portrait_png()) + composed = compose_member_assets(str(path), "shooter", "luxury_noir", ["portrait"]) + assert set(composed["files"]) == {"portrait"} + assert composed["files"]["portrait"][:8] == b"\x89PNG\r\n\x1a\n" diff --git a/docs/PHOTO_LIKENESS.md b/docs/PHOTO_LIKENESS.md new file mode 100644 index 0000000..27df0b6 --- /dev/null +++ b/docs/PHOTO_LIKENESS.md @@ -0,0 +1,58 @@ +# Photo-to-member likeness + +Players can put a friend on their crew by uploading a photo. The promise is +**the resulting character looks like that person**, not like a stock dealer. + +## What happens now + +1. The player uploads a photo in Contacts → Add → Create from Photo (or Phone). +2. They must check three consents: adult photo, permission from the person, and + processing to make a fictional game character. +3. The backend analyzes the photo (skin tone, hair, facial hair, clothing) and + composites a SLIDE character card that **keeps the uploaded face**. +4. Only generated card URLs are stored on the roster. The source photo is deleted + from the server after the job finishes. + +If the Flask API is down, the browser does the same thing locally with an SVG +card that still embeds the uploaded photo. It will **not** silently swap in +stock role art. + +## Optional AI restyle (not required) + +The default path does not call an image model. To try a stylized 2.5D restyle +that still asks the model to preserve the face: + +``` +# backend/python/.env +OPENAI_API_KEY=sk-... +SLIDE_AVATAR_PROVIDER=openai +SLIDE_AVATAR_MODEL=gpt-image-1 +``` + +Also install the optional client in the backend venv: + +``` +./venv/bin/pip install 'openai>=1.40.0' +``` + +`GET /api/avatar/provider` reports whether that path is live. If the key, +package, or model call fails, generation falls back to the local photo card +and returns `aiProviderError`. It does not invent a different person. + +Owner decisions still needed: + +- Which image vendor (OpenAI vs another) and who pays for it. +- Private durable storage for generated cards (today: in-memory job + data URL + on the local roster). Do not put source photos or public signed URLs in git. +- Whether AI restyle is offered at all for the first public cohort. + +## Safety + +- 18+ only. The UI and API reject the job without adult + permission + + processing consent. +- Cosmetic fictional avatar only. Not a real-world claim or targeting feature. +- Canonical world/rival art stays wholly fictional. +- Do not commit founder photos or generated likenesses to the repository. + +See issue #195 and the fictional-content contract in +`docs/AI_CONTRIBUTOR_START_HERE.md`. diff --git a/frontend/.env.example b/frontend/.env.example index b68878c..d4068e4 100644 --- a/frontend/.env.example +++ b/frontend/.env.example @@ -19,3 +19,7 @@ VITE_ENV=development # loop is playable without credentials. # NEVER set this in a production deployment. # VITE_DEMO_MODE=1 + +# Photo-to-member likeness talks to the Flask backend at VITE_API_URL. +# No frontend AI key is required. Optional OpenAI restyle is backend-only: +# see backend/python/.env.example and docs/PHOTO_LIKENESS.md. diff --git a/frontend/src/components/contacts/Contacts.tsx b/frontend/src/components/contacts/Contacts.tsx index 0d0c66f..caae5e4 100644 --- a/frontend/src/components/contacts/Contacts.tsx +++ b/frontend/src/components/contacts/Contacts.tsx @@ -346,7 +346,7 @@ const Contacts: React.FC = () => { name={contact.nickname || contact.name} role={contact.role} level={(member as any)?.level ?? 1} - avatarUrl={contact.customAvatarUrl ?? contact.avatar} + avatarUrl={contact.customAvatarUrl ?? member?.portraitUrl ?? member?.customAvatarUrl ?? contact.avatar} stats={getRoleCardStats(member as any, contact.role)} statusNote={moraleInfo.warning ? `${moraleInfo.label} — ${moraleInfo.warning}` : moraleInfo.label} onOpen={() => setSelectedContact(contact)} diff --git a/frontend/src/components/gang/PhotoMemberCreator.css b/frontend/src/components/gang/PhotoMemberCreator.css index 720ce38..aa5e041 100644 --- a/frontend/src/components/gang/PhotoMemberCreator.css +++ b/frontend/src/components/gang/PhotoMemberCreator.css @@ -6,10 +6,13 @@ .pmc-container { display: flex; flex-direction: column; - height: 100%; + width: min(520px, 100%); + height: min(92vh, 100%); background: #0a0a0f; font-family: 'SF Mono', 'Fira Code', monospace; overflow: hidden; + border: 1px solid #1e293b; + border-radius: 12px; } /* ── Header ── */ @@ -96,16 +99,44 @@ } /* ── Consent ── */ +.pmc-consent-group { + border: 1px solid #1e293b; + border-radius: 10px; + padding: 10px 12px; + display: flex; + flex-direction: column; + gap: 8px; + margin: 0; +} +.pmc-consent-group legend { + font-size: 10px; + font-weight: 700; + color: #94a3b8; + text-transform: uppercase; + letter-spacing: 0.6px; + padding: 0 4px; +} .pmc-consent { display: flex; align-items: flex-start; gap: 8px; font-size: 10px; - color: #64748b; + color: #94a3b8; cursor: pointer; line-height: 1.4; } .pmc-consent input { margin-top: 2px; accent-color: #4ade80; } +.pmc-consent-note { + margin: 0; + font-size: 10px; + color: #64748b; + line-height: 1.45; +} +.pmc-traits { + margin: 0; + font-size: 10px; + color: #94a3b8; +} /* ── Role grid ── */ .pmc-role-grid { diff --git a/frontend/src/components/gang/PhotoMemberCreator.tsx b/frontend/src/components/gang/PhotoMemberCreator.tsx index 45ce15e..3956c9a 100644 --- a/frontend/src/components/gang/PhotoMemberCreator.tsx +++ b/frontend/src/components/gang/PhotoMemberCreator.tsx @@ -7,9 +7,10 @@ import React, { useState, useCallback, useEffect, useRef } from 'react'; import { motion, AnimatePresence } from 'framer-motion'; import { useGangStore, usePlayerStore } from '../../stores/gameStore'; -import type { AvatarGenerationStatus, GeneratedMemberAsset } from '../../types/avatar.types'; +import type { AvatarGenerationStatus, AvatarProviderStatus, GeneratedMemberAsset } from '../../types/avatar.types'; import type { GangMember } from '../../types/game.types'; import { avatarGenerationService } from '../../services/avatarGeneration.service'; +import { isStockPreview } from '../../services/likenessCompositor'; import './PhotoMemberCreator.css'; // ─── Constants ─────────────────────────────────────────────── @@ -138,7 +139,9 @@ const PhotoMemberCreator: React.FC = ({ onClose, onAppr // Form state const [previewUrl, setPreviewUrl] = useState(null); const [uploadedFile, setUploadedFile] = useState(null); - const [consent, setConsent] = useState(false); + const [consentPermission, setConsentPermission] = useState(false); + const [consentAdult, setConsentAdult] = useState(false); + const [consentProcessing, setConsentProcessing] = useState(false); const [memberName, setMemberName] = useState(''); const [selectedRole, setSelectedRole] = useState('dealer'); const [selectedStyle, setSelectedStyle] = useState('south_florida_streetwear'); @@ -149,6 +152,7 @@ const PhotoMemberCreator: React.FC = ({ onClose, onAppr const [generatedAsset, setGeneratedAsset] = useState(null); const [error, setError] = useState(null); const [approving, setApproving] = useState(false); + const [providerStatus, setProviderStatus] = useState(null); const fileInputRef = useRef(null); const pollRef = useRef | null>(null); @@ -166,6 +170,10 @@ const PhotoMemberCreator: React.FC = ({ onClose, onAppr setError('Please upload an image file.'); return; } + if (file.size > 8 * 1024 * 1024) { + setError('Photo must be 8 MB or smaller.'); + return; + } setUploadedFile(file); // The previous preview URL is revoked by the preview effect below. setPreviewUrl(URL.createObjectURL(file)); @@ -199,9 +207,17 @@ const PhotoMemberCreator: React.FC = ({ onClose, onAppr // finishes / a new job starts), never by replacing the preview photo. useEffect(() => stopPolling, [stopPolling]); + useEffect(() => { + let cancelled = false; + avatarGenerationService.getProviderStatus().then(status => { + if (!cancelled) setProviderStatus(status); + }); + return () => { cancelled = true; }; + }, []); + // ── Generate ── const handleGenerate = useCallback(async () => { - if (!uploadedFile || !consent || !isValidMemberName(memberName) || selectedOutputs.length === 0) return; + if (!uploadedFile || !consentPermission || !consentAdult || !consentProcessing || !isValidMemberName(memberName) || selectedOutputs.length === 0) return; stopPolling(); setStatus('uploading'); setError(null); @@ -212,6 +228,9 @@ const PhotoMemberCreator: React.FC = ({ onClose, onAppr role: selectedRole, style: selectedStyle, outputs: selectedOutputs, + consentPermission, + consentAdult, + consentProcessing, }); setStatus('generating'); @@ -234,7 +253,7 @@ const PhotoMemberCreator: React.FC = ({ onClose, onAppr setError(err.message ?? 'Generation failed. Try again.'); setStatus('failed'); } - }, [uploadedFile, consent, memberName, selectedRole, selectedStyle, selectedOutputs, stopPolling]); + }, [uploadedFile, consentPermission, consentAdult, consentProcessing, memberName, selectedRole, selectedStyle, selectedOutputs, stopPolling]); // ── Approve ── const handleApprove = useCallback(async () => { @@ -273,9 +292,13 @@ const PhotoMemberCreator: React.FC = ({ onClose, onAppr }, [stopPolling]); const nameIsValid = isValidMemberName(memberName); - const canGenerate = uploadedFile && consent && nameIsValid && selectedOutputs.length > 0 && status === 'idle'; + const hasConsent = consentPermission && consentAdult && consentProcessing; + const canGenerate = Boolean(uploadedFile && hasConsent && nameIsValid && selectedOutputs.length > 0 && status === 'idle'); const canApprove = nameIsValid && !approving; - const stockPreview = generatedAsset?.id.startsWith('mock-') ?? false; + const stockPreview = isStockPreview(generatedAsset); + const offlineLikeness = generatedAsset?.provider === 'local_photo_likeness_offline'; + const localLikeness = generatedAsset?.provider === 'local_photo_likeness' || offlineLikeness; + const aiLikeness = generatedAsset?.provider === 'openai'; return (
@@ -313,14 +336,45 @@ const PhotoMemberCreator: React.FC = ({ onClose, onAppr />
- +
+ Consent — required + + + +

+ This creates a cosmetic crew avatar in a fictional game. It is not a claim + about the real person and is not used to target anyone. The original photo + is not saved to the roster — only the generated character art is. +

+ {providerStatus && !providerStatus.aiConfigured && ( +

+ AI restyle is not configured. Generation will keep the face from your photo + on a SLIDE character card. To enable optional AI restyle later, set + OPENAI_API_KEY and SLIDE_AVATAR_PROVIDER=openai on the backend. +

+ )} +
@@ -431,9 +485,29 @@ const PhotoMemberCreator: React.FC = ({ onClose, onAppr initial={{ opacity: 0, y: 20 }} animate={{ opacity: 1, y: 0 }} > -

{stockPreview ? 'Stock Character Preview' : 'Generated Assets'}

+

+ {stockPreview + ? 'Stock Character Preview' + : aiLikeness + ? 'AI-stylized likeness' + : 'Photo-based likeness'} +

{stockPreview && ( -

Generation is offline. This preview uses stock character art.

+

Generation could not use this photo. This preview is stock character art, not your friend.

+ )} + {!stockPreview && localLikeness && ( +

+ {offlineLikeness + ? 'Backend unavailable. This card uses the uploaded photo as the face — not stock art.' + : 'This card keeps the face from your photo. Optional AI restyle is not configured.'} +

+ )} + {generatedAsset?.likenessTraits && !stockPreview && ( +

+ Read from photo: {generatedAsset.likenessTraits.skinTone} skin, + {' '}{generatedAsset.likenessTraits.hairColor} hair, + {' '}facial hair {generatedAsset.likenessTraits.facialHair.split('_').join(' ')}. +

)}
{generatedAsset.portraitUrl && ( diff --git a/frontend/src/components/gang/__tests__/PhotoMemberCreator.test.tsx b/frontend/src/components/gang/__tests__/PhotoMemberCreator.test.tsx index 8ace116..e19f057 100644 --- a/frontend/src/components/gang/__tests__/PhotoMemberCreator.test.tsx +++ b/frontend/src/components/gang/__tests__/PhotoMemberCreator.test.tsx @@ -11,6 +11,11 @@ vi.mock('../../../services/avatarGeneration.service', () => ({ generate: vi.fn(), getStatus: vi.fn(), approve: vi.fn(), + getProviderStatus: vi.fn().mockResolvedValue({ + provider: 'local_photo_likeness', + aiConfigured: false, + requiredForAi: { env: ['OPENAI_API_KEY'], pythonPackage: 'openai', notes: '' }, + }), }, })); @@ -56,7 +61,9 @@ function upload(container: HTMLElement, file = photo()) { } function consent() { - fireEvent.click(screen.getByRole('checkbox')); + fireEvent.click(screen.getByRole('checkbox', { name: /this photo is of an adult/i })); + fireEvent.click(screen.getByRole('checkbox', { name: /permission from the person/i })); + fireEvent.click(screen.getByRole('checkbox', { name: /consent to processing this image/i })); } function typeName(value: string) { @@ -86,19 +93,58 @@ describe('PhotoMemberCreator', () => { }); it('identifies stock offline previews without claiming the photo was generated', async () => { - service.generate.mockResolvedValue(asset({ id: 'mock-offline' })); + service.generate.mockResolvedValue(asset({ id: 'mock-offline', stockPreview: true })); const { container } = render(); upload(container); consent(); typeName('Jay'); await act(async () => { fireEvent.click(generateButton()); }); - expect(screen.getByRole('status')).toHaveTextContent(/generation is offline/i); expect(screen.getByRole('status')).toHaveTextContent(/stock character art/i); - expect(screen.queryByText('Generated Assets')).not.toBeInTheDocument(); + expect(screen.getByRole('status')).toHaveTextContent(/not your friend/i); + expect(screen.queryByText('Photo-based likeness')).not.toBeInTheDocument(); expect(screen.getByRole('button', { name: /add to roster/i })).toBeEnabled(); }); + it('labels a photo-based likeness instead of stock art', async () => { + service.generate.mockResolvedValue(asset({ + id: 'likeness-1', + provider: 'local_photo_likeness', + stockPreview: false, + likenessTraits: { + skinTone: 'caramel', + hairColor: 'black', + facialHair: 'none_or_light', + clothingColors: [], + notes: [], + }, + })); + const { container } = render(); + upload(container); + consent(); + typeName('Jay'); + await act(async () => { fireEvent.click(generateButton()); }); + + expect(screen.getByText('Photo-based likeness')).toBeInTheDocument(); + expect(screen.getByRole('status')).toHaveTextContent(/keeps the face from your photo/i); + expect(screen.getByText(/caramel skin/i)).toBeInTheDocument(); + expect(screen.queryByText(/stock character art/i)).not.toBeInTheDocument(); + }); + + it('does not generate until adult, permission, and processing consents are given', async () => { + const { container } = render(); + await act(async () => { await Promise.resolve(); }); + upload(container); + typeName('Jay'); + expect(generateButton()).toBeDisabled(); + fireEvent.click(screen.getByRole('checkbox', { name: /this photo is of an adult/i })); + expect(generateButton()).toBeDisabled(); + fireEvent.click(screen.getByRole('checkbox', { name: /permission from the person/i })); + expect(generateButton()).toBeDisabled(); + fireEvent.click(screen.getByRole('checkbox', { name: /consent to processing this image/i })); + expect(generateButton()).not.toBeDisabled(); + }); + it('starts generation when the name is typed after the photo and consent', async () => { service.generate.mockResolvedValue(asset()); const { container } = render(); diff --git a/frontend/src/services/__tests__/avatarGeneration.service.test.ts b/frontend/src/services/__tests__/avatarGeneration.service.test.ts index 65f71e9..5b31fc0 100644 --- a/frontend/src/services/__tests__/avatarGeneration.service.test.ts +++ b/frontend/src/services/__tests__/avatarGeneration.service.test.ts @@ -2,35 +2,79 @@ import { existsSync } from 'node:fs'; import { resolve } from 'node:path'; import { afterEach, describe, expect, it, vi } from 'vitest'; import { avatarGenerationService } from '../avatarGeneration.service'; +import { isStockPreview } from '../likenessCompositor'; afterEach(() => vi.unstubAllGlobals()); -describe('offline photo preview', () => { - it('returns loadable registered stock art for every recruit role', async () => { +const consents = { + consentPermission: true, + consentAdult: true, + consentProcessing: true, +}; + +describe('offline photo likeness', () => { + it('uses the uploaded photo instead of stock role art when the backend is down', async () => { vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new TypeError('offline'))); for (const role of ['dealer', 'shooter', 'driver', 'enforcer', 'lookout', 'runner', 'chemist'] as const) { const asset = await avatarGenerationService.generate({ - imageFile: new File(['source'], 'fictional.webp', { type: 'image/webp' }), + imageFile: new File(['source'], 'friend.webp', { type: 'image/webp' }), role, style: 'south_florida_streetwear', outputs: ['portrait', 'fullbody', 'topdown'], + ...consents, }); - expect(asset.id).toMatch(/^mock-/); + expect(asset.id).toMatch(/^likeness-local-/); + expect(asset.provider).toBe('local_photo_likeness_offline'); + expect(asset.stockPreview).toBe(false); + expect(isStockPreview(asset)).toBe(false); for (const url of [asset.portraitUrl, asset.fullbodyUrl, asset.topdownUrl]) { - expect(url).toMatch(/^\/assets\/runtime\//); - expect(existsSync(resolve('public', `.${url}`))).toBe(true); + expect(url).toMatch(/^data:image\/svg\+xml/); + expect(url).not.toMatch(/^\/assets\/runtime\//); + expect(existsSync(resolve('public', `.${url ?? ''}`))).toBe(false); } } }); - it('keeps unrequested outputs absent in the offline preview', async () => { + it('keeps unrequested outputs absent in the offline likeness', async () => { vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new TypeError('offline'))); const asset = await avatarGenerationService.generate({ - imageFile: new File(['source'], 'fictional.webp', { type: 'image/webp' }), + imageFile: new File(['source'], 'friend.webp', { type: 'image/webp' }), role: 'dealer', style: 'luxury_noir', outputs: ['portrait'], + ...consents, }); expect(asset.portraitUrl).toBeTruthy(); expect(asset.fullbodyUrl).toBeUndefined(); expect(asset.topdownUrl).toBeUndefined(); }); + + it('keeps inline portrait bytes after approve so roster cards do not 404', async () => { + vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ + id: 'job-1', + role: 'dealer', + style: 'south_florida_streetwear', + outputs: ['portrait'], + status: 'approved', + createdAt: new Date(0).toISOString(), + portraitUrl: '/api/avatar/asset/job-1/portrait', + portraitDataUrl: 'data:image/png;base64,abc', + }), + text: async () => '', + })); + const approved = await avatarGenerationService.approve('job-1'); + expect(approved.portraitUrl).toBe('data:image/png;base64,abc'); + }); + + it('refuses to generate without the three consent flags', async () => { + await expect(avatarGenerationService.generate({ + imageFile: new File(['source'], 'friend.webp', { type: 'image/webp' }), + role: 'dealer', + style: 'south_florida_streetwear', + outputs: ['portrait'], + consentPermission: false, + consentAdult: true, + consentProcessing: true, + })).rejects.toThrow(/consent/i); + }); }); diff --git a/frontend/src/services/__tests__/likenessCompositor.test.ts b/frontend/src/services/__tests__/likenessCompositor.test.ts new file mode 100644 index 0000000..625955f --- /dev/null +++ b/frontend/src/services/__tests__/likenessCompositor.test.ts @@ -0,0 +1,36 @@ +import { describe, expect, it } from 'vitest'; +import { composeLocalLikeness, isStockPreview } from '../likenessCompositor'; +import type { GeneratedMemberAsset } from '../../types/avatar.types'; + +describe('likenessCompositor', () => { + it('embeds the uploaded photo in every requested card', async () => { + const file = new File(['face-bytes'], 'friend.png', { type: 'image/png' }); + const asset = await composeLocalLikeness({ + imageFile: file, + role: 'shooter', + style: 'masked_tactical', + outputs: ['portrait', 'fullbody', 'topdown'], + consentPermission: true, + consentAdult: true, + consentProcessing: true, + }); + const payload = 'ZmFjZS1ieXRlcw'; // base64('face-bytes') + expect(asset.portraitUrl).toContain(payload); + expect(asset.fullbodyUrl).toContain(payload); + expect(asset.topdownUrl).toContain(payload); + expect(isStockPreview(asset)).toBe(false); + }); + + it('treats runtime stock paths as a stock preview', () => { + const asset: GeneratedMemberAsset = { + id: 'x', + role: 'dealer', + style: 'south_florida_streetwear', + outputs: ['portrait'], + portraitUrl: '/assets/runtime/characters/portraits/character_dealer.png', + status: 'ready', + createdAt: new Date(0).toISOString(), + }; + expect(isStockPreview(asset)).toBe(true); + }); +}); diff --git a/frontend/src/services/avatarGeneration.service.ts b/frontend/src/services/avatarGeneration.service.ts index 95911b9..c72dca7 100644 --- a/frontend/src/services/avatarGeneration.service.ts +++ b/frontend/src/services/avatarGeneration.service.ts @@ -1,38 +1,17 @@ // ============================================================ // avatarGeneration.service.ts -// Frontend service for the photo-to-member generation pipeline. -// Calls backend /api/avatar/* endpoints. -// Falls back to mock data when backend is unavailable. -// Sprint: morale-heat-photo-batch2 +// Photo-to-member generation. Local photo likeness is the honest +// fallback. Stock role art is never returned as a generated face. // ============================================================ import type { AvatarGenerateRequest, + AvatarProviderStatus, GeneratedMemberAsset, } from '../types/avatar.types'; -import { getCharacterForRole } from './assetResolver'; +import { composeLocalLikeness } from './likenessCompositor'; -const API_BASE: string = (typeof import.meta !== 'undefined' ? (import.meta as any).env?.VITE_API_URL : undefined) ?? 'http://localhost:5000'; - -// Offline previews use the existing registered character kits. -function buildMockAsset(req: AvatarGenerateRequest): GeneratedMemberAsset { - const role = req.role; - const stock = getCharacterForRole(role); - return { - id: `mock-${Date.now()}`, - role, - style: req.style, - outputs: req.outputs, - portraitUrl: req.outputs.includes('portrait') ? stock.portrait : undefined, - fullbodyUrl: req.outputs.includes('fullbody') ? stock.fullbody : undefined, - topdownUrl: req.outputs.includes('topdown') ? stock.topdown : undefined, - promptUsed: `Mock prompt for ${role} in ${req.style} style.`, - status: 'ready', - createdAt: new Date().toISOString(), - }; -} - -// ─── Service ───────────────────────────────────────────────── +const API_BASE: string = (typeof import.meta !== 'undefined' ? (import.meta as { env?: { VITE_API_URL?: string } }).env?.VITE_API_URL : undefined) ?? 'http://localhost:5000'; class AvatarGenerationService { private async post(path: string, body: FormData | object): Promise { @@ -52,54 +31,62 @@ class AvatarGenerationService { return res.json(); } - /** - * Submit a generation job. - * Falls back to mock if the backend is unreachable. - */ + async getProviderStatus(): Promise { + try { + return await this.get('/api/avatar/provider'); + } catch { + return null; + } + } + + private preferInlineUrls(asset: GeneratedMemberAsset): GeneratedMemberAsset { + const resolve = (dataUrl: string | undefined, url: string | undefined) => { + if (dataUrl) return dataUrl; + if (url?.startsWith('/api/')) return `${API_BASE}${url}`; + return url; + }; + return { + ...asset, + portraitUrl: resolve(asset.portraitDataUrl, asset.portraitUrl), + fullbodyUrl: resolve(asset.fullbodyDataUrl, asset.fullbodyUrl), + topdownUrl: resolve(asset.topdownDataUrl, asset.topdownUrl), + }; + } + async generate(req: AvatarGenerateRequest): Promise { + if (!req.consentPermission || !req.consentAdult || !req.consentProcessing) { + throw new Error('Consent is required before generating a member from a photo.'); + } try { const form = new FormData(); form.append('image', req.imageFile); form.append('role', req.role); form.append('style', req.style); + form.append('consentPermission', req.consentPermission ? '1' : '0'); + form.append('consentAdult', req.consentAdult ? '1' : '0'); + form.append('consentProcessing', req.consentProcessing ? '1' : '0'); req.outputs.forEach(o => form.append('outputs', o)); const data = await this.post<{ asset: GeneratedMemberAsset }>('/api/avatar/generate', form); - return data.asset; - } catch { - // Backend offline — return mock immediately - console.warn('[AvatarService] Backend unavailable, using mock assets.'); - return buildMockAsset(req); + return this.preferInlineUrls(data.asset); + } catch (error) { + if (error instanceof Error && error.message.startsWith('Consent is required')) { + throw error; + } + return composeLocalLikeness(req); } } - /** - * Poll generation status. - */ async getStatus(jobId: string): Promise { - try { - return await this.get(`/api/avatar/status/${jobId}`); - } catch { - // Return a mock ready state - return { - id: jobId, - role: 'dealer', - style: 'south_florida_streetwear', - outputs: ['portrait'], - status: 'ready', - createdAt: new Date().toISOString(), - }; - } + const job = await this.get(`/api/avatar/status/${jobId}`); + return this.preferInlineUrls(job); } - /** - * Approve a generated asset and save it to the roster. - */ async approve(assetId: string): Promise { try { - return await this.post('/api/avatar/approve', { assetId }); + const approved = await this.post('/api/avatar/approve', { assetId }); + return this.preferInlineUrls(approved); } catch { - // Mock approval — just return the asset as-is return { id: assetId, role: 'dealer', diff --git a/frontend/src/services/likenessCompositor.ts b/frontend/src/services/likenessCompositor.ts new file mode 100644 index 0000000..ced5969 --- /dev/null +++ b/frontend/src/services/likenessCompositor.ts @@ -0,0 +1,128 @@ +// Local photo-likeness compositor used when the backend is unreachable. +// It never substitutes stock role art. The uploaded photo is the identity. + +import type { + AvatarGenerateRequest, + AvatarOutput, + GeneratedMemberAsset, +} from '../types/avatar.types'; + +const ROLE_COLOR: Record = { + dealer: '#4ade80', + shooter: '#f87171', + driver: '#60a5fa', + enforcer: '#fb923c', + lookout: '#a78bfa', + runner: '#facc15', + chemist: '#2dd4bf', +}; + +function readFileAsDataUrl(file: File): Promise { + return new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.onerror = () => reject(new Error('Could not read the photo.')); + reader.onload = () => { + if (typeof reader.result === 'string') resolve(reader.result); + else reject(new Error('Could not read the photo.')); + }; + reader.readAsDataURL(file); + }); +} + +function svgDataUrl(markup: string): string { + return `data:image/svg+xml;utf8,${encodeURIComponent(markup)}`; +} + +function escapeXml(value: string): string { + return value + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"'); +} + +function portraitCard(photo: string, role: string): string { + const accent = ROLE_COLOR[role] ?? '#4ade80'; + return svgDataUrl(` + + + + + + + + + + +`); +} + +function fullbodyCard(photo: string, role: string, style: string): string { + const accent = ROLE_COLOR[role] ?? '#4ade80'; + return svgDataUrl(` + + + + + + + + + + ${escapeXml(role)} · ${escapeXml(style.split('_').join(' '))} +`); +} + +function topdownToken(photo: string, role: string): string { + const accent = ROLE_COLOR[role] ?? '#4ade80'; + return svgDataUrl(` + + + +`); +} + +export async function composeLocalLikeness( + req: AvatarGenerateRequest, +): Promise { + const photo = await readFileAsDataUrl(req.imageFile); + const outputs = req.outputs; + const asset: GeneratedMemberAsset = { + id: `likeness-local-${Date.now()}`, + role: req.role, + style: req.style, + outputs, + status: 'ready', + createdAt: new Date().toISOString(), + provider: 'local_photo_likeness_offline', + stockPreview: false, + sourcePhotoRetained: false, + likenessTraits: { + skinTone: 'from_photo', + hairColor: 'from_photo', + facialHair: 'from_photo', + clothingColors: [], + notes: ['Offline compositor used the uploaded photo as identity.'], + }, + promptUsed: 'Local photo-based likeness. Backend unavailable; no stock role art.', + }; + + const assign = (output: AvatarOutput, url: string) => { + if (output === 'portrait') asset.portraitUrl = url; + if (output === 'fullbody') asset.fullbodyUrl = url; + if (output === 'topdown') asset.topdownUrl = url; + }; + + if (outputs.includes('portrait')) assign('portrait', portraitCard(photo, req.role)); + if (outputs.includes('fullbody')) assign('fullbody', fullbodyCard(photo, req.role, req.style)); + if (outputs.includes('topdown')) assign('topdown', topdownToken(photo, req.role)); + return asset; +} + +export function isStockPreview(asset: GeneratedMemberAsset | null | undefined): boolean { + if (!asset) return false; + if (asset.stockPreview) return true; + if (asset.id.startsWith('mock-')) return true; + const urls = [asset.portraitUrl, asset.fullbodyUrl, asset.topdownUrl].filter(Boolean) as string[]; + return urls.some(url => url.startsWith('/assets/runtime/') || url.includes('/assets/generated/characters/')); +} diff --git a/frontend/src/types/avatar.types.ts b/frontend/src/types/avatar.types.ts index 32a8940..c3c707d 100644 --- a/frontend/src/types/avatar.types.ts +++ b/frontend/src/types/avatar.types.ts @@ -1,6 +1,5 @@ // ============================================================ // avatar.types.ts — Photo-to-member generation pipeline types -// Sprint: morale-heat-photo-batch2 // ============================================================ export type AvatarGenerationStatus = @@ -30,6 +29,20 @@ export type AvatarStyle = export type AvatarOutput = 'portrait' | 'fullbody' | 'topdown'; +export type AvatarProviderId = + | 'local_photo_likeness' + | 'local_photo_likeness_offline' + | 'openai' + | 'unconfigured'; + +export interface LikenessTraits { + skinTone: string; + hairColor: string; + facialHair: string; + clothingColors: string[]; + notes: string[]; +} + export interface GeneratedMemberAsset { id: string; sourceImageUrl?: string; @@ -39,9 +52,17 @@ export interface GeneratedMemberAsset { portraitUrl?: string; fullbodyUrl?: string; topdownUrl?: string; + portraitDataUrl?: string; + fullbodyDataUrl?: string; + topdownDataUrl?: string; promptUsed?: string; status: AvatarGenerationStatus; createdAt: string; + provider?: AvatarProviderId; + likenessTraits?: LikenessTraits; + sourcePhotoRetained?: boolean; + stockPreview?: boolean; + aiProviderError?: string; } export interface AvatarGenerateRequest { @@ -49,9 +70,25 @@ export interface AvatarGenerateRequest { role: AvatarRole; style: AvatarStyle; outputs: AvatarOutput[]; + consentPermission: boolean; + consentAdult: boolean; + consentProcessing: boolean; } export interface AvatarGenerateResponse { jobId: string; asset: GeneratedMemberAsset; } + +export interface AvatarProviderStatus { + provider: AvatarProviderId; + aiConfigured: boolean; + requestedProvider?: string; + model?: string | null; + requiredForAi: { + env: string[]; + optionalEnv?: string[]; + pythonPackage: string; + notes: string; + }; +} From 64857a4696cf12ff9f275c24ae7908bf7520d310 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 17 Oct 2026 16:18:44 +0000 Subject: [PATCH 3/4] feat: use GPT Image for GTA-style likeness, lock bodies without a key Owner rejected vector/clip-art crew bodies. GPT Image is now the only likeness provider, enabled by OPENAI_API_KEY alone. Without the key the roster shows the real photo crop and labels the body locked. Stick-figure full-body compositors are gone from player-facing paths. Adds a stack assessment to LAUNCH_READINESS.md and a Manus handoff in COLLABORATION.md. Co-authored-by: BrandDead --- COLLABORATION.md | 197 +++++++++++++++ LAUNCH_READINESS.md | 54 +++- backend/python/.env.example | 14 +- backend/python/api/avatar.py | 162 +++++++----- backend/python/services/likeness.py | 235 ++++++------------ backend/python/tests/test_avatar_likeness.py | 102 +++++++- docs/PHOTO_LIKENESS.md | 94 ++++--- docs/PROJECT_LOG.md | 8 + frontend/.env.example | 6 +- .../components/gang/PhotoMemberCreator.css | 33 +++ .../components/gang/PhotoMemberCreator.tsx | 45 ++-- .../__tests__/PhotoMemberCreator.test.tsx | 69 ++++- .../avatarGeneration.service.test.ts | 18 +- .../__tests__/likenessCompositor.test.ts | 9 +- .../src/services/avatarGeneration.service.ts | 5 +- frontend/src/services/likenessCompositor.ts | 54 +--- frontend/src/types/avatar.types.ts | 10 +- 17 files changed, 752 insertions(+), 363 deletions(-) create mode 100644 COLLABORATION.md diff --git a/COLLABORATION.md b/COLLABORATION.md new file mode 100644 index 0000000..b31adc4 --- /dev/null +++ b/COLLABORATION.md @@ -0,0 +1,197 @@ +# Collaboration handoff (for Manus and other agents) + +Read this after `AGENTS.md`, `docs/AI_CONTRIBUTOR_START_HERE.md`, and +`docs/PROJECT_LOG.md`. This file is the current-state map so another agent +can take work **without colliding PR #210**. + +**Default branch:** `main-tL2525` +**This branch / PR:** `cursor/launch-readiness-likeness-e7d0` → draft **#210** +**Do not merge PRs, apply production migrations, or rotate secrets.** + +--- + +## Current product state + +SLIDE / DEALT is an 18+ fictional urban strategy game presented as an +iOS-style command desktop. The unique hook is a **saved block + friends +on the crew**. It is **not** a public multiplayer FPS. + +| Surface | State | +|---|---| +| Demo path | `VITE_DEMO_MODE=1` bypasses Supabase and seeds Las Olas | +| Strip / Block DNA | Playable. 40 fictional cards. Street map is optional context | +| Save / return session | Code exists. **#175 proof is still the P0 launch blocker** | +| Ghost crews | NPC pressure exists. Live PvP does not | +| Photo recruit | Consent + photo crop shipped on #210. GPT Image wired, **key not set** | +| Combat | Several web toys (DRIVE, SLIDE, UnifiedEncounter). Not HW2-class | + +Honest design take: GTA-quality characters **and** Hood Warfare 2 gunplay +will keep fighting this React/Flask/canvas stack. Recommendation is in +`LAUNCH_READINESS.md` — illustrated GTA presentation for the web beta; +UE5 one-fight only if shooting is the public promise. + +--- + +## Art direction (locked) + +- **World:** cinematic 2.5D tactical diorama. See `.agents/skills/slide-visual-art-direction`. +- **Crew likeness:** GTA V loading-screen / cover-art illustrated realism. + Believable adult proportions and clothing. Same look across portrait, + full-body, and in-game states. +- **Forbidden in player-facing views:** South Park, clip-art, vector + characters, stick-figure bodies, stock role art presented as a friend’s face. +- **Vendor:** OpenAI GPT Image only (`gpt-image-2` → `gpt-image-1.5` → + `gpt-image-1`). Enable with `OPENAI_API_KEY`. Steps: + `docs/PHOTO_LIKENESS.md`. +- **No key:** real photo crop on the roster; body labeled locked. +- Fictional content only. Personal-crew photos need adult + permission + + processing consent. Never persist the source photo on the roster. + Never commit founder/real-person photos or generated likenesses. + +--- + +## How to run + +Backend is offline-friendly. Keep `SUPABASE_URL` and `DATABASE_URL` +**blank** in `backend/python/.env` or pytest and `/api/*` break. + +```bash +# Backend +cd backend/python +./venv/bin/python app.py # :5000 + +# Frontend (deterministic demo) +cd frontend +VITE_DEMO_MODE=1 npm run dev -- --host 0.0.0.0 # :3000 +``` + +Optional local Supabase (auth UI, not required for demo): + +```bash +sudo dockerd & +sudo chmod 666 /var/run/docker.sock +cd ~/supabase-local && supabase start -x studio,imgproxy,storage-api,realtime,edge-runtime,logflare,vector,supavisor,mailpit +``` + +Then set `VITE_SUPABASE_URL=http://127.0.0.1:54321` and the printed +`ANON_KEY` in `frontend/.env`. Restart Vite after any `VITE_*` change. + +GPT Image (owner will provide the key; not set now): + +```bash +# backend/python/.env +OPENAI_API_KEY=sk-... +# optional: SLIDE_AVATAR_MODEL=gpt-image-2 +./venv/bin/pip install 'openai>=1.40.0' +# restart Flask +``` + +--- + +## How to test + +Always run the focused test for the files you touched, then the matching +suite. Skills: `$slide-release-safety` for gameplay/code; +`$slide-visual-art-direction` then `$slide-asset-integration-qa` for art. + +```bash +# Backend +cd backend/python +./venv/bin/python -m pytest tests/test_avatar_likeness.py -q +./venv/bin/python -m pytest -q + +# Frontend +cd frontend +./node_modules/.bin/vitest run src/services/__tests__/likenessCompositor.test.ts \ + src/services/__tests__/avatarGeneration.service.test.ts \ + src/components/gang/__tests__/PhotoMemberCreator.test.tsx +npm run typecheck +# Prefer the local binary if `npx vitest` hits npm store errors. +``` + +`npm run lint` has known pre-existing `no-var-requires` errors; do not +“fix” unrelated lint as part of a gameplay PR. + +Player-path check: 390×844 and 1440×900, happy path + one failure. +MapLibre failing must leave Strip / recon / claims / crew placement usable. + +--- + +## Open work that matters + +| ID | State | What | Safe for Manus? | +|---|---|---|---| +| **#175** | OPEN | Authenticated saved-game proof on isolated staging. P0 launch blocker. | Yes — highest-value. Do not replay migration 007, do not touch production, do not add a second writer. | +| **#192** | OPEN | Desktop ATTACK / OPS Planner bypass the canonical encounter. PR #204 is already on this. | Only if #204 is abandoned or you take review findings. Do not open a parallel ATTACK PR. | +| **#201** | OPEN | DRIVE shots/rewards must bind to passenger shooters. PR #202 is already on this. | Same rule: do not fork a second DRIVE attribution PR. | +| **#163** | CLOSED | One Ghost Crew attack on the Strip. Follow-up is still “make it visible and playable” via UnifiedEncounter; do not recreate a second combat authority. | Yes, as a **new** focused issue/PR if the live Strip still cannot show one ghost hit. Check main first. | +| **#210** | OPEN (this PR) | Launch-readiness docs + GPT Image likeness + no vector bodies. | **No. Do not edit these files.** | + +Related open PRs to avoid colliding: **#169** (`visualProfile` / `game.types.ts`), +**#206** (Flask session headers), **#207** (encounter session owner), +**#209** (inventory transfer), **#205** (Nightfall tactical art). + +--- + +## Files reserved by PR #210 — do not edit + +Stay off this list until #210 merges or the integration owner reassigns it: + +- `backend/python/api/avatar.py` +- `backend/python/services/likeness.py` +- `backend/python/tests/test_avatar_likeness.py` +- `backend/python/.env.example` (likeness block only) +- `frontend/src/types/avatar.types.ts` +- `frontend/src/services/avatarGeneration.service.ts` +- `frontend/src/services/likenessCompositor.ts` +- `frontend/src/services/__tests__/avatarGeneration.service.test.ts` +- `frontend/src/services/__tests__/likenessCompositor.test.ts` +- `frontend/src/components/gang/PhotoMemberCreator.tsx` +- `frontend/src/components/gang/PhotoMemberCreator.css` +- `frontend/src/components/gang/__tests__/PhotoMemberCreator.test.tsx` +- `frontend/.env.example` (likeness comment only) +- `LAUNCH_READINESS.md` +- `docs/PHOTO_LIKENESS.md` +- `COLLABORATION.md` (this file — extend it after merge, do not rewrite mid-PR) +- `docs/PROJECT_LOG.md` — **append only**; do not rewrite the 2026-10-17 likeness entries + +`frontend/src/components/contacts/Contacts.tsx` has a one-line +`portraitUrl` fallback from this work. Prefer not to touch avatar URL +resolution there until #210 merges. **Do not edit** `game.types.ts` +(PR #169 owns `visualProfile`). + +--- + +## Safe areas to take without conflicting #210 + +Good first picks, each as its own branch off latest `origin/main-tL2525`: + +1. **#175 saved-game proof** — staging-only runbook, two-user isolation, + returning-player loop. Highest launch value. +2. **First-run five-minute loop** — hide Casino / Crush / extra apps behind + More; teach claim → place → deal → one defense. Avoid photo-creator files. +3. **Fictional onboarding presets** — rename `crip` / Yakuza / Triad-style + labels. `LAUNCH_READINESS.md` lists this cut; keep it a tiny PR. +4. **#163 follow-up** — one visible ghost hit on the Strip through the + existing UnifiedEncounter, if main still lacks it. Reserve renderer/store + files on the issue first. +5. **Private generated-asset storage design** (design-only PR) — #210 still + uses in-memory jobs + data URLs. Do not implement a bucket on this branch. + +Branch naming for Cursor agents: `cursor/-e7d0`. +Everyone else: `feature/-short-name`. One focused PR. Do not merge. + +--- + +## Skills and contracts + +| Skill | When | +|---|---| +| `$slide-release-safety` | Any gameplay or shared-behavior change | +| `$slide-visual-art-direction` | Any image generation | +| `$slide-asset-integration-qa` | Any asset that ships in the game | +| `$slide-game-critic` | After a real player-path capture | + +Non-negotiables: one authoritative state path; Block DNA `dnaId` is +stable; map failure must not block the Strip; no production Supabase +work unless the task names a confirmed non-production target. diff --git a/LAUNCH_READINESS.md b/LAUNCH_READINESS.md index 72a7ee3..17447f5 100644 --- a/LAUNCH_READINESS.md +++ b/LAUNCH_READINESS.md @@ -41,7 +41,7 @@ Verified in this environment unless marked otherwise. | Frontend unit tests (focused likeness + creator) | See PR verification | New photo-likeness contract | | Backend likeness tests | See PR verification | Consent, no stock swap, source deleted | | Avatar blueprint | Now registered on Flask | Previously written but **not mounted** — `/api/avatar/*` 404’d, so the client always fell back | -| Photo recruit UI | Contacts + Phone | Consent, name, role, style, outputs, add-to-roster. Live demo at 390×844: Generate stays disabled without consent; result is labeled photo-based likeness; Jay appears on the CREW card with the uploaded face. | +| Photo recruit UI | Contacts + Phone | Consent, name, role, style, outputs, add-to-roster. No key: real photo crop + body locked. GPT Image (when `OPENAI_API_KEY` is set) is the only body generator. | | Local API CORS | Flask default now allows :3000 and :5173 | Previously the documented Vite port 3000 was blocked, so the browser always took the offline path | ### Product surfaces that exist and are playable in demo mode @@ -166,7 +166,7 @@ Cut means **remove from the first-run home screen and marketing**, not delete fr Isolated staging only. Age gate → sign up → gang → starter block → deal → defense → reload → second session. No new writer. No production migrate. 3. **Ship photo friends as a real feature (#195, this PR).** - Local likeness cards now keep the uploaded face. Owner still chooses an AI vendor and private asset storage. Do not advertise “AI clones your friends” until a key is configured and privately QA’d. + Vendor is decided: GPT Image. Until `OPENAI_API_KEY` is set, the roster shows the real photo crop and the body stays locked. Do not advertise “AI clones your friends” until the key is configured and privately QA’d. 4. **First-run in five minutes.** Replace the app-tour tutorial with: name crew → claim Strip → assign dealer + shooter → one deal → one defense → hospital/bail if needed. Hide Casino/Crush/etc. behind an “More” folder. @@ -187,16 +187,48 @@ That is more GTA than Hood Warfare 2 — **if** the empire is saved. If it is no ## Photo-likeness state (this run) -| | Before | After | +Owner rejected the first likeness pass as South Park / clip-art / vector. That look is now treated as a defect, not a fallback. + +| | Before this PR | Now | |---|---|---| -| Backend route | `api/avatar.py` existed, **not registered** in `app.py` | Registered. `GET /api/avatar/provider` reports config. | -| No API key | `MockAvatarProvider` returned stock dealer/enforcer/lookout art | Local compositor keeps the uploaded face on portrait / full-body / token cards | -| Backend down | Frontend catch → stock `/assets/runtime/` portraits labeled “stock” | Frontend compositor embeds the photo; labeled “photo-based likeness” | -| Consent | One checkbox | Adult + permission + processing; API rejects without them | -| Source photo | Temp file deleted; roster stored URLs | Unchanged contract, now tested: source deleted, roster gets cards only | -| AI | DALL·E 2 stub if `OPENAI_API_KEY` happened to be set | Still optional. Needs key + `SLIDE_AVATAR_PROVIDER=openai` + `openai` package. Failures fall back to the photo card, not stock. | +| Backend route | `api/avatar.py` existed, **not registered** | Registered. `GET /api/avatar/provider` reports config. | +| No API key | Stock role art, then a stick-figure full-body | Real photo crop on the roster. Body labeled **locked**. | +| Backend down | Stock `/assets/runtime/` portraits | Browser photo crop. No vector body. | +| Consent | One checkbox, then three | Adult + permission + processing; API rejects without them | +| Source photo | Temp file deleted | Unchanged and tested. Roster never stores the upload. | +| AI vendor | Undecided; DALL·E 2 stub if a key happened to be set | **GPT Image only.** `OPENAI_API_KEY` enables it. Model order: `gpt-image-2` → `gpt-image-1.5` → `gpt-image-1`. DALL·E is never called. Failures return the photo crop + `aiProviderError`, never a stick figure. | + +Key is **not set yet**. Enable steps: `docs/PHOTO_LIKENESS.md`. + +--- + +## GTA-quality characters and gunplay — honest stack assessment + +**Plain answer:** yes — chasing GTA V cover-art characters **and** Hood Warfare 2 gunplay on the current web stack will be a constant struggle. The stack can ship a sharp command desktop and a truthful photo-to-crew hook. It cannot, without a new combat runtime, look and shoot like a packaged UE5 FPS. + +### Why this stack fights the target + +- **Characters.** GPT Image can make a still that looks like a GTA loading screen. It cannot, by itself, produce a consistent 3D person across idle / aim / fire / hit / downed, or bind that person to a shooter skeleton. Pillow and SVG compositors already proved the failure mode: a real face glued onto a vector body. That path is closed. +- **Gunplay.** Live combat is React + canvas + Phaser + a Babylon experiment. Hood Warfare 2 is Unreal Engine 5. Recoil, hit detection, animation, and netcode will not catch that product by polishing `DriveByEngine`. +- **World.** The 2.5D tactical diorama is the one visual contract that already fits a web command desktop. Mixing photoreal UE characters into Phaser sprites will look like two games. + +### Alternative designs + +| Option | What it is | What it wins | What it costs | +|---|---|---|---| +| **A. React empire + one UE5 fight** | Keep the web command desktop (crew, blocks, cash, photo recruit). Ship **one** packaged fight from `unreal/DealtSlideShowdown` (or a new UE5 slice) for the actual shooting. | Honest HW2-class gunplay without rewriting the empire. | Two runtimes, install/store story, and a hard seam between desk and fight. | +| **B. Babylon web fight** | Finish one first-person / over-shoulder fight in the existing Babylon route. Empire stays React. | One web URL, no store binary. | Ceiling is a good browser shooter, not HW2. Art, animation, and perf stay a grind. | +| **C. 2D illustrated GTA presentation** | Lean into GPT Image stills + the tactical diorama. Combat stays a readable encounter, not a FPS. Market as street-war **strategy** with illustrated crew. | Matches the current stack. Photo friends and saved blocks can look premium without pretending to be GTA V the videogame. | Will lose any player who came for the shooting feel. Must not use FPS trailer language. | + +### Recommendation + +**For the invite web beta: C, with GPT Image as the only likeness vendor.** Make the desk, the photo-friends hook, and one honest defense fight feel like one illustrated GTA-style game. Do not spend more cycles on Pillow bodies or a third canvas shooter. + +**If the public promise is “deeper than GTA and better than Hood Warfare 2 at shooting,” switch combat to A** (React empire + one UE5 fight) before advertising. B is the fallback only if the owner refuses a native binary and still wants a web gun. + +Do not try to win both promises on Phaser + image composites. That is the struggle the owner is already seeing. -Details: `docs/PHOTO_LIKENESS.md`. +Details and enable steps: `docs/PHOTO_LIKENESS.md`. Manus handoff: `COLLABORATION.md`. --- @@ -204,7 +236,7 @@ Details: `docs/PHOTO_LIKENESS.md`. 1. First launch is **invite web beta**, not Steam/iOS. Confirm. 2. Complete #175 on `dealt-world-proof-staging` (or a new isolated target). Do not use production. -3. Whether to buy an image API for stylized restyles. Not required for likeness. +3. Provide `OPENAI_API_KEY` so GPT Image can unlock GTA-style bodies. Vendor is decided; billing is not. Private bucket still needed if signed-in cards must survive Flask restart. 4. Private bucket for generated cards if you want them to survive server restart for signed-in players. 5. Founders-access price and processor (Stripe etc.) — only after save works. 6. Legal review of 18+ crime fiction + friend-photo consent copy before ads. diff --git a/backend/python/.env.example b/backend/python/.env.example index 41bca29..1479b62 100644 --- a/backend/python/.env.example +++ b/backend/python/.env.example @@ -32,10 +32,12 @@ ENABLE_STREET_VIEW=false ENABLE_3D_TILES=false ENABLE_WORLD_TICK=true -# Photo-to-member likeness -# Default: local photo compositor (uses the uploaded face; no API key). -# Optional AI restyle — only when BOTH are set. Never required for likeness. +# Photo-to-member likeness (GPT Image) +# Unset: roster shows the real photo crop; body stays locked. +# Set OPENAI_API_KEY to enable GTA-style generation. Never commit the key. # OPENAI_API_KEY= -# SLIDE_AVATAR_PROVIDER=openai -# SLIDE_AVATAR_MODEL=gpt-image-1 -# pip install 'openai>=1.40.0' is also required for the optional path. +# Optional pin. Default tries gpt-image-2, then gpt-image-1.5, then gpt-image-1. +# SLIDE_AVATAR_MODEL=gpt-image-2 +# pip install 'openai>=1.40.0' is required for the GPT Image path. +# DALL·E is never used. SLIDE_AVATAR_PROVIDER is no longer required. +# See docs/PHOTO_LIKENESS.md. diff --git a/backend/python/api/avatar.py b/backend/python/api/avatar.py index 872ade7..023d2a7 100644 --- a/backend/python/api/avatar.py +++ b/backend/python/api/avatar.py @@ -6,9 +6,10 @@ GET /api/avatar/provider POST /api/avatar/approve -The default path is a local photo-likeness compositor. Stock role art is -never returned as if it came from the uploaded photo. Optional OpenAI -restyle is used only when explicitly configured and the call succeeds. +Without OPENAI_API_KEY the server returns a real photo crop and locks +the body. With the key, GPT Image is the only likeness provider. Failures +fall back to the photo crop + bodyLocked — never DALL·E, never a +vector / stick-figure body. """ import base64 @@ -22,6 +23,7 @@ MAX_UPLOAD_BYTES, build_identity_prompt, compose_member_assets, + gpt_image_models, provider_status, ) @@ -31,6 +33,12 @@ _jobs: dict = {} _ASSET_ROOT = "/tmp/slide_generated" +_CAMERAS = { + "portrait": "chest-up cover-art portrait, GTA V loading-screen framing", + "fullbody": "full body standing, front three-quarter, feet visible, same wardrobe and face", + "topdown": "slight high-oblique in-game token of the same person and outfit, readable silhouette", +} + def _data_url(png_bytes: bytes) -> str: return "data:image/png;base64," + base64.b64encode(png_bytes).decode("ascii") @@ -63,32 +71,34 @@ def generate_member_assets( raise NotImplementedError -class LocalLikenessProvider(AvatarGenerationProvider): - """Compose roster cards that keep the uploaded face.""" +class PhotoCropProvider(AvatarGenerationProvider): + """Honest photo crop. Body stays locked until GPT Image is configured.""" - name = "local_photo_likeness" + name = "photo_crop" def generate_member_assets(self, source_image_path, role, style, outputs): - composed = compose_member_assets(source_image_path, role, style, outputs) + requested = outputs or ["portrait"] + if "portrait" not in requested: + requested = ["portrait", *requested] + composed = compose_member_assets(source_image_path, role, style, requested) traits = composed["likenessTraits"] - camera = "chest-up portrait, dramatic noir lighting" - prompt = build_identity_prompt(traits, role, style, camera) + prompt = build_identity_prompt(traits, role, style, _CAMERAS["portrait"]) return { "files": composed["files"], "likenessTraits": traits, "promptUsed": prompt, "provider": self.name, + "bodyLocked": True, } class OpenAIAvatarProvider(AvatarGenerationProvider): """ - Optional OpenAI image edit. Requires OPENAI_API_KEY and - SLIDE_AVATAR_PROVIDER=openai. Failures must fall back to local likeness - rather than stock art. + GPT Image only. Requires OPENAI_API_KEY. Uses the uploaded photo as + the identity reference. Never calls DALL·E. """ - name = "openai" + name = "gpt_image" def generate_member_assets(self, source_image_path, role, style, outputs): try: @@ -98,65 +108,85 @@ def generate_member_assets(self, source_image_path, role, style, outputs): "openai package is not installed. pip install 'openai>=1.40.0'" ) from exc - local = LocalLikenessProvider().generate_member_assets( - source_image_path, role, style, outputs + crop = PhotoCropProvider().generate_member_assets( + source_image_path, role, style, ["portrait"] ) client = openai.OpenAI(api_key=os.environ.get("OPENAI_API_KEY")) - model = os.environ.get("SLIDE_AVATAR_MODEL") or "gpt-image-1" - files = dict(local["files"]) - traits = local["likenessTraits"] - - cameras = { - "portrait": "chest-up portrait, dramatic noir lighting", - "fullbody": "full body, front-facing neutral pose, feet visible", - "topdown": "overhead 85-degree tactical token, full figure", - } - - for output in outputs: - prompt = build_identity_prompt(traits, role, style, cameras.get(output, output)) - with open(source_image_path, "rb") as img_file: + traits = crop["likenessTraits"] + requested = outputs or ["portrait", "fullbody"] + files: dict[str, bytes] = {} + last_error: Exception | None = None + used_model: str | None = None + last_prompt = crop["promptUsed"] + + for output in requested: + prompt = build_identity_prompt( + traits, role, style, _CAMERAS.get(output, output) + ) + last_prompt = prompt + generated = None + for model in gpt_image_models(): + if "dall-e" in model.lower(): + continue try: - response = client.images.edit( - model=model, - image=img_file, - prompt=prompt, - n=1, - size="1024x1024", + generated = self._edit_with_model( + client, model, source_image_path, prompt ) - except Exception: - # Older SDKs / dall-e-2 still accept this shape. - img_file.seek(0) - response = client.images.edit( - model="dall-e-2", - image=img_file, - prompt=prompt, - n=1, - size="1024x1024", - ) - payload = response.data[0] - if getattr(payload, "b64_json", None): - files[output] = base64.b64decode(payload.b64_json) - elif getattr(payload, "url", None): - import requests - downloaded = requests.get(payload.url, timeout=30) - downloaded.raise_for_status() - files[output] = downloaded.content - else: - raise RuntimeError("OpenAI image response had no bytes") + used_model = model + break + except Exception as exc: + last_error = exc + continue + if generated is None: + raise RuntimeError( + f"GPT Image failed for {output}: {last_error}" + ) + files[output] = generated return { "files": files, "likenessTraits": traits, - "promptUsed": prompt, + "promptUsed": last_prompt, "provider": self.name, + "bodyLocked": "fullbody" not in files, + "model": used_model, } + def _edit_with_model(self, client, model: str, image_path: str, prompt: str) -> bytes: + if "dall-e" in model.lower(): + raise RuntimeError(f"Refusing forbidden model {model}") + with open(image_path, "rb") as img_file: + try: + response = client.images.edit( + model=model, + image=img_file, + prompt=prompt, + n=1, + size="1024x1024", + ) + except TypeError: + img_file.seek(0) + response = client.images.edit( + model=model, + image=img_file, + prompt=prompt, + ) + payload = response.data[0] + if getattr(payload, "b64_json", None): + return base64.b64decode(payload.b64_json) + if getattr(payload, "url", None): + import requests + downloaded = requests.get(payload.url, timeout=45) + downloaded.raise_for_status() + return downloaded.content + raise RuntimeError("GPT Image response had no bytes") + def get_provider() -> AvatarGenerationProvider: status = provider_status() if status["aiConfigured"]: return OpenAIAvatarProvider() - return LocalLikenessProvider() + return PhotoCropProvider() def _require_consent(form) -> str | None: @@ -174,7 +204,7 @@ def _require_consent(form) -> str | None: @avatar_bp.route("/provider", methods=["GET"]) def provider(): - """Report whether optional AI restyle is configured.""" + """Report whether GPT Image is configured.""" return jsonify(provider_status()), 200 @@ -214,16 +244,16 @@ def generate(): image_file.save(tmp_path) job_id = str(uuid.uuid4()) - provider = get_provider() + selected = get_provider() ai_error = None try: try: - generated = provider.generate_member_assets(tmp_path, role, style, outputs) + generated = selected.generate_member_assets(tmp_path, role, style, outputs) except Exception as exc: - if provider.name != "local_photo_likeness": + if selected.name != "photo_crop": ai_error = str(exc) - generated = LocalLikenessProvider().generate_member_assets( + generated = PhotoCropProvider().generate_member_assets( tmp_path, role, style, outputs ) else: @@ -244,16 +274,18 @@ def generate(): "sourcePhotoRetained": False, "stockPreview": False, **generated, - "portraitUrl": urls.get("portrait") or _data_url(files["portrait"]) if "portrait" in files else None, - "fullbodyUrl": urls.get("fullbody") or _data_url(files["fullbody"]) if "fullbody" in files else None, - "topdownUrl": urls.get("topdown") or _data_url(files["topdown"]) if "topdown" in files else None, + "portraitUrl": urls.get("portrait"), + "fullbodyUrl": urls.get("fullbody"), + "topdownUrl": urls.get("topdown"), "portraitDataUrl": _data_url(files["portrait"]) if "portrait" in files else None, "fullbodyDataUrl": _data_url(files["fullbody"]) if "fullbody" in files else None, "topdownDataUrl": _data_url(files["topdown"]) if "topdown" in files else None, + "bodyLocked": bool(generated.get("bodyLocked", "fullbody" not in files)), } if ai_error: asset["aiProviderError"] = ai_error - asset["provider"] = "local_photo_likeness" + asset["provider"] = "photo_crop" + asset["bodyLocked"] = True _jobs[job_id] = asset return jsonify({"asset": asset}), 200 diff --git a/backend/python/services/likeness.py b/backend/python/services/likeness.py index fe2be5a..14c44b5 100644 --- a/backend/python/services/likeness.py +++ b/backend/python/services/likeness.py @@ -1,8 +1,8 @@ -"""Local photo-likeness analysis and character-card compositor. +"""Photo-crop analysis and the locked GTA-style likeness prompt. -This is the no-key path for custom crew members. It never invents a -different face. The uploaded photo is the identity; role/style only -change the fictional card around that face. +No-key path: crop the uploaded face for the roster. Never invent a +vector / stick-figure / clip-art body. Full-body and in-game states +come only from GPT Image once OPENAI_API_KEY is set. Source photos are read from a temp path and must be deleted by the caller after generation. Generated PNGs are either returned as data @@ -26,6 +26,18 @@ } MAX_UPLOAD_BYTES = 8 * 1024 * 1024 +# Latest-first GPT Image family. Never fall back to DALL·E. +GPT_IMAGE_MODELS = ("gpt-image-2", "gpt-image-1.5", "gpt-image-1") + +GTA_ART_DIRECTION = ( + "GTA V loading-screen / cover-art illustrated realism. Semi-realistic " + "painted character art with believable adult anatomy, real clothing " + "materials, and cinematic wet-night tropical-noir grade. Same look " + "across portrait, full-body, and in-game states. NOT South Park, NOT " + "clip-art, NOT vector, NOT a stick figure, NOT cel-shaded cartoon, " + "NOT a chibi or mascot." +) + SKIN_BANDS = ( ("ivory", (248, 228, 212)), ("sand", (232, 196, 162)), @@ -51,38 +63,23 @@ STYLE_CLOTHING = { "south_florida_streetwear": { "label": "South Florida streetwear", - "top": (36, 36, 40), - "accent": (74, 222, 128), - "bottom": (28, 42, 64), - "notes": "oversized tee, baggy jeans, sneakers, thin chain", + "notes": "oversized tee, baggy jeans, sneakers, thin chain — real fabric, no logos", }, "luxury_noir": { "label": "luxury noir", - "top": (18, 16, 20), - "accent": (212, 175, 55), - "bottom": (24, 22, 28), "notes": "dark tailored layers, gold hardware, no real logos", }, "masked_tactical": { "label": "tactical streetwear", - "top": (32, 38, 34), - "accent": (120, 140, 90), - "bottom": (28, 32, 28), - "notes": "utility vest, muted olive, pulled collar — face stays visible", + "notes": "utility vest, muted olive, pulled collar — face stays fully visible", }, "baggy_2000s": { "label": "baggy 2000s streetwear", - "top": (48, 28, 72), - "accent": (250, 204, 21), - "bottom": (22, 22, 28), - "notes": "oversized jersey, baggy jeans, work boots", + "notes": "oversized jersey, baggy jeans, work boots — real cloth folds", }, "clean_designer": { "label": "clean designer streetwear", - "top": (232, 232, 228), - "accent": (96, 165, 250), - "bottom": (40, 40, 48), - "notes": "fitted knit, clean sneakers, quiet jewelry", + "notes": "fitted knit, clean sneakers, quiet jewelry — no brand marks", }, } @@ -97,10 +94,6 @@ } -def _clamp(value: float, low: float, high: float) -> float: - return max(low, min(high, value)) - - def _nearest_name(rgb: tuple[int, int, int], bands: Iterable[tuple[str, tuple[int, int, int]]]) -> str: best_name = "unknown" best_dist = 1e12 @@ -142,7 +135,6 @@ def _face_box(image: Image.Image) -> tuple[int, int, int, int]: xs.append(x) ys.append(y) if len(xs) < 12: - # Typical portrait fallback: upper-center. return (int(w * 0.18), int(h * 0.08), int(w * 0.82), int(h * 0.72)) pad_x = int(w * 0.08) pad_y = int(h * 0.10) @@ -162,7 +154,7 @@ def _face_box(image: Image.Image) -> tuple[int, int, int, int]: def analyze_photo(image: Image.Image) -> dict: - """Extract likeness traits used for prompts and the local card.""" + """Extract likeness traits used for GPT Image prompts and the photo crop.""" rgb = image.convert("RGB") face = _face_box(rgb) fw = face[2] - face[0] @@ -225,7 +217,6 @@ def _grade_portrait(crop: Image.Image) -> Image.Image: graded = ImageEnhance.Contrast(crop).enhance(1.12) graded = ImageEnhance.Color(graded).enhance(1.06) graded = ImageEnhance.Sharpness(graded).enhance(1.15) - # Warm highlight / cool shadow grade without washing identity. overlay = Image.new("RGB", graded.size, (18, 24, 36)) graded = Image.blend(graded, overlay, 0.10) return graded @@ -243,9 +234,9 @@ def _vignette(image: Image.Image, strength: float = 0.42) -> Image.Image: def compose_portrait(source: Image.Image, traits: dict, role: str) -> Image.Image: + """Honest photo crop. Frame chrome only — no invented body.""" rgb = source.convert("RGB") face = tuple(traits["faceBox"]) - # Include hair and a bit of shoulders so the crop still looks like the person. w, h = rgb.size fw = face[2] - face[0] fh = face[3] - face[1] @@ -269,102 +260,6 @@ def compose_portrait(source: Image.Image, traits: dict, role: str) -> Image.Imag return frame.convert("RGB") -def _draw_body( - draw: ImageDraw.ImageDraw, - traits: dict, - role: str, - style: str, - origin: tuple[int, int], -) -> None: - x, y = origin - clothing = STYLE_CLOTHING.get(style, STYLE_CLOTHING["south_florida_streetwear"]) - skin = tuple(traits["skinRgb"]) - top = clothing["top"] - bottom = clothing["bottom"] - accent = clothing["accent"] - role_color = ROLE_COLORS.get(role, (74, 222, 128)) - - # Neck - draw.rectangle((x + 86, y + 132, x + 118, y + 168), fill=skin) - # Torso - if style == "masked_tactical": - draw.rounded_rectangle((x + 48, y + 156, x + 156, y + 320), radius=10, fill=top) - draw.rounded_rectangle((x + 58, y + 170, x + 146, y + 250), radius=6, fill=(40, 46, 40)) - draw.rectangle((x + 70, y + 186, x + 134, y + 196), fill=accent) - elif style == "luxury_noir": - draw.rounded_rectangle((x + 52, y + 156, x + 152, y + 330), radius=8, fill=top) - draw.polygon([(x + 102, y + 156), (x + 70, y + 250), (x + 134, y + 250)], fill=accent) - else: - draw.rounded_rectangle((x + 44, y + 156, x + 160, y + 318), radius=16, fill=top) - draw.rectangle((x + 44, y + 300, x + 160, y + 318), fill=accent) - - # Arms - draw.rounded_rectangle((x + 16, y + 168, x + 50, y + 300), radius=12, fill=top) - draw.rounded_rectangle((x + 154, y + 168, x + 188, y + 300), radius=12, fill=top) - draw.ellipse((x + 14, y + 292, x + 52, y + 330), fill=skin) - draw.ellipse((x + 152, y + 292, x + 190, y + 330), fill=skin) - # Legs - draw.rounded_rectangle((x + 56, y + 314, x + 96, y + 470), radius=10, fill=bottom) - draw.rounded_rectangle((x + 108, y + 314, x + 148, y + 470), radius=10, fill=bottom) - # Shoes - draw.rounded_rectangle((x + 46, y + 462, x + 102, y + 492), radius=8, fill=role_color) - draw.rounded_rectangle((x + 102, y + 462, x + 158, y + 492), radius=8, fill=role_color) - - -def compose_fullbody(source: Image.Image, traits: dict, role: str, style: str) -> Image.Image: - card = Image.new("RGB", (512, 768), (10, 10, 15)) - draw = ImageDraw.Draw(card, "RGBA") - # Wet-night ground and horizon so the card matches the diorama grade. - draw.rectangle((0, 520, 512, 768), fill=(16, 18, 24)) - draw.rectangle((0, 0, 512, 520), fill=(14, 16, 22)) - for i in range(8): - y = 530 + i * 28 - draw.line((0, y, 512, y), fill=(24, 28, 36), width=1) - - origin = (154, 86) - _draw_body(draw, traits, role, style, origin) - - rgb = source.convert("RGB") - face = tuple(traits["faceBox"]) - w, h = rgb.size - fw = face[2] - face[0] - fh = face[3] - face[1] - head_box = ( - max(0, face[0] - int(fw * 0.12)), - max(0, face[1] - int(fh * 0.22)), - min(w, face[2] + int(fw * 0.12)), - min(h, face[3] + int(fh * 0.08)), - ) - head = ImageOps.fit(rgb.crop(head_box), (168, 176), Image.Resampling.LANCZOS, centering=(0.5, 0.35)) - head = _grade_portrait(head) - mask = Image.new("L", head.size, 0) - ImageDraw.Draw(mask).ellipse((4, 2, 164, 174), fill=255) - mask = mask.filter(ImageFilter.GaussianBlur(1.4)) - card.paste(head, (origin[0] + 18, origin[1] - 8), mask) - - accent = ROLE_COLORS.get(role, (74, 222, 128)) - draw.rectangle((16, 16, 495, 751), outline=accent, width=3) - draw.rectangle((24, 700, 488, 736), fill=(10, 10, 15)) - draw.rectangle((24, 700, 488, 736), outline=accent, width=1) - return card.convert("RGB") - - -def compose_topdown(source: Image.Image, traits: dict, role: str) -> Image.Image: - token = Image.new("RGBA", (256, 256), (0, 0, 0, 0)) - draw = ImageDraw.Draw(token, "RGBA") - accent = ROLE_COLORS.get(role, (74, 222, 128)) - draw.ellipse((8, 8, 247, 247), fill=(10, 10, 15, 255), outline=accent + (255,), width=10) - - rgb = source.convert("RGB") - face = tuple(traits["faceBox"]) - head = ImageOps.fit(rgb.crop(face), (196, 196), Image.Resampling.LANCZOS, centering=(0.5, 0.32)) - head = _grade_portrait(head) - mask = Image.new("L", head.size, 0) - ImageDraw.Draw(mask).ellipse((2, 2, 193, 193), fill=255) - token.paste(head, (30, 30), mask) - return token - - def encode_png(image: Image.Image) -> bytes: buffer = io.BytesIO() image.save(buffer, format="PNG", optimize=True) @@ -377,14 +272,18 @@ def compose_member_assets( style: str, outputs: list[str], ) -> dict: + """Photo-crop only. Requested full-body / token outputs stay locked.""" + _ = style with Image.open(source_path) as raw: source = raw.convert("RGB") - # Bound analysis cost on huge camera rolls. max_edge = 1600 if max(source.size) > max_edge: source.thumbnail((max_edge, max_edge), Image.Resampling.LANCZOS) traits = analyze_photo(source) - assets: dict = { + files: dict[str, bytes] = {} + if "portrait" in outputs or not outputs: + files["portrait"] = encode_png(compose_portrait(source, traits, role)) + return { "likenessTraits": { "skinTone": traits["skinTone"], "hairColor": traits["hairColor"], @@ -392,15 +291,9 @@ def compose_member_assets( "clothingColors": [traits["skinTone"], _nearest_name(tuple(traits["clothingRgb"]), HAIR_BANDS)], "notes": traits["notes"], }, - "files": {}, + "files": files, + "bodyLocked": True, } - if "portrait" in outputs: - assets["files"]["portrait"] = encode_png(compose_portrait(source, traits, role)) - if "fullbody" in outputs: - assets["files"]["fullbody"] = encode_png(compose_fullbody(source, traits, role, style)) - if "topdown" in outputs: - assets["files"]["topdown"] = encode_png(compose_topdown(source, traits, role)) - return assets def build_identity_prompt(traits: dict, role: str, style: str, camera: str) -> str: @@ -411,41 +304,63 @@ def build_identity_prompt(traits: dict, role: str, style: str, camera: str) -> s notes = "; ".join(traits.get("notes") or []) return ( "SCENE\n" - "SLIDE custom crew card. Stylized-realistic 2.5D urban strategy character, " - "cinematic tropical-noir grade, clean silhouette, transparent-friendly, no scenery.\n\n" + f"SLIDE custom crew card. {GTA_ART_DIRECTION} Clean silhouette, " + "transparent-friendly dark ground, no scenery, no HUD text.\n\n" "SUBJECT\n" - f"A fictional playable {role} whose face is the same person as the reference photo. " - "This is a cosmetic game avatar, not a real-world accusation.\n\n" + f"A fictional playable {role} whose face is the same person as the " + "uploaded identity-reference photo. Cosmetic game avatar only — not " + "a real-world accusation.\n\n" "IMPORTANT DETAILS\n" - f"Preserve face shape, eye spacing, nose, jaw, skin tone ({skin}), hair color and " - f"hairline ({hair}). {facial} Clothing: {clothing['notes']}. Role readable as {role}. " - f"Camera: {camera}. {notes}\n\n" + f"Preserve face shape, eye spacing, nose, jaw, skin tone ({skin}), " + f"hair color and hairline ({hair}). {facial} Clothing: {clothing['notes']}. " + f"Role readable as {role}. Camera: {camera}. {notes}\n\n" "USE CASE\n" - "Crew roster portrait / full-body card / top-down token at phone and desktop sizes.\n\n" + "Crew roster portrait / full-body card / in-game token. Same identity " + "and wardrobe across every generated state.\n\n" "CONSTRAINTS\n" - "Preserve identity from the reference. No real brand logos, no watermarks, no baked UI " - "text, no real criminal organizations, no minors, no photoreal nude content." + "The uploaded photo is the identity reference — keep this person. " + "No South Park, clip-art, vector, stick-figure, emoji, or mascot look. " + "No real brand logos, watermarks, baked UI text, real criminal " + "organizations, minors, or photoreal nude content." ) +def gpt_image_models() -> list[str]: + """Latest-first GPT Image list. DALL·E values in env are ignored.""" + requested = (os.environ.get("SLIDE_AVATAR_MODEL") or "").strip() + models: list[str] = [] + if requested and "dall-e" not in requested.lower() and requested.lower().startswith("gpt-image"): + models.append(requested) + elif requested and "dall-e" not in requested.lower() and requested: + # Allow a future gpt-image name the owner pins, but never DALL·E. + if not requested.lower().startswith("dall"): + models.append(requested) + for name in GPT_IMAGE_MODELS: + if name not in models: + models.append(name) + return [name for name in models if "dall-e" not in name.lower()] + + def provider_status() -> dict: - requested = (os.environ.get("SLIDE_AVATAR_PROVIDER") or "local").strip().lower() - has_key = bool(os.environ.get("OPENAI_API_KEY")) - model = os.environ.get("SLIDE_AVATAR_MODEL") or "gpt-image-1" - ai_ready = requested == "openai" and has_key + has_key = bool((os.environ.get("OPENAI_API_KEY") or "").strip()) + models = gpt_image_models() return { - "provider": "openai" if ai_ready else "local_photo_likeness", - "aiConfigured": ai_ready, - "requestedProvider": requested, - "model": model if ai_ready else None, + "provider": "gpt_image" if has_key else "photo_crop", + "aiConfigured": has_key, + "requestedProvider": "gpt_image" if has_key else "photo_crop", + "model": models[0] if has_key else None, + "candidateModels": list(models) if has_key else [], + "bodyLockedUntilConfigured": not has_key, "requiredForAi": { - "env": ["OPENAI_API_KEY", "SLIDE_AVATAR_PROVIDER=openai"], - "optionalEnv": ["SLIDE_AVATAR_MODEL (default gpt-image-1)"], + "env": ["OPENAI_API_KEY"], + "optionalEnv": [ + "SLIDE_AVATAR_MODEL (optional pin; default tries gpt-image-2, then gpt-image-1.5, then gpt-image-1)" + ], "pythonPackage": "openai>=1.40.0", "notes": ( - "Without those values the server composes a photo-based likeness card " - "from the upload. It will not silently swap in stock role art. " - "AI restyle is optional and only used when it succeeds." + "OPENAI_API_KEY alone enables GPT Image. Without it the roster " + "shows the real photo crop and the body stays locked. Generation " + "never falls back to DALL·E or a stick-figure body." ), }, } diff --git a/backend/python/tests/test_avatar_likeness.py b/backend/python/tests/test_avatar_likeness.py index b73f920..a42c2e1 100644 --- a/backend/python/tests/test_avatar_likeness.py +++ b/backend/python/tests/test_avatar_likeness.py @@ -1,4 +1,4 @@ -"""Photo-likeness API: consent, no stock swap, source deletion, provider honesty.""" +"""Photo-likeness API: consent, photo crop, no vector bodies, GPT Image config.""" import io import os @@ -10,7 +10,14 @@ sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..')) from app import create_app -from services.likeness import analyze_photo, compose_member_assets +from services.likeness import ( + GTA_ART_DIRECTION, + analyze_photo, + build_identity_prompt, + compose_member_assets, + gpt_image_models, + provider_status, +) @pytest.fixture @@ -51,15 +58,52 @@ def test_avatar_allows_documented_frontend_dev_origin(client): assert response.headers.get('Access-Control-Allow-Origin') == 'http://127.0.0.1:3000' -def test_provider_endpoint_reports_local_without_key(client, monkeypatch): +def test_provider_endpoint_reports_photo_crop_without_key(client, monkeypatch): monkeypatch.delenv("OPENAI_API_KEY", raising=False) monkeypatch.delenv("SLIDE_AVATAR_PROVIDER", raising=False) + monkeypatch.delenv("SLIDE_AVATAR_MODEL", raising=False) response = client.get("/api/avatar/provider") assert response.status_code == 200 data = response.get_json() - assert data["provider"] == "local_photo_likeness" + assert data["provider"] == "photo_crop" assert data["aiConfigured"] is False - assert "OPENAI_API_KEY" in data["requiredForAi"]["env"] + assert data["bodyLockedUntilConfigured"] is True + assert data["requiredForAi"]["env"] == ["OPENAI_API_KEY"] + assert "SLIDE_AVATAR_PROVIDER" not in data["requiredForAi"]["env"] + + +def test_provider_status_enables_gpt_image_from_key_alone(monkeypatch): + monkeypatch.setenv("OPENAI_API_KEY", "sk-test-not-real") + monkeypatch.delenv("SLIDE_AVATAR_PROVIDER", raising=False) + status = provider_status() + assert status["provider"] == "gpt_image" + assert status["aiConfigured"] is True + assert status["bodyLockedUntilConfigured"] is False + assert status["model"].startswith("gpt-image") + + +def test_gpt_image_models_never_include_dalle(monkeypatch): + monkeypatch.setenv("SLIDE_AVATAR_MODEL", "dall-e-2") + models = gpt_image_models() + assert models + assert all("dall-e" not in name.lower() for name in models) + assert models[0].startswith("gpt-image") + + +def test_identity_prompt_locks_gta_illustrated_realism(): + prompt = build_identity_prompt( + {"skinTone": "bronze", "hairColor": "black", "facialHair": "visible", "notes": []}, + "shooter", + "south_florida_streetwear", + "full-body standing, feet visible", + ) + assert GTA_ART_DIRECTION in prompt + lowered = prompt.lower() + assert "south park" in lowered + assert "clip-art" in lowered or "clip art" in lowered + assert "vector" in lowered + assert "gta" in lowered + assert "dall-e" not in lowered def test_generate_requires_adult_and_permission_consent(client): @@ -76,7 +120,8 @@ def test_generate_requires_adult_and_permission_consent(client): assert response.get_json()["code"] == "consent_required" -def test_generate_returns_photo_likeness_not_stock(client, tmp_path): +def test_generate_without_key_returns_photo_crop_and_locks_body(client, monkeypatch): + monkeypatch.delenv("OPENAI_API_KEY", raising=False) png = _portrait_png(skin=(93, 64, 55), hair=(18, 16, 14), clothes=(48, 28, 72)) form = _consent_form() form["image"] = (io.BytesIO(png), "friend.png", "image/png") @@ -87,9 +132,14 @@ def test_generate_returns_photo_likeness_not_stock(client, tmp_path): ) assert response.status_code == 200 asset = response.get_json()["asset"] - assert asset["provider"] == "local_photo_likeness" + assert asset["provider"] == "photo_crop" assert asset["stockPreview"] is False assert asset["sourcePhotoRetained"] is False + assert asset["bodyLocked"] is True + assert asset["fullbodyUrl"] is None + assert asset["fullbodyDataUrl"] is None + assert asset["topdownUrl"] is None + assert asset["topdownDataUrl"] is None assert asset["likenessTraits"]["skinTone"] in { "chocolate", "espresso", "bronze", "toffee", "caramel", } @@ -99,6 +149,7 @@ def test_generate_returns_photo_likeness_not_stock(client, tmp_path): portrait = client.get(asset["portraitUrl"]) assert portrait.status_code == 200 assert portrait.mimetype == "image/png" + assert len(portrait.data) > 20_000 assert not any( stock in (asset["portraitUrl"] + asset.get("promptUsed", "")) for stock in ( @@ -107,8 +158,12 @@ def test_generate_returns_photo_likeness_not_stock(client, tmp_path): ) ) + missing_body = client.get(asset["portraitUrl"].replace("portrait", "fullbody")) + assert missing_body.status_code == 404 + def test_source_upload_is_deleted_after_generate(client, monkeypatch): + monkeypatch.delenv("OPENAI_API_KEY", raising=False) png = _portrait_png() form = _consent_form() form["image"] = (io.BytesIO(png), "friend.png", "image/png") @@ -134,9 +189,38 @@ def test_analyze_photo_reads_skin_and_hair_from_regions(): assert traits["hairColor"] in {"blonde", "platinum", "brown", "auburn"} -def test_compose_outputs_only_requested_kinds(tmp_path): +def test_compose_never_emits_vector_fullbody_or_topdown(tmp_path): path = tmp_path / "in.png" path.write_bytes(_portrait_png()) - composed = compose_member_assets(str(path), "shooter", "luxury_noir", ["portrait"]) + composed = compose_member_assets( + str(path), "shooter", "luxury_noir", ["portrait", "fullbody", "topdown"] + ) assert set(composed["files"]) == {"portrait"} assert composed["files"]["portrait"][:8] == b"\x89PNG\r\n\x1a\n" + assert composed["bodyLocked"] is True + + +def test_gpt_image_failure_falls_back_to_photo_crop_not_vectors(client, monkeypatch): + monkeypatch.setenv("OPENAI_API_KEY", "sk-test-not-real") + + def boom(*_args, **_kwargs): + raise RuntimeError("gpt-image unavailable in test") + + monkeypatch.setattr( + "api.avatar.OpenAIAvatarProvider.generate_member_assets", + boom, + ) + png = _portrait_png() + form = _consent_form() + form["image"] = (io.BytesIO(png), "friend.png", "image/png") + response = client.post( + "/api/avatar/generate", + data=form, + content_type="multipart/form-data", + ) + assert response.status_code == 200 + asset = response.get_json()["asset"] + assert asset["provider"] == "photo_crop" + assert asset["bodyLocked"] is True + assert asset["fullbodyUrl"] is None + assert "gpt-image unavailable" in asset["aiProviderError"] diff --git a/docs/PHOTO_LIKENESS.md b/docs/PHOTO_LIKENESS.md index 27df0b6..263e77f 100644 --- a/docs/PHOTO_LIKENESS.md +++ b/docs/PHOTO_LIKENESS.md @@ -1,50 +1,88 @@ -# Photo-to-member likeness +# Photo-to-member likeness (GPT Image) -Players can put a friend on their crew by uploading a photo. The promise is -**the resulting character looks like that person**, not like a stock dealer. +Players can put a friend on their crew by uploading a photo. The owner +target is **GTA V loading-screen / cover-art illustrated realism** — the +same person, believable proportions and clothing, one look across +portrait, full-body, and in-game states. + +Vector, South Park, clip-art, and stick-figure bodies are not a valid +fallback. If GPT Image is not configured, the roster shows the **real +photo crop** and the body is labeled **locked**. ## What happens now 1. The player uploads a photo in Contacts → Add → Create from Photo (or Phone). 2. They must check three consents: adult photo, permission from the person, and processing to make a fictional game character. -3. The backend analyzes the photo (skin tone, hair, facial hair, clothing) and - composites a SLIDE character card that **keeps the uploaded face**. -4. Only generated card URLs are stored on the roster. The source photo is deleted - from the server after the job finishes. +3. The backend analyzes the photo (skin, hair, facial hair) only to write the + GPT Image identity prompt. The source file is then deleted. +4. Only generated asset URLs (or the photo crop) are stored on the roster. + The source photo is never persisted there. -If the Flask API is down, the browser does the same thing locally with an SVG -card that still embeds the uploaded photo. It will **not** silently swap in -stock role art. +| `OPENAI_API_KEY` | Portrait | Body / token | +|---|---|---| +| unset | Real photo crop | Locked — not generated | +| set, GPT Image succeeds | GTA-style likeness of that person | Same look, generated | +| set, GPT Image fails | Real photo crop + `aiProviderError` | Locked — never a stick figure | -## Optional AI restyle (not required) +If Flask is down, the browser still shows the photo crop and locks the body. +It will **not** swap in stock role art or draw a vector body. -The default path does not call an image model. To try a stylized 2.5D restyle -that still asks the model to preserve the face: +## How to enable GPT Image -``` -# backend/python/.env +The key is **not** set in this environment yet. The owner will provide it. +No other vendor flag is required. + +```bash +# 1. Put the key in the Flask env (backend/python/.env). Never commit it. OPENAI_API_KEY=sk-... -SLIDE_AVATAR_PROVIDER=openai -SLIDE_AVATAR_MODEL=gpt-image-1 + +# 2. Optional pin. If unset, the server tries latest-first: +# gpt-image-2 → gpt-image-1.5 → gpt-image-1 +# SLIDE_AVATAR_MODEL=gpt-image-2 + +# 3. Install the client in the backend venv. +cd backend/python +./venv/bin/pip install 'openai>=1.40.0' + +# 4. Restart Flask so it rereads the env. +./venv/bin/python app.py ``` -Also install the optional client in the backend venv: +Then check: +```bash +curl -s http://127.0.0.1:5000/api/avatar/provider ``` -./venv/bin/pip install 'openai>=1.40.0' + +Ready looks like: + +```json +{ + "provider": "gpt_image", + "aiConfigured": true, + "bodyLockedUntilConfigured": false, + "model": "gpt-image-2" +} ``` -`GET /api/avatar/provider` reports whether that path is live. If the key, -package, or model call fails, generation falls back to the local photo card -and returns `aiProviderError`. It does not invent a different person. +Not ready (current default) looks like: + +```json +{ + "provider": "photo_crop", + "aiConfigured": false, + "bodyLockedUntilConfigured": true, + "requiredForAi": { "env": ["OPENAI_API_KEY"] } +} +``` -Owner decisions still needed: +`SLIDE_AVATAR_PROVIDER` is no longer required. DALL·E 2 / DALL·E 3 are +never used, even if someone puts those names in `SLIDE_AVATAR_MODEL`. -- Which image vendor (OpenAI vs another) and who pays for it. -- Private durable storage for generated cards (today: in-memory job + data URL - on the local roster). Do not put source photos or public signed URLs in git. -- Whether AI restyle is offered at all for the first public cohort. +The frontend does not need an OpenAI key. It talks to Flask at +`VITE_API_URL` (default `http://localhost:5000`). After changing +`frontend/.env`, restart Vite — `VITE_*` values are compile-time. ## Safety @@ -52,7 +90,7 @@ Owner decisions still needed: processing consent. - Cosmetic fictional avatar only. Not a real-world claim or targeting feature. - Canonical world/rival art stays wholly fictional. -- Do not commit founder photos or generated likenesses to the repository. +- Do not commit founder photos, generated likenesses, or the API key. See issue #195 and the fictional-content contract in `docs/AI_CONTRIBUTOR_START_HERE.md`. diff --git a/docs/PROJECT_LOG.md b/docs/PROJECT_LOG.md index fff931b..d959657 100644 --- a/docs/PROJECT_LOG.md +++ b/docs/PROJECT_LOG.md @@ -69,6 +69,14 @@ Payments/monetization P0s are separately listed in `docs/MVP_STATUS_AND_DEV_PLAN ## Log +### 2026-10-17 — GPT Image likeness, no vector bodies, Manus handoff (#210) + +- Owner rejected the stick-figure / clip-art full-body as unacceptable. Target is GTA V loading-screen illustrated realism, same look across portrait, body, and in-game states. +- Vendor decision (delegated): GPT Image only. `OPENAI_API_KEY` enables it (not set yet). Model order `gpt-image-2` → `gpt-image-1.5` → `gpt-image-1`. DALL·E is never used. No key or a failed call → real photo crop + `bodyLocked`, never a vector body. +- `LAUNCH_READINESS.md` now states plainly that GTA-quality characters **and** HW2 gunplay will struggle on the current web stack, and recommends illustrated GTA presentation for the web beta (UE5 one-fight if shooting is the public promise). +- Added root `COLLABORATION.md` so another agent (Manus) can pick up work without colliding this branch. +- Consent + source-photo deletion unchanged. No founder photos or keys committed. + ### 2026-10-17 — Launch-readiness audit and truthful photo likeness (#195) - Added repo-root `LAUNCH_READINESS.md`: what is actually playable, ranked public-launch blockers, post-launch deferrals, launch cuts, and a short path that does not try to out-FPS Hood Warfare 2. Reconciles start-here / project log / #45 / #175 / #195 with stale Jan-2025 beta docs. diff --git a/frontend/.env.example b/frontend/.env.example index d4068e4..153b9a2 100644 --- a/frontend/.env.example +++ b/frontend/.env.example @@ -20,6 +20,6 @@ VITE_ENV=development # NEVER set this in a production deployment. # VITE_DEMO_MODE=1 -# Photo-to-member likeness talks to the Flask backend at VITE_API_URL. -# No frontend AI key is required. Optional OpenAI restyle is backend-only: -# see backend/python/.env.example and docs/PHOTO_LIKENESS.md. +# Photo-to-member likeness talks to Flask at VITE_API_URL. +# No frontend OpenAI key. Set OPENAI_API_KEY on the backend to unlock +# GTA-style bodies. See docs/PHOTO_LIKENESS.md. diff --git a/frontend/src/components/gang/PhotoMemberCreator.css b/frontend/src/components/gang/PhotoMemberCreator.css index aa5e041..6f343e3 100644 --- a/frontend/src/components/gang/PhotoMemberCreator.css +++ b/frontend/src/components/gang/PhotoMemberCreator.css @@ -304,6 +304,39 @@ letter-spacing: 0.5px; } +.pmc-locked-body { + width: 100px; + height: 140px; + border-radius: 8px; + border: 1px dashed #475569; + background: repeating-linear-gradient( + -45deg, + #0a0a0f, + #0a0a0f 8px, + #111827 8px, + #111827 16px + ); + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 6px; + padding: 8px; + text-align: center; + color: #94a3b8; +} +.pmc-locked-body strong { + font-size: 10px; + letter-spacing: 0.4px; + text-transform: uppercase; + color: #e2e8f0; +} +.pmc-locked-body small { + font-size: 8px; + line-height: 1.35; + color: #64748b; +} + /* ── Result actions ── */ .pmc-result-actions { display: flex; diff --git a/frontend/src/components/gang/PhotoMemberCreator.tsx b/frontend/src/components/gang/PhotoMemberCreator.tsx index 3956c9a..e8240f9 100644 --- a/frontend/src/components/gang/PhotoMemberCreator.tsx +++ b/frontend/src/components/gang/PhotoMemberCreator.tsx @@ -296,9 +296,10 @@ const PhotoMemberCreator: React.FC = ({ onClose, onAppr const canGenerate = Boolean(uploadedFile && hasConsent && nameIsValid && selectedOutputs.length > 0 && status === 'idle'); const canApprove = nameIsValid && !approving; const stockPreview = isStockPreview(generatedAsset); - const offlineLikeness = generatedAsset?.provider === 'local_photo_likeness_offline'; - const localLikeness = generatedAsset?.provider === 'local_photo_likeness' || offlineLikeness; - const aiLikeness = generatedAsset?.provider === 'openai'; + const offlineCrop = generatedAsset?.provider === 'photo_crop_offline'; + const photoCrop = generatedAsset?.provider === 'photo_crop' || offlineCrop; + const gtaLikeness = generatedAsset?.provider === 'gpt_image' && !generatedAsset.bodyLocked; + const bodyLocked = Boolean(generatedAsset?.bodyLocked || (photoCrop && !gtaLikeness)); return (
@@ -369,9 +370,9 @@ const PhotoMemberCreator: React.FC = ({ onClose, onAppr

{providerStatus && !providerStatus.aiConfigured && (

- AI restyle is not configured. Generation will keep the face from your photo - on a SLIDE character card. To enable optional AI restyle later, set - OPENAI_API_KEY and SLIDE_AVATAR_PROVIDER=openai on the backend. + GPT Image is not configured. The roster will show your real photo crop. + The GTA-style body stays locked until OPENAI_API_KEY is set on the + backend. See docs/PHOTO_LIKENESS.md.

)} @@ -488,21 +489,24 @@ const PhotoMemberCreator: React.FC = ({ onClose, onAppr

{stockPreview ? 'Stock Character Preview' - : aiLikeness - ? 'AI-stylized likeness' - : 'Photo-based likeness'} + : gtaLikeness + ? 'GTA-style likeness' + : 'Photo crop'}

{stockPreview && (

Generation could not use this photo. This preview is stock character art, not your friend.

)} - {!stockPreview && localLikeness && ( + {!stockPreview && photoCrop && (

- {offlineLikeness - ? 'Backend unavailable. This card uses the uploaded photo as the face — not stock art.' - : 'This card keeps the face from your photo. Optional AI restyle is not configured.'} + {offlineCrop + ? 'Backend unavailable. Roster uses the real photo crop. The GTA-style body is locked until GPT Image is configured.' + : 'Roster uses the real photo crop. The GTA-style body is locked until GPT Image is configured with OPENAI_API_KEY.'}

)} - {generatedAsset?.likenessTraits && !stockPreview && ( + {generatedAsset?.aiProviderError && !stockPreview && ( +

GPT Image failed: {generatedAsset.aiProviderError}

+ )} + {generatedAsset?.likenessTraits && !stockPreview && !photoCrop && (

Read from photo: {generatedAsset.likenessTraits.skinTone} skin, {' '}{generatedAsset.likenessTraits.hairColor} hair, @@ -516,18 +520,27 @@ const PhotoMemberCreator: React.FC = ({ onClose, onAppr Portrait

)} - {generatedAsset.fullbodyUrl && ( + {gtaLikeness && generatedAsset.fullbodyUrl && (
Full Body Full Body
)} - {generatedAsset.topdownUrl && ( + {gtaLikeness && generatedAsset.topdownUrl && (
Top-Down Token
)} + {bodyLocked && !gtaLikeness && !stockPreview && ( +
+
+ Body locked + Waiting on OPENAI_API_KEY / GPT Image +
+ Full Body +
+ )}
({ getStatus: vi.fn(), approve: vi.fn(), getProviderStatus: vi.fn().mockResolvedValue({ - provider: 'local_photo_likeness', + provider: 'photo_crop', aiConfigured: false, - requiredForAi: { env: ['OPENAI_API_KEY'], pythonPackage: 'openai', notes: '' }, + bodyLockedUntilConfigured: true, + requiredForAi: { env: ['OPENAI_API_KEY'], pythonPackage: 'openai>=1.40.0', notes: '' }, }), }, })); @@ -106,11 +107,14 @@ describe('PhotoMemberCreator', () => { expect(screen.getByRole('button', { name: /add to roster/i })).toBeEnabled(); }); - it('labels a photo-based likeness instead of stock art', async () => { + it('labels a photo crop and a locked body when GPT Image is not configured', async () => { service.generate.mockResolvedValue(asset({ id: 'likeness-1', - provider: 'local_photo_likeness', + provider: 'photo_crop', stockPreview: false, + bodyLocked: true, + fullbodyUrl: undefined, + topdownUrl: undefined, likenessTraits: { skinTone: 'caramel', hairColor: 'black', @@ -125,10 +129,38 @@ describe('PhotoMemberCreator', () => { typeName('Jay'); await act(async () => { fireEvent.click(generateButton()); }); - expect(screen.getByText('Photo-based likeness')).toBeInTheDocument(); - expect(screen.getByRole('status')).toHaveTextContent(/keeps the face from your photo/i); - expect(screen.getByText(/caramel skin/i)).toBeInTheDocument(); + expect(screen.getByText('Photo crop')).toBeInTheDocument(); + expect(screen.getByRole('status')).toHaveTextContent(/real photo/i); + expect(screen.getByRole('status')).toHaveTextContent(/body is locked/i); + expect(screen.getByText(/body locked/i)).toBeInTheDocument(); + expect(screen.queryByAltText('Full Body')).not.toBeInTheDocument(); expect(screen.queryByText(/stock character art/i)).not.toBeInTheDocument(); + expect(screen.queryByText('Photo-based likeness')).not.toBeInTheDocument(); + }); + + it('labels a GTA-style likeness when GPT Image returns a body', async () => { + service.generate.mockResolvedValue(asset({ + id: 'likeness-ai', + provider: 'gpt_image', + stockPreview: false, + bodyLocked: false, + likenessTraits: { + skinTone: 'caramel', + hairColor: 'black', + facialHair: 'none_or_light', + clothingColors: [], + notes: [], + }, + })); + const { container } = render(); + upload(container); + consent(); + typeName('Jay'); + await act(async () => { fireEvent.click(generateButton()); }); + + expect(screen.getByText('GTA-style likeness')).toBeInTheDocument(); + expect(screen.getByAltText('Full Body')).toBeInTheDocument(); + expect(screen.queryByText(/body locked/i)).not.toBeInTheDocument(); }); it('does not generate until adult, permission, and processing consents are given', async () => { @@ -270,4 +302,27 @@ describe('PhotoMemberCreator', () => { expect(onApproved).toHaveBeenCalledWith(member.id); expect(onClose).toHaveBeenCalledTimes(1); }); + + it('adds a photo-crop member without inventing a vector body URL', async () => { + service.generate.mockResolvedValue(asset({ + provider: 'photo_crop', + bodyLocked: true, + fullbodyUrl: undefined, + topdownUrl: undefined, + })); + service.approve.mockResolvedValue(approveFallback('asset-1')); + const { container } = render(); + typeName('Jay'); + upload(container); + consent(); + await act(async () => { fireEvent.click(generateButton()); }); + await act(async () => { + fireEvent.click(await screen.findByRole('button', { name: /add to roster/i })); + }); + const [member] = useGangStore.getState().members; + expect(member.avatarUrl).toBe('/generated/asset-1-portrait.png'); + expect(member.portraitUrl).toBe('/generated/asset-1-portrait.png'); + expect(member.fullbodyUrl).toBeUndefined(); + expect(member.topdownUrl).toBeUndefined(); + }); }); diff --git a/frontend/src/services/__tests__/avatarGeneration.service.test.ts b/frontend/src/services/__tests__/avatarGeneration.service.test.ts index 5b31fc0..fe7b176 100644 --- a/frontend/src/services/__tests__/avatarGeneration.service.test.ts +++ b/frontend/src/services/__tests__/avatarGeneration.service.test.ts @@ -13,7 +13,7 @@ const consents = { }; describe('offline photo likeness', () => { - it('uses the uploaded photo instead of stock role art when the backend is down', async () => { + it('uses the uploaded photo crop and never invents a vector body when the backend is down', async () => { vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new TypeError('offline'))); for (const role of ['dealer', 'shooter', 'driver', 'enforcer', 'lookout', 'runner', 'chemist'] as const) { const asset = await avatarGenerationService.generate({ @@ -24,14 +24,15 @@ describe('offline photo likeness', () => { ...consents, }); expect(asset.id).toMatch(/^likeness-local-/); - expect(asset.provider).toBe('local_photo_likeness_offline'); + expect(asset.provider).toBe('photo_crop_offline'); expect(asset.stockPreview).toBe(false); + expect(asset.bodyLocked).toBe(true); expect(isStockPreview(asset)).toBe(false); - for (const url of [asset.portraitUrl, asset.fullbodyUrl, asset.topdownUrl]) { - expect(url).toMatch(/^data:image\/svg\+xml/); - expect(url).not.toMatch(/^\/assets\/runtime\//); - expect(existsSync(resolve('public', `.${url ?? ''}`))).toBe(false); - } + expect(asset.portraitUrl).toMatch(/^data:image\/svg\+xml/); + expect(asset.portraitUrl).not.toMatch(/^\/assets\/runtime\//); + expect(asset.fullbodyUrl).toBeUndefined(); + expect(asset.topdownUrl).toBeUndefined(); + expect(existsSync(resolve('public', `.${asset.portraitUrl ?? ''}`))).toBe(false); } }); @@ -45,6 +46,7 @@ describe('offline photo likeness', () => { expect(asset.portraitUrl).toBeTruthy(); expect(asset.fullbodyUrl).toBeUndefined(); expect(asset.topdownUrl).toBeUndefined(); + expect(asset.bodyLocked).toBe(true); }); it('keeps inline portrait bytes after approve so roster cards do not 404', async () => { @@ -59,11 +61,13 @@ describe('offline photo likeness', () => { createdAt: new Date(0).toISOString(), portraitUrl: '/api/avatar/asset/job-1/portrait', portraitDataUrl: 'data:image/png;base64,abc', + bodyLocked: true, }), text: async () => '', })); const approved = await avatarGenerationService.approve('job-1'); expect(approved.portraitUrl).toBe('data:image/png;base64,abc'); + expect(approved.bodyLocked).toBe(true); }); it('refuses to generate without the three consent flags', async () => { diff --git a/frontend/src/services/__tests__/likenessCompositor.test.ts b/frontend/src/services/__tests__/likenessCompositor.test.ts index 625955f..c1b832d 100644 --- a/frontend/src/services/__tests__/likenessCompositor.test.ts +++ b/frontend/src/services/__tests__/likenessCompositor.test.ts @@ -3,7 +3,7 @@ import { composeLocalLikeness, isStockPreview } from '../likenessCompositor'; import type { GeneratedMemberAsset } from '../../types/avatar.types'; describe('likenessCompositor', () => { - it('embeds the uploaded photo in every requested card', async () => { + it('embeds the uploaded photo in the portrait and locks the body', async () => { const file = new File(['face-bytes'], 'friend.png', { type: 'image/png' }); const asset = await composeLocalLikeness({ imageFile: file, @@ -16,8 +16,11 @@ describe('likenessCompositor', () => { }); const payload = 'ZmFjZS1ieXRlcw'; // base64('face-bytes') expect(asset.portraitUrl).toContain(payload); - expect(asset.fullbodyUrl).toContain(payload); - expect(asset.topdownUrl).toContain(payload); + expect(asset.fullbodyUrl).toBeUndefined(); + expect(asset.topdownUrl).toBeUndefined(); + expect(asset.bodyLocked).toBe(true); + expect(asset.provider).toBe('photo_crop_offline'); + expect(asset.portraitUrl).not.toMatch(/rect x="178" y="268"/); expect(isStockPreview(asset)).toBe(false); }); diff --git a/frontend/src/services/avatarGeneration.service.ts b/frontend/src/services/avatarGeneration.service.ts index c72dca7..8e024d3 100644 --- a/frontend/src/services/avatarGeneration.service.ts +++ b/frontend/src/services/avatarGeneration.service.ts @@ -1,7 +1,8 @@ // ============================================================ // avatarGeneration.service.ts -// Photo-to-member generation. Local photo likeness is the honest -// fallback. Stock role art is never returned as a generated face. +// Photo-to-member generation. GPT Image is the likeness vendor. +// Without a backend key the honest fallback is a photo crop + locked +// body — never stock role art, never a vector full-body. // ============================================================ import type { diff --git a/frontend/src/services/likenessCompositor.ts b/frontend/src/services/likenessCompositor.ts index ced5969..c206fc8 100644 --- a/frontend/src/services/likenessCompositor.ts +++ b/frontend/src/services/likenessCompositor.ts @@ -1,5 +1,5 @@ -// Local photo-likeness compositor used when the backend is unreachable. -// It never substitutes stock role art. The uploaded photo is the identity. +// Offline photo-crop compositor used when the backend is unreachable. +// It never substitutes stock role art and never invents a vector body. import type { AvatarGenerateRequest, @@ -57,66 +57,34 @@ function portraitCard(photo: string, role: string): string { `); } -function fullbodyCard(photo: string, role: string, style: string): string { - const accent = ROLE_COLOR[role] ?? '#4ade80'; - return svgDataUrl(` - - - - - - - - - - ${escapeXml(role)} · ${escapeXml(style.split('_').join(' '))} -`); -} - -function topdownToken(photo: string, role: string): string { - const accent = ROLE_COLOR[role] ?? '#4ade80'; - return svgDataUrl(` - - - -`); -} - export async function composeLocalLikeness( req: AvatarGenerateRequest, ): Promise { const photo = await readFileAsDataUrl(req.imageFile); - const outputs = req.outputs; - const asset: GeneratedMemberAsset = { + const outputs: AvatarOutput[] = req.outputs.includes('portrait') + ? req.outputs + : ['portrait', ...req.outputs]; + return { id: `likeness-local-${Date.now()}`, role: req.role, style: req.style, outputs, status: 'ready', createdAt: new Date().toISOString(), - provider: 'local_photo_likeness_offline', + provider: 'photo_crop_offline', stockPreview: false, sourcePhotoRetained: false, + bodyLocked: true, + portraitUrl: portraitCard(photo, req.role), likenessTraits: { skinTone: 'from_photo', hairColor: 'from_photo', facialHair: 'from_photo', clothingColors: [], - notes: ['Offline compositor used the uploaded photo as identity.'], + notes: ['Offline photo crop used the uploaded photo. Body stays locked until GPT Image is configured.'], }, - promptUsed: 'Local photo-based likeness. Backend unavailable; no stock role art.', + promptUsed: 'Offline photo crop. GPT Image unavailable; no vector body and no stock role art.', }; - - const assign = (output: AvatarOutput, url: string) => { - if (output === 'portrait') asset.portraitUrl = url; - if (output === 'fullbody') asset.fullbodyUrl = url; - if (output === 'topdown') asset.topdownUrl = url; - }; - - if (outputs.includes('portrait')) assign('portrait', portraitCard(photo, req.role)); - if (outputs.includes('fullbody')) assign('fullbody', fullbodyCard(photo, req.role, req.style)); - if (outputs.includes('topdown')) assign('topdown', topdownToken(photo, req.role)); - return asset; } export function isStockPreview(asset: GeneratedMemberAsset | null | undefined): boolean { diff --git a/frontend/src/types/avatar.types.ts b/frontend/src/types/avatar.types.ts index c3c707d..6aae6a6 100644 --- a/frontend/src/types/avatar.types.ts +++ b/frontend/src/types/avatar.types.ts @@ -30,9 +30,9 @@ export type AvatarStyle = export type AvatarOutput = 'portrait' | 'fullbody' | 'topdown'; export type AvatarProviderId = - | 'local_photo_likeness' - | 'local_photo_likeness_offline' - | 'openai' + | 'gpt_image' + | 'photo_crop' + | 'photo_crop_offline' | 'unconfigured'; export interface LikenessTraits { @@ -63,6 +63,8 @@ export interface GeneratedMemberAsset { sourcePhotoRetained?: boolean; stockPreview?: boolean; aiProviderError?: string; + bodyLocked?: boolean; + model?: string | null; } export interface AvatarGenerateRequest { @@ -85,6 +87,8 @@ export interface AvatarProviderStatus { aiConfigured: boolean; requestedProvider?: string; model?: string | null; + candidateModels?: string[]; + bodyLockedUntilConfigured?: boolean; requiredForAi: { env: string[]; optionalEnv?: string[]; From 1579cc90a311e513e81a9c6b3dd5d5ba48a684f0 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 17 Oct 2026 16:22:18 +0000 Subject: [PATCH 4/4] feat: lock GTA cover prompt and pin gpt-image-2.5-flare Owner-approved STYLE + SHOT + IDENTITY + SAFETY now lives in one module so portrait, full-body, future combat states, and props share the same look. images.edit uses the upload as identity at 1024x1536 medium, with gpt-image-2 as the only fallback. Co-authored-by: BrandDead --- COLLABORATION.md | 14 +- LAUNCH_READINESS.md | 2 +- backend/python/.env.example | 5 +- backend/python/api/avatar.py | 38 ++--- backend/python/config/slide_art_style.py | 142 +++++++++++++++++++ backend/python/services/likeness.py | 73 +++------- backend/python/tests/test_avatar_likeness.py | 99 +++++++++++-- docs/PHOTO_LIKENESS.md | 17 ++- docs/PROJECT_LOG.md | 6 + docs/STYLE_PROMPT.md | 40 ++++++ frontend/.env.example | 5 +- frontend/src/types/avatar.types.ts | 2 + 12 files changed, 345 insertions(+), 98 deletions(-) create mode 100644 backend/python/config/slide_art_style.py create mode 100644 docs/STYLE_PROMPT.md diff --git a/COLLABORATION.md b/COLLABORATION.md index b31adc4..54f4f9a 100644 --- a/COLLABORATION.md +++ b/COLLABORATION.md @@ -35,14 +35,14 @@ UE5 one-fight only if shooting is the public promise. ## Art direction (locked) - **World:** cinematic 2.5D tactical diorama. See `.agents/skills/slide-visual-art-direction`. -- **Crew likeness:** GTA V loading-screen / cover-art illustrated realism. - Believable adult proportions and clothing. Same look across portrait, - full-body, and in-game states. +- **Crew likeness:** Locked GTA V loading-screen / box-cover illustration + (owner-approved over cartoon). Shared STYLE + SHOT + IDENTITY + SAFETY + in `backend/python/config/slide_art_style.py` and `docs/STYLE_PROMPT.md`. - **Forbidden in player-facing views:** South Park, clip-art, vector characters, stick-figure bodies, stock role art presented as a friend’s face. -- **Vendor:** OpenAI GPT Image only (`gpt-image-2` → `gpt-image-1.5` → - `gpt-image-1`). Enable with `OPENAI_API_KEY`. Steps: - `docs/PHOTO_LIKENESS.md`. +- **Vendor:** `gpt-image-2.5-flare` via `images.edit` at `1024x1536` / + `medium`, falling back to `gpt-image-2`. Enable with `OPENAI_API_KEY`. + Steps: `docs/PHOTO_LIKENESS.md`. - **No key:** real photo crop on the roster; body labeled locked. - Fictional content only. Personal-crew photos need adult + permission + processing consent. Never persist the source photo on the roster. @@ -139,8 +139,10 @@ Stay off this list until #210 merges or the integration owner reassigns it: - `backend/python/api/avatar.py` - `backend/python/services/likeness.py` +- `backend/python/config/slide_art_style.py` - `backend/python/tests/test_avatar_likeness.py` - `backend/python/.env.example` (likeness block only) +- `docs/STYLE_PROMPT.md` - `frontend/src/types/avatar.types.ts` - `frontend/src/services/avatarGeneration.service.ts` - `frontend/src/services/likenessCompositor.ts` diff --git a/LAUNCH_READINESS.md b/LAUNCH_READINESS.md index 17447f5..52a7140 100644 --- a/LAUNCH_READINESS.md +++ b/LAUNCH_READINESS.md @@ -196,7 +196,7 @@ Owner rejected the first likeness pass as South Park / clip-art / vector. That l | Backend down | Stock `/assets/runtime/` portraits | Browser photo crop. No vector body. | | Consent | One checkbox, then three | Adult + permission + processing; API rejects without them | | Source photo | Temp file deleted | Unchanged and tested. Roster never stores the upload. | -| AI vendor | Undecided; DALL·E 2 stub if a key happened to be set | **GPT Image only.** `OPENAI_API_KEY` enables it. Model order: `gpt-image-2` → `gpt-image-1.5` → `gpt-image-1`. DALL·E is never called. Failures return the photo crop + `aiProviderError`, never a stick figure. | +| AI vendor | Undecided; DALL·E 2 stub if a key happened to be set | **GPT Image only.** `gpt-image-2.5-flare` via `images.edit` at 1024×1536 medium, fallback `gpt-image-2`. Shared STYLE+SHOT+IDENTITY+SAFETY in `config/slide_art_style.py`. DALL·E is never called. Failures return the photo crop + `aiProviderError`, never a stick figure. | Key is **not set yet**. Enable steps: `docs/PHOTO_LIKENESS.md`. diff --git a/backend/python/.env.example b/backend/python/.env.example index 1479b62..39fbbff 100644 --- a/backend/python/.env.example +++ b/backend/python/.env.example @@ -36,8 +36,9 @@ ENABLE_WORLD_TICK=true # Unset: roster shows the real photo crop; body stays locked. # Set OPENAI_API_KEY to enable GTA-style generation. Never commit the key. # OPENAI_API_KEY= -# Optional pin. Default tries gpt-image-2, then gpt-image-1.5, then gpt-image-1. -# SLIDE_AVATAR_MODEL=gpt-image-2 +# Optional pin. Default tries gpt-image-2.5-flare, then gpt-image-2. +# Size 1024x1536, quality medium, images.edit with the upload as identity. +# SLIDE_AVATAR_MODEL=gpt-image-2.5-flare # pip install 'openai>=1.40.0' is required for the GPT Image path. # DALL·E is never used. SLIDE_AVATAR_PROVIDER is no longer required. # See docs/PHOTO_LIKENESS.md. diff --git a/backend/python/api/avatar.py b/backend/python/api/avatar.py index 023d2a7..4cf6476 100644 --- a/backend/python/api/avatar.py +++ b/backend/python/api/avatar.py @@ -18,12 +18,12 @@ from utils.utc import utc_now_iso from flask import Blueprint, request, jsonify, send_file +from config.slide_art_style import gpt_image_edit_params, gpt_image_models from services.likeness import ( ALLOWED_MIME, MAX_UPLOAD_BYTES, build_identity_prompt, compose_member_assets, - gpt_image_models, provider_status, ) @@ -33,12 +33,6 @@ _jobs: dict = {} _ASSET_ROOT = "/tmp/slide_generated" -_CAMERAS = { - "portrait": "chest-up cover-art portrait, GTA V loading-screen framing", - "fullbody": "full body standing, front three-quarter, feet visible, same wardrobe and face", - "topdown": "slight high-oblique in-game token of the same person and outfit, readable silhouette", -} - def _data_url(png_bytes: bytes) -> str: return "data:image/png;base64," + base64.b64encode(png_bytes).decode("ascii") @@ -82,7 +76,7 @@ def generate_member_assets(self, source_image_path, role, style, outputs): requested = ["portrait", *requested] composed = compose_member_assets(source_image_path, role, style, requested) traits = composed["likenessTraits"] - prompt = build_identity_prompt(traits, role, style, _CAMERAS["portrait"]) + prompt = build_identity_prompt(traits, role, style, "portrait") return { "files": composed["files"], "likenessTraits": traits, @@ -120,9 +114,7 @@ def generate_member_assets(self, source_image_path, role, style, outputs): last_prompt = crop["promptUsed"] for output in requested: - prompt = build_identity_prompt( - traits, role, style, _CAMERAS.get(output, output) - ) + prompt = build_identity_prompt(traits, role, style, output) last_prompt = prompt generated = None for model in gpt_image_models(): @@ -155,22 +147,30 @@ def generate_member_assets(self, source_image_path, role, style, outputs): def _edit_with_model(self, client, model: str, image_path: str, prompt: str) -> bytes: if "dall-e" in model.lower(): raise RuntimeError(f"Refusing forbidden model {model}") + params = gpt_image_edit_params(prompt) with open(image_path, "rb") as img_file: try: response = client.images.edit( model=model, image=img_file, - prompt=prompt, - n=1, - size="1024x1024", + **params, ) except TypeError: img_file.seek(0) - response = client.images.edit( - model=model, - image=img_file, - prompt=prompt, - ) + slim = {key: value for key, value in params.items() if key != "quality"} + try: + response = client.images.edit( + model=model, + image=img_file, + **slim, + ) + except TypeError: + img_file.seek(0) + response = client.images.edit( + model=model, + image=img_file, + prompt=prompt, + ) payload = response.data[0] if getattr(payload, "b64_json", None): return base64.b64decode(payload.b64_json) diff --git a/backend/python/config/slide_art_style.py b/backend/python/config/slide_art_style.py new file mode 100644 index 0000000..3b42a53 --- /dev/null +++ b/backend/python/config/slide_art_style.py @@ -0,0 +1,142 @@ +"""Locked SLIDE GTA-cover art direction. + +Owner-approved (round 3). Every GPT Image call — crew portrait, full-body, +future combat states (idle / aim / fire / hit / downed), and props — is +assembled as: + + STYLE + SHOT + IDENTITY + SAFETY + +Do not invent a second style string in likeness, avatar, or art routes. +The human-readable source is `docs/STYLE_PROMPT.md`. +""" + +from __future__ import annotations + +import os + +# Pinned production model. Fallback is the same prompt on gpt-image-2. +GPT_IMAGE_PRIMARY = "gpt-image-2.5-flare" +GPT_IMAGE_FALLBACK = "gpt-image-2" +GPT_IMAGE_MODELS = (GPT_IMAGE_PRIMARY, GPT_IMAGE_FALLBACK) + +GPT_IMAGE_SIZE = "1024x1536" +GPT_IMAGE_QUALITY = "medium" +GPT_IMAGE_N = 1 + +# Shared look. Portrait, body, states, and props all start here. +STYLE = ( + "Official Grand Theft Auto V loading-screen / box-cover illustration style. " + "Hand-painted semi-realistic digital art with a strong graphic-novel finish: " + "bold confident black ink contour lines around the silhouette, clothing folds " + "and facial features; cel-shaded skin and fabric with 2-3 crisp hard-edged tone " + "steps plus subtle painterly brush texture; posterized deep shadows; punchy " + "saturated Los Santos sunset palette (burnt orange, hot magenta, deep teal-purple " + "shadows); strong warm rim light from behind; faint print grain. Realistic adult " + "anatomy and true-to-life facial structure, heroic but believable — a painted " + "Rockstar cover character, clearly an illustration and NOT a photo filter, NOT a " + "cartoon, NOT anime, NOT a 3D render, NOT flat vector, NOT South Park. " + "Re-pose and re-frame the person as a professional cover shot: not a selfie, no " + "outstretched arm, shoulders squared to camera, camera at eye level, slightly " + "low heroic angle, cool self-assured expression. " + "Background: stylized Los Santos-like palm trees and hazy downtown skyline at " + "sunset, softer and less detailed than the character so the character pops." +) + +SHOTS: dict[str, str] = { + "portrait": ( + "Character portrait card: head and shoulders to mid-chest, three-quarter " + "view, confident neutral expression, dramatic warm sunset rim light, " + "plain gradient sky backdrop." + ), + "fullbody": ( + "Full-body character art: the person standing in a relaxed confident pose, " + "whole body visible head to shoes, wearing believable modern streetwear " + "(fitted jacket or hoodie, joggers or jeans, clean sneakers, unbranded), " + "realistic adult proportions, isolated on a simple urban dusk backdrop." + ), + "topdown": ( + "Slight high-oblique in-game token of the same person and outfit: full " + "silhouette readable at small size, feet visible, same Los Santos sunset " + "grade and inked contour as the cover art." + ), + "idle": ( + "Full-body in-game idle state: standing ready, same person and wardrobe " + "as the approved cover, feet visible, isolated dusk backdrop." + ), + "aim": ( + "Full-body in-game aim state: weapon or hands raised to aim, same person " + "and wardrobe as the approved cover, feet planted, isolated dusk backdrop." + ), + "fire": ( + "Full-body in-game fire state: the aim pose at the moment of discharge, " + "same person and wardrobe as the approved cover, isolated dusk backdrop." + ), + "hit": ( + "Full-body in-game hit state: reacting to impact, same person and wardrobe " + "as the approved cover, readable silhouette, isolated dusk backdrop." + ), + "downed": ( + "Full-body in-game downed state: on the ground, same person and wardrobe " + "as the approved cover, isolated dusk backdrop." + ), + "prop": ( + "Isolated game prop in the same GTA V loading-screen illustration style " + "as the crew: bold ink contours, cel-shaded materials, Los Santos sunset " + "grade, unbranded, no text." + ), + "vehicle": ( + "Fictional vehicle in the same GTA V loading-screen illustration style as " + "the crew: believable proportions, unbranded, sunset grade, isolated." + ), + "product": ( + "Fictional packaged product in the same GTA V loading-screen illustration " + "style as the crew: unbranded, no readable labels, sunset grade, isolated." + ), +} + +IDENTITY = ( + "IDENTITY LOCK: the character must be unmistakably the same person as in the " + "reference photo — keep the exact face shape, jawline, skin tone, eye shape, " + "nose, lips, eyebrows, hairstyle and hair color, facial hair, piercings, scars " + "and approximate age. Do not beautify, slim, whiten or change ethnicity. " + "Stylize the rendering, not the person." +) + +SAFETY = ( + "No text, no logos, no brand marks, no gang signs or gang colors, no weapons." +) + +# Alias used by older likeness tests and docs. +GTA_ART_DIRECTION = STYLE + + +def compose_art_prompt(shot: str, extra: str = "") -> str: + """STYLE + SHOT + optional extra + IDENTITY + SAFETY.""" + shot_text = SHOTS.get(shot, SHOTS["portrait"]) + parts = [STYLE, shot_text] + if extra.strip(): + parts.append(extra.strip()) + parts.extend([IDENTITY, SAFETY]) + return "\n\n".join(parts) + + +def gpt_image_models() -> list[str]: + """Flare first, then gpt-image-2. DALL·E values in env are ignored.""" + requested = (os.environ.get("SLIDE_AVATAR_MODEL") or "").strip() + models: list[str] = [] + if requested and "dall-e" not in requested.lower() and not requested.lower().startswith("dall"): + models.append(requested) + for name in GPT_IMAGE_MODELS: + if name not in models: + models.append(name) + return [name for name in models if "dall-e" not in name.lower()] + + +def gpt_image_edit_params(prompt: str) -> dict: + """images.edit kwargs. The uploaded photo is passed separately as `image`.""" + return { + "prompt": prompt, + "n": GPT_IMAGE_N, + "size": GPT_IMAGE_SIZE, + "quality": GPT_IMAGE_QUALITY, + } diff --git a/backend/python/services/likeness.py b/backend/python/services/likeness.py index 14c44b5..b735f1f 100644 --- a/backend/python/services/likeness.py +++ b/backend/python/services/likeness.py @@ -17,6 +17,12 @@ from PIL import Image, ImageDraw, ImageEnhance, ImageFilter, ImageOps +from config.slide_art_style import ( + GTA_ART_DIRECTION, + compose_art_prompt, + gpt_image_models, +) + ALLOWED_MIME = { "image/jpeg", "image/jpg", @@ -26,18 +32,6 @@ } MAX_UPLOAD_BYTES = 8 * 1024 * 1024 -# Latest-first GPT Image family. Never fall back to DALL·E. -GPT_IMAGE_MODELS = ("gpt-image-2", "gpt-image-1.5", "gpt-image-1") - -GTA_ART_DIRECTION = ( - "GTA V loading-screen / cover-art illustrated realism. Semi-realistic " - "painted character art with believable adult anatomy, real clothing " - "materials, and cinematic wet-night tropical-noir grade. Same look " - "across portrait, full-body, and in-game states. NOT South Park, NOT " - "clip-art, NOT vector, NOT a stick figure, NOT cel-shaded cartoon, " - "NOT a chibi or mascot." -) - SKIN_BANDS = ( ("ivory", (248, 228, 212)), ("sand", (232, 196, 162)), @@ -296,49 +290,18 @@ def compose_member_assets( } -def build_identity_prompt(traits: dict, role: str, style: str, camera: str) -> str: +def build_identity_prompt(traits: dict, role: str, style: str, shot: str) -> str: clothing = STYLE_CLOTHING.get(style, STYLE_CLOTHING["south_florida_streetwear"]) hair = traits.get("hairColor", "dark") skin = traits.get("skinTone", "medium") facial = "Keep the same facial hair." if traits.get("facialHair") == "visible" else "Do not invent heavy facial hair." notes = "; ".join(traits.get("notes") or []) - return ( - "SCENE\n" - f"SLIDE custom crew card. {GTA_ART_DIRECTION} Clean silhouette, " - "transparent-friendly dark ground, no scenery, no HUD text.\n\n" - "SUBJECT\n" - f"A fictional playable {role} whose face is the same person as the " - "uploaded identity-reference photo. Cosmetic game avatar only — not " - "a real-world accusation.\n\n" - "IMPORTANT DETAILS\n" - f"Preserve face shape, eye spacing, nose, jaw, skin tone ({skin}), " - f"hair color and hairline ({hair}). {facial} Clothing: {clothing['notes']}. " - f"Role readable as {role}. Camera: {camera}. {notes}\n\n" - "USE CASE\n" - "Crew roster portrait / full-body card / in-game token. Same identity " - "and wardrobe across every generated state.\n\n" - "CONSTRAINTS\n" - "The uploaded photo is the identity reference — keep this person. " - "No South Park, clip-art, vector, stick-figure, emoji, or mascot look. " - "No real brand logos, watermarks, baked UI text, real criminal " - "organizations, minors, or photoreal nude content." + extra = ( + f"Fictional playable {role} whose face is the identity-reference photo. " + f"Preserve skin tone ({skin}), hair ({hair}). {facial} " + f"Wardrobe: {clothing['notes']}. {notes}" ) - - -def gpt_image_models() -> list[str]: - """Latest-first GPT Image list. DALL·E values in env are ignored.""" - requested = (os.environ.get("SLIDE_AVATAR_MODEL") or "").strip() - models: list[str] = [] - if requested and "dall-e" not in requested.lower() and requested.lower().startswith("gpt-image"): - models.append(requested) - elif requested and "dall-e" not in requested.lower() and requested: - # Allow a future gpt-image name the owner pins, but never DALL·E. - if not requested.lower().startswith("dall"): - models.append(requested) - for name in GPT_IMAGE_MODELS: - if name not in models: - models.append(name) - return [name for name in models if "dall-e" not in name.lower()] + return compose_art_prompt(shot, extra=extra) def provider_status() -> dict: @@ -351,16 +314,20 @@ def provider_status() -> dict: "model": models[0] if has_key else None, "candidateModels": list(models) if has_key else [], "bodyLockedUntilConfigured": not has_key, + "size": "1024x1536", + "quality": "medium", "requiredForAi": { "env": ["OPENAI_API_KEY"], "optionalEnv": [ - "SLIDE_AVATAR_MODEL (optional pin; default tries gpt-image-2, then gpt-image-1.5, then gpt-image-1)" + "SLIDE_AVATAR_MODEL (optional pin; default tries gpt-image-2.5-flare, then gpt-image-2)" ], "pythonPackage": "openai>=1.40.0", "notes": ( - "OPENAI_API_KEY alone enables GPT Image. Without it the roster " - "shows the real photo crop and the body stays locked. Generation " - "never falls back to DALL·E or a stick-figure body." + "OPENAI_API_KEY alone enables GPT Image. Default model is " + "gpt-image-2.5-flare via images.edit at 1024x1536 medium, " + "falling back to gpt-image-2. Without a key the roster shows " + "the real photo crop and the body stays locked. Never DALL·E " + "or a stick-figure body." ), }, } diff --git a/backend/python/tests/test_avatar_likeness.py b/backend/python/tests/test_avatar_likeness.py index a42c2e1..8ddb96d 100644 --- a/backend/python/tests/test_avatar_likeness.py +++ b/backend/python/tests/test_avatar_likeness.py @@ -10,6 +10,16 @@ sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..')) from app import create_app +from config.slide_art_style import ( + GPT_IMAGE_FALLBACK, + GPT_IMAGE_PRIMARY, + IDENTITY, + SAFETY, + SHOTS, + STYLE, + compose_art_prompt, + gpt_image_edit_params, +) from services.likeness import ( GTA_ART_DIRECTION, analyze_photo, @@ -75,11 +85,24 @@ def test_provider_endpoint_reports_photo_crop_without_key(client, monkeypatch): def test_provider_status_enables_gpt_image_from_key_alone(monkeypatch): monkeypatch.setenv("OPENAI_API_KEY", "sk-test-not-real") monkeypatch.delenv("SLIDE_AVATAR_PROVIDER", raising=False) + monkeypatch.delenv("SLIDE_AVATAR_MODEL", raising=False) status = provider_status() assert status["provider"] == "gpt_image" assert status["aiConfigured"] is True assert status["bodyLockedUntilConfigured"] is False - assert status["model"].startswith("gpt-image") + assert status["model"] == GPT_IMAGE_PRIMARY + assert status["size"] == "1024x1536" + assert status["quality"] == "medium" + assert status["candidateModels"][:2] == [GPT_IMAGE_PRIMARY, GPT_IMAGE_FALLBACK] + + +def test_gpt_image_models_flare_then_gpt_image_2(monkeypatch): + monkeypatch.delenv("SLIDE_AVATAR_MODEL", raising=False) + models = gpt_image_models() + assert models[0] == "gpt-image-2.5-flare" + assert models[1] == "gpt-image-2" + assert "dall-e-2" not in models + assert "gpt-image-1" not in models def test_gpt_image_models_never_include_dalle(monkeypatch): @@ -87,25 +110,51 @@ def test_gpt_image_models_never_include_dalle(monkeypatch): models = gpt_image_models() assert models assert all("dall-e" not in name.lower() for name in models) - assert models[0].startswith("gpt-image") + assert models[0] == "gpt-image-2.5-flare" -def test_identity_prompt_locks_gta_illustrated_realism(): +def test_identity_prompt_locks_gta_cover_pieces(): prompt = build_identity_prompt( {"skinTone": "bronze", "hairColor": "black", "facialHair": "visible", "notes": []}, "shooter", "south_florida_streetwear", - "full-body standing, feet visible", + "fullbody", ) - assert GTA_ART_DIRECTION in prompt + assert STYLE in prompt + assert SHOTS["fullbody"] in prompt + assert IDENTITY in prompt + assert SAFETY in prompt + assert GTA_ART_DIRECTION == STYLE lowered = prompt.lower() assert "south park" in lowered - assert "clip-art" in lowered or "clip art" in lowered - assert "vector" in lowered - assert "gta" in lowered + assert "identity lock" in lowered + assert "head to shoes" in lowered assert "dall-e" not in lowered +def test_style_module_shares_style_across_states_and_props(): + portrait = compose_art_prompt("portrait") + idle = compose_art_prompt("idle") + prop = compose_art_prompt("prop") + assert portrait.startswith(STYLE) + assert idle.startswith(STYLE) + assert prop.startswith(STYLE) + assert SHOTS["portrait"] in portrait + assert SHOTS["idle"] in idle + assert SHOTS["prop"] in prop + assert IDENTITY in idle and SAFETY in prop + + +def test_edit_params_are_flare_card_size(): + params = gpt_image_edit_params("locked prompt") + assert params == { + "prompt": "locked prompt", + "n": 1, + "size": "1024x1536", + "quality": "medium", + } + + def test_generate_requires_adult_and_permission_consent(client): png = _portrait_png() response = client.post( @@ -224,3 +273,37 @@ def boom(*_args, **_kwargs): assert asset["bodyLocked"] is True assert asset["fullbodyUrl"] is None assert "gpt-image unavailable" in asset["aiProviderError"] + + +def test_edit_call_uses_flare_size_and_medium_quality(tmp_path): + from api.avatar import OpenAIAvatarProvider + + captured: dict = {} + + class Payload: + b64_json = __import__("base64").b64encode(b"\x89PNG\r\n\x1a\nxxxx").decode("ascii") + url = None + + class Response: + data = [Payload()] + + class FakeImages: + def edit(self, **kwargs): + captured.update(kwargs) + return Response() + + class FakeClient: + images = FakeImages() + + path = tmp_path / "ref.png" + path.write_bytes(_portrait_png()) + payload = OpenAIAvatarProvider()._edit_with_model( + FakeClient(), "gpt-image-2.5-flare", str(path), "STYLE prompt" + ) + assert payload[:8] == b"\x89PNG\r\n\x1a\n" + assert captured["model"] == "gpt-image-2.5-flare" + assert captured["size"] == "1024x1536" + assert captured["quality"] == "medium" + assert captured["n"] == 1 + assert captured["prompt"] == "STYLE prompt" + assert captured["image"] is not None diff --git a/docs/PHOTO_LIKENESS.md b/docs/PHOTO_LIKENESS.md index 263e77f..0b3b50e 100644 --- a/docs/PHOTO_LIKENESS.md +++ b/docs/PHOTO_LIKENESS.md @@ -1,9 +1,10 @@ # Photo-to-member likeness (GPT Image) Players can put a friend on their crew by uploading a photo. The owner -target is **GTA V loading-screen / cover-art illustrated realism** — the -same person, believable proportions and clothing, one look across -portrait, full-body, and in-game states. +locked **GTA V loading-screen / box-cover illustration** (round 3). The +same STYLE + IDENTITY + SAFETY string is used for portrait, full-body, +future idle/aim/hit/downed sheets, and props. See `docs/STYLE_PROMPT.md` +and `backend/python/config/slide_art_style.py`. Vector, South Park, clip-art, and stick-figure bodies are not a valid fallback. If GPT Image is not configured, the roster shows the **real @@ -35,11 +36,11 @@ No other vendor flag is required. ```bash # 1. Put the key in the Flask env (backend/python/.env). Never commit it. +# Cursor cloud agent secrets can also inject OPENAI_API_KEY. OPENAI_API_KEY=sk-... -# 2. Optional pin. If unset, the server tries latest-first: -# gpt-image-2 → gpt-image-1.5 → gpt-image-1 -# SLIDE_AVATAR_MODEL=gpt-image-2 +# 2. Optional pin. Default is gpt-image-2.5-flare, then gpt-image-2. +# SLIDE_AVATAR_MODEL=gpt-image-2.5-flare # 3. Install the client in the backend venv. cd backend/python @@ -62,7 +63,9 @@ Ready looks like: "provider": "gpt_image", "aiConfigured": true, "bodyLockedUntilConfigured": false, - "model": "gpt-image-2" + "model": "gpt-image-2.5-flare", + "size": "1024x1536", + "quality": "medium" } ``` diff --git a/docs/PROJECT_LOG.md b/docs/PROJECT_LOG.md index d959657..639d1f9 100644 --- a/docs/PROJECT_LOG.md +++ b/docs/PROJECT_LOG.md @@ -69,6 +69,12 @@ Payments/monetization P0s are separately listed in `docs/MVP_STATUS_AND_DEV_PLAN ## Log +### 2026-10-17 — Locked GTA cover prompt + gpt-image-2.5-flare (#210) + +- Owner approved the round-3 GTA V loading-screen / box-cover look over the cartoon path. Winning prompt is `docs/STYLE_PROMPT.md`. Runtime pieces live in one module, `backend/python/config/slide_art_style.py` (STYLE + SHOT + IDENTITY + SAFETY), shared by portrait, full-body, future idle/aim/fire/hit/downed sheets, and props. +- GPT Image calls use `images.edit` with the upload as the identity reference, size `1024x1536`, quality `medium`. Primary model `gpt-image-2.5-flare`, fallback `gpt-image-2`. DALL·E still never used. No-key path is unchanged: photo crop + body locked. +- `OPENAI_API_KEY` was not present in this environment at wiring time. Owner is adding it to Cursor cloud agent secrets. + ### 2026-10-17 — GPT Image likeness, no vector bodies, Manus handoff (#210) - Owner rejected the stick-figure / clip-art full-body as unacceptable. Target is GTA V loading-screen illustrated realism, same look across portrait, body, and in-game states. diff --git a/docs/STYLE_PROMPT.md b/docs/STYLE_PROMPT.md new file mode 100644 index 0000000..33fa979 --- /dev/null +++ b/docs/STYLE_PROMPT.md @@ -0,0 +1,40 @@ +# SLIDE gang member art direction — LOCKED (round 3) + +**Model:** `gpt-image-2.5-flare` (pinned snapshot `gpt-image-2.5-flare-2026-09-08`). Fallback: `gpt-image-2` with the identical prompt (slightly softer/less inked, but on-style). +**Endpoint:** `images.edit` with the friend's uploaded photo as the single `image` input (identity reference). +**Size:** `1024x1536` (portrait card and full-body). **Quality:** `medium`. n=1. No `input_fidelity` param needed. + +Runtime copy of these pieces lives in `backend/python/config/slide_art_style.py`. +Portrait, full-body, future state sheets (idle / aim / fire / hit / downed), and +props all share STYLE + IDENTITY + SAFETY. Only SHOT changes. + +## Prompt = STYLE + SHOT + IDENTITY + SAFETY + +### STYLE (shared) +Official Grand Theft Auto V loading-screen / box-cover illustration style. Hand-painted semi-realistic digital art with a strong graphic-novel finish: bold confident black ink contour lines around the silhouette, clothing folds and facial features; cel-shaded skin and fabric with 2-3 crisp hard-edged tone steps plus subtle painterly brush texture; posterized deep shadows; punchy saturated Los Santos sunset palette (burnt orange, hot magenta, deep teal-purple shadows); strong warm rim light from behind; faint print grain. Realistic adult anatomy and true-to-life facial structure, heroic but believable — a painted Rockstar cover character, clearly an illustration and NOT a photo filter, NOT a cartoon, NOT anime, NOT a 3D render, NOT flat vector, NOT South Park. +Re-pose and re-frame the person as a professional cover shot: not a selfie, no outstretched arm, shoulders squared to camera, camera at eye level, slightly low heroic angle, cool self-assured expression. +Background: stylized Los Santos-like palm trees and hazy downtown skyline at sunset, softer and less detailed than the character so the character pops. + +### SHOT — portrait card +Character portrait card: head and shoulders to mid-chest, three-quarter view, confident neutral expression, dramatic warm sunset rim light, plain gradient sky backdrop. + +### SHOT — full body +Full-body character art: the person standing in a relaxed confident pose, whole body visible head to shoes, wearing believable modern streetwear (fitted jacket or hoodie, joggers or jeans, clean sneakers, unbranded), realistic adult proportions, isolated on a simple urban dusk backdrop. + +### IDENTITY (always append) +IDENTITY LOCK: the character must be unmistakably the same person as in the reference photo — keep the exact face shape, jawline, skin tone, eye shape, nose, lips, eyebrows, hairstyle and hair color, facial hair, piercings, scars and approximate age. Do not beautify, slim, whiten or change ethnicity. Stylize the rendering, not the person. + +### SAFETY (always append) +No text, no logos, no brand marks, no gang signs or gang colors, no weapons. + +## Cost (from API usage + platform.openai.com/docs/pricing, Oct 17 2026) +gpt-image-2.5-flare: $8/1M image-in, $5/1M text-in, $30/1M image-out. +Observed per image (1024x1536, medium): ~1,400 input tokens + 343 output tokens ≈ **$0.02/image → ≈ $0.04–0.05 per member** (portrait + full body). +gpt-image-2 fallback: same input, 1,372 output tokens ≈ $0.05/image → ≈ $0.10 per member. +Batch API halves these. Estimates; confirm on the billing dashboard. + +## Notes +- Portraits sometimes inherit the selfie head tilt/crop from the upload; acceptable, or crop server-side. +- Sneakers can drift toward recognizable silhouettes (no logos rendered in tests); keep "unbranded". +- Test inputs used to lock this look are synthetic AI-generated fictional people, not real persons. +- Owner approved this direction over the cartoon / South Park / clip-art look. diff --git a/frontend/.env.example b/frontend/.env.example index 153b9a2..1f43e0a 100644 --- a/frontend/.env.example +++ b/frontend/.env.example @@ -21,5 +21,6 @@ VITE_ENV=development # VITE_DEMO_MODE=1 # Photo-to-member likeness talks to Flask at VITE_API_URL. -# No frontend OpenAI key. Set OPENAI_API_KEY on the backend to unlock -# GTA-style bodies. See docs/PHOTO_LIKENESS.md. +# No frontend OpenAI key. Backend uses gpt-image-2.5-flare +# (fallback gpt-image-2) when OPENAI_API_KEY is set. +# See docs/PHOTO_LIKENESS.md and docs/STYLE_PROMPT.md. diff --git a/frontend/src/types/avatar.types.ts b/frontend/src/types/avatar.types.ts index 6aae6a6..bf2b5c3 100644 --- a/frontend/src/types/avatar.types.ts +++ b/frontend/src/types/avatar.types.ts @@ -89,6 +89,8 @@ export interface AvatarProviderStatus { model?: string | null; candidateModels?: string[]; bodyLockedUntilConfigured?: boolean; + size?: string; + quality?: string; requiredForAi: { env: string[]; optionalEnv?: string[];