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.
@@ -1,91 +0,0 @@
1
- interface PredictiveState {
2
- velocity: number
3
- direction: 'up' | 'down' | 'none'
4
- lastScrollY: number
5
- lastTimestamp: number
6
- preloadDistance: number
7
- }
8
-
9
- const MIN_PRELOAD = 600
10
- const MAX_PRELOAD = 3000
11
- const VELOCITY_SAMPLES = 5
12
-
13
- export class PredictiveLoader {
14
- private state: PredictiveState = {
15
- velocity: 0,
16
- direction: 'none',
17
- lastScrollY: 0,
18
- lastTimestamp: Date.now(),
19
- preloadDistance: MIN_PRELOAD,
20
- }
21
-
22
- private velocities: number[] = []
23
- private observer: IntersectionObserver | null = null
24
- constructor() {
25
- if (typeof window !== 'undefined') {
26
- this.state.lastScrollY = window.scrollY
27
- window.addEventListener('scroll', this.onScroll, { passive: true })
28
- }
29
- }
30
-
31
- private onScroll = (): void => {
32
- const now = Date.now()
33
- const dt = Math.max(1, now - this.state.lastTimestamp)
34
- const dy = Math.abs(window.scrollY - this.state.lastScrollY)
35
- const velocity = dy / dt
36
-
37
- this.velocities.push(velocity)
38
- if (this.velocities.length > VELOCITY_SAMPLES) {
39
- this.velocities.shift()
40
- }
41
-
42
- const avgVelocity = this.velocities.reduce((a, b) => a + b, 0) / this.velocities.length
43
-
44
- this.state.velocity = avgVelocity
45
- this.state.direction =
46
- window.scrollY > this.state.lastScrollY
47
- ? 'down'
48
- : window.scrollY < this.state.lastScrollY
49
- ? 'up'
50
- : 'none'
51
- this.state.lastScrollY = window.scrollY
52
- this.state.lastTimestamp = now
53
- this.state.preloadDistance = Math.min(MAX_PRELOAD, MIN_PRELOAD + avgVelocity * 5000)
54
- }
55
-
56
- getPreloadMargin(): string {
57
- return `${this.state.preloadDistance}px`
58
- }
59
-
60
- observe(element: HTMLElement, callback: (entry: IntersectionObserverEntry) => void): void {
61
- this.observer?.disconnect()
62
- this.observer = new IntersectionObserver(
63
- (entries) => {
64
- for (const entry of entries) {
65
- if (entry.isIntersecting) {
66
- callback(entry)
67
- this.observer?.unobserve(entry.target)
68
- }
69
- }
70
- },
71
- {
72
- rootMargin: this.getPreloadMargin(),
73
- threshold: 0.01,
74
- },
75
- )
76
-
77
- this.observer.observe(element)
78
- }
79
-
80
- unobserve(element: HTMLElement): void {
81
- this.observer?.unobserve(element)
82
- }
83
-
84
- destroy(): void {
85
- this.observer?.disconnect()
86
- if (typeof window !== 'undefined') {
87
- window.removeEventListener('scroll', this.onScroll)
88
- }
89
- this.observer = null
90
- }
91
- }
@@ -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
- }
@@ -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
- }