583 lines
28 KiB
C#
583 lines
28 KiB
C#
#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
|
|
{
|
|
/// <summary>
|
|
/// Attributes required and identically for drawing and handling all time clip items of a canvas for the current frame.
|
|
/// </summary>
|
|
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,
|
|
}
|
|
|
|
/// <summary>
|
|
/// 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
|
|
/// </summary>
|
|
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);
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
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);
|
|
}
|
|
|
|
/// <summary>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 <see cref="VideoClipDurationCache"/> (probed once).</summary>
|
|
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;
|
|
}
|
|
|
|
/// <summary>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.</summary>
|
|
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];
|
|
} |