gotodev-image-optimizer 0.1.1 → 0.1.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,97 +0,0 @@
1
- import type { ImageFormat, OutputFormat } from './types.ts'
2
-
3
- const MAGIC_BYTES: Record<string, Uint8Array> = {
4
- jpeg: new Uint8Array([0xff, 0xd8, 0xff]),
5
- png: new Uint8Array([0x89, 0x50, 0x4e, 0x47]),
6
- webp: new Uint8Array([0x52, 0x49, 0x46, 0x46]),
7
- gif: new Uint8Array([0x47, 0x49, 0x46, 0x38]),
8
- bmp: new Uint8Array([0x42, 0x4d]),
9
- tiff: new Uint8Array([0x49, 0x49, 0x2a, 0x00]),
10
- ico: new Uint8Array([0x00, 0x00, 0x01, 0x00]),
11
- svg: new Uint8Array([0x3c]), // '<'
12
- }
13
-
14
- const EXTENSION_MAP: Record<string, ImageFormat> = {
15
- jpg: 'jpeg',
16
- jpeg: 'jpeg',
17
- png: 'png',
18
- webp: 'webp',
19
- avif: 'avif',
20
- gif: 'gif',
21
- svg: 'svg',
22
- bmp: 'bmp',
23
- tiff: 'tiff',
24
- tif: 'tiff',
25
- ico: 'ico',
26
- }
27
-
28
- export function detectFormat(buffer: Uint8Array, extension: string): ImageFormat {
29
- const extFormat = EXTENSION_MAP[extension.toLowerCase()]
30
- if (extFormat === 'svg') return 'svg'
31
-
32
- for (const [format, magic] of Object.entries(MAGIC_BYTES)) {
33
- if (buffer.length >= magic.length) {
34
- let match = true
35
- for (let i = 0; i < magic.length; i++) {
36
- if (buffer[i] !== magic[i]) {
37
- match = false
38
- break
39
- }
40
- }
41
- if (match) {
42
- if (format === 'webp') {
43
- const riffType = new TextDecoder().decode(buffer.slice(8, 12))
44
- if (riffType === 'WEBP') return 'webp'
45
- continue
46
- }
47
- return format as ImageFormat
48
- }
49
- }
50
- }
51
-
52
- return extFormat ?? 'jpeg'
53
- }
54
-
55
- export function getExtension(format: ImageFormat): string {
56
- const map: Record<ImageFormat, string> = {
57
- jpeg: 'jpg',
58
- png: 'png',
59
- webp: 'webp',
60
- avif: 'avif',
61
- gif: 'gif',
62
- svg: 'svg',
63
- bmp: 'bmp',
64
- tiff: 'tiff',
65
- ico: 'ico',
66
- }
67
- return map[format]
68
- }
69
-
70
- export function getMimeType(format: ImageFormat | OutputFormat): string {
71
- const map: Record<string, string> = {
72
- jpeg: 'image/jpeg',
73
- png: 'image/png',
74
- webp: 'image/webp',
75
- avif: 'image/avif',
76
- gif: 'image/gif',
77
- svg: 'image/svg+xml',
78
- bmp: 'image/bmp',
79
- tiff: 'image/tiff',
80
- ico: 'image/x-icon',
81
- }
82
- return map[format] ?? 'application/octet-stream'
83
- }
84
-
85
- export function isRasterFormat(format: ImageFormat): boolean {
86
- return format !== 'svg'
87
- }
88
-
89
- export function isAnimatedFormat(format: ImageFormat): boolean {
90
- return format === 'gif'
91
- }
92
-
93
- export function outputFormatsFrom(format: ImageFormat): OutputFormat[] {
94
- if (format === 'svg') return []
95
- if (format === 'gif') return ['webp', 'jpeg']
96
- return ['avif', 'webp', 'jpeg']
97
- }
@@ -1,38 +0,0 @@
1
- import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'
2
- import { dirname } from 'node:path'
3
- import type { BuildManifest, ManifestEntry } from './types.ts'
4
-
5
- const MANIFEST_VERSION = '1.0.0'
6
-
7
- export function createManifest(): BuildManifest {
8
- return {
9
- version: MANIFEST_VERSION,
10
- generatedAt: new Date().toISOString(),
11
- entries: {},
12
- }
13
- }
14
-
15
- export function addToManifest(manifest: BuildManifest, key: string, entry: ManifestEntry): void {
16
- manifest.entries[key] = entry
17
- }
18
-
19
- export function writeManifest(manifest: BuildManifest, outPath: string): void {
20
- const dir = dirname(outPath)
21
- if (!existsSync(dir)) {
22
- mkdirSync(dir, { recursive: true })
23
- }
24
- writeFileSync(outPath, JSON.stringify(manifest, null, 2))
25
- }
26
-
27
- export function loadManifest(manifestPath: string): BuildManifest | null {
28
- try {
29
- const content = readFileSafe(manifestPath)
30
- return JSON.parse(content) as BuildManifest
31
- } catch {
32
- return null
33
- }
34
- }
35
-
36
- function readFileSafe(path: string): string {
37
- return readFileSync(path, 'utf-8')
38
- }
@@ -1,58 +0,0 @@
1
- import sharp from 'sharp'
2
- import type { AnalysisResult } from './analyzer.ts'
3
-
4
- const OVERLAP_PX = 8
5
-
6
- export async function preprocessImage(
7
- imagePath: string,
8
- analysis: AnalysisResult,
9
- ): Promise<Buffer> {
10
- const { width, height, tileSize, tiles } = analysis
11
-
12
- const sorted = [...tiles].sort((a, b) => b.importance - a.importance)
13
- const topCount = Math.max(1, Math.ceil(sorted.length * 0.2))
14
- const importantTiles = sorted.slice(0, topCount)
15
-
16
- const composites: { input: Buffer; top: number; left: number }[] = []
17
-
18
- for (const tile of importantTiles) {
19
- const tileLeft = tile.x * tileSize
20
- const tileTop = tile.y * tileSize
21
- const tileW = Math.min(tileSize, width - tileLeft)
22
- const tileH = Math.min(tileSize, height - tileTop)
23
-
24
- const padL = tileLeft > 0 ? OVERLAP_PX : 0
25
- const padT = tileTop > 0 ? OVERLAP_PX : 0
26
- const padR = tileLeft + tileW < width ? OVERLAP_PX : 0
27
- const padB = tileTop + tileH < height ? OVERLAP_PX : 0
28
-
29
- const extLeft = tileLeft - padL
30
- const extTop = tileTop - padT
31
- const extWidth = tileW + padL + padR
32
- const extHeight = tileH + padT + padB
33
-
34
- const sigma = 1 + tile.importance * 1.2
35
-
36
- const sharpened = await sharp(imagePath)
37
- .extract({ left: extLeft, top: extTop, width: extWidth, height: extHeight })
38
- .sharpen(sigma)
39
- .png()
40
- .toBuffer()
41
-
42
- const trimmed = await sharp(sharpened)
43
- .extract({ left: padL, top: padT, width: tileW, height: tileH })
44
- .png()
45
- .toBuffer()
46
-
47
- composites.push({ input: trimmed, top: tileTop, left: tileLeft })
48
- }
49
-
50
- if (composites.length === 0) {
51
- return sharp(imagePath).png().toBuffer()
52
- }
53
-
54
- return sharp(imagePath)
55
- .composite(composites.map((c) => ({ input: c.input, top: c.top, left: c.left })))
56
- .png()
57
- .toBuffer()
58
- }
@@ -1,43 +0,0 @@
1
- export function sanitizeSvg(input: string): string {
2
- let sanitized = input
3
-
4
- sanitized = sanitized.replace(/<script[\s\S]*?<\/script>/gi, '')
5
- sanitized = sanitized.replace(/<\s*script[^>]*\/?>/gi, '')
6
-
7
- sanitized = sanitized.replace(/\s+on\w+\s*=\s*(?:"[^"]*"|'[^']*'|[^\s>]+)/gi, '')
8
-
9
- sanitized = sanitized.replace(/javascript\s*:/gi, '')
10
- sanitized = sanitized.replace(/data\s*:\s*text\s*\/\s*html/gi, '')
11
- sanitized = sanitized.replace(/document\./gi, '')
12
- sanitized = sanitized.replace(/window\./gi, '')
13
- sanitized = sanitized.replace(/eval\s*\(/gi, '')
14
- sanitized = sanitized.replace(/new\s+Function\s*\(/gi, '')
15
- sanitized = sanitized.replace(/setTimeout\s*\(/gi, '')
16
- sanitized = sanitized.replace(/setInterval\s*\(/gi, '')
17
-
18
- return sanitized
19
- }
20
-
21
- export function validateSvgContent(input: string): boolean {
22
- const dangerous = [
23
- 'script',
24
- 'onerror',
25
- 'onload',
26
- 'onclick',
27
- 'onmouseover',
28
- 'javascript:',
29
- 'data:text/html',
30
- 'document.cookie',
31
- '<?',
32
- '<%',
33
- ]
34
-
35
- const lower = input.toLowerCase()
36
- for (const pattern of dangerous) {
37
- if (lower.includes(pattern)) {
38
- return false
39
- }
40
- }
41
-
42
- return true
43
- }
package/src/core/tuner.ts DELETED
@@ -1,68 +0,0 @@
1
- import sharp from 'sharp'
2
- import { computeSSIM } from '../utils/ssim.ts'
3
- import type { OutputFormat } from './types.ts'
4
-
5
- const SSIM_THRESHOLD = 0.97
6
- const QUALITY_RANGE = [70, 75, 80, 85, 90, 95] as const
7
-
8
- export interface TunedResult {
9
- quality: number
10
- ssim: number
11
- size: number
12
- }
13
-
14
- export async function autoTuneQuality(
15
- source: string | Buffer,
16
- format: OutputFormat,
17
- options?: {
18
- threshold?: number
19
- minQuality?: number
20
- maxQuality?: number
21
- },
22
- ): Promise<TunedResult> {
23
- const threshold = options?.threshold ?? SSIM_THRESHOLD
24
- const minQ = options?.minQuality ?? 70
25
- const maxQ = options?.maxQuality ?? 95
26
-
27
- const candidateQualities = QUALITY_RANGE.filter((q) => q >= minQ && q <= maxQ)
28
-
29
- const original = await sharp(source)
30
- .resize(256, 256, { fit: 'inside' })
31
- .grayscale()
32
- .raw()
33
- .toBuffer()
34
-
35
- const originalMeta = await sharp(source).metadata()
36
- const resizeW = Math.min(256, originalMeta.width ?? 256)
37
- const resizeH = Math.min(256, originalMeta.height ?? 256)
38
-
39
- let best: TunedResult = { quality: minQ, ssim: 1, size: Number.POSITIVE_INFINITY }
40
-
41
- for (const quality of candidateQualities) {
42
- const { data, info } = await sharp(source)
43
- .resize(resizeW, resizeH, { fit: 'inside' })
44
- [format]({ quality })
45
- .grayscale()
46
- .raw()
47
- .toBuffer({ resolveWithObject: true })
48
-
49
- const ssim = computeSSIM(
50
- new Uint8Array(original),
51
- new Uint8Array(data),
52
- info.width,
53
- info.height,
54
- )
55
- const size = Buffer.byteLength(data)
56
-
57
- if (ssim >= threshold && size < best.size) {
58
- best = { quality, ssim, size }
59
- }
60
-
61
- if (ssim >= threshold && quality === minQ) {
62
- return best
63
- }
64
- }
65
-
66
- if (best.ssim >= threshold) return best
67
- return { quality: maxQ, ssim: best.ssim, size: best.size }
68
- }
package/src/core/types.ts DELETED
@@ -1,83 +0,0 @@
1
- export type ImageFormat = 'jpeg' | 'png' | 'webp' | 'avif' | 'gif' | 'svg' | 'bmp' | 'tiff' | 'ico'
2
-
3
- export type OutputFormat = 'avif' | 'webp' | 'jpeg' | 'png'
4
-
5
- export type QualityTier = 'ultra' | 'high' | 'medium' | 'low'
6
-
7
- export interface TierConfig {
8
- quality: number
9
- widths: number[]
10
- }
11
-
12
- export interface PluginOptions {
13
- tiers?: Partial<Record<QualityTier, TierConfig>>
14
- adaptive?: boolean
15
- autoTune?: boolean
16
- formats?: OutputFormat[]
17
- maxFileSize?: number
18
- widths?: number[]
19
- verbose?: boolean
20
- preprocess?: boolean
21
- faceDetection?: boolean
22
- }
23
-
24
- export interface ImageVariant {
25
- src: string
26
- width: number
27
- format: OutputFormat
28
- size: number
29
- integrity: string
30
- }
31
-
32
- export interface ImageMetadata {
33
- src: string
34
- width: number
35
- height: number
36
- format: ImageFormat
37
- placeholder: string
38
- variants: ImageVariant[]
39
- tiers: Record<QualityTier, string>
40
- blurHash?: string
41
- }
42
-
43
- export interface TileAnalysis {
44
- x: number
45
- y: number
46
- entropy: number
47
- edgeDensity: number
48
- importance: number
49
- centerWeight: number
50
- }
51
-
52
- export interface TileQuality {
53
- x: number
54
- y: number
55
- saliency: number
56
- quality: number
57
- weight: number
58
- }
59
-
60
- export interface DeviceFingerprint {
61
- effectiveType: 'slow-2g' | '2g' | '3g' | '4g' | 'unknown'
62
- deviceMemory: number
63
- hardwareConcurrency: number
64
- devicePixelRatio: number
65
- saveData: boolean
66
- tier: QualityTier
67
- }
68
-
69
- export interface ManifestEntry {
70
- src: string
71
- width: number
72
- height: number
73
- format: ImageFormat
74
- placeholder: string
75
- tiers: Record<QualityTier, string>
76
- variants: ImageVariant[]
77
- }
78
-
79
- export interface BuildManifest {
80
- version: string
81
- generatedAt: string
82
- entries: Record<string, ManifestEntry>
83
- }
@@ -1,63 +0,0 @@
1
- import { detectFormat } from './formats.ts'
2
- import type { ImageFormat } from './types.ts'
3
-
4
- export class ValidationError extends Error {
5
- constructor(
6
- message: string,
7
- public readonly code: string,
8
- ) {
9
- super(message)
10
- this.name = 'ValidationError'
11
- }
12
- }
13
-
14
- const MAX_FILE_SIZE = 50 * 1024 * 1024
15
- const ALLOWED_EXTENSIONS = new Set([
16
- 'jpg',
17
- 'jpeg',
18
- 'png',
19
- 'webp',
20
- 'avif',
21
- 'gif',
22
- 'svg',
23
- 'bmp',
24
- 'tiff',
25
- 'tif',
26
- 'ico',
27
- ])
28
-
29
- export function validatePath(filePath: string): void {
30
- if (filePath.includes('..')) {
31
- throw new ValidationError('Path traversal detected', 'PATH_TRAVERSAL')
32
- }
33
- if (filePath.includes('\0')) {
34
- throw new ValidationError('Null byte in path', 'NULL_BYTE')
35
- }
36
- }
37
-
38
- export function validateExtension(extension: string): void {
39
- if (!ALLOWED_EXTENSIONS.has(extension.toLowerCase())) {
40
- throw new ValidationError(`Unsupported file extension: .${extension}`, 'UNSUPPORTED_EXTENSION')
41
- }
42
- }
43
-
44
- export function validateFileSize(size: number, maxSize: number = MAX_FILE_SIZE): void {
45
- if (size > maxSize) {
46
- throw new ValidationError(`File size ${size} exceeds maximum ${maxSize}`, 'FILE_TOO_LARGE')
47
- }
48
- }
49
-
50
- export function validateImageContent(buffer: Uint8Array, extension: string): ImageFormat {
51
- const detectedFormat = detectFormat(buffer, extension)
52
- if (!detectedFormat) {
53
- throw new ValidationError(`Cannot detect image format for .${extension}`, 'INVALID_FORMAT')
54
- }
55
- return detectedFormat
56
- }
57
-
58
- export function validateOutputFormat(format: string): void {
59
- const allowed: string[] = ['avif', 'webp', 'jpeg', 'png']
60
- if (!allowed.includes(format)) {
61
- throw new ValidationError(`Unsupported output format: ${format}`, 'UNSUPPORTED_OUTPUT_FORMAT')
62
- }
63
- }
package/src/index.ts DELETED
@@ -1,13 +0,0 @@
1
- export type { GImageProps } from './components/GImage.tsx'
2
- export { default as GImage } from './components/GImage.tsx'
3
- export type {
4
- BuildManifest,
5
- DeviceFingerprint,
6
- ImageFormat,
7
- ImageMetadata,
8
- ManifestEntry,
9
- OutputFormat,
10
- PluginOptions,
11
- QualityTier,
12
- } from './core/types.ts'
13
- export { default as gotodevImageOptimizer } from './vite-plugin.ts'
@@ -1,66 +0,0 @@
1
- export function computeEntropy(pixels: Uint8Array): number {
2
- const histogram = new Float64Array(256)
3
- const len = pixels.length
4
-
5
- for (let i = 0; i < len; i++) {
6
- const val = pixels[i]
7
- if (val !== undefined) {
8
- histogram[val] = (histogram[val] ?? 0) + 1
9
- }
10
- }
11
-
12
- let entropy = 0
13
- const lenF = len
14
- for (let i = 0; i < 256; i++) {
15
- const count = histogram[i]
16
- if (count && count > 0) {
17
- const p = count / lenF
18
- entropy -= p * Math.log2(p)
19
- }
20
- }
21
-
22
- return entropy
23
- }
24
-
25
- export function computeEdgeDensity(pixels: Uint8Array, width: number, height: number): number {
26
- let edges = 0
27
- let total = 0
28
-
29
- for (let y = 1; y < height - 1; y++) {
30
- for (let x = 1; x < width - 1; x++) {
31
- const idx = y * width + x
32
- const center = pixels[idx] ?? 0
33
- const right = pixels[idx + 1] ?? 0
34
- const down = pixels[idx + width] ?? 0
35
- const dx = Math.abs(center - right)
36
- const dy = Math.abs(center - down)
37
-
38
- if (dx > 20 || dy > 20) {
39
- edges++
40
- }
41
- total++
42
- }
43
- }
44
-
45
- return total > 0 ? edges / total : 0
46
- }
47
-
48
- export function computeSaliency(pixels: Uint8Array, width: number, height: number): number {
49
- const entropy = computeEntropy(pixels)
50
- const edgeDensity = computeEdgeDensity(pixels, width, height)
51
- const normalizedEntropy = entropy / 8.0
52
- return normalizedEntropy * 0.5 + edgeDensity * 0.5
53
- }
54
-
55
- export function computeCenterWeight(
56
- tileX: number,
57
- tileY: number,
58
- tilesX: number,
59
- tilesY: number,
60
- ): number {
61
- const cx = (tileX + 0.5) / tilesX - 0.5
62
- const cy = (tileY + 0.5) / tilesY - 0.5
63
- const dist = Math.sqrt(cx * cx + cy * cy)
64
- const normalizedDist = Math.min(1, dist / Math.SQRT1_2)
65
- return 1.0 - normalizedDist * 0.4
66
- }
package/src/utils/hash.ts DELETED
@@ -1,10 +0,0 @@
1
- import { createHash } from 'node:crypto'
2
-
3
- export function contentHash(buffer: Buffer): string {
4
- return createHash('sha384').update(buffer).digest('base64url').slice(0, 16)
5
- }
6
-
7
- export function sriHash(buffer: Buffer): string {
8
- const hash = createHash('sha384').update(buffer).digest('base64')
9
- return `sha384-${hash}`
10
- }
package/src/utils/ssim.ts DELETED
@@ -1,56 +0,0 @@
1
- export function computeSSIM(
2
- original: Uint8Array,
3
- compressed: Uint8Array,
4
- width: number,
5
- height: number,
6
- ): number {
7
- const K1 = 0.01
8
- const K2 = 0.03
9
- const L = 255
10
- const C1 = (K1 * L) ** 2
11
- const C2 = (K2 * L) ** 2
12
-
13
- const windowSize = 8
14
- const step = 4
15
- let totalSSIM = 0
16
- let windows = 0
17
-
18
- for (let y = 0; y <= height - windowSize; y += step) {
19
- for (let x = 0; x <= width - windowSize; x += step) {
20
- let sumX = 0
21
- let sumY = 0
22
- let sumX2 = 0
23
- let sumY2 = 0
24
- let sumXY = 0
25
- let count = 0
26
-
27
- for (let wy = 0; wy < windowSize; wy++) {
28
- for (let wx = 0; wx < windowSize; wx++) {
29
- const idx = (y + wy) * width + (x + wx)
30
- const ox = original[idx] ?? 0
31
- const cy = compressed[idx] ?? 0
32
- sumX += ox
33
- sumY += cy
34
- sumX2 += ox * ox
35
- sumY2 += cy * cy
36
- sumXY += ox * cy
37
- count++
38
- }
39
- }
40
-
41
- const muX = sumX / count
42
- const muY = sumY / count
43
- const sigmaX2 = sumX2 / count - muX * muX
44
- const sigmaY2 = sumY2 / count - muY * muY
45
- const sigmaXY = sumXY / count - muX * muY
46
-
47
- const numerator = (2 * muX * muY + C1) * (2 * sigmaXY + C2)
48
- const denominator = (muX * muX + muY * muY + C1) * (sigmaX2 + sigmaY2 + C2)
49
-
50
- totalSSIM += denominator > 0 ? numerator / denominator : 1
51
- windows++
52
- }
53
- }
54
-
55
- return windows > 0 ? totalSSIM / windows : 1
56
- }
@@ -1,73 +0,0 @@
1
- import { availableParallelism } from 'node:os'
2
-
3
- export type WorkerTask<T> = () => Promise<T>
4
-
5
- interface QueueItem {
6
- task: WorkerTask<unknown>
7
- resolve: (value: unknown) => void
8
- reject: (reason: unknown) => void
9
- }
10
-
11
- const POOL_SIZE = Math.max(1, availableParallelism() - 1)
12
-
13
- export class WorkerPool {
14
- private queue: QueueItem[] = []
15
- private active = 0
16
- private maxWorkers: number
17
-
18
- constructor(maxWorkers: number = POOL_SIZE) {
19
- this.maxWorkers = Math.max(1, maxWorkers)
20
- }
21
-
22
- async run<T>(task: WorkerTask<T>): Promise<T> {
23
- if (this.active < this.maxWorkers) {
24
- this.active++
25
- try {
26
- const result = await task()
27
- this.active--
28
- this.drain()
29
- return result
30
- } catch (error) {
31
- this.active--
32
- this.drain()
33
- throw error
34
- }
35
- }
36
-
37
- return new Promise<T>((resolve, reject) => {
38
- this.queue.push({
39
- task: task as WorkerTask<unknown>,
40
- resolve: resolve as (value: unknown) => void,
41
- reject,
42
- })
43
- })
44
- }
45
-
46
- private drain(): void {
47
- while (this.active < this.maxWorkers && this.queue.length > 0) {
48
- const item = this.queue.shift()
49
- if (!item) break
50
- this.active++
51
- item
52
- .task()
53
- .then((result) => {
54
- this.active--
55
- item.resolve(result)
56
- this.drain()
57
- })
58
- .catch((error) => {
59
- this.active--
60
- item.reject(error)
61
- this.drain()
62
- })
63
- }
64
- }
65
-
66
- get pending(): number {
67
- return this.queue.length
68
- }
69
-
70
- get running(): number {
71
- return this.active
72
- }
73
- }