Skip to content

Fix #37 and #35: Title styling, frame positioning, and unique text-style ids - #44

Merged
leogdion merged 7 commits into
v0.1.xfrom
issue/37-title-style-pos
Aug 3, 2026
Merged

Fix #37 and #35: Title styling, frame positioning, and unique text-style ids#44
leogdion merged 7 commits into
v0.1.xfrom
issue/37-title-style-pos

Conversation

@leogdion

@leogdion leogdion commented Aug 3, 2026

Copy link
Copy Markdown
Member

Closes #37. Closes #35.

Two issues, two commits, one PR. They are folded together because both rewrite the same ~14 lines of Title.build and would otherwise conflict in exactly the code being rewritten.

Commit 1 — #35: unique text-style-def ids

Title.build hardcoded "ts1" in both the TextStyle(ref:) and the TextStyleDef(id:) it emitted, for every title. The DTD declares text-style-def/@id as ID, which XML requires to be unique within a document, so any document containing two titles emitted invalid XML. Latent only because no shipped document had more than one title; the presentation deck has seven.

Adds a textStyleID() allocator to ResourceStore, modeled on the existing nextNumber counter but in its own ts1, ts2, … namespace. Ids are numbered globally across titles, matching what Final Cut itself writes — 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 no fixtures needed editing.

Commit 2 — #37: styling and positioning

Adds TitleStyle plus .font, .fontSize, .fontFace, .fontColor, .alignment, .bold, .name, and FramePosition with an alignment-first .position(_:inset:) and an absolute .position(x:y:) escape hatch.

The coordinate unit is verified, not assumed. Percent of frame height on both axes, from the centre, Y-up. The test reproduces real Final Cut output exactly — TestData/UntitledXML.fcpxml:448 has position="-17.8241 7.77778", and the test asserts both components land on those values rather than on a hand-derived number.

Frame size threading. Rather than adding a parameter to DSLNode.build, the frame size rides on ResourceStore, which is already threaded through every build call. Sequence publishes its format's dimensions before building children and restores the outer value afterwards, so nested sequences do not leak frame sizes. This keeps DSLNode.build's signature stable, which matters because #36 is making that protocol public.

Nothing is emitted unless asked for. An unstyled, unpositioned title serializes byte-identically to today; bold is written only when true; 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, anchor — which were silently dropped. Note the DTD declares enabled first, so CodingKeys order is enabled, position, scale, rotation, anchor.

Ordering guard

Position resolution rewrites title elements on the way out, and the diff engine's inventory is order-blind (a reordered spine reports zero loss). PositionedSpineOrderingTests asserts a [title, transition, title] spine survives in order after resolution, at both the model and serialized-XML level.

Note for reviewers

Both DTD-validation tests export at 1.14 deliberately. At the 1.13 default these documents are also invalid for an unrelated reason — the default smart collections emit match-analysis-type, which 1.13 does not declare (#41, fixed in #42) — and that failure would mask the regressions these tests exist to catch.

x and y are added to SwiftLint's identifier_name exclusions, alongside the existing id/no.

Verification

  • FCPKIT_REQUIRE_DTD=1 swift test — 68 XCTest + 49/12/37 Swift Testing, all passing
  • fcpxml-diff schema-completeness --fail-if-total-exceeds 0 — no structural loss
  • swift-format lint and swiftlint — clean

🤖 Generated with Claude Code

leogdion and others added 2 commits August 3, 2026 06:46
`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) <noreply@anthropic.com>
`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) <noreply@anthropic.com>
@coderabbitai

coderabbitai Bot commented Aug 3, 2026

Copy link
Copy Markdown

Important

Review skipped

Auto reviews are disabled on base/target branches other than the default branch.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: d32a80e2-f4bd-4b4e-9835-2c3bc05d0e1a

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

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) <noreply@anthropic.com>
@leogdion

leogdion commented Aug 3, 2026

Copy link
Copy Markdown
Member Author

Follow-up: adversarial review found a crash

Pushed 17b1924 after re-reviewing this branch. Three bugs, all sharing a root cause — unvalidated DoubleString conversion, and an Optional treated as "use a default" rather than "cannot resolve".

1. Crash (the one that matters). fontSize and the position formatter both did an unguarded Int(value) after a whole-number check. 1e21 is a whole number:

Title("Hi").fontSize(1e21)
→ Fatal error: Double value cannot be converted to Int because the result would be greater than Int.max
→ exited with unexpected signal code 5

Infinity and NaN take the same path. Reproduced in a real test run, not reasoned about. Fixed with a shared decimalString(_:) that falls back to the plain description outside Int's range or when non-finite. A library must not take the host process down on user input.

2. .position(.top, inset: 80) with no format silently dropped the inset, emitting "0 50". An inset is in points; converting to percent-of-height needs the frame height. The absolute .position(x:y:) path already threw missingFrameSize in exactly this situation — so the design already agreed it's unresolvable, and the alignment path just failed silently instead. Now it throws too. Zero-inset alignments still never throw.

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 has no direction to inset along, so it now always resolves to no transform.

Also corrected a misleading test comment: the fixture whose position="-17.8241 7.77778" the formula test reproduces uses FFVideoFormat3840x2160p24, not 1080p as I'd written. The unit is percent-of-height and therefore scale-invariant, so the test passed for the right reason — but the stated provenance would have misled whoever derives from it next.

Gate after the fixes: 68 XCTest + 49/12/42 Swift Testing (was 37), schema-completeness clean, 0 lint violations.

