v7.19.0 — trueblack auto: trails reach pure black at any persist without opting in; exact alpha-preserving -1/255 GPU sweep (mode 22) runs by default in the sticky zone (persist >= 0.5), Canvas2D difference tail demoted to a gated no-WebGL fallback (alphaArt latch), trueblack header tri-state (auto/on/off) — fixes the Texe-rings stuck-quantum smear

This commit is contained in:
zai-bot 2026-11-07 03:25:42 +00:00
parent 2f0ed4f657
commit 60a7d40568
7 changed files with 229 additions and 32 deletions

View file

@ -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'], ['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'], ['w / h', 'canvas size in pixels'],
['aspect', 'canvas width / height -- divide x by it for perfect circles'], ['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'], ['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)'], ['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'], ['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 mode: lines # lines | dots | solid
blend: additive # any of the 41 keywords -- see the Blend modes table blend: additive # any of the 41 keywords -- see the Blend modes table
persist: 0.85 # trail persistence 0..1 (1 = never fades) persist: 0.85 # trail persistence 0..1 (1 = never fades)
trueblack: off # subtractive fade tail: trails reach PURE black even at trueblack: auto # on | off | auto (default auto): subtractive fade tail
# high persist (no gray wash) -- but it turns transparent # so trails reach PURE black even at high persist --
# pixels opaque, so leave it off for behind/clear art # auto runs it exactly in the sticky zone (persist >= 0.5)
linewidth: 2 linewidth: 2
[init] # runs once on load [init] # runs once on load
@ -211,11 +211,13 @@ linewidth: 2
[point] # runs n times per frame`} [point] # runs n times per frame`}
</pre> </pre>
<p className="mt-2 text-[11px] leading-relaxed text-zinc-500"> <p className="mt-2 text-[11px] leading-relaxed text-zinc-500">
<span className="font-mono text-emerald-400/90">trueblack: on</span> pairs with a high persist: the trail fade multiplies the <span className="font-mono text-emerald-400/90">trueblack: auto</span> keeps every trail honest: the fade multiplies the framebuffer in 8-bit
framebuffer in 8-bit steps, so values like 0.9 leave a stuck gray residue (round(v·p) = v for the smallest steps, so a persist of 0.5 or more leaves a stuck residue (round(v·p) = v for the smallest values) -- a faint
values); the tail subtracts 1/255 per frame so everything reaches true black -- long echo trails stay clean colored wash that never fully fades. auto (the default) subtracts 1/255 per frame exactly in that sticky zone,
over a pure background. Because it forces pixels opaque, never use it together with{' '} so everything ends in true black while cheap low-persist scripts pay nothing. The sweep runs on the GPU and
<span className="font-mono">behind</span>/<span className="font-mono">clear</span> blending. preserves transparency, so <span className="font-mono">behind</span>/<span className="font-mono">clear</span>{' '}
art keeps its holes (the no-WebGL fallback stands down for it instead). <span className="font-mono">on</span>{' '}
forces the sweep at any persist below 1, <span className="font-mono">off</span> keeps the wash for looks.
</p> </p>
</HelpSection> </HelpSection>
</div> </div>

View file

@ -6,6 +6,13 @@
// negation, phoenix, grain extract/merge, reflect, glow, and the four // negation, phoenix, grain extract/merge, reflect, glow, and the four
// After Effects masks (stencil / silhouette, luma + alpha). // 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 // Per frame the engine draws the stroke passes onto a scratch canvas
// (source-over, so the two-pass glow keeps its usual tuning), then: // (source-over, so the two-pass glow keeps its usual tuning), then:
// 1. uploads the live canvas (backdrop) and the scratch (stroke layer) // 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 vec3 b = c > 0.0 ? S.rgb / max(c, 1e-4) : vec3(0.0); // straight stroke
// -- special-tail modes ------------------------------------------------ // -- 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 if (uMode == 1) { // dissolve: stochastic full-intensity speckle
// the +uFrame offset re-rolls the grain every frame (animated 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; 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 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 /** Render one blended frame into this.canvas. The engine then copies it
* back onto the live canvas with composite 'copy'. */ * back onto the live canvas with composite 'copy'. */
blend(dst: HTMLCanvasElement, src: HTMLCanvasElement, mode: number, frame: number): void { blend(dst: HTMLCanvasElement, src: HTMLCanvasElement, mode: number, frame: number): void {

View file

@ -31,6 +31,26 @@
// every pixel opaque (alpha composites to 1 under 'difference'), so it // every pixel opaque (alpha composites to 1 under 'difference'), so it
// must not be combined with 'behind'/'clear' art -- opt-in, off by // must not be combined with 'behind'/'clear' art -- opt-in, off by
// default, and the Help panel documents the caveat. // 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 { blendByIndex, blendIndex, findBlend } from './blend'
import { BlendCompositor } from './blend-gpu' import { BlendCompositor } from './blend-gpu'
@ -122,6 +142,13 @@ export class AvaEngine {
* nearest native operator (spec.fallbackOp). */ * nearest native operator (spec.fallbackOp). */
private gpuBroken = false 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() { constructor() {
// AVS-eel compatibility hooks — the expression engine reads audio/time // AVS-eel compatibility hooks — the expression engine reads audio/time
// through these (getspec/getosc/gettime). Headless tests can override. // through these (getspec/getosc/gettime). Headless tests can override.
@ -191,6 +218,11 @@ export class AvaEngine {
this.script = script this.script = script
this.scriptError = null this.scriptError = null
this.ysign = script.meta.ysign === -1 ? -1 : 1 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 // beatage starts "long ago" so exp(-beatage*k) envelopes read as 0 until
// the first real beat arrives. // the first real beat arrives.
this.env = { n: 400, t: 0, time: 0, frame: 0, beats: 0, beatage: 99 } this.env = { n: 400, t: 0, time: 0, frame: 0, beats: 0, beatage: 99 }
@ -205,6 +237,7 @@ export class AvaEngine {
clearScript(): void { clearScript(): void {
this.script = null this.script = null
this.scriptError = null this.scriptError = null
this.alphaArt = false
} }
getScriptName(): string { getScriptName(): string {
@ -290,6 +323,31 @@ export class AvaEngine {
return true 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: /** 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 * 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 * 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 // persist thins trails exactly like the old rgb scaling did over the
// black stage, while pixels that were painted behind / cleared keep // black stage, while pixels that were painted behind / cleared keep
// fading honestly. // fading honestly.
// v7.17: trueblack: on adds the subtractive tail (see the file header) // v7.19: trueblack is AUTOMATIC (see the file header). The subtractive
// -- the 'difference' fill runs INSIDE this branch so persist 1 keeps // tail runs right after the erase whenever the multiply can stick
// its true zero-fade (permanent) semantics. // (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 meta = this.script?.meta
const persistBase = meta ? meta.persist : 0.85 const persistBase = meta ? meta.persist : 0.85
const persist = Math.min(Math.max(this.env.persist ?? persistBase, 0), 1) const persist = Math.min(Math.max(this.env.persist ?? persistBase, 0), 1)
@ -445,10 +510,14 @@ export class AvaEngine {
ctx.globalCompositeOperation = 'destination-out' ctx.globalCompositeOperation = 'destination-out'
ctx.fillStyle = `rgba(0,0,0,${(1 - persist).toFixed(4)})` ctx.fillStyle = `rgba(0,0,0,${(1 - persist).toFixed(4)})`
ctx.fillRect(0, 0, w, h) 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.globalCompositeOperation = 'difference'
ctx.fillStyle = '#010101' ctx.fillStyle = '#010101'
ctx.fillRect(0, 0, w, h) ctx.fillRect(0, 0, w, h)
ctx.globalCompositeOperation = 'source-over'
} }
} }
ctx.globalCompositeOperation = 'source-over' ctx.globalCompositeOperation = 'source-over'
@ -583,6 +652,9 @@ export class AvaEngine {
// so the stroke owns its pixels outright. // so the stroke owns its pixels outright.
const headerSpec = findBlend(meta.blend) const headerSpec = findBlend(meta.blend)
const spec = blendByIndex(env.blend ?? 0) ?? headerSpec ?? undefined 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) { if (spec) {
const viaGpu = const viaGpu =
spec.impl === 'gpu' && spec.impl === 'gpu' &&

View file

@ -11,10 +11,13 @@
// per frame through the blend variable // per frame through the blend variable
// (by its number in the Help table) // (by its number in the Help table)
// persist: 0.9 <- motion trail persistence 0..1 (1 = never fades) // persist: 0.9 <- motion trail persistence 0..1 (1 = never fades)
// trueblack: on <- v7.17: adds a -1/255/frame subtractive tail to // trueblack: auto <- v7.19: tri-state subtractive tail on the
// the trail fade so it reaches PURE black at any // trail fade. auto (default): the engine
// persist (long trails normally settle into a // sweeps stuck 8-bit quanta to PURE black
// faint gray wash from 8-bit rounding) // exactly when the fade can stick
// (persist >= 0.5); on: sweep at any
// persist < 1; off: never (the old faint
// wash look)
// linewidth: 2 // linewidth: 2
// //
// [init] runs once when the script loads // [init] runs once when the script loads
@ -33,9 +36,12 @@ export interface ScriptMeta {
mode: RenderMode mode: RenderMode
blend: BlendMode blend: BlendMode
persist: number persist: number
/** v7.17 — subtractive tail on the trail fade (see engine.ts). Off by default: /** v7.19 — subtractive tail on the trail fade (see engine.ts), now a
* it forces every pixel opaque, which breaks 'behind'/'clear' transparency. */ * tri-state. 'auto' (default): the tail runs exactly in the sticky
trueblack: boolean * 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 linewidth: number
/** +1 (default, y is up) or -1 (AVS convention, y is down — used by auto-ported presets). */ /** +1 (default, y is up) or -1 (AVS convention, y is down — used by auto-ported presets). */
ysign: number ysign: number
@ -77,7 +83,7 @@ const DEFAULT_META: ScriptMeta = {
mode: 'lines', mode: 'lines',
blend: 'additive', blend: 'additive',
persist: 0.85, persist: 0.85,
trueblack: false, trueblack: 'auto',
linewidth: 2, linewidth: 2,
ysign: 1, ysign: 1,
} }
@ -191,18 +197,19 @@ function tryParse(text: string): { script: AvaScript | null; meta?: ScriptMeta;
break break
} }
case 'trueblack': { case 'trueblack': {
// v7.17: on/off flag (also 1/0, true/false, yes/no). The engine's // v7.19: tri-state (on/off/auto, also 1/0, true/false, yes/no).
// fade multiplies the framebuffer by persist in 8-bit steps, so any // The engine's fade multiplies the framebuffer by persist in 8-bit
// persist >= 0.5 has fixed points (round(v*p) == v) -- a gray wash // steps, so any persist >= 0.5 has fixed points (round(v*p) == v)
// that never fully fades. The subtractive tail this flag enables // -- a faint colored wash that never fully fades. auto (the new
// sweeps those quantum values to a true 0 every frame. Costs one // default) sweeps those quantum values to a true 0 exactly in that
// fullscreen 'difference' fill; forces pixels opaque (never combine // zone, through the alpha-preserving GPU sweep (the Canvas2D
// with 'behind'/'clear' art -- the Help panel says so too). // 'difference' fallback stands down for behind/clear art); on
// forces the sweep at any persist < 1; off keeps the wash.
const t = value.toLowerCase() const t = value.toLowerCase()
if (!['on', 'off', '1', '0', 'true', 'false', 'yes', 'no'].includes(t)) { if (!['on', 'off', 'auto', '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 } } 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 break
} }
case 'linewidth': { case 'linewidth': {

View file

@ -39,6 +39,24 @@ export interface ReleaseNote {
} }
export const RELEASE_NOTES: 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', version: '7.18.0',
name: 'Beautiful Spectrogram', name: 'Beautiful Spectrogram',

View file

@ -12,6 +12,38 @@
// warns in the console when the two drift apart. // warns in the console when the two drift apart.
// //
// History: // 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 // 7.18 — "Beautiful Spectrogram" (user request, twelfth Zai-Magic
// preset): a true scrolling spectrogram as a pure script -- the // preset): a true scrolling spectrogram as a pure script -- the
// v7.12 bgx feedback slide moves the whole framebuffer left // v7.12 bgx feedback slide moves the whole framebuffer left
@ -616,4 +648,4 @@
export const APP_NAME = 'Open Visualization Engine' export const APP_NAME = 'Open Visualization Engine'
export const APP_SHORT = 'OVE' export const APP_SHORT = 'OVE'
export const APP_VERSION = '7.18.0' export const APP_VERSION = '7.19.0'

View file

@ -2684,3 +2684,33 @@ BROWSER E2E (agent-browser, ALL PASS):
Stage Summary: 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. - 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.