@nodaro/prompts 1.20.0 → 1.23.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 (54) hide show
  1. package/dist/index.cjs +8214 -7143
  2. package/dist/index.cjs.map +1 -1
  3. package/dist/index.d.cts +213 -61
  4. package/dist/index.d.ts +213 -61
  5. package/dist/index.js +8206 -7145
  6. package/dist/index.js.map +1 -1
  7. package/package.json +2 -2
  8. package/src/__tests__/adult-only-ratchet.test.ts +9 -1
  9. package/src/__tests__/catalog-packs.test.ts +13 -0
  10. package/src/__tests__/character-motion-audit.test.ts +77 -0
  11. package/src/__tests__/character-motion-exit-visibility.test.ts +183 -0
  12. package/src/__tests__/character-motion-partner-referent.test.ts +225 -0
  13. package/src/__tests__/character-motion.test.ts +11 -4
  14. package/src/__tests__/factory-presets.test.ts +28 -3
  15. package/src/__tests__/fixtures/parameter-hint-golden.json +22 -6
  16. package/src/__tests__/frame-delivery.test.ts +93 -0
  17. package/src/__tests__/graph-composed-unwired-identity.test.ts +166 -0
  18. package/src/__tests__/node-prompt-fields.test.ts +2 -2
  19. package/src/__tests__/parameter-prompt-hint.test.ts +9 -3
  20. package/src/__tests__/transitions-instant.test.ts +112 -0
  21. package/src/ad-creative-analysis.ts +99 -0
  22. package/src/catalog-packs.ts +13 -2
  23. package/src/character-motion/animals-pets.ts +112 -85
  24. package/src/character-motion/athletic-stunts.ts +155 -214
  25. package/src/character-motion/camera-interaction.ts +92 -133
  26. package/src/character-motion/combat-weapons.ts +143 -202
  27. package/src/character-motion/dance.ts +121 -169
  28. package/src/character-motion/entrances-exits.ts +88 -109
  29. package/src/character-motion/evasive-falls.ts +88 -130
  30. package/src/character-motion/everyday-actions.ts +233 -274
  31. package/src/character-motion/face-expression.ts +125 -178
  32. package/src/character-motion/gestures.ts +211 -304
  33. package/src/character-motion/head-gestures.ts +66 -94
  34. package/src/character-motion/idle-ambient.ts +85 -124
  35. package/src/character-motion/posture-shifts.ts +116 -139
  36. package/src/character-motion/runway.ts +73 -97
  37. package/src/character-motion/stage-performance.ts +92 -127
  38. package/src/character-motion/turns-looks.ts +86 -112
  39. package/src/character-motion/two-person.ts +241 -229
  40. package/src/character-motion/types.ts +4 -1
  41. package/src/character-motion/unnatural-horror.ts +53 -91
  42. package/src/character-motion/vehicles-mounts.ts +101 -97
  43. package/src/character-motion/walks-runs.ts +93 -121
  44. package/src/character-motion-diagnostics.ts +49 -0
  45. package/src/character-motion.ts +89 -13
  46. package/src/factory-presets/generate-video.ts +22 -0
  47. package/src/frame-delivery.ts +106 -0
  48. package/src/index.ts +7 -0
  49. package/src/node-prompt-fields.ts +4 -0
  50. package/src/parameter-prompt-hint.ts +23 -17
  51. package/src/picker-catalogs.ts +23 -2
  52. package/src/ref-binding.ts +19 -0
  53. package/src/transitions.ts +47 -10
  54. package/src/video-reference-resolver.ts +1 -1
