-
Notifications
You must be signed in to change notification settings - Fork 0
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Showing
8 changed files
with
930 additions
and
55 deletions.
There are no files selected for viewing
Large diffs are not rendered by default.
Oops, something went wrong.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,124 @@ | ||
import { readFile, stat } from 'node:fs/promises' | ||
import sizeOf from 'image-size' | ||
import sharp from 'sharp' | ||
|
||
export interface ImageStats { | ||
path: string | ||
size: number | ||
format: string | ||
width: number | ||
height: number | ||
aspectRatio: number | ||
hasAlpha: boolean | ||
isAnimated: boolean | ||
colorSpace: string | ||
channels: number | ||
density: number | ||
compression?: string | ||
quality?: number | ||
optimizationPotential: 'low' | 'medium' | 'high' | ||
metadata: Record<string, any> | ||
warnings: string[] | ||
} | ||
|
||
export async function analyzeImage(path: string): Promise<ImageStats> { | ||
const warnings = [] | ||
const fileStats = await stat(path) | ||
const buffer = await readFile(path) | ||
const dimensions = sizeOf(buffer) | ||
const metadata = await sharp(buffer).metadata() | ||
|
||
// Analyze optimization potential | ||
let optimizationPotential: 'low' | 'medium' | 'high' = 'low' | ||
|
||
// Check file size relative to dimensions | ||
const pixelCount = dimensions.width * dimensions.height | ||
const bytesPerPixel = fileStats.size / pixelCount | ||
|
||
if (bytesPerPixel > 4) | ||
optimizationPotential = 'high' | ||
else if (bytesPerPixel > 2) | ||
optimizationPotential = 'medium' | ||
|
||
// Check for common issues | ||
if (dimensions.width > 3000 || dimensions.height > 3000) { | ||
warnings.push('Image dimensions are very large') | ||
optimizationPotential = 'high' | ||
} | ||
|
||
if (fileStats.size > 1024 * 1024) { | ||
warnings.push('File size exceeds 1MB') | ||
optimizationPotential = 'high' | ||
} | ||
|
||
if (metadata.format === 'jpeg' && !metadata.isProgressive) { | ||
warnings.push('JPEG is not progressive') | ||
} | ||
|
||
if (metadata.format === 'png' && metadata.channels === 4 && !metadata.hasAlpha) { | ||
warnings.push('PNG has alpha channel but no transparency') | ||
} | ||
|
||
return { | ||
path, | ||
size: fileStats.size, | ||
format: metadata.format, | ||
width: dimensions.width, | ||
height: dimensions.height, | ||
aspectRatio: dimensions.width / dimensions.height, | ||
hasAlpha: metadata.hasAlpha, | ||
isAnimated: metadata.pages > 1, | ||
colorSpace: metadata.space, | ||
channels: metadata.channels, | ||
density: metadata.density, | ||
compression: metadata.compression, | ||
quality: metadata.quality, | ||
optimizationPotential, | ||
metadata, | ||
warnings, | ||
} | ||
} | ||
|
||
export async function generateReport(paths: string[]): Promise<{ | ||
stats: ImageStats[] | ||
summary: { | ||
totalSize: number | ||
averageSize: number | ||
totalImages: number | ||
formatBreakdown: Record<string, number> | ||
potentialSavings: string | ||
warnings: string[] | ||
} | ||
}> { | ||
const stats = await Promise.all(paths.map(analyzeImage)) | ||
const totalSize = stats.reduce((sum, stat) => sum + stat.size, 0) | ||
|
||
const formatBreakdown = stats.reduce((acc, stat) => { | ||
acc[stat.format] = (acc[stat.format] || 0) + 1 | ||
return acc | ||
}, {}) | ||
|
||
const warnings = Array.from(new Set(stats.flatMap(s => s.warnings))) | ||
|
||
// Estimate potential savings | ||
const potentialSavings = stats.reduce((sum, stat) => { | ||
switch (stat.optimizationPotential) { | ||
case 'high': return sum + stat.size * 0.7 | ||
case 'medium': return sum + stat.size * 0.4 | ||
case 'low': return sum + stat.size * 0.1 | ||
default: return sum | ||
} | ||
}, 0) | ||
|
||
return { | ||
stats, | ||
summary: { | ||
totalSize, | ||
averageSize: totalSize / stats.length, | ||
totalImages: stats.length, | ||
formatBreakdown, | ||
potentialSavings: `${Math.round(potentialSavings / 1024)}KB`, | ||
warnings, | ||
}, | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,195 @@ | ||
import { writeFile } from 'node:fs/promises' | ||
import { join } from 'node:path' | ||
import sharp from 'sharp' | ||
import { debugLog } from './utils' | ||
|
||
interface SpriteConfig { | ||
padding?: number | ||
maxWidth?: number | ||
prefix?: string | ||
format?: 'png' | 'webp' | ||
quality?: number | ||
scale?: number | ||
} | ||
|
||
interface SpriteResult { | ||
imagePath: string | ||
cssPath: string | ||
width: number | ||
height: number | ||
sprites: Array<{ | ||
name: string | ||
x: number | ||
y: number | ||
width: number | ||
height: number | ||
}> | ||
} | ||
|
||
export async function generateSprite( | ||
images: Array<{ path: string, name: string }>, | ||
outputDir: string, | ||
config: SpriteConfig = {}, | ||
): Promise<SpriteResult> { | ||
const { | ||
padding = 2, | ||
maxWidth = 2048, | ||
prefix = 'sprite', | ||
format = 'png', | ||
quality = 90, | ||
scale = 1, | ||
} = config | ||
|
||
debugLog('sprite', `Generating sprite sheet from ${images.length} images`) | ||
|
||
// Load and process all images | ||
const sprites = await Promise.all( | ||
images.map(async ({ path, name }) => { | ||
const image = sharp(path) | ||
const metadata = await image.metadata() | ||
return { | ||
name, | ||
image, | ||
width: Math.ceil(metadata.width * scale), | ||
height: Math.ceil(metadata.height * scale), | ||
} | ||
}), | ||
) | ||
|
||
// Simple packing algorithm | ||
let currentX = 0 | ||
let currentY = 0 | ||
let rowHeight = 0 | ||
let maxWidth = 0 | ||
let maxHeight = 0 | ||
|
||
const positions = sprites.map((sprite) => { | ||
if (currentX + sprite.width + padding > maxWidth) { | ||
currentX = 0 | ||
currentY += rowHeight + padding | ||
rowHeight = 0 | ||
} | ||
|
||
const position = { | ||
x: currentX, | ||
y: currentY, | ||
width: sprite.width, | ||
height: sprite.height, | ||
} | ||
|
||
currentX += sprite.width + padding | ||
rowHeight = Math.max(rowHeight, sprite.height) | ||
maxWidth = Math.max(maxWidth, currentX) | ||
maxHeight = Math.max(maxHeight, currentY + sprite.height) | ||
|
||
return position | ||
}) | ||
|
||
// Create sprite sheet | ||
const composite = positions.map((pos, i) => ({ | ||
input: sprites[i].image, | ||
left: pos.x, | ||
top: pos.y, | ||
})) | ||
|
||
const spriteSheet = sharp({ | ||
create: { | ||
width: maxWidth, | ||
height: maxHeight, | ||
channels: 4, | ||
background: { r: 0, g: 0, b: 0, alpha: 0 }, | ||
}, | ||
}) | ||
.composite(composite) | ||
[format]({ quality }) | ||
|
||
const spritePath = join(outputDir, `${prefix}-sprite.${format}`) | ||
await spriteSheet.toFile(spritePath) | ||
|
||
// Generate CSS | ||
const spriteData = positions.map((pos, i) => ({ | ||
name: sprites[i].name, | ||
...pos, | ||
})) | ||
|
||
const css = generateCSS(prefix, spriteData, format) | ||
const cssPath = join(outputDir, `${prefix}-sprite.css`) | ||
await writeFile(cssPath, css) | ||
|
||
// Generate SCSS | ||
const scss = generateSCSS(prefix, spriteData, format) | ||
const scssPath = join(outputDir, `${prefix}-sprite.scss`) | ||
await writeFile(scssPath, scss) | ||
|
||
return { | ||
imagePath: spritePath, | ||
cssPath, | ||
width: maxWidth, | ||
height: maxHeight, | ||
sprites: spriteData, | ||
} | ||
} | ||
|
||
function generateCSS(prefix: string, sprites: Array<{ name: string, x: number, y: number, width: number, height: number }>, format: string): string { | ||
return ` | ||
.${prefix} { | ||
background-image: url('./${prefix}-sprite.${format}'); | ||
background-repeat: no-repeat; | ||
display: inline-block; | ||
} | ||
${sprites | ||
.map( | ||
sprite => ` | ||
.${prefix}-${sprite.name} { | ||
width: ${sprite.width}px; | ||
height: ${sprite.height}px; | ||
background-position: -${sprite.x}px -${sprite.y}px; | ||
} | ||
`, | ||
) | ||
.join('\n')} | ||
/* Retina support */ | ||
@media (-webkit-min-device-pixel-ratio: 2), (min-resolution: 192dpi) { | ||
.${prefix} { | ||
background-size: ${sprites[0].width * 2}px ${sprites[0].height * 2}px; | ||
} | ||
} | ||
` | ||
} | ||
|
||
function generateSCSS(prefix: string, sprites: Array<{ name: string, x: number, y: number, width: number, height: number }>, format: string): string { | ||
return ` | ||
$${prefix}-sprites: ( | ||
${sprites | ||
.map( | ||
sprite => ` | ||
'${sprite.name}': ( | ||
x: -${sprite.x}px, | ||
y: -${sprite.y}px, | ||
width: ${sprite.width}px, | ||
height: ${sprite.height}px | ||
)`, | ||
) | ||
.join(',\n')} | ||
); | ||
@mixin ${prefix}-sprite($name) { | ||
$sprite: map-get($${prefix}-sprites, $name); | ||
width: map-get($sprite, 'width'); | ||
height: map-get($sprite, 'height'); | ||
background-position: map-get($sprite, 'x') map-get($sprite, 'y'); | ||
background-image: url('./${prefix}-sprite.${format}'); | ||
background-repeat: no-repeat; | ||
display: inline-block; | ||
@media (-webkit-min-device-pixel-ratio: 2), (min-resolution: 192dpi) { | ||
$w: map-get($sprite, 'width'); | ||
$h: map-get($sprite, 'height'); | ||
background-size: $w * 2 $h * 2; | ||
} | ||
} | ||
` | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.