Skip to content

Claude/live photo to gif converter foe0d#7

Open
kertal wants to merge 4 commits into
mainfrom
claude/live-photo-to-gif-converter-foe0d
Open

Claude/live photo to gif converter foe0d#7
kertal wants to merge 4 commits into
mainfrom
claude/live-photo-to-gif-converter-foe0d

Conversation

@kertal

@kertal kertal commented Feb 20, 2026

Copy link
Copy Markdown
Owner

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:

  • Added LivePhotoExtractor and GIFConverter in Converter.swift to 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:

  • Implemented PhotoPicker and AnimatedGIFView in PlatformViews.swift for both iOS and macOS, wrapping platform-native views for photo selection and GIF display; also added an iOS-only share sheet.
  • Created LivePhotoToGIFApp.swift as the app entry point, supporting both platforms with appropriate window sizing for macOS.

Project Documentation & Setup:

  • Added SETUP.md with step-by-step instructions for creating, configuring, and running the app on both platforms, including privacy settings, sandbox permissions, deployment targets, and troubleshooting tips.

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

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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")!)

Copilot AI Feb 20, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
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)
}

Copilot uses AI. Check for mistakes.
} completionHandler: { success, error in
DispatchQueue.main.async {
if success {
errorMessage = "Saved to Photos!"

Copilot AI Feb 20, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
errorMessage = "Saved to Photos!"
// Clear any previous error; iOS will show its own "Saved" banner.
errorMessage = ""

Copilot uses AI. Check for mistakes.
Comment on lines +26 to +31
let status = PHPhotoLibrary.authorizationStatus(for: .readWrite)
switch status {
case .authorized, .limited:
return true
case .notDetermined:
let newStatus = await PHPhotoLibrary.requestAuthorization(for: .readWrite)

Copilot AI Feb 20, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.

let tempURL = FileManager.default.temporaryDirectory
.appendingPathComponent("livephoto_\(assetIdentifier.prefix(8)).mov")
try? FileManager.default.removeItem(at: tempURL)

Copilot AI Feb 20, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
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)")
}
}

Copilot uses AI. Check for mistakes.
Comment on lines +141 to +145
progress(Double(i + 1) / Double(frameCount) * 0.8)
}

// Encode GIF
progress(0.85)

Copilot AI Feb 20, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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%.

Copilot uses AI. Check for mistakes.
Comment on lines +514 to +520
NotificationCenter.default.addObserver(
forName: .AVPlayerItemDidPlayToEndTime,
object: player?.currentItem,
queue: .main
) { _ in
player?.seek(to: .zero)
player?.play()

Copilot AI Feb 20, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
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()

Copilot uses AI. Check for mistakes.
Comment on lines +308 to +312
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)

Copilot AI Feb 20, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
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)

Copilot uses AI. Check for mistakes.
Comment on lines +561 to +568
private func reset() {
screen = .idle
player?.pause()
player = nil
videoURL = nil
gifURL = nil
progress = 0
}

Copilot AI Feb 20, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
}

let tempURL = FileManager.default.temporaryDirectory
.appendingPathComponent("livephoto_\(assetIdentifier.prefix(8)).mov")

Copilot AI Feb 20, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants