#nullable enable using System; using System.Numerics; using ImGuiNET; using Newtonsoft.Json.Linq; using T3.Core.Animation; using T3.Core.Audio; using T3.Core.Logging; using T3.Core.Operator; using T3.Core.Operator.Slots; using T3.Core.Resource.Assets; using T3.Core.Video; using T3.Editor.Gui.Styling; using T3.Editor.Gui.UiHelpers; using T3.Editor.UiModel; using T3.Editor.UiModel.Commands; using T3.Editor.UiModel.Commands.Graph; using T3.Editor.UiModel.Helpers; using T3.Editor.UiModel.ProjectHandling; using T3.IoServices; namespace T3.Editor.Gui.Windows.TimeLine.TimeClips; /// /// Generic timeline-clip drop. Any asset whose is set /// (AudioClip, VideoClip, LoadDataClip, …) can be dropped onto the clip area — from the AssetLibrary /// () or as an external OS file /// (, imported first). Each drop creates the /// type's op at the drop time / layer, sets its file-path input (found generically via /// ), and initialises its TimeClip — all as /// one . New clip types work out of the box once they declare the hint. /// internal static class TimelineClipDrop { public static void Handle(Instance compositionOp, TimeLineCanvas timeCanvas, float layerTopY, int minLayerIndex) { // AssetLibrary drag — payload is an asset address; already imported. var assetResult = DragAndDropHandling.TryHandleDropOnItem(DragAndDropHandling.DragTypes.FileAsset, out var address); if (assetResult == DragAndDropHandling.DragInteractionResult.Dropped && !string.IsNullOrEmpty(address)) { if (AssetRegistry.TryGetAsset(address, out var asset) && asset.AssetType.TimelineClipOperator is { } opId && TryComputeDrop(timeCanvas, layerTopY, minLayerIndex, out var dropBars, out var dropLayer, out var playback)) { var macro = new MacroCommand("Drop timeline clip"); CreateClipOp(compositionOp, macro, opId, asset.Address, asset.FullPath, dropBars, dropLayer, playback); FinishMacro(compositionOp, macro); } return; } // While dragging over the timeline, preview where the clip will land and how long it will be. if (assetResult == DragAndDropHandling.DragInteractionResult.Hovering && !string.IsNullOrEmpty(address)) { DrawDropPreview(compositionOp, timeCanvas, layerTopY, minLayerIndex, address); return; } // External OS-file drag — import each (only files whose type is timeline-droppable). var fileResult = DragAndDropHandling.TryHandleDropOnItem(DragAndDropHandling.DragTypes.ExternalFile, out var data); if (fileResult != DragAndDropHandling.DragInteractionResult.Dropped || string.IsNullOrEmpty(data)) return; var package = compositionOp.Symbol.SymbolPackage; if (package == null) { Log.Warning("Cannot resolve composition's resource package for timeline clip drop."); return; } if (!TryComputeDrop(timeCanvas, layerTopY, minLayerIndex, out var bars, out var layer, out var pb)) return; var fileMacro = new MacroCommand("Drop timeline clips"); var added = 0; foreach (var path in data.Split('|')) { if (!AssetType.TryGetForFilePath(path, out var assetType, out _) || assetType.TimelineClipOperator is not { } fileOpId) continue; if (!FileImport.TryImportDroppedFile(path, package, subfolder: null, out var asset)) { Log.Warning($"Failed to import dropped file: {path}"); continue; } CreateClipOp(compositionOp, fileMacro, fileOpId, asset.Address, asset.FullPath, bars, layer, pb); // Stack subsequent files on new layers so a multi-drop doesn't pile clips on top of each other. layer++; added++; } if (added > 0) FinishMacro(compositionOp, fileMacro); } /// /// Ghost of the clip a hovering asset would create: drawn at the prospective drop time/layer and sized to /// the asset's real duration (audio/video probed once via the async caches, others a placeholder). Mirrors /// what initialises so the drag previews the actual result. /// private static void DrawDropPreview(Instance compositionOp, TimeLineCanvas timeCanvas, float layerTopY, int minLayerIndex, string address) { if (!AssetRegistry.TryGetAsset(address, out var asset) || asset.AssetType.TimelineClipOperator is null || !TryComputeDrop(timeCanvas, layerTopY, minLayerIndex, out var dropBars, out var dropLayer, out var playback)) return; // Probe via the async per-asset caches so the drag never blocks on a file open; until the probe lands // the preview uses the same placeholder length the drop would. double durationSecs = 0; if (asset.AssetType.Name == "Video") { VideoClipDurationCache.TryGetDurationSecs(address, compositionOp, out durationSecs); } else if (asset.AssetType.Name == "Audio") { AudioClipDurationCache.TryGetDurationSecs(address, compositionOp, out durationSecs); } else if (asset.AssetType.Name == "Midi") { // Synchronous is fine here: the converter caches by (path, last-write), so only the // first preview frame pays the parse. durationSecs = TryProbeMidiDurationSecs(asset.FullPath); } var durationBars = durationSecs > 0 ? (float)playback.BarsFromSeconds(durationSecs) : 4f; var x0 = timeCanvas.TransformX(dropBars); var x1 = timeCanvas.TransformX(dropBars + durationBars); var layerOffset = minLayerIndex == int.MaxValue ? 0 : dropLayer - minLayerIndex; var y0 = layerTopY + layerOffset * ClipArea.LayerHeight; var y1 = y0 + ClipArea.LayerHeight - 2; var drawList = ImGui.GetWindowDrawList(); var color = asset.AssetType.Color; drawList.AddRectFilled(new Vector2(x0, y0), new Vector2(x1, y1), color.Fade(0.3f), 4.5f); drawList.AddRect(new Vector2(x0, y0), new Vector2(x1, y1), color.Fade(0.8f), 4.5f); var label = System.IO.Path.GetFileNameWithoutExtension(asset.FullPath); ImGui.PushFont(Fonts.FontSmall); drawList.AddText(new Vector2(x0 + 4, y0 + 1), UiColors.ForegroundFull.Fade(0.8f), label); ImGui.PopFont(); } private static void CreateClipOp(Instance compositionOp, MacroCommand macro, Guid opSymbolId, string assetAddress, string assetFullPath, float startBars, int layer, Playback playback) { var addCmd = new AddSymbolChildCommand(compositionOp.Symbol, opSymbolId) { PosOnCanvas = GraphUtils.FindFreePosition(compositionOp.GetSymbolUi(), new Vector2(0, 200), SymbolUi.Child.DefaultOpSize), }; macro.AddAndExecCommand(addCmd); var childId = addCmd.AddedChildId; if (!compositionOp.Symbol.Children.TryGetValue(childId, out var symbolChild)) return; var durationBars = ProbeDurationBars(assetFullPath, playback); // Init the op's TimeClip output data in place. AddSymbolChildCommand.Undo removes the child // outright, so this mutation doesn't need its own undo entry. foreach (var output in symbolChild.Outputs.Values) { if (output.OutputData is TimeClip tc) { tc.TimeRange = new TimeRange(startBars, startBars + durationBars); tc.SourceRange = new TimeRange(0f, durationBars); tc.LayerIndex = layer; break; } } // Set the file-path input generically (the input whose UI Usage is FilePath), via the // standard input-value command so undo restores the empty default. if (compositionOp.Children.TryGetChildInstance(childId, out var instance) && SymbolAnalysis.TryGetFileInputFromInstance(instance, out var fileInputSlot, out _) && symbolChild.Inputs.TryGetValue(fileInputSlot.Id, out var childInput)) { macro.AddAndExecCommand(new ChangeInputValueCommand(compositionOp.Symbol, childId, childInput, new InputValue(assetAddress))); } else { Log.Warning($"Dropped timeline clip op {opSymbolId} has no file-path input; clip created without a file."); } } private static bool TryComputeDrop(TimeLineCanvas timeCanvas, float layerTopY, int minLayerIndex, out float dropBars, out int dropLayer, out Playback playback) { dropBars = 0; dropLayer = 0; playback = null!; var p = timeCanvas.Playback; if (p == null) return false; playback = p; var mousePos = ImGui.GetMousePos(); dropBars = (float)timeCanvas.InverseTransformX(mousePos.X); dropLayer = minLayerIndex == int.MaxValue ? 0 : minLayerIndex + (int)Math.Round((mousePos.Y - layerTopY - ClipArea.LayerHeight * 0.5f) / ClipArea.LayerHeight); return true; } /// /// Initial clip length in bars. Audio, video and MIDI are probed for their real duration so the clip /// matches the file; types with no probe (e.g. data) default to a placeholder. /// private static float ProbeDurationBars(string absolutePath, Playback playback) { // MIDI goes first: the audio probe logs a FileFormat warning for non-audio files. var durationSecs = TryProbeMidiDurationSecs(absolutePath); if (durationSecs <= 0) { durationSecs = AudioMixerManager.TryProbeAudioDurationSecs(absolutePath); } if (durationSecs <= 0 && VideoExport.Factory is { } factory && factory.TryProbeDurationSeconds(absolutePath, out var videoSecs)) { durationSecs = videoSecs; } return durationSecs > 0 ? (float)playback.BarsFromSeconds(durationSecs) : 4f; } /// /// Duration of a .mid/.midi file in seconds, or 0 for non-MIDI paths. The converter caches by /// (path, last-write), so the parse cost is paid once and shared with the LoadMidiFile op the /// drop creates. /// private static double TryProbeMidiDurationSecs(string absolutePath) { var extension = System.IO.Path.GetExtension(absolutePath); var isMidi = string.Equals(extension, ".mid", StringComparison.OrdinalIgnoreCase) || string.Equals(extension, ".midi", StringComparison.OrdinalIgnoreCase); if (!isMidi) return 0; if (!MidiFileToDataSet.TryGet(absolutePath, out var dataSet, out _)) return 0; return dataSet.Metadata?[MidiFileToDataSet.SourceDurationSecsKey]?.Value() ?? 0; } private static void FinishMacro(Instance compositionOp, MacroCommand macro) { UndoRedoStack.Add(macro); compositionOp.GetSymbolUi().FlagAsModified(); ProjectView.Focused?.FlagChanges(ProjectView.ChangeTypes.Children); } }