Skip to content
Open
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
1 change: 1 addition & 0 deletions .claude/agent-notes.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -142,3 +142,5 @@ xcuserdata
# fcpxml-dsl export outputs (regenerate with: swift run fcpxml-dsl export)
/transitions.fcpxml
/titles.fcpxml
/rgb.fcpxml
/presentation.fcpxml
17 changes: 16 additions & 1 deletion Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,10 @@ let package = Package(
name: "FCPKitDSL",
targets: ["FCPKitDSL"]
),
.library(
name: "FCPKitDemo",
targets: ["FCPKitDemo"]
),
.executable(
name: "fcpxml-generator",
targets: ["fcpxml-generator"]
Expand Down Expand Up @@ -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",
Expand All @@ -99,5 +110,9 @@ let package = Package(
name: "FCPKitDSLTests",
dependencies: ["FCPKitDSL", "FCPKit", "FCPXMLDiff"]
),
.testTarget(
name: "FCPKitDemoTests",
dependencies: ["FCPKitDemo", "FCPKit", "FCPKitDSL", "FCPXMLDiff"]
),
]
)
55 changes: 54 additions & 1 deletion Sources/FCPKit/FCPXMLParser.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -107,3 +114,49 @@ public class FCPXMLParser {
try data.write(to: url)
}
}

extension FCPXMLParser {
/// Collapses pretty-print whitespace around pure-text `<text-style>` 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 `<param>` children) are left unchanged.
internal static func compactingTextStyleCharacterData(in xml: String) -> String {
compactingCharacterOnlyElement("text-style", in: xml)
}

/// Collapses pretty-print whitespace inside character-only `<data>` 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)([^>]*)>([^<]*)</\(name)>"
guard let regex = try? NSRegularExpression(pattern: pattern) else {
return xml
}
let nsRange = NSRange(xml.startIndex..<xml.endIndex, in: xml)
var result = ""
var lastEnd = xml.startIndex
for match in regex.matches(in: xml, range: nsRange) {
guard
let fullRange = Range(match.range, in: xml),
let attrsRange = Range(match.range(at: 1), in: xml),
let bodyRange = Range(match.range(at: 2), in: xml)
else {
continue
}
result += xml[lastEnd..<fullRange.lowerBound]
let trimmed = xml[bodyRange].trimmingCharacters(in: .whitespacesAndNewlines)
result += "<\(name)\(xml[attrsRange])>\(trimmed)</\(name)>"
lastEnd = fullRange.upperBound
}
result += xml[lastEnd...]
return result
}
}
45 changes: 44 additions & 1 deletion Sources/FCPKitDSL/Anchor.swift
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,18 @@ internal struct Anchor: DSLNode {
internal let content: any DSLNode

internal func build(_ resources: inout ResourceStore) throws(BuildError) -> 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
Expand All @@ -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
}
}
87 changes: 78 additions & 9 deletions Sources/FCPKitDSL/AssetClip.swift
Original file line number Diff line number Diff line change
Expand Up @@ -33,35 +33,79 @@ 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.
public let anchors: [any DSLNode]
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.
public init(
_ 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)
}

Expand Down Expand Up @@ -95,11 +139,32 @@ public struct AssetClip: StoryItem {
replacing(anchors: anchors)
}

/// Lowers this clip into an `<asset-clip>` story item.
/// Lowers this clip into a story item.
///
/// Still sources (`asset` `duration="0s"`, as from ``AssetSource/still(url:width:height:name:id:)``)
/// become `<video>` — Final Cut imports real still PNGs that way and aborts in
/// `addAssetClip` when they are emitted as `<asset-clip>`. Movies stay `<asset-clip>`.
public func build(_ resources: inout ResourceStore) throws(BuildError) -> Built {
let ref = try resources.asset(source)
let displayName = name ?? source.asset.name ?? "asset clip"
if source.asset.duration == "0s" {
guard let storyDuration = duration else {
throw BuildError.missingDuration(displayName)
}
var video = FCPKit.Video(
ref: ResourceRef<AssetKind>(ref.rawValue),
name: name ?? source.asset.name,
start: "0s",
duration: storyDuration.description
)
video.anchoredItems = try anchors.anchoredItems(
resources: &resources,
hostDuration: storyDuration
)
return .item(.video(video))
}
guard let value = duration?.description ?? source.asset.duration, FCPTime(value) != nil else {
throw BuildError.missingDuration(name ?? source.asset.name ?? "asset clip")
throw BuildError.missingDuration(displayName)
}
var clip = FCPKit.AssetClip(
ref: ref,
Expand All @@ -111,7 +176,11 @@ public struct AssetClip: StoryItem {
if let format = source.format, source.formatOnClip {
clip.format = try resources.format(FormatPreset(format))
}
clip.anchoredItems = try anchors.anchoredItems(resources: &resources)
let hostDuration = FCPTime(value)
clip.anchoredItems = try anchors.anchoredItems(
resources: &resources,
hostDuration: hostDuration
)
return .item(.assetClip(clip))
}

Expand Down
74 changes: 74 additions & 0 deletions Sources/FCPKitDSL/AssetSource+Still.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
//
// AssetSource+Still.swift
// FCPKit
//
// 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.
//

import FCPKit
import Foundation

extension AssetSource {
/// An image still as Final Cut Pro expects it: `duration="0s"`, `hasVideo`,
/// `videoSources="1"`, and a `FFVideoFormatRateUndefined` format.
///
/// ``AssetClip`` lowers stills to spine `<video>` (not `<asset-clip>`),
/// matching real Final Cut still PNG exports. URL-only
/// ``AssetSource/init(url:name:duration:id:)`` is for movies. Storyline
/// length comes from ``AssetClip/duration(_:)`` (required for stills).
///
/// - Parameters:
/// - url: On-disk image URL written into `media-rep/@src`.
/// - width: Pixel width of the still (and its format resource).
/// - height: Pixel height of the still (and its format resource).
/// - name: Browser name; defaults to the URL's basename without extension.
/// - id: Optional explicit resource id.
/// - Returns: An asset source that registers itself and its still format on export.
public static func still(
url: URL,
width: Int,
height: Int,
name: String? = nil,
id: ResourceID? = nil
) -> AssetSource {
let asset = FCPKit.Asset(
id: id ?? ResourceStore.draftID,
name: name ?? url.deletingPathExtension().lastPathComponent,
start: "0s",
duration: "0s",
hasVideo: true,
videoSources: "1",
mediaRep: [FCPKit.MediaRep(kind: .originalMedia, src: url.absoluteString)]
)
let format = FCPKit.Format(
id: ResourceStore.draftID,
name: "FFVideoFormatRateUndefined",
width: String(width),
height: String(height),
colorSpace: "1-1-1 (Rec. 709)"
)
return AssetSource(asset, format: format, formatOnClip: false, id: id)
}
}
Loading
Loading