gotodev-image-optimizer 0.1.0 → 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 +18 -10
- 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/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
|
-
}
|
package/src/core/formats.ts
DELETED
|
@@ -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
|
-
}
|
package/src/core/manifest.ts
DELETED
|
@@ -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
|
-
}
|
package/src/core/preprocessor.ts
DELETED
|
@@ -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
|
-
}
|
package/src/core/sanitizer.ts
DELETED
|
@@ -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
|
-
}
|
package/src/core/validator.ts
DELETED
|
@@ -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
|
-
}
|