@nodaro/prompts 1.8.1 → 1.10.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 +371 -104
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +538 -64
- package/dist/index.d.ts +538 -64
- package/dist/index.js +353 -106
- package/dist/index.js.map +1 -1
- package/package.json +2 -2
- package/src/__tests__/character-fx-timing-catalogs.test.ts +240 -0
- package/src/__tests__/factory-presets.test.ts +62 -12
- package/src/__tests__/node-prompt-fields.test.ts +81 -0
- package/src/__tests__/parameter-registry-sync.test.ts +4 -1
- package/src/__tests__/prompt-affixes.test.ts +83 -0
- package/src/__tests__/transition-timing-catalogs.test.ts +153 -0
- package/src/__tests__/video-reference-ref-id-tokens.test.ts +301 -0
- package/src/character-fx.ts +102 -19
- package/src/composition-effects.ts +6 -1
- package/src/factory-presets/generate-image.ts +37 -24
- package/src/factory-presets/shared-image.ts +10 -10
- package/src/factory-presets/switchx.ts +6 -6
- package/src/factory-presets/video-edit.ts +6 -6
- package/src/index.ts +1 -0
- package/src/node-prompt-fields.ts +206 -0
- package/src/picker-catalogs.ts +66 -17
- package/src/picker-wiring.ts +2 -1
- package/src/provider-prompt-doctrine.ts +2 -2
- package/src/ref-binding.ts +45 -0
- package/src/ref-id-tokens.ts +112 -0
- package/src/resolve-prompt.ts +60 -5
- package/src/transitions.ts +79 -19
- package/src/video-reference-resolver.ts +78 -39
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The transition node's three timing parameters (position / duration /
|
|
3
|
+
* intensity) are enumerable catalogs, not free values, so a consumer that can
|
|
4
|
+
* only send ids — Studio, the SDK, MCP — can offer them without composing any
|
|
5
|
+
* prompt text of its own.
|
|
6
|
+
*
|
|
7
|
+
* Two properties matter, and both are easy to lose silently:
|
|
8
|
+
*
|
|
9
|
+
* 1. The clause the composer INJECTS and the hint the catalog ADVERTISES are
|
|
10
|
+
* the same string. They are derived from one array in `transitions.ts`, and
|
|
11
|
+
* these tests fail the moment someone re-introduces a second copy.
|
|
12
|
+
* 2. The dimensions survive the wire projection. `projectPickerCatalog` used
|
|
13
|
+
* to return `options` only for a `kind: "single"` catalog, which silently
|
|
14
|
+
* dropped them — green unit tests on the in-memory catalog would not have
|
|
15
|
+
* caught it.
|
|
16
|
+
*/
|
|
17
|
+
import { describe, it, expect } from "vitest"
|
|
18
|
+
import {
|
|
19
|
+
TRANSITION_POSITIONS,
|
|
20
|
+
TRANSITION_DURATIONS,
|
|
21
|
+
TRANSITION_INTENSITIES,
|
|
22
|
+
composeTransitionHintFromConnections,
|
|
23
|
+
} from "../transitions.js"
|
|
24
|
+
import { PICKER_CATALOGS, projectPickerCatalog } from "../picker-catalogs.js"
|
|
25
|
+
|
|
26
|
+
const DIMENSIONS = [
|
|
27
|
+
["position", TRANSITION_POSITIONS],
|
|
28
|
+
["duration", TRANSITION_DURATIONS],
|
|
29
|
+
["intensity", TRANSITION_INTENSITIES],
|
|
30
|
+
] as const
|
|
31
|
+
|
|
32
|
+
const transitionCatalog = PICKER_CATALOGS.find((c) => c.nodeType === "transition")!
|
|
33
|
+
|
|
34
|
+
describe("transition timing catalogs", () => {
|
|
35
|
+
// The `TransitionPosition` / `Duration` / `Intensity` unions are DERIVED from
|
|
36
|
+
// these arrays, so nothing at the type level can pin the values themselves.
|
|
37
|
+
// These are the values the editor dropdowns, the node data, and every stored
|
|
38
|
+
// workflow already use: changing one is a data migration, not a rename.
|
|
39
|
+
it("spells exactly the ids every stored workflow already uses", () => {
|
|
40
|
+
expect(TRANSITION_POSITIONS.map((o) => o.id)).toEqual([
|
|
41
|
+
"auto", "start", "middle", "end", "full",
|
|
42
|
+
])
|
|
43
|
+
expect(TRANSITION_DURATIONS.map((o) => o.id)).toEqual([
|
|
44
|
+
"auto", "instant", "short", "medium", "long",
|
|
45
|
+
])
|
|
46
|
+
expect(TRANSITION_INTENSITIES.map((o) => o.id)).toEqual([
|
|
47
|
+
"auto", "subtle", "natural", "dynamic", "crazy",
|
|
48
|
+
])
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
it("carries the timing precision users read off the dropdown", () => {
|
|
52
|
+
// The editor used to hand-write these labels and had already drifted from
|
|
53
|
+
// the catalog on all three rows. It now renders straight from here, so this
|
|
54
|
+
// is the one place the numbers live.
|
|
55
|
+
expect(TRANSITION_DURATIONS.map((o) => o.label)).toEqual([
|
|
56
|
+
"Auto", "Instant", "Short (~1s)", "Medium (~2s)", "Long (~3s)",
|
|
57
|
+
])
|
|
58
|
+
})
|
|
59
|
+
|
|
60
|
+
it("gives every injecting step a compact term, so compact mode never drops it", () => {
|
|
61
|
+
// `catalog-terms.test.ts` enforces this globally; pinned here too because
|
|
62
|
+
// the platform composes timing from `promptHint` in BOTH hint modes, so a
|
|
63
|
+
// broken `term` would only ever surface on an external compact client.
|
|
64
|
+
for (const [field, options] of DIMENSIONS) {
|
|
65
|
+
for (const o of options.slice(1)) {
|
|
66
|
+
expect(o.term, `${field}/${o.id} needs a compact term`).toBeTruthy()
|
|
67
|
+
}
|
|
68
|
+
expect(options[0]!.term, `${field}/auto injects nothing`).toBe("")
|
|
69
|
+
}
|
|
70
|
+
})
|
|
71
|
+
|
|
72
|
+
it("derives a clause for every non-auto step, so the composer can't index a hole", () => {
|
|
73
|
+
// The regression this guards: a step present in the catalog but missing
|
|
74
|
+
// from the clause table made `parts.push(undefined)` render as a dangling
|
|
75
|
+
// ", " on a prompt that shipped to the provider.
|
|
76
|
+
for (const [field, options] of DIMENSIONS) {
|
|
77
|
+
for (const o of options.slice(1)) {
|
|
78
|
+
const composed = composeTransitionHintFromConnections("cross-dissolve", [], [], {
|
|
79
|
+
[field]: o.id,
|
|
80
|
+
} as never)
|
|
81
|
+
expect(composed, `${field}/${o.id} composed a dangling separator`).not.toMatch(/,\s*$/)
|
|
82
|
+
expect(composed, `${field}/${o.id}`).not.toContain("undefined")
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
})
|
|
86
|
+
|
|
87
|
+
it("every scale leads with a no-op `auto` that injects nothing", () => {
|
|
88
|
+
for (const [field, options] of DIMENSIONS) {
|
|
89
|
+
expect(options[0]!.id, `${field} must lead with auto`).toBe("auto")
|
|
90
|
+
expect(options[0]!.promptHint, `${field}/auto must inject nothing`).toBe("")
|
|
91
|
+
// Every other step must actually carry a clause.
|
|
92
|
+
for (const o of options.slice(1)) {
|
|
93
|
+
expect(o.promptHint, `${field}/${o.id} needs a promptHint`).not.toBe("")
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
})
|
|
97
|
+
|
|
98
|
+
it("the composed clause is the catalog's own promptHint, verbatim", () => {
|
|
99
|
+
// One source of truth: if the composer ever grows a private copy of these
|
|
100
|
+
// strings, the composed sentence stops containing the advertised hint.
|
|
101
|
+
for (const o of TRANSITION_POSITIONS.slice(1)) {
|
|
102
|
+
const composed = composeTransitionHintFromConnections("cross-dissolve", [], [], { position: o.id as never })
|
|
103
|
+
expect(composed, `position/${o.id}`).toContain(o.promptHint)
|
|
104
|
+
}
|
|
105
|
+
for (const o of TRANSITION_DURATIONS.slice(1)) {
|
|
106
|
+
const composed = composeTransitionHintFromConnections("cross-dissolve", [], [], { duration: o.id as never })
|
|
107
|
+
expect(composed, `duration/${o.id}`).toContain(o.promptHint)
|
|
108
|
+
}
|
|
109
|
+
for (const o of TRANSITION_INTENSITIES.slice(1)) {
|
|
110
|
+
const composed = composeTransitionHintFromConnections("cross-dissolve", [], [], { intensity: o.id as never })
|
|
111
|
+
expect(composed, `intensity/${o.id}`).toContain(o.promptHint)
|
|
112
|
+
}
|
|
113
|
+
})
|
|
114
|
+
|
|
115
|
+
it("`auto` adds nothing to the composed sentence", () => {
|
|
116
|
+
const bare = composeTransitionHintFromConnections("cross-dissolve", [], [])
|
|
117
|
+
const allAuto = composeTransitionHintFromConnections("cross-dissolve", [], [], { position: "auto", duration: "auto", intensity: "auto" })
|
|
118
|
+
expect(allAuto).toBe(bare)
|
|
119
|
+
})
|
|
120
|
+
|
|
121
|
+
it("exposes the three dimensions on the transition catalog", () => {
|
|
122
|
+
expect(transitionCatalog.kind).toBe("single")
|
|
123
|
+
expect(transitionCatalog.dimensions?.map((d) => d.field)).toEqual([
|
|
124
|
+
"position",
|
|
125
|
+
"duration",
|
|
126
|
+
"intensity",
|
|
127
|
+
])
|
|
128
|
+
})
|
|
129
|
+
|
|
130
|
+
it("keeps the dimensions through the wire projection", () => {
|
|
131
|
+
const wire = projectPickerCatalog(transitionCatalog, { detail: "full" })
|
|
132
|
+
|
|
133
|
+
// The 82-option transition list is untouched by the addition.
|
|
134
|
+
expect(wire.options?.length).toBe(transitionCatalog.options?.length)
|
|
135
|
+
|
|
136
|
+
expect(wire.dimensions).toHaveLength(3)
|
|
137
|
+
for (const [field, options] of DIMENSIONS) {
|
|
138
|
+
const dim = wire.dimensions?.find((d) => d.field === field)
|
|
139
|
+
expect(dim, `${field} must reach the wire`).toBeDefined()
|
|
140
|
+
expect(dim!.options.map((o) => o.id)).toEqual(options.map((o) => o.id))
|
|
141
|
+
expect(dim!.options.map((o) => o.promptHint)).toEqual(options.map((o) => o.promptHint))
|
|
142
|
+
}
|
|
143
|
+
})
|
|
144
|
+
|
|
145
|
+
it("leaves every other single-dim catalog without dimensions", () => {
|
|
146
|
+
// `character-fx` carries the same three timing fields with its own,
|
|
147
|
+
// deliberately different wording (see `character-fx-timing-catalogs.test.ts`).
|
|
148
|
+
const singlesWithDims = PICKER_CATALOGS.filter(
|
|
149
|
+
(c) => c.kind === "single" && c.dimensions,
|
|
150
|
+
).map((c) => c.nodeType)
|
|
151
|
+
expect(singlesWithDims).toEqual(["transition", "character-fx"])
|
|
152
|
+
})
|
|
153
|
+
})
|
|
@@ -0,0 +1,301 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `{ref:<id>}` / `{ref:<id>:<label>}` — id-addressed reference tokens.
|
|
3
|
+
*
|
|
4
|
+
* A client that names a reference by the `connectedReferences[].id` it sent
|
|
5
|
+
* (Studio's bound `@`-chips) gets the `@image_N` slot substituted by the
|
|
6
|
+
* platform AFTER the platform has numbered the references. That removes the
|
|
7
|
+
* client-side mirror of the numbering walk — the one duplicated rule that could
|
|
8
|
+
* silently misbind pictures for a client built against an older package.
|
|
9
|
+
*
|
|
10
|
+
* Contract pinned here:
|
|
11
|
+
* - the slot comes from the SAME walk that numbers the directives (mention
|
|
12
|
+
* URLs → canonical fallback → extras, offset by the leading flat refs);
|
|
13
|
+
* - the token is resolved BEFORE the `referenceOrder` reorder, so the
|
|
14
|
+
* renumber pass carries the binding to the ref's final seat — the opposite
|
|
15
|
+
* of `{image:N}`, which is resolved AFTER it to keep the author's N;
|
|
16
|
+
* - an unresolvable token never ships raw: label → the ref's display name
|
|
17
|
+
* (when the id is known) → "";
|
|
18
|
+
* - a prompt with no `{ref:` token is byte-identical to before.
|
|
19
|
+
*/
|
|
20
|
+
import { describe, it, expect } from "vitest"
|
|
21
|
+
import {
|
|
22
|
+
resolveVideoReferenceCore,
|
|
23
|
+
resolveRefIdTokens,
|
|
24
|
+
resolveReferenceTokens,
|
|
25
|
+
} from "../video-reference-resolver.js"
|
|
26
|
+
import type { ConnectedReference } from "@nodaro/shared"
|
|
27
|
+
|
|
28
|
+
const charRef = (over: Partial<ConnectedReference> = {}): ConnectedReference => ({
|
|
29
|
+
id: "char-kira", defaultName: "Kira", source: "wired-character", url: "https://r2/kira.png",
|
|
30
|
+
characterSlug: "kira", variantSlug: undefined, characterCanonicalDescription: null,
|
|
31
|
+
variantDescription: null, variantDisplayName: "canonical", ...over,
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
const A = "https://cdn/a.png"
|
|
35
|
+
const B = "https://cdn/b.png"
|
|
36
|
+
|
|
37
|
+
/** Every case: the token must be gone, whatever it resolved to. */
|
|
38
|
+
function expectNoRawToken(prompt: string | undefined) {
|
|
39
|
+
expect(prompt ?? "").not.toMatch(/\{ref:/i)
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
describe("resolveVideoReferenceCore — {ref:<id>} id-addressed tokens", () => {
|
|
43
|
+
it("bare {ref:<id>} binds an image extra to its @image_N slot", () => {
|
|
44
|
+
const out = resolveVideoReferenceCore({
|
|
45
|
+
prompt: "drive {ref:car-1} fast",
|
|
46
|
+
wiredCharRefs: [],
|
|
47
|
+
extraRefs: [{ id: "car-1", url: "https://r2/car.png", description: "a red car" }],
|
|
48
|
+
})
|
|
49
|
+
expect(out.additionalUrls).toEqual(["https://r2/car.png"])
|
|
50
|
+
expect(out.prompt).toContain("- @image_1 (reference): a red car.")
|
|
51
|
+
expect(out.prompt).toContain("drive @image_1 fast")
|
|
52
|
+
expectNoRawToken(out.prompt)
|
|
53
|
+
})
|
|
54
|
+
|
|
55
|
+
it("labeled {ref:<id>:<label>} binds through REF_BINDING.image (parity with {image:N:label})", () => {
|
|
56
|
+
const out = resolveVideoReferenceCore({
|
|
57
|
+
prompt: "drive {ref:car-1:car} fast",
|
|
58
|
+
wiredCharRefs: [],
|
|
59
|
+
extraRefs: [{ id: "car-1", url: "https://r2/car.png", description: "a red car" }],
|
|
60
|
+
})
|
|
61
|
+
expect(out.prompt).toContain("drive the car from @image_1 fast")
|
|
62
|
+
expectNoRawToken(out.prompt)
|
|
63
|
+
})
|
|
64
|
+
|
|
65
|
+
it("a canonical wired character binds to its canonical-fallback slot", () => {
|
|
66
|
+
const out = resolveVideoReferenceCore({
|
|
67
|
+
prompt: "{ref:char-kira} walks in",
|
|
68
|
+
wiredCharRefs: [charRef()],
|
|
69
|
+
})
|
|
70
|
+
expect(out.additionalUrls).toEqual(["https://r2/kira.png"])
|
|
71
|
+
expect(out.prompt).toContain("Use these characters:")
|
|
72
|
+
expect(out.prompt).toContain("@image_1 walks in")
|
|
73
|
+
expectNoRawToken(out.prompt)
|
|
74
|
+
})
|
|
75
|
+
|
|
76
|
+
it("a character VIEW (extra with characterSlug) binds to its pair-back slot after the canonical", () => {
|
|
77
|
+
const out = resolveVideoReferenceCore({
|
|
78
|
+
prompt: "{ref:view-1} turns to face {ref:char-kira}",
|
|
79
|
+
wiredCharRefs: [charRef()],
|
|
80
|
+
extraRefs: [{ id: "view-1", url: "https://r2/kira-side.png", characterSlug: "kira", description: "side profile" }],
|
|
81
|
+
})
|
|
82
|
+
expect(out.additionalUrls).toEqual(["https://r2/kira.png", "https://r2/kira-side.png"])
|
|
83
|
+
expect(out.prompt).toContain("- @image_2 is the same subject as @image_1, side profile.")
|
|
84
|
+
expect(out.prompt).toContain("@image_2 turns to face @image_1")
|
|
85
|
+
expectNoRawToken(out.prompt)
|
|
86
|
+
})
|
|
87
|
+
|
|
88
|
+
it("leading flat refs offset the slot (D5 image-refs-first)", () => {
|
|
89
|
+
const out = resolveVideoReferenceCore({
|
|
90
|
+
prompt: "the {ref:obj} on the table",
|
|
91
|
+
wiredCharRefs: [],
|
|
92
|
+
leadingRefUrls: [A],
|
|
93
|
+
extraRefs: [{ id: "obj", url: B, description: "object" }],
|
|
94
|
+
})
|
|
95
|
+
expect(out.additionalUrls).toEqual([A, B])
|
|
96
|
+
expect(out.prompt).toContain("the @image_2 on the table")
|
|
97
|
+
expectNoRawToken(out.prompt)
|
|
98
|
+
})
|
|
99
|
+
|
|
100
|
+
it("ids are opaque: `:` and `/` inside an id resolve, and a trailing label still parses", () => {
|
|
101
|
+
const out = resolveVideoReferenceCore({
|
|
102
|
+
prompt: "{ref:https://cdn/pic.png} beside {ref:kira:smile:smile}",
|
|
103
|
+
wiredCharRefs: [],
|
|
104
|
+
extraRefs: [
|
|
105
|
+
{ id: "https://cdn/pic.png", url: "https://cdn/pic.png", description: "pic" },
|
|
106
|
+
{ id: "kira:smile", url: "https://r2/kira-smile.png", description: "smile" },
|
|
107
|
+
],
|
|
108
|
+
})
|
|
109
|
+
expect(out.prompt).toContain("@image_1 beside the smile from @image_2")
|
|
110
|
+
expectNoRawToken(out.prompt)
|
|
111
|
+
})
|
|
112
|
+
|
|
113
|
+
it("an unknown id degrades to its label, or to nothing — never the raw token", () => {
|
|
114
|
+
const out = resolveVideoReferenceCore({
|
|
115
|
+
prompt: "a {ref:nope:ghost} b {ref:nope2} c",
|
|
116
|
+
wiredCharRefs: [],
|
|
117
|
+
extraRefs: [{ id: "x", url: A, description: "d" }],
|
|
118
|
+
})
|
|
119
|
+
expect(out.prompt).toContain("a ghost b c")
|
|
120
|
+
expectNoRawToken(out.prompt)
|
|
121
|
+
})
|
|
122
|
+
|
|
123
|
+
it("a known ref the walk never seated degrades to its display name", () => {
|
|
124
|
+
// A wired character with no url is skipped by the canonical loop — the id
|
|
125
|
+
// is known (so the name is), but there is no slot to bind.
|
|
126
|
+
const out = resolveVideoReferenceCore({
|
|
127
|
+
prompt: "{ref:char-kira} waves at {ref:capped-1}",
|
|
128
|
+
wiredCharRefs: [charRef({ url: "" })],
|
|
129
|
+
extraRefs: [{ id: "x", url: A, description: "d" }],
|
|
130
|
+
// The caller's full id → name map (the route builds it from EVERY
|
|
131
|
+
// connectedReference, including the ones it capped out before the walk).
|
|
132
|
+
refNamesById: new Map([["capped-1", "Truck"]]),
|
|
133
|
+
})
|
|
134
|
+
expect(out.prompt).toContain("Kira waves at Truck")
|
|
135
|
+
expectNoRawToken(out.prompt)
|
|
136
|
+
})
|
|
137
|
+
|
|
138
|
+
it("a duplicate-URL extra never binds past the payload: its {ref:} degrades to its name", () => {
|
|
139
|
+
// The walk counts every extra with a url while `merged` dedups by URL, so
|
|
140
|
+
// the second extra's directive is numbered @image_2 although the payload
|
|
141
|
+
// carries ONE image (pre-existing walk-vs-merged drift). The token is
|
|
142
|
+
// range-gated against the image count, so it degrades instead of emitting
|
|
143
|
+
// a phantom binding.
|
|
144
|
+
const out = resolveVideoReferenceCore({
|
|
145
|
+
prompt: "{ref:x} then {ref:y}",
|
|
146
|
+
wiredCharRefs: [],
|
|
147
|
+
extraRefs: [
|
|
148
|
+
{ id: "x", url: A, description: "first" },
|
|
149
|
+
{ id: "y", url: A, description: "second" },
|
|
150
|
+
],
|
|
151
|
+
refNamesById: new Map([["y", "Second"]]),
|
|
152
|
+
})
|
|
153
|
+
expect(out.additionalUrls).toEqual([A])
|
|
154
|
+
expect(out.prompt).toContain("@image_1 then Second")
|
|
155
|
+
expectNoRawToken(out.prompt)
|
|
156
|
+
})
|
|
157
|
+
|
|
158
|
+
it("resolves BEFORE the referenceOrder reorder, so the binding follows the ref to its final seat", () => {
|
|
159
|
+
const out = resolveVideoReferenceCore({
|
|
160
|
+
prompt: "{ref:y} leads, {ref:x} follows",
|
|
161
|
+
wiredCharRefs: [],
|
|
162
|
+
extraRefs: [
|
|
163
|
+
{ id: "x", url: A, description: "ax" },
|
|
164
|
+
{ id: "y", url: B, description: "by" },
|
|
165
|
+
],
|
|
166
|
+
// Extras' tile ids are `wired:<url>` (the reorder contract, unchanged).
|
|
167
|
+
referenceOrder: [`wired:${B}`, `wired:${A}`],
|
|
168
|
+
})
|
|
169
|
+
expect(out.additionalUrls).toEqual([B, A])
|
|
170
|
+
expect(out.prompt).toContain("@image_1 leads, @image_2 follows")
|
|
171
|
+
expect(out.prompt).toContain("- @image_1 (reference): by.")
|
|
172
|
+
expect(out.prompt).toContain("- @image_2 (reference): ax.")
|
|
173
|
+
expectNoRawToken(out.prompt)
|
|
174
|
+
})
|
|
175
|
+
|
|
176
|
+
it("keeps {image:N} resolved AFTER the reorder (author's N kept) while {ref:} follows the ref", () => {
|
|
177
|
+
const out = resolveVideoReferenceCore({
|
|
178
|
+
prompt: "{ref:y} and {image:2:second}",
|
|
179
|
+
wiredCharRefs: [],
|
|
180
|
+
extraRefs: [
|
|
181
|
+
{ id: "x", url: A, description: "ax" },
|
|
182
|
+
{ id: "y", url: B, description: "by" },
|
|
183
|
+
],
|
|
184
|
+
referenceOrder: [`wired:${B}`, `wired:${A}`],
|
|
185
|
+
})
|
|
186
|
+
// y moved to seat 1 → {ref:y} rides along; {image:2} keeps the literal 2.
|
|
187
|
+
expect(out.prompt).toContain("@image_1 and the second from @image_2")
|
|
188
|
+
expectNoRawToken(out.prompt)
|
|
189
|
+
})
|
|
190
|
+
|
|
191
|
+
it("an @-mentioned character's ref binds to the mention's slot", () => {
|
|
192
|
+
const out = resolveVideoReferenceCore({
|
|
193
|
+
prompt: "@kira:1 waves, then {ref:char-kira} sits",
|
|
194
|
+
wiredCharRefs: [charRef()],
|
|
195
|
+
})
|
|
196
|
+
expect(out.additionalUrls).toEqual(["https://r2/kira.png"])
|
|
197
|
+
expect(out.prompt).toContain("Kira waves, then @image_1 sits")
|
|
198
|
+
expectNoRawToken(out.prompt)
|
|
199
|
+
})
|
|
200
|
+
|
|
201
|
+
it("is independent of hybridRoles — same slot, no legend block", () => {
|
|
202
|
+
const out = resolveVideoReferenceCore({
|
|
203
|
+
prompt: "drive {ref:car-1} fast",
|
|
204
|
+
wiredCharRefs: [],
|
|
205
|
+
extraRefs: [{ id: "car-1", url: "https://r2/car.png", description: "a red car" }],
|
|
206
|
+
hybridRoles: true,
|
|
207
|
+
})
|
|
208
|
+
expect(out.prompt).not.toContain("Use these characters:")
|
|
209
|
+
expect(out.prompt).toContain("drive @image_1 fast")
|
|
210
|
+
expectNoRawToken(out.prompt)
|
|
211
|
+
})
|
|
212
|
+
|
|
213
|
+
it("a prompt with no {ref: token is untouched — `{ref}` and `ref:` are not tokens", () => {
|
|
214
|
+
const out = resolveVideoReferenceCore({
|
|
215
|
+
prompt: "circle {image:1:object} {ref} ref: x",
|
|
216
|
+
wiredCharRefs: [],
|
|
217
|
+
extraRefs: [{ id: "car-1", url: "https://r2/car.png", description: "a red car" }],
|
|
218
|
+
})
|
|
219
|
+
expect(out.prompt).toContain("circle the object from @image_1 {ref} ref: x")
|
|
220
|
+
})
|
|
221
|
+
|
|
222
|
+
it("an empty id drops to nothing and the keyword is case-insensitive", () => {
|
|
223
|
+
const out = resolveVideoReferenceCore({
|
|
224
|
+
prompt: "x {ref:} {REF:car-1} y",
|
|
225
|
+
wiredCharRefs: [],
|
|
226
|
+
extraRefs: [{ id: "car-1", url: "https://r2/car.png", description: "a red car" }],
|
|
227
|
+
})
|
|
228
|
+
expect(out.prompt).toContain("x @image_1 y")
|
|
229
|
+
expectNoRawToken(out.prompt)
|
|
230
|
+
})
|
|
231
|
+
|
|
232
|
+
it("imageRefCount: 0 (no image tokens may bind) degrades a seated ref to its name", () => {
|
|
233
|
+
const out = resolveVideoReferenceCore({
|
|
234
|
+
prompt: "{ref:char-kira} walks",
|
|
235
|
+
wiredCharRefs: [charRef()],
|
|
236
|
+
imageRefCount: 0,
|
|
237
|
+
})
|
|
238
|
+
expect(out.prompt).toContain("Kira walks")
|
|
239
|
+
expect(out.prompt).not.toContain("@image_1 walks")
|
|
240
|
+
expectNoRawToken(out.prompt)
|
|
241
|
+
})
|
|
242
|
+
|
|
243
|
+
it("early-return path (no wired chars, no extras): degrades to name / label / nothing", () => {
|
|
244
|
+
const out = resolveVideoReferenceCore({
|
|
245
|
+
prompt: "{ref:a} and {ref:b:the dog} and {ref:c}",
|
|
246
|
+
wiredCharRefs: [],
|
|
247
|
+
leadingRefUrls: [A],
|
|
248
|
+
refNamesById: new Map([["a", "Alpha"]]),
|
|
249
|
+
})
|
|
250
|
+
expect(out.additionalUrls).toEqual([A])
|
|
251
|
+
expect(out.prompt).toBe("Alpha and the dog and")
|
|
252
|
+
expectNoRawToken(out.prompt)
|
|
253
|
+
})
|
|
254
|
+
})
|
|
255
|
+
|
|
256
|
+
describe("resolveRefIdTokens — malformed and adversarial input", () => {
|
|
257
|
+
it("a malformed token (brace inside the id, no closing brace) never ships its `{ref:` prefix", () => {
|
|
258
|
+
const out = resolveRefIdTokens("x {ref:a{b} y {ref:unclosed z", {
|
|
259
|
+
slotById: new Map([["a", 1]]),
|
|
260
|
+
nameById: new Map(),
|
|
261
|
+
imageCount: 1,
|
|
262
|
+
})
|
|
263
|
+
expect(out).not.toMatch(/\{ref:/i)
|
|
264
|
+
// The net is bounded by whitespace/braces: the prose after each run survives.
|
|
265
|
+
expect(out).toContain(" y ")
|
|
266
|
+
expect(out).toContain(" z")
|
|
267
|
+
})
|
|
268
|
+
|
|
269
|
+
it("scans a prompt at the hard ceiling with an adversarial shape and still resolves (linear matcher)", () => {
|
|
270
|
+
// 30k chars of `{ref:` followed by label-class text with no closing brace —
|
|
271
|
+
// the shape that made a lazy-quantifier matcher quadratic.
|
|
272
|
+
const adversarial = "{ref:" + ":a".repeat(15000)
|
|
273
|
+
const out = resolveRefIdTokens(`${adversarial} end {ref:x}`, {
|
|
274
|
+
slotById: new Map([["x", 1]]),
|
|
275
|
+
nameById: new Map(),
|
|
276
|
+
imageCount: 1,
|
|
277
|
+
})
|
|
278
|
+
expect(out).not.toMatch(/\{ref:/i)
|
|
279
|
+
expect(out).toContain("end @image_1")
|
|
280
|
+
})
|
|
281
|
+
})
|
|
282
|
+
|
|
283
|
+
describe("resolveRefIdTokens (standalone — the route's no-image-ref early return)", () => {
|
|
284
|
+
it("binds in-range slots, and degrades label → name → nothing otherwise", () => {
|
|
285
|
+
const resolved = resolveRefIdTokens("{ref:x:car} {ref:x} {ref:y:dog} {ref:y} {ref:z}", {
|
|
286
|
+
slotById: new Map([["x", 2], ["y", 4]]),
|
|
287
|
+
nameById: new Map([["y", "Dog"]]),
|
|
288
|
+
imageCount: 3,
|
|
289
|
+
})
|
|
290
|
+
// y is seated at 4 but only 3 images ship → name; z is unknown → nothing.
|
|
291
|
+
expect(resolveReferenceTokens(resolved, { image: 3, video: 0, audio: 0 })).toBe(
|
|
292
|
+
"the car from @image_2 @image_2 dog Dog",
|
|
293
|
+
)
|
|
294
|
+
})
|
|
295
|
+
|
|
296
|
+
it("returns the prompt untouched when no {ref: token is present", () => {
|
|
297
|
+
const prompt = "plain {image:1} prose"
|
|
298
|
+
expect(resolveRefIdTokens(prompt, { slotById: new Map(), nameById: new Map(), imageCount: 0 })).toBe(prompt)
|
|
299
|
+
expect(resolveRefIdTokens(undefined, { slotById: new Map(), nameById: new Map(), imageCount: 0 })).toBeUndefined()
|
|
300
|
+
})
|
|
301
|
+
})
|
package/src/character-fx.ts
CHANGED
|
@@ -37,9 +37,23 @@ export interface CharacterFx {
|
|
|
37
37
|
readonly term?: string
|
|
38
38
|
}
|
|
39
39
|
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
40
|
+
/**
|
|
41
|
+
* The three timing scales, each derived from the catalog that defines it (see
|
|
42
|
+
* `CHARACTER_FX_POSITIONS` and friends below).
|
|
43
|
+
*
|
|
44
|
+
* The direction matters. These used to be hand-written unions with the clause
|
|
45
|
+
* tables written out separately beside them, so the two could disagree: add a
|
|
46
|
+
* step to the union, forget the clause, and the composer indexed a missing key
|
|
47
|
+
* — pushing `undefined` into the parts list, which `join(", ")` renders as a
|
|
48
|
+
* dangling separator on a prompt that then ships to a provider with the user's
|
|
49
|
+
* chosen parameter silently dropped. Deriving the union FROM the catalog makes
|
|
50
|
+
* that unrepresentable: one array is the source of the type, the option list
|
|
51
|
+
* the API serves, and the clause table, so a new step reaches all three or
|
|
52
|
+
* none. The exact id sets are pinned by `character-fx-timing-catalogs.test.ts`.
|
|
53
|
+
*/
|
|
54
|
+
export type CharacterFxPosition = (typeof CHARACTER_FX_POSITIONS)[number]["id"]
|
|
55
|
+
export type CharacterFxDuration = (typeof CHARACTER_FX_DURATIONS)[number]["id"]
|
|
56
|
+
export type CharacterFxIntensity = (typeof CHARACTER_FX_INTENSITIES)[number]["id"]
|
|
43
57
|
|
|
44
58
|
export interface CharacterFxTiming {
|
|
45
59
|
position?: CharacterFxPosition
|
|
@@ -266,27 +280,96 @@ export const CHARACTER_FX_IDS: ReadonlyArray<string> = CHARACTER_FX.map((c) => c
|
|
|
266
280
|
// Graph-aware composer — target input handle + timing fields + multi-pick
|
|
267
281
|
// ---------------------------------------------------------------------------
|
|
268
282
|
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
283
|
+
/**
|
|
284
|
+
* The character-fx node's three timing parameters, as catalogs.
|
|
285
|
+
*
|
|
286
|
+
* Graded scales in the standard option shape, so a consumer that can only send
|
|
287
|
+
* ids (Studio, the SDK, MCP) can offer Position / Duration / Intensity without
|
|
288
|
+
* composing prompt text of its own. `auto` is the no-op head of each scale: an
|
|
289
|
+
* empty `promptHint`, so an unset parameter contributes nothing and the model
|
|
290
|
+
* is left to decide, exactly as before these were enumerable.
|
|
291
|
+
*
|
|
292
|
+
* These are NOT the transition node's scales, even though the ids match. The
|
|
293
|
+
* wording is deliberately different — a transition OCCURS and SPANS the clip,
|
|
294
|
+
* an effect MANIFESTS and PERSISTS — and the three intensity clauses coincide
|
|
295
|
+
* by accident, not by shared definition. Keep the two catalogs separate; do
|
|
296
|
+
* not fold one into the other.
|
|
297
|
+
*
|
|
298
|
+
* `POSITION_CLAUSES` / `DURATION_CLAUSES` / `INTENSITY_CLAUSES` below are
|
|
299
|
+
* DERIVED from these arrays, so the clause the composer injects and the hint
|
|
300
|
+
* the catalog advertises are the same string by construction and cannot drift.
|
|
301
|
+
*/
|
|
302
|
+
export interface CharacterFxTimingOption {
|
|
303
|
+
readonly id: string
|
|
304
|
+
readonly label: string
|
|
305
|
+
readonly description: string
|
|
306
|
+
readonly promptHint: string
|
|
307
|
+
readonly term?: string
|
|
274
308
|
}
|
|
275
309
|
|
|
276
|
-
const
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
}
|
|
310
|
+
export const CHARACTER_FX_POSITIONS = [
|
|
311
|
+
{ id: "auto", label: "Auto", description: "Let the model place the effect", promptHint: "", term: "" },
|
|
312
|
+
{ id: "start", label: "Start", description: "Occurs at the opening of the clip", promptHint: "the effect occurs at the opening of the clip", term: "at the opening of the clip" },
|
|
313
|
+
{ id: "middle", label: "Middle", description: "Occurs in the middle of the clip", promptHint: "the effect occurs in the middle of the clip", term: "mid-clip" },
|
|
314
|
+
{ id: "end", label: "End", description: "Occurs at the end of the clip", promptHint: "the effect occurs at the end of the clip", term: "at the end of the clip" },
|
|
315
|
+
{ id: "full", label: "Full", description: "Persists for the entire clip", promptHint: "the effect persists for the entire clip", term: "persisting for the whole clip" },
|
|
316
|
+
] as const satisfies ReadonlyArray<CharacterFxTimingOption>
|
|
317
|
+
|
|
318
|
+
export const CHARACTER_FX_DURATIONS = [
|
|
319
|
+
{ id: "auto", label: "Auto", description: "Let the model time the effect", promptHint: "", term: "" },
|
|
320
|
+
{ id: "instant", label: "Instant", description: "Manifests instantaneously", promptHint: "manifesting instantaneously", term: "manifesting instantly" },
|
|
321
|
+
{ id: "short", label: "Short (~1s)", description: "Manifests over approximately 1 second", promptHint: "manifesting over approximately 1 second", term: "manifesting over about 1 second" },
|
|
322
|
+
{ id: "medium", label: "Medium (~2s)", description: "Manifests over approximately 2 seconds", promptHint: "manifesting over approximately 2 seconds", term: "manifesting over about 2 seconds" },
|
|
323
|
+
{ id: "long", label: "Long (~3s)", description: "Manifests over approximately 3 seconds", promptHint: "manifesting over approximately 3 seconds", term: "manifesting over about 3 seconds" },
|
|
324
|
+
] as const satisfies ReadonlyArray<CharacterFxTimingOption>
|
|
282
325
|
|
|
283
|
-
const
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
326
|
+
export const CHARACTER_FX_INTENSITIES = [
|
|
327
|
+
{ id: "auto", label: "Auto", description: "Let the model judge the effect's energy", promptHint: "", term: "" },
|
|
328
|
+
{ id: "subtle", label: "Subtle", description: "Restrained, minimal flourish", promptHint: "with subtle restrained energy and minimal flourish", term: "subtly" },
|
|
329
|
+
{ id: "natural", label: "Natural", description: "Unhurried, unforced timing", promptHint: "with natural unhurried timing", term: "at a natural pace" },
|
|
330
|
+
{ id: "dynamic", label: "Dynamic", description: "Assertive, energetic", promptHint: "with dynamic energy and assertive flourish", term: "energetically" },
|
|
331
|
+
{ id: "crazy", label: "Crazy", description: "Extreme, wild, distorted", promptHint: "with extreme exaggerated energy, wild flourishes, and dramatic distortion", term: "wildly exaggerated" },
|
|
332
|
+
] as const satisfies ReadonlyArray<CharacterFxTimingOption>
|
|
333
|
+
|
|
334
|
+
/**
|
|
335
|
+
* Index a timing catalog into the `Record<value, clause>` the composer reads.
|
|
336
|
+
*
|
|
337
|
+
* The key type is derived from the SAME array, so the record is total over the
|
|
338
|
+
* catalog by construction. That matters: the composer indexes these records
|
|
339
|
+
* without a fallback, and a missing key would push `undefined` into the parts
|
|
340
|
+
* list, which `join(", ")` renders as a dangling separator — a malformed prompt
|
|
341
|
+
* shipped to a provider with the user's chosen parameter silently dropped.
|
|
342
|
+
*
|
|
343
|
+
* Deliberately a private twin of the helper in `transitions.ts` rather than a
|
|
344
|
+
* shared import: the two nodes' timing catalogs must stay independent.
|
|
345
|
+
*/
|
|
346
|
+
function clausesOf<T extends CharacterFxTimingOption>(
|
|
347
|
+
options: ReadonlyArray<T>,
|
|
348
|
+
): Record<Exclude<T["id"], "auto">, string> {
|
|
349
|
+
return Object.fromEntries(
|
|
350
|
+
options.filter((o) => o.id !== "auto").map((o) => [o.id, o.promptHint]),
|
|
351
|
+
) as Record<Exclude<T["id"], "auto">, string>
|
|
288
352
|
}
|
|
289
353
|
|
|
354
|
+
const POSITION_CLAUSES = clausesOf(CHARACTER_FX_POSITIONS)
|
|
355
|
+
const DURATION_CLAUSES = clausesOf(CHARACTER_FX_DURATIONS)
|
|
356
|
+
const INTENSITY_CLAUSES = clausesOf(CHARACTER_FX_INTENSITIES)
|
|
357
|
+
|
|
358
|
+
/**
|
|
359
|
+
* Widening guard. `as const` on the three arrays above is load-bearing: drop
|
|
360
|
+
* it and every id widens to `string`, the derived unions stop constraining
|
|
361
|
+
* anything, and the clause records degrade to `Record<string, string>` — the
|
|
362
|
+
* totality guarantee is gone with no runtime symptom. This assignment stops
|
|
363
|
+
* compiling the moment that happens (tsup's DTS build runs the type checker).
|
|
364
|
+
*/
|
|
365
|
+
type NarrowIds<T> = string extends T ? never : true
|
|
366
|
+
const _timingIdsStayNarrow: [
|
|
367
|
+
NarrowIds<CharacterFxPosition>,
|
|
368
|
+
NarrowIds<CharacterFxDuration>,
|
|
369
|
+
NarrowIds<CharacterFxIntensity>,
|
|
370
|
+
] = [true, true, true]
|
|
371
|
+
void _timingIdsStayNarrow
|
|
372
|
+
|
|
290
373
|
/**
|
|
291
374
|
* Compose a character-fx prompt-hint sentence from an effect id (or array
|
|
292
375
|
* of 1-2 ids for multi-pick) plus target-ref display names (from upstream
|
|
@@ -32,6 +32,12 @@ export interface CompositionEffect {
|
|
|
32
32
|
}
|
|
33
33
|
|
|
34
34
|
export const COMPOSITION_EFFECTS: ReadonlyArray<CompositionEffect> = [
|
|
35
|
+
// The neutral default. Every other entry is a dramatic subject transform, so
|
|
36
|
+
// an unconfigured node must inject nothing — the same no-op-entry convention
|
|
37
|
+
// `transitions`/`character-fx` use for their "auto" default, which keeps the
|
|
38
|
+
// default a real catalog id (see the `defaultValue` contract in
|
|
39
|
+
// picker-catalogs.ts) instead of a value absent from the option list.
|
|
40
|
+
{ id: "none", label: "None", description: "No composition effect", promptHint: "", term: "" },
|
|
35
41
|
{ id: "bursting-through-frame", label: "Bursting Through Frame", description: "3D paper-tear breaking the frame", promptHint: "the subject bursting forward through a torn paper frame in a 3D paper-tear effect, head and shoulders breaking the plane of the canvas with ragged paper edges curling outward", term: "3d pop-out through a torn paper frame" },
|
|
36
42
|
{ id: "breaking-out-of-frame", label: "Breaking Out of Frame", description: "Limb extends past canvas border", promptHint: "the subject's hand or limb extending past the canvas border, breaking the fourth wall and reaching beyond the frame's edge into the viewer's space", term: "out-of-bounds effect, limb past the frame" },
|
|
37
43
|
{ id: "pixel-disintegration", label: "Pixel Disintegration", description: "Subject dissolving into particles", promptHint: "the subject pixelating and dissolving into floating geometric particles drifting outward, ordered chaos with crisp pixel-cube fragments scattering away from the silhouette" },
|
|
@@ -45,7 +51,6 @@ export const COMPOSITION_EFFECTS: ReadonlyArray<CompositionEffect> = [
|
|
|
45
51
|
{ id: "floating-fragments", label: "Floating Fragments", description: "Body partially drifting away", promptHint: "the subject's body partially floating away in fragments, broken pieces of the figure detaching and drifting upward like windborne paper, leaving a partially dissolved silhouette", term: "body fragmenting into drifting pieces" },
|
|
46
52
|
{ id: "silhouette-outline", label: "Silhouette Outline", description: "Clean black silhouette on flat BG", promptHint: "the subject reduced to a clean black silhouette outline against a flat single-color background, no internal detail, pure shape language", term: "flat black silhouette on a solid background" },
|
|
47
53
|
{ id: "exploding-particles", label: "Exploding Particles", description: "Outline scattering into particles", promptHint: "the subject's outline exploding outward into a cloud of fine particles, dust and motes scattering radially around a partially intact core figure" },
|
|
48
|
-
{ id: "3x3-grid-collage", label: "3x3 Grid Collage", description: "Contact-sheet 9-pose montage", promptHint: "the subject shown in a 3x3 grid of varied poses and expressions arranged as a contact-sheet collage, nine clean panels with consistent lighting and slight pose variation in each cell" },
|
|
49
54
|
{ id: "matte-painting", label: "Matte Painting", description: "Composite matte-painted background blended with live action, classic VFX", promptHint: "the subject composited against a matte-painted background blended seamlessly with live action, classic VFX matte-painting integration with hand-painted environment extension behind the figure" },
|
|
50
55
|
{ id: "double-exposure", label: "Double Exposure", description: "Two layered photographic exposures fused into one image", promptHint: "two layered photographic exposures fused into one image, the subject's silhouette filled with a secondary scene and translucent overlapping forms in classic darkroom double-exposure technique" },
|
|
51
56
|
{ id: "multiple-exposure", label: "Multiple Exposure", description: "Three or more exposures stacked, kaleidoscopic layering", promptHint: "three or more photographic exposures stacked into a single frame, kaleidoscopic layering of repeated subject positions with translucent overlapping silhouettes echoing across the canvas" },
|