Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -21,3 +21,5 @@ logs/
docker-compose.override.yml

*.exe

cs/
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -161,7 +161,7 @@ Inside the VM install:

- GPU drivers
- CS2
- [HLAE](https://github.com/advancedfx/advancedfx)
- [HLAE](https://github.com/advancedfx/advancedfx) (full version + ffmpeg (regardless if already have ffmpeg locally))

Make sure to add both the CS2 and HLAE executable to the system wide Windows PATH.

Expand Down
1 change: 1 addition & 0 deletions config/development.yml
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,7 @@ recorder:
interval_s: 10
dummy_data: true
hlae_path: "C:\\Program Files (x86)\\HLAE"
ffmpeg_path: "C:\\Program Files (x86)\\HLAE FFMPEG"
cs2_path: "C:\\Program Files (x86)\\Steam\\SteamApps\\common\\Counter-Strike Global Offensive"

logger:
Expand Down
1 change: 1 addition & 0 deletions config/production.yml
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,7 @@ recorder:
interval_s: 60
dummy_data: false
hlae_path: "C:\\Program Files (x86)\\HLAE"
ffmpeg_path: "C:\\Program Files (x86)\\HLAE FFMPEG"
cs2_path: "C:\\Program Files (x86)\\Steam\\SteamApps\\common\\Counter-Strike Global Offensive"

logger:
Expand Down
7 changes: 7 additions & 0 deletions db/queries/highlight.sql
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,13 @@ FROM highlights
WHERE demo_id = $1
ORDER BY created_at;

-- name: HighlightGetByDemoPopulated :many
SELECT sqlc.embed(h), sqlc.embed(s)
FROM highlights h
LEFT JOIN highlight_segments s ON s.highlight_id = h.id
WHERE demo_id = $1
ORDER BY h.created_at, s.start_tick;

-- name: HighlightGetByDemos :many
SELECT *
FROM highlights
Expand Down
10 changes: 4 additions & 6 deletions docker/recorder-dev-oem/install.bat
Original file line number Diff line number Diff line change
Expand Up @@ -7,17 +7,15 @@ mkdir C:\Fragtape\scripts 2>nul
mkdir C:\Fragtape\logs 2>nul

copy /Y C:\OEM\watch.ps1 C:\Fragtape\scripts\watch.ps1 >nul
copy /Y C:\OEM\startup.ps1 C:\Fragtape\scripts\startup.ps1 >nul

schtasks /Delete /F /TN "FragtapeRecorderDev" >nul 2>&1

schtasks /Create /F ^
/TN "FragtapeRecorderDev" ^
/SC ONSTART ^
/RU "SYSTEM" ^
/SC ONLOGON ^
/RU "Fragtape" ^
/RL HIGHEST ^
/TR "powershell.exe -NoProfile -ExecutionPolicy Bypass -File C:\Fragtape\scripts\watch.ps1" >nul

schtasks /Run /TN "FragtapeRecorderDev" >nul 2>&1
/TR "powershell.exe -NoProfile -ExecutionPolicy Bypass -File C:\Fragtape\scripts\startup.ps1"

endlocal

7 changes: 7 additions & 0 deletions docker/recorder-dev-oem/startup.ps1
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
Start-Sleep -Seconds 10

& "$env:SystemRoot\System32\DisplaySwitch.exe" /external

Start-Sleep -Seconds 2

Start-Process "powershell.exe" -ArgumentList "-NoProfile -ExecutionPolicy Bypass -File C:\Fragtape\scripts\watch.ps1"
25 changes: 25 additions & 0 deletions internal/database/repository/highlight.go
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,31 @@ func (h *Highlight) GetByDemo(ctx context.Context, demoID int) ([]*model.Highlig
return utils.SliceMap(highlights, model.HighlightModel), nil
}

func (h *Highlight) GetByDemoPopulated(ctx context.Context, demoID int) ([]*model.Highlight, error) {
highlights, err := h.repo.queries(ctx).HighlightGetByDemoPopulated(ctx, int32(demoID))
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
return nil, nil
}
return nil, fmt.Errorf("get highlights by demo %d | %w", demoID, err)
}

highlightMap := make(map[int]*model.Highlight)

for _, h := range highlights {
highlight, ok := highlightMap[int(h.Highlight.ID)]
if !ok {
highlight = model.HighlightModel(h.Highlight)
highlight.Segments = []model.HighlightSegment{}
}

highlight.Segments = append(highlight.Segments, *model.HighlightSegmentModel(h.HighlightSegment))
highlightMap[highlight.ID] = highlight
}

return utils.MapValues(highlightMap), nil
}

