@nodaro/prompts 1.7.2 → 1.8.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 (76) hide show
  1. package/dist/index.cjs +5453 -4271
  2. package/dist/index.cjs.map +1 -1
  3. package/dist/index.d.cts +1221 -63
  4. package/dist/index.d.ts +1221 -63
  5. package/dist/index.js +5366 -4273
  6. package/dist/index.js.map +1 -1
  7. package/package.json +1 -1
  8. package/src/__tests__/b7-person-pack-e2e.test.ts +37 -0
  9. package/src/__tests__/catalog-funnel-ratchet.test.ts +97 -0
  10. package/src/__tests__/catalog-packs.test.ts +130 -0
  11. package/src/__tests__/catalog-sidecar-coverage.test.ts +27 -0
  12. package/src/__tests__/catalog-terms.test.ts +162 -0
  13. package/src/__tests__/character-default-role.test.ts +3 -2
  14. package/src/__tests__/content-free-contract.test.ts +45 -0
  15. package/src/__tests__/dod-replace-pack-acceptance.test.ts +41 -0
  16. package/src/__tests__/fixtures/parameter-hint-golden.json +2959 -0
  17. package/src/__tests__/fixtures/person-sector-pack.ts +27 -0
  18. package/src/__tests__/i18n-entry-completeness.test.ts +5 -2
  19. package/src/__tests__/parameter-hint-mode.test.ts +385 -0
  20. package/src/__tests__/parameter-prompt-hint-pack-fallback.test.ts +25 -0
  21. package/src/__tests__/person-packs.test.ts +166 -0
  22. package/src/__tests__/project-all-catalogs.test.ts +26 -0
  23. package/src/__tests__/prompt-builder.test.ts +53 -0
  24. package/src/__tests__/registered-catalogs-funnel.test.ts +30 -0
  25. package/src/__tests__/registered-catalogs-guard.test.ts +11 -0
  26. package/src/__tests__/term.test.ts +104 -0
  27. package/src/__tests__/transitions.test.ts +8 -5
  28. package/src/__tests__/upstream-immutability.test.ts +23 -0
  29. package/src/action-fx.ts +67 -17
  30. package/src/aesthetic.ts +54 -6
  31. package/src/atmosphere.ts +64 -23
  32. package/src/backdrop.ts +44 -30
  33. package/src/camera-format.ts +33 -11
  34. package/src/camera-motions.ts +89 -0
  35. package/src/catalog-packs.ts +125 -0
  36. package/src/catalog-sidecar-coverage.ts +36 -0
  37. package/src/character-fx.ts +112 -39
  38. package/src/color-look.ts +44 -27
  39. package/src/composition-effects.ts +21 -7
  40. package/src/era.ts +24 -0
  41. package/src/exposure-settings.ts +76 -18
  42. package/src/framing.ts +100 -0
  43. package/src/held-prop.ts +125 -63
  44. package/src/identity-lock.ts +12 -5
  45. package/src/image-reference-doctrine.ts +55 -0
  46. package/src/index.ts +6 -0
  47. package/src/instrumentation.ts +148 -57
  48. package/src/lens.ts +31 -15
  49. package/src/lighting.ts +120 -59
  50. package/src/loop-subject.ts +27 -1
  51. package/src/materials.ts +123 -69
  52. package/src/mood.ts +123 -51
  53. package/src/music-genre.ts +171 -67
  54. package/src/music-mood.ts +85 -17
  55. package/src/parameter-prompt-hint.ts +168 -56
  56. package/src/person-packs.ts +182 -0
  57. package/src/person.ts +506 -408
  58. package/src/photo-genre.ts +37 -22
  59. package/src/photographer.ts +138 -1
  60. package/src/picker-catalogs.ts +86 -40
  61. package/src/picker-wiring.ts +1 -1
  62. package/src/pose.ts +105 -40
  63. package/src/post-process-effects.ts +51 -8
  64. package/src/prompt-builder.ts +16 -1
  65. package/src/provider-prompt-doctrine.ts +1 -2
  66. package/src/render-quality.ts +25 -7
  67. package/src/setting.ts +30 -14
  68. package/src/style-presets.ts +1 -1
  69. package/src/style.ts +33 -15
  70. package/src/styling.ts +155 -94
  71. package/src/surround-fill.ts +67 -0
  72. package/src/temporal.ts +80 -18
  73. package/src/term.ts +155 -0
  74. package/src/transitions.ts +73 -26
  75. package/src/voice-character.ts +190 -90
  76. package/src/voice-delivery.ts +83 -12
package/src/temporal.ts CHANGED
@@ -15,6 +15,8 @@
15
15
  * frontend DAG executor and the backend orchestrator.
16
16
  */
17
17
 
18
+ import { resolveTerm, type PickerHintMode } from "./term.js"
19
+
18
20
  export type TemporalCategory = "speed" | "freeze" | "direction" | "shutter"
19
21
 
