Files
2026-07-13 13:13:17 +08:00

141 lines
5.7 KiB
C#

using System.Collections.Generic;
using System.IO;
using T3.Core.Settings;
using T3.Core.Video;
namespace T3.VideoServices;
/// <summary>
/// The global <see cref="IVideoPlaybackEngine"/>. Operators are thin clients: each posts its per-frame
/// request keyed by a stable stream id, and the engine owns the underlying
/// <see cref="VideoPlaybackController"/>s (decode workers + frame caches). It caps how many streams stay live
/// and shares one cache budget across them, so a timeline with hundreds of clips never means hundreds of
/// decoders.
///
/// Accessed only from the operator evaluation thread (graph eval is single-threaded), so the stream map needs
/// no locking.
/// </summary>
public sealed class VideoPlaybackEngine : IVideoPlaybackEngine
{
private sealed class Stream(VideoPlaybackController controller)
{
public readonly VideoPlaybackController Controller = controller;
public long LastRequestMs;
}
/// <summary>The singleton; creating it also publishes it to <see cref="VideoPlayback.Engine"/>.</summary>
public static VideoPlaybackEngine Instance => _instance ??= Register();
public VideoFrameResult RequestFrame(Guid streamId, string absolutePath, double requestedSeconds, bool loop,
bool renderingToFile, VideoPlaybackOptimization optimization)
{
var now = Environment.TickCount64;
if (!_streams.TryGetValue(streamId, out var stream))
{
stream = new Stream(new VideoPlaybackController()) { LastRequestMs = now };
_streams[streamId] = stream;
RedistributeBudget();
}
stream.LastRequestMs = now;
EvictStaleStreams(now);
var controller = stream.Controller;
var effectivePath = ResolveEffectivePath(absolutePath, renderingToFile);
var produced = controller.Update(effectivePath, requestedSeconds, loop, renderingToFile, optimization);
return new VideoFrameResult(produced, controller.Texture, controller.Duration, controller.HasCompleted,
controller.IsReady, controller.ErrorMessage);
}
public void ReleaseStream(Guid streamId)
{
if (!_streams.Remove(streamId, out var stream))
return;
stream.Controller.Dispose();
RedistributeBudget();
}
// Frees streams the graph no longer drives: any idle past the timeout, plus — when above the live cap —
// the most-idle ones past a short grace, down to the cap. A genuinely active stream (requested within the
// grace) is never evicted, so more-than-cap simultaneously-visible clips degrade by exceeding the cap
// rather than thrashing decoders. Runs opportunistically: the engine is only ticked from RequestFrame, so
// when nothing requests there is also no resource pressure.
private void EvictStaleStreams(long now)
{
var evictedAny = false;
while (TryFindEvictable(now, out var evictId))
{
_streams[evictId].Controller.Dispose();
_streams.Remove(evictId);
evictedAny = true;
}
if (evictedAny)
RedistributeBudget();
}
private bool TryFindEvictable(long now, out Guid evictId)
{
evictId = default;
var overCap = _streams.Count > MaxLiveStreams;
var oldest = long.MaxValue;
foreach (var (id, stream) in _streams)
{
var idleMs = now - stream.LastRequestMs;
var evictable = idleMs > IdleTimeoutMs || (overCap && idleMs > IdleGraceMs);
if (!evictable || stream.LastRequestMs >= oldest)
continue;
oldest = stream.LastRequestMs;
evictId = id;
}
return oldest != long.MaxValue;
}
// Splits the shared cache budget evenly across live streams: a lone video gets the whole budget; more
// streams each get a smaller share. (Activity-weighted shares are a later refinement.)
private void RedistributeBudget()
{
var count = _streams.Count;
if (count == 0)
return;
var perStream = GlobalCacheBudgetBytes / count;
foreach (var stream in _streams.Values)
stream.Controller.SetCacheBudget(perStream);
}
// Preview/scrub uses the sibling proxy when one exists and the project enables proxy preview; rendering to
// file always uses the full-resolution source. The preference is a per-project setting (it travels with the
// project); the engine reads it straight from the active composition. Substitution is transparent to the
// operator — flipping the path makes the controller re-open on its next worker tick. (Existence-only for now;
// staleness checks come later.)
private static string ResolveEffectivePath(string absolutePath, bool renderingToFile)
{
if (renderingToFile || !CompositionSettings.Current.Proxy.UseForPreview)
return absolutePath;
var proxyPath = VideoPlayback.GetProxyPath(absolutePath);
return File.Exists(proxyPath) ? proxyPath : absolutePath;
}
private static VideoPlaybackEngine Register()
{
var engine = new VideoPlaybackEngine();
VideoPlayback.Engine = engine;
return engine;
}
// Caps and budget become project settings later (export-safe); the bounded-pool defaults match the
// observed concurrency (mostly 0-3 streams, rarely >7).
private const int MaxLiveStreams = 8;
private const long IdleTimeoutMs = 5000;
private const long IdleGraceMs = 250;
private const long GlobalCacheBudgetBytes = 1024L * 1024 * 1024;
private static VideoPlaybackEngine? _instance;
private readonly Dictionary<Guid, Stream> _streams = new();
}