@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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nodaro/prompts",
3
- "version": "1.28.0",
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.15.0"
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
+ })