20
22
  export interface Temporal {
@@ -23,33 +25,43 @@ export interface Temporal {
23
25
  readonly category: TemporalCategory
24
26
  readonly description: string
25
27
  readonly promptHint: string
28
+ /**
29
+ * Compact professional term injected in compact hint mode (see `term.ts`).
30
+ *
31
+ * Authored where the picker label is a UI compound ("Reverse / Rewind",
32
+ * "Loop / Boomerang") or a bare word that reads as something else entirely
33
+ * inside a prompt — "Forward" as a camera move rather than playback
34
+ * direction, "Moving Subject" as a plain description of the action rather
35
+ * than the frozen-world effect it selects here.
36
+ */
37
+ readonly term?: string
26
38
  }
27
39
 
28
40
  export const TEMPORALS: ReadonlyArray<Temporal> = [
29
41
  // Speed (6)
30
- { id: "real-time", label: "Real-time", category: "speed", description: "Normal playback speed", promptHint: "real-time playback, normal speed" },
42
+ { id: "real-time", label: "Real-time", category: "speed", description: "Normal playback speed", promptHint: "real-time playback, normal speed", term: "real-time playback" },
31
43
  { id: "slow-motion", label: "Slow Motion", category: "speed", description: "Moderately slowed footage", promptHint: "slow motion, footage slowed down with smooth deliberate movement" },
32
- { id: "super-slow-mo", label: "Super Slow-mo", category: "speed", description: "Extremely slow footage", promptHint: "super slow motion, extreme high-speed slow-mo capturing motion at a fraction of real time" },
44
+ { id: "super-slow-mo", label: "Super Slow-mo", category: "speed", description: "Extremely slow footage", promptHint: "super slow motion, extreme high-speed slow-mo capturing motion at a fraction of real time", term: "super slow motion" },
33
45
  { id: "time-lapse", label: "Time-lapse", category: "speed", description: "Compressed time, fast passage", promptHint: "time-lapse, highly compressed time showing hours or days passing in seconds" },
34
46
  { id: "hyper-lapse", label: "Hyper-lapse", category: "speed", description: "Moving time-lapse", promptHint: "hyper-lapse, time-lapse combined with forward camera motion, accelerated movement through space" },
35
47
  { id: "speed-ramp", label: "Speed Ramp", category: "speed", description: "Dynamic speed change mid-shot", promptHint: "speed ramp, dynamic speed change within the shot from slow motion to real-time or faster" },
36
48
 
37
49
  // Freeze (4)
38
- { id: "full-freeze", label: "Full Freeze-frame", category: "freeze", description: "All motion frozen", promptHint: "full freeze-frame, all motion in the scene completely frozen like a photograph" },
50
+ { id: "full-freeze", label: "Full Freeze-frame", category: "freeze", description: "All motion frozen", promptHint: "full freeze-frame, all motion in the scene completely frozen like a photograph", term: "freeze frame" },
39
51
  { id: "bullet-time", label: "Bullet Time", category: "freeze", description: "Subject frozen, camera orbits", promptHint: "bullet time effect, subject completely frozen mid-motion while camera orbits around them, Matrix-style" },
40
- { id: "frozen-subject", label: "Frozen Subject", category: "freeze", description: "Subject frozen, world moves", promptHint: "frozen subject with moving world, subject remains completely still while the environment continues in motion" },
41
- { id: "moving-subject", label: "Moving Subject", category: "freeze", description: "Subject moves, world frozen", promptHint: "moving subject with frozen world, subject continues in motion while everything else in the scene is completely frozen" },
52
+ { id: "frozen-subject", label: "Frozen Subject", category: "freeze", description: "Subject frozen, world moves", promptHint: "frozen subject with moving world, subject remains completely still while the environment continues in motion", term: "frozen subject, world in motion" },
53
+ { id: "moving-subject", label: "Moving Subject", category: "freeze", description: "Subject moves, world frozen", promptHint: "moving subject with frozen world, subject continues in motion while everything else in the scene is completely frozen", term: "moving subject, frozen world" },
42
54
 
43
55
  // Direction (3)
44
- { id: "forward", label: "Forward", category: "direction", description: "Normal forward playback", promptHint: "forward playback, time moving forward in the natural direction" },
45
- { id: "reverse", label: "Reverse / Rewind", category: "direction", description: "Time plays backwards", promptHint: "reverse playback, time and motion running backwards" },
46
- { id: "loop-boomerang", label: "Loop / Boomerang", category: "direction", description: "Forward then reverse", promptHint: "boomerang loop, playing forward then reversing back, creating a looping motion" },
56
+ { id: "forward", label: "Forward", category: "direction", description: "Normal forward playback", promptHint: "forward playback, time moving forward in the natural direction", term: "forward playback" },
57
+ { id: "reverse", label: "Reverse / Rewind", category: "direction", description: "Time plays backwards", promptHint: "reverse playback, time and motion running backwards", term: "reverse playback" },
58
+ { id: "loop-boomerang", label: "Loop / Boomerang", category: "direction", description: "Forward then reverse", promptHint: "boomerang loop, playing forward then reversing back, creating a looping motion", term: "boomerang loop" },
47
59
 
48
60
  // Shutter (5)
49
61
  { id: "long-exposure", label: "Long Exposure", category: "shutter", description: "Motion trails and streaks", promptHint: "long exposure effect, motion trails and light streaks from slow shutter speed" },
50
- { id: "crisp-shutter", label: "Crisp Shutter", category: "shutter", description: "Sharp motion, no blur", promptHint: "crisp shutter, sharp motion captured with fast shutter speed and no motion blur" },
62
+ { id: "crisp-shutter", label: "Crisp Shutter", category: "shutter", description: "Sharp motion, no blur", promptHint: "crisp shutter, sharp motion captured with fast shutter speed and no motion blur", term: "fast shutter, sharp motion" },
51
63
  { id: "motion-blur", label: "Motion Blur", category: "shutter", description: "Pronounced directional blur", promptHint: "pronounced motion blur, moving subjects blur directionally from slower-than-normal shutter speed" },
52
- { id: "stutter-strobe", label: "Stutter / Strobe", category: "shutter", description: "Strobe-effect jerky motion", promptHint: "stutter or strobe effect, jerky discontinuous motion with visible frame steps" },
64
+ { id: "stutter-strobe", label: "Stutter / Strobe", category: "shutter", description: "Strobe-effect jerky motion", promptHint: "stutter or strobe effect, jerky discontinuous motion with visible frame steps", term: "strobe stutter effect" },
53
65
  { id: "stop-motion", label: "Stop-motion", category: "shutter", description: "Stepped frame-by-frame motion", promptHint: "stop-motion animation style, stepped frame-by-frame motion with characteristic discrete movement" },
54
66
  ] as const
55
67
 
@@ -85,6 +97,16 @@ export function getTemporalPromptHint(id: string | undefined | null): string {
85
97
  return getTemporal(id)?.promptHint ?? ""
86
98
  }
87
99
 
100
+ /**
101
+ * The COMPACT counterpart of `getTemporalPromptHint`: the short professional
102
+ * term ("super slow motion", "reverse playback", "boomerang loop") a consumer
103
+ * injects instead of the full mechanism description. Same lookup, same
104
+ * empty-string-on-miss behavior.
105
+ */
106
+ export function getTemporalTerm(id: string | undefined | null): string {
107
+ return resolveTerm(getTemporal(id))
108
+ }
109
+
88
110
  export const TEMPORAL_IDS: ReadonlyArray<string> = TEMPORALS.map((t) => t.id)
89
111
 
90
112
  /**
@@ -121,6 +143,34 @@ export interface TemporalValue {
121
143
  const TEMPORAL_FIELDS_IN_ORDER: ReadonlyArray<readonly [keyof TemporalValue, TemporalCategory]> =
122
144
  TEMPORAL_CATEGORY_ORDER.map((cat) => [TEMPORAL_FIELD_BY_CATEGORY[cat], cat] as const)
123
145
 
146
+ type TemporalHintData = Record<string, unknown> & {
147
+ temporalSpeed?: unknown
148
+ temporalFreeze?: unknown
149
+ temporalDirection?: unknown
150
+ temporalShutter?: unknown
151
+ }
152
+
153
+ /**
154
+ * The one walk over the per-category temporal fields, parameterized by how a
155
+ * single selected id turns into a fragment. `buildTemporalHints` (verbose) and
156
+ * `buildTemporalTerms` (compact) both delegate here, so the two can never
157
+ * disagree about WHICH ids contribute or in what order — only about how each
158
+ * one is phrased.
159
+ */
160
+ function collectTemporalFragments(
161
+ data: TemporalHintData,
162
+ fragmentFor: (id: string) => string,
163
+ ): string[] {
164
+ const out: string[] = []
165
+ for (const [field] of TEMPORAL_FIELDS_IN_ORDER) {
166
+ const id = data[field]
167
+ if (typeof id !== "string" || id.length === 0) continue
168
+ const fragment = fragmentFor(id)
169
+ if (fragment) out.push(fragment)
170
+ }
171
+ return out
172
+ }
173
+
124
174
  /**
125
175
  * Aggregate all enabled per-category temporal prompt hints from a consumer's
126
176
  * data, in canonical category order (speed, freeze, direction, shutter).
@@ -139,13 +189,25 @@ export function buildTemporalHints(
139
189
  temporalDirection?: unknown
140
190
  temporalShutter?: unknown
141
191
  },
192
+ mode: PickerHintMode = "full",
142
193
  ): string[] {
143
- const hints: string[] = []
144
- for (const [field] of TEMPORAL_FIELDS_IN_ORDER) {
145
- const id = data[field]
146
- if (typeof id !== "string" || id.length === 0) continue
147
- const hint = getTemporalPromptHint(id)
148
- if (hint) hints.push(hint)
149
- }
150
- return hints
194
+ if (mode === "compact") return buildTemporalTerms(data)
195
+ return collectTemporalFragments(data, getTemporalPromptHint)
196
+ }
197
+
198
+ /**
199
+ * The COMPACT counterpart of `buildTemporalHints`: the same per-category walk
200
+ * in the same canonical order, emitting each selection's short professional
201
+ * term ("super slow motion", "bullet time", "reverse playback") instead of its
202
+ * full mechanism description.
203
+ */
204
+ export function buildTemporalTerms(
205
+ data: Record<string, unknown> & {
206
+ temporalSpeed?: unknown
207
+ temporalFreeze?: unknown
208
+ temporalDirection?: unknown
209
+ temporalShutter?: unknown
210
+ },
211
+ ): string[] {
212
+ return collectTemporalFragments(data, getTemporalTerm)
151
213
  }
package/src/term.ts ADDED
@@ -0,0 +1,155 @@
1
+ /**
2
+ * Compact professional TERMS for picker-catalog entries.
3
+ *
4
+ * Every picker catalog entry carries a long `promptHint` — a full mechanism
5
+ * description injected downstream by `getParameterPromptHint`. A `term` is the
6
+ * SHORT form of the same entry: the two-to-four word phrase a professional
7
+ * would actually write in a prompt ("whip pan left", "hard cut", "medium
8
+ * close-up") when the consumer wants a compact instruction instead of a
9
+ * paragraph.
10
+ *
11
+ * The split of responsibilities is:
12
+ * - `label` → what USERS see in the picker.
13
+ * - `promptHint` → what MODELS read in verbose ("full") hint mode.
14
+ * - `term` → what MODELS read in compact hint mode.
15
+ *
16
+ * ---------------------------------------------------------------------------
17
+ * THE CONVENTION EVERY CATALOG FOLLOWS
18
+ * ---------------------------------------------------------------------------
19
+ * 1. The catalog's entry interface gains an OPTIONAL `term?: string`. It is
20
+ * authored only where the label does not already read as the professional
21
+ * term (see `isSuspiciousDerivedTerm` for the failure shapes); everywhere
22
+ * else the lowercased label IS the term and no data is added.
23
+ * 2. Alongside each `get<Name>PromptHint(id)` getter, the catalog exports a
24
+ * sibling `get<Name>Term(id)` implemented as
25
+ * `export function get<Name>Term(id: string | undefined | null): string {
26
+ * return resolveTerm(get<Name>(id))
27
+ * }`
28
+ * — same arity, same lookup, same empty-string-on-miss behavior, so the two
29
+ * getters can never disagree about which entry they are describing.
30
+ * 3. An authored `term` is:
31
+ * - lowercase,
32
+ * - at most `TERM_MAX_CHARS` characters,
33
+ * - at most 8 words,
34
+ * - with NO trailing period,
35
+ * - phrased the way a professional cinematographer / photographer /
36
+ * sound designer / stylist would write it in a prompt.
37
+ * Entries with no standard trade term (exotic morph/portal transitions and
38
+ * the like) get a short descriptive phrase instead — never a sentence, and
39
+ * never the long `promptHint`.
40
+ * 4. An entry whose `promptHint` is `""` — the no-op "auto" / "none" entries —
41
+ * injects NOTHING, so its resolved term is `""` too. `resolveTerm` enforces
42
+ * that; do not author a `term` on such an entry expecting it to be used.
43
+ *
44
+ * A guard test (`__tests__/catalog-terms.test.ts`) walks every registered
45
+ * catalog and fails for entries whose label cannot be safely lowercased into a
46
+ * term and that have no explicit `term` authored — so the convention above is
47
+ * enforced, not merely documented.
48
+ */
49
+
50
+ /** Hard cap on an authored/derived term. Longer than this is a hint, not a term. */
51
+ export const TERM_MAX_CHARS = 60
52
+
53
+ /**
54
+ * How verbose a picker node's injected fragment is.
55
+ *
56
+ * - `"full"` — the long `promptHint` (the historical, and still default,
57
+ * behavior; output is byte-identical to before hint modes
58
+ * existed).
59
+ * - `"compact"` — the short professional `term` instead.
60
+ *
61
+ * ONLY the base catalog fragment swaps. The user's `preText` / `postText`
62
+ * free text, the transition / camera-motion / character-fx timing and
63
+ * start-state/end-state clauses, multi-pick joining, and multi-dimension
64
+ * composition all apply exactly the same in both modes.
65
+ *
66
+ * "The same" means the same MEANING, not always the same string. Where a
67
+ * wrapper is grammatically fused to the long hint it cannot simply be reused:
68
+ * the character-fx composer names its target by rewriting the words "the
69
+ * subject" inside a full hint, and a bare term has no such words, so compact
70
+ * mode names the target with an explicit `"{target}: {effect}"` prefix. When a
71
+ * catalog's grammar IS its meaning — Material's `"made of ..."`, Held Prop's
72
+ * `"holding a ..."` — that grammar is authored into the term itself rather
73
+ * than left for a consumer to re-add, because a projected `term` is injected
74
+ * standalone by thin clients that have no composer at all.
75
+ *
76
+ * A picker node selects the mode via an optional `hintMode` field on its node
77
+ * data; absent (or any unrecognized value) means `"full"`.
78
+ */
79
+ export type PickerHintMode = "full" | "compact"
80
+
81
+ /**
82
+ * The minimal shape of a catalog entry that can resolve a term: an id, the
83
+ * user-facing label, the long hint (whose emptiness marks a no-op entry), and
84
+ * the optional authored short term.
85
+ */
86
+ export interface TermCarrier {
87
+ readonly id: string
88
+ readonly label: string
89
+ readonly promptHint: string
90
+ readonly term?: string
91
+ }
92
+
93
+ /** Parenthetical segments — "Ultra-wide (14mm)", "ISO 1600 (visible grain)". */
94
+ const PARENTHETICAL = /\([^)]*\)/g
95
+
96
+ /**
97
+ * Mechanical label → term derivation: lowercase, drop parenthetical segments,
98
+ * collapse whitespace, trim.
99
+ *
100
+ * Deliberately NOT clever: it does not split on "/" or strip category nouns.
101
+ * A label like "None / Hard Cut" or "Fog / Mist" has to SURFACE as suspicious
102
+ * (see `isSuspiciousDerivedTerm`) so a human authors the right term — guessing
103
+ * here would quietly inject the wrong wording into every prompt.
104
+ */
105
+ export function deriveTerm(label: string): string {
106
+ return label
107
+ .replace(PARENTHETICAL, " ")
108
+ .toLowerCase()
109
+ .replace(/\s+/g, " ")
110
+ .trim()
111
+ }
112
+
113
+ /** Punctuation that marks a UI compound / annotation rather than a trade term. */
114
+ const SUSPICIOUS_CHARS = ["/", "(", ")", ":", "&", "→", "×"] as const
115
+
116
+ export interface SuspiciousTermOptions {
117
+ /**
118
+ * Treat a single-word derived term as suspicious. Set for catalogs whose
119
+ * labels are bare MODIFIERS that only read as a professional term with
120
+ * their category noun attached — lighting "Short" → "short lighting",
121
+ * color-look "Warm" → "warm grade".
122
+ */
123
+ readonly bareWordSuspicious?: boolean
124
+ }
125
+
126
+ /**
127
+ * Is the mechanically-derived term unsafe to inject as-is?
128
+ *
129
+ * True when the label is a UI compound or carries an annotation ("None / Hard
130
+ * Cut", "Ultra-wide (14mm)", "Key: Rembrandt"), when nothing survives the
131
+ * derivation, or — under `bareWordSuspicious` — when the result is a lone word
132
+ * that needs its category noun to mean anything to a model.
133
+ */
134
+ export function isSuspiciousDerivedTerm(
135
+ label: string,
136
+ opts: SuspiciousTermOptions = {},
137
+ ): boolean {
138
+ if (SUSPICIOUS_CHARS.some((c) => label.includes(c))) return true
139
+ const derived = deriveTerm(label)
140
+ if (derived.length === 0) return true
141
+ if (opts.bareWordSuspicious && !derived.includes(" ")) return true
142
+ return false
143
+ }
144
+
145
+ /**
146
+ * The single resolution point every consumer reads: an explicit `term` when
147
+ * the catalog authored one, the derived label otherwise, and `""` for a no-op
148
+ * entry (missing entry, or an "auto"/"none" entry whose `promptHint` is empty
149
+ * and which therefore injects nothing).
150
+ */
151
+ export function resolveTerm(entry: TermCarrier | undefined | null): string {
152
+ if (!entry) return ""
153
+ if (entry.promptHint === "") return ""
154
+ return entry.term ?? deriveTerm(entry.label)
155
+ }
@@ -12,6 +12,8 @@
12
12
  * "starting from <X>, ending at <Y>".
