Fix #41: gate match-analysis-type smart collection on FCPXML version - #42
Conversation
`Defaults.smartCollections()` unconditionally emitted a "Missing Analysis" smart collection containing `<match-analysis-type>`. That element does not exist in the FCPXML 1.13 DTD (0 occurrences in FCPXMLv1_13.dtd, 5 in FCPXMLv1_14.dtd), so every DSL document exported at the library default version — `FCPXMLVersion.supportedGeneration`, which is 1.13 — was invalid against Final Cut's own DTD. The `fcpxml-dsl` CLI defaults to 1.14, which is why the common path validated and this went unnoticed. Thread the target version through `ResourceStore`, which is already passed `inout` to every `build` call, and gate the collection on `compatibility(relativeTo:)` rather than string-matching a version. The collection is gated, not dropped: the feature-pair acceptance tests export at 1.14 and their real Final Cut fixtures do contain "Missing Analysis", so removing it outright would fail the symmetric structural diff. Also extracts the previously private `assertDTDValidates` helper into a shared `DTDValidationSupport.swift` for reuse by other test suites. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
Important Review skippedAuto reviews are disabled on base/target branches other than the default branch. Please check the settings in the CodeRabbit UI or the ⚙️ Run configurationConfiguration used: defaults Review profile: CHILL Plan: Pro Plus Run ID: You can disable this status message by setting the Use the checkbox below for a quick retry:
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. Comment |
There was a problem hiding this comment.
no global properties or functions
There was a problem hiding this comment.
Done in 6c0227d. softPromote and its four private helpers are now statics on a SoftPromote enum.
I renamed the entry point to promote(_:resources:) — as library(_:resources:) it would have overloaded the private library(for:version:) helpers it calls, which read badly.
This one predates the branch, but I converted it here rather than leave it as an exception to the rule. Same treatment on #43 and #44; FCPKitDSL now has no top-level functions.
There was a problem hiding this comment.
Done in 2d79e24. SoftPromote is gone; it is now Built.promotedLibrary(resources:).
You were right that it read as an anti-pattern. Looking at what it actually does — each Built case enters a fixed ladder at its own rung and folds upward, item → spine → sequence → project → event → library — it is plainly an operation on Built, not a bag of functions that happened to need a home.
I did not use a protocol here: one implementation, closed case set. AnchorableItem on #43 does get one, because it has four real conformers and collapses a genuine duplication. Happy to revisit if you'd rather see a protocol seam for testability.
Defaults went too, split along the seam it was hiding — two concerns that shared nothing but a file:
- version-gated vocabulary →
FCPXMLVersion.admitsAnalysisMatching/.defaultSmartCollections(depended on nothing but the version) - the sequence factory →
ModelSequence.init(packing:format:)on the model type it constructs
Two incidental fixes fell out: the .event arm no longer inlines FCPKit.Library(...) instead of calling its own helper, and the .item arm no longer packs twice.
Also adopted typed throws across the module per your other note — throws(BuildError) end to end. It immediately earned its keep: it caught ResourceStore.format and the whole Layout chain still being untyped, which I'd have missed by eye.
Repo-wide namespace audit filed as #46, typed throws for the other modules as #47.
Addresses review feedback on #42: "no global properties or functions". `softPromote` and its four private helpers become statics on a `SoftPromote` enum. The entry point is renamed to `promote(_:resources:)` so it does not overload the private `library(for:version:)` helpers it calls. These globals predate this branch, but the rule is a general one, so they are converted here rather than left as the only exceptions in the module. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…rows Addresses review feedback on #42: Defaults belongs on the types it concerns, and SoftPromote reads as an anti-pattern rather than a namespace. `Defaults` held two unrelated concerns and is split along that seam: - The version-gated vocabulary moves onto the type that decides it — `FCPXMLVersion.admitsAnalysisMatching` and `.defaultSmartCollections`. Both depended on nothing but the version, so this is a clean move. The documented `.malformed`-admits-the-element behaviour is preserved. - The sequence factory becomes `ModelSequence.init(packing:format:)`, an extension on the FCPXML model type it constructs. The typealias exists because FCPKitDSL declares its own `Sequence`, so the file cannot be named for a qualified `FCPKit.Sequence`. `SoftPromote` becomes `Built.promotedLibrary(resources:)`. The switch is a fold up a fixed ladder — item to spine to sequence to project to event to library — where each case enters at its own rung, which is an operation on `Built` rather than a bag of functions. No protocol here: one implementation, closed case set. Two incidental fixes fall out of the rewrite: the `.event` arm no longer inlines `FCPKit.Library(...)` instead of calling its own helper, and the `.item` arm no longer packs twice (once here, once inside the old `Defaults.sequence`). Adopts typed throws across FCPKitDSL. Every throw site in the module already raised `BuildError` and nothing else, and the module calls no throwing FCPKit API, so `throws(BuildError)` now runs end to end from `DSLNode.build` through `Document.export` — callers get a concrete catch type. Closures need explicit `throws(BuildError)` annotations where they propagate. 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 already documented as evolving, but it is an API narrowing. 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>
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>
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>
`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>
* Fix #36: lift .anchor onto StoryItem and make DSLNode public `.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> * Cover the new anchoredExtent case, negative lanes, and Title/Gap hosts 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> * Replace global functions with namespaced statics; StoryItem refines DSLNode 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> * Replace lowering namespaces with a protocol and array extensions 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> * Carry the version property through the v0.1.x merge `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> --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
…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>
…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>
Closes #41.
Problem
Defaults.smartCollections()unconditionally emitted aMissing Analysissmart collection containing<match-analysis-type>. That element does not exist in the FCPXML 1.13 DTD, verified against Apple's shipped DTDs:The library default is
FCPXMLVersion.supportedGeneration, which is1.13, so every DSL document exported at the default version was invalid against Final Cut's own DTD. Thefcpxml-dslCLI defaults to 1.14, which is why the common path validated and this went unnoticed.Approach
The version is only known inside
export(version:), which runs after the whole library is built. Rather than post-processing the built model, the target version now rides onResourceStore— already threadedinoutthrough everybuildcall, so it is the natural ambient-context channel. There is exactly oneResourceStore()call site in the codebase, so a defaultedinit(version:)keeps everything compiling.Gating uses the existing
FCPXMLVersion.compatibility(relativeTo:)rather than string-matching a version number.Why gated rather than dropped
The issue suggests "drop it as multicam did" as an option. That would break two green tests: all three feature-pair tests export at 1.14, and both real Final Cut fixtures (
transitions/after.fcpxml:82,titles/after.fcpxml:61) do containMissing Analysis. The structural diff runs in.symmetricmode, so removing the element from generated output is a reported difference. The multicam precedent does not transfer — multicam has no parity test pinning it to a real export.Note for reviewers
The default (1.13) export now emits five smart collections rather than six. That is the fix. 1.14 output is unchanged.
Also extracts the previously private
assertDTDValidateshelper into a sharedDTDValidationSupport.swift, since sibling branches need it too.Verification
FCPKIT_REQUIRE_DTD=1 swift test— 68 XCTest + 49/12/19 Swift Testing, all passingfcpxml-diff schema-completeness --fail-if-total-exceeds 0— no structural lossswift-format lintandswiftlint— clean🤖 Generated with Claude Code