ValentinH / react-easy-crop

A React component to crop images/videos with easy interactions
https://valentinh.github.io/react-easy-crop/
MIT License
2.21k stars 167 forks source link

The cropped image is black on mobile Safari when the image is very large #91

Closed yuanxiang1990 closed 4 years ago

yuanxiang1990 commented 4 years ago

The cropped image is black on mobile Safari when the image is very large.(https://codesandbox.io/embed/react-easy-crop-custom-image-demo-hgqst?codemirror=1).

ValentinH commented 4 years ago

Thanks for posting this issue. However I don't have a device with Safari mobile so I'm not able to reproduce.

When you say the "cropped image", are you talking about the image in the pop-up after clicking the Crop button ?

yuanxiang1990 commented 4 years ago

Thanks for posting this issue. However I don't have a device with Safari mobile so I'm not able to reproduce.

When you say the "cropped image", are you talking about the image in the pop-up after clicking the Crop button ?

Yes, maybe Safari on the mobile terminal will fail to draw a large picture on canvas.

ValentinH commented 4 years ago

Actually, on codesandbox, we are using a less efficient approach to create the image. If you look at the createImage.js file, there is a commented block (As a blob): try to use this one instead of the block above.

yuanxiang1990 commented 4 years ago

Actually, on codesandbox, we are using a less efficient approach to create the image. If you look at the createImage.js file, there is a commented block (As a blob): try to use this one instead of the block above.

Thank you for your advice. I tried,The problem still exists.I think it's caused by the following code in the createimage.jsfile

const safeArea = Math.max(image.width, image.height) * 2 
canvas.width = safeArea
canvas.height = safeArea

// translate canvas context to a central location on image to allow rotating around the center.
ctx.translate(safeArea / 2, safeArea / 2)
ctx.rotate(getRadianAngle(rotation))
ctx.translate(-safeArea / 2, -safeArea / 2)

// draw rotated image and store data.
ctx.drawImage(
    image,
    safeArea / 2 - image.width * 0.5,
    safeArea / 2 - image.height * 0.5
)

The width and height of the canvas can be large, which will lead to canvas rotation and painting failure under the mobile Safari browser with low performance.

ValentinH commented 4 years ago

Do you get any error message in the console when the black screen appears?

brennick commented 4 years ago

I am having the same issue on iOS 13 Safari. Images from camera are failing to be able to be cropped and appearing as a black image.

ValentinH commented 4 years ago

Are you also talking about the image that we crop on the client-side and display in the pop-up? Or about the image in the cropper component?

brennick commented 4 years ago

The image that is appearing black is after the cropping has finished and when we are trying to display the output.

We resolved it by modifying our getCroppedImage code to divide the width and height by 2.

Before:

      const image: HTMLImageElement = await createImage(imageSrc);

      const canvas = document.createElement('canvas');
      const ctx = canvas.getContext('2d');

      const safeArea = Math.max(image.width, image.height) * 2;

      // set each dimensions to double largest dimension to allow for a safe area for the
      // image to rotate in without being clipped by canvas context
      canvas.width = safeArea;
      canvas.height = safeArea;

      if (!ctx) {
        setIsLoading(false);
        return null;
      }

      const getRadianAngle = (degreeValue: number) => {
        return (degreeValue * Math.PI) / 180;
      };
      // translate canvas context to a central location on image to allow rotating around the center.
      ctx.translate(safeArea / 2, safeArea / 2);
      ctx.rotate(getRadianAngle(rotation));
      ctx.translate(-safeArea / 2, -safeArea / 2);

      // draw rotated image and store data.
      ctx.drawImage(
        image,
        safeArea / 2 - image.width * 0.5,
        safeArea / 2 - image.height * 0.5
      );
      const data = ctx.getImageData(0, 0, safeArea, safeArea);

      // set canvas width to final desired crop size - this will clear existing context
      canvas.width = pixelCrop.width;
      canvas.height = pixelCrop.height;

      // paste generated rotate image with correct offsets for x,y crop values.
      ctx.putImageData(
        data,
        0 - safeArea / 2 + image.width * 0.5 - pixelCrop.x,
        0 - safeArea / 2 + image.height * 0.5 - pixelCrop.y
      );
      return canvas.toDataURL('image/jpeg');

After:

      const image: HTMLImageElement = await createImage(imageSrc);

      const canvas = document.createElement('canvas');
      const ctx = canvas.getContext('2d');

      image.width = image.width / 2;
      image.height = image.height / 2;

      const safeArea = Math.max(image.width, image.height) * 2;

      // set each dimensions to double largest dimension to allow for a safe area for the
      // image to rotate in without being clipped by canvas context
      canvas.width = safeArea;
      canvas.height = safeArea;

      if (!ctx) {
        setIsLoading(false);
        return null;
      }

      const getRadianAngle = (degreeValue: number) => {
        return (degreeValue * Math.PI) / 180;
      };
      // translate canvas context to a central location on image to allow rotating around the center.
      ctx.translate(safeArea / 2, safeArea / 2);
      ctx.rotate(getRadianAngle(rotation));
      ctx.translate(-safeArea / 2, -safeArea / 2);

      // draw rotated image and store data.
      ctx.drawImage(
        image,
        safeArea / 2 - image.width,
        safeArea / 2 - image.height
      );
      const data = ctx.getImageData(0, 0, safeArea, safeArea);

      // set canvas width to final desired crop size - this will clear existing context
      canvas.width = pixelCrop.width;
      canvas.height = pixelCrop.height;

      // paste generated rotate image with correct offsets for x,y crop values.
      ctx.putImageData(
        data,
        0 - safeArea / 2 + image.width - pixelCrop.x,
        0 - safeArea / 2 + image.height - pixelCrop.y
      );
      return canvas.toDataURL('image/jpeg');
ValentinH commented 4 years ago

Thanks for sharing this @brennick. Indeed, I found another issue thread on another React crop component sharing this. Making the canvas smaller was also the solution there.

ValentinH commented 4 years ago

I'm closing this issue now as this is actually not an issue with the library itself. Creating the cropped image is not done by the lib. Also a solution was shared above.

ValentinH commented 4 years ago

Just a small update: I've changed the logic in the demo to use a smaller safeArea: it used to be twice the max of width and height. Now it uses this formula which only uses the minimal size:

const maxSize = Math.max(image.width, image.height)
const safeArea = 2 * ((maxSize / 2) * Math.sqrt(2))
a-m-dev commented 4 years ago

problem still exists for iPhone , iPhone just warns us that its been exceeds the maximum memory allocated

ValentinH commented 4 years ago

OK then it means you need to make the safeArea even smaller to reduce the memory footprint.

kodai3 commented 3 years ago

@ValentinH Thank you for the work. I had the same issue and took me some time to find this. and seems to be same for others https://github.com/ricardo-ch/react-easy-crop/issues/98, https://github.com/ricardo-ch/react-easy-crop/issues/147

I thought it would be great to be documented as tips or known issue

ValentinH commented 3 years ago

The thing is that the cropping logic using canvas are not part of this library. They are just provided as an example. I don't want to have this in the library because there are a lot of edge cases as we can see in the issues.

kdenz commented 3 years ago

@ValentinH What worked for me is to utilize canvas-size library to detect max canvas size supported, and then do something like below to make sure safeArea is within max canvas sizes. On mobile Safari the limit is 4096 * 4096 for example.

const canvasLimitation = await canvasSize.maxArea({
    usePromise: true,
    useWorker: true,
  });

  if (safeArea > canvasLimitation.height) {
    safeArea *= canvasLimitation.height / safeArea;
  }
ValentinH commented 3 years ago

Thank you really much for sharing this! I think that we could use this to limit that image output size instead of only limiting the safe area. Otherwise, some parts might be cut when using rotation.

It means that we first need to resize the image to the maximum supported size (if smaller than image size) before applying the crop operations.

kdenz commented 3 years ago

@ValentinH No problem : ) Right, it makes sense, I was using this without rotation.

