Files
live-photo-concat/README.md
T
ergosteur 5fa68dac76 Add standalone set_mtime_from_metadata.py utility
Extracted the mtime-stamping logic added to concat_live_clips.py into
its own dependency-free script (just needs ffprobe), so it can be run
against any video file -- not just Live Photo clips -- to fix a
mismatched filesystem mtime for tools like Synology Photos that sort
videos by mtime instead of parsing embedded metadata.
2026-07-26 13:01:50 -04:00

145 lines
6.7 KiB
Markdown

# 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`:
```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`) |
| `--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, just `ffprobe`) that sets a video
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
video whose filesystem timestamp doesn't match its metadata (e.g. after
copying, downloading, or exporting), for tools like Synology Photos that
sort/date videos by mtime instead of parsing the embedded metadata.
```bash
./set_mtime_from_metadata.py video1.mov video2.mp4 ...
./set_mtime_from_metadata.py --dry-run *.mov # preview without changing anything
```
Prefers the Apple `com.apple.quicktime.creationdate` tag (local time) when
present, falling back to the generic `creation_time` tag (usually UTC).
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.