@nodaro/prompts 1.0.1
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/LICENSE +105 -0
- package/README.md +27 -0
- package/dist/index.cjs +21240 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +3599 -0
- package/dist/index.d.ts +3599 -0
- package/dist/index.js +20854 -0
- package/dist/index.js.map +1 -0
- package/package.json +49 -0
- package/src/__tests__/__snapshots__/prompt-builder-segments.test.ts.snap +65 -0
- package/src/__tests__/action-fx.test.ts +155 -0
- package/src/__tests__/apply-picker-json.test.ts +50 -0
- package/src/__tests__/assemble-image-input.test.ts +248 -0
- package/src/__tests__/assemble-suno-input.test.ts +283 -0
- package/src/__tests__/brand-tokens.test.ts +81 -0
- package/src/__tests__/build-image-prompt-element-injection.test.ts +118 -0
- package/src/__tests__/build-image-prompt-hybrid-format.test.ts +131 -0
- package/src/__tests__/build-image-prompt-mentions.test.ts +809 -0
- package/src/__tests__/build-image-prompt-reference-cap.test.ts +57 -0
- package/src/__tests__/build-image-prompt-reference-numbering.test.ts +237 -0
- package/src/__tests__/build-image-prompt-reference-order.test.ts +313 -0
- package/src/__tests__/camera-motions-from-connections.test.ts +71 -0
- package/src/__tests__/catalog-gapfill.test.ts +53 -0
- package/src/__tests__/character-convergence-image.test.ts +217 -0
- package/src/__tests__/character-default-role-image.test.ts +167 -0
- package/src/__tests__/character-default-role-video.test.ts +168 -0
- package/src/__tests__/character-default-role.test.ts +70 -0
- package/src/__tests__/character-fx.test.ts +200 -0
- package/src/__tests__/entity-prompts-location.test.ts +97 -0
- package/src/__tests__/entity-prompts.test.ts +240 -0
- package/src/__tests__/expand-extra-refs-role.test.ts +50 -0
- package/src/__tests__/factory-presets.test.ts +1045 -0
- package/src/__tests__/factory-snippets.test.ts +68 -0
- package/src/__tests__/framing-multi.test.ts +71 -0
- package/src/__tests__/framing-vantage.test.ts +39 -0
- package/src/__tests__/i18n-entry-completeness.test.ts +269 -0
- package/src/__tests__/identity-lock.test.ts +46 -0
- package/src/__tests__/instrumentation.test.ts +68 -0
- package/src/__tests__/lighting-multi.test.ts +68 -0
- package/src/__tests__/location-convergence-image.test.ts +124 -0
- package/src/__tests__/mention-lock-flag.test.ts +499 -0
- package/src/__tests__/multi-picker-spec.test.ts +71 -0
- package/src/__tests__/music-genre.test.ts +103 -0
- package/src/__tests__/music-mood.test.ts +87 -0
- package/src/__tests__/object-creature-convergence-image.test.ts +105 -0
- package/src/__tests__/parameter-prompt-hint.test.ts +270 -0
- package/src/__tests__/parameter-registry-sync.test.ts +243 -0
- package/src/__tests__/person-age.test.ts +80 -0
- package/src/__tests__/person-analyzer-invariants.test.ts +47 -0
- package/src/__tests__/person-body-axes.test.ts +67 -0
- package/src/__tests__/person-facial-geometry.test.ts +156 -0
- package/src/__tests__/person-regional-aesthetic.test.ts +161 -0
- package/src/__tests__/person-sections.test.ts +18 -0
- package/src/__tests__/picker-analyzer-registry.test.ts +108 -0
- package/src/__tests__/picker-catalogs-project.test.ts +85 -0
- package/src/__tests__/picker-catalogs.test.ts +53 -0
- package/src/__tests__/picker-limits.test.ts +20 -0
- package/src/__tests__/prompt-builder-segments.test.ts +183 -0
- package/src/__tests__/prompt-builder-structured-fields.test.ts +40 -0
- package/src/__tests__/prompt-builder.test.ts +1773 -0
- package/src/__tests__/prompt-wizard-categories.test.ts +31 -0
- package/src/__tests__/provider-prompt-doctrine.test.ts +49 -0
- package/src/__tests__/resolve-prompt-append.test.ts +58 -0
- package/src/__tests__/resolve-prompt.test.ts +44 -0
- package/src/__tests__/role-picker-shared.test.ts +208 -0
- package/src/__tests__/seedance-2-inputs.test.ts +173 -0
- package/src/__tests__/seedance-extend.test.ts +44 -0
- package/src/__tests__/sound-aggregator.test.ts +350 -0
- package/src/__tests__/style-presets.test.ts +34 -0
- package/src/__tests__/temporal-multi.test.ts +74 -0
- package/src/__tests__/transitions.test.ts +213 -0
- package/src/__tests__/video-reference-features.test.ts +41 -0
- package/src/__tests__/video-reference-leading-refs.test.ts +90 -0
- package/src/__tests__/video-reference-resolver.test.ts +233 -0
- package/src/__tests__/video-reference-roles.test.ts +96 -0
- package/src/__tests__/voice-character.test.ts +48 -0
- package/src/__tests__/voice-delivery.test.ts +41 -0
- package/src/__tests__/wardrobe.test.ts +25 -0
- package/src/action-fx.ts +255 -0
- package/src/aesthetic.ts +435 -0
- package/src/assemble-image-input.ts +236 -0
- package/src/assemble-suno-input.ts +147 -0
- package/src/atmosphere.ts +104 -0
- package/src/backdrop.ts +131 -0
- package/src/brand-tokens.ts +154 -0
- package/src/camera-format.ts +76 -0
- package/src/camera-motions.ts +615 -0
- package/src/character-fx.ts +274 -0
- package/src/color-look.ts +103 -0
- package/src/composition-effects.ts +67 -0
- package/src/entity-prompts.ts +231 -0
- package/src/era.ts +292 -0
- package/src/exposure-settings.ts +142 -0
- package/src/factory-presets/generate-image.ts +1644 -0
- package/src/factory-presets/generate-video.ts +1116 -0
- package/src/factory-presets/index.ts +46 -0
- package/src/factory-presets/lottie-overlay.ts +166 -0
- package/src/factory-presets/motion-graphics.ts +350 -0
- package/src/factory-presets/music.ts +734 -0
- package/src/factory-presets/sfx.ts +136 -0
- package/src/factory-presets/shared-image.ts +207 -0
- package/src/factory-presets/switchx.ts +65 -0
- package/src/factory-presets/text.ts +292 -0
- package/src/factory-presets/types.ts +51 -0
- package/src/factory-presets/video-edit.ts +136 -0
- package/src/factory-presets/voice.ts +172 -0
- package/src/factory-presets.ts +2 -0
- package/src/factory-snippets/catalog.ts +105 -0
- package/src/factory-snippets/index.ts +16 -0
- package/src/factory-snippets/types.ts +33 -0
- package/src/framing.ts +634 -0
- package/src/held-prop.ts +187 -0
- package/src/identity-lock.ts +213 -0
- package/src/index.ts +66 -0
- package/src/instrumentation.ts +337 -0
- package/src/lens.ts +59 -0
- package/src/lighting.ts +229 -0
- package/src/loop-subject.ts +276 -0
- package/src/materials.ts +184 -0
- package/src/mood.ts +186 -0
- package/src/music-genre.ts +662 -0
- package/src/music-mood.ts +137 -0
- package/src/object-asset-presets.ts +81 -0
- package/src/parameter-prompt-hint.ts +281 -0
- package/src/person.ts +1368 -0
- package/src/photo-genre.ts +151 -0
- package/src/photographer.ts +612 -0
- package/src/picker-analyzer-registry.ts +374 -0
- package/src/picker-catalogs.ts +858 -0
- package/src/pose.ts +246 -0
- package/src/post-process-effects.ts +94 -0
- package/src/prompt-builder-structured-fields.ts +116 -0
- package/src/prompt-builder.ts +2964 -0
- package/src/prompt-templates.ts +50 -0
- package/src/prompt-wizard-categories.ts +334 -0
- package/src/provider-prompt-doctrine.ts +85 -0
- package/src/render-quality.ts +89 -0
- package/src/resolve-prompt.ts +120 -0
- package/src/seedance-2-inputs.ts +100 -0
- package/src/setting.ts +130 -0
- package/src/sound-aggregator.ts +241 -0
- package/src/style-presets.ts +162 -0
- package/src/style.ts +99 -0
- package/src/styling.ts +585 -0
- package/src/temporal.ts +151 -0
- package/src/transitions.ts +333 -0
- package/src/video-reference-resolver.ts +823 -0
- package/src/voice-character.ts +237 -0
- package/src/voice-delivery.ts +142 -0
- package/src/wardrobe.ts +179 -0
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest"
|
|
2
|
+
import {
|
|
3
|
+
getCategoriesForNodeType,
|
|
4
|
+
isWizardSupported,
|
|
5
|
+
MUSIC_WIZARD_CATEGORIES,
|
|
6
|
+
} from "../prompt-wizard-categories.js"
|
|
7
|
+
|
|
8
|
+
describe("suno-generate:style wizard target", () => {
|
|
9
|
+
it("reuses MUSIC categories and is supported", () => {
|
|
10
|
+
// The composite `:style` target drives the SAME music wizard form as the
|
|
11
|
+
// base `suno-generate` node — it only changes the OUTPUT shape (tags vs
|
|
12
|
+
// prose), handled in the backend system-prompt builder.
|
|
13
|
+
expect(getCategoriesForNodeType("suno-generate:style")).toEqual(MUSIC_WIZARD_CATEGORIES)
|
|
14
|
+
expect(isWizardSupported("suno-generate:style")).toBe(true)
|
|
15
|
+
})
|
|
16
|
+
})
|
|
17
|
+
|
|
18
|
+
describe("suno-generate:negativeStyle + :lyrics wizard targets", () => {
|
|
19
|
+
// Task 5 extends the Phase-D composite-key pattern to two more Suno fields.
|
|
20
|
+
// BOTH reuse the SAME music wizard FORM as `:style`/`suno-generate`; only the
|
|
21
|
+
// backend OUTPUT framing differs (avoid-tags / sectioned lyrics).
|
|
22
|
+
it("negativeStyle reuses MUSIC categories and is supported", () => {
|
|
23
|
+
expect(getCategoriesForNodeType("suno-generate:negativeStyle")).toEqual(MUSIC_WIZARD_CATEGORIES)
|
|
24
|
+
expect(isWizardSupported("suno-generate:negativeStyle")).toBe(true)
|
|
25
|
+
})
|
|
26
|
+
|
|
27
|
+
it("lyrics reuses MUSIC categories and is supported", () => {
|
|
28
|
+
expect(getCategoriesForNodeType("suno-generate:lyrics")).toEqual(MUSIC_WIZARD_CATEGORIES)
|
|
29
|
+
expect(isWizardSupported("suno-generate:lyrics")).toBe(true)
|
|
30
|
+
})
|
|
31
|
+
})
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest"
|
|
2
|
+
import {
|
|
3
|
+
PROVIDER_PROMPT_DOCTRINES,
|
|
4
|
+
getPromptDoctrine,
|
|
5
|
+
getPromptTips,
|
|
6
|
+
} from "../provider-prompt-doctrine.js"
|
|
7
|
+
import { MODEL_CATALOG } from "@nodaro/shared"
|
|
8
|
+
|
|
9
|
+
describe("PROVIDER_PROMPT_DOCTRINES", () => {
|
|
10
|
+
it("every doctrine maps to real catalog providers and has tips + body", () => {
|
|
11
|
+
expect(PROVIDER_PROMPT_DOCTRINES.length).toBeGreaterThan(0)
|
|
12
|
+
for (const d of PROVIDER_PROMPT_DOCTRINES) {
|
|
13
|
+
expect(d.providers.length).toBeGreaterThan(0)
|
|
14
|
+
for (const p of d.providers) expect(MODEL_CATALOG[p]).toBeDefined()
|
|
15
|
+
expect(d.tips.length).toBeGreaterThanOrEqual(3)
|
|
16
|
+
expect(d.tips.length).toBeLessThanOrEqual(6)
|
|
17
|
+
for (const t of d.tips) expect(t.length).toBeLessThanOrEqual(220)
|
|
18
|
+
expect(d.doctrine.length).toBeGreaterThan(500)
|
|
19
|
+
expect(d.heading.length).toBeGreaterThan(0)
|
|
20
|
+
}
|
|
21
|
+
})
|
|
22
|
+
|
|
23
|
+
it("resolves by provider id, returns undefined/[] for providers without doctrine", () => {
|
|
24
|
+
expect(getPromptDoctrine("seedance-2")).toBeDefined()
|
|
25
|
+
expect(getPromptDoctrine("seedance-2-fast")).toBeDefined()
|
|
26
|
+
expect(getPromptDoctrine("veo3.1")).toBeUndefined()
|
|
27
|
+
expect(getPromptTips("seedance-2").length).toBeGreaterThan(0)
|
|
28
|
+
expect(getPromptTips("veo3.1")).toEqual([])
|
|
29
|
+
})
|
|
30
|
+
|
|
31
|
+
it("seedance doctrine encodes the official rules and bans the unstable patterns", () => {
|
|
32
|
+
const d = getPromptDoctrine("seedance-2")!
|
|
33
|
+
// untimed Shot N storyboards, never "(0-3s)" timestamps
|
|
34
|
+
expect(d.doctrine).toContain("Shot 1")
|
|
35
|
+
expect(d.doctrine).toMatch(/timestamp/i)
|
|
36
|
+
expect(d.doctrine).not.toMatch(/Shot \d+\s*\(\d+\s*[-–]\s*\d+s\)/)
|
|
37
|
+
// headshot + full-body identity rule (NOT multi-view)
|
|
38
|
+
expect(d.doctrine).toMatch(/headshot/i)
|
|
39
|
+
expect(d.doctrine).toMatch(/multi-view|three-view/i)
|
|
40
|
+
// audio symbol semantics + Nodaro brace caveat
|
|
41
|
+
expect(d.doctrine).toContain("(")
|
|
42
|
+
expect(d.doctrine).toContain("<")
|
|
43
|
+
expect(d.doctrine).toMatch(/quoted|quotes/i)
|
|
44
|
+
// edit/extend "reference" keyword trap
|
|
45
|
+
expect(d.doctrine).toMatch(/reference Video/i)
|
|
46
|
+
// no native negative-prompt param
|
|
47
|
+
expect(d.doctrine).toMatch(/negative/i)
|
|
48
|
+
})
|
|
49
|
+
})
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest"
|
|
2
|
+
import { resolvePrompt, computeNodePrompt, composeNegative } from "../resolve-prompt.js"
|
|
3
|
+
|
|
4
|
+
// `appendWired` (generate-image / generate-video only): the connected (wired)
|
|
5
|
+
// prompt is APPENDED to the typed prompt instead of being a fallback. Every
|
|
6
|
+
// other node type keeps the legacy precedence (override > typed > wired).
|
|
7
|
+
const NO_REFS = new Map<string, string>()
|
|
8
|
+
|
|
9
|
+
describe("composeNegative", () => {
|
|
10
|
+
it("joins typed + wired with '. '", () => {
|
|
11
|
+
expect(composeNegative("blurry", "low-res")).toBe("blurry. low-res")
|
|
12
|
+
})
|
|
13
|
+
it("typed alone when no wired; wired alone when no typed", () => {
|
|
14
|
+
expect(composeNegative("blurry", undefined)).toBe("blurry")
|
|
15
|
+
expect(composeNegative(undefined, "low-res")).toBe("low-res")
|
|
16
|
+
})
|
|
17
|
+
it("drops empty / whitespace parts → ''", () => {
|
|
18
|
+
expect(composeNegative(" ", "")).toBe("")
|
|
19
|
+
expect(composeNegative(undefined, undefined)).toBe("")
|
|
20
|
+
})
|
|
21
|
+
})
|
|
22
|
+
|
|
23
|
+
describe("resolvePrompt — appendWired", () => {
|
|
24
|
+
it("appends wired to the typed base, joined '. '", () => {
|
|
25
|
+
expect(resolvePrompt({ typed: ["a cat"], wired: "shot on film", refMap: NO_REFS, appendWired: true }))
|
|
26
|
+
.toBe("a cat. shot on film")
|
|
27
|
+
})
|
|
28
|
+
|
|
29
|
+
it("typed only when wired is absent/blank", () => {
|
|
30
|
+
expect(resolvePrompt({ typed: ["a cat"], wired: " ", refMap: NO_REFS, appendWired: true })).toBe("a cat")
|
|
31
|
+
expect(resolvePrompt({ typed: ["a cat"], refMap: NO_REFS, appendWired: true })).toBe("a cat")
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
it("wired only when typed is empty", () => {
|
|
35
|
+
expect(resolvePrompt({ typed: [], wired: "shot on film", refMap: NO_REFS, appendWired: true })).toBe("shot on film")
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
it("an override (list fan-out item) fully replaces — wired does NOT append onto it", () => {
|
|
39
|
+
expect(resolvePrompt({ override: "OVR", typed: ["a cat"], wired: "film", refMap: NO_REFS, appendWired: true }))
|
|
40
|
+
.toBe("OVR")
|
|
41
|
+
})
|
|
42
|
+
|
|
43
|
+
it("WITHOUT appendWired: legacy precedence is byte-unchanged (typed wins, wired = fallback)", () => {
|
|
44
|
+
expect(resolvePrompt({ typed: ["a cat"], wired: "film", refMap: NO_REFS })).toBe("a cat")
|
|
45
|
+
expect(resolvePrompt({ typed: [], wired: "film", refMap: NO_REFS })).toBe("film")
|
|
46
|
+
expect(resolvePrompt({ override: "OVR", typed: ["a cat"], wired: "film", refMap: NO_REFS })).toBe("OVR")
|
|
47
|
+
})
|
|
48
|
+
|
|
49
|
+
it("computeNodePrompt threads appendWired for generate-image; other types unchanged", () => {
|
|
50
|
+
expect(
|
|
51
|
+
computeNodePrompt("generate-image", { prompt: "a cat" }, { wired: "film", refMap: NO_REFS, appendWired: true }),
|
|
52
|
+
).toBe("a cat. film")
|
|
53
|
+
// video-to-video does NOT pass appendWired → wired stays a fallback
|
|
54
|
+
expect(
|
|
55
|
+
computeNodePrompt("video-to-video", { prompt: "a cat" }, { wired: "film", refMap: NO_REFS }),
|
|
56
|
+
).toBe("a cat")
|
|
57
|
+
})
|
|
58
|
+
})
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest"
|
|
2
|
+
import { resolvePrompt, computeNodePrompt, computeLlmChatFields } from "../resolve-prompt.js"
|
|
3
|
+
const M = new Map<string, string>()
|
|
4
|
+
const R = new Map([["Hero", "a knight"]])
|
|
5
|
+
describe("resolvePrompt", () => {
|
|
6
|
+
it("override wins", () => expect(resolvePrompt({ override: "o", typed: ["t"], wired: "w", refMap: M })).toBe("o"))
|
|
7
|
+
it("first present typed candidate wins over wired", () =>
|
|
8
|
+
expect(resolvePrompt({ typed: [undefined, " ", "t2"], wired: "w", refMap: M })).toBe("t2"))
|
|
9
|
+
it("falls through empty typed to wired", () =>
|
|
10
|
+
expect(resolvePrompt({ typed: ["", " "], wired: "w", refMap: M })).toBe("w"))
|
|
11
|
+
it("nothing -> empty string", () => expect(resolvePrompt({ typed: [], refMap: M })).toBe(""))
|
|
12
|
+
it("resolves {Label} refs on the chosen branch", () =>
|
|
13
|
+
expect(resolvePrompt({ typed: ["x {Hero} y"], refMap: R })).toBe("x a knight y"))
|
|
14
|
+
})
|
|
15
|
+
describe("computeNodePrompt", () => {
|
|
16
|
+
const M = new Map<string, string>()
|
|
17
|
+
it("generate-image: typed prompt wins over wired", () =>
|
|
18
|
+
expect(computeNodePrompt("generate-image", { prompt: "typed" }, { wired: "wire", refMap: M })).toBe("typed"))
|
|
19
|
+
it("image-to-video: falls back to motionPrompt when prompt empty", () =>
|
|
20
|
+
expect(computeNodePrompt("image-to-video", { prompt: "", motionPrompt: "mp" }, { wired: "wire", refMap: M })).toBe("mp"))
|
|
21
|
+
it("text-to-audio: falls back to data.text", () =>
|
|
22
|
+
expect(computeNodePrompt("text-to-audio", { prompt: "", text: "t" }, { wired: "wire", refMap: M })).toBe("t"))
|
|
23
|
+
it("text-to-speech direct: uses directText", () =>
|
|
24
|
+
expect(computeNodePrompt("text-to-speech", { textSource: "direct", directText: "d" }, { wired: "wire", refMap: M })).toBe("d"))
|
|
25
|
+
it("text-to-speech connected: wire only (no typed)", () =>
|
|
26
|
+
expect(computeNodePrompt("text-to-speech", { textSource: "connected", directText: "ignored" }, { wired: "wire", refMap: M })).toBe("wire"))
|
|
27
|
+
it("video-retake: does not crash (not in NODE_MAPPABLE_FIELDS)", () =>
|
|
28
|
+
expect(computeNodePrompt("video-retake", { prompt: "vr" }, { wired: "w", refMap: M })).toBe("vr"))
|
|
29
|
+
it("social-publish: uses caption", () =>
|
|
30
|
+
expect(computeNodePrompt("social-publish", { caption: "c" }, { wired: "w", refMap: M })).toBe("c"))
|
|
31
|
+
it("override wins for any node", () =>
|
|
32
|
+
expect(computeNodePrompt("generate-image", { prompt: "typed" }, { wired: "w", override: "o", refMap: M })).toBe("o"))
|
|
33
|
+
it("unknown node defaults to ['prompt']", () =>
|
|
34
|
+
expect(computeNodePrompt("whatever", { prompt: "p" }, { refMap: M })).toBe("p"))
|
|
35
|
+
})
|
|
36
|
+
describe("computeLlmChatFields", () => {
|
|
37
|
+
const M = new Map<string, string>()
|
|
38
|
+
it("typed fields win; override beats typed userInput", () => {
|
|
39
|
+
expect(computeLlmChatFields({ userInput: "u", systemPrompt: "s" },
|
|
40
|
+
{ wiredUserInput: "wu", wiredSystemPrompt: "ws", refMap: M })).toEqual({ userInput: "u", systemPrompt: "s" })
|
|
41
|
+
expect(computeLlmChatFields({ userInput: "u", systemPrompt: "s" },
|
|
42
|
+
{ override: "o", wiredUserInput: "wu", wiredSystemPrompt: "ws", refMap: M }).userInput).toBe("o")
|
|
43
|
+
})
|
|
44
|
+
})
|
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Unified Reference Roles — Phase D Task 1 (shared role-picker support).
|
|
3
|
+
*
|
|
4
|
+
* Two shared changes that back the editor role-picker (Tasks 2-3):
|
|
5
|
+
*
|
|
6
|
+
* (A) Custom-role VERBATIM relaxation in the HYBRID resolvers — a free-form
|
|
7
|
+
* role typed in a mention's variant/role slot (e.g. `@kira:1:earrings`)
|
|
8
|
+
* that doesn't resolve to a real matched variant now survives verbatim
|
|
9
|
+
* ("the earrings from …") instead of collapsing to the source default
|
|
10
|
+
* ("the person from …"). Applies to character (image + video) and location.
|
|
11
|
+
*
|
|
12
|
+
* (B) Location bare-slug ROLE parsing — a slash-less, non-mode 3rd segment now
|
|
13
|
+
* parses as a role (previously null → literal text). The F2 follow-up
|
|
14
|
+
* REMOVED the preset gate, so a CUSTOM slug (`@old-library:1:foobar`) parses
|
|
15
|
+
* as `role: "foobar"` too, mirroring the character parser. Legacy stays
|
|
16
|
+
* byte-identical NOT via the parser but via the resolver's `if (t.role)
|
|
17
|
+
* continue` guard — a role token (preset OR custom) is skipped in the legacy
|
|
18
|
+
* path, so it stays literal text with no bullet / phrase promotion.
|
|
19
|
+
* `normalizeRoleSlug` maps the slug back to the phrase key so the non-noun
|
|
20
|
+
* specials (`empty-background` → `empty background`) key `roleToPhrase`
|
|
21
|
+
* correctly; a custom slug passes through verbatim.
|
|
22
|
+
*
|
|
23
|
+
* Existing `@-mention` / variant / mode parsing + the legacy resolvers stay
|
|
24
|
+
* byte-identical — those suites are the guard.
|
|
25
|
+
*/
|
|
26
|
+
import { describe, it, expect } from "vitest"
|
|
27
|
+
import { buildImagePrompt } from "../prompt-builder.js"
|
|
28
|
+
import { resolveVideoReferenceCore } from "../video-reference-resolver.js"
|
|
29
|
+
import { parseLocationMentionToken } from "@nodaro/shared"
|
|
30
|
+
import { normalizeRoleSlug } from "@nodaro/shared"
|
|
31
|
+
import type { ConnectedReference } from "@nodaro/shared"
|
|
32
|
+
|
|
33
|
+
const kira: ConnectedReference = {
|
|
34
|
+
id: "k", defaultName: "Kira", source: "wired-character",
|
|
35
|
+
url: "https://cdn/kira.png", characterSlug: "kira",
|
|
36
|
+
}
|
|
37
|
+
const library: ConnectedReference = {
|
|
38
|
+
id: "l", defaultName: "Old Library", source: "wired-location",
|
|
39
|
+
url: "https://cdn/library.png", locationSlug: "old-library",
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
describe("(A) character custom-role verbatim relaxation — image hybrid", () => {
|
|
43
|
+
it("PRESET role-only @kira:1:clothes → 'the clothes from reference image A' (pin)", () => {
|
|
44
|
+
const out = buildImagePrompt({
|
|
45
|
+
provider: "nano-banana-pro",
|
|
46
|
+
prompt: "@kira:1:clothes on a runway",
|
|
47
|
+
connectedReferences: [kira],
|
|
48
|
+
referenceFormat: "hybrid",
|
|
49
|
+
})
|
|
50
|
+
expect(out.prompt).toContain("the clothes from reference image A on a runway")
|
|
51
|
+
expect(out.prompt).not.toContain("Use these characters:")
|
|
52
|
+
})
|
|
53
|
+
|
|
54
|
+
it("CUSTOM role @kira:1:earrings → 'the earrings from reference image A' (NOT 'person')", () => {
|
|
55
|
+
const out = buildImagePrompt({
|
|
56
|
+
provider: "nano-banana-pro",
|
|
57
|
+
prompt: "@kira:1:earrings on a runway",
|
|
58
|
+
connectedReferences: [kira],
|
|
59
|
+
referenceFormat: "hybrid",
|
|
60
|
+
})
|
|
61
|
+
expect(out.prompt).toContain("the earrings from reference image A on a runway")
|
|
62
|
+
expect(out.prompt).not.toContain("the person from reference image A")
|
|
63
|
+
expect(out.referenceImageUrls).toContain("https://cdn/kira.png")
|
|
64
|
+
})
|
|
65
|
+
})
|
|
66
|
+
|
|
67
|
+
describe("(A) character custom-role verbatim relaxation — video hybrid", () => {
|
|
68
|
+
it("CUSTOM role @kira:1:earrings → 'the earrings from @image_1' (NOT 'person')", () => {
|
|
69
|
+
const out = resolveVideoReferenceCore({
|
|
70
|
+
prompt: "@kira:1:earrings spins", wiredCharRefs: [kira], hybridRoles: true,
|
|
71
|
+
})
|
|
72
|
+
expect(out.prompt).toContain("the earrings from @image_1 spins")
|
|
73
|
+
expect(out.prompt).not.toContain("the person from @image_1")
|
|
74
|
+
expect(out.additionalUrls).toContain("https://cdn/kira.png")
|
|
75
|
+
})
|
|
76
|
+
|
|
77
|
+
it("PRESET role @kira:1:face → 'the face from @image_1' (pin, unchanged)", () => {
|
|
78
|
+
const out = resolveVideoReferenceCore({
|
|
79
|
+
prompt: "@kira:1:face runs", wiredCharRefs: [kira], hybridRoles: true,
|
|
80
|
+
})
|
|
81
|
+
expect(out.prompt).toContain("the face from @image_1 runs")
|
|
82
|
+
})
|
|
83
|
+
})
|
|
84
|
+
|
|
85
|
+
describe("(B) location bare-slug role parsing — image hybrid", () => {
|
|
86
|
+
it("@old-library:1:background → 'the background from reference image A', token replaced inline", () => {
|
|
87
|
+
const out = buildImagePrompt({
|
|
88
|
+
provider: "nano-banana-pro",
|
|
89
|
+
prompt: "@old-library:1:background a chase scene",
|
|
90
|
+
connectedReferences: [library],
|
|
91
|
+
referenceFormat: "hybrid",
|
|
92
|
+
})
|
|
93
|
+
expect(out.prompt).toContain("the background from reference image A")
|
|
94
|
+
// Token resolved → no literal slug left behind, no legacy block.
|
|
95
|
+
expect(out.prompt).not.toContain("@old-library:1:background")
|
|
96
|
+
expect(out.prompt).not.toContain("Use these locations:")
|
|
97
|
+
expect(out.referenceImageUrls).toContain("https://cdn/library.png")
|
|
98
|
+
})
|
|
99
|
+
|
|
100
|
+
it("@old-library:1:empty-background → 'the background … (without its foreground objects)'", () => {
|
|
101
|
+
const out = buildImagePrompt({
|
|
102
|
+
provider: "nano-banana-pro",
|
|
103
|
+
prompt: "@old-library:1:empty-background a chase scene",
|
|
104
|
+
connectedReferences: [library],
|
|
105
|
+
referenceFormat: "hybrid",
|
|
106
|
+
})
|
|
107
|
+
expect(out.prompt).toContain(
|
|
108
|
+
"the background from reference image A (without its foreground objects)",
|
|
109
|
+
)
|
|
110
|
+
expect(out.prompt).not.toContain("@old-library:1:empty-background")
|
|
111
|
+
})
|
|
112
|
+
|
|
113
|
+
it("CUSTOM role @old-library:1:foobar → 'the foobar from reference image A' (verbatim, F2)", () => {
|
|
114
|
+
const out = buildImagePrompt({
|
|
115
|
+
provider: "nano-banana-pro",
|
|
116
|
+
prompt: "@old-library:1:foobar a chase scene",
|
|
117
|
+
connectedReferences: [library],
|
|
118
|
+
referenceFormat: "hybrid",
|
|
119
|
+
})
|
|
120
|
+
expect(out.prompt).toContain("the foobar from reference image A")
|
|
121
|
+
expect(out.prompt).not.toContain("@old-library:1:foobar")
|
|
122
|
+
expect(out.prompt).not.toContain("Use these locations:")
|
|
123
|
+
expect(out.referenceImageUrls).toContain("https://cdn/library.png")
|
|
124
|
+
})
|
|
125
|
+
})
|
|
126
|
+
|
|
127
|
+
describe("(B) location parser additive change — direct", () => {
|
|
128
|
+
it("@old-library:1:background parses as a role (slug stored verbatim)", () => {
|
|
129
|
+
expect(parseLocationMentionToken("@old-library:1:background")).toEqual({
|
|
130
|
+
locationSlug: "old-library", imageIndex: 1,
|
|
131
|
+
bucket: null, variant: null, usageMode: null, role: "background",
|
|
132
|
+
})
|
|
133
|
+
})
|
|
134
|
+
|
|
135
|
+
it("@old-library:1:empty-background parses as a role (slug form)", () => {
|
|
136
|
+
expect(parseLocationMentionToken("@old-library:1:empty-background")).toEqual({
|
|
137
|
+
locationSlug: "old-library", imageIndex: 1,
|
|
138
|
+
bucket: null, variant: null, usageMode: null, role: "empty-background",
|
|
139
|
+
})
|
|
140
|
+
})
|
|
141
|
+
|
|
142
|
+
it("real variant @old-library:1:weather/rain still parses as a variant (unchanged, no role)", () => {
|
|
143
|
+
expect(parseLocationMentionToken("@old-library:1:weather/rain")).toEqual({
|
|
144
|
+
locationSlug: "old-library", imageIndex: 1,
|
|
145
|
+
bucket: "weather", variant: "rain", usageMode: null,
|
|
146
|
+
})
|
|
147
|
+
})
|
|
148
|
+
|
|
149
|
+
it("CUSTOM bare slug @old-library:1:foobar now parses as a role (parser gate removed, F2)", () => {
|
|
150
|
+
expect(parseLocationMentionToken("@old-library:1:foobar")).toEqual({
|
|
151
|
+
locationSlug: "old-library", imageIndex: 1,
|
|
152
|
+
bucket: null, variant: null, usageMode: null, role: "foobar",
|
|
153
|
+
})
|
|
154
|
+
})
|
|
155
|
+
|
|
156
|
+
it("known mode @old-library:1:layout still parses as a usage mode (unchanged)", () => {
|
|
157
|
+
expect(parseLocationMentionToken("@old-library:1:layout")).toEqual({
|
|
158
|
+
locationSlug: "old-library", imageIndex: 1,
|
|
159
|
+
bucket: null, variant: null, usageMode: "layout",
|
|
160
|
+
})
|
|
161
|
+
})
|
|
162
|
+
})
|
|
163
|
+
|
|
164
|
+
describe("normalizeRoleSlug (slug ↔ phrase key)", () => {
|
|
165
|
+
it("maps the multi-word location preset slug back to its phrase key", () => {
|
|
166
|
+
expect(normalizeRoleSlug("empty-background")).toBe("empty background")
|
|
167
|
+
})
|
|
168
|
+
it("passes single-token + hyphenated-key roles through unchanged", () => {
|
|
169
|
+
expect(normalizeRoleSlug("background")).toBe("background")
|
|
170
|
+
expect(normalizeRoleSlug("as-is")).toBe("as-is")
|
|
171
|
+
})
|
|
172
|
+
it("passes free-form custom slugs through unchanged", () => {
|
|
173
|
+
expect(normalizeRoleSlug("rooftop-view")).toBe("rooftop-view")
|
|
174
|
+
})
|
|
175
|
+
})
|
|
176
|
+
|
|
177
|
+
describe("legacy (no referenceFormat) is NOT affected by the relaxation", () => {
|
|
178
|
+
it("legacy never emits the hybrid role phrase for char or location mentions", () => {
|
|
179
|
+
const charOut = buildImagePrompt({
|
|
180
|
+
provider: "nano-banana-pro",
|
|
181
|
+
prompt: "@kira:1:earrings poses",
|
|
182
|
+
connectedReferences: [kira],
|
|
183
|
+
})
|
|
184
|
+
expect(charOut.prompt).not.toContain("the earrings from reference image")
|
|
185
|
+
|
|
186
|
+
const locOut = buildImagePrompt({
|
|
187
|
+
provider: "nano-banana-pro",
|
|
188
|
+
prompt: "@old-library:1:background scene",
|
|
189
|
+
connectedReferences: [library],
|
|
190
|
+
})
|
|
191
|
+
expect(locOut.prompt).not.toContain("the background from reference image")
|
|
192
|
+
})
|
|
193
|
+
|
|
194
|
+
it("CUSTOM location role @old-library:1:foobar stays LITERAL in legacy (byte-identical, F2)", () => {
|
|
195
|
+
// The parser now accepts `foobar` as a role, but the legacy resolver's
|
|
196
|
+
// `if (t.role) continue` guard skips ANY role token (preset or custom): no
|
|
197
|
+
// inline replacement, no bullet, no `Use these locations:` block — the raw
|
|
198
|
+
// token survives verbatim exactly as it did pre-Phase-D (parser → null).
|
|
199
|
+
const out = buildImagePrompt({
|
|
200
|
+
provider: "nano-banana-pro",
|
|
201
|
+
prompt: "@old-library:1:foobar a chase scene",
|
|
202
|
+
connectedReferences: [library],
|
|
203
|
+
})
|
|
204
|
+
expect(out.prompt).toContain("@old-library:1:foobar")
|
|
205
|
+
expect(out.prompt).not.toContain("Use these locations:")
|
|
206
|
+
expect(out.prompt).not.toContain("the foobar from reference image")
|
|
207
|
+
})
|
|
208
|
+
})
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest"
|
|
2
|
+
import { resolveSeedance2Inputs } from "../seedance-2-inputs.js"
|
|
3
|
+
|
|
4
|
+
describe("resolveSeedance2Inputs", () => {
|
|
5
|
+
it("first frame only, no references → strict first-frame, no suffix", () => {
|
|
6
|
+
const r = resolveSeedance2Inputs({ firstFrameUrl: "https://a/first.png" })
|
|
7
|
+
expect(r.mode).toBe("first-frame")
|
|
8
|
+
expect(r.firstFrameUrl).toBe("https://a/first.png")
|
|
9
|
+
expect(r.lastFrameUrl).toBeUndefined()
|
|
10
|
+
expect(r.referenceImageUrls).toEqual([])
|
|
11
|
+
expect(r.promptSuffix).toBe("")
|
|
12
|
+
})
|
|
13
|
+
|
|
14
|
+
it("first + last frame, no references → strict first-last-frame", () => {
|
|
15
|
+
const r = resolveSeedance2Inputs({ firstFrameUrl: "https://a/f.png", lastFrameUrl: "https://a/l.png" })
|
|
16
|
+
expect(r.mode).toBe("first-last-frame")
|
|
17
|
+
expect(r.firstFrameUrl).toBe("https://a/f.png")
|
|
18
|
+
expect(r.lastFrameUrl).toBe("https://a/l.png")
|
|
19
|
+
expect(r.promptSuffix).toBe("")
|
|
20
|
+
})
|
|
21
|
+
|
|
22
|
+
it("nothing connected → first-frame with no urls, no suffix", () => {
|
|
23
|
+
const r = resolveSeedance2Inputs({})
|
|
24
|
+
expect(r.mode).toBe("first-frame")
|
|
25
|
+
expect(r.firstFrameUrl).toBeUndefined()
|
|
26
|
+
expect(r.referenceImageUrls).toEqual([])
|
|
27
|
+
expect(r.promptSuffix).toBe("")
|
|
28
|
+
})
|
|
29
|
+
|
|
30
|
+
it("first frame + one reference image → reference mode, frame appended last, ordinal 2", () => {
|
|
31
|
+
const r = resolveSeedance2Inputs({ firstFrameUrl: "https://a/first.png", refImageUrls: ["https://a/ref.png"] })
|
|
32
|
+
expect(r.mode).toBe("reference")
|
|
33
|
+
expect(r.firstFrameUrl).toBeUndefined()
|
|
34
|
+
expect(r.lastFrameUrl).toBeUndefined()
|
|
35
|
+
expect(r.referenceImageUrls).toEqual(["https://a/ref.png", "https://a/first.png"])
|
|
36
|
+
expect(r.promptSuffix).toBe("Use @image_2 as the opening (first) frame of the video.")
|
|
37
|
+
})
|
|
38
|
+
|
|
39
|
+
it("first + last frame + two reference images → reference, frames at 3 and 4", () => {
|
|
40
|
+
const r = resolveSeedance2Inputs({
|
|
41
|
+
firstFrameUrl: "https://a/f.png", lastFrameUrl: "https://a/l.png",
|
|
42
|
+
refImageUrls: ["https://a/r1.png", "https://a/r2.png"],
|
|
43
|
+
})
|
|
44
|
+
expect(r.referenceImageUrls).toEqual(["https://a/r1.png", "https://a/r2.png", "https://a/f.png", "https://a/l.png"])
|
|
45
|
+
expect(r.promptSuffix).toBe("Use @image_3 as the opening (first) frame and @image_4 as the closing (last) frame of the video.")
|
|
46
|
+
})
|
|
47
|
+
|
|
48
|
+
it("reference audio + first frame (no images/videos) → reference mode, frame as Image 1, audio passed through", () => {
|
|
49
|
+
const r = resolveSeedance2Inputs({ firstFrameUrl: "https://a/f.png", refAudioUrls: ["https://a/voice.mp3"] })
|
|
50
|
+
expect(r.mode).toBe("reference")
|
|
51
|
+
expect(r.referenceImageUrls).toEqual(["https://a/f.png"])
|
|
52
|
+
expect(r.referenceAudioUrls).toEqual(["https://a/voice.mp3"])
|
|
53
|
+
expect(r.promptSuffix).toBe("Use @image_1 as the opening (first) frame of the video.")
|
|
54
|
+
})
|
|
55
|
+
|
|
56
|
+
it("last frame only, no first frame, no references → reference, closing-frame hint", () => {
|
|
57
|
+
const r = resolveSeedance2Inputs({ lastFrameUrl: "https://a/l.png" })
|
|
58
|
+
expect(r.mode).toBe("reference")
|
|
59
|
+
expect(r.referenceImageUrls).toEqual(["https://a/l.png"])
|
|
60
|
+
expect(r.promptSuffix).toBe("Use @image_1 as the closing (last) frame of the video.")
|
|
61
|
+
})
|
|
62
|
+
|
|
63
|
+
it("9 user images + first + last → frames kept, 2 user images dropped, ordinals 8 and 9", () => {
|
|
64
|
+
const refs = Array.from({ length: 9 }, (_, i) => `https://a/r${i}.png`)
|
|
65
|
+
const r = resolveSeedance2Inputs({ firstFrameUrl: "https://a/f.png", lastFrameUrl: "https://a/l.png", refImageUrls: refs })
|
|
66
|
+
expect(r.referenceImageUrls).toHaveLength(9)
|
|
67
|
+
expect(r.referenceImageUrls[7]).toBe("https://a/f.png")
|
|
68
|
+
expect(r.referenceImageUrls[8]).toBe("https://a/l.png")
|
|
69
|
+
expect(r.droppedRefImages).toBe(2)
|
|
70
|
+
expect(r.promptSuffix).toBe("Use @image_8 as the opening (first) frame and @image_9 as the closing (last) frame of the video.")
|
|
71
|
+
})
|
|
72
|
+
|
|
73
|
+
it("blank/whitespace urls are ignored", () => {
|
|
74
|
+
const r = resolveSeedance2Inputs({ firstFrameUrl: " ", refImageUrls: ["", " ", "https://a/r.png"] })
|
|
75
|
+
expect(r.mode).toBe("reference")
|
|
76
|
+
expect(r.referenceImageUrls).toEqual(["https://a/r.png"])
|
|
77
|
+
expect(r.promptSuffix).toBe("")
|
|
78
|
+
})
|
|
79
|
+
|
|
80
|
+
it("caps reference videos at 3 and audio at 3", () => {
|
|
81
|
+
const r = resolveSeedance2Inputs({
|
|
82
|
+
refImageUrls: ["https://a/r.png"],
|
|
83
|
+
refVideoUrls: ["v1", "v2", "v3", "v4"], refAudioUrls: ["a1", "a2", "a3", "a4"],
|
|
84
|
+
})
|
|
85
|
+
expect(r.referenceVideoUrls).toEqual(["v1", "v2", "v3"])
|
|
86
|
+
expect(r.referenceAudioUrls).toEqual(["a1", "a2", "a3"])
|
|
87
|
+
})
|
|
88
|
+
|
|
89
|
+
// ---------------------------------------------------------------------------
|
|
90
|
+
// Frame-ordinal ↔ array-position INVARIANT (drift guard).
|
|
91
|
+
//
|
|
92
|
+
// The frame `@image_N` numbers the resolver writes into `promptSuffix` must be
|
|
93
|
+
// the literal 1-based positions of `firstFrameUrl` / `lastFrameUrl` in the
|
|
94
|
+
// returned `referenceImageUrls`, AND must be strictly greater than the count
|
|
95
|
+
// of user reference images (frames always come AFTER user refs, never in a
|
|
96
|
+
// user-ref slot). This locks the resolver's internal numbering so a future
|
|
97
|
+
// refactor of the append order or suffix wording can't silently desync the
|
|
98
|
+
// ordinal it tells the model from where the frame actually sits in the array.
|
|
99
|
+
//
|
|
100
|
+
// NB: this guards the resolver in ISOLATION. The combined-case interaction
|
|
101
|
+
// (a @-mentioned character that also fills the i2v start-frame slot while
|
|
102
|
+
// other refs are present) is covered at the payload-builder + provider seam —
|
|
103
|
+
// see backend `seedance2-multimodal.test.ts` "combined case" tests.
|
|
104
|
+
// ---------------------------------------------------------------------------
|
|
105
|
+
describe("frame-ordinal ↔ array-position invariant", () => {
|
|
106
|
+
// Parse every `@image_N` ordinal out of the suffix in document order. The
|
|
107
|
+
// regex tolerates both the `@image_N` form (current) and a legacy `Image N`
|
|
108
|
+
// form so the invariant holds across the REF_BINDING swap-point flip.
|
|
109
|
+
const ordinalsIn = (suffix: string): number[] =>
|
|
110
|
+
Array.from(suffix.matchAll(/@?image[_ ](\d+)/gi)).map((m) => parseInt(m[1], 10))
|
|
111
|
+
|
|
112
|
+
const refShapes: Array<{ name: string; refs: string[] }> = [
|
|
113
|
+
{ name: "0 user refs", refs: [] },
|
|
114
|
+
{ name: "1 user ref", refs: ["https://a/u1.png"] },
|
|
115
|
+
{ name: "2 user refs", refs: ["https://a/u1.png", "https://a/u2.png"] },
|
|
116
|
+
]
|
|
117
|
+
const frameShapes: Array<{ name: string; first?: string; last?: string }> = [
|
|
118
|
+
{ name: "first-only", first: "https://a/first.png" },
|
|
119
|
+
{ name: "first+last", first: "https://a/first.png", last: "https://a/last.png" },
|
|
120
|
+
{ name: "last-only", last: "https://a/last.png" },
|
|
121
|
+
]
|
|
122
|
+
|
|
123
|
+
for (const rs of refShapes) {
|
|
124
|
+
for (const fs of frameShapes) {
|
|
125
|
+
it(`${rs.name} × ${fs.name}: suffix ordinals == array positions, all > user-ref count`, () => {
|
|
126
|
+
const r = resolveSeedance2Inputs({
|
|
127
|
+
firstFrameUrl: fs.first,
|
|
128
|
+
lastFrameUrl: fs.last,
|
|
129
|
+
refImageUrls: rs.refs,
|
|
130
|
+
})
|
|
131
|
+
|
|
132
|
+
// Strict first/last-frame mode (a first frame with NO references) does
|
|
133
|
+
// not number anything: frames ride `firstFrameUrl`/`lastFrameUrl` and
|
|
134
|
+
// the suffix is empty. The frame-ordinal invariant is reference-mode
|
|
135
|
+
// only, so assert the strict-mode contract and stop here.
|
|
136
|
+
if (r.mode !== "reference") {
|
|
137
|
+
expect(rs.refs.length).toBe(0)
|
|
138
|
+
expect(r.referenceImageUrls).toEqual([])
|
|
139
|
+
expect(r.promptSuffix).toBe("")
|
|
140
|
+
expect(r.firstFrameUrl).toBe(fs.first)
|
|
141
|
+
return
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
const userRefCount = rs.refs.length
|
|
145
|
+
const arr = r.referenceImageUrls
|
|
146
|
+
|
|
147
|
+
// 1-based position of each frame URL in the final array (0 = absent).
|
|
148
|
+
const firstPos = fs.first ? arr.indexOf(fs.first) + 1 : 0
|
|
149
|
+
const lastPos = fs.last ? arr.indexOf(fs.last) + 1 : 0
|
|
150
|
+
|
|
151
|
+
// Every frame URL present must actually be in the array.
|
|
152
|
+
if (fs.first) expect(firstPos).toBeGreaterThan(0)
|
|
153
|
+
if (fs.last) expect(lastPos).toBeGreaterThan(0)
|
|
154
|
+
|
|
155
|
+
// Frames sit strictly AFTER every user-ref slot (no collision).
|
|
156
|
+
if (firstPos) expect(firstPos).toBeGreaterThan(userRefCount)
|
|
157
|
+
if (lastPos) expect(lastPos).toBeGreaterThan(userRefCount)
|
|
158
|
+
|
|
159
|
+
// The suffix's `@image_N` ordinals are EXACTLY the array positions, in
|
|
160
|
+
// the order the suffix names them (first frame before last frame).
|
|
161
|
+
const expectedOrdinals: number[] = []
|
|
162
|
+
if (firstPos) expectedOrdinals.push(firstPos)
|
|
163
|
+
if (lastPos) expectedOrdinals.push(lastPos)
|
|
164
|
+
expect(ordinalsIn(r.promptSuffix)).toEqual(expectedOrdinals)
|
|
165
|
+
|
|
166
|
+
// User refs occupy the first `userRefCount` slots verbatim — frames
|
|
167
|
+
// never displace them.
|
|
168
|
+
expect(arr.slice(0, userRefCount)).toEqual(rs.refs)
|
|
169
|
+
})
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
})
|
|
173
|
+
})
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest"
|
|
2
|
+
import { PROVIDER_CAPABILITIES } from "../index.js"
|
|
3
|
+
import { EXTEND_VIDEO_PROVIDERS, MODEL_CATALOG, SEEDANCE_2_EXTEND_STITCH, buildVideoCreditModelIdentifier } from "@nodaro/shared"
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* seedance-2-extend: extend ANY video by URL — bare-template continuation
|
|
7
|
+
* generation + trim-stitch (spike-validated).
|
|
8
|
+
*/
|
|
9
|
+
describe("seedance-2-extend shared wiring", () => {
|
|
10
|
+
it("is a registered extend provider", () => {
|
|
11
|
+
expect(EXTEND_VIDEO_PROVIDERS).toContain("seedance-2-extend")
|
|
12
|
+
})
|
|
13
|
+
|
|
14
|
+
it("catalog entry drives the levers (extend mode, 4-15s, three resolutions)", () => {
|
|
15
|
+
const m = MODEL_CATALOG["seedance-2-extend"]!
|
|
16
|
+
expect(m.modes).toEqual(["extend"])
|
|
17
|
+
expect(m.family).toBe("Bytedance")
|
|
18
|
+
expect(m.durations).toEqual([4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15])
|
|
19
|
+
expect(m.resolutions).toEqual(["480p", "720p", "1080p"])
|
|
20
|
+
expect(m.pricing.length).toBeGreaterThanOrEqual(4)
|
|
21
|
+
expect(m.pricing[0]!.identifier).toBe("seedance-2-extend")
|
|
22
|
+
})
|
|
23
|
+
|
|
24
|
+
it("stitch constants match the spike-validated recipe (4 tail / 3 head / 0.15s fades)", () => {
|
|
25
|
+
expect(SEEDANCE_2_EXTEND_STITCH).toEqual({
|
|
26
|
+
trimTailFrames: 4,
|
|
27
|
+
trimHeadFrames: 3,
|
|
28
|
+
audioFadeSec: 0.15,
|
|
29
|
+
})
|
|
30
|
+
})
|
|
31
|
+
|
|
32
|
+
it("is advertised to the wizard's extend-video capabilities", () => {
|
|
33
|
+
expect(PROVIDER_CAPABILITIES["extend-video"]!["seedance-2-extend"]).toMatch(/ANY video/i)
|
|
34
|
+
})
|
|
35
|
+
|
|
36
|
+
it("credit identifiers snap to duration tiers with a :res suffix (no -ref dimension)", () => {
|
|
37
|
+
expect(buildVideoCreditModelIdentifier("seedance-2-extend", 6, undefined, undefined, undefined, "480p"))
|
|
38
|
+
.toBe("seedance-2-extend:8s:480p") // 6s snaps into the 8s tier
|
|
39
|
+
expect(buildVideoCreditModelIdentifier("seedance-2-extend", 4, undefined, undefined, undefined, "720p"))
|
|
40
|
+
.toBe("seedance-2-extend:4s:720p")
|
|
41
|
+
expect(buildVideoCreditModelIdentifier("seedance-2-extend", 15, undefined, undefined, undefined, "1080p"))
|
|
42
|
+
.toBe("seedance-2-extend:15s:1080p")
|
|
43
|
+
})
|
|
44
|
+
})
|