From e4325f72406268a3a0b697f1c5153ab54cd8fb57 Mon Sep 17 00:00:00 2001 From: Leo Dion Date: Mon, 3 Aug 2026 06:44:12 -0400 Subject: [PATCH 1/5] Fix #36: lift .anchor onto StoryItem and make DSLNode public MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `.anchor(lane:offset:content:)` was declared only on `AssetClip`, so nothing could be anchored onto a generator or a color. A media-free slide deck needs exactly that: text over a solid color background. Introduce `StoryItem` — "content that can sit in a spine", matching the DTD's `%clip_item;` entity — and hang the shared `.anchor` modifier off it. `AssetClip`, `Generator`, `Title`, `Gap`, and `Transition` conform directly. `Color` promotes to `Generator`, since it is a model type in another module and cannot gain stored properties; `Color.build` already desugared that way, so anchoring just performs the desugaring one step earlier. A public protocol requirement cannot mention internal types, so `DSLNode`, `Built`, and `ResourceStore` become public. Every `ResourceStore` member stays internal, so only the type name is exposed and no usable extension point ships. Anchoring onto a `Transition` is accepted but never emitted: the DTD does not admit anchored items there. Pinned by a test so it stays documented behavior rather than something a later change "fixes" into a throw. Also fixes a latent bug: `.anchor` replaced rather than appended, so chained calls silently dropped earlier lanes. The shared modifier now appends, and `replacingAnchors` is documented as the pure setter it is. This was latent because nothing chained; `StoryItem` makes chaining the natural idiom. Adds the missing `.video` and `.generator` cases to `anchoredExtent`, so an anchored generator contributes to its parent's extent. Co-Authored-By: Claude Opus 5 (1M context) --- Sources/FCPKitDSL/AnchoredItemBuilder.swift | 54 ++++++ Sources/FCPKitDSL/AssetClip+Modifiers.swift | 15 -- Sources/FCPKitDSL/AssetClip.swift | 29 ++-- Sources/FCPKitDSL/Built.swift | 11 +- Sources/FCPKitDSL/Color+DSL.swift | 24 ++- Sources/FCPKitDSL/DSLNode.swift | 14 +- Sources/FCPKitDSL/DocumentGroup.swift | 3 +- Sources/FCPKitDSL/Event.swift | 3 +- Sources/FCPKitDSL/Gap.swift | 23 ++- Sources/FCPKitDSL/Generator+Modifiers.swift | 37 +++++ Sources/FCPKitDSL/Generator.swift | 29 +++- Sources/FCPKitDSL/Layout+Packing.swift | 8 + Sources/FCPKitDSL/Library.swift | 3 +- Sources/FCPKitDSL/Project.swift | 3 +- Sources/FCPKitDSL/ResourceStore.swift | 7 +- Sources/FCPKitDSL/Sequence.swift | 3 +- Sources/FCPKitDSL/Spine.swift | 3 +- Sources/FCPKitDSL/StoryItem.swift | 86 ++++++++++ Sources/FCPKitDSL/Title.swift | 71 ++++++-- Sources/FCPKitDSL/Transition.swift | 16 +- .../StoryItemAnchorErrorTests.swift | 102 ++++++++++++ Tests/FCPKitDSLTests/StoryItemDoc.swift | 44 +++++ Tests/FCPKitDSLTests/StoryItemSupport.swift | 65 ++++++++ Tests/FCPKitDSLTests/StoryItemTests.swift | 155 ++++++++++++++++++ 24 files changed, 734 insertions(+), 74 deletions(-) create mode 100644 Sources/FCPKitDSL/AnchoredItemBuilder.swift create mode 100644 Sources/FCPKitDSL/Generator+Modifiers.swift create mode 100644 Sources/FCPKitDSL/StoryItem.swift create mode 100644 Tests/FCPKitDSLTests/StoryItemAnchorErrorTests.swift create mode 100644 Tests/FCPKitDSLTests/StoryItemDoc.swift create mode 100644 Tests/FCPKitDSLTests/StoryItemSupport.swift create mode 100644 Tests/FCPKitDSLTests/StoryItemTests.swift diff --git a/Sources/FCPKitDSL/AnchoredItemBuilder.swift b/Sources/FCPKitDSL/AnchoredItemBuilder.swift new file mode 100644 index 0000000..573da88 --- /dev/null +++ b/Sources/FCPKitDSL/AnchoredItemBuilder.swift @@ -0,0 +1,54 @@ +// +// AnchoredItemBuilder.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 + +/// Lowers a single anchor node into the DTD's `%anchor_item;` entity. +internal func anchoredItem( + _ node: any DSLNode, + resources: inout ResourceStore +) throws -> 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 + } +} + +/// Lowers a list of anchor nodes, returning `nil` when the list is empty. +internal func anchoredItems( + _ nodes: [any DSLNode], + resources: inout ResourceStore +) throws -> [FCPKit.AnchoredItem]? { + let items = try nodes.map { try anchoredItem($0, resources: &resources) } + return items.isEmpty ? nil : 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 d0dec80..40a3a7c 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: DSLNode, 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 -> 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 -> 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,8 +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 { try anchoredItem($0, resources: &resources) } - clip.anchoredItems = items.isEmpty ? nil : items + clip.anchoredItems = try anchoredItems(anchors, resources: &resources) return .item(.assetClip(clip)) } @@ -118,17 +124,4 @@ public struct AssetClip: DSLNode { audioRole: audioRole ?? self.audioRole ) } - - private func anchoredItem(_ node: any DSLNode, resources: inout ResourceStore) throws - -> 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.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 885042e..6615044 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 -> Built { + /// Lowers this color into a custom solid generator `