@nodaro/prompts 1.47.0 → 1.49.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.
Files changed (92) hide show
  1. package/dist/{catalog-packs-D7NJpo4k.d.ts → catalog-packs-BzzZCjbz.d.ts} +1 -1
  2. package/dist/{catalog-packs-C9CXEWfl.d.cts → catalog-packs-C--EzV3K.d.cts} +1 -1
  3. package/dist/{chunk-PHR4UETI.cjs → chunk-3AVLIA4B.cjs} +18 -18
  4. package/dist/{chunk-PHR4UETI.cjs.map → chunk-3AVLIA4B.cjs.map} +1 -1
  5. package/dist/{chunk-OSKABA64.cjs → chunk-3HMMG6W4.cjs} +8 -8
  6. package/dist/{chunk-OSKABA64.cjs.map → chunk-3HMMG6W4.cjs.map} +1 -1
  7. package/dist/{chunk-NN4FL7SB.js → chunk-3J52HGBL.js} +4 -4
  8. package/dist/{chunk-NN4FL7SB.js.map → chunk-3J52HGBL.js.map} +1 -1
  9. package/dist/{chunk-TXHFL725.js → chunk-4V4JJUEH.js} +102 -54
  10. package/dist/chunk-4V4JJUEH.js.map +1 -0
  11. package/dist/{chunk-CPKYIVWP.js → chunk-7C7XLZPU.js} +12 -9
  12. package/dist/chunk-7C7XLZPU.js.map +1 -0
  13. package/dist/{chunk-YCCQXX3U.cjs → chunk-7VVVHIMH.cjs} +39 -39
  14. package/dist/{chunk-YCCQXX3U.cjs.map → chunk-7VVVHIMH.cjs.map} +1 -1
  15. package/dist/{chunk-2OIKQ4WB.js → chunk-FDTRN7OE.js} +5 -5
  16. package/dist/{chunk-2OIKQ4WB.js.map → chunk-FDTRN7OE.js.map} +1 -1
  17. package/dist/{chunk-JHEZJKTX.cjs → chunk-GTN3UO74.cjs} +113 -56
  18. package/dist/chunk-GTN3UO74.cjs.map +1 -0
  19. package/dist/{chunk-C4FDHCEZ.cjs → chunk-KBIA4R6X.cjs} +125 -125
  20. package/dist/{chunk-C4FDHCEZ.cjs.map → chunk-KBIA4R6X.cjs.map} +1 -1
  21. package/dist/{chunk-2HJJBXGA.js → chunk-MJ5OXUDF.js} +6 -6
  22. package/dist/{chunk-2HJJBXGA.js.map → chunk-MJ5OXUDF.js.map} +1 -1
  23. package/dist/{chunk-OY7XOPDG.cjs → chunk-NBJR7JS4.cjs} +57 -57
  24. package/dist/chunk-NBJR7JS4.cjs.map +1 -0
  25. package/dist/{chunk-VAK4RUNU.cjs → chunk-NET25TEQ.cjs} +13 -13
  26. package/dist/{chunk-VAK4RUNU.cjs.map → chunk-NET25TEQ.cjs.map} +1 -1
  27. package/dist/{chunk-SMKIVT7P.js → chunk-NRBO4I4G.js} +4 -4
  28. package/dist/{chunk-SMKIVT7P.js.map → chunk-NRBO4I4G.js.map} +1 -1
  29. package/dist/{chunk-FCTK7RGO.cjs → chunk-OXJ5GN57.cjs} +42 -39
  30. package/dist/chunk-OXJ5GN57.cjs.map +1 -0
  31. package/dist/{chunk-IKHRZ5UQ.js → chunk-PJPCNKEJ.js} +3 -3
  32. package/dist/{chunk-IKHRZ5UQ.js.map → chunk-PJPCNKEJ.js.map} +1 -1
  33. package/dist/{chunk-XZXUU3YH.js → chunk-QN3OP6TG.js} +8 -3
  34. package/dist/chunk-QN3OP6TG.js.map +1 -0
  35. package/dist/{chunk-UNR34GYU.cjs → chunk-WW7IHRIE.cjs} +8 -3
  36. package/dist/chunk-WW7IHRIE.cjs.map +1 -0
  37. package/dist/{chunk-57PTYJJX.js → chunk-XVAVCHPJ.js} +13 -13
  38. package/dist/chunk-XVAVCHPJ.js.map +1 -0
  39. package/dist/entries/assembly.cjs +29 -29
  40. package/dist/entries/assembly.d.cts +1 -2
  41. package/dist/entries/assembly.d.ts +1 -2
  42. package/dist/entries/assembly.js +6 -6
  43. package/dist/entries/catalog-packs.d.cts +3 -3
  44. package/dist/entries/catalog-packs.d.ts +3 -3
  45. package/dist/entries/person.d.cts +2 -2
  46. package/dist/entries/person.d.ts +2 -2
  47. package/dist/entries/picker-art.cjs +19 -19
  48. package/dist/entries/picker-art.d.cts +5 -0
  49. package/dist/entries/picker-art.d.ts +5 -0
  50. package/dist/entries/picker-art.js +1 -1
  51. package/dist/entries/picker-catalogs.cjs +57 -57
  52. package/dist/entries/picker-catalogs.d.cts +26 -4
  53. package/dist/entries/picker-catalogs.d.ts +26 -4
  54. package/dist/entries/picker-catalogs.js +5 -5
  55. package/dist/entries/pickers.cjs +410 -374
  56. package/dist/entries/pickers.d.cts +524 -3
  57. package/dist/entries/pickers.d.ts +524 -3
  58. package/dist/entries/pickers.js +3 -3
  59. package/dist/entries/prompt-builder.cjs +76 -76
  60. package/dist/entries/prompt-builder.d.cts +2 -3
  61. package/dist/entries/prompt-builder.d.ts +2 -3
  62. package/dist/entries/prompt-builder.js +5 -5
  63. package/dist/index.cjs +580 -544
  64. package/dist/index.d.cts +5 -6
  65. package/dist/index.d.ts +5 -6
  66. package/dist/index.js +9 -9
  67. package/dist/{picker-catalogs-C3utGMRB.d.cts → picker-catalogs-BylSIFKL.d.cts} +17 -0
  68. package/dist/{picker-catalogs-BM8IphF1.d.ts → picker-catalogs-DHRyjPXb.d.ts} +17 -0
  69. package/dist/{subject-registry-DFiGx49v.d.ts → subject-registry-Cf802LQ5.d.ts} +8 -4
  70. package/dist/{subject-registry-CQi_CZfJ.d.cts → subject-registry-D2uMBVpP.d.cts} +8 -4
  71. package/package.json +2 -2
  72. package/src/__tests__/assemble-video-input.test.ts +13 -11
  73. package/src/__tests__/direction-registry.test.ts +5 -5
  74. package/src/__tests__/prompt-style-section.test.ts +2 -2
  75. package/src/__tests__/style-media.test.ts +141 -0
  76. package/src/__tests__/style-video-wording.test.ts +273 -0
  77. package/src/catalog-overlay.ts +15 -1
  78. package/src/direction-registry.ts +25 -7
  79. package/src/parameter-prompt-hint.ts +23 -7
  80. package/src/picker-art/look-preview-sets.ts +5 -0
  81. package/src/picker-catalogs.ts +30 -0
  82. package/src/style.ts +163 -50
  83. package/dist/chunk-57PTYJJX.js.map +0 -1
  84. package/dist/chunk-CPKYIVWP.js.map +0 -1
  85. package/dist/chunk-FCTK7RGO.cjs.map +0 -1
  86. package/dist/chunk-JHEZJKTX.cjs.map +0 -1
  87. package/dist/chunk-OY7XOPDG.cjs.map +0 -1
  88. package/dist/chunk-TXHFL725.js.map +0 -1
  89. package/dist/chunk-UNR34GYU.cjs.map +0 -1
  90. package/dist/chunk-XZXUU3YH.js.map +0 -1
  91. package/dist/transitions-BLFfAJUH.d.cts +0 -450
  92. package/dist/transitions-BgwWM37i.d.ts +0 -450
