@nodaro/prompts 1.21.0 → 1.25.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 +191 -45
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +362 -207
- package/dist/index.d.ts +362 -207
- package/dist/index.js +183 -46
- package/dist/index.js.map +1 -1
- package/package.json +2 -2
- package/src/__tests__/assemble-video-input.test.ts +20 -12
- package/src/__tests__/camera-motion-approved.test.ts +46 -0
- package/src/__tests__/camera-motions-from-connections.test.ts +3 -2
- package/src/__tests__/catalog-packs.test.ts +13 -0
- package/src/__tests__/character-fx-timing-catalogs.test.ts +3 -2
- package/src/__tests__/factory-presets.test.ts +28 -3
- package/src/__tests__/fixtures/camera-motion-approved.json +330 -0
- package/src/__tests__/fixtures/parameter-hint-golden.json +32 -16
- package/src/__tests__/hint-join.test.ts +3 -2
- package/src/__tests__/node-prompt-fields.test.ts +2 -2
- package/src/__tests__/parameter-hint-mode.test.ts +17 -5
- package/src/__tests__/prompt-style-section.test.ts +2 -2
- package/src/__tests__/transitions-instant.test.ts +242 -0
- package/src/__tests__/transitions-scope.test.ts +160 -0
- package/src/__tests__/transitions.test.ts +9 -9
- package/src/ad-creative-analysis.ts +99 -0
- package/src/camera-motions.ts +21 -21
- package/src/catalog-packs.ts +13 -2
- package/src/direction-registry.ts +2 -2
- package/src/factory-presets/generate-video.ts +22 -0
- package/src/index.ts +3 -0
- package/src/node-prompt-fields.ts +4 -0
- package/src/picker-catalogs.ts +13 -0
- package/src/ref-binding.ts +19 -0
- package/src/transitions.ts +204 -27
- package/src/video-reference-resolver.ts +1 -1
package/src/transitions.ts
CHANGED
|
@@ -41,6 +41,30 @@ export interface Transition {
|
|
|
41
41
|
* "invisible cut"). Everywhere else the label IS the term.
|
|
42
42
|
*/
|
|
43
43
|
readonly term?: string
|
|
44
|
+
/**
|
|
45
|
+
* `true` on rows whose mechanism IS A CUT — the change happens between two
|
|
46
|
+
* frames, so it has no duration to time. A duration clause ("lasting
|
|
47
|
+
* approximately 1 second") on such a row tells the video model to spend a
|
|
48
|
+
* second on the change, and it obliges with a dissolve: a match cut rendered
|
|
49
|
+
* as a 1.75 s cross-dissolve in QA. The composer therefore skips the duration
|
|
50
|
+
* lever when every picked transition is instant, and consumers (the picker
|
|
51
|
+
* UI, Studio) read `isInstantTransition` to hide that lever.
|
|
52
|
+
*
|
|
53
|
+
* The same holds for INTENSITY: every intensity clause describes how the
|
|
54
|
+
* change PERFORMS over time ("natural unhurried timing", "wild flourishes and
|
|
55
|
+
* dramatic distortion"), and a cut has no performance to shape — "unhurried"
|
|
56
|
+
* on a match cut is another invitation to blend. So the composer drops the
|
|
57
|
+
* intensity clause too when every pick is instant. Position still applies —
|
|
58
|
+
* WHERE the cut lands is a real choice — except `full`: a single-frame cut
|
|
59
|
+
* cannot "span the entire clip", so that clause is dropped on an all-instant
|
|
60
|
+
* pick (start / middle / end still place the cut).
|
|
61
|
+
*
|
|
62
|
+
* The bare term is not enough on its own either: "match cut" still came back
|
|
63
|
+
* as a ~1 s superimposition in prod QA (seedance-2-5). The composer therefore
|
|
64
|
+
* puts `INSTANT_CUT_CLAUSE` inside an all-instant pick's parentheses, once — the explicit
|
|
65
|
+
* anti-blend instruction that made the model render a true single-frame cut.
|
|
66
|
+
*/
|
|
67
|
+
readonly instant?: boolean
|
|
44
68
|
}
|
|
45
69
|
|
|
46
70
|
/**
|
|
@@ -73,7 +97,7 @@ export const TRANSITIONS: ReadonlyArray<Transition> = [
|
|
|
73
97
|
// ============================================================================
|
|
74
98
|
{ id: "auto", label: "Auto", category: "standard", description: "Let the model choose", promptHint: "" },
|
|
75
99
|
{ id: "none", label: "None / Hard Cut", category: "standard", description: "Instantaneous switch, no transition",
|
|
76
|
-
promptHint: "no transition, hard cut, instantaneous switch from first shot to second shot", term: "hard cut" },
|
|
100
|
+
promptHint: "no transition, hard cut, instantaneous switch from first shot to second shot", term: "hard cut" , instant: true },
|
|
77
101
|
{ id: "cross-dissolve", label: "Cross-Dissolve", category: "standard", description: "Gradual blend between shots",
|
|
78
102
|
promptHint: "smooth cross-dissolve transition where the first shot gradually fades out as the second shot fades in" },
|
|
79
103
|
{ id: "fade-to-black", label: "Fade to Black", category: "standard", description: "Darkens to black, second emerges",
|
|
@@ -81,11 +105,11 @@ export const TRANSITIONS: ReadonlyArray<Transition> = [
|
|
|
81
105
|
{ id: "fade-to-white", label: "Fade to White", category: "standard", description: "Blooms to white, second emerges",
|
|
82
106
|
promptHint: "fade to white: the first shot brightens until the frame is pure white, then the second shot resolves out of the white" },
|
|
83
107
|
{ id: "snap-to-black", label: "Snap to Black", category: "standard", description: "Instant cut to full black for a beat, then the next shot",
|
|
84
|
-
promptHint: "snap to black: the first shot cuts instantly to full black with no fade, the frame holds pure black for a single beat, then the second shot cuts in at full brightness", term: "snap to black" },
|
|
108
|
+
promptHint: "snap to black: the first shot cuts instantly to full black with no fade, the frame holds pure black for a single beat, then the second shot cuts in at full brightness", term: "snap to black" , instant: true },
|
|
85
109
|
{ id: "match-cut", label: "Match Cut", category: "standard", description: "Shape or motion match across shots",
|
|
86
|
-
promptHint: "match cut: the final composition of the first shot matches the opening composition of the second shot in shape, color, and motion, so the cut feels like a visual rhyme" },
|
|
110
|
+
promptHint: "match cut: the final composition of the first shot matches the opening composition of the second shot in shape, color, and motion, so the cut feels like a visual rhyme" , instant: true },
|
|
87
111
|
{ id: "smash-cut", label: "Smash Cut", category: "standard", description: "Jarring abrupt cut between contrasting shots",
|
|
88
|
-
promptHint: "smash cut: an abrupt jarring transition between two visually or tonally contrasting shots with no fade, on a beat" },
|
|
112
|
+
promptHint: "smash cut: an abrupt jarring transition between two visually or tonally contrasting shots with no fade, on a beat" , instant: true },
|
|
89
113
|
{ id: "iris", label: "Iris", category: "standard", description: "Circular iris closes, then opens on second",
|
|
90
114
|
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" },
|
|
91
115
|
{ id: "wipe", label: "Wipe", category: "standard", description: "Linear wipe replaces first shot",
|
|
@@ -93,11 +117,11 @@ export const TRANSITIONS: ReadonlyArray<Transition> = [
|
|
|
93
117
|
{ id: "roll-transition", label: "Roll", category: "standard", description: "Frame rolls 90-180°, second shot upright on landing",
|
|
94
118
|
promptHint: "the frame rolls along the camera axis with a smooth 90 to 180 degree rotation, motion-blurred during the roll, and as the rotation completes the new shot is upright and stable in frame", term: "camera roll transition" },
|
|
95
119
|
{ id: "seamless-match", label: "Seamless Match", category: "standard", description: "Hidden cut disguised by matched motion and color",
|
|
96
|
-
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" },
|
|
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 },
|
|
97
121
|
{ id: "whip-pan", label: "Whip Pan", category: "standard", description: "Camera whips sideways into blur, next shot rides the same direction",
|
|
98
122
|
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" },
|
|
99
123
|
{ id: "jump-cut", label: "Jump Cut", category: "standard", description: "Same framing, time skips forward",
|
|
100
|
-
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" },
|
|
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 },
|
|
101
125
|
|
|
102
126
|
// ============================================================================
|
|
103
127
|
// TIME — 8 entries — temporal shifts (same or related scene, different time, or memory)
|
|
@@ -109,11 +133,11 @@ export const TRANSITIONS: ReadonlyArray<Transition> = [
|
|
|
109
133
|
{ id: "seasonal-shift", label: "Seasonal Shift", category: "time", description: "Same scene through changing seasons",
|
|
110
134
|
promptHint: "accelerated seasonal time-lapse: foliage transitions from spring green to summer lushness to autumn red-gold to winter bare, leaves fall and regrow, snow accumulates and melts, all within the same locked framing", term: "seasonal time-lapse" },
|
|
111
135
|
{ id: "aging", label: "Aging", category: "time", description: "Subject visibly ages forward in time",
|
|
112
|
-
promptHint: "accelerated aging transition: the subject visibly ages forward
|
|
136
|
+
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" },
|
|
113
137
|
{ id: "rewind", label: "Rewind", category: "time", description: "Time reverses, motion plays backward",
|
|
114
138
|
promptHint: "rewind transition: time reverses and all motion plays smoothly backward, water flows up, debris reassembles, the subject's recent actions undo, with a faint VHS-rewind tracking distortion at the edges", term: "reverse-motion rewind" },
|
|
115
139
|
{ id: "freeze-frame-jump", label: "Freeze-Frame Jump", category: "time", description: "Action freezes, jumps forward in time",
|
|
116
|
-
promptHint: "freeze-frame transition: motion
|
|
140
|
+
promptHint: "freeze-frame transition: all motion stops mid-action and the picture holds still for a beat; only then does it jump to the same view hours or days later, everything in new positions, and motion resumes", term: "freeze-frame time jump" },
|
|
117
141
|
{ id: "weather-shift", label: "Weather Shift", category: "time", description: "Same scene through changing weather",
|
|
118
142
|
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" },
|
|
119
143
|
{ id: "flashback", label: "Flashback", category: "time", description: "Memory-flashback into a past moment of the subject",
|
|
@@ -181,7 +205,7 @@ export const TRANSITIONS: ReadonlyArray<Transition> = [
|
|
|
181
205
|
{ id: "zoom-into-mirror", label: "Zoom Into Mirror", category: "portal", description: "Push into mirror, scene inside reflection",
|
|
182
206
|
promptHint: "the camera pushes toward a mirror in the scene, the mirror's reflection fills the frame, and the camera passes through the mirror surface into the reflected world which becomes the new scene" },
|
|
183
207
|
{ id: "zoom-into-screen", label: "Zoom Into Screen", category: "portal", description: "Push into TV/phone screen",
|
|
184
|
-
promptHint: "the camera pushes toward a screen visible in the scene
|
|
208
|
+
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" },
|
|
185
209
|
{ id: "zoom-into-book", label: "Zoom Into Book", category: "portal", description: "Push into book page illustration",
|
|
186
210
|
promptHint: "the camera pushes down into an illustrated page in a book, the illustration grows to fill the frame, and the illustration comes alive as the new scene" },
|
|
187
211
|
{ id: "walk-through-door", label: "Walk Through Doorway", category: "portal", description: "Through doorway into new scene",
|
|
@@ -191,7 +215,7 @@ export const TRANSITIONS: ReadonlyArray<Transition> = [
|
|
|
191
215
|
{ id: "pull-out-reveal", label: "Pull-Out Reveal", category: "portal", description: "Reveals scene was a picture in larger context",
|
|
192
216
|
promptHint: "the camera pulls back rapidly and reveals that the entire first scene was actually contained within a picture, painting, screen, or window in a larger second scene", term: "pull-back reveal" },
|
|
193
217
|
{ id: "zoom-into-mouth", label: "Zoom Into Mouth", category: "portal", description: "Push into open mouth, emerges in new world inside",
|
|
194
|
-
promptHint: "the camera pushes into the subject's open mouth, the dark interior fills the frame, and the camera passes through
|
|
218
|
+
promptHint: "the camera pushes into the subject's open mouth, the dark interior fills the frame, and the camera passes through into the new scene which materialises as if emerging from inside the body" },
|
|
195
219
|
{ id: "push-through-glass", label: "Push Through Glass", category: "portal", description: "Camera pushes through pane of glass into new world",
|
|
196
220
|
promptHint: "the camera pushes toward a pane of glass in the scene, the surface ripples like liquid as the camera passes through with a faint refraction, and the space on the other side resolves as the new scene" },
|
|
197
221
|
{ id: "soul-jump", label: "Soul Jump", category: "portal", description: "Translucent soul leaves body, enters new body",
|
|
@@ -219,11 +243,11 @@ export const TRANSITIONS: ReadonlyArray<Transition> = [
|
|
|
219
243
|
{ id: "vehicle-explosion", label: "Vehicle Explosion", category: "physics", description: "Vehicle detonates in foreground, scene changes behind",
|
|
220
244
|
promptHint: "a vehicle in the foreground erupts in a violent explosion of fire and twisted metal, the fireball expands toward the camera and washes the frame in orange flame, and as the smoke parts the second scene resolves" },
|
|
221
245
|
{ id: "jump-match", label: "Jump Match", category: "physics", description: "Subject jumps, landing matches into new scene",
|
|
222
|
-
promptHint: "the subject jumps upward and out of frame at the end of the first shot, with matched velocity the camera follows the arc, and on landing the subject is in a new location seamlessly continuing the same jump", term: "match cut on a jump" },
|
|
246
|
+
promptHint: "the subject jumps upward and out of frame at the end of the first shot, with matched velocity the camera follows the arc, and on landing the subject is in a new location seamlessly continuing the same jump", term: "match cut on a jump" , instant: true },
|
|
223
247
|
{ id: "hand-swipe", label: "Hand Swipe", category: "physics", description: "Hand swipes across lens, scene changes during occlusion",
|
|
224
248
|
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" },
|
|
225
249
|
{ id: "action-relay", label: "Action Match", category: "physics", description: "Subject exits on an action and lands in the new scene mid-move",
|
|
226
|
-
promptHint: "match cut on action: the subject exits the frame on a committed action — a stride, a throw, a turn — and enters the new scene on the same beat continuing that movement at matched speed and direction, so the action carries unbroken across the cut", term: "match cut on action" },
|
|
250
|
+
promptHint: "match cut on action: the subject exits the frame on a committed action — a stride, a throw, a turn — and enters the new scene on the same beat continuing that movement at matched speed and direction, so the action carries unbroken across the cut", term: "match cut on action" , instant: true },
|
|
227
251
|
|
|
228
252
|
// ============================================================================
|
|
229
253
|
// LIGHT — 8 entries — flash and lens FX
|
|
@@ -314,6 +338,116 @@ export function getTransitionTerm(id: string | undefined | null): string {
|
|
|
314
338
|
|
|
315
339
|
export const TRANSITION_IDS: ReadonlyArray<string> = TRANSITIONS.map((t) => t.id)
|
|
316
340
|
|
|
341
|
+
/**
|
|
342
|
+
* Whether a transition is a CUT — instantaneous by nature, so it takes no
|
|
343
|
+
* duration (see `Transition.instant`). Reads through `getTransition`, so it
|
|
344
|
+
* answers for the same entry every other getter describes; an unknown id, the
|
|
345
|
+
* no-op "auto" and an empty value are all `false`.
|
|
346
|
+
*
|
|
347
|
+
* A multi-pick (`string[]`) is instant only when EVERY picked id is: a cut
|
|
348
|
+
* paired with a dissolve still has a dissolve to time.
|
|
349
|
+
*/
|
|
350
|
+
export function isInstantTransition(
|
|
351
|
+
id: string | ReadonlyArray<string> | undefined | null,
|
|
352
|
+
): boolean {
|
|
353
|
+
const ids = typeof id === "string" ? [id] : id ? [...id] : []
|
|
354
|
+
if (ids.length === 0) return false
|
|
355
|
+
return ids.every((one) => getTransition(one)?.instant === true)
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
/**
|
|
359
|
+
* The anti-blend instruction an all-instant transition pick carries — the ONE
|
|
360
|
+
* place this sentence lives (see `Transition.instant`). Each row keeps its own
|
|
361
|
+
* meaning in its own hint (a match cut still matches shapes, snap to black
|
|
362
|
+
* still holds black for a beat); this clause only forbids the blend a video
|
|
363
|
+
* model otherwise puts between the two images. Measured on prod with
|
|
364
|
+
* seedance-2-5: with it, a match cut renders as a true single-frame hard cut,
|
|
365
|
+
* with and without an end frame; without it, as a ~1 s dissolve.
|
|
366
|
+
*/
|
|
367
|
+
export const INSTANT_CUT_CLAUSE =
|
|
368
|
+
"an abrupt single-frame hard cut, no dissolve, crossfade or superimposition; the two images never blend"
|
|
369
|
+
|
|
370
|
+
/**
|
|
371
|
+
* A short "<label>: " heading at the head of a hint ("match cut: …", "whip pan
|
|
372
|
+
* transition: …", "fast-forward time-lapse transition: …"). At most five words
|
|
373
|
+
* with no clause punctuation, so a colon deep inside a sentence never matches.
|
|
374
|
+
* ANY such heading is stripped — including one on a catalog-pack row — because
|
|
375
|
+
* the term already names the move (documented in
|
|
376
|
+
* `docs/design/catalog-pack-seam.md` and `docs/nodes/parameters/transition.md`).
|
|
377
|
+
*/
|
|
378
|
+
const LEADING_LABEL = /^\s*((?:[^\s:,;.()]+\s+){0,4}[^\s:,;.()]+)\s*:\s+/
|
|
379
|
+
|
|
380
|
+
const normalizePhrase = (s: string): string => s.trim().toLowerCase().replace(/\s+/g, " ")
|
|
381
|
+
|
|
382
|
+
/**
|
|
383
|
+
* The hint BODY that goes inside a transition's parentheses: the promptHint
|
|
384
|
+
* with its leading "<label>:" heading removed (the term already names it) and
|
|
385
|
+
* any comma-separated item that merely restates the term dropped (`none`'s
|
|
386
|
+
* "no transition, hard cut, instantaneous switch …" loses its "hard cut").
|
|
387
|
+
*/
|
|
388
|
+
function transitionHintBody(id: string, term: string): string {
|
|
389
|
+
const hint = getTransitionPromptHint(id).replace(LEADING_LABEL, "").trim()
|
|
390
|
+
const t = normalizePhrase(term)
|
|
391
|
+
return hint
|
|
392
|
+
.split(/,\s*/)
|
|
393
|
+
.filter((item) => normalizePhrase(item) !== t)
|
|
394
|
+
.join(", ")
|
|
395
|
+
.trim()
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
/**
|
|
399
|
+
* One picked transition as a VIDEO prompt reads it: `<term> (<hint body>)`.
|
|
400
|
+
* The term names the move in the editor's own words; the parentheses carry the
|
|
401
|
+
* model-facing mechanism, which the term alone does not convey (a bare "match
|
|
402
|
+
* cut" rendered as a dissolve on prod). `withCutClause` appends
|
|
403
|
+
* `; INSTANT_CUT_CLAUSE` inside the parentheses. A row whose hint is just its
|
|
404
|
+
* term (or empty) renders as the term alone; an unknown id or "auto" as "".
|
|
405
|
+
*/
|
|
406
|
+
function transitionFragment(id: string, withCutClause: boolean): string {
|
|
407
|
+
const term = getTransitionTerm(id)
|
|
408
|
+
if (!term) return ""
|
|
409
|
+
const inner = [transitionHintBody(id, term), withCutClause ? INSTANT_CUT_CLAUSE : ""]
|
|
410
|
+
.filter((part) => part.length > 0)
|
|
411
|
+
.join("; ")
|
|
412
|
+
return inner.length > 0 && normalizePhrase(inner) !== normalizePhrase(term)
|
|
413
|
+
? `${term} (${inner})`
|
|
414
|
+
: term
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
/**
|
|
418
|
+
* The transition BASE fragments for a pick — one `<term> (<hint body>)` per id
|
|
419
|
+
* that resolves to a term, in pick order. When EVERY contributing id is
|
|
420
|
+
* instant, the FIRST fragment's parentheses also carry `; INSTANT_CUT_CLAUSE`
|
|
421
|
+
* (once per pick — two cuts picked together are still one cut). FIRST, not
|
|
422
|
+
* last: the direction fold sheds fragments from the TAIL under a provider cap,
|
|
423
|
+
* so the clause rides the fragment that survives longest. A mixed pick carries
|
|
424
|
+
* no clause: its non-cut is meant to blend.
|
|
425
|
+
*
|
|
426
|
+
* The same text in both hint modes. A transition only ever reaches a VIDEO
|
|
427
|
+
* prompt (the registry row is `surface: "video"`; the canvas node is in
|
|
428
|
+
* `VIDEO_ONLY_PARAMETER_NODE_TYPES`), and there the bare compact term was not
|
|
429
|
+
* enough to steer the model, so the mode no longer changes a transition.
|
|
430
|
+
*
|
|
431
|
+
* Each fragment is ONE string, parentheses included: the cap-aware assemblers
|
|
432
|
+
* shed whole fragments from the tail, so a fragment is kept or dropped whole
|
|
433
|
+
* and can never lose its hint or its clause on its own.
|
|
434
|
+
*
|
|
435
|
+
* Shared by `composeTransitionHintFromConnections` (the canvas transition node,
|
|
436
|
+
* Studio's transition clauses) and the direction registry's `transition` row
|
|
437
|
+
* (the server fold of `direction.transition`), so both paths word a transition
|
|
438
|
+
* identically.
|
|
439
|
+
*/
|
|
440
|
+
export function renderTransitionBases(
|
|
441
|
+
ids: ReadonlyArray<string>,
|
|
442
|
+
_mode: PickerHintMode = "full",
|
|
443
|
+
): string[] {
|
|
444
|
+
// Only ids that contribute a fragment count — a no-op "auto" beside a cut
|
|
445
|
+
// must not make the pick look non-instant.
|
|
446
|
+
const picked = ids.filter((id) => getTransitionTerm(id).length > 0)
|
|
447
|
+
const instant = isInstantTransition(picked)
|
|
448
|
+
return picked.map((id, i) => transitionFragment(id, instant && i === 0))
|
|
449
|
+
}
|
|
450
|
+
|
|
317
451
|
// ---------------------------------------------------------------------------
|
|
318
452
|
// Graph-aware composer — start/end input handles + timing fields + multi-pick
|
|
319
453
|
// ---------------------------------------------------------------------------
|
|
@@ -359,7 +493,7 @@ export const TRANSITION_DURATIONS = [
|
|
|
359
493
|
export const TRANSITION_INTENSITIES = [
|
|
360
494
|
{ id: "auto", label: "Auto", description: "Let the model judge it", promptHint: "", term: "" },
|
|
361
495
|
{ id: "subtle", label: "Subtle", description: "Restrained, minimal flourish", promptHint: "with subtle restrained energy and minimal flourish", term: "subtly" },
|
|
362
|
-
{ id: "natural", label: "Natural", description: "Unhurried, unforced timing", promptHint: "with natural
|
|
496
|
+
{ id: "natural", label: "Natural", description: "Unhurried, unforced timing", promptHint: "with natural timing", term: "at a natural pace" },
|
|
363
497
|
{ id: "dynamic", label: "Dynamic", description: "Assertive, energetic", promptHint: "with dynamic energy and assertive flourish", term: "energetically" },
|
|
364
498
|
{ id: "crazy", label: "Crazy", description: "Extreme, wild, distorted", promptHint: "with extreme exaggerated energy, wild flourishes, and dramatic distortion", term: "wildly exaggerated" },
|
|
365
499
|
] as const satisfies ReadonlyArray<TransitionTimingOption>
|
|
@@ -382,6 +516,35 @@ function clausesOf<T extends TransitionTimingOption>(
|
|
|
382
516
|
}
|
|
383
517
|
|
|
384
518
|
const POSITION_CLAUSES = clausesOf(TRANSITION_POSITIONS)
|
|
519
|
+
|
|
520
|
+
/**
|
|
521
|
+
* Where the composed hint lands. `"clip"` (the default) is a whole video's
|
|
522
|
+
* prompt, so a position is placed within "the clip". `"shot"` is one shot's
|
|
523
|
+
* time window inside a multi-shot prompt (`0-2s — …`, `2-4s — …`): there "the
|
|
524
|
+
* middle of the clip" points the model at the wrong span, so the position
|
|
525
|
+
* clause says "of this shot" instead.
|
|
526
|
+
*/
|
|
527
|
+
export type TransitionHintScope = "clip" | "shot"
|
|
528
|
+
|
|
529
|
+
export interface TransitionHintOptions {
|
|
530
|
+
readonly scope?: TransitionHintScope
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
/**
|
|
534
|
+
* The position clauses for a shot window — DERIVED from `POSITION_CLAUSES`, so
|
|
535
|
+
* the catalog stays the one source of the wording: every " of the clip" reads
|
|
536
|
+
* " of this shot", and `full`'s "the entire clip" reads "this entire shot".
|
|
537
|
+
* `transitions-scope.test.ts` pins that every non-auto row changes, so a
|
|
538
|
+
* reword that drops either phrase fails loudly instead of quietly saying
|
|
539
|
+
* "clip" inside a shot window.
|
|
540
|
+
*/
|
|
541
|
+
const SHOT_POSITION_CLAUSES: typeof POSITION_CLAUSES = Object.fromEntries(
|
|
542
|
+
Object.entries(POSITION_CLAUSES).map(([id, clause]) => [
|
|
543
|
+
id,
|
|
544
|
+
clause.replace(/ of the clip\b/g, " of this shot").replace(/ the entire clip\b/g, " this entire shot"),
|
|
545
|
+
]),
|
|
546
|
+
) as typeof POSITION_CLAUSES
|
|
547
|
+
|
|
385
548
|
const DURATION_CLAUSES = clausesOf(TRANSITION_DURATIONS)
|
|
386
549
|
const INTENSITY_CLAUSES = clausesOf(TRANSITION_INTENSITIES)
|
|
387
550
|
|
|
@@ -393,15 +556,22 @@ const INTENSITY_CLAUSES = clausesOf(TRANSITION_INTENSITIES)
|
|
|
393
556
|
*
|
|
394
557
|
* Behavior:
|
|
395
558
|
* - 0 hints (no transition, empty array, or all-empty hints) → ""
|
|
396
|
-
* -
|
|
559
|
+
* - each pick rendered `<term> (<hint body>)` (`renderTransitionBases`),
|
|
560
|
+
* n picks joined with ", and "
|
|
397
561
|
* - Timing/start/end clauses apply ONCE at the outer layer, not per-id
|
|
562
|
+
* - When every picked id is instant (a cut — see `isInstantTransition`) the
|
|
563
|
+
* first base's parentheses carry `INSTANT_CUT_CLAUSE` once, and the
|
|
564
|
+
* duration and intensity clauses are dropped; position still applies,
|
|
565
|
+
* except `full` — a single-frame cut spans nothing, so it adds no clause
|
|
398
566
|
* - null input is treated like undefined (falsy short-circuit → returns "")
|
|
399
567
|
*
|
|
400
|
-
* @param mode
|
|
401
|
-
*
|
|
402
|
-
*
|
|
403
|
-
*
|
|
404
|
-
*
|
|
568
|
+
* @param mode Accepted for the picker-hint signature; a transition composes
|
|
569
|
+
* the same text in both modes — each pick as `<term> (<hint body>)`, see
|
|
570
|
+
* `renderTransitionBases`.
|
|
571
|
+
* @param options `scope: "shot"` when the hint is folded into one shot's time
|
|
572
|
+
* window of a multi-shot prompt — the position clause then says "of this
|
|
573
|
+
* shot" instead of "of the clip" (and `full`, on a non-cut, "spans this
|
|
574
|
+
* entire shot"). Omitted, the wording is the clip's.
|
|
405
575
|
*/
|
|
406
576
|
export function composeTransitionHintFromConnections(
|
|
407
577
|
transitionId: string | ReadonlyArray<string> | undefined,
|
|
@@ -409,26 +579,33 @@ export function composeTransitionHintFromConnections(
|
|
|
409
579
|
endHints: ReadonlyArray<string>,
|
|
410
580
|
timing?: TransitionTiming,
|
|
411
581
|
mode: PickerHintMode = "full",
|
|
582
|
+
options?: TransitionHintOptions,
|
|
412
583
|
): string {
|
|
413
584
|
const ids = Array.isArray(transitionId)
|
|
414
585
|
? Array.from(new Set(transitionId)).slice(0, 2)
|
|
415
586
|
: transitionId ? [transitionId] : []
|
|
416
|
-
|
|
417
|
-
// timing clauses and the start/end clauses below are identical either way.
|
|
418
|
-
const resolveBase = mode === "compact" ? getTransitionTerm : getTransitionPromptHint
|
|
419
|
-
const baseHints = ids.map(resolveBase).filter((h) => h.length > 0)
|
|
587
|
+
const baseHints = renderTransitionBases(ids, mode)
|
|
420
588
|
if (baseHints.length === 0) return ""
|
|
589
|
+
// Same "contributes a fragment" filter `renderTransitionBases` applies.
|
|
590
|
+
const instant = isInstantTransition(ids.filter((id) => getTransitionTerm(id).length > 0))
|
|
421
591
|
|
|
422
592
|
const combinedBase = baseHints.join(", and ")
|
|
423
593
|
const parts: string[] = [combinedBase]
|
|
424
594
|
|
|
425
|
-
|
|
426
|
-
|
|
595
|
+
// "Spans the entire clip" beside a single-frame cut contradicts it, so an
|
|
596
|
+
// all-instant pick drops `full`; start / middle / end still place the cut.
|
|
597
|
+
if (timing?.position && timing.position !== "auto" && !(instant && timing.position === "full")) {
|
|
598
|
+
const clauses = options?.scope === "shot" ? SHOT_POSITION_CLAUSES : POSITION_CLAUSES
|
|
599
|
+
parts.push(clauses[timing.position])
|
|
427
600
|
}
|
|
428
|
-
|
|
601
|
+
// A cut has no duration and no performance: "lasting approximately 1
|
|
602
|
+
// second" or "with natural timing" on a match cut makes the model
|
|
603
|
+
// render a dissolve. Skipped only when EVERY picked id is instant — a mixed
|
|
604
|
+
// pick still has a non-cut to time and shape.
|
|
605
|
+
if (timing?.duration && timing.duration !== "auto" && !instant) {
|
|
429
606
|
parts.push(DURATION_CLAUSES[timing.duration])
|
|
430
607
|
}
|
|
431
|
-
if (timing?.intensity && timing.intensity !== "auto") {
|
|
608
|
+
if (timing?.intensity && timing.intensity !== "auto" && !instant) {
|
|
432
609
|
parts.push(INTENSITY_CLAUSES[timing.intensity])
|
|
433
610
|
}
|
|
434
611
|
|
|
@@ -46,7 +46,7 @@ import { insertBeforeStyleSection } from "./prompt-style-section.js"
|
|
|
46
46
|
// The binding surface string and the id-addressed token resolver live in their
|
|
47
47
|
// own modules (see them for the contracts); re-exported here so every existing
|
|
48
48
|
// importer of this module — and the package index's `export *` — keeps working.
|
|
49
|
-
export { REF_BINDING, identityRefsSentence } from "./ref-binding.js"
|
|
49
|
+
export { REF_BINDING, identityRefsSentence, SEEDANCE_VIDEO_EDIT_PREFIX, buildSeedanceVideoEditPrompt } from "./ref-binding.js"
|
|
50
50
|
export { resolveRefIdTokens, type RefIdTokenContext } from "./ref-id-tokens.js"
|
|
51
51
|
|
|
52
52
|
|