using System; using System.Collections.Generic; using System.Dynamic; using System.Linq; using System.Reflection; using Needle.Engine.Core; using Needle.Engine.Gltf; using Needle.Engine.Interfaces; using Needle.Engine.Problems; using Needle.Engine.Utils; using Newtonsoft.Json.Linq; using UnityEditor; using UnityEngine; using UnityEngine.Playables; using UnityEngine.Timeline; using UnityGLTF.Extensions; using Object = UnityEngine.Object; namespace Needle.Engine.Timeline { public class PlayableDirectorExportContext { public PlayableDirector Director; public IExportContext ExportContext; public TrackExportContext CurrentTrack; /// /// Key is either a TimelineClip or an AnimationClip if the track is in infinite mode /// public readonly Dictionary ClipMap = new Dictionary(); public PlayableDirectorExportContext(PlayableDirector director, IExportContext exportContext) { Director = director; ExportContext = exportContext; } } public class TrackExportContext { public readonly List Bindings = new List(); } public static class TimelineSerializer { public delegate void CreateModelEvent(object asset, ref object model); public static event CreateModelEvent CreateModel; public const string ExportMutedAssetLabel = "ExportMuted"; /// /// Called before a track is added to the serialization model. Useful to e.g. control a tracks mute state to be added to the model or not (if a track is muted it is not added by default /// public static event Action BeforeAddTrack; /// /// Director, Asset, Bool if it was added /// public static event Action AfterAddTrack; public static bool TryExportPlayableAsset(PlayableDirectorExportContext context, TimelineAsset asset, out object result) { // evaluate the timeline once to make sure the data is up to date // this fixes an issue we had for one timeline when building the website // where exported clips/offset/... were not correct and the timeline had // always to be opened once in the timeline window to be exported correctly. if (context.Director) { TimelineUtils.EvaluateTimeline(context.Director); } var tags = AssetDatabase.GetLabels(asset); var ignoreMuted = true; foreach (var tag in tags) { if(tag.Equals(ExportMutedAssetLabel, StringComparison.OrdinalIgnoreCase)) ignoreMuted = false; } // const int k_maxStatesAfterTrial = 2; // var trialHasEnded = NeedleEngineAuthorization.TrialEnded; // var isInTrial = NeedleEngineAuthorization.IsInTrialPeriod; // var exportedTracks = 0; var exp = new TimelineAssetModel(); exp.name = asset.name; exp.guid = asset.GetId(); var outputTracks = asset.GetOutputTracks().ToArray(); // if (outputTracks.Length > k_maxStatesAfterTrial && !LicenseCheck.HasLicense && isInTrial) // { // Debug.LogWarning( // $"Timeline \"{asset.name}\" has more than {k_maxStatesAfterTrial} tracks. Please upgrade to an Indie or Pro Plan of Needle to export more than {k_maxStatesAfterTrial} tracks after your trial has ended.", asset); // } foreach (var track in outputTracks) { // if (LicenseCheck.HasLicense == false) // { // if (exportedTracks++ >= k_maxStatesAfterTrial && trialHasEnded) // { // Debug.LogWarning( // $"Timeline \"{asset.name}\" has more than {k_maxStatesAfterTrial} tracks. Please upgrade to an Indie or Pro Plan of Needle to export more than {k_maxStatesAfterTrial} timeline tracks."); // var info = new BuildResultInformation($"Timeline \"{asset.name}\" has {outputTracks.Length} tracks", asset, ProblemSeverity.Error); // info.ActionDescription = "Purchase a commercial license or reduce to 2 tracks"; // BuildResultInformation.Report(info); // break; // } // } BeforeAddTrack?.Invoke(context.Director, track); var shouldAdd = !ignoreMuted || track.mutedInHierarchy == false; if (shouldAdd) { AddTrack(exp, context.Director, track, context); } AfterAddTrack?.Invoke(context.Director, track, shouldAdd); } result = exp; return true; } private static bool _triedGettingSceneOffsetProperties; private static PropertyInfo _sceneOffsetPositionProp, _sceneOffsetRotationProp; private static Quaternion _sceneOffsetRotationTempQuat = new Quaternion(); public static void AddTrack(TimelineAssetModel timelineAssetModel, PlayableDirector dir, TrackAsset track, PlayableDirectorExportContext context) { var tr = new TimelineTrackModel(); tr.name = track.name; tr.type = track.GetType().Name; tr.muted = track.muted; context.CurrentTrack ??= new TrackExportContext(); var trackContext = context.CurrentTrack; trackContext.Bindings.Clear(); foreach (var output in track.outputs) { var binding = dir.GetGenericBinding(output.sourceObject); if (binding) { trackContext.Bindings.Add(binding); tr.outputs.Add(binding.GetId()); } else tr.outputs.Add(null); } timelineAssetModel.tracks.Add(tr); if (track is AnimationTrack animationTrack) { tr.trackOffset = new TrackOffset(); switch (animationTrack.trackOffset) { case UnityEngine.Timeline.TrackOffset.ApplySceneOffsets: if (_sceneOffsetPositionProp == null) { if (_triedGettingSceneOffsetProperties) break; _triedGettingSceneOffsetProperties = true; _sceneOffsetPositionProp = typeof(AnimationTrack).GetProperty("sceneOffsetPosition", BindingFlags.Instance | BindingFlags.NonPublic); _sceneOffsetRotationProp = typeof(AnimationTrack).GetProperty("sceneOffsetRotation", BindingFlags.Instance | BindingFlags.NonPublic); if(_sceneOffsetPositionProp == null || _sceneOffsetRotationProp == null) break; } tr.trackOffset.position = new Vec3((Vector3)_sceneOffsetPositionProp.GetValue(animationTrack)); var rot = (Vector3)_sceneOffsetRotationProp.GetValue(animationTrack); _sceneOffsetRotationTempQuat.eulerAngles = rot; tr.trackOffset.rotation = new Quat(_sceneOffsetRotationTempQuat.ToGltfQuaternionConvert()); break; case UnityEngine.Timeline.TrackOffset.Auto: case UnityEngine.Timeline.TrackOffset.ApplyTransformOffsets: tr.trackOffset.position = new Vec3(animationTrack.position); tr.trackOffset.rotation = new Quat(animationTrack.rotation.ToGltfQuaternionConvert()); break; } if (animationTrack.inClipMode == false && animationTrack.infiniteClip) { var cl = new TimelineClipModel(); cl.start = animationTrack.start; cl.end = animationTrack.end; cl.duration = animationTrack.duration; cl.timeScale = 1; CreateAnimationClipModel(context, tr, cl, TimelineAnimationClipInfo.CreateFromInfiniteTrack(animationTrack), animationTrack.infiniteClip, animationTrack.infiniteClip); cl.preExtrapolationMode = (int)TimelineClip.ClipExtrapolation.Hold; cl.postExtrapolationMode = (int)TimelineClip.ClipExtrapolation.Hold; tr.clips.Add(cl); } } else if (track is AudioTrack audioTrack) { tr.volume = 1; if (TryGetAudioTrackVolume(audioTrack, out var vol)) { tr.volume = vol; } } foreach (var clip in track.GetClips()) AddClip(tr, clip, context); foreach (var marker in track.GetMarkers()) AddMarker(tr, marker, context); } private static readonly Dictionary fieldsForType = new Dictionary(); public static void AddClip(TimelineTrackModel timelineTrackModel, TimelineClip clip, PlayableDirectorExportContext context) { var cl = new TimelineClipModel(); cl.start = clip.start; cl.end = clip.end; cl.duration = clip.duration; cl.timeScale = clip.timeScale; cl.clipIn = clip.clipIn; cl.easeInDuration = clip.blendInDuration >= 0 ? clip.blendInDuration : clip.easeInDuration; cl.easeOutDuration = clip.blendOutDuration >= 0 ? clip.blendOutDuration : clip.easeOutDuration; cl.preExtrapolationMode = (int)clip.preExtrapolationMode; cl.postExtrapolationMode = (int)clip.postExtrapolationMode; timelineTrackModel.clips.Add(cl); switch (clip.asset) { default: if (clip.asset) { CreateModel?.Invoke(clip.asset, ref cl.asset); if (cl.asset == null) { // automatically create a model when none is provided via callback var assetType = clip.asset.GetType(); var serializable = typeof(SerializeField); var obj = new ExpandoObject() as IDictionary; cl.asset = obj; if (!fieldsForType.TryGetValue(assetType, out var fields)) { fields = assetType.GetFields(BindingFlags.Instance | BindingFlags.NonPublic | BindingFlags.Public); fieldsForType.Add(assetType, fields); } foreach (var type in fields) { if (type.GetCustomAttribute(serializable) != null) { obj.Add(type.Name, type.GetValue(clip.asset)); } } } } break; case ControlPlayableAsset control: var target = control.sourceGameObject.Resolve(context.Director); if (target) { var controlModel = new ControlClipModel(); controlModel.sourceObject = target.GetId(); controlModel.controlActivation = control.active; controlModel.updateDirector = control.updateDirector; cl.asset = controlModel; } break; case AudioPlayableAsset audio: var audioClip = audio.clip; if (audioClip) { var model = new AudioCLipModel(); model.loop = audio.loop; model.clip = audioClip; if(TryGetAudioClipVolume(clip, audio, out var volume)) model.volume = volume; cl.asset = model; } else { // dont export audio clip track with missing clip timelineTrackModel.clips.Remove(cl); } break; case AnimationPlayableAsset anim: CreateAnimationClipModel(context, timelineTrackModel, cl, TimelineAnimationClipInfo.CreateFromAsset(anim), anim.clip, clip); break; } } private static readonly FieldInfo audioClipPropertiesField = typeof(AudioPlayableAsset).GetField("m_ClipProperties", BindingFlags.Instance | BindingFlags.NonPublic); private static FieldInfo audioClipPropertiesVolumeField; private static bool TryGetAudioClipVolume(TimelineClip clip, AudioPlayableAsset audio, out float volume) { if (audioClipPropertiesField != null) { var props = audioClipPropertiesField.GetValue(audio); if (props != null) { audioClipPropertiesVolumeField ??= props.GetType().GetField("volume", BindingFlags.Instance | BindingFlags.Public); var res = audioClipPropertiesVolumeField?.GetValue(props); if (res is float fl) { volume = fl; return true; } } } volume = 0; return false; } private static readonly FieldInfo audioTrackPropertiesField = typeof(AudioTrack).GetField("m_TrackProperties", BindingFlags.Instance | BindingFlags.NonPublic); private static FieldInfo audioMixerVolumeField; private static bool TryGetAudioTrackVolume(AudioTrack track, out float volume) { if (audioTrackPropertiesField != null) { var props = audioTrackPropertiesField.GetValue(track); if (props != null) { audioMixerVolumeField ??= props.GetType().GetField("volume", BindingFlags.Instance | BindingFlags.Public); var res = audioMixerVolumeField?.GetValue(props); if (res is float fl) { volume = fl; return true; } } } volume = 0; return false; } private struct TimelineAnimationClipInfo { public string name; public bool removeStartOffset; public Vector3 position; public Quaternion rotation; public AnimationPlayableAsset.LoopMode loop; public static TimelineAnimationClipInfo CreateFromInfiniteTrack(AnimationTrack track) { Debug.Assert(track.inClipMode == false); Debug.Assert(track.infiniteClip); var useOffset = track.infiniteClip?.UseOffsets() ?? true; return new TimelineAnimationClipInfo() { name = track.name, removeStartOffset = false, position = useOffset ? track.infiniteClipOffsetPosition : Vector3.zero, rotation = useOffset ? track.infiniteClipOffsetRotation : Quaternion.identity }; } public static TimelineAnimationClipInfo CreateFromAsset(AnimationPlayableAsset asset) { var useOffset = asset.clip?.UseOffsets() ?? true; return new TimelineAnimationClipInfo() { removeStartOffset = asset.removeStartOffset, position = useOffset ? asset.position : Vector3.zero, rotation = useOffset ? asset.rotation : Quaternion.identity, loop = asset.loop, name = asset.name }; } } private static AnimationClipModel CreateAnimationClipModel(PlayableDirectorExportContext context, TimelineTrackModel timelineTrackModel, TimelineClipModel cl, TimelineAnimationClipInfo anim, AnimationClip clip, object key) { if (!clip) return null; if (context.ClipMap.TryGetValue(key, out var id)) { var animClipModel = new AnimationClipModel(); cl.asset = animClipModel; animClipModel.clip = id.AsAnimationPointer(); animClipModel.duration = clip.length; animClipModel.removeStartOffset = anim.removeStartOffset; animClipModel.position = new Vec3(anim.position); animClipModel.position.x *= -1; // (animClipModel.position.x, animClipModel.position.z) = (animClipModel.position.z, animClipModel.position.x); animClipModel.rotation = new Quat(anim.rotation.ToGltfQuaternionConvert()); switch (anim.loop) { default: case AnimationPlayableAsset.LoopMode.UseSourceAsset: animClipModel.loop = clip.isLooping; break; case AnimationPlayableAsset.LoopMode.On: animClipModel.loop = true; break; case AnimationPlayableAsset.LoopMode.Off: animClipModel.loop = false; break; } return animClipModel; } var hasAnyOutput = timelineTrackModel.outputs.FirstOrDefault(s => !string.IsNullOrWhiteSpace(s)) != null; if (hasAnyOutput) Debug.LogWarning( $"Some animation clips could not be found/exported for \"{anim.name}/{clip.name}\". The objects might be disabled or the clips may need KHR_animation_pointer support.", clip); return null; } public static void AddMarker(TimelineTrackModel timelineTrackModel, IMarker marker, PlayableDirectorExportContext context) { switch (marker) { case SignalEmitter emitter: var model = new TimelineSignalEmitterMarkerModel(); model.name = emitter.name; model.type = nameof(SignalEmitter); model.time = (float)emitter.time; model.retroActive = emitter.retroactive; model.emitOnce = emitter.emitOnce; model.asset = emitter.asset.GetId(); timelineTrackModel.markers.Add(model); break; } } } }