func (h *Highlight) GetByDemos(ctx context.Context, demoIDs []int) ([]*model.Highlight, error) {
highlights, err := h.repo.queries(ctx).HighlightGetByDemos(ctx, utils.SliceMap(demoIDs, func(id int) int32 { return int32(id) }))
if err != nil {
Expand Down
32 changes: 13 additions & 19 deletions internal/recorder/capture/capture.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,14 @@ package capture

import (
"context"
"fmt"

"github.com/topvennie/fragtape/internal/database/model"
"github.com/topvennie/fragtape/internal/database/repository"
"github.com/topvennie/fragtape/internal/recorder/capture/dummy"
"github.com/topvennie/fragtape/internal/recorder/capture/hlae"
"github.com/topvennie/fragtape/pkg/config"
"github.com/topvennie/fragtape/pkg/utils"
)

type Capturer struct {
Expand All @@ -17,8 +19,8 @@ type Capturer struct {

dummy bool

cDummy *dummy.Dummy
cHLAE *hlae.Hlae
captureDummy *dummy.Dummy
captureHLAE *hlae.Hlae
}

func New(repo repository.Repository) (*Capturer, error) {
Expand All @@ -33,46 +35,38 @@ func New(repo repository.Repository) (*Capturer, error) {
if err != nil {
return nil, err
}
capturer.cDummy = cDummy
capturer.captureDummy = cDummy
} else {
cHLAE, err := hlae.New(repo)
if err != nil {
return nil, err
}
capturer.cHLAE = cHLAE
capturer.captureHLAE = cHLAE
}

return capturer, nil
}

func (c *Capturer) Capture(ctx context.Context, demo model.Demo) error {
highlightsAll, err := c.highlight.GetByDemo(ctx, demo.ID)
highlights, err := c.highlight.GetByDemoPopulated(ctx, demo.ID)
if err != nil {
return err
}

// If the recorder part of the pipeline failed then it might have already created some highlights
highlights := []model.Highlight{}
for _, h := range highlightsAll {
if h.FileID == "" {
highlights = append(highlights, *h)
}
}
highlights = utils.SliceFilter(highlights, func(h *model.Highlight) bool { return h.FileID == "" })

if len(highlights) == 0 {
// No highlights
return nil
}

if c.dummy {
err = c.cDummy.Capture(ctx, highlights)
} else {
err = c.cHLAE.Capture(ctx, demo, highlights)
if !utils.SliceAll(highlights, func(h *model.Highlight) bool { return len(h.Segments) > 0 }) {
return fmt.Errorf("demo %d has a highlight without segments", demo.ID)
}

if err != nil {
return err
if c.dummy {
return c.captureDummy.Capture(ctx, utils.SliceDereference(highlights))
}

return nil
return c.captureHLAE.Capture(ctx, demo, utils.SliceDereference(highlights))
}
165 changes: 147 additions & 18 deletions internal/recorder/capture/hlae/capture.go
Original file line number Diff line number Diff line change
@@ -1,13 +1,19 @@
package hlae

import (
"bytes"
"context"
_ "embed"
"encoding/json"
"fmt"
"os"
"path/filepath"
"slices"
"text/template"

"github.com/topvennie/fragtape/internal/database/model"
"github.com/topvennie/fragtape/pkg/storage"
"github.com/topvennie/fragtape/pkg/utils"
)

func (h *Hlae) Capture(ctx context.Context, demo model.Demo, highlights []model.Highlight) error {
Expand All @@ -17,40 +23,163 @@ func (h *Hlae) Capture(ctx context.Context, demo model.Demo, highlights []model.
}

// Save demo file somewhere accessible to CS2
demoPath := filepath.Join(h.cs2Demo(), fmt.Sprintf("%d.dem", demo.ID))
if err := os.WriteFile(demoPath, data, 0o644); err != nil {
if err := os.WriteFile(h.cs2Demo(demo), data, 0o644); err != nil {
return fmt.Errorf("failed to write demo file %w", err)
}

// Create script
if err := h.buildScript(ctx, h.hlaeScript(demo), demo, highlights); err != nil {
return fmt.Errorf("failed to build script %w", err)
}

// Create cfg
cfgPath := filepath.Join(h.cs2Cfg(), fmt.Sprintf("%d.cfg", demo.ID))
if err := h.buildCfg(cfgPath, demoPath); err != nil {
if err := h.buildCfg(h.cs2Cfg(demo), demo); err != nil {
return fmt.Errorf("failed to build cfg %w", err)
}

// Cleanup
// TODO: Uncomment
// defer func() {
// _ = os.Remove(demoPath)
// _ = os.Remove(cfgPath)
// _ = os.Remove(h.cs2Demo(demo))
// _ = os.Remove(h.hlaeScript(demo))
// _ = os.Remove(h.cs2Cfg(demo))
// }()

return h.launch(ctx, cfgPath)
if err := h.launch(ctx, demo); err != nil {
return fmt.Errorf("launch cs2 %w", err)
}

return h.convert(ctx, demo, highlights)
}

//go:embed script.js.tmpl
var scriptTemplate string

type scriptData struct {
RecordingDir string
DemoID int
Players []player
PlayersJSON string
}

type player struct {
ID int `json:"id"`
SteamID int64 `json:"steamId"`
Highlights []highlight `json:"highlights"`
}

type highlight struct {
ID int `json:"id"`
Segments []segment `json:"segments"`
}

type segment struct {
Start int `json:"start"`
End int `json:"end"`
}

func (h *Hlae) buildScript(ctx context.Context, scriptPath string, demo model.Demo, highlights []model.Highlight) error {
users, err := h.user.GetByIDs(ctx, utils.SliceUnique(utils.SliceMap(highlights, func(h model.Highlight) int { return h.UserID })))
if err != nil {
return err
}

// Only keep the highlights with segments
// In theorie it shouldn't be possible to have a highlight without segments
highlights = utils.SliceFilter(highlights, func(h model.Highlight) bool { return len(h.Segments) > 0 })

// Sort all highlights and segments
for _, h := range highlights {
slices.SortFunc(h.Segments, func(a, b model.HighlightSegment) int { return a.StartTick - b.StartTick })
}
slices.SortFunc(highlights, func(a, b model.Highlight) int { return a.Segments[0].StartTick - b.Segments[0].StartTick })

tmpl, err := template.New("script").Parse(scriptTemplate)
if err != nil {
return fmt.Errorf("parse script template %w", err)
}

playerMap := make(map[int]player)

for _, h := range highlights {
userIdx := slices.IndexFunc(users, func(u *model.User) bool { return u.ID == h.UserID })
if userIdx == -1 {
continue
}

pl, ok := playerMap[h.UserID]

if !ok {
pl = player{
ID: h.UserID,
SteamID: users[userIdx].UID,
Highlights: []highlight{},
}
}

pl.Highlights = append(pl.Highlights, highlight{
ID: h.ID,
Segments: utils.SliceMap(h.Segments, func(s model.HighlightSegment) segment {
return segment{
Start: s.StartTick,
End: s.EndTick,
}
}),
})

playerMap[h.UserID] = pl
}

recordingPath := h.cs2Video()
recordingPath = filepath.ToSlash(recordingPath)

data := scriptData{
RecordingDir: recordingPath,
DemoID: demo.ID,
Players: utils.MapValues(playerMap),
}

playersJSON, err := json.Marshal(data.Players)
if err != nil {
return fmt.Errorf("marshal players data %+v | %w", data, err)
}
data.PlayersJSON = string(playersJSON)

var out bytes.Buffer
if err := tmpl.Execute(&out, data); err != nil {
return fmt.Errorf("execute script template %w", err)
}

if err := os.WriteFile(scriptPath, out.Bytes(), 0o644); err != nil {
return fmt.Errorf("failed to write script file %w", err)
}

return nil
}

//go:embed config.cfg.tmpl
var configTemplate string

type configData struct {
DemoID int
}

func (h *Hlae) buildCfg(cfgPath, demoPath string) error {
demoRel, _ := filepath.Rel(h.cs2Dir(), demoPath)
demoRel = filepath.ToSlash(demoRel)
func (h *Hlae) buildCfg(cfgPath string, demo model.Demo) error {
tmpl, err := template.New("cfg").Parse(configTemplate)
if err != nil {
return fmt.Errorf("parse config template %w", err)
}

data := configData{
DemoID: demo.ID,
}

cfg := fmt.Sprintf(
`
echo "[Fragtape] loading demo"
playdemo %s
`,
demoRel,
)
var out bytes.Buffer
if err := tmpl.Execute(&out, data); err != nil {
return fmt.Errorf("failed to write config file %w", err)
}

if err := os.WriteFile(cfgPath, []byte(cfg), 0o644); err != nil {
if err := os.WriteFile(cfgPath, out.Bytes(), 0o644); err != nil {
return fmt.Errorf("failed to write cfg file %w", err)
}

Expand Down
4 changes: 4 additions & 0 deletions internal/recorder/capture/hlae/config.cfg.tmpl
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
echo ""
echo "[Fragtape] Loading script"
echo ""
mirv_script_load fragtape/{{ .DemoID }}.js
Loading
Loading