sekoyo / react-image-crop

A responsive image cropping tool for React
ISC License
3.85k stars 344 forks source link

Preview Canvas resolution is huge. #564

Closed bensquire closed 11 months ago

bensquire commented 11 months ago

I'm not sure if this is the intended behaviour or not, but I've seen a few issue in the repo complaining about huge files when extracting the final image from the preview canvas.

I think the issue comes down to people expecting the final image to have the width/height of the canvas, but instead ~it's the same~ has a higher resolution than the input image.

You can see this in the demo app by looking at the inspector:

Screenshot 2023-10-06 at 16 53 13

While we have set a width/height of 355px x 200px the actual canvas is 5828px x 3276px. When I export that image and compare it to the original, it's filesize has ballooned.

Screenshot 2023-10-06 at 16 55 43

For our own project, I've worked round this by building out a new version of canvasPreview.tsx, but I've removed the stuff we don't need; this allows me to set the desired output size. This probably isn't fit 4 purpose, because it's so heavily tailored towards our needs, but maybe it can help rectify an ongoing issue?

interface CropObject {
    x: number;
    y: number;
    width: number;
    height: number;
}

export function canvasPreview(
    image: HTMLImageElement,
    canvas: HTMLCanvasElement,
    crop: CropObject,
    canvasWidth: number,
    canvasHeight: number
) {
    // Get the canvas context
    const ctx = canvas.getContext('2d');

    // If there's no context, throw an error
    if (!ctx) {
        throw new Error('No 2d context available');
    }

    // Given that the canvas is always 250x250
    canvas.width = canvasWidth;
    canvas.height = canvasHeight;

    // Calculate scaling factors based on natural dimensions of the image
    const scaleX = image.naturalWidth / image.width;
    const scaleY = image.naturalHeight / image.height;

    // Calculate the real crop dimensions
    const cropX = crop.x * scaleX;
    const cropY = crop.y * scaleY;
    const cropWidth = crop.width * scaleX;
    const cropHeight = crop.height * scaleY;

    // Clear the canvas (Useful if this function is called multiple times)
    ctx.clearRect(0, 0, canvas.width, canvas.height);

    // Perform the drawing operation
    ctx.drawImage(
        image,
        cropX, cropY, cropWidth, cropHeight, // Source coordinates
        0, 0, canvas.width, canvas.height // Destination coordinates
    );
}
sekoyo commented 11 months ago

The issue is that for correct quality on devices with a larger pixel ratio than 1 (window.devicePixelRatio) like retina screens, you have to have a larger canvas than is actually rendered.

I was lazy and didn't resize it down to the correct value on click of the Download button.

What I should do is if (window.devicePixelRatio !== 1) create an Offscreen Canvas of the actual crop size and copy the preview canvas to it (ctx.drawImage can resize as its copying).

But yes your way is the simple solution of always rendering it at the actual size which works too (at the expense of some visual quality on retina devices) ;)

bensquire commented 11 months ago

I am keen to improve quality if possible... If I were to use my method and the offscreen canvas, would that give me better quality output, or is it not as simple as that?

sekoyo commented 11 months ago

I've updated the example here: https://codesandbox.io/s/react-image-crop-demo-with-react-hooks-y831o?file=/src/App.tsx

async function onDownloadCropClick() {
  const image = imgRef.current
  const previewCanvas = previewCanvasRef.current
  if (!image || !previewCanvas || !completedCrop) {
    throw new Error('Crop canvas does not exist')
  }

  // This will size relative to the uploaded image
  // size. If you want to size according to what they
  // are looking at on screen, remove scaleX + scaleY
  const scaleX = image.naturalWidth / image.width
  const scaleY = image.naturalHeight / image.height

  const offscreen = new OffscreenCanvas(
    completedCrop.width * scaleX,
    completedCrop.height * scaleY,
  )
  const ctx = offscreen.getContext('2d')
  if (!ctx) {
    throw new Error('No 2d context')
  }

  ctx.drawImage(
    previewCanvas,
    0,
    0,
    previewCanvas.width,
    previewCanvas.height,
    0,
    0,
    offscreen.width,
    offscreen.height,
  )
  // You might want { type: "image/jpeg", quality: <0 to 1> } to
  // reduce image size
  const blob = await offscreen.convertToBlob({
    type: 'image/png',
  })

  if (blobUrlRef.current) {
    URL.revokeObjectURL(blobUrlRef.current)
  }
  blobUrlRef.current = URL.createObjectURL(blob)
  hiddenAnchorRef.current!.href = blobUrlRef.current
  hiddenAnchorRef.current!.click()
}

Note the comment about scaleX + scaleY, remove those to get a crop exactly as the user sees it. Otherwise it will be based on the uploaded image which could be a lot bigger than that.

bensquire commented 11 months ago

Sorry @DominicTobias I forgot to reply, this worked a treat thank-you.