Initial commit: iPhone Live Photo clip concatenator

Detects genuine audio/video overlap between consecutive Live Photo
clips via audio cross-correlation, trims the duplicated footage, and
re-encodes a seamless, metadata-preserving output.
This commit is contained in:
2026-07-26 12:41:19 -04:00
commit 302afee062
4 changed files with 357 additions and 0 deletions
+5
View File
@@ -0,0 +1,5 @@
__pycache__/
*.pyc
*.mov
*.MOV
*.raw
+80
View File
@@ -0,0 +1,80 @@
# live-photo-concat
Concatenate sequential iPhone Live Photo `.MOV` clips into a single seamless,
metadata-preserving video — with the real duplicate footage between clips
removed.
## Why
Each iPhone Live Photo `.MOV` captures roughly 1.5 seconds before and after
its key moment. When several Live Photos are taken in quick succession (e.g.
rapid-fire shutter presses), consecutive clips genuinely overlap: the same
seconds of real-world video and audio get captured twice, once at the end of
clip *N* and again at the start of clip *N+1*. Naively concatenating the
clips repeats that footage, which shows up as an odd "loop" at each clip
boundary.
This script:
1. Extracts the real video+audio streams from each clip (iPhone Live Photo
files bundle a handful of extra HDR/depth/metadata tracks that aren't
needed here).
2. Cross-correlates the audio at each clip boundary to measure the *actual*
overlap duration from the content itself (not just filename order or
whole-second creation timestamps, which aren't precise enough).
3. Trims the duplicated span off the start of each subsequent clip.
4. Re-encodes the video across the joins (trimming mid-GOP HEVC can't be done
with a plain stream copy) and concatenates everything into one file.
5. Carries over the original container metadata (GPS, device info, creation
time, Live Photo identifiers) from one of the source clips.
Audio stays uncompressed PCM throughout, so it never loses quality. Video
quality defaults to a bitrate slightly above the source clips' own bitrate
(configurable — see below).
## Requirements
- `ffmpeg` / `ffprobe` on `PATH`
- Python 3 with the packages in `requirements.txt`:
```bash
pip install --user -r requirements.txt
```
## Usage
```bash
./concat_live_clips.py IMG_2441.MOV IMG_2442.MOV IMG_2443.MOV ...
./concat_live_clips.py -o myvideo.mov clip1.MOV clip2.MOV ...
```
If `-o/--output` is omitted, the output filename is derived from the source
filenames' numbering, e.g. `IMG_2441.MOV .. IMG_2445.MOV` produces
`IMG_2441-2445.mov`.
### Numbered sequence shortcuts
```bash
# bash / zsh
./concat_live_clips.py IMG_{2441..2445}.MOV
# PowerShell
python .\concat_live_clips.py (2441..2445 | ForEach-Object { "IMG_$_.MOV" })
```
### Options
| Flag | Description |
|---|---|
| `-o, --output` | Output file path (default: derived from source filenames) |
| `--crf N` | Use CRF (quality-based) encoding instead of the default bitrate target. Lower = higher quality; ~14-18 is near-transparent |
| `--bitrate RATE` | Explicit target video bitrate, e.g. `12M` or `12000k` |
| `--lossless` | Mathematically lossless video encoding (much larger output) |
| `--bitrate-multiplier N` | When auto-selecting a bitrate, multiply the source clips' peak bitrate by this (default: `1.2`) |
| `--bitrate-floor RATE` | When auto-selecting a bitrate, never go below this (default: `10M`) |
| `--preset` | x265 preset (default: `medium`) |
| `--metadata-from N` | Take container metadata from the Nth input clip, 0-indexed (default: `0`, the first clip) |
| `--confidence-threshold N` | Minimum audio cross-correlation confidence required to trust an overlap detection before falling back to 0 (default: `0.9`) |
`--crf`, `--bitrate`, and `--lossless` are mutually exclusive; the default
(no flag) auto-computes a bitrate target from the source clips.
+271
View File
@@ -0,0 +1,271 @@
#!/usr/bin/env python3
"""Concatenate sequential iPhone Live Photo .MOV clips into one seamless,
metadata-preserving file, with real duplicate footage removed.
Consecutive Live Photos taken close together in time genuinely re-capture
the same seconds of real-world video+audio (each Live Photo spans roughly
1.5s before/after its key moment). Naively concatenating them repeats that
footage. This script cross-correlates the audio at each clip boundary to
measure the real overlap duration from content, then trims that duplicated
span off the start of each subsequent clip before joining. Video is
re-encoded (trimming mid-GOP HEVC can't be done with a plain stream copy);
by default the target bitrate is set slightly above the source clips' own
bitrate, so quality shouldn't visibly regress. Audio is PCM throughout, so
it stays lossless. Metadata (GPS, device info, creation time, Live Photo
IDs) is taken from one clip (the first, by default).
Usage:
./concat_live_clips.py IMG_2441.MOV IMG_2442.MOV IMG_2443.MOV ...
./concat_live_clips.py -o myvideo.mov clip1.MOV clip2.MOV ...
Numbered sequence shortcuts:
bash/zsh: ./concat_live_clips.py IMG_{2441..2445}.MOV
PowerShell: python .\\concat_live_clips.py (2441..2445 | ForEach-Object { "IMG_$_.MOV" })
Requirements:
- ffmpeg / ffprobe on PATH
- Python package 'numpy' (used for the audio cross-correlation that
detects clip overlap): pip install --user numpy
"""
import argparse
import re
import subprocess
import sys
import tempfile
from pathlib import Path
try:
import numpy as np
except ImportError:
sys.exit(
"error: this script requires the 'numpy' package (used to detect "
"audio overlap between clips), but it isn't installed.\n"
"Install it with:\n"
" python3 -m pip install --user numpy"
)
SR = 48000
def extract_audio(path, out_raw):
subprocess.run(
[
"ffmpeg", "-y", "-v", "error", "-i", str(path),
"-map", "0:a:0", "-f", "s16le", "-acodec", "pcm_s16le",
"-ar", str(SR), "-ac", "1", str(out_raw),
],
check=True,
)
def load_audio(path):
return np.fromfile(path, dtype="<i2").astype(np.float64)
def best_overlap(a_tail, b_head, sr=SR, kmin_s=0.05, coarse_step_s=0.002, refine_range_s=0.01):
"""Find the overlap (seconds) that best aligns the tail of clip A with
the head of clip B, via normalized cross-correlation, plus a confidence
score in [0, 1]."""
n = min(len(a_tail), len(b_head))
a_tail = a_tail[-n:]
b_head = b_head[:n]
kmin = max(1, int(kmin_s * sr))
coarse_step = max(1, int(coarse_step_s * sr))
def score(k):
a_seg = a_tail[n - k:]
b_seg = b_head[:k]
ea = np.dot(a_seg, a_seg)
eb = np.dot(b_seg, b_seg)
if ea < 1e-6 or eb < 1e-6:
return 0.0
return float(np.dot(a_seg, b_seg) / np.sqrt(ea * eb))
ks = list(range(kmin, n, coarse_step))
if not ks:
return 0.0, 0.0
scores = [score(k) for k in ks]
best_i = int(np.argmax(scores))
best_k = ks[best_i]
best_score = scores[best_i]
refine_range = max(1, int(refine_range_s * sr))
lo = max(kmin, best_k - coarse_step - refine_range)
hi = min(n, best_k + coarse_step + refine_range)
for k in range(lo, hi):
s = score(k)
if s > best_score:
best_score = s
best_k = k
return best_k / sr, best_score
def detect_overlaps(clips, confidence_threshold):
with tempfile.TemporaryDirectory() as tmp:
raws = []
for c in clips:
raw = Path(tmp) / (c.name + ".raw")
extract_audio(c, raw)
raws.append(raw)
audios = [load_audio(r) for r in raws]
overlaps = []
for i in range(len(audios) - 1):
overlap_s, score = best_overlap(audios[i], audios[i + 1])
if score < confidence_threshold:
print(
f"warning: low-confidence overlap ({score:.3f}) between "
f"{clips[i].name} and {clips[i + 1].name}; treating as 0 overlap",
file=sys.stderr,
)
overlap_s = 0.0
print(f" {clips[i + 1].name}: trimming {overlap_s:.3f}s of duplicated "
f"start (confidence {score:.3f})", file=sys.stderr)
overlaps.append(overlap_s)
return overlaps
def parse_rate(rate):
"""Parse an ffmpeg-style bitrate string ('12M', '12000k', '12000000') into bits/second."""
rate = str(rate)
multiplier = {"k": 1_000, "M": 1_000_000}.get(rate[-1], None)
if multiplier:
return int(float(rate[:-1]) * multiplier)
return int(rate)
def source_video_bitrate(clips):
"""Peak per-clip video-stream bitrate (bits/s) across the sources, used
as the baseline for the default output bitrate."""
rates = []
for c in clips:
out = subprocess.run(
["ffprobe", "-v", "error", "-select_streams", "v:0",
"-show_entries", "stream=bit_rate:format=size,duration",
"-of", "default=noprint_wrappers=1:nokey=1", str(c)],
check=True, capture_output=True, text=True,
).stdout.split()
bit_rate = int(out[0]) if out and out[0] != "N/A" else None
if bit_rate is None:
size, duration = int(out[1]), float(out[2])
bit_rate = int(size * 8 / duration)
rates.append(bit_rate)
return max(rates)
NUMBERED_RE = re.compile(r"^(.*?)(\d+)(\.[^.]+)?$")
def default_output_name(clips):
"""Build an output filename from the source files' numbering, e.g.
IMG_2441.MOV..IMG_2445.MOV -> IMG_2441-2445.mov. Falls back to
'combined.mov' if the names don't share a recognizable numbered pattern."""
first_match = NUMBERED_RE.match(clips[0].name)
last_match = NUMBERED_RE.match(clips[-1].name)
if first_match and last_match and first_match.group(1) == last_match.group(1):
prefix = first_match.group(1)
first_num = first_match.group(2)
last_num = last_match.group(2)
if first_num == last_num:
return Path(f"{prefix}{first_num}.mov")
return Path(f"{prefix}{first_num}-{last_num}.mov")
return Path("combined.mov")
def build_filter(clips, overlaps):
trim_filters = ""
concat_inputs = ""
for i in range(len(clips)):
if i == 0:
concat_inputs += f"[{i}:0][{i}:6]"
else:
trim = overlaps[i - 1]
trim_filters += (
f"[{i}:0]trim=start={trim},setpts=PTS-STARTPTS[v{i}];"
f"[{i}:6]atrim=start={trim},asetpts=PTS-STARTPTS[a{i}];"
)
concat_inputs += f"[v{i}][a{i}]"
return f"{trim_filters}{concat_inputs}concat=n={len(clips)}:v=1:a=1[v][a]"
def main():
parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
parser.add_argument("clips", nargs="+", type=Path, help="Live Photo .MOV clips, in playback order")
parser.add_argument("-o", "--output", type=Path, default=None,
help="Output file (default: derived from source filenames, e.g. IMG_2441-2445.mov)")
quality = parser.add_mutually_exclusive_group()
quality.add_argument("--crf", type=float, default=None,
help="Use CRF (quality-based) encoding instead of the default bitrate target (lower = higher quality, ~14-18 is near-transparent)")
quality.add_argument("--bitrate", type=str, default=None, metavar="RATE",
help="Explicit target video bitrate, e.g. 12M or 12000k (default: auto, ~1.2x the source clips' own bitrate)")
quality.add_argument("--lossless", action="store_true",
help="Mathematically lossless encoding (much larger output)")
parser.add_argument("--bitrate-multiplier", type=float, default=1.2,
help="When auto-selecting a bitrate, multiply the source clips' peak bitrate by this (default: 1.2)")
parser.add_argument("--bitrate-floor", type=str, default="10M", metavar="RATE",
help="When auto-selecting a bitrate, never go below this (default: 10M)")
parser.add_argument("--preset", default="medium", help="x265 preset (default: medium)")
parser.add_argument("--metadata-from", type=int, default=0, metavar="N",
help="Take container metadata from the Nth clip, 0-indexed (default: 0, the first clip)")
parser.add_argument("--confidence-threshold", type=float, default=0.9,
help="Minimum audio cross-correlation confidence to trust an overlap detection (default: 0.9)")
args = parser.parse_args()
clips = args.clips
if len(clips) < 2:
parser.error("need at least 2 clips")
for c in clips:
if not c.is_file():
parser.error(f"file not found: {c}")
output = args.output or default_output_name(clips)
print(f"Detecting real overlap between {len(clips)} clips via audio cross-correlation...", file=sys.stderr)
overlaps = detect_overlaps(clips, args.confidence_threshold)
filter_complex = build_filter(clips, overlaps)
video_args = ["-c:v", "libx265", "-tag:v", "hvc1", "-pix_fmt", "yuv420p"]
if args.lossless:
video_args += ["-x265-params", "lossless=1"]
elif args.crf is not None:
video_args += ["-crf", str(args.crf)]
else:
if args.bitrate is not None:
target = args.bitrate
else:
source_rate = source_video_bitrate(clips)
floor_bps = parse_rate(args.bitrate_floor)
target_bps = max(int(source_rate * args.bitrate_multiplier), floor_bps)
target = f"{target_bps}"
print(f"Source peak video bitrate ~{source_rate / 1e6:.1f} Mbps -> "
f"targeting ~{target_bps / 1e6:.1f} Mbps"
f"{' (bitrate floor applied)' if target_bps == floor_bps else ''}", file=sys.stderr)
headroom = str(int(parse_rate(target) * 1.5))
video_args += ["-b:v", target, "-maxrate", headroom, "-bufsize", headroom]
# The source clips have irregular variable frame rates, which get worse
# once trimmed/concatenated; that confuses x265's rate control (it badly
# underspends the target bitrate). Normalize to a constant frame rate.
video_args += ["-r", "30", "-preset", args.preset]
cmd = ["ffmpeg", "-y"]
for c in clips:
cmd += ["-i", str(c)]
cmd += [
"-filter_complex", filter_complex,
"-map", "[v]", "-map", "[a]",
"-map_metadata", str(args.metadata_from),
*video_args,
"-c:a", "pcm_s16le",
"-movflags", "+faststart+use_metadata_tags",
str(output),
]
print(f"Encoding seamless, deduplicated output -> {output}", file=sys.stderr)
subprocess.run(cmd, check=True)
print(f"Done: {output}", file=sys.stderr)
if __name__ == "__main__":
main()
+1
View File
@@ -0,0 +1 @@
numpy