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
20 changes: 20 additions & 0 deletions Keychy/Keychy.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -359,6 +359,9 @@
AA2146B72F15E5B60048D40E /* BundleEditView+SelectSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA2146B62F15E5B60048D40E /* BundleEditView+SelectSheet.swift */; };
AA2146BB2F161D0C0048D40E /* BundleEditView+Initialization.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA2146BA2F161D0C0048D40E /* BundleEditView+Initialization.swift */; };
AA3908F82EC8BF0400D87EEC /* BundleDetailView+SaveImage.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA3908F72EC8BF0400D87EEC /* BundleDetailView+SaveImage.swift */; };
BC4CMPLT2F3B123400000001 /* BundleCompleteView.swift in Sources */ = {isa = PBXBuildFile; fileRef = BC1CMPLT2F3B123400000001 /* BundleCompleteView.swift */; };
BC5CMPLT2F3B123400000002 /* BundleCompleteView+VideoGen.swift in Sources */ = {isa = PBXBuildFile; fileRef = BC2CMPLT2F3B123400000002 /* BundleCompleteView+VideoGen.swift */; };
BC6CMPLT2F3B123400000003 /* BundleCompleteView+SaveImage.swift in Sources */ = {isa = PBXBuildFile; fileRef = BC3CMPLT2F3B123400000003 /* BundleCompleteView+SaveImage.swift */; };
AA3909462EC9F29500D87EEC /* UIApplication+Extension.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA3909452EC9F29500D87EEC /* UIApplication+Extension.swift */; };
AA39098E2ECA061700D87EEC /* GridItemSpacing.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA39098D2ECA061700D87EEC /* GridItemSpacing.swift */; };
AA390CE52ECC60A700D87EEC /* BundleRoute.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA390CE42ECC60A700D87EEC /* BundleRoute.swift */; };
Expand Down Expand Up @@ -819,6 +822,9 @@
AA2146B62F15E5B60048D40E /* BundleEditView+SelectSheet.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "BundleEditView+SelectSheet.swift"; sourceTree = "<group>"; };
AA2146BA2F161D0C0048D40E /* BundleEditView+Initialization.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "BundleEditView+Initialization.swift"; sourceTree = "<group>"; };
AA3908F72EC8BF0400D87EEC /* BundleDetailView+SaveImage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "BundleDetailView+SaveImage.swift"; sourceTree = "<group>"; };
BC1CMPLT2F3B123400000001 /* BundleCompleteView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BundleCompleteView.swift; sourceTree = "<group>"; };
BC2CMPLT2F3B123400000002 /* BundleCompleteView+VideoGen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "BundleCompleteView+VideoGen.swift"; sourceTree = "<group>"; };
BC3CMPLT2F3B123400000003 /* BundleCompleteView+SaveImage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "BundleCompleteView+SaveImage.swift"; sourceTree = "<group>"; };
AA3909452EC9F29500D87EEC /* UIApplication+Extension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UIApplication+Extension.swift"; sourceTree = "<group>"; };
AA39098D2ECA061700D87EEC /* GridItemSpacing.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GridItemSpacing.swift; sourceTree = "<group>"; };
AA390CE42ECC60A700D87EEC /* BundleRoute.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BundleRoute.swift; sourceTree = "<group>"; };
Expand Down Expand Up @@ -2177,6 +2183,7 @@
AA8C9B962F10E6A500A352D2 /* Views */ = {
isa = PBXGroup;
children = (
BC0CMPLT2F3B123400000000 /* Complete */,
D6A1E83B3F994B7DA46CDC7B /* Detail */,
A44AB20AA6F24D959D7EAC79 /* Create */,
AD292C97DAFD4C18A064840D /* Edit */,
Expand Down Expand Up @@ -2314,6 +2321,16 @@
path = Detail;
sourceTree = "<group>";
};
BC0CMPLT2F3B123400000000 /* Complete */ = {
isa = PBXGroup;
children = (
BC1CMPLT2F3B123400000001 /* BundleCompleteView.swift */,
BC2CMPLT2F3B123400000002 /* BundleCompleteView+VideoGen.swift */,
BC3CMPLT2F3B123400000003 /* BundleCompleteView+SaveImage.swift */,
);
path = Complete;
sourceTree = "<group>";
};
/* End PBXGroup section */

/* Begin PBXNativeTarget section */
Expand Down Expand Up @@ -2815,6 +2832,9 @@
STYVKQEFFZGIGOFGB2L823IG /* BundleViewModel+Views.swift in Sources */,
40WF8CXMLHGD9B5S521VX89Y /* BundleViewModel+Cache.swift in Sources */,
AA3908F82EC8BF0400D87EEC /* BundleDetailView+SaveImage.swift in Sources */,
BC4CMPLT2F3B123400000001 /* BundleCompleteView.swift in Sources */,
BC5CMPLT2F3B123400000002 /* BundleCompleteView+VideoGen.swift in Sources */,
BC6CMPLT2F3B123400000003 /* BundleCompleteView+SaveImage.swift in Sources */,
4C4733EB2F22553F005D2376 /* WorkshopView+StickyHeader.swift in Sources */,
4C4733F72F225A2C005D2376 /* WorkshopItemDetailView.swift in Sources */,
4C4733EC2F22553F005D2376 /* WorkshopView+MainContent.swift in Sources */,
Expand Down
1 change: 1 addition & 0 deletions Keychy/Keychy/Core/Navigation/Routes/BundleRoute.swift
Original file line number Diff line number Diff line change
Expand Up @@ -15,5 +15,6 @@ protocol BundleRoute: Hashable {
static var bundleNameInputView: Self { get }
static var bundleNameEditView: Self { get }
static var bundleEditView: Self { get }
static var bundleCompleteView: Self { get }
static var coinCharge: Self { get }
}
1 change: 1 addition & 0 deletions Keychy/Keychy/Core/Navigation/Routes/CollectionRoute.swift
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ enum CollectionRoute: Hashable, BundleRoute {
case bundleNameInputView
case bundleNameEditView
case bundleEditView
case bundleCompleteView

// 위젯 안내
case widgetSettingView
Expand Down
1 change: 1 addition & 0 deletions Keychy/Keychy/Core/Navigation/Routes/HomeRoute.swift
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ enum HomeRoute: Hashable, BundleRoute {
case bundleNameInputView
case bundleNameEditView
case bundleEditView
case bundleCompleteView

// Home
case coinCharge
Expand Down
1 change: 1 addition & 0 deletions Keychy/Keychy/Core/Navigation/Routes/WorkshopRoute.swift
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ enum WorkshopRoute: Hashable, BundleRoute {
case bundleNameInputView
case bundleNameEditView
case bundleEditView
case bundleCompleteView

// MARK: - 아크릴 포토 템플릿
case acrylicPhotoPreview
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,146 @@
//
// BundleCompleteView+SaveImage.swift
// Keychy
//
// Created by 길지훈 on 2/7/26.
//
// 뭉치 완성뷰 - 이미지 캡처 및 저장 기능

import SwiftUI
import Photos

// MARK: - Photo Library Save
extension BundleCompleteView {

/// 포토 라이브러리 권한 요청
func requestPhotoLibraryPermission(completion: @escaping (Bool) -> Void) {
let status = PHPhotoLibrary.authorizationStatus()

switch status {
case .authorized, .limited:
completion(true)
case .denied, .restricted:
completion(false)
case .notDetermined:
PHPhotoLibrary.requestAuthorization { newStatus in
DispatchQueue.main.async {
completion(newStatus == .authorized || newStatus == .limited)
}
}
@unknown default:
completion(false)
}
}

/// 이미지를 포토 라이브러리에 저장
@MainActor
func saveImageToLibrary(_ image: UIImage) async {
await withCheckedContinuation { continuation in
requestPhotoLibraryPermission { granted in
guard granted else {
Task { @MainActor in
isCapturing = false
}
continuation.resume()
return
}

PHPhotoLibrary.shared().performChanges({
PHAssetChangeRequest.creationRequestForAsset(from: image)
}) { success, error in
Task { @MainActor in
if success {
print("[BundleCompleteView] 이미지 저장 성공")
showImageSaved = true
} else if let error = error {
print("[BundleCompleteView] 이미지 저장 실패: \(error.localizedDescription)")
}

// 캡처 상태 해제
isCapturing = false
}
continuation.resume()
}
}
}
}

/// 이미지 캡처 및 저장 (메인 함수) - 로컬 데이터 사용
func captureAndSaveImage() {
guard let carabiner = bundleVM.selectedCarabiner,
let background = bundleVM.selectedBackground else { return }

// 캡쳐 시작
withAnimation(.none) {
isCapturing = true
}

Task {
// 캡쳐용 키링 데이터 생성 (로컬 데이터 사용)
var captureKeyringDataList: [MultiKeyringCaptureScene.KeyringData] = []
let selectedKeyrings = bundleVM.selectedKeyringsForBundle

for (index, keyring) in selectedKeyrings.sorted(by: { $0.key < $1.key }) {
guard index < carabiner.maxKeyringCount else { continue }

captureKeyringDataList.append(
MultiKeyringCaptureScene.KeyringData(
index: index,
position: CGPoint(
x: carabiner.keyringXPosition[index],
y: carabiner.keyringYPosition[index]
),
bodyImageURL: keyring.bodyImage,
hookOffsetY: keyring.hookOffsetY,
chainLength: keyring.chainLength
)
)
}

let carabinerType = CarabinerType.from(carabiner.carabinerType)
let carabinerBackURL: String?
let carabinerFrontURL: String?

if carabinerType == .hamburger {
carabinerBackURL = carabiner.carabinerImage[1]
carabinerFrontURL = carabiner.carabinerImage[2]
} else {
// plain 타입일 때
carabinerBackURL = carabiner.carabinerImage[0]
carabinerFrontURL = nil
}

// 배경 포함 캡쳐
guard let fullImageData = await MultiKeyringCaptureScene.captureBundleImage(
keyringDataList: captureKeyringDataList,
backgroundImageURL: background.backgroundImage,
carabinerBackImageURL: carabinerBackURL,
carabinerFrontImageURL: carabinerFrontURL,
carabinerType: carabinerType,
carabinerX: carabiner.carabinerX,
carabinerY: carabiner.carabinerY,
carabinerWidth: carabiner.carabinerWidth
) else {
await MainActor.run {
isCapturing = false
}
return
}

// viewModel에 캡쳐된 이미지 저장
await MainActor.run {
bundleVM.bundleCapturedImage = fullImageData
}

// PNG 데이터를 UIImage로 변환하여 포토 라이브러리에 저장
guard let image = UIImage(data: fullImageData) else {
await MainActor.run {
isCapturing = false
}
return
}

await saveImageToLibrary(image)
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
//
// BundleCompleteView+VideoGen.swift
// Keychy
//
// Created by 길지훈 on 2/7/26.
//
// 뭉치 완성뷰 - 영상 생성 기능

import SwiftUI
import Photos

// MARK: - Video Generation
extension BundleCompleteView {

/// 공유용 영상 생성 (캐싱)
@MainActor
func generateVideoForShare() async {
isGeneratingVideo = true

do {
guard let carabiner = bundleVM.selectedCarabiner,
let background = bundleVM.selectedBackground else {
isGeneratingVideo = false
return
}

// 배경 이미지 로드
let backgroundImage = await loadImage(from: background.backgroundImage)

// 영상 생성
let videoURL = try await videoGenerator.generateVideo(
keyringDataList: keyringDataList,
backgroundImage: backgroundImage,
backgroundImageURL: background.backgroundImage,
carabinerBackImageURL: carabiner.backImageURL,
carabinerFrontImageURL: carabiner.frontImageURL,
carabinerX: carabiner.carabinerX,
carabinerY: carabiner.carabinerY,
carabinerWidth: carabiner.carabinerWidth,
carabinerType: carabiner.type,
bundleScale: 2.5
)

cachedVideoURL = videoURL
isGeneratingVideo = false

// UI 업데이트 완료 대기 후 시트 표시
try? await Task.sleep(for: .seconds(0.3))
showShareSheet = true

} catch {
print("[BundleCompleteView] 영상 생성 실패: \(error)")
isGeneratingVideo = false
}
}

/// 영상 생성 및 사진 앨범에 저장
@MainActor
func generateAndSaveVideo() async {
isGeneratingVideo = true

do {
guard let carabiner = bundleVM.selectedCarabiner,
let background = bundleVM.selectedBackground else {
isGeneratingVideo = false
return
}

// 배경 이미지 로드
let backgroundImage = await loadImage(from: background.backgroundImage)

// 영상 생성
let videoURL = try await videoGenerator.generateVideo(
keyringDataList: keyringDataList,
backgroundImage: backgroundImage,
backgroundImageURL: background.backgroundImage,
carabinerBackImageURL: carabiner.backImageURL,
carabinerFrontImageURL: carabiner.frontImageURL,
carabinerX: carabiner.carabinerX,
carabinerY: carabiner.carabinerY,
carabinerWidth: carabiner.carabinerWidth,
carabinerType: carabiner.type,
bundleScale: 2.5
)

// 사진 앨범 저장
try await saveVideoToPhotoLibrary(url: videoURL)

// 임시 파일 삭제
try? FileManager.default.removeItem(at: videoURL)

// 성공 Alert 표시
isGeneratingVideo = false
showVideoSaved = true

} catch {
print("[BundleCompleteView] 영상 생성 실패: \(error)")
isGeneratingVideo = false
}
}

/// 비디오 파일을 사진 라이브러리에 저장
private func saveVideoToPhotoLibrary(url: URL) async throws {
try await PHPhotoLibrary.shared().performChanges {
PHAssetChangeRequest.creationRequestForAssetFromVideo(atFileURL: url)
}
}

/// URL에서 이미지 로드
private func loadImage(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("[BundleCompleteView] 배경 이미지 로드 실패: \(error)")
return nil
}
}

/// 캐시된 영상 파일 삭제
func cleanupCachedVideo() {
guard let url = cachedVideoURL else { return }
try? FileManager.default.removeItem(at: url)
cachedVideoURL = nil
}
}
Loading