From 51ceae6d4662bbc47cf1dbb47a9fb9a391464475 Mon Sep 17 00:00:00 2001 From: MICHAEL STATHOPOULOS Date: Tue, 12 May 2026 23:52:52 -0500 Subject: [PATCH 01/14] Add selectForward / selectBackward timeline actions + build fixes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds two new built-in timeline actions to SpliceKit core: - `selectForward` — selects all primary-storyline clips whose start time is at or after the playhead position. - `selectBackward` — selects all primary-storyline clips whose end time is at or before the playhead position. Both actions use `effectiveRangeOfObject:` on the spine collection for accurate absolute positions and `setSelectedItems:` on the timeline module for selection — same ObjC patterns used throughout the server. Clips that span the playhead are excluded from both directions so that downstream ripple-delete / copy operations are predictable. Also included: - `SpliceKitBRAW.mm`: no-op stubs for `SpliceKit_bootstrapBRAWAtLaunchPhase` and `SpliceKit_handleBRAWAVProbe` under `#if !SPLICEKIT_HAS_BRAW_SDK` so the dylib links cleanly on machines without the Blackmagic RAW SDK. - `Makefile`: removed `braw-prototype` from the hard prerequisites of the `deploy` target; it is still run conditionally when ENABLE_BRAW_PROTOTYPE=1. Co-Authored-By: Claude Sonnet 4.6 --- Makefile | 2 +- Sources/SpliceKitBRAW.mm | 23 ++++++++ Sources/SpliceKitServer.m | 121 ++++++++++++++++++++++++++++++++++++++ 3 files changed, 145 insertions(+), 1 deletion(-) diff --git a/Makefile b/Makefile index 5c2db51..afd86e7 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" diff --git a/Sources/SpliceKitBRAW.mm b/Sources/SpliceKitBRAW.mm index 5f8b0a0..d5daaa8 100644 --- a/Sources/SpliceKitBRAW.mm +++ b/Sources/SpliceKitBRAW.mm @@ -5756,4 +5756,27 @@ SPLICEKIT_BRAW_EXTERN_C BOOL SpliceKitBRAW_ReadAudioSamples( return NO; } +#endif // SPLICEKIT_HAS_BRAW_SDK + +// Stubs compiled unconditionally so the Makefile linker-symbol validation +// passes even when the Blackmagic RAW SDK is not installed on this machine. +#if !SPLICEKIT_HAS_BRAW_SDK + +#ifdef __cplusplus +extern "C" { +#endif + +void SpliceKit_bootstrapBRAWAtLaunchPhase(NSString *phase) { + (void)phase; +} + +NSDictionary *SpliceKit_handleBRAWAVProbe(NSDictionary *params) { + (void)params; + return @{@"error": @"BRAW support requires the Blackmagic RAW SDK at build time."}; +} + +#ifdef __cplusplus +} // extern "C" #endif + +#endif // !SPLICEKIT_HAS_BRAW_SDK diff --git a/Sources/SpliceKitServer.m b/Sources/SpliceKitServer.m index 34789a2..a558b17 100644 --- a/Sources/SpliceKitServer.m +++ b/Sources/SpliceKitServer.m @@ -3046,6 +3046,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(^{ From df731c9a44db7bb19e13719b0ce1594deb6e44fd Mon Sep 17 00:00:00 2001 From: MICHAEL STATHOPOULOS Date: Tue, 12 May 2026 23:53:13 -0500 Subject: [PATCH 02/14] Add com.splicekit.select-from-playhead-buttons example plugin MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Native ObjC plugin that injects two buttons directly into Final Cut Pro's main toolbar using the SpliceKit plugin API: ◀| 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. Each button invokes the new `selectForward` / `selectBackward` timeline actions added to SpliceKit core in this same PR. Implementation notes: - Swizzles `PEMainWindowModule`'s toolbar delegate method using `class_replaceMethod` (not `method_setImplementation`) so we only touch the specific class's own method table, avoiding superclass side-effects that caused FCP crashes in earlier iterations. - The `SpliceKitPluginAPI` struct is copied into static storage at init time. The loader allocates it on the stack; storing the raw pointer and using it from an async block (after the stack frame is gone) is undefined behaviour and was the root cause of the crash. - Swizzle is installed eagerly on the main thread before the notification/polling loop starts, so the identifier is always resolvable the moment `insertItemWithItemIdentifier:atIndex:` runs. - Stale (view-less) items are cleaned up on each installation attempt, handling the edge-case where a previous run inserted items before the swizzle was in place. - Buttons use SF Symbols (`arrow.right.square` / `arrow.left.square`) at 13 pt Medium weight to match FCP's native toolbar aesthetic. Build & install: cd examples/plugins/com.splicekit.select-from-playhead-buttons make install # builds universal dylib and copies to ~/Library/… Co-Authored-By: Claude Sonnet 4.6 --- .../Makefile | 42 +++ .../plugin.json | 13 + .../src/SelectFromPlayheadButtons.m | 330 ++++++++++++++++++ 3 files changed, 385 insertions(+) create mode 100644 examples/plugins/com.splicekit.select-from-playhead-buttons/Makefile create mode 100644 examples/plugins/com.splicekit.select-from-playhead-buttons/plugin.json create mode 100644 examples/plugins/com.splicekit.select-from-playhead-buttons/src/SelectFromPlayheadButtons.m 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..2a867d2 --- /dev/null +++ b/examples/plugins/com.splicekit.select-from-playhead-buttons/src/SelectFromPlayheadButtons.m @@ -0,0 +1,330 @@ +// +// SelectFromPlayheadButtons.m +// com.splicekit.select-from-playhead-buttons +// +// Injects two buttons into Final Cut Pro's main toolbar: +// +// ▶| Select Forward — selects all primary-storyline clips whose start +// time is at or after the playhead position. +// |◀ Select Backward — selects all primary-storyline clips whose end +// time is at or before the playhead position. +// +// The buttons invoke timeline.action("selectForward") and +// timeline.action("selectBackward"), which are built into SpliceKit core. +// +// Toolbar injection follows the same pattern as SpliceKit's own LiveCam / +// Transcript / Command Palette buttons (SpliceKit.m lines 2819-3047): +// 1. Swizzle FCP's toolbar delegate so it recognises our custom item IDs. +// 2. Insert our items into the toolbar once the main window is ready. +// 3. Use a notification + polling fallback to survive timing edge-cases. +// +// Build: make -f Makefile +// Install: make -f Makefile install +// + +#import +#import +#import +#import + +#import "SpliceKitPluginAPI.h" + +// --------------------------------------------------------------------------- +// Constants +// --------------------------------------------------------------------------- + +// Unique identifier strings for our two toolbar items. +// Must not collide with any identifier already used by FCP or SpliceKit core. +static NSString * const kSFPBForwardID = @"SpliceKitSFP_SelectForwardItemID"; +static NSString * const kSFPBBackwardID = @"SpliceKitSFP_SelectBackwardItemID"; + +// Plugin API — copied into static storage at init time. +// IMPORTANT: SpliceKitPlugins_loadNative allocates SpliceKitPluginAPI on the +// stack and passes a pointer to it. If we store that raw pointer and use it +// after init returns (e.g. from an async block), we read freed memory and +// crash. We avoid this by copying the struct contents and keeping our own copy. +static SpliceKitPluginAPI sSFPBAPIStorage; // owns the data +static SpliceKitPluginAPI *sSFPBAPI = NULL; // set to &sSFPBAPIStorage once copied + +// The original (or already-swizzled) implementation we chain through to. +// This is whatever was in place at the moment we installed our swizzle — +// either FCP's original method or SpliceKit's already-installed swizzle. +static IMP sSFPBOriginalItemForIdentifier = NULL; + +// --------------------------------------------------------------------------- +// Button target — singleton NSObject whose action methods fire the RPC calls. +// Declaring an ObjC class inside a plugin dylib is fine; the class is +// registered in the shared runtime and persists for the process lifetime. +// --------------------------------------------------------------------------- +@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; +} + +- (void)selectForwardAction:(id)sender { + (void)sender; + if (sSFPBAPI) { + sSFPBAPI->callMethod(@{ + @"method": @"timeline.action", + @"params": @{@"action": @"selectForward"} + }); + } +} + +- (void)selectBackwardAction:(id)sender { + (void)sender; + if (sSFPBAPI) { + sSFPBAPI->callMethod(@{ + @"method": @"timeline.action", + @"params": @{@"action": @"selectBackward"} + }); + } +} + +@end + +// --------------------------------------------------------------------------- +// Icon helper — SF Symbol sized to match FCP's toolbar buttons. +// --------------------------------------------------------------------------- +static NSImage *SFPB_makeIcon(NSString *symbolName, NSString *description) { + NSImage *img = [NSImage imageWithSystemSymbolName:symbolName + accessibilityDescription:description]; + NSImageSymbolConfiguration *cfg = [NSImageSymbolConfiguration + configurationWithPointSize:13 weight:NSFontWeightMedium]; + return img ? [img imageWithSymbolConfiguration:cfg] : nil; +} + +// --------------------------------------------------------------------------- +// Toolbar delegate swizzle +// +// FCP asks its toolbar delegate "what NSToolbarItem goes at identifier X?" +// whenever it needs to instantiate or display an item. By replacing the +// delegate's implementation with this function, we intercept requests for +// our two custom identifiers and synthesise NSToolbarItems on the fly. +// All other identifiers are forwarded to whatever was there before us. +// --------------------------------------------------------------------------- +static id SFPB_toolbarItemForIdentifier(id self, SEL _cmd, + NSToolbar *toolbar, + NSString *identifier, + BOOL willInsert) { + // Helper: build a momentary push button that matches FCP's toolbar aesthetic. + NSButton * (^makeButton)(NSImage *, SEL) = ^(NSImage *icon, SEL action) { + NSButton *btn = [[NSButton alloc] initWithFrame:NSMakeRect(0, 0, 36, 25)]; + [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.paletteLabel = @"Select Forward from Playhead"; + item.toolTip = @"Select all clips from the playhead to the end of the timeline"; + + item.view = makeButton(SFPB_makeIcon(@"arrow.right.square", + @"Select Forward from Playhead"), + @selector(selectForwardAction:)); + return item; + } + + if ([identifier isEqualToString:kSFPBBackwardID]) { + NSToolbarItem *item = [[NSToolbarItem alloc] initWithItemIdentifier:identifier]; + item.label = @"Select Bwd"; + item.paletteLabel = @"Select Backward from Playhead"; + item.toolTip = @"Select all clips from the beginning of the timeline to the playhead"; + + item.view = makeButton(SFPB_makeIcon(@"arrow.left.square", + @"Select Backward from Playhead"), + @selector(selectBackwardAction:)); + return item; + } + + // Not ours — call through to whatever was there before (either FCP's + // original method or SpliceKit core's already-installed swizzle). + return ((id (*)(id, SEL, NSToolbar *, NSString *, BOOL))sSFPBOriginalItemForIdentifier)( + self, _cmd, toolbar, identifier, willInsert); +} + +// --------------------------------------------------------------------------- +// Swizzle installation — targets PEMainWindowModule by name so it works +// even when called before the toolbar/delegate are reachable. +// --------------------------------------------------------------------------- + +static void SFPB_installSwizzle(void) { + if (sSFPBOriginalItemForIdentifier) return; // already installed + + @try { + // Prefer the named class so we always swizzle the right delegate regardless + // of which window we happen to find first during the polling phase. + Class cls = NSClassFromString(@"PEMainWindowModule"); + + // Fallback: walk open windows to find whatever class is acting as delegate. + if (!cls) { + for (NSWindow *w in [[NSApplication sharedApplication] windows]) { + if (w.toolbar && w.toolbar.delegate) { + cls = [w.toolbar.delegate class]; + break; + } + } + } + if (!cls) return; + + SEL sel = @selector(toolbar:itemForItemIdentifier:willBeInsertedIntoToolbar:); + Method m = class_getInstanceMethod(cls, sel); + if (!m) return; + + // Capture the current IMP (may be SpliceKit's swizzle or FCP's original). + sSFPBOriginalItemForIdentifier = method_getImplementation(m); + + // Use class_replaceMethod rather than method_setImplementation so we only + // touch cls's OWN method table. method_setImplementation would modify + // whichever class in the hierarchy owns the Method object, potentially + // breaking unrelated subclasses that share the same inherited method. + class_replaceMethod(cls, sel, + (IMP)SFPB_toolbarItemForIdentifier, + method_getTypeEncoding(m)); + + if (sSFPBAPI) + sSFPBAPI->log(@"[SFPButtons] Swizzled %s", class_getName(cls)); + + } @catch (NSException *e) { + sSFPBOriginalItemForIdentifier = NULL; // reset so we can retry + if (sSFPBAPI) + sSFPBAPI->log(@"[SFPButtons] Swizzle exception: %@", e.reason); + } +} + +// --------------------------------------------------------------------------- +// Toolbar installation +// --------------------------------------------------------------------------- + +static void SFPB_addButtonsToToolbar(NSToolbar *toolbar) { + @try { + if (!toolbar.delegate) return; + + // Swizzle is installed eagerly at init; this is just a safety net in + // case we somehow get here before the eager call finished. + SFPB_installSwizzle(); + + // Scan for existing items; remove any stale (view-less) copies. + BOOL hasForward = NO, hasBackward = NO; + for (NSInteger i = (NSInteger)toolbar.items.count - 1; i >= 0; i--) { + NSToolbarItem *ti = toolbar.items[(NSUInteger)i]; + if ([ti.itemIdentifier isEqualToString:kSFPBForwardID]) { + if (ti.view) hasForward = YES; + else [toolbar removeItemAtIndex:(NSUInteger)i]; + } else if ([ti.itemIdentifier isEqualToString:kSFPBBackwardID]) { + if (ti.view) hasBackward = YES; + else [toolbar removeItemAtIndex:(NSUInteger)i]; + } + } + if (hasForward && hasBackward) return; // Already present — done. + + // Insert before the flexible space so our buttons sit with FCP's own + // tool controls (same grouping used by SpliceKit's built-in buttons). + NSUInteger insertIdx = toolbar.items.count; + for (NSUInteger i = 0; i < toolbar.items.count; i++) { + if ([toolbar.items[i].itemIdentifier + isEqualToString:NSToolbarFlexibleSpaceItemIdentifier]) { + insertIdx = i; + break; + } + } + + // Insert backward first so forward ends up to its right: [Bwd][Fwd] + if (!hasBackward) { + [toolbar insertItemWithItemIdentifier:kSFPBBackwardID atIndex:insertIdx]; + insertIdx++; + } + if (!hasForward) { + [toolbar insertItemWithItemIdentifier:kSFPBForwardID atIndex:insertIdx]; + } + + if (sSFPBAPI) + sSFPBAPI->log(@"[SFPButtons] Toolbar buttons installed."); + + } @catch (NSException *e) { + if (sSFPBAPI) + sSFPBAPI->log(@"[SFPButtons] Exception during toolbar install: %@", e.reason); + } +} + +// Polling fallback: check all windows for a ready toolbar. +static void SFPB_tryInstall(int attempt); + +static void SFPB_tryInstall(int attempt) { + if (attempt >= 30) { + if (sSFPBAPI) sSFPBAPI->log(@"[SFPButtons] Giving up after %d attempts.", attempt); + 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) { + SFPB_addButtonsToToolbar(w.toolbar); + return; + } + } + SFPB_tryInstall(attempt + 1); + }); +} + +static void SFPB_startInstallation(void) { + // Fast path: notification fires when a window with a toolbar becomes main. + __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; + SFPB_addButtonsToToolbar(win.toolbar); + } + }]; + + // Slow path: poll every second in case the notification already fired + // before we registered, or in case FCP's window isn't main yet. + SFPB_tryInstall(0); +} + +// --------------------------------------------------------------------------- +// Plugin entry point +// --------------------------------------------------------------------------- +__attribute__((visibility("default"))) +void SpliceKitPlugin_init(SpliceKitPluginAPI *api) { + // Copy the struct — the caller allocates it on the stack and it's gone + // the moment SpliceKitPlugins_loadNative returns (before our async block runs). + sSFPBAPIStorage = *api; + sSFPBAPI = &sSFPBAPIStorage; + + sSFPBAPI->log(@"[SFPButtons] Loading — will inject Select Forward/Backward toolbar buttons."); + + // All AppKit work must happen on the main thread. + api->executeOnMainThreadAsync(^{ + // Install the swizzle immediately — before the notification/polling + // fires — so FCP's toolbar delegate is ready to serve our identifiers + // the moment we call insertItemWithItemIdentifier:atIndex:. + SFPB_installSwizzle(); + SFPB_startInstallation(); + }); + + api->log(@"[SFPButtons] Loaded."); +} From ac97e4f5aa85310614051d1ede20cde3f713c3cf Mon Sep 17 00:00:00 2001 From: MICHAEL STATHOPOULOS Date: Sun, 31 May 2026 20:18:58 -0700 Subject: [PATCH 03/14] Restore Replace at Playhead + add Undo History palette; fix deploy signing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace at Playhead (SpliceKitReplaceAtPlayhead.m): - Re-injects "Replace at Playhead" into FFAnchoredTimelineModule.dropMenu (LKMenu) after "Replace from End", wired to actionDropMenuReplaceAtPlayhead: - Registers LKCommand bound to Option+Shift+R in FCP's Command Editor - Drag-drop path: intercepts operationReplaceItem:withItems:replaceActionType:1 to apply correct source in-point (srcIn = sourcePlayheadTime − (playheadTime − targetStart)) and trim to original target clip duration via setTimeRangeInAsset: - Keyboard path: reads browser cursor from FFOrganizerFilmstripModule.currentSequenceTime (organizer-cumulative coords), walks primary storyline to find target clip geometry, arms _deferredDropTargetItem so FCP sizes the clip correctly, then triggers performEditAction:fromPasteboardWithName: with replace type=1; operationReplaceItem: swizzle substitutes the clippedRange with orgClipStart+desiredSrcIn coords Undo History palette (SpliceKitUndoHistoryPanel.h/.m): - Floating panel showing every action on FCP's undo stack including pre-open history - Click any row to undo/redo to that state; redo entries shown greyed below cursor - Toolbar button (clock icon) + Splices menu item (Ctrl+Option+U) - Tracks up to 100 entries via NSUndoManagerDidCloseUndoGroupNotification with nesting-depth gating so only outermost group closes produce entries Makefile deploy signing fixes: - Strip com.apple.provenance xattrs from PlugIns before signing (was causing codesign to fail with "resource fork not allowed", silently breaking deploys) - Add explicit codesign step for SpliceKitMKVImport.bundle (was missing, causing app-level signing to fail with "code object is not signed at all") Co-Authored-By: Claude Sonnet 4.6 --- Makefile | 6 + Sources/SOURCES.txt | 2 + Sources/SpliceKit.m | 97 ++- Sources/SpliceKitReplaceAtPlayhead.m | 961 +++++++++++++++++++++++++++ Sources/SpliceKitUndoHistoryPanel.h | 64 ++ Sources/SpliceKitUndoHistoryPanel.m | 465 +++++++++++++ 6 files changed, 1586 insertions(+), 9 deletions(-) create mode 100644 Sources/SpliceKitReplaceAtPlayhead.m create mode 100644 Sources/SpliceKitUndoHistoryPanel.h create mode 100644 Sources/SpliceKitUndoHistoryPanel.m diff --git a/Makefile b/Makefile index afd86e7..338964f 100644 --- a/Makefile +++ b/Makefile @@ -451,11 +451,13 @@ deploy: $(OUTPUT) $(SILENCE_DETECTOR) $(STRUCTURE_ANALYZER) $(MIXER_APP) vp9-pro @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 +466,7 @@ deploy: $(OUTPUT) $(SILENCE_DETECTOR) $(STRUCTURE_ANALYZER) $(MIXER_APP) vp9-pro 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 +483,9 @@ deploy: $(OUTPUT) $(SILENCE_DETECTOR) $(STRUCTURE_ANALYZER) $(MIXER_APP) vp9-pro 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..a1cead3 100644 --- a/Sources/SOURCES.txt +++ b/Sources/SOURCES.txt @@ -26,6 +26,7 @@ SpliceKitSentry.m SpliceKitTranscriptPanel.m SpliceKitTranscriptDiagnostics.m SpliceKitCaptionPanel.m +SpliceKitUndoHistoryPanel.m SpliceKitCommandPalette.m SpliceKitDebugUI.m SpliceKitStructureBlocks.m @@ -36,6 +37,7 @@ SpliceKitLuaPanel.m SpliceKitPlugins.m SpliceKitMixerPanel.m SpliceKitSidebarCoalesce.m +SpliceKitReplaceAtPlayhead.m SpliceKitTimelineInteractionSuspend.m SpliceKitTimelinePlayheadOverlay.m SpliceKitTimelinePerfMode.m diff --git a/Sources/SpliceKit.m b/Sources/SpliceKit.m index a1e0f14..0a52cd6 100644 --- a/Sources/SpliceKit.m +++ b/Sources/SpliceKit.m @@ -17,6 +17,7 @@ #import "SpliceKitLiveCam.h" #import "SpliceKitURLImport.h" #import "SpliceKitImmersivePreviewPanel.h" +#import "SpliceKitUndoHistoryPanel.h" #import #import #import @@ -34,6 +35,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 +461,11 @@ - (void)importOTIO:(id)sender; - (void)toggleLiveCamPanel:(id)sender; - (void)updateLiveCamToolbarButtonState:(BOOL)active; - (void)toggleVisionProPanel:(id)sender; +- (void)toggleUndoHistoryPanel:(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 +511,15 @@ - (void)toggleCaptionPanel:(id)sender { } } +- (void)toggleUndoHistoryPanel:(id)sender { + SpliceKitUndoHistoryPanel *panel = [SpliceKitUndoHistoryPanel sharedPanel]; + if ([panel isVisible]) { + [panel hidePanel]; + } else { + [panel showPanel]; + } +} + - (void)toggleMixerPanel:(id)sender { Class panelClass = objc_getClass("SpliceKitMixerPanel"); if (!panelClass) { @@ -2521,6 +2534,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] @@ -2816,9 +2837,10 @@ static void SpliceKit_installMenu(void) { 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 +2930,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 +3033,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 +3060,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 +3096,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) { @@ -3391,6 +3462,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. @@ -3433,6 +3509,9 @@ static void SpliceKit_appDidLaunch(void) { SpliceKit_safeInstall("BridgeMetadata", ^{ SpliceKit_installBridgeMetadata(); }); + SpliceKit_safeInstall("UndoHistoryHooks", ^{ + [SpliceKitUndoHistoryPanel installHooks]; + }); SpliceKit_safeInstall("AsyncEvents", ^{ SpliceKit_installAsync(); }); diff --git a/Sources/SpliceKitReplaceAtPlayhead.m b/Sources/SpliceKitReplaceAtPlayhead.m new file mode 100644 index 0000000..0dbd4ea --- /dev/null +++ b/Sources/SpliceKitReplaceAtPlayhead.m @@ -0,0 +1,961 @@ +// 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); + } +} + +// 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 applyReplaceAtPlayheadCommand re-registers after the reload. + sReplaceAtPlayheadCmd = nil; + SpliceKit_applyReplaceAtPlayheadCommand(self, commandSet); +} + +// 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); +} + +// --------------------------------------------------------------------------- +// 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); + } + } + } + }); +} 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 From b69fe6af92af580ead1c33eb68862a9b8a2306b4 Mon Sep 17 00:00:00 2001 From: MICHAEL STATHOPOULOS Date: Sun, 31 May 2026 21:47:55 -0700 Subject: [PATCH 04/14] Add Paste Overwrite to Edit menu MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds "Paste Overwrite" immediately after the native Paste item in FCP's Edit menu (Cmd+Shift+V). Replaces timeline content at the playhead with clipboard clips without changing timeline duration. Algorithm: paste: (inserts clipboard at T, shifts content right) → blade: at T+2D (isolates D seconds of original content in [T+D, T+2D]) → _selectRange:{T+D,D} (selects all real clips in that segment) → clearRange: + deleteSelectionOnly: (ripple-deletes only the selected clips). No gap clips involved — avoids the gap-selection problem entirely. Clipboard duration is read directly from FCPXML on the pasteboard (IXXMLPasteboardType) to avoid a probe paste. Falls back to probe-paste measurement when FCPXML is unavailable. Co-Authored-By: Claude Sonnet 4.6 --- CLAUDE.md | 38 +++++ Sources/SpliceKit.h | 1 + Sources/SpliceKit.m | 3 + Sources/SpliceKitBRAW.mm | 35 ++-- Sources/SpliceKitServer.m | 346 ++++++++++++++++++++++++++++++++++++++ 5 files changed, 401 insertions(+), 22 deletions(-) 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/Sources/SpliceKit.h b/Sources/SpliceKit.h index a69c0de..adf2b8f 100644 --- a/Sources/SpliceKit.h +++ b/Sources/SpliceKit.h @@ -221,6 +221,7 @@ BOOL SpliceKit_isTimelinePerformanceModeEnabled(void); // 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 0a52cd6..002570e 100644 --- a/Sources/SpliceKit.m +++ b/Sources/SpliceKit.m @@ -3483,6 +3483,9 @@ 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(); diff --git a/Sources/SpliceKitBRAW.mm b/Sources/SpliceKitBRAW.mm index d5daaa8..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, @@ -5758,25 +5769,5 @@ SPLICEKIT_BRAW_EXTERN_C BOOL SpliceKitBRAW_ReadAudioSamples( #endif // SPLICEKIT_HAS_BRAW_SDK -// Stubs compiled unconditionally so the Makefile linker-symbol validation -// passes even when the Blackmagic RAW SDK is not installed on this machine. -#if !SPLICEKIT_HAS_BRAW_SDK - -#ifdef __cplusplus -extern "C" { -#endif - -void SpliceKit_bootstrapBRAWAtLaunchPhase(NSString *phase) { - (void)phase; -} - -NSDictionary *SpliceKit_handleBRAWAVProbe(NSDictionary *params) { - (void)params; - return @{@"error": @"BRAW support requires the Blackmagic RAW SDK at build time."}; -} - -#ifdef __cplusplus -} // extern "C" -#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/SpliceKitServer.m b/Sources/SpliceKitServer.m index a558b17..865be52 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); @@ -3359,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 @@ -12195,6 +12442,105 @@ 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:@""]; + 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" From 409245462356a0c3d71a606c5c3ccb0d662fa963 Mon Sep 17 00:00:00 2001 From: MICHAEL STATHOPOULOS Date: Sun, 31 May 2026 21:55:17 -0700 Subject: [PATCH 05/14] Fix make deploy to always write correct version to framework Info.plist The patcher checks CFBundleShortVersionString in the installed SpliceKit.framework to decide whether to prompt for an update. The old Makefile only created Info.plist when it was missing, and used a hardcoded "1.0.0" version. After any manual deploy that recreated the plist, the version mismatch caused the patcher to block FCP launch with a spurious "update available" prompt. Fix: always overwrite Info.plist on every make deploy, writing both CFBundleShortVersionString and CFBundleVersion from the SPLICEKIT_VERSION variable (currently 3.3.8). Co-Authored-By: Claude Sonnet 4.6 --- Makefile | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/Makefile b/Makefile index 338964f..eb6c4f5 100644 --- a/Makefile +++ b/Makefile @@ -394,9 +394,11 @@ deploy: $(OUTPUT) $(SILENCE_DETECTOR) $(STRUCTURE_ANALYZER) $(MIXER_APP) vp9-pro @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 From 9246298020e1406f5b1093ec7651590e128dcac8 Mon Sep 17 00:00:00 2001 From: MICHAEL STATHOPOULOS Date: Mon, 1 Jun 2026 22:13:32 -0700 Subject: [PATCH 06/14] =?UTF-8?q?Add=20ClipLock:=20per-clip=20locking=20wi?= =?UTF-8?q?th=20=F0=9F=94=92=20label,=20=E2=8C=A5L=20shortcut,=20RPC,=20an?= =?UTF-8?q?d=20delete=20protection?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Locked clips cannot be moved, trimmed, deleted, cut, or have effects dropped on them. Deleting an unlocked clip when locked clips exist redirects delete: → shiftDelete: (gap delete) so locked clips never shift position. Key lesson: operationRemoveEdits:...playhead:error: has type encoding ^{?=qiIq} for the playhead arg — a pointer to CMTime, not CMTime by value. Using the wrong type caused EXC_BAD_ACCESS (null ptr deref at FAR=0) on every delete. The swizzle was removed; delete: interception handles the use case safely. Also adds: transition-alignment plugin, undo-history-ui plugin, independent-slip plugin (disabled), Replace at Playhead command palette entry, history.* and clips.* bridge metadata, SelectFromPlayheadButtons reworked to timeline bar with direct setSelectedItems: calls, Paste Overwrite ⌥V shortcut. Co-Authored-By: Claude Sonnet 4.6 --- Sources/SOURCES.txt | 1 + Sources/SpliceKit.h | 4 + Sources/SpliceKit.m | 5 + Sources/SpliceKitBridgeMetadata.m | 15 + Sources/SpliceKitClipLock.m | 1828 +++++++++++++++++ Sources/SpliceKitCommandPalette.m | 3 +- Sources/SpliceKitServer.m | 3 +- .../com.splicekit.independent-slip/Makefile | 42 + .../plugin.json | 13 + .../src/IndependentSlip.m | 545 +++++ .../src/SelectFromPlayheadButtons.m | 424 ++-- .../Makefile | 42 + .../plugin.json | 22 + .../src/TransitionAlignment.m | 341 +++ .../com.splicekit.undo-history-ui/Makefile | 42 + .../com.splicekit.undo-history-ui/plugin.json | 13 + .../src/UndoHistoryUI.m | 676 ++++++ 17 files changed, 3845 insertions(+), 174 deletions(-) create mode 100644 Sources/SpliceKitClipLock.m create mode 100644 examples/plugins/com.splicekit.independent-slip/Makefile create mode 100644 examples/plugins/com.splicekit.independent-slip/plugin.json create mode 100644 examples/plugins/com.splicekit.independent-slip/src/IndependentSlip.m create mode 100644 examples/plugins/com.splicekit.transition-alignment/Makefile create mode 100644 examples/plugins/com.splicekit.transition-alignment/plugin.json create mode 100644 examples/plugins/com.splicekit.transition-alignment/src/TransitionAlignment.m create mode 100644 examples/plugins/com.splicekit.undo-history-ui/Makefile create mode 100644 examples/plugins/com.splicekit.undo-history-ui/plugin.json create mode 100644 examples/plugins/com.splicekit.undo-history-ui/src/UndoHistoryUI.m diff --git a/Sources/SOURCES.txt b/Sources/SOURCES.txt index a1cead3..b07f35d 100644 --- a/Sources/SOURCES.txt +++ b/Sources/SOURCES.txt @@ -38,6 +38,7 @@ SpliceKitPlugins.m SpliceKitMixerPanel.m SpliceKitSidebarCoalesce.m SpliceKitReplaceAtPlayhead.m +SpliceKitClipLock.m SpliceKitTimelineInteractionSuspend.m SpliceKitTimelinePlayheadOverlay.m SpliceKitTimelinePerfMode.m diff --git a/Sources/SpliceKit.h b/Sources/SpliceKit.h index adf2b8f..7281042 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); diff --git a/Sources/SpliceKit.m b/Sources/SpliceKit.m index 002570e..02a8c22 100644 --- a/Sources/SpliceKit.m +++ b/Sources/SpliceKit.m @@ -3499,6 +3499,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", ^{ 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/SpliceKitServer.m b/Sources/SpliceKitServer.m index 865be52..b8cfd84 100644 --- a/Sources/SpliceKitServer.m +++ b/Sources/SpliceKitServer.m @@ -12528,7 +12528,8 @@ void SpliceKit_installPasteOverwriteMenuItem(void) { NSMenuItem *item = [[NSMenuItem alloc] initWithTitle:@"Paste Overwrite" action:@selector(pasteOverwrite:) - keyEquivalent:@""]; + keyEquivalent:@"v"]; + item.keyEquivalentModifierMask = NSEventModifierFlagOption; item.target = [SpliceKitPasteOverwriteMenuTarget shared]; [editMenu insertItem:item atIndex:pasteIdx + 1]; 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/src/SelectFromPlayheadButtons.m b/examples/plugins/com.splicekit.select-from-playhead-buttons/src/SelectFromPlayheadButtons.m index 2a867d2..7c57823 100644 --- a/examples/plugins/com.splicekit.select-from-playhead-buttons/src/SelectFromPlayheadButtons.m +++ b/examples/plugins/com.splicekit.select-from-playhead-buttons/src/SelectFromPlayheadButtons.m @@ -2,21 +2,26 @@ // SelectFromPlayheadButtons.m // com.splicekit.select-from-playhead-buttons // -// Injects two buttons into Final Cut Pro's main toolbar: +// 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 Forward — selects all primary-storyline clips whose start -// time is at or after the playhead position. // |◀ 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("selectForward") and -// timeline.action("selectBackward"), which are built into SpliceKit core. +// The buttons invoke timeline.action("selectBackward") and +// timeline.action("selectForward"), which are built into SpliceKit core. // -// Toolbar injection follows the same pattern as SpliceKit's own LiveCam / -// Transcript / Command Palette buttons (SpliceKit.m lines 2819-3047): -// 1. Swizzle FCP's toolbar delegate so it recognises our custom item IDs. -// 2. Insert our items into the toolbar once the main window is ready. -// 3. Use a notification + polling fallback to survive timing edge-cases. +// 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 @@ -33,28 +38,17 @@ // Constants // --------------------------------------------------------------------------- -// Unique identifier strings for our two toolbar items. -// Must not collide with any identifier already used by FCP or SpliceKit core. static NSString * const kSFPBForwardID = @"SpliceKitSFP_SelectForwardItemID"; static NSString * const kSFPBBackwardID = @"SpliceKitSFP_SelectBackwardItemID"; -// Plugin API — copied into static storage at init time. -// IMPORTANT: SpliceKitPlugins_loadNative allocates SpliceKitPluginAPI on the -// stack and passes a pointer to it. If we store that raw pointer and use it -// after init returns (e.g. from an async block), we read freed memory and -// crash. We avoid this by copying the struct contents and keeping our own copy. -static SpliceKitPluginAPI sSFPBAPIStorage; // owns the data -static SpliceKitPluginAPI *sSFPBAPI = NULL; // set to &sSFPBAPIStorage once copied - -// The original (or already-swizzled) implementation we chain through to. -// This is whatever was in place at the moment we installed our swizzle — -// either FCP's original method or SpliceKit's already-installed swizzle. +static SpliceKitPluginAPI sSFPBAPIStorage; +static SpliceKitPluginAPI *sSFPBAPI = NULL; + +// Toolbar swizzle — kept only for backward-compat resolution of saved items. static IMP sSFPBOriginalItemForIdentifier = NULL; // --------------------------------------------------------------------------- -// Button target — singleton NSObject whose action methods fire the RPC calls. -// Declaring an ObjC class inside a plugin dylib is fine; the class is -// registered in the shared runtime and persists for the process lifetime. +// Button target // --------------------------------------------------------------------------- @interface SpliceKitSFP_BtnController : NSObject + (instancetype)shared; @@ -71,55 +65,93 @@ + (instancetype)shared { 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) { - sSFPBAPI->callMethod(@{ - @"method": @"timeline.action", - @"params": @{@"action": @"selectForward"} - }); - } + if (!sSFPBAPI) return; + [self _selectClips:YES]; } - (void)selectBackwardAction:(id)sender { (void)sender; - if (sSFPBAPI) { - sSFPBAPI->callMethod(@{ - @"method": @"timeline.action", - @"params": @{@"action": @"selectBackward"} - }); + 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 — SF Symbol sized to match FCP's toolbar buttons. +// Icon helper // --------------------------------------------------------------------------- static NSImage *SFPB_makeIcon(NSString *symbolName, NSString *description) { NSImage *img = [NSImage imageWithSystemSymbolName:symbolName accessibilityDescription:description]; NSImageSymbolConfiguration *cfg = [NSImageSymbolConfiguration - configurationWithPointSize:13 weight:NSFontWeightMedium]; + configurationWithPointSize:11 weight:NSFontWeightMedium]; return img ? [img imageWithSymbolConfiguration:cfg] : nil; } // --------------------------------------------------------------------------- -// Toolbar delegate swizzle -// -// FCP asks its toolbar delegate "what NSToolbarItem goes at identifier X?" -// whenever it needs to instantiate or display an item. By replacing the -// delegate's implementation with this function, we intercept requests for -// our two custom identifiers and synthesise NSToolbarItems on the fly. -// All other identifiers are forwarded to whatever was there before us. +// Toolbar delegate swizzle (backward-compat only — resolves saved items) // --------------------------------------------------------------------------- static id SFPB_toolbarItemForIdentifier(id self, SEL _cmd, NSToolbar *toolbar, NSString *identifier, BOOL willInsert) { - // Helper: build a momentary push button that matches FCP's toolbar aesthetic. + // 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, 36, 25)]; + NSButton *btn = [[NSButton alloc] initWithFrame:NSMakeRect(0, 0, 30, 22)]; [btn setButtonType:NSButtonTypeMomentaryPushIn]; btn.bezelStyle = NSBezelStyleTexturedRounded; btn.bordered = YES; @@ -132,176 +164,228 @@ static id SFPB_toolbarItemForIdentifier(id self, SEL _cmd, if ([identifier isEqualToString:kSFPBForwardID]) { NSToolbarItem *item = [[NSToolbarItem alloc] initWithItemIdentifier:identifier]; - item.label = @"Select Fwd"; - item.paletteLabel = @"Select Forward from Playhead"; - item.toolTip = @"Select all clips from the playhead to the end of the timeline"; - - item.view = makeButton(SFPB_makeIcon(@"arrow.right.square", - @"Select Forward from Playhead"), - @selector(selectForwardAction:)); + 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.paletteLabel = @"Select Backward from Playhead"; - item.toolTip = @"Select all clips from the beginning of the timeline to the playhead"; - - item.view = makeButton(SFPB_makeIcon(@"arrow.left.square", - @"Select Backward from Playhead"), - @selector(selectBackwardAction:)); + 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; } - // Not ours — call through to whatever was there before (either FCP's - // original method or SpliceKit core's already-installed swizzle). return ((id (*)(id, SEL, NSToolbar *, NSString *, BOOL))sSFPBOriginalItemForIdentifier)( self, _cmd, toolbar, identifier, willInsert); } -// --------------------------------------------------------------------------- -// Swizzle installation — targets PEMainWindowModule by name so it works -// even when called before the toolbar/delegate are reachable. -// --------------------------------------------------------------------------- - static void SFPB_installSwizzle(void) { - if (sSFPBOriginalItemForIdentifier) return; // already installed - + if (sSFPBOriginalItemForIdentifier) return; @try { - // Prefer the named class so we always swizzle the right delegate regardless - // of which window we happen to find first during the polling phase. Class cls = NSClassFromString(@"PEMainWindowModule"); - - // Fallback: walk open windows to find whatever class is acting as delegate. - if (!cls) { - for (NSWindow *w in [[NSApplication sharedApplication] windows]) { - if (w.toolbar && w.toolbar.delegate) { - cls = [w.toolbar.delegate class]; - break; - } - } - } if (!cls) return; - SEL sel = @selector(toolbar:itemForItemIdentifier:willBeInsertedIntoToolbar:); Method m = class_getInstanceMethod(cls, sel); if (!m) return; - - // Capture the current IMP (may be SpliceKit's swizzle or FCP's original). sSFPBOriginalItemForIdentifier = method_getImplementation(m); - - // Use class_replaceMethod rather than method_setImplementation so we only - // touch cls's OWN method table. method_setImplementation would modify - // whichever class in the hierarchy owns the Method object, potentially - // breaking unrelated subclasses that share the same inherited method. - class_replaceMethod(cls, sel, - (IMP)SFPB_toolbarItemForIdentifier, - method_getTypeEncoding(m)); - - if (sSFPBAPI) - sSFPBAPI->log(@"[SFPButtons] Swizzled %s", class_getName(cls)); - - } @catch (NSException *e) { - sSFPBOriginalItemForIdentifier = NULL; // reset so we can retry - if (sSFPBAPI) - sSFPBAPI->log(@"[SFPButtons] Swizzle exception: %@", e.reason); + class_replaceMethod(cls, sel, (IMP)SFPB_toolbarItemForIdentifier, method_getTypeEncoding(m)); + } @catch (__unused NSException *e) { + sSFPBOriginalItemForIdentifier = NULL; } } // --------------------------------------------------------------------------- -// Toolbar installation +// 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 *) {} +} -static void SFPB_addButtonsToToolbar(NSToolbar *toolbar) { +// --------------------------------------------------------------------------- +// 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 { - if (!toolbar.delegate) return; + 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; } +} - // Swizzle is installed eagerly at init; this is just a safety net in - // case we somehow get here before the eager call finished. - SFPB_installSwizzle(); +// --------------------------------------------------------------------------- +// 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; - // Scan for existing items; remove any stale (view-less) copies. - BOOL hasForward = NO, hasBackward = NO; - for (NSInteger i = (NSInteger)toolbar.items.count - 1; i >= 0; i--) { - NSToolbarItem *ti = toolbar.items[(NSUInteger)i]; - if ([ti.itemIdentifier isEqualToString:kSFPBForwardID]) { - if (ti.view) hasForward = YES; - else [toolbar removeItemAtIndex:(NSUInteger)i]; - } else if ([ti.itemIdentifier isEqualToString:kSFPBBackwardID]) { - if (ti.view) hasBackward = YES; - else [toolbar removeItemAtIndex:(NSUInteger)i]; - } + // Idempotency: already installed if our identifier is on any subview. + for (NSView *sv in bar.subviews) { + if ([sv.identifier isEqualToString:kSFPBForwardID]) return; } - if (hasForward && hasBackward) return; // Already present — done. - - // Insert before the flexible space so our buttons sit with FCP's own - // tool controls (same grouping used by SpliceKit's built-in buttons). - NSUInteger insertIdx = toolbar.items.count; - for (NSUInteger i = 0; i < toolbar.items.count; i++) { - if ([toolbar.items[i].itemIdentifier - isEqualToString:NSToolbarFlexibleSpaceItemIdentifier]) { - insertIdx = i; + + // 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; } } - - // Insert backward first so forward ends up to its right: [Bwd][Fwd] - if (!hasBackward) { - [toolbar insertItemWithItemIdentifier:kSFPBBackwardID atIndex:insertIdx]; - insertIdx++; - } - if (!hasForward) { - [toolbar insertItemWithItemIdentifier:kSFPBForwardID atIndex:insertIdx]; + 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 are vertically centred on the segControl. + CGFloat w = 26.0, h = 20.0; + [NSLayoutConstraint activateConstraints:@[ + [fwdBtn.trailingAnchor constraintEqualToAnchor:segControl.leadingAnchor constant:-6.0], + [fwdBtn.centerYAnchor constraintEqualToAnchor:segControl.centerYAnchor], + [fwdBtn.widthAnchor constraintEqualToConstant:w], + [fwdBtn.heightAnchor constraintEqualToConstant:h], + + [bwdBtn.trailingAnchor constraintEqualToAnchor:fwdBtn.leadingAnchor constant:-2.0], + [bwdBtn.centerYAnchor constraintEqualToAnchor:segControl.centerYAnchor], + [bwdBtn.widthAnchor constraintEqualToConstant:w], + [bwdBtn.heightAnchor constraintEqualToConstant:h], + ]]; + if (sSFPBAPI) - sSFPBAPI->log(@"[SFPButtons] Toolbar buttons installed."); + sSFPBAPI->log(@"[SFPButtons] Buttons installed in timeline bar next to scrolling-timeline control."); } @catch (NSException *e) { if (sSFPBAPI) - sSFPBAPI->log(@"[SFPButtons] Exception during toolbar install: %@", e.reason); + sSFPBAPI->log(@"[SFPButtons] Exception installing in timeline bar: %@", e.reason); } } -// Polling fallback: check all windows for a ready toolbar. +// --------------------------------------------------------------------------- +// Polling — wait for the timeline module to be ready +// --------------------------------------------------------------------------- static void SFPB_tryInstall(int attempt); static void SFPB_tryInstall(int attempt) { - if (attempt >= 30) { + 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.0 * NSEC_PER_SEC)), + dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(1.5 * NSEC_PER_SEC)), dispatch_get_main_queue(), ^{ - for (NSWindow *w in [[NSApplication sharedApplication] windows]) { - if (w.toolbar && w.toolbar.items.count > 0) { - SFPB_addButtonsToToolbar(w.toolbar); - return; - } + NSView *bar = SFPB_findTimelineBarView(); + if (bar && bar.subviews.count > 0) { + SFPB_removeFromMainToolbar(); + SFPB_addButtonsToTimelineBar(); + } else { + SFPB_tryInstall(attempt + 1); } - SFPB_tryInstall(attempt + 1); }); } static void SFPB_startInstallation(void) { - // Fast path: notification fires when a window with a toolbar becomes main. - __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; - SFPB_addButtonsToToolbar(win.toolbar); - } - }]; - - // Slow path: poll every second in case the notification already fired - // before we registered, or in case FCP's window isn't main yet. + // 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); } @@ -310,18 +394,14 @@ static void SFPB_startInstallation(void) { // --------------------------------------------------------------------------- __attribute__((visibility("default"))) void SpliceKitPlugin_init(SpliceKitPluginAPI *api) { - // Copy the struct — the caller allocates it on the stack and it's gone - // the moment SpliceKitPlugins_loadNative returns (before our async block runs). sSFPBAPIStorage = *api; sSFPBAPI = &sSFPBAPIStorage; - sSFPBAPI->log(@"[SFPButtons] Loading — will inject Select Forward/Backward toolbar buttons."); + sSFPBAPI->log(@"[SFPButtons] Loading — will inject into timeline bar."); - // All AppKit work must happen on the main thread. api->executeOnMainThreadAsync(^{ - // Install the swizzle immediately — before the notification/polling - // fires — so FCP's toolbar delegate is ready to serve our identifiers - // the moment we call insertItemWithItemIdentifier:atIndex:. + // 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(); }); 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.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."); +} From 3b53e9a8a649942f68888cd7dfb4815c89027a8d Mon Sep 17 00:00:00 2001 From: MICHAEL STATHOPOULOS Date: Wed, 3 Jun 2026 11:49:22 -0700 Subject: [PATCH 07/14] =?UTF-8?q?Add=20Close=20Other=20Libraries=20to=20Fi?= =?UTF-8?q?le=20menu=20with=20=E2=87=A7W=20shortcut?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a new File > Close Other Libraries command that closes all open libraries except the currently focused one (_targetLibrary). Grayed out when only one library is open. Co-Authored-By: Claude Sonnet 4.6 --- Sources/SpliceKit.m | 77 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 77 insertions(+) diff --git a/Sources/SpliceKit.m b/Sources/SpliceKit.m index 02a8c22..ccf620c 100644 --- a/Sources/SpliceKit.m +++ b/Sources/SpliceKit.m @@ -13,11 +13,13 @@ #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 #import #import @@ -462,6 +464,7 @@ - (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; @@ -520,6 +523,40 @@ - (void)toggleUndoHistoryPanel:(id)sender { } } +- (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) { @@ -2040,6 +2077,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; @@ -2832,6 +2877,28 @@ 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)"); @@ -3491,6 +3558,10 @@ static void SpliceKit_appDidLaunch(void) { // 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 @@ -3511,6 +3582,12 @@ 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(); + }); + // 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. From dcf38676e515c3e810ec463cfdd95c35bd1e1cdc Mon Sep 17 00:00:00 2001 From: MICHAEL STATHOPOULOS Date: Wed, 3 Jun 2026 11:52:57 -0700 Subject: [PATCH 08/14] Add SpliceKit Preferences pane to FCP Settings Adds a dedicated SpliceKit tab to FCP's built-in Settings window (alongside the existing Debug pane). Subclasses PEAppDebugPreferencesModule and re-populates _masterPreferenceViews after _setupToolbar so both tabs appear in a single rebuild. Moves FFDontCoalesceGaps into the SpliceKit pane and adds inline descriptions to DebugUI checkbox groups. Co-Authored-By: Claude Sonnet 4.6 --- Sources/SpliceKitDebugUI.m | 92 ++++- Sources/SpliceKitPreferencesPane.h | 8 + Sources/SpliceKitPreferencesPane.m | 613 +++++++++++++++++++++++++++++ 3 files changed, 693 insertions(+), 20 deletions(-) create mode 100644 Sources/SpliceKitPreferencesPane.h create mode 100644 Sources/SpliceKitPreferencesPane.m 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..3881349 --- /dev/null +++ b/Sources/SpliceKitPreferencesPane.m @@ -0,0 +1,613 @@ +// +// 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 "SpliceKit.h" +#import +#import +#import + +// --------------------------------------------------------------------------- +#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"; + +@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); +} + +@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)]; + + // Default Spatial Conform — popup row + { + NSStackView *row = [NSStackView stackViewWithViews:@[]]; + row.orientation = NSUserInterfaceLayoutOrientationHorizontal; + row.alignment = NSLayoutAttributeCenterY; + row.spacing = 8; + row.translatesAutoresizingMaskIntoConstraints = NO; + + NSTextField *lbl = [NSTextField labelWithString:@"Default New Clip 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 *current = SpliceKit_getDefaultSpatialConformType(); + if ([current isEqualToString:@"fill"]) [popup selectItemAtIndex:1]; + else if ([current isEqualToString:@"none"]) [popup selectItemAtIndex:2]; + else [popup selectItemAtIndex:0]; + + popup.target = [SKPrefsController shared]; + popup.action = @selector(conformPopupChanged:); + popup.translatesAutoresizingMaskIntoConstraints = NO; + [row addArrangedSubview:popup]; + + NSStackView *conformCol = [NSStackView stackViewWithViews:@[]]; + conformCol.orientation = NSUserInterfaceLayoutOrientationVertical; + conformCol.alignment = NSLayoutAttributeLeading; + conformCol.spacing = 2; + conformCol.translatesAutoresizingMaskIntoConstraints = NO; + [conformCol addArrangedSubview:row]; + [conformCol addArrangedSubview:SKPrefs_makeNote( + @"Spatial conform applied to new clips dropped onto the timeline: " + @"Fit (letterbox), Fill (crop to frame), or None (native resolution).", + kNoteWidth)]; + [root addArrangedSubview:conformCol]; + } + + [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)]; + + 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:"); +} From fd627d30ad84ac0ff142cea618eec51d75d980cd Mon Sep 17 00:00:00 2001 From: MICHAEL STATHOPOULOS Date: Wed, 3 Jun 2026 11:53:16 -0700 Subject: [PATCH 09/14] Add Timeline Tabs: named project tabs above the timeline MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a persistent tab bar between the viewer and timeline decks showing all open projects as clickable tabs. Uses LKContainerView (flipped coords), matches tabs by pointer→UID→name to avoid restart duplicates, and persists state to timeline_tabs.json. Co-Authored-By: Claude Sonnet 4.6 --- Sources/SOURCES.txt | 2 + Sources/SpliceKitTimelineTabs.h | 25 ++ Sources/SpliceKitTimelineTabs.m | 759 ++++++++++++++++++++++++++++++++ 3 files changed, 786 insertions(+) create mode 100644 Sources/SpliceKitTimelineTabs.h create mode 100644 Sources/SpliceKitTimelineTabs.m diff --git a/Sources/SOURCES.txt b/Sources/SOURCES.txt index b07f35d..1dd961f 100644 --- a/Sources/SOURCES.txt +++ b/Sources/SOURCES.txt @@ -29,6 +29,7 @@ SpliceKitCaptionPanel.m SpliceKitUndoHistoryPanel.m SpliceKitCommandPalette.m SpliceKitDebugUI.m +SpliceKitPreferencesPane.m SpliceKitStructureBlocks.m SpliceKitSectionsBar.m SpliceKitTimelineOverview.m @@ -41,6 +42,7 @@ SpliceKitReplaceAtPlayhead.m SpliceKitClipLock.m SpliceKitTimelineInteractionSuspend.m SpliceKitTimelinePlayheadOverlay.m +SpliceKitTimelineTabs.m SpliceKitTimelinePerfMode.m SpliceKitURLImport.m SpliceKitLiveCam.m 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; +} From 8913c189f458d3784a053a1419739de5ebbc0bea Mon Sep 17 00:00:00 2001 From: MICHAEL STATHOPOULOS Date: Wed, 3 Jun 2026 15:34:46 -0700 Subject: [PATCH 10/14] =?UTF-8?q?Add=20Replace=20and=20Retain=20Attributes?= =?UTF-8?q?:=20replace=20clip=20keeping=20original=20effects=20(=E2=8C=A5?= =?UTF-8?q?=E2=87=A7=E2=8C=98R)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New command registered as LKCommand "SKReplaceRetainingAttributes" bound to ⌥⇧⌘R, appearing in the Command Editor under the Editing group alongside the other Replace variants (Replace at Playhead, Replace From Start, etc.). 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: 1. select clip at playhead → copy: on the timeline module directly (sendAction:to:nil routes to the browser when it has focus, so we target the timeline module via SpliceKit_getActiveTimelineModule) 2. replaceWithSelectedMediaAtPlayhead: via sendAction (uses a private named pasteboard — does not disturb copy:'s data) 3. dispatch_async one runloop cycle to let the timeline model settle 4. selectClipAtPlayhead: + pasteAllAttributes: on the timeline module ("Edit > Paste Effects" fires pasteAllAttributes:, not pasteEffects:) Co-Authored-By: Claude Sonnet 4.6 --- Sources/SpliceKitReplaceAtPlayhead.m | 130 ++++++++++++++++++++++++++- 1 file changed, 129 insertions(+), 1 deletion(-) diff --git a/Sources/SpliceKitReplaceAtPlayhead.m b/Sources/SpliceKitReplaceAtPlayhead.m index 0dbd4ea..4f42f28 100644 --- a/Sources/SpliceKitReplaceAtPlayhead.m +++ b/Sources/SpliceKitReplaceAtPlayhead.m @@ -464,6 +464,10 @@ static void SpliceKit_applyReplaceAtPlayheadCommand(id controller, id commandSet } } +// 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); @@ -471,9 +475,11 @@ 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 applyReplaceAtPlayheadCommand re-registers after the reload. + // 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. @@ -485,6 +491,124 @@ static void SpliceKit_setActiveCommandSet(id self, SEL _cmd, id 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: // --------------------------------------------------------------------------- @@ -954,8 +1078,12 @@ void SpliceKit_installReplaceAtPlayhead(void) { 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(); }); } From a9d3779977d9d508933844407f5ca8ce52cf02c0 Mon Sep 17 00:00:00 2001 From: MICHAEL STATHOPOULOS Date: Thu, 4 Jun 2026 11:09:11 -0700 Subject: [PATCH 11/14] Add Transition Auditions plugin: tournament-style audition with confirm/favorites exit MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New plugin for cycling through transitions at an edit point via a timeline bar button. Features a tournament bracket (👍 Add / 👎 Reject) that narrows a full or custom set down to a winner. Any point during the audition, two exit buttons let the user commit the current transition immediately: - "Confirm Transition and Exit" — keeps the applied transition, stops playback - "Add to Favorites and Exit" — same, plus adds to FCP Favorites and the plugin's Custom Set Co-Authored-By: Claude Sonnet 4.6 --- .../Makefile | 42 + .../plugin.json | 13 + .../src/TransitionAuditions.m | 1584 +++++++++++++++++ 3 files changed, 1639 insertions(+) create mode 100644 examples/plugins/com.splicekit.transition-auditions/Makefile create mode 100644 examples/plugins/com.splicekit.transition-auditions/plugin.json create mode 100644 examples/plugins/com.splicekit.transition-auditions/src/TransitionAuditions.m 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."); +} From 51a93830496f415a050a8be0a95c0fb58b8e0822 Mon Sep 17 00:00:00 2001 From: MICHAEL STATHOPOULOS Date: Thu, 4 Jun 2026 19:57:40 -0700 Subject: [PATCH 12/14] Add WaveformExpand: audio waveform fills clip when thumbnails hidden MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Swizzles PETimelineItemLayer.updateAppearance: — when wantsFilmstripLayer returns NO (Change Appearance → no thumbnails), expands _audioContentsLayer to fill the full clip bounds instead of leaving the filmstrip area vacant. Co-Authored-By: Claude Sonnet 4.6 --- Sources/SOURCES.txt | 1 + Sources/SpliceKit.h | 3 ++ Sources/SpliceKit.m | 3 ++ Sources/SpliceKitWaveformExpand.m | 60 +++++++++++++++++++++++++++++++ 4 files changed, 67 insertions(+) create mode 100644 Sources/SpliceKitWaveformExpand.m diff --git a/Sources/SOURCES.txt b/Sources/SOURCES.txt index 1dd961f..a310432 100644 --- a/Sources/SOURCES.txt +++ b/Sources/SOURCES.txt @@ -44,6 +44,7 @@ SpliceKitTimelineInteractionSuspend.m SpliceKitTimelinePlayheadOverlay.m SpliceKitTimelineTabs.m SpliceKitTimelinePerfMode.m +SpliceKitWaveformExpand.m SpliceKitURLImport.m SpliceKitLiveCam.m SpliceKitVisionPro.m diff --git a/Sources/SpliceKit.h b/Sources/SpliceKit.h index 7281042..b95adc7 100644 --- a/Sources/SpliceKit.h +++ b/Sources/SpliceKit.h @@ -220,6 +220,9 @@ 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, diff --git a/Sources/SpliceKit.m b/Sources/SpliceKit.m index ccf620c..ef1d2e8 100644 --- a/Sources/SpliceKit.m +++ b/Sources/SpliceKit.m @@ -3509,6 +3509,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()) { diff --git a/Sources/SpliceKitWaveformExpand.m b/Sources/SpliceKitWaveformExpand.m new file mode 100644 index 0000000..e4f6caf --- /dev/null +++ b/Sources/SpliceKitWaveformExpand.m @@ -0,0 +1,60 @@ +// +// SpliceKitWaveformExpand.m +// +// When video thumbnails are hidden via FCP's "Change Appearance" button, +// the filmstrip area goes vacant. This swizzle expands the audio waveform +// layer to fill the full clip height instead of occupying only a thin strip. +// +// Hook: PETimelineItemLayer +// -updateAppearance:(uint64_t)flags +// +// PETimelineItemLayer overrides this to apply an FFTimelineItemAppearance +// to the clip's sublayers. After the original runs, if the clip is not +// showing a filmstrip (wantsFilmstripLayer == NO), we expand the audio +// contents layer to cover the full clip bounds. +// + +#import "SpliceKit.h" +#import +#import + +typedef void (*UpdateAppearanceFlagsFn)(id, SEL, uint64_t); +static UpdateAppearanceFlagsFn sOrigUpdateAppearanceFlags = NULL; + +static void WE_expandWaveformIfNeeded(id self) { + // Only act when the filmstrip is hidden (Change Appearance → no thumbnails, + // or a purely audio clip with no video to show). + typedef BOOL (*BoolImpFn)(id, SEL); + BOOL wantsFilmstrip = ((BoolImpFn)objc_msgSend)(self, @selector(wantsFilmstripLayer)); + if (wantsFilmstrip) return; + + // Grab the audio contents layer via KVC — this is _audioContentsLayer (TLKFilmstripLayer). + CALayer *audioLayer = [self valueForKey:@"_audioContentsLayer"]; + if (!audioLayer) return; + + // Expand it to fill the full clip layer bounds. + CGRect bounds = ((CALayer *)self).bounds; + if (CGRectIsEmpty(bounds)) return; + + audioLayer.frame = bounds; +} + +static void WE_updateAppearanceFlags(id self, SEL _cmd, uint64_t flags) { + sOrigUpdateAppearanceFlags(self, _cmd, flags); + WE_expandWaveformIfNeeded(self); +} + +void SpliceKit_installWaveformExpand(void) { + Class cls = NSClassFromString(@"PETimelineItemLayer"); + if (!cls) { + SpliceKit_log(@"[WaveformExpand] PETimelineItemLayer not found"); + return; + } + + SEL sel = NSSelectorFromString(@"updateAppearance:"); + IMP orig = SpliceKit_swizzleMethod(cls, sel, (IMP)WE_updateAppearanceFlags); + if (orig) { + sOrigUpdateAppearanceFlags = (UpdateAppearanceFlagsFn)orig; + SpliceKit_log(@"[WaveformExpand] installed"); + } +} From d676cba565fd3029f298e9938ce1e25ff7db8f29 Mon Sep 17 00:00:00 2001 From: stopedog <111427236+stopedog@users.noreply.github.com> Date: Mon, 8 Jun 2026 00:39:55 -0700 Subject: [PATCH 13/14] Add customizable shortcut buttons in timeline toolbar flex gap MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Injects a SpliceKitShortcutsBar into the Auto Layout flex gap between the history-forward button (PEEditorMenuDelayButton) and the snapping/skimming control (LKPaneCapSegmentedControl) in FCP's timeline toolbar bar. - 44 assignable actions across 8 categories (Edit, Markers, Navigation, Color, Rating, Speed, Trim, View) with SF Symbol icons and tooltips - Config persisted to ~/Library/Application Support/SpliceKit/timecode_bar_shortcuts.json - Hot-reload: SpliceKit_setTimecodeBarConfig() swaps buttons live without restart - Preferences pane section lets users toggle buttons via checkboxes grouped by category - Constraint search fix: Auto Layout normalizes H:[A]-(>=N)-[B] so firstItem=B, not A — corrected SCB_findFlexConstraint to match actual storage order - Robust reload: uses shortcutsBar.superview as the sole install-state indicator; retries on both toolbar-not-found AND constraint-not-found paths - Right-anchor fix: SCB_findRightAnchor() walks the trailing/leading == chain leftward from snappingCtrl to find the leftmost existing pinned button (transition audition + clip buttons), preventing our bar from overlapping them Co-Authored-By: Claude Sonnet 4.6 --- Sources/SOURCES.txt | 1 + Sources/SpliceKit.m | 6 + Sources/SpliceKitPreferencesPane.m | 124 ++++++ Sources/SpliceKitTimecodeBarShortcuts.h | 31 ++ Sources/SpliceKitTimecodeBarShortcuts.m | 508 ++++++++++++++++++++++++ 5 files changed, 670 insertions(+) create mode 100644 Sources/SpliceKitTimecodeBarShortcuts.h create mode 100644 Sources/SpliceKitTimecodeBarShortcuts.m diff --git a/Sources/SOURCES.txt b/Sources/SOURCES.txt index a310432..ee57964 100644 --- a/Sources/SOURCES.txt +++ b/Sources/SOURCES.txt @@ -43,6 +43,7 @@ SpliceKitClipLock.m SpliceKitTimelineInteractionSuspend.m SpliceKitTimelinePlayheadOverlay.m SpliceKitTimelineTabs.m +SpliceKitTimecodeBarShortcuts.m SpliceKitTimelinePerfMode.m SpliceKitWaveformExpand.m SpliceKitURLImport.m diff --git a/Sources/SpliceKit.m b/Sources/SpliceKit.m index ef1d2e8..836b20f 100644 --- a/Sources/SpliceKit.m +++ b/Sources/SpliceKit.m @@ -20,6 +20,7 @@ #import "SpliceKitImmersivePreviewPanel.h" #import "SpliceKitUndoHistoryPanel.h" #import "SpliceKitTimelineTabs.h" +#import "SpliceKitTimecodeBarShortcuts.h" #import #import #import @@ -3591,6 +3592,11 @@ static void SpliceKit_appDidLaunch(void) { 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. diff --git a/Sources/SpliceKitPreferencesPane.m b/Sources/SpliceKitPreferencesPane.m index 3881349..a0ae1dd 100644 --- a/Sources/SpliceKitPreferencesPane.m +++ b/Sources/SpliceKitPreferencesPane.m @@ -9,6 +9,7 @@ // #import "SpliceKitPreferencesPane.h" +#import "SpliceKitTimecodeBarShortcuts.h" #import "SpliceKit.h" #import #import @@ -129,6 +130,34 @@ - (void)conformPopupChanged:(NSPopUpButton *)popup { 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 // --------------------------------------------------------------------------- @@ -315,6 +344,101 @@ - (void)conformPopupChanged:(NSPopUpButton *)popup { @"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; } 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(); +} From f39374b03d023935ade51ed7808f452c303226eb Mon Sep 17 00:00:00 2001 From: stopedog <111427236+stopedog@users.noreply.github.com> Date: Mon, 15 Jun 2026 20:11:53 -0700 Subject: [PATCH 14/14] Add FCP Defaults preferences; root-cause fix for WaveformExpand; widen playhead-select buttons MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit FCP Defaults (PreferencesPane.m, Server.m): - Persistent answers for FCP's "not enough media" transition dialog (ask / freeze frames / ripple trim), plus default clip height, audio channel config, and audio pan mode — settable via set_bridge_option_value or the new Preferences pane section, applied automatically so the dialog never interrupts the workflow. WaveformExpand: - Swizzles FFFilmstripCell.audioHeight instead of PETimelineItemLayer.updateAppearance:. Root cause: the -1 sentinel ("use appearance default") was being drawn literally as 28px even though the container expanded to 96px. Now returns the cell's actual layer height when thumbnails are hidden. SelectFromPlayheadButtons (example plugin): - Bumped icon point size 11->19 and switched button height to track the segmented control's height anchor instead of a fixed 20pt. Co-Authored-By: Claude Sonnet 4.6 --- Sources/SpliceKitPreferencesPane.m | 263 +++++++++++++++-- Sources/SpliceKitServer.m | 264 ++++++++++++++++-- Sources/SpliceKitWaveformExpand.m | 66 ++--- .../src/SelectFromPlayheadButtons.m | 10 +- 4 files changed, 525 insertions(+), 78 deletions(-) diff --git a/Sources/SpliceKitPreferencesPane.m b/Sources/SpliceKitPreferencesPane.m index a0ae1dd..cacd4c8 100644 --- a/Sources/SpliceKitPreferencesPane.m +++ b/Sources/SpliceKitPreferencesPane.m @@ -15,6 +15,16 @@ #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 @@ -67,6 +77,12 @@ - (BOOL)moduleCanBeRemoved { return NO; } 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; @@ -130,6 +146,48 @@ - (void)conformPopupChanged:(NSPopUpButton *)popup { 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); @@ -269,15 +327,191 @@ - (void)timecodeBarShortcutToggled:(NSButton *)sender { @"camera, memory card, or external drive is connected.", SpliceKit_isSuppressAutoImportEnabled(), kNoteWidth)]; - // Default Spatial Conform — popup row + [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 New Clip Conform:"]; + 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]; @@ -285,28 +519,21 @@ - (void)timecodeBarShortcutToggled:(NSButton *)sender { [popup addItemWithTitle:@"Fit (Default)"]; [popup addItemWithTitle:@"Fill"]; [popup addItemWithTitle:@"None"]; - - NSString *current = SpliceKit_getDefaultSpatialConformType(); - if ([current isEqualToString:@"fill"]) [popup selectItemAtIndex:1]; - else if ([current isEqualToString:@"none"]) [popup selectItemAtIndex:2]; + 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(conformPopupChanged:); + popup.action = @selector(spatialConformPopupChangedInFCPDefaults:); popup.translatesAutoresizingMaskIntoConstraints = NO; [row addArrangedSubview:popup]; - - NSStackView *conformCol = [NSStackView stackViewWithViews:@[]]; - conformCol.orientation = NSUserInterfaceLayoutOrientationVertical; - conformCol.alignment = NSLayoutAttributeLeading; - conformCol.spacing = 2; - conformCol.translatesAutoresizingMaskIntoConstraints = NO; - [conformCol addArrangedSubview:row]; - [conformCol addArrangedSubview:SKPrefs_makeNote( + [col addArrangedSubview:row]; + [col addArrangedSubview:SKPrefs_makeNote( @"Spatial conform applied to new clips dropped onto the timeline: " - @"Fit (letterbox), Fill (crop to frame), or None (native resolution).", + @"Fit (letterbox/pillarbox), Fill (crop to fill frame), or None (native resolution).", kNoteWidth)]; - [root addArrangedSubview:conformCol]; + [root addArrangedSubview:col]; } [root addArrangedSubview:SKPrefs_makeSep()]; diff --git a/Sources/SpliceKitServer.m b/Sources/SpliceKitServer.m index b8cfd84..f1d556c 100644 --- a/Sources/SpliceKitServer.m +++ b/Sources/SpliceKitServer.m @@ -9680,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()), @@ -9690,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()), @@ -9815,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]}; @@ -9864,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); } @@ -10905,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; + } + // 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 2; + return overlapType; } static NSModalResponse SpliceKit_swizzled_NSAlert_runModal(id self, SEL _cmd) { @@ -11220,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"); @@ -13286,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/SpliceKitWaveformExpand.m b/Sources/SpliceKitWaveformExpand.m index e4f6caf..e8e4826 100644 --- a/Sources/SpliceKitWaveformExpand.m +++ b/Sources/SpliceKitWaveformExpand.m @@ -2,59 +2,51 @@ // SpliceKitWaveformExpand.m // // When video thumbnails are hidden via FCP's "Change Appearance" button, -// the filmstrip area goes vacant. This swizzle expands the audio waveform -// layer to fill the full clip height instead of occupying only a thin strip. +// the filmstrip area goes vacant. This swizzle makes the audio waveform +// fill the full clip height including the space where the filmstrip was. // -// Hook: PETimelineItemLayer -// -updateAppearance:(uint64_t)flags +// 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. // -// PETimelineItemLayer overrides this to apply an FFTimelineItemAppearance -// to the clip's sublayers. After the original runs, if the clip is not -// showing a filmstrip (wantsFilmstripLayer == NO), we expand the audio -// contents layer to cover the full clip bounds. +// 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 void (*UpdateAppearanceFlagsFn)(id, SEL, uint64_t); -static UpdateAppearanceFlagsFn sOrigUpdateAppearanceFlags = NULL; +typedef double (*AudioHeightGetterFn)(id, SEL); +static AudioHeightGetterFn sOrigAudioHeight = NULL; -static void WE_expandWaveformIfNeeded(id self) { - // Only act when the filmstrip is hidden (Change Appearance → no thumbnails, - // or a purely audio clip with no video to show). - typedef BOOL (*BoolImpFn)(id, SEL); - BOOL wantsFilmstrip = ((BoolImpFn)objc_msgSend)(self, @selector(wantsFilmstripLayer)); - if (wantsFilmstrip) return; +static double WE_audioHeight(id self, SEL _cmd) { + double h = sOrigAudioHeight(self, _cmd); + if (h >= 0) return h; // explicit value — honour it - // Grab the audio contents layer via KVC — this is _audioContentsLayer (TLKFilmstripLayer). - CALayer *audioLayer = [self valueForKey:@"_audioContentsLayer"]; - if (!audioLayer) return; - - // Expand it to fill the full clip layer bounds. - CGRect bounds = ((CALayer *)self).bounds; - if (CGRectIsEmpty(bounds)) return; - - audioLayer.frame = bounds; -} - -static void WE_updateAppearanceFlags(id self, SEL _cmd, uint64_t flags) { - sOrigUpdateAppearanceFlags(self, _cmd, flags); - WE_expandWaveformIfNeeded(self); + // 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 cls = NSClassFromString(@"PETimelineItemLayer"); - if (!cls) { - SpliceKit_log(@"[WaveformExpand] PETimelineItemLayer not found"); + Class cellCls = NSClassFromString(@"FFFilmstripCell"); + if (!cellCls) { + SpliceKit_log(@"[WaveformExpand] FFFilmstripCell not found"); return; } - - SEL sel = NSSelectorFromString(@"updateAppearance:"); - IMP orig = SpliceKit_swizzleMethod(cls, sel, (IMP)WE_updateAppearanceFlags); + SEL sel = NSSelectorFromString(@"audioHeight"); + IMP orig = SpliceKit_swizzleMethod(cellCls, sel, (IMP)WE_audioHeight); if (orig) { - sOrigUpdateAppearanceFlags = (UpdateAppearanceFlagsFn)orig; + sOrigAudioHeight = (AudioHeightGetterFn)orig; SpliceKit_log(@"[WaveformExpand] installed"); } } 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 index 7c57823..6fe9c68 100644 --- a/examples/plugins/com.splicekit.select-from-playhead-buttons/src/SelectFromPlayheadButtons.m +++ b/examples/plugins/com.splicekit.select-from-playhead-buttons/src/SelectFromPlayheadButtons.m @@ -138,7 +138,7 @@ - (void)_selectClips:(BOOL)forward { NSImage *img = [NSImage imageWithSystemSymbolName:symbolName accessibilityDescription:description]; NSImageSymbolConfiguration *cfg = [NSImageSymbolConfiguration - configurationWithPointSize:11 weight:NSFontWeightMedium]; + configurationWithPointSize:19 weight:NSFontWeightMedium]; return img ? [img imageWithSymbolConfiguration:cfg] : nil; } @@ -326,18 +326,18 @@ static void SFPB_addButtonsToTimelineBar(void) { // fwdBtn: trailing edge touches segControl leading edge with a 6pt gap. // bwdBtn: trailing edge touches fwdBtn leading edge with a 2pt gap. - // Both are vertically centred on the segControl. - CGFloat w = 26.0, h = 20.0; + // 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 constraintEqualToConstant:h], + [fwdBtn.heightAnchor constraintEqualToAnchor:segControl.heightAnchor], [bwdBtn.trailingAnchor constraintEqualToAnchor:fwdBtn.leadingAnchor constant:-2.0], [bwdBtn.centerYAnchor constraintEqualToAnchor:segControl.centerYAnchor], [bwdBtn.widthAnchor constraintEqualToConstant:w], - [bwdBtn.heightAnchor constraintEqualToConstant:h], + [bwdBtn.heightAnchor constraintEqualToAnchor:segControl.heightAnchor], ]]; if (sSFPBAPI)