UI/GifExportOverlay.razor
@using System
@using System.Collections.Generic
@using System.Linq
@using Sandbox
@using Sandbox.UI
@inherits PanelComponent
@namespace BlockParty
@attribute [StyleSheet( "GifExportOverlay.razor.scss" )]
@* Persistent GIF-export overlay (lives on its own GameObject under GameManager, NOT a stage HUD, so it
survives the stage rebuilds a backward replay seek triggers). It renders nothing until a preview is
active. While active the live replay is shrunk into the top of the screen (GameManager.BeginGifPreview)
and these controls sit below: a two-head timeline to pick the span, a readout, the output SIZE
dropdown, and Export. Export runs incrementally in OnUpdate via a GifExporter, then shows the saved
path — the s&box sandbox has no native save dialog, so the file lands in the game data folder. *@
<root>
@if ( PreviewActive )
{
<div class="controls">
@if ( _exporter is null )
{
<label class="title">EXPORT GIF</label>
<GifTimeline Length=@Length StartTick=@_startTick EndTick=@_endTick OnChanged=@OnHeadsChanged />
<div class="readout">
<label class="range">@RangeText</label>
<label class="stats">@StatsText</label>
@if ( TooMany )
{
<label class="warning">@WarningText</label>
}
</div>
<div class="settings">
<label class="setting-name">SIZE</label>
<PixelDropdown Options=@_scaleOptions Selected=@(Scale - 1) OnSelected=@OnScaleSelected OpenUp=@true></PixelDropdown>
</div>
<div class="buttons">
<button class="cancel" onclick=@Close>CANCEL</button>
<button class="confirm @(TooMany ? "disabled" : "")" onclick=@StartExport>EXPORT</button>
</div>
}
else if ( _exporter.State == GifExporter.Phase.Done )
{
<label class="title">GIF SAVED</label>
<label class="path">@_exporter.ResultPath</label>
<div class="buttons">
<button class="confirm" onclick=@CopyPath>@(_copied ? "COPIED!" : "COPY PATH")</button>
<button class="confirm" onclick=@Close>DONE</button>
</div>
}
else if ( _exporter.State == GifExporter.Phase.Failed )
{
<label class="title">EXPORT FAILED</label>
<label class="error">@_exporter.Error</label>
<div class="buttons">
<button class="cancel" onclick=@Close>CLOSE</button>
</div>
}
else
{
<label class="title">EXPORTING…</label>
<div class="progress">
<div class="progress-fill" style="width: @(Pct( _exporter.Progress ))%;"></div>
</div>
<label class="progress-label">@($"{(int)(_exporter.Progress * 100)}%")</label>
}
</div>
}
</root>
@code
{
// Every tick is always captured and played at a fixed 2cs (~50fps) — the fastest delay browsers play
// smoothly (see GifExporter.FrameDelayCs). No frame-skip / speed / loop controls: the GIF always loops.
private int _startTick;
private int _endTick;
private GifExporter _exporter;
private bool _wasActive;
private bool _copied;
// Estimated output size for the current span (-1 = unknown), sampled incrementally by a
// GifSizeEstimator (one frame per update, like the exporter — sprite batches upload once per update).
// Sampling seeks the replay around, so it's debounced: any head change cancels the running job, clears
// the estimate and schedules a re-sample shortly after the heads go quiet. A finished estimator is kept
// so a SIZE change can re-encode its cached frames instead of sampling again.
private long _estimatedBytes = -1;
private float _estimateDueAt = -1f;
private GifSizeEstimator _estimator;
private const float EstimateDebounce = 0.35f;
// Output scale (1x..MaxScale of the 240px arena), persisted across sessions in Settings (whose setter
// clamps). Dropdown labels are indexed scale-1 so the selected index maps straight to the setting.
private static readonly List<string> _scaleOptions = Enumerable.Range( 1, GifExporter.MaxScale )
.Select( s => $"{s}x • {GifExporter.OutputSizeFor( s )}px" ).ToList();
private static int Scale => Settings.Current.GifScale;
private bool PreviewActive => GameManager.Instance?.IsGifPreviewActive ?? false;
private int Length => GameManager.Instance?.ReplayLength ?? 0;
private int FrameCount => GifExporter.FrameCount( _startTick, _endTick, Length );
private float Fps => 100f / GifExporter.FrameDelayCs;
private bool TooMany => FrameCount > GifExporter.MaxFrames;
// Each readout label is built as ONE interpolated string: an s&box label that mixes @expressions with
// literal text mis-diffs when the panel re-renders (text nodes get dropped/duplicated on hover), so a
// single expression per label keeps the render correct.
private static string Fmt( float v ) => v.ToString( "0.#", System.Globalization.CultureInfo.InvariantCulture );
private string RangeText => $"{FormatTime( _startTick / 60f )} – {FormatTime( _endTick / 60f )}";
private string StatsText => $"{FrameCount} frames • {Fmt( Fps )} fps • {Fmt( DurationSeconds )}s{SizeSuffix}";
private string SizeSuffix => _estimatedBytes < 0 ? "" : $" • ~{FormatSize( _estimatedBytes )}";
private static string FormatSize( long bytes )
=> bytes < 1024 * 1024 ? $"{bytes / 1024} KB" : $"{Fmt( bytes / (1024f * 1024f) )} MB";
private string WarningText => $"Too many frames (max {GifExporter.MaxFrames}) — shorten the range.";
// Total play length: captured frames at the fixed per-frame delay.
private float DurationSeconds => FrameCount * GifExporter.FrameDelayCs / 100f;
protected override void OnUpdate()
{
bool active = PreviewActive;
// Opening edge: a fresh preview — (re)initialise the selection and clear any prior export.
if ( active && !_wasActive )
ResetForOpen();
_wasActive = active;
if ( !active )
return;
// Drive the capture incrementally while it runs (the render updates via BuildHash).
if ( _exporter is not null && _exporter.State == GifExporter.Phase.Capturing )
_exporter.Step();
// Size estimate: drive a running job one frame per update, and once the heads have been quiet for
// the debounce window, start a new one. (The preview briefly shows the sample frames flick past — a
// quick scrub across the selected span.) A Done estimator stays around for SIZE retargets.
if ( _exporter is null )
{
if ( _estimator is not null )
{
if ( _estimator.Running )
_estimator.Step();
if ( _estimator.State == GifSizeEstimator.Phase.Done && _estimatedBytes != _estimator.ResultBytes )
{
_estimatedBytes = _estimator.ResultBytes;
StateHasChanged();
}
else if ( _estimator.State == GifSizeEstimator.Phase.Failed )
{
_estimator = null;
}
}
else if ( _estimateDueAt >= 0f && RealTime.Now >= _estimateDueAt )
{
_estimateDueAt = -1f;
if ( !TooMany )
_estimator = new GifSizeEstimator( GameManager.Instance, _startTick, _endTick, Scale );
}
}
}
private void InvalidateEstimate()
{
_estimator?.Cancel();
_estimator = null;
_estimatedBytes = -1;
_estimateDueAt = RealTime.Now + EstimateDebounce;
}
private void ResetForOpen()
{
_exporter = null;
_copied = false;
InvalidateEstimate();
int last = Math.Max( 0, Length - 1 );
// Restore the settings from the last export in THIS replay session, if any (GameManager clears them
// when the replay ends or a different one starts). Otherwise fall back to defaults.
if ( GameManager.Instance?.SavedGifConfig is GifExportConfig cfg )
{
_startTick = Math.Clamp( cfg.StartTick, 0, last );
_endTick = Math.Clamp( cfg.EndTick, _startTick, last );
return;
}
_startTick = 0;
// Default to the whole replay, but not past the frame cap (every tick is captured, so a run longer
// than MaxFrames ticks would otherwise disable Export). The user can drag further — that trips the
// "too many frames" warning.
_endTick = Math.Min( last, GifExporter.MaxFrames - 1 );
}
private void OnHeadsChanged( int start, int end, bool draggingEnd )
{
_startTick = start;
_endTick = end;
InvalidateEstimate();
StateHasChanged();
}
// SIZE dropdown changed: persist it and re-estimate (the byte size scales with the frame area). The
// estimator re-encodes its cached native frames at the new scale — no seeks, no preview scrub. With no
// estimator (debounce pending / too many frames) the next one simply starts at the new scale.
private void OnScaleSelected( int index )
{
Settings.Current.GifScale = index + 1;
Settings.Save();
_estimatedBytes = -1;
_estimator?.Retarget( Scale );
StateHasChanged();
}
private void StartExport()
{
var mgr = GameManager.Instance;
if ( mgr is null || Length <= 0 || TooMany )
return;
Audio.PlaySfx( SfxType.MenuStart );
// A running size-sample would fight the exporter's seeks — stop it (the exporter reframes anyway).
_estimator?.Cancel();
_estimator = null;
int scale = Scale;
string file = GifExporter.BuildFileName( mgr, _startTick, _endTick, scale );
_exporter = new GifExporter( mgr, _startTick, _endTick, file, scale );
// Remember the span for the rest of this replay session (see GameManager.SavedGifConfig).
mgr.SavedGifConfig = new GifExportConfig( _startTick, _endTick );
StateHasChanged();
}
private void CopyPath()
{
if ( string.IsNullOrEmpty( _exporter?.ResultPath ) )
return;
Audio.PlaySfx( SfxType.MenuBlip );
Sandbox.UI.Clipboard.SetText( _exporter.ResultPath );
_copied = true;
StateHasChanged();
}
private void Close()
{
Audio.PlaySfx( SfxType.MenuBlip );
_estimator?.Cancel();
_estimator = null;
GameManager.Instance?.EndGifPreview();
StateHasChanged();
}
private static string Pct( float f )
=> (Math.Clamp( f, 0f, 1f ) * 100f).ToString( "0.#", System.Globalization.CultureInfo.InvariantCulture );
private static string FormatTime( float seconds )
{
int total = (int)seconds;
return $"{total / 60}:{total % 60:00}";
}
protected override int BuildHash()
{
float progress = _exporter?.Progress ?? 0f;
int state = (int)(_exporter?.State ?? GifExporter.Phase.Capturing) + (_exporter is null ? 100 : 0);
return System.HashCode.Combine( PreviewActive, _startTick, _endTick, state, (int)(progress * 200f), _estimatedBytes, Scale ) ^ (_copied ? 91 : 0);
}
}