From 1a40dfae5b678e8b89cf7236267de270e2f803fb Mon Sep 17 00:00:00 2001 From: Leo Dion Date: Mon, 3 Aug 2026 06:46:14 -0400 Subject: [PATCH 1/6] Fix #35: allocate unique text-style-def ids per document MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `Title.build` hardcoded the id "ts1" in both the `TextStyle(ref:)` and the `TextStyleDef(id:)` it emitted, for every title. The DTD declares `text-style-def/@id` as `ID`, and XML requires `ID` values to be unique within a document, so any document containing two titles emitted invalid XML. This was latent only because no shipped document had more than one title. The presentation deck (#38) has seven. Add a `textStyleID()` allocator to `ResourceStore`, modeled on the existing `nextNumber` resource counter but in its own `ts1`, `ts2`, … namespace. Ids are numbered globally across titles, matching what Final Cut itself writes: `Tests/FCPKitTests/TestData/UntitledXML.fcpxml` shows two titles producing ts1..ts4 rather than restarting per title. The counter starts at 1, so single-title documents still emit exactly "ts1" and the feature-pair fixtures need no edits. The DTD regression test exports at 1.14 on purpose: at the 1.13 default the document is also invalid because the default smart collections emit `match-analysis-type`, which 1.13 does not declare (#41, fixed separately). That unrelated failure would otherwise mask the ID regression this guards. Co-Authored-By: Claude Opus 5 (1M context) --- Sources/FCPKitDSL/ResourceStore.swift | 10 ++ Sources/FCPKitDSL/Title.swift | 5 +- Tests/FCPKitDSLTests/TextStyleIDTests.swift | 130 ++++++++++++++++++++ 3 files changed, 143 insertions(+), 2 deletions(-) create mode 100644 Tests/FCPKitDSLTests/TextStyleIDTests.swift diff --git a/Sources/FCPKitDSL/ResourceStore.swift b/Sources/FCPKitDSL/ResourceStore.swift index 2ab2a35..e86e17c 100644 --- a/Sources/FCPKitDSL/ResourceStore.swift +++ b/Sources/FCPKitDSL/ResourceStore.swift @@ -39,6 +39,7 @@ internal struct ResourceStore { private var fingerprints: [String: ResourceID] = [:] private var explicitFingerprints: [String: String] = [:] private var nextNumber = 1 + private var nextTextStyleNumber = 1 private static func formatFingerprint(_ format: FCPKit.Format) -> String { [ @@ -116,6 +117,15 @@ internal struct ResourceStore { return try resourceRef(id) } + /// Allocates the next document-unique `text-style-def` id (`ts1`, `ts2`, …). + /// + /// The DTD declares `text-style-def/@id` as `ID`, which XML requires to be unique + /// across the whole document, so ids are numbered globally rather than per title. + internal mutating func textStyleID() -> String { + defer { nextTextStyleNumber += 1 } + return "ts\(nextTextStyleNumber)" + } + internal func materialize() -> FCPKit.Resources { FCPKit.Resources( assets: assets.isEmpty ? nil : assets, diff --git a/Sources/FCPKitDSL/Title.swift b/Sources/FCPKitDSL/Title.swift index a59fb03..70cb522 100644 --- a/Sources/FCPKitDSL/Title.swift +++ b/Sources/FCPKitDSL/Title.swift @@ -63,9 +63,10 @@ public struct Title: DSLNode { internal func build(_ resources: inout ResourceStore) throws -> Built { let ref = try resources.effect(name: preset.name, uid: preset.uid) - let style = FCPKit.TextStyle(ref: "ts1", content: text) + let styleID = resources.textStyleID() + let style = FCPKit.TextStyle(ref: styleID, content: text) let definition = FCPKit.TextStyleDef( - id: "ts1", + id: styleID, textStyle: FCPKit.TextStyle( font: "Helvetica", fontSize: "63", diff --git a/Tests/FCPKitDSLTests/TextStyleIDTests.swift b/Tests/FCPKitDSLTests/TextStyleIDTests.swift new file mode 100644 index 0000000..1fd5be8 --- /dev/null +++ b/Tests/FCPKitDSLTests/TextStyleIDTests.swift @@ -0,0 +1,130 @@ +// +// TextStyleIDTests.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 TextStyleIDTests { + private struct MultiTitleDoc: Document { + let count: Int + + var body: some DocumentContent { + Sequence(format: .p1080p24) { + Title("Slide 1", duration: FCPTime(numerator: 5)) + if count > 1 { + Title("Slide 2", duration: FCPTime(numerator: 5)) + } + if count > 2 { + Title("Slide 3", duration: FCPTime(numerator: 5)) + } + } + } + } + + private struct SingleTitleDoc: Document { + var body: some DocumentContent { + Sequence(format: .p1080p24) { + Title("Only", duration: FCPTime(numerator: 5)) + } + } + } + + /// Returns every title in the exported document's spine, in order. + private func titles(_ exported: FCPXML) throws -> [FCPKit.Title] { + let sequence = try #require(exported.library?.events?.first?.projects?.first?.sequence) + let items = try #require(sequence.spine?.items) + return items.compactMap { item in + guard case .title(let title) = item else { + return nil + } + return title + } + } + + /// Validates against Final Cut's DTD, soft-skipping when the tooling is absent. + private 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") + } + } + } + + @Test + internal func threeTitlesGetDistinctSequentialIDs() throws { + let exported = try MultiTitleDoc(count: 3).export() + let ids = try titles(exported).compactMap { $0.textStyleDef?.first?.id } + #expect(ids == ["ts1", "ts2", "ts3"]) + #expect(Set(ids).count == 3) + } + + @Test + internal func eachTitleReferencesItsOwnStyleDefinition() throws { + let exported = try MultiTitleDoc(count: 3).export() + + // Uniqueness alone is not enough: the ref must still point at that title's + // own definition, not merely at some distinct id. + for title in try titles(exported) { + let definitionID = try #require(title.textStyleDef?.first?.id) + let ref = try #require(title.text?.first?.textStyle?.first?.ref) + #expect(ref == definitionID) + } + } + + @Test + internal func singleTitleStillEmitsTS1() throws { + // Backward compatibility: the counter starts at 1, so existing single-title + // fixtures stay byte-identical and need no edits. + let exported = try SingleTitleDoc().export() + let ids = try titles(exported).compactMap { $0.textStyleDef?.first?.id } + #expect(ids == ["ts1"]) + } + + @Test + internal func multiTitleDocumentValidatesAgainstDTD() throws { + // Duplicate `ID` values are invalid XML, so this is the regression guard: + // it fails against the pre-fix hardcoded "ts1". + // + // Exported at 1.14 deliberately. At the 1.13 default the document is also + // invalid for an unrelated reason — the default smart collections emit + // `match-analysis-type`, which 1.13 does not declare (#41) — and that + // failure would mask the ID regression this test exists to catch. + let exported = try MultiTitleDoc(count: 3).export(version: FCPXMLVersion("1.14")) + let encoded = try FCPXMLParser().encode(exported) + try assertDTDValidates(encoded) + } +} From c6b74c519e6ed47cdfc7447baf3952febb850ac8 Mon Sep 17 00:00:00 2001 From: Leo Dion Date: Mon, 3 Aug 2026 06:54:32 -0400 Subject: [PATCH 2/6] Fix #37: add Title styling and frame positioning modifiers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `Title.build` hardcoded Helvetica 63pt Regular white centered for every title. A slide deck needs to distinguish a heading from body text and to place text somewhere other than dead centre. Adds `TitleStyle` plus `.font`, `.fontSize`, `.fontFace`, `.fontColor`, `.alignment`, `.bold`, and `.name` modifiers, and `FramePosition` with an alignment-first `.position(_:inset:)` and an absolute `.position(x:y:)` escape hatch. Positioning emits a sibling `adjust-transform`, which composes over whatever the title preset does internally. The unit is percent of frame HEIGHT on both axes, measured from the centre, Y-up — verified against real Final Cut output (TestData/UntitledXML.fcpxml:448, position="-17.8241 7.77778"), which the tests reproduce exactly rather than asserting a hand-derived number. The frame size rides on `ResourceStore`, which is already threaded through every `build` call, so `DSLNode.build`'s signature is untouched. `Sequence` publishes its format's dimensions before building children and restores the outer value afterwards, so nested sequences do not leak frame sizes. Nothing is emitted unless a modifier is applied: an unstyled, unpositioned title serializes byte-identically to today, `bold` is written only when true, and `fontSize` collapses whole numbers (63, not 63.0). The feature-pair structural diff against a real Final Cut export passes unchanged. Also fills in `AdjustTransform`'s missing DTD attributes — `enabled`, `rotation`, and `anchor` — which were silently dropped. CodingKeys follow the DTD's declaration order, in which `enabled` comes first. Adds post-resolution spine ordering tests: resolution rewrites title elements on the way out, and the diff engine's inventory is order-blind, so ordering needed a guard that runs after resolution rather than only after build. Allows `x` and `y` as identifier names, alongside the existing `id`/`no`. Co-Authored-By: Claude Opus 5 (1M context) --- .swiftlint.yml | 4 + .../FCPKit/Adjustments/AdjustTransform.swift | 27 ++- Sources/FCPKitDSL/BuildError.swift | 2 + Sources/FCPKitDSL/FramePosition.swift | 190 ++++++++++++++++++ Sources/FCPKitDSL/ResourceStore.swift | 7 + Sources/FCPKitDSL/Sequence.swift | 12 ++ Sources/FCPKitDSL/Title+Modifiers.swift | 105 ++++++++++ Sources/FCPKitDSL/Title.swift | 81 +++++--- Sources/FCPKitDSL/TitleStyle.swift | 106 ++++++++++ Tests/FCPKitDSLTests/FramePositionTests.swift | 137 +++++++++++++ .../PositionedOrderingDoc.swift | 48 +++++ .../PositionedSpineOrderingTests.swift | 86 ++++++++ Tests/FCPKitDSLTests/TitleStyleSupport.swift | 115 +++++++++++ .../TitleStyleSupportError.swift | 36 ++++ Tests/FCPKitDSLTests/TitleStyleTests.swift | 116 +++++++++++ 15 files changed, 1044 insertions(+), 28 deletions(-) create mode 100644 Sources/FCPKitDSL/FramePosition.swift create mode 100644 Sources/FCPKitDSL/Title+Modifiers.swift create mode 100644 Sources/FCPKitDSL/TitleStyle.swift create mode 100644 Tests/FCPKitDSLTests/FramePositionTests.swift create mode 100644 Tests/FCPKitDSLTests/PositionedOrderingDoc.swift create mode 100644 Tests/FCPKitDSLTests/PositionedSpineOrderingTests.swift create mode 100644 Tests/FCPKitDSLTests/TitleStyleSupport.swift create mode 100644 Tests/FCPKitDSLTests/TitleStyleSupportError.swift create mode 100644 Tests/FCPKitDSLTests/TitleStyleTests.swift diff --git a/.swiftlint.yml b/.swiftlint.yml index 8bbf4cc..27b73fd 100644 --- a/.swiftlint.yml +++ b/.swiftlint.yml @@ -116,6 +116,10 @@ identifier_name: excluded: - id - no + # Frame coordinate labels: `.position(x:y:)` reads better than any longer + # spelling, and matches how the DTD names the axes. + - x + - y excluded: - DerivedData - .build diff --git a/Sources/FCPKit/Adjustments/AdjustTransform.swift b/Sources/FCPKit/Adjustments/AdjustTransform.swift index ade40f5..6f3d334 100644 --- a/Sources/FCPKit/Adjustments/AdjustTransform.swift +++ b/Sources/FCPKit/Adjustments/AdjustTransform.swift @@ -30,22 +30,43 @@ import Foundation import XMLCoder -/// An `adjust-transform` element controlling a clip's spatial transform (position and scale). +/// An `adjust-transform` element controlling a clip's spatial transform. +/// +/// Covers the DTD's `enabled`, `position`, `scale`, `rotation`, and `anchor` +/// attributes. `CodingKeys` follow DTD declaration order. public struct AdjustTransform: Codable { internal enum CodingKeys: String, CodingKey { + case enabled case position case scale + case rotation + case anchor } + /// Whether the adjustment is active. `"0"` disables it; the DTD default is `"1"`. + public var enabled: String? /// The position offset as an "x y" pair, as a string. public var position: String? /// The scale factor as an "x y" pair, as a string. public var scale: String? + /// The rotation in degrees, as a string. + public var rotation: String? + /// The anchor point as an "x y" pair, as a string. + public var anchor: String? - /// Creates an `adjust-transform` adjustment with an optional position and scale. - public init(position: String? = nil, scale: String? = nil) { + /// Creates an `adjust-transform` adjustment. Omitted values are not encoded. + public init( + enabled: String? = nil, + position: String? = nil, + scale: String? = nil, + rotation: String? = nil, + anchor: String? = nil + ) { + self.enabled = enabled self.position = position self.scale = scale + self.rotation = rotation + self.anchor = anchor } } diff --git a/Sources/FCPKitDSL/BuildError.swift b/Sources/FCPKitDSL/BuildError.swift index 88ba0bc..674a57d 100644 --- a/Sources/FCPKitDSL/BuildError.swift +++ b/Sources/FCPKitDSL/BuildError.swift @@ -41,6 +41,8 @@ public enum BuildError: Error, Equatable, Sendable { case conflictingResourceID(String) /// A format was required but could not be resolved. case missingFormat + /// An absolute frame position was used with no enclosing sequence format. + case missingFrameSize /// A resource identifier string was illegal. case invalidResourceID(String) } diff --git a/Sources/FCPKitDSL/FramePosition.swift b/Sources/FCPKitDSL/FramePosition.swift new file mode 100644 index 0000000..2708a6c --- /dev/null +++ b/Sources/FCPKitDSL/FramePosition.swift @@ -0,0 +1,190 @@ +// +// FramePosition.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 + +/// A position within the video frame. +/// +/// Final Cut's `adjust-transform position` is expressed as a percentage of the +/// frame **height on both axes**, measured from the frame centre, with Y +/// pointing up. Alignment cases resolve without knowing the frame size; +/// absolute pixel coordinates need the enclosing sequence's format. +public struct FramePosition: Equatable, Sendable { + /// The nine standard frame alignments. + public enum Alignment: Equatable, Sendable { + /// The top-leading corner. + case topLeading + /// The top edge, horizontally centered. + case top + /// The top-trailing corner. + case topTrailing + /// The leading edge, vertically centered. + case leading + /// The frame centre. + case center + /// The trailing edge, vertically centered. + case trailing + /// The bottom-leading corner. + case bottomLeading + /// The bottom edge, horizontally centered. + case bottom + /// The bottom-trailing corner. + case bottomTrailing + } + + /// How a position is expressed before resolution. + internal enum Kind: Equatable, Sendable { + case alignment(Alignment, inset: Double) + case absolute(x: Double, y: Double) + } + + internal let kind: Kind + + /// A position at a frame alignment, optionally inset in points. + public static func aligned(_ alignment: Alignment, inset: Double = 0) -> FramePosition { + FramePosition(kind: .alignment(alignment, inset: inset)) + } + + /// A position at absolute pixel coordinates, with the origin at the top left. + public static func absolute(x: Double, y: Double) -> FramePosition { + FramePosition(kind: .absolute(x: x, y: y)) + } +} + +extension FramePosition { + /// Converts absolute pixels (origin top-left) into Final Cut's percent-of-height units. + /// + /// The divisor is the frame **height** on both axes; that is what makes the + /// values in the 16:9 fixtures land correctly. + internal static func percent( + x absoluteX: Double, + y absoluteY: Double, + width: Double, + height: Double + ) -> (x: Double, y: Double) { + ( + x: (absoluteX - width / 2) / height * 100, + y: (height / 2 - absoluteY) / height * 100 + ) + } + + /// Resolves an alignment, returning `nil` for a dead-centre position. + private static func alignmentPercent( + _ alignment: Alignment, + inset: Double, + frameSize: (width: Double, height: Double)? + ) -> (x: Double, y: Double)? { + if alignment == .center, inset == 0 { + return nil + } + + // Vertical extent is exactly ±50% of the height. Horizontal extent depends + // on the aspect ratio, because the unit's divisor is the height on both + // axes; 16:9 is assumed when no format is known. + let aspect = frameSize.map { $0.width / $0.height } ?? (16.0 / 9.0) + let halfWidth = aspect / 2 * 100 + let insetPercent = frameSize.map { inset / $0.height * 100 } ?? 0 + + return ( + x: horizontalPercent(alignment, extent: halfWidth, inset: insetPercent), + y: verticalPercent(alignment, inset: insetPercent) + ) + } + + /// The horizontal component of an alignment, in percent-of-height units. + private static func horizontalPercent( + _ alignment: Alignment, + extent: Double, + inset: Double + ) -> Double { + switch alignment { + case .topLeading, .leading, .bottomLeading: + return -extent + inset + case .topTrailing, .trailing, .bottomTrailing: + return extent - inset + case .top, .center, .bottom: + return 0 + } + } + + /// The vertical component of an alignment, in percent-of-height units. + private static func verticalPercent(_ alignment: Alignment, inset: Double) -> Double { + switch alignment { + case .topLeading, .top, .topTrailing: + return 50 - inset + case .bottomLeading, .bottom, .bottomTrailing: + return -50 + inset + case .leading, .center, .trailing: + return 0 + } + } +} + +extension FramePosition { + /// Resolves this position into an `adjust-transform position` value. + /// + /// - Parameter frameSize: The enclosing sequence's frame size, when known. + /// - Returns: The formatted `"x y"` pair, or `nil` when the position is the + /// frame centre and therefore needs no `adjust-transform` at all. + /// - Throws: ``BuildError/missingFrameSize`` when absolute coordinates were + /// used without an enclosing format. + internal func resolve(frameSize: (width: Double, height: Double)?) throws -> String? { + let point: (x: Double, y: Double) + + switch kind { + case .alignment(let alignment, let inset): + guard let resolved = Self.alignmentPercent(alignment, inset: inset, frameSize: frameSize) + else { + return nil + } + point = resolved + + case .absolute(let absoluteX, let absoluteY): + guard let frameSize else { + throw BuildError.missingFrameSize + } + point = Self.percent( + x: absoluteX, + y: absoluteY, + width: frameSize.width, + height: frameSize.height + ) + } + + return "\(format(point.x)) \(format(point.y))" + } + + /// Formats a component, collapsing whole numbers (`0`, not `0.0`). + fileprivate func format(_ value: Double) -> String { + if value.truncatingRemainder(dividingBy: 1) == 0 { + return String(Int(value)) + } + return String(value) + } +} diff --git a/Sources/FCPKitDSL/ResourceStore.swift b/Sources/FCPKitDSL/ResourceStore.swift index e86e17c..d0f55b6 100644 --- a/Sources/FCPKitDSL/ResourceStore.swift +++ b/Sources/FCPKitDSL/ResourceStore.swift @@ -41,6 +41,13 @@ internal struct ResourceStore { private var nextNumber = 1 private var nextTextStyleNumber = 1 + /// The enclosing sequence's frame size, once a format has been resolved. + /// + /// Ambient build context rather than a resource: story items nested under a + /// ``Sequence`` need the frame size to resolve absolute ``FramePosition`` + /// coordinates, and `ResourceStore` is already threaded through every `build`. + internal var frameSize: (width: Double, height: Double)? + private static func formatFingerprint(_ format: FCPKit.Format) -> String { [ format.name ?? "", diff --git a/Sources/FCPKitDSL/Sequence.swift b/Sources/FCPKitDSL/Sequence.swift index 4847ed1..20d47c2 100644 --- a/Sources/FCPKitDSL/Sequence.swift +++ b/Sources/FCPKitDSL/Sequence.swift @@ -42,6 +42,18 @@ public struct Sequence: DSLNode { internal func build(_ resources: inout ResourceStore) throws -> Built { let formatRef = try format.map { try resources.format($0) } + + // Publish the frame size before building children: story items resolve + // absolute `FramePosition` coordinates against it. + let outerFrameSize = resources.frameSize + if let format, + 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( storyItems(content.contents, resources: &resources), frameDuration: format?.format.frameDuration diff --git a/Sources/FCPKitDSL/Title+Modifiers.swift b/Sources/FCPKitDSL/Title+Modifiers.swift new file mode 100644 index 0000000..3e53640 --- /dev/null +++ b/Sources/FCPKitDSL/Title+Modifiers.swift @@ -0,0 +1,105 @@ +// +// Title+Modifiers.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 Title { + /// Sets the font family, such as `Helvetica`. + public func font(_ name: String) -> Title { + var updated = style + updated.font = name + return replacing(style: updated) + } + + /// Sets the font size in points. + public func fontSize(_ points: Double) -> Title { + var updated = style + updated.fontSize = points + return replacing(style: updated) + } + + /// Sets the font face or weight within the family, such as `Bold`. + public func fontFace(_ face: String) -> Title { + var updated = style + updated.fontFace = face + return replacing(style: updated) + } + + /// Sets the text color. + public func fontColor(_ color: Color) -> Title { + var updated = style + updated.fontColor = color + return replacing(style: updated) + } + + /// Sets the paragraph alignment. + public func alignment(_ alignment: TitleStyle.Alignment) -> Title { + var updated = style + updated.alignment = alignment + return replacing(style: updated) + } + + /// Renders the text bold. + /// + /// Final Cut writes `fontFace` rather than `bold` for most families, so prefer + /// ``fontFace(_:)`` with a face known to exist for the chosen font. + public func bold(_ isBold: Bool = true) -> Title { + var updated = style + updated.bold = isBold + return replacing(style: updated) + } + + /// Replaces the whole text style. + public func style(_ style: TitleStyle) -> Title { + replacing(style: style) + } + + /// Sets the title clip's display name, independent of its text. + public func name(_ name: String) -> Title { + replacing(displayName: name) + } +} + +extension Title { + /// Positions the title at a frame alignment, optionally inset in points. + /// + /// Alignment positions never require a frame size. A centered title with no + /// inset emits no `adjust-transform` at all. + public func position(_ alignment: FramePosition.Alignment, inset: Double = 0) -> Title { + replacing(position: .aligned(alignment, inset: inset)) + } + + /// Positions the title at absolute pixel coordinates, with the origin top-left. + /// + /// - Throws: At `export()`, ``BuildError/missingFrameSize`` when the title has + /// no enclosing ``Sequence`` format to resolve against. + public func position(x: Double, y: Double) -> Title { + replacing(position: .absolute(x: x, y: y)) + } +} diff --git a/Sources/FCPKitDSL/Title.swift b/Sources/FCPKitDSL/Title.swift index 70cb522..009efa0 100644 --- a/Sources/FCPKitDSL/Title.swift +++ b/Sources/FCPKitDSL/Title.swift @@ -37,6 +37,9 @@ public struct Title: DSLNode { public let duration: FCPTime internal let lane: Int? internal let offset: FCPTime? + internal let style: TitleStyle + internal let position: FramePosition? + internal let displayName: String? /// Creates a Basic Title from text and optional duration. public init(_ text: String, duration: FCPTime? = nil) { @@ -48,46 +51,74 @@ public struct Title: DSLNode { self.init(preset: preset, text: text, duration: duration ?? .zero, lane: nil, offset: nil) } - private init(preset: TitlePreset, text: String, duration: FCPTime, lane: Int?, offset: FCPTime?) { + internal init( + preset: TitlePreset, + text: String, + duration: FCPTime, + lane: Int?, + offset: FCPTime?, + style: TitleStyle = .default, + position: FramePosition? = nil, + displayName: String? = nil + ) { self.preset = preset self.text = text self.duration = duration self.lane = lane self.offset = offset + self.style = style + self.position = position + self.displayName = displayName } /// Sets the title clip duration. public func duration(_ duration: FCPTime) -> Title { - Title(preset: preset, text: text, duration: duration, lane: lane, offset: offset) + replacing(duration: duration) + } + + /// Returns a copy of this title with the given fields replaced. + internal func replacing( + duration: FCPTime? = nil, + style: TitleStyle? = nil, + position: FramePosition? = nil, + displayName: String? = nil + ) -> Title { + Title( + preset: preset, + text: text, + duration: duration ?? self.duration, + lane: lane, + offset: offset, + style: style ?? self.style, + position: position ?? self.position, + displayName: displayName ?? self.displayName + ) } internal func build(_ resources: inout ResourceStore) throws -> Built { let ref = try resources.effect(name: preset.name, uid: preset.uid) let styleID = resources.textStyleID() let style = FCPKit.TextStyle(ref: styleID, content: text) - let definition = FCPKit.TextStyleDef( - id: styleID, - textStyle: FCPKit.TextStyle( - font: "Helvetica", - fontSize: "63", - fontFace: "Regular", - fontColor: "1 1 1 1", - alignment: "center" - ) - ) - return .item( - .title( - FCPKit.Title( - ref: ref, - name: preset.name, - duration: duration.description, - start: "3600s", - lane: lane.map(String.init), - offset: offset?.description ?? "0s", - text: [FCPKit.TextElement(textStyle: [style])], - textStyleDef: [definition] - ) - ) + let definition = FCPKit.TextStyleDef(id: styleID, textStyle: self.style.textStyle()) + + // No adjust-transform unless a position was requested, so unpositioned + // titles stay byte-identical to real Final Cut output. + let transform = + try position + .flatMap { try $0.resolve(frameSize: resources.frameSize) } + .map { FCPKit.AdjustTransform(position: $0) } + + var element = FCPKit.Title( + ref: ref, + name: displayName ?? preset.name, + duration: duration.description, + start: "3600s", + lane: lane.map(String.init), + offset: offset?.description ?? "0s", + text: [FCPKit.TextElement(textStyle: [style])], + textStyleDef: [definition] ) + element.adjustTransform = transform + return .item(.title(element)) } } diff --git a/Sources/FCPKitDSL/TitleStyle.swift b/Sources/FCPKitDSL/TitleStyle.swift new file mode 100644 index 0000000..f4c0bb7 --- /dev/null +++ b/Sources/FCPKitDSL/TitleStyle.swift @@ -0,0 +1,106 @@ +// +// TitleStyle.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 + +/// Text styling applied to a ``Title``. +/// +/// The defaults reproduce Final Cut Pro's Basic Title styling exactly, so an +/// unstyled title serializes byte-identically to a real Final Cut export. +public struct TitleStyle: Equatable, Sendable { + /// Paragraph alignment within the title's text box. + public enum Alignment: String, Equatable, Sendable { + /// Align text to the leading edge. + case left + /// Center text horizontally. + case center + /// Align text to the trailing edge. + case right + /// Stretch text to fill the line. + case justified + } + + /// Final Cut Pro's Basic Title default styling. + public static let `default` = TitleStyle() + + /// The font family, such as `Helvetica`. + public var font: String + /// The font size in points. + public var fontSize: Double + /// The font face or weight within the family, such as `Regular` or `Bold`. + public var fontFace: String + /// The text color. + public var fontColor: Color + /// The paragraph alignment. + public var alignment: Alignment + /// Whether the text renders bold. + /// + /// Emitted only when `true`: real Final Cut output never writes the attribute, + /// so leaving it off keeps default output identical to the fixtures. + public var bold: Bool + + /// Creates a title style, defaulting to Final Cut's Basic Title styling. + public init( + font: String = "Helvetica", + fontSize: Double = 63, + fontFace: String = "Regular", + fontColor: Color = .white, + alignment: Alignment = .center, + bold: Bool = false + ) { + self.font = font + self.fontSize = fontSize + self.fontFace = fontFace + self.fontColor = fontColor + self.alignment = alignment + self.bold = bold + } +} + +extension TitleStyle { + /// The `fontSize` attribute value, collapsing whole numbers (`63`, not `63.0`). + internal var fontSizeString: String { + if fontSize.truncatingRemainder(dividingBy: 1) == 0 { + return String(Int(fontSize)) + } + return String(fontSize) + } + + /// Lowers this style into the model's `text-style` element. + internal func textStyle() -> FCPKit.TextStyle { + FCPKit.TextStyle( + font: font, + fontSize: fontSizeString, + fontFace: fontFace, + fontColor: fontColor.description, + bold: bold ? "1" : nil, + alignment: alignment.rawValue + ) + } +} diff --git a/Tests/FCPKitDSLTests/FramePositionTests.swift b/Tests/FCPKitDSLTests/FramePositionTests.swift new file mode 100644 index 0000000..31093b8 --- /dev/null +++ b/Tests/FCPKitDSLTests/FramePositionTests.swift @@ -0,0 +1,137 @@ +// +// FramePositionTests.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 Foundation +import Testing + +@Suite +internal struct FramePositionTests { + @Test + internal func unpositionedTitleEmitsNoAdjustTransform() throws { + // Regression guard: existing output must stay byte-identical. + let built = try TitleStyleSupport.firstTitle(Title("Hello", duration: .seconds(5))) + #expect(built.adjustTransform == nil) + } + + @Test + internal func frameCentreEmitsNoAdjustTransform() throws { + let built = try TitleStyleSupport.firstTitle( + Title("Hello", duration: .seconds(5)).position(.center) + ) + #expect(built.adjustTransform == nil) + } + + @Test + internal func absoluteCentreResolvesToZero() throws { + let position = try TitleStyleSupport.transformPosition( + Title("Hello", duration: .seconds(5)).position(x: 960, y: 540) + ) + #expect(position == "0 0") + } + + @Test + internal func absoluteCoordinatesMatchTheFixtureFormula() throws { + // Real Final Cut output on a 1920x1080 sequence contains + // position="-17.8241 7.77778" (TestData/UntitledXML.fcpxml:448). The unit is + // percent of frame HEIGHT on both axes, from the centre, Y-up. So Y=7.77778 + // is 84px ABOVE centre, i.e. an absolute y of 540 - 84 = 456, and + // X=-17.8241 is 192.5px left of centre, i.e. an absolute x of 767.5. + let position = try #require( + try TitleStyleSupport.transformPosition( + Title("Hello", duration: .seconds(5)).position(x: 960 - 192.5, y: 540 - 84) + ) + ) + + let parts = position.split(separator: " ") + #expect(parts.count == 2) + let xComponent = try #require(Double(parts[0])) + let yComponent = try #require(Double(parts[1])) + + #expect(abs(yComponent - 7.77778) < 0.001) + #expect(abs(xComponent - (-17.8241)) < 0.001) + + // The divisor is the height on both axes, not the width. + #expect(abs(yComponent - 84.0 / 1_080.0 * 100.0) < 0.001) + #expect(abs(xComponent - (-192.5 / 1_080.0 * 100.0)) < 0.001) + } + + @Test + internal func topAndBottomAreSymmetric() throws { + let top = try #require( + try TitleStyleSupport.transformPosition( + Title("Hello", duration: .seconds(5)).position(.top) + ) + ) + let bottom = try #require( + try TitleStyleSupport.transformPosition( + Title("Hello", duration: .seconds(5)).position(.bottom) + ) + ) + #expect(top == "0 50") + #expect(bottom == "0 -50") + } + + @Test + internal func absolutePositionWithoutFormatThrows() throws { + let document = TitleStyleSupport.TitleDoc( + Title("Hello", duration: .seconds(5)).position(x: 100, y: 100), + format: nil + ) + #expect(throws: BuildError.missingFrameSize) { + _ = try document.export() + } + } + + @Test + internal func alignmentPositionWithoutFormatDoesNotThrow() throws { + let document = TitleStyleSupport.TitleDoc( + Title("Hello", duration: .seconds(5)).position(.topLeading), + format: nil + ) + #expect(throws: Never.self) { + _ = try document.export() + } + } + + @Test + internal func adjustTransformRoundTripsNewAttributes() throws { + let transform = FCPKit.AdjustTransform( + enabled: "1", + position: "10 20", + scale: "2 2", + rotation: "45", + anchor: "0 0" + ) + #expect(transform.rotation == "45") + #expect(transform.anchor == "0 0") + #expect(transform.enabled == "1") + } +} diff --git a/Tests/FCPKitDSLTests/PositionedOrderingDoc.swift b/Tests/FCPKitDSLTests/PositionedOrderingDoc.swift new file mode 100644 index 0000000..9b47d83 --- /dev/null +++ b/Tests/FCPKitDSLTests/PositionedOrderingDoc.swift @@ -0,0 +1,48 @@ +// +// PositionedOrderingDoc.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 Foundation + +/// A clip / transition / clip spine where the clips carry deferred positions. +/// +/// The v0.1.0 Step 3 ordering guarantee is that `Spine.items` keeps DTD order. +/// Position resolution rewrites title elements on the way out, so these tests +/// assert order survives *after* resolution — the schema-completeness inventory +/// is order-blind and would not catch a reordering here. +internal struct PositionedOrderingDoc: Document { + internal var body: some DocumentContent { + Sequence(format: .p1080p24) { + Title("First", duration: .seconds(5)).position(.topLeading) + Transition(.crossDissolve) + Title("Second", duration: .seconds(5)).position(x: 200, y: 300) + } + } +} diff --git a/Tests/FCPKitDSLTests/PositionedSpineOrderingTests.swift b/Tests/FCPKitDSLTests/PositionedSpineOrderingTests.swift new file mode 100644 index 0000000..bf4a78f --- /dev/null +++ b/Tests/FCPKitDSLTests/PositionedSpineOrderingTests.swift @@ -0,0 +1,86 @@ +// +// PositionedSpineOrderingTests.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 PositionedSpineOrderingTests { + 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 spineOrderSurvivesPositionResolution() throws { + let exported = try PositionedOrderingDoc().export() + let sequence = try #require(exported.library?.events?.first?.projects?.first?.sequence) + let items = try #require(sequence.spine?.items) + + #expect(items.count == 3) + guard case .title(let first) = items[0] else { + Issue.record("Expected item 0 to be .title") + return + } + guard case .transition = items[1] else { + Issue.record("Expected item 1 to be .transition") + return + } + guard case .title(let third) = items[2] else { + Issue.record("Expected item 2 to be .title") + return + } + + // Both titles resolved a position, and they stayed in authored order. + #expect(first.adjustTransform?.position != nil) + #expect(third.adjustTransform?.position != nil) + #expect(first.text?.first?.textStyle?.first?.content == "First") + #expect(third.text?.first?.textStyle?.first?.content == "Second") + } + + @Test + internal func serializedChildOrderSurvivesPositionResolution() throws { + let exported = try PositionedOrderingDoc().export() + let encoded = try FCPXMLParser().encode(exported) + let root = try XMLTreeParser().parse(encoded) + + let spine = try #require(Self.firstNode(named: "spine", in: root)) + #expect(spine.children.map(\.name) == ["title", "transition", "title"]) + } +} diff --git a/Tests/FCPKitDSLTests/TitleStyleSupport.swift b/Tests/FCPKitDSLTests/TitleStyleSupport.swift new file mode 100644 index 0000000..e292555 --- /dev/null +++ b/Tests/FCPKitDSLTests/TitleStyleSupport.swift @@ -0,0 +1,115 @@ +// +// TitleStyleSupport.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 + +/// Shared fixtures and assertions for the title styling and positioning suites. +internal enum TitleStyleSupport { + /// Wraps a single title in a sequence of the given format. + internal struct TitleDoc: Document { + internal let title: FCPKitDSL.Title + internal let format: FormatPreset? + + internal var body: some DocumentContent { + Sequence(format: format) { + title + } + } + + internal init(_ title: FCPKitDSL.Title, format: FormatPreset? = .p1080p24) { + self.title = title + self.format = format + } + } + + /// Two styled titles, used to prove a styled multi-title document validates. + internal struct StyledPair: Document { + internal var body: some DocumentContent { + Sequence(format: .p1080p24) { + Title("Heading", duration: .seconds(5)) + .font("Helvetica") + .fontSize(96) + .fontColor(.white) + .position(.top, inset: 80) + Title("Body", duration: .seconds(5)) + .fontSize(48) + .alignment(.left) + .position(.bottomLeading, inset: 40) + } + } + } + + /// Exports a document holding `title` and returns the built title element. + internal static func firstTitle( + _ title: FCPKitDSL.Title, + format: FormatPreset? = .p1080p24 + ) throws -> FCPKit.Title { + let exported = try TitleDoc(title, format: format).export() + let sequence = try #require(exported.library?.events?.first?.projects?.first?.sequence) + let items = try #require(sequence.spine?.items) + guard case .title(let built) = items[0] else { + throw TitleStyleSupportError.notATitle + } + return built + } + + /// Returns the `text-style` inside the title's `text-style-def`. + internal static func definitionStyle( + _ title: FCPKitDSL.Title, + format: FormatPreset? = .p1080p24 + ) throws -> FCPKit.TextStyle { + let built = try firstTitle(title, format: format) + return try #require(built.textStyleDef?.first?.textStyle) + } + + /// Returns the resolved `adjust-transform position`, or `nil` when absent. + internal static func transformPosition( + _ title: FCPKitDSL.Title, + format: FormatPreset? = .p1080p24 + ) throws -> String? { + try firstTitle(title, format: format).adjustTransform?.position + } + + /// Validates against Final Cut's DTD, soft-skipping when tooling is absent. + internal static 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") + } + } + } +} diff --git a/Tests/FCPKitDSLTests/TitleStyleSupportError.swift b/Tests/FCPKitDSLTests/TitleStyleSupportError.swift new file mode 100644 index 0000000..d8176ed --- /dev/null +++ b/Tests/FCPKitDSLTests/TitleStyleSupportError.swift @@ -0,0 +1,36 @@ +// +// TitleStyleSupportError.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 + +/// Failures raised by the title styling test helpers. +internal enum TitleStyleSupportError: Error { + /// The first spine item was not a title. + case notATitle +} diff --git a/Tests/FCPKitDSLTests/TitleStyleTests.swift b/Tests/FCPKitDSLTests/TitleStyleTests.swift new file mode 100644 index 0000000..3876559 --- /dev/null +++ b/Tests/FCPKitDSLTests/TitleStyleTests.swift @@ -0,0 +1,116 @@ +// +// TitleStyleTests.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 Foundation +import Testing + +@Suite +internal struct TitleStyleTests { + @Test + internal func unstyledTitleMatchesFinalCutDefaults() throws { + // Fixture parity guard: these are exactly the attributes real Final Cut + // writes (Tests/FCPKitTests/FeaturePairs/titles/after.fcpxml). + let style = try TitleStyleSupport.definitionStyle(Title("Hello", duration: .seconds(5))) + #expect(style.font == "Helvetica") + #expect(style.fontSize == "63") + #expect(style.fontFace == "Regular") + #expect(style.fontColor == "1 1 1 1") + #expect(style.alignment == "center") + #expect(style.bold == nil) + } + + @Test + internal func modifiersApplyToTextStyle() throws { + let title = Title("Hello", duration: .seconds(5)) + .font("Avenir Next") + .fontSize(96) + .fontColor(.red) + .alignment(.left) + + let style = try TitleStyleSupport.definitionStyle(title) + #expect(style.font == "Avenir Next") + #expect(style.fontSize == "96") + #expect(style.fontColor == "1 0 0 1") + #expect(style.alignment == "left") + } + + @Test + internal func boldIsEmittedOnlyWhenSet() throws { + let plain = try TitleStyleSupport.definitionStyle(Title("Hello", duration: .seconds(5))) + #expect(plain.bold == nil) + + let bolded = try TitleStyleSupport.definitionStyle( + Title("Hello", duration: .seconds(5)).bold() + ) + #expect(bolded.bold == "1") + } + + @Test + internal func fontSizeCollapsesWholeNumbers() throws { + let whole = try TitleStyleSupport.definitionStyle( + Title("Hello", duration: .seconds(5)).fontSize(63) + ) + #expect(whole.fontSize == "63") + + let fractional = try TitleStyleSupport.definitionStyle( + Title("Hello", duration: .seconds(5)).fontSize(63.5) + ) + #expect(fractional.fontSize == "63.5") + } + + @Test + internal func fontFaceIsSettable() throws { + let style = try TitleStyleSupport.definitionStyle( + Title("Hello", duration: .seconds(5)).fontFace("Bold") + ) + #expect(style.fontFace == "Bold") + } + + @Test + internal func nameOverridesDisplayNameWithoutChangingText() throws { + let title = try TitleStyleSupport.firstTitle( + Title("Body copy", duration: .seconds(5)).name("Slide Heading") + ) + #expect(title.name == "Slide Heading") + + let run = try #require(title.text?.first?.textStyle?.first) + #expect(run.content == "Body copy") + } + + @Test + internal func styledMultiTitleDocumentValidatesAgainstDTD() throws { + // Exported at 1.14: at the 1.13 default the document is invalid for the + // unrelated `match-analysis-type` reason (#41), which would mask this. + let exported = try TitleStyleSupport.StyledPair().export(version: FCPXMLVersion("1.14")) + let encoded = try FCPXMLParser().encode(exported) + try TitleStyleSupport.assertDTDValidates(encoded) + } +} From 17b19244eac6614638063b56dffc4433a3f99148 Mon Sep 17 00:00:00 2001 From: Leo Dion Date: Mon, 3 Aug 2026 13:18:45 -0400 Subject: [PATCH 3/6] Fix a crash and two silent-wrong-output bugs in title positioning MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Follow-up from an adversarial review of c6b74c5. All three share a root cause: unvalidated Double -> String conversion, and an Optional treated as "use a default" rather than "cannot resolve". 1. CRASH. `fontSize` and the position formatter both did an unguarded `Int(value)` after a whole-number check. `1e21` is a whole number, so `Title("Hi").fontSize(1e21)` trapped with "Double value cannot be converted to Int" and took the host process down (signal 5). Infinity and NaN hit the same path. Replaced both with a shared `decimalString(_:)` that falls back to the plain Double description outside Int's range or when non-finite. A library must not crash on user input. 2. `.position(.top, inset: 80)` with no enclosing format silently DROPPED the inset and emitted "0 50". An inset is in points, and converting points to Final Cut's percent-of-height unit requires the frame height. The absolute `.position(x:y:)` path already threw `missingFrameSize` in exactly this situation, so the design already agreed it is unresolvable — the alignment path just failed silently instead. It now throws too. A zero inset still never throws, since plain alignments need no frame size. 3. `.position(.center, inset: 100)` emitted a pointless `adjust-transform` with "0 0", breaking the "centred titles emit nothing" invariant while ignoring the inset. `.center` is the frame centre on both axes, so an inset has no direction to move along; it now always resolves to no transform. Also corrects a misleading test comment: the fixture whose position value the formula test reproduces uses FFVideoFormat3840x2160p24, not 1080p. The math is scale-invariant so the test passed for the right reason, but the stated provenance would have misled the next person deriving from it. Co-Authored-By: Claude Opus 5 (1M context) --- Sources/FCPKitDSL/DecimalString.swift | 48 +++++++++++++ Sources/FCPKitDSL/FramePosition.swift | 25 ++++--- Sources/FCPKitDSL/TitleStyle.swift | 5 +- Tests/FCPKitDSLTests/FramePositionTests.swift | 69 +++++++++++++++++-- 4 files changed, 130 insertions(+), 17 deletions(-) create mode 100644 Sources/FCPKitDSL/DecimalString.swift diff --git a/Sources/FCPKitDSL/DecimalString.swift b/Sources/FCPKitDSL/DecimalString.swift new file mode 100644 index 0000000..8f787f8 --- /dev/null +++ b/Sources/FCPKitDSL/DecimalString.swift @@ -0,0 +1,48 @@ +// +// DecimalString.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 + +/// Formats a `Double` for an FCPXML attribute, collapsing whole numbers. +/// +/// Final Cut writes `63` rather than `63.0`, so whole values lose their fractional +/// part. Values outside `Int`'s range, and non-finite values, fall back to the +/// plain `Double` description: converting them with `Int(_:)` would trap and take +/// the host process down with it. +internal func decimalString(_ value: Double) -> String { + guard value.isFinite else { + return String(value) + } + guard value.truncatingRemainder(dividingBy: 1) == 0, + value >= Double(Int.min), value <= Double(Int.max) + else { + return String(value) + } + return String(Int(value)) +} diff --git a/Sources/FCPKitDSL/FramePosition.swift b/Sources/FCPKitDSL/FramePosition.swift index 2708a6c..c852189 100644 --- a/Sources/FCPKitDSL/FramePosition.swift +++ b/Sources/FCPKitDSL/FramePosition.swift @@ -94,16 +94,27 @@ extension FramePosition { ) } - /// Resolves an alignment, returning `nil` for a dead-centre position. + /// Resolves an alignment, returning `nil` when it needs no transform. + /// + /// - Throws: ``BuildError/missingFrameSize`` when a non-zero inset is requested + /// without a frame size. An inset is in points, and converting points to + /// Final Cut's percent-of-height unit requires the frame height — silently + /// dropping it would emit a position the caller did not ask for. private static func alignmentPercent( _ alignment: Alignment, inset: Double, frameSize: (width: Double, height: Double)? - ) -> (x: Double, y: Double)? { - if alignment == .center, inset == 0 { + ) throws -> (x: Double, y: Double)? { + // `.center` is the frame centre on both axes, so an inset has no direction + // to move along and the position needs no `adjust-transform` at all. + if alignment == .center { return nil } + guard inset == 0 || frameSize != nil else { + throw BuildError.missingFrameSize + } + // Vertical extent is exactly ±50% of the height. Horizontal extent depends // on the aspect ratio, because the unit's divisor is the height on both // axes; 16:9 is assumed when no format is known. @@ -159,7 +170,8 @@ extension FramePosition { switch kind { case .alignment(let alignment, let inset): - guard let resolved = Self.alignmentPercent(alignment, inset: inset, frameSize: frameSize) + guard + let resolved = try Self.alignmentPercent(alignment, inset: inset, frameSize: frameSize) else { return nil } @@ -182,9 +194,6 @@ extension FramePosition { /// Formats a component, collapsing whole numbers (`0`, not `0.0`). fileprivate func format(_ value: Double) -> String { - if value.truncatingRemainder(dividingBy: 1) == 0 { - return String(Int(value)) - } - return String(value) + decimalString(value) } } diff --git a/Sources/FCPKitDSL/TitleStyle.swift b/Sources/FCPKitDSL/TitleStyle.swift index f4c0bb7..1ea3947 100644 --- a/Sources/FCPKitDSL/TitleStyle.swift +++ b/Sources/FCPKitDSL/TitleStyle.swift @@ -86,10 +86,7 @@ public struct TitleStyle: Equatable, Sendable { extension TitleStyle { /// The `fontSize` attribute value, collapsing whole numbers (`63`, not `63.0`). internal var fontSizeString: String { - if fontSize.truncatingRemainder(dividingBy: 1) == 0 { - return String(Int(fontSize)) - } - return String(fontSize) + decimalString(fontSize) } /// Lowers this style into the model's `text-style` element. diff --git a/Tests/FCPKitDSLTests/FramePositionTests.swift b/Tests/FCPKitDSLTests/FramePositionTests.swift index 31093b8..26c45e8 100644 --- a/Tests/FCPKitDSLTests/FramePositionTests.swift +++ b/Tests/FCPKitDSLTests/FramePositionTests.swift @@ -59,11 +59,16 @@ internal struct FramePositionTests { @Test internal func absoluteCoordinatesMatchTheFixtureFormula() throws { - // Real Final Cut output on a 1920x1080 sequence contains - // position="-17.8241 7.77778" (TestData/UntitledXML.fcpxml:448). The unit is - // percent of frame HEIGHT on both axes, from the centre, Y-up. So Y=7.77778 - // is 84px ABOVE centre, i.e. an absolute y of 540 - 84 = 456, and - // X=-17.8241 is 192.5px left of centre, i.e. an absolute x of 767.5. + // Real Final Cut output contains position="-17.8241 7.77778" + // (TestData/UntitledXML.fcpxml:448). That fixture's sequence uses format r2, + // FFVideoFormat3840x2160p24 — 3840x2160, not 1080p. + // + // The unit is percent of frame HEIGHT on both axes, from the centre, Y-up, + // which makes it scale-invariant: the same percentages describe the same + // relative position at any resolution. So the fixture's values reproduce on + // a 1080p sequence at the proportionally equivalent pixels — Y=7.77778 is + // 0.0777778 * 1080 = 84px above centre (absolute y 456), and X=-17.8241 is + // 192.5px left of centre (absolute x 767.5). let position = try #require( try TitleStyleSupport.transformPosition( Title("Hello", duration: .seconds(5)).position(x: 960 - 192.5, y: 540 - 84) @@ -134,4 +139,58 @@ internal struct FramePositionTests { #expect(transform.anchor == "0 0") #expect(transform.enabled == "1") } + + @Test + internal func hugeFontSizeDoesNotTrap() throws { + // Whole-but-huge doubles took an unguarded Int(_:) conversion, which traps + // and takes the host process down. A library must not crash on user input. + let huge = Double("1e21") ?? 0 + let style = try TitleStyleSupport.definitionStyle( + Title("Hello", duration: .seconds(5)).fontSize(huge) + ) + #expect(style.fontSize != nil) + } + + @Test + internal func nonFiniteFontSizeDoesNotTrap() throws { + let style = try TitleStyleSupport.definitionStyle( + Title("Hello", duration: .seconds(5)).fontSize(.infinity) + ) + #expect(style.fontSize != nil) + } + + @Test + internal func centerIgnoresInsetAndEmitsNoTransform() throws { + // `.center` is the frame centre on both axes, so an inset has no direction + // to move along. It must still emit nothing rather than a no-op transform. + let built = try TitleStyleSupport.firstTitle( + Title("Hello", duration: .seconds(5)).position(.center, inset: 100) + ) + #expect(built.adjustTransform == nil) + } + + @Test + internal func insetWithoutFormatThrowsRatherThanSilentlyDropping() throws { + // An inset is in points; converting to percent-of-height needs the frame + // height. Silently dropping it would emit a position never asked for. + let document = TitleStyleSupport.TitleDoc( + Title("Hello", duration: .seconds(5)).position(.top, inset: 80), + format: nil + ) + #expect(throws: BuildError.missingFrameSize) { + _ = try document.export() + } + } + + @Test + internal func zeroInsetAlignmentWithoutFormatStillResolves() throws { + // Only a non-zero inset needs the frame size; plain alignments never throw. + let document = TitleStyleSupport.TitleDoc( + Title("Hello", duration: .seconds(5)).position(.top), + format: nil + ) + #expect(throws: Never.self) { + _ = try document.export() + } + } } From aec1ed6cdc33e3a864204e691284d7d98f8c1fc4 Mon Sep 17 00:00:00 2001 From: Leo Dion Date: Mon, 3 Aug 2026 13:48:55 -0400 Subject: [PATCH 4/6] Namespace the decimal formatter; document why not Decimal.FormatStyle MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses review feedback on #44. "Make it a rule never to have global functions": `decimalString` becomes `AttributeValue.decimal(_:)`, and the file is renamed to match. The trivial `FramePosition.format` wrapper is inlined rather than left as an alias. "Is there something here to use Decimal.FormatStyle?" — I tested it, and no. Three reasons, all verified rather than assumed: - It is locale-aware. `63.5` renders as "63,5" under de_DE and fr_FR, which would emit invalid FCPXML on any non-English machine. This is the decisive one, and it is now pinned by a test so nobody swaps a locale-aware formatter back in. - It rounds to six fractional digits, so a position component like 7.777777777 would be silently truncated. - `Decimal(Double.infinity)` traps, so it does not even solve the crash that motivated this helper. It does collapse whole numbers natively, which is the one thing it would have bought us — not worth the three costs above. Co-Authored-By: Claude Opus 5 (1M context) --- ...cimalString.swift => AttributeValue.swift} | 40 +++++++++++-------- Sources/FCPKitDSL/FramePosition.swift | 7 +--- Sources/FCPKitDSL/TitleStyle.swift | 2 +- Tests/FCPKitDSLTests/FramePositionTests.swift | 19 +++++++++ 4 files changed, 45 insertions(+), 23 deletions(-) rename Sources/FCPKitDSL/{DecimalString.swift => AttributeValue.swift} (51%) diff --git a/Sources/FCPKitDSL/DecimalString.swift b/Sources/FCPKitDSL/AttributeValue.swift similarity index 51% rename from Sources/FCPKitDSL/DecimalString.swift rename to Sources/FCPKitDSL/AttributeValue.swift index 8f787f8..a5e1747 100644 --- a/Sources/FCPKitDSL/DecimalString.swift +++ b/Sources/FCPKitDSL/AttributeValue.swift @@ -1,5 +1,5 @@ // -// DecimalString.swift +// AttributeValue.swift // FCPKit // // Created by Leo Dion. @@ -29,20 +29,28 @@ import Foundation -/// Formats a `Double` for an FCPXML attribute, collapsing whole numbers. -/// -/// Final Cut writes `63` rather than `63.0`, so whole values lose their fractional -/// part. Values outside `Int`'s range, and non-finite values, fall back to the -/// plain `Double` description: converting them with `Int(_:)` would trap and take -/// the host process down with it. -internal func decimalString(_ value: Double) -> String { - guard value.isFinite else { - return String(value) +/// Renders Swift values as FCPXML attribute strings. +internal enum AttributeValue { + /// Formats a `Double` for an FCPXML attribute, collapsing whole numbers. + /// + /// Final Cut writes `63` rather than `63.0`, so whole values lose their + /// fractional part. Values outside `Int`'s range, and non-finite values, fall + /// back to the plain `Double` description: converting them with `Int(_:)` + /// would trap and take the host process down with it. + /// + /// `Decimal.FormatStyle` is deliberately not used here. It is locale-aware, so + /// `63.5` renders as `63,5` under a German or French locale, which is invalid + /// FCPXML; it rounds to six fractional digits, which would corrupt position + /// values like `7.777777777`; and `Decimal(Double.infinity)` traps outright. + internal static func decimal(_ value: Double) -> String { + guard value.isFinite else { + return String(value) + } + guard value.truncatingRemainder(dividingBy: 1) == 0, + value >= Double(Int.min), value <= Double(Int.max) + else { + return String(value) + } + return String(Int(value)) } - guard value.truncatingRemainder(dividingBy: 1) == 0, - value >= Double(Int.min), value <= Double(Int.max) - else { - return String(value) - } - return String(Int(value)) } diff --git a/Sources/FCPKitDSL/FramePosition.swift b/Sources/FCPKitDSL/FramePosition.swift index c852189..eaaaaf1 100644 --- a/Sources/FCPKitDSL/FramePosition.swift +++ b/Sources/FCPKitDSL/FramePosition.swift @@ -189,11 +189,6 @@ extension FramePosition { ) } - return "\(format(point.x)) \(format(point.y))" - } - - /// Formats a component, collapsing whole numbers (`0`, not `0.0`). - fileprivate func format(_ value: Double) -> String { - decimalString(value) + return "\(AttributeValue.decimal(point.x)) \(AttributeValue.decimal(point.y))" } } diff --git a/Sources/FCPKitDSL/TitleStyle.swift b/Sources/FCPKitDSL/TitleStyle.swift index 1ea3947..0d1fdb9 100644 --- a/Sources/FCPKitDSL/TitleStyle.swift +++ b/Sources/FCPKitDSL/TitleStyle.swift @@ -86,7 +86,7 @@ public struct TitleStyle: Equatable, Sendable { extension TitleStyle { /// The `fontSize` attribute value, collapsing whole numbers (`63`, not `63.0`). internal var fontSizeString: String { - decimalString(fontSize) + AttributeValue.decimal(fontSize) } /// Lowers this style into the model's `text-style` element. diff --git a/Tests/FCPKitDSLTests/FramePositionTests.swift b/Tests/FCPKitDSLTests/FramePositionTests.swift index 26c45e8..67af449 100644 --- a/Tests/FCPKitDSLTests/FramePositionTests.swift +++ b/Tests/FCPKitDSLTests/FramePositionTests.swift @@ -193,4 +193,23 @@ internal struct FramePositionTests { _ = try document.export() } } + + @Test + internal func decimalStringsUseAPeriodRegardlessOfLocale() throws { + // FCPXML attribute values are machine-readable and must always use a period + // as the decimal separator. `Decimal.FormatStyle` would emit "63,5" under a + // German or French locale, producing invalid FCPXML — this pins the + // locale-independent formatting so nobody swaps one in. + let style = try TitleStyleSupport.definitionStyle( + Title("Hello", duration: .seconds(5)).fontSize(63.5) + ) + #expect(style.fontSize == "63.5") + + let position = try #require( + try TitleStyleSupport.transformPosition( + Title("Hello", duration: .seconds(5)).position(x: 0, y: 0) + ) + ) + #expect(!position.contains(",")) + } } From 517a1c25845ecb9b2281f27d1b7ba317f065f85a Mon Sep 17 00:00:00 2001 From: Leo Dion Date: Mon, 3 Aug 2026 15:23:06 -0400 Subject: [PATCH 5/6] Express the FCPXML decimal formatter as String.init(fcpxmlValue:) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses review feedback on #44: make it an extension on Double or an init extension on String rather than a namespaced static. Went with `String.init(fcpxmlValue:)`. Both call sites produce a String from a Double, which is exactly what an init is for, and it keeps the conversion with the type being produced instead of hanging a domain-specific property off `Double`. The argument label matters: a bare `String(_: Double)` already exists via `LosslessStringConvertible`, so an unlabelled init would quietly overload it. Verified `String(63.5)` still routes to the stdlib and returns "63.5" while `String(fcpxmlValue:)` applies the whole-number collapse. Behaviour is unchanged — same guards, same fallbacks, same tests. Co-Authored-By: Claude Opus 5 (1M context) --- Sources/FCPKitDSL/FramePosition.swift | 2 +- ...teValue.swift => String+FCPXMLValue.swift} | 22 +++++++++---------- Sources/FCPKitDSL/TitleStyle.swift | 2 +- 3 files changed, 13 insertions(+), 13 deletions(-) rename Sources/FCPKitDSL/{AttributeValue.swift => String+FCPXMLValue.swift} (81%) diff --git a/Sources/FCPKitDSL/FramePosition.swift b/Sources/FCPKitDSL/FramePosition.swift index eaaaaf1..dfc4946 100644 --- a/Sources/FCPKitDSL/FramePosition.swift +++ b/Sources/FCPKitDSL/FramePosition.swift @@ -189,6 +189,6 @@ extension FramePosition { ) } - return "\(AttributeValue.decimal(point.x)) \(AttributeValue.decimal(point.y))" + return "\(String(fcpxmlValue: point.x)) \(String(fcpxmlValue: point.y))" } } diff --git a/Sources/FCPKitDSL/AttributeValue.swift b/Sources/FCPKitDSL/String+FCPXMLValue.swift similarity index 81% rename from Sources/FCPKitDSL/AttributeValue.swift rename to Sources/FCPKitDSL/String+FCPXMLValue.swift index a5e1747..510d055 100644 --- a/Sources/FCPKitDSL/AttributeValue.swift +++ b/Sources/FCPKitDSL/String+FCPXMLValue.swift @@ -1,5 +1,5 @@ // -// AttributeValue.swift +// String+FCPXMLValue.swift // FCPKit // // Created by Leo Dion. @@ -29,9 +29,8 @@ import Foundation -/// Renders Swift values as FCPXML attribute strings. -internal enum AttributeValue { - /// Formats a `Double` for an FCPXML attribute, collapsing whole numbers. +extension String { + /// Creates an FCPXML attribute value from a `Double`, collapsing whole numbers. /// /// Final Cut writes `63` rather than `63.0`, so whole values lose their /// fractional part. Values outside `Int`'s range, and non-finite values, fall @@ -42,15 +41,16 @@ internal enum AttributeValue { /// `63.5` renders as `63,5` under a German or French locale, which is invalid /// FCPXML; it rounds to six fractional digits, which would corrupt position /// values like `7.777777777`; and `Decimal(Double.infinity)` traps outright. - internal static func decimal(_ value: Double) -> String { - guard value.isFinite else { - return String(value) - } - guard value.truncatingRemainder(dividingBy: 1) == 0, + /// + /// - Parameter value: The number to render. + internal init(fcpxmlValue value: Double) { + guard value.isFinite, + value.truncatingRemainder(dividingBy: 1) == 0, value >= Double(Int.min), value <= Double(Int.max) else { - return String(value) + self = String(value) + return } - return String(Int(value)) + self = String(Int(value)) } } diff --git a/Sources/FCPKitDSL/TitleStyle.swift b/Sources/FCPKitDSL/TitleStyle.swift index 0d1fdb9..82b9585 100644 --- a/Sources/FCPKitDSL/TitleStyle.swift +++ b/Sources/FCPKitDSL/TitleStyle.swift @@ -86,7 +86,7 @@ public struct TitleStyle: Equatable, Sendable { extension TitleStyle { /// The `fontSize` attribute value, collapsing whole numbers (`63`, not `63.0`). internal var fontSizeString: String { - AttributeValue.decimal(fontSize) + String(fcpxmlValue: fontSize) } /// Lowers this style into the model's `text-style` element. From 1920647c8c95f8dea32f94daf2cdbf2e8194f0f1 Mon Sep 17 00:00:00 2001 From: Leo Dion Date: Mon, 3 Aug 2026 15:46:26 -0400 Subject: [PATCH 6/6] Adopt typed throws across FCPKitDSL Matches the typed-throws work on #42 and #43 so the three branches agree. Every throw site in FCPKitDSL already raised `BuildError` and nothing else, and the module calls no throwing FCPKit API, so `throws(BuildError)` runs end to end from `DSLNode.build` through `Document.export`. Callers get a concrete catch type instead of `any Error`. Closures that propagate need explicit `throws(BuildError)` annotations. In `Title.build` the `flatMap`/`map` chain resolving a deferred position is rewritten as a plain `if let`, which types cleanly and reads better than an annotated chain. Note this narrows `DSLNode.build`: an external conformer declaring plain `throws` no longer satisfies the protocol. Pre-1.0 and in-policy for a protocol documented as evolving, but it is an API narrowing. Co-Authored-By: Claude Opus 5 (1M context) --- Sources/FCPKitDSL/Anchor.swift | 4 ++-- Sources/FCPKitDSL/AssetClip.swift | 8 +++++--- Sources/FCPKitDSL/Color+DSL.swift | 2 +- Sources/FCPKitDSL/DSLNode.swift | 2 +- Sources/FCPKitDSL/Defaults.swift | 2 +- Sources/FCPKitDSL/Document+Export.swift | 2 +- Sources/FCPKitDSL/DocumentGroup.swift | 2 +- Sources/FCPKitDSL/Event.swift | 4 ++-- Sources/FCPKitDSL/FramePosition.swift | 4 ++-- Sources/FCPKitDSL/Gap.swift | 2 +- Sources/FCPKitDSL/Generator.swift | 2 +- Sources/FCPKitDSL/Layout+Packing.swift | 8 ++++---- Sources/FCPKitDSL/Layout.swift | 6 ++++-- Sources/FCPKitDSL/Library.swift | 4 ++-- Sources/FCPKitDSL/Project.swift | 2 +- Sources/FCPKitDSL/ResourceStore.swift | 14 +++++++++----- Sources/FCPKitDSL/Sequence.swift | 6 ++++-- Sources/FCPKitDSL/SoftPromote.swift | 6 ++++-- Sources/FCPKitDSL/Spine.swift | 2 +- Sources/FCPKitDSL/StoryItems.swift | 4 ++-- Sources/FCPKitDSL/Title.swift | 10 +++++----- Sources/FCPKitDSL/Transition.swift | 2 +- 22 files changed, 55 insertions(+), 43 deletions(-) diff --git a/Sources/FCPKitDSL/Anchor.swift b/Sources/FCPKitDSL/Anchor.swift index d799ae5..a0e6e11 100644 --- a/Sources/FCPKitDSL/Anchor.swift +++ b/Sources/FCPKitDSL/Anchor.swift @@ -34,7 +34,7 @@ internal struct Anchor: DSLNode { internal let offset: FCPTime internal let content: any DSLNode - internal func build(_ resources: inout ResourceStore) throws -> Built { + 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) { @@ -46,7 +46,7 @@ internal struct Anchor: DSLNode { return .spine(spine) } - private func applyLaneOffset(to built: Built) throws -> Built? { + private func applyLaneOffset(to built: Built) throws(BuildError) -> Built? { switch built { case .item(.title(var title)): title.lane = String(lane) diff --git a/Sources/FCPKitDSL/AssetClip.swift b/Sources/FCPKitDSL/AssetClip.swift index d0dec80..7cd5279 100644 --- a/Sources/FCPKitDSL/AssetClip.swift +++ b/Sources/FCPKitDSL/AssetClip.swift @@ -89,7 +89,7 @@ public struct AssetClip: DSLNode { ) } - internal func build(_ resources: inout ResourceStore) throws -> Built { + internal 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,7 +104,9 @@ 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) } + let items = try anchors.map { node throws(BuildError) in + try anchoredItem(node, resources: &resources) + } clip.anchoredItems = items.isEmpty ? nil : items return .item(.assetClip(clip)) } @@ -119,7 +121,7 @@ public struct AssetClip: DSLNode { ) } - private func anchoredItem(_ node: any DSLNode, resources: inout ResourceStore) throws + private func anchoredItem(_ node: any DSLNode, resources: inout ResourceStore) throws(BuildError) -> FCPKit.AnchoredItem { switch try node.build(&resources) { diff --git a/Sources/FCPKitDSL/Color+DSL.swift b/Sources/FCPKitDSL/Color+DSL.swift index 885042e..b70f369 100644 --- a/Sources/FCPKitDSL/Color+DSL.swift +++ b/Sources/FCPKitDSL/Color+DSL.swift @@ -40,7 +40,7 @@ extension Color: DSLNode { return copy } - internal func build(_ resources: inout ResourceStore) throws -> Built { + internal func build(_ resources: inout ResourceStore) throws(BuildError) -> Built { guard let duration else { throw BuildError.missingDuration("color generator") } diff --git a/Sources/FCPKitDSL/DSLNode.swift b/Sources/FCPKitDSL/DSLNode.swift index 1b099e8..b5f79d6 100644 --- a/Sources/FCPKitDSL/DSLNode.swift +++ b/Sources/FCPKitDSL/DSLNode.swift @@ -30,5 +30,5 @@ import FCPKit internal protocol DSLNode: DocumentContent { - func build(_ resources: inout ResourceStore) throws -> Built + func build(_ resources: inout ResourceStore) throws(BuildError) -> Built } diff --git a/Sources/FCPKitDSL/Defaults.swift b/Sources/FCPKitDSL/Defaults.swift index 2839a8d..96c31f1 100644 --- a/Sources/FCPKitDSL/Defaults.swift +++ b/Sources/FCPKitDSL/Defaults.swift @@ -71,7 +71,7 @@ internal enum Defaults { internal static func sequence( spine: FCPKit.Spine, format: ResourceRef? - ) throws -> FCPKit.Sequence { + ) throws(BuildError) -> FCPKit.Sequence { let packed = try Layout.pack( spine.items, frameDuration: FormatPreset.p1080p24.format.frameDuration diff --git a/Sources/FCPKitDSL/Document+Export.swift b/Sources/FCPKitDSL/Document+Export.swift index 31a2760..49ed45f 100644 --- a/Sources/FCPKitDSL/Document+Export.swift +++ b/Sources/FCPKitDSL/Document+Export.swift @@ -31,7 +31,7 @@ import FCPKit extension Document { /// Soft-promotes shells, interns resources, packs the spine, and returns `FCPXML`. - public func export(version: FCPXMLVersion = .supportedGeneration) throws -> FCPXML { + public func export(version: FCPXMLVersion = .supportedGeneration) throws(BuildError) -> FCPXML { guard let root = body as? any DSLNode else { throw BuildError.unsupportedContent } diff --git a/Sources/FCPKitDSL/DocumentGroup.swift b/Sources/FCPKitDSL/DocumentGroup.swift index e6b45e5..bfb09b4 100644 --- a/Sources/FCPKitDSL/DocumentGroup.swift +++ b/Sources/FCPKitDSL/DocumentGroup.swift @@ -38,7 +38,7 @@ public struct DocumentGroup: DocumentContent, DSLNode { self.contents = contents } - internal func build(_ resources: inout ResourceStore) throws -> Built { + internal func build(_ resources: inout ResourceStore) throws(BuildError) -> Built { if contents.count == 1, let node = contents[0] as? any DSLNode { return try node.build(&resources) } diff --git a/Sources/FCPKitDSL/Event.swift b/Sources/FCPKitDSL/Event.swift index c1573bd..5653cb0 100644 --- a/Sources/FCPKitDSL/Event.swift +++ b/Sources/FCPKitDSL/Event.swift @@ -44,13 +44,13 @@ public struct Event: DSLNode { self.content = content() } - internal func build(_ resources: inout ResourceStore) throws -> Built { + internal func build(_ resources: inout ResourceStore) throws(BuildError) -> Built { let built = try content.build(&resources) let project = try project(from: built) return .event(FCPKit.Event(name: name ?? "Untitled", uid: uid, projects: [project])) } - private func project(from built: Built) throws -> FCPKit.Project { + private func project(from built: Built) throws(BuildError) -> FCPKit.Project { switch built { case .project(let project): return project case .sequence(let sequence): return FCPKit.Project(name: "Untitled", sequence: sequence) diff --git a/Sources/FCPKitDSL/FramePosition.swift b/Sources/FCPKitDSL/FramePosition.swift index dfc4946..9538ee9 100644 --- a/Sources/FCPKitDSL/FramePosition.swift +++ b/Sources/FCPKitDSL/FramePosition.swift @@ -104,7 +104,7 @@ extension FramePosition { _ alignment: Alignment, inset: Double, frameSize: (width: Double, height: Double)? - ) throws -> (x: Double, y: Double)? { + ) throws(BuildError) -> (x: Double, y: Double)? { // `.center` is the frame centre on both axes, so an inset has no direction // to move along and the position needs no `adjust-transform` at all. if alignment == .center { @@ -165,7 +165,7 @@ extension FramePosition { /// frame centre and therefore needs no `adjust-transform` at all. /// - Throws: ``BuildError/missingFrameSize`` when absolute coordinates were /// used without an enclosing format. - internal func resolve(frameSize: (width: Double, height: Double)?) throws -> String? { + internal func resolve(frameSize: (width: Double, height: Double)?) throws(BuildError) -> String? { let point: (x: Double, y: Double) switch kind { diff --git a/Sources/FCPKitDSL/Gap.swift b/Sources/FCPKitDSL/Gap.swift index f26763f..8cad0ba 100644 --- a/Sources/FCPKitDSL/Gap.swift +++ b/Sources/FCPKitDSL/Gap.swift @@ -44,7 +44,7 @@ public struct Gap: DSLNode { Gap(duration: duration) } - internal func build(_ resources: inout ResourceStore) throws -> Built { + internal func build(_ resources: inout ResourceStore) throws(BuildError) -> Built { guard let duration else { throw BuildError.missingDuration("gap") } return .item(.gap(FCPKit.Gap(duration: duration.description))) } diff --git a/Sources/FCPKitDSL/Generator.swift b/Sources/FCPKitDSL/Generator.swift index 9b9dbf1..4155aab 100644 --- a/Sources/FCPKitDSL/Generator.swift +++ b/Sources/FCPKitDSL/Generator.swift @@ -110,7 +110,7 @@ public struct Generator: DSLNode { return replacing(params: updated) } - internal func build(_ resources: inout ResourceStore) throws -> Built { + internal func build(_ resources: inout ResourceStore) throws(BuildError) -> Built { let ref = try resources.effect(name: preset.name, uid: preset.uid) let videoElement = FCPKit.Video( ref: ResourceRef(ref.rawValue), diff --git a/Sources/FCPKitDSL/Layout+Packing.swift b/Sources/FCPKitDSL/Layout+Packing.swift index f2186b8..10670b3 100644 --- a/Sources/FCPKitDSL/Layout+Packing.swift +++ b/Sources/FCPKitDSL/Layout+Packing.swift @@ -36,7 +36,7 @@ extension Layout { cursor: Int64, longest: Int64, tickDenominator: Int32 - ) throws -> PackStep? { + ) throws(BuildError) -> PackStep? { switch item { case .assetClip(var clip): let placement = try placeOverlapping( @@ -93,7 +93,7 @@ extension Layout { cursor: Int64, longest: Int64, tickDenominator: Int32 - ) throws -> PackStep { + ) throws(BuildError) -> PackStep { switch item { case .gap(var gap): let duration = try ticks(gap.duration, tickDenominator, "gap") @@ -127,7 +127,7 @@ extension Layout { longest: Int64, tickDenominator: Int32, anchoredExtent: Int64 - ) throws -> Placement { + ) throws(BuildError) -> Placement { let original = try ticks(description, tickDenominator, subject) let start = overlap.previous let duration = original - start - overlap.next @@ -145,7 +145,7 @@ extension Layout { _ description: String?, _ denominator: Int32, _ subject: String - ) throws -> Int64 { + ) throws(BuildError) -> Int64 { guard let description, let value = FCPTime(description) else { throw BuildError.missingDuration(subject) } diff --git a/Sources/FCPKitDSL/Layout.swift b/Sources/FCPKitDSL/Layout.swift index 3735b71..8976555 100644 --- a/Sources/FCPKitDSL/Layout.swift +++ b/Sources/FCPKitDSL/Layout.swift @@ -59,7 +59,9 @@ internal enum Layout { /// Each transition of duration `T` overlaps the previous clip's end and the next /// clip's start by `T/2`. Times that reduce to whole seconds render as `"Ns"`; /// otherwise they keep the sequence tick denominator (for 24fps, `/2400s`). - internal static func pack(_ items: [FCPKit.SpineItem], frameDuration: String?) throws -> Packed { + internal static func pack(_ items: [FCPKit.SpineItem], frameDuration: String?) throws(BuildError) + -> Packed + { let tickDenominator = tickDenominator(for: frameDuration) var cursor: Int64 = 0 var longest: Int64 = 0 @@ -87,7 +89,7 @@ internal enum Layout { cursor: Int64, longest: Int64, tickDenominator: Int32 - ) throws -> PackStep { + ) throws(BuildError) -> PackStep { if let overlapping = try packOverlapping( item, overlap: overlap, diff --git a/Sources/FCPKitDSL/Library.swift b/Sources/FCPKitDSL/Library.swift index b4c8610..7f6dfd4 100644 --- a/Sources/FCPKitDSL/Library.swift +++ b/Sources/FCPKitDSL/Library.swift @@ -46,7 +46,7 @@ public struct Library: DSLNode { self.content = content() } - internal func build(_ resources: inout ResourceStore) throws -> Built { + internal func build(_ resources: inout ResourceStore) throws(BuildError) -> Built { let built = try content.build(&resources) let event = try event(from: built) return .library( @@ -59,7 +59,7 @@ public struct Library: DSLNode { ) } - private func event(from built: Built) throws -> FCPKit.Event { + private func event(from built: Built) throws(BuildError) -> FCPKit.Event { switch built { case .event(let event): return event case .project(let project): return FCPKit.Event(name: "Untitled", projects: [project]) diff --git a/Sources/FCPKitDSL/Project.swift b/Sources/FCPKitDSL/Project.swift index e6fbfeb..aae9f49 100644 --- a/Sources/FCPKitDSL/Project.swift +++ b/Sources/FCPKitDSL/Project.swift @@ -49,7 +49,7 @@ public struct Project: DSLNode { self.content = content() } - internal func build(_ resources: inout ResourceStore) throws -> Built { + internal func build(_ resources: inout ResourceStore) throws(BuildError) -> Built { guard case .sequence(let sequence) = try content.build(&resources) else { throw BuildError.unsupportedContent } diff --git a/Sources/FCPKitDSL/ResourceStore.swift b/Sources/FCPKitDSL/ResourceStore.swift index d0f55b6..6f380ea 100644 --- a/Sources/FCPKitDSL/ResourceStore.swift +++ b/Sources/FCPKitDSL/ResourceStore.swift @@ -78,7 +78,9 @@ internal struct ResourceStore { ].joined(separator: "|") } - internal mutating func format(_ preset: FormatPreset) throws -> ResourceRef { + internal mutating func format(_ preset: FormatPreset) throws(BuildError) -> ResourceRef< + FormatKind + > { let fingerprint = Self.formatFingerprint(preset.format) let id = try register( key: "format:\(fingerprint)", @@ -93,7 +95,7 @@ internal struct ResourceStore { return try resourceRef(id) } - internal mutating func asset(_ source: AssetSource) throws -> ResourceRef { + internal mutating func asset(_ source: AssetSource) throws(BuildError) -> ResourceRef { let fingerprint = Self.assetFingerprint(source.asset) let id = try register( key: "asset:\(fingerprint)", @@ -111,7 +113,9 @@ internal struct ResourceStore { return try resourceRef(id) } - internal mutating func effect(name: String, uid: String) throws -> ResourceRef { + internal mutating func effect(name: String, uid: String) throws(BuildError) -> ResourceRef< + EffectKind + > { let fingerprint = "\(name)|\(uid)" let id = try register( key: "effect:\(fingerprint)", @@ -145,7 +149,7 @@ internal struct ResourceStore { key: String, explicitID: ResourceID?, fingerprint: String - ) throws -> ResourceID { + ) throws(BuildError) -> ResourceID { if let existing = fingerprints[key] { return existing } @@ -166,7 +170,7 @@ internal struct ResourceStore { return id } - private func resourceRef(_ id: ResourceID) throws -> ResourceRef { + private func resourceRef(_ id: ResourceID) throws(BuildError) -> ResourceRef { guard let ref = ResourceRef(id.rawValue) else { throw BuildError.invalidResourceID(id.rawValue) } diff --git a/Sources/FCPKitDSL/Sequence.swift b/Sources/FCPKitDSL/Sequence.swift index 20d47c2..693b9b3 100644 --- a/Sources/FCPKitDSL/Sequence.swift +++ b/Sources/FCPKitDSL/Sequence.swift @@ -40,8 +40,10 @@ public struct Sequence: DSLNode { self.content = content() } - internal func build(_ resources: inout ResourceStore) throws -> Built { - let formatRef = try format.map { try resources.format($0) } + internal func build(_ resources: inout ResourceStore) throws(BuildError) -> Built { + let formatRef = try format.map { preset throws(BuildError) in + try resources.format(preset) + } // Publish the frame size before building children: story items resolve // absolute `FramePosition` coordinates against it. diff --git a/Sources/FCPKitDSL/SoftPromote.swift b/Sources/FCPKitDSL/SoftPromote.swift index 9c444ca..4301270 100644 --- a/Sources/FCPKitDSL/SoftPromote.swift +++ b/Sources/FCPKitDSL/SoftPromote.swift @@ -29,7 +29,9 @@ import FCPKit -internal func softPromote(_ built: Built, resources: inout ResourceStore) throws -> FCPKit.Library { +internal func softPromote(_ built: Built, resources: inout ResourceStore) throws(BuildError) + -> FCPKit.Library +{ switch built { case .library(let library): return library @@ -64,6 +66,6 @@ private func project(for sequence: FCPKit.Sequence) -> FCPKit.Project { private func sequence( for spine: FCPKit.Spine, resources: inout ResourceStore -) throws -> FCPKit.Sequence { +) throws(BuildError) -> FCPKit.Sequence { try Defaults.sequence(spine: spine, format: resources.format(.p1080p24)) } diff --git a/Sources/FCPKitDSL/Spine.swift b/Sources/FCPKitDSL/Spine.swift index 493c9ce..92bcc80 100644 --- a/Sources/FCPKitDSL/Spine.swift +++ b/Sources/FCPKitDSL/Spine.swift @@ -38,7 +38,7 @@ public struct Spine: DSLNode { self.content = content() } - internal func build(_ resources: inout ResourceStore) throws -> Built { + internal func build(_ resources: inout ResourceStore) throws(BuildError) -> Built { let packed = try Layout.pack( storyItems(content.contents, resources: &resources), frameDuration: FormatPreset.p1080p24.format.frameDuration diff --git a/Sources/FCPKitDSL/StoryItems.swift b/Sources/FCPKitDSL/StoryItems.swift index 921448f..f3dc1fe 100644 --- a/Sources/FCPKitDSL/StoryItems.swift +++ b/Sources/FCPKitDSL/StoryItems.swift @@ -32,8 +32,8 @@ import FCPKit internal func storyItems( _ content: [any DocumentContent], resources: inout ResourceStore -) throws -> [FCPKit.SpineItem] { - try content.map { value in +) throws(BuildError) -> [FCPKit.SpineItem] { + try content.map { value throws(BuildError) in guard let node = value as? any DSLNode, case .item(let item) = try node.build(&resources) else { throw BuildError.unsupportedContent } diff --git a/Sources/FCPKitDSL/Title.swift b/Sources/FCPKitDSL/Title.swift index 009efa0..fbe5983 100644 --- a/Sources/FCPKitDSL/Title.swift +++ b/Sources/FCPKitDSL/Title.swift @@ -95,7 +95,7 @@ public struct Title: DSLNode { ) } - internal func build(_ resources: inout ResourceStore) throws -> Built { + internal func build(_ resources: inout ResourceStore) throws(BuildError) -> Built { let ref = try resources.effect(name: preset.name, uid: preset.uid) let styleID = resources.textStyleID() let style = FCPKit.TextStyle(ref: styleID, content: text) @@ -103,10 +103,10 @@ public struct Title: DSLNode { // No adjust-transform unless a position was requested, so unpositioned // titles stay byte-identical to real Final Cut output. - let transform = - try position - .flatMap { try $0.resolve(frameSize: resources.frameSize) } - .map { FCPKit.AdjustTransform(position: $0) } + var transform: FCPKit.AdjustTransform? + if let position, let resolved = try position.resolve(frameSize: resources.frameSize) { + transform = FCPKit.AdjustTransform(position: resolved) + } var element = FCPKit.Title( ref: ref, diff --git a/Sources/FCPKitDSL/Transition.swift b/Sources/FCPKitDSL/Transition.swift index 9b67ce4..9a92ee4 100644 --- a/Sources/FCPKitDSL/Transition.swift +++ b/Sources/FCPKitDSL/Transition.swift @@ -46,7 +46,7 @@ public struct Transition: DSLNode { Transition(preset, duration: duration) } - internal func build(_ resources: inout ResourceStore) throws -> Built { + internal func build(_ resources: inout ResourceStore) throws(BuildError) -> Built { let video = try resources.effect(name: preset.name, uid: preset.videoUID) let audio = try resources.effect(name: "Audio Crossfade", uid: preset.audioUID) let filters = CrossDissolveFilters.make(video: video, audio: audio)