From d4667f5ea94cc9eeff81497da040b45ce6410b28 Mon Sep 17 00:00:00 2001 From: vicrodh <103399283+vicrodh@users.noreply.github.com> Date: Wed, 29 Jul 2026 21:23:10 -0600 Subject: [PATCH] perf(qt): rounded covers use a real mask, the spectrum ticks at 62Hz, the derivative cache is bounded MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The track's "ShaderEffect / ColorOverlay / MultiEffect render NOTHING on the software path this port runs on" doctrine was measured FALSE on 2026-07-29: qbz-qt runs on the GPU (OpenGL RHI, threaded render loop, vsync 5.56ms). The claim came from an offscreen session, and QT_QPA_PLATFORM=offscreen forces software by definition. Every design it justified needed re-deciding. - RoundedImage was a Canvas (renderTarget: Canvas.Image) that rasterised with QPainter on the CPU AND uploaded a texture per repaint, for every cover in the app, purely to round a corner identical in every frame of that image's life. It is now layer.enabled + MultiEffect { maskSource } — an FBO rendered once and cached for static content. Rectangle { radius; clip: true } cannot do this: clip in Qt Quick is a RECTANGULAR scissor and ignores radius. maskThresholdMin/maskSpreadAtMin are 0.5/1.0, NOT the 0.0/0.0 that looks natural: 0.0 collapses Qt's mask smoothstep to step(0, alpha) and every pixel with any alpha snaps fully opaque — measured 0 antialiased pixels at radius 40 against the Canvas's 292. At 0.5/1.0 it is 308, ramp [0,51,219,255]. A software machine keeps the Canvas arm (GraphicsInfo.api), and QBZ_QT_ROUND_MODE=canvas pins it on a GPU machine where the mask misbehaves. - VizSettle drove the spectrum from FrameAnimation, i.e. every rendered frame: 180fps on this box for data the producer publishes at TARGET_FPS = 30. Now a 16ms Timer; the wall-clock maths is unchanged. - SpectrumBand's per-bar y was a SECOND JS binding derived from height. An anchor does the same arithmetic in C++ with no binding at all, and each FFT frame is materialised into a plain array once per publish instead of being read element by element across the JS/C++ boundary. Bindings per tick: bars 28 (was 56), waveform 48 (was 96), energy 5 (was 10). QQuickPaintedItem was investigated and rejected with evidence: cxx-qt 0.7.3 emits X(QObject* parent) : base(parent), which does not compile against QQuickPaintedItem(QQuickItem*), paint() is pure virtual, and there is zero precedent for a custom base class in this workspace. Qt 6 ShaderEffect takes no GLSL, only precompiled .qsb, which would make the build depend on a Qt tool whose path differs on macOS. - PlaylistCollage point-sampled its mosaic in its own Canvas (Context2D.drawImage does not filter when it scales — measured RMSE 0.000 against nearest-neighbour). It now draws through RoundedImage. - The scaled-derivative cache had no eviction at all and the .jpg -> .png key change had orphaned 1251 files. Boot housekeeping sweeps the orphans once and bounds the directory at 64 MB, LRU by mtime. Verified by effect: first run deleted 1259 files / 10.05 MB, every run since reports 0, and the directory is 14 MB. The eviction is only survivable because RoundedImage clears _scaled on Image.Error and re-requests. - For You artwork patched the rails in place instead of republishing the document, which was resetting contentY. HomeView is destroyed on every navigation, so the patch map is re-handed on mount through QbzHome.refreshForyouArt() — otherwise the Radio rail and the Spotlight hero went blank for the rest of the session after one navigation, and the skeleton pulse Timer stayed pinned ON, defeating the fix it belonged to. - Spotlight's placeholder gated on the local path instead of the remote url, so it flashed a glyph on a cold cache where the Slint does not. The retired doctrine is corrected wherever it was load-bearing for a decision. The pre-baked icon tints are deliberately NOT reopened and no drop-shadows are added: both are visual changes, not perf fixes. cc --- crates/qbz-qt/qml/LoginScreen.qml | 11 +- crates/qbz-qt/qml/cards/PlaylistCollage.qml | 295 +++++----- crates/qbz-qt/qml/controls/QbzTooltip.qml | 16 +- .../qml/shell/SidebarNowPlayingDock.qml | 10 +- crates/qbz-qt/qml/shell/SongCardStamp.qml | 10 +- crates/qbz-qt/qml/shell/SpectrumBand.qml | 26 +- crates/qbz-qt/qml/shell/TrackInfoModal.qml | 10 +- crates/qbz-qt/qml/shell/VizSettle.qml | 75 ++- crates/qbz-qt/qml/theme/QbzIcon.qml | 17 +- crates/qbz-qt/qml/theme/RoundedImage.qml | 518 +++++++++++++----- crates/qbz-qt/qml/views/HomeView.qml | 102 +++- crates/qbz-qt/src/artwork_qt.rs | 125 ++++- crates/qbz-qt/src/foryou_qt.rs | 128 ++++- crates/qbz-qt/src/home_bridge.rs | 49 ++ 14 files changed, 1069 insertions(+), 323 deletions(-) diff --git a/crates/qbz-qt/qml/LoginScreen.qml b/crates/qbz-qt/qml/LoginScreen.qml index 754319069..d382ccf16 100644 --- a/crates/qbz-qt/qml/LoginScreen.qml +++ b/crates/qbz-qt/qml/LoginScreen.qml @@ -29,8 +29,15 @@ Rectangle { QbzTheme { id: theme } // Faked drop shadow (blur 32, offset-y 8, #00000066): a translucent - // black rounded rect behind the card. Qt5Compat DropShadow is not - // assumed to be installed on the target. + // black rounded rect behind the card. + // SUPERSEDED (2026-07-29): the justification was "Qt5Compat DropShadow is + // not assumed to be installed on the target", of a piece with the retired + // "effects render nothing" doctrine. Both effect modules ARE installed here + // and this port runs on the GPU (OpenGL RHI, measured); the old note came + // from an offscreen session, which forces the software renderer by + // definition — theme/RoundedImage.qml now DETECTS the software path with + // `GraphicsInfo.api`. A real blurred shadow is a visual change owing its + // own parity pass, so the fake stays for now. Rectangle { anchors.horizontalCenter: card.horizontalCenter y: card.y + 8 diff --git a/crates/qbz-qt/qml/cards/PlaylistCollage.qml b/crates/qbz-qt/qml/cards/PlaylistCollage.qml index 0d5892be3..5f9c32175 100644 --- a/crates/qbz-qt/qml/cards/PlaylistCollage.qml +++ b/crates/qbz-qt/qml/cards/PlaylistCollage.qml @@ -16,11 +16,34 @@ // the 800x380 playlist banner therefore has no case to answer here, and // duplicating it into this file would only fork the one implementation. // -// Canvas rather than a Grid of Images for the reason theme/RoundedImage.qml -// documents: QML `clip` is rectangular, so a Rectangle with `radius` does -// NOT round its children, and shader masks render nothing on the -// software/offscreen path. One Canvas per card, CPU raster, same cost -// profile as the RoundedImage every AlbumCard already mounts. +// ── HOW THE TILES ARE DRAWN (this file used to be a Canvas) ──────────────── +// Each tile is a `RoundedImage { radius: 0; fit: "crop" }`, i.e. a plain +// filtered `Image` that requests its own scaled derivative at its own device +// size through the existing `QbzSession.artScaled` seam. The previous body was +// a per-card `Canvas` whose `Context2D.drawImage` does NOT filter when it +// scales, so a 600px member cover in a ~100px mosaic cell was EXACTLY +// nearest-neighbour (measured RMSE 0.000 against `-filter Point`) — the +// artwork fix that reached RoundedImage never reached this file. Reuse over a +// second copy: RoundedImage already owns the intrinsic-size latch, the +// derivative request, the crop fit, the readiness latch and the +// recycled-delegate invalidation. +// +// Only the collage's OUTER shape is rounded; the tiles are square-cornered. +// That rounding is one `layer.enabled` + one `MultiEffect` mask on the mosaic +// (NOT one per tile), the same mechanism theme/RoundedImage.qml documents. +// Effects need shaders: this port runs on the GPU (OpenGL RHI, measured +// 2026-07-29), and the earlier "shader masks render nothing" note in this +// header came from an offscreen session, which forces the software renderer by +// definition. Where a software path is genuinely possible it is DETECTED +// (`GraphicsInfo.api`) — there the mask is skipped and the mosaic reads +// square-cornered over the already-rounded placeholder fill, rather than +// blank. +// +// PARITY NOTE (pre-existing gap, not introduced here): Slint's `n == 1` arm +// (PlaylistCollage.slint:39-49) contain-fits the single cover over a +// `playlist.dominant-color` letterbox; this port crops it, because the Qt feed +// publishes no `dominantColor` field. Logged for the UX pass — a perf fix must +// not invent a producer field. // // Artwork is URL-keyed, not artKey-keyed (a playlist row has ONE artKey but // up to four covers): the component hands its urls to the shared url-keyed @@ -31,6 +54,7 @@ // a windowed grid never fetches covers for delegates that are already gone. import QtQuick +import QtQuick.Effects import com.blitzfc.qbz import "../theme" @@ -52,6 +76,28 @@ Item { // url -> local cached path, filled by the artwork signal. property var pathMap: ({}) + /// See theme/RoundedImage.qml: the ONLY case the retired "effects render + /// nothing" doctrine was ever true for is the software/Null renderer, and + /// `GraphicsInfo.api` answers it per window at runtime (`api` carries + /// `notify: "apiChanged"`, so this re-evaluates when the window arrives). + /// Test Software/Null negatively — under the RHI an OpenGL backend reports + /// `GraphicsInfo.OpenGL`. + /// + /// `QbzShell.forceCanvasArt` (env `QBZ_QT_ROUND_MODE=canvas`, default OFF) + /// is honoured HERE TOO, and that is not cosmetic symmetry: the override + /// exists for a machine where `GraphicsInfo` reports a GPU and the mask + /// still misbehaves. If the mask draws nothing there, this file's mask + /// draws nothing either and the whole mosaic disappears — pinning the + /// tiles onto RoundedImage's canvas arm while leaving the mosaic masked + /// would defeat the override on exactly the surface it was added for. + /// Square outer corners (the same degrade the software path already takes, + /// see the header) is the correct fallback. `typeof` guard mirrors + /// RoundedImage: eager binding, and the file must stay loadable in an + /// isolated qml6 scene. + readonly property bool _noShaders: GraphicsInfo.api === GraphicsInfo.Software + || GraphicsInfo.api === GraphicsInfo.Null + || (typeof QbzShell !== "undefined" && QbzShell.forceCanvasArt) + function dedup(list) { var out = [] var seen = ({}) @@ -71,8 +117,48 @@ Item { return u ? (root.pathMap[u] || "") : "" } + /// The mosaic layout, transcribed from the deleted `onPaint` and confirmed + /// against discover/PlaylistCollage.slint line by line: `gap: 2px` (:19), + /// n==2 halves `(size - gap) / 2` (:57), n==3 `0.62` / `0.38` with + /// `(size - gap) / 2` rows (:74, :82-89), n>=4 `0.34` / `0.66` with + /// `(size - 2*gap) / 3` rows (:102-115, :121). These numbers do not + /// change. Returns an empty rect for a tile this playlist does not have. + function cellOf(index) { + var w = root.width + var h = root.height + var n = root.tiles.length + if (w <= 0 || h <= 0 || n <= 0 || index >= n) + return Qt.rect(0, 0, 0, 0) + var g = root.gap + if (n === 1) + return Qt.rect(0, 0, w, h) + if (n === 2) { + // Side-by-side halves. + var half = (w - g) / 2 + return index === 0 ? Qt.rect(0, 0, half, h) + : Qt.rect(half + g, 0, half, h) + } + if (n === 3) { + // One large left (62%), two stacked right (38%). + var big3 = (w - g) * 0.62 + var small3 = (w - g) * 0.38 + var half3 = (h - g) / 2 + if (index === 0) return Qt.rect(0, 0, big3, h) + if (index === 1) return Qt.rect(big3 + g, 0, small3, half3) + return Qt.rect(big3 + g, half3 + g, small3, half3) + } + // Three stacked left (34%), one large right (66%). + var col4 = (w - g) * 0.34 + var big4 = (w - g) * 0.66 + var row4 = (h - 2 * g) / 3 + if (index === 0) return Qt.rect(0, 0, col4, row4) + if (index === 1) return Qt.rect(0, row4 + g, col4, row4) + if (index === 2) return Qt.rect(0, 2 * (row4 + g), col4, row4) + return Qt.rect(col4 + g, 0, big4, h) + } + // Placeholder surface. A Rectangle's OWN rounded fill paints correctly — - // only its children need the Canvas clip. + // only its children need a mask. Rectangle { anchors.fill: parent radius: root.radius @@ -138,148 +224,69 @@ Item { } } - Canvas { - id: canvas + /// The rounded OUTER shape, rendered to its own FBO once (it changes only + /// with width/height/radius, never per frame). `visible: false` + layered + /// is the documented MultiEffect mask idiom — the layer renders regardless + /// of visibility, which is the whole point. + Item { + id: maskSrc anchors.fill: parent - renderTarget: Canvas.Image - // See RoundedImage.qml: GUI-thread raster. This canvas repaints from - // libraryArtworkReady and from four async image probes, on a card the - // feed recycles. - renderStrategy: Canvas.Immediate + visible: false + // Also gated on `tiles.length` — a layer renders even when its item is + // invisible (that is the whole mask idiom), so a coverless card would + // otherwise pay two FBOs to mask a glyph that is not inside them. + layer.enabled: !root._noShaders && root.radius > 0 && root.tiles.length > 0 + layer.smooth: true + Rectangle { + anchors.fill: parent + radius: root.radius + color: "#ffffff" + } + } - // Dimension probes: Canvas cannot query a loaded image's intrinsic - // size and the crop fit needs it (RoundedImage's pattern, one per - // tile). They double as the async-load notification. - Image { - id: p0 - source: root.pathAt(0) - visible: false - asynchronous: true - cache: true - onSourceChanged: canvas.prime(source) - onStatusChanged: canvas.requestPaint() - } - Image { - id: p1 - source: root.pathAt(1) - visible: false - asynchronous: true - cache: true - onSourceChanged: canvas.prime(source) - onStatusChanged: canvas.requestPaint() - } - Image { - id: p2 - source: root.pathAt(2) - visible: false - asynchronous: true - cache: true - onSourceChanged: canvas.prime(source) - onStatusChanged: canvas.requestPaint() - } - Image { - id: p3 - source: root.pathAt(3) - visible: false - asynchronous: true - cache: true - onSourceChanged: canvas.prime(source) - onStatusChanged: canvas.requestPaint() + // ONE layer + ONE mask for the whole mosaic, not one per tile. `clip: true` + // bounds the outer rect; each tile's own crop overflow is scissored by + // RoundedImage itself (`clip` there covers exactly the radius-0 crop case + // this file creates — the deleted Canvas clipped per tile). + Item { + id: mosaic + anchors.fill: parent + clip: true + visible: root.tiles.length > 0 + layer.enabled: !root._noShaders && root.radius > 0 && root.tiles.length > 0 + layer.smooth: true + layer.effect: MultiEffect { + maskEnabled: true + maskSource: maskSrc + // 0.5 / 1.0 — MEASURED, see theme/RoundedImage.qml for the full + // table. Qt's mask is a smoothstep CENTRED on maskThresholdMin of + // width maskSpreadAtMin, so (0.5, 1.0) is the only pair that maps + // mask alpha 0..1 onto 0..1 and therefore the only one that + // reproduces `ctx.clip()`'s antialiased corner. (0.0, 0.0) + // collapses to step(0, alpha) and the outer corners come back + // HARD (0 antialiased pixels vs the Canvas's 292); (0.0, > 0) + // clamps everything opaque and disables the mask outright. Do not + // "restore the defaults" here. + maskThresholdMin: 0.5 + maskSpreadAtMin: 1.0 } - function prime(src) { - if (src) - canvas.loadImage(src) - canvas.requestPaint() - } - - function probeAt(index) { - return index === 0 ? p0 : index === 1 ? p1 : index === 2 ? p2 : p3 - } - - // One crop-fitted tile, clipped to its own rect (uniform scale - // covering the cell, centered — Image.PreserveAspectCrop). - function tile(ctx, index, x, y, tw, th) { - var src = root.pathAt(index) - if (!src || tw <= 0 || th <= 0) - return - var probe = canvas.probeAt(index) - if (probe.status !== Image.Ready || probe.sourceSize.width <= 0 - || probe.sourceSize.height <= 0) - return - if (!canvas.isImageLoaded(src)) { - canvas.loadImage(src) - return - } - var iw = probe.sourceSize.width - var ih = probe.sourceSize.height - var scale = Math.max(tw / iw, th / ih) - var dw = iw * scale - var dh = ih * scale - ctx.save() - ctx.beginPath() - ctx.rect(x, y, tw, th) - ctx.clip() - ctx.drawImage(src, x + (tw - dw) / 2, y + (th - dh) / 2, dw, dh) - ctx.restore() - } - - onWidthChanged: requestPaint() - onHeightChanged: requestPaint() - onImageLoaded: requestPaint() - - onPaint: { - var ctx = getContext("2d") - ctx.reset() - var w = width - var h = height - if (w <= 0 || h <= 0) - return - var n = root.tiles.length - if (n <= 0) - return - - var r = Math.max(0, Math.min(root.radius, w / 2, h / 2)) - if (r > 0) { - ctx.beginPath() - ctx.moveTo(r, 0) - ctx.lineTo(w - r, 0) - ctx.arcTo(w, 0, w, r, r) - ctx.lineTo(w, h - r) - ctx.arcTo(w, h, w - r, h, r) - ctx.lineTo(r, h) - ctx.arcTo(0, h, 0, h - r, r) - ctx.lineTo(0, r) - ctx.arcTo(0, 0, r, 0, r) - ctx.closePath() - ctx.clip() - } - - var g = root.gap - if (n === 1) { - canvas.tile(ctx, 0, 0, 0, w, h) - } else if (n === 2) { - // Side-by-side halves. - var half = (w - g) / 2 - canvas.tile(ctx, 0, 0, 0, half, h) - canvas.tile(ctx, 1, half + g, 0, half, h) - } else if (n === 3) { - // One large left (62%), two stacked right (38%). - var big3 = (w - g) * 0.62 - var small3 = (w - g) * 0.38 - var half3 = (h - g) / 2 - canvas.tile(ctx, 0, 0, 0, big3, h) - canvas.tile(ctx, 1, big3 + g, 0, small3, half3) - canvas.tile(ctx, 2, big3 + g, half3 + g, small3, half3) - } else { - // Three stacked left (34%), one large right (66%). - var col4 = (w - g) * 0.34 - var big4 = (w - g) * 0.66 - var row4 = (h - 2 * g) / 3 - canvas.tile(ctx, 0, 0, 0, col4, row4) - canvas.tile(ctx, 1, 0, row4 + g, col4, row4) - canvas.tile(ctx, 2, 0, 2 * (row4 + g), col4, row4) - canvas.tile(ctx, 3, col4 + g, 0, big4, h) + Repeater { + model: 4 + delegate: RoundedImage { + required property int index + readonly property rect cell: root.cellOf(index) + visible: index < root.tiles.length + x: cell.x + y: cell.y + width: cell.width + height: cell.height + source: root.pathAt(index) + // Square-cornered: only the mosaic's outer shape is rounded. + // radius 0 also means this RoundedImage mounts a plain Image + // with NO layer and NO mask of its own. + radius: 0 + fit: "crop" } } } diff --git a/crates/qbz-qt/qml/controls/QbzTooltip.qml b/crates/qbz-qt/qml/controls/QbzTooltip.qml index bde3f658d..eed6e3a4d 100644 --- a/crates/qbz-qt/qml/controls/QbzTooltip.qml +++ b/crates/qbz-qt/qml/controls/QbzTooltip.qml @@ -48,11 +48,17 @@ // - MAX WIDTH + ELISION. Neither Slint bubble caps its width; a 200-character // playlist name would run off the window. `maxWidth` (320 default) caps it // and the label elides. Set maxWidth: 0 for the uncapped Slint behaviour. -// - SHADOW. Slint draws `drop-shadow-blur: 16px` / `10px`. Qt Quick has no -// shadow primitive on the software path (QtQuick.Effects renders NOTHING — -// see theme/RoundedImage.qml), so the port's usual approximation is used: -// one offset rectangle behind the bubble, exactly like -// SidebarNowPlayingDock.qml does for the cover. +// - SHADOW. Slint draws `drop-shadow-blur: 16px` / `10px`. The port's usual +// approximation is used instead: one offset rectangle behind the bubble, +// exactly like SidebarNowPlayingDock.qml does for the cover. +// SUPERSEDED (2026-07-29): the justification here used to be "QtQuick. +// Effects renders NOTHING". Effects need shaders, and this port runs on the +// GPU (OpenGL RHI, measured); that note came from an offscreen session, +// which forces the software renderer by definition — see +// theme/RoundedImage.qml, which now detects the software path with +// `GraphicsInfo.api` instead of assuming it. A real MultiEffect shadow is +// therefore possible; it is a VISUAL change needing its own parity pass +// against Slint's `drop-shadow-blur`, so it is deliberately NOT done here. import QtQuick import "../theme" diff --git a/crates/qbz-qt/qml/shell/SidebarNowPlayingDock.qml b/crates/qbz-qt/qml/shell/SidebarNowPlayingDock.qml index b4b149687..cc0bf3888 100644 --- a/crates/qbz-qt/qml/shell/SidebarNowPlayingDock.qml +++ b/crates/qbz-qt/qml/shell/SidebarNowPlayingDock.qml @@ -84,8 +84,14 @@ Item { // ---- Album cover ------------------------------------------------------- // Drop-shadow approximation of the Slint dock's 24px blur (two offset - // rects — QtQuick.Effects renders nothing on the software path, see - // RoundedImage.qml). + // rects). + // SUPERSEDED (2026-07-29): this used to say "QtQuick.Effects renders + // nothing on the software path". Effects need shaders, and this port runs + // on the GPU (OpenGL RHI, measured); that note came from an offscreen + // session, which forces the software renderer by definition — see + // theme/RoundedImage.qml, which now DETECTS the software path with + // `GraphicsInfo.api`. A real blurred shadow is possible; it is a visual + // change needing its own parity pass, so the approximation stays for now. Rectangle { x: 0 y: root.artY + 4 diff --git a/crates/qbz-qt/qml/shell/SongCardStamp.qml b/crates/qbz-qt/qml/shell/SongCardStamp.qml index 3f01fb5d4..a581ea94b 100644 --- a/crates/qbz-qt/qml/shell/SongCardStamp.qml +++ b/crates/qbz-qt/qml/shell/SongCardStamp.qml @@ -63,8 +63,14 @@ Column { // bottomPadding of 10 (5px bubble padding + the 5px caret zone). // // Slint also drop-shadows the bubble (blur 10, #00000099); that needs a - // shader layer effect, which renders nothing on the software path, so it - // is deliberately dropped. + // shader layer effect and is deliberately dropped. + // SUPERSEDED (2026-07-29): the reason used to be "renders nothing on the + // software path". Effects need shaders, and this port runs on the GPU + // (OpenGL RHI, measured); that note came from an offscreen session, which + // forces the software renderer by definition — theme/RoundedImage.qml now + // detects the software path with `GraphicsInfo.api` instead of assuming it. + // Adding the shadow is a visual change owing its own parity pass, so the + // bubble stays flat here. component TipBubble: Item { Rectangle { anchors.fill: parent diff --git a/crates/qbz-qt/qml/shell/SpectrumBand.qml b/crates/qbz-qt/qml/shell/SpectrumBand.qml index 7f1111513..9c117a6b8 100644 --- a/crates/qbz-qt/qml/shell/SpectrumBand.qml +++ b/crates/qbz-qt/qml/shell/SpectrumBand.qml @@ -20,6 +20,16 @@ // one stream the visible mode consumes. // 6. Capture itself is gated by the dock's vizShouldRun — a hidden band costs // zero: the producer parks and the drain thread sleeps. +// 7. ONE JS binding per bar, not two. `y` used to be a DERIVED binding on +// `height`, so every height change evaluated two bindings per bar; a static +// anchor (bottom / verticalCenter) does the same arithmetic in C++ and +// evaluates nothing. Combined with note 8 and VizSettle's 16 ms tick, bars +// mode went from 180 Hz x 56 binding evaluations/s to 62 Hz x 28. +// 8. `at()` indexes a PLAIN JS ARRAY: VizSettle materialises each published +// QList_f32 frame once per publish (30/s) instead of letting every `at(i)` +// cross into the sequence wrapper twice per bar per tick. The waveform arm +// already builds a plain Array in its own `target` binding, so it is not +// copied twice (VizSettle branches on Array.isArray). import QtQuick import com.blitzfc.qbz @@ -83,7 +93,14 @@ Rectangle { x: index * barsMode.slot + 1 width: barsMode.slot - 2 height: Math.max(2, barsSettle.at(bin) * band.height) - y: band.height - height + // `y: band.height - height` was a SECOND JS binding that + // re-evaluated on every height change. An anchor does the + // same arithmetic in C++ and evaluates no JS at all. + // Geometry is identical (Slint's VBar does + // `y: root.area-h - self.height`). Only the VERTICAL axis + // is anchored — `x`/`width` stay positional, which QML + // allows and which keeps the slot arithmetic untouched. + anchors.bottom: parent.bottom radius: 1 gradient: barGradient } @@ -124,7 +141,9 @@ Rectangle { x: index * waveMode.slot + 1 width: waveMode.slot - 2 height: Math.max(2, waveSettle.at(index) * band.height) - y: (band.height - height) / 2 + // Was `y: (band.height - height) / 2` — a second JS binding + // per column (48 of them). Same geometry, no JS. + anchors.verticalCenter: parent.verticalCenter radius: 1 color: band.topColor } @@ -153,7 +172,8 @@ Rectangle { x: index * energyMode.slot + 3 width: energyMode.slot - 6 height: Math.max(2, energySettle.at(index) * band.height) - y: band.height - height + // Was `y: band.height - height` — see the bars delegate. + anchors.bottom: parent.bottom radius: 1 gradient: barGradient } diff --git a/crates/qbz-qt/qml/shell/TrackInfoModal.qml b/crates/qbz-qt/qml/shell/TrackInfoModal.qml index 3a6680690..b478ad700 100644 --- a/crates/qbz-qt/qml/shell/TrackInfoModal.qml +++ b/crates/qbz-qt/qml/shell/TrackInfoModal.qml @@ -15,8 +15,14 @@ // metadata col = (card width - 48 - 2 * 24) / 3 (three equal columns) // scrollbar = 14px, inset 4px from the card's right edge // The Slint card carries drop-shadow-blur 32px; QML shadows need an effect -// (Qt5Compat / MultiEffect) and effects render NOTHING on the software path in -// this port, so the shadow is dropped rather than faked. +// (Qt5Compat / MultiEffect) and it is dropped here rather than faked. +// SUPERSEDED (2026-07-29): the reason used to be "effects render NOTHING on the +// software path in this port". Effects need shaders, and this port runs on the +// GPU (OpenGL RHI, measured); that note came from an offscreen session, which +// forces the software renderer by definition — theme/RoundedImage.qml now +// detects the software path with `GraphicsInfo.api` rather than assuming it. +// The shadow is still not added: it is a visual change owing its own parity +// pass against Slint's blur, not a perf fix. // // DATA — see the report's GLUE NEEDED; the Qt port has no track-info bridge // yet. Contract: QbzAlbum.openTrackInfo(trackId) fetches and publishes diff --git a/crates/qbz-qt/qml/shell/VizSettle.qml b/crates/qbz-qt/qml/shell/VizSettle.qml index 72881df2e..9c8546c7f 100644 --- a/crates/qbz-qt/qml/shell/VizSettle.qml +++ b/crates/qbz-qt/qml/shell/VizSettle.qml @@ -25,13 +25,22 @@ // shared. Interpolating is the correct fix for a step artefact. // // COST CONTRACT. -// - Zero allocation per rendered frame: `from`/`to` are REFERENCE SWAPS of the -// arrays the bridge already marshals once per publish. Only `progress` -// changes per frame. +// - Zero allocation per rendered frame: `from` is a REFERENCE SWAP and only +// `progress` changes per tick. `to` is materialised into a plain JS array +// ONCE per publish (30/s) so `at()` does not cross into the QList_f32 +// sequence wrapper twice per bar per tick — see `onTargetChanged`. +// - The DRIVER IS A 16 ms Timer, not a FrameAnimation. The producer publishes +// at 30/s; a per-frame trigger fired 180x/s on Linux (100x/s on macOS) for +// it. ~62 Hz keeps two interpolated steps per published frame, which is all +// the motion there is to buy. // - SELF-PARKING, exactly like the Rust drain: the driver starts on a publish -// and stops itself the moment `progress` reaches 1. A paused player, a hidden -// band or a disabled tap publishes nothing, so the driver never runs and the -// render loop goes back to sleep. No idle wakeups. +// and stops itself the moment `progress` reaches 1 (within one 33 ms period +// of the last publish). A paused player, a hidden band or a disabled tap +// publishes nothing, so nothing restarts it. A Timer does NOT stop itself +// when the window renders nothing the way a FrameAnimation did, so the +// `onLiveChanged` gate below — fed by the dock's `vizShouldRun`, which drops +// when the window deactivates — is what keeps an occluded window at zero +// wakeups. Do not weaken it. // - Delegates bind ONE call, `at(i)`, which reads `from`, `to` and `progress` // unconditionally on every path — QML captures those three as dependencies, // so a `progress` change re-evaluates the bar heights and instantiates @@ -93,9 +102,34 @@ Item { // 5..125 fps, everything a sane producer can emit. if (dt > 8 && dt < 200) settle.periodMs = settle.periodMs * 0.8 + dt * 0.2; - // Reference swap — no copy, no allocation. + // Plain-JS copy, ONCE per publish (30/s), so `at()` indexes a JS array + // instead of crossing into the QList_f32 sequence wrapper 2x per bar + // per tick (56 crossings for bars). `from = to` stays a reference + // swap — no copy, no allocation per rendered frame (the COST CONTRACT + // above). + // + // The waveform arm ALREADY hands over a plain Array (it folds |sample| + // in its own binding, SpectrumBand.qml:108-117), so skip the copy + // there instead of paying a second 48-element pass per publish. + // `t ? … : 0` is not defensive padding: the pre-fix body was a bare + // `settle.to = settle.target` and never dereferenced `target`, so the + // copy below is the FIRST deref in this file. All three producers + // (QbzViz.bars / .energy sequences, the waveform arm's own Array) + // always hand over a value, but a future fourth arm that publishes + // undefined would now throw inside a 30/s handler instead of degrading + // to a hold — which is the contract stated in the header. + var t = settle.target; + var arr; + if (Array.isArray(t)) { + arr = t; + } else { + var n = t ? t.length : 0; + arr = new Array(n); + for (var i = 0; i < n; ++i) + arr[i] = t[i]; + } settle.from = settle.to; - settle.to = settle.target; + settle.to = arr; settle.lastTickMs = now; settle.progress = 0.0; if (!driver.running) @@ -109,11 +143,28 @@ Item { } } - // Wall clock, not the driver's elapsedTime: elapsedTime only advances on a - // rendered frame, so using it would quantise the segment start to vsync and - // re-introduce the beat we are removing. - FrameAnimation { + // Wall clock, not a frame-driven elapsedTime: the time source must not + // quantise the segment start to vsync (that is the beat this object + // removes). The TRIGGER, however, must not run at the frame rate either — + // the producer publishes at TARGET_FPS = 30 (qbz-audio visualizer/mod.rs:32) + // and FrameAnimation fired 180x/s on Linux and 100x/s on macOS for it, + // i.e. 3-6x the frames the data justifies. 16 ms = ~62 Hz gives two + // interpolated steps per published frame, which is what buys the motion + // (Slint's equivalent is `animate bar-h { duration: 90ms; easing: ease; }`, + // SidebarNowPlayingDock.slint:45). + // + // `repeat: true` is MANDATORY — Timer defaults to false, and without it the + // driver fires once per publish and `progress` freezes at ~0.5. + // `triggeredOnStart` stays default false: `running` is only set from + // `onTargetChanged`, which has just written `progress = 0.0`, so an + // immediate tick would recompute p≈0 and do nothing. + // Coarse timer resolution is irrelevant: `progress` is derived from + // Date.now(), so a late tick lands a correspondingly larger `p` and a + // missed tick degrades to a hold, never a jump. + Timer { id: driver + interval: 16 + repeat: true running: false onTriggered: { var p = (Date.now() - settle.lastTickMs) / settle.periodMs; diff --git a/crates/qbz-qt/qml/theme/QbzIcon.qml b/crates/qbz-qt/qml/theme/QbzIcon.qml index 1314570f7..4e45c2767 100644 --- a/crates/qbz-qt/qml/theme/QbzIcon.qml +++ b/crates/qbz-qt/qml/theme/QbzIcon.qml @@ -5,12 +5,21 @@ // // Slint's QbzIcon is `Image { colorize: }`: the renderer // recolours the glyph every frame, so an icon always matches the current -// theme, all 36 of them, custom accent included. Qt has no equivalent on the -// path this port runs on — ColorOverlay / MultiEffect / shaders draw NOTHING -// on the software renderer (verified with a probe during the POC), which is -// why this file used to point straight at PRE-BAKED variants, +// theme, all 36 of them, custom accent included. Qt has no *free* equivalent, +// which is why this file used to point straight at PRE-BAKED variants, // assets/icons//.svg, each with a fixed hex burned in. // +// SUPERSEDED (2026-07-29): the reason recorded here used to be "ColorOverlay / +// MultiEffect / shaders draw NOTHING", from a POC probe. Effects need shaders, +// and this port runs on the GPU (OpenGL RHI, measured with QSG_INFO=1 in a real +// windowed session); the probe was taken under QT_QPA_PLATFORM=offscreen, which +// forces the software renderer by definition. Where a software path is +// genuinely possible, detect it with `GraphicsInfo.api` rather than assuming it +// — theme/RoundedImage.qml does exactly that. +// This does NOT re-open the design below. The runtime tint generator is cached, +// costs zero per frame, and serves all 36 themes; a per-glyph shader pass would +// be a rewrite, not a fix. Annotation only. +// // Fixed hexes cannot serve 36 themes: the bakes were cut against the dark // palette, so `secondary` (#cccccc) and `muted` (#888888) were near-white // glyphs on near-white surfaces on every LIGHT theme, and `accent` was diff --git a/crates/qbz-qt/qml/theme/RoundedImage.qml b/crates/qbz-qt/qml/theme/RoundedImage.qml index c44ed1c38..e4a695d4b 100644 --- a/crates/qbz-qt/qml/theme/RoundedImage.qml +++ b/crates/qbz-qt/qml/theme/RoundedImage.qml @@ -3,40 +3,69 @@ // Why this exists: QML's `clip` is rectangular — a Rectangle with // `radius` + `clip: true` does NOT clip children to the rounded shape // (proven with an isolated scene on this Qt build: the child paints -// square over the rounded fill). Shader masks (Qt5Compat OpacityMask, -// QtQuick.Effects MultiEffect) render NOTHING on the software/offscreen -// path (the phase-2 icon-tinting probe), and the Pi kiosk can run the -// software path — so rounding is done with a Canvas raster clip -// (QPainter, antialiased, renderTarget Image = CPU). +// square over the rounded fill). So the curve has to come from a mask. // -// Usage mirrors the old pattern: `RoundedImage { anchors.fill: parent; -// source: artPath; radius: theme.radiusSm }` inside the same rounded -// placeholder Rectangle (the placeholder's OWN fill rounds fine — only -// children needed the workaround). +// ── HOW THE ROUNDING IS DONE (and the doctrine this file used to carry) ──── +// Effects need shaders. This port runs on the GPU (OpenGL RHI, measured +// 2026-07-29 with QSG_INFO=1 in a real windowed session); the earlier +// "renders nothing" note in this header came from an offscreen session, which +// forces the software renderer by definition. Where a software path is +// genuinely possible, detect it with `GraphicsInfo.api` rather than assuming +// it. +// +// So there are two arms, chosen per item, at runtime: +// +// FAST ARM (default) one `Image` + ONE `layer.enabled` whose `layer.effect` +// is a `MultiEffect` masked by a rounded `Rectangle`. +// The layer FBO and the mask FBO are pure functions of +// (width, height, radius) — they are rendered once and +// cached, so a static cover costs ZERO CPU raster and +// ZERO texture upload per frame. `radius <= 0` takes +// neither the layer nor the mask, i.e. a plain Image. +// CANVAS ARM the previous CPU raster (QPainter into a QImage, +// `renderTarget: Canvas.Image`), kept verbatim for the +// two cases that genuinely need it: +// * `fit: "pad"` — its band colours come from +// `Context2D.getImageData` on the pixels the canvas +// just drew, and there is no non-Canvas way to read +// those back; +// * a software / Null renderer (offscreen, and any +// platform `qt-diag.sh` shows on the software path) +// — no shaders, so no mask. +// `QbzShell.forceCanvasArt` (env `QBZ_QT_ROUND_MODE= +// canvas`, default OFF) pins this arm by hand. +// +// Usage is unchanged: `RoundedImage { anchors.fill: parent; source: artPath; +// radius: theme.radiusSm }` inside the same rounded placeholder Rectangle +// (the placeholder's OWN fill rounds fine — only children needed a mask). // // ── READINESS CONTRACT (`ready`) ─────────────────────────────────────────── // A host that draws a loading placeholder over this item MUST gate it on // `ready`, never on "the path is non-empty". // -// The path landing means NOTHING here. This is a Canvas: once `source` is -// assigned the probe still has to load the file through QQuickPixmap and then -// a paint pass has to rasterize it. A placeholder gated on the path therefore -// vanishes the instant the path arrives and leaves an EMPTY tile on screen -// until the canvas paints — the reported bug ("the skeleton disappears before -// the art is rendered"). +// The path landing means NOTHING here: once `source` is assigned the file +// still has to be decoded (and, on the canvas arm, rasterized). A placeholder +// gated on the path therefore vanishes the instant the path arrives and leaves +// an EMPTY tile on screen until the pixels exist — the reported bug ("the +// skeleton disappears before the art is rendered"). // -// `ready` is true only when BOTH have happened for the CURRENT source: -// 1. the dimension probe reached Image.Ready (the pixmap is decoded), and -// 2. `onPaint` has run a pass that actually drew THAT source. -// It is derived from `_paintedSource`, so re-assigning `source` drops it back -// to false with no bookkeeping — a recycled list delegate cannot inherit the -// previous row's readiness. +// `ready` is true only when the CURRENT source is on screen: +// fast arm `Image.Ready` — the pixmap is decoded and its texture uploads +// on the next render pass; QbzSkeleton's 180ms fade covers that +// one frame (QbzSkeleton.qml:51-61 documents the same thing for +// its own probe arm, implemented `handedOver` :184 -> `retired` +// :214 -> `Behavior on opacity` :216). +// canvas arm `_paintedSource` — a paint pass actually drew THAT source. +// Both are MONOTONE WITHIN ONE SOURCE and cleared only by `onSourceChanged`, +// so a recycled list delegate cannot inherit the previous row's readiness and +// a mid-life derivative swap cannot un-retire a placeholder that has already +// handed over. // // Hosts that cannot reach this item (the image lives inside AlbumCard, // PlaylistCard, …) use the equivalent self-probing arm of the placeholder // itself: `QbzSkeleton { coverSource: }` — see QbzSkeleton.qml. // -// ── THE RASTER IS IN DEVICE PIXELS ───────────────────────────────────────── +// ── THE CANVAS ARM RASTERS IN DEVICE PIXELS ──────────────────────────────── // `onPaint` lays everything out in DEVICE pixels and pre-divides the context // by the device pixel ratio, instead of drawing in the item's logical pixels. // @@ -75,8 +104,9 @@ // card drawing the derivative RMSE 1.303 // ArtPreviewOverlay's plain Image RMSE 2.832 // -// so the fix below (`_effectiveSource` everywhere in `onPaint`) puts the card -// AHEAD of the preview that was reported as the good one. +// The derivative is kept on BOTH arms: the fast arm's `Image` filters, but a +// file already at the drawn size is still one decode of 200x200 instead of +// 600x600, and it is what makes `pad` 1:1. // // ── NON-SQUARE SOURCES: `fit: "pad"` / `fit: "auto"` ─────────────────────── // Qobuz playlist artwork (`image_rectangle`) is 800x380 — a 2.11:1 banner, @@ -103,15 +133,12 @@ // visible join, and the picture dissolves into the frame instead of ending at // a hard rectangle. // -// Two implementation constraints decided this shape: -// * ShaderEffect / ColorOverlay / MultiEffect render NOTHING on the -// software path, and `layer.enabled` would put a per-frame FBO on a card -// that appears dozens of times in a rail. A Canvas `createLinearGradient` -// is neither — it is the same CPU raster this file already runs. -// * The colours come from `Context2D.getImageData` on the pixels THIS -// canvas just drew, so no second decode, no extra cache dir and no Rust -// round-trip. Measured against a PIL average of the same source strip: -// (11,18,27) read back vs (9,17,25) — faithful. +// The colours come from `Context2D.getImageData` on the pixels THIS canvas +// just drew, so no second decode, no extra cache dir and no Rust round-trip. +// Measured against a PIL average of the same source strip: (11,18,27) read +// back vs (9,17,25) — faithful. That read-back is the reason `pad` keeps the +// Canvas: it is not a renderer limitation, it is that nothing else can hand +// back the pixels it just drew. // // `getImageData` here addresses the DEVICE-pixel backing store and IGNORES // the active transform — the same space `onPaint` already lays everything out @@ -138,9 +165,10 @@ import QtQuick import QtQuick.Window +import QtQuick.Effects import com.blitzfc.qbz -Canvas { +Item { id: root property string source: "" property real radius: 8 @@ -178,13 +206,64 @@ Canvas { /// must agree on this or the scaled file comes back the wrong shape. readonly property bool _contains: root._fit === "contain" || root._fit === "pad" + /// ── ARM SELECTION — detected, not configured ─────────────────────────── + /// Shaders are unavailable on the software/Null renderer (and that is the + /// ONLY case the retired "effects render nothing" doctrine was ever true + /// for: it was observed under QT_QPA_PLATFORM=offscreen, which forces + /// software by definition). GraphicsInfo.api answers it exactly, per + /// window, at runtime — so the offscreen gate keeps drawing covers the way + /// it does today and macOS gets the mask the moment it reports Metal. + /// + /// `api` carries `notify: "apiChanged"`, so this IS a live binding: before + /// the item has a window the value is `Unknown` (fast arm) and it + /// re-evaluates when the window arrives. Under the RHI an OpenGL backend + /// reports `GraphicsInfo.OpenGL` and the `*Rhi` values are Qt5-era + /// aliases, so testing Software/Null NEGATIVELY is the correct test — do + /// not test for OpenGL positively. + readonly property bool _noShaders: GraphicsInfo.api === GraphicsInfo.Software + || GraphicsInfo.api === GraphicsInfo.Null + /// `pad` samples the pixels it just drew (Context2D.getImageData); there + /// is no non-Canvas way to read them back, so that mode keeps the raster. + /// It is confined to Qobuz playlist banners (fit "pad" / a resolved + /// "auto"), i.e. 179 covers in the local cache, not every album cover. + /// + /// `typeof QbzShell !== "undefined"` mirrors the guard `_requestScaled` + /// already uses for QbzSession: a registered singleton is always defined + /// in the built binary, but the guard keeps this file loadable in an + /// isolated qml6 scene, which is how the "clip does not round" finding in + /// the header was obtained. + readonly property bool _useCanvas: root._fit === "pad" || root._noShaders + || (typeof QbzShell !== "undefined" && QbzShell.forceCanvasArt) + + /// PreserveAspectCrop overflows the item. A layer confines it (the layer + /// texture IS the item rect); with no layer and a crop fit, nothing does — + /// and a cropped tile would paint over its neighbour (PlaylistCollage's + /// mosaic, whose Canvas clipped per tile). Cheapest correct rule: scissor + /// exactly in the one case that has neither. NOT `clip: true` + /// unconditionally — that would put a scissor under every layered cover + /// for nothing. + clip: !root._useCanvas && root.radius <= 0 && !root._contains + /// THE handover signal — see the READINESS CONTRACT above. True only once /// the current `source` is on screen, not merely known. - readonly property bool ready: root._paintedSource !== "" - && root._paintedSource === root.source - /// The source of the last paint that actually drew pixels. Internal; it - /// exists as a property (not a bool flag) so that `ready` invalidates - /// itself on every source change. + /// + /// MONOTONE WITHIN A SOURCE, and that is not cosmetic: `QbzSkeleton`'s + /// `retired` is a live binding, so anything that drops `ready` back to + /// false brings the placeholder back over drawn art. Five hosts bind this + /// directly — FolderSubcard.qml:62, LocalAlbumRow.qml:115, + /// LocalAlbumHeader.qml:63, LocalEphemeralPane.qml:168, + /// LocalArtistRow.qml:70 — and every `coverSource` host observes the same + /// shape through its own probe. + readonly property bool ready: root.source !== "" + && (root._useCanvas ? (root._paintedSource === root.source) + : root._imgReady) + /// Fast-arm latch: set true on `Image.Ready`, cleared ONLY by + /// `onSourceChanged`. See the display Image's `onStatusChanged` for why + /// this must not mirror `status`. + property bool _imgReady: false + /// Canvas-arm equivalent: the source of the last paint that actually drew + /// pixels. Internal; it exists as a property (not a bool flag) so that + /// `ready` invalidates itself on every source change. property string _paintedSource: "" /// ── THE SCALED SOURCE ────────────────────────────────────────────────── @@ -242,7 +321,7 @@ Canvas { var w = Math.ceil(root.width * root._dpr) var h = Math.ceil(root.height * root._dpr) if (w <= 0 || h <= 0) return - // The SAME geometry `onPaint` uses, so the answer is the drawn size. + // The SAME geometry the draw uses, so the answer is the drawn size. // `_contains`, not `fit === "contain"`: "pad" and a resolved "auto" // fit INSIDE the cell, and asking for the crop (max) scale there // would hand back a file wider than the band the art occupies — @@ -274,34 +353,189 @@ Canvas { } } - renderTarget: Canvas.Image - // GUI-thread raster. Cooperative/Threaded rasterize on the SCENE GRAPH - // RENDER THREAD, and this item is created and destroyed by list recycling - // while async artwork callbacks schedule repaints — the render thread can - // be mid-draw on a pixmap whose Canvas is already gone. Immediate keeps - // paint and destruction on one thread. renderTarget: Image already meant - // CPU raster, so this costs no visual change. - renderStrategy: Canvas.Immediate - - // Dimension probe: Canvas can't query the loaded image's intrinsic - // size, and PreserveAspectCrop needs it. A hidden Image doubles as - // the async loader notification. + // Dimension probe: the ORIGINAL's intrinsic size is what `_requestScaled` + // and `fit: "auto"` need, and neither a Canvas nor a cropping Image can be + // asked for it. A hidden Image doubles as the async loader notification. + // + // It follows `_effectiveSource`, not `source`: once the derivative lands + // both this probe and the display Image reference the SAME file, so the + // original drops to refcount 0 and becomes evictable — which is the other + // half of the "RoundedImage keeps both the original and the derivative + // loaded" report. (Releasing the probe outright by binding + // `source: _srcW > 0 ? "" : source` is possible but requires reordering + // `_captureSrc` to read into locals first, because assigning `_srcW` + // would zero `sourceSize` mid-function. Deliberately NOT done here.) Image { id: probe source: root._effectiveSource visible: false asynchronous: true cache: true + // NO sourceSize: it is part of the QQuickPixmapCache key, so a + // decode-size request here would fork a SECOND decode of the file the + // derivative pipeline already sized (QbzSkeleton.qml:178-180 documents + // the same trap for its own probe). onStatusChanged: { // While `_scaled` is "" this IS the original, so this is where its // intrinsic size becomes knowable — and the derivative cannot be // requested before it (see `_srcW`). root._captureSrc() - if (status === Image.Ready || status === Image.Error) root.requestPaint() + if (status === Image.Ready || status === Image.Error) root._repaint() } onSourceSizeChanged: root._captureSrc() } + /// The rounded shape, rendered to its own FBO ONCE (it changes only with + /// width/height/radius, never per frame). `visible: false` + layered is + /// the documented MultiEffect mask idiom — the layer renders regardless of + /// visibility, which is the whole point. + Item { + id: maskSrc + anchors.fill: parent + visible: false + layer.enabled: root.radius > 0 && !root._useCanvas + layer.smooth: true + Rectangle { + anchors.fill: parent + radius: root.radius // Rectangle clamps internally, exactly + color: "#ffffff" // as onPaint's Math.min(r, w/2, h/2) did + } + } + + // ── The two arms ──────────────────────────────────────────────────────── + // One Loader swap at startup (when GraphicsInfo.api resolves), not per + // frame. `active` is a live binding on `_useCanvas`, which also follows + // `_fit` — a `fit: "auto"` cell that resolves to "pad" moves to the canvas + // arm on the same pass that resolves it. + Loader { + id: fastArm + anchors.fill: parent + active: !root._useCanvas + sourceComponent: imageComp + } + Loader { + id: canvasArm + anchors.fill: parent + active: root._useCanvas + sourceComponent: canvasComp + // A newly created Canvas has loaded no image yet (the root-level + // handlers already fired), so prime it here as well as there. + onLoaded: root._repaint() + } + + Component { + id: imageComp + Image { + anchors.fill: parent + source: root._effectiveSource + // "crop" = PreserveAspectCrop; "contain"/"pad" fit INSIDE. Same + // predicate the derivative request uses, so the file that comes + // back is the size this fillMode draws (see _requestScaled). + fillMode: root._contains ? Image.PreserveAspectFit + : Image.PreserveAspectCrop + asynchronous: true + cache: true + // NO sourceSize: it is part of the QQuickPixmapCache key, so a + // decode-size request here would fork a SECOND decode of the file + // the derivative pipeline already sized. + smooth: true + mipmap: false + // The derivative swaps `source` mid-life. Without this the item + // blanks for the frames the new file takes to decode — the Canvas + // never did (it kept the previous raster), so this is what keeps + // the swap invisible. Qt >= 6.8. + retainWhileLoading: true + // Called from onStatusChanged AND from Component.onCompleted: an + // already-cached pixmap can reach Image.Ready DURING the evaluation + // of the `source` binding, i.e. possibly before the handler is + // connected, and a missed latch would leave `ready` false forever — + // a QbzSkeleton shimmering on top of drawn art at five direct + // hosts. Same self-healing discipline as `_captureSrc`. + function _sync() { + // LATCH, do not mirror. `status` goes back to Image.Loading + // when the derivative swaps `_effectiveSource` mid-life + // (retainWhileLoading keeps the PIXELS, not the status), and a + // mirrored `_imgReady` would drop `ready` to false for those + // frames. `ready` false un-retires QbzSkeleton (`handedOver` + // :184 -> `retired` :214 -> opacity 1.0), i.e. the placeholder + // FADES BACK IN over art that is already on screen. The Canvas + // never did that: `_paintedSource` is monotone within one + // source. So: set true on Ready, and clear ONLY from + // onSourceChanged. + if (status === Image.Ready) + root._imgReady = true + // FALLBACK TO THE ORIGINAL, which the Canvas has (`onPaint` + // picks `source` when `_effectiveSource` is not loadable) and + // this arm would otherwise lose. Reachable in production + // because of the derivative-cache eviction: + // `artwork_qt::evict_scaled` can unlink a derivative a live or + // later-recycled delegate still names in `_scaled`, and + // `_reqKey` would block the re-request, leaving the cover + // permanently blank. Dropping both re-arms the pipeline. + if (status === Image.Error && root._scaled !== "") { + root._scaled = "" + root._reqKey = "" + } + } + onStatusChanged: _sync() + Component.onCompleted: _sync() + // Rounding: ONE layer + ONE mask, both static for this item's + // life. radius 0 (LabelCard, LabelView, LabelReleasesView) takes + // neither — those three used to pay a full CPU raster to round + // nothing. + layer.enabled: root.radius > 0 + layer.smooth: true + layer.effect: MultiEffect { + maskEnabled: true + maskSource: maskSrc + // ── 0.5 / 1.0, and BOTH numbers were MEASURED, not reasoned. + // Do not "restore the defaults" here. + // + // Qt's mask maths is a smoothstep CENTRED on + // `maskThresholdMin` whose width is `maskSpreadAtMin`, i.e. + // roughly smoothstep(min - spread/2, min + spread/2, maskAlpha). + // So (0.5, 1.0) is the only pair that maps the mask's alpha + // 0..1 monotonically onto 0..1 — the straight alpha multiply + // `ctx.clip()` gave. Grabbed a 200x200 cover under a + // radius-40 mask on this Qt build (6.11.1, OpenGL RHI) and + // counted the ANTIALIASED pixels (0 < alpha < 255): + // + // Canvas arm (the baseline this replaces) 292 fringe px + // maskThresholdMin 0.0 / maskSpreadAtMin 0.0 0 fringe px + // maskThresholdMin 0.5 / maskSpreadAtMin 1.0 308 fringe px + // Qt5Compat OpacityMask (the sanctioned fallback) 308 + // + // and on a FULL CIRCLE (radius 100 on 200px — ArtistCard 95, + // ArtistView 100, HomeView's Spotlight hero 70): + // + // Canvas 752 fringe px | 0.0/0.0 -> 0 | 0.5/1.0 -> 740 + // + // (0.0, 0.0) collapses the smoothstep to `step(0, alpha)`: + // every mask pixel with any alpha at all snaps to fully + // opaque, so the arc comes back HARD and half a pixel fat — + // a visibly stair-stepped circle at the six circle sites. + // (0.0, anything > 0) is worse still: the low edge goes + // negative, every pixel clamps to 1 and the mask is disabled + // outright (measured: 40000/40000 px opaque, square corners). + maskThresholdMin: 0.5 + maskSpreadAtMin: 1.0 + } + } + } + + // ── Root-level source/geometry handlers ───────────────────────────────── + // The canvas arm needs an explicit loadImage + requestPaint from each of + // these; the fast arm needs neither (its `Image` re-binds itself), so they + // funnel through one guarded helper. + + /// No-op on the fast arm, by construction: `canvasArm.item` is null there. + function _repaint() { + if (canvasArm.item) { + canvasArm.item.loadImage(root._effectiveSource) + canvasArm.item.requestPaint() + } + } + // requestPaint() as well as loadImage(): a delegate recycled onto a row // with NO cover assigns source = "", which emits no imageLoaded, so // without an explicit repaint the canvas would keep showing the previous @@ -315,95 +549,121 @@ Canvas { root._srcW = 0 root._srcH = 0 root._reqKey = "" - loadImage(root._effectiveSource) - requestPaint() + // THE ONLY place the fast-arm latch is cleared — see `_imgReady`. + root._imgReady = false + root._repaint() } - on_EffectiveSourceChanged: { loadImage(root._effectiveSource); requestPaint() } - Component.onCompleted: loadImage(root._effectiveSource) - onImageLoaded: { _captureSrc(); requestPaint() } - onWidthChanged: { requestPaint(); _requestScaled() } - onHeightChanged: { requestPaint(); _requestScaled() } + on_EffectiveSourceChanged: root._repaint() + Component.onCompleted: root._repaint() + onWidthChanged: { root._repaint(); root._requestScaled() } + onHeightChanged: { root._repaint(); root._requestScaled() } /// Device pixels per logical pixel for the screen this item is on. Held as /// a property so moving the window between screens of different scales /// re-rasterizes (see the DEVICE PIXELS note above). readonly property real _dpr: Screen.devicePixelRatio > 0 ? Screen.devicePixelRatio : 1 - on_DprChanged: { requestPaint(); _requestScaled() } + on_DprChanged: { root._repaint(); root._requestScaled() } - onPaint: { - // Last of the self-healing capture hooks (see `_captureSrc`): a paint - // means the cell is on screen, so if the size is knowable at all it is - // knowable now. One integer compare once the latch is set. - _captureSrc() - var ctx = getContext("2d") - ctx.reset() - // Everything below is in DEVICE pixels; the context is pre-divided so - // the item still occupies `width` x `height` logical pixels. - var dpr = root._dpr - var w = Math.round(width * dpr) - var h = Math.round(height * dpr) - if (w <= 0 || h <= 0) return - if (dpr !== 1) ctx.scale(1 / dpr, 1 / dpr) - var r = Math.max(0, Math.min(radius * dpr, w / 2, h / 2)) - if (r > 0) { - ctx.beginPath() - ctx.moveTo(r, 0) - ctx.lineTo(w - r, 0) - ctx.arcTo(w, 0, w, r, r) - ctx.lineTo(w, h - r) - ctx.arcTo(w, h, w - r, h, r) - ctx.lineTo(r, h) - ctx.arcTo(0, h, 0, h - r, r) - ctx.lineTo(0, r) - ctx.arcTo(0, 0, r, 0, r) - ctx.closePath() - ctx.clip() - } - // Geometry comes from the ORIGINAL's intrinsic size, not from the - // probe. The derivative preserves the aspect ratio, so `dw`/`dh` are - // identical either way (they depend only on the cell and the ratio) — - // but `_srcW`/`_srcH` do not go stale for the frame in which the probe - // is re-loading after `_scaled` lands, which would otherwise blank - // every card once, mid-rail, the moment its derivative arrived. - var iw = root._srcW > 0 ? root._srcW : probe.sourceSize.width - var ih = root._srcH > 0 ? root._srcH : probe.sourceSize.height - // Prefer the derivative; fall back to the original while it resolves - // (or if it never does) so a cell is never empty for want of it. - var drawSrc = isImageLoaded(root._effectiveSource) ? root._effectiveSource - : (isImageLoaded(root.source) ? root.source : "") - if (iw > 0 && ih > 0 && drawSrc !== "") { - // "crop" = uniform scale covering the rect; "contain"/"pad" = - // fitting inside it. All centered. Once the derivative is in play - // it is already `dw` x `dh`, so the blit is 1:1 — which is the - // entire point of it, since drawImage does not filter when it - // scales. - var scale = root._contains ? Math.min(w / iw, h / ih) : Math.max(w / iw, h / ih) - var dw = iw * scale - var dh = ih * scale - var ox = (w - dw) / 2 - var oy = (h - dh) / 2 - // `drawSrc`, NOT `source`: drawing the original here is what made - // the derivative dead weight and every card a nearest-neighbour - // downscale. - ctx.drawImage(drawSrc, ox, oy, dw, dh) - // The uncovered area, filled from the image itself. AFTER the - // draw on purpose: the sampler reads the pixels that were just - // laid down, so it needs no second decode and cannot disagree - // with what is on screen. - if (root._fit === "pad") - root._paintPad(ctx, dpr, w, h, ox, oy, dw, dh) - // ONLY here: pixels for `source` are now on the canvas. Anything - // earlier (the path arriving, the probe going Ready) would hand - // the cell over to an empty tile. Assigning the same string twice - // is a no-op in QML, so repeated paints cost no notifications. - root._paintedSource = source + Component { + id: canvasComp + Canvas { + id: canvas + anchors.fill: parent + renderTarget: Canvas.Image + // GUI-thread raster. Cooperative/Threaded rasterize on the SCENE + // GRAPH RENDER THREAD, and this item is created and destroyed by + // list recycling while async artwork callbacks schedule repaints — + // the render thread can be mid-draw on a pixmap whose Canvas is + // already gone. Immediate keeps paint and destruction on one + // thread. renderTarget: Image already meant CPU raster, so this + // costs no visual change. + renderStrategy: Canvas.Immediate + + onImageLoaded: { root._captureSrc(); canvas.requestPaint() } + + onPaint: { + // Last of the self-healing capture hooks (see `_captureSrc`): + // a paint means the cell is on screen, so if the size is + // knowable at all it is knowable now. One integer compare once + // the latch is set. + root._captureSrc() + var ctx = canvas.getContext("2d") + ctx.reset() + // Everything below is in DEVICE pixels; the context is + // pre-divided so the item still occupies `width` x `height` + // logical pixels. + var dpr = root._dpr + var w = Math.round(canvas.width * dpr) + var h = Math.round(canvas.height * dpr) + if (w <= 0 || h <= 0) return + if (dpr !== 1) ctx.scale(1 / dpr, 1 / dpr) + var r = Math.max(0, Math.min(root.radius * dpr, w / 2, h / 2)) + if (r > 0) { + ctx.beginPath() + ctx.moveTo(r, 0) + ctx.lineTo(w - r, 0) + ctx.arcTo(w, 0, w, r, r) + ctx.lineTo(w, h - r) + ctx.arcTo(w, h, w - r, h, r) + ctx.lineTo(r, h) + ctx.arcTo(0, h, 0, h - r, r) + ctx.lineTo(0, r) + ctx.arcTo(0, 0, r, 0, r) + ctx.closePath() + ctx.clip() + } + // Geometry comes from the ORIGINAL's intrinsic size, not from + // the probe. The derivative preserves the aspect ratio, so + // `dw`/`dh` are identical either way (they depend only on the + // cell and the ratio) — but `_srcW`/`_srcH` do not go stale + // for the frame in which the probe is re-loading after + // `_scaled` lands, which would otherwise blank every card + // once, mid-rail, the moment its derivative arrived. + var iw = root._srcW > 0 ? root._srcW : probe.sourceSize.width + var ih = root._srcH > 0 ? root._srcH : probe.sourceSize.height + // Prefer the derivative; fall back to the original while it + // resolves (or if it never does) so a cell is never empty for + // want of it. + var drawSrc = canvas.isImageLoaded(root._effectiveSource) ? root._effectiveSource + : (canvas.isImageLoaded(root.source) ? root.source : "") + if (iw > 0 && ih > 0 && drawSrc !== "") { + // "crop" = uniform scale covering the rect; + // "contain"/"pad" = fitting inside it. All centered. Once + // the derivative is in play it is already `dw` x `dh`, so + // the blit is 1:1 — which is the entire point of it, since + // drawImage does not filter when it scales. + var scale = root._contains ? Math.min(w / iw, h / ih) : Math.max(w / iw, h / ih) + var dw = iw * scale + var dh = ih * scale + var ox = (w - dw) / 2 + var oy = (h - dh) / 2 + // `drawSrc`, NOT `source`: drawing the original here is + // what made the derivative dead weight and every card a + // nearest-neighbour downscale. + ctx.drawImage(drawSrc, ox, oy, dw, dh) + // The uncovered area, filled from the image itself. AFTER + // the draw on purpose: the sampler reads the pixels that + // were just laid down, so it needs no second decode and + // cannot disagree with what is on screen. + if (root._fit === "pad") + root._paintPad(ctx, dpr, w, h, ox, oy, dw, dh) + // ONLY here: pixels for `source` are now on the canvas. + // Anything earlier (the path arriving, the probe going + // Ready) would hand the cell over to an empty tile. + // Assigning the same string twice is a no-op in QML, so + // repeated paints cost no notifications. + root._paintedSource = root.source + } + } } } // ── The image-derived padding ───────────────────────────────────────── // Everything below runs in DEVICE pixels, like the rest of `onPaint` — // `getImageData` included (see the header note; this was measured, not - // assumed, and the HTML-spec answer is the wrong one here). + // assumed, and the HTML-spec answer is the wrong one here). It stays on + // ROOT rather than inside the canvas component: the context is passed in, + // and keeping it here is what makes the moved body a pure relocation. /// Average the strip of drawn art next to a seam into `K` buckets along /// its long axis, so the band can carry the image's colour VARIATION and diff --git a/crates/qbz-qt/qml/views/HomeView.qml b/crates/qbz-qt/qml/views/HomeView.qml index 61925599a..770a1aa57 100644 --- a/crates/qbz-qt/qml/views/HomeView.qml +++ b/crates/qbz-qt/qml/views/HomeView.qml @@ -109,6 +109,73 @@ Rectangle { return ({}) } } + // Per-URL cover patches for the two out-of-document For You rails. The + // documents are NOT republished for artwork (src/foryou_qt.rs), because a + // republish hands `model:` a new JS array and `QQuickItemView::setModel()` + // resets the rail's scroll offset AND tears down the QQmlDelegateModel — + // the "a cover batch landing mid-drag snaps the rail back" report, and this + // build's only crash signature. So this map is what fills the tiles in: the + // `coverMap` pattern AlbumView / ArtistView / QueuePanel already use, on a + // signal only this view listens to. + // + // The map is VIEW-LOCAL and this view is NOT durable: AppShell binds + // `viewLoader.source` to QbzShell.currentView, so opening an album destroys + // HomeView and the map with it, while the rails' documents are published + // once per session. That is what `refreshForyouArt()` below is for — on + // every mount Rust re-hands whatever it has already resolved. Without it the + // Radio rail and the Spotlight came back blank for the rest of the session + // and `anyForYouArtPending` never went false again, pinning the pulse Timer. + property var forYouArt: ({}) + Connections { + target: QbzHome + // ONE emit carries the WHOLE landed batch as a `{artUrl: path}` object + // (src/foryou_qt.rs): merge it, then rebind ONCE. `forYouArt` is a + // `var`, so only a new object REFERENCE notifies its dependents — an + // emit per url paid a copy of the growing map plus a sweep over every + // forYouArtOf(...) binding in both rails, per cover. + function onForyouArtReady(patchJson) { + var patch + try { + patch = JSON.parse(patchJson) + } catch (e) { + return + } + var m = root.forYouArt + var changed = false + for (var url in patch) { + if (m[url] !== patch[url]) { + m[url] = patch[url] + changed = true + } + } + if (changed) + root.forYouArt = Object.assign({}, m) // rebind needs a NEW ref + } + } + // Re-hand the resolved covers to this (possibly brand-new) instance. Cheap + // and idempotent: it re-reads the memoized cache paths off the Rust stores, + // never downloads, never republishes a document, and emits nothing at all + // when nothing is resolved yet — so the first, cold mount is a no-op and the + // in-flight download's own emit still fills the map. + Component.onCompleted: QbzHome.refreshForyouArt() + /// The patch map first, then whatever the document happened to carry (a + /// full Home reload publishes the paths it already has). + function forYouArtOf(item) { + return (item && item.artUrl && root.forYouArt[item.artUrl]) + || (item ? (item.artPath || "") : "") + } + /// Same predicate as anyItemArtPending, plus the patch map — the rails no + /// longer get their `artPath` back through a republish, so "artUrl set and + /// artPath empty" is now true forever and would pin the pulse Timer on. + function anyForYouArtPending(items) { + for (var i = 0; i < items.length; i++) { + var it = items[i] + if ((it.artUrl || "") !== "" && root.forYouArtOf(it) === "") + return true + } + return false + } + property string activeTab: "home" // Slint gates the 4th tab on the persisted `show_recommendations` pref @@ -169,8 +236,15 @@ Rectangle { // reason the pinned rows are: without them a Spotlight or Radio tile // waiting for its cover would sit on a FROZEN placeholder, because // the pulse Timer runs only while something is pending. - || root.anyItemArtPending(root.radioStations) - || root.anyItemArtPending(root.spotlight.albums || []) + // + // They use `anyForYouArtPending`, which consults the per-URL patch map + // as well: their `artPath` never comes back through a republish any + // more, so the plain `artPath === ""` probe would be true forever and + // would pin the pulse Timer on. The third term is NEW coverage — the + // Spotlight DOCUMENT itself (the 140px hero portrait) was never probed. + || root.anyForYouArtPending(root.radioStations) + || root.anyForYouArtPending(root.spotlight.albums || []) + || root.anyForYouArtPending(root.spotlight.visible === true ? [root.spotlight] : []) function anyArtPending(model) { for (var s = 0; s < model.length; s++) { if (root.anyItemArtPending(model[s].items || [])) @@ -726,7 +800,9 @@ Rectangle { seedTitle: modelData.title seedSubtitle: modelData.artist label: QbzSession.tr("RADIO", QbzSession.trRev) - artSource: modelData.artPath || "" + // The patch map, not `artPath`: the rail's document is + // published ONCE and covers arrive per-URL. + artSource: root.forYouArtOf(modelData) // Both the card body and the hover disc start the // album radio (the .slint wires `clicked` and `play` // to the same callback). @@ -743,7 +819,7 @@ Rectangle { y: 29 blockRadius: 4 pending: (modelData.artUrl || "") !== "" - coverSource: modelData.artPath || "" + coverSource: root.forYouArtOf(modelData) phase: root.skelPhase cellIndex: index settleMs: 6000 @@ -793,7 +869,15 @@ Rectangle { color: theme.surfaceElevated clip: true QbzIcon { - visible: (spot.doc.artPath || "") === "" + // The REMOTE url, not the local path — 1:1 with + // Spotlight.slint:91-98 (`spotlight-image-url == ""`). + // Gating on artPath flashed this glyph on every cold + // cache, because the path is empty until the download + // lands; the artist HAS a portrait, it just is not here + // yet, and the placeholder for that is the surface fill + // (Slint's hero is an unconditional Image over the + // surface-elevated box, so it simply draws nothing). + visible: (spot.doc.artUrl || "") === "" name: "user" width: 40 height: 40 @@ -802,7 +886,7 @@ Rectangle { } RoundedImage { anchors.fill: parent - source: spot.doc.artPath || "" + source: root.forYouArtOf(spot.doc) radius: 70 } MouseArea { @@ -891,7 +975,7 @@ Rectangle { seedSubtitle: QbzSession.tr("By {}", QbzSession.trRev) .replace("{}", spot.doc.name || "") label: QbzSession.tr("TOP TRACKS", QbzSession.trRev) - artSource: spot.doc.artPath || "" + artSource: root.forYouArtOf(spot.doc) onActivated: QbzHome.playArtistTopTracks(spot.doc.artistId || "") onPlayRequested: QbzHome.playArtistTopTracks(spot.doc.artistId || "") } @@ -902,7 +986,7 @@ Rectangle { seedTitle: spot.doc.name || "" seedSubtitle: QbzSession.tr("Qobuz Radio Station", QbzSession.trRev) label: QbzSession.tr("RADIO", QbzSession.trRev) - artSource: spot.doc.artPath || "" + artSource: root.forYouArtOf(spot.doc) onActivated: QbzHome.startArtistRadio(spot.doc.artistId || "") onPlayRequested: QbzHome.startArtistRadio(spot.doc.artistId || "") } @@ -917,7 +1001,7 @@ Rectangle { genre: modelData.album.genre year: modelData.album.year qualityTier: modelData.album.qualityTier - artSource: modelData.album.artPath || "" + artSource: root.forYouArtOf(modelData.album) artworkUrl: modelData.album.artUrl || "" isPinned: modelData.album.isPinned === true isFavorite: modelData.album.isFavorite === true diff --git a/crates/qbz-qt/src/artwork_qt.rs b/crates/qbz-qt/src/artwork_qt.rs index efe76364f..2116f6045 100644 --- a/crates/qbz-qt/src/artwork_qt.rs +++ b/crates/qbz-qt/src/artwork_qt.rs @@ -44,8 +44,16 @@ //! POC-NOTE: on download completion the WHOLE section list is republished //! (a `homeSectionsJson` swap) — per-row QAbstractListModel updates are //! the documented follow-up. No RGBA decode in Rust: QML `Image` decodes -//! `file://` asynchronously and natively. Cache eviction (`evict`, 200 MB cap -//! in the Slint app) is still not wired here — POC scale. +//! `file://` asynchronously and natively. +//! +//! ## Cache eviction — scaled derivatives only +//! +//! [`housekeeping`] bounds `~/.cache/qbz/images/scaled` (this module's OWN +//! derivative directory) at [`MAX_SCALED_BYTES`], LRU by mtime, and does the +//! one-time `.jpg` orphan sweep the `.png` key change left behind. The PARENT +//! `~/.cache/qbz/images` is the SHARED cache and keeps its own policy +//! (`qbz_cache::ImageCacheService`, SQLite `last_accessed` + the Slint app's +//! 200 MB `evict`) — do NOT add a second one for it from here. use std::collections::{HashMap, HashSet}; use std::path::{Path, PathBuf}; @@ -500,6 +508,15 @@ pub fn scaled_path(path: &str, w: u32, h: u32) -> Option { std::hash::Hasher::finish(&hasher) )); if out.is_file() { + // Mark it used so `evict_scaled`'s mtime order is access order, not + // creation order. Best-effort: a failure only degrades eviction to + // FIFO, which costs one regeneration. (`File::set_modified` is stable + // since Rust 1.75; if `cargo check` disagrees, that is the ONE thing + // to re-check here.) + let _ = std::fs::File::options() + .write(true) + .open(&out) + .and_then(|f| f.set_modified(std::time::SystemTime::now())); return Some(out); } @@ -526,3 +543,107 @@ pub fn scaled_path(path: &str, w: u32, h: u32) -> Option { .ok()?; Some(out) } + +/// Byte ceiling for the scaled-derivative cache. Measured 2026-07-29: a warm +/// cache is 261 files / 13.7 MB at 52.6 KB average, and a fully browsed +/// session projects to ~45 MB. 64 MB leaves headroom for ~1200 derivatives +/// (every card size of ~400 distinct covers) while keeping the directory an +/// order of magnitude under the 260 MB the parent image cache already holds. +const MAX_SCALED_BYTES: u64 = 64 * 1024 * 1024; + +/// Keep `scaled/` bounded: drop the oldest derivatives past +/// [`MAX_SCALED_BYTES`]. Mtime order, oldest first — the same shape as +/// `icon_tint_qt::prune`, with one deliberate difference: `prune` caps on a +/// directory COUNT, this caps on BYTES, because a derivative's size varies 20x +/// with the drawn cell size and a file count says nothing about the footprint. +/// +/// Eviction is free of consequence: a re-request regenerates the file, so a +/// wrong victim costs one `thumbnail()` call and never a wrong pixel. The one +/// case that is NOT free is a live `RoundedImage` whose `_scaled` still names +/// a file this just unlinked — that is handled where it can be, on the QML +/// side, by RoundedImage's `Image.Error` -> clear `_scaled` + `_reqKey` arm. +/// Do not land this without that arm. +/// +/// Returns (files removed, bytes reclaimed) for the log line the verification +/// counts. +fn evict_scaled(dir: &Path) -> (usize, u64) { + let Ok(entries) = std::fs::read_dir(dir) else { + return (0, 0); + }; + let mut files: Vec<(std::time::SystemTime, u64, PathBuf)> = entries + .flatten() + .filter_map(|e| { + let meta = e.metadata().ok()?; + if !meta.is_file() { + return None; + } + Some((meta.modified().ok()?, meta.len(), e.path())) + }) + .collect(); + let total: u64 = files.iter().map(|(_, len, _)| *len).sum(); + if total <= MAX_SCALED_BYTES { + return (0, 0); + } + files.sort_by_key(|(t, _, _)| *t); + let mut over = total - MAX_SCALED_BYTES; + let (mut n, mut freed) = (0usize, 0u64); + for (_, len, path) in files { + if over == 0 { + break; + } + if std::fs::remove_file(&path).is_ok() { + over = over.saturating_sub(len); + freed += len; + n += 1; + } + } + (n, freed) +} + +/// ONE-TIME orphan sweep. `scaled_path` wrote `.jpg` before the lossless +/// change to this module's `{hash}_{w}x{h}.png` key; the extension IS the +/// cache key, so every `.jpg` here is unreachable by construction — 1259 +/// files / 10.05 MB measured 2026-07-29. Nothing can regenerate them and +/// nothing can serve them, so this is a delete, not an eviction policy. +fn sweep_orphan_scaled(dir: &Path) -> (usize, u64) { + let Ok(entries) = std::fs::read_dir(dir) else { + return (0, 0); + }; + let (mut n, mut freed) = (0usize, 0u64); + for entry in entries.flatten() { + let path = entry.path(); + if path.extension().and_then(|e| e.to_str()) != Some("jpg") { + continue; + } + let len = entry.metadata().map(|m| m.len()).unwrap_or(0); + if std::fs::remove_file(&path).is_ok() { + n += 1; + freed += len; + } + } + (n, freed) +} + +/// Boot-time housekeeping for the derivative cache: the one-time `.jpg` +/// orphan sweep, then the byte cap. Blocking (`read_dir` + unlinks) — call +/// from `spawn_blocking`. +/// +/// SCOPE: `images/scaled` ONLY. The parent `~/.cache/qbz/images` (260 MB) is +/// the SHARED cache owned by `qbz_cache::ImageCacheService` (SQLite +/// `last_accessed` + the Slint app's 200 MB `evict`); two processes evicting +/// the same directory on different policies is exactly what the memo at the +/// top of this file had to be written about. +pub fn housekeeping() { + let Some(dir) = dirs::cache_dir().map(|d| d.join("qbz").join("images").join("scaled")) else { + return; + }; + if !dir.is_dir() { + return; + } + let (orphans, o_bytes) = sweep_orphan_scaled(&dir); + let (evicted, e_bytes) = evict_scaled(&dir); + log::info!( + "[qbz-qt][perf] scaled cache housekeeping: {orphans} orphans ({o_bytes} B), \ + {evicted} evicted ({e_bytes} B)" + ); +} diff --git a/crates/qbz-qt/src/foryou_qt.rs b/crates/qbz-qt/src/foryou_qt.rs index b66bbf3ed..3139c7dfd 100644 --- a/crates/qbz-qt/src/foryou_qt.rs +++ b/crates/qbz-qt/src/foryou_qt.rs @@ -56,7 +56,7 @@ //! / LabelView precedent), and "Add to playlist" (`list-plus`) is likewise //! inert, exactly as in the Slint header where it carries no `clicked`. -use std::collections::HashSet; +use std::collections::{BTreeMap, HashSet}; use std::sync::{Arc, Mutex}; use cxx_qt_lib::QString; @@ -130,8 +130,103 @@ pub(crate) fn build_radio( out } -/// Store + publish the radio rail, then fetch the covers it is missing and -/// republish (the `label_qt` two-pass artwork shape). +/// The landed covers of ONE batch as a `{artUrl: file://path}` patch, instead +/// of a second full document publish. +/// +/// Two separate decisions live here. +/// +/// (a) PER URL, NOT PER DOCUMENT. The two-pass `label_qt` shape republished the +/// whole JSON when the covers arrived, which hands QML's `model:` a NEW JS +/// array; `QQuickItemView::setModel()` then resets `contentX` to 0 and tears +/// down the QQmlDelegateModel (this build's only crash signature — `main.rs` +/// documents it for the Library feed, and `LibraryView.qml` documents the cure: +/// patch in place, never re-emit the document). +/// +/// (b) ONE EMIT PER BATCH, NOT ONE PER URL. `HomeView.qml`'s `forYouArt` is a +/// `var` property, so only a NEW object reference notifies its dependents: an +/// emit per url paid one copy of the (growing) map AND one invalidation sweep +/// over every `forYouArtOf(...)` binding in both rails plus the `artPending` +/// probe — quadratic in the ~38 covers a cold For You load resolves, for 38 Qt +/// thread hops. Nothing is shown any later for it: `download_missing().await` +/// settles the WHOLE batch before the first path can be read back. +/// +/// The emit shape is the port's existing signal helper (`emit_library_favorite` +/// in main.rs): a cxx-qt signal is emitted by calling the declared Rust method +/// on `Pin<&mut T>`. +fn emit_foryou_art(patch: BTreeMap) { + if patch.is_empty() { + return; + } + let json = patch_json(&patch); + crate::home_bridge::ui(move |mut b| { + b.as_mut().foryou_art_ready(QString::from(json.as_str())); + }); +} + +fn patch_json(patch: &BTreeMap) -> String { + serde_json::to_string(patch).unwrap_or_else(|_| "{}".to_string()) +} + +/// Collect one resolved cover into the patch (blanks are not patches). +fn collect_resolved(patch: &mut BTreeMap, url: &str, path: &str) { + if !url.is_empty() && !path.is_empty() { + patch.insert(url.to_string(), path.to_string()); + } +} + +/// Every For You cover the stores have ALREADY resolved, as the same +/// `{artUrl: file://path}` patch [`emit_foryou_art`] sends — or "" when there +/// is nothing to hand over yet. +/// +/// WHY THIS EXISTS. `AppShell.qml` binds `viewLoader.source` to +/// `QbzShell.currentView`, so HomeView is DESTROYED on every navigation and its +/// per-url `forYouArt` map dies with it, while the rails' documents are +/// deliberately never republished for artwork (see [`emit_foryou_art`]) and +/// `publish_radio` / `publish_spotlight` run ONCE per session (`home_qt:: +/// load_home` <- `reload_home` <- the boot chain in main.rs; otherwise only the +/// error-retry button and a language change). Without a re-hand on mount the +/// Radio rail and the Spotlight hero + album tiles come back BLANK for the rest +/// of the session — and `anyForYouArtPending` stays true forever, which pins +/// HomeView's 900 ms skeleton pulse Timer permanently ON, i.e. it also defeats +/// the perf fix this transport exists for. +/// +/// It re-resolves the cache paths into the stores first, so a cover that landed +/// while Home was unmounted (that emit reached nobody) — or that some other +/// view downloaded — is picked up. `cached_path` is memoized, so the second and +/// later calls touch neither SQLite nor the disk. +/// +/// It NEVER downloads and NEVER republishes a document, and it returns "" when +/// nothing is resolved, so the FIRST (cold) mount emits nothing at all and +/// cannot clear a map the in-flight download is about to fill. +pub(crate) fn resolved_art_patch() -> String { + let mut patch: BTreeMap = BTreeMap::new(); + if let Ok(mut store) = RADIO.lock() { + let _ = attach_radio_art(&mut store); + for row in store.iter() { + collect_resolved(&mut patch, &row.art_url, &row.art_path); + } + } + if let Ok(mut store) = SPOTLIGHT.lock() { + if let Some(doc) = store.as_mut() { + let _ = attach_spotlight_art(doc); + collect_resolved(&mut patch, &doc.art_url, &doc.art_path); + for card in doc.albums.iter() { + collect_resolved(&mut patch, &card.art_url, &card.art_path); + } + } + } + if patch.is_empty() { + return String::new(); + } + log::debug!( + "[qbz-qt] for-you art re-handed to a fresh HomeView: {} covers", + patch.len() + ); + patch_json(&patch) +} + +/// Store + publish the radio rail ONCE, then fetch the covers it is missing +/// and hand them to the rail as a url-keyed patch (see [`emit_foryou_art`]). pub(crate) fn publish_radio(rows: Vec) { let missing = { let Ok(mut store) = RADIO.lock() else { @@ -145,11 +240,22 @@ pub(crate) fn publish_radio(rows: Vec) { return; } crate::spawn(async move { - crate::artwork_qt::download_missing(missing).await; + crate::artwork_qt::download_missing(missing.clone()).await; + // In place: the rows keep the art they already have and the landed + // covers reach the rail as one url-keyed patch. The store is still + // updated so a LATER full publish (a new Home load) carries the + // paths — and so `resolved_art_patch` can re-hand them to a HomeView + // that was destroyed and re-created meanwhile — but nothing + // republishes for artwork alone. if let Ok(mut store) = RADIO.lock() { let _ = attach_radio_art(&mut store); } - push_radio(); + let mut patch: BTreeMap = BTreeMap::new(); + for url in missing { + let path = crate::artwork_qt::cached_path(&url); + collect_resolved(&mut patch, &url, &path); + } + emit_foryou_art(patch); }); } @@ -321,13 +427,21 @@ fn publish_spotlight(doc: Option) { return; } crate::spawn(async move { - crate::artwork_qt::download_missing(missing).await; + crate::artwork_qt::download_missing(missing.clone()).await; + // Same in-place patch as the radio rail: the hero portrait and every + // album tile arrive inside ONE `foryouArtReady`, so the Spotlight + // document is never republished for artwork alone. if let Ok(mut store) = SPOTLIGHT.lock() { if let Some(doc) = store.as_mut() { let _ = attach_spotlight_art(doc); } } - push_spotlight(); + let mut patch: BTreeMap = BTreeMap::new(); + for url in missing { + let path = crate::artwork_qt::cached_path(&url); + collect_resolved(&mut patch, &url, &path); + } + emit_foryou_art(patch); }); } diff --git a/crates/qbz-qt/src/home_bridge.rs b/crates/qbz-qt/src/home_bridge.rs index 20bf240a5..d39ef81d9 100644 --- a/crates/qbz-qt/src/home_bridge.rs +++ b/crates/qbz-qt/src/home_bridge.rs @@ -192,6 +192,20 @@ pub mod qbz_home { /// Spotlight play / TOP TRACKS card — the artist's popular tracks. #[qinvokable] fn play_artist_top_tracks(self: Pin<&mut QbzHome>, artist_id: QString); + /// HomeView MOUNTED: re-hand it every For You cover already resolved. + /// + /// `AppShell.qml` binds `viewLoader.source` to `QbzShell.currentView`, + /// so HomeView is DESTROYED on every navigation and the per-url cover + /// map it keeps (`forYouArt`) dies with it — while the two rails' + /// documents are published ONCE per session and deliberately never + /// republished for artwork (see `foryouArtReady`). Without this the + /// Radio rail and the Spotlight hero + tiles are blank for the rest of + /// the session after the first navigation away, and the view's + /// "something is still pending" probe never goes false, which pins its + /// 900 ms skeleton pulse Timer on. Idempotent, emits nothing when the + /// stores hold no resolved path yet (`foryou_qt::resolved_art_patch`). + #[qinvokable] + fn refresh_foryou_art(self: Pin<&mut QbzHome>); // --- Label releases sub-view -------------------------------------- #[qinvokable] @@ -204,6 +218,28 @@ pub mod qbz_home { fn label_releases_set_group(self: Pin<&mut QbzHome>, on: bool); #[qinvokable] fn label_releases_search(self: Pin<&mut QbzHome>, query: QString); + + /// A batch of For You covers landed: a JSON `{ "": "" }` patch. Per-URL, so the rails patch the TILES instead of + /// taking a new `radioStationsJson` / `spotlightJson` document — a + /// republished document hands `model:` a new JS array and + /// `QQuickItemView::setModel()` resets the rail's scroll offset AND + /// tears down the QQmlDelegateModel, which is this build's only crash + /// signature (a null read at libQt6QmlModels[420c5], reproduced live). + /// Deliberately NOT `QbzLibrary.libraryArtworkReady`: six views listen + /// to that one and each rebuilds its whole cover map per emit. + /// + /// ONE emit per batch, not one per url: the receiving map is a QML + /// `var`, so it only notifies on a NEW object reference — an emit per + /// url copied the growing map and invalidated every cover binding in + /// both rails once per cover (quadratic in the ~38 a cold load + /// resolves) for no earlier paint, since the whole batch settles inside + /// one `download_missing().await`. See `foryou_qt::emit_foryou_art`. + /// + /// `#[auto_cxx_name]` on this block makes the QML spelling + /// `foryouArtReady`, i.e. the handler is `onForyouArtReady(patchJson)`. + #[qsignal] + fn foryou_art_ready(self: Pin<&mut QbzHome>, patch_json: QString); } impl cxx_qt::Threading for QbzHome {} @@ -434,6 +470,19 @@ impl qbz_home::QbzHome { crate::foryou_qt::spotlight_play_top_tracks(artist_id.to_string()); } + /// Emitted SYNCHRONOUSLY here rather than through `home_bridge::ui`: this + /// runs on the Qt thread already (HomeView's `Component.onCompleted`), and + /// the `ui()` hop silently no-ops until `boot()` has registered the thread, + /// which is exactly the failure mode this fix exists to remove. + pub fn refresh_foryou_art(mut self: Pin<&mut Self>) { + let patch = crate::foryou_qt::resolved_art_patch(); + if patch.is_empty() { + return; + } + self.as_mut() + .foryou_art_ready(QString::from(patch.as_str())); + } + // --- Label releases ---------------------------------------------------- pub fn label_releases_load_more(self: Pin<&mut Self>) {