schwamic commented 3 years ago

Another solution to the problem is to normalise all images first, e.g. the size of the input via compressor-js:

const onFileChange = async e => {
    if (e.target.files && e.target.files.length > 0) {
      const file = e.target.files[0]
      const imageDataUrl = await readFile(file)
      setImage(imageDataUrl) // <Cropper image={image} />
    }
  }
const readFile = useCallback(file => {
    return new Promise((resolve, reject) => {
      try {
        const reader = new FileReader()
        reader.onload = () => resolve(reader.result)
        getNormalizedFile(file)
          .then(normalizedFile => reader.readAsDataURL(normalizedFile))
          .catch(error => reject(error))
      } catch (error) {
        reject(error)
      }
    })
  }, [])
export const getNormalizedFile = file => {
  return new Promise((resolve, reject) => {
    new Compressor(file, {
      maxWidth: 1000,
      maxHeight: 1000,
      success(normalizedFile) {
        resolve(normalizedFile)
      },
      error(error) {
        reject(error)
      },
    })
  })
}
anjitpariyar commented 2 years ago

The image that is appearing black is after the cropping has finished and when we are trying to display the output.

We resolved it by modifying our getCroppedImage code to divide the width and height by 2.

Before:

      const image: HTMLImageElement = await createImage(imageSrc);

      const canvas = document.createElement('canvas');
      const ctx = canvas.getContext('2d');

      const safeArea = Math.max(image.width, image.height) * 2;

      // set each dimensions to double largest dimension to allow for a safe area for the
      // image to rotate in without being clipped by canvas context
      canvas.width = safeArea;
      canvas.height = safeArea;

      if (!ctx) {
        setIsLoading(false);
        return null;
      }

      const getRadianAngle = (degreeValue: number) => {
        return (degreeValue * Math.PI) / 180;
      };
      // translate canvas context to a central location on image to allow rotating around the center.
      ctx.translate(safeArea / 2, safeArea / 2);
      ctx.rotate(getRadianAngle(rotation));
      ctx.translate(-safeArea / 2, -safeArea / 2);

      // draw rotated image and store data.
      ctx.drawImage(
        image,
        safeArea / 2 - image.width * 0.5,
        safeArea / 2 - image.height * 0.5
      );
      const data = ctx.getImageData(0, 0, safeArea, safeArea);

      // set canvas width to final desired crop size - this will clear existing context
      canvas.width = pixelCrop.width;
      canvas.height = pixelCrop.height;

      // paste generated rotate image with correct offsets for x,y crop values.
      ctx.putImageData(
        data,
        0 - safeArea / 2 + image.width * 0.5 - pixelCrop.x,
        0 - safeArea / 2 + image.height * 0.5 - pixelCrop.y
      );
      return canvas.toDataURL('image/jpeg');

