@waterwx/dsh-novel-forge 1.4.1 → 1.5.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 (43) hide show
  1. package/assets/styles/arcane-thick-3d.webp +0 -0
  2. package/assets/styles/chinese-ink.webp +0 -0
  3. package/assets/styles/clay-stopmotion-3d.webp +0 -0
  4. package/assets/styles/dark-ghibli.webp +0 -0
  5. package/assets/styles/dark-gothic-3d.webp +0 -0
  6. package/assets/styles/film-photo.webp +0 -0
  7. package/assets/styles/guofeng-heavy.webp +0 -0
  8. package/assets/styles/horror-game-3d.webp +0 -0
  9. package/assets/styles/japan-cel-dark.webp +0 -0
  10. package/assets/styles/korean-webtoon.webp +0 -0
  11. package/assets/styles/low-cyberpunk-3d.webp +0 -0
  12. package/assets/styles/nextgen-cg.webp +0 -0
  13. package/assets/styles/noir-film.webp +0 -0
  14. package/assets/styles/openworld-survival.webp +0 -0
  15. package/assets/styles/paper-cut-3d.webp +0 -0
  16. package/assets/styles/pixar-adult-3d.webp +0 -0
  17. package/assets/styles/pop-marvel-2d.webp +0 -0
  18. package/assets/styles/shinkai-anime.webp +0 -0
  19. package/assets/styles/tactical-stealth.webp +0 -0
  20. package/assets/styles/vaporwave-3d.webp +0 -0
  21. package/assets/styles/vhs90.webp +0 -0
  22. package/lib/client.js +1455 -301
  23. package/lib/client.js.map +1 -1
  24. package/lib/index.js +589 -6
  25. package/lib/index.js.map +1 -1
  26. package/lib/types/client/api.d.ts +8 -0
  27. package/lib/types/client/panel/MangaWorkspace.d.ts +9 -0
  28. package/lib/types/client/panel/NovelPanel.d.ts +1 -1
  29. package/lib/types/client/panel/StoryboardTab.d.ts +13 -0
  30. package/lib/types/client/panel/StyleGallery.d.ts +1 -0
  31. package/lib/types/engine.d.ts +19 -3
  32. package/lib/types/protocol.d.ts +149 -0
  33. package/lib/types/style-library.d.ts +33 -0
  34. package/package.json +2 -1
  35. package/src/client/api.ts +20 -0
  36. package/src/client/panel/MangaWorkspace.tsx +185 -0
  37. package/src/client/panel/NovelPanel.tsx +18 -3
  38. package/src/client/panel/StoryboardTab.tsx +287 -0
  39. package/src/client/panel/StyleGallery.tsx +109 -0
  40. package/src/engine.ts +247 -0
  41. package/src/protocol.ts +164 -0
  42. package/src/routes.ts +192 -2
  43. package/src/style-library.ts +78 -0
package/src/routes.ts CHANGED
@@ -12,6 +12,8 @@ import type { IncomingMessage, ServerResponse } from 'node:http'
12
12
  import { exec } from 'node:child_process'
