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

212 lines
9.8 KiB
C#

#nullable enable annotations
namespace Lib.io.video;
/// <summary>
/// Internal helper for [VideoClipPlayer]: composites the active video clips into the bound render target,
/// lowest LayerIndex on top. Clips come from the wired multi-input and — when AutoCollect is on — from sibling
/// [VideoClip]s in the same composition (deduped). For each it points the draw subgraph's [UseTextureReference]
/// at the clip's frame and re-evaluates the subgraph so [DrawScreenQuad] draws it (the [Loop]
/// invalidate-then-evaluate pattern). Inactive clips are skipped; one about to start is pre-rolled so its first
/// frame is ready at the cut.
/// </summary>
[Guid("0162ddd9-4611-4a0a-b02f-8f68ded99cfb")]
internal sealed class _ProcessVideoClips : Instance<_ProcessVideoClips>
{
private enum ClipState
{
Inactive,
Active,
Upcoming,
}
private readonly struct ClipEntry(Slot<Texture2D> textureSlot, IVideoClipProvider provider, int layerIndex)
{
public readonly Slot<Texture2D> TextureSlot = textureSlot;
public readonly IVideoClipProvider Provider = provider; // null for a plain wired texture (no per-clip params)
public readonly int LayerIndex = layerIndex;
}
[Output(Guid = "4022374f-2022-466d-9787-a7c47fe45737")]
public readonly Slot<Command> Output = new();
public _ProcessVideoClips()
{
Output.UpdateAction += Update;
}
private void Update(EvaluationContext context)
{
var reference = TextureReference.GetValue(context);
if (reference == null)
return;
var localTime = context.LocalTime;
_activeEntries.Clear();
_seenChildIds.Clear();
// Wired clips (the multi-input). Active ones are insertion-sorted by layer; one about to start is warmed
// (preroll). Every wired clip's id is recorded so the AutoCollect scan below can't add it twice.
var clips = Textures.CollectedInputs;
for (var i = 0; i < clips.Count; i++)
{
var slot = clips[i];
var instance = slot.Parent;
if (instance != null)
_seenChildIds.Add(instance.SymbolChildId);
var provider = instance as IVideoClipProvider;
provider?.MarkManaged(); // mark before classifying, so an inactive-but-managed clip doesn't hint
var state = ClassifyClip(instance, localTime, out var layerIndex);
if (state == ClipState.Upcoming)
slot.GetValue(context);
else if (state == ClipState.Active)
InsertActive(new ClipEntry(slot, provider, layerIndex));
}
// Auto-collected sibling [VideoClip]s in the same composition (unwired), if enabled. Force-evaluating
// them works because VideoClip.Texture is DirtyFlagTrigger.Animated (always re-evaluates), so a plain
// GetValue re-decodes for the current time without an explicit InvalidateGraph. Deduped against the
// wired set (and each other) by SymbolChildId so a clip that's both wired and a sibling draws once.
if (AutoCollect.GetValue(context) && Parent?.Parent is { } composition)
{
// Rebuild the sibling-clip list only when the composition instance changes (focus / hot-reload)
// or its Symbol.VersionCounter bumps (any edit) — not every frame. Avoids the per-frame
// Children.Values walk + locked per-child lookup, a frame-drop source on large graphs.
if (!ReferenceEquals(_cachedComposition, composition) || _cachedStructureVersion != composition.Symbol.VersionCounter)
{
_cachedComposition = composition;
_cachedStructureVersion = composition.Symbol.VersionCounter;
_autoCollectedChildren.Clear();
foreach (var child in composition.Children.Values)
{
if (child is IVideoClipProvider)
_autoCollectedChildren.Add(child);
}
}
for (var i = 0; i < _autoCollectedChildren.Count; i++)
{
var child = _autoCollectedChildren[i];
if (!_seenChildIds.Add(child.SymbolChildId))
continue;
if (child is not IVideoClipProvider provider)
continue;
provider.MarkManaged();
var state = ClassifyClip(child, localTime, out var layerIndex);
if (state == ClipState.Upcoming)
provider.TextureOutput.GetValue(context);
else if (state == ClipState.Active)
InsertActive(new ClipEntry(provider.TextureOutput, provider, layerIndex));
}
}
// Composite the active set into the bound target, lowest LayerIndex on top (the list is descending by
// layer). Per-clip color (tint + alpha opacity) rides context.ForegroundColor (restored after the loop);
// the wrapper feeds it into [DrawScreenQuad].Color via [GetForegroundColor]. Blend mode goes through a
// context variable read by [GetIntVar].
var baseForeground = context.ForegroundColor;
for (var a = 0; a < _activeEntries.Count; a++)
{
var entry = _activeEntries[a];
var texture = entry.TextureSlot.GetValue(context);
if (texture == null)
continue;
// A wired texture that isn't a [VideoClip] (no provider) keeps the white / Normal defaults.
var color = entry.Provider?.ColorInput.GetValue(context) ?? Vector4.One;
var blendMode = entry.Provider?.BlendModeInput.GetValue(context) ?? 0;
context.ForegroundColor = baseForeground * color;
context.IntVariables[BlendModeVariableName] = blendMode;
// Point the subgraph's [UseTextureReference] at this clip's frame, then re-evaluate the draw subtree
// so [DrawScreenQuad] composites it into the bound render target.
reference.ColorTexture = texture;
DirtyFlag.GlobalInvalidationTick++;
DrawCommand.InvalidateGraph();
DrawCommand.GetValue(context);
}
context.ForegroundColor = baseForeground;
Textures.DirtyFlag.Clear();
}
// Keep _activeEntries descending by LayerIndex so the draw loop paints the highest layer first (back) and
// the lowest last (on top). Stable for equal layers — stops at the first entry with a strictly lower layer,
// so ties keep insertion order (wired before auto-collected, connection order within each).
private void InsertActive(ClipEntry entry)
{
var pos = _activeEntries.Count;
while (pos > 0 && _activeEntries[pos - 1].LayerIndex < entry.LayerIndex)
pos--;
_activeEntries.Insert(pos, entry);
}
// A VideoClip exposes its TimeClip via a TimeClipSlot output (ITimeClipProvider). Classifies the clip at
// localTime and (out) its layer index. Exclusive end matches TimeClipSlot's own range test so adjacent
// clips sharing a cut boundary never both draw on that frame. Upcoming = within PrerollSeconds before the
// clip's start, so its decoder can be warmed ahead of the cut. A wired texture without a TimeClip (e.g. a
// plain image) has no range, so it is always active and sits on layer 0.
private static ClipState ClassifyClip(Instance clipInstance, double localTime, out int layerIndex)
{
layerIndex = 0;
if (clipInstance == null)
return ClipState.Active;
var outputs = clipInstance.Outputs;
for (var i = 0; i < outputs.Count; i++)
{
if (outputs[i] is ITimeClipProvider clipProvider)
{
var clip = clipProvider.TimeClip;
layerIndex = clip.LayerIndex;
var range = clip.TimeRange;
if (localTime >= range.Start && localTime < range.End)
return ClipState.Active;
if (localTime >= range.Start - PrerollSeconds && localTime < range.Start)
return ClipState.Upcoming;
return ClipState.Inactive;
}
}
return ClipState.Active;
}
// Variable name the wrapper's draw subgraph reads via [GetIntVar] to pick up the active clip's blend mode.
// Must match exactly what that op is configured with in [VideoClipPlayer]. (Opacity rides ForegroundColor.)
private const string BlendModeVariableName = "VideoClip.BlendMode";
// Forward look-ahead for warming a clip's decoder before its cut-in (covers cold open + seek + first-GOP
// decode). Timeline seconds; very fast playback leaves less wall-clock to preroll, so it may still blink.
// Direction-aware preroll for reverse play lives in the engine scheduler (later).
private const double PrerollSeconds = 0.5;
// Reused across frames (Update runs on the single graph-eval thread); cleared, not reallocated, each frame.
private readonly List<ClipEntry> _activeEntries = new();
private readonly HashSet<Guid> _seenChildIds = new();
// AutoCollect scan cache — rebuilt only when the composition instance changes (reload) or its
// Symbol.VersionCounter bumps (any edit), not every frame.
private Instance? _cachedComposition;
private int _cachedStructureVersion = -1;
private readonly List<Instance> _autoCollectedChildren = new();
[Input(Guid = "116a67d9-e985-4c2e-a71b-73fbcdadbb18")]
public readonly MultiInputSlot<Texture2D> Textures = new();
[Input(Guid = "956080eb-e811-4b4a-bb73-d70a36455fa2")]
public readonly InputSlot<RenderTargetReference> TextureReference = new();
[Input(Guid = "b6930a1d-ae4a-4c79-81b4-97ff8c23b681")]
public readonly InputSlot<Command> DrawCommand = new();
[Input(Guid = "a431cf04-491d-4b0d-9691-a0375e07e855")]
public readonly InputSlot<bool> AutoCollect = new();
}