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 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 exact file size using binary search algorithm.
3
+ Automatically compress images to approximate target file size using binary search algorithm.
4
4
 
5
5
  [![version](https://img.shields.io/github/v/release/molvqingtai/imgcap)](https://www.npmjs.com/package/imgcap) [![workflow](https://github.com/molvqingtai/imgcap/actions/workflows/ci.yml/badge.svg)](https://github.com/molvqingtai/imgcap/actions) [![download](https://img.shields.io/npm/dt/imgcap)](https://www.npmjs.com/package/imgcap) [![npm package minimized gzipped size](https://img.shields.io/bundlejs/size/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 ImgCap?
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 exact size requirements - no user intervention needed.
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
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "__tests__",
3
3
  "version": "1.0.0",
4
- "description": "Automatically compress images to exact file size using binary search algorithm.",
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 tolerance size less than 1024', async () => {
22
- await expect(imgcap(testImageBlob, { targetSize: 10000, toleranceSize: 512 })).rejects.toThrow(
23
- 'Tolerance size must be at least ±1024 bytes.'
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 tolerance size less than -1024', async () => {
28
- await expect(imgcap(testImageBlob, { targetSize: 10000, toleranceSize: -512 })).rejects.toThrow(
29
- 'Tolerance size must be at least ±1024 bytes.'
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 accept tolerance size of exactly ±1024', async () => {
34
- await expect(imgcap(testImageBlob, { targetSize: 10000, toleranceSize: 1024 })).resolves.toBeDefined()
35
-
36
- await expect(imgcap(testImageBlob, { targetSize: 10000, toleranceSize: -1024 })).resolves.toBeDefined()
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 tolerance size greater than ±1024', async () => {
40
- await expect(imgcap(testImageBlob, { targetSize: 10000, toleranceSize: 2048 })).resolves.toBeDefined()
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 respect tolerance range', async () => {
69
- const targetSize = 3000
70
- const toleranceSize = -1024
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.size).toBeLessThanOrEqual(upperBound)
80
- // 对于负容差,只检查上界,因为压缩可能产生比期望更小的文件
81
- if (toleranceSize < 0) {
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 edge case with very large tolerance', async () => {
138
- const targetSize = 5000
139
- const toleranceSize = 10000
140
- const result = await imgcap(testImageBlob, {
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
- expect(result.size).toBeLessThanOrEqual(targetSize + toleranceSize)
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
 
@@ -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
- // Draw a simple pattern
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, width / 2, height / 2)
18
+ ctx.fillRect(0, 0, halfWidth, halfHeight)
19
+
20
+ // Top-right: Green
10
21
  ctx.fillStyle = '#00ff00'
11
- ctx.fillRect(width / 2, 0, width / 2, height / 2)
22
+ ctx.fillRect(halfWidth, 0, halfWidth, halfHeight)
23
+
24
+ // Bottom-left: Blue
12
25
  ctx.fillStyle = '#0000ff'
13
- ctx.fillRect(0, height / 2, width / 2, height / 2)
26
+ ctx.fillRect(0, halfHeight, halfWidth, halfHeight)
27
+
28
+ // Bottom-right: Yellow
14
29
  ctx.fillStyle = '#ffff00'
15
- ctx.fillRect(width / 2, height / 2, width / 2, height / 2)
30
+ ctx.fillRect(halfWidth, halfHeight, halfWidth, halfHeight)
16
31
 
17
32
  return await canvas.convertToBlob({ type })
18
33
  }
@@ -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 exact target file size using binary search algorithm.
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>;
@@ -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:o,quality:s}),p=f.size,m=n+Math.min(0,a),h=n+Math.max(0,a);return p>=m&&p<=h||(i-r)/i<.001?f: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,toleranceSize:i=-1024}=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(Math.abs(i)<1024)throw Error(`Tolerance size must be at least ±1024 bytes.`);let a=n.outputType||t.type;if(t.size<=r&&t.type===a)return t;let o=await createImageBitmap(t),s=0,c=1;return await e(o,r,s,c,i,a)};var n=t;export{n as default,t as imgcap};
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
@@ -1 +1 @@
1
- {"version":3,"file":"index.js","names":["imageBitmap: ImageBitmap","targetSize: number","low: number","high: number","toleranceSize: number","outputType: ImageType","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 */\n targetSize: number\n /** Size tolerance in bytes (default: -1024) */\n toleranceSize?: 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 toleranceSize: number,\n outputType: ImageType\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 // Check if current size is within tolerance range\n const lowerBound = targetSize + Math.min(0, toleranceSize)\n const upperBound = targetSize + Math.max(0, toleranceSize)\n\n if (currentSize >= lowerBound && currentSize <= upperBound) {\n return outputBlob\n }\n\n // Use relative error\n if ((high - low) / high < 0.001) {\n return outputBlob\n }\n\n if (currentSize > targetSize) {\n return await compress(imageBitmap, targetSize, low, mid, toleranceSize, outputType)\n } else {\n return await compress(imageBitmap, targetSize, mid, high, toleranceSize, outputType)\n }\n}\n\n/**\n * Compress an image to exact target file size using binary search algorithm.\n *\n * @param input - Image blob/file to compress\n * @param options - Compression options\n * @returns Promise that resolves to compressed image blob\n *\n * @example\n * ```typescript\n * // Basic usage - compress to 500KB\n * const compressed = await imgcap(imageFile, { targetSize: 500 * 1024 })\n *\n * // With format conversion\n * const webp = await imgcap(imageFile, {\n * targetSize: 300 * 1024,\n * outputType: 'image/webp'\n * })\n * ```\n */\nexport const imgcap = async (input: Blob, options: Options) => {\n const { targetSize, toleranceSize = -1024 } = 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 (Math.abs(toleranceSize) < 1024) {\n throw new Error('Tolerance size must be at least ±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, toleranceSize, outputType)\n}\n\nexport default imgcap\n"],"mappings":"AAaA,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,KAGzB,EAAa,EAAa,KAAK,IAAI,EAAG,EAAc,CACpD,EAAa,EAAa,KAAK,IAAI,EAAG,EAAc,CAcxD,OAZE,GAAe,GAAc,GAAe,IAK3C,EAAO,GAAO,EAAO,KACjB,EAGL,EAAc,EACT,KAAM,GAAS,EAAa,EAAY,EAAK,EAAK,EAAe,EAAW,CAE5E,KAAM,GAAS,EAAa,EAAY,EAAK,EAAM,EAAe,EAAW,AAEvF,EAqBY,EAAS,MAAOC,EAAaC,IAAqB,CAC7D,GAAM,CAAE,aAAY,gBAAgB,MAAO,CAAG,EAE9C,IAAK,CAAC,aAAc,YAAa,aAAc,YAAa,EAAC,SAAS,EAAM,KAAK,CAC/E,KAAM,CAAI,MAAM,sDAAA,CAGlB,GAAI,KAAK,IAAI,EAAc,CAAG,KAC5B,KAAM,CAAI,MAAM,+CAAA,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,EAAe,EAAW,AACrF,EAED,IAAA,EAAe"}
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 exact file size using binary search algorithm.",
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
- toleranceSize: number,
20
- outputType: ImageType
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
- // Check if current size is within tolerance range
40
- const lowerBound = targetSize + Math.min(0, toleranceSize)
41
- const upperBound = targetSize + Math.max(0, toleranceSize)
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
- if (currentSize >= lowerBound && currentSize <= upperBound) {
44
- return outputBlob
42
+ // Precision limit reached - return best result
43
+ if ((high - low) / high < 0.01) {
44
+ return bestBlob
45
45
  }
46
46
 
47
- // Use relative error
48
- if ((high - low) / high < 0.001) {
49
- return outputBlob
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, toleranceSize, outputType)
59
+ return await compress(imageBitmap, targetSize, low, mid, outputType, bestBlob)
54
60
  } else {
55
- return await compress(imageBitmap, targetSize, mid, high, toleranceSize, outputType)
61
+ return await compress(imageBitmap, targetSize, mid, high, outputType, bestBlob)
56
62
  }
57
63
  }
58
64
 
59
65
  /**
60
- * Compress an image to exact target file size using binary search algorithm.
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, toleranceSize = -1024 } = options
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 (Math.abs(toleranceSize) < 1024) {
86
- throw new Error('Tolerance size must be at least ±1024 bytes.')
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, toleranceSize, outputType)
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.1",
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"