@nodaro/prompts 1.28.0 → 1.29.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -151,3 +151,44 @@ describe("transition timing catalogs", () => {
151
151
  expect(singlesWithDims).toEqual(["transition", "character-fx", "character-motion"])
152
152
  })
153
153
  })
154
+
155
+ describe("the blended-cut flags on the picker catalog", () => {
156
+ // What a consumer that reads only `getPickerCatalog("transition")` (Studio) builds its Blend lever from:
157
+ // the blendable cut rows, and the duration step that blends them. See transitions-blended-cut.test.ts.
158
+ it("the transition options carry `blendable` on exactly seamless-match and jump-match, beside `instant`", () => {
159
+ const flagged = transitionCatalog.options!.filter((o) => o.blendable)
160
+ expect(flagged.map((o) => o.id)).toEqual(["seamless-match", "jump-match"])
161
+ for (const o of flagged) expect(o.instant, o.id).toBe(true)
162
+ expect(transitionCatalog.options!.some((o) => o.blendsCut)).toBe(false)
163
+ })
164
+
165
+ it("the `duration` dimension carries `blendsCut` on `short` only; the other dimensions carry neither flag", () => {
166
+ for (const dim of transitionCatalog.dimensions!) {
167
+ const blendsCut = dim.options.filter((o) => o.blendsCut).map((o) => o.id)
168
+ expect(blendsCut, dim.field).toEqual(dim.field === "duration" ? ["short"] : [])
169
+ expect(dim.options.some((o) => o.blendable), dim.field).toBe(false)
170
+ }
171
+ })
172
+
173
+ it("Character FX and Character Motion carry neither flag", () => {
174
+ for (const nodeType of ["character-fx", "character-motion"]) {
175
+ const cat = PICKER_CATALOGS.find((c) => c.nodeType === nodeType)!
176
+ const rows = [...(cat.options ?? []), ...(cat.dimensions ?? []).flatMap((d) => d.options)]
177
+ expect(rows.some((o) => o.blendable || o.blendsCut), nodeType).toBe(false)
178
+ }
179
+ })
180
+
181
+ it("the wire projection carries no cut flag, like `instant` (in-memory catalog only)", () => {
182
+ // Publishing them on `/v1/picker-catalogs` (and `@nodaro/shared` / the SDK) would be a deliberate,
183
+ // additive change carrying all three flags together, not drift.
184
+ for (const detail of ["compact", "full"] as const) {
185
+ const wire = projectPickerCatalog(transitionCatalog, { detail })
186
+ const rows = [...(wire.options ?? []), ...(wire.dimensions ?? []).flatMap((d) => d.options)]
187
+ for (const row of rows) {
188
+ for (const flag of ["instant", "blendable", "blendsCut"]) {
189
+ expect(Object.prototype.hasOwnProperty.call(row, flag), `${detail} ${row.id} ${flag}`).toBe(false)
190
+ }
191
+ }
192
+ }
193
+ })
194
+ })
@@ -0,0 +1,325 @@
1
+ /**
2
+ * THE BLENDED CUT (2026-10-04 test round). On `seamless-match` and `jump-match`, Duration `short` turns the
3
+ * hard cut into a blended cut: the anti-blend sentence inside the first pick's parentheses is REPLACED by
4
+ * "instead of a hard cut, the two shots blend into each other over about 1 second" ("about 1 second" is the
5
+ * `short` row's own term). Everything else about the clause is the hard cut's: the position clause (`full`
6
+ * dropped), no duration clause, no intensity clause.
7
+ *
8
+ * Everything else renders exactly as before: the two rows at Auto / Instant / Medium / Long, the other six
9
+ * cuts at every duration, every non-cut, every mixed pick, a blendable cut beside a cut that is not, and the
10
+ * direction fold (`renderTransitionBases`, which has no duration).
11
+ *
12
+ * The approved strings are pinned byte for byte from `fixtures/blended-cut-approved.json`: `approved` (the
13
+ * approved strings) and `ruleDerived` (the two-row multi-picks the approved rule produces) are copied from
14
+ * the review page, so a wording change there comes back for approval and is never made by hand. `hardCut`
15
+ * holds the same two rows at every other duration (today's hard cut); it shares their bodies, so a reword of
16
+ * either row changes it together with `approved`. Multi-picks with any other row are proven structurally
17
+ * below, so a reword of another row never touches this file.
18
+ */
19
+ import { readFileSync } from "node:fs"
20
+ import { describe, expect, it } from "vitest"
21
+ import {
22
+ INSTANT_CUT_CLAUSE,
23
+ TRANSITIONS,
24
+ TRANSITION_DURATIONS,
25
+ TRANSITION_INTENSITIES,
26
+ TRANSITION_POSITIONS,
27
+ blendedCutClause,
28
+ composeTransitionHintFromConnections,
29
+ getTransitionTerm,
30
+ isBlendableTransition,
31
+ isInstantTransition,
32
+ renderTransitionBases,
33
+ type TransitionHintScope,
34
+ type TransitionTiming,
35
+ type TransitionTimingOption,
36
+ } from "../transitions.js"
37
+ import { renderDirectionHints } from "../direction-registry.js"
38
+
39
+ type Case = {
40
+ readonly key: string
41
+ readonly ids: string | string[]
42
+ readonly timing: TransitionTiming
43
+ readonly scopes: TransitionHintScope[]
44
+ readonly text: string
45
+ }
46
+ const FIXTURE = JSON.parse(
47
+ readFileSync(new URL("./fixtures/blended-cut-approved.json", import.meta.url), "utf8"),
48
+ ) as { blendSentence: string; approved: Case[]; ruleDerived: Case[]; hardCut: Case[] }
49
+
50
+ /** The approved sentence, typed once here so the test file itself shows it. */
51
+ const BLEND = "instead of a hard cut, the two shots blend into each other over about 1 second"
52
+ const BLENDABLE = ["seamless-match", "jump-match"] as const
53
+ const CUTS = TRANSITIONS.filter((t) => t.instant).map((t) => t.id)
54
+ const OTHER_CUTS = CUTS.filter((id) => !(BLENDABLE as readonly string[]).includes(id))
55
+ const NON_CUTS = TRANSITIONS.filter((t) => !t.instant && t.id !== "auto").map((t) => t.id)
56
+ const ALL_IDS = TRANSITIONS.map((t) => t.id)
57
+
58
+ const POSITIONS = [undefined, ...TRANSITION_POSITIONS.map((o) => o.id)]
59
+ const DURATIONS = [undefined, ...TRANSITION_DURATIONS.map((o) => o.id)]
60
+ const INTENSITIES = [undefined, ...TRANSITION_INTENSITIES.map((o) => o.id)]
61
+ const SCOPES: TransitionHintScope[] = ["clip", "shot"]
62
+
63
+ const compose = (
64
+ ids: string | readonly string[],
65
+ timing: TransitionTiming | undefined,
66
+ scope: TransitionHintScope = "clip",
67
+ mode: "full" | "compact" = "full",
68
+ ) => composeTransitionHintFromConnections(ids as string | string[], [], [], timing, mode, { scope })
69
+
70
+ /** Levers with every unset field left out, so `undefined` and an absent field are the same case. */
71
+ function levers(position?: string, duration?: string, intensity?: string): TransitionTiming {
72
+ const t: Record<string, string> = {}
73
+ if (position !== undefined) t.position = position
74
+ if (duration !== undefined) t.duration = duration
75
+ if (intensity !== undefined) t.intensity = intensity
76
+ return t as TransitionTiming
77
+ }
78
+
79
+ const count = (haystack: string, needle: string) => haystack.split(needle).length - 1
80
+
81
+ describe("blended cut — the catalog", () => {
82
+ it("marks exactly seamless-match and jump-match blendable, and both are cuts", () => {
83
+ expect(TRANSITIONS.filter((t) => t.blendable).map((t) => t.id)).toEqual([...BLENDABLE])
84
+ for (const id of BLENDABLE) expect(isInstantTransition(id), id).toBe(true)
85
+ })
86
+
87
+ it("marks exactly the `short` duration as the step that blends a cut, with a term to blend over", () => {
88
+ const rows = (TRANSITION_DURATIONS as ReadonlyArray<TransitionTimingOption>).filter((d) => d.blendsCut)
89
+ expect(rows.map((d) => d.id)).toEqual(["short"])
90
+ expect(rows[0]!.term).toBe("about 1 second")
91
+ })
92
+
93
+ it("the fixture carries the same sentence", () => {
94
+ expect(FIXTURE.blendSentence).toBe(BLEND)
95
+ })
96
+ })
97
+
98
+ describe("isBlendableTransition", () => {
99
+ it("is true for each blendable row and for the two together, in either order", () => {
100
+ for (const id of BLENDABLE) expect(isBlendableTransition(id), id).toBe(true)
101
+ expect(isBlendableTransition(["seamless-match", "jump-match"])).toBe(true)
102
+ expect(isBlendableTransition(["jump-match", "seamless-match"])).toBe(true)
103
+ })
104
+
105
+ it("is false for the other six cuts, every non-cut, mixed picks and empty values", () => {
106
+ expect(OTHER_CUTS).toEqual(["none", "snap-to-black", "match-cut", "smash-cut", "jump-cut", "action-relay"])
107
+ for (const id of [...OTHER_CUTS, ...NON_CUTS]) expect(isBlendableTransition(id), id).toBe(false)
108
+ expect(isBlendableTransition(["seamless-match", "match-cut"])).toBe(false)
109
+ expect(isBlendableTransition(["jump-match", "cross-dissolve"])).toBe(false)
110
+ expect(isBlendableTransition("auto")).toBe(false)
111
+ expect(isBlendableTransition("nonexistent")).toBe(false)
112
+ expect(isBlendableTransition("")).toBe(false)
113
+ expect(isBlendableTransition([])).toBe(false)
114
+ expect(isBlendableTransition(undefined)).toBe(false)
115
+ expect(isBlendableTransition(null)).toBe(false)
116
+ })
117
+ })
118
+
119
+ describe("blendedCutClause", () => {
120
+ it("`short` gives the approved sentence", () => {
121
+ expect(blendedCutClause("short")).toBe(BLEND)
122
+ })
123
+
124
+ it("every other value gives nothing", () => {
125
+ for (const d of ["auto", "instant", "medium", "long", "nonexistent", "", undefined]) {
126
+ expect(blendedCutClause(d), String(d)).toBe("")
127
+ }
128
+ })
129
+ })
130
+
131
+ describe("blended cut — the approved strings, byte for byte", () => {
132
+ it("the tile default of each row at Short (also inline, so the approved text reads here)", () => {
133
+ expect(compose("seamless-match", { duration: "short" })).toBe(
134
+ "invisible cut (the camera motion, color palette, and on-screen motion at the end of the first shot " +
135
+ "continue exactly across the cut into the second shot, so the boundary is invisible and the two shots " +
136
+ "feel like one unbroken take; instead of a hard cut, the two shots blend into each other over about 1 second)",
137
+ )
138
+ expect(compose("jump-match", { duration: "short" })).toBe(
139
+ "match cut on a jump (the subject launches into a jump, and at the height of the leap the picture cuts to " +
140
+ "a new place. The camera follows the arc of the jump at the same speed on both sides of the cut, and the " +
141
+ "subject stays at the same place in the frame. In the second shot the same jump carries on without a " +
142
+ "break, and the subject comes down and lands in the new place. The shot ends on the subject landed in the " +
143
+ "second shot, fully resolved; instead of a hard cut, the two shots blend into each other over about 1 second)",
144
+ )
145
+ })
146
+
147
+ it("covers 12 approved cases: both rows on a tile, mid-clip, mid-shot and in the END slot, and the ignored levers", () => {
148
+ expect(FIXTURE.approved.map((c) => c.key)).toEqual(
149
+ BLENDABLE.flatMap((id) =>
150
+ ["short|tile", "short|mid-clip", "short|mid-shot", "short|end-slot", "short+natural|tile", "short+full|clip"].map(
151
+ (seat) => `${id}|${seat}`,
152
+ ),
153
+ ),
154
+ )
155
+ })
156
+
157
+ it.each(FIXTURE.approved)("$key", ({ ids, timing, scopes, text }) => {
158
+ expect(count(text, BLEND)).toBe(1)
159
+ expect(text).not.toContain(INSTANT_CUT_CLAUSE)
160
+ for (const scope of scopes) {
161
+ expect(compose(ids, timing, scope), scope).toBe(text)
162
+ expect(compose(ids, timing, scope, "compact"), `${scope} compact`).toBe(text)
163
+ }
164
+ })
165
+ })
166
+
167
+ describe("blended cut — the multi-pick rule (both picks blendable, Short: the sentence once, in the first parentheses)", () => {
168
+ it("covers both orders of the two rows", () => {
169
+ expect(FIXTURE.ruleDerived).toHaveLength(8)
170
+ })
171
+
172
+ it.each(FIXTURE.ruleDerived)("$key", ({ ids, timing, scopes, text }) => {
173
+ expect(count(text, BLEND)).toBe(1)
174
+ expect(text).not.toContain(INSTANT_CUT_CLAUSE)
175
+ // The sentence rides the first pick; the second keeps its plain wording.
176
+ const [first] = renderTransitionBases(ids as string[])
177
+ expect(text.startsWith(first!.replace(INSTANT_CUT_CLAUSE, BLEND))).toBe(true)
178
+ for (const scope of scopes) expect(compose(ids, timing, scope), scope).toBe(text)
179
+ })
180
+
181
+ it("a blendable cut beside a cut that is not stays a hard cut", () => {
182
+ for (const pair of [["seamless-match", "match-cut"], ["none", "jump-match"], ["jump-match", "action-relay"]]) {
183
+ const out = compose(pair, { position: "middle", duration: "short", intensity: "natural" })
184
+ expect(out, pair.join("+")).toBe(compose(pair, { position: "middle" }))
185
+ expect(count(out, INSTANT_CUT_CLAUSE)).toBe(1)
186
+ expect(out).not.toContain(BLEND)
187
+ }
188
+ })
189
+
190
+ it("a cut beside a non-cut is unchanged: no cut sentence, and the duration and intensity clauses stay", () => {
191
+ for (const pair of [["seamless-match", "cross-dissolve"], ["whip-pan", "jump-match"]]) {
192
+ const out = compose(pair, { position: "end", duration: "short", intensity: "natural" })
193
+ expect(out).toBe(
194
+ renderTransitionBases(pair).join(", and ") +
195
+ ", the transition occurs at the end of the clip, lasting approximately 1 second, with natural timing",
196
+ )
197
+ expect(out).not.toContain(INSTANT_CUT_CLAUSE)
198
+ expect(out).not.toContain(BLEND)
199
+ }
200
+ })
201
+
202
+ it("a no-op `auto` beside a blendable cut does not stop the blend", () => {
203
+ for (const id of BLENDABLE) {
204
+ for (const pick of [["auto", id], [id, "auto"]]) {
205
+ expect(compose(pick, { position: "middle", duration: "short" }), pick.join("+")).toBe(
206
+ compose(id, { position: "middle", duration: "short" }),
207
+ )
208
+ }
209
+ }
210
+ })
211
+ })
212
+
213
+ describe("blended cut — what stays a hard cut", () => {
214
+ it.each(FIXTURE.hardCut)("today's text: $key", ({ ids, timing, scopes, text }) => {
215
+ expect(text).not.toContain(BLEND)
216
+ for (const scope of scopes) expect(compose(ids, timing, scope), scope).toBe(text)
217
+ })
218
+
219
+ it("the two rows at Auto, Instant, Medium and Long render the hard cut, at every position and intensity", () => {
220
+ for (const id of BLENDABLE) {
221
+ for (const duration of [undefined, "auto", "instant", "medium", "long"]) {
222
+ for (const position of POSITIONS) for (const intensity of INTENSITIES) for (const scope of SCOPES) {
223
+ const out = compose(id, levers(position, duration, intensity), scope)
224
+ expect(out, `${id} ${position} ${duration} ${intensity} ${scope}`).toBe(compose(id, levers(position), scope))
225
+ expect(count(out, INSTANT_CUT_CLAUSE)).toBe(1)
226
+ expect(out).not.toContain(BLEND)
227
+ }
228
+ }
229
+ }
230
+ })
231
+
232
+ it("the other six cuts render the hard cut at every duration, Short included", () => {
233
+ for (const id of OTHER_CUTS) {
234
+ for (const duration of DURATIONS) {
235
+ for (const position of POSITIONS) for (const intensity of INTENSITIES) for (const scope of SCOPES) {
236
+ const out = compose(id, levers(position, duration, intensity), scope)
237
+ expect(out, `${id} ${position} ${duration} ${intensity} ${scope}`).toBe(compose(id, levers(position), scope))
238
+ expect(count(out, INSTANT_CUT_CLAUSE)).toBe(1)
239
+ expect(out).not.toContain(BLEND)
240
+ }
241
+ }
242
+ }
243
+ })
244
+
245
+ it("a non-cut never carries the blend sentence, and keeps its Short duration clause", () => {
246
+ for (const id of NON_CUTS) {
247
+ const out = compose(id, { duration: "short" })
248
+ expect(out, id).toContain("lasting approximately 1 second")
249
+ expect(out, id).not.toContain(BLEND)
250
+ }
251
+ })
252
+
253
+ it("a blended cut carries no span, duration or intensity clause", () => {
254
+ for (const pick of [...BLENDABLE.map((id) => [id]), ["seamless-match", "jump-match"], ["jump-match", "seamless-match"]]) {
255
+ for (const position of POSITIONS) for (const intensity of INTENSITIES) for (const scope of SCOPES) {
256
+ const out = compose(pick, levers(position, "short", intensity), scope)
257
+ expect(count(out, BLEND), `${pick} ${position} ${intensity} ${scope}`).toBe(1)
258
+ for (const word of ["spans", "lasting", "timing", "energy", "flourish"]) expect(out).not.toContain(word)
259
+ }
260
+ }
261
+ })
262
+ })
263
+
264
+ describe("blended cut — the direction fold never blends", () => {
265
+ it("renderTransitionBases keeps the hard cut for every single and ordered pair", () => {
266
+ const picks: string[][] = ALL_IDS.map((id) => [id])
267
+ for (const a of ALL_IDS) for (const b of ALL_IDS) if (a !== b) picks.push([a, b])
268
+ for (const pick of picks) {
269
+ const bases = renderTransitionBases(pick)
270
+ expect(bases.join(" ")).not.toContain(BLEND)
271
+ expect(bases.join(", and ")).toBe(compose(pick, undefined))
272
+ }
273
+ })
274
+
275
+ it("the server fold of `direction.transition` keeps the hard cut", () => {
276
+ for (const id of BLENDABLE) {
277
+ const [fold] = renderDirectionHints({ transition: id }, { surface: "video" })
278
+ expect(fold).toBe(compose(id, undefined))
279
+ expect(fold).toContain(INSTANT_CUT_CLAUSE)
280
+ expect(fold).not.toContain(BLEND)
281
+ }
282
+ })
283
+ })
284
+
285
+ describe("blended cut — the rule holds for every row and lever (self-consistency sweep)", () => {
286
+ /** A pick blends iff every contributing id is a blendable cut and the duration is `short`. */
287
+ const blends = (pick: readonly string[], duration: string | undefined) =>
288
+ duration === "short" &&
289
+ pick.filter((id) => getTransitionTerm(id).length > 0).every((id) => (BLENDABLE as readonly string[]).includes(id)) &&
290
+ pick.some((id) => getTransitionTerm(id).length > 0)
291
+
292
+ function check(pick: readonly string[], timing: TransitionTiming, scope: TransitionHintScope) {
293
+ const out = compose(pick, timing, scope)
294
+ const label = `${pick.join("+")} ${JSON.stringify(timing)} ${scope}`
295
+ const allCuts = isInstantTransition(pick.filter((id) => getTransitionTerm(id).length > 0))
296
+ const { duration, ...rest } = timing
297
+ if (blends(pick, duration)) {
298
+ // Exactly the hard cut with its anti-blend sentence swapped, once.
299
+ const hard = compose(pick, rest, scope)
300
+ expect(count(hard, INSTANT_CUT_CLAUSE), label).toBe(1)
301
+ expect(out, label).toBe(hard.replace(INSTANT_CUT_CLAUSE, BLEND))
302
+ } else {
303
+ expect(out, label).not.toContain(BLEND)
304
+ // On an all-cut pick a duration never changes anything else.
305
+ if (allCuts) expect(out, label).toBe(compose(pick, rest, scope))
306
+ }
307
+ }
308
+
309
+ it("every row alone, at every position, duration and intensity, in both scopes", () => {
310
+ for (const id of ALL_IDS) {
311
+ for (const position of POSITIONS) for (const duration of DURATIONS) for (const intensity of INTENSITIES) {
312
+ for (const scope of SCOPES) check([id], levers(position, duration, intensity), scope)
313
+ }
314
+ }
315
+ })
316
+
317
+ it("every ordered pair, at the levers that decide a blend", () => {
318
+ for (const a of ALL_IDS) for (const b of ALL_IDS) {
319
+ if (a === b) continue
320
+ for (const duration of [undefined, "short", "medium"]) for (const position of [undefined, "middle", "full"]) {
321
+ check([a, b], levers(position, duration, "natural"), "clip")
322
+ }
323
+ }
324
+ })
325
+ })
@@ -506,8 +506,10 @@ describe("F8 cut bodies (2026-10-03 A/B: F8 arm B on snap-to-black, jump-match a
506
506
  expect(composeTransitionHintFromConnections(id, [], [])).toBe(clause)
507
507
  })
