imgcap 1.0.1 → 1.0.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/CHANGELOG.md +7 -0
- package/README.md +21 -5
- package/__tests__/package.json +1 -1
- package/__tests__/unit/imgcap.test.ts +31 -52
- package/__tests__/utils/index.ts +20 -5
- package/core/dist/index.d.ts +11 -6
- package/core/dist/index.js +1 -1
- package/core/dist/index.js.map +1 -1
- package/core/package.json +1 -1
- package/core/src/index.ts +35 -22
- package/index.html +479 -0
- package/package.json +4 -2
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,10 @@
|
|
|
1
|
+
## [1.0.2](https://github.com/molvqingtai/imgcap/compare/v1.0.1...v1.0.2) (2025-07-14)
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
### Bug Fixes
|
|
5
|
+
|
|
6
|
+
* optimize result selection logic in compression algorithm ([48505de](https://github.com/molvqingtai/imgcap/commit/48505de64e9d361266c604c92fb9f62c64ba5f75))
|
|
7
|
+
|
|
1
8
|
## [1.0.1](https://github.com/molvqingtai/imgcap/compare/v1.0.0...v1.0.1) (2025-07-14)
|
|
2
9
|
|
|
3
10
|
|
package/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# ImgCap
|
|
2
2
|
|
|
3
|
-
Automatically compress images to
|
|
3
|
+
Automatically compress images to approximate target file size using binary search algorithm.
|
|
4
4
|
|
|
5
5
|
[](https://www.npmjs.com/package/imgcap) [](https://github.com/molvqingtai/imgcap/actions) [](https://www.npmjs.com/package/imgcap) [](https://www.npmjs.com/package/imgcap)
|
|
6
6
|
|
|
@@ -8,9 +8,11 @@ Automatically compress images to exact file size using binary search algorithm.
|
|
|
8
8
|
pnpm install imgcap
|
|
9
9
|
```
|
|
10
10
|
|
|
11
|
-
### Why
|
|
11
|
+
### Why?
|
|
12
12
|
|
|
13
|
-
Users often encounter "File too large" errors when uploading images, forcing them to manually compress files using external tools. This creates friction and leads to user dropout. imgcap solves this by automatically compressing images to
|
|
13
|
+
Users often encounter "File too large" errors when uploading images, forcing them to manually compress files using external tools. This creates friction and leads to user dropout. imgcap solves this by automatically compressing images to approximate your target size - no user intervention needed.
|
|
14
|
+
|
|
15
|
+
**Note**: The `targetSize` represents an ideal target. The actual output size will approximate this value within a reasonable tolerance range for optimal performance.
|
|
14
16
|
|
|
15
17
|
```typescript
|
|
16
18
|
// Before: User sees error, leaves frustrated
|
|
@@ -39,14 +41,28 @@ const webp = await imgcap(imageFile, {
|
|
|
39
41
|
|
|
40
42
|
```typescript
|
|
41
43
|
interface Options {
|
|
42
|
-
targetSize: number // Target file size in bytes
|
|
43
|
-
toleranceSize?: number // Size tolerance (default: -1024)
|
|
44
|
+
targetSize: number // Target file size in bytes (approximate)
|
|
44
45
|
outputType?: ImageType // Output format (default: same as input)
|
|
45
46
|
}
|
|
46
47
|
|
|
47
48
|
type ImageType = 'image/jpeg' | 'image/png' | 'image/webp' | 'image/avif'
|
|
48
49
|
```
|
|
49
50
|
|
|
51
|
+
#### Tolerance Behavior
|
|
52
|
+
|
|
53
|
+
The algorithm automatically applies smart tolerance based on target size:
|
|
54
|
+
|
|
55
|
+
- **Small files** (<100KB): ±1KB tolerance for high precision
|
|
56
|
+
- **Medium files** (100KB-100MB): ±1% tolerance for reasonable flexibility
|
|
57
|
+
- **Large files** (>100MB): ±1MB tolerance to avoid excessive processing
|
|
58
|
+
|
|
59
|
+
**Examples:**
|
|
60
|
+
|
|
61
|
+
- Target 50KB → Actual: 49-51KB
|
|
62
|
+
- Target 500KB → Actual: 495-505KB
|
|
63
|
+
- Target 50MB → Actual: 49.5-50.5MB
|
|
64
|
+
- Target 1GB → Actual: 1023-1025MB
|
|
65
|
+
|
|
50
66
|
**Browser only** - Requires OffscreenCanvas support (modern browsers).
|
|
51
67
|
|
|
52
68
|
### License
|
package/__tests__/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "__tests__",
|
|
3
3
|
"version": "1.0.0",
|
|
4
|
-
"description": "Automatically compress images to
|
|
4
|
+
"description": "Automatically compress images to approximate target file size using binary search algorithm.",
|
|
5
5
|
"main": "index.js",
|
|
6
6
|
"scripts": {
|
|
7
7
|
"test": "vitest --browser.headless",
|
|
@@ -18,28 +18,26 @@ describe('imgcap', () => {
|
|
|
18
18
|
)
|
|
19
19
|
})
|
|
20
20
|
|
|
21
|
-
it('should throw error for
|
|
22
|
-
await expect(imgcap(testImageBlob, { targetSize:
|
|
23
|
-
'
|
|
21
|
+
it('should throw error for zero target size', async () => {
|
|
22
|
+
await expect(imgcap(testImageBlob, { targetSize: 0 })).rejects.toThrow(
|
|
23
|
+
'Target size must be at least 1KB (1024 bytes).'
|
|
24
24
|
)
|
|
25
25
|
})
|
|
26
26
|
|
|
27
|
-
it('should throw error for negative
|
|
28
|
-
await expect(imgcap(testImageBlob, { targetSize:
|
|
29
|
-
'
|
|
27
|
+
it('should throw error for negative target size', async () => {
|
|
28
|
+
await expect(imgcap(testImageBlob, { targetSize: -1000 })).rejects.toThrow(
|
|
29
|
+
'Target size must be at least 1KB (1024 bytes).'
|
|
30
30
|
)
|
|
31
31
|
})
|
|
32
32
|
|
|
33
|
-
it('should
|
|
34
|
-
await expect(imgcap(testImageBlob, { targetSize:
|
|
35
|
-
|
|
36
|
-
|
|
33
|
+
it('should throw error for target size less than 1KB', async () => {
|
|
34
|
+
await expect(imgcap(testImageBlob, { targetSize: 512 })).rejects.toThrow(
|
|
35
|
+
'Target size must be at least 1KB (1024 bytes).'
|
|
36
|
+
)
|
|
37
37
|
})
|
|
38
38
|
|
|
39
|
-
it('should accept
|
|
40
|
-
await expect(imgcap(testImageBlob, { targetSize:
|
|
41
|
-
|
|
42
|
-
await expect(imgcap(testImageBlob, { targetSize: 10000, toleranceSize: -2048 })).resolves.toBeDefined()
|
|
39
|
+
it('should accept target size of exactly 1KB', async () => {
|
|
40
|
+
await expect(imgcap(testImageBlob, { targetSize: 1024 })).resolves.toBeDefined()
|
|
43
41
|
})
|
|
44
42
|
})
|
|
45
43
|
|
|
@@ -53,36 +51,21 @@ describe('imgcap', () => {
|
|
|
53
51
|
})
|
|
54
52
|
|
|
55
53
|
it('should compress image to approximate target size', async () => {
|
|
56
|
-
const targetSize = 5000
|
|
57
|
-
const result = await imgcap(testImageBlob, {
|
|
58
|
-
targetSize,
|
|
59
|
-
toleranceSize: -1024
|
|
60
|
-
})
|
|
54
|
+
const targetSize = 5000 // Use 5KB target to force compression
|
|
55
|
+
const result = await imgcap(testImageBlob, { targetSize })
|
|
61
56
|
|
|
62
57
|
expect(result).toBeInstanceOf(Blob)
|
|
63
|
-
expect(result.size).toBeLessThanOrEqual(targetSize)
|
|
64
|
-
// 允许更大的容差范围,因为压缩算法可能产生更小的文件
|
|
65
|
-
expect(result.size).toBeGreaterThan(500)
|
|
58
|
+
expect(result.size).toBeLessThanOrEqual(targetSize + 2000) // Allow reasonable tolerance
|
|
66
59
|
})
|
|
67
60
|
|
|
68
|
-
it('should
|
|
69
|
-
|
|
70
|
-
const
|
|
71
|
-
const result = await imgcap(testImageBlob, {
|
|
72
|
-
targetSize,
|
|
73
|
-
toleranceSize
|
|
74
|
-
})
|
|
75
|
-
|
|
76
|
-
const lowerBound = targetSize + Math.min(0, toleranceSize)
|
|
77
|
-
const upperBound = targetSize + Math.max(0, toleranceSize)
|
|
61
|
+
it('should produce reasonably sized output', async () => {
|
|
62
|
+
// Test that algorithm produces reasonable results
|
|
63
|
+
const targetSize = 3000 // Use 3KB target
|
|
64
|
+
const result = await imgcap(testImageBlob, { targetSize })
|
|
78
65
|
|
|
79
|
-
expect(result
|
|
80
|
-
//
|
|
81
|
-
|
|
82
|
-
expect(result.size).toBeGreaterThan(500) // 确保不会过小
|
|
83
|
-
} else {
|
|
84
|
-
expect(result.size).toBeGreaterThanOrEqual(lowerBound)
|
|
85
|
-
}
|
|
66
|
+
expect(result).toBeInstanceOf(Blob)
|
|
67
|
+
expect(result.size).toBeLessThanOrEqual(targetSize + 1000) // Allow tolerance
|
|
68
|
+
expect(result.size).toBeGreaterThan(1000) // Should not be too small
|
|
86
69
|
})
|
|
87
70
|
})
|
|
88
71
|
|
|
@@ -125,24 +108,20 @@ describe('imgcap', () => {
|
|
|
125
108
|
|
|
126
109
|
it('should handle very small target sizes', async () => {
|
|
127
110
|
const targetSize = 2000
|
|
128
|
-
const result = await imgcap(testImageBlob, {
|
|
129
|
-
targetSize,
|
|
130
|
-
toleranceSize: -1024
|
|
131
|
-
})
|
|
111
|
+
const result = await imgcap(testImageBlob, { targetSize })
|
|
132
112
|
|
|
133
|
-
expect(result.size).toBeLessThanOrEqual(targetSize)
|
|
113
|
+
expect(result.size).toBeLessThanOrEqual(targetSize + 1024) // Within 1KB tolerance for small files
|
|
134
114
|
expect(result.size).toBeGreaterThan(500) // Should not be too small
|
|
135
115
|
})
|
|
136
116
|
|
|
137
|
-
it('should handle
|
|
138
|
-
const
|
|
139
|
-
const
|
|
140
|
-
const result = await imgcap(
|
|
141
|
-
targetSize,
|
|
142
|
-
toleranceSize
|
|
143
|
-
})
|
|
117
|
+
it('should handle large target sizes gracefully', async () => {
|
|
118
|
+
const largeBlob = await createTestImageBlob(400, 300, 'image/png')
|
|
119
|
+
const targetSize = 50 * 1024 * 1024 // 50MB (unrealistically large for test image)
|
|
120
|
+
const result = await imgcap(largeBlob, { targetSize })
|
|
144
121
|
|
|
145
|
-
|
|
122
|
+
// Since test image can't be compressed to 50MB, it should return the best possible result
|
|
123
|
+
expect(result).toBeInstanceOf(Blob)
|
|
124
|
+
expect(result.size).toBeLessThanOrEqual(targetSize) // Should not exceed target
|
|
146
125
|
})
|
|
147
126
|
})
|
|
148
127
|
|
package/__tests__/utils/index.ts
CHANGED
|
@@ -4,15 +4,30 @@ import { type ImageType } from 'imgcap'
|
|
|
4
4
|
export const createTestImageBlob = async (width: number = 100, height: number = 100, type: ImageType = 'image/png') => {
|
|
5
5
|
const canvas = new OffscreenCanvas(width, height)
|
|
6
6
|
const ctx = canvas.getContext('2d')!
|
|
7
|
-
|
|
7
|
+
|
|
8
|
+
// Fill with white background first
|
|
9
|
+
ctx.fillStyle = '#ffffff'
|
|
10
|
+
ctx.fillRect(0, 0, width, height)
|
|
11
|
+
|
|
12
|
+
// Create colorful quadrant pattern for better compression testing
|
|
13
|
+
const halfWidth = width / 2
|
|
14
|
+
const halfHeight = height / 2
|
|
15
|
+
|
|
16
|
+
// Top-left: Red
|
|
8
17
|
ctx.fillStyle = '#ff0000'
|
|
9
|
-
ctx.fillRect(0, 0,
|
|
18
|
+
ctx.fillRect(0, 0, halfWidth, halfHeight)
|
|
19
|
+
|
|
20
|
+
// Top-right: Green
|
|
10
21
|
ctx.fillStyle = '#00ff00'
|
|
11
|
-
ctx.fillRect(
|
|
22
|
+
ctx.fillRect(halfWidth, 0, halfWidth, halfHeight)
|
|
23
|
+
|
|
24
|
+
// Bottom-left: Blue
|
|
12
25
|
ctx.fillStyle = '#0000ff'
|
|
13
|
-
ctx.fillRect(0,
|
|
26
|
+
ctx.fillRect(0, halfHeight, halfWidth, halfHeight)
|
|
27
|
+
|
|
28
|
+
// Bottom-right: Yellow
|
|
14
29
|
ctx.fillStyle = '#ffff00'
|
|
15
|
-
ctx.fillRect(
|
|
30
|
+
ctx.fillRect(halfWidth, halfHeight, halfWidth, halfHeight)
|
|
16
31
|
|
|
17
32
|
return await canvas.convertToBlob({ type })
|
|
18
33
|
}
|
package/core/dist/index.d.ts
CHANGED
|
@@ -3,30 +3,35 @@
|
|
|
3
3
|
type ImageType = 'image/jpeg' | 'image/png' | 'image/webp' | 'image/avif';
|
|
4
4
|
/** Compression options */
|
|
5
5
|
interface Options {
|
|
6
|
-
/** Target file size in bytes */
|
|
6
|
+
/** Target file size in bytes (result will approximate this value) */
|
|
7
7
|
targetSize: number;
|
|
8
|
-
/** Size tolerance in bytes (default: -1024) */
|
|
9
|
-
toleranceSize?: number;
|
|
10
8
|
/** Output image format (default: same as input) */
|
|
11
9
|
outputType?: ImageType;
|
|
12
10
|
}
|
|
13
11
|
/**
|
|
14
|
-
* Compress an image to
|
|
12
|
+
* Compress an image to approximate target file size using binary search algorithm.
|
|
13
|
+
*
|
|
14
|
+
* The actual output size will be close to the target size within a smart tolerance range:
|
|
15
|
+
* - Small files (<100KB): ±1KB tolerance
|
|
16
|
+
* - Medium files (100KB-100MB): ±1% tolerance
|
|
17
|
+
* - Large files (>100MB): ±1MB tolerance
|
|
15
18
|
*
|
|
16
19
|
* @param input - Image blob/file to compress
|
|
17
|
-
* @param options - Compression options
|
|
20
|
+
* @param options - Compression options with targetSize as approximate target
|
|
18
21
|
* @returns Promise that resolves to compressed image blob
|
|
19
22
|
*
|
|
20
23
|
* @example
|
|
21
24
|
* ```typescript
|
|
22
|
-
* // Basic usage - compress to 500KB
|
|
25
|
+
* // Basic usage - compress to approximately 500KB
|
|
23
26
|
* const compressed = await imgcap(imageFile, { targetSize: 500 * 1024 })
|
|
27
|
+
* // Result: ~495-505KB depending on image characteristics
|
|
24
28
|
*
|
|
25
29
|
* // With format conversion
|
|
26
30
|
* const webp = await imgcap(imageFile, {
|
|
27
31
|
* targetSize: 300 * 1024,
|
|
28
32
|
* outputType: 'image/webp'
|
|
29
33
|
* })
|
|
34
|
+
* // Result: ~297-303KB in WebP format
|
|
30
35
|
* ```
|
|
31
36
|
*/
|
|
32
37
|
declare const imgcap: (input: Blob, options: Options) => Promise<Blob>;
|
package/core/dist/index.js
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
const e=async(t,n,r,i,a,o)=>{let s=(r+i)/2,c=Math.round(t.width*s),l=Math.round(t.height*s),u=new OffscreenCanvas(c,l),d=u.getContext(`2d`);d.drawImage(t,0,0,t.width,t.height,0,0,c,l);let f=await u.convertToBlob({type:
|
|
1
|
+
const e=async(t,n,r,i,a,o)=>{let s=(r+i)/2,c=Math.round(t.width*s),l=Math.round(t.height*s),u=new OffscreenCanvas(c,l),d=u.getContext(`2d`);d.drawImage(t,0,0,t.width,t.height,0,0,c,l);let f=await u.convertToBlob({type:a,quality:s}),p=f.size;if((!o||Math.abs(p-n)<Math.abs(o.size-n))&&(o=f),(i-r)/i<.01)return o;let m=Math.min(Math.max(1024,n*.01),1024*1024);return Math.abs(p-n)<=m?Math.abs(p-n)<=Math.abs(o.size-n)?f:o:p>n?await e(t,n,r,s,a,o):await e(t,n,s,i,a,o)},t=async(t,n)=>{let{targetSize:r}=n;if(![`image/jpeg`,`image/png`,`image/webp`,`image/avif`].includes(t.type))throw Error(`Only PNG, JPEG, WebP and AVIF images are supported.`);if(r<1024)throw Error(`Target size must be at least 1KB (1024 bytes).`);let i=n.outputType||t.type;if(t.size<=r&&t.type===i)return t;let a=await createImageBitmap(t),o=0,s=1;return await e(a,r,o,s,i)};var n=t;export{n as default,t as imgcap};
|
|
2
2
|
//# sourceMappingURL=index.js.map
|
package/core/dist/index.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.js","names":["imageBitmap: ImageBitmap","targetSize: number","low: number","high: number","
|
|
1
|
+
{"version":3,"file":"index.js","names":["imageBitmap: ImageBitmap","targetSize: number","low: number","high: number","outputType: ImageType","bestBlob?: Blob","input: Blob","options: Options"],"sources":["../src/index.ts"],"sourcesContent":["/** Supported image formats */\nexport type ImageType = 'image/jpeg' | 'image/png' | 'image/webp' | 'image/avif'\n\n/** Compression options */\nexport interface Options {\n /** Target file size in bytes (result will approximate this value) */\n targetSize: number\n /** Output image format (default: same as input) */\n outputType?: ImageType\n}\n\nconst compress = async (\n imageBitmap: ImageBitmap,\n targetSize: number,\n low: number,\n high: number,\n outputType: ImageType,\n bestBlob?: Blob\n): Promise<Blob> => {\n // Calculate the middle quality value\n const mid = (low + high) / 2\n\n // Calculate the width and height after scaling\n const width = Math.round(imageBitmap.width * mid)\n const height = Math.round(imageBitmap.height * mid)\n\n const offscreenCanvas = new OffscreenCanvas(width, height)\n const offscreenContext = offscreenCanvas.getContext('2d')!\n\n // Draw the scaled image\n offscreenContext.drawImage(imageBitmap, 0, 0, imageBitmap.width, imageBitmap.height, 0, 0, width, height)\n\n const outputBlob = await offscreenCanvas.convertToBlob({ type: outputType, quality: mid })\n\n const currentSize = outputBlob.size\n\n // Update best result - track the closest to target size\n if (!bestBlob || Math.abs(currentSize - targetSize) < Math.abs(bestBlob.size - targetSize)) {\n bestBlob = outputBlob\n }\n\n // Precision limit reached - return best result\n if ((high - low) / high < 0.01) {\n return bestBlob\n }\n\n // Check if we hit the target exactly or very close\n // Dynamic tolerance: 1KB or 1% of target size (whichever is larger), capped at 1MB\n // - Small files (<100KB): 1KB tolerance for high precision\n // - Medium files (100KB-100MB): 1% tolerance for reasonable flexibility\n // - Large files (>100MB): 1MB tolerance to avoid excessive iterations\n const tolerance = Math.min(Math.max(1024, targetSize * 0.01), 1024 * 1024)\n if (Math.abs(currentSize - targetSize) <= tolerance) {\n // Return the result that's closest to target size\n return Math.abs(currentSize - targetSize) <= Math.abs(bestBlob.size - targetSize) ? outputBlob : bestBlob\n }\n\n if (currentSize > targetSize) {\n return await compress(imageBitmap, targetSize, low, mid, outputType, bestBlob)\n } else {\n return await compress(imageBitmap, targetSize, mid, high, outputType, bestBlob)\n }\n}\n\n/**\n * Compress an image to approximate target file size using binary search algorithm.\n *\n * The actual output size will be close to the target size within a smart tolerance range:\n * - Small files (<100KB): ±1KB tolerance\n * - Medium files (100KB-100MB): ±1% tolerance\n * - Large files (>100MB): ±1MB tolerance\n *\n * @param input - Image blob/file to compress\n * @param options - Compression options with targetSize as approximate target\n * @returns Promise that resolves to compressed image blob\n *\n * @example\n * ```typescript\n * // Basic usage - compress to approximately 500KB\n * const compressed = await imgcap(imageFile, { targetSize: 500 * 1024 })\n * // Result: ~495-505KB depending on image characteristics\n *\n * // With format conversion\n * const webp = await imgcap(imageFile, {\n * targetSize: 300 * 1024,\n * outputType: 'image/webp'\n * })\n * // Result: ~297-303KB in WebP format\n * ```\n */\nexport const imgcap = async (input: Blob, options: Options) => {\n const { targetSize } = options\n\n if (!['image/jpeg', 'image/png', 'image/webp', 'image/avif'].includes(input.type)) {\n throw new Error('Only PNG, JPEG, WebP and AVIF images are supported.')\n }\n\n if (targetSize < 1024) {\n throw new Error('Target size must be at least 1KB (1024 bytes).')\n }\n\n const outputType = options.outputType || (input.type as ImageType)\n\n if (input.size <= targetSize && input.type === outputType) {\n return input\n }\n\n const imageBitmap = await createImageBitmap(input)\n\n // Initialize quality range\n const low = 0\n const high = 1\n\n return await compress(imageBitmap, targetSize, low, high, outputType)\n}\n\nexport default imgcap\n"],"mappings":"AAWA,MAAM,EAAW,MACfA,EACAC,EACAC,EACAC,EACAC,EACAC,IACkB,CAElB,IAAM,GAAO,EAAM,GAAQ,EAGrB,EAAQ,KAAK,MAAM,EAAY,MAAQ,EAAI,CAC3C,EAAS,KAAK,MAAM,EAAY,OAAS,EAAI,CAE7C,EAAkB,IAAI,gBAAgB,EAAO,GAC7C,EAAmB,EAAgB,WAAW,KAAK,CAGzD,EAAiB,UAAU,EAAa,EAAG,EAAG,EAAY,MAAO,EAAY,OAAQ,EAAG,EAAG,EAAO,EAAO,CAEzG,IAAM,EAAa,KAAM,GAAgB,cAAc,CAAE,KAAM,EAAY,QAAS,CAAK,EAAC,CAEpF,EAAc,EAAW,KAQ/B,KALK,GAAY,KAAK,IAAI,EAAc,EAAW,CAAG,KAAK,IAAI,EAAS,KAAO,EAAW,IACxF,EAAW,IAIR,EAAO,GAAO,EAAO,IACxB,OAAO,EAQT,IAAM,EAAY,KAAK,IAAI,KAAK,IAAI,KAAM,EAAa,IAAK,CAAE,KAAO,KAAK,CASxE,MARE,MAAK,IAAI,EAAc,EAAW,EAAI,EAEjC,KAAK,IAAI,EAAc,EAAW,EAAI,KAAK,IAAI,EAAS,KAAO,EAAW,CAAG,EAAa,EAG/F,EAAc,EACT,KAAM,GAAS,EAAa,EAAY,EAAK,EAAK,EAAY,EAAS,CAEvE,KAAM,GAAS,EAAa,EAAY,EAAK,EAAM,EAAY,EAAS,AAElF,EA4BY,EAAS,MAAOC,EAAaC,IAAqB,CAC7D,GAAM,CAAE,aAAY,CAAG,EAEvB,IAAK,CAAC,aAAc,YAAa,aAAc,YAAa,EAAC,SAAS,EAAM,KAAK,CAC/E,KAAM,CAAI,MAAM,sDAAA,CAGlB,GAAI,EAAa,KACf,KAAM,CAAI,MAAM,iDAAA,CAGlB,IAAM,EAAa,EAAQ,YAAe,EAAM,KAEhD,GAAI,EAAM,MAAQ,GAAc,EAAM,OAAS,EAC7C,OAAO,EAGT,IAAM,EAAc,KAAM,mBAAkB,EAAM,CAG5C,EAAM,EACN,EAAO,EAEb,OAAO,KAAM,GAAS,EAAa,EAAY,EAAK,EAAM,EAAW,AACtE,EAED,IAAA,EAAe"}
|
package/core/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "core",
|
|
3
3
|
"version": "1.0.0",
|
|
4
|
-
"description": "Automatically compress images to
|
|
4
|
+
"description": "Automatically compress images to approximate target file size using binary search algorithm.",
|
|
5
5
|
"main": "dist/index.js",
|
|
6
6
|
"type": "module",
|
|
7
7
|
"scripts": {
|
package/core/src/index.ts
CHANGED
|
@@ -3,10 +3,8 @@ export type ImageType = 'image/jpeg' | 'image/png' | 'image/webp' | 'image/avif'
|
|
|
3
3
|
|
|
4
4
|
/** Compression options */
|
|
5
5
|
export interface Options {
|
|
6
|
-
/** Target file size in bytes */
|
|
6
|
+
/** Target file size in bytes (result will approximate this value) */
|
|
7
7
|
targetSize: number
|
|
8
|
-
/** Size tolerance in bytes (default: -1024) */
|
|
9
|
-
toleranceSize?: number
|
|
10
8
|
/** Output image format (default: same as input) */
|
|
11
9
|
outputType?: ImageType
|
|
12
10
|
}
|
|
@@ -16,8 +14,8 @@ const compress = async (
|
|
|
16
14
|
targetSize: number,
|
|
17
15
|
low: number,
|
|
18
16
|
high: number,
|
|
19
|
-
|
|
20
|
-
|
|
17
|
+
outputType: ImageType,
|
|
18
|
+
bestBlob?: Blob
|
|
21
19
|
): Promise<Blob> => {
|
|
22
20
|
// Calculate the middle quality value
|
|
23
21
|
const mid = (low + high) / 2
|
|
@@ -36,54 +34,69 @@ const compress = async (
|
|
|
36
34
|
|
|
37
35
|
const currentSize = outputBlob.size
|
|
38
36
|
|
|
39
|
-
//
|
|
40
|
-
|
|
41
|
-
|
|
37
|
+
// Update best result - track the closest to target size
|
|
38
|
+
if (!bestBlob || Math.abs(currentSize - targetSize) < Math.abs(bestBlob.size - targetSize)) {
|
|
39
|
+
bestBlob = outputBlob
|
|
40
|
+
}
|
|
42
41
|
|
|
43
|
-
|
|
44
|
-
|
|
42
|
+
// Precision limit reached - return best result
|
|
43
|
+
if ((high - low) / high < 0.01) {
|
|
44
|
+
return bestBlob
|
|
45
45
|
}
|
|
46
46
|
|
|
47
|
-
//
|
|
48
|
-
|
|
49
|
-
|
|
47
|
+
// Check if we hit the target exactly or very close
|
|
48
|
+
// Dynamic tolerance: 1KB or 1% of target size (whichever is larger), capped at 1MB
|
|
49
|
+
// - Small files (<100KB): 1KB tolerance for high precision
|
|
50
|
+
// - Medium files (100KB-100MB): 1% tolerance for reasonable flexibility
|
|
51
|
+
// - Large files (>100MB): 1MB tolerance to avoid excessive iterations
|
|
52
|
+
const tolerance = Math.min(Math.max(1024, targetSize * 0.01), 1024 * 1024)
|
|
53
|
+
if (Math.abs(currentSize - targetSize) <= tolerance) {
|
|
54
|
+
// Return the result that's closest to target size
|
|
55
|
+
return Math.abs(currentSize - targetSize) <= Math.abs(bestBlob.size - targetSize) ? outputBlob : bestBlob
|
|
50
56
|
}
|
|
51
57
|
|
|
52
58
|
if (currentSize > targetSize) {
|
|
53
|
-
return await compress(imageBitmap, targetSize, low, mid,
|
|
59
|
+
return await compress(imageBitmap, targetSize, low, mid, outputType, bestBlob)
|
|
54
60
|
} else {
|
|
55
|
-
return await compress(imageBitmap, targetSize, mid, high,
|
|
61
|
+
return await compress(imageBitmap, targetSize, mid, high, outputType, bestBlob)
|
|
56
62
|
}
|
|
57
63
|
}
|
|
58
64
|
|
|
59
65
|
/**
|
|
60
|
-
* Compress an image to
|
|
66
|
+
* Compress an image to approximate target file size using binary search algorithm.
|
|
67
|
+
*
|
|
68
|
+
* The actual output size will be close to the target size within a smart tolerance range:
|
|
69
|
+
* - Small files (<100KB): ±1KB tolerance
|
|
70
|
+
* - Medium files (100KB-100MB): ±1% tolerance
|
|
71
|
+
* - Large files (>100MB): ±1MB tolerance
|
|
61
72
|
*
|
|
62
73
|
* @param input - Image blob/file to compress
|
|
63
|
-
* @param options - Compression options
|
|
74
|
+
* @param options - Compression options with targetSize as approximate target
|
|
64
75
|
* @returns Promise that resolves to compressed image blob
|
|
65
76
|
*
|
|
66
77
|
* @example
|
|
67
78
|
* ```typescript
|
|
68
|
-
* // Basic usage - compress to 500KB
|
|
79
|
+
* // Basic usage - compress to approximately 500KB
|
|
69
80
|
* const compressed = await imgcap(imageFile, { targetSize: 500 * 1024 })
|
|
81
|
+
* // Result: ~495-505KB depending on image characteristics
|
|
70
82
|
*
|
|
71
83
|
* // With format conversion
|
|
72
84
|
* const webp = await imgcap(imageFile, {
|
|
73
85
|
* targetSize: 300 * 1024,
|
|
74
86
|
* outputType: 'image/webp'
|
|
75
87
|
* })
|
|
88
|
+
* // Result: ~297-303KB in WebP format
|
|
76
89
|
* ```
|
|
77
90
|
*/
|
|
78
91
|
export const imgcap = async (input: Blob, options: Options) => {
|
|
79
|
-
const { targetSize
|
|
92
|
+
const { targetSize } = options
|
|
80
93
|
|
|
81
94
|
if (!['image/jpeg', 'image/png', 'image/webp', 'image/avif'].includes(input.type)) {
|
|
82
95
|
throw new Error('Only PNG, JPEG, WebP and AVIF images are supported.')
|
|
83
96
|
}
|
|
84
97
|
|
|
85
|
-
if (
|
|
86
|
-
throw new Error('
|
|
98
|
+
if (targetSize < 1024) {
|
|
99
|
+
throw new Error('Target size must be at least 1KB (1024 bytes).')
|
|
87
100
|
}
|
|
88
101
|
|
|
89
102
|
const outputType = options.outputType || (input.type as ImageType)
|
|
@@ -98,7 +111,7 @@ export const imgcap = async (input: Blob, options: Options) => {
|
|
|
98
111
|
const low = 0
|
|
99
112
|
const high = 1
|
|
100
113
|
|
|
101
|
-
return await compress(imageBitmap, targetSize, low, high,
|
|
114
|
+
return await compress(imageBitmap, targetSize, low, high, outputType)
|
|
102
115
|
}
|
|
103
116
|
|
|
104
117
|
export default imgcap
|
package/index.html
ADDED
|
@@ -0,0 +1,479 @@
|
|
|
1
|
+
<!DOCTYPE html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="UTF-8" />
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
6
|
+
<title>imgcap Demo - Precise Image Compression</title>
|
|
7
|
+
<script type="importmap">
|
|
8
|
+
{
|
|
9
|
+
"imports": {
|
|
10
|
+
"imgcap": "https://esm.sh/imgcap@latest"
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
</script>
|
|
14
|
+
<style>
|
|
15
|
+
* {
|
|
16
|
+
margin: 0;
|
|
17
|
+
padding: 0;
|
|
18
|
+
box-sizing: border-box;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
body {
|
|
22
|
+
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
|
23
|
+
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
|
24
|
+
min-height: 100vh;
|
|
25
|
+
padding: 20px;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
.container {
|
|
29
|
+
max-width: 800px;
|
|
30
|
+
margin: 0 auto;
|
|
31
|
+
background: white;
|
|
32
|
+
border-radius: 20px;
|
|
33
|
+
box-shadow: 0 20px 40px rgba(0, 0, 0, 0.1);
|
|
34
|
+
overflow: hidden;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
.header {
|
|
38
|
+
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
|
39
|
+
color: white;
|
|
40
|
+
padding: 30px;
|
|
41
|
+
text-align: center;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
.header h1 {
|
|
45
|
+
font-size: 2.5rem;
|
|
46
|
+
margin-bottom: 10px;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
.header p {
|
|
50
|
+
font-size: 1.1rem;
|
|
51
|
+
opacity: 0.9;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
.content {
|
|
55
|
+
padding: 40px;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
.upload-section {
|
|
59
|
+
margin-bottom: 40px;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
.upload-area {
|
|
63
|
+
border: 3px dashed #ddd;
|
|
64
|
+
border-radius: 15px;
|
|
65
|
+
padding: 40px;
|
|
66
|
+
text-align: center;
|
|
67
|
+
cursor: pointer;
|
|
68
|
+
transition: all 0.3s ease;
|
|
69
|
+
background: #fafafa;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
.upload-area:hover {
|
|
73
|
+
border-color: #667eea;
|
|
74
|
+
background: #f0f4ff;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
.upload-area.dragover {
|
|
78
|
+
border-color: #667eea;
|
|
79
|
+
background: #e8f2ff;
|
|
80
|
+
transform: scale(1.02);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
.upload-icon {
|
|
84
|
+
font-size: 3rem;
|
|
85
|
+
margin-bottom: 20px;
|
|
86
|
+
color: #667eea;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
.upload-text {
|
|
90
|
+
font-size: 1.2rem;
|
|
91
|
+
color: #666;
|
|
92
|
+
margin-bottom: 10px;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
.upload-hint {
|
|
96
|
+
font-size: 0.9rem;
|
|
97
|
+
color: #999;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
.file-input {
|
|
101
|
+
display: none;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
.controls {
|
|
105
|
+
display: grid;
|
|
106
|
+
grid-template-columns: 1fr 1fr;
|
|
107
|
+
gap: 20px;
|
|
108
|
+
margin-bottom: 30px;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
.control-group {
|
|
112
|
+
background: #f8f9fa;
|
|
113
|
+
padding: 20px;
|
|
114
|
+
border-radius: 10px;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
.control-group label {
|
|
118
|
+
display: block;
|
|
119
|
+
font-weight: 600;
|
|
120
|
+
margin-bottom: 10px;
|
|
121
|
+
color: #333;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
.control-group input,
|
|
125
|
+
.control-group select {
|
|
126
|
+
width: 100%;
|
|
127
|
+
padding: 12px;
|
|
128
|
+
border: 2px solid #e1e5e9;
|
|
129
|
+
border-radius: 8px;
|
|
130
|
+
font-size: 1rem;
|
|
131
|
+
transition: border-color 0.3s ease;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
.control-group input:focus,
|
|
135
|
+
.control-group select:focus {
|
|
136
|
+
outline: none;
|
|
137
|
+
border-color: #667eea;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
.compress-btn {
|
|
141
|
+
width: 100%;
|
|
142
|
+
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
|
143
|
+
color: white;
|
|
144
|
+
border: none;
|
|
145
|
+
padding: 15px 30px;
|
|
146
|
+
font-size: 1.1rem;
|
|
147
|
+
font-weight: 600;
|
|
148
|
+
border-radius: 10px;
|
|
149
|
+
cursor: pointer;
|
|
150
|
+
transition: transform 0.3s ease;
|
|
151
|
+
margin-bottom: 30px;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
.compress-btn:hover:not(:disabled) {
|
|
155
|
+
transform: translateY(-2px);
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
.compress-btn:disabled {
|
|
159
|
+
opacity: 0.6;
|
|
160
|
+
cursor: not-allowed;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
.results {
|
|
164
|
+
display: none;
|
|
165
|
+
background: #f8f9fa;
|
|
166
|
+
border-radius: 15px;
|
|
167
|
+
padding: 30px;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
.results.show {
|
|
171
|
+
display: block;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
.result-item {
|
|
175
|
+
display: flex;
|
|
176
|
+
justify-content: space-between;
|
|
177
|
+
align-items: center;
|
|
178
|
+
padding: 15px 0;
|
|
179
|
+
border-bottom: 1px solid #e1e5e9;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
.result-item:last-child {
|
|
183
|
+
border-bottom: none;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
.result-label {
|
|
187
|
+
font-weight: 600;
|
|
188
|
+
color: #333;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
.result-value {
|
|
192
|
+
color: #667eea;
|
|
193
|
+
font-weight: 500;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
.image-preview {
|
|
197
|
+
display: grid;
|
|
198
|
+
grid-template-columns: 1fr 1fr;
|
|
199
|
+
gap: 20px;
|
|
200
|
+
margin-top: 20px;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
.preview-item {
|
|
204
|
+
text-align: center;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
.preview-item h4 {
|
|
208
|
+
margin-bottom: 10px;
|
|
209
|
+
color: #333;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
.preview-item img {
|
|
213
|
+
max-width: 100%;
|
|
214
|
+
max-height: 200px;
|
|
215
|
+
border-radius: 10px;
|
|
216
|
+
box-shadow: 0 5px 15px rgba(0, 0, 0, 0.1);
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
.status {
|
|
220
|
+
margin: 20px 0;
|
|
221
|
+
padding: 15px;
|
|
222
|
+
border-radius: 8px;
|
|
223
|
+
text-align: center;
|
|
224
|
+
display: none;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
.status.show {
|
|
228
|
+
display: block;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
.status.loading {
|
|
232
|
+
background: #e3f2fd;
|
|
233
|
+
color: #1976d2;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
.status.success {
|
|
237
|
+
background: #e8f5e8;
|
|
238
|
+
color: #2e7d32;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
.status.error {
|
|
242
|
+
background: #ffebee;
|
|
243
|
+
color: #c62828;
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
@media (max-width: 768px) {
|
|
247
|
+
.controls {
|
|
248
|
+
grid-template-columns: 1fr;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
.image-preview {
|
|
252
|
+
grid-template-columns: 1fr;
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
</style>
|
|
256
|
+
</head>
|
|
257
|
+
<body>
|
|
258
|
+
<div class="container">
|
|
259
|
+
<div class="header">
|
|
260
|
+
<h1>🎯 imgcap Demo</h1>
|
|
261
|
+
<p>Precisely cap your image size with binary search compression</p>
|
|
262
|
+
</div>
|
|
263
|
+
|
|
264
|
+
<div class="content">
|
|
265
|
+
<div class="upload-section">
|
|
266
|
+
<div class="upload-area" id="uploadArea">
|
|
267
|
+
<div class="upload-icon">📁</div>
|
|
268
|
+
<div class="upload-text">Drop your image here or click to browse</div>
|
|
269
|
+
<div class="upload-hint">Supports JPEG, PNG, WebP files</div>
|
|
270
|
+
<input type="file" class="file-input" id="fileInput" accept="image/*" />
|
|
271
|
+
</div>
|
|
272
|
+
</div>
|
|
273
|
+
|
|
274
|
+
<div class="controls">
|
|
275
|
+
<div class="control-group">
|
|
276
|
+
<label for="targetSize">Target Size (KB)</label>
|
|
277
|
+
<input type="number" id="targetSize" value="500" min="1" max="10000" />
|
|
278
|
+
</div>
|
|
279
|
+
<div class="control-group">
|
|
280
|
+
<label for="outputFormat">Output Format</label>
|
|
281
|
+
<select id="outputFormat">
|
|
282
|
+
<option value="">Same as input</option>
|
|
283
|
+
<option value="image/jpeg">JPEG</option>
|
|
284
|
+
<option value="image/png">PNG</option>
|
|
285
|
+
<option value="image/webp">WebP</option>
|
|
286
|
+
<option value="image/avif">AVIF</option>
|
|
287
|
+
</select>
|
|
288
|
+
</div>
|
|
289
|
+
</div>
|
|
290
|
+
|
|
291
|
+
<button class="compress-btn" id="compressBtn" disabled>🚀 Compress Image</button>
|
|
292
|
+
|
|
293
|
+
<div class="status" id="status"></div>
|
|
294
|
+
|
|
295
|
+
<div class="results" id="results">
|
|
296
|
+
<h3>Compression Results</h3>
|
|
297
|
+
<div class="result-item">
|
|
298
|
+
<span class="result-label">Original Size:</span>
|
|
299
|
+
<span class="result-value" id="originalSize">-</span>
|
|
300
|
+
</div>
|
|
301
|
+
<div class="result-item">
|
|
302
|
+
<span class="result-label">Compressed Size:</span>
|
|
303
|
+
<span class="result-value" id="compressedSize">-</span>
|
|
304
|
+
</div>
|
|
305
|
+
<div class="result-item">
|
|
306
|
+
<span class="result-label">Compression Ratio:</span>
|
|
307
|
+
<span class="result-value" id="compressionRatio">-</span>
|
|
308
|
+
</div>
|
|
309
|
+
<div class="result-item">
|
|
310
|
+
<span class="result-label">Size Reduction:</span>
|
|
311
|
+
<span class="result-value" id="sizeReduction">-</span>
|
|
312
|
+
</div>
|
|
313
|
+
|
|
314
|
+
<div class="image-preview">
|
|
315
|
+
<div class="preview-item">
|
|
316
|
+
<h4>Original</h4>
|
|
317
|
+
<img id="originalPreview" alt="Original image" />
|
|
318
|
+
</div>
|
|
319
|
+
<div class="preview-item">
|
|
320
|
+
<h4>Compressed</h4>
|
|
321
|
+
<img id="compressedPreview" alt="Compressed image" />
|
|
322
|
+
</div>
|
|
323
|
+
</div>
|
|
324
|
+
</div>
|
|
325
|
+
</div>
|
|
326
|
+
</div>
|
|
327
|
+
|
|
328
|
+
<script type="module">
|
|
329
|
+
import imgcap from 'imgcap'
|
|
330
|
+
|
|
331
|
+
let selectedFile = null
|
|
332
|
+
let compressedBlob = null
|
|
333
|
+
|
|
334
|
+
// DOM elements
|
|
335
|
+
const uploadArea = document.getElementById('uploadArea')
|
|
336
|
+
const fileInput = document.getElementById('fileInput')
|
|
337
|
+
const targetSizeInput = document.getElementById('targetSize')
|
|
338
|
+
const outputFormatSelect = document.getElementById('outputFormat')
|
|
339
|
+
const compressBtn = document.getElementById('compressBtn')
|
|
340
|
+
const status = document.getElementById('status')
|
|
341
|
+
const results = document.getElementById('results')
|
|
342
|
+
const originalSizeSpan = document.getElementById('originalSize')
|
|
343
|
+
const compressedSizeSpan = document.getElementById('compressedSize')
|
|
344
|
+
const compressionRatioSpan = document.getElementById('compressionRatio')
|
|
345
|
+
const sizeReductionSpan = document.getElementById('sizeReduction')
|
|
346
|
+
const originalPreview = document.getElementById('originalPreview')
|
|
347
|
+
const compressedPreview = document.getElementById('compressedPreview')
|
|
348
|
+
|
|
349
|
+
// File upload handlers
|
|
350
|
+
uploadArea.addEventListener('click', () => fileInput.click())
|
|
351
|
+
uploadArea.addEventListener('dragover', handleDragOver)
|
|
352
|
+
uploadArea.addEventListener('dragleave', handleDragLeave)
|
|
353
|
+
uploadArea.addEventListener('drop', handleDrop)
|
|
354
|
+
fileInput.addEventListener('change', handleFileSelect)
|
|
355
|
+
compressBtn.addEventListener('click', compressImage)
|
|
356
|
+
|
|
357
|
+
function handleDragOver(e) {
|
|
358
|
+
e.preventDefault()
|
|
359
|
+
uploadArea.classList.add('dragover')
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
function handleDragLeave(e) {
|
|
363
|
+
e.preventDefault()
|
|
364
|
+
uploadArea.classList.remove('dragover')
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
function handleDrop(e) {
|
|
368
|
+
e.preventDefault()
|
|
369
|
+
uploadArea.classList.remove('dragover')
|
|
370
|
+
const files = e.dataTransfer.files
|
|
371
|
+
if (files.length > 0) {
|
|
372
|
+
handleFile(files[0])
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
function handleFileSelect(e) {
|
|
377
|
+
const files = e.target.files
|
|
378
|
+
if (files.length > 0) {
|
|
379
|
+
handleFile(files[0])
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
function handleFile(file) {
|
|
384
|
+
if (!file.type.startsWith('image/')) {
|
|
385
|
+
showStatus('Please select a valid image file', 'error')
|
|
386
|
+
return
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
selectedFile = file
|
|
390
|
+
|
|
391
|
+
// Update upload area
|
|
392
|
+
uploadArea.innerHTML = `
|
|
393
|
+
<div class="upload-icon">✅</div>
|
|
394
|
+
<div class="upload-text">${file.name}</div>
|
|
395
|
+
<div class="upload-hint">${formatFileSize(file.size)} • Click to change</div>
|
|
396
|
+
`
|
|
397
|
+
|
|
398
|
+
// Enable compress button
|
|
399
|
+
compressBtn.disabled = false
|
|
400
|
+
|
|
401
|
+
// Show original preview
|
|
402
|
+
const originalUrl = URL.createObjectURL(file)
|
|
403
|
+
originalPreview.src = originalUrl
|
|
404
|
+
originalPreview.onload = () => URL.revokeObjectURL(originalUrl)
|
|
405
|
+
|
|
406
|
+
// Hide previous results
|
|
407
|
+
results.classList.remove('show')
|
|
408
|
+
status.classList.remove('show')
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
async function compressImage() {
|
|
412
|
+
if (!selectedFile) return
|
|
413
|
+
|
|
414
|
+
const targetSize = parseInt(targetSizeInput.value) * 1024 // Convert KB to bytes
|
|
415
|
+
const outputType = outputFormatSelect.value || undefined
|
|
416
|
+
|
|
417
|
+
try {
|
|
418
|
+
showStatus('🔄 Compressing image...', 'loading')
|
|
419
|
+
compressBtn.disabled = true
|
|
420
|
+
|
|
421
|
+
const compressed = await imgcap(selectedFile, {
|
|
422
|
+
targetSize,
|
|
423
|
+
outputType
|
|
424
|
+
})
|
|
425
|
+
|
|
426
|
+
compressedBlob = compressed
|
|
427
|
+
showResults(selectedFile, compressed)
|
|
428
|
+
showStatus('✅ Compression completed!', 'success')
|
|
429
|
+
} catch (error) {
|
|
430
|
+
console.error('Compression failed:', error)
|
|
431
|
+
showStatus(`❌ Error: ${error.message}`, 'error')
|
|
432
|
+
} finally {
|
|
433
|
+
compressBtn.disabled = false
|
|
434
|
+
}
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
function showResults(original, compressed) {
|
|
438
|
+
// Update result values
|
|
439
|
+
originalSizeSpan.textContent = formatFileSize(original.size)
|
|
440
|
+
compressedSizeSpan.textContent = formatFileSize(compressed.size)
|
|
441
|
+
|
|
442
|
+
const ratio = ((1 - compressed.size / original.size) * 100).toFixed(1)
|
|
443
|
+
compressionRatioSpan.textContent = `${ratio}%`
|
|
444
|
+
|
|
445
|
+
const reduction = formatFileSize(original.size - compressed.size)
|
|
446
|
+
sizeReductionSpan.textContent = `${reduction} saved`
|
|
447
|
+
|
|
448
|
+
// Debug info - log actual bytes
|
|
449
|
+
console.log('🔍 Debug Info:')
|
|
450
|
+
console.log(
|
|
451
|
+
`Target size: ${parseInt(targetSizeInput.value)} KB (${parseInt(targetSizeInput.value) * 1024} bytes)`
|
|
452
|
+
)
|
|
453
|
+
console.log(`Actual size: ${formatFileSize(compressed.size)} (${compressed.size} bytes)`)
|
|
454
|
+
console.log(`Difference: ${compressed.size - parseInt(targetSizeInput.value) * 1024} bytes`)
|
|
455
|
+
|
|
456
|
+
// Show compressed preview
|
|
457
|
+
const compressedUrl = URL.createObjectURL(compressed)
|
|
458
|
+
compressedPreview.src = compressedUrl
|
|
459
|
+
compressedPreview.onload = () => URL.revokeObjectURL(compressedUrl)
|
|
460
|
+
|
|
461
|
+
// Show results section
|
|
462
|
+
results.classList.add('show')
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
function showStatus(message, type) {
|
|
466
|
+
status.textContent = message
|
|
467
|
+
status.className = `status show ${type}`
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
function formatFileSize(bytes) {
|
|
471
|
+
if (bytes === 0) return '0 Bytes'
|
|
472
|
+
const k = 1024
|
|
473
|
+
const sizes = ['Bytes', 'KB', 'MB', 'GB']
|
|
474
|
+
const i = Math.floor(Math.log(bytes) / Math.log(k))
|
|
475
|
+
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i]
|
|
476
|
+
}
|
|
477
|
+
</script>
|
|
478
|
+
</body>
|
|
479
|
+
</html>
|
package/package.json
CHANGED
|
@@ -1,12 +1,13 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "imgcap",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.2",
|
|
4
4
|
"description": "Automatically compress images to exact file size using binary search algorithm.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "core/dist/index.js",
|
|
7
7
|
"scripts": {
|
|
8
8
|
"dev": "pnpm --filter core dev",
|
|
9
9
|
"build": "pnpm --filter core build",
|
|
10
|
+
"serve": "serve",
|
|
10
11
|
"lint": "npm-run-all -p lint:*",
|
|
11
12
|
"check": "npm-run-all -p check:*",
|
|
12
13
|
"test": "pnpm --filter __tests__ test",
|
|
@@ -52,7 +53,8 @@
|
|
|
52
53
|
"@semantic-release/git": "^10.0.1",
|
|
53
54
|
"husky": "^9.1.7",
|
|
54
55
|
"npm-run-all": "^4.1.5",
|
|
55
|
-
"semantic-release": "^24.2.7"
|
|
56
|
+
"semantic-release": "^24.2.7",
|
|
57
|
+
"serve": "^14.2.4"
|
|
56
58
|
},
|
|
57
59
|
"publishConfig": {
|
|
58
60
|
"access": "public"
|