mrousavy / react-native-vision-camera

πŸ“Έ A powerful, high-performance React Native Camera library.
https://react-native-vision-camera.com
MIT License
7.56k stars 1.1k forks source link

πŸ› RN 0.74, RNVC 4.3.2. Take a photo or snapshot on iOS, the file does not exist at the path indicated by the response #3009

Open davidburson opened 4 months ago

davidburson commented 4 months ago

What's happening?

I recently upgraded my project to React Native 0.74 and RNVC 4.3.2. Works great on Android!

But on iOS, when I take a photo or snapshot there is no error, but the path in the response does not exist. Where did the file go?

In the response path, this part of the path exists on the device: /private/var/mobile/Containers/Data/Application/, but the uuid subdirectory that comes next in the response path does not exist. Similarly, /var/mobile/Containers/Data/Application/ (without /private) exists, but not the uuid subdirectory.

I tried to get the example project to run, but I failed. yarn bootstrap failed with "Could not find 'bundler' (2.3.22) required by your [path]/Gemfile.lock." I don't know how to fix this, and all my rather blind attempts to follow suggestions I found online failed. I also tried to get the example project running with yarn install from the "example" directory, and pod install from "example/ios". Still getting incomprehensible errors in Xcode.

So I created a minimal example to demonstrate the RNVC issue and pasted it into the Reproduceable Code section below. Tap to take a picture, and the log shows the response from takePhoto(). It also shows whether the path returned by takePhoto() exists. And that's the issue: at least for me, it never exists on iOS.

EDIT: video works fine on iOS. It's just taking photos and snapshots that has the bad path issue.

Reproduceable Code

import React, { useRef } from 'react';
import { StyleSheet, TouchableOpacity, View, Text } from 'react-native';
import { Camera, useCameraDevice } from 'react-native-vision-camera';
import RNFetchBlob from 'rn-fetch-blob';

export const QuickPhotoTest = () => {
  const camera = useRef<Camera>(null);
  const device = useCameraDevice('back');

  const onTakePhotoPress = async () => {
    if (camera && camera.current) {
      const res = await camera.current.takePhoto();
      console.log('res', res);
      const resPathExists = await RNFetchBlob.fs.exists(res.path);
      console.log('res.path exists?', resPathExists);  // no errors, but this is always false
    }
  };

  return (
    <View style={[styles.container, styles.blankBackground]}>
      { device ?
        <Camera
          ref={camera}
          style={StyleSheet.absoluteFill}
          device={device}
          photo={true}
          video={false}
          audio={false}
          isActive={true}
          onError={(err) => console.log('QuickPhoto > Camera MYBIGERROR', err)}
          onInitialized={() => console.log('QuickPhoto > Camera initialized')}
        />
        : null
      }
      <TouchableOpacity style={styles.captureContainer} onPress={() => onTakePhotoPress()}>
        <Text style={styles.text}>Press to take photo</Text>
      </TouchableOpacity>
    </View>
  );
};

const styles = StyleSheet.create({
  blankBackground: {
    backgroundColor: '#000',
  },
  container: {
    flex: 1,
    position: 'relative',
  },
  captureContainer: {
    flex: 1,
    flexDirection: 'column',
    justifyContent: 'flex-end',
    alignItems: 'center',
    padding: 20,
  },
  text: {
    color: 'white',
    fontSize: 24,
  },
});

Relevant log output

