@nodaro/prompts 1.28.0 → 1.30.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -151,3 +151,44 @@ describe("transition timing catalogs", () => {
151
151
  expect(singlesWithDims).toEqual(["transition", "character-fx", "character-motion"])
152
152
  })
153
153
  })
154
+
155
+ describe("the blended-cut flags on the picker catalog", () => {
156
+ // What a consumer that reads only `getPickerCatalog("transition")` (Studio) builds its Blend lever from:
157
+ // the blendable cut rows, and the duration step that blends them. See transitions-blended-cut.test.ts.
158
+ it("the transition options carry `blendable` on exactly seamless-match and jump-match, beside `instant`", () => {
159
+ const flagged = transitionCatalog.options!.filter((o) => o.blendable)
160
+ expect(flagged.map((o) => o.id)).toEqual(["seamless-match", "jump-match"])
161
+ for (const o of flagged) expect(o.instant, o.id).toBe(true)
162
+ expect(transitionCatalog.options!.some((o) => o.blendsCut)).toBe(false)
163
+ })
164
+
165
+ it("the `duration` dimension carries `blendsCut` on `short` only; the other dimensions carry neither flag", () => {
166
+ for (const dim of transitionCatalog.dimensions!) {
167
+ const blendsCut = dim.options.filter((o) => o.blendsCut).map((o) => o.id)
168
+ expect(blendsCut, dim.field).toEqual(dim.field === "duration" ? ["short"] : [])
169
+ expect(dim.options.some((o) => o.blendable), dim.field).toBe(false)
170
+ }
171
+ })
172
+
173
+ it("Character FX and Character Motion carry neither flag", () => {
174
+ for (const nodeType of ["character-fx", "character-motion"]) {
175
+ const cat = PICKER_CATALOGS.find((c) => c.nodeType === nodeType)!
176
+ const rows = [...(cat.options ?? []), ...(cat.dimensions ?? []).flatMap((d) => d.options)]
177
+ expect(rows.some((o) => o.blendable || o.blendsCut), nodeType).toBe(false)
178
+ }
179
+ })
180
+
181
+ it("the wire projection carries no cut flag, like `instant` (in-memory catalog only)", () => {
182
+ // Publishing them on `/v1/picker-catalogs` (and `@nodaro/shared` / the SDK) would be a deliberate,
183
+ // additive change carrying all three flags together, not drift.
184
+ for (const detail of ["compact", "full"] as const) {
185
+ const wire = projectPickerCatalog(transitionCatalog, { detail })
186
+ const rows = [...(wire.options ?? []), ...(wire.dimensions ?? []).flatMap((d) => d.options)]
187
+ for (const row of rows) {
188
+ for (const flag of ["instant", "blendable", "blendsCut"]) {
189
+ expect(Object.prototype.hasOwnProperty.call(row, flag), `${detail} ${row.id} ${flag}`).toBe(false)
190
+ }
191
+ }
192
+ }
193
+ })
194
+ })
@@ -0,0 +1,325 @@
1
+ /**
2
+ * THE BLENDED CUT (2026-10-04 test round). On `seamless-match` and `jump-match`, Duration `short` turns the
3
+ * hard cut into a blended cut: the anti-blend sentence inside the first pick's parentheses is REPLACED by
4
+ * "instead of a hard cut, the two shots blend into each other over about 1 second" ("about 1 second" is the
5
+ * `short` row's own term). Everything else about the clause is the hard cut's: the position clause (`full`
6
+ * dropped), no duration clause, no intensity clause.
7
+ *
8
+ * Everything else renders exactly as before: the two rows at Auto / Instant / Medium / Long, the other six
9
+ * cuts at every duration, every non-cut, every mixed pick, a blendable cut beside a cut that is not, and the
10
+ * direction fold (`renderTransitionBases`, which has no duration).
11
+ *
12
+ * The approved strings are pinned byte for byte from `fixtures/blended-cut-approved.json`: `approved` (the
13
+ * approved strings) and `ruleDerived` (the two-row multi-picks the approved rule produces) are copied from
14
+ * the review page, so a wording change there comes back for approval and is never made by hand. `hardCut`
15
+ * holds the same two rows at every other duration (today's hard cut); it shares their bodies, so a reword of
16
+ * either row changes it together with `approved`. Multi-picks with any other row are proven structurally
17
+ * below, so a reword of another row never touches this file.
18
+ */
19
+ import { readFileSync } from "node:fs"
20
+ import { describe, expect, it } from "vitest"
21
+ import {
22
+ INSTANT_CUT_CLAUSE,
23
+ TRANSITIONS,
24
+ TRANSITION_DURATIONS,
25
+ TRANSITION_INTENSITIES,
26
+ TRANSITION_POSITIONS,
27
+ blendedCutClause,
28
+ composeTransitionHintFromConnections,
29
+ getTransitionTerm,
30
+ isBlendableTransition,
31
+ isInstantTransition,
32
+ renderTransitionBases,
33
+ type TransitionHintScope,
34
+ type TransitionTiming,
35
+ type TransitionTimingOption,
36
+ } from "../transitions.js"
37
+ import { renderDirectionHints } from "../direction-registry.js"
38
+
39
+ type Case = {
40
+ readonly key: string
41
+ readonly ids: string | string[]
42
+ readonly timing: TransitionTiming
43
+ readonly scopes: TransitionHintScope[]
44
+ readonly text: string
45
+ }
46
+ const FIXTURE = JSON.parse(
47
+ readFileSync(new URL("./fixtures/blended-cut-approved.json", import.meta.url), "utf8"),
48
+ ) as { blendSentence: string; approved: Case[]; ruleDerived: Case[]; hardCut: Case[] }
49
+
50
+ /** The approved sentence, typed once here so the test file itself shows it. */
51
+ const BLEND = "instead of a hard cut, the two shots blend into each other over about 1 second"
52
+ const BLENDABLE = ["seamless-match", "jump-match"] as const
53
+ const CUTS = TRANSITIONS.filter((t) => t.instant).map((t) => t.id)
54
+ const OTHER_CUTS = CUTS.filter((id) => !(BLENDABLE as readonly string[]).includes(id))
55
+ const NON_CUTS = TRANSITIONS.filter((t) => !t.instant && t.id !== "auto").map((t) => t.id)
56
+ const ALL_IDS = TRANSITIONS.map((t) => t.id)
57
+
58
+ const POSITIONS = [undefined, ...TRANSITION_POSITIONS.map((o) => o.id)]
59
+ const DURATIONS = [undefined, ...TRANSITION_DURATIONS.map((o) => o.id)]
60
+ const INTENSITIES = [undefined, ...TRANSITION_INTENSITIES.map((o) => o.id)]
61
+ const SCOPES: TransitionHintScope[] = ["clip", "shot"]
62
+
63
+ const compose = (
64
+ ids: string | readonly string[],
65
+ timing: TransitionTiming | undefined,
66
+ scope: TransitionHintScope = "clip",
67
+ mode: "full" | "compact" = "full",
68
+ ) => composeTransitionHintFromConnections(ids as string | string[], [], [], timing, mode, { scope })
69
+
70
+ /** Levers with every unset field left out, so `undefined` and an absent field are the same case. */
71
+ function levers(position?: string, duration?: string, intensity?: string): TransitionTiming {
72
+ const t: Record<string, string> = {}
73
+ if (position !== undefined) t.position = position
74
+ if (duration !== undefined) t.duration = duration
75
+ if (intensity !== undefined) t.intensity = intensity
76
+ return t as TransitionTiming
77
+ }
78
+
79
+ const count = (haystack: string, needle: string) => haystack.split(needle).length - 1
80
+
81
+ describe("blended cut — the catalog", () => {
82
+ it("marks exactly seamless-match and jump-match blendable, and both are cuts", () => {
83
+ expect(TRANSITIONS.filter((t) => t.blendable).map((t) => t.id)).toEqual([...BLENDABLE])
84
+ for (const id of BLENDABLE) expect(isInstantTransition(id), id).toBe(true)
85
+ })
86
+
87
+ it("marks exactly the `short` duration as the step that blends a cut, with a term to blend over", () => {
88
+ const rows = (TRANSITION_DURATIONS as ReadonlyArray<TransitionTimingOption>).filter((d) => d.blendsCut)
89
+ expect(rows.map((d) => d.id)).toEqual(["short"])
90
+ expect(rows[0]!.term).toBe("about 1 second")
91
+ })
92
+
93
+ it("the fixture carries the same sentence", () => {
94
+ expect(FIXTURE.blendSentence).toBe(BLEND)
95
+ })
96
+ })
97
+
98
+ describe("isBlendableTransition", () => {
99
+ it("is true for each blendable row and for the two together, in either order", () => {
100
+ for (const id of BLENDABLE) expect(isBlendableTransition(id), id).toBe(true)
101
+ expect(isBlendableTransition(["seamless-match", "jump-match"])).toBe(true)
102
+ expect(isBlendableTransition(["jump-match", "seamless-match"])).toBe(true)
103
+ })
104
+
105
+ it("is false for the other six cuts, every non-cut, mixed picks and empty values", () => {
106
+ expect(OTHER_CUTS).toEqual(["none", "snap-to-black", "match-cut", "smash-cut", "jump-cut", "action-relay"])
107
+ for (const id of [...OTHER_CUTS, ...NON_CUTS]) expect(isBlendableTransition(id), id).toBe(false)
108
+ expect(isBlendableTransition(["seamless-match", "match-cut"])).toBe(false)
109
+ expect(isBlendableTransition(["jump-match", "cross-dissolve"])).toBe(false)
110
+ expect(isBlendableTransition("auto")).toBe(false)
111
+ expect(isBlendableTransition("nonexistent")).toBe(false)
112
+ expect(isBlendableTransition("")).toBe(false)
113
+ expect(isBlendableTransition([])).toBe(false)
114
+ expect(isBlendableTransition(undefined)).toBe(false)
115
+ expect(isBlendableTransition(null)).toBe(false)
116
+ })
117
+ })
118
+
119
+ describe("blendedCutClause", () => {
120
+ it("`short` gives the approved sentence", () => {
121
+ expect(blendedCutClause("short")).toBe(BLEND)
122
+ })
123
+
124
+ it("every other value gives nothing", () => {
125
+ for (const d of ["auto", "instant", "medium", "long", "nonexistent", "", undefined]) {
126
+ expect(blendedCutClause(d), String(d)).toBe("")
127
+ }
128
+ })
129
+ })
130
+
131
+ describe("blended cut — the approved strings, byte for byte", () => {
132
+ it("the tile default of each row at Short (also inline, so the approved text reads here)", () => {
133
+ expect(compose("seamless-match", { duration: "short" })).toBe(
134
+ "invisible cut (the camera motion, color palette, and on-screen motion at the end of the first shot " +
135
+ "continue exactly across the cut into the second shot, so the boundary is invisible and the two shots " +
136
+ "feel like one unbroken take; instead of a hard cut, the two shots blend into each other over about 1 second)",
137
+ )
138
+ expect(compose("jump-match", { duration: "short" })).toBe(
139
+ "match cut on a jump (the subject launches into a jump, and at the height of the leap the picture cuts to " +
140
+ "a new place. The camera follows the arc of the jump at the same speed on both sides of the cut, and the " +
141
+ "subject stays at the same place in the frame. In the second shot the same jump carries on without a " +
142
+ "break, and the subject comes down and lands in the new place. The shot ends on the subject landed in the " +
143
+ "second shot, fully resolved; instead of a hard cut, the two shots blend into each other over about 1 second)",
144
+ )
145
+ })
146
+
147
+ it("covers 12 approved cases: both rows on a tile, mid-clip, mid-shot and in the END slot, and the ignored levers", () => {
148
+ expect(FIXTURE.approved.map((c) => c.key)).toEqual(
149
+ BLENDABLE.flatMap((id) =>
150
+ ["short|tile", "short|mid-clip", "short|mid-shot", "short|end-slot", "short+natural|tile", "short+full|clip"].map(
151
+ (seat) => `${id}|${seat}`,
152
+ ),
153
+ ),
154
+ )
155
+ })
156
+
157
+ it.each(FIXTURE.approved)("$key", ({ ids, timing, scopes, text }) => {
158
+ expect(count(text, BLEND)).toBe(1)
159
+ expect(text).not.toContain(INSTANT_CUT_CLAUSE)
160
+ for (const scope of scopes) {
161
+ expect(compose(ids, timing, scope), scope).toBe(text)
162
+ expect(compose(ids, timing, scope, "compact"), `${scope} compact`).toBe(text)
163
+ }
164
+ })
165
+ })
166
+
167
+ describe("blended cut — the multi-pick rule (both picks blendable, Short: the sentence once, in the first parentheses)", () => {
168
+ it("covers both orders of the two rows", () => {
169
+ expect(FIXTURE.ruleDerived).toHaveLength(8)
170
+ })
171
+
172
+ it.each(FIXTURE.ruleDerived)("$key", ({ ids, timing, scopes, text }) => {
173
+ expect(count(text, BLEND)).toBe(1)
174
+ expect(text).not.toContain(INSTANT_CUT_CLAUSE)
175
+ // The sentence rides the first pick; the second keeps its plain wording.
176
+ const [first] = renderTransitionBases(ids as string[])
177
+ expect(text.startsWith(first!.replace(INSTANT_CUT_CLAUSE, BLEND))).toBe(true)
178
+ for (const scope of scopes) expect(compose(ids, timing, scope), scope).toBe(text)
179
+ })
180
+
181
+ it("a blendable cut beside a cut that is not stays a hard cut", () => {
182
+ for (const pair of [["seamless-match", "match-cut"], ["none", "jump-match"], ["jump-match", "action-relay"]]) {
183
+ const out = compose(pair, { position: "middle", duration: "short", intensity: "natural" })
184
+ expect(out, pair.join("+")).toBe(compose(pair, { position: "middle" }))
185
+ expect(count(out, INSTANT_CUT_CLAUSE)).toBe(1)
186
+ expect(out).not.toContain(BLEND)
187
+ }
188
+ })
189
+
190
+ it("a cut beside a non-cut is unchanged: no cut sentence, and the duration and intensity clauses stay", () => {
191
+ for (const pair of [["seamless-match", "cross-dissolve"], ["whip-pan", "jump-match"]]) {
192
+ const out = compose(pair, { position: "end", duration: "short", intensity: "natural" })
193
+ expect(out).toBe(
194
+ renderTransitionBases(pair).join(", and ") +
195
+ ", the transition occurs at the end of the clip, lasting approximately 1 second, with natural timing",
196
+ )
197
+ expect(out).not.toContain(INSTANT_CUT_CLAUSE)
198
+ expect(out).not.toContain(BLEND)
199
+ }
200
+ })
201
+
202
+ it("a no-op `auto` beside a blendable cut does not stop the blend", () => {
203
+ for (const id of BLENDABLE) {
204
+ for (const pick of [["auto", id], [id, "auto"]]) {
205
+ expect(compose(pick, { position: "middle", duration: "short" }), pick.join("+")).toBe(
206
+ compose(id, { position: "middle", duration: "short" }),
207
+ )
208
+ }
209
+ }
210
+ })
211
+ })
212
+
213
+ describe("blended cut — what stays a hard cut", () => {
214
+ it.each(FIXTURE.hardCut)("today's text: $key", ({ ids, timing, scopes, text }) => {
215
+ expect(text).not.toContain(BLEND)
216
+ for (const scope of scopes) expect(compose(ids, timing, scope), scope).toBe(text)
217
+ })
218
+
219
+ it("the two rows at Auto, Instant, Medium and Long render the hard cut, at every position and intensity", () => {
220
+ for (const id of BLENDABLE) {
221
+ for (const duration of [undefined, "auto", "instant", "medium", "long"]) {
222
+ for (const position of POSITIONS) for (const intensity of INTENSITIES) for (const scope of SCOPES) {
223
+ const out = compose(id, levers(position, duration, intensity), scope)
224
+ expect(out, `${id} ${position} ${duration} ${intensity} ${scope}`).toBe(compose(id, levers(position), scope))
225
+ expect(count(out, INSTANT_CUT_CLAUSE)).toBe(1)
226
+ expect(out).not.toContain(BLEND)
227
+ }
228
+ }
229
+ }
230
+ })
231
+
232
+ it("the other six cuts render the hard cut at every duration, Short included", () => {
233
+ for (const id of OTHER_CUTS) {
234
+ for (const duration of DURATIONS) {
235
+ for (const position of POSITIONS) for (const intensity of INTENSITIES) for (const scope of SCOPES) {
236
+ const out = compose(id, levers(position, duration, intensity), scope)
237
+ expect(out, `${id} ${position} ${duration} ${intensity} ${scope}`).toBe(compose(id, levers(position), scope))
238
+ expect(count(out, INSTANT_CUT_CLAUSE)).toBe(1)
239
+ expect(out).not.toContain(BLEND)
240
+ }
241
+ }
242
+ }
243
+ })
244
+
245
+ it("a non-cut never carries the blend sentence, and keeps its Short duration clause", () => {
246
+ for (const id of NON_CUTS) {
247
+ const out = compose(id, { duration: "short" })
248
+ expect(out, id).toContain("lasting approximately 1 second")
249
+ expect(out, id).not.toContain(BLEND)
250
+ }
251
+ })
252
+
253
+ it("a blended cut carries no span, duration or intensity clause", () => {
254
+ for (const pick of [...BLENDABLE.map((id) => [id]), ["seamless-match", "jump-match"], ["jump-match", "seamless-match"]]) {
255
+ for (const position of POSITIONS) for (const intensity of INTENSITIES) for (const scope of SCOPES) {
256
+ const out = compose(pick, levers(position, "short", intensity), scope)
257
+ expect(count(out, BLEND), `${pick} ${position} ${intensity} ${scope}`).toBe(1)
258
+ for (const word of ["spans", "lasting", "timing", "energy", "flourish"]) expect(out).not.toContain(word)
259
+ }
260
+ }
261
+ })
262
+ })
263
+
264
+ describe("blended cut — the direction fold never blends", () => {
265
+ it("renderTransitionBases keeps the hard cut for every single and ordered pair", () => {
266
+ const picks: string[][] = ALL_IDS.map((id) => [id])
267
+ for (const a of ALL_IDS) for (const b of ALL_IDS) if (a !== b) picks.push([a, b])
268
+ for (const pick of picks) {
269
+ const bases = renderTransitionBases(pick)
270
+ expect(bases.join(" ")).not.toContain(BLEND)
271
+ expect(bases.join(", and ")).toBe(compose(pick, undefined))
272
+ }
273
+ })
274
+
275
+ it("the server fold of `direction.transition` keeps the hard cut", () => {
276
+ for (const id of BLENDABLE) {
277
+ const [fold] = renderDirectionHints({ transition: id }, { surface: "video" })
278
+ expect(fold).toBe(compose(id, undefined))
279
+ expect(fold).toContain(INSTANT_CUT_CLAUSE)
280
+ expect(fold).not.toContain(BLEND)
281
+ }
282
+ })
283
+ })
284
+
285
+ describe("blended cut — the rule holds for every row and lever (self-consistency sweep)", () => {
286
+ /** A pick blends iff every contributing id is a blendable cut and the duration is `short`. */
287
+ const blends = (pick: readonly string[], duration: string | undefined) =>
288
+ duration === "short" &&
289
+ pick.filter((id) => getTransitionTerm(id).length > 0).every((id) => (BLENDABLE as readonly string[]).includes(id)) &&
290
+ pick.some((id) => getTransitionTerm(id).length > 0)
291
+
292
+ function check(pick: readonly string[], timing: TransitionTiming, scope: TransitionHintScope) {
293
+ const out = compose(pick, timing, scope)
294
+ const label = `${pick.join("+")} ${JSON.stringify(timing)} ${scope}`
295
+ const allCuts = isInstantTransition(pick.filter((id) => getTransitionTerm(id).length > 0))
296
+ const { duration, ...rest } = timing
297
+ if (blends(pick, duration)) {
298
+ // Exactly the hard cut with its anti-blend sentence swapped, once.
299
+ const hard = compose(pick, rest, scope)
300
+ expect(count(hard, INSTANT_CUT_CLAUSE), label).toBe(1)
301
+ expect(out, label).toBe(hard.replace(INSTANT_CUT_CLAUSE, BLEND))
302
+ } else {
303
+ expect(out, label).not.toContain(BLEND)
304
+ // On an all-cut pick a duration never changes anything else.
305
+ if (allCuts) expect(out, label).toBe(compose(pick, rest, scope))
306
+ }
307
+ }
308
+
309
+ it("every row alone, at every position, duration and intensity, in both scopes", () => {
310
+ for (const id of ALL_IDS) {
311
+ for (const position of POSITIONS) for (const duration of DURATIONS) for (const intensity of INTENSITIES) {
312
+ for (const scope of SCOPES) check([id], levers(position, duration, intensity), scope)
313
+ }
314
+ }
315
+ })
316
+
317
+ it("every ordered pair, at the levers that decide a blend", () => {
318
+ for (const a of ALL_IDS) for (const b of ALL_IDS) {
319
+ if (a === b) continue
320
+ for (const duration of [undefined, "short", "medium"]) for (const position of [undefined, "middle", "full"]) {
321
+ check([a, b], levers(position, duration, "natural"), "clip")
322
+ }
323
+ }
324
+ })
325
+ })
@@ -506,8 +506,10 @@ describe("F8 cut bodies (2026-10-03 A/B: F8 arm B on snap-to-black, jump-match a
506
506
  expect(composeTransitionHintFromConnections(id, [], [])).toBe(clause)
507
507
  })
