@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.
Files changed (33) hide show
  1. package/dist/index.cjs +191 -45
  2. package/dist/index.cjs.map +1 -1
  3. package/dist/index.d.cts +362 -207
  4. package/dist/index.d.ts +362 -207
  5. package/dist/index.js +183 -46
  6. package/dist/index.js.map +1 -1
  7. package/package.json +2 -2
  8. package/src/__tests__/assemble-video-input.test.ts +20 -12
  9. package/src/__tests__/camera-motion-approved.test.ts +46 -0
  10. package/src/__tests__/camera-motions-from-connections.test.ts +3 -2
  11. package/src/__tests__/catalog-packs.test.ts +13 -0
  12. package/src/__tests__/character-fx-timing-catalogs.test.ts +3 -2
  13. package/src/__tests__/factory-presets.test.ts +28 -3
  14. package/src/__tests__/fixtures/camera-motion-approved.json +330 -0
  15. package/src/__tests__/fixtures/parameter-hint-golden.json +32 -16
  16. package/src/__tests__/hint-join.test.ts +3 -2
  17. package/src/__tests__/node-prompt-fields.test.ts +2 -2
  18. package/src/__tests__/parameter-hint-mode.test.ts +17 -5
  19. package/src/__tests__/prompt-style-section.test.ts +2 -2
  20. package/src/__tests__/transitions-instant.test.ts +242 -0
  21. package/src/__tests__/transitions-scope.test.ts +160 -0
  22. package/src/__tests__/transitions.test.ts +9 -9
  23. package/src/ad-creative-analysis.ts +99 -0
  24. package/src/camera-motions.ts +21 -21
  25. package/src/catalog-packs.ts +13 -2
  26. package/src/direction-registry.ts +2 -2
  27. package/src/factory-presets/generate-video.ts +22 -0
  28. package/src/index.ts +3 -0
  29. package/src/node-prompt-fields.ts +4 -0
  30. package/src/picker-catalogs.ts +13 -0
  31. package/src/ref-binding.ts +19 -0
  32. package/src/transitions.ts +204 -27
  33. package/src/video-reference-resolver.ts +1 -1
@@ -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 — skin develops fine lines then deeper wrinkles, hair lightens to silver, posture shifts subtly, while the framing holds steady on the face", term: "accelerated aging" },
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 arrests mid-action, the frame holds frozen for a beat, then snaps to a new moment hours or days later in the same scene with subjects in different positions", term: "freeze-frame time jump" },
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 (TV, phone, monitor), the screen's image fills the frame, and the camera passes through into that image which becomes the new 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 the throat into the new scene which materialises as if emerging from inside the body" },
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 unhurried timing", term: "at a natural pace" },
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
- * - n base hints joined with ", and "
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 `"compact"` builds the base from each transition's short
401
- * professional `term` ("hard cut") instead of its full mechanism paragraph.
402
- * Everything else — the ", and " multi-pick join, the position/duration/
403
- * intensity clauses, and the "starting from"/"ending at" clauses — is
404
- * emitted identically in both modes.
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
- // ONLY the base fragment swaps in compact mode — the multi-pick join, the
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
- if (timing?.position && timing.position !== "auto") {
426
- parts.push(POSITION_CLAUSES[timing.position])
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
- if (timing?.duration && timing.duration !== "auto") {
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