144 lines
4.0 KiB
Python
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())
|