Creative Analyzer - v0.0.1
    Preparing search index...

    Creative Analyzer - v0.0.1

    Creative Analyzer

    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:

    1. One Cloudflare Worker for everything (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.
    2. One Gemini call per analysis, not two. Visual analysis + audio transcript come back from the same 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.
    3. 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.
    4. Hand-written 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.
    5. Friendly error mapping in 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.
    6. React + Vite for the SPA. The assignment table mandates a modern framework. 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.
    7. Visual language extracted from unicorn.pro. Brand cyan #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:

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

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

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

    4. 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:

    1. Retry-with-backoff on transient Gemini 5xx. Three of six files returned 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.
    2. Workers KV cache keyed by Drive 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.
    3. Per-attribute confidence scores. Extend 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.
    4. Streaming response. Transcript first, then person parameters. Lowers perceived latency on the 15–30 s tail for the larger video files. Server-Sent Events from the Worker is straightforward.
    5. Cross-platform integration test against live Gemini. The 114 Vitest tests are unit-level (pure functions, mocked fetch). A smoke test that hits the real Gemini API with a checked-in fixture image (and asserts the response shape, not content) would catch upstream contract drift. Could run nightly in CI with a dedicated test key.
    6. Full WCAG 2.1 AA audit. I added an 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.
    7. Frame-by-frame timeline using Gemini 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:

    • File 2 (image) confirms transcript: null per the SYSTEM_INSTRUCTION rule 3.
    • File 5 shows 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.
    • 3 of 6 files needed one manual retry on first attempt due to Gemini 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
    • The exact list of 7 person parameters wasn't in the assignment text I had access to. Current fields — 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.
    • First hands-on Cloudflare Worker project for me. I hit one early wrangler/Node-version mismatch (wrangler 4 requires Node 22+). The committed .nvmrc (lts/*) ensures contributors get the right Node automatically. Documented in commit history.
    • Drive's virus-scan interstitial code path is wired and tested (parsing the <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.
    • Deploy is to Cloudflare Workers with Static Assets, not Pages. Both are listed as acceptable in the assignment table; Workers Static Assets keeps everything in one product with one URL.

    MIT.