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
+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;
}
+70 -49
View File
@@ -4,57 +4,78 @@
ViewData["Title"] = "Clip Jobs";
}
<section class="card detail-hero">
<div class="detail-hero-copy">
<span class="eyebrow">Render Queue</span>
<h1>Clip Jobs</h1>
<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">
<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>
@if (Model.ClipJobs.Count == 0)
{
<tr><td colspan="8" class="muted">No clip jobs created yet.</td></tr>
}
else
{
@if (Model.ClipJobs.Count == 0)
{
<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>
<span class="pill [email protected]">@row.ClipJob.Status</span>
@if (!string.IsNullOrWhiteSpace(row.ClipJob.ErrorMessage))
{
<br />
<span class="muted">@row.ClipJob.ErrorMessage</span>
}
</td>
<td>@row.ClipJob.Renderer</td>
<td>@row.ClipJob.AttemptCount</td>
<td>@row.ClipJob.StartTick - @row.ClipJob.EndTick</td>
<td>
@if (row.ClipAsset is null)
{
<span class="muted">Not available</span>
}
else
{
<video class="clip-player" controls preload="metadata">
<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>
<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))
{
<div class="callout-inline">
<span class="muted">@row.ClipJob.ErrorMessage</span>
</div>
}
<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)
{
<div class="clip-placeholder">
<span class="muted">Playback not available</span>
</div>
}
else
{
<video class="clip-player" controls preload="metadata">
<source src="/clip-jobs?handler=Media&amp;[email protected]" type="@row.ClipAsset.ContentType" />
</video>
}
</div>
</div>
</article>
}
}
</tbody>
</table>
</div>
}
</section>
+65 -33
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,41 +56,71 @@ else
</section>
<section class="card">
<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>
@if (Model.ViewModel.Highlights.Count == 0)
{
<tr><td colspan="6" class="muted">No highlights scored above threshold.</td></tr>
}
else
{
<div class="section-head">
<div>
<span class="eyebrow">Highlights</span>
<h2>Top Highlights</h2>
</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)
{
<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>
@if (row.ClipAsset is null || row.ClipJob is null)
<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="muted">None</span>
<span class="tag-chip">@tag</span>
}
else
{
<video class="clip-player" controls preload="metadata">
<source src="/clip-jobs?handler=Media&amp;[email protected]" type="@row.ClipAsset.ContentType" />
</video>
}
</td>
</tr>
</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)
{
<div class="clip-placeholder">
<span class="muted">No clip attached yet</span>
</div>
}
else
{
<video class="clip-player" controls preload="metadata">
<source src="/clip-jobs?handler=Media&amp;[email protected]" type="@row.ClipAsset.ContentType" />
</video>
}
</div>
</div>
</article>
}
}
</tbody>
</table>
</div>
}
</section>
}
}
+81 -60
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>
<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>
</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">
<h2>Recent Demos</h2>
<form method="post" asp-page-handler="RunWorker">
@Html.AntiForgeryToken()
<button type="submit" class="secondary">Run Worker Once</button>
</form>
<div>
<span class="eyebrow">Queue</span>
<h2>Recent Demos</h2>
</div>
<a class="section-link" asp-page="/Index">Refresh</a>
</div>
<table>
<thead><tr><th>ID</th><th>File</th><th>Status</th><th>Uploaded</th></tr></thead>
<tbody>
@if (Model.Dashboard.Demos.Count == 0)
{
<tr><td colspan="4" class="muted">No demos uploaded yet.</td></tr>
}
else
{
@if (Model.Dashboard.Demos.Count == 0)
{
<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>
}
}
</tbody>
</table>
</div>
}
</div>
<div class="card">
<div class="section-head">
<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>
<span class="eyebrow">Output</span>
<h2>Recent Clip Jobs</h2>
</div>
<a class="section-link" asp-page="/ClipJobs/Index">Open All</a>
</div>
<table>
<thead><tr><th>ID</th><th>Status</th><th>Ticks</th><th>Renderer</th></tr></thead>
<tbody>
@if (Model.Dashboard.ClipJobs.Count == 0)
{
<tr><td colspan="4" class="muted">No clip jobs yet.</td></tr>
}
else
{
@if (Model.Dashboard.ClipJobs.Count == 0)
{
<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>
}
}
</tbody>
</table>
</div>
}
</div>
</section>
+119 -80
View File
@@ -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,93 +27,131 @@ 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>
<div class="section-head">
<div>
<span class="eyebrow">Highlights</span>
<h2>Ranked Moments</h2>
</div>
</div>
@if (Model.ViewModel.Highlights.Count == 0)
{
<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)
{
<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 class="explanation-row">
<span>@item.Detail</span>
<strong>[email protected]("0.#")</strong>
</div>
}
</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)
{
<strong>-</strong>
}
else
{
<strong>#@row.ClipJob.Id · @row.ClipJob.Status</strong>
}
</div>
@if (row.ClipAsset is null || row.ClipJob is null)
{
<div class="clip-placeholder">
<span class="muted">No playback asset</span>
</div>
}
else
{
<video class="clip-player" controls preload="metadata">
<source src="/clip-jobs?handler=Media&amp;[email protected]" type="@row.ClipAsset.ContentType" />
</video>
}
</div>
</div>
</article>
}
</div>
}
</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)
{
<tr>
<td>@player.Name</td>
<td>@player.SteamId</td>
<td>@(player.TeamName ?? "-")</td>
<td>@(player.Side ?? "-")</td>
</tr>
<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>
}
</tbody>
</table>
</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>
</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)
<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)
{
<tr><td colspan="7" class="muted">No highlights available.</td></tr>
<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>
}
else
{
@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>
@foreach (var item in row.Highlight.Explanation)
{
<div>@item.Reason ([email protected])</div>
}
</td>
<td>
@if (row.ClipJob is null)
{
<text>-</text>
}
else
{
<text>#@row.ClipJob.Id @row.ClipJob.Status<br />@row.ClipJob.StartTick - @row.ClipJob.EndTick</text>
}
</td>
<td>
@if (row.ClipAsset is null || row.ClipJob is null)
{
<span class="muted">None</span>
}
else
{
<video class="clip-player" controls preload="metadata">
<source src="/clip-jobs?handler=Media&amp;[email protected]" type="@row.ClipAsset.ContentType" />
</video>
}
</td>
</tr>
}
}
</tbody>
</table>
</div>
</section>
}
+16 -13
View File
@@ -7,18 +7,21 @@
<link rel="stylesheet" href="~/css/site.css" asp-append-version="true" />
</head>
<body>
<header class="site-header">
<div>
<a class="brand" asp-page="/Index">Fragalytics</a>
<p class="muted">ASP.NET Core MVP for self-hosted CS2 demo analysis</p>
</div>
<nav class="nav">
<a asp-page="/Index">Demos</a>
<a asp-page="/ClipJobs/Index">Clip Jobs</a>
</nav>
</header>
<main class="page">
@RenderBody()
</main>
<div class="site-shell">
<header class="site-header">
<div class="site-branding">
<span class="eyebrow">Self-hosted CS2 Analysis</span>
<a class="brand" asp-page="/Index">Fragalytics</a>
<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">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;
}
}