Skip to content

Title styling and positioning modifiers #37

Description

@leogdion

Problem

Sources/FCPKitDSL/Title.swift:64-76 hardcodes Helvetica 63pt Regular white centered
for every title. A slide deck needs to distinguish a heading from body text, and needs
to place text somewhere other than dead center — independent of whatever the title
preset does internally.

Files

  • Create Sources/FCPKitDSL/TitleStyle.swift — public style value type
  • Create Sources/FCPKitDSL/Title+Modifiers.swift — the public modifiers
  • Create Sources/FCPKitDSL/FramePosition.swift — position value type + alignment
  • Create Sources/FCPKitDSL/BuildEnvironment.swift — internal environment + keys
  • Modify Sources/FCPKitDSL/Title.swift — add style and position payloads, thread
    through the inits and duration(_:), consume in build
  • Modify Sources/FCPKit/Adjustments/AdjustTransform.swift — add the DTD's missing
    rotation and anchor attributes (see below)

API

/// Text styling applied to a ``Title``.
public struct TitleStyle: Equatable, Sendable {
  /// Final Cut Pro's Basic Title default styling.
  public static let `default` = TitleStyle()

  /// Paragraph alignment.
  public enum Alignment: String, Equatable, Sendable {
    case left, center, right, justified
  }

  public var font: String = "Helvetica"
  public var fontSize: String = "63"
  public var fontFace: String = "Regular"
  public var fontColor: Color = .white
  public var alignment: Alignment = .center
  public var bold: Bool = false
}

extension Title {
  /// Sets the font family, such as `Helvetica`.
  public func font(_ name: String) -> Title
  /// Sets the font size in points.
  public func fontSize(_ points: Double) -> Title
  /// Sets the text color.
  public func fontColor(_ color: Color) -> Title
  /// Sets the paragraph alignment.
  public func alignment(_ alignment: TitleStyle.Alignment) -> Title
  /// Sets the font face or weight within the family, such as `Bold`.
  public func fontFace(_ face: String) -> Title
  /// Renders the text bold.
  public func bold(_ isBold: Bool = true) -> Title
  /// Sets the title clip display name, independent of its text.
  public func name(_ name: String) -> Title
}

Positioning within the frame

Text must be placeable independent of the title preset's own layout. This is a
sibling <adjust-transform> on the clip, which composes over whatever the preset
does internally.

The public surface is alignment-first, in the SwiftUI spirit — developers should
not do coordinate math:

Title("FCPKit")                            // centered; emits no adjust-transform
Title("FCPKit").position(.topLeading)      // alignment — the common case
Title("FCPKit").position(.bottom, inset: 40)
Title("FCPKit").position(x: 960, y: 200)   // absolute pixels — escape hatch
/// A position within the video frame.
public struct FramePosition: Equatable, Sendable {
  /// The nine standard frame alignments.
  public enum Alignment: Equatable, Sendable {
    case topLeading, top, topTrailing
    case leading, center, trailing
    case bottomLeading, bottom, bottomTrailing
  }
}

extension Title {
  /// Positions the title at a frame alignment, optionally inset in points.
  public func position(_ alignment: FramePosition.Alignment, inset: Double = 0) -> Title
  /// Positions the title at absolute pixel coordinates, origin top-left.
  ///
  /// Requires an enclosing ``Sequence`` with a format; otherwise ``BuildError``
  /// `missingFrameSize` is thrown at `export()`.
  public func position(x: Double, y: Double) -> Title
}

No adjust-transform is emitted unless a position modifier is applied, so
existing output is byte-identical.

The coordinate unit (verified against fixtures and the DTD)

FCPXMLv1_9.dtd:266-271 declares:

<!ATTLIST adjust-transform position CDATA "0 0">
<!ATTLIST adjust-transform scale    CDATA "1 1">
<!ATTLIST adjust-transform rotation CDATA "0">
<!ATTLIST adjust-transform anchor   CDATA "0 0">

position is percent of frame height on both axes, measured from frame center,
Y-up. Confirmed from Tests/FCPKitTests/TestData: on a 1920×1080 sequence,
position="-17.8241 7.77778" has a Y of 84/1080 × 100. The multicam split-screen
values (67.5926, -33.9193) are consistent with the same unit.

Conversion from absolute pixels (origin top-left):

xPercent = (absX - width / 2)  / height * 100
yPercent = (height / 2 - absY) / height * 100

The divisor is height on both axes — that is what makes the 16:9 fixture values
land correctly. Assert this formula directly in tests using the fixture numbers above.

Alignment cases need no frame size at all: they map to fixed percentages
(.leading is -50 × aspect… but expressed in height-percent it depends only on the
aspect ratio, and .top/.bottom are exactly ±50 minus inset). Only
.position(x:y:) requires the actual pixel dimensions.

Deferred resolution (Built carries unresolved transforms)