@@ -0,0 +1,106 @@
1
+ /**
2
+ * HOW A START/END FRAME TRAVELS — one plan, read by both ends.
3
+ *
4
+ * A frame can reach a model two ways: as a real frame, or as a reference image
5
+ * bound in prose as the opening (or closing) frame. Which one is right is
6
+ * measured per model — the Seedance 2.0 family crop-zooms a frame 2% and drifts
7
+ * 11-26% darker within six frames, but holds its look from frame 0 when the same
8
+ * image rides as a reference; every other model measured reproduces the opening
9
+ * frame better as a frame (see `resolveFrameDelivery` in @nodaro/shared).
10
+ *
11
+ * The composition — where the frames sit in the reference list, which sentence
12
+ * binds them, when the switch is refused — lives HERE rather than at the
13
+ * dispatch site, because two places need the same answer:
14
+ *
15
+ * - `backend/src/lib/video-frame-dispatch.ts` builds the actual request,
16
+ * - the editor's config panel tells the user which mode their node will run in.
17
+ *
18
+ * When those two disagree the panel lies, which is worse than having no panel
19
+ * text at all. One function, two callers, no drift.
20
+ */
21
+ import { VIDEO_REF_LIMITS_BY_PROVIDER, resolveFrameDelivery, type FrameDelivery } from "@nodaro/shared"
22
+ import { REF_BINDING } from "./ref-binding.js"
23
+ import { promptBindsFirstFrame } from "./seedance-2-inputs.js"
24
+
25
+ export interface FrameDeliveryPlanArgs {
26
+ readonly provider: string | undefined
27
+ /** The node's / caller's choice. `auto` (or absent) resolves per model. */
28
+ readonly requested?: FrameDelivery
29
+ /** Whether the model accepts reference images at all. */
30
+ readonly supportsReferenceImages: boolean
31
+ readonly startFrameUrl?: string
32
+ readonly endFrameUrl?: string
33
+ /** The user's OWN reference images, in their existing order. */
34
+ readonly userReferenceUrls?: readonly string[]
35
+ /** Consulted only to avoid a duplicate opening-frame sentence. */
36
+ readonly prompt?: string
37
+ }
38
+
39
+ export interface FrameDeliveryPlan {
40
+ /** What will actually happen — never `auto`. */
41
+ readonly delivery: Exclude<FrameDelivery, "auto">
42
+ /** The full reference list to send. Equals the user's own list under `frame`. */
43
+ readonly referenceImageUrls: readonly string[]
44
+ /** Sentence(s) to append to the prompt. Empty under `frame`. */
45
+ readonly promptSuffix: string
46
+ /** Set when reference delivery was WANTED but refused, so a caller can say why. */
47
+ readonly refusedReason?: "no-reference-support" | "image-cap"
48
+ }
49
+
50
+ /**
51
+ * Resolve delivery and compose the request shape that follows from it.
52
+ *
53
+ * Two refusals, both deliberate:
54
+ * - a model with no reference-image support keeps frame mode (there is nowhere
55
+ * for the frame to go);
56
+ * - a switch that would push past the model's image cap keeps frame mode,
57
+ * because the alternative is dropping one of the USER's reference images to
58
+ * make room for ours.
59
+ *
60
+ * The frames are appended AFTER the user's own images so their `@image_N`
61
+ * ordinals never shift, and the opening sentence is skipped when the prompt
62
+ * already binds its own first frame — a second binding at another position
63
+ * dilutes the first back into coin-flip behaviour (field finding 2026-07-20).
64
+ */
65
+ export function planFrameDelivery(args: FrameDeliveryPlanArgs): FrameDeliveryPlan {
66
+ const userRefs = (args.userReferenceUrls ?? []).filter(Boolean)
67
+ const requested = args.requested ?? "auto"
68
+ const delivery = resolveFrameDelivery({
69
+ provider: args.provider,
70
+ requested,
71
+ supportsReferenceImages: args.supportsReferenceImages,
72
+ })
73
+
74
+ const frames = [args.startFrameUrl, args.endFrameUrl].filter((u): u is string => Boolean(u))
75
+
76
+ if (delivery === "frame" || frames.length === 0) {
77
+ return {
78
+ delivery: "frame",
79
+ referenceImageUrls: userRefs,
80
+ promptSuffix: "",
81
+ ...(requested === "reference" && !args.supportsReferenceImages
82
+ ? { refusedReason: "no-reference-support" as const }
83
+ : {}),
84
+ }
85
+ }
86
+
87
+ const cap = VIDEO_REF_LIMITS_BY_PROVIDER[args.provider ?? ""]?.images
88
+ if (cap !== undefined && userRefs.length + frames.length > cap) {
89
+ return { delivery: "frame", referenceImageUrls: userRefs, promptSuffix: "", refusedReason: "image-cap" }
90
+ }
91
+
92
+ const referenceImageUrls = [...userRefs]
93
+ const sentences: string[] = []
94
+ if (args.startFrameUrl) {
95
+ referenceImageUrls.push(args.startFrameUrl)
96
+ if (!promptBindsFirstFrame(args.prompt)) {
97
+ sentences.push(REF_BINDING.frame(referenceImageUrls.length, "opening"))
98
+ }
99
+ }
100
+ if (args.endFrameUrl) {
101
+ referenceImageUrls.push(args.endFrameUrl)
102
+ sentences.push(REF_BINDING.frame(referenceImageUrls.length, "closing"))
103
+ }
104
+
105
+ return { delivery: "reference", referenceImageUrls, promptSuffix: sentences.join(" ") }
106
+ }
package/src/index.ts CHANGED
@@ -92,3 +92,10 @@ export * from "./object-asset-presets.js"
92
92
  export * from "./factory-snippets/index.js"
