Skip to content

Fix #36: lift .anchor onto StoryItem and make DSLNode public - #43

Merged
leogdion merged 6 commits into
v0.1.xfrom
issue/36-story-item
Aug 3, 2026
Merged

Fix #36: lift .anchor onto StoryItem and make DSLNode public#43
leogdion merged 6 commits into
v0.1.xfrom
issue/36-story-item

Conversation

@leogdion

@leogdion leogdion commented Aug 3, 2026

Copy link
Copy Markdown
Member

Closes #36.

Problem

.anchor(lane:offset:content:) was declared only on AssetClip, so nothing could be anchored onto a generator or a color. A media-free slide deck needs exactly that: text over a solid color background.

Approach

Introduces StoryItem — "content that can sit in a spine", matching the DTD's %clip_item; entity — and hangs the shared .anchor modifier off it. AssetClip, Generator, Title, Gap, and Transition conform directly. Color promotes to Generator, since it is a model type in another module and cannot gain stored properties; Color.build already desugared to Generator(.custom).color(self), so anchoring just performs that desugaring one step earlier.

The DSLNode cascade

A public protocol requirement cannot mention internal types, and DSLNode.build mentions both Built and ResourceStore. Making DSLNode public therefore requires all three — this is not called out in the spec, but it is unavoidable (confirmed with the compiler).

The cascade is kept shallow: every ResourceStore member stays internal, so only the type name is exposed. An outside type can name ResourceStore but cannot call effect(...), so no usable extension point ships. Built is a 6-case enum wrapping already-public FCPKit types, so it exposes nothing new.

Public conformers also need public witnesses, so build became public on DocumentGroup, Event, Library, Project, Sequence, and Spine.

Bug fixed along the way

.anchor replaced rather than appended (replacing(anchors:)anchors ?? self.anchors), so x.anchor(lane: 1) { … }.anchor(lane: 2) { … } silently dropped lane 1. Latent today because nothing chains — but StoryItem makes chaining the natural idiom the moment .anchor is available everywhere. The shared modifier now appends; replacingAnchors stays the pure setter the spec specifies. Pinned by chainedAnchorsAccumulateLanes.

This changes AssetClip behavior too. No existing call site chains, so nothing breaks.

Also

  • Adds the missing .video and .generator cases to anchoredExtent, so an anchored generator contributes to its parent's extent.
  • transitionAnchorsAreIgnored pins the documented no-op: the DTD admits no anchored items on <transition>, so they are accepted but never emitted.

Verification

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

🤖 Generated with Claude Code

`.anchor(lane:offset:content:)` was declared only on `AssetClip`, so nothing
could be anchored onto a generator or a color. A media-free slide deck needs
exactly that: text over a solid color background.

Introduce `StoryItem` — "content that can sit in a spine", matching the DTD's
`%clip_item;` entity — and hang the shared `.anchor` modifier off it.
`AssetClip`, `Generator`, `Title`, `Gap`, and `Transition` conform directly.
`Color` promotes to `Generator`, since it is a model type in another module
and cannot gain stored properties; `Color.build` already desugared that way,
so anchoring just performs the desugaring one step earlier.

A public protocol requirement cannot mention internal types, so `DSLNode`,
`Built`, and `ResourceStore` become public. Every `ResourceStore` member stays
internal, so only the type name is exposed and no usable extension point ships.

Anchoring onto a `Transition` is accepted but never emitted: the DTD does not
admit anchored items there. Pinned by a test so it stays documented behavior
rather than something a later change "fixes" into a throw.

Also fixes a latent bug: `.anchor` replaced rather than appended, so chained
calls silently dropped earlier lanes. The shared modifier now appends, and
`replacingAnchors` is documented as the pure setter it is. This was latent
because nothing chained; `StoryItem` makes chaining the natural idiom.

Adds the missing `.video` and `.generator` cases to `anchoredExtent`, so an
anchored generator contributes to its parent's extent.

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: 6f71ac30-5432-4552-aeb4-84d14a7378be

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 e4325f7.

The `.video` case added to `anchoredExtent` had no test: the existing
"anchored item longer than its background" test used a Title, which the
pre-existing `.title` case already handled. The new branch could have been
deleted with the suite staying green. Now covered by an anchored *generator*
outrunning its background.