15:12:40.341: [info] πŸ“Έ VisionCamera.didSetProps(_:): Updating 24 props: [onInitialized, cameraId, position, enableBufferCompression, preview, onOutputOrientationChanged, onStarted, onCodeScanned, top, right, isActive, video, onViewReady, onError, onStopped, onPreviewOrientationChanged, onPreviewStopped, enableFrameProcessor, onPreviewStarted, left, audio, bottom, photo, onShutter]
15:12:40.342: [info] πŸ“Έ VisionCamera.configure(_:): configure { ... }: Waiting for lock...
15:12:40.346: [info] πŸ“Έ VisionCamera.configure(_:): configure { ... }: Updating CameraSession Configuration... Difference(inputChanged: true, outputsChanged: true, videoStabilizationChanged: true, orientationChanged: true, formatChanged: true, sidePropsChanged: true, torchChanged: true, zoomChanged: true, exposureChanged: true, audioSessionChanged: true, locationChanged: true)
15:12:40.346: [info] πŸ“Έ VisionCamera.configureDevice(configuration:): Configuring Input Device...
15:12:40.346: [info] πŸ“Έ VisionCamera.configureDevice(configuration:): Configuring Camera com.apple.avfoundation.avcapturedevice.built-in_video:0...
15:12:40.358: [info] πŸ“Έ VisionCamera.configurePreviewOrientation(_:): Updating Preview rotation: portrait...
15:12:40.358: [info] πŸ“Έ VisionCamera.configureOutputOrientation(_:): Updating Outputs rotation: portrait...
15:12:40.358: [info] πŸ“Έ VisionCamera.configureDevice(configuration:): Successfully configured Input Device!
15:12:40.358: [info] πŸ“Έ VisionCamera.configureOutputs(configuration:): Configuring Outputs...
15:12:40.358: [info] πŸ“Έ VisionCamera.configureOutputs(configuration:): Adding Photo output...
15:12:40.361: [info] πŸ“Έ VisionCamera.configurePreviewOrientation(_:): Updating Preview rotation: portrait...
15:12:40.362: [info] πŸ“Έ VisionCamera.configureOutputOrientation(_:): Updating Outputs rotation: portrait...
15:12:40.362: [info] πŸ“Έ VisionCamera.configureOutputs(configuration:): Successfully configured all outputs!
15:12:40.362: [info] πŸ“Έ VisionCamera.setTargetOutputOrientation(_:): Setting target output orientation from device to device...
2024-06-18 15:12:40.362625-0400 StagesApp[6384:3774135] [javascript] QuickPhoto > Camera initialized
15:12:40.636: [info] πŸ“Έ VisionCamera.init(frame:session:): Preview Layer started previewing.
15:12:40.637: [info] πŸ“Έ VisionCamera.configure(_:): Beginning AudioSession configuration...
15:12:40.637: [info] πŸ“Έ VisionCamera.configureAudioSession(configuration:): Configuring Audio Session...
15:12:40.638: [info] πŸ“Έ VisionCamera.configure(_:): Beginning Location Output configuration...
15:12:40.639: [info] πŸ“Έ VisionCamera.configure(_:): Committed AudioSession configuration!
15:12:40.646: [info] πŸ“Έ VisionCamera.configure(_:): Finished Location Output configuration!
2024-06-18 15:13:06.224601-0400 StagesApp[6384:3774135] [javascript] 'quickMediaFolderExists', true
15:13:06.229: [info] πŸ“Έ VisionCamera.takePhoto(options:promise:): Capturing photo...
2024-06-18 15:13:06.377473-0400 StagesApp[6384:3774135] [javascript] 'res', { isRawPhoto: false,
  path: 'file:///private/var/mobile/Containers/Data/Application/5CF1ADC7-3D83-4415-BB1C-EB3ADCE8DC77/tmp/5CCB9739-3EFD-4FBB-95C3-C361B8262B1B.jpeg',
  thumbnail: null,
  orientation: 'landscape-right',
  width: 4224,
  height: 2376,
  metadata: 
   { DPIWidth: 72,
     '{Exif}': 
      { DateTimeOriginal: '2024:06:18 15:13:06',
        ExposureTime: 0.025,
        FNumber: 1.8,
        LensSpecification: [ 4, 4, 1.8, 1.8 ],
        ExposureBiasValue: 0,
        ColorSpace: 65535,
        FocalLenIn35mmFilm: 29,
        BrightnessValue: 0.9432791989433479,
        ExposureMode: 0,
        LensModel: 'iPhone X back camera 4mm f/1.8',
        SceneType: 1,
        PixelXDimension: 4224,
        ShutterSpeedValue: 5.322101228680505,
        SensingMethod: 2,
        SubjectArea: [ 2111, 1187, 2323, 1045 ],
        ApertureValue: 1.6959938131099002,
        SubsecTimeDigitized: '346',
        FocalLength: 4,
        LensMake: 'Apple',
        SubsecTimeOriginal: '346',
        OffsetTimeDigitized: '-04:00',
        PixelYDimension: 2376,
        ISOSpeedRatings: [ 320 ],
        WhiteBalance: 0,
        DateTimeDigitized: '2024:06:18 15:13:06',
        OffsetTimeOriginal: '-04:00',
        ExifVersion: '0232',
        OffsetTime: '-04:00',
        Flash: 16,
        ExposureProgram: 2,
        MeteringMode: 5 },
     '{MakerApple}': 
      { '1': 14,
        '2': null,
        '3': 
         { timescale: 1000000000,
           epoch: 0,
           value: 1123034256013750,
           flags: 1 },
        '4': 1,
        '5': 193,
        '6': 195,
        '7': 1,
        '8': 
         [ 0.046814244240522385,
           -0.8590918779373169,
           -0.48627182841300964 ],
        '12': [ 0.3046875, 0.64453125 ],
        '13': 0,
        '14': 0,
        '15': 3,
        '16': 1,
        '20': 1,
        '23': 0,
        '25': 0,
        '31': 0,
        '32': 'EA1BDEB0-144C-48E2-9B6A-9C52224ABBC6',
        '37': 0,
        '38': 0,
        '39': 0,
        '43': '4CEEAA7B-5213-48F3-8C3D-1E2E573818FC',
        '45': 5751,
        '46': 0,
        '47': 48,
        '54': 150,
        '55': 4,
        '59': 0,
        '60': 4,
        '65': false,
        '74': 2 },
     Orientation: 6,
     DPIHeight: 72,
     '{TIFF}': 
      { ResolutionUnit: 2,
        Software: '16.7.8',
        Make: 'Apple',
        DateTime: '2024:06:18 15:13:06',
        XResolution: 72,
        HostComputer: 'iPhone X',
        Model: 'iPhone X',
        YResolution: 72 },
     kCGImageDestinationICCProfile: null },
  isMirrored: false }
