From b563e8db49eb005bd584d02377fbc35b3828cbf2 Mon Sep 17 00:00:00 2001 From: giljihun Date: Sat, 31 Jan 2026 11:07:46 +0900 Subject: [PATCH 01/16] =?UTF-8?q?feat:=20=ED=82=A4=EB=A7=81=20=EC=9D=B4?= =?UTF-8?q?=EB=AF=B8=EC=A7=80=20=EC=A0=80=EC=9E=A5=20=EC=8B=9C=20=ED=88=AC?= =?UTF-8?q?=EB=AA=85=20=EB=B0=B0=EA=B2=BD=20PNG=20=EC=BA=A1=EC=B2=98?= =?UTF-8?q?=EB=A1=9C=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - KeyringCellScene init에 bodyUIImage 파라미터 추가 - KeyringCellScene+Setup에서 UIImage 우선 사용하도록 수정 - KeyringCompleteView+SaveImage에서 화면 캡처 → Scene 캡처로 변경 - customBackgroundColor: .clear로 투명 배경 적용 --- .../Components/KeyringCellScene+Setup.swift | 18 ++-- .../Core/Components/KeyringCellScene.swift | 5 +- .../Views/KeyringCompleteView+SaveImage.swift | 94 ++++++++++++++----- 3 files changed, 84 insertions(+), 33 deletions(-) diff --git a/Keychy/Keychy/Core/Components/KeyringCellScene+Setup.swift b/Keychy/Keychy/Core/Components/KeyringCellScene+Setup.swift index c55e3e4da..4ae135f7d 100644 --- a/Keychy/Keychy/Core/Components/KeyringCellScene+Setup.swift +++ b/Keychy/Keychy/Core/Components/KeyringCellScene+Setup.swift @@ -69,18 +69,18 @@ extension KeyringCellScene { return images } - // Body 이미지 다운 + // Body 이미지 처리 var processedBodyImage: UIImage? - if let bodyImageURL = self.bodyImage { - // 1. 이미지 다운로드 + if let directImage = self.bodyUIImage { + // UIImage가 직접 전달된 경우 (URL 다운로드 스킵) + processedBodyImage = await Task.detached(priority: .userInitiated) { + await directImage.fixedOrientation() + }.value + } else if let bodyImageURL = self.bodyImage { + // URL로 다운로드 let downloadedImage = try await StorageManager.shared.getImage(path: bodyImageURL) - - // 2. 이미지 처리 (메인 스레드가 아닌 백그라운드에서 실행) processedBodyImage = await Task.detached(priority: .userInitiated) { - // orientation 정규화 - let fixedImage = await downloadedImage.fixedOrientation() - - return fixedImage + await downloadedImage.fixedOrientation() }.value } diff --git a/Keychy/Keychy/Core/Components/KeyringCellScene.swift b/Keychy/Keychy/Core/Components/KeyringCellScene.swift index 9682c3773..c04dbd2e1 100644 --- a/Keychy/Keychy/Core/Components/KeyringCellScene.swift +++ b/Keychy/Keychy/Core/Components/KeyringCellScene.swift @@ -12,6 +12,7 @@ class KeyringCellScene: SKScene { // MARK: - Properties var bodyImage: String? + var bodyUIImage: UIImage? // UIImage 직접 전달용 (URL 다운로드 스킵) var templateId: String? // 템플릿 ID (옵션) var onLoadingComplete: (() -> Void)? var hookOffsetY: CGFloat? // 바디 연결 지점 Y 오프셋 (nil이면 0.0 사용) @@ -37,6 +38,7 @@ class KeyringCellScene: SKScene { ringType: RingType, chainType: ChainType, bodyImage: String? = nil, + bodyUIImage: UIImage? = nil, templateId: String? = nil, targetSize: CGSize, customBackgroundColor: UIColor = .gray50, @@ -48,12 +50,13 @@ class KeyringCellScene: SKScene { self.currentRingType = ringType self.currentChainType = chainType self.bodyImage = bodyImage + self.bodyUIImage = bodyUIImage self.templateId = templateId self.hookOffsetY = hookOffsetY self.chainLength = chainLength self.onLoadingComplete = onLoadingComplete self.customBackgroundColor = customBackgroundColor - + let scaleX = targetSize.width / originalSize.width let scaleY = targetSize.height / originalSize.height self.scaleFactor = min(scaleX, scaleY) * zoomScale diff --git a/Keychy/Keychy/Presentation/KeyringMaker/Shared/Views/KeyringCompleteView+SaveImage.swift b/Keychy/Keychy/Presentation/KeyringMaker/Shared/Views/KeyringCompleteView+SaveImage.swift index f2341b870..975709c5a 100644 --- a/Keychy/Keychy/Presentation/KeyringMaker/Shared/Views/KeyringCompleteView+SaveImage.swift +++ b/Keychy/Keychy/Presentation/KeyringMaker/Shared/Views/KeyringCompleteView+SaveImage.swift @@ -7,21 +7,68 @@ import SwiftUI import Photos +import SpriteKit -// MARK: - Image Capture +// MARK: - Transparent Keyring Capture extension KeyringCompleteView { - /// 현재 화면을 직접 캡처 (window hierarchy 사용) - @MainActor - func captureVisibleScreen() -> UIImage? { - guard let windowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene, - let window = windowScene.windows.first else { + /// 키링을 투명 배경 PNG로 캡처 + func captureKeyringToPNG() async -> UIImage? { + guard let bodyImage = viewModel.bodyImage else { + print("[KeyringCapture] bodyImage 없음") return nil } - let renderer = UIGraphicsImageRenderer(bounds: window.bounds) - return renderer.image { context in - window.drawHierarchy(in: window.bounds, afterScreenUpdates: true) + // 캡처용 Scene 생성 (투명 배경) + let scene = KeyringCellScene( + ringType: .basic, + chainType: .basic, + bodyUIImage: bodyImage, + templateId: viewModel.templateId, + targetSize: CGSize(width: 350, height: 466), + customBackgroundColor: .clear, + zoomScale: 2.0, + hookOffsetY: viewModel.hookOffsetY != 0 ? viewModel.hookOffsetY : nil, + chainLength: viewModel.chainLength + ) + scene.scaleMode = .aspectFill + + // 로딩 완료 대기용 플래그 + var loadingCompleted = false + scene.onLoadingComplete = { + loadingCompleted = true + } + + // SKView 생성 및 Scene 표시 + let view = SKView(frame: CGRect(origin: .zero, size: scene.size)) + view.allowsTransparency = true + view.backgroundColor = .clear + view.presentScene(scene) + + // 로딩 완료 대기 (최대 3초) + var waitTime = 0.0 + let checkInterval = 0.1 + let maxWaitTime = 3.0 + + while !loadingCompleted && waitTime < maxWaitTime { + try? await Task.sleep(nanoseconds: UInt64(checkInterval * 1_000_000_000)) + waitTime += checkInterval + } + + if !loadingCompleted { + print("[KeyringCapture] 타임아웃 - 로딩 미완료") + } else { + // 로딩 완료 후 추가 렌더링 대기 + try? await Task.sleep(nanoseconds: 200_000_000) + } + + // PNG 캡처 + guard let pngData = await scene.captureToPNG(), + let image = UIImage(data: pngData) else { + print("[KeyringCapture] PNG 캡처 실패") + return nil } + + return image } } @@ -75,28 +122,29 @@ extension KeyringCompleteView { /// 이미지 캡처 및 저장 (메인 함수) func captureAndSaveImage() { - // 1. 저장 버튼과 toolbar 임시 숨기기 (애니메이션 없이) + // 캡처 중 표시 withAnimation(.none) { isCapturingImage = true } - // 2. UI 업데이트 완전히 대기 후 캡처 (0.5초로 증가) - DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { - guard let image = self.captureVisibleScreen() else { - print("Failed to capture image") - // UI 복원 (애니메이션 없이) - withAnimation(.none) { - self.isCapturingImage = false + Task { + // 투명 배경 PNG 캡처 + guard let image = await captureKeyringToPNG() else { + print("[KeyringCapture] 캡처 실패") + await MainActor.run { + withAnimation(.none) { + isCapturingImage = false + } } return } - // 3. 이미지 저장 - self.saveImageToLibrary(image) - - // 4. UI 복원 (애니메이션 없이) - withAnimation(.none) { - self.isCapturingImage = false + // 이미지 저장 + await MainActor.run { + saveImageToLibrary(image) + withAnimation(.none) { + isCapturingImage = false + } } } } From 142239e83712e8cbb3ca181a7d05cd1e7708c65a Mon Sep 17 00:00:00 2001 From: giljihun Date: Sat, 31 Jan 2026 12:06:55 +0900 Subject: [PATCH 02/16] =?UTF-8?q?feat:=20=ED=82=A4=EB=A7=81=20=EC=98=81?= =?UTF-8?q?=EC=83=81=20=EA=B3=B5=EC=9C=A0=EB=A5=BC=20=EC=9C=84=ED=95=9C=20?= =?UTF-8?q?=EC=83=81=ED=83=9C=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - cachedVideoURL : 생성된 영상 캐싱 (시트 다시 열 수 있으니까) - showShareSheet : 공유 시트 표시 여부 --- Keychy/Keychy/Core/Video/ShareSheet.swift | 25 +++++++++++++++++++ .../Shared/Views/KeyringCompleteView.swift | 4 ++- 2 files changed, 28 insertions(+), 1 deletion(-) create mode 100644 Keychy/Keychy/Core/Video/ShareSheet.swift diff --git a/Keychy/Keychy/Core/Video/ShareSheet.swift b/Keychy/Keychy/Core/Video/ShareSheet.swift new file mode 100644 index 000000000..7a4645aef --- /dev/null +++ b/Keychy/Keychy/Core/Video/ShareSheet.swift @@ -0,0 +1,25 @@ +// +// ShareSheet.swift +// Keychy +// +// Created by 길지훈 on 1/31/26. +// + +import SwiftUI +import UIKit + +struct ShareSheet: UIViewControllerRepresentable { + let items: [Any] + var excludedActivityTypes: [UIActivity.ActivityType]? = nil + + func makeUIViewController(context: Context) -> UIActivityViewController { + let controller = UIActivityViewController( + activityItems: items, + applicationActivities: nil + ) + controller.excludedActivityTypes = excludedActivityTypes + return controller + } + + func updateUIViewController(_ uiViewController: UIActivityViewController, context: Context) {} +} diff --git a/Keychy/Keychy/Presentation/KeyringMaker/Shared/Views/KeyringCompleteView.swift b/Keychy/Keychy/Presentation/KeyringMaker/Shared/Views/KeyringCompleteView.swift index 0d9bac16c..26442ced2 100644 --- a/Keychy/Keychy/Presentation/KeyringMaker/Shared/Views/KeyringCompleteView.swift +++ b/Keychy/Keychy/Presentation/KeyringMaker/Shared/Views/KeyringCompleteView.swift @@ -25,9 +25,11 @@ struct KeyringCompleteView: View { @State var showImageSaved = false @State var isCapturingImage = false - // 영상 생성 + // 영상 생성 및 공유 @State var isGeneratingVideo = false @State var showVideoSaved = false + @State var cachedVideoURL: URL? + @State var showShareSheet = false // 씬 인터랙션 @State var isInteractionEnabled = false From 76f5de115db60aa0006b0a1ae14e59e004853527 Mon Sep 17 00:00:00 2001 From: giljihun Date: Sat, 31 Jan 2026 12:07:35 +0900 Subject: [PATCH 03/16] =?UTF-8?q?feat:=20=EA=B3=B5=EC=9C=A0=20=EB=B2=84?= =?UTF-8?q?=ED=8A=BC=20=EB=A1=9C=EC=A7=81=20=EB=B0=8F=20=EC=98=81=EC=83=81?= =?UTF-8?q?=20=EC=83=9D=EC=84=B1=20=ED=95=A8=EC=88=98=20=EC=9E=AC=EA=B5=AC?= =?UTF-8?q?=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 공유 버튼 클릭 시 캐시 확인 후 영상 생성 - generateVideoForShare(): 영상 생성 후 캐싱 - cleanupCachedVideo(): 캐시 파일 삭제 --- .../Views/KeyringCompleteView+VideoGen.swift | 24 +++++++++++++++++++ .../Shared/Views/KeyringCompleteView.swift | 11 ++++++++- 2 files changed, 34 insertions(+), 1 deletion(-) diff --git a/Keychy/Keychy/Presentation/KeyringMaker/Shared/Views/KeyringCompleteView+VideoGen.swift b/Keychy/Keychy/Presentation/KeyringMaker/Shared/Views/KeyringCompleteView+VideoGen.swift index 10a047211..15de71d63 100644 --- a/Keychy/Keychy/Presentation/KeyringMaker/Shared/Views/KeyringCompleteView+VideoGen.swift +++ b/Keychy/Keychy/Presentation/KeyringMaker/Shared/Views/KeyringCompleteView+VideoGen.swift @@ -44,5 +44,29 @@ extension KeyringCompleteView { PHAssetChangeRequest.creationRequestForAssetFromVideo(atFileURL: url) } } + + // MARK: - Share + + /// 공유용 영상 생성 (캐싱) + func generateVideoForShare() async { + isGeneratingVideo = true + + do { + let videoURL = try await videoGenerator.generateVideo(viewModel: viewModel) + cachedVideoURL = videoURL + showShareSheet = true + } catch { + print("[VideoShare] 영상 생성 실패: \(error)") + } + + isGeneratingVideo = false + } + + /// 캐시된 영상 파일 삭제 + func cleanupCachedVideo() { + guard let url = cachedVideoURL else { return } + try? FileManager.default.removeItem(at: url) + cachedVideoURL = nil + } } diff --git a/Keychy/Keychy/Presentation/KeyringMaker/Shared/Views/KeyringCompleteView.swift b/Keychy/Keychy/Presentation/KeyringMaker/Shared/Views/KeyringCompleteView.swift index 26442ced2..bba0ddd71 100644 --- a/Keychy/Keychy/Presentation/KeyringMaker/Shared/Views/KeyringCompleteView.swift +++ b/Keychy/Keychy/Presentation/KeyringMaker/Shared/Views/KeyringCompleteView.swift @@ -332,7 +332,16 @@ extension KeyringCompleteView { // 공유 actionButton(image: .share, title: "공유") { - // TODO: 공유 기능 + // 캐시된 영상이 있으면 바로 시트 + if cachedVideoURL != nil { + showShareSheet = true + return + } + + // 영상 생성 후 시트 + Task { + await generateVideoForShare() + } } // 선물하기 From 2c5141214abd1243eeb8f907f84de1aa651a822c Mon Sep 17 00:00:00 2001 From: giljihun Date: Sat, 31 Jan 2026 12:09:52 +0900 Subject: [PATCH 04/16] =?UTF-8?q?feat:=20UIActivityViewController=20?= =?UTF-8?q?=EB=9E=98=ED=8D=BC=20=EB=B0=8F=20=EA=B3=B5=EC=9C=A0=20=EC=8B=9C?= =?UTF-8?q?=ED=8A=B8=20=EC=97=B0=EA=B2=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ShareSheet.swift 생성 (파일명 바뀔 수도 있음) - KeyringCompleteView에 연결 --- Keychy/Keychy/Core/Video/ShareSheet.swift | 8 ++++++++ .../KeyringMaker/Shared/Views/KeyringCompleteView.swift | 5 +++++ 2 files changed, 13 insertions(+) diff --git a/Keychy/Keychy/Core/Video/ShareSheet.swift b/Keychy/Keychy/Core/Video/ShareSheet.swift index 7a4645aef..869139d77 100644 --- a/Keychy/Keychy/Core/Video/ShareSheet.swift +++ b/Keychy/Keychy/Core/Video/ShareSheet.swift @@ -11,6 +11,7 @@ import UIKit struct ShareSheet: UIViewControllerRepresentable { let items: [Any] var excludedActivityTypes: [UIActivity.ActivityType]? = nil + var onComplete: ((Bool) -> Void)? = nil func makeUIViewController(context: Context) -> UIActivityViewController { let controller = UIActivityViewController( @@ -18,6 +19,13 @@ struct ShareSheet: UIViewControllerRepresentable { applicationActivities: nil ) controller.excludedActivityTypes = excludedActivityTypes + controller.completionWithItemsHandler = { activityType, completed, items, error in + if let error = error { + print("[ShareSheet] 에러: \(error.localizedDescription)") + } + print("[ShareSheet] activityType: \(activityType?.rawValue ?? "nil"), completed: \(completed)") + onComplete?(completed) + } return controller } diff --git a/Keychy/Keychy/Presentation/KeyringMaker/Shared/Views/KeyringCompleteView.swift b/Keychy/Keychy/Presentation/KeyringMaker/Shared/Views/KeyringCompleteView.swift index bba0ddd71..db9434f4e 100644 --- a/Keychy/Keychy/Presentation/KeyringMaker/Shared/Views/KeyringCompleteView.swift +++ b/Keychy/Keychy/Presentation/KeyringMaker/Shared/Views/KeyringCompleteView.swift @@ -65,6 +65,11 @@ struct KeyringCompleteView: View { checkReviewTriggers() } .withToast(position: .default) + .sheet(isPresented: $showShareSheet) { + if let url = cachedVideoURL { + ShareSheet(items: [url]) + } + } } } From 8a9699ade8fc7473fbc726b20c5700b4d7730fbf Mon Sep 17 00:00:00 2001 From: giljihun Date: Sat, 31 Jan 2026 12:10:20 +0900 Subject: [PATCH 05/16] =?UTF-8?q?feat:=20=EC=9D=B4=EC=A0=A0=20=EC=99=84?= =?UTF-8?q?=EC=84=B1=EB=B7=B0=EC=97=90=EC=84=9C=20=EC=99=84=EC=A0=84?= =?UTF-8?q?=ED=9E=88=20=EB=B2=97=EC=96=B4=EB=82=98=EC=95=BC,=20=EC=BA=90?= =?UTF-8?q?=EC=8B=9C=20=EC=98=81=EC=83=81=20=ED=8C=8C=EC=9D=BC=20=EC=82=AD?= =?UTF-8?q?=EC=A0=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../KeyringMaker/Shared/Views/KeyringCompleteView.swift | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/Keychy/Keychy/Presentation/KeyringMaker/Shared/Views/KeyringCompleteView.swift b/Keychy/Keychy/Presentation/KeyringMaker/Shared/Views/KeyringCompleteView.swift index db9434f4e..5b3f46772 100644 --- a/Keychy/Keychy/Presentation/KeyringMaker/Shared/Views/KeyringCompleteView.swift +++ b/Keychy/Keychy/Presentation/KeyringMaker/Shared/Views/KeyringCompleteView.swift @@ -214,8 +214,9 @@ extension KeyringCompleteView { var closeToolbarItem: some ToolbarContent { ToolbarItem(placement: .topBarLeading) { Button { + cleanupCachedVideo() viewModel.resetAll() - + // Festival에서 온 경우 콜백 실행 if let onCloseFromFestival = onCloseFromFestival { onCloseFromFestival(router) @@ -257,6 +258,8 @@ extension KeyringCompleteView { /// 콜렉션으로 이동 (부드러운 전환) private func navigateToCollection() { + cleanupCachedVideo() + // 1. 탭 전환 먼저 (현재 뷰가 보이는 상태에서) TabBarManager.switchTo(.collection) TabBarManager.show() From 289c77f3e294e7ba9d95313b7fb8fec0a357b31d Mon Sep 17 00:00:00 2001 From: giljihun Date: Sat, 31 Jan 2026 12:10:42 +0900 Subject: [PATCH 06/16] =?UTF-8?q?feat:=20=EA=B3=B5=EC=9C=A0=20=EC=98=81?= =?UTF-8?q?=EC=83=81=EB=AA=85=EC=9D=84=20=ED=82=A4=EB=A7=81=20=EC=9D=B4?= =?UTF-8?q?=EB=A6=84=EC=9C=BC=EB=A1=9C=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Keychy/Keychy.xcodeproj/project.pbxproj | 4 ++++ .../Core/Video/Keyring/KeyringVideoGenerator+Setup.swift | 6 +++++- .../Keychy/Core/Video/Keyring/KeyringVideoGenerator.swift | 2 ++ 3 files changed, 11 insertions(+), 1 deletion(-) diff --git a/Keychy/Keychy.xcodeproj/project.pbxproj b/Keychy/Keychy.xcodeproj/project.pbxproj index 8c3e8cf2f..6112c76c0 100644 --- a/Keychy/Keychy.xcodeproj/project.pbxproj +++ b/Keychy/Keychy.xcodeproj/project.pbxproj @@ -243,6 +243,7 @@ 4C7775412EB1343600981C3E /* IntroViewModel+NicknameSetup.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4C77753B2EB1343600981C3E /* IntroViewModel+NicknameSetup.swift */; }; 4C7A9EC72F2B0567008B520C /* KeyringPackageManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4C7A9EC62F2B0567008B520C /* KeyringPackageManager.swift */; }; 4C7A9EC92F2B0586008B520C /* KeyringPackageCompleteView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4C7A9EC82F2B0586008B520C /* KeyringPackageCompleteView.swift */; }; + 4C7A9ECB2F2DA419008B520C /* ShareSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4C7A9ECA2F2DA419008B520C /* ShareSheet.swift */; }; 4C8426602ED3585A0050B6FE /* gulimche-Regular.ttf in Resources */ = {isa = PBXBuildFile; fileRef = 4C84265F2ED3585A0050B6FE /* gulimche-Regular.ttf */; }; 4C8426642ED375840050B6FE /* ColorPalette.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4C8426632ED375840050B6FE /* ColorPalette.swift */; }; 4C84A1602EB134BD008FFE57 /* ProfileSetupView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38A596832EAFEAA20003D712 /* ProfileSetupView.swift */; }; @@ -691,6 +692,7 @@ 4C77753C2EB1343600981C3E /* IntroViewModel+Signup.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "IntroViewModel+Signup.swift"; sourceTree = ""; }; 4C7A9EC62F2B0567008B520C /* KeyringPackageManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KeyringPackageManager.swift; sourceTree = ""; }; 4C7A9EC82F2B0586008B520C /* KeyringPackageCompleteView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KeyringPackageCompleteView.swift; sourceTree = ""; }; + 4C7A9ECA2F2DA419008B520C /* ShareSheet.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShareSheet.swift; sourceTree = ""; }; 4C84265F2ED3585A0050B6FE /* gulimche-Regular.ttf */ = {isa = PBXFileReference; lastKnownFileType = text; path = "gulimche-Regular.ttf"; sourceTree = ""; }; 4C8426632ED375840050B6FE /* ColorPalette.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ColorPalette.swift; sourceTree = ""; }; 4C86A6112F25C0B10023AA2D /* WorkshopBundleGridView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WorkshopBundleGridView.swift; sourceTree = ""; }; @@ -1624,6 +1626,7 @@ 4CDCADC12F14CC5A00C01972 /* Video */ = { isa = PBXGroup; children = ( + 4C7A9ECA2F2DA419008B520C /* ShareSheet.swift */, 4C004F9A2F177C4600D9063E /* Bundle */, 4C004FA02F177C4600D9063E /* Keyring */, ); @@ -2460,6 +2463,7 @@ 3828F5472EC4CCDC00F1B040 /* CollectionView+NormalMode.swift in Sources */, 4CEBB1612EFAD3F800CF53E2 /* MainTabView.swift in Sources */, 386102482F1104EB0045C529 /* KeyringCollectView+Alerts.swift in Sources */, + 4C7A9ECB2F2DA419008B520C /* ShareSheet.swift in Sources */, 4CEC621D2EAE08DA0099ECEE /* HomeRoute.swift in Sources */, 38A5967A2EAFA94E0003D712 /* IntroView.swift in Sources */, 4C004FB52F18D98C00D9063E /* ReviewManager.swift in Sources */, diff --git a/Keychy/Keychy/Core/Video/Keyring/KeyringVideoGenerator+Setup.swift b/Keychy/Keychy/Core/Video/Keyring/KeyringVideoGenerator+Setup.swift index 94ba12734..11d031cfa 100644 --- a/Keychy/Keychy/Core/Video/Keyring/KeyringVideoGenerator+Setup.swift +++ b/Keychy/Keychy/Core/Video/Keyring/KeyringVideoGenerator+Setup.swift @@ -54,8 +54,12 @@ extension KeyringVideoGenerator { /// AVAssetWriter 설정 /// H.264 코덱으로 1080x1920 비디오 인코딩 func setupVideoWriter() throws { + // 파일 이름에 사용 불가 문자 제거 + let safeName = keyringName + .replacingOccurrences(of: "/", with: "_") + .replacingOccurrences(of: ":", with: "_") let outputURL = FileManager.default.temporaryDirectory - .appendingPathComponent("keyring_video_\(UUID()).mp4") + .appendingPathComponent("\(safeName)_\(UUID().uuidString.prefix(8)).mp4") let writer = try AVAssetWriter(outputURL: outputURL, fileType: .mp4) diff --git a/Keychy/Keychy/Core/Video/Keyring/KeyringVideoGenerator.swift b/Keychy/Keychy/Core/Video/Keyring/KeyringVideoGenerator.swift index ba093a3d3..1f1a68aa6 100644 --- a/Keychy/Keychy/Core/Video/Keyring/KeyringVideoGenerator.swift +++ b/Keychy/Keychy/Core/Video/Keyring/KeyringVideoGenerator.swift @@ -84,6 +84,7 @@ class KeyringVideoGenerator { var backgroundImage: UIImage? var keyringScale: CGFloat = 1.0 + var keyringName: String = "키링" // MARK: - Particle Properties var particleSpriteNode: SKSpriteNode? @@ -119,6 +120,7 @@ class KeyringVideoGenerator { ) async throws -> URL { self.backgroundImage = backgroundImage self.keyringScale = keyringScale + self.keyringName = viewModel.nameText.isEmpty ? "키링" : viewModel.nameText // Metal 디바이스 설정 guard let device = MTLCreateSystemDefaultDevice() else { From da3132b15bb8402f37b0cc9054a34711489a43f8 Mon Sep 17 00:00:00 2001 From: giljihun Date: Sat, 31 Jan 2026 12:12:34 +0900 Subject: [PATCH 07/16] =?UTF-8?q?chore:=20ShareSheet=20=EC=9C=84=EC=B9=98?= =?UTF-8?q?=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Keychy/Keychy.xcodeproj/project.pbxproj | 2 +- Keychy/Keychy/Core/{Video => Components/View}/ShareSheet.swift | 0 2 files changed, 1 insertion(+), 1 deletion(-) rename Keychy/Keychy/Core/{Video => Components/View}/ShareSheet.swift (100%) diff --git a/Keychy/Keychy.xcodeproj/project.pbxproj b/Keychy/Keychy.xcodeproj/project.pbxproj index 6112c76c0..68840258c 100644 --- a/Keychy/Keychy.xcodeproj/project.pbxproj +++ b/Keychy/Keychy.xcodeproj/project.pbxproj @@ -1626,7 +1626,6 @@ 4CDCADC12F14CC5A00C01972 /* Video */ = { isa = PBXGroup; children = ( - 4C7A9ECA2F2DA419008B520C /* ShareSheet.swift */, 4C004F9A2F177C4600D9063E /* Bundle */, 4C004FA02F177C4600D9063E /* Keyring */, ); @@ -1801,6 +1800,7 @@ 38173D0D2EB902FD00E36F7E /* Popup */, 4CBBEF202EB3705E00252590 /* Items */, 38C147B92EB13B2100A8E511 /* Button */, + 4C7A9ECA2F2DA419008B520C /* ShareSheet.swift */, 4CEC62A62EAE0C130099ECEE /* LottieView.swift */, AA9B2E902EB081750004D31C /* ItemDetailView.swift */, 4C8426632ED375840050B6FE /* ColorPalette.swift */, diff --git a/Keychy/Keychy/Core/Video/ShareSheet.swift b/Keychy/Keychy/Core/Components/View/ShareSheet.swift similarity index 100% rename from Keychy/Keychy/Core/Video/ShareSheet.swift rename to Keychy/Keychy/Core/Components/View/ShareSheet.swift From 7c05848adc4121ec6b468532a0574cc75ecb42f6 Mon Sep 17 00:00:00 2001 From: giljihun Date: Sat, 31 Jan 2026 13:16:24 +0900 Subject: [PATCH 08/16] =?UTF-8?q?feat:=20CollectionKeyringDetailView=20-?= =?UTF-8?q?=20=EA=B3=B5=EC=9C=A0=20=EA=B8=B0=EB=8A=A5=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...CollectionKeyringDetailView+VideoGen.swift | 32 +++++++++++++++++++ .../Detail/CollectionKeyringDetailView.swift | 13 ++++++-- 2 files changed, 43 insertions(+), 2 deletions(-) diff --git a/Keychy/Keychy/Presentation/Collection/Views/Detail/CollectionKeyringDetailView+VideoGen.swift b/Keychy/Keychy/Presentation/Collection/Views/Detail/CollectionKeyringDetailView+VideoGen.swift index fb4ce19a2..f43662793 100644 --- a/Keychy/Keychy/Presentation/Collection/Views/Detail/CollectionKeyringDetailView+VideoGen.swift +++ b/Keychy/Keychy/Presentation/Collection/Views/Detail/CollectionKeyringDetailView+VideoGen.swift @@ -42,4 +42,36 @@ extension CollectionKeyringDetailView { } } } + + // MARK: - Share + + /// 공유용 영상 생성 (캐싱) + func generateVideoForShare() async { + guard !isGeneratingVideo else { return } + + await MainActor.run { + isGeneratingVideo = true + } + + do { + let videoURL = try await videoGenerator.generateVideo(keyring: keyring) + await MainActor.run { + cachedVideoURL = videoURL + isGeneratingVideo = false + showShareSheet = true + } + } catch { + print("[CollectionKeyringDetailView] 영상 생성 실패: \(error)") + await MainActor.run { + isGeneratingVideo = false + } + } + } + + /// 캐시된 영상 파일 삭제 + func cleanupCachedVideo() { + guard let url = cachedVideoURL else { return } + try? FileManager.default.removeItem(at: url) + cachedVideoURL = nil + } } diff --git a/Keychy/Keychy/Presentation/Collection/Views/Detail/CollectionKeyringDetailView.swift b/Keychy/Keychy/Presentation/Collection/Views/Detail/CollectionKeyringDetailView.swift index 6c7844638..5bbe2b798 100644 --- a/Keychy/Keychy/Presentation/Collection/Views/Detail/CollectionKeyringDetailView.swift +++ b/Keychy/Keychy/Presentation/Collection/Views/Detail/CollectionKeyringDetailView.swift @@ -40,10 +40,13 @@ struct CollectionKeyringDetailView: View { @State var checkmarkOpacity: Double = 0.0 @State var showUIForCapture: Bool = true // 캡처 시 UI 표시 여부 - // 영상 생성 관련 + // 영상 생성 및 공유 @State var isGeneratingVideo: Bool = false @State var showVideoSaved: Bool = false @State var videoGenerator = KeyringVideoGenerator() + @State var cachedVideoURL: URL? + @State var showShareSheet: Bool = false + @State var pendingShareAction: Bool = false // 시트 닫힌 후 공유 실행 대기 // 포장 관련 @State var postOfficeId: String = "" @@ -135,7 +138,13 @@ struct CollectionKeyringDetailView: View { .presentationBackgroundInteraction(.enabled(upThrough: .fraction(0.48))) .interactiveDismissDisabled(false) } - + .sheet(isPresented: $showShareSheet) { + if let url = cachedVideoURL { + ShareSheet(items: [url]) + .presentationDetents([.fraction(0.65)]) + .presentationDragIndicator(.visible) + } + } .onAppear { handleViewAppear() refreshCopyVoucher() From 9db13872bc6cb73736ed49a1e7f83975a355d0ef Mon Sep 17 00:00:00 2001 From: giljihun Date: Sat, 31 Jan 2026 13:16:54 +0900 Subject: [PATCH 09/16] =?UTF-8?q?feat:=20=ED=95=98=EB=8B=A8=20=EC=98=81?= =?UTF-8?q?=EC=97=AD=EC=97=90=20=EA=B3=B5=EC=9C=A0=20=EB=B2=84=ED=8A=BC=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 --- .../Detail/CollectionKeyringDetailView.swift | 32 +++++++------------ 1 file changed, 11 insertions(+), 21 deletions(-) diff --git a/Keychy/Keychy/Presentation/Collection/Views/Detail/CollectionKeyringDetailView.swift b/Keychy/Keychy/Presentation/Collection/Views/Detail/CollectionKeyringDetailView.swift index 5bbe2b798..097a78476 100644 --- a/Keychy/Keychy/Presentation/Collection/Views/Detail/CollectionKeyringDetailView.swift +++ b/Keychy/Keychy/Presentation/Collection/Views/Detail/CollectionKeyringDetailView.swift @@ -57,8 +57,6 @@ struct CollectionKeyringDetailView: View { var body: some View { GeometryReader { geometry in - let heightRatio = geometry.size.height / 852 - ZStack(alignment: .top) { Image(.whiteBackground) .resizable() @@ -151,6 +149,7 @@ struct CollectionKeyringDetailView: View { } .onDisappear { handleViewDisappear() + cleanupCachedVideo() } .onPreferenceChange(MenuButtonPreferenceKey.self) { frame in menuPosition = frame @@ -287,7 +286,7 @@ extension CollectionKeyringDetailView { Spacer() - downloadImageButton + shareButton } .padding(EdgeInsets(top: 4, leading: 16, bottom: 36, trailing: 16)) .adaptiveBottomPadding() @@ -295,33 +294,24 @@ extension CollectionKeyringDetailView { .animation(.spring(response: 0.4, dampingFraction: 0.8), value: isSheetPresented) } - private var downloadVideoButton: some View { + private var shareButton: some View { Button(action: { + if cachedVideoURL != nil { + showShareSheet = true + return + } Task { - await generateAndSaveVideo() + await generateVideoForShare() } }) { - Image(systemName: "video.fill") - .foregroundStyle(.black) + Image(.share) } - .disabled(isGeneratingVideo || showUIForCapture == false) + .disabled(isGeneratingVideo) .frame(width: 48, height: 48) .glassEffect(.regular.interactive(), in: .circle) - .opacity((isGeneratingVideo || showUIForCapture == false) ? 0.5 : 1) + .opacity(isGeneratingVideo ? 0.5 : 1) } - private var downloadImageButton: some View { - Button(action: { - captureAndSaveImage() - }) { - Image(.imageDownload) - } - .disabled(isGeneratingVideo || showUIForCapture == false) - .frame(width: 48, height: 48) - .glassEffect(.regular.interactive(), in: .circle) - .opacity((isGeneratingVideo || showUIForCapture == false) ? 0.5 : 1) - } - private var packageButton: some View { Button(action: { withAnimation(.spring(response: 0.3, dampingFraction: 0.8)) { From d3939afb2212ddcc098cd540e134aeccd36d08bd Mon Sep 17 00:00:00 2001 From: giljihun Date: Sat, 31 Jan 2026 13:17:27 +0900 Subject: [PATCH 10/16] =?UTF-8?q?feat:=20=EC=A0=95=EB=B3=B4=20=EC=8B=9C?= =?UTF-8?q?=ED=8A=B8=EC=97=90=20=EA=B3=B5=EC=9C=A0=20=EB=B2=84=ED=8A=BC=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 - 공유버튼 클릭 시, 정보시트가 확실히 내려가고 공유시트가 올라오게 해야함. - 안그러면 시트 충돌로 스크롤버그 발생 --- .../CollectionKeyringDetailView+Sheet.swift | 7 +++-- .../Detail/CollectionKeyringDetailView.swift | 29 +++++++++---------- 2 files changed, 18 insertions(+), 18 deletions(-) diff --git a/Keychy/Keychy/Presentation/Collection/Views/Detail/CollectionKeyringDetailView+Sheet.swift b/Keychy/Keychy/Presentation/Collection/Views/Detail/CollectionKeyringDetailView+Sheet.swift index 047c66737..92d04c553 100644 --- a/Keychy/Keychy/Presentation/Collection/Views/Detail/CollectionKeyringDetailView+Sheet.swift +++ b/Keychy/Keychy/Presentation/Collection/Views/Detail/CollectionKeyringDetailView+Sheet.swift @@ -87,12 +87,15 @@ extension CollectionKeyringDetailView { Spacer() Button(action: { - captureAndSaveImage() + // 공유 액션 대기 설정 후 시트 닫기 + pendingShareAction = true + isSheetPresented = false }) { - Image(.save) + Image(.share) .resizable() .frame(width: 28, height: 28) } + .disabled(isGeneratingVideo) .opacity(showUIForCapture ? 1 : 0) } .padding(.top, 14) diff --git a/Keychy/Keychy/Presentation/Collection/Views/Detail/CollectionKeyringDetailView.swift b/Keychy/Keychy/Presentation/Collection/Views/Detail/CollectionKeyringDetailView.swift index 097a78476..5a8c43466 100644 --- a/Keychy/Keychy/Presentation/Collection/Views/Detail/CollectionKeyringDetailView.swift +++ b/Keychy/Keychy/Presentation/Collection/Views/Detail/CollectionKeyringDetailView.swift @@ -82,21 +82,6 @@ struct CollectionKeyringDetailView: View { } } - // 영상 저장 버튼 - 이미지 저장 버튼 바로 위 - VStack { - Spacer() - HStack { - Spacer() - downloadVideoButton - } - .padding(.trailing, 16) - .padding(.bottom, 36 + 48 + 12) - .adaptiveBottomPadding() - } - .opacity(showUIForCapture && !isSheetPresented ? 1 : 0) - .blur(radius: shouldApplyBlur ? 15 : 0) - .animation(.spring(response: 0.4, dampingFraction: 0.8), value: isSheetPresented) - if showMenu { menuOverlay } @@ -129,7 +114,19 @@ struct CollectionKeyringDetailView: View { .navigationBarBackButtonHidden(true) .interactiveDismissDisabled(false) .withToast(position: .default) - .sheet(isPresented: $isSheetPresented) { + .sheet(isPresented: $isSheetPresented, onDismiss: { + // 시트 완전히 닫힌 후 대기 중인 공유 액션 실행 + if pendingShareAction { + pendingShareAction = false + if cachedVideoURL != nil { + showShareSheet = true + } else { + Task { + await generateVideoForShare() + } + } + } + }) { infoSheet .presentationDetents([.fraction(0.48), .fraction(0.93)], selection: $sheetDetent) .presentationDragIndicator(.visible) From 3886623ca8e8bdcd9908b14dbe35dd19cb85d49d Mon Sep 17 00:00:00 2001 From: giljihun Date: Sat, 31 Jan 2026 13:25:01 +0900 Subject: [PATCH 11/16] =?UTF-8?q?feat:=20CollectionKeyringDetailView=20?= =?UTF-8?q?=EB=84=A4=EB=B9=84=EB=B0=94=EC=97=90=20=EC=9D=B4=EB=AF=B8?= =?UTF-8?q?=EC=A7=80=20=EC=A0=80=EC=9E=A5=20=EB=B2=84=ED=8A=BC=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 기존 화면캡처 -> png 투명배경으로 저장 --- .../Detail/CollectionKeyringDetailView.swift | 90 ++++++++++++------- 1 file changed, 60 insertions(+), 30 deletions(-) diff --git a/Keychy/Keychy/Presentation/Collection/Views/Detail/CollectionKeyringDetailView.swift b/Keychy/Keychy/Presentation/Collection/Views/Detail/CollectionKeyringDetailView.swift index 5a8c43466..cded4c62e 100644 --- a/Keychy/Keychy/Presentation/Collection/Views/Detail/CollectionKeyringDetailView.swift +++ b/Keychy/Keychy/Presentation/Collection/Views/Detail/CollectionKeyringDetailView.swift @@ -189,43 +189,73 @@ struct CollectionKeyringDetailView: View { // MARK: - 툴바 extension CollectionKeyringDetailView { - var customNavigationBar: some View { - CustomNavigationBar { - // Leading (왼쪽) - 뒤로가기 버튼 - BackToolbarButton { - isSheetPresented = false + private var safeAreaTop: CGFloat { + guard let window = UIApplication.shared.connectedScenes + .compactMap({ $0 as? UIWindowScene }) + .first?.windows + .first(where: { $0.isKeyWindow }) else { + return 0 + } + return window.safeAreaInsets.top + } - router.pop() - } - .opacity(showUIForCapture ? 1 : 0) - } center: { - // Center (중앙) + var customNavigationBar: some View { + ZStack { + // 타이틀 (화면 정중앙) Text(showUIForCapture ? keyring.name : "") + .typography(.notosans17M) .foregroundStyle(.gray600) - } trailing: { - // Trailing (오른쪽) - 다음/구매 버튼 - Button(action: { - withAnimation(.spring(response: 0.3, dampingFraction: 0.8)) { - showMenu.toggle() + + // Leading & Trailing + HStack { + // 뒤로가기 버튼 + BackToolbarButton { + isSheetPresented = false + router.pop() } - }) { - Image(.menuIcon) - .resizable() - .frame(width: 34, height: 34) - .contentShape(Rectangle()) - .background( - GeometryReader { geometry in - Color.clear.preference( - key: MenuButtonPreferenceKey.self, - value: geometry.frame(in: .global) - ) + .opacity(showUIForCapture ? 1 : 0) + + Spacer() + + // 오른쪽 버튼들 + HStack(spacing: 10) { + // 이미지 저장 버튼 + Button { + captureAndSaveImage() + } label: { + Image(.imageDownload) + } + .frame(width: 44, height: 44) + .glassEffect(.regular.interactive(), in: .circle) + + // 메뉴 버튼 + Button(action: { + withAnimation(.spring(response: 0.3, dampingFraction: 0.8)) { + showMenu.toggle() } - ) + }) { + Image(.menuIcon) + .resizable() + .frame(width: 34, height: 34) + .contentShape(Rectangle()) + .background( + GeometryReader { geometry in + Color.clear.preference( + key: MenuButtonPreferenceKey.self, + value: geometry.frame(in: .global) + ) + } + ) + } + .frame(width: 44, height: 44) + .glassEffect(.regular.interactive(), in: .circle) + } + .opacity(showUIForCapture ? 1 : 0) } - .frame(width: 44, height: 44) - .glassEffect(.regular.interactive(), in: .circle) - .opacity(showUIForCapture ? 1 : 0) + .padding(.horizontal, 16) } + .frame(height: 44) + .padding(.top, safeAreaTop) } } From 88b984ed0ff302b039a0103f328787c900e06014 Mon Sep 17 00:00:00 2001 From: giljihun Date: Sat, 31 Jan 2026 13:25:26 +0900 Subject: [PATCH 12/16] =?UTF-8?q?feat:=20=EC=9D=B4=EB=AF=B8=EC=A7=80=20?= =?UTF-8?q?=EC=A0=80=EC=9E=A5=20=EC=8B=9C=20=ED=88=AC=EB=AA=85=20=EB=B0=B0?= =?UTF-8?q?=EA=B2=BD=20PNG=EB=A1=9C=20=EC=BA=A1=EC=B2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...ollectionKeyringDetailView+SaveImage.swift | 83 +++++++++++-------- 1 file changed, 50 insertions(+), 33 deletions(-) diff --git a/Keychy/Keychy/Presentation/Collection/Views/Detail/CollectionKeyringDetailView+SaveImage.swift b/Keychy/Keychy/Presentation/Collection/Views/Detail/CollectionKeyringDetailView+SaveImage.swift index 559bd9231..a3e1bd2e5 100644 --- a/Keychy/Keychy/Presentation/Collection/Views/Detail/CollectionKeyringDetailView+SaveImage.swift +++ b/Keychy/Keychy/Presentation/Collection/Views/Detail/CollectionKeyringDetailView+SaveImage.swift @@ -7,21 +7,56 @@ import SwiftUI import Photos +import SpriteKit -// MARK: - Image Capture +// MARK: - Transparent Keyring Capture extension CollectionKeyringDetailView { - /// 현재 화면을 직접 캡처 (window hierarchy 사용) - @MainActor - func captureVisibleScreen() -> UIImage? { - guard let windowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene, - let window = windowScene.windows.first else { - return nil + /// 키링을 투명 배경 PNG로 캡처 + func captureKeyringToPNG() async -> UIImage? { + // 캡처용 Scene 생성 (투명 배경) + let scene = KeyringCellScene( + ringType: RingType.fromID(keyring.selectedRing), + chainType: ChainType.fromID(keyring.selectedChain), + bodyImage: keyring.bodyImage, + templateId: keyring.selectedTemplate, + targetSize: CGSize(width: 350, height: 466), + customBackgroundColor: UIColor.clear, + zoomScale: 2.0, + hookOffsetY: keyring.hookOffsetY, + chainLength: keyring.chainLength + ) + scene.scaleMode = .aspectFill + + var loadingCompleted = false + scene.onLoadingComplete = { + loadingCompleted = true + } + + let view = SKView(frame: CGRect(origin: .zero, size: scene.size)) + view.allowsTransparency = true + view.backgroundColor = .clear + view.presentScene(scene) + + // 로딩 완료 대기 (최대 3초) + var waitTime = 0.0 + let checkInterval = 0.1 + let maxWaitTime = 3.0 + + while !loadingCompleted && waitTime < maxWaitTime { + try? await Task.sleep(nanoseconds: UInt64(checkInterval * 1_000_000_000)) + waitTime += checkInterval } - let renderer = UIGraphicsImageRenderer(bounds: window.bounds) - return renderer.image { context in - window.drawHierarchy(in: window.bounds, afterScreenUpdates: true) + if loadingCompleted { + try? await Task.sleep(nanoseconds: 200_000_000) } + + guard let pngData = await scene.captureToPNG(), + let image = UIImage(data: pngData) else { + return nil + } + + return image } } @@ -81,31 +116,13 @@ extension CollectionKeyringDetailView { /// 이미지 캡처 및 저장 (메인 함수) func captureAndSaveImage() { - // 1. 시트 내리기 + UI opacity를 0으로 (서서히 사라짐) - withAnimation(.easeOut(duration: 0.3)) { - showUIForCapture = false - isSheetPresented = false - } - - // 2. 애니메이션 완료 대기 - DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { - // 3. 캡처 (UI 없는 깨끗한 화면) - guard let image = self.captureVisibleScreen() else { - // 실패 시 UI 복원 - withAnimation(.easeIn(duration: 0.3)) { - self.showUIForCapture = true - self.isSheetPresented = false - } + Task { + guard let image = await captureKeyringToPNG() else { + print("[ImageCapture] 캡처 실패") return } - - // 4. 이미지 저장 - self.saveImageToLibrary(image) - - // 5. UI 복원 (서서히 나타남) - withAnimation(.easeIn(duration: 0.3)) { - self.showUIForCapture = true - self.isSheetPresented = false + await MainActor.run { + saveImageToLibrary(image) } } } From 6e437fd671b74953041f8aa8c75e7a22fbaa7f75 Mon Sep 17 00:00:00 2001 From: giljihun Date: Sat, 31 Jan 2026 13:33:19 +0900 Subject: [PATCH 13/16] =?UTF-8?q?refactor:=20=EC=98=81=EC=83=81=20?= =?UTF-8?q?=EC=83=9D=EC=84=B1=20=EC=8B=9C,=20=EB=A9=94=EC=9D=B8=EC=8A=A4?= =?UTF-8?q?=EB=A0=88=EB=93=9C=20=EB=B8=94=EB=A1=9C=ED=82=B9=20=EC=88=98?= =?UTF-8?q?=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - bodyImage 동기 로딩 → URLSession 비동기 다운로드로 변경 - downloadBodyImage() 함수 추가 - KeyringAdapter에 preloadedBodyImage 전달 --- .../KeyringVideoGenerator+Keyring.swift | 33 +++++++++++++------ 1 file changed, 23 insertions(+), 10 deletions(-) diff --git a/Keychy/Keychy/Core/Video/Keyring/KeyringVideoGenerator+Keyring.swift b/Keychy/Keychy/Core/Video/Keyring/KeyringVideoGenerator+Keyring.swift index 50c24ea48..7d2a8e22a 100644 --- a/Keychy/Keychy/Core/Video/Keyring/KeyringVideoGenerator+Keyring.swift +++ b/Keychy/Keychy/Core/Video/Keyring/KeyringVideoGenerator+Keyring.swift @@ -24,8 +24,11 @@ extension KeyringVideoGenerator { backgroundImage: UIImage? = UIImage(named: "completeBG2"), keyringScale: CGFloat = 3.5 ) async throws -> URL { - // Keyring 모델을 래핑하는 어댑터 생성 - let adapter = KeyringAdapter(keyring: keyring) + // 이미지를 비동기로 먼저 다운로드 (메인스레드 블로킹 방지) + let preloadedImage = await downloadBodyImage(from: keyring.bodyImage) + + // Keyring 모델을 래핑하는 어댑터 생성 (미리 로드된 이미지 전달) + let adapter = KeyringAdapter(keyring: keyring, preloadedBodyImage: preloadedImage) // 기존 메서드 호출 return try await generateVideo( @@ -34,6 +37,19 @@ extension KeyringVideoGenerator { keyringScale: keyringScale ) } + + /// 이미지 URL에서 비동기로 다운로드 + private func downloadBodyImage(from urlString: String) async -> UIImage? { + guard let url = URL(string: urlString) else { return nil } + + do { + let (data, _) = try await URLSession.shared.data(from: url) + return UIImage(data: data) + } catch { + print("[KeyringVideoGenerator] 이미지 다운로드 실패: \(error)") + return nil + } + } } // MARK: - Keyring Adapter @@ -44,9 +60,11 @@ extension KeyringVideoGenerator { private class KeyringAdapter: KeyringViewModelProtocol { let keyring: Keyring + private let preloadedBodyImage: UIImage? - init(keyring: Keyring) { + init(keyring: Keyring, preloadedBodyImage: UIImage?) { self.keyring = keyring + self.preloadedBodyImage = preloadedBodyImage } // MARK: - KeyringViewModelProtocol 구현 @@ -76,13 +94,8 @@ private class KeyringAdapter: KeyringViewModelProtocol { } var bodyImage: UIImage? { - // URL 문자열을 UIImage로 변환 (동기 처리) - guard let url = URL(string: keyring.bodyImage), - let data = try? Data(contentsOf: url), - let image = UIImage(data: data) else { - return nil - } - return image + // 미리 로드된 이미지 반환 (비동기 다운로드 완료된 상태) + preloadedBodyImage } var hookOffsetY: CGFloat { From 384ed943bd527c71d4d0940797de8c647330ade5 Mon Sep 17 00:00:00 2001 From: giljihun Date: Sat, 31 Jan 2026 13:34:20 +0900 Subject: [PATCH 14/16] =?UTF-8?q?fix:=20=EC=98=81=EC=83=81=20=EC=83=9D?= =?UTF-8?q?=EC=84=B1=20=ED=9B=84,=20=EB=B8=94=EB=9F=AC=20=EC=9C=A0?= =?UTF-8?q?=EC=A7=80=20=EB=AC=B8=EC=A0=9C=20=ED=95=B4=EA=B2=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 블러 애니메이션 종료 후 공유 시트 표시하도록 수정 --- .../Views/Detail/CollectionKeyringDetailView+VideoGen.swift | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/Keychy/Keychy/Presentation/Collection/Views/Detail/CollectionKeyringDetailView+VideoGen.swift b/Keychy/Keychy/Presentation/Collection/Views/Detail/CollectionKeyringDetailView+VideoGen.swift index f43662793..afe39227e 100644 --- a/Keychy/Keychy/Presentation/Collection/Views/Detail/CollectionKeyringDetailView+VideoGen.swift +++ b/Keychy/Keychy/Presentation/Collection/Views/Detail/CollectionKeyringDetailView+VideoGen.swift @@ -58,6 +58,10 @@ extension CollectionKeyringDetailView { await MainActor.run { cachedVideoURL = videoURL isGeneratingVideo = false + } + // 블러 애니메이션 완료 후 공유 시트 표시 + try? await Task.sleep(for: .seconds(0.3)) + await MainActor.run { showShareSheet = true } } catch { From 490e831fe5611008882039fc78939a6d9a44bf87 Mon Sep 17 00:00:00 2001 From: giljihun Date: Sat, 31 Jan 2026 13:37:57 +0900 Subject: [PATCH 15/16] =?UTF-8?q?fix:=20=EC=99=84=EB=A3=8C=EB=B7=B0=20-=20?= =?UTF-8?q?=EB=B8=94=EB=9F=AC=EC=98=A4=EB=A5=98/=EB=A9=94=EC=9D=B8?= =?UTF-8?q?=EC=8A=A4=EB=A0=88=EB=93=9C=20=EB=B8=94=EB=A1=9C=ED=82=B9?= =?UTF-8?q?=EB=B0=A9=EC=A7=80=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Views/KeyringCompleteView+VideoGen.swift | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/Keychy/Keychy/Presentation/KeyringMaker/Shared/Views/KeyringCompleteView+VideoGen.swift b/Keychy/Keychy/Presentation/KeyringMaker/Shared/Views/KeyringCompleteView+VideoGen.swift index 15de71d63..2f53d5aa2 100644 --- a/Keychy/Keychy/Presentation/KeyringMaker/Shared/Views/KeyringCompleteView+VideoGen.swift +++ b/Keychy/Keychy/Presentation/KeyringMaker/Shared/Views/KeyringCompleteView+VideoGen.swift @@ -53,13 +53,21 @@ extension KeyringCompleteView { do { let videoURL = try await videoGenerator.generateVideo(viewModel: viewModel) - cachedVideoURL = videoURL - showShareSheet = true + await MainActor.run { + cachedVideoURL = videoURL + isGeneratingVideo = false + } + // 블러 애니메이션 완료 후 공유 시트 표시 + try? await Task.sleep(for: .seconds(0.3)) + await MainActor.run { + showShareSheet = true + } } catch { print("[VideoShare] 영상 생성 실패: \(error)") + await MainActor.run { + isGeneratingVideo = false + } } - - isGeneratingVideo = false } /// 캐시된 영상 파일 삭제 From be66e52c82bcaff995d07b853661ba324d5edb90 Mon Sep 17 00:00:00 2001 From: giljihun Date: Sat, 31 Jan 2026 18:57:45 +0900 Subject: [PATCH 16/16] =?UTF-8?q?style:=20=EB=B2=88=EB=93=A4/=ED=82=A4?= =?UTF-8?q?=EB=A7=81=20=EC=98=81=EC=83=81=20=EC=83=9D=EC=84=B1=20=EB=A1=9C?= =?UTF-8?q?=EB=94=A9=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - LoadingAlert 활용 --- .../Bundle/Views/BundleDetailView+Alert.swift | 13 ++----------- .../CollectionKeyringDetailView+Alerts.swift | 19 ++----------------- 2 files changed, 4 insertions(+), 28 deletions(-) diff --git a/Keychy/Keychy/Presentation/Bundle/Views/BundleDetailView+Alert.swift b/Keychy/Keychy/Presentation/Bundle/Views/BundleDetailView+Alert.swift index 272c0bd4b..472b3f227 100644 --- a/Keychy/Keychy/Presentation/Bundle/Views/BundleDetailView+Alert.swift +++ b/Keychy/Keychy/Presentation/Bundle/Views/BundleDetailView+Alert.swift @@ -48,17 +48,8 @@ extension BundleDetailView { // 영상 생성 중 로딩 if uiState.isGeneratingVideo { - VStack(spacing: 20) { - ProgressView() - .scaleEffect(1.5) - .tint(.white) - - Text("영상 생성 중...") - .typography(.suit17SB) - .foregroundStyle(.white) - } - .position(x: screenWidth/2, y: screenHeight/2) - .zIndex(200) + LoadingAlert(type: .longWithKeychy, message: "공유할 영상을 만들고 있어요!") + .zIndex(200) } // 뭉치 삭제 알럿 diff --git a/Keychy/Keychy/Presentation/Collection/Views/Detail/CollectionKeyringDetailView+Alerts.swift b/Keychy/Keychy/Presentation/Collection/Views/Detail/CollectionKeyringDetailView+Alerts.swift index 6090f81d2..d1bf3169b 100644 --- a/Keychy/Keychy/Presentation/Collection/Views/Detail/CollectionKeyringDetailView+Alerts.swift +++ b/Keychy/Keychy/Presentation/Collection/Views/Detail/CollectionKeyringDetailView+Alerts.swift @@ -353,23 +353,8 @@ extension CollectionKeyringDetailView { .ignoresSafeArea() .zIndex(99) - VStack(spacing: 20) { - ProgressView() - .scaleEffect(1.5) - .tint(.white) - - Text("영상 생성 중...") - .typography(.suit17SB) - .foregroundColor(.white) - - Text("5~10초 소요") - .typography(.suit14M) - .foregroundColor(.white.opacity(0.7)) - } - .padding(40) - .background(.ultraThinMaterial) - .cornerRadius(20) - .zIndex(100) + LoadingAlert(type: .longWithKeychy, message: "공유할 영상을 만들고 있어요!") + .zIndex(100) } } }