diff --git a/COLLABORATION.md b/COLLABORATION.md new file mode 100644 index 0000000..54f4f9a --- /dev/null +++ b/COLLABORATION.md @@ -0,0 +1,199 @@ +# 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:** 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:** `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. + 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/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` +- `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 new file mode 100644 index 0000000..52a7140 --- /dev/null +++ b/LAUNCH_READINESS.md @@ -0,0 +1,250 @@ +# 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. 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 + +`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).** + 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. + +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) + +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** | 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.** `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`. + +--- + +## 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 and enable steps: `docs/PHOTO_LIKENESS.md`. Manus handoff: `COLLABORATION.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. 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. +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/backend/python/.env.example b/backend/python/.env.example index bb9a1a7..39fbbff 100644 --- a/backend/python/.env.example +++ b/backend/python/.env.example @@ -31,3 +31,14 @@ GOOGLE_MAPS_API_KEY= ENABLE_STREET_VIEW=false ENABLE_3D_TILES=false ENABLE_WORLD_TICK=true + +# 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= +# 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 224796f..4cf6476 100644 --- a/backend/python/api/avatar.py +++ b/backend/python/api/avatar.py @@ -2,28 +2,59 @@ 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 +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 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 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, + 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 +65,178 @@ def generate_member_assets( raise NotImplementedError -class MockAvatarProvider(AvatarGenerationProvider): - """Returns placeholder asset URLs immediately — no external API needed.""" +class PhotoCropProvider(AvatarGenerationProvider): + """Honest photo crop. Body stays locked until GPT Image is configured.""" - 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 = "photo_crop" 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 + 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"] + prompt = build_identity_prompt(traits, role, style, "portrait") + return { + "files": composed["files"], + "likenessTraits": traits, + "promptUsed": prompt, + "provider": self.name, + "bodyLocked": True, + } class OpenAIAvatarProvider(AvatarGenerationProvider): """ - Real OpenAI image-to-image provider. - Requires OPENAI_API_KEY in environment. + GPT Image only. Requires OPENAI_API_KEY. Uses the uploaded photo as + the identity reference. Never calls DALL·E. """ + name = "gpt_image" + 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 + + crop = PhotoCropProvider().generate_member_assets( + source_image_path, role, style, ["portrait"] + ) 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", + 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, output) + last_prompt = prompt + generated = None + for model in gpt_image_models(): + if "dall-e" in model.lower(): + continue + try: + generated = self._edit_with_model( + client, model, source_image_path, prompt + ) + 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": last_prompt, + "provider": self.name, + "bodyLocked": "fullbody" not in files, + "model": used_model, } - 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." - ) - - with open(source_image_path, "rb") as img_file: + 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="dall-e-2", + model=model, image=img_file, - prompt=prompt, - n=1, - size="1024x1024", + **params, ) - url = response.data[0].url - if output == "portrait": - result["portraitUrl"] = url - elif output == "fullbody": - result["fullbodyUrl"] = url - else: - result["topdownUrl"] = url - - result["promptUsed"] = prompt - return result + except TypeError: + img_file.seek(0) + 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) + 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: - """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 PhotoCropProvider() + +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 GPT Image 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" @@ -165,16 +244,26 @@ def generate(): image_file.save(tmp_path) job_id = str(uuid.uuid4()) - provider = get_provider() + selected = 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 = selected.generate_member_assets(tmp_path, role, style, outputs) + except Exception as exc: + if selected.name != "photo_crop": + ai_error = str(exc) + generated = PhotoCropProvider().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 +271,37 @@ def generate(): "outputs": outputs, "status": "ready", "createdAt": utc_now_iso(), - **assets, + "sourcePhotoRetained": False, + "stockPreview": False, + **generated, + "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"] = "photo_crop" + asset["bodyLocked"] = True _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 +310,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/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 new file mode 100644 index 0000000..b735f1f --- /dev/null +++ b/backend/python/services/likeness.py @@ -0,0 +1,333 @@ +"""Photo-crop analysis and the locked GTA-style likeness prompt. + +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 +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 + +from config.slide_art_style import ( + GTA_ART_DIRECTION, + compose_art_prompt, + gpt_image_models, +) + +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", + "notes": "oversized tee, baggy jeans, sneakers, thin chain — real fabric, no logos", + }, + "luxury_noir": { + "label": "luxury noir", + "notes": "dark tailored layers, gold hardware, no real logos", + }, + "masked_tactical": { + "label": "tactical streetwear", + "notes": "utility vest, muted olive, pulled collar — face stays fully visible", + }, + "baggy_2000s": { + "label": "baggy 2000s streetwear", + "notes": "oversized jersey, baggy jeans, work boots — real cloth folds", + }, + "clean_designer": { + "label": "clean designer streetwear", + "notes": "fitted knit, clean sneakers, quiet jewelry — no brand marks", + }, +} + +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 _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: + 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 GPT Image prompts and the photo crop.""" + 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) + 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: + """Honest photo crop. Frame chrome only — no invented body.""" + rgb = source.convert("RGB") + face = tuple(traits["faceBox"]) + 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 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: + """Photo-crop only. Requested full-body / token outputs stay locked.""" + _ = style + with Image.open(source_path) as raw: + source = raw.convert("RGB") + max_edge = 1600 + if max(source.size) > max_edge: + source.thumbnail((max_edge, max_edge), Image.Resampling.LANCZOS) + traits = analyze_photo(source) + 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"], + "facialHair": traits["facialHair"], + "clothingColors": [traits["skinTone"], _nearest_name(tuple(traits["clothingRgb"]), HAIR_BANDS)], + "notes": traits["notes"], + }, + "files": files, + "bodyLocked": True, + } + + +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 []) + 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}" + ) + return compose_art_prompt(shot, extra=extra) + + +def provider_status() -> dict: + has_key = bool((os.environ.get("OPENAI_API_KEY") or "").strip()) + models = gpt_image_models() + return { + "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, + "size": "1024x1536", + "quality": "medium", + "requiredForAi": { + "env": ["OPENAI_API_KEY"], + "optionalEnv": [ + "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. 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 new file mode 100644 index 0000000..8ddb96d --- /dev/null +++ b/backend/python/tests/test_avatar_likeness.py @@ -0,0 +1,309 @@ +"""Photo-likeness API: consent, photo crop, no vector bodies, GPT Image config.""" + +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 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, + build_identity_prompt, + compose_member_assets, + gpt_image_models, + provider_status, +) + + +@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_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"] == "photo_crop" + assert data["aiConfigured"] is False + 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) + 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"] == 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): + 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] == "gpt-image-2.5-flare" + + +def test_identity_prompt_locks_gta_cover_pieces(): + prompt = build_identity_prompt( + {"skinTone": "bronze", "hairColor": "black", "facialHair": "visible", "notes": []}, + "shooter", + "south_florida_streetwear", + "fullbody", + ) + 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 "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( + "/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_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") + 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["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", + } + 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 len(portrait.data) > 20_000 + assert not any( + stock in (asset["portraitUrl"] + asset.get("promptUsed", "")) + for stock in ( + "character_dealer_male_blacktee_portrait", + "/assets/generated/characters/portraits/", + ) + ) + + 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") + 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_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", "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"] + + +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 new file mode 100644 index 0000000..0b3b50e --- /dev/null +++ b/docs/PHOTO_LIKENESS.md @@ -0,0 +1,99 @@ +# Photo-to-member likeness (GPT Image) + +Players can put a friend on their crew by uploading a photo. The owner +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 +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, 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. + +| `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 | + +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. + +## How to enable GPT Image + +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. +# Cursor cloud agent secrets can also inject OPENAI_API_KEY. +OPENAI_API_KEY=sk-... + +# 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 +./venv/bin/pip install 'openai>=1.40.0' + +# 4. Restart Flask so it rereads the env. +./venv/bin/python app.py +``` + +Then check: + +```bash +curl -s http://127.0.0.1:5000/api/avatar/provider +``` + +Ready looks like: + +```json +{ + "provider": "gpt_image", + "aiConfigured": true, + "bodyLockedUntilConfigured": false, + "model": "gpt-image-2.5-flare", + "size": "1024x1536", + "quality": "medium" +} +``` + +Not ready (current default) looks like: + +```json +{ + "provider": "photo_crop", + "aiConfigured": false, + "bodyLockedUntilConfigured": true, + "requiredForAi": { "env": ["OPENAI_API_KEY"] } +} +``` + +`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`. + +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 + +- 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, 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 e4373f7..639d1f9 100644 --- a/docs/PROJECT_LOG.md +++ b/docs/PROJECT_LOG.md @@ -69,6 +69,26 @@ 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. +- 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. +- 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. 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 b68878c..1f43e0a 100644 --- a/frontend/.env.example +++ b/frontend/.env.example @@ -19,3 +19,8 @@ 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 Flask at VITE_API_URL. +# 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/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..6f343e3 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 { @@ -273,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 45ce15e..e8240f9 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,14 @@ 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 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 (
@@ -313,14 +337,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 && ( +

+ 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. +

+ )} +
@@ -431,9 +486,32 @@ 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' + : gtaLikeness + ? 'GTA-style likeness' + : 'Photo crop'} +

