Files
music-video-gen/flow-state/vite.config.js
T
Dejvino 46fab512df Venues: persistent room, motivated camera, ride; drop Pylon Grid
- Venue as one continuous place: persistent per-song layer, interpolated
  venueState, story jumps (one re-hang max + recap) with camera cuts
- Room camera shot plan (venueShots.js): per-section size/target, monotonic
  look sweep, traverse glide body, room-side alternation
- New Ride venue: generated vehicle + actor passengers, radio RTT screen,
  chase/stationary modes, ride archetype across generator/camera/staging
- Flora cost trim (5x(12+6+6) -> 4x(8+4+4) SDF evals, 204ms -> 140ms worst)
- Delete Pylon Grid shader (2.5s/frame SwiftShader); drop its metadata row
- tools/venue-check.js headless gate harness + venue-gallery explorer
2026-09-17 19:35:26 +02:00

311 lines
14 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { defineConfig } from 'vite';
import { resolve } from 'path';
import { writeFileSync, existsSync, mkdirSync, appendFileSync, statSync } from 'fs';
/**
* Let the measuring pages write their results back into the source tree.
*
* The measurements that describe the scene library — how much frame each one
* paints, how much it changes between songs, what it looks like structurally —
* can only be taken by rendering, and rendering needs WebGL, which means a
* browser. So the numbers are produced in a page and have to get from there
* into a git-tracked file. This is that hole in the wall.
*
* Dev only, by construction: `apply: 'serve'` means it does not exist in a
* build, so a deployed gallery cannot write anything. The path it may write is
* a fixed allow-list rather than a parameter — a request body decides the
* CONTENTS of a known file and never which file.
*/
function measurementWriter() {
const WRITABLE = {
'/__metadata': 'src/scenes/metadata.json',
};
return {
name: 'flow-state-measurement-writer',
apply: 'serve',
configureServer(server) {
server.middlewares.use((req, res, next) => {
const target = WRITABLE[req.url];
if (!target || req.method !== 'POST') return next();
let body = '';
req.on('data', (chunk) => { body += chunk; });
req.on('end', () => {
try {
writeFileSync(resolve(process.cwd(), target), body);
res.statusCode = 200;
res.end(`wrote ${target} (${body.length} bytes)`);
} catch (err) {
res.statusCode = 500;
res.end(String(err && err.message));
}
});
});
},
};
}
/**
* Proxy LLM calls through the dev server so the browser stays same-origin.
*
* The Vercel AI Gateway does not send CORS headers, so a direct
* `fetch('https://ai-gateway.vercel.sh/v1/...')` from
* `http://localhost:5180` is blocked. The AI SDK (`ai` + `@ai-sdk/openai`)
* runs in the browser and would hit the gateway directly.
*
* In dev the app instead calls `http://localhost:5180/__llm/v1/...` and this
* middleware forwards the request server-side (Node `fetch`) to the real
* gateway — no CORS involved. The target gateway is taken from the
* `x-llm-gateway` request header (client's configured gatewayUrl) so custom
* gateways keep working; absent, it falls back to Vercel.
*
* Production builds are static; there this route does not exist and the
* client falls through to a direct fetch. Deployments that need CORS-free
* production should add an equivalent `/api/llm` serverless proxy.
*
* Also logs sanitized traffic (no Authorization) to `logs/llm-proxy.log` so
* the coding agent can inspect prompts/responses without re-calling the LLM.
*/
function llmGatewayProxy() {
const LOG_DIR = resolve(process.cwd(), 'logs');
const LOG_FILE = resolve(LOG_DIR, 'llm-proxy.log');
function safeAppend(entry) {
try {
if (!existsSync(LOG_DIR)) mkdirSync(LOG_DIR, { recursive: true });
// Rotate: truncate if >1MB to avoid unbounded growth
try {
if (existsSync(LOG_FILE) && statSync(LOG_FILE).size > 1024 * 1024) {
writeFileSync(LOG_FILE, '');
}
} catch { /* ignore stat/truncate errors */ }
appendFileSync(LOG_FILE, JSON.stringify(entry) + '\n');
} catch { /* ignore */ }
}
function sanitizePathForLog(path) {
try {
const u = new URL(path, 'http://localhost');
for (const key of [...u.searchParams.keys()]) {
const lower = key.toLowerCase();
if (lower === 'apikey' || lower === 'api_key' || lower === 'authorization') {
u.searchParams.delete(key);
}
}
return u.pathname + u.search;
} catch {
// Fallback: strip sensitive query params via regex
return path.replace(/([?&])(apiKey|api_key|authorization)=[^&]*/gi, '').replace(/[?&]$/, '').replace(/\?&/, '?') || '/';
}
}
function redactJsonForLog(obj) {
if (Array.isArray(obj)) return obj.map(redactJsonForLog);
if (obj && typeof obj === 'object') {
const out = {};
for (const [k, v] of Object.entries(obj)) {
const lower = k.toLowerCase();
if (lower === 'apikey' || lower === 'api_key' || lower === 'authorization') {
out[k] = '[REDACTED]';
} else {
out[k] = redactJsonForLog(v);
}
}
return out;
}
return obj;
}
function sanitizeBodyForLog(ct, raw) {
// Allow __llm JSON (which includes images) to be larger — spec asked
// for full logging. 64k keeps 8× images plus schema without truncation.
const limit = 65536;
if (!ct || !ct.includes('json')) {
if (raw && raw.length > limit) return `[${ct || 'unknown'} body ${raw.length} bytes, not logged]`;
try { return raw ? raw.toString('utf8').slice(0, limit) : ''; } catch { return '[unprintable]'; }
}
try {
const text = raw ? raw.toString('utf8') : '';
const parsed = JSON.parse(text);
const redacted = redactJsonForLog(parsed);
return JSON.stringify(redacted).slice(0, limit);
} catch {
return raw ? raw.toString('utf8').slice(0, limit) : '';
}
}
return {
name: 'flow-state-llm-gateway-proxy',
apply: 'serve',
configureServer(server) {
server.middlewares.use(async (req, res, next) => {
if (!req.url.startsWith('/__llm/')) return next();
// CORS preflight from the browser's fetch
if (req.method === 'OPTIONS') {
res.writeHead(204, {
'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Methods': 'GET, POST, PUT, PATCH, DELETE, OPTIONS',
'Access-Control-Allow-Headers': 'Content-Type, Authorization, x-llm-gateway',
'Access-Control-Max-Age': '86400',
});
res.end();
return;
}
const gatewayBase = (req.headers['x-llm-gateway'] && String(req.headers['x-llm-gateway'])) || 'https://ai-gateway.vercel.sh';
const pathAfter = req.url.replace(/^\/__llm/, '') || '/';
let origin;
try {
origin = new URL(gatewayBase).origin;
} catch {
origin = 'https://ai-gateway.vercel.sh';
}
const finalUrl = origin + pathAfter;
let bodyChunks = [];
req.on('data', (c) => bodyChunks.push(c));
req.on('end', async () => {
const started = Date.now();
const reqCt = req.headers['content-type'] || '';
const rawReq = bodyChunks.length ? Buffer.concat(bodyChunks) : null;
const reqLog = sanitizeBodyForLog(reqCt, rawReq);
// Always log the request (sanitized — no Authorization/apiKey)
safeAppend({
ts: new Date().toISOString(),
dir: '→',
method: req.method,
path: sanitizePathForLog(pathAfter),
gateway: origin,
contentType: reqCt,
// Authorization header and apiKey query param never logged
body: reqLog,
});
try {
const body = rawReq && rawReq.length ? rawReq : undefined;
const headers = {};
if (req.headers['content-type']) headers['content-type'] = req.headers['content-type'];
if (req.headers['authorization']) headers['authorization'] = req.headers['authorization'];
else if (req.headers['x-api-key']) headers['authorization'] = `Bearer ${req.headers['x-api-key']}`;
for (const [k, v] of Object.entries(req.headers)) {
if (k.startsWith('x-') && k !== 'x-llm-gateway') headers[k] = v;
}
const upstream = await fetch(finalUrl, {
method: req.method,
headers,
body: req.method === 'GET' || req.method === 'HEAD' ? undefined : body,
});
const contentType = upstream.headers.get('content-type') || '';
const isStream = contentType.includes('event-stream') || contentType.includes('text/event-stream');
// Stream SSE straight through so streamObject sees incremental chunks.
if (isStream && upstream.body) {
res.writeHead(upstream.status, {
'content-type': contentType,
'access-control-allow-origin': '*',
'cache-control': 'no-cache',
});
const reader = upstream.body.getReader();
let totalBytes = 0;
const chunks = [];
try {
while (true) {
const { done, value } = await reader.read();
if (done) break;
totalBytes += value.byteLength;
if (chunks.length < 64) chunks.push(Buffer.from(value).toString('utf8'));
res.write(value);
}
} finally {
try { await reader.cancel(); } catch { /* ignore */ }
}
res.end();
const elapsed = Date.now() - started;
safeAppend({
ts: new Date().toISOString(),
dir: '←',
status: upstream.status,
elapsedMs: elapsed,
contentType,
body: chunks.join('').slice(0, 16384) || `[stream ${totalBytes} bytes]`,
});
console.info(`[llm-proxy] ← OK (stream) ${req.method} ${pathAfter} → ${upstream.status} (${elapsed}ms, ${totalBytes} bytes)`);
return;
}
// Non-streaming: buffer then forward
const buf = Buffer.from(await upstream.arrayBuffer());
res.writeHead(upstream.status, {
...(contentType ? { 'content-type': contentType } : {}),
'access-control-allow-origin': '*',
});
res.end(buf);
const elapsed = Date.now() - started;
let respLog = '';
try {
if (contentType.includes('json') || buf.length < 32768) {
const text = buf.toString('utf8');
if (contentType.includes('json')) {
try { respLog = JSON.stringify(JSON.parse(text)).slice(0, 16384); } catch { respLog = text.slice(0, 8192); }
} else {
respLog = text.slice(0, 8192);
}
} else {
respLog = `[${contentType} ${buf.length} bytes]`;
}
} catch { respLog = `[${buf.length} bytes]`; }
safeAppend({
ts: new Date().toISOString(),
dir: '←',
status: upstream.status,
elapsedMs: elapsed,
contentType: contentType || '',
body: respLog,
});
const tag = upstream.status >= 400 ? '← ERR' : '← OK';
console.info(`[llm-proxy] ${tag} ${req.method} ${pathAfter} → ${upstream.status} (${elapsed}ms, ${buf.length} bytes)`);
} catch (err) {
const elapsed = Date.now() - started;
safeAppend({
ts: new Date().toISOString(),
dir: '←',
status: 502,
elapsedMs: elapsed,
error: String(err && err.message ? err.message : err).slice(0, 2000),
});
console.error(`[llm-proxy] ← 502 ${req.method} ${pathAfter}`, err);
res.writeHead(502, { 'access-control-allow-origin': '*' });
res.end(String(err && err.message ? err.message : err));
}
});
});
},
};
}
export default defineConfig({
plugins: [measurementWriter(), llmGatewayProxy()],
server: {
port: 5180,
host: '127.0.0.1',
},
build: {
rollupOptions: {
input: {
main: resolve(process.cwd(), 'index.html'),
checks: resolve(process.cwd(), 'checks.html'),
debug: resolve(process.cwd(), 'debug.html'),
gallery: resolve(process.cwd(), 'gallery.html'),
filmstrip: resolve(process.cwd(), 'filmstrip.html'),
'venue-gallery': resolve(process.cwd(), 'venue-gallery.html'),
},
},
},
});