diff --git a/Directory.Build.props b/Directory.Build.props index 9678f29..c9c69cb 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -8,6 +8,17 @@ $(MSBuildThisFileDirectory).artifacts/ $(RepoArtifactsPath)obj/$(MSBuildProjectName)/$(HostBuildFlavor)/ $(RepoArtifactsPath)bin/$(MSBuildProjectName)/$(HostBuildFlavor)/ - $(DefaultItemExcludes);$(RepoArtifactsPath.Replace('\', '/'))** + $(DefaultItemExcludes);.artifacts/**;**/obj/**;**/bin/**;$(RepoArtifactsPath.Replace('\', '/'))** + + + + + + + + + + + diff --git a/src/Fragalytics.RenderWorker/Program.cs b/src/Fragalytics.RenderWorker/Program.cs index 62237ee..877651d 100644 --- a/src/Fragalytics.RenderWorker/Program.cs +++ b/src/Fragalytics.RenderWorker/Program.cs @@ -16,12 +16,12 @@ builder.Services.AddSingleton(); builder.Services.AddSingleton(serviceProvider => { var options = serviceProvider.GetRequiredService>().Value; - if (options.RenderWorker.Backend.Equals("sample-copy", StringComparison.OrdinalIgnoreCase)) + if (options.RenderWorker.Backend.Equals(RenderBackendNames.SampleCopy, StringComparison.OrdinalIgnoreCase)) { return new SampleCopyRenderBackend(serviceProvider.GetRequiredService>()); } - if (options.RenderWorker.Backend.Equals("cs2-scripted", StringComparison.OrdinalIgnoreCase)) + if (options.RenderWorker.Backend.Equals(RenderBackendNames.Cs2Scripted, StringComparison.OrdinalIgnoreCase)) { return new Cs2ScriptedRenderBackend( serviceProvider.GetRequiredService(), diff --git a/src/Fragalytics.RenderWorker/Services/Cs2ScriptedRenderBackend.cs b/src/Fragalytics.RenderWorker/Services/Cs2ScriptedRenderBackend.cs index c1e2938..c155114 100644 --- a/src/Fragalytics.RenderWorker/Services/Cs2ScriptedRenderBackend.cs +++ b/src/Fragalytics.RenderWorker/Services/Cs2ScriptedRenderBackend.cs @@ -18,7 +18,7 @@ public sealed class Cs2ScriptedRenderBackend : IClipRenderBackend _options = options.Value; } - public string Name => "cs2-scripted"; + public string Name => RenderBackendNames.Cs2Scripted; public async Task RenderAsync(ClipJobRecord clipJob, CancellationToken cancellationToken) { @@ -73,9 +73,7 @@ public sealed class Cs2ScriptedRenderBackend : IClipRenderBackend { FilePath = attemptArtifacts.OutputPath, FileName = Path.GetFileName(attemptArtifacts.OutputPath), - ContentType = renderJob.GetSuggestedFileExtension().Equals(".webm", StringComparison.OrdinalIgnoreCase) - ? "video/webm" - : "video/mp4" + ContentType = ClipMediaFormats.InferContentType(renderJob.GetSuggestedFileExtension()) }; } diff --git a/src/Fragalytics.RenderWorker/Services/IClipRenderBackend.cs b/src/Fragalytics.RenderWorker/Services/IClipRenderBackend.cs index 6e2263c..2ef7f84 100644 --- a/src/Fragalytics.RenderWorker/Services/IClipRenderBackend.cs +++ b/src/Fragalytics.RenderWorker/Services/IClipRenderBackend.cs @@ -12,5 +12,5 @@ public sealed class RenderedClipResult { public string FilePath { get; set; } = string.Empty; public string FileName { get; set; } = string.Empty; - public string ContentType { get; set; } = "video/mp4"; + public string ContentType { get; set; } = ClipMediaFormats.Mp4ContentType; } diff --git a/src/Fragalytics.RenderWorker/Services/SampleCopyRenderBackend.cs b/src/Fragalytics.RenderWorker/Services/SampleCopyRenderBackend.cs index 98d9eeb..63587b7 100644 --- a/src/Fragalytics.RenderWorker/Services/SampleCopyRenderBackend.cs +++ b/src/Fragalytics.RenderWorker/Services/SampleCopyRenderBackend.cs @@ -13,7 +13,7 @@ public sealed class SampleCopyRenderBackend : IClipRenderBackend _options = options.Value; } - public string Name => "sample-copy"; + public string Name => RenderBackendNames.SampleCopy; public async Task RenderAsync(ClipJobRecord clipJob, CancellationToken cancellationToken) { @@ -42,7 +42,7 @@ public sealed class SampleCopyRenderBackend : IClipRenderBackend { FilePath = outputPath, FileName = Path.GetFileName(sampleClipPath), - ContentType = extension.Equals(".webm", StringComparison.OrdinalIgnoreCase) ? "video/webm" : "video/mp4" + ContentType = ClipMediaFormats.InferContentType(extension) }; } } diff --git a/src/Fragalytics.Web/FragalyticsOptions.cs b/src/Fragalytics.Web/FragalyticsOptions.cs index 81273cd..82eaeef 100644 --- a/src/Fragalytics.Web/FragalyticsOptions.cs +++ b/src/Fragalytics.Web/FragalyticsOptions.cs @@ -1,5 +1,18 @@ namespace Fragalytics.Web; +public static class ParserModes +{ + public const string Sample = "sample"; + public const string External = "external"; +} + +public static class RenderBackendNames +{ + public const string FutureRenderer = "future-renderer"; + public const string SampleCopy = "sample-copy"; + public const string Cs2Scripted = "cs2-scripted"; +} + public sealed class FragalyticsOptions { public string DataDirectory { get; set; } = "data"; @@ -13,7 +26,7 @@ public sealed class FragalyticsOptions public sealed class ParserOptions { - public string Mode { get; set; } = "sample"; + public string Mode { get; set; } = ParserModes.Sample; public string? ExternalCommand { get; set; } public string? ExternalArguments { get; set; } } @@ -21,7 +34,7 @@ public sealed class ParserOptions public sealed class RenderWorkerOptions { public int PollSeconds { get; set; } = 3; - public string Backend { get; set; } = "sample-copy"; + public string Backend { get; set; } = RenderBackendNames.SampleCopy; public string? SampleClipPath { get; set; } public string TempDirectory { get; set; } = "data/render-temp"; public bool RunContinuously { get; set; } = true; diff --git a/src/Fragalytics.Web/Models/AppState.cs b/src/Fragalytics.Web/Models/AppState.cs index 6837809..3c672b1 100644 --- a/src/Fragalytics.Web/Models/AppState.cs +++ b/src/Fragalytics.Web/Models/AppState.cs @@ -1,5 +1,55 @@ namespace Fragalytics.Web.Models; +public static class DemoStatuses +{ + public const string Uploaded = "uploaded"; + public const string Queued = "queued"; + public const string Processing = "processing"; + public const string Parsed = "parsed"; + public const string Failed = "failed"; +} + +public static class ClipJobStatuses +{ + public const string Pending = "pending"; + public const string Rendering = "rendering"; + public const string Rendered = "rendered"; + public const string Failed = "failed"; +} + +public static class EventTypes +{ + public const string Kill = "kill"; +} + +public static class ClipMediaFormats +{ + public const string Mp4Extension = ".mp4"; + public const string WebMExtension = ".webm"; + public const string Mp4ContentType = "video/mp4"; + public const string WebMContentType = "video/webm"; + + public static readonly HashSet AllowedExtensions = new(StringComparer.OrdinalIgnoreCase) + { + Mp4Extension, + WebMExtension + }; + + public static string InferContentType(string extension) + { + return extension.Equals(WebMExtension, StringComparison.OrdinalIgnoreCase) + ? WebMContentType + : Mp4ContentType; + } + + public static string GetSuggestedFileExtension(string outputFormat) + { + return outputFormat.Equals("webm", StringComparison.OrdinalIgnoreCase) + ? WebMExtension + : Mp4Extension; + } +} + public sealed class AppState { public int NextDemoId { get; set; } = 1; @@ -29,7 +79,7 @@ public sealed class DemoRecord public string OriginalFileName { get; set; } = string.Empty; public string StoredPath { get; set; } = string.Empty; public string ParserBackend { get; set; } = string.Empty; - public string Status { get; set; } = "uploaded"; + public string Status { get; set; } = DemoStatuses.Uploaded; public string? ErrorMessage { get; set; } public DateTime UploadedAtUtc { get; set; } = DateTime.UtcNow; public DateTime? ProcessedAtUtc { get; set; } @@ -76,7 +126,7 @@ public sealed class EventRecord public int Id { get; set; } public int MatchId { get; set; } public int? RoundId { get; set; } - public string EventType { get; set; } = "kill"; + public string EventType { get; set; } = EventTypes.Kill; public int Tick { get; set; } public double? Second { get; set; } public int? ActorPlayerId { get; set; } @@ -117,8 +167,8 @@ public sealed class ClipJobRecord { public int Id { get; set; } public int HighlightId { get; set; } - public string Renderer { get; set; } = "future-renderer"; - public string Status { get; set; } = "pending"; + public string Renderer { get; set; } = RenderBackendNames.FutureRenderer; + public string Status { get; set; } = ClipJobStatuses.Pending; public int StartTick { get; set; } public int EndTick { get; set; } public Dictionary Payload { get; set; } = []; @@ -191,12 +241,7 @@ public sealed class RenderJobContract public string GetSuggestedFileExtension() { - if (string.Equals(OutputFormat, "webm", StringComparison.OrdinalIgnoreCase)) - { - return ".webm"; - } - - return ".mp4"; + return ClipMediaFormats.GetSuggestedFileExtension(OutputFormat); } private static int ReadInt(IReadOnlyDictionary payload, string key) @@ -216,7 +261,7 @@ public sealed class ClipAssetRecord public int ClipJobId { get; set; } public string FileName { get; set; } = string.Empty; public string StoredPath { get; set; } = string.Empty; - public string ContentType { get; set; } = "video/mp4"; + public string ContentType { get; set; } = ClipMediaFormats.Mp4ContentType; public long FileSizeBytes { get; set; } public DateTime CreatedAtUtc { get; set; } = DateTime.UtcNow; } diff --git a/src/Fragalytics.Web/Pages/ClipJobs/Index.cshtml b/src/Fragalytics.Web/Pages/ClipJobs/Index.cshtml index ee9030c..fac7a64 100644 --- a/src/Fragalytics.Web/Pages/ClipJobs/Index.cshtml +++ b/src/Fragalytics.Web/Pages/ClipJobs/Index.cshtml @@ -4,57 +4,78 @@ ViewData["Title"] = "Clip Jobs"; } +
+
+ Render Queue +

Clip Jobs

+

Attach a rendered `.mp4` or `.webm` to any queued highlight. Once attached, playback appears here and across the related match and demo views.

+
+
+
-

Clip Jobs

-

You can manually attach a rendered `.mp4` or `.webm` file to any clip job. Once attached, the clip is playable in the page and on related highlight views.

- - - - @if (Model.ClipJobs.Count == 0) - { - - } - else - { + @if (Model.ClipJobs.Count == 0) + { +
+

No clip jobs created yet.

+

Clip jobs are generated automatically when a parsed match produces highlights above the current threshold.

+
+ } + else + { +
@foreach (var row in Model.ClipJobs) { -
- - - - - - - - - +
+
+
+

#@row.ClipJob.Id

+

@row.ClipJob.Renderer

+
+
+ @row.ClipJob.Status + @row.ClipJob.StartTick - @row.ClipJob.EndTick +
+
+
+
+
+ Attempts + @row.ClipJob.AttemptCount +
+
+ Payload + @string.Join(", ", row.ClipJob.Payload.Select(item => $"{item.Key}={item.Value}")) +
+ @if (!string.IsNullOrWhiteSpace(row.ClipJob.ErrorMessage)) + { +
+ @row.ClipJob.ErrorMessage +
+ } +
+ @Html.AntiForgeryToken() + + + + +
+
+ @if (row.ClipAsset is null) + { +
+ Playback not available +
+ } + else + { + + } +
+
+
} - } - -
IDStatusRendererAttemptsTicksPlaybackUploadPayload
No clip jobs created yet.
#@row.ClipJob.Id - @row.ClipJob.Status - @if (!string.IsNullOrWhiteSpace(row.ClipJob.ErrorMessage)) - { -
- @row.ClipJob.ErrorMessage - } -
@row.ClipJob.Renderer@row.ClipJob.AttemptCount@row.ClipJob.StartTick - @row.ClipJob.EndTick - @if (row.ClipAsset is null) - { - Not available - } - else - { - - } - -
- @Html.AntiForgeryToken() - - - -
-
@string.Join(", ", row.ClipJob.Payload.Select(item => $"{item.Key}={item.Value}"))
+ + }
diff --git a/src/Fragalytics.Web/Pages/Demos/Details.cshtml b/src/Fragalytics.Web/Pages/Demos/Details.cshtml index d96a04e..66f7c9b 100644 --- a/src/Fragalytics.Web/Pages/Demos/Details.cshtml +++ b/src/Fragalytics.Web/Pages/Demos/Details.cshtml @@ -10,13 +10,14 @@ } else { -
+
-
+
+ Demo Record

Demo #@Model.ViewModel.Demo.Id

@Model.ViewModel.Demo.OriginalFileName

-
+
@Model.ViewModel.Demo.Status
@Html.AntiForgeryToken() @@ -40,6 +41,7 @@ else
+ Parsed Output

Parsed Match

@Model.ViewModel.Match.MapName

@@ -54,41 +56,71 @@ else
-

Top Highlights

- - - - @if (Model.ViewModel.Highlights.Count == 0) - { - - } - else - { +
+
+ Highlights +

Top Highlights

+
+ Full Match Breakdown +
+ @if (Model.ViewModel.Highlights.Count == 0) + { +
+

No highlights scored above threshold.

+

Try rescoring the library after adjusting the scoring rules.

+
+ } + else + { +
@foreach (var row in Model.ViewModel.Highlights.Take(10)) { -
- - - - - - - + +
+
+ @foreach (var item in row.Highlight.Explanation) + { +
+ @item.Detail + +@item.Value.ToString("0.#") +
+ } +
+
+ @if (row.ClipAsset is null || row.ClipJob is null) + { +
+ No clip attached yet +
+ } + else + { + + } +
+
+ } - } - -
ScoreRoundMomentTagsClipPlayback
No highlights scored above threshold.
@row.Highlight.Score.ToString("0.0")@(row.Round?.Number.ToString() ?? "-")@(row.Actor?.Name ?? row.Event.KillerNameSnapshot) eliminated @(row.Target?.Name ?? row.Event.VictimNameSnapshot)@string.Join(", ", row.Highlight.Tags)@row.Highlight.StartTick - @row.Highlight.EndTick - @if (row.ClipAsset is null || row.ClipJob is null) +
+
+
+

@row.Highlight.Score.ToString("0.0")

+

@(row.Actor?.Name ?? row.Event.KillerNameSnapshot) eliminated @(row.Target?.Name ?? row.Event.VictimNameSnapshot)

+
+
+ Round @(row.Round?.Number.ToString() ?? "-") + @row.Highlight.StartTick - @row.Highlight.EndTick +
+
+
+ @foreach (var tag in row.Highlight.Tags) { - None + @tag } - else - { - - } -
+
+ }
} } diff --git a/src/Fragalytics.Web/Pages/Index.cshtml b/src/Fragalytics.Web/Pages/Index.cshtml index 5855598..3a3d418 100644 --- a/src/Fragalytics.Web/Pages/Index.cshtml +++ b/src/Fragalytics.Web/Pages/Index.cshtml @@ -5,98 +5,119 @@ }
-
-

