Claude/live photo to gif converter foe0d#7
Conversation
The ISOBMFF parser was based on a wrong assumption — iOS Live Photos store the still image (.HEIC) and video (.MOV) as separate paired files. The HEIC doesn't contain an embedded video track that can be extracted in the browser. Replaced with: - Clear tabbed guide (iPhone / Mac / Quickest Way) showing how to get the .MOV companion video from a Live Photo - Smart HEIC detection on drop: shows a specific warning explaining why the HEIC won't work and points to the instructions - Multi-file drop: if both HEIC + MOV are dropped, auto-picks the MOV - Accepts .MOV/.MP4 video files directly https://claude.ai/code/session_01YLrWud6tskxirgrvVBAsGE
Unlike the browser version which requires users to manually export the .MOV companion file, the native app uses PHAssetResource to extract the paired video directly from the Live Photo — zero friction. Four source files (~450 lines total): - LivePhotoToGIFApp.swift: App entry point - ContentView.swift: Full SwiftUI UI with state machine (idle → loading → editing → converting → done) - LivePhotoPicker.swift: PHPickerViewController wrapper for Live Photos, animated GIF viewer, and share sheet - Converter.swift: LivePhotoExtractor (PHAsset → .MOV via PHAssetResource) and GIFConverter (AVAssetImageGenerator frames → ImageIO CGImageDestination GIF89a encoding) Features: trim start/end, adjustable FPS (10-30), max width slider (200-1080px), animated GIF preview, share sheet, save to Photos. Includes SETUP.md with step-by-step Xcode project creation guide. https://claude.ai/code/session_01YLrWud6tskxirgrvVBAsGE
Desktop-native version with drag-and-drop as the primary interaction. Same PHAssetResource video extraction and ImageIO GIF encoding as the iOS version, adapted for AppKit: - Drag .MOV files from Finder (e.g. after AirDrop from iPhone) - Pick from Photos library (PHPickerViewController via NSViewControllerRepresentable, filtered for Live Photos) - File → Open dialog via SwiftUI fileImporter - HEIC detection: if user drops a .HEIC, shows an alert explaining it's the still image and they need the .MOV companion - Security-scoped URL handling for sandboxed file access macOS-specific UI: - Native NSSavePanel for Save As... - NSWorkspace.activateFileViewerSelecting for Reveal in Finder - NSSharingServicePicker for Share... - NSImageView with animates=true for GIF preview (built-in GIF animation, much simpler than the iOS UIImageView approach) - Converter.swift is cross-platform (no UIKit dependency) Includes SETUP.md with Xcode project creation and App Sandbox config. https://claude.ai/code/session_01YLrWud6tskxirgrvVBAsGE
Use #if os(iOS) / #if os(macOS) conditional compilation so all 4 Swift files compile for both platforms from one Xcode Multiplatform project. Remove the separate LivePhotoToGIF-macOS/ directory. https://claude.ai/code/session_01YLrWud6tskxirgrvVBAsGE
There was a problem hiding this comment.
Pull request overview
This pull request significantly refactors the Live Photo to GIF converter, removing complex browser-based HEIC parsing in favor of a simpler workflow and introducing a companion SwiftUI multiplatform app. The web interface now expects users to provide the .MOV video file directly rather than attempting to extract it from HEIC files in the browser, with comprehensive instructions added to guide users through the extraction process.
Changes:
- Removed ~230 lines of complex ISOBMFF/HEIC parsing JavaScript from the web app and simplified it to accept only video files (.MOV/.MP4)
- Added comprehensive step-by-step instructions with tabbed interface (iPhone/Mac/Quickest Way) to help users extract the .MOV companion file
- Introduced a complete multiplatform SwiftUI app (iOS & macOS) with photo picker integration, video trimming, GIF conversion, and platform-specific export options
Reviewed changes
Copilot reviewed 6 out of 6 changed files in this pull request and generated 9 comments.
Show a summary per file
| File | Description |
|---|---|
| live-photo-to-gif.html | Simplified to accept only video files, removed HEIC parser, added enhanced instructional UI with tabs |
| LivePhotoToGIF/SETUP.md | Comprehensive setup documentation with platform-specific instructions and troubleshooting |
| LivePhotoToGIF/PlatformViews.swift | Cross-platform wrappers for photo picker, animated GIF display, and iOS share sheet |
| LivePhotoToGIF/LivePhotoToGIFApp.swift | App entry point with macOS window configuration |
| LivePhotoToGIF/Converter.swift | Core conversion engine with Live Photo extraction and GIF encoding |
| LivePhotoToGIF/ContentView.swift | Main UI with platform-specific views and export functionality |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| } | ||
| .alert("Photo Access Required", isPresented: $showSettingsAlert) { | ||
| Button("Open System Settings") { | ||
| NSWorkspace.shared.open(URL(string: "x-apple.systempreferences:com.apple.preference.security?Privacy_Photos")!) |
There was a problem hiding this comment.
The alert on line 119 attempts to force-unwrap a URL, which will crash if the URL string is invalid. While the URL string appears to be a valid system settings URL, it's safer to handle the optional properly or use a guard statement.
| NSWorkspace.shared.open(URL(string: "x-apple.systempreferences:com.apple.preference.security?Privacy_Photos")!) | |
| if let url = URL(string: "x-apple.systempreferences:com.apple.preference.security?Privacy_Photos") { | |
| NSWorkspace.shared.open(url) | |
| } |
| } completionHandler: { success, error in | ||
| DispatchQueue.main.async { | ||
| if success { | ||
| errorMessage = "Saved to Photos!" |
There was a problem hiding this comment.
The error message display on line 581 says "Saved to Photos!" which is not actually an error - it's a success message. Using the errorMessage state variable for success notifications is confusing and semantically incorrect. Consider adding a separate success message state or using a different UI pattern for success feedback.
| errorMessage = "Saved to Photos!" | |
| // Clear any previous error; iOS will show its own "Saved" banner. | |
| errorMessage = "" |
| let status = PHPhotoLibrary.authorizationStatus(for: .readWrite) | ||
| switch status { | ||
| case .authorized, .limited: | ||
| return true | ||
| case .notDetermined: | ||
| let newStatus = await PHPhotoLibrary.requestAuthorization(for: .readWrite) |
There was a problem hiding this comment.
The authorization request uses .readWrite permission level (line 26), but the app only needs read access to extract videos from Live Photos. The write permission is only used for the optional "Save to Photos" feature on iOS. Consider requesting .addOnly or checking if read-only access would suffice for the core functionality, and only requesting write access when the user attempts to save to Photos.
|
|
||
| let tempURL = FileManager.default.temporaryDirectory | ||
| .appendingPathComponent("livephoto_\(assetIdentifier.prefix(8)).mov") | ||
| try? FileManager.default.removeItem(at: tempURL) |
There was a problem hiding this comment.
The temporary file cleanup (try? FileManager.default.removeItem(at: tempURL)) silently ignores any errors. While this is generally acceptable for cleanup operations, consider logging the error or at least checking if the file exists first to avoid unnecessary error generation. This pattern is also repeated on lines 148 and 626.
| try? FileManager.default.removeItem(at: tempURL) | |
| if FileManager.default.fileExists(atPath: tempURL.path) { | |
| do { | |
| try FileManager.default.removeItem(at: tempURL) | |
| } catch { | |
| print("Warning: Failed to remove temporary file at \(tempURL): \(error)") | |
| } | |
| } |
| progress(Double(i + 1) / Double(frameCount) * 0.8) | ||
| } | ||
|
|
||
| // Encode GIF | ||
| progress(0.85) |
There was a problem hiding this comment.
The progress calculation (line 141) multiplies by 0.8, then later sets progress to 0.85 and 0.95 manually. This creates an inconsistency where the progress jumps from a max of 80% (when all frames are extracted) to 85%. Consider adjusting the scaling factor or the fixed progress values to ensure smooth progression. For example, use 0.75 as the multiplier so progress goes 0-75%, then 85%, then 95%, then 100%.
| NotificationCenter.default.addObserver( | ||
| forName: .AVPlayerItemDidPlayToEndTime, | ||
| object: player?.currentItem, | ||
| queue: .main | ||
| ) { _ in | ||
| player?.seek(to: .zero) | ||
| player?.play() |
There was a problem hiding this comment.
The NotificationCenter observer is added in loadVideo (lines 514-521) but never removed. This can lead to memory leaks and unexpected behavior if the user loads multiple videos in a session. The observer should be stored and removed in an onDisappear or deinit, or use a weak reference pattern.
| NotificationCenter.default.addObserver( | |
| forName: .AVPlayerItemDidPlayToEndTime, | |
| object: player?.currentItem, | |
| queue: .main | |
| ) { _ in | |
| player?.seek(to: .zero) | |
| player?.play() | |
| let currentPlayer = player | |
| NotificationCenter.default.addObserver( | |
| forName: .AVPlayerItemDidPlayToEndTime, | |
| object: currentPlayer?.currentItem, | |
| queue: .main | |
| ) { [weak currentPlayer] _ in | |
| currentPlayer?.seek(to: .zero) | |
| currentPlayer?.play() |
| 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) |
There was a problem hiding this comment.
The slider for trim start (line 308) has a maximum value of max(0.01, trimEnd - 0.1), which could result in trimStart being locked at 0.01 if trimEnd is less than 0.11 seconds. For very short videos (less than 0.2 seconds), this creates an unusable state. Consider handling edge cases for very short videos or adjusting the minimum gap between start and end.
| 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) | |
| Slider(value: $trimStart, in: 0...max(0.0, trimEnd - min(0.1, videoDuration / 2)), step: 0.1) | |
| } | |
| VStack(alignment: .leading, spacing: 2) { | |
| Text("End").font(.caption2).foregroundStyle(.tertiary) | |
| Slider(value: $trimEnd, in: (trimStart + min(0.1, videoDuration / 2))...videoDuration, step: 0.1) |
| private func reset() { | ||
| screen = .idle | ||
| player?.pause() | ||
| player = nil | ||
| videoURL = nil | ||
| gifURL = nil | ||
| progress = 0 | ||
| } |
There was a problem hiding this comment.
The reset function doesn't clean up temporary files or remove the NotificationCenter observer. When a user loads multiple videos in succession, this could lead to accumulation of temporary files and multiple observers being added. Consider adding cleanup for videoURL (if it's a temp file) and removing any observers in the reset function.
| } | ||
|
|
||
| let tempURL = FileManager.default.temporaryDirectory | ||
| .appendingPathComponent("livephoto_\(assetIdentifier.prefix(8)).mov") |
There was a problem hiding this comment.
The temporary filename uses only the first 8 characters of the asset identifier (line 52). If there's a collision between asset identifiers in their first 8 characters, this could lead to race conditions or file overwrites. Consider using the full identifier or adding a timestamp/UUID to ensure uniqueness, especially since the file is cleaned up before creation.
This pull request introduces a multiplatform SwiftUI app for converting Live Photos to animated GIFs, supporting both iOS and macOS. The main changes include implementation of the core GIF conversion engine, platform-specific UI components, and detailed project setup instructions. The codebase is organized for cross-platform compatibility, using conditional compilation to handle platform differences.
Core Functionality — Live Photo Extraction & GIF Conversion:
LivePhotoExtractorandGIFConverterinConverter.swiftto handle extraction of video from Live Photos and conversion to GIF, including error handling and customizable settings for FPS, width, and trimming.Platform-Specific UI Components:
PhotoPickerandAnimatedGIFViewinPlatformViews.swiftfor both iOS and macOS, wrapping platform-native views for photo selection and GIF display; also added an iOS-only share sheet.LivePhotoToGIFApp.swiftas the app entry point, supporting both platforms with appropriate window sizing for macOS.Project Documentation & Setup:
SETUP.mdwith step-by-step instructions for creating, configuring, and running the app on both platforms, including privacy settings, sandbox permissions, deployment targets, and troubleshooting tips.