diff --git a/LivePhotoToGIF/ContentView.swift b/LivePhotoToGIF/ContentView.swift new file mode 100644 index 0000000..82c735c --- /dev/null +++ b/LivePhotoToGIF/ContentView.swift @@ -0,0 +1,655 @@ +import AVKit +import Photos +import SwiftUI +import UniformTypeIdentifiers + +struct ContentView: View { + enum Screen: Equatable { + case idle + case loadingVideo + case editing + case converting + case done + } + + @State private var screen: Screen = .idle + @State private var showPhotoPicker = false + @State private var errorMessage: String? + @State private var showSettingsAlert = false + + // Video + @State private var videoURL: URL? + @State private var videoDuration: Double = 3 + @State private var player: AVPlayer? + + // Settings + @State private var fps: Double = 15 + @State private var maxWidth: Double = 480 + @State private var trimStart: Double = 0 + @State private var trimEnd: Double = 3 + + // Progress & result + @State private var progress: Double = 0 + @State private var gifURL: URL? + @State private var gifSize: String = "" + + // iOS only + #if os(iOS) + @State private var showShareSheet = false + #endif + + // macOS only + #if os(macOS) + @State private var isDragOver = false + @State private var showFileImporter = false + @State private var heicWarning = false + #endif + + var body: some View { + #if os(iOS) + NavigationStack { + ScrollView { + VStack(spacing: 24) { + screenContent + } + .padding() + .animation(.default, value: screen == .idle) + } + .navigationTitle("Live Photo → GIF") + } + .sheet(isPresented: $showPhotoPicker) { + PhotoPicker { assetId in + Task { await handlePick(assetIdentifier: assetId) } + } + } + .sheet(isPresented: $showShareSheet) { + if let gifURL { + ShareSheet(items: [gifURL]) + } + } + .alert("Error", isPresented: errorBinding) { + Button("OK") { errorMessage = nil } + } message: { + Text(errorMessage ?? "") + } + .alert("Photo Access Required", isPresented: $showSettingsAlert) { + Button("Open Settings") { + if let url = URL(string: UIApplication.openSettingsURLString) { + UIApplication.shared.open(url) + } + } + Button("Cancel", role: .cancel) {} + } message: { + Text("This app needs access to your Photos library to read Live Photos. Please grant access in Settings.") + } + + #elseif os(macOS) + VStack(spacing: 0) { + screenContent + } + .frame(minWidth: 480, idealWidth: 560, minHeight: 500, idealHeight: 640) + .animation(.default, value: screen) + .fileImporter( + isPresented: $showFileImporter, + allowedContentTypes: [.movie, .quickTimeMovie, .mpeg4Movie], + allowsMultipleSelection: false + ) { result in + if case .success(let urls) = result, let url = urls.first { + loadVideoFromFile(url: url) + } + } + .sheet(isPresented: $showPhotoPicker) { + PhotoPicker { assetId in + Task { await handlePick(assetIdentifier: assetId) } + } + .frame(width: 600, height: 500) + } + .alert("Error", isPresented: errorBinding) { + Button("OK") { errorMessage = nil } + } message: { + Text(errorMessage ?? "") + } + .alert("That's the still image", isPresented: $heicWarning) { + Button("OK") {} + } message: { + Text("You dropped a .HEIC file, which is just the still photo.\n\nLive Photos are two separate files — drop the .MOV companion video instead.\n\nIn Photos: File → Export → Export Unmodified Original to get both files.") + } + .alert("Photo Access Required", isPresented: $showSettingsAlert) { + Button("Open System Settings") { + NSWorkspace.shared.open(URL(string: "x-apple.systempreferences:com.apple.preference.security?Privacy_Photos")!) + } + Button("Cancel", role: .cancel) {} + } message: { + Text("This app needs access to your Photos library to read Live Photos. Please grant access in System Settings → Privacy & Security → Photos.") + } + #endif + } + + private var errorBinding: Binding { + .init( + get: { errorMessage != nil }, + set: { if !$0 { errorMessage = nil } } + ) + } + + @ViewBuilder + private var screenContent: some View { + switch screen { + case .idle: + idleView + case .loadingVideo: + loadingView + case .editing: + editingView + case .converting: + convertingView + case .done: + doneView + } + } + + // MARK: - Idle + + @ViewBuilder + private var idleView: some View { + #if os(iOS) + VStack(spacing: 20) { + Spacer().frame(height: 40) + + Image(systemName: "livephoto") + .font(.system(size: 64)) + .foregroundStyle(.purple) + + Text("Select a Live Photo to convert it to an animated GIF.") + .multilineTextAlignment(.center) + .foregroundStyle(.secondary) + .padding(.horizontal) + + Button { + Task { await selectLivePhoto() } + } label: { + Label("Choose Live Photo", systemImage: "photo.on.rectangle") + .font(.headline) + .frame(maxWidth: .infinity) + .padding() + } + .buttonStyle(.borderedProminent) + .tint(.purple) + .padding(.horizontal) + + Spacer().frame(height: 20) + } + + #elseif os(macOS) + VStack(spacing: 0) { + // Drop zone + RoundedRectangle(cornerRadius: 12) + .strokeBorder( + style: StrokeStyle(lineWidth: 2, dash: [8, 4]) + ) + .foregroundStyle(isDragOver ? Color.accentColor : .secondary.opacity(0.3)) + .background( + RoundedRectangle(cornerRadius: 12) + .fill(isDragOver ? Color.accentColor.opacity(0.05) : Color.clear) + ) + .frame(height: 200) + .overlay { + VStack(spacing: 10) { + Image(systemName: "arrow.down.doc") + .font(.system(size: 36)) + .foregroundStyle(isDragOver ? .primary : .secondary) + Text("Drop .MOV file here") + .font(.title3.weight(.medium)) + Text("from a Live Photo export or AirDrop") + .font(.caption) + .foregroundStyle(.tertiary) + } + } + .onDrop(of: [.fileURL], isTargeted: $isDragOver) { providers in + handleDrop(providers: providers) + return true + } + .padding() + + Divider().padding(.horizontal) + + // Buttons + VStack(spacing: 12) { + Text("or").foregroundStyle(.tertiary).font(.subheadline) + + HStack(spacing: 12) { + Button { + showFileImporter = true + } label: { + Label("Open File...", systemImage: "folder") + .frame(maxWidth: .infinity) + } + .controlSize(.large) + + Button { + Task { await selectLivePhoto() } + } label: { + Label("Pick from Photos", systemImage: "photo.on.rectangle") + .frame(maxWidth: .infinity) + } + .controlSize(.large) + } + .padding(.horizontal) + } + .padding(.vertical, 16) + + Spacer() + + // Tip + GroupBox { + VStack(alignment: .leading, spacing: 6) { + Text("How to get the .MOV from a Live Photo") + .font(.subheadline.weight(.semibold)) + Text("**AirDrop** from iPhone → arrives as .HEIC + .MOV pair\n**Photos app** → File → Export → Export Unmodified Original\n**iPhone** → Photos → tap ··· → Save as Video") + .font(.caption) + .foregroundStyle(.secondary) + .lineSpacing(3) + } + } + .padding() + } + #endif + } + + // MARK: - Loading + + private var loadingView: some View { + VStack(spacing: 16) { + Spacer() + ProgressView() + #if os(iOS) + .scaleEffect(1.5) + #else + .scaleEffect(1.2) + #endif + Text("Extracting video from Live Photo...") + .foregroundStyle(.secondary) + Spacer() + } + } + + // MARK: - Editing + + private var editingView: some View { + ScrollView { + VStack(spacing: 16) { + // Video preview + if let player { + VideoPlayer(player: player) + .frame(height: 260) + .clipShape(RoundedRectangle(cornerRadius: 8)) + .onAppear { player.play() } + } + + // Trim + GroupBox("Trim") { + VStack(spacing: 10) { + HStack { + Text("Start: \(String(format: "%.1fs", trimStart))") + Spacer() + Text("Duration: \(String(format: "%.1fs", trimEnd - trimStart))") + .foregroundStyle(.purple) + .fontWeight(.medium) + Spacer() + Text("End: \(String(format: "%.1fs", trimEnd))") + } + .monospacedDigit() + .font(.caption) + .foregroundStyle(.secondary) + + HStack(spacing: 16) { + VStack(alignment: .leading, spacing: 2) { + Text("Start").font(.caption2).foregroundStyle(.tertiary) + Slider(value: $trimStart, in: 0...max(0.01, trimEnd - 0.1), step: 0.1) + } + VStack(alignment: .leading, spacing: 2) { + Text("End").font(.caption2).foregroundStyle(.tertiary) + Slider(value: $trimEnd, in: (trimStart + 0.1)...videoDuration, step: 0.1) + } + } + } + } + + // Settings + GroupBox("Settings") { + VStack(spacing: 14) { + HStack { + Text("FPS").frame(width: 70, alignment: .leading) + Picker("FPS", selection: $fps) { + Text("10").tag(10.0) + Text("15").tag(15.0) + Text("20").tag(20.0) + Text("25").tag(25.0) + Text("30").tag(30.0) + } + .pickerStyle(.segmented) + } + + HStack { + Text("Max width").frame(width: 70, alignment: .leading) + Slider(value: $maxWidth, in: 200...1080, step: 40) + Text("\(Int(maxWidth))px") + .monospacedDigit() + .frame(width: 50, alignment: .trailing) + } + } + .font(.subheadline) + } + + // Convert + HStack { + Button(role: .cancel) { + reset() + } label: { + #if os(iOS) + Text("Choose Different Photo") + #else + Text("Cancel") + #endif + } + .controlSize(.large) + + Spacer() + + Button { + Task { await convert() } + } label: { + Label("Convert to GIF", systemImage: "arrow.triangle.2.circlepath") + } + .controlSize(.large) + .buttonStyle(.borderedProminent) + .tint(.purple) + } + } + .padding() + } + } + + // MARK: - Converting + + private var convertingView: some View { + VStack(spacing: 20) { + Spacer() + + ProgressView(value: progress) { + Text("Converting...") + .font(.headline) + } currentValueLabel: { + Text("\(Int(progress * 100))%") + .monospacedDigit() + } + .tint(.purple) + .padding(.horizontal, 60) + + Text(progress < 0.8 ? "Extracting frames..." : "Encoding GIF...") + .foregroundStyle(.secondary) + .font(.subheadline) + + Spacer() + } + } + + // MARK: - Done + + @ViewBuilder + private var doneView: some View { + ScrollView { + VStack(spacing: 16) { + if let gifURL { + AnimatedGIFView(url: gifURL) + .frame(height: 300) + .clipShape(RoundedRectangle(cornerRadius: 8)) + } + + Text(gifSize) + .font(.subheadline) + .foregroundStyle(.secondary) + + #if os(iOS) + Button { + showShareSheet = true + } label: { + Label("Share GIF", systemImage: "square.and.arrow.up") + .font(.headline) + .frame(maxWidth: .infinity) + .padding() + } + .buttonStyle(.borderedProminent) + .tint(.green) + + Button { + Task { await saveToPhotos() } + } label: { + Label("Save to Photos", systemImage: "square.and.arrow.down") + .frame(maxWidth: .infinity) + .padding() + } + .buttonStyle(.bordered) + + #elseif os(macOS) + HStack(spacing: 12) { + Button { + saveAs() + } label: { + Label("Save As...", systemImage: "square.and.arrow.down") + .frame(maxWidth: .infinity) + } + .controlSize(.large) + .buttonStyle(.borderedProminent) + + Button { + revealInFinder() + } label: { + Label("Reveal in Finder", systemImage: "folder") + .frame(maxWidth: .infinity) + } + .controlSize(.large) + } + + Button { + if let gifURL { + let picker = NSSharingServicePicker(items: [gifURL]) + if let window = NSApp.keyWindow, let contentView = window.contentView { + picker.show( + relativeTo: .zero, + of: contentView, + preferredEdge: .minY + ) + } + } + } label: { + Label("Share...", systemImage: "square.and.arrow.up") + .frame(maxWidth: .infinity) + } + .controlSize(.large) + #endif + + Divider() + + Button("Convert Another") { + reset() + } + .font(.subheadline) + } + .padding() + } + } + + // MARK: - Actions + + private func selectLivePhoto() async { + let granted = await LivePhotoExtractor.requestAccess() + if granted { + showPhotoPicker = true + } else { + showSettingsAlert = true + } + } + + private func handlePick(assetIdentifier: String) async { + screen = .loadingVideo + do { + let url = try await LivePhotoExtractor.extractVideo(assetIdentifier: assetIdentifier) + loadVideo(url: url) + } catch { + errorMessage = error.localizedDescription + screen = .idle + } + } + + private func loadVideo(url: URL) { + Task { + let dur = await LivePhotoExtractor.videoDuration(url: url) + videoURL = url + videoDuration = dur + trimStart = 0 + trimEnd = dur + player = AVPlayer(url: url) + player?.actionAtItemEnd = .none + NotificationCenter.default.addObserver( + forName: .AVPlayerItemDidPlayToEndTime, + object: player?.currentItem, + queue: .main + ) { _ in + player?.seek(to: .zero) + player?.play() + } + screen = .editing + } + } + + private func convert() async { + guard let videoURL else { return } + screen = .converting + progress = 0 + + let settings = GIFSettings( + fps: fps, + maxWidth: maxWidth, + trimStart: trimStart, + trimEnd: trimEnd + ) + + do { + let url = try await GIFConverter.convert( + videoURL: videoURL, + settings: settings, + progress: { p in self.progress = p } + ) + gifURL = url + + if let attrs = try? FileManager.default.attributesOfItem(atPath: url.path), + let size = attrs[.size] as? Int { + let mb = Double(size) / (1024 * 1024) + gifSize = mb >= 1 + ? String(format: "%.1f MB", mb) + : "\(size / 1024) KB" + } + + screen = .done + } catch { + errorMessage = error.localizedDescription + screen = .editing + } + } + + private func reset() { + screen = .idle + player?.pause() + player = nil + videoURL = nil + gifURL = nil + progress = 0 + } + + // MARK: - iOS-specific actions + + #if os(iOS) + private func saveToPhotos() async { + guard let gifURL, let data = try? Data(contentsOf: gifURL) else { return } + PHPhotoLibrary.shared().performChanges { + let request = PHAssetCreationRequest.forAsset() + request.addResource(with: .photo, data: data, options: nil) + } completionHandler: { success, error in + DispatchQueue.main.async { + if success { + errorMessage = "Saved to Photos!" + } else { + errorMessage = error?.localizedDescription ?? "Could not save to Photos." + } + } + } + } + #endif + + // MARK: - macOS-specific actions + + #if os(macOS) + private func handleDrop(providers: [NSItemProvider]) { + guard let provider = providers.first else { return } + provider.loadItem(forTypeIdentifier: UTType.fileURL.identifier, options: nil) { item, _ in + guard let data = item as? Data, + let url = URL(dataRepresentation: data, relativeTo: nil) else { return } + DispatchQueue.main.async { + processDroppedURL(url) + } + } + } + + private func processDroppedURL(_ url: URL) { + let ext = url.pathExtension.lowercased() + + if ["mov", "mp4", "m4v"].contains(ext) { + loadVideoFromFile(url: url) + return + } + + if ["heic", "heif"].contains(ext) { + heicWarning = true + return + } + + errorMessage = "Unsupported file type. Drop a .MOV or .MP4 video file." + } + + private func loadVideoFromFile(url: URL) { + let accessing = url.startAccessingSecurityScopedResource() + defer { if accessing { url.stopAccessingSecurityScopedResource() } } + + let temp = FileManager.default.temporaryDirectory + .appendingPathComponent("livephoto_import.\(url.pathExtension)") + try? FileManager.default.removeItem(at: temp) + do { + try FileManager.default.copyItem(at: url, to: temp) + } catch { + errorMessage = "Could not read the file: \(error.localizedDescription)" + return + } + + loadVideo(url: temp) + } + + private func saveAs() { + guard let gifURL else { return } + let panel = NSSavePanel() + panel.allowedContentTypes = [.gif] + panel.nameFieldStringValue = "live-photo.gif" + panel.begin { response in + if response == .OK, let dest = panel.url { + try? FileManager.default.removeItem(at: dest) + try? FileManager.default.copyItem(at: gifURL, to: dest) + } + } + } + + private func revealInFinder() { + guard let gifURL else { return } + NSWorkspace.shared.activateFileViewerSelecting([gifURL]) + } + #endif +} diff --git a/LivePhotoToGIF/Converter.swift b/LivePhotoToGIF/Converter.swift new file mode 100644 index 0000000..a4d1b05 --- /dev/null +++ b/LivePhotoToGIF/Converter.swift @@ -0,0 +1,186 @@ +import AVFoundation +import ImageIO +import Photos +import UniformTypeIdentifiers + +// MARK: - Video extraction from Live Photo PHAsset + +enum LivePhotoExtractor { + + enum Error: LocalizedError { + case assetNotFound + case noVideoResource + case permissionDenied + + var errorDescription: String? { + switch self { + case .assetNotFound: return "Could not find the selected photo." + case .noVideoResource: return "No video found in this Live Photo." + case .permissionDenied: return "Photo library access is required." + } + } + } + + /// Request photo library read access. Returns true if granted (full or limited). + static func requestAccess() async -> Bool { + let status = PHPhotoLibrary.authorizationStatus(for: .readWrite) + switch status { + case .authorized, .limited: + return true + case .notDetermined: + let newStatus = await PHPhotoLibrary.requestAuthorization(for: .readWrite) + return newStatus == .authorized || newStatus == .limited + default: + return false + } + } + + /// Extract the paired .MOV video from a Live Photo asset. + static func extractVideo(assetIdentifier: String) async throws -> URL { + let assets = PHAsset.fetchAssets(withLocalIdentifiers: [assetIdentifier], options: nil) + guard let asset = assets.firstObject else { + throw Error.assetNotFound + } + + let resources = PHAssetResource.assetResources(for: asset) + guard let videoResource = resources.first(where: { $0.type == .pairedVideo }) + ?? resources.first(where: { $0.type == .video }) else { + throw Error.noVideoResource + } + + let tempURL = FileManager.default.temporaryDirectory + .appendingPathComponent("livephoto_\(assetIdentifier.prefix(8)).mov") + try? FileManager.default.removeItem(at: tempURL) + + return try await withCheckedThrowingContinuation { continuation in + let options = PHAssetResourceRequestOptions() + options.isNetworkAccessAllowed = true + + PHAssetResourceManager.default().writeData( + for: videoResource, + toFile: tempURL, + options: options + ) { error in + if let error { + continuation.resume(throwing: error) + } else { + continuation.resume(returning: tempURL) + } + } + } + } + + /// Get the duration of a video file in seconds. + static func videoDuration(url: URL) async -> Double { + let asset = AVURLAsset(url: url) + let duration = try? await asset.load(.duration) + return duration?.seconds ?? 0 + } +} + +// MARK: - GIF Converter + +struct GIFSettings { + var fps: Double = 15 + var maxWidth: CGFloat = 480 + var trimStart: Double = 0 // seconds + var trimEnd: Double? = nil // nil = end of video +} + +enum GIFConverter { + + enum Error: LocalizedError { + case noVideoTrack + case cannotCreateDestination + case encodingFailed + + var errorDescription: String? { + switch self { + case .noVideoTrack: return "No video track found in the file." + case .cannotCreateDestination: return "Could not create GIF output file." + case .encodingFailed: return "GIF encoding failed." + } + } + } + + /// Convert a video file to an animated GIF. + /// `progress` is called with values from 0.0 to 1.0 on the main actor. + @MainActor + static func convert( + videoURL: URL, + settings: GIFSettings, + progress: @escaping (Double) -> Void + ) async throws -> URL { + let asset = AVURLAsset(url: videoURL) + let duration = try await asset.load(.duration).seconds + let endTime = settings.trimEnd ?? duration + let startTime = settings.trimStart + let clipDuration = endTime - startTime + + // Set up frame generator + let generator = AVAssetImageGenerator(asset: asset) + generator.appliesPreferredTrackTransform = true + generator.maximumSize = CGSize(width: settings.maxWidth, height: settings.maxWidth) + generator.requestedTimeToleranceBefore = CMTime(seconds: 0.02, preferredTimescale: 600) + generator.requestedTimeToleranceAfter = CMTime(seconds: 0.02, preferredTimescale: 600) + + let frameCount = max(1, Int(ceil(clipDuration * settings.fps))) + let delay = 1.0 / settings.fps + + // Extract frames + var frames: [CGImage] = [] + frames.reserveCapacity(frameCount) + + for i in 0.. Void + + func makeUIViewController(context: Context) -> PHPickerViewController { + var config = PHPickerConfiguration(photoLibrary: .shared()) + config.filter = .livePhotos + config.selectionLimit = 1 + let picker = PHPickerViewController(configuration: config) + picker.delegate = context.coordinator + return picker + } + + func updateUIViewController(_ vc: PHPickerViewController, context: Context) {} + + func makeCoordinator() -> Coordinator { Coordinator(self) } + + final class Coordinator: NSObject, PHPickerViewControllerDelegate { + let parent: PhotoPicker + init(_ parent: PhotoPicker) { self.parent = parent } + + func picker(_ picker: PHPickerViewController, didFinishPicking results: [PHPickerResult]) { + parent.dismiss() + guard let result = results.first, let id = result.assetIdentifier else { return } + parent.onPick(id) + } + } +} + +#elseif os(macOS) + +struct PhotoPicker: NSViewControllerRepresentable { + @Environment(\.dismiss) private var dismiss + let onPick: (String) -> Void + + func makeNSViewController(context: Context) -> PHPickerViewController { + var config = PHPickerConfiguration(photoLibrary: .shared()) + config.filter = .livePhotos + config.selectionLimit = 1 + let picker = PHPickerViewController(configuration: config) + picker.delegate = context.coordinator + return picker + } + + func updateNSViewController(_ vc: PHPickerViewController, context: Context) {} + + func makeCoordinator() -> Coordinator { Coordinator(self) } + + final class Coordinator: NSObject, PHPickerViewControllerDelegate { + let parent: PhotoPicker + init(_ parent: PhotoPicker) { self.parent = parent } + + func picker(_ picker: PHPickerViewController, didFinishPicking results: [PHPickerResult]) { + parent.dismiss() + guard let result = results.first, let id = result.assetIdentifier else { return } + parent.onPick(id) + } + } +} + +#endif + +// MARK: - Animated GIF View + +#if os(iOS) + +import UIKit +import ImageIO + +struct AnimatedGIFView: UIViewRepresentable { + let url: URL + + func makeUIView(context: Context) -> UIImageView { + let iv = UIImageView() + iv.contentMode = .scaleAspectFit + iv.clipsToBounds = true + iv.backgroundColor = .clear + return iv + } + + func updateUIView(_ iv: UIImageView, context: Context) { + guard let data = try? Data(contentsOf: url), + let source = CGImageSourceCreateWithData(data as CFData, nil) else { return } + + let count = CGImageSourceGetCount(source) + var images: [UIImage] = [] + var duration: Double = 0 + + for i in 0.. 0 ? $0 : nil }) + ?? gif[kCGImagePropertyGIFDelayTime as String] as? Double + ?? 0.1 + duration += d + } + } + + iv.animationImages = images + iv.animationDuration = duration + iv.animationRepeatCount = 0 + iv.startAnimating() + } +} + +#elseif os(macOS) + +import AppKit + +struct AnimatedGIFView: NSViewRepresentable { + let url: URL + + func makeNSView(context: Context) -> NSImageView { + let iv = NSImageView() + iv.imageScaling = .scaleProportionallyUpOrDown + iv.animates = true + iv.setContentHuggingPriority(.defaultLow, for: .horizontal) + iv.setContentHuggingPriority(.defaultLow, for: .vertical) + return iv + } + + func updateNSView(_ iv: NSImageView, context: Context) { + iv.image = NSImage(contentsOf: url) + } +} + +#endif + +// MARK: - Share Sheet (iOS only) + +#if os(iOS) + +struct ShareSheet: UIViewControllerRepresentable { + let items: [Any] + func makeUIViewController(context: Context) -> UIActivityViewController { + UIActivityViewController(activityItems: items, applicationActivities: nil) + } + func updateUIViewController(_ vc: UIActivityViewController, context: Context) {} +} + +#endif diff --git a/LivePhotoToGIF/SETUP.md b/LivePhotoToGIF/SETUP.md new file mode 100644 index 0000000..c24572e --- /dev/null +++ b/LivePhotoToGIF/SETUP.md @@ -0,0 +1,145 @@ +# Live Photo → GIF — Multiplatform App Setup + +A single-codebase SwiftUI app that runs on **both iOS and macOS**. +Convert Live Photos to animated GIFs — pick from Photos, drag-and-drop (Mac), or open a file. + +## Requirements + +- **Xcode 15+** (free from the Mac App Store) +- **iOS 16+** device or simulator (Live Photos need a real device to test properly) +- **macOS 13 Ventura** or later + +## Quick Setup (5 minutes) + +### 1. Create the Xcode project + +1. Open **Xcode** +2. **File → New → Project** +3. Choose **Multiplatform → App**, click Next +4. Fill in: + - **Product Name:** `LivePhotoToGIF` + - **Interface:** SwiftUI + - **Language:** Swift +5. Choose a save location, click **Create** + +### 2. Replace the template files + +1. In the Project Navigator (left sidebar), **delete** the template `ContentView.swift` + (right-click → Delete → Move to Trash) +2. Also delete `LivePhotoToGIFApp.swift` if present +3. Drag **all 4 Swift files** from this directory into the Project Navigator: + - `LivePhotoToGIFApp.swift` + - `ContentView.swift` + - `Converter.swift` + - `PlatformViews.swift` +4. When prompted, check **"Copy items if needed"** and ensure **both targets** (iOS and macOS) are checked, then click **Add** + +### 3. Add Privacy Descriptions + +1. Click the **project** (blue icon) in the Project Navigator +2. Select the **LivePhotoToGIF** target +3. Go to the **Info** tab +4. Under **Custom Target Properties**, click **+** and add: + +| Key | Value | +|-----|-------| +| `Privacy - Photo Library Usage Description` | `This app needs access to your Photos to read Live Photos and convert them to GIFs.` | + +> For iOS, also add if you want "Save to Photos" to work: +> +> | Key | Value | +> |-----|-------| +> | `Privacy - Photo Library Additions Usage Description` | `This app saves converted GIFs to your Photos library.` | + +**Repeat** for the macOS target if the project has separate targets per platform. + +### 4. Enable App Sandbox Permissions (macOS) + +Since the macOS app is sandboxed: + +1. Select the **macOS target** (if separate) or the main target +2. Go to the **Signing & Capabilities** tab +3. Under **App Sandbox**, enable: + - **File Access → User Selected File** → Read Only + - **Photos** (check the box) + +If App Sandbox is not present, click **+ Capability** → App Sandbox. + +### 5. Set Deployment Targets + +1. Select the **project** (blue icon) → **General** tab +2. Under **Minimum Deployments**, set: + - **iOS:** 16.0 + - **macOS:** 13.0 + +### 6. Build & Run + +- **iOS:** Select your iPhone (or simulator) and press **Cmd+R** +- **macOS:** Select **My Mac** and press **Cmd+R** + +## Project Structure + +``` +LivePhotoToGIF/ +├── LivePhotoToGIFApp.swift # App entry point (shared) +├── ContentView.swift # Main UI with #if os() for platform differences +├── Converter.swift # GIF conversion engine (fully cross-platform) +├── PlatformViews.swift # Photo picker, GIF viewer, share sheet (#if os()) +└── SETUP.md # This file +``` + +All 4 Swift files compile for both iOS and macOS. Platform-specific code uses +`#if os(iOS)` and `#if os(macOS)` conditional compilation. + +## Usage + +### iOS +- Tap **"Choose Live Photo"** to open the Photos picker (filtered for Live Photos) +- Trim, adjust FPS and width, then tap **Convert to GIF** +- **Share** or **Save to Photos** + +### macOS +- **Drag & drop** a .MOV file onto the app window (AirDrop a Live Photo → drop the .MOV) +- Or click **"Open File..."** for the standard file dialog +- Or click **"Pick from Photos"** to browse your Mac's Photos library +- Trim, adjust settings, convert, then **Save As...**, **Reveal in Finder**, or **Share...** + +## Testing on a Real Device (iOS) + +Live Photos only exist on real iPhones/iPads. The Simulator has no Live Photos to pick from. + +1. Plug in your iPhone via USB +2. Select it as the run destination in Xcode +3. You may need to trust the developer certificate: + **Settings → General → VPN & Device Management** on the iPhone +4. Build & run — the app installs and launches + +**Free Apple ID works** — you don't need a paid developer account to test on your own device. + +## How It Works + +1. **Pick:** PHPickerViewController filtered for Live Photos gives us the asset identifier +2. **Extract:** `PHAssetResource` finds the paired `.MOV` video and exports it to a temp file +3. **Frames:** `AVAssetImageGenerator` steps through the video at the chosen FPS +4. **Encode:** `CGImageDestination` (ImageIO) writes a GIF89a with proper frame delays +5. **Export:** iOS uses share sheet / save to Photos; macOS uses NSSavePanel / Finder + +## Troubleshooting + +**"Photo Access Required" alert:** +Grant photo library access in Settings (iOS) or System Settings → Privacy & Security → Photos (macOS). + +**Picker shows no photos:** +If you granted "Limited Access" on iOS, tap "Select More Photos" in the picker. + +**Dragged file doesn't work (macOS):** +Make sure you're dropping the .MOV file, not the .HEIC. The .HEIC is just the still image. + +**Build error about deployment target:** +Set iOS to 16.0 and macOS to 13.0 under Project → General → Minimum Deployments. + +**Sandbox error reading files (macOS):** +Enable "User Selected File → Read Only" in Signing & Capabilities → App Sandbox. + +**GIF is too large:** +Lower the Max Width slider (try 320px) and reduce FPS to 10. Live Photo GIFs are typically 1–4 MB. diff --git a/live-photo-to-gif.html b/live-photo-to-gif.html index 90c58fa..4d330e3 100644 --- a/live-photo-to-gif.html +++ b/live-photo-to-gif.html @@ -34,7 +34,7 @@ } h1 { font-size: 1.6rem; font-weight: 700; margin-bottom: 4px; text-align: center; } - .subtitle { color: var(--text-dim); font-size: 0.9rem; margin-bottom: 28px; text-align: center; max-width: 520px; line-height: 1.5; } + .subtitle { color: var(--text-dim); font-size: 0.9rem; margin-bottom: 28px; text-align: center; max-width: 540px; line-height: 1.5; } .app { width: 100%; max-width: 720px; display: flex; flex-direction: column; gap: 20px; } @@ -42,7 +42,7 @@ .drop-zone { border: 2px dashed var(--border); border-radius: var(--radius); - padding: 40px 24px; + padding: 36px 24px; text-align: center; cursor: pointer; transition: border-color 0.2s, background 0.2s; @@ -58,31 +58,66 @@ } .drop-zone .browse:hover { background: var(--accent-hover); } - .how-to { - margin-top: 18px; padding-top: 16px; border-top: 1px solid var(--border); - text-align: left; max-width: 420px; margin-left: auto; margin-right: auto; + /* How-to guide */ + .guide { + background: var(--surface); border: 1px solid var(--border); + border-radius: var(--radius); overflow: hidden; } - .how-to summary { - font-size: 0.82rem; color: var(--text-dim); cursor: pointer; - text-align: center; list-style: none; + .guide-header { + padding: 16px 20px; cursor: pointer; display: flex; + justify-content: space-between; align-items: center; + user-select: none; } - .how-to summary::-webkit-details-marker { display: none; } - .how-to .steps { - margin-top: 10px; padding: 0; list-style: none; - font-size: 0.82rem; color: var(--text-dim); line-height: 1.7; + .guide-header h2 { font-size: 0.95rem; font-weight: 600; } + .guide-header .arrow { transition: transform 0.2s; color: var(--text-dim); font-size: 0.8rem; } + .guide.open .guide-header .arrow { transform: rotate(180deg); } + .guide-body { display: none; padding: 0 20px 20px; } + .guide.open .guide-body { display: block; } + + .method-tabs { + display: flex; gap: 0; margin-bottom: 16px; + border-bottom: 2px solid var(--border); } - .how-to .steps li { padding-left: 20px; position: relative; } - .how-to .steps li::before { - content: attr(data-n); position: absolute; left: 0; color: var(--accent); font-weight: 600; + .method-tab { + padding: 10px 18px; font-size: 0.85rem; font-weight: 600; + color: var(--text-dim); background: none; border: none; + cursor: pointer; border-bottom: 2px solid transparent; + margin-bottom: -2px; transition: color 0.2s, border-color 0.2s; + } + .method-tab:hover { color: var(--text); } + .method-tab.active { color: var(--accent); border-bottom-color: var(--accent); } + + .method-content { display: none; } + .method-content.active { display: block; } + + .step-list { list-style: none; padding: 0; counter-reset: step; } + .step-list li { + counter-increment: step; position: relative; + padding: 10px 0 10px 36px; font-size: 0.85rem; + line-height: 1.6; color: var(--text-dim); + border-left: 2px solid var(--border); margin-left: 12px; + } + .step-list li:last-child { border-left-color: transparent; } + .step-list li::before { + content: counter(step); position: absolute; left: -11px; top: 8px; + width: 22px; height: 22px; border-radius: 50%; + background: var(--accent); color: #fff; font-size: 0.72rem; + font-weight: 700; display: flex; align-items: center; justify-content: center; + } + .step-list li strong { color: var(--text); } + + .tip-box { + margin-top: 14px; padding: 10px 14px; border-radius: 8px; + background: rgba(78,205,196,0.08); border: 1px solid rgba(78,205,196,0.2); + font-size: 0.82rem; line-height: 1.6; color: var(--success); } /* Status banner */ .status-banner { - padding: 10px 16px; border-radius: 8px; font-size: 0.85rem; line-height: 1.5; - display: none; align-items: flex-start; gap: 8px; + padding: 12px 16px; border-radius: 8px; font-size: 0.85rem; line-height: 1.5; + display: none; } - .status-banner.active { display: flex; } - .status-banner.info { background: rgba(108,99,255,0.12); border: 1px solid rgba(108,99,255,0.3); } + .status-banner.active { display: block; } .status-banner.warn { background: rgba(240,192,64,0.1); border: 1px solid rgba(240,192,64,0.3); color: var(--warn); } .status-banner.error { background: rgba(255,107,107,0.1); border: 1px solid rgba(255,107,107,0.3); color: var(--danger); } @@ -183,41 +218,82 @@ h1 { font-size: 1.3rem; } .drop-zone { padding: 28px 16px; } .card { padding: 14px; } + .method-tab { padding: 8px 12px; font-size: 0.8rem; } }

Live Photo → GIF

-

Drop an iOS Live Photo and convert it to an animated GIF with optional trimming — entirely in your browser, no upload.

+

Convert the video from an iOS Live Photo into an animated GIF — entirely in your browser, nothing uploaded.

-
Drop your Live Photo here
-

- Accepts .HEIC Live Photos directly,
- or the .MOV video companion,
- or both files together. -

- - - -
- How do I get my Live Photo file? -
    -
  1. Open Photos on iPhone or Mac
  2. -
  3. Select the Live Photo
  4. -
  5. iPhone: Tap Share → Save to Files → pick this page via Safari
  6. -
  7. Mac: File → Export → Export Unmodified Original — then drag the .HEIC (or both files) here
  8. -
  9. AirDrop to Mac also gives you the paired files
  10. -
-
+
Drop the video from your Live Photo here
+

Accepts .MOV or .MP4 files

+ + +
+ + +
+
+

How to get the video from a Live Photo

+ +
+
+

+ iOS stores a Live Photo as two separate files: a still image (.HEIC) and a short video (.MOV). + Browsers can't read the .HEIC directly — you need the .MOV video companion. Here's how to get it: +

+
+ + + +
+ +
+
    +
  1. Open the Photos app and find your Live Photo
  2. +
  3. Tap the ··· menu (top right) or long-press the photo
  4. +
  5. Tap Save as Video
  6. +
  7. The video now appears in your Recents — share or save it to Files
  8. +
  9. Open this page in Safari and Choose Video File above
  10. +
+
+ Don't see "Save as Video"? Make sure Live is turned on for that photo (not set to Long Exposure or Loop). +
+
+ +
+
    +
  1. Open Photos on your Mac and select the Live Photo
  2. +
  3. Go to File → Export → Export Unmodified Original
  4. +
  5. This saves two files: a .HEIC (still image) and a .MOV (video)
  6. +
  7. Drag the .MOV file onto the drop zone above
  8. +
+
+ You can also drag the Live Photo directly from Photos into Finder first, then drag the .MOV file here. +
+
+ +
+
    +
  1. AirDrop the Live Photo from iPhone to your Mac
  2. +
  3. It arrives as two files: .HEIC + .MOV
  4. +
  5. Drag the .MOV here
  6. +
+
+ This is the fastest way if you have a Mac nearby. The .MOV file is the exact original video from the Live Photo. +
+
+
- +
@@ -303,233 +379,6 @@

Your GIF