Upload a CS2 demo

-

Uploads are stored locally, parsed in the background, scored into explainable highlights, and converted into clip job records for a future render worker.

+
+ Local Workflow +

Turn raw CS2 demos into ranked moments.

+

Uploads are stored locally, parsed in the background, scored into explainable highlights, and converted into clip job records you can review before full render automation exists.

+
+ + @Html.AntiForgeryToken() + + +
+ @Html.AntiForgeryToken() + +
+
+
+
+

Upload Demo

+

Accepts `.dem` files only. New uploads land in the local queue immediately.

+
+ @Html.AntiForgeryToken() + + +
-
- @Html.AntiForgeryToken() - - -
@if (!string.IsNullOrWhiteSpace(Model.StatusMessage)) { -
+

@Model.StatusMessage

} @if (!string.IsNullOrWhiteSpace(Model.ErrorMessage)) { -
+

@Model.ErrorMessage

}
-

@Model.Dashboard.DemoCount

Demos

-

@Model.Dashboard.MatchCount

Matches

-

@Model.Dashboard.HighlightCount

Highlights

-

@Model.Dashboard.ClipJobCount

Clip Jobs

+
Library

@Model.Dashboard.DemoCount

Demos Ingested

+
Parsed

@Model.Dashboard.MatchCount

