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.
- package/dist/chunk-JJABKWGE.js +705 -0
- package/dist/index.d.ts +19 -0
- package/dist/index.js +348 -0
- package/dist/vite-plugin-CpGEB8EW.d.ts +63 -0
- package/dist/vite-plugin.d.ts +2 -0
- package/dist/vite-plugin.js +6 -0
- package/package.json +16 -8
- package/src/adaptive/fingerprint.ts +0 -117
- package/src/adaptive/predictive.ts +0 -91
- package/src/adaptive/tier.ts +0 -34
- package/src/components/GImage.tsx +0 -218
- package/src/core/analyzer.ts +0 -189
- package/src/core/encoder.ts +0 -244
- package/src/core/formats.ts +0 -97
- package/src/core/manifest.ts +0 -38
- package/src/core/preprocessor.ts +0 -58
- package/src/core/sanitizer.ts +0 -43
- package/src/core/tuner.ts +0 -68
- package/src/core/types.ts +0 -83
- package/src/core/validator.ts +0 -63
- package/src/index.ts +0 -13
- package/src/utils/entropy.ts +0 -66
- package/src/utils/hash.ts +0 -10
- package/src/utils/ssim.ts +0 -56
- package/src/utils/worker.ts +0 -73
- package/src/vite-plugin.ts +0 -111
package/src/adaptive/tier.ts
DELETED
|
@@ -1,34 +0,0 @@
|
|
|
1
|
-
import type { ManifestEntry, QualityTier } from '../core/types.ts'
|
|
2
|
-
|
|
3
|
-
export function resolveTierSrc(metadata: ManifestEntry, tier: QualityTier): string {
|
|
4
|
-
const tierSrc = metadata.tiers[tier]
|
|
5
|
-
if (tierSrc) return tierSrc
|
|
6
|
-
|
|
7
|
-
const tierOrder: QualityTier[] = ['ultra', 'high', 'medium', 'low']
|
|
8
|
-
const tierIndex = tierOrder.indexOf(tier)
|
|
9
|
-
|
|
10
|
-
for (let i = tierIndex - 1; i >= 0; i--) {
|
|
11
|
-
const fallbackTier = tierOrder[i]
|
|
12
|
-
if (!fallbackTier) break
|
|
13
|
-
const fallback = metadata.tiers[fallbackTier]
|
|
14
|
-
if (fallback) return fallback
|
|
15
|
-
}
|
|
16
|
-
|
|
17
|
-
return metadata.src
|
|
18
|
-
}
|
|
19
|
-
|
|
20
|
-
export function resolveVariantsForTier(
|
|
21
|
-
metadata: ManifestEntry,
|
|
22
|
-
tier: QualityTier,
|
|
23
|
-
): ManifestEntry['variants'] {
|
|
24
|
-
const tierWidths: Record<QualityTier, number> = {
|
|
25
|
-
ultra: 1920,
|
|
26
|
-
high: 1024,
|
|
27
|
-
medium: 768,
|
|
28
|
-
low: 480,
|
|
29
|
-
}
|
|
30
|
-
|
|
31
|
-
const maxWidth = tierWidths[tier]
|
|
32
|
-
|
|
33
|
-
return metadata.variants.filter((v) => v.width <= maxWidth)
|
|
34
|
-
}
|
|
@@ -1,218 +0,0 @@
|
|
|
1
|
-
import { type ImgHTMLAttributes, useCallback, useEffect, useRef, useState } from 'react'
|
|
2
|
-
import { getDeviceFingerprint, listenForChanges } from '../adaptive/fingerprint.ts'
|
|
3
|
-
import { PredictiveLoader } from '../adaptive/predictive.ts'
|
|
4
|
-
import { isAnimatedFormat } from '../core/formats.ts'
|
|
5
|
-
import type { ManifestEntry, QualityTier } from '../core/types.ts'
|
|
6
|
-
|
|
7
|
-
export interface GImageProps
|
|
8
|
-
extends Omit<ImgHTMLAttributes<HTMLImageElement>, 'src' | 'srcSet' | 'width' | 'height'> {
|
|
9
|
-
src: ManifestEntry | string
|
|
10
|
-
alt: string
|
|
11
|
-
priority?: boolean
|
|
12
|
-
sizes?: string
|
|
13
|
-
disableAdaptive?: boolean
|
|
14
|
-
placeholder?: 'blur' | 'none'
|
|
15
|
-
onLoad?: () => void
|
|
16
|
-
onError?: () => void
|
|
17
|
-
}
|
|
18
|
-
|
|
19
|
-
interface LoadState {
|
|
20
|
-
loaded: boolean
|
|
21
|
-
error: boolean
|
|
22
|
-
currentTier: QualityTier
|
|
23
|
-
}
|
|
24
|
-
|
|
25
|
-
export default function GImage({
|
|
26
|
-
src,
|
|
27
|
-
alt,
|
|
28
|
-
priority = false,
|
|
29
|
-
sizes = '100vw',
|
|
30
|
-
disableAdaptive = false,
|
|
31
|
-
placeholder: placeholderMode = 'blur',
|
|
32
|
-
className,
|
|
33
|
-
style,
|
|
34
|
-
loading: loadingProp,
|
|
35
|
-
onLoad,
|
|
36
|
-
onError,
|
|
37
|
-
...imgProps
|
|
38
|
-
}: GImageProps) {
|
|
39
|
-
const imgRef = useRef<HTMLImageElement>(null)
|
|
40
|
-
const containerRef = useRef<HTMLDivElement>(null)
|
|
41
|
-
const predictiveRef = useRef<PredictiveLoader | null>(null)
|
|
42
|
-
const [loadState, setLoadState] = useState<LoadState>({
|
|
43
|
-
loaded: false,
|
|
44
|
-
error: false,
|
|
45
|
-
currentTier: 'ultra',
|
|
46
|
-
})
|
|
47
|
-
|
|
48
|
-
const metadata: ManifestEntry | null =
|
|
49
|
-
typeof src === 'string'
|
|
50
|
-
? null
|
|
51
|
-
: {
|
|
52
|
-
src: src.src,
|
|
53
|
-
width: src.width,
|
|
54
|
-
height: src.height,
|
|
55
|
-
format: src.format,
|
|
56
|
-
placeholder: src.placeholder,
|
|
57
|
-
variants: src.variants,
|
|
58
|
-
tiers: src.tiers,
|
|
59
|
-
}
|
|
60
|
-
|
|
61
|
-
useEffect(() => {
|
|
62
|
-
if (disableAdaptive || !metadata) {
|
|
63
|
-
setLoadState((prev) => ({ ...prev, currentTier: 'ultra' }))
|
|
64
|
-
return
|
|
65
|
-
}
|
|
66
|
-
|
|
67
|
-
const fp = getDeviceFingerprint()
|
|
68
|
-
setLoadState((prev) => ({ ...prev, currentTier: fp.tier }))
|
|
69
|
-
|
|
70
|
-
const cleanup = listenForChanges((newFp) => {
|
|
71
|
-
setLoadState((prev) => ({ ...prev, currentTier: newFp.tier }))
|
|
72
|
-
})
|
|
73
|
-
|
|
74
|
-
return cleanup
|
|
75
|
-
}, [disableAdaptive, metadata])
|
|
76
|
-
|
|
77
|
-
useEffect(() => {
|
|
78
|
-
if (priority || loadingProp === 'eager') {
|
|
79
|
-
setLoadState((prev) => ({ ...prev, loaded: true }))
|
|
80
|
-
return
|
|
81
|
-
}
|
|
82
|
-
|
|
83
|
-
if (!containerRef.current) return
|
|
84
|
-
|
|
85
|
-
predictiveRef.current = new PredictiveLoader()
|
|
86
|
-
predictiveRef.current.observe(containerRef.current, () => {
|
|
87
|
-
setLoadState((prev) => ({ ...prev, loaded: true }))
|
|
88
|
-
})
|
|
89
|
-
|
|
90
|
-
return () => {
|
|
91
|
-
predictiveRef.current?.destroy()
|
|
92
|
-
}
|
|
93
|
-
}, [priority, loadingProp])
|
|
94
|
-
|
|
95
|
-
const handleLoad = useCallback(() => {
|
|
96
|
-
setLoadState((prev) => ({ ...prev, loaded: true }))
|
|
97
|
-
onLoad?.()
|
|
98
|
-
}, [onLoad])
|
|
99
|
-
|
|
100
|
-
const handleError = useCallback(() => {
|
|
101
|
-
setLoadState((prev) => ({ ...prev, error: true }))
|
|
102
|
-
onError?.()
|
|
103
|
-
}, [onError])
|
|
104
|
-
|
|
105
|
-
if (!metadata) {
|
|
106
|
-
return (
|
|
107
|
-
<img
|
|
108
|
-
ref={imgRef}
|
|
109
|
-
{...imgProps}
|
|
110
|
-
src={typeof src === 'string' ? src : ''}
|
|
111
|
-
alt={alt ?? ''}
|
|
112
|
-
className={className}
|
|
113
|
-
style={style}
|
|
114
|
-
loading={priority ? 'eager' : (loadingProp ?? 'lazy')}
|
|
115
|
-
onLoad={handleLoad}
|
|
116
|
-
onError={handleError}
|
|
117
|
-
/>
|
|
118
|
-
)
|
|
119
|
-
}
|
|
120
|
-
|
|
121
|
-
const aspectRatio =
|
|
122
|
-
metadata.width && metadata.height ? (metadata.height / metadata.width) * 100 : 0
|
|
123
|
-
|
|
124
|
-
const tier = loadState.currentTier
|
|
125
|
-
const tierVariants = metadata.variants.filter((v) => {
|
|
126
|
-
const tierWidths: Record<QualityTier, number> = {
|
|
127
|
-
ultra: 99999,
|
|
128
|
-
high: 1920,
|
|
129
|
-
medium: 1024,
|
|
130
|
-
low: 768,
|
|
131
|
-
}
|
|
132
|
-
return v.width <= tierWidths[tier]
|
|
133
|
-
})
|
|
134
|
-
|
|
135
|
-
const hasPlaceholder = placeholderMode === 'blur' && !!metadata.placeholder && !loadState.loaded
|
|
136
|
-
const isAnimated = isAnimatedFormat(metadata.format)
|
|
137
|
-
|
|
138
|
-
const pictureContent = (
|
|
139
|
-
<picture>
|
|
140
|
-
{!isAnimated && tierVariants.length > 0 && (
|
|
141
|
-
<>
|
|
142
|
-
<source
|
|
143
|
-
type="image/avif"
|
|
144
|
-
srcSet={tierVariants
|
|
145
|
-
.filter((v) => v.format === 'avif')
|
|
146
|
-
.map((v) => `${v.src} ${v.width}w`)
|
|
147
|
-
.join(', ')}
|
|
148
|
-
sizes={sizes}
|
|
149
|
-
/>
|
|
150
|
-
<source
|
|
151
|
-
type="image/webp"
|
|
152
|
-
srcSet={tierVariants
|
|
153
|
-
.filter((v) => v.format === 'webp')
|
|
154
|
-
.map((v) => `${v.src} ${v.width}w`)
|
|
155
|
-
.join(', ')}
|
|
156
|
-
sizes={sizes}
|
|
157
|
-
/>
|
|
158
|
-
</>
|
|
159
|
-
)}
|
|
160
|
-
<img
|
|
161
|
-
ref={imgRef}
|
|
162
|
-
{...imgProps}
|
|
163
|
-
src={tierVariants.find((v) => v.format === 'jpeg')?.src ?? metadata.src}
|
|
164
|
-
srcSet={
|
|
165
|
-
tierVariants
|
|
166
|
-
.filter((v) => v.format === 'jpeg' || v.format === 'png')
|
|
167
|
-
.map((v) => `${v.src} ${v.width}w`)
|
|
168
|
-
.join(', ') || undefined
|
|
169
|
-
}
|
|
170
|
-
sizes={sizes}
|
|
171
|
-
alt={alt ?? ''}
|
|
172
|
-
width={metadata.width}
|
|
173
|
-
height={metadata.height}
|
|
174
|
-
loading={loadState.loaded && !priority ? 'lazy' : 'eager'}
|
|
175
|
-
fetchPriority={priority ? 'high' : undefined}
|
|
176
|
-
decoding={priority ? 'auto' : 'async'}
|
|
177
|
-
className={className}
|
|
178
|
-
style={{
|
|
179
|
-
width: '100%',
|
|
180
|
-
height: 'auto',
|
|
181
|
-
display: 'block',
|
|
182
|
-
background: hasPlaceholder ? `${metadata.placeholder} center/cover no-repeat` : undefined,
|
|
183
|
-
filter: hasPlaceholder ? 'blur(20px)' : undefined,
|
|
184
|
-
transition: 'filter 0.4s ease-out, opacity 0.4s ease-out',
|
|
185
|
-
opacity: loadState.loaded ? 1 : 0.99,
|
|
186
|
-
...style,
|
|
187
|
-
}}
|
|
188
|
-
onLoad={handleLoad}
|
|
189
|
-
onError={handleError}
|
|
190
|
-
/>
|
|
191
|
-
</picture>
|
|
192
|
-
)
|
|
193
|
-
|
|
194
|
-
if (aspectRatio > 0) {
|
|
195
|
-
return (
|
|
196
|
-
<div
|
|
197
|
-
ref={containerRef}
|
|
198
|
-
style={{
|
|
199
|
-
position: 'relative',
|
|
200
|
-
width: '100%',
|
|
201
|
-
paddingBottom: `${aspectRatio}%`,
|
|
202
|
-
overflow: 'hidden',
|
|
203
|
-
}}
|
|
204
|
-
>
|
|
205
|
-
<div
|
|
206
|
-
style={{
|
|
207
|
-
position: 'absolute',
|
|
208
|
-
inset: 0,
|
|
209
|
-
}}
|
|
210
|
-
>
|
|
211
|
-
{pictureContent}
|
|
212
|
-
</div>
|
|
213
|
-
</div>
|
|
214
|
-
)
|
|
215
|
-
}
|
|
216
|
-
|
|
217
|
-
return <div ref={containerRef}>{pictureContent}</div>
|
|
218
|
-
}
|
package/src/core/analyzer.ts
DELETED
|
@@ -1,189 +0,0 @@
|
|
|
1
|
-
import sharp from 'sharp'
|
|
2
|
-
import { computeCenterWeight, computeEdgeDensity, computeEntropy } from '../utils/entropy.ts'
|
|
3
|
-
import type { TileAnalysis, TileQuality } from './types.ts'
|
|
4
|
-
|
|
5
|
-
export interface AnalysisResult {
|
|
6
|
-
tiles: TileAnalysis[]
|
|
7
|
-
tileQualities: TileQuality[]
|
|
8
|
-
overallImportance: number
|
|
9
|
-
tileSize: number
|
|
10
|
-
width: number
|
|
11
|
-
height: number
|
|
12
|
-
}
|
|
13
|
-
|
|
14
|
-
function computeSkinRatio(pixels: Uint8Array, width: number, height: number): number {
|
|
15
|
-
let skin = 0
|
|
16
|
-
const total = width * height
|
|
17
|
-
|
|
18
|
-
for (let i = 0; i < total; i++) {
|
|
19
|
-
const r = pixels[i * 3] ?? 0
|
|
20
|
-
const g = pixels[i * 3 + 1] ?? 0
|
|
21
|
-
const b = pixels[i * 3 + 2] ?? 0
|
|
22
|
-
|
|
23
|
-
const cr = (r - 128) * 0.713 + (g - 128) * -0.287 + (b - 128) * -0.426 + 128
|
|
24
|
-
const cb = (r - 128) * -0.169 + (g - 128) * -0.331 + (b - 128) * 0.5 + 128
|
|
25
|
-
|
|
26
|
-
if (cr >= 133 && cr <= 173 && cb >= 77 && cb <= 127) {
|
|
27
|
-
skin++
|
|
28
|
-
}
|
|
29
|
-
}
|
|
30
|
-
|
|
31
|
-
return total > 0 ? skin / total : 0
|
|
32
|
-
}
|
|
33
|
-
|
|
34
|
-
export async function analyzeImage(
|
|
35
|
-
imagePath: string,
|
|
36
|
-
tileSize = 64,
|
|
37
|
-
detectFaces = false,
|
|
38
|
-
): Promise<AnalysisResult> {
|
|
39
|
-
const metadata = await sharp(imagePath).metadata()
|
|
40
|
-
const width = metadata.width ?? 0
|
|
41
|
-
const height = metadata.height ?? 0
|
|
42
|
-
|
|
43
|
-
if (width === 0 || height === 0) {
|
|
44
|
-
throw new Error('Could not read image dimensions')
|
|
45
|
-
}
|
|
46
|
-
|
|
47
|
-
const tilesX = Math.ceil(width / tileSize)
|
|
48
|
-
const tilesY = Math.ceil(height / tileSize)
|
|
49
|
-
const tiles: TileAnalysis[] = []
|
|
50
|
-
|
|
51
|
-
let totalImportance = 0
|
|
52
|
-
|
|
53
|
-
for (let ty = 0; ty < tilesY; ty++) {
|
|
54
|
-
for (let tx = 0; tx < tilesX; tx++) {
|
|
55
|
-
const left = tx * tileSize
|
|
56
|
-
const top = ty * tileSize
|
|
57
|
-
const tileW = Math.min(tileSize, width - left)
|
|
58
|
-
const tileH = Math.min(tileSize, height - top)
|
|
59
|
-
|
|
60
|
-
const buffer = await sharp(imagePath)
|
|
61
|
-
.extract({ left, top, width: tileW, height: tileH })
|
|
62
|
-
.raw()
|
|
63
|
-
.toBuffer()
|
|
64
|
-
|
|
65
|
-
const raw = new Uint8Array(buffer)
|
|
66
|
-
const pixelCount = tileW * tileH
|
|
67
|
-
const gray = new Uint8Array(pixelCount)
|
|
68
|
-
|
|
69
|
-
for (let i = 0; i < pixelCount; i++) {
|
|
70
|
-
const r = raw[i * 3] ?? 0
|
|
71
|
-
const g = raw[i * 3 + 1] ?? 0
|
|
72
|
-
const b = raw[i * 3 + 2] ?? 0
|
|
73
|
-
gray[i] = Math.round(r * 0.299 + g * 0.587 + b * 0.114)
|
|
74
|
-
}
|
|
75
|
-
|
|
76
|
-
const entropy = computeEntropy(gray)
|
|
77
|
-
const edgeDensity = computeEdgeDensity(gray, tileW, tileH)
|
|
78
|
-
const centerWeight = computeCenterWeight(tx, ty, tilesX, tilesY)
|
|
79
|
-
|
|
80
|
-
const normalizedEntropy = entropy / 8.0
|
|
81
|
-
let importance = Math.min(1, normalizedEntropy * 0.4 + edgeDensity * 0.6)
|
|
82
|
-
|
|
83
|
-
if (detectFaces && importance < 0.9) {
|
|
84
|
-
const skinRatio = computeSkinRatio(raw, tileW, tileH)
|
|
85
|
-
if (skinRatio > 0.05) {
|
|
86
|
-
importance = Math.min(1, importance + 0.3)
|
|
87
|
-
}
|
|
88
|
-
}
|
|
89
|
-
|
|
90
|
-
tiles.push({
|
|
91
|
-
x: tx,
|
|
92
|
-
y: ty,
|
|
93
|
-
entropy,
|
|
94
|
-
edgeDensity,
|
|
95
|
-
importance,
|
|
96
|
-
centerWeight,
|
|
97
|
-
})
|
|
98
|
-
|
|
99
|
-
totalImportance += importance
|
|
100
|
-
}
|
|
101
|
-
}
|
|
102
|
-
|
|
103
|
-
const tileQualities = computeTileQualities(tiles)
|
|
104
|
-
|
|
105
|
-
return {
|
|
106
|
-
tiles,
|
|
107
|
-
tileQualities,
|
|
108
|
-
overallImportance: tiles.length > 0 ? totalImportance / tiles.length : 0,
|
|
109
|
-
tileSize,
|
|
110
|
-
width,
|
|
111
|
-
height,
|
|
112
|
-
}
|
|
113
|
-
}
|
|
114
|
-
|
|
115
|
-
function computeTileQualities(tiles: TileAnalysis[]): TileQuality[] {
|
|
116
|
-
const tileQualities: TileQuality[] = []
|
|
117
|
-
|
|
118
|
-
for (const tile of tiles) {
|
|
119
|
-
let importance = tile.importance
|
|
120
|
-
|
|
121
|
-
// Edge-density bonus: protect text and fine details
|
|
122
|
-
if (tile.edgeDensity > 0.3) {
|
|
123
|
-
const bonus = Math.min(0.2, (tile.edgeDensity - 0.3) * 0.5)
|
|
124
|
-
importance = Math.min(1, importance + bonus)
|
|
125
|
-
}
|
|
126
|
-
|
|
127
|
-
// Center-weight bonus: photo subjects tend to be centered
|
|
128
|
-
if (tile.centerWeight > 0.7) {
|
|
129
|
-
importance = Math.min(1, importance + (tile.centerWeight - 0.7) * 0.3)
|
|
130
|
-
}
|
|
131
|
-
|
|
132
|
-
tileQualities.push({
|
|
133
|
-
x: tile.x,
|
|
134
|
-
y: tile.y,
|
|
135
|
-
saliency: importance,
|
|
136
|
-
quality: 0,
|
|
137
|
-
weight: importance ** 2 + 0.1,
|
|
138
|
-
})
|
|
139
|
-
}
|
|
140
|
-
|
|
141
|
-
return tileQualities
|
|
142
|
-
}
|
|
143
|
-
|
|
144
|
-
export function computeTargetQuality(
|
|
145
|
-
analysis: AnalysisResult,
|
|
146
|
-
baseQuality = 80,
|
|
147
|
-
options?: {
|
|
148
|
-
minQuality?: number
|
|
149
|
-
maxQuality?: number
|
|
150
|
-
},
|
|
151
|
-
): number {
|
|
152
|
-
if (analysis.tiles.length === 0) return baseQuality
|
|
153
|
-
|
|
154
|
-
const minQuality = options?.minQuality ?? Math.max(20, baseQuality - 30)
|
|
155
|
-
const maxQuality = options?.maxQuality ?? Math.min(95, baseQuality + 10)
|
|
156
|
-
|
|
157
|
-
const tileQualities = analysis.tileQualities
|
|
158
|
-
|
|
159
|
-
// Compute per-tile quality and weight
|
|
160
|
-
const weighted = tileQualities.map((tq) => ({
|
|
161
|
-
quality: minQuality + tq.saliency * (maxQuality - minQuality),
|
|
162
|
-
weight: tq.weight,
|
|
163
|
-
}))
|
|
164
|
-
|
|
165
|
-
// Sort by quality descending
|
|
166
|
-
weighted.sort((a, b) => b.quality - a.quality)
|
|
167
|
-
|
|
168
|
-
// Top 30% tiles get 70% weight — prioritizes important content
|
|
169
|
-
const splitIndex = Math.max(1, Math.ceil(weighted.length * 0.3))
|
|
170
|
-
const highPriority = weighted.slice(0, splitIndex)
|
|
171
|
-
const lowPriority = weighted.slice(splitIndex)
|
|
172
|
-
|
|
173
|
-
const highW = highPriority.reduce((s, t) => s + t.weight, 0)
|
|
174
|
-
const lowW = lowPriority.reduce((s, t) => s + t.weight, 0)
|
|
175
|
-
|
|
176
|
-
const highAvg = highPriority.reduce((s, t) => s + t.quality * t.weight, 0) / (highW || 1)
|
|
177
|
-
const lowAvg = lowPriority.reduce((s, t) => s + t.quality * t.weight, 0) / (lowW || 1)
|
|
178
|
-
|
|
179
|
-
const weightedQuality = highAvg * 0.7 + lowAvg * 0.3
|
|
180
|
-
|
|
181
|
-
// Variance boost: high variance = mixed content → protect detail
|
|
182
|
-
const qualities = weighted.map((t) => t.quality)
|
|
183
|
-
const mean = qualities.reduce((s, q) => s + q, 0) / qualities.length
|
|
184
|
-
const variance = qualities.reduce((s, q) => s + (q - mean) ** 2, 0) / qualities.length
|
|
185
|
-
const stdDev = Math.sqrt(variance)
|
|
186
|
-
const varianceBoost = stdDev > 12 ? Math.min(5, stdDev * 0.12) : 0
|
|
187
|
-
|
|
188
|
-
return Math.round(Math.min(maxQuality, Math.max(minQuality, weightedQuality + varianceBoost)))
|
|
189
|
-
}
|
package/src/core/encoder.ts
DELETED
|
@@ -1,244 +0,0 @@
|
|
|
1
|
-
import { readFileSync } from 'node:fs'
|
|
2
|
-
import sharp from 'sharp'
|
|
3
|
-
import { contentHash, sriHash } from '../utils/hash.ts'
|
|
4
|
-
import type { AnalysisResult } from './analyzer.ts'
|
|
5
|
-
import { analyzeImage, computeTargetQuality } from './analyzer.ts'
|
|
6
|
-
import { isAnimatedFormat } from './formats.ts'
|
|
7
|
-
import { preprocessImage } from './preprocessor.ts'
|
|
8
|
-
import { sanitizeSvg } from './sanitizer.ts'
|
|
9
|
-
import { autoTuneQuality } from './tuner.ts'
|
|
10
|
-
import type { ImageVariant, ManifestEntry, OutputFormat, QualityTier, TierConfig } from './types.ts'
|
|
11
|
-
import {
|
|
12
|
-
validateFileSize,
|
|
13
|
-
validateImageContent,
|
|
14
|
-
validateOutputFormat,
|
|
15
|
-
validatePath,
|
|
16
|
-
} from './validator.ts'
|
|
17
|
-
|
|
18
|
-
export interface EncodeOptions {
|
|
19
|
-
widths: number[]
|
|
20
|
-
formats: OutputFormat[]
|
|
21
|
-
tiers: Record<QualityTier, TierConfig>
|
|
22
|
-
autoTune: boolean
|
|
23
|
-
adaptive: boolean
|
|
24
|
-
preprocess: boolean
|
|
25
|
-
faceDetection: boolean
|
|
26
|
-
outDir: string
|
|
27
|
-
}
|
|
28
|
-
|
|
29
|
-
export interface EncodeResult {
|
|
30
|
-
entries: Record<string, ManifestEntry>
|
|
31
|
-
}
|
|
32
|
-
|
|
33
|
-
function readFileSafe(filePath: string): Buffer {
|
|
34
|
-
validatePath(filePath)
|
|
35
|
-
const buffer = readFileSync(filePath)
|
|
36
|
-
validateFileSize(buffer.length)
|
|
37
|
-
return buffer
|
|
38
|
-
}
|
|
39
|
-
|
|
40
|
-
async function encodeVariant(
|
|
41
|
-
source: string | Buffer,
|
|
42
|
-
width: number,
|
|
43
|
-
format: OutputFormat,
|
|
44
|
-
quality: number,
|
|
45
|
-
outDir: string,
|
|
46
|
-
): Promise<ImageVariant | null> {
|
|
47
|
-
try {
|
|
48
|
-
validateOutputFormat(format)
|
|
49
|
-
|
|
50
|
-
const img = typeof source === 'string' ? sharp(source) : sharp(source)
|
|
51
|
-
const metadata = await img.metadata()
|
|
52
|
-
|
|
53
|
-
if ((metadata.width ?? 0) <= width) {
|
|
54
|
-
const buffer = await img[format]({ quality }).toBuffer()
|
|
55
|
-
const hash = contentHash(buffer)
|
|
56
|
-
const ext = format === 'jpeg' ? 'jpg' : format
|
|
57
|
-
const filename = `${hash}-${width}.${ext}`
|
|
58
|
-
const outPath = `${outDir}/${filename}`
|
|
59
|
-
|
|
60
|
-
await sharp(buffer).toFile(outPath)
|
|
61
|
-
|
|
62
|
-
return {
|
|
63
|
-
src: filename,
|
|
64
|
-
width,
|
|
65
|
-
format,
|
|
66
|
-
size: buffer.length,
|
|
67
|
-
integrity: sriHash(buffer),
|
|
68
|
-
}
|
|
69
|
-
}
|
|
70
|
-
|
|
71
|
-
const resized =
|
|
72
|
-
typeof source === 'string'
|
|
73
|
-
? await sharp(source).resize(width).toBuffer()
|
|
74
|
-
: await sharp(source).resize(width).toBuffer()
|
|
75
|
-
const encoded = await sharp(resized)[format]({ quality }).toBuffer()
|
|
76
|
-
const hash = contentHash(encoded)
|
|
77
|
-
const ext = format === 'jpeg' ? 'jpg' : format
|
|
78
|
-
const filename = `${hash}-${width}.${ext}`
|
|
79
|
-
const outPath = `${outDir}/${filename}`
|
|
80
|
-
|
|
81
|
-
await sharp(encoded).toFile(outPath)
|
|
82
|
-
|
|
83
|
-
return {
|
|
84
|
-
src: filename,
|
|
85
|
-
width,
|
|
86
|
-
format,
|
|
87
|
-
size: encoded.length,
|
|
88
|
-
integrity: sriHash(encoded),
|
|
89
|
-
}
|
|
90
|
-
} catch {
|
|
91
|
-
return null
|
|
92
|
-
}
|
|
93
|
-
}
|
|
94
|
-
|
|
95
|
-
export async function encodeImage(
|
|
96
|
-
imagePath: string,
|
|
97
|
-
options: EncodeOptions,
|
|
98
|
-
): Promise<ManifestEntry> {
|
|
99
|
-
const buffer = readFileSafe(imagePath)
|
|
100
|
-
const ext = imagePath.split('.').pop() ?? 'jpg'
|
|
101
|
-
const format = validateImageContent(buffer, ext)
|
|
102
|
-
|
|
103
|
-
if (format === 'svg') {
|
|
104
|
-
const content = buffer.toString('utf-8')
|
|
105
|
-
const sanitized = sanitizeSvg(content)
|
|
106
|
-
const hash = contentHash(Buffer.from(sanitized))
|
|
107
|
-
const filename = `${hash}.svg`
|
|
108
|
-
const svgBuffer = Buffer.from(sanitized)
|
|
109
|
-
|
|
110
|
-
return {
|
|
111
|
-
src: filename,
|
|
112
|
-
width: 0,
|
|
113
|
-
height: 0,
|
|
114
|
-
format: 'svg',
|
|
115
|
-
placeholder: '',
|
|
116
|
-
tiers: {} as Record<QualityTier, string>,
|
|
117
|
-
variants: [
|
|
118
|
-
{
|
|
119
|
-
src: filename,
|
|
120
|
-
width: 0,
|
|
121
|
-
format: 'webp',
|
|
122
|
-
size: svgBuffer.length,
|
|
123
|
-
integrity: sriHash(svgBuffer),
|
|
124
|
-
},
|
|
125
|
-
],
|
|
126
|
-
}
|
|
127
|
-
}
|
|
128
|
-
|
|
129
|
-
if (isAnimatedFormat(format)) {
|
|
130
|
-
const img = sharp(buffer, { animated: true })
|
|
131
|
-
const metadata = await img.metadata()
|
|
132
|
-
const width = metadata.width ?? 0
|
|
133
|
-
const height = metadata.height ?? 0
|
|
134
|
-
|
|
135
|
-
const webpBuffer = await img.webp({ quality: 75 }).toBuffer()
|
|
136
|
-
const hash = contentHash(webpBuffer)
|
|
137
|
-
const filename = `${hash}.webp`
|
|
138
|
-
|
|
139
|
-
return {
|
|
140
|
-
src: filename,
|
|
141
|
-
width,
|
|
142
|
-
height,
|
|
143
|
-
format: 'gif',
|
|
144
|
-
placeholder: '',
|
|
145
|
-
tiers: {} as Record<QualityTier, string>,
|
|
146
|
-
variants: [
|
|
147
|
-
{
|
|
148
|
-
src: filename,
|
|
149
|
-
width,
|
|
150
|
-
format: 'webp',
|
|
151
|
-
size: webpBuffer.length,
|
|
152
|
-
integrity: sriHash(webpBuffer),
|
|
153
|
-
},
|
|
154
|
-
],
|
|
155
|
-
}
|
|
156
|
-
}
|
|
157
|
-
|
|
158
|
-
const metadata = await sharp(buffer).metadata()
|
|
159
|
-
const originalWidth = metadata.width ?? 0
|
|
160
|
-
const originalHeight = metadata.height ?? 0
|
|
161
|
-
|
|
162
|
-
let placeholder = ''
|
|
163
|
-
if (originalWidth > 0 && originalHeight > 0) {
|
|
164
|
-
const placeholderBuffer = await sharp(buffer)
|
|
165
|
-
.resize(32, 32, { fit: 'inside' })
|
|
166
|
-
.webp({ quality: 20 })
|
|
167
|
-
.toBuffer()
|
|
168
|
-
placeholder = `data:image/webp;base64,${placeholderBuffer.toString('base64')}`
|
|
169
|
-
}
|
|
170
|
-
|
|
171
|
-
const tiers: Partial<Record<QualityTier, string>> = {}
|
|
172
|
-
const variants: ImageVariant[] = []
|
|
173
|
-
|
|
174
|
-
let analysis: AnalysisResult | null = null
|
|
175
|
-
let preprocessedSource: Buffer | null = null
|
|
176
|
-
|
|
177
|
-
if (options.adaptive || options.preprocess) {
|
|
178
|
-
try {
|
|
179
|
-
analysis = await analyzeImage(imagePath, 64, options.faceDetection)
|
|
180
|
-
} catch {}
|
|
181
|
-
}
|
|
182
|
-
|
|
183
|
-
if (options.preprocess && analysis) {
|
|
184
|
-
try {
|
|
185
|
-
preprocessedSource = await preprocessImage(imagePath, analysis)
|
|
186
|
-
} catch {}
|
|
187
|
-
}
|
|
188
|
-
|
|
189
|
-
const source = preprocessedSource ?? imagePath
|
|
190
|
-
|
|
191
|
-
for (const [tierKey, tierConfig] of Object.entries(options.tiers)) {
|
|
192
|
-
const tier = tierKey as QualityTier
|
|
193
|
-
let quality = tierConfig.quality
|
|
194
|
-
|
|
195
|
-
if (options.adaptive && analysis) {
|
|
196
|
-
try {
|
|
197
|
-
quality = computeTargetQuality(analysis, quality, {
|
|
198
|
-
minQuality: Math.max(20, quality - 30),
|
|
199
|
-
maxQuality: Math.min(95, quality + 10),
|
|
200
|
-
})
|
|
201
|
-
} catch {}
|
|
202
|
-
}
|
|
203
|
-
|
|
204
|
-
if (options.autoTune) {
|
|
205
|
-
try {
|
|
206
|
-
const tuned = await autoTuneQuality(source, 'webp', {
|
|
207
|
-
threshold: 0.97,
|
|
208
|
-
minQuality: Math.max(40, quality - 10),
|
|
209
|
-
maxQuality: Math.min(95, quality + 5),
|
|
210
|
-
})
|
|
211
|
-
quality = tuned.quality
|
|
212
|
-
} catch {}
|
|
213
|
-
}
|
|
214
|
-
|
|
215
|
-
const widths = tierConfig.widths.filter((w) => w <= originalWidth)
|
|
216
|
-
if (widths.length === 0) {
|
|
217
|
-
widths.push(originalWidth)
|
|
218
|
-
}
|
|
219
|
-
|
|
220
|
-
for (const w of widths) {
|
|
221
|
-
for (const fmt of options.formats) {
|
|
222
|
-
const variant = await encodeVariant(source, w, fmt, quality, options.outDir)
|
|
223
|
-
if (variant) {
|
|
224
|
-
variants.push(variant)
|
|
225
|
-
}
|
|
226
|
-
}
|
|
227
|
-
}
|
|
228
|
-
|
|
229
|
-
const bestVariant = variants.find((v) => v.format === 'webp')
|
|
230
|
-
if (bestVariant) {
|
|
231
|
-
tiers[tier] = bestVariant.src
|
|
232
|
-
}
|
|
233
|
-
}
|
|
234
|
-
|
|
235
|
-
return {
|
|
236
|
-
src: imagePath.split('/').pop() ?? 'image',
|
|
237
|
-
width: originalWidth,
|
|
238
|
-
height: originalHeight,
|
|
239
|
-
format,
|
|
240
|
-
placeholder,
|
|
241
|
-
tiers: tiers as Record<QualityTier, string>,
|
|
242
|
-
variants,
|
|
243
|
-
}
|
|
244
|
-
}
|