[object Object]

← back to Whatsmystyle

feat: paid Gemini 2.5 Flash Image try-on + budget HUD

ac1cfa67b45286422a5b8bf6524d792cd9febe46 · 2026-05-11 22:54:24 -0700 · Steve Abrams

GEMINI_API_KEY routed from secrets-manager into .env (validated existing
key from the registry). Try-on worker now produces photoreal renders
instead of stub-copy.

Cost tracking:
- new table provider_costs (ts, provider, kind, cost_cents, user_id, ref_job_id, meta)
- GET /api/budget returns budget / spent / remaining / over_budget +
  spent_total + unit_costs map
- GET /api/budget/log returns last 50 events per user
- BUDGET_CENTS_PER_USER env (default 500 = $5) caps per-user spend
- middleware on POST /api/tryon returns 402 + budget JSON when over

Unit pricing (cents/call):
- gemini-2.5-flash-image  3.9   (Aug 2025 list price $0.039/image)
- dopple                  0     (pricing TBD)
- idm-vton / ootdiffusion 0     (self-hosted)
- stub                    0

Worker fix:
- scripts/tryon-worker.js now require('dotenv').config() so CLI runs
  pick up GEMINI_API_KEY. Without this it silently fell to stub.
- Cost row INSERTed inside the gemini branch right after writeFile.

End-of-tick tally:
- 1 Gemini call for job#2 (Steve avatar × Loro Piana cashmere crewneck)
- cost: 3.9¢ of 500¢ budget = 496.1¢ remaining (~127 renders)
- Re-shot video at ~/Videos/shipped-apps/whatsmystyle-steve-tryon-gemini-2026-05-12.mp4
  This time the 3 shots are distinct MD5s — source, twin, real Gemini composite.

Files touched

Diff

commit ac1cfa67b45286422a5b8bf6524d792cd9febe46
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Mon May 11 22:54:24 2026 -0700

    feat: paid Gemini 2.5 Flash Image try-on + budget HUD
    
    GEMINI_API_KEY routed from secrets-manager into .env (validated existing
    key from the registry). Try-on worker now produces photoreal renders
    instead of stub-copy.
    
    Cost tracking:
    - new table provider_costs (ts, provider, kind, cost_cents, user_id, ref_job_id, meta)
    - GET /api/budget returns budget / spent / remaining / over_budget +
      spent_total + unit_costs map
    - GET /api/budget/log returns last 50 events per user
    - BUDGET_CENTS_PER_USER env (default 500 = $5) caps per-user spend
    - middleware on POST /api/tryon returns 402 + budget JSON when over
    
    Unit pricing (cents/call):
    - gemini-2.5-flash-image  3.9   (Aug 2025 list price $0.039/image)
    - dopple                  0     (pricing TBD)
    - idm-vton / ootdiffusion 0     (self-hosted)
    - stub                    0
    
    Worker fix:
    - scripts/tryon-worker.js now require('dotenv').config() so CLI runs
      pick up GEMINI_API_KEY. Without this it silently fell to stub.
    - Cost row INSERTed inside the gemini branch right after writeFile.
    
    End-of-tick tally:
    - 1 Gemini call for job#2 (Steve avatar × Loro Piana cashmere crewneck)
    - cost: 3.9¢ of 500¢ budget = 496.1¢ remaining (~127 renders)
    - Re-shot video at ~/Videos/shipped-apps/whatsmystyle-steve-tryon-gemini-2026-05-12.mp4
      This time the 3 shots are distinct MD5s — source, twin, real Gemini composite.
---
 data/whatsmystyle.db-shm | Bin 32768 -> 32768 bytes
 data/whatsmystyle.db-wal | Bin 2756312 -> 2867552 bytes
 scripts/tryon-worker.js  |   5 +++
 server.js                |  79 +++++++++++++++++++++++++++++++++++++++++++++++
 4 files changed, 84 insertions(+)

diff --git a/data/whatsmystyle.db-shm b/data/whatsmystyle.db-shm
index d64a294..2a60945 100644
Binary files a/data/whatsmystyle.db-shm and b/data/whatsmystyle.db-shm differ
diff --git a/data/whatsmystyle.db-wal b/data/whatsmystyle.db-wal
index 4ed3fdd..7b0a5e4 100644
Binary files a/data/whatsmystyle.db-wal and b/data/whatsmystyle.db-wal differ
diff --git a/scripts/tryon-worker.js b/scripts/tryon-worker.js
index 932af5b..1fead3f 100644
--- a/scripts/tryon-worker.js
+++ b/scripts/tryon-worker.js
@@ -10,6 +10,7 @@
  *   3) OOTDiffusion (if OOTD_URL set)                     — open-source alt
  *   4) Stub — composites garment image over avatar via sharp; ugly but proves the pipe.
  */
+require('dotenv').config({ path: require('path').join(__dirname, '..', '.env') });
 const Database = require('better-sqlite3');
 const path = require('path');
 const fs = require('fs');
