mirror of
https://github.com/noctalia-dev/noctalia-shell.git
synced 2026-05-11 17:08:27 +08:00
Wallpaper: resizing and caching with imagemagick for max quality and lower memory usage.
This commit is contained in:
@@ -67,10 +67,8 @@ Variants {
|
||||
target: WallpaperService
|
||||
function onWallpaperChanged(screenName, path) {
|
||||
if (screenName === modelData.name) {
|
||||
// Update wallpaper display
|
||||
// Set wallpaper immediately on startup
|
||||
futureWallpaper = path;
|
||||
debounceTimer.restart();
|
||||
// Request preprocessed wallpaper from cache service
|
||||
requestPreprocessedWallpaper(path);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -78,11 +76,14 @@ Variants {
|
||||
Connections {
|
||||
target: CompositorService
|
||||
function onDisplayScalesChanged() {
|
||||
// Recalculate image sizes without interrupting startup transition
|
||||
// Re-request preprocessed wallpaper at new dimensions
|
||||
if (isStartupTransition) {
|
||||
return;
|
||||
}
|
||||
recalculateImageSizes();
|
||||
const currentPath = WallpaperService.getWallpaper(modelData.name);
|
||||
if (currentPath) {
|
||||
requestPreprocessedWallpaper(currentPath);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -112,36 +113,22 @@ Variants {
|
||||
Image {
|
||||
id: currentWallpaper
|
||||
|
||||
property bool dimensionsCalculated: false
|
||||
|
||||
source: ""
|
||||
smooth: true
|
||||
mipmap: false
|
||||
visible: false
|
||||
cache: false
|
||||
asynchronous: true
|
||||
sourceSize: undefined
|
||||
onStatusChanged: {
|
||||
if (status === Image.Error) {
|
||||
Logger.w("Current wallpaper failed to load:", source);
|
||||
} else if (status === Image.Ready && !dimensionsCalculated) {
|
||||
dimensionsCalculated = true;
|
||||
const optimalSize = calculateOptimalWallpaperSize(implicitWidth, implicitHeight);
|
||||
if (optimalSize !== false) {
|
||||
sourceSize = optimalSize;
|
||||
}
|
||||
}
|
||||
}
|
||||
onSourceChanged: {
|
||||
dimensionsCalculated = false;
|
||||
sourceSize = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
Image {
|
||||
id: nextWallpaper
|
||||
|
||||
property bool dimensionsCalculated: false
|
||||
property bool pendingTransition: false
|
||||
|
||||
source: ""
|
||||
@@ -150,19 +137,11 @@ Variants {
|
||||
visible: false
|
||||
cache: false
|
||||
asynchronous: true
|
||||
sourceSize: undefined
|
||||
onStatusChanged: {
|
||||
if (status === Image.Error) {
|
||||
Logger.w("Next wallpaper failed to load:", source);
|
||||
pendingTransition = false;
|
||||
} else if (status === Image.Ready) {
|
||||
if (!dimensionsCalculated) {
|
||||
dimensionsCalculated = true;
|
||||
const optimalSize = calculateOptimalWallpaperSize(implicitWidth, implicitHeight);
|
||||
if (optimalSize !== false) {
|
||||
sourceSize = optimalSize;
|
||||
}
|
||||
}
|
||||
if (pendingTransition) {
|
||||
pendingTransition = false;
|
||||
currentWallpaper.asynchronous = false;
|
||||
@@ -170,10 +149,6 @@ Variants {
|
||||
}
|
||||
}
|
||||
}
|
||||
onSourceChanged: {
|
||||
dimensionsCalculated = false;
|
||||
sourceSize = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
// Dynamic shader loader - only loads the active transition shader
|
||||
@@ -321,10 +296,9 @@ Variants {
|
||||
transitionProgress = 0.0;
|
||||
|
||||
// Now clear nextWallpaper after currentWallpaper has the new source
|
||||
// Force complete cleanup to free texture memory (~18-25MB per monitor)
|
||||
// Force complete cleanup to free texture memory
|
||||
Qt.callLater(() => {
|
||||
nextWallpaper.source = "";
|
||||
nextWallpaper.sourceSize = undefined;
|
||||
Qt.callLater(() => {
|
||||
currentWallpaper.asynchronous = true;
|
||||
});
|
||||
@@ -333,63 +307,47 @@ Variants {
|
||||
}
|
||||
|
||||
// ------------------------------------------------------
|
||||
function calculateOptimalWallpaperSize(wpWidth, wpHeight) {
|
||||
const compositorScale = CompositorService.getDisplayScale(modelData.name);
|
||||
const screenWidth = modelData.width * compositorScale;
|
||||
const screenHeight = modelData.height * compositorScale;
|
||||
if (wpWidth <= screenWidth || wpHeight <= screenHeight || wpWidth <= 0 || wpHeight <= 0) {
|
||||
// Do not resize if wallpaper is smaller than one of the screen dimension
|
||||
function setWallpaperInitial() {
|
||||
// On startup, defer assigning wallpaper until the services are ready
|
||||
if (!WallpaperService || !WallpaperService.isInitialized) {
|
||||
Qt.callLater(setWallpaperInitial);
|
||||
return;
|
||||
}
|
||||
|
||||
const imageAspectRatio = wpWidth / wpHeight;
|
||||
var dim = Qt.size(0, 0);
|
||||
if (screenWidth >= screenHeight) {
|
||||
const w = Math.min(screenWidth, wpWidth);
|
||||
dim = Qt.size(Math.round(w), Math.round(w / imageAspectRatio));
|
||||
} else {
|
||||
const h = Math.min(screenHeight, wpHeight);
|
||||
dim = Qt.size(Math.round(h * imageAspectRatio), Math.round(h));
|
||||
}
|
||||
|
||||
Logger.d("Background", `Wallpaper resized on ${modelData.name} ${screenWidth}x${screenHeight} @ ${compositorScale}x`, "src:", wpWidth, wpHeight, "dst:", dim.width, dim.height);
|
||||
return dim;
|
||||
}
|
||||
|
||||
// ------------------------------------------------------
|
||||
function recalculateImageSizes() {
|
||||
// Re-evaluate and apply optimal sourceSize for both images when ready
|
||||
if (currentWallpaper.status === Image.Ready) {
|
||||
const optimal = calculateOptimalWallpaperSize(currentWallpaper.implicitWidth, currentWallpaper.implicitHeight);
|
||||
if (optimal !== undefined && optimal !== false) {
|
||||
currentWallpaper.sourceSize = optimal;
|
||||
} else {
|
||||
currentWallpaper.sourceSize = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
if (nextWallpaper.status === Image.Ready) {
|
||||
const optimal2 = calculateOptimalWallpaperSize(nextWallpaper.implicitWidth, nextWallpaper.implicitHeight);
|
||||
if (optimal2 !== undefined && optimal2 !== false) {
|
||||
nextWallpaper.sourceSize = optimal2;
|
||||
} else {
|
||||
nextWallpaper.sourceSize = undefined;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ------------------------------------------------------
|
||||
function setWallpaperInitial() {
|
||||
// On startup, defer assigning wallpaper until the service cache is ready, retries every tick
|
||||
if (!WallpaperService || !WallpaperService.isInitialized) {
|
||||
if (!WallpaperCacheService || !WallpaperCacheService.initialized) {
|
||||
Qt.callLater(setWallpaperInitial);
|
||||
return;
|
||||
}
|
||||
|
||||
const wallpaperPath = WallpaperService.getWallpaper(modelData.name);
|
||||
const compositorScale = CompositorService.getDisplayScale(modelData.name);
|
||||
const targetWidth = Math.round(modelData.width * compositorScale);
|
||||
const targetHeight = Math.round(modelData.height * compositorScale);
|
||||
|
||||
futureWallpaper = wallpaperPath;
|
||||
performStartupTransition();
|
||||
WallpaperCacheService.getPreprocessed(wallpaperPath, modelData.name, targetWidth, targetHeight, function (cachedPath, success) {
|
||||
if (success) {
|
||||
futureWallpaper = cachedPath;
|
||||
} else {
|
||||
// Fallback to original
|
||||
futureWallpaper = wallpaperPath;
|
||||
}
|
||||
performStartupTransition();
|
||||
});
|
||||
}
|
||||
|
||||
// ------------------------------------------------------
|
||||
function requestPreprocessedWallpaper(originalPath) {
|
||||
const compositorScale = CompositorService.getDisplayScale(modelData.name);
|
||||
const targetWidth = Math.round(modelData.width * compositorScale);
|
||||
const targetHeight = Math.round(modelData.height * compositorScale);
|
||||
|
||||
WallpaperCacheService.getPreprocessed(originalPath, modelData.name, targetWidth, targetHeight, function (cachedPath, success) {
|
||||
if (success) {
|
||||
futureWallpaper = cachedPath;
|
||||
} else {
|
||||
futureWallpaper = originalPath;
|
||||
}
|
||||
debounceTimer.restart();
|
||||
});
|
||||
}
|
||||
|
||||
// ------------------------------------------------------
|
||||
|
||||
@@ -0,0 +1,333 @@
|
||||
pragma Singleton
|
||||
|
||||
import QtQuick
|
||||
import Quickshell
|
||||
import Quickshell.Io
|
||||
import "../../Helpers/sha256.js" as Checksum
|
||||
import qs.Commons
|
||||
|
||||
Singleton {
|
||||
id: root
|
||||
|
||||
readonly property string cacheDir: Settings.cacheDirImagesWallpapers + "preprocessed/"
|
||||
property bool imageMagickAvailable: false
|
||||
property bool initialized: false
|
||||
|
||||
// Track pending preprocessing operations
|
||||
// key: cacheKey, value: { callbacks: [], sourcePath: string, screenName: string }
|
||||
property var pendingRequests: ({})
|
||||
|
||||
// Signals
|
||||
signal preprocessComplete(string cacheKey, string cachedPath, string screenName)
|
||||
signal preprocessFailed(string cacheKey, string error, string screenName)
|
||||
|
||||
// -------------------------------------------------
|
||||
function init() {
|
||||
Logger.i("WallpaperCache", "Service started");
|
||||
Quickshell.execDetached(["mkdir", "-p", cacheDir]);
|
||||
checkMagickProcess.running = true;
|
||||
}
|
||||
|
||||
// -------------------------------------------------
|
||||
// Main API: Request preprocessed wallpaper
|
||||
// callback signature: function(cachedPath: string, success: bool)
|
||||
function getPreprocessed(sourcePath, screenName, width, height, callback) {
|
||||
if (!sourcePath || sourcePath === "") {
|
||||
callback("", false);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!imageMagickAvailable) {
|
||||
// Fallback: return original path
|
||||
Logger.d("WallpaperCache", "ImageMagick not available, using original:", sourcePath);
|
||||
callback(sourcePath, false);
|
||||
return;
|
||||
}
|
||||
|
||||
// First check image dimensions - skip preprocessing if image is smaller than screen
|
||||
getImageDimensions(sourcePath, function (imgWidth, imgHeight) {
|
||||
if (imgWidth > 0 && imgHeight > 0 && imgWidth <= width && imgHeight <= height) {
|
||||
// Image is smaller than or equal to screen - no preprocessing needed
|
||||
Logger.d("WallpaperCache", `Image ${imgWidth}x${imgHeight} <= screen ${width}x${height}, using original`);
|
||||
callback(sourcePath, false);
|
||||
return;
|
||||
}
|
||||
|
||||
// Image is larger - proceed with preprocessing
|
||||
proceedWithPreprocessing(sourcePath, screenName, width, height, callback);
|
||||
});
|
||||
}
|
||||
|
||||
// -------------------------------------------------
|
||||
function proceedWithPreprocessing(sourcePath, screenName, width, height, callback) {
|
||||
// Get mtime for cache invalidation
|
||||
getMtime(sourcePath, function (mtime) {
|
||||
const cacheKey = generateCacheKey(sourcePath, width, height, mtime);
|
||||
const cachedPath = cacheDir + cacheKey + ".jpg";
|
||||
|
||||
// Check if already processing this exact request
|
||||
if (pendingRequests[cacheKey]) {
|
||||
pendingRequests[cacheKey].callbacks.push({
|
||||
callback: callback,
|
||||
screenName: screenName
|
||||
});
|
||||
Logger.d("WallpaperCache", "Coalescing request for:", cacheKey);
|
||||
return;
|
||||
}
|
||||
|
||||
// Check cache first
|
||||
checkFileExists(cachedPath, function (exists) {
|
||||
if (exists) {
|
||||
Logger.d("WallpaperCache", "Cache hit:", cachedPath);
|
||||
callback(cachedPath, true);
|
||||
return;
|
||||
}
|
||||
|
||||
// Re-check pendingRequests in case another request started processing
|
||||
// while we were checking file existence (race condition fix)
|
||||
if (pendingRequests[cacheKey]) {
|
||||
pendingRequests[cacheKey].callbacks.push({
|
||||
callback: callback,
|
||||
screenName: screenName
|
||||
});
|
||||
Logger.d("WallpaperCache", "Coalescing request (late):", cacheKey);
|
||||
return;
|
||||
}
|
||||
|
||||
// Start new processing
|
||||
Logger.d("WallpaperCache", `Preprocessing ${sourcePath} to ${width}x${height}`);
|
||||
pendingRequests[cacheKey] = {
|
||||
callbacks: [
|
||||
{
|
||||
callback: callback,
|
||||
screenName: screenName
|
||||
}
|
||||
],
|
||||
sourcePath: sourcePath
|
||||
};
|
||||
|
||||
startPreprocessing(sourcePath, cachedPath, width, height, cacheKey, screenName);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// -------------------------------------------------
|
||||
function generateCacheKey(sourcePath, width, height, mtime) {
|
||||
const keyString = sourcePath + "@" + width + "x" + height + "@" + (mtime || "unknown");
|
||||
return Checksum.sha256(keyString);
|
||||
}
|
||||
|
||||
// -------------------------------------------------
|
||||
function buildCommand(sourcePath, outputPath, width, height) {
|
||||
// Escape paths for shell
|
||||
const srcEsc = sourcePath.replace(/'/g, "'\\''");
|
||||
const dstEsc = outputPath.replace(/'/g, "'\\''");
|
||||
|
||||
// Resize to cover screen dimensions, preserve aspect ratio
|
||||
// The ^ flag ensures the image covers the target (smaller dimension fits exactly)
|
||||
// The > flag ensures we only shrink, never enlarge (prevents blurry upscaling)
|
||||
// The shader will handle actual fill mode (crop/fit/center/stretch)
|
||||
return `convert '${srcEsc}' -resize '${width}x${height}^>' -quality 95 '${dstEsc}'`;
|
||||
}
|
||||
|
||||
// -------------------------------------------------
|
||||
function startPreprocessing(sourcePath, outputPath, width, height, cacheKey, screenName) {
|
||||
const command = buildCommand(sourcePath, outputPath, width, height);
|
||||
|
||||
// Create dynamic process for this request
|
||||
const processString = `
|
||||
import QtQuick
|
||||
import Quickshell.Io
|
||||
Process {
|
||||
property string cacheKey: ""
|
||||
property string cachedPath: ""
|
||||
property string screenName: ""
|
||||
command: ["bash", "-c", ""]
|
||||
stdout: StdioCollector {}
|
||||
stderr: StdioCollector {}
|
||||
}
|
||||
`;
|
||||
|
||||
try {
|
||||
const processObj = Qt.createQmlObject(processString, root, "PreprocessProcess_" + cacheKey);
|
||||
processObj.cacheKey = cacheKey;
|
||||
processObj.cachedPath = outputPath;
|
||||
processObj.screenName = screenName;
|
||||
processObj.command = ["bash", "-c", command];
|
||||
|
||||
processObj.exited.connect(function (exitCode) {
|
||||
handleProcessComplete(processObj, exitCode);
|
||||
});
|
||||
|
||||
processObj.running = true;
|
||||
} catch (e) {
|
||||
Logger.e("WallpaperCache", "Failed to create process:", e);
|
||||
notifyCallbacks(cacheKey, sourcePath, false);
|
||||
}
|
||||
}
|
||||
|
||||
// -------------------------------------------------
|
||||
function handleProcessComplete(processObj, exitCode) {
|
||||
const cacheKey = processObj.cacheKey;
|
||||
const cachedPath = processObj.cachedPath;
|
||||
const screenName = processObj.screenName;
|
||||
const stderrText = processObj.stderr.text || "";
|
||||
|
||||
if (exitCode !== 0) {
|
||||
Logger.e("WallpaperCache", "Preprocessing failed:", stderrText);
|
||||
const sourcePath = pendingRequests[cacheKey] ? pendingRequests[cacheKey].sourcePath : "";
|
||||
notifyCallbacks(cacheKey, sourcePath, false);
|
||||
preprocessFailed(cacheKey, stderrText, screenName);
|
||||
} else {
|
||||
Logger.d("WallpaperCache", "Preprocessing complete:", cachedPath);
|
||||
notifyCallbacks(cacheKey, cachedPath, true);
|
||||
preprocessComplete(cacheKey, cachedPath, screenName);
|
||||
}
|
||||
|
||||
// Clean up
|
||||
processObj.destroy();
|
||||
}
|
||||
|
||||
// -------------------------------------------------
|
||||
function notifyCallbacks(cacheKey, path, success) {
|
||||
const request = pendingRequests[cacheKey];
|
||||
if (request) {
|
||||
request.callbacks.forEach(function (item) {
|
||||
item.callback(path, success);
|
||||
});
|
||||
delete pendingRequests[cacheKey];
|
||||
}
|
||||
}
|
||||
|
||||
// -------------------------------------------------
|
||||
function getMtime(filePath, callback) {
|
||||
const pathEsc = filePath.replace(/'/g, "'\\''");
|
||||
const processString = `
|
||||
import QtQuick
|
||||
import Quickshell.Io
|
||||
Process {
|
||||
command: ["stat", "-c", "%Y", "${pathEsc}"]
|
||||
stdout: StdioCollector {}
|
||||
stderr: StdioCollector {}
|
||||
}
|
||||
`;
|
||||
|
||||
try {
|
||||
const processObj = Qt.createQmlObject(processString, root, "MtimeProcess");
|
||||
|
||||
processObj.exited.connect(function (exitCode) {
|
||||
const mtime = exitCode === 0 ? processObj.stdout.text.trim() : "";
|
||||
processObj.destroy();
|
||||
callback(mtime);
|
||||
});
|
||||
|
||||
processObj.running = true;
|
||||
} catch (e) {
|
||||
Logger.e("WallpaperCache", "Failed to get mtime:", e);
|
||||
callback("");
|
||||
}
|
||||
}
|
||||
|
||||
// -------------------------------------------------
|
||||
function checkFileExists(filePath, callback) {
|
||||
const pathEsc = filePath.replace(/'/g, "'\\''");
|
||||
const processString = `
|
||||
import QtQuick
|
||||
import Quickshell.Io
|
||||
Process {
|
||||
command: ["test", "-f", "${pathEsc}"]
|
||||
stdout: StdioCollector {}
|
||||
stderr: StdioCollector {}
|
||||
}
|
||||
`;
|
||||
|
||||
try {
|
||||
const processObj = Qt.createQmlObject(processString, root, "FileExistsProcess");
|
||||
|
||||
processObj.exited.connect(function (exitCode) {
|
||||
processObj.destroy();
|
||||
callback(exitCode === 0);
|
||||
});
|
||||
|
||||
processObj.running = true;
|
||||
} catch (e) {
|
||||
Logger.e("WallpaperCache", "Failed to check file:", e);
|
||||
callback(false);
|
||||
}
|
||||
}
|
||||
|
||||
// -------------------------------------------------
|
||||
// Get image dimensions using ImageMagick identify
|
||||
function getImageDimensions(filePath, callback) {
|
||||
const pathEsc = filePath.replace(/'/g, "'\\''");
|
||||
const processString = `
|
||||
import QtQuick
|
||||
import Quickshell.Io
|
||||
Process {
|
||||
command: ["identify", "-format", "%w %h", "${pathEsc}[0]"]
|
||||
stdout: StdioCollector {}
|
||||
stderr: StdioCollector {}
|
||||
}
|
||||
`;
|
||||
|
||||
try {
|
||||
const processObj = Qt.createQmlObject(processString, root, "IdentifyProcess");
|
||||
|
||||
processObj.exited.connect(function (exitCode) {
|
||||
let width = 0, height = 0;
|
||||
if (exitCode === 0) {
|
||||
const parts = processObj.stdout.text.trim().split(" ");
|
||||
if (parts.length >= 2) {
|
||||
width = parseInt(parts[0], 10) || 0;
|
||||
height = parseInt(parts[1], 10) || 0;
|
||||
}
|
||||
}
|
||||
processObj.destroy();
|
||||
callback(width, height);
|
||||
});
|
||||
|
||||
processObj.running = true;
|
||||
} catch (e) {
|
||||
Logger.e("WallpaperCache", "Failed to get image dimensions:", e);
|
||||
callback(0, 0);
|
||||
}
|
||||
}
|
||||
|
||||
// -------------------------------------------------
|
||||
// Utility: Clear cache for a specific source
|
||||
function invalidateForSource(sourcePath) {
|
||||
// Since cache keys include the source path hash, we'd need to track mappings
|
||||
// For simplicity, this clears the entire cache
|
||||
Logger.i("WallpaperCache", "Invalidating cache for:", sourcePath);
|
||||
clearAllCache();
|
||||
}
|
||||
|
||||
// -------------------------------------------------
|
||||
// Utility: Clear entire cache
|
||||
function clearAllCache() {
|
||||
Logger.i("WallpaperCache", "Clearing all cache");
|
||||
Quickshell.execDetached(["rm", "-rf", cacheDir]);
|
||||
Quickshell.execDetached(["mkdir", "-p", cacheDir]);
|
||||
}
|
||||
|
||||
// -------------------------------------------------
|
||||
// Check if ImageMagick is available
|
||||
Process {
|
||||
id: checkMagickProcess
|
||||
command: ["which", "convert"]
|
||||
running: false
|
||||
|
||||
stdout: StdioCollector {}
|
||||
stderr: StdioCollector {}
|
||||
|
||||
onExited: function (exitCode) {
|
||||
root.imageMagickAvailable = (exitCode === 0);
|
||||
root.initialized = true;
|
||||
if (root.imageMagickAvailable) {
|
||||
Logger.i("WallpaperCache", "ImageMagick available");
|
||||
} else {
|
||||
Logger.w("WallpaperCache", "ImageMagick not found, caching disabled");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user