One design question I did not change

Zero-inset .leading resolves to -88.889 and .top to 50 — the frame edges. That puts a centred title's centre on the edge, so half of it sits off-screen. Defensible (it's literally the alignment point) but arguably not what .top should mean in a SwiftUI-shaped API, where it usually means "inside the top edge". Left as-is since changing it is a design call, not a bug fix — flagging it for your judgement.

Comment thread Sources/FCPKitDSL/DecimalString.swift Outdated
/// 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.

leogdion and others added 3 commits August 3, 2026 13:48
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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
leogdion added a commit that referenced this pull request Aug 3, 2026
Integrates #42, #43, and #44 after the review-feedback refactor. The three
branches each touched most FCPKitDSL files (typed throws is module-wide), so
the merge needed hand-resolution across the shared surface — `Title.swift` in
particular, which carries anchors from #43 and styling/positioning from #44.

Verified the merge lost nothing: 68 XCTest + 49/12/65 Swift Testing, matching
the pre-refactor baseline exactly, and the exported deck is byte-identical to
the pre-refactor output while still validating against Apple's real 1.13 and
1.14 DTDs.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@leogdion

leogdion commented Aug 3, 2026

Copy link
Copy Markdown
Member Author

Typed throws adopted (1920647)

Per your note on the other PRs, FCPKitDSL now uses throws(BuildError) end to end — DSLNode.build through Document.export. This branch had no namespace enums to remove, so typed throws is the whole change here.

It was well-scoped rather than a stretch: every throw site in the module already raised BuildError and nothing else, and the module calls no throwing FCPKit API. So there were no catch sites to rewrite, and callers now get a concrete error type instead of any Error.

Two things worth knowing:

It caught things I'd have missed. Typing ModelSequence.init immediately surfaced that ResourceStore.format and the entire Layout packing chain were still untyped — the compiler walked the call graph for me.

Closures need explicit annotations. try map { … } does not infer the thrown type, so propagating closures need try map { x throws(BuildError) in … }. In Title.build the flatMap/map chain resolving a deferred position is rewritten as a plain if let — types cleanly and reads better than an annotated chain.

One API narrowing, deliberate: an external conformer declaring plain throws no longer satisfies DSLNode.build. That is a hard compile error, not a warning. DSLNode is public as of #43 and already carries a - Warning: that its signature evolves until 1.0, so this is in-policy — but it is a narrowing and worth knowing before merge.

Typed throws for the remaining modules is filed as #47 — FCPKit is a much bigger job (47 throwing declarations across six error types) and needs a design call first.

Gate: 68 XCTest + 49/12/43 Swift Testing, 0 lint violations, no structural loss.

…e-pos

# Conflicts:
#	Sources/FCPKitDSL/Anchor.swift
#	Sources/FCPKitDSL/AssetClip.swift
#	Sources/FCPKitDSL/Color+DSL.swift
#	Sources/FCPKitDSL/DSLNode.swift
#	Sources/FCPKitDSL/DocumentGroup.swift
#	Sources/FCPKitDSL/Event.swift
#	Sources/FCPKitDSL/FCPXMLVersion+Defaults.swift
#	Sources/FCPKitDSL/Gap.swift
#	Sources/FCPKitDSL/Generator.swift
#	Sources/FCPKitDSL/Library.swift
#	Sources/FCPKitDSL/Project.swift
#	Sources/FCPKitDSL/Sequence.swift
#	Sources/FCPKitDSL/SoftPromote.swift
#	Sources/FCPKitDSL/Spine.swift
#	Sources/FCPKitDSL/Title.swift
#	Sources/FCPKitDSL/Transition.swift
#	Tests/FCPKitDSLTests/StoryItemDoc.swift
@leogdion
leogdion merged commit 829eb38 into v0.1.x Aug 3, 2026
4 checks passed
leogdion added a commit that referenced this pull request Aug 3, 2026
…39)

Closes #38. Closes #39.

The demo deck: seven solid-color slides with styled titles anchored on lane 1,
separated by one-second cross dissolves. No .mov files and no ffmpeg, so anyone
who clones the repo can regenerate the .fcpxml with one command — FCPKit
demonstrating itself.

`PresentationDocument` and `PresentationSlide` ship in FCPKitDSL so the showcase
is browsable and testable; the CLI holds only a thin command that invokes them.
`PresentationSequence` assembles the story items from an array, because a deck's
length is data-driven rather than fixed-arity like the result builder's
`buildBlock`.

Dissolve-safe title timing: anchored items are not swept into a primary-storyline
transition, so a title spanning a dissolve would hard-cut while its background
dissolved. Packing sets a dissolved clip's `start` to T/2 and shrinks its
duration by both overlaps, and an anchor's offset is relative to that trimmed
start — so a title at offset zero already begins where the incoming dissolve
ends, and only the tail needs trimming.

Deletes the duplicated library copy of `RGBDocument`; demo scaffolding belongs
only in the CLI, and `FCPTimeIntervalTests` already used its own local fixture.

Rebuilt on v0.1.x after #42, #43, and #44 landed, so this branch now carries
only the presentation work rather than the three tracks it was stacked on.

Verified end to end against Apple's shipped DTDs: the deck packs to 36s, emits
ts1..ts7, validates at both 1.13 and 1.14, and is byte-identical to the export
produced before the tracks were squashed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@leogdion
leogdion deleted the issue/37-title-style-pos branch August 5, 2026 17:49
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant