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
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 gotodev
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
# gotodev-image-optimizer
|
|
2
|
+
|
|
3
|
+
**Content-aware, device-adaptive image optimizer for Vite + React.**
|
|
4
|
+
Surpasses Next.js Image in perceptual quality at equal or smaller file sizes.
|
|
5
|
+
|
|
6
|
+
[](https://www.npmjs.com/package/gotodev-image-optimizer)
|
|
7
|
+
[](https://opensource.org/licenses/MIT)
|
|
8
|
+
[](https://nodejs.org)
|
|
9
|
+
[](https://vitejs.dev)
|
|
10
|
+
[](https://react.dev)
|
|
11
|
+
|
|
12
|
+
---
|
|
13
|
+
|
|
14
|
+
## How it works
|
|
15
|
+
|
|
16
|
+
### Build time (Vite plugin)
|
|
17
|
+
|
|
18
|
+
Every image is divided into 64×64 tiles and analyzed:
|
|
19
|
+
|
|
20
|
+
1. **Analyze** — per-tile Shannon entropy, Sobel edge density, and skin-tone ratio produce an importance map
|
|
21
|
+
2. **Weight** — top 30% most-important tiles drive 70% of the quality decision; center-bias and edge-bonuses refine it
|
|
22
|
+
3. **Preprocess** — high-importance tiles are selectively sharpened (with overlapped boundaries to prevent seams)
|
|
23
|
+
4. **Encode** — each tier/variant is encoded at the perceptually-weighted quality; SSIM auto-tune finds the Pareto-optimal quality/size point
|
|
24
|
+
5. **Emit** — manifest with variants, tiers, LQIP placeholders, and SRI hashes is embedded in the module
|
|
25
|
+
|
|
26
|
+
### Runtime (GImage component)
|
|
27
|
+
|
|
28
|
+
1. **Fingerprint** — reads `effectiveType`, `deviceMemory`, `hardwareConcurrency`, `devicePixelRatio`, `saveData`
|
|
29
|
+
2. **Tier** — scoring algorithm selects `ultra`/`high`/`medium`/`low` per device capability
|
|
30
|
+
3. **Format** — `<picture>` with AVIF, WebP, and JPEG sources
|
|
31
|
+
4. **Load** — IntersectionObserver with scroll-velocity-adaptive preload distance (600–3000px)
|
|
32
|
+
5. **Placeholder** — blur-up CSS transition from 32×32 WebP base64 to full image
|
|
33
|
+
|
|
34
|
+
---
|
|
35
|
+
|
|
36
|
+
## Features
|
|
37
|
+
|
|
38
|
+
**Perceptually-optimized quality** — 64×64 tile saliency drives quality per region. Faces, text, and detail get higher quality; backgrounds compress harder.
|
|
39
|
+
|
|
40
|
+
**Saliency-driven preprocessing** — important tiles are sharpened before encoding, preserving detail where it matters.
|
|
41
|
+
|
|
42
|
+
**Skin-tone face detection** — automatic quality boost around skin-colored regions. Zero extra dependencies.
|
|
43
|
+
|
|
44
|
+
**SSIM auto-tune** — finds the lowest quality where SSIM >= 0.97, saving 20–40% file size with no visible loss.
|
|
45
|
+
|
|
46
|
+
**Device-adaptive delivery** — runtime fingerprinting selects the optimal quality tier for each device.
|
|
47
|
+
|
|
48
|
+
**Automatic format conversion** — AVIF, WebP, and JPEG sources in a `<picture>` element.
|
|
49
|
+
|
|
50
|
+
**Predictive lazy loading** — scroll velocity sampling dynamically adjusts the preload distance.
|
|
51
|
+
|
|
52
|
+
**Blur-up placeholders** — 32×32 WebP base64 with CSS fade-in.
|
|
53
|
+
|
|
54
|
+
**CLS prevention** — fixed-aspect-ratio container from image metadata.
|
|
55
|
+
|
|
56
|
+
---
|
|
57
|
+
|
|
58
|
+
## Install
|
|
59
|
+
|
|
60
|
+
```bash
|
|
61
|
+
npm install gotodev-image-optimizer
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
---
|
|
65
|
+
|
|
66
|
+
## Usage
|
|
67
|
+
|
|
68
|
+
### Vite plugin
|
|
69
|
+
|
|
70
|
+
```ts
|
|
71
|
+
// vite.config.ts
|
|
72
|
+
import { defineConfig } from 'vite'
|
|
73
|
+
import react from '@vitejs/plugin-react'
|
|
74
|
+
import gotodevImageOptimizer from 'gotodev-image-optimizer/vite-plugin'
|
|
75
|
+
|
|
76
|
+
export default defineConfig({
|
|
77
|
+
plugins: [
|
|
78
|
+
react(),
|
|
79
|
+
gotodevImageOptimizer(),
|
|
80
|
+
],
|
|
81
|
+
})
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
### React component
|
|
85
|
+
|
|
86
|
+
```tsx
|
|
87
|
+
import GImage from 'gotodev-image-optimizer'
|
|
88
|
+
import hero from './hero.jpg'
|
|
89
|
+
|
|
90
|
+
function Page() {
|
|
91
|
+
return (
|
|
92
|
+
<GImage
|
|
93
|
+
src={hero}
|
|
94
|
+
alt="Hero banner"
|
|
95
|
+
priority
|
|
96
|
+
sizes="(max-width: 768px) 100vw, 50vw"
|
|
97
|
+
/>
|
|
98
|
+
)
|
|
99
|
+
}
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
All standard `<img>` props work: `className`, `style`, `onLoad`, `onError`, `loading`, etc.
|
|
103
|
+
|
|
104
|
+
---
|
|
105
|
+
|
|
106
|
+
## Options
|
|
107
|
+
|
|
108
|
+
### Plugin options
|
|
109
|
+
|
|
110
|
+
```ts
|
|
111
|
+
gotodevImageOptimizer({
|
|
112
|
+
tiers?: Partial<Record<QualityTier, TierConfig>>
|
|
113
|
+
adaptive?: boolean // default: true
|
|
114
|
+
autoTune?: boolean // default: true
|
|
115
|
+
preprocess?: boolean // default: true — sharpen important tiles before encoding
|
|
116
|
+
faceDetection?: boolean // default: true — boost quality around skin tones
|
|
117
|
+
formats?: OutputFormat[] // default: ['avif', 'webp', 'jpeg']
|
|
118
|
+
maxFileSize?: number // default: 52_428_800 (50MB)
|
|
119
|
+
verbose?: boolean // default: false
|
|
120
|
+
})
|
|
121
|
+
```
|
|
122
|
+
|
|
123
|
+
### GImage props
|
|
124
|
+
|
|
125
|
+
```ts
|
|
126
|
+
interface GImageProps {
|
|
127
|
+
src: string | ImageMetadata // import result or metadata object
|
|
128
|
+
alt: string
|
|
129
|
+
priority?: boolean // eager load + fetchPriority='high'
|
|
130
|
+
sizes?: string // default: '100vw'
|
|
131
|
+
disableAdaptive?: boolean // always deliver highest quality
|
|
132
|
+
placeholder?: 'blur' | 'none' // default: 'blur'
|
|
133
|
+
onLoad?: () => void
|
|
134
|
+
onError?: () => void
|
|
135
|
+
// + all standard img props (className, style, loading, etc.)
|
|
136
|
+
}
|
|
137
|
+
```
|
|
138
|
+
|
|
139
|
+
---
|
|
140
|
+
|
|
141
|
+
## Comparison: next/image vs GImage
|
|
142
|
+
|
|
143
|
+
| Aspect | next/image | GImage |
|
|
144
|
+
|---|---|---|
|
|
145
|
+
| **Quality strategy** | Uniform (e.g. 75) | Perceptually-weighted — important regions drive quality |
|
|
146
|
+
| **Preprocessing** | None | Saliency-guided sharpen — detail preserved where it matters |
|
|
147
|
+
| **Face/subject detection** | None | Skin-tone heuristic — quality boost on faces |
|
|
148
|
+
| **SSIM auto-tune** | None | Smallest file at SSIM >= 0.97 |
|
|
149
|
+
| **Device adaptation** | Responsive srcSet only | Runtime tier switching — CPU/memory/connection-aware |
|
|
150
|
+
| **Predictive loading** | Fixed threshold | Velocity-adaptive — faster scroll = bigger preload zone |
|
|
151
|
+
| **Format pipeline** | AVIF/WebP/JPEG | Same + skin detection + selectable preprocessor |
|
|
152
|
+
|
|
153
|
+
---
|
|
154
|
+
|
|
155
|
+
## Requirements
|
|
156
|
+
|
|
157
|
+
- Node.js >= 22
|
|
158
|
+
- Vite >= 7
|
|
159
|
+
- React >= 19
|
|
160
|
+
|
|
161
|
+
---
|
|
162
|
+
|
|
163
|
+
## License
|
|
164
|
+
|
|
165
|
+
MIT
|
package/package.json
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "gotodev-image-optimizer",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Content-aware, device-adaptive image optimizer for React + Vite projects",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"exports": {
|
|
7
|
+
".": "./src/index.ts",
|
|
8
|
+
"./vite-plugin": "./src/vite-plugin.ts"
|
|
9
|
+
},
|
|
10
|
+
"types": "./src/index.ts",
|
|
11
|
+
"files": [
|
|
12
|
+
"src",
|
|
13
|
+
"!src/**/*.test.ts",
|
|
14
|
+
"!src/**/*.test.tsx",
|
|
15
|
+
"!playground"
|
|
16
|
+
],
|
|
17
|
+
"scripts": {
|
|
18
|
+
"dev": "vite playground",
|
|
19
|
+
"build": "vite build playground",
|
|
20
|
+
"preview": "vite preview playground",
|
|
21
|
+
"typecheck": "tsc --noEmit",
|
|
22
|
+
"lint": "biome check src/",
|
|
23
|
+
"lint:fix": "biome check --apply src/",
|
|
24
|
+
"test": "vitest run",
|
|
25
|
+
"test:watch": "vitest",
|
|
26
|
+
"prepare": "echo 'ready'"
|
|
27
|
+
},
|
|
28
|
+
"license": "MIT",
|
|
29
|
+
"repository": {
|
|
30
|
+
"type": "git",
|
|
31
|
+
"url": "https://github.com/gotodev/image-optimizer.git"
|
|
32
|
+
},
|
|
33
|
+
"engines": {
|
|
34
|
+
"node": ">=22"
|
|
35
|
+
},
|
|
36
|
+
"dependencies": {
|
|
37
|
+
"sharp": "^0.34.5"
|
|
38
|
+
},
|
|
39
|
+
"peerDependencies": {
|
|
40
|
+
"react": "^19.0.0",
|
|
41
|
+
"vite": "^7.0.0"
|
|
42
|
+
},
|
|
43
|
+
"devDependencies": {
|
|
44
|
+
"@biomejs/biome": "^2.4.15",
|
|
45
|
+
"@testing-library/jest-dom": "^6.9.1",
|
|
46
|
+
"@testing-library/react": "^16.3.2",
|
|
47
|
+
"@types/react": "^19.2.14",
|
|
48
|
+
"@vitejs/plugin-react": "^6.0.2",
|
|
49
|
+
"happy-dom": "^20.9.0",
|
|
50
|
+
"react": "^19.2.6",
|
|
51
|
+
"react-dom": "^19.2.6",
|
|
52
|
+
"typescript": "^6.0.3",
|
|
53
|
+
"vite": "^8.0.13",
|
|
54
|
+
"vitest": "^4.1.6"
|
|
55
|
+
}
|
|
56
|
+
}
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
import type { DeviceFingerprint, QualityTier } from '../core/types.ts'
|
|
2
|
+
|
|
3
|
+
function getConnectionType(): 'slow-2g' | '2g' | '3g' | '4g' | 'unknown' {
|
|
4
|
+
try {
|
|
5
|
+
const conn = (navigator as unknown as { connection?: { effectiveType?: string } }).connection
|
|
6
|
+
if (conn?.effectiveType) {
|
|
7
|
+
const type = conn.effectiveType
|
|
8
|
+
if (['slow-2g', '2g', '3g', '4g'].includes(type)) {
|
|
9
|
+
return type as 'slow-2g' | '2g' | '3g' | '4g'
|
|
10
|
+
}
|
|
11
|
+
}
|
|
12
|
+
} catch {}
|
|
13
|
+
return 'unknown'
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function getDeviceMemory(): number {
|
|
17
|
+
try {
|
|
18
|
+
const mem = (navigator as unknown as { deviceMemory?: number }).deviceMemory
|
|
19
|
+
return mem ?? 4
|
|
20
|
+
} catch {
|
|
21
|
+
return 4
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function getHardwareConcurrency(): number {
|
|
26
|
+
try {
|
|
27
|
+
return navigator.hardwareConcurrency ?? 4
|
|
28
|
+
} catch {
|
|
29
|
+
return 4
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function getDevicePixelRatio(): number {
|
|
34
|
+
try {
|
|
35
|
+
return window.devicePixelRatio ?? 1
|
|
36
|
+
} catch {
|
|
37
|
+
return 1
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function getSaveData(): boolean {
|
|
42
|
+
try {
|
|
43
|
+
const sd = (navigator as unknown as { connection?: { saveData?: boolean } }).connection
|
|
44
|
+
return sd?.saveData ?? false
|
|
45
|
+
} catch {
|
|
46
|
+
return false
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function computeTier(fp: Omit<DeviceFingerprint, 'tier'>): QualityTier {
|
|
51
|
+
if (fp.saveData) return 'low'
|
|
52
|
+
|
|
53
|
+
if (fp.effectiveType === 'slow-2g' || fp.effectiveType === '2g') {
|
|
54
|
+
return 'low'
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const score =
|
|
58
|
+
(fp.effectiveType === '4g' ? 40 : fp.effectiveType === '3g' ? 20 : 0) +
|
|
59
|
+
Math.min(fp.deviceMemory / 8, 1) * 25 +
|
|
60
|
+
Math.min(fp.hardwareConcurrency / 8, 1) * 20 +
|
|
61
|
+
Math.min(fp.devicePixelRatio / 3, 1) * 15
|
|
62
|
+
|
|
63
|
+
if (score >= 80) return 'ultra'
|
|
64
|
+
if (score >= 55) return 'high'
|
|
65
|
+
if (score >= 30) return 'medium'
|
|
66
|
+
return 'low'
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
let cachedFingerprint: DeviceFingerprint | null = null
|
|
70
|
+
|
|
71
|
+
export function getDeviceFingerprint(): DeviceFingerprint {
|
|
72
|
+
if (cachedFingerprint) return cachedFingerprint
|
|
73
|
+
|
|
74
|
+
const base = {
|
|
75
|
+
effectiveType: getConnectionType(),
|
|
76
|
+
deviceMemory: getDeviceMemory(),
|
|
77
|
+
hardwareConcurrency: getHardwareConcurrency(),
|
|
78
|
+
devicePixelRatio: getDevicePixelRatio(),
|
|
79
|
+
saveData: getSaveData(),
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
cachedFingerprint = {
|
|
83
|
+
...base,
|
|
84
|
+
tier: computeTier(base),
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
return cachedFingerprint
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export function clearFingerprintCache(): void {
|
|
91
|
+
cachedFingerprint = null
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export function listenForChanges(onChange: (fp: DeviceFingerprint) => void): () => void {
|
|
95
|
+
const handler = () => {
|
|
96
|
+
clearFingerprintCache()
|
|
97
|
+
onChange(getDeviceFingerprint())
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
try {
|
|
101
|
+
const conn = (
|
|
102
|
+
navigator as unknown as {
|
|
103
|
+
connection?: { addEventListener?: (type: string, handler: () => void) => void }
|
|
104
|
+
}
|
|
105
|
+
).connection
|
|
106
|
+
conn?.addEventListener?.('change', handler)
|
|
107
|
+
return () => {
|
|
108
|
+
try {
|
|
109
|
+
;(
|
|
110
|
+
conn as { removeEventListener?: (t: string, h: () => void) => void }
|
|
111
|
+
)?.removeEventListener?.('change', handler)
|
|
112
|
+
} catch {}
|
|
113
|
+
}
|
|
114
|
+
} catch {
|
|
115
|
+
return () => {}
|
|
116
|
+
}
|
|
117
|
+
}
|
|
@@ -0,0 +1,91 @@
|
|
|
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
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
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
|
+
}
|
|
@@ -0,0 +1,218 @@
|
|
|
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
|
+
}
|