아이폰의 라이브 포토를 구글 포토에 백업하려면? 구글 모션포토 변환기

아이폰 라이브 포토, 윈도우와 맥에서 이제 간편하게 변환하세요! (feat. Python)

(1/24일 수정) 아래 블로그 내용을 열심히 작성했으나, 더 멋진 프로그램 발견!

클리앙을 보다보니 누군가 깃허브에 훨씬 더 멋진 프로그램을 만들어 올려주셨네요. 그동안 GPT와 씨름하면서 해결하던 내용들이 모두 포함되고 미려한 UI를 제공하네요. 아래 내용은 참고만 하시고 이 Github에 방문해 Motionphoto2를 만나보세요. 사용법은 동일하네요. 라이브포토의 재료가 될 동 파일명의 이미지파일과 동영상파일을 한 폴더에 넣고 지정하면, 모션포토로의 변환이 진행되네요. 참고로 아이폰에서 사진과 동영상을 동 파일명으로 꺼내시려면 3uTools를 활용해보세요.

Github에 올라온 motionphoto2 입니다. 그동안 제가 고민했던게 여기 다 있네요.

아이폰 라이브 포토의 매력과 불편함

아이폰으로 촬영한 생생한 순간들, 라이브 포토 기능으로 더욱 특별하게 담아내시는 분들 많으시죠? 하지만, 이 라이브 포토를 윈도우나 맥 환경에서 관리하거나 다른 서비스로 백업하는 과정이 꽤나 번거롭다는 것을 느끼셨을 겁니다. 특히 구글 포토에 백업할 때면, 라이브 포토가 제대로 업로드되지 않아 난감한 경험을 하셨을 수도 있습니다.

저도 아이폰 라이브 포토를 구글 포토에 백업하는 과정에서 불편함을 느껴 직접 문제 해결에 나섰습니다. 수많은 시행착오 끝에, 아이폰에서 촬영한 .jpg 이미지와 .mov 비디오 파일을 결합하여 라이브 포토 형태로 변환해 주는 파이썬 스크립트를 개발하게 되었습니다. 이제 윈도우와 맥 환경 모두에서 간편하게 라이브 포토를 변환하고 구글 포토에 백업할 수 있게 되었죠.

GPT와 씨름하며 만들어 본 아이폰 라이브포토의 모션포토 변환기

이 블로그 포스팅에서는 제가 개발한 파이썬 스크립트의 사용 방법과 원리, 그리고 설치 과정까지 상세하게 소개하려 합니다. 아이폰 라이브 포토 때문에 골치 아팠던 경험이 있으시다면, 이 글이 분명 도움이 될 것이라 확신합니다.

윈도우와 맥에서 라이브 포토 변환이 어려운 이유

아이폰에서 촬영한 라이브 포토는 단순히 사진과 영상이 결합된 것이 아니라, 내부적으로 복잡한 구조를 가지고 있습니다. .jpg 확장자를 가진 사진 파일과, .mov 확장자를 가진 짧은 비디오 파일이 함께 존재하며, XMP 메타데이터라는 추가 정보가 이 둘을 연결해 줍니다.

3uTools로 이미지를 추출할 경우 이미지와 동영상이 동 파일명으로 1쌍 생성됩니다.

문제는 윈도우와 맥 환경 모두에서 이러한 라이브 포토의 구조를 제대로 인식하지 못한다는 것입니다. 운영체제들은 단순히 .jpg 파일과 .mov 파일을 별개의 파일로 취급하기 때문에, 라이브 포토를 그대로 구글 포토에 백업하면 움직이는 사진으로 표시되지 않고 일반 사진과 영상으로 나뉘어 업로드되는 문제가 발생합니다.

Motionphoto.py 를 통해 모션포토로 변환하면 두 파일이 하나로 합쳐집니다.

파이썬 스크립트, 라이브 포토 변환의 해결사!

이러한 문제점을 해결하기 위해 제가 개발한 파이썬 스크립트는 다음과 같은 핵심 기능을 제공합니다:

  • .jpg 사진 파일과 .mov 비디오 파일 병합: 동일한 파일명을 가진 .jpg 사진 파일과 .mov 비디오 파일을 찾아 하나의 파일로 결합합니다. 병합 과정에서 파일의 바이너리 데이터를 그대로 이어붙여 손실 없이 파일을 합칩니다.
  • XMP 메타데이터 삽입: exiftool이라는 외부 프로그램을 사용하여 병합된 파일에 라이브 포토로 인식될 수 있도록 필요한 XMP 메타데이터를 삽입합니다.
  • 변환된 라이브 포토 파일 관리: 변환된 파일은 output 폴더에 저장하고, 원본 파일은 livephoto 폴더로 이동시켜 원본 폴더를 깔끔하게 유지합니다.
    합쳐진 파일을 비교하면 왼쪽 파일과 같이 동영상이 포함되 이미지 사이즈가 커집니다.

스크립트 사용법: 누구나 쉽게!

