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
4 changes: 4 additions & 0 deletions Keychy/Keychy.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -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 */; };
Expand Down Expand Up @@ -691,6 +692,7 @@
4C77753C2EB1343600981C3E /* IntroViewModel+Signup.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "IntroViewModel+Signup.swift"; sourceTree = "<group>"; };
4C7A9EC62F2B0567008B520C /* KeyringPackageManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KeyringPackageManager.swift; sourceTree = "<group>"; };
4C7A9EC82F2B0586008B520C /* KeyringPackageCompleteView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KeyringPackageCompleteView.swift; sourceTree = "<group>"; };
4C7A9ECA2F2DA419008B520C /* ShareSheet.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShareSheet.swift; sourceTree = "<group>"; };
4C84265F2ED3585A0050B6FE /* gulimche-Regular.ttf */ = {isa = PBXFileReference; lastKnownFileType = text; path = "gulimche-Regular.ttf"; sourceTree = "<group>"; };
4C8426632ED375840050B6FE /* ColorPalette.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ColorPalette.swift; sourceTree = "<group>"; };
4C86A6112F25C0B10023AA2D /* WorkshopBundleGridView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WorkshopBundleGridView.swift; sourceTree = "<group>"; };
Expand Down Expand Up @@ -1798,6 +1800,7 @@
38173D0D2EB902FD00E36F7E /* Popup */,
4CBBEF202EB3705E00252590 /* Items */,
38C147B92EB13B2100A8E511 /* Button */,
4C7A9ECA2F2DA419008B520C /* ShareSheet.swift */,
4CEC62A62EAE0C130099ECEE /* LottieView.swift */,
AA9B2E902EB081750004D31C /* ItemDetailView.swift */,
4C8426632ED375840050B6FE /* ColorPalette.swift */,
Expand Down Expand Up @@ -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 */,
Expand Down
18 changes: 9 additions & 9 deletions Keychy/Keychy/Core/Components/KeyringCellScene+Setup.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
}

Expand Down
5 changes: 4 additions & 1 deletion Keychy/Keychy/Core/Components/KeyringCellScene.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 사용)
Expand All @@ -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,
Expand All @@ -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
Expand Down
33 changes: 33 additions & 0 deletions Keychy/Keychy/Core/Components/View/ShareSheet.swift
Original file line number Diff line number Diff line change
@@ -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) {}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand All @@ -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
Expand All @@ -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 구현
Expand Down Expand Up @@ -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 {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down
2 changes: 2 additions & 0 deletions Keychy/Keychy/Core/Video/Keyring/KeyringVideoGenerator.swift
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,7 @@ class KeyringVideoGenerator {

var backgroundImage: UIImage?
var keyringScale: CGFloat = 1.0
var keyringName: String = "키링"

// MARK: - Particle Properties
var particleSpriteNode: SKSpriteNode?
Expand Down Expand Up @@ -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 {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}

// 뭉치 삭제 알럿
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
}

Expand Down Expand Up @@ -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)
}
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Loading