벌써 4편째 포스팅을 이어가고 있네요.
지난번에는 색상 목업 데이터로 간단하게
인스타그램의 UI를 클론해봤는데요.
이번에는 실제 카메라 기능을 어떻게 구현했는지
결과물 + 코드로 한 번 알아보겠습니다!
결과물
개요
결과물에서 볼 수 있듯, 트랜지션은 지난번에
구현한 인스타그램과 비슷한 형태를 유지했어요.
다만 사진 비율에 있어서 9:16 비율은 일기의
느낌을 띄는 앱에서 적절하지 않다 생각해
3:4 비율로 변경했죠.
그리고 이에 따라 인스타그램의 카메라 컨트롤
UI와 동일하게 가져갈 수는 없을 것 같아
결과물에서 보이는 방식처럼 구현을 했습니다!
이제 코드를 볼 건데, 이번에는 핵심 기능들만
포함하는 간소화한 버전을 다뤄볼거에요.
나중에 카메라 과정 전체를 한 번 담은 포스트를
써보면 좋겠네요 ㅋㅋ
카메라
저는 UI를 제외하고는 두개의 파일로 카메라를
구성했습니다.
CameraService, CameraViewModel
위 두개로 구성을 했어요
CameraService는 실제 카메라 디바이스를
조작하는 역할을 합니다.
Zoom, Exposure, Lens, Position, Resolution, Flash 등을
관리할 수 있습니다.
CameraViewModel은 AVCaptureDevice의
Delegate 역할을 하며, 디바이스에서 받아온
이미지를 가공하는 역할을 합니다.
이렇게 가공된 이미지는 Published 변수에 저장되어
CameraView에서 이미지로 띄워주게 되죠.
또, 유저가 인터페이스를 통해 카메라 조작을 시도하면
CameraService로 곧장 연결되는게 아니라,
해당 뷰모델을 거쳐 카메라 조작을 하도록 해줍니다.
CameraService Code
먼저 카메라 서비스의 전체 코드를 보겠습니다.
import AVFoundation
class CameraService {
private var videoSession: AVCaptureSession = AVCaptureSession()
private var cameraDevice: AVCaptureDevice?
private var videoOutput: AVCaptureVideoDataOutput = AVCaptureVideoDataOutput()
private var captureOutput: AVCapturePhotoOutput = AVCapturePhotoOutput()
public var cameraPosition: AVCaptureDevice.Position = .back
private var captureDelegate: AVCapturePhotoCaptureDelegate? = nil
private var previewLayer: AVCaptureVideoPreviewLayer?
private var bestDevice: AVCaptureDevice? {
return AVCaptureDevice.default(.builtInTripleCamera, for: .video, position: cameraPosition) ?? AVCaptureDevice.default(.builtInWideAngleCamera, for: .video, position: cameraPosition)
}
let isSupportingUltraWide: Bool = {
return AVCaptureDevice.DiscoverySession(
deviceTypes: [.builtInUltraWideCamera],
mediaType: .video,
position: .back
).devices.first != nil
}()
var currentExposure: Float {
guard let deviceInput = self.videoSession.inputs.first as? AVCaptureDeviceInput else { return 0 }
return deviceInput.device.exposureTargetBias
}
var currentZoomFactor: CGFloat {
guard let deviceInput = self.videoSession.inputs.first as? AVCaptureDeviceInput else { return 1 }
return deviceInput.device.videoZoomFactor
}
init() {
print("Camera Initialized")
}
func stopSession() {
if videoSession.isRunning {
DispatchQueue.global().async {
self.videoSession.stopRunning()
}
}
}
func startSession() {
if !videoSession.isRunning {
DispatchQueue.global().async {
self.videoSession.startRunning()
}
}
}
func switchCamera() {
self.videoSession.beginConfiguration()
cameraPosition = (cameraPosition == .back) ? .front : .back
guard let currentInput = self.videoSession.inputs.first as? AVCaptureDeviceInput else { return }
if let device = bestDevice {
do {
self.videoSession.removeInput(currentInput)
try device.lockForConfiguration()
if device.deviceType == .builtInTripleCamera {
device.videoZoomFactor = 2.0
} else {
device.videoZoomFactor = 1.0
}
device.unlockForConfiguration()
let input = try AVCaptureDeviceInput(device: device)
if videoSession.canAddInput(input) {
videoSession.addInput(input)
}
} catch {
print("카메라 입력 장치 설정 오류: \(error)")
}
}
self.videoSession.commitConfiguration()
}
func setUpCamera(delegate: AVCaptureVideoDataOutputSampleBufferDelegate?) {
self.videoSession = AVCaptureSession()
if let device = bestDevice {
self.videoSession.sessionPreset = .photo
do {
let input = try AVCaptureDeviceInput(device: device)
if videoSession.canAddInput(input) {
videoSession.addInput(input)
}
try device.lockForConfiguration()
if device.deviceType == .builtInTripleCamera {
device.videoZoomFactor = 2.0
} else {
device.videoZoomFactor = 1.0
}
device.unlockForConfiguration()
} catch {
print("카메라 설정 실패: \(error)")
}
}
videoOutput.videoSettings = [String(kCVPixelBufferPixelFormatTypeKey): kCVPixelFormatType_32BGRA]
videoOutput.setSampleBufferDelegate(delegate, queue: DispatchQueue(label: "video_output_queue"))
videoOutput.alwaysDiscardsLateVideoFrames = true // 늦은 프레임 자동 폐기
if videoSession.canAddOutput(videoOutput) {
videoSession.addOutput(videoOutput)
}
captureOutput.maxPhotoQualityPrioritization = .speed
if videoSession.canAddOutput(captureOutput) {
videoSession.addOutput(captureOutput)
}
}
func setExposure(_ value: Float) {
guard let deviceInput = self.videoSession.inputs.first as? AVCaptureDeviceInput else { return }
let device = deviceInput.device
do {
try device.lockForConfiguration()
let newValue = min(max(value, device.minExposureTargetBias), device.maxExposureTargetBias)
device.setExposureTargetBias(newValue)
device.unlockForConfiguration()
} catch {
print(error.localizedDescription)
}
}
func focus(at point: CGPoint) {
guard let deviceInput = self.videoSession.inputs.first as? AVCaptureDeviceInput else { return }
let device = deviceInput.device
do {
try device.lockForConfiguration()
if device.isFocusModeSupported(.continuousAutoFocus) && device.isFocusPointOfInterestSupported {
device.focusMode = .continuousAutoFocus
device.focusPointOfInterest = point
if device.isExposureModeSupported(.autoExpose) && device.isExposurePointOfInterestSupported {
device.exposureMode = .autoExpose
device.exposurePointOfInterest = point
}
}
device.unlockForConfiguration()
} catch {
print("포커스 설정 오류: \(error)")
}
}
func zoom(factor: CGFloat, originalFactor: CGFloat, ramp: Bool = false) {
guard let deviceInput = self.videoSession.inputs.first as? AVCaptureDeviceInput else { return }
let device = deviceInput.device
let zoomFactor = min(max(factor * originalFactor, 1.0), isSupportingUltraWide ? 20 : 10)
do {
try device.lockForConfiguration()
if ramp {
device.ramp(toVideoZoomFactor: zoomFactor, withRate: 8.0)
} else {
device.videoZoomFactor = zoomFactor
}
device.unlockForConfiguration()
} catch {
print("줌 설정 오류: \(error)")
}
}
func setCaptureDelegate(delegate: AVCapturePhotoCaptureDelegate) {
self.captureDelegate = delegate
}
func capture(_ flashMode: AVCaptureDevice.FlashMode = .auto) {
guard let captureDelegate else {
return
}
let settings = AVCapturePhotoSettings()
settings.flashMode = flashMode
self.captureOutput.capturePhoto(with: settings, delegate: captureDelegate)
}
}
CameraSettingUp
가장 기본이 되는 카메라 설정입니다.
이 설정을 해줘야 카메라를 통해 이미지를
받아내고 사진이나 동영상을 촬영할 수 있습니다.
func setUpCamera(delegate: AVCaptureVideoDataOutputSampleBufferDelegate?) {
self.videoSession = AVCaptureSession()
if let device = bestDevice {
self.videoSession.sessionPreset = .photo
do {
let input = try AVCaptureDeviceInput(device: device)
if videoSession.canAddInput(input) {
videoSession.addInput(input)
}
try device.lockForConfiguration()
if device.deviceType == .builtInTripleCamera {
device.videoZoomFactor = 2.0
} else {
device.videoZoomFactor = 1.0
}
device.unlockForConfiguration()
} catch {
print("카메라 설정 실패: \(error)")
}
}
videoOutput.videoSettings = [String(kCVPixelBufferPixelFormatTypeKey): kCVPixelFormatType_32BGRA]
videoOutput.setSampleBufferDelegate(delegate, queue: DispatchQueue(label: "video_output_queue"))
videoOutput.alwaysDiscardsLateVideoFrames = true // 늦은 프레임 자동 폐기
if videoSession.canAddOutput(videoOutput) {
videoSession.addOutput(videoOutput)
}
captureOutput.maxPhotoQualityPrioritization = .speed
if videoSession.canAddOutput(captureOutput) {
videoSession.addOutput(captureOutput)
}
}
먼저 인자에 보시면 AVCaptureVideoDataOutputSampleBufferDelegate를
받고 있습니다. 이건 카메라에서 받은 샘플 버퍼를
처리할 위임자를 선택하는거에요.
아무런 가공없이 카메라만 보여줄거라면
preview를 사용하고, 해당 delegate도
필요없지만 저는 필터를 넣을 예정이기 때문에
sampleBuffer가 필요합니다.
따라서 카메라를 설정할 때, 실시간으로
받는 sampleBuffer를 처리할 위임자를
선택해 넣어주면 됩니다.
또, AVCaptureDevice를 설정해주는데요.
위에서 보시면 bestDevice라고 제가 미리
설정해둔 디바이스 프리셋을 쓰고 있어요.
private var bestDevice: AVCaptureDevice? {
return AVCaptureDevice.default(.builtInTripleCamera, for: .video, position: cameraPosition) ?? AVCaptureDevice.default(.builtInWideAngleCamera, for: .video, position: cameraPosition)
}
트리플 카메라가 달린 모델들은 zoom 배율만으로도
알아서 카메라를 스위칭해줍니다.
제가 프로 모델들만 사용해봐서
일반 모델이나 미니 모델은 초광각이
아예 없는줄 알았는데, 그게 아니더라구요.
그래서 수정이 필요하지만.. 일단 이렇게 구현해뒀습니다.
필요에 따라 디바이스를 설정해서 videoSessionInput에
추가해주면 되겠습니다.
try device.lockForConfiguration()
if device.deviceType == .builtInTripleCamera {
device.videoZoomFactor = 2.0
} else {
device.videoZoomFactor = 1.0
}
device.unlockForConfiguration()
zoom의 경우 우리가 일반적으로
카메라를 사용하면 0.5배율이 존재하죠?
하지만 이건 표기상 0.5배율이지
AVCaptureDevice에 직접적으로 0.5 배율을
할당할 수는 없습니다.
모든 디바이스의 최소 배율은 1배율이에요.
0.5배율이라는 건 초광각 카메라에서 1배율
이라는 의미입니다!
func capture(_ flashMode: AVCaptureDevice.FlashMode = .auto) {
guard let captureDelegate else {
return
}
let settings = AVCapturePhotoSettings()
settings.flashMode = flashMode
self.captureOutput.capturePhoto(with: settings, delegate: captureDelegate)
}
마지막으로 사진을 실제로 찍는 부분이에요.
captureOutput.capturePhoto로 사진을
찍고 나면, 찍힌 사진을 처리할 Delegate가
또 필요하답니다.
따라서 CameraService는 기기에게
"사진 찍어" 라고 요청만 하고,
그 결과는 알 필요 없다는 얘기죠.
플래시 및 기타 촬영에 필요한 설정들은
AVCapturePhotoSettings에 추가해서
delegate와 함께 capturePhoto에 넘겨주면 끝!
나머지 코드들은 부수적인 요소들이므로
필요한 부분을 발췌해서 참고하시면
되겠어요.
CameraViewModel
카메라에 필터를 적용하기 위해서는
SampleBuffer가 필요하다고 했죠.
그리고 이 sampleBuffer를 처리하기 위해
카메라 세팅에서 위임자(Delegate)를
설정해줬어요.
그 Delegate역할을 CameraViewModel가
수행하게 됩니다.
카메라를 찍는 Delegate도 필요하다고 했죠.
그 위임자 역시 이 뷰모델이 담당합니다.
또한 카메라를 컨트롤하기 위해 View에서
ViewModel로 연결해주는 징검다리 역할이에요.
import AVFoundation
import CoreImage
import SwiftUI
class CameraViewModel: NSObject, ObservableObject, AVCaptureVideoDataOutputSampleBufferDelegate, AVCapturePhotoCaptureDelegate {
private let context = CIContext()
@Published var selectedFilter: CIFilter?
@Published var filteredImage: UIImage?
@Published var originalFactor: CGFloat?
@Published var flashMode: AVCaptureDevice.FlashMode = .off
@Published var isCapturing: Bool = false
@Published var capturedImage: UIImage?
@Published var filters: [CIFilter] = [.init()]
var exposure: Float {
return self.cameraService.currentExposure
}
private let cameraService: CameraService = CameraService()
var cameraPosition: AVCaptureDevice.Position {
return self.cameraService.cameraPosition
}
var isSupportingUltraWide: Bool {
return self.cameraService.isSupportingUltraWide
}
var currentZoomFactor: CGFloat {
return self.cameraService.currentZoomFactor
}
var flashModeImageName: String {
return switch flashMode {
case .off:
"bolt.fill"
case .on:
"bolt.fill"
case .auto:
"bolt.badge.a.fill"
@unknown default:
""
}
}
override init() {
super.init()
cameraService.setUpCamera(delegate: self)
cameraService.setCaptureDelegate(delegate: self)
}
func stopSession() {
cameraService.stopSession()
}
func startSession() {
cameraService.startSession()
}
func zoom(factor: CGFloat, originalFactor: CGFloat, ramp: Bool = false) {
self.cameraService.zoom(factor: factor, originalFactor: originalFactor, ramp: ramp)
}
func capture() {
guard !self.isCapturing else {
return
}
self.isCapturing = true
self.cameraService.capture(flashMode)
}
func switchFlashMode() {
switch flashMode {
case .off:
self.flashMode = .on
case .on:
self.flashMode = .auto
case .auto:
self.flashMode = .off
@unknown default:
return
}
}
func switchCamera() {
self.cameraService.switchCamera()
}
func setExposure(_ value: Float) {
self.cameraService.setExposure(value)
}
func photoOutput(_ output: AVCapturePhotoOutput, didFinishProcessingPhoto photo: AVCapturePhoto, error: Error?){
guard let cgImage = photo.cgImageRepresentation() else {
return
}
self.capturedImage = UIImage(cgImage: cgImage, scale: 1, orientation: cameraService.cameraPosition == .front ? .leftMirrored : .right)
self.isCapturing = false
}
func captureOutput(_ output: AVCaptureOutput, didOutput sampleBuffer: CMSampleBuffer, from connection: AVCaptureConnection) {
if let connection = output.connection(with: .video) {
connection.videoRotationAngle = 90
if cameraService.cameraPosition == .front {
connection.isVideoMirrored = true
}
}
guard let pixelBuffer = CMSampleBufferGetImageBuffer(sampleBuffer) else { return }
let ciImage = CIImage(cvImageBuffer: pixelBuffer)
var finalCGImage: CGImage!
if let selectedFilter {
selectedFilter.setValue(ciImage, forKey: kCIInputImageKey)
guard let outputImage = selectedFilter.outputImage,
let cgImage = context.createCGImage(outputImage, from: outputImage.extent) else { return }
finalCGImage = cgImage
} else {
guard let cgImage = context.createCGImage(ciImage, from: ciImage.extent) else { return }
finalCGImage = cgImage
}
let uiImage = UIImage(cgImage: finalCGImage)
DispatchQueue.main.async {
self.filteredImage = uiImage
}
}
}
다른 코드들은 대부분 CameraService를 연결해주는 용도이고,
우리가 중점적으로 봐야할 부분들은
delegate 함수들이에요.
func photoOutput(_ output: AVCapturePhotoOutput, didFinishProcessingPhoto photo: AVCapturePhoto, error: Error?){
guard let cgImage = photo.cgImageRepresentation() else {
return
}
self.capturedImage = UIImage(cgImage: cgImage, scale: 1, orientation: cameraService.cameraPosition == .front ? .leftMirrored : .right)
self.isCapturing = false
}
이 delegate 함수는 AVCapturePhotoCaptureDelegate에
포함되어있는 함수입니다.
사진 촬영 버튼이 눌리고, 디바이스가 촬영을 한 뒤
기본적인 보정 및 가공처리를 마친 이미지 데이터를
넘겨줍니다.
그러면 우리는 이 이미지 데이터를
우리의 필요에 따라 추가 가공한 뒤,
사용자에게 표시해주면 되는거예요.
func captureOutput(_ output: AVCaptureOutput, didOutput sampleBuffer: CMSampleBuffer, from connection: AVCaptureConnection) {
if let connection = output.connection(with: .video) {
connection.videoRotationAngle = 90
if cameraService.cameraPosition == .front {
connection.isVideoMirrored = true
}
}
guard let pixelBuffer = CMSampleBufferGetImageBuffer(sampleBuffer) else { return }
let ciImage = CIImage(cvImageBuffer: pixelBuffer)
var finalCGImage: CGImage!
if let selectedFilter {
selectedFilter.setValue(ciImage, forKey: kCIInputImageKey)
guard let outputImage = selectedFilter.outputImage,
let cgImage = context.createCGImage(outputImage, from: outputImage.extent) else { return }
finalCGImage = cgImage
} else {
guard let cgImage = context.createCGImage(ciImage, from: ciImage.extent) else { return }
finalCGImage = cgImage
}
let uiImage = UIImage(cgImage: finalCGImage)
DispatchQueue.main.async {
self.filteredImage = uiImage
}
}
이 코드가 바로 카메라 피드에 등장할 녀석을 가공하는
부분입니다.
이 뷰모델이 delegate이고 우리는 CameraService에
카메라 세팅을 할 때 이 뷰모델을 delegate로 사용하라고
명시했죠?
따라서 실시간으로 받아오는 카메라 이미지의 샘플 버퍼를
이곳에서 처리할 수 있는거예요.
다만, 이 샘플 버퍼는 orientation 개념을 포함하고 있지는
않기 때문에 rotationAngle, mirror를 잘 설정해줘야 합니다.
해당 output은 CameraService에서 설정한 captureOutput
이기 때문에 카메라를 세팅할 때 미리 설정해줘도
문제없습니다.
사실 문제가 없다기보단 그게 정석이에요 ㅋㅋㅋㅠ
현재 코드처럼 작성했을 때 문제는
첫 프레임은 orientation 적용이 안되어서
화면이 이상하게 나올수도 있거든요.
(귀찮아서 이렇게 일단 처리해뒀습니다.)
하여튼 설정을 마쳤다면,
sampleBuffer -> pixelBuffer -> CIImage
-> Filter chaining -> CGImage -> UIImage
의 과정을 거쳐 카메라 피드에 등장할 이미지가
완성되게 됩니다!
이전에 만들었던 카메라 앱들은 Metal을 직접
사용할 수 있는 MTKView를 사용했었는데요..
CIFilter에서 자체 제작한 Metal 코드를
사용할 수 있다는 걸 몰랐고(아무도 안알려줬어)
겉멋이 들어서 그게 Cool Thang 이라 생각했는데,
이렇게 간단하게 요약할 수 있다니
너무 좋네요.
물론 3d로 나아가면 MTKView를 쓰긴 해야겠지만요 ㅋㅋ
하여튼 오늘은 이렇게 제가 제작한 카메라
코드를 살펴봤어요.
설명이 이곳저곳 많이 부족하지만,
글도 쓰다보면 뭔가 노하우가 생기겠죠?
이제 남은 기능들 마저 구현하고 다음주 내로
앱스토어에 등록까지 완료해보겠습니다!