178 lines
8.3 KiB
C#
178 lines
8.3 KiB
C#
using System;
|
||
using UnityEngine;
|
||
using UnityEngine.Playables;
|
||
using UnityEngine.Timeline;
|
||
#if UNITY_EDITOR
|
||
using UnityEditor;
|
||
#endif
|
||
|
||
namespace FT.Timeline
|
||
{
|
||
/// <summary>多线程 Timeline 驱动策略(与 <see cref="LeviathanVideoPlayableAsset.timelineMultithreadedDriveMode"/> 对应)。</summary>
|
||
public enum LeviathanVideoTimelineMtDriveMode
|
||
{
|
||
/// <summary>不外接时钟;子线程持续解码,ProcessFrame 只消费就绪帧追上 Timeline(BossFight 等高帧率推荐)。序列化默认 0 以便旧资源无字段时自动走此模式。</summary>
|
||
DecodeThreadCatchUp = 0,
|
||
/// <summary>外接时钟 + <c>SyncMultithreadedToTimelineFrame</c>(每拍 Pause/握手,仅当 CatchUp 有异常时选用)。</summary>
|
||
ProcessFrameLockstep = 1,
|
||
}
|
||
|
||
[Serializable]
|
||
#if UNITY_EDITOR
|
||
[System.ComponentModel.DisplayName("Leviathan Video Clip")]
|
||
#endif
|
||
/// <summary>
|
||
/// 在 Leviathan 视频轨上<strong>新建</strong>片段时,若 Timeline 窗口中已为该轨绑定解码器,会自动把 <see cref="videoOverride"/> 与 <see cref="clipDurationSeconds"/> 同步为当前视频资源与时长。
|
||
/// 在 Inspector 中更换 <see cref="videoOverride"/> 时也会按 bytes 内元数据(含 Duration / 帧推算)自动写入 <see cref="clipDurationSeconds"/>;亦可使用检视面板「从视频同步时长」手动对齐 Timeline 片段长度。
|
||
/// 性能分析:代码里设 <c>LeviathanLogConfig.EnableTimelineVideoPerfLog = true</c>,或 PlayerPrefs <c>LeviathanTimelinePerf=1</c> 后重启,看 Logcat 中 <c>[LeviathanTimelinePerf]</c>。
|
||
/// </summary>
|
||
public class LeviathanVideoPlayableAsset : PlayableAsset, ITimelineClipAsset
|
||
{
|
||
[Tooltip("留空则使用绑定物体上 LeviathanVideoDecoder 的 bytesFile")]
|
||
[SerializeField]
|
||
public TextAsset videoOverride;
|
||
|
||
[Tooltip("片段在 Timeline 上的长度,建议与视频时长一致;指定 Video Override 后会自动按资源解析时长")]
|
||
[SerializeField]
|
||
[Min(0.01f)]
|
||
public double clipDurationSeconds = 5.0;
|
||
|
||
/// <summary>编辑器:上次已根据 <see cref="videoOverride"/> 自动同步过时长的资源实例 ID,避免反复覆盖用户手改的长度。</summary>
|
||
[SerializeField, HideInInspector]
|
||
int _editorLastAutoSyncVideoOverrideInstanceId;
|
||
|
||
[SerializeField]
|
||
public bool syncDecodeFirstFrame = true;
|
||
|
||
[SerializeField]
|
||
public bool fastDecode = true;
|
||
|
||
[Tooltip("多线程解码并由 ProcessFrame 对齐 Timeline;主线程只做握手与显示,解码在子线程。顺序播时会预解下一帧;编辑器预览同样生效(需可写 Pause/Resume)。")]
|
||
[SerializeField]
|
||
public bool useMultithreadedDecode = true;
|
||
|
||
[Tooltip("多线程时:CatchUp=子线程持续解码、ProcessFrame 只取帧(近 Match 待机,BossFight 推荐);Lockstep=外接时钟+每帧 Sync(旧行为)。")]
|
||
[SerializeField]
|
||
public LeviathanVideoTimelineMtDriveMode timelineMultithreadedDriveMode = LeviathanVideoTimelineMtDriveMode.DecodeThreadCatchUp;
|
||
|
||
[Tooltip("仅当开启多线程并由 Timeline 驱动时生效:等解码线程交出下一帧的最长等待(毫秒)。过小易频繁 Seek 更卡;过大阻塞主线程。顺序播放建议 100~200。")]
|
||
[SerializeField]
|
||
[Min(8)]
|
||
public int timelineMultithreadedWaitMs = 150;
|
||
|
||
[Tooltip("CatchUp:单次 ProcessFrame 最多消费几帧就绪(与「整帧预算」取较小值)。≤0 表示 8。")]
|
||
[SerializeField]
|
||
[Min(0)]
|
||
public int timelineCatchUpMaxConsumePerCall = 0;
|
||
|
||
[Tooltip("CatchUp:同一 Unity 帧内所有 ProcessFrame 合计最多几次 DisplayFrame(抑 60Hz 求值×多遍 Graph)。≤0 时 Android=6,其它=8。")]
|
||
[SerializeField]
|
||
[Min(0)]
|
||
public int timelineCatchUpUnityFrameDisplayBudget = 0;
|
||
|
||
[Tooltip("勾选后整帧 Display 预算再封顶为 3,适合约 24fps 片源 + 60Hz Timeline。")]
|
||
[SerializeField]
|
||
public bool timelineCatchUpConservativeLowFpsBudget;
|
||
|
||
/// <summary>必须包含 <see cref="ClipCaps.ClipIn"/>,否则 Timeline 会禁止掐头(<c>clipIn</c> 无法写入、始终为 0)。</summary>
|
||
public ClipCaps clipCaps => ClipCaps.ClipIn;
|
||
|
||
public override double duration => clipDurationSeconds;
|
||
|
||
public override Playable CreatePlayable(PlayableGraph graph, GameObject owner)
|
||
{
|
||
var playable = ScriptPlayable<LeviathanVideoBehaviour>.Create(graph);
|
||
var behaviour = playable.GetBehaviour();
|
||
behaviour.videoOverride = videoOverride;
|
||
behaviour.syncDecodeFirstFrame = syncDecodeFirstFrame;
|
||
behaviour.fastDecode = fastDecode;
|
||
behaviour.useMultithreadedDecode = useMultithreadedDecode;
|
||
behaviour.timelineMultithreadedDriveMode = timelineMultithreadedDriveMode;
|
||
behaviour.timelineMultithreadedWaitMs = timelineMultithreadedWaitMs;
|
||
behaviour.sourcePlayableAsset = this;
|
||
return playable;
|
||
}
|
||
|
||
/// <summary>从容积 bytes(<see cref="LeviathanVideoDecoderBase.TryProbeVideoMetaFromBytes"/>)解析时长,与解码器 Duration / 帧数推算一致。</summary>
|
||
public static bool TryGetClipDurationSecondsFromTextAsset(TextAsset file, out double seconds)
|
||
{
|
||
seconds = 0;
|
||
if (file == null)
|
||
return false;
|
||
if (!LeviathanVideoDecoderBase.TryProbeVideoMetaFromBytes(file, out seconds, out _, out _))
|
||
return false;
|
||
return seconds > 0.0001;
|
||
}
|
||
|
||
/// <summary>优先使用解码器已得到的 Duration / 帧间隔,否则回退解析 <see cref="LeviathanVideoDecoderBase.bytesFile"/>。</summary>
|
||
public static bool TryGetClipDurationSecondsFromDecoder(LeviathanVideoDecoderBase decoder, out double seconds)
|
||
{
|
||
seconds = 0;
|
||
if (decoder == null)
|
||
return false;
|
||
if (decoder.Duration > 0)
|
||
seconds = decoder.Duration / 1_000_000.0;
|
||
else if (decoder.FrameCount > 0 && decoder.FrameInterval > 0)
|
||
seconds = decoder.FrameCount * decoder.FrameInterval;
|
||
if (seconds > 0.0001)
|
||
return true;
|
||
return decoder.bytesFile != null && TryGetClipDurationSecondsFromTextAsset(decoder.bytesFile, out seconds);
|
||
}
|
||
|
||
#if UNITY_EDITOR
|
||
void OnValidate()
|
||
{
|
||
if (videoOverride == null)
|
||
{
|
||
_editorLastAutoSyncVideoOverrideInstanceId = 0;
|
||
return;
|
||
}
|
||
|
||
var id = videoOverride.GetInstanceID();
|
||
if (id == _editorLastAutoSyncVideoOverrideInstanceId)
|
||
return;
|
||
|
||
_editorLastAutoSyncVideoOverrideInstanceId = id;
|
||
if (TryGetClipDurationSecondsFromTextAsset(videoOverride, out var sec))
|
||
{
|
||
clipDurationSeconds = sec;
|
||
EditorSyncParentTimelineClipDurations(this);
|
||
}
|
||
|
||
EditorUtility.SetDirty(this);
|
||
}
|
||
|
||
/// <summary>编辑器:将父级 Timeline 中引用本资源的 <see cref="TimelineClip"/> 长度与 <see cref="clipDurationSeconds"/> 对齐。</summary>
|
||
public static void EditorSyncParentTimelineClipDurations(LeviathanVideoPlayableAsset asset)
|
||
{
|
||
if (asset == null)
|
||
return;
|
||
var path = AssetDatabase.GetAssetPath(asset);
|
||
if (string.IsNullOrEmpty(path))
|
||
return;
|
||
if (AssetDatabase.LoadMainAssetAtPath(path) is not TimelineAsset timelineAsset)
|
||
return;
|
||
|
||
var dirty = false;
|
||
var targetDur = asset.duration;
|
||
foreach (var track in timelineAsset.GetOutputTracks())
|
||
{
|
||
foreach (var clip in track.GetClips())
|
||
{
|
||
if (clip.asset != asset)
|
||
continue;
|
||
if (System.Math.Abs(clip.duration - targetDur) > 1e-6)
|
||
{
|
||
clip.duration = targetDur;
|
||
dirty = true;
|
||
}
|
||
}
|
||
}
|
||
|
||
if (dirty)
|
||
EditorUtility.SetDirty(timelineAsset);
|
||
}
|
||
#endif
|
||
}
|
||
}
|