← 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
M data/whatsmystyle.db-shmM data/whatsmystyle.db-walM scripts/tryon-worker.jsM server.js
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 →