poops-images 1.0.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 +598 -0
- package/lib/analyze.js +177 -0
- package/lib/cache.js +91 -0
- package/lib/config.js +159 -0
- package/lib/discover.js +34 -0
- package/lib/formats.js +107 -0
- package/lib/processor.js +463 -0
- package/lib/sizes.js +97 -0
- package/lib/svg.js +81 -0
- package/lib/utils/format.js +43 -0
- package/lib/utils/log.js +49 -0
- package/package.json +50 -0
- package/poops-images.js +169 -0
package/lib/analyze.js
ADDED
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
import path from 'node:path'
|
|
2
|
+
import sharp from 'sharp'
|
|
3
|
+
import exifReader from 'exif-reader'
|
|
4
|
+
import { hasTransparency } from './formats.js'
|
|
5
|
+
import { dmsToDecimal, formatDms } from './utils/format.js'
|
|
6
|
+
import log from './utils/log.js'
|
|
7
|
+
|
|
8
|
+
function formatExposure(value) {
|
|
9
|
+
if (value >= 1) return `${value}s`
|
|
10
|
+
return `1/${Math.round(1 / value)}`
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function extractExif(raw) {
|
|
14
|
+
if (!raw) return null
|
|
15
|
+
|
|
16
|
+
const image = raw.Image || {}
|
|
17
|
+
const photo = raw.Photo || {}
|
|
18
|
+
const gps = raw.GPSInfo || {}
|
|
19
|
+
|
|
20
|
+
const result = {
|
|
21
|
+
make: image.Make || null,
|
|
22
|
+
model: image.Model || null,
|
|
23
|
+
orientation: image.Orientation || null,
|
|
24
|
+
resolution: (image.XResolution || image.YResolution)
|
|
25
|
+
? { x: image.XResolution, y: image.YResolution }
|
|
26
|
+
: null,
|
|
27
|
+
dateTime: image.DateTime || null,
|
|
28
|
+
offsetTime: photo.OffsetTime || null,
|
|
29
|
+
fNumber: photo.FNumber || null,
|
|
30
|
+
exposure: photo.ExposureTime != null
|
|
31
|
+
? { value: photo.ExposureTime, formatted: formatExposure(photo.ExposureTime) }
|
|
32
|
+
: null,
|
|
33
|
+
iso: photo.ISOSpeedRatings || null,
|
|
34
|
+
focalLength: photo.FocalLength || null,
|
|
35
|
+
focalLength35mm: photo.FocalLengthIn35mmFilm || null,
|
|
36
|
+
flash: photo.Flash != null ? !!(photo.Flash & 1) : null,
|
|
37
|
+
lensModel: photo.LensModel || null,
|
|
38
|
+
software: image.Software || null,
|
|
39
|
+
gps: null,
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
if (gps.GPSLatitude && gps.GPSLongitude) {
|
|
43
|
+
const latDecimal = dmsToDecimal(gps.GPSLatitude, gps.GPSLatitudeRef)
|
|
44
|
+
const lonDecimal = dmsToDecimal(gps.GPSLongitude, gps.GPSLongitudeRef)
|
|
45
|
+
|
|
46
|
+
result.gps = {
|
|
47
|
+
latitude: {
|
|
48
|
+
degrees: gps.GPSLatitude,
|
|
49
|
+
ref: gps.GPSLatitudeRef,
|
|
50
|
+
decimal: latDecimal,
|
|
51
|
+
formatted: formatDms(gps.GPSLatitude, gps.GPSLatitudeRef),
|
|
52
|
+
},
|
|
53
|
+
longitude: {
|
|
54
|
+
degrees: gps.GPSLongitude,
|
|
55
|
+
ref: gps.GPSLongitudeRef,
|
|
56
|
+
decimal: lonDecimal,
|
|
57
|
+
formatted: formatDms(gps.GPSLongitude, gps.GPSLongitudeRef),
|
|
58
|
+
},
|
|
59
|
+
altitude: gps.GPSAltitude != null
|
|
60
|
+
? { value: Math.round(gps.GPSAltitude * 100) / 100, ref: gps.GPSAltitudeRef }
|
|
61
|
+
: null,
|
|
62
|
+
direction: gps.GPSImgDirection != null
|
|
63
|
+
? Math.round(gps.GPSImgDirection * 100) / 100
|
|
64
|
+
: null,
|
|
65
|
+
speed: gps.GPSSpeed != null
|
|
66
|
+
? { value: Math.round(gps.GPSSpeed * 100) / 100, unit: gps.GPSSpeedRef === 'K' ? 'km/h' : gps.GPSSpeedRef === 'M' ? 'mph' : 'knots' }
|
|
67
|
+
: null,
|
|
68
|
+
dateTime: null,
|
|
69
|
+
googleMapsUrl: `https://www.google.com/maps?q=${latDecimal},${lonDecimal}`,
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
if (gps.GPSDateStamp && gps.GPSTimeStamp) {
|
|
73
|
+
const [year, month, day] = gps.GPSDateStamp.split(':')
|
|
74
|
+
const [hours, minutes, seconds] = gps.GPSTimeStamp
|
|
75
|
+
const h = String(Math.floor(hours)).padStart(2, '0')
|
|
76
|
+
const m = String(Math.floor(minutes)).padStart(2, '0')
|
|
77
|
+
const s = String(Math.floor(seconds)).padStart(2, '0')
|
|
78
|
+
result.gps.dateTime = `${year}-${month}-${day}T${h}:${m}:${s}Z`
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
return result
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export async function analyzeImage(inputPath, config, stat) {
|
|
86
|
+
const relativePath = path.relative(config.in, inputPath)
|
|
87
|
+
const parsed = path.parse(relativePath)
|
|
88
|
+
const originalExt = parsed.ext.replace('.', '').toLowerCase()
|
|
89
|
+
|
|
90
|
+
// Read metadata
|
|
91
|
+
let metadata
|
|
92
|
+
try {
|
|
93
|
+
metadata = await sharp(inputPath).metadata()
|
|
94
|
+
} catch (err) {
|
|
95
|
+
log({ tag: 'error', text: `Failed to read metadata: ${err.message}`, link: relativePath })
|
|
96
|
+
return null
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// Parse EXIF data if available — extract only important fields
|
|
100
|
+
let exif = null
|
|
101
|
+
if (metadata.exif) {
|
|
102
|
+
try {
|
|
103
|
+
exif = extractExif(exifReader(metadata.exif))
|
|
104
|
+
} catch {
|
|
105
|
+
// Malformed EXIF — ignore
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// Normalize base format for web output
|
|
110
|
+
const normalizedExt = { jpeg: 'jpg', tif: 'tiff' }[originalExt] || originalExt
|
|
111
|
+
let outputExt = normalizedExt
|
|
112
|
+
let transparent = false
|
|
113
|
+
|
|
114
|
+
// Formats that support alpha and need transparency detection
|
|
115
|
+
const alphaFormats = new Set(['png', 'tiff', 'heic', 'heif', 'gif'])
|
|
116
|
+
|
|
117
|
+
// Check transparency for alpha-capable formats (needed for default + smart modes)
|
|
118
|
+
const formatIncludesSmart = config.format === 'smart' ||
|
|
119
|
+
(Array.isArray(config.format) && config.format.includes('smart'))
|
|
120
|
+
|
|
121
|
+
const needsTransparencyCheck =
|
|
122
|
+
alphaFormats.has(normalizedExt) &&
|
|
123
|
+
(config.format === false || formatIncludesSmart)
|
|
124
|
+
|
|
125
|
+
if (needsTransparencyCheck) {
|
|
126
|
+
transparent = await hasTransparency(sharp(inputPath))
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// Formats that aren't web-native and need normalization to jpg/png
|
|
130
|
+
const nonWebFormats = new Set(['tiff', 'heic', 'heif', 'gif'])
|
|
131
|
+
|
|
132
|
+
// Default mode: normalize non-web formats
|
|
133
|
+
if (config.format === false) {
|
|
134
|
+
if (originalExt === 'png' && !transparent) {
|
|
135
|
+
outputExt = 'jpg'
|
|
136
|
+
log({ tag: 'image', text: 'Opaque PNG \u2192 JPEG:', link: relativePath })
|
|
137
|
+
} else if (nonWebFormats.has(normalizedExt)) {
|
|
138
|
+
const label = normalizedExt.toUpperCase()
|
|
139
|
+
if (transparent) {
|
|
140
|
+
outputExt = 'png'
|
|
141
|
+
log({ tag: 'image', text: `Transparent ${label} \u2192 PNG:`, link: relativePath })
|
|
142
|
+
} else {
|
|
143
|
+
outputExt = 'jpg'
|
|
144
|
+
log({ tag: 'image', text: `${label} \u2192 JPEG:`, link: relativePath })
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// Build effective sizes list — include original unless skipOriginal
|
|
150
|
+
let effectiveSizes = config.sizes
|
|
151
|
+
if (!config.skipOriginal) {
|
|
152
|
+
const hasPassthrough = effectiveSizes.some(s => s.width === 0 && s.height === 0)
|
|
153
|
+
if (!hasPassthrough) {
|
|
154
|
+
effectiveSizes = [{ name: '', width: 0, height: 0, crop: false }, ...effectiveSizes]
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// EXIF orientations 5-8 swap width/height
|
|
159
|
+
const swapped = metadata.orientation >= 5
|
|
160
|
+
const sourceWidth = swapped ? metadata.height : metadata.width
|
|
161
|
+
const sourceHeight = swapped ? metadata.width : metadata.height
|
|
162
|
+
|
|
163
|
+
return {
|
|
164
|
+
inputPath,
|
|
165
|
+
relativePath,
|
|
166
|
+
parsed,
|
|
167
|
+
originalExt,
|
|
168
|
+
outputExt,
|
|
169
|
+
transparent,
|
|
170
|
+
sourceWidth,
|
|
171
|
+
sourceHeight,
|
|
172
|
+
exif,
|
|
173
|
+
mtime: stat.mtimeMs,
|
|
174
|
+
fileSize: stat.size,
|
|
175
|
+
effectiveSizes
|
|
176
|
+
}
|
|
177
|
+
}
|
package/lib/cache.js
ADDED
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import fs from 'node:fs'
|
|
2
|
+
import path from 'node:path'
|
|
3
|
+
|
|
4
|
+
const CACHE_FILENAME = '.poops-images-cache.json'
|
|
5
|
+
|
|
6
|
+
export default class Cache {
|
|
7
|
+
constructor(outputDir, cacheOption = true) {
|
|
8
|
+
this.outputDir = outputDir
|
|
9
|
+
this.disabled = cacheOption === false
|
|
10
|
+
|
|
11
|
+
if (this.disabled) {
|
|
12
|
+
this.cachePath = null
|
|
13
|
+
} else if (typeof cacheOption === 'string') {
|
|
14
|
+
this.cachePath = path.isAbsolute(cacheOption)
|
|
15
|
+
? cacheOption
|
|
16
|
+
: path.join(outputDir, cacheOption)
|
|
17
|
+
} else {
|
|
18
|
+
this.cachePath = path.join(outputDir, CACHE_FILENAME)
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
this.data = { configHash: null, entries: {} }
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
load() {
|
|
25
|
+
if (this.disabled) return
|
|
26
|
+
try {
|
|
27
|
+
if (fs.existsSync(this.cachePath)) {
|
|
28
|
+
this.data = JSON.parse(fs.readFileSync(this.cachePath, 'utf-8'))
|
|
29
|
+
}
|
|
30
|
+
} catch {
|
|
31
|
+
this.data = { configHash: null, entries: {} }
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
save() {
|
|
36
|
+
if (this.disabled) return
|
|
37
|
+
fs.mkdirSync(path.dirname(this.cachePath), { recursive: true })
|
|
38
|
+
fs.writeFileSync(this.cachePath, JSON.stringify(this.data, null, 2))
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
getConfigHash() {
|
|
42
|
+
return this.data.configHash
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
setConfigHash(hash) {
|
|
46
|
+
this.data.configHash = hash
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
invalidateAll() {
|
|
50
|
+
// Mark all entries stale (mtime = -1) so shouldSkip returns false,
|
|
51
|
+
// but preserve outputs for stale file cleanup
|
|
52
|
+
for (const key of Object.keys(this.data.entries)) {
|
|
53
|
+
this.data.entries[key].mtime = -1
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
getEntry(relativePath) {
|
|
58
|
+
return this.data.entries[relativePath] || null
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
setEntry(relativePath, entry) {
|
|
62
|
+
this.data.entries[relativePath] = entry
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
removeEntry(relativePath) {
|
|
66
|
+
delete this.data.entries[relativePath]
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
shouldSkip(relativePath, mtime, size) {
|
|
70
|
+
if (this.disabled) return false
|
|
71
|
+
const entry = this.getEntry(relativePath)
|
|
72
|
+
if (!entry) return false
|
|
73
|
+
|
|
74
|
+
if (entry.mtime !== mtime || entry.size !== size) return false
|
|
75
|
+
|
|
76
|
+
// Check all outputs still exist on disk
|
|
77
|
+
if (entry.outputs && Array.isArray(entry.outputs)) {
|
|
78
|
+
for (const output of entry.outputs) {
|
|
79
|
+
const outputPath = typeof output === 'string' ? output : output.path
|
|
80
|
+
if (!fs.existsSync(path.join(this.outputDir, outputPath))) return false
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
return true
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
getOutputs(relativePath) {
|
|
88
|
+
const entry = this.getEntry(relativePath)
|
|
89
|
+
return entry?.outputs || []
|
|
90
|
+
}
|
|
91
|
+
}
|
package/lib/config.js
ADDED
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
import fs from 'node:fs'
|
|
2
|
+
import path from 'node:path'
|
|
3
|
+
import crypto from 'node:crypto'
|
|
4
|
+
import { validateSize } from './sizes.js'
|
|
5
|
+
|
|
6
|
+
// Output formats only — tiff/tif/heic/heif/gif are input formats normalized to jpg/png during processing
|
|
7
|
+
const SUPPORTED_FORMATS = new Set(['jpg', 'jpeg', 'png', 'webp', 'avif'])
|
|
8
|
+
|
|
9
|
+
const DEFAULTS = {
|
|
10
|
+
in: '.',
|
|
11
|
+
out: '.',
|
|
12
|
+
sizes: [],
|
|
13
|
+
quality: {
|
|
14
|
+
jpg: 82,
|
|
15
|
+
webp: 80,
|
|
16
|
+
avif: 60,
|
|
17
|
+
png: 90
|
|
18
|
+
},
|
|
19
|
+
include: '**/*.{jpg,jpeg,png,tiff,tif,webp,heic,heif}',
|
|
20
|
+
exclude: [],
|
|
21
|
+
concurrency: 4,
|
|
22
|
+
format: false,
|
|
23
|
+
skipOriginal: false,
|
|
24
|
+
cache: true
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function loadConfig(configPath) {
|
|
28
|
+
const cwd = process.cwd()
|
|
29
|
+
|
|
30
|
+
let raw = null
|
|
31
|
+
let resolvedPath = null
|
|
32
|
+
|
|
33
|
+
if (configPath) {
|
|
34
|
+
resolvedPath = path.resolve(cwd, configPath)
|
|
35
|
+
if (!fs.existsSync(resolvedPath)) {
|
|
36
|
+
throw new Error(`Config file not found: ${resolvedPath}`)
|
|
37
|
+
}
|
|
38
|
+
raw = JSON.parse(fs.readFileSync(resolvedPath, 'utf-8'))
|
|
39
|
+
} else {
|
|
40
|
+
// Try poops-images.json first
|
|
41
|
+
resolvedPath = path.resolve(cwd, 'poops-images.json')
|
|
42
|
+
if (fs.existsSync(resolvedPath)) {
|
|
43
|
+
raw = JSON.parse(fs.readFileSync(resolvedPath, 'utf-8'))
|
|
44
|
+
} else {
|
|
45
|
+
// Fallback: read images key from poops.json
|
|
46
|
+
resolvedPath = path.resolve(cwd, 'poops.json')
|
|
47
|
+
if (fs.existsSync(resolvedPath)) {
|
|
48
|
+
const poopsConfig = JSON.parse(fs.readFileSync(resolvedPath, 'utf-8'))
|
|
49
|
+
if (poopsConfig.images) {
|
|
50
|
+
raw = poopsConfig.images
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
if (!raw) {
|
|
55
|
+
// Try 💩.json as last fallback
|
|
56
|
+
resolvedPath = path.resolve(cwd, '💩.json')
|
|
57
|
+
if (fs.existsSync(resolvedPath)) {
|
|
58
|
+
const poopsConfig = JSON.parse(fs.readFileSync(resolvedPath, 'utf-8'))
|
|
59
|
+
if (poopsConfig.images) {
|
|
60
|
+
raw = poopsConfig.images
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
if (!raw) {
|
|
66
|
+
throw new Error('No config found. Create poops-images.json or add "images" key to poops.json')
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
return validateConfig(raw)
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export function validateConfig(raw) {
|
|
75
|
+
const config = { ...DEFAULTS, ...raw }
|
|
76
|
+
|
|
77
|
+
if (typeof config.in !== 'string') {
|
|
78
|
+
throw new Error('Config "in" must be a string path')
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
if (typeof config.out !== 'string') {
|
|
82
|
+
throw new Error('Config "out" must be a string path')
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
if (!Array.isArray(config.sizes)) {
|
|
86
|
+
throw new Error('Config "sizes" must be an array')
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// Empty sizes = conversion-only mode (no resize, just format convert)
|
|
90
|
+
if (config.sizes.length === 0) {
|
|
91
|
+
config.sizes = [{ width: 0, height: 0 }]
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
config.sizes = config.sizes.map(validateSize)
|
|
95
|
+
|
|
96
|
+
if (typeof config.quality === 'number') {
|
|
97
|
+
const q = config.quality
|
|
98
|
+
if (q < 1 || q > 100) {
|
|
99
|
+
throw new Error('Config "quality" must be between 1 and 100')
|
|
100
|
+
}
|
|
101
|
+
config.quality = { jpg: q, webp: q, avif: q, png: q }
|
|
102
|
+
} else if (typeof config.quality !== 'object') {
|
|
103
|
+
throw new Error('Config "quality" must be a number or an object')
|
|
104
|
+
} else {
|
|
105
|
+
config.quality = { ...DEFAULTS.quality, ...config.quality }
|
|
106
|
+
for (const [key, val] of Object.entries(config.quality)) {
|
|
107
|
+
if (typeof val !== 'number' || val < 1 || val > 100) {
|
|
108
|
+
throw new Error(`Config "quality.${key}" must be a number between 1 and 100`)
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
if (typeof config.include !== 'string') {
|
|
114
|
+
throw new Error('Config "include" must be a string glob pattern')
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
if (!Array.isArray(config.exclude)) {
|
|
118
|
+
config.exclude = config.exclude ? [config.exclude] : []
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
if (typeof config.concurrency !== 'number' || config.concurrency < 1) {
|
|
122
|
+
config.concurrency = DEFAULTS.concurrency
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
config.skipOriginal = !!config.skipOriginal
|
|
126
|
+
|
|
127
|
+
if (config.cache !== false && config.cache !== true && typeof config.cache !== 'string') {
|
|
128
|
+
throw new Error('Config "cache" must be false, true, or a string path')
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
if (config.format !== false) {
|
|
132
|
+
const fmts = Array.isArray(config.format)
|
|
133
|
+
? config.format
|
|
134
|
+
: typeof config.format === 'string'
|
|
135
|
+
? [config.format]
|
|
136
|
+
: null
|
|
137
|
+
if (fmts === null) {
|
|
138
|
+
throw new Error('Config "format" must be false, a format string, or an array of format strings')
|
|
139
|
+
}
|
|
140
|
+
for (const f of fmts) {
|
|
141
|
+
if (f === 'smart') continue
|
|
142
|
+
if (!SUPPORTED_FORMATS.has(f)) {
|
|
143
|
+
throw new Error(`Unsupported format: "${f}"`)
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
return config
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
export function configHash(config) {
|
|
152
|
+
const hashable = {
|
|
153
|
+
sizes: config.sizes,
|
|
154
|
+
format: config.format,
|
|
155
|
+
quality: config.quality,
|
|
156
|
+
skipOriginal: config.skipOriginal
|
|
157
|
+
}
|
|
158
|
+
return crypto.createHash('md5').update(JSON.stringify(hashable)).digest('hex')
|
|
159
|
+
}
|
package/lib/discover.js
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { glob } from 'glob'
|
|
2
|
+
import path from 'node:path'
|
|
3
|
+
|
|
4
|
+
export async function discoverSources(config) {
|
|
5
|
+
const pattern = path.join(config.in, config.include)
|
|
6
|
+
const ignore = [
|
|
7
|
+
...config.exclude.map(e => path.join(config.in, e)),
|
|
8
|
+
// SVG and GIF have dedicated pipelines — exclude from raster discovery
|
|
9
|
+
path.join(config.in, '**/*.svg'),
|
|
10
|
+
path.join(config.in, '**/*.gif')
|
|
11
|
+
]
|
|
12
|
+
return glob(pattern, { ignore, nodir: true })
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export async function discoverSvgs(config) {
|
|
16
|
+
const pattern = path.join(config.in, '**/*.svg')
|
|
17
|
+
const ignore = config.exclude.map(e => path.join(config.in, e))
|
|
18
|
+
return glob(pattern, { ignore, nodir: true })
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export async function discoverGifs(config) {
|
|
22
|
+
const pattern = path.join(config.in, '**/*.gif')
|
|
23
|
+
const ignore = config.exclude.map(e => path.join(config.in, e))
|
|
24
|
+
return glob(pattern, { ignore, nodir: true })
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export async function discoverAll(config) {
|
|
28
|
+
const [raster, svg, gif] = await Promise.all([
|
|
29
|
+
discoverSources(config),
|
|
30
|
+
discoverSvgs(config),
|
|
31
|
+
discoverGifs(config)
|
|
32
|
+
])
|
|
33
|
+
return { raster, svg, gif }
|
|
34
|
+
}
|
package/lib/formats.js
ADDED
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import sharp from 'sharp'
|
|
2
|
+
|
|
3
|
+
const FORMAT_MAP = {
|
|
4
|
+
jpg: 'jpeg',
|
|
5
|
+
jpeg: 'jpeg',
|
|
6
|
+
png: 'png',
|
|
7
|
+
webp: 'webp',
|
|
8
|
+
avif: 'avif',
|
|
9
|
+
gif: 'gif',
|
|
10
|
+
tiff: 'tiff',
|
|
11
|
+
tif: 'tiff',
|
|
12
|
+
heic: 'heif',
|
|
13
|
+
heif: 'heif'
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const EXT_MAP = {
|
|
17
|
+
jpeg: 'jpg',
|
|
18
|
+
jpg: 'jpg',
|
|
19
|
+
png: 'png',
|
|
20
|
+
webp: 'webp',
|
|
21
|
+
avif: 'avif',
|
|
22
|
+
gif: 'gif',
|
|
23
|
+
tiff: 'tiff',
|
|
24
|
+
tif: 'tif',
|
|
25
|
+
heic: 'heic',
|
|
26
|
+
heif: 'heif'
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function toSharpFormat(ext) {
|
|
30
|
+
return FORMAT_MAP[ext.toLowerCase()] || ext.toLowerCase()
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function toFileExt(format) {
|
|
34
|
+
return EXT_MAP[format.toLowerCase()] || format.toLowerCase()
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function getQuality(format, qualityConfig) {
|
|
38
|
+
const ext = toFileExt(format)
|
|
39
|
+
return qualityConfig[ext] || qualityConfig[format] || 80
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export async function hasTransparency(sharpInstance) {
|
|
43
|
+
const { channels, hasAlpha } = await sharpInstance.metadata()
|
|
44
|
+
if (!hasAlpha || channels < 4) return false
|
|
45
|
+
|
|
46
|
+
const { channels: channelStats } = await sharpInstance.stats()
|
|
47
|
+
// Alpha is the last channel — if its min is 255, every pixel is fully opaque
|
|
48
|
+
const alphaStats = channelStats[channels - 1]
|
|
49
|
+
return alphaStats.min < 255
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export async function convertFormat(sharpInstance, targetFormat, qualityConfig) {
|
|
53
|
+
const fmt = toSharpFormat(targetFormat)
|
|
54
|
+
const quality = getQuality(targetFormat, qualityConfig)
|
|
55
|
+
|
|
56
|
+
const opts = { quality }
|
|
57
|
+
if (fmt === 'png') {
|
|
58
|
+
opts.compressionLevel = Math.round((100 - quality) / 100 * 9)
|
|
59
|
+
delete opts.quality
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const buffer = await sharpInstance.clone().toFormat(fmt, opts).toBuffer()
|
|
63
|
+
return buffer
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export async function resolveTargetFormats(outputExt, transparent, rawData, formatConfig, qualityConfig) {
|
|
67
|
+
const preEncoded = new Map()
|
|
68
|
+
|
|
69
|
+
// Default: just the normalized base format
|
|
70
|
+
if (formatConfig === false) return { formats: [outputExt], preEncoded }
|
|
71
|
+
|
|
72
|
+
// Expand format config to array
|
|
73
|
+
const fmts = typeof formatConfig === 'string' ? [formatConfig] : [...formatConfig]
|
|
74
|
+
|
|
75
|
+
// Resolve 'smart' entries
|
|
76
|
+
const hasSmart = fmts.includes('smart')
|
|
77
|
+
const result = fmts.filter(f => f !== 'smart')
|
|
78
|
+
|
|
79
|
+
if (hasSmart) {
|
|
80
|
+
if (transparent) {
|
|
81
|
+
result.push('webp')
|
|
82
|
+
} else {
|
|
83
|
+
// Compare jpg vs webp, pick smaller — keep the winner buffer to avoid re-encoding
|
|
84
|
+
const [jpgBuf, webpBuf] = await Promise.all([
|
|
85
|
+
sharp(rawData)
|
|
86
|
+
.toFormat('jpeg', { quality: getQuality('jpg', qualityConfig) })
|
|
87
|
+
.toBuffer(),
|
|
88
|
+
sharp(rawData)
|
|
89
|
+
.toFormat('webp', { quality: getQuality('webp', qualityConfig) })
|
|
90
|
+
.toBuffer()
|
|
91
|
+
])
|
|
92
|
+
if (webpBuf.length < jpgBuf.length) {
|
|
93
|
+
result.push('webp')
|
|
94
|
+
preEncoded.set('webp', webpBuf)
|
|
95
|
+
} else {
|
|
96
|
+
result.push('jpg')
|
|
97
|
+
preEncoded.set('jpg', jpgBuf)
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// Normalize jpeg → jpg and dedupe
|
|
103
|
+
return {
|
|
104
|
+
formats: [...new Set(result.map(f => f === 'jpeg' ? 'jpg' : f))],
|
|
105
|
+
preEncoded
|
|
106
|
+
}
|
|
107
|
+
}
|