@@ -0,0 +1,141 @@
1
+ /**
2
+ * A style's medium, tones and media (`Style.medium` / `tones` / `media`):
3
+ * - every style classifies itself; the 49 styles offered for images and video
4
+ * keep exactly the medium and tones Nodaro Motion's art-style line has read
5
+ * them with so far (its own id lists), so moving them into the catalog
6
+ * changes no prompt;
7
+ * - the video-only styles are rendered 3D looks, offered for video alone;
8
+ * - image style lists leave them out, and the wire carries `media` so a thin
9
+ * client can do the same.
10
+ */
11
+ import { afterEach, describe, expect, it } from "vitest"
12
+ import {
13
+ STYLES,
14
+ STYLE_MEDIUMS,
15
+ getStyleMedium,
16
+ getStyleTones,
17
+ styleOffersMedia,
18
+ stylesForMedia,
19
+ styleOffersRestyle,
20
+ stylesForRestyle,
21
+ getPickerCatalog,
22
+ projectPickerCatalog,
23
+ registerCatalogPack,
24
+ resetCatalogPacks,
25
+ } from "../index.js"
26
+
27
+ afterEach(() => resetCatalogPacks())
28
+
29
+ /** Nodaro Motion's lists as they read the 49 styles before the catalog carried a medium. */
30
+ const PHOTOGRAPHIC = ["photorealistic", "cinematic", "noir", "early-color-photo", "retro-vintage", "cyberpunk", "steampunk", "glitch-art"]
31
+ const RENDERED = ["3d-render", "low-poly", "isometric", "pixar-3d", "claymation"]
32
+ const BLACK_AND_WHITE = ["noir", "manga", "pencil-sketch", "charcoal", "etching", "woodcut", "ink-wash"]
33
+ const ONE_COLOUR = ["blueprint", "ascii-art", "chalkboard"]
34
+ const VIDEO_ONLY = ["felt-puppet", "knitted-yarn", "toy-figure-3d", "painterly-3d", "painted-clay"]
35
+ /** Left out of restyle lists: in tests each came out as the same plain 2D cartoon, without its own look. */
36
+ const NOT_FOR_RESTYLE = ["fantasy", "concept-art", "surrealism"]
37
+
38
+ const both = STYLES.filter((s) => s.media === undefined)
39
+
40
+ describe("medium and tones", () => {
41
+ it("every style declares a known medium", () => {
42
+ for (const s of STYLES) expect(STYLE_MEDIUMS, s.id).toContain(s.medium)
43
+ })
44
+
45
+ it("the 49 styles for images and video keep the medium Nodaro Motion read them with", () => {
46
+ expect(both).toHaveLength(49)
47
+ for (const s of both) {
48
+ const expected = PHOTOGRAPHIC.includes(s.id) ? "photo" : RENDERED.includes(s.id) ? "3d" : "2d"
49
+ expect(getStyleMedium(s.id), s.id).toBe(expected)
50
+ }
51
+ })
52
+
53
+ it("tones: black and white, one colour, or none", () => {
54
+ for (const s of STYLES) {
55
+ const expected = BLACK_AND_WHITE.includes(s.id) ? "black-and-white" : ONE_COLOUR.includes(s.id) ? "one-colour" : undefined
56
+ expect(getStyleTones(s.id), s.id).toBe(expected)
57
+ }
58
+ })
59
+
60
+ it("an unknown id has no medium and no tones", () => {
61
+ expect(getStyleMedium("no-such-style")).toBeUndefined()
62
+ expect(getStyleMedium(undefined)).toBeUndefined()
63
+ expect(getStyleTones("no-such-style")).toBeUndefined()
64
+ })
65
+ })
66
+
67
+ describe("media", () => {
68
+ it("the video-only styles are rendered 3D looks, offered for video alone", () => {
69
+ const videoOnly = STYLES.filter((s) => s.media !== undefined)
70
+ expect(videoOnly.map((s) => s.id)).toEqual(VIDEO_ONLY)
71
+ for (const s of videoOnly) {
72
+ expect(s.media, s.id).toEqual(["video"])
73
+ expect(getStyleMedium(s.id), s.id).toBe("3d")
74
+ expect(styleOffersMedia(s, "video"), s.id).toBe(true)
75
+ expect(styleOffersMedia(s, "image"), s.id).toBe(false)
76
+ }
77
+ })
78
+
79
+ it("an image list leaves out the video-only styles; a video list has every style, in catalog order", () => {
80
+ expect(stylesForMedia(STYLES, "image").map((s) => s.id)).toEqual(both.map((s) => s.id))
81
+ expect(stylesForMedia(STYLES, "video").map((s) => s.id)).toEqual(STYLES.map((s) => s.id))
82
+ })
83
+
84
+ it("a style with no media is offered for both", () => {
85
+ expect(styleOffersMedia({}, "image")).toBe(true)
86
+ expect(styleOffersMedia({}, "video")).toBe(true)
87
+ })
88
+ })
89
+
90
+ describe("restyle", () => {
91
+ it("exactly the styles that do not hold up as a restyle say so", () => {
92
+ expect(STYLES.filter((s) => !styleOffersRestyle(s)).map((s) => s.id)).toEqual(NOT_FOR_RESTYLE)
93
+ for (const id of NOT_FOR_RESTYLE) expect(STYLES.find((s) => s.id === id)!.restyle, id).toBe(false)
94
+ })
95
+
96
+ it("a restyle list is the video list without them, in catalog order", () => {
97
+ expect(stylesForRestyle(STYLES).map((s) => s.id)).toEqual(STYLES.filter((s) => !NOT_FOR_RESTYLE.includes(s.id)).map((s) => s.id))
98
+ })
99
+
100
+ it("video generation still offers them", () => {
101
+ for (const id of NOT_FOR_RESTYLE) expect(stylesForMedia(STYLES, "video").some((s) => s.id === id), id).toBe(true)
102
+ })
103
+
104
+ it("a style that says nothing is offered for restyle", () => {
105
+ expect(styleOffersRestyle({})).toBe(true)
106
+ })
107
+ })
108
+
109
+ describe("the style catalog", () => {
110
+ const catalog = () => getPickerCatalog("style")!
111
+
112
+ it("carries media on the video-only options only", () => {
113
+ for (const o of catalog().options!) {
114
+ expect(o.media, o.id).toEqual(VIDEO_ONLY.includes(o.id) ? ["video"] : undefined)
115
+ }
116
+ })
117
+
118
+ it("the wire carries media at both detail levels", () => {
119
+ for (const detail of ["compact", "full"] as const) {
120
+ const options = projectPickerCatalog(catalog(), { detail }).options!
121
+ const felt = options.find((o) => o.id === "felt-puppet")!
122
+ expect(felt.media, detail).toEqual(["video"])
123
+ expect(options.find((o) => o.id === "anime")!.media, detail).toBeUndefined()
124
+ }
125
+ })
126
+
127
+ it("the catalog and the wire carry restyle: false on exactly those options, at both detail levels", () => {
128
+ for (const o of catalog().options!) expect(o.restyle, o.id).toBe(NOT_FOR_RESTYLE.includes(o.id) ? false : undefined)
129
+ for (const detail of ["compact", "full"] as const) {
130
+ const options = projectPickerCatalog(catalog(), { detail }).options!
131
+ expect(options.find((o) => o.id === "fantasy")!.restyle, detail).toBe(false)
132
+ expect(options.find((o) => o.id === "vaporwave")!.restyle, detail).toBeUndefined()
133
+ }
134
+ })
135
+
136
+ it("a catalog pack that removes a video-only style takes away its medium too", () => {
137
+ registerCatalogPack({ id: "t-deny", catalogId: "style", mode: "deny", denyIds: ["felt-puppet"] })
138
+ expect(getStyleMedium("felt-puppet")).toBeUndefined()
139
+ expect(getPickerCatalog("style")!.options!.some((o) => o.id === "felt-puppet")).toBe(false)
140
+ })
141
+ })
@@ -0,0 +1,273 @@
1
+ /**
2
+ * Every style carries a VIDEO-only wording (`videoPromptHint`): what the medium
3
+ * is, then its concrete techniques (the pattern that styled every shot in video
4
+ * tests). Every video surface reads it through `getStyleVideoPromptHint`
5
+ * (falling back to `promptHint` for a pack style without one); every image
6
+ * surface keeps `promptHint`. These tests pin:
7
+ * - stills render exactly the image wording for every style;
8
+ * - video renders each style's own video wording, the three tested on video
9
+ * and the two chosen for 3D Render and Charcoal word for word, the
10
+ * photographic ones still photographic;
11
+ * - a deployment's catalog packs reach the video wording the same way they
12
+ * reach the image wording, and a rewrite of the image wording never ships
13
+ * beside the stock video wording.
14
+ */
15
+ import { afterEach, describe, expect, it } from "vitest"
16
+ import {
17
+ STYLES,
18
+ getStyle,
19
+ getStylePromptHint,
20
+ getStyleVideoPromptHint,
21
+ getPickerCatalog,
22
+ projectPickerCatalog,
23
+ registerCatalogPack,
24
+ resetCatalogPacks,
25
+ renderDirectionHints,
26
+ composeVideoPromptText,
27
+ assembleImageInput,
28
+ getParameterPromptHint,
29
+ type PickerCatalogInput,
30
+ } from "../index.js"
31
+
32
+ afterEach(() => resetCatalogPacks())
33
+
34
+ const LOW_POLY_VIDEO =
35
+ "Stylised low poly 3D animation, computer-generated: every person, face, vehicle and room built from large flat-shaded triangles, visible facets on every surface, flat untextured colours, crisp shadow edges between facets."
36
+ /** The wordings tested on video (Seedance 2.5), word for word. */
37
+ const TESTED_ON_VIDEO: Readonly<Record<string, string>> = {
38
+ "felt-puppet": "Needle-felted stop-motion puppet animation, handmade: every person a felt puppet with soft fuzzy wool-felt skin, simple stitched features, small bead-like eyes and hair of felted wool strands, clothes sewn from real fabric with visible weave. The whole set is a handmade felt miniature: every car a fuzzy felt-covered model with felt seats, every wall, floor, curtain, table, chair and plant made of felt or wool fabric with visible fibres, the street and sky felt too; warm soft studio light on a tabletop miniature set, a slight stop-motion step to the motion.",
39
+ "knitted-yarn": "Knitted yarn stop-motion animation, handmade: every person a knitted doll with yarn skin in visible knit loops, hair of chunky yarn strands, clothes knitted in cable and rib stitch; every car, building, road, tree and sky knitted from wool yarn with visible stitches; soft warm studio light on a tabletop miniature set, a slight stop-motion step to the motion.",
40
+ "toy-figure-3d": "Stylised 3D toy animation, computer-generated: every person a rounded vinyl toy figure with smooth glossy plastic skin, a simplified oversized head, small dot eyes and moulded plastic hair, clothes as moulded painted plastic; every car, room and prop a chunky rounded toy model with soft bevelled edges; bright clean colours, soft studio lighting.",
41
+ "painterly-3d": "Painterly 3D animation, computer-generated with a hand-painted finish: every person a stylised 3D character with soft rounded features and expressive eyes, skin, hair and clothes textured with visible painted brush strokes; every room and landscape a 3D set painted like a storybook illustration; warm glowing light, rich soft colours.",
42
+ "painted-clay": "Sculpted clay animation with a painted finish, handmade: every person sculpted from matte clay with visible thumbprints and tool marks, painted in soft gouache colour; every car, room and prop sculpted and hand-painted the same way; warm painterly light, a slight stop-motion step to the motion.",
43
+ "pop-art": "Flat 2D cel-shaded comic illustration, hand-drawn: thick uniform black ink outlines, flat colour fills, Ben-Day dot shading, bold saturated primary colours, high contrast.",
44
+ claymation: "Stop-motion claymation, handmade: every person, vehicle and set sculpted from matte plasticine clay with visible fingerprints and tool marks, rounded simplified forms, miniature handcrafted diorama sets, soft warm studio lighting.",
45
+ anime: "2D Japanese anime, hand-drawn cel animation: clean thin ink line art, flat cel shading with one hard shadow tone, large expressive eyes, hair drawn as solid shapes, saturated colours, painted backgrounds.",
46
+ }
47
+ /** The 3D Render and Charcoal video wordings as chosen, word for word, beside their unchanged image wordings. */
48
+ const CHOSEN_FOR_VIDEO: Readonly<Record<string, { readonly video: string; readonly image: string }>> = {
49
+ "3d-render": {
50
+ video: "Stylised 3D render animation, computer-generated: every person, vehicle and room modelled in smooth matte CG surfaces, simplified clean forms, soft studio lighting, gentle ambient occlusion, bright clean colours.",
51
+ image: "3D rendered style, modern CGI aesthetic with clean shading, subtle ambient occlusion and crisp materials",
52
+ },
53
+ charcoal: {
54
+ video: "Rough 2D charcoal sketch, hand-drawn on white paper: every person and every face drawn in heavy black charcoal outlines and loose hatched strokes, white paper showing through, smudged edges, bold gestural lines.",
55
+ image: "rendered as a charcoal drawing on toothy paper, soft black and grey tonal masses with smudged blended midtones, sharp accented darks, expressive gestural strokes and the raw immediacy of a fine-art figure study",
56
+ },
57
+ }
58
+ /** The photographic styles: their video wording keeps them photographed, never drawn. */
59
+ const PHOTOGRAPHIC = ["photorealistic", "cinematic", "noir", "retro-vintage", "early-color-photo"]
60
+ const videoOf = (id: string) => STYLES.find((s) => s.id === id)!.videoPromptHint!
61
+ const LOW_POLY_IMAGE =
62
+ "low poly style, faceted geometric 3D aesthetic with visible flat triangular faces, simplified forms and crisp shadow edges between facets"
63
+
64
+ const styleNode = (style: string) => ({ id: "s1", type: "style", data: { style } })
65
+
66
+ /** The styles offered for video alone (`media: ["video"]`). */
67
+ const VIDEO_ONLY = ["felt-puppet", "knitted-yarn", "toy-figure-3d", "painterly-3d", "painted-clay"]
68
+ /**
69
+ * A video wording's length cap. A style offered for images too names its medium and its techniques in one short line;
70
+ * a video-only style also names what the whole set is made of (cars, rooms, props), and the felt line that felted
71
+ * the whole set of a live-action clip on Seedance 2.5 (people, car, street and curtains) is 562 characters.
72
+ */
73
+ const capOf = (id: string) => (VIDEO_ONLY.includes(id) ? 600 : 240)
74
+
75
+ describe("the catalog", () => {
76
+ it("has 54 styles (49 for images and video, 5 for video alone), and every one carries its own video wording, different from its image wording", () => {
77
+ expect(STYLES).toHaveLength(54)
78
+ expect(STYLES.filter((s) => s.media?.length === 1 && s.media[0] === "video").map((s) => s.id)).toEqual(VIDEO_ONLY)
79
+ for (const s of STYLES) {
80
+ expect(s.videoPromptHint, s.id).toEqual(expect.any(String))
81
+ expect(s.videoPromptHint, s.id).not.toBe(s.promptHint)
82
+ }
83
+ })
84
+
85
+ it("each video wording names the medium, then its techniques after a colon: one sentence, one line", () => {
86
+ for (const s of STYLES) {
87
+ const v = s.videoPromptHint!
88
+ expect(v, s.id).toMatch(/^[^:]+: \S[^\n]*\.$/)
89
+ expect(v.length, s.id).toBeLessThanOrEqual(capOf(s.id))
90
+ }
91
+ })
92
+
93
+ it("the wordings tested on video are word for word", () => {
94
+ for (const [id, wording] of Object.entries(TESTED_ON_VIDEO)) expect(getStyleVideoPromptHint(id), id).toBe(wording)
95
+ })
96
+
97
+ it("the 3D Render and Charcoal video wordings are word for word, and their image wordings unchanged", () => {
98
+ for (const [id, { video, image }] of Object.entries(CHOSEN_FOR_VIDEO)) {
99
+ expect(getStyleVideoPromptHint(id), id).toBe(video)
100
+ expect(getStylePromptHint(id), id).toBe(image)
101
+ }
102
+ })
103
+
104
+ it("the photographic styles stay photographed on video", () => {
105
+ for (const id of PHOTOGRAPHIC) expect(getStyleVideoPromptHint(id), id).toMatch(/live-action|photograph/i)
106
+ })
107
+
108
+ it("Low Poly keeps its image wording and carries the video wording", () => {
109
+ expect(getStylePromptHint("low-poly")).toBe(LOW_POLY_IMAGE)
110
+ expect(getStyleVideoPromptHint("low-poly")).toBe(LOW_POLY_VIDEO)
111
+ })
112
+
113
+ it("every style's video wording is its own, read through getStyleVideoPromptHint", () => {
114
+ for (const s of STYLES) expect(getStyleVideoPromptHint(s.id), s.id).toBe(s.videoPromptHint)
115
+ })
116
+
117
+ it("an unknown or empty id has no wording on either surface", () => {
118
+ expect(getStyleVideoPromptHint("no-such-style")).toBe("")
119
+ expect(getStyleVideoPromptHint(undefined)).toBe("")
120
+ expect(getStyleVideoPromptHint("")).toBe("")
121
+ })
122
+ })
123
+
124
+ describe("image surfaces read only promptHint, for every style (a video-only one wired into an image included)", () => {
125
+ it("the direction fold on image", () => {
126
+ for (const s of STYLES) {
127
+ expect(renderDirectionHints({ style: s.id }, { surface: "image" }), s.id).toEqual([s.promptHint])
128
+ }
129
+ })
130
+
131
+ it("the assembled image prompt", () => {
132
+ for (const s of STYLES) {
133
+ const { prompt } = assembleImageInput({ userPrompt: "a man walks", provider: "flux-2-max", direction: { style: s.id } })
134
+ expect(prompt, s.id).toBe(`a man walks\n\n[style]:\n${s.promptHint}`)
135
+ }
136
+ })
137
+
138
+ it("a Style node's own hint (no surface, and surface image)", () => {
139
+ for (const s of STYLES) {
140
+ expect(getParameterPromptHint(styleNode(s.id)), s.id).toBe(s.promptHint)
141
+ expect(getParameterPromptHint(styleNode(s.id), undefined, { surface: "image" }), s.id).toBe(s.promptHint)
142
+ }
143
+ })
144
+ })
145
+
146
+ describe("video surfaces read the video wording", () => {
147
+ it("the direction fold on video: each style's video wording", () => {
148
+ for (const s of STYLES) expect(renderDirectionHints({ style: s.id }, { surface: "video" }), s.id).toEqual([videoOf(s.id)])
149
+ })
150
+
151
+ it("the composed video prompt", () => {
152
+ for (const s of STYLES) {
153
+ expect(composeVideoPromptText("a man walks", { style: s.id }), s.id).toBe(`a man walks\n\n[style]:\n${videoOf(s.id)}`)
154
+ }
155
+ })
156
+
157
+ it("a Style node folded into a video consumer", () => {
158
+ for (const s of STYLES) {
159
+ expect(getParameterPromptHint(styleNode(s.id), undefined, { surface: "video" }), s.id).toBe(videoOf(s.id))
160
+ }
161
+ })
162
+
163
+ it("keeps the node's own pre/post text around the video wording", () => {
164
+ const node = { id: "s1", type: "style", data: { style: "low-poly", preText: "before", postText: "after" } }
165
+ // The video wording ends a sentence, so what follows it is joined with a space (joinHintFragments).
166
+ expect(getParameterPromptHint(node, undefined, { surface: "video" })).toBe(`before, ${LOW_POLY_VIDEO} after`)
167
+ })
168
+
169
+ it("compact mode injects the same term on both surfaces", () => {
170
+ expect(renderDirectionHints({ style: "low-poly" }, { surface: "video", mode: "compact" })).toEqual(["low poly"])
171
+ const node = { id: "s1", type: "style", data: { style: "low-poly", hintMode: "compact" } }
172
+ expect(getParameterPromptHint(node, undefined, { surface: "video" })).toBe("low poly")
173
+ })
174
+
175
+ it("a Style node on a camera move's start state takes the video surface down with it", () => {
176
+ const nodes = [
177
+ { id: "cm", type: "camera-motion", data: { cameraMotion: "dolly-in" } },
178
+ styleNode("low-poly"),
179
+ ]
180
+ const edges = [{ id: "e1", source: "s1", target: "cm", targetHandle: "startState" }]
181
+ const video = getParameterPromptHint(nodes[0], { nodes, edges }, { surface: "video" })
182
+ const image = getParameterPromptHint(nodes[0], { nodes, edges })
183
+ expect(video).toContain(LOW_POLY_VIDEO)
184
+ expect(image).toContain(LOW_POLY_IMAGE)
185
+ expect(image).not.toContain(LOW_POLY_VIDEO)
186
+ })
187
+ })
188
+
189
+ describe("catalog packs", () => {
190
+ const styleCatalog = () => getPickerCatalog("style")!
191
+ const replaceWith = (mapOption: (o: NonNullable<PickerCatalogInput["options"]>[number]) => object) => {
192
+ const base = styleCatalog()
193
+ registerCatalogPack({
194
+ id: "t-style-replace",
195
+ catalogId: "style",
196
+ mode: "replace",
197
+ catalog: { ...base, options: base.options!.map((o) => ({ ...o, ...mapOption(o) })) },
198
+ })
199
+ }
200
+
201
+ it("the composed catalog carries every style's video wording", () => {
202
+ const options = styleCatalog().options!
203
+ expect(options).toHaveLength(54)
204
+ for (const o of options) expect(o.videoPromptHint, o.id).toBe(videoOf(o.id))
205
+ })
206
+
207
+ it("the wire projection does not carry it", () => {
208
+ const projected = projectPickerCatalog(styleCatalog(), { detail: "full" })
209
+ expect(JSON.stringify(projected)).not.toContain("videoPromptHint")
210
+ })
211
+
212
+ it("a deny pack: a denied style has no video wording either", () => {
213
+ registerCatalogPack({ id: "t-deny", catalogId: "style", mode: "deny", denyIds: ["low-poly"] })
214
+ expect(getStyleVideoPromptHint("low-poly")).toBe("")
215
+ expect(renderDirectionHints({ style: "low-poly" }, { surface: "video" })).toEqual([])
216
+ // A sibling the pack did not touch still resolves on both surfaces.
217
+ expect(getStyleVideoPromptHint("anime")).toBe(TESTED_ON_VIDEO.anime)
218
+ expect(getStylePromptHint("anime")).toBe(STYLES.find((s) => s.id === "anime")!.promptHint)
219
+ })
220
+
221
+ it("a deny pack on ANOTHER style keeps Low Poly's video wording", () => {
222
+ registerCatalogPack({ id: "t-deny", catalogId: "style", mode: "deny", denyIds: ["anime"] })
223
+ expect(getStyleVideoPromptHint("low-poly")).toBe(LOW_POLY_VIDEO)
224
+ expect(getStylePromptHint("low-poly")).toBe(LOW_POLY_IMAGE)
225
+ })
226
+
227
+ it("the browser's copy of the server catalog (full detail, no video wording) keeps the stock video wording", () => {
228
+ // What `catalog-bootstrap` registers on a curated deployment: the server's
229
+ // composed catalog read back from `/v1/catalogs?detail=full`.
230
+ const wire = projectPickerCatalog(styleCatalog(), { detail: "full" }) as unknown as PickerCatalogInput
231
+ registerCatalogPack({ id: "wire:style", catalogId: "style", mode: "replace", catalog: wire })
232
+ expect(getStyleVideoPromptHint("low-poly")).toBe(LOW_POLY_VIDEO)
233
+ expect(getStylePromptHint("low-poly")).toBe(LOW_POLY_IMAGE)
234
+ })
235
+
236
+ it("a pack that rewrites the image wording drops the stock video wording (video follows the rewrite)", () => {
237
+ replaceWith((o) => (o.id === "low-poly" ? { promptHint: "curated low poly wording", videoPromptHint: undefined } : {}))
238
+ expect(getStylePromptHint("low-poly")).toBe("curated low poly wording")
239
+ expect(getStyleVideoPromptHint("low-poly")).toBe("curated low poly wording")
240
+ expect(getStyle("low-poly")?.videoPromptHint).toBeUndefined()
241
+ })
242
+
243
+ it("a pack may carry its own video wording", () => {
244
+ replaceWith((o) => (o.id === "low-poly" ? { promptHint: "curated still", videoPromptHint: "curated motion" } : {}))
245
+ expect(getStylePromptHint("low-poly")).toBe("curated still")
246
+ expect(getStyleVideoPromptHint("low-poly")).toBe("curated motion")
247
+ expect(renderDirectionHints({ style: "low-poly" }, { surface: "video" })).toEqual(["curated motion"])
248
+ expect(renderDirectionHints({ style: "low-poly" }, { surface: "image" })).toEqual(["curated still"])
249
+ })
250
+
251
+ it("a pack may replace a stock style's video wording and keep its image wording", () => {
252
+ replaceWith((o) => (o.id === "anime" ? { videoPromptHint: "anime video wording" } : {}))
253
+ expect(getStylePromptHint("anime")).toBe(STYLES.find((s) => s.id === "anime")!.promptHint)
254
+ expect(getStyleVideoPromptHint("anime")).toBe("anime video wording")
255
+ })
256
+
257
+ it("a pack-ADDED style resolves its own video wording, else its image wording", () => {
258
+ registerCatalogPack({
259
+ id: "t-extend",
260
+ catalogId: "style",
261
+ mode: "extend",
262
+ options: [
263
+ { id: "claymation-x", label: "Clay X", promptHint: "clay still", videoPromptHint: "clay motion" },
264
+ { id: "inkwash-x", label: "Ink X", promptHint: "ink still" },
265
+ ],
266
+ })
267
+ expect(getStyleVideoPromptHint("claymation-x")).toBe("clay motion")
268
+ expect(getStylePromptHint("claymation-x")).toBe("clay still")
269
+ expect(getStyleVideoPromptHint("inkwash-x")).toBe("ink still")
270
+ expect(getParameterPromptHint(styleNode("claymation-x"), undefined, { surface: "video" })).toBe("clay motion")
271
+ expect(getParameterPromptHint(styleNode("claymation-x"))).toBe("clay still")
272
+ })
273
+ })
@@ -26,6 +26,16 @@ import { catalogPacksVersion, hasCatalogPacksFor } from "./catalog-packs.js"
26
26
  * carry (typed extras a specific builder reads) survive from the base.
