managarten/packages/wallpaper-generator/src/renderers/node.ts
Till-JS e5109da732 feat(wallpaper-generator): add device wallpaper generation package
Add new @manacore/wallpaper-generator package for creating device
wallpapers from QR codes and images.

Features:
- 30 device presets (phones, tablets, desktops)
- 3 layout types (center, corner, pattern)
- 5 gradient presets + solid color backgrounds
- Browser (Canvas) and Node.js (Sharp) renderers
- Svelte WallpaperModal UI component

Integrations:
- @manacore/qr-export: toWallpaper() function
- @manacore/spiral-db: toWallpaper() function
- QRExportModal: "Als Wallpaper" button

Also includes the full @manacore/qr-export package which was
previously untracked.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-17 12:57:43 +01:00

397 lines
10 KiB
TypeScript

/**
* Node.js Renderer
*
* Sharp-based wallpaper generation for Node.js environments.
*/
import type {
ImageSource,
WallpaperOptions,
WallpaperResult,
WallpaperGenerator,
DevicePreset,
DeviceCategory,
Layout,
Background,
CenterLayout,
CornerLayout,
PatternLayout,
} from '../types.js';
import { ALL_DEVICE_PRESETS, getDevicePreset, getPresetsByCategory } from '../presets/index.js';
import { createSolidBuffer } from '../backgrounds/solid.js';
import { createGradientBuffer } from '../backgrounds/gradient.js';
import { calculateCenterPosition } from '../layouts/center.js';
import { calculateCornerPosition } from '../layouts/corner.js';
import { calculatePatternTiles } from '../layouts/pattern.js';
// =============================================================================
// TYPES
// =============================================================================
interface SharpInstance {
metadata(): Promise<{ width?: number; height?: number }>;
resize(width: number, height: number, options?: object): SharpInstance;
composite(inputs: CompositeInput[]): SharpInstance;
png(options?: { compressionLevel?: number }): SharpInstance;
jpeg(options?: { quality?: number }): SharpInstance;
toBuffer(): Promise<Buffer>;
}
interface CompositeInput {
input:
| Buffer
| {
create: {
width: number;
height: number;
channels: number;
background: { r: number; g: number; b: number; alpha: number };
};
};
top?: number;
left?: number;
blend?: string;
}
type SharpConstructor = {
(
input?:
| Buffer
| string
| {
create: {
width: number;
height: number;
channels: 3 | 4;
background: { r: number; g: number; b: number; alpha?: number };
};
}
): SharpInstance;
(
buffer: Buffer,
options: { raw: { width: number; height: number; channels: 3 | 4 } }
): SharpInstance;
};
// =============================================================================
// SHARP LOADING
// =============================================================================
let sharpModule: SharpConstructor | null = null;
async function getSharp(): Promise<SharpConstructor> {
if (sharpModule) return sharpModule;
try {
const module = await import('sharp');
sharpModule = module.default as SharpConstructor;
return sharpModule;
} catch {
throw new Error(
'Sharp is required for Node.js wallpaper generation. Install it with: npm install sharp'
);
}
}
// =============================================================================
// IMAGE SOURCE LOADING
// =============================================================================
/**
* Convert data URL to Buffer
*/
function dataUrlToBuffer(dataUrl: string): Buffer {
const base64 = dataUrl.split(',')[1];
if (!base64) throw new Error('Invalid data URL');
return Buffer.from(base64, 'base64');
}
/**
* Load source image and get dimensions
*/
async function loadSourceBuffer(
source: ImageSource
): Promise<{ buffer: Buffer; width: number; height: number; isRaw?: boolean; channels?: 3 | 4 }> {
const sharp = await getSharp();
switch (source.type) {
case 'dataUrl': {
const buffer = dataUrlToBuffer(source.data);
const img = sharp(buffer);
const metadata = await img.metadata();
return {
buffer,
width: metadata.width ?? 0,
height: metadata.height ?? 0,
isRaw: false,
};
}
case 'canvas': {
throw new Error('Canvas source is not supported in Node.js environment');
}
case 'buffer': {
return {
buffer: Buffer.from(source.buffer),
width: source.width,
height: source.height,
isRaw: true,
channels: source.channels ?? 3,
};
}
default:
throw new Error('Unknown image source type');
}
}
// =============================================================================
// BACKGROUND CREATION
// =============================================================================
/**
* Create background buffer
*/
function createBackgroundBuffer(width: number, height: number, background: Background): Buffer {
let buffer: Uint8Array;
if (background.type === 'solid') {
buffer = createSolidBuffer(width, height, background.color);
} else if (background.type === 'gradient') {
buffer = createGradientBuffer(width, height, background.colors, background.angle ?? 180);
} else {
buffer = createSolidBuffer(width, height, '#000000');
}
return Buffer.from(buffer);
}
// =============================================================================
// DIMENSION RESOLUTION
// =============================================================================
/**
* Resolve device option to dimensions
*/
function resolveDimensions(device: string | { width: number; height: number }): {
width: number;
height: number;
} {
if (typeof device === 'string') {
const preset = getDevicePreset(device);
if (!preset) {
throw new Error(`Unknown device preset: ${device}`);
}
return { width: preset.width, height: preset.height };
}
return device;
}
// =============================================================================
// NODE.JS GENERATOR
// =============================================================================
/**
* Create Node.js-based wallpaper generator using Sharp
*/
export function createNodeGenerator(): WallpaperGenerator {
return {
async generate(source: ImageSource, options: WallpaperOptions): Promise<WallpaperResult> {
const sharp = await getSharp();
const { width, height } = resolveDimensions(options.device);
const format = options.format ?? 'png';
const quality = options.quality ?? 90;
// Create background
const bgBuffer = createBackgroundBuffer(width, height, options.background);
let canvas = sharp(bgBuffer, { raw: { width, height, channels: 3 } });
// Load source image
const sourceData = await loadSourceBuffer(source);
// Calculate composite operations based on layout
const composites: CompositeInput[] = [];
const { layout } = options;
// Create sharp instance for source image
const createSourceSharp = () => {
if (sourceData.isRaw) {
return sharp(sourceData.buffer, {
raw: {
width: sourceData.width,
height: sourceData.height,
channels: sourceData.channels ?? 3,
},
});
}
return sharp(sourceData.buffer);
};
if (layout.type === 'center') {
const pos = calculateCenterPosition(
width,
height,
sourceData.width,
sourceData.height,
layout as CenterLayout
);
// Resize source image
const resizedSource = await createSourceSharp()
.resize(pos.width, pos.height, { fit: 'fill' })
.png()
.toBuffer();
composites.push({
input: resizedSource,
top: Math.max(0, pos.y),
left: Math.max(0, pos.x),
});
} else if (layout.type === 'corner') {
const pos = calculateCornerPosition(
width,
height,
sourceData.width,
sourceData.height,
layout as CornerLayout
);
const resizedSource = await createSourceSharp()
.resize(pos.width, pos.height, { fit: 'fill' })
.png()
.toBuffer();
composites.push({
input: resizedSource,
top: Math.max(0, pos.y),
left: Math.max(0, pos.x),
});
} else if (layout.type === 'pattern') {
const tiles = calculatePatternTiles(
width,
height,
sourceData.width,
sourceData.height,
layout as PatternLayout
);
// Resize source once for all tiles
const tileSize = tiles[0];
if (tileSize) {
const resizedTile = await createSourceSharp()
.resize(tileSize.width, tileSize.height, { fit: 'fill' })
.png()
.toBuffer();
// Add each tile (limited opacity in Sharp requires different approach)
for (const tile of tiles) {
if (tile.x >= 0 && tile.y >= 0 && tile.x < width && tile.y < height) {
composites.push({
input: resizedTile,
top: tile.y,
left: tile.x,
blend: 'over',
});
}
}
}
}
// Apply composites
if (composites.length > 0) {
canvas = canvas.composite(composites);
}
// Export
let outputBuffer: Buffer;
if (format === 'jpeg') {
outputBuffer = await canvas.jpeg({ quality }).toBuffer();
} else {
outputBuffer = await canvas.png({ compressionLevel: 9 }).toBuffer();
}
// Convert to data URL
const mimeType = format === 'jpeg' ? 'image/jpeg' : 'image/png';
const dataUrl = `data:${mimeType};base64,${outputBuffer.toString('base64')}`;
return {
dataUrl,
width,
height,
format,
size: outputBuffer.length,
};
},
async preview(source: ImageSource, options: WallpaperOptions): Promise<string> {
// Generate at 1/4 resolution for preview
const { width, height } = resolveDimensions(options.device);
const previewWidth = Math.round(width / 4);
const previewHeight = Math.round(height / 4);
const previewOptions: WallpaperOptions = {
...options,
device: { width: previewWidth, height: previewHeight },
format: 'jpeg',
quality: 70,
};
// Scale layout parameters
previewOptions.layout = scaleLayout(options.layout, 0.25);
const result = await this.generate(source, previewOptions);
return result.dataUrl;
},
getSupportedDevices(): DevicePreset[] {
return ALL_DEVICE_PRESETS;
},
getDevicesByCategory(category: DeviceCategory): DevicePreset[] {
return getPresetsByCategory(category);
},
};
}
/**
* Scale layout parameters for preview
*/
function scaleLayout(layout: Layout, scaleFactor: number): Layout {
switch (layout.type) {
case 'center':
return {
...layout,
offset: layout.offset
? [layout.offset[0] * scaleFactor, layout.offset[1] * scaleFactor]
: undefined,
} as CenterLayout;
case 'corner':
return {
...layout,
padding: ((layout as CornerLayout).padding ?? 40) * scaleFactor,
} as CornerLayout;
case 'pattern':
return {
...layout,
gap: ((layout as PatternLayout).gap ?? 20) * scaleFactor,
} as PatternLayout;
default:
return layout;
}
}
// =============================================================================
// NODE.JS UTILITIES
// =============================================================================
/**
* Save wallpaper to file
*/
export async function saveWallpaperToFile(
result: WallpaperResult,
filePath: string
): Promise<void> {
const fs = await import('fs/promises');
const base64 = result.dataUrl.split(',')[1];
if (!base64) throw new Error('Invalid data URL');
const buffer = Buffer.from(base64, 'base64');
await fs.writeFile(filePath, buffer);
}