diff --git a/Sources/FCPKitDSL/Anchor.swift b/Sources/FCPKitDSL/Anchor.swift index a0e6e11..2036b9b 100644 --- a/Sources/FCPKitDSL/Anchor.swift +++ b/Sources/FCPKitDSL/Anchor.swift @@ -37,7 +37,7 @@ 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 { @@ -45,27 +45,4 @@ internal struct Anchor: DSLNode { } 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 - } - } } diff --git a/Sources/FCPKitDSL/AnchorableItem.swift b/Sources/FCPKitDSL/AnchorableItem.swift new file mode 100644 index 0000000..c6b78f3 --- /dev/null +++ b/Sources/FCPKitDSL/AnchorableItem.swift @@ -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) } +} diff --git a/Sources/FCPKitDSL/Array+Lowering.swift b/Sources/FCPKitDSL/Array+Lowering.swift new file mode 100644 index 0000000..e6e5b1f --- /dev/null +++ b/Sources/FCPKitDSL/Array+Lowering.swift @@ -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 + } +} diff --git a/Sources/FCPKitDSL/AssetClip+Modifiers.swift b/Sources/FCPKitDSL/AssetClip+Modifiers.swift index 110cdbd..b4b55c2 100644 --- a/Sources/FCPKitDSL/AssetClip+Modifiers.swift +++ b/Sources/FCPKitDSL/AssetClip+Modifiers.swift @@ -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) - } - ) - } } diff --git a/Sources/FCPKitDSL/AssetClip.swift b/Sources/FCPKitDSL/AssetClip.swift index 7cd5279..0a05e66 100644 --- a/Sources/FCPKitDSL/AssetClip.swift +++ b/Sources/FCPKitDSL/AssetClip.swift @@ -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``. @@ -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 `` 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") @@ -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)) } @@ -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 - } - } } diff --git a/Sources/FCPKitDSL/Built+Anchoring.swift b/Sources/FCPKitDSL/Built+Anchoring.swift new file mode 100644 index 0000000..536262b --- /dev/null +++ b/Sources/FCPKitDSL/Built+Anchoring.swift @@ -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 `` 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 + } +} diff --git a/Sources/FCPKitDSL/Built.swift b/Sources/FCPKitDSL/Built.swift index d0867ae..631f037 100644 --- a/Sources/FCPKitDSL/Built.swift +++ b/Sources/FCPKitDSL/Built.swift @@ -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 `` element. case library(FCPKit.Library) + /// A built `` element. case event(FCPKit.Event) + /// A built `` element. case project(FCPKit.Project) + /// A built `` element. case sequence(FCPKit.Sequence) + /// A built `` element. case spine(FCPKit.Spine) + /// A built story item, such as an asset clip, title, gap, or video. case item(FCPKit.SpineItem) } diff --git a/Sources/FCPKitDSL/Color+DSL.swift b/Sources/FCPKitDSL/Color+DSL.swift index b70f369..d38ff37 100644 --- a/Sources/FCPKitDSL/Color+DSL.swift +++ b/Sources/FCPKitDSL/Color+DSL.swift @@ -40,7 +40,8 @@ extension Color: DSLNode { return copy } - internal func build(_ resources: inout ResourceStore) throws(BuildError) -> Built { + /// Lowers this color into a custom solid generator `