Skip to content

Repository files navigation

multicam-resolve-build

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.


Overview

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.


How it works

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.


What's automated, and what stays a manual step

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 ⚠️ One-time GUI template
Final shot-to-shot color match eyeballed on the scopes ⚠️ One-time GUI template

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.


Prerequisites

  • DaVinci Resolve Studio, running. The free edition cannot do external scripting, so the paid Studio license is required for step3step5. (step1 and step2 are pure ffmpeg/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.so ABI breaks against it and the connection silently fails. 3.12 is the sweet spot.
  • ffmpeg and ffprobe on your PATH.

Python dependencies actually used by the scripts:

numpy
soundfile
librosa

Installation

# 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 librosa

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


Configuration

Copy the template and edit it for your shoot:

cp config.example.toml config.toml

A ready-made real example lives at examples/config_atxmjune.toml.

Top-level keys

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.

The [autocut] block (step5)

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.

The [[cameras]] model

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"]

Usage

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

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

Sample console output

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


Options at a glance

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.


What it produces

  • <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.json plus the _master.wav mastered audio and scratch extraction files.

Editing from here

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.


Helper skill: resolve-random-cuts

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.


Use as a Claude Code skill

This repo ships two Claude Code skills:

  • the main pipeline — SKILL.md at the repo root;
  • the resolve-random-cuts helper — its own SKILL.md inside 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.


Troubleshooting

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.


Credits and acknowledgements

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

Author

Eric A. Booth Sr Researcher, Texas 2036 eric.a.booth@gmail.com


License

MIT © 2026 Eric A. Booth

About

MCP/skill for LLM to autobuild Davinci Resolve MultiCam audio-synced timeline

Resources

Stars

1 star

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages