I record every Battlefield 6 session with NVIDIA's ShadowPlay, because who doesn't want to relive that one clutch double kill at 2am? The problem is that "recording everything" very quickly turns into a graveyard of 4K DVR files nobody will ever open again. 105 minutes of footage from a single afternoon, most of it me reloading behind a wall.
So I decided to automate the boring part: find the kills, cut them, and stitch them into one highlight reel. No manual scrubbing through 20 clips at 2x speed anymore.
As usual, a Github repository is available at the end if you want to skip the reading and just go try it 😉
Objective
The goal was simple on paper: point a script at my ShadowPlay folder, and get one final 4K video containing only the good bits, same resolution and quality as the source. Bonus points if it also works for whoever wants to reuse it for their own BF6 clips.
I found an existing project on Github that did something similar for older Battlefield titles, detecting the killfeed (the little box that stacks up in a fixed corner of the screen when you get a kill). Great starting point conceptually. Except for one small detail...
The Sad Part Of The Story
BF6 does have a killfeed: top-right corner, classic "Killer 🔫 Victim" scrolling list, same as every other Battlefield. But that feed shows everyone's kills, the whole match, not just mine. To use it for a personal highlight reel I'd have to OCR every entry and string-match it against my own username, which is exactly the kind of fragile, brittle thing I was trying to avoid.
Turns out there's something better: BF6 also shows a separate, personal kill-confirmation overlay every time you get a kill: a skull icon, the damage number, the enemy's name and a little boxed "KILL" text (sometimes with extra badges tacked on, like "SQUAD SAVIOR" or "PROTECTOR"), sitting roughly center-screen, just below the crosshair. It only ever fires for your own kills, so it filters itself for free. No OCR, no username matching needed.
So I pointed a fixed-region brightness/contour detector (crop that region, threshold the bright pixels, count the blobs) at it, same idea as the old repo's killfeed approach. It "worked", in the sense that it produced detections. Then I actually watched the timestamp it flagged:
A wet puddle reflecting the sky. That's it. That's the "kill".
Turns out ordinary bright scenery (puddles, pale vehicles, a glimpse of sky through a window) trips a brightness threshold just as easily as a skull icon does — and so do the other legitimate HUD badges ("PROTECTOR", "SQUAD SAVIOR", "OBJECTIVE ARMED") that can pop up in that exact same spot without you having actually gotten a kill. Brightness alone just isn't a specific enough signal, cropped region or not.
The Fix: Template Matching
Instead of asking "is this pixel bright", I started asking "does this region of the frame look like the literal 'KILL' box graphic". That's a job for cv2.matchTemplate: crop a clean reference image of the box once, slide it across a search region of each sampled frame, and keep the best correlation score.
class KillfeedDetector:
def __init__(self, detection_cfg, template_path, src_width):
template = cv2.imread(template_path, cv2.IMREAD_GRAYSCALE)
scale_factor = detection_cfg.get("detect_scale_width", src_width) / src_width
self.template = cv2.resize(template, None, fx=scale_factor, fy=scale_factor)
self.match_threshold = detection_cfg.get("match_threshold", 0.72)
def detect(self, frame, frame_num, time_sec):
roi, ox, oy = self.crop_roi(frame)
gray = cv2.cvtColor(roi, cv2.COLOR_BGR2GRAY)
result = cv2.matchTemplate(gray, self.template, cv2.TM_CCOEFF_NORMED)
_, max_val, _, max_loc = cv2.minMaxLoc(result)
if max_val < self.match_threshold:
return []
return [Detection(frame=frame_num, time_sec=time_sec,
x=ox + max_loc[0], y=oy + max_loc[1], confidence=max_val)]
The difference was night and day. I ran it against a 32-second clip with two known real kills in it and printed the match score for every sampled frame:
t= 6.20s score=0.561
t= 16.60s score=0.624
t= 17.80s score=0.817 <=== real kill starts
t= 18.00s score=0.873
t= 19.00s score=0.882
...
t= 20.80s score=0.673
t= 26.20s score=0.839 <=== second kill
t= 27.00s score=0.878
Everything that isn't a kill hovers under ~0.65. Every real kill jumps to 0.79+. A threshold of 0.72 cleanly separates signal from noise, and it stopped caring about puddles entirely. Much better than chasing brightness values.
Turning Detections Into A Highlight Reel
Once I had reliable per-frame detections, the rest is basically an events pipeline:
- Group detections that are close in time (within ~0.8s) into a single kill event. The marker stays visible for a couple of seconds, so one kill produces a short burst of matches, not just one.
- Cluster kill events that happen close together (within 6s by default) into one highlight window. Two or three kills within a few seconds of each other become one continuous clip instead of a choppy back-to-back cut, and if a window contains 2+ events, it gets tagged
MULTI_KILLinstead ofKILL. - Pad each window with some lead-in and lead-out (4s before, 2.5s after by default), so you actually see the approach and not just the confirmation text.
- Cut each window out of the source clip with FFmpeg, re-encoding (NVENC locally,
libx265in the Docker image) so the cut lands exactly where it should and the audio survives. - Concatenate every cut segment, in chronological order, into one final video.
You get to choose what counts as a "highlight" through a single config value:
"clipping": {
"allowed_tags": ["MULTI_KILL"]
}
["MULTI_KILL"] only keeps clustered multi-kills. ["KILL", "MULTI_KILL"] keeps every single kill too. Simple enough to tweak per session depending on how trigger-happy I was.
The Split Workflow
I didn't want an all-or-nothing tool. Sometimes you want to eyeball what got detected before committing to a final render, so the CLI has three modes:
python build_highlights.py clips # detect + cut every highlight into output/segments/
python build_highlights.py assemble # concat whatever is left in output/segments/
python build_highlights.py full # clips + assemble in one go
clips also writes an index.csv next to the segments, so you know exactly which source clip and timestamp each cut came from. Delete whatever you don't want, then run assemble. It just concatenates whatever .mp4 files are still sitting there. Deliberately dumb, and exactly what I wanted.
Dockerized It (Because Why Not)
I run this locally with a Shotcut-bundled FFmpeg and NVENC hardware encoding, which is fast, but obviously not something I can expect a random Github visitor to have lying around. So the whole thing is also packaged as a Docker image, defaulting to software encoding (libx265) so it runs anywhere, no GPU required:
docker build -t bf6-highlights .
docker run --rm \
-v "/path/to/your/recordings:/input" \
-v "/path/to/output:/output" \
bf6-highlights full
If you do have an NVIDIA GPU and nvidia-container-toolkit configured, you can swap the codec back to hevc_nvenc in the mounted config for a nice speed boost.
Does It Actually Work?
First real batch: 21 clips, roughly 105 minutes of 4K footage. Result: 48 detected highlight windows, assembled into an 8-minute final video, same 3840x2160@60fps as the source, audio intact.
I spot-checked a handful of the cuts by pulling stills at the timestamps. One of them landed on this, completely unprompted by me:
DOUBLE KILL 💀💀
The game's own UI confirmed what my clustering logic had already guessed. That felt pretty good.
Wrap Up
Github repo is here, MIT licensed, Docker-ready: github.com/mydnic/battlefield6-highlights-auto-clip
If you record BF6 (or honestly any game with a similarly-shaped kill confirmation UI), feel free to grab it, recrop the template image for your game, and let me know how it goes. And if it saves you from ever manually scrubbing through DVR footage again, smash that star button on the repo a thousand times ❤️