From cd363585798abd08fd82a7f8294ab310e4c3b223 Mon Sep 17 00:00:00 2001 From: jet3004 <106498013+jet3004@users.noreply.github.com> Date: Sat, 11 Apr 2026 13:39:51 -0700 Subject: [PATCH] Add URL import workflow for direct media and YouTube ingest --- Makefile | 24 +- Sources/SpliceKitCommandPalette.m | 327 +++++- Sources/SpliceKitLua.m | 60 ++ Sources/SpliceKitServer.m | 553 +++++++++- Sources/SpliceKitURLImport.h | 16 + Sources/SpliceKitURLImport.m | 1673 +++++++++++++++++++++++++++++ mcp/server.py | 49 + tests/test_mcp_endpoints.py | 10 + 8 files changed, 2657 insertions(+), 55 deletions(-) create mode 100644 Sources/SpliceKitURLImport.h create mode 100644 Sources/SpliceKitURLImport.m diff --git a/Makefile b/Makefile index 198f2e3..ab7d66e 100644 --- a/Makefile +++ b/Makefile @@ -2,7 +2,8 @@ CC = clang ARCHS = -arch arm64 -arch x86_64 MIN_VERSION = -mmacosx-version-min=14.0 FRAMEWORKS = -framework Foundation -framework AppKit -framework AVFoundation -framework CoreServices -OBJC_FLAGS = -fobjc-arc -fmodules +MODULE_CACHE_DIR = $(BUILD_DIR)/ModuleCache +OBJC_FLAGS = -fobjc-arc -fmodules -fmodules-cache-path=$(abspath $(MODULE_CACHE_DIR)) LINKER_FLAGS = -undefined dynamic_lookup -dynamiclib INSTALL_NAME = -install_name @rpath/SpliceKit.framework/Versions/A/SpliceKit @@ -10,6 +11,7 @@ SOURCES = Sources/SpliceKit.m \ Sources/SpliceKitRuntime.m \ Sources/SpliceKitSwizzle.m \ Sources/SpliceKitServer.m \ + Sources/SpliceKitURLImport.m \ Sources/SpliceKitLogPanel.m \ Sources/SpliceKitTranscriptPanel.m \ Sources/SpliceKitCaptionPanel.m \ @@ -37,12 +39,29 @@ ENTITLEMENTS = entitlements.plist SILENCE_DETECTOR = $(BUILD_DIR)/silence-detector TOOLS_DIR = $(HOME)/Applications/SpliceKit/tools -.PHONY: all clean deploy launch tools +.PHONY: all clean deploy launch tools url-import-tools all: $(OUTPUT) tools: $(SILENCE_DETECTOR) +url-import-tools: + @mkdir -p "$(TOOLS_DIR)" + @YTDLP_PATH="$$(command -v yt-dlp || true)"; \ + if [ -n "$$YTDLP_PATH" ]; then \ + ln -sf "$$YTDLP_PATH" "$(TOOLS_DIR)/yt-dlp"; \ + echo "Linked yt-dlp -> $$YTDLP_PATH"; \ + else \ + echo "yt-dlp not found in PATH. Install with: brew install yt-dlp"; \ + fi + @FFMPEG_PATH="$$(command -v ffmpeg || true)"; \ + if [ -n "$$FFMPEG_PATH" ]; then \ + ln -sf "$$FFMPEG_PATH" "$(TOOLS_DIR)/ffmpeg"; \ + echo "Linked ffmpeg -> $$FFMPEG_PATH"; \ + else \ + echo "ffmpeg not found in PATH. Install with: brew install ffmpeg"; \ + fi + $(SILENCE_DETECTOR): tools/silence-detector.swift @mkdir -p $(BUILD_DIR) swiftc -O -suppress-warnings -o $(SILENCE_DETECTOR) tools/silence-detector.swift @@ -84,6 +103,7 @@ deploy: $(OUTPUT) $(SILENCE_DETECTOR) @/usr/libexec/PlistBuddy -c "Add :NSSpeechRecognitionUsageDescription string 'SpliceKit uses speech recognition to transcribe timeline audio for text-based editing.'" "$(MODDED_APP)/Contents/Info.plist" 2>/dev/null || true @# Deploy tools @mkdir -p "$(TOOLS_DIR)" + @$(MAKE) url-import-tools @cp $(SILENCE_DETECTOR) "$(TOOLS_DIR)/silence-detector" 2>/dev/null || true @test -f tools/parakeet-transcriber/.build/release/parakeet-transcriber && \ cp tools/parakeet-transcriber/.build/release/parakeet-transcriber "$(TOOLS_DIR)/parakeet-transcriber" || true diff --git a/Sources/SpliceKitCommandPalette.m b/Sources/SpliceKitCommandPalette.m index bc3d095..a415269 100644 --- a/Sources/SpliceKitCommandPalette.m +++ b/Sources/SpliceKitCommandPalette.m @@ -14,6 +14,7 @@ #import "SpliceKitCommandPalette.h" #import "SpliceKit.h" +#import "SpliceKitURLImport.h" #import #import #import @@ -588,6 +589,8 @@ - (void)registerCommands { add(@"New Project", @"newProject", @"timeline", SpliceKitCommandCategoryExport, @"Project", @"Cmd+N", @"Create a new project in the current event", @[@"new timeline"]); add(@"New Event", @"newEvent", @"timeline", SpliceKitCommandCategoryExport, @"Project", nil, @"Create a new event in the library", @[]); add(@"Import Media", @"importMedia", @"timeline", SpliceKitCommandCategoryExport, @"Project", @"Cmd+I", @"Open the import media dialog", @[@"add files", @"ingest"]); + add(@"Import URL to Library", @"import_only", @"url_import_prompt", SpliceKitCommandCategoryExport, @"Project", nil, @"Download a remote video URL and import it into the current library/event", @[@"import from url", @"download url", @"web video", @"remote media", @"youtube url", @"vimeo url"]); + add(@"Import URL to Timeline", @"insert_at_playhead", @"url_import_prompt", SpliceKitCommandCategoryExport, @"Project", nil, @"Download a remote video URL, import it, and place it in the active timeline", @[@"add url to timeline", @"download and insert", @"append url"]); add(@"Show Project Properties", @"showProjectProperties", @"timeline", SpliceKitCommandCategoryExport, @"Project", nil, @"View resolution, frame rate, and codec settings", @[@"settings", @"format"]); add(@"Consolidate Library Media", @"consolidateMedia", @"timeline", SpliceKitCommandCategoryExport, @"Project", nil, @"Copy external media into the library", @[@"collect", @"gather"]); @@ -1558,6 +1561,9 @@ - (NSDictionary *)executeCommand:(NSString *)action type:(NSString *)type { SpliceKit_setDefaultSpatialConformType(next); result = @{@"action": action, @"status": @"ok", @"defaultSpatialConformType": next}; + } else if ([type isEqualToString:@"url_import_prompt"]) { + [self showURLImportPromptWithDefaultMode:action]; + result = @{@"action": action, @"status": @"started"}; } if (!result) { @@ -1584,7 +1590,7 @@ - (NSPanel *)showProcessingHUD:(NSString *)message { } - (NSPanel *)_createProcessingHUD:(NSString *)message { - NSPanel *hud = [[NSPanel alloc] initWithContentRect:NSMakeRect(0, 0, 280, 80) + NSPanel *hud = [[NSPanel alloc] initWithContentRect:NSMakeRect(0, 0, 520, 118) styleMask:(NSWindowStyleMaskTitled | NSWindowStyleMaskFullSizeContentView) backing:NSBackingStoreBuffered defer:NO]; hud.title = @""; @@ -1605,22 +1611,75 @@ - (NSPanel *)_createProcessingHUD:(NSString *)message { bg.layer.masksToBounds = YES; [hud.contentView addSubview:bg]; - NSProgressIndicator *spinner = [[NSProgressIndicator alloc] initWithFrame:NSMakeRect(20, 28, 24, 24)]; + NSProgressIndicator *spinner = [[NSProgressIndicator alloc] initWithFrame:NSMakeRect(20, 58, 24, 24)]; spinner.style = NSProgressIndicatorStyleSpinning; spinner.controlSize = NSControlSizeRegular; [spinner startAnimation:nil]; [bg addSubview:spinner]; - NSTextField *label = [NSTextField labelWithString:message]; - label.frame = NSMakeRect(52, 28, 210, 24); + NSTextField *label = [NSTextField wrappingLabelWithString:message ?: @"Working..."]; + label.frame = NSMakeRect(52, 42, 446, 40); label.font = [NSFont systemFontOfSize:13 weight:NSFontWeightMedium]; label.textColor = [NSColor labelColor]; + label.maximumNumberOfLines = 2; + label.lineBreakMode = NSLineBreakByWordWrapping; [bg addSubview:label]; + objc_setAssociatedObject(hud, "processingLabel", label, OBJC_ASSOCIATION_RETAIN_NONATOMIC); [hud makeKeyAndOrderFront:nil]; return hud; } +- (void)updateProcessingHUD:(NSPanel *)hud message:(NSString *)message { + if (!hud) return; + dispatch_async(dispatch_get_main_queue(), ^{ + NSTextField *label = objc_getAssociatedObject(hud, "processingLabel"); + if (label) { + label.stringValue = message ?: @"Working..."; + [label sizeToFit]; + NSRect frame = label.frame; + frame.origin.x = 52; + frame.origin.y = 42; + frame.size.width = 446; + frame.size.height = MIN(MAX(frame.size.height, 20), 40); + label.frame = frame; + } + }); +} + +- (void)attachURLImportCancelButtonToHUD:(NSPanel *)hud jobID:(NSString *)jobID { + if (!hud || jobID.length == 0) return; + dispatch_async(dispatch_get_main_queue(), ^{ + if (objc_getAssociatedObject(hud, "urlImportCancelButton")) return; + + NSVisualEffectView *bg = hud.contentView.subviews.firstObject; + if (![bg isKindOfClass:[NSVisualEffectView class]]) return; + + objc_setAssociatedObject(hud, "urlImportJobID", jobID, OBJC_ASSOCIATION_COPY_NONATOMIC); + + NSButton *cancelButton = [NSButton buttonWithTitle:@"Cancel Import" + target:self + action:@selector(handleURLImportCancelButton:)]; + cancelButton.frame = NSMakeRect(388, 14, 112, 30); + cancelButton.bezelStyle = NSBezelStyleRounded; + [bg addSubview:cancelButton]; + objc_setAssociatedObject(hud, "urlImportCancelButton", cancelButton, OBJC_ASSOCIATION_RETAIN_NONATOMIC); + }); +} + +- (void)handleURLImportCancelButton:(NSButton *)sender { + NSPanel *hud = (NSPanel *)sender.window; + NSString *jobID = objc_getAssociatedObject(hud, "urlImportJobID"); + if (jobID.length == 0) return; + + NSDictionary *cancelResult = SpliceKitURLImport_cancel(@{@"job_id": jobID}); + sender.enabled = NO; + NSString *message = [cancelResult[@"state"] isEqualToString:@"cancelled"] + ? @"Cancelling URL import..." + : @"Cancel request sent..."; + [self updateProcessingHUD:hud message:message]; +} + - (void)dismissProcessingHUD:(NSPanel *)hud { if (!hud) return; dispatch_async(dispatch_get_main_queue(), ^{ @@ -1628,6 +1687,236 @@ - (void)dismissProcessingHUD:(NSPanel *)hud { }); } +- (void)showSimpleAlertWithTitle:(NSString *)title message:(NSString *)message { + dispatch_async(dispatch_get_main_queue(), ^{ + NSAlert *alert = [[NSAlert alloc] init]; + alert.messageText = title ?: @"SpliceKit"; + alert.informativeText = message ?: @""; + [alert addButtonWithTitle:@"OK"]; + [alert runModal]; + }); +} + +- (NSString *)selectedURLImportModeForToggle:(NSButton *)timelineToggle + popup:(NSPopUpButton *)popup { + if (timelineToggle.state != NSControlStateValueOn) { + return @"import_only"; + } + switch (popup.indexOfSelectedItem) { + case 1: return @"insert_at_timeline_start"; + case 2: return @"append_to_timeline"; + default: return @"insert_at_playhead"; + } +} + +- (void)syncURLImportTimelineControls:(NSButton *)timelineToggle { + NSTextField *label = objc_getAssociatedObject(timelineToggle, "urlImportTimelineLabel"); + NSPopUpButton *popup = objc_getAssociatedObject(timelineToggle, "urlImportTimelinePopup"); + BOOL enabled = (timelineToggle.state == NSControlStateValueOn); + popup.enabled = enabled; + label.enabled = enabled; + label.textColor = enabled ? [NSColor labelColor] : [NSColor secondaryLabelColor]; +} + +- (void)handleURLImportTimelineToggle:(NSButton *)sender { + [self syncURLImportTimelineControls:sender]; +} + +- (void)showURLImportPromptWithDefaultMode:(NSString *)defaultMode { + dispatch_async(dispatch_get_main_queue(), ^{ + BOOL defaultsToTimeline = ![defaultMode isEqualToString:@"import_only"]; + NSString *promptTitle = defaultsToTimeline + ? @"Import URL to Timeline" + : @"Import URL to Library"; + NSString *primaryButtonTitle = defaultsToTimeline + ? @"Add to Timeline" + : @"Import to Library"; + NSString *failureTitle = defaultsToTimeline + ? @"Import URL to Timeline Failed" + : @"Import URL to Library Failed"; + NSString *emptyMessage = defaultsToTimeline + ? @"Paste a video URL to add to the timeline." + : @"Paste a video URL to import into the library."; + + NSAlert *alert = [[NSAlert alloc] init]; + alert.messageText = promptTitle; + alert.informativeText = defaultsToTimeline + ? @"Paste a YouTube, Vimeo, or direct media URL. SpliceKit will download it, import it into Final Cut Pro, then place it in the active timeline using the selected placement." + : @"Paste a YouTube, Vimeo, or direct media URL. SpliceKit will download it, convert it if needed, and import it into Final Cut Pro."; + [alert addButtonWithTitle:primaryButtonTitle]; + [alert addButtonWithTitle:@"Cancel"]; + + NSView *accessory = [[NSView alloc] initWithFrame:NSMakeRect(0, 0, 420, 184)]; + + NSTextField *urlLabel = [NSTextField labelWithString:@"URL"]; + urlLabel.frame = NSMakeRect(0, 160, 80, 20); + [accessory addSubview:urlLabel]; + + NSScrollView *urlScroll = [[NSScrollView alloc] initWithFrame:NSMakeRect(0, 108, 420, 48)]; + urlScroll.hasVerticalScroller = YES; + urlScroll.hasHorizontalScroller = NO; + urlScroll.borderType = NSBezelBorder; + urlScroll.autohidesScrollers = YES; + + NSTextView *urlField = [[NSTextView alloc] initWithFrame:NSMakeRect(0, 0, 420, 48)]; + urlField.minSize = NSMakeSize(0, 48); + urlField.maxSize = NSMakeSize(CGFLOAT_MAX, CGFLOAT_MAX); + urlField.verticallyResizable = YES; + urlField.horizontallyResizable = NO; + urlField.automaticQuoteSubstitutionEnabled = NO; + urlField.automaticDashSubstitutionEnabled = NO; + urlField.automaticTextReplacementEnabled = NO; + urlField.font = [NSFont systemFontOfSize:13]; + urlField.string = @""; + urlScroll.documentView = urlField; + [accessory addSubview:urlScroll]; + + NSTextField *urlHint = [NSTextField labelWithString:@"Paste a full YouTube, Vimeo, or direct video URL"]; + urlHint.frame = NSMakeRect(2, 90, 320, 14); + urlHint.font = [NSFont systemFontOfSize:11]; + urlHint.textColor = [NSColor secondaryLabelColor]; + [accessory addSubview:urlHint]; + + NSButton *timelineToggle = [[NSButton alloc] initWithFrame:NSMakeRect(0, 62, 260, 20)]; + [timelineToggle setButtonType:NSButtonTypeSwitch]; + timelineToggle.title = defaultsToTimeline + ? @"Place in active timeline" + : @"Also place in active timeline"; + timelineToggle.target = self; + timelineToggle.action = @selector(handleURLImportTimelineToggle:); + timelineToggle.state = defaultsToTimeline + ? NSControlStateValueOn + : NSControlStateValueOff; + [accessory addSubview:timelineToggle]; + + NSTextField *placementLabel = [NSTextField labelWithString:@"Timeline Placement"]; + placementLabel.frame = NSMakeRect(0, 34, 140, 20); + [accessory addSubview:placementLabel]; + + NSPopUpButton *modePopup = [[NSPopUpButton alloc] initWithFrame:NSMakeRect(0, 8, 188, 26) pullsDown:NO]; + [modePopup addItemsWithTitles:@[@"At Current Playhead", @"At Timeline Start", @"Append to Timeline End"]]; + if ([defaultMode isEqualToString:@"append_to_timeline"]) { + [modePopup selectItemAtIndex:2]; + } else if ([defaultMode isEqualToString:@"insert_at_timeline_start"]) { + [modePopup selectItemAtIndex:1]; + } else { + [modePopup selectItemAtIndex:0]; + } + [accessory addSubview:modePopup]; + objc_setAssociatedObject(timelineToggle, "urlImportTimelineLabel", placementLabel, OBJC_ASSOCIATION_ASSIGN); + objc_setAssociatedObject(timelineToggle, "urlImportTimelinePopup", modePopup, OBJC_ASSOCIATION_ASSIGN); + [self syncURLImportTimelineControls:timelineToggle]; + + NSTextField *titleLabel = [NSTextField labelWithString:@"Title Override"]; + titleLabel.frame = NSMakeRect(198, 34, 100, 20); + [accessory addSubview:titleLabel]; + + NSTextField *titleField = [[NSTextField alloc] initWithFrame:NSMakeRect(198, 8, 222, 24)]; + titleField.placeholderString = @"Optional clip name"; + [accessory addSubview:titleField]; + + alert.accessoryView = accessory; + + NSInteger response = [alert runModal]; + if (response != NSAlertFirstButtonReturn) return; + + NSString *url = [urlField.string stringByTrimmingCharactersInSet: + [NSCharacterSet whitespaceAndNewlineCharacterSet]]; + if (url.length == 0) { + [self showSimpleAlertWithTitle:promptTitle message:emptyMessage]; + return; + } + + NSMutableDictionary *params = [NSMutableDictionary dictionary]; + params[@"url"] = url; + params[@"mode"] = [self selectedURLImportModeForToggle:timelineToggle popup:modePopup]; + NSString *title = [titleField.stringValue stringByTrimmingCharactersInSet: + [NSCharacterSet whitespaceAndNewlineCharacterSet]]; + if (title.length > 0) params[@"title"] = title; + + NSPanel *hud = [self showProcessingHUD:(defaultsToTimeline + ? @"Starting timeline URL import..." + : @"Starting library URL import...")]; + dispatch_async(dispatch_get_global_queue(QOS_CLASS_USER_INITIATED, 0), ^{ + NSDictionary *start = SpliceKitURLImport_start(params); + if (start[@"error"]) { + [self dismissProcessingHUD:hud]; + [self showSimpleAlertWithTitle:failureTitle message:start[@"error"]]; + return; + } + + NSString *jobID = start[@"job_id"]; + if (jobID.length == 0) { + [self dismissProcessingHUD:hud]; + [self showSimpleAlertWithTitle:failureTitle + message:@"The URL import job did not return a valid job ID."]; + return; + } + [self attachURLImportCancelButtonToHUD:hud jobID:jobID]; + + dispatch_async(dispatch_get_main_queue(), ^{ + __block dispatch_source_t timer = dispatch_source_create(DISPATCH_SOURCE_TYPE_TIMER, 0, 0, + dispatch_get_main_queue()); + dispatch_source_set_timer(timer, + dispatch_time(DISPATCH_TIME_NOW, 0), + (uint64_t)(0.35 * NSEC_PER_SEC), + (uint64_t)(0.05 * NSEC_PER_SEC)); + + dispatch_source_set_event_handler(timer, ^{ + NSDictionary *status = SpliceKitURLImport_status(@{@"job_id": jobID}); + NSString *state = status[@"state"] ?: @""; + NSString *message = status[@"message"] ?: @"Working..."; + double progress = [status[@"progress"] doubleValue]; + NSString *hudMessage = message; + if (progress > 0.0 && progress < 1.0) { + hudMessage = [NSString stringWithFormat:@"%@ %.0f%%", message, progress * 100.0]; + } + [self updateProcessingHUD:hud message:hudMessage]; + + BOOL finished = [state isEqualToString:@"completed"] || + [state isEqualToString:@"failed"] || + [state isEqualToString:@"cancelled"]; + if (!finished) return; + + dispatch_source_cancel(timer); + timer = nil; + [self dismissProcessingHUD:hud]; + + BOOL completed = [state isEqualToString:@"completed"]; + NSString *statusError = [status[@"error"] isKindOfClass:[NSString class]] + ? status[@"error"] : @""; + BOOL hasWarning = completed && statusError.length > 0; + + if (completed && !hasWarning) { + SpliceKit_log(@"[URLImport] %@", status[@"message"] ?: @"URL import finished."); + return; + } + + NSMutableString *summary = [NSMutableString stringWithString: + status[@"message"] ?: @"URL import finished."]; + NSString *targetEvent = status[@"target_event"]; + NSString *finalPath = [status[@"normalized_path"] length] > 0 + ? status[@"normalized_path"] : status[@"download_path"]; + if (targetEvent.length > 0) { + [summary appendFormat:@"\n\nEvent: %@", targetEvent]; + } + if (finalPath.length > 0 && !completed) { + [summary appendFormat:@"\nPath: %@", finalPath]; + } + if (statusError.length > 0) { + [summary appendFormat:@"\n\nDetail: %@", statusError]; + } + + NSString *titleText = completed ? @"URL Imported With Warning" : @"Import From URL Failed"; + [self showSimpleAlertWithTitle:titleText message:summary]; + }); + + dispatch_resume(timer); + }); + }); + }); +} + #pragma mark - Remove Silences - (NSString *)findSilenceDetector { @@ -4251,6 +4540,17 @@ - (NSArray *)buildGemmaToolSchema { }, @"required": @[@"xml"]}); + addTool(@"import_url", + @"Download a remote media URL, import it into Final Cut Pro, and optionally place it in the timeline.", + @{@"type": @"object", + @"properties": @{ + @"url": @{@"type": @"string", @"description": @"Direct media URL or a supported provider URL"}, + @"mode": @{@"type": @"string", @"description": @"import_only, insert_at_playhead, or append_to_timeline"}, + @"title": @{@"type": @"string", @"description": @"Optional clip title override"}, + @"target_event": @{@"type": @"string", @"description": @"Optional event name override"} + }, + @"required": @[@"url"]}); + addTool(@"export_xml", @"Export current project as FCPXML to a file path.", @{@"type": @"object", @@ -4387,6 +4687,7 @@ - (NSArray *)buildGemmaToolSchema { @"generate_captions": @"captions.generate", @"generate_fcpxml": @"fcpxml.generate", @"import_fcpxml": @"fcpxml.import", + @"import_url": @"urlImport.import", @"export_xml": @"fcpxml.export", @"detect_scene_changes": @"scene.detect", @"toggle_panel": @"view.toggle", @@ -4777,6 +5078,11 @@ - (NSString *)buildAgenticSwiftScript:(NSString *)query timelineContext:(NSDicti "}\n" "@Generable struct FxArgs { @Guide(description: \"effect name\") var name: String }\n" "@Generable struct MenuArgs { @Guide(description: \"Menu path, e.g. ['File','New','Project...']\") var path: [String] }\n" + "@Generable struct ImportArgs {\n" + " @Guide(description: \"A direct .mp4/.mov/.m4v/.webm URL, or a supported provider URL\") var url: String\n" + " @Guide(description: \"import_only, insert_at_playhead, or append_to_timeline\") var mode: String?\n" + " @Guide(description: \"Optional clip title override\") var title: String?\n" + "}\n" "\n" "struct Act: Tool {\n" " let name = \"edit\"\n" @@ -4821,11 +5127,20 @@ - (NSString *)buildAgenticSwiftScript:(NSString *)query timelineContext:(NSDicti " let description = \"Execute any menu command by path\"\n" " func call(arguments: MenuArgs) async throws -> String { bridge(\"menu.execute\", [\"menuPath\": arguments.path]) }\n" "}\n" + "struct ImportURL: Tool {\n" + " let name = \"import_url\"\n" + " let description = \"Download a remote media URL, import it into Final Cut Pro, and optionally place it into the timeline\"\n" + " func call(arguments: ImportArgs) async throws -> String {\n" + " var params: [String: Any] = [\"url\": arguments.url, \"mode\": arguments.mode ?? \"import_only\"]\n" + " if let title = arguments.title, !title.isEmpty { params[\"title\"] = title }\n" + " return bridge(\"urlImport.import\", params)\n" + " }\n" + "}\n" "\n" "Task {\n" " do {\n" - " let s = LanguageModelSession(tools: [Act(), Seek(), Clips(), Repeat(), Fx(), Menu()],\n" - " instructions: \"You control Final Cut Pro via tools. %@ IMPORTANT: cut/split means blade NOT delete. For cut/blade/marker every N seconds use repeat_action. Summarize what you did.\")\n" + " let s = LanguageModelSession(tools: [Act(), Seek(), Clips(), Repeat(), Fx(), Menu(), ImportURL()],\n" + " instructions: \"You control Final Cut Pro via tools. %@ IMPORTANT: cut/split means blade NOT delete. For cut/blade/marker every N seconds use repeat_action. If the request includes a media URL, use import_url. Summarize what you did.\")\n" " let r = try await s.respond(to: \"%@\")\n" " print(r.content ?? \"Done.\")\n" " } catch { print(\"Error: \\(error.localizedDescription)\") }\n" diff --git a/Sources/SpliceKitLua.m b/Sources/SpliceKitLua.m index 24fbbaf..c72e8de 100644 --- a/Sources/SpliceKitLua.m +++ b/Sources/SpliceKitLua.m @@ -441,6 +441,62 @@ static int sk_selected(lua_State *L) { return 1; } +// --- URL Import --- + +static int sk_import_url(lua_State *L) { + const char *url = luaL_checkstring(L, 1); + NSMutableDictionary *params = [NSMutableDictionary dictionaryWithObject:@(url) forKey:@"url"]; + if (lua_istable(L, 2)) { + NSDictionary *options = SpliceKitLua_toNSDictionary(L, 2); + if (options) [params addEntriesFromDictionary:options]; + } + NSDictionary *response = SpliceKit_handleRequest(@{ + @"method": @"urlImport.import", + @"params": params + }); + id result = response[@"result"]; + SpliceKitLua_pushValue(L, result ?: response); + return 1; +} + +static int sk_import_url_start(lua_State *L) { + const char *url = luaL_checkstring(L, 1); + NSMutableDictionary *params = [NSMutableDictionary dictionaryWithObject:@(url) forKey:@"url"]; + if (lua_istable(L, 2)) { + NSDictionary *options = SpliceKitLua_toNSDictionary(L, 2); + if (options) [params addEntriesFromDictionary:options]; + } + NSDictionary *response = SpliceKit_handleRequest(@{ + @"method": @"urlImport.start", + @"params": params + }); + id result = response[@"result"]; + SpliceKitLua_pushValue(L, result ?: response); + return 1; +} + +static int sk_import_url_status(lua_State *L) { + const char *jobID = luaL_checkstring(L, 1); + NSDictionary *response = SpliceKit_handleRequest(@{ + @"method": @"urlImport.status", + @"params": @{@"job_id": @(jobID)} + }); + id result = response[@"result"]; + SpliceKitLua_pushValue(L, result ?: response); + return 1; +} + +static int sk_import_url_cancel(lua_State *L) { + const char *jobID = luaL_checkstring(L, 1); + NSDictionary *response = SpliceKit_handleRequest(@{ + @"method": @"urlImport.cancel", + @"params": @{@"job_id": @(jobID)} + }); + id result = response[@"result"]; + SpliceKitLua_pushValue(L, result ?: response); + return 1; +} + // --- Generic RPC Passthrough --- // sk.rpc("method.name", {param = value}) → calls any RPC method @@ -1057,6 +1113,10 @@ static int sk_index(lua_State *L) { {"clips", sk_clips}, {"position", sk_position}, {"selected", sk_selected}, + {"import_url", sk_import_url}, + {"import_url_start", sk_import_url_start}, + {"import_url_status", sk_import_url_status}, + {"import_url_cancel", sk_import_url_cancel}, // Generic access {"timeline", sk_timeline_action}, diff --git a/Sources/SpliceKitServer.m b/Sources/SpliceKitServer.m index 2ab500f..77f88ef 100644 --- a/Sources/SpliceKitServer.m +++ b/Sources/SpliceKitServer.m @@ -19,6 +19,7 @@ #import "SpliceKitCommandPalette.h" #import "SpliceKitDebugUI.h" #import "SpliceKitLua.h" +#import "SpliceKitURLImport.h" #import #import #import @@ -10601,8 +10602,433 @@ void SpliceKit_installDefaultSpatialConformType(void) { return result ?: @{@"error": @"Failed to list browser clips"}; } -// Append a clip from the event browser to the timeline -static NSDictionary *SpliceKit_handleBrowserAppendClip(NSDictionary *params) { +static NSString *SpliceKit_browserClipName(id clip) { + if (!clip) return @""; + if ([clip respondsToSelector:@selector(displayName)]) { + id name = ((id (*)(id, SEL))objc_msgSend)(clip, @selector(displayName)); + if ([name isKindOfClass:[NSString class]]) return name; + } + return @""; +} + +static NSString *SpliceKit_browserShortDescription(id obj, NSUInteger maxLength) { + if (!obj) return @""; + NSString *desc = [obj description] ?: @""; + if (desc.length > maxLength) { + return [desc substringToIndex:maxLength]; + } + return desc; +} + +static BOOL SpliceKit_browserCMTimeIsUsable(SpliceKit_CMTime t) { + return (t.timescale > 0 && t.value >= 0); +} + +static double SpliceKit_browserSecondsForTime(SpliceKit_CMTime t) { + if (!SpliceKit_browserCMTimeIsUsable(t)) return 0.0; + return (double)t.value / (double)t.timescale; +} + +static void SpliceKit_browserAssignTime(NSMutableDictionary *dict, NSString *key, SpliceKit_CMTime t) { + if (!dict || key.length == 0) return; + if (SpliceKit_browserCMTimeIsUsable(t)) { + dict[key] = SpliceKit_serializeCMTime(t); + } +} + +static id SpliceKit_browserSequenceForTimeline(id timelineModule) { + if (!timelineModule || ![timelineModule respondsToSelector:@selector(sequence)]) return nil; + return ((id (*)(id, SEL))objc_msgSend)(timelineModule, @selector(sequence)); +} + +static id SpliceKit_browserPrimaryContainerForSequence(id sequence) { + if (!sequence) return nil; + if ([sequence respondsToSelector:@selector(primaryObject)]) { + id container = ((id (*)(id, SEL))objc_msgSend)(sequence, @selector(primaryObject)); + if (container) return container; + } + return sequence; +} + +static NSArray *SpliceKit_browserContainedItems(id sequence, id container) { + id items = nil; + if (container && [container respondsToSelector:@selector(containedItems)]) { + items = ((id (*)(id, SEL))objc_msgSend)(container, @selector(containedItems)); + } else if (sequence && [sequence respondsToSelector:@selector(containedItems)]) { + items = ((id (*)(id, SEL))objc_msgSend)(sequence, @selector(containedItems)); + } + return [items isKindOfClass:[NSArray class]] ? items : nil; +} + +static NSDictionary *SpliceKit_browserTimelineItemSummary(id item, id container) { + if (!item) return @{}; + + NSMutableDictionary *summary = [NSMutableDictionary dictionary]; + summary[@"class"] = NSStringFromClass([item class]) ?: @""; + summary[@"description"] = SpliceKit_browserShortDescription(item, 240); + summary[@"handle"] = SpliceKit_storeHandle(item) ?: @""; + + NSString *name = SpliceKit_browserClipName(item); + if (name.length > 0) summary[@"name"] = name; + + if ([item respondsToSelector:@selector(duration)]) { + SpliceKit_CMTime duration = ((SpliceKit_CMTime (*)(id, SEL))STRET_MSG)(item, @selector(duration)); + SpliceKit_browserAssignTime(summary, @"duration", duration); + } + + SEL effectiveRangeSel = NSSelectorFromString(@"effectiveRangeOfObject:"); + if (container && [container respondsToSelector:effectiveRangeSel]) { + @try { + SpliceKit_CMTimeRange range = + ((SpliceKit_CMTimeRange (*)(id, SEL, id))STRET_MSG)(container, effectiveRangeSel, item); + if (SpliceKit_browserCMTimeIsUsable(range.start)) { + summary[@"startTime"] = SpliceKit_serializeCMTime(range.start); + } + if (SpliceKit_browserCMTimeIsUsable(range.duration)) { + SpliceKit_CMTime endTime = range.start; + if (range.duration.timescale == range.start.timescale) { + endTime.value = range.start.value + range.duration.value; + } else if (range.duration.timescale > 0 && range.start.timescale > 0) { + endTime.value = range.start.value + + (range.duration.value * range.start.timescale / range.duration.timescale); + } + if (SpliceKit_browserCMTimeIsUsable(endTime)) { + summary[@"endTime"] = SpliceKit_serializeCMTime(endTime); + } + } + } @catch (__unused NSException *e) {} + } + + return summary; +} + +static NSDictionary *SpliceKit_browserPlacementSnapshot(id timelineModule, id clip) { + NSMutableDictionary *snapshot = [NSMutableDictionary dictionary]; + id sequence = SpliceKit_browserSequenceForTimeline(timelineModule); + id container = SpliceKit_browserPrimaryContainerForSequence(sequence); + + if (clip) { + snapshot[@"clipHandle"] = SpliceKit_storeHandle(clip) ?: @""; + snapshot[@"clipClass"] = NSStringFromClass([clip class]) ?: @""; + snapshot[@"clipDescription"] = SpliceKit_browserShortDescription(clip, 240); + NSString *clipName = SpliceKit_browserClipName(clip); + if (clipName.length > 0) snapshot[@"clipName"] = clipName; + } + + if (sequence) { + snapshot[@"sequenceHandle"] = SpliceKit_storeHandle(sequence) ?: @""; + snapshot[@"sequenceClass"] = NSStringFromClass([sequence class]) ?: @""; + snapshot[@"sequenceDescription"] = SpliceKit_browserShortDescription(sequence, 240); + NSString *sequenceName = SpliceKit_browserClipName(sequence); + if (sequenceName.length > 0) snapshot[@"sequenceName"] = sequenceName; + if ([sequence respondsToSelector:@selector(duration)]) { + SpliceKit_CMTime duration = ((SpliceKit_CMTime (*)(id, SEL))STRET_MSG)(sequence, @selector(duration)); + SpliceKit_browserAssignTime(snapshot, @"sequenceDuration", duration); + } + } + + if (container) { + snapshot[@"containerHandle"] = SpliceKit_storeHandle(container) ?: @""; + snapshot[@"containerClass"] = NSStringFromClass([container class]) ?: @""; + snapshot[@"containerDescription"] = SpliceKit_browserShortDescription(container, 240); + SEL endSel = NSSelectorFromString(@"endTimeOfLastContainedItem"); + if ([container respondsToSelector:endSel]) { + SpliceKit_CMTime end = ((SpliceKit_CMTime (*)(id, SEL))STRET_MSG)(container, endSel); + SpliceKit_browserAssignTime(snapshot, @"containerEndTime", end); + } + } + + SEL currentSel = NSSelectorFromString(@"currentSequenceTime"); + if ([timelineModule respondsToSelector:currentSel]) { + SpliceKit_CMTime t = ((SpliceKit_CMTime (*)(id, SEL))STRET_MSG)(timelineModule, currentSel); + SpliceKit_browserAssignTime(snapshot, @"currentSequenceTime", t); + } + if ([timelineModule respondsToSelector:@selector(playheadTime)]) { + SpliceKit_CMTime t = ((SpliceKit_CMTime (*)(id, SEL))STRET_MSG)(timelineModule, @selector(playheadTime)); + SpliceKit_browserAssignTime(snapshot, @"playheadTime", t); + } + SEL committedSel = NSSelectorFromString(@"committedPlayheadTime"); + if ([timelineModule respondsToSelector:committedSel]) { + SpliceKit_CMTime t = ((SpliceKit_CMTime (*)(id, SEL))STRET_MSG)(timelineModule, committedSel); + SpliceKit_browserAssignTime(snapshot, @"committedPlayheadTime", t); + } + + NSArray *items = SpliceKit_browserContainedItems(sequence, container); + snapshot[@"itemCount"] = @(items.count); + if (items.count > 0) { + NSInteger tailStart = MAX((NSInteger)0, (NSInteger)items.count - 5); + NSMutableArray *tailItems = [NSMutableArray array]; + for (NSInteger idx = tailStart; idx < (NSInteger)items.count; idx++) { + [tailItems addObject:SpliceKit_browserTimelineItemSummary(items[idx], container)]; + } + snapshot[@"tailItems"] = tailItems; + snapshot[@"lastItem"] = SpliceKit_browserTimelineItemSummary(items.lastObject, container); + } + + return snapshot; +} + +static BOOL SpliceKit_browserPrepareExplicitPasteboard(id clip, + id mediaRange, + NSString **outPasteboardName, + NSMutableDictionary *debugInfo, + NSString **outError) { + NSPasteboard *generalPB = [NSPasteboard generalPasteboard]; + [generalPB clearContents]; + + Class ffPasteboardClass = objc_getClass("FFPasteboard"); + if (!ffPasteboardClass) { + if (outError) *outError = @"FFPasteboard class not found"; + return NO; + } + + id ffPasteboard = ((id (*)(id, SEL))objc_msgSend)((id)ffPasteboardClass, @selector(alloc)); + SEL initWithNameSel = NSSelectorFromString(@"initWithName:"); + if (![ffPasteboard respondsToSelector:initWithNameSel]) { + if (outError) *outError = @"FFPasteboard does not respond to initWithName:"; + return NO; + } + + NSString *pasteboardName = NSPasteboardNameGeneral; + ffPasteboard = ((id (*)(id, SEL, id))objc_msgSend)(ffPasteboard, initWithNameSel, pasteboardName); + BOOL wroteRanges = NO; + BOOL wroteAnchored = NO; + + SEL writeRangesSel = NSSelectorFromString(@"writeRangesOfMedia:options:"); + if (mediaRange && [ffPasteboard respondsToSelector:writeRangesSel]) { + wroteRanges = ((BOOL (*)(id, SEL, id, id))objc_msgSend)(ffPasteboard, writeRangesSel, @[mediaRange], nil); + } + + if (!wroteRanges) { + SEL writeAnchoredSel = NSSelectorFromString(@"writeAnchoredObjects:options:"); + if ([ffPasteboard respondsToSelector:writeAnchoredSel]) { + wroteAnchored = ((BOOL (*)(id, SEL, id, id))objc_msgSend)(ffPasteboard, writeAnchoredSel, @[clip], nil); + } + } + + if (debugInfo) { + debugInfo[@"pasteboardName"] = pasteboardName ?: @""; + debugInfo[@"pasteboardWriteRanges"] = @(wroteRanges); + debugInfo[@"pasteboardWriteAnchored"] = @(wroteAnchored); + if ([ffPasteboard respondsToSelector:@selector(hasMedia:)]) { + BOOL hasMedia = ((BOOL (*)(id, SEL, BOOL))objc_msgSend)(ffPasteboard, @selector(hasMedia:), YES); + debugInfo[@"pasteboardHasMedia"] = @(hasMedia); + } + if ([ffPasteboard respondsToSelector:@selector(hasEdits:)]) { + BOOL hasEdits = ((BOOL (*)(id, SEL, BOOL))objc_msgSend)(ffPasteboard, @selector(hasEdits:), YES); + debugInfo[@"pasteboardHasEdits"] = @(hasEdits); + } + } + + if (outPasteboardName) *outPasteboardName = pasteboardName; + if (!wroteRanges && !wroteAnchored) { + if (outError) *outError = @"Failed to write explicit clip data to pasteboard"; + return NO; + } + return YES; +} + +static NSDictionary *SpliceKit_browserInsertExplicitClipAtPlayhead(id timelineModule, + id clip, + NSString *pasteboardName) { + NSMutableDictionary *debugInfo = [NSMutableDictionary dictionary]; + debugInfo[@"primitive"] = @"explicit_paste_at_playhead"; + debugInfo[@"before"] = SpliceKit_browserPlacementSnapshot(timelineModule, clip); + debugInfo[@"pasteboardName"] = pasteboardName ?: @""; + + SEL pasteSel = NSSelectorFromString(@"paste:"); + if (![timelineModule respondsToSelector:pasteSel]) { + return @{@"error": @"Timeline module does not respond to paste:", + @"placementDebug": debugInfo}; + } + + ((void (*)(id, SEL, id))objc_msgSend)(timelineModule, pasteSel, nil); + [[NSRunLoop currentRunLoop] runUntilDate:[NSDate dateWithTimeIntervalSinceNow:0.12]]; + + NSDictionary *after = SpliceKit_browserPlacementSnapshot(timelineModule, clip); + debugInfo[@"after"] = after; + + return @{@"status": @"ok", + @"primitive": @"explicit_paste_at_playhead", + @"placementVerified": @YES, + @"placementDebug": debugInfo}; +} + +static NSDictionary *SpliceKit_browserAppendExplicitClipToTimelineEnd(id timelineModule, + id clip, + NSString *pasteboardName) { + NSMutableDictionary *debugInfo = [NSMutableDictionary dictionary]; + debugInfo[@"primitive"] = @"verified_seek_to_end_then_paste"; + debugInfo[@"pasteboardName"] = pasteboardName ?: @""; + + NSDictionary *before = SpliceKit_browserPlacementSnapshot(timelineModule, clip); + debugInfo[@"before"] = before; + + NSDictionary *durationInfo = before[@"sequenceDuration"]; + NSDictionary *containerEndInfo = before[@"containerEndTime"]; + double targetSeconds = [containerEndInfo[@"seconds"] doubleValue]; + if (targetSeconds <= 0.0) targetSeconds = [durationInfo[@"seconds"] doubleValue]; + + if (targetSeconds < 0.0) { + return @{@"error": @"Could not determine the current primary storyline end.", + @"placementDebug": debugInfo}; + } + + double frameSeconds = SpliceKit_transitionFrameDurationSeconds(timelineModule); + double tolerance = MAX(frameSeconds * 2.0, 0.05); + debugInfo[@"targetEndSeconds"] = @(targetSeconds); + debugInfo[@"toleranceSeconds"] = @(tolerance); + + NSString *expectedName = SpliceKit_browserClipName(clip); + double beforePlayheadSeconds = [before[@"playheadTime"][@"seconds"] doubleValue]; + SpliceKit_log(@"%@", [NSString stringWithFormat: + @"[AppendPlacement] begin clip=%@ targetEnd=%.6f playheadBefore=%.6f primitive=%@", + expectedName.length > 0 ? expectedName : @"", + targetSeconds, + beforePlayheadSeconds, + @"verified_seek_to_end_then_paste"]); + + if (!SpliceKit_transitionSeekToSeconds(timelineModule, targetSeconds)) { + return @{@"error": @"Could not move the playhead to the current storyline end.", + @"placementDebug": debugInfo}; + } + + NSDictionary *seekImmediate = SpliceKit_browserPlacementSnapshot(timelineModule, clip); + debugInfo[@"seekImmediate"] = seekImmediate; + + [[NSRunLoop currentRunLoop] runUntilDate:[NSDate dateWithTimeIntervalSinceNow:0.02]]; + NSDictionary *seekNextRunloop = SpliceKit_browserPlacementSnapshot(timelineModule, clip); + debugInfo[@"seekNextRunloop"] = seekNextRunloop; + + NSDictionary *seekDeferred = nil; + double currentSequenceSeconds = 0.0; + double playheadSeconds = 0.0; + double committedSeconds = 0.0; + BOOL currentMatches = NO; + BOOL playheadMatches = NO; + BOOL committedMatches = NO; + NSInteger seekVerificationPollCount = 0; + + NSDate *seekDeadline = [NSDate dateWithTimeIntervalSinceNow:0.75]; + do { + [[NSRunLoop currentRunLoop] runUntilDate:[NSDate dateWithTimeIntervalSinceNow:0.05]]; + seekVerificationPollCount++; + seekDeferred = SpliceKit_browserPlacementSnapshot(timelineModule, clip); + debugInfo[@"seekDeferred"] = seekDeferred; + + currentSequenceSeconds = [seekDeferred[@"currentSequenceTime"][@"seconds"] doubleValue]; + playheadSeconds = [seekDeferred[@"playheadTime"][@"seconds"] doubleValue]; + committedSeconds = [seekDeferred[@"committedPlayheadTime"][@"seconds"] doubleValue]; + + currentMatches = fabs(currentSequenceSeconds - targetSeconds) <= tolerance; + playheadMatches = fabs(playheadSeconds - targetSeconds) <= tolerance; + committedMatches = (seekDeferred[@"committedPlayheadTime"] == nil) || + fabs(committedSeconds - targetSeconds) <= tolerance; + } while (!(currentMatches && playheadMatches && committedMatches) && + [seekDeadline timeIntervalSinceNow] > 0.0); + + debugInfo[@"seekVerificationPollCount"] = @(seekVerificationPollCount); + debugInfo[@"seekVerified"] = @(currentMatches && playheadMatches && committedMatches); + + if (!(currentMatches && playheadMatches && committedMatches)) { + NSString *reason = [NSString stringWithFormat: + @"Append verification failed before paste. target=%.6f current=%.6f playhead=%.6f committed=%.6f", + targetSeconds, currentSequenceSeconds, playheadSeconds, committedSeconds]; + SpliceKit_log(@"[AppendPlacement] %@", reason); + return @{@"error": reason, @"placementDebug": debugInfo}; + } + + SEL pasteSel = NSSelectorFromString(@"paste:"); + if (![timelineModule respondsToSelector:pasteSel]) { + return @{@"error": @"Timeline module does not respond to paste:", + @"placementDebug": debugInfo}; + } + + ((void (*)(id, SEL, id))objc_msgSend)(timelineModule, pasteSel, nil); + NSDictionary *after = nil; + NSDictionary *match = nil; + double afterTailSeconds = targetSeconds; + BOOL tailAdvanced = NO; + NSInteger verificationPollCount = 0; + + NSDate *verificationDeadline = [NSDate dateWithTimeIntervalSinceNow:0.75]; + do { + [[NSRunLoop currentRunLoop] runUntilDate:[NSDate dateWithTimeIntervalSinceNow:0.05]]; + verificationPollCount++; + + after = SpliceKit_browserPlacementSnapshot(timelineModule, clip); + NSDictionary *afterContainerEnd = after[@"containerEndTime"]; + NSDictionary *afterDuration = after[@"sequenceDuration"]; + afterTailSeconds = [afterContainerEnd[@"seconds"] doubleValue]; + if (afterTailSeconds <= 0.0) afterTailSeconds = [afterDuration[@"seconds"] doubleValue]; + tailAdvanced = afterTailSeconds > (targetSeconds + (frameSeconds * 0.5)); + + id sequence = SpliceKit_browserSequenceForTimeline(timelineModule); + id container = SpliceKit_browserPrimaryContainerForSequence(sequence); + NSArray *items = SpliceKit_browserContainedItems(sequence, container); + match = nil; + + for (id item in items) { + NSString *itemName = SpliceKit_browserClipName(item); + if (expectedName.length == 0 || ![itemName isEqualToString:expectedName]) continue; + + NSDictionary *summary = SpliceKit_browserTimelineItemSummary(item, container); + double startSeconds = [summary[@"startTime"][@"seconds"] doubleValue]; + if (fabs(startSeconds - targetSeconds) <= tolerance) { + match = summary; + break; + } + } + } while ((match == nil || !tailAdvanced) && + [verificationDeadline timeIntervalSinceNow] > 0.0); + + debugInfo[@"after"] = after; + if (match) debugInfo[@"matchedInsertedItem"] = match; + + double beforeTailSeconds = [containerEndInfo[@"seconds"] doubleValue]; + if (beforeTailSeconds <= 0.0) beforeTailSeconds = [durationInfo[@"seconds"] doubleValue]; + BOOL durationGrew = afterTailSeconds > (beforeTailSeconds + (frameSeconds * 0.5)); + BOOL verified = (match != nil && durationGrew); + debugInfo[@"durationBeforeSeconds"] = @(beforeTailSeconds); + debugInfo[@"durationAfterSeconds"] = @(afterTailSeconds); + debugInfo[@"storylineTailBeforeSeconds"] = @(beforeTailSeconds); + debugInfo[@"storylineTailAfterSeconds"] = @(afterTailSeconds); + debugInfo[@"durationGrew"] = @(durationGrew); + debugInfo[@"verificationPollCount"] = @(verificationPollCount); + + if (!verified) { + NSString *reason = [NSString stringWithFormat: + @"Append paste completed but could not verify the inserted clip at the prior storyline end."]; + SpliceKit_log(@"%@", [NSString stringWithFormat: + @"[AppendPlacement] fail clip=%@ targetEnd=%.6f afterTail=%.6f match=%@ polls=%ld", + expectedName.length > 0 ? expectedName : @"", + targetSeconds, + afterTailSeconds, + match ? @"YES" : @"NO", + (long)verificationPollCount]); + SpliceKit_log(@"[AppendPlacement] %@", reason); + return @{@"error": reason, @"placementDebug": debugInfo}; + } + + double insertedStartSeconds = [match[@"startTime"][@"seconds"] doubleValue]; + SpliceKit_log(@"%@", [NSString stringWithFormat: + @"[AppendPlacement] success clip=%@ targetEnd=%.6f insertedStart=%.6f tailBefore=%.6f tailAfter=%.6f polls=%ld", + expectedName.length > 0 ? expectedName : @"", + targetSeconds, + insertedStartSeconds, + beforeTailSeconds, + afterTailSeconds, + (long)verificationPollCount]); + + return @{@"status": @"ok", + @"primitive": @"verified_seek_to_end_then_paste", + @"placementVerified": @YES, + @"placementDebug": debugInfo}; +} + +static NSDictionary *SpliceKit_handleBrowserPlaceClip(NSDictionary *params, + NSString *selectorName, + NSString *actionName) { NSString *handle = params[@"handle"]; NSNumber *indexNum = params[@"index"]; NSString *name = params[@"name"]; @@ -10659,24 +11085,26 @@ void SpliceKit_installDefaultSpatialConformType(void) { return; } - // Get the organizer filmstrip module and select this clip + id timelineModule = SpliceKit_getActiveTimelineModule(); + if (timelineModule) { + SpliceKit_sendTimelineSimpleAction(timelineModule, @"deselectAll:"); + [[NSRunLoop currentRunLoop] runUntilDate:[NSDate dateWithTimeIntervalSinceNow:0.05]]; + } + id app = ((id (*)(id, SEL))objc_msgSend)( objc_getClass("NSApplication"), @selector(sharedApplication)); id delegate = ((id (*)(id, SEL))objc_msgSend)(app, @selector(delegate)); - // Try to get the media browser and select the clip SEL browserSel = NSSelectorFromString(@"mediaBrowserContainerModule"); id browserContainer = nil; if ([delegate respondsToSelector:browserSel]) { browserContainer = ((id (*)(id, SEL))objc_msgSend)(delegate, browserSel); } - // Create a media range for the full clip and select it in the browser - // Use FigTimeRangeAndObject to wrap the clip + SpliceKit_CMTimeRange clipRange = {0}; + id mediaRange = nil; Class rangeObjClass = objc_getClass("FigTimeRangeAndObject"); if (rangeObjClass && clip) { - // Get the clip's clipped range - SpliceKit_CMTimeRange clipRange = {0}; if ([clip respondsToSelector:@selector(clippedRange)]) { clipRange = ((SpliceKit_CMTimeRange (*)(id, SEL))STRET_MSG)(clip, @selector(clippedRange)); } else if ([clip respondsToSelector:@selector(duration)]) { @@ -10687,61 +11115,80 @@ void SpliceKit_installDefaultSpatialConformType(void) { SEL rangeAndObjSel = NSSelectorFromString(@"rangeAndObjectWithRange:andObject:"); if ([(id)rangeObjClass respondsToSelector:rangeAndObjSel]) { - id mediaRange = ((id (*)(id, SEL, SpliceKit_CMTimeRange, id))objc_msgSend)( + mediaRange = ((id (*)(id, SEL, SpliceKit_CMTimeRange, id))objc_msgSend)( (id)rangeObjClass, rangeAndObjSel, clipRange, clip); + } + } - if (mediaRange) { - // Select the media range in the browser filmstrip - SEL filmstripSel = NSSelectorFromString(@"filmstripModule"); - id filmstrip = nil; - if (browserContainer && [browserContainer respondsToSelector:filmstripSel]) { - filmstrip = ((id (*)(id, SEL))objc_msgSend)(browserContainer, filmstripSel); - } - if (!filmstrip) { - // Try getting through organizer - SEL orgSel = NSSelectorFromString(@"organizerModule"); - id organizer = [delegate respondsToSelector:orgSel] - ? ((id (*)(id, SEL))objc_msgSend)(delegate, orgSel) : nil; - if (organizer) { - SEL itemsSel = NSSelectorFromString(@"itemsModule"); - filmstrip = [organizer respondsToSelector:itemsSel] - ? ((id (*)(id, SEL))objc_msgSend)(organizer, itemsSel) : nil; - } - } + if (!timelineModule) { + result = @{@"error": @"No active timeline module. Is a project open?"}; + return; + } - if (filmstrip) { - SEL selectSel = NSSelectorFromString(@"_selectMediaRanges:"); - if ([filmstrip respondsToSelector:selectSel]) { - NSArray *ranges = @[mediaRange]; - ((void (*)(id, SEL, id))objc_msgSend)(filmstrip, selectSel, ranges); - [[NSRunLoop currentRunLoop] runUntilDate:[NSDate dateWithTimeIntervalSinceNow:0.1]]; - } - } - } - } + NSString *pasteboardName = nil; + NSMutableDictionary *pasteboardDebug = [NSMutableDictionary dictionary]; + NSString *pasteboardError = nil; + BOOL wroteExplicitClip = SpliceKit_browserPrepareExplicitPasteboard( + clip, mediaRange, &pasteboardName, pasteboardDebug, &pasteboardError); + if (!wroteExplicitClip) { + result = @{@"error": pasteboardError ?: @"Failed to prepare explicit pasteboard data.", + @"placementDebug": pasteboardDebug}; + return; } - // Append to storyline via responder chain (direct ObjC, no key simulation) - [[NSRunLoop currentRunLoop] runUntilDate:[NSDate dateWithTimeIntervalSinceNow:0.1]]; - BOOL sent = ((BOOL (*)(id, SEL, SEL, id, id))objc_msgSend)( - app, @selector(sendAction:to:from:), - NSSelectorFromString(@"appendWithSelectedMedia:"), nil, nil); - if (!sent) { - result = @{@"error": @"No responder handled appendWithSelectedMedia:"}; + NSDictionary *placementResult = nil; + if ([selectorName isEqualToString:@"appendWithSelectedMedia:"]) { + placementResult = SpliceKit_browserAppendExplicitClipToTimelineEnd( + timelineModule, clip, pasteboardName); + } else { + placementResult = SpliceKit_browserInsertExplicitClipAtPlayhead( + timelineModule, clip, pasteboardName); + } + + NSMutableDictionary *mergedResult = [NSMutableDictionary dictionaryWithDictionary: + placementResult ?: @{}]; + NSMutableDictionary *mergedDebug = [NSMutableDictionary dictionary]; + if ([placementResult[@"placementDebug"] isKindOfClass:[NSDictionary class]]) { + [mergedDebug addEntriesFromDictionary:placementResult[@"placementDebug"]]; + } + [mergedDebug addEntriesFromDictionary:pasteboardDebug]; + if (mergedDebug.count > 0) { + mergedResult[@"placementDebug"] = mergedDebug; + } + if (placementResult[@"error"]) { + result = mergedResult; return; } - [[NSRunLoop currentRunLoop] runUntilDate:[NSDate dateWithTimeIntervalSinceNow:0.1]]; + + [[NSRunLoop currentRunLoop] runUntilDate:[NSDate dateWithTimeIntervalSinceNow:0.05]]; NSString *clipName = @""; if ([clip respondsToSelector:@selector(displayName)]) clipName = ((id (*)(id, SEL))objc_msgSend)(clip, @selector(displayName)) ?: @""; - result = @{@"status": @"ok", @"clip": clipName, @"action": @"appendToStoryline"}; + mergedResult[@"status"] = @"ok"; + mergedResult[@"clip"] = clipName; + mergedResult[@"action"] = actionName ?: @"browserPlaceClip"; + result = mergedResult; } @catch (NSException *e) { result = @{@"error": [NSString stringWithFormat:@"Exception: %@", e.reason]}; } }); - return result ?: @{@"error": @"Failed to append clip"}; + return result ?: @{@"error": @"Failed to place browser clip"}; +} + +// Append a clip from the event browser to the timeline +static NSDictionary *SpliceKit_handleBrowserAppendClip(NSDictionary *params) { + return SpliceKit_handleBrowserPlaceClip(params, + @"appendWithSelectedMedia:", + @"appendToStoryline"); +} + +// Insert a clip from the event browser at the current playhead +static NSDictionary *SpliceKit_handleBrowserInsertClip(NSDictionary *params) { + return SpliceKit_handleBrowserPlaceClip(params, + @"insertWithSelectedMedia:", + @"insertAtPlayhead"); } #pragma mark - Menu Execute Handler @@ -17926,6 +18373,8 @@ static void SpliceKit_breakpointHit(NSString *key, id self_obj, SEL _cmd, result = SpliceKit_handleBrowserListClips(params); } else if ([method isEqualToString:@"browser.appendClip"]) { result = SpliceKit_handleBrowserAppendClip(params); + } else if ([method isEqualToString:@"browser.insertClip"]) { + result = SpliceKit_handleBrowserInsertClip(params); } // menu.* namespace else if ([method isEqualToString:@"menu.execute"]) { @@ -17965,6 +18414,16 @@ static void SpliceKit_breakpointHit(NSString *key, id self_obj, SEL _cmd, } else if ([method isEqualToString:@"project.open"]) { result = SpliceKit_handleProjectOpen(params); } + // urlImport.* namespace + else if ([method isEqualToString:@"urlImport.start"]) { + result = SpliceKitURLImport_start(params); + } else if ([method isEqualToString:@"urlImport.import"]) { + result = SpliceKitURLImport_importSync(params); + } else if ([method isEqualToString:@"urlImport.status"]) { + result = SpliceKitURLImport_status(params); + } else if ([method isEqualToString:@"urlImport.cancel"]) { + result = SpliceKitURLImport_cancel(params); + } // timeline lane selection else if ([method isEqualToString:@"timeline.selectClipInLane"]) { result = SpliceKit_handleSelectClipAtPlayheadLane(params); diff --git a/Sources/SpliceKitURLImport.h b/Sources/SpliceKitURLImport.h new file mode 100644 index 0000000..35bd705 --- /dev/null +++ b/Sources/SpliceKitURLImport.h @@ -0,0 +1,16 @@ +// +// SpliceKitURLImport.h +// Remote URL -> local media -> Final Cut import workflow. +// + +#ifndef SpliceKitURLImport_h +#define SpliceKitURLImport_h + +#import + +NSDictionary *SpliceKitURLImport_start(NSDictionary *params); +NSDictionary *SpliceKitURLImport_importSync(NSDictionary *params); +NSDictionary *SpliceKitURLImport_status(NSDictionary *params); +NSDictionary *SpliceKitURLImport_cancel(NSDictionary *params); + +#endif /* SpliceKitURLImport_h */ diff --git a/Sources/SpliceKitURLImport.m b/Sources/SpliceKitURLImport.m new file mode 100644 index 0000000..3ecf4ff --- /dev/null +++ b/Sources/SpliceKitURLImport.m @@ -0,0 +1,1673 @@ +// +// SpliceKitURLImport.m +// Native URL ingest pipeline for SpliceKit. +// + +#import "SpliceKitURLImport.h" +#import "SpliceKit.h" +#import +#import + +extern NSDictionary *SpliceKit_handleRequest(NSDictionary *request); +extern id SpliceKit_getActiveTimelineModule(void); + +#if defined(__x86_64__) +#define SPLICEKIT_URLIMPORT_STRET_MSG objc_msgSend_stret +#else +#define SPLICEKIT_URLIMPORT_STRET_MSG objc_msgSend +#endif + +static NSString * const SpliceKitURLImportStateQueued = @"queued"; +static NSString * const SpliceKitURLImportStateResolving = @"resolving"; +static NSString * const SpliceKitURLImportStateDownloading = @"downloading"; +static NSString * const SpliceKitURLImportStateNormalizing = @"normalizing"; +static NSString * const SpliceKitURLImportStateImporting = @"importing"; +static NSString * const SpliceKitURLImportStateInserting = @"inserting"; +static NSString * const SpliceKitURLImportStateCompleted = @"completed"; +static NSString * const SpliceKitURLImportStateFailed = @"failed"; +static NSString * const SpliceKitURLImportStateCancelled = @"cancelled"; + +static NSString *SpliceKitURLImportStringFromData(NSData *data); + +static NSString *SpliceKitURLImportString(id value) { + return [value isKindOfClass:[NSString class]] ? value : @""; +} + +static NSString *SpliceKitURLImportDiagnosticString(id value) { + if (!value || value == [NSNull null]) return @""; + if ([value isKindOfClass:[NSString class]]) return value; + if ([value isKindOfClass:[NSNumber class]]) return [value stringValue]; + if ([value isKindOfClass:[NSDictionary class]]) { + NSString *message = SpliceKitURLImportString(value[@"message"]); + if (message.length > 0) return message; + if ([NSJSONSerialization isValidJSONObject:value]) { + NSData *json = [NSJSONSerialization dataWithJSONObject:value options:0 error:nil]; + NSString *jsonString = SpliceKitURLImportStringFromData(json); + if (jsonString.length > 0) return jsonString; + } + } + if ([value isKindOfClass:[NSArray class]] && [NSJSONSerialization isValidJSONObject:value]) { + NSData *json = [NSJSONSerialization dataWithJSONObject:value options:0 error:nil]; + NSString *jsonString = SpliceKitURLImportStringFromData(json); + if (jsonString.length > 0) return jsonString; + } + NSString *description = [[value description] copy]; + return [description isKindOfClass:[NSString class]] ? description : @""; +} + +static NSString *SpliceKitURLImportTrimmedString(id value) { + return [SpliceKitURLImportString(value) + stringByTrimmingCharactersInSet:[NSCharacterSet whitespaceAndNewlineCharacterSet]]; +} + +static NSString *SpliceKitURLImportNormalizeURLString(id value) { + NSString *raw = SpliceKitURLImportTrimmedString(value); + if (raw.length == 0) return @""; + + NSError *detectorError = nil; + NSDataDetector *detector = [NSDataDetector dataDetectorWithTypes:NSTextCheckingTypeLink + error:&detectorError]; + if (!detectorError && detector) { + NSTextCheckingResult *match = [detector firstMatchInString:raw + options:0 + range:NSMakeRange(0, raw.length)]; + if (match.URL.absoluteString.length > 0) { + return match.URL.absoluteString; + } + } + + NSArray *parts = [raw componentsSeparatedByCharactersInSet: + [NSCharacterSet whitespaceAndNewlineCharacterSet]]; + NSMutableString *joined = [NSMutableString string]; + for (NSString *part in parts) { + if (part.length > 0) [joined appendString:part]; + } + return [joined copy]; +} + +static NSString *SpliceKitURLImportSanitizeFilename(NSString *input) { + NSString *trimmed = SpliceKitURLImportTrimmedString(input); + if (trimmed.length == 0) return @"Imported Clip"; + + NSCharacterSet *bad = [NSCharacterSet characterSetWithCharactersInString:@"/:\\?%*|\"<>"]; + NSArray *parts = [trimmed componentsSeparatedByCharactersInSet:bad]; + NSString *joined = [[parts componentsJoinedByString:@"-"] + stringByTrimmingCharactersInSet:[NSCharacterSet whitespaceAndNewlineCharacterSet]]; + + while ([joined containsString:@" "]) { + joined = [joined stringByReplacingOccurrencesOfString:@" " withString:@" "]; + } + while ([joined containsString:@"--"]) { + joined = [joined stringByReplacingOccurrencesOfString:@"--" withString:@"-"]; + } + + if (joined.length == 0) return @"Imported Clip"; + return joined; +} + +static NSString *SpliceKitURLImportEscapeXML(NSString *input) { + NSString *s = SpliceKitURLImportString(input); + s = [s stringByReplacingOccurrencesOfString:@"&" withString:@"&"]; + s = [s stringByReplacingOccurrencesOfString:@"\"" withString:@"""]; + s = [s stringByReplacingOccurrencesOfString:@"<" withString:@"<"]; + s = [s stringByReplacingOccurrencesOfString:@">" withString:@">"]; + s = [s stringByReplacingOccurrencesOfString:@"'" withString:@"'"]; + return s; +} + +static NSString *SpliceKitURLImportCMTimeString(CMTime time, NSString *fallback) { + if (CMTIME_IS_VALID(time) && !CMTIME_IS_INDEFINITE(time) && time.timescale > 0 && time.value >= 0) { + return [NSString stringWithFormat:@"%lld/%ds", time.value, time.timescale]; + } + return fallback ?: @"2400/2400s"; +} + +static BOOL SpliceKitURLImportIsDirectMediaExtension(NSString *extension) { + static NSSet *allowed = nil; + static dispatch_once_t onceToken; + dispatch_once(&onceToken, ^{ + allowed = [NSSet setWithArray:@[@"mp4", @"mov", @"m4v", @"webm"]]; + }); + return [allowed containsObject:[extension lowercaseString]]; +} + +static NSString *SpliceKitURLImportEnsureDirectory(NSString *path) { + if (path.length == 0) return @""; + [[NSFileManager defaultManager] createDirectoryAtPath:path + withIntermediateDirectories:YES + attributes:nil + error:nil]; + return path; +} + +static NSString *SpliceKitURLImportToolsDirectory(void) { + return [NSHomeDirectory() stringByAppendingPathComponent:@"Applications/SpliceKit/tools"]; +} + +static NSString *SpliceKitURLImportSharedBaseDirectory(void) { + return SpliceKitURLImportEnsureDirectory([NSHomeDirectory() + stringByAppendingPathComponent:@"Library/Application Support/SpliceKit/URLImports"]); +} + +static NSString *SpliceKitURLImportSharedDownloadsDirectory(void) { + return SpliceKitURLImportEnsureDirectory([SpliceKitURLImportSharedBaseDirectory() + stringByAppendingPathComponent:@"downloads"]); +} + +static NSString *SpliceKitURLImportExecutablePath(NSArray *candidates) { + NSFileManager *fm = [NSFileManager defaultManager]; + for (NSString *path in candidates) { + if ([fm isExecutableFileAtPath:path]) return path; + } + return nil; +} + +static NSString *SpliceKitURLImportYTDLPPath(void) { + NSString *tools = SpliceKitURLImportToolsDirectory(); + return SpliceKitURLImportExecutablePath(@[ + [tools stringByAppendingPathComponent:@"yt-dlp"], + @"/opt/homebrew/bin/yt-dlp", + @"/usr/local/bin/yt-dlp", + @"/usr/bin/yt-dlp", + ]); +} + +static NSString *SpliceKitURLImportFFmpegPath(void) { + NSString *tools = SpliceKitURLImportToolsDirectory(); + return SpliceKitURLImportExecutablePath(@[ + [tools stringByAppendingPathComponent:@"ffmpeg"], + @"/opt/homebrew/bin/ffmpeg", + @"/usr/local/bin/ffmpeg", + @"/usr/bin/ffmpeg", + ]); +} + +static NSString *SpliceKitURLImportProviderDependencyMessage(NSString *provider) { + NSString *label = provider.length > 0 ? provider : @"Provider"; + return [NSString stringWithFormat: + @"%@ import requires yt-dlp and ffmpeg. Install them with `brew install yt-dlp ffmpeg`, then re-run `make deploy` so SpliceKit can see them in ~/Applications/SpliceKit/tools/.", + label]; +} + +static NSString *SpliceKitURLImportStringFromData(NSData *data) { + if (data.length == 0) return @""; + NSString *string = [[NSString alloc] initWithData:data encoding:NSUTF8StringEncoding]; + if (string.length > 0) return string; + string = [[NSString alloc] initWithData:data encoding:NSISOLatin1StringEncoding]; + return string ?: @""; +} + +static NSDictionary *SpliceKitURLImportProviderMetadata(NSString *ytDLP, + NSURL *url, + NSString *provider, + NSString **outError) { + if (ytDLP.length == 0 || !url) { + if (outError) *outError = @"Provider metadata check could not start."; + return nil; + } + + NSTask *task = [[NSTask alloc] init]; + task.executableURL = [NSURL fileURLWithPath:ytDLP]; + task.arguments = @[ + @"--skip-download", + @"--no-playlist", + @"--no-warnings", + @"--print", @"%(live_status)s\t%(is_live)s\t%(was_live)s\t%(title)s", + url.absoluteString ?: @"" + ]; + + NSPipe *pipe = [NSPipe pipe]; + task.standardOutput = pipe; + task.standardError = pipe; + + NSError *launchError = nil; + if (![task launchAndReturnError:&launchError]) { + if (outError) { + *outError = launchError.localizedDescription ?: @"Could not launch yt-dlp metadata check."; + } + return nil; + } + + [task waitUntilExit]; + NSData *data = [[pipe fileHandleForReading] readDataToEndOfFile]; + NSString *output = SpliceKitURLImportTrimmedString(SpliceKitURLImportStringFromData(data)); + if (task.terminationStatus != 0) { + if (outError) { + *outError = output.length > 0 + ? output + : [NSString stringWithFormat:@"%@ metadata check failed.", provider ?: @"Provider"]; + } + return nil; + } + + NSArray *lines = [output componentsSeparatedByCharactersInSet: + [NSCharacterSet newlineCharacterSet]]; + NSString *lastLine = @""; + for (NSString *line in [lines reverseObjectEnumerator]) { + NSString *trimmed = SpliceKitURLImportTrimmedString(line); + if (trimmed.length > 0) { + lastLine = trimmed; + break; + } + } + + if (lastLine.length == 0) return @{}; + + NSArray *parts = [lastLine componentsSeparatedByString:@"\t"]; + NSString *liveStatus = parts.count > 0 ? SpliceKitURLImportTrimmedString(parts[0]) : @""; + NSString *isLive = parts.count > 1 ? SpliceKitURLImportTrimmedString(parts[1]) : @""; + NSString *wasLive = parts.count > 2 ? SpliceKitURLImportTrimmedString(parts[2]) : @""; + NSString *title = @""; + if (parts.count > 3) { + title = [[parts subarrayWithRange:NSMakeRange(3, parts.count - 3)] + componentsJoinedByString:@"\t"]; + title = SpliceKitURLImportTrimmedString(title); + } + + return @{ + @"live_status": liveStatus ?: @"", + @"is_live": isLive ?: @"", + @"was_live": wasLive ?: @"", + @"title": title ?: @"" + }; +} + +static double SpliceKitURLImportPercentFromLine(NSString *line) { + static NSRegularExpression *regex = nil; + static dispatch_once_t onceToken; + dispatch_once(&onceToken, ^{ + regex = [NSRegularExpression regularExpressionWithPattern:@"([0-9]+(?:\\.[0-9]+)?)%" + options:0 + error:nil]; + }); + NSTextCheckingResult *match = [regex firstMatchInString:line + options:0 + range:NSMakeRange(0, line.length)]; + if (!match || match.numberOfRanges < 2) return -1.0; + NSString *number = [line substringWithRange:[match rangeAtIndex:1]]; + return [number doubleValue]; +} + +static NSString *SpliceKitURLImportDownloadedFileMatchingPrefix(NSString *directory, NSString *prefix) { + if (directory.length == 0 || prefix.length == 0) return nil; + + NSFileManager *fm = [NSFileManager defaultManager]; + NSArray *entries = [fm contentsOfDirectoryAtPath:directory error:nil]; + NSString *bestPath = nil; + NSDate *bestDate = nil; + + for (NSString *name in entries) { + if (![name hasPrefix:prefix]) continue; + if ([name hasSuffix:@".part"] || [name hasSuffix:@".ytdl"] || [name hasSuffix:@".tmp"]) continue; + + NSString *path = [directory stringByAppendingPathComponent:name]; + NSDictionary *attrs = [fm attributesOfItemAtPath:path error:nil]; + if ([attrs[NSFileType] isEqualToString:NSFileTypeDirectory]) continue; + + NSDate *modDate = attrs[NSFileModificationDate] ?: [NSDate distantPast]; + if (!bestPath || [modDate compare:bestDate] == NSOrderedDescending) { + bestPath = path; + bestDate = modDate; + } + } + + return bestPath; +} + +@interface SpliceKitURLImportJob : NSObject +@property (nonatomic, copy) NSString *jobID; +@property (nonatomic, copy) NSString *sourceURL; +@property (nonatomic, copy) NSString *sourceType; +@property (nonatomic, copy) NSString *state; +@property (nonatomic, copy) NSString *message; +@property (nonatomic, copy) NSString *mode; +@property (nonatomic, copy) NSString *targetEvent; +@property (nonatomic, copy) NSString *titleOverride; +@property (nonatomic, copy) NSString *clipName; +@property (nonatomic, copy) NSString *downloadPath; +@property (nonatomic, copy) NSString *normalizedPath; +@property (nonatomic, copy) NSString *errorMessage; +@property (nonatomic, assign) double progress; +@property (nonatomic, assign) BOOL success; +@property (nonatomic, assign) BOOL imported; +@property (nonatomic, assign) BOOL timelineInserted; +@property (nonatomic, assign) BOOL transcoded; +@property (nonatomic, assign) BOOL cancelled; +@property (nonatomic, strong) NSDate *createdAt; +@property (nonatomic, strong) NSDate *updatedAt; +@property (nonatomic, strong) NSTask *resolverTask; +@property (nonatomic, strong) NSURLSessionDownloadTask *downloadTask; +@property (nonatomic, strong) AVAssetExportSession *exportSession; +@property (nonatomic) dispatch_semaphore_t completionSemaphore; +- (NSDictionary *)snapshot; +- (BOOL)isFinished; +@end + +@implementation SpliceKitURLImportJob + +- (instancetype)init { + self = [super init]; + if (self) { + _createdAt = [NSDate date]; + _updatedAt = [NSDate date]; + _completionSemaphore = dispatch_semaphore_create(0); + _state = SpliceKitURLImportStateQueued; + _message = @"Queued"; + } + return self; +} + +- (NSDictionary *)snapshot { + NSMutableDictionary *result = [NSMutableDictionary dictionary]; + result[@"job_id"] = self.jobID ?: @""; + result[@"success"] = @(self.success); + result[@"state"] = SpliceKitURLImportDiagnosticString(self.state); + result[@"progress"] = @((self.progress < 0.0) ? 0.0 : (self.progress > 1.0 ? 1.0 : self.progress)); + result[@"source_url"] = SpliceKitURLImportDiagnosticString(self.sourceURL); + result[@"source_type"] = SpliceKitURLImportDiagnosticString(self.sourceType ?: @"unknown"); + result[@"mode"] = SpliceKitURLImportDiagnosticString(self.mode ?: @"import_only"); + result[@"target_event"] = SpliceKitURLImportDiagnosticString(self.targetEvent); + result[@"title"] = SpliceKitURLImportDiagnosticString(self.clipName); + result[@"download_path"] = SpliceKitURLImportDiagnosticString(self.downloadPath); + result[@"normalized_path"] = SpliceKitURLImportDiagnosticString(self.normalizedPath); + result[@"transcoded"] = @(self.transcoded); + result[@"imported"] = @(self.imported); + result[@"timeline_inserted"] = @(self.timelineInserted); + result[@"message"] = SpliceKitURLImportDiagnosticString(self.message); + result[@"created_at"] = @([self.createdAt timeIntervalSince1970]); + result[@"updated_at"] = @([self.updatedAt timeIntervalSince1970]); + NSString *errorText = SpliceKitURLImportDiagnosticString(self.errorMessage); + if (errorText.length > 0) result[@"error"] = errorText; + return result; +} + +- (BOOL)isFinished { + return [self.state isEqualToString:SpliceKitURLImportStateCompleted] || + [self.state isEqualToString:SpliceKitURLImportStateFailed] || + [self.state isEqualToString:SpliceKitURLImportStateCancelled]; +} + +@end + +typedef void (^SpliceKitURLImportResolverProgressBlock)(NSString *message, double progress); +typedef void (^SpliceKitURLImportResolverCompletionBlock)(NSURL *downloadURL, + NSString *resolvedTitle, + NSString *localPath, + NSString *errorMessage); + +@protocol SpliceKitURLResolver +- (NSString *)sourceType; +- (BOOL)canResolveURL:(NSURL *)url; +- (void)resolveURL:(NSURL *)url + job:(SpliceKitURLImportJob *)job + progress:(SpliceKitURLImportResolverProgressBlock)progress + completion:(SpliceKitURLImportResolverCompletionBlock)completion; +@end + +@interface SpliceKitDirectFileResolver : NSObject +@end + +@interface SpliceKitYouTubeResolver : NSObject +@end + +@interface SpliceKitVimeoResolver : NSObject +@end + +static void SpliceKitURLImportResolveProviderURL(NSString *provider, + NSURL *url, + SpliceKitURLImportJob *job, + SpliceKitURLImportResolverProgressBlock progress, + SpliceKitURLImportResolverCompletionBlock completion) { + NSString *ytDLP = SpliceKitURLImportYTDLPPath(); + NSString *ffmpeg = SpliceKitURLImportFFmpegPath(); + if (ytDLP.length == 0 || ffmpeg.length == 0) { + completion(nil, nil, nil, SpliceKitURLImportProviderDependencyMessage(provider)); + return; + } + + if (progress) { + progress([NSString stringWithFormat:@"Resolving %@ stream...", provider ?: @"provider"], 0.04); + } + SpliceKit_log(@"[URLImport] Starting %@ resolve for %@", provider ?: @"provider", url.absoluteString ?: @""); + + dispatch_async(dispatch_get_global_queue(QOS_CLASS_USER_INITIATED, 0), ^{ + if (job.cancelled) { + completion(nil, nil, nil, @"URL import was cancelled."); + return; + } + NSString *resolvedTitle = nil; + + if (progress) { + progress([NSString stringWithFormat:@"Checking %@ stream metadata...", provider ?: @"provider"], 0.05); + } + + NSString *metadataError = nil; + NSDictionary *metadata = SpliceKitURLImportProviderMetadata(ytDLP, url, provider, &metadataError); + NSString *liveStatus = [SpliceKitURLImportTrimmedString(metadata[@"live_status"]) lowercaseString]; + NSString *isLive = [SpliceKitURLImportTrimmedString(metadata[@"is_live"]) lowercaseString]; + NSString *wasLive = [SpliceKitURLImportTrimmedString(metadata[@"was_live"]) lowercaseString]; + NSString *metadataTitle = SpliceKitURLImportTrimmedString(metadata[@"title"]); + if (metadataTitle.length > 0) resolvedTitle = metadataTitle; + + BOOL providerIsLive = [liveStatus isEqualToString:@"is_live"] || + [liveStatus isEqualToString:@"is_upcoming"] || + [isLive isEqualToString:@"true"] || + [isLive isEqualToString:@"yes"] || + [isLive isEqualToString:@"1"]; + BOOL providerWasLive = [liveStatus isEqualToString:@"post_live"] || + [wasLive isEqualToString:@"true"] || + [wasLive isEqualToString:@"yes"] || + [wasLive isEqualToString:@"1"]; + + if (providerIsLive && !providerWasLive) { + NSString *label = resolvedTitle.length > 0 ? resolvedTitle : (provider ?: @"This URL"); + NSString *detail = [NSString stringWithFormat: + @"%@ is a live or upcoming stream. SpliceKit URL Import currently supports finished videos, not active live streams.", + label]; + SpliceKit_log(@"[URLImport] %@ metadata rejected live stream: %@", provider ?: @"Provider", detail); + completion(nil, resolvedTitle, nil, detail); + return; + } + + if (metadataError.length > 0) { + SpliceKit_log(@"[URLImport] %@ metadata probe failed, continuing with download path: %@", + provider ?: @"Provider", metadataError); + } + + NSString *baseName = job.titleOverride.length > 0 + ? job.titleOverride + : (resolvedTitle.length > 0 ? resolvedTitle : job.clipName); + baseName = SpliceKitURLImportSanitizeFilename(baseName); + NSString *prefix = [NSString stringWithFormat:@"%@-%@", + baseName, + [[[NSUUID UUID] UUIDString] substringToIndex:8]]; + NSString *downloadsDir = SpliceKitURLImportSharedDownloadsDirectory(); + NSString *outputTemplate = [downloadsDir stringByAppendingPathComponent: + [NSString stringWithFormat:@"%@.%%(ext)s", prefix]]; + + NSMutableArray *args = [NSMutableArray arrayWithObjects: + @"--newline", + @"--no-playlist", + @"--restrict-filenames", + @"--no-warnings", + @"--output", outputTemplate, + @"-f", @"b[ext=mp4]/bv*[ext=mp4]+ba[ext=m4a]/b[ext=mp4]/bv*+ba/b", + @"--merge-output-format", @"mp4", + @"--ffmpeg-location", [ffmpeg stringByDeletingLastPathComponent], + url.absoluteString ?: @"", + nil]; + + NSTask *task = [[NSTask alloc] init]; + task.executableURL = [NSURL fileURLWithPath:ytDLP]; + task.arguments = args; + + NSPipe *pipe = [NSPipe pipe]; + task.standardOutput = pipe; + task.standardError = pipe; + + NSMutableString *logBuffer = [NSMutableString string]; + __block double lastMappedProgress = 0.08; + __block BOOL sawFragmentedDownload = NO; + NSFileHandle *readHandle = [pipe fileHandleForReading]; + readHandle.readabilityHandler = ^(NSFileHandle *handle) { + NSData *data = [handle availableData]; + if (data.length == 0) return; + + NSString *chunk = SpliceKitURLImportStringFromData(data); + if (chunk.length == 0) return; + + @synchronized (logBuffer) { + [logBuffer appendString:chunk]; + } + + NSArray *lines = [chunk componentsSeparatedByCharactersInSet: + [NSCharacterSet newlineCharacterSet]]; + for (NSString *rawLine in lines) { + NSString *line = SpliceKitURLImportTrimmedString(rawLine); + if (line.length == 0) continue; + + double percent = SpliceKitURLImportPercentFromLine(line); + if (percent >= 0.0) { + double mapped = 0.08 + MIN(MAX(percent, 0.0), 100.0) / 100.0 * 0.64; + if (progress && (mapped - lastMappedProgress >= 0.01 || mapped >= 0.72)) { + lastMappedProgress = mapped; + progress([NSString stringWithFormat:@"Downloading %@ media...", provider ?: @"provider"], + mapped); + } + continue; + } + + NSString *lower = [line lowercaseString]; + if ([lower containsString:@"extracting url"] || [lower containsString:@"downloading webpage"]) { + if (progress) progress([NSString stringWithFormat:@"Resolving %@ stream...", provider ?: @"provider"], 0.05); + } else if ([lower containsString:@"fragment"] || + [lower containsString:@".part-frag"] || + [lower containsString:@"hls"]) { + sawFragmentedDownload = YES; + double mapped = MIN(MAX(lastMappedProgress, 0.72) + 0.01, 0.79); + if (progress && mapped - lastMappedProgress >= 0.005) { + lastMappedProgress = mapped; + progress([NSString stringWithFormat:@"Downloading %@ media fragments...", provider ?: @"provider"], + mapped); + } + } else if ([lower containsString:@"recoding video"] || + [lower containsString:@"post-process"] || + [lower containsString:@"merging formats"]) { + lastMappedProgress = MAX(lastMappedProgress, 0.82); + if (progress) progress(@"Converting provider download to MP4...", lastMappedProgress); + } + } + }; + + task.terminationHandler = ^(__unused NSTask *finishedTask) { + readHandle.readabilityHandler = nil; + NSData *tail = [readHandle readDataToEndOfFile]; + if (tail.length > 0) { + NSString *tailString = SpliceKitURLImportStringFromData(tail); + @synchronized (logBuffer) { + [logBuffer appendString:tailString ?: @""]; + } + } + + @synchronized (job) { + job.resolverTask = nil; + } + + if (job.cancelled) { + completion(nil, resolvedTitle, nil, @"URL import was cancelled."); + return; + } + + NSString *fullLog = nil; + @synchronized (logBuffer) { + fullLog = [logBuffer copy]; + } + NSString *trimmedLog = SpliceKitURLImportTrimmedString(fullLog); + + if (task.terminationStatus != 0) { + NSString *detail = trimmedLog.length > 0 + ? trimmedLog + : [NSString stringWithFormat:@"%@ download failed through yt-dlp.", provider ?: @"Provider"]; + SpliceKit_log(@"[URLImport] %@ download failed: %@", provider ?: @"Provider", detail); + completion(nil, resolvedTitle, nil, detail); + return; + } + + NSString *downloadedFile = SpliceKitURLImportDownloadedFileMatchingPrefix(downloadsDir, prefix); + if (downloadedFile.length == 0) { + SpliceKit_log(@"[URLImport] %@ download completed but no file matched prefix %@", provider ?: @"Provider", prefix); + completion(nil, resolvedTitle, nil, + @"yt-dlp finished, but SpliceKit could not locate the downloaded file in its cache."); + return; + } + + SpliceKit_log(@"[URLImport] %@ download complete: %@", provider ?: @"Provider", downloadedFile); + if (progress) { + double finalDownloadProgress = sawFragmentedDownload ? 0.84 : 0.76; + progress(@"Provider download complete. Inspecting media...", finalDownloadProgress); + } + completion(nil, resolvedTitle, downloadedFile, nil); + }; + + NSError *launchError = nil; + @synchronized (job) { + job.resolverTask = task; + } + SpliceKit_log(@"[URLImport] Launching %@ download task via yt-dlp for %@", + provider ?: @"provider", url.absoluteString ?: @""); + if (![task launchAndReturnError:&launchError]) { + readHandle.readabilityHandler = nil; + @synchronized (job) { + job.resolverTask = nil; + } + completion(nil, + resolvedTitle, + nil, + launchError.localizedDescription ?: @"Could not launch yt-dlp download task."); + return; + } + }); +} + +@implementation SpliceKitDirectFileResolver + +- (NSString *)sourceType { return @"direct_file"; } + +- (BOOL)canResolveURL:(NSURL *)url { + NSString *ext = [[url pathExtension] lowercaseString]; + return SpliceKitURLImportIsDirectMediaExtension(ext); +} + +- (void)resolveURL:(NSURL *)url + job:(SpliceKitURLImportJob *)job + progress:(SpliceKitURLImportResolverProgressBlock)progress + completion:(SpliceKitURLImportResolverCompletionBlock)completion { + (void)job; + (void)progress; + NSString *candidate = [[url lastPathComponent] stringByDeletingPathExtension]; + completion(url, candidate, nil, nil); +} + +@end + +@implementation SpliceKitYouTubeResolver + +- (NSString *)sourceType { return @"youtube"; } + +- (BOOL)canResolveURL:(NSURL *)url { + NSString *host = [[url host] lowercaseString]; + return [host containsString:@"youtube.com"] || [host containsString:@"youtu.be"]; +} + +- (void)resolveURL:(NSURL *)url + job:(SpliceKitURLImportJob *)job + progress:(SpliceKitURLImportResolverProgressBlock)progress + completion:(SpliceKitURLImportResolverCompletionBlock)completion { + SpliceKitURLImportResolveProviderURL(@"YouTube", url, job, progress, completion); +} + +@end + +@implementation SpliceKitVimeoResolver + +- (NSString *)sourceType { return @"vimeo"; } + +- (BOOL)canResolveURL:(NSURL *)url { + NSString *host = [[url host] lowercaseString]; + return [host containsString:@"vimeo.com"]; +} + +- (void)resolveURL:(NSURL *)url + job:(SpliceKitURLImportJob *)job + progress:(SpliceKitURLImportResolverProgressBlock)progress + completion:(SpliceKitURLImportResolverCompletionBlock)completion { + SpliceKitURLImportResolveProviderURL(@"Vimeo", url, job, progress, completion); +} + +@end + +@interface SpliceKitURLImportService : NSObject +@property (nonatomic, strong) NSURLSession *session; +@property (nonatomic, strong) dispatch_queue_t stateQueue; +@property (nonatomic, strong) NSMutableDictionary *jobs; +@property (nonatomic, strong) NSMutableDictionary *taskToJob; +@property (nonatomic, strong) NSArray> *resolvers; ++ (instancetype)sharedService; +- (NSDictionary *)startImportWithParams:(NSDictionary *)params waitForCompletion:(BOOL)wait; +- (NSDictionary *)statusForJobID:(NSString *)jobID; +- (NSDictionary *)cancelJobID:(NSString *)jobID; +@end + +@implementation SpliceKitURLImportService + ++ (instancetype)sharedService { + static SpliceKitURLImportService *service = nil; + static dispatch_once_t onceToken; + dispatch_once(&onceToken, ^{ + service = [[self alloc] init]; + }); + return service; +} + +- (instancetype)init { + self = [super init]; + if (self) { + _stateQueue = dispatch_queue_create("com.splicekit.urlimport.state", DISPATCH_QUEUE_SERIAL); + _jobs = [NSMutableDictionary dictionary]; + _taskToJob = [NSMutableDictionary dictionary]; + _resolvers = @[ + [[SpliceKitDirectFileResolver alloc] init], + [[SpliceKitYouTubeResolver alloc] init], + [[SpliceKitVimeoResolver alloc] init], + ]; + + NSURLSessionConfiguration *config = [NSURLSessionConfiguration defaultSessionConfiguration]; + config.timeoutIntervalForRequest = 120.0; + config.timeoutIntervalForResource = 1800.0; + config.HTTPMaximumConnectionsPerHost = 4; + + NSOperationQueue *delegateQueue = [[NSOperationQueue alloc] init]; + delegateQueue.maxConcurrentOperationCount = 1; + _session = [NSURLSession sessionWithConfiguration:config delegate:self delegateQueue:delegateQueue]; + } + return self; +} + +- (NSString *)baseDirectory { + NSString *path = [NSHomeDirectory() stringByAppendingPathComponent: + @"Library/Application Support/SpliceKit/URLImports"]; + [[NSFileManager defaultManager] createDirectoryAtPath:path + withIntermediateDirectories:YES + attributes:nil + error:nil]; + return path; +} + +- (NSString *)downloadsDirectory { + NSString *path = [[self baseDirectory] stringByAppendingPathComponent:@"downloads"]; + [[NSFileManager defaultManager] createDirectoryAtPath:path + withIntermediateDirectories:YES + attributes:nil + error:nil]; + return path; +} + +- (NSString *)normalizedDirectory { + NSString *path = [[self baseDirectory] stringByAppendingPathComponent:@"normalized"]; + [[NSFileManager defaultManager] createDirectoryAtPath:path + withIntermediateDirectories:YES + attributes:nil + error:nil]; + return path; +} + +- (void)updateJob:(SpliceKitURLImportJob *)job + state:(NSString *)state + message:(NSString *)message + progress:(double)progress { + dispatch_async(self.stateQueue, ^{ + NSString *safeState = SpliceKitURLImportDiagnosticString(state); + NSString *safeMessage = SpliceKitURLImportDiagnosticString(message); + if (safeState.length > 0) job.state = safeState; + if (safeMessage.length > 0) job.message = safeMessage; + if (progress >= 0.0) job.progress = progress; + job.updatedAt = [NSDate date]; + }); +} + +- (void)finishJob:(SpliceKitURLImportJob *)job + success:(BOOL)success + state:(NSString *)state + message:(NSString *)message + error:(NSString *)errorMessage { + dispatch_async(self.stateQueue, ^{ + job.success = success; + NSString *safeState = SpliceKitURLImportDiagnosticString(state); + NSString *safeMessage = SpliceKitURLImportDiagnosticString(message); + NSString *safeError = SpliceKitURLImportDiagnosticString(errorMessage); + job.state = safeState.length > 0 + ? safeState + : (success ? SpliceKitURLImportStateCompleted : SpliceKitURLImportStateFailed); + job.message = safeMessage.length > 0 + ? safeMessage + : (success ? @"Completed" : @"Failed"); + job.errorMessage = safeError; + job.progress = success ? 1.0 : job.progress; + job.updatedAt = [NSDate date]; + dispatch_semaphore_signal(job.completionSemaphore); + }); +} + +- (NSDictionary *)validationError:(NSString *)message { + return @{@"success": @NO, @"error": message ?: @"Invalid request"}; +} + +- (NSString *)resolvedModeFromParams:(NSDictionary *)params { + NSString *mode = [SpliceKitURLImportTrimmedString(params[@"mode"]) lowercaseString]; + NSString *timelineAction = [SpliceKitURLImportTrimmedString(params[@"timeline_action"]) lowercaseString]; + + if (timelineAction.length > 0 && ![timelineAction isEqualToString:@"none"]) { + if ([timelineAction isEqualToString:@"append"] || + [timelineAction isEqualToString:@"append_to_timeline"]) { + return @"append_to_timeline"; + } + if ([timelineAction isEqualToString:@"start"] || + [timelineAction isEqualToString:@"insert_at_start"] || + [timelineAction isEqualToString:@"insert_at_timeline_start"]) { + return @"insert_at_timeline_start"; + } + if ([timelineAction isEqualToString:@"insert"] || + [timelineAction isEqualToString:@"insert_at_playhead"]) { + return @"insert_at_playhead"; + } + } + + if ([mode isEqualToString:@"append"] || [mode isEqualToString:@"append_to_timeline"]) { + return @"append_to_timeline"; + } + if ([mode isEqualToString:@"start"] || [mode isEqualToString:@"insert_at_start"] || + [mode isEqualToString:@"insert_at_timeline_start"]) { + return @"insert_at_timeline_start"; + } + if ([mode isEqualToString:@"insert"] || [mode isEqualToString:@"insert_at_playhead"] || + [mode isEqualToString:@"timeline"]) { + return @"insert_at_playhead"; + } + return @"import_only"; +} + +- (id)resolverForURL:(NSURL *)url { + for (id resolver in self.resolvers) { + if ([resolver canResolveURL:url]) { + return resolver; + } + } + return nil; +} + +- (NSString *)defaultClipNameForURL:(NSURL *)url titleOverride:(NSString *)titleOverride { + if (titleOverride.length > 0) return SpliceKitURLImportSanitizeFilename(titleOverride); + + NSString *fromPath = [[url lastPathComponent] stringByDeletingPathExtension]; + if (fromPath.length > 0) return SpliceKitURLImportSanitizeFilename(fromPath); + + NSString *host = url.host ?: @"Imported Clip"; + return SpliceKitURLImportSanitizeFilename(host); +} + +- (NSString *)currentTimelineEventName { + __block NSString *eventName = nil; + SpliceKit_executeOnMainThread(^{ + @try { + id timeline = SpliceKit_getActiveTimelineModule(); + if (!timeline) return; + SEL seqSel = NSSelectorFromString(@"sequence"); + id sequence = (timeline && [timeline respondsToSelector:seqSel]) + ? ((id (*)(id, SEL))objc_msgSend)(timeline, seqSel) : nil; + + SEL eventSel = NSSelectorFromString(@"event"); + SEL containerEventSel = NSSelectorFromString(@"containerEvent"); + id event = nil; + if (sequence && [sequence respondsToSelector:eventSel]) { + event = ((id (*)(id, SEL))objc_msgSend)(sequence, eventSel); + } else if (sequence && [sequence respondsToSelector:containerEventSel]) { + event = ((id (*)(id, SEL))objc_msgSend)(sequence, containerEventSel); + } + if (!event) { + @try { event = [sequence valueForKey:@"event"]; } @catch (__unused NSException *e) {} + } + if (!event) { + @try { event = [sequence valueForKey:@"containerEvent"]; } @catch (__unused NSException *e) {} + } + if (event && [event respondsToSelector:@selector(displayName)]) { + eventName = ((id (*)(id, SEL))objc_msgSend)(event, @selector(displayName)); + } + SpliceKit_log(@"[URLImport] currentTimelineEventName resolved to '%@'", eventName ?: @""); + } @catch (NSException *e) { + SpliceKit_log(@"[URLImport] Failed to read current event: %@", e.reason); + } + }); + return eventName; +} + +- (BOOL)hasActiveTimeline { + __block BOOL hasTimeline = NO; + SpliceKit_executeOnMainThread(^{ + @try { + id timeline = SpliceKit_getActiveTimelineModule(); + hasTimeline = (timeline != nil); + } @catch (NSException *e) { + SpliceKit_log(@"[URLImport] Failed to detect active timeline: %@", e.reason); + } + }); + return hasTimeline; +} + +- (NSString *)pathForFilename:(NSString *)filename directory:(NSString *)directory { + NSString *base = [[filename stringByDeletingPathExtension] copy]; + NSString *ext = [[filename pathExtension] lowercaseString]; + NSString *candidate = filename; + NSInteger suffix = 1; + + while ([[NSFileManager defaultManager] fileExistsAtPath:[directory stringByAppendingPathComponent:candidate]]) { + candidate = ext.length > 0 + ? [NSString stringWithFormat:@"%@-%ld.%@", base, (long)suffix, ext] + : [NSString stringWithFormat:@"%@-%ld", base, (long)suffix]; + suffix++; + } + + return [directory stringByAppendingPathComponent:candidate]; +} + +- (NSString *)filenameForJob:(SpliceKitURLImportJob *)job response:(NSURLResponse *)response { + NSString *suggested = response.suggestedFilename ?: @""; + NSString *ext = [[suggested pathExtension] lowercaseString]; + if (ext.length == 0) { + ext = [[[NSURL URLWithString:job.sourceURL] pathExtension] lowercaseString]; + } + if (ext.length == 0) ext = @"mp4"; + NSString *base = SpliceKitURLImportSanitizeFilename(job.clipName ?: @"Imported Clip"); + return [NSString stringWithFormat:@"%@.%@", base, ext]; +} + +- (NSDictionary *)inspectMediaAtPath:(NSString *)path { + NSString *safePath = SpliceKitURLImportTrimmedString(path); + if (safePath.length == 0) { + SpliceKit_log(@"[URLImport] inspectMediaAtPath received an empty path"); + return @{@"error": @"Downloaded media did not produce a usable local file path"}; + } + + BOOL exists = [[NSFileManager defaultManager] fileExistsAtPath:safePath]; + if (!exists) { + SpliceKit_log(@"[URLImport] inspectMediaAtPath missing file at %@", safePath); + return @{@"error": @"Downloaded media file could not be found on disk"}; + } + + SpliceKit_log(@"[URLImport] Inspecting media at %@", safePath); + + NSURL *url = [NSURL fileURLWithPath:safePath]; + AVURLAsset *asset = [AVURLAsset URLAssetWithURL:url options:nil]; + NSArray *videoTracks = [asset tracksWithMediaType:AVMediaTypeVideo]; + NSArray *audioTracks = [asset tracksWithMediaType:AVMediaTypeAudio]; + CMTime duration = asset.duration; + + if (!CMTIME_IS_VALID(duration) || CMTIME_IS_INDEFINITE(duration) || duration.timescale <= 0 || duration.value <= 0) { + return @{@"error": @"Downloaded media has no readable duration"}; + } + + AVAssetTrack *videoTrack = videoTracks.firstObject; + AVAssetTrack *audioTrack = audioTracks.firstObject; + NSString *ext = [[safePath pathExtension] lowercaseString]; + BOOL requiresNormalization = !([@[@"mp4", @"mov", @"m4v"] containsObject:ext]); + + int width = 1920; + int height = 1080; + NSString *frameDuration = @"100/2400s"; + + if (videoTrack) { + CGSize size = CGSizeApplyAffineTransform(videoTrack.naturalSize, videoTrack.preferredTransform); + width = (int)fabs(size.width); + height = (int)fabs(size.height); + if (width <= 0) width = 1920; + if (height <= 0) height = 1080; + + CMTime minFrameDuration = videoTrack.minFrameDuration; + if (CMTIME_IS_VALID(minFrameDuration) && !CMTIME_IS_INDEFINITE(minFrameDuration) && + minFrameDuration.value > 0 && minFrameDuration.timescale > 0) { + frameDuration = SpliceKitURLImportCMTimeString(minFrameDuration, frameDuration); + } else if (videoTrack.nominalFrameRate > 0.0f) { + int timescale = 2400; + int value = (int)lrint((double)timescale / videoTrack.nominalFrameRate); + if (value > 0) { + frameDuration = [NSString stringWithFormat:@"%d/%ds", value, timescale]; + } + } + } + + NSMutableDictionary *info = [NSMutableDictionary dictionary]; + info[@"duration"] = SpliceKitURLImportCMTimeString(duration, @"2400/2400s"); + info[@"width"] = @(width); + info[@"height"] = @(height); + info[@"frameDuration"] = frameDuration; + info[@"hasVideo"] = @(videoTrack != nil); + info[@"hasAudio"] = @(audioTrack != nil); + info[@"audioRate"] = @(audioTrack ? 48000 : 0); + info[@"requiresNormalization"] = @(requiresNormalization); + return info; +} + +- (NSString *)importXMLForJob:(SpliceKitURLImportJob *)job + mediaInfo:(NSDictionary *)mediaInfo + eventName:(NSString *)eventName { + NSString *uid = [[NSUUID UUID] UUIDString]; + NSString *fmtID = [NSString stringWithFormat:@"fmt_%@", [uid substringToIndex:8]]; + NSString *assetID = [NSString stringWithFormat:@"asset_%@", [uid substringToIndex:8]]; + NSString *clipName = SpliceKitURLImportEscapeXML(job.clipName ?: @"Imported Clip"); + NSString *escapedEvent = SpliceKitURLImportEscapeXML(eventName ?: @"URL Imports"); + NSString *mediaURL = [[[NSURL fileURLWithPath:(job.normalizedPath ?: job.downloadPath)] absoluteURL] absoluteString]; + NSString *duration = mediaInfo[@"duration"] ?: @"2400/2400s"; + NSString *frameDuration = mediaInfo[@"frameDuration"] ?: @"100/2400s"; + int width = [mediaInfo[@"width"] intValue] ?: 1920; + int height = [mediaInfo[@"height"] intValue] ?: 1080; + BOOL hasVideo = [mediaInfo[@"hasVideo"] boolValue]; + BOOL hasAudio = [mediaInfo[@"hasAudio"] boolValue]; + int audioRate = [mediaInfo[@"audioRate"] intValue] ?: 48000; + + NSMutableString *xml = [NSMutableString string]; + [xml appendString:@"\n"]; + [xml appendString:@"\n"]; + [xml appendString:@"\n"]; + [xml appendString:@" \n"]; + [xml appendFormat:@" \n", + fmtID, frameDuration, width, height, width, height]; + [xml appendFormat:@" \n", + assetID, clipName, uid, duration, hasVideo ? @"1" : @"0", hasAudio ? @"1" : @"0", + fmtID, hasAudio ? @"1" : @"0", audioRate]; + [xml appendFormat:@" \n", mediaURL]; + [xml appendString:@" \n"]; + [xml appendString:@" \n"]; + [xml appendFormat:@" \n", escapedEvent]; + [xml appendFormat:@" \n", + assetID, clipName, duration]; + [xml appendString:@" \n"]; + [xml appendString:@"\n"]; + return xml; +} + +- (id)findClipNamed:(NSString *)clipName inEventNamed:(NSString *)eventName { + __block id foundClip = nil; + NSString *needle = [clipName lowercaseString]; + NSString *eventNeedle = [eventName lowercaseString]; + + SpliceKit_executeOnMainThread(^{ + @try { + id libs = ((id (*)(id, SEL))objc_msgSend)( + objc_getClass("FFLibraryDocument"), @selector(copyActiveLibraries)); + if (![libs isKindOfClass:[NSArray class]] || [(NSArray *)libs count] == 0) { + return; + } + + id library = [(NSArray *)libs firstObject]; + SEL eventsSel = NSSelectorFromString(@"events"); + id events = [library respondsToSelector:eventsSel] + ? ((id (*)(id, SEL))objc_msgSend)(library, eventsSel) : nil; + if (![events isKindOfClass:[NSArray class]]) return; + + for (id event in (NSArray *)events) { + NSString *candidateEvent = @""; + if ([event respondsToSelector:@selector(displayName)]) { + candidateEvent = ((id (*)(id, SEL))objc_msgSend)(event, @selector(displayName)) ?: @""; + } + if (eventNeedle.length > 0 && + ![[candidateEvent lowercaseString] containsString:eventNeedle]) { + continue; + } + + id clips = nil; + SEL displayClipsSel = NSSelectorFromString(@"displayOwnedClips"); + SEL ownedClipsSel = NSSelectorFromString(@"ownedClips"); + SEL childItemsSel = NSSelectorFromString(@"childItems"); + if ([event respondsToSelector:displayClipsSel]) { + clips = ((id (*)(id, SEL))objc_msgSend)(event, displayClipsSel); + } else if ([event respondsToSelector:ownedClipsSel]) { + clips = ((id (*)(id, SEL))objc_msgSend)(event, ownedClipsSel); + } else if ([event respondsToSelector:childItemsSel]) { + clips = ((id (*)(id, SEL))objc_msgSend)(event, childItemsSel); + } + if ([clips isKindOfClass:[NSSet class]]) clips = [(NSSet *)clips allObjects]; + if (![clips isKindOfClass:[NSArray class]]) continue; + + for (id clip in [(NSArray *)clips reverseObjectEnumerator]) { + if (![clip respondsToSelector:@selector(displayName)]) continue; + NSString *candidateName = ((id (*)(id, SEL))objc_msgSend)(clip, @selector(displayName)) ?: @""; + if ([[candidateName lowercaseString] isEqualToString:needle]) { + foundClip = clip; + return; + } + } + } + } @catch (NSException *e) { + SpliceKit_log(@"[URLImport] Clip lookup failed: %@", e.reason); + } + }); + return foundClip; +} + +- (BOOL)selectClipInBrowser:(id)clip { + __block BOOL selected = NO; + if (!clip) return NO; + + SpliceKit_executeOnMainThread(^{ + @try { + id app = ((id (*)(id, SEL))objc_msgSend)( + objc_getClass("NSApplication"), @selector(sharedApplication)); + id delegate = ((id (*)(id, SEL))objc_msgSend)(app, @selector(delegate)); + if (!delegate) return; + + id browserContainer = nil; + SEL browserSel = NSSelectorFromString(@"mediaBrowserContainerModule"); + if ([delegate respondsToSelector:browserSel]) { + browserContainer = ((id (*)(id, SEL))objc_msgSend)(delegate, browserSel); + } + + Class rangeObjClass = objc_getClass("FigTimeRangeAndObject"); + if (!rangeObjClass) return; + + CMTimeRange clipRange = kCMTimeRangeZero; + SEL clippedRangeSel = NSSelectorFromString(@"clippedRange"); + SEL durationSel = NSSelectorFromString(@"duration"); + if ([clip respondsToSelector:clippedRangeSel]) { + clipRange = ((CMTimeRange (*)(id, SEL))SPLICEKIT_URLIMPORT_STRET_MSG)(clip, clippedRangeSel); + } else if ([clip respondsToSelector:durationSel]) { + CMTime dur = ((CMTime (*)(id, SEL))SPLICEKIT_URLIMPORT_STRET_MSG)(clip, durationSel); + clipRange = CMTimeRangeMake(kCMTimeZero, dur); + } + + SEL rangeAndObjSel = NSSelectorFromString(@"rangeAndObjectWithRange:andObject:"); + if (![(id)rangeObjClass respondsToSelector:rangeAndObjSel]) return; + id mediaRange = ((id (*)(id, SEL, CMTimeRange, id))objc_msgSend)( + (id)rangeObjClass, rangeAndObjSel, clipRange, clip); + if (!mediaRange) return; + + id filmstrip = nil; + SEL filmstripSel = NSSelectorFromString(@"filmstripModule"); + if (browserContainer && [browserContainer respondsToSelector:filmstripSel]) { + filmstrip = ((id (*)(id, SEL))objc_msgSend)(browserContainer, filmstripSel); + } + if (!filmstrip) { + SEL orgSel = NSSelectorFromString(@"organizerModule"); + id organizer = [delegate respondsToSelector:orgSel] + ? ((id (*)(id, SEL))objc_msgSend)(delegate, orgSel) : nil; + SEL itemsSel = NSSelectorFromString(@"itemsModule"); + if (organizer && [organizer respondsToSelector:itemsSel]) { + filmstrip = ((id (*)(id, SEL))objc_msgSend)(organizer, itemsSel); + } + } + SEL selectSel = NSSelectorFromString(@"_selectMediaRanges:"); + if (filmstrip && [filmstrip respondsToSelector:selectSel]) { + ((void (*)(id, SEL, id))objc_msgSend)(filmstrip, selectSel, @[mediaRange]); + [[NSRunLoop currentRunLoop] runUntilDate:[NSDate dateWithTimeIntervalSinceNow:0.1]]; + selected = YES; + } + } @catch (NSException *e) { + SpliceKit_log(@"[URLImport] Browser selection failed: %@", e.reason); + } + }); + + return selected; +} + +- (NSDictionary *)performMediaInsertAction:(NSString *)selectorName { + __block NSDictionary *result = nil; + SpliceKit_executeOnMainThread(^{ + @try { + id app = ((id (*)(id, SEL))objc_msgSend)( + objc_getClass("NSApplication"), @selector(sharedApplication)); + BOOL sent = ((BOOL (*)(id, SEL, SEL, id, id))objc_msgSend)( + app, @selector(sendAction:to:from:), + NSSelectorFromString(selectorName), nil, nil); + result = sent + ? @{@"status": @"ok", @"action": selectorName} + : @{@"error": [NSString stringWithFormat:@"No responder handled %@", selectorName]}; + } @catch (NSException *e) { + result = @{@"error": [NSString stringWithFormat:@"Exception: %@", e.reason]}; + } + }); + return result ?: @{@"error": @"Failed to execute media insert action"}; +} + +- (void)performTimelineInsertionForJob:(SpliceKitURLImportJob *)job { + if (![self hasActiveTimeline]) { + [self finishJob:job + success:YES + state:SpliceKitURLImportStateCompleted + message:@"Imported to event, but there is no active project to place it into." + error:nil]; + return; + } + + [self updateJob:job state:SpliceKitURLImportStateInserting + message:@"Placing imported clip into the active timeline..." + progress:0.97]; + + dispatch_async(dispatch_get_global_queue(QOS_CLASS_USER_INITIATED, 0), ^{ + id clip = nil; + for (NSInteger attempt = 0; attempt < 12 && !clip; attempt++) { + clip = [self findClipNamed:job.clipName inEventNamed:job.targetEvent]; + if (!clip) [NSThread sleepForTimeInterval:0.25]; + } + + if (!clip) { + [self finishJob:job + success:YES + state:SpliceKitURLImportStateCompleted + message:@"Imported to the event, but could not find the new browser clip to place on the timeline." + error:nil]; + return; + } + + NSString *clipHandle = SpliceKit_storeHandle(clip); + if (clipHandle.length == 0) { + [self finishJob:job + success:YES + state:SpliceKitURLImportStateCompleted + message:@"Imported to the event, but could not prepare the browser clip for timeline placement." + error:nil]; + return; + } + + if ([job.mode isEqualToString:@"insert_at_timeline_start"]) { + NSDictionary *seekResponse = SpliceKit_handleRequest(@{ + @"method": @"playback.seekToTime", + @"params": @{@"seconds": @0} + }); + NSDictionary *seekResult = seekResponse[@"result"] ?: seekResponse; + if (seekResult[@"error"]) { + [self finishJob:job + success:YES + state:SpliceKitURLImportStateCompleted + message:@"Imported to the event, but could not move the playhead to the timeline start." + error:seekResult[@"error"]]; + return; + } + [NSThread sleepForTimeInterval:0.1]; + } + + NSString *method = [job.mode isEqualToString:@"append_to_timeline"] + ? @"browser.appendClip" + : @"browser.insertClip"; + NSDictionary *insertResponse = SpliceKit_handleRequest(@{ + @"method": method, + @"params": @{@"handle": clipHandle} + }); + NSDictionary *insertResult = insertResponse[@"result"] ?: insertResponse; + if (insertResult[@"error"]) { + [self finishJob:job + success:YES + state:SpliceKitURLImportStateCompleted + message:@"Imported to the event, but timeline placement failed." + error:insertResult[@"error"]]; + return; + } + + dispatch_async(self.stateQueue, ^{ + job.timelineInserted = YES; + }); + NSString *message = nil; + if ([job.mode isEqualToString:@"append_to_timeline"]) { + message = @"Downloaded, imported, and appended to the active timeline."; + } else if ([job.mode isEqualToString:@"insert_at_timeline_start"]) { + message = @"Downloaded, imported, and inserted at the timeline start."; + } else { + message = @"Downloaded, imported, and inserted at the playhead."; + } + [self finishJob:job success:YES state:SpliceKitURLImportStateCompleted message:message error:nil]; + }); +} + +- (void)importJobIntoFinalCut:(SpliceKitURLImportJob *)job mediaInfo:(NSDictionary *)mediaInfo { + [self updateJob:job state:SpliceKitURLImportStateImporting + message:@"Importing media into Final Cut Pro..." + progress:0.92]; + + NSString *eventName = job.targetEvent.length > 0 ? job.targetEvent : [self currentTimelineEventName]; + if (eventName.length == 0) eventName = @"URL Imports"; + dispatch_async(self.stateQueue, ^{ + job.targetEvent = eventName; + }); + + NSString *xml = [self importXMLForJob:job mediaInfo:mediaInfo eventName:eventName]; + NSDictionary *response = SpliceKit_handleRequest(@{ + @"method": @"fcpxml.import", + @"params": @{@"xml": xml, @"internal": @YES} + }); + NSDictionary *result = response[@"result"] ?: response; + if (result[@"error"]) { + [self finishJob:job + success:NO + state:SpliceKitURLImportStateFailed + message:@"Final Cut import failed." + error:result[@"error"]]; + return; + } + + dispatch_async(self.stateQueue, ^{ + job.imported = YES; + }); + + if ([job.mode isEqualToString:@"import_only"]) { + [self finishJob:job + success:YES + state:SpliceKitURLImportStateCompleted + message:@"Downloaded and imported into the current event." + error:nil]; + return; + } + + [self performTimelineInsertionForJob:job]; +} + +- (void)normalizeJob:(SpliceKitURLImportJob *)job { + NSString *sourcePath = SpliceKitURLImportTrimmedString(job.downloadPath); + SpliceKit_log(@"[URLImport] normalizeJob starting for %@ with path %@", + job.jobID ?: @"", sourcePath); + NSDictionary *mediaInfo = [self inspectMediaAtPath:sourcePath]; + if (mediaInfo[@"error"]) { + [self finishJob:job + success:NO + state:SpliceKitURLImportStateFailed + message:@"Downloaded file could not be inspected." + error:mediaInfo[@"error"]]; + return; + } + + BOOL requiresNormalization = [mediaInfo[@"requiresNormalization"] boolValue]; + if (!requiresNormalization) { + job.normalizedPath = sourcePath; + [self importJobIntoFinalCut:job mediaInfo:mediaInfo]; + return; + } + + [self updateJob:job state:SpliceKitURLImportStateNormalizing + message:@"Normalizing media for Final Cut Pro..." + progress:0.82]; + + NSURL *sourceURL = [NSURL fileURLWithPath:sourcePath]; + AVURLAsset *asset = [AVURLAsset URLAssetWithURL:sourceURL options:nil]; + AVAssetExportSession *export = [[AVAssetExportSession alloc] initWithAsset:asset + presetName:AVAssetExportPresetHighestQuality]; + if (!export) { + [self finishJob:job + success:NO + state:SpliceKitURLImportStateFailed + message:@"Media normalization could not start." + error:@"Could not create AVAssetExportSession for this file."]; + return; + } + + NSString *outputName = [NSString stringWithFormat:@"%@.mov", + SpliceKitURLImportSanitizeFilename(job.clipName ?: @"Imported Clip")]; + NSString *outputPath = [self pathForFilename:outputName directory:[self normalizedDirectory]]; + [[NSFileManager defaultManager] removeItemAtPath:outputPath error:nil]; + + export.outputURL = [NSURL fileURLWithPath:outputPath]; + export.outputFileType = [export.supportedFileTypes containsObject:AVFileTypeQuickTimeMovie] + ? AVFileTypeQuickTimeMovie + : export.supportedFileTypes.firstObject; + export.shouldOptimizeForNetworkUse = YES; + + dispatch_async(self.stateQueue, ^{ + job.exportSession = export; + }); + + [export exportAsynchronouslyWithCompletionHandler:^{ + switch (export.status) { + case AVAssetExportSessionStatusCompleted: { + dispatch_async(self.stateQueue, ^{ + job.normalizedPath = outputPath; + job.transcoded = YES; + job.exportSession = nil; + }); + NSMutableDictionary *normalizedInfo = [mediaInfo mutableCopy]; + normalizedInfo[@"requiresNormalization"] = @NO; + [self importJobIntoFinalCut:job mediaInfo:normalizedInfo]; + break; + } + case AVAssetExportSessionStatusCancelled: + [self finishJob:job + success:NO + state:SpliceKitURLImportStateCancelled + message:@"URL import was cancelled during normalization." + error:nil]; + break; + default: { + NSString *errorMessage = export.error.localizedDescription ?: @"Media normalization failed."; + [self finishJob:job + success:NO + state:SpliceKitURLImportStateFailed + message:@"Media normalization failed." + error:errorMessage]; + break; + } + } + }]; +} + +- (void)beginDownloadForJob:(SpliceKitURLImportJob *)job downloadURL:(NSURL *)downloadURL { + [self updateJob:job state:SpliceKitURLImportStateDownloading + message:@"Downloading media..." + progress:0.05]; + NSURLSessionDownloadTask *task = [self.session downloadTaskWithURL:downloadURL]; + dispatch_async(self.stateQueue, ^{ + job.downloadTask = task; + self.taskToJob[@(task.taskIdentifier)] = job.jobID; + }); + [task resume]; +} + +- (NSDictionary *)startImportWithParams:(NSDictionary *)params waitForCompletion:(BOOL)wait { + NSString *rawURLString = SpliceKitURLImportTrimmedString(params[@"url"]); + NSString *urlString = SpliceKitURLImportNormalizeURLString(params[@"url"]); + if (urlString.length == 0) return [self validationError:@"url parameter required"]; + if (rawURLString.length > 0 && ![rawURLString isEqualToString:urlString]) { + SpliceKit_log(@"[URLImport] Normalized pasted URL from '%@' to '%@'", rawURLString, urlString); + } + + NSURL *url = [NSURL URLWithString:urlString]; + if (!url || url.scheme.length == 0 || url.host.length == 0) { + return [self validationError:@"Invalid URL. Provide a full https:// or http:// media URL."]; + } + + id resolver = [self resolverForURL:url]; + if (!resolver) { + return [self validationError: + @"Unsupported URL source. This build supports direct .mp4/.mov/.m4v/.webm links plus YouTube and Vimeo URLs through yt-dlp."]; + } + + SpliceKitURLImportJob *job = [[SpliceKitURLImportJob alloc] init]; + job.jobID = [[NSUUID UUID] UUIDString]; + job.sourceURL = urlString; + job.sourceType = [resolver sourceType]; + job.mode = [self resolvedModeFromParams:params]; + job.targetEvent = SpliceKitURLImportTrimmedString(params[@"target_event"]); + job.titleOverride = SpliceKitURLImportTrimmedString(params[@"title"]); + job.clipName = [self defaultClipNameForURL:url titleOverride:job.titleOverride]; + job.progress = 0.01; + job.message = @"Resolving URL..."; + job.state = SpliceKitURLImportStateResolving; + + dispatch_sync(self.stateQueue, ^{ + self.jobs[job.jobID] = job; + }); + + [resolver resolveURL:url + job:job + progress:^(NSString *message, double progress) { + NSString *state = SpliceKitURLImportStateResolving; + NSString *lowerMessage = [SpliceKitURLImportString(message) lowercaseString]; + if ([lowerMessage containsString:@"downloading"]) { + state = SpliceKitURLImportStateDownloading; + } else if ([lowerMessage containsString:@"converting"] || + [lowerMessage containsString:@"normalizing"] || + [lowerMessage containsString:@"inspecting media"]) { + state = SpliceKitURLImportStateNormalizing; + } + [self updateJob:job + state:state + message:message + progress:progress]; + } + completion:^(NSURL *downloadURL, + NSString *resolvedTitle, + NSString *localPath, + NSString *errorMessage) { + if (job.cancelled) return; + + if (errorMessage.length > 0 || (!downloadURL && localPath.length == 0)) { + [self finishJob:job + success:NO + state:SpliceKitURLImportStateFailed + message:@"Could not resolve the media URL." + error:errorMessage ?: @"Resolver returned no download URL."]; + return; + } + + if (resolvedTitle.length > 0 && job.titleOverride.length == 0) { + job.clipName = SpliceKitURLImportSanitizeFilename(resolvedTitle); + } + + if (localPath.length > 0) { + job.downloadPath = localPath; + if (job.titleOverride.length == 0) { + NSString *downloadName = [[localPath lastPathComponent] stringByDeletingPathExtension]; + if (downloadName.length > 0) { + job.clipName = SpliceKitURLImportSanitizeFilename(downloadName); + } + } + [self updateJob:job + state:SpliceKitURLImportStateNormalizing + message:@"Download complete. Inspecting media..." + progress:0.78]; + [self normalizeJob:job]; + return; + } + + [self beginDownloadForJob:job downloadURL:downloadURL]; + }]; + + if (!wait) { + return [job snapshot]; + } + + NSTimeInterval timeout = 900.0; + NSNumber *timeoutNum = params[@"timeout_seconds"]; + if ([timeoutNum respondsToSelector:@selector(doubleValue)] && [timeoutNum doubleValue] > 0) { + timeout = [timeoutNum doubleValue]; + } + + long waitResult = dispatch_semaphore_wait(job.completionSemaphore, + dispatch_time(DISPATCH_TIME_NOW, (int64_t)(timeout * NSEC_PER_SEC))); + if (waitResult != 0) { + [self finishJob:job + success:NO + state:SpliceKitURLImportStateFailed + message:@"Timed out waiting for URL import to finish." + error:@"Timed out waiting for URL import to finish."]; + } + return [self statusForJobID:job.jobID]; +} + +- (NSDictionary *)statusForJobID:(NSString *)jobID { + __block NSDictionary *snapshot = nil; + dispatch_sync(self.stateQueue, ^{ + SpliceKitURLImportJob *job = self.jobs[jobID]; + snapshot = job ? [job snapshot] : nil; + }); + return snapshot ?: @{@"success": @NO, @"error": @"Unknown job_id"}; +} + +- (NSDictionary *)cancelJobID:(NSString *)jobID { + __block NSDictionary *snapshot = nil; + dispatch_sync(self.stateQueue, ^{ + SpliceKitURLImportJob *job = self.jobs[jobID]; + if (!job) { + snapshot = @{@"success": @NO, @"error": @"Unknown job_id"}; + return; + } + + job.cancelled = YES; + @synchronized (job) { + if (job.resolverTask) { + [job.resolverTask terminate]; + job.resolverTask = nil; + } + if (job.downloadTask) { + [job.downloadTask cancel]; + job.downloadTask = nil; + } + if (job.exportSession) { + [job.exportSession cancelExport]; + job.exportSession = nil; + } + } + if (![job isFinished]) { + job.state = SpliceKitURLImportStateCancelled; + job.message = @"Cancelled"; + job.updatedAt = [NSDate date]; + dispatch_semaphore_signal(job.completionSemaphore); + } + snapshot = [job snapshot]; + }); + return snapshot; +} + +#pragma mark - NSURLSessionDownloadDelegate + +- (void)URLSession:(NSURLSession *)session + downloadTask:(NSURLSessionDownloadTask *)downloadTask + didWriteData:(int64_t)bytesWritten +totalBytesWritten:(int64_t)totalBytesWritten +totalBytesExpectedToWrite:(int64_t)totalBytesExpectedToWrite { + (void)session; + __block SpliceKitURLImportJob *job = nil; + dispatch_sync(self.stateQueue, ^{ + NSString *jobID = self.taskToJob[@(downloadTask.taskIdentifier)]; + job = jobID ? self.jobs[jobID] : nil; + }); + if (!job || totalBytesExpectedToWrite <= 0) return; + + double fraction = (double)totalBytesWritten / (double)totalBytesExpectedToWrite; + double progress = 0.05 + MIN(MAX(fraction, 0.0), 1.0) * 0.65; + NSString *message = [NSString stringWithFormat:@"Downloading media... %.0f%%", fraction * 100.0]; + [self updateJob:job state:SpliceKitURLImportStateDownloading message:message progress:progress]; +} + +- (void)URLSession:(NSURLSession *)session + downloadTask:(NSURLSessionDownloadTask *)downloadTask +didFinishDownloadingToURL:(NSURL *)location { + (void)session; + __block SpliceKitURLImportJob *job = nil; + dispatch_sync(self.stateQueue, ^{ + NSString *jobID = self.taskToJob[@(downloadTask.taskIdentifier)]; + job = jobID ? self.jobs[jobID] : nil; + [self.taskToJob removeObjectForKey:@(downloadTask.taskIdentifier)]; + if (job) job.downloadTask = nil; + }); + if (!job || job.cancelled) return; + + NSString *filename = [self filenameForJob:job response:downloadTask.response]; + NSString *destinationPath = [self pathForFilename:filename directory:[self downloadsDirectory]]; + NSError *moveError = nil; + [[NSFileManager defaultManager] removeItemAtPath:destinationPath error:nil]; + [[NSFileManager defaultManager] moveItemAtURL:location + toURL:[NSURL fileURLWithPath:destinationPath] + error:&moveError]; + if (moveError) { + [self finishJob:job + success:NO + state:SpliceKitURLImportStateFailed + message:@"Downloaded file could not be moved into the SpliceKit cache." + error:moveError.localizedDescription]; + return; + } + + job.downloadPath = destinationPath; + if (job.titleOverride.length == 0) { + NSString *downloadName = [[destinationPath lastPathComponent] stringByDeletingPathExtension]; + if (downloadName.length > 0) { + job.clipName = SpliceKitURLImportSanitizeFilename(downloadName); + } + } + + [self normalizeJob:job]; +} + +- (void)URLSession:(NSURLSession *)session + task:(NSURLSessionTask *)task +didCompleteWithError:(NSError *)error { + (void)session; + if (!error) return; + + __block SpliceKitURLImportJob *job = nil; + dispatch_sync(self.stateQueue, ^{ + NSString *jobID = self.taskToJob[@(task.taskIdentifier)]; + job = jobID ? self.jobs[jobID] : nil; + [self.taskToJob removeObjectForKey:@(task.taskIdentifier)]; + if (job) job.downloadTask = nil; + }); + if (!job || [job isFinished]) return; + + NSString *state = (error.code == NSURLErrorCancelled || job.cancelled) + ? SpliceKitURLImportStateCancelled + : SpliceKitURLImportStateFailed; + NSString *message = [state isEqualToString:SpliceKitURLImportStateCancelled] + ? @"URL import was cancelled." + : @"Download failed."; + [self finishJob:job + success:NO + state:state + message:message + error:[state isEqualToString:SpliceKitURLImportStateCancelled] ? nil : error.localizedDescription]; +} + +@end + +NSDictionary *SpliceKitURLImport_start(NSDictionary *params) { + return [[SpliceKitURLImportService sharedService] startImportWithParams:params waitForCompletion:NO]; +} + +NSDictionary *SpliceKitURLImport_importSync(NSDictionary *params) { + return [[SpliceKitURLImportService sharedService] startImportWithParams:params waitForCompletion:YES]; +} + +NSDictionary *SpliceKitURLImport_status(NSDictionary *params) { + NSString *jobID = SpliceKitURLImportTrimmedString(params[@"job_id"]); + if (jobID.length == 0) return @{@"success": @NO, @"error": @"job_id parameter required"}; + return [[SpliceKitURLImportService sharedService] statusForJobID:jobID]; +} + +NSDictionary *SpliceKitURLImport_cancel(NSDictionary *params) { + NSString *jobID = SpliceKitURLImportTrimmedString(params[@"job_id"]); + if (jobID.length == 0) return @{@"success": @NO, @"error": @"job_id parameter required"}; + return [[SpliceKitURLImportService sharedService] cancelJobID:jobID]; +} diff --git a/mcp/server.py b/mcp/server.py index a7889cd..9f69a02 100644 --- a/mcp/server.py +++ b/mcp/server.py @@ -726,6 +726,55 @@ def import_fcpxml(xml: str, internal: bool = True) -> str: return _fmt(r) +@mcp.tool() +def import_url(url: str, mode: str = "import_only", target_event: str = "", + title: str = "", wait_until_complete: bool = True) -> str: + """Download a remote media URL, import it into Final Cut Pro, and optionally + place it into the active timeline. + + Args: + url: Direct media URL (.mp4, .mov, .m4v, .webm) or a supported provider URL + like YouTube or Vimeo. + mode: "import_only", "insert_at_playhead", or "append_to_timeline". + target_event: Optional event name override. + title: Optional clip title override. + wait_until_complete: If False, returns immediately with a job_id that can + be polled via import_url_status(). + + Notes: + Provider URLs rely on yt-dlp + ffmpeg being available to the modded app. + """ + params = {"url": url, "mode": mode} + if target_event: + params["target_event"] = target_event + if title: + params["title"] = title + + method = "urlImport.import" if wait_until_complete else "urlImport.start" + r = bridge.call(method, **params) + if _err(r): + return f"Error: {r.get('error', r)}" + return _fmt(r) + + +@mcp.tool() +def import_url_status(job_id: str) -> str: + """Check the current status of a URL import job.""" + r = bridge.call("urlImport.status", job_id=job_id) + if _err(r): + return f"Error: {r.get('error', r)}" + return _fmt(r) + + +@mcp.tool() +def cancel_import_url(job_id: str) -> str: + """Cancel an in-flight URL import job.""" + r = bridge.call("urlImport.cancel", job_id=job_id) + if _err(r): + return f"Error: {r.get('error', r)}" + return _fmt(r) + + @mcp.tool() def generate_fcpxml(event_name: str = "SpliceKit Event", project_name: str = "SpliceKit Project", frame_rate: str = "24", width: int = 1920, height: int = 1080, diff --git a/tests/test_mcp_endpoints.py b/tests/test_mcp_endpoints.py index fd0aca0..b714e2e 100644 --- a/tests/test_mcp_endpoints.py +++ b/tests/test_mcp_endpoints.py @@ -222,6 +222,15 @@ def test_fcpxml(): expect_error("pasteImport (no xml)", rpc("fcpxml.pasteImport", {}), "xml") +def test_url_import(): + print("\n[urlImport.*]") + expect_error("import (no url)", rpc("urlImport.import", {}), "url") + expect_error("import (bad url)", rpc("urlImport.import", {"url": "not-a-url"}), "Invalid URL") + expect_error("status (no job_id)", rpc("urlImport.status", {}), "job_id") + expect_error("status (unknown job)", rpc("urlImport.status", {"job_id": "missing-job"}), "Unknown") + expect_error("cancel (no job_id)", rpc("urlImport.cancel", {}), "job_id") + + def test_inspector(): print("\n[inspector.*]") r = rpc("inspector.get") @@ -573,6 +582,7 @@ def test_new_actions(): "effects": test_effects, "transitions": test_transitions, "fcpxml": test_fcpxml, + "url_import": test_url_import, "inspector": test_inspector, "menu": test_menu, "tool": test_tool,