@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.
- package/dist/index.cjs +32 -9
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +86 -10
- package/dist/index.d.ts +86 -10
- package/dist/index.js +31 -10
- package/dist/index.js.map +1 -1
- package/package.json +2 -2
- package/src/__tests__/fixtures/blended-cut-approved.json +712 -0
- package/src/__tests__/transition-timing-catalogs.test.ts +41 -0
- package/src/__tests__/transitions-blended-cut.test.ts +325 -0
- package/src/__tests__/transitions-scope.test.ts +7 -3
- package/src/picker-catalogs.ts +28 -5
- package/src/transitions.ts +104 -17
|
@@ -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
|
-
|
|
510
|
-
|
|
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: "
|
|
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
|
})
|
package/src/picker-catalogs.ts
CHANGED
|
@@ -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
|
|
128
|
-
*
|
|
129
|
-
*
|
|
130
|
-
* it only when the pack's own option says so, and
|
|
131
|
-
* duration" — the safe reading, since the composer then
|
|
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,
|