diff --git a/.claude/agent-notes.md b/.claude/agent-notes.md index d7b21d3..e4da9f3 100644 --- a/.claude/agent-notes.md +++ b/.claude/agent-notes.md @@ -38,3 +38,4 @@ Running log of user corrections and standing always/never directives for this re - 2026-08-02: Pre-1.0, do not contort a design to avoid changing DSL APIs — make types public and change signatures when that yields the simpler design. - 2026-08-02: Do not add public API for a capability nothing needs yet (e.g. keep build-environment keys internal until an external need appears). - 2026-08-02: When designing DSL ergonomics, ask what SwiftUI would do and prefer the option that demands least from the developer (alignment-style APIs over coordinate math), keeping absolute-value APIs as an escape hatch. +- 2026-08-05: Presentation authoring lives in `FCPKitDemo` (not `FCPKitDSL`) and depends only on the public DSL; `PresentationDocument` is a thin editable shell, not a canned feature deck. diff --git a/.gitignore b/.gitignore index d5c2877..9e5f8cc 100644 --- a/.gitignore +++ b/.gitignore @@ -142,3 +142,5 @@ xcuserdata # fcpxml-dsl export outputs (regenerate with: swift run fcpxml-dsl export) /transitions.fcpxml /titles.fcpxml +/rgb.fcpxml +/presentation.fcpxml diff --git a/Package.swift b/Package.swift index 2d1b8b5..caea029 100644 --- a/Package.swift +++ b/Package.swift @@ -32,6 +32,10 @@ let package = Package( name: "FCPKitDSL", targets: ["FCPKitDSL"] ), + .library( + name: "FCPKitDemo", + targets: ["FCPKitDemo"] + ), .executable( name: "fcpxml-generator", targets: ["fcpxml-generator"] @@ -69,13 +73,20 @@ let package = Package( name: "FCPKitDSL", dependencies: ["FCPKit"] ), + .target( + name: "FCPKitDemo", + dependencies: ["FCPKitDSL"], + resources: [ + .process("Resources") + ] + ), .executableTarget( name: "fcpxml-generator", dependencies: ["FCPKitMediaTools"] ), .executableTarget( name: "fcpxml-dsl", - dependencies: ["FCPKit", "FCPKitDSL", "FCPKitMediaTools", "FCPKitScripting"] + dependencies: ["FCPKit", "FCPKitDSL", "FCPKitDemo", "FCPKitMediaTools", "FCPKitScripting"] ), .executableTarget( name: "FCPXMLDiffCLI", @@ -99,5 +110,9 @@ let package = Package( name: "FCPKitDSLTests", dependencies: ["FCPKitDSL", "FCPKit", "FCPXMLDiff"] ), + .testTarget( + name: "FCPKitDemoTests", + dependencies: ["FCPKitDemo", "FCPKit", "FCPKitDSL", "FCPXMLDiff"] + ), ] ) diff --git a/Sources/FCPKit/FCPXMLParser.swift b/Sources/FCPKit/FCPXMLParser.swift index 23fe1ae..5eb3488 100644 --- a/Sources/FCPKit/FCPXMLParser.swift +++ b/Sources/FCPKit/FCPXMLParser.swift @@ -82,7 +82,14 @@ public class FCPXMLParser { encoder.dateEncodingStrategy = .iso8601 encoder.keyEncodingStrategy = .useDefaultKeys encoder.outputFormatting = [.prettyPrinted] - return try encoder.encode(fcpxml, withRootKey: "fcpxml") + let data = try encoder.encode(fcpxml, withRootKey: "fcpxml") + guard let xml = String(data: data, encoding: .utf8) else { + return data + } + let compacted = Self.compactingOpaqueDataCharacterData( + in: Self.compactingTextStyleCharacterData(in: xml) + ) + return Data(compacted.utf8) } /// Encodes a document as an FCPXML string. @@ -107,3 +114,49 @@ public class FCPXMLParser { try data.write(to: url) } } + +extension FCPXMLParser { + /// Collapses pretty-print whitespace around pure-text `` runs. + /// + /// XMLCoder's `.prettyPrinted` wraps element character data onto indented + /// lines. Final Cut Pro treats that leading/trailing whitespace as part of + /// the title string. Elements that contain nested children (for example a + /// definition style with `` children) are left unchanged. + internal static func compactingTextStyleCharacterData(in xml: String) -> String { + compactingCharacterOnlyElement("text-style", in: xml) + } + + /// Collapses pretty-print whitespace inside character-only `` elements. + /// + /// Opaque payloads such as `effectConfig` are base64; indented newlines from + /// `.prettyPrinted` make Final Cut report an unexpected value on the parent + /// transition. + internal static func compactingOpaqueDataCharacterData(in xml: String) -> String { + compactingCharacterOnlyElement("data", in: xml) + } + + private static func compactingCharacterOnlyElement(_ name: String, in xml: String) -> String { + let pattern = "<\(name)([^>]*)>([^<]*)" + guard let regex = try? NSRegularExpression(pattern: pattern) else { + return xml + } + let nsRange = NSRange(xml.startIndex.. Built { - guard lane != 0 else { throw BuildError.invalidLane } + try build(&resources, hostDuration: nil) + } + + /// Lowers anchored content, inheriting `hostDuration` when the content has none. + internal func build( + _ resources: inout ResourceStore, + hostDuration: FCPTime? + ) throws(BuildError) -> Built { + guard lane != 0 else { + throw BuildError.invalidLane + } + let content = Self.resolvingDuration(hostDuration, into: content) let built = try content.build(&resources) if let item = built.placed(lane: lane, offset: offset) { return item @@ -46,3 +57,35 @@ internal struct Anchor: DSLNode { return .spine(spine) } } + +extension Anchor { + /// Applies the host's duration to content that has not set one. + private static func resolvingDuration( + _ host: FCPTime?, + into content: any DSLNode + ) -> any DSLNode { + guard let host, host != .zero else { + return content + } + return applying(host, to: content) ?? content + } + + private static func applying(_ host: FCPTime, to content: any DSLNode) -> (any DSLNode)? { + if let title = content as? Title, title.duration == nil { + return title.duration(host) + } + if let generator = content as? Generator, generator.duration == nil { + return generator.duration(host) + } + if let gap = content as? Gap, gap.duration == nil { + return gap.duration(host) + } + if let clip = content as? AssetClip, clip.duration == nil, clip.source.asset.duration == nil { + return clip.duration(host) + } + if let color = content as? Color, color.duration == nil { + return color.duration(host) + } + return nil + } +} diff --git a/Sources/FCPKitDSL/AssetClip.swift b/Sources/FCPKitDSL/AssetClip.swift index 0a05e66..c3b1e05 100644 --- a/Sources/FCPKitDSL/AssetClip.swift +++ b/Sources/FCPKitDSL/AssetClip.swift @@ -33,7 +33,7 @@ import Foundation /// An `asset-clip` story item with optional anchors and audio role. public struct AssetClip: StoryItem { internal let source: AssetSource - /// Clip duration on the storyline, when set explicitly. + /// Clip duration on the storyline, when set explicitly via ``duration(_:)``. public let duration: FCPTime? internal let name: String? /// The anchors attached to this clip. @@ -41,8 +41,8 @@ public struct AssetClip: StoryItem { internal let audioRole: String? /// Creates a clip from an ``AssetSource``. - public init(_ source: AssetSource, duration: FCPTime? = nil, name: String? = nil) { - self.init(source: source, duration: duration, name: name, anchors: [], audioRole: nil) + public init(_ source: AssetSource, name: String? = nil) { + self.init(source: source, duration: nil, name: name, anchors: [], audioRole: nil) } /// Creates a clip from a model asset and optional format. @@ -50,18 +50,62 @@ public struct AssetClip: StoryItem { _ asset: FCPKit.Asset, format: FCPKit.Format? = nil, formatOnClip: Bool = false, - duration: FCPTime? = nil, name: String? = nil ) { self.init( AssetSource(asset, format: format, formatOnClip: formatOnClip), - duration: duration, name: name ) } /// Creates a clip from a media URL. - public init(_ url: URL, duration: FCPTime? = nil, name: String? = nil) { + public init(_ url: URL, name: String? = nil) { + self.init(AssetSource(url: url, name: name), name: name) + } + + /// Creates a clip from an ``AssetSource`` with an optional duration. + @available( + *, deprecated, + message: """ + Use `.duration(_:)` instead of passing duration to the initializer. \ + Anchored clips inherit the host duration when omitted. + """ + ) + public init(_ source: AssetSource, duration: FCPTime?, name: String? = nil) { + self.init(source: source, duration: duration, name: name, anchors: [], audioRole: nil) + } + + /// Creates a clip from a model asset with an optional duration. + @available( + *, deprecated, + message: """ + Use `.duration(_:)` instead of passing duration to the initializer. \ + Anchored clips inherit the host duration when omitted. + """ + ) + public init( + _ asset: FCPKit.Asset, + format: FCPKit.Format? = nil, + formatOnClip: Bool = false, + duration: FCPTime?, + name: String? = nil + ) { + self.init( + AssetSource(asset, format: format, formatOnClip: formatOnClip), + duration: duration, + name: name + ) + } + + /// Creates a clip from a media URL with an optional duration. + @available( + *, deprecated, + message: """ + Use `.duration(_:)` instead of passing duration to the initializer. \ + Anchored clips inherit the host duration when omitted. + """ + ) + public init(_ url: URL, duration: FCPTime?, name: String? = nil) { self.init(AssetSource(url: url, name: name, duration: duration), duration: duration, name: name) } @@ -95,11 +139,32 @@ public struct AssetClip: StoryItem { replacing(anchors: anchors) } - /// Lowers this clip into an `` story item. + /// Lowers this clip into a story item. + /// + /// Still sources (`asset` `duration="0s"`, as from ``AssetSource/still(url:width:height:name:id:)``) + /// become `