13
13
  */
14
14
 
15
+ import { resolveTerm, type PickerHintMode } from "./term.js"
16
+
15
17
  export type TransitionCategory =
16
18
  | "standard"
17
19
  | "time"
@@ -28,6 +30,16 @@ export interface Transition {
28
30
  readonly category: TransitionCategory
29
31
  readonly description: string
30
32
  readonly promptHint: string
33
+ /**
34
+ * Optional authored compact term (see `term.ts`). Authored where the
35
+ * lowercased label is not what an editor would write in a prompt — a UI
36
+ * compound ("None / Hard Cut" → "hard cut"), an annotation the derivation
37
+ * drops ("Fast-Forward (Day → Night)" → "day-to-night time-lapse"), a bare
38
+ * word that collides with another meaning ("Melt Down", "Channel Flip",
39
+ * "Roll"), or a coinage that is not the trade term ("Seamless Match" →
40
+ * "invisible cut"). Everywhere else the label IS the term.
41
+ */
42
+ readonly term?: string
31
43
  }
32
44
 
33
45
  export type TransitionPosition = "auto" | "start" | "middle" | "end" | "full"
@@ -42,47 +54,53 @@ export interface TransitionTiming {
42
54
 
43
55
  export const TRANSITIONS: ReadonlyArray<Transition> = [
44
56
  // ============================================================================
45
- // STANDARD — 11 entries — classical editing transitions
57
+ // STANDARD — 14 entries — classical editing transitions
46
58
  // ============================================================================
47
59
  { id: "auto", label: "Auto", category: "standard", description: "Let the model choose", promptHint: "" },
48
60
  { id: "none", label: "None / Hard Cut", category: "standard", description: "Instantaneous switch, no transition",
49
- promptHint: "no transition, hard cut, instantaneous switch from first shot to second shot" },
61
+ promptHint: "no transition, hard cut, instantaneous switch from first shot to second shot", term: "hard cut" },
50
62
  { id: "cross-dissolve", label: "Cross-Dissolve", category: "standard", description: "Gradual blend between shots",
51
63
  promptHint: "smooth cross-dissolve transition where the first shot gradually fades out as the second shot fades in" },
52
64
  { id: "fade-to-black", label: "Fade to Black", category: "standard", description: "Darkens to black, second emerges",
53
65
  promptHint: "fade to black: the first shot gradually darkens to full black, holds briefly, then the second shot fades up from black" },
54
66
  { id: "fade-to-white", label: "Fade to White", category: "standard", description: "Blooms to white, second emerges",
55
67
  promptHint: "fade to white: the first shot brightens until the frame is pure white, then the second shot resolves out of the white" },
68
+ { id: "snap-to-black", label: "Snap to Black", category: "standard", description: "Instant cut to full black for a beat, then the next shot",
69
+ 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" },
56
70
  { id: "match-cut", label: "Match Cut", category: "standard", description: "Shape or motion match across shots",
57
71
  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" },
58
72
  { id: "smash-cut", label: "Smash Cut", category: "standard", description: "Jarring abrupt cut between contrasting shots",
59
73
  promptHint: "smash cut: an abrupt jarring transition between two visually or tonally contrasting shots with no fade, on a beat" },
60
74
  { id: "iris", label: "Iris", category: "standard", description: "Circular iris closes, then opens on second",
61
- 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" },
75
+ 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" },
62
76
  { id: "wipe", label: "Wipe", category: "standard", description: "Linear wipe replaces first shot",
63
- promptHint: "linear wipe transition: a clean diagonal line sweeps across the frame, revealing the second shot behind it" },
77
+ promptHint: "linear wipe transition: a clean diagonal line sweeps across the frame, revealing the second shot behind it", term: "linear wipe" },
64
78
  { id: "roll-transition", label: "Roll", category: "standard", description: "Frame rolls 90-180°, second shot upright on landing",
65
- 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" },
79
+ 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" },
66
80
  { id: "seamless-match", label: "Seamless Match", category: "standard", description: "Hidden cut disguised by matched motion and color",
67
- 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" },
81
+ 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" },
82
+ { id: "whip-pan", label: "Whip Pan", category: "standard", description: "Camera whips sideways into blur, next shot rides the same direction",
83
+ 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" },
84
+ { id: "jump-cut", label: "Jump Cut", category: "standard", description: "Same framing, time skips forward",
85
+ 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" },
68
86
 
69
87
  // ============================================================================
70
88
  // TIME — 8 entries — temporal shifts (same or related scene, different time, or memory)
71
89
  // ============================================================================
72
90
  { id: "fast-forward-day-night", label: "Fast-Forward (Day → Night)", category: "time", description: "Time-lapse day to night same scene",
73
- promptHint: "fast-forward time-lapse transition: the sun visibly arcs across the sky, shadows sweep, clouds streak, sky shifts from daylight blue through golden hour to deep night, stars emerge, all while framing and camera position remain locked on the same scene" },
91
+ promptHint: "fast-forward time-lapse transition: the sun visibly arcs across the sky, shadows sweep, clouds streak, sky shifts from daylight blue through golden hour to deep night, stars emerge, all while framing and camera position remain locked on the same scene", term: "day-to-night time-lapse" },
74
92
  { id: "fast-forward-night-day", label: "Fast-Forward (Night → Day)", category: "time", description: "Time-lapse night to dawn same scene",
75
- promptHint: "fast-forward time-lapse transition: stars fade, the sky shifts from deep night through pre-dawn blue to golden sunrise, shadows sweep in reverse, all while framing and camera position remain locked on the same scene" },
93
+ promptHint: "fast-forward time-lapse transition: stars fade, the sky shifts from deep night through pre-dawn blue to golden sunrise, shadows sweep in reverse, all while framing and camera position remain locked on the same scene", term: "night-to-day time-lapse" },
76
94
  { id: "seasonal-shift", label: "Seasonal Shift", category: "time", description: "Same scene through changing seasons",
77
- 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" },
95
+ 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" },
78
96
  { id: "aging", label: "Aging", category: "time", description: "Subject visibly ages forward in time",
79
- 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" },
97
+ 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" },
80
98
  { id: "rewind", label: "Rewind", category: "time", description: "Time reverses, motion plays backward",
81
- 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" },
99
+ 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" },
82
100
  { id: "freeze-frame-jump", label: "Freeze-Frame Jump", category: "time", description: "Action freezes, jumps forward in time",
83
- 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" },
101
+ 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" },
84
102
  { id: "weather-shift", label: "Weather Shift", category: "time", description: "Same scene through changing weather",
85
- 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" },
103
+ 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" },
86
104
  { id: "flashback", label: "Flashback", category: "time", description: "Memory-flashback into a past moment of the subject",
87
105
  promptHint: "brief flashback transition: the frame washes with a soft warm or desaturated tint, faint ripple distortion crosses the image as the present scene fades, and a remembered earlier moment resolves into focus on the same subject" },
88
106
 
@@ -96,7 +114,7 @@ export const TRANSITIONS: ReadonlyArray<Transition> = [
96
114
  { id: "sand-scatter", label: "Sand Scatter", category: "element", description: "Subject becomes sand, blown away, reforms",
97
115
  promptHint: "the subject crumbles into fine sand that is swept away by a gust of wind in a swirling vortex, then the sand particles converge and re-form into the new subject" },
98
116
  { id: "fire-burnup", label: "Burn-Up", category: "element", description: "Subject burns to embers, embers reform",
99
- promptHint: "the subject ignites and burns from edges inward into glowing embers and ash, the embers swirl through the frame and re-ignite into the new subject" },
117
+ promptHint: "the subject ignites and burns from edges inward into glowing embers and ash, the embers swirl through the frame and re-ignite into the new subject", term: "burn to embers and reform" },
100
118
  { id: "smoke-puff", label: "Smoke Puff", category: "element", description: "Subject vanishes in smoke, reappears",
101
119
  promptHint: "the subject vanishes in a soft puff of smoke that billows outward and fills the frame, the smoke then clears to reveal the new subject in the new scene" },
102
120
  { id: "magic-sparkles", label: "Magic Sparkles", category: "element", description: "Particle dissolve à la Avengers / apparition",
@@ -112,7 +130,7 @@ export const TRANSITIONS: ReadonlyArray<Transition> = [
112
130
  { id: "aurora-sweep", label: "Aurora Sweep", category: "element", description: "Aurora curtain sweeps across, scene changes behind",
113
131
  promptHint: "a luminous green and violet aurora curtain ripples across the entire frame, the bright bands obscure the first scene, and as the aurora dissipates the second scene resolves in the clear sky" },
114
132
  { id: "sakura-petals", label: "Sakura Storm", category: "element", description: "Cherry blossom petals storm across the frame",
115
- promptHint: "a dense storm of cherry blossom petals swirls in from one side and fills the frame in soft pink motion, the petals cluster to fully veil the image, then drift past to reveal the new scene" },
133
+ promptHint: "a dense storm of cherry blossom petals swirls in from one side and fills the frame in soft pink motion, the petals cluster to fully veil the image, then drift past to reveal the new scene", term: "cherry blossom petal storm" },
116
134
  { id: "garden-bloom", label: "Garden Bloom", category: "element", description: "Flowers bloom outward, parting to reveal new scene",
117
135
  promptHint: "lush flowers and vines rapidly grow and bloom outward from the edges of the frame, the foliage spreads to overtake the entire image, then parts open like curtains to reveal the new scene behind" },
118
136
  { id: "powder-burst", label: "Powder Burst", category: "element", description: "Colored powder bursts across frame and clears",
@@ -124,9 +142,9 @@ export const TRANSITIONS: ReadonlyArray<Transition> = [
124
142
  { id: "liquid-morph", label: "Liquid Morph", category: "morph", description: "Subject melts and reforms as new subject",
125
143
  promptHint: "smooth liquid morph: the first subject's surface becomes fluid and continuously deforms, flowing without breaks into the silhouette and details of the second subject" },
126
144
  { id: "pixelate-reform", label: "Pixelate & Reform", category: "morph", description: "Pixelates, scatters, reforms as new",
127
- promptHint: "the first subject pixelates into large mosaic blocks that scatter outward across the frame, then the blocks converge and resolve into the new subject" },
145
+ promptHint: "the first subject pixelates into large mosaic blocks that scatter outward across the frame, then the blocks converge and resolve into the new subject", term: "pixelate and reform" },
128
146
  { id: "shatter-glass", label: "Shatter & Reform", category: "morph", description: "Subject shatters like glass, reforms",
129
- promptHint: "the first subject shatters like glass into hundreds of shards that fly outward, then the shards reverse direction in reverse time and reassemble into the new subject" },
147
+ promptHint: "the first subject shatters like glass into hundreds of shards that fly outward, then the shards reverse direction in reverse time and reassemble into the new subject", term: "shatter like glass and reform" },
130
148
  { id: "origami-fold", label: "Origami Fold", category: "morph", description: "Subject folds like paper into new subject",
131
149
  promptHint: "the first subject creases and folds like sheets of origami paper, the folds rotate and re-arrange in elegant geometric steps, and the final fold reveals the new subject" },
132
150
  { id: "vortex-swirl", label: "Vortex Swirl", category: "morph", description: "Subject swirls into vortex, unwinds as new",
@@ -138,10 +156,10 @@ export const TRANSITIONS: ReadonlyArray<Transition> = [
138
156
  { id: "polygon-shatter", label: "Polygon Shatter", category: "morph", description: "Subject fragments into low-poly chunks, reassembles",
139
157
  promptHint: "the first subject fractures into low-polygon faceted chunks that explode outward in slow motion, the polygons then reverse course and re-assemble in clean geometric flight paths into the silhouette of the new subject" },
140
158
  { id: "melt-down", label: "Melt Down", category: "morph", description: "Subject melts into puddle, reforms as new",
141
- promptHint: "the first subject's form softens and melts downward like wax, collapsing into a glossy puddle on the ground, the puddle then surges upward and re-solidifies into the new subject standing in the new scene" },
159
+ promptHint: "the first subject's form softens and melts downward like wax, collapsing into a glossy puddle on the ground, the puddle then surges upward and re-solidifies into the new subject standing in the new scene", term: "melt into a puddle and reform" },
142
160
 
143
161
  // ============================================================================
144
- // PORTAL — 10 entries — zoom-into-object world-jumps
162
+ // PORTAL — 12 entries — zoom-into-object world-jumps
145
163
  // ============================================================================
146
164
  { id: "zoom-into-eye", label: "Zoom Into Eye", category: "portal", description: "Push into pupil, new world inside",
147
165
  promptHint: "the camera pushes into a tight macro of the subject's eye, the pupil dilates and fills the frame, and the new scene materialises from within the pupil as if the pupil itself were a portal" },
@@ -156,16 +174,20 @@ export const TRANSITIONS: ReadonlyArray<Transition> = [
156
174
  { id: "fall-into-hole", label: "Fall Into Hole", category: "portal", description: "Camera falls through opening",
157
175
  promptHint: "the floor or ground opens beneath the camera and the camera falls downward through the opening, tumbling, and emerges into the new scene below" },
158
176
  { id: "pull-out-reveal", label: "Pull-Out Reveal", category: "portal", description: "Reveals scene was a picture in larger context",
159
- 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" },
177
+ 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" },
160
178
  { id: "zoom-into-mouth", label: "Zoom Into Mouth", category: "portal", description: "Push into open mouth, emerges in new world inside",
161
179
  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" },
162
180
  { id: "push-through-glass", label: "Push Through Glass", category: "portal", description: "Camera pushes through pane of glass into new world",
163
181
  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" },
164
182
  { id: "soul-jump", label: "Soul Jump", category: "portal", description: "Translucent soul leaves body, enters new body",
165
- promptHint: "a translucent luminous form rises out of the first subject's body and shoots forward through the frame as a ghost-like soul, then dives into a new body in the new scene where the second subject animates to life" },
183
+ promptHint: "a translucent luminous form rises out of the first subject's body and shoots forward through the frame as a ghost-like soul, then dives into a new body in the new scene where the second subject animates to life", term: "soul leaves body and enters another" },
184
+ { id: "mask-transition", label: "Mask Transition", category: "portal", description: "Foreground object blacks out the frame, camera pulls through",
185
+ promptHint: "mask transition: a foreground object or a passer-by sweeps across the lens and fills the frame with darkness, the camera keeps travelling forward through the black, then pulls out of the darkness into the new scene", term: "mask transition" },
186
+ { id: "zoom-through", label: "Zoom Through", category: "portal", description: "Camera magnifies one detail until the new scene unfolds inside it",
187
+ promptHint: "zoom-through transition: the camera magnifies one small detail of the frame further and further until the detail loses its texture and fills the image entirely, and the new scene unfolds from within it", term: "zoom-through transition" },
166
188
 
167
189
  // ============================================================================
168
- // PHYSICS — 9 entries — force-driven transitions
190
+ // PHYSICS — 10 entries — force-driven transitions
169
191
  // ============================================================================
170
192
  { id: "explosion-blast", label: "Explosion Blast", category: "physics", description: "Explosion wipes frame, new scene emerges",
171
193
  promptHint: "an explosion erupts from the center of the frame with a bright fireball that expands to fill the frame, and as the fireball dissipates the new scene is revealed" },
@@ -182,9 +204,11 @@ export const TRANSITIONS: ReadonlyArray<Transition> = [
182
204
  { id: "vehicle-explosion", label: "Vehicle Explosion", category: "physics", description: "Vehicle detonates in foreground, scene changes behind",
183
205
  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" },
184
206
  { id: "jump-match", label: "Jump Match", category: "physics", description: "Subject jumps, landing matches into new scene",
185
- 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" },
207
+ 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" },
186
208
  { id: "hand-swipe", label: "Hand Swipe", category: "physics", description: "Hand swipes across lens, scene changes during occlusion",
187
209
  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" },
210
+ { id: "action-relay", label: "Action Match", category: "physics", description: "Subject exits on an action and lands in the new scene mid-move",
211
+ 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" },
188
212
 
189
213
  // ============================================================================
190
214
  // LIGHT — 8 entries — flash and lens FX
@@ -216,11 +240,11 @@ export const TRANSITIONS: ReadonlyArray<Transition> = [
216
240
  { id: "datamosh", label: "Datamosh", category: "glitch", description: "Motion-vector smear bleeds scenes",
217
241
  promptHint: "datamosh transition: the motion vectors of the first scene continue smearing into the pixels of the second scene, creating a fluid pixel-bleed handoff" },
218
242
  { id: "channel-flip", label: "Channel Flip", category: "glitch", description: "TV channel flip with static",
219
- promptHint: "a brief burst of TV static and channel-flip artifacts sweeps the frame, and the new scene resolves as if changing channels on an old television" },
243
+ promptHint: "a brief burst of TV static and channel-flip artifacts sweeps the frame, and the new scene resolves as if changing channels on an old television", term: "tv channel flip with static" },
220
244
  { id: "hologram-flicker", label: "Hologram Flicker", category: "glitch", description: "Hologram-style flicker materialises new scene",
221
245
  promptHint: "a hologram-style flicker with horizontal interference bands and chromatic aberration overtakes the frame for a beat, and resolves into the new scene as if it materialised from a projection" },
222
246
  { id: "display-wipe", label: "Display Wipe", category: "glitch", description: "Scene compresses into display, expands to new scene",
223
- promptHint: "the first scene compresses into a small floating display screen at the center of the frame with a CRT power-on/off animation and scanline flicker, the display then expands outward and unfolds into the new scene full-frame" },
247
+ promptHint: "the first scene compresses into a small floating display screen at the center of the frame with a CRT power-on/off animation and scanline flicker, the display then expands outward and unfolds into the new scene full-frame", term: "compress into a screen and expand out" },
224
248
  { id: "double-exposure", label: "Double Exposure", category: "glitch", description: "Two scenes overlay translucent, first fades to second",
225
249
  promptHint: "the first and second scenes blend as a translucent double exposure where both images coexist semi-transparently on the frame, the first image then gradually fades out leaving the second image fully resolved" },
226
250
  ]
@@ -260,6 +284,19 @@ export function getTransitionPromptHint(id: string | undefined | null): string {
260
284
  return getTransition(id)?.promptHint ?? ""
261
285
  }
262
286
 
287
+ /**
288
+ * Compact professional TERM for an id — the short phrase an editor would write
289
+ * in a prompt ("hard cut", "invisible cut", "day-to-night time-lapse"), as
290
+ * opposed to the paragraph-length `promptHint`.
291
+ *
292
+ * Same lookup and same empty-string-on-miss behavior as
293
+ * `getTransitionPromptHint`, so the two can never disagree about which entry
294
+ * they describe. The no-op "Auto" entry resolves to `""` in both.
295
+ */
296
+ export function getTransitionTerm(id: string | undefined | null): string {
297
+ return resolveTerm(getTransition(id))
298
+ }
299
+
263
300
  export const TRANSITION_IDS: ReadonlyArray<string> = TRANSITIONS.map((t) => t.id)
264
301
 
265
302
  // ---------------------------------------------------------------------------
@@ -298,17 +335,27 @@ const INTENSITY_CLAUSES: Record<Exclude<TransitionIntensity, "auto">, string> =
298
335
  * - n base hints joined with ", and "
299
336
  * - Timing/start/end clauses apply ONCE at the outer layer, not per-id
300
337
  * - null input is treated like undefined (falsy short-circuit → returns "")
338
+ *
339
+ * @param mode `"compact"` builds the base from each transition's short
340
+ * professional `term` ("hard cut") instead of its full mechanism paragraph.
341
+ * Everything else — the ", and " multi-pick join, the position/duration/
342
+ * intensity clauses, and the "starting from"/"ending at" clauses — is
343
+ * emitted identically in both modes.
301
344
  */
302
345
  export function composeTransitionHintFromConnections(
303
346
  transitionId: string | ReadonlyArray<string> | undefined,
304
347
  startHints: ReadonlyArray<string>,
305
348
  endHints: ReadonlyArray<string>,
306
349
  timing?: TransitionTiming,
350
+ mode: PickerHintMode = "full",
307
351
  ): string {
308
352
  const ids = Array.isArray(transitionId)
309
353
  ? Array.from(new Set(transitionId)).slice(0, 2)
310
354
  : transitionId ? [transitionId] : []
311
- const baseHints = ids.map(getTransitionPromptHint).filter((h) => h.length > 0)
355
+ // ONLY the base fragment swaps in compact mode — the multi-pick join, the
356
+ // timing clauses and the start/end clauses below are identical either way.
357
+ const resolveBase = mode === "compact" ? getTransitionTerm : getTransitionPromptHint
358
+ const baseHints = ids.map(resolveBase).filter((h) => h.length > 0)
312
359
  if (baseHints.length === 0) return ""
313
360
 
314
361
  const combinedBase = baseHints.join(", and ")