13
13
  import { copyFileSync, existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
14
14
  import { join, basename, extname } from 'node:path'
15
+ import { homedir } from 'node:os'
16
+ import { fileURLToPath } from 'node:url'
15
17
  import type { WebRoute } from '@deepseek-ai/dsh-host-webserver'
16
18
  import type { Context } from '@deepseek-ai/cordis'
17
19
  import { ProductionRunner } from './run.ts'
@@ -55,6 +57,9 @@ import {
55
57
  type ForeshadowResponse,
56
58
  type JobFrame,
57
59
  type LoadOutlineRequest,
60
+ type MangaPlan,
61
+ type MangaPlansRequest,
62
+ type MangaPlansResponse,
58
63
  type LoadOutlineResponse,
59
64
  type NovelConfig,
60
65
  type PlotlinesRequest,
@@ -85,6 +90,12 @@ import {
85
90
  type SensitiveHit,
86
91
  type StatusResponse,
87
92
  type StoryBible,
93
+ type StoryboardSkeletonRequest,
94
+ type StoryboardSkeletonResponse,
95
+ type StoryboardTableRequest,
96
+ type StoryboardTableResponse,
97
+ type StoryboardPromptsRequest,
98
+ type StoryboardPromptsResponse,
88
99
  type StyleEngineRequest,
89
100
  type SummaryRequest,
90
101
  type VolumesRequest,
@@ -135,6 +146,9 @@ import {
135
146
  reverseOutlineFromChapters,
136
147
  breakdownBook,
137
148
  generateRoleReferenceImage,
149
+ generateStoryboardSkeleton,
150
+ generateStoryboardTable,
151
+ generateStoryboardPrompts,
138
152
  checkSensitiveText,
139
153
  suggestForeshadows,
140
154
  suggestPlotlines,
@@ -146,6 +160,9 @@ import {
146
160
  /** Cap on JSON request bodies (generous: cover images travel as base64). */
147
161
  const MAX_JSON_BODY_BYTES = 64 * 1024 * 1024
148
162
 
163
+ /** 包内置风格效果图目录(assets/styles,随 npm 包分发)。 */
164
+ const builtinStyleDir = fileURLToPath(new URL('../assets/styles/', import.meta.url))
165
+
149
166
  /** Loopback-only fence (mirrors the family plugins' pairing routes). */
150
167
  function isLoopbackRequest(request: IncomingMessage): boolean {
151
168
  const address = request.socket.remoteAddress
@@ -1854,7 +1871,7 @@ export function makeRoutes(deps: NovelRoutesDeps): WebRoute[] {
1854
1871
  return
1855
1872
  }
1856
1873
  try {
1857
- const visual = await extractRoleVisual(ctx, config, project, config.outputDir, name)
1874
+ const visual = await extractRoleVisual(ctx, config, project, config.outputDir, name, body?.styleId, body?.filterId)
1858
1875
  const role = project.roles.find(r => r.name === name)
1859
1876
  if (role !== undefined) {
1860
1877
  role.imagePrompt = visual
@@ -1877,7 +1894,7 @@ export function makeRoutes(deps: NovelRoutesDeps): WebRoute[] {
1877
1894
  return
1878
1895
  }
1879
1896
  try {
1880
- const kit = await generateRolePromptKit(ctx, config, project, name)
1897
+ const kit = await generateRolePromptKit(ctx, config, project, name, body?.styleId, body?.filterId)
1881
1898
  const role = project.roles.find(r => r.name === name)
1882
1899
  if (role !== undefined) role.promptKit = kit
1883
1900
  project.updatedAt = new Date().toISOString()
@@ -2146,6 +2163,174 @@ export function makeRoutes(deps: NovelRoutesDeps): WebRoute[] {
2146
2163
  },
2147
2164
  }
2148
2165
 
2166
+ // ------------------------------------------------- storyboard prompts
2167
+ /** 分镜·提示词级:分镜表 → 即梦可粘贴视频提示词。 */
2168
+ const storyboardPromptsRoute: WebRoute = {
2169
+ kind: 'exact',
2170
+ path: NOVEL_API.storyboardPrompts,
2171
+ handler: async (req, res) => {
2172
+ if (!guard(req, res, 'POST')) return
2173
+ const config = getConfig()
2174
+ const project = requireProject(res)
2175
+ if (project === undefined) return
2176
+ const body = await readJsonBody<StoryboardPromptsRequest>(req)
2177
+ const no = body?.chapterNo
2178
+ if (!Number.isInteger(no) || no === undefined || no < 1) {
2179
+ writeJson(res, 400, { error: 'chapterNo 须为正整数' })
2180
+ return
2181
+ }
2182
+ if (body?.table === undefined || (body.table.shots ?? []).length === 0) {
2183
+ writeJson(res, 400, { error: 'table 不能为空,请先生成分镜表' })
2184
+ return
2185
+ }
2186
+ try {
2187
+ const prompts = await generateStoryboardPrompts(ctx, config, project, config.outputDir, no, body.table, body?.styleId, body?.filterId)
2188
+ writeJson(res, 200, { prompts } satisfies StoryboardPromptsResponse)
2189
+ } catch (error) {
2190
+ writeJson(res, 500, { error: `视频提示词生成失败:${(error as Error).message}` })
2191
+ }
2192
+ },
2193
+ }
2194
+
2195
+ // ---------------------------------------------------- storyboard table
2196
+ /** 分镜·导演级:骨架 → 分镜表(镜头级)。 */
2197
+ const storyboardTableRoute: WebRoute = {
2198
+ kind: 'exact',
2199
+ path: NOVEL_API.storyboardTable,
2200
+ handler: async (req, res) => {
2201
+ if (!guard(req, res, 'POST')) return
2202
+ const config = getConfig()
2203
+ const project = requireProject(res)
2204
+ if (project === undefined) return
2205
+ const body = await readJsonBody<StoryboardTableRequest>(req)
2206
+ const no = body?.chapterNo
2207
+ if (!Number.isInteger(no) || no === undefined || no < 1) {
2208
+ writeJson(res, 400, { error: 'chapterNo 须为正整数' })
2209
+ return
2210
+ }
2211
+ if (body?.skeleton === undefined || (body.skeleton.beats ?? []).length === 0) {
2212
+ writeJson(res, 400, { error: 'skeleton 不能为空,请先生成剧情骨架' })
2213
+ return
2214
+ }
2215
+ try {
2216
+ const table = await generateStoryboardTable(ctx, config, project, config.outputDir, no, body.skeleton, body?.styleId, body?.filterId)
2217
+ writeJson(res, 200, { table } satisfies StoryboardTableResponse)
2218
+ } catch (error) {
2219
+ writeJson(res, 500, { error: `分镜表生成失败:${(error as Error).message}` })
2220
+ }
2221
+ },
2222
+ }
2223
+
2224
+ // ------------------------------------------------------ manhua plans
2225
+ /** 漫剧方案管理:create / remove / activate。 */
2226
+ const manhuaPlansRoute: WebRoute = {
2227
+ kind: 'exact',
2228
+ path: NOVEL_API.manhuaPlans,
2229
+ handler: async (req, res) => {
2230
+ if (!guard(req, res, 'POST')) return
2231
+ const config = getConfig()
2232
+ const project = requireProject(res)
2233
+ if (project === undefined) return
2234
+ const body = await readJsonBody<MangaPlansRequest>(req)
2235
+ if (project.mangaPlans === undefined) project.mangaPlans = []
2236
+ if (body?.op === 'create') {
2237
+ const name = body.name?.trim()
2238
+ const styleId = body.styleId?.trim()
2239
+ if (name === undefined || name === '' || styleId === undefined || styleId === '') {
2240
+ writeJson(res, 400, { error: 'name 与 styleId 不能为空' })
2241
+ return
2242
+ }
2243
+ if (project.mangaPlans.some(p => p.name === name)) {
2244
+ writeJson(res, 400, { error: `方案名「${name}」已存在` })
2245
+ return
2246
+ }
2247
+ const id = `manga-${Date.now().toString(36)}`
2248
+ project.mangaPlans.push({
2249
+ id,
2250
+ name: name.slice(0, 40),
2251
+ styleId,
2252
+ filterId: body.filterId?.trim() !== '' ? body.filterId?.trim() : undefined,
2253
+ active: project.mangaPlans.length === 0,
2254
+ createdAt: new Date().toISOString(),
2255
+ updatedAt: new Date().toISOString(),
2256
+ })
2257
+ } else if (body?.op === 'remove' && body.id !== undefined) {
2258
+ const removed = project.mangaPlans.find(p => p.id === body.id)
2259
+ project.mangaPlans = project.mangaPlans.filter(p => p.id !== body.id)
2260
+ if (removed?.active === true && project.mangaPlans.length > 0) {
2261
+ project.mangaPlans[0].active = true
2262
+ }
2263
+ } else if (body?.op === 'activate' && body.id !== undefined) {
2264
+ project.mangaPlans.forEach(p => { p.active = p.id === body.id })
2265
+ } else {
2266
+ writeJson(res, 400, { error: 'op 须为 create/remove/activate' })
2267
+ return
2268
+ }
2269
+ project.updatedAt = new Date().toISOString()
2270
+ saveProject(config.outputDir, project)
2271
+ writeJson(res, 200, { plans: project.mangaPlans } satisfies MangaPlansResponse)
2272
+ },
2273
+ }
2274
+
2275
+ // ------------------------------------------------------ style image
2276
+ /** 风格库效果图:GET /styles/image?id=<styleId>,从 ~/.dsh/dsh-novel-forge-styles/<id>.png 读取。 */
2277
+ const styleImageRoute: WebRoute = {
2278
+ kind: 'exact',
2279
+ path: '/api/dsh-novel-forge/styles/image',
2280
+ handler: async (req, res) => {
2281
+ if (!isLoopbackRequest(req)) {
2282
+ writeJson(res, 403, { error: 'forbidden: loopback-only' })
2283
+ return
2284
+ }
2285
+ const url = new URL(req.url ?? '', 'http://localhost')
2286
+ const id = (url.searchParams.get('id') ?? '').replace(/[^a-z0-9-]/gi, '')
2287
+ if (id === '') {
2288
+ writeJson(res, 404, { error: 'style image not found' })
2289
+ return
2290
+ }
2291
+ // 图片来源:用户数据目录缩略图(自定义覆盖)→ 原图 → 包内置缩略图(随插件分发)。
2292
+ const base = join(homedir(), '.dsh', 'dsh-novel-forge-styles')
2293
+ const candidates: Array<{ file: string; type: string }> = [
2294
+ { file: join(base, 'thumbs', id + '.webp'), type: 'image/webp' },
2295
+ { file: join(base, id + '.png'), type: 'image/png' },
2296
+ { file: join(builtinStyleDir, id + '.webp'), type: 'image/webp' },
2297
+ ]
2298
+ for (const c of candidates) {
2299
+ if (existsSync(c.file)) {
2300
+ res.writeHead(200, { 'content-type': c.type, 'cache-control': 'public, max-age=86400' })
2301
+ res.end(readFileSync(c.file))
2302
+ return
2303
+ }
2304
+ }
2305
+ writeJson(res, 404, { error: 'style image not found' })
2306
+ },
2307
+ }
2308
+
2309
+ // ------------------------------------------------------- storyboard
2310
+ /** 分镜·编剧级:单章 → 剧情骨架(节拍链)。 */
2311
+ const storyboardSkeletonRoute: WebRoute = {
2312
+ kind: 'exact',
2313
+ path: NOVEL_API.storyboardSkeleton,
2314
+ handler: async (req, res) => {
2315
+ if (!guard(req, res, 'POST')) return
2316
+ const config = getConfig()
2317
+ const project = requireProject(res)
2318
+ if (project === undefined) return
2319
+ const body = await readJsonBody<StoryboardSkeletonRequest>(req)
2320
+ const no = body?.chapterNo
2321
+ if (!Number.isInteger(no) || no === undefined || no < 1) {
2322
+ writeJson(res, 400, { error: 'chapterNo 须为正整数' })
2323
+ return
2324
+ }
2325
+ try {
2326
+ const skeleton = await generateStoryboardSkeleton(ctx, config, project, config.outputDir, no)
2327
+ writeJson(res, 200, { skeleton } satisfies StoryboardSkeletonResponse)
2328
+ } catch (error) {
2329
+ writeJson(res, 500, { error: `剧情骨架生成失败:${(error as Error).message}` })
2330
+ }
2331
+ },
2332
+ }
2333
+
2149
2334
  // ----------------------------------------------------------- breakdown
2150
2335
  /** 拆书分析:对已写章节做结构/人物/文风/卖点体检(两阶段:源笔记→分节分析)。 */
2151
2336
  const breakdownRoute: WebRoute = {
@@ -2388,6 +2573,11 @@ export function makeRoutes(deps: NovelRoutesDeps): WebRoute[] {
2388
2573
  openFolderRoute,
2389
2574
  outlineSuggestRoute,
2390
2575
  outlineReverseRoute,
2576
+ manhuaPlansRoute,
2577
+ styleImageRoute,
2578
+ storyboardSkeletonRoute,
2579
+ storyboardTableRoute,
2580
+ storyboardPromptsRoute,
2391
2581
  breakdownRoute,
2392
2582
  runStartRoute,
2393
2583
  runControlRoute,
@@ -0,0 +1,78 @@
1
+ /**
2
+ * 风格库(内置模板):漫剧方案的视觉基底 + 可叠加滤镜。
3
+ * 字段来自通用风格模板(豆包整理),keywords 即风格前缀词块,生成时直接套用。
4
+ */
5
+
6
+ export type StyleCategory = '3d' | 'game' | '2d' | 'film' | 'craft'
7
+
8
+ export interface ArtStyle {
9
+ id: string
10
+ /** 风格名(用户可见)。 */
11
+ name: string
12
+ category: StyleCategory
13
+ /** 特点一句话(卡片展示)。 */
14
+ traits: string
15
+ /** 风格关键词/前缀(生成角色图/视频提示词时套用)。 */
16
+ keywords: string
17
+ /** 是否可作为滤镜叠加在基底风格之上(影视实验类;缺省 false)。 */
18
+ stackable?: boolean
19
+ /** 默认推荐排序权重(0-1,推荐池排序参考)。 */
20
+ weight?: number
21
+ }
22
+
23
+ export const STYLE_CATEGORIES: ReadonlyArray<{ id: StyleCategory; label: string; icon: string; desc: string }> = [
24
+ { id: '3d', label: '3D 类', icon: '🧊', desc: '人物稳定性最好,视频不易崩脸' },
25
+ { id: 'game', label: '游戏 3D', icon: '🎮', desc: '游戏渲染质感与镜头语言(生存/恐怖/潜行)' },
26
+ { id: '2d', label: '2D 二次元绘画', icon: '✏️', desc: '手绘感,人物一致性略弱' },
27
+ { id: 'film', label: '影视实验质感', icon: '🎞️', desc: '滤镜/影像体系,可叠加在 3D/2D 之上' },
28
+ { id: 'craft', label: '国风/特殊工艺', icon: '🏮', desc: '国风与传统工艺质感' },
29
+ ]
30
+
31
+ /** 内置风格模板(18 个)。 */
32
+ export const STYLE_LIBRARY: ArtStyle[] = [
33
+ // ---------------- 3D 类 ----------------
34
+ { id: 'pixar-adult-3d', name: '成人向皮克斯 3D', category: '3d', traits: 'CG 三维渲染,材质细腻,微表情表现力强;需压制 Q 版可爱感', keywords: '3D皮克斯动画,成人写实CGI,电影光影,拒绝Q版', weight: 1.0 },
35
+ { id: 'arcane-thick-3d', name: '双城之战美漫厚涂 3D', category: '3d', traits: '粗重轮廓线,高对比硬阴影,材质粗粝,悲剧、压抑、科幻题材适配高', keywords: '双城之战美术,3D美漫厚涂,强轮廓光影,暗调粗粝渲染', weight: 0.95 },
36
+ { id: 'nextgen-cg', name: '次世代游戏 CG', category: '3d', traits: '接近真人的三维渲染,照片级布料、皮肤材质,冷峻写实', keywords: '次世代游戏CG渲染,超写实3D,真实物理材质,电影布光', weight: 0.9 },
37
+ { id: 'low-cyberpunk-3d', name: '底层赛博朋克 3D', category: '3d', traits: '锈蚀工业、破碎霓虹、屏幕噪点;避开华丽高楼,偏向破败底层', keywords: '底层赛博朋克3D,废土工业,破碎霓虹,画面噪点色散', weight: 0.85 },
38
+ { id: 'vaporwave-3d', name: '复古蒸汽波 3D', category: '3d', traits: '粉-蓝-紫霓虹轮廓、90 年代复古网格、胶片色散颗粒,颓废疏离', keywords: '3D复古蒸汽波,90年代三维渲染,霓虹轮廓光,胶片颗粒', weight: 0.8 },
39
+ { id: 'dark-gothic-3d', name: '暗黑哥特 3D', category: '3d', traits: '大面积阴影、冷青色调、衰败破败质感,适合幽暗密闭空间', keywords: '3D暗黑哥特,冷青暗光,衰败质感,大面积阴影', weight: 0.8 },
40
+ { id: 'clay-stopmotion-3d', name: '黏土 / 定格 3D', category: '3d', traits: '捏塑颗粒肌理,手工定格质感;可做压抑向,不局限可爱风', keywords: '3D黏土定格渲染,泥塑肌理,电影打光', weight: 0.7 },
41
+ { id: 'openworld-survival', name: '开放世界生存游戏风', category: 'game', traits: '场景破败感强,货架、仓库、管线、灰尘、锈蚀明显;人物带一点“生存者”气质;适合探索感、逃亡感、世界观展示', keywords: '开放世界生存游戏风格,废土场景,破败仓库,锈蚀金属货架,灰尘粒子,冷色写实光影', weight: 0.8 },
42
+ { id: 'horror-game-3d', name: '恐怖游戏 3D', category: 'game', traits: '大面积阴影,荧光灯频闪,空间压抑;适合悬疑、压迫、精神异常段落', keywords: '恐怖游戏 3D 风格,昏暗仓库,老旧荧光灯,硬阴影,压迫空间,冷灰蓝调,低饱和', weight: 0.75 },
43
+ { id: 'tactical-stealth', name: '战术潜行游戏风', category: 'game', traits: '低视角、第三人称镜头感;人物在货架之间移动,光影克制;适合紧张剧情', keywords: '战术潜行游戏视角,第三人称镜头,货架通道,冷色暗光,真实3D渲染,紧张压迫氛围', weight: 0.7 },
44
+ // ---------------- 2D 类 ----------------
45
+ { id: 'korean-webtoon', name: '韩漫条漫风', category: '2d', traits: '修长人物,低饱和高级灰,氛围感强,都市向漫剧爆款画风', keywords: '韩漫2D绘画,精致人物,低饱和灰调,氛围感插画', weight: 1.0 },
46
+ { id: 'japan-cel-dark', name: '日系赛璐璐动画', category: '2d', traits: '干净勾线,硬阴影;分明亮版、暗调悲剧版(边缘行者质感)', keywords: '日系赛璐璐动画,清晰轮廓线,硬阴影,暗调都市动画', weight: 0.9 },
47
+ { id: 'shinkai-anime', name: '新海诚动画风', category: '2d', traits: '极致环境光影、丁达尔、空气中尘埃粒子,擅长雨夜、夜景外景', keywords: '新海诚动画渲染,细腻环境光,漂浮尘埃粒子', weight: 0.85 },
48
+ { id: 'pop-marvel-2d', name: '波普美漫', category: '2d', traits: '网点半调、强撞色,线条张扬,节奏冲击力强', keywords: '平行宇宙波普美漫,网点半调,漫画粗线条', weight: 0.8 },
49
+ { id: 'dark-ghibli', name: '暗黑吉卜力手绘', category: '2d', traits: '手绘水彩肌理;强制关闭温暖治愈,做残酷冷调版本', keywords: '吉卜力手绘动画,暗黑写实,水彩肌理,拒绝暖光治愈', weight: 0.75 },
50
+ // ---------------- 影视实验质感(可叠加滤镜) ----------------
51
+ { id: 'noir-film', name: '黑色电影 Noir', category: 'film', traits: '高反差黑白,只保留少量色彩作为视觉锚点,张力极强', keywords: '黑色电影,大部分黑白,局部保留色彩,硬侧光,胶片颗粒', stackable: true, weight: 1.0 },
52
+ { id: 'vhs90', name: 'VHS 90 录像带复古', category: 'film', traits: '扫描线、色彩偏移、磁带噪点,模拟老旧档案录像', keywords: 'VHS录像带质感,扫描线,色彩偏移,磁带噪点', stackable: true, weight: 0.9 },
53
+ { id: 'film-photo', name: '胶片电影写真', category: 'film', traits: '电影机拍摄质感,轻微颗粒,景深虚化,无夸张特效', keywords: '电影写真质感,35mm胶片,胶片颗粒,浅景深', stackable: true, weight: 0.85 },
54
+ // ---------------- 国风 / 特殊工艺 ----------------
55
+ { id: 'chinese-ink', name: '新中式水墨动画', category: 'craft', traits: '水墨晕染,留白写意', keywords: '水墨动画渲染,写意晕染,国风2D', weight: 1.0 },
56
+ { id: 'paper-cut-3d', name: '纸雕定格', category: 'craft', traits: '多层纸张镂空叠层,光影穿透纸面', keywords: '立体纸雕定格,纸张肌理,层叠镂空光影', weight: 0.9 },
57
+ { id: 'guofeng-heavy', name: '工笔重彩国风', category: 'craft', traits: '精细线条,矿物颜料质感,古典华美', keywords: '工笔重彩,国风2D绘画,矿物颜料质感,精细勾线', weight: 0.8 },
58
+ ]
59
+
60
+ /** 按分类取风格。 */
61
+ export function stylesByCategory(category: StyleCategory): ArtStyle[] {
62
+ return STYLE_LIBRARY.filter(s => s.category === category)
63
+ }
64
+
65
+ /** 取单个风格(未找到返回 undefined)。 */
66
+ export function findStyle(id: string): ArtStyle | undefined {
67
+ return STYLE_LIBRARY.find(s => s.id === id)
68
+ }
69
+
70
+ /** 按基底 + 滤镜拼接风格关键词(角色图/分镜/视频提示词共用;缺省回退 3D 动漫)。 */
71
+ export function styleKeywords(styleId?: string, filterId?: string): string {
72
+ const parts: string[] = []
73
+ const base = styleId !== undefined ? findStyle(styleId) : undefined
74
+ if (base !== undefined) parts.push(base.keywords)
75
+ const filter = filterId !== undefined ? findStyle(filterId) : undefined
76
+ if (filter !== undefined) parts.push(filter.keywords)
77
+ return parts.join(',') || '3D动漫,超精细建模,电影光影'
78
+ }