This commit is contained in:
Zeeshaun
2026-04-21 15:57:09 +00:00
parent c52bd0c50c
commit 6b3408a1df
20 changed files with 1136 additions and 330 deletions
+12 -1
View File
@@ -8,6 +8,17 @@
<RepoArtifactsPath Condition="'$(RepoArtifactsPath)' == ''">$(MSBuildThisFileDirectory).artifacts/</RepoArtifactsPath>
<BaseIntermediateOutputPath Condition="'$(BaseIntermediateOutputPath)' == ''">$(RepoArtifactsPath)obj/$(MSBuildProjectName)/$(HostBuildFlavor)/</BaseIntermediateOutputPath>
<BaseOutputPath Condition="'$(BaseOutputPath)' == ''">$(RepoArtifactsPath)bin/$(MSBuildProjectName)/$(HostBuildFlavor)/</BaseOutputPath>
<DefaultItemExcludes>$(DefaultItemExcludes);$(RepoArtifactsPath.Replace('\', '/'))**</DefaultItemExcludes>
<DefaultItemExcludes>$(DefaultItemExcludes);.artifacts/**;**/obj/**;**/bin/**;$(RepoArtifactsPath.Replace('\', '/'))**</DefaultItemExcludes>
</PropertyGroup>
<ItemGroup>
<Compile Remove=".artifacts/**/*.cs" />
<Compile Remove="**/obj/**/*.cs" />
<Content Remove=".artifacts/**" />
<Content Remove="**/obj/**;**/bin/**" />
<EmbeddedResource Remove=".artifacts/**" />
<EmbeddedResource Remove="**/obj/**;**/bin/**" />
<None Remove=".artifacts/**" />
<None Remove="**/obj/**;**/bin/**" />
</ItemGroup>
</Project>
+2 -2
View File
@@ -16,12 +16,12 @@ builder.Services.AddSingleton<ClipMediaService>();
builder.Services.AddSingleton<IClipRenderBackend>(serviceProvider =>
{
var options = serviceProvider.GetRequiredService<IOptions<FragalyticsOptions>>().Value;
if (options.RenderWorker.Backend.Equals("sample-copy", StringComparison.OrdinalIgnoreCase))
if (options.RenderWorker.Backend.Equals(RenderBackendNames.SampleCopy, StringComparison.OrdinalIgnoreCase))
{
return new SampleCopyRenderBackend(serviceProvider.GetRequiredService<IOptions<FragalyticsOptions>>());
}
if (options.RenderWorker.Backend.Equals("cs2-scripted", StringComparison.OrdinalIgnoreCase))
if (options.RenderWorker.Backend.Equals(RenderBackendNames.Cs2Scripted, StringComparison.OrdinalIgnoreCase))
{
return new Cs2ScriptedRenderBackend(
serviceProvider.GetRequiredService<JsonAppStore>(),
@@ -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<RenderedClipResult> 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())
};
}
@@ -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;
}
@@ -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<RenderedClipResult> 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)
};
}
}
+15 -2
View File
@@ -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;
+56 -11
View File
@@ -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<string> 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<string, string> 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<string, string> 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;
}
+50 -29
View File
@@ -4,37 +4,67 @@
ViewData["Title"] = "Clip Jobs";
}
<section class="card">
<section class="card detail-hero">
<div class="detail-hero-copy">
<span class="eyebrow">Render Queue</span>
<h1>Clip Jobs</h1>
<p class="muted">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.</p>
<table>
<thead><tr><th>ID</th><th>Status</th><th>Renderer</th><th>Attempts</th><th>Ticks</th><th>Playback</th><th>Upload</th><th>Payload</th></tr></thead>
<tbody>
<p class="muted">Attach a rendered `.mp4` or `.webm` to any queued highlight. Once attached, playback appears here and across the related match and demo views.</p>
</div>
</section>
<section class="card">
@if (Model.ClipJobs.Count == 0)
{
<tr><td colspan="8" class="muted">No clip jobs created yet.</td></tr>
<div class="empty-state">
<h3>No clip jobs created yet.</h3>
<p class="muted">Clip jobs are generated automatically when a parsed match produces highlights above the current threshold.</p>
</div>
}
else
{
<div class="highlight-stack">
@foreach (var row in Model.ClipJobs)
{
<tr>
<td>#@row.ClipJob.Id</td>
<td>
<article class="highlight-card">
<div class="highlight-card-head">
<div>
<p class="highlight-score">#@row.ClipJob.Id</p>
<h3>@row.ClipJob.Renderer</h3>
</div>
<div class="highlight-meta">
<span class="pill [email protected]">@row.ClipJob.Status</span>
<span>@row.ClipJob.StartTick - @row.ClipJob.EndTick</span>
</div>
</div>
<div class="highlight-body">
<div class="highlight-explainer">
<div class="explanation-row">
<span>Attempts</span>
<strong>@row.ClipJob.AttemptCount</strong>
</div>
<div class="explanation-row">
<span>Payload</span>
<code>@string.Join(", ", row.ClipJob.Payload.Select(item => $"{item.Key}={item.Value}"))</code>
</div>
@if (!string.IsNullOrWhiteSpace(row.ClipJob.ErrorMessage))
{
<br />
<div class="callout-inline">
<span class="muted">@row.ClipJob.ErrorMessage</span>
</div>
}
</td>
<td>@row.ClipJob.Renderer</td>
<td>@row.ClipJob.AttemptCount</td>
<td>@row.ClipJob.StartTick - @row.ClipJob.EndTick</td>
<td>
<form class="upload-form compact-upload" method="post" enctype="multipart/form-data" asp-page-handler="UploadClip">
@Html.AntiForgeryToken()
<input type="hidden" name="clipJobId" value="@row.ClipJob.Id" />
<input type="file" name="clipUpload" accept=".mp4,.webm" required />
<button type="submit" class="secondary">Attach Clip</button>
</form>
</div>
<div class="highlight-side-panel">
@if (row.ClipAsset is null)
{
<span class="muted">Not available</span>
<div class="clip-placeholder">
<span class="muted">Playback not available</span>
</div>
}
else
{
@@ -42,19 +72,10 @@
<source src="/clip-jobs?handler=Media&amp;[email protected]" type="@row.ClipAsset.ContentType" />
</video>
}
</td>
<td>
<form method="post" enctype="multipart/form-data" asp-page-handler="UploadClip">
@Html.AntiForgeryToken()
<input type="hidden" name="clipJobId" value="@row.ClipJob.Id" />
<input type="file" name="clipUpload" accept=".mp4,.webm" required />
<button type="submit" class="secondary">Attach Clip</button>
</form>
</td>
<td><code>@string.Join(", ", row.ClipJob.Payload.Select(item => $"{item.Key}={item.Value}"))</code></td>
</tr>
</div>
</div>
</article>
}
</div>
}
</tbody>
</table>
</section>
+51 -19
View File
@@ -10,13 +10,14 @@
}
else
{
<section class="card">
<section class="card detail-hero">
<div class="section-head">
<div>
<div class="detail-hero-copy">
<span class="eyebrow">Demo Record</span>
<h1>Demo #@Model.ViewModel.Demo.Id</h1>
<p class="muted">@Model.ViewModel.Demo.OriginalFileName</p>
</div>
<div>
<div class="detail-hero-actions">
<span class="pill [email protected]">@Model.ViewModel.Demo.Status</span>
<form method="post" asp-page-handler="Delete" asp-route-id="@Model.ViewModel.Demo.Id" style="margin-top: 0.75rem;">
@Html.AntiForgeryToken()
@@ -40,6 +41,7 @@ else
<section class="card">
<div class="section-head">
<div>
<span class="eyebrow">Parsed Output</span>
<h2>Parsed Match</h2>
<p class="muted">@Model.ViewModel.Match.MapName</p>
</div>
@@ -54,28 +56,58 @@ else
</section>
<section class="card">
<div class="section-head">
<div>
<span class="eyebrow">Highlights</span>
<h2>Top Highlights</h2>
<table>
<thead><tr><th>Score</th><th>Round</th><th>Moment</th><th>Tags</th><th>Clip</th><th>Playback</th></tr></thead>
<tbody>
</div>
<a class="section-link" asp-page="/Matches/Details" asp-route-id="@Model.ViewModel.Match.Id">Full Match Breakdown</a>
</div>
@if (Model.ViewModel.Highlights.Count == 0)
{
<tr><td colspan="6" class="muted">No highlights scored above threshold.</td></tr>
<div class="empty-state">
<h3>No highlights scored above threshold.</h3>
<p class="muted">Try rescoring the library after adjusting the scoring rules.</p>
</div>
}
else
{
<div class="highlight-stack">
@foreach (var row in Model.ViewModel.Highlights.Take(10))
{
<tr>
<td>@row.Highlight.Score.ToString("0.0")</td>
<td>@(row.Round?.Number.ToString() ?? "-")</td>
<td>@(row.Actor?.Name ?? row.Event.KillerNameSnapshot) eliminated @(row.Target?.Name ?? row.Event.VictimNameSnapshot)</td>
<td>@string.Join(", ", row.Highlight.Tags)</td>
<td>@row.Highlight.StartTick - @row.Highlight.EndTick</td>
<td>
<article class="highlight-card">
<div class="highlight-card-head">
<div>
<p class="highlight-score">@row.Highlight.Score.ToString("0.0")</p>
<h3>@(row.Actor?.Name ?? row.Event.KillerNameSnapshot) eliminated @(row.Target?.Name ?? row.Event.VictimNameSnapshot)</h3>
</div>
<div class="highlight-meta">
<span>Round @(row.Round?.Number.ToString() ?? "-")</span>
<span>@row.Highlight.StartTick - @row.Highlight.EndTick</span>
</div>
</div>
<div class="tag-row">
@foreach (var tag in row.Highlight.Tags)
{
<span class="tag-chip">@tag</span>
}
</div>
<div class="highlight-body">
<div class="highlight-explainer">
@foreach (var item in row.Highlight.Explanation)
{
<div class="explanation-row">
<span>@item.Detail</span>
<strong>[email protected]("0.#")</strong>
</div>
}
</div>
<div class="highlight-media">
@if (row.ClipAsset is null || row.ClipJob is null)
{
<span class="muted">None</span>
<div class="clip-placeholder">
<span class="muted">No clip attached yet</span>
</div>
}
else
{
@@ -83,12 +115,12 @@ else
<source src="/clip-jobs?handler=Media&amp;[email protected]" type="@row.ClipAsset.ContentType" />
</video>
}
</td>
</tr>
</div>
</div>
</article>
}
</div>
}
</tbody>
</table>
</section>
}
}
+62 -41
View File
@@ -5,98 +5,119 @@
}
<section class="hero card">
<div>
<h1>Upload a CS2 demo</h1>
<p class="muted">Uploads are stored locally, parsed in the background, scored into explainable highlights, and converted into clip job records for a future render worker.</p>
<div class="hero-copy">
<span class="eyebrow">Local Workflow</span>
<h1>Turn raw CS2 demos into ranked moments.</h1>
<p class="muted">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.</p>
<div class="hero-actions">
<form method="post" asp-page-handler="RunWorker">
@Html.AntiForgeryToken()
<button type="submit" class="secondary">Run Worker Once</button>
</form>
<form method="post" asp-page-handler="Rescore">
@Html.AntiForgeryToken()
<button type="submit" class="ghost">Rescore All Matches</button>
</form>
</div>
</div>
<div class="upload-panel">
<h2>Upload Demo</h2>
<p class="muted">Accepts `.dem` files only. New uploads land in the local queue immediately.</p>
<form class="upload-form" method="post" enctype="multipart/form-data" asp-page-handler="Upload">
@Html.AntiForgeryToken()
<input type="file" name="upload" accept=".dem" required />
<button type="submit">Upload Demo</button>
</form>
</div>
</section>
@if (!string.IsNullOrWhiteSpace(Model.StatusMessage))
{
<section class="card">
<section class="card callout callout-success">
<p>@Model.StatusMessage</p>
</section>
}
@if (!string.IsNullOrWhiteSpace(Model.ErrorMessage))
{
<section class="card">
<section class="card callout callout-danger">
<p class="muted">@Model.ErrorMessage</p>
</section>
}
<section class="stats-grid">
<article class="card"><h2>@Model.Dashboard.DemoCount</h2><p>Demos</p></article>
<article class="card"><h2>@Model.Dashboard.MatchCount</h2><p>Matches</p></article>
<article class="card"><h2>@Model.Dashboard.HighlightCount</h2><p>Highlights</p></article>
<article class="card"><h2>@Model.Dashboard.ClipJobCount</h2><p>Clip Jobs</p></article>
<article class="card stat-card"><span class="eyebrow">Library</span><h2>@Model.Dashboard.DemoCount</h2><p>Demos Ingested</p></article>
<article class="card stat-card"><span class="eyebrow">Parsed</span><h2>@Model.Dashboard.MatchCount</h2><p>Match Records</p></article>
<article class="card stat-card"><span class="eyebrow">Highlights</span><h2>@Model.Dashboard.HighlightCount</h2><p>Scored Moments</p></article>
<article class="card stat-card"><span class="eyebrow">Render Queue</span><h2>@Model.Dashboard.ClipJobCount</h2><p>Clip Jobs</p></article>
</section>
<section class="split">
<div class="card">
<div class="section-head">
<div>
<span class="eyebrow">Queue</span>
<h2>Recent Demos</h2>
<form method="post" asp-page-handler="RunWorker">
@Html.AntiForgeryToken()
<button type="submit" class="secondary">Run Worker Once</button>
</form>
</div>
<table>
<thead><tr><th>ID</th><th>File</th><th>Status</th><th>Uploaded</th></tr></thead>
<tbody>
<a class="section-link" asp-page="/Index">Refresh</a>
</div>
@if (Model.Dashboard.Demos.Count == 0)
{
<tr><td colspan="4" class="muted">No demos uploaded yet.</td></tr>
<div class="empty-state">
<h3>No demos uploaded yet.</h3>
<p class="muted">Use the upload panel to seed the library and start generating match and highlight data.</p>
</div>
}
else
{
<div class="stack-list">
@foreach (var demo in Model.Dashboard.Demos)
{
<tr>
<td><a asp-page="/Demos/Details" asp-route-id="@demo.Id">#@demo.Id</a></td>
<td>@demo.OriginalFileName</td>
<td><span class="pill [email protected]">@demo.Status</span></td>
<td>@demo.UploadedAtUtc.ToString("yyyy-MM-dd HH:mm:ss") UTC</td>
</tr>
<article class="list-card">
<div class="list-card-main">
<div class="list-card-title-row">
<a class="list-card-title" asp-page="/Demos/Details" asp-route-id="@demo.Id">#@demo.Id @demo.OriginalFileName</a>
<span class="pill [email protected]">@demo.Status</span>
</div>
<p class="muted">Uploaded @demo.UploadedAtUtc.ToString("yyyy-MM-dd HH:mm:ss") UTC</p>
</div>
</article>
}
</div>
}
</tbody>
</table>
</div>
<div class="card">
<div class="section-head">
<div>
<span class="eyebrow">Output</span>
<h2>Recent Clip Jobs</h2>
<form method="post" asp-page-handler="Rescore">
@Html.AntiForgeryToken()
<button type="submit" class="secondary">Rescore All Matches</button>
</form>
</div>
<table>
<thead><tr><th>ID</th><th>Status</th><th>Ticks</th><th>Renderer</th></tr></thead>
<tbody>
<a class="section-link" asp-page="/ClipJobs/Index">Open All</a>
</div>
@if (Model.Dashboard.ClipJobs.Count == 0)
{
<tr><td colspan="4" class="muted">No clip jobs yet.</td></tr>
<div class="empty-state">
<h3>No clip jobs yet.</h3>
<p class="muted">Clip jobs appear automatically whenever highlights clear the scoring threshold.</p>
</div>
}
else
{
<div class="stack-list">
@foreach (var job in Model.Dashboard.ClipJobs)
{
<tr>
<td><a asp-page="/ClipJobs/Index">#@job.Id</a></td>
<td><span class="pill [email protected]">@job.Status</span></td>
<td>@job.StartTick - @job.EndTick</td>
<td>@job.Renderer</td>
</tr>
<article class="list-card">
<div class="list-card-main">
<div class="list-card-title-row">
<a class="list-card-title" asp-page="/ClipJobs/Index">#@job.Id</a>
<span class="pill [email protected]">@job.Status</span>
</div>
<p class="muted">@job.Renderer · @job.StartTick - @job.EndTick</p>
</div>
</article>
}
</div>
}
</tbody>
</table>
</div>
</section>
@@ -10,9 +10,10 @@
}
else
{
<section class="card">
<section class="card detail-hero">
<div class="section-head">
<div>
<div class="detail-hero-copy">
<span class="eyebrow">Match Breakdown</span>
<h1>Match #@Model.ViewModel.Match.Id</h1>
<p class="muted">@Model.ViewModel.Match.MapName on @(Model.ViewModel.Match.ServerName ?? "Unknown server")</p>
</div>
@@ -26,81 +27,74 @@ else
</dl>
</section>
<section class="split">
<section class="split split-heavy">
<div class="card">
<h2>Players</h2>
<table>
<thead><tr><th>Name</th><th>Steam ID</th><th>Team</th><th>Side</th></tr></thead>
<tbody>
@foreach (var player in Model.ViewModel.Players)
{
<tr>
<td>@player.Name</td>
<td>@player.SteamId</td>
<td>@(player.TeamName ?? "-")</td>
<td>@(player.Side ?? "-")</td>
</tr>
}
</tbody>
</table>
<div class="section-head">
<div>
<span class="eyebrow">Highlights</span>
<h2>Ranked Moments</h2>
</div>
<div class="card">
<h2>Rounds</h2>
<table>
<thead><tr><th>#</th><th>Winner</th><th>Reason</th><th>Ticks</th></tr></thead>
<tbody>
@foreach (var round in Model.ViewModel.Rounds)
{
<tr>
<td>@round.Number</td>
<td>@(round.WinnerSide ?? "-")</td>
<td>@(round.Reason ?? "-")</td>
<td>@(round.StartTick ?? 0) - @(round.EndTick ?? 0)</td>
</tr>
}
</tbody>
</table>
</div>
</section>
<section class="card">
<h2>Highlights</h2>
<table>
<thead><tr><th>Score</th><th>Round</th><th>Moment</th><th>Tags</th><th>Why</th><th>Clip Job</th><th>Playback</th></tr></thead>
<tbody>
@if (Model.ViewModel.Highlights.Count == 0)
{
<tr><td colspan="7" class="muted">No highlights available.</td></tr>
<div class="empty-state">
<h3>No highlights available.</h3>
<p class="muted">This match parsed successfully, but no events cleared the current scoring threshold.</p>
</div>
}
else
{
<div class="highlight-stack">
@foreach (var row in Model.ViewModel.Highlights)
{
<tr>
<td>@row.Highlight.Score.ToString("0.0")</td>
<td>@(row.Round?.Number.ToString() ?? "-")</td>
<td>@(row.Actor?.Name ?? row.Event.KillerNameSnapshot) eliminated @(row.Target?.Name ?? row.Event.VictimNameSnapshot) at tick @row.Event.Tick</td>
<td>@string.Join(", ", row.Highlight.Tags)</td>
<td>
<article class="highlight-card">
<div class="highlight-card-head">
<div>
<p class="highlight-score">@row.Highlight.Score.ToString("0.0")</p>
<h3>@(row.Actor?.Name ?? row.Event.KillerNameSnapshot) eliminated @(row.Target?.Name ?? row.Event.VictimNameSnapshot)</h3>
</div>
<div class="highlight-meta">
<span>Round @(row.Round?.Number.ToString() ?? "-")</span>
<span>Tick @row.Event.Tick</span>
</div>
</div>
<div class="tag-row">
@foreach (var tag in row.Highlight.Tags)
{
<span class="tag-chip">@tag</span>
}
</div>
<div class="highlight-body">
<div class="highlight-explainer">
@foreach (var item in row.Highlight.Explanation)
{
<div>@item.Reason ([email protected])</div>
<div class="explanation-row">
<span>@item.Detail</span>
<strong>[email protected]("0.#")</strong>
</div>
}
</td>
<td>
</div>
<div class="highlight-side-panel">
<div class="mini-meta">
<span class="mini-label">Clip Window</span>
<strong>@row.Highlight.StartTick - @row.Highlight.EndTick</strong>
</div>
<div class="mini-meta">
<span class="mini-label">Clip Job</span>
@if (row.ClipJob is null)
{
<text>-</text>
<strong>-</strong>
}
else
{
<text>#@row.ClipJob.Id @row.ClipJob.Status<br />@row.ClipJob.StartTick - @row.ClipJob.EndTick</text>
<strong>#@row.ClipJob.Id · @row.ClipJob.Status</strong>
}
</td>
<td>
</div>
@if (row.ClipAsset is null || row.ClipJob is null)
{
<span class="muted">None</span>
<div class="clip-placeholder">
<span class="muted">No playback asset</span>
</div>
}
else
{
@@ -108,11 +102,56 @@ else
<source src="/clip-jobs?handler=Media&amp;[email protected]" type="@row.ClipAsset.ContentType" />
</video>
}
</td>
</tr>
</div>
</div>
</article>
}
</div>
}
</tbody>
</table>
</div>
<div class="card">
<div class="section-head">
<div>
<span class="eyebrow">Roster</span>
<h2>Players</h2>
</div>
</div>
<div class="stack-list compact-list">
@foreach (var player in Model.ViewModel.Players)
{
<article class="list-card compact-card">
<div class="list-card-main">
<div class="list-card-title-row">
<span class="list-card-title">@player.Name</span>
<span class="pill">@((player.Side ?? "-"))</span>
</div>
<p class="muted">@player.SteamId</p>
<p class="muted">@(player.TeamName ?? "-")</p>
</div>
</article>
}
</div>
</div>
</section>
<section class="card">
<div class="section-head">
<div>
<span class="eyebrow">Timeline</span>
<h2>Rounds</h2>
</div>
</div>
<div class="round-grid">
@foreach (var round in Model.ViewModel.Rounds)
{
<article class="round-card">
<span class="eyebrow">Round @round.Number</span>
<h3>@(round.WinnerSide ?? "-")</h3>
<p class="muted">@(round.Reason ?? "-")</p>
<p class="round-ticks">@(round.StartTick ?? 0) - @(round.EndTick ?? 0)</p>
</article>
}
</div>
</section>
}
@@ -7,18 +7,21 @@
<link rel="stylesheet" href="~/css/site.css" asp-append-version="true" />
</head>
<body>
<div class="site-shell">
<header class="site-header">
<div>
<div class="site-branding">
<span class="eyebrow">Self-hosted CS2 Analysis</span>
<a class="brand" asp-page="/Index">Fragalytics</a>
<p class="muted">ASP.NET Core MVP for self-hosted CS2 demo analysis</p>
<p class="muted site-tagline">Upload demos, review scored moments, and manage the clip pipeline without leaving the local app.</p>
</div>
<nav class="nav">
<a asp-page="/Index">Demos</a>
<a asp-page="/Index">Dashboard</a>
<a asp-page="/ClipJobs/Index">Clip Jobs</a>
</nav>
</header>
<main class="page">
@RenderBody()
</main>
</div>
</body>
</html>
@@ -6,12 +6,6 @@ namespace Fragalytics.Web.Services;
public sealed class ClipMediaService
{
private static readonly HashSet<string> 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"
};
}
@@ -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;
@@ -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<ParsedDemo> ParseAsync(string demoPath, CancellationToken cancellationToken)
{
@@ -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;
@@ -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)
{
@@ -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),
+309 -27
View File
@@ -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;
}
}
+369 -16
View File
@@ -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<string, string>
{
["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<InvalidOperationException>(
() => 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<TException> AssertThrowsAsync<TException>(Func<Task> 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<int> 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<ParsedDemo> 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<ParsedDemo> ParseAsync(string demoPath, CancellationToken cancellationToken)
{
throw new InvalidOperationException(_message);
}
}