@nodaro/prompts 1.29.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.
- package/dist/index.cjs +148 -0
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +35 -2
- package/dist/index.d.ts +35 -2
- package/dist/index.js +144 -1
- package/dist/index.js.map +1 -1
- package/package.json +2 -2
- package/src/__tests__/caption-treatments.test.ts +155 -0
- package/src/__tests__/creator-presets.test.ts +151 -0
- package/src/__tests__/factory-presets.test.ts +63 -0
- package/src/caption-treatments.ts +109 -0
- package/src/factory-presets/sfx.ts +21 -0
- package/src/factory-presets/text.ts +29 -0
- package/src/factory-presets/video-edit.ts +14 -0
- package/src/factory-presets/voice.ts +14 -0
- package/src/index.ts +1 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nodaro/prompts",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.30.0",
|
|
4
4
|
"description": "Nodaro's prompt-engineering layer — person/picker catalogs with prompt hints, identity-lock clauses, entity prompt builders, brand presets, and prompt/reference assembly shared by the Nodaro platform and SDK.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "FSL-1.1-Apache-2.0",
|
|
@@ -20,7 +20,7 @@
|
|
|
20
20
|
"test": "vitest run"
|
|
21
21
|
},
|
|
22
22
|
"dependencies": {
|
|
23
|
-
"@nodaro/shared": "^3.
|
|
23
|
+
"@nodaro/shared": "^3.19.0"
|
|
24
24
|
},
|
|
25
25
|
"devDependencies": {
|
|
26
26
|
"tsup": "^8.5.0",
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
// packages/prompts/src/__tests__/caption-treatments.test.ts
|
|
2
|
+
import { describe, it, expect } from "vitest"
|
|
3
|
+
import { captionRoutesToRemotion, type CaptionPlan } from "@nodaro/shared"
|
|
4
|
+
import {
|
|
5
|
+
BODY_CAPTIONS_PRESET_ID,
|
|
6
|
+
CAPTION_LEVERS_BY_LANGUAGE,
|
|
7
|
+
CAPTION_SEGMENT_LEVER_KEYS,
|
|
8
|
+
HOOK_PLATE_PRESET_ID,
|
|
9
|
+
hookPlateCaptionSegments,
|
|
10
|
+
} from "../caption-treatments.js"
|
|
11
|
+
import { getFactoryPresets } from "../factory-presets.js"
|
|
12
|
+
|
|
13
|
+
type CaptionInput = Parameters<typeof captionRoutesToRemotion>[0]
|
|
14
|
+
const data = (id: string) => getFactoryPresets("add-captions").find((p) => p.id === id)!.data
|
|
15
|
+
const levers = (d: Readonly<Record<string, unknown>>) => Object.fromEntries(Object.entries(d).filter(([k]) => CAPTION_SEGMENT_LEVER_KEYS.includes(k)))
|
|
16
|
+
const PLATE = levers(data(HOOK_PLATE_PRESET_ID))
|
|
17
|
+
const BODY = levers(data(BODY_CAPTIONS_PRESET_ID))
|
|
18
|
+
const KARAOKE = data("add-captions/karaoke")
|
|
19
|
+
const WORDS = [
|
|
20
|
+
{ text: "so", startMs: 1600, endMs: 1900 },
|
|
21
|
+
{ text: "I", startMs: 2000, endMs: 2200 },
|
|
22
|
+
{ text: "opened", startMs: 2200, endMs: 2600 },
|
|
23
|
+
{ text: "Acme", startMs: 2600, endMs: 4000 },
|
|
24
|
+
]
|
|
25
|
+
const PLAN: CaptionPlan = { v: 1, hookText: " Saved 3 hours a week ", hookEndMs: 1600, bodyEndMs: 4300, captions: WORDS }
|
|
26
|
+
const SEGMENT_SHAPE_KEYS = new Set([...CAPTION_SEGMENT_LEVER_KEYS, "startMs", "endMs", "text", "captions"])
|
|
27
|
+
|
|
28
|
+
describe("hookPlateCaptionSegments — windows and sources", () => {
|
|
29
|
+
it("emits the plate, then the body, with the opening line untrimmed and the words as captions", () => {
|
|
30
|
+
const r = hookPlateCaptionSegments(PLAN)
|
|
31
|
+
expect(r.dropped).toEqual([])
|
|
32
|
+
expect(r.segments).toEqual([
|
|
33
|
+
{ startMs: 0, endMs: 1600, text: " Saved 3 hours a week ", ...PLATE },
|
|
34
|
+
{ startMs: 1600, endMs: 4300, captions: WORDS, ...BODY },
|
|
35
|
+
])
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
it("the plate's levers are Hook Plate's segment levers (autoTranscribe is not a segment key)", () => {
|
|
39
|
+
expect(PLATE).not.toHaveProperty("autoTranscribe")
|
|
40
|
+
expect(hookPlateCaptionSegments(PLAN).segments[0]).not.toHaveProperty("autoTranscribe")
|
|
41
|
+
})
|
|
42
|
+
|
|
43
|
+
it("every emitted key is a segment lever, a time, the text or the captions", () => {
|
|
44
|
+
for (const s of hookPlateCaptionSegments({ ...PLAN, language: "he" }, { bodyLevers: KARAOKE }).segments)
|
|
45
|
+
for (const k of Object.keys(s)) expect(SEGMENT_SHAPE_KEYS.has(k), k).toBe(true)
|
|
46
|
+
})
|
|
47
|
+
})
|
|
48
|
+
|
|
49
|
+
describe("hookPlateCaptionSegments — drop cases (never throws)", () => {
|
|
50
|
+
it("a blank opening line → body only, still starting where the opening line ends", () => {
|
|
51
|
+
const r = hookPlateCaptionSegments({ ...PLAN, hookText: " " })
|
|
52
|
+
expect(r.dropped).toEqual(["hook"])
|
|
53
|
+
expect(r.segments).toHaveLength(1)
|
|
54
|
+
expect(r.segments[0]).toMatchObject({ startMs: 1600, endMs: 4300 })
|
|
55
|
+
})
|
|
56
|
+
it("no body words → plate only", () => {
|
|
57
|
+
const r = hookPlateCaptionSegments({ ...PLAN, captions: [] })
|
|
58
|
+
expect(r.dropped).toEqual(["body"])
|
|
59
|
+
expect(r.segments.map((s) => s.text)).toEqual([" Saved 3 hours a week "])
|
|
60
|
+
})
|
|
61
|
+
it("a video that ends before the opening line → the plate ends with the video, the body is dropped", () => {
|
|
62
|
+
const r = hookPlateCaptionSegments({ ...PLAN, videoDurationMs: 1000 })
|
|
63
|
+
expect(r.segments).toEqual([{ startMs: 0, endMs: 1000, text: " Saved 3 hours a week ", ...PLATE }])
|
|
64
|
+
expect(r.dropped).toEqual(["body"])
|
|
65
|
+
})
|
|
66
|
+
it("a video length of 0 → both dropped", () => {
|
|
67
|
+
expect(hookPlateCaptionSegments({ ...PLAN, videoDurationMs: 0 })).toEqual({ segments: [], dropped: ["hook", "body"] })
|
|
68
|
+
})
|
|
69
|
+
it("a video that ends exactly where the opening line ends → plate only", () => {
|
|
70
|
+
const r = hookPlateCaptionSegments({ ...PLAN, videoDurationMs: 1600 })
|
|
71
|
+
expect(r.segments).toHaveLength(1)
|
|
72
|
+
expect(r.dropped).toEqual(["body"])
|
|
73
|
+
})
|
|
74
|
+
it("a body that ends at or before the opening line's end → body dropped", () => {
|
|
75
|
+
for (const bodyEndMs of [1600, 1000]) expect(hookPlateCaptionSegments({ ...PLAN, bodyEndMs }).dropped).toEqual(["body"])
|
|
76
|
+
})
|
|
77
|
+
it("a negative or non-finite hookEndMs → both dropped", () => {
|
|
78
|
+
for (const hookEndMs of [-1, Number.NaN, Number.POSITIVE_INFINITY, "1600" as unknown as number])
|
|
79
|
+
expect(hookPlateCaptionSegments({ ...PLAN, hookEndMs })).toEqual({ segments: [], dropped: ["hook", "body"] })
|
|
80
|
+
})
|
|
81
|
+
it("the body never starts before the opening line ends, whatever the video length", () => {
|
|
82
|
+
for (const videoDurationMs of [0, 500, 1599, 1600, 1601, 99_999])
|
|
83
|
+
for (const s of hookPlateCaptionSegments({ ...PLAN, videoDurationMs }).segments) if (s.captions) expect(s.startMs).toBe(1600)
|
|
84
|
+
})
|
|
85
|
+
it("never throws on non-numeric times", () => {
|
|
86
|
+
expect(() => hookPlateCaptionSegments({ ...PLAN, bodyEndMs: Number.NaN })).not.toThrow()
|
|
87
|
+
expect(() => hookPlateCaptionSegments({ ...PLAN, videoDurationMs: Number.NaN })).not.toThrow()
|
|
88
|
+
expect(hookPlateCaptionSegments({ ...PLAN, videoDurationMs: Number.NaN }).dropped).toEqual(["hook", "body"])
|
|
89
|
+
})
|
|
90
|
+
})
|
|
91
|
+
|
|
92
|
+
describe("hookPlateCaptionSegments — language levers", () => {
|
|
93
|
+
it("he → Rubik and uppercase false on both segments, winning over a bodyLevers fontFamily", () => {
|
|
94
|
+
const r = hookPlateCaptionSegments({ ...PLAN, language: "he" }, { bodyLevers: { ...BODY, fontFamily: "Anton" } })
|
|
95
|
+
expect(r.segments).toHaveLength(2)
|
|
96
|
+
for (const s of r.segments) expect(s).toMatchObject({ fontFamily: "Rubik", uppercase: false })
|
|
97
|
+
})
|
|
98
|
+
it("a regional or capitalised Hebrew tag gets the Hebrew levers", () => {
|
|
99
|
+
for (const language of ["he-IL", "HE", "he_IL"])
|
|
100
|
+
for (const s of hookPlateCaptionSegments({ ...PLAN, language }).segments) expect(s, language).toMatchObject({ fontFamily: "Rubik", uppercase: false })
|
|
101
|
+
})
|
|
102
|
+
it("en, an unmapped tag, an inherited key name or no language → neither key", () => {
|
|
103
|
+
for (const language of ["en", "fr", "constructor", undefined])
|
|
104
|
+
for (const s of hookPlateCaptionSegments({ ...PLAN, language }).segments) {
|
|
105
|
+
expect(s, String(language)).not.toHaveProperty("fontFamily")
|
|
106
|
+
expect(s, String(language)).not.toHaveProperty("uppercase")
|
|
107
|
+
}
|
|
108
|
+
})
|
|
109
|
+
})
|
|
110
|
+
|
|
111
|
+
describe("hookPlateCaptionSegments — body levers", () => {
|
|
112
|
+
it("bodyLevers restyle the body only", () => {
|
|
113
|
+
const [plate, body] = hookPlateCaptionSegments(PLAN, { bodyLevers: KARAOKE }).segments
|
|
114
|
+
expect(body).toMatchObject({ style: "karaoke", fontSize: 56, position: "bottom" })
|
|
115
|
+
expect(plate).toEqual({ startMs: 0, endMs: 1600, text: " Saved 3 hours a week ", ...PLATE })
|
|
116
|
+
})
|
|
117
|
+
it("bodyLevers keep only segment levers, and drop null and undefined values", () => {
|
|
118
|
+
const body = hookPlateCaptionSegments(PLAN, {
|
|
119
|
+
bodyLevers: { ...KARAOKE, autoTranscribe: true, text: "x", label: "Captions", fontFamily: null, strokeColor: undefined },
|
|
120
|
+
}).segments[1]!
|
|
121
|
+
for (const k of ["autoTranscribe", "label", "fontFamily", "strokeColor"]) expect(body, k).not.toHaveProperty(k)
|
|
122
|
+
expect(body.text).toBeUndefined()
|
|
123
|
+
expect(body.style).toBe("karaoke")
|
|
124
|
+
})
|
|
125
|
+
it("no bodyLevers, or none left after filtering → Body Captions", () => {
|
|
126
|
+
for (const opts of [undefined, {}, { bodyLevers: {} }, { bodyLevers: { autoTranscribe: true, label: "x" } }])
|
|
127
|
+
expect(hookPlateCaptionSegments(PLAN, opts).segments[1]).toEqual({ startMs: 1600, endMs: 4300, captions: WORDS, ...BODY })
|
|
128
|
+
})
|
|
129
|
+
})
|
|
130
|
+
|
|
131
|
+
describe("hookPlateCaptionSegments — the plate never falls back to drawtext", () => {
|
|
132
|
+
it("every plate segment, sent alone, routes to the styled renderer — for every mapped language and for none", () => {
|
|
133
|
+
for (const language of [undefined, ...Object.keys(CAPTION_LEVERS_BY_LANGUAGE)]) {
|
|
134
|
+
const plate = hookPlateCaptionSegments({ ...PLAN, language }).segments[0]!
|
|
135
|
+
expect(plate.text, String(language)).toBeDefined()
|
|
136
|
+
expect(captionRoutesToRemotion(plate as CaptionInput), String(language)).toBe(true)
|
|
137
|
+
}
|
|
138
|
+
})
|
|
139
|
+
it("bodyLevers never reach the plate", () => {
|
|
140
|
+
const plate = hookPlateCaptionSegments(PLAN, { bodyLevers: { fontFamily: "Anton", backgroundColor: "#FF0000", positionY: 90, style: "karaoke" } }).segments[0]
|
|
141
|
+
expect(plate).toEqual({ startMs: 0, endMs: 1600, text: " Saved 3 hours a week ", ...PLATE })
|
|
142
|
+
})
|
|
143
|
+
})
|
|
144
|
+
|
|
145
|
+
describe("caption lever constants", () => {
|
|
146
|
+
it("CAPTION_SEGMENT_LEVER_KEYS is the route's segment keys without times and words", () => {
|
|
147
|
+
expect([...CAPTION_SEGMENT_LEVER_KEYS].sort()).toEqual(
|
|
148
|
+
["style", "position", "fontSize", "color", "backgroundColor", "look", "fontFamily", "fontWeight", "strokeColor", "strokeWidth", "highlightColor", "uppercase", "positionY", "animate", "maxWordsPerLine"].sort(),
|
|
149
|
+
)
|
|
150
|
+
})
|
|
151
|
+
it("every language entry uses segment lever keys only", () => {
|
|
152
|
+
for (const entry of Object.values(CAPTION_LEVERS_BY_LANGUAGE))
|
|
153
|
+
for (const k of Object.keys(entry)) expect(CAPTION_SEGMENT_LEVER_KEYS.includes(k), k).toBe(true)
|
|
154
|
+
})
|
|
155
|
+
})
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
// packages/prompts/src/__tests__/creator-presets.test.ts
|
|
2
|
+
import { describe, it, expect } from "vitest"
|
|
3
|
+
import { captionRoutesToRemotion, presetApplyClearKeys } from "@nodaro/shared"
|
|
4
|
+
import { getFactoryPresets, type FactoryPreset } from "../factory-presets.js"
|
|
5
|
+
|
|
6
|
+
/** Words that name a recording or viewing device. Plural forms included. */
|
|
7
|
+
const DEVICE_WORDS = /\b(?:smartphones?|iphones?|phones?|cameras?|lens(?:es)?|webcams?|laptops?|tablets?|screens?|monitors?|displays?|tripods?|selfie[ -]sticks?)\b/i
|
|
8
|
+
/** "adult", or an age phrase in the twenties to sixties. */
|
|
9
|
+
const ADULT = /\badults?\b|\bin (?:her|his|their) (?:early |mid-|late )?(?:twenties|thirties|forties|fifties|sixties)\b/i
|
|
10
|
+
|
|
11
|
+
function preset(nodeType: string, id: string): FactoryPreset {
|
|
12
|
+
const p = getFactoryPresets(nodeType).find((x) => x.id === id)
|
|
13
|
+
if (!p) throw new Error(`${id} is missing from the ${nodeType} catalog`)
|
|
14
|
+
return p
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
describe("creator-preset test helpers (self-check)", () => {
|
|
18
|
+
it("the device pattern finds device words and their plurals, and leaves ordinary words alone", () => {
|
|
19
|
+
for (const s of ["a phone in hand", "two cameras", "the lenses", "a selfie stick", "on screen"]) expect(s).toMatch(DEVICE_WORDS)
|
|
20
|
+
for (const s of ["a hand-held microphone", "deep focus", "the viewer at eye level"]) expect(s).not.toMatch(DEVICE_WORDS)
|
|
21
|
+
})
|
|
22
|
+
it("the adult pattern accepts 'adult' and twenties-to-sixties age phrases only", () => {
|
|
23
|
+
for (const s of ["an adult woman", "in her late twenties", "in his thirties", "in their mid-forties"]) expect(s).toMatch(ADULT)
|
|
24
|
+
for (const s of ["a teenager", "in her teens", "a young woman"]) expect(s).not.toMatch(ADULT)
|
|
25
|
+
})
|
|
26
|
+
it("preset() finds a shipped preset and refuses a missing one", () => {
|
|
27
|
+
expect(preset("add-captions", "add-captions/clean-subtitles").group).toBe("Caption Styles")
|
|
28
|
+
expect(() => preset("add-captions", "add-captions/does-not-exist")).toThrow()
|
|
29
|
+
})
|
|
30
|
+
})
|
|
31
|
+
|
|
32
|
+
describe("Wave 0 — Casual Creator voices", () => {
|
|
33
|
+
const VOICES = ["voice-design/casual-creator-female", "voice-design/casual-creator-male"]
|
|
34
|
+
|
|
35
|
+
it("ships both in Narration & Character, right after Meditation Guide, carrying only a voice description", () => {
|
|
36
|
+
for (const id of VOICES) {
|
|
37
|
+
const p = preset("voice-design", id)
|
|
38
|
+
expect(p.group).toBe("Narration & Character")
|
|
39
|
+
expect(Object.keys(p.data)).toEqual(["voiceDescription"])
|
|
40
|
+
}
|
|
41
|
+
const ids = getFactoryPresets("voice-design").map((p) => p.id)
|
|
42
|
+
const i = ids.indexOf("voice-design/meditation-guide")
|
|
43
|
+
expect(ids.slice(i + 1, i + 3)).toEqual(VOICES)
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
it("every new voice description names an adult and no recording device", () => {
|
|
47
|
+
for (const id of VOICES) {
|
|
48
|
+
const d = preset("voice-design", id).data.voiceDescription as string
|
|
49
|
+
expect(d, id).toMatch(ADULT)
|
|
50
|
+
expect(d, id).not.toMatch(DEVICE_WORDS)
|
|
51
|
+
}
|
|
52
|
+
})
|
|
53
|
+
})
|
|
54
|
+
|
|
55
|
+
describe("Wave 0 — room tones", () => {
|
|
56
|
+
const TONES = ["text-to-audio/living-room-tone", "text-to-audio/kitchen-hum", "text-to-audio/cafe-murmur"]
|
|
57
|
+
|
|
58
|
+
it("ships three loopable room tones in the ambience shape, right after Sci-Fi Drone", () => {
|
|
59
|
+
for (const id of TONES) {
|
|
60
|
+
const p = preset("text-to-audio", id)
|
|
61
|
+
expect(p.group).toBe("Ambiences (loopable)")
|
|
62
|
+
expect(Object.keys(p.data)).toEqual(["provider", "duration", "loop", "promptInfluence", "prompt"])
|
|
63
|
+
expect(p.data).toMatchObject({ provider: "elevenlabs-sfx", duration: 22, loop: true, promptInfluence: 0.4 })
|
|
64
|
+
}
|
|
65
|
+
const ids = getFactoryPresets("text-to-audio").map((p) => p.id)
|
|
66
|
+
const i = ids.indexOf("text-to-audio/scifi-drone")
|
|
67
|
+
expect(ids.slice(i + 1, i + 4)).toEqual(TONES)
|
|
68
|
+
})
|
|
69
|
+
})
|
|
70
|
+
|
|
71
|
+
describe("Wave 0 — Site Screenshot Summary", () => {
|
|
72
|
+
it("ships in Extraction right after Product Description, structured, asking for the five labelled lines", () => {
|
|
73
|
+
const p = preset("image-to-text", "image-to-text/site-screenshot-summary")
|
|
74
|
+
expect(p.group).toBe("Extraction")
|
|
75
|
+
expect(p.data.detailLevel).toBe("structured")
|
|
76
|
+
const prompt = p.data.customPrompt as string
|
|
77
|
+
for (const label of ["Section:", "Headings:", "Figures:", "Brand:", "First look:"]) expect(prompt).toContain(label)
|
|
78
|
+
expect(prompt).toContain("exactly as written")
|
|
79
|
+
const ids = getFactoryPresets("image-to-text").map((x) => x.id)
|
|
80
|
+
expect(ids[ids.indexOf("image-to-text/product-desc") + 1]).toBe("image-to-text/site-screenshot-summary")
|
|
81
|
+
})
|
|
82
|
+
})
|
|
83
|
+
|
|
84
|
+
describe("Wave 0 — Hook Generator", () => {
|
|
85
|
+
it("ships in Writing & Marketing right after the script writer, at temperature 0.9, with node defaults for tokens and model", () => {
|
|
86
|
+
const p = preset("llm-chat", "llm-chat/hook-generator")
|
|
87
|
+
expect(p.group).toBe("Writing & Marketing")
|
|
88
|
+
expect(p.data.temperature).toBe(0.9)
|
|
89
|
+
expect(p.data.maxTokens).toBeUndefined()
|
|
90
|
+
expect(p.data.llmModel).toBeUndefined()
|
|
91
|
+
const ids = getFactoryPresets("llm-chat").map((x) => x.id)
|
|
92
|
+
expect(ids[ids.indexOf("llm-chat/script-writer") + 1]).toBe("llm-chat/hook-generator")
|
|
93
|
+
})
|
|
94
|
+
|
|
95
|
+
it("states the hook limit and the craft in words, and no rate figure", () => {
|
|
96
|
+
const s = preset("llm-chat", "llm-chat/hook-generator").data.systemPrompt as string
|
|
97
|
+
expect(s).toContain("at most 8 words")
|
|
98
|
+
expect(s).toContain("write 10 different opening lines")
|
|
99
|
+
expect(s).not.toMatch(/per second/i)
|
|
100
|
+
})
|
|
101
|
+
})
|
|
102
|
+
|
|
103
|
+
describe("Wave 0 — Hook Plate and Body Captions", () => {
|
|
104
|
+
const HOOK_PLATE_ID = "add-captions/hook-plate"
|
|
105
|
+
const BODY_ID = "add-captions/body-captions"
|
|
106
|
+
type CaptionInput = Parameters<typeof captionRoutesToRemotion>[0]
|
|
107
|
+
const asInput = (d: Readonly<Record<string, unknown>>): CaptionInput => d as CaptionInput
|
|
108
|
+
// The eight levers that move a subtitle off the plain drawtext burn (captionRoutesToRemotion).
|
|
109
|
+
const ROUTING_LEVERS: readonly string[] = ["look", "fontFamily", "fontWeight", "strokeColor", "strokeWidth", "uppercase", "positionY", "maxWordsPerLine"]
|
|
110
|
+
|
|
111
|
+
// Other Nodaro services render these values; a change here changes their output, so it needs its owner's review.
|
|
112
|
+
it("Hook Plate and Body Captions carry exactly these values", () => {
|
|
113
|
+
expect(preset("add-captions", HOOK_PLATE_ID).data).toEqual({
|
|
114
|
+
style: "subtitle", look: "clean", position: "top", positionY: 20, fontSize: 32, fontWeight: 800,
|
|
115
|
+
maxWordsPerLine: 4, color: "#111111", backgroundColor: "#FFFFFF", autoTranscribe: false,
|
|
116
|
+
})
|
|
117
|
+
expect(preset("add-captions", BODY_ID).data).toEqual({
|
|
118
|
+
style: "word-highlight", look: "outline", position: "bottom", positionY: 72, fontSize: 32,
|
|
119
|
+
maxWordsPerLine: 3, color: "#FFFFFF", autoTranscribe: true,
|
|
120
|
+
})
|
|
121
|
+
})
|
|
122
|
+
|
|
123
|
+
it("both sit in Caption Styles, right after Top Banner", () => {
|
|
124
|
+
const ids = getFactoryPresets("add-captions").map((p) => p.id)
|
|
125
|
+
const i = ids.indexOf("add-captions/top-banner")
|
|
126
|
+
expect(ids.slice(i + 1, i + 3)).toEqual([HOOK_PLATE_ID, BODY_ID])
|
|
127
|
+
for (const id of [HOOK_PLATE_ID, BODY_ID]) expect(preset("add-captions", id).group).toBe("Caption Styles")
|
|
128
|
+
})
|
|
129
|
+
|
|
130
|
+
it("Hook Plate with its line wired as text routes to the styled renderer", () => {
|
|
131
|
+
expect(captionRoutesToRemotion(asInput({ ...preset("add-captions", HOOK_PLATE_ID).data, text: "Sample hook line" }))).toBe(true)
|
|
132
|
+
})
|
|
133
|
+
|
|
134
|
+
it("negative control: the same plate stripped of its eight routing levers would burn through drawtext", () => {
|
|
135
|
+
const stripped = Object.fromEntries(
|
|
136
|
+
Object.entries({ ...preset("add-captions", HOOK_PLATE_ID).data, text: "Sample hook line" }).filter(([k]) => !ROUTING_LEVERS.includes(k)),
|
|
137
|
+
)
|
|
138
|
+
expect(captionRoutesToRemotion(asInput(stripped))).toBe(false)
|
|
139
|
+
})
|
|
140
|
+
|
|
141
|
+
it("applying Body Captions or Clean Subtitles over Hook Plate leaves no plate lever", () => {
|
|
142
|
+
const applied = (node: Readonly<Record<string, unknown>>, p: Readonly<Record<string, unknown>>) =>
|
|
143
|
+
({ ...node, ...Object.fromEntries(presetApplyClearKeys(p, "add-captions").map((k) => [k, undefined])), ...p }) as Record<string, unknown>
|
|
144
|
+
const plate = preset("add-captions", HOOK_PLATE_ID).data
|
|
145
|
+
const overBody = applied(plate, preset("add-captions", BODY_ID).data)
|
|
146
|
+
expect(overBody.backgroundColor).toBeUndefined()
|
|
147
|
+
expect(overBody.fontWeight).toBeUndefined()
|
|
148
|
+
const overClean = applied(plate, preset("add-captions", "add-captions/clean-subtitles").data)
|
|
149
|
+
for (const k of ["backgroundColor", "look", "positionY", "fontWeight", "maxWordsPerLine"]) expect(overClean[k], k).toBeUndefined()
|
|
150
|
+
})
|
|
151
|
+
})
|
|
@@ -6,6 +6,7 @@ import { STYLE_IDS } from "../index.js"
|
|
|
6
6
|
import { joinPromptParts } from "../resolve-prompt.js"
|
|
7
7
|
import { nodeSupportsPromptAffixes } from "../node-prompt-fields.js"
|
|
8
8
|
import { IMAGE_GEN_PROVIDERS, MODIFY_IMAGE_PROVIDERS, VIDEO_GEN_PROVIDERS, VIDEO_TO_VIDEO_PROVIDERS, MUSIC_PROVIDERS, SUNO_MODELS, TTS_PROVIDERS, TEXT_TO_AUDIO_PROVIDERS, ALL_CAPTION_STYLES, COMBINE_TRANSITION_IDS, AUDIO_CROSSFADE_CURVE_IDS, aspectRatioOptionsByKind, durationsByMode, IMAGE_PROMPT_MAX, MODEL_CATALOG, NATIVE_NEGATIVE_VIDEO_PROVIDERS, VIDEO_DURATION_AUTO, supportsAutoVideoDuration } from "@nodaro/shared"
|
|
9
|
+
import { CAPTION_LOOK_IDS, CAPTION_MAX_WORDS_PER_LINE_MAX, CAPTION_MAX_WORDS_PER_LINE_MIN, KINETIC_ONLY_CAPTION_LEVER_KEYS, SUPPORTED_FONT_NAMES } from "@nodaro/shared"
|
|
9
10
|
|
|
10
11
|
/**
|
|
11
12
|
* The text a preset ACTUALLY sends: a preset may ship its doctrine as pre/post
|
|
@@ -740,6 +741,37 @@ describe("add-captions factory preset data validity", () => {
|
|
|
740
741
|
}
|
|
741
742
|
})
|
|
742
743
|
|
|
744
|
+
it("keeps every look lever valid: positionY 0-100, a known look and font, fontWeight a 100-step in 100-900, maxWordsPerLine in bounds", () => {
|
|
745
|
+
for (const p of presets) {
|
|
746
|
+
const d = p.data
|
|
747
|
+
if (d.positionY !== undefined) {
|
|
748
|
+
expect(d.positionY as number, `${p.id}: positionY`).toBeGreaterThanOrEqual(0)
|
|
749
|
+
expect(d.positionY as number, `${p.id}: positionY`).toBeLessThanOrEqual(100)
|
|
750
|
+
}
|
|
751
|
+
if (d.look !== undefined) expect(CAPTION_LOOK_IDS, `${p.id}: look`).toContain(d.look as never)
|
|
752
|
+
if (d.fontFamily !== undefined) expect(SUPPORTED_FONT_NAMES, `${p.id}: fontFamily`).toContain(d.fontFamily as never)
|
|
753
|
+
if (d.fontWeight !== undefined) {
|
|
754
|
+
const w = d.fontWeight as number
|
|
755
|
+
expect(w % 100, `${p.id}: fontWeight step`).toBe(0)
|
|
756
|
+
expect(w, `${p.id}: fontWeight`).toBeGreaterThanOrEqual(100)
|
|
757
|
+
expect(w, `${p.id}: fontWeight`).toBeLessThanOrEqual(900)
|
|
758
|
+
}
|
|
759
|
+
if (d.maxWordsPerLine !== undefined) {
|
|
760
|
+
const m = d.maxWordsPerLine as number
|
|
761
|
+
expect(Number.isInteger(m), `${p.id}: maxWordsPerLine integer`).toBe(true)
|
|
762
|
+
expect(m).toBeGreaterThanOrEqual(CAPTION_MAX_WORDS_PER_LINE_MIN)
|
|
763
|
+
expect(m).toBeLessThanOrEqual(CAPTION_MAX_WORDS_PER_LINE_MAX)
|
|
764
|
+
}
|
|
765
|
+
}
|
|
766
|
+
})
|
|
767
|
+
|
|
768
|
+
it("a subtitle preset carries no kinetic-only lever (the route rejects them on subtitle)", () => {
|
|
769
|
+
for (const p of presets) {
|
|
770
|
+
if (p.data.style !== "subtitle") continue
|
|
771
|
+
for (const k of KINETIC_ONLY_CAPTION_LEVER_KEYS) expect(p.data[k], `${p.id}: ${k}`).toBeUndefined()
|
|
772
|
+
}
|
|
773
|
+
})
|
|
774
|
+
|
|
743
775
|
it("groups every preset under a folder", () => {
|
|
744
776
|
for (const p of presets) expect(p.group, `${p.id}: missing group`).toBeTruthy()
|
|
745
777
|
})
|
|
@@ -1124,3 +1156,34 @@ describe("generate-video/edit-video — the Seedance 2.5 edit shape, up front",
|
|
|
1124
1156
|
})
|
|
1125
1157
|
})
|
|
1126
1158
|
|
|
1159
|
+
|
|
1160
|
+
describe("factory preset data carries no null", () => {
|
|
1161
|
+
// The canvas sends a node's levers as they are, and the routes' optional fields reject null —
|
|
1162
|
+
// a null in a preset would fail the run with a 400. "Unset" is expressed by leaving the key out.
|
|
1163
|
+
const nullPaths = (data: Readonly<Record<string, unknown>>): string[] => {
|
|
1164
|
+
const out: string[] = []
|
|
1165
|
+
for (const [k, v] of Object.entries(data)) {
|
|
1166
|
+
if (v === null) {
|
|
1167
|
+
out.push(k)
|
|
1168
|
+
} else if (Array.isArray(v)) {
|
|
1169
|
+
v.forEach((x, i) => {
|
|
1170
|
+
if (x === null) out.push(`${k}[${i}]`)
|
|
1171
|
+
})
|
|
1172
|
+
} else if (typeof v === "object") {
|
|
1173
|
+
for (const [k2, v2] of Object.entries(v as Record<string, unknown>)) if (v2 === null) out.push(`${k}.${k2}`)
|
|
1174
|
+
}
|
|
1175
|
+
}
|
|
1176
|
+
return out
|
|
1177
|
+
}
|
|
1178
|
+
|
|
1179
|
+
it("finds a null at the top level and one level down (self-check)", () => {
|
|
1180
|
+
expect(nullPaths({ a: null, b: { c: null }, d: [null], e: 1 })).toEqual(["a", "b.c", "d[0]"])
|
|
1181
|
+
})
|
|
1182
|
+
|
|
1183
|
+
it("no factory preset data value is null", () => {
|
|
1184
|
+
const offenders = Object.values(FACTORY_PRESETS)
|
|
1185
|
+
.flat()
|
|
1186
|
+
.flatMap((p) => nullPaths(p.data).map((path) => `${p.id}.${path}`))
|
|
1187
|
+
expect(offenders, `null values: ${offenders.join(", ")}`).toEqual([])
|
|
1188
|
+
})
|
|
1189
|
+
})
|
|
@@ -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"
|