diff --git a/CLAUDE.md b/CLAUDE.md index 84060f6..aac2e16 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1052,6 +1052,44 @@ highlight color, others get the base text color. and point size from the selected Motion title's CHChannelText channel. `verify_captions()` walks connected titles on the timeline and checks text/fontSize against the expected style. +## Undo History Palette + +A floating palette that shows every action pushed onto FCP's undo stack, including +actions registered before the panel was opened. Redo entries (done-then-undone) are +shown greyed-out below the current state. Click any row to undo or redo to that state. + +``` +history.show() # open the Undo History palette +history.hide() # close it +history.get() # return full history + cursor position +history.jumpToIndex(index=-1) # undo/redo to a specific index (-1 = pristine) +history.clear() # clear SpliceKit's buffer (does NOT touch FCP undo stack) +``` + +`history.get()` returns: +```json +{ + "entries": [ + { "index": -1, "name": "(Pristine)", "timestamp": "", "isCurrent": false }, + { "index": 0, "name": "Blade", "timestamp": "14:32:01", "isCurrent": false }, + { "index": 1, "name": "Add Marker", "timestamp": "14:32:05", "isCurrent": true }, + { "index": 2, "name": "Trim Start", "timestamp": "14:32:09", "isCurrent": false } + ], + "cursor": 1, + "canUndo": true, + "canRedo": true, + "visible": true +} +``` + +Entries at indices > cursor are redo-available (greyed in the UI). +The palette tracks up to 100 entries in a ring buffer. + +**Implementation note**: history is captured by observing `NSUndoManagerDidCloseUndoGroupNotification` +with per-manager nesting depth tracking — only the outermost group close (depth 0→1→0) produces an +entry, so nested FCP transactions don't generate spurious rows. Recording is suppressed during +undo/redo replay so redo-stack registrations aren't mistaken for new user actions. + ## Lua Scripting SpliceKit embeds a Lua 5.4 VM directly in FCP's process. Scripts use the `sk` diff --git a/Makefile b/Makefile index 5c2db51..eb6c4f5 100644 --- a/Makefile +++ b/Makefile @@ -380,7 +380,7 @@ braw-prototype: $(BRAW_IMPORT_EXEC) $(BRAW_DECODER_EXEC) $(BRAW_CLI_BIN) braw-raw-processor: $(BRAW_RAWPROC_EXEC) @echo "Staged: $(BRAW_RAWPROC_BUNDLE)" -deploy: $(OUTPUT) $(SILENCE_DETECTOR) $(STRUCTURE_ANALYZER) $(MIXER_APP) braw-prototype vp9-prototype mkv-prototype +deploy: $(OUTPUT) $(SILENCE_DETECTOR) $(STRUCTURE_ANALYZER) $(MIXER_APP) vp9-prototype mkv-prototype @echo "=== Deploying SpliceKit to modded FCP ===" @rm -rf "$(FW_DIR)" @mkdir -p "$(FW_DIR)/Versions/A/Resources" @@ -394,9 +394,11 @@ deploy: $(OUTPUT) $(SILENCE_DETECTOR) $(STRUCTURE_ANALYZER) $(MIXER_APP) braw-pr @cd "$(FW_DIR)/Versions" && ln -sfn A Current @cd "$(FW_DIR)" && ln -sfn Versions/Current/SpliceKit SpliceKit @cd "$(FW_DIR)" && ln -sfn Versions/Current/Resources Resources - @# Create Info.plist if missing - @test -f "$(FW_DIR)/Versions/A/Resources/Info.plist" || \ - printf '\n\nCFBundleIdentifiercom.splicekit.SpliceKitCFBundleNameSpliceKitCFBundleVersion1.0.0CFBundlePackageTypeFMWKCFBundleExecutableSpliceKit' \ + @# Always write Info.plist with the current SPLICEKIT_VERSION so the patcher + @# version check (CFBundleShortVersionString comparison) never triggers a + @# spurious "update available" prompt after a manual make deploy. + @mkdir -p "$(FW_DIR)/Versions/A/Resources" + @printf '\n\nCFBundleIdentifiercom.splicekit.SpliceKitCFBundleNameSpliceKitCFBundleShortVersionString$(SPLICEKIT_VERSION)CFBundleVersion$(SPLICEKIT_VERSION)CFBundlePackageTypeFMWKCFBundleExecutableSpliceKit' \ > "$(FW_DIR)/Versions/A/Resources/Info.plist" @# Add privacy usage descriptions for transcript, LiveCam, and palette voice dictation. @/usr/libexec/PlistBuddy -c "Set :NSSpeechRecognitionUsageDescription 'SpliceKit uses speech recognition for transcript editing and command palette voice dictation inside Final Cut Pro.'" "$(MODDED_APP)/Contents/Info.plist" 2>/dev/null || /usr/libexec/PlistBuddy -c "Add :NSSpeechRecognitionUsageDescription string 'SpliceKit uses speech recognition for transcript editing and command palette voice dictation inside Final Cut Pro.'" "$(MODDED_APP)/Contents/Info.plist" 2>/dev/null || true @@ -451,11 +453,13 @@ deploy: $(OUTPUT) $(SILENCE_DETECTOR) $(STRUCTURE_ANALYZER) $(MIXER_APP) braw-pr @mkdir -p "$(MODDED_APP)/Contents/PlugIns/Codecs" @rm -rf "$(MODDED_APP)/Contents/PlugIns/Codecs/SpliceKitVP9Decoder.bundle" @cp -R "$(VP9_DECODER_BUNDLE)" "$(MODDED_APP)/Contents/PlugIns/Codecs/SpliceKitVP9Decoder.bundle" + @xattr -rc "$(MODDED_APP)/Contents/PlugIns/Codecs/SpliceKitVP9Decoder.bundle" 2>/dev/null || true @echo "VP9 decoder bundle copied into FCP.app/Contents/PlugIns" @$(MAKE) mkv-prototype @mkdir -p "$(MODDED_APP)/Contents/PlugIns/FormatReaders" @rm -rf "$(MODDED_APP)/Contents/PlugIns/FormatReaders/SpliceKitMKVImport.bundle" @cp -R "$(MKV_IMPORT_BUNDLE)" "$(MODDED_APP)/Contents/PlugIns/FormatReaders/SpliceKitMKVImport.bundle" + @xattr -rc "$(MODDED_APP)/Contents/PlugIns/FormatReaders/SpliceKitMKVImport.bundle" 2>/dev/null || true @echo "MKV/WebM format reader copied into FCP.app/Contents/PlugIns" @if [ "$(ENABLE_BRAW_RAW_PROCESSOR)" = "1" ]; then \ $(MAKE) braw-raw-processor; \ @@ -464,6 +468,7 @@ deploy: $(OUTPUT) $(SILENCE_DETECTOR) $(STRUCTURE_ANALYZER) $(MIXER_APP) braw-pr cp -R "$(BUILD_DIR)/braw-prototype/Extensions/SpliceKitBRAWRAWProcessor.appex" "$(MODDED_APP)/Contents/Extensions/SpliceKitBRAWRAWProcessor.appex"; \ echo "Opt-in BRAW RAW processor copied into FCP.app/Contents/Extensions"; \ fi + @xattr -rc "$(MODDED_APP)/Contents/PlugIns" 2>/dev/null || true @sign_identity=$$(security find-identity -v -p codesigning 2>/dev/null | awk '/"Apple Development:/ { print $$2; exit } /"Developer ID Application:/ && developer == "" { developer = $$2 } /[0-9]+\) [0-9A-F]+ "/ && first == "" { first = $$2 } END { if (developer != "") print developer; else if (first != "") print first }'); \ if [ -n "$$sign_identity" ]; then \ echo "Using signing identity: $$sign_identity"; \ @@ -480,6 +485,9 @@ deploy: $(OUTPUT) $(SILENCE_DETECTOR) $(STRUCTURE_ANALYZER) $(MIXER_APP) braw-pr if [ -d "$(MODDED_APP)/Contents/PlugIns/Codecs/SpliceKitVP9Decoder.bundle" ]; then \ codesign --force --sign "$$sign_identity" "$(MODDED_APP)/Contents/PlugIns/Codecs/SpliceKitVP9Decoder.bundle"; \ fi; \ + if [ -d "$(MODDED_APP)/Contents/PlugIns/FormatReaders/SpliceKitMKVImport.bundle" ]; then \ + codesign --force --sign "$$sign_identity" "$(MODDED_APP)/Contents/PlugIns/FormatReaders/SpliceKitMKVImport.bundle"; \ + fi; \ if [ -d "$(MODDED_APP)/Contents/Extensions/SpliceKitBRAWRAWProcessor.appex" ]; then \ appex_sign_id="$(BRAW_RAWPROC_SIGN_ID)"; \ if [ -n "$$appex_sign_id" ]; then \ diff --git a/Sources/SOURCES.txt b/Sources/SOURCES.txt index b8254f6..ee57964 100644 --- a/Sources/SOURCES.txt +++ b/Sources/SOURCES.txt @@ -26,8 +26,10 @@ SpliceKitSentry.m SpliceKitTranscriptPanel.m SpliceKitTranscriptDiagnostics.m SpliceKitCaptionPanel.m +SpliceKitUndoHistoryPanel.m SpliceKitCommandPalette.m SpliceKitDebugUI.m +SpliceKitPreferencesPane.m SpliceKitStructureBlocks.m SpliceKitSectionsBar.m SpliceKitTimelineOverview.m @@ -36,9 +38,14 @@ SpliceKitLuaPanel.m SpliceKitPlugins.m SpliceKitMixerPanel.m SpliceKitSidebarCoalesce.m +SpliceKitReplaceAtPlayhead.m +SpliceKitClipLock.m SpliceKitTimelineInteractionSuspend.m SpliceKitTimelinePlayheadOverlay.m +SpliceKitTimelineTabs.m +SpliceKitTimecodeBarShortcuts.m SpliceKitTimelinePerfMode.m +SpliceKitWaveformExpand.m SpliceKitURLImport.m SpliceKitLiveCam.m SpliceKitVisionPro.m diff --git a/Sources/SpliceKit.h b/Sources/SpliceKit.h index a69c0de..b95adc7 100644 --- a/Sources/SpliceKit.h +++ b/Sources/SpliceKit.h @@ -157,6 +157,10 @@ void SpliceKit_removeViewerPinchZoom(void); void SpliceKit_setViewerPinchZoomEnabled(BOOL enabled); BOOL SpliceKit_isViewerPinchZoomEnabled(void); +// Clip locking: right-click → "Lock/Unlock Clip", ⌥L shortcut, and RPC methods. +// Locked clips cannot be moved, trimmed, deleted, or cut. +void SpliceKit_installClipLock(void); + // Adds a right-click "Favorite" option in the effect browser. void SpliceKit_installEffectFavoritesSwizzle(void); @@ -216,11 +220,15 @@ void SpliceKit_installTimelinePerformanceMode(void); void SpliceKit_setTimelinePerformanceModeEnabled(BOOL enabled); BOOL SpliceKit_isTimelinePerformanceModeEnabled(void); +// Expands audio waveform to fill full clip height when video thumbnails are hidden. +void SpliceKit_installWaveformExpand(void); + // Swizzles pasteAnchored: and paste: to handle FCPXML on the pasteboard. // When FCPXML is detected, imports it into a temp project, converts to native // clipboard format, and then lets the original paste proceed. Includes caching, // screen freeze to hide the project switch, and playhead restoration. void SpliceKit_installFCPXMLPasteSwizzle(void); +void SpliceKit_installPasteOverwriteMenuItem(void); // Shared FCPXML-to-native conversion function. Checks if the pasteboard has // FCPXML, converts it to native proFFPasteboardUTI format (using cache if diff --git a/Sources/SpliceKit.m b/Sources/SpliceKit.m index a1e0f14..836b20f 100644 --- a/Sources/SpliceKit.m +++ b/Sources/SpliceKit.m @@ -13,10 +13,14 @@ #import "SpliceKitPlugins.h" #import "SpliceKitCommandPalette.h" #import "SpliceKitDebugUI.h" +#import "SpliceKitPreferencesPane.h" #import "SpliceKitSentry.h" #import "SpliceKitLiveCam.h" #import "SpliceKitURLImport.h" #import "SpliceKitImmersivePreviewPanel.h" +#import "SpliceKitUndoHistoryPanel.h" +#import "SpliceKitTimelineTabs.h" +#import "SpliceKitTimecodeBarShortcuts.h" #import #import #import @@ -34,6 +38,7 @@ extern NSDictionary *SpliceKit_handleFCPXMLImport(NSDictionary *params); extern NSDictionary *SpliceKit_handleProjectOpen(NSDictionary *params); extern void SpliceKit_installMixerSkimHooks(void); +extern void SpliceKit_installReplaceAtPlayhead(void); extern void SpliceKit_installBRAWProviderShim(void); extern void SpliceKit_bootstrapBRAWAtLaunchPhase(NSString *phase); extern BOOL SpliceKit_installBRAWRAWSettingsHooks(void); @@ -459,9 +464,12 @@ - (void)importOTIO:(id)sender; - (void)toggleLiveCamPanel:(id)sender; - (void)updateLiveCamToolbarButtonState:(BOOL)active; - (void)toggleVisionProPanel:(id)sender; +- (void)toggleUndoHistoryPanel:(id)sender; +- (void)closeOtherLibraries:(id)sender; @property (nonatomic, weak) NSButton *toolbarButton; @property (nonatomic, weak) NSButton *paletteToolbarButton; @property (nonatomic, weak) NSButton *liveCamToolbarButton; +@property (nonatomic, weak) NSButton *undoHistoryToolbarButton; @property (nonatomic, strong) NSMenu *luaScriptsMenu; @end @@ -507,6 +515,49 @@ - (void)toggleCaptionPanel:(id)sender { } } +- (void)toggleUndoHistoryPanel:(id)sender { + SpliceKitUndoHistoryPanel *panel = [SpliceKitUndoHistoryPanel sharedPanel]; + if ([panel isVisible]) { + [panel hidePanel]; + } else { + [panel showPanel]; + } +} + +- (void)closeOtherLibraries:(id)sender { + // Get the currently active/focused library via PEAppController._targetLibrary + id app = ((id (*)(id, SEL))objc_msgSend)( + objc_getClass("NSApplication"), @selector(sharedApplication)); + id delegate = ((id (*)(id, SEL))objc_msgSend)(app, @selector(delegate)); + + id activeLib = nil; + SEL targetLibSel = NSSelectorFromString(@"_targetLibrary"); + if ([delegate respondsToSelector:targetLibSel]) { + activeLib = ((id (*)(id, SEL))objc_msgSend)(delegate, targetLibSel); + } + + // Enumerate all open libraries + Class libDocClass = objc_getClass("FFLibraryDocument"); + if (!libDocClass) return; + id allLibs = ((id (*)(id, SEL))objc_msgSend)( + libDocClass, NSSelectorFromString(@"copyActiveLibraries")); + if (![allLibs isKindOfClass:[NSArray class]]) return; + + NSArray *libs = (NSArray *)allLibs; + if (libs.count <= 1) return; + + for (id lib in libs) { + if (activeLib && lib == activeLib) continue; + // Close via the library's NSDocument (triggers FCP's close flow including save prompt) + SEL libDocSel = NSSelectorFromString(@"libraryDocument"); + id doc = [lib respondsToSelector:libDocSel] + ? ((id (*)(id, SEL))objc_msgSend)(lib, libDocSel) : nil; + if (doc && [doc respondsToSelector:@selector(close)]) { + ((void (*)(id, SEL))objc_msgSend)(doc, @selector(close)); + } + } +} + - (void)toggleMixerPanel:(id)sender { Class panelClass = objc_getClass("SpliceKitMixerPanel"); if (!panelClass) { @@ -2027,6 +2078,14 @@ - (void)importOTIO:(id)sender { } - (BOOL)validateMenuItem:(NSMenuItem *)menuItem { + if (menuItem.action == @selector(closeOtherLibraries:)) { + Class libDocClass = objc_getClass("FFLibraryDocument"); + if (!libDocClass) return NO; + id allLibs = ((id (*)(id, SEL))objc_msgSend)( + libDocClass, NSSelectorFromString(@"copyActiveLibraries")); + NSInteger count = [allLibs isKindOfClass:[NSArray class]] ? [(NSArray *)allLibs count] : 0; + return count > 1; + } if (menuItem.action == @selector(toggleSections:)) { NSDictionary *state = SpliceKit_handleSectionsGet(@{}); menuItem.state = [state[@"installed"] boolValue] ? NSControlStateValueOn : NSControlStateValueOff; @@ -2521,6 +2580,14 @@ static void SpliceKit_installMenu(void) { mixerItem.target = [SpliceKitMenuController shared]; [bridgeMenu addItem:mixerItem]; + NSMenuItem *undoHistoryItem = [[NSMenuItem alloc] + initWithTitle:@"Undo History" + action:@selector(toggleUndoHistoryPanel:) + keyEquivalent:@"u"]; + undoHistoryItem.keyEquivalentModifierMask = NSEventModifierFlagControl | NSEventModifierFlagOption; + undoHistoryItem.target = [SpliceKitMenuController shared]; + [bridgeMenu addItem:undoHistoryItem]; + [bridgeMenu addItem:[NSMenuItem separatorItem]]; NSMenuItem *muteAudioItem = [[NSMenuItem alloc] @@ -2811,14 +2878,37 @@ static void SpliceKit_installMenu(void) { } SpliceKit_log(@"OTIO import/export added to File menu"); + + // Find "Close Library" and insert "Close Other Libraries" after it + NSMenuItem *closeOtherLibrariesItem = [[NSMenuItem alloc] + initWithTitle:@"Close Other Libraries" + action:@selector(closeOtherLibraries:) + keyEquivalent:@"W"]; + closeOtherLibrariesItem.keyEquivalentModifierMask = NSEventModifierFlagShift; + closeOtherLibrariesItem.target = [SpliceKitMenuController shared]; + BOOL insertedCloseOther = NO; + for (NSInteger i = 0; i < fileMenu.numberOfItems; i++) { + NSString *title = [fileMenu itemAtIndex:i].title; + if ([title isEqualToString:@"Close Library"]) { + [fileMenu insertItem:closeOtherLibrariesItem atIndex:i + 1]; + insertedCloseOther = YES; + break; + } + } + if (!insertedCloseOther) { + // Fallback: insert near top of File menu if "Close Library" not found + [fileMenu insertItem:closeOtherLibrariesItem atIndex:1]; + } + SpliceKit_log(@"Close Other Libraries added to File menu"); } SpliceKit_log(@"SpliceKit menu installed (Ctrl+Option+T Transcript, Ctrl+Option+C Captions, Cmd+Shift+P Palette, Ctrl+Option+L Lua REPL)"); } -static NSString * const kSpliceKitLiveCamToolbarID = @"SpliceKitLiveCamItemID"; -static NSString * const kSpliceKitTranscriptToolbarID = @"SpliceKitTranscriptItemID"; -static NSString * const kSpliceKitPaletteToolbarID = @"SpliceKitPaletteItemID"; +static NSString * const kSpliceKitLiveCamToolbarID = @"SpliceKitLiveCamItemID"; +static NSString * const kSpliceKitTranscriptToolbarID = @"SpliceKitTranscriptItemID"; +static NSString * const kSpliceKitPaletteToolbarID = @"SpliceKitPaletteItemID"; +static NSString * const kSpliceKitUndoHistoryToolbarID = @"SpliceKitUndoHistoryItemID"; static IMP sOriginalToolbarItemForIdentifier = NULL; // We swizzle FCP's toolbar delegate so it knows about our custom toolbar items. @@ -2908,6 +2998,35 @@ static id SpliceKit_toolbar_itemForItemIdentifier(id self, SEL _cmd, NSToolbar * return item; } + if ([identifier isEqualToString:kSpliceKitUndoHistoryToolbarID]) { + NSToolbarItem *item = [[NSToolbarItem alloc] initWithItemIdentifier:kSpliceKitUndoHistoryToolbarID]; + item.label = @"History"; + item.paletteLabel = @"Undo History"; + item.toolTip = @"Undo History"; + + NSImage *icon = [NSImage imageWithSystemSymbolName:@"clock.arrow.trianglehead.counterclockwise.rotate.90" + accessibilityDescription:@"Undo History"]; + if (!icon) icon = [NSImage imageWithSystemSymbolName:@"clock.arrow.circlepath" + accessibilityDescription:@"Undo History"]; + if (!icon) icon = [NSImage imageNamed:NSImageNameRefreshTemplate]; + NSImageSymbolConfiguration *config = [NSImageSymbolConfiguration + configurationWithPointSize:13 weight:NSFontWeightMedium]; + icon = [icon imageWithSymbolConfiguration:config]; + + NSButton *button = [[NSButton alloc] initWithFrame:NSMakeRect(0, 0, 32, 25)]; + [button setButtonType:NSButtonTypePushOnPushOff]; + button.bezelStyle = NSBezelStyleTexturedRounded; + button.bordered = YES; + button.image = icon; + button.alternateImage = icon; + button.imagePosition = NSImageOnly; + button.target = [SpliceKitMenuController shared]; + button.action = @selector(toggleUndoHistoryPanel:); + + [SpliceKitMenuController shared].undoHistoryToolbarButton = button; + item.view = button; + return item; + } // Call original return ((id (*)(id, SEL, NSToolbar *, NSString *, BOOL))sOriginalToolbarItemForIdentifier)( self, _cmd, toolbar, identifier, willInsert); @@ -2982,7 +3101,7 @@ + (void)addToolbarButtonToWindow:(NSWindow *)window { // Guard against double-insertion — can happen if both the notification // and the polling fallback fire. Also clean up stale items (no view). - BOOL hasLiveCam = NO, hasTranscript = NO, hasPalette = NO; + BOOL hasLiveCam = NO, hasTranscript = NO, hasPalette = NO, hasUndoHistory = NO; for (NSInteger i = (NSInteger)toolbar.items.count - 1; i >= 0; i--) { NSToolbarItem *ti = toolbar.items[(NSUInteger)i]; if ([ti.itemIdentifier isEqualToString:kSpliceKitLiveCamToolbarID]) { @@ -3009,19 +3128,25 @@ + (void)addToolbarButtonToWindow:(NSWindow *)window { } else { [toolbar removeItemAtIndex:(NSUInteger)i]; } + } else if ([ti.itemIdentifier isEqualToString:kSpliceKitUndoHistoryToolbarID]) { + if (ti.view) { + if ([ti.view isKindOfClass:[NSButton class]]) + [SpliceKitMenuController shared].undoHistoryToolbarButton = (NSButton *)ti.view; + hasUndoHistory = YES; + } else { + [toolbar removeItemAtIndex:(NSUInteger)i]; + } } } - if (hasLiveCam && hasTranscript && hasPalette) { + if (hasLiveCam && hasTranscript && hasPalette && hasUndoHistory) { SpliceKit_log(@"All toolbar buttons already present — skipping"); return; } - // Insert our buttons just before the flexible space — that's where - // they look most natural, grouped with FCP's own tool buttons. + // Find a good base insertion point: just before the flexible space. NSUInteger insertIdx = toolbar.items.count; for (NSUInteger i = 0; i < toolbar.items.count; i++) { - NSToolbarItem *ti = toolbar.items[i]; - if ([ti.itemIdentifier isEqualToString:NSToolbarFlexibleSpaceItemIdentifier]) { + if ([toolbar.items[i].itemIdentifier isEqualToString:NSToolbarFlexibleSpaceItemIdentifier]) { insertIdx = i; break; } @@ -3039,6 +3164,20 @@ + (void)addToolbarButtonToWindow:(NSWindow *)window { if (!hasTranscript) { [toolbar insertItemWithItemIdentifier:kSpliceKitTranscriptToolbarID atIndex:insertIdx]; SpliceKit_log(@"Transcript toolbar button inserted at index %lu", (unsigned long)insertIdx); + insertIdx++; + } + if (!hasUndoHistory) { + // Insert right after the Transcript button if it's already in the toolbar, + // otherwise use insertIdx which is already positioned correctly. + NSUInteger undoIdx = insertIdx; + for (NSUInteger i = 0; i < toolbar.items.count; i++) { + if ([toolbar.items[i].itemIdentifier isEqualToString:kSpliceKitTranscriptToolbarID]) { + undoIdx = i + 1; + break; + } + } + [toolbar insertItemWithItemIdentifier:kSpliceKitUndoHistoryToolbarID atIndex:undoIdx]; + SpliceKit_log(@"Undo History toolbar button inserted at index %lu", (unsigned long)undoIdx); } } @catch (NSException *e) { @@ -3371,6 +3510,9 @@ static void SpliceKit_appDidLaunch(void) { // Install effect browser favorites context menu (always on) SpliceKit_installEffectFavoritesSwizzle(); + // Expand audio waveform to full clip height when video thumbnails are hidden (always on) + SpliceKit_installWaveformExpand(); + // Debounce FFSidebarModule KVO churn on the Effects sidebar's category // list during live scroll (fixes scrolling jerks with many effects). if (SpliceKit_isSidebarCoalesceLiveScrollEnabled()) { @@ -3391,6 +3533,11 @@ static void SpliceKit_appDidLaunch(void) { // meter live skims even when isToolSkimming stays false. SpliceKit_installMixerSkimHooks(); + // Restore Replace at Playhead edit mode removed from FCP 12.2's UI. + SpliceKit_safeInstall("ReplaceAtPlayhead", ^{ + SpliceKit_installReplaceAtPlayhead(); + }); + // Restore persisted social caption text after relaunch once a real sequence is // active. Automatic repair is intentionally limited to the Motion effect text // field API so relaunch does not wake the heavier channel/document machinery. @@ -3407,11 +3554,18 @@ static void SpliceKit_appDidLaunch(void) { // to native clipboard format so pasteAnchored: can handle it) SpliceKit_installFCPXMLPasteSwizzle(); + // Add "Paste Overwrite" to the Edit menu after the native Paste item + SpliceKit_installPasteOverwriteMenuItem(); + // Swizzle J/L to use configurable speed ladders SpliceKit_installPlaybackSpeedSwizzle(); // Rebuild FCP's hidden Debug pane + Debug menu bar (Apple strips the NIB // and leaves the menu unassigned in release builds; we reconstruct both). + // SpliceKit pane first: inserts data into _preferenceTitles/_preferenceModules + // WITHOUT calling _setupToolbar. Then Debug installs and calls _setupToolbar + // once, picking up both tabs in a single rebuild. + SpliceKit_installSpliceKitPreferencesPane(); SpliceKit_installDebugSettingsPanel(); // SpliceKit_installDebugMenuBar(); // disabled — don't add the Debug menu to the bar @@ -3420,6 +3574,11 @@ static void SpliceKit_appDidLaunch(void) { SpliceKit_installStructureBlockContextMenu(); }); + // Clip locking: ⌥L / right-click → Lock/Unlock Clip, RPC, move/trim/delete blocking + SpliceKit_safeInstall("ClipLock", ^{ + SpliceKit_installClipLock(); + }); + // Inline miniature-timeline overview bar — install if user had it on if (SpliceKit_isTimelineOverviewBarEnabled()) { SpliceKit_safeInstall("TimelineOverviewBar", ^{ @@ -3427,12 +3586,26 @@ static void SpliceKit_appDidLaunch(void) { }); } + // Named project tabs — persistent row above the timeline replacing the + // back/forward arrow tap-dance. + SpliceKit_safeInstall("TimelineTabs", ^{ + SpliceKit_installTimelineTabs(); + }); + + // Customizable shortcut buttons injected into the timeline toolbar's flex gap. + SpliceKit_safeInstall("TimecodeBarShortcuts", ^{ + SpliceKit_installTimecodeBarShortcuts(); + }); + // Bridge metadata (bridge.describe / bridge.alive) and async/events // infrastructure must be registered before the control server starts // accepting requests, since they go through the plugin registry. SpliceKit_safeInstall("BridgeMetadata", ^{ SpliceKit_installBridgeMetadata(); }); + SpliceKit_safeInstall("UndoHistoryHooks", ^{ + [SpliceKitUndoHistoryPanel installHooks]; + }); SpliceKit_safeInstall("AsyncEvents", ^{ SpliceKit_installAsync(); }); diff --git a/Sources/SpliceKitBRAW.mm b/Sources/SpliceKitBRAW.mm index 5f8b0a0..8ab4572 100644 --- a/Sources/SpliceKitBRAW.mm +++ b/Sources/SpliceKitBRAW.mm @@ -5625,6 +5625,17 @@ SPLICEKIT_BRAW_EXTERN_C BOOL SpliceKit_installBRAWAVURLAssetMIMEHook(void) { }; } +SPLICEKIT_BRAW_EXTERN_C void SpliceKit_bootstrapBRAWAtLaunchPhase(NSString *phase) { + (void)phase; +} + +SPLICEKIT_BRAW_EXTERN_C NSDictionary *SpliceKit_handleBRAWAVProbe(NSDictionary *params) { + (void)params; + return @{ + @"error": @"Blackmagic RAW SDK headers are not available at /Applications/Blackmagic RAW/Blackmagic RAW SDK/Mac/Include/BlackmagicRawAPI.h", + }; +} + SPLICEKIT_BRAW_EXTERN_C BOOL SpliceKitBRAW_DecodeFrameBytes( CFStringRef pathRef, uint32_t frameIndex, @@ -5756,4 +5767,7 @@ SPLICEKIT_BRAW_EXTERN_C BOOL SpliceKitBRAW_ReadAudioSamples( return NO; } -#endif +#endif // SPLICEKIT_HAS_BRAW_SDK + +// (Stubs for SpliceKit_bootstrapBRAWAtLaunchPhase and SpliceKit_handleBRAWAVProbe +// are already defined above inside the #else !SPLICEKIT_HAS_BRAW_SDK block.) diff --git a/Sources/SpliceKitBridgeMetadata.m b/Sources/SpliceKitBridgeMetadata.m index f715dc6..e898e7c 100644 --- a/Sources/SpliceKitBridgeMetadata.m +++ b/Sources/SpliceKitBridgeMetadata.m @@ -140,6 +140,21 @@ static void SpliceKit_initBuiltinMetadata(void) { // scene.* @"scene.detect": meta(@"safe", @"Detect scene changes in timeline."), + + // clips.* (clip locking) + @"clips.lock": meta(@"state_dependent", @"Lock the clip at the playhead (or by handle) to prevent editing."), + @"clips.unlock": meta(@"state_dependent", @"Unlock the clip at the playhead (or by handle)."), + @"clips.toggleLock": meta(@"state_dependent", @"Toggle lock state on the clip at the playhead (or by handle)."), + @"clips.isLocked": meta(@"safe", @"Return whether the clip at the playhead (or by handle) is locked."), + @"clips.listLocked": meta(@"safe", @"Return all locked clips in the current sequence."), + @"clips.unlockAll": meta(@"state_dependent", @"Unlock all locked clips in the current sequence."), + + // history.* + @"history.show": meta(@"safe", @"Open the Undo History palette."), + @"history.hide": meta(@"safe", @"Close the Undo History palette."), + @"history.get": meta(@"safe", @"Return all recorded history entries and the current cursor."), + @"history.jumpToIndex": meta(@"state_dependent", @"Undo or redo to a specific history index (-1 = pristine)."), + @"history.clear": meta(@"safe", @"Clear the SpliceKit history buffer (FCP undo stack is unchanged)."), }; }); } diff --git a/Sources/SpliceKitClipLock.m b/Sources/SpliceKitClipLock.m new file mode 100644 index 0000000..0a04f75 --- /dev/null +++ b/Sources/SpliceKitClipLock.m @@ -0,0 +1,1828 @@ +// SpliceKitClipLock.m +// +// Adds clip, compound-clip, and connected-clip locking to FCP. +// A locked clip cannot be moved, trimmed, deleted, cut, or have effects dropped onto it. +// +// LOCK STORAGE +// Locked clip identifiers are stored in NSUserDefaults under "SpliceKitLockedClipIds" +// as an NSArray of UUID strings. Because each clip has a globally-unique +// identifier in FCP's model, a single flat set covers all sequences in all libraries. +// +// SWIZZLE POINTS +// 1. TLKTimelineView.menuForEvent: +// Right-click menu → "Lock Clip" / "Unlock Clip" +// 2. FFAnchoredTimelineModule.timelineView:shouldMoveItems:byPlacingItem:inContainer:atIndex:atTime: +// Blocks drag-move of locked clips (returns NO → FCP refuses the move). +// 3. FFAnchoredTimelineModule._validateTrimAction: +// Blocks trim of locked clips (returns NO → trim cursor disabled). +// 4. FFAnchoredTimelineModule.validateUserInterfaceItem:withSelectedItems: +// Blocks delete/cut/effects-drop on locked clips by returning NO for +// destructive action selectors when the selection contains a locked clip. +// +// KEYBOARD SHORTCUT +// ⌥L registered as LKCommand "SKLockClip" via the same path as ReplaceAtPlayhead. +// +// RPC METHODS (all routed through SpliceKit_registerPluginMethod) +// clips.lock — lock clip at playhead (or by handle) +// clips.unlock — unlock clip at playhead (or by handle) +// clips.toggleLock — toggle lock on clip at playhead +// clips.isLocked — return lock state of clip at playhead +// clips.listLocked — list all currently locked clip IDs +// clips.unlockAll — clear all locks + +#import "SpliceKit.h" +#import +#import +#import + +// --------------------------------------------------------------------------- +// CMTime (inline, no CoreMedia link needed) +// --------------------------------------------------------------------------- + +typedef struct { + int64_t value; + int32_t timescale; + uint32_t flags; + int64_t epoch; +} CL_CMTime; + +// --------------------------------------------------------------------------- +// Persistence — clip metadata via mdSetLocalValue:forKey: +// +// FCP stores metadata in the library's SQLite database, so it survives +// restarts, FCP updates, and anything short of deleting the library. +// No external file or NSUserDefaults needed. +// +// Key: "SpliceKitLocked" Value: "1" (present = locked, absent = unlocked) +// +// In-memory state: +// sAnyLockedInTimeline — quick flag so CL_hasAnyLockedClips() is O(1). +// Updated whenever a lock changes or a new sequence is loaded. +// --------------------------------------------------------------------------- + +static NSString * const kSKLockedKey = @"SpliceKitLocked"; +static NSString * const kSKLockedValue = @"1"; + +static BOOL sAnyLockedInTimeline = NO; +static NSLock *sLockStateLock; // guards sAnyLockedInTimeline +static NSHashTable *sSessionLockedClips; // weak-ref in-session cache +static os_unfair_lock sSessionLock = OS_UNFAIR_LOCK_INIT; // guards sSessionLockedClips + +static void CL_ensureState(void) { + static dispatch_once_t once; + dispatch_once(&once, ^{ + sLockStateLock = [[NSLock alloc] init]; + sSessionLockedClips = [NSHashTable weakObjectsHashTable]; + }); +} + +// Thread-safe cache helpers +static BOOL CL_cacheContains(id clip) { + os_unfair_lock_lock(&sSessionLock); + BOOL found = [sSessionLockedClips containsObject:clip]; + os_unfair_lock_unlock(&sSessionLock); + return found; +} +static void CL_cacheAdd(id clip) { + os_unfair_lock_lock(&sSessionLock); + [sSessionLockedClips addObject:clip]; + os_unfair_lock_unlock(&sSessionLock); +} +static void CL_cacheRemove(id clip) { + os_unfair_lock_lock(&sSessionLock); + [sSessionLockedClips removeObject:clip]; + os_unfair_lock_unlock(&sSessionLock); +} + +// --------------------------------------------------------------------------- +// Two-tier lock check: +// +// CL_isLockedClip(clip) — CACHE ONLY. Safe to call from any swizzle +// that fires during an active FCP operation +// (delete, trim, move, volume). Never reads +// the library DB — mdLocalValueForKey: crashes +// when called on a clip that is mid-operation. +// +// CL_isLockedClipDB(clip) — CACHE + DB fallback. Only safe to call from +// idle contexts: RPC handlers, startup scans, +// and _startListeningToSequence: (which fires +// before any user interaction on the new seq). +// --------------------------------------------------------------------------- + +static BOOL CL_isLockedClip(id clip) { + if (!clip) return NO; + CL_ensureState(); + // Cache-only check. No ObjC messages to clip beyond what's already safe. + // parentItem walks are in CL_isLockedClipDB (idle contexts only). + return CL_cacheContains(clip); +} + +static BOOL CL_isLockedClipDB(id clip) { + if (!clip) return NO; + CL_ensureState(); + if (CL_cacheContains(clip)) return YES; + SEL sel = NSSelectorFromString(@"mdLocalValueForKey:"); + if (![clip respondsToSelector:sel]) return NO; + @try { + id val = ((id (*)(id, SEL, id))objc_msgSend)(clip, sel, kSKLockedKey); + if (val) { CL_cacheAdd(clip); return YES; } + } @catch (...) {} + // Walk up one level. + SEL parentSel = NSSelectorFromString(@"parentItem"); + if (![clip respondsToSelector:parentSel]) return NO; + id parent = nil; + @try { parent = ((id (*)(id, SEL))objc_msgSend)(clip, parentSel); } + @catch (...) { return NO; } + if (!parent || parent == clip) return NO; + if (CL_cacheContains(parent)) return YES; + @try { + id val = ((id (*)(id, SEL, id))objc_msgSend)(parent, sel, kSKLockedKey); + if (val) { CL_cacheAdd(parent); return YES; } + } @catch (...) {} + id grandparent = nil; + @try { grandparent = ((id (*)(id, SEL))objc_msgSend)(parent, parentSel); } + @catch (...) { return NO; } + if (!grandparent || grandparent == parent) return NO; + if (CL_cacheContains(grandparent)) return YES; + @try { + id val = ((id (*)(id, SEL, id))objc_msgSend)(grandparent, sel, kSKLockedKey); + if (val) { CL_cacheAdd(grandparent); return YES; } + } @catch (...) {} + return NO; +} + +// Write lock state. +// Immediately updates sSessionLockedClips (so the lock is effective right away) +// and attempts to persist via mdSetLocalValue:forKey: + action wrappers (which +// commits to the library's SQLite for cross-restart persistence). +static void CL_setClipLocked(id clip, BOOL locked) { + if (!clip) return; + CL_ensureState(); + // Update in-session cache immediately. + if (locked) CL_cacheAdd(clip); + else CL_cacheRemove(clip); + + // Persist to DB (best-effort — may silently fail if clip is not in an + // active editing context, but will work via the right-click/⌥L paths + // where the clip is always selected/active). + SEL setSel = NSSelectorFromString(@"mdSetLocalValue:forKey:"); + SEL beginSel = NSSelectorFromString(@"actionBeginSetMetadataValue"); + SEL endSel = NSSelectorFromString(@"actionEndSetMetadataValueWithError:"); + if (![clip respondsToSelector:setSel]) return; + @try { + if ([clip respondsToSelector:beginSel]) + ((void (*)(id, SEL))objc_msgSend)(clip, beginSel); + id value = locked ? kSKLockedValue : nil; + ((void (*)(id, SEL, id, id))objc_msgSend)(clip, setSel, value, kSKLockedKey); + if ([clip respondsToSelector:endSel]) + ((BOOL (*)(id, SEL, id *))objc_msgSend)(clip, endSel, NULL); + } @catch (...) {} +} + +// Clips live at sequence.primaryObject.allContainedItems — NOT sequence.allContainedItems +// (which returns nil on FFAnchoredSequence). +static NSArray *CL_getSpineItems(id seq) { + if (!seq) return nil; + SEL primarySel = NSSelectorFromString(@"primaryObject"); + if (![seq respondsToSelector:primarySel]) return nil; + id primary = nil; + @try { primary = ((id (*)(id, SEL))objc_msgSend)(seq, primarySel); } @catch (...) { return nil; } + if (!primary) return nil; + SEL allSel = NSSelectorFromString(@"allContainedItems"); + if (![primary respondsToSelector:allSel]) return nil; + @try { return ((id (*)(id, SEL))objc_msgSend)(primary, allSel); } @catch (...) { return nil; } +} + +// Scan the sequence owned by timelineModule and update the quick flag. +static void CL_updateAnyLockedFlag(id timelineModule) { + SEL seqSel = NSSelectorFromString(@"sequence"); + if (!timelineModule || ![timelineModule respondsToSelector:seqSel]) return; + id seq = ((id (*)(id, SEL))objc_msgSend)(timelineModule, seqSel); + NSArray *items = CL_getSpineItems(seq); + if (!items) return; + BOOL found = NO; + for (id item in items) { if (CL_isLockedClipDB(item)) { found = YES; break; } } + [sLockStateLock lock]; + sAnyLockedInTimeline = found; + [sLockStateLock unlock]; +} + +// --------------------------------------------------------------------------- +// Collection helpers (used by swizzles) +// --------------------------------------------------------------------------- + +// Returns YES if any item in the array is locked. +static BOOL CL_arrayContainsLocked(NSArray *items) { + for (id item in items) { + if (CL_isLockedClip(item)) return YES; + } + return NO; +} + +// Returns YES if any *selected* item in the given timeline module is locked. +static BOOL CL_selectionContainsLocked(id timelineModule) { + if (!timelineModule) return NO; + SEL sel = NSSelectorFromString(@"selectedItems"); + if (![timelineModule respondsToSelector:sel]) return NO; + @try { + NSArray *items = ((id (*)(id, SEL))objc_msgSend)(timelineModule, sel); + return CL_arrayContainsLocked(items); + } @catch (...) { return NO; } +} + +// Returns the first selected clip in the active timeline module. +static id CL_clipAtPlayhead(void) { + id tm = SpliceKit_getActiveTimelineModule(); + if (!tm) return nil; + SEL itemsSel = NSSelectorFromString(@"selectedItems"); + if (![tm respondsToSelector:itemsSel]) return nil; + @try { + NSArray *items = ((id (*)(id, SEL))objc_msgSend)(tm, itemsSel); + return items.firstObject; + } @catch (...) { return nil; } +} + +// Forward declaration needed before shouldRippleEdge swizzle. +static BOOL CL_hasAnyLockedClips(void); + +// --------------------------------------------------------------------------- +// Display name prefix — shows 🔒 on locked clips in the timeline label, +// inspector, and browser. We swizzle the getter only; the stored ivar +// (_displayName) is never touched so FCPXML export is unaffected. +// --------------------------------------------------------------------------- + +static IMP sOrigDisplayName = NULL; + +static NSString *CL_swizzled_displayName(id self, SEL _cmd) { + NSString *orig = sOrigDisplayName + ? ((NSString *(*)(id, SEL))sOrigDisplayName)(self, _cmd) + : @""; + // Only check the in-memory cache — never walk parentItem here. + // displayName is called on clips mid-deletion; parentItem on a + // partially-deallocated object causes EXC_BAD_ACCESS. + CL_ensureState(); + if (CL_cacheContains(self)) + return [NSString stringWithFormat:@"🔒 %@", orig]; + return orig; +} + +// Force-refresh specific clips in the timeline view so the 🔒 label appears +// immediately without requiring a zoom or interaction. +// +// TLKTimelineView.reloadWithItemsAdded:removed:modified: is FCP's proper +// pipeline for pushing model changes to the layer system. Passing a clip in +// the `modified:` array causes its TLKItemLayer to call setDisplayName: again +// with the fresh value from our swizzled FFAnchoredObject.displayName getter. +static void CL_refreshClips(NSArray *clips) { + if (!clips.count) return; + dispatch_async(dispatch_get_main_queue(), ^{ + id tm = SpliceKit_getActiveTimelineModule(); + if (!tm) return; + SEL viewSel = NSSelectorFromString(@"timelineView"); + if (![tm respondsToSelector:viewSel]) return; + id view = ((id (*)(id, SEL))objc_msgSend)(tm, viewSel); + if (!view) return; + + // Post KVO for each clip so bindings / observers see the new displayName. + for (id clip in clips) { + [clip willChangeValueForKey:@"displayName"]; + [clip didChangeValueForKey:@"displayName"]; + } + + // Tell the timeline view these items were modified → triggers a layer + // refresh cycle that re-reads displayName from the model. + SEL reloadSel = NSSelectorFromString(@"reloadWithItemsAdded:removed:modified:"); + if ([view respondsToSelector:reloadSel]) { + NSArray *empty = @[]; + ((void (*)(id, SEL, id, id, id))objc_msgSend)(view, reloadSel, + empty, empty, clips); + } else { + // Fallback: queue each clip as updated and flush. + SEL queueSel = NSSelectorFromString(@"queueUpdatedObjectsForReload:ofType:"); + SEL flushSel = NSSelectorFromString(@"reloadPendingChangesWithAnimation:"); + if ([view respondsToSelector:queueSel] && [view respondsToSelector:flushSel]) { + for (id clip in clips) { + ((void (*)(id, SEL, id, id))objc_msgSend)(view, queueSel, clip, nil); + } + ((void (*)(id, SEL, BOOL))objc_msgSend)(view, flushSel, NO); + } else { + [view setNeedsDisplay:YES]; + } + } + }); +} + +// --------------------------------------------------------------------------- +// Swizzle 1 — TLKTimelineView.menuForEvent: +// --------------------------------------------------------------------------- + +static IMP sOrigMenuForEvent_CL = NULL; + +// Set of action selector name strings that are destructive (so we know when +// to gray items out in validateUserInterfaceItem:withSelectedItems:). +static NSSet *CL_destructiveActionNames(void) { + static NSSet *s; + static dispatch_once_t once; + dispatch_once(&once, ^{ + s = [NSSet setWithArray:@[ + @"delete:", + @"cut:", + @"performDeleteKey:", + @"actionDelete:", + @"actionCut:", + @"actionTrimStart:", + @"actionTrimEnd:", + @"actionTrimToPlayhead:", + @"actionExtendEdit:", + @"actionJoinClips:", + @"actionNudgeLeft:", + @"actionNudgeRight:", + @"actionNudgeUp:", + @"actionNudgeDown:", + @"moveToPlayhead:", + ]]; + }); + return s; +} + +// Handler object that lives forever — target for context menu items. +@interface SKClipLockMenuHandler : NSObject ++ (instancetype)shared; +- (void)toggleLockFromMenu:(NSMenuItem *)sender; +@end + +@implementation SKClipLockMenuHandler ++ (instancetype)shared { + static SKClipLockMenuHandler *inst; + static dispatch_once_t once; + dispatch_once(&once, ^{ inst = [[SKClipLockMenuHandler alloc] init]; }); + return inst; +} + +- (void)toggleLockFromMenu:(NSMenuItem *)sender { + NSArray *clips = sender.representedObject; + if (![clips isKindOfClass:[NSArray class]] || clips.count == 0) { + id clip = CL_clipAtPlayhead(); + clips = clip ? @[clip] : @[]; + } + NSMutableArray *changed = [NSMutableArray array]; + for (id clip in clips) { + BOOL wasLocked = CL_isLockedClipDB(clip); + CL_setClipLocked(clip, !wasLocked); + [changed addObject:clip]; + SpliceKit_log(@"[ClipLock] %@ clip", wasLocked ? @"Unlocked" : @"Locked"); + } + id tm = SpliceKit_getActiveTimelineModule(); + if (tm) CL_updateAnyLockedFlag(tm); + CL_refreshClips(changed); +} +@end + +static NSMenu *CL_swizzled_menuForEvent(id self, SEL _cmd, NSEvent *event) { + // Call whatever is currently installed as the "original" — this may itself + // be another SpliceKit swizzle (e.g. StructureBlocks), so we always chain. + NSMenu *menu = sOrigMenuForEvent_CL + ? ((NSMenu *(*)(id, SEL, NSEvent *))sOrigMenuForEvent_CL)(self, _cmd, event) + : nil; + + id tm = SpliceKit_getActiveTimelineModule(); + if (!tm) return menu; + + SEL itemsSel = NSSelectorFromString(@"selectedItems"); + if (![tm respondsToSelector:itemsSel]) return menu; + + NSArray *selectedItems = nil; + @try { selectedItems = ((id (*)(id, SEL))objc_msgSend)(tm, itemsSel); } + @catch (...) { return menu; } + if (![selectedItems isKindOfClass:[NSArray class]] || selectedItems.count == 0) return menu; + + // Filter to lockable types: skip gaps, transitions, markers. + NSMutableArray *lockable = [NSMutableArray array]; + for (id item in selectedItems) { + NSString *cls = NSStringFromClass([item class]) ?: @""; + SEL isGapSel = NSSelectorFromString(@"isGap"); + if ([item respondsToSelector:isGapSel]) { + @try { if (((BOOL (*)(id, SEL))objc_msgSend)(item, isGapSel)) continue; } + @catch (...) {} + } + if ([cls containsString:@"Transition"]) continue; + SEL isMarkerSel = NSSelectorFromString(@"isMarker"); + if ([item respondsToSelector:isMarkerSel]) { + @try { if (((BOOL (*)(id, SEL))objc_msgSend)(item, isMarkerSel)) continue; } + @catch (...) {} + } + [lockable addObject:item]; + } + if (lockable.count == 0) return menu; + + // Are all lockable items currently locked? + BOOL allLocked = YES; + for (id clip in lockable) { + if (!CL_isLockedClipDB(clip)) { allLocked = NO; break; } + } + + if (!menu) menu = [[NSMenu alloc] initWithTitle:@""]; + if (menu.numberOfItems > 0) [menu addItem:[NSMenuItem separatorItem]]; + + // Build the icon (lock emoji rendered into a small NSImage for the menu item) + NSString *icon = allLocked ? @"🔓" : @"🔒"; + NSString *verb = allLocked ? @"Unlock" : @"Lock"; + NSString *suffix = lockable.count > 1 ? @" Clips" : @" Clip"; + NSString *title = [NSString stringWithFormat:@"%@ %@%@", icon, verb, suffix]; + + NSMenuItem *item = [[NSMenuItem alloc] initWithTitle:title + action:@selector(toggleLockFromMenu:) + keyEquivalent:@""]; + item.target = [SKClipLockMenuHandler shared]; + item.representedObject = lockable; + + // Show lock state of each clip in a sub-note if mixed + BOOL anyLocked = NO, anyUnlocked = NO; + for (id clip in lockable) { + if (CL_isLockedClipDB(clip)) anyLocked = YES; + else anyUnlocked = YES; + } + if (anyLocked && anyUnlocked) { + item.title = [NSString stringWithFormat:@"🔒 Toggle Lock (%lu clips)", + (unsigned long)lockable.count]; + } + + [menu addItem:item]; + return menu; +} + +// --------------------------------------------------------------------------- +// Swizzle 2 — FFAnchoredTimelineModule.timelineView:shouldMoveItems:... +// --------------------------------------------------------------------------- + +typedef BOOL (*ShouldMoveItemsFn)(id, SEL, id, id, id, id, unsigned long long, CL_CMTime); +static ShouldMoveItemsFn sOrigShouldMoveItems = NULL; + +static BOOL CL_swizzled_shouldMoveItems(id self, SEL _cmd, + id timelineView, + NSArray *items, + id placingItem, + id container, + unsigned long long atIndex, + CL_CMTime atTime) +{ + if (CL_arrayContainsLocked(items)) { + NSBeep(); + SpliceKit_log(@"[ClipLock] Move blocked — selection contains a locked clip"); + return NO; + } + // Also block if the placingItem itself is locked + if (CL_isLockedClip(placingItem)) { + NSBeep(); + SpliceKit_log(@"[ClipLock] Move blocked — placing item is locked"); + return NO; + } + return sOrigShouldMoveItems + ? sOrigShouldMoveItems(self, _cmd, timelineView, items, placingItem, container, atIndex, atTime) + : YES; +} + +// --------------------------------------------------------------------------- +// Swizzle 3 — FFAnchoredTimelineModule._validateTrimAction: +// Controls the trim cursor appearance. Belt-and-suspenders alongside shouldTrimEdge. +// --------------------------------------------------------------------------- + +typedef BOOL (*ValidateTrimFn)(id, SEL, id); +static ValidateTrimFn sOrigValidateTrimAction = NULL; + +static BOOL CL_swizzled_validateTrimAction(id self, SEL _cmd, id action) { + if (CL_selectionContainsLocked(self)) return NO; + return sOrigValidateTrimAction + ? sOrigValidateTrimAction(self, _cmd, action) + : NO; +} + +// --------------------------------------------------------------------------- +// Swizzle 4a — FFAnchoredTimelineModule.timelineView:shouldTrimEdge:trimType:edgeType:ofItems:byTimeOffset:movementType: +// Called for every drag-trim on one or more items. Return NO to block. +// --------------------------------------------------------------------------- + +typedef struct { int64_t v; int32_t ts; uint32_t fl; int64_t ep; } CL_CMTimeOffset; +typedef BOOL (*ShouldTrimEdgeMultiFn)(id, SEL, id, id, int, int, NSArray *, CL_CMTimeOffset *, int); +static ShouldTrimEdgeMultiFn sOrigShouldTrimEdgeMulti = NULL; + +static BOOL CL_swizzled_shouldTrimEdgeMulti(id self, SEL _cmd, + id timelineView, + id trimEdge, + int trimType, + int edgeType, + NSArray *ofItems, + CL_CMTimeOffset *byTimeOffset, + int movementType) +{ + if (CL_arrayContainsLocked(ofItems)) { + NSBeep(); + SpliceKit_log(@"[ClipLock] Trim blocked — items contain a locked clip"); + return NO; + } + return sOrigShouldTrimEdgeMulti + ? sOrigShouldTrimEdgeMulti(self, _cmd, timelineView, trimEdge, trimType, edgeType, ofItems, byTimeOffset, movementType) + : YES; +} + +// --------------------------------------------------------------------------- +// Swizzle 4b — FFAnchoredTimelineModule.timelineView:shouldTrimEdge:trimType:ofItem:byTimeOffset:movementType: +// Single-item variant. +// --------------------------------------------------------------------------- + +typedef BOOL (*ShouldTrimEdgeSingleFn)(id, SEL, id, id, int, id, CL_CMTimeOffset *, int); +static ShouldTrimEdgeSingleFn sOrigShouldTrimEdgeSingle = NULL; + +static BOOL CL_swizzled_shouldTrimEdgeSingle(id self, SEL _cmd, + id timelineView, + id trimEdge, + int trimType, + id ofItem, + CL_CMTimeOffset *byTimeOffset, + int movementType) +{ + if (CL_isLockedClip(ofItem)) { + NSBeep(); + SpliceKit_log(@"[ClipLock] Trim blocked — item is locked"); + return NO; + } + return sOrigShouldTrimEdgeSingle + ? sOrigShouldTrimEdgeSingle(self, _cmd, timelineView, trimEdge, trimType, ofItem, byTimeOffset, movementType) + : YES; +} + +// --------------------------------------------------------------------------- +// Swizzle 4c — FFAnchoredTimelineModule.timelineView:shouldRippleEdge:ofItem: +// Blocks ripple trim on locked clips. +// --------------------------------------------------------------------------- + +typedef BOOL (*ShouldRippleEdgeFn)(id, SEL, id, id, id); +static ShouldRippleEdgeFn sOrigShouldRippleEdge = NULL; + +static BOOL CL_swizzled_shouldRippleEdge(id self, SEL _cmd, id timelineView, id edge, id ofItem) { + // Block if the item itself is locked. + if (CL_isLockedClip(ofItem)) { NSBeep(); return NO; } + // Block if ANY locked clips exist in the timeline: ripple shifts every clip + // to the right of the trim point, which would move locked clips downstream. + if (CL_hasAnyLockedClips()) { + NSBeep(); + SpliceKit_log(@"[ClipLock] Ripple trim blocked — locked clips present in timeline"); + return NO; + } + return sOrigShouldRippleEdge + ? sOrigShouldRippleEdge(self, _cmd, timelineView, edge, ofItem) + : YES; +} + +// --------------------------------------------------------------------------- +// Swizzle 4c2 — FFAnchoredTimelineModule.timelineView:shouldAnchorItems:... +// +// Fired when the user drags a clip UP out of the primary storyline (turning it +// into a connected clip) or drags a connected clip to a new anchor point. +// Block if any item being anchored is locked. +// --------------------------------------------------------------------------- + +typedef BOOL (*ShouldAnchorItemsFn)(id, SEL, id, NSArray *, id, id, id, CL_CMTime); +static ShouldAnchorItemsFn sOrigShouldAnchorItems = NULL; + +static BOOL CL_swizzled_shouldAnchorItems(id self, SEL _cmd, + id timelineView, + NSArray *items, + id container, + id anchoringItem, + id lane, + CL_CMTime atTime) +{ + if (CL_arrayContainsLocked(items)) { + NSBeep(); + SpliceKit_log(@"[ClipLock] Anchor/lift-from-spine blocked — locked clip"); + return NO; + } + return sOrigShouldAnchorItems + ? sOrigShouldAnchorItems(self, _cmd, timelineView, items, container, anchoringItem, lane, atTime) + : YES; +} + +// --------------------------------------------------------------------------- +// Swizzle 4c3 — FFAnchoredTimelineModule.nudgeUp: / nudgeDown: +// +// Keyboard shortcuts ⌥↑/⌥↓ that move a clip to a different lane. +// Block on locked clips. +// --------------------------------------------------------------------------- + +typedef void (*NudgeVerticalFn)(id, SEL, id); +static NudgeVerticalFn sOrigNudgeUp = NULL; +static NudgeVerticalFn sOrigNudgeDown = NULL; + +static void CL_swizzled_nudgeUp(id self, SEL _cmd, id sender) { + if (CL_selectionContainsLocked(self)) { NSBeep(); return; } + if (sOrigNudgeUp) sOrigNudgeUp(self, _cmd, sender); +} +static void CL_swizzled_nudgeDown(id self, SEL _cmd, id sender) { + if (CL_selectionContainsLocked(self)) { NSBeep(); return; } + if (sOrigNudgeDown) sOrigNudgeDown(self, _cmd, sender); +} + +// --------------------------------------------------------------------------- +// Swizzles 4d-4g — Roll tool (timelineView:shouldRollEdge:...) +// +// Roll trim moves the outgoing edge of `ofItem` and the incoming edge of +// `adjacentItem` simultaneously. Block if EITHER clip is locked. +// There are four selector variants (with/without edgeType, with/without offset). +// --------------------------------------------------------------------------- + +typedef BOOL (*ShouldRollEdgeTypeFn)(id, SEL, id, id, int, id, id); +typedef BOOL (*ShouldRollEdgeTypeOffFn)(id, SEL, id, id, int, id, id, CL_CMTimeOffset *); +typedef BOOL (*ShouldRollEdgeFn)(id, SEL, id, id, id, id); +typedef BOOL (*ShouldRollEdgeOffFn)(id, SEL, id, id, id, id, CL_CMTimeOffset *); +typedef BOOL (*ShouldRollRangeFn)(id, SEL, id, id, id, CL_CMTimeOffset *); + +static ShouldRollEdgeTypeFn sOrigShouldRollEdgeType = NULL; +static ShouldRollEdgeTypeOffFn sOrigShouldRollEdgeTypeOff = NULL; +static ShouldRollEdgeFn sOrigShouldRollEdge = NULL; +static ShouldRollEdgeOffFn sOrigShouldRollEdgeOff = NULL; +static ShouldRollRangeFn sOrigShouldRollRange = NULL; + +static BOOL CL_eitherLocked(id item, id adjacent) { + return CL_isLockedClip(item) || CL_isLockedClip(adjacent); +} + +static BOOL CL_swizzled_shouldRollEdgeType(id self, SEL _cmd, id view, id edge, + int edgeType, id ofItem, id adjItem) { + if (CL_eitherLocked(ofItem, adjItem)) { NSBeep(); return NO; } + return sOrigShouldRollEdgeType + ? sOrigShouldRollEdgeType(self, _cmd, view, edge, edgeType, ofItem, adjItem) : YES; +} + +static BOOL CL_swizzled_shouldRollEdgeTypeOff(id self, SEL _cmd, id view, id edge, + int edgeType, id ofItem, id adjItem, + CL_CMTimeOffset *offset) { + if (CL_eitherLocked(ofItem, adjItem)) { NSBeep(); return NO; } + return sOrigShouldRollEdgeTypeOff + ? sOrigShouldRollEdgeTypeOff(self, _cmd, view, edge, edgeType, ofItem, adjItem, offset) : YES; +} + +static BOOL CL_swizzled_shouldRollEdge(id self, SEL _cmd, id view, id edge, + id ofItem, id adjItem) { + if (CL_eitherLocked(ofItem, adjItem)) { NSBeep(); return NO; } + return sOrigShouldRollEdge + ? sOrigShouldRollEdge(self, _cmd, view, edge, ofItem, adjItem) : YES; +} + +static BOOL CL_swizzled_shouldRollEdgeOff(id self, SEL _cmd, id view, id edge, + id ofItem, id adjItem, CL_CMTimeOffset *offset) { + if (CL_eitherLocked(ofItem, adjItem)) { NSBeep(); return NO; } + return sOrigShouldRollEdgeOff + ? sOrigShouldRollEdgeOff(self, _cmd, view, edge, ofItem, adjItem, offset) : YES; +} + +static BOOL CL_swizzled_shouldRollRange(id self, SEL _cmd, id view, id edge, + id rangeItem, CL_CMTimeOffset *toTime) { + if (CL_isLockedClip(rangeItem)) { NSBeep(); return NO; } + return sOrigShouldRollRange + ? sOrigShouldRollRange(self, _cmd, view, edge, rangeItem, toTime) : YES; +} + +// --------------------------------------------------------------------------- +// Helper — are any locked clips in the current timeline? +// +// Fast path: O(1) flag set by CL_updateAnyLockedFlag() on lock/unlock and +// on sequence-load events. +// +// This is intentionally a fast O(1) flag check only. We do NOT do an inline +// scan of allContainedItems here because this function is called from inside +// active FCP operations (delete, trim, move) where reading clip metadata +// (mdLocalValueForKey:) is unsafe and can crash. +// +// sAnyLockedInTimeline is kept accurate by: +// - CL_swizzled_startListeningToSequence: (project load) +// - The 1s startup deferred scan in SpliceKit_installClipLock +// - CL_updateAnyLockedFlag() after every lock/unlock operation +// --------------------------------------------------------------------------- + +static BOOL CL_hasAnyLockedClips(void) { + [sLockStateLock lock]; + BOOL any = sAnyLockedInTimeline; + [sLockStateLock unlock]; + return any; +} + +// --------------------------------------------------------------------------- +// Swizzle 4h — FFAnchoredSequence.operationTrimEdit:endEdits:edgeType:byDelta:trimCommand:... +// +// ALL trim-type operations (trim, roll, slip, slide) eventually reach one of +// these two FFAnchoredSequence methods. `startEdits` and `endEdits` are +// NSArrays of FFAnchoredObject clips (the items whose edges are being changed). +// Block if any of them is locked. +// +// This also catches the keyboard slip/slide nudge path which bypasses the +// TLKTimelineView shouldTrimEdge: delegate chain entirely. +// --------------------------------------------------------------------------- + +// CMTime struct for the byDelta parameter (24 bytes: qiIq) +typedef struct { int64_t v; int32_t ts; uint32_t fl; int64_t ep; } CL_CMTimeDelta; + +// Variant 1 (without trimFlags) +typedef BOOL (*OpTrimEditFn)(id, SEL, id, id, int, CL_CMTimeDelta, int, int, id, id *); +static OpTrimEditFn sOrigOpTrimEdit = NULL; + +static BOOL CL_swizzled_operationTrimEdit(id self, SEL _cmd, + id startEdits, id endEdits, + int edgeType, + CL_CMTimeDelta byDelta, + int trimCommand, + int temporalResolutionMode, + id animationHint, + id *error) +{ + // Case A: a locked clip is directly being trimmed — always block. + if (CL_arrayContainsLocked(startEdits) || CL_arrayContainsLocked(endEdits)) { + NSBeep(); + SpliceKit_log(@"[ClipLock] operationTrimEdit blocked (trimCommand=%d) — locked clip in edits", trimCommand); + return NO; + } + // Case B: one-sided trim (one array empty, one non-empty) = ripple trim. + // Ripple shifts every downstream clip, which would move locked clips. + // Roll/slip/slide are two-sided (both arrays non-empty) and don't shift positions. + NSUInteger startCount = [startEdits count]; + NSUInteger endCount = [endEdits count]; + BOOL isRipple = (startCount == 0) != (endCount == 0); // XOR: exactly one side has items + if (isRipple && CL_hasAnyLockedClips()) { + NSBeep(); + SpliceKit_log(@"[ClipLock] operationTrimEdit blocked (ripple, trimCommand=%d) — locked clips present", trimCommand); + return NO; + } + return sOrigOpTrimEdit + ? sOrigOpTrimEdit(self, _cmd, startEdits, endEdits, edgeType, byDelta, + trimCommand, temporalResolutionMode, animationHint, error) + : NO; +} + +// Variant 2 (with trimFlags) +typedef BOOL (*OpTrimEditFlagsFn)(id, SEL, id, id, int, CL_CMTimeDelta, int, int, int, id, id *); +static OpTrimEditFlagsFn sOrigOpTrimEditFlags = NULL; + +static BOOL CL_swizzled_operationTrimEditFlags(id self, SEL _cmd, + id startEdits, id endEdits, + int edgeType, + CL_CMTimeDelta byDelta, + int trimCommand, + int trimFlags, + int temporalResolutionMode, + id animationHint, + id *error) +{ + if (CL_arrayContainsLocked(startEdits) || CL_arrayContainsLocked(endEdits)) { + NSBeep(); + SpliceKit_log(@"[ClipLock] operationTrimEditFlags blocked (trimCommand=%d) — locked clip in edits", trimCommand); + return NO; + } + NSUInteger startCount = [startEdits count]; + NSUInteger endCount = [endEdits count]; + BOOL isRipple = (startCount == 0) != (endCount == 0); + if (isRipple && CL_hasAnyLockedClips()) { + NSBeep(); + SpliceKit_log(@"[ClipLock] operationTrimEditFlags blocked (ripple, trimCommand=%d) — locked clips present", trimCommand); + return NO; + } + return sOrigOpTrimEditFlags + ? sOrigOpTrimEditFlags(self, _cmd, startEdits, endEdits, edgeType, byDelta, + trimCommand, trimFlags, temporalResolutionMode, animationHint, error) + : NO; +} + +// --------------------------------------------------------------------------- +// Swizzle 4i — FFAnchoredTimelineModule.liftFromSpine: +// Lift from primary storyline detaches the clip into a connected clip. +// Block if any selected clip is locked. +// --------------------------------------------------------------------------- + +typedef void (*LiftFromSpineFn)(id, SEL, id); +static LiftFromSpineFn sOrigLiftFromSpine = NULL; + +static void CL_swizzled_liftFromSpine(id self, SEL _cmd, id sender) { + if (CL_selectionContainsLocked(self)) { + NSBeep(); + SpliceKit_log(@"[ClipLock] Lift from storyline blocked — locked clip selected"); + return; + } + if (sOrigLiftFromSpine) sOrigLiftFromSpine(self, _cmd, sender); +} + +// --------------------------------------------------------------------------- +// Swizzles — Audio level / fade locking +// +// When a clip is locked its audio level and fade handles should be immutable. +// +// Intercept points: +// adjustVolumeByAmount:isRelative:actionName: — core volume change, all +// paths (keyboard ⌃+/⌃-, inspector slider, direct action) funnel here. +// volumeUp: / volumeDown: / volumeZero: / volumeMinusInfinity: — discrete +// menu/keyboard shortcuts. +// fadeHandlesLayer:moveFadeAtIndex:toTime:symmetric:... — drag of audio +// fade handles (in/out fade points on the clip waveform). +// audioComponentSource:moveFadeAtIndex:toTime:... — programmatic fade move. +// --------------------------------------------------------------------------- + +typedef void (*AdjustVolumeFn)(id, SEL, double, BOOL, id); +static AdjustVolumeFn sOrigAdjustVolumeByAmount = NULL; +static void CL_swizzled_adjustVolumeByAmount(id self, SEL _cmd, double amount, + BOOL isRelative, id actionName) { + if (CL_selectionContainsLocked(self)) { NSBeep(); return; } + if (sOrigAdjustVolumeByAmount) + sOrigAdjustVolumeByAmount(self, _cmd, amount, isRelative, actionName); +} + +typedef void (*VolumeSenderFn)(id, SEL, id); +static VolumeSenderFn sOrigVolumeUp = NULL; +static VolumeSenderFn sOrigVolumeDown = NULL; +static VolumeSenderFn sOrigVolumeZero = NULL; +static VolumeSenderFn sOrigVolumeMinusInfinity = NULL; +static VolumeSenderFn sOrigAdjustVolumeAbsolute = NULL; +static VolumeSenderFn sOrigAdjustVolumeRelative = NULL; + +#define MAKE_VOLUME_SWIZZLE(NAME, ORIG) \ +static void CL_swizzled_##NAME(id self, SEL _cmd, id sender) { \ + if (CL_selectionContainsLocked(self)) { NSBeep(); return; } \ + if (ORIG) ORIG(self, _cmd, sender); \ +} +MAKE_VOLUME_SWIZZLE(volumeUp, sOrigVolumeUp) +MAKE_VOLUME_SWIZZLE(volumeDown, sOrigVolumeDown) +MAKE_VOLUME_SWIZZLE(volumeZero, sOrigVolumeZero) +MAKE_VOLUME_SWIZZLE(volumeMinusInfinity, sOrigVolumeMinusInfinity) +MAKE_VOLUME_SWIZZLE(adjustVolumeAbsolute, sOrigAdjustVolumeAbsolute) +MAKE_VOLUME_SWIZZLE(adjustVolumeRelative, sOrigAdjustVolumeRelative) +#undef MAKE_VOLUME_SWIZZLE + +// Fade handle drag — symmetric variant (most common) +typedef BOOL (*MoveFadeSymFn)(id, SEL, id, unsigned long long, CL_CMTimeOffset *, BOOL, id); +static MoveFadeSymFn sOrigMoveFadeSym = NULL; +static BOOL CL_swizzled_moveFadeSym(id self, SEL _cmd, id layer, unsigned long long idx, + CL_CMTimeOffset *toTime, BOOL sym, id aligned) { + if (CL_selectionContainsLocked(self)) { NSBeep(); return NO; } + return sOrigMoveFadeSym + ? sOrigMoveFadeSym(self, _cmd, layer, idx, toTime, sym, aligned) : NO; +} + +// Fade handle drag — audio component source variant +typedef BOOL (*MoveFadeCompFn)(id, SEL, id, unsigned long long, CL_CMTimeOffset *, id); +static MoveFadeCompFn sOrigMoveFadeComp = NULL; +static BOOL CL_swizzled_moveFadeComp(id self, SEL _cmd, id src, unsigned long long idx, + CL_CMTimeOffset *toTime, id aligned) { + if (CL_selectionContainsLocked(self)) { NSBeep(); return NO; } + return sOrigMoveFadeComp + ? sOrigMoveFadeComp(self, _cmd, src, idx, toTime, aligned) : NO; +} + +// --------------------------------------------------------------------------- +// Swizzle 4j — FFAnchoredTimelineModule._startListeningToSequence: +// +// Called every time a project/sequence is loaded into the timeline editor. +// We hook it to refresh the 🔒 label on any locked clips after the project +// opens — FCP's initial layout reads the ivar directly, bypassing our +// displayName getter, so the prefix doesn't appear until a reload is queued. +// --------------------------------------------------------------------------- + +typedef void (*StartListeningFn)(id, SEL, id); +static StartListeningFn sOrigStartListeningToSequence = NULL; + +static void CL_swizzled_startListeningToSequence(id self, SEL _cmd, id sequence) { + if (sOrigStartListeningToSequence) + sOrigStartListeningToSequence(self, _cmd, sequence); + if (!sequence) return; + + // Delay slightly so the timeline view finishes its initial layout pass. + dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(0.3 * NSEC_PER_SEC)), + dispatch_get_main_queue(), ^{ + NSArray *allItems = CL_getSpineItems(sequence); + if (!allItems) return; + + NSMutableArray *locked = [NSMutableArray array]; + for (id item in allItems) { + if (CL_isLockedClipDB(item)) { + [locked addObject:item]; + // Populate the session cache so in-session checks are fast. + CL_cacheAdd(item); + } + } + // Update the quick flag. + [sLockStateLock lock]; + sAnyLockedInTimeline = (locked.count > 0); + [sLockStateLock unlock]; + + if (locked.count) { + SpliceKit_log(@"[ClipLock] Project loaded: refreshing 🔒 on %lu clip(s)", + (unsigned long)locked.count); + CL_refreshClips(locked); + } + }); +} + +// --------------------------------------------------------------------------- +// Helper — returns YES if every item in the current selection is a gap clip. +// Used to exempt gap-only ripple deletes from the locked-clip position guard. +// --------------------------------------------------------------------------- + +static BOOL CL_selectionIsOnlyGaps(id timelineModule) { + SEL sel = NSSelectorFromString(@"selectedItems"); + if (![timelineModule respondsToSelector:sel]) return NO; + NSArray *items = nil; + @try { items = ((id (*)(id, SEL))objc_msgSend)(timelineModule, sel); } + @catch (...) { return NO; } + if (!items.count) return NO; + SEL isGapSel = NSSelectorFromString(@"isGap"); + for (id item in items) { + if (![item respondsToSelector:isGapSel]) return NO; + if (!((BOOL (*)(id, SEL))objc_msgSend)(item, isGapSel)) return NO; + } + return YES; +} + +// --------------------------------------------------------------------------- +// Swizzles — CHChannelDouble.setCurveDoubleValue:atTime:options: +// CHChannel.setCurveValueWithDouble:atTime:options: +// +// The inspector volume slider and the clip rubber-band (in-timeline drag line) +// bypass FFAnchoredTimelineModule entirely and write directly to the clip's +// CHChannelDouble (audio level channel, a CH-framework wrapper around the +// Ozone parameter curve). These two selectors are the actual write paths. +// +// We check the SELECTION rather than navigating the channel→clip parent chain, +// because when the user drags either control the clip is always selected. +// Swizzling on CHChannel (base class) covers both the decibel and double variants. +// --------------------------------------------------------------------------- + +typedef void (*SetCurveDoubleFn)(id, SEL, double, CL_CMTime, unsigned int); +static SetCurveDoubleFn sOrigSetCurveDoubleValue = NULL; +static SetCurveDoubleFn sOrigSetCurveValueWithDouble = NULL; + +static void CL_swizzled_setCurveDoubleValue(id self, SEL _cmd, double value, + CL_CMTime atTime, unsigned int options) { + id tm = SpliceKit_getActiveTimelineModule(); + if (tm && CL_selectionContainsLocked(tm)) { + NSBeep(); + SpliceKit_log(@"[ClipLock] CHChannelDouble write blocked — locked clip selected"); + return; + } + if (sOrigSetCurveDoubleValue) sOrigSetCurveDoubleValue(self, _cmd, value, atTime, options); +} + +static void CL_swizzled_setCurveValueWithDouble(id self, SEL _cmd, double value, + CL_CMTime atTime, unsigned int options) { + id tm = SpliceKit_getActiveTimelineModule(); + if (tm && CL_selectionContainsLocked(tm)) { + NSBeep(); + return; + } + if (sOrigSetCurveValueWithDouble) sOrigSetCurveValueWithDouble(self, _cmd, value, atTime, options); +} + +// --------------------------------------------------------------------------- +// Swizzle — FFAnchoredSequence.actionChangeAudioVolume:byAmount:overRange:isRelative:error: +// +// The sequence-level volume change used by the inspector slider and the clip +// rubber-band volume overlay (the horizontal drag line on the clip waveform). +// `items` is the NSArray of clips whose volume is being changed. +// +// CMTimeRange = two CMTime structs = 48 bytes total (non-HFA on arm64, passed on stack). +// --------------------------------------------------------------------------- + +typedef struct { + int64_t sv; int32_t sts; uint32_t sfl; int64_t sep; // start CMTime + int64_t dv; int32_t dts; uint32_t dfl; int64_t dep; // duration CMTime +} CL_CMTimeRange_48; + +typedef BOOL (*ActionChangeAudioVolFn)(id, SEL, id, double, CL_CMTimeRange_48, BOOL, id *); +static ActionChangeAudioVolFn sOrigActionChangeAudioVol = NULL; + +static BOOL CL_swizzled_actionChangeAudioVol(id self, SEL _cmd, + id items, + double amount, + CL_CMTimeRange_48 overRange, + BOOL isRelative, + id *error) +{ + // `items` is the array of clips being modified. + if (CL_arrayContainsLocked(items)) { + NSBeep(); + SpliceKit_log(@"[ClipLock] Volume change blocked — locked clip in items"); + return NO; + } + return sOrigActionChangeAudioVol + ? sOrigActionChangeAudioVol(self, _cmd, items, amount, overRange, isRelative, error) + : NO; +} + +// --------------------------------------------------------------------------- +// Swizzles — FFAnchoredSequence.operationRemoveEdits:rootItem:preserveTime:... +// +// The actual delete path — _deleteCore: on the timeline module does NOT end +// up calling this (FCP's delete routes through the sequence directly). +// +// preserveTime=NO → ripple delete (downstream clips shift left) +// preserveTime=YES → lift delete (leaves a gap, positions preserved) +// +// When the selection contains a locked clip → block entirely (return NO). +// When ripple AND locked clips exist anywhere in the timeline → force +// preserveTime=YES so locked clips don't move. +// Exception: gap-only selections may still ripple freely. +// +// Two variants: with and without an explicit playhead CMTime parameter. +// --------------------------------------------------------------------------- + +typedef BOOL (*OpRemoveEditsFn)(id, SEL, id, id, BOOL, BOOL, id *); +static OpRemoveEditsFn sOrigOpRemoveEdits = NULL; + +static BOOL CL_swizzled_operationRemoveEdits(id self, SEL _cmd, + id edits, id rootItem, + BOOL preserveTime, BOOL preserveAnchors, + id *error) +{ + // Case A: selected/deleted clips include a locked clip → block + if (CL_arrayContainsLocked(edits)) { + NSBeep(); + SpliceKit_log(@"[ClipLock] Delete blocked — edits contain a locked clip"); + return NO; + } + // Also check active selection for locked clips + id tm = SpliceKit_getActiveTimelineModule(); + if (tm && CL_selectionContainsLocked(tm)) { + NSBeep(); + SpliceKit_log(@"[ClipLock] Delete blocked — selection contains a locked clip"); + return NO; + } + // Case B: ripple delete + locked clips in timeline → lift instead + if (!preserveTime && CL_hasAnyLockedClips()) { + if (!CL_selectionIsOnlyGaps(tm)) { + SpliceKit_log(@"[ClipLock] Ripple delete → lift delete (locked clips in timeline)"); + preserveTime = YES; + } + } + return sOrigOpRemoveEdits + ? sOrigOpRemoveEdits(self, _cmd, edits, rootItem, preserveTime, preserveAnchors, error) + : NO; +} + +// Variant with explicit playhead CMTime +// NOTE: operationRemoveEdits:...playhead:... swizzle removed. +// The playhead parameter is ^{?=qiIq} (pointer to CMTime), and swizzling this +// method during an active delete transaction crashes FCP (null ptr deref at FAR=0). +// Delete-blocking is handled at the delete: action level instead. + +// Variant with range +typedef BOOL (*OpRemoveEditsRangeFn)(id, SEL, id, id, int, BOOL, BOOL, BOOL, id *); +static OpRemoveEditsRangeFn sOrigOpRemoveEditsRange = NULL; + +static BOOL CL_swizzled_operationRemoveEditsRange(id self, SEL _cmd, + id ranges, id rootItem, + int editMode, + BOOL preserveTime, BOOL overwritePrep, + BOOL preservePartialAudio, id *error) +{ + id tm = SpliceKit_getActiveTimelineModule(); + if (tm && CL_selectionContainsLocked(tm)) { NSBeep(); return NO; } + if (!preserveTime && CL_hasAnyLockedClips()) { + if (!CL_selectionIsOnlyGaps(tm)) { + SpliceKit_log(@"[ClipLock] Ripple delete (range) → lift delete"); + preserveTime = YES; + } + } + return sOrigOpRemoveEditsRange + ? sOrigOpRemoveEditsRange(self, _cmd, ranges, rootItem, editMode, preserveTime, overwritePrep, preservePartialAudio, error) + : NO; +} + +// --------------------------------------------------------------------------- +// Swizzles — FFAnchoredSequence.actionRemoveEdits:replaceWithGap:... +// and actionRemoveEditsForRanges:replaceWithGap:... +// +// THIS is the method `delete:` on FFAnchoredTimelineModule actually calls. +// `_deleteCore` is never reached in practice. +// +// replaceWithGap=NO → ripple delete (shifts downstream clips) +// replaceWithGap=YES → lift delete (leaves a gap, positions preserved) +// +// Logic mirrors operationRemoveEdits: +// A) edits or selection contains a locked clip → block entirely +// B) ripple delete while any locked clip exists → force replaceWithGap=YES +// --------------------------------------------------------------------------- + +typedef BOOL (*ActRemoveEditsFn)(id, SEL, id, BOOL, int, id, id *); +static ActRemoveEditsFn sOrigActRemoveEdits = NULL; + +static BOOL CL_swizzled_actionRemoveEdits(id self, SEL _cmd, + id edits, BOOL replaceWithGap, + int removeOperation, + id rootItem, id *error) +{ + if (CL_arrayContainsLocked(edits)) { + NSBeep(); + SpliceKit_log(@"[ClipLock] actionRemoveEdits blocked — locked clip in edits"); + return NO; + } + id tm = SpliceKit_getActiveTimelineModule(); + if (tm && CL_selectionContainsLocked(tm)) { + NSBeep(); + SpliceKit_log(@"[ClipLock] actionRemoveEdits blocked — selection contains a locked clip"); + return NO; + } + if (!replaceWithGap && CL_hasAnyLockedClips()) { + if (tm && !CL_selectionIsOnlyGaps(tm)) { + SpliceKit_log(@"[ClipLock] actionRemoveEdits: ripple → lift (locked clips in timeline)"); + replaceWithGap = YES; + } + } + return sOrigActRemoveEdits + ? sOrigActRemoveEdits(self, _cmd, edits, replaceWithGap, removeOperation, rootItem, error) + : NO; +} + +typedef BOOL (*ActRemoveEditsPhFn)(id, SEL, id, BOOL, int, id, CL_CMTime *, id *); +static ActRemoveEditsPhFn sOrigActRemoveEditsPh = NULL; + +static BOOL CL_swizzled_actionRemoveEditsPh(id self, SEL _cmd, + id edits, BOOL replaceWithGap, + int removeOperation, + id rootItem, CL_CMTime *playhead, id *error) +{ + if (CL_arrayContainsLocked(edits)) { NSBeep(); return NO; } + id tm = SpliceKit_getActiveTimelineModule(); + if (tm && CL_selectionContainsLocked(tm)) { NSBeep(); return NO; } + if (!replaceWithGap && CL_hasAnyLockedClips()) { + if (tm && !CL_selectionIsOnlyGaps(tm)) { + SpliceKit_log(@"[ClipLock] actionRemoveEdits+ph: ripple → lift"); + replaceWithGap = YES; + } + } + return sOrigActRemoveEditsPh + ? sOrigActRemoveEditsPh(self, _cmd, edits, replaceWithGap, removeOperation, rootItem, playhead, error) + : NO; +} + +typedef BOOL (*ActRemoveRangesFn)(id, SEL, id, BOOL, int, id, id *); +static ActRemoveRangesFn sOrigActRemoveRanges = NULL; + +static BOOL CL_swizzled_actionRemoveEditsForRanges(id self, SEL _cmd, + id ranges, BOOL replaceWithGap, + int removeOperation, + id rootItem, id *error) +{ + id tm = SpliceKit_getActiveTimelineModule(); + if (tm && CL_selectionContainsLocked(tm)) { + NSBeep(); + SpliceKit_log(@"[ClipLock] actionRemoveEditsForRanges blocked — locked clip selected"); + return NO; + } + if (!replaceWithGap && CL_hasAnyLockedClips()) { + if (tm && !CL_selectionIsOnlyGaps(tm)) { + SpliceKit_log(@"[ClipLock] actionRemoveEditsForRanges: ripple → lift"); + replaceWithGap = YES; + } + } + return sOrigActRemoveRanges + ? sOrigActRemoveRanges(self, _cmd, ranges, replaceWithGap, removeOperation, rootItem, error) + : NO; +} + +typedef BOOL (*ActRemoveRangesPhFn)(id, SEL, id, BOOL, int, id, CL_CMTime *, id *); +static ActRemoveRangesPhFn sOrigActRemoveRangesPh = NULL; + +static BOOL CL_swizzled_actionRemoveEditsForRangesPh(id self, SEL _cmd, + id ranges, BOOL replaceWithGap, + int removeOperation, + id rootItem, CL_CMTime *playhead, id *error) +{ + id tm = SpliceKit_getActiveTimelineModule(); + if (tm && CL_selectionContainsLocked(tm)) { NSBeep(); return NO; } + if (!replaceWithGap && CL_hasAnyLockedClips()) { + if (tm && !CL_selectionIsOnlyGaps(tm)) { + SpliceKit_log(@"[ClipLock] actionRemoveEditsForRanges+ph: ripple → lift"); + replaceWithGap = YES; + } + } + return sOrigActRemoveRangesPh + ? sOrigActRemoveRangesPh(self, _cmd, ranges, replaceWithGap, removeOperation, rootItem, playhead, error) + : NO; +} + +// --------------------------------------------------------------------------- +// Swizzle 5 — FFAnchoredTimelineModule.delete: +// +// Intercepts the Delete key action BEFORE _deleteCore is entered. +// This is safe: no clip objects are in an active operation state yet. +// +// Behaviour: +// A) Selected clips include a locked clip → block entirely (beep, return). +// B) Ripple delete while locked clips exist → call shiftDelete: instead, +// which replaces the selection with a gap (FCP's own gap-delete path). +// Gap-only selections are exempted so the user can freely collapse gaps. +// --------------------------------------------------------------------------- + +typedef void (*DeleteFn)(id, SEL, id); +static DeleteFn sOrigDelete = NULL; +static _Atomic(BOOL) sCLDeleteReentrancy = NO; + +static void CL_swizzled_delete(id self, SEL _cmd, id sender) { + if (sCLDeleteReentrancy) { + if (sOrigDelete) sOrigDelete(self, _cmd, sender); + return; + } + if (CL_selectionContainsLocked(self)) { + NSBeep(); + SpliceKit_log(@"[ClipLock] Delete blocked — selection contains a locked clip"); + return; + } + if (CL_hasAnyLockedClips() && !CL_selectionIsOnlyGaps(self)) { + SpliceKit_log(@"[ClipLock] Delete → shiftDelete (locked clips in timeline)"); + sCLDeleteReentrancy = YES; + SEL shiftSel = NSSelectorFromString(@"shiftDelete:"); + if ([self respondsToSelector:shiftSel]) + ((void (*)(id, SEL, id))objc_msgSend)(self, shiftSel, sender); + else if (sOrigDelete) + sOrigDelete(self, _cmd, sender); + sCLDeleteReentrancy = NO; + return; + } + if (sOrigDelete) sOrigDelete(self, _cmd, sender); +} + +// --------------------------------------------------------------------------- +// Swizzle 6 — FFAnchoredTimelineModule.validateUserInterfaceItem:withSelectedItems: +// Grays out Delete / Cut menu items when locked clips are selected. +// (Does NOT block keyboard shortcuts — _deleteCore: handles that.) +// --------------------------------------------------------------------------- + +typedef BOOL (*ValidateUIFn)(id, SEL, id, NSArray *); +static ValidateUIFn sOrigValidateUI = NULL; + +static BOOL CL_swizzled_validateUI(id self, SEL _cmd, id uiItem, NSArray *selectedItems) { + BOOL orig = sOrigValidateUI + ? sOrigValidateUI(self, _cmd, uiItem, selectedItems) + : NO; + if (!orig) return NO; + if (!CL_arrayContainsLocked(selectedItems)) return orig; + + SEL action = NULL; + @try { action = [uiItem action]; } + @catch (...) { return orig; } + if (!action) return orig; + + if ([CL_destructiveActionNames() containsObject:NSStringFromSelector(action)]) return NO; + return orig; +} + +// --------------------------------------------------------------------------- +// RPC methods +// --------------------------------------------------------------------------- + +static id CL_clipAtPlayheadFromTM(id tm) { + SEL sel = NSSelectorFromString(@"selectedItems"); + if (![tm respondsToSelector:sel]) return nil; + @try { + NSArray *items = ((id (*)(id, SEL))objc_msgSend)(tm, sel); + return items.firstObject; + } @catch (...) { return nil; } +} + +static void CL_registerRPC(void) { + // clips.lock + SpliceKit_registerPluginMethod(@"clips.lock", ^NSDictionary *(NSDictionary *params) { + __block NSString *result = nil; + SpliceKit_executeOnMainThread(^{ + id tm = SpliceKit_getActiveTimelineModule(); + NSString *handle = params[@"handle"]; + id clip = handle ? SpliceKit_resolveHandle(handle) : CL_clipAtPlayheadFromTM(tm); + if (!clip) { result = @"No clip found"; return; } + CL_setClipLocked(clip, YES); + if (tm) CL_updateAnyLockedFlag(tm); + CL_refreshClips(@[clip]); + result = @"Locked"; + }); + return @{@"result": result ?: @"ok"}; + }, @{@"description": @"Lock the selected clip (or clip by handle) to prevent moving or editing."}); + + // clips.unlock + SpliceKit_registerPluginMethod(@"clips.unlock", ^NSDictionary *(NSDictionary *params) { + __block NSString *result = nil; + SpliceKit_executeOnMainThread(^{ + id tm = SpliceKit_getActiveTimelineModule(); + NSString *handle = params[@"handle"]; + id clip = handle ? SpliceKit_resolveHandle(handle) : CL_clipAtPlayheadFromTM(tm); + if (!clip) { result = @"No clip found"; return; } + CL_setClipLocked(clip, NO); + if (tm) CL_updateAnyLockedFlag(tm); + CL_refreshClips(@[clip]); + result = @"Unlocked"; + }); + return @{@"result": result ?: @"ok"}; + }, @{@"description": @"Unlock the selected clip (or clip by handle)."}); + + // clips.toggleLock + SpliceKit_registerPluginMethod(@"clips.toggleLock", ^NSDictionary *(NSDictionary *params) { + __block NSDictionary *resp = nil; + SpliceKit_executeOnMainThread(^{ + id tm = SpliceKit_getActiveTimelineModule(); + NSString *handle = params[@"handle"]; + id clip = handle ? SpliceKit_resolveHandle(handle) : CL_clipAtPlayheadFromTM(tm); + if (!clip) { resp = @{@"error": @"No clip found"}; return; } + BOOL nowLocked = !CL_isLockedClipDB(clip); + CL_setClipLocked(clip, nowLocked); + if (tm) CL_updateAnyLockedFlag(tm); + CL_refreshClips(@[clip]); + resp = @{@"locked": @(nowLocked)}; + }); + return resp ?: @{@"error": @"unknown error"}; + }, @{@"description": @"Toggle lock on the selected clip (or clip by handle)."}); + + // clips.isLocked + SpliceKit_registerPluginMethod(@"clips.isLocked", ^NSDictionary *(NSDictionary *params) { + __block NSDictionary *resp = nil; + SpliceKit_executeOnMainThread(^{ + id tm = SpliceKit_getActiveTimelineModule(); + NSString *handle = params[@"handle"]; + id clip = handle ? SpliceKit_resolveHandle(handle) : CL_clipAtPlayheadFromTM(tm); + if (!clip) { resp = @{@"error": @"No clip found"}; return; } + resp = @{@"locked": @(CL_isLockedClipDB(clip))}; + }); + return resp ?: @{@"error": @"unknown error"}; + }, @{@"description": @"Return whether the selected clip (or clip by handle) is locked."}); + + // clips.listLocked — scans the current sequence + SpliceKit_registerPluginMethod(@"clips.listLocked", ^NSDictionary *(NSDictionary *params) { + __block NSDictionary *resp = nil; + SpliceKit_executeOnMainThread(^{ + id tm = SpliceKit_getActiveTimelineModule(); + SEL seqSel = NSSelectorFromString(@"sequence"); + id seq = (tm && [tm respondsToSelector:seqSel]) + ? ((id (*)(id, SEL))objc_msgSend)(tm, seqSel) : nil; + NSArray *items = CL_getSpineItems(seq); + NSMutableArray *locked = [NSMutableArray array]; + for (id item in items) { + if (CL_isLockedClipDB(item)) { + NSString *name = ((id (*)(id, SEL))objc_msgSend)(item, @selector(displayName)); + [locked addObject:name ?: @"?"]; + } + } + resp = @{@"lockedClips": locked, @"count": @(locked.count)}; + }); + return resp ?: @{@"error": @"no timeline"}; + }, @{@"description": @"Return all locked clips in the current sequence."}); + + // clips.unlockAll + SpliceKit_registerPluginMethod(@"clips.unlockAll", ^NSDictionary *(NSDictionary *params) { + __block NSUInteger count = 0; + SpliceKit_executeOnMainThread(^{ + id tm = SpliceKit_getActiveTimelineModule(); + SEL seqSel = NSSelectorFromString(@"sequence"); + id seq = (tm && [tm respondsToSelector:seqSel]) + ? ((id (*)(id, SEL))objc_msgSend)(tm, seqSel) : nil; + if (!seq) return; + NSArray *items = CL_getSpineItems(seq); + NSMutableArray *changed = [NSMutableArray array]; + for (id item in items) { + if (CL_isLockedClipDB(item)) { + CL_setClipLocked(item, NO); + [changed addObject:item]; + count++; + } + } + [sLockStateLock lock]; sAnyLockedInTimeline = NO; [sLockStateLock unlock]; + if (changed.count) CL_refreshClips(changed); + }); + SpliceKit_log(@"[ClipLock] Unlocked all — %lu clips", (unsigned long)count); + return @{@"result": @"ok", @"unlockedCount": @(count)}; + }, @{@"description": @"Unlock all locked clips in the current sequence."}); +} + +// --------------------------------------------------------------------------- +// Keyboard shortcut — ⌥L (LKCommand "SKLockClip") +// --------------------------------------------------------------------------- + +static IMP sOrigLoadCommands_CL = NULL; +static IMP sOrigSetActiveCommandSet_CL = NULL; + +static void CL_registerLKCommand(void) { + Class cmdClass = objc_getClass("LKCommand"); + Class ctrlClass = objc_getClass("LKCommandsController"); + if (!cmdClass || !ctrlClass) { + SpliceKit_log(@"[ClipLock] LKCommand/LKCommandsController not found, skipping ⌥L binding"); + return; + } + + id ctrl = ((id (*)(Class, SEL))objc_msgSend)(ctrlClass, NSSelectorFromString(@"sharedController")); + if (!ctrl) { + SpliceKit_log(@"[ClipLock] LKCommandsController.sharedController nil, skipping ⌥L binding"); + return; + } + + // Create the LKCommand: identifier "SKLockClip", action "SKToggleLockClipAction:" + SEL initSel = NSSelectorFromString(@"initWithCommandIdentifier:action:"); + id cmd = [cmdClass alloc]; + @try { + cmd = ((id (*)(id, SEL, NSString *, SEL))objc_msgSend)( + cmd, initSel, + @"SKLockClip", + NSSelectorFromString(@"SKToggleLockClipAction:") + ); + } @catch (NSException *e) { + SpliceKit_log(@"[ClipLock] LKCommand init failed: %@", e); + return; + } + if (!cmd) { + SpliceKit_log(@"[ClipLock] LKCommand alloc/init returned nil"); + return; + } + + SEL registerSel = NSSelectorFromString(@"registerCommand:"); + if ([ctrl respondsToSelector:registerSel]) { + @try { ((void (*)(id, SEL, id))objc_msgSend)(ctrl, registerSel, cmd); } + @catch (NSException *e) { SpliceKit_log(@"[ClipLock] registerCommand: threw %@", e); } + } + + // Bind ⌥L + SEL bindSel = NSSelectorFromString(@"bindCommandWithIdentifier:toKeyEquivalent:modifiers:"); + if ([ctrl respondsToSelector:bindSel]) { + @try { + ((void (*)(id, SEL, NSString *, NSString *, NSUInteger))objc_msgSend)( + ctrl, bindSel, + @"SKLockClip", + @"l", + NSEventModifierFlagOption + ); + SpliceKit_log(@"[ClipLock] Bound ⌥L → SKLockClip"); + } @catch (NSException *e) { + SpliceKit_log(@"[ClipLock] Key binding failed: %@", e); + } + } +} + +// The LKCommand fires "SKToggleLockClipAction:" down the responder chain. +// We add it to NSApplication so it always reaches us. +static void CL_installActionHandler(void) { + // Swizzle NSApplication to catch SKToggleLockClipAction: + // We add the method dynamically if it doesn't exist yet. + Class appClass = [NSApplication class]; + SEL actionSel = NSSelectorFromString(@"SKToggleLockClipAction:"); + + if (![appClass instancesRespondToSelector:actionSel]) { + IMP impl = imp_implementationWithBlock(^(id appSelf, id sender) { + id tm = SpliceKit_getActiveTimelineModule(); + if (!tm) { NSBeep(); return; } + SEL itemsSel = NSSelectorFromString(@"selectedItems"); + if (![tm respondsToSelector:itemsSel]) { NSBeep(); return; } + NSArray *items = nil; + @try { items = ((id (*)(id, SEL))objc_msgSend)(tm, itemsSel); } + @catch (...) { NSBeep(); return; } + + NSUInteger locked = 0, unlocked = 0; + for (id clip in items) { + if (CL_isLockedClipDB(clip)) locked++; + else unlocked++; + } + + // If any are unlocked, lock all. If all are locked, unlock all. + BOOL shouldLock = unlocked > 0; + NSMutableArray *changed = [NSMutableArray array]; + for (id clip in items) { + SEL isGapSel = NSSelectorFromString(@"isGap"); + if ([clip respondsToSelector:isGapSel]) { + @try { if (((BOOL (*)(id, SEL))objc_msgSend)(clip, isGapSel)) continue; } + @catch (...) {} + } + CL_setClipLocked(clip, shouldLock); + [changed addObject:clip]; + } + id lockTM = SpliceKit_getActiveTimelineModule(); + if (lockTM) CL_updateAnyLockedFlag(lockTM); + SpliceKit_log(@"[ClipLock] ⌥L → %@ %lu clip(s)", + shouldLock ? @"Locked" : @"Unlocked", + (unsigned long)changed.count); + CL_refreshClips(changed); + }); + class_addMethod(appClass, actionSel, impl, "v@:@"); + } +} + +// Re-register on future _loadCommands / _setActiveCommandSet: calls. +static void CL_swizzled_loadCommands(id self, SEL _cmd) { + if (sOrigLoadCommands_CL) ((void (*)(id, SEL))sOrigLoadCommands_CL)(self, _cmd); + CL_registerLKCommand(); +} +static void CL_swizzled_setActiveCommandSet(id self, SEL _cmd, id set) { + if (sOrigSetActiveCommandSet_CL) ((void (*)(id, SEL, id))sOrigSetActiveCommandSet_CL)(self, _cmd, set); + CL_registerLKCommand(); +} + +// --------------------------------------------------------------------------- +// Public install entry point +// --------------------------------------------------------------------------- + +void SpliceKit_installClipLock(void) { + CL_ensureState(); + SpliceKit_log(@"[ClipLock] Installing (metadata-based persistence)"); + + Class anchoredObj = objc_getClass("FFAnchoredObject"); + if (anchoredObj) { + Method dn = class_getInstanceMethod(anchoredObj, @selector(displayName)); + if (dn) { + sOrigDisplayName = method_getImplementation(dn); + method_setImplementation(dn, (IMP)CL_swizzled_displayName); + SpliceKit_log(@"[ClipLock] Swizzled FFAnchoredObject.displayName"); + } else { + SpliceKit_log(@"[ClipLock] displayName not found on FFAnchoredObject"); + } + } + + // Hoist atm/seqClass so they're available outside the diagnostic if(NO) block. + Class atm = objc_getClass("FFAnchoredTimelineModule"); + Class seqClass = objc_getClass("FFAnchoredSequence"); + (void)seqClass; // suppress unused warning when block is disabled + + + // --- Swizzle 1: TLKTimelineView.menuForEvent: --- + Class tlkView = objc_getClass("TLKTimelineView"); + if (tlkView) { + Method m = class_getInstanceMethod(tlkView, @selector(menuForEvent:)); + if (m) { + sOrigMenuForEvent_CL = method_getImplementation(m); + method_setImplementation(m, (IMP)CL_swizzled_menuForEvent); + SpliceKit_log(@"[ClipLock] Swizzled TLKTimelineView.menuForEvent:"); + } else { + SpliceKit_log(@"[ClipLock] menuForEvent: not found on TLKTimelineView"); + } + } else { + SpliceKit_log(@"[ClipLock] TLKTimelineView not found"); + } + + // --- Swizzle 2: FFAnchoredTimelineModule.timelineView:shouldMoveItems:... --- + if (atm) { + SEL shouldMoveSel = NSSelectorFromString( + @"timelineView:shouldMoveItems:byPlacingItem:inContainer:atIndex:atTime:"); + Method m = class_getInstanceMethod(atm, shouldMoveSel); + if (m) { + sOrigShouldMoveItems = (ShouldMoveItemsFn)method_getImplementation(m); + method_setImplementation(m, (IMP)CL_swizzled_shouldMoveItems); + SpliceKit_log(@"[ClipLock] Swizzled shouldMoveItems:byPlacingItem:..."); + } else { + SpliceKit_log(@"[ClipLock] shouldMoveItems not found on FFAnchoredTimelineModule"); + } + + // --- Swizzle 3: _validateTrimAction: (cursor state) --- + SEL validateTrimSel = NSSelectorFromString(@"_validateTrimAction:"); + Method mt = class_getInstanceMethod(atm, validateTrimSel); + if (mt) { + sOrigValidateTrimAction = (ValidateTrimFn)method_getImplementation(mt); + method_setImplementation(mt, (IMP)CL_swizzled_validateTrimAction); + SpliceKit_log(@"[ClipLock] Swizzled _validateTrimAction:"); + } else { + SpliceKit_log(@"[ClipLock] _validateTrimAction: not found"); + } + + // --- Swizzle 4a: timelineView:shouldTrimEdge:trimType:edgeType:ofItems:... (multi) --- + SEL shouldTrimMultiSel = NSSelectorFromString( + @"timelineView:shouldTrimEdge:trimType:edgeType:ofItems:byTimeOffset:movementType:"); + Method mm = class_getInstanceMethod(atm, shouldTrimMultiSel); + if (mm) { + sOrigShouldTrimEdgeMulti = (ShouldTrimEdgeMultiFn)method_getImplementation(mm); + method_setImplementation(mm, (IMP)CL_swizzled_shouldTrimEdgeMulti); + SpliceKit_log(@"[ClipLock] Swizzled shouldTrimEdge:...ofItems:..."); + } else { + SpliceKit_log(@"[ClipLock] shouldTrimEdge:...ofItems:... not found"); + } + + // --- Swizzle 4b: timelineView:shouldTrimEdge:trimType:ofItem:... (single) --- + SEL shouldTrimSingleSel = NSSelectorFromString( + @"timelineView:shouldTrimEdge:trimType:ofItem:byTimeOffset:movementType:"); + Method ms = class_getInstanceMethod(atm, shouldTrimSingleSel); + if (ms) { + sOrigShouldTrimEdgeSingle = (ShouldTrimEdgeSingleFn)method_getImplementation(ms); + method_setImplementation(ms, (IMP)CL_swizzled_shouldTrimEdgeSingle); + SpliceKit_log(@"[ClipLock] Swizzled shouldTrimEdge:...ofItem:..."); + } else { + SpliceKit_log(@"[ClipLock] shouldTrimEdge:...ofItem:... not found"); + } + + // --- Swizzle 4c2: timelineView:shouldAnchorItems:... (drag-up / lift-from-spine) --- + SEL shouldAnchorSel = NSSelectorFromString( + @"timelineView:shouldAnchorItems:inContainer:byAnchoringItem:inLane:atTime:"); + Method ma2 = class_getInstanceMethod(atm, shouldAnchorSel); + if (ma2) { + sOrigShouldAnchorItems = (ShouldAnchorItemsFn)method_getImplementation(ma2); + method_setImplementation(ma2, (IMP)CL_swizzled_shouldAnchorItems); + SpliceKit_log(@"[ClipLock] Swizzled shouldAnchorItems:..."); + } else { + SpliceKit_log(@"[ClipLock] shouldAnchorItems:... not found"); + } + + // --- Swizzle 4c3: nudgeUp: / nudgeDown: --- + SEL nudgeUpSel = NSSelectorFromString(@"nudgeUp:"); + Method mnu = class_getInstanceMethod(atm, nudgeUpSel); + if (mnu) { + sOrigNudgeUp = (NudgeVerticalFn)method_getImplementation(mnu); + method_setImplementation(mnu, (IMP)CL_swizzled_nudgeUp); + SpliceKit_log(@"[ClipLock] Swizzled nudgeUp:"); + } + SEL nudgeDownSel = NSSelectorFromString(@"nudgeDown:"); + Method mnd = class_getInstanceMethod(atm, nudgeDownSel); + if (mnd) { + sOrigNudgeDown = (NudgeVerticalFn)method_getImplementation(mnd); + method_setImplementation(mnd, (IMP)CL_swizzled_nudgeDown); + SpliceKit_log(@"[ClipLock] Swizzled nudgeDown:"); + } + + struct { const char *sel; IMP imp; IMP *orig; } audioSwizzles[] = { + { "adjustVolumeByAmount:isRelative:actionName:", + (IMP)CL_swizzled_adjustVolumeByAmount, (IMP *)&sOrigAdjustVolumeByAmount }, + { "volumeUp:", + (IMP)CL_swizzled_volumeUp, (IMP *)&sOrigVolumeUp }, + { "volumeDown:", + (IMP)CL_swizzled_volumeDown, (IMP *)&sOrigVolumeDown }, + { "volumeZero:", + (IMP)CL_swizzled_volumeZero, (IMP *)&sOrigVolumeZero }, + { "volumeMinusInfinity:", + (IMP)CL_swizzled_volumeMinusInfinity, (IMP *)&sOrigVolumeMinusInfinity }, + { "fadeHandlesLayer:moveFadeAtIndex:toTime:symmetric:componentSourcesFadeAligned:", + (IMP)CL_swizzled_moveFadeSym, (IMP *)&sOrigMoveFadeSym }, + { "audioComponentSource:moveFadeAtIndex:toTime:componentSourcesFadeAligned:", + (IMP)CL_swizzled_moveFadeComp, (IMP *)&sOrigMoveFadeComp }, + { "adjustVolumeAbsolute:", + (IMP)CL_swizzled_adjustVolumeAbsolute, (IMP *)&sOrigAdjustVolumeAbsolute }, + { "adjustVolumeRelative:", + (IMP)CL_swizzled_adjustVolumeRelative, (IMP *)&sOrigAdjustVolumeRelative }, + }; + for (size_t i = 0; i < sizeof(audioSwizzles)/sizeof(audioSwizzles[0]); i++) { + SEL s = NSSelectorFromString(@(audioSwizzles[i].sel)); + IMP orig = SpliceKit_swizzleMethod(atm, s, audioSwizzles[i].imp); + if (orig) { + *audioSwizzles[i].orig = orig; + SpliceKit_log(@"[ClipLock] Swizzled %s", audioSwizzles[i].sel); + } else { + SpliceKit_log(@"[ClipLock] Not found: %s", audioSwizzles[i].sel); + } + } + + // NOTE: shouldRippleEdge:ofItem: disabled — returning NO mid-delete crashes FCP. + // Ripple-delete blocking is handled at delete: level instead. + + // --- Swizzles 4d-4g: Roll tool --- + struct { const char *sel; IMP imp; IMP *orig; } rollSwizzles[] = { + { "timelineView:shouldRollEdge:edgeType:ofItem:adjacentItem:", + (IMP)CL_swizzled_shouldRollEdgeType, (IMP *)&sOrigShouldRollEdgeType }, + { "timelineView:shouldRollEdge:edgeType:ofItem:adjacentItem:byTimeOffset:", + (IMP)CL_swizzled_shouldRollEdgeTypeOff, (IMP *)&sOrigShouldRollEdgeTypeOff }, + { "timelineView:shouldRollEdge:ofItem:adjacentItem:", + (IMP)CL_swizzled_shouldRollEdge, (IMP *)&sOrigShouldRollEdge }, + { "timelineView:shouldRollEdge:ofItem:adjacentItem:byTimeOffset:", + (IMP)CL_swizzled_shouldRollEdgeOff, (IMP *)&sOrigShouldRollEdgeOff }, + { "timelineView:shouldRollEdge:ofRangeItem:toTime:", + (IMP)CL_swizzled_shouldRollRange, (IMP *)&sOrigShouldRollRange }, + }; + for (size_t i = 0; i < sizeof(rollSwizzles)/sizeof(rollSwizzles[0]); i++) { + SEL s = NSSelectorFromString(@(rollSwizzles[i].sel)); + Method m2 = class_getInstanceMethod(atm, s); + if (m2) { + *rollSwizzles[i].orig = method_getImplementation(m2); + method_setImplementation(m2, rollSwizzles[i].imp); + SpliceKit_log(@"[ClipLock] Swizzled %s", rollSwizzles[i].sel); + } + } + + // --- Swizzle 4j: _startListeningToSequence: (refresh labels on project load) --- + SEL startListenSel = NSSelectorFromString(@"_startListeningToSequence:"); + Method msl = class_getInstanceMethod(atm, startListenSel); + if (msl) { + sOrigStartListeningToSequence = (StartListeningFn)method_getImplementation(msl); + method_setImplementation(msl, (IMP)CL_swizzled_startListeningToSequence); + SpliceKit_log(@"[ClipLock] Swizzled _startListeningToSequence:"); + } else { + SpliceKit_log(@"[ClipLock] _startListeningToSequence: not found"); + } + + // --- Swizzle 4i: liftFromSpine: --- + SEL liftSel = NSSelectorFromString(@"liftFromSpine:"); + Method ml2 = class_getInstanceMethod(atm, liftSel); + if (ml2) { + sOrigLiftFromSpine = (LiftFromSpineFn)method_getImplementation(ml2); + method_setImplementation(ml2, (IMP)CL_swizzled_liftFromSpine); + SpliceKit_log(@"[ClipLock] Swizzled liftFromSpine:"); + } else { + SpliceKit_log(@"[ClipLock] liftFromSpine: not found"); + } + } + + // --- Swizzles 4h: FFAnchoredSequence.operationTrimEdit:... (slip + slide core) --- + if (seqClass) { + SEL opTrimSel = NSSelectorFromString( + @"operationTrimEdit:endEdits:edgeType:byDelta:trimCommand:temporalResolutionMode:animationHint:error:"); + Method mot = class_getInstanceMethod(seqClass, opTrimSel); + if (mot) { + sOrigOpTrimEdit = (OpTrimEditFn)method_getImplementation(mot); + method_setImplementation(mot, (IMP)CL_swizzled_operationTrimEdit); + SpliceKit_log(@"[ClipLock] Swizzled FFAnchoredSequence.operationTrimEdit:..."); + } else { + SpliceKit_log(@"[ClipLock] operationTrimEdit:... not found on FFAnchoredSequence"); + } + SEL opTrimFlagsSel = NSSelectorFromString( + @"operationTrimEdit:endEdits:edgeType:byDelta:trimCommand:trimFlags:temporalResolutionMode:animationHint:error:"); + Method motf = class_getInstanceMethod(seqClass, opTrimFlagsSel); + if (motf) { + sOrigOpTrimEditFlags = (OpTrimEditFlagsFn)method_getImplementation(motf); + method_setImplementation(motf, (IMP)CL_swizzled_operationTrimEditFlags); + SpliceKit_log(@"[ClipLock] Swizzled FFAnchoredSequence.operationTrimEdit:...trimFlags:..."); + } else { + SpliceKit_log(@"[ClipLock] operationTrimEdit:...trimFlags:... not found on FFAnchoredSequence"); + } + + // NOTE: actionChangeAudioVolume swizzle disabled pending crash investigation. + + // NOTE: operationRemoveEdits / actionRemoveEdits swizzles intentionally omitted. + // Metadata reads inside an active FCP delete transaction crash FCP. + // _deleteCore:replaceWithGap:removeOperation: is the safe intercept point. + + // NOTE: actionRemoveEdits:replaceWithGap:... swizzles are intentionally NOT installed. + // Reading mdLocalValueForKey: on clip objects during an active FCP delete operation + // crashes. _deleteCore:replaceWithGap:removeOperation: is the correct intercept point + // — it fires before the sequence action system is entered. + } + + + if (atm) { + SEL deleteSel = NSSelectorFromString(@"delete:"); + Method md = class_getInstanceMethod(atm, deleteSel); + if (md) { + sOrigDelete = (DeleteFn)method_getImplementation(md); + method_setImplementation(md, (IMP)CL_swizzled_delete); + SpliceKit_log(@"[ClipLock] Swizzled FFAnchoredTimelineModule.delete:"); + } else { + SpliceKit_log(@"[ClipLock] delete: not found on FFAnchoredTimelineModule"); + } + + SEL validateUISel = NSSelectorFromString(@"validateUserInterfaceItem:withSelectedItems:"); + Method mu = class_getInstanceMethod(atm, validateUISel); + if (mu) { + sOrigValidateUI = (ValidateUIFn)method_getImplementation(mu); + method_setImplementation(mu, (IMP)CL_swizzled_validateUI); + SpliceKit_log(@"[ClipLock] Swizzled validateUserInterfaceItem:withSelectedItems:"); + } else { + SpliceKit_log(@"[ClipLock] validateUserInterfaceItem:withSelectedItems: not found"); + } + } else { + SpliceKit_log(@"[ClipLock] FFAnchoredTimelineModule not found"); + } + + // --- Keyboard shortcut ⌥L --- + CL_installActionHandler(); + CL_registerLKCommand(); + + Class lkcc = objc_getClass("LKCommandsController"); + if (lkcc) { + SEL loadSel = NSSelectorFromString(@"_loadCommands"); + Method ml = class_getInstanceMethod(lkcc, loadSel); + if (ml) { + sOrigLoadCommands_CL = method_getImplementation(ml); + method_setImplementation(ml, (IMP)CL_swizzled_loadCommands); + } + SEL activeSel = NSSelectorFromString(@"_setActiveCommandSet:"); + Method ma = class_getInstanceMethod(lkcc, activeSel); + if (ma) { + sOrigSetActiveCommandSet_CL = method_getImplementation(ma); + method_setImplementation(ma, (IMP)CL_swizzled_setActiveCommandSet); + } + } + + // NOTE: CHChannelDouble/CHChannel setCurveDoubleValue swizzles intentionally omitted. + // These fire deep inside FCP's audio subsystem during clip deletion, causing crashes. + + // --- RPC methods --- + CL_registerRPC(); + + // FCP may restore the last project before _startListeningToSequence: is swizzled. + // Run a deferred scan 1s after install to catch that case. + dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(1.0 * NSEC_PER_SEC)), + dispatch_get_main_queue(), ^{ + id tm = SpliceKit_getActiveTimelineModule(); + if (!tm) return; + SEL seqSel = NSSelectorFromString(@"sequence"); + if (![tm respondsToSelector:seqSel]) return; + id seq = ((id (*)(id, SEL))objc_msgSend)(tm, seqSel); + NSArray *allItems = CL_getSpineItems(seq); + if (!allItems) return; + NSMutableArray *locked = [NSMutableArray array]; + for (id item in allItems) { + if (CL_isLockedClipDB(item)) { + [locked addObject:item]; + CL_cacheAdd(item); + } + } + [sLockStateLock lock]; + sAnyLockedInTimeline = (locked.count > 0); + [sLockStateLock unlock]; + if (locked.count) { + SpliceKit_log(@"[ClipLock] Startup scan: found %lu locked clip(s), refreshing labels", + (unsigned long)locked.count); + CL_refreshClips(locked); + } + }); + + SpliceKit_log(@"[ClipLock] Installed successfully"); +} diff --git a/Sources/SpliceKitCommandPalette.m b/Sources/SpliceKitCommandPalette.m index 18be4fd..d05fe9c 100644 --- a/Sources/SpliceKitCommandPalette.m +++ b/Sources/SpliceKitCommandPalette.m @@ -1354,6 +1354,7 @@ - (void)registerCommands { add(@"Replace from Start", @"replaceFromStart", @"timeline", SpliceKitCommandCategoryEditing, @"Edit Modes", nil, @"Replace clip from start", @[@"replace edit"]); add(@"Replace from End", @"replaceFromEnd", @"timeline", SpliceKitCommandCategoryEditing, @"Edit Modes", nil, @"Replace clip from end", @[@"replace edit backtimed"]); add(@"Replace Whole Clip", @"replaceWhole", @"timeline", SpliceKitCommandCategoryEditing, @"Edit Modes", nil, @"Replace entire clip", @[@"swap"]); + add(@"Replace at Playhead", @"replaceAtPlayhead", @"timeline", SpliceKitCommandCategoryEditing, @"Edit Modes", nil, @"Replace clip synced to timeline playhead position", @[@"replace sync playhead backtimed"]); // --- Trim Extras --- add(@"Trim Start", @"trimStart", @"timeline", SpliceKitCommandCategoryEditing, @"Trim", @"Opt+[", @"Trim clip start to playhead", @[@"head trim"]); @@ -5163,7 +5164,7 @@ - (void)executeNaturalLanguage:(NSString *)query @"appendEditAudio", @"appendEditVideo", @"overwriteEditAudio", @"overwriteEditVideo", @"avEditModeAudio", @"avEditModeVideo", @"avEditModeBoth", - @"replaceFromStart", @"replaceFromEnd", @"replaceWhole", + @"replaceFromStart", @"replaceFromEnd", @"replaceWhole", @"replaceAtPlayhead", // Navigation go-to @"goToInspector", @"goToTimeline", @"goToViewer", @"goToColorBoard", ]]; diff --git a/Sources/SpliceKitDebugUI.m b/Sources/SpliceKitDebugUI.m index 57f1364..2120887 100644 --- a/Sources/SpliceKitDebugUI.m +++ b/Sources/SpliceKitDebugUI.m @@ -73,8 +73,8 @@ } static NSArray *SKDebug_fcpBehaviorFlags(void) { + // FFDontCoalesceGaps lives in the SpliceKit preferences pane — omit here. return @[ - @"FFDontCoalesceGaps", @"FFDisableSnapping", @"FFDisableSkimming", ]; @@ -469,14 +469,28 @@ - (void)menuNeedsUpdate:(NSMenu *)menu { return box; } -static NSStackView *SKDebug_makeCheckboxGroup(NSArray *keys) { +// descriptions: key → human-readable explanation (may be nil for a given key). +static NSStackView *SKDebug_makeCheckboxGroup(NSArray *keys, + NSDictionary *descriptions) { NSStackView *stack = [NSStackView stackViewWithViews:@[]]; stack.orientation = NSUserInterfaceLayoutOrientationVertical; stack.alignment = NSLayoutAttributeLeading; - stack.spacing = 4; + stack.spacing = 6; stack.translatesAutoresizingMaskIntoConstraints = NO; for (NSString *key in keys) { - [stack addArrangedSubview:SKDebug_makeCheckbox(key, SKDebug_humanizeKey(key))]; + NSString *desc = descriptions[key]; + if (desc.length > 0) { + NSStackView *row = [NSStackView stackViewWithViews:@[]]; + row.orientation = NSUserInterfaceLayoutOrientationVertical; + row.alignment = NSLayoutAttributeLeading; + row.spacing = 2; + row.translatesAutoresizingMaskIntoConstraints = NO; + [row addArrangedSubview:SKDebug_makeCheckbox(key, SKDebug_humanizeKey(key))]; + [row addArrangedSubview:SKDebug_makeNoteLabel(desc)]; + [stack addArrangedSubview:row]; + } else { + [stack addArrangedSubview:SKDebug_makeCheckbox(key, SKDebug_humanizeKey(key))]; + } } return stack; } @@ -529,22 +543,52 @@ - (void)menuNeedsUpdate:(NSMenu *)menu { // --- Timeline Visual Overlays --- [root addArrangedSubview:SKDebug_makeSectionLabel(@"Timeline Visual Overlays")]; - [root addArrangedSubview:SKDebug_makeCheckboxGroup(SKDebug_tlkVisualFlags())]; + [root addArrangedSubview:SKDebug_makeCheckboxGroup(SKDebug_tlkVisualFlags(), @{ + @"TLKShowItemLaneIndex": @"Shows the lane number on each timeline item — useful for understanding connected clip stacking order.", + @"TLKShowMisalignedEdges": @"Highlights item edges that are not pixel-aligned, which can cause sub-pixel rendering artefacts.", + @"TLKShowRenderBar": @"Shows the render status colour bar overlaid on the timeline ruler.", + @"TLKShowHiddenGapItems": @"Reveals gap items that FCP normally hides from view, exposing the true timeline data model.", + @"TLKShowHiddenItemHeaders": @"Reveals item header elements (role labels, etc.) that FCP hides in compact layouts.", + @"TLKShowInvalidLayoutRects": @"Highlights rectangles where layout constraints could not be satisfied.", + @"TLKShowContainerBounds": @"Draws the bounding box of each timeline container (primary storyline, connected story lines).", + @"TLKShowContentLayers": @"Draws the boundary of each content layer within an item (video, audio, overlay).", + @"TLKShowRulerBounds": @"Shows the ruler overlay bounding box.", + @"TLKShowUsedRegion": @"Highlights the portion of each container that is actually occupied by items.", + @"TLKShowZeroHeightSpineItems": @"Reveals spine items with zero height — normally invisible but present in the data model.", + @"TLKDebugColorChangedObjects": @"Tints objects that changed during the last layout pass, making update propagation visible.", + })]; [root addArrangedSubview:SKDebug_makeSeparator()]; // --- Timeline Logging --- [root addArrangedSubview:SKDebug_makeSectionLabel(@"Timeline Logging")]; - [root addArrangedSubview:SKDebug_makeCheckboxGroup(SKDebug_tlkLoggingFlags())]; + [root addArrangedSubview:SKDebug_makeCheckboxGroup(SKDebug_tlkLoggingFlags(), @{ + @"TLKLogVisibleLayerChanges": @"Logs when visible layers are added, removed, or reordered (high volume during scrubbing).", + @"TLKLogParts": @"Logs timeline part lifecycle events: load, layout, unload, recycle.", + @"TLKLogReloadRequests": @"Logs every request to reload or refresh timeline content.", + @"TLKLogRecyclingLayerChanges": @"Logs when layers are recycled between items to reduce allocation overhead.", + @"TLKLogVisibleRectChanges": @"Logs changes to the visible rectangle as the user scrolls or zooms.", + @"TLKLogSegmentationStatistics": @"Logs statistics about how the timeline is segmented for rendering.", + })]; [root addArrangedSubview:SKDebug_makeSeparator()]; // --- Performance & Rendering --- [root addArrangedSubview:SKDebug_makeSectionLabel(@"Performance & Rendering")]; - [root addArrangedSubview:SKDebug_makeCheckboxGroup(SKDebug_renderFlags())]; + [root addArrangedSubview:SKDebug_makeCheckboxGroup(SKDebug_renderFlags(), @{ + @"TLKPerformanceMonitorEnabled": @"Enables TLK's built-in overlay showing layout and render timings per frame.", + @"TLKDisableItemContents": @"Disables all item content rendering (video frames, waveforms, thumbnails). Useful for isolating UI performance from media decode.", + @"DebugKeyItemVideoFilmstripsDisabled": @"Disables video filmstrip thumbnail generation only, leaving waveforms and backgrounds intact.", + @"DebugKeyItemBackgroundDisabled": @"Disables the coloured background bar behind each clip.", + @"DebugKeyItemAudioWaveformsDisabled": @"Disables audio waveform drawing — can speed up rendering on large timelines.", + @"GPU_LOGGING": @"Enables verbose GPU and FxPlug shader pipeline logging in the system log.", + })]; [root addArrangedSubview:SKDebug_makeSeparator()]; // --- FCP Behavior Overrides --- [root addArrangedSubview:SKDebug_makeSectionLabel(@"FCP Behavior Overrides")]; - [root addArrangedSubview:SKDebug_makeCheckboxGroup(SKDebug_fcpBehaviorFlags())]; + [root addArrangedSubview:SKDebug_makeCheckboxGroup(SKDebug_fcpBehaviorFlags(), @{ + @"FFDisableSnapping": @"Disables magnetic snapping when moving or trimming clips. Useful when testing free-form positioning.", + @"FFDisableSkimming": @"Disables clip skimming when the pointer hovers over clips in the browser or timeline.", + })]; [root addArrangedSubview:SKDebug_makeSeparator()]; // --- ProAppSupport Log --- @@ -569,6 +613,11 @@ - (void)menuNeedsUpdate:(NSMenu *)menu { levelPopup.target = [SpliceKitDebugController shared]; levelPopup.action = @selector(setLogLevel:); [row addArrangedSubview:levelPopup]; + NSTextField *logLevelNote = SKDebug_makeNoteLabel( + @"Minimum severity for ProAppSupport-emitted log entries. " + @"Trace is most verbose; Failure is errors only."); + logLevelNote.preferredMaxLayoutWidth = 200; + [row addArrangedSubview:logLevelNote]; [row addArrangedSubview:SKDebug_makeCheckbox(@"LogUI", @"Show In-App Log Panel")]; [row addArrangedSubview:SKDebug_makeCheckbox(@"LogThread", @"Include Thread Info")]; [root addArrangedSubview:row]; @@ -621,11 +670,18 @@ - (void)menuNeedsUpdate:(NSMenu *)menu { [row addArrangedSubview:p2]; [root addArrangedSubview:row]; + [root addArrangedSubview:SKDebug_makeNoteLabel( + @"Video Decoder Log: verbosity of the NLE video decode pipeline (0 = off, higher = more detail). " + @"Frame Drop Log: verbosity of dropped-frame reporting (0 = off). " + @"Both write to the system unified log — view with Console.app or `log stream --process \"Final Cut Pro\"`.")]; } [root addArrangedSubview:SKDebug_makeSeparator()]; // --- Presets (row of buttons) --- [root addArrangedSubview:SKDebug_makeSectionLabel(@"Presets")]; + [root addArrangedSubview:SKDebug_makeNoteLabel( + @"One-click combinations of the flags above. \"All Off\" removes every debug key " + @"from NSUserDefaults and CFPreferences, restoring FCP's default behaviour.")]; { NSStackView *row1 = [NSStackView stackViewWithViews:@[ SKDebug_makePresetButton(@"Timeline Visual", @"timeline_visual"), @@ -649,6 +705,11 @@ - (void)menuNeedsUpdate:(NSMenu *)menu { // --- Actions --- [root addArrangedSubview:SKDebug_makeSectionLabel(@"Actions")]; + [root addArrangedSubview:SKDebug_makeNoteLabel( + @"HMD Framerate Monitor uses FCP's built-in HMDFramerate (ProCore) to log overall fps, " + @"average getFrame() time in ms, and min/max frame times every 2 seconds. " + @"Output appears in the system unified log — monitor with Console.app. " + @"\"Clear User Defaults\" removes all debug flags set on this tab.")]; { NSStackView *row = [NSStackView stackViewWithViews:@[]]; row.orientation = NSUserInterfaceLayoutOrientationHorizontal; @@ -784,18 +845,9 @@ BOOL SpliceKit_installDebugSettingsPanel(void) { [modules addObject:module]; master[title] = view; - // Rebuild the toolbar so the Debug tab appears. Private but stable — it's - // the same method LKPreferences calls from addPreferenceNamed:owner:. - SEL setupToolbar = NSSelectorFromString(@"_setupToolbar"); - if ([shared respondsToSelector:setupToolbar]) { - ((void (*)(id, SEL))objc_msgSend)(shared, setupToolbar); - } - - // Also update the panel's size constraint if it's open - SEL updateFrame = @selector(updatePanelFrameAnimated:); - if ([shared respondsToSelector:updateFrame]) { - ((void (*)(id, SEL, BOOL))objc_msgSend)(shared, updateFrame, NO); - } + // Do NOT call _setupToolbar here — it wipes FCP's owner registry and + // breaks native tab switching. Toolbar items are inserted directly + // when showPreferencesPanel is called (see SpliceKitPreferencesPane). sDebugPrefsInstalled = YES; success = YES; diff --git a/Sources/SpliceKitPreferencesPane.h b/Sources/SpliceKitPreferencesPane.h new file mode 100644 index 0000000..2ff0566 --- /dev/null +++ b/Sources/SpliceKitPreferencesPane.h @@ -0,0 +1,8 @@ +// +// SpliceKitPreferencesPane.h +// Adds a "SpliceKit" tab to FCP's Preferences / Settings window. +// + +#import + +BOOL SpliceKit_installSpliceKitPreferencesPane(void); diff --git a/Sources/SpliceKitPreferencesPane.m b/Sources/SpliceKitPreferencesPane.m new file mode 100644 index 0000000..cacd4c8 --- /dev/null +++ b/Sources/SpliceKitPreferencesPane.m @@ -0,0 +1,964 @@ +// +// SpliceKitPreferencesPane.m +// Adds a "SpliceKit" tab to FCP's Preferences / Settings window. +// +// Installation follows the same pattern as SpliceKitDebugUI: +// 1. Build the view programmatically (no NIB needed). +// 2. Mutate LKPreferences' internal arrays / dictionary directly. +// 3. Call _setupToolbar so the toolbar picks up the new tab. +// + +#import "SpliceKitPreferencesPane.h" +#import "SpliceKitTimecodeBarShortcuts.h" +#import "SpliceKit.h" +#import +#import +#import + +// FCP-default preference functions defined in SpliceKitServer.m +extern NSString *SpliceKit_getTransitionWarningDefault(void); +extern void SpliceKit_setTransitionWarningDefault(NSString *value); +extern NSInteger SpliceKit_getDefaultClipHeight(void); +extern void SpliceKit_setDefaultClipHeight(NSInteger pixelHeight); +extern NSString *SpliceKit_getDefaultAudioChannelConfig(void); +extern void SpliceKit_setDefaultAudioChannelConfig(NSString *value); +extern NSString *SpliceKit_getDefaultAudioPanMode(void); +extern void SpliceKit_setDefaultAudioPanMode(NSString *value); + +// --------------------------------------------------------------------------- +#pragma mark - Module class + +// Subclass PEAppDebugPreferencesModule — same base class as FCP's Debug tab — +// so that LKPreferences' toolbarItemClicked: accepts our module without a swizzle. +// We override icon, title, and viewForPreferenceNamed: to return our own content. +@interface PEAppDebugPreferencesModule : NSObject +- (void)setPreferencesView:(NSView *)view; +- (NSView *)preferencesView; +@end + +@interface SpliceKitPrefsModule : PEAppDebugPreferencesModule +@property (nonatomic, strong) NSView *spliceKitContentView; +@end + +@implementation SpliceKitPrefsModule + +- (NSImage *)imageForPreferenceNamed:(NSString *)name { + (void)name; + NSImage *img = [NSImage imageWithSystemSymbolName:@"wand.and.stars" + accessibilityDescription:@"SpliceKit"]; + return img ?: [NSImage imageNamed:NSImageNameAdvanced]; +} + +- (NSString *)titleForIdentifier:(NSString *)identifier { + (void)identifier; + return @"SpliceKit"; +} + +- (NSView *)viewForPreferenceNamed:(NSString *)name { + (void)name; + return self.spliceKitContentView; +} + +- (BOOL)preferencesWindowShouldClose { return YES; } +- (BOOL)moduleCanBeRemoved { return NO; } + +@end + +// --------------------------------------------------------------------------- +#pragma mark - Controller + +// Identifier tags written into NSButton/NSPopUpButton.identifier so action +// methods can dispatch without a big if-chain. +static NSString *const kIDEffectDrag = @"effectDragAsAdjustmentClip"; +static NSString *const kIDPinchZoom = @"viewerPinchZoom"; +static NSString *const kIDVideoOnlyAudio = @"videoOnlyKeepsAudioDisabled"; +static NSString *const kIDSuppressImport = @"suppressAutoImport"; +static NSString *const kIDColorizeLanes = @"TLKColorizesLanes"; +static NSString *const kIDDontCoalesceGaps = @"FFDontCoalesceGaps"; +static NSString *const kIDPlayheadOverlay = @"timelinePlayheadOverlay"; +static NSString *const kIDPerfMode = @"timelinePerformanceMode"; +// FCP Defaults popup identifiers +static NSString *const kIDTransitionWarning = @"transitionWarningDefault"; +static NSString *const kIDDefaultClipHeight = @"defaultClipHeight"; +static NSString *const kIDDefaultAudioChannel = @"defaultAudioChannelConfig"; +static NSString *const kIDDefaultAudioPanMode = @"defaultAudioPanMode"; +static NSString *const kIDSpatialConform = @"defaultSpatialConformType"; + +@interface SKPrefsController : NSObject ++ (instancetype)shared; +@end + +@implementation SKPrefsController + ++ (instancetype)shared { + static SKPrefsController *s = nil; + static dispatch_once_t once; + dispatch_once(&once, ^{ s = [[self alloc] init]; }); + return s; +} + +// Reload TLKUserDefaults so flag changes take effect immediately. +static void SKPrefs_reloadTLK(void) { + Class cls = NSClassFromString(@"TLKUserDefaults"); + if (!cls) return; + SEL sel = NSSelectorFromString(@"_loadUserDefaults"); + if ([cls respondsToSelector:sel]) ((void (*)(id, SEL))objc_msgSend)(cls, sel); +} + +- (void)checkboxChanged:(NSButton *)sender { + NSString *ident = sender.identifier; + BOOL on = (sender.state == NSControlStateValueOn); + + if ([ident isEqualToString:kIDEffectDrag]) { + SpliceKit_setEffectDragAsAdjustmentClipEnabled(on); + + } else if ([ident isEqualToString:kIDPinchZoom]) { + SpliceKit_setViewerPinchZoomEnabled(on); + + } else if ([ident isEqualToString:kIDVideoOnlyAudio]) { + SpliceKit_setVideoOnlyKeepsAudioDisabledEnabled(on); + + } else if ([ident isEqualToString:kIDSuppressImport]) { + SpliceKit_setSuppressAutoImportEnabled(on); + + } else if ([ident isEqualToString:kIDColorizeLanes]) { + [[NSUserDefaults standardUserDefaults] setBool:on forKey:kIDColorizeLanes]; + SKPrefs_reloadTLK(); + + } else if ([ident isEqualToString:kIDDontCoalesceGaps]) { + [[NSUserDefaults standardUserDefaults] setBool:on forKey:kIDDontCoalesceGaps]; + SKPrefs_reloadTLK(); + + } else if ([ident isEqualToString:kIDPlayheadOverlay]) { + SpliceKit_setTimelinePlayheadOverlayEnabled(on); + + } else if ([ident isEqualToString:kIDPerfMode]) { + SpliceKit_setTimelinePerformanceModeEnabled(on); + } + + SpliceKit_log(@"SpliceKit pref %@ -> %@", ident, on ? @"ON" : @"OFF"); +} + +- (void)conformPopupChanged:(NSPopUpButton *)popup { + NSInteger idx = popup.indexOfSelectedItem; + NSString *value = (idx == 1) ? @"fill" : (idx == 2) ? @"none" : @"fit"; + SpliceKit_setDefaultSpatialConformType(value); + SpliceKit_log(@"Default spatial conform -> %@", value); +} + +- (void)transitionWarningPopupChanged:(NSPopUpButton *)popup { + NSInteger idx = popup.indexOfSelectedItem; + // 0 = Always Ask, 1 = Use Freeze Frames, 2 = Ripple Trim + NSString *value = (idx == 1) ? @"freeze_frames" : (idx == 2) ? @"ripple_trim" : @"ask"; + SpliceKit_setTransitionWarningDefault(value); + SpliceKit_log(@"Transition warning default -> %@", value); +} + +- (void)clipHeightPopupChanged:(NSPopUpButton *)popup { + NSInteger idx = popup.indexOfSelectedItem; + // 0 = No Override, 1 = Small (35), 2 = Medium (80), 3 = Large (153) + NSInteger heights[] = {0, 35, 80, 153}; + NSInteger height = (idx >= 0 && idx < 4) ? heights[idx] : 0; + SpliceKit_setDefaultClipHeight(height); + SpliceKit_log(@"Default clip height -> %ld", (long)height); +} + +- (void)audioChannelPopupChanged:(NSPopUpButton *)popup { + NSInteger idx = popup.indexOfSelectedItem; + // 0 = No Override, 1 = Stereo, 2 = Dual Mono + NSString *value = (idx == 1) ? @"stereo" : (idx == 2) ? @"dual_mono" : @""; + SpliceKit_setDefaultAudioChannelConfig(value.length ? value : nil); + SpliceKit_log(@"Default audio channel config -> %@", value.length ? value : @"(none)"); +} + +- (void)audioPanModePopupChanged:(NSPopUpButton *)popup { + NSInteger idx = popup.indexOfSelectedItem; + // 0 = No Override, 1 = None, 2 = Stereo Left+Right, 3 = Mono + NSString *vals[] = {@"", @"none", @"stereo", @"mono"}; + NSString *value = (idx >= 0 && idx < 4) ? vals[idx] : @""; + SpliceKit_setDefaultAudioPanMode(value.length ? value : nil); + SpliceKit_log(@"Default audio pan mode -> %@", value.length ? value : @"(none)"); +} + +- (void)spatialConformPopupChangedInFCPDefaults:(NSPopUpButton *)popup { + NSInteger idx = popup.indexOfSelectedItem; + // 0 = Fit (Default), 1 = Fill, 2 = None + NSString *value = (idx == 1) ? @"fill" : (idx == 2) ? @"none" : @"fit"; + SpliceKit_setDefaultSpatialConformType(value); + SpliceKit_log(@"Default spatial conform -> %@", value); +} + +- (void)timecodeBarShortcutToggled:(NSButton *)sender { + NSString *actionID = sender.identifier; + BOOL on = (sender.state == NSControlStateValueOn); + + // Rebuild config: start from existing saved config, add/remove this action + NSMutableArray *config = [SpliceKit_getTimecodeBarConfig() mutableCopy]; + NSArray *catalogue = SpliceKit_getAvailableTimecodeBarActions(); + + if (on) { + // Append if not already present; fill in canonical metadata + BOOL exists = NO; + for (NSDictionary *item in config) + if ([item[@"id"] isEqualToString:actionID]) { exists = YES; break; } + if (!exists) { + for (NSDictionary *meta in catalogue) { + if ([meta[@"id"] isEqualToString:actionID]) { + [config addObject:meta]; + break; + } + } + } + } else { + [config filterUsingPredicate: + [NSPredicate predicateWithFormat:@"id != %@", actionID]]; + } + SpliceKit_setTimecodeBarConfig(config); +} + +@end + +// --------------------------------------------------------------------------- +#pragma mark - View construction helpers + +static NSTextField *SKPrefs_makeHeader(NSString *text) { + NSTextField *f = [NSTextField labelWithString:text]; + f.font = [NSFont boldSystemFontOfSize:13]; + f.textColor = [NSColor labelColor]; + f.translatesAutoresizingMaskIntoConstraints = NO; + return f; +} + +static NSTextField *SKPrefs_makeNote(NSString *text, CGFloat maxWidth) { + NSTextField *f = [NSTextField labelWithString:text]; + f.font = [NSFont systemFontOfSize:11]; + f.textColor = [NSColor secondaryLabelColor]; + f.maximumNumberOfLines = 0; + f.lineBreakMode = NSLineBreakByWordWrapping; + f.preferredMaxLayoutWidth = maxWidth; + f.translatesAutoresizingMaskIntoConstraints = NO; + return f; +} + +static NSBox *SKPrefs_makeSep(void) { + NSBox *b = [[NSBox alloc] initWithFrame:NSMakeRect(0, 0, 400, 1)]; + b.boxType = NSBoxSeparator; + b.translatesAutoresizingMaskIntoConstraints = NO; + return b; +} + +static NSButton *SKPrefs_makeCheckbox(NSString *ident, NSString *title, BOOL on) { + NSButton *cb = [NSButton checkboxWithTitle:title + target:[SKPrefsController shared] + action:@selector(checkboxChanged:)]; + cb.identifier = ident; + cb.state = on ? NSControlStateValueOn : NSControlStateValueOff; + cb.translatesAutoresizingMaskIntoConstraints = NO; + return cb; +} + +// Row: label + indented checkbox +static NSStackView *SKPrefs_makeRow(NSString *ident, NSString *title, + NSString *note, BOOL on, CGFloat noteWidth) { + NSStackView *col = [NSStackView stackViewWithViews:@[]]; + col.orientation = NSUserInterfaceLayoutOrientationVertical; + col.alignment = NSLayoutAttributeLeading; + col.spacing = 2; + col.translatesAutoresizingMaskIntoConstraints = NO; + + [col addArrangedSubview:SKPrefs_makeCheckbox(ident, title, on)]; + if (note.length > 0) { + [col addArrangedSubview:SKPrefs_makeNote(note, noteWidth)]; + } + return col; +} + +// --------------------------------------------------------------------------- +#pragma mark - Build full pane view + +static NSView *SKPrefs_buildView(void) { + const CGFloat kWidth = 560.0; + const CGFloat kNoteWidth = kWidth - 44.0; // inset under checkbox indent + + NSView *doc = [[NSView alloc] initWithFrame:NSMakeRect(0, 0, kWidth, 900)]; + doc.translatesAutoresizingMaskIntoConstraints = NO; + + NSStackView *root = [NSStackView stackViewWithViews:@[]]; + root.orientation = NSUserInterfaceLayoutOrientationVertical; + root.alignment = NSLayoutAttributeLeading; + root.spacing = 8; + root.edgeInsets = NSEdgeInsetsMake(16, 20, 20, 20); + root.translatesAutoresizingMaskIntoConstraints = NO; + [doc addSubview:root]; + [NSLayoutConstraint activateConstraints:@[ + [root.topAnchor constraintEqualToAnchor:doc.topAnchor], + [root.leadingAnchor constraintEqualToAnchor:doc.leadingAnchor], + [root.trailingAnchor constraintEqualToAnchor:doc.trailingAnchor], + [root.bottomAnchor constraintEqualToAnchor:doc.bottomAnchor], + ]]; + + // ── SpliceKit Options ──────────────────────────────────────────────── + [root addArrangedSubview:SKPrefs_makeHeader(@"SpliceKit Options")]; + + [root addArrangedSubview:SKPrefs_makeRow( + kIDEffectDrag, + @"Effect Drag as Adjustment Clip", + @"Dragging an effect onto an empty area of the timeline creates an " + @"adjustment clip instead of applying the effect to the clip below.", + SpliceKit_isEffectDragAsAdjustmentClipEnabled(), kNoteWidth)]; + + [root addArrangedSubview:SKPrefs_makeRow( + kIDPinchZoom, + @"Viewer Pinch-to-Zoom", + @"Use a trackpad pinch gesture to zoom the viewer canvas in or out.", + SpliceKit_isViewerPinchZoomEnabled(), kNoteWidth)]; + + [root addArrangedSubview:SKPrefs_makeRow( + kIDVideoOnlyAudio, + @"Video-Only Edit Keeps Audio Disabled", + @"After a video-only append/insert edit, the audio role stays muted " + @"so accidental audio bleed is prevented on subsequent exports.", + SpliceKit_isVideoOnlyKeepsAudioDisabledEnabled(), kNoteWidth)]; + + [root addArrangedSubview:SKPrefs_makeRow( + kIDSuppressImport, + @"Suppress Auto Import Window on Device Connect", + @"Prevents Final Cut Pro from opening the Import window every time a " + @"camera, memory card, or external drive is connected.", + SpliceKit_isSuppressAutoImportEnabled(), kNoteWidth)]; + + [root addArrangedSubview:SKPrefs_makeSep()]; + + // ── FCP Defaults ───────────────────────────────────────────────────── + [root addArrangedSubview:SKPrefs_makeHeader(@"FCP Defaults")]; + [root addArrangedSubview:SKPrefs_makeNote( + @"Save your preferred answer for common FCP dialogs. " + @"These apply automatically so the dialog never interrupts your workflow.", + kNoteWidth)]; + + // ── Transition Warning popup ───────────────────────────────────────── + { + NSStackView *col = [NSStackView stackViewWithViews:@[]]; + col.orientation = NSUserInterfaceLayoutOrientationVertical; + col.alignment = NSLayoutAttributeLeading; + col.spacing = 2; + col.translatesAutoresizingMaskIntoConstraints = NO; + + NSStackView *row = [NSStackView stackViewWithViews:@[]]; + row.orientation = NSUserInterfaceLayoutOrientationHorizontal; + row.alignment = NSLayoutAttributeCenterY; + row.spacing = 8; + row.translatesAutoresizingMaskIntoConstraints = NO; + + NSTextField *lbl = [NSTextField labelWithString:@"Not Enough Media (Transition):"]; + lbl.translatesAutoresizingMaskIntoConstraints = NO; + [row addArrangedSubview:lbl]; + + NSPopUpButton *popup = [[NSPopUpButton alloc] initWithFrame:NSZeroRect pullsDown:NO]; + [popup addItemWithTitle:@"Always Ask"]; + [popup addItemWithTitle:@"Use Freeze Frames"]; + [popup addItemWithTitle:@"Ripple Trim"]; + NSString *twPref = SpliceKit_getTransitionWarningDefault(); + if ([twPref isEqualToString:@"freeze_frames"]) [popup selectItemAtIndex:1]; + else if ([twPref isEqualToString:@"ripple_trim"]) [popup selectItemAtIndex:2]; + else [popup selectItemAtIndex:0]; + popup.identifier = kIDTransitionWarning; + popup.target = [SKPrefsController shared]; + popup.action = @selector(transitionWarningPopupChanged:); + popup.translatesAutoresizingMaskIntoConstraints = NO; + [row addArrangedSubview:popup]; + [col addArrangedSubview:row]; + [col addArrangedSubview:SKPrefs_makeNote( + @"When applying a transition without enough media handles: ask every time, " + @"automatically extend clip edges with frozen frames, or let FCP ripple-trim the clips.", + kNoteWidth)]; + [root addArrangedSubview:col]; + } + + // ── Default Clip Height popup ───────────────────────────────────────── + { + NSStackView *col = [NSStackView stackViewWithViews:@[]]; + col.orientation = NSUserInterfaceLayoutOrientationVertical; + col.alignment = NSLayoutAttributeLeading; + col.spacing = 2; + col.translatesAutoresizingMaskIntoConstraints = NO; + + NSStackView *row = [NSStackView stackViewWithViews:@[]]; + row.orientation = NSUserInterfaceLayoutOrientationHorizontal; + row.alignment = NSLayoutAttributeCenterY; + row.spacing = 8; + row.translatesAutoresizingMaskIntoConstraints = NO; + + NSTextField *lbl = [NSTextField labelWithString:@"Default Clip Height:"]; + lbl.translatesAutoresizingMaskIntoConstraints = NO; + [row addArrangedSubview:lbl]; + + NSPopUpButton *popup = [[NSPopUpButton alloc] initWithFrame:NSZeroRect pullsDown:NO]; + [popup addItemWithTitle:@"No Override"]; + [popup addItemWithTitle:@"Small"]; + [popup addItemWithTitle:@"Medium"]; + [popup addItemWithTitle:@"Large"]; + NSInteger clipH = SpliceKit_getDefaultClipHeight(); + if (clipH == 35) [popup selectItemAtIndex:1]; + else if (clipH == 80) [popup selectItemAtIndex:2]; + else if (clipH == 153) [popup selectItemAtIndex:3]; + else [popup selectItemAtIndex:0]; + popup.identifier = kIDDefaultClipHeight; + popup.target = [SKPrefsController shared]; + popup.action = @selector(clipHeightPopupChanged:); + popup.translatesAutoresizingMaskIntoConstraints = NO; + [row addArrangedSubview:popup]; + [col addArrangedSubview:row]; + [col addArrangedSubview:SKPrefs_makeNote( + @"Sets FFOrganizedTimelineClipHeight in FCP's global preferences, which controls " + @"the default clip row height for new timelines.", + kNoteWidth)]; + [root addArrangedSubview:col]; + } + + // ── Default Audio Channel Config popup ──────────────────────────────── + { + NSStackView *col = [NSStackView stackViewWithViews:@[]]; + col.orientation = NSUserInterfaceLayoutOrientationVertical; + col.alignment = NSLayoutAttributeLeading; + col.spacing = 2; + col.translatesAutoresizingMaskIntoConstraints = NO; + + NSStackView *row = [NSStackView stackViewWithViews:@[]]; + row.orientation = NSUserInterfaceLayoutOrientationHorizontal; + row.alignment = NSLayoutAttributeCenterY; + row.spacing = 8; + row.translatesAutoresizingMaskIntoConstraints = NO; + + NSTextField *lbl = [NSTextField labelWithString:@"Default Audio Channels:"]; + lbl.translatesAutoresizingMaskIntoConstraints = NO; + [row addArrangedSubview:lbl]; + + NSPopUpButton *popup = [[NSPopUpButton alloc] initWithFrame:NSZeroRect pullsDown:NO]; + [popup addItemWithTitle:@"No Override"]; + [popup addItemWithTitle:@"Stereo"]; + [popup addItemWithTitle:@"Dual Mono"]; + NSString *audioCfg = SpliceKit_getDefaultAudioChannelConfig(); + if ([audioCfg isEqualToString:@"stereo"]) [popup selectItemAtIndex:1]; + else if ([audioCfg isEqualToString:@"dual_mono"]) [popup selectItemAtIndex:2]; + else [popup selectItemAtIndex:0]; + popup.identifier = kIDDefaultAudioChannel; + popup.target = [SKPrefsController shared]; + popup.action = @selector(audioChannelPopupChanged:); + popup.translatesAutoresizingMaskIntoConstraints = NO; + [row addArrangedSubview:popup]; + [col addArrangedSubview:row]; + [col addArrangedSubview:SKPrefs_makeNote( + @"Saves your preferred channel interpretation for two-channel audio clips " + @"(stereo L+R mix vs. dual mono independent channels). " + @"Apply to selected clips via the bridge set_bridge_option_value tool.", + kNoteWidth)]; + [root addArrangedSubview:col]; + } + + // ── Default Audio Pan Mode popup ───────────────────────────────────── + { + NSStackView *col = [NSStackView stackViewWithViews:@[]]; + col.orientation = NSUserInterfaceLayoutOrientationVertical; + col.alignment = NSLayoutAttributeLeading; + col.spacing = 2; + col.translatesAutoresizingMaskIntoConstraints = NO; + + NSStackView *row = [NSStackView stackViewWithViews:@[]]; + row.orientation = NSUserInterfaceLayoutOrientationHorizontal; + row.alignment = NSLayoutAttributeCenterY; + row.spacing = 8; + row.translatesAutoresizingMaskIntoConstraints = NO; + + NSTextField *lbl = [NSTextField labelWithString:@"Default Audio Pan Mode:"]; + lbl.translatesAutoresizingMaskIntoConstraints = NO; + [row addArrangedSubview:lbl]; + + NSPopUpButton *popup = [[NSPopUpButton alloc] initWithFrame:NSZeroRect pullsDown:NO]; + [popup addItemWithTitle:@"No Override"]; + [popup addItemWithTitle:@"None (Stereo Passthrough)"]; + [popup addItemWithTitle:@"Stereo Left+Right"]; + [popup addItemWithTitle:@"Mono"]; + NSString *panMode = SpliceKit_getDefaultAudioPanMode(); + if ([panMode isEqualToString:@"none"]) [popup selectItemAtIndex:1]; + else if ([panMode isEqualToString:@"stereo"]) [popup selectItemAtIndex:2]; + else if ([panMode isEqualToString:@"mono"]) [popup selectItemAtIndex:3]; + else [popup selectItemAtIndex:0]; + popup.identifier = kIDDefaultAudioPanMode; + popup.target = [SKPrefsController shared]; + popup.action = @selector(audioPanModePopupChanged:); + popup.translatesAutoresizingMaskIntoConstraints = NO; + [row addArrangedSubview:popup]; + [col addArrangedSubview:row]; + [col addArrangedSubview:SKPrefs_makeNote( + @"Panning mode applied to audio clips: None = stereo passthrough (no panner AU), " + @"Stereo Left+Right = stereo panning, Mono = mono downmix panner.", + kNoteWidth)]; + [root addArrangedSubview:col]; + } + + // ── Default Spatial Conform popup ───────────────────────────────────── + { + NSStackView *col = [NSStackView stackViewWithViews:@[]]; + col.orientation = NSUserInterfaceLayoutOrientationVertical; + col.alignment = NSLayoutAttributeLeading; + col.spacing = 2; + col.translatesAutoresizingMaskIntoConstraints = NO; + + NSStackView *row = [NSStackView stackViewWithViews:@[]]; + row.orientation = NSUserInterfaceLayoutOrientationHorizontal; + row.alignment = NSLayoutAttributeCenterY; + row.spacing = 8; + row.translatesAutoresizingMaskIntoConstraints = NO; + + NSTextField *lbl = [NSTextField labelWithString:@"Default Spatial Conform:"]; + lbl.translatesAutoresizingMaskIntoConstraints = NO; + [row addArrangedSubview:lbl]; + + NSPopUpButton *popup = [[NSPopUpButton alloc] initWithFrame:NSZeroRect pullsDown:NO]; + [popup addItemWithTitle:@"Fit (Default)"]; + [popup addItemWithTitle:@"Fill"]; + [popup addItemWithTitle:@"None"]; + NSString *conform = SpliceKit_getDefaultSpatialConformType(); + if ([conform isEqualToString:@"fill"]) [popup selectItemAtIndex:1]; + else if ([conform isEqualToString:@"none"]) [popup selectItemAtIndex:2]; + else [popup selectItemAtIndex:0]; + popup.identifier = kIDSpatialConform; + popup.target = [SKPrefsController shared]; + popup.action = @selector(spatialConformPopupChangedInFCPDefaults:); + popup.translatesAutoresizingMaskIntoConstraints = NO; + [row addArrangedSubview:popup]; + [col addArrangedSubview:row]; + [col addArrangedSubview:SKPrefs_makeNote( + @"Spatial conform applied to new clips dropped onto the timeline: " + @"Fit (letterbox/pillarbox), Fill (crop to fill frame), or None (native resolution).", + kNoteWidth)]; + [root addArrangedSubview:col]; + } + + [root addArrangedSubview:SKPrefs_makeSep()]; + + // ── Timeline Enhancements ──────────────────────────────────────────── + [root addArrangedSubview:SKPrefs_makeHeader(@"Timeline Enhancements")]; + + NSUserDefaults *ud = [NSUserDefaults standardUserDefaults]; + + [root addArrangedSubview:SKPrefs_makeRow( + kIDColorizeLanes, + @"Colorize Connected Clip Lanes", + @"Tints each connected clip lane a distinct color, making it easier " + @"to follow complex multi-lane compositions at a glance.", + [ud boolForKey:kIDColorizeLanes], kNoteWidth)]; + + [root addArrangedSubview:SKPrefs_makeRow( + kIDDontCoalesceGaps, + @"Prevent Gap Auto-Merge", + @"Stops Final Cut Pro from automatically collapsing adjacent gaps into " + @"one. Useful when gaps are intentional spacers you want to keep separate.", + [ud boolForKey:kIDDontCoalesceGaps], kNoteWidth)]; + + [root addArrangedSubview:SKPrefs_makeRow( + kIDPlayheadOverlay, + @"120 Hz Playhead Overlay", + @"Renders the timeline playhead at up to 120 Hz on ProMotion displays, " + @"decoupled from the timeline redraw cycle for smoother scrubbing.", + SpliceKit_isTimelinePlayheadOverlayEnabled(), kNoteWidth)]; + + [root addArrangedSubview:SKPrefs_makeRow( + kIDPerfMode, + @"Timeline Performance Mode", + @"Bundles the 120 Hz playhead overlay, interaction-suspend (freezes " + @"non-essential redraws during drags), and optimized reload into one toggle.", + SpliceKit_isTimelinePerformanceModeEnabled(), kNoteWidth)]; + + [root addArrangedSubview:SKPrefs_makeSep()]; + + // ── Timecode Bar Shortcuts ─────────────────────────────────────────── + [root addArrangedSubview:SKPrefs_makeHeader(@"Timecode Bar Shortcuts")]; + [root addArrangedSubview:SKPrefs_makeNote( + @"Check the actions you want as quick-access buttons in the timeline toolbar, " + @"between the history arrows and the snapping/skimming controls. " + @"Changes take effect immediately.", + kNoteWidth)]; + + { + NSArray *catalogue = SpliceKit_getAvailableTimecodeBarActions(); + NSArray *config = SpliceKit_getTimecodeBarConfig(); + + // Build a set of currently-enabled action IDs for O(1) lookup + NSMutableSet *enabledIDs = [NSMutableSet set]; + for (NSDictionary *item in config) [enabledIDs addObject:item[@"id"]]; + + // Group by category, emit a compact label + row of checkboxes per group + NSMutableArray *seenCategories = [NSMutableArray array]; + NSMutableDictionary *byCategory = [NSMutableDictionary dictionary]; + for (NSDictionary *action in catalogue) { + NSString *cat = action[@"category"] ?: @"Other"; + if (!byCategory[cat]) { + byCategory[cat] = [NSMutableArray array]; + [seenCategories addObject:cat]; + } + [byCategory[cat] addObject:action]; + } + + for (NSString *category in seenCategories) { + NSArray *actions = byCategory[category]; + + NSTextField *catLabel = [NSTextField labelWithString:category]; + catLabel.font = [NSFont systemFontOfSize:11 weight:NSFontWeightSemibold]; + catLabel.textColor = [NSColor secondaryLabelColor]; + catLabel.translatesAutoresizingMaskIntoConstraints = NO; + [root addArrangedSubview:catLabel]; + + // Lay actions out in a flow: up to 4 per row + static const NSInteger kPerRow = 4; + NSInteger i = 0; + while (i < (NSInteger)actions.count) { + NSStackView *row = [NSStackView stackViewWithViews:@[]]; + row.orientation = NSUserInterfaceLayoutOrientationHorizontal; + row.spacing = 16; + row.alignment = NSLayoutAttributeCenterY; + row.translatesAutoresizingMaskIntoConstraints = NO; + row.edgeInsets = NSEdgeInsetsMake(0, 20, 0, 0); + + for (NSInteger j = 0; j < kPerRow && i < (NSInteger)actions.count; j++, i++) { + NSDictionary *action = actions[i]; + NSString *actionID = action[@"id"]; + NSString *label = action[@"tooltip"] ?: actionID; + // Trim key equiv suffix for checkbox label (e.g. "Blade — B" → "Blade") + NSRange dash = [label rangeOfString:@" — "]; + if (dash.location != NSNotFound) label = [label substringToIndex:dash.location]; + + NSButton *cb = [NSButton checkboxWithTitle:label + target:[SKPrefsController shared] + action:@selector(timecodeBarShortcutToggled:)]; + cb.identifier = actionID; + cb.state = [enabledIDs containsObject:actionID] + ? NSControlStateValueOn : NSControlStateValueOff; + cb.translatesAutoresizingMaskIntoConstraints = NO; + cb.font = [NSFont systemFontOfSize:12]; + + // Show the SF Symbol as a small image on the checkbox label + NSImage *img = [NSImage imageWithSystemSymbolName:action[@"icon"] + accessibilityDescription:label]; + if (img) { + // We can't directly put an image on a checkbox, so build a + // horizontal stack of image + checkbox + NSImageView *iv = [[NSImageView alloc] initWithFrame:NSMakeRect(0,0,14,14)]; + iv.image = img; + iv.imageScaling = NSImageScaleProportionallyUpOrDown; + iv.translatesAutoresizingMaskIntoConstraints = NO; + [iv.widthAnchor constraintEqualToConstant:14].active = YES; + [iv.heightAnchor constraintEqualToConstant:14].active = YES; + + NSStackView *cell = [NSStackView stackViewWithViews:@[iv, cb]]; + cell.orientation = NSUserInterfaceLayoutOrientationHorizontal; + cell.spacing = 4; + cell.alignment = NSLayoutAttributeCenterY; + cell.translatesAutoresizingMaskIntoConstraints = NO; + [row addArrangedSubview:cell]; + } else { + [row addArrangedSubview:cb]; + } + } + [root addArrangedSubview:row]; + } + } + } + + return doc; +} + +// Wrap the doc view in a scroll view (same pattern as Debug pane). +static NSView *SKPrefs_buildScrollableView(void) { + NSView *content = SKPrefs_buildView(); + + NSScrollView *scroll = [[NSScrollView alloc] initWithFrame:NSMakeRect(0, 0, 600, 480)]; + scroll.hasVerticalScroller = YES; + scroll.hasHorizontalScroller = NO; + scroll.autohidesScrollers = YES; + scroll.borderType = NSNoBorder; + scroll.drawsBackground = NO; + + content.translatesAutoresizingMaskIntoConstraints = NO; + scroll.documentView = content; + + [NSLayoutConstraint activateConstraints:@[ + [content.topAnchor constraintEqualToAnchor:scroll.contentView.topAnchor], + [content.leadingAnchor constraintEqualToAnchor:scroll.contentView.leadingAnchor], + [content.trailingAnchor constraintEqualToAnchor:scroll.contentView.trailingAnchor], + [content.widthAnchor constraintEqualToAnchor:scroll.contentView.widthAnchor], + ]]; + + return scroll; +} + +static void SKPrefs_swizzleShowPanel(void); // forward declaration + +// --------------------------------------------------------------------------- +#pragma mark - Installation + +static id SKPrefs_getIvar(id obj, const char *name) { + if (!obj) return nil; + Ivar iv = class_getInstanceVariable(object_getClass(obj), name); + if (!iv) return nil; + return object_getIvar(obj, iv); +} + +static BOOL sSpliceKitPrefsInstalled = NO; + +BOOL SpliceKit_installSpliceKitPreferencesPane(void) { + if (sSpliceKitPrefsInstalled) return YES; + + __block BOOL success = NO; + dispatch_block_t work = ^{ + Class prefsClass = objc_getClass("LKPreferences"); + if (!prefsClass) return; + + id shared = ((id (*)(id, SEL))objc_msgSend)((id)prefsClass, + @selector(sharedPreferences)); + if (!shared) return; + + NSMutableArray *titles = SKPrefs_getIvar(shared, "_preferenceTitles"); + NSMutableArray *modules = SKPrefs_getIvar(shared, "_preferenceModules"); + NSMutableDictionary *master = SKPrefs_getIvar(shared, "_masterPreferenceViews"); + + if (![titles isKindOfClass:[NSMutableArray class]] || + ![modules isKindOfClass:[NSMutableArray class]] || + ![master isKindOfClass:[NSMutableDictionary class]]) return; + + NSString *title = @"SpliceKit"; + if ([titles containsObject:title]) { + sSpliceKitPrefsInstalled = YES; + success = YES; + return; + } + + SpliceKitPrefsModule *module = [[SpliceKitPrefsModule alloc] init]; + NSView *view = SKPrefs_buildScrollableView(); + module.spliceKitContentView = view; + // Also set via inherited setter for any code that calls preferencesView. + if ([module respondsToSelector:@selector(setPreferencesView:)]) { + [module setPreferencesView:view]; + } + + // Insert before "Debug" so SpliceKit sits between Destinations and Debug. + NSUInteger insertAt = [titles indexOfObject:@"Debug"]; + if (insertAt == NSNotFound) insertAt = titles.count; + + [titles insertObject:title atIndex:insertAt]; + [modules insertObject:module atIndex:insertAt]; + master[title] = view; + + // Never call _setupToolbar — it wipes FCP's internal owner registry, + // breaking native tab switching. Toolbar items are inserted directly + // in the showPreferencesPanel swizzle below, after the window is ready. + + sSpliceKitPrefsInstalled = YES; + success = YES; + SpliceKit_log(@"SpliceKit preferences pane data registered"); + }; + + if ([NSThread isMainThread]) work(); + else dispatch_sync(dispatch_get_main_queue(), work); + + // Swizzle showPreferencesPanel to insert toolbar items the first time the + // window opens. At that point the NSToolbar exists and inserting an item + // calls toolbar:itemForItemIdentifier:willBeInsertedIntoToolbar: which + // reads from _preferenceTitles/_preferenceModules — already populated above. + static dispatch_once_t once; + dispatch_once(&once, ^{ SKPrefs_swizzleShowPanel(); }); + + return success; +} + +// --------------------------------------------------------------------------- +#pragma mark - showPreferencesPanel swizzle (toolbar item insertion) + +@interface LKPreferences : NSObject +- (id)_preferencesPanel; +@end + +@interface LKPreferences (SKPanelHook) +- (void)spliceKit_showPreferencesPanel; +- (void)spliceKit_selectModuleOwner:(id)sender; +@end + +@implementation LKPreferences (SKPanelHook) + +- (void)spliceKit_showPreferencesPanel { + // Call original first so the window, toolbar, and native modules are ready. + [self spliceKit_showPreferencesPanel]; + + id panel = ((id (*)(id,SEL))objc_msgSend)(self, @selector(_preferencesPanel)); + NSToolbar *toolbar = [(NSWindow *)panel toolbar]; + if (!toolbar) return; + + // _selectModuleOwner: requires every tab's view to be pre-cached in + // _masterPreferenceViews. Native tabs are not there yet after our changes; + // populate them now while native modules are fully initialised in window context. + NSMutableArray *titles = SKPrefs_getIvar(self, "_preferenceTitles"); + NSMutableArray *modules = SKPrefs_getIvar(self, "_preferenceModules"); + NSMutableDictionary *master = SKPrefs_getIvar(self, "_masterPreferenceViews"); + + if ([titles isKindOfClass:[NSArray class]] && + [modules isKindOfClass:[NSArray class]] && + [master isKindOfClass:[NSMutableDictionary class]]) { + SEL viewSel = @selector(viewForPreferenceNamed:); + for (NSUInteger i = 0; i < titles.count; i++) { + NSString *t = titles[i]; + if (master[t]) continue; + id m = modules[i]; + if ([m respondsToSelector:viewSel]) { + NSView *v = ((id (*)(id, SEL, id))objc_msgSend)(m, viewSel, t); + if (v) { + master[t] = v; + SpliceKit_log(@"Pre-cached view for %@: %@", t, NSStringFromClass([v class])); + } + } + } + } + + // Insert SpliceKit and Debug toolbar items if not already present. + NSArray *toAdd = @[@"SpliceKit", @"Debug"]; + for (NSString *ident in toAdd) { + BOOL found = NO; + for (NSToolbarItem *item in toolbar.items) { + if ([item.itemIdentifier isEqualToString:ident]) { found = YES; break; } + } + if (found) continue; + NSUInteger insertIdx = toolbar.items.count; + [toolbar insertItemWithItemIdentifier:ident atIndex:insertIdx]; + SpliceKit_log(@"Inserted %@ toolbar item at index %lu", ident, (unsigned long)insertIdx); + } + + // Enforce a minimum window width wide enough for all toolbar items. + // Without this, narrow panes (e.g. Editing) cause overflow arrows to appear. + if ([panel respondsToSelector:@selector(setMinSize:)]) { + NSSize minSize = [(NSWindow *)panel minSize]; + // ~85px per item × 7 items + padding. Measure from actual toolbar if possible. + CGFloat needed = toolbar.items.count * 85.0 + 40.0; + if (minSize.width < needed) { + minSize.width = needed; + [(NSWindow *)panel setMinSize:minSize]; + } + } +} + +// Full replacement for _selectModuleOwner: — the original has an undiagnosed +// early-return that prevents native tabs from switching. We look up the view +// from _masterPreferenceViews (pre-populated in showPreferencesPanel above) +// and set it directly on _preferenceBox, bypassing whatever gate the original uses. +- (void)spliceKit_selectModuleOwner:(id)sender { + NSString *identifier = nil; + if ([sender respondsToSelector:@selector(itemIdentifier)]) { + identifier = [sender itemIdentifier]; + } + // Real toolbar clicks arrive as NSToolbarButton (no itemIdentifier). + // Get the selected tab from the toolbar instead. + if (!identifier) { + id panel = ((id (*)(id,SEL))objc_msgSend)(self, @selector(_preferencesPanel)); + if ([panel respondsToSelector:@selector(toolbar)]) { + identifier = [[panel toolbar] selectedItemIdentifier]; + } + } + if (!identifier) { [self spliceKit_selectModuleOwner:sender]; return; } + + NSMutableDictionary *master = SKPrefs_getIvar(self, "_masterPreferenceViews"); + NSView *view = master[identifier]; + if (!view) { [self spliceKit_selectModuleOwner:sender]; return; } + + id boxRaw = SKPrefs_getIvar(self, "_preferenceBox"); + if (![boxRaw isKindOfClass:[NSBox class]]) { [self spliceKit_selectModuleOwner:sender]; return; } + NSBox *box = (NSBox *)boxRaw; + + // Find the module for this tab. + NSArray *titles = SKPrefs_getIvar(self, "_preferenceTitles"); + NSArray *modules = SKPrefs_getIvar(self, "_preferenceModules"); + NSUInteger idx = [titles indexOfObject:identifier]; + id module = (idx != NSNotFound && idx < modules.count) ? modules[idx] : nil; + + // Populate controls and patch targets BEFORE placing in the window. + // initializeFromDefaults can trigger Auto Layout passes; doing this while + // the view is detached prevents it from locking in a wrong frame size. + if (module) { + SEL initSel = @selector(initializeFromDefaults); + if ([module respondsToSelector:initSel]) { + ((void (*)(id, SEL))objc_msgSend)(module, initSel); + } + Class walkClass = [module class]; + while (walkClass && walkClass != [NSObject class]) { + unsigned int ivarCount = 0; + Ivar *ivars = class_copyIvarList(walkClass, &ivarCount); + for (unsigned int i = 0; i < ivarCount; i++) { + const char *enc = ivar_getTypeEncoding(ivars[i]); + if (!enc || enc[0] != '@') continue; + id val = object_getIvar(module, ivars[i]); + if (!val) continue; + if (![val isKindOfClass:[NSControl class]]) continue; + NSControl *ctl = (NSControl *)val; + if (ctl.target == nil && ctl.action != NULL) ctl.target = module; + } + if (ivars) free(ivars); + walkClass = class_getSuperclass(walkClass); + } + } + + // Set _currentModule so updatePanelFrameWithOwner: knows the target size. + if (module) { + Ivar ivCurrent = class_getInstanceVariable(object_getClass(self), "_currentModule"); + if (ivCurrent) object_setIvar(self, ivCurrent, module); + } + + // Resize the window to the new pane's size, then place the content view. + SEL updateOwner = @selector(updatePanelFrameWithOwner:isChangingPanes:animated:); + if (module && [self respondsToSelector:updateOwner]) { + ((void (*)(id, SEL, id, BOOL, BOOL))objc_msgSend)(self, updateOwner, module, YES, NO); + } else { + SEL updateFrame = @selector(updatePanelFrameAnimated:); + if ([self respondsToSelector:updateFrame]) { + ((void (*)(id, SEL, BOOL))objc_msgSend)(self, updateFrame, NO); + } + } + + if ([view isKindOfClass:[NSScrollView class]]) { + view.autoresizingMask = NSViewWidthSizable | NSViewHeightSizable; + } + [box setContentView:view]; + + // Cache in session views. + NSMutableDictionary *session = SKPrefs_getIvar(self, "_currentSessionPreferenceViews"); + if ([session isKindOfClass:[NSMutableDictionary class]]) session[identifier] = view; + + // Update toolbar selection (resize already done above before setContentView:). + id panel = ((id (*)(id,SEL))objc_msgSend)(self, @selector(_preferencesPanel)); + if ([panel respondsToSelector:@selector(toolbar)]) { + [[panel toolbar] setSelectedItemIdentifier:identifier]; + } + + SpliceKit_log(@"SKTabSwitch → %@", identifier); +} + +@end + +static void SKPrefs_swizzleShowPanel(void) { + Class cls = objc_getClass("LKPreferences"); + if (!cls) return; + + void (^swizzle)(SEL, SEL, NSString *) = ^(SEL orig, SEL swiz, NSString *name) { + Method origM = class_getInstanceMethod(cls, orig); + Method swizM = class_getInstanceMethod(cls, swiz); + if (origM && swizM) { + method_exchangeImplementations(origM, swizM); + SpliceKit_log(@"Swizzled LKPreferences.%@", name); + } + }; + + swizzle(@selector(showPreferencesPanel), + @selector(spliceKit_showPreferencesPanel), + @"showPreferencesPanel"); + + swizzle(@selector(_selectModuleOwner:), + @selector(spliceKit_selectModuleOwner:), + @"_selectModuleOwner:"); +} diff --git a/Sources/SpliceKitReplaceAtPlayhead.m b/Sources/SpliceKitReplaceAtPlayhead.m new file mode 100644 index 0000000..4f42f28 --- /dev/null +++ b/Sources/SpliceKitReplaceAtPlayhead.m @@ -0,0 +1,1089 @@ +// SpliceKitReplaceAtPlayhead.m +// +// Restores the "Replace at Playhead" edit mode that Apple removed from FCP 12.2's UI. +// The underlying ObjC methods still exist — Apple only removed all UI entry points. +// +// This module re-wires it in two places: +// +// 1. The drag-drop menu shown when hovering a clip over the timeline. +// In FCP 12.2 the Replace overlay is a plain NSMenu (LKMenu) stored as +// FFAnchoredTimelineModule.dropMenu — not OZDropMenuWindow. +// Swizzles FFAnchoredTimelineModule.init and _startListeningToSequence: to +// append "Replace at Playhead" (→ actionDropMenuReplaceAtPlayhead:) after +// "Replace from End". actionDropMenuReplaceAtPlayhead: is also swizzled to +// fix its broken duration handling: the original performs a full replace +// (changes target duration), so we trim the result back to the original +// target duration after the replace. +// +// 2. FCP's Command Editor (View > Commands > Customize). +// Registers the LKCommand and binds Option+Shift+R directly at install time +// (LKCommandsController._loadCommands has already fired by the time our +// swizzle is installed, so we can't rely on the swizzle for initial setup). +// _loadCommands and _setActiveCommandSet: swizzles re-apply on future reloads +// and command-set switches. + +#import "SpliceKit.h" +#import +#import +#import + +// CMTime struct (matches CoreMedia layout without linking the framework). +typedef struct { int64_t value; int32_t timescale; uint32_t flags; int64_t epoch; } SK_CMTime; +#define SK_kCMTimeInvalid ((SK_CMTime){0, 0, 0, 0}) +static inline int SK_CMTimeIsValid(SK_CMTime t) { return t.timescale != 0; } +static inline int SK_CMTimeCompare(SK_CMTime a, SK_CMTime b) { + if (a.timescale == 0 || b.timescale == 0) return 0; + // Convert to common timescale via cross-multiply to avoid overflow on typical values. + __int128 av = (__int128)a.value * b.timescale; + __int128 bv = (__int128)b.value * a.timescale; + return (av > bv) - (av < bv); +} +static inline SK_CMTime SK_CMTimeSubtract(SK_CMTime a, SK_CMTime b) { + if (a.timescale == b.timescale) + return (SK_CMTime){a.value - b.value, a.timescale, a.flags, 0}; + // Normalize b to a's timescale to avoid int32 overflow when timescales differ + // (e.g. 48000 × 90000 = 4.3B > INT32_MAX). Rounding error < 1/a.timescale seconds. + int64_t b_in_a = ((int64_t)b.value * a.timescale + b.timescale / 2) / b.timescale; + return (SK_CMTime){a.value - b_in_a, a.timescale, a.flags, 0}; +} +static inline SK_CMTime SK_CMTimeAdd(SK_CMTime a, SK_CMTime b) { + if (a.timescale == b.timescale) + return (SK_CMTime){a.value + b.value, a.timescale, a.flags, 0}; + int64_t b_in_a = ((int64_t)b.value * a.timescale + b.timescale / 2) / b.timescale; + return (SK_CMTime){a.value + b_in_a, a.timescale, a.flags, 0}; +} +static inline SK_CMTime SK_CMTimeNegate(SK_CMTime t) { t.value = -t.value; return t; } +static inline double SK_CMTimeGetSeconds(SK_CMTime t) { + return t.timescale ? (double)t.value / t.timescale : 0.0; +} + +// --------------------------------------------------------------------------- +// 1. Drag-drop menu injection +// --------------------------------------------------------------------------- +// FFAnchoredTimelineModule keeps a pre-built LKMenu in its `dropMenu` ivar. +// It's populated during -init and reused for every drag. We inject our item +// once per instance. +// +// actionDropMenuReplaceAtPlayhead: exists but is broken in FCP 12.2: it does a +// full replace that changes the target clip's duration to the full source length +// (same as plain "Replace"), instead of trimming the source to fit the target. +// We swizzle it to: +// 1. Run the original (which correctly uses the browser skimmer as source in-point) +// 2. Trim the replacement clip's end back to the original target's duration + +// CMTimeRange: two consecutive CMTime values. +typedef struct { SK_CMTime start; SK_CMTime duration; } SK_CMTimeRange; + +static SK_CMTime SpliceKit_readCMTime(id obj, SEL sel) { + SK_CMTime t = SK_kCMTimeInvalid; + NSMethodSignature *sig = [obj methodSignatureForSelector:sel]; + if (!sig) return t; + NSInvocation *inv = [NSInvocation invocationWithMethodSignature:sig]; + inv.target = obj; inv.selector = sel; + [inv invoke]; + [inv getReturnValue:&t]; + return t; +} + +static SK_CMTimeRange SpliceKit_readCMTimeRange(id obj, SEL sel) { + SK_CMTimeRange r = { SK_kCMTimeInvalid, SK_kCMTimeInvalid }; + NSMethodSignature *sig = [obj methodSignatureForSelector:sel]; + if (!sig) return r; + NSInvocation *inv = [NSInvocation invocationWithMethodSignature:sig]; + inv.target = obj; inv.selector = sel; + [inv invoke]; + [inv getReturnValue:&r]; + return r; +} + +static void (*sOrigActionDropMenuReplaceAtPlayhead)(id, SEL, id); + +// Target clip geometry captured in replaceWithPasteboard: (module level, has _deferredDropTargetItem) +// and consumed in operationReplaceItem:withItems: (sequence level). +// Both methods execute on the main thread, back-to-back in the same call stack. +static SK_CMTime sTargetClipStart = {0, 0, 0, 0}; +static SK_CMTime sTargetClipDur = {0, 0, 0, 0}; + +// Keyboard path geometry, set in SpliceKit_replaceWithSelectedMediaAtPlayhead BEFORE +// performEditAction: fires, consumed in SpliceKit_seqOperationReplaceItem. +// sKBReplaceInFlight gates the keyboard handling path to avoid interfering with other +// replace operations. +static SK_CMTime sKBTargetClipStart = {0, 0, 0, 0}; +static SK_CMTime sKBTargetClipDur = {0, 0, 0, 0}; +static SK_CMTime sKBDesiredSrcIn = {0, 0, 0, 0}; // pre-computed srcIn (SPH=0 for keyboard path) +static SK_CMTime sKBOrgDesiredStart = {0, 0, 0, 0}; // organizer-cumulative start = orgClipStart + desiredSrcIn +static BOOL sKBReplaceInFlight = NO; + +// Swizzle on replaceWithPasteboard:replaceActionType: (FFAnchoredTimelineModule). +// Fires just before the drag-drop replace is executed. Captures target clip geometry +// (start position and duration) by walking containedItems and matching _deferredDropTargetItem +// by pointer identity. Results are consumed in SpliceKit_seqOperationReplaceItem. +static void (*sOrigReplaceWithPasteboard)(id, SEL, id, int); + +static void SpliceKit_replaceWithPasteboard(id self, SEL _cmd, id pasteboard, int actionType) { + if (actionType == 1) { + Ivar tgtIvar = class_getInstanceVariable(object_getClass(self), "_deferredDropTargetItem"); + id tgt = tgtIvar ? object_getIvar(self, tgtIvar) : nil; + SEL durSel = NSSelectorFromString(@"duration"); + SEL sequenceSel = NSSelectorFromString(@"sequence"); + SEL primaryObjSel = NSSelectorFromString(@"primaryObject"); + SEL containedItemsSel = NSSelectorFromString(@"containedItems"); + + sTargetClipDur = (tgt && [tgt respondsToSelector:durSel]) + ? SpliceKit_readCMTime(tgt, durSel) : SK_kCMTimeInvalid; + + // Compute the target clip's absolute timeline start by walking the primary storyline + // and matching by pointer identity. FFAnchoredCollection clips always return 0 from + // timeOffset; position is cumulative sum of preceding durations. + sTargetClipStart = SK_kCMTimeInvalid; + if (tgt) { + id sequence = [self respondsToSelector:sequenceSel] + ? ((id (*)(id, SEL))objc_msgSend)(self, sequenceSel) : nil; + id primary = (sequence && [sequence respondsToSelector:primaryObjSel]) + ? ((id (*)(id, SEL))objc_msgSend)(sequence, primaryObjSel) : nil; + if (primary && [primary respondsToSelector:containedItemsSel]) { + NSArray *clips = ((NSArray *(*)(id, SEL))objc_msgSend)(primary, containedItemsSel); + SK_CMTime cursor = {0, 90000, 1, 0}; + for (id item in clips) { + if (item == tgt) { sTargetClipStart = cursor; break; } + SK_CMTime d = SpliceKit_readCMTime(item, durSel); + if (SK_CMTimeIsValid(d)) cursor = SK_CMTimeAdd(cursor, d); + } + } + } + SpliceKit_log(@"[ReplaceAtPlayhead] replaceWithPasteboard type=1: T1=%.3fs(v=%d) dur=%.3fs(v=%d)", + SK_CMTimeGetSeconds(sTargetClipStart), SK_CMTimeIsValid(sTargetClipStart), + SK_CMTimeGetSeconds(sTargetClipDur), SK_CMTimeIsValid(sTargetClipDur)); + } + sOrigReplaceWithPasteboard(self, _cmd, pasteboard, actionType); +} + +// --------------------------------------------------------------------------- +// Swizzle on FFAnchoredSequence.operationReplaceItem:withItems:... +// Intercepts the structural replace to apply the correct source in-point +// immediately inside the operation context (the only safe place for all clip types). +// On arm64, CMTime (24 bytes > 16) is passed as a pointer-to-copy by the ABI. +// --------------------------------------------------------------------------- + +// The function pointer type must match the actual arm64 calling convention: +// large structs (>16 bytes) are passed by hidden pointer, but since clang +// lowers CMTime the same way in both the IMP and in our wrapper, it just works. +typedef BOOL (*OrigSeqReplaceItemFn)(id, SEL, id, id, int, id *, SK_CMTime, SK_CMTime, BOOL); +static OrigSeqReplaceItemFn sOrigSeqOperationReplaceItem; + +static BOOL SpliceKit_seqOperationReplaceItem(id self, SEL _cmd, + id replaceItem, + id withItems, + int actionType, + id *editsAdded, + SK_CMTime playheadTime, + SK_CMTime sourcePlayheadTime, + BOOL autoCancelOnFail) { + SK_CMTime targetStart = SK_kCMTimeInvalid; + SK_CMTime targetDur = SK_kCMTimeInvalid; + SK_CMTime effectivePH = playheadTime; + SK_CMTime effectiveSPH = sourcePlayheadTime; + SK_CMTime kbDesiredSrcIn = SK_kCMTimeInvalid; + SK_CMTime kbOrgDesiredStart = SK_kCMTimeInvalid; + BOOL isKBPath = NO; + + if (actionType == 1 && sKBReplaceInFlight) { + // Keyboard path: type=1 with our pre-armed geometry. + isKBPath = YES; + targetStart = sKBTargetClipStart; + targetDur = sKBTargetClipDur; + kbDesiredSrcIn = sKBDesiredSrcIn; + kbOrgDesiredStart = sKBOrgDesiredStart; + sKBReplaceInFlight = NO; + sKBTargetClipStart = SK_kCMTimeInvalid; + sKBTargetClipDur = SK_kCMTimeInvalid; + sKBDesiredSrcIn = SK_kCMTimeInvalid; + sKBOrgDesiredStart = SK_kCMTimeInvalid; + SpliceKit_log(@"[ReplaceAtPlayhead] seqOpReplace KB: PH=%.3fs SPH=%.3fs " + "T1=%.3fs dur=%.3fs srcIn=%.3fs", + SK_CMTimeGetSeconds(playheadTime), SK_CMTimeGetSeconds(sourcePlayheadTime), + SK_CMTimeGetSeconds(targetStart), SK_CMTimeGetSeconds(targetDur), + SK_CMTimeGetSeconds(kbDesiredSrcIn)); + } else if (actionType == 1 && !sKBReplaceInFlight) { + // Drag-drop path: geometry from replaceWithPasteboard: swizzle. + targetStart = sTargetClipStart; + targetDur = sTargetClipDur; + sTargetClipStart = SK_kCMTimeInvalid; + sTargetClipDur = SK_kCMTimeInvalid; + } else if (sKBReplaceInFlight) { + // KB path fired with unexpected actionType — FCP changed its internal routing. + sKBReplaceInFlight = NO; + sKBTargetClipStart = SK_kCMTimeInvalid; + sKBTargetClipDur = SK_kCMTimeInvalid; + sKBDesiredSrcIn = SK_kCMTimeInvalid; + sKBOrgDesiredStart = SK_kCMTimeInvalid; + SpliceKit_log(@"[ReplaceAtPlayhead] seqOpReplace KB unexpected actionType=%d — passthrough", + actionType); + return sOrigSeqOperationReplaceItem(self, _cmd, replaceItem, withItems, actionType, + editsAdded, playheadTime, sourcePlayheadTime, + autoCancelOnFail); + } else { + return sOrigSeqOperationReplaceItem(self, _cmd, replaceItem, withItems, actionType, + editsAdded, playheadTime, sourcePlayheadTime, + autoCancelOnFail); + } + + // ── KB pre-correction: substitute withItems with a correctly-ranged FigTimeRangeAndObject ── + // withItems[0] is a FigTimeRangeAndObject from selectedRangesOfMedia. Its + // clippedRange = {orgCursor, 0} because the user has a cursor (no range selected). + // FCP uses clippedRange.start directly as the source in-point (SPH=0 → no phOffset + // adjustment) and clippedRange.duration to size the placed clip → 1-frame stub. + // + // Fix: create a new FigTimeRangeAndObject{start=kbDesiredSrcIn, dur=targetDur} + // keeping the same .object (the media source). FCP then places the clip at exactly + // the right source in-point with the correct duration, without any post-correction. + id substitutedWithItems = withItems; + // Use organizer-cumulative coords for clippedRange.start: + // FCP interprets clippedRange.start as organizer position (not raw source coords). + // kbOrgDesiredStart = orgClipStart + desiredSrcIn → raw srcIn = desiredSrcIn ✓ + // Passing raw kbDesiredSrcIn caused: range-outside-organizer rejection (large orgClipStart) + // or wrong raw placement (small orgClipStart). + if (isKBPath && SK_CMTimeIsValid(kbOrgDesiredStart) && SK_CMTimeIsValid(targetDur)) { + Class figClass = NSClassFromString(@"FigTimeRangeAndObject"); + SEL factorySel = NSSelectorFromString(@"rangeAndObjectWithRange:andObject:"); + id firstItem = [withItems isKindOfClass:[NSArray class]] + ? [(NSArray *)withItems firstObject] : withItems; + SEL objSel0 = NSSelectorFromString(@"object"); + id origObj = (firstItem && [firstItem respondsToSelector:objSel0]) + ? ((id (*)(id, SEL))objc_msgSend)(firstItem, objSel0) : nil; + if (figClass && origObj) { + SK_CMTimeRange newRange = { kbOrgDesiredStart, targetDur }; + id newItem = ((id (*)(Class, SEL, SK_CMTimeRange, id))objc_msgSend)( + figClass, factorySel, newRange, origObj); + if (newItem) { + substitutedWithItems = @[newItem]; + SpliceKit_log(@"[ReplaceAtPlayhead] KB withItems substituted: " + "orgStart=%.3fs rawSrcIn=%.3fs dur=%.3fs", + SK_CMTimeGetSeconds(kbOrgDesiredStart), + SK_CMTimeGetSeconds(kbDesiredSrcIn), + SK_CMTimeGetSeconds(targetDur)); + } else { + SpliceKit_log(@"[ReplaceAtPlayhead] KB FigTimeRangeAndObject factory returned nil"); + } + } else { + SpliceKit_log(@"[ReplaceAtPlayhead] KB withItems substitution failed: " + "figClass=%@ origObj=%@", + NSStringFromClass(figClass), origObj); + } + } + + id __autoreleasing localEdits = nil; + id __autoreleasing *editsPtr = editsAdded ? (id __autoreleasing *)editsAdded : &localEdits; + BOOL result = sOrigSeqOperationReplaceItem(self, _cmd, replaceItem, substitutedWithItems, + actionType, (id *)editsPtr, playheadTime, + sourcePlayheadTime, autoCancelOnFail); + if (!result) return result; + + // ── KB path: FigTimeRangeAndObject substitution already placed at kbDesiredSrcIn ── + // We pre-set withItems[0].clippedRange = {kbDesiredSrcIn, targetDur} above. + // FCP places within sub-frame rounding (~0.02s) — no secondary setTimeRangeInAsset: + // needed. The secondary call previously caused FCP's post-op validation to crash. + if (isKBPath) { + SpliceKit_log(@"[ReplaceAtPlayhead] KB placed (no correction): srcIn≈%.3fs dur≈%.3fs", + SK_CMTimeGetSeconds(kbDesiredSrcIn), SK_CMTimeGetSeconds(targetDur)); + return result; + } + + // ── Drag-drop path: post-correction (srcIn via setTimeRangeInAsset:) ───── + id added = editsPtr ? *editsPtr : nil; + id wrapper = [added isKindOfClass:[NSArray class]] ? [(NSArray *)added firstObject] : added; + if (!wrapper) { + SpliceKit_log(@"[ReplaceAtPlayhead] editsAdded empty — alignment not applied"); + return result; + } + SEL objSel = NSSelectorFromString(@"object"); + id newClip = [wrapper respondsToSelector:objSel] + ? ((id (*)(id, SEL))objc_msgSend)(wrapper, objSel) + : wrapper; + + SpliceKit_log(@"[ReplaceAtPlayhead] type=%d T1=%.3fs(v=%d) PH=%.3fs(v=%d) SPH=%.3fs dur=%.3fs(v=%d)", + actionType, + SK_CMTimeGetSeconds(targetStart), SK_CMTimeIsValid(targetStart), + SK_CMTimeGetSeconds(effectivePH), SK_CMTimeIsValid(effectivePH), + SK_CMTimeGetSeconds(effectiveSPH), + SK_CMTimeGetSeconds(targetDur), SK_CMTimeIsValid(targetDur)); + + if (!SK_CMTimeIsValid(effectivePH) || !SK_CMTimeIsValid(targetStart) || !SK_CMTimeIsValid(targetDur)) { + SpliceKit_log(@"[ReplaceAtPlayhead] geometry incomplete — skip trim"); + return result; + } + + // Drag-drop: srcIn = effectiveSPH − (playheadTime − targetStart) + // effectiveSPH = browser skimmer position (source-media coords for drag-drop type=1) + SK_CMTime phOffset = SK_CMTimeSubtract(effectivePH, targetStart); + SK_CMTime srcIn = SK_CMTimeSubtract(effectiveSPH, phOffset); + if (SK_CMTimeCompare(srcIn, (SK_CMTime){0, srcIn.timescale, 1, 0}) < 0) + srcIn = (SK_CMTime){0, srcIn.timescale, 1, 0}; + + SEL startSel2 = NSSelectorFromString(@"start"); + SK_CMTime placedOrgClipStart = [newClip respondsToSelector:startSel2] + ? SpliceKit_readCMTime(newClip, startSel2) : SK_kCMTimeInvalid; + SK_CMTime correctedStart = (SK_CMTimeIsValid(placedOrgClipStart) && + SK_CMTimeGetSeconds(placedOrgClipStart) > 1.0) + ? SK_CMTimeAdd(placedOrgClipStart, srcIn) + : srcIn; + + SEL crSel = NSSelectorFromString(@"clippedRange"); + SK_CMTimeRange crBefore = { SK_kCMTimeInvalid, SK_kCMTimeInvalid }; + if ([newClip respondsToSelector:crSel]) + crBefore = SpliceKit_readCMTimeRange(newClip, crSel); + SpliceKit_log(@"[ReplaceAtPlayhead] pre-correct: clippedRange={%.3fs,%.3fs} " + "placedOrgClipStart=%.3fs wantSrcIn=%.3fs correctedStart=%.3fs", + SK_CMTimeGetSeconds(crBefore.start), SK_CMTimeGetSeconds(crBefore.duration), + SK_CMTimeGetSeconds(placedOrgClipStart), + SK_CMTimeGetSeconds(srcIn), SK_CMTimeGetSeconds(correctedStart)); + + SEL setRangeSel = NSSelectorFromString(@"setTimeRangeInAsset:"); + if (![newClip respondsToSelector:setRangeSel]) { + SpliceKit_log(@"[ReplaceAtPlayhead] setTimeRangeInAsset: not found on %@", + NSStringFromClass(object_getClass(newClip))); + return result; + } + + SK_CMTimeRange newRange = { correctedStart, targetDur }; + NSMethodSignature *ms = [newClip methodSignatureForSelector:setRangeSel]; + NSInvocation *inv = [NSInvocation invocationWithMethodSignature:ms]; + inv.target = newClip; + inv.selector = setRangeSel; + [inv setArgument:&newRange atIndex:2]; + [inv invoke]; + + SK_CMTimeRange crAfter = { SK_kCMTimeInvalid, SK_kCMTimeInvalid }; + if ([newClip respondsToSelector:crSel]) + crAfter = SpliceKit_readCMTimeRange(newClip, crSel); + SK_CMTime durAfter = SpliceKit_readCMTime(newClip, NSSelectorFromString(@"duration")); + SpliceKit_log(@"[ReplaceAtPlayhead] post-correct: clippedRange={%.3fs,%.3fs} timelineDur=%.3fs " + "effectiveSrcIn=%.3fs", + SK_CMTimeGetSeconds(crAfter.start), SK_CMTimeGetSeconds(crAfter.duration), + SK_CMTimeGetSeconds(durAfter), + SK_CMTimeGetSeconds(SK_CMTimeSubtract(crAfter.start, placedOrgClipStart))); + return result; +} + +static void SpliceKit_actionDropMenuReplaceAtPlayhead(id self, SEL _cmd, id sender) { + sOrigActionDropMenuReplaceAtPlayhead(self, _cmd, sender); +} + +static void SpliceKit_injectReplaceAtPlayheadMenuItem(id module) { + Ivar ivar = class_getInstanceVariable(object_getClass(module), "dropMenu"); + if (!ivar) return; + id menu = object_getIvar(module, ivar); + if (!menu) return; + + SEL rapSel = NSSelectorFromString(@"actionDropMenuReplaceAtPlayhead:"); + SEL fromEndSel = NSSelectorFromString(@"actionDropMenuReplaceFromEnd:"); + + // Guard: don't inject twice (check by title). + if ([(NSMenu *)menu indexOfItemWithTitle:@"Replace at Playhead"] != -1) return; + + NSMenuItem *item = [[NSMenuItem alloc] initWithTitle:@"Replace at Playhead" + action:rapSel + keyEquivalent:@""]; + NSInteger idx = [(NSMenu *)menu indexOfItemWithTarget:nil andAction:fromEndSel]; + if (idx == -1) idx = 2; + [(NSMenu *)menu insertItem:item atIndex:idx + 1]; + + SpliceKit_log(@"[ReplaceAtPlayhead] Injected 'Replace at Playhead' into dropMenu at index %ld", + (long)(idx + 1)); +} + +static id (*sOrigFFAtmInit)(id, SEL); + +static id SpliceKit_FFAtmInit(id self, SEL _cmd) { + id result = sOrigFFAtmInit(self, _cmd); + if (result) SpliceKit_injectReplaceAtPlayheadMenuItem(result); + return result; +} + +// _startListeningToSequence: fires when a project is loaded into the timeline. +// Use it as the reliable post-init injection point for the already-live instance. +// (init swizzle handles future instances; this handles the startup case where +// the module was created before our init swizzle was installed.) +static void (*sOrigStartListeningToSequence)(id, SEL, id); + +static void SpliceKit_startListeningToSequence(id self, SEL _cmd, id sequence) { + sOrigStartListeningToSequence(self, _cmd, sequence); + SpliceKit_injectReplaceAtPlayheadMenuItem(self); +} + +// --------------------------------------------------------------------------- +// 2. Command Editor registration +// --------------------------------------------------------------------------- + +static id sReplaceAtPlayheadCmd = nil; + +// Creates the LKCommand, registers it with the controller, and binds the +// Option+Shift+R shortcut to the given command set. Safe to call multiple +// times — skips command creation after the first call. +static void SpliceKit_applyReplaceAtPlayheadCommand(id controller, id commandSet) { + if (!sReplaceAtPlayheadCmd) { + Class cmdClass = objc_getClass("LKCommand"); + if (!cmdClass) return; + + SEL initSel = NSSelectorFromString(@"initWithCommandIdentifier:action:"); + if (![cmdClass instancesRespondToSelector:initSel]) return; + + id cmd = ((id (*)(id, SEL, NSString *, SEL))objc_msgSend)( + [cmdClass alloc], initSel, + @"ReplaceWithSelectedMediaAtPlayhead", + NSSelectorFromString(@"replaceWithSelectedMediaAtPlayhead:")); + if (!cmd) return; + + SEL registerSel = NSSelectorFromString(@"registerCommand:"); + if ([controller respondsToSelector:registerSel]) + ((void (*)(id, SEL, id))objc_msgSend)(controller, registerSel, cmd); + + // Add to the same "Editing" group as Replace From Start / From End / Whole Clip + // so the command appears in the Command Editor under the right category. + SEL addToGroupSel = NSSelectorFromString(@"addCommandWithIdentifier:toGroupWithIdentifier:"); + if ([controller respondsToSelector:addToGroupSel]) + ((void (*)(id, SEL, NSString *, NSString *))objc_msgSend)( + controller, addToGroupSel, + @"ReplaceWithSelectedMediaAtPlayhead", + @"Editing"); + + sReplaceAtPlayheadCmd = cmd; + SpliceKit_log(@"[ReplaceAtPlayhead] Registered LKCommand ReplaceWithSelectedMediaAtPlayhead"); + } + + if (!commandSet) return; + + // Option (NSAlternateKeyMask = 1<<19) | Shift (NSShiftKeyMask = 1<<17) = 0xA0000 = 655360 + SEL addSel = NSSelectorFromString(@"_addCommand:withCharacter:modifierMask:"); + if ([commandSet respondsToSelector:addSel]) { + ((void (*)(id, SEL, id, unichar, NSUInteger))objc_msgSend)( + commandSet, addSel, + sReplaceAtPlayheadCmd, + (unichar)'r', + (NSUInteger)655360); + } +} + +// Forward declarations for the retain-attributes command (defined below). +static id sReplaceRetainingAttrCmd; +static void SpliceKit_applyReplaceRetainingAttributesCommand(id controller); + +// Re-register on future _loadCommands calls (e.g. Command Editor reloads). +static void (*sOrigLoadCommands)(id, SEL); + +static void SpliceKit_loadCommands(id self, SEL _cmd) { + sOrigLoadCommands(self, _cmd); + SEL activeSetSel = NSSelectorFromString(@"_activeCommandSet"); + id commandSet = ((id (*)(id, SEL))objc_msgSend)(self, activeSetSel); + // Reset so both commands re-register after the reload. + sReplaceAtPlayheadCmd = nil; + SpliceKit_applyReplaceAtPlayheadCommand(self, commandSet); + sReplaceRetainingAttrCmd = nil; + SpliceKit_applyReplaceRetainingAttributesCommand(self); +} + +// Re-bind the shortcut whenever a new command set becomes active. +static void (*sOrigSetActiveCommandSet)(id, SEL, id); + +static void SpliceKit_setActiveCommandSet(id self, SEL _cmd, id commandSet) { + sOrigSetActiveCommandSet(self, _cmd, commandSet); + if (commandSet) + SpliceKit_applyReplaceAtPlayheadCommand(self, commandSet); +} + +// --------------------------------------------------------------------------- +// 2b. Replace and Retain Attributes command (⌥⇧⌘R) +// --------------------------------------------------------------------------- +// Replaces the timeline clip at the playhead with the browser selection while +// preserving all effects, color corrections, transforms, and audio attributes +// of the original clip. +// +// Strategy: +// 1. Select clip at playhead → copyAttributes: (writes to FCP effects clipboard) +// 2. replaceWithSelectedMediaAtPlayhead: (uses a private named pasteboard — no conflict) +// 3. selectClipAtPlayhead: → pasteEffects: (restores all attributes, no dialog) + +static void SpliceKit_applyReplaceRetainingAttributesCommand(id controller) { + if (sReplaceRetainingAttrCmd) return; + + Class cmdClass = objc_getClass("LKCommand"); + if (!cmdClass) return; + SEL initSel = NSSelectorFromString(@"initWithCommandIdentifier:action:"); + if (![cmdClass instancesRespondToSelector:initSel]) return; + + id cmd = ((id (*)(id, SEL, NSString *, SEL))objc_msgSend)( + [cmdClass alloc], initSel, + @"SKReplaceRetainingAttributes", + NSSelectorFromString(@"SKReplaceRetainingAttributesAction:")); + if (!cmd) return; + + SEL registerSel = NSSelectorFromString(@"registerCommand:"); + if ([controller respondsToSelector:registerSel]) + ((void (*)(id, SEL, id))objc_msgSend)(controller, registerSel, cmd); + + SEL addToGroupSel = NSSelectorFromString(@"addCommandWithIdentifier:toGroupWithIdentifier:"); + if ([controller respondsToSelector:addToGroupSel]) + ((void (*)(id, SEL, NSString *, NSString *))objc_msgSend)( + controller, addToGroupSel, + @"SKReplaceRetainingAttributes", @"Editing"); + + // Bind ⌥⇧⌘R + SEL bindSel = NSSelectorFromString(@"bindCommandWithIdentifier:toKeyEquivalent:modifiers:"); + if ([controller respondsToSelector:bindSel]) { + @try { + ((void (*)(id, SEL, NSString *, NSString *, NSUInteger))objc_msgSend)( + controller, bindSel, + @"SKReplaceRetainingAttributes", + @"r", + NSEventModifierFlagOption | NSEventModifierFlagShift | NSEventModifierFlagCommand); + SpliceKit_log(@"[ReplaceAtPlayhead] Bound ⌥⇧⌘R → SKReplaceRetainingAttributes"); + } @catch (NSException *e) { + SpliceKit_log(@"[ReplaceAtPlayhead] ⌥⇧⌘R binding failed: %@", e); + } + } + + sReplaceRetainingAttrCmd = cmd; + SpliceKit_log(@"[ReplaceAtPlayhead] Registered SKReplaceRetainingAttributes (⌥⇧⌘R)"); +} + +// Adds SKReplaceRetainingAttributesAction: to NSApplication so the LKCommand +// reaches our handler regardless of which view is first responder. +static void SpliceKit_installRetainAttributesActionHandler(void) { + Class appClass = [NSApplication class]; + SEL actionSel = NSSelectorFromString(@"SKReplaceRetainingAttributesAction:"); + if ([appClass instancesRespondToSelector:actionSel]) return; + + IMP impl = imp_implementationWithBlock(^(id __unused appSelf, id sender) { + id app = [NSApplication sharedApplication]; + + // Get the timeline module directly — the user's focus is on the browser + // (they just selected a clip there), so sendAction:to:nil would route copy: + // and pasteEffects: to the browser instead of the timeline. + id tm = SpliceKit_getActiveTimelineModule(); + if (!tm) { + SpliceKit_log(@"[ReplaceAtPlayhead] RetainAttrs: no timeline module"); + return; + } + + // Step 1: Select the timeline clip at the playhead, then copy it directly + // to the timeline module. copy: populates FCP's clip clipboard which + // pasteEffects: reads from. + SEL selectSel = NSSelectorFromString(@"selectClipAtPlayhead:"); + if ([tm respondsToSelector:selectSel]) + ((void(*)(id,SEL,id))objc_msgSend)(tm, selectSel, nil); + + if ([tm respondsToSelector:@selector(copy:)]) { + ((void(*)(id,SEL,id))objc_msgSend)(tm, @selector(copy:), nil); + SpliceKit_log(@"[ReplaceAtPlayhead] RetainAttrs: copied timeline clip"); + } else { + SpliceKit_log(@"[ReplaceAtPlayhead] RetainAttrs: timeline module has no copy: — aborting"); + return; + } + + // Step 2: Replace clip with browser selection at playhead. + // Routes to our SpliceKit_replaceWithSelectedMediaAtPlayhead swizzle, + // which writes to a private named pasteboard, leaving copy:'s data intact. + [app sendAction:NSSelectorFromString(@"replaceWithSelectedMediaAtPlayhead:") to:nil from:nil]; + + // Step 3: Select the new clip and paste all effects (no dialog). + // Deferred one runloop cycle so the timeline model fully settles after replace. + dispatch_async(dispatch_get_main_queue(), ^{ + id tm2 = SpliceKit_getActiveTimelineModule(); + if (!tm2) return; + SEL selSel = NSSelectorFromString(@"selectClipAtPlayhead:"); + if ([tm2 respondsToSelector:selSel]) + ((void(*)(id,SEL,id))objc_msgSend)(tm2, selSel, nil); + // "Paste Effects" (Edit > Paste Effects, ⌘⌥V) fires pasteAllAttributes:, + // not pasteEffects: — confirmed via dry_run on the menu item. + SEL pasteSel = NSSelectorFromString(@"pasteAllAttributes:"); + if ([tm2 respondsToSelector:pasteSel]) { + ((void(*)(id,SEL,id))objc_msgSend)(tm2, pasteSel, nil); + SpliceKit_log(@"[ReplaceAtPlayhead] Replace and Retain Attributes complete"); + } else { + [app sendAction:pasteSel to:nil from:nil]; + SpliceKit_log(@"[ReplaceAtPlayhead] RetainAttrs: pasteAllAttributes: via sendAction (fallback)"); + } + }); + }); + class_addMethod(appClass, actionSel, impl, "v@:@"); + SpliceKit_log(@"[ReplaceAtPlayhead] Installed SKReplaceRetainingAttributesAction: on NSApplication"); +} + +// --------------------------------------------------------------------------- +// 3. Keyboard shortcut path: FFEditActionMgr.replaceWithSelectedMediaAtPlayhead: +// --------------------------------------------------------------------------- +// ⌥⇧R → LKCommand "ReplaceWithSelectedMediaAtPlayhead" → responder chain → +// FFEditActionMgr.replaceWithSelectedMediaAtPlayhead: → our swizzle. +// +// FCP 12.2 broke the original (effectively deletes the clip). Fix strategy: +// +// 1. Read browserTime (source-media offset) and orgClipStart from selection: +// activeEditSourceForToolBar → FFOrganizerFilmstripModule +// selectedRangesOfMedia[0].start = orgCursor (organizer-cumulative) +// selectedRangesOfMedia[0]._object.start = orgClipStart (organizer-cumulative) +// browserTime = orgCursor − orgClipStart = source-media offset ✓ +// +// 2. Serialize browser selection to named pasteboard via +// PEAppController.writeDataForEditAction:toPasteboardWithName: with +// replaceType=2 ("Replace From Start") FFEditAction. +// +// 3. Walk containedItems to find the target clip's timeline start and duration. +// +// 4. Select the clip at the playhead. +// +// 5. Call performEditAction:fromPasteboardWithName:fromAnimation: with the +// Replace From Start action. FCP replaces the clip. +// After this, FCP has called setClippedRange:{orgCursor, targetDur} on the +// new clip (organizer coords: srcIn = orgCursor − orgClipStart = browserTime). +// +// 6. Find the new clip at targetStart. Apply alignment by calling setClippedRange: +// DIRECTLY with organizer coords: +// orgDesiredStart = orgClipStart + desiredSrcIn +// = orgClipStart + (browserTime − (PH − targetStart)) +// setClippedRange:{orgDesiredStart, targetDur} +// → srcIn = orgDesiredStart − orgClipStart = desiredSrcIn ✓ +// +// WHY setClippedRange and not setTimeRangeInAsset: +// Replace-From-Start clips retain their browser orgClipStart (e.g. 40401.127s). +// setTimeRangeInAsset: calls setClippedRange: with raw source-media coords +// (e.g. start=51.67), but setClippedRange: interprets start in organizer +// coords → srcIn = 51.67 − 40401.127 = −40349s → negative → clip deleted. +// Drag-drop (Replace at Playhead) clips have orgClipStart=0, so +// setTimeRangeInAsset: works there. For this keyboard path we must pass +// organizer coords: orgClipStart + desiredSrcIn. + +static void (*sOrigReplaceWithSelectedMediaAtPlayhead)(id, SEL, id); + +static void SpliceKit_replaceWithSelectedMediaAtPlayhead(id self, SEL _cmd, id sender) { + id delegate = [[NSApplication sharedApplication] delegate]; + + // ── Step 1: Capture browser state ──────────────────────────────────────── + // filmstrip and clipObj are hoisted so we can seek them in step 3. + SK_CMTime browserTime = SK_kCMTimeInvalid; + SK_CMTime orgClipStart = SK_kCMTimeInvalid; + SK_CMTime orgCursor = SK_kCMTimeInvalid; // updated after seek; used for guard check + SK_CMTime sourceClipDur = SK_kCMTimeInvalid; + id filmstrip = nil; + id clipObj = nil; + { + SEL editSrcSel = NSSelectorFromString(@"activeEditSourceForToolBar"); + SEL rangesSel = NSSelectorFromString(@"selectedRangesOfMedia"); + SEL startSel = NSSelectorFromString(@"start"); + SEL curSeqTimeSel = NSSelectorFromString(@"currentSequenceTime"); + filmstrip = [delegate respondsToSelector:editSrcSel] + ? ((id (*)(id, SEL))objc_msgSend)(delegate, editSrcSel) : nil; + NSArray *ranges = (filmstrip && [filmstrip respondsToSelector:rangesSel]) + ? ((NSArray *(*)(id, SEL))objc_msgSend)(filmstrip, rangesSel) : nil; + id rangeObj = ranges.count > 0 ? ranges.firstObject : nil; + // currentSequenceTime = organizer-cumulative skimmer/cursor position (NOT clip start). + // rangeObj.start is the clip's start in organizer coords (= orgClipStart), not the cursor. + if (filmstrip && [filmstrip respondsToSelector:curSeqTimeSel]) + orgCursor = SpliceKit_readCMTime(filmstrip, curSeqTimeSel); + if (rangeObj) { + Ivar objIvar = class_getInstanceVariable(object_getClass(rangeObj), "_object"); + clipObj = objIvar ? object_getIvar(rangeObj, objIvar) : nil; + orgClipStart = (clipObj && [clipObj respondsToSelector:startSel]) + ? SpliceKit_readCMTime(clipObj, startSel) : SK_kCMTimeInvalid; + // clippedRange = {orgClipStart, sourceClipDur} in organizer-cumulative coords. + SEL crSel = NSSelectorFromString(@"clippedRange"); + if (clipObj && [clipObj respondsToSelector:crSel]) { + SK_CMTimeRange cr = SpliceKit_readCMTimeRange(clipObj, crSel); + sourceClipDur = cr.duration; + } + if (SK_CMTimeIsValid(orgCursor) && SK_CMTimeIsValid(orgClipStart)) + browserTime = SK_CMTimeSubtract(orgCursor, orgClipStart); + else + browserTime = orgCursor; + SpliceKit_log(@"[ReplaceAtPlayhead-KB] browserTime=%.3fs orgClipStart=%.3fs(v=%d) " + "sourceClipDur=%.3fs(v=%d)", + SK_CMTimeGetSeconds(browserTime), + SK_CMTimeGetSeconds(orgClipStart), SK_CMTimeIsValid(orgClipStart), + SK_CMTimeGetSeconds(sourceClipDur), SK_CMTimeIsValid(sourceClipDur)); + } + } + if (!SK_CMTimeIsValid(browserTime)) { + SpliceKit_log(@"[ReplaceAtPlayhead-KB] no browser selection — fallback"); + sOrigReplaceWithSelectedMediaAtPlayhead(self, _cmd, sender); + return; + } + + // ── Step 2: Gather target clip geometry ────────────────────────────────── + // Done BEFORE writing to the pasteboard so we can compute desiredSrcIn first. + id module = ((id (*)(id, SEL))objc_msgSend)(delegate, NSSelectorFromString(@"timelineModule")); + if (!module) { + SpliceKit_log(@"[ReplaceAtPlayhead-KB] no timelineModule — fallback"); + sOrigReplaceWithSelectedMediaAtPlayhead(self, _cmd, sender); + return; + } + SK_CMTime ph = SpliceKit_readCMTime(module, NSSelectorFromString(@"playheadTime")); + id sequence = ((id (*)(id, SEL))objc_msgSend)(module, NSSelectorFromString(@"sequence")); + id primary = sequence + ? ((id (*)(id, SEL))objc_msgSend)(sequence, NSSelectorFromString(@"primaryObject")) : nil; + SEL containedSel = NSSelectorFromString(@"containedItems"); + SEL durSel = NSSelectorFromString(@"duration"); + + SK_CMTime targetStart = SK_kCMTimeInvalid; + SK_CMTime targetDur = SK_kCMTimeInvalid; + id originalClip = nil; // pointer to clip at playhead BEFORE replace (for guard) + if (SK_CMTimeIsValid(ph) && primary && [primary respondsToSelector:containedSel]) { + NSArray *clips = ((NSArray *(*)(id, SEL))objc_msgSend)(primary, containedSel); + SK_CMTime cursor = {0, 90000, 1, 0}; + for (id item in clips) { + SK_CMTime d = SpliceKit_readCMTime(item, durSel); + if (!SK_CMTimeIsValid(d)) continue; + SK_CMTime end = SK_CMTimeAdd(cursor, d); + if (SK_CMTimeCompare(cursor, ph) <= 0 && SK_CMTimeCompare(ph, end) < 0) { + targetStart = cursor; targetDur = d; originalClip = item; break; + } + cursor = end; + } + } + + SpliceKit_log(@"[ReplaceAtPlayhead-KB] PH=%.3fs BT=%.3fs T1=%.3fs dur=%.3fs", + SK_CMTimeGetSeconds(ph), SK_CMTimeGetSeconds(browserTime), + SK_CMTimeGetSeconds(targetStart), SK_CMTimeGetSeconds(targetDur)); + + // ── Step 3: Compute desiredSrcIn ───────────────────────────────────────── + // "Replace at Playhead": the browser cursor frame appears AT the timeline + // playhead position. srcIn = browserTime − (PH − T1), so that at position + // PH the source shows frame browserTime (= what was under the browser cursor). + SK_CMTime desiredSrcIn = browserTime; // fallback: keep original cursor + SK_CMTime orgDesiredStart = orgCursor; // fallback: keep original cursor + if (SK_CMTimeIsValid(ph) && SK_CMTimeIsValid(targetStart) && + SK_CMTimeIsValid(targetDur) && SK_CMTimeIsValid(orgClipStart)) { + SK_CMTime phOffset = SK_CMTimeSubtract(ph, targetStart); + SK_CMTime raw = SK_CMTimeSubtract(browserTime, phOffset); + if (SK_CMTimeCompare(raw, (SK_CMTime){0, raw.timescale, 1, 0}) < 0) + raw = (SK_CMTime){0, raw.timescale, 1, 0}; + desiredSrcIn = raw; + orgDesiredStart = SK_CMTimeAdd(orgClipStart, desiredSrcIn); + + // Pre-flight: abort if there's not enough source media. + // desiredSrcIn is the actual source in-point we'll apply; if desiredSrcIn + targetDur + // exceeds the source clip's total duration, the replace would produce a black or + // truncated clip. Abort here so the original clip stays intact. + if (SK_CMTimeIsValid(sourceClipDur)) { + SK_CMTime needed = SK_CMTimeAdd(desiredSrcIn, targetDur); + if (SK_CMTimeCompare(needed, sourceClipDur) > 0) { + SpliceKit_log(@"[ReplaceAtPlayhead-KB] not enough source media: " + "desiredSrcIn=%.3fs + dur=%.3fs = %.3fs > sourceClipDur=%.3fs — aborted", + SK_CMTimeGetSeconds(desiredSrcIn), + SK_CMTimeGetSeconds(targetDur), + SK_CMTimeGetSeconds(needed), + SK_CMTimeGetSeconds(sourceClipDur)); + return; + } + } + } + + // ── Step 4: Serialize browser selection to named pasteboard ────────────── + // Use Replace at Playhead (type=1) — NOT Replace From Start (type=2). + // Type=2 places a 0-duration stub that FCP extends AFTER operationReplaceItem: returns, + // conflicting with our in-operation srcIn correction. Type=1 places the clip with + // targetDur from the start, so our setTimeRangeInAsset: only adjusts srcIn. + Class eaClass = objc_getClass("FFEditAction"); + id fromStartAction = ((id (*)(id, SEL, int))objc_msgSend)( + eaClass, NSSelectorFromString(@"editActionOfReplaceType:"), 1); + SEL canSel = NSSelectorFromString(@"canSourceDataForEditAction:"); + if (!((BOOL (*)(id, SEL, id))objc_msgSend)(delegate, canSel, fromStartAction)) { + SpliceKit_log(@"[ReplaceAtPlayhead-KB] canSourceData=NO — fallback"); + sOrigReplaceWithSelectedMediaAtPlayhead(self, _cmd, sender); + return; + } + NSString *pbName = @"com.apple.splicekit.replaceAtPlayhead"; + SEL writeSel = NSSelectorFromString(@"writeDataForEditAction:toPasteboardWithName:"); + if (!((BOOL (*)(id, SEL, id, id))objc_msgSend)(delegate, writeSel, fromStartAction, pbName)) { + SpliceKit_log(@"[ReplaceAtPlayhead-KB] writeDataForEditAction failed — fallback"); + sOrigReplaceWithSelectedMediaAtPlayhead(self, _cmd, sender); + return; + } + + // ── Step 5: Arm keyboard-path statics for operationReplaceItem: swizzle ───── + // Set geometry statics and raise sKBReplaceInFlight BEFORE performEditAction: fires. + // If the operation routes through operationReplaceItem:withItems:, the swizzle + // picks up these values and calls setTimeRangeInAsset: from inside the operation + // (the only safe place for orgClipStart≈0 clips — calling it from outside moves the clip). + // After performEditAction: returns, if sKBReplaceInFlight was cleared, the swizzle + // handled it and we skip the post-correction block below. + if (SK_CMTimeIsValid(targetStart) && SK_CMTimeIsValid(targetDur)) { + sKBTargetClipStart = targetStart; + sKBTargetClipDur = targetDur; + sKBDesiredSrcIn = desiredSrcIn; + sKBOrgDesiredStart = orgDesiredStart; + sKBReplaceInFlight = YES; + } + + // ── Step 5.5: Arm _deferredDropTargetItem so FCP places with targetDur ────── + // FCP reads _deferredDropTargetItem.duration to determine the replacement clip's + // duration in replaceWithPasteboard:replaceActionType:. For keyboard path, + // _deferredDropTargetItem is nil — FCP falls back to the pasteboard clip's extent + // which is 0 (cursor, no range selection) → places a 1-frame stub. + // Temporarily set _deferredDropTargetItem = originalClip so FCP uses targetDur. + Ivar deferredIvar = class_getInstanceVariable(object_getClass(module), "_deferredDropTargetItem"); + id prevDeferred = nil; + if (deferredIvar && originalClip) { + prevDeferred = object_getIvar(module, deferredIvar); + object_setIvar(module, deferredIvar, originalClip); + SpliceKit_log(@"[ReplaceAtPlayhead-KB] armed _deferredDropTargetItem dur=%.3fs", + SK_CMTimeGetSeconds(targetDur)); + } + + // ── Step 6: Select the clip at the playhead ─────────────────────────────── + SEL selectSel = NSSelectorFromString(@"selectClipAtPlayhead:"); + if ([module respondsToSelector:selectSel]) + ((void (*)(id, SEL, id))objc_msgSend)(module, selectSel, nil); + + // ── Step 7: Replace at Playhead via named pasteboard ───────────────────── + SEL performSel = NSSelectorFromString(@"performEditAction:fromPasteboardWithName:fromAnimation:"); + ((void (*)(id, SEL, id, id, BOOL))objc_msgSend)( + module, performSel, fromStartAction, pbName, NO); + + // Restore _deferredDropTargetItem to avoid affecting subsequent operations. + if (deferredIvar) { + object_setIvar(module, deferredIvar, prevDeferred); + } + + // ── Step 8: Verify in-operation correction persisted ───────────────────── + // After performEditAction: fully returns, check the clip at targetStart to confirm + // that FCP's own post-processing didn't overwrite/discard our correction. + if (!sKBReplaceInFlight) { + // operationReplaceItem: fired — walk containedItems to see what FCP committed. + id verifyClip = nil; + if (primary && [primary respondsToSelector:containedSel]) { + NSArray *clips = ((NSArray *(*)(id, SEL))objc_msgSend)(primary, containedSel); + SK_CMTime cursor = {0, 90000, 1, 0}; + for (id item in clips) { + if (SK_CMTimeCompare(cursor, targetStart) == 0) { verifyClip = item; break; } + SK_CMTime d = SpliceKit_readCMTime(item, durSel); + if (SK_CMTimeIsValid(d)) cursor = SK_CMTimeAdd(cursor, d); + } + } + SEL crSel2 = NSSelectorFromString(@"clippedRange"); + if (verifyClip) { + SK_CMTimeRange cr = SpliceKit_readCMTimeRange(verifyClip, crSel2); + SK_CMTime tDur = SpliceKit_readCMTime(verifyClip, durSel); + SpliceKit_log(@"[ReplaceAtPlayhead-KB] post-perform: " + "clippedRange={%.3fs,%.3fs} timelineDur=%.3fs class=%@ ptr=%p", + SK_CMTimeGetSeconds(cr.start), SK_CMTimeGetSeconds(cr.duration), + SK_CMTimeGetSeconds(tDur), + NSStringFromClass(object_getClass(verifyClip)), + (__bridge void *)verifyClip); + } else { + SpliceKit_log(@"[ReplaceAtPlayhead-KB] post-perform: clip at T1=%.3fs GONE from timeline", + SK_CMTimeGetSeconds(targetStart)); + } + return; + } + // operationReplaceItem: did NOT fire. + sKBReplaceInFlight = NO; + SpliceKit_log(@"[ReplaceAtPlayhead-KB] operationReplaceItem: did not fire — using post-correction"); + + // ── Step 9: Post-correction (fallback) ──────────────────────────────────── + if (!SK_CMTimeIsValid(ph) || !SK_CMTimeIsValid(targetStart) || + !SK_CMTimeIsValid(targetDur) || !SK_CMTimeIsValid(orgClipStart)) { + SpliceKit_log(@"[ReplaceAtPlayhead-KB] geometry incomplete — skip correction"); + return; + } + + id newClip = nil; + if (primary && [primary respondsToSelector:containedSel]) { + NSArray *clips = ((NSArray *(*)(id, SEL))objc_msgSend)(primary, containedSel); + SK_CMTime cursor = {0, 90000, 1, 0}; + for (id item in clips) { + if (SK_CMTimeCompare(cursor, targetStart) == 0) { newClip = item; break; } + SK_CMTime d = SpliceKit_readCMTime(item, durSel); + if (SK_CMTimeIsValid(d)) cursor = SK_CMTimeAdd(cursor, d); + } + } + // ── Guard: verify Replace From Start actually fired ────────────────────────── + // Use ObjC pointer identity: compare the clip at targetStart after the replace + // with originalClip captured before. + // newClip == originalClip → replace didn't fire (FCP aborted, e.g. not enough media) + // newClip == nil → slot is empty (severe failure) → auto-undo + // newClip != originalClip → replace fired → proceed with correction + if (!newClip || newClip == originalClip) { + if (!newClip) { + SpliceKit_log(@"[ReplaceAtPlayhead-KB] clip at targetStart gone after replace — auto-undo"); + SEL undoSel = NSSelectorFromString(@"actionUndo:"); + if ([module respondsToSelector:undoSel]) + ((void (*)(id, SEL, id))objc_msgSend)(module, undoSel, nil); + else + SpliceKit_log(@"[ReplaceAtPlayhead-KB] actionUndo: not found — clip lost"); + } else { + SpliceKit_log(@"[ReplaceAtPlayhead-KB] replace didn't fire (same clip pointer) — original intact"); + } + return; + } + + // Apply alignment: set the desired source in-point on the placed clip. + // IMPORTANT: we preserve the placed clip's ACTUAL duration (from clippedRange) + // to avoid inadvertently trimming/extending the clip and causing a timeline ripple. + // + // Two paths depending on orgClipStart: + // orgClipStart≈0 → use setTimeRangeInAsset:{desiredSrcIn, placedDur} + // (raw source coords; same as drag-drop path) + // setClippedRange: with small start values physically MOVES + // the clip's timeline position for these clips. + // orgClipStart large → use setClippedRange:{orgClipStart+desiredSrcIn, placedDur} + // (organizer-cumulative coords) + // setTimeRangeInAsset: would compute negative srcIn here. + + // Read the placed clip's actual clippedRange duration before touching it. + SK_CMTime placedDur = targetDur; // fallback + { + SEL crReadSel = NSSelectorFromString(@"clippedRange"); + if ([newClip respondsToSelector:crReadSel]) { + SK_CMTimeRange cr = SpliceKit_readCMTimeRange(newClip, crReadSel); + if (SK_CMTimeIsValid(cr.duration)) + placedDur = cr.duration; + } + } + + if (SK_CMTimeGetSeconds(orgClipStart) < 1.0) { + // orgClipStart≈0: setTimeRangeInAsset: with raw source coords (same as drag-drop path). + SEL setTRSel = NSSelectorFromString(@"setTimeRangeInAsset:"); + if (![newClip respondsToSelector:setTRSel]) { + SpliceKit_log(@"[ReplaceAtPlayhead-KB] %@ has no setTimeRangeInAsset: — skip", + NSStringFromClass(object_getClass(newClip))); + return; + } + SK_CMTimeRange srcRange = { desiredSrcIn, placedDur }; + NSMethodSignature *ms = [newClip methodSignatureForSelector:setTRSel]; + NSInvocation *inv = [NSInvocation invocationWithMethodSignature:ms]; + inv.target = newClip; inv.selector = setTRSel; + [inv setArgument:&srcRange atIndex:2]; + [inv invoke]; + } else { + // large orgClipStart: setClippedRange: with organizer-cumulative coords. + SEL setCRSel = NSSelectorFromString(@"setClippedRange:"); + if (![newClip respondsToSelector:setCRSel]) { + SpliceKit_log(@"[ReplaceAtPlayhead-KB] %@ has no setClippedRange: — skip", + NSStringFromClass(object_getClass(newClip))); + return; + } + SK_CMTimeRange clippedRange = { orgDesiredStart, placedDur }; + NSMethodSignature *ms = [newClip methodSignatureForSelector:setCRSel]; + NSInvocation *inv = [NSInvocation invocationWithMethodSignature:ms]; + inv.target = newClip; inv.selector = setCRSel; + [inv setArgument:&clippedRange atIndex:2]; + [inv invoke]; + } + + SpliceKit_log(@"[ReplaceAtPlayhead-KB] done (post-correction/%@) — " + "BT=%.3fs desiredSrcIn=%.3fs orgClipStart=%.3fs", + SK_CMTimeGetSeconds(orgClipStart) < 1.0 ? @"setTR" : @"setCR", + SK_CMTimeGetSeconds(browserTime), + SK_CMTimeGetSeconds(desiredSrcIn), + SK_CMTimeGetSeconds(orgClipStart)); +} + +// --------------------------------------------------------------------------- +// Install +// --------------------------------------------------------------------------- + +void SpliceKit_installReplaceAtPlayhead(void) { + static dispatch_once_t once; + dispatch_once(&once, ^{ + // --- Drag-drop menu injection --- + // Swizzle FFAnchoredTimelineModule.init so every new instance gets the item. + Class atmClass = objc_getClass("FFAnchoredTimelineModule"); + if (atmClass) { + SEL rapSel = NSSelectorFromString(@"actionDropMenuReplaceAtPlayhead:"); + sOrigActionDropMenuReplaceAtPlayhead = + (void (*)(id, SEL, id))SpliceKit_swizzleMethod(atmClass, rapSel, + (IMP)SpliceKit_actionDropMenuReplaceAtPlayhead); + SpliceKit_log(sOrigActionDropMenuReplaceAtPlayhead + ? @"[ReplaceAtPlayhead] Swizzled actionDropMenuReplaceAtPlayhead: (playhead alignment)" + : @"[ReplaceAtPlayhead] actionDropMenuReplaceAtPlayhead: swizzle failed"); + + sOrigFFAtmInit = (id (*)(id, SEL))SpliceKit_swizzleMethod(atmClass, + @selector(init), (IMP)SpliceKit_FFAtmInit); + SpliceKit_log(sOrigFFAtmInit + ? @"[ReplaceAtPlayhead] Swizzled FFAnchoredTimelineModule.init" + : @"[ReplaceAtPlayhead] FFAnchoredTimelineModule.init swizzle failed"); + + // _startListeningToSequence: fires each time a project is loaded into the + // timeline — the reliable post-startup hook for injecting into the live instance. + SEL listenSel = NSSelectorFromString(@"_startListeningToSequence:"); + sOrigStartListeningToSequence = + (void (*)(id, SEL, id))SpliceKit_swizzleMethod(atmClass, listenSel, + (IMP)SpliceKit_startListeningToSequence); + SpliceKit_log(sOrigStartListeningToSequence + ? @"[ReplaceAtPlayhead] Swizzled FFAnchoredTimelineModule._startListeningToSequence:" + : @"[ReplaceAtPlayhead] _startListeningToSequence: swizzle failed"); + + // replaceWithPasteboard:replaceActionType: fires during drag-drop just before + // the structural replace. Captures target clip geometry (start + dur) from + // _deferredDropTargetItem for use in operationReplaceItem:withItems:. + SEL replacePBSel = NSSelectorFromString(@"replaceWithPasteboard:replaceActionType:"); + sOrigReplaceWithPasteboard = + (void (*)(id, SEL, id, int))SpliceKit_swizzleMethod( + atmClass, replacePBSel, (IMP)SpliceKit_replaceWithPasteboard); + SpliceKit_log(sOrigReplaceWithPasteboard + ? @"[ReplaceAtPlayhead] Swizzled replaceWithPasteboard:replaceActionType:" + : @"[ReplaceAtPlayhead] replaceWithPasteboard: swizzle failed"); + } else { + SpliceKit_log(@"[ReplaceAtPlayhead] FFAnchoredTimelineModule not found"); + } + + // Intercept FFAnchoredSequence.operationReplaceItem:withItems: to apply the correct + // source in-point from inside the operation context (safe for all clip types). + Class seqClass = objc_getClass("FFAnchoredSequence"); + if (seqClass) { + SEL seqReplaceSel = NSSelectorFromString( + @"operationReplaceItem:withItems:replaceActionType:editsAdded:playheadTime:sourcePlayheadTime:autoCancelOnFail:"); + sOrigSeqOperationReplaceItem = (OrigSeqReplaceItemFn)SpliceKit_swizzleMethod( + seqClass, seqReplaceSel, (IMP)SpliceKit_seqOperationReplaceItem); + SpliceKit_log(sOrigSeqOperationReplaceItem + ? @"[ReplaceAtPlayhead] Swizzled FFAnchoredSequence.operationReplaceItem:withItems:" + : @"[ReplaceAtPlayhead] FFAnchoredSequence.operationReplaceItem: swizzle failed"); + } + + // --- Keyboard shortcut path: FFEditActionMgr --- + Class editActionMgrClass = objc_getClass("FFEditActionMgr"); + if (editActionMgrClass) { + SEL kbSel = NSSelectorFromString(@"replaceWithSelectedMediaAtPlayhead:"); + sOrigReplaceWithSelectedMediaAtPlayhead = + (void (*)(id, SEL, id))SpliceKit_swizzleMethod(editActionMgrClass, kbSel, + (IMP)SpliceKit_replaceWithSelectedMediaAtPlayhead); + SpliceKit_log(sOrigReplaceWithSelectedMediaAtPlayhead + ? @"[ReplaceAtPlayhead] Swizzled FFEditActionMgr.replaceWithSelectedMediaAtPlayhead:" + : @"[ReplaceAtPlayhead] FFEditActionMgr.replaceWithSelectedMediaAtPlayhead: swizzle failed"); + } else { + SpliceKit_log(@"[ReplaceAtPlayhead] FFEditActionMgr not found"); + } + + // --- Command Editor --- + Class lkcc = objc_getClass("LKCommandsController"); + if (lkcc) { + SEL loadSel = NSSelectorFromString(@"_loadCommands"); + sOrigLoadCommands = (void (*)(id, SEL))SpliceKit_swizzleMethod(lkcc, loadSel, + (IMP)SpliceKit_loadCommands); + SpliceKit_log(sOrigLoadCommands + ? @"[ReplaceAtPlayhead] Swizzled LKCommandsController._loadCommands" + : @"[ReplaceAtPlayhead] _loadCommands swizzle failed"); + + SEL activeSel = NSSelectorFromString(@"_setActiveCommandSet:"); + sOrigSetActiveCommandSet = (void (*)(id, SEL, id))SpliceKit_swizzleMethod(lkcc, activeSel, + (IMP)SpliceKit_setActiveCommandSet); + SpliceKit_log(sOrigSetActiveCommandSet + ? @"[ReplaceAtPlayhead] Swizzled LKCommandsController._setActiveCommandSet:" + : @"[ReplaceAtPlayhead] _setActiveCommandSet: swizzle failed"); + + // _loadCommands already fired at startup — register directly now. + SEL sharedSel = NSSelectorFromString(@"sharedController"); + if ([lkcc respondsToSelector:sharedSel]) { + id controller = ((id (*)(id, SEL))objc_msgSend)((id)lkcc, sharedSel); + if (controller) { + SEL activeSetSel = NSSelectorFromString(@"_activeCommandSet"); + id commandSet = ((id (*)(id, SEL))objc_msgSend)(controller, activeSetSel); + SpliceKit_applyReplaceAtPlayheadCommand(controller, commandSet); + SpliceKit_applyReplaceRetainingAttributesCommand(controller); + } + } + } + + // Install the action handler for SKReplaceRetainingAttributesAction: on NSApplication. + SpliceKit_installRetainAttributesActionHandler(); + }); +} diff --git a/Sources/SpliceKitServer.m b/Sources/SpliceKitServer.m index 34789a2..f1d556c 100644 --- a/Sources/SpliceKitServer.m +++ b/Sources/SpliceKitServer.m @@ -211,6 +211,7 @@ void SpliceKit_releaseAllHandles(void) { typedef struct { SpliceKit_CMTime start; SpliceKit_CMTime duration; } SpliceKit_CMTimeRange; static SpliceKit_CMTimeRange SpliceKit_clipRangeForItem(id item); +static BOOL SpliceKit_seekAndMark(id timeline, SpliceKit_CMTime time, NSString *actionSelector); static NSDictionary *SpliceKit_prepareBrowserClipSourceForInsertion(id sourceBrowserClip, SpliceKit_CMTimeRange clipRange, BOOL preferAudio); @@ -3046,6 +3047,127 @@ static id SpliceKit_getEditorContainer(void) { return allResult ?: @{@"error": @"Failed to add transitions to all clips"}; } + // === Select Forward / Backward from Playhead === + // selectForward: select every primary-storyline item whose start time is >= playhead. + // selectBackward: select every primary-storyline item whose end time is <= playhead. + // Items that span the playhead are excluded from both — the behaviour is intentionally + // conservative so that subsequent ripple-delete or copy operations are predictable. + if ([action isEqualToString:@"selectForward"] || [action isEqualToString:@"selectBackward"]) { + BOOL wantForward = [action isEqualToString:@"selectForward"]; + __block NSDictionary *sfResult = nil; + SpliceKit_executeOnMainThread(^{ + @try { + id timeline = SpliceKit_getActiveTimelineModule(); + if (!timeline) { + sfResult = @{@"error": @"No active timeline module — is a project open?"}; + return; + } + + id sequence = nil; + if ([timeline respondsToSelector:@selector(sequence)]) + sequence = ((id (*)(id, SEL))objc_msgSend)(timeline, @selector(sequence)); + if (!sequence) { + sfResult = @{@"error": @"No sequence in timeline."}; + return; + } + + // Current playhead position in seconds + SpliceKit_CMTime phTime = ((SpliceKit_CMTime (*)(id, SEL))STRET_MSG)( + timeline, @selector(playheadTime)); + double playheadSec = (phTime.timescale > 0) + ? (double)phTime.value / phTime.timescale : 0.0; + + // Primary spine: sequence → primaryObject → containedItems + id primaryObj = nil; + if ([sequence respondsToSelector:@selector(primaryObject)]) + primaryObj = ((id (*)(id, SEL))objc_msgSend)(sequence, @selector(primaryObject)); + + id itemsSource = nil; + if (primaryObj && [primaryObj respondsToSelector:@selector(containedItems)]) + itemsSource = ((id (*)(id, SEL))objc_msgSend)(primaryObj, @selector(containedItems)); + if (!itemsSource && [sequence respondsToSelector:@selector(containedItems)]) + itemsSource = ((id (*)(id, SEL))objc_msgSend)(sequence, @selector(containedItems)); + + if (![itemsSource isKindOfClass:[NSArray class]] || + [(NSArray *)itemsSource count] == 0) { + sfResult = @{@"error": @"Timeline is empty."}; + return; + } + + NSArray *allItems = (NSArray *)itemsSource; + + SEL erSel = NSSelectorFromString(@"effectiveRangeOfObject:"); + if (!primaryObj || ![primaryObj respondsToSelector:erSel]) { + sfResult = @{@"error": @"Timeline model does not support positional queries."}; + return; + } + + // 1 ms epsilon absorbs floating-point imprecision at edit points + const double kEps = 0.001; + NSMutableArray *toSelect = [NSMutableArray array]; + + for (id item in allItems) { + @try { + SpliceKit_CMTimeRange range = ((SpliceKit_CMTimeRange (*)(id, SEL, id))STRET_MSG)( + primaryObj, erSel, item); + double startSec = (range.start.timescale > 0) + ? (double)range.start.value / range.start.timescale : 0.0; + double durSec = (range.duration.timescale > 0) + ? (double)range.duration.value / range.duration.timescale : 0.0; + double endSec = startSec + durSec; + + BOOL include = wantForward + ? (startSec >= playheadSec - kEps) // clip starts at or after playhead + : (endSec <= playheadSec + kEps); // clip ends at or before playhead + if (include) + [toSelect addObject:item]; + } @catch (NSException *) {} + } + + if (toSelect.count == 0) { + sfResult = @{ + @"status": @"ok", + @"selected": @0, + @"action": action, + @"playhead_seconds": @(playheadSec), + @"message": [NSString stringWithFormat: + @"No clips found %@ the playhead (%.3f s).", + wantForward ? @"forward from" : @"backward from", playheadSec] + }; + return; + } + + SEL setSelSel = NSSelectorFromString(@"setSelectedItems:"); + if (![timeline respondsToSelector:setSelSel]) + setSelSel = NSSelectorFromString(@"_setSelectedItems:"); + if (![timeline respondsToSelector:setSelSel]) { + sfResult = @{@"error": @"Timeline does not support setSelectedItems:"}; + return; + } + ((void (*)(id, SEL, id))objc_msgSend)(timeline, setSelSel, toSelect); + + SpliceKit_log(@"[Timeline] %@ — selected %lu of %lu items at playhead %.3f s", + action, (unsigned long)toSelect.count, + (unsigned long)allItems.count, playheadSec); + + sfResult = @{ + @"status": @"ok", + @"action": action, + @"selected": @(toSelect.count), + @"total": @(allItems.count), + @"playhead_seconds": @(playheadSec), + @"message": [NSString stringWithFormat: + @"Selected %lu of %lu clip(s) %@ the playhead (%.3f s).", + (unsigned long)toSelect.count, (unsigned long)allItems.count, + wantForward ? @"forward from" : @"backward from", playheadSec] + }; + } @catch (NSException *e) { + sfResult = @{@"error": [NSString stringWithFormat:@"Exception: %@", e.reason]}; + } + }); + return sfResult ?: @{@"error": @"selectForward/selectBackward failed"}; + } + if ([action isEqualToString:@"removeAllKeyframesFromClip"]) { __block NSDictionary *clearResult = nil; SpliceKit_executeOnMainThread(^{ @@ -3238,6 +3360,252 @@ static id SpliceKit_getEditorContainer(void) { return muteResult ?: @{@"error": @"Failed to toggle audio mute"}; } + // === Paste Overwrite === + // Pastes clipboard content at the playhead, overwriting (not inserting) what's there. + // + // FCP's magnetic timeline only supports ripple-insert paste. To get overwrite behavior: + // 1. Ensure native clipboard data (converts FCPXML→native if needed) + // 2. Probe paste to measure clipboard duration (paste:, read delta, undo) + // 3. Ripple-delete the range [playhead, playhead+D] to clear the destination + // 4. Paste again (now inserts into the cleared position) + // + // Net effect: clipboard content replaces [P, P+D]; timeline duration unchanged. + if ([action isEqualToString:@"pasteOverwrite"]) { + __block NSDictionary *ovResult = nil; + SpliceKit_executeOnMainThread(^{ + __block BOOL screenFrozen = NO; + @try { + id timeline = SpliceKit_getActiveTimelineModule(); + if (!timeline) { + ovResult = @{@"error": @"No active timeline module. Is a project open?"}; + return; + } + // Ensure native clipboard data exists (convert FCPXML→native if needed) + Class ffpbClass = objc_getClass("FFPasteboard"); + BOOL hasNative = NO; + if (ffpbClass) { + id ffpb = ((id (*)(id, SEL, id))objc_msgSend)( + ((id (*)(id, SEL))objc_msgSend)((id)ffpbClass, @selector(alloc)), + NSSelectorFromString(@"initWithName:"), NSPasteboardNameGeneral); + hasNative = ((BOOL (*)(id, SEL, BOOL))objc_msgSend)(ffpb, + NSSelectorFromString(@"hasEdits:"), NO); + } + if (!hasNative) hasNative = SpliceKit_convertFCPXMLToNativeClipboard(); + if (!hasNative) { + ovResult = @{@"error": @"Nothing to paste — clipboard is empty"}; + return; + } + + // Save playhead position (overwrite start point) + SpliceKit_CMTime phTime = ((SpliceKit_CMTime (*)(id, SEL))STRET_MSG)( + timeline, @selector(playheadTime)); + double phSec = (phTime.timescale > 0) ? (double)phTime.value / phTime.timescale : 0; + + // Freeze screen to hide intermediate probe steps + NSDisableScreenUpdates(); + screenFrozen = YES; + dispatch_after(dispatch_time(DISPATCH_TIME_NOW, 15 * NSEC_PER_SEC), + dispatch_get_main_queue(), ^{ + if (screenFrozen) { screenFrozen = NO; NSEnableScreenUpdates(); } + }); + + // Step 1 — Determine clipboard duration from FCPXML on the pasteboard. + // Reading the FCPXML avoids a probe paste entirely so there are no + // visible intermediate steps on screen. + double clipDur = 0; + { + NSPasteboard *pb = [NSPasteboard generalPasteboard]; + Class IXType = objc_getClass("IXXMLPasteboardType"); + if (IXType) { + NSString *currentType = ((id (*)(id, SEL))objc_msgSend)( + (id)IXType, NSSelectorFromString(@"current")); + NSData *xmlData = currentType ? [pb dataForType:currentType] : nil; + if (!xmlData) { + NSString *genericType = ((id (*)(id, SEL))objc_msgSend)( + (id)IXType, NSSelectorFromString(@"generic")); + if (genericType) xmlData = [pb dataForType:genericType]; + } + if (xmlData) { + NSString *fcpxml = [[NSString alloc] initWithData:xmlData + encoding:NSUTF8StringEncoding]; + // Parse duration="X/Ys" from the first element + NSRegularExpression *re = [NSRegularExpression + regularExpressionWithPattern:@"]+\\bduration=\"([^\"]+)\"" + options:0 error:nil]; + NSTextCheckingResult *m = [re firstMatchInString:fcpxml options:0 + range:NSMakeRange(0, fcpxml.length)]; + if (m && [m numberOfRanges] > 1) { + NSString *d = [fcpxml substringWithRange:[m rangeAtIndex:1]]; + // FCP rational time: "numerator/denominators" or "Xs" + if ([d hasSuffix:@"s"]) d = [d substringToIndex:d.length - 1]; + NSRange slash = [d rangeOfString:@"/"]; + if (slash.location != NSNotFound) { + double num = [[d substringToIndex:slash.location] doubleValue]; + double den = [[d substringFromIndex:slash.location + 1] doubleValue]; + if (den > 0) clipDur = num / den; + } else { + clipDur = [d doubleValue]; + } + } + } + } + } + SpliceKit_log(@"[PasteOverwrite] Clipboard duration from FCPXML: %.4fs", clipDur); + + // Fallback: probe paste to measure duration if FCPXML parse failed. + // This is a last resort and will cause a brief flash. + if (clipDur < 0.001) { + SpliceKit_log(@"[PasteOverwrite] FCPXML duration unavailable — using probe paste"); + ((void (*)(id, SEL, id))objc_msgSend)(timeline, NSSelectorFromString(@"paste:"), nil); + [[NSRunLoop currentRunLoop] runUntilDate:[NSDate dateWithTimeIntervalSinceNow:0.25]]; + SpliceKit_CMTime phAfter = ((SpliceKit_CMTime (*)(id, SEL))STRET_MSG)( + timeline, @selector(playheadTime)); + double phAfterSec = (phAfter.timescale > 0) ? (double)phAfter.value / phAfter.timescale : 0; + clipDur = phAfterSec - phSec; + + if (clipDur < 0.001) { + [[NSApplication sharedApplication] sendAction:@selector(undo:) to:nil from:nil]; + [[NSRunLoop currentRunLoop] runUntilDate:[NSDate dateWithTimeIntervalSinceNow:0.2]]; + screenFrozen = NO; + NSEnableScreenUpdates(); + ovResult = @{@"error": @"Cannot determine clipboard duration — clipboard may be empty"}; + return; + } + // Undo the probe paste via responder chain + [[NSApplication sharedApplication] sendAction:@selector(undo:) to:nil from:nil]; + [[NSRunLoop currentRunLoop] runUntilDate:[NSDate dateWithTimeIntervalSinceNow:0.3]]; + } + + // Steps 2–4 — Paste → Blade → Select real clips → deleteSelectionOnly: + // + // DESIGN: Every prior approach tried to operate on the gap clip left by a + // lift (step 2). Gap clips resist selection by selectClipAtPlayhead:, and + // _deleteCore:replaceWithGap:NO acts on the current clip SELECTION (the + // freshly pasted clips), not the range. Instead, we skip the lift entirely: + // + // Step 2: paste: at T → clipboard inserts at [T, T+D]; + // original content shifts right to [T+D, ...] + // Step 3: blade: at T+2D → creates a clean cut so [T+D, T+2D] is a + // discrete, selectable segment of REAL (non-gap) original clips + // Step 4: _selectRange:{T+D, D} extendRange:NO selectedItem:nil + // → selects all clips in [T+D, T+2D] (even if spanning multiple) + // Step 5: clearRange:, deleteSelectionOnly: → ripple-deletes only the + // selected clips, closing the [T+D, T+2D] hole + // + // Net: clipboard at [T, T+D], original content from T+2D onward shifts + // left by D, timeline duration unchanged. No gap clips at any point. + + int32_t ts = (phTime.timescale > 0) ? phTime.timescale : 600; + SpliceKit_CMTime endTime; // T + D + endTime.value = (int64_t)((phSec + clipDur) * ts + 0.5); + endTime.timescale = ts; + endTime.flags = 1; + endTime.epoch = 0; + SpliceKit_CMTime gapEndTime; // T + 2D + gapEndTime.value = (int64_t)((phSec + 2.0 * clipDur) * ts + 0.5); + gapEndTime.timescale = ts; + gapEndTime.flags = 1; + gapEndTime.epoch = 0; + SEL setPhSel0 = NSSelectorFromString(@"setPlayheadTime:"); + + // Step 2: Paste at T (ripple-inserts clipboard; original content shifts right). + id tmPaste = SpliceKit_getActiveTimelineModule() ?: timeline; + if ([tmPaste respondsToSelector:setPhSel0]) + ((void (*)(id, SEL, SpliceKit_CMTime))objc_msgSend)(tmPaste, setPhSel0, phTime); + [[NSRunLoop currentRunLoop] runUntilDate:[NSDate dateWithTimeIntervalSinceNow:0.05]]; + [[NSApplication sharedApplication] sendAction:NSSelectorFromString(@"selectToolArrow:") to:nil from:nil]; + [[NSRunLoop currentRunLoop] runUntilDate:[NSDate dateWithTimeIntervalSinceNow:0.03]]; + ((void (*)(id, SEL, id))objc_msgSend)(tmPaste, NSSelectorFromString(@"paste:"), nil); + [[NSRunLoop currentRunLoop] runUntilDate:[NSDate dateWithTimeIntervalSinceNow:0.25]]; + + // Step 3: Blade at T+2D — isolates exactly D seconds of original content + // in [T+D, T+2D]. The edit point at T+D already exists (paste boundary). + id tmBlade = SpliceKit_getActiveTimelineModule() ?: timeline; + if ([tmBlade respondsToSelector:setPhSel0]) + ((void (*)(id, SEL, SpliceKit_CMTime))objc_msgSend)(tmBlade, setPhSel0, gapEndTime); + [[NSRunLoop currentRunLoop] runUntilDate:[NSDate dateWithTimeIntervalSinceNow:0.05]]; + ((void (*)(id, SEL, id))objc_msgSend)(tmBlade, NSSelectorFromString(@"blade:"), nil); + [[NSRunLoop currentRunLoop] runUntilDate:[NSDate dateWithTimeIntervalSinceNow:0.15]]; + + // Step 4: Select all clips in [T+D, T+2D] via _selectRange:extendRange:selectedItem:. + // Uses NSInvocation because the CMTimeRange struct argument (48 bytes) is too + // large to pass cleanly via objc_msgSend on all ABIs. + typedef struct { SpliceKit_CMTime start; SpliceKit_CMTime duration; } SK_CMTimeRange; + SpliceKit_CMTime durTime; // duration = D + durTime.value = endTime.value - phTime.value; + durTime.timescale = ts; + durTime.flags = 1; + durTime.epoch = 0; + SK_CMTimeRange selRange = { endTime, durTime }; + + SEL selectRangeSel = NSSelectorFromString(@"_selectRange:extendRange:selectedItem:"); + id tmSel = SpliceKit_getActiveTimelineModule() ?: timeline; + BOOL selectedWithRange = NO; + if ([tmSel respondsToSelector:selectRangeSel]) { + NSMethodSignature *sig = [tmSel methodSignatureForSelector:selectRangeSel]; + NSInvocation *inv = [NSInvocation invocationWithMethodSignature:sig]; + inv.target = tmSel; + inv.selector = selectRangeSel; + [inv setArgument:&selRange atIndex:2]; // CMTimeRange at arg 2 + BOOL extendNo = NO; + [inv setArgument:&extendNo atIndex:3]; // extendRange:NO + id nilItem = nil; + [inv setArgument:&nilItem atIndex:4]; // selectedItem:nil + [inv invoke]; + selectedWithRange = YES; + SpliceKit_log(@"[PasteOverwrite] _selectRange:%.3f–%.3fs OK", + phSec + clipDur, phSec + 2.0 * clipDur); + } else { + // Fallback: works when [T+D, T+2D] contains exactly one clip + SpliceKit_log(@"[PasteOverwrite] _selectRange: unavailable, using selectClipAtPlayhead:"); + SpliceKit_CMTime midTime; + midTime.value = endTime.value + 1; + midTime.timescale = ts; midTime.flags = 1; midTime.epoch = 0; + if ([tmSel respondsToSelector:setPhSel0]) + ((void (*)(id, SEL, SpliceKit_CMTime))objc_msgSend)(tmSel, setPhSel0, midTime); + [[NSRunLoop currentRunLoop] runUntilDate:[NSDate dateWithTimeIntervalSinceNow:0.05]]; + [[NSApplication sharedApplication] sendAction:NSSelectorFromString(@"selectClipAtPlayhead:") to:nil from:nil]; + selectedWithRange = YES; + } + [[NSRunLoop currentRunLoop] runUntilDate:[NSDate dateWithTimeIntervalSinceNow:0.1]]; + + // Step 5: clearRange: so delete: acts on the selection, not a range lift. + // deleteSelectionOnly: ripple-deletes only the selected clips. + [[NSApplication sharedApplication] sendAction:NSSelectorFromString(@"clearRange:") to:nil from:nil]; + [[NSRunLoop currentRunLoop] runUntilDate:[NSDate dateWithTimeIntervalSinceNow:0.05]]; + + id tmDel = SpliceKit_getActiveTimelineModule() ?: timeline; + SEL delSelOnly = NSSelectorFromString(@"deleteSelectionOnly:"); + if ([tmDel respondsToSelector:delSelOnly]) { + ((void (*)(id, SEL, id))objc_msgSend)(tmDel, delSelOnly, nil); + } else { + ((void (*)(id, SEL, id))objc_msgSend)(tmDel, NSSelectorFromString(@"delete:"), nil); + } + [[NSRunLoop currentRunLoop] runUntilDate:[NSDate dateWithTimeIntervalSinceNow:0.25]]; + + // Restore playhead to T. + id tm = SpliceKit_getActiveTimelineModule() ?: timeline; + if ([tm respondsToSelector:setPhSel0]) + ((void (*)(id, SEL, SpliceKit_CMTime))objc_msgSend)(tm, setPhSel0, phTime); + + screenFrozen = NO; + NSEnableScreenUpdates(); + + ovResult = @{ + @"action": @"pasteOverwrite", + @"status": @"ok", + @"clipboardDuration": @(clipDur), + @"overwriteStart": @(phSec), + @"overwriteEnd": @(phSec + clipDur) + }; + } @catch (NSException *e) { + if (screenFrozen) { screenFrozen = NO; NSEnableScreenUpdates(); } + ovResult = @{@"error": [NSString stringWithFormat:@"Exception in pasteOverwrite: %@", e.reason]}; + } + }); + return ovResult ?: @{@"error": @"pasteOverwrite timed out"}; + } + NSString *selector = actionMap[action]; if (!selector) { // Allow passing raw selector names too @@ -9312,6 +9680,16 @@ static id SpliceKit_backgroundRenderJSONValue(id value) { return result ?: @{@"error": @"Unable to control background render state"}; } +// Forward declarations for FCP-default preference functions defined later in this file +NSString *SpliceKit_getTransitionWarningDefault(void); +void SpliceKit_setTransitionWarningDefault(NSString *value); +NSInteger SpliceKit_getDefaultClipHeight(void); +void SpliceKit_setDefaultClipHeight(NSInteger pixelHeight); +NSString *SpliceKit_getDefaultAudioChannelConfig(void); +void SpliceKit_setDefaultAudioChannelConfig(NSString *value); +NSString *SpliceKit_getDefaultAudioPanMode(void); +void SpliceKit_setDefaultAudioPanMode(NSString *value); + static NSDictionary *SpliceKit_handleOptionsGet(NSDictionary *params) { return @{ @"effectDragAsAdjustmentClip": @(SpliceKit_isEffectDragAsAdjustmentClipEnabled()), @@ -9322,6 +9700,10 @@ static id SpliceKit_backgroundRenderJSONValue(id value) { @"lLadder": SpliceKit_getLLadder(), @"jLadder": SpliceKit_getJLadder(), @"defaultSpatialConformType": SpliceKit_getDefaultSpatialConformType(), + @"transitionWarningDefault": SpliceKit_getTransitionWarningDefault(), + @"defaultClipHeight": @(SpliceKit_getDefaultClipHeight()), + @"defaultAudioChannelConfig": SpliceKit_getDefaultAudioChannelConfig() ?: @"", + @"defaultAudioPanMode": SpliceKit_getDefaultAudioPanMode() ?: @"", @"aiEngine": @([SpliceKitCommandPalette sharedPalette].aiEngine), @"gemmaModel": [SpliceKitCommandPalette sharedPalette].gemmaModel ?: @"unsloth/gemma-4-E4B-it-UD-MLX-4bit", @"sidebarCoalesceLiveScroll": @(SpliceKit_isSidebarCoalesceLiveScrollEnabled()), @@ -9447,6 +9829,44 @@ static id SpliceKit_backgroundRenderJSONValue(id value) { SpliceKit_setTLKOptimizedReloadEnabled([enabled boolValue]); return @{@"status": @"ok", @"tlkOptimizedReload": @(SpliceKit_isTLKOptimizedReloadEnabled())}; + } else if ([option isEqualToString:@"transitionWarningDefault"]) { + NSString *value = params[@"value"]; + if (!value) return @{@"error": @"'value' required (\"ask\", \"freeze_frames\", \"ripple_trim\")"}; + value = [value lowercaseString]; + if (!([value isEqualToString:@"ask"] || + [value isEqualToString:@"freeze_frames"] || + [value isEqualToString:@"ripple_trim"])) { + return @{@"error": @"Invalid value — must be \"ask\", \"freeze_frames\", or \"ripple_trim\""}; + } + SpliceKit_setTransitionWarningDefault(value); + return @{@"status": @"ok", @"transitionWarningDefault": SpliceKit_getTransitionWarningDefault()}; + } else if ([option isEqualToString:@"defaultClipHeight"]) { + NSNumber *value = params[@"value"]; + if (!value) return @{@"error": @"'value' required (0=off, 35=small, 80=medium, 153=large)"}; + SpliceKit_setDefaultClipHeight([value integerValue]); + return @{@"status": @"ok", @"defaultClipHeight": @(SpliceKit_getDefaultClipHeight())}; + } else if ([option isEqualToString:@"defaultAudioChannelConfig"]) { + NSString *value = params[@"value"]; + if (!value) return @{@"error": @"'value' required (\"\" to disable, \"stereo\", \"dual_mono\")"}; + value = [value lowercaseString]; + if (![value isEqualToString:@""] && + ![value isEqualToString:@"stereo"] && + ![value isEqualToString:@"dual_mono"]) { + return @{@"error": @"Invalid value — must be \"\", \"stereo\", or \"dual_mono\""}; + } + SpliceKit_setDefaultAudioChannelConfig(value.length ? value : nil); + return @{@"status": @"ok", + @"defaultAudioChannelConfig": SpliceKit_getDefaultAudioChannelConfig() ?: @""}; + } else if ([option isEqualToString:@"defaultAudioPanMode"]) { + NSString *value = params[@"value"]; + if (!value) return @{@"error": @"'value' required (\"\" to disable, \"none\", \"stereo\", \"mono\", \"surround\")"}; + value = [value lowercaseString]; + NSArray *valid = @[@"", @"none", @"stereo", @"mono", @"surround"]; + if (![valid containsObject:value]) + return @{@"error": @"Invalid value — must be \"\", \"none\", \"stereo\", \"mono\", or \"surround\""}; + SpliceKit_setDefaultAudioPanMode(value.length ? value : nil); + return @{@"status": @"ok", + @"defaultAudioPanMode": SpliceKit_getDefaultAudioPanMode() ?: @""}; } return @{@"error": [NSString stringWithFormat:@"Unknown option: %@", option]}; @@ -9496,12 +9916,18 @@ static id SpliceKit_backgroundRenderJSONValue(id value) { static BOOL sFreezeExtendHasOperationReplay = NO; // Swizzled -[FFAnchoredSequence defaultTransitionOverlapType] -// Original returns 1 (needs handles). We return 2 (overlap/use edge frames) when forced. +// Original returns 1 (needs handles). We return 2 (overlap/use edge frames) when forced, +// or when the global "freeze_frames" preference is set. static int SpliceKit_swizzled_defaultTransitionOverlapType(id self, SEL _cmd) { if (sForceOverlap) { SpliceKit_log(@"[FreezeExtend] defaultTransitionOverlapType -> 2 (freeze-frame overlap)"); return 2; } + NSString *pref = [[NSUserDefaults standardUserDefaults] + stringForKey:@"SpliceKitTransitionWarningDefault"]; + if ([pref isEqualToString:@"freeze_frames"]) { + return 2; + } return ((int (*)(id, SEL))sOrigDefaultOverlapType)(self, _cmd); } @@ -10537,19 +10963,22 @@ static void SpliceKit_scheduleFreezeExtendRepair(void) { } static int SpliceKit_effectiveTransitionOverlapType(int overlapType, NSString *source) { - if (!SpliceKit_shouldForceFreezeOverlap()) { - return overlapType; - } - - if (overlapType != 2) { - SpliceKit_log(@"[FreezeExtend] Forcing transitionOverlapType -> 2"); - if (source.length > 0) { - SpliceKit_log(@"%@", [NSString stringWithFormat: - @"[FreezeExtend] Source=%@ original transitionOverlapType=%d", - source, overlapType]); + // Per-transition force (API freeze_extend or dialog "Use Freeze Frames") + if (SpliceKit_shouldForceFreezeOverlap()) { + if (overlapType != 2) { + SpliceKit_log(@"[FreezeExtend] Forcing transitionOverlapType -> 2"); + if (source.length > 0) + SpliceKit_log(@"[FreezeExtend] Source=%@ original=%d", source, overlapType); } + return 2; } - return 2; + // Global preference: always use edge frames (no dialog, no ripple trim) + NSString *pref = [[NSUserDefaults standardUserDefaults] + stringForKey:@"SpliceKitTransitionWarningDefault"]; + if ([pref isEqualToString:@"freeze_frames"]) { + return 2; + } + return overlapType; } static NSModalResponse SpliceKit_swizzled_NSAlert_runModal(id self, SEL _cmd) { @@ -10852,14 +11281,69 @@ static BOOL SpliceKit_applyHoldFrameExtension(id timelineModule, double clipStar static BOOL sFreezeExtendAsyncPending = NO; // Replacement for -[FFAnchoredSequence displayTransitionAvailableMediaAlertDialog:] -// Instead of showing the "not enough extra media" dialog, cancel the current -// transition attempt, schedule hold-frame extensions on the short clips, then -// retry the transition on the next run-loop iteration. +// Handles three modes set via SpliceKitTransitionWarningDefault preference: +// "ripple_trim" - auto-accept without showing the dialog (FCP ripple-trims) +// "freeze_frames"- never reached (effectiveTransitionOverlapType already returns 2) +// "ask" (default)- show our custom dialog with "Use Freeze Frames" added static char SpliceKit_swizzled_displayTransitionAlert(id self, SEL _cmd, char *result) { - // Freeze-extend auto-hold is disabled pending further development. - // Pass through to FCP's original dialog. + NSString *pref = [[NSUserDefaults standardUserDefaults] + stringForKey:@"SpliceKitTransitionWarningDefault"]; + + // Auto-ripple: create anyway, no dialog shown + if ([pref isEqualToString:@"ripple_trim"] && !sFreezeExtendPendingAutoAccept) { + SpliceKit_log(@"[TransitionDefault] Auto-ripple trim (preference)"); + if (result) *result = 1; + return 1; + } + + // Freeze frames: effectiveTransitionOverlapType should have returned 2 already + // so the dialog shouldn't be reached. Fallback: set flags and accept. + if ([pref isEqualToString:@"freeze_frames"] && !sFreezeExtendPendingAutoAccept) { + SpliceKit_log(@"[TransitionDefault] Auto-freeze frames fallback"); + sForceOverlap = YES; + sFreezeExtendUseFreezeFramesForCurrentAlert = YES; + if (result) *result = 1; + return 1; + } + + // API-triggered freeze extend auto-accept: existing complex retry path if (!sFreezeExtendPendingAutoAccept) { - return ((char (*)(id, SEL, char *))sOrigDisplayTransitionAlert)(self, _cmd, result); + // "ask" mode: replace FCP's 2-button dialog with our 3-button version + NSAlert *alert = [[NSAlert alloc] init]; + [alert setMessageText:@"There is not enough extra media beyond clip edges to create the transition."]; + [alert setInformativeText: + @"You can create the transition anyway (ripple trim), extend the clip edges with " + @"freeze frames, or cancel."]; + [alert addButtonWithTitle:@"Create Anyway"]; + [alert addButtonWithTitle:@"Use Freeze Frames"]; + [alert addButtonWithTitle:@"Cancel"]; + + NSModalResponse resp = [alert runModal]; + + if (resp == NSAlertThirdButtonReturn) { + // Cancel + SpliceKit_log(@"[TransitionDialog] Cancelled"); + if (result) *result = 0; + return 0; + } + if (resp == NSAlertSecondButtonReturn) { + // Use Freeze Frames: set flags then accept; FCP will call + // defaultTransitionOverlapType again and get 2 + SpliceKit_log(@"[TransitionDialog] Chose 'Use Freeze Frames'"); + sForceOverlap = YES; + sFreezeExtendUseFreezeFramesForCurrentAlert = YES; + if (result) *result = 1; + // Reset after this run-loop turn so sForceOverlap doesn't persist + dispatch_async(dispatch_get_main_queue(), ^{ + sForceOverlap = NO; + sFreezeExtendUseFreezeFramesForCurrentAlert = NO; + }); + return 1; + } + // Create Anyway (ripple trim) + SpliceKit_log(@"[TransitionDialog] Chose 'Create Anyway'"); + if (result) *result = 1; + return 1; } SpliceKit_log(@"[FreezeExtend] Intercepted 'not enough media' dialog"); @@ -12074,6 +12558,106 @@ void SpliceKit_installFCPXMLPasteSwizzle(void) { } } +#pragma mark - Paste Overwrite Edit Menu Item + +// Inserts "Paste Overwrite" into FCP's Edit menu immediately after the native "Paste" item. +// Uses a dedicated action target so we can control validation without touching FCP internals. + +@interface SpliceKitPasteOverwriteMenuTarget : NSObject ++ (instancetype)shared; +- (void)pasteOverwrite:(id)sender; +- (BOOL)validateMenuItem:(NSMenuItem *)menuItem; +@end + +@implementation SpliceKitPasteOverwriteMenuTarget + ++ (instancetype)shared { + static SpliceKitPasteOverwriteMenuTarget *sInstance; + static dispatch_once_t once; + dispatch_once(&once, ^{ sInstance = [[self alloc] init]; }); + return sInstance; +} + +- (void)pasteOverwrite:(id)sender { + // Dispatch async so the menu can close before the operation starts + dispatch_async(dispatch_get_main_queue(), ^{ + SpliceKit_handleTimelineAction(@{@"action": @"pasteOverwrite"}); + }); +} + +- (BOOL)validateMenuItem:(NSMenuItem *)menuItem { + if ([menuItem action] != @selector(pasteOverwrite:)) return NO; + // Enabled when FCP native clipboard data is present OR FCPXML is on the pasteboard + Class ffpbClass = objc_getClass("FFPasteboard"); + if (ffpbClass) { + id ffpb = ((id (*)(id, SEL, id))objc_msgSend)( + ((id (*)(id, SEL))objc_msgSend)((id)ffpbClass, @selector(alloc)), + NSSelectorFromString(@"initWithName:"), NSPasteboardNameGeneral); + if (((BOOL (*)(id, SEL, BOOL))objc_msgSend)(ffpb, NSSelectorFromString(@"hasEdits:"), NO)) + return YES; + } + NSPasteboard *pb = [NSPasteboard generalPasteboard]; + SEL containsXMLSel = NSSelectorFromString(@"containsXML"); + if ([pb respondsToSelector:containsXMLSel] && + ((BOOL (*)(id, SEL))objc_msgSend)(pb, containsXMLSel)) + return YES; + return NO; +} + +@end + +static BOOL sPasteOverwriteMenuInstalled = NO; + +void SpliceKit_installPasteOverwriteMenuItem(void) { + if (sPasteOverwriteMenuInstalled) return; + + dispatch_block_t work = ^{ + NSMenu *mainMenu = [NSApp mainMenu]; + if (!mainMenu) return; + + // Find the Edit menu by title + NSInteger editIdx = [mainMenu indexOfItemWithTitle:@"Edit"]; + if (editIdx < 0) return; + NSMenu *editMenu = [[mainMenu itemAtIndex:editIdx] submenu]; + if (!editMenu) return; + + // Guard against double-install + if ([editMenu indexOfItemWithTitle:@"Paste Overwrite"] >= 0) { + sPasteOverwriteMenuInstalled = YES; + return; + } + + // Find the "Paste" item (action = paste:) + NSInteger pasteIdx = -1; + for (NSInteger i = 0; i < [editMenu numberOfItems]; i++) { + NSMenuItem *item = [editMenu itemAtIndex:i]; + if ([[item title] isEqualToString:@"Paste"] && + [item action] == NSSelectorFromString(@"paste:")) { + pasteIdx = i; + break; + } + } + if (pasteIdx < 0) { + SpliceKit_log(@"[PasteOverwrite] Could not find Paste item in Edit menu — skipping"); + return; + } + + NSMenuItem *item = [[NSMenuItem alloc] initWithTitle:@"Paste Overwrite" + action:@selector(pasteOverwrite:) + keyEquivalent:@"v"]; + item.keyEquivalentModifierMask = NSEventModifierFlagOption; + item.target = [SpliceKitPasteOverwriteMenuTarget shared]; + + [editMenu insertItem:item atIndex:pasteIdx + 1]; + sPasteOverwriteMenuInstalled = YES; + SpliceKit_log(@"[PasteOverwrite] Inserted 'Paste Overwrite' into Edit menu at index %ld", + (long)(pasteIdx + 1)); + }; + + if ([NSThread isMainThread]) work(); + else dispatch_sync(dispatch_get_main_queue(), work); +} + #pragma mark - Effect Browser Favorites (context menu) // Swizzle -[FFEffectLibraryItemView menu] to add "Add to Favorites" / "Remove from Favorites" @@ -12818,6 +13402,118 @@ void SpliceKit_installDefaultSpatialConformType(void) { SpliceKit_getDefaultSpatialConformType()); } +#pragma mark - Transition Warning Default + +// "SpliceKitTransitionWarningDefault": "ask" (default), "freeze_frames", "ripple_trim" +// - "ask" : show our 3-button dialog (Create Anyway / Use Freeze Frames / Cancel) +// - "freeze_frames": pass overlapType=2 to FCP — no dialog, uses edge frames +// - "ripple_trim" : auto-accept FCP's dialog with result=1 — no dialog, FCP ripple-trims + +NSString *SpliceKit_getTransitionWarningDefault(void) { + NSString *val = [[NSUserDefaults standardUserDefaults] + stringForKey:@"SpliceKitTransitionWarningDefault"]; + if ([val isEqualToString:@"freeze_frames"] || [val isEqualToString:@"ripple_trim"]) + return val; + return @"ask"; +} + +void SpliceKit_setTransitionWarningDefault(NSString *value) { + if (!value || [value isEqualToString:@"ask"]) { + [[NSUserDefaults standardUserDefaults] + removeObjectForKey:@"SpliceKitTransitionWarningDefault"]; + } else { + [[NSUserDefaults standardUserDefaults] + setObject:value forKey:@"SpliceKitTransitionWarningDefault"]; + } + SpliceKit_log(@"[TransitionDefault] Set to '%@'", value ?: @"ask"); +} + +#pragma mark - Default Clip Height + +// "SpliceKitDefaultClipHeight": 0 (no override), 35 (small), 80 (medium), 153 (large) +// Writes to FFOrganizedTimelineClipHeight which FCP reads as the global clip height +// preference. TLKUserDefaults is reloaded so the change takes effect immediately. + +NSInteger SpliceKit_getDefaultClipHeight(void) { + NSNumber *val = [[NSUserDefaults standardUserDefaults] + objectForKey:@"SpliceKitDefaultClipHeight"]; + return val ? [val integerValue] : 0; +} + +void SpliceKit_setDefaultClipHeight(NSInteger pixelHeight) { + if (pixelHeight <= 0) { + [[NSUserDefaults standardUserDefaults] + removeObjectForKey:@"SpliceKitDefaultClipHeight"]; + SpliceKit_log(@"[DefaultClipHeight] Cleared (no override)"); + return; + } + [[NSUserDefaults standardUserDefaults] + setInteger:pixelHeight forKey:@"SpliceKitDefaultClipHeight"]; + // Also write to FCP's own key so new projects pick it up + [[NSUserDefaults standardUserDefaults] + setInteger:pixelHeight forKey:@"FFOrganizedTimelineClipHeight"]; + // Reload TLK so the change is visible immediately + Class tlk = NSClassFromString(@"TLKUserDefaults"); + if (tlk) { + SEL load = NSSelectorFromString(@"_loadUserDefaults"); + if ([tlk respondsToSelector:load]) + ((void (*)(id, SEL))objc_msgSend)((id)tlk, load); + } + SpliceKit_log(@"[DefaultClipHeight] Set to %ld px", (long)pixelHeight); +} + +#pragma mark - Default Audio Channel Config + +// "SpliceKitDefaultAudioChannelConfig": nil/empty (no override), "stereo", "dual_mono" +// Stored as a preference; applied to new clips via the audio component source API. + +NSString *SpliceKit_getDefaultAudioChannelConfig(void) { + NSString *val = [[NSUserDefaults standardUserDefaults] + stringForKey:@"SpliceKitDefaultAudioChannelConfig"]; + if ([val isEqualToString:@"dual_mono"] || [val isEqualToString:@"stereo"]) + return val; + return @""; // no override +} + +void SpliceKit_setDefaultAudioChannelConfig(NSString *value) { + if (!value || value.length == 0) { + [[NSUserDefaults standardUserDefaults] + removeObjectForKey:@"SpliceKitDefaultAudioChannelConfig"]; + SpliceKit_log(@"[DefaultAudioChannel] Cleared (no override)"); + } else { + [[NSUserDefaults standardUserDefaults] + setObject:value forKey:@"SpliceKitDefaultAudioChannelConfig"]; + SpliceKit_log(@"[DefaultAudioChannel] Set to '%@'", value); + } +} + +#pragma mark - Default Audio Pan Mode + +// "SpliceKitDefaultAudioPanMode": nil/empty (no override), "none", "stereo", "mono" +// "stereo" adds a Stereo Left+Right panner AU; "mono" adds a Mono panner; "none" = no panner. +// Applied to clips via FFEffectStack(Audio) addSurroundPannerEffectWithPanMode: on clip addition. + +NSString *SpliceKit_getDefaultAudioPanMode(void) { + NSString *val = [[NSUserDefaults standardUserDefaults] + stringForKey:@"SpliceKitDefaultAudioPanMode"]; + if ([val isEqualToString:@"none"] || [val isEqualToString:@"stereo"] || + [val isEqualToString:@"mono"] || [val isEqualToString:@"surround"]) + return val; + return @""; // no override +} + +void SpliceKit_setDefaultAudioPanMode(NSString *value) { + if (!value || value.length == 0) { + [[NSUserDefaults standardUserDefaults] + removeObjectForKey:@"SpliceKitDefaultAudioPanMode"]; + SpliceKit_log(@"[DefaultAudioPanMode] Cleared (no override)"); + } else { + [[NSUserDefaults standardUserDefaults] + setObject:value forKey:@"SpliceKitDefaultAudioPanMode"]; + SpliceKit_log(@"[DefaultAudioPanMode] Set to '%@'", value); + } +} + #pragma mark - Transition Handlers // // List and apply FCP's 376+ built-in transitions. The freeze_extend option diff --git a/Sources/SpliceKitTimecodeBarShortcuts.h b/Sources/SpliceKitTimecodeBarShortcuts.h new file mode 100644 index 0000000..2d09d32 --- /dev/null +++ b/Sources/SpliceKitTimecodeBarShortcuts.h @@ -0,0 +1,31 @@ +// +// SpliceKitTimecodeBarShortcuts.h +// SpliceKit — Customizable shortcut buttons in FCP's timeline toolbar +// +// Injects compact NSButtons into the flex gap between the timeline history +// forward button and the snapping/skimming control cluster. Configuration +// is stored as JSON in ~/Library/Application Support/SpliceKit/. +// + +#ifndef SpliceKitTimecodeBarShortcuts_h +#define SpliceKitTimecodeBarShortcuts_h + +#import + +// Install the shortcut bar. Called once at launch; retries until FCP's +// timeline module is ready. +void SpliceKit_installTimecodeBarShortcuts(void); + +// Reload buttons from the saved config (call after prefs change). +void SpliceKit_reloadTimecodeBarShortcuts(void); + +// Return the full catalogue of actions users can assign to buttons. +// Each dict: @{@"id", @"type", @"icon", @"tooltip", @"category"} +NSArray *SpliceKit_getAvailableTimecodeBarActions(void); + +// Return / replace the current ordered shortcut config. +// Each dict must have at least @"id" and @"type". +NSArray *SpliceKit_getTimecodeBarConfig(void); +void SpliceKit_setTimecodeBarConfig(NSArray *config); + +#endif /* SpliceKitTimecodeBarShortcuts_h */ diff --git a/Sources/SpliceKitTimecodeBarShortcuts.m b/Sources/SpliceKitTimecodeBarShortcuts.m new file mode 100644 index 0000000..7436e9d --- /dev/null +++ b/Sources/SpliceKitTimecodeBarShortcuts.m @@ -0,0 +1,508 @@ +// +// SpliceKitTimecodeBarShortcuts.m +// SpliceKit — Customizable shortcut buttons in FCP's timeline toolbar +// +// View hierarchy path to the injection point: +// NSApp.mainWindow → contentView → LKContainerView → PEMainContainerModule → +// LKContainerView → PELowerDeckContainer → LKContainerView → +// PEMainEditorContainer (LKContainerItemView) → LKContainerItemCapView → +// NSView ← the 30px toolbar bar with 20 subviews +// +// Injection target: the Auto Layout flex gap between the history-forward +// PEEditorMenuDelayButton and the LKPaneCapSegmentedControl (which holds the +// snapping / skimming / solo / scrolling toggles). The existing constraint is +// ">=5" (compressible spacing); we replace it with: +// histFwd.trailing -(>=6)- [SpliceKitShortcutsBar] -(>=6)- snappingCtrl.leading +// so the bar compresses naturally if the window is narrow. +// + +#import "SpliceKitTimecodeBarShortcuts.h" +#import "SpliceKit.h" +#import +#import +#import + +// ────────────────────────────────────────────────────────────────────────────── +#pragma mark - Layout constants +// ────────────────────────────────────────────────────────────────────────────── + +static const CGFloat kSCBButtonSize = 20.0; // width = height of each button +static const CGFloat kSCBButtonGap = 3.0; // spacing between buttons +static const CGFloat kSCBGroupPad = 6.0; // outer padding from neighbours + +// ────────────────────────────────────────────────────────────────────────────── +#pragma mark - Config persistence +// ────────────────────────────────────────────────────────────────────────────── + +static NSURL *SCB_configURL(void) { + NSURL *appSupport = [[[NSFileManager defaultManager] + URLsForDirectory:NSApplicationSupportDirectory + inDomains:NSUserDomainMask] firstObject]; + NSURL *dir = [appSupport URLByAppendingPathComponent:@"SpliceKit"]; + [[NSFileManager defaultManager] createDirectoryAtURL:dir + withIntermediateDirectories:YES attributes:nil error:nil]; + return [dir URLByAppendingPathComponent:@"timecode_bar_shortcuts.json"]; +} + +static NSArray *SCB_loadConfig(void) { + NSData *data = [NSData dataWithContentsOfURL:SCB_configURL()]; + if (!data) return @[]; + NSArray *arr = [NSJSONSerialization JSONObjectWithData:data options:0 error:nil]; + return [arr isKindOfClass:[NSArray class]] ? arr : @[]; +} + +static void SCB_saveConfig(NSArray *config) { + NSData *data = [NSJSONSerialization dataWithJSONObject:config + options:NSJSONWritingPrettyPrinted + error:nil]; + if (data) [data writeToURL:SCB_configURL() atomically:YES]; +} + +// ────────────────────────────────────────────────────────────────────────────── +#pragma mark - Available actions catalogue +// ────────────────────────────────────────────────────────────────────────────── + +NSArray *SpliceKit_getAvailableTimecodeBarActions(void) { + static NSArray *sActions = nil; + static dispatch_once_t once; + dispatch_once(&once, ^{ + sActions = @[ + // ── Edit ───────────────────────────────────────────────────────── + @{@"id":@"blade", @"type":@"timeline", @"category":@"Edit", @"icon":@"scissors", @"tooltip":@"Blade — B"}, + @{@"id":@"bladeAll", @"type":@"timeline", @"category":@"Edit", @"icon":@"scissors.circle.fill", @"tooltip":@"Blade All — Shift-B"}, + @{@"id":@"undo", @"type":@"timeline", @"category":@"Edit", @"icon":@"arrow.uturn.backward", @"tooltip":@"Undo — ⌘Z"}, + @{@"id":@"redo", @"type":@"timeline", @"category":@"Edit", @"icon":@"arrow.uturn.forward", @"tooltip":@"Redo — ⇧⌘Z"}, + @{@"id":@"selectAll", @"type":@"timeline", @"category":@"Edit", @"icon":@"checkmark.rectangle.fill", @"tooltip":@"Select All — ⌘A"}, + @{@"id":@"delete", @"type":@"timeline", @"category":@"Edit", @"icon":@"trash", @"tooltip":@"Delete"}, + @{@"id":@"cut", @"type":@"timeline", @"category":@"Edit", @"icon":@"scissors.badge.ellipsis", @"tooltip":@"Cut — ⌘X"}, + @{@"id":@"copy", @"type":@"timeline", @"category":@"Edit", @"icon":@"doc.on.doc", @"tooltip":@"Copy — ⌘C"}, + @{@"id":@"paste", @"type":@"timeline", @"category":@"Edit", @"icon":@"doc.on.clipboard", @"tooltip":@"Paste — ⌘V"}, + @{@"id":@"joinClips", @"type":@"timeline", @"category":@"Edit", @"icon":@"link", @"tooltip":@"Join Clips"}, + // ── Markers ────────────────────────────────────────────────────── + @{@"id":@"addMarker", @"type":@"timeline", @"category":@"Markers", @"icon":@"bookmark.fill", @"tooltip":@"Add Marker — M"}, + @{@"id":@"addChapterMarker", @"type":@"timeline", @"category":@"Markers", @"icon":@"star.fill", @"tooltip":@"Add Chapter Marker"}, + @{@"id":@"addTodoMarker", @"type":@"timeline", @"category":@"Markers", @"icon":@"checkmark.seal.fill", @"tooltip":@"Add To-Do Marker"}, + @{@"id":@"deleteMarker", @"type":@"timeline", @"category":@"Markers", @"icon":@"bookmark.slash.fill", @"tooltip":@"Delete Marker"}, + @{@"id":@"nextMarker", @"type":@"timeline", @"category":@"Markers", @"icon":@"chevron.right", @"tooltip":@"Next Marker"}, + @{@"id":@"previousMarker", @"type":@"timeline", @"category":@"Markers", @"icon":@"chevron.left", @"tooltip":@"Previous Marker"}, + // ── Navigation ─────────────────────────────────────────────────── + @{@"id":@"goToStart", @"type":@"playback", @"category":@"Navigation", @"icon":@"backward.end.fill", @"tooltip":@"Go to Start — Home"}, + @{@"id":@"goToEnd", @"type":@"playback", @"category":@"Navigation", @"icon":@"forward.end.fill", @"tooltip":@"Go to End — End"}, + @{@"id":@"nextEdit", @"type":@"timeline", @"category":@"Navigation", @"icon":@"forward.frame.fill", @"tooltip":@"Next Edit — ;"}, + @{@"id":@"previousEdit", @"type":@"timeline", @"category":@"Navigation", @"icon":@"backward.frame.fill", @"tooltip":@"Previous Edit — :"}, + @{@"id":@"selectClipAtPlayhead",@"type":@"timeline",@"category":@"Navigation", @"icon":@"cursorarrow.click", @"tooltip":@"Select Clip at Playhead — X"}, + // ── Color ──────────────────────────────────────────────────────── + @{@"id":@"addColorBoard", @"type":@"timeline", @"category":@"Color", @"icon":@"paintpalette", @"tooltip":@"Add Color Board"}, + @{@"id":@"addColorWheels", @"type":@"timeline", @"category":@"Color", @"icon":@"circle.hexagongrid", @"tooltip":@"Add Color Wheels"}, + @{@"id":@"addColorCurves", @"type":@"timeline", @"category":@"Color", @"icon":@"chart.line.uptrend.xyaxis", @"tooltip":@"Add Color Curves"}, + @{@"id":@"balanceColor", @"type":@"timeline", @"category":@"Color", @"icon":@"wand.and.stars", @"tooltip":@"Balance Color — ⌥⌘B"}, + @{@"id":@"matchColor", @"type":@"timeline", @"category":@"Color", @"icon":@"eyedropper.halffull", @"tooltip":@"Match Color"}, + @{@"id":@"addColorAdjustment", @"type":@"timeline", @"category":@"Color", @"icon":@"slider.horizontal.3", @"tooltip":@"Add Color Adjustment"}, + // ── Rating ─────────────────────────────────────────────────────── + @{@"id":@"favorite", @"type":@"timeline", @"category":@"Rating", @"icon":@"hand.thumbsup.fill", @"tooltip":@"Favorite — F"}, + @{@"id":@"reject", @"type":@"timeline", @"category":@"Rating", @"icon":@"hand.thumbsdown.fill", @"tooltip":@"Reject — Delete"}, + @{@"id":@"unrate", @"type":@"timeline", @"category":@"Rating", @"icon":@"minus.circle", @"tooltip":@"Unrate — U"}, + // ── Speed ──────────────────────────────────────────────────────── + @{@"id":@"retimeSlow50", @"type":@"timeline", @"category":@"Speed", @"icon":@"tortoise.fill", @"tooltip":@"50% Slow Motion"}, + @{@"id":@"retimeFast2x", @"type":@"timeline", @"category":@"Speed", @"icon":@"hare.fill", @"tooltip":@"2× Fast Motion"}, + @{@"id":@"retimeNormal", @"type":@"timeline", @"category":@"Speed", @"icon":@"speedometer", @"tooltip":@"Normal Speed — ⌥⌘R"}, + @{@"id":@"retimeReverse", @"type":@"timeline", @"category":@"Speed", @"icon":@"arrow.counterclockwise", @"tooltip":@"Reverse"}, + @{@"id":@"freezeFrame", @"type":@"timeline", @"category":@"Speed", @"icon":@"pause.circle.fill", @"tooltip":@"Freeze Frame — F"}, + // ── Trim ───────────────────────────────────────────────────────── + @{@"id":@"trimToPlayhead", @"type":@"timeline", @"category":@"Trim", @"icon":@"crop", @"tooltip":@"Trim to Playhead"}, + @{@"id":@"extendEditToPlayhead",@"type":@"timeline",@"category":@"Trim", @"icon":@"arrow.left.and.right.circle", @"tooltip":@"Extend Edit to Playhead — ⇧X"}, + @{@"id":@"nudgeLeft", @"type":@"timeline", @"category":@"Trim", @"icon":@"arrow.left", @"tooltip":@"Nudge Left — ,"}, + @{@"id":@"nudgeRight", @"type":@"timeline", @"category":@"Trim", @"icon":@"arrow.right", @"tooltip":@"Nudge Right — ."}, + // ── View ───────────────────────────────────────────────────────── + @{@"id":@"zoomToFit", @"type":@"timeline", @"category":@"View", @"icon":@"arrow.up.left.and.arrow.down.right", @"tooltip":@"Zoom to Fit — ⇧Z"}, + @{@"id":@"zoomIn", @"type":@"timeline", @"category":@"View", @"icon":@"plus.magnifyingglass", @"tooltip":@"Zoom In — ⌘="}, + @{@"id":@"zoomOut", @"type":@"timeline", @"category":@"View", @"icon":@"minus.magnifyingglass", @"tooltip":@"Zoom Out — ⌘-"}, + @{@"id":@"toggleInspector", @"type":@"timeline", @"category":@"View", @"icon":@"sidebar.right", @"tooltip":@"Toggle Inspector — ⌘4"}, + @{@"id":@"toggleSnapping", @"type":@"timeline", @"category":@"View", @"icon":@"magnet", @"tooltip":@"Toggle Snapping — N"}, + @{@"id":@"toggleSkimming", @"type":@"timeline", @"category":@"View", @"icon":@"eye", @"tooltip":@"Toggle Skimming — S"}, + ]; + }); + return sActions; +} + +static NSDictionary *SCB_actionForID(NSString *actionID) { + for (NSDictionary *a in SpliceKit_getAvailableTimecodeBarActions()) + if ([a[@"id"] isEqualToString:actionID]) return a; + return nil; +} + +// ────────────────────────────────────────────────────────────────────────────── +#pragma mark - Action dispatch +// ────────────────────────────────────────────────────────────────────────────── + +extern NSDictionary *SpliceKit_handleTimelineAction(NSDictionary *params); + +static void SCB_fireAction(NSString *actionID, NSString *type) { + if (!actionID) return; + if ([type isEqualToString:@"playback"]) { + static NSDictionary *map = nil; + if (!map) map = @{ + @"goToStart": @"goToStart:", + @"goToEnd": @"goToEnd:", + @"playPause": @"playPause:", + @"nextFrame": @"nextFrame:", + @"prevFrame": @"previousFrame:", + }; + NSString *sel = map[actionID]; + if (sel) [[NSApplication sharedApplication] sendAction:NSSelectorFromString(sel) to:nil from:nil]; + } else { + // timeline action — goes through the existing well-tested dispatcher + SpliceKit_handleTimelineAction(@{@"action": actionID}); + } +} + +// ────────────────────────────────────────────────────────────────────────────── +#pragma mark - Shortcut bar view +// ────────────────────────────────────────────────────────────────────────────── + +@interface SpliceKitShortcutsBar : NSView +- (void)reloadFromConfig:(NSArray *)config; +@end + +@implementation SpliceKitShortcutsBar + +- (instancetype)init { + self = [super initWithFrame:NSZeroRect]; + self.translatesAutoresizingMaskIntoConstraints = NO; + self.clipsToBounds = YES; + return self; +} + +- (void)reloadFromConfig:(NSArray *)config { + // Remove existing buttons + for (NSView *sv in self.subviews.copy) [sv removeFromSuperview]; + if (!config.count) return; + + NSButton *prev = nil; + for (NSDictionary *item in config) { + NSString *actionID = item[@"id"]; + if (!actionID) continue; + // Fill in icon/tooltip from catalogue if the config entry doesn't have them + NSDictionary *meta = SCB_actionForID(actionID); + NSString *type = item[@"type"] ?: meta[@"type"] ?: @"timeline"; + NSString *icon = item[@"icon"] ?: meta[@"icon"] ?: @"questionmark.circle"; + NSString *tooltip = item[@"tooltip"] ?: meta[@"tooltip"] ?: actionID; + + NSButton *btn = [NSButton buttonWithTitle:@"" target:self action:@selector(_buttonClicked:)]; + btn.identifier = [NSString stringWithFormat:@"%@|%@", actionID, type]; + btn.toolTip = tooltip; + btn.bezelStyle = NSBezelStyleInline; + btn.bordered = NO; + btn.translatesAutoresizingMaskIntoConstraints = NO; + + // Build a symbol image at a size that matches FCP's own inline icons (~12pt) + NSImage *img = [NSImage imageWithSystemSymbolName:icon accessibilityDescription:tooltip]; + if (img) { + NSImageSymbolConfiguration *cfg = [NSImageSymbolConfiguration + configurationWithPointSize:11.5 + weight:NSFontWeightRegular + scale:NSImageSymbolScaleSmall]; + btn.image = [img imageWithSymbolConfiguration:cfg]; + btn.imageScaling = NSImageScaleProportionallyUpOrDown; + } + + [self addSubview:btn]; + [NSLayoutConstraint activateConstraints:@[ + [btn.centerYAnchor constraintEqualToAnchor:self.centerYAnchor], + [btn.widthAnchor constraintEqualToConstant:kSCBButtonSize], + [btn.heightAnchor constraintEqualToConstant:kSCBButtonSize], + ]]; + if (!prev) { + [btn.leadingAnchor constraintEqualToAnchor:self.leadingAnchor constant:kSCBGroupPad].active = YES; + } else { + [btn.leadingAnchor constraintEqualToAnchor:prev.trailingAnchor constant:kSCBButtonGap].active = YES; + } + prev = btn; + } + if (prev) { + [prev.trailingAnchor constraintEqualToAnchor:self.trailingAnchor constant:-kSCBGroupPad].active = YES; + } +} + +- (void)_buttonClicked:(NSButton *)sender { + NSString *raw = sender.identifier; + NSArray *parts = [raw componentsSeparatedByString:@"|"]; + if (parts.count == 2) SCB_fireAction(parts[0], parts[1]); +} + +@end + +// ────────────────────────────────────────────────────────────────────────────── +#pragma mark - View hierarchy navigation +// ────────────────────────────────────────────────────────────────────────────── + +// Returns the NSView that directly parents the toolbar's 20 subviews, or nil. +// Walks up from the active timeline module's view until it finds the +// LKContainerItemView labelled "PEMainEditorContainer", then returns the +// first NSView subview of its LKContainerItemCapView child. +static NSView *SCB_findToolbarView(void) { + @try { + id tm = SpliceKit_getActiveTimelineModule(); + if (!tm) return nil; + + NSView *v = nil; + SEL viewSel = @selector(view); + if ([tm respondsToSelector:viewSel]) + v = ((NSView *(*)(id, SEL))objc_msgSend)(tm, viewSel); + if (!v) return nil; + + for (int depth = 0; depth < 16; depth++) { + v = v.superview; + if (!v) return nil; + if ([[v description] containsString:@"PEMainEditorContainer"]) { + // v is the LKContainerItemView — find its LKContainerItemCapView + for (NSView *sv in v.subviews) { + if ([NSStringFromClass([sv class]) isEqualToString:@"LKContainerItemCapView"]) { + return sv.subviews.firstObject; + } + } + return nil; + } + } + } @catch (__unused NSException *e) {} + return nil; +} + +// ────────────────────────────────────────────────────────────────────────────── +#pragma mark - Constraint surgery +// ────────────────────────────────────────────────────────────────────────────── + +// Walk "X.trailing == Y.leading - C" chains leftward from snappingCtrl to find +// the leftmost view already pinned in the flex gap (e.g. transition audition +// buttons, any other SpliceKit toolbar buttons). Returns snappingCtrl itself +// if nothing is pinned to its left. +static NSView *SCB_findRightAnchor(NSView *toolbar, NSView *snappingCtrl) { + // Build a map: rightView → leftView for == trailing/leading pairs. + NSMutableDictionary *pred = [NSMutableDictionary dictionary]; + for (NSLayoutConstraint *c in toolbar.constraints) { + if (c.relation != NSLayoutRelationEqual) continue; + if (c.firstAttribute != NSLayoutAttributeTrailing) continue; + if (c.secondAttribute != NSLayoutAttributeLeading) continue; + id first = c.firstItem, second = c.secondItem; + if (!first || !second) continue; + // Accept any first-class view pinned to the snapping ctrl or another NSButton. + NSString *secondCls = NSStringFromClass([second class]); + if (!([secondCls isEqualToString:@"LKPaneCapSegmentedControl"] || + [secondCls isEqualToString:@"NSButton"])) continue; + pred[[NSValue valueWithNonretainedObject:second]] = first; + } + // Walk left until no more predecessor. + id current = snappingCtrl; + for (int i = 0; i < 16; i++) { + NSView *p = pred[[NSValue valueWithNonretainedObject:current]]; + if (!p) break; + current = p; + } + return current; // leftmost button, or snappingCtrl if chain was empty +} + +// Finds H:[PEEditorMenuDelayButton]-(>=5)-[LKPaneCapSegmentedControl]. +// The first item is the history-forward button; the second is snapping/skimming. +static BOOL SCB_findFlexConstraint(NSView *toolbar, + NSView * __strong *histFwdOut, + NSView * __strong *snappingOut, + NSLayoutConstraint * __strong *cOut) { + for (NSLayoutConstraint *c in toolbar.constraints) { + id first = c.firstItem, second = c.secondItem; + if (!first || !second) continue; + if (c.relation != NSLayoutRelationGreaterThanOrEqual) continue; + + // Auto Layout normalizes H:[A]-(>=N)-[B] so that firstItem = B (leading-anchored) + // and secondItem = A (trailing reference). Accept both orderings to be safe. + NSString *firstName = NSStringFromClass([first class]); + NSString *secondName = NSStringFromClass([second class]); + + NSView *histFwd = nil, *snapping = nil; + if ([firstName isEqualToString:@"LKPaneCapSegmentedControl"] && + [secondName isEqualToString:@"PEEditorMenuDelayButton"]) { + // Normal (leading-normalized) storage: first=snapping, second=histFwd + snapping = first; + histFwd = second; + } else if ([firstName isEqualToString:@"PEEditorMenuDelayButton"] && + [secondName isEqualToString:@"LKPaneCapSegmentedControl"]) { + // Reverse storage (unlikely but guard against it) + histFwd = first; + snapping = second; + } else { + continue; + } + + if (histFwdOut) *histFwdOut = histFwd; + if (snappingOut) *snappingOut = snapping; + if (cOut) *cOut = c; + return YES; + } + return NO; +} + +// ────────────────────────────────────────────────────────────────────────────── +#pragma mark - Manager +// ────────────────────────────────────────────────────────────────────────────── + +@interface SpliceKitTimecodeBarShortcutsManager : NSObject +// Strong refs to the two anchor views keep them alive across reloads so we +// never need to re-search for the flex constraint. +@property (nonatomic, strong) SpliceKitShortcutsBar *shortcutsBar; +@property (nonatomic, strong) NSLayoutConstraint *origFlexConstraint; +@property (nonatomic, strong) NSArray *injectedConstraints; +@property (nonatomic, strong) NSView *histFwdBtn; // PEEditorMenuDelayButton +@property (nonatomic, strong) NSView *snappingCtrl; // LKPaneCapSegmentedControl +@property (nonatomic, assign) NSInteger installRetries; ++ (instancetype)shared; +- (void)install; +- (void)reload; +@end + +@implementation SpliceKitTimecodeBarShortcutsManager + ++ (instancetype)shared { + static SpliceKitTimecodeBarShortcutsManager *s = nil; + static dispatch_once_t once; + dispatch_once(&once, ^{ s = [[self alloc] init]; }); + return s; +} + +- (instancetype)init { + self = [super init]; + _shortcutsBar = [[SpliceKitShortcutsBar alloc] init]; + return self; +} + +// ── Install ────────────────────────────────────────────────────────────────── + +- (void)install { + SpliceKit_executeOnMainThread(^{ [self _installOnMainThread]; }); +} + +- (void)_installOnMainThread { + // If the bar is already in a toolbar, nothing to do. + if (_shortcutsBar.superview) return; + + NSView *toolbar = SCB_findToolbarView(); + if (!toolbar) { + if (_installRetries++ < 20) { + dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(0.5 * NSEC_PER_SEC)), + dispatch_get_main_queue(), ^{ [self _installOnMainThread]; }); + } else { + SpliceKit_log(@"[TimecodeBarShortcuts] Toolbar not found after 20 retries — giving up"); + } + return; + } + _installRetries = 0; + + NSView *histFwdBtn = nil, *snappingCtrl = nil; + NSLayoutConstraint *flexC = nil; + if (!SCB_findFlexConstraint(toolbar, &histFwdBtn, &snappingCtrl, &flexC)) { + // Constraint not ready yet — retry (FCP may still be laying out on first launch). + if (_installRetries++ < 20) { + dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(0.5 * NSEC_PER_SEC)), + dispatch_get_main_queue(), ^{ [self _installOnMainThread]; }); + } else { + SpliceKit_log(@"[TimecodeBarShortcuts] Flex constraint not found after 20 retries — giving up"); + } + return; + } + + // Keep strong refs to the anchors so reload never needs to search again. + _histFwdBtn = histFwdBtn; + _snappingCtrl = snappingCtrl; + + // Build buttons from saved config and add bar. + NSArray *config = SCB_loadConfig(); + [_shortcutsBar reloadFromConfig:config]; + [toolbar addSubview:_shortcutsBar]; + + // Replace the original flex gap with two gaps flanking our bar. + // For the right-side anchor use the leftmost view already pinned in the gap + // (e.g. transition-audition buttons) so we don't overlap them. + NSView *rightAnchor = SCB_findRightAnchor(toolbar, snappingCtrl); + + _origFlexConstraint = flexC; + flexC.active = NO; + + NSLayoutConstraint *c1 = [NSLayoutConstraint + constraintWithItem:_shortcutsBar + attribute:NSLayoutAttributeLeading + relatedBy:NSLayoutRelationGreaterThanOrEqual + toItem:histFwdBtn + attribute:NSLayoutAttributeTrailing + multiplier:1.0 constant:kSCBGroupPad]; + c1.priority = NSLayoutPriorityDefaultHigh; + + NSLayoutConstraint *c2 = [NSLayoutConstraint + constraintWithItem:rightAnchor + attribute:NSLayoutAttributeLeading + relatedBy:NSLayoutRelationGreaterThanOrEqual + toItem:_shortcutsBar + attribute:NSLayoutAttributeTrailing + multiplier:1.0 constant:kSCBGroupPad]; + c2.priority = NSLayoutPriorityDefaultHigh; + + NSLayoutConstraint *c3 = [_shortcutsBar.centerYAnchor + constraintEqualToAnchor:toolbar.centerYAnchor]; + NSLayoutConstraint *c4 = [_shortcutsBar.heightAnchor + constraintEqualToConstant:kSCBButtonSize + 2.0]; + + [NSLayoutConstraint activateConstraints:@[c1, c2, c3, c4]]; + _injectedConstraints = @[c1, c2, c3, c4]; + + SpliceKit_log(@"[TimecodeBarShortcuts] Installed — %lu shortcuts", (unsigned long)config.count); +} + +// ── Reload ─────────────────────────────────────────────────────────────────── + +- (void)reload { + SpliceKit_executeOnMainThread(^{ + if (_shortcutsBar.superview) { + // Fast path: bar is already placed — just swap the buttons in place. + NSArray *config = SCB_loadConfig(); + [_shortcutsBar reloadFromConfig:config]; + [_shortcutsBar.superview layoutSubtreeIfNeeded]; + SpliceKit_log(@"[TimecodeBarShortcuts] Reloaded — %lu shortcuts", (unsigned long)config.count); + } else { + // Bar not installed yet (or was removed by FCP) — full install. + _installRetries = 0; + [self _installOnMainThread]; + } + }); +} + +@end + +// ────────────────────────────────────────────────────────────────────────────── +#pragma mark - Public C API +// ────────────────────────────────────────────────────────────────────────────── + +void SpliceKit_installTimecodeBarShortcuts(void) { + [[SpliceKitTimecodeBarShortcutsManager shared] install]; +} + +void SpliceKit_reloadTimecodeBarShortcuts(void) { + [[SpliceKitTimecodeBarShortcutsManager shared] reload]; +} + +NSArray *SpliceKit_getTimecodeBarConfig(void) { + return SCB_loadConfig(); +} + +void SpliceKit_setTimecodeBarConfig(NSArray *config) { + // Ensure only the storable keys are written (strip any ephemeral state) + NSMutableArray *clean = [NSMutableArray arrayWithCapacity:config.count]; + for (NSDictionary *item in config) { + if (!item[@"id"]) continue; + NSDictionary *meta = SCB_actionForID(item[@"id"]); + [clean addObject:@{ + @"id": item[@"id"], + @"type": item[@"type"] ?: meta[@"type"] ?: @"timeline", + @"icon": item[@"icon"] ?: meta[@"icon"] ?: @"questionmark.circle", + @"tooltip": item[@"tooltip"] ?: meta[@"tooltip"] ?: item[@"id"], + }]; + } + SCB_saveConfig(clean); + SpliceKit_reloadTimecodeBarShortcuts(); +} diff --git a/Sources/SpliceKitTimelineTabs.h b/Sources/SpliceKitTimelineTabs.h new file mode 100644 index 0000000..5eae02b --- /dev/null +++ b/Sources/SpliceKitTimelineTabs.h @@ -0,0 +1,25 @@ +// +// SpliceKitTimelineTabs.h +// SpliceKit – Named project tabs for Final Cut Pro's timeline navigation +// +// Replaces the awkward back/forward arrow tap-dance with a persistent row of +// named tabs, one per project/sequence visited. Click a tab to jump directly +// to that sequence. An × button on each tab removes it from the bar. +// + +#ifndef SpliceKitTimelineTabs_h +#define SpliceKitTimelineTabs_h + +#import +#import + +// Install the tab bar. Call once after FCP's main window is visible. +void SpliceKit_installTimelineTabs(void); + +// Remove the tab bar. +void SpliceKit_uninstallTimelineTabs(void); + +// Whether the tab bar is currently shown. +BOOL SpliceKit_isTimelineTabsInstalled(void); + +#endif /* SpliceKitTimelineTabs_h */ diff --git a/Sources/SpliceKitTimelineTabs.m b/Sources/SpliceKitTimelineTabs.m new file mode 100644 index 0000000..1c4fd13 --- /dev/null +++ b/Sources/SpliceKitTimelineTabs.m @@ -0,0 +1,759 @@ +// +// SpliceKitTimelineTabs.m +// SpliceKit – Named project tabs for Final Cut Pro's timeline navigation +// +// Creates a dedicated 28 px row between the viewer/browser section +// (PEUpperDeckContainer) and the timeline section (PELowerDeckContainer) by +// shrinking PELowerDeckContainer by kTabBarHeight from its top edge and +// inserting SpliceKitTimelineTabsView as a native subview of the shared +// LKContainerView. No floating panel, no event monitors, no coverage of +// any existing UI. +// +// FCP's layout engine resets child frames on resize; we observe +// NSViewFrameDidChangeNotification on PELowerDeckContainer and re-apply the +// reduction immediately, keeping the dedicated row intact. +// + +#import "SpliceKit.h" +#import +#import +#import + +// --------------------------------------------------------------------------- +// Layout constants +// --------------------------------------------------------------------------- + +static const CGFloat kTabBarHeight = 28.0; +static const CGFloat kTabMinWidth = 72.0; +static const CGFloat kTabMaxWidth = 200.0; +static const CGFloat kTabHPad = 10.0; +static const CGFloat kCloseButtonSize = 14.0; +static const CGFloat kTabSeparator = 1.0; + +// --------------------------------------------------------------------------- +// Colors +// --------------------------------------------------------------------------- + +static NSColor *TT_colorTabActive(void) { return [NSColor colorWithSRGBRed:0.28 green:0.28 blue:0.30 alpha:1.0]; } +static NSColor *TT_colorTabInactive(void) { return [NSColor colorWithSRGBRed:0.18 green:0.18 blue:0.20 alpha:0.92]; } +static NSColor *TT_colorTabHover(void) { return [NSColor colorWithSRGBRed:0.24 green:0.24 blue:0.26 alpha:1.0]; } +static NSColor *TT_colorBarBackground(void){ return [NSColor colorWithSRGBRed:0.13 green:0.13 blue:0.14 alpha:1.0]; } +static NSColor *TT_colorText(void) { return [NSColor colorWithSRGBRed:0.93 green:0.93 blue:0.93 alpha:1.0]; } +static NSColor *TT_colorTextInactive(void) { return [NSColor colorWithSRGBRed:0.65 green:0.65 blue:0.67 alpha:1.0]; } +static NSColor *TT_colorClose(void) { return [NSColor colorWithSRGBRed:0.60 green:0.60 blue:0.62 alpha:1.0]; } +static NSColor *TT_colorCloseHover(void) { return [NSColor colorWithSRGBRed:0.90 green:0.90 blue:0.90 alpha:1.0]; } +static NSColor *TT_colorSeparator(void) { return [NSColor colorWithSRGBRed:0.08 green:0.08 blue:0.09 alpha:1.0]; } + +// --------------------------------------------------------------------------- +// Tab model +// --------------------------------------------------------------------------- + +@interface SpliceKitTabEntry : NSObject +@property (nonatomic, copy) NSString *displayName; +@property (nonatomic, copy) NSString *uid; +@property (nonatomic, strong) id sequenceObject; +@property (nonatomic, assign) uintptr_t sequenceID; +@end +@implementation SpliceKitTabEntry +@end + +// --------------------------------------------------------------------------- +// ObjC helpers +// --------------------------------------------------------------------------- + +static id TT_getEditorContainer(void) { + id app = [NSApplication sharedApplication]; + id delegate = ((id (*)(id, SEL))objc_msgSend)(app, @selector(delegate)); + if (!delegate) return nil; + SEL s = NSSelectorFromString(@"activeEditorContainer"); + if (![delegate respondsToSelector:s]) return nil; + return ((id (*)(id, SEL))objc_msgSend)(delegate, s); +} + +static id TT_currentSequence(void) { + id tm = SpliceKit_getActiveTimelineModule(); + if (!tm) return nil; + SEL s = NSSelectorFromString(@"sequence"); + if (![tm respondsToSelector:s]) return nil; + return ((id (*)(id, SEL))objc_msgSend)(tm, s); +} + +static NSString *TT_sequenceDisplayName(id seq) { + if (!seq) return nil; + SEL s = NSSelectorFromString(@"displayName"); + if (![seq respondsToSelector:s]) return @"Untitled"; + NSString *name = ((id (*)(id, SEL))objc_msgSend)(seq, s); + return name.length ? name : @"Untitled"; +} + +static NSString *TT_sequenceUID(id seq) { + if (!seq) return nil; + for (NSString *selName in @[@"uid", @"uniqueID", @"identifier", @"persistentID"]) { + SEL sel = NSSelectorFromString(selName); + if ([seq respondsToSelector:sel]) { + id val = ((id (*)(id, SEL))objc_msgSend)(seq, sel); + if (val) return [val description]; + } + } + return [NSString stringWithFormat:@"hash-%@-%lx", + TT_sequenceDisplayName(seq), (unsigned long)[seq hash]]; +} + +static NSURL *TT_tabsSaveURL(void) { + NSURL *appSupport = [[[NSFileManager defaultManager] + URLsForDirectory:NSApplicationSupportDirectory + inDomains:NSUserDomainMask] firstObject]; + NSURL *dir = [appSupport URLByAppendingPathComponent:@"SpliceKit"]; + [[NSFileManager defaultManager] createDirectoryAtURL:dir + withIntermediateDirectories:YES attributes:nil error:nil]; + return [dir URLByAppendingPathComponent:@"timeline_tabs.json"]; +} + +// Walk all sequences in every open library, trying UID first then display name. +// UID may be hash-based and stale after restart; display name is the reliable fallback. +static id TT_findSequenceByUIDOrName(NSString *uid, NSString *name) { + @try { + SEL libsSel = NSSelectorFromString(@"copyActiveLibraries"); + Class libDoc = NSClassFromString(@"FFLibraryDocument"); + if (!libDoc || ![libDoc respondsToSelector:libsSel]) return nil; + id libs = ((id (*)(id, SEL))objc_msgSend)(libDoc, libsSel); + if (!libs) return nil; + + id nameMatch = nil; // keep first name match in case UID never hits + + for (NSInteger i = 0, n = [libs count]; i < n; i++) { + id lib = [libs objectAtIndex:i]; + SEL deepSel = NSSelectorFromString(@"_deepLoadedSequences"); + if (![lib respondsToSelector:deepSel]) continue; + id seqSet = ((id (*)(id, SEL))objc_msgSend)(lib, deepSel); + if (!seqSet) continue; + for (id seq in ((id (*)(id, SEL))objc_msgSend)(seqSet, @selector(allObjects))) { + // Exact UID match — return immediately. + if (uid.length && [TT_sequenceUID(seq) isEqualToString:uid]) return seq; + // Name match — store as fallback (prefer first match). + if (!nameMatch && name.length && + [TT_sequenceDisplayName(seq) isEqualToString:name]) { + nameMatch = seq; + } + } + } + return nameMatch; + } @catch (__unused NSException *e) {} + return nil; +} + +// --------------------------------------------------------------------------- +// Tab bar view +// --------------------------------------------------------------------------- + +@interface SpliceKitTimelineTabsView : NSView { + NSInteger _hoveredTabIndex; + NSInteger _hoveredCloseIndex; + NSInteger _dragTabIndex; + NSInteger _dragInsertIndex; + NSPoint _dragStartPoint; + CGFloat _dragCurrentX; + BOOL _dragging; + NSTrackingArea *_trackingArea; +} +@property (nonatomic, strong) NSMutableArray *tabs; +@property (nonatomic, assign) NSInteger activeIndex; +@property (nonatomic, copy) void (^onTabSelected)(NSInteger); +@property (nonatomic, copy) void (^onTabClosed)(NSInteger); +@property (nonatomic, copy) void (^onTabReordered)(NSInteger from, NSInteger to); ++ (instancetype)shared; +- (void)reloadData; +@end + +@implementation SpliceKitTimelineTabsView + ++ (instancetype)shared { + static SpliceKitTimelineTabsView *s; + static dispatch_once_t once; + dispatch_once(&once, ^{ s = [[self alloc] initWithFrame:NSZeroRect]; }); + return s; +} + +- (instancetype)initWithFrame:(NSRect)frame { + self = [super initWithFrame:frame]; + if (self) { + _tabs = [NSMutableArray array]; + _activeIndex = -1; + _hoveredTabIndex = _hoveredCloseIndex = _dragTabIndex = -1; + } + return self; +} + +// ── Layout ────────────────────────────────────────────────────────────────── + +- (CGFloat)tabWidth { + NSInteger n = (NSInteger)_tabs.count; + if (n == 0) return kTabMaxWidth; + return MAX(kTabMinWidth, MIN(kTabMaxWidth, self.bounds.size.width / n)); +} + +- (NSRect)rectForTabAtIndex:(NSInteger)i { + CGFloat w = [self tabWidth]; + return NSMakeRect(i * (w + kTabSeparator), 0, w, kTabBarHeight); +} + +- (NSRect)closeRectForTabAtIndex:(NSInteger)i { + NSRect t = [self rectForTabAtIndex:i]; + CGFloat m = (kTabBarHeight - kCloseButtonSize) / 2.0; + return NSMakeRect(NSMaxX(t) - m - kCloseButtonSize, m, kCloseButtonSize, kCloseButtonSize); +} + +- (NSInteger)tabIndexAtPoint:(NSPoint)pt closeHit:(BOOL *)ch { + for (NSInteger i = 0, n = (NSInteger)_tabs.count; i < n; i++) { + if (!NSPointInRect(pt, [self rectForTabAtIndex:i])) continue; + if (ch) *ch = NSPointInRect(pt, [self closeRectForTabAtIndex:i]); + return i; + } + if (ch) *ch = NO; + return -1; +} + +// ── Drawing ────────────────────────────────────────────────────────────────── + +- (void)_drawTab:(NSInteger)i inRect:(NSRect)tabRect isActive:(BOOL)isActive isHover:(BOOL)isHover { + NSColor *bg = isActive ? TT_colorTabActive() : isHover ? TT_colorTabHover() : TT_colorTabInactive(); + [bg setFill]; + [[NSBezierPath bezierPathWithRoundedRect:NSInsetRect(tabRect, 0, 2) xRadius:4 yRadius:4] fill]; + + NSRect cr = NSMakeRect(NSMaxX(tabRect) - (kTabBarHeight - kCloseButtonSize) / 2.0 - kCloseButtonSize, + (kTabBarHeight - kCloseButtonSize) / 2.0, kCloseButtonSize, kCloseButtonSize); + BOOL chov = !_dragging && (i == _hoveredCloseIndex); + NSString *xg = @"✕"; + NSDictionary *xa = @{ + NSFontAttributeName: [NSFont systemFontOfSize:9 weight:NSFontWeightMedium], + NSForegroundColorAttributeName: chov ? TT_colorCloseHover() : TT_colorClose(), + }; + NSSize xs = [xg sizeWithAttributes:xa]; + [xg drawAtPoint:NSMakePoint(cr.origin.x + (cr.size.width - xs.width) / 2.0, + cr.origin.y + (cr.size.height - xs.height) / 2.0) + withAttributes:xa]; + + NSDictionary *ta = @{ + NSFontAttributeName: [NSFont systemFontOfSize:11.5 + weight:isActive ? NSFontWeightMedium : NSFontWeightRegular], + NSForegroundColorAttributeName: isActive ? TT_colorText() : TT_colorTextInactive(), + }; + CGFloat w = tabRect.size.width; + NSRect tr = NSMakeRect(tabRect.origin.x + kTabHPad, + (kTabBarHeight - 14.0) / 2.0, + w - kTabHPad - (cr.size.width + 4.0) - kTabHPad, 14.0); + NSMutableAttributedString *mas = [[[NSAttributedString alloc] + initWithString:_tabs[i].displayName attributes:ta] mutableCopy]; + NSMutableParagraphStyle *ps = [[NSMutableParagraphStyle alloc] init]; + ps.lineBreakMode = NSLineBreakByTruncatingTail; + [mas addAttribute:NSParagraphStyleAttributeName value:ps range:NSMakeRange(0, mas.length)]; + [mas drawInRect:tr]; +} + +- (void)drawRect:(NSRect)dirtyRect { + // Full background — we own this dedicated row so we can fill it. + [TT_colorBarBackground() setFill]; + NSRectFill(self.bounds); + + NSInteger n = (NSInteger)_tabs.count; + if (n == 0) { + [TT_colorSeparator() setFill]; + NSRectFill(NSMakeRect(0, 0, self.bounds.size.width, 1)); + return; + } + + static const CGFloat kDrop = 3.0; + + if (_dragging) { + CGFloat x = 0, w = [self tabWidth]; + NSInteger slot = 0; + for (NSInteger i = 0; i < n; i++) { + if (i == _dragTabIndex) continue; + if (slot == _dragInsertIndex) { + [[NSColor colorWithSRGBRed:0.40 green:0.55 blue:0.90 alpha:0.9] setFill]; + NSRectFill(NSMakeRect(x, 2, kDrop, kTabBarHeight - 4)); + x += kDrop + 2.0; + } + NSRect tr = NSMakeRect(x, 0, w, kTabBarHeight); + if (slot > 0) { [TT_colorSeparator() setFill]; NSRectFill(NSMakeRect(x - kTabSeparator, 2, kTabSeparator, kTabBarHeight - 4)); } + [self _drawTab:i inRect:tr isActive:(i == _activeIndex) isHover:NO]; + x += w + kTabSeparator; + slot++; + } + if (slot == _dragInsertIndex) { + [[NSColor colorWithSRGBRed:0.40 green:0.55 blue:0.90 alpha:0.9] setFill]; + NSRectFill(NSMakeRect(x, 2, kDrop, kTabBarHeight - 4)); + } + // Ghost tab at cursor + CGFloat w2 = [self tabWidth]; + NSRect ghost = NSMakeRect(_dragCurrentX - w2 / 2.0, 0, w2, kTabBarHeight); + [[NSGraphicsContext currentContext] saveGraphicsState]; + CGContextSetAlpha([[NSGraphicsContext currentContext] CGContext], 0.55); + [self _drawTab:_dragTabIndex inRect:ghost isActive:(_dragTabIndex == _activeIndex) isHover:NO]; + [[NSGraphicsContext currentContext] restoreGraphicsState]; + } else { + for (NSInteger i = 0; i < n; i++) { + NSRect tabRect = [self rectForTabAtIndex:i]; + if (NSMaxX(tabRect) > self.bounds.size.width) break; + if (i > 0) { [TT_colorSeparator() setFill]; NSRectFill(NSMakeRect(tabRect.origin.x - kTabSeparator, 2, kTabSeparator, kTabBarHeight - 4)); } + [self _drawTab:i inRect:tabRect isActive:(i == _activeIndex) isHover:(i == _hoveredTabIndex)]; + } + } + + [TT_colorSeparator() setFill]; + NSRectFill(NSMakeRect(0, 0, self.bounds.size.width, 1)); +} + +// ── Insertion index for drag ───────────────────────────────────────────────── + +- (NSInteger)_insertIndexForDragX:(CGFloat)cx { + CGFloat x = 0, w = [self tabWidth]; + NSInteger slot = 0; + for (NSInteger i = 0, n = (NSInteger)_tabs.count; i < n; i++) { + if (i == _dragTabIndex) continue; + if (cx < x + w / 2.0) return slot; + x += w + kTabSeparator; + slot++; + } + return slot; +} + +// ── Mouse tracking ─────────────────────────────────────────────────────────── + +- (void)updateTrackingAreas { + [super updateTrackingAreas]; + if (_trackingArea) [self removeTrackingArea:_trackingArea]; + _trackingArea = [[NSTrackingArea alloc] + initWithRect:self.bounds + options:NSTrackingMouseMoved | NSTrackingMouseEnteredAndExited | NSTrackingActiveAlways + owner:self userInfo:nil]; + [self addTrackingArea:_trackingArea]; +} + +- (void)mouseMoved:(NSEvent *)e { + if (_dragging) return; + NSPoint pt = [self convertPoint:e.locationInWindow fromView:nil]; + BOOL ch = NO; + NSInteger idx = [self tabIndexAtPoint:pt closeHit:&ch]; + NSInteger pc = _hoveredCloseIndex, pt2 = _hoveredTabIndex; + _hoveredTabIndex = idx; + _hoveredCloseIndex = (idx >= 0 && ch) ? idx : -1; + if (_hoveredTabIndex != pt2 || _hoveredCloseIndex != pc) [self setNeedsDisplay:YES]; +} + +- (void)mouseExited:(NSEvent *)e { + _hoveredTabIndex = _hoveredCloseIndex = -1; + [self setNeedsDisplay:YES]; +} + +- (void)mouseDown:(NSEvent *)e { + NSPoint pt = [self convertPoint:e.locationInWindow fromView:nil]; + BOOL ch = NO; + NSInteger idx = [self tabIndexAtPoint:pt closeHit:&ch]; + if (idx < 0) return; + if (ch) { if (_onTabClosed) _onTabClosed(idx); } + else { _dragTabIndex = idx; _dragStartPoint = pt; _dragging = NO; } +} + +- (void)mouseDragged:(NSEvent *)e { + if (_dragTabIndex < 0) return; + NSPoint pt = [self convertPoint:e.locationInWindow fromView:nil]; + if (!_dragging) { + if (ABS(pt.x - _dragStartPoint.x) < 4.0 && ABS(pt.y - _dragStartPoint.y) < 4.0) return; + _dragging = YES; + _hoveredTabIndex = _hoveredCloseIndex = -1; + } + _dragCurrentX = pt.x; + _dragInsertIndex = [self _insertIndexForDragX:pt.x]; + [self setNeedsDisplay:YES]; +} + +- (void)mouseUp:(NSEvent *)e { + NSPoint pt = [self convertPoint:e.locationInWindow fromView:nil]; + if (_dragging) { + _dragging = NO; + NSInteger from = _dragTabIndex, to = [self _insertIndexForDragX:_dragCurrentX]; + if (to != from && _onTabReordered) _onTabReordered(from, to); + _dragTabIndex = -1; + [self setNeedsDisplay:YES]; + } else if (_dragTabIndex >= 0) { + BOOL ch = NO; + NSInteger idx = [self tabIndexAtPoint:pt closeHit:&ch]; + if (idx >= 0 && !ch && _onTabSelected) _onTabSelected(idx); + _dragTabIndex = -1; + } +} + +- (void)reloadData { [self setNeedsDisplay:YES]; } + +@end + +// --------------------------------------------------------------------------- +// Tab manager +// --------------------------------------------------------------------------- + +@interface SpliceKitTimelineTabs : NSObject +@property (nonatomic, strong) NSMutableArray *tabs; +@property (nonatomic, assign) NSInteger activeIndex; +@property (nonatomic, strong) NSMutableArray *observerTokens; +@property (nonatomic, assign) NSInteger installRetries; +// Native layout: the views we modify to create the dedicated tab row. +@property (nonatomic, weak) NSView *lkContainerView; // LKContainerView (5 levels up) +@property (nonatomic, weak) NSView *lowerDeckView; // PELowerDeckContainer +@property (nonatomic, assign) BOOL applyingLayout; // guard against notification loops ++ (instancetype)shared; +- (void)install; +- (void)uninstall; +- (void)onSequenceChanged; +- (void)selectTabAtIndex:(NSInteger)idx; +- (void)closeTabAtIndex:(NSInteger)idx; +- (void)reorderTabFrom:(NSInteger)from to:(NSInteger)to; +@end + +static SpliceKitTimelineTabs *sTabsManager = nil; + +@implementation SpliceKitTimelineTabs + ++ (instancetype)shared { + static dispatch_once_t once; + dispatch_once(&once, ^{ sTabsManager = [[self alloc] init]; }); + return sTabsManager; +} + +- (instancetype)init { + self = [super init]; + if (self) { + _tabs = [NSMutableArray array]; + _activeIndex = -1; + _observerTokens = [NSMutableArray array]; + } + return self; +} + +// ── Sequence tracking ──────────────────────────────────────────────────────── + +- (void)onSequenceChanged { + dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(0.05 * NSEC_PER_SEC)), + dispatch_get_main_queue(), ^{ [self _updateTabsForCurrentSequence]; }); +} + +- (void)_updateTabsForCurrentSequence { + id seq = TT_currentSequence(); + if (!seq) return; + uintptr_t seqID = (uintptr_t)(__bridge void *)seq; + NSString *uid = TT_sequenceUID(seq); + NSString *name = TT_sequenceDisplayName(seq); + + NSInteger existingIdx = -1; + for (NSInteger i = 0; i < (NSInteger)_tabs.count; i++) { + SpliceKitTabEntry *t = _tabs[i]; + if (t.sequenceID == seqID || + (uid.length && [t.uid isEqualToString:uid]) || + (name.length && [t.displayName isEqualToString:name])) // name fallback for stale UIDs + { existingIdx = i; break; } + } + if (existingIdx >= 0) { + _activeIndex = existingIdx; + _tabs[existingIdx].displayName = name; + _tabs[existingIdx].uid = uid; + _tabs[existingIdx].sequenceObject = seq; + _tabs[existingIdx].sequenceID = seqID; + } else { + SpliceKitTabEntry *e = [SpliceKitTabEntry new]; + e.displayName = name; e.uid = uid; e.sequenceObject = seq; e.sequenceID = seqID; + [_tabs addObject:e]; + _activeIndex = (NSInteger)_tabs.count - 1; + } + [self _syncView]; [self _saveTabState]; +} + +- (void)selectTabAtIndex:(NSInteger)idx { + if (idx < 0 || idx >= (NSInteger)_tabs.count || idx == _activeIndex) return; + SpliceKitTabEntry *entry = _tabs[idx]; + + // Resolve the sequence object lazily from the saved UID. + // Resolve the sequence — try UID first, then fall back to display name. + // Hash-based UIDs (saved when no persistent ID was available) become stale + // after restart, so the name fallback is essential. + if (!entry.sequenceObject) { + entry.sequenceObject = TT_findSequenceByUIDOrName(entry.uid, entry.displayName); + if (entry.sequenceObject) { + entry.sequenceID = (uintptr_t)(__bridge void *)entry.sequenceObject; + // Refresh the UID now that we have a live object (may be more stable). + entry.uid = TT_sequenceUID(entry.sequenceObject) ?: entry.uid; + } + } + if (!entry.sequenceObject) { + SpliceKit_log(@"[TimelineTabs] Cannot navigate to '%@' — not found by UID or name", + entry.displayName); + return; + } + + // Highlight the tab immediately so the user sees their click was registered. + // FCP fires activeRootItemDidChange asynchronously; _updateTabsForCurrentSequence + // will confirm and persist the new activeIndex once FCP finishes loading. + _activeIndex = idx; + [self _syncView]; + + id ec = TT_getEditorContainer(); + SEL s = NSSelectorFromString(@"loadEditorForSequence:"); + if (ec && [ec respondsToSelector:s]) + ((void (*)(id,SEL,id))objc_msgSend)(ec, s, entry.sequenceObject); +} + +- (void)closeTabAtIndex:(NSInteger)idx { + if (idx < 0 || idx >= (NSInteger)_tabs.count) return; + BOOL wasActive = (idx == _activeIndex); + [_tabs removeObjectAtIndex:idx]; + if (_tabs.count == 0) { _activeIndex = -1; } + else if (wasActive) { NSInteger ni = idx > 0 ? idx - 1 : 0; _activeIndex = ni; [self selectTabAtIndex:ni]; return; } + else if (_activeIndex > idx) { _activeIndex--; } + [self _syncView]; [self _saveTabState]; +} + +- (void)reorderTabFrom:(NSInteger)from to:(NSInteger)to { + NSInteger n = (NSInteger)_tabs.count; + if (from < 0 || from >= n || to < 0 || to > n || from == to) return; + SpliceKitTabEntry *entry = _tabs[from]; + [_tabs removeObjectAtIndex:from]; + NSInteger insertAt = MAX(0, MIN(to, (NSInteger)_tabs.count)); + [_tabs insertObject:entry atIndex:insertAt]; + if (_activeIndex == from) _activeIndex = insertAt; + else if (from < _activeIndex && insertAt >= _activeIndex) _activeIndex--; + else if (from > _activeIndex && insertAt <= _activeIndex) _activeIndex++; + [self _syncView]; [self _saveTabState]; +} + +- (void)_syncView { + SpliceKitTimelineTabsView *v = [SpliceKitTimelineTabsView shared]; + v.tabs = _tabs; v.activeIndex = _activeIndex; [v reloadData]; +} + +// ── Persistence ────────────────────────────────────────────────────────────── + +- (void)_saveTabState { + NSMutableArray *arr = [NSMutableArray array]; + for (SpliceKitTabEntry *e in _tabs) + [arr addObject:@{@"name": e.displayName ?: @"", @"uid": e.uid ?: @""}]; + NSDictionary *dict = @{@"tabs": arr, @"activeIndex": @(_activeIndex)}; + NSData *data = [NSJSONSerialization dataWithJSONObject:dict options:0 error:nil]; + if (data) [data writeToURL:TT_tabsSaveURL() atomically:YES]; +} + +- (void)_loadTabState { + NSData *data = [NSData dataWithContentsOfURL:TT_tabsSaveURL()]; + if (!data) return; + NSDictionary *dict = [NSJSONSerialization JSONObjectWithData:data options:0 error:nil]; + if (![dict isKindOfClass:[NSDictionary class]]) return; + NSArray *arr = dict[@"tabs"]; + if (![arr isKindOfClass:[NSArray class]]) return; + [_tabs removeAllObjects]; + for (NSDictionary *item in arr) { + SpliceKitTabEntry *e = [SpliceKitTabEntry new]; + e.displayName = item[@"name"] ?: @"Untitled"; + e.uid = item[@"uid"] ?: @""; + [_tabs addObject:e]; + } + NSInteger si = [dict[@"activeIndex"] integerValue]; + _activeIndex = _tabs.count > 0 ? MAX(0, MIN(si, (NSInteger)_tabs.count - 1)) : -1; + SpliceKit_log(@"[TimelineTabs] Loaded %lu tabs", (unsigned long)_tabs.count); +} + +// ── Native layout: create dedicated row ────────────────────────────────────── + +// Walk up from timeline module view until we find the LKContainerView that +// directly contains BOTH PELowerDeckContainer and PEUpperDeckContainer. +// From our live exploration that is 7 levels up (not 5 — there is an extra +// LKContainerView + LKContainerItemView layer around PELowerDeckContainer). +- (NSView *)_findLKContainerView { + @try { + id tm = SpliceKit_getActiveTimelineModule(); + if (!tm) return nil; + NSView *v = [(id)tm performSelector:@selector(view)]; + for (int i = 0; i < 10; i++) { // walk up to 10 levels, stop when we find both decks + v = v.superview; + if (!v) return nil; + BOOL hasLower = NO, hasUpper = NO; + for (NSView *sv in v.subviews) { + NSString *desc = [sv description]; + if ([desc containsString:@"LowerDeck"]) hasLower = YES; + if ([desc containsString:@"UpperDeck"]) hasUpper = YES; + } + if (hasLower && hasUpper) return v; + } + } @catch (__unused NSException *e) {} + return nil; +} + +// Apply (or re-apply) the 28px reduction to PELowerDeckContainer. +// Positions the tab view in the gap and marks the guard flag. +- (void)_applyLowerDeckReduction { + if (_applyingLayout || !_lowerDeckView || !_lkContainerView) return; + + NSRect deckFrame = _lowerDeckView.frame; + if (deckFrame.size.height <= kTabBarHeight) return; // deck too small + + SpliceKitTimelineTabsView *tabView = [SpliceKitTimelineTabsView shared]; + + // Compute what the reduced deck frame and tab frame should be. + // Must account for whether LKContainerView is flipped (y=0 at top, y increases + // downward — typical for LunaKit) or non-flipped (y=0 at bottom). + NSRect newDeckFrame = deckFrame; + NSRect tabFrame; + + if (_lkContainerView.isFlipped) { + // Flipped: deckFrame.origin.y is the TOP edge of the lower deck. + // Shrink from the top → push origin.y DOWN (larger y), reduce height. + newDeckFrame.origin.y += kTabBarHeight; + newDeckFrame.size.height -= kTabBarHeight; + // Tab bar sits at the OLD top edge (the 28 px gap we opened). + tabFrame = NSMakeRect(deckFrame.origin.x, deckFrame.origin.y, + deckFrame.size.width, kTabBarHeight); + } else { + // Non-flipped: deckFrame.origin.y is the BOTTOM edge. + // Shrink from the top → keep origin.y, reduce height. + newDeckFrame.size.height -= kTabBarHeight; + // Tab bar sits at the new top edge of the lower deck. + tabFrame = NSMakeRect(deckFrame.origin.x, + newDeckFrame.origin.y + newDeckFrame.size.height, + deckFrame.size.width, kTabBarHeight); + } + + // Skip if already applied (avoids thrashing when the observer fires for our own setFrame:). + if (tabView.superview == _lkContainerView && + ABS(_lowerDeckView.frame.size.height - newDeckFrame.size.height) < 0.5 && + ABS(tabView.frame.origin.y - tabFrame.origin.y) < 0.5) return; + + _applyingLayout = YES; + _lowerDeckView.frame = newDeckFrame; + if (tabView.superview != _lkContainerView) { + tabView.autoresizingMask = NSViewWidthSizable; + [_lkContainerView addSubview:tabView]; + } + tabView.frame = tabFrame; + _applyingLayout = NO; + + SpliceKit_log(@"[TimelineTabs] Applied reduction: deck h=%.0f tab y=%.0f (flipped=%d)", + newDeckFrame.size.height, tabFrame.origin.y, (int)_lkContainerView.isFlipped); +} + +// ── Install ─────────────────────────────────────────────────────────────────── + +- (void)install { + [self _loadTabState]; + SpliceKit_executeOnMainThread(^{ [self _installOnMainThread]; }); +} + +- (void)_installOnMainThread { + NSView *lkContainer = [self _findLKContainerView]; + if (!lkContainer) { + if (_installRetries++ < 20) { + dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(0.5 * NSEC_PER_SEC)), + dispatch_get_main_queue(), ^{ [self _installOnMainThread]; }); + } else { + SpliceKit_log(@"[TimelineTabs] LKContainerView not found — giving up"); + } + return; + } + _installRetries = 0; + + // Find PELowerDeckContainer among LKContainerView's subviews. + NSView *lowerDeck = nil; + for (NSView *sv in lkContainer.subviews) { + if ([[sv description] containsString:@"LowerDeck"]) { lowerDeck = sv; break; } + } + if (!lowerDeck) { + SpliceKit_log(@"[TimelineTabs] PELowerDeckContainer not found"); + return; + } + + _lkContainerView = lkContainer; + _lowerDeckView = lowerDeck; + + // Wire up the tab view callbacks. + SpliceKitTimelineTabsView *tabView = [SpliceKitTimelineTabsView shared]; + tabView.tabs = _tabs; tabView.activeIndex = _activeIndex; + __weak SpliceKitTimelineTabs *weakSelf = self; + tabView.onTabSelected = ^(NSInteger i) { [weakSelf selectTabAtIndex:i]; }; + tabView.onTabClosed = ^(NSInteger i) { [weakSelf closeTabAtIndex:i]; }; + tabView.onTabReordered = ^(NSInteger f, NSInteger t) { [weakSelf reorderTabFrom:f to:t]; }; + + // Create the dedicated row. + [self _applyLowerDeckReduction]; + + // Re-apply whenever FCP's layout engine resets the lower deck frame. + lowerDeck.postsFrameChangedNotifications = YES; + NSNotificationCenter *nc = [NSNotificationCenter defaultCenter]; + id t1 = [nc addObserverForName:NSViewFrameDidChangeNotification object:lowerDeck queue:nil + usingBlock:^(NSNotification *n) { + [weakSelf _applyLowerDeckReduction]; + }]; + // Also track sequence changes. + id t2 = [nc addObserverForName:@"activeRootItemDidChange" object:nil queue:nil + usingBlock:^(NSNotification *n) { + dispatch_async(dispatch_get_main_queue(), ^{ [weakSelf onSequenceChanged]; }); + }]; + id t3 = [nc addObserverForName:@"PEActivePlayerModuleDidChangeNotification" object:nil queue:nil + usingBlock:^(NSNotification *n) { + dispatch_async(dispatch_get_main_queue(), ^{ [weakSelf onSequenceChanged]; }); + }]; + id t4 = [nc addObserverForName:@"FFTimelineIndexDidReloadArrangedItemsNotification" object:nil queue:nil + usingBlock:^(NSNotification *n) { + dispatch_async(dispatch_get_main_queue(), ^{ [weakSelf onSequenceChanged]; }); + }]; + [_observerTokens addObjectsFromArray:@[t1, t2, t3, t4]]; + + [self _syncView]; + [self onSequenceChanged]; + SpliceKit_log(@"[TimelineTabs] Installed as native subview of LKContainerView"); +} + +// ── Uninstall ───────────────────────────────────────────────────────────────── + +- (void)uninstall { + SpliceKit_executeOnMainThread(^{ + NSNotificationCenter *nc = [NSNotificationCenter defaultCenter]; + for (id tok in _observerTokens) [nc removeObserver:tok]; + [_observerTokens removeAllObjects]; + + // Restore lower deck to its natural size and remove our tab view. + if (_lowerDeckView) { + SpliceKitTimelineTabsView *tv = [SpliceKitTimelineTabsView shared]; + if (tv.superview == _lkContainerView) { + // Restore the 28px we took. + NSRect f = _lowerDeckView.frame; + f.size.height += kTabBarHeight; + _lowerDeckView.frame = f; + [tv removeFromSuperview]; + } + } + [_tabs removeAllObjects]; + _activeIndex = -1; + _lowerDeckView = nil; + _lkContainerView = nil; + }); +} + +@end + +// --------------------------------------------------------------------------- +// Public C API +// --------------------------------------------------------------------------- + +static BOOL sTabsInstalled = NO; + +void SpliceKit_installTimelineTabs(void) { + if (sTabsInstalled) return; + sTabsInstalled = YES; + [[SpliceKitTimelineTabs shared] install]; +} + +void SpliceKit_uninstallTimelineTabs(void) { + if (!sTabsInstalled) return; + sTabsInstalled = NO; + [[SpliceKitTimelineTabs shared] uninstall]; +} + +BOOL SpliceKit_isTimelineTabsInstalled(void) { + return sTabsInstalled; +} diff --git a/Sources/SpliceKitUndoHistoryPanel.h b/Sources/SpliceKitUndoHistoryPanel.h new file mode 100644 index 0000000..278604e --- /dev/null +++ b/Sources/SpliceKitUndoHistoryPanel.h @@ -0,0 +1,64 @@ +// +// SpliceKitUndoHistoryPanel.h +// SpliceKit – Floating Undo History palette for Final Cut Pro +// +// Intercepts NSUndoManager group-close notifications to build a persistent +// ordered list of every action pushed onto the undo stack, including those +// registered before the panel was first opened. Redo entries (actions that +// were done then undone) are shown greyed-out below the current state. +// + +#ifndef SpliceKitUndoHistoryPanel_h +#define SpliceKitUndoHistoryPanel_h + +#import +#import + +// --------------------------------------------------------------------------- +// Data model +// --------------------------------------------------------------------------- + +@interface SpliceKitUndoEntry : NSObject +@property (nonatomic, copy) NSString *actionName; +@property (nonatomic, strong) NSDate *timestamp; +@property (nonatomic) NSInteger index; // position in the history array +@end + +// --------------------------------------------------------------------------- +// Panel +// --------------------------------------------------------------------------- + +@interface SpliceKitUndoHistoryPanel : NSObject + ++ (instancetype)sharedPanel; + +// Called once from SpliceKit.m after the app finishes launching. +// Installs NSNotificationCenter observers on all NSUndoManagers in the process. ++ (void)installHooks; + +// Panel visibility +- (void)showPanel; +- (void)hidePanel; +- (BOOL)isVisible; + +// RPC surface --------------------------------------------------------------- + +// Returns the full history as an array of entry dictionaries plus the cursor. +// { +// "entries": [{ "index": 0, "name": "Blade", "timestamp": "HH:MM:SS" }, ...], +// "cursor": 2, // index of current state (-1 = pristine/nothing done) +// "canUndo": true, +// "canRedo": false +// } +- (NSDictionary *)getHistory; + +// Jump to a specific history index by performing undo or redo as needed. +// Pass -1 to jump all the way back to the pristine (fully-undone) state. +- (NSDictionary *)jumpToIndex:(NSInteger)targetIndex; + +// Clear the local history buffer (does NOT touch FCP's undo stack). +- (void)clearHistory; + +@end + +#endif /* SpliceKitUndoHistoryPanel_h */ diff --git a/Sources/SpliceKitUndoHistoryPanel.m b/Sources/SpliceKitUndoHistoryPanel.m new file mode 100644 index 0000000..49f542c --- /dev/null +++ b/Sources/SpliceKitUndoHistoryPanel.m @@ -0,0 +1,465 @@ +// +// SpliceKitUndoHistoryPanel.m +// SpliceKit – Floating Undo History palette for Final Cut Pro +// +// Architecture +// ─────────── +// • Observes NSUndoManager group notifications (DidOpen / DidClose / Will+DidUndo / +// Will+DidRedo) with object:nil so we catch every undo manager in the process. +// • Tracks per-manager group nesting depth in a weak-keyed NSMapTable so nested +// beginUndoGrouping/endUndoGrouping pairs don't generate spurious entries — only +// the outermost close (depth 0→1→0) produces a new history entry. +// • Suppresses recording during undo/redo replays (undo handlers register new actions +// on the redo stack; we don't want those treated as fresh user actions). +// • Stores up to kMaxEntries=100 SpliceKitUndoEntry objects in _entries (oldest→newest). +// _cursor is the index of the last "done" entry (-1 = pristine state). +// • The NSPanel shows a single-column NSTableView; row 0 is always the synthetic +// "Pristine" row, rows 1..N correspond to _entries[0].._entries[N-1]. +// Rows ≤ (_cursor+1) are current/past; rows > (_cursor+1) are redo-available (grey). +// Clicking any row calls -jumpToIndex: which loops undo/redo to reach that state. +// + +#import "SpliceKitUndoHistoryPanel.h" +#import "SpliceKit.h" +#import + +// --------------------------------------------------------------------------- +// Constants +// --------------------------------------------------------------------------- + +static const NSInteger kMaxEntries = 100; +static NSString * const kPrisitineLabel = @"(Pristine)"; + +// --------------------------------------------------------------------------- +// SpliceKitUndoEntry +// --------------------------------------------------------------------------- + +@implementation SpliceKitUndoEntry +@end + +// --------------------------------------------------------------------------- +// SpliceKitUndoHistoryTableView – forwards clicks to panel before selection +// --------------------------------------------------------------------------- + +@interface SpliceKitUndoHistoryTableView : NSTableView +@property (nonatomic, weak) id clickTarget; +@property (nonatomic) SEL clickAction; +@end + +@implementation SpliceKitUndoHistoryTableView +- (void)mouseDown:(NSEvent *)event { + [super mouseDown:event]; + NSInteger row = [self rowAtPoint:[self convertPoint:event.locationInWindow fromView:nil]]; + if (row >= 0 && self.clickTarget) { +#pragma clang diagnostic push +#pragma clang diagnostic ignored "-Warc-performSelector-leaks" + [self.clickTarget performSelector:self.clickAction withObject:@(row)]; +#pragma clang diagnostic pop + } +} +@end + +// --------------------------------------------------------------------------- +// SpliceKitUndoHistoryPanel +// --------------------------------------------------------------------------- + +@interface SpliceKitUndoHistoryPanel () + +// History state +@property (nonatomic, strong) NSMutableArray *entries; +@property (nonatomic) NSInteger cursor; // -1 = pristine +@property (nonatomic) BOOL suppressRecord; // YES during undo/redo replay + +// Nesting depth tracking: NSUndoManager* (weak) → NSNumber (depth) +@property (nonatomic, strong) NSMapTable *nestingDepths; + +// UI +@property (nonatomic, strong) NSPanel *panel; +@property (nonatomic, strong) SpliceKitUndoHistoryTableView *tableView; +@property (nonatomic, strong) NSScrollView *scrollView; +@property (nonatomic, strong) NSButton *clearButton; + +@end + +@implementation SpliceKitUndoHistoryPanel + +// --------------------------------------------------------------------------- +// Singleton +// --------------------------------------------------------------------------- + ++ (instancetype)sharedPanel { + static SpliceKitUndoHistoryPanel *instance = nil; + static dispatch_once_t once; + dispatch_once(&once, ^{ + instance = [[SpliceKitUndoHistoryPanel alloc] init]; + }); + return instance; +} + +// --------------------------------------------------------------------------- +// Hook installation (called once at app-did-finish-launch) +// --------------------------------------------------------------------------- + ++ (void)installHooks { + static BOOL sInstalled = NO; + if (sInstalled) return; + sInstalled = YES; + + SpliceKitUndoHistoryPanel *panel = [self sharedPanel]; + NSNotificationCenter *nc = [NSNotificationCenter defaultCenter]; + + [nc addObserver:panel selector:@selector(_undoGroupDidOpen:) + name:NSUndoManagerDidOpenUndoGroupNotification object:nil]; + [nc addObserver:panel selector:@selector(_undoGroupDidClose:) + name:NSUndoManagerDidCloseUndoGroupNotification object:nil]; +} + +// --------------------------------------------------------------------------- +// Init +// --------------------------------------------------------------------------- + +- (instancetype)init { + self = [super init]; + if (!self) return nil; + + _entries = [NSMutableArray array]; + _cursor = -1; + _nestingDepths = [NSMapTable weakToStrongObjectsMapTable]; + + [self _buildPanel]; + return self; +} + +// --------------------------------------------------------------------------- +// Panel construction +// --------------------------------------------------------------------------- + +- (void)_buildPanel { + NSRect frame = NSMakeRect(200, 400, 300, 480); + _panel = [[NSPanel alloc] initWithContentRect:frame + styleMask:NSWindowStyleMaskTitled + |NSWindowStyleMaskClosable + |NSWindowStyleMaskMiniaturizable + |NSWindowStyleMaskResizable + backing:NSBackingStoreBuffered + defer:NO]; + _panel.title = @"Undo History"; + _panel.floatingPanel = YES; + _panel.becomesKeyOnlyIfNeeded = YES; + _panel.minSize = NSMakeSize(220, 200); + + NSView *content = _panel.contentView; + + // ── Clear button ────────────────────────────────────────────────────── + _clearButton = [NSButton buttonWithTitle:@"Clear" + target:self + action:@selector(_clearButtonClicked:)]; + _clearButton.translatesAutoresizingMaskIntoConstraints = NO; + _clearButton.bezelStyle = NSBezelStyleRounded; + _clearButton.controlSize = NSControlSizeSmall; + [content addSubview:_clearButton]; + + // ── Table view ──────────────────────────────────────────────────────── + _tableView = [[SpliceKitUndoHistoryTableView alloc] initWithFrame:NSZeroRect]; + _tableView.clickTarget = self; + _tableView.clickAction = @selector(_rowClicked:); + _tableView.dataSource = self; + _tableView.delegate = self; + _tableView.headerView = nil; + _tableView.rowSizeStyle = NSTableViewRowSizeStyleSmall; + _tableView.selectionHighlightStyle = NSTableViewSelectionHighlightStyleNone; + _tableView.usesAlternatingRowBackgroundColors = NO; + _tableView.gridStyleMask = NSTableViewGridNone; + _tableView.focusRingType = NSFocusRingTypeNone; + + NSTableColumn *col = [[NSTableColumn alloc] initWithIdentifier:@"action"]; + col.resizingMask = NSTableColumnAutoresizingMask; + [_tableView addTableColumn:col]; + + _scrollView = [[NSScrollView alloc] initWithFrame:NSZeroRect]; + _scrollView.documentView = _tableView; + _scrollView.hasVerticalScroller = YES; + _scrollView.autohidesScrollers = YES; + _scrollView.borderType = NSNoBorder; + _scrollView.translatesAutoresizingMaskIntoConstraints = NO; + [content addSubview:_scrollView]; + + // ── Layout ──────────────────────────────────────────────────────────── + NSDictionary *views = @{@"scroll": _scrollView, @"clear": _clearButton}; + NSDictionary *metrics = @{@"m": @8, @"b": @4}; + [NSLayoutConstraint activateConstraints: + [NSLayoutConstraint constraintsWithVisualFormat:@"H:|-(m)-[scroll]-(m)-|" + options:0 metrics:metrics views:views]]; + [NSLayoutConstraint activateConstraints: + [NSLayoutConstraint constraintsWithVisualFormat:@"H:|-(m)-[clear]-(m)-|" + options:0 metrics:metrics views:views]]; + [NSLayoutConstraint activateConstraints: + [NSLayoutConstraint constraintsWithVisualFormat:@"V:|-(m)-[scroll]-(b)-[clear]-(m)-|" + options:0 metrics:metrics views:views]]; + + // Auto-size the single column to fill scroll view width + [_tableView sizeLastColumnToFit]; +} + +// --------------------------------------------------------------------------- +// Visibility +// --------------------------------------------------------------------------- + +- (void)showPanel { + dispatch_async(dispatch_get_main_queue(), ^{ + [self _reloadTable]; + [self->_panel makeKeyAndOrderFront:nil]; + [NSApp activateIgnoringOtherApps:YES]; + }); +} + +- (void)hidePanel { + dispatch_async(dispatch_get_main_queue(), ^{ + [self->_panel orderOut:nil]; + }); +} + +- (BOOL)isVisible { + return _panel.isVisible; +} + +// --------------------------------------------------------------------------- +// NSUndoManager notification handlers +// --------------------------------------------------------------------------- + +- (void)_undoGroupDidOpen:(NSNotification *)n { + id um = n.object; + NSInteger depth = [[_nestingDepths objectForKey:um] integerValue]; + [_nestingDepths setObject:@(depth + 1) forKey:um]; +} + +- (void)_undoGroupDidClose:(NSNotification *)n { + id um = n.object; + NSInteger depth = [[_nestingDepths objectForKey:um] integerValue]; + depth = MAX(0, depth - 1); + [_nestingDepths setObject:@(depth) forKey:um]; + + if (depth != 0 || _suppressRecord) return; + + // Top-level group just closed — record the action. + // FCP prefixes many action names with "Edit " internally; strip it to match + // what the Edit menu displays (e.g. "Edit Blade" → "Blade"). + NSString *name = [um undoActionName]; + if ([name hasPrefix:@"Edit "]) name = [name substringFromIndex:5]; + if (name.length == 0 || [name isEqualToString:@"Edit"]) return; + + dispatch_async(dispatch_get_main_queue(), ^{ + [self _recordAction:name]; + }); +} + +// --------------------------------------------------------------------------- +// History management +// --------------------------------------------------------------------------- + +- (void)_recordAction:(NSString *)name { + // Ring: if at capacity, drop the oldest entry and adjust cursor. + if ((NSInteger)_entries.count >= kMaxEntries) { + [_entries removeObjectAtIndex:0]; + // Re-index all entries + for (NSInteger i = 0; i < (NSInteger)_entries.count; i++) { + _entries[(NSUInteger)i].index = i; + } + _cursor = MAX(-1, _cursor - 1); + } + + SpliceKitUndoEntry *entry = [[SpliceKitUndoEntry alloc] init]; + entry.actionName = name; + entry.timestamp = [NSDate date]; + entry.index = (NSInteger)_entries.count; + [_entries addObject:entry]; + _cursor = entry.index; + + [self _reloadTable]; +} + +- (void)clearHistory { + dispatch_async(dispatch_get_main_queue(), ^{ + [self->_entries removeAllObjects]; + self->_cursor = -1; + [self _reloadTable]; + }); +} + +// --------------------------------------------------------------------------- +// Table reload + scroll-to-current +// --------------------------------------------------------------------------- + +- (void)_reloadTable { + [_tableView reloadData]; + + // Scroll so the current row is visible. + if (_cursor >= 0 && _cursor < (NSInteger)_entries.count) { + [_tableView scrollRowToVisible:_cursor]; + } +} + +// --------------------------------------------------------------------------- +// NSTableViewDataSource +// --------------------------------------------------------------------------- + +- (NSInteger)numberOfRowsInTableView:(NSTableView *)tv { + return (NSInteger)_entries.count; +} + +// --------------------------------------------------------------------------- +// NSTableViewDelegate +// --------------------------------------------------------------------------- + +- (NSView *)tableView:(NSTableView *)tv + viewForTableColumn:(NSTableColumn *)col + row:(NSInteger)row { + NSTextField *label = [tv makeViewWithIdentifier:@"cell" owner:self]; + if (!label) { + label = [NSTextField labelWithString:@""]; + label.identifier = @"cell"; + label.lineBreakMode = NSLineBreakByTruncatingTail; + } + + BOOL isCurrent = (row == _cursor); + + SpliceKitUndoEntry *entry = _entries[(NSUInteger)row]; + NSString *text = entry.actionName; + + if (isCurrent) { + label.font = [NSFont boldSystemFontOfSize:[NSFont smallSystemFontSize]]; + label.textColor = [NSColor controlAccentColor]; + } else { + label.font = [NSFont systemFontOfSize:[NSFont smallSystemFontSize]]; + label.textColor = [NSColor labelColor]; + } + + label.stringValue = text; + return label; +} + +- (CGFloat)tableView:(NSTableView *)tv heightOfRow:(NSInteger)row { + return 20.0; +} + +// --------------------------------------------------------------------------- +// Row click → jump +// --------------------------------------------------------------------------- + +- (void)_rowClicked:(NSNumber *)rowNumber { + NSInteger row = rowNumber.integerValue; + if (row == _cursor) return; + [self jumpToIndex:row]; +} + +- (void)_clearButtonClicked:(id)sender { + [self clearHistory]; +} + +// --------------------------------------------------------------------------- +// RPC: getHistory +// --------------------------------------------------------------------------- + +- (NSDictionary *)getHistory { + __block NSDictionary *result = nil; + dispatch_sync(dispatch_get_main_queue(), ^{ + NSMutableArray *arr = [NSMutableArray arrayWithCapacity:self->_entries.count]; + + NSDateFormatter *fmt = [[NSDateFormatter alloc] init]; + fmt.dateFormat = @"HH:mm:ss"; + + for (SpliceKitUndoEntry *e in self->_entries) { + [arr addObject:@{ + @"index": @(e.index), + @"name": e.actionName, + @"timestamp": [fmt stringFromDate:e.timestamp], + @"isCurrent": @(e.index == self->_cursor) + }]; + } + + // Determine canUndo / canRedo from the live undo manager if available. + BOOL canUndo = NO, canRedo = NO; + @try { + id doc = [[[NSApplication sharedApplication] delegate] + performSelector:@selector(_targetLibrary)]; + id um = [((id(*)(id,SEL))objc_msgSend)(doc, @selector(libraryDocument)) + undoManager]; + canUndo = [um canUndo]; + canRedo = [um canRedo]; + } @catch (...) {} + + result = @{ + @"entries": [arr copy], + @"cursor": @(self->_cursor), + @"canUndo": @(canUndo), + @"canRedo": @(canRedo), + @"visible": @(self->_panel.isVisible) + }; + }); + return result; +} + +// --------------------------------------------------------------------------- +// RPC: jumpToIndex +// --------------------------------------------------------------------------- + +- (NSDictionary *)jumpToIndex:(NSInteger)target { + if (target < -1 || target >= (NSInteger)_entries.count) { + return @{@"error": @"Index out of range"}; + } + + __block NSDictionary *result = nil; + SpliceKit_executeOnMainThread(^{ + // Resolve the undo manager + NSUndoManager *um = nil; + @try { + id delegate = [[NSApplication sharedApplication] delegate]; + id lib = [delegate performSelector:@selector(_targetLibrary)]; + id doc = ((id(*)(id,SEL))objc_msgSend)(lib, @selector(libraryDocument)); + um = [doc undoManager]; + } @catch (...) {} + + if (!um) { + result = @{@"error": @"No undo manager found — is a project open?"}; + return; + } + + NSInteger delta = target - self->_cursor; + if (delta == 0) { + result = @{@"status": @"ok", @"message": @"Already at that state"}; + return; + } + + self->_suppressRecord = YES; + NSInteger performed = 0; + if (delta < 0) { + for (NSInteger i = 0; i < -delta; i++) { + if (![um canUndo]) break; + [um undo]; + performed--; + } + } else { + for (NSInteger i = 0; i < delta; i++) { + if (![um canRedo]) break; + [um redo]; + performed++; + } + } + self->_suppressRecord = NO; + + // Advance cursor to where we actually landed and refresh the highlight. + self->_cursor += performed; + [self _reloadTable]; + + result = @{ + @"status": @"ok", + @"moved": @(performed), + @"cursor": @(self->_cursor), + @"canUndo": @([um canUndo]), + @"canRedo": @([um canRedo]) + }; + }); + return result; +} + +@end diff --git a/Sources/SpliceKitWaveformExpand.m b/Sources/SpliceKitWaveformExpand.m new file mode 100644 index 0000000..e8e4826 --- /dev/null +++ b/Sources/SpliceKitWaveformExpand.m @@ -0,0 +1,52 @@ +// +// SpliceKitWaveformExpand.m +// +// When video thumbnails are hidden via FCP's "Change Appearance" button, +// the filmstrip area goes vacant. This swizzle makes the audio waveform +// fill the full clip height including the space where the filmstrip was. +// +// Root cause: FFFilmstripCell.audioHeight returns -1 (sentinel: "use +// appearance default = 28px") when thumbnails are turned off. Even though +// the container layer expands to 96px, the waveform renderer reads -1 and +// draws only the default 28px at the bottom of the 96px cell. +// +// Fix: swizzle FFFilmstripCell.audioHeight getter. When the stored value +// is -1 (i.e., "auto"), return the cell layer's actual height so the +// renderer fills the full available space. +// + +#import "SpliceKit.h" +#import +#import + +typedef double (*AudioHeightGetterFn)(id, SEL); +static AudioHeightGetterFn sOrigAudioHeight = NULL; + +static double WE_audioHeight(id self, SEL _cmd) { + double h = sOrigAudioHeight(self, _cmd); + if (h >= 0) return h; // explicit value — honour it + + // h == -1 (or any negative): FCP means "use the appearance default strip". + // Instead, return the cell layer's actual current height so the waveform + // fills the full audio container when thumbnails are hidden. + CALayer *layer = ((id (*)(id, SEL))objc_msgSend)(self, NSSelectorFromString(@"layer")); + if (layer) { + CGFloat layerH = layer.bounds.size.height; + if (layerH > 1.0) return layerH; + } + return h; +} + +void SpliceKit_installWaveformExpand(void) { + Class cellCls = NSClassFromString(@"FFFilmstripCell"); + if (!cellCls) { + SpliceKit_log(@"[WaveformExpand] FFFilmstripCell not found"); + return; + } + SEL sel = NSSelectorFromString(@"audioHeight"); + IMP orig = SpliceKit_swizzleMethod(cellCls, sel, (IMP)WE_audioHeight); + if (orig) { + sOrigAudioHeight = (AudioHeightGetterFn)orig; + SpliceKit_log(@"[WaveformExpand] installed"); + } +} diff --git a/examples/plugins/com.splicekit.independent-slip/Makefile b/examples/plugins/com.splicekit.independent-slip/Makefile new file mode 100644 index 0000000..5098d1d --- /dev/null +++ b/examples/plugins/com.splicekit.independent-slip/Makefile @@ -0,0 +1,42 @@ +# Makefile for com.splicekit.independent-slip +# +# Usage: +# make — compile plugin.dylib into build/ +# make install — compile + copy into ~/Library/Application Support/SpliceKit/plugins/ +# make clean — remove build artefacts + +PLUGIN_ID = com.splicekit.independent-slip +PLUGIN_ROOT = $(shell pwd) +REPO_ROOT = $(PLUGIN_ROOT)/../../.. +SRC = src/IndependentSlip.m +DYLIB = build/plugin.dylib +INSTALL_DIR = $(HOME)/Library/Application Support/SpliceKit/plugins/$(PLUGIN_ID) + +CC = clang +ARCHS = -arch arm64 -arch x86_64 +MIN_VER = -mmacosx-version-min=14.0 +CFLAGS = -O2 -fvisibility=hidden -fobjc-arc \ + -I$(REPO_ROOT)/Sources \ + $(ARCHS) $(MIN_VER) +LDFLAGS = -dynamiclib -undefined dynamic_lookup \ + -framework Foundation -framework AppKit -framework CoreMedia \ + $(ARCHS) $(MIN_VER) + +.PHONY: all install clean + +all: $(DYLIB) + +$(DYLIB): $(SRC) $(REPO_ROOT)/Sources/SpliceKitPluginAPI.h + @mkdir -p build + $(CC) $(CFLAGS) $(LDFLAGS) -o $@ $< + @echo "Built $(DYLIB)" + +install: $(DYLIB) + @mkdir -p "$(INSTALL_DIR)" + cp -f $(DYLIB) "$(INSTALL_DIR)/plugin.dylib" + cp -f plugin.json "$(INSTALL_DIR)/plugin.json" + @echo "Installed $(PLUGIN_ID) → $(INSTALL_DIR)" + @echo "Restart FCP to activate." + +clean: + rm -rf build diff --git a/examples/plugins/com.splicekit.independent-slip/plugin.json b/examples/plugins/com.splicekit.independent-slip/plugin.json new file mode 100644 index 0000000..97cb345 --- /dev/null +++ b/examples/plugins/com.splicekit.independent-slip/plugin.json @@ -0,0 +1,13 @@ +{ + "id": "com.splicekit.independent-slip", + "name": "Independent Slip", + "version": "1.0.0", + "description": "Adds two toolbar toggle buttons — [▶] Video Only and [♪] Audio Only — that make the Trim tool's slip and slide operations affect only video or only audio independently.", + "author": "SpliceKit", + "apiVersion": 1, + "entry": { + "native": "plugin.dylib" + }, + "methods": [], + "dependencies": [] +} diff --git a/examples/plugins/com.splicekit.independent-slip/src/IndependentSlip.m b/examples/plugins/com.splicekit.independent-slip/src/IndependentSlip.m new file mode 100644 index 0000000..6785d98 --- /dev/null +++ b/examples/plugins/com.splicekit.independent-slip/src/IndependentSlip.m @@ -0,0 +1,545 @@ +// +// IndependentSlip.m +// com.splicekit.independent-slip +// +// Adds two toggle buttons to Final Cut Pro's toolbar that make the Trim +// tool's slip operation affect only one component of a linked clip: +// +// [▶] Video only — audio source range stays in place, video slips freely. +// [♪] Audio only — video source range stays in place, audio slips freely. +// +// How it works +// ───────────── +// FCP's slip drag runs through TLKSlipSlideHandler (TimelineKit). +// Each drag frame calls continueTracking:, which updates both the video and +// audio source ranges of the clicked clip together. +// +// We swizzle three methods: +// +// TLKSlipSlideHandler -startTracking: +// Capture the locked component's source range before any drag frame moves it. +// +// TLKSlipSlideHandler -continueTracking: +// Call original (both components slip). No live restore — restoring via KVC +// during the drag triggers FFAnchoredCollection's notification chain which +// can crash on a freed FFSourceVideoEffect node (use-after-free in +// FFSynchronizable::Lock). The locked waveform visual is corrected at commit. +// +// TLKSlipSlideHandler -stopTrackingWithCommit: +// After commit, restore the locked range via CoreData's primitive setter +// (bypasses the notification chain that would crash), then force a full +// timeline reload so TLKLayerManager.updateMediaRectsForItemComponentFragment: +// re-reads audioClippedRange and recomputes unusedAudioMediaRect (the waveform +// position rect) from the now-correct locked value. +// +// We deliberately avoid putting CMTimeRange in any C function signature +// because the arm64 register-decomposition ABI for 48-byte structs is +// incompatible with ObjC swizzle chains. KVC (valueForKey:/setValue:forKey:) +// lets FCP's own compiled code handle the struct layout correctly. +// +// KVC targets: +// VideoOnly: FFAnchoredCollection.audioClippedRange (audio source range) +// AudioOnly: FFAnchoredCollection.clippedRange (the video source range) +// +// Toolbar injection follows the same pattern as SpliceKit's built-in buttons. +// + +#import +#import +#import +#import + +#import "SpliceKitPluginAPI.h" + +// --------------------------------------------------------------------------- +// Plugin API +// --------------------------------------------------------------------------- +static SpliceKitPluginAPI sISAPIStorage; +static SpliceKitPluginAPI *sISAPI = NULL; + +// --------------------------------------------------------------------------- +// Mode +// --------------------------------------------------------------------------- +typedef enum : NSInteger { + kISMode_Normal = 0, + kISMode_VideoOnly = 1, + kISMode_AudioOnly = 2, +} ISMode; + +static ISMode sMode = kISMode_Normal; +// State captured at startTracking:. +static id sLockedObject = nil; // FFAnchoredCollection being slipped +static NSString *sLockedKey = nil; // "audioClippedRange" or "clippedRange" +static id sRangeToRestore = nil; // range to restore (NSValue wrapping CMTimeRange) + +// --------------------------------------------------------------------------- +// Toolbar item identifiers +// --------------------------------------------------------------------------- +static NSString * const kISVideoOnlyID = @"SpliceKitIS_VideoOnlyItemID"; +static NSString * const kISAudioOnlyID = @"SpliceKitIS_AudioOnlyItemID"; + +// --------------------------------------------------------------------------- +// Saved IMPs +// --------------------------------------------------------------------------- +static IMP sOrigToolbarItemForIdentifier = NULL; +static IMP sOrigSlipStartTracking = NULL; +static IMP sOrigSlipContinueTracking = NULL; +static IMP sOrigSlipStopTracking = NULL; +static IMP sOrigSlipStopHandling = NULL; + +// --------------------------------------------------------------------------- +// Button references (weak — toolbar owns them) +// --------------------------------------------------------------------------- +static __weak NSButton *sVideoOnlyButton = nil; +static __weak NSButton *sAudioOnlyButton = nil; + +// --------------------------------------------------------------------------- +// Mode helpers +// --------------------------------------------------------------------------- +static void IS_setMode(ISMode newMode) { + sMode = newMode; + dispatch_async(dispatch_get_main_queue(), ^{ + sVideoOnlyButton.state = (newMode == kISMode_VideoOnly) + ? NSControlStateValueOn : NSControlStateValueOff; + sAudioOnlyButton.state = (newMode == kISMode_AudioOnly) + ? NSControlStateValueOn : NSControlStateValueOff; + }); +} + +// --------------------------------------------------------------------------- +// Button controller +// --------------------------------------------------------------------------- +@interface SpliceKitIS_Controller : NSObject ++ (instancetype)shared; +- (void)videoOnlyAction:(id)sender; +- (void)audioOnlyAction:(id)sender; +@end + +@implementation SpliceKitIS_Controller + ++ (instancetype)shared { + static SpliceKitIS_Controller *sInstance = nil; + static dispatch_once_t sOnce; + dispatch_once(&sOnce, ^{ sInstance = [[self alloc] init]; }); + return sInstance; +} + +- (void)videoOnlyAction:(id)sender { + IS_setMode(sMode == kISMode_VideoOnly ? kISMode_Normal : kISMode_VideoOnly); + if (sISAPI) sISAPI->log(@"[IndependentSlip] Mode → %ld", (long)sMode); +} + +- (void)audioOnlyAction:(id)sender { + IS_setMode(sMode == kISMode_AudioOnly ? kISMode_Normal : kISMode_AudioOnly); + if (sISAPI) sISAPI->log(@"[IndependentSlip] Mode → %ld", (long)sMode); +} + +@end + +// --------------------------------------------------------------------------- +// Icon helper +// --------------------------------------------------------------------------- +static NSImage *IS_makeIcon(NSString *symbolName, NSString *desc) { + NSImage *img = [NSImage imageWithSystemSymbolName:symbolName + accessibilityDescription:desc]; + NSImageSymbolConfiguration *cfg = [NSImageSymbolConfiguration + configurationWithPointSize:13 weight:NSFontWeightMedium]; + return img ? [img imageWithSymbolConfiguration:cfg] : nil; +} + +// --------------------------------------------------------------------------- +// Toolbar delegate swizzle +// --------------------------------------------------------------------------- +static id IS_toolbarItemForIdentifier(id self, SEL _cmd, + NSToolbar *toolbar, + NSString *identifier, + BOOL willInsert) { + NSButton * (^makeToggle)(NSImage *, SEL) = ^(NSImage *icon, SEL action) { + NSButton *btn = [[NSButton alloc] initWithFrame:NSMakeRect(0, 0, 36, 25)]; + [btn setButtonType:NSButtonTypePushOnPushOff]; + btn.bezelStyle = NSBezelStyleTexturedRounded; + btn.bordered = YES; + btn.image = icon; + btn.imagePosition = NSImageOnly; + btn.target = [SpliceKitIS_Controller shared]; + btn.action = action; + return btn; + }; + + if ([identifier isEqualToString:kISVideoOnlyID]) { + NSToolbarItem *item = [[NSToolbarItem alloc] initWithItemIdentifier:identifier]; + item.label = @"Video Only"; + item.paletteLabel = @"Slip Video Only"; + item.toolTip = @"Slip video independently — audio source stays in place"; + NSButton *btn = makeToggle(IS_makeIcon(@"film", @"Slip video only"), + @selector(videoOnlyAction:)); + sVideoOnlyButton = btn; + item.view = btn; + return item; + } + + if ([identifier isEqualToString:kISAudioOnlyID]) { + NSToolbarItem *item = [[NSToolbarItem alloc] initWithItemIdentifier:identifier]; + item.label = @"Audio Only"; + item.paletteLabel = @"Slip Audio Only"; + item.toolTip = @"Slip audio independently — video source stays in place"; + NSButton *btn = makeToggle(IS_makeIcon(@"waveform", @"Slip audio only"), + @selector(audioOnlyAction:)); + sAudioOnlyButton = btn; + item.view = btn; + return item; + } + + return ((id (*)(id, SEL, NSToolbar *, NSString *, BOOL))sOrigToolbarItemForIdentifier)( + self, _cmd, toolbar, identifier, willInsert); +} + +// --------------------------------------------------------------------------- +// KVC helpers — avoid CMTimeRange in any C function signature. +// +// lockKey: "audioClippedRange" in VideoOnly mode (keep audio source fixed) +// "clippedRange" in AudioOnly mode (keep video source fixed) +// +// Both are set on the FFAnchoredCollection (clickedItem from the handler). +// --------------------------------------------------------------------------- + +// The clickedItem on TLKDragEdgesHandler is the FFAnchoredCollection being slipped. +static id IS_clipFromHandler(id handler) { + @try { + return ((id (*)(id, SEL))objc_msgSend)(handler, @selector(clickedItem)); + } @catch (__unused NSException *) { return nil; } +} + +// Capture state at drag start. +static void IS_captureLockedState(id clip) { + sLockedObject = nil; + sLockedKey = nil; + sRangeToRestore = nil; + if (!clip) return; + + sLockedObject = clip; + sLockedKey = (sMode == kISMode_VideoOnly) ? @"audioClippedRange" : @"clippedRange"; + @try { sRangeToRestore = [clip valueForKey:sLockedKey]; } + @catch (__unused NSException *) {} + + if (sISAPI) sISAPI->log(@"[IndependentSlip] Locking %@=%@", sLockedKey, sRangeToRestore); +} + +// Restore using the stored object+key via CoreData's primitive setter. +// setPrimitiveValue:forKey: writes directly to the CoreData backing store without +// calling willChangeValueForKey:, custom ObjC setters, or the FFAnchoredCollection +// notification/invalidation chain. Using setValue:forKey: here triggers +// setAudioClippedRange: → invalidateSourceRange: → FFNotificationCenter → +// FFSourceVideoEffect._invalidateCachedMD5: → FFSynchronizable::Lock() which +// crashes on freed effect nodes. The primitive write is safe everywhere. +static void IS_restoreLockedRange(id savedRange) { + if (!sLockedObject || !savedRange) return; + @try { + SEL primSel = NSSelectorFromString(@"setPrimitiveValue:forKey:"); + if ([sLockedObject respondsToSelector:primSel]) + ((void(*)(id,SEL,id,id))objc_msgSend)(sLockedObject, primSel, savedRange, sLockedKey); + else + [sLockedObject setValue:savedRange forKey:sLockedKey]; + } + @catch (__unused NSException *) {} +} + +// Force a full timeline redraw so any cached waveform/filmstrip display +// reflects the model state we just restored via KVC. +static void IS_reloadTimeline(void) { + @try { + id app = ((id(*)(id,SEL))objc_msgSend)(NSClassFromString(@"NSApplication"), + @selector(sharedApplication)); + id delegate = ((id(*)(id,SEL))objc_msgSend)(app, @selector(delegate)); + id container = ((id(*)(id,SEL))objc_msgSend)(delegate, + @selector(activeEditorContainer)); + id tlModule = ((id(*)(id,SEL))objc_msgSend)(container, @selector(timelineModule)); + if (!tlModule) return; + SEL sel = NSSelectorFromString(@"reloadTimelineView:"); + if ([tlModule respondsToSelector:sel]) + ((void(*)(id,SEL,id))objc_msgSend)(tlModule, sel, nil); + } @catch (__unused NSException *) {} +} + +// --------------------------------------------------------------------------- +// Slip lifecycle swizzles — on TLKClipTrimmerDragClipHandler +// --------------------------------------------------------------------------- + +// startTracking: — capture which object+key+value to lock, BEFORE the original +// fires, so we have the true pre-drag value even if startTracking mutates it. +static BOOL IS_slipStartTracking(id self, SEL _cmd, id dispatcher) { + id clip = IS_clipFromHandler(self); + if (sMode != kISMode_Normal && clip) + IS_captureLockedState(clip); + + BOOL result = ((BOOL (*)(id, SEL, id))sOrigSlipStartTracking)(self, _cmd, dispatcher); + + if (!result) { + sLockedObject = nil; + sLockedKey = nil; + sRangeToRestore = nil; + } + + if (sISAPI) + sISAPI->log(@"[IndependentSlip] startTracking result=%d mode=%ld clip=%s key=%@ range=%@", + (int)result, (long)sMode, + clip ? class_getName([clip class]) : "NIL", + sLockedKey, sRangeToRestore); + return result; +} + +// continueTracking: — per-frame: let FCP slip both components freely. +// We do NOT restore the locked range here. Calling any setter on audioClippedRange +// during the drag triggers FFAnchoredCollection's deferred-update + notification +// pipeline which crashes on freed FFSourceVideoEffect nodes (use-after-free). +// The waveform display is corrected at commit time in stopTrackingWithCommit:. +static BOOL IS_slipContinueTracking(id self, SEL _cmd, id eventContext) { + return ((BOOL (*)(id, SEL, id))sOrigSlipContinueTracking)(self, _cmd, eventContext); +} + +static BOOL IS_slipStopTracking(id self, SEL _cmd, BOOL commit) { + if (sMode != kISMode_Normal && commit) { + id savedRange = sRangeToRestore; + id savedObject = sLockedObject; + NSString *savedKey = sLockedKey; + + // Let FCP commit the slip. CoreData writes videoClippedRange = slipped and + // audioClippedRange = slipped. The TLK layer reload inside this call reads + // audioClippedRange to compute unusedAudioMediaRect (waveform position rect), + // so the waveform is briefly wrong after this returns. + BOOL result = ((BOOL (*)(id, SEL, BOOL))sOrigSlipStopTracking)(self, _cmd, commit); + + // Belt-and-suspenders: restore after commit in case stopHandling: didn't fire. + // IS_slipStopHandling (swizzled below) does the primary restore BEFORE the TLK + // reload so updateMediaRectsForItemComponentFragment: reads the correct value. + if (savedObject && savedKey && savedRange) + IS_restoreLockedRange(savedRange); + + if (sISAPI) + sISAPI->log(@"[IndependentSlip] commit done, locked range restored: %@=%@", savedKey, savedRange); + + sRangeToRestore = nil; + sLockedObject = nil; + sLockedKey = nil; + return result; + } + + sRangeToRestore = nil; + sLockedObject = nil; + sLockedKey = nil; + return ((BOOL (*)(id, SEL, BOOL))sOrigSlipStopTracking)(self, _cmd, commit); +} + +// stopHandling: — fires inside stopTrackingWithCommit:YES, AFTER the CoreData +// commit writes audioClippedRange = slipped but BEFORE TLKDragEdgesHandler calls +// TLKTimelineView.reloadWithItemsAdded:removed:modified: → TLKReloadOperation.main +// → TLKLayerManager.updateMediaRectsForItemComponentFragment:. +// +// By restoring the locked range here (before calling the original) we ensure that +// updateMediaRectsForItemComponentFragment: reads audioClippedRange = locked and +// computes the correct unusedAudioMediaRect (waveform position rect). +// +// We swizzle this on TLKSlipSlideHandler directly — the previous attempt swizzled +// TLKDragEdgesHandler (parent), but TLKSlipSlideHandler has its own override which +// shadows the parent's method table entry for all slip handler instances. +static void IS_slipStopHandling(id self, SEL _cmd, id eventContext) { + if (sMode != kISMode_Normal && sLockedObject && sRangeToRestore) { + IS_restoreLockedRange(sRangeToRestore); + if (sISAPI) + sISAPI->log(@"[IndependentSlip] stopHandling: restored %@=%@ before TLK reload", + sLockedKey, sRangeToRestore); + } + ((void(*)(id,SEL,id))sOrigSlipStopHandling)(self, _cmd, eventContext); +} + +// --------------------------------------------------------------------------- +// Swizzle installation +// --------------------------------------------------------------------------- +static void IS_installToolbarSwizzle(void) { + if (sOrigToolbarItemForIdentifier) return; + @try { + Class cls = NSClassFromString(@"PEMainWindowModule"); + if (!cls) return; + SEL sel = @selector(toolbar:itemForItemIdentifier:willBeInsertedIntoToolbar:); + Method m = class_getInstanceMethod(cls, sel); + if (!m) return; + sOrigToolbarItemForIdentifier = method_getImplementation(m); + class_replaceMethod(cls, sel, (IMP)IS_toolbarItemForIdentifier, + method_getTypeEncoding(m)); + if (sISAPI) sISAPI->log(@"[IndependentSlip] Toolbar swizzle installed."); + } @catch (NSException *e) { + sOrigToolbarItemForIdentifier = NULL; + if (sISAPI) sISAPI->log(@"[IndependentSlip] Toolbar swizzle failed: %@", e.reason); + } +} + +static void IS_installSlipSwizzles(void) { + @try { + // TLKSlipSlideHandler is the handler TimelineKit uses for slip drags. + // It owns startTracking: and stopTrackingWithCommit: directly and + // inherits continueTracking: from TLKDragEdgesHandler. + // clickedItem (also on TLKDragEdgesHandler) returns FFAnchoredCollection + // directly — the model object we KVC against for clippedRange. + Class cls = NSClassFromString(@"TLKSlipSlideHandler"); + if (!cls) { + if (sISAPI) + sISAPI->log(@"[IndependentSlip] TLKClipTrimmerDragClipHandler not found."); + return; + } + + if (!sOrigSlipStartTracking) { + SEL sel = @selector(startTracking:); + Method m = class_getInstanceMethod(cls, sel); + if (m) { + sOrigSlipStartTracking = method_getImplementation(m); + class_replaceMethod(cls, sel, (IMP)IS_slipStartTracking, + method_getTypeEncoding(m)); + } + } + + if (!sOrigSlipContinueTracking) { + SEL sel = @selector(continueTracking:); + Method m = class_getInstanceMethod(cls, sel); + if (m) { + sOrigSlipContinueTracking = method_getImplementation(m); + class_replaceMethod(cls, sel, (IMP)IS_slipContinueTracking, + method_getTypeEncoding(m)); + } + } + + if (!sOrigSlipStopTracking) { + SEL sel = @selector(stopTrackingWithCommit:); + Method m = class_getInstanceMethod(cls, sel); + if (m) { + sOrigSlipStopTracking = method_getImplementation(m); + class_replaceMethod(cls, sel, (IMP)IS_slipStopTracking, + method_getTypeEncoding(m)); + } + } + + // stopHandling: — swizzled on TLKSlipSlideHandler rather than the parent + // TLKDragEdgesHandler. TLKSlipSlideHandler declares its own stopHandling: which + // takes precedence over the parent's method entry for all slip handler instances. + // class_addMethod adds an entry directly to cls's method table if the selector + // is not already there (inherited); class_replaceMethod handles the case where + // cls owns the method itself. Either way dispatch to cls instances hits our IMP. + if (!sOrigSlipStopHandling) { + SEL sel = NSSelectorFromString(@"stopHandling:"); + Method m = class_getInstanceMethod(cls, sel); + if (m) { + sOrigSlipStopHandling = method_getImplementation(m); + if (!class_addMethod(cls, sel, (IMP)IS_slipStopHandling, + method_getTypeEncoding(m))) + class_replaceMethod(cls, sel, (IMP)IS_slipStopHandling, + method_getTypeEncoding(m)); + } + } + + if (sISAPI) sISAPI->log(@"[IndependentSlip] Slip swizzles installed on TLKSlipSlideHandler."); + } @catch (NSException *e) { + if (sISAPI) sISAPI->log(@"[IndependentSlip] Slip swizzle exception: %@", e.reason); + } +} + +// --------------------------------------------------------------------------- +// Toolbar insertion +// --------------------------------------------------------------------------- +static void IS_addButtonsToToolbar(NSToolbar *toolbar) { + @try { + if (!toolbar.delegate) return; + IS_installToolbarSwizzle(); + + BOOL hasVideo = NO, hasAudio = NO; + for (NSInteger i = (NSInteger)toolbar.items.count - 1; i >= 0; i--) { + NSToolbarItem *ti = toolbar.items[(NSUInteger)i]; + if ([ti.itemIdentifier isEqualToString:kISVideoOnlyID]) { + if (ti.view) hasVideo = YES; else [toolbar removeItemAtIndex:(NSUInteger)i]; + } else if ([ti.itemIdentifier isEqualToString:kISAudioOnlyID]) { + if (ti.view) hasAudio = YES; else [toolbar removeItemAtIndex:(NSUInteger)i]; + } + } + if (hasVideo && hasAudio) return; + + NSUInteger insertIdx = toolbar.items.count; + for (NSUInteger i = 0; i < toolbar.items.count; i++) { + if ([toolbar.items[i].itemIdentifier + isEqualToString:NSToolbarFlexibleSpaceItemIdentifier]) { + insertIdx = i + 1; + break; + } + } + + if (!hasAudio) { + [toolbar insertItemWithItemIdentifier:kISAudioOnlyID atIndex:insertIdx]; + insertIdx++; + } + if (!hasVideo) { + [toolbar insertItemWithItemIdentifier:kISVideoOnlyID atIndex:insertIdx]; + } + + if (sISAPI) sISAPI->log(@"[IndependentSlip] Toolbar buttons installed."); + } @catch (NSException *e) { + if (sISAPI) sISAPI->log(@"[IndependentSlip] Toolbar insert exception: %@", e.reason); + } +} + +static void IS_tryInstall(int attempt); +static void IS_tryInstall(int attempt) { + if (attempt >= 30) return; + dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(1.0 * NSEC_PER_SEC)), + dispatch_get_main_queue(), ^{ + for (NSWindow *w in [[NSApplication sharedApplication] windows]) { + if (w.toolbar && w.toolbar.items.count > 0) { + IS_addButtonsToToolbar(w.toolbar); + return; + } + } + IS_tryInstall(attempt + 1); + }); +} + +static void IS_startInstallation(void) { + __block id observer = + [[NSNotificationCenter defaultCenter] + addObserverForName:NSWindowDidBecomeMainNotification + object:nil queue:[NSOperationQueue mainQueue] + usingBlock:^(NSNotification *note) { + NSWindow *win = note.object; + if (win.toolbar) { + [[NSNotificationCenter defaultCenter] removeObserver:observer]; + observer = nil; + IS_addButtonsToToolbar(win.toolbar); + } + }]; + IS_tryInstall(0); +} + +// --------------------------------------------------------------------------- +// Plugin entry point +// --------------------------------------------------------------------------- +__attribute__((visibility("default"))) +void SpliceKitPlugin_init(SpliceKitPluginAPI *api) { + sISAPIStorage = *api; + sISAPI = &sISAPIStorage; + + sISAPI->log(@"[IndependentSlip] Loading (toolbar buttons disabled — removing legacy items)."); + + // Remove old IS_ toolbar items that may have been saved in FCP's toolbar + // customisation preferences from a previous install of this plugin. + api->executeOnMainThreadAsync(^{ + @try { + NSToolbar *tb = [[NSApplication sharedApplication] mainWindow].toolbar; + if (!tb) return; + for (NSInteger i = (NSInteger)tb.items.count - 1; i >= 0; i--) { + NSString *ident = tb.items[(NSUInteger)i].itemIdentifier; + if ([ident isEqualToString:kISVideoOnlyID] || + [ident isEqualToString:kISAudioOnlyID]) { + [tb removeItemAtIndex:(NSUInteger)i]; + if (sISAPI) sISAPI->log(@"[IndependentSlip] Removed legacy toolbar item: %@", ident); + } + } + } @catch (__unused NSException *) {} + }); + + sISAPI->log(@"[IndependentSlip] Loaded."); +} diff --git a/examples/plugins/com.splicekit.select-from-playhead-buttons/Makefile b/examples/plugins/com.splicekit.select-from-playhead-buttons/Makefile new file mode 100644 index 0000000..e3f94f2 --- /dev/null +++ b/examples/plugins/com.splicekit.select-from-playhead-buttons/Makefile @@ -0,0 +1,42 @@ +# Makefile for com.splicekit.select-from-playhead-buttons +# +# Usage: +# make — compile plugin.dylib into build/ +# make install — compile + copy into ~/Library/Application Support/SpliceKit/plugins/ +# make clean — remove build artefacts + +PLUGIN_ID = com.splicekit.select-from-playhead-buttons +PLUGIN_ROOT = $(shell pwd) +REPO_ROOT = $(PLUGIN_ROOT)/../../.. +SRC = src/SelectFromPlayheadButtons.m +DYLIB = build/plugin.dylib +INSTALL_DIR = $(HOME)/Library/Application Support/SpliceKit/plugins/$(PLUGIN_ID) + +CC = clang +ARCHS = -arch arm64 -arch x86_64 +MIN_VER = -mmacosx-version-min=14.0 +CFLAGS = -O2 -fvisibility=hidden -fobjc-arc \ + -I$(REPO_ROOT)/Sources \ + $(ARCHS) $(MIN_VER) +LDFLAGS = -dynamiclib -undefined dynamic_lookup \ + -framework Foundation -framework AppKit \ + $(ARCHS) $(MIN_VER) + +.PHONY: all install clean + +all: $(DYLIB) + +$(DYLIB): $(SRC) $(REPO_ROOT)/Sources/SpliceKitPluginAPI.h + @mkdir -p build + $(CC) $(CFLAGS) $(LDFLAGS) -o $@ $< + @echo "Built $(DYLIB)" + +install: $(DYLIB) + @mkdir -p "$(INSTALL_DIR)" + cp -f $(DYLIB) "$(INSTALL_DIR)/plugin.dylib" + cp -f plugin.json "$(INSTALL_DIR)/plugin.json" + @echo "Installed $(PLUGIN_ID) → $(INSTALL_DIR)" + @echo "Restart FCP (or hot-reload via debug_load_plugin) to see the buttons." + +clean: + rm -rf build diff --git a/examples/plugins/com.splicekit.select-from-playhead-buttons/plugin.json b/examples/plugins/com.splicekit.select-from-playhead-buttons/plugin.json new file mode 100644 index 0000000..b1af897 --- /dev/null +++ b/examples/plugins/com.splicekit.select-from-playhead-buttons/plugin.json @@ -0,0 +1,13 @@ +{ + "id": "com.splicekit.select-from-playhead-buttons", + "name": "Select From Playhead Buttons", + "version": "1.0.0", + "description": "Adds two buttons to Final Cut Pro's toolbar: Select Forward (all clips starting at or after the playhead) and Select Backward (all clips ending at or before the playhead).", + "author": "SpliceKit", + "apiVersion": 1, + "entry": { + "native": "plugin.dylib" + }, + "methods": [], + "dependencies": [] +} diff --git a/examples/plugins/com.splicekit.select-from-playhead-buttons/src/SelectFromPlayheadButtons.m b/examples/plugins/com.splicekit.select-from-playhead-buttons/src/SelectFromPlayheadButtons.m new file mode 100644 index 0000000..6fe9c68 --- /dev/null +++ b/examples/plugins/com.splicekit.select-from-playhead-buttons/src/SelectFromPlayheadButtons.m @@ -0,0 +1,410 @@ +// +// SelectFromPlayheadButtons.m +// com.splicekit.select-from-playhead-buttons +// +// Injects two buttons into Final Cut Pro's timeline bar (the strip above the +// timeline tracks), positioned just to the left of the scrolling-timeline / +// snapping segment control (LKPaneCapSegmentedControl): +// +// |◀ Select Backward — selects all primary-storyline clips whose end +// time is at or before the playhead position. +// ▶| Select Forward — selects all primary-storyline clips whose start +// time is at or after the playhead position. +// +// The buttons invoke timeline.action("selectBackward") and +// timeline.action("selectForward"), which are built into SpliceKit core. +// +// The timeline bar is NOT an NSToolbar — it is a plain NSView +// (NSView 0x… inside LKContainerItemCapView) that FCP builds via +// PETimelineToolbarBuilder. We reach it by walking up from the timeline +// module's view and looking for LKContainerItemCapView. +// +// The toolbar swizzle is kept so FCP can still resolve our identifiers if +// they happen to be saved in the toolbar customisation preferences from a +// previous install. On load we actively remove them from the main toolbar. +// +// Build: make -f Makefile +// Install: make -f Makefile install +// + +#import +#import +#import +#import + +#import "SpliceKitPluginAPI.h" + +// --------------------------------------------------------------------------- +// Constants +// --------------------------------------------------------------------------- + +static NSString * const kSFPBForwardID = @"SpliceKitSFP_SelectForwardItemID"; +static NSString * const kSFPBBackwardID = @"SpliceKitSFP_SelectBackwardItemID"; + +static SpliceKitPluginAPI sSFPBAPIStorage; +static SpliceKitPluginAPI *sSFPBAPI = NULL; + +// Toolbar swizzle — kept only for backward-compat resolution of saved items. +static IMP sSFPBOriginalItemForIdentifier = NULL; + +// --------------------------------------------------------------------------- +// Button target +// --------------------------------------------------------------------------- +@interface SpliceKitSFP_BtnController : NSObject ++ (instancetype)shared; +- (void)selectForwardAction:(id)sender; +- (void)selectBackwardAction:(id)sender; +@end + +@implementation SpliceKitSFP_BtnController + ++ (instancetype)shared { + static SpliceKitSFP_BtnController *sInstance = nil; + static dispatch_once_t sOnce; + dispatch_once(&sOnce, ^{ sInstance = [[self alloc] init]; }); + return sInstance; +} + +// Select all primary-storyline clips whose start time >= playhead (forward) +// or whose end time <= playhead (backward). Implemented directly via the +// timeline's detailed state + setSelectedItems: rather than sendAction:, +// so it works regardless of which view is first responder. +- (void)selectForwardAction:(id)sender { + (void)sender; + if (!sSFPBAPI) return; + [self _selectClips:YES]; +} + +- (void)selectBackwardAction:(id)sender { + (void)sender; + if (!sSFPBAPI) return; + [self _selectClips:NO]; +} + +// Uses timeline.getDetailedState (one RPC call) to read both the playhead +// position and all clip handles. Handles are resolved to live ObjC objects +// via the plugin API, then setSelectedItems: is called directly on the +// timeline module — no sendAction:/responder-chain dependency. +- (void)_selectClips:(BOOL)forward { + @try { + NSDictionary *r = sSFPBAPI->callMethod(@{@"method": @"timeline.getDetailedState"}); + NSDictionary *result = r[@"result"]; + if (!result) { sSFPBAPI->log(@"[SFPButtons] getDetailedState returned nil"); return; } + + double playhead = [result[@"playheadTime"][@"seconds"] doubleValue]; + NSArray *items = result[@"items"]; + if (!items.count) return; + + // Resolve clip handles to live ObjC objects and filter by playhead. + NSMutableArray *toSelect = [NSMutableArray array]; + for (NSDictionary *item in items) { + if ([item[@"lane"] integerValue] != 0) continue; // primary storyline only + double start = [item[@"startTime"][@"seconds"] doubleValue]; + double end = [item[@"endTime"][@"seconds"] doubleValue]; + BOOL include = forward ? (start >= playhead) : (end <= playhead); + if (!include) continue; + NSString *handle = item[@"handle"]; + if (!handle) continue; + id obj = sSFPBAPI->resolveHandle(handle); + if (obj) [toSelect addObject:obj]; + } + + // Apply selection via the timeline module directly. + id delegate = [[NSApplication sharedApplication] delegate]; + id ec = ((id(*)(id,SEL))objc_msgSend)(delegate, NSSelectorFromString(@"activeEditorContainer")); + id tm = ((id(*)(id,SEL))objc_msgSend)(ec, NSSelectorFromString(@"timelineModule")); + if (!tm) return; + + SEL deselSel = NSSelectorFromString(@"deselectAll:"); + if ([tm respondsToSelector:deselSel]) + ((void(*)(id,SEL,id))objc_msgSend)(tm, deselSel, nil); + + if (toSelect.count > 0) { + SEL setSel = NSSelectorFromString(@"setSelectedItems:"); + if ([tm respondsToSelector:setSel]) + ((void(*)(id,SEL,id))objc_msgSend)(tm, setSel, toSelect); + } + } @catch (NSException *e) { + sSFPBAPI->log(@"[SFPButtons] _selectClips exception: %@", e.reason); + } +} + +@end + +// --------------------------------------------------------------------------- +// Icon helper +// --------------------------------------------------------------------------- +static NSImage *SFPB_makeIcon(NSString *symbolName, NSString *description) { + NSImage *img = [NSImage imageWithSystemSymbolName:symbolName + accessibilityDescription:description]; + NSImageSymbolConfiguration *cfg = [NSImageSymbolConfiguration + configurationWithPointSize:19 weight:NSFontWeightMedium]; + return img ? [img imageWithSymbolConfiguration:cfg] : nil; +} + +// --------------------------------------------------------------------------- +// Toolbar delegate swizzle (backward-compat only — resolves saved items) +// --------------------------------------------------------------------------- +static id SFPB_toolbarItemForIdentifier(id self, SEL _cmd, + NSToolbar *toolbar, + NSString *identifier, + BOOL willInsert) { + // Build a stub item so FCP can load it from prefs (we immediately remove it). + NSButton * (^makeButton)(NSImage *, SEL) = ^(NSImage *icon, SEL action) { + NSButton *btn = [[NSButton alloc] initWithFrame:NSMakeRect(0, 0, 30, 22)]; + [btn setButtonType:NSButtonTypeMomentaryPushIn]; + btn.bezelStyle = NSBezelStyleTexturedRounded; + btn.bordered = YES; + btn.image = icon; + btn.imagePosition = NSImageOnly; + btn.target = [SpliceKitSFP_BtnController shared]; + btn.action = action; + return btn; + }; + + if ([identifier isEqualToString:kSFPBForwardID]) { + NSToolbarItem *item = [[NSToolbarItem alloc] initWithItemIdentifier:identifier]; + item.label = @"Select Fwd"; + item.toolTip = @"Select all clips from the playhead to the end of the timeline"; + item.view = makeButton(SFPB_makeIcon(@"arrow.right.square", @"Select Forward"), + @selector(selectForwardAction:)); + return item; + } + if ([identifier isEqualToString:kSFPBBackwardID]) { + NSToolbarItem *item = [[NSToolbarItem alloc] initWithItemIdentifier:identifier]; + item.label = @"Select Bwd"; + item.toolTip = @"Select all clips from the beginning of the timeline to the playhead"; + item.view = makeButton(SFPB_makeIcon(@"arrow.left.square", @"Select Backward"), + @selector(selectBackwardAction:)); + return item; + } + + return ((id (*)(id, SEL, NSToolbar *, NSString *, BOOL))sSFPBOriginalItemForIdentifier)( + self, _cmd, toolbar, identifier, willInsert); +} + +static void SFPB_installSwizzle(void) { + if (sSFPBOriginalItemForIdentifier) return; + @try { + Class cls = NSClassFromString(@"PEMainWindowModule"); + if (!cls) return; + SEL sel = @selector(toolbar:itemForItemIdentifier:willBeInsertedIntoToolbar:); + Method m = class_getInstanceMethod(cls, sel); + if (!m) return; + sSFPBOriginalItemForIdentifier = method_getImplementation(m); + class_replaceMethod(cls, sel, (IMP)SFPB_toolbarItemForIdentifier, method_getTypeEncoding(m)); + } @catch (__unused NSException *e) { + sSFPBOriginalItemForIdentifier = NULL; + } +} + +// --------------------------------------------------------------------------- +// Remove our items from the main toolbar (cleanup from previous installs) +// --------------------------------------------------------------------------- +static void SFPB_removeFromMainToolbar(void) { + @try { + NSToolbar *tb = [[NSApplication sharedApplication] mainWindow].toolbar; + if (!tb) return; + for (NSInteger i = (NSInteger)tb.items.count - 1; i >= 0; i--) { + NSString *ident = tb.items[(NSUInteger)i].itemIdentifier; + if ([ident isEqualToString:kSFPBForwardID] || + [ident isEqualToString:kSFPBBackwardID]) { + [tb removeItemAtIndex:(NSUInteger)i]; + } + } + } @catch (__unused NSException *) {} +} + +// --------------------------------------------------------------------------- +// Timeline bar view discovery +// +// Walk up from FFAnchoredTimelineModule.view four levels to reach the +// LKContainerItemView (PEMainEditorContainer), then find its +// LKContainerItemCapView sibling — that cap view's first subview is the +// timeline bar NSView containing all 17 timeline-bar controls. +// --------------------------------------------------------------------------- +static NSView *SFPB_findTimelineBarView(void) { + @try { + id delegate = [[NSApplication sharedApplication] delegate]; + if (!delegate) return nil; + + SEL ecSel = NSSelectorFromString(@"activeEditorContainer"); + if (![delegate respondsToSelector:ecSel]) return nil; + id editorContainer = ((id(*)(id,SEL))objc_msgSend)(delegate, ecSel); + if (!editorContainer) return nil; + + SEL tmSel = NSSelectorFromString(@"timelineModule"); + if (![editorContainer respondsToSelector:tmSel]) return nil; + id timelineModule = ((id(*)(id,SEL))objc_msgSend)(editorContainer, tmSel); + if (!timelineModule) return nil; + + NSView *tlView = [timelineModule performSelector:@selector(view)]; + if (!tlView) return nil; + + // tlView → NSView(container) → PEEditorContainerSplitView → + // PEEditorContainerView → LKContainerItemView(PEMainEditorContainer) + NSView *v = tlView; + for (int i = 0; i < 4; i++) { + v = v.superview; + if (!v) return nil; + } + // v = LKContainerItemView — find LKContainerItemCapView among its subviews + Class capClass = NSClassFromString(@"LKContainerItemCapView"); + for (NSView *sv in v.subviews) { + if (capClass ? [sv isKindOfClass:capClass] + : [NSStringFromClass([sv class]) containsString:@"CapView"]) { + return sv.subviews.firstObject; // the timeline bar NSView + } + } + return nil; + } @catch (__unused NSException *) { return nil; } +} + +// --------------------------------------------------------------------------- +// Timeline bar injection +// +// Adds the two buttons as Auto-Layout subviews anchored to the left edge of +// LKPaneCapSegmentedControl (the rightmost group: beat grid, scrolling +// timeline, skimming, audio skimming, solo, snapping). FCP's frame-based +// layout auto-generates translatesAutoresizingMaskIntoConstraints constraints +// for existing views, so our explicit anchors track correctly as FCP resizes. +// --------------------------------------------------------------------------- +static void SFPB_addButtonsToTimelineBar(void) { + @try { + NSView *bar = SFPB_findTimelineBarView(); + if (!bar) return; + + // Idempotency: already installed if our identifier is on any subview. + for (NSView *sv in bar.subviews) { + if ([sv.identifier isEqualToString:kSFPBForwardID]) return; + } + + // Find LKPaneCapSegmentedControl — the rightmost group containing + // the scrolling timeline segment. + Class paneCapClass = NSClassFromString(@"LKPaneCapSegmentedControl"); + NSView *segControl = nil; + for (NSView *sv in bar.subviews) { + if (paneCapClass && [sv isKindOfClass:paneCapClass]) { + segControl = sv; + break; + } + } + if (!segControl) { + if (sSFPBAPI) sSFPBAPI->log(@"[SFPButtons] LKPaneCapSegmentedControl not found in timeline bar."); + return; + } + + SpliceKitSFP_BtnController *ctrl = [SpliceKitSFP_BtnController shared]; + + NSButton * (^makeBtn)(NSImage *, SEL, NSString *, NSString *) = + ^(NSImage *icon, SEL action, NSString *ident, NSString *tip) { + NSButton *btn = [[NSButton alloc] init]; + [btn setButtonType:NSButtonTypeMomentaryPushIn]; + btn.bezelStyle = NSBezelStyleTexturedRounded; + btn.bordered = YES; + btn.image = icon; + btn.imagePosition = NSImageOnly; + btn.target = ctrl; + btn.action = action; + btn.identifier = ident; + btn.toolTip = tip; + btn.translatesAutoresizingMaskIntoConstraints = NO; + return btn; + }; + + NSButton *bwdBtn = makeBtn( + SFPB_makeIcon(@"arrow.left.square", @"Select Backward from Playhead"), + @selector(selectBackwardAction:), kSFPBBackwardID, + @"Select all clips from the beginning of the timeline to the playhead"); + NSButton *fwdBtn = makeBtn( + SFPB_makeIcon(@"arrow.right.square", @"Select Forward from Playhead"), + @selector(selectForwardAction:), kSFPBForwardID, + @"Select all clips from the playhead to the end of the timeline"); + + [bar addSubview:bwdBtn]; + [bar addSubview:fwdBtn]; + + // fwdBtn: trailing edge touches segControl leading edge with a 6pt gap. + // bwdBtn: trailing edge touches fwdBtn leading edge with a 2pt gap. + // Both match the segControl height and are vertically centred on it. + CGFloat w = 40.0; + [NSLayoutConstraint activateConstraints:@[ + [fwdBtn.trailingAnchor constraintEqualToAnchor:segControl.leadingAnchor constant:-6.0], + [fwdBtn.centerYAnchor constraintEqualToAnchor:segControl.centerYAnchor], + [fwdBtn.widthAnchor constraintEqualToConstant:w], + [fwdBtn.heightAnchor constraintEqualToAnchor:segControl.heightAnchor], + + [bwdBtn.trailingAnchor constraintEqualToAnchor:fwdBtn.leadingAnchor constant:-2.0], + [bwdBtn.centerYAnchor constraintEqualToAnchor:segControl.centerYAnchor], + [bwdBtn.widthAnchor constraintEqualToConstant:w], + [bwdBtn.heightAnchor constraintEqualToAnchor:segControl.heightAnchor], + ]]; + + if (sSFPBAPI) + sSFPBAPI->log(@"[SFPButtons] Buttons installed in timeline bar next to scrolling-timeline control."); + + } @catch (NSException *e) { + if (sSFPBAPI) + sSFPBAPI->log(@"[SFPButtons] Exception installing in timeline bar: %@", e.reason); + } +} + +// --------------------------------------------------------------------------- +// Polling — wait for the timeline module to be ready +// --------------------------------------------------------------------------- +static void SFPB_tryInstall(int attempt); + +static void SFPB_tryInstall(int attempt) { + if (attempt >= 40) { + if (sSFPBAPI) sSFPBAPI->log(@"[SFPButtons] Giving up after %d attempts.", attempt); + return; + } + dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(1.5 * NSEC_PER_SEC)), + dispatch_get_main_queue(), ^{ + NSView *bar = SFPB_findTimelineBarView(); + if (bar && bar.subviews.count > 0) { + SFPB_removeFromMainToolbar(); + SFPB_addButtonsToTimelineBar(); + } else { + SFPB_tryInstall(attempt + 1); + } + }); +} + +static void SFPB_startInstallation(void) { + // Fast path: fire when a project is opened and the timeline bar becomes visible. + [[NSNotificationCenter defaultCenter] + addObserverForName:NSWindowDidBecomeMainNotification + object:nil + queue:[NSOperationQueue mainQueue] + usingBlock:^(NSNotification * __unused note) { + NSView *bar = SFPB_findTimelineBarView(); + if (bar && bar.subviews.count > 0) { + SFPB_removeFromMainToolbar(); + SFPB_addButtonsToTimelineBar(); + } + }]; + + // Slow path: poll until the timeline module is loaded. + SFPB_tryInstall(0); +} + +// --------------------------------------------------------------------------- +// Plugin entry point +// --------------------------------------------------------------------------- +__attribute__((visibility("default"))) +void SpliceKitPlugin_init(SpliceKitPluginAPI *api) { + sSFPBAPIStorage = *api; + sSFPBAPI = &sSFPBAPIStorage; + + sSFPBAPI->log(@"[SFPButtons] Loading — will inject into timeline bar."); + + api->executeOnMainThreadAsync(^{ + // Install the toolbar swizzle so FCP can resolve our identifiers if they + // are still saved in its toolbar customisation preferences (legacy cleanup). + SFPB_installSwizzle(); + SFPB_startInstallation(); + }); + + api->log(@"[SFPButtons] Loaded."); +} diff --git a/examples/plugins/com.splicekit.transition-alignment/Makefile b/examples/plugins/com.splicekit.transition-alignment/Makefile new file mode 100644 index 0000000..ef10af5 --- /dev/null +++ b/examples/plugins/com.splicekit.transition-alignment/Makefile @@ -0,0 +1,42 @@ +# Makefile for com.splicekit.transition-alignment +# +# Usage: +# make — compile plugin.dylib into build/ +# make install — compile + copy into ~/Library/Application Support/SpliceKit/plugins/ +# make clean — remove build artefacts + +PLUGIN_ID = com.splicekit.transition-alignment +PLUGIN_ROOT = $(shell pwd) +REPO_ROOT = $(PLUGIN_ROOT)/../../.. +SRC = src/TransitionAlignment.m +DYLIB = build/plugin.dylib +INSTALL_DIR = $(HOME)/Library/Application Support/SpliceKit/plugins/$(PLUGIN_ID) + +CC = clang +ARCHS = -arch arm64 -arch x86_64 +MIN_VER = -mmacosx-version-min=14.0 +CFLAGS = -O2 -fvisibility=hidden -fobjc-arc \ + -I$(REPO_ROOT)/Sources \ + $(ARCHS) $(MIN_VER) +LDFLAGS = -dynamiclib -undefined dynamic_lookup \ + -framework Foundation -framework AppKit \ + $(ARCHS) $(MIN_VER) + +.PHONY: all install clean + +all: $(DYLIB) + +$(DYLIB): $(SRC) $(REPO_ROOT)/Sources/SpliceKitPluginAPI.h + @mkdir -p build + $(CC) $(CFLAGS) $(LDFLAGS) -o $@ $< + @echo "Built $(DYLIB)" + +install: $(DYLIB) + @mkdir -p "$(INSTALL_DIR)" + cp -f $(DYLIB) "$(INSTALL_DIR)/plugin.dylib" + cp -f plugin.json "$(INSTALL_DIR)/plugin.json" + @echo "Installed $(PLUGIN_ID) → $(INSTALL_DIR)" + @echo "Restart FCP (or hot-reload via debug_load_plugin) to activate." + +clean: + rm -rf build diff --git a/examples/plugins/com.splicekit.transition-alignment/plugin.json b/examples/plugins/com.splicekit.transition-alignment/plugin.json new file mode 100644 index 0000000..e0a06c0 --- /dev/null +++ b/examples/plugins/com.splicekit.transition-alignment/plugin.json @@ -0,0 +1,22 @@ +{ + "id": "com.splicekit.transition-alignment", + "name": "Transition Alignment", + "version": "1.0.0", + "description": "Exposes the hidden transitionOverlapType property on FFAnchoredTransition, restoring legacy FCP 7 transition alignment options (Center on Edit, End on Edit) via the transitions.setAlignment MCP method.", + "author": "SpliceKit", + "apiVersion": 1, + "entry": { + "native": "plugin.dylib" + }, + "methods": [ + { + "name": "transitions.setAlignment", + "description": "Set transition alignment (Center on Edit or End on Edit) for selected or all transitions on the primary storyline.", + "params": { + "alignment": "\"center\" | \"end\"", + "scope": "\"all\" | \"selected\" (default: \"all\")" + } + } + ], + "dependencies": [] +} diff --git a/examples/plugins/com.splicekit.transition-alignment/src/TransitionAlignment.m b/examples/plugins/com.splicekit.transition-alignment/src/TransitionAlignment.m new file mode 100644 index 0000000..648deab --- /dev/null +++ b/examples/plugins/com.splicekit.transition-alignment/src/TransitionAlignment.m @@ -0,0 +1,341 @@ +// +// TransitionAlignment.m +// com.splicekit.transition-alignment +// +// Restores legacy FCP 7 transition alignment options via the hidden +// transitionOverlapType property on FFAnchoredTransition. +// +// FCP 12.x only exposes "Center on Edit" in its UI, but the internal +// data model still supports: +// 0 = End on Edit (transition ends exactly at the cut point) +// 1 = Center on Edit (default — transition straddles the cut point) +// +// "Start on Edit" was removed from FCP X and is not supported. +// +// Features: +// 1. Right-click a transition → "Transition Alignment" submenu with +// "Center on Edit" and "End on Edit" items. +// 2. MCP method transitions.setAlignment for programmatic control. +// +// Build: make +// Install: make install +// + +#import +#import +#import +#import + +#import "SpliceKitPluginAPI.h" + +// --------------------------------------------------------------------------- +// Plugin API storage +// --------------------------------------------------------------------------- +static SpliceKitPluginAPI sTAAPIStorage; +static SpliceKitPluginAPI *sTAAPI = NULL; + +// IMP of the original timelineView:contextMenuForItem: we chain through to. +static IMP sTAOrigContextMenu = NULL; + +// --------------------------------------------------------------------------- +// Runtime navigation helpers +// --------------------------------------------------------------------------- + +static id TA_getSequence(void) { + @try { + Class appClass = NSClassFromString(@"NSApplication"); + id app = ((id(*)(id,SEL))objc_msgSend)(appClass, @selector(sharedApplication)); + id delegate = ((id(*)(id,SEL))objc_msgSend)(app, @selector(delegate)); + if (!delegate) return nil; + id container = ((id(*)(id,SEL))objc_msgSend)(delegate, @selector(activeEditorContainer)); + if (!container) return nil; + id tlModule = ((id(*)(id,SEL))objc_msgSend)(container, @selector(timelineModule)); + if (!tlModule) return nil; + return ((id(*)(id,SEL))objc_msgSend)(tlModule, @selector(sequence)); + } @catch (__unused NSException *e) { return nil; } +} + +static id TA_getSpine(id sequence) { + if (!sequence) return nil; + @try { + SEL sel = NSSelectorFromString(@"primaryObject"); + if (![sequence respondsToSelector:sel]) return nil; + return ((id(*)(id,SEL))objc_msgSend)(sequence, sel); + } @catch (__unused NSException *e) { return nil; } +} + +// --------------------------------------------------------------------------- +// Core alignment operation — caller must be on the main thread. +// +// Primary-storyline transitions have no meaningful anchorPair of their own. +// Their timeline position is entirely derived from where their adjacent clips +// overlap. The only API that physically moves the transition is +// FFAnchoredSequence -operationChangeTransitionObjectsOnSpineObjectOverlapType: +// spineObjectsToChange:transitionOverlapType:error:, which is what FCP uses +// internally. It adjusts the source-handle split between the two adjacent clips +// (outgoing donates more, incoming less, for End on Edit vs Center on Edit) +// and registers an undo action. We resolve sequence/spine here so the context- +// menu path can pass nil and still get the live objects. +// --------------------------------------------------------------------------- +static NSString *TA_applyAlignment(id sequence, id spine, + NSArray *transitions, int overlapType) { + if (transitions.count == 0) return @"No transitions found"; + + if (!sequence) sequence = TA_getSequence(); + if (!sequence) return @"No active sequence"; + if (!spine) spine = TA_getSpine(sequence); + if (!spine) return @"Could not find primary storyline"; + + @try { + SEL opSel = NSSelectorFromString( + @"operationChangeTransitionObjectsOnSpineObjectOverlapType:" + "spineObjectsToChange:transitionOverlapType:error:"); + if (![sequence respondsToSelector:opSel]) + return @"operationChangeTransitionObjectsOnSpineObjectOverlapType: not found"; + + NSError *error = nil; + BOOL ok = ((BOOL(*)(id,SEL,id,NSArray*,int,NSError**))objc_msgSend)( + sequence, opSel, spine, transitions, overlapType, &error); + + if (sTAAPI) + sTAAPI->log(@"[TransitionAlignment] op ok=%d overlapType=%d count=%lu err=%@", + ok, overlapType, (unsigned long)transitions.count, error); + + if (!ok) return error ? error.localizedDescription : @"Operation returned NO"; + return nil; + } @catch (NSException *e) { + return [NSString stringWithFormat:@"Exception: %@", e.reason]; + } +} + +// --------------------------------------------------------------------------- +// Context menu swizzle +// +// Swizzles FFAnchoredTimelineModule -timelineView:contextMenuForItem: +// When the right-clicked item is an FFAnchoredTransition, we append a +// "Transition Alignment" submenu to whatever menu FCP built. +// --------------------------------------------------------------------------- + +// Button controller that owns the menu item actions. +@interface SpliceKitTA_MenuController : NSObject ++ (instancetype)shared; +- (void)setCenterOnEdit:(id)sender; +- (void)setEndOnEdit:(id)sender; +@end + +@implementation SpliceKitTA_MenuController + ++ (instancetype)shared { + static SpliceKitTA_MenuController *sInstance; + static dispatch_once_t sOnce; + dispatch_once(&sOnce, ^{ sInstance = [[self alloc] init]; }); + return sInstance; +} + +static void TA_reloadTimeline(void) { + @try { + id app = ((id(*)(id,SEL))objc_msgSend)(NSClassFromString(@"NSApplication"), + @selector(sharedApplication)); + id delegate = ((id(*)(id,SEL))objc_msgSend)(app, @selector(delegate)); + id container = ((id(*)(id,SEL))objc_msgSend)(delegate, + @selector(activeEditorContainer)); + id tlModule = ((id(*)(id,SEL))objc_msgSend)(container, @selector(timelineModule)); + if (!tlModule) return; + SEL sel = NSSelectorFromString(@"reloadTimelineView:"); + if ([tlModule respondsToSelector:sel]) + ((void(*)(id,SEL,id))objc_msgSend)(tlModule, sel, nil); + } @catch (__unused NSException *e) {} +} + +// The represented object of each menu item is the FFAnchoredTransition. +- (void)_applyOverlapType:(int)overlapType fromSender:(id)sender { + id transition = [sender representedObject]; + if (!transition) return; + NSString *err = TA_applyAlignment(nil, nil, @[transition], overlapType); + if (err) { + if (sTAAPI) sTAAPI->log(@"[TransitionAlignment] Context menu error: %@", err); + } else { + TA_reloadTimeline(); + } +} + +- (void)setCenterOnEdit:(id)sender { [self _applyOverlapType:1 fromSender:sender]; } +- (void)setEndOnEdit:(id)sender { [self _applyOverlapType:0 fromSender:sender]; } + +@end + + +// The replacement IMP for timelineView:contextMenuForItem: +static id TA_contextMenuForItem(id self, SEL _cmd, id tlView, id item) { + // Call through to get FCP's original menu. + NSMenu *menu = ((id(*)(id, SEL, id, id))sTAOrigContextMenu)(self, _cmd, tlView, item); + + if (sTAAPI && item) + sTAAPI->log(@"[TransitionAlignment] contextMenu item class: %s", + class_getName([item class])); + + // Accept FFAnchoredTransition or any object that has transitionOverlapType. + // The item might be a view-model wrapper rather than the model object itself, + // so we use KVC probing instead of an exact class check. + id overlapVal = nil; + @try { overlapVal = [item valueForKey:@"transitionOverlapType"]; } + @catch (__unused NSException *e) { overlapVal = nil; } + if (!overlapVal) return menu; + + // The item IS or WRAPS a transition. Use it directly as the target. + id transition = item; + + // Create the submenu items. The representedObject carries the transition + // so the action can operate on just that one clip. + NSMenuItem *centerItem = [[NSMenuItem alloc] + initWithTitle:@"Center on Edit" + action:@selector(setCenterOnEdit:) + keyEquivalent:@""]; + centerItem.target = [SpliceKitTA_MenuController shared]; + centerItem.representedObject = item; + + NSMenuItem *endItem = [[NSMenuItem alloc] + initWithTitle:@"End on Edit" + action:@selector(setEndOnEdit:) + keyEquivalent:@""]; + endItem.target = [SpliceKitTA_MenuController shared]; + endItem.representedObject = item; + + // Mark the current alignment using the overlapVal we already have. + int current = overlapVal ? [overlapVal intValue] : 1; + centerItem.state = (current == 1) ? NSControlStateValueOn : NSControlStateValueOff; + endItem.state = (current == 0) ? NSControlStateValueOn : NSControlStateValueOff; + + NSMenu *submenu = [[NSMenu alloc] initWithTitle:@"Transition Alignment"]; + [submenu addItem:centerItem]; + [submenu addItem:endItem]; + + NSMenuItem *parentItem = [[NSMenuItem alloc] initWithTitle:@"Transition Alignment" + action:nil + keyEquivalent:@""]; + parentItem.submenu = submenu; + + if (!menu) menu = [[NSMenu alloc] initWithTitle:@""]; + [menu addItem:[NSMenuItem separatorItem]]; + [menu addItem:parentItem]; + + return menu; +} + +static void TA_installContextMenuSwizzle(void) { + if (sTAOrigContextMenu) return; + @try { + Class cls = NSClassFromString(@"FFAnchoredTimelineModule"); + if (!cls) { + if (sTAAPI) sTAAPI->log(@"[TransitionAlignment] FFAnchoredTimelineModule not found."); + return; + } + SEL sel = NSSelectorFromString(@"timelineView:contextMenuForItem:"); + Method m = class_getInstanceMethod(cls, sel); + if (!m) { + if (sTAAPI) sTAAPI->log(@"[TransitionAlignment] timelineView:contextMenuForItem: not found."); + return; + } + sTAOrigContextMenu = method_getImplementation(m); + class_replaceMethod(cls, sel, (IMP)TA_contextMenuForItem, + method_getTypeEncoding(m)); + if (sTAAPI) sTAAPI->log(@"[TransitionAlignment] Context menu swizzle installed."); + } @catch (NSException *e) { + sTAOrigContextMenu = NULL; + if (sTAAPI) sTAAPI->log(@"[TransitionAlignment] Swizzle failed: %@", e.reason); + } +} + +// --------------------------------------------------------------------------- +// MCP method: transitions.setAlignment +// --------------------------------------------------------------------------- +static NSArray *TA_collectTransitions(id spine, BOOL selectedOnly) { + if (!spine) return @[]; + @try { + SEL itemsSel = NSSelectorFromString(@"containedItems"); + if (![spine respondsToSelector:itemsSel]) return @[]; + id items = ((id(*)(id,SEL))objc_msgSend)(spine, itemsSel); + if (![items respondsToSelector:@selector(objectEnumerator)]) return @[]; + Class transitionClass = NSClassFromString(@"FFAnchoredTransition"); + NSMutableArray *result = [NSMutableArray array]; + for (id item in items) { + if (!transitionClass || [item isKindOfClass:transitionClass]) { + if (selectedOnly && ![[item valueForKey:@"selected"] boolValue]) continue; + [result addObject:item]; + } + } + return result; + } @catch (__unused NSException *e) { return @[]; } +} + +static NSDictionary *TA_handleSetAlignment(NSDictionary *params) { + NSString *alignment = params[@"alignment"]; + NSString *scope = params[@"scope"] ?: @"all"; + + if (!alignment) + return @{@"error": @"Missing required param: alignment (\"center\" or \"end\")"}; + + int overlapType; + if ([alignment isEqualToString:@"center"]) overlapType = 1; + else if ([alignment isEqualToString:@"end"]) overlapType = 0; + else return @{@"error": [NSString stringWithFormat: + @"Invalid alignment \"%@\" — use \"center\" or \"end\"", alignment]}; + + BOOL selectedOnly = [scope isEqualToString:@"selected"]; + + __block NSString *errorMsg = nil; + __block NSUInteger changed = 0; + dispatch_semaphore_t sem = dispatch_semaphore_create(0); + + dispatch_async(dispatch_get_main_queue(), ^{ + id sequence = TA_getSequence(); + if (!sequence) { errorMsg = @"No active sequence"; dispatch_semaphore_signal(sem); return; } + id spine = TA_getSpine(sequence); + if (!spine) { errorMsg = @"Could not find primary storyline"; dispatch_semaphore_signal(sem); return; } + NSArray *transitions = TA_collectTransitions(spine, selectedOnly); + if (transitions.count == 0) { + errorMsg = selectedOnly ? @"No transitions selected" : @"No transitions on primary storyline"; + dispatch_semaphore_signal(sem); + return; + } + errorMsg = TA_applyAlignment(nil, nil, transitions, overlapType); + if (!errorMsg) changed = transitions.count; + dispatch_semaphore_signal(sem); + }); + dispatch_semaphore_wait(sem, DISPATCH_TIME_FOREVER); + + if (errorMsg) return @{@"error": errorMsg}; + return @{@"status": @"ok", @"alignment": alignment, @"scope": scope, @"changed": @(changed)}; +} + +// --------------------------------------------------------------------------- +// Plugin entry point +// --------------------------------------------------------------------------- +__attribute__((visibility("default"))) +void SpliceKitPlugin_init(SpliceKitPluginAPI *api) { + sTAAPIStorage = *api; + sTAAPI = &sTAAPIStorage; + + sTAAPI->log(@"[TransitionAlignment] Loading."); + + // Context menu swizzle must run on main thread. + sTAAPI->executeOnMainThreadAsync(^{ + TA_installContextMenuSwizzle(); + }); + + sTAAPI->registerMethod( + @"transitions.setAlignment", + ^NSDictionary *(NSDictionary *params) { return TA_handleSetAlignment(params); }, + @{ + @"description": @"Set transition alignment. \"center\" = Center on Edit (FCP default). " + @"\"end\" = End on Edit (transition ends at the cut point).", + @"params": @{ + @"alignment": @"\"center\" | \"end\"", + @"scope": @"\"all\" | \"selected\" (default: \"all\")", + }, + @"readOnly": @NO, + } + ); + + sTAAPI->log(@"[TransitionAlignment] Loaded."); +} diff --git a/examples/plugins/com.splicekit.transition-auditions/Makefile b/examples/plugins/com.splicekit.transition-auditions/Makefile new file mode 100644 index 0000000..e914fe9 --- /dev/null +++ b/examples/plugins/com.splicekit.transition-auditions/Makefile @@ -0,0 +1,42 @@ +# Makefile for com.splicekit.transition-auditions +# +# Usage: +# make — compile plugin.dylib into build/ +# make install — compile + copy into ~/Library/Application Support/SpliceKit/plugins/ +# make clean — remove build artefacts + +PLUGIN_ID = com.splicekit.transition-auditions +PLUGIN_ROOT = $(shell pwd) +REPO_ROOT = $(PLUGIN_ROOT)/../../.. +SRC = src/TransitionAuditions.m +DYLIB = build/plugin.dylib +INSTALL_DIR = $(HOME)/Library/Application Support/SpliceKit/plugins/$(PLUGIN_ID) + +CC = clang +ARCHS = -arch arm64 -arch x86_64 +MIN_VER = -mmacosx-version-min=14.0 +CFLAGS = -O2 -fvisibility=hidden -fobjc-arc \ + -I$(REPO_ROOT)/Sources \ + $(ARCHS) $(MIN_VER) +LDFLAGS = -dynamiclib -undefined dynamic_lookup \ + -framework Foundation -framework AppKit \ + $(ARCHS) $(MIN_VER) + +.PHONY: all install clean + +all: $(DYLIB) + +$(DYLIB): $(SRC) $(REPO_ROOT)/Sources/SpliceKitPluginAPI.h + @mkdir -p build + $(CC) $(CFLAGS) $(LDFLAGS) -o $@ $< + @echo "Built $(DYLIB)" + +install: $(DYLIB) + @mkdir -p "$(INSTALL_DIR)" + cp -f $(DYLIB) "$(INSTALL_DIR)/plugin.dylib" + cp -f plugin.json "$(INSTALL_DIR)/plugin.json" + @echo "Installed $(PLUGIN_ID) → $(INSTALL_DIR)" + @echo "Restart FCP (or hot-reload via debug_load_plugin) to activate." + +clean: + rm -rf build diff --git a/examples/plugins/com.splicekit.transition-auditions/plugin.json b/examples/plugins/com.splicekit.transition-auditions/plugin.json new file mode 100644 index 0000000..355342c --- /dev/null +++ b/examples/plugins/com.splicekit.transition-auditions/plugin.json @@ -0,0 +1,13 @@ +{ + "id": "com.splicekit.transition-auditions", + "name": "Transition Auditions", + "version": "1.0.0", + "description": "Adds a Transition Auditions button to FCP's timeline bar. Position the playhead near a cut, click the button, choose All Transitions or a custom subset, then cycle through them live with Prev/Next. Keep the one you like or cancel to remove it.", + "author": "SpliceKit", + "apiVersion": 1, + "entry": { + "native": "plugin.dylib" + }, + "methods": [], + "dependencies": [] +} diff --git a/examples/plugins/com.splicekit.transition-auditions/src/TransitionAuditions.m b/examples/plugins/com.splicekit.transition-auditions/src/TransitionAuditions.m new file mode 100644 index 0000000..b41f5c6 --- /dev/null +++ b/examples/plugins/com.splicekit.transition-auditions/src/TransitionAuditions.m @@ -0,0 +1,1584 @@ +// +// TransitionAuditions.m +// com.splicekit.transition-auditions +// +// Adds a "Transition Auditions" button to FCP's timeline bar. +// +// IDLE state: +// - Segmented control: [All Transitions] [Custom Set] +// - In Custom mode: searchable checkbox table +// - [Start Audition] — finds nearest edit point, applies first transition +// +// ACTIVE state — tournament bracket: +// Each transition is applied at the edit point. +// [▶ Preview] — seek 2s before transition, play +// [👍 Add to Audition] — keep this one in the running, advance +// [👎 Skip] — eliminate this one, advance +// After all are evaluated, repeat with the survivors. +// When only one transition remains it is applied and the popover closes. +// +// Custom set persisted in {dataPath}/custom-set.json. +// +// Build: make +// Install: make install +// + +#import +#import +#import +#import +#import + +#import "SpliceKitPluginAPI.h" + +// --------------------------------------------------------------------------- +// Plugin globals +// --------------------------------------------------------------------------- +static SpliceKitPluginAPI sTAuAPIStorage; +static SpliceKitPluginAPI *sTAuAPI = NULL; + +static NSString * const kTAuButtonID = @"SpliceKitTAu_AuditionButtonID"; +static NSString * const kSFPBackwardID = @"SpliceKitSFP_SelectBackwardItemID"; + +// --------------------------------------------------------------------------- +#pragma mark - Audition state +// --------------------------------------------------------------------------- + +typedef NS_ENUM(NSInteger, TAuPhase) { + TAuPhaseIdle = 0, + TAuPhaseActive, +}; + +@interface TAuState : NSObject +@property (nonatomic) TAuPhase phase; +// Tournament bracket +@property (nonatomic, strong) NSMutableArray *pendingQueue; // not yet voted this round +@property (nonatomic, strong) NSMutableArray *keptSet; // survived this round +@property (nonatomic, strong) NSDictionary *currentTransition; // currently applied +@property (nonatomic) NSInteger currentIndex; // index into pendingQueue of currentTransition +@property (nonatomic) double editPointTime; // seconds +@property (nonatomic) NSInteger roundNumber; +@property (nonatomic) NSInteger originalCount; // set at start of each round +@property (nonatomic) NSInteger positionInRound; // 1-indexed display counter +// If there was already a transition at the edit point when audition started, +// we delete it first so addTransition: can succeed (count 0→1). +// Cancel fires one extra undo to restore it. +@property (nonatomic) BOOL hadOriginalTransition; +@end + +@implementation TAuState +- (instancetype)init { + self = [super init]; + _phase = TAuPhaseIdle; + _pendingQueue = [NSMutableArray array]; + _keptSet = [NSMutableArray array]; + _currentIndex = 0; + _editPointTime = 0; + _roundNumber = 0; + _originalCount = 0; + _positionInRound = 1; + _hadOriginalTransition = NO; + return self; +} +@end + +// --------------------------------------------------------------------------- +#pragma mark - Settings persistence (preroll / postroll / custom set) +// --------------------------------------------------------------------------- + +static NSMutableSet *sTAuCustomIDs = nil; +static NSString *sTAuDataPath = nil; +static double sTAuPrerollSeconds = 2.0; +static double sTAuPostrollSeconds = 2.0; + +// Loop interval = preroll + ~1.2s default transition buffer + postroll +static NSTimeInterval TAu_loopInterval(void) { + return sTAuPrerollSeconds + 1.2 + sTAuPostrollSeconds; +} + +static void TAu_loadSettings(void) { + if (!sTAuDataPath) return; + NSString *path = [sTAuDataPath stringByAppendingPathComponent:@"settings.json"]; + NSData *data = [NSData dataWithContentsOfFile:path]; + if (!data) return; + NSDictionary *d = [NSJSONSerialization JSONObjectWithData:data options:0 error:nil]; + if (![d isKindOfClass:[NSDictionary class]]) return; + if (d[@"prerollSeconds"]) sTAuPrerollSeconds = MAX(2.0, MIN(5.0, [d[@"prerollSeconds"] doubleValue])); + if (d[@"postrollSeconds"]) sTAuPostrollSeconds = MAX(2.0, MIN(5.0, [d[@"postrollSeconds"] doubleValue])); +} + +static void TAu_saveSettings(void) { + if (!sTAuDataPath) return; + [[NSFileManager defaultManager] createDirectoryAtPath:sTAuDataPath + withIntermediateDirectories:YES attributes:nil error:nil]; + NSDictionary *d = @{@"prerollSeconds": @(sTAuPrerollSeconds), + @"postrollSeconds": @(sTAuPostrollSeconds)}; + NSData *data = [NSJSONSerialization dataWithJSONObject:d options:0 error:nil]; + if (data) + [data writeToFile:[sTAuDataPath stringByAppendingPathComponent:@"settings.json"] + atomically:YES]; +} + +static void TAu_loadCustomSet(void) { + sTAuCustomIDs = [NSMutableSet set]; + if (!sTAuDataPath) return; + NSString *path = [sTAuDataPath stringByAppendingPathComponent:@"custom-set.json"]; + NSData *data = [NSData dataWithContentsOfFile:path]; + if (!data) return; + NSArray *ids = [NSJSONSerialization JSONObjectWithData:data options:0 error:nil]; + if ([ids isKindOfClass:[NSArray class]]) + [sTAuCustomIDs addObjectsFromArray:ids]; +} + +static void TAu_saveCustomSet(void) { + if (!sTAuDataPath || !sTAuCustomIDs) return; + [[NSFileManager defaultManager] createDirectoryAtPath:sTAuDataPath + withIntermediateDirectories:YES attributes:nil error:nil]; + NSData *data = [NSJSONSerialization dataWithJSONObject:[sTAuCustomIDs allObjects] + options:0 error:nil]; + if (data) + [data writeToFile:[sTAuDataPath stringByAppendingPathComponent:@"custom-set.json"] + atomically:YES]; +} + +// --------------------------------------------------------------------------- +#pragma mark - RPC helpers (background thread) +// --------------------------------------------------------------------------- + +static NSArray *TAu_loadAllTransitions(void) { + NSDictionary *r = sTAuAPI->callMethod(@{@"method": @"transitions.list"}); + NSDictionary *result = r[@"result"]; + NSArray *list = result[@"transitions"]; + if (![list isKindOfClass:[NSArray class]]) return @[]; + + // Deduplicate by display name — the same transition often appears multiple + // times with different effectIDs when it is saved in Favorites as well as + // its original category folder. Keep the first occurrence of each name. + NSMutableOrderedSet *seen = [NSMutableOrderedSet orderedSet]; + NSMutableArray *deduped = [NSMutableArray array]; + for (NSDictionary *t in list) { + NSString *name = t[@"name"] ?: @""; + if (![seen containsObject:name]) { + [seen addObject:name]; + [deduped addObject:t]; + } + } + sTAuAPI->log(@"[TAu] Loaded %lu transitions (%lu after dedup)", + (unsigned long)[(NSArray *)list count], (unsigned long)deduped.count); + return deduped; +} + +// Returns the edit-point time (seconds) to audition, or -1 if none qualifies. +// +// Rules: +// 1. Only consider primary-storyline FFAnchoredTransition objects. +// 2. Exclude the last (highest-midpoint) transition — it's at the sequence +// tail with no postroll room. +// 3. Among the remaining candidates: +// a. A SELECTED transition takes priority regardless of playhead distance. +// b. Otherwise, use the candidate whose midpoint is nearest the playhead, +// provided it is within 10 seconds (prevents auditing a random distant +// transition when the playhead is far away). +// 4. Fall back to nearest bare clip boundary if no transitions exist at all. +static double TAu_findNearestEditPoint(void) { + NSDictionary *r = sTAuAPI->callMethod(@{@"method": @"timeline.getDetailedState"}); + NSDictionary *result = r[@"result"]; + if (!result || result[@"error"] || !result[@"items"]) return -1; + + double playhead = [result[@"playheadTime"][@"seconds"] doubleValue]; + NSArray *items = result[@"items"]; + + // --- Collect primary-storyline transitions --- + NSMutableArray *transitions = [NSMutableArray array]; + for (NSDictionary *item in items) { + NSNumber *lane = item[@"lane"]; + if (lane && [lane integerValue] != 0) continue; + if (![item[@"class"] containsString:@"Transition"]) continue; + if (!item[@"startTime"] || !item[@"endTime"]) continue; + [transitions addObject:item]; + } + + if (transitions.count > 0) { + // Sequence duration — used to detect end-of-sequence transitions. + double seqDuration = [result[@"duration"][@"seconds"] doubleValue]; + + // Exclude transitions with less than 2s of postroll. + NSMutableArray *candidates = [NSMutableArray array]; + for (NSDictionary *t in transitions) { + double te = [t[@"endTime"][@"seconds"] doubleValue]; + double postroll = seqDuration - te; + if (seqDuration > 0 && postroll < 2.0) { + sTAuAPI->log(@"[TAu] skipping end-of-sequence transition (postroll=%.2fs)", postroll); + continue; + } + [candidates addObject:t]; + } + + // Priority 1: transition whose range CONTAINS the playhead. + // When the user scrubs to a transition this fires instantly. + for (NSDictionary *t in candidates) { + double ts = [t[@"startTime"][@"seconds"] doubleValue]; + double te = [t[@"endTime"][@"seconds"] doubleValue]; + if (playhead >= ts && playhead <= te) { + double mid = (ts + te) / 2.0; + sTAuAPI->log(@"[TAu] edit point: playhead inside transition at %.4fs", mid); + return mid; + } + } + + // Priority 2: nearest transition to the playhead. + // FCP's selectedItems: never includes transitions, so we can't detect + // "highlighted" programmatically — proximity to playhead is the signal. + double nearest = -1, bestDist = DBL_MAX; + for (NSDictionary *t in candidates) { + double ts = [t[@"startTime"][@"seconds"] doubleValue]; + double te = [t[@"endTime"][@"seconds"] doubleValue]; + double mid = (ts + te) / 2.0; + double dist = fabs(mid - playhead); + if (dist < bestDist) { bestDist = dist; nearest = mid; } + } + if (nearest >= 0) { + sTAuAPI->log(@"[TAu] edit point: nearest transition at %.4fs (dist %.2fs)", nearest, bestDist); + return nearest; + } + } + + // --- Fallback: bare clip boundaries (no transitions on timeline yet) --- + NSMutableArray *clips = [NSMutableArray array]; + for (NSDictionary *item in items) { + NSNumber *lane = item[@"lane"]; + if (lane && [lane integerValue] != 0) continue; + if ([item[@"class"] containsString:@"Transition"]) continue; + if (!item[@"startTime"] || !item[@"endTime"]) continue; + [clips addObject:item]; + } + [clips sortUsingComparator:^NSComparisonResult(NSDictionary *a, NSDictionary *b) { + double ta = [a[@"startTime"][@"seconds"] doubleValue]; + double tb = [b[@"startTime"][@"seconds"] doubleValue]; + return (ta < tb) ? NSOrderedAscending : (ta > tb ? NSOrderedDescending : NSOrderedSame); + }]; + // Exclude boundaries with less than 2s remaining in the sequence. + double seqDur2 = [result[@"duration"][@"seconds"] doubleValue]; + double nearest = -1, bestDist = DBL_MAX; + for (NSInteger i = 0; i + 1 < (NSInteger)clips.count; i++) { + double boundary = [clips[i][@"endTime"][@"seconds"] doubleValue]; + if (seqDur2 > 0 && (seqDur2 - boundary) < 2.0) continue; + double dist = fabs(boundary - playhead); + if (dist < bestDist) { bestDist = dist; nearest = boundary; } + } + if (nearest >= 0) + sTAuAPI->log(@"[TAu] edit point: clip boundary at %.4fs (dist %.2fs)", nearest, bestDist); + return nearest; +} + +static void TAu_seekToTime(double seconds) { + sTAuAPI->callMethod(@{ + @"method": @"playback.seekToTime", + @"params": @{@"seconds": @(seconds)} + }); +} + +static BOOL TAu_isPlaying(void) { + NSDictionary *r = sTAuAPI->callMethod(@{@"method": @"playback.getPosition"}); + return [r[@"result"][@"isPlaying"] boolValue]; +} + +static void TAu_play(void) { + sTAuAPI->callMethod(@{@"method": @"playback.action", + @"params": @{@"action": @"playPause"}}); +} + +static void TAu_ensurePlaying(void) { + if (!TAu_isPlaying()) TAu_play(); +} + +static void TAu_ensurePaused(void) { + if (TAu_isPlaying()) TAu_play(); +} + +static void TAu_applyTransition(NSDictionary *transition) { + NSDictionary *r = sTAuAPI->callMethod(@{ + @"method": @"transitions.apply", + @"params": @{@"effectID": transition[@"effectID"], @"freezeExtend": @YES} + }); + NSDictionary *res = r[@"result"]; + if (res[@"error"] || r[@"error"]) { + sTAuAPI->log(@"[TAu] apply '%@' ERROR: %@", transition[@"name"], res[@"error"] ?: r[@"error"]); + } else { + sTAuAPI->log(@"[TAu] apply '%@' OK: %@", transition[@"name"], res[@"transition"]); + } +} + +static void TAu_undo(void) { + NSDictionary *r = sTAuAPI->callMethod(@{ + @"method": @"timeline.action", + @"params": @{@"action": @"undo"} + }); + NSDictionary *res = r[@"result"]; + sTAuAPI->log(@"[TAu] undo -> action='%@' error='%@'", res[@"action"] ?: @"?", r[@"error"] ?: @"none"); +} + +// Delete the transition at editTime. +// Strategy: getDetailedState gives us item handles; use the handle to call +// setSelectedItems: directly on the timeline module, then delete via responder chain. +// This avoids selectClipAtPlayhead which ignores transitions entirely. +static BOOL TAu_deleteExistingTransitionAt(double editTime) { + NSDictionary *r = sTAuAPI->callMethod(@{@"method": @"timeline.getDetailedState"}); + NSDictionary *result = r[@"result"]; + NSArray *items = result[@"items"]; + if (!items) return NO; + + // Find the transition item within 1s of editTime and grab its handle string + NSString *transHandle = nil; + for (NSDictionary *item in items) { + if (![item[@"class"] containsString:@"Transition"]) continue; + double ts = [item[@"startTime"][@"seconds"] doubleValue]; + double te = [item[@"endTime"][@"seconds"] doubleValue]; + double mid = (ts + te) / 2.0; + if (fabs(mid - editTime) < 1.0) { transHandle = item[@"handle"]; break; } + } + if (!transHandle) return NO; + + // Resolve the handle to the live ObjC object via SpliceKit's inspect endpoint, + // then select it directly on the timeline module and delete. + // SpliceKit stores handles in a global registry; call_method_with_args can + // dereference them. We use call_method with the handle as the receiver to + // call a no-op selector just to confirm it's alive, then select via main thread. + __block BOOL deleted = NO; + dispatch_sync(dispatch_get_main_queue(), ^{ + @try { + // Retrieve the live object from SpliceKit's handle table via the + // inspect_handle RPC which returns the pointer address. We can also + // directly call objc_msgSend on the stored pointer if we can retrieve it. + // + // Simpler: use the bridge's list_handles to get the raw pointer, but + // the cleanest path is to traverse the live sequence ourselves now that + // we know the transition's midpoint. + id delegate = [[NSApplication sharedApplication] delegate]; + id ec = ((id(*)(id,SEL))objc_msgSend)(delegate, NSSelectorFromString(@"activeEditorContainer")); + id tm = ((id(*)(id,SEL))objc_msgSend)(ec, NSSelectorFromString(@"timelineModule")); + id seq = ((id(*)(id,SEL))objc_msgSend)(tm, NSSelectorFromString(@"sequence")); + id primary = ((id(*)(id,SEL))objc_msgSend)(seq, NSSelectorFromString(@"primaryObject")); + NSArray *liveItems = ((NSArray*(*)(id,SEL))objc_msgSend)(primary, NSSelectorFromString(@"containedItems")); + + Class transCls = NSClassFromString(@"FFAnchoredTransition"); + SEL erSel = NSSelectorFromString(@"effectiveRangeOfObject:"); + id found = nil; + + for (id item in liveItems) { + if (transCls && ![item isKindOfClass:transCls]) continue; + if (![primary respondsToSelector:erSel]) continue; + // effectiveRangeOfObject: returns a CMTimeRange struct. + // CMTimeRange = { CMTime start, CMTime duration } + // CMTime = { int64_t value; int32_t timescale; uint32_t flags; int64_t epoch; } + // Total: 2 × (8+4+4+8) = 2 × 24 = 48 bytes on arm64. + // Use NSInvocation to call the struct-returning method safely. + NSMethodSignature *sig = [primary methodSignatureForSelector:erSel]; + if (!sig) continue; + NSInvocation *inv = [NSInvocation invocationWithMethodSignature:sig]; + inv.target = primary; + inv.selector = erSel; + id itemArg = item; + [inv setArgument:&itemArg atIndex:2]; + [inv invoke]; + + // Read the returned struct: 48 bytes + uint8_t buf[48] = {0}; + [inv getReturnValue:buf]; + // start: bytes 0-23, duration: bytes 24-47 + int64_t sv = *(int64_t *)(buf + 0); + int32_t sts = *(int32_t *)(buf + 8); + int64_t dv = *(int64_t *)(buf + 24); + int32_t dts = *(int32_t *)(buf + 32); + double startSec = sts > 0 ? (double)sv / sts : 0; + double durSec = dts > 0 ? (double)dv / dts : 0; + double mid = startSec + durSec / 2.0; + if (fabs(mid - editTime) < 1.0) { found = item; break; } + } + + if (!found) return; + + // Select the transition directly on the timeline module + SEL setSel = NSSelectorFromString(@"setSelectedItems:"); + if ([tm respondsToSelector:setSel]) + ((void(*)(id,SEL,id))objc_msgSend)(tm, setSel, @[found]); + deleted = YES; + } @catch (NSException *e) { + sTAuAPI->log(@"[TAu] findTransition exception: %@", e.reason); + } + }); + + if (!deleted) return NO; + + [NSThread sleepForTimeInterval:0.05]; + sTAuAPI->callMethod(@{@"method": @"timeline.action", @"params": @{@"action": @"delete"}}); + [NSThread sleepForTimeInterval:0.15]; + sTAuAPI->log(@"[TAu] Deleted existing transition at %.3fs", editTime); + return YES; +} + +// --------------------------------------------------------------------------- +#pragma mark - Popover controller +// --------------------------------------------------------------------------- + +@interface TAuPopoverController : NSObject + +@property (nonatomic, strong) NSPopover *popover; +@property (nonatomic, weak) NSButton *toolbarButton; +@property (nonatomic) BOOL cancelingAudition; // YES only when Cancel button tapped + +// Transition library +@property (nonatomic, strong) NSArray *allTransitions; +@property (nonatomic, strong) NSArray *filteredTransitions; +@property (nonatomic) BOOL loading; + +// Audition state +@property (nonatomic, strong) TAuState *audition; + +// ---- Idle UI ---- +@property (nonatomic, strong) NSView *idleView; +@property (nonatomic, strong) NSPopUpButton *prerollPopup; +@property (nonatomic, strong) NSPopUpButton *postrollPopup; +@property (nonatomic, strong) NSSegmentedControl *modeControl; +@property (nonatomic, strong) NSScrollView *tableScroll; +@property (nonatomic, strong) NSTableView *tableView; +@property (nonatomic, strong) NSSearchField *searchField; +@property (nonatomic, strong) NSTextField *countLabel; +@property (nonatomic, strong) NSButton *startButton; +@property (nonatomic, strong) NSProgressIndicator *spinner; + +// Preview loop +@property (nonatomic, strong) NSTimer *loopTimer; +@property (atomic) BOOL loopActive; // checked by in-flight bg blocks + +// ---- Active UI ---- +@property (nonatomic, strong) NSView *activeView; +@property (nonatomic, strong) NSTextField *roundLabel; // "Round 2" +@property (nonatomic, strong) NSTextField *nameLabel; // transition name +@property (nonatomic, strong) NSTextField *progressLabel; // "3 of 7 remaining" +@property (nonatomic, strong) NSButton *previewButton; +@property (nonatomic, strong) NSButton *prevButton; // browse prev +@property (nonatomic, strong) NSButton *nextButton; // browse next +@property (nonatomic, strong) NSButton *addButton; // 👍 Add to Audition +@property (nonatomic, strong) NSButton *skipButton; // 👎 Reject +@property (nonatomic, strong) NSButton *cancelButton; +@property (nonatomic, strong) NSButton *confirmButton; // keep transition + exit +@property (nonatomic, strong) NSButton *confirmFavButton; // keep transition + add to favorites + exit + ++ (instancetype)shared; +- (void)toggleFromButton:(NSButton *)button; + +@end + +@implementation TAuPopoverController + ++ (instancetype)shared { + static TAuPopoverController *s = nil; + static dispatch_once_t once; + dispatch_once(&once, ^{ s = [[self alloc] init]; }); + return s; +} + +- (instancetype)init { + self = [super init]; + if (!self) return nil; + _allTransitions = @[]; + _filteredTransitions = @[]; + _audition = [[TAuState alloc] init]; + return self; +} + +// --------------------------------------------------------------------------- +#pragma mark Popover building +// --------------------------------------------------------------------------- + +- (void)buildPopoverIfNeeded { + if (_popover) return; + + // ============================ IDLE VIEW ================================ + NSView *idle = [[NSView alloc] initWithFrame:NSMakeRect(0, 0, 320, 390)]; + idle.translatesAutoresizingMaskIntoConstraints = NO; + + NSTextField *title = [NSTextField labelWithString:@"Transition Auditions"]; + title.font = [NSFont boldSystemFontOfSize:13]; + title.toolTip = @"Scrub the playhead to the transition you want to audition, then click Start Audition."; + title.translatesAutoresizingMaskIntoConstraints = NO; + [idle addSubview:title]; + + // --- Preroll / Postroll row --- + NSTextField *prerollLbl = [NSTextField labelWithString:@"Preroll:"]; + prerollLbl.font = [NSFont systemFontOfSize:12]; + prerollLbl.translatesAutoresizingMaskIntoConstraints = NO; + [idle addSubview:prerollLbl]; + + NSPopUpButton *prerollPop = [[NSPopUpButton alloc] init]; + for (int s = 2; s <= 5; s++) + [prerollPop addItemWithTitle:[NSString stringWithFormat:@"%ds", s]]; + [prerollPop selectItemWithTitle:[NSString stringWithFormat:@"%ds", (int)sTAuPrerollSeconds]]; + prerollPop.target = self; + prerollPop.action = @selector(prerollChanged:); + prerollPop.translatesAutoresizingMaskIntoConstraints = NO; + [idle addSubview:prerollPop]; + + NSTextField *postrollLbl = [NSTextField labelWithString:@"Postroll:"]; + postrollLbl.font = [NSFont systemFontOfSize:12]; + postrollLbl.translatesAutoresizingMaskIntoConstraints = NO; + [idle addSubview:postrollLbl]; + + NSPopUpButton *postrollPop = [[NSPopUpButton alloc] init]; + for (int s = 2; s <= 5; s++) + [postrollPop addItemWithTitle:[NSString stringWithFormat:@"%ds", s]]; + [postrollPop selectItemWithTitle:[NSString stringWithFormat:@"%ds", (int)sTAuPostrollSeconds]]; + postrollPop.target = self; + postrollPop.action = @selector(postrollChanged:); + postrollPop.translatesAutoresizingMaskIntoConstraints = NO; + [idle addSubview:postrollPop]; + + NSSegmentedControl *mode = [NSSegmentedControl + segmentedControlWithLabels:@[@"All Transitions", @"Custom Set"] + trackingMode:NSSegmentSwitchTrackingSelectOne + target:self action:@selector(modeChanged:)]; + mode.selectedSegment = 0; + mode.translatesAutoresizingMaskIntoConstraints = NO; + [idle addSubview:mode]; + + NSSearchField *sf = [[NSSearchField alloc] init]; + sf.placeholderString = @"Filter transitions…"; + sf.delegate = self; + sf.hidden = YES; + sf.translatesAutoresizingMaskIntoConstraints = NO; + [idle addSubview:sf]; + + NSTableView *tv = [[NSTableView alloc] init]; + tv.dataSource = self; + tv.delegate = self; + tv.rowHeight = 20; + tv.headerView = nil; + tv.focusRingType = NSFocusRingTypeNone; + + NSTableColumn *checkCol = [[NSTableColumn alloc] initWithIdentifier:@"check"]; + checkCol.width = checkCol.minWidth = checkCol.maxWidth = 24; + NSButtonCell *checkCell = [[NSButtonCell alloc] init]; + checkCell.buttonType = NSButtonTypeSwitch; + checkCell.controlSize = NSControlSizeSmall; + checkCell.title = @""; + checkCol.dataCell = checkCell; + [tv addTableColumn:checkCol]; + + NSTableColumn *nameCol = [[NSTableColumn alloc] initWithIdentifier:@"name"]; + nameCol.width = 270; + nameCol.editable = NO; + [tv addTableColumn:nameCol]; + + NSScrollView *scroll = [[NSScrollView alloc] init]; + scroll.documentView = tv; + scroll.hasVerticalScroller = YES; + scroll.borderType = NSBezelBorder; + scroll.hidden = YES; + scroll.translatesAutoresizingMaskIntoConstraints = NO; + [idle addSubview:scroll]; + + NSTextField *count = [NSTextField labelWithString:@""]; + count.textColor = [NSColor secondaryLabelColor]; + count.font = [NSFont systemFontOfSize:11]; + count.hidden = NO; + count.translatesAutoresizingMaskIntoConstraints = NO; + [idle addSubview:count]; + + NSProgressIndicator *spin = [[NSProgressIndicator alloc] init]; + spin.style = NSProgressIndicatorStyleSpinning; + spin.controlSize = NSControlSizeSmall; + spin.hidden = YES; + spin.translatesAutoresizingMaskIntoConstraints = NO; + [idle addSubview:spin]; + + NSButton *start = [NSButton buttonWithTitle:@"Start Audition" + target:self action:@selector(startAudition:)]; + start.bezelStyle = NSBezelStyleRounded; + start.keyEquivalent = @"\r"; + start.translatesAutoresizingMaskIntoConstraints = NO; + [idle addSubview:start]; + + CGFloat pad = 14, gap = 8; + [NSLayoutConstraint activateConstraints:@[ + [idle.widthAnchor constraintEqualToConstant:320], + [idle.heightAnchor constraintEqualToConstant:420], + + // Title row + [title.topAnchor constraintEqualToAnchor:idle.topAnchor constant:pad], + [title.leadingAnchor constraintEqualToAnchor:idle.leadingAnchor constant:pad], + + [spin.centerYAnchor constraintEqualToAnchor:title.centerYAnchor], + [spin.leadingAnchor constraintEqualToAnchor:title.trailingAnchor constant:6], + + // Preroll / Postroll row (below title) + [prerollLbl.topAnchor constraintEqualToAnchor:title.bottomAnchor constant:gap], + [prerollLbl.leadingAnchor constraintEqualToAnchor:idle.leadingAnchor constant:pad], + [prerollLbl.centerYAnchor constraintEqualToAnchor:prerollPop.centerYAnchor], + + [prerollPop.topAnchor constraintEqualToAnchor:title.bottomAnchor constant:gap], + [prerollPop.leadingAnchor constraintEqualToAnchor:prerollLbl.trailingAnchor constant:4], + [prerollPop.widthAnchor constraintEqualToConstant:62], + + [postrollLbl.centerYAnchor constraintEqualToAnchor:prerollPop.centerYAnchor], + [postrollLbl.leadingAnchor constraintEqualToAnchor:prerollPop.trailingAnchor constant:12], + + [postrollPop.topAnchor constraintEqualToAnchor:prerollPop.topAnchor], + [postrollPop.leadingAnchor constraintEqualToAnchor:postrollLbl.trailingAnchor constant:4], + [postrollPop.widthAnchor constraintEqualToConstant:62], + + // Mode control (All / Custom) — below preroll row + [mode.topAnchor constraintEqualToAnchor:prerollPop.bottomAnchor constant:gap], + [mode.leadingAnchor constraintEqualToAnchor:idle.leadingAnchor constant:pad], + [mode.trailingAnchor constraintEqualToAnchor:idle.trailingAnchor constant:-pad], + + [sf.topAnchor constraintEqualToAnchor:mode.bottomAnchor constant:gap], + [sf.leadingAnchor constraintEqualToAnchor:idle.leadingAnchor constant:pad], + [sf.trailingAnchor constraintEqualToAnchor:idle.trailingAnchor constant:-pad], + + [scroll.topAnchor constraintEqualToAnchor:sf.bottomAnchor constant:4], + [scroll.leadingAnchor constraintEqualToAnchor:idle.leadingAnchor constant:pad], + [scroll.trailingAnchor constraintEqualToAnchor:idle.trailingAnchor constant:-pad], + [scroll.heightAnchor constraintEqualToConstant:230], + + [count.topAnchor constraintEqualToAnchor:mode.bottomAnchor constant:gap], + [count.leadingAnchor constraintEqualToAnchor:idle.leadingAnchor constant:pad], + + [start.bottomAnchor constraintEqualToAnchor:idle.bottomAnchor constant:-pad], + [start.trailingAnchor constraintEqualToAnchor:idle.trailingAnchor constant:-pad], + ]]; + + self.prerollPopup = prerollPop; + self.postrollPopup = postrollPop; + self.modeControl = mode; + self.searchField = sf; + self.tableScroll = scroll; + self.tableView = tv; + self.countLabel = count; + self.spinner = spin; + self.startButton = start; + self.idleView = idle; + + // ============================ ACTIVE VIEW ============================== + NSView *active = [[NSView alloc] initWithFrame:NSMakeRect(0, 0, 320, 240)]; + active.translatesAutoresizingMaskIntoConstraints = NO; + + NSTextField *roundLbl = [NSTextField labelWithString:@"Round 1"]; + roundLbl.textColor = [NSColor secondaryLabelColor]; + roundLbl.font = [NSFont systemFontOfSize:11]; + roundLbl.translatesAutoresizingMaskIntoConstraints = NO; + [active addSubview:roundLbl]; + + // Use a non-empty placeholder so Auto Layout measures a real intrinsic height from the start. + // The string is replaced with the real transition name in updateActiveView. + NSTextField *nameLbl = [NSTextField labelWithString:@"Transition Name"]; + nameLbl.font = [NSFont boldSystemFontOfSize:17]; + nameLbl.lineBreakMode = NSLineBreakByTruncatingTail; + nameLbl.alignment = NSTextAlignmentLeft; + nameLbl.translatesAutoresizingMaskIntoConstraints = NO; + [active addSubview:nameLbl]; + + NSTextField *progLbl = [NSTextField labelWithString:@""]; + progLbl.textColor = [NSColor secondaryLabelColor]; + progLbl.font = [NSFont systemFontOfSize:12]; + progLbl.translatesAutoresizingMaskIntoConstraints = NO; + [active addSubview:progLbl]; + + // Preview button — full width + NSButton *preview = [NSButton buttonWithTitle:@"▶ Preview" + target:self action:@selector(previewTransition:)]; + preview.bezelStyle = NSBezelStyleRounded; + preview.translatesAutoresizingMaskIntoConstraints = NO; + [active addSubview:preview]; + + // Prev / Next navigation (browse without voting) + NSButton *prevBtn = [NSButton buttonWithTitle:@"◀ Prev" + target:self action:@selector(browseTransition:)]; + prevBtn.tag = -1; + prevBtn.bezelStyle = NSBezelStyleRounded; + prevBtn.translatesAutoresizingMaskIntoConstraints = NO; + [active addSubview:prevBtn]; + + NSButton *nextBtn = [NSButton buttonWithTitle:@"Next ▶" + target:self action:@selector(browseTransition:)]; + nextBtn.tag = +1; + nextBtn.bezelStyle = NSBezelStyleRounded; + nextBtn.translatesAutoresizingMaskIntoConstraints = NO; + [active addSubview:nextBtn]; + + // Separator + NSBox *sep = [[NSBox alloc] init]; + sep.boxType = NSBoxSeparator; + sep.translatesAutoresizingMaskIntoConstraints = NO; + [active addSubview:sep]; + + // Add / Reject vote buttons + NSButton *addBtn = [NSButton buttonWithTitle:@"👍 Add to Audition" + target:self action:@selector(addToAudition:)]; + addBtn.bezelStyle = NSBezelStyleRounded; + addBtn.translatesAutoresizingMaskIntoConstraints = NO; + [active addSubview:addBtn]; + + NSButton *rejectBtn = [NSButton buttonWithTitle:@"👎 Reject" + target:self action:@selector(skipTransition:)]; + rejectBtn.bezelStyle = NSBezelStyleRounded; + rejectBtn.translatesAutoresizingMaskIntoConstraints = NO; + [active addSubview:rejectBtn]; + + // Cancel link + NSButton *cancelBtn = [NSButton buttonWithTitle:@"Cancel Audition" + target:self action:@selector(cancelAudition:)]; + cancelBtn.bezelStyle = NSBezelStyleInline; + cancelBtn.keyEquivalent = @"\033"; + cancelBtn.translatesAutoresizingMaskIntoConstraints = NO; + [active addSubview:cancelBtn]; + + NSButton *confirmBtn = [NSButton buttonWithTitle:@"Confirm Transition and Exit" + target:self action:@selector(confirmAndExit:)]; + confirmBtn.bezelStyle = NSBezelStyleRounded; + confirmBtn.translatesAutoresizingMaskIntoConstraints = NO; + [active addSubview:confirmBtn]; + + NSButton *confirmFavBtn = [NSButton buttonWithTitle:@"Add to Favorites and Exit" + target:self action:@selector(confirmAndAddFavoritesAndExit:)]; + confirmFavBtn.bezelStyle = NSBezelStyleRounded; + confirmFavBtn.translatesAutoresizingMaskIntoConstraints = NO; + [active addSubview:confirmFavBtn]; + + CGFloat apad = 14, agap = 8; + [NSLayoutConstraint activateConstraints:@[ + [active.widthAnchor constraintEqualToConstant:320], + [active.heightAnchor constraintEqualToConstant:360], + + [roundLbl.topAnchor constraintEqualToAnchor:active.topAnchor constant:apad], + [roundLbl.leadingAnchor constraintEqualToAnchor:active.leadingAnchor constant:apad], + + [nameLbl.topAnchor constraintEqualToAnchor:roundLbl.bottomAnchor constant:2], + [nameLbl.leadingAnchor constraintEqualToAnchor:active.leadingAnchor constant:apad], + [nameLbl.trailingAnchor constraintEqualToAnchor:active.trailingAnchor constant:-apad], + + [progLbl.topAnchor constraintEqualToAnchor:nameLbl.bottomAnchor constant:2], + [progLbl.leadingAnchor constraintEqualToAnchor:active.leadingAnchor constant:apad], + + [preview.topAnchor constraintEqualToAnchor:progLbl.bottomAnchor constant:agap + 4], + [preview.leadingAnchor constraintEqualToAnchor:active.leadingAnchor constant:apad], + [preview.trailingAnchor constraintEqualToAnchor:active.trailingAnchor constant:-apad], + + [prevBtn.topAnchor constraintEqualToAnchor:preview.bottomAnchor constant:agap], + [prevBtn.leadingAnchor constraintEqualToAnchor:active.leadingAnchor constant:apad], + [prevBtn.widthAnchor constraintEqualToConstant:120], + + [nextBtn.topAnchor constraintEqualToAnchor:preview.bottomAnchor constant:agap], + [nextBtn.trailingAnchor constraintEqualToAnchor:active.trailingAnchor constant:-apad], + [nextBtn.widthAnchor constraintEqualToConstant:120], + + [sep.topAnchor constraintEqualToAnchor:prevBtn.bottomAnchor constant:agap], + [sep.leadingAnchor constraintEqualToAnchor:active.leadingAnchor constant:apad], + [sep.trailingAnchor constraintEqualToAnchor:active.trailingAnchor constant:-apad], + [sep.heightAnchor constraintEqualToConstant:1], + + [addBtn.topAnchor constraintEqualToAnchor:sep.bottomAnchor constant:agap], + [addBtn.leadingAnchor constraintEqualToAnchor:active.leadingAnchor constant:apad], + [addBtn.widthAnchor constraintEqualToConstant:160], + + [rejectBtn.topAnchor constraintEqualToAnchor:sep.bottomAnchor constant:agap], + [rejectBtn.trailingAnchor constraintEqualToAnchor:active.trailingAnchor constant:-apad], + [rejectBtn.widthAnchor constraintEqualToConstant:100], + + [confirmBtn.topAnchor constraintEqualToAnchor:addBtn.bottomAnchor constant:20], + [confirmBtn.leadingAnchor constraintEqualToAnchor:active.leadingAnchor constant:apad], + [confirmBtn.trailingAnchor constraintEqualToAnchor:active.trailingAnchor constant:-apad], + + [confirmFavBtn.topAnchor constraintEqualToAnchor:confirmBtn.bottomAnchor constant:agap], + [confirmFavBtn.leadingAnchor constraintEqualToAnchor:active.leadingAnchor constant:apad], + [confirmFavBtn.trailingAnchor constraintEqualToAnchor:active.trailingAnchor constant:-apad], + + [cancelBtn.bottomAnchor constraintEqualToAnchor:active.bottomAnchor constant:-apad], + [cancelBtn.leadingAnchor constraintEqualToAnchor:active.leadingAnchor constant:apad], + ]]; + + self.roundLabel = roundLbl; + self.nameLabel = nameLbl; + self.progressLabel = progLbl; + self.previewButton = preview; + self.prevButton = prevBtn; + self.nextButton = nextBtn; + self.addButton = addBtn; + self.skipButton = rejectBtn; + self.cancelButton = cancelBtn; + self.confirmButton = confirmBtn; + self.confirmFavButton = confirmFavBtn; + self.activeView = active; + + // ============================ POPOVER ================================== + NSViewController *vc = [[NSViewController alloc] init]; + vc.view = idle; + + NSPopover *pop = [[NSPopover alloc] init]; + pop.contentViewController = vc; + pop.behavior = NSPopoverBehaviorTransient; + pop.delegate = self; + self.popover = pop; +} + +// --------------------------------------------------------------------------- +#pragma mark Show / hide +// --------------------------------------------------------------------------- + +- (void)toggleFromButton:(NSButton *)button { + self.toolbarButton = button; + [self buildPopoverIfNeeded]; + + if (self.popover.shown) { + [self.popover close]; + return; + } + + if (self.audition.phase == TAuPhaseActive) { + [self showActiveView]; + } else { + [self showIdleView]; + [self ensureTransitionsLoaded]; + } + + [self.popover showRelativeToRect:button.bounds + ofView:button + preferredEdge:NSRectEdgeMaxY]; +} + +- (void)showIdleView { + self.popover.contentViewController.view = self.idleView; + self.popover.contentSize = self.idleView.frame.size; + [self updateIdleView]; +} + +- (void)showActiveView { + self.addButton.hidden = NO; + self.skipButton.hidden = NO; + self.confirmButton.enabled = YES; + self.confirmFavButton.enabled = YES; + self.popover.contentViewController.view = self.activeView; + self.popover.contentSize = self.activeView.frame.size; + [self updateActiveView]; +} + +- (void)updateIdleView { + BOOL isCustom = (self.modeControl.selectedSegment == 1); + self.searchField.hidden = !isCustom; + self.tableScroll.hidden = !isCustom; + self.countLabel.hidden = isCustom; + + if (!isCustom) { + NSUInteger n = self.allTransitions.count; + self.countLabel.stringValue = n > 0 + ? [NSString stringWithFormat:@"%lu transitions available", (unsigned long)n] + : @"Loading…"; + } else { + [self.tableView reloadData]; + } + + BOOL isCustomOK = isCustom && sTAuCustomIDs.count > 0; + BOOL isAllOK = !isCustom && self.allTransitions.count > 0; + self.startButton.enabled = !self.loading && (isCustomOK || isAllOK); +} + +- (void)updateActiveView { + TAuState *a = self.audition; + if (!a.currentTransition) return; + + // positionInRound is 1-indexed: which transition we are currently reviewing this round. + // originalCount is the count at the start of this round. + self.roundLabel.stringValue = [NSString stringWithFormat:@"Round %ld · %lu in this round", + (long)a.roundNumber, (unsigned long)a.originalCount]; + + self.nameLabel.stringValue = a.currentTransition[@"name"] ?: @""; + + self.progressLabel.stringValue = [NSString stringWithFormat:@"%ld of %lu · %lu kept so far", + (long)a.positionInRound, + (unsigned long)a.originalCount, + (unsigned long)a.keptSet.count]; + + self.addButton.enabled = YES; + self.skipButton.enabled = YES; + self.previewButton.enabled = YES; +} + +// --------------------------------------------------------------------------- +#pragma mark Loading +// --------------------------------------------------------------------------- + +- (void)ensureTransitionsLoaded { + if (self.allTransitions.count > 0) { [self updateIdleView]; return; } + self.loading = YES; + self.startButton.enabled = NO; + self.spinner.hidden = NO; + self.countLabel.hidden = NO; + self.countLabel.stringValue = @"Loading transitions…"; + [self.spinner startAnimation:nil]; + + dispatch_async(dispatch_get_global_queue(QOS_CLASS_USER_INITIATED, 0), ^{ + NSArray *all = TAu_loadAllTransitions(); + dispatch_async(dispatch_get_main_queue(), ^{ + self.allTransitions = all; + self.filteredTransitions = all; + self.loading = NO; + [self.spinner stopAnimation:nil]; + self.spinner.hidden = YES; + [self updateIdleView]; + }); + }); +} + +// --------------------------------------------------------------------------- +#pragma mark Mode / search +// --------------------------------------------------------------------------- + +- (void)modeChanged:(NSSegmentedControl *)sender { + (void)sender; + BOOL isCustom = (self.modeControl.selectedSegment == 1); + // 420 = full height with table; 210 = compact (title+preroll row+mode+count+start) + self.popover.contentSize = isCustom ? NSMakeSize(320, 420) : NSMakeSize(320, 210); + [self updateIdleView]; +} + +- (void)prerollChanged:(NSPopUpButton *)sender { + NSString *title = sender.selectedItem.title; // e.g. "3s" + sTAuPrerollSeconds = MAX(2.0, MIN(5.0, [[title stringByReplacingOccurrencesOfString:@"s" withString:@""] doubleValue])); + TAu_saveSettings(); +} + +- (void)postrollChanged:(NSPopUpButton *)sender { + NSString *title = sender.selectedItem.title; + sTAuPostrollSeconds = MAX(2.0, MIN(5.0, [[title stringByReplacingOccurrencesOfString:@"s" withString:@""] doubleValue])); + TAu_saveSettings(); +} + +- (void)controlTextDidChange:(NSNotification *)note { + (void)note; + NSString *query = [self.searchField.stringValue lowercaseString]; + self.filteredTransitions = query.length == 0 ? self.allTransitions : + [self.allTransitions filteredArrayUsingPredicate: + [NSPredicate predicateWithBlock:^BOOL(NSDictionary *t, id _) { + return [[t[@"name"] lowercaseString] containsString:query]; + }]]; + [self.tableView reloadData]; +} + +// --------------------------------------------------------------------------- +#pragma mark NSTableView (custom set) +// --------------------------------------------------------------------------- + +- (NSInteger)numberOfRowsInTableView:(NSTableView *)tv { (void)tv; return (NSInteger)self.filteredTransitions.count; } + +- (id)tableView:(NSTableView *)tv objectValueForTableColumn:(NSTableColumn *)col row:(NSInteger)row { + (void)tv; + if (row < 0 || row >= (NSInteger)self.filteredTransitions.count) return nil; + NSDictionary *t = self.filteredTransitions[row]; + if ([col.identifier isEqualToString:@"check"]) + return @([sTAuCustomIDs containsObject:t[@"effectID"]]); + return t[@"name"]; +} + +- (void)tableView:(NSTableView *)tv setObjectValue:(id)val + forTableColumn:(NSTableColumn *)col row:(NSInteger)row { + (void)tv; + if (![col.identifier isEqualToString:@"check"]) return; + if (row < 0 || row >= (NSInteger)self.filteredTransitions.count) return; + NSString *eid = self.filteredTransitions[row][@"effectID"]; + if ([val boolValue]) [sTAuCustomIDs addObject:eid]; else [sTAuCustomIDs removeObject:eid]; + TAu_saveCustomSet(); + self.startButton.enabled = sTAuCustomIDs.count > 0; +} + +// --------------------------------------------------------------------------- +#pragma mark Start +// --------------------------------------------------------------------------- + +- (void)startAudition:(id)sender { + (void)sender; + BOOL isCustom = (self.modeControl.selectedSegment == 1); + NSArray *set; + if (!isCustom) { + set = self.allTransitions; + } else { + NSMutableArray *custom = [NSMutableArray array]; + for (NSDictionary *t in self.allTransitions) + if ([sTAuCustomIDs containsObject:t[@"effectID"]]) [custom addObject:t]; + set = custom; + } + if (set.count == 0) return; + + self.startButton.enabled = NO; + self.startButton.title = @"Finding edit point…"; + + NSArray *captured = set; + dispatch_async(dispatch_get_global_queue(QOS_CLASS_USER_INITIATED, 0), ^{ + double editTime = TAu_findNearestEditPoint(); + dispatch_async(dispatch_get_main_queue(), ^{ + self.startButton.title = @"Start Audition"; + if (editTime < 0) { + NSAlert *a = [[NSAlert alloc] init]; + a.messageText = @"No transition found"; + a.informativeText = @"Scrub the playhead to (or near) the transition you want to audition, then click Start Audition. The final transition in a sequence is excluded — there must be at least 2 seconds of footage after it."; + [a addButtonWithTitle:@"OK"]; + [a runModal]; + self.startButton.enabled = YES; + return; + } + TAuState *au = self.audition; + au.editPointTime = editTime; + au.originalCount = captured.count; + au.roundNumber = 1; + au.positionInRound = 1; + au.currentIndex = 0; + au.pendingQueue = [captured mutableCopy]; + au.keptSet = [NSMutableArray array]; + au.currentTransition = au.pendingQueue.firstObject; + au.phase = TAuPhaseActive; + + // Delete any pre-existing transition at the edit point so addTransition: + // can succeed (addTransition: only inserts — it won't replace). + dispatch_async(dispatch_get_global_queue(QOS_CLASS_USER_INITIATED, 0), ^{ + au.hadOriginalTransition = TAu_deleteExistingTransitionAt(editTime); + dispatch_async(dispatch_get_main_queue(), ^{ + [self applyNextTransitionCompletion:^{ [self showActiveView]; }]; + }); + }); + }); + }); +} + +// --------------------------------------------------------------------------- +#pragma mark Tournament bracket +// --------------------------------------------------------------------------- + +// Dequeues pendingQueue[0], applies it, starts the preview loop, calls completion on main thread. +- (void)applyNextTransitionCompletion:(dispatch_block_t)completion { + TAuState *au = self.audition; + NSDictionary *next = au.pendingQueue.firstObject; + if (!next) { if (completion) completion(); return; } + au.currentTransition = next; + double editTime = au.editPointTime; + double preroll = MAX(0.0, editTime - sTAuPrerollSeconds); + + dispatch_async(dispatch_get_global_queue(QOS_CLASS_USER_INITIATED, 0), ^{ + // Seek slightly before the edit point then snap with nextEdit so the + // playhead lands on the exact CMTime frame boundary. Seeking directly + // to the floating-point editTime loses sub-millisecond precision which + // causes addTransition: to silently fail to find the edit point. + TAu_seekToTime(MAX(0.0, editTime - MIN(sTAuPrerollSeconds, 0.5))); + sTAuAPI->callMethod(@{@"method": @"timeline.action", + @"params": @{@"action": @"nextEdit"}}); + TAu_applyTransition(next); + // Immediately jump to preroll and start playing + TAu_seekToTime(preroll); + TAu_ensurePlaying(); + dispatch_async(dispatch_get_main_queue(), ^{ + [self updateActiveView]; + // Kick off the repeating loop timer + [self stopPreviewLoop]; + self.loopActive = YES; + self.loopTimer = [NSTimer scheduledTimerWithTimeInterval:TAu_loopInterval() + target:self + selector:@selector(_loopTimerFired:) + userInfo:nil + repeats:YES]; + if (completion) completion(); + }); + }); +} + +// Called after user rates the current transition (add or skip). +// Removes current from pendingQueue, then either advances or ends the round. +// Called after a vote (add/reject) has removed the item from pendingQueue +// and undo has been executed. +- (void)advanceAfterUndo { + TAuState *au = self.audition; + au.positionInRound++; + + if (au.pendingQueue.count > 0) { + // Apply the item now at currentIndex (clamped by caller) + au.currentTransition = au.pendingQueue[au.currentIndex]; + [self applyNextTransitionCompletion:^{ [self enableRatingButtons]; }]; + } else { + [self endRound]; + } +} + +- (void)endRound { + TAuState *au = self.audition; + NSUInteger kept = au.keptSet.count; + + if (kept == 0) { + // Nobody was liked — shouldn't happen in practice, but handle gracefully + sTAuAPI->log(@"[TAu] All transitions skipped — ending audition with no result."); + au.phase = TAuPhaseIdle; + [self.popover close]; + [self updateToolbarButtonTint]; + return; + } + + if (kept == 1) { + // We have a winner — apply it and show the final selection UI. + NSDictionary *winner = au.keptSet.firstObject; + au.currentTransition = winner; + [self disableRatingButtons]; + dispatch_async(dispatch_get_global_queue(QOS_CLASS_USER_INITIATED, 0), ^{ + TAu_ensurePaused(); + TAu_seekToTime(MAX(0.0, au.editPointTime - MIN(sTAuPrerollSeconds, 0.5))); + sTAuAPI->callMethod(@{@"method": @"timeline.action", + @"params": @{@"action": @"nextEdit"}}); + TAu_applyTransition(winner); + TAu_seekToTime(MAX(0.0, au.editPointTime - sTAuPrerollSeconds)); + TAu_ensurePlaying(); + dispatch_async(dispatch_get_main_queue(), ^{ + sTAuAPI->log(@"[TAu] Winner: %@", winner[@"name"]); + self.roundLabel.stringValue = @"Winner"; + self.nameLabel.stringValue = winner[@"name"] ?: @""; + self.progressLabel.stringValue = @""; + self.addButton.hidden = YES; + self.skipButton.hidden = YES; + self.confirmButton.enabled = YES; + self.confirmFavButton.enabled = YES; + // Restart preview loop so user can watch the winning transition loop + [self stopPreviewLoop]; + self.loopActive = YES; + self.loopTimer = [NSTimer scheduledTimerWithTimeInterval:TAu_loopInterval() + target:self + selector:@selector(_loopTimerFired:) + userInfo:nil + repeats:YES]; + }); + }); + return; + } + + // Start a new round with the survivors + au.roundNumber++; + au.pendingQueue = [au.keptSet mutableCopy]; + au.keptSet = [NSMutableArray array]; + au.originalCount = au.pendingQueue.count; + au.positionInRound = 1; + au.currentIndex = 0; + au.currentTransition = au.pendingQueue.firstObject; + + [self disableRatingButtons]; + self.roundLabel.stringValue = [NSString stringWithFormat:@"Round %ld · %lu surviving", + (long)au.roundNumber, (unsigned long)au.pendingQueue.count]; + [self applyNextTransitionCompletion:^{ [self enableRatingButtons]; }]; +} + +// --------------------------------------------------------------------------- +#pragma mark Rating actions +// --------------------------------------------------------------------------- + +// Browse without voting: cycle prev/next through the pending queue. +- (void)browseTransition:(NSButton *)sender { + TAuState *au = self.audition; + if (au.phase != TAuPhaseActive || !au.currentTransition) return; + NSInteger delta = sender.tag; // -1 = prev, +1 = next + NSInteger count = (NSInteger)au.pendingQueue.count; + if (count <= 1) return; // nothing to browse + au.currentIndex = ((au.currentIndex + delta) % count + count) % count; + au.currentTransition = au.pendingQueue[au.currentIndex]; + [self stopPreviewLoop]; + [self disableRatingButtons]; + dispatch_async(dispatch_get_global_queue(QOS_CLASS_USER_INITIATED, 0), ^{ + TAu_ensurePaused(); + [NSThread sleepForTimeInterval:0.15]; + TAu_undo(); // remove current transition + [NSThread sleepForTimeInterval:0.2]; + // Snap to exact edit point and apply the browsed transition + TAu_seekToTime(MAX(0.0, au.editPointTime - MIN(sTAuPrerollSeconds, 0.5))); + sTAuAPI->callMethod(@{@"method": @"timeline.action", + @"params": @{@"action": @"nextEdit"}}); + TAu_applyTransition(au.currentTransition); + TAu_seekToTime(MAX(0.0, au.editPointTime - sTAuPrerollSeconds)); + TAu_ensurePlaying(); + dispatch_async(dispatch_get_main_queue(), ^{ + [self updateActiveView]; + [self stopPreviewLoop]; + self.loopActive = YES; + self.loopTimer = [NSTimer scheduledTimerWithTimeInterval:TAu_loopInterval() + target:self + selector:@selector(_loopTimerFired:) + userInfo:nil + repeats:YES]; + [self enableRatingButtons]; + }); + }); +} + +- (void)addToAudition:(id)sender { + (void)sender; + TAuState *au = self.audition; + if (au.phase != TAuPhaseActive || !au.currentTransition) return; + [au.keptSet addObject:au.currentTransition]; + sTAuAPI->log(@"[TAu] Add '%@' kept=%lu", au.currentTransition[@"name"], (unsigned long)au.keptSet.count); + [au.pendingQueue removeObjectAtIndex:au.currentIndex]; + // Keep index in bounds after removal + if (au.pendingQueue.count > 0) + au.currentIndex = au.currentIndex % (NSInteger)au.pendingQueue.count; + [self stopPreviewLoop]; + [self disableRatingButtons]; + [self _doUndoThenVoteAdvance]; +} + +- (void)skipTransition:(id)sender { + (void)sender; + TAuState *au = self.audition; + if (au.phase != TAuPhaseActive || !au.currentTransition) return; + sTAuAPI->log(@"[TAu] Reject '%@'", au.currentTransition[@"name"]); + [au.pendingQueue removeObjectAtIndex:au.currentIndex]; + if (au.pendingQueue.count > 0) + au.currentIndex = au.currentIndex % (NSInteger)au.pendingQueue.count; + [self stopPreviewLoop]; + [self disableRatingButtons]; + [self _doUndoThenVoteAdvance]; +} + +- (void)_doUndoThenVoteAdvance { + dispatch_async(dispatch_get_global_queue(QOS_CLASS_USER_INITIATED, 0), ^{ + TAu_ensurePaused(); + [NSThread sleepForTimeInterval:0.2]; + TAu_undo(); + [NSThread sleepForTimeInterval:0.2]; + dispatch_async(dispatch_get_main_queue(), ^{ + [self advanceAfterUndo]; + }); + }); +} + +- (void)confirmAndExit:(id)sender { + (void)sender; + TAuState *au = self.audition; + if (au.phase != TAuPhaseActive) return; + [self stopPreviewLoop]; + [self disableRatingButtons]; + self.confirmButton.enabled = NO; + self.confirmFavButton.enabled = NO; + dispatch_async(dispatch_get_global_queue(QOS_CLASS_USER_INITIATED, 0), ^{ + TAu_ensurePaused(); + dispatch_async(dispatch_get_main_queue(), ^{ + au.phase = TAuPhaseIdle; + [self.popover close]; + [self updateToolbarButtonTint]; + }); + }); +} + +- (void)confirmAndAddFavoritesAndExit:(id)sender { + (void)sender; + TAuState *au = self.audition; + if (au.phase != TAuPhaseActive || !au.currentTransition) return; + NSString *effectID = au.currentTransition[@"effectID"]; + if (effectID) { + Class ffEffect = NSClassFromString(@"FFEffect"); + if (ffEffect) { + @try { + SEL getSel = NSSelectorFromString(@"favoriteEffectIDs:video:filterUnregisteredEffects:"); + NSArray *current = ((NSArray*(*)(id,SEL,BOOL,BOOL,BOOL))objc_msgSend)( + (id)ffEffect, getSel, NO, YES, NO); + if (![current containsObject:effectID]) { + NSMutableArray *updated = current ? [current mutableCopy] : [NSMutableArray array]; + [updated addObject:effectID]; + SEL setSel = NSSelectorFromString(@"setFavoriteEffectIDs:video:"); + ((void(*)(id,SEL,id,BOOL))objc_msgSend)((id)ffEffect, setSel, updated, YES); + sTAuAPI->log(@"[TAu] Added '%@' (%@) to FCP Favorites", + au.currentTransition[@"name"], effectID); + } + } @catch (NSException *e) { + sTAuAPI->log(@"[TAu] addToFavorites exception: %@", e.reason); + } + } + [sTAuCustomIDs addObject:effectID]; + TAu_saveCustomSet(); + } + [self confirmAndExit:nil]; +} + +- (void)cancelAudition:(id)sender { + (void)sender; + TAuState *au = self.audition; + [self stopPreviewLoop]; + if (au.phase != TAuPhaseActive) { [self.popover close]; return; } + BOOL hadOriginal = au.hadOriginalTransition; + self.cancelingAudition = YES; + dispatch_async(dispatch_get_global_queue(QOS_CLASS_USER_INITIATED, 0), ^{ + TAu_ensurePaused(); + [NSThread sleepForTimeInterval:0.15]; + TAu_undo(); // undo the current test transition + if (hadOriginal) { + [NSThread sleepForTimeInterval:0.1]; + TAu_undo(); // undo the original deletion → restores original transition + } + dispatch_async(dispatch_get_main_queue(), ^{ + au.phase = TAuPhaseIdle; + self.cancelingAudition = NO; + [self.popover close]; + [self updateToolbarButtonTint]; + }); + }); +} + +// --------------------------------------------------------------------------- +#pragma mark Preview loop +// --------------------------------------------------------------------------- + +// Loop interval is now computed dynamically from sTAuPrerollSeconds + sTAuPostrollSeconds. + +- (void)previewTransition:(id)sender { + (void)sender; + if (self.audition.phase != TAuPhaseActive) return; + [self startPreviewLoop]; +} + +- (void)startPreviewLoop { + [self stopPreviewLoop]; // sets loopActive=NO, then we set YES below + self.loopActive = YES; + double preroll = MAX(0.0, self.audition.editPointTime - sTAuPrerollSeconds); + + dispatch_async(dispatch_get_global_queue(QOS_CLASS_USER_INITIATED, 0), ^{ + TAu_seekToTime(preroll); + TAu_ensurePlaying(); + dispatch_async(dispatch_get_main_queue(), ^{ + // Repeating timer: on each fire, seek back to preroll while FCP keeps playing. + self.loopTimer = [NSTimer scheduledTimerWithTimeInterval:TAu_loopInterval() + target:self + selector:@selector(_loopTimerFired:) + userInfo:nil + repeats:YES]; + }); + }); +} + +- (void)_loopTimerFired:(NSTimer *)timer { + (void)timer; + if (!self.loopActive || self.audition.phase != TAuPhaseActive) { return; } + double preroll = MAX(0.0, self.audition.editPointTime - sTAuPrerollSeconds); + dispatch_async(dispatch_get_global_queue(QOS_CLASS_USER_INITIATED, 0), ^{ + if (!self.loopActive) return; // bail if stopped while we were queued + TAu_seekToTime(preroll); + if (!self.loopActive) return; // bail if stopped while seeking + TAu_ensurePlaying(); + }); +} + +- (void)stopPreviewLoop { + // Set loopActive=NO FIRST so any in-flight background blocks exit immediately + // before trying to sync-dispatch back to main (which could interfere with an + // ongoing transitions.apply / waitForTransitionInsertion spin). + self.loopActive = NO; + [self.loopTimer invalidate]; + self.loopTimer = nil; +} + +// --------------------------------------------------------------------------- +#pragma mark Helpers +// --------------------------------------------------------------------------- + +- (void)disableRatingButtons { + self.addButton.enabled = NO; + self.skipButton.enabled = NO; + self.previewButton.enabled = NO; + self.prevButton.enabled = NO; + self.nextButton.enabled = NO; + self.confirmButton.enabled = NO; + self.confirmFavButton.enabled = NO; +} + +- (void)enableRatingButtons { + NSInteger queueCount = (NSInteger)self.audition.pendingQueue.count; + self.addButton.enabled = YES; + self.skipButton.enabled = YES; + self.previewButton.enabled = YES; + self.prevButton.enabled = (queueCount > 1); + self.nextButton.enabled = (queueCount > 1); + self.confirmButton.enabled = YES; + self.confirmFavButton.enabled = YES; + [self updateActiveView]; +} + +- (void)updateToolbarButtonTint { + if (!self.toolbarButton) return; + self.toolbarButton.contentTintColor = + (self.audition.phase == TAuPhaseActive) + ? [NSColor controlAccentColor] + : [NSColor controlTextColor]; +} + +// NSPopoverDelegate — clicking outside the popover keeps whatever transition is applied +- (void)popoverWillClose:(NSNotification *)note { + (void)note; + if (self.cancelingAudition) return; // cancel handles its own cleanup + TAuState *au = self.audition; + if (au.phase == TAuPhaseActive) { + // User dismissed by clicking away — stop the preview loop but keep the transition + [self stopPreviewLoop]; + au.phase = TAuPhaseIdle; + [self updateToolbarButtonTint]; + } +} + +@end + +// --------------------------------------------------------------------------- +#pragma mark - Button target +// --------------------------------------------------------------------------- + +@interface TAuButtonTarget : NSObject ++ (instancetype)shared; +- (void)buttonClicked:(NSButton *)sender; +@end + +@implementation TAuButtonTarget ++ (instancetype)shared { + static TAuButtonTarget *s = nil; + static dispatch_once_t once; + dispatch_once(&once, ^{ s = [[self alloc] init]; }); + return s; +} +- (void)buttonClicked:(NSButton *)sender { + [[TAuPopoverController shared] toggleFromButton:sender]; +} +@end + +// --------------------------------------------------------------------------- +#pragma mark - Timeline bar injection +// --------------------------------------------------------------------------- + +static NSImage *TAu_makeIcon(void) { + NSImage *img = [NSImage imageWithSystemSymbolName:@"film.stack" + accessibilityDescription:@"Transition Auditions"]; + return img ? [img imageWithSymbolConfiguration: + [NSImageSymbolConfiguration configurationWithPointSize:19 weight:NSFontWeightMedium]] : nil; +} + +static NSView *TAu_findTimelineBarView(void) { + @try { + id delegate = [[NSApplication sharedApplication] delegate]; + if (!delegate) return nil; + SEL ecSel = NSSelectorFromString(@"activeEditorContainer"); + if (![delegate respondsToSelector:ecSel]) return nil; + id ec = ((id(*)(id,SEL))objc_msgSend)(delegate, ecSel); + if (!ec) return nil; + SEL tmSel = NSSelectorFromString(@"timelineModule"); + if (![ec respondsToSelector:tmSel]) return nil; + id tm = ((id(*)(id,SEL))objc_msgSend)(ec, tmSel); + if (!tm) return nil; + NSView *v = [tm performSelector:@selector(view)]; + if (!v) return nil; + for (int i = 0; i < 4; i++) { v = v.superview; if (!v) return nil; } + Class capClass = NSClassFromString(@"LKContainerItemCapView"); + for (NSView *sv in v.subviews) { + if (capClass ? [sv isKindOfClass:capClass] + : [NSStringFromClass([sv class]) containsString:@"CapView"]) + return sv.subviews.firstObject; + } + return nil; + } @catch (__unused NSException *) { return nil; } +} + +static void TAu_addButtonToTimelineBar(void) { + @try { + NSView *bar = TAu_findTimelineBarView(); + if (!bar) return; + for (NSView *sv in bar.subviews) + if ([sv.identifier isEqualToString:kTAuButtonID]) return; + + NSView *anchor = nil; + CGFloat gap = 2.0; + for (NSView *sv in bar.subviews) + if ([sv.identifier isEqualToString:kSFPBackwardID]) { anchor = sv; break; } + if (!anchor) { + Class pc = NSClassFromString(@"LKPaneCapSegmentedControl"); + for (NSView *sv in bar.subviews) + if (pc && [sv isKindOfClass:pc]) { anchor = sv; gap = 6.0; break; } + } + if (!anchor) { sTAuAPI->log(@"[TAu] Anchor not found."); return; } + + NSButton *btn = [[NSButton alloc] init]; + [btn setButtonType:NSButtonTypeMomentaryPushIn]; + btn.bezelStyle = NSBezelStyleTexturedRounded; + btn.bordered = YES; + btn.image = TAu_makeIcon(); + btn.imagePosition = NSImageOnly; + btn.target = [TAuButtonTarget shared]; + btn.action = @selector(buttonClicked:); + btn.identifier = kTAuButtonID; + btn.toolTip = @"Transition Auditions — cycle through transitions at the nearest edit point"; + btn.translatesAutoresizingMaskIntoConstraints = NO; + + [bar addSubview:btn]; + [NSLayoutConstraint activateConstraints:@[ + [btn.trailingAnchor constraintEqualToAnchor:anchor.leadingAnchor constant:-gap], + [btn.centerYAnchor constraintEqualToAnchor:anchor.centerYAnchor], + [btn.widthAnchor constraintEqualToConstant:40.0], + [btn.heightAnchor constraintEqualToAnchor:anchor.heightAnchor], + ]]; + sTAuAPI->log(@"[TAu] Button installed."); + } @catch (NSException *e) { + sTAuAPI->log(@"[TAu] Exception: %@", e.reason); + } +} + +static void TAu_tryInstall(int attempt); +static void TAu_tryInstall(int attempt) { + if (attempt >= 40) return; + dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(1.5 * NSEC_PER_SEC)), + dispatch_get_main_queue(), ^{ + NSView *bar = TAu_findTimelineBarView(); + if (bar && bar.subviews.count > 0) TAu_addButtonToTimelineBar(); + else TAu_tryInstall(attempt + 1); + }); +} + +static void TAu_startInstallation(void) { + [[NSNotificationCenter defaultCenter] + addObserverForName:NSWindowDidBecomeMainNotification object:nil + queue:[NSOperationQueue mainQueue] + usingBlock:^(NSNotification * __unused n) { + NSView *bar = TAu_findTimelineBarView(); + if (bar && bar.subviews.count > 0) TAu_addButtonToTimelineBar(); + }]; + TAu_tryInstall(0); +} + +// --------------------------------------------------------------------------- +#pragma mark - Plugin entry point +// --------------------------------------------------------------------------- + +__attribute__((visibility("default"))) +void SpliceKitPlugin_init(SpliceKitPluginAPI *api) { + sTAuAPIStorage = *api; + sTAuAPI = &sTAuAPIStorage; + + if (api->dataPath) + sTAuDataPath = [NSString stringWithUTF8String:api->dataPath]; + + sTAuAPI->log(@"[TAu] Loading."); + TAu_loadSettings(); + TAu_loadCustomSet(); + + api->executeOnMainThreadAsync(^{ + TAu_startInstallation(); + + // Global Escape key monitor: cancel audition regardless of popover state. + // Key code 53 = Escape. Return nil to consume the event (suppress FCP's own handler). + [NSEvent addLocalMonitorForEventsMatchingMask:NSEventMaskKeyDown + handler:^NSEvent *(NSEvent *event) { + if (event.keyCode == 53) { + TAuPopoverController *ctrl = [TAuPopoverController shared]; + if (ctrl.audition.phase == TAuPhaseActive) { + [ctrl cancelAudition:nil]; + return nil; // consume — don't let FCP also handle Escape + } + } + return event; + }]; + }); + api->log(@"[TAu] Loaded."); +} diff --git a/examples/plugins/com.splicekit.undo-history-ui/Makefile b/examples/plugins/com.splicekit.undo-history-ui/Makefile new file mode 100644 index 0000000..c7d4877 --- /dev/null +++ b/examples/plugins/com.splicekit.undo-history-ui/Makefile @@ -0,0 +1,42 @@ +# Makefile for com.splicekit.undo-history-ui +# +# Usage: +# make — compile plugin.dylib into build/ +# make install — compile + copy into ~/Library/Application Support/SpliceKit/plugins/ +# make clean — remove build artefacts + +PLUGIN_ID = com.splicekit.undo-history-ui +PLUGIN_ROOT = $(shell pwd) +REPO_ROOT = $(PLUGIN_ROOT)/../../.. +SRC = src/UndoHistoryUI.m +DYLIB = build/plugin.dylib +INSTALL_DIR = $(HOME)/Library/Application Support/SpliceKit/plugins/$(PLUGIN_ID) + +CC = clang +ARCHS = -arch arm64 -arch x86_64 +MIN_VER = -mmacosx-version-min=14.0 +CFLAGS = -O2 -fvisibility=hidden -fobjc-arc \ + -I$(REPO_ROOT)/Sources \ + $(ARCHS) $(MIN_VER) +LDFLAGS = -dynamiclib -undefined dynamic_lookup \ + -framework Foundation -framework AppKit -framework CoreMedia \ + $(ARCHS) $(MIN_VER) + +.PHONY: all install clean + +all: $(DYLIB) + +$(DYLIB): $(SRC) $(REPO_ROOT)/Sources/SpliceKitPluginAPI.h + @mkdir -p build + $(CC) $(CFLAGS) $(LDFLAGS) -o $@ $< + @echo "Built $(DYLIB)" + +install: $(DYLIB) + @mkdir -p "$(INSTALL_DIR)" + cp -f $(DYLIB) "$(INSTALL_DIR)/plugin.dylib" + cp -f plugin.json "$(INSTALL_DIR)/plugin.json" + @echo "Installed $(PLUGIN_ID) → $(INSTALL_DIR)" + @echo "Restart FCP to activate." + +clean: + rm -rf build diff --git a/examples/plugins/com.splicekit.undo-history-ui/plugin.json b/examples/plugins/com.splicekit.undo-history-ui/plugin.json new file mode 100644 index 0000000..a80f5e7 --- /dev/null +++ b/examples/plugins/com.splicekit.undo-history-ui/plugin.json @@ -0,0 +1,13 @@ +{ + "id": "com.splicekit.undo-history-ui", + "name": "Undo History UI", + "version": "1.0.0", + "description": "Adds an Undo History palette that shows every action on FCP's undo stack. Includes a toolbar button and Splices menu item. Survives SpliceKit patcher updates.", + "author": "SpliceKit", + "apiVersion": 1, + "entry": { + "native": "plugin.dylib" + }, + "methods": [], + "dependencies": [] +} diff --git a/examples/plugins/com.splicekit.undo-history-ui/src/UndoHistoryUI.m b/examples/plugins/com.splicekit.undo-history-ui/src/UndoHistoryUI.m new file mode 100644 index 0000000..9297946 --- /dev/null +++ b/examples/plugins/com.splicekit.undo-history-ui/src/UndoHistoryUI.m @@ -0,0 +1,676 @@ +// +// UndoHistoryUI.m +// com.splicekit.undo-history-ui +// +// Self-contained undo history palette for Final Cut Pro. +// Survives SpliceKit patcher updates — the entire feature lives here. +// +// What this plugin does +// ───────────────────── +// 1. Defines SpliceKitUndoEntry + SpliceKitUndoHistoryPanel (if not already +// present in the host binary — e.g. after a patcher update). +// 2. Installs NSUndoManager group-close observers so every user action is +// captured into the palette's list (idempotent, safe to call twice). +// 3. Swizzles PEMainWindowModule's toolbar delegate method to vend a "History" +// toolbar item, then inserts it after the Transcript button. +// 4. Injects an "Undo History" menu item into the Splices menu. +// +// When the host binary already contains SpliceKitUndoHistoryPanel (user build), +// the ObjC runtime keeps that version and the @implementation blocks here are +// silently ignored. The toolbar/menu injection and installHooks call proceed +// normally — installHooks is idempotent so double-calling is harmless. +// + +#import +#import +#import +#import + +#import "SpliceKitPluginAPI.h" + +// --------------------------------------------------------------------------- +// Plugin API storage +// --------------------------------------------------------------------------- +static SpliceKitPluginAPI sAPIStorage; +static SpliceKitPluginAPI *sAPI = NULL; + +// --------------------------------------------------------------------------- +// Forward declarations for C functions provided by the host binary. +// With -undefined dynamic_lookup these resolve at load time from SpliceKit. +// --------------------------------------------------------------------------- +extern void SpliceKit_executeOnMainThread(dispatch_block_t block); +extern void SpliceKit_log(NSString *fmt, ...) __attribute__((format(__NSString__, 1, 2))); + +// --------------------------------------------------------------------------- +// Constants +// --------------------------------------------------------------------------- +static const NSInteger kUH_MaxEntries = 100; +static NSString * const kUH_ToolbarID = @"SpliceKitUndoHistoryItemID"; + +// --------------------------------------------------------------------------- +// SpliceKitUndoEntry +// (No-op if host binary already defines this class — first loader wins.) +// --------------------------------------------------------------------------- + +@interface SpliceKitUndoEntry : NSObject +@property (nonatomic, copy) NSString *actionName; +@property (nonatomic, strong) NSDate *timestamp; +@property (nonatomic) NSInteger index; +@end + +@implementation SpliceKitUndoEntry +@end + +// --------------------------------------------------------------------------- +// SpliceKitUndoHistoryTableView – forwards row clicks to the panel +// --------------------------------------------------------------------------- + +@interface SpliceKitUndoHistoryTableView : NSTableView +@property (nonatomic, weak) id clickTarget; +@property (nonatomic) SEL clickAction; +@end + +@implementation SpliceKitUndoHistoryTableView +- (void)mouseDown:(NSEvent *)event { + [super mouseDown:event]; + NSInteger row = [self rowAtPoint:[self convertPoint:event.locationInWindow fromView:nil]]; + if (row >= 0 && self.clickTarget) { +#pragma clang diagnostic push +#pragma clang diagnostic ignored "-Warc-performSelector-leaks" + [self.clickTarget performSelector:self.clickAction withObject:@(row)]; +#pragma clang diagnostic pop + } +} +@end + +// --------------------------------------------------------------------------- +// SpliceKitUndoHistoryPanel +// --------------------------------------------------------------------------- + +@interface SpliceKitUndoHistoryPanel : NSObject + +@property (nonatomic, strong) NSMutableArray *entries; +@property (nonatomic) NSInteger cursor; +@property (nonatomic) BOOL suppressRecord; +@property (nonatomic, strong) NSMapTable *nestingDepths; +@property (nonatomic, strong) NSPanel *panel; +@property (nonatomic, strong) SpliceKitUndoHistoryTableView *tableView; +@property (nonatomic, strong) NSScrollView *scrollView; +@property (nonatomic, strong) NSButton *clearButton; + ++ (instancetype)sharedPanel; ++ (void)installHooks; +- (void)showPanel; +- (void)hidePanel; +- (BOOL)isVisible; +- (NSDictionary *)getHistory; +- (NSDictionary *)jumpToIndex:(NSInteger)targetIndex; +- (void)clearHistory; + +@end + +@implementation SpliceKitUndoHistoryPanel + +// ── Singleton ──────────────────────────────────────────────────────────────── + ++ (instancetype)sharedPanel { + static SpliceKitUndoHistoryPanel *sInstance = nil; + static dispatch_once_t sOnce; + dispatch_once(&sOnce, ^{ sInstance = [[self alloc] init]; }); + return sInstance; +} + +// ── Hook installation (idempotent) ─────────────────────────────────────────── + ++ (void)installHooks { + static BOOL sInstalled = NO; + if (sInstalled) return; + sInstalled = YES; + + SpliceKitUndoHistoryPanel *panel = [self sharedPanel]; + NSNotificationCenter *nc = [NSNotificationCenter defaultCenter]; + [nc addObserver:panel selector:@selector(_undoGroupDidOpen:) + name:NSUndoManagerDidOpenUndoGroupNotification object:nil]; + [nc addObserver:panel selector:@selector(_undoGroupDidClose:) + name:NSUndoManagerDidCloseUndoGroupNotification object:nil]; +} + +// ── Init ───────────────────────────────────────────────────────────────────── + +- (instancetype)init { + self = [super init]; + if (!self) return nil; + _entries = [NSMutableArray array]; + _cursor = -1; + _nestingDepths = [NSMapTable weakToStrongObjectsMapTable]; + [self _buildPanel]; + return self; +} + +// ── Panel construction ─────────────────────────────────────────────────────── + +- (void)_buildPanel { + NSRect frame = NSMakeRect(200, 400, 300, 480); + _panel = [[NSPanel alloc] initWithContentRect:frame + styleMask:NSWindowStyleMaskTitled + |NSWindowStyleMaskClosable + |NSWindowStyleMaskMiniaturizable + |NSWindowStyleMaskResizable + backing:NSBackingStoreBuffered + defer:NO]; + _panel.title = @"Undo History"; + _panel.floatingPanel = YES; + _panel.becomesKeyOnlyIfNeeded = YES; + _panel.minSize = NSMakeSize(220, 200); + + NSView *content = _panel.contentView; + + _clearButton = [NSButton buttonWithTitle:@"Clear" target:self + action:@selector(_clearButtonClicked:)]; + _clearButton.translatesAutoresizingMaskIntoConstraints = NO; + _clearButton.bezelStyle = NSBezelStyleRounded; + _clearButton.controlSize = NSControlSizeSmall; + [content addSubview:_clearButton]; + + _tableView = [[SpliceKitUndoHistoryTableView alloc] initWithFrame:NSZeroRect]; + _tableView.clickTarget = self; + _tableView.clickAction = @selector(_rowClicked:); + _tableView.dataSource = self; + _tableView.delegate = self; + _tableView.headerView = nil; + _tableView.rowSizeStyle = NSTableViewRowSizeStyleSmall; + _tableView.selectionHighlightStyle = NSTableViewSelectionHighlightStyleNone; + _tableView.usesAlternatingRowBackgroundColors = NO; + _tableView.gridStyleMask = NSTableViewGridNone; + _tableView.focusRingType = NSFocusRingTypeNone; + + NSTableColumn *col = [[NSTableColumn alloc] initWithIdentifier:@"action"]; + col.resizingMask = NSTableColumnAutoresizingMask; + [_tableView addTableColumn:col]; + + _scrollView = [[NSScrollView alloc] initWithFrame:NSZeroRect]; + _scrollView.documentView = _tableView; + _scrollView.hasVerticalScroller = YES; + _scrollView.autohidesScrollers = YES; + _scrollView.borderType = NSNoBorder; + _scrollView.translatesAutoresizingMaskIntoConstraints = NO; + [content addSubview:_scrollView]; + + NSDictionary *views = @{@"scroll": _scrollView, @"clear": _clearButton}; + NSDictionary *metrics = @{@"m": @8, @"b": @4}; + [NSLayoutConstraint activateConstraints: + [NSLayoutConstraint constraintsWithVisualFormat:@"H:|-(m)-[scroll]-(m)-|" + options:0 metrics:metrics views:views]]; + [NSLayoutConstraint activateConstraints: + [NSLayoutConstraint constraintsWithVisualFormat:@"H:|-(m)-[clear]-(m)-|" + options:0 metrics:metrics views:views]]; + [NSLayoutConstraint activateConstraints: + [NSLayoutConstraint constraintsWithVisualFormat:@"V:|-(m)-[scroll]-(b)-[clear]-(m)-|" + options:0 metrics:metrics views:views]]; + + [_tableView sizeLastColumnToFit]; +} + +// ── Visibility ──────────────────────────────────────────────────────────────── + +- (void)showPanel { + dispatch_async(dispatch_get_main_queue(), ^{ + [self _reloadTable]; + [self->_panel makeKeyAndOrderFront:nil]; + [NSApp activateIgnoringOtherApps:YES]; + }); +} + +- (void)hidePanel { + dispatch_async(dispatch_get_main_queue(), ^{ + [self->_panel orderOut:nil]; + }); +} + +- (BOOL)isVisible { + return _panel.isVisible; +} + +// ── NSUndoManager notification handlers ───────────────────────────────────── + +- (void)_undoGroupDidOpen:(NSNotification *)n { + id um = n.object; + NSInteger depth = [[_nestingDepths objectForKey:um] integerValue]; + [_nestingDepths setObject:@(depth + 1) forKey:um]; +} + +- (void)_undoGroupDidClose:(NSNotification *)n { + id um = n.object; + NSInteger depth = [[_nestingDepths objectForKey:um] integerValue]; + depth = MAX(0, depth - 1); + [_nestingDepths setObject:@(depth) forKey:um]; + if (depth != 0 || _suppressRecord) return; + + NSString *name = [um undoActionName]; + if ([name hasPrefix:@"Edit "]) name = [name substringFromIndex:5]; + if (name.length == 0 || [name isEqualToString:@"Edit"]) return; + + dispatch_async(dispatch_get_main_queue(), ^{ + [self _recordAction:name]; + }); +} + +// ── History management ──────────────────────────────────────────────────────── + +- (void)_recordAction:(NSString *)name { + if ((NSInteger)_entries.count >= kUH_MaxEntries) { + [_entries removeObjectAtIndex:0]; + for (NSInteger i = 0; i < (NSInteger)_entries.count; i++) + _entries[(NSUInteger)i].index = i; + _cursor = MAX(-1, _cursor - 1); + } + + SpliceKitUndoEntry *entry = [[SpliceKitUndoEntry alloc] init]; + entry.actionName = name; + entry.timestamp = [NSDate date]; + entry.index = (NSInteger)_entries.count; + [_entries addObject:entry]; + _cursor = entry.index; + [self _reloadTable]; +} + +- (void)clearHistory { + dispatch_async(dispatch_get_main_queue(), ^{ + [self->_entries removeAllObjects]; + self->_cursor = -1; + [self _reloadTable]; + }); +} + +// ── Table reload ────────────────────────────────────────────────────────────── + +- (void)_reloadTable { + [_tableView reloadData]; + if (_cursor >= 0 && _cursor < (NSInteger)_entries.count) + [_tableView scrollRowToVisible:_cursor]; +} + +// ── NSTableViewDataSource ───────────────────────────────────────────────────── + +- (NSInteger)numberOfRowsInTableView:(NSTableView *)tv { + return (NSInteger)_entries.count; +} + +// ── NSTableViewDelegate ─────────────────────────────────────────────────────── + +- (NSView *)tableView:(NSTableView *)tv + viewForTableColumn:(NSTableColumn *)col + row:(NSInteger)row { + NSTextField *label = [tv makeViewWithIdentifier:@"cell" owner:self]; + if (!label) { + label = [NSTextField labelWithString:@""]; + label.identifier = @"cell"; + label.lineBreakMode = NSLineBreakByTruncatingTail; + } + + SpliceKitUndoEntry *entry = _entries[(NSUInteger)row]; + BOOL isCurrent = (row == _cursor); + + if (isCurrent) { + label.font = [NSFont boldSystemFontOfSize:[NSFont smallSystemFontSize]]; + label.textColor = [NSColor controlAccentColor]; + } else { + label.font = [NSFont systemFontOfSize:[NSFont smallSystemFontSize]]; + label.textColor = [NSColor labelColor]; + } + + label.stringValue = entry.actionName; + return label; +} + +- (CGFloat)tableView:(NSTableView *)tv heightOfRow:(NSInteger)row { + return 20.0; +} + +// ── Row click ───────────────────────────────────────────────────────────────── + +- (void)_rowClicked:(NSNumber *)rowNumber { + NSInteger row = rowNumber.integerValue; + if (row == _cursor) return; + [self jumpToIndex:row]; +} + +- (void)_clearButtonClicked:(id)sender { + [self clearHistory]; +} + +// ── RPC: getHistory ─────────────────────────────────────────────────────────── + +- (NSDictionary *)getHistory { + __block NSDictionary *result = nil; + dispatch_sync(dispatch_get_main_queue(), ^{ + NSMutableArray *arr = [NSMutableArray arrayWithCapacity:self->_entries.count]; + NSDateFormatter *fmt = [[NSDateFormatter alloc] init]; + fmt.dateFormat = @"HH:mm:ss"; + + for (SpliceKitUndoEntry *e in self->_entries) { + [arr addObject:@{ + @"index": @(e.index), + @"name": e.actionName, + @"timestamp": [fmt stringFromDate:e.timestamp], + @"isCurrent": @(e.index == self->_cursor) + }]; + } + + BOOL canUndo = NO, canRedo = NO; + @try { + id doc = [[[NSApplication sharedApplication] delegate] + performSelector:@selector(_targetLibrary)]; + id um = [((id(*)(id,SEL))objc_msgSend)(doc, @selector(libraryDocument)) + undoManager]; + canUndo = [um canUndo]; + canRedo = [um canRedo]; + } @catch (...) {} + + result = @{ + @"entries": [arr copy], + @"cursor": @(self->_cursor), + @"canUndo": @(canUndo), + @"canRedo": @(canRedo), + @"visible": @(self->_panel.isVisible) + }; + }); + return result; +} + +// ── RPC: jumpToIndex ────────────────────────────────────────────────────────── + +- (NSDictionary *)jumpToIndex:(NSInteger)target { + if (target < -1 || target >= (NSInteger)_entries.count) + return @{@"error": @"Index out of range"}; + + __block NSDictionary *result = nil; + SpliceKit_executeOnMainThread(^{ + NSUndoManager *um = nil; + @try { + id delegate = [[NSApplication sharedApplication] delegate]; + id lib = [delegate performSelector:@selector(_targetLibrary)]; + id doc = ((id(*)(id,SEL))objc_msgSend)(lib, @selector(libraryDocument)); + um = [doc undoManager]; + } @catch (...) {} + + if (!um) { + result = @{@"error": @"No undo manager found — is a project open?"}; + return; + } + + NSInteger delta = target - self->_cursor; + if (delta == 0) { + result = @{@"status": @"ok", @"message": @"Already at that state"}; + return; + } + + self->_suppressRecord = YES; + NSInteger performed = 0; + if (delta < 0) { + for (NSInteger i = 0; i < -delta; i++) { + if (![um canUndo]) break; + [um undo]; + performed--; + } + } else { + for (NSInteger i = 0; i < delta; i++) { + if (![um canRedo]) break; + [um redo]; + performed++; + } + } + self->_suppressRecord = NO; + + self->_cursor += performed; + [self _reloadTable]; + + result = @{ + @"status": @"ok", + @"moved": @(performed), + @"cursor": @(self->_cursor), + @"canUndo": @([um canUndo]), + @"canRedo": @([um canRedo]) + }; + }); + return result; +} + +@end + +// --------------------------------------------------------------------------- +// UHPlugin_Controller – action target for toolbar button and menu item +// --------------------------------------------------------------------------- + +@interface UHPlugin_Controller : NSObject ++ (instancetype)shared; +- (void)togglePanel:(id)sender; +@end + +@implementation UHPlugin_Controller + ++ (instancetype)shared { + static UHPlugin_Controller *sInstance = nil; + static dispatch_once_t sOnce; + dispatch_once(&sOnce, ^{ sInstance = [[self alloc] init]; }); + return sInstance; +} + +- (void)togglePanel:(id)sender { + SpliceKitUndoHistoryPanel *panel = [SpliceKitUndoHistoryPanel sharedPanel]; + if (panel.isVisible) + [panel hidePanel]; + else + [panel showPanel]; + + // Sync button state if the sender is a button + if ([sender isKindOfClass:[NSButton class]]) { + NSControlStateValue state = panel.isVisible + ? NSControlStateValueOn : NSControlStateValueOff; + ((NSButton *)sender).state = state; + } +} + +@end + +// --------------------------------------------------------------------------- +// Toolbar swizzle +// --------------------------------------------------------------------------- + +static IMP sOrigToolbarItemForIdentifier = NULL; + +static id UH_toolbarItemForIdentifier(id self, SEL _cmd, + NSToolbar *toolbar, + NSString *identifier, + BOOL willInsert) { + if ([identifier isEqualToString:kUH_ToolbarID]) { + NSToolbarItem *item = [[NSToolbarItem alloc] initWithItemIdentifier:kUH_ToolbarID]; + item.label = @"History"; + item.paletteLabel = @"Undo History"; + item.toolTip = @"Undo History"; + + NSImage *icon = [NSImage imageWithSystemSymbolName:@"clock.arrow.trianglehead.counterclockwise.rotate.90" + accessibilityDescription:@"Undo History"]; + if (!icon) icon = [NSImage imageWithSystemSymbolName:@"clock.arrow.circlepath" + accessibilityDescription:@"Undo History"]; + if (!icon) icon = [NSImage imageNamed:NSImageNameRefreshTemplate]; + + NSImageSymbolConfiguration *cfg = [NSImageSymbolConfiguration + configurationWithPointSize:13 weight:NSFontWeightMedium]; + icon = [icon imageWithSymbolConfiguration:cfg]; + + NSButton *btn = [[NSButton alloc] initWithFrame:NSMakeRect(0, 0, 32, 25)]; + [btn setButtonType:NSButtonTypePushOnPushOff]; + btn.bezelStyle = NSBezelStyleTexturedRounded; + btn.bordered = YES; + btn.image = icon; + btn.alternateImage = icon; + btn.imagePosition = NSImageOnly; + btn.target = [UHPlugin_Controller shared]; + btn.action = @selector(togglePanel:); + item.view = btn; + return item; + } + + return ((id (*)(id, SEL, NSToolbar *, NSString *, BOOL))sOrigToolbarItemForIdentifier)( + self, _cmd, toolbar, identifier, willInsert); +} + +static void UH_installToolbarSwizzle(void) { + if (sOrigToolbarItemForIdentifier) return; + @try { + Class cls = NSClassFromString(@"PEMainWindowModule"); + if (!cls) return; + SEL sel = @selector(toolbar:itemForItemIdentifier:willBeInsertedIntoToolbar:); + Method m = class_getInstanceMethod(cls, sel); + if (!m) return; + sOrigToolbarItemForIdentifier = method_getImplementation(m); + class_replaceMethod(cls, sel, (IMP)UH_toolbarItemForIdentifier, + method_getTypeEncoding(m)); + if (sAPI) sAPI->log(@"[UndoHistoryUI] Toolbar swizzle installed."); + } @catch (NSException *e) { + sOrigToolbarItemForIdentifier = NULL; + if (sAPI) sAPI->log(@"[UndoHistoryUI] Toolbar swizzle failed: %@", e.reason); + } +} + +// --------------------------------------------------------------------------- +// Toolbar button insertion +// --------------------------------------------------------------------------- + +static NSString * const kTranscriptToolbarID = @"SpliceKitTranscriptItemID"; + +static void UH_addButtonToToolbar(NSToolbar *toolbar) { + @try { + if (!toolbar.delegate) return; + UH_installToolbarSwizzle(); + + // Deduplicate: scan for existing item, clean up stale (no view) entries. + BOOL hasButton = NO; + for (NSInteger i = (NSInteger)toolbar.items.count - 1; i >= 0; i--) { + NSToolbarItem *ti = toolbar.items[(NSUInteger)i]; + if ([ti.itemIdentifier isEqualToString:kUH_ToolbarID]) { + if (ti.view) { hasButton = YES; } + else { [toolbar removeItemAtIndex:(NSUInteger)i]; } + } + } + if (hasButton) return; + + // Insert after the Transcript button if present, else after flexible space. + NSUInteger insertIdx = toolbar.items.count; + for (NSUInteger i = 0; i < toolbar.items.count; i++) { + if ([toolbar.items[i].itemIdentifier isEqualToString:kTranscriptToolbarID]) { + insertIdx = i + 1; + break; + } + } + if (insertIdx == toolbar.items.count) { + // No Transcript button found — fall back to just after flexible space. + for (NSUInteger i = 0; i < toolbar.items.count; i++) { + if ([toolbar.items[i].itemIdentifier + isEqualToString:NSToolbarFlexibleSpaceItemIdentifier]) { + insertIdx = i + 1; + break; + } + } + } + + [toolbar insertItemWithItemIdentifier:kUH_ToolbarID atIndex:insertIdx]; + if (sAPI) sAPI->log(@"[UndoHistoryUI] History button inserted at index %lu.", + (unsigned long)insertIdx); + } @catch (NSException *e) { + if (sAPI) sAPI->log(@"[UndoHistoryUI] Toolbar insert exception: %@", e.reason); + } +} + +static void UH_tryInstall(int attempt); +static void UH_tryInstall(int attempt) { + if (attempt >= 30) return; + dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(1.0 * NSEC_PER_SEC)), + dispatch_get_main_queue(), ^{ + for (NSWindow *w in [[NSApplication sharedApplication] windows]) { + if (w.toolbar && w.toolbar.items.count > 0) { + UH_addButtonToToolbar(w.toolbar); + return; + } + } + UH_tryInstall(attempt + 1); + }); +} + +static void UH_startToolbarInstall(void) { + __block id observer = + [[NSNotificationCenter defaultCenter] + addObserverForName:NSWindowDidBecomeMainNotification + object:nil queue:[NSOperationQueue mainQueue] + usingBlock:^(NSNotification *note) { + NSWindow *win = note.object; + if (win.toolbar) { + [[NSNotificationCenter defaultCenter] removeObserver:observer]; + observer = nil; + UH_addButtonToToolbar(win.toolbar); + } + }]; + UH_tryInstall(0); +} + +// --------------------------------------------------------------------------- +// Menu item injection into the Splices menu +// --------------------------------------------------------------------------- + +static void UH_addMenuItemIfNeeded(void) { + @try { + NSMenu *mainMenu = [NSApp mainMenu]; + if (!mainMenu) return; + + // Find the Splices top-level menu. + NSMenu *splicesMenu = nil; + for (NSMenuItem *topItem in mainMenu.itemArray) { + if ([topItem.title isEqualToString:@"Splices"]) { + splicesMenu = topItem.submenu; + break; + } + } + if (!splicesMenu) return; + + // Check if "Undo History" is already present. + for (NSMenuItem *item in splicesMenu.itemArray) { + if ([item.title isEqualToString:@"Undo History"]) return; + } + + NSMenuItem *item = [[NSMenuItem alloc] + initWithTitle:@"Undo History" + action:@selector(togglePanel:) + keyEquivalent:@"u"]; + item.keyEquivalentModifierMask = NSEventModifierFlagControl | NSEventModifierFlagOption; + item.target = [UHPlugin_Controller shared]; + [splicesMenu addItem:item]; + + if (sAPI) sAPI->log(@"[UndoHistoryUI] Added Undo History to Splices menu."); + } @catch (NSException *e) { + if (sAPI) sAPI->log(@"[UndoHistoryUI] Menu inject exception: %@", e.reason); + } +} + +// --------------------------------------------------------------------------- +// Plugin entry point +// --------------------------------------------------------------------------- + +__attribute__((visibility("default"))) +void SpliceKitPlugin_init(SpliceKitPluginAPI *api) { + sAPIStorage = *api; + sAPI = &sAPIStorage; + + sAPI->log(@"[UndoHistoryUI] Loading."); + + // Install NSUndoManager hooks (idempotent — safe even if host binary already called this). + [SpliceKitUndoHistoryPanel installHooks]; + + // Inject toolbar button and menu item on the main thread. + api->executeOnMainThreadAsync(^{ + UH_addMenuItemIfNeeded(); + UH_startToolbarInstall(); + }); + + sAPI->log(@"[UndoHistoryUI] Loaded."); +}