Files
chameleon/Assets/Scripts/Components/VideoClipPlaylist.cs
T
adminandClaude Fable 5 36ce4a66df Add VideoClipPlaylist component for sequential gapless clip playback
Plays a list of items (clip + percentage range) on a VideoPlayer.
Double-buffers via a second player (auto-created child incl. AudioSource
copy) and drives the screen texture through a MaterialPropertyBlock,
flipping only once the next player delivers frames - no black/freeze gap.
Editor drawer renders each item as clip row + min-max percent range slider.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-09 16:38:57 +02:00

351 lines
11 KiB
C#

using System;
using UnityEngine;
using UnityEngine.Video;
/// <summary>
/// Plays a list of clips sequentially on the sibling <see cref="VideoPlayer"/>. Each entry
/// defines the portion of its clip to play as a normalized start/end range (drawn as a
/// percent min-max slider). With an empty list the component leaves the player untouched,
/// so a plain single-clip setup keeps working.
/// For material-override screens (e.g. the TV) a second player pre-buffers the next entry
/// while the current one plays; both players run API-only and this component drives the
/// screen texture via a property block, flipping it only once the incoming player delivers
/// frames — transitions are gapless. Other render modes fall back to single-player playback.
/// </summary>
[RequireComponent(typeof(VideoPlayer))]
[DisallowMultipleComponent]
public class VideoClipPlaylist : MonoBehaviour
{
[Serializable]
public class Item
{
[Tooltip("Clip to play for this entry. Entries without a clip are skipped.")]
public VideoClip clip;
[Tooltip("Portion of the clip to play, normalized 0..1 (shown as percent).")]
public Vector2 range = new Vector2(0f, 1f);
}
[Tooltip("Played top to bottom; entries without a clip are skipped.")]
public Item[] items = Array.Empty<Item>();
[Tooltip("Restart from the first entry after the last one finished.")]
public bool loop = true;
[Tooltip("Second player used to pre-buffer the next entry for gapless transitions. " +
"Auto-created on a child when left empty (material-override screens only).")]
public VideoPlayer secondaryPlayer;
// Segment end is checked slightly early so ranges ending at 100% advance via the time
// check even when VideoPlayer's loop-point event races it (both paths advance safely).
private const double EndEpsilon = 0.05;
private const double MinSegmentSeconds = 0.05;
private VideoPlayer _active;
private VideoPlayer _standby; // null => single-player fallback (non material-override)
private Renderer _screen;
private int _screenProperty;
private MaterialPropertyBlock _block;
private Texture _presented;
private bool _setupDone;
private int _index = -1;
private double _segmentEnd = double.MaxValue;
private bool _activePreparing;
private bool _endReached;
private int _standbyIndex = -1;
private double _standbySegmentEnd;
private bool _standbyReady;
private bool _switching;
private void Awake()
{
_active = GetComponent<VideoPlayer>();
Hook(_active);
}
private void OnDestroy()
{
Unhook(_active);
Unhook(_standby);
}
private void OnEnable()
{
int first = NextPlayableIndex(0);
if (first < 0) return; // empty list: leave the player's own setup alone
SetupOnce();
Configure(_active);
if (_standby) Configure(_standby);
PlayItem(first);
}
private void OnDisable()
{
_activePreparing = false;
_standbyReady = false;
_switching = false;
_endReached = false;
_index = -1;
_standbyIndex = -1;
if (_active) _active.Stop();
if (_standby) _standby.Stop();
}
private void Update()
{
if (_index < 0) return;
if (_switching)
{
// Hold the old frame until the incoming player actually delivers.
if (_standby.frame >= 0 && _standby.texture) CompleteSwitch();
}
else if (!_activePreparing)
{
if (_endReached || (_active.isPlaying && _active.time >= _segmentEnd))
Advance();
}
Present();
}
/// <summary>Advances to the next playable entry (wraps when <see cref="loop"/> is on).</summary>
public void Next()
{
if (_index < 0)
{
int first = NextPlayableIndex(0);
if (first >= 0 && isActiveAndEnabled) PlayItem(first);
return;
}
_endReached = true;
}
private void SetupOnce()
{
if (_setupDone) return;
_setupDone = true;
// Gapless playback needs to own the output; that is only possible when the player
// drives a renderer material — then both players run API-only and this component
// applies the current texture itself.
if (_active.renderMode != VideoRenderMode.MaterialOverride) return;
_screen = _active.targetMaterialRenderer ? _active.targetMaterialRenderer : GetComponent<Renderer>();
if (!_screen) return;
string property = _active.targetMaterialProperty;
_screenProperty = Shader.PropertyToID(string.IsNullOrEmpty(property) ? "_MainTex" : property);
_block = new MaterialPropertyBlock();
if (!secondaryPlayer) secondaryPlayer = CreateSecondaryPlayer();
_standby = secondaryPlayer;
Hook(_standby);
}
private VideoPlayer CreateSecondaryPlayer()
{
var go = new GameObject("Video Player (Playlist Buffer)");
go.transform.SetParent(transform, false);
var player = go.AddComponent<VideoPlayer>();
player.playbackSpeed = _active.playbackSpeed;
player.audioOutputMode = _active.audioOutputMode;
if (_active.audioOutputMode == VideoAudioOutputMode.AudioSource)
{
AudioSource template = _active.GetTargetAudioSource(0);
if (!template) template = GetComponent<AudioSource>();
if (template)
{
var audio = go.AddComponent<AudioSource>();
CopyAudioSettings(template, audio);
player.SetTargetAudioSource(0, audio);
}
else
{
player.audioOutputMode = VideoAudioOutputMode.Direct;
}
}
return player;
}
private static void CopyAudioSettings(AudioSource from, AudioSource to)
{
to.playOnAwake = false;
to.loop = false;
to.outputAudioMixerGroup = from.outputAudioMixerGroup;
to.mute = from.mute;
to.volume = from.volume;
to.pitch = from.pitch;
to.priority = from.priority;
to.panStereo = from.panStereo;
to.spatialBlend = from.spatialBlend;
to.spatialize = from.spatialize;
to.spatializePostEffects = from.spatializePostEffects;
to.reverbZoneMix = from.reverbZoneMix;
to.bypassEffects = from.bypassEffects;
to.bypassListenerEffects = from.bypassListenerEffects;
to.bypassReverbZones = from.bypassReverbZones;
to.dopplerLevel = from.dopplerLevel;
to.spread = from.spread;
to.rolloffMode = from.rolloffMode;
to.minDistance = from.minDistance;
to.maxDistance = from.maxDistance;
}
private void Configure(VideoPlayer player)
{
player.playOnAwake = false;
player.isLooping = false;
player.source = VideoSource.VideoClip;
if (_screen) player.renderMode = VideoRenderMode.APIOnly;
}
private void Hook(VideoPlayer player)
{
player.prepareCompleted += OnPrepareCompleted;
player.loopPointReached += OnLoopPointReached;
}
private void Unhook(VideoPlayer player)
{
if (!player) return;
player.prepareCompleted -= OnPrepareCompleted;
player.loopPointReached -= OnLoopPointReached;
}
private int NextPlayableIndex(int start)
{
for (int i = start; i < items.Length; i++)
if (items[i] != null && items[i].clip)
return i;
return -1;
}
private int NextPlayableAfter(int index)
{
int next = NextPlayableIndex(index + 1);
if (next < 0 && loop) next = NextPlayableIndex(0);
return next;
}
private void PlayItem(int index)
{
_index = index;
_activePreparing = true;
_segmentEnd = double.MaxValue;
_active.Stop();
_active.clip = items[index].clip;
_active.Prepare();
}
private void PrepareStandby(int index)
{
_standbyIndex = index;
_standbyReady = false;
if (index < 0) return; // last entry without loop: nothing to buffer
_standby.Stop();
_standby.clip = items[index].clip;
_standby.Prepare();
}
private void Advance()
{
if (_standby)
{
if (_standbyIndex < 0)
{
_endReached = false;
StopPlayback();
return;
}
if (!_standbyReady) return; // buffer not ready yet: hold the frame, retry next Update
_endReached = false;
_standby.Play();
_switching = true;
}
else
{
_endReached = false;
int next = NextPlayableAfter(_index);
if (next < 0) StopPlayback();
else PlayItem(next);
}
}
private void CompleteSwitch()
{
_switching = false;
_active.Stop();
(_active, _standby) = (_standby, _active);
_index = _standbyIndex;
_segmentEnd = _standbySegmentEnd;
PrepareStandby(NextPlayableAfter(_index));
}
private void StopPlayback()
{
_index = -1;
_active.Stop();
}
private void Present()
{
if (!_screen) return;
Texture texture = _active.texture;
if (!texture || texture == _presented) return;
_presented = texture;
_screen.GetPropertyBlock(_block);
_block.SetTexture(_screenProperty, texture);
_screen.SetPropertyBlock(_block);
}
private double ComputeSegment(int index, double length, out double segmentEnd)
{
Vector2 range = items[index].range;
double start = length * Mathf.Clamp01(Mathf.Min(range.x, range.y));
double end = length * Mathf.Clamp01(Mathf.Max(range.x, range.y));
// Degenerate ranges still play a minimal moment instead of skip-looping forever.
end = Math.Min(Math.Max(end, start + MinSegmentSeconds), length);
segmentEnd = end - EndEpsilon;
return start;
}
private void OnPrepareCompleted(VideoPlayer source)
{
if (source == _active && _activePreparing && _index >= 0)
{
_activePreparing = false;
source.time = ComputeSegment(_index, source.length, out _segmentEnd);
source.Play();
if (_standby && !_switching) PrepareStandby(NextPlayableAfter(_index));
}
else if (source == _standby && _standbyIndex >= 0 && !_standbyReady)
{
// Pre-seek now so the switch starts on the right frame without a visible jump.
source.time = ComputeSegment(_standbyIndex, source.length, out _standbySegmentEnd);
_standbyReady = true;
}
}
private void OnLoopPointReached(VideoPlayer source)
{
// Natural clip end (range up to 100%) — advance if this is the presenting player.
if (source == _active && !_activePreparing && !_switching && _index >= 0 && isActiveAndEnabled)
_endReached = true;
}
private void OnValidate()
{
if (items == null) return;
foreach (Item item in items)
{
if (item == null) continue;
// Fresh array elements zero-initialize the range; default to "full clip".
if (item.range == Vector2.zero) item.range = new Vector2(0f, 1f);
}
}
}