From 36ce4a66dfd5d0103e98dda751b8467f88b28859 Mon Sep 17 00:00:00 2001 From: MelekTaus Moribundus Date: Thu, 9 Jul 2026 16:38:57 +0200 Subject: [PATCH] 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 --- .../Scripts/Components/VideoClipPlaylist.cs | 350 ++++++++++++++++++ .../Components/VideoClipPlaylist.cs.meta | 2 + .../Editor/VideoClipPlaylistItemDrawer.cs | 58 +++ .../VideoClipPlaylistItemDrawer.cs.meta | 2 + 4 files changed, 412 insertions(+) create mode 100644 Assets/Scripts/Components/VideoClipPlaylist.cs create mode 100644 Assets/Scripts/Components/VideoClipPlaylist.cs.meta create mode 100644 Assets/Scripts/Editor/VideoClipPlaylistItemDrawer.cs create mode 100644 Assets/Scripts/Editor/VideoClipPlaylistItemDrawer.cs.meta diff --git a/Assets/Scripts/Components/VideoClipPlaylist.cs b/Assets/Scripts/Components/VideoClipPlaylist.cs new file mode 100644 index 0000000..2be44cf --- /dev/null +++ b/Assets/Scripts/Components/VideoClipPlaylist.cs @@ -0,0 +1,350 @@ +using System; +using UnityEngine; +using UnityEngine.Video; + +/// +/// Plays a list of clips sequentially on the sibling . 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. +/// +[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(); + + [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(); + 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(); + } + + /// Advances to the next playable entry (wraps when is on). + 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(); + 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(); + player.playbackSpeed = _active.playbackSpeed; + player.audioOutputMode = _active.audioOutputMode; + + if (_active.audioOutputMode == VideoAudioOutputMode.AudioSource) + { + AudioSource template = _active.GetTargetAudioSource(0); + if (!template) template = GetComponent(); + if (template) + { + var audio = go.AddComponent(); + 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); + } + } +} diff --git a/Assets/Scripts/Components/VideoClipPlaylist.cs.meta b/Assets/Scripts/Components/VideoClipPlaylist.cs.meta new file mode 100644 index 0000000..a5c90c0 --- /dev/null +++ b/Assets/Scripts/Components/VideoClipPlaylist.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: e05ac1f3c7372d743bff4ab1492a9fab \ No newline at end of file diff --git a/Assets/Scripts/Editor/VideoClipPlaylistItemDrawer.cs b/Assets/Scripts/Editor/VideoClipPlaylistItemDrawer.cs new file mode 100644 index 0000000..bb8259b --- /dev/null +++ b/Assets/Scripts/Editor/VideoClipPlaylistItemDrawer.cs @@ -0,0 +1,58 @@ +using UnityEditor; +using UnityEngine; + +/// +/// Draws a as two lines: the clip field and a +/// percent min-max slider (stored normalized 0..1) selecting the played portion. +/// +[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(); + } +} diff --git a/Assets/Scripts/Editor/VideoClipPlaylistItemDrawer.cs.meta b/Assets/Scripts/Editor/VideoClipPlaylistItemDrawer.cs.meta new file mode 100644 index 0000000..320373b --- /dev/null +++ b/Assets/Scripts/Editor/VideoClipPlaylistItemDrawer.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 7623ddbca00a62a4c8718ae46341b87a \ No newline at end of file