using System; using System.Collections.Generic; using System.Linq; using UnityEngine; using UnityEngine.Playables; using UnityEngine.Timeline; namespace UnityEditor.Timeline { class TimelineClipGUI : TimelineItemGUI, IClipCurveEditorOwner, ISnappable, IAttractable { EditorClip m_EditorItem; Rect m_ClipCenterSection; readonly List m_LoopRects = new List(); ClipDrawData m_ClipDrawData; Rect m_MixOutRect = new Rect(); Rect m_MixInRect = new Rect(); int m_MinLoopIndex = 1; // clip dirty detection int m_LastDirtyIndex = Int32.MinValue; bool m_ClipViewDirty = true; bool supportResize { get; } public ClipCurveEditor clipCurveEditor { get; set; } public TimelineClipGUI previousClip { get; set; } public TimelineClipGUI nextClip { get; set; } static readonly float k_MinMixWidth = 2; static readonly float k_MaxHandleWidth = 10f; static readonly float k_MinHandleWidth = 1f; bool? m_ShowDrillIcon; ClipEditor m_ClipEditor; static List s_TempSubDirectors = new List(); static readonly IconData k_DiggableClipIcon = new IconData(DirectorStyles.LoadIcon("TimelineDigIn")); string name { get { if (string.IsNullOrEmpty(clip.displayName)) return "(Empty)"; return clip.displayName; } } public bool inlineCurvesSelected => SelectionManager.IsCurveEditorFocused(this); public Rect mixOutRect { get { var percent = clip.mixOutPercentage; var x = Mathf.Round(treeViewRect.width * (1 - percent)); var width = Mathf.Round(treeViewRect.width * percent); m_MixOutRect.Set(x, 0.0f, width, treeViewRect.height); return m_MixOutRect; } } public Rect mixInRect { get { var width = Mathf.Round(treeViewRect.width * clip.mixInPercentage); m_MixInRect.Set(0.0f, 0.0f, width, treeViewRect.height); return m_MixInRect; } } public ClipBlends GetClipBlends() { var _mixInRect = mixInRect; var _mixOutRect = mixOutRect; var blendInKind = BlendKind.None; if (_mixInRect.width > k_MinMixWidth && clip.hasBlendIn) blendInKind = BlendKind.Mix; else if (_mixInRect.width > k_MinMixWidth) blendInKind = BlendKind.Ease; var blendOutKind = BlendKind.None; if (_mixOutRect.width > k_MinMixWidth && clip.hasBlendOut) blendOutKind = BlendKind.Mix; else if (_mixOutRect.width > k_MinMixWidth) blendOutKind = BlendKind.Ease; return new ClipBlends(blendInKind, _mixInRect, blendOutKind, _mixOutRect); } public override double start { get { return clip.start; } } public override double end { get { return clip.end; } } public bool supportsLooping { get { return clip.SupportsLooping(); } } // for the inline curve editor, only show loops if we recorded the asset bool IClipCurveEditorOwner.showLoops { get { return clip.SupportsLooping() && (clip.asset is AnimationPlayableAsset); } } TrackAsset IClipCurveEditorOwner.owner { get { return clip.parentTrack; } } public bool supportsSubTimelines { get { return m_ClipEditor.supportsSubTimelines; } } public int minLoopIndex { get { return m_MinLoopIndex; } } public Rect clippedRect { get; private set; } public override void Select() { zOrder = zOrderProvider.Next(); SelectionManager.Add(clip); if (clipCurveEditor != null && SelectionManager.Count() == 1) SelectionManager.SelectInlineCurveEditor(this); } public override bool IsSelected() { return SelectionManager.Contains(clip); } public override void Deselect() { SelectionManager.Remove(clip); if (inlineCurvesSelected) SelectionManager.SelectInlineCurveEditor(null); } public override bool CanSelect() { ClipBlends clipBlends = GetClipBlends(); //clips that do not overlap are always selectable if (clipBlends.inKind != BlendKind.Mix && clipBlends.outKind != BlendKind.Mix) return true; Vector2 mousePos = Event.current.mousePosition - rect.position; return m_ClipCenterSection.Contains(mousePos) || IsPointLocatedInClipBlend(mousePos, clipBlends); } static bool IsPointLocatedInClipBlend(Vector2 pt, ClipBlends blends) { if (blends.inRect.Contains(pt)) return Sign(pt, blends.inRect.min, blends.inRect.max) < 0; if (blends.outRect.Contains(pt)) return Sign(pt, blends.outRect.min, blends.outRect.max) >= 0; return false; } static float Sign(Vector2 point, Vector2 linePoint1, Vector2 linePoint2) { return (point.x - linePoint2.x) * (linePoint1.y - linePoint2.y) - (linePoint1.x - linePoint2.x) * (point.y - linePoint2.y); } public override ITimelineItem item { get { return ItemsUtils.ToItem(clip); } } IZOrderProvider zOrderProvider { get; } public TimelineClipHandle leftHandle { get; } public TimelineClipHandle rightHandle { get; } public TimelineClipGUI(TimelineClip clip, IRowGUI parent, IZOrderProvider provider) : base(parent) { zOrderProvider = provider; zOrder = provider.Next(); m_EditorItem = EditorClipFactory.GetEditorClip(clip); m_ClipEditor = CustomTimelineEditorCache.GetClipEditor(clip); supportResize = true; leftHandle = new TimelineClipHandle(this, TrimEdge.Start); rightHandle = new TimelineClipHandle(this, TrimEdge.End); ItemToItemGui.Add(clip, this); } void CreateInlineCurveEditor(WindowState state) { if (clipCurveEditor != null) return; var animationClip = clip.animationClip; if (animationClip != null && animationClip.empty) animationClip = null; // prune out clips coming from FBX if (animationClip != null && !clip.recordable) return; // don't show, even if there are curves if (animationClip == null && !clip.HasAnyAnimatableParameters()) return; // nothing to show state.AddEndFrameDelegate((istate, currentEvent) => { clipCurveEditor = new ClipCurveEditor(CurveDataSource.Create(this), TimelineWindow.instance, clip.parentTrack); return true; }); } public TimelineClip clip { get { return m_EditorItem.clip; } } // Draw the actual clip. Defers to the track drawer for customization void UpdateDrawData(WindowState state, Rect drawRect, string title, bool selected, bool previousClipSelected, float rectXOffset) { m_ClipDrawData.clip = clip; m_ClipDrawData.targetRect = drawRect; m_ClipDrawData.clipCenterSection = m_ClipCenterSection; m_ClipDrawData.unclippedRect = treeViewRect; m_ClipDrawData.title = title; m_ClipDrawData.selected = selected; m_ClipDrawData.inlineCurvesSelected = inlineCurvesSelected; m_ClipDrawData.previousClip = previousClip != null ? previousClip.clip : null; m_ClipDrawData.previousClipSelected = previousClipSelected; Vector3 shownAreaTime = state.timeAreaShownRange; m_ClipDrawData.localVisibleStartTime = clip.ToLocalTimeUnbound(Math.Max(clip.start, shownAreaTime.x)); m_ClipDrawData.localVisibleEndTime = clip.ToLocalTimeUnbound(Math.Min(clip.end, shownAreaTime.y)); m_ClipDrawData.clippedRect = new Rect(clippedRect.x - rectXOffset, 0.0f, clippedRect.width, clippedRect.height); m_ClipDrawData.minLoopIndex = minLoopIndex; m_ClipDrawData.loopRects = m_LoopRects; m_ClipDrawData.supportsLooping = supportsLooping; m_ClipDrawData.clipBlends = GetClipBlends(); m_ClipDrawData.clipEditor = m_ClipEditor; m_ClipDrawData.ClipDrawOptions = UpdateClipDrawOptions(m_ClipEditor, clip); UpdateClipIcons(state); } void UpdateClipIcons(WindowState state) { // Pass 1 - gather size int required = 0; bool requiresDigIn = ShowDrillIcon(state.editSequence.director); if (requiresDigIn) required++; var icons = m_ClipDrawData.ClipDrawOptions.icons; foreach (var icon in icons) { if (icon != null) required++; } // Pass 2 - copy icon data if (required == 0) { m_ClipDrawData.rightIcons = null; return; } if (m_ClipDrawData.rightIcons == null || m_ClipDrawData.rightIcons.Length != required) m_ClipDrawData.rightIcons = new IconData[required]; int index = 0; if (requiresDigIn) m_ClipDrawData.rightIcons[index++] = k_DiggableClipIcon; foreach (var icon in icons) { if (icon != null) m_ClipDrawData.rightIcons[index++] = new IconData(icon); } } static ClipDrawOptions UpdateClipDrawOptions(ClipEditor clipEditor, TimelineClip clip) { try { return clipEditor.GetClipOptions(clip); } catch (Exception e) { Debug.LogException(e); } return CustomTimelineEditorCache.GetDefaultClipEditor().GetClipOptions(clip); } static void DrawClip(ClipDrawData drawData) { ClipDrawer.DrawDefaultClip(drawData); if (drawData.clip.asset is AnimationPlayableAsset) { var state = TimelineWindow.instance.state; if (state.recording && state.IsArmedForRecord(drawData.clip.parentTrack)) { ClipDrawer.DrawAnimationRecordBorder(drawData); ClipDrawer.DrawRecordProhibited(drawData); } } } public void DrawGhostClip(Rect targetRect) { DrawSimpleClip(targetRect, ClipBorder.Selection(), new Color(1.0f, 1.0f, 1.0f, 0.5f)); } public void DrawInvalidClip(Rect targetRect) { DrawSimpleClip(targetRect, ClipBorder.Selection(), DirectorStyles.Instance.customSkin.colorInvalidClipOverlay); } void DrawSimpleClip(Rect targetRect, ClipBorder border, Color overlay) { var drawOptions = UpdateClipDrawOptions(CustomTimelineEditorCache.GetClipEditor(clip), clip); ClipDrawer.DrawSimpleClip(clip, targetRect, border, overlay, drawOptions); } void DrawInto(Rect drawRect, WindowState state) { if (Event.current.type != EventType.Repaint) return; // create the inline curve editor if not already created CreateInlineCurveEditor(state); // @todo optimization, most of the calculations (rect, offsets, colors, etc.) could be cached // and rebuilt when the hash of the clip changes. if (isInvalid) { DrawInvalidClip(treeViewRect); return; } GUI.BeginClip(drawRect); var originRect = new Rect(0.0f, 0.0f, drawRect.width, drawRect.height); string clipLabel = name; var selected = SelectionManager.Contains(clip); var previousClipSelected = previousClip != null && SelectionManager.Contains(previousClip.clip); if (selected && 1.0 != clip.timeScale) clipLabel += " " + clip.timeScale.ToString("F2") + "x"; UpdateDrawData(state, originRect, clipLabel, selected, previousClipSelected, drawRect.x); DrawClip(m_ClipDrawData); GUI.EndClip(); if (clip.parentTrack != null && !clip.parentTrack.lockedInHierarchy) { if (selected && supportResize) { var cursorRect = rect; cursorRect.xMin += leftHandle.boundingRect.width; cursorRect.xMax -= rightHandle.boundingRect.width; EditorGUIUtility.AddCursorRect(cursorRect, MouseCursor.MoveArrow); } if (supportResize) { var handleWidth = Mathf.Clamp(drawRect.width * 0.3f, k_MinHandleWidth, k_MaxHandleWidth); leftHandle.Draw(drawRect, handleWidth, state); rightHandle.Draw(drawRect, handleWidth, state); } } } void CalculateClipRectangle(Rect trackRect, WindowState state) { if (m_ClipViewDirty) { var clipRect = RectToTimeline(trackRect, state); treeViewRect = clipRect; // calculate clipped rect clipRect.xMin = Mathf.Max(clipRect.xMin, trackRect.xMin); clipRect.xMax = Mathf.Min(clipRect.xMax, trackRect.xMax); if (clipRect.width > 0 && clipRect.width < 2) { clipRect.width = 5.0f; } clippedRect = clipRect; } } void AddToSpacePartitioner(WindowState state) { if (Event.current.type == EventType.Repaint && !parent.locked) state.spacePartitioner.AddBounds(this, rect); } void CalculateBlendRect() { m_ClipCenterSection = treeViewRect; m_ClipCenterSection.x = 0; m_ClipCenterSection.y = 0; m_ClipCenterSection.xMin = Mathf.Round(treeViewRect.width * clip.mixInPercentage); m_ClipCenterSection.width = Mathf.Round(treeViewRect.width); m_ClipCenterSection.xMax -= Mathf.Round(mixOutRect.width + treeViewRect.width * clip.mixInPercentage); } // Entry point to the Clip Drawing... public override void Draw(Rect trackRect, bool trackRectChanged, WindowState state) { // if the clip has changed, fire the appropriate callback DetectClipChanged(trackRectChanged); // update the clip projected rectangle on the timeline CalculateClipRectangle(trackRect, state); AddToSpacePartitioner(state); // update the blend rects (when clip overlaps with others) CalculateBlendRect(); // update the loop rects (when clip loops) CalculateLoopRects(trackRect, state); DrawExtrapolation(trackRect, treeViewRect); DrawInto(treeViewRect, state); ResetClipChanged(); } void DetectClipChanged(bool trackRectChanged) { if (Event.current.type == EventType.Layout) { if (clip.DirtyIndex != m_LastDirtyIndex) { m_ClipViewDirty = true; try { m_ClipEditor.OnClipChanged(clip); } catch (Exception e) { Debug.LogException(e); } m_LastDirtyIndex = clip.DirtyIndex; } m_ClipViewDirty |= trackRectChanged; } } void ResetClipChanged() { if (Event.current.type == EventType.Repaint) m_ClipViewDirty = false; } GUIStyle GetExtrapolationIcon(TimelineClip.ClipExtrapolation mode) { GUIStyle extrapolationIcon = null; switch (mode) { case TimelineClip.ClipExtrapolation.None: return null; case TimelineClip.ClipExtrapolation.Hold: extrapolationIcon = m_Styles.extrapolationHold; break; case TimelineClip.ClipExtrapolation.Loop: extrapolationIcon = m_Styles.extrapolationLoop; break; case TimelineClip.ClipExtrapolation.PingPong: extrapolationIcon = m_Styles.extrapolationPingPong; break; case TimelineClip.ClipExtrapolation.Continue: extrapolationIcon = m_Styles.extrapolationContinue; break; } return extrapolationIcon; } Rect GetPreExtrapolationBounds(Rect trackRect, Rect clipRect, GUIStyle icon) { float x = clipRect.xMin - (icon.fixedWidth + 10.0f); float y = trackRect.yMin + (trackRect.height - icon.fixedHeight) / 2.0f; if (previousClip != null) { float distance = Mathf.Abs(treeViewRect.xMin - previousClip.treeViewRect.xMax); if (distance < icon.fixedWidth) return new Rect(0.0f, 0.0f, 0.0f, 0.0f); if (distance < icon.fixedWidth + 20.0f) { float delta = (distance - icon.fixedWidth) / 2.0f; x = clipRect.xMin - (icon.fixedWidth + delta); } } return new Rect(x, y, icon.fixedWidth, icon.fixedHeight); } Rect GetPostExtrapolationBounds(Rect trackRect, Rect clipRect, GUIStyle icon) { float x = clipRect.xMax + 10.0f; float y = trackRect.yMin + (trackRect.height - icon.fixedHeight) / 2.0f; if (nextClip != null) { float distance = Mathf.Abs(nextClip.treeViewRect.xMin - treeViewRect.xMax); if (distance < icon.fixedWidth) return new Rect(0.0f, 0.0f, 0.0f, 0.0f); if (distance < icon.fixedWidth + 20.0f) { float delta = (distance - icon.fixedWidth) / 2.0f; x = clipRect.xMax + delta; } } return new Rect(x, y, icon.fixedWidth, icon.fixedHeight); } static void DrawExtrapolationIcon(Rect rect, GUIStyle icon) { GUI.Label(rect, GUIContent.none, icon); } void DrawExtrapolation(Rect trackRect, Rect clipRect) { if (clip.hasPreExtrapolation) { GUIStyle icon = GetExtrapolationIcon(clip.preExtrapolationMode); if (icon != null) { Rect iconBounds = GetPreExtrapolationBounds(trackRect, clipRect, icon); if (iconBounds.width > 1 && iconBounds.height > 1) DrawExtrapolationIcon(iconBounds, icon); } } if (clip.hasPostExtrapolation) { GUIStyle icon = GetExtrapolationIcon(clip.postExtrapolationMode); if (icon != null) { Rect iconBounds = GetPostExtrapolationBounds(trackRect, clipRect, icon); if (iconBounds.width > 1 && iconBounds.height > 1) DrawExtrapolationIcon(iconBounds, icon); } } } static Rect ProjectRectOnTimeline(Rect rect, Rect trackRect, WindowState state) { Rect newRect = rect; // transform clipRect into pixel-space newRect.x *= state.timeAreaScale.x; newRect.width *= state.timeAreaScale.x; newRect.x += state.timeAreaTranslation.x + trackRect.xMin; // adjust clipRect height and vertical centering const int clipPadding = 2; newRect.y = trackRect.y + clipPadding; newRect.height = trackRect.height - (2 * clipPadding); return newRect; } void CalculateLoopRects(Rect trackRect, WindowState state) { if (!m_ClipViewDirty) return; m_LoopRects.Clear(); if (clip.duration < WindowState.kTimeEpsilon) return; var times = TimelineHelpers.GetLoopTimes(clip); var loopDuration = TimelineHelpers.GetLoopDuration(clip); m_MinLoopIndex = -1; // we have a hold, no need to compute all loops if (!supportsLooping) { if (times.Length > 1) { var t = times[1]; float loopTime = (float)(clip.duration - t); m_LoopRects.Add(ProjectRectOnTimeline(new Rect((float)(t + clip.start), 0, loopTime, 0), trackRect, state)); } return; } var range = state.timeAreaShownRange; var visibleStartTime = range.x - clip.start; var visibleEndTime = range.y - clip.start; for (int i = 1; i < times.Length; i++) { var t = times[i]; // don't draw off screen loops if (t > visibleEndTime) break; float loopTime = Mathf.Min((float)(clip.duration - t), (float)loopDuration); var loopEnd = t + loopTime; if (loopEnd < visibleStartTime) continue; m_LoopRects.Add(ProjectRectOnTimeline(new Rect((float)(t + clip.start), 0, loopTime, 0), trackRect, state)); if (m_MinLoopIndex == -1) m_MinLoopIndex = i; } } public override Rect RectToTimeline(Rect trackRect, WindowState state) { var offsetFromTimeSpaceToPixelSpace = state.timeAreaTranslation.x + trackRect.xMin; var start = (float)(DiscreteTime)clip.start; var end = (float)(DiscreteTime)clip.end; return Rect.MinMaxRect( Mathf.Round(start * state.timeAreaScale.x + offsetFromTimeSpaceToPixelSpace), Mathf.Round(trackRect.yMin), Mathf.Round(end * state.timeAreaScale.x + offsetFromTimeSpaceToPixelSpace), Mathf.Round(trackRect.yMax) ); } public IEnumerable SnappableEdgesFor(IAttractable attractable, ManipulateEdges manipulateEdges) { var edges = new List(); bool canAddEdges = !parent.muted; if (canAddEdges) { // Hack: Trim Start in Ripple mode should not have any snap point added if (EditMode.editType == EditMode.EditType.Ripple && manipulateEdges == ManipulateEdges.Left) return edges; if (attractable != this) { if (EditMode.editType == EditMode.EditType.Ripple) { bool skip = false; // Hack: Since Trim End and Move in Ripple mode causes other snap point to move on the same track (which is not supported), disable snapping for this special cases... // TODO Find a proper way to have different snap edges for each edit mode. if (manipulateEdges == ManipulateEdges.Right) { var otherClipGUI = attractable as TimelineClipGUI; skip = otherClipGUI != null && otherClipGUI.parent == parent; } else if (manipulateEdges == ManipulateEdges.Both) { var moveHandler = attractable as MoveItemHandler; skip = moveHandler != null && moveHandler.movingItems.Any(clips => clips.targetTrack == clip.parentTrack && clip.start >= clips.start); } if (skip) return edges; } AddEdge(edges, clip.start); AddEdge(edges, clip.end); } else { if (manipulateEdges == ManipulateEdges.Right) { var d = TimelineHelpers.GetClipAssetEndTime(clip); if (d < double.MaxValue) { if (clip.SupportsLooping()) { var l = TimelineHelpers.GetLoopDuration(clip); var shownTime = TimelineWindow.instance.state.timeAreaShownRange; do { AddEdge(edges, d, false); d += l; } while (d < shownTime.y); } else { AddEdge(edges, d, false); } } } if (manipulateEdges == ManipulateEdges.Left) { var clipInfo = AnimationClipCurveCache.Instance.GetCurveInfo(clip.animationClip); if (clipInfo != null && clipInfo.keyTimes.Any()) AddEdge(edges, clip.FromLocalTimeUnbound(clipInfo.keyTimes.Min()), false); } } } return edges; } public bool ShouldSnapTo(ISnappable snappable) { return true; } bool ShowDrillIcon(PlayableDirector resolver) { if (!m_ShowDrillIcon.HasValue || TimelineWindow.instance.hierarchyChangedThisFrame) { var nestable = m_ClipEditor.supportsSubTimelines; m_ShowDrillIcon = nestable && resolver != null; if (m_ShowDrillIcon.Value) { s_TempSubDirectors.Clear(); try { m_ClipEditor.GetSubTimelines(clip, resolver, s_TempSubDirectors); } catch (Exception e) { Debug.LogException(e); } m_ShowDrillIcon &= s_TempSubDirectors.Count > 0; } } return m_ShowDrillIcon.Value; } static void AddEdge(List edges, double time, bool showEdgeHint = true) { var shownTime = TimelineWindow.instance.state.timeAreaShownRange; if (time >= shownTime.x && time <= shownTime.y) edges.Add(new Edge(time, showEdgeHint)); } public void SelectCurves() { SelectionManager.SelectOnly(clip); SelectionManager.SelectInlineCurveEditor(this); } public void ValidateCurvesSelection() { if (!IsSelected()) //if clip is not selected, deselect the inline curve SelectionManager.SelectInlineCurveEditor(null); } } }