Also adds the negative-lane case (legal FCPXML — content below the primary
storyline; only lane 0 is reserved) and Title/Gap as anchor hosts, both of
which gained StoryItem conformance in e4325f7 without direct coverage.

Documents why the `.generator` packing branch passes `anchoredExtent: 0`:
`FCPKit.Generator` has no `anchoredItems` property at all, because
`<generator>` is absent from the DTD's `%anchor_item;` list. Zero is the
correct value there, not a stub — the previous commit message implied that
call site was also updated, which overstated the change.

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: test coverage gap found in review

Pushed 56dcb07 after re-reviewing this branch.

The .video case I added to anchoredExtent had no test. The existing "anchored item longer than its background" test uses a Title, which the pre-existing .title case already handled — so my new branch could have been deleted and the suite would have stayed green. Now covered by an anchored generator outrunning its background.

Also added two cases that were conformances-without-coverage:

  • Negative lanes (.anchor(lane: -1)) — legal FCPXML for content below the primary storyline; only lane 0 is reserved. Issue StoryItem: lift .anchor off AssetClip and make DSLNode public #36 calls this out explicitly and it was untested.
  • Title and Gap as anchor hosts — both gained StoryItem conformance here with no direct test.

Correction to my commit message

e4325f7 said it "adds the missing .video and .generator cases to anchoredExtent, so an anchored generator contributes to its parent's extent." That overstated it: I added the anchoredExtent reader cases but left the .generator packing call site passing 0.

Having checked, 0 is actually correct there — FCPKit.Generator has no anchoredItems property at all, because <generator> is absent from the DTD's %anchor_item; list, and the DSL's own Generator lowers to <video> regardless. So there's no bug, but the call site now carries a comment saying why, rather than looking like an unfinished stub.

Gate: 68 XCTest + 49/12/28 Swift Testing (was 25), 0 lint violations, zero fixture changes.

Verified but unchanged

A reviewer flagged that StoryItem/DSLNode are publicly conformable, so an external type could adopt them and get a .anchor that silently discards anchors if its build doesn't lower them. That's true, but it's inherent to the public cascade this PR exists to make — the alternative is the opaque wrapper the spec explicitly rejected. Worth knowing; not worth contorting the design for pre-1.0.

import FCPKit

