Skip to content

Commit

Permalink
chore: wip
Browse files Browse the repository at this point in the history
chore: wip
  • Loading branch information
chrisbbreuer committed Jan 26, 2025
1 parent fdb8038 commit acec9b7
Show file tree
Hide file tree
Showing 8 changed files with 930 additions and 55 deletions.
429 changes: 374 additions & 55 deletions bin/cli.ts

Large diffs are not rendered by default.

5 changes: 5 additions & 0 deletions bun.lock
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
"cac": "^6.7.14",
"changelogen": "^0.5.7",
"consola": "^3.4.0",
"image-size": "^1.2.0",
"sharp": "^0.33.5",
"svgo": "^3.3.2",
"typescript": "^5.7.3",
Expand Down Expand Up @@ -1096,6 +1097,8 @@

"ignore": ["ignore@5.3.2", "", {}, "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g=="],

"image-size": ["image-size@1.2.0", "", { "dependencies": { "queue": "6.0.2" }, "bin": { "image-size": "bin/image-size.js" } }, "sha512-4S8fwbO6w3GeCVN6OPtA9I5IGKkcDMPcKndtUlpJuCwu7JLjtj7JZpwqLuyY2nrmQT3AWsCJLSKPsc2mPBSl3w=="],

"import-fresh": ["import-fresh@3.3.0", "", { "dependencies": { "parent-module": "^1.0.0", "resolve-from": "^4.0.0" } }, "sha512-veYYhQa+D1QBKznvhUHxb8faxlrwUnxseDAbAp457E0wLNio2bOSKnjYDhMj+YiAq61xrMGhQk9iXVk5FzgQMw=="],

"imurmurhash": ["imurmurhash@0.1.4", "", {}, "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA=="],
Expand Down Expand Up @@ -1472,6 +1475,8 @@

"punycode": ["punycode@2.3.1", "", {}, "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg=="],

"queue": ["queue@6.0.2", "", { "dependencies": { "inherits": "~2.0.3" } }, "sha512-iHZWu+q3IdFZFX36ro/lKBkSvfkztY5Y7HMiPlOUjhupPcG2JMfst2KKEpu5XndviX/3UhFbRngUPNKtgvtZiA=="],

"queue-microtask": ["queue-microtask@1.2.3", "", {}, "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A=="],

"randombytes": ["randombytes@2.1.0", "", { "dependencies": { "safe-buffer": "^5.1.0" } }, "sha512-vYl3iOX+4CKUWuxGi9Ukhie6fsqXqS9FE2Zaic4tNFD2N2QQaXOMFbuKK4QmDHC0JO6B1Zp41J0LpT0oR68amQ=="],
Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,7 @@
"cac": "^6.7.14",
"changelogen": "^0.5.7",
"consola": "^3.4.0",
"image-size": "^1.2.0",
"sharp": "^0.33.5",
"svgo": "^3.3.2",
"typescript": "^5.7.3",
Expand Down
124 changes: 124 additions & 0 deletions src/analyze.ts
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,
},
}
}
195 changes: 195 additions & 0 deletions src/sprite-generator.ts
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;
}
}
`
}
14 changes: 14 additions & 0 deletions src/thumbhash.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import sharp from 'sharp'

// Thanks to evanw for the original implementation of this code:
// https://github.com/evanw/thumbhash

Expand Down Expand Up @@ -369,3 +371,15 @@ export function thumbHashToDataURL(hash: ArrayLike<number>): string {
const image = thumbHashToRGBA(hash)
return rgbaToDataURL(image.w, image.h, image.rgba)
}

export async function generateThumbHash(input: string): Promise<{ hash: Uint8Array, dataUrl: string }> {
const image = sharp(input)
const { data, info } = await image
.resize(100, 100, { fit: 'inside' })
.raw()
.toBuffer({ resolveWithObject: true })

const hash = rgbaToThumbHash(info.width, info.height, data)
const dataUrl = thumbHashToDataURL(hash)
return { hash, dataUrl }
}
Loading

0 comments on commit acec9b7

Please sign in to comment.