DJI Ronin 4D-8K ProRes RAW on a Mac
Working notes for turning DJI Ronin 4D-8K ProRes RAW footage into deliverable H.264 on a Mac, without DaVinci Resolve. Everything here was learned the hard way on the Perception Instruments shoots in August and October 2026, and every command below was re-run before publishing. Tested with ffmpeg 8.1.2, Swift 6.3, and an Apple Silicon Mac. On my machine the tooling lives in /opt/sources/pi-llc/day-one-review/.
One honest caveat up front: every clip that has gone through this pipeline so far was 4096x2160. The one 8K clip on the card has not been rendered yet. Where the article says what to expect from 8K, it is labeled as untested.
The footage
- DJI Ronin 4D with the Zenmuse X9 head, ProRes RAW HQ
.MOV(sample entryaprh), 60 fps, 17:9 DCI (4096x2160 or 8192x4320), 48 kHz 24-bit PCM stereo, plus an embedded 1920x1012 MJPEG thumbnail stream. - Roughly 13 GB per minute at 4K and 30 GB per minute at 8K. A 9 minute 4K take is about 120 GB. A 6 minute 8K take was 190 GB.
- The camera clock was never set. Filenames carry
691231and mtimes say 1969 or 1979. Ignore both. - The card mounts at
/Volumes/DJI, clips sit inAXXX_5HRDXX/folders, and it reads at about 1 GB/s. The bottleneck is decode, not I/O. - Clip numbering restarts. Two shoot days on the same card produced identical filenames one directory apart. Disambiguate by path and duration, never by name.
- Clips can be truncated. One 7 minute take's container claimed 421 s and 25267 frames, but the video track ended at 404 s. Recording was interrupted and the last frames were never written, while the audio track is full length. Verify decodable length before using a clip's tail.
Rule one: never decode with ffmpeg
ffmpeg's handling of these files is broken, in versions 8.0.1 and 8.1.2 at least, and older static 6.x and 7.x builds have no prores_raw decoder at all. The mov demuxer returns roughly one sample per 536, which works out to about one frame per 2 GB chunk, and the decoder holds that frame. The sample tables in the file are complete and valid, so the bug is on ffmpeg's side. The render looks perfect in every probe: ffprobe reports the right frame count, duration, and codec. On playback the picture is frozen while the audio moves. A full day was lost to this in August because nothing in the toolchain flags it.
Also ruled out:
avconvert, Apple's AVFoundation CLI, decodes every frame but applies generic RAW processing with no DJI camera plugin, so blown highlights wrap into neon pink garbage. The file'sproresraw.whitebalancefactorsandcolormatricestags are empty, so there is nothing to calibrate against.- DJI's published "codec" download is only a D-Log to Rec.709 LUT for non-RAW footage. It does nothing for ProRes RAW.
- Carving the MOV into small valid slices to dodge the demuxer bug. The slices decoded zero frames.
- OpenCV blur and resize corrupted frame buffers nondeterministically. Keep any post-processing in pure numpy.
What ffmpeg can do safely on these files: pull the audio track, and grab a single frame with -ss. Every frame is a keyframe, so seeks are fast to position, but each RAW grab takes about 13 s and lands a little imprecisely. Seeks past about 97% of a clip can fail outright. Use -ss both before and after -i as a workaround.
What works: AVAssetReader through a Swift pipe
avf_pipe.swift reads frames through AVFoundation's AVAssetReader, the same decode path QuickTime Player uses, so color is correct by construction. It writes BGRA rawvideo to stdout and ffmpeg encodes from the pipe.
import AVFoundation
import Foundation
// avf_pipe <src.mov> <start_sec> <dur_sec> [30]
// Decodes via AVFoundation and writes BGRA rawvideo to stdout.
// Pass 30 as the 4th arg to keep every other frame (60 -> 30 fps).
let args = CommandLine.arguments
let url = URL(fileURLWithPath: args[1])
let start = Double(args[2])!
let dur = Double(args[3])!
let thin30 = args.count > 4 && args[4] == "30"
let asset = AVURLAsset(url: url)
guard let track = asset.tracks(withMediaType: .video).first else {
fatalError("no video track")
}
let reader = try! AVAssetReader(asset: asset)
reader.timeRange = CMTimeRange(start: CMTime(seconds: start, preferredTimescale: 600),
duration: CMTime(seconds: dur, preferredTimescale: 600))
let out = AVAssetReaderTrackOutput(track: track, outputSettings: [
kCVPixelBufferPixelFormatTypeKey as String: kCVPixelFormatType_32BGRA
])
reader.add(out)
reader.startReading()
let stdout = FileHandle.standardOutput
var n = 0, kept = 0, w = 0, h = 0
while let sb = out.copyNextSampleBuffer() {
guard let pb = CMSampleBufferGetImageBuffer(sb) else { continue }
CVPixelBufferLockBaseAddress(pb, .readOnly)
w = CVPixelBufferGetWidth(pb)
h = CVPixelBufferGetHeight(pb)
let stride = CVPixelBufferGetBytesPerRow(pb)
let base = CVPixelBufferGetBaseAddress(pb)!
if !thin30 || (n % 2 == 0) {
var row = 0
while row < h {
stdout.write(Data(bytes: base.advanced(by: row * stride), count: w * 4))
row += 1
}
kept += 1
}
n += 1
CVPixelBufferUnlockBaseAddress(pb, .readOnly)
}
FileHandle.standardError.write("avf_pipe: read \(n) wrote \(kept) \(w)x\(h)\n".data(using: .utf8)!)
Compile once. A deprecation warning about tracks(withMediaType:) is expected and harmless.
swiftc -O avf_pipe.swift -o avf_pipe
The stderr line at the end is your first QC gate. read should match the frame count ffprobe reports for the range you asked for. If it is short, the clip is truncated (see above).
Two gotchas with the pipe:
- The pixels come out in linear light. Apply the sRGB transfer curve on encode (
lut1d=srgb-eotf-1d.cube) or everything is double-darkened. That was an entire failed render version in August. - Always redirect
</dev/nullintoavf_pipe(andavconvert) inside shell loops. They eat stdin and the loop silently stops after the first item.
The 1D LUT is a plain 4096-entry linear to sRGB curve. This generator reproduces the file in use byte for byte:
#!/usr/bin/env python3
N = 4096
print('TITLE "linear->sRGB EOTF"')
print(f"LUT_1D_SIZE {N}")
for i in range(N):
x = i / (N - 1)
y = 12.92 * x if x <= 0.0031308 else 1.055 * x ** (1 / 2.4) - 0.055
print(f"{y:.6f} {y:.6f} {y:.6f}")
Prove it on 10 seconds first
Never start with the full clip. Render a short slice, open it, and confirm motion and color before committing an hour.
SRC=/Volumes/DJI/A006_5HRD28/A006C0012_691231_5HRD28.MOV
LUT=/opt/sources/pi-llc/day-one-review/srgb-eotf-1d.cube
./avf_pipe "$SRC" 236 10 30 </dev/null 2>proof.log | \
ffmpeg -y -v error -f rawvideo -pix_fmt bgra -s 4096x2160 -r 30 -i - \
-vf "lut1d=file=$LUT,format=yuv420p" \
-c:v h264_videotoolbox -b:v 30M -profile:v high \
-color_primaries bt709 -color_trc iec61966-2-1 -colorspace bt709 -r 30 proof.mp4
grep avf_pipe proof.log # expect: avf_pipe: read 600 wrote 300 4096x2160
The -s value must match what the pipe emits, which is the clip's native size. Get it wrong and ffmpeg will happily encode scrambled rows.
Untested, for 8K clips: the pipe should report 8192x4320, so -s 8192x4320, and add scale=4096:2160 ahead of the LUT to downscale exactly 2:1. The aspect is the same 17:9, so no crop. Expect the raw stream through the pipe to be about 4 GB/s at 30 fps, and expect it to be slower than the numbers below.
QC: check frame uniqueness, not ffprobe
The August failure passed every probe. The only test that catches it is hashing consecutive decoded frames and confirming they differ. The -an matters: without it framemd5 hashes audio frames too and the count is meaningless.
# Count unique video frames in a 4 s window starting at 120 s. Expect 120/120.
ffmpeg -v error -ss 120 -t 4 -an -i out.mp4 -f framemd5 - | \
awk '!/^#/ {n++; if (!($NF in u)) {u[$NF]=1; k++}} END {print k "/" n " unique frames"}'
Run it on at least three windows (start, middle, end) and on anything that lands near a cut or an overlay. Pick windows with visible motion. A locked-off shot of nothing can legitimately produce identical frames and fail this test for the wrong reason. The Oct 28 render passes at 120/120 on every window. A deliberately frozen clip scores 1/60.
Then confirm the streams line up:
ffprobe -v error -select_streams v:0 -count_frames \
-show_entries stream=nb_read_frames,width,height,r_frame_rate:format=duration \
-of compact out.mp4
ffprobe -v error -select_streams a:0 \
-show_entries stream=codec_name,sample_rate,channels,duration -of compact out.mp4
ffmpeg -v info -i out.mp4 -af volumedetect -f null - 2>&1 | grep -E "mean_volume|max_volume"
Encoder settings that held up
-c:v h264_videotoolbox -b:v 30M -profile:v high -pix_fmt yuv420p
-color_primaries bt709 -color_trc iec61966-2-1 -colorspace bt709 -r 30
- 30 Mbps at 4096x2160. 20 Mbps showed grain in bright areas. At 30 Mbps expect about 225 MB per minute of output.
- VideoToolbox once ignored the bitrate cap on an overlay chain and wrote a 23 GB segment. Check segment sizes against that 225 MB per minute figure.
libx264 -crf 19is the predictable fallback, just slower. - Measured throughput on Oct 28, 4K source to 4K 30 fps output, idle machine: a 4:37 clip rendered in 3:12 and a 1:41 clip in 1:10, so about 1.4x realtime. Under CPU load in August it dropped to 0.3 to 0.5x. Budget an hour for a 7 minute deliverable and be pleasantly surprised.
- Audio:
-c:a aac -b:a 256kwithloudnorm=I=-18:TP=-1.5:LRA=11on the final mix. Follow loudnorm witharesample=48000. loudnorm resamples internally and hands the encoder 192 kHz; the Oct 28 render shipped with 96 kHz AAC because of this. It plays fine everywhere, but it is not what anyone intended.
Full build pattern: segments, concat, mux
Render each clip or sub-range as its own segment, skip segments that already exist so a crash is resumable, concat with stream copy, then mux separately built audio. This is the Oct 28 build with the paths generalized.
#!/bin/bash
set -u
D1=/opt/sources/pi-llc/day-one-review
LUT=$D1/srgb-eotf-1d.cube
SRC12=/Volumes/DJI/A006_5HRD28/A006C0012_691231_5HRD28.MOV
SRC09=/Volumes/DJI/A006_5HRD28/A006C0009_691231_5HRD28.MOV
OVL=/path/to/thickness_3d.png
DUR12=276.565; DUR09=101.12
OV_START=240.0 # overlay appears here
OV_W=1000; OV_H=800; OV_X=60; OV_Y=60
mkdir -p segs
enc() { # $1 = output, rest = extra ffmpeg args inserted before the encoder
ffmpeg -y -v error -f rawvideo -pix_fmt bgra -s 4096x2160 -r 30 -i - "${@:2}" \
-c:v h264_videotoolbox -b:v 30M -profile:v high -pix_fmt yuv420p \
-color_primaries bt709 -color_trc iec61966-2-1 -colorspace bt709 -r 30 "$1"
}
# Segment 1: clip with PNG overlay fading in at OV_START
[ -s segs/seg12.mp4 ] || $D1/avf_pipe "$SRC12" 0 $DUR12 30 </dev/null 2>segs/seg12.log | \
enc segs/seg12.mp4 -loop 1 -i "$OVL" -filter_complex \
"[0:v]lut1d=file=$LUT,format=rgb24,fade=t=in:st=0:d=0.5,fade=t=out:st=$(python3 -c "print($DUR12-0.5)"):d=0.5[base];\
[1:v]scale=${OV_W}:${OV_H},format=rgba,fade=t=in:st=${OV_START}:d=0.6:alpha=1[ov];\
[base][ov]overlay=${OV_X}:${OV_Y}:enable='gte(t,${OV_START})':eof_action=pass,format=yuv420p" -shortest
# Segment 2: plain clip with fades
[ -s segs/seg09.mp4 ] || $D1/avf_pipe "$SRC09" 0 $DUR09 30 </dev/null 2>segs/seg09.log | \
enc segs/seg09.mp4 -vf "lut1d=file=$LUT,fade=t=in:st=0:d=0.5,fade=t=out:st=$(python3 -c "print($DUR09-0.8)"):d=0.8,format=yuv420p"
# Audio straight from the originals (ffmpeg audio decode is fine)
ffmpeg -y -v error -i "$SRC12" -i "$SRC09" -filter_complex \
"[0:a:0]afade=t=in:st=0:d=0.5,afade=t=out:st=$(python3 -c "print($DUR12-0.5)"):d=0.5[a0];\
[1:a:0]afade=t=in:st=0:d=0.5,afade=t=out:st=$(python3 -c "print($DUR09-0.8)"):d=0.8[a1];\
[a0][a1]concat=n=2:v=0:a=1,loudnorm=I=-18:TP=-1.5:LRA=11,aresample=48000[a]" \
-map "[a]" -c:a aac -b:a 256k segs/audio.m4a </dev/null
# Concat video, then mux
printf "file 'seg12.mp4'\nfile 'seg09.mp4'\n" > segs/concat.txt
ffmpeg -y -v error -f concat -safe 0 -i segs/concat.txt -c copy segs/video.mp4 </dev/null
ffmpeg -y -v error -i segs/video.mp4 -i segs/audio.m4a -map 0:v -map 1:a -c copy \
-movflags +faststart final-4K.mp4 </dev/null
Filtergraph gotchas hit while writing that:
lut1d=/path.cube[base]glues the label onto the filename. Writelut1d=file=/path.cube,format=rgb24[base].- A
-loop 1PNG input never ends, so pass-shortestor the encode runs forever after the pipe closes. - The overlay fade needs
alpha=1on thefadefilter, andenable='gte(t,START)'onoverlayso the PNG is not drawn before its fade-in. eof_action=passkeeps the base video going if the overlay input ends first.- The backslash continuations inside the quoted filtergraph are fine. ffmpeg ignores the leading whitespace on the continued lines.
Indexing a new card
Before cutting anything, build a clip map. Audio and single-frame grabs go through ffmpeg directly. The green cast on the screenshots is expected: it is the raw ffmpeg preview with no DJI color science, and it only matters for identifying content.
SRC=/Volumes/DJI/A004_5HRD26
mkdir -p audio screenshots transcripts
for f in "$SRC"/*.MOV; do
base=$(basename "$f" | cut -c1-9) # A004C0004
dur=$(ffprobe -v error -show_entries format=duration -of csv=p=0 "$f")
# 16 kHz mono WAV for whisper
ffmpeg -y -v error -i "$f" -vn -ac 1 -ar 16000 -c:a pcm_s16le "audio/$base.wav" </dev/null
# 5 frames at 5/25/50/75/95 percent, about 13 s each
for p in 5 25 50 75 95; do
t=$(python3 -c "print($dur*$p/100)")
ffmpeg -y -v error -ss "$t" -i "$f" -frames:v 1 -vf scale=1920:-2 "screenshots/$base-p$p.jpg" </dev/null
done
whisper "audio/$base.wav" --model small --device cpu --output_dir transcripts --output_format txt </dev/null
done
Let whisper auto-detect language. Forcing --language English once hid Russian off-camera chatter and made a throwaway take look usable.
Write the result into an INDEX.md with a table of clip, resolution, length, size, and a one-line description. It pays off the first time someone asks "which take was the good one" three months later.
Card hygiene
- Originals on the card are read-only in practice. Every script reads from
/Volumes/DJIand writes elsewhere. Spot-check after a build that the source files are byte for byte untouched. - Deleting from the card in Finder moves files into a hidden
.Trashesfolder on the card itself. No space is freed until Trash is emptied with the card mounted. exFAT needs no reformat or camera-side cleanup; the Ronin sees the space on the next mount. - Leave the camera's own index, thumbnail, and sensor database folders alone.
When to stop and use Resolve
DaVinci Resolve has native Ronin 4D ProRes RAW support with DJI's color science. For a client grade, HDR delivery, or anything where highlight roll-off matters, that is the correct route. The pipe above is for fast turnarounds where QuickTime-equivalent color is good enough and you want a scriptable, resumable build.