Files
music-video-gen/flow-state/src/llm/spec.js
T
DejvinoandClaude 49914fd4ad LLM refine: 2dp prompt, mixed 64/32 frames, full proxy log, low reasoning
- spec.js: round all songSpec + extended summary/tempo/normalization to
  r2 (2dp) so high-precision 0.6607… stops wasting tokens; hoist r2/r3
  above specForPrompt to fix ReferenceError; FULL control wording kept.
- thumbs.js: border detection — first/last + 0.5s of any section start
  → 64×64 (borders), interior → 32×32 (between). Back-compat shim for
  old {size:N} callers. Returns size+role per frame for attachFrames.
- vite.config.js: raise sanitizeBodyForLog to 64k for __llm so 8×
  vision frames aren't truncated (was 16384, only 2 images logged).
  Also log reasoningEffort via providerOptions.
- llmTweak.js: set providerOptions reasoningEffort:'low' for both
  streamObject and generateObject fallback to cut 2459 → faster
  streaming progress, temperature 0.7 kept for variety.

Verification: build+lint:scenes green, tests 18/18.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-09-13 22:21:09 +02:00

251 lines
11 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.
// Pure prompt/schema logic — no gateway, no LookGenerator/Scene imports.
//
// Split from llmTweak.js so `node --test` can import it without pulling in
// `src/scenes/metadata.json` (which requires Vite's JSON handling and fails
// under bare Node's import-assertion check).
import { z } from 'zod';
import { ARRANGEMENTS } from '../audio/synth.js';
const ARRANGEMENT_VALUES = Object.keys(ARRANGEMENTS);
export { ARRANGEMENT_VALUES };
export const TweakEditsSchema = z.object({
bpm: z.number().int().min(60).max(180).nullable(),
brightness: z.number().min(0).max(1).nullable(),
noise: z.number().min(0).max(1).nullable(),
noiseColour: z.number().min(0).max(1).nullable(),
dynamics: z.number().min(0).max(1).nullable(),
transition: z.number().min(0).max(1).nullable(),
arrangement: z.enum(ARRANGEMENT_VALUES).nullable(),
});
export const TweakResultSchema = z.object({
rationale: z.string().min(1).max(800),
edits: TweakEditsSchema,
confidence: z.number().min(0).max(1),
});
export const ALLOWED_KEYS = ['bpm', 'brightness', 'noise', 'noiseColour', 'dynamics', 'transition', 'arrangement'];
const clamp01 = (x) => Math.max(0, Math.min(1, x));
const round2 = (x) => Math.round(x * 100) / 100;
export function clampEdits(edits) {
if (!edits || typeof edits !== 'object') return {};
const out = {};
if (edits.bpm != null) {
const n = Number(edits.bpm);
if (Number.isFinite(n)) out.bpm = Math.max(60, Math.min(180, Math.round(n)));
}
for (const k of ['brightness', 'noise', 'dynamics', 'transition']) {
if (edits[k] != null) {
const n = Number(edits[k]);
if (Number.isFinite(n)) out[k] = round2(clamp01(n));
}
}
if (Object.prototype.hasOwnProperty.call(edits, 'noiseColour')) {
const v = edits.noiseColour;
if (v == null) out.noiseColour = null;
else {
const n = Number(v);
if (Number.isFinite(n)) out.noiseColour = round2(clamp01(n));
}
}
if (edits.arrangement != null && ARRANGEMENT_VALUES.includes(edits.arrangement)) {
out.arrangement = edits.arrangement;
}
return out;
}
/**
* Build chat messages from already-serializable inputs.
*
* `lookDigest` is the pre-built digest (describeLook + validateLook) — callers
* that have a live Look should build it via llmTweak.lookDigestForPrompt(look)
* and pass it here, so this module never imports scene metadata.
*
* Kept compatible with the old `{spec,track,look,hint}` shape by accepting
* either `look` (no-op here) or `lookDigest`.
*/
export function buildTweakPrompt({ spec, track, look, lookDigest = null, hint = '', frames = [] } = {}) {
const r2 = (v) => {
const n = Number(v);
return Number.isFinite(n) ? Math.round(n * 100) / 100 : 0;
};
const r3 = r2;
const summary = track ? track.summary : null;
const digest = lookDigest ?? (look ? look : null);
const specForPrompt = spec ? {
bpm: spec.bpm != null ? Math.round(Number(spec.bpm)) : null,
brightness: spec.brightness != null ? r2(spec.brightness) : null,
noise: spec.noise != null ? r2(spec.noise) : null,
noiseColour: spec.noiseColour != null ? r2(spec.noiseColour) : null,
dynamics: spec.dynamics != null ? r2(spec.dynamics) : null,
transition: spec.transition != null ? r2(spec.transition) : null,
arrangement: spec.arrangement ?? null,
duration: spec.duration != null ? r2(spec.duration) : null,
seed: spec.seed ?? null,
} : null;
const sectionsDetailed = track ? (track.sections || []).map((s, i) => ({
index: Number.isFinite(s.index) ? s.index : i,
kind: s.kind || 'unknown',
start: r2(s.startSec != null ? s.startSec : (s.start ?? 0)),
end: r2(s.endSec != null ? s.endSec : (s.end ?? 0)),
duration: r2(s.duration ?? ((s.endSec != null ? s.endSec : (s.end ?? 0)) - (s.startSec != null ? s.startSec : (s.start ?? 0)))),
energy: r3(s.energy ?? 0),
flux: r3(s.flux ?? 0),
centroid: r3(s.centroid ?? 0),
flatness: r3(s.flatness ?? 0),
width: r3(s.width ?? 0),
slope: r3(s.slope ?? 0),
endEnergy: r3(s.endEnergy ?? 0),
})) : [];
// trackSummary — extended
let trackSummary = null;
if (summary) {
trackSummary = {
bpm: summary.bpm ?? track?.tempo?.bpm ?? null,
meanCentroid: summary.meanCentroid != null ? r3(summary.meanCentroid) : null,
meanFlatness: summary.meanFlatness != null ? r3(summary.meanFlatness) : null,
meanWidth: summary.meanWidth != null ? r3(summary.meanWidth) : undefined,
meanLoudness: summary.meanLoudness != null ? r3(summary.meanLoudness) : null,
dynamicRange: summary.dynamicRange != null ? r3(summary.dynamicRange) : null,
tempoConfidence: summary.tempoConfidence != null ? r3(summary.tempoConfidence) : (track?.tempo?.confidence != null ? r3(track.tempo.confidence) : null),
frameCount: track?.frameCount ?? summary.frameCount ?? null,
fps: track?.fps ?? summary.fps ?? null,
duration: r2(track?.duration ?? summary.duration ?? 0),
sections: sectionsDetailed,
};
if (summary.bandBalance) {
trackSummary.bandBalance = {
sub: r3(summary.bandBalance.sub ?? 0),
low: r3(summary.bandBalance.low ?? 0),
mid: r3(summary.bandBalance.mid ?? 0),
high: r3(summary.bandBalance.high ?? 0),
air: r3(summary.bandBalance.air ?? 0),
};
}
// remove undefined meanWidth if not present to keep payload compact
if (trackSummary.meanWidth === undefined) delete trackSummary.meanWidth;
} else if (sectionsDetailed.length || track) {
// fallback when summary missing but track exists (e.g. synthetic test track)
trackSummary = {
bpm: track?.tempo?.bpm ?? null,
frameCount: track?.frameCount ?? null,
fps: track?.fps ?? null,
duration: r2(track?.duration ?? 0),
sections: sectionsDetailed,
};
if (track?.summary?.bandBalance) {
trackSummary.bandBalance = {
sub: r3(track.summary.bandBalance.sub ?? 0),
low: r3(track.summary.bandBalance.low ?? 0),
mid: r3(track.summary.bandBalance.mid ?? 0),
high: r3(track.summary.bandBalance.high ?? 0),
air: r3(track.summary.bandBalance.air ?? 0),
};
}
}
// tempo
let tempo = null;
if (track?.tempo) {
tempo = {
bpm: r2(track.tempo.bpm ?? summary?.bpm ?? 0),
confidence: r3(track.tempo.confidence ?? 0),
beatsPerBar: track.tempo.beatsPerBar ?? 4,
downbeats: Array.isArray(track.tempo.downbeats) ? track.tempo.downbeats.length : 0,
};
} else if (summary?.bpm != null) {
tempo = { bpm: r2(summary.bpm), confidence: r3(summary.tempoConfidence ?? 0), beatsPerBar: 4, downbeats: 0 };
}
// normalization
let normalization = null;
if (track?.normalization && typeof track.normalization === 'object') {
normalization = {};
for (const [k, v] of Object.entries(track.normalization)) {
if (v && typeof v === 'object' && v.lo != null && v.hi != null) {
normalization[k] = { lo: r3(v.lo), hi: r3(v.hi) };
}
}
if (!Object.keys(normalization).length) normalization = null;
}
// stats — derived
let stats = null;
if (sectionsDetailed.length || tempo || track) {
const kinds = [...new Set(sectionsDetailed.map((s) => s.kind))];
const avgEnergy = sectionsDetailed.length
? r3(sectionsDetailed.reduce((a, s) => a + s.energy, 0) / sectionsDetailed.length)
: 0;
let maxSec = null;
let maxE = -Infinity;
for (const s of sectionsDetailed) if (s.energy > maxE) { maxE = s.energy; maxSec = s; }
const duration = track?.duration ?? summary?.duration ?? 0;
const bpmVal = tempo?.bpm ?? summary?.bpm ?? 120;
const bpb = tempo?.beatsPerBar ?? 4;
const bars = bpmVal > 0 && duration > 0 ? Math.round(duration / ((60 / bpmVal) * bpb)) : 0;
stats = {
totalSections: sectionsDetailed.length,
kinds,
avgEnergy,
maxEnergySection: maxSec ? { index: maxSec.index, kind: maxSec.kind, energy: maxSec.energy } : null,
bpm: tempo?.bpm ?? null,
beatsPerBar: tempo?.beatsPerBar ?? null,
downbeats: tempo?.downbeats ?? 0,
bars,
duration: r2(duration),
fps: track?.fps ?? summary?.fps ?? null,
frameCount: track?.frameCount ?? summary?.frameCount ?? null,
};
}
const hintBlock = hint && String(hint).trim()
? `User hint: "${String(hint).trim().slice(0, 300)}"`
: 'No user hint.';
const frameNote = Array.isArray(frames) && frames.length
? `You are also given ${frames.length} low-res thumbnails (32×32) sampled across the song in order — use them to judge color, structure, and motion.`
: null;
const system = [
'You are a producer for a generative music-video system called flow-state.',
'You suggest tweaks to the *song synthesis parameters* so the next generation looks better.',
'You do NOT edit the visualization directly — only the 7 song knobs below. The system re-synthesizes audio and regenerates the look.',
'',
'Rules:',
'You have FULL control — change any subset of the 7 knobs by any amount within valid ranges, including large jumps, arrangement swaps, or bpm shifts. Prefer impactful changes that clearly improve visuals; keep duration fixed.',
'- Valid bpm 60-180 int, brightness/noise/dynamics/transition/noiseColour 0-1, arrangement one of: ' + ARRANGEMENT_VALUES.join(', ') + '.',
'- noiseColour may be null (follows brightness) or 0-1; leave it null unless you have a reason.',
'- All 7 edit fields MUST be present in edits — use null for unchanged (not omitted). Example: { bpm: null, brightness: 0.6, noise: null, noiseColour: null, dynamics: null, transition: null, arrangement: null }',
'- Explain your reasoning in 1-2 sentences (rationale).',
'- If the look is already good, return all-null edits.',
hintBlock,
frameNote,
].filter(Boolean).join('\n');
const userPayload = {
songSpec: specForPrompt,
trackSummary,
sections: sectionsDetailed,
tempo,
normalization,
lookDigest: digest,
stats,
};
const user = JSON.stringify(userPayload, null, 2);
return [
{ role: 'system', content: system },
{ role: 'user', content: `Current generation:\n\`\`\`json\n${user}\n\`\`\`\n\nSuggest edits as { rationale, edits, confidence }.` },
];
}