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
Problem
Sources/FCPKitDSL/Title.swift:64-76hardcodes Helvetica 63pt Regular white centeredfor 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
Sources/FCPKitDSL/TitleStyle.swift— public style value typeSources/FCPKitDSL/Title+Modifiers.swift— the public modifiersSources/FCPKitDSL/FramePosition.swift— position value type + alignmentSources/FCPKitDSL/BuildEnvironment.swift— internal environment + keysSources/FCPKitDSL/Title.swift— addstyleandpositionpayloads, threadthrough the inits and
duration(_:), consume inbuildSources/FCPKit/Adjustments/AdjustTransform.swift— add the DTD's missingrotationandanchorattributes (see below)API
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 presetdoes internally.
The public surface is alignment-first, in the SwiftUI spirit — developers should
not do coordinate math:
No
adjust-transformis emitted unless a position modifier is applied, soexisting output is byte-identical.
The coordinate unit (verified against fixtures and the DTD)
FCPXMLv1_9.dtd:266-271declares:positionis 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 of84/1080 × 100. The multicam split-screenvalues (
67.5926,-33.9193) are consistent with the same unit.Conversion from absolute pixels (origin top-left):
The divisor is
heighton both axes — that is what makes the 16:9 fixture valuesland 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
(
.leadingis-50 × aspect… but expressed in height-percent it depends only on theaspect ratio, and
.top/.bottomare exactly±50minus inset). Only.position(x:y:)requires the actual pixel dimensions.Deferred resolution (
Builtcarries unresolved transforms)buildis a single eager bottom-up pass:Title.buildfinishes before theenclosing
Sequence.buildruns, so a title cannot read the sequence format at themoment it builds. Rather than mutating shared state on the way down, resolution is
deferred (decision 2026-08-02):
Title.buildemits itsFCPKit.Titlewith the position still symbolic.adjust-transform position="…"on the way out.The environment and its keys are internal. Only
.position(…)is public; there isno public
@Environment-style injection API, and third-partyDSLNodeconformerscannot participate. That keeps the resolver total over values it created. Revisit if
an external need appears.
Ordering invariant.
Builtfeeds the orderedSpine.itemsarray, whose DTD orderguarantee 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 noenclosing format throws
BuildError.missingFrameSizeatexport(). Alignment casesnever throw.
AdjustTransformis missing two DTD attributesSources/FCPKit/Adjustments/AdjustTransform.swiftmodels onlypositionandscale.The DTD also declares
rotationandanchor, which are silently dropped today —a schema-completeness hole. Add both (as
String?, matching the existing style, inDTD
CodingKeysorder:position, scale, rotation, anchor) while this type is beingtouched.
enabledis also declared; add it for completeness.Matching real Final Cut output
Tests/FCPKitTests/FeaturePairs/titles/after.fcpxml:37shows exactly what FCP emits:Attribute order is
font, fontSize, fontFace, fontColor, alignment, andboldisnever set.
FCPKit.TextStyle'sCodingKeysplaceboldbetweenfontColorandalignment, so emitboldonly when true to keep the default pathbyte-identical to the fixture.
fontSizetakesDoublebut must serialize as63, not63.0— reuse theinteger-collapse approach from
Sources/FCPKit/Values/Color.swift.Keep
namedefaulting topreset.name("Basic Title") andstart: "3600s"; thefixture confirms both are what real Final Cut writes.
Tests
New
Tests/FCPKitDSLTests/TitleStyleTests.swift:Titleemits 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()emitsbold == "1"; the default emitsbold == nil..fontSize(63.5)→"63.5";.fontSize(63)→"63".New
Tests/FCPKitDSLTests/FramePositionTests.swift:Titlewith no position modifier emits noadjust-transform— regressionguard for existing output.
.position(x: 1920/2, y: 1080/2)on a 1080p sequence →position == "0 0".(0, 84)→ Y component7.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 throwsBuildError.missingFrameSize;.position(.topLeading)in the same document does not throw.[clip, transition, clip]where a clip carries a deferredposition still emits in that order after resolution.
AdjustTransformround-tripsrotationandanchor(new attributes).Acceptance criteria
titlesFeaturePairMatchesAfterNormalizepasses unchanged — a structural diffagainst a real Final Cut export is the strongest available parity proof.
rotation/anchormay decrease it.Open question for Final Cut verification
.bold()(theboldattribute) and.fontFace("Bold")are two different mechanismsand 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 itsfamily, since that is what Final Cut itself writes.
alignment: "justified"isincluded for completeness but is likewise unverified.
Conventions
Scripts/header.sh).import Testing,@Test) for new tests, per.claude/agent-notes.md. Include "Tests" in either the parent enum or the child struct, never both.file_length); preferType+Modifiers.swiftsplits.swift testandswift run fcpxml-diff schema-completeness Tests/FCPKitTests/TestDatabefore each PR. Prefer opening a PR over merging.Full spec with context and rationale: docs/planning/demo-presentation-video.md