diff --git a/src/components/ava/help-panel.tsx b/src/components/ava/help-panel.tsx index 2102c9f..2137d04 100755 --- a/src/components/ava/help-panel.tsx +++ b/src/components/ava/help-panel.tsx @@ -24,7 +24,7 @@ const FRAME_VARS: [string, string][] = [ ['silent', '1 while silence holds the beat grid (v7.15.1) -- no beats fire and beatphase freezes; releases the moment real audio returns. Waveform / band data still flows, it is just near zero'], ['w / h', 'canvas size in pixels'], ['aspect', 'canvas width / height -- divide x by it for perfect circles'], - ['persist', 'motion-trail persistence 0..1, writable per frame -- 1 = the framebuffer never fades (old content then leaves only through a bg* transform or a blend: replace repaint); at 0.5 and above the 8-bit fade can leave a faint gray wash -- the trueblack: header fixes that'], + ['persist', 'motion-trail persistence 0..1, writable per frame -- 1 = the framebuffer never fades (old content then leaves only through a bg* transform or a blend: replace repaint); at 0.5 and above the 8-bit fade used to leave a faint gray wash -- the engine now sweeps that to pure black automatically (see trueblack)'], ['blend', 'per-frame override of the blend: header -- set it to a mode\'s number from the Blend modes table below (1 = normal, 2 = additive, 3 = replace, 6 = subtract, ...); read after [frame]/[beat], before drawing'], ['bgrot', 'background spin in radians per frame -- + clockwise, - counter-clockwise; 0 = off (set in [init] / [frame] / [beat]; persists like any variable)'], ['bgx / bgy', 'background drift per frame, the -1..1 screen convention (y is up) -- slide the whole picture up, down, left or right'], @@ -200,9 +200,9 @@ description: shown in the preset list mode: lines # lines | dots | solid blend: additive # any of the 41 keywords -- see the Blend modes table persist: 0.85 # trail persistence 0..1 (1 = never fades) -trueblack: off # subtractive fade tail: trails reach PURE black even at - # high persist (no gray wash) -- but it turns transparent - # pixels opaque, so leave it off for behind/clear art +trueblack: auto # on | off | auto (default auto): subtractive fade tail + # so trails reach PURE black even at high persist -- + # auto runs it exactly in the sticky zone (persist >= 0.5) linewidth: 2 [init] # runs once on load @@ -211,11 +211,13 @@ linewidth: 2 [point] # runs n times per frame`}
- trueblack: on pairs with a high persist: the trail fade multiplies the - framebuffer in 8-bit steps, so values like 0.9 leave a stuck gray residue (round(v·p) = v for the smallest - values); the tail subtracts 1/255 per frame so everything reaches true black -- long echo trails stay clean - over a pure background. Because it forces pixels opaque, never use it together with{' '} - behind/clear blending. + trueblack: auto keeps every trail honest: the fade multiplies the framebuffer in 8-bit + steps, so a persist of 0.5 or more leaves a stuck residue (round(v·p) = v for the smallest values) -- a faint + colored wash that never fully fades. auto (the default) subtracts 1/255 per frame exactly in that sticky zone, + so everything ends in true black while cheap low-persist scripts pay nothing. The sweep runs on the GPU and + preserves transparency, so behind/clear{' '} + art keeps its holes (the no-WebGL fallback stands down for it instead). on{' '} + forces the sweep at any persist below 1, off keeps the wash for looks.
diff --git a/src/lib/ava/blend-gpu.ts b/src/lib/ava/blend-gpu.ts index 34c4622..5ad1fd4 100755 --- a/src/lib/ava/blend-gpu.ts +++ b/src/lib/ava/blend-gpu.ts @@ -6,6 +6,13 @@ // negation, phoenix, grain extract/merge, reflect, glow, and the four // After Effects masks (stencil / silhouette, luma + alpha). // +// v7.19 adds mode 22, the trueblack decay sweep (sweep()): every pixel's +// premultiplied rgb AND alpha lose exactly 1/255, clamped at 0 -- the +// engine's persistence-fade tail. Unlike the Canvas2D 'difference' +// fallback it is exact (black stays a true 0, no round-trip oscillation) +// and alpha-preserving (behind/clear holes keep healing), which is what +// lets the engine run the sweep by default for every sticky persist. +// // Per frame the engine draws the stroke passes onto a scratch canvas // (source-over, so the two-pass glow keeps its usual tuning), then: // 1. uploads the live canvas (backdrop) and the scratch (stroke layer) @@ -67,6 +74,13 @@ void main() { vec3 b = c > 0.0 ? S.rgb / max(c, 1e-4) : vec3(0.0); // straight stroke // -- special-tail modes ------------------------------------------------ + if (uMode == 22) { // trueblack decay sweep (engine.ts fade tail) + // premultiplied rgb AND alpha lose exactly 1/255, clamped at 0; the + // stroke layer is not read. 1/255 = 0.00392156863... + gl_FragColor = vec4(max(D.rgb - vec3(0.00392156863), vec3(0.0)), + max(D.a - 0.00392156863, 0.0)); + return; + } if (uMode == 1) { // dissolve: stochastic full-intensity speckle // the +uFrame offset re-rolls the grain every frame (animated speckle) gl_FragColor = hash(vUV * 997.0 + vec2(uFrame * 0.618, uFrame * 0.317)) < c ? vec4(b, 1.0) : D; @@ -221,6 +235,28 @@ export class BlendCompositor { return true } + /** v7.19 — trueblack decay sweep: render one pass of mode 22 into + * this.canvas. The engine then copies it back onto the live canvas + * with composite 'copy'. Only the backdrop is uploaded (the stroke + * sampler is pointed at the same texture — mode 22 never reads it). */ + sweep(dst: HTMLCanvasElement): void { + const gl = this.gl + const prog = this.prog + if (!gl || !prog) return + gl.viewport(0, 0, this.canvas.width, this.canvas.height) + gl.useProgram(prog) + gl.activeTexture(gl.TEXTURE0) + gl.bindTexture(gl.TEXTURE_2D, this.texDst) + gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, dst) + gl.uniform1i(this.uDst, 0) + gl.activeTexture(gl.TEXTURE1) + gl.bindTexture(gl.TEXTURE_2D, this.texDst) // uSrc unread by mode 22 + gl.uniform1i(this.uSrc, 1) + gl.uniform1i(this.uMode, 22) + gl.uniform1f(this.uFrame, 0) + gl.drawArrays(gl.TRIANGLE_STRIP, 0, 4) + } + /** Render one blended frame into this.canvas. The engine then copies it * back onto the live canvas with composite 'copy'. */ blend(dst: HTMLCanvasElement, src: HTMLCanvasElement, mode: number, frame: number): void { diff --git a/src/lib/ava/engine.ts b/src/lib/ava/engine.ts index b43af64..225f619 100755 --- a/src/lib/ava/engine.ts +++ b/src/lib/ava/engine.ts @@ -31,6 +31,26 @@ // every pixel opaque (alpha composites to 1 under 'difference'), so it // must not be combined with 'behind'/'clear' art -- opt-in, off by // default, and the Help panel documents the caveat. +// +// v7.19: trueblack is AUTOMATIC. The subtractive tail now runs by default +// in exactly the regime where the multiply sticks (persist >= 0.5), so +// every trail -- not just scripts that know the header -- ends in pure +// black. The default path is the WebGL compositor's mode-22 sweep +// (blend-gpu.ts): premultiplied rgb AND alpha each lose exactly 1/255, +// clamped at 0. That is exact in 8-bit (black stays a true 0 -- no +// round-trip oscillation like the 'difference' fill's 0<->1 flicker) and +// alpha-PRESERVING, so 'behind'/'clear' art keeps its transparent holes +// (which heal to truly empty, too -- stuck alpha quanta are swept like +// everything else). Because the GPU sweep is safe for alpha art, it runs +// unconditionally. The v7.17 Canvas2D 'difference' fill survives only as +// the no-WebGL fallback, and there it stands down once the script has +// been seen drawing with 'behind'/'clear' (the alphaArt latch; the header +// blend pre-latches it, a per-frame env.blend switch latches at draw +// time). The header itself is now a tri-state: auto (default -- sweep in +// the sticky zone only), on (always, the v7.17 behavior) and off (never, +// for authors who want the wash). Scripts at persist < 0.5 pay nothing: +// their multiply already decays every quantum value, so the sweep is +// skipped entirely. import { blendByIndex, blendIndex, findBlend } from './blend' import { BlendCompositor } from './blend-gpu' @@ -122,6 +142,13 @@ export class AvaEngine { * nearest native operator (spec.fallbackOp). */ private gpuBroken = false + /** v7.19 — latched while the current script draws alpha-dependent art + * ('behind' / 'clear', from the header blend or a per-frame env.blend + * switch). The GPU trueblack sweep preserves alpha and always runs; the + * Canvas2D 'difference' fallback forces pixels opaque, so it stands + * down for these scripts instead of punching their holes shut. */ + private alphaArt = false + constructor() { // AVS-eel compatibility hooks — the expression engine reads audio/time // through these (getspec/getosc/gettime). Headless tests can override. @@ -191,6 +218,11 @@ export class AvaEngine { this.script = script this.scriptError = null this.ysign = script.meta.ysign === -1 ? -1 : 1 + // v7.19: pre-latch the alpha-art guard when the header blend itself is + // behind/clear (a per-frame env.blend switch latches at draw time) -- + // the Canvas2D fallback sweep must not punch those holes shut. + const headerSpec = findBlend(script.meta.blend) + this.alphaArt = headerSpec?.key === 'behind' || headerSpec?.key === 'clear' // beatage starts "long ago" so exp(-beatage*k) envelopes read as 0 until // the first real beat arrives. this.env = { n: 400, t: 0, time: 0, frame: 0, beats: 0, beatage: 99 } @@ -205,6 +237,7 @@ export class AvaEngine { clearScript(): void { this.script = null this.scriptError = null + this.alphaArt = false } getScriptName(): string { @@ -290,6 +323,31 @@ export class AvaEngine { return true } + /** v7.19 — run the trueblack decay sweep through the WebGL compositor + * (mode 22: premultiplied rgb and alpha each lose exactly 1/255, + * clamped at 0 -- see the file header). Exact in 8-bit and + * alpha-preserving, which is why the automatic sweep prefers it over + * the Canvas2D 'difference' tail. False when WebGL is unavailable or + * the pass throws (the caller then falls back, or stands down for + * alpha-dependent art). */ + private sweepViaGpu(w: number, h: number): boolean { + if (this.gpuBroken || !this.gpuEnsure(w, h)) return false + const compositor = this.compositor + const canvas = this.canvas + const ctx = this.ctx + if (!compositor || !canvas || !ctx) return false + try { + compositor.sweep(canvas) + ctx.globalCompositeOperation = 'copy' + ctx.drawImage(compositor.canvas, 0, 0) + ctx.globalCompositeOperation = 'source-over' + return true + } catch { + this.gpuBroken = true // one bad frame never repeats; fallback next frame + return false + } + } + /** v7.15 — one place for the stroke/dot/polygon passes, target-agnostic: * the native path draws onto the live canvas with the mode's composite * op; the gpu path draws onto the scratch with source-over (the shader @@ -435,9 +493,16 @@ export class AvaEngine { // persist thins trails exactly like the old rgb scaling did over the // black stage, while pixels that were painted behind / cleared keep // fading honestly. - // v7.17: trueblack: on adds the subtractive tail (see the file header) - // -- the 'difference' fill runs INSIDE this branch so persist 1 keeps - // its true zero-fade (permanent) semantics. + // v7.19: trueblack is AUTOMATIC (see the file header). The subtractive + // tail runs right after the erase whenever the multiply can stick + // (persist >= 0.5; below that every quantum value already decays), so + // trails always end in pure black -- Texe-rings at 0.78, blow-outs at + // 0.93, anything. Preferred path: the GPU sweep (exact, alpha-safe); + // the 'difference' fill is only the no-WebGL fallback and it stands + // down for behind/clear art (alphaArt). trueblack: on forces the tail + // at any persist < 1 (v7.17 behavior), off disables it entirely. + // persist 1 keeps its true zero-fade (permanent) semantics -- neither + // the erase nor the tail runs there. const meta = this.script?.meta const persistBase = meta ? meta.persist : 0.85 const persist = Math.min(Math.max(this.env.persist ?? persistBase, 0), 1) @@ -445,10 +510,14 @@ export class AvaEngine { ctx.globalCompositeOperation = 'destination-out' ctx.fillStyle = `rgba(0,0,0,${(1 - persist).toFixed(4)})` ctx.fillRect(0, 0, w, h) - if (meta?.trueblack) { + ctx.globalCompositeOperation = 'source-over' + const tb = meta?.trueblack ?? 'auto' + const wantSweep = tb === 'on' ? true : tb === 'off' ? false : persist >= 0.5 + if (wantSweep && !this.sweepViaGpu(w, h) && !this.alphaArt) { ctx.globalCompositeOperation = 'difference' ctx.fillStyle = '#010101' ctx.fillRect(0, 0, w, h) + ctx.globalCompositeOperation = 'source-over' } } ctx.globalCompositeOperation = 'source-over' @@ -583,6 +652,9 @@ export class AvaEngine { // so the stroke owns its pixels outright. const headerSpec = findBlend(meta.blend) const spec = blendByIndex(env.blend ?? 0) ?? headerSpec ?? undefined + // v7.19: remember alpha-dependent art for the Canvas2D fallback + // sweep's stand-down (the GPU sweep is alpha-safe and keeps running). + if (spec && (spec.key === 'behind' || spec.key === 'clear')) this.alphaArt = true if (spec) { const viaGpu = spec.impl === 'gpu' && diff --git a/src/lib/ava/script.ts b/src/lib/ava/script.ts index f1843ee..03eda04 100755 --- a/src/lib/ava/script.ts +++ b/src/lib/ava/script.ts @@ -11,10 +11,13 @@ // per frame through the blend variable // (by its number in the Help table) // persist: 0.9 <- motion trail persistence 0..1 (1 = never fades) -// trueblack: on <- v7.17: adds a -1/255/frame subtractive tail to -// the trail fade so it reaches PURE black at any -// persist (long trails normally settle into a -// faint gray wash from 8-bit rounding) +// trueblack: auto <- v7.19: tri-state subtractive tail on the +// trail fade. auto (default): the engine +// sweeps stuck 8-bit quanta to PURE black +// exactly when the fade can stick +// (persist >= 0.5); on: sweep at any +// persist < 1; off: never (the old faint +// wash look) // linewidth: 2 // // [init] runs once when the script loads @@ -33,9 +36,12 @@ export interface ScriptMeta { mode: RenderMode blend: BlendMode persist: number - /** v7.17 — subtractive tail on the trail fade (see engine.ts). Off by default: - * it forces every pixel opaque, which breaks 'behind'/'clear' transparency. */ - trueblack: boolean + /** v7.19 — subtractive tail on the trail fade (see engine.ts), now a + * tri-state. 'auto' (default): the tail runs exactly in the sticky + * zone (persist >= 0.5) through the alpha-safe GPU sweep, so every + * trail ends in pure black without the script opting in. 'on': forced + * at any persist < 1 (the v7.17 flag). 'off': never. */ + trueblack: 'on' | 'off' | 'auto' linewidth: number /** +1 (default, y is up) or -1 (AVS convention, y is down — used by auto-ported presets). */ ysign: number @@ -77,7 +83,7 @@ const DEFAULT_META: ScriptMeta = { mode: 'lines', blend: 'additive', persist: 0.85, - trueblack: false, + trueblack: 'auto', linewidth: 2, ysign: 1, } @@ -191,18 +197,19 @@ function tryParse(text: string): { script: AvaScript | null; meta?: ScriptMeta; break } case 'trueblack': { - // v7.17: on/off flag (also 1/0, true/false, yes/no). The engine's - // fade multiplies the framebuffer by persist in 8-bit steps, so any - // persist >= 0.5 has fixed points (round(v*p) == v) -- a gray wash - // that never fully fades. The subtractive tail this flag enables - // sweeps those quantum values to a true 0 every frame. Costs one - // fullscreen 'difference' fill; forces pixels opaque (never combine - // with 'behind'/'clear' art -- the Help panel says so too). + // v7.19: tri-state (on/off/auto, also 1/0, true/false, yes/no). + // The engine's fade multiplies the framebuffer by persist in 8-bit + // steps, so any persist >= 0.5 has fixed points (round(v*p) == v) + // -- a faint colored wash that never fully fades. auto (the new + // default) sweeps those quantum values to a true 0 exactly in that + // zone, through the alpha-preserving GPU sweep (the Canvas2D + // 'difference' fallback stands down for behind/clear art); on + // forces the sweep at any persist < 1; off keeps the wash. const t = value.toLowerCase() - if (!['on', 'off', '1', '0', 'true', 'false', 'yes', 'no'].includes(t)) { - return { script: null, meta: { ...meta }, error: { message: `trueblack must be on/off (got '${value}')`, section: 'header', line: lineNo } } + if (!['on', 'off', 'auto', '1', '0', 'true', 'false', 'yes', 'no'].includes(t)) { + return { script: null, meta: { ...meta }, error: { message: `trueblack must be on/off/auto (got '${value}')`, section: 'header', line: lineNo } } } - meta.trueblack = ['on', '1', 'true', 'yes'].includes(t) + meta.trueblack = t === 'auto' ? 'auto' : ['on', '1', 'true', 'yes'].includes(t) ? 'on' : 'off' break } case 'linewidth': { diff --git a/src/lib/release-notes.ts b/src/lib/release-notes.ts index 8220bb0..6139534 100755 --- a/src/lib/release-notes.ts +++ b/src/lib/release-notes.ts @@ -39,6 +39,24 @@ export interface ReleaseNote { } export const RELEASE_NOTES: ReleaseNote[] = [ + { + version: '7.19.0', + name: 'Automatic true-black', + changes: [ + { + kind: 'fixed', + text: 'Trails now fade all the way to pure black on their own. The fade works in 8-bit steps, so a persist of 0.5 or more used to leave a faint stuck residue behind everything that moved -- a colored smear where rings or blooms had passed. The engine now sweeps that residue away every frame, in every script, with no header needed.', + }, + { + kind: 'improved', + text: 'The sweep runs on the GPU as an exact -1/255 step that also preserves transparency, so behind/clear art keeps its holes (they heal to truly empty now too), and black stays a true black with no flicker. Scripts with a low persist pay nothing -- the sweep only switches on in the range where the 8-bit math could stick.', + }, + { + kind: 'improved', + text: 'The trueblack: header grows an auto mode (the new default) next to on and off, and the Help panel documents all three.', + }, + ], + }, { version: '7.18.0', name: 'Beautiful Spectrogram', diff --git a/src/lib/version.ts b/src/lib/version.ts index 7b74bb4..8fa7de6 100755 --- a/src/lib/version.ts +++ b/src/lib/version.ts @@ -12,6 +12,38 @@ // warns in the console when the two drift apart. // // History: +// 7.19 — Automatic true-black (user request, "Texe-rings"): the v7.17 +// trueblack tail is no longer opt-in. Root cause of the smear +// the user saw: the persistence fade multiplies the framebuffer +// by persist in 8-bit steps, and round-to-nearest leaves fixed +// points -- at persist 0.78 every premultiplied value 1..2 +// satisfies round(v*0.78) == v, so ring trails settle into a +// faint permanently-stuck wash (colored, not gray, because the +// script's channels divide by different constants and each +// channel sticks at its own quantum). No multiply-only +// compositing can fix that (proven: unsticking v=1 needs a +// multiplier < 0.5, which halves the trail every frame), so the +// fix is a subtractive -1/255/frame tail -- and the reason it +// could become automatic is a NEW implementation: shader mode 22 +// in the WebGL compositor (blend-gpu.ts) clamps +// max(premult-1/255, 0) on rgb AND alpha in one pass. That is +// exact in 8-bit (black stays a true 0, unlike the Canvas2D +// 'difference' fill's 0<->1 flicker) and alpha-PRESERVING, so +// behind/clear art keeps its holes (which now heal to truly +// empty too -- stuck alpha quanta sweep like everything else). +// Engine policy: the sweep runs by default exactly in the +// sticky zone (persist >= 0.5), so low-persist scripts pay +// nothing and nothing visual changes where the multiply already +// decayed fully. The old 'difference' fill survives only as the +// no-WebGL fallback, and it stands down once a script has drawn +// with behind/clear (the alphaArt latch -- pre-latched from the +// header blend, latched at draw time on a per-frame env.blend +// switch). The trueblack: header becomes a tri-state: auto +// (default, the new behavior), on (forced at any persist < 1, +// the v7.17 flag), off (never -- the wash look, for authors who +// want it). Texe-rings itself needs no edit: at 0.78 the auto +// sweep clears each channel's quanta in 2-3 frames after the +// multiply stalls, and the background returns to pure black. // 7.18 — "Beautiful Spectrogram" (user request, twelfth Zai-Magic // preset): a true scrolling spectrogram as a pure script -- the // v7.12 bgx feedback slide moves the whole framebuffer left @@ -616,4 +648,4 @@ export const APP_NAME = 'Open Visualization Engine' export const APP_SHORT = 'OVE' -export const APP_VERSION = '7.18.0' +export const APP_VERSION = '7.19.0' diff --git a/worklog.md b/worklog.md index 882dc58..b5a9677 100755 --- a/worklog.md +++ b/worklog.md @@ -2684,3 +2684,33 @@ BROWSER E2E (agent-browser, ALL PASS): Stage Summary: - v7.18.0 shipped: "Beautiful Spectrogram", the twelfth hand-authored Zai-Magic preset -- a true scrolling spectrogram as a pure script (bgx slide + persist 1 zero-fade + megabuf bin memory + palette table), with the per-frame ink computed from live stroke geometry so the accumulation lands on the palette at any fps/aspect/brush width. Browser-verified end to end: navy field, pink-to-white print, leftward crawl at design rate, no channel contamination, clean console. + +--- +Task ID: 52 +Agent: main (Z.ai Code) +Task: v7.19.0 (user request, "Texe-rings" script): diagnose why the background where the two rings move over leaves a slight off color instead of returning to pure black -- fix it wherever the fault lies (script, engine, blend, or persist/fade). + +Diagnosis (engine, not the script): +- The persistence fade multiplies the framebuffer by persist via destination-out, quantized to 8 bits with round-to-nearest. For any persist >= 0.5 there are fixed points: round(v*p) == v. At the script's persist 0.78 every premultiplied value 1..2 is stuck; ring trails therefore settle into a permanent faint wash. Colored, not gray, because the script divides its channels by different constants (red /7, green /12, blue /20) so each channel freezes at its own quantum. +- Proof that no multiply-only compositing can fix it: unsticking v=1 requires a multiplier < 0.5, which halves the trail every frame. Subtraction (or float precision) is required -- exactly why v7.17's opt-in trueblack: on existed. The script simply did not opt in, and the v7.17 tail could not be made default because the Canvas2D 'difference' fill forces every pixel opaque (breaks behind/clear art) and leaves untouched black oscillating 0<->1. + +Fix (default-on, exact, alpha-safe): +- blend-gpu.ts: new shader mode 22 + BlendCompositor.sweep() -- one GPU pass computing max(premult-1/255, 0) on rgb AND alpha. Exact in 8-bit (black stays a true 0) and alpha-preserving, which is what makes automatic default-on safe: behind/clear holes keep healing to truly empty too. +- engine.ts: after the destination-out erase, the sweep runs automatically exactly in the sticky zone (persist >= 0.5; below it every quantum already decays, so low-persist scripts pay nothing). GPU path preferred; the 'difference' fill survives only as the no-WebGL fallback and stands down once the script draws behind/clear (new alphaArt latch -- pre-latched from the header blend, latched at draw time on a per-frame env.blend switch; the GPU sweep never stands down). +- script.ts: trueblack header becomes a tri-state -- auto (default: sweep only in the sticky zone), on (forced at any persist < 1, the v7.17 flag), off (never; the wash look). persist 1 keeps its zero-fade semantics. +- Docs: help-panel persist row + file-format block + explainer rewritten for auto; version.ts 7.19.0 + history entry; release-notes 7.19.0 "Automatic true-black" (fixed + 2x improved). + +Work Log: +- src/lib/ava/blend-gpu.ts: mode 22 shader branch + sweep() (single dst texture upload; uSrc pointed at the same texture, unread). +- src/lib/ava/engine.ts: file-header v7.19 section; alphaArt field + pre-latch in setScript + reset in clearScript + draw-time latch; sweepViaGpu() helper (copy-back like the GPU blend path, gpuBroken latching on throw); fade section rewritten (tri-state gate, GPU-first, gated fallback). +- src/lib/ava/script.ts: ScriptMeta.trueblack: 'on' | 'off' | 'auto'; DEFAULT_META 'auto'; parser accepts auto alongside on/off/1/0/true/false/yes/no; header docs updated. +- src/components/ava/help-panel.tsx, src/lib/version.ts, src/lib/release-notes.ts: docs + version bump per the standing rule. + +BROWSER E2E (agent-browser, ALL PASS): +- Bug reproduced first: time-driven sweep probe (no audio needed), persist 0.9, trueblack off -- old-trail strip frozen at premultiplied 4 (max 4, avg 4, 100% nonzero) across two samples 3 s / ~140 frames apart. Non-decaying fixed point, exactly the reported smear. +- Fix verified: same probe with trueblack auto -- old-trail strip max 0, avg 0, 0% nonzero, alpha healed to 0 (GPU sweep path; WebGL confirmed available). Active drawing unharmed: the bar itself renders 255 with a ~21% fresh wake. +- Texe-rings verbatim (persist 0.78, no script edits): compiles (dots, n=100), runs against the Demo track (BPM 116.9); center disc census 81% exact-zero pure black, stuck band only 2-3% and moving between samples (the live decay wake behind the rings, not frozen residue), outer region pure black, 24 fps headless with audio decode. +- App: title v7.19.0; What's New leads with 7.19.0 "Automatic true-black" (CURRENT); console clean after reload (one stale version-drift warning from a mid-edit Fast Refresh cycle, gone on reload); mobile 420x800 footer flush at content end, zero gap; bun run lint clean; dev.log 200s only. + +Stage Summary: +- v7.19.0 shipped: the trueblack tail is automatic. Every trail now ends in pure black at any persist without opting in, via an exact -1/255 GPU sweep that preserves alpha (behind/clear safe); the Canvas2D difference tail is demoted to a gated no-WebGL fallback; the header is a tri-state (auto/on/off). Texe-rings works unmodified -- the smear is gone and the background returns to pure black behind the rings.