After:

      const image: HTMLImageElement = await createImage(imageSrc);

      const canvas = document.createElement('canvas');
      const ctx = canvas.getContext('2d');

      image.width = image.width / 2;
      image.height = image.height / 2;

      const safeArea = Math.max(image.width, image.height) * 2;

      // set each dimensions to double largest dimension to allow for a safe area for the
      // image to rotate in without being clipped by canvas context
      canvas.width = safeArea;
      canvas.height = safeArea;

      if (!ctx) {
        setIsLoading(false);
        return null;
      }

      const getRadianAngle = (degreeValue: number) => {
        return (degreeValue * Math.PI) / 180;
      };
      // translate canvas context to a central location on image to allow rotating around the center.
      ctx.translate(safeArea / 2, safeArea / 2);
      ctx.rotate(getRadianAngle(rotation));
      ctx.translate(-safeArea / 2, -safeArea / 2);

      // draw rotated image and store data.
      ctx.drawImage(
        image,
        safeArea / 2 - image.width,
        safeArea / 2 - image.height
      );
      const data = ctx.getImageData(0, 0, safeArea, safeArea);

      // set canvas width to final desired crop size - this will clear existing context
      canvas.width = pixelCrop.width;
      canvas.height = pixelCrop.height;

      // paste generated rotate image with correct offsets for x,y crop values.
      ctx.putImageData(
        data,
        0 - safeArea / 2 + image.width - pixelCrop.x,
        0 - safeArea / 2 + image.height - pixelCrop.y
      );
      return canvas.toDataURL('image/jpeg');

thanks, mate. I converted your code into responsive size only

const createImage = (url) =>
  new Promise((resolve, reject) => {
    const image = new Image();
    image.addEventListener("load", () => resolve(image));
    image.addEventListener("error", (error) => reject(error));
    image.setAttribute("crossOrigin", "anonymous"); // needed to avoid cross-origin issues on CodeSandbox
    image.src = url;
  });

function getRadianAngle(degreeValue) {
  return (degreeValue * Math.PI) / 180;
}

/**
 * This function was adapted from the one in the ReadMe of https://github.com/DominicTobias/react-image-crop
 * @param {File} image - Image File url
 * @param {Object} pixelCrop - pixelCrop Object provided by react-easy-crop
 * @param {number} rotation - optional rotation parameter
 */