27
27
  * - pack-ADDED id (no base entry) → the composed option itself.
28
28
  *
29
+ * THE VIDEO WORDING (`videoPromptHint`, styles only) pairs with the image
30
+ * wording it was written beside, so it cannot simply survive from the base
31
+ * like a typed extra: a pack that rewrites a style's `promptHint` would then
32
+ * still inject the stock video text. The composed option's own
33
+ * `videoPromptHint` wins when it carries one; otherwise the base's is kept
34
+ * only while the composed `promptHint` is the stock one (a deny / extend pack,
35
+ * or the browser's `replace` copy of the server's composed catalog, which
36
+ * carries `promptHint` but not the video wording); a rewritten `promptHint`
37
+ * drops it, so video falls back to the pack's own wording.
38
+ *
29
39
  * LATE-BOUND RESOLVER, ON PURPOSE. The catalog modules cannot import
30
40
  * `getPickerCatalog`: picker-catalogs.ts imports every one of them to build
31
41
  * `PICKER_CATALOGS` at module scope, and a module-scope cycle is a TDZ error
@@ -91,7 +101,7 @@ export function overlayEntry<T extends { readonly id: string }>(
91
101
  const opt = composedOptionIndex(catalogId).get(id)
92
102
  if (!opt) return undefined
93
103
  if (!base) return opt as unknown as T
94
- return {
104
+ const out: Record<string, unknown> = {
95
105
  ...base,
96
106
  label: opt.label,
97
107
  promptHint: opt.promptHint,
@@ -99,6 +109,10 @@ export function overlayEntry<T extends { readonly id: string }>(
99
109
  ...(opt.description !== undefined ? { description: opt.description } : {}),
100
110
  ...(opt.category !== undefined ? { category: opt.category } : {}),
101
111
  }
112
+ const baseFields = base as { readonly promptHint?: unknown; readonly videoPromptHint?: unknown }
113
+ if (opt.videoPromptHint !== undefined) out.videoPromptHint = opt.videoPromptHint
114
+ else if (baseFields.videoPromptHint !== undefined && opt.promptHint !== baseFields.promptHint) delete out.videoPromptHint
115
+ return out as unknown as T
102
116
  }
103
117
 
104
118
  /**
@@ -56,7 +56,7 @@ import { getExposurePromptHint, getExposureTerm } from "./exposure-settings.js"
56
56
  import { getColorLookPromptHint, getColorLookTerm } from "./color-look.js"
57
57
  import { buildAtmosphereHints } from "./atmosphere.js"
58
58
  import { buildPostProcessHints } from "./post-process-effects.js"
59
- import { getStylePromptHint, getStyleTerm } from "./style.js"
59
+ import { getStylePromptHint, getStyleTerm, getStyleVideoPromptHint } from "./style.js"
60
60
  import { buildMoodHints } from "./mood.js"
61
61
  import { buildAestheticHints } from "./aesthetic.js"
62
62
  import { getPhotoGenrePromptHint, getPhotoGenreTerm } from "./photo-genre.js"
@@ -125,8 +125,13 @@ export interface DirectionFieldSpec {
125
125
  * Render selected ids in this catalog's own doctrine: per-id for independent
126
126
  * catalogs, ONE blended clause for Mood / Aesthetic / Photographer, the
127
127
  * catalog's own multi builder where it has one. `[]` when nothing resolves.
128
+ *
129
+ * `surface` is the stage being folded. Nearly every row ignores it (one
130
+ * wording serves stills and video); a row whose catalog authors a video-only
131
+ * wording (`style`, via `getStyleVideoPromptHint`) reads it. Absent reads as
132
+ * `"image"`.
128
133
  */
129
- readonly render: (ids: readonly string[], mode: PickerHintMode) => string[]
134
+ readonly render: (ids: readonly string[], mode: PickerHintMode, surface?: "image" | "video") => string[]
130
135
  }
131
136
 
132
137
  // ── Render adapters (module-private) ────────────────────────────────────────
@@ -163,6 +168,16 @@ const viaStringBuilder =
163
168
  return s.length > 0 ? [s] : []
164
169
  }
