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.
- package/dist/chunk-JJABKWGE.js +705 -0
- package/dist/index.d.ts +19 -0
- package/dist/index.js +348 -0
- package/dist/vite-plugin-CpGEB8EW.d.ts +63 -0
- package/dist/vite-plugin.d.ts +2 -0
- package/dist/vite-plugin.js +6 -0
- package/package.json +18 -10
- package/src/adaptive/fingerprint.ts +0 -117
- package/src/adaptive/predictive.ts +0 -91
- package/src/adaptive/tier.ts +0 -34
- package/src/components/GImage.tsx +0 -218
- package/src/core/analyzer.ts +0 -189
- package/src/core/encoder.ts +0 -244
- package/src/core/formats.ts +0 -97
- package/src/core/manifest.ts +0 -38
- package/src/core/preprocessor.ts +0 -58
- package/src/core/sanitizer.ts +0 -43
- package/src/core/tuner.ts +0 -68
- package/src/core/types.ts +0 -83
- package/src/core/validator.ts +0 -63
- package/src/index.ts +0 -13
- package/src/utils/entropy.ts +0 -66
- package/src/utils/hash.ts +0 -10
- package/src/utils/ssim.ts +0 -56
- package/src/utils/worker.ts +0 -73
- package/src/vite-plugin.ts +0 -111
|
@@ -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
|
-
}
|
package/src/adaptive/tier.ts
DELETED
|
@@ -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
|
-
}
|
package/src/core/analyzer.ts
DELETED
|
@@ -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
|
-
}
|