gotodev-image-optimizer 0.1.0
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.
- package/LICENSE +21 -0
- package/README.md +165 -0
- package/package.json +56 -0
- package/src/adaptive/fingerprint.ts +117 -0
- package/src/adaptive/predictive.ts +91 -0
- package/src/adaptive/tier.ts +34 -0
- package/src/components/GImage.tsx +218 -0
- package/src/core/analyzer.ts +189 -0
- package/src/core/encoder.ts +244 -0
- package/src/core/formats.ts +97 -0
- package/src/core/manifest.ts +38 -0
- package/src/core/preprocessor.ts +58 -0
- package/src/core/sanitizer.ts +43 -0
- package/src/core/tuner.ts +68 -0
- package/src/core/types.ts +83 -0
- package/src/core/validator.ts +63 -0
- package/src/index.ts +13 -0
- package/src/utils/entropy.ts +66 -0
- package/src/utils/hash.ts +10 -0
- package/src/utils/ssim.ts +56 -0
- package/src/utils/worker.ts +73 -0
- package/src/vite-plugin.ts +111 -0
|
@@ -0,0 +1,68 @@
|
|
|
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
|
+
}
|
|
@@ -0,0 +1,83 @@
|
|
|
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
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
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
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
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'
|
|
@@ -0,0 +1,66 @@
|
|
|
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
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
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
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
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
|
+
}
|
|
@@ -0,0 +1,73 @@
|
|
|
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
|
+
}
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
import { existsSync, mkdirSync } from 'node:fs'
|
|
2
|
+
import { basename, extname, resolve } from 'node:path'
|
|
3
|
+
import type { Plugin, ResolvedConfig } from 'vite'
|
|
4
|
+
import { encodeImage } from './core/encoder.ts'
|
|
5
|
+
import { addToManifest, createManifest, writeManifest } from './core/manifest.ts'
|
|
6
|
+
import type { PluginOptions, QualityTier, TierConfig } from './core/types.ts'
|
|
7
|
+
|
|
8
|
+
const IMAGE_EXTENSIONS = /\.(jpe?g|png|webp|avif|gif|svg|bmp|tiff?|ico)$/i
|
|
9
|
+
|
|
10
|
+
const DEFAULT_TIERS: Record<QualityTier, TierConfig> = {
|
|
11
|
+
ultra: { quality: 90, widths: [480, 768, 1024, 1920] },
|
|
12
|
+
high: { quality: 80, widths: [480, 768, 1024] },
|
|
13
|
+
medium: { quality: 60, widths: [480, 768] },
|
|
14
|
+
low: { quality: 40, widths: [480] },
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export default function gotodevImageOptimizer(userOptions: PluginOptions = {}): Plugin {
|
|
18
|
+
let config: ResolvedConfig
|
|
19
|
+
let manifest = createManifest()
|
|
20
|
+
const options: PluginOptions = {
|
|
21
|
+
tiers: { ...DEFAULT_TIERS, ...userOptions.tiers },
|
|
22
|
+
adaptive: userOptions.adaptive ?? true,
|
|
23
|
+
autoTune: userOptions.autoTune ?? true,
|
|
24
|
+
preprocess: userOptions.preprocess ?? true,
|
|
25
|
+
faceDetection: userOptions.faceDetection ?? true,
|
|
26
|
+
formats: userOptions.formats ?? ['avif', 'webp', 'jpeg'],
|
|
27
|
+
maxFileSize: userOptions.maxFileSize ?? 50 * 1024 * 1024,
|
|
28
|
+
verbose: userOptions.verbose ?? false,
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
return {
|
|
32
|
+
name: 'gotodev-image-optimizer',
|
|
33
|
+
enforce: 'post',
|
|
34
|
+
|
|
35
|
+
configResolved(resolved: ResolvedConfig) {
|
|
36
|
+
config = resolved
|
|
37
|
+
},
|
|
38
|
+
|
|
39
|
+
async buildStart() {
|
|
40
|
+
manifest = createManifest()
|
|
41
|
+
},
|
|
42
|
+
|
|
43
|
+
async transform(_code: string, id: string) {
|
|
44
|
+
if (!IMAGE_EXTENSIONS.test(id)) return
|
|
45
|
+
if (id.includes('node_modules')) return
|
|
46
|
+
|
|
47
|
+
const outDir = resolve(config.root, config.build.outDir ?? 'dist', 'assets')
|
|
48
|
+
if (!existsSync(outDir)) {
|
|
49
|
+
mkdirSync(outDir, { recursive: true })
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const tiers = options.tiers as Record<QualityTier, TierConfig>
|
|
53
|
+
|
|
54
|
+
const formats = options.formats ?? (['avif', 'webp', 'jpeg'] as const)
|
|
55
|
+
|
|
56
|
+
try {
|
|
57
|
+
const entry = await encodeImage(id, {
|
|
58
|
+
widths: tiers.high.widths,
|
|
59
|
+
formats: [...formats],
|
|
60
|
+
tiers,
|
|
61
|
+
autoTune: options.autoTune ?? true,
|
|
62
|
+
adaptive: options.adaptive ?? true,
|
|
63
|
+
preprocess: options.preprocess ?? true,
|
|
64
|
+
faceDetection: options.faceDetection ?? true,
|
|
65
|
+
outDir,
|
|
66
|
+
})
|
|
67
|
+
|
|
68
|
+
const key = basename(id)
|
|
69
|
+
addToManifest(manifest, key, entry)
|
|
70
|
+
|
|
71
|
+
if (options.verbose) {
|
|
72
|
+
console.log(`[gotodev-image-optimizer] Optimized: ${key}`)
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const manifestData = JSON.stringify(entry)
|
|
76
|
+
const manifestPath = resolve(outDir, 'gimage-manifest.json')
|
|
77
|
+
writeManifest(manifest, manifestPath)
|
|
78
|
+
|
|
79
|
+
return {
|
|
80
|
+
code: `export default ${manifestData};`,
|
|
81
|
+
map: null,
|
|
82
|
+
}
|
|
83
|
+
} catch (error) {
|
|
84
|
+
if (options.verbose) {
|
|
85
|
+
console.error(`[gotodev-image-optimizer] Failed to optimize ${id}:`, error)
|
|
86
|
+
}
|
|
87
|
+
return {
|
|
88
|
+
code: `export default ${JSON.stringify({
|
|
89
|
+
src: basename(id),
|
|
90
|
+
width: 0,
|
|
91
|
+
height: 0,
|
|
92
|
+
format: extname(id).slice(1),
|
|
93
|
+
placeholder: '',
|
|
94
|
+
variants: [],
|
|
95
|
+
tiers: {},
|
|
96
|
+
})};`,
|
|
97
|
+
map: null,
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
},
|
|
101
|
+
|
|
102
|
+
closeBundle() {
|
|
103
|
+
const outDir = resolve(config.root, config.build.outDir ?? 'dist', 'assets')
|
|
104
|
+
if (!existsSync(outDir)) {
|
|
105
|
+
mkdirSync(outDir, { recursive: true })
|
|
106
|
+
}
|
|
107
|
+
const manifestPath = resolve(outDir, 'gimage-manifest.json')
|
|
108
|
+
writeManifest(manifest, manifestPath)
|
|
109
|
+
},
|
|
110
|
+
}
|
|
111
|
+
}
|