/// Lowers a single anchor node into the DTD's `%anchor_item;` entity.
internal func anchoredItem(

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.

As a rule never use global functions or properties

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 e8bf4b1. These are now statics on an AnchoredItemBuilder enum (.item / .items).

I applied the rule module-wide rather than just here, since it's a general one: storyItems also became StoryItemLowering.items (file renamed to match), and softPromote is handled on #42. Those two predate this branch, but leaving them as the only globals in the module would have undercut the rule. FCPKitDSL now has zero top-level functions.

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 80705de. AnchoredItemBuilder and StoryItemLowering are both gone.

Chasing this turned up a real duplication underneath. Anchor.applyLaneOffset and AnchoredItemBuilder.item each switched over the same four shapes, and both already omitted .gap despite the schema admitting it — two switches, one concept, already drifted.

So the anchoring half became the protocol you suggested:

internal protocol AnchorableItem {
  var lane: String? { get set }
  var offset: String? { get set }
  var asAnchoredItem: FCPKit.AnchoredItem { get }
  var asSpineItem: FCPKit.SpineItem { get }
}

Each conformer knows how to re-wrap itself into both ordered-choice containers, so Built.anchorable is the only switch and there are no downcasts. Anchor.build drops from a body with its own parallel switch to three lines.

The rest became extensions on what they operate on:

  • Built.anchoredItem() / Built.placed(lane:offset:) — the mapping is genuinely Built → anchored item
  • [any DSLNode].anchoredItems(resources:) and [any DocumentContent].spineItems(resources:) — both always took a collection, so they read as collection operations: content.contents.spineItems(resources: &resources)

.gap stays unsupported so behaviour is byte-identical; widening it is a capability change, filed as #49.

Also adopted typed throws here per your other note. Repo-wide audit is #46.

Comment thread Sources/FCPKitDSL/AssetClip.swift Outdated

/// An `asset-clip` story item with optional anchors and audio role.
public struct AssetClip: DSLNode {
public struct AssetClip: DSLNode, StoryItem {

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.

Are all StoryItem a DSLNode?

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.

Yes — and good catch, the declaration didn't say so. Fixed in e8bf4b1.

Every conformer is a DSLNode, including the two extension-based ones (Color, Generator) which extend types already declared as such. StoryItem now refines DSLNode instead of DocumentContent, so the four DSLNode, StoryItem declarations drop the redundant conformance.

It also tightens the contract: the free .anchor extension calls replacingAnchors and then depends on the conformer's build actually lowering those anchors into anchoredItems — which only a DSLNode can do. The old DocumentContent bound let a type conform without any way to honour that, so anchors could be silently dropped.

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.

Confirmed and now enforced — see e8bf4b1 (earlier) and 80705de.

Every conformer is a DSLNode, including the two extension-based ones. StoryItem refines DSLNode rather than DocumentContent, so the four DSLNode, StoryItem declarations dropped the redundant conformance.

The refactor in 80705de makes the reason concrete: .anchor calls replacingAnchors and then depends on the conformer's build lowering those anchors via anchors.anchoredItems(resources:). Only a DSLNode can do that. Under the old DocumentContent bound a type could conform and silently drop every anchor.

leogdion and others added 2 commits August 3, 2026 13:45
…SLNode

Addresses review feedback on #43.

"As a rule never use global functions or properties": the anchored-item
lowering helpers become statics on a new `AnchoredItemBuilder` enum, and the
pre-existing `storyItems` global becomes `StoryItemLowering.items`. The latter
predates this branch, but the rule is a general one, so leaving it behind would
undercut it. `StoryItems.swift` is renamed to match its type.

"Are all StoryItem a DSLNode?" — yes. Every conformer is a `DSLNode`, and the
two extension-based conformances (`Color`, `Generator`) extend types already
declared as such. `StoryItem` now refines `DSLNode` rather than
`DocumentContent`, which makes the relationship explicit and lets the four
`DSLNode, StoryItem` declarations drop the redundant conformance.

This also tightens the protocol: the free `.anchor` extension calls
`replacingAnchors` and relies on the conformer's `build` lowering those anchors,
which only a `DSLNode` can do. The old declaration did not require that.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Addresses review feedback on #43: AnchoredItemBuilder and StoryItemLowering are
namespaces holding functions rather than types that mean anything.

The anchoring case had a real duplication behind it. `Anchor.applyLaneOffset`
and `AnchoredItemBuilder.item` each switched over the same four shapes, and both
already omitted `.gap` despite the schema admitting it — two switches, one
concept, already drifted.

`AnchorableItem` states that set once. Each conformer describes how to re-wrap
itself into both ordered-choice containers, so `Built.anchorable` is the only
switch and no downcasts are needed. `Anchor.build` drops from a body with its
own parallel switch to three lines. `.gap` stays unsupported, so behaviour is
unchanged; widening it is a capability change and gets its own issue.

The rest become extensions on what they operate on:

- `Built.anchoredItem()` and `Built.placed(lane:offset:)` — the mapping is
  genuinely `Built` to an anchored item, so it lives on `Built`.
- `[any DSLNode].anchoredItems(resources:)` and
  `[any DocumentContent].spineItems(resources:)` — both always took a
  collection, so they read as collection operations:
  `content.contents.spineItems(resources: &resources)`.

Adopts typed throws across the module, matching #42. Every throw site already
raised `BuildError`, so `throws(BuildError)` runs end to end and callers get a
concrete catch type. 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
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 and others added 2 commits August 3, 2026 16:48
# 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
`ResourceStore` needs both this branch's public cascade and #42's `version`
property. The merge resolution restored this branch's copy, which predates
#42, so `ResourceStore(version:)` went missing and `Document+Export` failed to
compile against it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@leogdion
leogdion merged commit faacb17 into v0.1.x Aug 3, 2026
9 checks passed
leogdion added a commit that referenced this pull request Aug 3, 2026
…yle ids (#44)

* Fix #35: allocate unique text-style-def ids per document

`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>

* Fix #37: add Title styling and frame positioning modifiers

`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>

* Fix a crash and two silent-wrong-output bugs in title positioning

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>

* Namespace the decimal formatter; document why not Decimal.FormatStyle

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>

* Express the FCPXML decimal formatter as String.init(fcpxmlValue:)

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>

* Adopt typed throws across FCPKitDSL

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>

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
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/36-story-item 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