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.");
+}