diff --git a/Examples/iOS/Screencast/SampleHandler.swift b/Examples/iOS/Screencast/SampleHandler.swift index 50191fc59..9c255b904 100644 --- a/Examples/iOS/Screencast/SampleHandler.swift +++ b/Examples/iOS/Screencast/SampleHandler.swift @@ -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) diff --git a/HaishinKit.xcodeproj/project.pbxproj b/HaishinKit.xcodeproj/project.pbxproj index 7f0fa497a..7fc7e1ec1 100644 --- a/HaishinKit.xcodeproj/project.pbxproj +++ b/HaishinKit.xcodeproj/project.pbxproj @@ -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 */; }; @@ -555,6 +556,7 @@ BC0587C02BD2A123006751C8 /* AudioMixerBySingleTrackTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AudioMixerBySingleTrackTests.swift; sourceTree = ""; }; BC0587C22BD2A5E8006751C8 /* AudioMixerByMultiTrackTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AudioMixerByMultiTrackTests.swift; sourceTree = ""; }; BC0587D12BD2CA7F006751C8 /* AudioStreamBasicDescription+DebugExtension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "AudioStreamBasicDescription+DebugExtension.swift"; sourceTree = ""; }; + BC0628342CD2545E005EB88E /* HKStreamRecorderTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HKStreamRecorderTests.swift; sourceTree = ""; }; BC0B5B112BE8CFA800D83F8E /* CMVideoDimention+Extension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "CMVideoDimention+Extension.swift"; sourceTree = ""; }; BC0B5B132BE8DFE300D83F8E /* AVLayerVideoGravity+Extension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "AVLayerVideoGravity+Extension.swift"; sourceTree = ""; }; BC0B5B162BE919D000D83F8E /* ScreenObjectTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ScreenObjectTests.swift; sourceTree = ""; }; @@ -1005,6 +1007,7 @@ BC0B5B1D2BE9310800D83F8E /* CMVideoSampleBufferFactory.swift */, 295018191FFA196800358E10 /* Codec */, BC03945D2AA8AFDD006EDE38 /* Extension */, + BC0628332CD2544E005EB88E /* HKStream */, 29798E5D1CE60E5300F5CBD0 /* Info.plist */, 291C2ACF1CE9FF2B006F042B /* ISO */, BC0BF4F329866FB700D72CB4 /* Mixer */, @@ -1150,6 +1153,14 @@ path = DebugDescription; sourceTree = ""; }; + BC0628332CD2544E005EB88E /* HKStream */ = { + isa = PBXGroup; + children = ( + BC0628342CD2545E005EB88E /* HKStreamRecorderTests.swift */, + ); + path = HKStream; + sourceTree = ""; + }; BC0B5B152BE919B700D83F8E /* Screen */ = { isa = PBXGroup; children = ( @@ -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 */, diff --git a/Sources/HKStream/HKStreamRecorder.swift b/Sources/HKStream/HKStreamRecorder.swift index 33c5f7b49..a7059c677 100644 --- a/Sources/HKStream/HKStreamRecorder.swift +++ b/Sources/HKStream/HKStreamRecorder.swift @@ -130,29 +130,36 @@ public actor HKStreamRecorder { } /// Starts recording. + /// + /// 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: - /// - file: The file path for recording. If nil is specified, a unique file path will be returned automatically. + /// - url: The file path for recording. If nil is specified, a unique file path will be returned automatically. /// - settings: Settings for recording. - public func startRecording(_ file: URL? = nil, settings: [AVMediaType: [String: any Sendable]] = HKStreamRecorder.defaultSettings) async throws { + /// - 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 } - var outputURL: URL - if let file { - if file.hasDirectoryPath { - outputURL = file - } else { - outputURL = moviesDirectory.appendingPathComponent(file.absoluteString) - } - if file.pathExtension == "" { - outputURL = outputURL.appendingPathExtension(Self.defaultPathExtension) - } - } else { - outputURL = moviesDirectory.appendingPathComponent(UUID().uuidString).appendingPathExtension(Self.defaultPathExtension) - } - - if FileManager.default.fileExists(atPath: outputURL.absoluteString) { + let outputURL = makeOutputURL(url) + if FileManager.default.fileExists(atPath: outputURL.path) { throw Error.fileAlreadyExists(outputURL: outputURL) } @@ -216,6 +223,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 diff --git a/Tests/HKStream/HKStreamRecorderTests.swift b/Tests/HKStream/HKStreamRecorderTests.swift new file mode 100644 index 000000000..1fd7d4425 --- /dev/null +++ b/Tests/HKStream/HKStreamRecorderTests.swift @@ -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) + } + } +}