From fa6cd807898ba7e9e83ccac77d1e6eb343912f27 Mon Sep 17 00:00:00 2001 From: giljihun Date: Mon, 9 Feb 2026 15:10:40 +0900 Subject: [PATCH 1/4] =?UTF-8?q?feat:=20Firebase=20Storage=20URL=20?= =?UTF-8?q?=ED=8C=8C=EC=9D=BC=EB=AA=85=20=EC=B6=94=EC=B6=9C=20Extension=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 3개 파일에서 동일한 로직이 중복되어 추상화했음. - URL 형식 .../o/path%2Fto%2Ffile.m4a?token=... ~~ 에서 file.m4a 추출 - URL 인코딩된 경로 디코딩 처리 --- .../Core/Extensions/String+Extension.swift | 22 +++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/Keychy/Keychy/Core/Extensions/String+Extension.swift b/Keychy/Keychy/Core/Extensions/String+Extension.swift index 298eb112f..28d384393 100644 --- a/Keychy/Keychy/Core/Extensions/String+Extension.swift +++ b/Keychy/Keychy/Core/Extensions/String+Extension.swift @@ -13,4 +13,26 @@ extension String { var byCharWrapping: Self { map(String.init).joined(separator: "\u{200B}") } + + /// Firebase Storage URL에서 파일명 추출 + /// URL 형식: https://firebasestorage.googleapis.com/v0/b/.../o/path%2Fto%2Ffile.m4a?alt=media&token=xxx + var firebaseStorageFileName: String { + // /o/ 이후의 경로 추출 + guard let oRange = range(of: "/o/") else { + return URL(string: self)?.lastPathComponent ?? "custom_sound.m4a" + } + + // /o/ 이후부터 ? 이전까지 추출 + var encodedPath = String(self[oRange.upperBound...]) + if let queryIndex = encodedPath.firstIndex(of: "?") { + encodedPath = String(encodedPath[.. Date: Mon, 9 Feb 2026 15:12:04 +0900 Subject: [PATCH 2/4] =?UTF-8?q?fix:=20=EC=BB=A4=EC=8A=A4=ED=85=80=20?= =?UTF-8?q?=EC=82=AC=EC=9A=B4=EB=93=9C=20=ED=82=A4=EB=A7=81=20=EC=98=81?= =?UTF-8?q?=EC=83=81=20=EC=83=9D=EC=84=B1=20=EC=8B=A4=ED=8C=A8=20=EB=B2=84?= =?UTF-8?q?=EA=B7=B8=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 새로 생성된 키링의 커스텀 사운드가 캐시에 없어 영상 생성 실패!~ 였으나 수정 - Firebase 업로드 후에, viewModel.soundId를 Firebase URL로 업뎃 - copySoundToCache: 로컬 파일을 캐시에 복사 -> 영상 생성 시 사용 - extractFileName 중복 제거 -> String 익스텐션에서 사용 --- .../KeyringInfoInputView+FirebaseSave.swift | 48 ++++++++++++++++--- 1 file changed, 41 insertions(+), 7 deletions(-) diff --git a/Keychy/Keychy/Presentation/KeyringMaker/Shared/Views/KeyringInfoInputView+FirebaseSave.swift b/Keychy/Keychy/Presentation/KeyringMaker/Shared/Views/KeyringInfoInputView+FirebaseSave.swift index 2f2a9b144..3a80186ae 100644 --- a/Keychy/Keychy/Presentation/KeyringMaker/Shared/Views/KeyringInfoInputView+FirebaseSave.swift +++ b/Keychy/Keychy/Presentation/KeyringMaker/Shared/Views/KeyringInfoInputView+FirebaseSave.swift @@ -30,15 +30,21 @@ extension KeyringInfoInputView { // 2. 커스텀 사운드가 있으면 Firebase Storage에 업로드 if let customSoundURL = self.viewModel.customSoundURL { - self.uploadSoundToStorage(soundURL: customSoundURL, uid: uid) { soundURL in - guard let soundURL = soundURL else { + self.uploadSoundToStorage(soundURL: customSoundURL, uid: uid) { firebaseURL in + guard let firebaseURL = firebaseURL else { // 업로드 실패 시 기존 soundId 사용 self.createKeyringWithData(uid: uid, imageURL: imageURL, soundId: self.viewModel.soundId) return } - + // 업로드 성공 - Firebase Storage URL을 soundId로 사용 - self.createKeyringWithData(uid: uid, imageURL: imageURL, soundId: soundURL) + // viewModel도 업데이트하여 CompleteView에서 올바른 soundId 사용 + self.viewModel.soundId = firebaseURL + + // 로컬 사운드 파일을 캐시에 복사 (영상 생성 시 사용) + self.copySoundToCache(localURL: customSoundURL, firebaseURL: firebaseURL) + + self.createKeyringWithData(uid: uid, imageURL: imageURL, soundId: firebaseURL) } } else { // 커스텀 사운드 없음 - 기존 soundId 사용 @@ -127,10 +133,10 @@ extension KeyringInfoInputView { completion(nil) return } - + let fileName = "\(UUID().uuidString).m4a" let path = "Keyrings/CustomSounds/\(uid)/\(fileName)" - + Task { do { let downloadURL = try await StorageManager.shared.uploadAudio(soundData, path: path) @@ -141,7 +147,35 @@ extension KeyringInfoInputView { } } } - + + // MARK: - 로컬 사운드 파일을 캐시에 복사 + /// 새로 생성된 키링의 커스텀 사운드를 영상 생성 시 사용할 수 있도록 캐시에 복사 + private func copySoundToCache(localURL: URL, firebaseURL: String) { + let cacheDirectory = FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask)[0] + let soundsDir = cacheDirectory.appendingPathComponent("sounds") + + // sounds 디렉토리 생성 + if !FileManager.default.fileExists(atPath: soundsDir.path) { + try? FileManager.default.createDirectory(at: soundsDir, withIntermediateDirectories: true) + } + + // Firebase URL에서 파일명 추출 + let fileName = firebaseURL.firebaseStorageFileName + let destinationURL = soundsDir.appendingPathComponent(fileName) + + do { + // 이미 존재하면 삭제 + if FileManager.default.fileExists(atPath: destinationURL.path) { + try FileManager.default.removeItem(at: destinationURL) + } + + // 로컬 파일을 캐시로 복사 + try FileManager.default.copyItem(at: localURL, to: destinationURL) + } catch { + print("[SoundCache] 사운드 캐시 복사 실패: \(error.localizedDescription)") + } + } + // MARK: - 새 키링 생성 및 User에 추가 func createKeyring( uid: String, From 31c86bdc1d21917259387a9d14377a9de44a420a Mon Sep 17 00:00:00 2001 From: giljihun Date: Mon, 9 Feb 2026 15:13:29 +0900 Subject: [PATCH 3/4] =?UTF-8?q?fix:=20EffectSyncManager=20=EC=BB=A4?= =?UTF-8?q?=EC=8A=A4=ED=85=80=20=EC=82=AC=EC=9A=B4=EB=93=9C=20=ED=8C=8C?= =?UTF-8?q?=EC=9D=BC=EB=AA=85=20=EC=B6=94=EC=B6=9C=20=EB=B2=84=EA=B7=B8=20?= =?UTF-8?q?=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Firebase URL path에서 잘못된 파일명을 추출하던 버그 수정 - 기존: downloadURL.path.last → URL 인코딩된 전체 경로 반환 - 수정: firebaseStorageFileName → /o/ 이후 경로를 디코딩하여 실제 파일명 추출 - 불필요한 디버그 로그 제거 --- Keychy/Keychy/Core/Firebase/EffectSyncManager.swift | 12 ++---------- 1 file changed, 2 insertions(+), 10 deletions(-) diff --git a/Keychy/Keychy/Core/Firebase/EffectSyncManager.swift b/Keychy/Keychy/Core/Firebase/EffectSyncManager.swift index 9c4e827e1..a76d9f191 100644 --- a/Keychy/Keychy/Core/Firebase/EffectSyncManager.swift +++ b/Keychy/Keychy/Core/Firebase/EffectSyncManager.swift @@ -285,9 +285,8 @@ class EffectSyncManager { return } - // URL path에서 마지막 컴포넌트만 추출 (예: 6FBEABEA-F603-40EA-B953-7D2F0AA9EB03.m4a) - let pathComponents = downloadURL.path.components(separatedBy: "/") - let fileName = pathComponents.last ?? "custom_sound.m4a" + // Firebase Storage URL에서 파일명 추출 + let fileName = soundURLString.firebaseStorageFileName let cacheDirectory = FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask)[0] let soundsDir = cacheDirectory.appendingPathComponent("sounds") @@ -306,19 +305,12 @@ class EffectSyncManager { // 이미 다운로드되어 있으면 스킵 if FileManager.default.fileExists(atPath: localURL.path) { - print("[EffectSync] 커스텀 사운드 이미 캐시에 있음: \(fileName)") return } do { - // URLSession으로 직접 다운로드 let (tempURL, _) = try await URLSession.shared.download(from: downloadURL) - - // 임시 파일을 최종 위치로 이동 try FileManager.default.moveItem(at: tempURL, to: localURL) - - print("[EffectSync] 커스텀 사운드 다운로드 완료: \(fileName)") - } catch { print("[EffectSync] 커스텀 사운드 다운로드 실패 (\(fileName)): \(error.localizedDescription)") } From ffb224baf8605b2e2e81107f3e00154cfd5569ac Mon Sep 17 00:00:00 2001 From: giljihun Date: Mon, 9 Feb 2026 15:13:49 +0900 Subject: [PATCH 4/4] =?UTF-8?q?refactor:=20KeyringVideoGenerator=20?= =?UTF-8?q?=EC=82=AC=EC=9A=B4=EB=93=9C=20=EC=B2=98=EB=A6=AC=20=EB=A1=9C?= =?UTF-8?q?=EC=A7=81=20=EC=A0=95=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - findSoundURL 함수 간결화 --- .../Keyring/KeyringVideoGenerator+Sound.swift | 52 ++++++++++++------- 1 file changed, 33 insertions(+), 19 deletions(-) diff --git a/Keychy/Keychy/Core/Video/Keyring/KeyringVideoGenerator+Sound.swift b/Keychy/Keychy/Core/Video/Keyring/KeyringVideoGenerator+Sound.swift index 6957767dd..51a9b18a1 100644 --- a/Keychy/Keychy/Core/Video/Keyring/KeyringVideoGenerator+Sound.swift +++ b/Keychy/Keychy/Core/Video/Keyring/KeyringVideoGenerator+Sound.swift @@ -77,18 +77,25 @@ extension KeyringVideoGenerator { } let audioAsset = AVURLAsset(url: soundURL) - guard let audioTrack = try await audioAsset.loadTracks(withMediaType: .audio).first else { - continue - } - let audioDuration = try await audioAsset.load(.duration) - let startTime = CMTime(seconds: event.time, preferredTimescale: 600) + do { + let tracks = try await audioAsset.loadTracks(withMediaType: .audio) - try compositionAudioTrack?.insertTimeRange( - CMTimeRange(start: .zero, duration: audioDuration), - of: audioTrack, - at: startTime - ) + guard let audioTrack = tracks.first else { + continue + } + + let audioDuration = try await audioAsset.load(.duration) + let startTime = CMTime(seconds: event.time, preferredTimescale: 600) + + try compositionAudioTrack?.insertTimeRange( + CMTimeRange(start: .zero, duration: audioDuration), + of: audioTrack, + at: startTime + ) + } catch { + print("[VideoGenerator] 오디오 트랙 삽입 실패: \(error.localizedDescription)") + } } // 최종 비디오 Export @@ -111,22 +118,29 @@ extension KeyringVideoGenerator { } /// 사운드 파일 URL 찾기 - /// 1. Firebase 캐시 확인 (sounds/soundId.mp3) - /// 2. 커스텀 녹음 파일인 경우 URL 직접 반환 + /// 1. Firebase Storage URL인 경우 (커스텀 사운드) 캐시에서 파일명으로 찾기 + /// 2. 일반 사운드 ID인 경우 캐시에서 찾기 private func findSoundURL(soundId: String) -> URL? { - // 커스텀 녹음 파일 (URL 형식) - if soundId.starts(with: "file://") || soundId.starts(with: "/") { - return URL(fileURLWithPath: soundId.replacingOccurrences(of: "file://", with: "")) + let cacheDirectory = FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask)[0] + let soundsDir = cacheDirectory.appendingPathComponent("sounds") + + // 1. Firebase Storage URL (커스텀 사운드) + if soundId.hasPrefix("https://") || soundId.hasPrefix("http://") { + let fileName = soundId.firebaseStorageFileName + let cachedURL = soundsDir.appendingPathComponent(fileName) + + guard FileManager.default.fileExists(atPath: cachedURL.path) else { + return nil + } + return cachedURL } - // Firebase 캐시 - let cacheDirectory = FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask)[0] - let cachedURL = cacheDirectory.appendingPathComponent("sounds/\(soundId).mp3") + // 2. 일반 사운드 ID (Firebase 캐시) + let cachedURL = soundsDir.appendingPathComponent("\(soundId).mp3") guard FileManager.default.fileExists(atPath: cachedURL.path) else { return nil } - return cachedURL } }