93
93
  export * from "./picker-wiring.js"
94
94
  export * from "./surround-fill.js"
95
+
96
+ // --- Start/end frame delivery: one plan for the dispatch and the editor ---
97
+ export * from "./frame-delivery.js"
98
+ export * from "./character-motion-diagnostics.js"
99
+
100
+ // --- Per-ad creative analysis for the social scraper nodes ---
101
+ export * from "./ad-creative-analysis.js"
@@ -128,6 +128,10 @@ export const NODE_PROMPT_FIELDS: Readonly<Record<string, PromptFieldSpec>> = {
128
128
  // Video Analysis — its focus hint is the editable prompt (renders a JSON scene
129
129
  // table, not a media preview → no inline editor).
130
130
  "video-analysis": { prompt: "analysisFocus", promptLabel: "Analysis focus", media: "video", inline: false },
131
+ // Edit Plan (podcast editing) — its free-text editing instructions are the
132
+ // editable prompt; the affixes wrap them. Emits an EDL (JSON), not a media
133
+ // preview, so no inline editor.
134
+ "edit-plan": { prompt: "instructions", promptLabel: "Editing instructions", media: "video", inline: false },
131
135
  }
132
136
 
133
137
  /** The prompt-field spec for a node type, or undefined if it has none. */
@@ -102,6 +102,28 @@ function extractCharacterRefMinor(node: HintNodeLike): boolean {
102
102
  return REF_DESCRIPTION_FIELDS.some((field) => containsMinorAgeHint(asStr(d[field])))
103
103
  }
104
104
 
105
+ /** The preview, diagnostics and execution use identical graph bindings. */
106
+ export function getCharacterMotionBindings(node: HintNodeLike, ctx?: HintGraphContext) {
107
+ const targetNames: string[] = []
108
+ const partnerNames: string[] = []
109
+ const targetIds = new Set<string>()
110
+ const partnerIds = new Set<string>()
111
+ let subjectMinor = false
112
+ for (const edge of ctx?.edges ?? []) {
113
+ if (edge.target !== node.id || (edge.targetHandle !== "target" && edge.targetHandle !== "partner")) continue
114
+ const src = ctx?.nodes.find(n => n.id === edge.source)
115
+ if (!src) continue
116
+ if (edge.targetHandle === "target") targetIds.add(src.id)
117
+ else partnerIds.add(src.id)
118
+ if (extractCharacterRefMinor(src)) subjectMinor = true
119
+ const name = extractCharacterMotionRefName(src)
120
+ if (!name) continue
121
+ const names = edge.targetHandle === "target" ? targetNames : partnerNames
122
+ if (!names.includes(name)) names.push(name)
123
+ }
124
+ return { targetNames, partnerNames, subjectMinor, selfPairing: [...targetIds].some(id => partnerIds.has(id)) }
125
+ }
126
+
105
127
  /** Compose `[preText, mainHint, postText]` into a comma-joined string,
106
128
  * honoring the user's free-text fragments around the structured hint.
107
129
  * Helpers like `getStylePromptHint` that already include preText/postText
@@ -261,23 +283,7 @@ function resolveParameterHint(
261
283
  if (!ctx) {
262
284
  return withCustomText(data, composeCharacterMotionHintFromConnections(motionId, [], [], timing, mode))
263
285
  }
264
- const targetNames: string[] = []
265
- const partnerNames: string[] = []
266
- // Minor-age floor: ANY ref wired to target or partner that describes a
267
- // minor drops every adultOnly move from the composed sequence.
268
- let subjectMinor = false
269
- for (const edge of ctx.edges) {
270
- if (edge.target !== node.id) continue
271
- if (edge.targetHandle !== "target" && edge.targetHandle !== "partner") continue
272
- const src = ctx.nodes.find((n) => n.id === edge.source)
273
- if (!src) continue
274
- // Before the name check: an unnamed ref that describes a minor still floors.
275
- if (!subjectMinor && extractCharacterRefMinor(src)) subjectMinor = true
276
- const name = extractCharacterMotionRefName(src)
277
- if (!name) continue
278
- if (edge.targetHandle === "target") targetNames.push(name)
279
- else partnerNames.push(name)
280
- }
286
+ const { targetNames, partnerNames, subjectMinor } = getCharacterMotionBindings(node, ctx)
281
287
  return withCustomText(
282
288
  data,
283
289
  composeCharacterMotionHintFromConnections(motionId, targetNames, partnerNames, timing, mode, { subjectMinor }),
@@ -1,3 +1,4 @@
1
+ import type { CharacterMotionMetadata } from "./character-motion/types.js"
1
2
  /**
2
3
  * Public, discoverable registry of the parameter-picker catalogs.
3
4
  *
@@ -98,6 +99,9 @@ import { setComposedCatalogResolver } from "./catalog-overlay.js"
98
99
  import { deriveTerm, resolveTerm } from "./term.js"
99
100
 
100
101
  export interface PickerOption {
102
+ /** Character Motion only: authored prerequisites, state and search metadata. */
103
+ readonly motion?: CharacterMotionMetadata
104
+
101
105
  readonly id: string
