Updates
This commit is contained in:
+12
-1
@@ -8,6 +8,17 @@
|
|||||||
<RepoArtifactsPath Condition="'$(RepoArtifactsPath)' == ''">$(MSBuildThisFileDirectory).artifacts/</RepoArtifactsPath>
|
<RepoArtifactsPath Condition="'$(RepoArtifactsPath)' == ''">$(MSBuildThisFileDirectory).artifacts/</RepoArtifactsPath>
|
||||||
<BaseIntermediateOutputPath Condition="'$(BaseIntermediateOutputPath)' == ''">$(RepoArtifactsPath)obj/$(MSBuildProjectName)/$(HostBuildFlavor)/</BaseIntermediateOutputPath>
|
<BaseIntermediateOutputPath Condition="'$(BaseIntermediateOutputPath)' == ''">$(RepoArtifactsPath)obj/$(MSBuildProjectName)/$(HostBuildFlavor)/</BaseIntermediateOutputPath>
|
||||||
<BaseOutputPath Condition="'$(BaseOutputPath)' == ''">$(RepoArtifactsPath)bin/$(MSBuildProjectName)/$(HostBuildFlavor)/</BaseOutputPath>
|
<BaseOutputPath Condition="'$(BaseOutputPath)' == ''">$(RepoArtifactsPath)bin/$(MSBuildProjectName)/$(HostBuildFlavor)/</BaseOutputPath>
|
||||||
<DefaultItemExcludes>$(DefaultItemExcludes);$(RepoArtifactsPath.Replace('\', '/'))**</DefaultItemExcludes>
|
<DefaultItemExcludes>$(DefaultItemExcludes);.artifacts/**;**/obj/**;**/bin/**;$(RepoArtifactsPath.Replace('\', '/'))**</DefaultItemExcludes>
|
||||||
</PropertyGroup>
|
</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>
|
</Project>
|
||||||
|
|||||||
@@ -16,12 +16,12 @@ builder.Services.AddSingleton<ClipMediaService>();
|
|||||||
builder.Services.AddSingleton<IClipRenderBackend>(serviceProvider =>
|
builder.Services.AddSingleton<IClipRenderBackend>(serviceProvider =>
|
||||||
{
|
{
|
||||||
var options = serviceProvider.GetRequiredService<IOptions<FragalyticsOptions>>().Value;
|
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>>());
|
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(
|
return new Cs2ScriptedRenderBackend(
|
||||||
serviceProvider.GetRequiredService<JsonAppStore>(),
|
serviceProvider.GetRequiredService<JsonAppStore>(),
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ public sealed class Cs2ScriptedRenderBackend : IClipRenderBackend
|
|||||||
_options = options.Value;
|
_options = options.Value;
|
||||||
}
|
}
|
||||||
|
|
||||||
public string Name => "cs2-scripted";
|
public string Name => RenderBackendNames.Cs2Scripted;
|
||||||
|
|
||||||
public async Task<RenderedClipResult> RenderAsync(ClipJobRecord clipJob, CancellationToken cancellationToken)
|
public async Task<RenderedClipResult> RenderAsync(ClipJobRecord clipJob, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
@@ -73,9 +73,7 @@ public sealed class Cs2ScriptedRenderBackend : IClipRenderBackend
|
|||||||
{
|
{
|
||||||
FilePath = attemptArtifacts.OutputPath,
|
FilePath = attemptArtifacts.OutputPath,
|
||||||
FileName = Path.GetFileName(attemptArtifacts.OutputPath),
|
FileName = Path.GetFileName(attemptArtifacts.OutputPath),
|
||||||
ContentType = renderJob.GetSuggestedFileExtension().Equals(".webm", StringComparison.OrdinalIgnoreCase)
|
ContentType = ClipMediaFormats.InferContentType(renderJob.GetSuggestedFileExtension())
|
||||||
? "video/webm"
|
|
||||||
: "video/mp4"
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -12,5 +12,5 @@ public sealed class RenderedClipResult
|
|||||||
{
|
{
|
||||||
public string FilePath { get; set; } = string.Empty;
|
public string FilePath { get; set; } = string.Empty;
|
||||||
public string FileName { 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;
|
_options = options.Value;
|
||||||
}
|
}
|
||||||
|
|
||||||
public string Name => "sample-copy";
|
public string Name => RenderBackendNames.SampleCopy;
|
||||||
|
|
||||||
public async Task<RenderedClipResult> RenderAsync(ClipJobRecord clipJob, CancellationToken cancellationToken)
|
public async Task<RenderedClipResult> RenderAsync(ClipJobRecord clipJob, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
@@ -42,7 +42,7 @@ public sealed class SampleCopyRenderBackend : IClipRenderBackend
|
|||||||
{
|
{
|
||||||
FilePath = outputPath,
|
FilePath = outputPath,
|
||||||
FileName = Path.GetFileName(sampleClipPath),
|
FileName = Path.GetFileName(sampleClipPath),
|
||||||
ContentType = extension.Equals(".webm", StringComparison.OrdinalIgnoreCase) ? "video/webm" : "video/mp4"
|
ContentType = ClipMediaFormats.InferContentType(extension)
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,18 @@
|
|||||||
namespace Fragalytics.Web;
|
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 sealed class FragalyticsOptions
|
||||||
{
|
{
|
||||||
public string DataDirectory { get; set; } = "data";
|
public string DataDirectory { get; set; } = "data";
|
||||||
@@ -13,7 +26,7 @@ public sealed class FragalyticsOptions
|
|||||||
|
|
||||||
public sealed class ParserOptions
|
public sealed class ParserOptions
|
||||||
{
|
{
|
||||||
public string Mode { get; set; } = "sample";
|
public string Mode { get; set; } = ParserModes.Sample;
|
||||||
public string? ExternalCommand { get; set; }
|
public string? ExternalCommand { get; set; }
|
||||||
public string? ExternalArguments { get; set; }
|
public string? ExternalArguments { get; set; }
|
||||||
}
|
}
|
||||||
@@ -21,7 +34,7 @@ public sealed class ParserOptions
|
|||||||
public sealed class RenderWorkerOptions
|
public sealed class RenderWorkerOptions
|
||||||
{
|
{
|
||||||
public int PollSeconds { get; set; } = 3;
|
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? SampleClipPath { get; set; }
|
||||||
public string TempDirectory { get; set; } = "data/render-temp";
|
public string TempDirectory { get; set; } = "data/render-temp";
|
||||||
public bool RunContinuously { get; set; } = true;
|
public bool RunContinuously { get; set; } = true;
|
||||||
|
|||||||
@@ -1,5 +1,55 @@
|
|||||||
namespace Fragalytics.Web.Models;
|
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 sealed class AppState
|
||||||
{
|
{
|
||||||
public int NextDemoId { get; set; } = 1;
|
public int NextDemoId { get; set; } = 1;
|
||||||
@@ -29,7 +79,7 @@ public sealed class DemoRecord
|
|||||||
public string OriginalFileName { get; set; } = string.Empty;
|
public string OriginalFileName { get; set; } = string.Empty;
|
||||||
public string StoredPath { get; set; } = string.Empty;
|
public string StoredPath { get; set; } = string.Empty;
|
||||||
public string ParserBackend { 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 string? ErrorMessage { get; set; }
|
||||||
public DateTime UploadedAtUtc { get; set; } = DateTime.UtcNow;
|
public DateTime UploadedAtUtc { get; set; } = DateTime.UtcNow;
|
||||||
public DateTime? ProcessedAtUtc { get; set; }
|
public DateTime? ProcessedAtUtc { get; set; }
|
||||||
@@ -76,7 +126,7 @@ public sealed class EventRecord
|
|||||||
public int Id { get; set; }
|
public int Id { get; set; }
|
||||||
public int MatchId { get; set; }
|
public int MatchId { get; set; }
|
||||||
public int? RoundId { 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 int Tick { get; set; }
|
||||||
public double? Second { get; set; }
|
public double? Second { get; set; }
|
||||||
public int? ActorPlayerId { get; set; }
|
public int? ActorPlayerId { get; set; }
|
||||||
@@ -117,8 +167,8 @@ public sealed class ClipJobRecord
|
|||||||
{
|
{
|
||||||
public int Id { get; set; }
|
public int Id { get; set; }
|
||||||
public int HighlightId { get; set; }
|
public int HighlightId { get; set; }
|
||||||
public string Renderer { get; set; } = "future-renderer";
|
public string Renderer { get; set; } = RenderBackendNames.FutureRenderer;
|
||||||
public string Status { get; set; } = "pending";
|
public string Status { get; set; } = ClipJobStatuses.Pending;
|
||||||
public int StartTick { get; set; }
|
public int StartTick { get; set; }
|
||||||
public int EndTick { get; set; }
|
public int EndTick { get; set; }
|
||||||
public Dictionary<string, string> Payload { get; set; } = [];
|
public Dictionary<string, string> Payload { get; set; } = [];
|
||||||
@@ -191,12 +241,7 @@ public sealed class RenderJobContract
|
|||||||
|
|
||||||
public string GetSuggestedFileExtension()
|
public string GetSuggestedFileExtension()
|
||||||
{
|
{
|
||||||
if (string.Equals(OutputFormat, "webm", StringComparison.OrdinalIgnoreCase))
|
return ClipMediaFormats.GetSuggestedFileExtension(OutputFormat);
|
||||||
{
|
|
||||||
return ".webm";
|
|
||||||
}
|
|
||||||
|
|
||||||
return ".mp4";
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private static int ReadInt(IReadOnlyDictionary<string, string> payload, string key)
|
private static int ReadInt(IReadOnlyDictionary<string, string> payload, string key)
|
||||||
@@ -216,7 +261,7 @@ public sealed class ClipAssetRecord
|
|||||||
public int ClipJobId { get; set; }
|
public int ClipJobId { get; set; }
|
||||||
public string FileName { get; set; } = string.Empty;
|
public string FileName { get; set; } = string.Empty;
|
||||||
public string StoredPath { 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 long FileSizeBytes { get; set; }
|
||||||
public DateTime CreatedAtUtc { get; set; } = DateTime.UtcNow;
|
public DateTime CreatedAtUtc { get; set; } = DateTime.UtcNow;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,57 +4,78 @@
|
|||||||
ViewData["Title"] = "Clip Jobs";
|
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">
|
<section class="card">
|
||||||
<h1>Clip Jobs</h1>
|
@if (Model.ClipJobs.Count == 0)
|
||||||
<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>
|
<div class="empty-state">
|
||||||
<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>
|
<h3>No clip jobs created yet.</h3>
|
||||||
<tbody>
|
<p class="muted">Clip jobs are generated automatically when a parsed match produces highlights above the current threshold.</p>
|
||||||
@if (Model.ClipJobs.Count == 0)
|
</div>
|
||||||
{
|
}
|
||||||
<tr><td colspan="8" class="muted">No clip jobs created yet.</td></tr>
|
else
|
||||||
}
|
{
|
||||||
else
|
<div class="highlight-stack">
|
||||||
{
|
|
||||||
@foreach (var row in Model.ClipJobs)
|
@foreach (var row in Model.ClipJobs)
|
||||||
{
|
{
|
||||||
<tr>
|
<article class="highlight-card">
|
||||||
<td>#@row.ClipJob.Id</td>
|
<div class="highlight-card-head">
|
||||||
<td>
|
<div>
|
||||||
<span class="pill [email protected]">@row.ClipJob.Status</span>
|
<p class="highlight-score">#@row.ClipJob.Id</p>
|
||||||
@if (!string.IsNullOrWhiteSpace(row.ClipJob.ErrorMessage))
|
<h3>@row.ClipJob.Renderer</h3>
|
||||||
{
|
</div>
|
||||||
<br />
|
<div class="highlight-meta">
|
||||||
<span class="muted">@row.ClipJob.ErrorMessage</span>
|
<span class="pill [email protected]">@row.ClipJob.Status</span>
|
||||||
}
|
<span>@row.ClipJob.StartTick - @row.ClipJob.EndTick</span>
|
||||||
</td>
|
</div>
|
||||||
<td>@row.ClipJob.Renderer</td>
|
</div>
|
||||||
<td>@row.ClipJob.AttemptCount</td>
|
<div class="highlight-body">
|
||||||
<td>@row.ClipJob.StartTick - @row.ClipJob.EndTick</td>
|
<div class="highlight-explainer">
|
||||||
<td>
|
<div class="explanation-row">
|
||||||
@if (row.ClipAsset is null)
|
<span>Attempts</span>
|
||||||
{
|
<strong>@row.ClipJob.AttemptCount</strong>
|
||||||
<span class="muted">Not available</span>
|
</div>
|
||||||
}
|
<div class="explanation-row">
|
||||||
else
|
<span>Payload</span>
|
||||||
{
|
<code>@string.Join(", ", row.ClipJob.Payload.Select(item => $"{item.Key}={item.Value}"))</code>
|
||||||
<video class="clip-player" controls preload="metadata">
|
</div>
|
||||||
<source src="/clip-jobs?handler=Media&[email protected]" type="@row.ClipAsset.ContentType" />
|
@if (!string.IsNullOrWhiteSpace(row.ClipJob.ErrorMessage))
|
||||||
</video>
|
{
|
||||||
}
|
<div class="callout-inline">
|
||||||
</td>
|
<span class="muted">@row.ClipJob.ErrorMessage</span>
|
||||||
<td>
|
</div>
|
||||||
<form method="post" enctype="multipart/form-data" asp-page-handler="UploadClip">
|
}
|
||||||
@Html.AntiForgeryToken()
|
<form class="upload-form compact-upload" method="post" enctype="multipart/form-data" asp-page-handler="UploadClip">
|
||||||
<input type="hidden" name="clipJobId" value="@row.ClipJob.Id" />
|
@Html.AntiForgeryToken()
|
||||||
<input type="file" name="clipUpload" accept=".mp4,.webm" required />
|
<input type="hidden" name="clipJobId" value="@row.ClipJob.Id" />
|
||||||
<button type="submit" class="secondary">Attach Clip</button>
|
<input type="file" name="clipUpload" accept=".mp4,.webm" required />
|
||||||
</form>
|
<button type="submit" class="secondary">Attach Clip</button>
|
||||||
</td>
|
</form>
|
||||||
<td><code>@string.Join(", ", row.ClipJob.Payload.Select(item => $"{item.Key}={item.Value}"))</code></td>
|
</div>
|
||||||
</tr>
|
<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&[email protected]" type="@row.ClipAsset.ContentType" />
|
||||||
|
</video>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
}
|
}
|
||||||
}
|
</div>
|
||||||
</tbody>
|
}
|
||||||
</table>
|
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
@@ -10,13 +10,14 @@
|
|||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
<section class="card">
|
<section class="card detail-hero">
|
||||||
<div class="section-head">
|
<div class="section-head">
|
||||||
<div>
|
<div class="detail-hero-copy">
|
||||||
|
<span class="eyebrow">Demo Record</span>
|
||||||
<h1>Demo #@Model.ViewModel.Demo.Id</h1>
|
<h1>Demo #@Model.ViewModel.Demo.Id</h1>
|
||||||
<p class="muted">@Model.ViewModel.Demo.OriginalFileName</p>
|
<p class="muted">@Model.ViewModel.Demo.OriginalFileName</p>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div class="detail-hero-actions">
|
||||||
<span class="pill [email protected]">@Model.ViewModel.Demo.Status</span>
|
<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;">
|
<form method="post" asp-page-handler="Delete" asp-route-id="@Model.ViewModel.Demo.Id" style="margin-top: 0.75rem;">
|
||||||
@Html.AntiForgeryToken()
|
@Html.AntiForgeryToken()
|
||||||
@@ -40,6 +41,7 @@ else
|
|||||||
<section class="card">
|
<section class="card">
|
||||||
<div class="section-head">
|
<div class="section-head">
|
||||||
<div>
|
<div>
|
||||||
|
<span class="eyebrow">Parsed Output</span>
|
||||||
<h2>Parsed Match</h2>
|
<h2>Parsed Match</h2>
|
||||||
<p class="muted">@Model.ViewModel.Match.MapName</p>
|
<p class="muted">@Model.ViewModel.Match.MapName</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -54,41 +56,71 @@ else
|
|||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="card">
|
<section class="card">
|
||||||
<h2>Top Highlights</h2>
|
<div class="section-head">
|
||||||
<table>
|
<div>
|
||||||
<thead><tr><th>Score</th><th>Round</th><th>Moment</th><th>Tags</th><th>Clip</th><th>Playback</th></tr></thead>
|
<span class="eyebrow">Highlights</span>
|
||||||
<tbody>
|
<h2>Top Highlights</h2>
|
||||||
@if (Model.ViewModel.Highlights.Count == 0)
|
</div>
|
||||||
{
|
<a class="section-link" asp-page="/Matches/Details" asp-route-id="@Model.ViewModel.Match.Id">Full Match Breakdown</a>
|
||||||
<tr><td colspan="6" class="muted">No highlights scored above threshold.</td></tr>
|
</div>
|
||||||
}
|
@if (Model.ViewModel.Highlights.Count == 0)
|
||||||
else
|
{
|
||||||
{
|
<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))
|
@foreach (var row in Model.ViewModel.Highlights.Take(10))
|
||||||
{
|
{
|
||||||
<tr>
|
<article class="highlight-card">
|
||||||
<td>@row.Highlight.Score.ToString("0.0")</td>
|
<div class="highlight-card-head">
|
||||||
<td>@(row.Round?.Number.ToString() ?? "-")</td>
|
<div>
|
||||||
<td>@(row.Actor?.Name ?? row.Event.KillerNameSnapshot) eliminated @(row.Target?.Name ?? row.Event.VictimNameSnapshot)</td>
|
<p class="highlight-score">@row.Highlight.Score.ToString("0.0")</p>
|
||||||
<td>@string.Join(", ", row.Highlight.Tags)</td>
|
<h3>@(row.Actor?.Name ?? row.Event.KillerNameSnapshot) eliminated @(row.Target?.Name ?? row.Event.VictimNameSnapshot)</h3>
|
||||||
<td>@row.Highlight.StartTick - @row.Highlight.EndTick</td>
|
</div>
|
||||||
<td>
|
<div class="highlight-meta">
|
||||||
@if (row.ClipAsset is null || row.ClipJob is null)
|
<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
|
</div>
|
||||||
{
|
<div class="highlight-body">
|
||||||
<video class="clip-player" controls preload="metadata">
|
<div class="highlight-explainer">
|
||||||
<source src="/clip-jobs?handler=Media&[email protected]" type="@row.ClipAsset.ContentType" />
|
@foreach (var item in row.Highlight.Explanation)
|
||||||
</video>
|
{
|
||||||
}
|
<div class="explanation-row">
|
||||||
</td>
|
<span>@item.Detail</span>
|
||||||
</tr>
|
<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&[email protected]" type="@row.ClipAsset.ContentType" />
|
||||||
|
</video>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
}
|
}
|
||||||
}
|
</div>
|
||||||
</tbody>
|
}
|
||||||
</table>
|
|
||||||
</section>
|
</section>
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,98 +5,119 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
<section class="hero card">
|
<section class="hero card">
|
||||||
<div>
|
<div class="hero-copy">
|
||||||
<h1>Upload a CS2 demo</h1>
|
<span class="eyebrow">Local Workflow</span>
|
||||||
<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>
|
<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>
|
</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>
|
</section>
|
||||||
|
|
||||||
@if (!string.IsNullOrWhiteSpace(Model.StatusMessage))
|
@if (!string.IsNullOrWhiteSpace(Model.StatusMessage))
|
||||||
{
|
{
|
||||||
<section class="card">
|
<section class="card callout callout-success">
|
||||||
<p>@Model.StatusMessage</p>
|
<p>@Model.StatusMessage</p>
|
||||||
</section>
|
</section>
|
||||||
}
|
}
|
||||||
|
|
||||||
@if (!string.IsNullOrWhiteSpace(Model.ErrorMessage))
|
@if (!string.IsNullOrWhiteSpace(Model.ErrorMessage))
|
||||||
{
|
{
|
||||||
<section class="card">
|
<section class="card callout callout-danger">
|
||||||
<p class="muted">@Model.ErrorMessage</p>
|
<p class="muted">@Model.ErrorMessage</p>
|
||||||
</section>
|
</section>
|
||||||
}
|
}
|
||||||
|
|
||||||
<section class="stats-grid">
|
<section class="stats-grid">
|
||||||
<article class="card"><h2>@Model.Dashboard.DemoCount</h2><p>Demos</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"><h2>@Model.Dashboard.MatchCount</h2><p>Matches</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"><h2>@Model.Dashboard.HighlightCount</h2><p>Highlights</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"><h2>@Model.Dashboard.ClipJobCount</h2><p>Clip Jobs</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>
|
||||||
|
|
||||||
<section class="split">
|
<section class="split">
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<div class="section-head">
|
<div class="section-head">
|
||||||
<h2>Recent Demos</h2>
|
<div>
|
||||||
<form method="post" asp-page-handler="RunWorker">
|
<span class="eyebrow">Queue</span>
|
||||||
@Html.AntiForgeryToken()
|
<h2>Recent Demos</h2>
|
||||||
<button type="submit" class="secondary">Run Worker Once</button>
|
</div>
|
||||||
</form>
|
<a class="section-link" asp-page="/Index">Refresh</a>
|
||||||
</div>
|
</div>
|
||||||
<table>
|
@if (Model.Dashboard.Demos.Count == 0)
|
||||||
<thead><tr><th>ID</th><th>File</th><th>Status</th><th>Uploaded</th></tr></thead>
|
{
|
||||||
<tbody>
|
<div class="empty-state">
|
||||||
@if (Model.Dashboard.Demos.Count == 0)
|
<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>
|
||||||
<tr><td colspan="4" class="muted">No demos uploaded yet.</td></tr>
|
</div>
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
|
<div class="stack-list">
|
||||||
@foreach (var demo in Model.Dashboard.Demos)
|
@foreach (var demo in Model.Dashboard.Demos)
|
||||||
{
|
{
|
||||||
<tr>
|
<article class="list-card">
|
||||||
<td><a asp-page="/Demos/Details" asp-route-id="@demo.Id">#@demo.Id</a></td>
|
<div class="list-card-main">
|
||||||
<td>@demo.OriginalFileName</td>
|
<div class="list-card-title-row">
|
||||||
<td><span class="pill [email protected]">@demo.Status</span></td>
|
<a class="list-card-title" asp-page="/Demos/Details" asp-route-id="@demo.Id">#@demo.Id @demo.OriginalFileName</a>
|
||||||
<td>@demo.UploadedAtUtc.ToString("yyyy-MM-dd HH:mm:ss") UTC</td>
|
<span class="pill [email protected]">@demo.Status</span>
|
||||||
</tr>
|
</div>
|
||||||
|
<p class="muted">Uploaded @demo.UploadedAtUtc.ToString("yyyy-MM-dd HH:mm:ss") UTC</p>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
}
|
}
|
||||||
}
|
</div>
|
||||||
</tbody>
|
}
|
||||||
</table>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<div class="section-head">
|
<div class="section-head">
|
||||||
<h2>Recent Clip Jobs</h2>
|
<div>
|
||||||
<form method="post" asp-page-handler="Rescore">
|
<span class="eyebrow">Output</span>
|
||||||
@Html.AntiForgeryToken()
|
<h2>Recent Clip Jobs</h2>
|
||||||
<button type="submit" class="secondary">Rescore All Matches</button>
|
</div>
|
||||||
</form>
|
<a class="section-link" asp-page="/ClipJobs/Index">Open All</a>
|
||||||
</div>
|
</div>
|
||||||
<table>
|
@if (Model.Dashboard.ClipJobs.Count == 0)
|
||||||
<thead><tr><th>ID</th><th>Status</th><th>Ticks</th><th>Renderer</th></tr></thead>
|
{
|
||||||
<tbody>
|
<div class="empty-state">
|
||||||
@if (Model.Dashboard.ClipJobs.Count == 0)
|
<h3>No clip jobs yet.</h3>
|
||||||
{
|
<p class="muted">Clip jobs appear automatically whenever highlights clear the scoring threshold.</p>
|
||||||
<tr><td colspan="4" class="muted">No clip jobs yet.</td></tr>
|
</div>
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
|
<div class="stack-list">
|
||||||
@foreach (var job in Model.Dashboard.ClipJobs)
|
@foreach (var job in Model.Dashboard.ClipJobs)
|
||||||
{
|
{
|
||||||
<tr>
|
<article class="list-card">
|
||||||
<td><a asp-page="/ClipJobs/Index">#@job.Id</a></td>
|
<div class="list-card-main">
|
||||||
<td><span class="pill [email protected]">@job.Status</span></td>
|
<div class="list-card-title-row">
|
||||||
<td>@job.StartTick - @job.EndTick</td>
|
<a class="list-card-title" asp-page="/ClipJobs/Index">#@job.Id</a>
|
||||||
<td>@job.Renderer</td>
|
<span class="pill [email protected]">@job.Status</span>
|
||||||
</tr>
|
</div>
|
||||||
|
<p class="muted">@job.Renderer · @job.StartTick - @job.EndTick</p>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
}
|
}
|
||||||
}
|
</div>
|
||||||
</tbody>
|
}
|
||||||
</table>
|
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
@@ -10,9 +10,10 @@
|
|||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
<section class="card">
|
<section class="card detail-hero">
|
||||||
<div class="section-head">
|
<div class="section-head">
|
||||||
<div>
|
<div class="detail-hero-copy">
|
||||||
|
<span class="eyebrow">Match Breakdown</span>
|
||||||
<h1>Match #@Model.ViewModel.Match.Id</h1>
|
<h1>Match #@Model.ViewModel.Match.Id</h1>
|
||||||
<p class="muted">@Model.ViewModel.Match.MapName on @(Model.ViewModel.Match.ServerName ?? "Unknown server")</p>
|
<p class="muted">@Model.ViewModel.Match.MapName on @(Model.ViewModel.Match.ServerName ?? "Unknown server")</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -26,93 +27,131 @@ else
|
|||||||
</dl>
|
</dl>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="split">
|
<section class="split split-heavy">
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<h2>Players</h2>
|
<div class="section-head">
|
||||||
<table>
|
<div>
|
||||||
<thead><tr><th>Name</th><th>Steam ID</th><th>Team</th><th>Side</th></tr></thead>
|
<span class="eyebrow">Highlights</span>
|
||||||
<tbody>
|
<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&[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)
|
@foreach (var player in Model.ViewModel.Players)
|
||||||
{
|
{
|
||||||
<tr>
|
<article class="list-card compact-card">
|
||||||
<td>@player.Name</td>
|
<div class="list-card-main">
|
||||||
<td>@player.SteamId</td>
|
<div class="list-card-title-row">
|
||||||
<td>@(player.TeamName ?? "-")</td>
|
<span class="list-card-title">@player.Name</span>
|
||||||
<td>@(player.Side ?? "-")</td>
|
<span class="pill">@((player.Side ?? "-"))</span>
|
||||||
</tr>
|
</div>
|
||||||
|
<p class="muted">@player.SteamId</p>
|
||||||
|
<p class="muted">@(player.TeamName ?? "-")</p>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
}
|
}
|
||||||
</tbody>
|
</div>
|
||||||
</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>
|
||||||
|
|
||||||
<section class="card">
|
<section class="card">
|
||||||
<h2>Highlights</h2>
|
<div class="section-head">
|
||||||
<table>
|
<div>
|
||||||
<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>
|
<span class="eyebrow">Timeline</span>
|
||||||
<tbody>
|
<h2>Rounds</h2>
|
||||||
@if (Model.ViewModel.Highlights.Count == 0)
|
</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
|
</div>
|
||||||
{
|
|
||||||
@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&[email protected]" type="@row.ClipAsset.ContentType" />
|
|
||||||
</video>
|
|
||||||
}
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</section>
|
</section>
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,18 +7,21 @@
|
|||||||
<link rel="stylesheet" href="~/css/site.css" asp-append-version="true" />
|
<link rel="stylesheet" href="~/css/site.css" asp-append-version="true" />
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<header class="site-header">
|
<div class="site-shell">
|
||||||
<div>
|
<header class="site-header">
|
||||||
<a class="brand" asp-page="/Index">Fragalytics</a>
|
<div class="site-branding">
|
||||||
<p class="muted">ASP.NET Core MVP for self-hosted CS2 demo analysis</p>
|
<span class="eyebrow">Self-hosted CS2 Analysis</span>
|
||||||
</div>
|
<a class="brand" asp-page="/Index">Fragalytics</a>
|
||||||
<nav class="nav">
|
<p class="muted site-tagline">Upload demos, review scored moments, and manage the clip pipeline without leaving the local app.</p>
|
||||||
<a asp-page="/Index">Demos</a>
|
</div>
|
||||||
<a asp-page="/ClipJobs/Index">Clip Jobs</a>
|
<nav class="nav">
|
||||||
</nav>
|
<a asp-page="/Index">Dashboard</a>
|
||||||
</header>
|
<a asp-page="/ClipJobs/Index">Clip Jobs</a>
|
||||||
<main class="page">
|
</nav>
|
||||||
@RenderBody()
|
</header>
|
||||||
</main>
|
<main class="page">
|
||||||
|
@RenderBody()
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -6,12 +6,6 @@ namespace Fragalytics.Web.Services;
|
|||||||
|
|
||||||
public sealed class ClipMediaService
|
public sealed class ClipMediaService
|
||||||
{
|
{
|
||||||
private static readonly HashSet<string> AllowedExtensions = new(StringComparer.OrdinalIgnoreCase)
|
|
||||||
{
|
|
||||||
".mp4",
|
|
||||||
".webm"
|
|
||||||
};
|
|
||||||
|
|
||||||
private readonly JsonAppStore _store;
|
private readonly JsonAppStore _store;
|
||||||
private readonly FragalyticsOptions _options;
|
private readonly FragalyticsOptions _options;
|
||||||
|
|
||||||
@@ -29,9 +23,9 @@ public sealed class ClipMediaService
|
|||||||
CancellationToken cancellationToken)
|
CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
var extension = Path.GetExtension(fileName);
|
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");
|
var clipsDirectory = Path.Combine(Path.GetFullPath(_options.DataDirectory), "clips");
|
||||||
@@ -69,22 +63,16 @@ public sealed class ClipMediaService
|
|||||||
ClipJobId = clipJobId,
|
ClipJobId = clipJobId,
|
||||||
FileName = safeFileName,
|
FileName = safeFileName,
|
||||||
StoredPath = storedPath,
|
StoredPath = storedPath,
|
||||||
ContentType = string.IsNullOrWhiteSpace(contentType) ? InferContentType(extension) : contentType,
|
ContentType = string.IsNullOrWhiteSpace(contentType) ? ClipMediaFormats.InferContentType(extension) : contentType,
|
||||||
FileSizeBytes = fileSize
|
FileSizeBytes = fileSize
|
||||||
};
|
};
|
||||||
|
|
||||||
state.ClipAssets.Add(asset);
|
state.ClipAssets.Add(asset);
|
||||||
clipJob.ClipAssetId = asset.Id;
|
clipJob.ClipAssetId = asset.Id;
|
||||||
clipJob.Status = "rendered";
|
clipJob.Status = ClipJobStatuses.Rendered;
|
||||||
clipJob.ErrorMessage = null;
|
clipJob.ErrorMessage = null;
|
||||||
clipJob.CompletedAtUtc = DateTime.UtcNow;
|
clipJob.CompletedAtUtc = DateTime.UtcNow;
|
||||||
return asset.Id;
|
return asset.Id;
|
||||||
}, cancellationToken);
|
}, 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,
|
OriginalFileName = safeName,
|
||||||
StoredPath = storedPath,
|
StoredPath = storedPath,
|
||||||
ParserBackend = _parserAdapter.Name,
|
ParserBackend = _parserAdapter.Name,
|
||||||
Status = "uploaded",
|
Status = DemoStatuses.Uploaded,
|
||||||
UploadedAtUtc = DateTime.UtcNow
|
UploadedAtUtc = DateTime.UtcNow
|
||||||
};
|
};
|
||||||
state.Demos.Add(demo);
|
state.Demos.Add(demo);
|
||||||
@@ -60,7 +60,7 @@ public sealed class DemoIngestService
|
|||||||
var nextDemo = await _store.UpdateAsync(state =>
|
var nextDemo = await _store.UpdateAsync(state =>
|
||||||
{
|
{
|
||||||
var demo = state.Demos
|
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)
|
.OrderBy(candidate => candidate.UploadedAtUtc)
|
||||||
.FirstOrDefault();
|
.FirstOrDefault();
|
||||||
|
|
||||||
@@ -69,7 +69,7 @@ public sealed class DemoIngestService
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
demo.Status = "processing";
|
demo.Status = DemoStatuses.Processing;
|
||||||
demo.ErrorMessage = null;
|
demo.ErrorMessage = null;
|
||||||
return new DemoRecord
|
return new DemoRecord
|
||||||
{
|
{
|
||||||
@@ -152,7 +152,7 @@ public sealed class DemoIngestService
|
|||||||
Id = state.NextEventId++,
|
Id = state.NextEventId++,
|
||||||
MatchId = match.Id,
|
MatchId = match.Id,
|
||||||
RoundId = round?.Id,
|
RoundId = round?.Id,
|
||||||
EventType = "kill",
|
EventType = EventTypes.Kill,
|
||||||
Tick = parsedKill.Tick,
|
Tick = parsedKill.Tick,
|
||||||
Second = parsedKill.Second,
|
Second = parsedKill.Second,
|
||||||
ActorPlayerId = killer?.Id,
|
ActorPlayerId = killer?.Id,
|
||||||
@@ -170,7 +170,7 @@ public sealed class DemoIngestService
|
|||||||
|
|
||||||
_matchScoringService.RescoreMatch(state, match);
|
_matchScoringService.RescoreMatch(state, match);
|
||||||
|
|
||||||
demo.Status = "parsed";
|
demo.Status = DemoStatuses.Parsed;
|
||||||
demo.ErrorMessage = null;
|
demo.ErrorMessage = null;
|
||||||
demo.ProcessedAtUtc = DateTime.UtcNow;
|
demo.ProcessedAtUtc = DateTime.UtcNow;
|
||||||
return 1;
|
return 1;
|
||||||
@@ -183,7 +183,7 @@ public sealed class DemoIngestService
|
|||||||
await _store.UpdateAsync(state =>
|
await _store.UpdateAsync(state =>
|
||||||
{
|
{
|
||||||
var demo = state.Demos.First(item => item.Id == nextDemo.Id);
|
var demo = state.Demos.First(item => item.Id == nextDemo.Id);
|
||||||
demo.Status = "failed";
|
demo.Status = DemoStatuses.Failed;
|
||||||
demo.ErrorMessage = exception.Message;
|
demo.ErrorMessage = exception.Message;
|
||||||
demo.ProcessedAtUtc = DateTime.UtcNow;
|
demo.ProcessedAtUtc = DateTime.UtcNow;
|
||||||
return 0;
|
return 0;
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ namespace Fragalytics.Web.Services.Parser;
|
|||||||
|
|
||||||
public sealed class SampleDemoParserAdapter : IDemoParserAdapter
|
public sealed class SampleDemoParserAdapter : IDemoParserAdapter
|
||||||
{
|
{
|
||||||
public string Name => "sample";
|
public string Name => ParserModes.Sample;
|
||||||
|
|
||||||
public async Task<ParsedDemo> ParseAsync(string demoPath, CancellationToken cancellationToken)
|
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)
|
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.RetryAfterUtc.HasValue || job.RetryAfterUtc <= utcNow)
|
||||||
&& job.AttemptCount < GetEffectiveMaxAttempts(job);
|
&& job.AttemptCount < GetEffectiveMaxAttempts(job);
|
||||||
}
|
}
|
||||||
|
|
||||||
public static void MarkAttemptStarted(ClipJobRecord job, string renderer, DateTime utcNow)
|
public static void MarkAttemptStarted(ClipJobRecord job, string renderer, DateTime utcNow)
|
||||||
{
|
{
|
||||||
job.Status = "rendering";
|
job.Status = ClipJobStatuses.Rendering;
|
||||||
job.Renderer = renderer;
|
job.Renderer = renderer;
|
||||||
job.AttemptCount++;
|
job.AttemptCount++;
|
||||||
job.ErrorMessage = null;
|
job.ErrorMessage = null;
|
||||||
@@ -30,7 +30,7 @@ public static class ClipJobRetryPolicy
|
|||||||
public static void MarkAttemptFailed(ClipJobRecord job, string errorMessage, DateTime utcNow, TimeSpan retryDelay)
|
public static void MarkAttemptFailed(ClipJobRecord job, string errorMessage, DateTime utcNow, TimeSpan retryDelay)
|
||||||
{
|
{
|
||||||
var hasAttemptsRemaining = job.AttemptCount < GetEffectiveMaxAttempts(job);
|
var hasAttemptsRemaining = job.AttemptCount < GetEffectiveMaxAttempts(job);
|
||||||
job.Status = hasAttemptsRemaining ? "pending" : "failed";
|
job.Status = hasAttemptsRemaining ? ClipJobStatuses.Pending : ClipJobStatuses.Failed;
|
||||||
job.ErrorMessage = errorMessage;
|
job.ErrorMessage = errorMessage;
|
||||||
job.CompletedAtUtc = utcNow;
|
job.CompletedAtUtc = utcNow;
|
||||||
job.RetryAfterUtc = hasAttemptsRemaining ? utcNow.Add(retryDelay) : null;
|
job.RetryAfterUtc = hasAttemptsRemaining ? utcNow.Add(retryDelay) : null;
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ namespace Fragalytics.Web.Services.Render;
|
|||||||
|
|
||||||
public sealed class NoOpRenderWorkerAdapter : IRenderWorkerAdapter
|
public sealed class NoOpRenderWorkerAdapter : IRenderWorkerAdapter
|
||||||
{
|
{
|
||||||
public string Name => "future-renderer";
|
public string Name => RenderBackendNames.FutureRenderer;
|
||||||
|
|
||||||
public void Enqueue(ClipJobRecord clipJob)
|
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 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 roundsById = state.Rounds.Where(round => round.MatchId == match.Id).ToDictionary(round => round.Id);
|
||||||
var killEvents = state.Events
|
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)
|
.OrderBy(@event => @event.RoundId)
|
||||||
.ThenBy(@event => @event.Tick)
|
.ThenBy(@event => @event.Tick)
|
||||||
.ToList();
|
.ToList();
|
||||||
@@ -114,7 +114,7 @@ public sealed class MatchScoringService
|
|||||||
Id = state.NextClipJobId++,
|
Id = state.NextClipJobId++,
|
||||||
HighlightId = highlight.Id,
|
HighlightId = highlight.Id,
|
||||||
Renderer = _renderWorker.Name,
|
Renderer = _renderWorker.Name,
|
||||||
Status = "pending",
|
Status = ClipJobStatuses.Pending,
|
||||||
StartTick = highlight.StartTick,
|
StartTick = highlight.StartTick,
|
||||||
EndTick = highlight.EndTick,
|
EndTick = highlight.EndTick,
|
||||||
MaxAttempts = Math.Max(1, _options.RenderWorker.MaxAttempts),
|
MaxAttempts = Math.Max(1, _options.RenderWorker.MaxAttempts),
|
||||||
|
|||||||
@@ -1,32 +1,37 @@
|
|||||||
:root {
|
:root {
|
||||||
--bg: #f4f1ea;
|
--bg: #efe8db;
|
||||||
--panel: #fffdf8;
|
--panel: #fffdf8;
|
||||||
|
--panel-2: #f8f2e7;
|
||||||
--ink: #1f2421;
|
--ink: #1f2421;
|
||||||
--muted: #5a645e;
|
--muted: #5a645e;
|
||||||
--accent: #b6482f;
|
--accent: #b6482f;
|
||||||
--accent-2: #24584f;
|
--accent-2: #24584f;
|
||||||
--line: #d9d0c3;
|
--accent-3: #7f6a2d;
|
||||||
--soft: #efe8de;
|
--line: #d8cec0;
|
||||||
|
--soft: #efe7da;
|
||||||
|
--shadow: 0 18px 50px rgba(38, 31, 23, 0.08);
|
||||||
}
|
}
|
||||||
|
|
||||||
* { box-sizing: border-box; }
|
* { box-sizing: border-box; }
|
||||||
|
|
||||||
body {
|
body {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
font-family: Georgia, "Times New Roman", serif;
|
font-family: "Iowan Old Style", "Palatino Linotype", "Book Antiqua", Georgia, serif;
|
||||||
color: var(--ink);
|
color: var(--ink);
|
||||||
background:
|
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));
|
linear-gradient(180deg, #f8f4ed, var(--bg));
|
||||||
|
min-height: 100vh;
|
||||||
}
|
}
|
||||||
|
|
||||||
a { color: var(--accent-2); text-decoration: none; }
|
a { color: var(--accent-2); text-decoration: none; }
|
||||||
a:hover { text-decoration: underline; }
|
a:hover { text-decoration: underline; }
|
||||||
h1, h2, p { margin-top: 0; }
|
h1, h2, h3, p { margin-top: 0; }
|
||||||
table { width: 100%; border-collapse: collapse; }
|
table { width: 100%; border-collapse: collapse; }
|
||||||
th, td { padding: 0.75rem; border-bottom: 1px solid var(--line); vertical-align: top; text-align: left; }
|
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; }
|
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,
|
.site-header,
|
||||||
.page {
|
.page {
|
||||||
@@ -34,23 +39,48 @@ code { font-family: "Courier New", monospace; font-size: 0.9rem; }
|
|||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.site-shell {
|
||||||
|
padding-bottom: 3rem;
|
||||||
|
}
|
||||||
|
|
||||||
.site-header {
|
.site-header {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
align-items: end;
|
align-items: flex-start;
|
||||||
gap: 1rem;
|
gap: 2rem;
|
||||||
padding: 1.5rem 0 1rem;
|
padding: 2rem 0 1.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.site-branding {
|
||||||
|
max-width: 44rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.brand {
|
.brand {
|
||||||
color: var(--ink);
|
color: var(--ink);
|
||||||
font-size: 2rem;
|
display: inline-block;
|
||||||
|
font-size: clamp(2rem, 4vw, 3rem);
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
|
letter-spacing: -0.04em;
|
||||||
|
margin-bottom: 0.35rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.nav {
|
.nav {
|
||||||
display: flex;
|
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 {
|
.page {
|
||||||
@@ -60,29 +90,78 @@ code { font-family: "Courier New", monospace; font-size: 0.9rem; }
|
|||||||
.card {
|
.card {
|
||||||
background: var(--panel);
|
background: var(--panel);
|
||||||
border: 1px solid var(--line);
|
border: 1px solid var(--line);
|
||||||
border-radius: 18px;
|
border-radius: 24px;
|
||||||
padding: 1.25rem;
|
padding: 1.4rem;
|
||||||
box-shadow: 0 8px 30px rgba(31, 36, 33, 0.05);
|
box-shadow: var(--shadow);
|
||||||
}
|
}
|
||||||
|
|
||||||
.hero,
|
.hero,
|
||||||
.split,
|
.split,
|
||||||
.stats-grid,
|
.stats-grid,
|
||||||
.meta-grid {
|
.meta-grid,
|
||||||
|
.highlight-body {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 1rem;
|
gap: 1rem;
|
||||||
margin-bottom: 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 { grid-template-columns: 1.15fr 1fr; }
|
||||||
|
.split-heavy { grid-template-columns: 1.5fr 0.9fr; }
|
||||||
.stats-grid { grid-template-columns: repeat(4, 1fr); }
|
.stats-grid { grid-template-columns: repeat(4, 1fr); }
|
||||||
.meta-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 {
|
.upload-form {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 0.75rem;
|
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 {
|
.section-head {
|
||||||
@@ -90,26 +169,35 @@ code { font-family: "Courier New", monospace; font-size: 0.9rem; }
|
|||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
align-items: start;
|
align-items: start;
|
||||||
gap: 1rem;
|
gap: 1rem;
|
||||||
|
margin-bottom: 1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.muted {
|
.muted {
|
||||||
color: var(--muted);
|
color: var(--muted);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.section-link {
|
||||||
|
align-self: center;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
}
|
||||||
|
|
||||||
.pill {
|
.pill {
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
padding: 0.2rem 0.6rem;
|
padding: 0.28rem 0.72rem;
|
||||||
border-radius: 999px;
|
border-radius: 999px;
|
||||||
background: var(--soft);
|
background: var(--soft);
|
||||||
border: 1px solid var(--line);
|
border: 1px solid var(--line);
|
||||||
font-size: 0.85rem;
|
font-size: 0.8rem;
|
||||||
|
line-height: 1.2;
|
||||||
}
|
}
|
||||||
|
|
||||||
.status-uploaded,
|
.status-uploaded,
|
||||||
.status-pending { background: #f5e8b8; }
|
.status-pending { background: #f4e4a7; }
|
||||||
.status-processing { background: #cfe0ff; }
|
.status-processing,
|
||||||
.status-parsed { background: #c9ead0; }
|
.status-rendering { background: #cedef8; }
|
||||||
.status-failed { background: #f1c4bd; }
|
.status-parsed,
|
||||||
|
.status-rendered { background: #cbe8d0; }
|
||||||
|
.status-failed { background: #efcbc1; }
|
||||||
|
|
||||||
button,
|
button,
|
||||||
.button-link,
|
.button-link,
|
||||||
@@ -119,9 +207,10 @@ input[type="submit"] {
|
|||||||
border-radius: 999px;
|
border-radius: 999px;
|
||||||
background: var(--accent);
|
background: var(--accent);
|
||||||
color: white;
|
color: white;
|
||||||
padding: 0.7rem 1rem;
|
padding: 0.75rem 1.05rem;
|
||||||
font: inherit;
|
font: inherit;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
|
text-decoration: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
button.secondary {
|
button.secondary {
|
||||||
@@ -130,10 +219,16 @@ button.secondary {
|
|||||||
border: 1px solid var(--line);
|
border: 1px solid var(--line);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.ghost {
|
||||||
|
background: transparent;
|
||||||
|
color: var(--accent-2);
|
||||||
|
border: 1px solid rgba(36, 88, 79, 0.18);
|
||||||
|
}
|
||||||
|
|
||||||
input[type="file"] {
|
input[type="file"] {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
border: 1px dashed var(--line);
|
border: 1px dashed var(--line);
|
||||||
border-radius: 12px;
|
border-radius: 14px;
|
||||||
background: var(--soft);
|
background: var(--soft);
|
||||||
padding: 1rem;
|
padding: 1rem;
|
||||||
}
|
}
|
||||||
@@ -147,11 +242,169 @@ dd {
|
|||||||
margin: 0.3rem 0 0;
|
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) {
|
@media (max-width: 900px) {
|
||||||
.hero,
|
.hero,
|
||||||
.split,
|
.split,
|
||||||
.stats-grid,
|
.stats-grid,
|
||||||
.meta-grid {
|
.meta-grid,
|
||||||
|
.highlight-body,
|
||||||
|
.split-heavy {
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -159,4 +412,33 @@ dd {
|
|||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
align-items: start;
|
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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -47,6 +47,42 @@ catch (Exception exception)
|
|||||||
failures.Add($"Path resolution test failed: {exception.Message}");
|
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)
|
if (failures.Count > 0)
|
||||||
{
|
{
|
||||||
foreach (var failure in failures)
|
foreach (var failure in failures)
|
||||||
@@ -94,7 +130,7 @@ static void RunScoringTest()
|
|||||||
[7] = new() { Id = 7, Name = "Alpha", Side = "CT" }
|
[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
|
var target = new EventRecord
|
||||||
{
|
{
|
||||||
Id = 2,
|
Id = 2,
|
||||||
@@ -102,7 +138,7 @@ static void RunScoringTest()
|
|||||||
Tick = 450,
|
Tick = 450,
|
||||||
Second = 38,
|
Second = 38,
|
||||||
ActorPlayerId = 7,
|
ActorPlayerId = 7,
|
||||||
EventType = "kill",
|
EventType = EventTypes.Kill,
|
||||||
IsHeadshot = true,
|
IsHeadshot = true,
|
||||||
KillerAliveCount = 1,
|
KillerAliveCount = 1,
|
||||||
EnemyAliveCount = 3
|
EnemyAliveCount = 3
|
||||||
@@ -115,6 +151,10 @@ static void RunScoringTest()
|
|||||||
Assert(result.Tags.Contains("multikill"), "Expected multikill tag.");
|
Assert(result.Tags.Contains("multikill"), "Expected multikill tag.");
|
||||||
Assert(result.Tags.Contains("clutch"), "Expected clutch tag.");
|
Assert(result.Tags.Contains("clutch"), "Expected clutch tag.");
|
||||||
Assert(result.Tags.Contains("1vX"), "Expected 1vX 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()
|
static async Task RunFlowTestAsync()
|
||||||
@@ -146,7 +186,7 @@ static async Task RunFlowTestAsync()
|
|||||||
|
|
||||||
var store = new JsonAppStore(options);
|
var store = new JsonAppStore(options);
|
||||||
var scoringProvider = new ScoringRulesProvider(environment);
|
var scoringProvider = new ScoringRulesProvider(environment);
|
||||||
var renderWorker = new NoOpRenderWorkerAdapter();
|
var renderWorker = new RecordingRenderWorkerAdapter();
|
||||||
var scoring = new MatchScoringService(scoringProvider, renderWorker, options);
|
var scoring = new MatchScoringService(scoringProvider, renderWorker, options);
|
||||||
var ingest = new DemoIngestService(store, options, new SampleDemoParserAdapter(), scoring);
|
var ingest = new DemoIngestService(store, options, new SampleDemoParserAdapter(), scoring);
|
||||||
var clipMedia = new ClipMediaService(store, options);
|
var clipMedia = new ClipMediaService(store, options);
|
||||||
@@ -159,17 +199,18 @@ static async Task RunFlowTestAsync()
|
|||||||
|
|
||||||
var state = await store.GetSnapshotAsync();
|
var state = await store.GetSnapshotAsync();
|
||||||
Assert(state.Demos.Count == 1, "Expected one demo.");
|
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.Matches.Count == 1, "Expected one match.");
|
||||||
Assert(state.Highlights.Count > 0, "Expected highlights.");
|
Assert(state.Highlights.Count > 0, "Expected highlights.");
|
||||||
Assert(state.ClipJobs.Count == state.Highlights.Count, "Expected one clip job per highlight.");
|
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(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 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();
|
state = await store.GetSnapshotAsync();
|
||||||
Assert(state.ClipAssets.Count == 1, "Expected one clip asset.");
|
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("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.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.");
|
Assert(state.ClipJobs[0].Payload.ContainsKey("cameraMode"), "Expected render payload defaults on clip job.");
|
||||||
@@ -198,18 +239,18 @@ static void RunRetryFlowTest()
|
|||||||
{
|
{
|
||||||
Id = 1,
|
Id = 1,
|
||||||
HighlightId = 7,
|
HighlightId = 7,
|
||||||
Renderer = "future-renderer",
|
Renderer = RenderBackendNames.FutureRenderer,
|
||||||
Status = "pending",
|
Status = ClipJobStatuses.Pending,
|
||||||
StartTick = 100,
|
StartTick = 100,
|
||||||
EndTick = 200,
|
EndTick = 200,
|
||||||
MaxAttempts = 2
|
MaxAttempts = 2
|
||||||
};
|
};
|
||||||
|
|
||||||
Assert(ClipJobRetryPolicy.CanStartAttempt(firstJob, DateTime.UtcNow), "Expected pending job to be claimable before any attempts.");
|
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));
|
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.AttemptCount == 1, "Expected first failed attempt to increment attempt count.");
|
||||||
Assert(firstJob.RetryAfterUtc.HasValue, "Expected retry to be scheduled after a failure.");
|
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.");
|
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);
|
firstJob.RetryAfterUtc = DateTime.UtcNow.AddSeconds(-1);
|
||||||
Assert(ClipJobRetryPolicy.CanStartAttempt(firstJob, DateTime.UtcNow), "Expected job to become claimable once retry delay passes.");
|
Assert(ClipJobRetryPolicy.CanStartAttempt(firstJob, DateTime.UtcNow), "Expected job to become claimable once retry delay passes.");
|
||||||
|
|
||||||
ClipJobRetryPolicy.MarkAttemptStarted(firstJob, "sample-copy", DateTime.UtcNow);
|
ClipJobRetryPolicy.MarkAttemptStarted(firstJob, RenderBackendNames.SampleCopy, DateTime.UtcNow);
|
||||||
firstJob.Status = "rendered";
|
firstJob.Status = ClipJobStatuses.Rendered;
|
||||||
firstJob.ErrorMessage = null;
|
firstJob.ErrorMessage = null;
|
||||||
firstJob.RetryAfterUtc = null;
|
firstJob.RetryAfterUtc = null;
|
||||||
firstJob.CompletedAtUtc = DateTime.UtcNow;
|
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.AttemptCount == 2, "Expected successful retry to record the second attempt.");
|
||||||
Assert(firstJob.RetryAfterUtc is null, "Expected successful retry to clear the retry schedule.");
|
Assert(firstJob.RetryAfterUtc is null, "Expected successful retry to clear the retry schedule.");
|
||||||
|
|
||||||
@@ -232,13 +273,13 @@ static void RunRetryFlowTest()
|
|||||||
{
|
{
|
||||||
Id = 2,
|
Id = 2,
|
||||||
HighlightId = 8,
|
HighlightId = 8,
|
||||||
Status = "pending",
|
Status = ClipJobStatuses.Pending,
|
||||||
MaxAttempts = 1
|
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));
|
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.");
|
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.");
|
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()
|
static string FindRepoRoot()
|
||||||
{
|
{
|
||||||
var current = AppContext.BaseDirectory;
|
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
|
internal sealed class FakeEnvironment : IWebHostEnvironment
|
||||||
{
|
{
|
||||||
public FakeEnvironment(string rootPath)
|
public FakeEnvironment(string rootPath)
|
||||||
@@ -307,3 +615,48 @@ internal sealed class FakeEnvironment : IWebHostEnvironment
|
|||||||
public string ContentRootPath { get; set; }
|
public string ContentRootPath { get; set; }
|
||||||
public IFileProvider ContentRootFileProvider { 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user