koishi-plugin-p-draw 1.2.13 → 1.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/parse.js ADDED
@@ -0,0 +1,265 @@
1
+ // 解析器纯函数库:地址/尺寸/批量/seed/denoise/raw 前缀/tag 合并/预设解析。
2
+ // 不依赖 koishi,可独立单测。
3
+
4
+ // ------------------------------------------------------------------
5
+ // 地址规范化
6
+ // ------------------------------------------------------------------
7
+ function normalizeBaseUrl(raw) {
8
+ let value = String(raw || '').trim()
9
+ if (!value) value = 'http://127.0.0.1:8188'
10
+ // 保留用户声明的协议(http/https),其余部分清理重复协议头。
11
+ // 修复:原实现会把 https:// 降级成 http://,反向代理/加密链路下会连不上。
12
+ const protocol = /^https?:\/\//i.test(value) ? value.slice(0, value.indexOf('://') + 3).toLowerCase() : 'http://'
13
+ value = value.replace(/^https?:\/\//i, '')
14
+ // 清理重复协议头,例如 http://http://host 或 http://https://host
15
+ value = value.replace(/^https?:\/\//i, '')
16
+ return (protocol + value).replace(/\/+$/, '')
17
+ }
18
+
19
+ // ------------------------------------------------------------------
20
+ // 尺寸别名与解析(移植自 anima command_router)
21
+ // ------------------------------------------------------------------
22
+ const SIZE_ALIASES = {
23
+ '方图': 1.0,
24
+ '正方形': 1.0,
25
+ '竖图': 2 / 3,
26
+ '竖版': 2 / 3,
27
+ '横图': 3 / 2,
28
+ '横版': 3 / 2,
29
+ '长竖图': 9 / 16,
30
+ '手机竖屏': 9 / 16,
31
+ '宽屏': 16 / 9,
32
+ '超宽图': 16 / 9,
33
+ }
34
+
35
+ const SIZE_VALUE_PATTERN = String.raw`(?<width>\d{2,5})\s*[xX×**✕✖хХ]\s*(?<height>\d{2,5})`
36
+
37
+ function escapeRe(text) {
38
+ return String(text).replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
39
+ }
40
+
41
+ function parseGenerationSize(text, allowed) {
42
+ const prompt = String(text || '').trim()
43
+ let sizeMatch = null
44
+ const patterns = [
45
+ new RegExp(String.raw`(?<!\S)--(?:尺寸|分辨率)\s*(?:=|=|:|:)?\s*${SIZE_VALUE_PATTERN}`, 'i'),
46
+ new RegExp(String.raw`(?:尺寸|分辨率)\s*(?:为|是|=|=|:|:)?\s*${SIZE_VALUE_PATTERN}`, 'i'),
47
+ new RegExp(String.raw`^\s*${SIZE_VALUE_PATTERN}\s*[::,,]`, 'i'),
48
+ ]
49
+ for (const pattern of patterns) {
50
+ sizeMatch = prompt.match(pattern)
51
+ if (sizeMatch) break
52
+ }
53
+
54
+ let selected = null
55
+ if (sizeMatch) {
56
+ selected = [parseInt(sizeMatch.groups.width), parseInt(sizeMatch.groups.height)]
57
+ } else {
58
+ const aliases = Object.keys(SIZE_ALIASES)
59
+ .sort((a, b) => b.length - a.length)
60
+ .map(escapeRe)
61
+ .join('|')
62
+ const aliasPatterns = [
63
+ new RegExp(String.raw`(?<!\S)--(?:尺寸|分辨率)\s*(?:=|=|:|:)?\s*(?<alias>${aliases})(?=$|\s|[::,,])`, 'i'),
64
+ new RegExp(String.raw`(?:尺寸|分辨率)\s*(?:为|是|=|=|:|:)?\s*(?<alias>${aliases})(?=$|\s|[::,,])`, 'i'),
65
+ new RegExp(String.raw`^\s*(?<alias>${aliases})(?=$|\s|[::,,])\s*[::,,]?`, 'i'),
66
+ ]
67
+ for (const pattern of aliasPatterns) {
68
+ sizeMatch = prompt.match(pattern)
69
+ if (sizeMatch) break
70
+ }
71
+ if (sizeMatch && allowed.length) {
72
+ const targetRatio = SIZE_ALIASES[sizeMatch.groups.alias]
73
+ selected = allowed.reduce((best, size) => {
74
+ const a = Math.abs(size[0] / size[1] - targetRatio)
75
+ const b = Math.abs(size[0] * size[1] - 1024 * 1024)
76
+ const ba = Math.abs(best[0] / best[1] - targetRatio)
77
+ const bb = Math.abs(best[0] * best[1] - 1024 * 1024)
78
+ return a < ba || (a === ba && b < bb) ? size : best
79
+ })
80
+ }
81
+ }
82
+
83
+ if (!sizeMatch) return { prompt, size: null, error: null }
84
+
85
+ let cleaned = (prompt.slice(0, sizeMatch.index) + ' ' + prompt.slice(sizeMatch.index + sizeMatch[0].length)).trim()
86
+ cleaned = cleaned.replace(/^[\s,,;;::]+|[\s,,;;::]+$/g, '')
87
+ cleaned = cleaned.replace(/([,,;;])\s*[,,;;]+/g, '$1')
88
+ cleaned = cleaned.replace(/\s+/g, ' ')
89
+
90
+ if (selected && allowed.length && !allowed.some(s => s[0] === selected[0] && s[1] === selected[1])) {
91
+ return {
92
+ prompt: cleaned,
93
+ size: null,
94
+ error: `尺寸 ${selected[0]}x${selected[1]} 不可用。可用尺寸:${allowed.map(s => `${s[0]}x${s[1]}`).join('、')}`,
95
+ }
96
+ }
97
+ if (selected === null) return { prompt: cleaned, size: null, error: '当前没有配置可用尺寸。' }
98
+ return { prompt: cleaned, size: selected, error: null }
99
+ }
100
+
101
+ // 批量张数解析:x3 / ×3 / 3张 / 三张 / --数量 3 / 数量:3
102
+ const BATCH_TOKEN_PATTERNS = [
103
+ /(?<!\S)(--|——)(?:数量|张数)\s*(?:=|=|:|:)?\s*(?<num>\d+)/i,
104
+ /(?:数量|张数)\s*(?:为|是|=|=|:|:)\s*(?<num>\d+)/i,
105
+ /(?<!\S)[x×X](?<num>\d+)(?![a-zA-Z0-9])/,
106
+ /(?<!\S)(?<num>[一二两三四五六七八九十]+)张/,
107
+ /(?<!\S)(?<num>\d+)张(?:图)?/,
108
+ ]
109
+
110
+ const CN_NUM_MAP = { '一': 1, '两': 2, '二': 2, '三': 3, '四': 4, '五': 5, '六': 6, '七': 7, '八': 8, '九': 9, '十': 10 }
111
+
112
+ function cnNumValue(text) {
113
+ if (/^\d+$/.test(text)) return parseInt(text, 10)
114
+ if (CN_NUM_MAP[text] != null) return CN_NUM_MAP[text]
115
+ if (/^十[一二三四五六七八九]$/.test(text)) return 10 + CN_NUM_MAP[text.slice(1)]
116
+ if (text === '十') return 10
117
+ return 0
118
+ }
119
+
120
+ function parseBatchCount(text, max) {
121
+ const prompt = String(text || '').trim()
122
+ const cap = Math.max(1, parseInt(max) || 1)
123
+ let requested = 1
124
+ let matched = false
125
+ for (const pattern of BATCH_TOKEN_PATTERNS) {
126
+ const m = prompt.match(pattern)
127
+ if (!m) continue
128
+ const numText = m.groups && m.groups.num != null ? m.groups.num : ''
129
+ const value = cnNumValue(numText)
130
+ if (value >= 1) {
131
+ requested = value
132
+ matched = true
133
+ }
134
+ const cleaned = (prompt.slice(0, m.index) + ' ' + prompt.slice(m.index + m[0].length)).trim()
135
+ .replace(/^[\s,,;;::]+|[\s,,;;::]+$/g, '')
136
+ .replace(/\s+/g, ' ')
137
+ return { count: Math.min(requested, cap), requested, prompt: cleaned, matched, clamped: requested > cap }
138
+ }
139
+ return { count: 1, requested: 1, prompt, matched, clamped: false }
140
+ }
141
+
142
+ // 固定种子解析:--seed 17021628 / --seed:17021628 / --seed=17021628 / --seed=123
143
+ // 从提示词里剥离并返回 { seed, prompt }
144
+ function parseSeed(text) {
145
+ const prompt = String(text || '').trim()
146
+ const m = prompt.match(/(?<!\S)--seed\s*(?:=|=|:|:)?\s*(\d+)/i)
147
+ if (!m) return { seed: null, prompt }
148
+ const seed = parseInt(m[1], 10) >>> 0
149
+ const cleaned = (prompt.slice(0, m.index) + ' ' + prompt.slice(m.index + m[0].length)).trim()
150
+ .replace(/^[\s,,;;::]+|[\s,,;;::]+$/g, '')
151
+ .replace(/\s+/g, ' ')
152
+ return { seed, prompt: cleaned }
153
+ }
154
+
155
+ // 去噪强度解析:--denoise 0.3 / --denoise=0.6 / --去噪 0.4,并从提示词里剥离
156
+ function parseDenoise(text) {
157
+ const prompt = String(text || '').trim()
158
+ const m = prompt.match(/(?<!\S)--(?:denoise|去噪)\s*(?:=|=|:|:)?\s*(\d+(?:\.\d+)?)/i)
159
+ if (!m) return { denoise: null, prompt }
160
+ const denoise = Math.min(1, Math.max(0, parseFloat(m[1])))
161
+ const cleaned = (prompt.slice(0, m.index) + ' ' + prompt.slice(m.index + m[0].length)).trim()
162
+ .replace(/^[\s,,;;::]+|[\s,,;;::]+$/g, '')
163
+ .replace(/\s+/g, ' ')
164
+ return { denoise, prompt: cleaned }
165
+ }
166
+
167
+ // ------------------------------------------------------------------
168
+ // 提示词辅助(移植自 anima prompt_presets)
169
+ // ------------------------------------------------------------------
170
+ const RAW_PREFIXES = [
171
+ '原样', '原样tags', '原样tag', '原样 tags', '原样 tag',
172
+ '直接画', '直接出图', '直接生图', '直接tags', '直接tag', '直接 tags', '直接 tag',
173
+ '不优化', '无优化', '无优化tags', '无优化tag', '无优化 tags', '无优化 tag',
174
+ '不要优化', '跳过优化', '跳过提示词优化', 'raw tags', 'raw tag', 'raw',
175
+ 'no optimize', 'no optimization', '不用优化',
176
+ ]
177
+
178
+ function stripRawPrefix(prompt) {
179
+ const text = String(prompt || '').trim()
180
+ const lowered = text.toLowerCase()
181
+ for (const prefix of RAW_PREFIXES) {
182
+ if (lowered.startsWith(prefix.toLowerCase())) {
183
+ return { raw: true, prompt: text.slice(prefix.length).replace(/^[\s,,;;::]+/, '').trim() }
184
+ }
185
+ }
186
+ return { raw: false, prompt: text }
187
+ }
188
+
189
+ function mergeTagText(existing, addition) {
190
+ const tags = []
191
+ const seen = new Set()
192
+ for (const source of [existing, addition]) {
193
+ for (const tag of String(source || '').split(',')) {
194
+ const text = tag.trim()
195
+ if (!text) continue
196
+ const key = text.toLowerCase()
197
+ if (seen.has(key)) continue
198
+ tags.push(text)
199
+ seen.add(key)
200
+ }
201
+ }
202
+ return tags.join(', ') + (tags.length ? ',' : '')
203
+ }
204
+
205
+ function parseNameTags(text) {
206
+ const raw = String(text || '').trim()
207
+ const candidates = []
208
+ for (const separator of ['=', '=', ':', ':']) {
209
+ const index = raw.indexOf(separator)
210
+ if (index >= 0) candidates.push([index, separator])
211
+ }
212
+ candidates.sort((a, b) => a[0] - b[0])
213
+ for (const [index, separator] of candidates) {
214
+ const name = raw.slice(0, index).trim()
215
+ const tags = raw.slice(index + separator.length).trim()
216
+ if (!name || !tags) continue
217
+ if (name.includes(',') || name.includes('\n')) continue
218
+ if (/[@()[\]{}]/.test(name)) continue
219
+ if (['artist', 'tag', 'tags', 'prompt', 'positive', 'negative'].includes(name.toLowerCase())) continue
220
+ return { name, tags }
221
+ }
222
+ return null
223
+ }
224
+
225
+ function parsePresetListRaw(list) {
226
+ const result = {}
227
+ for (const item of list || []) {
228
+ const text = String(item || '').trim()
229
+ if (!text) continue
230
+ const parsed = parseNameTags(text)
231
+ if (parsed) result[parsed.name] = parsed.tags
232
+ }
233
+ return result
234
+ }
235
+
236
+ // 预设列表解析带缓存:cfg.fixedCharacters / cfg.artistPresets 在持久化时整体替换(不原地修改),
237
+ // 用 WeakMap 按数组引用缓存解析结果,热路径(composePrompt/多人规划/批量)不再重复 split。
238
+ const presetListCache = new WeakMap()
239
+ function parsePresetList(list) {
240
+ if (list && typeof list === 'object' && presetListCache.has(list)) {
241
+ return presetListCache.get(list)
242
+ }
243
+ const result = parsePresetListRaw(list)
244
+ if (list && typeof list === 'object') presetListCache.set(list, result)
245
+ return result
246
+ }
247
+
248
+ module.exports = {
249
+ normalizeBaseUrl,
250
+ SIZE_ALIASES,
251
+ SIZE_VALUE_PATTERN,
252
+ escapeRe,
253
+ parseGenerationSize,
254
+ BATCH_TOKEN_PATTERNS,
255
+ CN_NUM_MAP,
256
+ cnNumValue,
257
+ parseBatchCount,
258
+ parseSeed,
259
+ parseDenoise,
260
+ RAW_PREFIXES,
261
+ stripRawPrefix,
262
+ mergeTagText,
263
+ parseNameTags,
264
+ parsePresetList,
265
+ }
package/lib/tags.js ADDED
@@ -0,0 +1,261 @@
1
+ // Tag 清洗纯函数库(移植自 anima tag_cleaner)。
2
+ // 不依赖 koishi,可独立单测。
3
+
4
+ function splitTags(text) {
5
+ let cleaned = String(text || '')
6
+ cleaned = cleaned.replace(/```[\s\S]*?```/g, m => m.slice(3, -3).trim())
7
+ cleaned = cleaned.replace(/,/g, ',').replace(/、/g, ',').replace(/;/g, ',')
8
+ cleaned = cleaned.replace(/\n/g, ',')
9
+ cleaned = cleaned.replace(/^(?:positive|prompt|tags|提示词|正向提示词)\s*[::]/i, '')
10
+ const parts = cleaned.split(',').map(p => p.trim().replace(/^[\s,.;::]+|[\s,.;::]+$/g, ''))
11
+ return parts.filter(Boolean)
12
+ }
13
+
14
+ function normalizeTagKey(tag) {
15
+ let value = String(tag || '').trim().toLowerCase()
16
+ if (
17
+ value.startsWith('(') && value.endsWith(')') &&
18
+ (value.match(/\(/g) || []).length === 1 &&
19
+ (value.match(/\)/g) || []).length === 1
20
+ ) {
21
+ value = value.slice(1, -1).trim()
22
+ }
23
+ value = value.replace(/:\s*[\d.]+$/, '')
24
+ value = value.replace(/\s+/g, ' ')
25
+ return value
26
+ }
27
+
28
+ function stripWrappingBrackets(text) {
29
+ let value = String(text || '').trim()
30
+ const pairs = { '(': ')', '[': ']', '{': '}' }
31
+ let changed = true
32
+ while (changed && value.length >= 2) {
33
+ changed = false
34
+ const left = value[0]
35
+ const right = pairs[left]
36
+ if (right && value.endsWith(right)) {
37
+ value = value.slice(1, -1).trim()
38
+ changed = true
39
+ }
40
+ }
41
+ return value
42
+ }
43
+
44
+ const ARTIST_FUNCTION_RE = /^artist\s*:\s*([^:=()[\]{}]+?)\s*(?:[:=]\s*[-+]?(?:\d+(?:\.\d+)?|\.\d+)\s*)?$/i
45
+
46
+ function normalizeAnimaArtistTag(tag) {
47
+ const raw = String(tag || '').trim()
48
+ if (!raw) return ''
49
+ if (raw.startsWith('@')) {
50
+ const name = raw.slice(1).trim().replace(/_/g, ' ').replace(/\s+/g, ' ').trim()
51
+ return name ? `@${name}` : raw
52
+ }
53
+ const inner = stripWrappingBrackets(raw)
54
+ const match = ARTIST_FUNCTION_RE.exec(inner)
55
+ if (!match) return raw
56
+ let name = match[1].trim()
57
+ if (name.startsWith('@')) name = name.slice(1).trim()
58
+ name = name.replace(/_/g, ' ').replace(/\s+/g, ' ').trim()
59
+ return name ? `@${name}` : raw
60
+ }
61
+
62
+ function canonicalTagText(tag) {
63
+ const artistTag = normalizeAnimaArtistTag(tag)
64
+ if (artistTag.startsWith('@')) return artistTag
65
+ const key = normalizeTagKey(tag)
66
+ if (key === '1 girl') return '1girl'
67
+ if (key === 'punis') return 'penis'
68
+ if (['point a sword at the audience', 'point a sword at viewer', 'point sword at the audience', 'point sword at viewer'].includes(key)) return 'sword pointed at viewer'
69
+ return String(tag || '').trim()
70
+ }
71
+
72
+ // 内联画师标签(@name / artist:name)与内联质量词(masterpiece / best quality / score_N 等)。
73
+ // 提示词优化会把它们交给 LLM 重写并剥掉,这里在优化后把它们从原始输入中补回,
74
+ // 避免用户直接写在消息里的画师标签 / 质量词丢失。
75
+ const INLINE_QUALITY_RE = /^(masterpiece|best quality|amazing quality|high quality|good quality|score_\d+)$/i
76
+
77
+ // 用户明确表示「不要画师 / 不要风格」时的守卫正则。
78
+ // 语义说明:这与负面词里的 artist name(不要画师署名/水印)是两回事;
79
+ // 只有用户明确说了这些词才跳过画师/风格 tags,其余情况画师 tags 正常拼接。
80
+ const NO_ARTIST_RE = /(不用我的风格|不要我的风格|不使用我的风格|不要画师|不用画师|不加画师|不要画师词|不用画师词|不加画师词|no artist)/i
81
+ const NO_STYLE_RE = /(不用我的风格|不要我的风格|不使用我的风格)/i
82
+
83
+ function appendInlineProtectedTags(prompt, original, raw) {
84
+ if (raw || !original) return prompt
85
+ if (NO_ARTIST_RE.test(original)) return prompt
86
+ const tags = []
87
+ const seen = new Set(splitTags(prompt).map(t => normalizeTagKey(t)))
88
+ const addArtist = (artist) => {
89
+ const key = normalizeTagKey(artist)
90
+ if (!seen.has(key)) {
91
+ seen.add(key)
92
+ tags.push(artist)
93
+ }
94
+ }
95
+ const addQuality = (t) => {
96
+ const key = normalizeTagKey(t)
97
+ if (!seen.has(key)) {
98
+ seen.add(key)
99
+ tags.push(t)
100
+ }
101
+ }
102
+ // 逗号分隔的显式 token
103
+ for (const token of splitTags(original)) {
104
+ const t = String(token || '').trim()
105
+ if (!t) continue
106
+ const artist = normalizeAnimaArtistTag(t)
107
+ if (artist.startsWith('@')) {
108
+ addArtist(artist)
109
+ continue
110
+ }
111
+ if (INLINE_QUALITY_RE.test(t)) addQuality(t)
112
+ }
113
+ // 修复:未用逗号分隔的内联 @artist(如「帮我画一个@wlop风格的女孩」)也会被保留
114
+ const inlineMatches = String(original).match(/@[^\s,,、;;@]+/g) || []
115
+ for (const rawArtist of inlineMatches) {
116
+ const artist = normalizeAnimaArtistTag(rawArtist)
117
+ if (artist.startsWith('@')) addArtist(artist)
118
+ }
119
+ if (!tags.length) return prompt
120
+ return prompt + ', ' + tags.join(', ')
121
+ }
122
+
123
+ const QUALITY_BLOCKLIST = new Set([
124
+ 'masterpiece', 'best quality', 'score_7', 'score_6', 'score_5', 'score_4', 'score_3', 'score_2', 'score_1',
125
+ 'safe', 'worst quality', 'low quality', 'artist name',
126
+ ])
127
+ const CHARACTER_BLOCKLIST = new Set(['1 girl', '1girl', 'solo'])
128
+ const CHARACTER_IDENTITY_EXACT_BLOCKLIST = new Set([
129
+ 'girl', 'boy', 'child', 'teenager', 'young adult', 'adult', 'mature', 'loli', 'shota', 'petite', 'aged down', 'age regression',
130
+ 'vampire', 'angel', 'demon', 'fox girl', 'cat girl', 'animal girl',
131
+ 'ahoge', 'bangs', 'blunt bangs', 'sidelocks', 'hair between eyes', 'long hair', 'short hair', 'medium hair', 'very long hair',
132
+ 'twintails', 'low twintails', 'braids', 'side braid', 'ponytail', 'side ponytail', 'one side up', 'hair bun', 'double bun',
133
+ 'heterochromia', 'blue eyes', 'red eyes', 'green eyes', 'pink eyes', 'purple eyes', 'yellow eyes', 'golden eyes', 'grey eyes',
134
+ 'gray eyes', 'brown eyes', 'black eyes', 'black hair', 'brown hair', 'blonde hair', 'white hair', 'silver hair', 'blue hair',
135
+ 'red hair', 'pink hair', 'purple hair', 'green hair', 'grey hair', 'gray hair',
136
+ 'fox ears', 'cat ears', 'animal ears', 'pointed ears', 'tail', 'fox tail', 'cat tail', 'wings', 'angel wings', 'demon wings',
137
+ 'horns', 'halo', 'fang', 'freckles',
138
+ ])
139
+ const CHARACTER_IDENTITY_PATTERNS = [
140
+ /\b(?:black|brown|blonde|white|silver|blue|red|pink|purple|green|grey|gray|orange|gold|golden|light|dark|ice blue|silver white)\s+hair\b/,
141
+ /\b(?:black|brown|blue|red|pink|purple|green|grey|gray|gold|golden|light|dark|ice blue|amber)\s+eyes?\b/,
142
+ /\b(?:ears?|tail|wings?|horns?|halo|fangs?|heterochromia)\b/,
143
+ /\b(?:vampire|angel|demon|fox girl|cat girl|animal girl)\b/,
144
+ /\b(?:loli|shota|teenager|young adult|adult|mature|aged down|age regression)\b/,
145
+ ]
146
+ const MULTI_CHARACTER_BLOCKLIST = new Set([
147
+ '2girls', '3girls', '4girls', '5girls', '6+girls', 'multiple girls',
148
+ '2boys', '3boys', '4boys', '5boys', '6+boys', 'multiple boys',
149
+ 'multiple people', 'crowd', 'group', 'background characters', 'extra girl', 'extra person', 'clone', 'duplicate', 'twins',
150
+ ])
151
+ const NON_VISUAL_TAGS = new Set(['holding nothing'])
152
+ const EXCLUSIVE_TAG_GROUPS = {
153
+ 'looking at viewer': 'gaze_target', 'looking away': 'gaze_target',
154
+ 'light rays': 'light_beams', 'sun rays': 'light_beams', 'sunbeams': 'light_beams', 'sunlight rays': 'light_beams',
155
+ 'glowing': 'light_intensity', 'illuminated': 'light_intensity', 'bright': 'light_intensity', 'luminous': 'light_intensity', 'radiant': 'light_intensity',
156
+ 'backlight': 'backlighting', 'backlighting': 'backlighting',
157
+ 'rim light': 'rim_lighting', 'rim lighting': 'rim_lighting',
158
+ 'soft light': 'soft_lighting', 'soft lighting': 'soft_lighting',
159
+ 'floating particles': 'light_particles', 'light particles': 'light_particles', 'glowing particles': 'light_particles',
160
+ 'flowing dress': 'flowing_dress', 'dress flowing': 'flowing_dress',
161
+ 'hair blowing': 'wind_in_hair', 'wind in hair': 'wind_in_hair',
162
+ 'sad expression': 'sad_expression', 'sorrowful expression': 'sad_expression',
163
+ 'teary eyes': 'tearful_eyes', 'watery eyes': 'tearful_eyes', 'wet eyes': 'tearful_eyes',
164
+ }
165
+ const TAG_GROUP_LIMITS = { light_intensity: 2 }
166
+
167
+ function isCharacterIdentityTag(key) {
168
+ const compact = normalizeTagKey(key).replace(/_/g, ' ')
169
+ if (!compact) return false
170
+ if (CHARACTER_IDENTITY_EXACT_BLOCKLIST.has(compact)) return true
171
+ return CHARACTER_IDENTITY_PATTERNS.some(pattern => pattern.test(compact))
172
+ }
173
+
174
+ function cleanContentTags(text, maxTags = 65, stripCharacterTags = true, protectedCoreTags = [], allowMultiCharacter = false) {
175
+ const tags = splitTags(text)
176
+ const seen = new Set()
177
+ const cleaned = []
178
+ const artistRe = /^@\S+/
179
+ const protectedSet = new Set(protectedCoreTags.map(t => normalizeTagKey(t)))
180
+ const parenthesizedCoreRe = /^[a-z0-9_.'-]+_\([a-z0-9_.' -]{2,60}\)$/i
181
+ for (let tag of tags) {
182
+ tag = canonicalTagText(tag)
183
+ const key = normalizeTagKey(tag)
184
+ if (!key) continue
185
+ if (seen.has(key)) continue
186
+ if (QUALITY_BLOCKLIST.has(key)) continue
187
+ if (stripCharacterTags && CHARACTER_BLOCKLIST.has(key)) continue
188
+ if (stripCharacterTags && isCharacterIdentityTag(key)) continue
189
+ if (!allowMultiCharacter && MULTI_CHARACTER_BLOCKLIST.has(key)) continue
190
+ if (protectedSet.size && parenthesizedCoreRe.test(key) && !protectedSet.has(key)) continue
191
+ if (artistRe.test(tag.trim())) continue
192
+ if (tag.length > 80) continue
193
+ seen.add(key)
194
+ cleaned.push(tag)
195
+ }
196
+ const semanticKeys = cleaned.map(tag => normalizeTagKey(stripWrappingBrackets(tag)))
197
+ const fullNudityKey = semanticKeys.includes('nude') ? 'nude' : 'naked'
198
+ const hasFullNudity = semanticKeys.includes(fullNudityKey)
199
+ const hasSpecificMist = semanticKeys.includes('morning mist')
200
+ const hasClosedEyes = semanticKeys.some(k => k === 'closed eyes' || k === 'eyes closed')
201
+ const hasSheerFabric = semanticKeys.includes('sheer fabric')
202
+ const groupCounts = {}
203
+ const semanticCleaned = []
204
+ cleaned.forEach((tag, i) => {
205
+ const key = semanticKeys[i]
206
+ if (NON_VISUAL_TAGS.has(key)) return
207
+ if (hasFullNudity && ['nude', 'naked', 'topless', 'bottomless'].includes(key)) {
208
+ if (key !== fullNudityKey) return
209
+ }
210
+ if (hasSpecificMist && key === 'mist') return
211
+ if (hasClosedEyes && key.includes('looking') && key.includes('viewer')) return
212
+ if (hasSheerFabric && key === 'translucent fabric') return
213
+ const group = EXCLUSIVE_TAG_GROUPS[key.replace(/_/g, ' ')]
214
+ if (group) {
215
+ const count = groupCounts[group] || 0
216
+ if (count >= (TAG_GROUP_LIMITS[group] != null ? TAG_GROUP_LIMITS[group] : 1)) return
217
+ groupCounts[group] = count + 1
218
+ }
219
+ semanticCleaned.push(tag)
220
+ })
221
+ return semanticCleaned.slice(0, maxTags).join(', ')
222
+ }
223
+
224
+ function joinPromptParts(parts) {
225
+ const tags = []
226
+ const seen = new Set()
227
+ for (const part of parts) {
228
+ for (const tag of splitTags(part)) {
229
+ const canonical = canonicalTagText(tag)
230
+ const key = normalizeTagKey(canonical)
231
+ if (!key || seen.has(key)) continue
232
+ seen.add(key)
233
+ tags.push(canonical)
234
+ }
235
+ }
236
+ return tags.join(', ')
237
+ }
238
+
239
+ module.exports = {
240
+ splitTags,
241
+ normalizeTagKey,
242
+ stripWrappingBrackets,
243
+ ARTIST_FUNCTION_RE,
244
+ normalizeAnimaArtistTag,
245
+ canonicalTagText,
246
+ INLINE_QUALITY_RE,
247
+ NO_ARTIST_RE,
248
+ NO_STYLE_RE,
249
+ appendInlineProtectedTags,
250
+ QUALITY_BLOCKLIST,
251
+ CHARACTER_BLOCKLIST,
252
+ CHARACTER_IDENTITY_EXACT_BLOCKLIST,
253
+ CHARACTER_IDENTITY_PATTERNS,
254
+ MULTI_CHARACTER_BLOCKLIST,
255
+ NON_VISUAL_TAGS,
256
+ EXCLUSIVE_TAG_GROUPS,
257
+ TAG_GROUP_LIMITS,
258
+ isCharacterIdentityTag,
259
+ cleanContentTags,
260
+ joinPromptParts,
261
+ }