@nodaro/prompts 1.28.0 → 1.30.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.
@@ -124,13 +124,30 @@ export interface PickerOption {
124
124
  /**
125
125
  * Transitions only: present (`true`) on a row whose mechanism is a CUT, so
126
126
  * it takes no duration and no intensity — see `Transition.instant` /
127
- * `isInstantTransition`. A consumer that only reads this wire catalog
128
- * (Studio builds the transition Duration lever from
129
- * `getPickerCatalog("transition")`) hides those levers for such a row. Carried from the base catalog; a row a catalog pack ADDS has
130
- * it only when the pack's own option says so, and absent means "has a
131
- * duration" — the safe reading, since the composer then behaves as before.
127
+ * `isInstantTransition`. A consumer that only reads this catalog (Studio
128
+ * builds the transition levers from `getPickerCatalog("transition")`) hides
129
+ * those levers for such a row. Carried from the base catalog; a row a
130
+ * catalog pack ADDS has it only when the pack's own option says so, and
131
+ * absent means "has a duration" — the safe reading, since the composer then
132
+ * behaves as before. On this in-memory catalog only: the wire projection
133
+ * (`projectPickerCatalog`) does not carry it.
132
134
  */
133
135
  readonly instant?: true
136
+ /**
137
+ * Transitions only: present (`true`) on a CUT that a `blendsCut` duration
138
+ * turns into a BLENDED CUT — see `Transition.blendable` /
139
+ * `isBlendableTransition`. Always beside `instant`. A consumer offers such a
140
+ * row's Duration as a Blend lever (hard cut, or one of the `blendsCut`
141
+ * steps of the catalog's `duration` dimension). Carried like `instant`, and
142
+ * like it on this in-memory catalog only.
143
+ */
144
+ readonly blendable?: true
145
+ /**
146
+ * Transition `duration` dimension only: present (`true`) on the step that
147
+ * blends a `blendable` cut (`short`) — see `TransitionTimingOption.blendsCut`
148
+ * / `blendedCutClause`. On this in-memory catalog only.
149
+ */
150
+ readonly blendsCut?: true
134
151
  /**
135
152
  * PER-OPTION PARAMETERS — extra node-data fields that apply only while THIS
136
153
  * option is picked, each with its own rows (Transitions: a wipe's
@@ -197,6 +214,10 @@ interface BaseCatalogEntry {
197
214
  readonly adultOnly?: true
198
215
  /** Transitions: see `Transition.instant`. Propagated into the flattened option as `instant: true`. */
199
216
  readonly instant?: boolean
217
+ /** Transitions: see `Transition.blendable`. Propagated into the flattened option as `blendable: true`. */
218
+ readonly blendable?: boolean
219
+ /** Transition durations: see `TransitionTimingOption.blendsCut`. Propagated as `blendsCut: true`. */
220
+ readonly blendsCut?: boolean
200
221
  /** Transitions: see `Transition.options`. Propagated as the option's `params`. */
201
222
  readonly options?: ReadonlyArray<{
202
223
  readonly field: string
@@ -238,6 +259,8 @@ function toOptions<T extends BaseCatalogEntry>(
238
259
  if (categoryField) opt.category = e[categoryField] as unknown as string
239
260
  if (e.adultOnly) opt.adultOnly = true
240
261
  if (e.instant) opt.instant = true
262
+ if (e.blendable) opt.blendable = true
263
+ if (e.blendsCut) opt.blendsCut = true
241
264
  if (e.options?.length) {
242
265
  opt.params = e.options.map((o) => ({
243
266
  field: o.field,
@@ -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
- * lever when every picked transition is instant, and consumers (the picker
51
- * UI, Studio) read `isInstantTransition` to hide that lever.
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,24 @@ 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
68
91
  /**
69
92
  * PER-ROW OPTIONS — extra choices that only mean something for THIS
70
93
  * transition (a wipe's direction), each written into the row's own hint.
@@ -323,7 +346,7 @@ export const TRANSITIONS: ReadonlyArray<Transition> = [
323
346
  { id: "roll-transition", label: "Roll", category: "standard", description: "Frame rolls 90-180°, second shot upright on landing",
324
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" },
325
348
  { id: "seamless-match", label: "Seamless Match", category: "standard", description: "Hidden cut disguised by matched motion and color",
326
- 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 },
327
350
  { id: "whip-pan", label: "Whip Pan", category: "standard", description: "Camera whips sideways into blur, next shot rides the same direction",
328
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" },
329
352
  { id: "jump-cut", label: "Jump Cut", category: "standard", description: "Same framing, time skips forward",
@@ -455,7 +478,7 @@ export const TRANSITIONS: ReadonlyArray<Transition> = [
455
478
  { id: "vehicle-explosion", label: "Vehicle Explosion", category: "physics", description: "Vehicle detonates in foreground, scene changes behind",
456
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" },
457
480
  { id: "jump-match", label: "Jump Match", category: "physics", description: "Subject jumps, landing matches into new scene",
458
- 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 },
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 },
459
482
  { id: "hand-swipe", label: "Hand Swipe", category: "physics", description: "Hand swipes across lens, scene changes during occlusion",
460
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" },
461
484
  { id: "action-relay", label: "Action Match", category: "physics", description: "Subject exits on an action and lands in the new scene mid-move",
@@ -614,6 +637,25 @@ export function isInstantTransition(
614
637
  return ids.every((one) => getTransition(one)?.instant === true)
615
638
  }
616
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
+
617
659
  /**
618
660
  * The anti-blend instruction an all-instant transition pick carries — the ONE
619
661
  * place this sentence lives (see `Transition.instant`). Each row keeps its own
@@ -658,14 +700,16 @@ function transitionHintBody(id: string, term: string, values?: TransitionOptionV
658
700
  * One picked transition as a VIDEO prompt reads it: `<term> (<hint body>)`.
659
701
  * The term names the move in the editor's own words; the parentheses carry the
660
702
  * model-facing mechanism, which the term alone does not convey (a bare "match
661
- * cut" rendered as a dissolve on prod). `withCutClause` appends
662
- * `; INSTANT_CUT_CLAUSE` inside the parentheses. A row whose hint is just its
663
- * term (or empty) renders as the term alone; an unknown id or "auto" as "".
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 "".
664
708
  */
665
- function transitionFragment(id: string, withCutClause: boolean, values?: TransitionOptionValues): string {
709
+ function transitionFragment(id: string, cutClause: string, values?: TransitionOptionValues): string {
666
710
  const term = getTransitionTerm(id)
667
711
  if (!term) return ""
668
- const inner = [transitionHintBody(id, term, values), withCutClause ? INSTANT_CUT_CLAUSE : ""]
712
+ const inner = [transitionHintBody(id, term, values), cutClause]
669
713
  .filter((part) => part.length > 0)
670
714
  .join("; ")
671
715
  return inner.length > 0 && normalizePhrase(inner) !== normalizePhrase(term)
@@ -699,17 +743,44 @@ function transitionFragment(id: string, withCutClause: boolean, values?: Transit
699
743
  * `values` are the per-row option choices (`TransitionOptionValues`) — a wipe's
700
744
  * direction. The direction registry passes none, so a `direction.transition`
701
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`.
702
750
  */
703
751
  export function renderTransitionBases(
704
752
  ids: ReadonlyArray<string>,
705
753
  _mode: PickerHintMode = "full",
706
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,
707
765
  ): string[] {
708
766
  // Only ids that contribute a fragment count — a no-op "auto" beside a cut
709
767
  // must not make the pick look non-instant.
710
768
  const picked = ids.filter((id) => getTransitionTerm(id).length > 0)
711
769
  const instant = isInstantTransition(picked)
712
- return picked.map((id, i) => transitionFragment(id, instant && i === 0, values))
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}` : ""
713
784
  }
714
785
 
715
786
  // ---------------------------------------------------------------------------
@@ -736,6 +807,12 @@ export interface TransitionTimingOption {
736
807
  readonly description: string
737
808
  readonly promptHint: string
738
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
739
816
  }
740
817
 
741
818
  export const TRANSITION_POSITIONS = [
@@ -749,7 +826,7 @@ export const TRANSITION_POSITIONS = [
749
826
  export const TRANSITION_DURATIONS = [
750
827
  { id: "auto", label: "Auto", description: "Let the model time it", promptHint: "", term: "" },
751
828
  { id: "instant", label: "Instant", description: "No perceptible duration", promptHint: "occurring instantaneously", term: "instantaneous" },
752
- { 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 },
753
830
  { id: "medium", label: "Medium (~2s)", description: "Approximately 2 seconds", promptHint: "lasting approximately 2 seconds", term: "about 2 seconds" },
754
831
  { id: "long", label: "Long (~3s)", description: "Approximately 3 seconds", promptHint: "lasting approximately 3 seconds", term: "about 3 seconds" },
755
832
  ] as const satisfies ReadonlyArray<TransitionTimingOption>
@@ -830,9 +907,16 @@ const INTENSITY_CLAUSES = clausesOf(TRANSITION_INTENSITIES)
830
907
  * first base's parentheses carry `INSTANT_CUT_CLAUSE` once, and the
831
908
  * duration and intensity clauses are dropped; position still applies,
832
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
833
917
  * - null input is treated like undefined (falsy short-circuit → returns "")
834
918
  *
835
- * @param mode Accepted for the picker-hint signature; a transition composes
919
+ * @param _mode Accepted for the picker-hint signature; a transition composes
836
920
  * the same text in both modes — each pick as `<term> (<hint body>)`, see
837
921
  * `renderTransitionBases`.
838
922
  * @param options `scope: "shot"` when the hint is folded into one shot's time
@@ -847,16 +931,18 @@ export function composeTransitionHintFromConnections(
847
931
  startHints: ReadonlyArray<string>,
848
932
  endHints: ReadonlyArray<string>,
849
933
  timing?: TransitionTiming,
850
- mode: PickerHintMode = "full",
934
+ _mode: PickerHintMode = "full",
851
935
  options?: TransitionHintOptions,
852
936
  ): string {
853
937
  const ids = Array.isArray(transitionId)
854
938
  ? Array.from(new Set(transitionId)).slice(0, 2)
855
939
  : transitionId ? [transitionId] : []
856
- const baseHints = renderTransitionBases(ids, mode, options?.optionValues)
857
- if (baseHints.length === 0) return ""
858
940
  // Same "contributes a fragment" filter `renderTransitionBases` applies.
859
- const instant = isInstantTransition(ids.filter((id) => getTransitionTerm(id).length > 0))
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 ""
860
946
 
861
947
  const combinedBase = baseHints.join(", and ")
862
948
  const parts: string[] = [combinedBase]
@@ -870,7 +956,8 @@ export function composeTransitionHintFromConnections(
870
956
  // A cut has no duration and no performance: "lasting approximately 1
871
957
  // second" or "with natural timing" on a match cut makes the model
872
958
  // render a dissolve. Skipped only when EVERY picked id is instant — a mixed
873
- // 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.)
874
961
  if (timing?.duration && timing.duration !== "auto" && !instant) {
875
962
  parts.push(DURATION_CLAUSES[timing.duration])
876
963
  }