Film a CS2 highlight from the match demo, off-screen, then cut it into a montage.
Valve gives no video of a match, only a replay file (the demo) that the game plays back.
frag finds the match, downloads its demo, plays it in a hidden CS2 and films it. Nothing
opens on the desktop, no game sound reaches the headphones, and the game settings it
touches are put back.
git clone https://github.com/alexfrih/frag-cli && cd frag-cli
ln -sfn "$PWD/frag.sh" ~/.local/bin/frag
Needs:
- Linux with PipeWire, and an NVIDIA card: the picture is encoded by NVENC. Not tested on anything else. CS2 has no macOS build.
- Steam with CS2 installed, signed in once on this machine.
fragfilms the matches of the Steam account used last. gamescope,gst-launch-1.0with the PipeWire and NVENC plugins,ffmpegwithh264_nvenc,jq,uv,pactl,pw-link,python3,curl,unzip.
frag ls # recent matches, with the count of 3, 4 and 5 kill rounds
frag ace # newest five-kill round, filmed, scoreboard at the end
frag film 2 --round 9 --scoreboard
The mp4 lands in ~/Desktop/WIP/CS2/, or in the folder named by FRAG_OUT.
frag rounds <n> lists the rounds worth filming.
frag check says whether anything was left running.
- Match list: the Leetify public API, by Steam id. No key.
- Demo link: only Valve's game coordinator knows it. A signed-in Steam asks for it
through
boiler-writter(pinned release, checksum checked). The demo stays about a month. - Off-screen:
gamescope --backend headlessruns Steam and CS2 with no window. Steam on the desktop must be closed first: there is one Steam per user. - Picture: gamescope's PipeWire stream, read by GStreamer and encoded by NVENC. The frames are copied out at once and queued, or the stream runs out of buffers and drops half of them. A capture stopped with anything but INT, or started with a format the stream does not offer, kills the stream until the session restarts.
- Sound: the game plays into a silent output created for the run (
PIPEWIRE_NODE), and a watchdog relinks it if it ever lands on the headphones. - Smoothness: two takes. Real time for the sound, half speed for the picture, which then holds 60 frames a second even on a busy machine.
- Clock: each take is timed on the kill feed (
lib/sync.py): the line of a kill appears top right on the tick of the death, in every view. The first frame that moves is not a clock: a player who holds an angle does not move, and it put two takes out of twelve half a second off. The shot is seen 0.044 game seconds and 0.122 real seconds before its line appears. - Ten captures in one session crashed gamescope (SIGSEGV on the tenth).
frag down, then film the rest with--reuse: the takes already on disk are kept. - The game's own
startmoviewrites nothing on Linux. Do not try it again. - Playback bar:
demo_ui_mode 0only works on the launch command line, before the demo loads. - Settings CS2 saves to disk (
spec_show_xray,r_show_build_info,engine_no_focus_sleep) are recorded before the run and restored byfrag down. The display mode too: filming launches the game windowed at 1920x1080 and CS2 saves that tocs2_video.txt, so the file is copied before the launch and put back byfrag down.mangohudin the launch options is hidden withMANGOHUD_CONFIG=no_display. - Unfocused cap:
engine_no_focus_sleep 20limits an unfocused game to about 35 frames a second.
frag film 2 --round 9 --clean --scoreboard --lead 10 --tail 6 --takes ~/.local/share/frag/montage
frag sound music ~/.local/share/frag/montage/music.mp3 # sfx and announcer are in montage/audio
frag beats ~/.local/share/frag/montage/music.mp3 # where the beats and the drops are
$EDITOR ~/.local/share/frag/montage/plan.json # start from montage/plan-example.json
frag montage ~/.local/share/frag/montage
--clean leaves only the crosshair and the kill feed on screen. The plan says on which
beat each kill lands and how game time bends around it (warp: output second, game
second). montage.py does the rest: slow motion, punch-in, flash, the kill ladder with
each opponent's rating, the announcer word, the versus card, the scoreboard, the sound mix.
Names and ratings come from the demo.
Two things the montages need that filming does not:
- Sounds: the effects and the announcer are in
montage/audio.frag soundgenerates the music (and the kit again) with ElevenLabs: setELEVENLABS_API_KEY, or copy.env.exampleto~/.config/frag/elevenlabs.envand fill it. It is the only key, and filming needs none. Any other track works too: name itmusic.mp3. - Fonts: a title face, a numbers face and a CJK face for names. Set
FRAG_FONT_TITLE,FRAG_FONT_NUMandFRAG_FONT_CJKto font files; without them,fontconfigpicks the closest installed ones.
frag film 3 --round 11 --clean --victims --lead 10 --tail 6 --takes ~/.local/share/frag/recap/r11
frag film 3 --round 22 --clean --victims --lead 10 --tail 6 --takes ~/.local/share/frag/recap/r22
frag sound music ~/.local/share/frag/recap/music.mp3
frag beats ~/.local/share/frag/recap/music.mp3 # read where the drops really are: the model does not follow the plan
$EDITOR ~/.local/share/frag/recap/recap.json # start from montage/recap-example.json
frag recap ~/.local/share/frag/recap [--solo] [--still 4.95]
--victims films the last seven seconds of each victim through their own eyes (v1.mkv ...).
recap.py stacks two views in a 1080x1920 frame: the player on top, the next victim below, the
same instant. --solo keeps your view alone: a native 1080 square over a blurred fill, the five
names as a ladder under it. recap.json holds only the takes, the captions and the beat of each first kill
(drops, then one kill every every beats); the timeline is derived from the beats. The final
scoreboard is a screenshot (score.png) taken with +showscores after the last round.
frag film 3 --round 11 --full --xray --height 1440 --takes ~/.local/share/frag/aced/r11
frag film 3 --round 22 --full --xray --height 1440 --takes ~/.local/share/frag/aced/r22
frag join ~/Desktop/WIP/CS2/aced.mp4 ~/.local/share/frag/aced/r11/round.mp4 ~/.local/share/frag/aced/r22/round.mp4
--full films from the last second of the freeze time to the win banner, with the whole game
interface (players alive, radar, timer, kill feed); --xray keeps the enemies outlined through
walls, as the game's own replay shows them; no music, no title, nothing added. The sound is the
game's, in stereo: where a step comes from is the point. --reuse keeps a take already on disk,
so deleting real.mkv films the sound again alone.
frag film 3 --round 11 --clean --lead 10 --tail 6 --takes ~/.local/share/frag/uncut/r11
frag sound music ~/.local/share/frag/uncut/music.mp3 150 dnb # seconds, phonk or dnb
frag beats ~/.local/share/frag/uncut/music.mp3 # tempo and loudness over time: where the drops are
$EDITOR ~/.local/share/frag/uncut/uncut.json # start from montage/uncut-example.json
frag uncut ~/.local/share/frag/uncut [--still 63.75]
Nothing is cut inside a round and time is never bent: the music is what gets fitted. uncut.json
says which second of the track (music.sync.music, a drop) lands on which kill; music.tempo
stretches the track by a fraction of a percent and music.cuts drops whole bars (cut in the
breakdown, never inside a drop) so a second drop lands too; the lead of a later round is set so
its first kill falls on it. Additions on screen: a title, the round, 1K to 4K and ACE, the
real scoreboard. No announcer: the Heroes of Newerth words stay in montage and recap.
Exported at 1440p (height) so YouTube serves its better codec.
The music model follows the lengths asked, not the section plan exactly: measure the track
(frag beats), then fit.
Takes weigh gigabytes; they are deleted after a week.
SKILL.md tells a coding agent when to use frag and what not to do with it. For Claude Code:
ln -sfn "$PWD" ~/.claude/skills/frag
MIT. Counter-Strike 2 belongs to Valve; this project is not affiliated with Valve, Leetify or ElevenLabs.