2024-06-18 15:13:06.381343-0400 StagesApp[6384:3774135] [javascript] 'res.path exists?', false

Camera Device

{
  "id": "com.apple.avfoundation.avcapturedevice.built-in_video:0",
  "position": "back",
  "supportsFocus": true,
  "supportsRawCapture": false,
  "neutralZoom": 1,
  "physicalDevices": [
    "wide-angle-camera"
  ],
  "hardwareLevel": "full",
  "minZoom": 1,
  "sensorOrientation": "landscape-left",
  "maxExposure": 8,
  "supportsLowLightBoost": false,
  "isMultiCam": false,
  "hasFlash": true,
  "name": "Back Camera",
  "minExposure": -8,
  "minFocusDistance": 10,
  "maxZoom": 16,
  "formats": [],
  "hasTorch": true
}

Device

iPhone X (iOS 16.7.8)

VisionCamera Version

4.3.2

Can you reproduce this issue in the VisionCamera Example app?

No, I cannot reproduce the issue in the Example app

Additional information

maintenance-hans[bot] commented 4 months ago

Guten Tag, Hans here.

[!NOTE] New features, bugfixes, updates and other improvements are all handled mostly by @mrousavy in his free time. To support @mrousavy, please consider πŸ’– sponsoring him on GitHub πŸ’–. Sponsored issues will be prioritized.

mrousavy commented 4 months ago

Add a file:// prefix

davidburson commented 4 months ago

Thanks, @mrousavy, but the file:// prefix is already included in res.path returned from const res = await camera.current.takePhoto();

As I tried to explain in my original post, the folder the file was saved in doesn't exist when program control returns from takePhoto(). For example, if res.path returned from takePhoto() is "file:///private/var/mobile/Containers/Data/Application/629FC6E1-1F8F-46B9-8D08-1CAF06EC450A/tmp/29A98AB6-0B9C-4F34-9846-23D036EAD751.jpeg", then when takePhoto() returns, the folder "/private/var/mobile/Containers/Data/Application/" exists, but the folder "/private/var/mobile/Containers/Data/Application/629FC6E1-1F8F-46B9-8D08-1CAF06EC450A/" does NOT exist.

