Add export functionality with format options
- Add ExportView with JPEG, PNG, HEIC format support - Include quality slider for JPEG compression - Show image dimensions and estimated file size - Implement save to Photo Library with proper permissions - Add share sheet for AirDrop, Messages, etc. - Wire export button in PhotoEditorView to show ExportView - Add HEIC encoding extension for UIImage Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -8,7 +8,9 @@
|
|||||||
"Bash(git commit -m \"$\\(cat <<''EOF''\nAdd Ralph Wiggum agent setup and project specifications\n\n- Add project constitution with vision, principles, and autonomy settings\n- Add 15 feature specifications covering full app scope\n- Configure agent entry points \\(AGENTS.md, CLAUDE.md\\)\n- Add build prompt and speckit command for spec creation\n- Include comprehensive .gitignore for iOS development\n\nCo-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>\nEOF\n\\)\")",
|
"Bash(git commit -m \"$\\(cat <<''EOF''\nAdd Ralph Wiggum agent setup and project specifications\n\n- Add project constitution with vision, principles, and autonomy settings\n- Add 15 feature specifications covering full app scope\n- Configure agent entry points \\(AGENTS.md, CLAUDE.md\\)\n- Add build prompt and speckit command for spec creation\n- Include comprehensive .gitignore for iOS development\n\nCo-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>\nEOF\n\\)\")",
|
||||||
"Bash(./check_build.sh:*)",
|
"Bash(./check_build.sh:*)",
|
||||||
"Bash(git commit -m \"$\\(cat <<''EOF''\nImplement core project structure and foundation components\n\nProject Setup \\(Spec 01\\):\n- Configure iOS 17.0 deployment target\n- Add photo library usage descriptions\n- Create proper folder structure \\(App, Features, Services, Models, Utilities\\)\n\nData Model \\(Spec 02\\):\n- Add EditOperation enum with mask, inpaint, adjustment cases\n- Add MaskOperation, InpaintOperation structs \\(Codable\\)\n- Add Project model with operation stack and undo/redo support\n- Add MaskData with dilation support via vImage\n\nServices \\(Specs 03-05\\):\n- Add InpaintEngine with Metal + Accelerate fallback\n- Add MaskingService wrapping Vision framework\n- Add ContourService for wire/line detection and scoring\n\nUI Components \\(Specs 06-08\\):\n- Add PhotoEditorView with photo picker integration\n- Add CanvasView with pinch-to-zoom and pan gestures\n- Add ToolbarView with tool selection and inspector panel\n\nUtilities:\n- Add ImagePipeline for preview/export rendering\n- Add EdgeRefinement for smart brush edge detection\n- Add check_build.sh for CI verification\n\nCo-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>\nEOF\n\\)\")",
|
"Bash(git commit -m \"$\\(cat <<''EOF''\nImplement core project structure and foundation components\n\nProject Setup \\(Spec 01\\):\n- Configure iOS 17.0 deployment target\n- Add photo library usage descriptions\n- Create proper folder structure \\(App, Features, Services, Models, Utilities\\)\n\nData Model \\(Spec 02\\):\n- Add EditOperation enum with mask, inpaint, adjustment cases\n- Add MaskOperation, InpaintOperation structs \\(Codable\\)\n- Add Project model with operation stack and undo/redo support\n- Add MaskData with dilation support via vImage\n\nServices \\(Specs 03-05\\):\n- Add InpaintEngine with Metal + Accelerate fallback\n- Add MaskingService wrapping Vision framework\n- Add ContourService for wire/line detection and scoring\n\nUI Components \\(Specs 06-08\\):\n- Add PhotoEditorView with photo picker integration\n- Add CanvasView with pinch-to-zoom and pan gestures\n- Add ToolbarView with tool selection and inspector panel\n\nUtilities:\n- Add ImagePipeline for preview/export rendering\n- Add EdgeRefinement for smart brush edge detection\n- Add check_build.sh for CI verification\n\nCo-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>\nEOF\n\\)\")",
|
||||||
"Bash(git push)"
|
"Bash(git push)",
|
||||||
|
"Bash(git commit -m \"$\\(cat <<''EOF''\nIntegrate services with UI via EditorViewModel\n\n- Add EditorViewModel coordinating masking, contour detection, and inpainting\n- Connect PhotoEditorView to viewModel with mask confirmation bar\n- Add processing overlay and error toast UI\n- Update CanvasView with tap-to-mask functionality\n- Add coordinate conversion from view space to image space\n- Update ToolbarView to bind to viewModel state\n- Wire up undo/redo actions through viewModel\n\nPerson, object, and wire removal now flow:\ntap -> detect -> preview mask -> confirm -> inpaint\n\nCo-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>\nEOF\n\\)\")",
|
||||||
|
"Bash(git commit -m \"$\\(cat <<''EOF''\nAdd export functionality with format options\n\n- Add ExportView with JPEG, PNG, HEIC format support\n- Include quality slider for JPEG compression\n- Show image dimensions and estimated file size\n- Implement save to Photo Library with proper permissions\n- Add share sheet for AirDrop, Messages, etc.\n- Wire export button in PhotoEditorView to show ExportView\n- Add HEIC encoding extension for UIImage\n\nCo-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>\nEOF\n\\)\")"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ struct PhotoEditorView: View {
|
|||||||
@State private var viewModel = EditorViewModel()
|
@State private var viewModel = EditorViewModel()
|
||||||
@State private var selectedItem: PhotosPickerItem?
|
@State private var selectedItem: PhotosPickerItem?
|
||||||
@State private var isShowingPicker = false
|
@State private var isShowingPicker = false
|
||||||
|
@State private var isShowingExport = false
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
NavigationStack {
|
NavigationStack {
|
||||||
@@ -57,11 +58,11 @@ struct PhotoEditorView: View {
|
|||||||
if viewModel.originalImage != nil {
|
if viewModel.originalImage != nil {
|
||||||
ToolbarItem(placement: .topBarTrailing) {
|
ToolbarItem(placement: .topBarTrailing) {
|
||||||
Button {
|
Button {
|
||||||
// Export action
|
isShowingExport = true
|
||||||
} label: {
|
} label: {
|
||||||
Image(systemName: "square.and.arrow.up")
|
Image(systemName: "square.and.arrow.up")
|
||||||
}
|
}
|
||||||
.disabled(viewModel.isProcessing || viewModel.editedImage == nil)
|
.disabled(viewModel.isProcessing || viewModel.displayImage == nil)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -74,6 +75,11 @@ struct PhotoEditorView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
.photosPicker(isPresented: $isShowingPicker, selection: $selectedItem, matching: .images)
|
.photosPicker(isPresented: $isShowingPicker, selection: $selectedItem, matching: .images)
|
||||||
|
.sheet(isPresented: $isShowingExport) {
|
||||||
|
if let image = viewModel.displayImage {
|
||||||
|
ExportView(image: image)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
263
CheapRetouch/Features/Export/ExportView.swift
Normal file
263
CheapRetouch/Features/Export/ExportView.swift
Normal file
@@ -0,0 +1,263 @@
|
|||||||
|
//
|
||||||
|
// ExportView.swift
|
||||||
|
// CheapRetouch
|
||||||
|
//
|
||||||
|
// Export edited photos at full resolution.
|
||||||
|
//
|
||||||
|
|
||||||
|
import SwiftUI
|
||||||
|
import UIKit
|
||||||
|
import Photos
|
||||||
|
|
||||||
|
struct ExportView: View {
|
||||||
|
let image: CGImage
|
||||||
|
@Environment(\.dismiss) private var dismiss
|
||||||
|
|
||||||
|
@State private var isExporting = false
|
||||||
|
@State private var exportProgress: Double = 0
|
||||||
|
@State private var showingShareSheet = false
|
||||||
|
@State private var showingError = false
|
||||||
|
@State private var errorMessage = ""
|
||||||
|
@State private var showingSuccess = false
|
||||||
|
@State private var selectedFormat: ExportFormat = .jpeg
|
||||||
|
@State private var jpegQuality: Double = 0.9
|
||||||
|
|
||||||
|
enum ExportFormat: String, CaseIterable, Identifiable {
|
||||||
|
case jpeg = "JPEG"
|
||||||
|
case png = "PNG"
|
||||||
|
case heic = "HEIC"
|
||||||
|
|
||||||
|
var id: String { rawValue }
|
||||||
|
|
||||||
|
var utType: String {
|
||||||
|
switch self {
|
||||||
|
case .jpeg: return "public.jpeg"
|
||||||
|
case .png: return "public.png"
|
||||||
|
case .heic: return "public.heic"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
NavigationStack {
|
||||||
|
Form {
|
||||||
|
Section {
|
||||||
|
// Preview
|
||||||
|
Image(decorative: image, scale: 1.0)
|
||||||
|
.resizable()
|
||||||
|
.aspectRatio(contentMode: .fit)
|
||||||
|
.frame(maxHeight: 200)
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
.background(Color.black)
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: 8))
|
||||||
|
}
|
||||||
|
|
||||||
|
Section("Format") {
|
||||||
|
Picker("Format", selection: $selectedFormat) {
|
||||||
|
ForEach(ExportFormat.allCases) { format in
|
||||||
|
Text(format.rawValue).tag(format)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.pickerStyle(.segmented)
|
||||||
|
|
||||||
|
if selectedFormat == .jpeg {
|
||||||
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
|
HStack {
|
||||||
|
Text("Quality")
|
||||||
|
Spacer()
|
||||||
|
Text("\(Int(jpegQuality * 100))%")
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
Slider(value: $jpegQuality, in: 0.5...1.0, step: 0.05)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Section("Image Info") {
|
||||||
|
LabeledContent("Dimensions", value: "\(image.width) × \(image.height)")
|
||||||
|
LabeledContent("Estimated Size", value: estimatedFileSize)
|
||||||
|
}
|
||||||
|
|
||||||
|
Section {
|
||||||
|
Button {
|
||||||
|
Task {
|
||||||
|
await saveToPhotoLibrary()
|
||||||
|
}
|
||||||
|
} label: {
|
||||||
|
Label("Save to Photos", systemImage: "photo.on.rectangle")
|
||||||
|
}
|
||||||
|
.disabled(isExporting)
|
||||||
|
|
||||||
|
Button {
|
||||||
|
showingShareSheet = true
|
||||||
|
} label: {
|
||||||
|
Label("Share", systemImage: "square.and.arrow.up")
|
||||||
|
}
|
||||||
|
.disabled(isExporting)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.navigationTitle("Export")
|
||||||
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
|
.toolbar {
|
||||||
|
ToolbarItem(placement: .cancellationAction) {
|
||||||
|
Button("Cancel") {
|
||||||
|
dismiss()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.overlay {
|
||||||
|
if isExporting {
|
||||||
|
exportingOverlay
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.alert("Export Failed", isPresented: $showingError) {
|
||||||
|
Button("OK") {}
|
||||||
|
} message: {
|
||||||
|
Text(errorMessage)
|
||||||
|
}
|
||||||
|
.alert("Saved", isPresented: $showingSuccess) {
|
||||||
|
Button("OK") {
|
||||||
|
dismiss()
|
||||||
|
}
|
||||||
|
} message: {
|
||||||
|
Text("Photo saved to your library")
|
||||||
|
}
|
||||||
|
.sheet(isPresented: $showingShareSheet) {
|
||||||
|
if let data = exportedImageData {
|
||||||
|
ShareSheet(items: [data])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var estimatedFileSize: String {
|
||||||
|
let pixels = image.width * image.height
|
||||||
|
let bytesPerPixel: Double
|
||||||
|
|
||||||
|
switch selectedFormat {
|
||||||
|
case .jpeg:
|
||||||
|
bytesPerPixel = 0.5 * jpegQuality + 0.1
|
||||||
|
case .png:
|
||||||
|
bytesPerPixel = 1.5
|
||||||
|
case .heic:
|
||||||
|
bytesPerPixel = 0.3
|
||||||
|
}
|
||||||
|
|
||||||
|
let bytes = Double(pixels) * bytesPerPixel
|
||||||
|
return ByteCountFormatter.string(fromByteCount: Int64(bytes), countStyle: .file)
|
||||||
|
}
|
||||||
|
|
||||||
|
private var exportedImageData: Data? {
|
||||||
|
let uiImage = UIImage(cgImage: image)
|
||||||
|
|
||||||
|
switch selectedFormat {
|
||||||
|
case .jpeg:
|
||||||
|
return uiImage.jpegData(compressionQuality: jpegQuality)
|
||||||
|
case .png:
|
||||||
|
return uiImage.pngData()
|
||||||
|
case .heic:
|
||||||
|
return uiImage.heicData()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var exportingOverlay: some View {
|
||||||
|
VStack(spacing: 12) {
|
||||||
|
ProgressView()
|
||||||
|
.scaleEffect(1.2)
|
||||||
|
Text("Exporting...")
|
||||||
|
.font(.subheadline)
|
||||||
|
}
|
||||||
|
.padding(24)
|
||||||
|
.background(.ultraThinMaterial, in: RoundedRectangle(cornerRadius: 16))
|
||||||
|
}
|
||||||
|
|
||||||
|
private func saveToPhotoLibrary() async {
|
||||||
|
isExporting = true
|
||||||
|
|
||||||
|
do {
|
||||||
|
// Request permission
|
||||||
|
let status = await PHPhotoLibrary.requestAuthorization(for: .addOnly)
|
||||||
|
guard status == .authorized || status == .limited else {
|
||||||
|
throw ExportError.permissionDenied
|
||||||
|
}
|
||||||
|
|
||||||
|
guard let data = exportedImageData else {
|
||||||
|
throw ExportError.encodingFailed
|
||||||
|
}
|
||||||
|
|
||||||
|
try await PHPhotoLibrary.shared().performChanges {
|
||||||
|
let request = PHAssetCreationRequest.forAsset()
|
||||||
|
request.addResource(with: .photo, data: data, options: nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
showingSuccess = true
|
||||||
|
} catch {
|
||||||
|
errorMessage = error.localizedDescription
|
||||||
|
showingError = true
|
||||||
|
}
|
||||||
|
|
||||||
|
isExporting = false
|
||||||
|
}
|
||||||
|
|
||||||
|
enum ExportError: LocalizedError {
|
||||||
|
case permissionDenied
|
||||||
|
case encodingFailed
|
||||||
|
|
||||||
|
var errorDescription: String? {
|
||||||
|
switch self {
|
||||||
|
case .permissionDenied:
|
||||||
|
return "Photo library access denied. Please enable it in Settings."
|
||||||
|
case .encodingFailed:
|
||||||
|
return "Failed to encode image"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - ShareSheet
|
||||||
|
|
||||||
|
struct ShareSheet: UIViewControllerRepresentable {
|
||||||
|
let items: [Any]
|
||||||
|
|
||||||
|
func makeUIViewController(context: Context) -> UIActivityViewController {
|
||||||
|
UIActivityViewController(activityItems: items, applicationActivities: nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
func updateUIViewController(_ uiViewController: UIActivityViewController, context: Context) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - UIImage HEIC Extension
|
||||||
|
|
||||||
|
extension UIImage {
|
||||||
|
func heicData(compressionQuality: CGFloat = 0.9) -> Data? {
|
||||||
|
guard let cgImage = self.cgImage else { return nil }
|
||||||
|
|
||||||
|
let mutableData = NSMutableData()
|
||||||
|
guard let destination = CGImageDestinationCreateWithData(
|
||||||
|
mutableData,
|
||||||
|
"public.heic" as CFString,
|
||||||
|
1,
|
||||||
|
nil
|
||||||
|
) else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
let options: [CFString: Any] = [
|
||||||
|
kCGImageDestinationLossyCompressionQuality: compressionQuality
|
||||||
|
]
|
||||||
|
|
||||||
|
CGImageDestinationAddImage(destination, cgImage, options as CFDictionary)
|
||||||
|
|
||||||
|
guard CGImageDestinationFinalize(destination) else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return mutableData as Data
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#Preview {
|
||||||
|
if let image = UIImage(systemName: "photo")?.cgImage {
|
||||||
|
ExportView(image: image)
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user