Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Added validation at the start of recording. #1612

Merged
merged 2 commits into from
Nov 1, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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)
}
}
}