165
170
 
171
+ /**
172
+ * Per-id emission whose FULL wording depends on the surface: the image getter
173
+ * on a still, the video getter on video (which falls back to the image wording
174
+ * for an id that authors none). The compact term is the same on both.
175
+ */
176
+ const perIdBySurface =
177
+ (image: (id: string) => string, video: (id: string) => string, compact: (id: string) => string) =>
178
+ (ids: readonly string[], mode: PickerHintMode, surface?: "image" | "video"): string[] =>
179
+ perId(surface === "video" ? video : image, compact)(ids, mode)
180
+
166
181
  /** Mood's builder takes a RECORD and returns a list — and blends multi picks. */
167
182
  const viaMood = (ids: readonly string[], mode: PickerHintMode): string[] =>
168
183
  buildMoodHints({ mood: ids.length === 1 ? ids[0] : [...ids] }, mode).filter(
@@ -242,7 +257,7 @@ export const DIRECTION_FIELDS = [
242
257
  { key: "postProcess", surface: "image", family: "look", maxPicks: 2, render: viaListBuilder(buildPostProcessHints) },
243
258
 
244
259
  // Style.
245
- { key: "style", surface: "both", family: "look", styleGroup: "film", maxPicks: 1, userRef: "moodboard", render: perId(getStylePromptHint, getStyleTerm) },
260
+ { key: "style", surface: "both", family: "look", styleGroup: "film", maxPicks: 1, userRef: "moodboard", render: perIdBySurface(getStylePromptHint, getStyleVideoPromptHint, getStyleTerm) },
246
261
  { key: "mood", surface: "both", family: "look", maxPicks: 2, render: viaMood },
247
262
  { key: "aesthetic", surface: "both", family: "look", maxPicks: 2, render: viaStringBuilder(buildAestheticHints) },
248
263
  { key: "photoGenre", surface: "image", family: "look", maxPicks: 1, render: perId(getPhotoGenrePromptHint, getPhotoGenreTerm) },
@@ -260,7 +275,9 @@ export const DIRECTION_FIELDS = [
260
275
  { key: "temporalFreeze", surface: "video", family: "motion", maxPicks: 1, render: temporal },
261
276
  { key: "temporalDirection", surface: "video", family: "motion", maxPicks: 1, render: temporal },
262
277
  { key: "temporalShutter", surface: "video", family: "motion", maxPicks: 1, render: temporal },
263
- { key: "transition", surface: "video", family: "motion", maxPicks: 2, render: renderTransitionBases },
278
+ // Wrapped: `renderTransitionBases`' third parameter is a row's option values,
279
+ // not the surface `render` is handed.
280
+ { key: "transition", surface: "video", family: "motion", maxPicks: 2, render: (ids, mode) => renderTransitionBases(ids, mode) },
264
281
  { key: "loopSubject", surface: "video", family: "motion", maxPicks: 1, render: perId(getLoopSubjectPromptHint, getLoopSubjectTerm) },
265
282
 
266
283
  // ── LEGACY BLOCK — see the table doc above. Placed LAST, in today's exact
@@ -408,14 +425,15 @@ function renderRow(
408
425
  spec: DirectionFieldSpec,
409
426
  ids: readonly string[],
410
427
  mode: PickerHintMode,
428
+ surface: "image" | "video",
411
429
  refs: ReadonlyMap<string, string> | undefined,
412
430
  ): string[] {
413
431
  if (spec.userRef === undefined || !ids.some((id) => parseUserDirectionRef(id) !== null)) {
414
- return spec.render(ids, mode)
432
+ return spec.render(ids, mode, surface)
415
433
  }
416
434
  return ids.flatMap((id) => {
417
435
  const ref = parseUserDirectionRef(id)
418
- if (ref === null) return spec.render([id], mode)
436
+ if (ref === null) return spec.render([id], mode, surface)
419
437
  const clause = ref.kind === spec.userRef ? refs?.get(id) : undefined
420
438
  return clause ? [clause] : []
421
439
  })
@@ -457,7 +475,7 @@ export function renderDirectionHintClauses(
457
475
  spec.maxPicks,
458
476
  )
459
477
  if (ids.length === 0) continue
460
- for (const hint of renderRow(spec, ids, modeForFamily(mode, spec.family), opts.refs)) {
478
+ for (const hint of renderRow(spec, ids, modeForFamily(mode, spec.family), opts.surface, opts.refs)) {
461
479
  if (hint.length > 0 && !seen.has(hint)) {
462
480
  seen.add(hint)
463
481
  out.push({
@@ -26,7 +26,7 @@ import { getCameraFormatPromptHint, getCameraFormatTerm } from "./camera-format.
26
26
  import { getColorLookPromptHint, getColorLookTerm } from "./color-look.js"
27
27
  import { buildAtmosphereHints } from "./atmosphere.js"
28
28
  import { buildActionFxHints } from "./action-fx.js"
29
- import { getStylePromptHint, getStyleTerm } from "./style.js"
29
+ import { getStylePromptHint, getStyleTerm, getStyleVideoPromptHint } from "./style.js"
30
30
  import { getSettingPromptHint, getSettingTerm } from "./setting.js"
31
31
  import { getLoopSubjectPromptHint, getLoopSubjectTerm } from "./loop-subject.js"
32
32
  import { buildPersonHints } from "./person.js"
@@ -179,12 +179,26 @@ const MOTION_INTENSITY_HINTS: Readonly<Record<string, { readonly full: string; r
179
179
  * transition / camera-motion / character-fx timing and start-state/end-state
180
180
  * clauses, multi-pick joining and multi-dimension composition are emitted the
181
181
  * same way in both modes.
182
+ *
183
+ * SURFACE — `opts.surface: "video"` is for a caller folding the hint into ONE
184
+ * video consumer's prompt (the canvas's video nodes): a Style node then injects
185
+ * its video wording (`getStyleVideoPromptHint`). Absent (or `"image"`) is the
186
+ * output this function always returned — what a still, a `{Label}` reference
187
+ * and every preview of the node's own text read, since a node's own output
188
+ * text has no single consumer. Every other node type ignores it.
182
189
  */
183
190
  export function getParameterPromptHint(
184
191
  node: HintNodeLike | undefined,
185
192
  ctx?: HintGraphContext,
193
+ opts?: ParameterHintOptions,
186
194
  ): string {
187
- return resolveParameterHint(node, ctx)
195
+ return resolveParameterHint(node, ctx, undefined, opts?.surface === "video" ? "video" : "image")
196
+ }
197
+
198
+ /** How `getParameterPromptHint` resolves a node. */
199
+ export interface ParameterHintOptions {
200
+ /** The stage the hint is folded into. Absent = `"image"`. */
201
+ readonly surface?: "image" | "video"
188
202
  }
189
203
 
190
204
  /**
@@ -198,6 +212,7 @@ function resolveParameterHint(
198
212
  node: HintNodeLike | undefined,
199
213
  ctx?: HintGraphContext,
200
214
  inherited?: PickerHintMode,
215
+ surface: "image" | "video" = "image",
201
216
  ): string {
202
217
  if (!node?.type) return ""
203
218
  const data = (node.data ?? {}) as Record<string, unknown>
@@ -216,7 +231,7 @@ function resolveParameterHint(
216
231
  // themselves parameter nodes (framing/tone/etc.) that don't need graph
217
232
  // context, and avoiding recursion keeps the walk cycle-safe. The mode
218
233
  // rides down so the composed clause stays at one level of detail.
219
- const hint = resolveParameterHint(src, undefined, mode)
234
+ const hint = resolveParameterHint(src, undefined, mode, surface)
220
235
  if (!hint) continue
221
236
  if (edge.targetHandle === "startState") startHints.push(hint)
222
237
  else if (edge.targetHandle === "endState") endHints.push(hint)
@@ -247,7 +262,7 @@ function resolveParameterHint(
247
262
  if (edge.target !== node.id) continue
248
263
  const src = ctx.nodes.find((n) => n.id === edge.source)
249
264
  if (!src) continue
250
- const hint = resolveParameterHint(src, undefined, mode) // no ctx — cycle-safe
265
+ const hint = resolveParameterHint(src, undefined, mode, surface) // no ctx — cycle-safe
251
266
  if (!hint) continue
252
267
  if (edge.targetHandle === "startState") startHints.push(hint)
253
268
  else if (edge.targetHandle === "endState") endHints.push(hint)
@@ -301,7 +316,7 @@ function resolveParameterHint(
301
316
  )
302
317
  }
303
318
 
304
- const base = resolveBaseHint(node.type, data, mode)
319
+ const base = resolveBaseHint(node.type, data, mode, surface)
305
320
  if (base) return base
306
321
  // Pack-extend fallback: a single-dim pack entry the per-catalog getter (which
307
322
  // reads the frozen base array) can't resolve. Resolve it against the
@@ -313,7 +328,7 @@ function resolveParameterHint(
313
328
  if (cat?.kind === "single" && cat.valueField) {
314
329
  const id = typeof data[cat.valueField] === "string" ? (data[cat.valueField] as string) : ""
315
330
  const opt = cat.options?.find((o) => o.id === id)
316
- if (opt) return byMode(mode, opt.promptHint, resolveTerm(opt))
331
+ if (opt) return byMode(mode, surface === "video" ? (opt.videoPromptHint ?? opt.promptHint) : opt.promptHint, resolveTerm(opt))
317
332
  }
318
333
  return base
319
334
  }
@@ -333,6 +348,7 @@ function resolveBaseHint(
333
348
  type: string,
334
349
  data: Record<string, unknown>,
335
350
  mode: PickerHintMode = "full",
351
+ surface: "image" | "video" = "image",
336
352
  ): string {
337
353
  switch (type) {
338
354
  case "framing":
@@ -373,7 +389,7 @@ function resolveBaseHint(
373
389
  case "action-fx":
374
390
  return withCustomText(data, buildActionFxHints(data.actionFx, mode).join(", "))
375
391
  case "style":
376
- return withCustomText(data, byMode(mode, getStylePromptHint, getStyleTerm)(asStr(data.style)))
392
+ return withCustomText(data, byMode(mode, surface === "video" ? getStyleVideoPromptHint : getStylePromptHint, getStyleTerm)(asStr(data.style)))
377
393
  case "setting":
378
394
  return withCustomText(data, byMode(mode, getSettingPromptHint, getSettingTerm)(asStr(data.setting)))
379
395
  case "loop-subject":
@@ -70,6 +70,11 @@ export const LOOK_PREVIEW_SETS = {
70
70
  "mixed-media": "https://cdn.nodaro.ai/picker-renders/d36ab150-9cd6-47ac-bdd9-3b26990a4fa9.png",
71
71
  "early-color-photo": "https://cdn.nodaro.ai/picker-renders/1bf61f3d-c174-466f-9092-5de72dc7abea.png",
72
72
  manga: "https://cdn.nodaro.ai/picker-renders/3d6bbfd8-8c62-4769-b62e-11d2a7e43b4b.png",
73
+ "felt-puppet": "https://cdn.nodaro.ai/picker-renders/31ad3b39-d501-4b76-9c1c-2f87fc374333.png",
74
+ "knitted-yarn": "https://cdn.nodaro.ai/picker-renders/b80b6a96-2221-4c68-8e0c-4beaa5a6ce56.png",
75
+ "toy-figure-3d": "https://cdn.nodaro.ai/picker-renders/add81127-a005-4f9a-a002-780768238715.png",
76
+ "painterly-3d": "https://cdn.nodaro.ai/picker-renders/6aec1d80-be9f-4f2e-a1b7-fa38a61131d1.png",
77
+ "painted-clay": "https://cdn.nodaro.ai/picker-renders/daedee18-c034-4b98-80bb-7bef7e05c547.png",
73
78
  },
74
79
  "color-look": {
75
80
  warm: "https://cdn.nodaro.ai/picker-renders/8511a1bc-3d3b-46a8-adc0-26fcc9e93a4b.png",