이 스크립트는 GUI 기반으로 제작되어, 코딩을 모르는 사용자도 쉽게 사용할 수 있습니다.

  1. "폴더 선택" 버튼 클릭: 라이브 포토 파일이 있는 폴더를 선택합니다.
  2. 파일 정보 확인: 선택한 폴더 내에서 변환 가능한 라이브 포토 개수와 출력 경로를 확인합니다. 변환 대상 파일이 없으면 경고 메시지가 표시됩니다.
  3. "변환 시작" 버튼 클릭: 변환 작업을 시작합니다. 변환 과정은 진행률 표시줄을 통해 시각적으로 확인할 수 있습니다.
  4. 변환 완료 및 파일 이동 여부 결정: 변환이 완료되면 모션포토를 원본 위치로 이동할지 여부를 묻는 창이 나타납니다.

스크립트 작동 원리 

이 스크립트는 파이썬의 강력한 기능을 활용하여 만들어졌습니다. 주요 모듈로는 os, shutil, subprocess 등이 사용되었으며, GUI 생성에는 tkinter가 활용되었습니다. 또한 다중 스레딩 지원을 위해 concurrent.futures 모듈도 적용되었습니다.

설치 및 실행 방법

먼저 파이썬 공식 웹사이트(https://www.python.org/)에서 최신 버전의 파이썬을 다운로드하여 설치하세요. 이후 터미널(macOS/Linux)이나 명령 프롬프트(Windows)에서 필요한 라이브러리를 설치합니다:

pip install tqdm
pip install Pillow

운영체제별 의존성 설치 방법은 다음과 같습니다:

  • macOS: brew install exiftool 및 xcode-select --install 명령어 실행.
  • Linux: sudo apt update && sudo apt install exiftool python3-tk 명령어 실행.
  • Windows: exiftool 공식 웹사이트에서 다운로드 후 설치.

마무리하며...

이 파이썬 스크립트를 통해 아이폰 라이브 포토를 윈도우와 맥 환경에서 간편하게 변환할 수 있습니다. 소중한 추억들을 더 편리하게 관리하고 공유할 수 있게 되길 바랍니다. 사용 중 궁금한 점이나 문제가 있다면 댓글로 문의해 주세요!

아이폰 라이브 포토 변환을 위한 파이썬 스크립트

아래는 제가 작성한 VBA 코드의 주요 부분입니다. 이 코드는 보낸 메일함에서 제목, 수신인, 발송일, 본문 앞 400바이트를 Excel로 추출합니다.

Python Script for Live Photo Conversion()
import os
import shutil
import subprocess
import mmap
from tkinter import Tk, Label, filedialog, StringVar, DISABLED, NORMAL, messagebox
from tkinter.ttk import Progressbar, Style, Button
from concurrent.futures import ThreadPoolExecutor
import platform

def merge_files(photo_path, video_path, output_path):
    out_path = os.path.join(output_path, os.path.basename(photo_path))
    os.makedirs(os.path.dirname(out_path), exist_ok=True)
    try:
        with open(out_path, "wb") as outfile:
            for file in [photo_path, video_path]:
                with open(file, "rb") as f:
                    with mmap.mmap(f.fileno(), length=0, access=mmap.ACCESS_READ) as mm:
                        outfile.write(mm[:])
    except Exception as e:
        print(f"Error merging {photo_path} and {video_path}: {e}")
        return None
    return out_path

def add_xmp_metadata(merged_file, offset, exiftool_path):
    command = [
        exiftool_path,
        '-XMP-GCamera:MicroVideo=1',
        '-XMP-GCamera:MicroVideoVersion=1',
        f'-XMP-GCamera:MicroVideoOffset={offset}',
        '-XMP-GCamera:MicroVideoPresentationTimestampUs=1500000',
        '-overwrite_original',
        merged_file
    ]
    try:
        subprocess.run(command, check=True)
    except subprocess.CalledProcessError as e:
        print(f"Error adding metadata to {merged_file}: {e}")
    except FileNotFoundError:
         messagebox.showerror("오류", "exiftool을 찾을 수 없습니다. exiftool을 설치하고 시스템 환경 변수에 추가해주세요.")

def update_file_timestamps(original_file, new_file):
    if platform.system() != "Windows":
        original_stat = os.stat(original_file)
        os.utime(new_file, (original_stat.st_atime, original_stat.st_mtime))
    else:
         print("윈도우 환경에서는 파일 타임스탬프를 정확히 복사할 수 없습니다.")
        #윈도우에서는 파일 타임스탬프를 정확히 복사할 수 없기에 해당 기능을 막았습니다.

def convert(photo_path, video_path, output_path, exiftool_path):
    merged = merge_files(photo_path, video_path, output_path)
    if merged is None:
        return
    try:
        offset = os.path.getsize(merged) - os.path.getsize(photo_path)
        add_xmp_metadata(merged, offset, exiftool_path)
        update_file_timestamps(photo_path, merged)
    except Exception as e:
        print(f"Error processing {photo_path} and {video_path}: {e}")

def find_pairs(directory):
    pairs = []
    image_extensions = ('.jpg', '.jpeg', '.png')
    
    for file in os.listdir(directory):
        if file.lower().endswith(image_extensions):
            base = os.path.splitext(file)[0]
            photo_path = os.path.join(directory, file)
            video_path = os.path.join(directory, base + ".mov")
            if os.path.exists(video_path):
                pairs.append((photo_path, video_path))
                
    return pairs

def count_files_and_pairs(directory):
    total_files = len(os.listdir(directory))
    conversion_count = len(find_pairs(directory))
    
    return total_files, conversion_count

def move_livephotos(src_directory, dest_directory):
    os.makedirs(dest_directory, exist_ok=True)
    pairs = find_pairs(src_directory)
    
    for photo_path, video_path in pairs:
        shutil.move(photo_path, dest_directory)
        shutil.move(video_path, dest_directory)
        
    return pairs

def process_directory(directory, pairs, progress_var, exiftool_path):
    output_dir = os.path.join(directory, "output")
    os.makedirs(output_dir, exist_ok=True)

    with ThreadPoolExecutor() as executor:
        futures = [executor.submit(convert, photo_path, video_path, output_dir, exiftool_path) for photo_path, video_path in pairs]
        total_pairs = len(pairs)
        for i, future in enumerate(futures):
            future.result()
            progress_var.set((i + 1) / total_pairs * 100)
            progress_bar.update()
            
def select_folder():
    folder_selected = filedialog.askdirectory()
    
    if folder_selected:
        folder_var.set(folder_selected)
        
        total_files, conversion_count = count_files_and_pairs(folder_selected)
        
        livephoto_dir = os.path.join(folder_selected, 'livephoto')
        
        info_var.set(f"총 {total_files}개의 파일 중 {conversion_count}개의 변환 대상이 있습니다.")
        
        output_var.set(f"{livephoto_dir}/output")
        
        if conversion_count > 0:
            convert_button.config(state=NORMAL)
            move_livephotos(folder_selected, livephoto_dir)
            messagebox.showinfo("안내", f"{conversion_count}개의 파일이 변환 준비되었습니다.")
        else:
            convert_button.config(state=DISABLED)
            messagebox.showwarning("경고", "변환할 파일이 없습니다.")

def start_conversion():
    directory = folder_var.get()
    
    livephoto_dir = os.path.join(directory, 'livephoto')
    
    pairs = find_pairs(livephoto_dir)
    
    progress_var.set(0)
    progress_bar.update()

    # exiftool 경로 설정 (윈도우에서는 실제 경로로 변경)
    if platform.system() == "Windows":
        exiftool_path = r"C:\exiftool\exiftool.exe" # 윈도우 환경에서 exiftool이 설치된 경로를 넣으세요.
    else:
        exiftool_path = "/usr/local/bin/exiftool"

    if os.path.exists(exiftool_path): # 실행 파일이 존재하는지 체크
      process_directory(livephoto_dir, pairs, progress_var, exiftool_path)
    else:
       messagebox.showerror("오류", "exiftool을 찾을 수 없습니다. 경로를 확인해주세요.")

    result = messagebox.askyesno("완료", "모든 변환이 완료되었습니다. 모션포토를 사진 저장 위치로 이동하시겠습니까?")
    
    if result:
        move_output_to_storage(livephoto_dir)

def move_output_to_storage(livephoto_dir):
    output_dir = os.path.join(livephoto_dir, 'output')
    storage_dir = folder_var.get()
    
    for file in os.listdir(output_dir):
        src_path = os.path.join(output_dir, file)
        dest_path = os.path.join(storage_dir, file)
        
        if not os.path.exists(dest_path):
            try:
                shutil.move(src_path, storage_dir)
            except Exception as e:
                print(f"Error moving {src_path} to {storage_dir}: {e}")
        else:
            print(f"File {dest_path} already exists. Skipping.")

    messagebox.showinfo("완료", "모든 작업이 완료되었습니다.")

# GUI 설정
root = Tk()
root.title("LivePhoto 변환기")

# macOS 스타일을 위한 테마 설정
style = Style(root)
style.theme_use('aqua') if platform.system() == 'Darwin' else style.theme_use('default') # macOS 테마 자동 설정

style.configure('TButton', font=('Helvetica', 12), padding=10)
style.configure('TLabel', font=('Helvetica', 14))
style.configure('TProgressbar', thickness=20)

folder_var = StringVar()
info_var = StringVar()
output_var = StringVar()
progress_var = StringVar()

Label(root, text="사진 저장 위치 선택:").pack(pady=5)
Button(root, text="폴더 선택", command=select_folder).pack(pady=5)

Label(root, textvariable=folder_var).pack(pady=5)
Label(root, textvariable=info_var).pack(pady=5)
Label(root, text="출력 위치:").pack(pady=5)
Label(root, textvariable=output_var).pack(pady=5)

convert_button = Button(root,
                        text="변환 시작",
                        command=start_conversion,
                        state=DISABLED,
                        style='TButton')
convert_button.pack(pady=10)

progress_bar = Progressbar(root,
                           orient='horizontal',
                           length=300,
                           mode='determinate',
                           variable=progress_var,

                           style="TProgressbar",
                           maximum=100)
progress_bar.pack(pady=10)

root.geometry("400x350+300+200")
root.mainloop()
  

다음 이전

POST ADS1

POST ADS 2