102
106
  readonly label: string
103
107
  readonly description?: string
@@ -115,6 +119,16 @@ export interface PickerOption {
115
119
  * `label` and inject `term`; they never derive it themselves.
116
120
  */
117
121
  readonly term: string
122
+ /**
123
+ * Transitions only: present (`true`) on a row whose mechanism is a CUT, so
124
+ * it takes no duration — see `Transition.instant` / `isInstantTransition`.
125
+ * A consumer that only reads this wire catalog (Studio builds the transition
126
+ * Duration lever from `getPickerCatalog("transition")`) hides that lever for
127
+ * such a row. Carried from the base catalog; a row a catalog pack ADDS has
128
+ * it only when the pack's own option says so, and absent means "has a
129
+ * duration" — the safe reading, since the composer then behaves as before.
130
+ */
131
+ readonly instant?: true
118
132
  /** Only present if the source catalog entry already carries a data icon/emoji/thumbnail field. */
119
133
  readonly icon?: string
120
134
  }
@@ -168,6 +182,8 @@ interface BaseCatalogEntry {
168
182
  readonly term?: string
169
183
  /** W1-a: see Person.adultOnly. Propagated verbatim into the flattened option. */
170
184
  readonly adultOnly?: true
185
+ /** Transitions: see `Transition.instant`. Propagated into the flattened option as `instant: true`. */
186
+ readonly instant?: boolean
171
187
  }
172
188
 
173
189
  /**
@@ -196,6 +212,7 @@ function toOptions<T extends BaseCatalogEntry>(
196
212
  if (e.description) opt.description = e.description
197
213
  if (categoryField) opt.category = e[categoryField] as unknown as string
198
214
  if (e.adultOnly) opt.adultOnly = true
215
+ if (e.instant) opt.instant = true
199
216
  return opt as PickerOption
200
217
  })
201
218
  }
@@ -604,7 +621,10 @@ const SINGLE_CATALOGS: readonly PickerCatalog[] = [
604
621
  defaultValue: "auto",
605
622
  categoryOrder: CHARACTER_MOTION_CATEGORY_ORDER,
606
623
  categoryLabels: CHARACTER_MOTION_CATEGORY_LABELS,
607
- options: toOptions(CHARACTER_MOTIONS, "category"),
624
+ options: toOptions(CHARACTER_MOTIONS, "category").map((option, index) => {
625
+ const { id: _id, label: _label, category: _category, description: _description, promptHint: _hint, term: _term, adultOnly: _adult, twoPerson: _duo, ...motion } = CHARACTER_MOTIONS[index]!
626
+ return { ...option, motion }
627
+ }),
608
628
  // Position + Pace, this node's own wording (a movement begins / plays out;
609
629
  // an effect manifests; a transition occurs) — never the sibling rows.
610
630
  dimensions: perFieldDims([
@@ -939,8 +959,9 @@ function projectOption(o: PickerOption, detail: PickerCatalogDetail): ProjectedP
939
959
  promptHint: o.promptHint,
940
960
  term: o.term,
941
961
  icon: o.icon,
962
+ ...(o.motion ? { motion: o.motion } : {}),
942
963
  }
943
- : { id: o.id, label: o.label, category: o.category, term: o.term, icon: o.icon }
964
+ : { id: o.id, label: o.label, category: o.category, term: o.term, icon: o.icon, ...(o.motion ? { motion: o.motion } : {}) }
944
965
  }
945
966
 
946
967
  /** Project a catalog to the wire shape: compact by default, optional category/field filter. */
