Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 17 additions & 15 deletions .claude/memory/fcp-scripting-live-facts.md
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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

Expand Down
6 changes: 4 additions & 2 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
6 changes: 0 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
30 changes: 15 additions & 15 deletions Sources/FCPKitScripting/FCPLibraryInspector.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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) }
)
Expand All @@ -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")
Expand Down
7 changes: 5 additions & 2 deletions Sources/FCPKitScripting/FCPScriptedEvent.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand All @@ -50,7 +53,7 @@ public struct FCPScriptedEvent: Hashable, Sendable {
public init(
name: String,
id: String,
persistentID: String,
persistentID: String? = nil,
projects: [FCPScriptedProject] = [],
sequences: [FCPScriptedSequence] = []
) {
Expand Down
7 changes: 5 additions & 2 deletions Sources/FCPKitScripting/FCPScriptedLibrary.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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?
Expand All @@ -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] = []
) {
Expand Down
7 changes: 5 additions & 2 deletions Sources/FCPKitScripting/FCPScriptedProject.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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?
Expand All @@ -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
Expand Down
20 changes: 17 additions & 3 deletions Sources/FCPKitScripting/FCPScriptingTimecodeFormatParser.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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)
}
}
13 changes: 12 additions & 1 deletion Tests/FCPKitScriptingTests/FCPLibraryInspectorLiveTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
35 changes: 20 additions & 15 deletions Tests/FCPKitScriptingTests/FCPLibraryInspectorMockTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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],
Expand All @@ -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]])
Expand All @@ -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)
Expand Down
Original file line number Diff line number Diff line change
@@ -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
Loading
Loading