#nullable enable using ImGuiNET; using T3.Core.Animation; using T3.Core.DataTypes.Vector; using T3.Core.Operator; using T3.Core.Resource.Assets; using T3.Editor.Gui.Interaction.Snapping; using T3.Editor.Gui.Styling; using T3.Editor.Gui.UiHelpers; using T3.Editor.UiModel; using T3.Editor.UiModel.Commands; using T3.Editor.UiModel.Commands.Animation; using T3.Editor.UiModel.ProjectHandling; using T3.Editor.UiModel.Selection; namespace T3.Editor.Gui.Windows.TimeLine.TimeClips; internal static class TimeClipItem { /// /// Attributes required and identically for drawing and handling all time clip items of a canvas for the current frame. /// public record struct ClipDrawingAttributes( ClipArea.LayerContext LayerContext, ImRect LayerRect, int MinLayerIndex, Instance CompositionOp, SymbolUi CompositionSymbolUi, MoveTimeClipsCommand? MoveClipsCommand, ImDrawListPtr DrawList); internal static void DrawClip(TimeClip timeClip, ref ClipDrawingAttributes attr) { var xStartTime = attr.LayerContext.TimeCanvas.TransformX(timeClip.TimeRange.Start) + 1; var xEndTime = attr.LayerContext.TimeCanvas.TransformX(timeClip.TimeRange.End) + 1; // Horizontal off-screen cull. A clip entirely left or right of the visible layer // area draws nothing and can't be interacted with — the item being dragged stays // under the mouse, hence on-screen, so the active drag is never the culled one. // This is the main win for compositions with many clips but only a few in view: // it skips the body, the per-event DataClip/audio overlays, the label, and the // interaction buttons entirely. if (xEndTime < attr.LayerRect.Min.X || xStartTime > attr.LayerRect.Max.X) return; var position = new Vector2(xStartTime, attr.LayerRect.Min.Y + (timeClip.LayerIndex - attr.MinLayerIndex) * ClipArea.LayerHeight); var clipWidth = xEndTime - xStartTime; // Clamp so a freshly-created zero-width clip (e.g. a recording in progress with // TimeRange.Start == TimeRange.End) still submits a hit-testable body and doesn't // trip ImGui's "InvisibleButton size must be non-zero" assert. if (clipWidth < 1) clipWidth = 1; var showSizeHandles = clipWidth > 4 * HandleWidth; var bodyWidth = showSizeHandles ? (clipWidth - 2 * HandleWidth) : clipWidth; var bodySize = new Vector2(bodyWidth, ClipArea.LayerHeight - 2); var clipSize = new Vector2(clipWidth, ClipArea.LayerHeight - 2); var symbolChildUi = attr.CompositionSymbolUi.ChildUis[timeClip.Id]; ImGui.PushID(symbolChildUi.Id.GetHashCode()); var isSelected = attr.LayerContext.ClipSelection.SelectedClipsIds.Contains(timeClip.Id); var itemRectMax = position + clipSize - new Vector2(1, 0); var rounding = 4.5f; var randomColor = DrawUtils.RandomColorForHash(timeClip.Id.GetHashCode()); var timeRemapped = timeClip.TimeRange != timeClip.SourceRange; var timeStretched = Math.Abs(timeClip.TimeRange.Duration - timeClip.SourceRange.Duration) > 0.001; // Body and outline var isConnected = attr.CompositionSymbolUi.Symbol.Connections.Any(c => c.SourceParentOrChildId == timeClip.Id); var isWithinPlaybackTime = timeClip.TimeRange.Contains(attr.LayerContext.TimeCanvas.Playback.TimeInBars); var fadeIfInActive = (isConnected && isWithinPlaybackTime) ? 1 : 0.8f; var fadeIfNotConnected = isConnected ? 1f : 0.4f; var innerColor = Color.Mix(UiColors.BackgroundFull,randomColor,0.5f).Fade(0.8f * fadeIfNotConnected * fadeIfInActive); attr.DrawList.AddRectFilled(position, itemRectMax, innerColor, rounding); // Live Instance for this clip — used both for the DataClip tick overlay below and // for the filename label further down. Missing = null; both consumers handle. attr.CompositionOp.Children.TryGetChildInstance(timeClip.Id, out var clipInstance); // Per-event tick overlay for ops that publish a DataClip; waveform for [AudioClip] ops. // Each no-ops for op kinds it doesn't handle. if (clipInstance != null) { DataClipBodyRenderer.TryDraw(clipInstance, timeClip, position, itemRectMax, attr.LayerRect.Min.X, attr.LayerRect.Max.X, attr.DrawList); AudioClipBodyRenderer.TryDraw(clipInstance, timeClip, position, itemRectMax, attr.DrawList); } if (isSelected) attr.DrawList.AddRect(position, itemRectMax, UiColors.Selection, rounding); // Label — for ops that load from a file (LoadDataClip, future MidiClip etc.), use // the loaded filename instead of the op's symbol name so the user can tell which // recording a clip references without opening the parameter window. if(ClipArea.LayerHeight > Fonts.FontSmall.FontSize){ var nameSource = symbolChildUi.SymbolChild.ReadableName; if (clipInstance is T3.Core.Operator.Interfaces.IDescriptiveFilename descriptive) { var path = descriptive.SourcePathSlot.TypedInputValue.Value; if (!string.IsNullOrEmpty(path)) { var fileName = System.IO.Path.GetFileNameWithoutExtension(path); // A renamed op keeps its custom name visible alongside the file it // references, e.g. "Song 1 (rec-004)"; an unnamed op shows just the file. nameSource = symbolChildUi.SymbolChild.HasCustomName ? $"{symbolChildUi.SymbolChild.Name} ({fileName})" : fileName; } } var label = timeStretched ? nameSource + $" ({timeClip.Speed*100:0.0}%)" : nameSource; ImGui.PushFont(Fonts.FontSmall); var labelSize = ImGui.CalcTextSize(label); // Keep the title readable when the clip starts off the left edge of the view: pin the text to the // visible area's left edge, but never push it past the clip's own right edge. var labelMaxX = itemRectMax.X - 3; var labelX = Math.Min(Math.Max(position.X + 4, attr.LayerRect.Min.X + 4), labelMaxX); var labelPos = new Vector2(labelX, position.Y + 1); var needsClipping = labelPos.X + labelSize.X > labelMaxX; if (needsClipping) ImGui.PushClipRect(position, itemRectMax - new Vector2(3, 0), true); attr.DrawList.AddText(labelPos, isSelected ? UiColors.Selection : randomColor.Fade(fadeIfNotConnected), label); if (needsClipping) ImGui.PopClipRect(); ImGui.PopFont(); } // Stretch indicators { if (timeStretched) { attr.DrawList.AddRectFilled(position + new Vector2(2, clipSize.Y - 4), position + new Vector2(clipSize.X - 3, clipSize.Y - 2), UiColors.StatusAttention.Fade(fadeIfNotConnected), rounding); } else if (timeRemapped) { attr.DrawList.AddRectFilled(position + new Vector2(2, clipSize.Y - 3), position + new Vector2(clipSize.X - 3, clipSize.Y - 1), UiColors.ForegroundFull.Fade(0.3f* fadeIfNotConnected)); } } // Draw stretch indicators if (isSelected && timeRemapped && attr.LayerContext.ClipSelection.Count == 1) { var estimatedRulerHeight = 40; var verticalOffset = ImGui.GetWindowPos().Y + estimatedRulerHeight - position.Y - ClipArea.LayerHeight; var horizontalOffset = attr.LayerContext.TimeCanvas.TransformDirection(new Vector2(timeClip.SourceRange.Start - timeClip.TimeRange.Start, 0)).X; var startPosition = position; attr.DrawList.AddBezierCubic(startPosition, startPosition + new Vector2(0, verticalOffset), startPosition + new Vector2(horizontalOffset, 0), startPosition + new Vector2(horizontalOffset, verticalOffset), _timeRemappingColor, 1); horizontalOffset = attr.LayerContext.TimeCanvas.TransformDirection(new Vector2(timeClip.SourceRange.End - timeClip.TimeRange.End, 0)).X; var endPosition = position + new Vector2(clipSize.X, 0); attr.DrawList.AddBezierCubic(endPosition, endPosition + new Vector2(0, verticalOffset), endPosition + new Vector2(horizontalOffset, 0), endPosition + new Vector2(horizontalOffset, verticalOffset), _timeRemappingColor, 1); } // Interaction and dragging ImGui.SetCursorScreenPos(showSizeHandles ? (position + _handleOffset) : position); var wasClickedDown = ImGui.InvisibleButton("body", bodySize); var bodyHovered = ImGui.IsItemHovered(); var bodyActive = ImGui.IsItemActive(); if (bodyHovered) { TryGetVideoFootageBars(ref attr, timeClip, clipInstance, out var footageBars); ImGui.PushStyleVar(ImGuiStyleVar.WindowPadding, new Vector2(4,4)); ImGui.BeginTooltip(); { ImGui.PushFont(Fonts.FontSmall); ImGui.TextUnformatted(symbolChildUi.SymbolChild.ReadableName); if (!isConnected) { ImGui.TextUnformatted("(Not connected?)"); } ImGui.PushStyleColor(ImGuiCol.Text, UiColors.TextMuted.Rgba); ImGui.TextUnformatted($"Visible: {timeClip.TimeRange.Start:0.00} ... {timeClip.TimeRange.End:0.00}"); if (timeRemapped) { ImGui.TextUnformatted($"Source {timeClip.SourceRange.Start:0.00} ... {timeClip.SourceRange.End:0.00}"); } if (footageBars > 0) { var readsPastFootage = timeClip.SourceRange.Start < -0.001f || timeClip.SourceRange.End > footageBars + 0.001f; ImGui.TextUnformatted(readsPastFootage ? $"Footage: 0.00 ... {footageBars:0.00} (reads past end — loops/freezes)" : $"Footage: 0.00 ... {footageBars:0.00}"); } if (timeStretched) { ImGui.TextUnformatted($"Speed: {timeClip.Speed*100:0.0}%"); } ImGui.PopStyleColor(); ImGui.PopFont(); } ImGui.EndTooltip(); ImGui.PopStyleVar(); } if (ImGui.IsItemHovered() && ImGui.IsMouseDoubleClicked(0)) { if (Structure.TryGetUiAndInstanceInComposition(timeClip.Id, attr.CompositionOp, out _, out var instance)) { if (instance.Symbol.Children.Count > 0) attr.LayerContext.RequestChildComposition(instance.SymbolChildId); } } if (ImGui.IsItemHovered()) { FrameStats.AddHoveredId(symbolChildUi.Id); } var notClickingOrDragging = !ImGui.IsItemActive() && !ImGui.IsMouseDragging(ImGuiMouseButton.Left); if (notClickingOrDragging && attr.MoveClipsCommand != null) { // Store values and nullify command attr.LayerContext.TimeCanvas.CompleteDragCommand(); } if (wasClickedDown) { FitViewToSelectionHandling.FitViewToSelection(); } HandleDragging(attr, timeClip, isSelected, wasClickedDown, HandleDragMode.Body); var handleSize = showSizeHandles ? new Vector2(HandleWidth, ClipArea.LayerHeight) : Vector2.One; ImGui.SetCursorScreenPos(position); var aHandleClicked = ImGui.InvisibleButton("startHandle", handleSize); var startHandleActive = ImGui.IsItemHovered() || ImGui.IsItemActive(); if (startHandleActive) { attr.DrawList.AddRectFilled(ImGui.GetItemRectMin() + new Vector2(2, 3), ImGui.GetItemRectMax() - new Vector2(1, 4), UiColors.ForegroundFull.Fade(0.3f), 5); } HandleDragging(attr, timeClip, isSelected, false, HandleDragMode.Start); ImGui.SetCursorScreenPos(position + new Vector2(bodyWidth + HandleWidth, 0)); aHandleClicked |= ImGui.InvisibleButton("endHandle", handleSize); var endHandleActive = ImGui.IsItemHovered() || ImGui.IsItemActive(); if (endHandleActive) { attr.DrawList.AddRectFilled(ImGui.GetItemRectMin() + new Vector2(0, 3), ImGui.GetItemRectMax() - new Vector2(3, 4), UiColors.ForegroundFull.Fade(0.3f), 5); } HandleDragging(attr, timeClip, isSelected, false, HandleDragMode.End); // Footage extent stays visible while hovering or while any drag of this clip is active (body move or // either trim handle). IsItemActive holds through the whole drag even when the mouse leaves the clip, // so the frame doesn't flicker as the pointer outruns the clip — and that's when it's most useful. if ((bodyHovered || bodyActive || startHandleActive || endHandleActive) && TryGetVideoFootageBars(ref attr, timeClip, clipInstance, out var hoverFootageBars)) { DrawSourceFootageExtent(ref attr, timeClip, position, itemRectMax, hoverFootageBars); } if (aHandleClicked) { attr.LayerContext.TimeCanvas.CompleteDragCommand(); if (attr.MoveClipsCommand != null) { attr.MoveClipsCommand.StoreCurrentValues(); UndoRedoStack.Add(attr.MoveClipsCommand); attr.MoveClipsCommand = null; } } ImGui.PopID(); } // private static double GetSpeed(TimeClip timeClip) // { // return Math.Abs(timeClip.TimeRange.Duration) > 0.001 // ? Math.Round((timeClip.SourceRange.Duration / timeClip.TimeRange.Duration) * 100) // : 9999; // } private enum HandleDragMode { Body = 0, Start, End, } /// /// Handles the invocation and update of drag commands. These will be forwarded to the timeline interface and /// applied to other selected items like keyframes and other selected time clips /// private static void HandleDragging(ClipDrawingAttributes attr, TimeClip timeClip, bool isSelected, bool wasClicked, HandleDragMode mode) { if (ImGui.IsItemHovered()) { ImGui.SetMouseCursor(mode == HandleDragMode.Body ? ImGuiMouseCursor.Hand : ImGuiMouseCursor.ResizeEW); } var isDeactivated = ImGui.IsItemDeactivated(); var isActive = ImGui.IsItemActive(); if (!isActive && !isDeactivated ) return; var wasClickRelease = isDeactivated && ImGui.GetMouseDragDelta().Length() < UserSettings.Config.ClickThreshold; if (wasClickRelease) { if (ImGui.GetIO().KeyCtrl) { if (isSelected) { attr.LayerContext.ClipSelection.Deselect(timeClip); } return; } if (!isSelected) { if (!ImGui.GetIO().KeyShift) { attr.LayerContext.TimeCanvas.ClearSelection(); } attr.LayerContext.ClipSelection.Select(timeClip); } return; } var mousePos = ImGui.GetIO().MousePos; var currentDragTime = attr.LayerContext.TimeCanvas.InverseTransformX(mousePos.X); if (attr.MoveClipsCommand == null) { if (!isSelected) { if (ImGui.GetIO().KeyShift) { attr.LayerContext.ClipSelection.AddSelection(timeClip); } else { // Full clear before adding — ClipSelection.Select only touches the // op-clip side, so without this an audio clip selected before this // press would remain selected alongside the new op clip. attr.LayerContext.TimeCanvas.ClearSelection(); attr.LayerContext.ClipSelection.Select(timeClip); } } _timeWithinDraggedClip = currentDragTime - timeClip.TimeRange.Start; _posPosYOnDragStart = mousePos.Y; _dragStartMouseTime = currentDragTime; _originalDraggedClipStart = timeClip.TimeRange.Start; _lastAppliedDeltaTime = 0; attr.LayerContext.TimeCanvas.StartDragCommand(attr.CompositionOp.Symbol.Id); } if (!ImGui.IsMouseDragging(0, UserSettings.Config.ClickThreshold)) return; var allowSnapping = !ImGui.GetIO().KeyShift && !(ImGui.GetIO().KeyAlt && ImGui.GetIO().KeyCtrl); // SelectionRangeIndicator's anchors are the selected clips' aggregate Start/End — // when we drag selected clips, those anchors move along with the clip, so without // exclusion the snap handler perpetually "re-snaps to self" and stutters. Same // exclusion list keyframe drags already use. var snapExclusions = attr.LayerContext.TimeCanvas.SelectionDragSnapExclusions; switch (mode) { case HandleDragMode.Body: var dy = _posPosYOnDragStart - mousePos.Y; // Derive the unsnapped target from the ORIGINAL drag-start positions, not // from incrementally accumulated state. This avoids the slow-drag artefact // where snap was sticky for several frames and the cumulative "applied // delta" diverged from the absolute mouse motion — leaving the clip stuck // or jumping unexpectedly when the mouse finally left the snap range. var rawDelta = currentDragTime - _dragStartMouseTime; var unsnappedTargetStart = _originalDraggedClipStart + rawDelta; var targetStart = unsnappedTargetStart; if (allowSnapping && attr.LayerContext.SnapHandler.TryCheckForSnapping(unsnappedTargetStart, out var snappedClipStartTime, attr.LayerContext.TimeCanvas.Scale.X, snapExclusions)) { targetStart = (float)snappedClipStartTime; } else if (allowSnapping && attr.LayerContext.SnapHandler.TryCheckForSnapping(unsnappedTargetStart + timeClip.TimeRange.Duration, out var snappedClipEndTime, attr.LayerContext.TimeCanvas.Scale.X, snapExclusions)) { targetStart = (float)snappedClipEndTime - timeClip.TimeRange.Duration; } // _lastAppliedDeltaTime stores the cumulative delta-from-original we've // committed so far. Compare absolute target → cumulative; emit the // increment needed to reach the new cumulative value. var finalDelta = targetStart - _originalDraggedClipStart; var incrementToApply = finalDelta - _lastAppliedDeltaTime; _lastAppliedDeltaTime = finalDelta; attr.LayerContext.TimeCanvas.UpdateDragCommand(incrementToApply, dy); break; case HandleDragMode.Start: var newDragStartTime = attr.LayerContext.TimeCanvas.InverseTransformX(mousePos.X); // Snap the in-point to the first frame of the available footage (SourceRange.Start == 0). var startFootageAttractor = TryGetFootageBoundaryTimes(ref attr, timeClip, out var footageStartTime, out _) ? UseFootageSnapAnchor(footageStartTime) : null; if (allowSnapping && attr.LayerContext.SnapHandler.TryCheckForSnapping(newDragStartTime, out var snappedValue3, attr.LayerContext.TimeCanvas.Scale.X, snapExclusions, startFootageAttractor)) { newDragStartTime = (float)snappedValue3; } attr.LayerContext.TimeCanvas.UpdateDragAtStartPointCommand(newDragStartTime - timeClip.TimeRange.Start, 0); break; case HandleDragMode.End: var newDragTime = attr.LayerContext.TimeCanvas.InverseTransformX(mousePos.X); // Snap the out-point to the last frame of the available footage (SourceRange.End == duration). var endFootageAttractor = TryGetFootageBoundaryTimes(ref attr, timeClip, out _, out var footageEndTime) ? UseFootageSnapAnchor(footageEndTime) : null; if (allowSnapping && attr.LayerContext.SnapHandler.TryCheckForSnapping(newDragTime, out var snappedValue4, attr.LayerContext.TimeCanvas.Scale.X, snapExclusions, endFootageAttractor)) { newDragTime = (float)snappedValue4; } attr.LayerContext.TimeCanvas.UpdateDragAtEndPointCommand(newDragTime - timeClip.TimeRange.End, 0); break; default: throw new ArgumentOutOfRangeException(nameof(mode), mode, null); } } /// /// Outlines the clip's full available source footage on the timeline. The footage maps through the same /// linear source→screen relation as the clip's in/out points, so the frame extending past the clip means /// unused head/tail to slip into, and the clip extending past the frame means it reads beyond the media. /// private static void DrawSourceFootageExtent(ref ClipDrawingAttributes attr, TimeClip timeClip, Vector2 position, Vector2 itemRectMax, float fullDurationBars) { var rate = timeClip.Speed; if (Math.Abs(rate) < 1e-6) return; // Transform the footage boundary *times* directly. They're invariant under a slip-trim (which preserves // speed), so the frame stays rock-steady while dragging a handle — unlike scaling from the clip's live // pixel width, which amplifies per-frame edge rounding into visible jitter. var footageStartTime = timeClip.TimeRange.Start - timeClip.SourceRange.Start / rate; var footageEndTime = timeClip.TimeRange.Start + (fullDurationBars - timeClip.SourceRange.Start) / rate; var xStart = attr.LayerContext.TimeCanvas.TransformX((float)footageStartTime) + 1; var xEnd = attr.LayerContext.TimeCanvas.TransformX((float)footageEndTime) + 1; // The frame's left/right edges are the media's first/last frame: when an edge sits inside the clip, // the clip is reading past the footage (looping/freezing); when it sits outside, there's slack to slip. attr.DrawList.AddRect(new Vector2(Math.Min(xStart, xEnd), position.Y), new Vector2(Math.Max(xStart, xEnd), itemRectMax.Y), UiColors.ForegroundFull.Fade(0.3f), 4.5f); } /// Full source length of a video clip in bars, or false (with -1) for non-video / unknown-duration /// clips. Duration is resolved through the per-asset (probed once). private static bool TryGetVideoFootageBars(ref ClipDrawingAttributes attr, TimeClip timeClip, Instance? clipInstance, out float footageBars) { footageBars = -1f; if (clipInstance is not T3.Core.Operator.Interfaces.IDescriptiveFilename describedFile) return false; var path = describedFile.SourcePathSlot.TypedInputValue.Value; if (string.IsNullOrEmpty(path) || !AssetType.TryGetForFilePath(path, out var assetType, out _) || assetType.Name != "Video" || !VideoClipDurationCache.TryGetDurationSecs(path, clipInstance, out var fullDurationSecs) || fullDurationSecs <= 0) return false; footageBars = (float)attr.LayerContext.TimeCanvas.Playback.BarsFromSeconds(fullDurationSecs); return true; } /// Timeline positions (bars) where the clip's source reads the first and last frame of its media. /// These are stable while trimming (a slip-trim preserves speed), so they make good snap targets. False for /// non-video clips or a degenerate (zero-speed) clip. private static bool TryGetFootageBoundaryTimes(ref ClipDrawingAttributes attr, TimeClip timeClip, out double footageStartTime, out double footageEndTime) { footageStartTime = 0; footageEndTime = 0; if (!attr.CompositionOp.Children.TryGetChildInstance(timeClip.Id, out var clipInstance) || !TryGetVideoFootageBars(ref attr, timeClip, clipInstance, out var footageBars)) return false; var rate = timeClip.Speed; if (Math.Abs(rate) < 1e-6) return false; footageStartTime = timeClip.TimeRange.Start - timeClip.SourceRange.Start / rate; footageEndTime = timeClip.TimeRange.Start + (footageBars - timeClip.SourceRange.Start) / rate; return true; } // Arms the shared single-anchor attractor for one TryCheckForSnapping call, returning it as a reusable // one-element list so the snap check stays allocation-free during a trim drag. private static IValueSnapAttractor[] UseFootageSnapAnchor(double anchorTime) { _footageSnapAttractor.AnchorTime = anchorTime; return _footageSnapAttractorList; } private sealed class FootageSnapAttractor : IValueSnapAttractor { public double AnchorTime; public void CheckForSnap(ref SnapResult snapResult) => snapResult.TryToImproveWithAnchorValue(AnchorTime); } private const float HandleWidth = 7; private static float _timeWithinDraggedClip; // Drag-start snapshots. Body drag computes the target position from these + // the absolute mouse-time delta, then derives the per-frame increment by subtracting // the cumulative delta already committed. Avoids drift across snap boundaries. private static double _dragStartMouseTime; private static double _originalDraggedClipStart; private static double _lastAppliedDeltaTime; private static readonly Vector2 _handleOffset = new(HandleWidth, 0); private static readonly Color _timeRemappingColor = UiColors.StatusAnimated.Fade(0.25f); private static float _posPosYOnDragStart; private static readonly FootageSnapAttractor _footageSnapAttractor = new(); private static readonly IValueSnapAttractor[] _footageSnapAttractorList = [_footageSnapAttractor]; }