Director cuts between palettes; the look panel shows which one is live

One palette for a whole song reads as one colour once five minutes
have passed. The track now keeps 2-4 audio-tilted palettes (forced
scheme/hue diversity so switches are legible) and the director owns
how they are traversed per cue — sequential/pong/kindLed/storyLed/
contrast — with cut or OKLCH blend timed to the cue crossfade. The
fine OKLCH paletteArc drift still rides on top of whichever base is
active.

The look tab renders the full set as a list; the row matching
ArcDriver.paletteIndexAt(frame) gets an active border/glow and
follows playback/scrub live, with a blend badge while two palettes
interpolate.

Co-Authored-By: internal-model
This commit is contained in:
Dejvino 2026-08-20 12:45:42 +02:00
parent df609424d5
commit ef1d24e692
7 changed files with 461 additions and 21 deletions

View File

@ -125,6 +125,9 @@ export class Show {
setPalette(palette) {
this.look.palette = palette;
if (this.look.palettes && this.look.palettes.length) {
this.look.palettes[0] = palette;
}
this.arc.setPalette(palette);
this.osd.setPalette(palette);
}

View File

@ -2,12 +2,13 @@ import { createLayer } from '../engine/Layer.js';
import { Rng } from '../engine/rng.js';
import { clampValue } from '../params/schema.js';
import { paletteShiftAt } from './paletteArc.js';
import { shiftPalette } from './palette.js';
import { shiftPalette, lerpPalettes } from './palette.js';
import { frameShot, neutralFraming } from './framing.js';
import { planGaze, gazeAt } from './Camera.js';
import { storyStateAt, NEUTRAL_STATE } from './Story.js';
import { subjectIndexOf, isGround } from './stack.js';
import { groundPersonalityFrom } from '../scenes/surface.js';
import { derivePalettePlan, directorByName } from './directors.js';
/**
* Drives the look across the song.
@ -48,6 +49,7 @@ export class ArcDriver {
? look.framing : null;
this._planFraming();
this._planGaze();
this._planPalette();
this.state = { sectionIndex: 0, shotIndex: 0, crossfade: 0, incoming: null };
}
@ -96,6 +98,21 @@ export class ArcDriver {
this.gaze = planGaze(this.cues, this.look.sections, this.look.camera, rng);
}
/**
* Plan which palette is on screen for each cue.
*
* The director picks the progression; the palette set determines how many
* choices there are. Stored on the look so it survives serialisation and so
* the HUD can describe it.
*/
_planPalette() {
const director = directorByName(this.look.director);
const palettes = this.look.palettes || [this.look.palette];
const rng = new Rng((this.look.seed ^ 0x7a1b3c9d) >>> 0);
this.look.palettePlan = derivePalettePlan(
this.cues, this.look.sections, director, rng, palettes);
}
/** Where the camera is looking at `frame`, for the cue at `cueIndex`. */
_gazeAt(cueIndex, frame) {
if (!this.gaze) return null;
@ -450,18 +467,60 @@ export class ArcDriver {
* scenes is a flash, and the flash meter is not decorative.
*/
/**
* The track's palette, moved to where this frame sits in the arc.
* The track's palette, moved to where this frame sits in the arc and in the
* palette plan.
*
* Recomputed once per frame rather than once per layer, and memoised on the
* rounded shift: the movement is slow by design, so consecutive frames
* almost always want the same colours and the OKLCH round trip is wasted
* work. Rounding also makes the cache key stable under a seek, which keeps
* the frame-exactness guarantee a seeked frame gets bit-identical colours
* to a played one rather than merely similar ones.
* Two levels: the director's coarse choice of which base palette is on screen
* for this cue (from `look.palettes` / `look.palettePlan`), plus the fine
* `paletteArc` drift inside that palette. Blends between two palettes in
* OKLCH when the plan's transition is `blend`, timed to the cue's own
* crossfade so colour and image move together.
*
* Recomputed once per frame and memoised on the rounded state.
*/
_paletteAt(frame, features, story) {
const arc = this.look.paletteArc;
if (!arc || arc.mode === 'static') return this.look.palette;
const plan = this.look.palettePlan;
const palettes = this.look.palettes || [this.look.palette];
const cueIndex = this._cueIndexAt(frame);
const cue = this.cues[cueIndex] || null;
// Coarse palette for this cue (fallback to the single palette when no plan)
let baseIndex = 0;
if (plan && plan.cues && plan.cues.length > cueIndex) {
baseIndex = plan.cues[cueIndex];
}
baseIndex = Math.max(0, Math.min(palettes.length - 1, baseIndex | 0));
// During a blend transition, lerp from the previous cue's palette to this
// one's over the cue's fadeFrames, in OKLCH so hue travel stays perceptual.
let base = palettes[baseIndex] || palettes[0];
let blendT = 0;
let blendFrom = -1;
if (plan && plan.transition === 'blend' && cue && cueIndex > 0) {
const prevIndex = plan.cues[cueIndex - 1];
if (prevIndex !== baseIndex) {
const into = frame - cue.startFrame;
if (into >= 0 && into < cue.fadeFrames) {
const t = into / Math.max(1, cue.fadeFrames);
blendT = t * t * (3 - 2 * t);
blendFrom = prevIndex;
const a = palettes[prevIndex] || palettes[0];
const b = base;
base = lerpPalettes(a, b, blendT);
}
}
}
if (!arc || arc.mode === 'static') {
// Still memoise so a seek returns the same object identity for the
// compositor's layer-change check, but key off palette selection too.
const key = `p${baseIndex}|f${blendFrom}:${blendT.toFixed(3)}`;
if (this._paletteKey === key) return this._palette;
this._paletteKey = key;
this._palette = base;
return this._palette;
}
const shift = paletteShiftAt(arc, {
progress: frame / Math.max(1, this.track.frameCount - 1),
@ -470,11 +529,11 @@ export class ArcDriver {
story,
});
const key = `${shift.hue.toFixed(3)}|${shift.chroma.toFixed(3)}|${shift.lightness.toFixed(3)}`;
const key = `p${baseIndex}|f${blendFrom}:${blendT.toFixed(3)}|${shift.hue.toFixed(3)}|${shift.chroma.toFixed(3)}|${shift.lightness.toFixed(3)}`;
if (this._paletteKey === key) return this._palette;
this._paletteKey = key;
this._palette = shiftPalette(this.look.palette, shift);
this._palette = shiftPalette(base, shift);
return this._palette;
}
@ -675,6 +734,8 @@ export class ArcDriver {
this.cues = this._buildCues();
this._planFraming();
this._planGaze();
this._planPalette();
this._paletteKey = null;
this._slopeCache = null;
}
@ -684,6 +745,8 @@ export class ArcDriver {
this.cues = this._buildCues();
this._planFraming();
this._planGaze();
this._planPalette();
this._paletteKey = null;
this._slopeCache = null;
}
@ -705,9 +768,49 @@ export class ArcDriver {
return this;
}
/**
* Which palette of the set is on screen for a frame. Mirrors the coarse
* selection in _paletteAt without the OKLCH lerp or the fine shift what
* the panel highlights as "active".
*/
paletteIndexAt(frame) {
const plan = this.look.palettePlan;
const palettes = this.look.palettes || [this.look.palette];
if (!plan || !plan.cues || !plan.cues.length) return 0;
const cueIndex = this._cueIndexAt(frame);
let idx = plan.cues[cueIndex];
if (idx === undefined) idx = plan.cues[plan.cues.length - 1] || 0;
return Math.max(0, Math.min(palettes.length - 1, idx | 0));
}
/**
* Whether a frame is inside a palette blend window.
* Returns {from,to,t} while the two palettes are interpolating, else null.
*/
paletteBlendAt(frame) {
const plan = this.look.palettePlan;
if (!plan || plan.transition !== 'blend') return null;
const cueIndex = this._cueIndexAt(frame);
if (cueIndex === 0) return null;
const cue = this.cues[cueIndex];
if (!cue) return null;
const cur = plan.cues[cueIndex];
const prev = plan.cues[cueIndex - 1];
if (cur === prev) return null;
const into = frame - cue.startFrame;
if (into >= 0 && into < cue.fadeFrames) {
return { from: prev, to: cur, t: into / Math.max(1, cue.fadeFrames) };
}
return null;
}
/** Push a palette change through without rebuilding layers. */
setPalette(palette) {
this.look.palette = palette;
// Keep the full set's alias in sync — `look.palette` is always palettes[0].
if (this.look.palettes && this.look.palettes.length) {
this.look.palettes[0] = palette;
}
// The moved palette is memoised on the SHIFT, so a new base palette at
// an unchanged point in the arc would otherwise keep serving the old
// colours until the arc happened to move.

View File

@ -5,7 +5,7 @@
// the decoded audio, so a given file always renders the same video.
import { Rng, hashSamples } from '../engine/rng.js';
import { AudioPalette, generateUsablePalette } from './palette.js';
import { AudioPalette, generateUsablePalette, rgbToOklch } from './palette.js';
import { scenes, scenesInFamily, FAMILIES } from '../scenes/registry.js';
import { sampleValues, defaultValues, canBackground } from '../params/schema.js';
import {
@ -86,6 +86,76 @@ function biasFor(section, summary, motion = null, story = null) {
const clamp01 = (x) => Math.max(0, Math.min(1, x));
/**
* Derive the set of palettes for this track. The director's palette.count
* names how many distinct schemes the video will cut between; the actual
* colours stay audio-tilted (same warmth/energy) so they read as one track's
* world rather than as unrelated palettes shuffled together.
*/
function derivePaletteSet(summary, rng, director) {
const cfg = (director && director.palette) || {};
const spec = cfg.count;
let count = 1;
if (Array.isArray(spec)) {
const lo = Math.max(1, spec[0] | 0);
const hi = Math.max(lo, spec[1] | 0);
count = lo === hi ? lo : rng.int(lo, hi);
} else if (typeof spec === 'number') {
count = Math.max(1, spec | 0);
} else {
// No palette config — single palette, like before
count = 1;
}
count = Math.min(4, Math.max(1, count));
const palettes = [];
const paletteSchemes = [];
const hueRefs = [];
for (let k = 0; k < count; k++) {
let palette = null;
let scheme = null;
let hue = 0;
// Try a few forks so the set is diverse in scheme and hue, not just
// repeated draws that happen to land on the same scheme.
for (let attempt = 0; attempt < 12; attempt++) {
const fork = rng.fork(`palette:${k}:${attempt}`);
const src = new AudioPalette(summary, fork);
const cand = generateUsablePalette(src, 6);
const candScheme = src.lastScheme || 'unknown';
let candHue = 0;
try { candHue = rgbToOklch(cand[0])[2]; } catch { candHue = fork.range(0, Math.PI * 2); }
const schemeDup = paletteSchemes.includes(candScheme);
let tooClose = false;
for (const h2 of hueRefs) {
let dh = Math.abs(candHue - h2) % (2 * Math.PI);
if (dh > Math.PI) dh = 2 * Math.PI - dh;
if (dh < 0.5) { tooClose = true; break; }
}
// Keep trying while we have attempts left and the candidate would
// make the set harder to tell apart. Relax after a few tries.
if (attempt < 8 && schemeDup) continue;
if (attempt < 6 && tooClose) continue;
palette = cand;
scheme = candScheme;
hue = candHue;
break;
}
if (!palette) {
const src = new AudioPalette(summary, rng.fork(`palette:${k}:final`));
palette = generateUsablePalette(src, 6);
scheme = src.lastScheme || 'unknown';
try { hue = rgbToOklch(palette[0])[2]; } catch { hue = 0; }
}
palettes.push(palette);
paletteSchemes.push(scheme);
hueRefs.push(hue);
}
return { palettes, paletteSchemes };
}
/**
* The track's temperament, moved to where this section sits in the story.
*
@ -698,9 +768,6 @@ export function generateLook(track, {
const rng = new Rng(resolvedSeed);
const summary = track.summary;
const paletteSource = new AudioPalette(summary, rng.fork('palette'));
const palette = generateUsablePalette(paletteSource, 6);
// The production design, decided before a single scene is cast — casting
// depends on it. See look/Personality.js.
const personality = generatePersonality(summary, rng.fork('personality'), (signature) =>
@ -711,6 +778,12 @@ export function generateLook(track, {
// scene is, because it decides which scenes are even candidates.
const director = pickDirector(summary, rng.fork('director'));
const { palettes, paletteSchemes } = derivePaletteSet(
summary, rng.fork('palettes'), director);
// Back-compat alias: the first palette is still the track's palette.
const palette = palettes[0];
const paletteSource = { lastScheme: paletteSchemes[0] };
// This track's cast, drawn before any section is assigned. See castingPool.
//
// `poolOverride` exists for the variety harness, which needs reference
@ -804,6 +877,9 @@ export function generateLook(track, {
const look = {
seed: resolvedSeed,
palette,
// The full set the director cuts between — ArcDriver reads this per cue.
palettes,
paletteSchemes,
personality,
paletteScheme: paletteSource.lastScheme,
director: director.name,
@ -881,7 +957,20 @@ export function rerollLook(look, track, newSeed) {
}
function applyOverrides(look, overrides) {
if (overrides.palette) look.palette = overrides.palette;
if (overrides.palette) {
look.palette = overrides.palette;
// Keep the full set in sync if the caller replaced the single alias.
if (look.palettes && look.palettes.length) look.palettes[0] = overrides.palette;
if (overrides.palettes) {
look.palettes = overrides.palettes;
look.palette = look.palettes[0] || look.palette;
}
}
if (overrides.palettes) {
look.palettes = overrides.palettes;
look.palette = look.palettes[0] || look.palette;
}
if (overrides.paletteSchemes) look.paletteSchemes = overrides.paletteSchemes;
if (overrides.post) look.post = { ...look.post, ...overrides.post };
if (overrides.feedback) look.feedback = { ...look.feedback, ...overrides.feedback };
if (overrides.sections) {
@ -897,10 +986,16 @@ function applyOverrides(look, overrides) {
/** Compact description, used by the HUD and by check output. */
export function describeLook(look) {
const kinds = look.sections.map((s) => `${s.kind}:${subjectOf(s.layers).module.name}`);
return `seed ${look.seed.toString(16)} · ${look.director} · ${look.paletteScheme} · ` +
const palTag = look.paletteSchemes && look.paletteSchemes.length > 1
? look.paletteSchemes.join('→')
: look.paletteScheme;
const planTag = look.palettePlan
? ` · palettes:${look.palettePlan.progression}/${look.palettePlan.transition}`
: '';
return `seed ${look.seed.toString(16)} · ${look.director} · ${palTag} · ` +
`${describePersonality(look.personality)} · ${describeGrain(look.grain)} · ` +
`${describePaletteArc(look.paletteArc)} · ${describeFraming(look.framing)} · ` +
`${describeCamera(look.camera)} · ` +
`${describeCamera(look.camera)}${planTag} · ` +
`${[...new Set(kinds)].join(', ')}`;
}

View File

@ -81,6 +81,8 @@ export const DIRECTORS = [
// look/Camera.js — a director's point of view now includes how it
// shoots, not only what it points at.
camera: 'contemplative',
// Colour stays slow — two palettes that blend into one another.
palette: { count: [2, 2], progression: 'sequential', transition: 'blend' },
// The original table. A drop resolves into geometry; everything quiet is
// minimal. Still the most broadly applicable, so it keeps the most weight.
weight: 3,
@ -102,6 +104,8 @@ export const DIRECTORS = [
crowd: 1.05,
// Holds, then commits to one large move. Architecture is looked AT.
camera: 'deliberate',
// Two palettes, story-led — the drop arrives in the second palette.
palette: { count: [2, 2], progression: 'storyLed', transition: 'cut' },
// Everything is architecture. Quiet means empty rather than soft, so it
// leads on minimal and reaches for organic last.
weight: 2,
@ -123,6 +127,8 @@ export const DIRECTORS = [
crowd: 1.45,
// Never settles, because nothing here is ever finished settling.
camera: 'roaming',
// Three palettes mapped by kind — each kind of section keeps a colour.
palette: { count: [3, 3], progression: 'kindLed', transition: 'blend' },
// Nothing is ever built; things grow and dissolve. Deliberately never
// reaches for glitch — a point of view is defined by what it refuses.
weight: 2,
@ -144,6 +150,8 @@ export const DIRECTORS = [
crowd: 1.7,
// Cuts with the camera already moving.
camera: 'kinetic',
// Most palettes, most contrast — the damage is legible as colour too.
palette: { count: [3, 4], progression: 'contrast', transition: 'cut' },
// The signal is damaged and the damage is the subject — everywhere the
// damage is allowed to be. Its quiet sections lead on flow, so the calm
// reads as signal drifting rather than as rest.
@ -167,6 +175,8 @@ export const DIRECTORS = [
// Small, exact, always arrives — the pattern is the subject and the
// camera does not editorialise about it.
camera: 'precise',
// Three palettes traversed as a pong — the pattern's colour answers its form.
palette: { count: [2, 3], progression: 'pong', transition: 'blend' },
// Pattern first, everywhere, at every energy. The drop is not an
// explosion, it is the pattern at its densest.
weight: 2,
@ -221,3 +231,138 @@ export function blazeOf(director) {
export function directorByName(name) {
return DIRECTORS.find((d) => d.name === name) || DIRECTORS[0];
}
// ── palette progression ───────────────────────────────────────────────
/**
* Build a per-cue palette assignment.
*
* `cues` is ArcDriver's flat list of (section,shot) spans; `sections` carries
* the story state each cue belongs to. `palettes` is the set LookGenerator built.
* The director's `palette.progression` names which strategy to use.
*
* Pure in (cues, sections, director, palettes, rng) the seed makes the same
* song always traverse its palettes the same way.
*/
export function derivePalettePlan(cues, sections, director, rng, palettes) {
const count = palettes ? palettes.length : 1;
if (!cues || !cues.length || count <= 1) {
return {
name: 'single',
paletteCount: Math.max(1, count),
progression: 'single',
transition: 'cut',
cues: (cues || []).map(() => 0),
};
}
const cfg = (director && director.palette) || {};
const progression = cfg.progression || 'sequential';
const transition = cfg.transition || 'blend';
const n = cues.length;
const indices = new Array(n);
if (progression === 'sequential') {
for (let i = 0; i < n; i++) indices[i] = i % count;
} else if (progression === 'pong') {
const cycle = count > 1 ? 2 * count - 2 : 1;
for (let i = 0; i < n; i++) {
const k = i % cycle;
indices[i] = k < count ? k : cycle - k;
}
} else if (progression === 'kindLed') {
// Stable kind → palette map, shuffled per track so two directors with the
// same progression don't map identically.
const kinds = ['intro', 'build', 'drop', 'sustain', 'breakdown', 'outro'];
const offset = rng ? rng.int(0, Math.max(1, count) - 1) : 0;
// Seeded shuffle of palette indices for the kind mapping
const order = [...Array(count).keys()];
if (rng) {
for (let i = order.length - 1; i > 0; i--) {
const j = rng.int(0, i);
const t = order[i]; order[i] = order[j]; order[j] = t;
}
}
const kindMap = new Map();
for (let k = 0; k < kinds.length; k++) {
// Round-robin through shuffled palette order, with offset
kindMap.set(kinds[k], order[(k + offset) % count]);
}
for (let i = 0; i < n; i++) {
const cue = cues[i];
const sec = sections[cue.sectionIndex];
const kind = sec ? sec.kind : 'intro';
indices[i] = kindMap.has(kind) ? kindMap.get(kind) : (i % count);
}
} else if (progression === 'storyLed') {
for (let i = 0; i < n; i++) {
const cue = cues[i];
const sec = sections[cue.sectionIndex];
const story = sec ? sec.story : null;
if (story) {
if (story.act === 'climax') indices[i] = count - 1;
else if (story.act === 'resolution') indices[i] = Math.max(0, Math.floor(count / 2));
else {
const t = Math.max(0, Math.min(1, story.tension ?? 0.5));
indices[i] = Math.min(count - 1, Math.floor(t * count));
}
} else {
const p = n > 1 ? i / (n - 1) : 0.5;
indices[i] = Math.min(count - 1, Math.floor(p * count));
}
}
// Ensure at least one switch happens — storyLed on flat material can collapse
const uniq = new Set(indices);
if (uniq.size < 2 && count >= 2) {
// Nudge the middle cue to the other palette
const mid = Math.floor(n / 2);
indices[mid] = count > 2 ? 1 : 1;
}
} else if (progression === 'contrast') {
// Start seeded, then always pick the palette furthest in hue from the
// previous. Hue of the first colour in OKLCH is the representative.
const toHue = ([r, g, b]) => {
const lin = (x) => {
const v = Math.max(0, Math.min(1, x));
return v <= 0.04045 ? v / 12.92 : Math.pow((v + 0.055) / 1.055, 2.4);
};
const R = lin(r), G = lin(g), B = lin(b);
const l = 0.4122214708 * R + 0.5363325363 * G + 0.0514459929 * B;
const m = 0.2119034982 * R + 0.6806995451 * G + 0.1073969566 * B;
const s_ = 0.0883024619 * R + 0.2817188376 * G + 0.6299787005 * B;
const l_ = Math.cbrt(l), m_ = Math.cbrt(m), s = Math.cbrt(s_);
const a = 1.9779984951 * l_ - 2.4285922050 * m_ + 0.4505937099 * s;
const bb = 0.0259040371 * l_ + 0.7827717662 * m_ - 0.8086757660 * s;
return Math.atan2(bb, a);
};
const hues = palettes.map((pal) => { try { return toHue(pal[0]); } catch { return 0; } });
const hueDist = (a, b) => {
let dh = Math.abs(a - b) % (2 * Math.PI);
if (dh > Math.PI) dh = 2 * Math.PI - dh;
return dh;
};
indices[0] = rng ? rng.int(0, count - 1) : 0;
for (let i = 1; i < n; i++) {
const prev = indices[i - 1];
let best = (prev + 1) % count;
let bestD = -1;
for (let c = 0; c < count; c++) {
if (c === prev && count > 1) continue;
const d = hueDist(hues[c], hues[prev]);
if (d > bestD) { bestD = d; best = c; }
}
indices[i] = best;
}
} else {
for (let i = 0; i < n; i++) indices[i] = i % count;
}
return {
name: progression,
paletteCount: count,
progression,
transition,
cues: indices,
};
}

View File

@ -299,3 +299,30 @@ export function toHex([r, g, b]) {
const c = (v) => Math.round(Math.max(0, Math.min(1, v)) * 255).toString(16).padStart(2, '0');
return `#${c(r)}${c(g)}${c(b)}`;
}
/**
* Lerp two palettes per-colour in OKLCH, so hue travel stays perceptual and
* lightness doesn't wobble the way an RGB lerp does. Hue takes the short way
* round the wheel.
*/
export function lerpPalettes(a, b, t) {
const n = Math.max(a.length, b.length);
const tt = Math.max(0, Math.min(1, t));
const out = [];
for (let i = 0; i < n; i++) {
const ca = a[i % a.length];
const cb = b[i % b.length];
const [La, Ca, ha] = rgbToOklch(ca);
const [Lb, Cb, hb] = rgbToOklch(cb);
let dh = hb - ha;
dh = ((dh + Math.PI) % (2 * Math.PI)) - Math.PI;
// Near-grey colours have unstable hue — fade hue influence with chroma.
// For now lerp directly; OKLCH hue of a near-zero chroma is still stable
// enough at 6-colour palette sizes.
const L = La + (Lb - La) * tt;
const C = Ca + (Cb - Ca) * tt;
const h = ha + dh * tt;
out.push(oklchToRgb(L, Math.max(0, C), h));
}
return out;
}

View File

@ -343,6 +343,14 @@ function renderPanel() {
// The track's production design. Scenes that cannot express what it is
// built on were never cast — see look/Personality.js.
const personality = show.look.personality;
const palettes = show.look.palettes || [show.look.palette];
const schemes = show.look.paletteSchemes || [show.look.paletteScheme];
const plan = show.look.palettePlan;
const activeIdx = show.arc ? show.arc.paletteIndexAt(show.timeline.frame) : 0;
const blend = show.arc ? show.arc.paletteBlendAt(show.timeline.frame) : null;
const planLabel = plan
? `${plan.progression}/${plan.transition} · ${palettes.length} palettes`
: `${schemes[0] || ''}`;
dom.panelBody.innerHTML = `
<div class="pp-heading"><span class="pp-name">${show.fileName || 'track'}</span></div>
<div class="kv"><span>seed</span><b>${show.look.seed.toString(16)}</b></div>
@ -350,7 +358,7 @@ function renderPanel() {
<div class="kv"><span>tempo conf.</span><b>${show.track.tempo.confidence.toFixed(2)}</b></div>
<div class="kv"><span>duration</span><b>${formatTime(show.duration)}</b></div>
<div class="kv"><span>sections</span><b>${show.track.sections.length}</b></div>
<div class="kv"><span>scheme</span><b>${show.look.paletteScheme}</b></div>
<div class="kv"><span>director</span><b>${show.look.director}${plan ? ` · ${plan.progression}` : ''}</b></div>
<div class="kv"><span>built on</span><b>${personality.signature.join(' + ') || 'nothing'}</b></div>
<div class="kv"><span>form</span><b>${personality.shape.sides || 'round'}${
personality.shape.sides ? '-sided' : ''}</b></div>
@ -360,8 +368,22 @@ function renderPanel() {
? personality.style.symmetry + '-fold · ' : ''}line ${personality.style.lineWeight.toFixed(2)}</b></div>
<div class="kv"><span>brightness</span><b>${summary.meanCentroid.toFixed(3)}</b></div>
<div class="kv"><span>dynamics</span><b>${summary.dynamicRange.toFixed(3)}</b></div>
<div class="swatches">${show.look.palette.map((c) =>
`<span class="sw" style="background:${toHex(c)}" title="${toHex(c)}"></span>`).join('')}</div>
<div class="pp-sub">palettes · ${planLabel}</div>
<div id="palette-list">${palettes.map((pal, i) => {
const sc = schemes[i] || schemes[0] || '';
const isActive = i === activeIdx;
const isBlendSrc = blend && (i === blend.from || i === blend.to);
const cls = isActive ? 'active' : (isBlendSrc ? 'blending' : '');
const tag = isActive
? (blend ? `● blend ${(blend.t * 100).toFixed(0)}%` : '● active')
: (isBlendSrc ? '○ blend' : '');
return `<div class="pal-row ${cls}" data-pal="${i}">
<div class="pal-meta"><span class="pal-idx">#${i + 1}</span><span class="pal-scheme">${sc}</span><span class="pal-tag">${tag}</span></div>
<div class="swatches pal-swatches">${pal.map((c) =>
`<span class="sw" style="background:${toHex(c)}" title="${toHex(c)}"></span>`).join('')}</div>
</div>`;
}).join('')}</div>
<div id="palette-blend-hint" class="hint"${blend ? '' : ' hidden'}>${blend ? `blending #${blend.from + 1} → #${blend.to + 1} · ${(blend.t * 100).toFixed(0)}% through cut` : ''}</div>
<div class="pp-sub">sections</div>
${show.look.sections.map((s, i) => `
<div class="kv ${i === index ? 'current' : ''}">
@ -631,6 +653,37 @@ window.addEventListener('resize', resize);
let lastPanelSection = -1;
let lastRenderedFrame = -1;
function syncPalettesLive() {
if (state.tab !== 'look' || !state.show.ready || !state.show.arc) return;
const palettes = state.show.look.palettes;
if (!palettes || palettes.length <= 1) return;
const list = document.getElementById('palette-list');
if (!list) return;
const frame = state.show.timeline.frame;
const active = state.show.arc.paletteIndexAt(frame);
const blend = state.show.arc.paletteBlendAt(frame);
for (const row of list.querySelectorAll('.pal-row')) {
const idx = Number(row.dataset.pal);
const isActive = idx === active;
const isBlendSrc = !!blend && (idx === blend.from || idx === blend.to) && !isActive;
row.classList.toggle('active', isActive);
row.classList.toggle('blending', isBlendSrc);
const tag = row.querySelector('.pal-tag');
if (tag) {
if (isActive) tag.textContent = blend ? `● blend ${(blend.t * 100).toFixed(0)}%` : '● active';
else if (isBlendSrc) tag.textContent = '○ blend';
else tag.textContent = '';
}
}
const hint = document.getElementById('palette-blend-hint');
if (hint) {
if (blend) {
hint.hidden = false;
hint.textContent = `blending #${blend.from + 1} → #${blend.to + 1} · ${(blend.t * 100).toFixed(0)}% through cut`;
} else hint.hidden = true;
}
}
function frame(now) {
requestAnimationFrame(frame);
const show = state.show;
@ -672,6 +725,8 @@ function frame(now) {
if (state.tab === 'scene' || state.tab === 'look') renderPanel();
}
syncPalettesLive();
if (state.hudVisible) {
const f = show.track.at(show.timeline.frame);
dom.hud.innerHTML =

View File

@ -248,6 +248,18 @@ input[type=range] { accent-color: var(--accent); background: transparent; }
.kv b { font-weight: 500; text-align: right; }
.kv.current { background: rgba(74,222,128,0.08); margin: 0 -4px; padding: 2px 4px; }
#palette-list { display: flex; flex-direction: column; gap: 6px; margin: 6px 0 8px; }
.pal-row { border: 1px solid var(--line); border-radius: 6px; padding: 6px 8px; background: #0b0d12; transition: border-color .18s, background .18s, box-shadow .18s; }
.pal-row.active { border-color: var(--accent); box-shadow: 0 0 0 1px rgba(74,222,128,0.25), 0 2px 10px rgba(74,222,128,0.12); background: rgba(74,222,128,0.06); }
.pal-row.blending { border-color: rgba(74,222,128,0.35); background: rgba(74,222,128,0.03); }
.pal-meta { display: flex; align-items: center; gap: 8px; font-size: 11px; margin-bottom: 4px; }
.pal-idx { color: var(--text); font-weight: 600; min-width: 2ch; }
.pal-scheme { color: var(--dim); text-transform: lowercase; }
.pal-tag { margin-left: auto; color: var(--accent); font-size: 10px; letter-spacing: .06em; white-space: nowrap; }
.pal-row:not(.active):not(.blending) .pal-tag { opacity: 0; }
.pal-swatches { margin: 0; }
.pal-swatches .sw { height: 18px; }
.swatches { display: flex; gap: 3px; margin: 10px 0; }
.sw { flex: 1; height: 26px; border: 1px solid rgba(0,0,0,0.4); }