I edited PhotoCaptureDelegate.swift to log the path and whether the file exists. It DOES exist immediately after taking the picture, but if I add code to check if that same file still exists, pod install, and re-run the app, the first file NO LONGER exists.

So, it seems to me the photo file along with its containing folder [uuid]/tmp is being deleted before control returns from takePhoto().

Here's the photoOutput with my edits:

func photoOutput(_: AVCapturePhotoOutput, didFinishProcessingPhoto photo: AVCapturePhoto, error: Error?) {
    defer {
      removeGlobal()
    }
    if let error = error as NSError? {
      promise.reject(error: .capture(.unknown(message: error.description)), cause: error)
      return
    }

    do {
      let path = try FileUtils.writePhotoToTempFile(photo: photo, metadataProvider: metadataProvider)

      // Does the path of a previous photo still exist?
      if FileManager.default.fileExists(atPath: "/private/var/mobile/Containers/Data/Application/629FC6E1-1F8F-46B9-8D08-1CAF06EC450A/tmp/29A98AB6-0B9C-4F34-9846-23D036EAD751.jpeg") {
        print("Old path exists:  /private/var/mobile/Containers/Data/Application/629FC6E1-1F8F-46B9-8D08-1CAF06EC450A/tmp/29A98AB6-0B9C-4F34-9846-23D036EAD751.jpeg")
      } else {
        print("Old path does not exist:  /private/var/mobile/Containers/Data/Application/629FC6E1-1F8F-46B9-8D08-1CAF06EC450A/tmp/29A98AB6-0B9C-4F34-9846-23D036EAD751.jpeg")
      }

      // path of the photo we just took
      print("Photo saved to path: \(path.absoluteString)")

      let exif = photo.metadata["{Exif}"] as? [String: Any]
      let width = exif?["PixelXDimension"]
      let height = exif?["PixelYDimension"]
      let exifOrientation = photo.metadata[String(kCGImagePropertyOrientation)] as? UInt32 ?? CGImagePropertyOrientation.up.rawValue
      let cgOrientation = CGImagePropertyOrientation(rawValue: exifOrientation) ?? CGImagePropertyOrientation.up
      let orientation = getOrientation(forExifOrientation: cgOrientation)
      let isMirrored = getIsMirrored(forExifOrientation: cgOrientation)

      // Verify the file exists for the photo we just took
      if FileManager.default.fileExists(atPath: path.path) {
        print("File exists at path: \(path.path)")
      } else {
        print("File does not exist at path: \(path.path)")
      }

      promise.resolve([
        "path": path.absoluteString,
        "width": width as Any,
        "height": height as Any,
        "orientation": orientation,
        "isMirrored": isMirrored,
        "isRawPhoto": photo.isRawPhoto,
        "metadata": photo.metadata,
        "thumbnail": photo.embeddedThumbnailPhotoFormat as Any,
      ])
    } catch let error as CameraError {
      promise.reject(error: error)
    } catch {
      promise.reject(error: .capture(.unknown(message: "An unknown error occured while capturing the photo!")), cause: error as NSError)
    }
  }

I hope this clarification is helpful! I very much appreciate help in resolving this issue. It seems so strange that takePhoto() would create a file in a [uuid]/tmp folder, then delete that whole folder before returning. I'm guessing the delete is happening in the Swift code, or due to some Apple "feature". Or possibly, there is also a permissions issue such that the [uuid]/tmp folder used in RNVC's Swift code cannot be seen in an app that uses RNVC.

In case it matters: I'm running Xcode 15.1 on an M1 mac Mini, running macOS Sonoma 14.5.

davidburson commented 4 months ago

I have a workaround!

const destPath = [where you want to store the photo]
const res = await camera.current.takePhoto();

// THE WORKAROUND:
const sourcePath = Platform.OS === 'ios' ? res.path.replace('file:///private', '') : res.path;

await RNFetchBlob.fs.mv(sourcePath, destPath);

So I was barking up a forest of wrong trees because I thought I had already checked that workaround. I'm a dummy, but at least I learned a little bit this week. (The fact that I'm a dummy I already knew.)

