@nodaro/prompts 1.27.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 +915 -603
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +251 -17
- package/dist/index.d.ts +251 -17
- package/dist/index.js +896 -604
- 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__/fixtures/parameter-hint-golden.json +33 -1
- package/src/__tests__/hint-join.test.ts +64 -1
- package/src/__tests__/motion-hint.test.ts +20 -0
- package/src/__tests__/parameter-registry-sync.test.ts +1 -0
- package/src/__tests__/person-mixed-heritage.test.ts +43 -0
- package/src/__tests__/resolve-prompt.test.ts +13 -1
- package/src/__tests__/transition-options.test.ts +223 -0
- package/src/__tests__/transition-styles.test.ts +202 -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-instant.test.ts +9 -5
- package/src/__tests__/transitions-scope.test.ts +420 -4
- package/src/__tests__/video-own-motion.test.ts +26 -0
- package/src/hint-join.ts +33 -1
- package/src/index.ts +1 -0
- package/src/parameter-prompt-hint.ts +20 -3
- package/src/person.ts +24 -8
- package/src/picker-art/character-art-files.generated.ts +536 -536
- package/src/picker-catalogs.ts +83 -7
- package/src/prompt-builder.ts +2 -1
- package/src/prompt-hint-join.ts +8 -2
- package/src/prompt-style-section.ts +3 -5
- package/src/resolve-prompt.ts +22 -4
- package/src/transitions.ts +408 -52
- package/src/video-own-motion.ts +29 -0
|
@@ -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
|
+
})
|
|
@@ -14,6 +14,13 @@ import { getPickerCatalog } from "../picker-catalogs.js"
|
|
|
14
14
|
import { VIDEO_HINT_MODE_DEFAULT, renderDirectionHints } from "../direction-registry.js"
|
|
15
15
|
import { composeVideoPromptText } from "../assemble-video-input.js"
|
|
16
16
|
|
|
17
|
+
/** match-cut's body (F8 arm B, 2026-10-03), as `transitions-scope.test.ts` pins it. */
|
|
18
|
+
const MATCH_CUT_BODY =
|
|
19
|
+
"the last picture of the first shot and the first picture of the second share one shape, at the same " +
|
|
20
|
+
"place and the same size in the frame. The camera holds that shape in place across the cut. On the ne" +
|
|
21
|
+
"xt frame everything around the shape has changed while the shape itself stays put. The shot ends on " +
|
|
22
|
+
"the second shot, fully resolved, with no flash frame or zoom between the two"
|
|
23
|
+
|
|
17
24
|
/**
|
|
18
25
|
* F5 (transition QA, 2026-09-22): a duration clause on a cut ("match cut, …,
|
|
19
26
|
* lasting approximately 1 second") made the video model render a 1.75 s
|
|
@@ -94,9 +101,7 @@ describe("instant transitions — the composer drops the duration lever", () =>
|
|
|
94
101
|
"match-cut", [], [], { position: "middle", duration: "short", intensity: "natural" }, "compact",
|
|
95
102
|
),
|
|
96
103
|
).toBe(
|
|
97
|
-
|
|
98
|
-
`in shape, color, and motion, so the cut feels like a visual rhyme; ${INSTANT_CUT_CLAUSE}), ` +
|
|
99
|
-
"the transition occurs in the middle of the clip",
|
|
104
|
+
`match cut (${MATCH_CUT_BODY}; ${INSTANT_CUT_CLAUSE}), the transition occurs in the middle of the clip`,
|
|
100
105
|
)
|
|
101
106
|
})
|
|
102
107
|
|
|
@@ -129,8 +134,7 @@ describe("transitions in a video prompt — `term (hint)`", () => {
|
|
|
129
134
|
it("the six A/B strings (compact, short + natural levers)", () => {
|
|
130
135
|
const c = (id: string) => composeTransitionHintFromConnections(id, [], [], LEVERS, "compact")
|
|
131
136
|
expect(c("match-cut")).toBe(
|
|
132
|
-
|
|
133
|
-
`in shape, color, and motion, so the cut feels like a visual rhyme; ${INSTANT_CUT_CLAUSE})`,
|
|
137
|
+
`match cut (${MATCH_CUT_BODY}; ${INSTANT_CUT_CLAUSE})`,
|
|
134
138
|
)
|
|
135
139
|
expect(c("none")).toBe(
|
|
136
140
|
`hard cut (no transition, instantaneous switch from first shot to second shot; ${INSTANT_CUT_CLAUSE})`,
|