Skip to content
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
22 changes: 22 additions & 0 deletions Keychy/Keychy/Core/Extensions/String+Extension.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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[..<queryIndex])
}

// URL 디코딩
let decodedPath = encodedPath.removingPercentEncoding ?? encodedPath

// 마지막 경로 컴포넌트 (파일명) 추출
let components = decodedPath.components(separatedBy: "/")
return components.last ?? "custom_sound.m4a"
}
}
12 changes: 2 additions & 10 deletions Keychy/Keychy/Core/Firebase/EffectSyncManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand All @@ -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)")
}
Expand Down
52 changes: 33 additions & 19 deletions Keychy/Keychy/Core/Video/Keyring/KeyringVideoGenerator+Sound.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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 사용
Expand Down Expand Up @@ -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)
Expand All @@ -141,7 +147,35 @@ extension KeyringInfoInputView {
}
}
}


// MARK: - 로컬 사운드 파일을 캐시에 복사
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

결국 이 사운드 파일도 캐시에 있어야 했네요... 그래도 명확한 해결방안이네요!
이전에 선물 주고 받는 것도 녹음 파일이 문제였는데 이래저래 손이 많이 가는 녀석...

/// 새로 생성된 키링의 커스텀 사운드를 영상 생성 시 사용할 수 있도록 캐시에 복사
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,
Expand Down