Files
pyheic_struct/scripts/apple_to_samsung_motion_photo.py

144 lines
4.0 KiB
Python

#!/usr/bin/env python3
"""
Convert an Apple Live Photo pair (HEIC + MOV) into a single Samsung-style
Motion Photo HEIC by embedding the video track inside an `mpvd` box.
"""
from __future__ import annotations
import argparse
import sys
from pathlib import Path
import pillow_heif
from pyheic_struct import HEICBuilder, HEICFile
SAMSUNG_FTYP_PAYLOAD = b"heic\x00\x00\x00\x00mif1heic"
def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(
description=(
"Merge an Apple Live Photo (HEIC + MOV) into a Samsung-compatible "
"Motion Photo HEIC (with embedded mpvd video box)."
)
)
parser.add_argument(
"still",
type=Path,
help="Path to the Apple HEIC still image.",
)
parser.add_argument(
"video",
type=Path,
help="Path to the corresponding MOV video.",
)
parser.add_argument(
"-o",
"--output",
type=Path,
default=None,
help="Output HEIC file. Defaults to <still>_samsung_compatible.heic",
)
return parser
def _validate_file(path: Path, description: str) -> Path:
resolved = path.expanduser().resolve()
if not resolved.is_file():
raise FileNotFoundError(f"{description} not found: {resolved}")
return resolved
def _build_mpvd_box(video_bytes: bytes) -> bytes:
size = len(video_bytes) + 8
return size.to_bytes(4, "big") + b"mpvd" + video_bytes
def _prepare_base_heic(still_path: Path) -> tuple[HEICFile, Path]:
original = HEICFile(str(still_path))
pil_image = original.reconstruct_primary_image()
if pil_image is None:
raise RuntimeError("Failed to reconstruct primary image from input HEIC.")
pillow_heif.register_heif_opener()
temp_flat_path = still_path.with_name(f"{still_path.stem}_temp_flat.heic")
if temp_flat_path.exists():
temp_flat_path.unlink()
pil_image.save(
temp_flat_path,
format="HEIF",
quality=95,
save_as_brand="mif1",
)
heic = HEICFile(str(temp_flat_path))
if heic._ftyp_box is None:
raise RuntimeError("Input HEIC file does not contain an ftyp box.")
# Samsung Motion Photos use a minimal brand list (heic/mif1).
heic._ftyp_box.raw_data = SAMSUNG_FTYP_PAYLOAD
heic._ftyp_box.size = len(SAMSUNG_FTYP_PAYLOAD) + 8
return heic, temp_flat_path
def convert_live_photo(still_path: Path, video_path: Path, output_path: Path) -> Path:
heic, temp_flat_path = _prepare_base_heic(still_path)
try:
builder = HEICBuilder(heic)
builder.write(str(output_path))
finally:
if temp_flat_path.exists():
temp_flat_path.unlink()
video_bytes = video_path.read_bytes()
if video_bytes[4:8] not in {b"ftyp", b"moov"}:
print(
"Warning: Video file does not look like a QuickTime/MP4 container. "
"Samsung devices may not recognize the embedded clip.",
file=sys.stderr,
)
mpvd_box = _build_mpvd_box(video_bytes)
with output_path.open("ab") as f:
f.write(mpvd_box)
return output_path
def main(argv: list[str] | None = None) -> int:
args = build_parser().parse_args(argv)
try:
still_path = _validate_file(args.still, "HEIC still")
video_path = _validate_file(args.video, "MOV video")
except FileNotFoundError as exc:
print(f"Error: {exc}", file=sys.stderr)
return 1
output_path = (
args.output.expanduser().resolve()
if args.output
else still_path.with_name(f"{still_path.stem}_samsung_compatible.heic")
)
try:
convert_live_photo(still_path, video_path, output_path)
except Exception as exc: # pragma: no cover - CLI diagnostic path
print(f"Error: Conversion failed: {exc}", file=sys.stderr)
return 1
print("Conversion finished successfully.")
print(f"Samsung-style Motion Photo saved to: {output_path}")
return 0
if __name__ == "__main__": # pragma: no cover
raise SystemExit(main())