From 02bd49768c6a7b812180756396c4adf78d597417 Mon Sep 17 00:00:00 2001 From: "prompt.ac/@jeffrey" Date: Mon, 18 May 2026 15:30:32 -0400 Subject: [PATCH] slab witness eye + trancenwaltzi liquid mode / camera / timecode MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit slab/menubar: StateSnapshot gains an off-main render-detect (pgrep for cover-video.mjs / preview-score.mjs / bgra ffmpeg); IconRenderer overlays a "witness" eye (almond + drifting/blinking pupil) on the menubar icon while a marketing/pop render runs. Closes when idle. cover-video.mjs: - --liquid flag (trancenwaltzi): slit-scan row-tear → smooth water ripple; column-shear doom-melt → liquid crossfade dissolve; string warp 0.4x. trancenwaltz proper keeps slit/shear at full strength. - camera: reverted fit-width/bleed (caused a repeated top/bottom band) back to cover-fit @ baseScale 1.0 (no overscan, ~12% less crop than the old 1.12, ken-burns still breathes). - timecode tcFontSize → 0.82x (read bigger than the title at equal pt). - IG-story title inset down to clear the IG UI. TODO.txt: note the slab/screensaver render-witness idea. Co-Authored-By: Claude Opus 4.7 --- TODO.txt | 2 + pop/dance/bin/cover-video.mjs | 74 ++++++++++++++----- .../Sources/SlabMenubar/IconRenderer.swift | 55 +++++++++++++- .../Sources/SlabMenubar/StateSnapshot.swift | 21 ++++++ 4 files changed, 133 insertions(+), 19 deletions(-) diff --git a/TODO.txt b/TODO.txt index dbe43eff40..7be1f7d534 100644 --- a/TODO.txt +++ b/TODO.txt @@ -2859,3 +2859,5 @@ If developing: Add a "#nodebug" hash to the end of a URL for less output. 📖 This project originally began as two separate repositories with their own commit history: `digitpain/system` and `digitpain/disks`. + +- slab menubar graphic + screensaver: add a "witness / eye-witness" indicator that shows when a marketing/pop render is in progress (poll for cover-video.mjs / preview-score.mjs / ffmpeg-bgra; show an eye/eyeball that "watches" while rendering, idle otherwise). ties slab status into the render pipeline. diff --git a/pop/dance/bin/cover-video.mjs b/pop/dance/bin/cover-video.mjs index 659a2bca90..755519eec3 100755 --- a/pop/dance/bin/cover-video.mjs +++ b/pop/dance/bin/cover-video.mjs @@ -94,6 +94,10 @@ const FPS = Number(flags.fps ?? 30); const HIDE_LANES = new Set( (flags["hide-lanes"] || "").toString().split(",").map((s) => s.trim()).filter(Boolean) ); +// --liquid → trancenwaltzi feel: smooth WATER-RIPPLE displacement + +// rippled section dissolve, instead of the slit-scan row-tear / +// column-shear melt used for trancenwaltz proper. +const LIQUID = Boolean(flags.liquid); if (!ILLUSTRATION || !existsSync(ILLUSTRATION)) { console.error(`✗ --illustration is required and must exist (got: ${ILLUSTRATION})`); @@ -718,7 +722,9 @@ for (const lane of LANES) { // shadow). At draw time the white plate is recoloured to the current // section tint (brightened by section progress) and the black plate // is stamped twice behind it — identical compositing to wanderbeach. -const tcFontSize = titleFontSize; // timecode matches the title size +// Timecode read BIGGER than the title at equal point size (different +// glyph metrics) — pulled to ~0.82× so it visually matches the title. +const tcFontSize = Math.round(titleFontSize * 0.82); const tcCache = new Map(); // "mm:ss / mm:ss" → { img, shadow } { const assetsDir = TRACK.replace(/\.mp3$/, ".assets"); @@ -1296,7 +1302,10 @@ function warpUnderString(theta, tSec = 0) { // (one continuous blit → no mosaic); the thin edge margins ramp the // shift 0→full via a handful of slices. Datamosh drag is rare + only // on hard bends, so it reads as occasional corruption, not a grid. - const gnarlAmt = Math.min(1, Math.abs(devPeak) / 44); + // LIQUID (trancenwaltzi) wants a MUCH gentler string distortion — + // 0.4× strength + jitter. trancenwaltz proper keeps full strength. + const wuMul = LIQUID ? 0.4 : 1; + const gnarlAmt = Math.min(1, Math.abs(devPeak) / 44) * wuMul; const FEATHER = 80; // px each edge that ramps in const MS = 9; // feather sub-slices per edge const msW = FEATHER / MS; @@ -1317,7 +1326,7 @@ function warpUnderString(theta, tSec = 0) { h2 = ((h2 ^ (h2 >>> 15)) * 2246822519) >>> 0; const j0 = (hsh & 255) / 255 - 0.5, j1 = (h2 & 255) / 255 - 0.5; const jit = ((j0 * (1 - fr) + j1 * fr)) * 1.1 * gnarlAmt + sway; // soft - const dx = (needleXAt(y) - playheadX) * WU_STR + jit; // smooth slit + const dx = (needleXAt(y) - playheadX) * WU_STR * wuMul + jit; // smooth slit // left feather margin — shift ramps 0 → dx for (let m = 0; m < MS; m++) { const sxl = x0 + m * msW; @@ -1598,6 +1607,10 @@ function drawFrame(t) { // the canvas. baseScale is the LARGER of width-fit / height-fit, so // the illustration overhangs at least one axis. Then a 20 % zoom // factor on top gives ken-burns slack. + // COVER-fit (fills the frame — NO letterbox, no added top/bottom + // pixels / repeated band). baseScale 1.0 (below) sits exactly at + // cover so it crops far LESS than the old 1.12 overscan while the + // ken-burns still breathes in/out. const coverScale = Math.max(W / illusImg.width, H / illusImg.height); // Pulled WAY back — sit just above exact-cover so the whole scene // (pixies + setting), not just jeffrey's face, reads. Then BOUNCE: @@ -1606,7 +1619,7 @@ function drawFrame(t) { // Enough overscan that the slow ken-burns + soft kick zoom never // expose an edge (1.005 left ~0 room → any motion slammed off-frame // = the "really bad / jerky" pan). Smooth, never jerky. - const baseScale = 1.12; + const baseScale = 1.0; // rest exactly at full-width edges (was 1.12 overscan) // ORGANIC motion: a slow ken-burns breath (~16 s) + a SOFT kick // zoom — each kick eases the picture in a touch then releases. Soft // attack (~70 ms) → gentle exp release; max() over recent kicks so @@ -1673,6 +1686,7 @@ function drawFrame(t) { // Clamp so the illustration always covers the canvas — no black // bars on the sides. For a baseW × baseH image to cover W × H, the // draw position must satisfy: W - baseW ≤ drawX ≤ 0 (and same for Y). + // Cover-clamp — image always covers the canvas, no black/bled edge. drawX = Math.min(0, Math.max(W - baseW, drawX)); drawY = Math.min(0, Math.max(H - baseH, drawY)); // ── parallax / depth / slit drivers ──────────────────────────── @@ -1694,6 +1708,21 @@ function drawFrame(t) { // per-row chaotic comb that tears it apart right before the landing. const beatPhase = audioT * beatHz * 2 * Math.PI; const paintSlit = (img, dx, dy, dw, dh, amp) => { + if (LIQUID) { + // WATER RIPPLE — fine rows displaced by smooth traveling sines + // (no tear / no chaos): the picture reads as seen through gently + // rippling water, swelling a touch with the music + pre-drop. + backCtx.clearRect(0, 0, W, H); + backCtx.drawImage(img, dx, dy, dw, dh); + const rh = 3; + const a = 5 + env * 9 + preMelt * 26; // gentle → swelling + for (let y = 0; y < H; y += rh) { + const off = a * (Math.sin(y * 0.017 + audioT * 1.7) * 0.6 + + Math.sin(y * 0.046 - audioT * 2.3) * 0.4); + ctx.drawImage(backCanvas, 0, y, W, rh, off, y, W, rh); + } + return; + } if (amp < 3) { ctx.drawImage(img, dx, dy, dw, dh); return; } backCtx.clearRect(0, 0, W, H); backCtx.drawImage(img, dx, dy, dw, dh); @@ -1738,19 +1767,30 @@ function drawFrame(t) { drawDepth(nextIllus, nextX, nextY, nextBaseW2, nextBaseH2, 0.55); paintSlit(nextIllus, nextX, nextY, nextBaseW2, nextBaseH2, Math.max(slitAmp, 4)); - // OLD column-melt on top. - meltCtx.clearRect(0, 0, W, H); - meltCtx.drawImage(illusImg, drawX, drawY, baseW, baseH); - for (let c = 0; c < MELT_COLS; c++) { - const colX = c * MELT_COL_W; - const localT = (crossfadeT - meltDelays[c]) / Math.max(0.01, 1 - meltDelays[c]); - if (localT >= 1) continue; - const sc = Math.floor(colX / SLIT_COL_W) % SLIT_COLS; - const jitter = slitAmp * 0.6 * Math.sin(t * slitFreq[sc] * 1.6 + slitPhase[sc]); - const fall = Math.max(0, localT) * H * 1.05 + jitter; - ctx.drawImage(meltCanvas, - colX, 0, MELT_COL_W, H, - colX, fall, MELT_COL_W, H); + if (LIQUID) { + // LIQUID DISSOLVE — the OLD plate ripples + fades into the NEW + // through water (no column shear). A smooth opacity crossfade on + // the rippled old illo. + const ease = crossfadeT * crossfadeT * (3 - 2 * crossfadeT); + ctx.save(); + ctx.globalAlpha = Math.max(0, 1 - ease); + paintSlit(illusImg, drawX, drawY, baseW, baseH, slitAmp); + ctx.restore(); + } else { + // OLD column-melt on top. + meltCtx.clearRect(0, 0, W, H); + meltCtx.drawImage(illusImg, drawX, drawY, baseW, baseH); + for (let c = 0; c < MELT_COLS; c++) { + const colX = c * MELT_COL_W; + const localT = (crossfadeT - meltDelays[c]) / Math.max(0.01, 1 - meltDelays[c]); + if (localT >= 1) continue; + const sc = Math.floor(colX / SLIT_COL_W) % SLIT_COLS; + const jitter = slitAmp * 0.6 * Math.sin(t * slitFreq[sc] * 1.6 + slitPhase[sc]); + const fall = Math.max(0, localT) * H * 1.05 + jitter; + ctx.drawImage(meltCanvas, + colX, 0, MELT_COL_W, H, + colX, fall, MELT_COL_W, H); + } } } else { // No transition — depth back-plate + slit-displaced front. diff --git a/slab/menubar-swift/Sources/SlabMenubar/IconRenderer.swift b/slab/menubar-swift/Sources/SlabMenubar/IconRenderer.swift index 9bbca2896f..3db958cce6 100644 --- a/slab/menubar-swift/Sources/SlabMenubar/IconRenderer.swift +++ b/slab/menubar-swift/Sources/SlabMenubar/IconRenderer.swift @@ -11,7 +11,8 @@ enum IconRenderer { /// for idle / ambient / lid-closed states. static func image(for state: StateSnapshot, phase: CGFloat = 0, rotation: CGFloat = 0) -> NSImage { if !state.claudeSessions.isEmpty { - return polygonImage(state: state, phase: phase, rotation: rotation) + let poly = polygonImage(state: state, phase: phase, rotation: rotation) + return state.rendering ? withWitnessEye(poly, phase: phase) : poly } let name: String @@ -39,7 +40,7 @@ enum IconRenderer { ?? fallbackImage() let configured = base.withSymbolConfiguration(config) ?? base configured.isTemplate = true - return configured + return state.rendering ? withWitnessEye(configured, phase: phase) : configured } /// Cap the number of distinct polygon edges. Beyond this we still draw @@ -207,6 +208,56 @@ enum IconRenderer { } } + /// Composite a small "witness" EYE over the icon while a marketing / + /// pop render is in progress — it watches the pixels get made. The + /// pupil drifts + occasionally blinks with the shared phase so it + /// reads as alive. Template (menubar tints it). + private static func withWitnessEye(_ base: NSImage, phase: CGFloat) -> NSImage { + let sz = base.size + let out = NSImage(size: sz) + out.lockFocus() + base.draw(in: NSRect(origin: .zero, size: sz)) + + // Eye box: bottom-right corner, ~52% of the icon. + let ew = sz.width * 0.52 + let eh = ew * 0.62 + let ox = sz.width - ew - 1 + let oy: CGFloat = 1 + let cx = ox + ew / 2, cy = oy + eh / 2 + + // Blink: lid closes briefly each cycle (a short dip in openness). + let t = phase.truncatingRemainder(dividingBy: 1) + let openness: CGFloat = t > 0.92 ? max(0.06, 1 - (t - 0.92) / 0.08 * 1.9) : 1.0 + let halfH = (eh / 2) * openness + + NSColor.black.setStroke() + NSColor.black.setFill() + + // Almond lens — two symmetric quad curves (lid open ∝ openness). + let lens = NSBezierPath() + lens.move(to: NSPoint(x: ox, y: cy)) + lens.curve(to: NSPoint(x: ox + ew, y: cy), + controlPoint1: NSPoint(x: ox + ew * 0.30, y: cy + halfH), + controlPoint2: NSPoint(x: ox + ew * 0.70, y: cy + halfH)) + lens.curve(to: NSPoint(x: ox, y: cy), + controlPoint1: NSPoint(x: ox + ew * 0.70, y: cy - halfH), + controlPoint2: NSPoint(x: ox + ew * 0.30, y: cy - halfH)) + lens.lineWidth = max(1, sz.width * 0.06) + lens.stroke() + + // Pupil — drifts horizontally (looking around), hidden on blink. + if openness > 0.45 { + let pr = eh * 0.30 + let drift = sin(Double(phase) * 2 * .pi) * Double(ew * 0.16) + let pupil = NSBezierPath(ovalIn: NSRect( + x: cx + CGFloat(drift) - pr, y: cy - pr, width: pr * 2, height: pr * 2)) + pupil.fill() + } + out.unlockFocus() + out.isTemplate = true + return out + } + private static func fallbackImage() -> NSImage { let size = NSSize(width: 16, height: 16) let img = NSImage(size: size) diff --git a/slab/menubar-swift/Sources/SlabMenubar/StateSnapshot.swift b/slab/menubar-swift/Sources/SlabMenubar/StateSnapshot.swift index 583993ee61..78b413cb2c 100644 --- a/slab/menubar-swift/Sources/SlabMenubar/StateSnapshot.swift +++ b/slab/menubar-swift/Sources/SlabMenubar/StateSnapshot.swift @@ -12,6 +12,9 @@ struct StateSnapshot { var themeByStatus: Bool = false var tailnetPeers: [TailnetPeer] = [] var claudeSessions: [ClaudeSession] = [] + /// A marketing / pop render is actively in progress — the menubar + /// "witness" eye opens while we watch the pixels get made. + var rendering: Bool = false var totalActive: Int { activePrompts + activeSubagents } var hasWork: Bool { totalActive > 0 } @@ -42,6 +45,7 @@ struct StateSnapshot { s.themeByStatus = FileManager.default.fileExists(atPath: Paths.themeByStatusFlag) s.tailnetPeers = TailnetPeer.query() s.claudeSessions = ClaudeSessionReader.active() + s.rendering = detectRendering() return s } @@ -74,4 +78,21 @@ struct StateSnapshot { guard let contents = try? FileManager.default.contentsOfDirectory(atPath: dir) else { return 0 } return contents.filter { !$0.hasPrefix(".") }.count } + + /// True while a marketing / pop render is running: a cover-video.mjs + /// or chillwave preview-score.mjs, or an ffmpeg consuming raw BGRA + /// frames (the canvas video encode). Off-main (gather() runs off the + /// main tick), one cheap pgrep with a short timeout. + private static func detectRendering() -> Bool { + guard let out = ShellRunner.output( + "/bin/sh", + args: ["-c", + "pgrep -f 'cover-video\\.mjs' >/dev/null 2>&1 || " + + "pgrep -f 'preview-score\\.mjs' >/dev/null 2>&1 || " + + "pgrep -f 'ffmpeg .*pix_fmt bgra' >/dev/null 2>&1; " + + "if [ $? -eq 0 ]; then echo R; fi"], + timeout: 2 + ) else { return false } + return out.contains("R") + } } -- 2.51.2