diff --git a/Keychy/Keychy.xcodeproj/project.pbxproj b/Keychy/Keychy.xcodeproj/project.pbxproj index 8c3e8cf2..68840258 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 = ""; }; @@ -1798,6 +1800,7 @@ 38173D0D2EB902FD00E36F7E /* Popup */, 4CBBEF202EB3705E00252590 /* Items */, 38C147B92EB13B2100A8E511 /* Button */, + 4C7A9ECA2F2DA419008B520C /* ShareSheet.swift */, 4CEC62A62EAE0C130099ECEE /* LottieView.swift */, AA9B2E902EB081750004D31C /* ItemDetailView.swift */, 4C8426632ED375840050B6FE /* ColorPalette.swift */, @@ -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/Components/KeyringCellScene+Setup.swift b/Keychy/Keychy/Core/Components/KeyringCellScene+Setup.swift index c55e3e4d..4ae135f7 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 9682c377..c04dbd2e 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/Core/Components/View/ShareSheet.swift b/Keychy/Keychy/Core/Components/View/ShareSheet.swift new file mode 100644 index 00000000..869139d7 --- /dev/null +++ b/Keychy/Keychy/Core/Components/View/ShareSheet.swift @@ -0,0 +1,33 @@ +// +// ShareSheet.swift +// Keychy +// +// Created by 길지훈 on 1/31/26. +// + +import SwiftUI +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( + activityItems: items, + 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 + } + + func updateUIViewController(_ uiViewController: UIActivityViewController, context: Context) {} +} diff --git a/Keychy/Keychy/Core/Video/Keyring/KeyringVideoGenerator+Keyring.swift b/Keychy/Keychy/Core/Video/Keyring/KeyringVideoGenerator+Keyring.swift index 50c24ea4..7d2a8e22 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 { diff --git a/Keychy/Keychy/Core/Video/Keyring/KeyringVideoGenerator+Setup.swift b/Keychy/Keychy/Core/Video/Keyring/KeyringVideoGenerator+Setup.swift index 94ba1273..11d031cf 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 ba093a3d..1f1a68aa 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 { diff --git a/Keychy/Keychy/Presentation/Bundle/Views/BundleDetailView+Alert.swift b/Keychy/Keychy/Presentation/Bundle/Views/BundleDetailView+Alert.swift index 272c0bd4..472b3f22 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 6090f81d..d1bf3169 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) } } } diff --git a/Keychy/Keychy/Presentation/Collection/Views/Detail/CollectionKeyringDetailView+SaveImage.swift b/Keychy/Keychy/Presentation/Collection/Views/Detail/CollectionKeyringDetailView+SaveImage.swift index 559bd923..a3e1bd2e 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) } } } diff --git a/Keychy/Keychy/Presentation/Collection/Views/Detail/CollectionKeyringDetailView+Sheet.swift b/Keychy/Keychy/Presentation/Collection/Views/Detail/CollectionKeyringDetailView+Sheet.swift index 047c6673..92d04c55 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+VideoGen.swift b/Keychy/Keychy/Presentation/Collection/Views/Detail/CollectionKeyringDetailView+VideoGen.swift index fb4ce19a..afe39227 100644 --- a/Keychy/Keychy/Presentation/Collection/Views/Detail/CollectionKeyringDetailView+VideoGen.swift +++ b/Keychy/Keychy/Presentation/Collection/Views/Detail/CollectionKeyringDetailView+VideoGen.swift @@ -42,4 +42,40 @@ 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 + } + // 블러 애니메이션 완료 후 공유 시트 표시 + try? await Task.sleep(for: .seconds(0.3)) + await MainActor.run { + 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 6c784463..cded4c62 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 = "" @@ -54,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() @@ -81,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 } @@ -128,20 +114,39 @@ 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) .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() } .onDisappear { handleViewDisappear() + cleanupCachedVideo() } .onPreferenceChange(MenuButtonPreferenceKey.self) { frame in menuPosition = frame @@ -184,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) } } @@ -278,7 +313,7 @@ extension CollectionKeyringDetailView { Spacer() - downloadImageButton + shareButton } .padding(EdgeInsets(top: 4, leading: 16, bottom: 36, trailing: 16)) .adaptiveBottomPadding() @@ -286,33 +321,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)) { diff --git a/Keychy/Keychy/Presentation/KeyringMaker/Shared/Views/KeyringCompleteView+SaveImage.swift b/Keychy/Keychy/Presentation/KeyringMaker/Shared/Views/KeyringCompleteView+SaveImage.swift index f2341b87..975709c5 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 + } } } } diff --git a/Keychy/Keychy/Presentation/KeyringMaker/Shared/Views/KeyringCompleteView+VideoGen.swift b/Keychy/Keychy/Presentation/KeyringMaker/Shared/Views/KeyringCompleteView+VideoGen.swift index 10a04721..2f53d5aa 100644 --- a/Keychy/Keychy/Presentation/KeyringMaker/Shared/Views/KeyringCompleteView+VideoGen.swift +++ b/Keychy/Keychy/Presentation/KeyringMaker/Shared/Views/KeyringCompleteView+VideoGen.swift @@ -44,5 +44,37 @@ extension KeyringCompleteView { PHAssetChangeRequest.creationRequestForAssetFromVideo(atFileURL: url) } } + + // MARK: - Share + + /// 공유용 영상 생성 (캐싱) + func generateVideoForShare() async { + isGeneratingVideo = true + + do { + let videoURL = try await videoGenerator.generateVideo(viewModel: viewModel) + 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 + } + } + } + + /// 캐시된 영상 파일 삭제 + 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 0d9bac16..5b3f4677 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 @@ -63,6 +65,11 @@ struct KeyringCompleteView: View { checkReviewTriggers() } .withToast(position: .default) + .sheet(isPresented: $showShareSheet) { + if let url = cachedVideoURL { + ShareSheet(items: [url]) + } + } } } @@ -207,8 +214,9 @@ extension KeyringCompleteView { var closeToolbarItem: some ToolbarContent { ToolbarItem(placement: .topBarLeading) { Button { + cleanupCachedVideo() viewModel.resetAll() - + // Festival에서 온 경우 콜백 실행 if let onCloseFromFestival = onCloseFromFestival { onCloseFromFestival(router) @@ -250,6 +258,8 @@ extension KeyringCompleteView { /// 콜렉션으로 이동 (부드러운 전환) private func navigateToCollection() { + cleanupCachedVideo() + // 1. 탭 전환 먼저 (현재 뷰가 보이는 상태에서) TabBarManager.switchTo(.collection) TabBarManager.show() @@ -330,7 +340,16 @@ extension KeyringCompleteView { // 공유 actionButton(image: .share, title: "공유") { - // TODO: 공유 기능 + // 캐시된 영상이 있으면 바로 시트 + if cachedVideoURL != nil { + showShareSheet = true + return + } + + // 영상 생성 후 시트 + Task { + await generateVideoForShare() + } } // 선물하기