508
508
 
509
- it.each(ROWS)("$id at middle / short / natural keeps only the position", ({ id, term, body }) => {
510
- const levers = { position: "middle", duration: "short", intensity: "natural" } as const
509
+ // `long`: a duration every cut ignores. Short on `jump-match` is a blended cut, pinned in
510
+ // transitions-blended-cut.test.ts.
511
+ it.each(ROWS)("$id at middle / long / natural keeps only the position", ({ id, term, body }) => {
512
+ const levers = { position: "middle", duration: "long", intensity: "natural" } as const
511
513
  expect(composeTransitionHintFromConnections(id, [], [], levers)).toBe(
512
514
  `${term} (${body}; ${INSTANT_CUT_CLAUSE}), the transition occurs in the middle of the clip`,
513
515
  )
@@ -770,8 +772,10 @@ describe("double-exposure body (2026-10-04: dblexp3 D1, the silhouette wording)"
770
772
  })
771
773
 
772
774
  describe("L1 — a cut spans nothing, so `full` adds no clause", () => {
775
+ // `long`: a duration every cut ignores. Short on `seamless-match` / `jump-match` is a blended cut, and
776
+ // its `full` (also dropped) is pinned in transitions-blended-cut.test.ts.
773
777
  it.each(INSTANT_IDS)("%s + full renders no position clause", (id) => {
774
- const out = composeTransitionHintFromConnections(id, [], [], { position: "full", duration: "short", intensity: "natural" })
778
+ const out = composeTransitionHintFromConnections(id, [], [], { position: "full", duration: "long", intensity: "natural" })
775
779
  expect(out).not.toContain("spans")
776
780
  expect(out).toBe(composeTransitionHintFromConnections(id, [], []))
777
781
  })
@@ -0,0 +1,109 @@
1
+ // packages/prompts/src/caption-treatments.ts
2
+ import type { CaptionPlan, SupportedFontName } from "@nodaro/shared"
3
+ import { ADD_CAPTIONS_PRESETS } from "./factory-presets/video-edit.js"
4
+
5
+ export const HOOK_PLATE_PRESET_ID = "add-captions/hook-plate"
6
+ export const BODY_CAPTIONS_PRESET_ID = "add-captions/body-captions"
7
+
8
+ /** Levers a spoken language needs on every caption segment; a language with no entry adds none. */
9
+ export interface CaptionLanguageLevers {
10
+ readonly fontFamily?: SupportedFontName
11
+ readonly uppercase?: boolean
12
+ }
13
+
14
+ /**
15
+ * Keyed by primary language tag. Hebrew: one face that carries Hebrew and Latin, so a Latin
16
+ * brand inside a Hebrew line is drawn in the same face, and no upper-casing, so the brand keeps
17
+ * its spelling.
18
+ */
19
+ export const CAPTION_LEVERS_BY_LANGUAGE: Readonly<Record<string, CaptionLanguageLevers>> = {
20
+ he: { fontFamily: "Rubik", uppercase: false },
21
+ }
22
+
23
+ /** The add-captions segment keys a lever set may carry: the segment schema minus times and words. */
24
+ export const CAPTION_SEGMENT_LEVER_KEYS: readonly string[] = [
25
+ "style", "position", "fontSize", "color", "backgroundColor", "look", "fontFamily", "fontWeight",
26
+ "strokeColor", "strokeWidth", "highlightColor", "uppercase", "positionY", "animate", "maxWordsPerLine",
27
+ ]
28
+
29
+ export type CaptionSegmentRole = "hook" | "body"
30
+
31
+ export interface HookPlateSegmentsOptions {
32
+ /** The body's levers; default = the Body Captions preset. Keys outside CAPTION_SEGMENT_LEVER_KEYS are ignored. */
33
+ readonly bodyLevers?: Readonly<Record<string, unknown>>
34
+ }
35
+
36
+ export interface HookPlateSegmentsResult {
37
+ /** Route-shaped (camelCase) `segments`. Empty means "no captions". */
38
+ readonly segments: Record<string, unknown>[]
39
+ /** Segments left out, in order hook, body. */
40
+ readonly dropped: CaptionSegmentRole[]
41
+ }
42
+
43
+ const LEVER_KEYS: ReadonlySet<string> = new Set(CAPTION_SEGMENT_LEVER_KEYS)
44
+
45
+ /** A lever set: segment lever keys only, without null or undefined values. */
46
+ function pickLevers(source: Readonly<Record<string, unknown>> | undefined): Record<string, unknown> {
47
+ const out: Record<string, unknown> = {}
48
+ if (!source) return out
49
+ for (const [k, v] of Object.entries(source)) if (LEVER_KEYS.has(k) && v !== undefined && v !== null) out[k] = v
50
+ return out
51
+ }
52
+
53
+ function presetLevers(id: string): Record<string, unknown> {
54
+ const preset = ADD_CAPTIONS_PRESETS.find((p) => p.id === id)
55
+ if (!preset) throw new Error(`The ${id} caption preset is missing from the catalog.`)
56
+ return pickLevers(preset.data)
57
+ }
58
+
59
+ const PLATE_LEVERS = presetLevers(HOOK_PLATE_PRESET_ID)
60
+ const BODY_LEVERS = presetLevers(BODY_CAPTIONS_PRESET_ID)
61
+
62
+ function languageLevers(tag: string | undefined): CaptionLanguageLevers {
63
+ if (typeof tag !== "string") return {}
64
+ const primary = tag.trim().split(/[-_]/)[0]!.toLowerCase()
65
+ return Object.hasOwn(CAPTION_LEVERS_BY_LANGUAGE, primary) ? CAPTION_LEVERS_BY_LANGUAGE[primary]! : {}
66
+ }
67
+
68
+ const isTime = (n: unknown): n is number => typeof n === "number" && Number.isFinite(n)
69
+
70
+ /**
71
+ * One caption plan → the `segments` of one add-captions request: the opening line as the Hook
72
+ * Plate, then the body's words in the Body Captions style (or the caller's own body levers).
73
+ * The styling is read from the two presets, never restated here. Pure: no I/O.
74
+ */
75
+ export function hookPlateCaptionSegments(plan: CaptionPlan, opts: HookPlateSegmentsOptions = {}): HookPlateSegmentsResult {
76
+ const hookEnd = plan.hookEndMs
77
+ if (!isTime(hookEnd) || hookEnd < 0) return { segments: [], dropped: ["hook", "body"] }
78
+ const language = languageLevers(plan.language)
79
+ const limit = typeof plan.videoDurationMs === "number" ? plan.videoDurationMs : Number.POSITIVE_INFINITY
80
+ const segments: Record<string, unknown>[] = []
81
+ const dropped: CaptionSegmentRole[] = []
82
+
83
+ const plateEnd = Math.min(hookEnd, limit)
84
+ if (plateEnd > 0 && typeof plan.hookText === "string" && /\S/.test(plan.hookText)) {
85
+ segments.push({ startMs: 0, endMs: plateEnd, text: plan.hookText, ...PLATE_LEVERS, ...language })
86
+ } else {
87
+ dropped.push("hook")
88
+ }
89
+
90
+ // The body starts where the opening line ends, even when no plate is shown, so it never
91
+ // draws over the opening line.
92
+ const bodyEnd = Math.min(plan.bodyEndMs, limit)
93
+ const words = Array.isArray(plan.captions) ? plan.captions : []
94
+ const chosen = pickLevers(opts.bodyLevers)
95
+ const bodyLevers = Object.keys(chosen).length > 0 ? chosen : BODY_LEVERS
96
+ if (bodyEnd > hookEnd && words.length > 0) {
97
+ segments.push({
98
+ startMs: hookEnd,
99
+ endMs: bodyEnd,
100
+ captions: words.map((w) => ({ text: w.text, startMs: w.startMs, endMs: w.endMs })),
101
+ ...bodyLevers,
102
+ ...language,
103
+ })
104
+ } else {
105
+ dropped.push("body")
106
+ }
107
+
108
+ return { segments, dropped }
109
+ }
@@ -53,6 +53,27 @@ export const TEXT_TO_AUDIO_PRESETS: readonly FactoryPreset[] = [
53
53
  group: "Ambiences (loopable)",
54
54
  data: { provider: "elevenlabs-sfx", duration: 22, loop: true, promptInfluence: 0.5, prompt: "low sci-fi ambient drone, ominous spaceship hum" },
55
55
  },
56
+ {
57
+ id: "text-to-audio/living-room-tone",
58
+ name: "Living Room Tone",
59
+ description: "Quiet, lived-in room.",
60
+ group: "Ambiences (loopable)",
61
+ data: { provider: "elevenlabs-sfx", duration: 22, loop: true, promptInfluence: 0.4, prompt: "quiet living room room tone, faint air and a soft distant household hum, steady and even, an empty room at rest" },
62
+ },
63
+ {
64
+ id: "text-to-audio/kitchen-hum",
65
+ name: "Kitchen Hum",
66
+ description: "Fridge hum in a still kitchen.",
67
+ group: "Ambiences (loopable)",
68
+ data: { provider: "elevenlabs-sfx", duration: 22, loop: true, promptInfluence: 0.4, prompt: "home kitchen room tone, a low steady refrigerator hum with faint air, calm and continuous, an empty kitchen at rest" },
69
+ },
70
+ {
71
+ id: "text-to-audio/cafe-murmur",
72
+ name: "Café Murmur",
73
+ description: "Soft, indistinct café chatter.",
74
+ group: "Ambiences (loopable)",
75
+ data: { provider: "elevenlabs-sfx", duration: 22, loop: true, promptInfluence: 0.4, prompt: "café background ambience, a soft indistinct murmur of distant conversation and the occasional faint cup clink, steady and even, no clear words" },
76
+ },
56
77
 
57
78
  // ── UI & Stingers ────────────────────────────────────────────────────────
58
79
  {
@@ -80,6 +80,24 @@ export const LLM_CHAT_PRESETS: readonly FactoryPreset[] = [
80
80
  temperature: 0.7,
81
81
  },
82
82
  },
83
+ {
84
+ id: "llm-chat/hook-generator",
85
+ name: "Hook Generator",
86
+ description: "10 opening lines for a talking-to-camera video.",
87
+ group: "Writing & Marketing",
88
+ data: {
89
+ systemPrompt:
90
+ "You write opening lines for short vertical videos in which one person talks straight to the camera about a product, a site or an idea. Given the user's topic, write 10 different opening lines.\n\n" +
91
+ "Every line:\n" +
92
+ "- is one short spoken clause of at most 8 words;\n" +
93
+ "- leads with the payload the viewer can check: a number, a price, a time saved, a before and after, a contrast with the usual way, or a named result;\n" +
94
+ "- opens on the claim itself — no greeting, no filler word, no introduction of the product before the claim;\n" +
95
+ "- sounds said, not written: plain words, contractions welcome, no hashtags, no emoji, no stage directions;\n" +
96
+ "- spells the product or brand exactly as the user gives it and writes numbers as digits.\n\n" +
97
+ "Vary the angle across the 10: a surprising number, a price, time saved, a mistake to avoid, a before and after, a contrast, a named result, a direct question. Write in the language the user writes in. Return only the numbered list, one line each.",
98
+ temperature: 0.9,
99
+ },
100
+ },
83
101
 
84
102
  // ── Utility ──────────────────────────────────────────────────────────────
85
103
  {
@@ -273,6 +291,17 @@ export const IMAGE_TO_TEXT_PRESETS: readonly FactoryPreset[] = [
273
291
  group: "Extraction",
274
292
  data: { detailLevel: "detailed", customPrompt: "Write a compelling e-commerce product description based on this product image." },
275
293
  },
294
+ {
295
+ id: "image-to-text/site-screenshot-summary",
296
+ name: "Site Screenshot Summary",
297
+ description: "Section, headings and figures from a web page.",
298
+ group: "Extraction",
299
+ data: {
300
+ detailLevel: "structured",
301
+ customPrompt:
302
+ "Describe this website screenshot as five labelled lines. Section: which part of the site it is (for example the top of the home page, a feature, how it works, reviews, pricing, sign-up, navigation or footer). Headings: every heading exactly as written. Figures: every number, price, rating and date exactly as written, each with what it refers to. Brand: the product or company name exactly as spelled. First look: what a viewer notices first, in one sentence. Write \"none\" on a line with nothing to report.",
303
+ },
304
+ },
276
305
 
277
306
  // ── Creative ─────────────────────────────────────────────────────────────
278
307
  {
@@ -95,6 +95,20 @@ export const ADD_CAPTIONS_PRESETS: readonly FactoryPreset[] = [
95
95
  group: "Caption Styles",
96
96
  data: { style: "subtitle", position: "top", fontSize: 36, color: "#FFFFFF", autoTranscribe: true },
97
97
  },
98
+ {
99
+ id: "add-captions/hook-plate",
100
+ name: "Hook Plate",
101
+ description: "Your opening line as a bold plate near the top.",
102
+ group: "Caption Styles",
103
+ data: { style: "subtitle", look: "clean", position: "top", positionY: 20, fontSize: 32, fontWeight: 800, maxWordsPerLine: 4, color: "#111111", backgroundColor: "#FFFFFF", autoTranscribe: false },
104
+ },
105
+ {
106
+ id: "add-captions/body-captions",
107
+ name: "Body Captions",
108
+ description: "Word-by-word captions under the face.",
109
+ group: "Caption Styles",
110
+ data: { style: "word-highlight", look: "outline", position: "bottom", positionY: 72, fontSize: 32, maxWordsPerLine: 3, color: "#FFFFFF", autoTranscribe: true },
111
+ },
98
112
  ]
99
113
 
100
114
  export const COMBINE_VIDEOS_PRESETS: readonly FactoryPreset[] = [
@@ -115,6 +115,20 @@ export const VOICE_DESIGN_PRESETS: readonly FactoryPreset[] = [
115
115
  group: "Narration & Character",
116
116
  data: { voiceDescription: "A calm, soft-spoken meditation guide with a slow, breathy, reassuring delivery." },
117
117
  },
118
+ {
119
+ id: "voice-design/casual-creator-female",
120
+ name: "Casual Creator (female)",
121
+ description: "Relaxed, warm, everyday.",
122
+ group: "Narration & Character",
123
+ data: { voiceDescription: "An adult woman in her late twenties talking casually to the viewer as if telling a friend about something she tried: relaxed, warm and upbeat, a natural conversational pace with small pauses, a light neutral American accent, recorded close and dry in a quiet room, never polished or announcer-like." },
124
+ },
125
+ {
126
+ id: "voice-design/casual-creator-male",
127
+ name: "Casual Creator (male)",
128
+ description: "Relaxed, warm, everyday.",
129
+ group: "Narration & Character",
130
+ data: { voiceDescription: "An adult man in his late twenties talking casually to the viewer as if telling a friend about something he tried: relaxed, warm and upbeat, a natural conversational pace with small pauses, a light neutral American accent, recorded close and dry in a quiet room, never polished or announcer-like." },
131
+ },
118
132
 
119
133
  // ── Professional & Assistant ─────────────────────────────────────────────
120
134
  {
package/src/index.ts CHANGED
@@ -88,6 +88,7 @@ export * from "./prompt-wizard-categories.js"
88
88
  export * from "./resolve-prompt.js"
89
89
  export * from "./node-prompt-fields.js"
90
90
  export * from "./factory-presets.js"
91
+ export * from "./caption-treatments.js"
91
92
  export * from "./style-presets.js"
92
93
  export * from "./object-asset-presets.js"
93
94
  export * from "./factory-snippets/index.js"