Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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)
}
194 changes: 194 additions & 0 deletions Sources/FCPKitDSL/FramePosition.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,194 @@
//
// 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(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 {
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(BuildError) -> 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 "\(String(fcpxmlValue: point.x)) \(String(fcpxmlValue: point.y))"
}
}
17 changes: 17 additions & 0 deletions Sources/FCPKitDSL/ResourceStore.swift
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,14 @@ public 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)?

/// Creates a store targeting the given document version.
internal init(version: FCPXMLVersion = .supportedGeneration) {
Expand Down Expand Up @@ -133,6 +141,15 @@ public 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 @@ -45,6 +45,18 @@ public struct Sequence: DSLNode {
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.
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(
content.contents.spineItems(resources: &resources),
frameDuration: format?.format.frameDuration
Expand Down
56 changes: 56 additions & 0 deletions Sources/FCPKitDSL/String+FCPXMLValue.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
//
// String+FCPXMLValue.swift
// FCPKit
//
// Created by Leo Dion.
// Copyright © 2026 BrightDigit.
//
// Permission is hereby granted, free of charge, to any person
// obtaining a copy of this software and associated documentation
// files (the "Software"), to deal in the Software without
// restriction, including without limitation the rights to use,
// copy, modify, merge, publish, distribute, sublicense, and/or
// sell copies of the Software, and to permit persons to whom the
// Software is furnished to do so, subject to the following
// conditions:
//
// The above copyright notice and this permission notice shall be
// included in all copies or substantial portions of the Software.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES
// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
// OTHER DEALINGS IN THE SOFTWARE.
//

import Foundation

extension 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
/// 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.
///
/// - 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 {
self = String(value)
return
}
self = String(Int(value))
}
}
Loading
Loading