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
25 changes: 1 addition & 24 deletions Sources/FCPKitDSL/Anchor.swift
Original file line number Diff line number Diff line change
Expand Up @@ -37,35 +37,12 @@ internal struct Anchor: DSLNode {
internal func build(_ resources: inout ResourceStore) throws(BuildError) -> Built {
guard lane != 0 else { throw BuildError.invalidLane }
let built = try content.build(&resources)
if let item = try applyLaneOffset(to: built) {
if let item = built.placed(lane: lane, offset: offset) {
return item
}
guard case .spine(let spine) = built else {
throw BuildError.unsupportedContent
}
return .spine(spine)
}

private func applyLaneOffset(to built: Built) throws(BuildError) -> Built? {
switch built {
case .item(.title(var title)):
title.lane = String(lane)
title.offset = offset.description
return .item(.title(title))
case .item(.assetClip(var clip)):
clip.lane = String(lane)
clip.offset = offset.description
return .item(.assetClip(clip))
case .item(.generator(var gen)):
gen.lane = String(lane)
gen.offset = offset.description
return .item(.generator(gen))
case .item(.video(var vid)):
vid.lane = String(lane)
vid.offset = offset.description
return .item(.video(vid))
default:
return nil
}
}
}
67 changes: 67 additions & 0 deletions Sources/FCPKitDSL/AnchorableItem.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
//
// AnchorableItem.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

/// A model element that may be attached to a connected lane.
///
/// The FCPXML DTD's `%anchor_item;` entity admits a subset of story elements.
/// Conformers describe how to re-wrap themselves into both ordered-choice
/// containers, so the set of anchorable shapes is stated exactly once — in
/// ``Built/anchorable`` — instead of being re-enumerated by every caller.
internal protocol AnchorableItem {
/// The connected lane this element sits on.
var lane: String? { get set }
/// The element's offset within its parent.
var offset: String? { get set }
/// This element as an anchored child.
var asAnchoredItem: FCPKit.AnchoredItem { get }
/// This element as a spine item.
var asSpineItem: FCPKit.SpineItem { get }
}

extension FCPKit.Title: AnchorableItem {
internal var asAnchoredItem: FCPKit.AnchoredItem { .title(self) }
internal var asSpineItem: FCPKit.SpineItem { .title(self) }
}

extension FCPKit.AssetClip: AnchorableItem {
internal var asAnchoredItem: FCPKit.AnchoredItem { .assetClip(self) }
internal var asSpineItem: FCPKit.SpineItem { .assetClip(self) }
}

extension FCPKit.Generator: AnchorableItem {
internal var asAnchoredItem: FCPKit.AnchoredItem { .generator(self) }
internal var asSpineItem: FCPKit.SpineItem { .generator(self) }
}

extension FCPKit.Video: AnchorableItem {
internal var asAnchoredItem: FCPKit.AnchoredItem { .video(self) }
internal var asSpineItem: FCPKit.SpineItem { .video(self) }
}
52 changes: 52 additions & 0 deletions Sources/FCPKitDSL/Array+Lowering.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
//
// Array+Lowering.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

extension Array where Element == any DocumentContent {
/// Lowers this content into ordered spine items, preserving authored order.
///
/// - Throws: ``BuildError/unsupportedContent`` when a value is not a story
/// item — a document shell such as a `Project` cannot sit in a spine.
internal func spineItems(
resources: inout ResourceStore
) throws(BuildError) -> [FCPKit.SpineItem] {
var items: [FCPKit.SpineItem] = []
items.reserveCapacity(count)
for value in self {
guard let node = value as? any DSLNode,
case .item(let item) = try node.build(&resources)
else {
throw BuildError.unsupportedContent
}
items.append(item)
}
return items
}
}
15 changes: 0 additions & 15 deletions Sources/FCPKitDSL/AssetClip+Modifiers.swift
Original file line number Diff line number Diff line change
Expand Up @@ -34,19 +34,4 @@ extension AssetClip {
public func audioRole(_ role: String) -> AssetClip {
replacing(audioRole: role)
}
/// Anchors content on a connected lane. `lane` must be nonzero.
public func anchor(
lane: Int,
offset: FCPTime = .zero,
@DocumentBuilder content: () -> DocumentGroup
) -> AssetClip {
replacing(
anchors: content().contents.compactMap { value in
guard let node = value as? any DSLNode else {
return nil
}
return Anchor(lane: lane, offset: offset, content: node)
}
)
}
}
31 changes: 11 additions & 20 deletions Sources/FCPKitDSL/AssetClip.swift
Original file line number Diff line number Diff line change
Expand Up @@ -31,12 +31,13 @@ import FCPKit
import Foundation

/// An `asset-clip` story item with optional anchors and audio role.
public struct AssetClip: DSLNode {
public struct AssetClip: StoryItem {
internal let source: AssetSource
/// Clip duration on the storyline, when set explicitly.
public let duration: FCPTime?
internal let name: String?
internal let anchors: [any DSLNode]
/// The anchors attached to this clip.
public let anchors: [any DSLNode]
internal let audioRole: String?

/// Creates a clip from an ``AssetSource``.
Expand Down Expand Up @@ -89,7 +90,13 @@ public struct AssetClip: DSLNode {
)
}

internal func build(_ resources: inout ResourceStore) throws(BuildError) -> Built {
/// Returns a copy of this clip carrying exactly the given anchors.
public func replacingAnchors(_ anchors: [any DSLNode]) -> AssetClip {
replacing(anchors: anchors)
}

/// Lowers this clip into an `<asset-clip>` story item.
public func build(_ resources: inout ResourceStore) throws(BuildError) -> Built {
let ref = try resources.asset(source)
guard let value = duration?.description ?? source.asset.duration, FCPTime(value) != nil else {
throw BuildError.missingDuration(name ?? source.asset.name ?? "asset clip")
Expand All @@ -104,10 +111,7 @@ public struct AssetClip: DSLNode {
if let format = source.format, source.formatOnClip {
clip.format = try resources.format(FormatPreset(format))
}
let items = try anchors.map { node throws(BuildError) in
try anchoredItem(node, resources: &resources)
}
clip.anchoredItems = items.isEmpty ? nil : items
clip.anchoredItems = try anchors.anchoredItems(resources: &resources)
return .item(.assetClip(clip))
}

Expand All @@ -120,17 +124,4 @@ public struct AssetClip: DSLNode {
audioRole: audioRole ?? self.audioRole
)
}

private func anchoredItem(_ node: any DSLNode, resources: inout ResourceStore) throws(BuildError)
-> FCPKit.AnchoredItem
{
switch try node.build(&resources) {
case .item(.title(let title)): return .title(title)
case .item(.assetClip(let clip)): return .assetClip(clip)
case .item(.generator(let gen)): return .generator(gen)
case .item(.video(let vid)): return .video(vid)
case .spine(let spine): return .spine(spine)
default: throw BuildError.unsupportedContent
}
}
}
87 changes: 87 additions & 0 deletions Sources/FCPKitDSL/Built+Anchoring.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
//
// Built+Anchoring.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

extension Built {
/// This value as an anchorable element, or `nil` if it cannot be anchored.
///
/// The single description of what the DTD's `%anchor_item;` entity admits.
/// A `<gap>` is anchorable per the schema but is not produced here, matching
/// long-standing behaviour; widening that is a capability change.
internal var anchorable: (any AnchorableItem)? {
switch self {
case .item(.title(let title)): return title
case .item(.assetClip(let clip)): return clip
case .item(.generator(let generator)): return generator
case .item(.video(let video)): return video
default: return nil
}
}

/// This value lowered into the DTD's `%anchor_item;` entity.
///
/// - Throws: ``BuildError/unsupportedContent`` when the value cannot be anchored.
internal func anchoredItem() throws(BuildError) -> FCPKit.AnchoredItem {
if case .spine(let spine) = self {
return .spine(spine)
}
guard let anchorable else {
throw BuildError.unsupportedContent
}
return anchorable.asAnchoredItem
}

/// A copy placed on `lane` at `offset`, or `nil` if it cannot be anchored.
internal func placed(lane: Int, offset: FCPTime) -> Built? {
guard var item = anchorable else {
return nil
}
item.lane = String(lane)
item.offset = offset.description
return .item(item.asSpineItem)
}
}

extension Array where Element == any DSLNode {
/// Lowers these nodes into anchored items, returning `nil` when empty.
///
/// The optional is load-bearing: the model omits the element entirely rather
/// than emitting an empty container.
internal func anchoredItems(
resources: inout ResourceStore
) throws(BuildError) -> [FCPKit.AnchoredItem]? {
var items: [FCPKit.AnchoredItem] = []
items.reserveCapacity(count)
for node in self {
items.append(try node.build(&resources).anchoredItem())
}
return items.isEmpty ? nil : items
}
}
11 changes: 10 additions & 1 deletion Sources/FCPKitDSL/Built.swift
Original file line number Diff line number Diff line change
Expand Up @@ -29,11 +29,20 @@

import FCPKit

internal enum Built {
/// The model value a ``DSLNode`` lowers to.
///
/// - Warning: The case list is expected to evolve before 1.0.
public enum Built {
/// A built `<library>` element.
case library(FCPKit.Library)
/// A built `<event>` element.
case event(FCPKit.Event)
/// A built `<project>` element.
case project(FCPKit.Project)
/// A built `<sequence>` element.
case sequence(FCPKit.Sequence)
/// A built `<spine>` element.
case spine(FCPKit.Spine)
/// A built story item, such as an asset clip, title, gap, or video.
case item(FCPKit.SpineItem)
}
24 changes: 23 additions & 1 deletion Sources/FCPKitDSL/Color+DSL.swift
Original file line number Diff line number Diff line change
Expand Up @@ -40,11 +40,33 @@ extension Color: DSLNode {
return copy
}

internal func build(_ resources: inout ResourceStore) throws(BuildError) -> Built {
/// Lowers this color into a custom solid generator `<video>` story item.
public func build(_ resources: inout ResourceStore) throws(BuildError) -> Built {
guard let duration else {
throw BuildError.missingDuration("color generator")
}
let generator = Generator(.custom, duration: duration).color(self)
return try generator.build(&resources)
}
}

extension Color: StoryItem {
/// Always empty: a color carries no anchors until it is promoted to a ``Generator``.
public var anchors: [any DSLNode] { [] }

/// Promotes this color to a ``Generator`` carrying the given anchors.
///
/// `Color` is a model type and cannot gain stored properties, so anchoring performs
/// the `Color` → `Generator` desugaring one step early. Chaining still works, because
/// ``Generator`` is itself a ``StoryItem``.
///
/// - Important: Call `.duration(_:)` *before* `.anchor(lane:offset:content:)`. Because
/// `.anchor` cannot throw from builder position, a color with no duration promotes
/// with a zero duration, which surfaces later as ``BuildError/missingDuration`` at
/// `export()`.
public func replacingAnchors(_ anchors: [any DSLNode]) -> Generator {
Generator(.custom, duration: duration ?? .zero)
.color(self)
.replacingAnchors(anchors)
}
}
Loading
Loading