@nodaro/prompts 1.27.0 → 1.28.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 +887 -598
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +166 -8
- package/dist/index.d.ts +166 -8
- package/dist/index.js +870 -599
- package/dist/index.js.map +1 -1
- package/package.json +2 -2
- package/src/__tests__/fixtures/parameter-hint-golden.json +33 -1
- package/src/__tests__/hint-join.test.ts +64 -1
- package/src/__tests__/motion-hint.test.ts +20 -0
- package/src/__tests__/parameter-registry-sync.test.ts +1 -0
- package/src/__tests__/person-mixed-heritage.test.ts +43 -0
- package/src/__tests__/resolve-prompt.test.ts +13 -1
- package/src/__tests__/transition-options.test.ts +223 -0
- package/src/__tests__/transition-styles.test.ts +202 -0
- package/src/__tests__/transitions-instant.test.ts +9 -5
- package/src/__tests__/transitions-scope.test.ts +415 -3
- package/src/__tests__/video-own-motion.test.ts +26 -0
- package/src/hint-join.ts +33 -1
- package/src/index.ts +1 -0
- package/src/parameter-prompt-hint.ts +20 -3
- package/src/person.ts +24 -8
- package/src/picker-art/character-art-files.generated.ts +536 -536
- package/src/picker-catalogs.ts +55 -2
- package/src/prompt-builder.ts +2 -1
- package/src/prompt-hint-join.ts +8 -2
- package/src/prompt-style-section.ts +3 -5
- package/src/resolve-prompt.ts +22 -4
- package/src/transitions.ts +309 -40
- package/src/video-own-motion.ts +29 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nodaro/prompts",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.28.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.15.0"
|
|
24
24
|
},
|
|
25
25
|
"devDependencies": {
|
|
26
26
|
"tsup": "^8.5.0",
|
|
@@ -1931,6 +1931,38 @@
|
|
|
1931
1931
|
"ctx": null,
|
|
1932
1932
|
"expected": "a Crusader knight — chainmail hauberk under a white surcoat with a red cross emblem, conical helm, longsword and kite shield, devout warrior bearing, in their early 20s, around 21 years old, of Norwegian descent, a Shibuya streetwear aesthetic — Tokyo street-fashion vibe, layered post-modern mood, full-figured, full bust, pear-shaped silhouette, a heart-shaped face with a wide forehead and pointed chin, a pointed chin with a narrow jawline, high, defined cheekbones, a lean face with low facial fat and subtle hollowing beneath the cheekbones, double-eyelid eyes with a clearly defined upper crease, droopy eyes with the upper lids sitting low for a soft, sleepy gaze, upturned eyes with the outer corners lifted, positive canthal tilt, close-set eyes set close together, eyes set close beneath the brow with little brow-to-eye distance, hunter eyes, a small button nose with a rounded tip, a slightly upturned nose tip, full plump lips, a wide mouth, with lips pursed, pressed and pushed forward, long straight hair, gray hair, bold defined dark full eyebrows, tan skin, with visible swimwear tan lines on the skin, rare violet eyes with a deep purple iris, gazing off-camera with a contemplative expression, a goatee, with a small face tattoo near the eye or cheekbone"
|
|
1933
1933
|
},
|
|
1934
|
+
{
|
|
1935
|
+
"key": "person/mixed-heritage",
|
|
1936
|
+
"note": "Two ethnicity picks → one mixed-heritage phrase from the entries' prompt text",
|
|
1937
|
+
"node": {
|
|
1938
|
+
"id": "n1",
|
|
1939
|
+
"type": "person",
|
|
1940
|
+
"data": {
|
|
1941
|
+
"ethnicity": [
|
|
1942
|
+
"slavic",
|
|
1943
|
+
"mediterranean"
|
|
1944
|
+
]
|
|
1945
|
+
}
|
|
1946
|
+
},
|
|
1947
|
+
"ctx": null,
|
|
1948
|
+
"expected": "of mixed Slavic Eastern European and Mediterranean heritage"
|
|
1949
|
+
},
|
|
1950
|
+
{
|
|
1951
|
+
"key": "person/mixed-heritage-abbrev",
|
|
1952
|
+
"note": "Two ethnicity picks whose short labels are abbreviations (Pacific Isl. / East (any))",
|
|
1953
|
+
"node": {
|
|
1954
|
+
"id": "n1",
|
|
1955
|
+
"type": "person",
|
|
1956
|
+
"data": {
|
|
1957
|
+
"ethnicity": [
|
|
1958
|
+
"pacific-islander",
|
|
1959
|
+
"east-asian"
|
|
1960
|
+
]
|
|
1961
|
+
}
|
|
1962
|
+
},
|
|
1963
|
+
"ctx": null,
|
|
1964
|
+
"expected": "of mixed Pacific Islander and East Asian heritage"
|
|
1965
|
+
},
|
|
1934
1966
|
{
|
|
1935
1967
|
"key": "person/one-dimension",
|
|
1936
1968
|
"note": "multi-dim: only \"type\" set",
|
|
@@ -2922,7 +2954,7 @@
|
|
|
2922
2954
|
}
|
|
2923
2955
|
},
|
|
2924
2956
|
"ctx": null,
|
|
2925
|
-
"expected": "double exposure (the first
|
|
2957
|
+
"expected": "double exposure (the first subject becomes a crisp silhouette filled solid with the second shot, while the rest of the first shot stays untouched around it. The camera stays where it is and the framing does not change. The silhouette holds for a clear beat, both pictures plainly seen at once, then the first shot around it gives way to the second shot. The shot ends on the second shot alone, fully resolved)"
|
|
2926
2958
|
},
|
|
2927
2959
|
{
|
|
2928
2960
|
"key": "transition/id:dream-ripple",
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import { describe, it, expect } from "vitest"
|
|
2
|
-
import { joinHintFragments } from "../hint-join.js"
|
|
2
|
+
import { joinHintFragments, joinSentences, appendPromptHints } from "../hint-join.js"
|
|
3
|
+
import { joinPromptHints } from "../prompt-hint-join.js"
|
|
4
|
+
import { composeNegative, resolvePrompt } from "../resolve-prompt.js"
|
|
3
5
|
import { composeCameraMotionHintFromConnections, getCameraMotionPromptHint } from "../camera-motions.js"
|
|
4
6
|
import { getParameterPromptHint } from "../parameter-prompt-hint.js"
|
|
5
7
|
|
|
@@ -56,3 +58,64 @@ describe("sentence-style camera-motion hints compose cleanly", () => {
|
|
|
56
58
|
)
|
|
57
59
|
})
|
|
58
60
|
})
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* A prompt typed with a closing period used to come out doubled once a picker
|
|
64
|
+
* hint was appended: "No text, no logos, no watermark.. butterfly portrait
|
|
65
|
+
* lighting" (every picker example run in the docs rebuild).
|
|
66
|
+
*/
|
|
67
|
+
describe("joinSentences", () => {
|
|
68
|
+
it("joins with a period and a space", () => {
|
|
69
|
+
expect(joinSentences(["a red fox", "soft window light"])).toBe("a red fox. soft window light")
|
|
70
|
+
})
|
|
71
|
+
|
|
72
|
+
it("never doubles a closing period, question or exclamation mark, or ellipsis", () => {
|
|
73
|
+
expect(joinSentences(["No text, no logos, no watermark.", "butterfly portrait lighting"])).toBe(
|
|
74
|
+
"No text, no logos, no watermark. butterfly portrait lighting",
|
|
75
|
+
)
|
|
76
|
+
expect(joinSentences(["Is it raining?", "wet asphalt"])).toBe("Is it raining? wet asphalt")
|
|
77
|
+
expect(joinSentences(["Go!", "motion blur"])).toBe("Go! motion blur")
|
|
78
|
+
expect(joinSentences(["and then…", "a door opens"])).toBe("and then… a door opens")
|
|
79
|
+
expect(joinSentences(["雨の街。", "neon"])).toBe("雨の街。 neon")
|
|
80
|
+
})
|
|
81
|
+
|
|
82
|
+
it("does not carry trailing space into the join, and drops blank pieces", () => {
|
|
83
|
+
expect(joinSentences(["a cat. ", "noir"])).toBe("a cat. noir")
|
|
84
|
+
expect(joinSentences(["a cat \n", "noir"])).toBe("a cat. noir")
|
|
85
|
+
expect(joinSentences(["", " ", undefined, "noir"])).toBe("noir")
|
|
86
|
+
expect(joinSentences([])).toBe("")
|
|
87
|
+
})
|
|
88
|
+
})
|
|
89
|
+
|
|
90
|
+
describe("appendPromptHints", () => {
|
|
91
|
+
it("joins the hints as clauses, then follows the prompt as a new sentence", () => {
|
|
92
|
+
expect(appendPromptHints("A studio portrait.", ["butterfly lighting", "85mm lens"])).toBe(
|
|
93
|
+
"A studio portrait. butterfly lighting, 85mm lens",
|
|
94
|
+
)
|
|
95
|
+
expect(appendPromptHints("A studio portrait", ["butterfly lighting"])).toBe("A studio portrait. butterfly lighting")
|
|
96
|
+
})
|
|
97
|
+
|
|
98
|
+
it("gives the hints alone with no prompt, and the prompt back unchanged with no hints", () => {
|
|
99
|
+
expect(appendPromptHints(undefined, ["butterfly lighting"])).toBe("butterfly lighting")
|
|
100
|
+
expect(appendPromptHints("", ["butterfly lighting"])).toBe("butterfly lighting")
|
|
101
|
+
expect(appendPromptHints("A portrait. ", [])).toBe("A portrait. ")
|
|
102
|
+
expect(appendPromptHints("A portrait", ["", " "])).toBe("A portrait")
|
|
103
|
+
})
|
|
104
|
+
})
|
|
105
|
+
|
|
106
|
+
describe("the other prompt joins are sentence-aware too", () => {
|
|
107
|
+
const M = new Map<string, string>()
|
|
108
|
+
it("folded direction hints (joinPromptHints)", () => {
|
|
109
|
+
expect(joinPromptHints("No watermark.", ["golden hour", "shallow depth of field"])).toBe(
|
|
110
|
+
"No watermark. golden hour. shallow depth of field",
|
|
111
|
+
)
|
|
112
|
+
expect(joinPromptHints(" untouched ", [])).toBe(" untouched ")
|
|
113
|
+
})
|
|
114
|
+
it("a connected prompt appended to the typed one (appendWired)", () => {
|
|
115
|
+
expect(resolvePrompt({ typed: ["A fox."], wired: "in the snow", refMap: M, appendWired: true })).toBe("A fox. in the snow")
|
|
116
|
+
})
|
|
117
|
+
it("a connected negative prompt (composeNegative)", () => {
|
|
118
|
+
expect(composeNegative("blurry.", "watermark")).toBe("blurry. watermark")
|
|
119
|
+
expect(composeNegative("blurry", "watermark")).toBe("blurry. watermark")
|
|
120
|
+
})
|
|
121
|
+
})
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest"
|
|
2
|
+
import { getParameterPromptHint } from "../parameter-prompt-hint.js"
|
|
3
|
+
|
|
4
|
+
describe("Motion's prompt clause", () => {
|
|
5
|
+
it.each([
|
|
6
|
+
["subtle", /subtle, gentle motion/],
|
|
7
|
+
["moderate", /moderate, natural motion/],
|
|
8
|
+
["dynamic", /dynamic, energetic motion/],
|
|
9
|
+
])("%s", (motion, clause) => {
|
|
10
|
+
expect(getParameterPromptHint({ id: "m", type: "motion", data: { motion } })).toMatch(clause)
|
|
11
|
+
})
|
|
12
|
+
|
|
13
|
+
it("is the bare term in compact mode", () => {
|
|
14
|
+
expect(getParameterPromptHint({ id: "m", type: "motion", data: { motion: "dynamic", hintMode: "compact" } })).toBe("dynamic motion")
|
|
15
|
+
})
|
|
16
|
+
|
|
17
|
+
it("is empty for an unknown step", () => {
|
|
18
|
+
expect(getParameterPromptHint({ id: "m", type: "motion", data: { motion: "wild" } })).toBe("")
|
|
19
|
+
})
|
|
20
|
+
})
|
|
@@ -152,6 +152,7 @@ const SAMPLE_DATA_BY_TYPE: Record<string, Record<string, unknown>> = {
|
|
|
152
152
|
"scene-count": { count: 5 },
|
|
153
153
|
"duration": { seconds: 8 },
|
|
154
154
|
"aspect-ratio": { ratio: "16:9" },
|
|
155
|
+
"provider": { category: "video", provider: "seedance-2" },
|
|
155
156
|
"music-genre": { genre: firstId(MUSIC_GENRES) },
|
|
156
157
|
"music-mood": { emotion: firstId(MUSIC_EMOTIONS) },
|
|
157
158
|
"instrumentation": { instruments: [firstId(INSTRUMENTS)] },
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Two ethnicity picks read as one heritage phrase built from each entry's
|
|
3
|
+
* prompt text — never from its short label, which is picker-grid display copy
|
|
4
|
+
* ("Mediter.", "Pacific Isl.", "any", a bare "East"). Two picks used to put
|
|
5
|
+
* those abbreviations into the prompt: "of mixed Slavic and Mediter. heritage".
|
|
6
|
+
*/
|
|
7
|
+
import { describe, it, expect } from "vitest"
|
|
8
|
+
import { buildPersonHints, buildPersonTerms, PEOPLE } from "../person.js"
|
|
9
|
+
|
|
10
|
+
const hintsFor = (ethnicity: string[]) => buildPersonHints({ ethnicity })
|
|
11
|
+
const termsFor = (ethnicity: string[]) => buildPersonTerms({ ethnicity })
|
|
12
|
+
|
|
13
|
+
describe("mixed heritage (two ethnicity picks)", () => {
|
|
14
|
+
it("names both heritages in full", () => {
|
|
15
|
+
expect(hintsFor(["slavic", "mediterranean"])).toContain(
|
|
16
|
+
"of mixed Slavic Eastern European and Mediterranean heritage",
|
|
17
|
+
)
|
|
18
|
+
expect(hintsFor(["pacific-islander", "east-asian"])).toContain("of mixed Pacific Islander and East Asian heritage")
|
|
19
|
+
})
|
|
20
|
+
|
|
21
|
+
it("never doubles the word heritage", () => {
|
|
22
|
+
expect(hintsFor(["brazilian", "west-african"])).toContain("of mixed Brazilian and West African heritage")
|
|
23
|
+
})
|
|
24
|
+
|
|
25
|
+
it("reads the same in compact mode", () => {
|
|
26
|
+
expect(termsFor(["slavic", "mediterranean"])).toContain("of mixed Slavic Eastern European and Mediterranean heritage")
|
|
27
|
+
})
|
|
28
|
+
|
|
29
|
+
it("treats 'mixed' paired with a specific pick as that pick", () => {
|
|
30
|
+
expect(hintsFor(["mixed", "italian"])).toEqual(hintsFor(["italian"]))
|
|
31
|
+
})
|
|
32
|
+
|
|
33
|
+
it("puts no short-label abbreviation into any two-pick phrase", () => {
|
|
34
|
+
// The short labels' tell-tales: an abbreviation dot ("Mediter.", "Isl."),
|
|
35
|
+
// "(any)" / a bare "any", and a region with no noun ("West", "East").
|
|
36
|
+
const bad = /\.|\(any\)|\bany\b|of mixed (West|East|Central|Southern) and/
|
|
37
|
+
// Paired with French, so French itself is the one entry left out.
|
|
38
|
+
const leaks = PEOPLE.filter((p) => p.dimension === "ethnicity" && p.id !== "mixed" && p.id !== "french")
|
|
39
|
+
.map((p) => `${p.id}: ${hintsFor([p.id, "french"]).find((h) => h.startsWith("of mixed")) ?? "(no phrase)"}`)
|
|
40
|
+
.filter((line) => bad.test(line.slice(line.indexOf(":") + 1)) || line.endsWith("(no phrase)"))
|
|
41
|
+
expect(leaks).toEqual([])
|
|
42
|
+
})
|
|
43
|
+
})
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { describe, it, expect } from "vitest"
|
|
2
|
-
import { resolvePrompt, computeNodePrompt, computeLlmChatFields } from "../resolve-prompt.js"
|
|
2
|
+
import { resolvePrompt, computeNodePrompt, computeLlmChatFields, computeScriptTopic } from "../resolve-prompt.js"
|
|
3
3
|
const M = new Map<string, string>()
|
|
4
4
|
const R = new Map([["Hero", "a knight"]])
|
|
5
5
|
describe("resolvePrompt", () => {
|
|
@@ -42,3 +42,15 @@ describe("computeLlmChatFields", () => {
|
|
|
42
42
|
{ override: "o", wiredUserInput: "wu", wiredSystemPrompt: "ws", refMap: M }).userInput).toBe("o")
|
|
43
43
|
})
|
|
44
44
|
})
|
|
45
|
+
describe("computeScriptTopic (generate-script)", () => {
|
|
46
|
+
it("the wired prompt wins over the saved `prompt`, the reverse of computeNodePrompt", () =>
|
|
47
|
+
expect(computeScriptTopic({ prompt: "saved" }, { wired: "wire", refMap: M })).toBe("wire"))
|
|
48
|
+
it("a list fan-out item wins over both", () =>
|
|
49
|
+
expect(computeScriptTopic({ prompt: "saved" }, { override: "row", wired: "wire", refMap: M })).toBe("row"))
|
|
50
|
+
it("falls back to the saved `prompt` and resolves its {Label}", () =>
|
|
51
|
+
expect(computeScriptTopic({ prompt: "{Hero}" }, { wired: " ", refMap: R })).toBe("a knight"))
|
|
52
|
+
it("wraps the topic with the node's pre/post text", () =>
|
|
53
|
+
expect(computeScriptTopic({ promptPrefix: "A story:", promptSuffix: "The end." }, { wired: "a knight", refMap: M }))
|
|
54
|
+
.toBe("A story: a knight The end."))
|
|
55
|
+
it("nothing -> empty string", () => expect(computeScriptTopic({}, { refMap: M })).toBe(""))
|
|
56
|
+
})
|
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
import { afterEach, describe, expect, it } from "vitest"
|
|
2
|
+
import {
|
|
3
|
+
TRANSITIONS,
|
|
4
|
+
TRANSITION_OPTION_FIELDS,
|
|
5
|
+
WIPE_DIRECTION,
|
|
6
|
+
composeTransitionHintFromConnections,
|
|
7
|
+
getParameterPromptHint,
|
|
8
|
+
getPickerCatalog,
|
|
9
|
+
getTransitionOptions,
|
|
10
|
+
getTransitionPromptHint,
|
|
11
|
+
projectPickerCatalog,
|
|
12
|
+
readTransitionOptionValues,
|
|
13
|
+
registerCatalogPack,
|
|
14
|
+
renderTransitionBases,
|
|
15
|
+
resetCatalogPacks,
|
|
16
|
+
} from "../index.js"
|
|
17
|
+
|
|
18
|
+
afterEach(() => resetCatalogPacks())
|
|
19
|
+
|
|
20
|
+
/** The rows with a Style option, in catalog order. */
|
|
21
|
+
const STYLED_ROWS = [
|
|
22
|
+
"smoke-puff", "sand-storm", "aurora-sweep", "sakura-petals", "garden-bloom", "debris-shower", "white-flash",
|
|
23
|
+
]
|
|
24
|
+
const OPTIONED_ROWS = ["wipe", ...STYLED_ROWS]
|
|
25
|
+
|
|
26
|
+
const BODY = (phrase: string) =>
|
|
27
|
+
`linear wipe (a clean ${phrase}, revealing the second shot behind it)`
|
|
28
|
+
|
|
29
|
+
/** Every wipe direction, as the video prompt reads it — the approved wording. */
|
|
30
|
+
const WIPE_BASES: Readonly<Record<string, string>> = {
|
|
31
|
+
auto: BODY("straight edge sweeps across the frame"),
|
|
32
|
+
"left-to-right": BODY("vertical edge sweeps across the frame from left to right"),
|
|
33
|
+
"right-to-left": BODY("vertical edge sweeps across the frame from right to left"),
|
|
34
|
+
"top-to-bottom": BODY("horizontal edge sweeps down the frame from top to bottom"),
|
|
35
|
+
"bottom-to-top": BODY("horizontal edge sweeps up the frame from bottom to top"),
|
|
36
|
+
"top-left-to-bottom-right": BODY(
|
|
37
|
+
"diagonal edge sweeps across the frame from the top-left corner to the bottom-right corner",
|
|
38
|
+
),
|
|
39
|
+
"top-right-to-bottom-left": BODY(
|
|
40
|
+
"diagonal edge sweeps across the frame from the top-right corner to the bottom-left corner",
|
|
41
|
+
),
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
describe("per-row transition options — the catalog", () => {
|
|
45
|
+
it("the wipe declares the direction; the styled rows share the style field", () => {
|
|
46
|
+
const withOptions = TRANSITIONS.filter((t) => t.options?.length)
|
|
47
|
+
expect(withOptions.map((t) => t.id)).toEqual(["wipe", ...STYLED_ROWS])
|
|
48
|
+
expect(getTransitionOptions("wipe")).toEqual([WIPE_DIRECTION])
|
|
49
|
+
for (const id of STYLED_ROWS) {
|
|
50
|
+
expect(getTransitionOptions(id).map((o) => o.field)).toEqual(["style"])
|
|
51
|
+
}
|
|
52
|
+
expect(TRANSITION_OPTION_FIELDS).toEqual(["wipeDirection", "style"])
|
|
53
|
+
})
|
|
54
|
+
|
|
55
|
+
it("a field is declared at most once per row", () => {
|
|
56
|
+
for (const t of TRANSITIONS) {
|
|
57
|
+
const fields = (t.options ?? []).map((o) => o.field)
|
|
58
|
+
expect(new Set(fields).size).toBe(fields.length)
|
|
59
|
+
}
|
|
60
|
+
})
|
|
61
|
+
|
|
62
|
+
it("the direction's choices: auto first, then the six approved directions", () => {
|
|
63
|
+
expect(WIPE_DIRECTION.choices.map((c) => c.id)).toEqual([
|
|
64
|
+
"auto",
|
|
65
|
+
"left-to-right",
|
|
66
|
+
"right-to-left",
|
|
67
|
+
"top-to-bottom",
|
|
68
|
+
"bottom-to-top",
|
|
69
|
+
"top-left-to-bottom-right",
|
|
70
|
+
"top-right-to-bottom-left",
|
|
71
|
+
])
|
|
72
|
+
expect(WIPE_DIRECTION.choices[0]!.term).toBe("")
|
|
73
|
+
})
|
|
74
|
+
|
|
75
|
+
it("every template carries exactly one token per option and no other token", () => {
|
|
76
|
+
for (const t of TRANSITIONS) {
|
|
77
|
+
if (!t.promptTemplate) {
|
|
78
|
+
expect(t.options ?? []).toEqual([])
|
|
79
|
+
continue
|
|
80
|
+
}
|
|
81
|
+
const tokens = t.promptTemplate.match(/\{[^}]*\}/g) ?? []
|
|
82
|
+
expect(tokens.sort()).toEqual((t.options ?? []).map((o) => `{${o.field}}`).sort())
|
|
83
|
+
}
|
|
84
|
+
})
|
|
85
|
+
|
|
86
|
+
it("no promptHint carries a token — a plain reader always sees a sentence", () => {
|
|
87
|
+
for (const t of TRANSITIONS) expect(t.promptHint).not.toMatch(/[{}]/)
|
|
88
|
+
})
|
|
89
|
+
|
|
90
|
+
it("the wipe's promptHint IS its template on the auto choice", () => {
|
|
91
|
+
expect(getTransitionPromptHint("wipe")).toBe(
|
|
92
|
+
"linear wipe transition: a clean straight edge sweeps across the frame, revealing the second shot behind it",
|
|
93
|
+
)
|
|
94
|
+
expect(getTransitionPromptHint("wipe", { wipeDirection: "auto" })).toBe(
|
|
95
|
+
getTransitionPromptHint("wipe"),
|
|
96
|
+
)
|
|
97
|
+
})
|
|
98
|
+
})
|
|
99
|
+
|
|
100
|
+
describe("per-row transition options — the rendered wipe", () => {
|
|
101
|
+
it.each(Object.entries(WIPE_BASES))("%s", (direction, base) => {
|
|
102
|
+
const values = direction === "auto" ? undefined : { wipeDirection: direction }
|
|
103
|
+
expect(renderTransitionBases(["wipe"], "full", values)).toEqual([base])
|
|
104
|
+
expect(
|
|
105
|
+
composeTransitionHintFromConnections("wipe", [], [], undefined, "full", { optionValues: values }),
|
|
106
|
+
).toBe(base)
|
|
107
|
+
})
|
|
108
|
+
|
|
109
|
+
it("the direction slots in ahead of the timing levers, in both scopes", () => {
|
|
110
|
+
const timing = { position: "middle", duration: "short", intensity: "natural" } as const
|
|
111
|
+
expect(
|
|
112
|
+
composeTransitionHintFromConnections("wipe", [], [], timing, "full", {
|
|
113
|
+
optionValues: { wipeDirection: "top-to-bottom" },
|
|
114
|
+
}),
|
|
115
|
+
).toBe(
|
|
116
|
+
`${WIPE_BASES["top-to-bottom"]}, the transition occurs in the middle of the clip, ` +
|
|
117
|
+
"lasting approximately 1 second, with natural timing",
|
|
118
|
+
)
|
|
119
|
+
expect(
|
|
120
|
+
composeTransitionHintFromConnections("wipe", [], [], timing, "full", {
|
|
121
|
+
scope: "shot",
|
|
122
|
+
optionValues: { wipeDirection: "top-to-bottom" },
|
|
123
|
+
}),
|
|
124
|
+
).toBe(
|
|
125
|
+
`${WIPE_BASES["top-to-bottom"]}, the transition occurs in the middle of this shot, ` +
|
|
126
|
+
"lasting approximately 1 second, with natural timing",
|
|
127
|
+
)
|
|
128
|
+
})
|
|
129
|
+
|
|
130
|
+
it("an unknown direction reads as auto — never a hole in the sentence", () => {
|
|
131
|
+
expect(renderTransitionBases(["wipe"], "full", { wipeDirection: "sideways" })).toEqual([
|
|
132
|
+
WIPE_BASES.auto,
|
|
133
|
+
])
|
|
134
|
+
})
|
|
135
|
+
|
|
136
|
+
it("a row that declares no option ignores the value", () => {
|
|
137
|
+
for (const t of TRANSITIONS) {
|
|
138
|
+
if (t.options?.length) continue
|
|
139
|
+
expect(renderTransitionBases([t.id], "full", { wipeDirection: "left-to-right" })).toEqual(
|
|
140
|
+
renderTransitionBases([t.id]),
|
|
141
|
+
)
|
|
142
|
+
}
|
|
143
|
+
})
|
|
144
|
+
|
|
145
|
+
it("a two-pick writes the direction into the wipe only", () => {
|
|
146
|
+
expect(
|
|
147
|
+
composeTransitionHintFromConnections(["cross-dissolve", "wipe"], [], [], undefined, "full", {
|
|
148
|
+
optionValues: { wipeDirection: "right-to-left" },
|
|
149
|
+
}),
|
|
150
|
+
).toBe(
|
|
151
|
+
`${renderTransitionBases(["cross-dissolve"])[0]}, and ${WIPE_BASES["right-to-left"]}`,
|
|
152
|
+
)
|
|
153
|
+
})
|
|
154
|
+
|
|
155
|
+
it("a catalog pack that REWRITES the wipe keeps its own words", () => {
|
|
156
|
+
const base = getPickerCatalog("transition")!
|
|
157
|
+
registerCatalogPack({
|
|
158
|
+
id: "t-wipe-rewrite",
|
|
159
|
+
catalogId: "transitions",
|
|
160
|
+
mode: "replace",
|
|
161
|
+
catalog: {
|
|
162
|
+
...base,
|
|
163
|
+
options: base.options!.map((o) =>
|
|
164
|
+
o.id === "wipe" ? { ...o, promptHint: "curated wipe text" } : o,
|
|
165
|
+
),
|
|
166
|
+
},
|
|
167
|
+
})
|
|
168
|
+
expect(getTransitionPromptHint("wipe", { wipeDirection: "left-to-right" })).toBe(
|
|
169
|
+
"curated wipe text",
|
|
170
|
+
)
|
|
171
|
+
})
|
|
172
|
+
})
|
|
173
|
+
|
|
174
|
+
describe("per-row transition options — the node data and the wire catalog", () => {
|
|
175
|
+
it("readTransitionOptionValues keeps declared, non-empty string fields only", () => {
|
|
176
|
+
expect(readTransitionOptionValues(undefined)).toBeUndefined()
|
|
177
|
+
expect(readTransitionOptionValues({ transition: "wipe" })).toBeUndefined()
|
|
178
|
+
expect(readTransitionOptionValues({ wipeDirection: "" })).toBeUndefined()
|
|
179
|
+
expect(
|
|
180
|
+
readTransitionOptionValues({ wipeDirection: "left-to-right", direction: "asc", other: 3 }),
|
|
181
|
+
).toEqual({ wipeDirection: "left-to-right" })
|
|
182
|
+
})
|
|
183
|
+
|
|
184
|
+
it("a canvas transition node writes its wipeDirection", () => {
|
|
185
|
+
const node = (data: Record<string, unknown>) => ({ id: "t", type: "transition", data })
|
|
186
|
+
expect(getParameterPromptHint(node({ transition: "wipe", wipeDirection: "bottom-to-top" }))).toBe(
|
|
187
|
+
WIPE_BASES["bottom-to-top"],
|
|
188
|
+
)
|
|
189
|
+
expect(getParameterPromptHint(node({ transition: "wipe" }))).toBe(WIPE_BASES.auto)
|
|
190
|
+
})
|
|
191
|
+
|
|
192
|
+
it("the transition catalog publishes the direction as the wipe option's params", () => {
|
|
193
|
+
const options = getPickerCatalog("transition")!.options!
|
|
194
|
+
const wipe = options.find((o) => o.id === "wipe")!
|
|
195
|
+
expect(wipe.params).toEqual([
|
|
196
|
+
{
|
|
197
|
+
field: "wipeDirection",
|
|
198
|
+
label: "Direction",
|
|
199
|
+
options: WIPE_DIRECTION.choices.map((c) => ({
|
|
200
|
+
id: c.id,
|
|
201
|
+
label: c.label,
|
|
202
|
+
description: c.description,
|
|
203
|
+
promptHint: c.phrase,
|
|
204
|
+
term: c.term,
|
|
205
|
+
})),
|
|
206
|
+
},
|
|
207
|
+
])
|
|
208
|
+
expect(options.filter((o) => o.params).map((o) => o.id)).toEqual(OPTIONED_ROWS)
|
|
209
|
+
})
|
|
210
|
+
})
|
|
211
|
+
|
|
212
|
+
describe("per-row transition options — the wire projection", () => {
|
|
213
|
+
it.each(["compact", "full"] as const)("the %s projection carries the wipe's params", (detail) => {
|
|
214
|
+
const projected = projectPickerCatalog(getPickerCatalog("transition")!, { detail })
|
|
215
|
+
const wipe = projected.options!.find((o) => o.id === "wipe")!
|
|
216
|
+
expect(wipe.params?.map((d) => d.field)).toEqual(["wipeDirection"])
|
|
217
|
+
expect(wipe.params![0]!.options.map((o) => o.id)).toEqual(WIPE_DIRECTION.choices.map((c) => c.id))
|
|
218
|
+
expect(wipe.params![0]!.options[1]!.promptHint).toBe(
|
|
219
|
+
detail === "full" ? "vertical edge sweeps across the frame from left to right" : undefined,
|
|
220
|
+
)
|
|
221
|
+
expect(projected.options!.filter((o) => o.params).map((o) => o.id)).toEqual(OPTIONED_ROWS)
|
|
222
|
+
})
|
|
223
|
+
})
|