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,189 @@
|
|
|
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
|
+
}
|
|
@@ -0,0 +1,244 @@
|
|
|
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
|
+
}
|
|
@@ -0,0 +1,97 @@
|
|
|
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
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
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
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
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
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
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
|
+
}
|