@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.
- package/dist/index.cjs +8214 -7143
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +213 -61
- package/dist/index.d.ts +213 -61
- package/dist/index.js +8206 -7145
- package/dist/index.js.map +1 -1
- package/package.json +2 -2
- package/src/__tests__/adult-only-ratchet.test.ts +9 -1
- package/src/__tests__/catalog-packs.test.ts +13 -0
- package/src/__tests__/character-motion-audit.test.ts +77 -0
- package/src/__tests__/character-motion-exit-visibility.test.ts +183 -0
- package/src/__tests__/character-motion-partner-referent.test.ts +225 -0
- package/src/__tests__/character-motion.test.ts +11 -4
- package/src/__tests__/factory-presets.test.ts +28 -3
- package/src/__tests__/fixtures/parameter-hint-golden.json +22 -6
- package/src/__tests__/frame-delivery.test.ts +93 -0
- package/src/__tests__/graph-composed-unwired-identity.test.ts +166 -0
- package/src/__tests__/node-prompt-fields.test.ts +2 -2
- package/src/__tests__/parameter-prompt-hint.test.ts +9 -3
- package/src/__tests__/transitions-instant.test.ts +112 -0
- package/src/ad-creative-analysis.ts +99 -0
- package/src/catalog-packs.ts +13 -2
- package/src/character-motion/animals-pets.ts +112 -85
- package/src/character-motion/athletic-stunts.ts +155 -214
- package/src/character-motion/camera-interaction.ts +92 -133
- package/src/character-motion/combat-weapons.ts +143 -202
- package/src/character-motion/dance.ts +121 -169
- package/src/character-motion/entrances-exits.ts +88 -109
- package/src/character-motion/evasive-falls.ts +88 -130
- package/src/character-motion/everyday-actions.ts +233 -274
- package/src/character-motion/face-expression.ts +125 -178
- package/src/character-motion/gestures.ts +211 -304
- package/src/character-motion/head-gestures.ts +66 -94
- package/src/character-motion/idle-ambient.ts +85 -124
- package/src/character-motion/posture-shifts.ts +116 -139
- package/src/character-motion/runway.ts +73 -97
- package/src/character-motion/stage-performance.ts +92 -127
- package/src/character-motion/turns-looks.ts +86 -112
- package/src/character-motion/two-person.ts +241 -229
- package/src/character-motion/types.ts +4 -1
- package/src/character-motion/unnatural-horror.ts +53 -91
- package/src/character-motion/vehicles-mounts.ts +101 -97
- package/src/character-motion/walks-runs.ts +93 -121
- package/src/character-motion-diagnostics.ts +49 -0
- package/src/character-motion.ts +89 -13
- package/src/factory-presets/generate-video.ts +22 -0
- package/src/frame-delivery.ts +106 -0
- package/src/index.ts +7 -0
- package/src/node-prompt-fields.ts +4 -0
- package/src/parameter-prompt-hint.ts +23 -17
- package/src/picker-catalogs.ts +23 -2
- package/src/ref-binding.ts +19 -0
- package/src/transitions.ts +47 -10
- 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
|
|
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 }),
|
package/src/picker-catalogs.ts
CHANGED
|
@@ -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. */
|
package/src/ref-binding.ts
CHANGED
|
@@ -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
|
+
}
|
package/src/transitions.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
|