mirror of
https://github.com/CompassConnections/Compass.git
synced 2026-07-30 09:48:47 -04:00
Add script to generate AI-based showcase portraits for fictional profiles
- Introduced `generate-showcase-portraits.ts` using the Gemini API for synthetic portrait generation. - Added `PORTRAITS` data structure for controlling persona appearances and scenes. - Implemented functionality to ensure consistent identity across generated photos. - Integrated `.env` API key support and optional modes for targeted regeneration (`--only`, `--force`, `--dry-run`). - Updated related documentation (`marketing-visuals.md` and `README.md`) with generation guidelines and usage instructions. - Seed script (`seed-showcase.ts`) enhanced to link generated photos with fictional profiles in the showcase dataset.
This commit is contained in:
190
docs/marketing-visuals.md
Normal file
190
docs/marketing-visuals.md
Normal file
@@ -0,0 +1,190 @@
|
||||
# Marketing visuals — home & about
|
||||
|
||||
Plan for adding visuals to the logged-out home page (`web/components/home/home.tsx`) and the about page
|
||||
(`web/pages/about.tsx`). Both are currently 100% type + Heroicons; `web/public/images/` holds only logos and
|
||||
`default-avatar.png`. Every differentiator ("keyword search any profile", "filter by values", "get notified
|
||||
about searches", "democratic") is asserted in prose and never shown.
|
||||
|
||||
## Principles
|
||||
|
||||
- **Product visuals, not people visuals.** The home subtitle ends "— not just your photos". Stock imagery of
|
||||
smiling couples would contradict the positioning and read as the thing we're arguing against.
|
||||
- **Captured, not mocked.** Everything that _can_ be a screenshot of the live app should be, so it can be
|
||||
regenerated when the UI changes instead of rotting. `media-creator/scripts/capture-profile.mjs` already
|
||||
does this for the profile tour reel; the same approach extends to marketing stills.
|
||||
- **Never fabricate social proof.** See H3 below.
|
||||
- **Poster-first.** The LCP element stays a static image; video is lazy and decorative.
|
||||
|
||||
## Prerequisite: showcase seed data (W0)
|
||||
|
||||
Screenshots are only as good as the database behind them. The current seed
|
||||
(`tests/e2e/utils/seedDatabase.ts` + `tests/e2e/backend/utils/userInformation.ts`) produces:
|
||||
|
||||
- `bio` = 200–350 lorem-ipsum words, `headline` = lorem words, `keywords` = lorem words
|
||||
- `age` uniform in 18–100
|
||||
- `pinned_url` = `/images/default-avatar.png` for _every_ medium/full profile
|
||||
- `city` / `country` / `university` / `links` / `photo_urls` / interests / causes / work never set
|
||||
|
||||
Unusable for capture. W0 adds a curated set of hand-authored personas alongside the faker ones — real prose,
|
||||
coherent field combinations, real photos, real locations, populated interests/causes/work and social links.
|
||||
|
||||
**Status: in progress.** See "Showcase profiles" below.
|
||||
|
||||
## Home page
|
||||
|
||||
### H1 — Hero search demo (highest value)
|
||||
|
||||
The page says "Don't Swipe. / Search." and then shows no search. A 10–15s loop — typing `stoicism`, filter
|
||||
chips appearing, result cards sliding in — proves the core differentiator at a glance.
|
||||
|
||||
- Build as a `SearchDemo` Remotion scene in `media-creator/`, fed by a `capture-search.mjs` companion to the
|
||||
existing `capture-profile.mjs`.
|
||||
- Place below the CTA row in `home.tsx`, above the divider, framed in browser chrome.
|
||||
- Static poster is the LCP image (`next/image`, `priority`, AVIF); `<video muted autoplay loop playsinline
|
||||
preload="none">` swaps in after load. Budget ~2 MB.
|
||||
- `prefers-reduced-motion: reduce` → poster only, no video element mounted.
|
||||
- Needs W0 (search results must show real people).
|
||||
|
||||
### H2 — Feature-card micro-screenshots
|
||||
|
||||
The three cards (`home.tsx:202-227`) each carry a generic Heroicon. Replace with a cropped ~400px-wide
|
||||
fragment of the real UI:
|
||||
|
||||
| Card | Visual |
|
||||
| --------------------- | ------------------------------------------- |
|
||||
| Radically Transparent | search results grid, no "recommended" badge |
|
||||
| Built for Depth | filter panel with value/interest chips |
|
||||
| Community Owned | GitHub contributor graph |
|
||||
|
||||
Layout unchanged; the icon tile becomes a bordered image. Needs W0.
|
||||
|
||||
### H3 — Replace the fabricated avatars
|
||||
|
||||
`SocialProof` (`home.tsx:69-74`) renders four gradient circles lettered S/R/T/L next to "700+ **real** people
|
||||
worldwide". Fabricated avatars next to the word _real_, on a platform selling radical transparency, is the one
|
||||
visual on the page that actively costs trust.
|
||||
|
||||
Options, in order of preference:
|
||||
|
||||
1. 4–5 actual member photos, with explicit opt-in recorded.
|
||||
2. Drop the avatars; keep the count.
|
||||
|
||||
Do **not** substitute showcase-seed photos here — the copy claims these are real members.
|
||||
|
||||
## About page
|
||||
|
||||
This page is a reference doc, not a pitch; it needs evidence, not a hero.
|
||||
|
||||
### A1 — Proof for "Democratic"
|
||||
|
||||
`about.tsx:235-252` links to `/vote` and `/constitution` in text. A small real screenshot of an actual vote
|
||||
tally turns the assertion into evidence.
|
||||
|
||||
### A2 — Proof for "Community Owned / no VC"
|
||||
|
||||
The one place a genuine human photo belongs, because it evidences the governance claim rather than selling a
|
||||
lifestyle: contributor photos, a Discord screenshot, or the GitHub contributor wall.
|
||||
|
||||
### A3 — Growth chart (optional)
|
||||
|
||||
A simple line of the member count over time near the `ShareStrip`. `web/public/md/financials.md` already
|
||||
establishes the transparency framing.
|
||||
|
||||
## Cross-cutting constraints
|
||||
|
||||
- **Dark mode doubles the assets.** The site is fully tokenised (`bg-canvas-*`, `text-ink-*`); a light-mode
|
||||
screenshot on a dark page looks broken. Capture scripts shoot both themes; serve via `<picture>` +
|
||||
`prefers-color-scheme`. This is real ongoing maintenance — accept it deliberately.
|
||||
- **i18n.** Text baked into a screenshot does not translate. Keep visible copy inside captures minimal, or
|
||||
accept English-only visuals.
|
||||
- **Privacy.** Nothing captured from the shared dev DB may contain real member data. Capture only against
|
||||
showcase-seed profiles (W0), which are fictional by construction.
|
||||
- **Photo licensing.** Showcase portraits must be licensed for this use — AI-generated, CC0, or explicitly
|
||||
consented. Never scraped.
|
||||
|
||||
## Work order
|
||||
|
||||
| ID | Item | Blocked by | Who |
|
||||
| --- | ------------------------------ | ---------- | ---------- |
|
||||
| W0 | Showcase seed profiles | — | Claude ✅ |
|
||||
| W0b | Showcase portrait photos | — | Claude ✅ |
|
||||
| W0c | Run the seed | W0, W0b | **Martin** |
|
||||
| H1 | Hero search demo | W0c | Claude |
|
||||
| H2 | Feature-card micro-screenshots | W0c | Claude |
|
||||
| H3 | Real member avatars | opt-in | **Martin** |
|
||||
| A1 | Vote-tally screenshot | — | Claude |
|
||||
| A2 | Community photo | — | **Martin** |
|
||||
| A3 | Growth chart | — | Claude |
|
||||
|
||||
---
|
||||
|
||||
## Showcase profiles (W0)
|
||||
|
||||
Ten hand-authored personas seeded next to the faker profiles, designed so that any screenshot of search,
|
||||
filters, or a profile page looks like a real product with real people in it.
|
||||
|
||||
### Design
|
||||
|
||||
- Data lives in `tests/e2e/utils/showcase-profiles.ts` — plain data, no faker.
|
||||
- Seeded by `seedShowcaseUsers()` in `tests/e2e/utils/seed-showcase.ts`, called from `seed-test-data.ts`
|
||||
under `SHOWCASE=1` (which seeds the personas _instead of_ the faker profiles).
|
||||
- **No Firebase auth accounts.** `seedUser()` creates them because e2e tests sign in as those users; nobody
|
||||
signs in as a showcase persona. `SPEC_CONFIG` hardcodes the Firebase emulator at `localhost:9099`, so
|
||||
requiring auth would make this fail with ECONNREFUSED whenever it runs against the remote dev DB without
|
||||
the local stack up. User ids are instead derived deterministically from the slug
|
||||
(`showcase<sha256(slug)[:20]>`, 28 chars like a real uid).
|
||||
- Usernames are the persona slug, so capture scripts can hard-code profile URLs
|
||||
(`/mayaokonkwo`, `/rafaelmendes`, …). Re-seeding is idempotent: ids are stable and the insert
|
||||
short-circuits if that user already exists.
|
||||
- Personas cover the filter surface deliberately: a spread of ages (24–61), genders, orientations,
|
||||
relationship intents (collaboration / friendship / relationship), countries, religions, politics, and MBTI —
|
||||
so a screenshot of the filter panel has something to match on whatever facet is demoed.
|
||||
- Bios are 3-paragraph tiptap docs in a real human register, each seeded with the niche keywords the marketing
|
||||
copy promises ("stoicism", "sustainable living", "neuroscience", "hiking", "meditation").
|
||||
- Interests / causes / work rows are upserted into the lookup tables by name, then linked.
|
||||
|
||||
### Photos (W0b — done)
|
||||
|
||||
29 portraits in `web/public/images/showcase/`, named `<slug>-1.jpg` … `<slug>-N.jpg` where `N` is the
|
||||
persona's `photoCount`. `-1` becomes `pinned_url` and the **rest** become `photo_urls` — the two are
|
||||
disjoint, because `ProfileCarousel` renders `buildArray(pinned_url, photo_urls)` without deduping and a
|
||||
shared entry would show twice. Missing files degrade gracefully — the seed falls back to
|
||||
`default-avatar.png` and warns.
|
||||
|
||||
Generated by `scripts/generate-showcase-portraits.ts` against the Gemini image API
|
||||
(`gemini-3-pro-image`, `GEMINI_API_KEY` from the repo-root `.env`):
|
||||
|
||||
```bash
|
||||
npx tsx scripts/generate-showcase-portraits.ts # fill in anything missing
|
||||
npx tsx scripts/generate-showcase-portraits.ts --only priyaraman --force
|
||||
npx tsx scripts/generate-showcase-portraits.ts --dry-run # print prompts, call nothing
|
||||
```
|
||||
|
||||
- **Licensing**: fully synthetic. No real person to consent, no license to track, nothing scraped. This is
|
||||
the only sourcing route that is unambiguously safe for fictional profiles — a CC0 portrait of a real
|
||||
person attached to an invented biography is a different and worse problem.
|
||||
- **Output**: 896×1200 JPEG, re-compressed at q82 to 100–250 KB each (4.4 MB total).
|
||||
- **Identity consistency** is the hard part. Shot 1 is generated from text; shots 2..N pass shot 1 back as a
|
||||
reference image with an explicit "same person" instruction. Text alone drifts — identical descriptions
|
||||
produce different faces.
|
||||
- **Prompt lessons**, encoded in the script as constants:
|
||||
- The "candid / shot on film" register drags in a white film border and an orange date stamp. `STYLE`
|
||||
rules them out by name.
|
||||
- Left alone the model backs the camera off and the subject becomes a small figure in a room. `FRAMING`
|
||||
forces a waist-up medium shot for shot 1, which is the avatar.
|
||||
- Sun-weathered cues ("tanned", "sun-bleached", "freckles") age a subject up ~10 years. The 24-year-old
|
||||
persona needed her youth asserted explicitly to stop reading as mid-thirties.
|
||||
|
||||
> **Deploy weight**: these live under `web/public`, so they ship with the Vercel build (4.4 MB) and are
|
||||
> publicly reachable at `/images/showcase/*.jpg` even though no production page references them. That's the
|
||||
> price of the dev server serving them same-origin for captures. If it becomes a problem, gitignore the
|
||||
> directory and regenerate locally — the script is the source of truth, not the files.
|
||||
|
||||
### Running it
|
||||
|
||||
Scripts that mutate backend state are run by the human:
|
||||
|
||||
```bash
|
||||
./scripts/dev_db_seed.sh # remote dev DB
|
||||
./scripts/seed.sh # local supabase stack
|
||||
```
|
||||
297
scripts/generate-showcase-portraits.ts
Normal file
297
scripts/generate-showcase-portraits.ts
Normal file
@@ -0,0 +1,297 @@
|
||||
/**
|
||||
* Generates the showcase-persona portraits with the Gemini image API.
|
||||
*
|
||||
* The personas in `tests/e2e/utils/showcase-profiles.ts` are fictional, so their photos have to be too:
|
||||
* no real person to consent, no license to track, nothing scraped. See `docs/marketing-visuals.md` (W0b).
|
||||
*
|
||||
* Identity consistency across a persona's photos comes from generating shot 1 from text, then passing that
|
||||
* image back as a reference for shots 2..N. Text alone drifts — same description, different face.
|
||||
*
|
||||
* Usage (needs GEMINI_API_KEY in the repo-root .env):
|
||||
*
|
||||
* npx tsx scripts/generate-showcase-portraits.ts # all missing portraits
|
||||
* npx tsx scripts/generate-showcase-portraits.ts --only priyaraman
|
||||
* npx tsx scripts/generate-showcase-portraits.ts --force # regenerate existing
|
||||
* npx tsx scripts/generate-showcase-portraits.ts --dry-run # print prompts, call nothing
|
||||
*/
|
||||
|
||||
import {execFileSync} from 'child_process'
|
||||
import {existsSync, mkdirSync, readFileSync, writeFileSync} from 'fs'
|
||||
import {join} from 'path'
|
||||
|
||||
import {SHOWCASE_PROFILES, ShowcaseProfile} from '../tests/e2e/utils/showcase-profiles'
|
||||
|
||||
const REPO_ROOT = join(__dirname, '..')
|
||||
const OUT_DIR = join(REPO_ROOT, 'web/public/images/showcase')
|
||||
const MODEL = 'gemini-3-pro-image'
|
||||
const API = `https://generativelanguage.googleapis.com/v1beta/models/${MODEL}:generateContent`
|
||||
|
||||
/** Target size after re-compression. Raw API output is ~700 KB. */
|
||||
const JPEG_QUALITY = 82
|
||||
|
||||
/**
|
||||
* Shared photographic direction. The negatives matter more than the positives here — without them the
|
||||
* model reliably produces glossy stock-photo faces, which is precisely the register this product is
|
||||
* positioned against.
|
||||
*/
|
||||
const STYLE =
|
||||
'Candid documentary-style photograph, natural available light, shot on a 50mm lens, shallow depth of ' +
|
||||
'field, realistic skin texture with visible pores and blemishes, unretouched. Not a stock photo, not a ' +
|
||||
'studio portrait, no artificial lighting, no perfect teeth, no posed corporate smile. Slightly imperfect ' +
|
||||
'framing, as if taken by a friend. ' +
|
||||
// The "candid / shot on film" register reliably drags in a white film border and an orange date stamp,
|
||||
// which look broken inside a profile card. Rule them out explicitly.
|
||||
'Full-bleed digital photograph filling the entire frame: no white border, no film frame, no Polaroid ' +
|
||||
'edge, no date stamp, no timestamp, no watermark, no text or lettering anywhere in the image.'
|
||||
|
||||
interface PortraitSpec {
|
||||
/** Stable physical description — repeated in every shot so the reference has something to lock onto. */
|
||||
appearance: string
|
||||
/** One scene per photo. Length must equal the persona's `photoCount`. */
|
||||
scenes: string[]
|
||||
}
|
||||
|
||||
const PORTRAITS: Record<string, PortraitSpec> = {
|
||||
mayaokonkwo: {
|
||||
appearance:
|
||||
'31-year-old Nigerian-British woman, medium-dark skin, long box braids often tied back, ' +
|
||||
'tortoiseshell glasses pushed up on her head, warm and slightly amused expression',
|
||||
scenes: [
|
||||
'sitting in a cluttered university office in Edinburgh, whiteboard covered in diagrams behind her, holding a mug, looking off camera',
|
||||
'on a windy Scottish hillside in a waterproof jacket, mid-hike, hair pulled back, laughing at something out of frame',
|
||||
'in a small kitchen cooking, steam rising from a pot, sleeves pushed up, concentrating on the pan',
|
||||
],
|
||||
},
|
||||
tomasleclerc: {
|
||||
appearance:
|
||||
'38-year-old French man, light skin, short dark hair going grey at the temples, close-cropped beard, ' +
|
||||
'lean build, forearms marked from manual work, quiet serious face',
|
||||
scenes: [
|
||||
'in a woodworking workshop surrounded by oak offcuts and hand tools, sawdust in the air, wearing a worn canvas apron',
|
||||
'planing a board at a workbench, hands in focus, face partly turned away',
|
||||
'outdoors stacking split firewood beside a stone house, autumn light, breath visible',
|
||||
'sitting on the workshop step drinking coffee, dog at his feet, looking tired and content',
|
||||
],
|
||||
},
|
||||
priyaraman: {
|
||||
appearance:
|
||||
'26-year-old South Indian woman, brown skin, thick shoulder-length black hair usually loose, ' +
|
||||
'small nose stud, animated open expression',
|
||||
scenes: [
|
||||
'in front of a wall of climate model output on monitors, gesturing mid-explanation to someone off camera',
|
||||
'sitting cross-legged on a mat by a window in early morning light, eyes closed, meditating',
|
||||
'crouched in a small allotment garden in Bangalore, hands in the soil, grinning up at the camera',
|
||||
],
|
||||
},
|
||||
davidhirsch: {
|
||||
appearance:
|
||||
'61-year-old white American man, full head of grey hair, wire-rimmed glasses, deep laugh lines, ' +
|
||||
'tall and slightly stooped, kind attentive face',
|
||||
scenes: [
|
||||
// The choir shot came out with singers crowding the foreground and his face half-hidden — fine as a
|
||||
// second photo, wrong for the avatar. The trail shot leads instead.
|
||||
'walking a wooded trail in Portland in a rain jacket, morning mist, pausing to look back at the camera',
|
||||
'conducting a community choir in a church hall, arms raised mid-beat, singers blurred in the foreground',
|
||||
],
|
||||
},
|
||||
aminahaddad: {
|
||||
appearance:
|
||||
'34-year-old Lebanese woman, olive skin, dark hair in a practical low ponytail, strong runner’s build, ' +
|
||||
'direct confident gaze',
|
||||
scenes: [
|
||||
'on a construction site in a hard hat and hi-vis vest, holding rolled drawings, Beirut skyline behind her',
|
||||
'mid-run on a coastal road at dawn, sweat visible, focused expression',
|
||||
'sitting on a balcony in the evening with a book about Ottoman history, city lights behind',
|
||||
],
|
||||
},
|
||||
joonpark: {
|
||||
appearance:
|
||||
'29-year-old Korean non-binary person, light skin, bleached-blond undercut, several ear piercings, ' +
|
||||
'oversized clothes, wry playful expression',
|
||||
scenes: [
|
||||
'at a messy desk in a Berlin game studio, three monitors of spreadsheets and game builds, mid-thought',
|
||||
'foraging in a damp beech forest, holding up a mushroom to the light, basket in the other hand',
|
||||
'hosting a dinner party, leaning over a crowded table serving food, guests blurred and laughing',
|
||||
'sitting on a windowsill in a Berlin flat at night, city outside, holding a controller',
|
||||
],
|
||||
},
|
||||
sofiacosta: {
|
||||
appearance:
|
||||
// Sun-weathered outdoor cues ("tanned", "sun-bleached", "freckles") reliably age this one up a
|
||||
// decade, so the youth has to be asserted harder than for the other personas.
|
||||
'young woman of 24, clearly in her early twenties with a smooth youthful face and no lines, ' +
|
||||
'Brazilian, lightly tanned skin, sun-bleached curly brown hair, freckles across the nose, ' +
|
||||
'wiry athletic build, open enthusiastic student-aged face',
|
||||
scenes: [
|
||||
'on a research boat in a wetsuit, clipboard in hand, salt-wet hair, squinting into the sun',
|
||||
'underwater free diving above a seagrass meadow, no mask fogging, calm and weightless',
|
||||
'at a climbing gym chalking her hands, mid-conversation, gear scattered around',
|
||||
],
|
||||
},
|
||||
marcusadeyemi: {
|
||||
appearance:
|
||||
'45-year-old Black British man, dark skin, shaved head, greying stubble beard, heavy-framed glasses, ' +
|
||||
'broad build, calm watchful expression',
|
||||
scenes: [
|
||||
'in a dim film edit suite lit by monitors, timeline visible, headphones around his neck',
|
||||
'in a home kitchen in Toronto cooking for a crowd, large pot, several dishes underway, sleeves rolled',
|
||||
],
|
||||
},
|
||||
ellenostrom: {
|
||||
appearance:
|
||||
'52-year-old Swedish woman, weathered fair skin, short greying blonde hair, no makeup, ' +
|
||||
'strong outdoor build, dry amused expression',
|
||||
scenes: [
|
||||
'in nurse scrubs in a small rural clinic, leaning on a doorframe, northern winter light through the window',
|
||||
'cross-country skiing through birch forest in deep snow, poles planted, grinning',
|
||||
'sitting on a wooden jetty in a towel after a cold-water swim, steam rising off her shoulders',
|
||||
],
|
||||
},
|
||||
rafaelmendes: {
|
||||
appearance:
|
||||
'36-year-old Brazilian man, light brown skin, dark curly hair getting long, patchy beard, ' +
|
||||
'thin build, slightly rumpled, thoughtful reserved expression',
|
||||
scenes: [
|
||||
'at a home desk in São Paulo late at night, terminal windows on screen, one lamp, mug of coffee',
|
||||
'reading in a park on a bench, book propped on one knee, city noise implied, sunlight through leaves',
|
||||
],
|
||||
},
|
||||
}
|
||||
|
||||
// ─── API ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
function loadApiKey() {
|
||||
const env = readFileSync(join(REPO_ROOT, '.env'), 'utf8')
|
||||
const key = env.match(/^GEMINI_API_KEY=(.+)$/m)?.[1]?.trim()
|
||||
if (!key) throw new Error('GEMINI_API_KEY not found in .env')
|
||||
return key
|
||||
}
|
||||
|
||||
/** Returns raw JPEG bytes. `reference` locks the face for shots after the first. */
|
||||
async function generateImage(apiKey: string, prompt: string, reference?: Buffer) {
|
||||
const parts: object[] = []
|
||||
if (reference) {
|
||||
parts.push({inlineData: {mimeType: 'image/jpeg', data: reference.toString('base64')}})
|
||||
}
|
||||
parts.push({text: prompt})
|
||||
|
||||
const res = await fetch(`${API}?key=${apiKey}`, {
|
||||
method: 'POST',
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: JSON.stringify({
|
||||
contents: [{parts}],
|
||||
generationConfig: {imageConfig: {aspectRatio: '3:4'}},
|
||||
}),
|
||||
})
|
||||
|
||||
const body = await res.json()
|
||||
if (body.error) throw new Error(`${body.error.status}: ${body.error.message}`)
|
||||
|
||||
const candidate = body.candidates?.[0]
|
||||
const imagePart = candidate?.content?.parts?.find((p: any) => p.inlineData)
|
||||
if (!imagePart) {
|
||||
const text = candidate?.content?.parts?.find((p: any) => p.text)?.text
|
||||
throw new Error(`no image returned (finishReason=${candidate?.finishReason}) ${text ?? ''}`)
|
||||
}
|
||||
return Buffer.from(imagePart.inlineData.data, 'base64')
|
||||
}
|
||||
|
||||
/** Re-encode to keep the repo light — raw output is ~700 KB per shot, 29 shots. */
|
||||
function compress(path: string) {
|
||||
execFileSync('convert', [path, '-strip', '-quality', String(JPEG_QUALITY), path])
|
||||
}
|
||||
|
||||
// ─── Prompt assembly ──────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Shot 1 becomes `pinned_url` — the avatar and the card thumbnail — so it needs the face big in frame.
|
||||
* Left to itself the model backs the camera off and the subject reads as a small figure in a room.
|
||||
*/
|
||||
const FRAMING =
|
||||
'Waist-up medium shot. The person is unmistakably the subject and their face is large and clearly visible, sharply in focus.'
|
||||
|
||||
function firstShotPrompt(profile: ShowcaseProfile, spec: PortraitSpec, scene: string) {
|
||||
return [
|
||||
`Photograph of a ${spec.appearance}.`,
|
||||
`She/he/they work as a ${profile.occupation_title} and live in ${profile.city}, ${profile.country}.`,
|
||||
`Scene: ${scene}.`,
|
||||
FRAMING,
|
||||
`They look their stated age of ${profile.age}, not older.`,
|
||||
STYLE,
|
||||
].join(' ')
|
||||
}
|
||||
|
||||
function followUpPrompt(spec: PortraitSpec, scene: string) {
|
||||
return [
|
||||
'Generate a new photograph of the exact same person shown in the reference image —',
|
||||
'same face, same bone structure, same hair, same age. Keep the identity strictly consistent.',
|
||||
`The person is a ${spec.appearance}.`,
|
||||
`New scene, different day, different clothes: ${scene}.`,
|
||||
'The person remains the clear subject of the frame, face visible and in focus.',
|
||||
STYLE,
|
||||
].join(' ')
|
||||
}
|
||||
|
||||
// ─── Main ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
async function main() {
|
||||
const args = process.argv.slice(2)
|
||||
const dryRun = args.includes('--dry-run')
|
||||
const force = args.includes('--force')
|
||||
const only = args[args.indexOf('--only') + 1]
|
||||
const targets = args.includes('--only')
|
||||
? SHOWCASE_PROFILES.filter((p) => p.slug === only)
|
||||
: SHOWCASE_PROFILES
|
||||
|
||||
if (targets.length === 0) throw new Error(`no persona matching --only ${only}`)
|
||||
|
||||
const apiKey = dryRun ? '' : loadApiKey()
|
||||
mkdirSync(OUT_DIR, {recursive: true})
|
||||
|
||||
for (const profile of targets) {
|
||||
const spec = PORTRAITS[profile.slug]
|
||||
if (!spec) throw new Error(`no portrait spec for ${profile.slug}`)
|
||||
if (spec.scenes.length !== profile.photoCount) {
|
||||
throw new Error(
|
||||
`${profile.slug}: ${spec.scenes.length} scenes but photoCount is ${profile.photoCount}`,
|
||||
)
|
||||
}
|
||||
|
||||
// The first shot defines the face; later shots reference it, so it must exist before they run.
|
||||
let reference: Buffer | undefined
|
||||
|
||||
for (let i = 0; i < spec.scenes.length; i++) {
|
||||
const path = join(OUT_DIR, `${profile.slug}-${i + 1}.jpg`)
|
||||
const scene = spec.scenes[i]
|
||||
const prompt = i === 0 ? firstShotPrompt(profile, spec, scene) : followUpPrompt(spec, scene)
|
||||
|
||||
if (dryRun) {
|
||||
console.log(`\n── ${profile.slug}-${i + 1}\n${prompt}`)
|
||||
continue
|
||||
}
|
||||
|
||||
if (existsSync(path) && !force) {
|
||||
console.log(`skip ${profile.slug}-${i + 1} (exists)`)
|
||||
if (i === 0) reference = readFileSync(path)
|
||||
continue
|
||||
}
|
||||
|
||||
try {
|
||||
const image = await generateImage(apiKey, prompt, i === 0 ? undefined : reference)
|
||||
writeFileSync(path, image)
|
||||
compress(path)
|
||||
if (i === 0) reference = readFileSync(path)
|
||||
console.log(`write ${profile.slug}-${i + 1}`)
|
||||
} catch (err: any) {
|
||||
console.error(`FAIL ${profile.slug}-${i + 1}: ${err.message}`)
|
||||
// A failed first shot leaves nothing to reference, so the rest of this persona is pointless.
|
||||
if (i === 0) break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
console.error(err.message)
|
||||
process.exit(1)
|
||||
})
|
||||
220
tests/e2e/utils/seed-showcase.ts
Normal file
220
tests/e2e/utils/seed-showcase.ts
Normal file
@@ -0,0 +1,220 @@
|
||||
import {debug} from 'common/logger'
|
||||
import {PrivateUser} from 'common/user'
|
||||
import {getDefaultNotificationPreferences} from 'common/user-notification-preferences'
|
||||
import {randomString} from 'common/util/random'
|
||||
import {createHash} from 'crypto'
|
||||
import {existsSync} from 'fs'
|
||||
import {join} from 'path'
|
||||
import {createSupabaseDirectClient, SupabaseDirectClient} from 'shared/supabase/init'
|
||||
import {insert} from 'shared/supabase/utils'
|
||||
import {getUser} from 'shared/utils'
|
||||
|
||||
import {SHOWCASE_PROFILES, ShowcaseProfile} from './showcase-profiles'
|
||||
|
||||
/**
|
||||
* Seeds the hand-authored showcase personas (see `showcase-profiles.ts`).
|
||||
*
|
||||
* Separate from `seedDbUser()` because the goals differ: that path randomises everything so e2e tests get
|
||||
* varied input, this one keeps every field exactly as written so marketing captures are reproducible.
|
||||
*
|
||||
* Deliberately does NOT create Firebase auth accounts. `seedUser()` does, because e2e tests sign in as
|
||||
* those users; nobody ever signs in as a showcase persona — they exist to be looked at. Skipping Firebase
|
||||
* means this runs against the remote dev DB without the emulator (SPEC_CONFIG hardcodes localhost:9099,
|
||||
* so with no emulator every sign-up fails with ECONNREFUSED).
|
||||
*
|
||||
* Idempotent — ids are derived from the slug, and re-running skips personas that already exist.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Deterministic stand-in for a Firebase uid: 28 chars, same shape as the real thing, stable across runs
|
||||
* so re-seeding updates nothing and capture scripts can rely on the id.
|
||||
*/
|
||||
function showcaseUserId(slug: string) {
|
||||
return 'showcase' + createHash('sha256').update(slug).digest('hex').slice(0, 20)
|
||||
}
|
||||
|
||||
/** Portraits live in the web app's public dir so dev captures can load them same-origin. */
|
||||
const PHOTO_PUBLIC_DIR = join(__dirname, '../../../web/public/images/showcase')
|
||||
const PHOTO_URL_PREFIX = '/images/showcase'
|
||||
const DEFAULT_AVATAR = '/images/default-avatar.png'
|
||||
|
||||
/**
|
||||
* Resolves `<slug>-1.jpg` … `<slug>-N.jpg`, keeping only files that actually exist.
|
||||
* Missing portraits are not fatal: the profile still seeds, it just wears the default avatar.
|
||||
*
|
||||
* `pinned_url` and `photo_urls` are disjoint: the pinned photo must NOT also appear in `photo_urls`.
|
||||
* `ProfileCarousel` renders `buildArray(pinned_url, photo_urls)` without deduping, so a shared entry
|
||||
* shows up twice. (The photo editor wraps the same call in `uniq`, which masks it while editing.)
|
||||
*/
|
||||
function resolvePhotos(profile: ShowcaseProfile) {
|
||||
const urls: string[] = []
|
||||
for (let i = 1; i <= profile.photoCount; i++) {
|
||||
const file = `${profile.slug}-${i}.jpg`
|
||||
if (existsSync(join(PHOTO_PUBLIC_DIR, file))) urls.push(`${PHOTO_URL_PREFIX}/${file}`)
|
||||
}
|
||||
if (urls.length === 0) {
|
||||
console.warn(
|
||||
`[showcase] no portraits found for ${profile.slug} in web/public/images/showcase — ` +
|
||||
`falling back to the default avatar. See docs/marketing-visuals.md (W0b).`,
|
||||
)
|
||||
return {photo_urls: [], pinned_url: DEFAULT_AVATAR}
|
||||
}
|
||||
return {pinned_url: urls[0], photo_urls: urls.slice(1)}
|
||||
}
|
||||
|
||||
/** Bios are stored as a tiptap doc; one paragraph node per entry. */
|
||||
function toBioDoc(paragraphs: string[]) {
|
||||
return {
|
||||
type: 'doc',
|
||||
content: paragraphs.map((text) => ({
|
||||
type: 'paragraph',
|
||||
content: [{type: 'text', text}],
|
||||
})),
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* `interests` / `causes` / `work` are user-extensible lookup tables keyed by a unique name.
|
||||
* Upsert by name and hand back the id, so re-seeding never duplicates an option.
|
||||
*/
|
||||
async function upsertOptionIds(
|
||||
tx: SupabaseDirectClient,
|
||||
table: 'interests' | 'causes' | 'work',
|
||||
names: string[],
|
||||
) {
|
||||
const ids: number[] = []
|
||||
for (const name of names) {
|
||||
const row = await tx.one<{id: number}>(
|
||||
`insert into ${table} (name) values ($1)
|
||||
on conflict (name) do update set name = excluded.name
|
||||
returning id`,
|
||||
[name],
|
||||
)
|
||||
ids.push(row.id)
|
||||
}
|
||||
return ids
|
||||
}
|
||||
|
||||
async function linkOptions(
|
||||
tx: SupabaseDirectClient,
|
||||
table: 'profile_interests' | 'profile_causes' | 'profile_work',
|
||||
profileId: number,
|
||||
optionIds: number[],
|
||||
) {
|
||||
for (const optionId of optionIds) {
|
||||
await insert(tx, table, {profile_id: profileId, option_id: optionId})
|
||||
}
|
||||
}
|
||||
|
||||
async function seedShowcaseProfile(profile: ShowcaseProfile, userId: string) {
|
||||
const pg = createSupabaseDirectClient()
|
||||
const {photo_urls, pinned_url} = resolvePhotos(profile)
|
||||
const bio = toBioDoc(profile.bio)
|
||||
const bioText = profile.bio.join('\n\n')
|
||||
|
||||
const privateUser: PrivateUser = {
|
||||
id: userId,
|
||||
email: profile.email,
|
||||
initialIpAddress: '127.0.0.1',
|
||||
initialDeviceToken: randomString(),
|
||||
notificationPreferences: getDefaultNotificationPreferences(),
|
||||
blockedUserIds: [],
|
||||
blockedByUserIds: [],
|
||||
}
|
||||
|
||||
return pg.tx(async (tx: any) => {
|
||||
if (await getUser(userId, tx)) return false
|
||||
|
||||
await insert(tx, 'users', {
|
||||
id: userId,
|
||||
name: profile.name,
|
||||
username: profile.slug,
|
||||
data: {},
|
||||
})
|
||||
|
||||
await insert(tx, 'private_users', {id: userId, data: privateUser})
|
||||
|
||||
const inserted = await insert(tx, 'profiles', {
|
||||
user_id: userId,
|
||||
age: profile.age,
|
||||
gender: profile.gender,
|
||||
orientation: profile.orientation,
|
||||
headline: profile.headline,
|
||||
bio,
|
||||
bio_length: bioText.length,
|
||||
keywords: profile.keywords,
|
||||
city: profile.city,
|
||||
region_code: profile.region_code,
|
||||
country: profile.country,
|
||||
city_latitude: profile.city_latitude,
|
||||
city_longitude: profile.city_longitude,
|
||||
born_in_location: profile.born_in_location,
|
||||
occupation_title: profile.occupation_title,
|
||||
company: profile.company,
|
||||
university: profile.university,
|
||||
education_level: profile.education_level,
|
||||
languages: profile.languages,
|
||||
ethnicity: profile.ethnicity,
|
||||
height_in_inches: profile.height_in_inches,
|
||||
religion: profile.religion,
|
||||
political_beliefs: profile.political_beliefs,
|
||||
diet: profile.diet,
|
||||
mbti: profile.mbti,
|
||||
big5_openness: profile.big5.openness,
|
||||
big5_conscientiousness: profile.big5.conscientiousness,
|
||||
big5_extraversion: profile.big5.extraversion,
|
||||
big5_agreeableness: profile.big5.agreeableness,
|
||||
big5_neuroticism: profile.big5.neuroticism,
|
||||
relationship_status: profile.relationship_status,
|
||||
pref_relation_styles: profile.pref_relation_styles,
|
||||
pref_romantic_styles: profile.pref_romantic_styles,
|
||||
pref_gender: profile.pref_gender,
|
||||
pref_age_min: profile.pref_age_min,
|
||||
pref_age_max: profile.pref_age_max,
|
||||
has_kids: profile.has_kids,
|
||||
wants_kids_strength: profile.wants_kids_strength,
|
||||
is_smoker: profile.is_smoker,
|
||||
drinks_per_month: profile.drinks_per_month,
|
||||
photo_urls,
|
||||
pinned_url,
|
||||
links: profile.links,
|
||||
// Public so captures can run against a logged-out browser.
|
||||
visibility: 'public',
|
||||
})
|
||||
|
||||
const profileId = inserted.id
|
||||
await linkOptions(
|
||||
tx,
|
||||
'profile_interests',
|
||||
profileId,
|
||||
await upsertOptionIds(tx, 'interests', profile.interests),
|
||||
)
|
||||
await linkOptions(
|
||||
tx,
|
||||
'profile_causes',
|
||||
profileId,
|
||||
await upsertOptionIds(tx, 'causes', profile.causes),
|
||||
)
|
||||
await linkOptions(
|
||||
tx,
|
||||
'profile_work',
|
||||
profileId,
|
||||
await upsertOptionIds(tx, 'work', profile.work),
|
||||
)
|
||||
|
||||
return true
|
||||
})
|
||||
}
|
||||
|
||||
/** Seeds every showcase persona. Returns how many were newly created. */
|
||||
export async function seedShowcaseUsers() {
|
||||
let created = 0
|
||||
for (const profile of SHOWCASE_PROFILES) {
|
||||
if (await seedShowcaseProfile(profile, showcaseUserId(profile.slug))) {
|
||||
created++
|
||||
debug('Showcase profile created:', profile.slug)
|
||||
}
|
||||
}
|
||||
console.log(`[showcase] ${created}/${SHOWCASE_PROFILES.length} personas created`)
|
||||
return created
|
||||
}
|
||||
@@ -3,6 +3,7 @@ import {createSomeNotifications} from 'shared/create-notification'
|
||||
import {createSupabaseDirectClient} from 'shared/supabase/init'
|
||||
import {insert} from 'shared/supabase/utils'
|
||||
|
||||
import {seedShowcaseUsers} from './seed-showcase'
|
||||
import {
|
||||
seedUser,
|
||||
TEST_USER_DISPLAY_NAME,
|
||||
@@ -52,27 +53,33 @@ async function seedNotifications() {
|
||||
|
||||
type ProfileType = 'basic' | 'medium' | 'full'
|
||||
;(async () => {
|
||||
//Edit the count seedConfig to specify the amount of each profiles to create
|
||||
const seedConfig = [
|
||||
{count: 8, profileType: 'basic' as ProfileType},
|
||||
{count: 8, profileType: 'medium' as ProfileType},
|
||||
{count: 8, profileType: 'full' as ProfileType},
|
||||
]
|
||||
if (process.env.SHOWCASE === '1') {
|
||||
// Hand-authored personas with real prose and portraits, for marketing captures.
|
||||
// Needs no Firebase emulator, so it works against the remote dev DB on its own.
|
||||
await seedShowcaseUsers()
|
||||
} else {
|
||||
//Edit the count seedConfig to specify the amount of each profiles to create
|
||||
const seedConfig = [
|
||||
{count: 8, profileType: 'basic' as ProfileType},
|
||||
{count: 8, profileType: 'medium' as ProfileType},
|
||||
{count: 8, profileType: 'full' as ProfileType},
|
||||
]
|
||||
|
||||
for (const {count, profileType} of seedConfig) {
|
||||
for (let i = 0; i < count; i++) {
|
||||
await seedUser(undefined, undefined, profileType)
|
||||
for (const {count, profileType} of seedConfig) {
|
||||
for (let i = 0; i < count; i++) {
|
||||
await seedUser(undefined, undefined, profileType)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Used in some tests that require interaction with a permanent user
|
||||
await seedUser(
|
||||
TEST_USER_EMAIL,
|
||||
TEST_USER_PASSWORD,
|
||||
'full',
|
||||
TEST_USER_DISPLAY_NAME,
|
||||
TEST_USER_USERNAME,
|
||||
)
|
||||
// Used in some tests that require interaction with a permanent user
|
||||
await seedUser(
|
||||
TEST_USER_EMAIL,
|
||||
TEST_USER_PASSWORD,
|
||||
'full',
|
||||
TEST_USER_DISPLAY_NAME,
|
||||
TEST_USER_USERNAME,
|
||||
)
|
||||
}
|
||||
|
||||
await seedCompatibilityPrompts()
|
||||
await seedNotifications()
|
||||
|
||||
631
tests/e2e/utils/showcase-profiles.ts
Normal file
631
tests/e2e/utils/showcase-profiles.ts
Normal file
@@ -0,0 +1,631 @@
|
||||
/**
|
||||
* Hand-authored "showcase" profiles.
|
||||
*
|
||||
* The faker-generated profiles (`UserAccountInformationForSeeding`) are fine for e2e assertions but useless
|
||||
* on camera: lorem-ipsum bios, lorem headlines, ages uniform over 18–100, and the same `default-avatar.png`
|
||||
* on every profile. Marketing captures (see `docs/marketing-visuals.md`) need a database that looks like a
|
||||
* real product with real people in it.
|
||||
*
|
||||
* These ten personas are fictional by construction, so nothing captured from them can leak member data.
|
||||
* They deliberately spread across the filter surface — age, gender, orientation, connection intent, country,
|
||||
* religion, politics, MBTI — so that whatever facet a screenshot demonstrates, there is something to match.
|
||||
*
|
||||
* Bios embed the niche keywords the marketing copy promises ("stoicism", "sustainable living",
|
||||
* "neuroscience", "hiking", "meditation") so keyword-search demos return sensible results.
|
||||
*
|
||||
* Photos are NOT in this repo by default — see `docs/marketing-visuals.md` (W0b) for the naming convention
|
||||
* and licensing rules. Missing files degrade to the default avatar with a warning.
|
||||
*/
|
||||
|
||||
export interface ShowcaseProfile {
|
||||
/** Stable slug — drives the user id, username, and photo filenames. Lowercase, no spaces. */
|
||||
slug: string
|
||||
name: string
|
||||
email: string
|
||||
age: number
|
||||
gender: string
|
||||
orientation: string[]
|
||||
headline: string
|
||||
/** Paragraphs of the bio, in order. */
|
||||
bio: string[]
|
||||
keywords: string[]
|
||||
city: string
|
||||
region_code: string | null
|
||||
country: string
|
||||
city_latitude: number
|
||||
city_longitude: number
|
||||
born_in_location: string
|
||||
occupation_title: string
|
||||
company: string | null
|
||||
university: string | null
|
||||
education_level: string
|
||||
languages: string[]
|
||||
ethnicity: string[]
|
||||
height_in_inches: number
|
||||
religion: string[]
|
||||
political_beliefs: string[]
|
||||
diet: string[]
|
||||
mbti: string
|
||||
big5: {
|
||||
openness: number
|
||||
conscientiousness: number
|
||||
extraversion: number
|
||||
agreeableness: number
|
||||
neuroticism: number
|
||||
}
|
||||
relationship_status: string[]
|
||||
pref_relation_styles: string[]
|
||||
pref_romantic_styles: string[]
|
||||
pref_gender: string[]
|
||||
pref_age_min: number
|
||||
pref_age_max: number
|
||||
has_kids: number
|
||||
wants_kids_strength: number
|
||||
is_smoker: boolean
|
||||
drinks_per_month: number
|
||||
/** Names upserted into the `interests` lookup table, then linked via `profile_interests`. */
|
||||
interests: string[]
|
||||
/** Names upserted into `causes` → `profile_causes`. */
|
||||
causes: string[]
|
||||
/** Names upserted into `work` → `profile_work`. */
|
||||
work: string[]
|
||||
/** Keys must be values of `SITE_ORDER` in `common/socials.ts`. */
|
||||
links: Record<string, string>
|
||||
/** How many portraits to expect at `/images/showcase/<slug>-<n>.jpg`. */
|
||||
photoCount: number
|
||||
}
|
||||
|
||||
export const SHOWCASE_PROFILES: ShowcaseProfile[] = [
|
||||
{
|
||||
slug: 'mayaokonkwo',
|
||||
name: 'Maya Okonkwo',
|
||||
email: 'maya.okonkwo@compass.showcase',
|
||||
age: 31,
|
||||
gender: 'female',
|
||||
orientation: ['bisexual'],
|
||||
headline: 'Cognitive scientist who reads too much Stoicism and hikes to think',
|
||||
bio: [
|
||||
'I spend my working hours on the neuroscience of attention — specifically what happens in the first 200 milliseconds after something grabs you. It has made me insufferable at parties and unexpectedly patient in traffic.',
|
||||
'Outside the lab I read a lot of Stoicism, mostly Epictetus, mostly badly. I have a standing Sunday hiking habit that started as a way to think through problems and turned into the part of the week I protect hardest. I cook Nigerian food from my mother’s recipes and I am still not as good at it as she is.',
|
||||
'I am looking for people who argue in good faith and change their minds out loud. Friendship or something more — I am genuinely open on that, and I would rather find out slowly.',
|
||||
],
|
||||
keywords: ['neuroscience', 'stoicism', 'hiking', 'cooking'],
|
||||
city: 'Edinburgh',
|
||||
region_code: 'SCT',
|
||||
country: 'United Kingdom',
|
||||
city_latitude: 55.9533,
|
||||
city_longitude: -3.1883,
|
||||
born_in_location: 'Nigeria',
|
||||
occupation_title: 'Cognitive Neuroscientist',
|
||||
company: 'University of Edinburgh',
|
||||
university: 'University College London',
|
||||
education_level: 'doctorate',
|
||||
languages: ['english', 'igbo', 'french'],
|
||||
ethnicity: ['african'],
|
||||
height_in_inches: 67,
|
||||
religion: ['agnostic'],
|
||||
political_beliefs: ['progressive'],
|
||||
diet: ['pescetarian'],
|
||||
mbti: 'intj',
|
||||
big5: {
|
||||
openness: 91,
|
||||
conscientiousness: 78,
|
||||
extraversion: 42,
|
||||
agreeableness: 66,
|
||||
neuroticism: 31,
|
||||
},
|
||||
relationship_status: ['single'],
|
||||
pref_relation_styles: ['friendship', 'relationship'],
|
||||
pref_romantic_styles: ['mono'],
|
||||
pref_gender: ['female', 'male', 'non-binary'],
|
||||
pref_age_min: 27,
|
||||
pref_age_max: 42,
|
||||
has_kids: 0,
|
||||
wants_kids_strength: 2,
|
||||
is_smoker: false,
|
||||
drinks_per_month: 6,
|
||||
interests: ['Neuroscience', 'Stoicism', 'Hiking', 'Cooking', 'Philosophy'],
|
||||
causes: ['Open science', 'Mental health'],
|
||||
work: ['Research', 'Academia'],
|
||||
links: {site: 'mayaokonkwo.science', github: 'mokonkwo', substack: 'twohundredms'},
|
||||
photoCount: 3,
|
||||
},
|
||||
{
|
||||
slug: 'tomasleclerc',
|
||||
name: 'Tomás Leclerc',
|
||||
email: 'tomas.leclerc@compass.showcase',
|
||||
age: 38,
|
||||
gender: 'male',
|
||||
orientation: ['straight'],
|
||||
headline: 'Carpenter, slow-living convert, looking for people who build things',
|
||||
bio: [
|
||||
'I left a decade of software consulting to make furniture. Best decision I have made, financially the worst. I build mostly in oak and mostly to commission, and I am learning Japanese joinery from books and failure.',
|
||||
'Sustainable living is the through-line: I heat with wood I cut, I fix rather than replace, and I have opinions about supply chains that I try to keep to myself for the first hour of knowing someone.',
|
||||
'I have a nine-year-old daughter half the week. I am not in a hurry about anything. I would like to meet people who make things with their hands, or who want to learn.',
|
||||
],
|
||||
keywords: ['woodworking', 'sustainable living', 'permaculture', 'japanese joinery'],
|
||||
city: 'Lyon',
|
||||
region_code: 'ARA',
|
||||
country: 'France',
|
||||
city_latitude: 45.764,
|
||||
city_longitude: 4.8357,
|
||||
born_in_location: 'France',
|
||||
occupation_title: 'Furniture Maker',
|
||||
company: 'Atelier Leclerc',
|
||||
university: null,
|
||||
education_level: 'bachelors',
|
||||
languages: ['french', 'english', 'spanish'],
|
||||
ethnicity: ['caucasian'],
|
||||
height_in_inches: 71,
|
||||
religion: ['atheist'],
|
||||
political_beliefs: ['green'],
|
||||
diet: ['omnivore'],
|
||||
mbti: 'istp',
|
||||
big5: {
|
||||
openness: 74,
|
||||
conscientiousness: 83,
|
||||
extraversion: 35,
|
||||
agreeableness: 71,
|
||||
neuroticism: 28,
|
||||
},
|
||||
relationship_status: ['single'],
|
||||
pref_relation_styles: ['friendship', 'relationship'],
|
||||
pref_romantic_styles: ['mono'],
|
||||
pref_gender: ['female'],
|
||||
pref_age_min: 30,
|
||||
pref_age_max: 48,
|
||||
has_kids: 1,
|
||||
wants_kids_strength: 1,
|
||||
is_smoker: false,
|
||||
drinks_per_month: 12,
|
||||
interests: ['Woodworking', 'Sustainable living', 'Permaculture', 'Cycling'],
|
||||
causes: ['Climate', 'Right to repair'],
|
||||
work: ['Craft', 'Design'],
|
||||
links: {site: 'atelier-leclerc.fr', instagram: 'atelierleclerc'},
|
||||
photoCount: 4,
|
||||
},
|
||||
{
|
||||
slug: 'priyaraman',
|
||||
name: 'Priya Raman',
|
||||
email: 'priya.raman@compass.showcase',
|
||||
age: 26,
|
||||
gender: 'female',
|
||||
orientation: ['queer'],
|
||||
headline: 'Climate modeller. Meditation twice a day. Will talk about soil for hours.',
|
||||
bio: [
|
||||
'I model regional climate for a living, which means I spend most days being carefully precise about uncertainty and most evenings being asked whether we are doomed. (Short answer: the question is badly posed.)',
|
||||
'I have had a daily meditation practice since I was nineteen — Vipassana, twenty minutes, twice. It is the reason I can do the job without burning out. I also keep an allotment that is more ambition than vegetable.',
|
||||
'Here for collaborators and friends, mostly. I have more ideas than time and I like people who fix that problem for each other.',
|
||||
],
|
||||
keywords: ['climate', 'meditation', 'soil', 'gardening'],
|
||||
city: 'Bangalore',
|
||||
region_code: 'KA',
|
||||
country: 'India',
|
||||
city_latitude: 12.9716,
|
||||
city_longitude: 77.5946,
|
||||
born_in_location: 'India',
|
||||
occupation_title: 'Climate Scientist',
|
||||
company: 'Indian Institute of Science',
|
||||
university: 'Indian Institute of Science',
|
||||
education_level: 'masters',
|
||||
languages: ['english', 'tamil', 'hindi'],
|
||||
ethnicity: ['south_asian'],
|
||||
height_in_inches: 63,
|
||||
religion: ['hindu'],
|
||||
political_beliefs: ['green'],
|
||||
diet: ['veg'],
|
||||
mbti: 'infj',
|
||||
big5: {
|
||||
openness: 88,
|
||||
conscientiousness: 80,
|
||||
extraversion: 48,
|
||||
agreeableness: 79,
|
||||
neuroticism: 36,
|
||||
},
|
||||
relationship_status: ['single'],
|
||||
pref_relation_styles: ['collaboration', 'friendship'],
|
||||
pref_romantic_styles: [],
|
||||
pref_gender: ['female', 'non-binary'],
|
||||
pref_age_min: 22,
|
||||
pref_age_max: 38,
|
||||
has_kids: 0,
|
||||
wants_kids_strength: 1,
|
||||
is_smoker: false,
|
||||
drinks_per_month: 0,
|
||||
interests: ['Meditation', 'Gardening', 'Climate science', 'Cycling'],
|
||||
causes: ['Climate', 'Food security'],
|
||||
work: ['Research', 'Climate'],
|
||||
links: {site: 'priyaraman.in', github: 'praman', x: 'praman_climate'},
|
||||
photoCount: 3,
|
||||
},
|
||||
{
|
||||
slug: 'davidhirsch',
|
||||
name: 'David Hirsch',
|
||||
email: 'david.hirsch@compass.showcase',
|
||||
age: 61,
|
||||
gender: 'male',
|
||||
orientation: ['straight'],
|
||||
headline: 'Retired GP, second act as a choir conductor, still learning Bach',
|
||||
bio: [
|
||||
'Forty years of general practice taught me that most of what people need is someone who will actually listen for more than four minutes. I retired two years ago and I am still adjusting to having time.',
|
||||
'I conduct a community choir of forty-odd amateurs. We are not good, but we are getting better, and Bach forgives a lot. I walk every morning and I have started reading philosophy properly rather than pretending I had.',
|
||||
'Widowed. Not looking to replace anyone. I would like conversation, walking companions, and people who are unbothered by the age gap in either direction.',
|
||||
],
|
||||
keywords: ['choir', 'bach', 'medicine', 'walking'],
|
||||
city: 'Portland',
|
||||
region_code: 'OR',
|
||||
country: 'United States',
|
||||
city_latitude: 45.5152,
|
||||
city_longitude: -122.6784,
|
||||
born_in_location: 'United States',
|
||||
occupation_title: 'Retired Physician',
|
||||
company: null,
|
||||
university: 'Oregon Health & Science University',
|
||||
education_level: 'doctorate',
|
||||
languages: ['english', 'german'],
|
||||
ethnicity: ['caucasian'],
|
||||
height_in_inches: 70,
|
||||
religion: ['jewish'],
|
||||
political_beliefs: ['liberal'],
|
||||
diet: ['omnivore'],
|
||||
mbti: 'enfj',
|
||||
big5: {
|
||||
openness: 77,
|
||||
conscientiousness: 86,
|
||||
extraversion: 63,
|
||||
agreeableness: 88,
|
||||
neuroticism: 25,
|
||||
},
|
||||
relationship_status: ['single'],
|
||||
pref_relation_styles: ['friendship'],
|
||||
pref_romantic_styles: [],
|
||||
pref_gender: ['female', 'male'],
|
||||
pref_age_min: 45,
|
||||
pref_age_max: 75,
|
||||
has_kids: 2,
|
||||
wants_kids_strength: 0,
|
||||
is_smoker: false,
|
||||
drinks_per_month: 8,
|
||||
interests: ['Choral music', 'Walking', 'Philosophy', 'Bach'],
|
||||
causes: ['Healthcare access', 'Arts funding'],
|
||||
work: ['Medicine', 'Music'],
|
||||
links: {site: 'rosecitychoir.org'},
|
||||
photoCount: 2,
|
||||
},
|
||||
{
|
||||
slug: 'aminahaddad',
|
||||
name: 'Amina Haddad',
|
||||
email: 'amina.haddad@compass.showcase',
|
||||
age: 34,
|
||||
gender: 'female',
|
||||
orientation: ['straight'],
|
||||
headline: 'Civil engineer rebuilding water systems. Long-distance runner.',
|
||||
bio: [
|
||||
'I design municipal water infrastructure, which is the least glamorous and most consequential engineering there is. Two years in Jordan, now back in Beirut doing the same work at home.',
|
||||
'I run — properly run, sixty kilometres a week, two marathons a year. It is the only time my head is quiet. I read history obsessively, mostly Ottoman, and I am teaching myself to sail badly.',
|
||||
'Looking for a partner who has their own thing and takes it seriously. I travel for work and I need someone who finds that interesting rather than threatening.',
|
||||
],
|
||||
keywords: ['engineering', 'running', 'water', 'history'],
|
||||
city: 'Beirut',
|
||||
region_code: null,
|
||||
country: 'Lebanon',
|
||||
city_latitude: 33.8938,
|
||||
city_longitude: 35.5018,
|
||||
born_in_location: 'Lebanon',
|
||||
occupation_title: 'Civil Engineer',
|
||||
company: 'Beirut Water Authority',
|
||||
university: 'American University of Beirut',
|
||||
education_level: 'masters',
|
||||
languages: ['arabic', 'french', 'english'],
|
||||
ethnicity: ['middle_eastern'],
|
||||
height_in_inches: 66,
|
||||
religion: ['muslim'],
|
||||
political_beliefs: ['moderate'],
|
||||
diet: ['omnivore'],
|
||||
mbti: 'estj',
|
||||
big5: {
|
||||
openness: 68,
|
||||
conscientiousness: 92,
|
||||
extraversion: 55,
|
||||
agreeableness: 61,
|
||||
neuroticism: 33,
|
||||
},
|
||||
relationship_status: ['single'],
|
||||
pref_relation_styles: ['relationship'],
|
||||
pref_romantic_styles: ['mono'],
|
||||
pref_gender: ['male'],
|
||||
pref_age_min: 30,
|
||||
pref_age_max: 45,
|
||||
has_kids: 0,
|
||||
wants_kids_strength: 3,
|
||||
is_smoker: false,
|
||||
drinks_per_month: 2,
|
||||
interests: ['Running', 'History', 'Sailing', 'Engineering'],
|
||||
causes: ['Clean water', 'Rebuilding Beirut'],
|
||||
work: ['Engineering', 'Infrastructure'],
|
||||
links: {linkedin: 'aminahaddad', site: 'aminahaddad.eng'},
|
||||
photoCount: 3,
|
||||
},
|
||||
{
|
||||
slug: 'joonpark',
|
||||
name: 'Joon Park',
|
||||
email: 'joon.park@compass.showcase',
|
||||
age: 29,
|
||||
gender: 'non-binary',
|
||||
orientation: ['pansexual'],
|
||||
headline: 'Game designer, amateur mycologist, host of a very small dinner party',
|
||||
bio: [
|
||||
'I design systems for a mid-size indie studio — mostly economies and progression, the invisible plumbing that makes a game feel fair. Before that I studied economics, which turns out to be the same job with worse graphics.',
|
||||
'I forage mushrooms, seriously and carefully, and I run a monthly dinner for eight people who mostly did not know each other beforehand. Seating chart is the whole art form.',
|
||||
'Open to friendship, collaboration, or dating — I have never found it useful to decide in advance. I am poly and clear about it from the start.',
|
||||
],
|
||||
keywords: ['game design', 'mycology', 'foraging', 'economics'],
|
||||
city: 'Berlin',
|
||||
region_code: 'BE',
|
||||
country: 'Germany',
|
||||
city_latitude: 52.52,
|
||||
city_longitude: 13.405,
|
||||
born_in_location: 'South Korea',
|
||||
occupation_title: 'Game Designer',
|
||||
company: 'Kettle Studios',
|
||||
university: 'Seoul National University',
|
||||
education_level: 'bachelors',
|
||||
languages: ['korean', 'english', 'german'],
|
||||
ethnicity: ['asian'],
|
||||
height_in_inches: 68,
|
||||
religion: ['agnostic'],
|
||||
political_beliefs: ['libertarian'],
|
||||
diet: ['omnivore'],
|
||||
mbti: 'entp',
|
||||
big5: {
|
||||
openness: 95,
|
||||
conscientiousness: 58,
|
||||
extraversion: 71,
|
||||
agreeableness: 64,
|
||||
neuroticism: 44,
|
||||
},
|
||||
relationship_status: ['open'],
|
||||
pref_relation_styles: ['collaboration', 'friendship', 'relationship'],
|
||||
pref_romantic_styles: ['poly'],
|
||||
pref_gender: ['female', 'male', 'non-binary'],
|
||||
pref_age_min: 24,
|
||||
pref_age_max: 40,
|
||||
has_kids: 0,
|
||||
wants_kids_strength: 0,
|
||||
is_smoker: false,
|
||||
drinks_per_month: 14,
|
||||
interests: ['Game design', 'Foraging', 'Cooking', 'Board games'],
|
||||
causes: ['Open source', 'Digital rights'],
|
||||
work: ['Games', 'Design'],
|
||||
links: {
|
||||
site: 'joonpark.games',
|
||||
github: 'joonp',
|
||||
discord: 'joonp',
|
||||
bluesky: 'joonp.bsky.social',
|
||||
},
|
||||
photoCount: 4,
|
||||
},
|
||||
{
|
||||
slug: 'sofiacosta',
|
||||
name: 'Sofia Costa',
|
||||
email: 'sofia.costa@compass.showcase',
|
||||
age: 24,
|
||||
gender: 'female',
|
||||
orientation: ['lesbian'],
|
||||
headline: 'Marine biology PhD student. Free diver. Terrible at sitting still.',
|
||||
bio: [
|
||||
'My PhD is on seagrass meadows — unglamorous, enormously important carbon sinks that everyone ignores in favour of coral. I am four months from fieldwork season and counting.',
|
||||
'I free dive, which is the closest thing I have to meditation. I also climb, badly and enthusiastically. I read almost entirely non-fiction and I am trying to fix that.',
|
||||
'I would like friends in my new city more than anything else right now. Dating is fine too but I moved here nine months ago and I know four people.',
|
||||
],
|
||||
keywords: ['marine biology', 'free diving', 'climbing', 'conservation'],
|
||||
city: 'Lisbon',
|
||||
region_code: 'LI',
|
||||
country: 'Portugal',
|
||||
city_latitude: 38.7223,
|
||||
city_longitude: -9.1393,
|
||||
born_in_location: 'Brazil',
|
||||
occupation_title: 'PhD Researcher',
|
||||
company: 'University of Lisbon',
|
||||
university: 'Federal University of Rio de Janeiro',
|
||||
education_level: 'masters',
|
||||
languages: ['portuguese', 'english', 'spanish'],
|
||||
ethnicity: ['hispanic'],
|
||||
height_in_inches: 64,
|
||||
religion: ['spiritual'],
|
||||
political_beliefs: ['progressive'],
|
||||
diet: ['pescetarian'],
|
||||
mbti: 'enfp',
|
||||
big5: {
|
||||
openness: 89,
|
||||
conscientiousness: 62,
|
||||
extraversion: 76,
|
||||
agreeableness: 82,
|
||||
neuroticism: 47,
|
||||
},
|
||||
relationship_status: ['single'],
|
||||
pref_relation_styles: ['friendship', 'relationship'],
|
||||
pref_romantic_styles: ['mono'],
|
||||
pref_gender: ['female'],
|
||||
pref_age_min: 22,
|
||||
pref_age_max: 34,
|
||||
has_kids: 0,
|
||||
wants_kids_strength: 1,
|
||||
is_smoker: false,
|
||||
drinks_per_month: 10,
|
||||
interests: ['Free diving', 'Climbing', 'Marine conservation', 'Non-fiction'],
|
||||
causes: ['Ocean conservation', 'Climate'],
|
||||
work: ['Research', 'Conservation'],
|
||||
links: {site: 'sofiacosta.bio', instagram: 'sofia.seagrass'},
|
||||
photoCount: 3,
|
||||
},
|
||||
{
|
||||
slug: 'marcusadeyemi',
|
||||
name: 'Marcus Adeyemi',
|
||||
email: 'marcus.adeyemi@compass.showcase',
|
||||
age: 45,
|
||||
gender: 'male',
|
||||
orientation: ['gay'],
|
||||
headline: 'Documentary editor. Twelve years sober. Cooks for a crowd.',
|
||||
bio: [
|
||||
'I cut documentaries — mostly long-form, mostly about people the news got bored of. The job is 90% patience and 10% knowing when a shot has earned its length.',
|
||||
'Twelve years sober this March, which shapes more of my life than any other single fact. I cook for large numbers of people as often as I can get away with it and I have strong feelings about braises.',
|
||||
'Married, and my husband and I are both here looking for friends — we moved for his work and starting over socially at forty-five is genuinely hard. Not looking for anything romantic.',
|
||||
],
|
||||
keywords: ['documentary', 'film editing', 'sobriety', 'cooking'],
|
||||
city: 'Toronto',
|
||||
region_code: 'ON',
|
||||
country: 'Canada',
|
||||
city_latitude: 43.6532,
|
||||
city_longitude: -79.3832,
|
||||
born_in_location: 'United Kingdom',
|
||||
occupation_title: 'Documentary Editor',
|
||||
company: 'Freelance',
|
||||
university: 'National Film and Television School',
|
||||
education_level: 'bachelors',
|
||||
languages: ['english', 'yoruba'],
|
||||
ethnicity: ['african'],
|
||||
height_in_inches: 73,
|
||||
religion: ['atheist'],
|
||||
political_beliefs: ['socialist'],
|
||||
diet: ['omnivore'],
|
||||
mbti: 'isfj',
|
||||
big5: {
|
||||
openness: 81,
|
||||
conscientiousness: 75,
|
||||
extraversion: 44,
|
||||
agreeableness: 85,
|
||||
neuroticism: 30,
|
||||
},
|
||||
relationship_status: ['married'],
|
||||
pref_relation_styles: ['friendship'],
|
||||
pref_romantic_styles: [],
|
||||
pref_gender: ['female', 'male', 'non-binary'],
|
||||
pref_age_min: 30,
|
||||
pref_age_max: 60,
|
||||
has_kids: 0,
|
||||
wants_kids_strength: 0,
|
||||
is_smoker: false,
|
||||
drinks_per_month: 0,
|
||||
interests: ['Film', 'Cooking', 'Documentary', 'Cycling'],
|
||||
causes: ['Addiction recovery', 'Press freedom'],
|
||||
work: ['Film', 'Media'],
|
||||
links: {site: 'marcusadeyemi.film', linkedin: 'marcusadeyemi'},
|
||||
photoCount: 2,
|
||||
},
|
||||
{
|
||||
slug: 'ellenostrom',
|
||||
name: 'Elle Nyström',
|
||||
email: 'elle.nystrom@compass.showcase',
|
||||
age: 52,
|
||||
gender: 'female',
|
||||
orientation: ['straight'],
|
||||
headline: 'Nurse practitioner, cross-country skier, deeply unserious about most things',
|
||||
bio: [
|
||||
'Twenty-six years in nursing, currently running a rural primary care clinic that covers an area the size of a small country and has three staff. I like the work more than I expected to at this stage.',
|
||||
'I ski about a thousand kilometres a winter and swim in cold water year-round, which locally is not considered eccentric. I play accordion, badly, at a level I describe as "socially tolerated".',
|
||||
'Divorced, kids grown and gone. Looking for a companion for long days outdoors and someone to be quiet with afterwards.',
|
||||
],
|
||||
keywords: ['nursing', 'skiing', 'cold water swimming', 'accordion'],
|
||||
city: 'Umeå',
|
||||
region_code: 'AC',
|
||||
country: 'Sweden',
|
||||
city_latitude: 63.8258,
|
||||
city_longitude: 20.263,
|
||||
born_in_location: 'Sweden',
|
||||
occupation_title: 'Nurse Practitioner',
|
||||
company: 'Västerbotten Regional Health',
|
||||
university: 'Umeå University',
|
||||
education_level: 'masters',
|
||||
// NOTE: 'Finnish' is capitalised in LANGUAGE_CHOICES while every other value is lowercase.
|
||||
// Matching the constant as it actually is — see the note in docs/marketing-visuals.md.
|
||||
languages: ['swedish', 'english', 'Finnish'],
|
||||
ethnicity: ['caucasian'],
|
||||
height_in_inches: 68,
|
||||
religion: ['atheist'],
|
||||
political_beliefs: ['socialist'],
|
||||
diet: ['omnivore'],
|
||||
mbti: 'esfp',
|
||||
big5: {
|
||||
openness: 70,
|
||||
conscientiousness: 84,
|
||||
extraversion: 68,
|
||||
agreeableness: 80,
|
||||
neuroticism: 22,
|
||||
},
|
||||
relationship_status: ['single'],
|
||||
pref_relation_styles: ['relationship', 'friendship'],
|
||||
pref_romantic_styles: ['mono'],
|
||||
pref_gender: ['male'],
|
||||
pref_age_min: 44,
|
||||
pref_age_max: 65,
|
||||
has_kids: 3,
|
||||
wants_kids_strength: 0,
|
||||
is_smoker: false,
|
||||
drinks_per_month: 6,
|
||||
interests: ['Cross-country skiing', 'Cold water swimming', 'Music', 'Hiking'],
|
||||
causes: ['Rural healthcare', 'Mental health'],
|
||||
work: ['Medicine', 'Healthcare'],
|
||||
links: {site: 'ellenystrom.se'},
|
||||
photoCount: 3,
|
||||
},
|
||||
{
|
||||
slug: 'rafaelmendes',
|
||||
name: 'Rafael Mendes',
|
||||
email: 'rafael.mendes@compass.showcase',
|
||||
age: 36,
|
||||
gender: 'male',
|
||||
orientation: ['straight', 'demisexual'],
|
||||
headline: 'Open-source maintainer looking for collaborators, not users',
|
||||
bio: [
|
||||
'I maintain a mid-sized open-source database tool that about four thousand companies depend on and roughly nine people contribute to. The maths of that is the most interesting problem in my life right now.',
|
||||
'I care a lot about how public digital goods get funded and governed — the Wikipedia question, basically. I read political economy for fun and I have a half-written essay about it that will probably stay half-written.',
|
||||
'Here mostly for collaboration. If you have ever wanted to work on infrastructure that outlives its funding round, talk to me.',
|
||||
],
|
||||
keywords: ['open source', 'databases', 'governance', 'political economy'],
|
||||
city: 'São Paulo',
|
||||
region_code: 'SP',
|
||||
country: 'Brazil',
|
||||
city_latitude: -23.5505,
|
||||
city_longitude: -46.6333,
|
||||
born_in_location: 'Brazil',
|
||||
occupation_title: 'Open Source Maintainer',
|
||||
company: 'Independent',
|
||||
university: 'University of São Paulo',
|
||||
education_level: 'bachelors',
|
||||
languages: ['portuguese', 'english'],
|
||||
ethnicity: ['hispanic'],
|
||||
height_in_inches: 69,
|
||||
religion: ['agnostic'],
|
||||
political_beliefs: ['progressive'],
|
||||
diet: ['veg'],
|
||||
mbti: 'intp',
|
||||
big5: {
|
||||
openness: 93,
|
||||
conscientiousness: 66,
|
||||
extraversion: 33,
|
||||
agreeableness: 69,
|
||||
neuroticism: 40,
|
||||
},
|
||||
relationship_status: ['single'],
|
||||
pref_relation_styles: ['collaboration', 'friendship'],
|
||||
pref_romantic_styles: ['mono'],
|
||||
pref_gender: ['female', 'non-binary'],
|
||||
pref_age_min: 28,
|
||||
pref_age_max: 44,
|
||||
has_kids: 0,
|
||||
wants_kids_strength: 2,
|
||||
is_smoker: false,
|
||||
drinks_per_month: 4,
|
||||
interests: ['Open source', 'Political economy', 'Chess', 'Running'],
|
||||
causes: ['Open source', 'Digital public goods'],
|
||||
work: ['Software', 'Open source'],
|
||||
links: {
|
||||
github: 'rafaelmendes',
|
||||
site: 'rafaelmendes.dev',
|
||||
discord: 'rmendes',
|
||||
substack: 'publicgoods',
|
||||
},
|
||||
photoCount: 2,
|
||||
},
|
||||
]
|
||||
22
web/public/images/showcase/README.md
Normal file
22
web/public/images/showcase/README.md
Normal file
@@ -0,0 +1,22 @@
|
||||
# Showcase portraits
|
||||
|
||||
Portraits for the hand-authored seed personas in `tests/e2e/utils/showcase-profiles.ts`, used only for
|
||||
marketing captures. See [`docs/marketing-visuals.md`](../../../../docs/marketing-visuals.md) (W0b).
|
||||
|
||||
These are **fully synthetic** — generated with the Gemini image API, no real person depicted. That is
|
||||
deliberate: the personas are fictional, and attaching a real person's likeness to an invented biography
|
||||
would be a different and worse problem than the licensing one.
|
||||
|
||||
Regenerate with:
|
||||
|
||||
```bash
|
||||
npx tsx scripts/generate-showcase-portraits.ts # fill in anything missing
|
||||
npx tsx scripts/generate-showcase-portraits.ts --only priyaraman --force
|
||||
```
|
||||
|
||||
- Naming: `<slug>-1.jpg` … `<slug>-N.jpg`, where `<slug>` is the persona's `slug` and `N` its `photoCount`.
|
||||
`-1` is the pinned photo, so it wants the face large in frame.
|
||||
- 896×1200 JPEG, q82, 100–250 KB each.
|
||||
- Prompts live in the script, not here — edit `PORTRAITS` there and re-run with `--force`.
|
||||
|
||||
Missing files are not fatal: the seed falls back to `default-avatar.png` and warns.
|
||||
Reference in New Issue
Block a user