export default async function getCroppedImg(imageSrc, pixelCrop, rotation = 0) {
  let image = await createImage(imageSrc);
  const canvas = document.createElement("canvas");
  const ctx = canvas.getContext("2d");
  // console.log("before", image.width);
  if (window.innerWidth < 1300 && (image.width > 1000 || image.width > 1000)) {
    image.width = image.width / 2;
    image.height = image.height / 2;
  }
  let maxSize = Math.max(image.width, image.height);
  // console.log("after", image.width);

  let safeArea = 2 * ((maxSize / 2) * Math.sqrt(2));
  if (window.innerWidth < 1300 && (image.width > 1000 || image.width > 1000)) {
    safeArea = Math.max(image.width, image.height) * 2;
  }

  // console.log("safearea", safeArea);

  // set each dimensions to double largest dimension to allow for a safe area for the
  // image to rotate in without being clipped by canvas context
  canvas.width = safeArea;
  canvas.height = safeArea;

  // translate canvas context to a central location on image to allow rotating around the center.
  ctx.translate(safeArea / 2, safeArea / 2);
  ctx.rotate(getRadianAngle(rotation));
  ctx.translate(-safeArea / 2, -safeArea / 2);

  // draw rotated image and store data.
  if (window.innerWidth < 1300 && (image.width > 1000 || image.width > 1000)) {
    ctx.drawImage(
      image,
      safeArea / 2 - image.width,
      safeArea / 2 - image.height
    );
  } else {
    ctx.drawImage(
      image,
      safeArea / 2 - image.width * 0.5,
      safeArea / 2 - image.height * 0.5
    );
  }
  const data = ctx.getImageData(0, 0, safeArea, safeArea);

  // set canvas width to final desired crop size - this will clear existing context
  canvas.width = pixelCrop.width;
  canvas.height = pixelCrop.height;

  // paste generated rotate image with correct offsets for x,y crop values.
  if (window.innerWidth < 1300 && (image.width < 1000 || image.width > 1000)) {
    ctx.putImageData(
      data,
      0 - safeArea / 2 + image.width - pixelCrop.x,
      0 - safeArea / 2 + image.height - pixelCrop.y
    );
  } else {
    ctx.putImageData(
      data,
      Math.round(0 - safeArea / 2 + image.width * 0.5 - pixelCrop.x),
      Math.round(0 - safeArea / 2 + image.height * 0.5 - pixelCrop.y)
    );
  }

  // As Base64 string
  // return canvas.toDataURL('image/jpeg');

  // As a blob
  return new Promise((resolve) => {
    canvas.toBlob((file) => {
      resolve(file);
    }, "image/jpeg");
  });
}
badtant commented 2 years ago

here's a snippet of how me and @hejdesgit solved scaling the image before we send it to crop.

if needed, we scale the image to a size that is safe within the browsers maximum canvas size.

import canvasSize from 'canvas-size';
import Compressor from 'compressorjs';

function getImageDimensions(file) {
    const imgObjectUrl = URL.createObjectURL(file);
    const img = new Image();

    return new Promise((resolve, reject) => {
        img.onload = () => {
            const width = img.naturalWidth;
            const height = img.naturalHeight;

            URL.revokeObjectURL(imgObjectUrl);
            resolve({ width, height });
        };

        img.onerror = reject;

        img.src = imgObjectUrl;
    });
}

function compressImage(imgObjectUrl, maxImageDimension) {
    return new Promise((resolve, reject) => {
        new Compressor(imgObjectUrl, {
            maxWidth: maxImageDimension,
            maxHeight: maxImageDimension,
            success(normalizedFile) {
                resolve(normalizedFile);
            },
            error(error) {
                reject(error);
            }
        });
    });
}

export default async function getScaledImg(file) {
    let fileToUse = file;

    const imageDimensions = await getImageDimensions(file);
    const maxCanvasSize = await canvasSize.maxArea({
        usePromise: true,
        useWorker: true
    });

    // Reverse of safeArea = 2 * ((maxSize / 2) * Math.sqrt(2)) in getCroppedImg.js
    const maxImageDimension = Math.floor(
        Math.max(maxCanvasSize.width, maxCanvasSize.height) / Math.sqrt(2)
    );

    if (
        imageDimensions.width > maxImageDimension ||
        imageDimensions.height > maxImageDimension
    ) {
        fileToUse = await compressImage(file, maxImageDimension);
    }

    return fileToUse;
}
tvankith commented 1 year ago

https://github.com/ValentinH/react-easy-crop/issues/91#issuecomment-766167263. This solution worked for me. My issue was safari throwing error like this

Unable to get image data from canvas. Requested size was