Skip to content

Commit

Permalink
Merge pull request #3 from enebin/release/0.1.1
Browse files Browse the repository at this point in the history
Release/0.1.1
  • Loading branch information
enebin authored Jun 14, 2023
2 parents be91549 + da1ced2 commit bcb8a9e
Show file tree
Hide file tree
Showing 19 changed files with 593 additions and 251 deletions.
4 changes: 4 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -11,3 +11,7 @@ DerivedData/
Aespa-test-env/cuckoo_generator

Aespa-test-env/run

Tests/Cuckoo/cuckoo_generator

Tests/Cuckoo/run
1 change: 0 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -152,7 +152,6 @@ aespaSession.videoFilePublisher
}
.store(in: &subsriptions)
```
Based on the code provided, here is a draft for a README file that includes this functionality:

## SwiftUI Integration

Expand Down
13 changes: 12 additions & 1 deletion Sources/Aespa/AespaOption.swift
Original file line number Diff line number Diff line change
Expand Up @@ -48,17 +48,26 @@ public extension AespaOption {
struct Asset {
/// The name of the album where recorded videos will be saved.
let albumName: String

/// A `Boolean` flag that determines to use in-memory cache for `VideoFile`
///
/// It's set `true` by default.
let useVideoFileCache: Bool

/// The file extension for the recorded videos.
let fileNameHandler: FileNamingRule

/// The rule for naming video files.
let fileExtension: String

init(
albumName: String,
useVideoFileCache: Bool = true,
fileExtension: FileExtension = .mp4,
fileNameHandler: @escaping FileNamingRule = FileNamingRulePreset.Timestamp().rule
) {
self.albumName = albumName
self.useVideoFileCache = useVideoFileCache
self.fileExtension = fileExtension.rawValue
self.fileNameHandler = fileNameHandler
}
Expand All @@ -68,6 +77,8 @@ public extension AespaOption {
struct Session {
/// A Boolean value that determines whether video orientation should be automatic.
var autoVideoOrientationEnabled: Bool = true
/// An `AVCaptureDevice.DeviceType` value that determines camera device. If not specified, the device is automatically selected.
var cameraDevicePreference: AVCaptureDevice.DeviceType?
}

/// `Log` provides an option for enabling or disabling logging.
Expand All @@ -89,7 +100,7 @@ public extension AespaOption {
/// Creates a `Timestamp` file naming rule.
init() {
formatter = DateFormatter()
formatter.dateFormat = "yyyy_MM_dd_HH-mm"
formatter.dateFormat = "yyyy_MM_dd_HH_mm_ss"
}
}

Expand Down
100 changes: 21 additions & 79 deletions Sources/Aespa/AespaSession.swift
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ open class AespaSession {
private let option: AespaOption
private let coreSession: AespaCoreSession
private let recorder: AespaCoreRecorder
private let fileManager: FileManager
private let fileManager: AespaCoreFileManager
private let albumManager: AespaCoreAlbumManager

private let videoFileBufferSubject: CurrentValueSubject<Result<VideoFile, Error>?, Never>
Expand All @@ -40,7 +40,7 @@ open class AespaSession {
option: option,
session: session,
recorder: .init(core: session),
fileManager: .default,
fileManager: .init(enableCaching: option.asset.useVideoFileCache),
albumManager: .init(albumName: option.asset.albumName)
)
}
Expand All @@ -49,7 +49,7 @@ open class AespaSession {
option: AespaOption,
session: AespaCoreSession,
recorder: AespaCoreRecorder,
fileManager: FileManager,
fileManager: AespaCoreFileManager,
albumManager: AespaCoreAlbumManager
) {
self.option = option
Expand All @@ -64,8 +64,8 @@ open class AespaSession {
self.previewLayer = AVCaptureVideoPreviewLayer(session: session)

// Add first video file to buffer if it exists
if let firstVideoFile = fetch(count: 1).first {
self.videoFileBufferSubject.send(.success(firstVideoFile))
if let firstVideoFile = fileManager.fetch(albumName: option.asset.albumName, count: 1).first {
videoFileBufferSubject.send(.success(firstVideoFile))
}
}

Expand Down Expand Up @@ -105,17 +105,12 @@ open class AespaSession {
///
/// - Returns: `VideoFile` wrapped in a `Result` type.
public var videoFilePublisher: AnyPublisher<Result<VideoFile, Error>, Never> {
recorder.fileIOResultPublihser.map { status in
switch status {
case .success(let url):
return .success(VideoFileGenerator.generate(with: url))
case .failure(let error):
videoFileBufferSubject.handleEvents(receiveOutput: { status in
if case .failure(let error) = status {
Logger.log(error: error)
return .failure(error)
}
}
.merge(with: videoFileBufferSubject.eraseToAnyPublisher())
.compactMap { $0 }
})
.compactMap({ $0 })
.eraseToAnyPublisher()
}

Expand Down Expand Up @@ -168,7 +163,6 @@ open class AespaSession {
}
}
}


/// Mutes the audio input for the video recording session.
///
Expand Down Expand Up @@ -323,7 +317,7 @@ open class AespaSession {
public func startRecordingWithError() throws {
let fileName = option.asset.fileNameHandler()
let filePath = try VideoFilePathProvider.requestFilePath(
from: fileManager,
from: fileManager.systemFileManager,
directoryName: option.asset.albumName,
fileName: fileName)

Expand All @@ -342,9 +336,12 @@ open class AespaSession {
/// - Throws: `AespaError` if stopping the recording fails.
public func stopRecording() async throws -> VideoFile {
let videoFilePath = try await recorder.stopRecording()
try await self.albumManager.addToAlbum(filePath: videoFilePath)
let videoFile = VideoFileGenerator.generate(with: videoFilePath, date: Date())

try await albumManager.addToAlbum(filePath: videoFilePath)
videoFileBufferSubject.send(.success(videoFile))

return VideoFileGenerator.generate(with: videoFilePath)
return videoFile
}

/// Mutes the audio input for the video recording session.
Expand Down Expand Up @@ -387,14 +384,17 @@ open class AespaSession {

/// Sets the camera position for the video recording session.
///
/// It refers to `AespaOption.Session.cameraDevicePreference` when choosing the camera device.
///
/// - Parameter position: An `AVCaptureDevice.Position` value indicating the camera position to be set.
///
/// - Throws: `AespaError` if the session fails to run the tuner.
///
/// - Returns: `AespaSession`, for chaining calls.
@discardableResult
public func setPositionWithError(to position: AVCaptureDevice.Position) throws -> AespaSession {
let tuner = CameraPositionTuner(position: position)
let tuner = CameraPositionTuner(position: position,
devicePreference: option.session.cameraDevicePreference)
try coreSession.run(tuner)
return self
}
Expand Down Expand Up @@ -479,9 +479,9 @@ open class AespaSession {
/// If the limit is set to 0 (default), all recorded video files will be fetched.
/// - Returns: An array of `VideoFile` instances.
public func fetchVideoFiles(limit: Int = 0) -> [VideoFile] {
return fetch(count: limit)
return fileManager.fetch(albumName: option.asset.albumName, count: limit)
}

/// Checks if essential conditions to start recording are satisfied.
/// This includes checking for capture authorization, if the session is running,
/// if there is an existing connection and if a device is attached.
Expand Down Expand Up @@ -526,61 +526,3 @@ extension AespaSession {
try coreSession.run(tuner)
}
}

private extension AespaSession {
/// If `count` is `0`, return all existing files
func fetch(count: Int) async -> [VideoFile] {
guard count >= 0 else { return [] }

return await withCheckedContinuation { [weak self] continuation in
guard let self else { return }

DispatchQueue.global(qos: .utility).async {
do {
let directoryPath = try VideoFilePathProvider.requestDirectoryPath(from: self.fileManager,
name: self.option.asset.albumName)

let filePaths = try self.fileManager.contentsOfDirectory(atPath: directoryPath.path)
let filePathPrefix = count == 0 ? filePaths : Array(filePaths.prefix(count))

let files = filePathPrefix
.map { name -> URL in
return directoryPath.appendingPathComponent(name)
}
.map { filePath -> VideoFile in
return VideoFileGenerator.generate(with: filePath)
}

continuation.resume(returning: files)
} catch let error {
Logger.log(error: error)
continuation.resume(returning: [])
}
}
}
}

/// If `count` is `0`, return all existing files
func fetch(count: Int) -> [VideoFile] {
guard count >= 0 else { return [] }

do {
let directoryPath = try VideoFilePathProvider.requestDirectoryPath(from: fileManager,
name: option.asset.albumName)

let filePaths = try fileManager.contentsOfDirectory(atPath: directoryPath.path)
let filePathPrefix = count == 0 ? filePaths : Array(filePaths.prefix(count))

return filePathPrefix
.map { name -> URL in
return directoryPath.appendingPathComponent(name)
}
.map { filePath -> VideoFile in
return VideoFileGenerator.generate(with: filePath)
}
} catch let error {
Logger.log(error: error)
return []
}
}
}
42 changes: 42 additions & 0 deletions Sources/Aespa/Core/AespaCoreFileManager.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
//
// AespaCoreFileManager.swift
//
//
// Created by 이영빈 on 2023/06/13.
//

import Foundation

class AespaCoreFileManager {
private var videoFileProxyDictionary: [String: VideoFileCachingProxy]
private let enableCaching: Bool

let systemFileManager: FileManager

init(
enableCaching: Bool,
fileManager: FileManager = .default
) {
videoFileProxyDictionary = [:]
self.enableCaching = enableCaching
self.systemFileManager = fileManager
}

/// If `count` is `0`, return all existing files
func fetch(albumName: String, count: Int) -> [VideoFile] {
guard count >= 0 else { return [] }

guard let proxy = videoFileProxyDictionary[albumName] else {
videoFileProxyDictionary[albumName] = VideoFileCachingProxy(
albumName: albumName,
enableCaching: enableCaching,
fileManager: systemFileManager)

return fetch(albumName: albumName, count: count)
}

let files = proxy.fetch(count: count)
Logger.log(message: "\(files.count) Video files fetched")
return files
}
}
4 changes: 0 additions & 4 deletions Sources/Aespa/Core/AespaCoreRecorder.swift
Original file line number Diff line number Diff line change
Expand Up @@ -31,10 +31,6 @@ class AespaCoreRecorder: NSObject {
}

extension AespaCoreRecorder {
var fileIOResultPublihser: AnyPublisher<Result<URL, Error>, Never> {
return self.fileIOResultSubject.eraseToAnyPublisher()
}

func startRecording(in filePath: URL) throws {
try run(processor: StartRecordProcessor(filePath: filePath, delegate: self))
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -55,10 +55,10 @@ public protocol AespaCoreSessionRepresentable {

/// Sets the position of the camera.
/// Throws an error if the operation fails.
func setCameraPosition(to position: AVCaptureDevice.Position) throws
func setCameraPosition(to position: AVCaptureDevice.Position, device deviceType: AVCaptureDevice.DeviceType?) throws

/// Sets the video quality preset.
func setVideoQuality(to preset: AVCaptureSession.Preset)
func setVideoQuality(to preset: AVCaptureSession.Preset) throws
}

extension AespaCoreSession: AespaCoreSessionRepresentable {
Expand Down Expand Up @@ -159,14 +159,22 @@ extension AespaCoreSession: AespaCoreSessionRepresentable {
}

// MARK: - Option related
func setCameraPosition(to position: AVCaptureDevice.Position) throws {
func setCameraPosition(to position: AVCaptureDevice.Position,device deviceType: AVCaptureDevice.DeviceType?) throws {
let session = self

if let videoDeviceInput {
session.removeInput(videoDeviceInput)
}

guard let device = position.chooseBestCamera else {
let device: AVCaptureDevice
if
let deviceType,
let captureDeivce = AVCaptureDevice.default(deviceType, for: .video, position: position)
{
device = captureDeivce
} else if let bestDevice = position.chooseBestCamera {
device = bestDevice
} else {
throw AespaError.device(reason: .invalid)
}

Expand Down
53 changes: 53 additions & 0 deletions Sources/Aespa/Data/VideoFile.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
//
// VideoFile.swift
//
//
// Created by 이영빈 on 2023/06/13.
//

import UIKit
import SwiftUI
import AVFoundation

/// `VideoFile` represents a video file with its associated metadata.
///
/// This struct holds information about the video file, including a unique identifier (`id`),
/// the path to the video file (`path`), and an optional thumbnail image (`thumbnail`)
/// generated from the video.
public struct VideoFile: Equatable {
/// A `Date` value keeps the date it's generated
public let generatedDate: Date

/// The path to the video file.
public let path: URL

/// An optional thumbnail generated from the video with `UIImage` type.
/// This will be `nil` if the thumbnail could not be generated for some reason.
public var thumbnail: UIImage?
}

/// UI related extension methods
public extension VideoFile {

/// An optional thumbnail generated from the video with SwiftUI `Image` type.
/// This will be `nil` if the thumbnail could not be generated for some reason.
var thumbnailImage: Image? {
if let thumbnail {
return Image(uiImage: thumbnail)
}

return nil
}
}

extension VideoFile: Identifiable {
public var id: URL {
self.path
}
}

extension VideoFile: Comparable {
public static func < (lhs: VideoFile, rhs: VideoFile) -> Bool {
lhs.generatedDate > rhs.generatedDate
}
}
Loading

0 comments on commit bcb8a9e

Please sign in to comment.