Match Records

+
Highlights

@Model.Dashboard.HighlightCount

Scored Moments

+
Render Queue

@Model.Dashboard.ClipJobCount

Clip Jobs

-

Recent Demos

-
- @Html.AntiForgeryToken() - -
+
+ Queue +

Recent Demos

+
+ Refresh
- - - - @if (Model.Dashboard.Demos.Count == 0) - { - - } - else - { + @if (Model.Dashboard.Demos.Count == 0) + { +
+

No demos uploaded yet.

+

Use the upload panel to seed the library and start generating match and highlight data.

+
+ } + else + { +
@foreach (var demo in Model.Dashboard.Demos) { -
- - - - - + } - } - -
IDFileStatusUploaded
No demos uploaded yet.
#@demo.Id@demo.OriginalFileName@demo.Status@demo.UploadedAtUtc.ToString("yyyy-MM-dd HH:mm:ss") UTC
+
+ }
-

Recent Clip Jobs

-
- @Html.AntiForgeryToken() - -
+
+ Output +

Recent Clip Jobs

+
+ Open All
- - - - @if (Model.Dashboard.ClipJobs.Count == 0) - { - - } - else - { + @if (Model.Dashboard.ClipJobs.Count == 0) + { +
+

No clip jobs yet.

+

Clip jobs appear automatically whenever highlights clear the scoring threshold.

+
+ } + else + { +
@foreach (var job in Model.Dashboard.ClipJobs) { -
- - - - - +
+
+
+ #@job.Id + @job.Status +
+

@job.Renderer · @job.StartTick - @job.EndTick

+
+
} - } - -
IDStatusTicksRenderer
No clip jobs yet.
#@job.Id@job.Status@job.StartTick - @job.EndTick@job.Renderer
+
+ }
diff --git a/src/Fragalytics.Web/Pages/Matches/Details.cshtml b/src/Fragalytics.Web/Pages/Matches/Details.cshtml index c5af7b9..2da115d 100644 --- a/src/Fragalytics.Web/Pages/Matches/Details.cshtml +++ b/src/Fragalytics.Web/Pages/Matches/Details.cshtml @@ -10,9 +10,10 @@ } else { -
+
-
+
+ Match Breakdown

Match #@Model.ViewModel.Match.Id

@Model.ViewModel.Match.MapName on @(Model.ViewModel.Match.ServerName ?? "Unknown server")

@@ -26,93 +27,131 @@ else
-
+
-

Players

- - - +
+
+ Highlights +

Ranked Moments

+
+
+ @if (Model.ViewModel.Highlights.Count == 0) + { +
+

No highlights available.

+

This match parsed successfully, but no events cleared the current scoring threshold.

+
+ } + else + { +
+ @foreach (var row in Model.ViewModel.Highlights) + { +
+
+
+

@row.Highlight.Score.ToString("0.0")

+

@(row.Actor?.Name ?? row.Event.KillerNameSnapshot) eliminated @(row.Target?.Name ?? row.Event.VictimNameSnapshot)

+
+
+ Round @(row.Round?.Number.ToString() ?? "-") + Tick @row.Event.Tick +
+
+
+ @foreach (var tag in row.Highlight.Tags) + { + @tag + } +
+
+
+ @foreach (var item in row.Highlight.Explanation) + { +
+ @item.Detail + +@item.Value.ToString("0.#") +
+ } +
+
+
+ Clip Window + @row.Highlight.StartTick - @row.Highlight.EndTick +
+
+ Clip Job + @if (row.ClipJob is null) + { + - + } + else + { + #@row.ClipJob.Id · @row.ClipJob.Status + } +
+ @if (row.ClipAsset is null || row.ClipJob is null) + { +
+ No playback asset +
+ } + else + { + + } +
+
+
+ } +
+ } + + +
+
+
+ Roster +

Players

+
+
+
@foreach (var player in Model.ViewModel.Players) { -
- - - - - +
+
+
+ @player.Name + @((player.Side ?? "-")) +
+

@player.SteamId

+

@(player.TeamName ?? "-")

+
+
} - -
NameSteam IDTeamSide
@player.Name@player.SteamId@(player.TeamName ?? "-")@(player.Side ?? "-")
-
-
-

Rounds

- - - - @foreach (var round in Model.ViewModel.Rounds) - { - - - - - - - } - -
#WinnerReasonTicks
@round.Number@(round.WinnerSide ?? "-")@(round.Reason ?? "-")@(round.StartTick ?? 0) - @(round.EndTick ?? 0)
+
-

Highlights

- - - - @if (Model.ViewModel.Highlights.Count == 0) +
+
+ Timeline +

Rounds

+
+
+
+ @foreach (var round in Model.ViewModel.Rounds) { -
+
+ Round @round.Number +

@(round.WinnerSide ?? "-")

+

@(round.Reason ?? "-")

+

@(round.StartTick ?? 0) - @(round.EndTick ?? 0)

+
} - else - { - @foreach (var row in Model.ViewModel.Highlights) - { - - - - - - - - - - } - } - -
ScoreRoundMomentTagsWhyClip JobPlayback
No highlights available.
@row.Highlight.Score.ToString("0.0")@(row.Round?.Number.ToString() ?? "-")@(row.Actor?.Name ?? row.Event.KillerNameSnapshot) eliminated @(row.Target?.Name ?? row.Event.VictimNameSnapshot) at tick @row.Event.Tick@string.Join(", ", row.Highlight.Tags) - @foreach (var item in row.Highlight.Explanation) - { -
@item.Reason (+@item.Value)
- } -
- @if (row.ClipJob is null) - { - - - } - else - { - #@row.ClipJob.Id @row.ClipJob.Status
@row.ClipJob.StartTick - @row.ClipJob.EndTick
- } -
- @if (row.ClipAsset is null || row.ClipJob is null) - { - None - } - else - { - - } -
+
} diff --git a/src/Fragalytics.Web/Pages/Shared/_Layout.cshtml b/src/Fragalytics.Web/Pages/Shared/_Layout.cshtml index 9e29356..0dbdb61 100644 --- a/src/Fragalytics.Web/Pages/Shared/_Layout.cshtml +++ b/src/Fragalytics.Web/Pages/Shared/_Layout.cshtml @@ -7,18 +7,21 @@ - -
- @RenderBody() -
+
+ +
+ @RenderBody() +
+
diff --git a/src/Fragalytics.Web/Services/ClipMediaService.cs b/src/Fragalytics.Web/Services/ClipMediaService.cs index 57eb6c0..bd3a314 100644 --- a/src/Fragalytics.Web/Services/ClipMediaService.cs +++ b/src/Fragalytics.Web/Services/ClipMediaService.cs @@ -6,12 +6,6 @@ namespace Fragalytics.Web.Services; public sealed class ClipMediaService { - private static readonly HashSet AllowedExtensions = new(StringComparer.OrdinalIgnoreCase) - { - ".mp4", - ".webm" - }; - private readonly JsonAppStore _store; private readonly FragalyticsOptions _options; @@ -29,9 +23,9 @@ public sealed class ClipMediaService CancellationToken cancellationToken) { var extension = Path.GetExtension(fileName); - if (!AllowedExtensions.Contains(extension)) + if (!ClipMediaFormats.AllowedExtensions.Contains(extension)) { - throw new InvalidOperationException("Only .mp4 and .webm clip uploads are supported."); + throw new InvalidOperationException($"Only {ClipMediaFormats.Mp4Extension} and {ClipMediaFormats.WebMExtension} clip uploads are supported."); } var clipsDirectory = Path.Combine(Path.GetFullPath(_options.DataDirectory), "clips"); @@ -69,22 +63,16 @@ public sealed class ClipMediaService ClipJobId = clipJobId, FileName = safeFileName, StoredPath = storedPath, - ContentType = string.IsNullOrWhiteSpace(contentType) ? InferContentType(extension) : contentType, + ContentType = string.IsNullOrWhiteSpace(contentType) ? ClipMediaFormats.InferContentType(extension) : contentType, FileSizeBytes = fileSize }; state.ClipAssets.Add(asset); clipJob.ClipAssetId = asset.Id; - clipJob.Status = "rendered"; + clipJob.Status = ClipJobStatuses.Rendered; clipJob.ErrorMessage = null; clipJob.CompletedAtUtc = DateTime.UtcNow; return asset.Id; }, cancellationToken); } - - private static string InferContentType(string extension) => extension.ToLowerInvariant() switch - { - ".webm" => "video/webm", - _ => "video/mp4" - }; } diff --git a/src/Fragalytics.Web/Services/DemoIngestService.cs b/src/Fragalytics.Web/Services/DemoIngestService.cs index f121443..8923e9e 100644 --- a/src/Fragalytics.Web/Services/DemoIngestService.cs +++ b/src/Fragalytics.Web/Services/DemoIngestService.cs @@ -47,7 +47,7 @@ public sealed class DemoIngestService OriginalFileName = safeName, StoredPath = storedPath, ParserBackend = _parserAdapter.Name, - Status = "uploaded", + Status = DemoStatuses.Uploaded, UploadedAtUtc = DateTime.UtcNow }; state.Demos.Add(demo); @@ -60,7 +60,7 @@ public sealed class DemoIngestService var nextDemo = await _store.UpdateAsync(state => { var demo = state.Demos - .Where(candidate => candidate.Status is "uploaded" or "queued") + .Where(candidate => candidate.Status is DemoStatuses.Uploaded or DemoStatuses.Queued) .OrderBy(candidate => candidate.UploadedAtUtc) .FirstOrDefault(); @@ -69,7 +69,7 @@ public sealed class DemoIngestService return null; } - demo.Status = "processing"; + demo.Status = DemoStatuses.Processing; demo.ErrorMessage = null; return new DemoRecord { @@ -152,7 +152,7 @@ public sealed class DemoIngestService Id = state.NextEventId++, MatchId = match.Id, RoundId = round?.Id, - EventType = "kill", + EventType = EventTypes.Kill, Tick = parsedKill.Tick, Second = parsedKill.Second, ActorPlayerId = killer?.Id, @@ -170,7 +170,7 @@ public sealed class DemoIngestService _matchScoringService.RescoreMatch(state, match); - demo.Status = "parsed"; + demo.Status = DemoStatuses.Parsed; demo.ErrorMessage = null; demo.ProcessedAtUtc = DateTime.UtcNow; return 1; @@ -183,7 +183,7 @@ public sealed class DemoIngestService await _store.UpdateAsync(state => { var demo = state.Demos.First(item => item.Id == nextDemo.Id); - demo.Status = "failed"; + demo.Status = DemoStatuses.Failed; demo.ErrorMessage = exception.Message; demo.ProcessedAtUtc = DateTime.UtcNow; return 0; diff --git a/src/Fragalytics.Web/Services/Parser/SampleDemoParserAdapter.cs b/src/Fragalytics.Web/Services/Parser/SampleDemoParserAdapter.cs index 28c9160..f6172d0 100644 --- a/src/Fragalytics.Web/Services/Parser/SampleDemoParserAdapter.cs +++ b/src/Fragalytics.Web/Services/Parser/SampleDemoParserAdapter.cs @@ -6,7 +6,7 @@ namespace Fragalytics.Web.Services.Parser; public sealed class SampleDemoParserAdapter : IDemoParserAdapter { - public string Name => "sample"; + public string Name => ParserModes.Sample; public async Task ParseAsync(string demoPath, CancellationToken cancellationToken) { diff --git a/src/Fragalytics.Web/Services/Render/ClipJobRetryPolicy.cs b/src/Fragalytics.Web/Services/Render/ClipJobRetryPolicy.cs index 38aa02c..2d47610 100644 --- a/src/Fragalytics.Web/Services/Render/ClipJobRetryPolicy.cs +++ b/src/Fragalytics.Web/Services/Render/ClipJobRetryPolicy.cs @@ -11,14 +11,14 @@ public static class ClipJobRetryPolicy public static bool CanStartAttempt(ClipJobRecord job, DateTime utcNow) { - return string.Equals(job.Status, "pending", StringComparison.OrdinalIgnoreCase) + return string.Equals(job.Status, ClipJobStatuses.Pending, StringComparison.OrdinalIgnoreCase) && (!job.RetryAfterUtc.HasValue || job.RetryAfterUtc <= utcNow) && job.AttemptCount < GetEffectiveMaxAttempts(job); } public static void MarkAttemptStarted(ClipJobRecord job, string renderer, DateTime utcNow) { - job.Status = "rendering"; + job.Status = ClipJobStatuses.Rendering; job.Renderer = renderer; job.AttemptCount++; job.ErrorMessage = null; @@ -30,7 +30,7 @@ public static class ClipJobRetryPolicy public static void MarkAttemptFailed(ClipJobRecord job, string errorMessage, DateTime utcNow, TimeSpan retryDelay) { var hasAttemptsRemaining = job.AttemptCount < GetEffectiveMaxAttempts(job); - job.Status = hasAttemptsRemaining ? "pending" : "failed"; + job.Status = hasAttemptsRemaining ? ClipJobStatuses.Pending : ClipJobStatuses.Failed; job.ErrorMessage = errorMessage; job.CompletedAtUtc = utcNow; job.RetryAfterUtc = hasAttemptsRemaining ? utcNow.Add(retryDelay) : null; diff --git a/src/Fragalytics.Web/Services/Render/NoOpRenderWorkerAdapter.cs b/src/Fragalytics.Web/Services/Render/NoOpRenderWorkerAdapter.cs index 45c9277..9484f39 100644 --- a/src/Fragalytics.Web/Services/Render/NoOpRenderWorkerAdapter.cs +++ b/src/Fragalytics.Web/Services/Render/NoOpRenderWorkerAdapter.cs @@ -4,7 +4,7 @@ namespace Fragalytics.Web.Services.Render; public sealed class NoOpRenderWorkerAdapter : IRenderWorkerAdapter { - public string Name => "future-renderer"; + public string Name => RenderBackendNames.FutureRenderer; public void Enqueue(ClipJobRecord clipJob) { diff --git a/src/Fragalytics.Web/Services/Scoring/MatchScoringService.cs b/src/Fragalytics.Web/Services/Scoring/MatchScoringService.cs index f22ee1f..bfafbc0 100644 --- a/src/Fragalytics.Web/Services/Scoring/MatchScoringService.cs +++ b/src/Fragalytics.Web/Services/Scoring/MatchScoringService.cs @@ -62,7 +62,7 @@ public sealed class MatchScoringService var playersById = state.Players.Where(player => player.MatchId == match.Id).ToDictionary(player => player.Id); var roundsById = state.Rounds.Where(round => round.MatchId == match.Id).ToDictionary(round => round.Id); var killEvents = state.Events - .Where(@event => @event.MatchId == match.Id && string.Equals(@event.EventType, "kill", StringComparison.OrdinalIgnoreCase)) + .Where(@event => @event.MatchId == match.Id && string.Equals(@event.EventType, EventTypes.Kill, StringComparison.OrdinalIgnoreCase)) .OrderBy(@event => @event.RoundId) .ThenBy(@event => @event.Tick) .ToList(); @@ -114,7 +114,7 @@ public sealed class MatchScoringService Id = state.NextClipJobId++, HighlightId = highlight.Id, Renderer = _renderWorker.Name, - Status = "pending", + Status = ClipJobStatuses.Pending, StartTick = highlight.StartTick, EndTick = highlight.EndTick, MaxAttempts = Math.Max(1, _options.RenderWorker.MaxAttempts), diff --git a/src/Fragalytics.Web/wwwroot/css/site.css b/src/Fragalytics.Web/wwwroot/css/site.css index c26ca18..7d7bd5b 100644 --- a/src/Fragalytics.Web/wwwroot/css/site.css +++ b/src/Fragalytics.Web/wwwroot/css/site.css @@ -1,32 +1,37 @@ :root { - --bg: #f4f1ea; + --bg: #efe8db; --panel: #fffdf8; + --panel-2: #f8f2e7; --ink: #1f2421; --muted: #5a645e; --accent: #b6482f; --accent-2: #24584f; - --line: #d9d0c3; - --soft: #efe8de; + --accent-3: #7f6a2d; + --line: #d8cec0; + --soft: #efe7da; + --shadow: 0 18px 50px rgba(38, 31, 23, 0.08); } * { box-sizing: border-box; } body { margin: 0; - font-family: Georgia, "Times New Roman", serif; + font-family: "Iowan Old Style", "Palatino Linotype", "Book Antiqua", Georgia, serif; color: var(--ink); background: - radial-gradient(circle at top left, rgba(182, 72, 47, 0.12), transparent 24rem), + radial-gradient(circle at top left, rgba(182, 72, 47, 0.16), transparent 24rem), + radial-gradient(circle at top right, rgba(36, 88, 79, 0.1), transparent 28rem), linear-gradient(180deg, #f8f4ed, var(--bg)); + min-height: 100vh; } a { color: var(--accent-2); text-decoration: none; } a:hover { text-decoration: underline; } -h1, h2, p { margin-top: 0; } +h1, h2, h3, p { margin-top: 0; } table { width: 100%; border-collapse: collapse; } th, td { padding: 0.75rem; border-bottom: 1px solid var(--line); vertical-align: top; text-align: left; } code { font-family: "Courier New", monospace; font-size: 0.9rem; } -.clip-player { width: 240px; max-width: 100%; border-radius: 12px; background: #000; } +.clip-player { width: 100%; max-width: 100%; border-radius: 14px; background: #000; aspect-ratio: 16 / 9; } .site-header, .page { @@ -34,23 +39,48 @@ code { font-family: "Courier New", monospace; font-size: 0.9rem; } margin: 0 auto; } +.site-shell { + padding-bottom: 3rem; +} + .site-header { display: flex; justify-content: space-between; - align-items: end; - gap: 1rem; - padding: 1.5rem 0 1rem; + align-items: flex-start; + gap: 2rem; + padding: 2rem 0 1.25rem; +} + +.site-branding { + max-width: 44rem; } .brand { color: var(--ink); - font-size: 2rem; + display: inline-block; + font-size: clamp(2rem, 4vw, 3rem); font-weight: 700; + letter-spacing: -0.04em; + margin-bottom: 0.35rem; } .nav { display: flex; - gap: 1rem; + gap: 0.75rem; + flex-wrap: wrap; +} + +.nav a { + padding: 0.65rem 1rem; + border: 1px solid rgba(31, 36, 33, 0.08); + border-radius: 999px; + background: rgba(255, 253, 248, 0.8); + backdrop-filter: blur(6px); +} + +.site-tagline { + max-width: 38rem; + font-size: 1rem; } .page { @@ -60,29 +90,78 @@ code { font-family: "Courier New", monospace; font-size: 0.9rem; } .card { background: var(--panel); border: 1px solid var(--line); - border-radius: 18px; - padding: 1.25rem; - box-shadow: 0 8px 30px rgba(31, 36, 33, 0.05); + border-radius: 24px; + padding: 1.4rem; + box-shadow: var(--shadow); } .hero, .split, .stats-grid, -.meta-grid { +.meta-grid, +.highlight-body { display: grid; gap: 1rem; margin-bottom: 1rem; } -.hero { grid-template-columns: 2fr 1fr; } +.hero { grid-template-columns: 1.45fr 0.95fr; align-items: stretch; } .split { grid-template-columns: 1.15fr 1fr; } +.split-heavy { grid-template-columns: 1.5fr 0.9fr; } .stats-grid { grid-template-columns: repeat(4, 1fr); } .meta-grid { grid-template-columns: repeat(4, 1fr); } +.highlight-body { grid-template-columns: 1.2fr 0.8fr; align-items: start; } + +.eyebrow { + display: inline-block; + font-size: 0.73rem; + text-transform: uppercase; + letter-spacing: 0.16em; + color: var(--accent-3); + margin-bottom: 0.45rem; +} .upload-form { display: grid; gap: 0.75rem; - align-content: center; + align-content: start; +} + +.hero-copy h1, +.detail-hero h1 { + font-size: clamp(2.2rem, 5vw, 3.6rem); + line-height: 0.95; + letter-spacing: -0.05em; + margin-bottom: 0.85rem; +} + +.hero-copy p { + max-width: 44rem; + font-size: 1.05rem; +} + +.hero-actions { + display: flex; + gap: 0.75rem; + flex-wrap: wrap; + margin-top: 1.25rem; +} + +.upload-panel { + background: linear-gradient(180deg, rgba(36, 88, 79, 0.06), rgba(182, 72, 47, 0.02)); + border: 1px solid rgba(36, 88, 79, 0.12); + border-radius: 18px; + padding: 1.2rem; +} + +.stat-card { + background: linear-gradient(180deg, var(--panel), var(--panel-2)); +} + +.stat-card h2 { + font-size: clamp(2rem, 4vw, 3rem); + margin-bottom: 0.25rem; + letter-spacing: -0.05em; } .section-head { @@ -90,26 +169,35 @@ code { font-family: "Courier New", monospace; font-size: 0.9rem; } justify-content: space-between; align-items: start; gap: 1rem; + margin-bottom: 1rem; } .muted { color: var(--muted); } +.section-link { + align-self: center; + font-size: 0.95rem; +} + .pill { display: inline-block; - padding: 0.2rem 0.6rem; + padding: 0.28rem 0.72rem; border-radius: 999px; background: var(--soft); border: 1px solid var(--line); - font-size: 0.85rem; + font-size: 0.8rem; + line-height: 1.2; } .status-uploaded, -.status-pending { background: #f5e8b8; } -.status-processing { background: #cfe0ff; } -.status-parsed { background: #c9ead0; } -.status-failed { background: #f1c4bd; } +.status-pending { background: #f4e4a7; } +.status-processing, +.status-rendering { background: #cedef8; } +.status-parsed, +.status-rendered { background: #cbe8d0; } +.status-failed { background: #efcbc1; } button, .button-link, @@ -119,9 +207,10 @@ input[type="submit"] { border-radius: 999px; background: var(--accent); color: white; - padding: 0.7rem 1rem; + padding: 0.75rem 1.05rem; font: inherit; cursor: pointer; + text-decoration: none; } button.secondary { @@ -130,10 +219,16 @@ button.secondary { border: 1px solid var(--line); } +.ghost { + background: transparent; + color: var(--accent-2); + border: 1px solid rgba(36, 88, 79, 0.18); +} + input[type="file"] { width: 100%; border: 1px dashed var(--line); - border-radius: 12px; + border-radius: 14px; background: var(--soft); padding: 1rem; } @@ -147,11 +242,169 @@ dd { margin: 0.3rem 0 0; } +.detail-hero { + background: + radial-gradient(circle at top right, rgba(36, 88, 79, 0.08), transparent 18rem), + linear-gradient(180deg, rgba(255, 253, 248, 0.98), rgba(248, 242, 231, 0.98)); +} + +.detail-hero-actions { + text-align: right; +} + +.stack-list, +.highlight-stack { + display: grid; + gap: 0.9rem; +} + +.list-card, +.highlight-card, +.round-card { + border: 1px solid rgba(31, 36, 33, 0.08); + border-radius: 18px; + background: rgba(255, 253, 248, 0.7); + padding: 1rem; +} + +.list-card-title-row, +.highlight-card-head, +.tag-row { + display: flex; + justify-content: space-between; + align-items: start; + gap: 0.75rem; + flex-wrap: wrap; +} + +.list-card-title { + font-weight: 700; + font-size: 1.05rem; +} + +.empty-state { + border: 1px dashed var(--line); + border-radius: 18px; + padding: 1.25rem; + background: rgba(239, 231, 218, 0.5); +} + +.highlight-card h3, +.round-card h3, +.empty-state h3 { + margin-bottom: 0.35rem; +} + +.highlight-score { + font-size: 0.82rem; + color: var(--accent-3); + text-transform: uppercase; + letter-spacing: 0.16em; + margin-bottom: 0.4rem; +} + +.highlight-meta { + display: grid; + gap: 0.3rem; + justify-items: end; + color: var(--muted); + font-size: 0.92rem; +} + +.tag-row { + margin: 0.85rem 0 1rem; +} + +.tag-chip { + display: inline-flex; + align-items: center; + gap: 0.3rem; + padding: 0.32rem 0.7rem; + border-radius: 999px; + background: rgba(36, 88, 79, 0.08); + color: var(--accent-2); + font-size: 0.84rem; + border: 1px solid rgba(36, 88, 79, 0.12); +} + +.highlight-explainer, +.highlight-side-panel { + display: grid; + gap: 0.7rem; +} + +.explanation-row, +.mini-meta { + display: flex; + justify-content: space-between; + align-items: start; + gap: 1rem; + padding: 0.65rem 0.8rem; + background: var(--panel-2); + border-radius: 14px; +} + +.mini-label { + color: var(--muted); + font-size: 0.8rem; + text-transform: uppercase; + letter-spacing: 0.08em; +} + +.clip-placeholder { + display: grid; + place-items: center; + min-height: 10rem; + border: 1px dashed var(--line); + border-radius: 14px; + background: rgba(31, 36, 33, 0.03); +} + +.compact-list .compact-card { + padding: 0.85rem 0.95rem; +} + +.round-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); + gap: 0.9rem; +} + +.round-ticks { + margin-bottom: 0; + color: var(--accent-2); + font-weight: 600; +} + +.compact-upload { + margin-top: 0.35rem; +} + +.callout { + border-left: 4px solid var(--accent-2); +} + +.callout-success { + border-left-color: var(--accent-2); +} + +.callout-danger { + border-left-color: var(--accent); +} + +.callout-inline { + padding: 0.7rem 0.85rem; + border-radius: 14px; + background: rgba(182, 72, 47, 0.08); +} + @media (max-width: 900px) { .hero, .split, .stats-grid, - .meta-grid { + .meta-grid, + .highlight-body, + .split-heavy { grid-template-columns: 1fr; } @@ -159,4 +412,33 @@ dd { flex-direction: column; align-items: start; } + + .detail-hero-actions { + text-align: left; + } + + .highlight-meta { + justify-items: start; + } +} + +@media (max-width: 640px) { + .site-header, + .page { + width: min(100% - 1rem, 1180px); + } + + .card { + padding: 1.05rem; + border-radius: 18px; + } + + .nav { + width: 100%; + } + + .nav a { + flex: 1 1 auto; + text-align: center; + } } diff --git a/tests/Fragalytics.Web.Tests/Program.cs b/tests/Fragalytics.Web.Tests/Program.cs index b936750..0bba0b9 100644 --- a/tests/Fragalytics.Web.Tests/Program.cs +++ b/tests/Fragalytics.Web.Tests/Program.cs @@ -47,6 +47,42 @@ catch (Exception exception) failures.Add($"Path resolution test failed: {exception.Message}"); } +try +{ + RunRenderJobContractDefaultsTest(); +} +catch (Exception exception) +{ + failures.Add($"Render contract test failed: {exception.Message}"); +} + +try +{ + await RunRescoreReplacementTestAsync(); +} +catch (Exception exception) +{ + failures.Add($"Rescore replacement test failed: {exception.Message}"); +} + +try +{ + await RunParserFailureTestAsync(); +} +catch (Exception exception) +{ + failures.Add($"Parser failure test failed: {exception.Message}"); +} + +try +{ + await RunInvalidClipUploadTestAsync(); +} +catch (Exception exception) +{ + failures.Add($"Invalid clip upload test failed: {exception.Message}"); +} + if (failures.Count > 0) { foreach (var failure in failures) @@ -94,7 +130,7 @@ static void RunScoringTest() [7] = new() { Id = 7, Name = "Alpha", Side = "CT" } }; - var first = new EventRecord { Id = 1, RoundId = 1, Tick = 100, ActorPlayerId = 7, EventType = "kill" }; + var first = new EventRecord { Id = 1, RoundId = 1, Tick = 100, ActorPlayerId = 7, EventType = EventTypes.Kill }; var target = new EventRecord { Id = 2, @@ -102,7 +138,7 @@ static void RunScoringTest() Tick = 450, Second = 38, ActorPlayerId = 7, - EventType = "kill", + EventType = EventTypes.Kill, IsHeadshot = true, KillerAliveCount = 1, EnemyAliveCount = 3 @@ -115,6 +151,10 @@ static void RunScoringTest() Assert(result.Tags.Contains("multikill"), "Expected multikill tag."); Assert(result.Tags.Contains("clutch"), "Expected clutch tag."); Assert(result.Tags.Contains("1vX"), "Expected 1vX tag."); + Assert(result.StartTick == 290, "Expected clip window pre-roll to be applied."); + Assert(result.EndTick == 546, "Expected clip window post-roll to be applied."); + Assert(result.Explanation.Any(item => item.Reason == "headshot"), "Expected score explanation to include headshot."); + Assert(result.Explanation.Any(item => item.Reason == "clutch_1v3"), "Expected score explanation to include clutch weight."); } static async Task RunFlowTestAsync() @@ -146,7 +186,7 @@ static async Task RunFlowTestAsync() var store = new JsonAppStore(options); var scoringProvider = new ScoringRulesProvider(environment); - var renderWorker = new NoOpRenderWorkerAdapter(); + var renderWorker = new RecordingRenderWorkerAdapter(); var scoring = new MatchScoringService(scoringProvider, renderWorker, options); var ingest = new DemoIngestService(store, options, new SampleDemoParserAdapter(), scoring); var clipMedia = new ClipMediaService(store, options); @@ -159,17 +199,18 @@ static async Task RunFlowTestAsync() var state = await store.GetSnapshotAsync(); Assert(state.Demos.Count == 1, "Expected one demo."); - Assert(state.Demos[0].Status == "parsed", "Expected parsed demo status."); + Assert(state.Demos[0].Status == DemoStatuses.Parsed, "Expected parsed demo status."); Assert(state.Matches.Count == 1, "Expected one match."); Assert(state.Highlights.Count > 0, "Expected highlights."); Assert(state.ClipJobs.Count == state.Highlights.Count, "Expected one clip job per highlight."); Assert(state.ClipJobs[0].AttemptCount == 0, "Expected new clip job to have no worker attempts yet."); + Assert(renderWorker.EnqueuedClipJobIds.Count == state.ClipJobs.Count, "Expected every created clip job to be enqueued."); await using var clipPayload = new MemoryStream("fake rendered mp4 bytes"u8.ToArray()); - await clipMedia.AttachClipAssetAsync(state.ClipJobs[0].Id, "sample.mp4", "video/mp4", clipPayload, CancellationToken.None); + await clipMedia.AttachClipAssetAsync(state.ClipJobs[0].Id, $"sample{ClipMediaFormats.Mp4Extension}", ClipMediaFormats.Mp4ContentType, clipPayload, CancellationToken.None); state = await store.GetSnapshotAsync(); Assert(state.ClipAssets.Count == 1, "Expected one clip asset."); - Assert(state.ClipJobs[0].Status == "rendered", "Expected rendered clip status after attach."); + Assert(state.ClipJobs[0].Status == ClipJobStatuses.Rendered, "Expected rendered clip status after attach."); Assert(state.ClipJobs[0].Payload.TryGetValue("version", out var version) && version == RenderJobContract.CurrentVersion, "Expected versioned render payload."); Assert(state.ClipJobs[0].Payload.TryGetValue("demoId", out var demoIdPayload) && demoIdPayload == "1", "Expected demo id in render payload."); Assert(state.ClipJobs[0].Payload.ContainsKey("cameraMode"), "Expected render payload defaults on clip job."); @@ -198,18 +239,18 @@ static void RunRetryFlowTest() { Id = 1, HighlightId = 7, - Renderer = "future-renderer", - Status = "pending", + Renderer = RenderBackendNames.FutureRenderer, + Status = ClipJobStatuses.Pending, StartTick = 100, EndTick = 200, MaxAttempts = 2 }; Assert(ClipJobRetryPolicy.CanStartAttempt(firstJob, DateTime.UtcNow), "Expected pending job to be claimable before any attempts."); - ClipJobRetryPolicy.MarkAttemptStarted(firstJob, "sample-copy", DateTime.UtcNow); + ClipJobRetryPolicy.MarkAttemptStarted(firstJob, RenderBackendNames.SampleCopy, DateTime.UtcNow); ClipJobRetryPolicy.MarkAttemptFailed(firstJob, "Simulated backend failure.", DateTime.UtcNow, TimeSpan.FromSeconds(30)); - Assert(firstJob.Status == "pending", "Expected failed render with retries remaining to return to pending."); + Assert(firstJob.Status == ClipJobStatuses.Pending, "Expected failed render with retries remaining to return to pending."); Assert(firstJob.AttemptCount == 1, "Expected first failed attempt to increment attempt count."); Assert(firstJob.RetryAfterUtc.HasValue, "Expected retry to be scheduled after a failure."); Assert(!string.IsNullOrWhiteSpace(firstJob.ErrorMessage), "Expected failure message to be preserved for retry visibility."); @@ -218,13 +259,13 @@ static void RunRetryFlowTest() firstJob.RetryAfterUtc = DateTime.UtcNow.AddSeconds(-1); Assert(ClipJobRetryPolicy.CanStartAttempt(firstJob, DateTime.UtcNow), "Expected job to become claimable once retry delay passes."); - ClipJobRetryPolicy.MarkAttemptStarted(firstJob, "sample-copy", DateTime.UtcNow); - firstJob.Status = "rendered"; + ClipJobRetryPolicy.MarkAttemptStarted(firstJob, RenderBackendNames.SampleCopy, DateTime.UtcNow); + firstJob.Status = ClipJobStatuses.Rendered; firstJob.ErrorMessage = null; firstJob.RetryAfterUtc = null; firstJob.CompletedAtUtc = DateTime.UtcNow; - Assert(firstJob.Status == "rendered", "Expected retry success to render the clip."); + Assert(firstJob.Status == ClipJobStatuses.Rendered, "Expected retry success to render the clip."); Assert(firstJob.AttemptCount == 2, "Expected successful retry to record the second attempt."); Assert(firstJob.RetryAfterUtc is null, "Expected successful retry to clear the retry schedule."); @@ -232,13 +273,13 @@ static void RunRetryFlowTest() { Id = 2, HighlightId = 8, - Status = "pending", + Status = ClipJobStatuses.Pending, MaxAttempts = 1 }; - ClipJobRetryPolicy.MarkAttemptStarted(terminalJob, "sample-copy", DateTime.UtcNow); + ClipJobRetryPolicy.MarkAttemptStarted(terminalJob, RenderBackendNames.SampleCopy, DateTime.UtcNow); ClipJobRetryPolicy.MarkAttemptFailed(terminalJob, "Permanent failure.", DateTime.UtcNow, TimeSpan.FromSeconds(30)); - Assert(terminalJob.Status == "failed", "Expected job with no retries remaining to become failed."); + Assert(terminalJob.Status == ClipJobStatuses.Failed, "Expected job with no retries remaining to become failed."); Assert(terminalJob.RetryAfterUtc is null, "Expected terminal failure to avoid scheduling another retry."); } @@ -257,6 +298,258 @@ static void RunPathResolutionTest() Assert(resolvedFromBaseDirectory == root, "Expected app base directory traversal to find the project root."); } +static void RunRenderJobContractDefaultsTest() +{ + var minimalPayload = new Dictionary + { + ["demoId"] = "9", + ["matchId"] = "17", + ["highlightId"] = "23", + ["sourceEventId"] = "42", + ["startTick"] = "1500", + ["endTick"] = "1800" + }; + + var contract = RenderJobContract.FromPayload(minimalPayload); + + Assert(contract.Version == RenderJobContract.CurrentVersion, "Expected missing version to fall back to current contract version."); + Assert(contract.CameraMode == RenderJobContract.DefaultCameraMode, "Expected default camera mode."); + Assert(contract.OutputFormat == RenderJobContract.DefaultOutputFormat, "Expected default output format."); + Assert(contract.Resolution == RenderJobContract.DefaultResolution, "Expected default resolution."); + Assert(contract.Fps == RenderJobContract.DefaultFps, "Expected default fps."); + Assert(contract.GetSuggestedFileExtension() == ClipMediaFormats.Mp4Extension, "Expected mp4 extension for default output format."); + + contract.OutputFormat = "webm"; + Assert(contract.GetSuggestedFileExtension() == ClipMediaFormats.WebMExtension, "Expected webm extension override."); +} + +static async Task RunRescoreReplacementTestAsync() +{ + var root = Path.Combine(Path.GetTempPath(), "fragalytics-tests", Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(root); + Directory.CreateDirectory(Path.Combine(root, "data")); + Directory.CreateDirectory(Path.Combine(root, "uploads")); + + var environment = new FakeEnvironment(root); + var options = Options.Create(new FragalyticsOptions + { + DataDirectory = Path.Combine(root, "data"), + UploadDirectory = Path.Combine(root, "uploads"), + AutoStartWorker = false, + RenderWorker = new RenderWorkerOptions + { + MaxAttempts = 3, + RetryDelaySeconds = 15 + } + }); + + var scoringRules = """ + { + "name": "test-rules", + "weights": { + "base_kill": 10, + "headshot": 8, + "opening_kill": 12, + "wallbang": 10, + "smoke_kill": 10, + "round_win": 6, + "multikill_2": 8, + "multikill_3": 18, + "multikill_4": 30, + "multikill_5": 45, + "clutch_1v2": 18, + "clutch_1v3": 28, + "clutch_1v4": 40, + "clutch_1v5": 55, + "impact_last_enemy": 12, + "impact_low_time": 4 + }, + "clip": { + "preTicks": 160, + "postTicks": 96 + }, + "thresholds": { + "highlightScoreMin": 18 + } + } + """; + File.WriteAllText(Path.Combine(root, "scoring-rules.json"), scoringRules); + + var store = new JsonAppStore(options); + var scoringProvider = new ScoringRulesProvider(environment); + var renderWorker = new RecordingRenderWorkerAdapter(); + var parser = new StaticParserAdapter(BuildRescoreParsedDemo()); + var scoring = new MatchScoringService(scoringProvider, renderWorker, options); + var ingest = new DemoIngestService(store, options, parser, scoring); + var clipMedia = new ClipMediaService(store, options); + + await using var payload = new MemoryStream("rescore demo"u8.ToArray()); + var demoId = await ingest.SaveUploadAsync("rescore.dem", payload, CancellationToken.None); + await ingest.ProcessNextAsync(CancellationToken.None); + + var state = await store.GetSnapshotAsync(); + Assert(state.Highlights.Count == 1, "Expected only one above-threshold highlight from the initial parse."); + Assert(state.ClipJobs.Count == 1, "Expected one clip job for one highlight."); + Assert(renderWorker.EnqueuedClipJobIds.Count == 1, "Expected initial highlight to be enqueued once."); + + var initialHighlightId = state.Highlights[0].Id; + var initialClipJobId = state.ClipJobs[0].Id; + + await using var clipPayload = new MemoryStream("first clip bytes"u8.ToArray()); + await clipMedia.AttachClipAssetAsync(initialClipJobId, $"initial{ClipMediaFormats.Mp4Extension}", ClipMediaFormats.Mp4ContentType, clipPayload, CancellationToken.None); + + state = await store.GetSnapshotAsync(); + Assert(state.ClipAssets.Count == 1, "Expected one attached clip asset before rescoring."); + + File.WriteAllText( + Path.Combine(root, "scoring-rules.json"), + scoringRules.Replace("\"highlightScoreMin\": 18", "\"highlightScoreMin\": 8", StringComparison.Ordinal)); + + var rescoredCount = await ingest.RescoreAllAsync(CancellationToken.None); + Assert(rescoredCount == 2, "Expected rescoring to create two highlights after lowering the threshold."); + + state = await store.GetSnapshotAsync(); + Assert(state.Highlights.Count == 2, "Expected rescoring to replace old highlights with the new set."); + Assert(state.Highlights.All(item => item.Id != initialHighlightId), "Expected rescoring to replace prior highlight records."); + Assert(state.ClipJobs.Count == 2, "Expected clip jobs to be regenerated on rescoring."); + Assert(state.ClipJobs.All(item => item.Id != initialClipJobId), "Expected prior clip jobs to be discarded on rescoring."); + Assert(state.ClipAssets.Count == 0, "Expected clip assets tied to old clip jobs to be removed during rescoring."); + Assert(renderWorker.EnqueuedClipJobIds.Count == 3, "Expected rescoring to enqueue the replacement clip jobs."); + Assert(state.ClipJobs.All(item => item.MaxAttempts == 3), "Expected regenerated clip jobs to preserve configured retry policy."); + Assert(state.ScoringProfiles.Count == 2, "Expected a new scoring profile version after rules changed."); + Assert(state.ScoringProfiles.Count(item => item.IsActive) == 1, "Expected only one active scoring profile."); + + var rescoredHighlightsByEvent = state.Highlights.ToDictionary(item => item.SourceEventId); + Assert(rescoredHighlightsByEvent.Count == 2, "Expected each source event to map to one highlight after rescoring."); + Assert(rescoredHighlightsByEvent.Values.Any(item => item.Score < 18), "Expected rescoring to include a previously filtered low-score highlight."); + + var regeneratedPayload = state.ClipJobs + .Select(item => RenderJobContract.FromPayload(item.Payload)) + .ToList(); + Assert(regeneratedPayload.All(item => item.DemoId == demoId), "Expected regenerated payloads to retain the demo id."); + Assert(regeneratedPayload.All(item => item.OutputFormat == RenderJobContract.DefaultOutputFormat), "Expected regenerated payloads to keep default render settings."); +} + +static async Task RunParserFailureTestAsync() +{ + var root = Path.Combine(Path.GetTempPath(), "fragalytics-tests", Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(root); + Directory.CreateDirectory(Path.Combine(root, "data")); + Directory.CreateDirectory(Path.Combine(root, "uploads")); + + var environment = new FakeEnvironment(root); + var options = Options.Create(new FragalyticsOptions + { + DataDirectory = Path.Combine(root, "data"), + UploadDirectory = Path.Combine(root, "uploads"), + AutoStartWorker = false + }); + + var repoRoot = FindRepoRoot(); + File.Copy( + Path.Combine(repoRoot, "src", "Fragalytics.Web", "scoring-rules.json"), + Path.Combine(root, "scoring-rules.json"), + overwrite: true); + + var store = new JsonAppStore(options); + var scoringProvider = new ScoringRulesProvider(environment); + var scoring = new MatchScoringService(scoringProvider, new RecordingRenderWorkerAdapter(), options); + var ingest = new DemoIngestService(store, options, new ThrowingParserAdapter("Simulated parser failure."), scoring); + + await using var payload = new MemoryStream("broken demo"u8.ToArray()); + var demoId = await ingest.SaveUploadAsync("broken.dem", payload, CancellationToken.None); + + var processed = await ingest.ProcessNextAsync(CancellationToken.None); + Assert(processed == 0, "Expected failed parse to report no processed demo."); + + var state = await store.GetSnapshotAsync(); + Assert(state.Demos.Count == 1, "Expected failed parse to retain the demo record."); + Assert(state.Demos[0].Id == demoId, "Expected the failed demo to remain addressable."); + Assert(state.Demos[0].Status == DemoStatuses.Failed, "Expected parser failure to mark the demo as failed."); + Assert(state.Demos[0].ErrorMessage == "Simulated parser failure.", "Expected parser failure message to be recorded."); + Assert(state.Demos[0].ProcessedAtUtc.HasValue, "Expected failed parser attempt to stamp processed time."); + Assert(state.Matches.Count == 0, "Expected failed parser attempt to avoid creating a match."); + Assert(state.Events.Count == 0, "Expected failed parser attempt to avoid creating events."); + Assert(state.Highlights.Count == 0, "Expected failed parser attempt to avoid creating highlights."); + Assert(state.ClipJobs.Count == 0, "Expected failed parser attempt to avoid creating clip jobs."); +} + +static async Task RunInvalidClipUploadTestAsync() +{ + var root = Path.Combine(Path.GetTempPath(), "fragalytics-tests", Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(root); + Directory.CreateDirectory(Path.Combine(root, "data")); + Directory.CreateDirectory(Path.Combine(root, "uploads")); + + var environment = new FakeEnvironment(root); + var options = Options.Create(new FragalyticsOptions + { + DataDirectory = Path.Combine(root, "data"), + UploadDirectory = Path.Combine(root, "uploads"), + AutoStartWorker = false + }); + + var repoRoot = FindRepoRoot(); + File.Copy( + Path.Combine(repoRoot, "src", "Fragalytics.Web", "scoring-rules.json"), + Path.Combine(root, "scoring-rules.json"), + overwrite: true); + + var store = new JsonAppStore(options); + var scoringProvider = new ScoringRulesProvider(environment); + var scoring = new MatchScoringService(scoringProvider, new RecordingRenderWorkerAdapter(), options); + var ingest = new DemoIngestService(store, options, new SampleDemoParserAdapter(), scoring); + var clipMedia = new ClipMediaService(store, options); + + await using var payload = new MemoryStream("valid demo"u8.ToArray()); + await ingest.SaveUploadAsync("valid.dem", payload, CancellationToken.None); + await ingest.ProcessNextAsync(CancellationToken.None); + + var state = await store.GetSnapshotAsync(); + Assert(state.ClipJobs.Count > 0, "Expected parsed demo to create a clip job for invalid upload test."); + var clipJobId = state.ClipJobs[0].Id; + + await using var invalidClip = new MemoryStream("not a video"u8.ToArray()); + var exception = await AssertThrowsAsync( + () => clipMedia.AttachClipAssetAsync(clipJobId, "invalid.avi", "video/x-msvideo", invalidClip, CancellationToken.None), + "Expected unsupported clip extension to be rejected."); + Assert(exception.Message.Contains(ClipMediaFormats.Mp4Extension, StringComparison.Ordinal), "Expected invalid clip upload message to mention mp4 support."); + Assert(exception.Message.Contains(ClipMediaFormats.WebMExtension, StringComparison.Ordinal), "Expected invalid clip upload message to mention webm support."); + + state = await store.GetSnapshotAsync(); + Assert(state.ClipAssets.Count == 0, "Expected invalid clip upload to avoid creating clip assets."); + Assert(state.ClipJobs[0].Id == clipJobId, "Expected invalid clip upload to leave the same clip job in place."); + Assert(state.ClipJobs[0].Status == ClipJobStatuses.Pending, "Expected invalid clip upload to avoid changing clip job status."); + Assert(state.ClipJobs[0].ClipAssetId is null, "Expected invalid clip upload to avoid linking a clip asset."); +} + +static ParsedDemo BuildRescoreParsedDemo() +{ + return new ParsedDemo + { + MapName = "test_rescore_map", + ServerName = "Deterministic Test Parser", + TickRate = 64, + TeamCtName = "Counter-Terrorists", + TeamTName = "Terrorists", + Players = + [ + new() { SteamId = "1001", Name = "Alpha", TeamName = "Counter-Terrorists", Side = "CT" }, + new() { SteamId = "1002", Name = "Bravo", TeamName = "Counter-Terrorists", Side = "CT" }, + new() { SteamId = "1003", Name = "Charlie", TeamName = "Terrorists", Side = "T" } + ], + Rounds = + [ + new() { Number = 1, StartTick = 0, EndTick = 600, WinnerSide = "CT", WinnerTeamName = "Counter-Terrorists", Reason = "elimination" } + ], + KillEvents = + [ + new() { Tick = 120, Second = 5.0, RoundNumber = 1, KillerSteamId = "1001", KillerName = "Alpha", VictimSteamId = "1003", VictimName = "Charlie", Weapon = "m4a1", IsHeadshot = true, KillerAliveCount = 2, EnemyAliveCount = 2 }, + new() { Tick = 280, Second = 18.0, RoundNumber = 1, KillerSteamId = "1002", KillerName = "Bravo", VictimSteamId = "1003", VictimName = "Charlie", Weapon = "famas", KillerAliveCount = 2, EnemyAliveCount = 2 } + ] + }; +} + static string FindRepoRoot() { var current = AppContext.BaseDirectory; @@ -288,6 +581,21 @@ static void Assert(bool condition, string message) } } +static async Task AssertThrowsAsync(Func action, string message) + where TException : Exception +{ + try + { + await action(); + } + catch (TException exception) + { + return exception; + } + + throw new InvalidOperationException(message); +} + internal sealed class FakeEnvironment : IWebHostEnvironment { public FakeEnvironment(string rootPath) @@ -307,3 +615,48 @@ internal sealed class FakeEnvironment : IWebHostEnvironment public string ContentRootPath { get; set; } public IFileProvider ContentRootFileProvider { get; set; } } + +internal sealed class RecordingRenderWorkerAdapter : IRenderWorkerAdapter +{ + public string Name => "recording-renderer"; + public List EnqueuedClipJobIds { get; } = []; + + public void Enqueue(ClipJobRecord clipJob) + { + EnqueuedClipJobIds.Add(clipJob.Id); + } +} + +internal sealed class StaticParserAdapter : IDemoParserAdapter +{ + private readonly ParsedDemo _parsedDemo; + + public StaticParserAdapter(ParsedDemo parsedDemo) + { + _parsedDemo = parsedDemo; + } + + public string Name => "static-test"; + + public Task ParseAsync(string demoPath, CancellationToken cancellationToken) + { + return Task.FromResult(_parsedDemo); + } +} + +internal sealed class ThrowingParserAdapter : IDemoParserAdapter +{ + private readonly string _message; + + public ThrowingParserAdapter(string message) + { + _message = message; + } + + public string Name => "throwing-test"; + + public Task ParseAsync(string demoPath, CancellationToken cancellationToken) + { + throw new InvalidOperationException(_message); + } +}