build is a single eager bottom-up pass: Title.build finishes before the
enclosing Sequence.build runs, so a title cannot read the sequence format at the
moment it builds. Rather than mutating shared state on the way down, resolution is
deferred
(decision 2026-08-02):

  • Title.build emits its FCPKit.Title with the position still symbolic.
  • The ancestor that knows the format resolves symbolic positions into
    adjust-transform position="…" on the way out.
/// Ambient values supplied by ancestors during a build.
internal struct BuildEnvironment {
  internal var frameSize: (width: Double, height: Double)?
}

The environment and its keys are internal. Only .position(…) is public; there is
no public @Environment-style injection API, and third-party DSLNode conformers
cannot participate. That keeps the resolver total over values it created. Revisit if
an external need appears.

Ordering invariant. Built feeds the ordered Spine.items array, whose DTD order
guarantee came from v0.1.0 Step 3. The resolution pass must rebuild items in place,
preserving index order exactly. Extend the Step 0 ordering tests to run against
post-resolution output, not just build output — the existing tests would not catch a
reordering introduced by the resolver.

Missing format is an error, not a silent default. .position(x:y:) with no
enclosing format throws BuildError.missingFrameSize at export(). Alignment cases
never throw.

AdjustTransform is missing two DTD attributes

Sources/FCPKit/Adjustments/AdjustTransform.swift models only position and scale.
The DTD also declares rotation and anchor, which are silently dropped today
a schema-completeness hole. Add both (as String?, matching the existing style, in
DTD CodingKeys order: position, scale, rotation, anchor) while this type is being
touched. enabled is also declared; add it for completeness.

Matching real Final Cut output

Tests/FCPKitTests/FeaturePairs/titles/after.fcpxml:37 shows exactly what FCP emits:

<text-style font="Helvetica" fontSize="63" fontFace="Regular" fontColor="1 1 1 1" alignment="center"/>

Attribute order is font, fontSize, fontFace, fontColor, alignment, and bold is
never set. FCPKit.TextStyle's CodingKeys place bold between fontColor and
alignment, so emit bold only when true to keep the default path
byte-identical to the fixture.

fontSize takes Double but must serialize as 63, not 63.0 — reuse the
integer-collapse approach from Sources/FCPKit/Values/Color.swift.

Keep name defaulting to preset.name ("Basic Title") and start: "3600s"; the
fixture confirms both are what real Final Cut writes.

Tests

New Tests/FCPKitDSLTests/TitleStyleTests.swift:

  • An unstyled Title emits today's exact attributes — fixture parity guard.
  • .font("Avenir Next").fontSize(96).fontColor(.red).alignment(.left)
    font == "Avenir Next", fontSize == "96", fontColor == "1 0 0 1",
    alignment == "left".
  • .bold() emits bold == "1"; the default emits bold == nil.
  • .fontSize(63.5)"63.5"; .fontSize(63)"63".
  • A styled multi-title document DTD-validates.

New Tests/FCPKitDSLTests/FramePositionTests.swift:

  • A Title with no position modifier emits no adjust-transform — regression
    guard for existing output.
  • .position(x: 1920/2, y: 1080/2) on a 1080p sequence → position == "0 0".
  • The fixture formula: on 1920×1080, absolute (0, 84) → Y component 7.77778.
    Assert against the real value from TestData.
  • .position(.top) and .position(.bottom) produce symmetric Y values.
  • .position(.center)"0 0".
  • .position(x:y:) with no enclosing format throws BuildError.missingFrameSize;
    .position(.topLeading) in the same document does not throw.
  • Ordering: a spine of [clip, transition, clip] where a clip carries a deferred
    position still emits in that order after resolution.
  • AdjustTransform round-trips rotation and anchor (new attributes).

Acceptance criteria

  • titlesFeaturePairMatchesAfterNormalize passes unchanged — a structural diff
    against a real Final Cut export is the strongest available parity proof.
  • Schema-completeness total does not increase; rotation/anchor may decrease it.
  • Step 0 ordering tests extended to cover post-resolution output.
  • Every new public declaration carries a doc comment.

Open question for Final Cut verification

.bold() (the bold attribute) and .fontFace("Bold") are two different mechanisms
and it is unverified which one Final Cut actually honors for a given family. The
presentation deck should prefer .fontFace(...) with a face known-good for its
family, since that is what Final Cut itself writes. alignment: "justified" is
included for completeness but is likewise unverified.


Conventions

  • MIT header block on every new file (see Scripts/header.sh).
  • Swift Testing (import Testing, @Test) for new tests, per .claude/agent-notes.md. Include "Tests" in either the parent enum or the child struct, never both.
  • Doc comments on every public declaration.
  • Keep files under 225 lines (SwiftLint file_length); prefer Type+Modifiers.swift splits.
  • Run swift test and swift run fcpxml-diff schema-completeness Tests/FCPKitTests/TestData before each PR. Prefer opening a PR over merging.

Full spec with context and rationale: docs/planning/demo-presentation-video.md

Metadata

Metadata

Assignees

No one assigned

    Labels

    enhancementNew feature or requestready-for-agentFully specified, ready for an AFK agentv0.1.0FCPKit v0.1.0 milestone work

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions