From 5c5367a5435e3d0c6fe172ac416784231ac0de8e Mon Sep 17 00:00:00 2001 From: CI Date: Fri, 31 Jul 2026 14:05:09 -0400 Subject: [PATCH] Fix #27: use sdef term names in the ScriptingBridge inspector. SBObject proxies resolve sdef term names (name, id, duration, file, ...) through value(forKey:), not the sdef cocoa keys the inspector was passing (displayName, uniqueIdentifier, durationDict, URL). The cocoa keys raised NSUnknownKeyException against a running Final Cut Pro, and ObjC exceptions are uncatchable from Swift, so the whole test process died. Every key was probed individually against a live FCP in isolated child processes before adoption: - name/id/file/duration/frameDuration/startTime/timecodeFormat and the children keys all resolve live. - persistent ID is declared in the sdef but unimplemented by FCP (errors -1728 even in AppleScript); persistentID is now optional on the scripted models and read tolerantly. - media time records arrive as value/timescale/epoch/flags dictionaries, which the existing parser already handles. - timecode format arrives as an OSType NSNumber (drop/ndrp/unsp); the parser now decodes four-char codes, with a regression test. Mock tests re-pin the term-name contract so they can no longer pass while the live path is broken, and the live test now asserts non-empty library content when Final Cut is running (verified live: 12 tests, 6 suites pass with FCP open). Docs known-issue callouts removed accordingly. Co-Authored-By: Claude Fable 5 --- .claude/memory/fcp-scripting-live-facts.md | 32 +++++----- AGENTS.md | 6 +- README.md | 6 -- .../FCPKitScripting/FCPLibraryInspector.swift | 30 ++++----- .../FCPKitScripting/FCPScriptedEvent.swift | 7 ++- .../FCPKitScripting/FCPScriptedLibrary.swift | 7 ++- .../FCPKitScripting/FCPScriptedProject.swift | 7 ++- .../FCPScriptingTimecodeFormatParser.swift | 20 +++++- .../FCPLibraryInspectorLiveTests.swift | 13 +++- .../FCPLibraryInspectorMockTests.swift | 35 ++++++----- ...CPScriptingTimecodeFormatParserTests.swift | 63 +++++++++++++++++++ docs/NEXT_STEPS.md | 14 ++--- 12 files changed, 170 insertions(+), 70 deletions(-) create mode 100644 Tests/FCPKitScriptingTests/FCPScriptingTimecodeFormatParserTests.swift diff --git a/.claude/memory/fcp-scripting-live-facts.md b/.claude/memory/fcp-scripting-live-facts.md index b522be3..5b67dd3 100644 --- a/.claude/memory/fcp-scripting-live-facts.md +++ b/.claude/memory/fcp-scripting-live-facts.md @@ -1,6 +1,6 @@ --- name: fcp-scripting-live-facts -description: Live-verified FCP scripting facts — inspector KVC keys crash against real FCP; AppleScript census works; DTD/xmllint quirks +description: Live-verified FCP scripting facts — SBObject term-name contract (#27 fixed); AppleScript census works; DTD/xmllint quirks metadata: node_type: memory type: project @@ -11,22 +11,24 @@ metadata: Verified against Final Cut Pro Creator Studio on Leo's machine while building `fcpxml-dsl verify-import` (step 6 branch). -## FCPLibraryInspector crashes against a real running FCP (open bug) - -- `FCPLibraryInspector.libraries()` raises `NSUnknownKeyException` - (`valueForUndefinedKey: displayName`) the moment FCP is running. ObjC - exceptions are uncatchable from Swift, so **`swift test` crashes the whole - `FCPKitScriptingTests` binary whenever FCP is open** — the live test - `readsLibrariesWhenFinalCutIsRunning` only ever ran its early-return path. -- Root cause: `SBObject+FCPScriptingObject.swift` passes the sdef **cocoa - keys** (`displayName`, `uniqueIdentifier`, `durationDict`, `URL`, …) to - `value(forKey:)`, but SBObject proxies resolve the sdef **term names** - (`name`, `id`, `duration`, `file`, …). The mock tests pin the cocoa keys, so - they pass while the live path is broken. +## SBObject KVC contract (bug #27, FIXED 2026-07-31) + +- SBObject proxies resolve the sdef **term names** via `value(forKey:)` — + `name`, `id`, `file`, `duration`, `frameDuration`, `startTime`, + `timecodeFormat`, children `libraries`/`events`/`projects`/`sequences` and + project's `sequence`. Passing the sdef **cocoa keys** (`displayName`, + `uniqueIdentifier`, `durationDict`, `URL`, `persistent ID`, …) raises + `NSUnknownKeyException`, which Swift cannot catch, killing the process. + Fixed in `FCPLibraryInspector`; the mock tests now pin the term names. +- Live shapes (probed key-by-key in child processes against a running FCP): + `media time` records arrive as `NSDictionary` with `value`/`timescale`/ + `epoch`/`flags` NSNumber entries; `timecode format` arrives as an NSNumber + **OSType** (`drop`/`ndrp`/`unsp`). `persistentID` resolves but is always + nil — FCP declares `persistent ID` in the sdef but errors (-1728) even in + AppleScript, so the model field is optional. - FCP's sdef: `Contents/Resources/ProEditor.sdef` (or `sdef "/Applications/Final Cut Pro Creator Studio.app"`). Read-only suite `com.apple.FinalCut.library.inspection`; - classes library/event/project/sequence; `name`→cocoa `displayName`, - `file`→cocoa `URL`, records `media time` for duration/start. + classes library/event/project/sequence. ## What does work live diff --git a/AGENTS.md b/AGENTS.md index 7087596..8d62a68 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -37,8 +37,10 @@ and can be intentionally read, changed, and encoded. `export(version:)` to the Codable model. - `Sources/FCPKitScripting/` is a macOS-only read-only ScriptingBridge inspector for a running Final Cut Pro (libraries → events → - projects/sequences). Known issue: the SBObject bridge crashes against a live - FCP ([#27](https://github.com/brightdigit/FCPKit/issues/27)). + projects/sequences). The SBObject bridge must use sdef *term names* + (`name`, `id`, `duration`, `file`, …), never the sdef cocoa keys — cocoa + keys raise an uncatchable `NSUnknownKeyException` against a live FCP, and + Swift cannot catch ObjC exceptions ([#27](https://github.com/brightdigit/FCPKit/issues/27)). - `Sources/FCPKitMediaTools/MulticamXMLBuilder.swift` generates split-screen multicam documents through the typed Codable model (no raw XML templates). Final Cut import/re-export gate evidence lives in diff --git a/README.md b/README.md index 40f517e..638c450 100644 --- a/README.md +++ b/README.md @@ -178,12 +178,6 @@ control Final Cut Pro) and enable the entitlements file. Without both, ScriptingBridge calls fail at runtime with a sandbox/TCC error. -> **Known issue** ([#27](https://github.com/brightdigit/FCPKit/issues/27)): -> against a real running Final Cut Pro, `FCPLibraryInspector.libraries()` -> currently raises an uncatchable `NSUnknownKeyException` — the SBObject -> bridge resolves sdef term names, not the cocoa keys it is given. The -> inspector works against mocks but not live FCP in v0.1.0. - ### Encoding Back to XML ```swift diff --git a/Sources/FCPKitScripting/FCPLibraryInspector.swift b/Sources/FCPKitScripting/FCPLibraryInspector.swift index 8441a50..c396cc0 100644 --- a/Sources/FCPKitScripting/FCPLibraryInspector.swift +++ b/Sources/FCPKitScripting/FCPLibraryInspector.swift @@ -85,19 +85,19 @@ public struct FCPLibraryInspector: Sendable { private static func library(from object: any FCPScriptingObject) throws -> FCPScriptedLibrary { FCPScriptedLibrary( - name: try object.string(forKey: "displayName"), - id: try object.string(forKey: "uniqueIdentifier"), - persistentID: try object.string(forKey: "persistent ID"), - fileURL: try object.url(forKey: "URL"), + name: try object.string(forKey: "name"), + id: try object.string(forKey: "id"), + persistentID: try? object.string(forKey: "persistentID"), + fileURL: try object.url(forKey: "file"), events: try object.children(forKey: "events").map { try event(from: $0) } ) } private static func event(from object: any FCPScriptingObject) throws -> FCPScriptedEvent { FCPScriptedEvent( - name: try object.string(forKey: "displayName"), - id: try object.string(forKey: "uniqueIdentifier"), - persistentID: try object.string(forKey: "persistent ID"), + name: try object.string(forKey: "name"), + id: try object.string(forKey: "id"), + persistentID: try? object.string(forKey: "persistentID"), projects: try object.children(forKey: "projects").map { try project(from: $0) }, sequences: try object.children(forKey: "sequences").compactMap { try sequence(from: $0) } ) @@ -107,24 +107,24 @@ public struct FCPLibraryInspector: Sendable { let sequenceObjects = try object.children(forKey: "sequence") let sequence = try sequenceObjects.first.flatMap { try sequence(from: $0) } return FCPScriptedProject( - name: try object.string(forKey: "displayName"), - id: try object.string(forKey: "uniqueIdentifier"), - persistentID: try object.string(forKey: "persistent ID"), + name: try object.string(forKey: "name"), + id: try object.string(forKey: "id"), + persistentID: try? object.string(forKey: "persistentID"), sequence: sequence ) } private static func sequence(from object: any FCPScriptingObject) throws -> FCPScriptedSequence? { - guard let duration = try object.mediaTime(forKey: "durationDict"), - let frameDuration = try object.mediaTime(forKey: "frameDurationDict") + guard let duration = try object.mediaTime(forKey: "duration"), + let frameDuration = try object.mediaTime(forKey: "frameDuration") else { return nil } return FCPScriptedSequence( - name: try object.string(forKey: "displayName"), - id: try object.string(forKey: "mediaIdentifier"), - startTime: try object.mediaTime(forKey: "startTimeDict"), + name: try object.string(forKey: "name"), + id: try object.string(forKey: "id"), + startTime: try object.mediaTime(forKey: "startTime"), duration: duration, frameDuration: frameDuration, timecodeFormat: try object.timecodeFormat(forKey: "timecodeFormat") diff --git a/Sources/FCPKitScripting/FCPScriptedEvent.swift b/Sources/FCPKitScripting/FCPScriptedEvent.swift index 6d37e02..45e5bd1 100644 --- a/Sources/FCPKitScripting/FCPScriptedEvent.swift +++ b/Sources/FCPKitScripting/FCPScriptedEvent.swift @@ -38,7 +38,10 @@ public struct FCPScriptedEvent: Hashable, Sendable { public var id: String /// The stable hexadecimal persistent identifier from Final Cut Pro. - public var persistentID: String + /// + /// Declared in the scripting dictionary but not returned by current + /// Final Cut Pro releases, so live inspection yields `nil`. + public var persistentID: String? /// Projects contained in the event. public var projects: [FCPScriptedProject] @@ -50,7 +53,7 @@ public struct FCPScriptedEvent: Hashable, Sendable { public init( name: String, id: String, - persistentID: String, + persistentID: String? = nil, projects: [FCPScriptedProject] = [], sequences: [FCPScriptedSequence] = [] ) { diff --git a/Sources/FCPKitScripting/FCPScriptedLibrary.swift b/Sources/FCPKitScripting/FCPScriptedLibrary.swift index c203eaa..f02577f 100644 --- a/Sources/FCPKitScripting/FCPScriptedLibrary.swift +++ b/Sources/FCPKitScripting/FCPScriptedLibrary.swift @@ -38,7 +38,10 @@ public struct FCPScriptedLibrary: Hashable, Sendable { public var id: String /// The stable hexadecimal persistent identifier from Final Cut Pro. - public var persistentID: String + /// + /// Declared in the scripting dictionary but not returned by current + /// Final Cut Pro releases, so live inspection yields `nil`. + public var persistentID: String? /// The on-disk library bundle URL when available. public var fileURL: URL? @@ -50,7 +53,7 @@ public struct FCPScriptedLibrary: Hashable, Sendable { public init( name: String, id: String, - persistentID: String, + persistentID: String? = nil, fileURL: URL? = nil, events: [FCPScriptedEvent] = [] ) { diff --git a/Sources/FCPKitScripting/FCPScriptedProject.swift b/Sources/FCPKitScripting/FCPScriptedProject.swift index 4e0fc1f..b4b08bc 100644 --- a/Sources/FCPKitScripting/FCPScriptedProject.swift +++ b/Sources/FCPKitScripting/FCPScriptedProject.swift @@ -38,7 +38,10 @@ public struct FCPScriptedProject: Hashable, Sendable { public var id: String /// The stable hexadecimal persistent identifier from Final Cut Pro. - public var persistentID: String + /// + /// Declared in the scripting dictionary but not returned by current + /// Final Cut Pro releases, so live inspection yields `nil`. + public var persistentID: String? /// The project's primary sequence when present. public var sequence: FCPScriptedSequence? @@ -47,7 +50,7 @@ public struct FCPScriptedProject: Hashable, Sendable { public init( name: String, id: String, - persistentID: String, + persistentID: String? = nil, sequence: FCPScriptedSequence? = nil ) { self.name = name diff --git a/Sources/FCPKitScripting/FCPScriptingTimecodeFormatParser.swift b/Sources/FCPKitScripting/FCPScriptingTimecodeFormatParser.swift index f50755e..1c436f7 100644 --- a/Sources/FCPKitScripting/FCPScriptingTimecodeFormatParser.swift +++ b/Sources/FCPKitScripting/FCPScriptingTimecodeFormatParser.swift @@ -35,9 +35,9 @@ internal enum FCPScriptingTimecodeFormatParser { .trimmingCharacters(in: .whitespacesAndNewlines) .lowercased() switch normalized { - case "drop frame", "dropframe", "df": + case "drop frame", "dropframe", "df", "drop": return .dropFrame - case "non drop frame", "non-drop frame", "nondropframe", "ndf", "non dropframe": + case "non drop frame", "non-drop frame", "nondropframe", "ndf", "non dropframe", "ndrp": return .nonDropFrame default: return .unspecified @@ -51,9 +51,23 @@ internal enum FCPScriptingTimecodeFormatParser { case let text as NSString: text as String case let number as NSNumber: - number.stringValue + fourCharCode(from: number) ?? number.stringValue default: "" } } + + /// Decodes the sdef `timecode formats` enumerator codes (`drop`, `ndrp`, + /// `unsp`) that a live ScriptingBridge proxy returns as an OSType number. + private static func fourCharCode(from number: NSNumber) -> String? { + let raw = number.uint32Value + let bytes = [ + UInt8((raw >> 24) & 0xFF), UInt8((raw >> 16) & 0xFF), + UInt8((raw >> 8) & 0xFF), UInt8(raw & 0xFF), + ] + guard bytes.allSatisfy({ $0 >= 0x20 && $0 < 0x7F }) else { + return nil + } + return String(bytes: bytes, encoding: .ascii) + } } diff --git a/Tests/FCPKitScriptingTests/FCPLibraryInspectorLiveTests.swift b/Tests/FCPKitScriptingTests/FCPLibraryInspectorLiveTests.swift index 9abe8c1..56e15bf 100644 --- a/Tests/FCPKitScriptingTests/FCPLibraryInspectorLiveTests.swift +++ b/Tests/FCPKitScriptingTests/FCPLibraryInspectorLiveTests.swift @@ -40,7 +40,18 @@ return } let inspector = FCPLibraryInspector() - _ = try inspector.libraries() + let libraries = try inspector.libraries() + // Final Cut always has at least the current library open, and every + // scripted object must surface non-empty name/id term properties. + #expect(!libraries.isEmpty) + for library in libraries { + #expect(!library.name.isEmpty) + #expect(!library.id.isEmpty) + for event in library.events { + #expect(!event.name.isEmpty) + #expect(!event.id.isEmpty) + } + } } @Test diff --git a/Tests/FCPKitScriptingTests/FCPLibraryInspectorMockTests.swift b/Tests/FCPKitScriptingTests/FCPLibraryInspectorMockTests.swift index e554ac2..90c69a8 100644 --- a/Tests/FCPKitScriptingTests/FCPLibraryInspectorMockTests.swift +++ b/Tests/FCPKitScriptingTests/FCPLibraryInspectorMockTests.swift @@ -36,32 +36,36 @@ @Suite internal struct FCPLibraryInspectorMockTests { + // The mock keys pin the sdef *term-name* contract that live SBObject + // proxies resolve (verified against a running Final Cut Pro, #27). Do + // not switch these to the sdef cocoa keys (`displayName`, + // `uniqueIdentifier`, `durationDict`, …) — those crash live. private static var sampleApplication: FCPScriptingObjectMock { let sequence = FCPScriptingObjectMock( strings: [ - "displayName": "Main Sequence", - "mediaIdentifier": "seq-1", + "name": "Main Sequence", + "id": "seq-1", ], mediaTimes: [ - "durationDict": FCPTime(numerator: 240, denominator: 24), - "frameDurationDict": FCPTime(numerator: 1, denominator: 24), - "startTimeDict": FCPTime(numerator: 0, denominator: 1), + "duration": FCPTime(numerator: 240, denominator: 24), + "frameDuration": FCPTime(numerator: 1, denominator: 24), + "startTime": FCPTime(numerator: 0, denominator: 1), ], timecodeFormats: ["timecodeFormat": .dropFrame] ) let project = FCPScriptingObjectMock( strings: [ - "displayName": "Project A", - "uniqueIdentifier": "proj-1", - "persistent ID": "deadbeef", + "name": "Project A", + "id": "proj-1", + "persistentID": "deadbeef", ], childObjects: ["sequence": [sequence]] ) let event = FCPScriptingObjectMock( strings: [ - "displayName": "Event 1", - "uniqueIdentifier": "event-1", - "persistent ID": "cafebabe", + "name": "Event 1", + "id": "event-1", + "persistentID": "cafebabe", ], childObjects: [ "projects": [project], @@ -70,11 +74,11 @@ ) let library = FCPScriptingObjectMock( strings: [ - "displayName": "Library", - "uniqueIdentifier": "lib-1", - "persistent ID": "feedface", + "name": "Library", + "id": "lib-1", + "persistentID": "feedface", ], - urls: ["URL": URL(fileURLWithPath: "/tmp/Library.fcpbundle")], + urls: ["file": URL(fileURLWithPath: "/tmp/Library.fcpbundle")], childObjects: ["events": [event]] ) return FCPScriptingObjectMock(childObjects: ["libraries": [library]]) @@ -86,6 +90,7 @@ let libraries = try inspector.libraries() #expect(libraries.count == 1) #expect(libraries[0].name == "Library") + #expect(libraries[0].persistentID == "feedface") #expect(libraries[0].fileURL?.path == "/tmp/Library.fcpbundle") #expect(libraries[0].events.count == 1) #expect(libraries[0].events[0].projects.count == 1) diff --git a/Tests/FCPKitScriptingTests/FCPScriptingTimecodeFormatParserTests.swift b/Tests/FCPKitScriptingTests/FCPScriptingTimecodeFormatParserTests.swift new file mode 100644 index 0000000..9cb664e --- /dev/null +++ b/Tests/FCPKitScriptingTests/FCPScriptingTimecodeFormatParserTests.swift @@ -0,0 +1,63 @@ +// +// FCPScriptingTimecodeFormatParserTests.swift +// FCPKitScriptingTests +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +#if os(macOS) + + @testable import FCPKitScripting + import Foundation + import Testing + + @Suite + internal struct FCPScriptingTimecodeFormatParserTests { + // Live ScriptingBridge proxies return the sdef `timecode formats` + // enumerator as an OSType number ('drop' / 'ndrp' / 'unsp'), verified + // against a running Final Cut Pro (#27). + @Test + internal func parsesLiveFourCharCodeNumbers() { + #expect( + FCPScriptingTimecodeFormatParser.parse(NSNumber(value: 0x6472_6F70 as UInt32)) + == .dropFrame) + #expect( + FCPScriptingTimecodeFormatParser.parse(NSNumber(value: 0x6E64_7270 as UInt32)) + == .nonDropFrame) + #expect( + FCPScriptingTimecodeFormatParser.parse(NSNumber(value: 0x756E_7370 as UInt32)) + == .unspecified) + } + + @Test + internal func parsesDescriptiveStrings() { + #expect(FCPScriptingTimecodeFormatParser.parse("Drop Frame") == .dropFrame) + #expect(FCPScriptingTimecodeFormatParser.parse("non drop frame") == .nonDropFrame) + #expect(FCPScriptingTimecodeFormatParser.parse(nil) == .unspecified) + #expect(FCPScriptingTimecodeFormatParser.parse(NSNumber(value: 12)) == .unspecified) + } + } + +#endif diff --git a/docs/NEXT_STEPS.md b/docs/NEXT_STEPS.md index 72ce51b..8994631 100644 --- a/docs/NEXT_STEPS.md +++ b/docs/NEXT_STEPS.md @@ -84,16 +84,16 @@ Human gates: the DSL create-path Final Cut import gate ([#14](https://github.com/brightdigit/FCPKit/issues/14)) is **Accepted** — evidence in [manual/typed-generation-gate.md](manual/typed-generation-gate.md). -**Known issue shipped with v0.1.0:** -[#27](https://github.com/brightdigit/FCPKit/issues/27) — the `FCPKitScripting` -SBObject bridge passes cocoa keys where SBObject resolves sdef term names, so -`FCPLibraryInspector` crashes against a real running Final Cut Pro (mock-backed -tests pass). Fix scheduled after v0.1.0. +[#27](https://github.com/brightdigit/FCPKit/issues/27) (the SBObject bridge +passed sdef cocoa keys where live proxies resolve term names, crashing against +a running Final Cut Pro) was fixed before release: the inspector now uses the +live-verified term-name contract, the mocks pin those keys, and the live test +asserts real content when Final Cut is running. `persistentID` became optional +because Final Cut declares but does not implement it, and the timecode format +parser accepts the OSType enumerator numbers live proxies return. ## Frontier (after v0.1.0) -- [#27](https://github.com/brightdigit/FCPKit/issues/27) — fix the scripting - bridge key mapping and repin the mocks to the sdef term-name contract. - [#16](https://github.com/brightdigit/FCPKit/issues/16) — MediaTools in-library probe path for Ubuntu and Windows (no host `ffprobe`). - Deferred DSL design items from