Internal tool for the Unicorn Pro performance-marketing team. Paste a public Google Drive URL of an ad creative (image or short video) and get a structured analysis of the person in focus plus a transcript of any speech. Powered by Gemini.
| Live URL | https://unicorn-creative-analyzer.ceo-63e.workers.dev |
| API docs | https://enakoneschniy.github.io/unicorn-creative-analyzer/ — TypeDoc, auto-published on every main push |
| Repository | https://github.com/Enakoneschniy/unicorn-creative-analyzer |
| Stack | Cloudflare Worker · Gemini 2.5 Flash · React 19 + Vite · TypeScript strict · Zod |
git clone https://github.com/Enakoneschniy/unicorn-creative-analyzer
cd unicorn-creative-analyzer
nvm use # picks up .nvmrc → Node 22+ LTS (wrangler 4 requires it)
npm install
cp .dev.vars.example .dev.vars
# edit .dev.vars and set GEMINI_API_KEY=<your-aistudio-key>
# Two terminals for fast iteration:
npm run dev:worker # http://localhost:8787 — Worker (/api/* + Static Assets)
npm run dev:web # http://localhost:5173 — Vite HMR (proxies /api/* → :8787)
For a single-port preview that mirrors production (Worker serving the built SPA from dist/):
npm run preview # = npm run build && wrangler dev
Production deploys run automatically on every push to main via Cloudflare Workers Builds → GitHub. See SETUP-CLOUDFLARE.md for the one-time dashboard configuration (subdomain registration + GitHub integration).
Manual deploy for hotfixes / feature branches:
npx wrangler login # one-time, opens browser
npx wrangler secret put GEMINI_API_KEY # set prod secret (read from stdin)
npm run deploy # = vite build → wrangler deploy
Every push to main and every PR runs the full verify pipeline in GitHub Actions (.github/workflows/ci.yml):
| Stage | Command | What it checks |
|---|---|---|
| Format | npm run format:check |
Prettier formatting consistent |
| Lint | npm run lint |
ESLint flat config — TypeScript + React rules |
| Type-check | npm run type-check |
tsc --noEmit for Worker and SPA |
| Test | npm test |
114 Vitest tests across domain / adapters / errors / handlers / api / components |
| Build | npm run build |
Vite build succeeds, dist/ populated |
Run any of them locally with the same command. npm run lint:fix and npm run format auto-fix what they can. npm run docs regenerates TypeDoc HTML under docs/; npm run docs:serve builds and opens it on http://localhost:4321. The live site at the top of this README is published by .github/workflows/docs.yml on every push to main.
The code is organised in four explicit layers — each file lives in exactly one layer and the dependency graph only flows inward (handlers → adapters → domain).
src/
├── index.ts Worker entry: router only, no business logic
├── handlers/ Use-case orchestrators (one per endpoint)
├── domain/ Pure business types/contracts (Zod, prompt, request shape)
├── adapters/ External integrations (Drive, Gemini) + their error classes
└── errors/ HTTP error translation (DriveError/GeminiError → user-safe JSON)
Decision-by-decision, why each was made over an alternative:
wrangler.toml [assets]). The Worker serves /api/analyze and the React SPA from the same origin via the Static Assets binding. One deploy, one URL, no CORS, no separate Pages project. The assignment table listed Workers Static Assets and Cloudflare Pages as both acceptable; Workers Static Assets keeps it to a single product.gemini-2.5-flash request via the Files API. Half the latency and cost of two separate calls, one error boundary instead of two, and the responseSchema enforces a single coherent contract.PERSON_FIELDS as the single source of truth. A const tuple in src/domain/person.ts drives (a) the Zod schema for runtime validation, (b) the Gemini responseSchema JSON in src/adapters/gemini.ts, and (c) the system-instruction prompt's field list in src/domain/prompt.ts. The customer hasn't confirmed the exact 7 attributes yet — when they do, it's a one-line change.responseSchema over zod-to-json-schema. Both representations derive programmatically from PERSON_FIELDS, so they cannot drift. Adding a dependency to convert between them would be solving a non-problem.src/errors/http.ts. Every internal error — DriveError, GeminiError, Zod validation, network failure — is translated to one user-safe English sentence before reaching the client. Raw upstream messages (status codes, parse errors, stack traces) stay in console.warn, visible via wrangler tail for debugging. The translation table is unit-tested in isolation.AnalyzeResponse is a type-only import from the Worker's domain layer, so the SPA and the API share a contract without shipping Zod (or any other backend code) to the browser. Bundle: 61 KB gzipped JS.#1AB8E4, Inter Display @ 600, pill buttons, soft warm-gray cards, 1200 px container with clamp()-based responsive side padding. The styles file is a real design-system layer (CSS custom properties, responsive breakpoint) rather than ad-hoc rules. Customer's brand identity should be visible at first glance.The whole project was built with Claude Code (Anthropic's CLI). Pattern: scaffold-with-AI, then review-and-decide. Not vibe coding. Concretely:
Structured planning. I used a custom planning skill stack — PROJECT.md → REQUIREMENTS.md → ROADMAP.md → per-phase CONTEXT.md → PLAN.md → SUMMARY.md → VERIFICATION.md. All artifacts committed to .planning/ so the customer can read the full planning trail. Each phase was discussed (15 locked decisions captured in 01-CONTEXT.md) → planned (atomic [BLOCKING] tasks with read_first + acceptance_criteria) → executed in atomic commits → verified against the original phase goal. This isn't ceremony for its own sake — it's how I bound scope on a tight timeline.
Visual extraction via Playwright MCP. Claude navigated unicorn.pro in a real browser, ran a JavaScript snippet against the live DOM to pull computed styles (brand cyan, Inter Display, pill button radius, container width). Those tokens then landed in web/src/styles.css as CSS custom properties — not eyeball-matched, but extracted.
Structured code review. After the SPA was working, I ran a code-review agent against the full custom codebase (19 files, standard depth). It flagged 10 findings (1 critical, 5 warning, 4 info). Full report: 01-REVIEW.md. All 10 addressed in the fix: address all 10 code review findings commit; fix audit: 01-REVIEW-FIX.md.
End-to-end verification on real files. Claude spun up wrangler dev in the background and ran a curl loop against all 6 reference Drive URLs, capturing HTTP code, person summary, and transcript snippet per file. See 01-03-VERIFICATION.md.
What I drove personally — every architecture-impacting decision, the Gemini prompt structure, schema layout, design-system tokens, error-mapping copy, this README, every commit message, and a final read-through of every diff before commit. Claude scaffolded fast; I kept it honest.
Priority-ordered, with the rationale for the ordering:
503 UNAVAILABLE on first attempt during verification (all succeeded on manual retry). A fetch-retry wrapper inside src/adapters/gemini.ts — 3 attempts with exponential backoff — would close this without touching the prompt. Biggest production reliability win for a small amount of code.fileId. A marketer's actual workflow re-analyzes the same creative many times during a review cycle. A 24-hour TTL cache would make re-views instant, save Gemini quota, and shrink the dashboard latency budget. Hashing key includes a prompt-version suffix so a prompt change auto-invalidates.geminiResponseSchema with confidence: number per person field; render a subtle indicator on low-confidence rows. Closes the gap between "model guessed wrong" and "model was uncertain" — important for marketers making downstream decisions.aria-live region during code review and corrected the lang attribute, but a comprehensive pass — keyboard-only nav, focus order, contrast on small text, screen-reader run-through — is still missing.videoMetadata segments. Lets a marketer pinpoint exactly when the person changes expression or the setting cuts. The schema for it lives in the Gemini Files API already; UI work is the bigger lift.All six reference creatives processed against wrangler dev. Schema-valid { person, transcript } for 6/6.
| # | File | Type | HTTP | Person summary | Transcript snippet |
|---|---|---|---|---|---|
| 1 | multiethnic female young.mp4 |
video | 200 | female · 20–29 · caucasian · blonde · athletic · smiling · public restroom | "Lesbians over 50 need to know about this site…" |
| 2 | couple … middle-aged.png |
image | 200 | female · 40s–50s · Caucasian · blonde · curvy · happy · street market | null ✓ (image, no audio) |
| 3 | multiethnic female middle-aged.mp4 |
video | 200 | female · 40s · caucasian · blonde · average · conversational · car interior | "After my divorce, I was done with men…" |
| 4 | asian female middle-aged.mp4 (19.7 MB) |
video | 200 | female · 25–35 · East Asian · dark brown · slender · joyful · kitchen | "These noodles are so delicious…" |
| 5 | couple latina female older asian male older.mp4 |
video | 200 | female · 40s–50s · Hispanic/Latina · dark brown · null (cropped) · smiling · beach |
"The light's perfect right now…" |
| 6 | couple asian female middle-aged multiethnic male older.mp4 |
video | 200 | female · 25–35 · east asian · dark brown · slender · happy · living room | "Oh yeah, very nice." |
Honest notes on the results:
null per the SYSTEM_INSTRUCTION rule 3.body_type: null — the model legitimately refused to guess on a cropped frame (rule 4: prefer null over wrong guess). That's the correct behaviour and the schema explicitly allows it.503 UNAVAILABLE (transient). All succeeded on the retry. Production fix is item #1 in the +5 h list.POST /api/analyze
Content-Type: application/json
{ "url": "https://drive.google.com/file/d/<id>/view" }
Returns 200 with:
{
"person": {
"gender": "string | null",
"age_range": "string | null",
"ethnicity": "string | null",
"hair_color": "string | null",
"body_type": "string | null",
"expression": "string | null",
"setting": "string | null"
} | null,
"transcript": "string | null"
}
Or 4xx/5xx with { "error": "<one user-safe sentence>" }.
GET /api/health → { "status": "ok", "service": "creative-analyzer" } — liveness probe.
src/ Cloudflare Worker — layered by concern
├── index.ts Worker fetch entry: router only
├── env.ts Env binding shape (ASSETS, GEMINI_API_KEY)
├── handlers/
│ └── analyze.ts POST /api/analyze orchestrator
├── domain/ Pure business types — no HTTP, no Gemini
│ ├── person.ts PERSON_FIELDS const + Zod schemas + AnalyzeResponse type
│ ├── prompt.ts SYSTEM_INSTRUCTION (derives from PERSON_FIELDS)
│ └── request.ts analyzeRequestSchema (input validation, https + Drive host)
├── adapters/ External integrations + their domain error classes
│ ├── drive.ts Drive download (interstitial fallback + magic-bytes sniff)
│ └── gemini.ts Files API + polling + generateContent + responseSchema
└── errors/
└── http.ts jsonError + mapErrorToResponse + friendly{Drive,Gemini}Message
web/ React + Vite SPA
├── index.html Vite entry (Inter + JetBrains Mono via Google Fonts)
├── vite.config.ts /api proxy to :8787 for local dev
├── tsconfig.json React + strict + noUncheckedIndexedAccess
└── src/
├── main.tsx
├── App.tsx Page layout + status state machine (idle/loading/error/ok)
├── types.ts type-only AnalyzeResponse from src/domain/person (no Zod in bundle)
├── components/
│ ├── AnalyzeForm.tsx URL input + pill submit button
│ ├── PersonTable.tsx handles person: null with a dashed-border empty state
│ └── TranscriptBlock.tsx handles transcript: null with an italic muted line
├── api/
│ ├── client.ts fetchAnalyze (POST /api/analyze)
│ └── errors.ts ApiError + NetworkError + extractUserMessage
├── test-setup.ts jest-dom matchers + RTL auto-cleanup
└── styles.css Design system (CSS custom properties, 640 px breakpoint)
# Tests live next to the code (*.test.ts / *.test.tsx). 114 tests across:
# - src/domain/{person,prompt,request}.test.ts
# - src/adapters/{drive,gemini}.test.ts
# - src/handlers/analyze.test.ts
# - src/errors/http.test.ts
# - web/src/api/{client,errors}.test.ts
# - web/src/components/{AnalyzeForm,PersonTable,TranscriptBlock}.test.tsx
.planning/ Project planning artifacts (committed for audit trail)
├── PROJECT.md Core value, validated/active/out-of-scope, key decisions
├── REQUIREMENTS.md 16 v1 requirements (12 functional + 4 QUAL) + v2 + out of scope + phase traceability
├── ROADMAP.md 5 phases (0, 1, 2, 2.5, 3), success criteria, dependencies
└── phases/01-analyze-pipeline/
├── 01-CONTEXT.md 15 locked decisions (D-01..D-15)
├── 01-PATTERNS.md Analog code patterns to reuse
├── 01-{01,02,03}-PLAN.md Per-plan task lists with read_first + acceptance criteria
├── 01-03-FIXTURES.md 6 reference URLs + expected media types
├── 01-03-VERIFICATION.md End-to-end test results
├── 01-VERIFICATION.md Phase verifier report
├── 01-REVIEW.md Code review (10 findings)
└── 01-REVIEW-FIX.md Fix audit (10/10 fixed)
.github/workflows/ci.yml Format / lint / type-check / test / build on every push
SETUP-CLOUDFLARE.md One-time dashboard config: subdomain + GitHub integration
gender, age_range, ethnicity, hair_color, body_type, expression, setting — are marketer-relevant placeholders. Locked in src/domain/person.ts as PERSON_FIELDS; renaming any of them is one line because the Zod schema, Gemini responseSchema, and prompt all derive from the same const. A passing test (src/domain/prompt.test.ts) verifies the prompt actually mentions every field name..nvmrc (lts/*) ensures contributors get the right Node automatically. Documented in commit history.<form>, forwarding hidden inputs + cookies), but none of the 6 reference files actually triggered it during testing (all small enough to skip the interstitial). The interstitial parser is left in because the assignment text explicitly called out "redirect chains, content-type quirks — це теж частина задачі". Unit tests cover the parser pure functions.MIT.