Skip to content

Commit

Permalink
Merge pull request #1612 from shogo4405/featue/advanced-recording
Browse files Browse the repository at this point in the history
Added validation at the start of recording.
  • Loading branch information
shogo4405 authored Nov 1, 2024
2 parents f582946 + 099c48b commit c2c423d
Show file tree
Hide file tree
Showing 4 changed files with 146 additions and 18 deletions.
1 change: 1 addition & 0 deletions Examples/iOS/Screencast/SampleHandler.swift
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,7 @@ final class SampleHandler: RPBroadcastSampleHandler, @unchecked Sendable {
Task { @MainActor in
if let volume = slider?.value {
var audioMixerSettings = await mixer.audioMixerSettings
audioMixerSettings.isMuted = true
audioMixerSettings.tracks[1] = .default
audioMixerSettings.tracks[1]?.volume = volume * 0.5
await mixer.setAudioMixerSettings(audioMixerSettings)
Expand Down
12 changes: 12 additions & 0 deletions HaishinKit.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,7 @@
BC0587C12BD2A123006751C8 /* AudioMixerBySingleTrackTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = BC0587C02BD2A123006751C8 /* AudioMixerBySingleTrackTests.swift */; };
BC0587C32BD2A5E8006751C8 /* AudioMixerByMultiTrackTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = BC0587C22BD2A5E8006751C8 /* AudioMixerByMultiTrackTests.swift */; };
BC0587D22BD2CA7F006751C8 /* AudioStreamBasicDescription+DebugExtension.swift in Sources */ = {isa = PBXBuildFile; fileRef = BC0587D12BD2CA7F006751C8 /* AudioStreamBasicDescription+DebugExtension.swift */; };
BC0628352CD25466005EB88E /* HKStreamRecorderTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = BC0628342CD2545E005EB88E /* HKStreamRecorderTests.swift */; };
BC0B5B122BE8CFA800D83F8E /* CMVideoDimention+Extension.swift in Sources */ = {isa = PBXBuildFile; fileRef = BC0B5B112BE8CFA800D83F8E /* CMVideoDimention+Extension.swift */; };
BC0B5B142BE8DFE300D83F8E /* AVLayerVideoGravity+Extension.swift in Sources */ = {isa = PBXBuildFile; fileRef = BC0B5B132BE8DFE300D83F8E /* AVLayerVideoGravity+Extension.swift */; };
BC0B5B172BE919D000D83F8E /* ScreenObjectTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = BC0B5B162BE919D000D83F8E /* ScreenObjectTests.swift */; };
Expand Down Expand Up @@ -555,6 +556,7 @@
BC0587C02BD2A123006751C8 /* AudioMixerBySingleTrackTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AudioMixerBySingleTrackTests.swift; sourceTree = "<group>"; };
BC0587C22BD2A5E8006751C8 /* AudioMixerByMultiTrackTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AudioMixerByMultiTrackTests.swift; sourceTree = "<group>"; };
BC0587D12BD2CA7F006751C8 /* AudioStreamBasicDescription+DebugExtension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "AudioStreamBasicDescription+DebugExtension.swift"; sourceTree = "<group>"; };
BC0628342CD2545E005EB88E /* HKStreamRecorderTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HKStreamRecorderTests.swift; sourceTree = "<group>"; };
BC0B5B112BE8CFA800D83F8E /* CMVideoDimention+Extension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "CMVideoDimention+Extension.swift"; sourceTree = "<group>"; };
BC0B5B132BE8DFE300D83F8E /* AVLayerVideoGravity+Extension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "AVLayerVideoGravity+Extension.swift"; sourceTree = "<group>"; };
BC0B5B162BE919D000D83F8E /* ScreenObjectTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ScreenObjectTests.swift; sourceTree = "<group>"; };
Expand Down Expand Up @@ -1005,6 +1007,7 @@
BC0B5B1D2BE9310800D83F8E /* CMVideoSampleBufferFactory.swift */,
295018191FFA196800358E10 /* Codec */,
BC03945D2AA8AFDD006EDE38 /* Extension */,
BC0628332CD2544E005EB88E /* HKStream */,
29798E5D1CE60E5300F5CBD0 /* Info.plist */,
291C2ACF1CE9FF2B006F042B /* ISO */,
BC0BF4F329866FB700D72CB4 /* Mixer */,
Expand Down Expand Up @@ -1150,6 +1153,14 @@
path = DebugDescription;
sourceTree = "<group>";
};
BC0628332CD2544E005EB88E /* HKStream */ = {
isa = PBXGroup;
children = (
BC0628342CD2545E005EB88E /* HKStreamRecorderTests.swift */,
);
path = HKStream;
sourceTree = "<group>";
};
BC0B5B152BE919B700D83F8E /* Screen */ = {
isa = PBXGroup;
children = (
Expand Down Expand Up @@ -1850,6 +1861,7 @@
BC56452C2C4972BD00CC79C5 /* CMSampleBuffer+ExtensionTests.swift in Sources */,
BC7C56C329A1F28700C41A9B /* TSReaderTests.swift in Sources */,
BC7C56D129A78D4F00C41A9B /* ADTSHeaderTests.swift in Sources */,
BC0628352CD25466005EB88E /* HKStreamRecorderTests.swift in Sources */,
BC3E384429C216BB007CD972 /* ADTSReaderTests.swift in Sources */,
BC1720A92C03473200F65941 /* AVCDecoderConfigurationRecordTests.swift in Sources */,
295018201FFA1BD700358E10 /* AudioCodecTests.swift in Sources */,
Expand Down
102 changes: 84 additions & 18 deletions Sources/HKStream/HKStreamRecorder.swift
Original file line number Diff line number Diff line change
Expand Up @@ -20,10 +20,16 @@
/// stream.addOutput(recorder)
/// ```
public actor HKStreamRecorder {
static let defaultPathExtension = "mp4"

/// The error domain codes.
public enum Error: Swift.Error {
/// An invalid internal stare.
case invalidState
/// The specified file already exists.
case fileAlreadyExists(outputURL: URL)
/// The specifiled file type is not supported.
case notSupportedFileType(pathExtension: String)
/// Failed to create the AVAssetWriter.
case failedToCreateAssetWriter(error: any Swift.Error)
/// Failed to create the AVAssetWriterInput.
Expand Down Expand Up @@ -59,16 +65,43 @@ public actor HKStreamRecorder {
}
}

enum SupportedFileType: String {
case mp4
case mov

var fileType: AVFileType {
switch self {
case .mp4:
return .mp4
case .mov:
return .mov
}
}
}

/// The recorder settings.
public private(set) var settings: [AVMediaType: [String: any Sendable]] = HKStreamRecorder.defaultSettings
/// The recording file name.
public private(set) var fileName: String?
/// The recording output url.
public var outputURL: URL? {
return writer?.outputURL
}
/// The recording or not.
public private(set) var isRecording = false
/// The the movie fragment interval in sec.
public private(set) var movieFragmentInterval: Double?
public private(set) var videoTrackId: UInt8? = UInt8.max
public private(set) var audioTrackId: UInt8? = UInt8.max
#if os(macOS) && !targetEnvironment(macCatalyst)
/// The default file save location.
public private(set) var moviesDirectory: URL = {
URL(fileURLWithPath: NSSearchPathForDirectoriesInDomains(.moviesDirectory, .userDomainMask, true)[0])
}()
#else
/// The default file save location.
public private(set) lazy var moviesDirectory: URL = {
URL(fileURLWithPath: NSSearchPathForDirectoriesInDomains(.documentDirectory, .userDomainMask, true)[0])
}()
#endif
private var isReadyForStartWriting: Bool {
guard let writer = writer else {
return false
Expand All @@ -82,16 +115,6 @@ public actor HKStreamRecorder {
private var videoPresentationTime: CMTime = .zero
private var dimensions: CMVideoDimensions = .init(width: 0, height: 0)

#if os(iOS)
private lazy var moviesDirectory: URL = {
URL(fileURLWithPath: NSSearchPathForDirectoriesInDomains(.documentDirectory, .userDomainMask, true)[0])
}()
#else
private lazy var moviesDirectory: URL = {
URL(fileURLWithPath: NSSearchPathForDirectoriesInDomains(.moviesDirectory, .userDomainMask, true)[0])
}()
#endif

/// Creates a new recorder.
public init() {
}
Expand All @@ -109,21 +132,51 @@ public actor HKStreamRecorder {
}

/// Starts recording.
public func startRecording(_ fileName: String? = nil, settings: [AVMediaType: [String: any Sendable]] = HKStreamRecorder.defaultSettings) async throws {
///
/// For iOS, if the URL is unspecified, the file will be saved in .documentDirectory. You can specify a folder of your choice, but please use an absolute path.
///
/// ```
/// try? await recorder.startRecording(nil)
/// // -> $documentDirectory/B644F60F-0959-4F54-9D14-7F9949E02AD8.mp4
///
/// try? await recorder.startRecording(URL(string: "dir/sample.mp4"))
/// // -> $documentDirectory/dir/sample.mp4
///
/// try? await recorder.startRecording(await recorder.moviesDirectory.appendingPathComponent("sample.mp4"))
/// // -> $documentDirectory/sample.mp4
///
/// try? await recorder.startRecording(URL(string: "dir"))
/// // -> $documentDirectory/dir/33FA7D32-E0A8-4E2C-9980-B54B60654044.mp4
/// ```
///
/// - Note: Folders are not created automatically, so it’s expected that the target directory is created in advance.
/// - Parameters:
/// - url: The file path for recording. If nil is specified, a unique file path will be returned automatically.
/// - settings: Settings for recording.
/// - Throws: `Error.fileAlreadyExists` when case file already exists.
/// - Throws: `Error.notSupportedFileType` when case species not supported format.
public func startRecording(_ url: URL? = nil, settings: [AVMediaType: [String: any Sendable]] = HKStreamRecorder.defaultSettings) async throws {
guard !isRecording else {
throw Error.invalidState
}

self.fileName = fileName ?? UUID().uuidString
self.settings = settings
let outputURL = makeOutputURL(url)
if FileManager.default.fileExists(atPath: outputURL.path) {
throw Error.fileAlreadyExists(outputURL: outputURL)
}

guard let fileName = self.fileName else { throw Error.invalidState }
var fileType: AVFileType = .mp4
if let supportedFileType = SupportedFileType(rawValue: outputURL.pathExtension) {
fileType = supportedFileType.fileType
} else {
throw Error.notSupportedFileType(pathExtension: outputURL.pathExtension)
}

writer = try AVAssetWriter(outputURL: outputURL, fileType: fileType)
videoPresentationTime = .zero
audioPresentationTime = .zero
self.settings = settings

let url = moviesDirectory.appendingPathComponent(fileName).appendingPathExtension("mp4")
writer = try AVAssetWriter(outputURL: url, fileType: .mp4)
isRecording = true
}

Expand Down Expand Up @@ -172,6 +225,19 @@ public actor HKStreamRecorder {
}
}

private func makeOutputURL(_ url: URL?) -> URL {
guard let url else {
return moviesDirectory.appendingPathComponent(UUID().uuidString).appendingPathExtension(Self.defaultPathExtension)
}
// AVAssetWriter requires a isFileURL condition.
guard url.isFileURL else {
return url.pathExtension != "" ?
moviesDirectory.appendingPathComponent(url.path) :
moviesDirectory.appendingPathComponent(url.path).appendingPathComponent(UUID().uuidString).appendingPathExtension(Self.defaultPathExtension)
}
return url.pathExtension != "" ? url : url.appendingPathComponent(UUID().uuidString).appendingPathExtension(Self.defaultPathExtension)
}

private func append(_ sampleBuffer: CMSampleBuffer) {
guard isRecording else {
return
Expand Down
49 changes: 49 additions & 0 deletions Tests/HKStream/HKStreamRecorderTests.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
import Foundation
import Testing

@testable import HaishinKit

@Suite struct HKStreamRecorderTests {
@Test func startRunning_nil() async {
let recorder = HKStreamRecorder()
try! await recorder.startRecording(nil)
let moviesDirectory = await recorder.moviesDirectory
// $moviesDirectory/B644F60F-0959-4F54-9D14-7F9949E02AD8.mp4
#expect(((await recorder.outputURL?.path.contains(moviesDirectory.path())) != nil))
}

@Test func startRunning_fileName() async {
let recorder = HKStreamRecorder()
try? await recorder.startRecording(URL(string: "dir/sample.mp4"))
let moviesDirectory = await recorder.moviesDirectory
// $moviesDirectory/dir/sample.mp4
#expect(((await recorder.outputURL?.path.contains("dir/sample.mp4")) != nil))
}

@Test func startRunning_fullPath() async {
let recorder = HKStreamRecorder()
let fullPath = await recorder.moviesDirectory.appendingPathComponent("sample.mp4")
// $moviesDirectory/sample.mp4
try? await recorder.startRecording(fullPath)
#expect(await recorder.outputURL == fullPath)
}

@Test func startRunning_dir() async {
let recorder = HKStreamRecorder()
try? await recorder.startRecording(URL(string: "dir"))
// $moviesDirectory/dir/33FA7D32-E0A8-4E2C-9980-B54B60654044.mp4
#expect(((await recorder.outputURL?.path.contains("dir")) != nil))
}

@Test func startRunning_fileAlreadyExists() async {
let recorder = HKStreamRecorder()
let filePath = await recorder.moviesDirectory.appendingPathComponent("duplicate-file.mp4")
FileManager.default.createFile(atPath: filePath.path, contents: nil)
do {
try await recorder.startRecording(filePath)
fatalError()
} catch {
try? FileManager.default.removeItem(atPath: filePath.path)
}
}
}

0 comments on commit c2c423d

Please sign in to comment.