@@ -43,3 +43,22 @@ export const REF_BINDING = {
43
43
  frame: (n: number, role: "opening" | "closing") =>
44
44
  `Use @image_${n} as the ${role} (${role === "opening" ? "first" : "last"}) frame of the video.`,
45
45
  } as const
46
+
47
+ /**
48
+ * The instruction that makes Seedance EDIT a wired clip rather than use it as a
49
+ * style reference — the Video to Video node's Seedance lane prepends it to the
50
+ * user's own sentence. Written with the EDITOR token (`{video:1}`) so it goes
51
+ * through the same reference resolver as any prompt: the source clip is the
52
+ * run's first reference video, so it resolves to `@video_1` on the wire. The
53
+ * instruction lands on its own line after it.
54
+ */
55
+ export const SEEDANCE_VIDEO_EDIT_PREFIX = "edit {video:1} as follows:\n"
56
+
57
+ /** The user's sentence, framed as an edit of the source clip. Idempotent: a
58
+ * prompt that already opens with the instruction (a preset's pre text, a
59
+ * hand-written one, either token spelling) is left alone. */
60
+ export function buildSeedanceVideoEditPrompt(prompt: string | undefined): string {
61
+ const body = (prompt ?? "").trim()
62
+ if (/^edit\s+(?:\{video:1(?::[^}]*)?\}|@video_1(?!\w))/i.test(body)) return body
63
+ return `${SEEDANCE_VIDEO_EDIT_PREFIX}${body}`
64
+ }
@@ -41,6 +41,18 @@ 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. Position and
52
+ * intensity still apply — WHERE the cut lands, and how hard it hits, are
53
+ * real choices.
54
+ */
55
+ readonly instant?: boolean
44
56
  }
45
57
 
46
58
  /**
@@ -73,7 +85,7 @@ export const TRANSITIONS: ReadonlyArray<Transition> = [
73
85
  // ============================================================================
74
86
  { id: "auto", label: "Auto", category: "standard", description: "Let the model choose", promptHint: "" },
75
87
  { 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" },
88
+ promptHint: "no transition, hard cut, instantaneous switch from first shot to second shot", term: "hard cut" , instant: true },
77
89
  { id: "cross-dissolve", label: "Cross-Dissolve", category: "standard", description: "Gradual blend between shots",
78
90
  promptHint: "smooth cross-dissolve transition where the first shot gradually fades out as the second shot fades in" },
79
91
  { id: "fade-to-black", label: "Fade to Black", category: "standard", description: "Darkens to black, second emerges",
@@ -81,11 +93,11 @@ export const TRANSITIONS: ReadonlyArray<Transition> = [
81
93
  { id: "fade-to-white", label: "Fade to White", category: "standard", description: "Blooms to white, second emerges",
82
94
  promptHint: "fade to white: the first shot brightens until the frame is pure white, then the second shot resolves out of the white" },
83
95
  { 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" },
96
+ 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
97
  { 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" },
98
+ 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
99
  { 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" },
100
+ promptHint: "smash cut: an abrupt jarring transition between two visually or tonally contrasting shots with no fade, on a beat" , instant: true },
89
101
  { id: "iris", label: "Iris", category: "standard", description: "Circular iris closes, then opens on second",
90
102
  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
103
  { id: "wipe", label: "Wipe", category: "standard", description: "Linear wipe replaces first shot",
@@ -93,11 +105,11 @@ export const TRANSITIONS: ReadonlyArray<Transition> = [
93
105
  { id: "roll-transition", label: "Roll", category: "standard", description: "Frame rolls 90-180°, second shot upright on landing",
94
106
  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
107
  { 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" },
108
+ 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
109
  { id: "whip-pan", label: "Whip Pan", category: "standard", description: "Camera whips sideways into blur, next shot rides the same direction",
98
110
  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
111
  { 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" },
112
+ 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
113
 
102
114
  // ============================================================================
103
115
  // TIME — 8 entries — temporal shifts (same or related scene, different time, or memory)
@@ -219,11 +231,11 @@ export const TRANSITIONS: ReadonlyArray<Transition> = [
219
231
  { id: "vehicle-explosion", label: "Vehicle Explosion", category: "physics", description: "Vehicle detonates in foreground, scene changes behind",
220
232
  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
233
  { 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" },
234
+ 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
235
  { id: "hand-swipe", label: "Hand Swipe", category: "physics", description: "Hand swipes across lens, scene changes during occlusion",
224
236
  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
237
  { 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" },
238
+ 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
239
 
228
240
  // ============================================================================
229
241
  // LIGHT — 8 entries — flash and lens FX
@@ -314,6 +326,23 @@ export function getTransitionTerm(id: string | undefined | null): string {
314
326
 
315
327
  export const TRANSITION_IDS: ReadonlyArray<string> = TRANSITIONS.map((t) => t.id)
316
328
 
329
+ /**
330
+ * Whether a transition is a CUT — instantaneous by nature, so it takes no
331
+ * duration (see `Transition.instant`). Reads through `getTransition`, so it
332
+ * answers for the same entry every other getter describes; an unknown id, the
333
+ * no-op "auto" and an empty value are all `false`.
334
+ *
335
+ * A multi-pick (`string[]`) is instant only when EVERY picked id is: a cut
336
+ * paired with a dissolve still has a dissolve to time.
337
+ */
338
+ export function isInstantTransition(
339
+ id: string | ReadonlyArray<string> | undefined | null,
340
+ ): boolean {
341
+ const ids = typeof id === "string" ? [id] : id ? [...id] : []
342
+ if (ids.length === 0) return false
343
+ return ids.every((one) => getTransition(one)?.instant === true)
344
+ }
345
+
317
346
  // ---------------------------------------------------------------------------
