Event recap video
Tested end to end on a 780-item event library. Requires ffmpeg/ffprobe on PATH and Python 3.10+ with pip install pillow pillow-heif numpy.
Scripts live next to this file. Copy into the project:
scripts/analyze.py,build.py,reel.py,motion.py,fonts.py→ project rootscripts/work/*.py→<project>/work/examples/*.json→ project root, then edit them
Project layout: raw/ (untouched originals), music/, work/ (cache), output/, edit.json (the whole edit).
Fonts are found automatically on Windows/macOS/Linux; override with FONT_SERIF, FONT_SERIF_BOLD, FONT_SANS_BOLD, FONT_BLACK, FONT_EMOJI.
1. Get media
- Put each export/zip in its own folder under
raw/. Never modify originals. - Google Photos has no API for an existing library: drive the user's own logged-in browser over CDP (see the
brave-cdpskill in this repo). Grid items area[href*="/photo/"]; day headers are[role=checkbox][aria-label^="Select all photos from"]. - Select day headers with REAL mouse events (
Input.dispatchMouseEvent), verify the"N selected"text, press Shift+D. The tab must be in front (Page.bringToFront) or the grid stays grey. - Google clears the selection after a download and header
aria-checkedcan go stale; reload before reselecting. Browser.setDownloadBehaviorwithallowAndNamesaves the zip under its GUID with no extension; watchBrowser.downloadProgressforcompleted.
2. Analyze and pick
python analyze.py→work/media.json(capture time from EXIF / videocreation_timein local tz, blur, brightness, duplicates, loudness) + per-day contact sheets inwork/sheets/.python work/vsheets.py→ 3-frame strips per video. LOOK at every sheet. Skip clips that rotate mid-recording, pocket shots and dark night clips.python work/loudest.py ID:seconds ...finds the loudest window (singing, speech, cheering). Verify with a 1 fps frame strip before trimming.- Shared/forwarded files have no capture date (falls back to file mtime) — place them by eye.
3. Edit (what audiences liked)
- Lead with VIDEO (~90% clips); photos only for strong group shots. Photo-heavy cuts feel slow.
- Cold open hook: 8–10 clips of 1.2–1.8 s,
hook_xf0.1 near-hard cuts, then the title card. Then days in order with a small"label": "Day N · date"on each day's first clip, 3–4 s cuts, and a few 9–12 s moments flagged"speech": true(music ducks under them). - Music makes or breaks the hook. Chain 2 tracks of similar tempo (
"music": [a, b], 3 s crossfade) instead of looping one.python work/bpm.py file.mp3estimates tempo. edit.jsonkeys:title, subtitle, closing, closing_sub, title_bg / closing_bg(+_bg_atfor video ids),music/vertical_music,music_volume, duck_volume, ambient_volume, photo_seconds, hook_xf, closing_seconds, horizontal_hook, horizontal, vertical_hook, vertical. Segment:{"id", "at" (0–1 centre) | "start", "dur", "speech", "label", "xf"}.python work/shortlist.py→output/shortlist.jpg(a frame at every cut). Show it to the user before rendering.
4. Render and QC
python build.py [horizontal|vertical|vertical-full]— per-segment cache inwork/seg, xfade chain, loudnorm −14 LUFS, x264.EDIT=other.json OUT_SUFFIX=-v2 python build.pyrenders an alternative edit without touching the main one.- QC every render:
nb_frames == duration*30(xfade chains can drop frames),blackdetectcount 0, ebur128 ≈ −14 LUFS, and a tile sheet of frames (fps=1/5,tile=8x6) to eyeball rotation and cut-off titles. - ffmpeg 7+ prefers
-/filter_complex fileover the deprecated-filter_complex_script.
5. Beat-synced reel (IG / TikTok / FB) — reel.py + reel.json
python reel.py→output/reel.mp4(~45 s), built on build.py's segment renderer.- Cuts land exactly on the beat grid (
bpmhint, auto phase, frame-rounded cut times). Double-check tempo: naive estimators often return half/double (85 vs 154). - First second = the most surprising moment, with a "POV: …" line. 4-beat cuts, 6–8 beats on hero moments, slow push-in on every clip, white flash on section starts.
- Text: heavy sans, white with thick black outline, ~24% from the top (clear of TikTok/IG buttons), slide-up fade. 5–7 short lines total; the last one is the event name.
6. Meme / motion cut — motion.py + motion.json (only when the user asks for memes/stickers)
- Many people find this style too much for a heartfelt event. Default to sections 3–5.
- One long, steady track (~3 min) instead of chaining; beat grid via comb-filter search.
- Per clip:
beats,speed(0.5 slow-mo / 2 fast-forward),freeze(beats; tape-stop + silence + desaturate),capsticker (rounded box, tilt, elastic pop, colour emoji),dayslam titles + whoosh,title,verse,shake,punch,flash,zoom. - If a render is killed, delete clip files that fail ffprobe before re-running (the cache would reuse them).
7. Vertical and social delivery
- When someone approves the 16:9 edit and asks for 9:16, give them the SAME edit vertical (
python build.py vertical-full). Don't restyle an approved edit; "make it more attractive" means polish only. - Platforms (especially Facebook) mute posts with unlicensed music. Offer an original-sound version (an edit with
"music": null) and/or a silent copy (ffmpeg -i in.mp4 -an -c copy out.mp4) so music can be added from the platform's licensed library. - Final polish:
eq=contrast=1.07:saturation=1.2:gamma=0.97,colorbalance=rm=0.03:bm=-0.03:rh=0.02:bh=-0.02,unsharp=5:5:0.45,vignette=angle=PI/5.5+ audiohighpass=80,dynaudnorm,acompressor,loudnorm=I=-14.