@nodaro/prompts 1.29.0 → 1.32.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.29.0",
3
+ "version": "1.32.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.17.0"
23
+ "@nodaro/shared": "^3.23.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
+ })
@@ -270,3 +270,35 @@ describe("targeted gap coverage: facial-geometry + brand camera/render entries",
270
270
  }
271
271
  })
272
272
  })
273
+
274
+ // ---------------------------------------------------------------------------
275
+ // Test 4 — the Camera Motion and Transition pickers carry a LABEL for every
276
+ // option in Japanese and Brazilian Portuguese. Fourteen camera motions (Dolly
277
+ // In, Zoom In, POV, …) had a translated description but no label in pt-BR, so
278
+ // the editor and the docs gallery (which syncs these sidecars) showed the
279
+ // English name. A rig or brand name keeps its canonical form as an explicit
280
+ // label (Steadicam, Snorricam) — a present label is the decision; a missing one
281
+ // is the bug. Scoped to the two locales whose docs pages are held to "no English
282
+ // apart from model names"; extend the list when another locale is held to it.
283
+ // ---------------------------------------------------------------------------
284
+
285
+ const FULL_LABEL_CATALOGS = ["camera-motions", "transitions"] as const
286
+ const FULL_LABEL_LOCALES: readonly LocaleId[] = ["ja", "pt-BR"]
287
+
288
+ describe("Camera Motion and Transition labels are complete in ja and pt-BR", () => {
289
+ const cases: Array<[string, LocaleId]> = []
290
+ for (const catalogId of FULL_LABEL_CATALOGS) {
291
+ for (const locale of FULL_LABEL_LOCALES) cases.push([catalogId, locale])
292
+ }
293
+
294
+ it.each(cases)("%s [%s] labels every entry", (catalogId, locale) => {
295
+ const catalog = CATALOGS.find((c) => c.catalogId === catalogId)
296
+ expect(catalog, `${catalogId} is not a registered picker catalog`).toBeDefined()
297
+ const map = mapFor(catalogId, locale)
298
+ const unlabelled = (catalog?.options ?? []).filter((o) => !has(map[o.id]?.label)).map((o) => o.id)
299
+ expect(
300
+ unlabelled,
301
+ `packages/shared/src/i18n/${catalogId}.${locale}.ts has no label for these ids, so the picker shows the English name`,
302
+ ).toEqual([])
303
+ })
304
+ })
@@ -0,0 +1,91 @@
1
+ import { describe, expect, it } from "vitest"
2
+ import { TEXT_REQUIRED_NODE_TYPES, computeAiWriterInput, computeNodeSendText } from "../node-send-text.js"
3
+
4
+ /**
5
+ * "What text would this node send?" — answered by the executor's own prompt
6
+ * rule per type, so the orchestrator's empty-input skip can never disagree
7
+ * with the request the node would make.
8
+ */
9
+ const none = new Map<string, string>()
10
+
11
+ describe("computeNodeSendText", () => {
12
+ it("answers for every text-requiring type and for no other (totality)", () => {
13
+ for (const type of TEXT_REQUIRED_NODE_TYPES) {
14
+ expect(typeof computeNodeSendText(type, {}, { refMap: none }), type).toBe("string")
15
+ }
16
+ // Video nodes and social posts stay out: an empty prompt on an image-to-video is a legal request, an empty caption the author's choice.
17
+ expect(computeNodeSendText("image-to-video", {}, { refMap: none })).toBeUndefined()
18
+ expect(computeNodeSendText("combine-text", { text: "x" }, { refMap: none })).toBeUndefined()
19
+ expect(computeNodeSendText("telegram-post", {}, { refMap: none })).toBeUndefined()
20
+ })
21
+
22
+ it("llm-chat: the wired text when the node typed none; nothing when both are empty; a typed prompt runs on its own", () => {
23
+ expect(computeNodeSendText("llm-chat", {}, { wired: "the posts", refMap: none })).toBe("the posts")
24
+ expect(computeNodeSendText("llm-chat", {}, { wired: "", refMap: none })).toBe("")
25
+ expect(computeNodeSendText("llm-chat", {}, { refMap: none })).toBe("")
26
+ expect(computeNodeSendText("llm-chat", { userInput: "Write a poem" }, { wired: "", refMap: none })).toContain("Write a poem")
27
+ })
28
+
29
+ it("llm-chat: a fan-out item outranks the wire", () => {
30
+ expect(computeNodeSendText("llm-chat", {}, { override: "item 1", wired: "all", refMap: none })).toBe("item 1")
31
+ })
32
+
33
+ it("llm-chat: a typed template whose {Ref} resolves to nothing is still a non-empty request (the stated residual)", () => {
34
+ const refMap = new Map([["Feed", ""]])
35
+ const text = computeNodeSendText("llm-chat", { userInput: "Summarize: {Feed}" }, { wired: "", refMap })
36
+ expect(text?.trim().length).toBeGreaterThan(0)
37
+ })
38
+
39
+ it("ai-writer: a fan-out item, else the wire, else the typed userInput, else the legacy prompt", () => {
40
+ expect(computeAiWriterInput({ userInput: "typed", prompt: "legacy" }, { override: "item", wired: "wire" })).toBe("item")
41
+ expect(computeAiWriterInput({ userInput: "typed", prompt: "legacy" }, { wired: "wire" })).toBe("wire")
42
+ expect(computeAiWriterInput({ userInput: "typed", prompt: "legacy" }, { wired: " " })).toBe("typed")
43
+ expect(computeAiWriterInput({ prompt: "legacy" }, {})).toBe("legacy")
44
+ expect(computeAiWriterInput({}, { wired: "" })).toBe("")
45
+ expect(computeNodeSendText("ai-writer", {}, { wired: "", refMap: none })).toBe("")
46
+ })
47
+
48
+ it("generate-script: the topic is the wire or the typed prompt", () => {
49
+ expect(computeNodeSendText("generate-script", { prompt: "a cat" }, { refMap: none })).toContain("a cat")
50
+ expect(computeNodeSendText("generate-script", {}, { wired: "", refMap: none })).toBe("")
51
+ })
52
+
53
+ it("generate-music: typed lyrics count as something to send when the prompt is empty", () => {
54
+ expect(computeNodeSendText("generate-music", { lyrics: "la la" }, { wired: "", refMap: none })).toBe("la la")
55
+ expect(computeNodeSendText("generate-music", {}, { wired: "", refMap: none })).toBe("")
56
+ })
57
+
58
+ it("collection-write: the item (a fan-out row, else the wire), else a typed title / text / link; nothing of those is nothing to save", () => {
59
+ const refMap = new Map<string, string>()
60
+ expect(computeNodeSendText("collection-write", {}, { wired: "", refMap })).toBe("")
61
+ expect(computeNodeSendText("collection-write", { title: " " }, { wired: "", refMap })).toBe("")
62
+ expect(computeNodeSendText("collection-write", { link: "https://news.example.test/daily" }, { wired: "", refMap })).toBe("https://news.example.test/daily")
63
+ expect(computeNodeSendText("collection-write", { title: "Daily" }, { wired: "the wire", refMap })).toBe("the wire")
64
+ expect(computeNodeSendText("collection-write", {}, { override: "row 3", wired: "the wire", refMap })).toBe("row 3")
65
+ })
66
+
67
+ it("prompt pre/post text never counts as something to send: an empty core with a prefix is nothing", () => {
68
+ const refMap = new Map<string, string>()
69
+ const affixed = { userInput: "", promptPrefix: "Write a news item about:", promptSuffix: "Keep it short." }
70
+ expect(computeNodeSendText("llm-chat", affixed, { wired: "", refMap })).toBe("")
71
+ expect(computeNodeSendText("generate-script", { prompt: "", promptPrefix: "Topic:" }, { wired: "", refMap })).toBe("")
72
+ expect(computeNodeSendText("text-to-speech", { promptPrefix: "Say:" }, { wired: "", refMap })).toBe("")
73
+ // With a core, the decision is on the core — non-empty either way.
74
+ expect(computeNodeSendText("llm-chat", affixed, { wired: "the posts", refMap })).not.toBe("")
75
+ })
76
+
77
+ it("generate-image: the prompt it would send (a fan-out row, else the wire, else the typed prompt); nothing of those is nothing", () => {
78
+ const refMap = new Map<string, string>()
79
+ expect(computeNodeSendText("generate-image", { prompt: "" }, { wired: "", refMap })).toBe("")
80
+ expect(computeNodeSendText("generate-image", { prompt: "a city at dusk" }, { wired: "", refMap })).toBe("a city at dusk")
81
+ expect(computeNodeSendText("generate-image", { prompt: "" }, { wired: "a cover for the story", refMap })).toBe("a cover for the story")
82
+ expect(computeNodeSendText("generate-image", { prompt: "", promptPrefix: "Photo of" }, { wired: "", refMap })).toBe("")
83
+ })
84
+
85
+ it("text-to-speech / text-to-audio: the wired text passes through; nothing wired and nothing typed is nothing", () => {
86
+ expect(computeNodeSendText("text-to-speech", {}, { wired: "read this", refMap: none })).toContain("read this")
87
+ expect(computeNodeSendText("text-to-speech", {}, { wired: "", refMap: none })).toBe("")
88
+ expect(computeNodeSendText("text-to-audio", {}, { wired: "rain", refMap: none })).toContain("rain")
89
+ expect(computeNodeSendText("text-to-audio", {}, { refMap: none })).toBe("")
90
+ })
91
+ })
@@ -321,5 +321,7 @@ describe("blended cut — the rule holds for every row and lever (self-consisten
321
321
  check([a, b], levers(position, duration, "natural"), "clip")
322
322
  }
