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>
This commit is contained in:
1 parent
ff01e07fa1
commit
36ce4a66df
4 files changed
+412
No files matched your search
@@ -0,0 +1,350 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: e05ac1f3c7372d743bff4ab1492a9fab
|
||||
@@ -0,0 +1,58 @@
|
||||
using UnityEditor;
|
||||
using UnityEngine;
|
||||
|
||||
/// <summary>
|
||||
/// Draws a <see cref="VideoClipPlaylist.Item"/> as two lines: the clip field and a
|
||||
/// percent min-max slider (stored normalized 0..1) selecting the played portion.
|
||||
/// </summary>
|
||||
[CustomPropertyDrawer(typeof(VideoClipPlaylist.Item))]
|
||||
public class VideoClipPlaylistItemDrawer : PropertyDrawer
|
||||
{
|
||||
private const float PercentFieldWidth = 44f;
|
||||
private const float Gap = 4f;
|
||||
|
||||
public override float GetPropertyHeight(SerializedProperty property, GUIContent label)
|
||||
=> EditorGUIUtility.singleLineHeight * 2f + EditorGUIUtility.standardVerticalSpacing;
|
||||
|
||||
public override void OnGUI(Rect position, SerializedProperty property, GUIContent label)
|
||||
{
|
||||
SerializedProperty clipProp = property.FindPropertyRelative(nameof(VideoClipPlaylist.Item.clip));
|
||||
SerializedProperty rangeProp = property.FindPropertyRelative(nameof(VideoClipPlaylist.Item.range));
|
||||
|
||||
EditorGUI.BeginProperty(position, label, property);
|
||||
|
||||
float line = EditorGUIUtility.singleLineHeight;
|
||||
var clipRect = new Rect(position.x, position.y, position.width, line);
|
||||
EditorGUI.PropertyField(clipRect, clipProp, label);
|
||||
|
||||
var rowRect = new Rect(position.x, clipRect.yMax + EditorGUIUtility.standardVerticalSpacing,
|
||||
position.width, line);
|
||||
Rect rangeRect = EditorGUI.PrefixLabel(rowRect, new GUIContent("Range (%)",
|
||||
"Played portion of the clip in percent (start/end)."));
|
||||
|
||||
Vector2 range = rangeProp.vector2Value;
|
||||
float min = Mathf.Round(range.x * 1000f) / 10f;
|
||||
float max = Mathf.Round(range.y * 1000f) / 10f;
|
||||
|
||||
var minRect = new Rect(rangeRect.x, rangeRect.y, PercentFieldWidth, line);
|
||||
var maxRect = new Rect(rangeRect.xMax - PercentFieldWidth, rangeRect.y, PercentFieldWidth, line);
|
||||
var sliderRect = new Rect(minRect.xMax + Gap, rangeRect.y,
|
||||
Mathf.Max(0f, rangeRect.width - 2f * (PercentFieldWidth + Gap)), line);
|
||||
|
||||
EditorGUI.BeginChangeCheck();
|
||||
int indent = EditorGUI.indentLevel;
|
||||
EditorGUI.indentLevel = 0; // fields inside the row must not re-indent
|
||||
min = EditorGUI.FloatField(minRect, min);
|
||||
EditorGUI.MinMaxSlider(sliderRect, ref min, ref max, 0f, 100f);
|
||||
max = EditorGUI.FloatField(maxRect, max);
|
||||
EditorGUI.indentLevel = indent;
|
||||
if (EditorGUI.EndChangeCheck())
|
||||
{
|
||||
min = Mathf.Clamp(min, 0f, 100f);
|
||||
max = Mathf.Clamp(max, min, 100f);
|
||||
rangeProp.vector2Value = new Vector2(min / 100f, max / 100f);
|
||||
}
|
||||
|
||||
EditorGUI.EndProperty();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 7623ddbca00a62a4c8718ae46341b87a
|
||||
Reference in new issue
Block a user