Generalizes the tool beyond QuickTime video containers, based on what we found fixing a Synology Photos mass-wrong-date bug: the indexer only reads photo-EXIF fields and never video container metadata, even though most cameras embed a real timestamp there. - .avi via exiftool's RIFF DateTimeOriginal (local time, no offset) - .jpg/.jpeg/.png/.tif/.tiff/.heic/.heif via exiftool's EXIF DateTimeOriginal - exiftool fallback for .mp4/.mov when ffprobe finds neither QuickTime tag - placeholder "clock never set" timestamps (e.g. 0000:00:00 00:00:00) are treated as no metadata, not a real date - --dir for recursive directory batch mode - interactive y/N confirmation by default; --yes for unattended runs Verified against real files: a 2007 camcorder AVI, a synthetic UTC-tagged MP4, and an EXIF-tagged JPEG all round-trip correctly, including DST handling. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
159 lines
7.7 KiB
Markdown
159 lines
7.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; 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.
|
|
|
|
```bash
|
|
./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
|
|
```
|
|
|
|
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.
|
|
|
|
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.
|