Files
live-photo-concat/README.md
T
ergosteurandClaude Sonnet 5 6fd83d2b1c Add --only-photo/--only-video filters, skip NAS thumbnail dirs
Verified against real files pulled from are-nas: confirmed the tool
correctly leaves already-correct EXIF-dated photos untouched and only
flags the ones actually missing real metadata (validates the earlier
manual fix was precisely targeted). Recursive --dir walk now prunes
@eaDir/#recycle/#snapshot/.SynologyWorkingDirectory instead of wasting
time on generated thumbnails. Fixed a misleading "no files given"
error when --dir was given but --only-photo/--only-video filtered
everything out.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-26 16:01:24 -04:00

8.1 KiB

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). If a boundary's audio simply doesn't match — a sign the inputs aren't actually a continuous sequence (wrong order, a missing clip, unrelated files) — the run aborts before encoding anything, rather than silently splicing together clips that don't belong together.
  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.
  6. Sets the output file's filesystem modification time to match that same capture time — some tools (e.g. Synology Photos, for videos) sort/date by file mtime instead of parsing embedded metadata.

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:

    pip install --user -r requirements.txt
    

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

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 / 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)
--min-signal-rms N Below this audio RMS level (int16 scale), a boundary is considered too quiet to judge continuity, so a low-confidence match there won't trigger an abort (default: 25)
--allow-discontinuous Proceed even if audio at a clip boundary doesn't match, instead of aborting

--crf, --bitrate, and --lossless are mutually exclusive; the default (no flag) auto-computes a bitrate target from the source clips.

Continuity check

Before encoding, each clip boundary's audio is checked for a real match. Three outcomes:

  • Confident match (confidence ≥ --confidence-threshold) — the overlap is measured and trimmed as normal.
  • Too quiet to tell (signal below --min-signal-rms) — treated as 0 overlap and the run proceeds, since there's no reliable signal either way.
  • Confident mismatch (enough signal, but it doesn't correlate) — treated as evidence the clips aren't actually a continuous sequence, and the run aborts with an error before any encoding happens. Pass --allow-discontinuous to concatenate them anyway.

If any boundary is a confident mismatch, a continuity report is printed before aborting: a table of every junction (gap between capture times, confidence, verdict), followed by the clips broken into continuous runs — each printed as a ready-to-paste clip list so you can immediately re-run on just the subset that's actually one sequence. For example:

Continuity report:
  junction                                       gap  confidence    verdict
  IMG_2449.MOV -> IMG_2450.MOV                    3s       0.216   NO MATCH
  IMG_2450.MOV -> IMG_2451.MOV                   15s       0.134   NO MATCH
  IMG_2451.MOV -> IMG_2452.MOV                    3s       1.000      match
  IMG_2452.MOV -> IMG_2453.MOV                    1s       1.000      match
  IMG_2453.MOV -> IMG_2454.MOV                    4s       0.590   NO MATCH

Continuous runs (copy-paste to re-run on just that subset):
  Run 1 (1 clip): IMG_2449.MOV -- nothing to concatenate on its own
  Run 2 (1 clip): IMG_2450.MOV -- nothing to concatenate on its own
  Run 3 (3 clips): IMG_2451.MOV IMG_2452.MOV IMG_2453.MOV
  Run 4 (1 clip): IMG_2454.MOV -- nothing to concatenate on its own

Bonus utility: set_mtime_from_metadata.py

A standalone script (no numpy dependency; needs ffprobe and exiftool) that sets a media file's filesystem mtime to match its own embedded creation-time metadata. Not specific to Live Photos or this repo's main script — useful for any photo or video whose filesystem timestamp doesn't match its metadata (e.g. after copying, downloading, exporting, or a bad backup restore), for tools like Synology Photos that sort/date videos by mtime instead of parsing embedded metadata.

./set_mtime_from_metadata.py video1.mov photo1.jpg ...
./set_mtime_from_metadata.py --dry-run *.mov               # preview without changing anything or prompting
./set_mtime_from_metadata.py --dir /path/to/library        # recursive batch mode, prompts before applying
./set_mtime_from_metadata.py --dir /path/to/library --yes  # recursive batch mode, unattended
./set_mtime_from_metadata.py --dir /path/to/library --only-photo --dry-run
./set_mtime_from_metadata.py --dir /path/to/library --only-video --yes

Handles .mov/.mp4/.m4v (ffprobe container tags, preferring Apple's com.apple.quicktime.creationdate, local time, over the generic creation_time, usually UTC; falls back to exiftool's QuickTime atoms if neither ffprobe tag is present), .avi (exiftool's RIFF DateTimeOriginal), and photo formats — .jpg/.jpeg/.png/.tif/.tiff/.heic/.heif (exiftool's EXIF DateTimeOriginal). Values with no timezone offset are interpreted as this machine's local time (DST-aware) — run it somewhere with the same system timezone the footage was actually shot in. Placeholder "clock never set" timestamps some cameras write (e.g. literal 0000:00:00 00:00:00) are treated as no metadata, not a real date. --only-photo/--only-video (mutually exclusive) restrict which formats are considered, for both explicit file args and --dir's recursive walk. The recursive walk skips known NAS/sync-tool housekeeping directories (@eaDir, #recycle, #snapshot, .SynologyWorkingDirectory) so it doesn't waste time on generated thumbnails.

Files with no usable timestamp, or that don't exist, are skipped with a warning and a non-zero exit code; the rest of the batch still runs. Without --dry-run or --yes, it prints the full plan and asks for a single y/N confirmation before touching anything.