gotodev-image-optimizer 0.1.1 → 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 +16 -8
- 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
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import * as react_jsx_runtime from 'react/jsx-runtime';
|
|
2
|
+
import { ImgHTMLAttributes } from 'react';
|
|
3
|
+
import { M as ManifestEntry } from './vite-plugin-CpGEB8EW.js';
|
|
4
|
+
export { B as BuildManifest, D as DeviceFingerprint, I as ImageFormat, a as ImageMetadata, O as OutputFormat, P as PluginOptions, Q as QualityTier, g as gotodevImageOptimizer } from './vite-plugin-CpGEB8EW.js';
|
|
5
|
+
import 'vite';
|
|
6
|
+
|
|
7
|
+
interface GImageProps extends Omit<ImgHTMLAttributes<HTMLImageElement>, 'src' | 'srcSet' | 'width' | 'height'> {
|
|
8
|
+
src: ManifestEntry | string;
|
|
9
|
+
alt: string;
|
|
10
|
+
priority?: boolean;
|
|
11
|
+
sizes?: string;
|
|
12
|
+
disableAdaptive?: boolean;
|
|
13
|
+
placeholder?: 'blur' | 'none';
|
|
14
|
+
onLoad?: () => void;
|
|
15
|
+
onError?: () => void;
|
|
16
|
+
}
|
|
17
|
+
declare function GImage({ src, alt, priority, sizes, disableAdaptive, placeholder: placeholderMode, className, style, loading: loadingProp, onLoad, onError, ...imgProps }: GImageProps): react_jsx_runtime.JSX.Element;
|
|
18
|
+
|
|
19
|
+
export { GImage, type GImageProps, ManifestEntry, GImage as default };
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,348 @@
|
|
|
1
|
+
import {
|
|
2
|
+
gotodevImageOptimizer,
|
|
3
|
+
isAnimatedFormat
|
|
4
|
+
} from "./chunk-JJABKWGE.js";
|
|
5
|
+
|
|
6
|
+
// src/components/GImage.tsx
|
|
7
|
+
import { useCallback, useEffect, useRef, useState } from "react";
|
|
8
|
+
|
|
9
|
+
// src/adaptive/fingerprint.ts
|
|
10
|
+
function getConnectionType() {
|
|
11
|
+
try {
|
|
12
|
+
const conn = navigator.connection;
|
|
13
|
+
if (conn?.effectiveType) {
|
|
14
|
+
const type = conn.effectiveType;
|
|
15
|
+
if (["slow-2g", "2g", "3g", "4g"].includes(type)) {
|
|
16
|
+
return type;
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
} catch {
|
|
20
|
+
}
|
|
21
|
+
return "unknown";
|
|
22
|
+
}
|
|
23
|
+
function getDeviceMemory() {
|
|
24
|
+
try {
|
|
25
|
+
const mem = navigator.deviceMemory;
|
|
26
|
+
return mem ?? 4;
|
|
27
|
+
} catch {
|
|
28
|
+
return 4;
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
function getHardwareConcurrency() {
|
|
32
|
+
try {
|
|
33
|
+
return navigator.hardwareConcurrency ?? 4;
|
|
34
|
+
} catch {
|
|
35
|
+
return 4;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
function getDevicePixelRatio() {
|
|
39
|
+
try {
|
|
40
|
+
return window.devicePixelRatio ?? 1;
|
|
41
|
+
} catch {
|
|
42
|
+
return 1;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
function getSaveData() {
|
|
46
|
+
try {
|
|
47
|
+
const sd = navigator.connection;
|
|
48
|
+
return sd?.saveData ?? false;
|
|
49
|
+
} catch {
|
|
50
|
+
return false;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
function computeTier(fp) {
|
|
54
|
+
if (fp.saveData) return "low";
|
|
55
|
+
if (fp.effectiveType === "slow-2g" || fp.effectiveType === "2g") {
|
|
56
|
+
return "low";
|
|
57
|
+
}
|
|
58
|
+
const score = (fp.effectiveType === "4g" ? 40 : fp.effectiveType === "3g" ? 20 : 0) + Math.min(fp.deviceMemory / 8, 1) * 25 + Math.min(fp.hardwareConcurrency / 8, 1) * 20 + Math.min(fp.devicePixelRatio / 3, 1) * 15;
|
|
59
|
+
if (score >= 80) return "ultra";
|
|
60
|
+
if (score >= 55) return "high";
|
|
61
|
+
if (score >= 30) return "medium";
|
|
62
|
+
return "low";
|
|
63
|
+
}
|
|
64
|
+
var cachedFingerprint = null;
|
|
65
|
+
function getDeviceFingerprint() {
|
|
66
|
+
if (cachedFingerprint) return cachedFingerprint;
|
|
67
|
+
const base = {
|
|
68
|
+
effectiveType: getConnectionType(),
|
|
69
|
+
deviceMemory: getDeviceMemory(),
|
|
70
|
+
hardwareConcurrency: getHardwareConcurrency(),
|
|
71
|
+
devicePixelRatio: getDevicePixelRatio(),
|
|
72
|
+
saveData: getSaveData()
|
|
73
|
+
};
|
|
74
|
+
cachedFingerprint = {
|
|
75
|
+
...base,
|
|
76
|
+
tier: computeTier(base)
|
|
77
|
+
};
|
|
78
|
+
return cachedFingerprint;
|
|
79
|
+
}
|
|
80
|
+
function clearFingerprintCache() {
|
|
81
|
+
cachedFingerprint = null;
|
|
82
|
+
}
|
|
83
|
+
function listenForChanges(onChange) {
|
|
84
|
+
const handler = () => {
|
|
85
|
+
clearFingerprintCache();
|
|
86
|
+
onChange(getDeviceFingerprint());
|
|
87
|
+
};
|
|
88
|
+
try {
|
|
89
|
+
const conn = navigator.connection;
|
|
90
|
+
conn?.addEventListener?.("change", handler);
|
|
91
|
+
return () => {
|
|
92
|
+
try {
|
|
93
|
+
;
|
|
94
|
+
conn?.removeEventListener?.("change", handler);
|
|
95
|
+
} catch {
|
|
96
|
+
}
|
|
97
|
+
};
|
|
98
|
+
} catch {
|
|
99
|
+
return () => {
|
|
100
|
+
};
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// src/adaptive/predictive.ts
|
|
105
|
+
var MIN_PRELOAD = 600;
|
|
106
|
+
var MAX_PRELOAD = 3e3;
|
|
107
|
+
var VELOCITY_SAMPLES = 5;
|
|
108
|
+
var PredictiveLoader = class {
|
|
109
|
+
state = {
|
|
110
|
+
velocity: 0,
|
|
111
|
+
direction: "none",
|
|
112
|
+
lastScrollY: 0,
|
|
113
|
+
lastTimestamp: Date.now(),
|
|
114
|
+
preloadDistance: MIN_PRELOAD
|
|
115
|
+
};
|
|
116
|
+
velocities = [];
|
|
117
|
+
observer = null;
|
|
118
|
+
constructor() {
|
|
119
|
+
if (typeof window !== "undefined") {
|
|
120
|
+
this.state.lastScrollY = window.scrollY;
|
|
121
|
+
window.addEventListener("scroll", this.onScroll, { passive: true });
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
onScroll = () => {
|
|
125
|
+
const now = Date.now();
|
|
126
|
+
const dt = Math.max(1, now - this.state.lastTimestamp);
|
|
127
|
+
const dy = Math.abs(window.scrollY - this.state.lastScrollY);
|
|
128
|
+
const velocity = dy / dt;
|
|
129
|
+
this.velocities.push(velocity);
|
|
130
|
+
if (this.velocities.length > VELOCITY_SAMPLES) {
|
|
131
|
+
this.velocities.shift();
|
|
132
|
+
}
|
|
133
|
+
const avgVelocity = this.velocities.reduce((a, b) => a + b, 0) / this.velocities.length;
|
|
134
|
+
this.state.velocity = avgVelocity;
|
|
135
|
+
this.state.direction = window.scrollY > this.state.lastScrollY ? "down" : window.scrollY < this.state.lastScrollY ? "up" : "none";
|
|
136
|
+
this.state.lastScrollY = window.scrollY;
|
|
137
|
+
this.state.lastTimestamp = now;
|
|
138
|
+
this.state.preloadDistance = Math.min(MAX_PRELOAD, MIN_PRELOAD + avgVelocity * 5e3);
|
|
139
|
+
};
|
|
140
|
+
getPreloadMargin() {
|
|
141
|
+
return `${this.state.preloadDistance}px`;
|
|
142
|
+
}
|
|
143
|
+
observe(element, callback) {
|
|
144
|
+
this.observer?.disconnect();
|
|
145
|
+
this.observer = new IntersectionObserver(
|
|
146
|
+
(entries) => {
|
|
147
|
+
for (const entry of entries) {
|
|
148
|
+
if (entry.isIntersecting) {
|
|
149
|
+
callback(entry);
|
|
150
|
+
this.observer?.unobserve(entry.target);
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
},
|
|
154
|
+
{
|
|
155
|
+
rootMargin: this.getPreloadMargin(),
|
|
156
|
+
threshold: 0.01
|
|
157
|
+
}
|
|
158
|
+
);
|
|
159
|
+
this.observer.observe(element);
|
|
160
|
+
}
|
|
161
|
+
unobserve(element) {
|
|
162
|
+
this.observer?.unobserve(element);
|
|
163
|
+
}
|
|
164
|
+
destroy() {
|
|
165
|
+
this.observer?.disconnect();
|
|
166
|
+
if (typeof window !== "undefined") {
|
|
167
|
+
window.removeEventListener("scroll", this.onScroll);
|
|
168
|
+
}
|
|
169
|
+
this.observer = null;
|
|
170
|
+
}
|
|
171
|
+
};
|
|
172
|
+
|
|
173
|
+
// src/components/GImage.tsx
|
|
174
|
+
import { Fragment, jsx, jsxs } from "react/jsx-runtime";
|
|
175
|
+
function GImage({
|
|
176
|
+
src,
|
|
177
|
+
alt,
|
|
178
|
+
priority = false,
|
|
179
|
+
sizes = "100vw",
|
|
180
|
+
disableAdaptive = false,
|
|
181
|
+
placeholder: placeholderMode = "blur",
|
|
182
|
+
className,
|
|
183
|
+
style,
|
|
184
|
+
loading: loadingProp,
|
|
185
|
+
onLoad,
|
|
186
|
+
onError,
|
|
187
|
+
...imgProps
|
|
188
|
+
}) {
|
|
189
|
+
const imgRef = useRef(null);
|
|
190
|
+
const containerRef = useRef(null);
|
|
191
|
+
const predictiveRef = useRef(null);
|
|
192
|
+
const [loadState, setLoadState] = useState({
|
|
193
|
+
loaded: false,
|
|
194
|
+
error: false,
|
|
195
|
+
currentTier: "ultra"
|
|
196
|
+
});
|
|
197
|
+
const metadata = typeof src === "string" ? null : {
|
|
198
|
+
src: src.src,
|
|
199
|
+
width: src.width,
|
|
200
|
+
height: src.height,
|
|
201
|
+
format: src.format,
|
|
202
|
+
placeholder: src.placeholder,
|
|
203
|
+
variants: src.variants,
|
|
204
|
+
tiers: src.tiers
|
|
205
|
+
};
|
|
206
|
+
useEffect(() => {
|
|
207
|
+
if (disableAdaptive || !metadata) {
|
|
208
|
+
setLoadState((prev) => ({ ...prev, currentTier: "ultra" }));
|
|
209
|
+
return;
|
|
210
|
+
}
|
|
211
|
+
const fp = getDeviceFingerprint();
|
|
212
|
+
setLoadState((prev) => ({ ...prev, currentTier: fp.tier }));
|
|
213
|
+
const cleanup = listenForChanges((newFp) => {
|
|
214
|
+
setLoadState((prev) => ({ ...prev, currentTier: newFp.tier }));
|
|
215
|
+
});
|
|
216
|
+
return cleanup;
|
|
217
|
+
}, [disableAdaptive, metadata]);
|
|
218
|
+
useEffect(() => {
|
|
219
|
+
if (priority || loadingProp === "eager") {
|
|
220
|
+
setLoadState((prev) => ({ ...prev, loaded: true }));
|
|
221
|
+
return;
|
|
222
|
+
}
|
|
223
|
+
if (!containerRef.current) return;
|
|
224
|
+
predictiveRef.current = new PredictiveLoader();
|
|
225
|
+
predictiveRef.current.observe(containerRef.current, () => {
|
|
226
|
+
setLoadState((prev) => ({ ...prev, loaded: true }));
|
|
227
|
+
});
|
|
228
|
+
return () => {
|
|
229
|
+
predictiveRef.current?.destroy();
|
|
230
|
+
};
|
|
231
|
+
}, [priority, loadingProp]);
|
|
232
|
+
const handleLoad = useCallback(() => {
|
|
233
|
+
setLoadState((prev) => ({ ...prev, loaded: true }));
|
|
234
|
+
onLoad?.();
|
|
235
|
+
}, [onLoad]);
|
|
236
|
+
const handleError = useCallback(() => {
|
|
237
|
+
setLoadState((prev) => ({ ...prev, error: true }));
|
|
238
|
+
onError?.();
|
|
239
|
+
}, [onError]);
|
|
240
|
+
if (!metadata) {
|
|
241
|
+
return /* @__PURE__ */ jsx(
|
|
242
|
+
"img",
|
|
243
|
+
{
|
|
244
|
+
ref: imgRef,
|
|
245
|
+
...imgProps,
|
|
246
|
+
src: typeof src === "string" ? src : "",
|
|
247
|
+
alt: alt ?? "",
|
|
248
|
+
className,
|
|
249
|
+
style,
|
|
250
|
+
loading: priority ? "eager" : loadingProp ?? "lazy",
|
|
251
|
+
onLoad: handleLoad,
|
|
252
|
+
onError: handleError
|
|
253
|
+
}
|
|
254
|
+
);
|
|
255
|
+
}
|
|
256
|
+
const aspectRatio = metadata.width && metadata.height ? metadata.height / metadata.width * 100 : 0;
|
|
257
|
+
const tier = loadState.currentTier;
|
|
258
|
+
const tierVariants = metadata.variants.filter((v) => {
|
|
259
|
+
const tierWidths = {
|
|
260
|
+
ultra: 99999,
|
|
261
|
+
high: 1920,
|
|
262
|
+
medium: 1024,
|
|
263
|
+
low: 768
|
|
264
|
+
};
|
|
265
|
+
return v.width <= tierWidths[tier];
|
|
266
|
+
});
|
|
267
|
+
const hasPlaceholder = placeholderMode === "blur" && !!metadata.placeholder && !loadState.loaded;
|
|
268
|
+
const isAnimated = isAnimatedFormat(metadata.format);
|
|
269
|
+
const pictureContent = /* @__PURE__ */ jsxs("picture", { children: [
|
|
270
|
+
!isAnimated && tierVariants.length > 0 && /* @__PURE__ */ jsxs(Fragment, { children: [
|
|
271
|
+
/* @__PURE__ */ jsx(
|
|
272
|
+
"source",
|
|
273
|
+
{
|
|
274
|
+
type: "image/avif",
|
|
275
|
+
srcSet: tierVariants.filter((v) => v.format === "avif").map((v) => `${v.src} ${v.width}w`).join(", "),
|
|
276
|
+
sizes
|
|
277
|
+
}
|
|
278
|
+
),
|
|
279
|
+
/* @__PURE__ */ jsx(
|
|
280
|
+
"source",
|
|
281
|
+
{
|
|
282
|
+
type: "image/webp",
|
|
283
|
+
srcSet: tierVariants.filter((v) => v.format === "webp").map((v) => `${v.src} ${v.width}w`).join(", "),
|
|
284
|
+
sizes
|
|
285
|
+
}
|
|
286
|
+
)
|
|
287
|
+
] }),
|
|
288
|
+
/* @__PURE__ */ jsx(
|
|
289
|
+
"img",
|
|
290
|
+
{
|
|
291
|
+
ref: imgRef,
|
|
292
|
+
...imgProps,
|
|
293
|
+
src: tierVariants.find((v) => v.format === "jpeg")?.src ?? metadata.src,
|
|
294
|
+
srcSet: tierVariants.filter((v) => v.format === "jpeg" || v.format === "png").map((v) => `${v.src} ${v.width}w`).join(", ") || void 0,
|
|
295
|
+
sizes,
|
|
296
|
+
alt: alt ?? "",
|
|
297
|
+
width: metadata.width,
|
|
298
|
+
height: metadata.height,
|
|
299
|
+
loading: loadState.loaded && !priority ? "lazy" : "eager",
|
|
300
|
+
fetchPriority: priority ? "high" : void 0,
|
|
301
|
+
decoding: priority ? "auto" : "async",
|
|
302
|
+
className,
|
|
303
|
+
style: {
|
|
304
|
+
width: "100%",
|
|
305
|
+
height: "auto",
|
|
306
|
+
display: "block",
|
|
307
|
+
background: hasPlaceholder ? `${metadata.placeholder} center/cover no-repeat` : void 0,
|
|
308
|
+
filter: hasPlaceholder ? "blur(20px)" : void 0,
|
|
309
|
+
transition: "filter 0.4s ease-out, opacity 0.4s ease-out",
|
|
310
|
+
opacity: loadState.loaded ? 1 : 0.99,
|
|
311
|
+
...style
|
|
312
|
+
},
|
|
313
|
+
onLoad: handleLoad,
|
|
314
|
+
onError: handleError
|
|
315
|
+
}
|
|
316
|
+
)
|
|
317
|
+
] });
|
|
318
|
+
if (aspectRatio > 0) {
|
|
319
|
+
return /* @__PURE__ */ jsx(
|
|
320
|
+
"div",
|
|
321
|
+
{
|
|
322
|
+
ref: containerRef,
|
|
323
|
+
style: {
|
|
324
|
+
position: "relative",
|
|
325
|
+
width: "100%",
|
|
326
|
+
paddingBottom: `${aspectRatio}%`,
|
|
327
|
+
overflow: "hidden"
|
|
328
|
+
},
|
|
329
|
+
children: /* @__PURE__ */ jsx(
|
|
330
|
+
"div",
|
|
331
|
+
{
|
|
332
|
+
style: {
|
|
333
|
+
position: "absolute",
|
|
334
|
+
inset: 0
|
|
335
|
+
},
|
|
336
|
+
children: pictureContent
|
|
337
|
+
}
|
|
338
|
+
)
|
|
339
|
+
}
|
|
340
|
+
);
|
|
341
|
+
}
|
|
342
|
+
return /* @__PURE__ */ jsx("div", { ref: containerRef, children: pictureContent });
|
|
343
|
+
}
|
|
344
|
+
export {
|
|
345
|
+
GImage,
|
|
346
|
+
GImage as default,
|
|
347
|
+
gotodevImageOptimizer
|
|
348
|
+
};
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { Plugin } from 'vite';
|
|
2
|
+
|
|
3
|
+
type ImageFormat = 'jpeg' | 'png' | 'webp' | 'avif' | 'gif' | 'svg' | 'bmp' | 'tiff' | 'ico';
|
|
4
|
+
type OutputFormat = 'avif' | 'webp' | 'jpeg' | 'png';
|
|
5
|
+
type QualityTier = 'ultra' | 'high' | 'medium' | 'low';
|
|
6
|
+
interface TierConfig {
|
|
7
|
+
quality: number;
|
|
8
|
+
widths: number[];
|
|
9
|
+
}
|
|
10
|
+
interface PluginOptions {
|
|
11
|
+
tiers?: Partial<Record<QualityTier, TierConfig>>;
|
|
12
|
+
adaptive?: boolean;
|
|
13
|
+
autoTune?: boolean;
|
|
14
|
+
formats?: OutputFormat[];
|
|
15
|
+
maxFileSize?: number;
|
|
16
|
+
widths?: number[];
|
|
17
|
+
verbose?: boolean;
|
|
18
|
+
preprocess?: boolean;
|
|
19
|
+
faceDetection?: boolean;
|
|
20
|
+
}
|
|
21
|
+
interface ImageVariant {
|
|
22
|
+
src: string;
|
|
23
|
+
width: number;
|
|
24
|
+
format: OutputFormat;
|
|
25
|
+
size: number;
|
|
26
|
+
integrity: string;
|
|
27
|
+
}
|
|
28
|
+
interface ImageMetadata {
|
|
29
|
+
src: string;
|
|
30
|
+
width: number;
|
|
31
|
+
height: number;
|
|
32
|
+
format: ImageFormat;
|
|
33
|
+
placeholder: string;
|
|
34
|
+
variants: ImageVariant[];
|
|
35
|
+
tiers: Record<QualityTier, string>;
|
|
36
|
+
blurHash?: string;
|
|
37
|
+
}
|
|
38
|
+
interface DeviceFingerprint {
|
|
39
|
+
effectiveType: 'slow-2g' | '2g' | '3g' | '4g' | 'unknown';
|
|
40
|
+
deviceMemory: number;
|
|
41
|
+
hardwareConcurrency: number;
|
|
42
|
+
devicePixelRatio: number;
|
|
43
|
+
saveData: boolean;
|
|
44
|
+
tier: QualityTier;
|
|
45
|
+
}
|
|
46
|
+
interface ManifestEntry {
|
|
47
|
+
src: string;
|
|
48
|
+
width: number;
|
|
49
|
+
height: number;
|
|
50
|
+
format: ImageFormat;
|
|
51
|
+
placeholder: string;
|
|
52
|
+
tiers: Record<QualityTier, string>;
|
|
53
|
+
variants: ImageVariant[];
|
|
54
|
+
}
|
|
55
|
+
interface BuildManifest {
|
|
56
|
+
version: string;
|
|
57
|
+
generatedAt: string;
|
|
58
|
+
entries: Record<string, ManifestEntry>;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
declare function gotodevImageOptimizer(userOptions?: PluginOptions): Plugin;
|
|
62
|
+
|
|
63
|
+
export { type BuildManifest as B, type DeviceFingerprint as D, type ImageFormat as I, type ManifestEntry as M, type OutputFormat as O, type PluginOptions as P, type QualityTier as Q, type ImageMetadata as a, gotodevImageOptimizer as g };
|
package/package.json
CHANGED
|
@@ -1,28 +1,35 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "gotodev-image-optimizer",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.2",
|
|
4
4
|
"description": "Content-aware, device-adaptive image optimizer for React + Vite projects",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"exports": {
|
|
7
|
-
".":
|
|
8
|
-
|
|
7
|
+
".": {
|
|
8
|
+
"types": "./dist/index.d.ts",
|
|
9
|
+
"default": "./dist/index.js"
|
|
10
|
+
},
|
|
11
|
+
"./vite-plugin": {
|
|
12
|
+
"types": "./dist/vite-plugin.d.ts",
|
|
13
|
+
"default": "./dist/vite-plugin.js"
|
|
14
|
+
}
|
|
9
15
|
},
|
|
10
|
-
"types": "./
|
|
16
|
+
"types": "./dist/index.d.ts",
|
|
11
17
|
"files": [
|
|
12
|
-
"
|
|
13
|
-
"!
|
|
14
|
-
"!src/**/*.test.tsx",
|
|
18
|
+
"dist",
|
|
19
|
+
"!dist/**/*.test.*",
|
|
15
20
|
"!playground"
|
|
16
21
|
],
|
|
17
22
|
"scripts": {
|
|
18
23
|
"dev": "vite playground",
|
|
19
|
-
"build": "
|
|
24
|
+
"build": "tsup --clean",
|
|
25
|
+
"build:playground": "vite build playground",
|
|
20
26
|
"preview": "vite preview playground",
|
|
21
27
|
"typecheck": "tsc --noEmit",
|
|
22
28
|
"lint": "biome check src/",
|
|
23
29
|
"lint:fix": "biome check --apply src/",
|
|
24
30
|
"test": "vitest run",
|
|
25
31
|
"test:watch": "vitest",
|
|
32
|
+
"prepublishOnly": "npm run build",
|
|
26
33
|
"prepare": "echo 'ready'"
|
|
27
34
|
},
|
|
28
35
|
"license": "MIT",
|
|
@@ -49,6 +56,7 @@
|
|
|
49
56
|
"happy-dom": "^20.9.0",
|
|
50
57
|
"react": "^19.2.6",
|
|
51
58
|
"react-dom": "^19.2.6",
|
|
59
|
+
"tsup": "^8.5.1",
|
|
52
60
|
"typescript": "^6.0.3",
|
|
53
61
|
"vite": "^8.0.13",
|
|
54
62
|
"vitest": "^4.1.6"
|
|
@@ -1,117 +0,0 @@
|
|
|
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
|
-
}
|
|
@@ -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
|
-
}
|