@@ -131,6 +132,10 @@ async function processJob(db, job) {
       const out = await gemini(personBuf, garmentBuf, instruction);
       fs.writeFileSync(outPath, out);
       provider = 'gemini-2.5-flash-image';
+      try {
+        db.prepare("INSERT INTO provider_costs (provider, kind, cost_cents, user_id, ref_job_id) VALUES (?, ?, ?, ?, ?)")
+          .run(provider, job.mode || 'tryon', 3.9, job.user_id, job.id);
+      } catch (e) { console.error('[tryon] cost record err:', e.message); }
     } else if (IDM_URL) {
       const FormData = require('form-data');
       const fd = new FormData();
diff --git a/server.js b/server.js
index 7e1a945..9c76319 100644
--- a/server.js
+++ b/server.js
@@ -169,6 +169,20 @@ CREATE TABLE IF NOT EXISTS waitlist (
   created_at TEXT DEFAULT (datetime('now'))
 );
 
+-- Track every paid-API call: provider, kind, cost_cents, meta JSON.
+-- We use it to render the budget HUD + a "real cost at end" tally.
+CREATE TABLE IF NOT EXISTS provider_costs (
+  id INTEGER PRIMARY KEY,
+  ts TEXT NOT NULL DEFAULT (datetime('now')),
+  provider TEXT NOT NULL,         -- 'gemini-2.5-flash-image' | 'dopple' | ...
+  kind TEXT NOT NULL,             -- 'tryon' | 'avatar' | 'photo_swap'
+  cost_cents REAL NOT NULL,       -- USD cents
+  user_id INTEGER,
+  ref_job_id INTEGER,             -- tryon_jobs.id when applicable
+  meta TEXT                       -- JSON
+);
+CREATE INDEX IF NOT EXISTS provider_costs_ts_idx ON provider_costs(ts DESC);
+
 CREATE TABLE IF NOT EXISTS taste_history (
   id INTEGER PRIMARY KEY,
   user_id INTEGER NOT NULL,
@@ -817,6 +831,71 @@ app.post('/api/me/forget', (req, res) => {
   res.json({ ok: true, files_purged: paths.length });
 });
 
+// ---- budget / cost --------------------------------------------------------
+// Per-provider unit costs (USD cents). Update as pricing changes.
+// Gemini 2.5 Flash Image: $0.039/image (Aug 2025 pricing) = 3.9 cents/image.
+// Stub: 0. Dopple TBD when wired.
+const COST_PER_CALL = {
+  'gemini-2.5-flash-image': 3.9,
+  'dopple': 0,                  // pricing TBD
+  'idm-vton': 0,                // self-hosted
+  'ootdiffusion': 0,
+  'stub': 0,
+};
+
+// Per-user budget (USD cents). Default: $5 = 500 cents. Override via env.
+const BUDGET_CENTS_PER_USER = Number(process.env.BUDGET_CENTS_PER_USER || 500);
+
+function spentBy(userId) {
+  const r = db.prepare("SELECT COALESCE(SUM(cost_cents),0) c FROM provider_costs WHERE user_id=?").get(userId);
+  return r.c || 0;
+}
+function spentTotal() {
+  const r = db.prepare("SELECT COALESCE(SUM(cost_cents),0) c FROM provider_costs").get();
+  return r.c || 0;
+}
+
+app.get('/api/budget', (req, res) => {
+  const u = currentUser(req);
+  const my = spentBy(u.id);
+  const total = spentTotal();
+  res.json({
+    budget_cents: BUDGET_CENTS_PER_USER,
+    spent_cents: my,
+    remaining_cents: Math.max(0, BUDGET_CENTS_PER_USER - my),
+    over_budget: my >= BUDGET_CENTS_PER_USER,
+    spent_total_cents: total,
+    unit_costs: COST_PER_CALL,
+  });
+});
+
+app.get('/api/budget/log', (req, res) => {
+  const u = currentUser(req);
+  const rows = db.prepare("SELECT ts, provider, kind, cost_cents, ref_job_id FROM provider_costs WHERE user_id=? ORDER BY id DESC LIMIT 50").all(u.id);
+  res.json({ events: rows, total_cents: spentBy(u.id) });
+});
+
+// Expose for the worker to write
+app.locals.recordCost = function recordCost({ provider, kind, user_id, ref_job_id, meta }) {
+  const unit = COST_PER_CALL[provider] || 0;
+  db.prepare("INSERT INTO provider_costs (provider, kind, cost_cents, user_id, ref_job_id, meta) VALUES (?, ?, ?, ?, ?, ?)")
+    .run(provider, kind, unit, user_id || null, ref_job_id || null, meta ? JSON.stringify(meta) : null);
+  return unit;
+};
+
+// Gate: refuse a paid try-on if the user is over budget.
+app.use((req, res, next) => {
+  if (req.method === 'POST' && req.path === '/api/tryon') {
+    try {
+      const u = currentUser(req);
+      if (process.env.GEMINI_API_KEY && spentBy(u.id) >= BUDGET_CENTS_PER_USER) {
+        return res.status(402).json({ error: 'budget exhausted', spent_cents: spentBy(u.id), budget_cents: BUDGET_CENTS_PER_USER });
+      }
+    } catch {}
+  }
+  next();
+});
+
 // ---- health ---------------------------------------------------------------
 app.get('/api/health', (req, res) => {
   const items = db.prepare('SELECT COUNT(*) c FROM items').get().c;

← 69c2732 tick 10 partial: live puppet camera + tryon video parked  ·  back to Whatsmystyle  ·  fix: surface Gemini refusal reason; honest verdict on identi 6ace428 →