From 0154aeb052ad25d5cb12c74bd290ba2e9732a5ce Mon Sep 17 00:00:00 2001 From: Leo Dion Date: Mon, 3 Aug 2026 17:15:21 -0400 Subject: [PATCH 1/7] Add PresentationDocument and the export presentation subcommand (#38, #39) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes #38. Closes #39. The demo deck: seven solid-color slides with styled titles anchored on lane 1, separated by one-second cross dissolves. No .mov files and no ffmpeg, so anyone who clones the repo can regenerate the .fcpxml with one command — FCPKit demonstrating itself. `PresentationDocument` and `PresentationSlide` ship in FCPKitDSL so the showcase is browsable and testable; the CLI holds only a thin command that invokes them. `PresentationSequence` assembles the story items from an array, because a deck's length is data-driven rather than fixed-arity like the result builder's `buildBlock`. Dissolve-safe title timing: anchored items are not swept into a primary-storyline transition, so a title spanning a dissolve would hard-cut while its background dissolved. Packing sets a dissolved clip's `start` to T/2 and shrinks its duration by both overlaps, and an anchor's offset is relative to that trimmed start — so a title at offset zero already begins where the incoming dissolve ends, and only the tail needs trimming. Deletes the duplicated library copy of `RGBDocument`; demo scaffolding belongs only in the CLI, and `FCPTimeIntervalTests` already used its own local fixture. Rebuilt on v0.1.x after #42, #43, and #44 landed, so this branch now carries only the presentation work rather than the three tracks it was stacked on. Verified end to end against Apple's shipped DTDs: the deck packs to 36s, emits ts1..ts7, validates at both 1.13 and 1.14, and is byte-identical to the export produced before the tracks were squashed. Co-Authored-By: Claude Opus 5 (1M context) --- .gitignore | 2 + Sources/FCPKitDSL/PresentationDocument.swift | 132 ++++++++++++++ Sources/FCPKitDSL/PresentationSequence.swift | 70 ++++++++ ...Document.swift => PresentationSlide.swift} | 43 ++--- .../fcpxml-dsl/FCPXMLDSLCommand+Export.swift | 12 ++ .../fcpxml-dsl/FCPXMLDSLCommand+Usage.swift | 72 ++++++++ Sources/fcpxml-dsl/FCPXMLDSLCommand.swift | 59 +++---- .../PresentationDocumentTests.swift | 164 ++++++++++++++++++ 8 files changed, 495 insertions(+), 59 deletions(-) create mode 100644 Sources/FCPKitDSL/PresentationDocument.swift create mode 100644 Sources/FCPKitDSL/PresentationSequence.swift rename Sources/FCPKitDSL/{RGBDocument.swift => PresentationSlide.swift} (60%) create mode 100644 Sources/fcpxml-dsl/FCPXMLDSLCommand+Usage.swift create mode 100644 Tests/FCPKitDSLTests/PresentationDocumentTests.swift 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/Sources/FCPKitDSL/PresentationDocument.swift b/Sources/FCPKitDSL/PresentationDocument.swift new file mode 100644 index 0000000..96acc4f --- /dev/null +++ b/Sources/FCPKitDSL/PresentationDocument.swift @@ -0,0 +1,132 @@ +// +// PresentationDocument.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 media-free slide deck showcasing `FCPKitDSL`. +/// +/// Each slide is a solid color generator with a styled title anchored on lane 1, +/// separated by cross dissolves. No `.mov` files and no `ffmpeg` are involved, so +/// anyone who clones the repository can regenerate the `.fcpxml` with one command. +public struct PresentationDocument: Document { + /// The Final Cut Pro project name written into the exported document. + public let projectName: String + /// The slides, in order. + public let slides: [PresentationSlide] + /// The cross dissolve duration between consecutive slides. + public let transitionDuration: FCPTime + + /// The slides, interleaved with cross dissolves. + public var body: some DocumentContent { + Project(name: projectName) { + PresentationSequence(document: self) + } + } + + /// Creates a presentation document. + public init( + projectName: String = "FCPKit Presentation", + slides: [PresentationSlide] = PresentationDocument.featureShowcase, + transitionDuration: FCPTime = FCPTime(numerator: 1) + ) { + self.projectName = projectName + self.slides = slides + self.transitionDuration = transitionDuration + } + + /// Builds the alternating background / transition sequence. + internal func storyContent() -> [any DocumentContent] { + var content: [any DocumentContent] = [] + for (index, slide) in slides.enumerated() { + if index > 0 { + content.append(Transition(.crossDissolve, duration: transitionDuration)) + } + content.append(background(for: slide, at: index)) + } + return content + } + + /// A slide's color background carrying its anchored, dissolve-safe title. + private func background(for slide: PresentationSlide, at index: Int) -> any DocumentContent { + slide.background + .duration(slide.duration) + .anchor(lane: 1, offset: .zero) { + Title(slide.heading, duration: titleDuration(for: slide, at: index)) + .font("Helvetica") + .fontFace("Bold") + .fontSize(96) + .position(.center) + } + } + + /// The visible span of a slide after its dissolves are deducted. + /// + /// Anchored items are not swept into a primary-storyline transition, so a title + /// spanning a dissolve would hard-cut while its background dissolved. Packing + /// sets a dissolved clip's `start` to T/2 and shrinks its duration by both + /// overlaps, and an anchor's offset is relative to that trimmed start — so a + /// title at offset zero already begins where the incoming dissolve ends, and + /// only the tail needs trimming. + public func titleDuration(for slide: PresentationSlide, at index: Int) -> FCPTime { + let incoming = index == 0 ? 0 : transitionDuration.seconds / 2 + let outgoing = index == slides.count - 1 ? 0 : transitionDuration.seconds / 2 + return .seconds(slide.duration.seconds - incoming - outgoing) + } +} + +extension PresentationDocument { + /// The default FCPKit feature deck. + public static let featureShowcase: [PresentationSlide] = [ + PresentationSlide(heading: "FCPKit", background: Color(red: 0.08, green: 0.08, blue: 0.08)), + PresentationSlide( + heading: "Typed FCPXML model", + background: Color(red: 0.05, green: 0.15, blue: 0.42) + ), + PresentationSlide( + heading: "Ordered spine, preserved", + background: Color(red: 0.0, green: 0.32, blue: 0.36) + ), + PresentationSlide( + heading: "SwiftUI-shaped DSL", + background: Color(red: 0.29, green: 0.12, blue: 0.45) + ), + PresentationSlide( + heading: "Resource interning", + background: Color(red: 0.6, green: 0.28, blue: 0.02) + ), + PresentationSlide( + heading: "DTD-validated output", + background: Color(red: 0.06, green: 0.36, blue: 0.16) + ), + PresentationSlide( + heading: "brightdigit/FCPKit", + background: Color(red: 0.08, green: 0.08, blue: 0.08) + ), + ] +} diff --git a/Sources/FCPKitDSL/PresentationSequence.swift b/Sources/FCPKitDSL/PresentationSequence.swift new file mode 100644 index 0000000..76c5921 --- /dev/null +++ b/Sources/FCPKitDSL/PresentationSequence.swift @@ -0,0 +1,70 @@ +// +// PresentationSequence.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 + +/// The primary storyline of a ``PresentationDocument``. +/// +/// A slide deck's length is data-driven, so its story items are assembled as an +/// array rather than through the result builder's fixed-arity `buildBlock`. +internal struct PresentationSequence: DSLNode { + internal let document: PresentationDocument + + internal func build(_ resources: inout ResourceStore) throws(BuildError) -> Built { + let format = FormatPreset.p1080p24 + let formatRef = try resources.format(format) + + // Publish the frame size so anchored titles can resolve their positions. + let outerFrameSize = resources.frameSize + if let width = format.format.width.flatMap(Double.init), + let height = format.format.height.flatMap(Double.init) + { + resources.frameSize = (width: width, height: height) + } + defer { resources.frameSize = outerFrameSize } + + let packed = try Layout.pack( + document.storyContent().spineItems(resources: &resources), + frameDuration: format.format.frameDuration + ) + + return .sequence( + FCPKit.Sequence( + format: formatRef, + duration: packed.duration, + tcStart: "0s", + tcFormat: .nonDropFrame, + audioLayout: .stereo, + audioRate: .hz48000, + renderFormat: "FFRenderFormatProRes422HQ", + spine: FCPKit.Spine(items: packed.items) + ) + ) + } +} diff --git a/Sources/FCPKitDSL/RGBDocument.swift b/Sources/FCPKitDSL/PresentationSlide.swift similarity index 60% rename from Sources/FCPKitDSL/RGBDocument.swift rename to Sources/FCPKitDSL/PresentationSlide.swift index 03dcb03..2118a75 100644 --- a/Sources/FCPKitDSL/RGBDocument.swift +++ b/Sources/FCPKitDSL/PresentationSlide.swift @@ -1,5 +1,5 @@ // -// RGBDocument.swift +// PresentationSlide.swift // FCPKit // // Created by Leo Dion. @@ -29,26 +29,27 @@ import FCPKit -/// A sample document creating a sequence of red, green, and blue solid generator clips. -public struct RGBDocument: Document { - /// The Final Cut Pro project name written into the exported document. - public let projectName: String +/// A slide in a ``PresentationDocument``: a heading over a solid color background. +public struct PresentationSlide: Sendable { + /// The slide's primary heading. + public var heading: String + /// An optional secondary line beneath the heading. + public var subheading: String? + /// The solid color filling the frame behind the text. + public var background: Color + /// How long the slide holds on screen, before dissolve overlap is deducted. + public var duration: FCPTime - /// Red, green, and blue generator clips separated by cross-dissolve transitions. - public var body: some DocumentContent { - Project(name: projectName) { - Sequence { - Color.red.duration(.seconds(5.0)) - Transition(.crossDissolve) - Color.green.duration(.seconds(5.0)) - Transition(.crossDissolve) - Color.blue.duration(.seconds(5.0)) - } - } - } - - /// Creates an RGB sample document with an optional project name. - public init(projectName: String = "DSL RGB") { - self.projectName = projectName + /// Creates a slide. + public init( + heading: String, + subheading: String? = nil, + background: Color, + duration: FCPTime = .seconds(6) + ) { + self.heading = heading + self.subheading = subheading + self.background = background + self.duration = duration } } diff --git a/Sources/fcpxml-dsl/FCPXMLDSLCommand+Export.swift b/Sources/fcpxml-dsl/FCPXMLDSLCommand+Export.swift index 02c42b8..0bdd685 100644 --- a/Sources/fcpxml-dsl/FCPXMLDSLCommand+Export.swift +++ b/Sources/fcpxml-dsl/FCPXMLDSLCommand+Export.swift @@ -83,6 +83,18 @@ extension FCPXMLDSLCommand { try write(document: document, to: output, version: version) print("Wrote RGB cut → \(output.path)") } + + internal static func exportPresentation( + output: URL, + projectName: String, + version: FCPXMLVersion + ) async throws { + // The deck itself lives in FCPKitDSL so it ships as a browsable showcase; + // this command only parses arguments and invokes it. + let document = PresentationDocument(projectName: projectName) + try write(document: document, to: output, version: version) + print("Wrote presentation → \(output.path)") + } #endif private static func write( diff --git a/Sources/fcpxml-dsl/FCPXMLDSLCommand+Usage.swift b/Sources/fcpxml-dsl/FCPXMLDSLCommand+Usage.swift new file mode 100644 index 0000000..25194c9 --- /dev/null +++ b/Sources/fcpxml-dsl/FCPXMLDSLCommand+Usage.swift @@ -0,0 +1,72 @@ +// +// FCPXMLDSLCommand+Usage.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 Foundation + +extension FCPXMLDSLCommand { + internal static func printUsage() { + // swiftlint:disable indentation_width + print( + """ + fcpxml-dsl - Export FCPKitDSL documents to FCPXML for Final Cut import + + USAGE: + fcpxml-dsl export transitions [output.fcpxml] + fcpxml-dsl export titles [output.fcpxml] + fcpxml-dsl export rgb [output.fcpxml] + fcpxml-dsl export presentation [output.fcpxml] + fcpxml-dsl verify-import + + OPTIONS: + --project Project name (defaults per kind; verify-import: expected name) + --text Title text for `titles` (default: Title) + --version FCPXML version (default: 1.14) + --timeout verify-import wait in seconds (default: 30) + -h, --help Show this help + + EXAMPLES: + fcpxml-dsl export transitions Left.mov Right.mov transitions.fcpxml + fcpxml-dsl export titles Left.mov titles.fcpxml --text "Hello" + fcpxml-dsl export rgb rgb.fcpxml + fcpxml-dsl export presentation presentation.fcpxml + fcpxml-dsl verify-import presentation.fcpxml + + DESCRIPTION: + `export` probes media durations with AVFoundation, builds a typed DSL + document, and writes FCPXML you can import into Final Cut Pro. + + `verify-import` opens the file in Final Cut Pro and confirms the + project appears in the library (needs Automation permission; the + rejection alert check also needs Accessibility). The import lands in + the active library and must be cleaned up manually. + """ + ) + // swiftlint:enable indentation_width + } +} diff --git a/Sources/fcpxml-dsl/FCPXMLDSLCommand.swift b/Sources/fcpxml-dsl/FCPXMLDSLCommand.swift index 6d90d96..852f4e0 100644 --- a/Sources/fcpxml-dsl/FCPXMLDSLCommand.swift +++ b/Sources/fcpxml-dsl/FCPXMLDSLCommand.swift @@ -86,6 +86,12 @@ internal enum FCPXMLDSLCommand { ) case "rgb": try await exportRGBCommand(positionals, projectName: projectName, version: version) + case "presentation": + try await exportPresentationCommand( + positionals, + projectName: projectName, + version: version + ) default: throw FCPXMLDSLCommandError.usage } @@ -142,6 +148,19 @@ internal enum FCPXMLDSLCommand { version: version ) } + + private static func exportPresentationCommand( + _ positionals: [String], + projectName: String, + version: FCPXMLVersion + ) async throws { + let output = URL(fileURLWithPath: positionals.first ?? "presentation.fcpxml") + try await exportPresentation( + output: output, + projectName: projectName, + version: version + ) + } #endif private static func parseOptions(_ arguments: inout [String]) throws -> [String: String] { @@ -170,46 +189,10 @@ internal enum FCPXMLDSLCommand { return "DSL Titles" case "rgb": return "DSL RGB" + case "presentation": + return "FCPKit Presentation" default: return "DSL Export" } } - - private static func printUsage() { - // swiftlint:disable indentation_width - print( - """ - fcpxml-dsl - Export FCPKitDSL documents to FCPXML for Final Cut import - - USAGE: - fcpxml-dsl export transitions [output.fcpxml] - fcpxml-dsl export titles [output.fcpxml] - fcpxml-dsl export rgb [output.fcpxml] - fcpxml-dsl verify-import - - OPTIONS: - --project Project name (defaults per kind; verify-import: expected name) - --text Title text for `titles` (default: Title) - --version FCPXML version (default: 1.14) - --timeout verify-import wait in seconds (default: 30) - -h, --help Show this help - - EXAMPLES: - fcpxml-dsl export transitions Left.mov Right.mov transitions.fcpxml - fcpxml-dsl export titles Left.mov titles.fcpxml --text "Hello" - fcpxml-dsl export rgb rgb.fcpxml - fcpxml-dsl verify-import rgb.fcpxml - - DESCRIPTION: - `export` probes media durations with AVFoundation, builds a typed DSL - document, and writes FCPXML you can import into Final Cut Pro. - - `verify-import` opens the file in Final Cut Pro and confirms the - project appears in the library (needs Automation permission; the - rejection alert check also needs Accessibility). The import lands in - the active library and must be cleaned up manually. - """ - ) - // swiftlint:enable indentation_width - } } diff --git a/Tests/FCPKitDSLTests/PresentationDocumentTests.swift b/Tests/FCPKitDSLTests/PresentationDocumentTests.swift new file mode 100644 index 0000000..c7b6e5e --- /dev/null +++ b/Tests/FCPKitDSLTests/PresentationDocumentTests.swift @@ -0,0 +1,164 @@ +// +// PresentationDocumentTests.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 FCPKitDSL +import FCPXMLDiff +import Foundation +import Testing + +@Suite +internal struct PresentationDocumentTests { + private static func spine(_ exported: FCPXML) throws -> [FCPKit.SpineItem] { + let sequence = try #require(exported.library?.events?.first?.projects?.first?.sequence) + return try #require(sequence.spine?.items) + } + + private static func firstNode(named name: String, in node: XMLTreeNode) -> XMLTreeNode? { + if node.name == name { + return node + } + for child in node.children { + if let match = firstNode(named: name, in: child) { + return match + } + } + return nil + } + + @Test + internal func spineAlternatesBackgroundsAndTransitions() throws { + let exported = try PresentationDocument().export() + let encoded = try FCPXMLParser().encode(exported) + let root = try XMLTreeParser().parse(encoded) + let spine = try #require(Self.firstNode(named: "spine", in: root)) + + // Seven slides interleaved with six dissolves. + var expected: [String] = [] + for index in 0..<7 { + if index > 0 { + expected.append("transition") + } + expected.append("video") + } + #expect(spine.children.map(\.name) == expected) + } + + @Test + internal func everyBackgroundCarriesOneAnchoredTitleOnLaneOne() throws { + let items = try Self.spine(try PresentationDocument().export()) + var videoCount = 0 + + for item in items { + guard case .video(let video) = item else { + continue + } + videoCount += 1 + let anchored = try #require(video.anchoredItems) + #expect(anchored.count == 1) + guard case .title(let title) = anchored[0] else { + Issue.record("Expected the anchored item to be a title") + return + } + #expect(title.lane == "1") + } + + #expect(videoCount == 7) + } + + @Test + internal func everyTextStyleDefIDIsUnique() throws { + // Ties #35 to the showcase: seven titles must not collide on "ts1". + let items = try Self.spine(try PresentationDocument().export()) + var ids: [String] = [] + + for item in items { + guard case .video(let video) = item, let anchored = video.anchoredItems else { + continue + } + for entry in anchored { + guard case .title(let title) = entry else { + continue + } + ids.append(contentsOf: title.textStyleDef?.compactMap(\.id) ?? []) + } + } + + #expect(ids.count == 7) + #expect(Set(ids).count == ids.count) + } + + @Test + internal func titleDurationsFollowTheDissolveSafeFormula() throws { + let document = PresentationDocument() + let slides = document.slides + + // First and last slides have only one dissolve; the middle slides have two. + let first = document.titleDuration(for: slides[0], at: 0) + let middle = document.titleDuration(for: slides[1], at: 1) + let last = document.titleDuration(for: slides[slides.count - 1], at: slides.count - 1) + + let slideSeconds = slides[0].duration.seconds + let half = document.transitionDuration.seconds / 2 + #expect(abs(first.seconds - (slideSeconds - half)) < 0.0001) + #expect(abs(middle.seconds - (slideSeconds - half - half)) < 0.0001) + #expect(abs(last.seconds - (slideSeconds - half)) < 0.0001) + } + + @Test + internal func sequenceDurationLandsInTheTargetRange() throws { + let exported = try PresentationDocument().export() + let sequence = try #require(exported.library?.events?.first?.projects?.first?.sequence) + let durationString = try #require(sequence.duration) + let duration = try #require(FCPTime(durationString)) + + // Assert on the packer's computed duration rather than hand arithmetic. + // Seven 6s slides with six 1s dissolves pack to 36s. + #expect(duration.seconds >= 30) + #expect(duration.seconds <= 45) + } + + @Test + internal func effectsAreInternedAcrossSlides() throws { + let exported = try PresentationDocument().export() + let effects = try #require(exported.resources?.effects) + + // Seven slides and six transitions, but each distinct effect is interned once. + #expect(effects.filter { $0.name == "Custom" }.count == 1) + #expect(effects.filter { $0.name == "Basic Title" }.count == 1) + #expect(effects.filter { $0.name == "Cross Dissolve" }.count == 1) + } + + @Test + internal func documentValidatesAgainstTheDTD() throws { + let exported = try PresentationDocument().export() + let encoded = try FCPXMLParser().encode(exported) + try assertDTDValidates(encoded) + } +} From e61658909f9c3531dea36c540e57f0a2b1641312 Mon Sep 17 00:00:00 2001 From: Leo Dion Date: Wed, 5 Aug 2026 14:14:51 -0400 Subject: [PATCH 2/7] Move PresentationDocument into a dedicated FCPKitDemo library product. Keep FCPKitDSL focused on authoring surface; showcase types depend only on the public DSL, with DocumentBuilder flattening nested groups so data-driven for/if spines export correctly. --- .claude/agent-notes.md | 1 + Package.swift | 14 +++- Sources/FCPKitDSL/DocumentBuilder.swift | 11 ++- Sources/FCPKitDSL/PresentationSequence.swift | 70 ------------------- .../PresentationDocument.swift | 30 ++++---- .../PresentationSlide.swift | 0 .../fcpxml-dsl/FCPXMLDSLCommand+Export.swift | 3 +- .../DTDValidationSupport.swift | 51 ++++++++++++++ .../PresentationDocumentTests.swift | 1 + 9 files changed, 92 insertions(+), 89 deletions(-) delete mode 100644 Sources/FCPKitDSL/PresentationSequence.swift rename Sources/{FCPKitDSL => FCPKitDemo}/PresentationDocument.swift (89%) rename Sources/{FCPKitDSL => FCPKitDemo}/PresentationSlide.swift (100%) create mode 100644 Tests/FCPKitDemoTests/DTDValidationSupport.swift rename Tests/{FCPKitDSLTests => FCPKitDemoTests}/PresentationDocumentTests.swift (99%) diff --git a/.claude/agent-notes.md b/.claude/agent-notes.md index d7b21d3..f461fc5 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: Showcase presentation types (`PresentationDocument` / `PresentationSlide`) ship in `FCPKitDemo`, not `FCPKitDSL`; that product depends only on the public DSL. diff --git a/Package.swift b/Package.swift index 2d1b8b5..5287ae4 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,17 @@ let package = Package( name: "FCPKitDSL", dependencies: ["FCPKit"] ), + .target( + name: "FCPKitDemo", + dependencies: ["FCPKitDSL"] + ), .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 +107,9 @@ let package = Package( name: "FCPKitDSLTests", dependencies: ["FCPKitDSL", "FCPKit", "FCPXMLDiff"] ), + .testTarget( + name: "FCPKitDemoTests", + dependencies: ["FCPKitDemo", "FCPKit", "FCPKitDSL", "FCPXMLDiff"] + ), ] ) diff --git a/Sources/FCPKitDSL/DocumentBuilder.swift b/Sources/FCPKitDSL/DocumentBuilder.swift index ffcbac8..b9726d2 100644 --- a/Sources/FCPKitDSL/DocumentBuilder.swift +++ b/Sources/FCPKitDSL/DocumentBuilder.swift @@ -30,9 +30,16 @@ /// Builds a document or story body from declarative child values. @resultBuilder public enum DocumentBuilder { - /// Joins child content values into a ``DocumentGroup``. + /// Joins child content values into a ``DocumentGroup``, flattening nested groups. public static func buildBlock(_ components: any DocumentContent...) -> DocumentGroup { - DocumentGroup(components) + DocumentGroup( + components.flatMap { component -> [any DocumentContent] in + if let group = component as? DocumentGroup { + return group.contents + } + return [component] + } + ) } /// Passes an expression through as document content. diff --git a/Sources/FCPKitDSL/PresentationSequence.swift b/Sources/FCPKitDSL/PresentationSequence.swift deleted file mode 100644 index 76c5921..0000000 --- a/Sources/FCPKitDSL/PresentationSequence.swift +++ /dev/null @@ -1,70 +0,0 @@ -// -// PresentationSequence.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 - -/// The primary storyline of a ``PresentationDocument``. -/// -/// A slide deck's length is data-driven, so its story items are assembled as an -/// array rather than through the result builder's fixed-arity `buildBlock`. -internal struct PresentationSequence: DSLNode { - internal let document: PresentationDocument - - internal func build(_ resources: inout ResourceStore) throws(BuildError) -> Built { - let format = FormatPreset.p1080p24 - let formatRef = try resources.format(format) - - // Publish the frame size so anchored titles can resolve their positions. - let outerFrameSize = resources.frameSize - if let width = format.format.width.flatMap(Double.init), - let height = format.format.height.flatMap(Double.init) - { - resources.frameSize = (width: width, height: height) - } - defer { resources.frameSize = outerFrameSize } - - let packed = try Layout.pack( - document.storyContent().spineItems(resources: &resources), - frameDuration: format.format.frameDuration - ) - - return .sequence( - FCPKit.Sequence( - format: formatRef, - duration: packed.duration, - tcStart: "0s", - tcFormat: .nonDropFrame, - audioLayout: .stereo, - audioRate: .hz48000, - renderFormat: "FFRenderFormatProRes422HQ", - spine: FCPKit.Spine(items: packed.items) - ) - ) - } -} diff --git a/Sources/FCPKitDSL/PresentationDocument.swift b/Sources/FCPKitDemo/PresentationDocument.swift similarity index 89% rename from Sources/FCPKitDSL/PresentationDocument.swift rename to Sources/FCPKitDemo/PresentationDocument.swift index 96acc4f..819fd99 100644 --- a/Sources/FCPKitDSL/PresentationDocument.swift +++ b/Sources/FCPKitDemo/PresentationDocument.swift @@ -28,6 +28,7 @@ // import FCPKit +import FCPKitDSL /// A media-free slide deck showcasing `FCPKitDSL`. /// @@ -43,9 +44,20 @@ public struct PresentationDocument: Document { public let transitionDuration: FCPTime /// The slides, interleaved with cross dissolves. - public var body: some DocumentContent { + public var body: DocumentGroup { Project(name: projectName) { - PresentationSequence(document: self) + Sequence { + for (index, slide) in slides.enumerated() { + // Prefer `if`/`else` over `if` alone so the builder never emits an empty + // `DocumentGroup` (which lowers to `.spine`, not a spine `.item`). + if index > 0 { + Transition(.crossDissolve, duration: transitionDuration) + background(for: slide, at: index) + } else { + background(for: slide, at: index) + } + } + } } } @@ -60,20 +72,8 @@ public struct PresentationDocument: Document { self.transitionDuration = transitionDuration } - /// Builds the alternating background / transition sequence. - internal func storyContent() -> [any DocumentContent] { - var content: [any DocumentContent] = [] - for (index, slide) in slides.enumerated() { - if index > 0 { - content.append(Transition(.crossDissolve, duration: transitionDuration)) - } - content.append(background(for: slide, at: index)) - } - return content - } - /// A slide's color background carrying its anchored, dissolve-safe title. - private func background(for slide: PresentationSlide, at index: Int) -> any DocumentContent { + private func background(for slide: PresentationSlide, at index: Int) -> some DocumentContent { slide.background .duration(slide.duration) .anchor(lane: 1, offset: .zero) { diff --git a/Sources/FCPKitDSL/PresentationSlide.swift b/Sources/FCPKitDemo/PresentationSlide.swift similarity index 100% rename from Sources/FCPKitDSL/PresentationSlide.swift rename to Sources/FCPKitDemo/PresentationSlide.swift diff --git a/Sources/fcpxml-dsl/FCPXMLDSLCommand+Export.swift b/Sources/fcpxml-dsl/FCPXMLDSLCommand+Export.swift index 0bdd685..66839dd 100644 --- a/Sources/fcpxml-dsl/FCPXMLDSLCommand+Export.swift +++ b/Sources/fcpxml-dsl/FCPXMLDSLCommand+Export.swift @@ -28,6 +28,7 @@ // import FCPKit +import FCPKitDemo import FCPKitDSL import Foundation @@ -89,7 +90,7 @@ extension FCPXMLDSLCommand { projectName: String, version: FCPXMLVersion ) async throws { - // The deck itself lives in FCPKitDSL so it ships as a browsable showcase; + // The deck itself lives in FCPKitDemo so it ships as a browsable showcase; // this command only parses arguments and invokes it. let document = PresentationDocument(projectName: projectName) try write(document: document, to: output, version: version) diff --git a/Tests/FCPKitDemoTests/DTDValidationSupport.swift b/Tests/FCPKitDemoTests/DTDValidationSupport.swift new file mode 100644 index 0000000..1e9d8c3 --- /dev/null +++ b/Tests/FCPKitDemoTests/DTDValidationSupport.swift @@ -0,0 +1,51 @@ +// +// DTDValidationSupport.swift +// FCPKitDemoTests +// +// 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 FCPXMLDiff +import Foundation +import Testing + +/// Validates serialized FCPXML against Final Cut's bundled DTD. +/// +/// When Final Cut or `xmllint` are unavailable the check soft-skips, unless the +/// `FCPKIT_REQUIRE_DTD` environment variable is set, in which case the missing +/// tooling is recorded as a failure. +internal func assertDTDValidates(_ data: Data) throws { + let requireDTD = ProcessInfo.processInfo.environment["FCPKIT_REQUIRE_DTD"] != nil + do { + let report = try FCPXMLDTDValidator().validate(data: data) + #expect(report.isValid, "DTD issues: \(report.issues)") + } catch FCPXMLValidationError.dtdNotFound, FCPXMLValidationError.xmllintUnavailable { + if requireDTD { + Issue.record("FCPKIT_REQUIRE_DTD is set but DTD tooling is unavailable") + } else { + // Soft skip when Final Cut / xmllint are absent. + } + } +} diff --git a/Tests/FCPKitDSLTests/PresentationDocumentTests.swift b/Tests/FCPKitDemoTests/PresentationDocumentTests.swift similarity index 99% rename from Tests/FCPKitDSLTests/PresentationDocumentTests.swift rename to Tests/FCPKitDemoTests/PresentationDocumentTests.swift index c7b6e5e..c058c1c 100644 --- a/Tests/FCPKitDSLTests/PresentationDocumentTests.swift +++ b/Tests/FCPKitDemoTests/PresentationDocumentTests.swift @@ -28,6 +28,7 @@ // import FCPKit +import FCPKitDemo import FCPKitDSL import FCPXMLDiff import Foundation From 3ac5bb12d6385d91b2896797d6310cdf2d3269c3 Mon Sep 17 00:00:00 2001 From: Leo Dion Date: Wed, 5 Aug 2026 14:59:34 -0400 Subject: [PATCH 3/7] Fix presentation FCP import and prefer .duration over init args. Emit library colorProcessing via Project.colorProcessing(.wideHDR), reject zero-duration titles at build, and let anchored story items inherit the host duration when unset so PresentationDocument exports import cleanly into wide-HDR libraries. Co-authored-by: Cursor --- .claude/agent-notes.md | 2 +- Sources/FCPKitDSL/Anchor.swift | 31 +++++ Sources/FCPKitDSL/AssetClip.swift | 53 +++++++- Sources/FCPKitDSL/Built+Anchoring.swift | 16 ++- Sources/FCPKitDSL/Color+DSL.swift | 17 +-- Sources/FCPKitDSL/Gap.swift | 21 ++- Sources/FCPKitDSL/Generator.swift | 42 ++++-- Sources/FCPKitDSL/Project.swift | 58 +++++++- Sources/FCPKitDSL/StoryItem.swift | 3 + Sources/FCPKitDSL/Title.swift | 47 ++++++- Sources/FCPKitDSL/Transition.swift | 16 ++- Sources/FCPKitDemo/PresentationDocument.swift | 98 ++------------ Sources/FCPKitDemo/PresentationSlide.swift | 55 -------- .../fcpxml-dsl/FCPXMLDSLCommand+Export.swift | 3 +- Sources/fcpxml-dsl/TitlesCutDocument.swift | 4 +- .../fcpxml-dsl/TransitionsCutDocument.swift | 4 +- Tests/FCPKitDSLTests/FCPKitDSLTests.swift | 85 ++++++++++-- .../FeaturePairAcceptanceTests.swift | 6 +- .../FCPKitDSLTests/FeaturePairDocuments.swift | 6 +- Tests/FCPKitDSLTests/FramePositionTests.swift | 30 ++--- Tests/FCPKitDSLTests/GeneratorDSLTests.swift | 12 +- .../PositionedOrderingDoc.swift | 4 +- .../SmartCollectionVersionTests.swift | 3 +- .../StoryItemAnchorErrorTests.swift | 8 +- Tests/FCPKitDSLTests/StoryItemSupport.swift | 6 +- Tests/FCPKitDSLTests/StoryItemTests.swift | 38 +++--- Tests/FCPKitDSLTests/TextStyleIDTests.swift | 8 +- Tests/FCPKitDSLTests/TitleStyleSupport.swift | 4 +- Tests/FCPKitDSLTests/TitleStyleTests.swift | 16 +-- .../PresentationDocumentTests.swift | 126 +++--------------- 30 files changed, 438 insertions(+), 384 deletions(-) delete mode 100644 Sources/FCPKitDemo/PresentationSlide.swift diff --git a/.claude/agent-notes.md b/.claude/agent-notes.md index f461fc5..e4da9f3 100644 --- a/.claude/agent-notes.md +++ b/.claude/agent-notes.md @@ -38,4 +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: Showcase presentation types (`PresentationDocument` / `PresentationSlide`) ship in `FCPKitDemo`, not `FCPKitDSL`; that product depends only on the public DSL. +- 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/Sources/FCPKitDSL/Anchor.swift b/Sources/FCPKitDSL/Anchor.swift index 2036b9b..37868a0 100644 --- a/Sources/FCPKitDSL/Anchor.swift +++ b/Sources/FCPKitDSL/Anchor.swift @@ -35,7 +35,16 @@ internal struct Anchor: DSLNode { internal let content: any DSLNode internal func build(_ resources: inout ResourceStore) throws(BuildError) -> Built { + 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 @@ -45,4 +54,26 @@ internal struct Anchor: DSLNode { } return .spine(spine) } + + /// 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 } + switch content { + case let title as Title where title.duration == nil: + return title.duration(host) + case let generator as Generator where generator.duration == nil: + return generator.duration(host) + case let gap as Gap where gap.duration == nil: + return gap.duration(host) + case let clip as AssetClip where clip.duration == nil && clip.source.asset.duration == nil: + return clip.duration(host) + case let color as Color where color.duration == nil: + return color.duration(host) + default: + return content + } + } } diff --git a/Sources/FCPKitDSL/AssetClip.swift b/Sources/FCPKitDSL/AssetClip.swift index 0a05e66..515c8e7 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,53 @@ 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) } @@ -111,7 +146,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)) } diff --git a/Sources/FCPKitDSL/Built+Anchoring.swift b/Sources/FCPKitDSL/Built+Anchoring.swift index 536262b..af8d3c1 100644 --- a/Sources/FCPKitDSL/Built+Anchoring.swift +++ b/Sources/FCPKitDSL/Built+Anchoring.swift @@ -74,13 +74,25 @@ extension Array where Element == any DSLNode { /// /// The optional is load-bearing: the model omits the element entirely rather /// than emitting an empty container. + /// + /// - Parameters: + /// - resources: The document resource table. + /// - hostDuration: Duration of the storyline host. Anchored content with no + /// explicit duration inherits this value. internal func anchoredItems( - resources: inout ResourceStore + resources: inout ResourceStore, + hostDuration: FCPTime? = nil ) throws(BuildError) -> [FCPKit.AnchoredItem]? { var items: [FCPKit.AnchoredItem] = [] items.reserveCapacity(count) for node in self { - items.append(try node.build(&resources).anchoredItem()) + let built: Built + if let anchor = node as? Anchor { + built = try anchor.build(&resources, hostDuration: hostDuration) + } else { + built = try node.build(&resources) + } + items.append(try built.anchoredItem()) } return items.isEmpty ? nil : items } diff --git a/Sources/FCPKitDSL/Color+DSL.swift b/Sources/FCPKitDSL/Color+DSL.swift index d38ff37..8505ace 100644 --- a/Sources/FCPKitDSL/Color+DSL.swift +++ b/Sources/FCPKitDSL/Color+DSL.swift @@ -45,7 +45,7 @@ extension Color: DSLNode { guard let duration else { throw BuildError.missingDuration("color generator") } - let generator = Generator(.custom, duration: duration).color(self) + let generator = Generator(.custom).duration(duration).color(self) return try generator.build(&resources) } } @@ -60,13 +60,14 @@ extension Color: StoryItem { /// 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()`. + /// - Important: Call `.duration(_:)` *before* `.anchor(lane:offset:content:)` on the + /// color host. Anchored children without an explicit duration inherit that host + /// duration at export. public func replacingAnchors(_ anchors: [any DSLNode]) -> Generator { - Generator(.custom, duration: duration ?? .zero) - .color(self) - .replacingAnchors(anchors) + var generator = Generator(.custom).color(self) + if let duration { + generator = generator.duration(duration) + } + return generator.replacingAnchors(anchors) } } diff --git a/Sources/FCPKitDSL/Gap.swift b/Sources/FCPKitDSL/Gap.swift index 208c59d..16503b9 100644 --- a/Sources/FCPKitDSL/Gap.swift +++ b/Sources/FCPKitDSL/Gap.swift @@ -29,15 +29,25 @@ import FCPKit -/// A gap on the storyline. Duration is required — set in initializer or via `.duration(...)`. +/// A gap on the storyline. Duration is required — set via `.duration(...)`, or +/// inherited from the host when this gap is anchored. public struct Gap: StoryItem { /// Gap duration on the storyline, when set. public let duration: FCPTime? /// The anchors attached to this gap. public let anchors: [any DSLNode] - /// Creates a gap. Export fails when `duration` is omitted. - public init(duration: FCPTime? = nil) { + /// Creates a gap. Export fails when duration is never set and not inherited. + public init() { + self.init(duration: nil, anchors: []) + } + + /// Creates a gap with an optional duration. + @available(*, deprecated, message: """ + Use `.duration(_:)` instead of passing duration to the initializer. \ + Anchored gaps inherit the host duration when omitted. + """) + public init(duration: FCPTime?) { self.init(duration: duration, anchors: []) } @@ -60,7 +70,10 @@ public struct Gap: StoryItem { public func build(_ resources: inout ResourceStore) throws(BuildError) -> Built { guard let duration else { throw BuildError.missingDuration("gap") } var element = FCPKit.Gap(duration: duration.description) - element.anchoredItems = try anchors.anchoredItems(resources: &resources) + element.anchoredItems = try anchors.anchoredItems( + resources: &resources, + hostDuration: duration + ) return .item(.gap(element)) } } diff --git a/Sources/FCPKitDSL/Generator.swift b/Sources/FCPKitDSL/Generator.swift index 0daada9..8a7db24 100644 --- a/Sources/FCPKitDSL/Generator.swift +++ b/Sources/FCPKitDSL/Generator.swift @@ -35,8 +35,8 @@ public struct Generator: DSLNode { public static let customColorKey = "9999/10008/10006/2/1/1" internal let preset: GeneratorPreset - /// Clip duration on the storyline. - public let duration: FCPTime + /// Clip duration on the storyline, when set via ``duration(_:)`` or inherited from a host. + public let duration: FCPTime? internal let name: String? internal let params: [ParamElement] internal let lane: Int? @@ -44,11 +44,31 @@ public struct Generator: DSLNode { /// The anchors attached to this generator clip. public let anchors: [any DSLNode] + /// Creates a generator clip from a preset. + /// + /// Set duration with ``duration(_:)``. When this generator is anchored and has + /// no duration, it inherits the host clip's duration. + public init(_ preset: GeneratorPreset = .custom, name: String? = nil) { + self.init( + preset: preset, + duration: nil, + name: name, + params: [], + lane: nil, + offset: nil, + anchors: [] + ) + } + /// Creates a generator clip from a preset and optional duration. - public init(_ preset: GeneratorPreset = .custom, duration: FCPTime? = nil, name: String? = nil) { + @available(*, deprecated, message: """ + Use `.duration(_:)` instead of passing duration to the initializer. \ + Anchored generators inherit the host duration when omitted. + """) + public init(_ preset: GeneratorPreset, duration: FCPTime?, name: String? = nil) { self.init( preset: preset, - duration: duration ?? .zero, + duration: duration, name: name, params: [], lane: nil, @@ -59,7 +79,7 @@ public struct Generator: DSLNode { private init( preset: GeneratorPreset, - duration: FCPTime, + duration: FCPTime?, name: String?, params: [ParamElement], lane: Int?, @@ -117,10 +137,11 @@ public struct Generator: DSLNode { /// Lowers this generator into a `