I'll leave this issue open since it seems to me that this is a bug: on iOS, res.path in my opinion should return a useable path like it does on Android.

@mrousavy thanks again for an outstandingly helpful component!

mrousavy commented 4 months ago

Hm this is very weird. Why would I be able to write to that path if it doesn't exist....

davidburson commented 4 months ago

iOS paths do seem weird to me. For example, in your Swift code when a photo is taken, it writes to a path that starts with /private in writeDataToTempFile (in ios/Core/Utils/FileUtils.swift). Immediately after that data.write, I added a test in the Swift code to log whether the file existed in the supplied path (beginning with /private), and the identical path without /private). The file existed in BOTH.

Additionally, every time I run my app (which I do from Xcode, with Metro running), the path to where pics are taken has a different uuid.

For example, I reported above RNVC returned path: 'file:///private/var/mobile/Containers/Data/Application/5CF1ADC7-3D83-4415-BB1C-EB3ADCE8DC77/tmp/5CCB9739-3EFD-4FBB-95C3-C361B8262B1B.jpeg'.

Every time I run, I get a different uuid in place of the folder 5CF1ADC7-3D83-4415-BB1C-EB3ADCE8DC77. Yet, the old photos that I didn't move from each time I ran my app previously and took photos, they are all there in the path with the new uuid folder.

All that to say, it seems to me iOS is doing some strange things with folders, maybe creating a new symlink every time I run my app and pointing them all to the same place, and always having the /private and NOT /private paths point to the same place, but /private is not allowed at the main app level, only in the Swift code?

mrousavy commented 4 months ago

but /private is not allowed at the main app level, only in the Swift code?

This can't be, maybe I return a different path. Either way, I am using react-native-vision-camera 4.3.2 in ShadowLens in production and it works perfectly fine, so I don't think this is a bug in VisionCamera?

davidburson commented 4 months ago

My best guess at this point is that I'm passing different parameters to RNVC than you do in ShadowLens, and that somehow the combination of parameters, maybe in conjunction with something else in my environment, causes this issue.

You've probably heard the old software developer's adage that "Nothing can be foolproof because fools are so ingenious". Maybe I've been the fool in that proverb in this case and, while trying to use RNVC as intended, I've somehow landed on a combination that causes this issue.

In any case, it's great that in ShadowLens you have a way to use RNVC that works, and for now my workaround works for me. If it becomes a problem again I'll revisit this issue. I'm currently wrestling with some orientation issues with RNVC on iOS. If I can't resolve them I'll open a separate issue, if my problems aren't already covered by what other people have found - I notice a few currently open issues dealing with orientation.

Thanks again, I really appreciate RNVC and all your work on it!

mrousavy commented 4 months ago

I dont think this changes anything.

After I take a photo, what do you do with the path?

  1. In the example app here I immediately display it in an , that works
  2. In ShadowLens I save it to the user gallery, that works as well
davidburson commented 4 months ago

This is the function where I take a photo, from the reproducible code I included with my original post:

  const onTakePhotoPress = async () => {
    if (camera && camera.current) {
      const res = await camera.current.takePhoto();
      console.log('res', res);
      const resPathExists = await RNFetchBlob.fs.exists(res.path);
      console.log('res.path exists?', resPathExists);  // no errors, but this is always false
    }
  };

After taking a photo, I log the response, then I check to see if the res.path exists. On iOS, it never does.

But I found that on iOS, if I strip off the "file:///private" that is always at the beginning of res.path, then that new path DOES exist and I can do whatever I want with the file. For convenience, here's my one-line workaround that I now add immediately after taking a photo:

const sourcePath = Platform.OS === 'ios' ? res.path.replace('file:///private', '') : res.path;

In my real app, after taking the photo (and now utilizing my workaround to get a path that works) I move the file to where my app persists its data on the user's phone, using

await RNFetchBlob.fs.mv(oldPath, newPath)
lichstam commented 2 months ago

i can confirm the same problem. i also tested whether the file with the private prefix exists and as david mentioned, it doesn't until you cut that part away. it is not bullet proof though, sometimes it fails to get the fail anyway

lichstam commented 2 months ago

running latest version, 4.5.2