323
323
  }
324
- })
324
+ // An exhaustive sweep: under a second locally, but past vitest's 5 s default
325
+ // on a loaded 2-vCPU CI runner (it timed out there three times on 2026-10-05).
326
+ }, 60_000)
325
327
  })
@@ -0,0 +1,36 @@
1
+ /**
2
+ * Remove inline `[audio tags]` from speech text, for a model that does not
3
+ * perform them (it would read them aloud). Pure, dependency-free: the
4
+ * backend's credit counter (lib/speech-credits.ts), its payload builder and
5
+ * the editor's estimate (frontend/src/lib/speech-estimate.ts) all read it, so
6
+ * the two engines count exactly the same characters.
7
+ *
8
+ * A tag is `[`, one or more characters that are not `]`, then `]` — what
9
+ * `/\[[^\]]+\]/g` matches. That pattern is quadratic on an unclosed run of `[`
10
+ * (every `[` rescans to the end of the text), and the credit counter runs this
11
+ * on raw request text on the API and orchestrator event loops, so the scan is
12
+ * written by hand: one `indexOf` per `[`, and when no `]` is left no later `[`
13
+ * can match either, so the rest of the text is kept in one slice.
14
+ */
15
+ function removeBracketTags(text: string): string {
16
+ let out = ""
17
+ let from = 0
18
+ let open = text.indexOf("[")
19
+ while (open !== -1) {
20
+ const close = text.indexOf("]", open + 1)
21
+ if (close === -1) break
22
+ if (close === open + 1) {
23
+ // "[]" is not a tag (needs 1+ characters); the "[" stays and the scan moves on.
24
+ open = text.indexOf("[", open + 1)
25
+ continue
26
+ }
27
+ out += text.slice(from, open)
28
+ from = close + 1
29
+ open = text.indexOf("[", from)
30
+ }
31
+ return out + text.slice(from)
32
+ }
33
+
34
+ export function stripAudioTags(text: string): string {
35
+ return removeBracketTags(text).replace(/\s{2,}/g, " ").trim()
36
+ }