{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 && photoCrop && ( +

+ {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?.aiProviderError && !stockPreview && ( +

GPT Image failed: {generatedAsset.aiProviderError}

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

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

)}
{generatedAsset.portraitUrl && ( @@ -442,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 +
+ )}
({ generate: vi.fn(), getStatus: vi.fn(), approve: vi.fn(), + getProviderStatus: vi.fn().mockResolvedValue({ + provider: 'photo_crop', + aiConfigured: false, + bodyLockedUntilConfigured: true, + requiredForAi: { env: ['OPENAI_API_KEY'], pythonPackage: 'openai>=1.40.0', notes: '' }, + }), }, })); @@ -56,7 +62,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 +94,89 @@ 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 crop and a locked body when GPT Image is not configured', async () => { + service.generate.mockResolvedValue(asset({ + id: 'likeness-1', + provider: 'photo_crop', + stockPreview: false, + bodyLocked: true, + fullbodyUrl: undefined, + topdownUrl: undefined, + 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 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 () => { + 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(); @@ -224,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 65f71e9..fe7b176 100644 --- a/frontend/src/services/__tests__/avatarGeneration.service.test.ts +++ b/frontend/src/services/__tests__/avatarGeneration.service.test.ts @@ -2,35 +2,83 @@ 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 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({ - 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-/); - for (const url of [asset.portraitUrl, asset.fullbodyUrl, asset.topdownUrl]) { - expect(url).toMatch(/^\/assets\/runtime\//); - expect(existsSync(resolve('public', `.${url}`))).toBe(true); - } + expect(asset.id).toMatch(/^likeness-local-/); + expect(asset.provider).toBe('photo_crop_offline'); + expect(asset.stockPreview).toBe(false); + expect(asset.bodyLocked).toBe(true); + expect(isStockPreview(asset)).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); } }); - 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(); + expect(asset.bodyLocked).toBe(true); + }); + + 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', + 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 () => { + 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..c1b832d --- /dev/null +++ b/frontend/src/services/__tests__/likenessCompositor.test.ts @@ -0,0 +1,39 @@ +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 the portrait and locks the body', 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).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); + }); + + 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..8e024d3 100644 --- a/frontend/src/services/avatarGeneration.service.ts +++ b/frontend/src/services/avatarGeneration.service.ts @@ -1,38 +1,18 @@ // ============================================================ // 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. 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 { 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 +32,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..c206fc8 --- /dev/null +++ b/frontend/src/services/likenessCompositor.ts @@ -0,0 +1,96 @@ +// 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, + 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(` + + + + + + + + + + +`); +} + +export async function composeLocalLikeness( + req: AvatarGenerateRequest, +): Promise { + const photo = await readFileAsDataUrl(req.imageFile); + 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: '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 photo crop used the uploaded photo. Body stays locked until GPT Image is configured.'], + }, + promptUsed: 'Offline photo crop. GPT Image unavailable; no vector body and no stock role art.', + }; +} + +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..bf2b5c3 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 = + | 'gpt_image' + | 'photo_crop' + | 'photo_crop_offline' + | 'unconfigured'; + +export interface LikenessTraits { + skinTone: string; + hairColor: string; + facialHair: string; + clothingColors: string[]; + notes: string[]; +} + export interface GeneratedMemberAsset { id: string; sourceImageUrl?: string; @@ -39,9 +52,19 @@ 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; + bodyLocked?: boolean; + model?: string | null; } export interface AvatarGenerateRequest { @@ -49,9 +72,29 @@ 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; + candidateModels?: string[]; + bodyLockedUntilConfigured?: boolean; + size?: string; + quality?: string; + requiredForAi: { + env: string[]; + optionalEnv?: string[]; + pythonPackage: string; + notes: string; + }; +}