@nodaro/prompts 1.7.3 → 1.8.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/dist/index.cjs +5443 -4295
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +1202 -61
- package/dist/index.d.ts +1202 -61
- package/dist/index.js +5357 -4297
- package/dist/index.js.map +1 -1
- package/package.json +2 -2
- package/src/__tests__/b7-person-pack-e2e.test.ts +37 -0
- package/src/__tests__/catalog-funnel-ratchet.test.ts +97 -0
- package/src/__tests__/catalog-packs.test.ts +130 -0
- package/src/__tests__/catalog-sidecar-coverage.test.ts +27 -0
- package/src/__tests__/catalog-terms.test.ts +162 -0
- package/src/__tests__/character-default-role.test.ts +3 -2
- package/src/__tests__/content-free-contract.test.ts +45 -0
- package/src/__tests__/dod-replace-pack-acceptance.test.ts +41 -0
- package/src/__tests__/fixtures/parameter-hint-golden.json +2959 -0
- package/src/__tests__/fixtures/person-sector-pack.ts +27 -0
- package/src/__tests__/i18n-entry-completeness.test.ts +5 -2
- package/src/__tests__/parameter-hint-mode.test.ts +385 -0
- package/src/__tests__/parameter-prompt-hint-pack-fallback.test.ts +25 -0
- package/src/__tests__/person-packs.test.ts +166 -0
- package/src/__tests__/project-all-catalogs.test.ts +26 -0
- package/src/__tests__/prompt-builder.test.ts +53 -0
- package/src/__tests__/provider-prompt-doctrine.test.ts +18 -0
- package/src/__tests__/registered-catalogs-funnel.test.ts +30 -0
- package/src/__tests__/registered-catalogs-guard.test.ts +11 -0
- package/src/__tests__/term.test.ts +104 -0
- package/src/__tests__/transitions.test.ts +8 -5
- package/src/__tests__/upstream-immutability.test.ts +23 -0
- package/src/action-fx.ts +67 -17
- package/src/aesthetic.ts +54 -6
- package/src/atmosphere.ts +64 -23
- package/src/backdrop.ts +44 -30
- package/src/camera-format.ts +33 -11
- package/src/camera-motions.ts +89 -0
- package/src/catalog-packs.ts +125 -0
- package/src/catalog-sidecar-coverage.ts +36 -0
- package/src/character-fx.ts +112 -39
- package/src/color-look.ts +44 -27
- package/src/composition-effects.ts +21 -7
- package/src/era.ts +24 -0
- package/src/exposure-settings.ts +76 -18
- package/src/framing.ts +102 -10
- package/src/held-prop.ts +125 -63
- package/src/identity-lock.ts +12 -5
- package/src/image-reference-doctrine.ts +55 -0
- package/src/index.ts +5 -0
- package/src/instrumentation.ts +148 -57
- package/src/lens.ts +31 -15
- package/src/lighting.ts +120 -59
- package/src/loop-subject.ts +27 -1
- package/src/materials.ts +123 -69
- package/src/mood.ts +123 -51
- package/src/music-genre.ts +171 -67
- package/src/music-mood.ts +85 -17
- package/src/parameter-prompt-hint.ts +168 -56
- package/src/person-packs.ts +182 -0
- package/src/person.ts +506 -408
- package/src/photo-genre.ts +37 -22
- package/src/photographer.ts +138 -1
- package/src/picker-catalogs.ts +86 -40
- package/src/pose.ts +105 -40
- package/src/post-process-effects.ts +51 -8
- package/src/prompt-builder.ts +16 -1
- package/src/provider-prompt-doctrine.ts +12 -5
- package/src/render-quality.ts +25 -7
- package/src/setting.ts +30 -14
- package/src/style.ts +33 -15
- package/src/styling.ts +155 -94
- package/src/temporal.ts +80 -18
- package/src/term.ts +155 -0
- package/src/transitions.ts +73 -26
- package/src/voice-character.ts +190 -90
- package/src/voice-delivery.ts +83 -12
|
@@ -1771,3 +1771,56 @@ describe("buildIdentityDirectives — locationReferencePhotoKind subject-line an
|
|
|
1771
1771
|
})
|
|
1772
1772
|
})
|
|
1773
1773
|
|
|
1774
|
+
|
|
1775
|
+
describe("the provider reference cap slices by ATTACH priority, not raw order", () => {
|
|
1776
|
+
// Incident 2026-08-25 ("two characters wired, one sent"): the raw candidate
|
|
1777
|
+
// list interleaves each character's canonical with its whole variant array,
|
|
1778
|
+
// and a raw slice at the provider cap (nano-banana-pro = 8) let one
|
|
1779
|
+
// variant-rich character evict the character wired after it ENTIRELY —
|
|
1780
|
+
// even though unmentioned variants never attach and the provider was sent
|
|
1781
|
+
// two URLs total.
|
|
1782
|
+
const characterRefs = (slug: string, name: string, url: string, variantCount: number) => [
|
|
1783
|
+
{
|
|
1784
|
+
id: `${slug}_canonical`, defaultName: name, source: "wired-character" as const, url,
|
|
1785
|
+
characterSlug: slug, variantSlug: undefined, characterCanonicalDescription: `about ${name}`,
|
|
1786
|
+
variantDescription: null, variantDisplayName: "canonical",
|
|
1787
|
+
},
|
|
1788
|
+
...Array.from({ length: variantCount }, (_, i) => ({
|
|
1789
|
+
id: `${slug}_v${i}`, defaultName: `${name} / v${i}`, source: "wired-character" as const,
|
|
1790
|
+
url: `${url}?v=${i}`, characterSlug: slug, variantSlug: `v${i}`,
|
|
1791
|
+
characterCanonicalDescription: `about ${name}`, variantDescription: null, variantDisplayName: `v${i}`,
|
|
1792
|
+
})),
|
|
1793
|
+
]
|
|
1794
|
+
|
|
1795
|
+
it("a variant-rich first character no longer evicts the second", () => {
|
|
1796
|
+
const refs = [
|
|
1797
|
+
...characterRefs("ava", "Ava", "https://cdn.test/ava.png", 9),
|
|
1798
|
+
...characterRefs("ben", "Ben", "https://cdn.test/ben.png", 0),
|
|
1799
|
+
]
|
|
1800
|
+
for (const referenceFormat of ["hybrid", "legacy"] as const) {
|
|
1801
|
+
const r = buildImagePrompt({
|
|
1802
|
+
prompt: "two people at the beach.",
|
|
1803
|
+
provider: "nano-banana-pro",
|
|
1804
|
+
referenceFormat,
|
|
1805
|
+
connectedReferences: refs as never,
|
|
1806
|
+
})
|
|
1807
|
+
expect(r.referenceImageUrls, referenceFormat).toEqual(
|
|
1808
|
+
expect.arrayContaining(["https://cdn.test/ava.png", "https://cdn.test/ben.png"]),
|
|
1809
|
+
)
|
|
1810
|
+
}
|
|
1811
|
+
})
|
|
1812
|
+
|
|
1813
|
+
it("the cap itself still holds when canonicals alone exceed it", () => {
|
|
1814
|
+
const refs = Array.from({ length: 10 }, (_, i) =>
|
|
1815
|
+
characterRefs(`c${i}`, `C${i}`, `https://cdn.test/c${i}.png`, 0),
|
|
1816
|
+
).flat()
|
|
1817
|
+
const r = buildImagePrompt({
|
|
1818
|
+
prompt: "a crowd.",
|
|
1819
|
+
provider: "nano-banana-pro",
|
|
1820
|
+
referenceFormat: "hybrid",
|
|
1821
|
+
connectedReferences: refs as never,
|
|
1822
|
+
})
|
|
1823
|
+
expect((r.referenceImageUrls ?? []).length).toBeLessThanOrEqual(8)
|
|
1824
|
+
expect((r.referenceImageUrls ?? []).length).toBe(8)
|
|
1825
|
+
})
|
|
1826
|
+
})
|
|
@@ -48,4 +48,22 @@ describe("PROVIDER_PROMPT_DOCTRINES", () => {
|
|
|
48
48
|
// no native negative-prompt param
|
|
49
49
|
expect(d.doctrine).toMatch(/negative/i)
|
|
50
50
|
})
|
|
51
|
+
|
|
52
|
+
it("splits the timestamp + multi-view rules by version: 2.0 SKUs no, seedance-2-5 yes (official 2.5 guide)", () => {
|
|
53
|
+
// Official "Dreamina Seedance 2.5 prompt guide" → Differences from Seedance 2.0:
|
|
54
|
+
// 2.0 does not respond to timestamps (shot numbers only) and multi-view
|
|
55
|
+
// subject images are not recommended; 2.5 supports integer-second
|
|
56
|
+
// timestamps and multi-view references. The doctrine is one group for the
|
|
57
|
+
// whole family, so BOTH halves must be stated — a rewrite that drops
|
|
58
|
+
// either side re-applies a 2.0 rule to 2.5 (or vice versa).
|
|
59
|
+
const d = getPromptDoctrine("seedance-2-5")!
|
|
60
|
+
expect(d.providers).toContain("seedance-2")
|
|
61
|
+
expect(d.doctrine).toMatch(/2\.0[^.]*(ignore|do not respond to|respond to shot numbers only)[^.]*timestamps|timestamps[^.]*2\.0/i)
|
|
62
|
+
expect(d.doctrine).toMatch(/seedance-2-5[^.]*integer-second timestamps|integer-second timestamps[^.]*2\.5/i)
|
|
63
|
+
expect(d.doctrine).toMatch(/At the 5-second mark/)
|
|
64
|
+
expect(d.doctrine).toMatch(/2\.5 accepts multi-view|supported on 2\.5/i)
|
|
65
|
+
const tips = getPromptTips("seedance-2-5").join(" ")
|
|
66
|
+
expect(tips).toMatch(/2\.0 SKUs ignore timestamps/)
|
|
67
|
+
expect(tips).toMatch(/seedance-2-5 honours integer-second timestamps/)
|
|
68
|
+
})
|
|
51
69
|
})
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach } from "vitest"
|
|
2
|
+
import {
|
|
3
|
+
PICKER_CATALOGS, getRegisteredPickerCatalogs, getPickerCatalog, listPickerCatalogs,
|
|
4
|
+
} from "../picker-catalogs.js"
|
|
5
|
+
import { registerCatalogPack, resetCatalogPacks } from "../catalog-packs.js"
|
|
6
|
+
|
|
7
|
+
beforeEach(() => resetCatalogPacks())
|
|
8
|
+
|
|
9
|
+
describe("registered read funnel", () => {
|
|
10
|
+
it("with no packs, registered set equals the frozen base (inert default)", () => {
|
|
11
|
+
expect(getRegisteredPickerCatalogs()).toEqual(PICKER_CATALOGS)
|
|
12
|
+
expect(Object.isFrozen(PICKER_CATALOGS)).toBe(true)
|
|
13
|
+
})
|
|
14
|
+
|
|
15
|
+
it("late registration is reflected after the memo invalidates by version", () => {
|
|
16
|
+
const before = getPickerCatalog("setting")!.options!.map((o) => o.id)
|
|
17
|
+
registerCatalogPack({ id: "sai/setting-extend", catalogId: "setting", mode: "extend",
|
|
18
|
+
options: [{ id: "shul-hall", label: "Shul Hall", promptHint: "in a synagogue hall", term: "shul hall" }] })
|
|
19
|
+
const after = getPickerCatalog("setting")!.options!.map((o) => o.id)
|
|
20
|
+
expect(after).toEqual([...before, "shul-hall"])
|
|
21
|
+
// base is untouched
|
|
22
|
+
expect(PICKER_CATALOGS.find((c) => c.catalogId === "setting")!.options!.some((o) => o.id === "shul-hall")).toBe(false)
|
|
23
|
+
})
|
|
24
|
+
|
|
25
|
+
it("listPickerCatalogs reads the registered set", () => {
|
|
26
|
+
registerCatalogPack({ id: "sai/deny", catalogId: "setting", mode: "deny", denyIds: ["forest"] })
|
|
27
|
+
const setting = listPickerCatalogs().find((c) => c.catalogId === "setting")!
|
|
28
|
+
expect(setting.options!.some((o) => o.id === "forest")).toBe(false)
|
|
29
|
+
})
|
|
30
|
+
})
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest"
|
|
2
|
+
import { getRegisteredPickerCatalogs, getPickerCatalog, projectPickerCatalog } from "../picker-catalogs.js"
|
|
3
|
+
|
|
4
|
+
describe("every registered catalog is resolvable + projectable", () => {
|
|
5
|
+
it.each(getRegisteredPickerCatalogs().map((c) => [c.catalogId] as const))("%s resolves and projects", (id) => {
|
|
6
|
+
const c = getPickerCatalog(id)
|
|
7
|
+
expect(c, `getPickerCatalog("${id}") returned undefined`).toBeTruthy()
|
|
8
|
+
const projected = projectPickerCatalog(c!, { detail: "compact" })
|
|
9
|
+
expect(projected.catalogId).toBe(id)
|
|
10
|
+
})
|
|
11
|
+
})
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest"
|
|
2
|
+
import {
|
|
3
|
+
TERM_MAX_CHARS,
|
|
4
|
+
deriveTerm,
|
|
5
|
+
isSuspiciousDerivedTerm,
|
|
6
|
+
resolveTerm,
|
|
7
|
+
type TermCarrier,
|
|
8
|
+
} from "../term.js"
|
|
9
|
+
|
|
10
|
+
const carrier = (over: Partial<TermCarrier> = {}): TermCarrier => ({
|
|
11
|
+
id: "x",
|
|
12
|
+
label: "Whip Pan Left",
|
|
13
|
+
promptHint: "the camera whips left in a single blurred sweep",
|
|
14
|
+
...over,
|
|
15
|
+
})
|
|
16
|
+
|
|
17
|
+
describe("TERM_MAX_CHARS", () => {
|
|
18
|
+
it("is the documented cap", () => {
|
|
19
|
+
expect(TERM_MAX_CHARS).toBe(60)
|
|
20
|
+
})
|
|
21
|
+
})
|
|
22
|
+
|
|
23
|
+
describe("deriveTerm", () => {
|
|
24
|
+
it("lowercases the label", () => {
|
|
25
|
+
expect(deriveTerm("Medium Close-Up")).toBe("medium close-up")
|
|
26
|
+
})
|
|
27
|
+
|
|
28
|
+
it("removes parenthetical segments", () => {
|
|
29
|
+
expect(deriveTerm("Ultra-wide (14mm)")).toBe("ultra-wide")
|
|
30
|
+
expect(deriveTerm("ISO 1600 (visible grain)")).toBe("iso 1600")
|
|
31
|
+
expect(deriveTerm("(only) parenthetical")).toBe("parenthetical")
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
it("collapses whitespace and trims", () => {
|
|
35
|
+
expect(deriveTerm(" Hard Cut \n")).toBe("hard cut")
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
it("does NOT split on '/' — ambiguity must surface, not be guessed", () => {
|
|
39
|
+
expect(deriveTerm("None / Hard Cut")).toBe("none / hard cut")
|
|
40
|
+
expect(deriveTerm("Fog / Mist")).toBe("fog / mist")
|
|
41
|
+
})
|
|
42
|
+
|
|
43
|
+
it("yields an empty string for an empty or purely parenthetical label", () => {
|
|
44
|
+
expect(deriveTerm("")).toBe("")
|
|
45
|
+
expect(deriveTerm(" ")).toBe("")
|
|
46
|
+
expect(deriveTerm("(auto)")).toBe("")
|
|
47
|
+
})
|
|
48
|
+
})
|
|
49
|
+
|
|
50
|
+
describe("isSuspiciousDerivedTerm", () => {
|
|
51
|
+
it("accepts a clean multi-word label", () => {
|
|
52
|
+
expect(isSuspiciousDerivedTerm("Whip Pan Left")).toBe(false)
|
|
53
|
+
expect(isSuspiciousDerivedTerm("Medium Close-Up")).toBe(false)
|
|
54
|
+
})
|
|
55
|
+
|
|
56
|
+
it.each(["None / Hard Cut", "Ultra-wide (14mm)", "Key: Rembrandt", "Black & White", "Start → End", "2× Speed"])(
|
|
57
|
+
"flags the UI-compound / annotated label %s",
|
|
58
|
+
(label) => {
|
|
59
|
+
expect(isSuspiciousDerivedTerm(label)).toBe(true)
|
|
60
|
+
},
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
it("flags a label that derives to nothing", () => {
|
|
64
|
+
expect(isSuspiciousDerivedTerm("")).toBe(true)
|
|
65
|
+
expect(isSuspiciousDerivedTerm(" ")).toBe(true)
|
|
66
|
+
})
|
|
67
|
+
|
|
68
|
+
it("flags a bare word only under bareWordSuspicious", () => {
|
|
69
|
+
expect(isSuspiciousDerivedTerm("Warm")).toBe(false)
|
|
70
|
+
expect(isSuspiciousDerivedTerm("Warm", { bareWordSuspicious: true })).toBe(true)
|
|
71
|
+
expect(isSuspiciousDerivedTerm("Short", { bareWordSuspicious: true })).toBe(true)
|
|
72
|
+
})
|
|
73
|
+
|
|
74
|
+
it("does not flag a multi-word label under bareWordSuspicious", () => {
|
|
75
|
+
expect(isSuspiciousDerivedTerm("Warm Tungsten", { bareWordSuspicious: true })).toBe(false)
|
|
76
|
+
})
|
|
77
|
+
})
|
|
78
|
+
|
|
79
|
+
describe("resolveTerm", () => {
|
|
80
|
+
it("returns '' for a missing entry", () => {
|
|
81
|
+
expect(resolveTerm(undefined)).toBe("")
|
|
82
|
+
expect(resolveTerm(null)).toBe("")
|
|
83
|
+
})
|
|
84
|
+
|
|
85
|
+
it("returns '' for a no-op entry whose promptHint is empty (auto / none)", () => {
|
|
86
|
+
expect(resolveTerm(carrier({ id: "auto", label: "Auto", promptHint: "" }))).toBe("")
|
|
87
|
+
// …even when a term was authored on it: an entry that injects nothing has
|
|
88
|
+
// nothing to inject in compact mode either.
|
|
89
|
+
expect(resolveTerm(carrier({ id: "auto", label: "Auto", promptHint: "", term: "auto" }))).toBe("")
|
|
90
|
+
})
|
|
91
|
+
|
|
92
|
+
it("prefers an explicit authored term", () => {
|
|
93
|
+
expect(resolveTerm(carrier({ label: "None / Hard Cut", term: "hard cut" }))).toBe("hard cut")
|
|
94
|
+
})
|
|
95
|
+
|
|
96
|
+
it("falls back to the derived label when no term is authored", () => {
|
|
97
|
+
expect(resolveTerm(carrier({ label: "Whip Pan Left" }))).toBe("whip pan left")
|
|
98
|
+
expect(resolveTerm(carrier({ label: "Ultra-wide (14mm)" }))).toBe("ultra-wide")
|
|
99
|
+
})
|
|
100
|
+
|
|
101
|
+
it("treats an empty authored term as authored (does not fall back to the label)", () => {
|
|
102
|
+
expect(resolveTerm(carrier({ label: "Whip Pan Left", term: "" }))).toBe("")
|
|
103
|
+
})
|
|
104
|
+
})
|
|
@@ -2,9 +2,9 @@ import { describe, expect, it } from "vitest"
|
|
|
2
2
|
import { TRANSITIONS, TRANSITION_IDS, TRANSITION_CATEGORY_ORDER, TRANSITION_CATEGORY_LABELS, composeTransitionHintFromConnections, getTransition, getTransitionLabel, getTransitionPromptHint } from "../transitions.js"
|
|
3
3
|
|
|
4
4
|
describe("transitions catalog", () => {
|
|
5
|
-
it("ships
|
|
6
|
-
expect(TRANSITIONS).toHaveLength(
|
|
7
|
-
expect(new Set(TRANSITION_IDS).size).toBe(
|
|
5
|
+
it("ships 82 unique entries", () => {
|
|
6
|
+
expect(TRANSITIONS).toHaveLength(82)
|
|
7
|
+
expect(new Set(TRANSITION_IDS).size).toBe(82)
|
|
8
8
|
})
|
|
9
9
|
|
|
10
10
|
it("every entry has a non-empty id, label, and description", () => {
|
|
@@ -20,9 +20,12 @@ describe("transitions catalog", () => {
|
|
|
20
20
|
for (const t of TRANSITIONS) {
|
|
21
21
|
counts[t.category] = (counts[t.category] ?? 0) + 1
|
|
22
22
|
}
|
|
23
|
+
// `snap-to-black` is filed under STANDARD, beside `fade-to-black`: it is a
|
|
24
|
+
// cut, and the catalog groups by editing technique, not by what the frame
|
|
25
|
+
// happens to do (which would have put it in LIGHT next to `white-flash`).
|
|
23
26
|
expect(counts).toEqual({
|
|
24
|
-
standard:
|
|
25
|
-
portal:
|
|
27
|
+
standard: 14, time: 8, element: 14, morph: 9,
|
|
28
|
+
portal: 12, physics: 10, light: 8, glitch: 7,
|
|
26
29
|
})
|
|
27
30
|
})
|
|
28
31
|
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
// The convergence invariant, in-package form: registering a pack NEVER mutates
|
|
2
|
+
// an upstream/base catalog array (curation is additive-by-registration, never
|
|
3
|
+
// an in-place edit of an upstream file's exported data).
|
|
4
|
+
import { describe, it, expect, beforeEach } from "vitest"
|
|
5
|
+
import { PICKER_CATALOGS } from "../picker-catalogs.js"
|
|
6
|
+
import { registerCatalogPack, resetCatalogPacks } from "../catalog-packs.js"
|
|
7
|
+
|
|
8
|
+
beforeEach(() => resetCatalogPacks())
|
|
9
|
+
|
|
10
|
+
describe("packs never edit upstream in place", () => {
|
|
11
|
+
it("base PICKER_CATALOGS is frozen and unchanged after replace/extend/deny", () => {
|
|
12
|
+
const snapshot = JSON.stringify(PICKER_CATALOGS)
|
|
13
|
+
expect(Object.isFrozen(PICKER_CATALOGS)).toBe(true)
|
|
14
|
+
registerCatalogPack({ id: "x", catalogId: "setting", mode: "deny", denyIds: ["forest"] })
|
|
15
|
+
registerCatalogPack({
|
|
16
|
+
id: "y",
|
|
17
|
+
catalogId: "mood",
|
|
18
|
+
mode: "extend",
|
|
19
|
+
options: [{ id: "z", label: "Z", promptHint: "z", term: "z" }],
|
|
20
|
+
})
|
|
21
|
+
expect(JSON.stringify(PICKER_CATALOGS)).toBe(snapshot)
|
|
22
|
+
})
|
|
23
|
+
})
|
package/src/action-fx.ts
CHANGED
|
@@ -17,6 +17,8 @@
|
|
|
17
17
|
* frontend DAG executor + backend orchestrator.
|
|
18
18
|
*/
|
|
19
19
|
|
|
20
|
+
import { resolveTerm, type PickerHintMode } from "./term.js"
|
|
21
|
+
|
|
20
22
|
export type ActionFxCategory =
|
|
21
23
|
| "disaster"
|
|
22
24
|
| "fire-blasts"
|
|
@@ -31,6 +33,8 @@ export interface ActionFx {
|
|
|
31
33
|
readonly category: ActionFxCategory
|
|
32
34
|
readonly description: string
|
|
33
35
|
readonly promptHint: string
|
|
36
|
+
/** Optional authored compact term (see `term.ts` for the convention). */
|
|
37
|
+
readonly term?: string
|
|
34
38
|
}
|
|
35
39
|
|
|
36
40
|
export const ACTION_FX_CATEGORY_ORDER: ReadonlyArray<ActionFxCategory> = [
|
|
@@ -70,7 +74,8 @@ export const ACTION_FX: ReadonlyArray<ActionFx> = [
|
|
|
70
74
|
{ id: "sandstorm", category: "disaster", label: "Sandstorm", description: "Wall of orange dust engulfing scene",
|
|
71
75
|
promptHint: "a towering sandstorm wall of orange-brown dust engulfing the scene, swirling fine particles reducing visibility to feet and burying surfaces under shifting waves of grit" },
|
|
72
76
|
{ id: "dust-storm-haboob", category: "disaster", label: "Dust Storm (Haboob)", description: "Towering desert dust front",
|
|
73
|
-
promptHint: "a towering haboob dust storm rolling across the scene, mountainous wall of dense ochre dust front billowing forward and swallowing the horizon in a dramatic monolithic curtain"
|
|
77
|
+
promptHint: "a towering haboob dust storm rolling across the scene, mountainous wall of dense ochre dust front billowing forward and swallowing the horizon in a dramatic monolithic curtain",
|
|
78
|
+
term: "haboob dust storm" },
|
|
74
79
|
{ id: "wildfire-distant", category: "disaster", label: "Distant Wildfire", description: "Orange glow + smoke on horizon",
|
|
75
80
|
promptHint: "a distant wildfire raging on the horizon, orange-red glow lighting up dark plumes of smoke billowing into a hazy sky with embers visible at the treeline" },
|
|
76
81
|
{ id: "wildfire-engulfing", category: "disaster", label: "Engulfing Wildfire", description: "Flames closing in, intense heat shimmer",
|
|
@@ -80,7 +85,8 @@ export const ACTION_FX: ReadonlyArray<ActionFx> = [
|
|
|
80
85
|
{ id: "lava-flow", category: "disaster", label: "Lava Flow", description: "Glowing molten river creeping across ground",
|
|
81
86
|
promptHint: "a glowing molten lava flow creeping across the ground, bright orange-red rivers of fire snaking through cooled black crust with shimmering heat distortion above and wisps of sulfurous smoke" },
|
|
82
87
|
{ id: "ash-rain", category: "disaster", label: "Falling Ash Rain", description: "Apocalyptic grey ash falling like snow",
|
|
83
|
-
promptHint: "thick grey volcanic ash falling like dirty snow across the scene, apocalyptic post-eruption atmosphere with fine flakes settling on every surface and a hazy desaturated sky"
|
|
88
|
+
promptHint: "thick grey volcanic ash falling like dirty snow across the scene, apocalyptic post-eruption atmosphere with fine flakes settling on every surface and a hazy desaturated sky",
|
|
89
|
+
term: "falling volcanic ash" },
|
|
84
90
|
{ id: "avalanche", category: "disaster", label: "Avalanche", description: "Wall of snow tumbling down mountainside",
|
|
85
91
|
promptHint: "a massive avalanche tumbling down a mountainside, wall of churning snow and ice cascading at speed with a billowing powder cloud rolling forward and crushing everything in its path" },
|
|
86
92
|
{ id: "hailstorm", category: "disaster", label: "Hailstorm", description: "Large hailstones bouncing off surfaces",
|
|
@@ -139,15 +145,18 @@ export const ACTION_FX: ReadonlyArray<ActionFx> = [
|
|
|
139
145
|
{ id: "bullet-trail", category: "combat", label: "Bullet Trail", description: "Visible bullet streak through air",
|
|
140
146
|
promptHint: "a streaking bullet trail cutting through the air, thin bright tracer line accompanied by faint vapor wake suggesting hypersonic velocity and a sense of forward motion through the frame" },
|
|
141
147
|
{ id: "sword-spark", category: "combat", label: "Sword Spark", description: "Macro shower of metal-on-metal friction sparks",
|
|
142
|
-
promptHint: "a macro close-up of bright sparks at the moment of metal-on-metal friction, white-hot incandescent flecks scattering radially with brief streaks of friction-light, captured at extreme close range with no swords or blades visible in the frame"
|
|
148
|
+
promptHint: "a macro close-up of bright sparks at the moment of metal-on-metal friction, white-hot incandescent flecks scattering radially with brief streaks of friction-light, captured at extreme close range with no swords or blades visible in the frame",
|
|
149
|
+
term: "metal-on-metal friction sparks" },
|
|
143
150
|
{ id: "blade-clash", category: "combat", label: "Blade Clash", description: "Two blades meeting with impact wave",
|
|
144
151
|
promptHint: "two blades clashing together at the moment of impact, sparks flying outward from the contact line and a brief shockwave of displaced air rippling around the steel edges" },
|
|
145
152
|
{ id: "ricochet-spark", category: "combat", label: "Ricochet Spark", description: "Bullet bouncing off metal with sparks",
|
|
146
153
|
promptHint: "a bullet ricocheting off a metal surface, sharp burst of orange-white sparks at the deflection point with the bullet visibly trailing off at a new angle and a thin streak of vapor in its wake" },
|
|
147
154
|
{ id: "debris-field", category: "combat", label: "Debris Field", description: "Frozen mid-air shrapnel scattering",
|
|
148
|
-
promptHint: "a debris field frozen in mid-air, fragments of wood, metal, and concrete scattering outward in a radial pattern from a central impact point with dust and smoke swirling among the larger pieces"
|
|
155
|
+
promptHint: "a debris field frozen in mid-air, fragments of wood, metal, and concrete scattering outward in a radial pattern from a central impact point with dust and smoke swirling among the larger pieces",
|
|
156
|
+
term: "debris frozen in mid-air" },
|
|
149
157
|
{ id: "glass-shatter-airborne", category: "combat", label: "Airborne Glass Shatter", description: "Glass exploding outward in mid-air shards",
|
|
150
|
-
promptHint: "a window or glass surface shattering outward in mid-air, hundreds of jagged crystalline shards exploding into the scene catching the light and tumbling in suspended chaos at the moment of impact"
|
|
158
|
+
promptHint: "a window or glass surface shattering outward in mid-air, hundreds of jagged crystalline shards exploding into the scene catching the light and tumbling in suspended chaos at the moment of impact",
|
|
159
|
+
term: "glass shattering in mid-air" },
|
|
151
160
|
{ id: "shockwave-ground", category: "combat", label: "Ground Shockwave", description: "Visible expanding ring at ground level",
|
|
152
161
|
promptHint: "a visible shockwave expanding at ground level, dust and small debris pushed outward in a clear radial ring from the impact origin with a momentary distortion in the air above" },
|
|
153
162
|
{ id: "sonic-boom", category: "combat", label: "Sonic Boom", description: "Cone of compressed air at supersonic speed",
|
|
@@ -159,7 +168,8 @@ export const ACTION_FX: ReadonlyArray<ActionFx> = [
|
|
|
159
168
|
{ id: "blood-spray", category: "combat", label: "Blood Spray", description: "Cinematic arc of blood droplets",
|
|
160
169
|
promptHint: "a cinematic arc of blood droplets spraying outward in slow-motion, suspended red flecks tracing a curved path through the air with the dramatic stylized aesthetic of action cinema" },
|
|
161
170
|
{ id: "arrow-hit-spark", category: "combat", label: "Arrow Hit Spark", description: "Arrow striking with small sparks at impact",
|
|
162
|
-
promptHint: "an arrow striking a surface with a small burst of sparks at the impact point, shaft quivering on contact and tiny incandescent flecks scattering outward from the arrowhead"
|
|
171
|
+
promptHint: "an arrow striking a surface with a small burst of sparks at the impact point, shaft quivering on contact and tiny incandescent flecks scattering outward from the arrowhead",
|
|
172
|
+
term: "arrow impact sparks" },
|
|
163
173
|
// ── Sci-Fi ──
|
|
164
174
|
{ id: "laser-blast", category: "sci-fi", label: "Laser Blast", description: "Bright coherent beam of energy",
|
|
165
175
|
promptHint: "a bright coherent laser blast cutting through the scene, vivid red or green beam of focused energy with a glowing core and a halo of scattered light along its path" },
|
|
@@ -172,9 +182,11 @@ export const ACTION_FX: ReadonlyArray<ActionFx> = [
|
|
|
172
182
|
{ id: "force-field-impact", category: "sci-fi", label: "Force Field Impact", description: "Visible ripple where projectile hits shield",
|
|
173
183
|
promptHint: "a force field absorbing an impact, visible ripple spreading outward from the strike point with hexagonal energy panels glowing brightly at the contact site and discharge arcs branching across the surface" },
|
|
174
184
|
{ id: "portal-opening", category: "sci-fi", label: "Portal Opening", description: "Swirling vortex of energy tearing open in space",
|
|
175
|
-
promptHint: "a sci-fi portal tearing open in space, swirling vortex of bluish energy with edges flickering between dimensions and a glowing event horizon framing a glimpse of the destination beyond"
|
|
185
|
+
promptHint: "a sci-fi portal tearing open in space, swirling vortex of bluish energy with edges flickering between dimensions and a glowing event horizon framing a glimpse of the destination beyond",
|
|
186
|
+
term: "sci-fi portal opening" },
|
|
176
187
|
{ id: "warp-distortion", category: "sci-fi", label: "Warp Distortion", description: "Spacetime bending around an object",
|
|
177
|
-
promptHint: "a spacetime warp distortion bending the air around an object, light streaks curving inward and the background visibly stretched and refracted in the gravitational well of the effect"
|
|
188
|
+
promptHint: "a spacetime warp distortion bending the air around an object, light streaks curving inward and the background visibly stretched and refracted in the gravitational well of the effect",
|
|
189
|
+
term: "spacetime warp distortion" },
|
|
178
190
|
{ id: "hologram-flicker", category: "sci-fi", label: "Hologram Flicker", description: "Translucent projection glitching",
|
|
179
191
|
promptHint: "a translucent holographic projection flickering and glitching in the scene, scanlines and chromatic noise distorting the cyan-blue figure with brief signal dropouts revealing the air behind it" },
|
|
180
192
|
{ id: "ion-storm", category: "sci-fi", label: "Ion Storm", description: "Crackling field of charged particles against a cosmic backdrop",
|
|
@@ -202,7 +214,8 @@ export const ACTION_FX: ReadonlyArray<ActionFx> = [
|
|
|
202
214
|
{ id: "dark-vortex", category: "magic", label: "Dark Vortex", description: "Ominous black-purple swirling void",
|
|
203
215
|
promptHint: "an ominous dark vortex swirling in the scene, churning black-and-purple void with tendrils of shadow energy reaching outward and a sense of consuming power drawing light into its center" },
|
|
204
216
|
{ id: "light-explosion", category: "magic", label: "Light Explosion", description: "Burst of pure white-gold radiance",
|
|
205
|
-
promptHint: "a brilliant explosion of pure white-gold radiance, divine burst of light radiating outward with rays of beam-light fanning across the scene and motes of golden particles suspended in the afterglow"
|
|
217
|
+
promptHint: "a brilliant explosion of pure white-gold radiance, divine burst of light radiating outward with rays of beam-light fanning across the scene and motes of golden particles suspended in the afterglow",
|
|
218
|
+
term: "explosion of white-gold light" },
|
|
206
219
|
] as const
|
|
207
220
|
|
|
208
221
|
export const ACTION_FX_IDS: ReadonlyArray<string> = ACTION_FX.map((fx) => fx.id)
|
|
@@ -226,15 +239,25 @@ export function getActionFxPromptHint(id: string | undefined | null): string {
|
|
|
226
239
|
}
|
|
227
240
|
|
|
228
241
|
/**
|
|
229
|
-
*
|
|
242
|
+
* Compact professional TERM for an id — the short phrase a VFX supervisor
|
|
243
|
+
* would write in a prompt ("haboob dust storm", "metal-on-metal friction
|
|
244
|
+
* sparks"), as opposed to the paragraph-length `promptHint`.
|
|
230
245
|
*
|
|
231
|
-
*
|
|
232
|
-
*
|
|
233
|
-
*
|
|
234
|
-
* cap, but the cap here keeps the contract robust against stale workflow
|
|
235
|
-
* data. Duplicate ids are deduplicated before the cap is applied.
|
|
246
|
+
* Same lookup and same empty-string-on-miss behavior as
|
|
247
|
+
* `getActionFxPromptHint`, so the two can never disagree about which entry
|
|
248
|
+
* they describe.
|
|
236
249
|
*/
|
|
237
|
-
export function
|
|
250
|
+
export function getActionFxTerm(id: string | undefined | null): string {
|
|
251
|
+
return resolveTerm(getActionFx(id))
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
/**
|
|
255
|
+
* The multi-pick id list both builders below read: a lone string, or the first
|
|
256
|
+
* 2 distinct strings of an array (silently dropping the rest). The picker UI
|
|
257
|
+
* enforces the same cap; keeping it here too holds the contract against stale
|
|
258
|
+
* workflow data.
|
|
259
|
+
*/
|
|
260
|
+
function pickActionFxIds(value: unknown): string[] {
|
|
238
261
|
const ids: string[] = []
|
|
239
262
|
if (typeof value === "string" && value) {
|
|
240
263
|
ids.push(value)
|
|
@@ -246,10 +269,37 @@ export function buildActionFxHints(value: unknown): string[] {
|
|
|
246
269
|
}
|
|
247
270
|
}
|
|
248
271
|
}
|
|
272
|
+
return ids
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
/**
|
|
276
|
+
* Multi-pick: 1–2 ids → composite FX clause.
|
|
277
|
+
*
|
|
278
|
+
* Single id → entry's own promptHint as a single-element array.
|
|
279
|
+
* Two ids → emit each independently and let the comma-join compose them.
|
|
280
|
+
* Caps at 2 ids (silently drops the rest); the picker UI also enforces this
|
|
281
|
+
* cap, but the cap here keeps the contract robust against stale workflow
|
|
282
|
+
* data. Duplicate ids are deduplicated before the cap is applied.
|
|
283
|
+
*/
|
|
284
|
+
export function buildActionFxHints(value: unknown, mode: PickerHintMode = "full"): string[] {
|
|
285
|
+
if (mode === "compact") return buildActionFxTerms(value)
|
|
249
286
|
const hints: string[] = []
|
|
250
|
-
for (const id of
|
|
287
|
+
for (const id of pickActionFxIds(value)) {
|
|
251
288
|
const hint = getActionFxPromptHint(id)
|
|
252
289
|
if (hint) hints.push(hint)
|
|
253
290
|
}
|
|
254
291
|
return hints
|
|
255
292
|
}
|
|
293
|
+
|
|
294
|
+
/**
|
|
295
|
+
* Compact-mode mirror of `buildActionFxHints`: the same 1–2 ids, resolved to
|
|
296
|
+
* their short professional terms instead of their paragraph-length hints.
|
|
297
|
+
*/
|
|
298
|
+
export function buildActionFxTerms(value: unknown): string[] {
|
|
299
|
+
const terms: string[] = []
|
|
300
|
+
for (const id of pickActionFxIds(value)) {
|
|
301
|
+
const term = getActionFxTerm(id)
|
|
302
|
+
if (term) terms.push(term)
|
|
303
|
+
}
|
|
304
|
+
return terms
|
|
305
|
+
}
|
package/src/aesthetic.ts
CHANGED
|
@@ -19,6 +19,8 @@
|
|
|
19
19
|
* backend orchestrator.
|
|
20
20
|
*/
|
|
21
21
|
|
|
22
|
+
import { resolveTerm, type PickerHintMode } from "./term.js"
|
|
23
|
+
|
|
22
24
|
export type AestheticCategory = "mainstream" | "niche" | "era" | "mood"
|
|
23
25
|
|
|
24
26
|
export interface Aesthetic {
|
|
@@ -27,6 +29,13 @@ export interface Aesthetic {
|
|
|
27
29
|
readonly category: AestheticCategory
|
|
28
30
|
readonly description: string
|
|
29
31
|
readonly promptHint: string
|
|
32
|
+
/**
|
|
33
|
+
* Compact professional term (see `term.ts`). Authored only where the
|
|
34
|
+
* lowercased label is not the phrase a stylist would write in a prompt —
|
|
35
|
+
* UI compounds ("Techwear / Gorpcore") and the fashion-era labels whose
|
|
36
|
+
* bare nouns name an art movement rather than the look.
|
|
37
|
+
*/
|
|
38
|
+
readonly term?: string
|
|
30
39
|
}
|
|
31
40
|
|
|
32
41
|
export const AESTHETICS: ReadonlyArray<Aesthetic> = [
|
|
@@ -70,6 +79,7 @@ export const AESTHETICS: ReadonlyArray<Aesthetic> = [
|
|
|
70
79
|
description: "Tactical urban performance gear",
|
|
71
80
|
promptHint:
|
|
72
81
|
"in the techwear-gorpcore aesthetic — black tactical shells, MOLLE webbing, cargo pants and chunky trail runners in a rain-slick neon city, with cold cyan-magenta color",
|
|
82
|
+
term: "techwear gorpcore",
|
|
73
83
|
},
|
|
74
84
|
{
|
|
75
85
|
id: "old-money",
|
|
@@ -78,6 +88,7 @@ export const AESTHETICS: ReadonlyArray<Aesthetic> = [
|
|
|
78
88
|
description: "Quiet-luxury heritage prep",
|
|
79
89
|
promptHint:
|
|
80
90
|
"in the old-money preppy aesthetic — cashmere sweaters, polo shirts, pearl strands and tailored linen on yacht decks and clay courts, with crisp, sun-bleached Mediterranean color",
|
|
91
|
+
term: "old money preppy",
|
|
81
92
|
},
|
|
82
93
|
{
|
|
83
94
|
id: "streetwear",
|
|
@@ -280,6 +291,7 @@ export const AESTHETICS: ReadonlyArray<Aesthetic> = [
|
|
|
280
291
|
description: "Grunge-meets-emo internet alt",
|
|
281
292
|
promptHint:
|
|
282
293
|
"in the alt-girl aesthetic — alternative subculture mix of grunge, scene, emo and e-girl with dyed split hair, layered chains, fishnets, oversized band tees and smudged dark eyeliner, lit with cool moody color",
|
|
294
|
+
term: "alt-girl",
|
|
283
295
|
},
|
|
284
296
|
{
|
|
285
297
|
id: "blokecore",
|
|
@@ -296,6 +308,7 @@ export const AESTHETICS: ReadonlyArray<Aesthetic> = [
|
|
|
296
308
|
description: "Neon Japanese decora rave kid",
|
|
297
309
|
promptHint:
|
|
298
310
|
"in the Y2K-rave decora club-kid aesthetic — fluorescent layered tops, plastic-charm bracelets, kawaii hair clips, leg warmers and platform sneakers under blacklight and neon strobes, with hyper-saturated electric color",
|
|
311
|
+
term: "y2k rave decora club-kid",
|
|
299
312
|
},
|
|
300
313
|
|
|
301
314
|
// -------------------- Fashion eras --------------------
|
|
@@ -306,6 +319,7 @@ export const AESTHETICS: ReadonlyArray<Aesthetic> = [
|
|
|
306
319
|
description: "Pared-down quiet-luxury restraint",
|
|
307
320
|
promptHint:
|
|
308
321
|
"in the minimalist aesthetic — clean monochrome tailoring, structured silhouettes and empty white-stone spaces, with restrained neutral color and even soft light",
|
|
322
|
+
term: "minimalist fashion",
|
|
309
323
|
},
|
|
310
324
|
{
|
|
311
325
|
id: "maximalism",
|
|
@@ -314,6 +328,7 @@ export const AESTHETICS: ReadonlyArray<Aesthetic> = [
|
|
|
314
328
|
description: "More-is-more clashing pattern",
|
|
315
329
|
promptHint:
|
|
316
330
|
"in the maximalist aesthetic — clashing prints, layered jewelry, velvet, sequins and floral wallpaper in densely styled rooms, with rich saturated color",
|
|
331
|
+
term: "maximalist fashion",
|
|
317
332
|
},
|
|
318
333
|
{
|
|
319
334
|
id: "avant-garde",
|
|
@@ -322,6 +337,7 @@ export const AESTHETICS: ReadonlyArray<Aesthetic> = [
|
|
|
322
337
|
description: "Conceptual sculptural fashion",
|
|
323
338
|
promptHint:
|
|
324
339
|
"in the avant-garde aesthetic — sculptural deconstructed silhouettes, exaggerated proportions and architectural draping in stark gallery-white spaces, with cold minimal color",
|
|
340
|
+
term: "avant-garde fashion",
|
|
325
341
|
},
|
|
326
342
|
{
|
|
327
343
|
id: "old-hollywood-glam",
|
|
@@ -330,6 +346,7 @@ export const AESTHETICS: ReadonlyArray<Aesthetic> = [
|
|
|
330
346
|
description: "Silver-screen 1940s glamour",
|
|
331
347
|
promptHint:
|
|
332
348
|
"in the old-Hollywood-glam aesthetic — satin gowns, fur stoles, finger waves and red lipstick under sculpted studio key light, with silvery monochrome and high-contrast tonality",
|
|
349
|
+
term: "old hollywood glamour",
|
|
333
350
|
},
|
|
334
351
|
|
|
335
352
|
// -------------------- Mood bundles --------------------
|
|
@@ -348,6 +365,7 @@ export const AESTHETICS: ReadonlyArray<Aesthetic> = [
|
|
|
348
365
|
description: "Gentle, low-effort warm calm",
|
|
349
366
|
promptHint:
|
|
350
367
|
"in a soft easygoing mood — relaxed knits, oversized layers and easy posture in softly lit rooms, with hazy natural light and creamy gentle color",
|
|
368
|
+
term: "soft easygoing mood",
|
|
351
369
|
},
|
|
352
370
|
{
|
|
353
371
|
id: "casual-lived-in",
|
|
@@ -356,6 +374,7 @@ export const AESTHETICS: ReadonlyArray<Aesthetic> = [
|
|
|
356
374
|
description: "Worn-in, unstyled everyday",
|
|
357
375
|
promptHint:
|
|
358
376
|
"in a casual lived-in mood — worn jeans, faded tees and rumpled hair in everyday domestic settings, with flat naturalistic light and unpolished snapshot color",
|
|
377
|
+
term: "casual lived-in mood",
|
|
359
378
|
},
|
|
360
379
|
{
|
|
361
380
|
id: "effortless-cool",
|
|
@@ -395,12 +414,13 @@ export function getAestheticPromptHint(id: string | undefined | null): string {
|
|
|
395
414
|
return getAesthetic(id)?.promptHint ?? ""
|
|
396
415
|
}
|
|
397
416
|
|
|
398
|
-
/**
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
417
|
+
/** Compact-mode sibling of `getAestheticPromptHint` (see `term.ts`). */
|
|
418
|
+
export function getAestheticTerm(id: string | undefined | null): string {
|
|
419
|
+
return resolveTerm(getAesthetic(id))
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
/** The 1-2 distinct ids a multi-pick aesthetic value carries, in order. */
|
|
423
|
+
function aestheticIds(value: unknown): string[] {
|
|
404
424
|
const ids: string[] = []
|
|
405
425
|
if (typeof value === "string" && value) ids.push(value)
|
|
406
426
|
else if (Array.isArray(value)) {
|
|
@@ -408,6 +428,17 @@ export function buildAestheticHints(value: unknown): string {
|
|
|
408
428
|
if (typeof v === "string" && v && !ids.includes(v)) ids.push(v)
|
|
409
429
|
}
|
|
410
430
|
}
|
|
431
|
+
return ids
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
/**
|
|
435
|
+
* Multi-pick variant: 1-2 aesthetic ids → blended hint. Single → entry's
|
|
436
|
+
* own promptHint. Two → "styled in a {A} + {B} aesthetic blend" with
|
|
437
|
+
* canonical entry labels (Y2K, dark academia, etc. stay as written).
|
|
438
|
+
*/
|
|
439
|
+
export function buildAestheticHints(value: unknown, mode: PickerHintMode = "full"): string {
|
|
440
|
+
if (mode === "compact") return buildAestheticTerms(value)
|
|
441
|
+
const ids = aestheticIds(value)
|
|
411
442
|
if (ids.length === 0) return ""
|
|
412
443
|
if (ids.length === 1) return getAestheticPromptHint(ids[0])
|
|
413
444
|
const labels = ids
|
|
@@ -418,6 +449,23 @@ export function buildAestheticHints(value: unknown): string {
|
|
|
418
449
|
return `styled in a ${labels[0]} + ${labels[1]} aesthetic blend`
|
|
419
450
|
}
|
|
420
451
|
|
|
452
|
+
/**
|
|
453
|
+
* Compact-mode sibling of `buildAestheticHints`: same 1-2 id shape, same
|
|
454
|
+
* blend structure, but composed from the entries' short terms instead of
|
|
455
|
+
* their paragraph-length hints.
|
|
456
|
+
*/
|
|
457
|
+
export function buildAestheticTerms(value: unknown): string {
|
|
458
|
+
const ids = aestheticIds(value)
|
|
459
|
+
if (ids.length === 0) return ""
|
|
460
|
+
if (ids.length === 1) return getAestheticTerm(ids[0])
|
|
461
|
+
const terms = ids
|
|
462
|
+
.slice(0, 2)
|
|
463
|
+
.map((id) => getAestheticTerm(id))
|
|
464
|
+
.filter((t) => t.length > 0)
|
|
465
|
+
if (terms.length < 2) return getAestheticTerm(ids[0])
|
|
466
|
+
return `${terms[0]} + ${terms[1]} aesthetic blend`
|
|
467
|
+
}
|
|
468
|
+
|
|
421
469
|
export const AESTHETIC_IDS: ReadonlyArray<string> = AESTHETICS.map((a) => a.id)
|
|
422
470
|
|
|
423
471
|
export const AESTHETIC_CATEGORY_LABELS: Readonly<Record<AestheticCategory, string>> = {
|