Turn a folder of multi-camera clips plus a separate audio recording into a synced, audio-mastered, marker-annotated 4K DaVinci Resolve project — and a beat-synced auto rough cut — with a handful of scripts.
You shot an event with several cameras and captured clean audio on a separate recorder (a Zoom, a Tascam, a lav mixer). Now you're facing the boring part: sync every angle to the good audio, master that audio, stack the cameras, drop markers, and start cutting.
multicam-resolve-build does that for you. It computes frame-accurate sync offsets by cross-correlating each camera's waveform against the master recording (no clapperboard, no jam-sync, no GUI waveform roulette), masters the recorder audio with ffmpeg, then drives the DaVinci Resolve scripting API to assemble a UHD timeline with each camera on its own stacked track, the mastered audio on A1, and IN / OUT / battery-gap markers dropped throughout. As a bonus, it detects the musical beat grid and lays down a separate rough-cut timeline that switches angles on the beat.
Everything is deterministic and repeatable. Point the pipeline at a different shoot by editing one TOML file.
The repo also includes resolve-random-cuts, a standalone helper skill that pre-slices every video track of any Resolve timeline at randomized marks (default 6–12 s apart) into a new _CUTS timeline — see Helper skill: resolve-random-cuts.
Five scripts, run in order. Each one takes a single argument: the path to your TOML config.
config.toml
│
master recording │ camera clips (per body, in record order)
(e.g. ZOOM0007_LR.WAV) │ MVI_4096.MP4, A093C487_..._CANON.MXF, ...
│ │ │
▼ ▼ ▼
┌───────────────────────────────────────────────────────────────────┐
│ step1_prep_audio.py ffmpeg: acompressor → EBU R128 loudnorm │
│ → 48 kHz / 24-bit WAV master │
└───────────────────────────────────────────────────────────────────┘
│
▼
┌───────────────────────────────────────────────────────────────────┐
│ step2_compute_sync.py GCC-PHAT cross-correlation of each clip │
│ against the master → work_dir/offsets.json │
│ (frame-accurate offsets + PSR confidence) │
└───────────────────────────────────────────────────────────────────┘
│
▼
┌───────────────────────────────────────────────────────────────────┐
│ step3_build_timeline.py Resolve API: fresh project, UHD 29.97 │
│ timeline, each camera on its own stacked │
│ video track at its synced recordFrame, │
│ mastered audio on A1, markers, export .drp │
└───────────────────────────────────────────────────────────────────┘
│
▼
┌───────────────────────────────────────────────────────────────────┐
│ step4_apply_grade.py Resolve API: base CDL (+contrast, lifted │
│ shadows) + optional shared look LUT, │
│ applied to every clip on the timeline │
└───────────────────────────────────────────────────────────────────┘
│
▼
┌───────────────────────────────────────────────────────────────────┐
│ step5_auto_cut.py librosa beat tracking on the master → a NEW │
│ '..._ROUGHCUT' timeline that switches angles │
│ on the beat (LRU rotation, no immediate repeats) │
└───────────────────────────────────────────────────────────────────┘
│
▼
ATXMJune_MULTICAM_v1 + ATXMJune_ROUGHCUT_v1 + ATXMJune.drp
The sync engine (sync_lib.py) uses GCC-PHAT — generalized cross-correlation with phase transform whitening. That whitening is what makes it robust to the level, EQ, and reverb differences between a room recorder and hot on-camera audio, the exact case that defeats naive correlation. Because each clip is synced independently against the full master, it handles record-run timecode and non-jam-synced cameras, and the gap from a battery or card swap simply falls out on its own.
Being honest about this matters. Sync, audio mastering, timeline assembly, markers, and the rough cut are fully automated. The parts of a color grade that live in Resolve's node graph — noise reduction and sharpening — are not reachable from the scripting API, so they stay a one-time GUI grade template that you build once and reuse forever.
| Task | Mechanism | Automated? |
|---|---|---|
| Master audio (loudness + compression) | ffmpeg EBU R128 loudnorm + acompressor |
✅ Fully |
| Sync offsets | ffmpeg extract + GCC-PHAT cross-correlation |
✅ Fully, frame-accurate |
| Project / UHD timeline / stacked tracks | Resolve API ImportMedia + AppendToTimeline(recordFrame) |
✅ Fully |
| Camera audio muted | camera audio is never imported to the timeline (video only) | ✅ By construction |
| IN / OUT / battery-gap markers | Resolve API Timeline.AddMarker |
✅ Fully |
| Beat-synced rough cut | librosa beat tracking + LRU angle rotation |
✅ Fully |
| Base contrast / shadow lift / shared look LUT | Resolve API TimelineItem.SetCDL / SetLUT |
✅ Approximate primary |
| Noise reduction + sharpen nodes | Resolve node graph — not scriptable | |
| Final shot-to-shot color match | eyeballed on the scopes |
The manual color step is quick: build a reference grade on one clean clip from each camera (shadows +10%, contrast +10%, a Sharpen node, and a light Spatial/Temporal NR node — Studio only), balance the cameras to it, then select all clips and Apply Grade to All Clips. Save that as a PowerGrade and the next shoot is two clicks. Run step4 afterward if you want the scripted CDL top-up on the primary.
- DaVinci Resolve Studio, running. The free edition cannot do external scripting, so the paid Studio license is required for
step3–step5. (step1andstep2are pureffmpeg/Python and work regardless.) - In Resolve: Preferences ▸ System ▸ General ▸ External scripting using set to Local.
- Python 3.11–3.12. Do not use Python 3.14 — Resolve's
fusionscript.soABI breaks against it and the connection silently fails. 3.12 is the sweet spot. ffmpegandffprobeon yourPATH.
Python dependencies actually used by the scripts:
numpy
soundfile
librosa
# 1. Clone
git clone https://github.com/<your-username>/multicam-resolve-build.git
cd multicam-resolve-build
# 2. Create a 3.12 virtual environment
python3.12 -m venv .venv
source .venv/bin/activate
# 3. Install dependencies
pip install numpy soundfile librosaPoint Python at Resolve's scripting library and modules. These are the macOS defaults — override them only if you installed Resolve somewhere unusual:
export RESOLVE_SCRIPT_API="/Library/Application Support/Blackmagic Design/DaVinci Resolve/Developer/Scripting"
export RESOLVE_SCRIPT_LIB="/Applications/DaVinci Resolve/DaVinci Resolve.app/Contents/Libraries/Fusion/fusionscript.so"
export PYTHONPATH="$RESOLVE_SCRIPT_API/Modules:$PYTHONPATH"resolve_lib.get_resolve() reads RESOLVE_SCRIPT_API and RESOLVE_SCRIPT_LIB and falls back to exactly these paths if they're unset, and it appends $RESOLVE_SCRIPT_API/Modules to sys.path for you — but exporting them explicitly (especially PYTHONPATH) is the reliable path.
Finally, in Resolve, enable Preferences ▸ System ▸ General ▸ External scripting using ▸ Local, and leave Resolve running before you get to step3.
Copy the template and edit it for your shoot:
cp config.example.toml config.tomlA ready-made real example lives at examples/config_atxmjune.toml.
| Key | What it does |
|---|---|
project_name |
The Resolve project to create (a fresh one is made each build). |
media_dir |
Folder holding your camera clips and the master recording. |
work_dir |
Where processed audio, offsets.json, and logs are written. |
timeline_name |
Name of the stacked multicam timeline. |
width, height |
Timeline resolution — 3840 × 2160 for UHD. |
fps |
Timeline frame rate, e.g. 29.97 (30000/1001). |
drop_frame |
true for drop-frame timecode. |
master_audio |
Filename (inside media_dir) of the recorder file to master and sync to. |
loudness_target_lufs |
EBU R128 integrated target, e.g. -14 for streaming, -16 broadcast, -23 EBU. |
loudness_true_peak |
dBTP ceiling for loudnorm, e.g. -1.5. |
loudness_range |
Target loudness range (LRA), e.g. 11. |
comp_threshold_db |
acompressor threshold in dB. |
comp_ratio |
Compression ratio. |
comp_attack_ms, comp_release_ms |
Compressor attack / release in milliseconds. |
analysis_rate |
Sample rate (Hz) for the sync cross-correlation. 2000 gives ~0.5 ms precision. |
min_confidence |
PSR threshold. Below this, a sync is flagged for review. A real lock is typically >8; noise floor sits around 3–4. |
| Key | What it does |
|---|---|
rough_timeline_name |
Name of the separate rough-cut timeline. |
beats_per_shot |
Lay a new shot roughly every N beats (16 ≈ 4 bars in 4/4). Lower = faster cutting. |
min_shot_seconds |
Never cut faster than this. |
librosa_sr |
Analysis sample rate for beat tracking. |
avoid_repeat_angle |
true to avoid switching to the same camera twice in a row. |
This is the heart of the config. One [[cameras]] block = one video track. The first block becomes the bottom track (V1), the next becomes V2, and so on. Inside each block, list that camera body's clips in record order — the pipeline syncs each clip independently, so a split from a battery or card swap naturally leaves the right gap on the timeline (and gets a battery-gap marker).
[[cameras]]
name = "CamA_CanonDSLR"
clips = ["MVI_4096.MP4", "MVI_4097.MP4"] # two split clips from one body, in order
[[cameras]]
name = "CamB_CanonDSLR"
clips = ["MVI_7759.MP4", "MVI_7760.MP4"]
[[cameras]]
name = "CamC_CanonCinema"
clips = ["A093C487_260615T9_CANON.MXF"]
[[cameras]]
name = "CamD_CanonCinema"
clips = ["Z_0163C004A260615_140725RK_CANON.MXF"]Make sure Resolve Studio is running with Local scripting enabled, then run the steps in order, passing your config each time. Using the ATXMJune example config:
# 1. Master the recorder audio (ffmpeg loudnorm + compression)
python step1_prep_audio.py examples/config_atxmjune.toml
# 2. Compute frame-accurate sync offsets → work_dir/offsets.json
python step2_compute_sync.py examples/config_atxmjune.toml
# 3. Build the 4K multicam project + timeline + markers in Resolve, export .drp
python step3_build_timeline.py examples/config_atxmjune.toml
# 4. (optional) Apply the scriptable base CDL / look LUT across every clip
python step4_apply_grade.py examples/config_atxmjune.toml
# 5. Build the beat-synced rough-cut timeline
python step5_auto_cut.py examples/config_atxmjune.tomlstep2 is the one to eyeball before you build. It's inspectable and re-runnable — check offsets.json and the PSR column. Anything below min_confidence prints a <-- LOW CONF, review flag; the fix is usually to point the recorder at a louder passage of the event and re-run.
$ python step2_compute_sync.py examples/config_atxmjune.toml
[step2] extracting master scratch @ 2000 Hz ...
[step2] master: 7241.6s
[step2] CamA_CanonDSLR MVI_4096.MP4 offset= 00:02:13.512 ( 3999f) PSR= 141.30 6s
[step2] CamA_CanonDSLR MVI_4097.MP4 offset= 00:41:07.880 ( 73921f) PSR= 38.74 6s
[step2] CamB_CanonDSLR MVI_7759.MP4 offset= 00:02:41.019 ( 4823f) PSR= 52.66 6s
[step2] CamB_CanonDSLR MVI_7760.MP4 offset= 00:39:58.204 ( 71871f) PSR= 27.11 6s
[step2] CamC_CanonCinema A093C487_260615T9_CANON.MXF offset= 00:01:58.301 ( 3545f) PSR= 12.09 9s
[step2] CamD_CanonCinema Z_0163C004A260615_140725RK_CANON.MXF offset= 00:03:04.977 ( 5546f) PSR= 9.02 9s
[step2] wrote /Volumes/Extreme Pro/ATXMJune/_resolve_build/offsets.json
All six clips locked (PSR 9–141, every one comfortably above the min_confidence of 8). Then the timeline build and the rough cut:
$ python step3_build_timeline.py examples/config_atxmjune.toml
[step3] project: ATXMJune 3840x2160 @ 29.97
[step3] imported 7 media pool items
[step3] timeline 'ATXMJune_MULTICAM_v1' with 4 video tracks
[step3] master audio placed on A1 (217140 frames)
[step3] V1 MVI_4096.MP4 rec= 3999f conf=141.301
[step3] V1 MVI_4097.MP4 rec= 73921f conf=38.740
[step3] V2 MVI_7759.MP4 rec= 4823f conf=52.660
...
[step3] added 16 markers
[step3] exported project -> /Volumes/Extreme Pro/ATXMJune/ATXMJune.drp
[step3] done.
$ python step5_auto_cut.py examples/config_atxmjune.toml
[step5] loading master for beat tracking ...
[step5] tempo ~123.0 BPM, 14847 beats
[step5] 871 shots planned, 871 filled (213141 frames covered)
[step5] angle usage: {'CamA_CanonDSLR': 224, 'CamB_CanonDSLR': 218, 'CamC_CanonCinema': 216, 'CamD_CanonCinema': 213}
[step5] rough cut 'ATXMJune_ROUGHCUT_v1' built with 871 cuts on V1 + master on A1
[step5] done.
That's the real ATXMJune run: 6 camera clips against a 2-hour Zoom recording, all locked, a detected tempo of 123 BPM, and 871 beat-synced cuts producing ATXMJune_MULTICAM_v1 and ATXMJune_ROUGHCUT_v1.
The keys you'll reach for most, with the ATXMJune defaults:
| Key | Default | Notes |
|---|---|---|
width × height |
3840 × 2160 |
UHD. |
fps |
29.97 |
Drop-frame footage. |
drop_frame |
true |
Drop-frame timecode. |
loudness_target_lufs |
-14.0 |
Streaming target. |
loudness_true_peak |
-1.5 |
dBTP ceiling. |
loudness_range |
11.0 |
LRA. |
comp_threshold_db |
-18.0 |
Compressor threshold. |
comp_ratio |
3.0 |
Gentle bus compression. |
comp_attack_ms / comp_release_ms |
20 / 250 |
Attack / release. |
analysis_rate |
2000 |
Hz for cross-correlation (~0.5 ms). |
min_confidence |
8.0 |
PSR review threshold. |
beats_per_shot |
16 |
≈ 4 bars; lower cuts faster. |
min_shot_seconds |
3.0 |
Minimum shot length. |
librosa_sr |
22050 |
Beat-tracking analysis rate. |
avoid_repeat_angle |
true |
No back-to-back same angle. |
look_lut is optional and unset by default: add look_lut = "/path/to/look.cube" and step4 will apply that shared LUT to every clip alongside the base CDL.
<timeline_name>— the stacked multicam timeline (e.g.ATXMJune_MULTICAM_v1): each camera on its own video track, positioned at its synced offset, with the mastered recorder audio on A1 and the camera audio deliberately absent so only the master is heard. IN / OUT markers on every clip, blue battery-gap markers wherever a body split more than a second, and every clip boundary flagged as a natural switch point.<autocut.rough_timeline_name>— a separate beat-synced rough cut (e.g.ATXMJune_ROUGHCUT_v1): one angle at a time on V1, switching on the beat with the master on A1. Every shot is an independent, already-trimmable edit.<project_name>.drp— an exported DaVinci Resolve project archive dropped next to your media.work_dir/offsets.jsonplus the_master.wavmastered audio and scratch extraction files.
The pipeline hands you a starting point, not a locked cut. Two natural ways to keep going:
From the stacked multicam timeline. This is a clean multicam source. In Resolve, select all the stacked video tracks in the media pool timeline view, right-click → Create New Multicam Clip Using Selected Clips (angles are already in sync because the tracks are placed frame-accurately), then cut live in the multicam viewer. The IN / OUT and battery-gap markers tell you exactly where each angle is available so you never cut to black.
From the rough cut. ..._ROUGHCUT is already a sequence of independent, trimmable edits — the footage is pre-cut at every suggested switch. Ripple-trim any shot to taste, or swap an angle by dropping a different camera's clip onto the same slot (all cameras share the same timeline offset, so a replacement lands in sync). Use the markers on the multicam timeline to navigate: they're the map of where each angle lives.
Pre-slice the whole stack. If you'd rather cut manually but want every clip already broken into swappable pieces, run the resolve-random-cuts helper (documented below). It razors every video track of the stacked timeline at randomized marks into a new ..._CUTS timeline, leaving the original untouched.
Refine the color with the one-time grade template described above, then re-run step4 for the scripted CDL top-up if you want it.
resolve-random-cuts/ is a self-contained companion skill that automates the manual "razor across all tracks every ~10 seconds" pass. It cuts every clip on every video track of a timeline at randomized marks — gap widths drawn uniformly from [min-gap, max-gap] seconds (default 6–12) — without flattening the stack: each layer keeps its own clips, just pre-broken at the same global cut points, so every piece is a trimmable, swappable edit.
Because the Resolve scripting API (checked through v21) has no razor/split call, the script rebuilds instead: it reads each item's placement and source in-point, then re-appends every clip as ranged segments split at the global marks. Output always goes to a new timeline named <source>_CUTS — the source timeline is never modified, so a bad run costs nothing.
It shares this repo's prerequisites (Resolve Studio running, Local scripting enabled, Python 3.11–3.12) but has no third-party dependencies — it runs on the standard library alone, so no venv is needed.
cd resolve-random-cuts
python random_cuts.py --dry-run # preview marks on the current timeline
python random_cuts.py # build <current>_CUTS with 6-12s gaps
python random_cuts.py --timeline "ATXMJune_MULTICAM_v1" --min-gap 4 --max-gap 9 --seed 42
python random_cuts.py --cut-audio # razor audio at the same marks too| Flag | Default | What it does |
|---|---|---|
--timeline |
current timeline | Source timeline name. |
--min-gap / --max-gap |
6 / 12 |
Bounds (seconds) for the random gap between cuts. |
--min-segment |
1.0 |
Skip a mark on a clip if it would leave a sliver shorter than this. |
--seed |
random | Reproducible cut pattern; the seed used is always printed. |
--suffix |
_CUTS |
Name suffix for the new timeline. |
--cut-audio |
off | Razor audio tracks at the same marks (default: audio copied whole). |
--dry-run |
off | Print the marks and span without building anything. |
The rebuild carries over track layout, clip positions, source ranges, gaps, enabled state, clip color, inspector properties, and color grades (via CopyGrades, Resolve 18.5+). It does not carry transitions (not in the API) or titles/generators without a media pool item (skipped with a warning), and retimed clips are appended with a warning since the source-range math assumes 100% speed. See resolve-random-cuts/SKILL.md for the full details and failure modes.
This repo ships two Claude Code skills:
- the main pipeline —
SKILL.mdat the repo root; - the
resolve-random-cutshelper — its ownSKILL.mdinside that folder.
To register both:
cp -R multicam-resolve-build ~/.claude/skills/multicam-resolve-build/
cp -R multicam-resolve-build/resolve-random-cuts ~/.claude/skills/resolve-random-cuts/Then a prompt like "build a multicam timeline from this shoot folder" triggers the pipeline, and "cut all my camera clips every 6–12 seconds" triggers the random-cuts helper. Because everything is plain scripts, both also run standalone or from cron for hands-off batch builds.
scriptapp("Resolve") returns None / "Could not connect to DaVinci Resolve."
Resolve isn't running, isn't the Studio edition, or external scripting isn't enabled. Confirm Resolve Studio is open and Preferences ▸ System ▸ General ▸ External scripting using is set to Local. resolve_lib.get_resolve() raises this exact message when the connection can't be made.
Everything imports but the connection silently fails on Python 3.14.
Resolve's fusionscript.so is compiled against a specific CPython ABI and does not load on 3.14. Use Python 3.11–3.12 (3.12 recommended). Rebuild your venv with python3.12 -m venv .venv and reinstall.
ModuleNotFoundError: DaVinciResolveScript.
PYTHONPATH doesn't include the modules folder. Export the two RESOLVE_SCRIPT_* vars and add $RESOLVE_SCRIPT_API/Modules to PYTHONPATH as shown in Installation. On non-macOS or a non-standard install, correct the two paths to match your machine.
Media pool imports come back empty or unreliable.
The scripts import via MediaPool.ImportMedia, not AddItemListToMediaPool — the latter proved unreliable in this workflow (see resolve_lib.import_media). If you're extending the code, prefer ImportMedia and verify each returned MediaPoolItem by name before placing it.
A clip flags <-- LOW CONF, review in step2.
Its PSR fell below min_confidence. Usually the recorder was too quiet during that clip's span. Re-run against a louder passage of the event, double-check you listed the right master_audio, and confirm the clip actually overlaps the recording in time.
ffmpeg: command not found.
ffmpeg/ffprobe aren't on PATH. Install them (e.g. brew install ffmpeg) and re-open your shell.
- Built to work alongside samuelgursky/davinci-resolve-mcp — an MCP server for DaVinci Resolve.
- Beat tracking by librosa.
- Audio mastering and scratch extraction by ffmpeg.
- Timeline assembly via Blackmagic Design's DaVinci Resolve scripting API.
Eric A. Booth Sr Researcher, Texas 2036 eric.a.booth@gmail.com
MIT © 2026 Eric A. Booth