mjpic 1.0.9 → 1.0.19

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.
@@ -6,8 +6,8 @@
6
6
  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
7
7
  <meta name="description" content="敏捷图片(mjpic)是一个轻量级网页版图片处理工具,设计灵感来源于光影魔术手。" />
8
8
  <title>敏捷图片 (mjpic) - 轻量级网页版图片处理工具</title>
9
- <script type="module" crossorigin src="/assets/index--OgUxz8X.js"></script>
10
- <link rel="stylesheet" crossorigin href="/assets/index-BoiS81Ei.css">
9
+ <script type="module" crossorigin src="/assets/index-Tbz-to9P.js"></script>
10
+ <link rel="stylesheet" crossorigin href="/assets/index-C6nMBMvY.css">
11
11
  </head>
12
12
  <body>
13
13
  <div id="root"></div>
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "mjpic",
3
3
  "description": "敏捷图片(mjpic)是一个轻量级网页版图片处理工具,设计灵感来源于光影魔术手。",
4
- "version": "1.0.9",
4
+ "version": "1.0.19",
5
5
  "type": "module",
6
6
  "bin": {
7
7
  "mjpic": "./dist/cli/cli.js"
@@ -75,8 +75,9 @@ export const CanvasArea = ({ stageRef }: CanvasAreaProps) => {
75
75
  // Use default values to prevent undefined issues
76
76
  const { size = 0, applyHorizontal = true, applyVertical = false } = config.border;
77
77
  if (size > 0) {
78
- if (applyHorizontal) borderW = imgWidth * (size / 100);
79
- if (applyVertical) borderH = imgHeight * (size / 100);
78
+ // Ensure border dimensions are integers to avoid sub-pixel black lines during rendering
79
+ if (applyHorizontal) borderW = Math.round(imgWidth * (size / 100));
80
+ if (applyVertical) borderH = Math.round(imgHeight * (size / 100));
80
81
  }
81
82
  }
82
83
 
@@ -231,7 +232,13 @@ export const CanvasArea = ({ stageRef }: CanvasAreaProps) => {
231
232
  filters.push(sharpnessFilter);
232
233
  }
233
234
 
234
- // Auto Enhance - 全面提升图片质量(优化版本)
235
+ // Auto Enhance - 智能自适应图片美化
236
+ // 算法特点:
237
+ // 1. 基于图像直方图分析,动态生成优化参数
238
+ // 2. 自适应亮度控制:保护高光,提升暗部
239
+ // 3. 智能对比度:根据动态范围自适应
240
+ // 4. 自然饱和度:避免过度饱和
241
+ // 5. 保持色温不变
235
242
  if (config.enhancements?.autoEnhance) {
236
243
  // @ts-ignore
237
244
  const autoEnhanceFilter = function(imageData) {
@@ -240,71 +247,138 @@ export const CanvasArea = ({ stageRef }: CanvasAreaProps) => {
240
247
  const height = imageData.height;
241
248
  const pixelCount = width * height;
242
249
 
250
+ // === 第一阶段:图像统计分析 ===
243
251
  // 使用采样来加速统计(每8个像素采样一次)
244
252
  const sampleStep = 8;
245
- let sumR = 0, sumG = 0, sumB = 0;
246
253
  let minVal = 255, maxVal = 0;
247
254
  let totalLuminance = 0;
255
+ let darkPixels = 0, brightPixels = 0; // 暗部和亮部像素统计
248
256
  let sampleCount = 0;
249
257
 
258
+ // 用于计算色彩分布
259
+ let sumR = 0, sumG = 0, sumB = 0;
260
+
250
261
  for (let i = 0; i < data.length; i += 4 * sampleStep) {
251
262
  const r = data[i], g = data[i+1], b = data[i+2];
252
- sumR += r; sumG += g; sumB += b;
253
263
  const lum = 0.299 * r + 0.587 * g + 0.114 * b;
264
+
254
265
  if (lum < minVal) minVal = lum;
255
266
  if (lum > maxVal) maxVal = lum;
256
267
  totalLuminance += lum;
268
+
269
+ // 统计暗部(<50)和亮部(>200)像素
270
+ if (lum < 50) darkPixels++;
271
+ if (lum > 200) brightPixels++;
272
+
273
+ sumR += r; sumG += g; sumB += b;
257
274
  sampleCount++;
258
275
  }
259
276
 
277
+ const avgLuminance = totalLuminance / sampleCount;
278
+ const dynamicRange = maxVal - minVal;
279
+ const darkRatio = darkPixels / sampleCount;
280
+ const brightRatio = brightPixels / sampleCount;
281
+
282
+ // 计算平均色彩(用于饱和度判断)
260
283
  const avgR = sumR / sampleCount;
261
284
  const avgG = sumG / sampleCount;
262
285
  const avgB = sumB / sampleCount;
263
- const avgLuminance = totalLuminance / sampleCount;
286
+ const avgColorLum = 0.299 * avgR + 0.587 * avgG + 0.114 * avgB;
287
+ const colorSaturation = (Math.abs(avgR - avgColorLum) + Math.abs(avgG - avgColorLum) + Math.abs(avgB - avgColorLum)) / 3 / 255;
264
288
 
265
- // 白平衡校正
266
- const wbR = avgG / (avgR || 1);
267
- const wbB = avgG / (avgB || 1);
289
+ // === 第二阶段:动态参数生成 ===
268
290
 
269
- // 计算调整参数
270
- let brightnessAdjust = 0;
271
- if (avgLuminance < 100) brightnessAdjust = (100 - avgLuminance) * 0.15;
272
- else if (avgLuminance > 160) brightnessAdjust = -(avgLuminance - 160) * 0.1;
291
+ // 1. 智能亮度调整(基于图像亮度分布)
292
+ // 使用曲线映射而非固定增量,保护高光
293
+ let shadowBoost = 0; // 暗部提升
294
+ let highlightCompress = 0; // 高光压缩
273
295
 
274
- const contrastAdjust = (maxVal - minVal) < 150 ? 15 : 8;
275
- const contrastFactor = (259 * (contrastAdjust + 255)) / (255 * (259 - contrastAdjust));
296
+ if (avgLuminance < 100) {
297
+ // 较暗图片:适度提升暗部,保护高光
298
+ shadowBoost = 25 + (100 - avgLuminance) * 0.15;
299
+ highlightCompress = 0;
300
+ } else if (avgLuminance > 180) {
301
+ // 较亮图片:减少提升,避免过曝
302
+ shadowBoost = 10;
303
+ highlightCompress = (avgLuminance - 180) * 0.3;
304
+ } else {
305
+ // 正常亮度:温和提升
306
+ shadowBoost = 20;
307
+ highlightCompress = 0;
308
+ }
309
+
310
+ // 2. 智能对比度(基于动态范围)
311
+ // 动态范围小的图片需要更多对比度
312
+ let contrastAdjust = 0;
313
+ if (dynamicRange < 100) {
314
+ contrastAdjust = 8;
315
+ } else if (dynamicRange < 150) {
316
+ contrastAdjust = 5;
317
+ } else if (dynamicRange > 200) {
318
+ contrastAdjust = -3; // 高动态范围图片,轻微降低对比度
319
+ }
320
+ const contrastFactor = contrastAdjust !== 0
321
+ ? (259 * (contrastAdjust + 255)) / (255 * (259 - contrastAdjust))
322
+ : 1;
276
323
 
277
- // 使用采样应用效果
278
- const applyStep = 4; // 逐像素处理
324
+ // 3. 智能饱和度(基于当前饱和度)
325
+ // 低饱和度图片需要更多提升,高饱和度图片减少提升
326
+ let saturationBoost = 1.08; // 基础提升8%
327
+ if (colorSaturation < 0.15) {
328
+ saturationBoost = 1.12; // 低饱和度图片提升12%
329
+ } else if (colorSaturation > 0.4) {
330
+ saturationBoost = 1.04; // 高饱和度图片仅提升4%
331
+ }
332
+
333
+ // === 第三阶段:应用处理 ===
279
334
  for (let i = 0; i < data.length; i += 4) {
280
- // 白平衡
281
- data[i] = Math.min(255, data[i] * wbR);
282
- data[i+2] = Math.min(255, data[i+2] * wbB);
335
+ let r = data[i], g = data[i+1], b = data[i+2];
336
+ const lum = 0.299 * r + 0.587 * g + 0.114 * b;
283
337
 
284
- // 亮度+对比度
285
- const lum = 0.299 * data[i] + 0.587 * data[i+1] + 0.114 * data[i+2];
286
- const newLum = lum + brightnessAdjust;
287
- const contrastLum = contrastFactor * (newLum - 128) + 128;
288
- const ratio = contrastLum / (lum || 1);
338
+ // 步骤1:自适应亮度调整(使用曲线映射)
339
+ // 暗部提升多,亮部提升少,保护高光
340
+ let brightnessCurve = 0;
341
+ if (lum < 80) {
342
+ // 暗部:线性提升
343
+ brightnessCurve = shadowBoost * (1 - lum / 80 * 0.3);
344
+ } else if (lum < 180) {
345
+ // 中间调:渐变减少提升
346
+ brightnessCurve = shadowBoost * 0.7 * (1 - (lum - 80) / 100 * 0.5);
347
+ } else {
348
+ // 高光:轻微提升或压缩
349
+ brightnessCurve = shadowBoost * 0.35 - highlightCompress * ((lum - 180) / 75);
350
+ }
351
+
352
+ r = Math.min(255, Math.max(0, r + brightnessCurve));
353
+ g = Math.min(255, Math.max(0, g + brightnessCurve));
354
+ b = Math.min(255, Math.max(0, b + brightnessCurve));
289
355
 
290
- data[i] = Math.min(255, data[i] * ratio);
291
- data[i+1] = Math.min(255, data[i+1] * ratio);
292
- data[i+2] = Math.min(255, data[i+2] * ratio);
356
+ // 步骤2:对比度调整(以128为中心)
357
+ if (contrastFactor !== 1) {
358
+ r = Math.min(255, Math.max(0, contrastFactor * (r - 128) + 128));
359
+ g = Math.min(255, Math.max(0, contrastFactor * (g - 128) + 128));
360
+ b = Math.min(255, Math.max(0, contrastFactor * (b - 128) + 128));
361
+ }
362
+
363
+ // 步骤3:饱和度提升(保持色温)
364
+ const gray = 0.299 * r + 0.587 * g + 0.114 * b;
365
+ r = Math.min(255, Math.max(0, gray + (r - gray) * saturationBoost));
366
+ g = Math.min(255, Math.max(0, gray + (g - gray) * saturationBoost));
367
+ b = Math.min(255, Math.max(0, gray + (b - gray) * saturationBoost));
293
368
 
294
- // 饱和度提升
295
- const gray = 0.299 * data[i] + 0.587 * data[i+1] + 0.114 * data[i+2];
296
- const sat = 1.15;
297
- data[i] = Math.min(255, gray + (data[i] - gray) * sat);
298
- data[i+1] = Math.min(255, gray + (data[i+1] - gray) * sat);
299
- data[i+2] = Math.min(255, gray + (data[i+2] - gray) * sat);
369
+ data[i] = r;
370
+ data[i+1] = g;
371
+ data[i+2] = b;
300
372
  }
301
373
 
302
- // 简化锐化(只处理边缘像素)
374
+ // === 第四阶段:智能锐化 ===
375
+ // 根据图像内容自适应锐化强度
376
+ const sharpnessAmount = dynamicRange < 100 ? 0.25 : 0.2;
303
377
  const oldData = new Uint8ClampedArray(data);
304
- const sharpenAmount = 0.3;
305
- const center = 1 + 4 * sharpenAmount;
306
- const neighbor = -sharpenAmount;
378
+ const center = 1 + 4 * sharpnessAmount;
379
+ const neighbor = -sharpnessAmount;
307
380
 
381
+ // 隔行隔列采样处理,提升性能
308
382
  for (let y = 1; y < height - 1; y += 2) {
309
383
  for (let x = 1; x < width - 1; x += 2) {
310
384
  const idx = (y * width + x) * 4;
@@ -614,16 +688,18 @@ export const CanvasArea = ({ stageRef }: CanvasAreaProps) => {
614
688
  {image && (
615
689
  <Group
616
690
  id="content-group"
617
- x={stageSize.width / 2}
618
- y={stageSize.height / 2}
691
+ // Use Math.round to avoid sub-pixel positioning which causes antialiasing black lines on borders
692
+ x={Math.round(stageSize.width / 2)}
693
+ y={Math.round(stageSize.height / 2)}
619
694
  rotation={config.rotation}
620
695
  >
621
696
  {(displayBorderW > 0 || displayBorderH > 0) && (
622
697
  <Rect
623
- width={displayWidth + displayBorderW * 2}
624
- height={displayHeight + displayBorderH * 2}
625
- offsetX={(displayWidth + displayBorderW * 2) / 2}
626
- offsetY={(displayHeight + displayBorderH * 2) / 2}
698
+ // Round up border dimensions to ensure full coverage
699
+ width={Math.ceil(displayWidth + displayBorderW * 2)}
700
+ height={Math.ceil(displayHeight + displayBorderH * 2)}
701
+ offsetX={Math.round((displayWidth + displayBorderW * 2) / 2)}
702
+ offsetY={Math.round((displayHeight + displayBorderH * 2) / 2)}
627
703
  fill={config.border?.color || '#ffffff'}
628
704
  listening={false}
629
705
  />
@@ -638,8 +714,8 @@ export const CanvasArea = ({ stageRef }: CanvasAreaProps) => {
638
714
  height={imgHeight}
639
715
  scaleX={scale}
640
716
  scaleY={scale}
641
- offsetX={imgWidth / 2}
642
- offsetY={imgHeight / 2}
717
+ offsetX={Math.round(imgWidth / 2)}
718
+ offsetY={Math.round(imgHeight / 2)}
643
719
  />
644
720
  </Group>
645
721
  )}
@@ -818,10 +894,11 @@ export const CanvasArea = ({ stageRef }: CanvasAreaProps) => {
818
894
  anchorStroke="#3b82f6"
819
895
  anchorFill="#3b82f6"
820
896
  anchorSize={8}
821
- enabledAnchors={isFreeCrop
822
- ? ['top-left', 'top-right', 'bottom-left', 'bottom-right', 'middle-left', 'middle-right', 'top-center', 'bottom-center']
823
- : ['top-left', 'top-right', 'bottom-left', 'bottom-right']
824
- }
897
+ // 所有裁剪模式都支持 8 个方向的手柄,方便对称裁剪
898
+ enabledAnchors={[
899
+ 'top-left', 'top-right', 'bottom-left', 'bottom-right',
900
+ 'top-center', 'bottom-center', 'middle-left', 'middle-right'
901
+ ]}
825
902
  boundBoxFunc={(oldBox, newBox) => {
826
903
  if (newBox.width < 20 || newBox.height < 20) {
827
904
  return oldBox;
@@ -840,6 +917,24 @@ export const CanvasArea = ({ stageRef }: CanvasAreaProps) => {
840
917
  }
841
918
  }
842
919
 
920
+ // 检测正在使用的手柄,保持中心位置不变
921
+ const transformerInstance = transformerRef.current;
922
+ if (transformerInstance) {
923
+ const activeAnchor = transformerInstance.getActiveAnchor();
924
+
925
+ if (activeAnchor === 'top-center' || activeAnchor === 'bottom-center') {
926
+ // 使用上/下手柄时,保持水平中心位置不变
927
+ const oldCenterX = oldBox.x + oldBox.width / 2;
928
+ const newCenterX = newBox.x + newBox.width / 2;
929
+ newBox.x += oldCenterX - newCenterX;
930
+ } else if (activeAnchor === 'middle-left' || activeAnchor === 'middle-right') {
931
+ // 使用左/右手柄时,保持垂直中心位置不变
932
+ const oldCenterY = oldBox.y + oldBox.height / 2;
933
+ const newCenterY = newBox.y + newBox.height / 2;
934
+ newBox.y += oldCenterY - newCenterY;
935
+ }
936
+ }
937
+
843
938
  const relX = newBox.x - contentX;
844
939
  const relY = newBox.y - contentY;
845
940
  if (relX < -5 || relY < -5 ||
@@ -40,53 +40,103 @@ export const Header = ({ stageRef }: HeaderProps) => {
40
40
 
41
41
  try {
42
42
  // Get the current state from the image store
43
- const { previewImage, config, originalWidth, originalHeight } = useImageStore.getState();
43
+ const { config, originalWidth, originalHeight } = useImageStore.getState();
44
44
 
45
- if (!previewImage) {
45
+ // Find the image node to get actual dimensions
46
+ const imageNode = stageRef.current.findOne('Image') as Konva.Image;
47
+ if (!imageNode) {
46
48
  console.error('No image found to save');
47
49
  return;
48
50
  }
49
51
 
50
52
  // Determine the final dimensions based on resize config
51
- let finalWidth = originalWidth || 800;
52
- let finalHeight = originalHeight || 600;
53
+ let finalWidth = originalWidth || imageNode.width();
54
+ let finalHeight = originalHeight || imageNode.height();
53
55
 
54
56
  if (config.resize && config.resize.width > 0 && config.resize.height > 0) {
55
57
  finalWidth = config.resize.width;
56
58
  finalHeight = config.resize.height;
57
59
  }
58
60
 
59
- // Create a temporary canvas with the correct dimensions
60
- const canvas = document.createElement('canvas');
61
- canvas.width = finalWidth;
62
- canvas.height = finalHeight;
63
- const ctx = canvas.getContext('2d');
61
+ // Ensure finalWidth and finalHeight are integers to prevent sub-pixel rendering issues (black lines)
62
+ finalWidth = Math.round(finalWidth);
63
+ finalHeight = Math.round(finalHeight);
64
64
 
65
- if (!ctx) {
66
- console.error('Failed to create canvas context');
67
- return;
65
+ // Calculate the pixel ratio needed to get the correct output size
66
+ const pixelRatio = finalWidth / imageNode.width();
67
+
68
+ // Hide other elements temporarily to only capture the image
69
+ const transformer = stageRef.current.findOne('Transformer');
70
+ const cropGroup = stageRef.current.findOne('#crop-group');
71
+ const rotationGrid = stageRef.current.findOne('#rotation-grid');
72
+
73
+ const transformerVisible = transformer?.visible();
74
+ const cropGroupVisible = cropGroup?.visible();
75
+ const rotationGridVisible = rotationGrid?.visible();
76
+
77
+ if (transformer) transformer.visible(false);
78
+ if (cropGroup) cropGroup.visible(false);
79
+ if (rotationGrid) rotationGrid.visible(false);
80
+
81
+ // Force a redraw
82
+ stageRef.current.batchDraw();
83
+
84
+ // Wait a bit for the render to complete
85
+ await new Promise(resolve => setTimeout(resolve, 50));
86
+
87
+ // Ensure finalWidth and finalHeight are integers to prevent sub-pixel rendering issues (black lines)
88
+ finalWidth = Math.round(finalWidth);
89
+ finalHeight = Math.round(finalHeight);
90
+
91
+ // Get the cached canvas from the image node
92
+ // This gives us the filtered image without group transformations
93
+ const cachedCanvas = (imageNode as any)._cacheCanvas;
94
+
95
+ let dataUrl: string;
96
+
97
+ if (cachedCanvas) {
98
+ // Create a temporary canvas with the correct output size
99
+ const tempCanvas = document.createElement('canvas');
100
+ tempCanvas.width = finalWidth;
101
+ tempCanvas.height = finalHeight;
102
+ const ctx = tempCanvas.getContext('2d');
103
+
104
+ if (ctx) {
105
+ // Set high quality rendering
106
+ ctx.imageSmoothingEnabled = true;
107
+ ctx.imageSmoothingQuality = 'high';
108
+
109
+ // Ensure dimensions are integers when drawing to avoid sub-pixel interpolation artifacts
110
+ ctx.drawImage(
111
+ cachedCanvas,
112
+ 0, 0, Math.round(cachedCanvas.width), Math.round(cachedCanvas.height),
113
+ 0, 0, finalWidth, finalHeight
114
+ );
115
+
116
+ // Get the data URL from the temporary canvas
117
+ dataUrl = tempCanvas.toDataURL(format, quality / 100);
118
+ } else {
119
+ // Fallback: use toCanvas if context creation fails
120
+ const fallbackCanvas = imageNode.toCanvas({
121
+ pixelRatio: pixelRatio
122
+ });
123
+ dataUrl = fallbackCanvas.toDataURL(format, quality / 100);
124
+ }
125
+ } else {
126
+ // Fallback: use toCanvas if no cache
127
+ const fallbackCanvas = imageNode.toCanvas({
128
+ pixelRatio: pixelRatio
129
+ });
130
+ dataUrl = fallbackCanvas.toDataURL(format, quality / 100);
68
131
  }
69
132
 
70
- // Set high quality rendering
71
- ctx.imageSmoothingEnabled = true;
72
- ctx.imageSmoothingQuality = 'high';
73
-
74
- // Load the preview image as the source
75
- const img = new Image();
76
- img.crossOrigin = 'anonymous';
77
-
78
- await new Promise<void>((resolve, reject) => {
79
- img.onload = () => {
80
- // Draw the image with the correct dimensions
81
- ctx.drawImage(img, 0, 0, finalWidth, finalHeight);
82
- resolve();
83
- };
84
- img.onerror = reject;
85
- img.src = previewImage;
86
- });
87
-
88
- // Convert canvas to data URL with correct quality
89
- const dataUrl = canvas.toDataURL(format, quality / 100);
133
+ // Restore visibility
134
+ if (transformer) transformer.visible(transformerVisible);
135
+ if (cropGroup) cropGroup.visible(cropGroupVisible);
136
+ if (rotationGrid) rotationGrid.visible(rotationGridVisible);
137
+
138
+ // Force another redraw
139
+ stageRef.current.batchDraw();
90
140
 
91
141
  // Check if we are in CLI mode with an opened file
92
142
  // If savePath is provided (from dialog), use it