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/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
+ }
@@ -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
+ }