@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
@@ -0,0 +1,287 @@
1
+ /**
2
+ * 分镜工作台:① 编剧级剧情骨架 → ② 导演级分镜表(镜头级)→ ③ 视频提示词(后续版本)。
3
+ * 定位:辅助人工——每级可重新生成、可复制,产出可导出。
4
+ */
5
+ import { useEffect, useMemo, useState } from 'react'
6
+ import type { NovelApi } from '../api.ts'
7
+ import type { ChapterPlan, ProjectState, StoryboardPrompt, StoryboardSkeleton, StoryboardTable } from '../../protocol.ts'
8
+ import css from './panel.module.css'
9
+
10
+ export function StoryboardTab({
11
+ api,
12
+ project,
13
+ chapters,
14
+ onProjectChanged,
15
+ styleId,
16
+ filterId,
17
+ }: {
18
+ api: NovelApi
19
+ project: ProjectState | null
20
+ chapters: ChapterPlan[]
21
+ /** 生成成功且已持久化后触发(刷新项目,切章/重进可恢复)。 */
22
+ onProjectChanged?: () => void | Promise<void>
23
+ /** 漫剧基底风格 id(画面措辞随风格)。 */
24
+ styleId?: string
25
+ /** 可选滤镜风格 id。 */
26
+ filterId?: string
27
+ }) {
28
+ const written = useMemo(() => chapters.filter(c => c.status !== 'pending' && c.status !== 'generating' && c.status !== 'error').sort((a, b) => a.no - b.no), [chapters])
29
+ const [chapterNo, setChapterNo] = useState<number | null>(written[0]?.no ?? null)
30
+ const [busy, setBusy] = useState(false)
31
+ const [error, setError] = useState('')
32
+ const [skeleton, setSkeleton] = useState<StoryboardSkeleton | null>(null)
33
+ const [table, setTable] = useState<StoryboardTable | null>(null)
34
+ const [tableBusy, setTableBusy] = useState(false)
35
+ const [prompts, setPrompts] = useState<StoryboardPrompt[] | null>(null)
36
+ const [promptsBusy, setPromptsBusy] = useState(false)
37
+ const [copied, setCopied] = useState('')
38
+
39
+ // 章节列表变化时保持选中有效章节
40
+ useEffect(() => {
41
+ if (chapterNo === null || !written.some(c => c.no === chapterNo)) {
42
+ setChapterNo(written[0]?.no ?? null)
43
+ setSkeleton(null)
44
+ setTable(null)
45
+ setPrompts(null)
46
+ }
47
+ }, [written, chapterNo])
48
+
49
+ // 从项目持久化恢复:切章节 / 重新进入本页时,读回已保存的骨架与分镜表(本地已有则不覆盖)。
50
+ useEffect(() => {
51
+ if (chapterNo === null) return
52
+ const entry = (project?.storyboards ?? []).find(e => e.chapterNo === chapterNo)
53
+ setSkeleton(prev => prev ?? entry?.skeleton ?? null)
54
+ setTable(prev => prev ?? entry?.table ?? null)
55
+ setPrompts(prev => prev ?? entry?.prompts ?? null)
56
+ }, [chapterNo, project?.storyboards])
57
+
58
+ const generate = async (): Promise<void> => {
59
+ if (chapterNo === null) return
60
+ setBusy(true)
61
+ setError('')
62
+ setSkeleton(null)
63
+ setTable(null)
64
+ setPrompts(null)
65
+ try {
66
+ const result = await api.storyboardSkeleton(chapterNo)
67
+ setSkeleton(result.skeleton)
68
+ void onProjectChanged?.()
69
+ } catch (err) {
70
+ setError((err as Error).message)
71
+ } finally {
72
+ setBusy(false)
73
+ }
74
+ }
75
+
76
+ /** ② 生成分镜表(骨架 → 镜头级)。 */
77
+ const generateTable = async (): Promise<void> => {
78
+ if (chapterNo === null || skeleton === null) return
79
+ setTableBusy(true)
80
+ setError('')
81
+ setTable(null)
82
+ try {
83
+ const result = await api.storyboardTable(chapterNo, skeleton, styleId, filterId)
84
+ setTable(result.table)
85
+ void onProjectChanged?.()
86
+ } catch (err) {
87
+ setError((err as Error).message)
88
+ } finally {
89
+ setTableBusy(false)
90
+ }
91
+ }
92
+
93
+ /** ③ 生成视频提示词(分镜表 → 即梦可粘贴)。 */
94
+ const generatePrompts = async (): Promise<void> => {
95
+ if (chapterNo === null || table === null) return
96
+ setPromptsBusy(true)
97
+ setError('')
98
+ setPrompts(null)
99
+ try {
100
+ const result = await api.storyboardPrompts(chapterNo, table, styleId, filterId)
101
+ setPrompts(result.prompts)
102
+ void onProjectChanged?.()
103
+ } catch (err) {
104
+ setError((err as Error).message)
105
+ } finally {
106
+ setPromptsBusy(false)
107
+ }
108
+ }
109
+
110
+ if (project === null) {
111
+ return <div className={css.card}><span className={css.meta}>请先开书或选择一本书,再进入分镜工作台。</span></div>
112
+ }
113
+
114
+ return (
115
+ <div className={css.card}>
116
+ <div className={css.row} style={{ justifyContent: 'space-between', flexWrap: 'wrap' }}>
117
+ <span className={css.cardTitle}>🎬 分镜工作台</span>
118
+ <span className={css.meta}>① 剧情骨架 → ② 分镜表(本节)→ ③ 视频提示词(后续版本)</span>
119
+ </div>
120
+
121
+ <div className={css.row} style={{ alignItems: 'flex-end', flexWrap: 'wrap' }}>
122
+ <div className={css.field} style={{ flex: 1, minWidth: 200 }}>
123
+ <label className={css.fieldLabel}>选择章节</label>
124
+ <select
125
+ className={css.input}
126
+ value={chapterNo ?? ''}
127
+ onChange={e => { setChapterNo(Number(e.target.value)); setError('') }}
128
+ >
129
+ {written.length === 0 && <option value="">(没有已写章节)</option>}
130
+ {written.map(c => (
131
+ <option key={c.no} value={c.no}>第{c.no}章 {c.title}</option>
132
+ ))}
133
+ </select>
134
+ </div>
135
+ <button
136
+ type="button"
137
+ className={`${css.button} ${css.buttonPrimary}`}
138
+ disabled={busy || chapterNo === null}
139
+ onClick={() => { void generate() }}
140
+ >
141
+ {busy ? '编剧分析中…' : '✍️ 生成剧情骨架'}
142
+ </button>
143
+ {skeleton !== null && (
144
+ <button type="button" className={css.button} disabled={busy} onClick={() => { void generate() }}>
145
+ 🔄 重新生成
146
+ </button>
147
+ )}
148
+ </div>
149
+
150
+ {error !== '' && <div className={css.importError}>{error}</div>}
151
+
152
+ {skeleton === null ? (
153
+ <div className={css.meta} style={{ marginTop: 8 }}>
154
+ 生成后这里显示本章剧情骨架:弧线 + 节拍链(事件 / 情绪走向 / 叙事功能 / 因果)。
155
+ 骨架是「完整剧情」的根——确认骨架没问题后,后续版本再展开为分镜表与视频提示词。
156
+ </div>
157
+ ) : (
158
+ <div style={{ display: 'flex', flexDirection: 'column', gap: 8, marginTop: 8 }}>
159
+ <div className={css.importPreview}>
160
+ <span>📖 本章弧线:{skeleton.arc}</span>
161
+ </div>
162
+ {skeleton.beats.map((b, i) => (
163
+ <div key={b.id} style={{ display: 'flex', flexDirection: 'column', gap: 3, border: '1px solid var(--nf-border)', borderRadius: 10, padding: '8px 10px' }}>
164
+ <div className={css.row} style={{ flexWrap: 'wrap' }}>
165
+ <b>节拍 {i + 1}</b>
166
+ <span className={`${css.badge} ${b.function === '高潮' ? css.badgeDone : b.function === '转折' ? css.badgeWritten : css.badgePending}`}>{b.function}</span>
167
+ <span className={css.meta}>{b.emotion}</span>
168
+ </div>
169
+ <span>{b.event}</span>
170
+ {b.cause !== undefined && <span className={css.meta}>承接:{b.cause}</span>}
171
+ </div>
172
+ ))}
173
+ <div className={css.row}>
174
+ <button
175
+ type="button"
176
+ className={`${css.button} ${css.buttonSmall}`}
177
+ onClick={() => {
178
+ const text = `第${skeleton.chapterNo}章 弧线:${skeleton.arc}\n\n` + skeleton.beats.map((b, i) => `${i + 1}. [${b.function}] ${b.event}(情绪:${b.emotion})${b.cause !== undefined ? `[承接:${b.cause}]` : ''}`).join('\n')
179
+ void navigator.clipboard?.writeText(text)
180
+ }}
181
+ >
182
+ 📋 复制骨架
183
+ </button>
184
+ <button
185
+ type="button"
186
+ className={`${css.button} ${css.buttonSmall} ${css.buttonPrimary}`}
187
+ disabled={tableBusy}
188
+ onClick={() => { void generateTable() }}
189
+ >
190
+ {tableBusy ? '导演分镜中…' : '🎬 ② 生成分镜表'}
191
+ </button>
192
+ <span className={css.meta}>共 {skeleton.beats.length} 个节拍 · 骨架可重新生成(后续版本支持直接编辑)</span>
193
+ </div>
194
+ </div>
195
+ )}
196
+
197
+ {table !== null && (
198
+ <div style={{ display: 'flex', flexDirection: 'column', gap: 10, marginTop: 10 }}>
199
+ <div className={css.row} style={{ justifyContent: 'space-between', flexWrap: 'wrap' }}>
200
+ <b>🎬 分镜表({table.shots.length} 个镜头)</b>
201
+ <button
202
+ type="button"
203
+ className={`${css.button} ${css.buttonSmall}`}
204
+ onClick={() => {
205
+ const text = table.shots.map(s => {
206
+ const beat = skeleton?.beats.find(b => b.id === s.beatId)
207
+ return `镜头 ${s.id}(节拍 ${beat?.id ?? s.beatId} · ${s.shot} · ${s.camera} · ${s.duration}s)\n画面:${s.visual}\n台词:${s.line !== '' ? s.line : '(无)'}\n音效:${s.sound !== '' ? s.sound : '(无)'}\n光效:${s.light !== '' ? s.light : '(无)'}\n承接:${s.prevState} → ${s.nextState}`
208
+ }).join('\n\n')
209
+ void navigator.clipboard?.writeText(`第${table.chapterNo}章分镜表\n\n` + text)
210
+ }}
211
+ >
212
+ 📋 复制分镜表
213
+ </button>
214
+ <span className={css.meta}>按骨架节拍展开 · 镜头间状态连续</span>
215
+ <button
216
+ type="button"
217
+ className={`${css.button} ${css.buttonSmall} ${css.buttonPrimary}`}
218
+ disabled={promptsBusy}
219
+ onClick={() => { void generatePrompts() }}
220
+ >
221
+ {promptsBusy ? '提示词生成中…' : '🎬 ③ 生成视频提示词'}
222
+ </button>
223
+ </div>
224
+ {table.shots.map(s => {
225
+ const beat = skeleton?.beats.find(b => b.id === s.beatId)
226
+ return (
227
+ <div key={s.id} style={{ display: 'flex', flexDirection: 'column', gap: 3, border: '1px solid var(--nf-border)', borderRadius: 10, padding: '8px 10px' }}>
228
+ <div className={css.row} style={{ flexWrap: 'wrap' }}>
229
+ <b>镜头 {s.id}</b>
230
+ <span className={css.badge}>{s.shot}</span>
231
+ <span className={css.meta}>{s.camera} · {s.duration}s</span>
232
+ {beat !== undefined && <span className={css.meta}>节拍 {beat.id}「{beat.function}」</span>}
233
+ </div>
234
+ <span>🎞️ {s.visual}</span>
235
+ <div className={css.row} style={{ flexWrap: 'wrap' }}>
236
+ <span className={css.meta}>💬 {s.line !== '' ? s.line : '(无台词)'}</span>
237
+ <span className={css.meta}>🔊 {s.sound !== '' ? s.sound : '(无音效)'}</span>
238
+ <span className={css.meta}>💡 {s.light !== '' ? s.light : '(无光效)'}</span>
239
+ </div>
240
+ <span className={css.meta}>承接:{s.prevState} → {s.nextState}</span>
241
+ </div>
242
+ )
243
+ })}
244
+ </div>
245
+ )}
246
+
247
+ {prompts !== null && (
248
+ <div style={{ display: 'flex', flexDirection: 'column', gap: 8, marginTop: 10 }}>
249
+ <div className={css.row} style={{ justifyContent: 'space-between', flexWrap: 'wrap' }}>
250
+ <b>🎬 视频提示词({prompts.length} 个镜头)</b>
251
+ <button
252
+ type="button"
253
+ className={`${css.button} ${css.buttonSmall}`}
254
+ onClick={() => {
255
+ const text = prompts.map(x => `【镜头 ${x.shotId}】${x.text}`).join('\n\n')
256
+ void navigator.clipboard?.writeText(`第${table?.chapterNo ?? ''}章视频提示词\n\n` + text)
257
+ }}
258
+ >
259
+ 📋 复制全部(即梦可逐条粘贴)
260
+ </button>
261
+ </div>
262
+ {prompts.map(x => (
263
+ <div key={x.shotId} style={{ display: 'flex', flexDirection: 'column', gap: 3, border: '1px solid var(--nf-accent)', borderRadius: 10, padding: '8px 10px' }}>
264
+ <div className={css.row} style={{ flexWrap: 'wrap' }}>
265
+ <b>镜头 {x.shotId}</b>
266
+ <span className={css.meta}>即梦/Seedance 提示词</span>
267
+ <div style={{ flex: 1 }} />
268
+ <button
269
+ type="button"
270
+ className={`${css.button} ${css.buttonSmall}`}
271
+ onClick={() => {
272
+ void navigator.clipboard?.writeText(x.text)
273
+ setCopied(x.shotId)
274
+ setTimeout(() => { setCopied('') }, 1500)
275
+ }}
276
+ >
277
+ {copied === x.shotId ? '✅ 已复制' : '📋 复制'}
278
+ </button>
279
+ </div>
280
+ <span style={{ fontSize: 13, lineHeight: 1.7 }}>{x.text}</span>
281
+ </div>
282
+ ))}
283
+ </div>
284
+ )}
285
+ </div>
286
+ )
287
+ }
@@ -0,0 +1,109 @@
1
+ /**
2
+ * 风格库页面:4 个分类标签 + 风格卡片网格。
3
+ * 卡片预留:风格效果图占位 + 特点说明 + 关键词(一键复制)。
4
+ * 后续:新建漫剧方案时从这里选风格(基底 + 滤镜)。
5
+ */
6
+ import { useState } from 'react'
7
+ import { STYLE_CATEGORIES, STYLE_LIBRARY, stylesByCategory, type StyleCategory } from '../../style-library.ts'
8
+ import css from './panel.module.css'
9
+
10
+ export function StyleGallery() {
11
+ const [active, setActive] = useState<StyleCategory>('3d')
12
+ const [copied, setCopied] = useState('')
13
+
14
+ const copyKeywords = async (id: string, keywords: string): Promise<void> => {
15
+ try {
16
+ await navigator.clipboard.writeText(keywords)
17
+ setCopied(id)
18
+ setTimeout(() => { setCopied('') }, 1500)
19
+ } catch { /* 剪贴板不可用时静默 */ }
20
+ }
21
+
22
+ const list = stylesByCategory(active)
23
+
24
+ return (
25
+ <div className={css.card}>
26
+ <div className={css.row} style={{ justifyContent: 'space-between', flexWrap: 'wrap' }}>
27
+ <span className={css.cardTitle}>🎨 风格库</span>
28
+ <span className={css.meta}>共 {STYLE_LIBRARY.length} 个内置模板 · 新建漫剧方案时选择(影视类可作滤镜叠加)</span>
29
+ </div>
30
+
31
+ {/* 分类标签 */}
32
+ <div className={css.row} style={{ gap: 8, flexWrap: 'wrap' }}>
33
+ {STYLE_CATEGORIES.map(cat => (
34
+ <button
35
+ key={cat.id}
36
+ type="button"
37
+ className={`${css.button} ${active === cat.id ? css.buttonPrimary : ''}`}
38
+ style={{ flex: 1, minWidth: 140 }}
39
+ onClick={() => { setActive(cat.id) }}
40
+ >
41
+ {cat.icon} {cat.label}({stylesByCategory(cat.id).length})
42
+ </button>
43
+ ))}
44
+ </div>
45
+ <div className={css.meta}>
46
+ {STYLE_CATEGORIES.find(c => c.id === active)?.desc}
47
+ </div>
48
+
49
+ {/* 风格卡片网格 */}
50
+ <div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(240px, 1fr))', gap: 12, marginTop: 6 }}>
51
+ {list.map(style => (
52
+ <div
53
+ key={style.id}
54
+ style={{
55
+ display: 'flex', flexDirection: 'column', gap: 6,
56
+ border: '1px solid var(--nf-border)', borderRadius: 14,
57
+ padding: 10, background: 'var(--nf-bg-raise)',
58
+ }}
59
+ >
60
+ {/* 效果图(竖版 3:4,服务端缩略图 webp;无图回退占位) */}
61
+ <div
62
+ style={{
63
+ aspectRatio: '3 / 4', borderRadius: 10,
64
+ position: 'relative', overflow: 'hidden',
65
+ display: 'flex', alignItems: 'center', justifyContent: 'center',
66
+ background: 'linear-gradient(145deg, var(--nf-bg-inset), color-mix(in srgb, var(--nf-accent) 12%, var(--nf-bg-inset)))',
67
+ border: '1px solid var(--nf-border)',
68
+ color: 'var(--nf-text-3)', fontSize: 12, textAlign: 'center',
69
+ }}
70
+ >
71
+ <img
72
+ src={`/api/dsh-novel-forge/styles/image?id=${style.id}`}
73
+ alt={style.name}
74
+ loading="lazy"
75
+ style={{ position: 'absolute', inset: 0, width: '100%', height: '100%', objectFit: 'cover' }}
76
+ onError={e => { e.currentTarget.style.display = 'none' }}
77
+ />
78
+ 🖼️ 效果图待生成
79
+ </div>
80
+
81
+ <div className={css.row} style={{ flexWrap: 'wrap' }}>
82
+ <b>{style.name}</b>
83
+ <span className={css.badge}>{STYLE_CATEGORIES.find(c => c.id === style.category)?.icon} {style.category.toUpperCase()}</span>
84
+ {style.stackable === true && <span className={css.badge}>叠加滤镜</span>}
85
+ </div>
86
+
87
+ {/* 说明位置 */}
88
+ <span className={css.meta} style={{ fontSize: 12, lineHeight: 1.6 }}>{style.traits}</span>
89
+
90
+ {/* 关键词词块(一键复制) */}
91
+ <div
92
+ style={{
93
+ fontSize: 11.5, lineHeight: 1.5, color: 'var(--nf-text-2)',
94
+ borderTop: '1px solid var(--nf-border)', paddingTop: 6,
95
+ display: '-webkit-box', WebkitLineClamp: 3, WebkitBoxOrient: 'vertical', overflow: 'hidden',
96
+ cursor: 'pointer',
97
+ }}
98
+ title="点击复制关键词"
99
+ onClick={() => { void copyKeywords(style.id, style.keywords) }}
100
+ >
101
+ <span className={css.meta}>关键词:</span>{style.keywords}
102
+ </div>
103
+ {copied === style.id && <span style={{ color: 'var(--nf-ok, #46a758)', fontSize: 12 }}>✅ 已复制</span>}
104
+ </div>
105
+ ))}
106
+ </div>
107
+ </div>
108
+ )
109
+ }