Comfort: agitation ceiling, composition validator and whole-screen caps
Whole-screen flashing/coherent motion is now a gated comfort signal rather than a hunt-and-tweak. Three-arm agitation (flash/3, tile-motion/0.32, structure/0.12) over a 1s floating window, worst window wins — engine/agitation.js. Policy centralised in look/comfort.js: director comfort appetite, per-scene agitationOf proxy, agitationWeight lean and stackAgitation. Canvas beat/flux capped in Layer.resolveParams (0.15/0.25, spike→smooth) so one contract change tames every full-frame scene; lint warns above the cap. Selection now filters before casting (look/scene-filter.js, 12+ ranged metrics from checks/scene-metrics.js, SCHEMA 7) and validates after stacking (look/composition-validator.js) with a deterministic retune: drop overlay → swap quiet composable → reselect filtered shot. Fix validator attempt-3 ground-layer bug that crashed ?song=dusk (LookGenerator now passes module, not layer). Eight canvas shaders window their beat/flux (focusField + per-seed hash mask) and report the scaled amount to Layer so the cap stays authoritative: scan-tear, gate-corridor, analog-wow, block-mosh, metaballs, pitch-shatter, scale-mosaic, tide-rings. Checks: scene-gate comfort gate (agitation ≤ 0.70), filmstrip/gallery meters + per-scene drawer, metadata SCHEMA 7 with ranged metrics and fingerprint over agitation/comfort sources. main.js surfaces COMPOSITION_REFUSED as a user-facing error. Lint: 114 files clean, 71 scenes ok. All 17 songs generate looks. Build passes. metadata.json refresh deferred to gallery → Refresh metadata (SCHEMA 7 writer in vite.config). Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -27,6 +27,8 @@ import { hashString } from '../engine/rng.js';
|
||||
import { frameDescriptor, motionDescriptor } from './variety/descriptors.js';
|
||||
import { descriptorDistance, directionOf, STRUCTURAL } from './variety/signature.js';
|
||||
import { describeStory } from '../look/Story.js';
|
||||
import { frameLuminance } from '../engine/hash.js';
|
||||
import { agitationScore, AGITATION_CEILING } from '../engine/agitation.js';
|
||||
|
||||
export const STRIP = { width: 256, height: 144 };
|
||||
|
||||
@@ -126,11 +128,53 @@ export function renderSong(show, name, { duration, every }) {
|
||||
}
|
||||
}
|
||||
|
||||
// Agitation: dense 60 fps burst in the busiest section, so the filmstrip is
|
||||
// also the agitation audit. The 30 s probes are 30 s apart and cannot see
|
||||
// beat-locked whole-screen motion — the metric needs consecutive frames.
|
||||
// A 120-frame (2 s) burst at the drop/busiest section captures the worst
|
||||
// 1 s floating window at its most energetic, mirroring gallery.js which
|
||||
// samples its 60-frame strip at the busiest context. Whole-screen
|
||||
// flash/coherent motion reserved for special cases, region motion fine.
|
||||
let score = { agitation: 0, peakFlashRate: 0, peakMotion: 0, meanMotion: 0, peakStructure: 0, meanStructure: 0 };
|
||||
let agitated = false;
|
||||
try {
|
||||
const busiest = look.sections.reduce(
|
||||
(best, s) => ((s.bias?.energy ?? 0) > (best.bias?.energy ?? 0) ? s : best),
|
||||
look.sections[0] || { startFrame: 0, endFrame: Math.min(track.frameCount, 120) },
|
||||
);
|
||||
const probe = busiest.startFrame + Math.floor((busiest.endFrame - busiest.startFrame) * 0.5);
|
||||
const STRIP_AGIT = 120; // 2 s @ 60 fps → 1 s window worst
|
||||
show.warmUp(probe, WARMUP);
|
||||
const agFrames = [];
|
||||
const agLums = [];
|
||||
for (let f = probe; f < probe + STRIP_AGIT; f++) {
|
||||
const ff = Math.min(track.frameCount - 1, f);
|
||||
const px = Uint8Array.from(show.readPixels(show.renderFrame(ff)));
|
||||
agFrames.push(px);
|
||||
agLums.push(frameLuminance(px));
|
||||
}
|
||||
score = agitationScore(agLums, agFrames, 60, { width: STRIP.width, height: STRIP.height });
|
||||
agitated = score.agitation >= AGITATION_CEILING;
|
||||
} catch {
|
||||
// leave agitation at 0 — row still renders, just grey
|
||||
}
|
||||
|
||||
return {
|
||||
name,
|
||||
frames,
|
||||
drift: mean(dists),
|
||||
direction: directionOf(gaps, dists),
|
||||
// Agitation over the strip: worst 1 s floating window, max(flash/3,
|
||||
// motion/0.55) → 0..1. The filmstrip already renders the whole video,
|
||||
// so this is the natural audit for "how much of the view moves/flashes
|
||||
// together". Gallery's agitation is per-scene; this one is per-song.
|
||||
agitation: score.agitation,
|
||||
agitationFlash: score.peakFlashRate,
|
||||
agitationMotion: score.peakMotion,
|
||||
agitationMean: score.meanMotion,
|
||||
agitationStructPeak: score.peakStructure,
|
||||
agitationStruct: score.meanStructure,
|
||||
agitated,
|
||||
// How much the FIRST half of the video resembles the last — the reading
|
||||
// that separates a video which ends somewhere new from one that has
|
||||
// come back on purpose. A recap should show up here and nowhere else.
|
||||
|
||||
Reference in New Issue
Block a user