508
508
 
509
- it.each(ROWS)("$id at middle / short / natural keeps only the position", ({ id, term, body }) => {
510
- const levers = { position: "middle", duration: "short", intensity: "natural" } as const
509
+ // `long`: a duration every cut ignores. Short on `jump-match` is a blended cut, pinned in
510
+ // transitions-blended-cut.test.ts.
511
+ it.each(ROWS)("$id at middle / long / natural keeps only the position", ({ id, term, body }) => {
512
+ const levers = { position: "middle", duration: "long", intensity: "natural" } as const
511
513
  expect(composeTransitionHintFromConnections(id, [], [], levers)).toBe(
512
514
  `${term} (${body}; ${INSTANT_CUT_CLAUSE}), the transition occurs in the middle of the clip`,
513
515
  )
@@ -770,8 +772,10 @@ describe("double-exposure body (2026-10-04: dblexp3 D1, the silhouette wording)"
770
772
  })
771
773
 
772
774
  describe("L1 — a cut spans nothing, so `full` adds no clause", () => {
775
+ // `long`: a duration every cut ignores. Short on `seamless-match` / `jump-match` is a blended cut, and
776
+ // its `full` (also dropped) is pinned in transitions-blended-cut.test.ts.
773
777
  it.each(INSTANT_IDS)("%s + full renders no position clause", (id) => {
774
- const out = composeTransitionHintFromConnections(id, [], [], { position: "full", duration: "short", intensity: "natural" })
778
+ const out = composeTransitionHintFromConnections(id, [], [], { position: "full", duration: "long", intensity: "natural" })
775
779
  expect(out).not.toContain("spans")
776
780
  expect(out).toBe(composeTransitionHintFromConnections(id, [], []))
777
781
  })
@@ -124,13 +124,30 @@ export interface PickerOption {
124
124
  /**
125
125
  * Transitions only: present (`true`) on a row whose mechanism is a CUT, so
126
126
  * it takes no duration and no intensity — see `Transition.instant` /
127
- * `isInstantTransition`. A consumer that only reads this wire catalog
128
- * (Studio builds the transition Duration lever from
129
- * `getPickerCatalog("transition")`) hides those levers for such a row. Carried from the base catalog; a row a catalog pack ADDS has
130
- * it only when the pack's own option says so, and absent means "has a
131
- * duration" — the safe reading, since the composer then behaves as before.
127
+ * `isInstantTransition`. A consumer that only reads this catalog (Studio
128
+ * builds the transition levers from `getPickerCatalog("transition")`) hides
129
+ * those levers for such a row. Carried from the base catalog; a row a
130
+ * catalog pack ADDS has it only when the pack's own option says so, and
131
+ * absent means "has a duration" — the safe reading, since the composer then
132
+ * behaves as before. On this in-memory catalog only: the wire projection
133
+ * (`projectPickerCatalog`) does not carry it.
132
134
  */
133
135
  readonly instant?: true
136
+ /**
137
+ * Transitions only: present (`true`) on a CUT that a `blendsCut` duration
138
+ * turns into a BLENDED CUT — see `Transition.blendable` /
139
+ * `isBlendableTransition`. Always beside `instant`. A consumer offers such a
140
+ * row's Duration as a Blend lever (hard cut, or one of the `blendsCut`
141
+ * steps of the catalog's `duration` dimension). Carried like `instant`, and
142
+ * like it on this in-memory catalog only.
143
+ */
144
+ readonly blendable?: true
145
+ /**
146
+ * Transition `duration` dimension only: present (`true`) on the step that
147
+ * blends a `blendable` cut (`short`) — see `TransitionTimingOption.blendsCut`
148
+ * / `blendedCutClause`. On this in-memory catalog only.
149
+ */
150
+ readonly blendsCut?: true
134
151
  /**
135
152
  * PER-OPTION PARAMETERS — extra node-data fields that apply only while THIS
136
153
  * option is picked, each with its own rows (Transitions: a wipe's
@@ -197,6 +214,10 @@ interface BaseCatalogEntry {
197
214
  readonly adultOnly?: true
198
215
  /** Transitions: see `Transition.instant`. Propagated into the flattened option as `instant: true`. */
199
216
  readonly instant?: boolean
217
+ /** Transitions: see `Transition.blendable`. Propagated into the flattened option as `blendable: true`. */
218
+ readonly blendable?: boolean
219
+ /** Transition durations: see `TransitionTimingOption.blendsCut`. Propagated as `blendsCut: true`. */
220
+ readonly blendsCut?: boolean
200
221
  /** Transitions: see `Transition.options`. Propagated as the option's `params`. */
201
222
  readonly options?: ReadonlyArray<{
202
223
  readonly field: string
@@ -238,6 +259,8 @@ function toOptions<T extends BaseCatalogEntry>(
238
259
  if (categoryField) opt.category = e[categoryField] as unknown as string
239
260
  if (e.adultOnly) opt.adultOnly = true
240
261
  if (e.instant) opt.instant = true
262
+ if (e.blendable) opt.blendable = true
263
+ if (e.blendsCut) opt.blendsCut = true
241
264
  if (e.options?.length) {
242
265
  opt.params = e.options.map((o) => ({
243
266
  field: o.field,