@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
package/src/transitions.ts
CHANGED
|
@@ -47,8 +47,13 @@ export interface Transition {
|
|
|
47
47
|
* approximately 1 second") on such a row tells the video model to spend a
|
|
48
48
|
* second on the change, and it obliges with a dissolve: a match cut rendered
|
|
49
49
|
* as a 1.75 s cross-dissolve in QA. The composer therefore skips the duration
|
|
50
|
-
*
|
|
51
|
-
*
|
|
50
|
+
* clause when every picked transition is instant (on a BLENDABLE cut, a
|
|
51
|
+
* `blendsCut` step changes the cut itself instead — see `blendable`), and
|
|
52
|
+
* consumers offer only the levers a cut takes — Position without `full`, no
|
|
53
|
+
* Intensity, and Duration only on a blendable cut, as a Blend lever of "hard
|
|
54
|
+
* cut" plus the `blendsCut` steps — reading `isInstantTransition` /
|
|
55
|
+
* `isBlendableTransition` (the canvas panel) or the picker catalog's
|
|
56
|
+
* `instant` / `blendable` / `blendsCut` flags (an id-only client).
|
|
52
57
|
*
|
|
53
58
|
* The same holds for INTENSITY: every intensity clause describes how the
|
|
54
59
|
* change PERFORMS over time ("natural unhurried timing", "wild flourishes and
|
|
@@ -65,6 +70,229 @@ export interface Transition {
|
|
|
65
70
|
* anti-blend instruction that made the model render a true single-frame cut.
|
|
66
71
|
*/
|
|
67
72
|
readonly instant?: boolean
|
|
73
|
+
/**
|
|
74
|
+
* `true` on a CUT that may become a BLENDED CUT: picked with a duration step
|
|
75
|
+
* that `blendsCut` (Short), the pick's anti-blend `INSTANT_CUT_CLAUSE` is
|
|
76
|
+
* REPLACED by `blendedCutClause` — the two shots blend into each other over
|
|
77
|
+
* that step's own `term` — and every other rule of a cut stands (position
|
|
78
|
+
* without `full`, no duration clause, no intensity clause). Any other
|
|
79
|
+
* duration leaves it a hard cut. See `isBlendableTransition`.
|
|
80
|
+
*
|
|
81
|
+
* Set on `seamless-match` and `jump-match` only. In the blended-cut test
|
|
82
|
+
* round (2026-10-04) their blended takes at Short kept what makes each row
|
|
83
|
+
* itself — the invisible cut still hid its join, the jump match still held
|
|
84
|
+
* the subject's place across the leap — and were approved. The blended takes
|
|
85
|
+
* of `match-cut`, `jump-cut` and `action-relay` lost their trait and rated
|
|
86
|
+
* below their hard cut, so those rows stay hard cuts at every duration, as
|
|
87
|
+
* do `none`, `snap-to-black` and `smash-cut`, whose descriptions a blend
|
|
88
|
+
* would contradict ("instantaneous", "on a single frame", "no fade").
|
|
89
|
+
*/
|
|
90
|
+
readonly blendable?: boolean
|
|
91
|
+
/**
|
|
92
|
+
* PER-ROW OPTIONS — extra choices that only mean something for THIS
|
|
93
|
+
* transition (a wipe's direction), each written into the row's own hint.
|
|
94
|
+
*
|
|
95
|
+
* Unlike the timing scales (every row has a position), an option is declared
|
|
96
|
+
* by the row that reads it, so it never appears beside a transition it would
|
|
97
|
+
* not change. The row authors `promptTemplate` — its hint with one
|
|
98
|
+
* `{<field>}` token per option — and `promptHint` is DERIVED from it with
|
|
99
|
+
* every option on its `auto` choice (see `withOptionSlots`), so a reader of
|
|
100
|
+
* the plain `promptHint` (the picker catalog, the analyzer, a catalog pack)
|
|
101
|
+
* always sees a finished sentence and never a token.
|
|
102
|
+
*
|
|
103
|
+
* The choice's `phrase` is what fills the token, so the row BODY and the
|
|
104
|
+
* option WORDING are separate: a reworded body keeps its options as long as
|
|
105
|
+
* it keeps the token. `transition-options.test.ts` pins that every template
|
|
106
|
+
* carries exactly one token per option and no other.
|
|
107
|
+
*/
|
|
108
|
+
readonly options?: ReadonlyArray<TransitionOption>
|
|
109
|
+
/** The hint with a `{<field>}` token per `options` entry — see `options`. */
|
|
110
|
+
readonly promptTemplate?: string
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* One choice of a per-row option. `auto` leads every option and is the choice
|
|
115
|
+
* `promptHint` is written with: picking it (or picking nothing) sends no value.
|
|
116
|
+
*/
|
|
117
|
+
export interface TransitionOptionChoice {
|
|
118
|
+
readonly id: string
|
|
119
|
+
readonly label: string
|
|
120
|
+
readonly description: string
|
|
121
|
+
/** The words that fill the row template's `{<field>}` token. */
|
|
122
|
+
readonly phrase: string
|
|
123
|
+
/** The compact term for the choice ("left to right"); `""` on `auto`. */
|
|
124
|
+
readonly term: string
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/** A per-row option (see `Transition.options`). */
|
|
128
|
+
export interface TransitionOption {
|
|
129
|
+
/** The node-data field the chosen id is stored under — also the template
|
|
130
|
+
* token's name (`{wipeDirection}`). Unique within a row; SHARED across rows
|
|
131
|
+
* only where the rows mean the same kind of setting (`style`, see
|
|
132
|
+
* `TRANSITION_STYLE_FIELD`) — each row still declares its own choices, and
|
|
133
|
+
* a value is read only by the row that declares it. */
|
|
134
|
+
readonly field: string
|
|
135
|
+
readonly label: string
|
|
136
|
+
/** `auto` first. */
|
|
137
|
+
readonly choices: ReadonlyArray<TransitionOptionChoice>
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* The chosen option ids, keyed by `TransitionOption.field`
|
|
142
|
+
* (`{ wipeDirection: "left-to-right" }`). One flat record for a whole pick: a
|
|
143
|
+
* row reads only the fields it declares, so the other row of a two-pick, or a
|
|
144
|
+
* value left behind by an earlier pick, changes nothing.
|
|
145
|
+
*/
|
|
146
|
+
export type TransitionOptionValues = Readonly<Record<string, string | undefined>>
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* The wipe's direction. The phrase is the edge AND its travel, so the row's
|
|
150
|
+
* body is free to change around it (`a clean {wipeDirection}, revealing …`).
|
|
151
|
+
* `auto` names no orientation at all — a straight edge — and is what a pick
|
|
152
|
+
* with no direction has always meant.
|
|
153
|
+
*/
|
|
154
|
+
export const WIPE_DIRECTION: TransitionOption = {
|
|
155
|
+
field: "wipeDirection",
|
|
156
|
+
label: "Direction",
|
|
157
|
+
choices: [
|
|
158
|
+
{ id: "auto", label: "Auto", description: "Let the model choose the direction", term: "",
|
|
159
|
+
phrase: "straight edge sweeps across the frame" },
|
|
160
|
+
{ id: "left-to-right", label: "Left → Right", description: "A vertical edge travels from the left side to the right", term: "left to right",
|
|
161
|
+
phrase: "vertical edge sweeps across the frame from left to right" },
|
|
162
|
+
{ id: "right-to-left", label: "Right → Left", description: "A vertical edge travels from the right side to the left", term: "right to left",
|
|
163
|
+
phrase: "vertical edge sweeps across the frame from right to left" },
|
|
164
|
+
{ id: "top-to-bottom", label: "Top → Bottom", description: "A horizontal edge travels down from the top", term: "top to bottom",
|
|
165
|
+
phrase: "horizontal edge sweeps down the frame from top to bottom" },
|
|
166
|
+
{ id: "bottom-to-top", label: "Bottom → Top", description: "A horizontal edge travels up from the bottom", term: "bottom to top",
|
|
167
|
+
phrase: "horizontal edge sweeps up the frame from bottom to top" },
|
|
168
|
+
{ id: "top-left-to-bottom-right", label: "Top-left → Bottom-right", description: "A diagonal edge travels from the top-left corner to the bottom-right", term: "top-left to bottom-right",
|
|
169
|
+
phrase: "diagonal edge sweeps across the frame from the top-left corner to the bottom-right corner" },
|
|
170
|
+
{ id: "top-right-to-bottom-left", label: "Top-right → Bottom-left", description: "A diagonal edge travels from the top-right corner to the bottom-left", term: "top-right to bottom-left",
|
|
171
|
+
phrase: "diagonal edge sweeps across the frame from the top-right corner to the bottom-left corner" },
|
|
172
|
+
],
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* STYLE — a row's alternative LOOKS, each a complete description.
|
|
177
|
+
*
|
|
178
|
+
* A styled row's `promptTemplate` is the single token `{style}`, so choosing a
|
|
179
|
+
* style swaps the row's whole body, not a phrase inside it. The field is SHARED
|
|
180
|
+
* by every styled row (one document key, `style`), but each row declares its
|
|
181
|
+
* own choices, and every choice id but `auto` carries the row's id as a prefix
|
|
182
|
+
* (`debris-shower-light-sweep`), so a stored style can never mean something on
|
|
183
|
+
* another row: re-pointing the pick drops it, and a row reads a foreign id as
|
|
184
|
+
* its default.
|
|
185
|
+
*
|
|
186
|
+
* `auto` is the row's DEFAULT look — labelled with that look's name
|
|
187
|
+
* ("Full cover (default)"), never "Auto", because it is a specific look and not
|
|
188
|
+
* "the model decides". Picking it stores nothing, so a pick with no style (every
|
|
189
|
+
* pick made before styles existed) reads as the default.
|
|
190
|
+
*
|
|
191
|
+
* Every body below is the text a tested A/B take was generated from (tidied:
|
|
192
|
+
* first letter lower-cased, final full stop dropped). `term` is `""` on every
|
|
193
|
+
* choice: the row's own term leads the compact rendering whatever the style.
|
|
194
|
+
*/
|
|
195
|
+
function rowStyle(
|
|
196
|
+
row: string,
|
|
197
|
+
choices: ReadonlyArray<{ readonly slug?: string; readonly label: string; readonly description: string; readonly phrase: string }>,
|
|
198
|
+
): TransitionOption {
|
|
199
|
+
return {
|
|
200
|
+
field: TRANSITION_STYLE_FIELD,
|
|
201
|
+
label: "Style",
|
|
202
|
+
choices: choices.map((c, i) => ({
|
|
203
|
+
id: i === 0 ? "auto" : `${row}-${c.slug}`,
|
|
204
|
+
label: i === 0 ? `${c.label} (default)` : c.label,
|
|
205
|
+
description: c.description,
|
|
206
|
+
phrase: c.phrase,
|
|
207
|
+
term: "",
|
|
208
|
+
})),
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/** The shared node-data field every styled row stores its look under. */
|
|
213
|
+
export const TRANSITION_STYLE_FIELD = "style"
|
|
214
|
+
|
|
215
|
+
export const DEBRIS_SHOWER_STYLE: TransitionOption = rowStyle("debris-shower", [
|
|
216
|
+
{ label: "Full cover", description: "Debris fills the whole screen, then blows past to reveal the next scene",
|
|
217
|
+
phrase: "a dense shower of loose leaves, paper scraps and dust whips across the frame from one side, close to the lens, thick enough to hide the whole picture. The camera stays where it is and the framing does not change. As the last of the debris blows past the far edge, the second shot is revealed behind it. The shot ends on the second shot, clear and fully resolved, with no debris left. The debris passes in front of the picture in one direction, and the second shot appears only once it has passed" },
|
|
218
|
+
{ slug: "light-sweep", label: "Light sweep", description: "A quick scatter of debris crosses the screen; the scene has changed behind it",
|
|
219
|
+
phrase: "a shower of debris — leaves, papers, dust — sweeps across the frame in front of the camera, and once the debris clears the scene behind has changed" },
|
|
220
|
+
])
|
|
221
|
+
|
|
222
|
+
export const GARDEN_BLOOM_STYLE: TransitionOption = rowStyle("garden-bloom", [
|
|
223
|
+
{ label: "Grow & part", description: "Flowers and vines grow over the picture, then part like curtains on the next scene",
|
|
224
|
+
phrase: "lush flowers and vines rapidly grow and bloom outward from the edges of the frame, the foliage spreads to overtake the entire image, then parts open like curtains to reveal the new scene behind" },
|
|
225
|
+
{ slug: "hedge-doors", label: "Hedge doors", description: "Leafy panels close over the picture, then slide apart to the sides",
|
|
226
|
+
phrase: "vines and flowers grow rapidly inward from the edges of the frame, blooming as they spread, until leaves and blossoms cover the whole picture. The camera stays where it is and the framing does not change. The foliage then splits down the middle and draws apart toward the side edges, opening onto the second shot behind it. The shot ends on the second shot, clear and fully resolved, with no leaves or flowers left. The plants grow over the front of the picture, and the first shot stays unchanged until they cover it completely" },
|
|
227
|
+
])
|
|
228
|
+
|
|
229
|
+
export const SMOKE_PUFF_STYLE: TransitionOption = rowStyle("smoke-puff", [
|
|
230
|
+
{ label: "Engulf", description: "Smoke billows up around the subject, and the new subject appears inside it",
|
|
231
|
+
phrase: "the subject vanishes in a soft puff of smoke that billows outward and fills the frame, the smoke then clears to reveal the new subject in the new scene" },
|
|
232
|
+
{ slug: "full-cover", label: "Full cover", description: "Smoke from the subject fills the whole screen, then clears on the next scene",
|
|
233
|
+
phrase: "the first subject vanishes in a sudden soft puff of smoke that billows outward from where it stood until the smoke fills the frame. The camera stays where it is and the framing does not change. The smoke thins and clears from the centre outward, revealing the second shot with the second subject at the same place in the frame. The shot ends on the second shot, clear and fully resolved, with no smoke left. The smoke comes only from where the first subject was" },
|
|
234
|
+
])
|
|
235
|
+
|
|
236
|
+
export const SAKURA_PETALS_STYLE: TransitionOption = rowStyle("sakura-petals", [
|
|
237
|
+
{ label: "Swirling veil", description: "A swirl of pink petals veils the picture, then drifts past",
|
|
238
|
+
phrase: "a dense storm of cherry blossom petals swirls in from one side and fills the frame in soft pink motion, the petals cluster to fully veil the image, then drift past to reveal the new scene" },
|
|
239
|
+
{ slug: "side-sweep", label: "Side sweep", description: "Petals sweep across from one side and leave by the other, uncovering the next scene",
|
|
240
|
+
phrase: "a dense storm of pink cherry blossom petals swirls in from one side of the frame, close to the lens, and thickens until the petals veil the whole picture. The camera stays where it is and the framing does not change. The petals keep drifting the same way and thin out, revealing the second shot behind them. The shot ends on the second shot, clear and fully resolved, with no petals left. The petals fly across the front of the picture in one direction, and the first shot stays unchanged until they hide it completely" },
|
|
241
|
+
])
|
|
242
|
+
|
|
243
|
+
export const AURORA_SWEEP_STYLE: TransitionOption = rowStyle("aurora-sweep", [
|
|
244
|
+
{ label: "Sky glow", description: "Aurora light glows over the scene, then fades to reveal the next one",
|
|
245
|
+
phrase: "a luminous curtain of green and violet aurora light ripples across the whole frame, and its bright bands veil the first shot. The camera stays where it is and the framing does not change. As the bands fade, the second shot is revealed behind them. The shot ends on the second shot, clear and fully resolved, with no aurora light left. The aurora glows over the front of the picture, and the second shot appears only as it fades" },
|
|
246
|
+
{ slug: "veil", label: "Veil", description: "Aurora curtains drop over the whole picture, then fade to reveal the next scene",
|
|
247
|
+
phrase: "a luminous green and violet aurora curtain ripples across the entire frame, the bright bands obscure the first scene, and as the aurora dissipates the second scene resolves in the clear sky" },
|
|
248
|
+
])
|
|
249
|
+
|
|
250
|
+
export const SAND_STORM_STYLE: TransitionOption = rowStyle("sand-storm", [
|
|
251
|
+
{ label: "Full cover", description: "A wall of sand hides the whole picture, then clears on the next scene",
|
|
252
|
+
phrase: "a wall of opaque, swirling ochre sand sweeps in from one side of the frame and surges across it until the whole picture is hidden in dust. The camera stays where it is and the framing does not change. The dust then thins and sinks away toward the lower edge of the frame, revealing the second shot behind it. The shot ends on the second shot, clear and fully resolved, with no dust left. The first shot stays as it is until the sand covers it completely, and the second shot appears only as the dust clears" },
|
|
253
|
+
{ slug: "light-sweep", label: "Light sweep", description: "A streak of blown sand crosses the screen with the next scene already behind it",
|
|
254
|
+
phrase: "a narrow streak of blown sand races across the frame close to the lens in one direction. The first shot stays clear ahead of the streak and the second shot is already clear behind it. The camera stays where it is and the framing does not change. The shot ends on the second shot, fully resolved, with no sand left" },
|
|
255
|
+
])
|
|
256
|
+
|
|
257
|
+
export const WHITE_FLASH_STYLE: TransitionOption = rowStyle("white-flash", [
|
|
258
|
+
{ label: "Flash", description: "A camera flash pops the picture to white, holds, then fades down on the next scene",
|
|
259
|
+
phrase: "a bright camera-flash bloom fills the whole frame with pure white. The camera stays where it is and the framing does not change. The white holds for a clear beat, then fades down to reveal the second shot. The shot ends on the second shot, fully resolved, with no white haze left. The first shot is gone once the frame is white, and the second shot appears only out of the white" },
|
|
260
|
+
{ slug: "overexposure", label: "Overexposure", description: "The picture blooms to white and resolves into the next scene",
|
|
261
|
+
phrase: "a bright camera-flash bloom fills the frame with pure white, holds for a fraction of a second, then resolves into the new scene" },
|
|
262
|
+
])
|
|
263
|
+
|
|
264
|
+
const optionToken = (field: string): string => `{${field}}`
|
|
265
|
+
|
|
266
|
+
/**
|
|
267
|
+
* Fill a template's option tokens: each option's chosen choice when `values`
|
|
268
|
+
* names one of its ids, its `auto` choice otherwise (an unknown id included).
|
|
269
|
+
*/
|
|
270
|
+
function fillOptionSlots(
|
|
271
|
+
template: string,
|
|
272
|
+
options: ReadonlyArray<TransitionOption>,
|
|
273
|
+
values?: TransitionOptionValues,
|
|
274
|
+
): string {
|
|
275
|
+
let out = template
|
|
276
|
+
for (const option of options) {
|
|
277
|
+
const picked = values?.[option.field]
|
|
278
|
+
const choice =
|
|
279
|
+
option.choices.find((c) => c.id === picked) ?? option.choices[0]
|
|
280
|
+
out = out.split(optionToken(option.field)).join(choice?.phrase ?? "")
|
|
281
|
+
}
|
|
282
|
+
return out
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
/**
|
|
286
|
+
* Author a row that has options: the row is written with its `promptTemplate`,
|
|
287
|
+
* and its `promptHint` is the template on every `auto` choice.
|
|
288
|
+
*/
|
|
289
|
+
function withOptionSlots(
|
|
290
|
+
row: Omit<Transition, "promptHint"> & {
|
|
291
|
+
readonly promptTemplate: string
|
|
292
|
+
readonly options: ReadonlyArray<TransitionOption>
|
|
293
|
+
},
|
|
294
|
+
): Transition {
|
|
295
|
+
return { ...row, promptHint: fillOptionSlots(row.promptTemplate, row.options) }
|
|
68
296
|
}
|
|
69
297
|
|
|
70
298
|
/**
|
|
@@ -105,33 +333,34 @@ export const TRANSITIONS: ReadonlyArray<Transition> = [
|
|
|
105
333
|
{ id: "fade-to-white", label: "Fade to White", category: "standard", description: "Blooms to white, second emerges",
|
|
106
334
|
promptHint: "fade to white: the first shot brightens until the frame is pure white, then the second shot resolves out of the white" },
|
|
107
335
|
{ id: "snap-to-black", label: "Snap to Black", category: "standard", description: "Instant cut to full black for a beat, then the next shot",
|
|
108
|
-
promptHint: "snap to black: the first shot cuts
|
|
336
|
+
promptHint: "snap to black: the first shot cuts straight to full black on a single frame. The camera holds its framing right up to the cut. The frame stays pure black for a single beat, then the second shot cuts in at full brightness on a single frame. The shot ends on the second shot, fully resolved", term: "snap to black" , instant: true },
|
|
109
337
|
{ id: "match-cut", label: "Match Cut", category: "standard", description: "Shape or motion match across shots",
|
|
110
|
-
promptHint: "match cut: the
|
|
338
|
+
promptHint: "match cut: the last picture of the first shot and the first picture of the second share one shape, at the same place and the same size in the frame. The camera holds that shape in place across the cut. On the next frame everything around the shape has changed while the shape itself stays put. The shot ends on the second shot, fully resolved, with no flash frame or zoom between the two" , instant: true },
|
|
111
339
|
{ id: "smash-cut", label: "Smash Cut", category: "standard", description: "Jarring abrupt cut between contrasting shots",
|
|
112
340
|
promptHint: "smash cut: an abrupt jarring transition between two visually or tonally contrasting shots with no fade, on a beat" , instant: true },
|
|
113
341
|
{ id: "iris", label: "Iris", category: "standard", description: "Circular iris closes, then opens on second",
|
|
114
342
|
promptHint: "iris transition: a circular vignette closes inward over the first shot until the frame is black, then opens outward to reveal the second shot", term: "iris wipe" },
|
|
115
|
-
{ id: "wipe",
|
|
116
|
-
|
|
343
|
+
withOptionSlots({ id: "wipe", label: "Wipe", category: "standard", description: "Linear wipe replaces first shot",
|
|
344
|
+
promptTemplate: "linear wipe transition: a clean {wipeDirection}, revealing the second shot behind it", term: "linear wipe",
|
|
345
|
+
options: [WIPE_DIRECTION] }),
|
|
117
346
|
{ id: "roll-transition", label: "Roll", category: "standard", description: "Frame rolls 90-180°, second shot upright on landing",
|
|
118
|
-
promptHint: "the
|
|
347
|
+
promptHint: "the picture rolls around its centre in one smooth, fast turn, blurred by the speed of the turn. The camera stays in the same spot, turning only around its lens axis. During the turn the second shot takes over, and the roll slows and stops with it level and upright. The shot ends on the second shot, level, upright and still. The roll turns one way only and stops once, with no swing back", term: "camera roll transition" },
|
|
119
348
|
{ id: "seamless-match", label: "Seamless Match", category: "standard", description: "Hidden cut disguised by matched motion and color",
|
|
120
|
-
promptHint: "hidden seamless transition: the camera motion, color palette, and on-screen motion at the end of the first shot continue exactly across the cut into the second shot, so the boundary is invisible and the two shots feel like one unbroken take", term: "invisible cut" , instant: true },
|
|
349
|
+
promptHint: "hidden seamless transition: the camera motion, color palette, and on-screen motion at the end of the first shot continue exactly across the cut into the second shot, so the boundary is invisible and the two shots feel like one unbroken take", term: "invisible cut" , instant: true, blendable: true },
|
|
121
350
|
{ id: "whip-pan", label: "Whip Pan", category: "standard", description: "Camera whips sideways into blur, next shot rides the same direction",
|
|
122
351
|
promptHint: "whip pan transition: the camera whips sideways at high speed, smearing the frame into heavy horizontal motion blur, and the second shot enters already travelling in the same direction before it settles into its framing", term: "whip pan" },
|
|
123
352
|
{ id: "jump-cut", label: "Jump Cut", category: "standard", description: "Same framing, time skips forward",
|
|
124
|
-
promptHint: "jump cut: the framing, lens, and camera position stay identical across the cut while time skips abruptly forward, so the subject snaps to a new position inside what still reads as one continuous shot", term: "jump cut" , instant: true },
|
|
353
|
+
promptHint: "jump cut: the framing, lens, and camera position stay identical across the cut while time skips abruptly forward, so the subject snaps to a new position inside what still reads as one continuous shot. No leap, no run, no lunge and no motion blur between the two positions; the subject is in the old place on one frame and already in the new place on the next", term: "jump cut" , instant: true },
|
|
125
354
|
|
|
126
355
|
// ============================================================================
|
|
127
356
|
// TIME — 8 entries — temporal shifts (same or related scene, different time, or memory)
|
|
128
357
|
// ============================================================================
|
|
129
358
|
{ id: "fast-forward-day-night", label: "Fast-Forward (Day → Night)", category: "time", description: "Time-lapse day to night same scene",
|
|
130
|
-
promptHint: "fast-forward time-lapse transition: the
|
|
359
|
+
promptHint: "fast-forward time-lapse transition: the same view with framing locked while hours pass in seconds: light and shadows sweep across the scene, daylight warms to dusk and fades to night, lights come on, until the picture matches the end frame", term: "day-to-night time-lapse" },
|
|
131
360
|
{ id: "fast-forward-night-day", label: "Fast-Forward (Night → Day)", category: "time", description: "Time-lapse night to dawn same scene",
|
|
132
361
|
promptHint: "fast-forward time-lapse transition: stars fade, the sky shifts from deep night through pre-dawn blue to golden sunrise, shadows sweep in reverse, all while framing and camera position remain locked on the same scene", term: "night-to-day time-lapse" },
|
|
133
362
|
{ id: "seasonal-shift", label: "Seasonal Shift", category: "time", description: "Same scene through changing seasons",
|
|
134
|
-
promptHint: "accelerated seasonal time-lapse:
|
|
363
|
+
promptHint: "accelerated seasonal time-lapse: the same view races through the seasons in fast motion, from the season of the first shot to the season of the second, as growing things bud, turn and fall and snow comes or goes. The camera stays where it is and the framing does not change. The change flows continuously across the whole picture, every part moving on together, until the view matches the second shot. The shot ends on the second shot's season, still and fully resolved. Only the season changes, and the layout of the view stays exactly the same", term: "seasonal time-lapse" },
|
|
135
364
|
{ id: "aging", label: "Aging", category: "time", description: "Subject visibly ages forward in time",
|
|
136
365
|
promptHint: "accelerated aging transition: the subject visibly ages forward - fine lines deepen into wrinkles, hair greys to silver, posture settles - while the framing stays unchanged", term: "accelerated aging" },
|
|
137
366
|
{ id: "rewind", label: "Rewind", category: "time", description: "Time reverses, motion plays backward",
|
|
@@ -141,7 +370,7 @@ export const TRANSITIONS: ReadonlyArray<Transition> = [
|
|
|
141
370
|
{ id: "weather-shift", label: "Weather Shift", category: "time", description: "Same scene through changing weather",
|
|
142
371
|
promptHint: "accelerated weather transition: same scene, framing locked — clear sky darkens to storm clouds, rain begins and intensifies then clears, sun returns through breaking clouds", term: "weather time-lapse" },
|
|
143
372
|
{ id: "flashback", label: "Flashback", category: "time", description: "Memory-flashback into a past moment of the subject",
|
|
144
|
-
promptHint: "brief flashback transition:
|
|
373
|
+
promptHint: "brief flashback transition: a soft warm wash spreads over the whole picture and a faint ripple drifts across it as the present moment fades. The camera stays where it is and the framing does not change. Through the ripple an earlier moment of the same subject comes into focus, like a memory. The shot ends on the remembered moment, steady and fully resolved, with the ripple gone. The subject stays at the same place in the frame while the moment around it changes" },
|
|
145
374
|
|
|
146
375
|
// ============================================================================
|
|
147
376
|
// ELEMENT — 14 entries — teleport via natural element
|
|
@@ -154,24 +383,29 @@ export const TRANSITIONS: ReadonlyArray<Transition> = [
|
|
|
154
383
|
promptHint: "the subject crumbles into fine sand that is swept away by a gust of wind in a swirling vortex, then the sand particles converge and re-form into the new subject" },
|
|
155
384
|
{ id: "fire-burnup", label: "Burn-Up", category: "element", description: "Subject burns to embers, embers reform",
|
|
156
385
|
promptHint: "the subject ignites and burns from edges inward into glowing embers and ash, the embers swirl through the frame and re-ignite into the new subject", term: "burn to embers and reform" },
|
|
157
|
-
{ id: "smoke-puff", label: "Smoke Puff", category: "element", description: "Subject vanishes in smoke, reappears",
|
|
158
|
-
|
|
386
|
+
withOptionSlots({ id: "smoke-puff", label: "Smoke Puff", category: "element", description: "Subject vanishes in smoke, reappears",
|
|
387
|
+
promptTemplate: "{style}",
|
|
388
|
+
options: [SMOKE_PUFF_STYLE] }),
|
|
159
389
|
{ id: "magic-sparkles", label: "Magic Sparkles", category: "element", description: "Particle dissolve à la Avengers / apparition",
|
|
160
390
|
promptHint: "the subject disintegrates into a cloud of glowing golden sparkles that scatter outward, then the sparkles converge from across the frame and re-coalesce into the new subject" },
|
|
161
391
|
{ id: "lightning-flash", label: "Lightning Strike", category: "element", description: "Lightning strikes, scene changes in flash",
|
|
162
392
|
promptHint: "a brilliant jagged bolt of lightning cracks across the frame and its flash turns the whole picture white. The camera stays where it is and the framing does not change. As the flash dies away, the second shot is revealed in its place. The shot ends on the second shot, fully resolved, with no bolt or flash left. The change happens inside the flash, and the second shot appears only as the white fades" },
|
|
163
393
|
{ id: "ink-splash", label: "Ink Splash", category: "element", description: "Ink splashes across, scene changes",
|
|
164
394
|
promptHint: "black ink splashes across the frame in expanding tendrils that fully cover the image, then the ink retracts inward and pulls back to reveal the new scene" },
|
|
165
|
-
{ id: "sand-storm", label: "Sand Storm", category: "element", description: "Sand storm engulfs the frame, scene changes inside",
|
|
166
|
-
|
|
395
|
+
withOptionSlots({ id: "sand-storm", label: "Sand Storm", category: "element", description: "Sand storm engulfs the frame, scene changes inside",
|
|
396
|
+
promptTemplate: "{style}",
|
|
397
|
+
options: [SAND_STORM_STYLE] }),
|
|
167
398
|
{ id: "paint-splash", label: "Paint Splash", category: "element", description: "Vivid paint splash covers, retracts into new scene",
|
|
168
399
|
promptHint: "vivid splashes of coloured paint fly across the frame in arcing streaks and pile over one another until the whole picture is covered in wet paint. The camera stays where it is and the framing does not change. The paint then gathers toward the centre of the frame and shrinks to nothing, uncovering the second shot from the edges inward. The shot ends on the second shot, clean and fully resolved, with no paint left. The paint lies on the surface of the picture itself, and the second shot appears only where the paint has gone" },
|
|
169
|
-
{ id: "aurora-sweep", label: "Aurora Sweep", category: "element", description: "Aurora curtain sweeps across, scene changes behind",
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
400
|
+
withOptionSlots({ id: "aurora-sweep", label: "Aurora Sweep", category: "element", description: "Aurora curtain sweeps across, scene changes behind",
|
|
401
|
+
promptTemplate: "{style}",
|
|
402
|
+
options: [AURORA_SWEEP_STYLE] }),
|
|
403
|
+
withOptionSlots({ id: "sakura-petals", label: "Sakura Storm", category: "element", description: "Cherry blossom petals storm across the frame",
|
|
404
|
+
promptTemplate: "{style}", term: "cherry blossom petal storm",
|
|
405
|
+
options: [SAKURA_PETALS_STYLE] }),
|
|
406
|
+
withOptionSlots({ id: "garden-bloom", label: "Garden Bloom", category: "element", description: "Flowers bloom outward, parting to reveal new scene",
|
|
407
|
+
promptTemplate: "{style}",
|
|
408
|
+
options: [GARDEN_BLOOM_STYLE] }),
|
|
175
409
|
{ id: "powder-burst", label: "Powder Burst", category: "element", description: "Colored powder bursts across frame and clears",
|
|
176
410
|
promptHint: "a burst of vivid colored powder explodes from the center of the frame in slow motion, the cloud of pigment expands to fill the image, then drifts apart and settles to reveal the second scene" },
|
|
177
411
|
|
|
@@ -203,7 +437,7 @@ export const TRANSITIONS: ReadonlyArray<Transition> = [
|
|
|
203
437
|
{ id: "zoom-into-eye", label: "Zoom Into Eye", category: "portal", description: "Push into pupil, new world inside",
|
|
204
438
|
promptHint: "the camera pushes into a tight macro of the subject's eye, the pupil dilates and fills the frame, and the new scene materialises from within the pupil as if the pupil itself were a portal" },
|
|
205
439
|
{ id: "zoom-into-mirror", label: "Zoom Into Mirror", category: "portal", description: "Push into mirror, scene inside reflection",
|
|
206
|
-
promptHint: "the camera pushes
|
|
440
|
+
promptHint: "the camera pushes straight at a mirror in the frame. As the lens meets the glass, the mirror's surface turns liquid and rings ripple out across the whole picture, and the camera keeps moving forward through the rippling surface, out the far side into the second shot. Until the lens touches the glass, the mirror keeps its own reflection. The shot ends in the second shot, still and fully resolved, with no ripples left" },
|
|
207
441
|
{ id: "zoom-into-screen", label: "Zoom Into Screen", category: "portal", description: "Push into TV/phone screen",
|
|
208
442
|
promptHint: "the camera pushes toward a screen visible in the scene, such as a TV, phone or monitor, the screen's image fills the frame, and the camera passes through into that image which becomes the new scene" },
|
|
209
443
|
{ id: "zoom-into-book", label: "Zoom Into Book", category: "portal", description: "Push into book page illustration",
|
|
@@ -234,8 +468,9 @@ export const TRANSITIONS: ReadonlyArray<Transition> = [
|
|
|
234
468
|
promptHint: "a flash at the exact centre of the frame bursts into a sharp, bright ring that races past every edge in a moment, trailing a smear of motion blur behind its rim and warping the picture as it goes. The camera stays where it is and the picture stays level. The second shot shows only inside the ring and the first only outside it, with the bright ring as the one hard border and no blending anywhere" },
|
|
235
469
|
{ id: "punch-into-camera", label: "Punch Into Camera", category: "physics", description: "Fist strikes camera, scene changes",
|
|
236
470
|
promptHint: "a fist or object swings rapidly toward the camera and strikes the lens with motion blur and impact frames, and the moment of impact reveals the new scene" },
|
|
237
|
-
{ id: "debris-shower", label: "Debris Shower", category: "physics", description: "Debris flies past, scene changes behind",
|
|
238
|
-
|
|
471
|
+
withOptionSlots({ id: "debris-shower", label: "Debris Shower", category: "physics", description: "Debris flies past, scene changes behind",
|
|
472
|
+
promptTemplate: "{style}",
|
|
473
|
+
options: [DEBRIS_SHOWER_STYLE] }),
|
|
239
474
|
{ id: "gravity-flip", label: "Gravity Flip", category: "physics", description: "Gravity inverts, camera rotates 180",
|
|
240
475
|
promptHint: "gravity inverts and the camera rotates a full 180 degrees as objects and the subject reorient to the new down, settling into the new scene oriented correctly" },
|
|
241
476
|
{ id: "building-explosion", label: "Building Explosion", category: "physics", description: "Structure detonates, scene shifts through smoke",
|
|
@@ -243,7 +478,7 @@ export const TRANSITIONS: ReadonlyArray<Transition> = [
|
|
|
243
478
|
{ id: "vehicle-explosion", label: "Vehicle Explosion", category: "physics", description: "Vehicle detonates in foreground, scene changes behind",
|
|
244
479
|
promptHint: "a vehicle in the frame bursts into a violent explosion of fire and twisted metal, and the fireball billows toward the lens until orange flame fills the whole picture. The camera stays where it is and the framing does not change. The flame gives way to thick smoke, and as the smoke parts the second shot is revealed. The shot ends on the second shot, clear and fully resolved, with no fire or smoke left. The explosion comes from that vehicle itself, and the second shot appears only as the smoke parts" },
|
|
245
480
|
{ id: "jump-match", label: "Jump Match", category: "physics", description: "Subject jumps, landing matches into new scene",
|
|
246
|
-
promptHint: "the subject
|
|
481
|
+
promptHint: "the subject launches into a jump, and at the height of the leap the picture cuts to a new place. The camera follows the arc of the jump at the same speed on both sides of the cut, and the subject stays at the same place in the frame. In the second shot the same jump carries on without a break, and the subject comes down and lands in the new place. The shot ends on the subject landed in the second shot, fully resolved", term: "match cut on a jump" , instant: true, blendable: true },
|
|
247
482
|
{ id: "hand-swipe", label: "Hand Swipe", category: "physics", description: "Hand swipes across lens, scene changes during occlusion",
|
|
248
483
|
promptHint: "a hand sweeps across the camera lens at close range, fully occluding the frame in motion blur for a single beat, and as the hand exits the opposite side the scene has changed to the new setting" },
|
|
249
484
|
{ id: "action-relay", label: "Action Match", category: "physics", description: "Subject exits on an action and lands in the new scene mid-move",
|
|
@@ -252,14 +487,15 @@ export const TRANSITIONS: ReadonlyArray<Transition> = [
|
|
|
252
487
|
// ============================================================================
|
|
253
488
|
// LIGHT — 8 entries — flash and lens FX
|
|
254
489
|
// ============================================================================
|
|
255
|
-
{ id: "white-flash", label: "White Flash", category: "light", description: "Frame blooms to white",
|
|
256
|
-
|
|
490
|
+
withOptionSlots({ id: "white-flash", label: "White Flash", category: "light", description: "Frame blooms to white",
|
|
491
|
+
promptTemplate: "{style}",
|
|
492
|
+
options: [WHITE_FLASH_STYLE] }),
|
|
257
493
|
{ id: "lens-flare-swipe", label: "Lens Flare Swipe", category: "light", description: "Anamorphic lens flare swipes",
|
|
258
494
|
promptHint: "a horizontal anamorphic lens flare sweeps across the frame from one side to the other, and as it crosses the frame the scene behind it has changed to the new setting" },
|
|
259
495
|
{ id: "light-streak", label: "Light Streak", category: "light", description: "Light streak wipes across",
|
|
260
496
|
promptHint: "a bright streak of light races across the frame leaving motion-blur trails, and as the streak exits the opposite side the scene is now the new setting" },
|
|
261
497
|
{ id: "color-invert", label: "Color Invert Flash", category: "light", description: "Colors invert briefly",
|
|
262
|
-
promptHint: "
|
|
498
|
+
promptHint: "the colours of the whole picture turn to their photographic negative in an instant. The camera stays where it is and the framing does not change. The negative holds for a single beat, and when the colours snap back to normal the second shot is in place. The shot ends on the second shot in natural colour, fully resolved. The change of shot happens while the picture is in negative, and the picture stays upright and in place throughout" },
|
|
263
499
|
{ id: "sun-glare", label: "Sun Glare", category: "light", description: "Sun glare washes frame",
|
|
264
500
|
promptHint: "intense warm glare floods the lens from one corner of the frame, blooming and scattering flares until the whole picture is washed out to a bright haze. The camera stays where it is and the framing does not change. As the glare fades, the second shot emerges through the haze. The shot ends on the second shot, clear and fully resolved, with no glare left. The glare comes from the lens itself, and the second shot appears only as the haze clears" },
|
|
265
501
|
{ id: "lens-crack", label: "Lens Crack", category: "light", description: "Lens cracks, scene through fractured glass",
|
|
@@ -277,15 +513,15 @@ export const TRANSITIONS: ReadonlyArray<Transition> = [
|
|
|
277
513
|
{ id: "vhs-rewind", label: "VHS Rewind", category: "glitch", description: "VHS tracking distortion",
|
|
278
514
|
promptHint: "VHS-style tracking distortion and tape-rewind artifacts sweep the frame with horizontal scanline noise, then resolve into the new scene as if rewinding to a different recording" },
|
|
279
515
|
{ id: "datamosh", label: "Datamosh", category: "glitch", description: "Motion-vector smear bleeds scenes",
|
|
280
|
-
promptHint: "datamosh transition: the
|
|
516
|
+
promptHint: "datamosh transition: the first shot's pixels tear loose in square compression blocks and slide sideways across the frame in long smeared streaks. The camera stays where it is and the framing does not change. The blocks pile up into the shapes of the second shot, still in the first shot's colours, until the true colours snap in block by block. The shot ends on the second shot, sharp and fully resolved, with no smears or blocks left. The smear covers the whole frame" },
|
|
281
517
|
{ id: "channel-flip", label: "Channel Flip", category: "glitch", description: "TV channel flip with static",
|
|
282
|
-
promptHint: "a brief burst of
|
|
518
|
+
promptHint: "the whole picture breaks into a brief burst of black-and-white static, the image jumping and tearing as if the channel were being changed. The camera stays where it is and the framing does not change. The static clears as quickly as it came, and the second shot snaps in, steady, like the next channel. The shot ends on the second shot, clean and fully resolved, with no static left. The static covers the whole frame, so the picture itself is what changes channel", term: "tv channel flip with static" },
|
|
283
519
|
{ id: "hologram-flicker", label: "Hologram Flicker", category: "glitch", description: "Hologram-style flicker materialises new scene",
|
|
284
|
-
promptHint: "
|
|
520
|
+
promptHint: "the first shot breaks into thin lines of cyan light that flicker out from the top down, leaving the frame black. The camera stays where it is and the framing does not change. A bright scan line sweeps down and draws the second shot behind it in flickering cyan lines that steady into natural colour. The shot ends on the second shot, solid and fully resolved, with no scan lines left. The second shot is drawn only on black, never over the first shot" },
|
|
285
521
|
{ id: "display-wipe", label: "Display Wipe", category: "glitch", description: "Scene compresses into display, expands to new scene",
|
|
286
|
-
promptHint: "the first
|
|
522
|
+
promptHint: "the whole first shot shrinks toward the centre of the frame into a small glowing rectangle, then collapses to a bright line and a dot as if its power were cut. The camera stays where it is and the framing does not change. From the dot a line snaps open and widens into a rectangle showing the second shot, which grows until it fills the frame. The shot ends on the second shot, full frame and fully resolved, with no border or scanlines left. The picture itself shrinks and grows on a black field, with the camera holding still throughout", term: "compress into a screen and expand out" },
|
|
287
523
|
{ id: "double-exposure", label: "Double Exposure", category: "glitch", description: "Two scenes overlay translucent, first fades to second",
|
|
288
|
-
promptHint: "the first
|
|
524
|
+
promptHint: "the first subject becomes a crisp silhouette filled solid with the second shot, while the rest of the first shot stays untouched around it. The camera stays where it is and the framing does not change. The silhouette holds for a clear beat, both pictures plainly seen at once, then the first shot around it gives way to the second shot. The shot ends on the second shot alone, fully resolved" },
|
|
289
525
|
]
|
|
290
526
|
|
|
291
527
|
export const TRANSITION_CATEGORY_ORDER: ReadonlyArray<TransitionCategory> = [
|
|
@@ -319,8 +555,54 @@ export function getTransitionLabel(id: string | undefined | null, fallback?: str
|
|
|
319
555
|
return (id ?? "").replace(/-/g, " ").replace(/\b\w/g, (c) => c.toUpperCase())
|
|
320
556
|
}
|
|
321
557
|
|
|
322
|
-
|
|
323
|
-
|
|
558
|
+
/**
|
|
559
|
+
* The row's hint. With `values`, a row that declares `options` is written with
|
|
560
|
+
* the chosen choices in its template (an absent or unknown choice reads as
|
|
561
|
+
* `auto`, which IS `promptHint`); every other row ignores them.
|
|
562
|
+
*
|
|
563
|
+
* A catalog pack that REWROTE the row's hint owns its words: its text has no
|
|
564
|
+
* token to fill, so it is returned as written and the options add nothing.
|
|
565
|
+
*/
|
|
566
|
+
export function getTransitionPromptHint(
|
|
567
|
+
id: string | undefined | null,
|
|
568
|
+
values?: TransitionOptionValues,
|
|
569
|
+
): string {
|
|
570
|
+
const t = getTransition(id)
|
|
571
|
+
if (!t) return ""
|
|
572
|
+
if (!values || !t.promptTemplate || !t.options?.length) return t.promptHint
|
|
573
|
+
if (t.promptHint !== fillOptionSlots(t.promptTemplate, t.options)) return t.promptHint
|
|
574
|
+
return fillOptionSlots(t.promptTemplate, t.options, values)
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
/** The per-row options a transition declares (`[]` for most rows, an unknown
|
|
578
|
+
* id and `auto`). Reads through `getTransition`, like every other getter. */
|
|
579
|
+
export function getTransitionOptions(
|
|
580
|
+
id: string | undefined | null,
|
|
581
|
+
): ReadonlyArray<TransitionOption> {
|
|
582
|
+
return getTransition(id)?.options ?? []
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
/** Every option field any row declares, in catalog order — what a node-data
|
|
586
|
+
* reader collects into `TransitionOptionValues`. */
|
|
587
|
+
export const TRANSITION_OPTION_FIELDS: ReadonlyArray<string> = Array.from(
|
|
588
|
+
new Set(TRANSITIONS.flatMap((t) => (t.options ?? []).map((o) => o.field))),
|
|
589
|
+
)
|
|
590
|
+
|
|
591
|
+
/**
|
|
592
|
+
* Collect `TransitionOptionValues` from a node-data-shaped record: every
|
|
593
|
+
* declared option field whose value is a non-empty string. Validation against
|
|
594
|
+
* a row's choices happens at render (an unknown id reads as `auto`).
|
|
595
|
+
*/
|
|
596
|
+
export function readTransitionOptionValues(
|
|
597
|
+
data: Readonly<Record<string, unknown>> | undefined | null,
|
|
598
|
+
): TransitionOptionValues | undefined {
|
|
599
|
+
if (!data) return undefined
|
|
600
|
+
const out: Record<string, string> = {}
|
|
601
|
+
for (const field of TRANSITION_OPTION_FIELDS) {
|
|
602
|
+
const v = data[field]
|
|
603
|
+
if (typeof v === "string" && v.length > 0) out[field] = v
|
|
604
|
+
}
|
|
605
|
+
return Object.keys(out).length > 0 ? out : undefined
|
|
324
606
|
}
|
|
325
607
|
|
|
326
608
|
/**
|
|
@@ -355,6 +637,25 @@ export function isInstantTransition(
|
|
|
355
637
|
return ids.every((one) => getTransition(one)?.instant === true)
|
|
356
638
|
}
|
|
357
639
|
|
|
640
|
+
/**
|
|
641
|
+
* Whether a pick may become a BLENDED CUT (see `Transition.blendable`): every
|
|
642
|
+
* picked id is a blendable cut. Same lookup and same empty-pick answer as
|
|
643
|
+
* `isInstantTransition`, so a pick is blendable only where it is instant. Like
|
|
644
|
+
* that check, it reads the ids it is given: the composer passes only the ids
|
|
645
|
+
* that contribute a fragment, so a no-op "auto" beside a blendable cut does not
|
|
646
|
+
* stop the blend there (`["auto", "seamless-match"]` given whole is `false`).
|
|
647
|
+
*/
|
|
648
|
+
export function isBlendableTransition(
|
|
649
|
+
id: string | ReadonlyArray<string> | undefined | null,
|
|
650
|
+
): boolean {
|
|
651
|
+
const ids = typeof id === "string" ? [id] : id ? [...id] : []
|
|
652
|
+
if (ids.length === 0) return false
|
|
653
|
+
return ids.every((one) => {
|
|
654
|
+
const t = getTransition(one)
|
|
655
|
+
return t?.instant === true && t.blendable === true
|
|
656
|
+
})
|
|
657
|
+
}
|
|
658
|
+
|
|
358
659
|
/**
|
|
359
660
|
* The anti-blend instruction an all-instant transition pick carries — the ONE
|
|
360
661
|
* place this sentence lives (see `Transition.instant`). Each row keeps its own
|
|
@@ -385,8 +686,8 @@ const normalizePhrase = (s: string): string => s.trim().toLowerCase().replace(/\
|
|
|
385
686
|
* any comma-separated item that merely restates the term dropped (`none`'s
|
|
386
687
|
* "no transition, hard cut, instantaneous switch …" loses its "hard cut").
|
|
387
688
|
*/
|
|
388
|
-
function transitionHintBody(id: string, term: string): string {
|
|
389
|
-
const hint = getTransitionPromptHint(id).replace(LEADING_LABEL, "").trim()
|
|
689
|
+
function transitionHintBody(id: string, term: string, values?: TransitionOptionValues): string {
|
|
690
|
+
const hint = getTransitionPromptHint(id, values).replace(LEADING_LABEL, "").trim()
|
|
390
691
|
const t = normalizePhrase(term)
|
|
391
692
|
return hint
|
|
392
693
|
.split(/,\s*/)
|
|
@@ -399,14 +700,16 @@ function transitionHintBody(id: string, term: string): string {
|
|
|
399
700
|
* One picked transition as a VIDEO prompt reads it: `<term> (<hint body>)`.
|
|
400
701
|
* The term names the move in the editor's own words; the parentheses carry the
|
|
401
702
|
* model-facing mechanism, which the term alone does not convey (a bare "match
|
|
402
|
-
* cut" rendered as a dissolve on prod). `
|
|
403
|
-
* `;
|
|
404
|
-
*
|
|
703
|
+
* cut" rendered as a dissolve on prod). A non-empty `cutClause` is appended
|
|
704
|
+
* as `; <cutClause>` inside the parentheses — the hard cut's
|
|
705
|
+
* `INSTANT_CUT_CLAUSE`, or a blended cut's `blendedCutClause`. A row whose hint
|
|
706
|
+
* is just its term (or empty) renders as the term alone; an unknown id or
|
|
707
|
+
* "auto" as "".
|
|
405
708
|
*/
|
|
406
|
-
function transitionFragment(id: string,
|
|
709
|
+
function transitionFragment(id: string, cutClause: string, values?: TransitionOptionValues): string {
|
|
407
710
|
const term = getTransitionTerm(id)
|
|
408
711
|
if (!term) return ""
|
|
409
|
-
const inner = [transitionHintBody(id, term),
|
|
712
|
+
const inner = [transitionHintBody(id, term, values), cutClause]
|
|
410
713
|
.filter((part) => part.length > 0)
|
|
411
714
|
.join("; ")
|
|
412
715
|
return inner.length > 0 && normalizePhrase(inner) !== normalizePhrase(term)
|
|
@@ -436,16 +739,48 @@ function transitionFragment(id: string, withCutClause: boolean): string {
|
|
|
436
739
|
* Studio's transition clauses) and the direction registry's `transition` row
|
|
437
740
|
* (the server fold of `direction.transition`), so both paths word a transition
|
|
438
741
|
* identically.
|
|
742
|
+
*
|
|
743
|
+
* `values` are the per-row option choices (`TransitionOptionValues`) — a wipe's
|
|
744
|
+
* direction. The direction registry passes none, so a `direction.transition`
|
|
745
|
+
* wipe reads as `auto`.
|
|
746
|
+
*
|
|
747
|
+
* These bases are always the HARD cut's: a pick here has no duration, so it
|
|
748
|
+
* never becomes a blended cut. The blend exists only where a duration is
|
|
749
|
+
* picked, in `composeTransitionHintFromConnections`.
|
|
439
750
|
*/
|
|
440
751
|
export function renderTransitionBases(
|
|
441
752
|
ids: ReadonlyArray<string>,
|
|
442
753
|
_mode: PickerHintMode = "full",
|
|
754
|
+
values?: TransitionOptionValues,
|
|
755
|
+
): string[] {
|
|
756
|
+
return renderBasesWith(ids, INSTANT_CUT_CLAUSE, values)
|
|
757
|
+
}
|
|
758
|
+
|
|
759
|
+
/** `renderTransitionBases` with the all-instant pick's clause chosen by the
|
|
760
|
+
* caller — the hard cut's, or a blended cut's. */
|
|
761
|
+
function renderBasesWith(
|
|
762
|
+
ids: ReadonlyArray<string>,
|
|
763
|
+
cutClause: string,
|
|
764
|
+
values?: TransitionOptionValues,
|
|
443
765
|
): string[] {
|
|
444
766
|
// Only ids that contribute a fragment count — a no-op "auto" beside a cut
|
|
445
767
|
// must not make the pick look non-instant.
|
|
446
768
|
const picked = ids.filter((id) => getTransitionTerm(id).length > 0)
|
|
447
769
|
const instant = isInstantTransition(picked)
|
|
448
|
-
return picked.map((id, i) => transitionFragment(id, instant && i === 0))
|
|
770
|
+
return picked.map((id, i) => transitionFragment(id, instant && i === 0 ? cutClause : "", values))
|
|
771
|
+
}
|
|
772
|
+
|
|
773
|
+
/**
|
|
774
|
+
* THE BLENDED CUT — what a blendable cut becomes when a duration that
|
|
775
|
+
* `blendsCut` (Short) is picked on it: instead of the anti-blend
|
|
776
|
+
* `INSTANT_CUT_CLAUSE`, the two shots blend into each other over that step's
|
|
777
|
+
* own `term` ("about 1 second"), so the time words are the catalog's and never
|
|
778
|
+
* typed here. "" for every other value (auto, instant, medium, long, an unknown
|
|
779
|
+
* id, none), which leaves the cut a hard cut.
|
|
780
|
+
*/
|
|
781
|
+
export function blendedCutClause(duration: string | undefined): string {
|
|
782
|
+
const row = TRANSITION_DURATIONS.find((d) => d.id === duration) as TransitionTimingOption | undefined
|
|
783
|
+
return row?.blendsCut ? `instead of a hard cut, the two shots blend into each other over ${row.term}` : ""
|
|
449
784
|
}
|
|
450
785
|
|
|
451
786
|
// ---------------------------------------------------------------------------
|
|
@@ -472,6 +807,12 @@ export interface TransitionTimingOption {
|
|
|
472
807
|
readonly description: string
|
|
473
808
|
readonly promptHint: string
|
|
474
809
|
readonly term?: string
|
|
810
|
+
/** Duration rows only: `true` on a step a blendable CUT may take — picked on
|
|
811
|
+
* such a pick it turns the cut into a BLENDED CUT over this row's `term`
|
|
812
|
+
* (see `blendedCutClause`). Absent = a cut ignores the step and stays a
|
|
813
|
+
* hard cut. Set on `short` only: the 2026-10-04 test round approved the
|
|
814
|
+
* blend at about 1 second, not at Medium's 2. */
|
|
815
|
+
readonly blendsCut?: boolean
|
|
475
816
|
}
|
|
476
817
|
|
|
477
818
|
export const TRANSITION_POSITIONS = [
|
|
@@ -485,7 +826,7 @@ export const TRANSITION_POSITIONS = [
|
|
|
485
826
|
export const TRANSITION_DURATIONS = [
|
|
486
827
|
{ id: "auto", label: "Auto", description: "Let the model time it", promptHint: "", term: "" },
|
|
487
828
|
{ id: "instant", label: "Instant", description: "No perceptible duration", promptHint: "occurring instantaneously", term: "instantaneous" },
|
|
488
|
-
{ id: "short", label: "Short (~1s)", description: "Approximately 1 second", promptHint: "lasting approximately 1 second", term: "about 1 second" },
|
|
829
|
+
{ id: "short", label: "Short (~1s)", description: "Approximately 1 second", promptHint: "lasting approximately 1 second", term: "about 1 second", blendsCut: true },
|
|
489
830
|
{ id: "medium", label: "Medium (~2s)", description: "Approximately 2 seconds", promptHint: "lasting approximately 2 seconds", term: "about 2 seconds" },
|
|
490
831
|
{ id: "long", label: "Long (~3s)", description: "Approximately 3 seconds", promptHint: "lasting approximately 3 seconds", term: "about 3 seconds" },
|
|
491
832
|
] as const satisfies ReadonlyArray<TransitionTimingOption>
|
|
@@ -528,6 +869,9 @@ export type TransitionHintScope = "clip" | "shot"
|
|
|
528
869
|
|
|
529
870
|
export interface TransitionHintOptions {
|
|
530
871
|
readonly scope?: TransitionHintScope
|
|
872
|
+
/** The per-row option choices (a wipe's `wipeDirection`) — see
|
|
873
|
+
* `Transition.options`. Absent, every row reads as `auto`. */
|
|
874
|
+
readonly optionValues?: TransitionOptionValues
|
|
531
875
|
}
|
|
532
876
|
|
|
533
877
|
/**
|
|
@@ -563,31 +907,42 @@ const INTENSITY_CLAUSES = clausesOf(TRANSITION_INTENSITIES)
|
|
|
563
907
|
* first base's parentheses carry `INSTANT_CUT_CLAUSE` once, and the
|
|
564
908
|
* duration and intensity clauses are dropped; position still applies,
|
|
565
909
|
* except `full` — a single-frame cut spans nothing, so it adds no clause
|
|
910
|
+
* - A BLENDED CUT: when every picked id is a blendable cut
|
|
911
|
+
* (`isBlendableTransition`) and the duration `blendsCut` (Short), that
|
|
912
|
+
* clause is `blendedCutClause(duration)` INSTEAD of `INSTANT_CUT_CLAUSE`
|
|
913
|
+
* (replaced, not appended; still once, in the first parentheses); every
|
|
914
|
+
* other rule above stands as for a hard cut, so there is still no duration
|
|
915
|
+
* clause, no intensity clause and no `full`. Any other duration on such a
|
|
916
|
+
* pick, and any duration on any other cut, leaves the hard cut
|
|
566
917
|
* - null input is treated like undefined (falsy short-circuit → returns "")
|
|
567
918
|
*
|
|
568
|
-
* @param
|
|
919
|
+
* @param _mode Accepted for the picker-hint signature; a transition composes
|
|
569
920
|
* the same text in both modes — each pick as `<term> (<hint body>)`, see
|
|
570
921
|
* `renderTransitionBases`.
|
|
571
922
|
* @param options `scope: "shot"` when the hint is folded into one shot's time
|
|
572
923
|
* window of a multi-shot prompt — the position clause then says "of this
|
|
573
924
|
* shot" instead of "of the clip" (and `full`, on a non-cut, "spans this
|
|
574
|
-
* entire shot"). Omitted, the wording is the clip's.
|
|
925
|
+
* entire shot"). Omitted, the wording is the clip's. `optionValues` are the
|
|
926
|
+
* per-row option choices (a wipe's direction), written into the row's own
|
|
927
|
+
* description; omitted, every row reads as `auto`.
|
|
575
928
|
*/
|
|
576
929
|
export function composeTransitionHintFromConnections(
|
|
577
930
|
transitionId: string | ReadonlyArray<string> | undefined,
|
|
578
931
|
startHints: ReadonlyArray<string>,
|
|
579
932
|
endHints: ReadonlyArray<string>,
|
|
580
933
|
timing?: TransitionTiming,
|
|
581
|
-
|
|
934
|
+
_mode: PickerHintMode = "full",
|
|
582
935
|
options?: TransitionHintOptions,
|
|
583
936
|
): string {
|
|
584
937
|
const ids = Array.isArray(transitionId)
|
|
585
938
|
? Array.from(new Set(transitionId)).slice(0, 2)
|
|
586
939
|
: transitionId ? [transitionId] : []
|
|
587
|
-
const baseHints = renderTransitionBases(ids, mode)
|
|
588
|
-
if (baseHints.length === 0) return ""
|
|
589
940
|
// Same "contributes a fragment" filter `renderTransitionBases` applies.
|
|
590
|
-
const
|
|
941
|
+
const contributing = ids.filter((id) => getTransitionTerm(id).length > 0)
|
|
942
|
+
const instant = isInstantTransition(contributing)
|
|
943
|
+
const blend = instant && isBlendableTransition(contributing) ? blendedCutClause(timing?.duration) : ""
|
|
944
|
+
const baseHints = renderBasesWith(ids, blend || INSTANT_CUT_CLAUSE, options?.optionValues)
|
|
945
|
+
if (baseHints.length === 0) return ""
|
|
591
946
|
|
|
592
947
|
const combinedBase = baseHints.join(", and ")
|
|
593
948
|
const parts: string[] = [combinedBase]
|
|
@@ -601,7 +956,8 @@ export function composeTransitionHintFromConnections(
|
|
|
601
956
|
// A cut has no duration and no performance: "lasting approximately 1
|
|
602
957
|
// second" or "with natural timing" on a match cut makes the model
|
|
603
958
|
// render a dissolve. Skipped only when EVERY picked id is instant — a mixed
|
|
604
|
-
// pick still has a non-cut to time and shape.
|
|
959
|
+
// pick still has a non-cut to time and shape. (A blended cut's duration has
|
|
960
|
+
// already done its work above, inside the parentheses.)
|
|
605
961
|
if (timing?.duration && timing.duration !== "auto" && !instant) {
|
|
606
962
|
parts.push(DURATION_CLAUSES[timing.duration])
|
|
607
963
|
}
|