280 lines
9.7 KiB
Swift
280 lines
9.7 KiB
Swift
//
|
||
// CameraView+Metal.swift of MijickCamera
|
||
//
|
||
// Created by Tomasz Kurylik. Sending ❤️ from Kraków!
|
||
// - Mail: tomasz.kurylik@mijick.com
|
||
// - GitHub: https://github.com/FulcrumOne
|
||
// - Medium: https://medium.com/@mijick
|
||
//
|
||
// Copyright ©2024 Mijick. All rights reserved.
|
||
|
||
|
||
import SwiftUI
|
||
import MetalKit
|
||
import AVKit
|
||
|
||
@MainActor class CameraMetalView: MTKView {
|
||
private(set) var parent: CameraManager!
|
||
private(set) var ciContext: CIContext!
|
||
private(set) var commandQueue: MTLCommandQueue!
|
||
private(set) var currentFrame: CIImage?
|
||
private(set) var focusIndicator: CameraFocusIndicatorView = .init()
|
||
private(set) var isAnimating: Bool = false
|
||
}
|
||
|
||
// MARK: Setup
|
||
extension CameraMetalView {
|
||
func setup(parent: CameraManager) throws(MCameraError) {
|
||
guard let metalDevice = MTLCreateSystemDefaultDevice() else { throw .cannotSetupMetalDevice }
|
||
|
||
self.assignInitialValues(parent: parent, metalDevice: metalDevice)
|
||
self.configureMetalView(metalDevice: metalDevice)
|
||
self.addToParent(parent.cameraView)
|
||
}
|
||
}
|
||
private extension CameraMetalView {
|
||
func assignInitialValues(parent: CameraManager, metalDevice: MTLDevice) {
|
||
self.parent = parent
|
||
self.ciContext = CIContext(mtlDevice: metalDevice)
|
||
self.commandQueue = metalDevice.makeCommandQueue()
|
||
}
|
||
func configureMetalView(metalDevice: MTLDevice) {
|
||
self.parent.cameraView.alpha = 0
|
||
|
||
self.delegate = self
|
||
self.device = metalDevice
|
||
self.isPaused = true
|
||
self.enableSetNeedsDisplay = false
|
||
self.framebufferOnly = false
|
||
self.autoResizeDrawable = false
|
||
self.contentMode = .scaleAspectFill
|
||
self.clipsToBounds = true
|
||
}
|
||
}
|
||
|
||
|
||
// MARK: - ANIMATIONS
|
||
|
||
|
||
|
||
// MARK: Camera Entrance
|
||
extension CameraMetalView {
|
||
func performCameraEntranceAnimation() { UIView.animate(withDuration: 0.33) { [self] in
|
||
parent.cameraView.alpha = 1
|
||
}}
|
||
}
|
||
|
||
// MARK: Image Capture
|
||
extension CameraMetalView {
|
||
func performImageCaptureAnimation() {
|
||
let blackMatte = createBlackMatte()
|
||
|
||
parent.cameraView.addSubview(blackMatte)
|
||
animateBlackMatte(blackMatte)
|
||
}
|
||
|
||
/// Shows screen flash for front camera, calls completion when ready to capture
|
||
func performScreenFlash(completion: @escaping @MainActor () -> Void) {
|
||
guard let cameraView = parent?.cameraView else {
|
||
completion()
|
||
return
|
||
}
|
||
|
||
let flashColor = parent.attributes.screenFlashColor ?? .white
|
||
let flashView = createScreenFlashView(color: flashColor)
|
||
let originalBrightness = UIScreen.main.brightness
|
||
|
||
// Add flash overlay to the window for full screen coverage
|
||
if let window = cameraView.window {
|
||
flashView.frame = window.bounds
|
||
window.addSubview(flashView)
|
||
} else {
|
||
flashView.frame = cameraView.bounds
|
||
cameraView.addSubview(flashView)
|
||
}
|
||
|
||
// Boost brightness and show flash
|
||
UIScreen.main.brightness = 1.0
|
||
UIView.animate(withDuration: 0.05, animations: {
|
||
flashView.alpha = 1.0
|
||
}) { _ in
|
||
// Small delay for camera to adjust to new lighting, then capture
|
||
Task { @MainActor in
|
||
try? await Task.sleep(for: .milliseconds(100))
|
||
completion()
|
||
|
||
// Fade out flash after capture is triggered
|
||
try? await Task.sleep(for: .milliseconds(150))
|
||
UIView.animate(withDuration: 0.2, animations: {
|
||
flashView.alpha = 0
|
||
}) { _ in
|
||
flashView.removeFromSuperview()
|
||
UIScreen.main.brightness = originalBrightness
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
private extension CameraMetalView {
|
||
func createBlackMatte() -> UIView {
|
||
let view = UIView()
|
||
view.frame = parent.cameraView.frame
|
||
view.backgroundColor = .init(resource: .mijickBackgroundPrimary)
|
||
view.alpha = 0
|
||
return view
|
||
}
|
||
func animateBlackMatte(_ view: UIView) {
|
||
UIView.animate(withDuration: 0.16, animations: { view.alpha = 1 }) { _ in
|
||
UIView.animate(withDuration: 0.16, animations: { view.alpha = 0 }) { _ in
|
||
view.removeFromSuperview()
|
||
}
|
||
}
|
||
}
|
||
func createScreenFlashView(color: UIColor) -> UIView {
|
||
let view = UIView()
|
||
view.frame = UIScreen.main.bounds
|
||
view.backgroundColor = color
|
||
view.alpha = 0
|
||
return view
|
||
}
|
||
}
|
||
|
||
// MARK: Camera Flip
|
||
extension CameraMetalView {
|
||
func beginCameraFlipAnimation() async {
|
||
let snapshot = createSnapshot()
|
||
isAnimating = true
|
||
insertBlurView(snapshot)
|
||
animateBlurFlip()
|
||
|
||
await Task.sleep(seconds: 0.01)
|
||
}
|
||
func finishCameraFlipAnimation() async {
|
||
guard let blurView = parent.cameraView.viewWithTag(.blurViewTag) else { return }
|
||
|
||
await Task.sleep(seconds: 0.44)
|
||
UIView.animate(withDuration: 0.3, animations: { blurView.alpha = 0 }) { [self] _ in
|
||
blurView.removeFromSuperview()
|
||
isAnimating = false
|
||
}
|
||
}
|
||
}
|
||
private extension CameraMetalView {
|
||
func createSnapshot() -> UIImage? {
|
||
guard let currentFrame else { return nil }
|
||
|
||
let image = UIImage(ciImage: currentFrame)
|
||
return image
|
||
}
|
||
func insertBlurView(_ snapshot: UIImage?) {
|
||
let blurView = UIImageView(frame: parent.cameraView.frame)
|
||
blurView.image = snapshot
|
||
blurView.contentMode = .scaleAspectFill
|
||
blurView.clipsToBounds = true
|
||
blurView.tag = .blurViewTag
|
||
blurView.applyBlurEffect(style: .regular)
|
||
|
||
parent.cameraView.addSubview(blurView)
|
||
}
|
||
func animateBlurFlip() {
|
||
UIView.transition(with: parent.cameraView, duration: 0.44, options: cameraFlipAnimationTransition) {}
|
||
}
|
||
}
|
||
private extension CameraMetalView {
|
||
var cameraFlipAnimationTransition: UIView.AnimationOptions { parent.attributes.cameraPosition == .back ? .transitionFlipFromLeft : .transitionFlipFromRight }
|
||
}
|
||
|
||
// MARK: Camera Focus
|
||
extension CameraMetalView {
|
||
func performCameraFocusAnimation(touchPoint: CGPoint) {
|
||
removeExistingFocusIndicatorAnimations()
|
||
|
||
let focusIndicator = focusIndicator.create(at: touchPoint)
|
||
parent.cameraView.addSubview(focusIndicator)
|
||
animateFocusIndicator(focusIndicator)
|
||
}
|
||
}
|
||
private extension CameraMetalView {
|
||
func removeExistingFocusIndicatorAnimations() { if let view = parent.cameraView.viewWithTag(.focusIndicatorTag) {
|
||
view.removeFromSuperview()
|
||
}}
|
||
func animateFocusIndicator(_ focusIndicator: UIImageView) {
|
||
UIView.animate(withDuration: 0.44, delay: 0, usingSpringWithDamping: 0.6, initialSpringVelocity: 0, animations: { focusIndicator.transform = .init(scaleX: 1, y: 1) }) { _ in
|
||
UIView.animate(withDuration: 0.44, delay: 1.44, animations: { focusIndicator.alpha = 0.2 }) { _ in
|
||
UIView.animate(withDuration: 0.44, delay: 1.44, animations: { focusIndicator.alpha = 0 })
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// MARK: Camera Orientation
|
||
extension CameraMetalView {
|
||
func beginCameraOrientationAnimation(if shouldAnimate: Bool) async { if shouldAnimate {
|
||
parent.cameraView.alpha = 0
|
||
await Task.sleep(seconds: 0.1)
|
||
}}
|
||
func finishCameraOrientationAnimation(if shouldAnimate: Bool) { if shouldAnimate {
|
||
UIView.animate(withDuration: 0.2, delay: 0.1) { self.parent.cameraView.alpha = 1 }
|
||
}}
|
||
}
|
||
|
||
|
||
// MARK: - CAPTURING FRAMES
|
||
|
||
|
||
|
||
// MARK: Capture
|
||
extension CameraMetalView: @preconcurrency AVCaptureVideoDataOutputSampleBufferDelegate {
|
||
func captureOutput(_ output: AVCaptureOutput, didOutput sampleBuffer: CMSampleBuffer, from connection: AVCaptureConnection) {
|
||
guard let cvImageBuffer = CMSampleBufferGetImageBuffer(sampleBuffer) else { return }
|
||
|
||
let currentFrame = captureCurrentFrame(cvImageBuffer)
|
||
let currentFrameWithFiltersApplied = applyingFiltersToCurrentFrame(currentFrame)
|
||
redrawCameraView(currentFrameWithFiltersApplied)
|
||
}
|
||
}
|
||
private extension CameraMetalView {
|
||
func captureCurrentFrame(_ cvImageBuffer: CVImageBuffer) -> CIImage {
|
||
let currentFrame = CIImage(cvImageBuffer: cvImageBuffer)
|
||
return currentFrame.oriented(parent.attributes.frameOrientation)
|
||
}
|
||
func applyingFiltersToCurrentFrame(_ currentFrame: CIImage) -> CIImage {
|
||
currentFrame.applyingFilters(parent.attributes.cameraFilters)
|
||
}
|
||
func redrawCameraView(_ frame: CIImage) {
|
||
currentFrame = frame
|
||
draw()
|
||
}
|
||
}
|
||
|
||
// MARK: Draw
|
||
extension CameraMetalView: MTKViewDelegate {
|
||
func draw(in view: MTKView) {
|
||
guard let commandBuffer = commandQueue.makeCommandBuffer(),
|
||
let ciImage = currentFrame,
|
||
let currentDrawable = view.currentDrawable
|
||
else { return }
|
||
|
||
changeDrawableSize(view, ciImage)
|
||
renderView(view, currentDrawable, commandBuffer, ciImage)
|
||
commitBuffer(currentDrawable, commandBuffer)
|
||
}
|
||
func mtkView(_ view: MTKView, drawableSizeWillChange size: CGSize) {}
|
||
}
|
||
private extension CameraMetalView {
|
||
func changeDrawableSize(_ view: MTKView, _ ciImage: CIImage) {
|
||
view.drawableSize = ciImage.extent.size
|
||
}
|
||
func renderView(_ view: MTKView, _ currentDrawable: any CAMetalDrawable, _ commandBuffer: any MTLCommandBuffer, _ ciImage: CIImage) { ciContext.render(
|
||
ciImage,
|
||
to: currentDrawable.texture,
|
||
commandBuffer: commandBuffer,
|
||
bounds: .init(origin: .zero, size: view.drawableSize),
|
||
colorSpace: CGColorSpaceCreateDeviceRGB()
|
||
)}
|
||
func commitBuffer(_ currentDrawable: any CAMetalDrawable, _ commandBuffer: any MTLCommandBuffer) {
|
||
commandBuffer.present(currentDrawable)
|
||
commandBuffer.commit()
|
||
}
|
||
}
|