318
347
  // Graph-aware composer — start/end input handles + timing fields + multi-pick
319
348
  // ---------------------------------------------------------------------------
@@ -395,6 +424,8 @@ const INTENSITY_CLAUSES = clausesOf(TRANSITION_INTENSITIES)
395
424
  * - 0 hints (no transition, empty array, or all-empty hints) → ""
396
425
  * - n base hints joined with ", and "
397
426
  * - Timing/start/end clauses apply ONCE at the outer layer, not per-id
427
+ * - The duration clause is dropped when every picked id is instant (a cut —
428
+ * see `isInstantTransition`); position and intensity still apply
398
429
  * - null input is treated like undefined (falsy short-circuit → returns "")
399
430
  *
400
431
  * @param mode `"compact"` builds the base from each transition's short
@@ -416,7 +447,10 @@ export function composeTransitionHintFromConnections(
416
447
  // ONLY the base fragment swaps in compact mode — the multi-pick join, the
417
448
  // timing clauses and the start/end clauses below are identical either way.
418
449
  const resolveBase = mode === "compact" ? getTransitionTerm : getTransitionPromptHint
419
- const baseHints = ids.map(resolveBase).filter((h) => h.length > 0)
450
+ // Only ids that contribute a base hint count below — a no-op "auto" beside a
451
+ // cut must not make the pick look non-instant.
452
+ const picked = ids.filter((id) => resolveBase(id).length > 0)
453
+ const baseHints = picked.map(resolveBase)
420
454
  if (baseHints.length === 0) return ""
421
455
 
422
456
  const combinedBase = baseHints.join(", and ")
@@ -425,7 +459,10 @@ export function composeTransitionHintFromConnections(
425
459
  if (timing?.position && timing.position !== "auto") {
426
460
  parts.push(POSITION_CLAUSES[timing.position])
427
461
  }
428
- if (timing?.duration && timing.duration !== "auto") {
462
+ // A cut has no duration: "lasting approximately 1 second" on a match cut
463
+ // makes the model render a one-second dissolve. Skipped only when EVERY
464
+ // picked id is instant — a mixed pick still has a non-cut to time.
465
+ if (timing?.duration && timing.duration !== "auto" && !isInstantTransition(picked)) {
429
466
  parts.push(DURATION_CLAUSES[timing.duration])
430
467
  }
431
468
  if (timing?.intensity && timing.intensity !== "auto") {
@@ -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