Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .swiftlint.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
27 changes: 24 additions & 3 deletions Sources/FCPKit/Adjustments/AdjustTransform.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
}

Expand Down
2 changes: 2 additions & 0 deletions Sources/FCPKitDSL/BuildError.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
48 changes: 48 additions & 0 deletions Sources/FCPKitDSL/DecimalString.swift
Original file line number Diff line number Diff line change
@@ -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 {

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

  1. Make it a rule never to have global functions
  2. Is there something here to use https://developer.apple.com/documentation/foundation/decimal/formatstyle

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done in aec1ed6, on both points.

1. Global function. Now AttributeValue.decimal(_:), file renamed to match. Applied module-wide — storyItems/anchoredItem(s) on #43 and softPromote on #42 got the same treatment, so FCPKitDSL has no top-level functions left.

2. Decimal.FormatStyle — I tested it, and it's the wrong tool here. It does collapse whole numbers natively, which is the one thing it would buy us, but three problems:

locale    Decimal(string: "63.5").formatted(.number.grouping(.never))
en_US  →  63.5
de_DE  →  63,5      ← invalid FCPXML
fr_FR  →  63,5
  • Locale-aware. That comma would produce invalid FCPXML on any non-English machine. This is the decisive one — these are machine-readable attribute values, not display strings. Now pinned by decimalStringsUseAPeriodRegardlessOfLocale so nobody swaps a locale-aware formatter back in.
  • Rounds to six fractional digits. 7.7777777777.777778. Fine for a font size, silently lossy for a position component.
  • Decimal(Double.infinity) traps, so it doesn't even solve the crash that motivated the helper.

All three verified by running them rather than reasoning about them. The rationale is in the doc comment so this doesn't get re-litigated later.

If you'd still prefer it for the font-size case specifically — where precision and range are not really at issue — say the word and I'll split the two call sites. My read is that one formatter with one rule is easier to reason about than two.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done in 517a1c2 — went with the String init.

internal init(fcpxmlValue value: Double)

Both call sites produce a String from a Double, which is what an init is for, and it keeps the conversion attached to the type being produced rather than hanging a domain-specific property off Double. Call sites read as String(fcpxmlValue: fontSize).

One thing worth flagging: the argument label is load-bearing. String(_: Double) already exists via LosslessStringConvertible, so an unlabelled init would have quietly overloaded it — anyone writing String(someDouble) would have silently got the FCPXML formatter instead of the stdlib one. I verified the labelled version coexists cleanly: String(63.5) still routes to the stdlib and returns "63.5".

Behaviour unchanged — same guards, same fallbacks, same tests, and the exported deck is byte-identical.

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))
}
199 changes: 199 additions & 0 deletions Sources/FCPKitDSL/FramePosition.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,199 @@
//
// 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` 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)?
) 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.
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 = try 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 {
decimalString(value)
}
}
17 changes: 17 additions & 0 deletions Sources/FCPKitDSL/ResourceStore.swift
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,14 @@ internal struct ResourceStore {
private var fingerprints: [String: ResourceID] = [:]
private var explicitFingerprints: [String: String] = [:]
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 {
[
Expand Down Expand Up @@ -116,6 +124,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,
Expand Down
12 changes: 12 additions & 0 deletions Sources/FCPKitDSL/Sequence.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading
Loading