@waterwx/dsh-novel-forge 1.2.0 → 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/README.md +5 -0
- package/lib/client.js +2100 -651
- package/lib/client.js.map +1 -1
- package/lib/index.js +567 -173
- package/lib/index.js.map +1 -1
- package/lib/types/client/api.d.ts +4 -4
- package/lib/types/client/panel/NovelPanel.d.ts +1 -1
- package/lib/types/comic-presets.d.ts +12 -0
- package/lib/types/engine.d.ts +16 -13
- package/lib/types/index.d.ts +6 -0
- package/lib/types/protocol.d.ts +128 -89
- package/package.json +1 -1
- package/src/client/api.ts +10 -10
- package/src/client/panel/NovelPanel.tsx +650 -172
- package/src/comic-presets.ts +67 -0
- package/src/engine.ts +344 -188
- package/src/index.ts +15 -0
- package/src/protocol.ts +121 -96
- package/src/routes.ts +173 -52
|
@@ -40,7 +40,8 @@ import css from './panel.module.css'
|
|
|
40
40
|
/** The panel's tab identifiers. */
|
|
41
41
|
export type NovelTab =
|
|
42
42
|
| 'workflow' | 'overview' | 'blurb' | 'plan' | 'bible' | 'world' | 'foreshadow' | 'assistant' | 'settings'
|
|
43
|
-
| 'characters' | 'roles' | 'facts' | 'plotlines' | 'reviews' | 'progress' | 'breakdown'
|
|
43
|
+
| 'characters' | 'roles' | 'facts' | 'plotlines' | 'reviews' | 'progress' | 'breakdown'
|
|
44
|
+
| 'roleImage' | 'scenes'
|
|
44
45
|
| 'assetsGenre' | 'assetsProgression' | 'assetsTemplates' | 'assetsRules' | 'assetsStyle' | 'run'
|
|
45
46
|
| 'book' | 'assets'
|
|
46
47
|
|
|
@@ -84,7 +85,8 @@ const NAV_GROUPS: ReadonlyArray<{ id: string; label: string; items: ReadonlyArra
|
|
|
84
85
|
{ id: 'assistant', label: tt('tab.assistant'), icon: '💬' },
|
|
85
86
|
{ id: 'progress', label: '工作进度', icon: '📊' },
|
|
86
87
|
{ id: 'breakdown', label: '拆书分析', icon: '🔍' },
|
|
87
|
-
{ id: '
|
|
88
|
+
{ id: 'roleImage', label: '角色形象', icon: '🖼️' },
|
|
89
|
+
{ id: 'scenes', label: '场景库', icon: '🏞️' },
|
|
88
90
|
{ id: 'run', label: '生产单', icon: '🏭' },
|
|
89
91
|
],
|
|
90
92
|
},
|
|
@@ -479,16 +481,23 @@ export function NovelPanel({ controller, api }: NovelPanelProps) {
|
|
|
479
481
|
const [breakdownResult, setBreakdownResult] = useState<import('../../protocol.ts').BreakdownResponse | null>(null)
|
|
480
482
|
const [breakdownScope, setBreakdownScope] = useState<'recent' | 'volume:2' | 'volume:3' | 'all'>('recent')
|
|
481
483
|
const [breakdownPreset, setBreakdownPreset] = useState<'quick' | 'standard'>('quick')
|
|
482
|
-
/**
|
|
483
|
-
const [
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
const [
|
|
490
|
-
/**
|
|
491
|
-
const [
|
|
484
|
+
/** 角色图集上传:当前等待上传的角色名。 */
|
|
485
|
+
const [roleImageTarget, setRoleImageTarget] = useState<string | null>(null)
|
|
486
|
+
/** 角色图集上传:当前用途标签(立绘/四视图/表情-x/场景/细节)。 */
|
|
487
|
+
const [roleImageLabel, setRoleImageLabel] = useState('立绘')
|
|
488
|
+
/** 角色形象详情:当前打开的角色名(null = 关闭)。 */
|
|
489
|
+
const [detailRoleName, setDetailRoleName] = useState<string | null>(null)
|
|
490
|
+
/** 详情面板:上传标签输入。 */
|
|
491
|
+
const [detailUploadLabel, setDetailUploadLabel] = useState('立绘')
|
|
492
|
+
/** 场景库:AI 提炼候选(null = 未运行)。 */
|
|
493
|
+
const [sceneCandidates, setSceneCandidates] = useState<import('../../protocol.ts').SceneCard[] | null>(null)
|
|
494
|
+
/** 场景库:当前打开的场景名。 */
|
|
495
|
+
const [sceneDetailName, setSceneDetailName] = useState<string | null>(null)
|
|
496
|
+
/** 场景库:上传标签输入。 */
|
|
497
|
+
const [sceneUploadLabel, setSceneUploadLabel] = useState('全景')
|
|
498
|
+
/** 场景库:编辑草稿(null = 不在编辑)。 */
|
|
499
|
+
const [sceneDraft, setSceneDraft] = useState<import('../../protocol.ts').SceneCard | null>(null)
|
|
500
|
+
const roleImageInputRef = useRef<HTMLInputElement | null>(null)
|
|
492
501
|
/** AI 建议的剧情线候选(null = 未运行)。 */
|
|
493
502
|
const [plotlineSuggestions, setPlotlineSuggestions] = useState<Plotline[] | null>(null)
|
|
494
503
|
/** 剧情健康检查报告(null = 未运行)。 */
|
|
@@ -1133,36 +1142,80 @@ export function NovelPanel({ controller, api }: NovelPanelProps) {
|
|
|
1133
1142
|
}
|
|
1134
1143
|
}
|
|
1135
1144
|
|
|
1136
|
-
/**
|
|
1137
|
-
const
|
|
1145
|
+
/** 生成单个角色的形象锚点(复用角色库 visual 能力)。 */
|
|
1146
|
+
const handleRoleVisual = async (name: string): Promise<void> => {
|
|
1138
1147
|
setBusy(true)
|
|
1139
|
-
setBusyLabel(
|
|
1148
|
+
setBusyLabel(`生成「${name}」形象锚点…`)
|
|
1140
1149
|
setError('')
|
|
1141
1150
|
try {
|
|
1142
|
-
|
|
1143
|
-
|
|
1144
|
-
pushProgress(
|
|
1151
|
+
await api.roles({ op: 'visual', name })
|
|
1152
|
+
await refresh(false)
|
|
1153
|
+
pushProgress(`已生成「${name}」形象锚点`, 'done')
|
|
1145
1154
|
} catch (err) {
|
|
1146
1155
|
setError((err as Error).message)
|
|
1147
|
-
pushProgress(
|
|
1156
|
+
pushProgress(`生成「${name}」形象锚点失败:${(err as Error).message}`, 'error')
|
|
1148
1157
|
} finally {
|
|
1149
1158
|
setBusy(false)
|
|
1150
1159
|
setBusyLabel('')
|
|
1151
1160
|
}
|
|
1152
1161
|
}
|
|
1153
1162
|
|
|
1154
|
-
/**
|
|
1155
|
-
const
|
|
1163
|
+
/** 上传角色图:读取本地图片为 dataURL 并写入角色卡(带用途标签 → 图集)。 */
|
|
1164
|
+
const handleRoleImageUpload = async (name: string, file: File, label = '立绘'): Promise<void> => {
|
|
1165
|
+
const dataUrl = await new Promise<string>((resolve, reject) => {
|
|
1166
|
+
const reader = new FileReader()
|
|
1167
|
+
reader.onload = () => resolve(String(reader.result))
|
|
1168
|
+
reader.onerror = () => reject(reader.error ?? new Error('读取图片失败'))
|
|
1169
|
+
reader.readAsDataURL(file)
|
|
1170
|
+
})
|
|
1156
1171
|
setBusy(true)
|
|
1157
|
-
setBusyLabel(
|
|
1172
|
+
setBusyLabel(`上传「${name}」${label}…`)
|
|
1158
1173
|
setError('')
|
|
1159
1174
|
try {
|
|
1160
|
-
|
|
1161
|
-
|
|
1162
|
-
pushProgress(
|
|
1175
|
+
await api.roles({ op: 'image', name, dataUrl, label })
|
|
1176
|
+
await refresh(false)
|
|
1177
|
+
pushProgress(`已上传「${name}」${label}`, 'done')
|
|
1163
1178
|
} catch (err) {
|
|
1164
1179
|
setError((err as Error).message)
|
|
1165
|
-
pushProgress(
|
|
1180
|
+
pushProgress(`上传「${name}」${label}失败:${(err as Error).message}`, 'error')
|
|
1181
|
+
} finally {
|
|
1182
|
+
setBusy(false)
|
|
1183
|
+
setBusyLabel('')
|
|
1184
|
+
setRoleImageTarget(null)
|
|
1185
|
+
if (roleImageInputRef.current !== null) roleImageInputRef.current.value = ''
|
|
1186
|
+
}
|
|
1187
|
+
}
|
|
1188
|
+
|
|
1189
|
+
/** 用豆包/Seedream 为角色生成参考图。 */
|
|
1190
|
+
const handleRoleImageGenerate = async (name: string): Promise<void> => {
|
|
1191
|
+
setBusy(true)
|
|
1192
|
+
setBusyLabel(`用豆包生成「${name}」参考图…`)
|
|
1193
|
+
setError('')
|
|
1194
|
+
try {
|
|
1195
|
+
await api.roles({ op: 'imageGenerate', name })
|
|
1196
|
+
await refresh(false)
|
|
1197
|
+
pushProgress(`已生成「${name}」参考图`, 'done')
|
|
1198
|
+
} catch (err) {
|
|
1199
|
+
setError((err as Error).message)
|
|
1200
|
+
pushProgress(`生成「${name}」参考图失败:${(err as Error).message}`, 'error')
|
|
1201
|
+
} finally {
|
|
1202
|
+
setBusy(false)
|
|
1203
|
+
setBusyLabel('')
|
|
1204
|
+
}
|
|
1205
|
+
}
|
|
1206
|
+
|
|
1207
|
+
/** 更新角色漫画重要性(main/support/extra)。 */
|
|
1208
|
+
const handleRoleImportance = async (role: RoleRecord, importance: RoleRecord['importance']): Promise<void> => {
|
|
1209
|
+
setBusy(true)
|
|
1210
|
+
setBusyLabel(`更新「${role.name}」重要性…`)
|
|
1211
|
+
setError('')
|
|
1212
|
+
try {
|
|
1213
|
+
await api.roles({ op: 'update', role: { ...role, importance } })
|
|
1214
|
+
await refresh(false)
|
|
1215
|
+
pushProgress(`已更新「${role.name}」重要性`, 'done')
|
|
1216
|
+
} catch (err) {
|
|
1217
|
+
setError((err as Error).message)
|
|
1218
|
+
pushProgress(`更新「${role.name}」重要性失败:${(err as Error).message}`, 'error')
|
|
1166
1219
|
} finally {
|
|
1167
1220
|
setBusy(false)
|
|
1168
1221
|
setBusyLabel('')
|
|
@@ -2024,6 +2077,9 @@ export function NovelPanel({ controller, api }: NovelPanelProps) {
|
|
|
2024
2077
|
autoReview: configDraft.autoReview,
|
|
2025
2078
|
autoAuthorReview: configDraft.autoAuthorReview,
|
|
2026
2079
|
autoReviewAfterRevise: configDraft.autoReviewAfterRevise,
|
|
2080
|
+
imageApiKey: configDraft.imageApiKey,
|
|
2081
|
+
imageApiModel: configDraft.imageApiModel,
|
|
2082
|
+
imageApiEnabled: configDraft.imageApiEnabled ?? false,
|
|
2027
2083
|
})
|
|
2028
2084
|
setConfig(result.config)
|
|
2029
2085
|
setConfigDraft(result.config)
|
|
@@ -3743,6 +3799,28 @@ export function NovelPanel({ controller, api }: NovelPanelProps) {
|
|
|
3743
3799
|
<span className={css.meta}>{tt('settings.reasoningHint')}</span>
|
|
3744
3800
|
</div>
|
|
3745
3801
|
</div>
|
|
3802
|
+
<div className={css.row}>
|
|
3803
|
+
<div className={css.field} style={{ flex: 1 }}>
|
|
3804
|
+
<label className={css.fieldLabel}>豆包 API Key(生图)</label>
|
|
3805
|
+
<input className={css.input} type="password" placeholder="ark-..." value={configDraft.imageApiKey ?? ''} onChange={e => { setConfigDraft({ ...configDraft, imageApiKey: e.target.value }) }} />
|
|
3806
|
+
</div>
|
|
3807
|
+
<div className={css.field} style={{ flex: 1 }}>
|
|
3808
|
+
<label className={css.fieldLabel}>豆包生图模型</label>
|
|
3809
|
+
<input className={css.input} placeholder="doubao-seedream-5-0-pro-260628" value={configDraft.imageApiModel ?? ''} onChange={e => { setConfigDraft({ ...configDraft, imageApiModel: e.target.value }) }} />
|
|
3810
|
+
</div>
|
|
3811
|
+
<div className={css.field} style={{ flex: 1 }}>
|
|
3812
|
+
<label className={css.fieldLabel}>启用豆包生图</label>
|
|
3813
|
+
<select
|
|
3814
|
+
className={css.input}
|
|
3815
|
+
value={configDraft.imageApiEnabled ? '1' : '0'}
|
|
3816
|
+
onChange={e => { setConfigDraft({ ...configDraft, imageApiEnabled: e.target.value === '1' }) }}
|
|
3817
|
+
>
|
|
3818
|
+
<option value="0">关(默认)</option>
|
|
3819
|
+
<option value="1">开</option>
|
|
3820
|
+
</select>
|
|
3821
|
+
<span className={css.meta}>开启后漫画工坊才显示「豆包生成」按钮</span>
|
|
3822
|
+
</div>
|
|
3823
|
+
</div>
|
|
3746
3824
|
<div className={css.row}>
|
|
3747
3825
|
<div className={css.field} style={{ flex: 1 }}>
|
|
3748
3826
|
<label className={css.fieldLabel}>{tt('settings.chapterChars')}</label>
|
|
@@ -4314,173 +4392,573 @@ export function NovelPanel({ controller, api }: NovelPanelProps) {
|
|
|
4314
4392
|
</div>
|
|
4315
4393
|
)}
|
|
4316
4394
|
|
|
4317
|
-
{activeTab === '
|
|
4318
|
-
<div className={css.card}>
|
|
4395
|
+
{activeTab === 'roleImage' && (
|
|
4396
|
+
<div className={css.card} style={{ flex: 1, minHeight: 0 }}>
|
|
4319
4397
|
<div className={css.row} style={{ justifyContent: 'space-between', flexWrap: 'wrap' }}>
|
|
4320
|
-
<span className={css.cardTitle} style={{ fontSize: 17, fontWeight: 700 }}
|
|
4398
|
+
<span className={css.cardTitle} style={{ fontSize: 17, fontWeight: 700 }}>🖼️ 角色形象</span>
|
|
4399
|
+
<span className={css.meta}>角色形象锚点 / 参考图(漫画功能已移除,形象保留用于立绘与一致性)</span>
|
|
4321
4400
|
</div>
|
|
4322
|
-
<div className={css.row} style={{ flexWrap: 'wrap', gap: 6 }}>
|
|
4323
|
-
<button
|
|
4324
|
-
type="button"
|
|
4325
|
-
className={`${css.button} ${storyboardView === 'single' ? css.buttonPrimary : ''}`}
|
|
4326
|
-
style={{ fontSize: 14, flex: 1 }}
|
|
4327
|
-
onClick={() => { setStoryboardView('single') }}
|
|
4328
|
-
>
|
|
4329
|
-
🎞️ 单集分镜
|
|
4330
|
-
</button>
|
|
4331
|
-
<button
|
|
4332
|
-
type="button"
|
|
4333
|
-
className={`${css.button} ${storyboardView === 'plan' ? css.buttonPrimary : ''}`}
|
|
4334
|
-
style={{ fontSize: 14, flex: 1 }}
|
|
4335
|
-
onClick={() => { setStoryboardView('plan') }}
|
|
4336
|
-
>
|
|
4337
|
-
📺 按卷规划
|
|
4338
|
-
</button>
|
|
4339
|
-
</div>
|
|
4340
4401
|
|
|
4341
|
-
|
|
4342
|
-
|
|
4343
|
-
|
|
4344
|
-
|
|
4345
|
-
|
|
4346
|
-
|
|
4347
|
-
|
|
4348
|
-
|
|
4349
|
-
|
|
4350
|
-
|
|
4351
|
-
|
|
4352
|
-
|
|
4353
|
-
|
|
4354
|
-
|
|
4355
|
-
|
|
4356
|
-
|
|
4357
|
-
|
|
4358
|
-
|
|
4402
|
+
<input
|
|
4403
|
+
ref={roleImageInputRef}
|
|
4404
|
+
type="file"
|
|
4405
|
+
accept="image/*"
|
|
4406
|
+
style={{ display: 'none' }}
|
|
4407
|
+
onChange={e => {
|
|
4408
|
+
const file = e.target.files?.[0]
|
|
4409
|
+
const target = roleImageTarget
|
|
4410
|
+
if (file === undefined || target === null) return
|
|
4411
|
+
const readDataUrl = (): Promise<string> => new Promise((resolve, reject) => {
|
|
4412
|
+
const reader = new FileReader()
|
|
4413
|
+
reader.onload = () => resolve(String(reader.result))
|
|
4414
|
+
reader.onerror = () => reject(reader.error ?? new Error('读取图片失败'))
|
|
4415
|
+
reader.readAsDataURL(file!)
|
|
4416
|
+
})
|
|
4417
|
+
void (async () => {
|
|
4418
|
+
const dataUrl = await readDataUrl()
|
|
4419
|
+
if (target.endsWith('||scene')) {
|
|
4420
|
+
const sceneName = target.slice(0, -'||scene'.length)
|
|
4421
|
+
setBusy(true)
|
|
4422
|
+
setBusyLabel(`上传场景图「${sceneName}」${roleImageLabel}…`)
|
|
4423
|
+
setError('')
|
|
4424
|
+
try {
|
|
4425
|
+
await api.scenes({ op: 'image', name: sceneName, dataUrl, label: roleImageLabel })
|
|
4426
|
+
await refresh(false)
|
|
4427
|
+
pushProgress(`已上传场景「${sceneName}」${roleImageLabel}`, 'done')
|
|
4428
|
+
} catch (err) {
|
|
4429
|
+
setError((err as Error).message)
|
|
4430
|
+
pushProgress(`场景图上传失败:${(err as Error).message}`, 'error')
|
|
4431
|
+
} finally {
|
|
4432
|
+
setBusy(false)
|
|
4433
|
+
setBusyLabel('')
|
|
4434
|
+
setRoleImageTarget(null)
|
|
4435
|
+
if (roleImageInputRef.current !== null) roleImageInputRef.current.value = ''
|
|
4436
|
+
}
|
|
4437
|
+
} else {
|
|
4438
|
+
await handleRoleImageUpload(target, file, roleImageLabel)
|
|
4439
|
+
}
|
|
4440
|
+
})()
|
|
4441
|
+
}}
|
|
4442
|
+
/>
|
|
4443
|
+
|
|
4444
|
+
<div style={{ display: 'flex', flexDirection: 'column', gap: 10, flex: 1, minHeight: 0, overflowY: 'auto' }}>
|
|
4445
|
+
<div className={css.row} style={{ flexWrap: 'wrap', gap: 6, justifyContent: 'space-between' }}>
|
|
4446
|
+
<span className={css.row} style={{ gap: 6 }}>
|
|
4447
|
+
<button type="button" className={`${css.button} ${css.buttonSmall}`} disabled={busy} onClick={() => { void (async () => {
|
|
4448
|
+
setBusy(true)
|
|
4449
|
+
setBusyLabel('⚠️ 提炼本书视觉规则…')
|
|
4450
|
+
setError('')
|
|
4451
|
+
try {
|
|
4452
|
+
const result = await api.visualRules({ op: 'extract' })
|
|
4453
|
+
await refresh(false)
|
|
4454
|
+
pushProgress(`已提炼 ${result.rules.length} 条视觉规则(已注入所有提示词)`, 'done')
|
|
4455
|
+
} catch (err) {
|
|
4456
|
+
setError((err as Error).message)
|
|
4457
|
+
pushProgress(`视觉规则提炼失败:${(err as Error).message}`, 'error')
|
|
4458
|
+
} finally {
|
|
4459
|
+
setBusy(false)
|
|
4460
|
+
setBusyLabel('')
|
|
4461
|
+
}
|
|
4462
|
+
})() }}>⚠️ 提炼视觉规则</button>
|
|
4463
|
+
<button type="button" className={`${css.button} ${css.buttonSmall}`} onClick={() => { void refresh(false); pushProgress('角色库已刷新', 'done') }}>🔄 刷新角色库</button>
|
|
4464
|
+
</span>
|
|
4359
4465
|
</div>
|
|
4360
|
-
<span className={css.meta}>分集原则:高潮章单独成集或两章一集;过渡章 2-3 章合并;断点选在钩子最强处;每集 60-120 秒。生成后逐集确认,满意再用「单集分镜」为某集生成分镜。</span>
|
|
4361
4466
|
|
|
4362
|
-
{
|
|
4363
|
-
<div
|
|
4364
|
-
<span className={css.
|
|
4365
|
-
<span
|
|
4366
|
-
<span className={css.meta}>选卷后点「生成分集计划」,AI 把整卷按故事弧线分成漫剧集</span>
|
|
4467
|
+
{(project?.visualRules ?? []).length > 0 && (
|
|
4468
|
+
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 4, alignItems: 'center', marginTop: 6 }}>
|
|
4469
|
+
<span className={css.meta} style={{ fontSize: 11 }}>⚠️ 本书视觉规则:</span>
|
|
4470
|
+
{(project?.visualRules ?? []).map((r, i) => <span key={i} style={{ border: '1px solid var(--nf-warn, #b8860b)', borderRadius: 999, padding: '1px 8px', fontSize: 10, color: 'var(--nf-warn, #b8860b)' }}>{r}</span>)}
|
|
4367
4471
|
</div>
|
|
4368
|
-
)
|
|
4369
|
-
|
|
4370
|
-
|
|
4371
|
-
|
|
4372
|
-
|
|
4373
|
-
|
|
4374
|
-
|
|
4375
|
-
|
|
4376
|
-
|
|
4377
|
-
|
|
4378
|
-
|
|
4472
|
+
)}
|
|
4473
|
+
|
|
4474
|
+
{(() => {
|
|
4475
|
+
const roles = project?.roles ?? []
|
|
4476
|
+
if (roles.length === 0) {
|
|
4477
|
+
return (
|
|
4478
|
+
<div className={css.shelfEmpty} style={{ minHeight: 140, flex: 1 }}>
|
|
4479
|
+
<span className={css.shelfEmptyIcon}>🎭</span>
|
|
4480
|
+
<span className={css.shelfEmptyTitle}>角色库为空</span>
|
|
4481
|
+
<span className={css.meta}>请先到「角色库」提炼角色并生成形象锚点</span>
|
|
4482
|
+
<button type="button" className={`${css.button} ${css.buttonSmall}`} onClick={() => { setActiveTab('roles') }}>去角色库</button>
|
|
4483
|
+
</div>
|
|
4484
|
+
)
|
|
4485
|
+
}
|
|
4486
|
+
const detailRole = detailRoleName !== null ? roles.find(r => r.name === detailRoleName) : undefined
|
|
4487
|
+
const group = (role: RoleRecord) => {
|
|
4488
|
+
const g = role.gallery ?? []
|
|
4489
|
+
const byLabel = (key: string) => g.filter(x => x.label === key)
|
|
4490
|
+
const byPrefix = (pre: string) => g.filter(x => x.label.startsWith(pre))
|
|
4491
|
+
return {
|
|
4492
|
+
portrait: byLabel('立绘')[0],
|
|
4493
|
+
sheet: byLabel('四视图')[0],
|
|
4494
|
+
expressions: byPrefix('表情-'),
|
|
4495
|
+
others: g.filter(x => x.label !== '立绘' && x.label !== '四视图' && !x.label.startsWith('表情-')),
|
|
4496
|
+
}
|
|
4497
|
+
}
|
|
4498
|
+
const uploadInDetail = (role: RoleRecord) => {
|
|
4499
|
+
setRoleImageLabel(detailUploadLabel.trim() !== '' ? detailUploadLabel.trim() : '立绘')
|
|
4500
|
+
setRoleImageTarget(role.name)
|
|
4501
|
+
roleImageInputRef.current?.click()
|
|
4502
|
+
}
|
|
4503
|
+
return (
|
|
4504
|
+
<>
|
|
4505
|
+
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(130px, 1fr))', gap: 10 }}>
|
|
4506
|
+
{roles.map(r => (
|
|
4507
|
+
<div key={r.name} onClick={() => { setDetailRoleName(r.name); setDetailUploadLabel('立绘') }} style={{ cursor: 'pointer', border: '1px solid var(--nf-border)', borderRadius: 12, overflow: 'hidden', background: 'var(--nf-bg-inset)', display: 'flex', flexDirection: 'column' }}>
|
|
4508
|
+
<div style={{ aspectRatio: '3/4', display: 'flex', alignItems: 'center', justifyContent: 'center', background: 'var(--nf-bg-inset)', borderBottom: '1px solid var(--nf-border)', position: 'relative', overflow: 'hidden' }}>
|
|
4509
|
+
{r.imageUrl !== undefined
|
|
4510
|
+
? <img src={r.imageUrl} alt={r.name} style={{ width: '100%', height: '100%', objectFit: 'cover', objectPosition: 'center 20%' }} />
|
|
4511
|
+
: <span className={css.meta}>暂无形象</span>}
|
|
4512
|
+
<span style={{ position: 'absolute', bottom: 4, right: 4, background: 'rgba(0,0,0,0.6)', color: '#fff', fontSize: 10, borderRadius: 6, padding: '1px 6px' }}>🖼 {(r.gallery ?? []).length}</span>
|
|
4513
|
+
</div>
|
|
4514
|
+
<div style={{ padding: 6, display: 'flex', flexDirection: 'column', gap: 2 }}>
|
|
4515
|
+
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
|
4516
|
+
<b style={{ fontSize: 13 }}>{r.name}</b>
|
|
4517
|
+
<span className={css.meta} style={{ fontSize: 10 }}>{r.roleLabel}</span>
|
|
4518
|
+
</div>
|
|
4519
|
+
<div className={css.meta} style={{ fontSize: 10, whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>
|
|
4520
|
+
{r.imagePrompt !== undefined ? '✓ 锚点' : '⚠ 无锚点'} · {(r.identity ?? '').slice(0, 16)}
|
|
4521
|
+
</div>
|
|
4522
|
+
</div>
|
|
4523
|
+
</div>
|
|
4524
|
+
))}
|
|
4525
|
+
</div>
|
|
4526
|
+
|
|
4527
|
+
{detailRole !== undefined && (
|
|
4528
|
+
<div style={{ position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.55)', zIndex: 1000, display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 24 }} onClick={() => { setDetailRoleName(null) }}>
|
|
4529
|
+
<div style={{ background: 'var(--nf-bg)', border: '1px solid var(--nf-border)', borderRadius: 16, width: 'min(960px, 100%)', maxHeight: '92vh', overflowY: 'auto', padding: 20, position: 'relative' }} onClick={e => e.stopPropagation()}>
|
|
4530
|
+
<button type="button" className={css.iconButton} style={{ position: 'absolute', top: 12, right: 12, fontSize: 18 }} title="关闭" aria-label="关闭" onClick={() => { setDetailRoleName(null) }}>✕</button>
|
|
4531
|
+
<div style={{ marginBottom: 12 }}>
|
|
4532
|
+
<div style={{ display: 'flex', alignItems: 'baseline', gap: 10, flexWrap: 'wrap' }}>
|
|
4533
|
+
<b style={{ fontSize: 22 }}>{detailRole.name}</b>
|
|
4534
|
+
<span className={css.badge} style={{ borderColor: 'var(--nf-accent)', color: 'var(--nf-accent)' }}>{detailRole.roleLabel}</span>
|
|
4535
|
+
</div>
|
|
4536
|
+
<div className={css.meta} style={{ marginTop: 4 }}>{detailRole.identity}</div>
|
|
4537
|
+
</div>
|
|
4538
|
+
|
|
4539
|
+
{(project?.visualRules ?? []).length > 0 && (
|
|
4540
|
+
<div style={{ border: '1px solid var(--nf-warn, #b8860b)', borderRadius: 10, padding: '6px 10px', marginBottom: 10, fontSize: 11 }}>
|
|
4541
|
+
<b>⚠️ 本书视觉规则(已内嵌到下方所有提示词)</b>
|
|
4542
|
+
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 4, marginTop: 4 }}>
|
|
4543
|
+
{(project?.visualRules ?? []).map((r, i) => <span key={i} style={{ border: '1px solid var(--nf-border)', borderRadius: 999, padding: '1px 8px', fontSize: 10 }}>{r}</span>)}
|
|
4544
|
+
</div>
|
|
4545
|
+
</div>
|
|
4546
|
+
)}
|
|
4547
|
+
|
|
4548
|
+
<details style={{ marginBottom: 10, fontSize: 12 }}>
|
|
4549
|
+
<summary style={{ cursor: 'pointer', color: 'var(--nf-text-2)' }}>🖼 图集({(detailRole.gallery ?? []).length} 张 · 点击展开/收起)</summary>
|
|
4550
|
+
<div style={{ marginTop: 8 }}>
|
|
4551
|
+
{(() => {
|
|
4552
|
+
const g = group(detailRole)
|
|
4553
|
+
const hasMain = g.portrait !== undefined || g.sheet !== undefined
|
|
4554
|
+
return (
|
|
4555
|
+
<>
|
|
4556
|
+
{hasMain && (
|
|
4557
|
+
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(240px, 1fr))', gap: 10, marginBottom: 12 }}>
|
|
4558
|
+
{g.portrait !== undefined && (
|
|
4559
|
+
<div style={{ border: '1px solid var(--nf-border)', borderRadius: 12, overflow: 'hidden' }}>
|
|
4560
|
+
<img src={g.portrait.dataUrl} alt="立绘" style={{ width: '100%', maxHeight: 380, objectFit: 'contain', display: 'block', background: '#111' }} />
|
|
4561
|
+
<div className={css.meta} style={{ padding: '2px 8px', fontSize: 11 }}>立绘</div>
|
|
4562
|
+
</div>
|
|
4563
|
+
)}
|
|
4564
|
+
{g.sheet !== undefined && (
|
|
4565
|
+
<div style={{ border: '1px solid var(--nf-border)', borderRadius: 12, overflow: 'hidden' }}>
|
|
4566
|
+
<img src={g.sheet.dataUrl} alt="四视图" style={{ width: '100%', maxHeight: 380, objectFit: 'contain', display: 'block', background: '#111' }} />
|
|
4567
|
+
<div className={css.meta} style={{ padding: '2px 8px', fontSize: 11 }}>四视图</div>
|
|
4568
|
+
</div>
|
|
4569
|
+
)}
|
|
4570
|
+
</div>
|
|
4571
|
+
)}
|
|
4572
|
+
{g.expressions.length > 0 && (
|
|
4573
|
+
<div style={{ marginBottom: 12 }}>
|
|
4574
|
+
<b style={{ fontSize: 13 }}>表情设定</b>
|
|
4575
|
+
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(110px, 1fr))', gap: 8, marginTop: 6 }}>
|
|
4576
|
+
{g.expressions.map(img => (
|
|
4577
|
+
<div key={img.label} style={{ border: '1px solid var(--nf-border)', borderRadius: 10, overflow: 'hidden', position: 'relative' }}>
|
|
4578
|
+
<img src={img.dataUrl} alt={img.label} style={{ width: '100%', height: 110, objectFit: 'cover', display: 'block' }} />
|
|
4579
|
+
<div style={{ background: 'rgba(0,0,0,0.55)', color: '#fff', fontSize: 10, padding: '1px 6px', textAlign: 'center' }}>{img.label.replace('表情-', '')}</div>
|
|
4580
|
+
<button type="button" className={css.iconButton} style={{ position: 'absolute', top: 2, right: 2, fontSize: 11 }} title="删除" aria-label="删除" onClick={() => { void (async () => {
|
|
4581
|
+
try {
|
|
4582
|
+
await api.roles({ op: 'removeImage', name: detailRole.name, label: img.label })
|
|
4583
|
+
await refresh(false)
|
|
4584
|
+
pushProgress(`已删除「${detailRole.name}」${img.label}`, 'done')
|
|
4585
|
+
} catch (err) { setError((err as Error).message) }
|
|
4586
|
+
})() }}>×</button>
|
|
4587
|
+
</div>
|
|
4588
|
+
))}
|
|
4589
|
+
</div>
|
|
4590
|
+
</div>
|
|
4591
|
+
)}
|
|
4592
|
+
{g.others.length > 0 && (
|
|
4593
|
+
<div style={{ marginBottom: 12 }}>
|
|
4594
|
+
<b style={{ fontSize: 13 }}>场景 / 细节</b>
|
|
4595
|
+
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(140px, 1fr))', gap: 8, marginTop: 6 }}>
|
|
4596
|
+
{g.others.map(img => (
|
|
4597
|
+
<div key={img.label} style={{ border: '1px solid var(--nf-border)', borderRadius: 10, overflow: 'hidden', position: 'relative' }}>
|
|
4598
|
+
<img src={img.dataUrl} alt={img.label} style={{ width: '100%', height: 90, objectFit: 'cover', display: 'block' }} />
|
|
4599
|
+
<div style={{ background: 'rgba(0,0,0,0.55)', color: '#fff', fontSize: 10, padding: '1px 6px', textAlign: 'center' }}>{img.label}</div>
|
|
4600
|
+
<button type="button" className={css.iconButton} style={{ position: 'absolute', top: 2, right: 2, fontSize: 11 }} title="删除" aria-label="删除" onClick={() => { void (async () => {
|
|
4601
|
+
try {
|
|
4602
|
+
await api.roles({ op: 'removeImage', name: detailRole.name, label: img.label })
|
|
4603
|
+
await refresh(false)
|
|
4604
|
+
pushProgress(`已删除「${detailRole.name}」${img.label}`, 'done')
|
|
4605
|
+
} catch (err) { setError((err as Error).message) }
|
|
4606
|
+
})() }}>×</button>
|
|
4607
|
+
</div>
|
|
4608
|
+
))}
|
|
4609
|
+
</div>
|
|
4610
|
+
</div>
|
|
4611
|
+
)}
|
|
4612
|
+
</>
|
|
4613
|
+
)
|
|
4614
|
+
})()}
|
|
4615
|
+
</div>
|
|
4616
|
+
</details>
|
|
4617
|
+
|
|
4618
|
+
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(280px, 1fr))', gap: 14, marginBottom: 12 }}>
|
|
4619
|
+
<div>
|
|
4620
|
+
<b style={{ fontSize: 13 }}>基本信息</b>
|
|
4621
|
+
<div style={{ fontSize: 12, lineHeight: 1.8, marginTop: 4 }}>
|
|
4622
|
+
<div><span className={css.meta}>性格:</span>{(detailRole.traits ?? []).join('、') || '—'}</div>
|
|
4623
|
+
<div><span className={css.meta}>目标:</span>{detailRole.goals || '—'}</div>
|
|
4624
|
+
<div><span className={css.meta}>关系:</span>{(detailRole.relations ?? []).join(';') || '—'}</div>
|
|
4625
|
+
<div><span className={css.meta}>成长线:</span>{(detailRole.arc ?? []).join(';') || '—'}</div>
|
|
4626
|
+
</div>
|
|
4627
|
+
</div>
|
|
4628
|
+
<div>
|
|
4629
|
+
<div className={css.row} style={{ justifyContent: 'space-between', flexWrap: 'wrap' }}>
|
|
4630
|
+
<b style={{ fontSize: 13 }}>📄 提示词包</b>
|
|
4631
|
+
{detailRole.imagePrompt !== undefined && detailRole.promptKit === undefined && (
|
|
4632
|
+
<button type="button" className={`${css.button} ${css.buttonSmall}`} disabled={busy} onClick={() => { void (async () => {
|
|
4633
|
+
setBusy(true)
|
|
4634
|
+
setBusyLabel(`精修「${detailRole.name}」提示词…`)
|
|
4635
|
+
setError('')
|
|
4636
|
+
try {
|
|
4637
|
+
await api.roles({ op: 'promptKit', name: detailRole.name })
|
|
4638
|
+
await refresh(false)
|
|
4639
|
+
pushProgress(`已生成「${detailRole.name}」四类精修提示词`, 'done')
|
|
4640
|
+
} catch (err) {
|
|
4641
|
+
setError((err as Error).message)
|
|
4642
|
+
pushProgress(`提示词精修失败:${(err as Error).message}`, 'error')
|
|
4643
|
+
} finally {
|
|
4644
|
+
setBusy(false)
|
|
4645
|
+
setBusyLabel('')
|
|
4646
|
+
}
|
|
4647
|
+
})() }}>✨ 生成精修版</button>
|
|
4648
|
+
)}
|
|
4649
|
+
</div>
|
|
4650
|
+
{detailRole.imagePrompt === undefined && <span className={css.meta}>未生成锚点,先点「✨ 生成锚点」</span>}
|
|
4651
|
+
{(() => {
|
|
4652
|
+
const anchor = detailRole.imagePrompt
|
|
4653
|
+
if (anchor === undefined) return null
|
|
4654
|
+
const rules = project?.visualRules ?? []
|
|
4655
|
+
const rulesZh = rules.length > 0 ? '\n【本书视觉规则】' + rules.join(';') : ''
|
|
4656
|
+
const expressions = detailRole.expressions ?? ['平静']
|
|
4657
|
+
const expName = (n: string) => n.replace(/^表情-/, '')
|
|
4658
|
+
const kit = detailRole.promptKit
|
|
4659
|
+
const blocks: Array<{ key: string; title: string; zh: string; en: string }> = []
|
|
4660
|
+
if (kit !== undefined) {
|
|
4661
|
+
blocks.push({ key: 'portrait', title: '立绘', zh: kit.portrait.zh, en: kit.portrait.en })
|
|
4662
|
+
blocks.push({ key: 'sheet', title: '四视图', zh: kit.sheet.zh, en: kit.sheet.en })
|
|
4663
|
+
for (const e of kit.expressions) blocks.push({ key: 'exp-' + e.name, title: `表情·${e.name}`, zh: e.zh, en: e.en })
|
|
4664
|
+
blocks.push({ key: 'details', title: '细节', zh: kit.details.zh, en: kit.details.en })
|
|
4665
|
+
} else {
|
|
4666
|
+
blocks.push({ key: 'portrait', title: '立绘', zh: anchor.zh + '。全身/半身正视图,写实电影感。' + rulesZh, en: anchor.en + ', full body, front view, plain background' })
|
|
4667
|
+
blocks.push({ key: 'sheet', title: '四视图', zh: anchor.zh + '。同一角色的正面/左侧面/右侧面/背面四视图设定表,纯白背景,四个视角分别描述。' + rulesZh, en: anchor.en + ', character sheet, front view, left side view, right side view, back view, full body, plain white background' })
|
|
4668
|
+
for (const n of expressions) blocks.push({ key: 'exp-' + n, title: `表情·${expName(n)}`, zh: anchor.zh + `。表情:${expName(n)},脸部特写(头部到锁骨),纯白背景,五官与角色定稿完全一致,皮肤纹理细节完整,无多余杂物。` + rulesZh, en: anchor.en + `, facial close-up, head to collarbone, expression: ${expName(n)}, plain white background, consistent with character design, detailed skin texture, no extra objects` })
|
|
4669
|
+
blocks.push({ key: 'details', title: '细节', zh: `多组局部细节集合参考图,纯白背景:${anchor.tags.map(t => t + '特写').join(';')}。细节清晰锐利,角色细节参考稿,无多余杂物。` + rulesZh, en: anchor.tags.join(', ') + ', multi-panel detail reference sheet, plain white background, macro close-up, sharp details, character detail sheet, no extra objects' })
|
|
4670
|
+
}
|
|
4671
|
+
return (
|
|
4672
|
+
<div style={{ display: 'flex', flexDirection: 'column', gap: 8, marginTop: 8 }}>
|
|
4673
|
+
{blocks.map(b => (
|
|
4674
|
+
<div key={b.key} style={{ border: '1px solid var(--nf-border)', borderRadius: 8, padding: '6px 8px' }}>
|
|
4675
|
+
<div className={css.row} style={{ justifyContent: 'space-between' }}>
|
|
4676
|
+
<b style={{ fontSize: 11 }}>{b.title}</b>
|
|
4677
|
+
<span className={css.row} style={{ gap: 4 }}>
|
|
4678
|
+
<button type="button" className={`${css.button} ${css.buttonSmall}`} style={{ fontSize: 10 }} onClick={() => {
|
|
4679
|
+
void navigator.clipboard?.writeText(b.zh).then(() => { pushProgress(`已复制「${detailRole.name}」${b.title}中文提示词`, 'done') }).catch(() => { /* ignore */ })
|
|
4680
|
+
}}>复制</button>
|
|
4681
|
+
<button type="button" className={`${css.button} ${css.buttonSmall}`} style={{ fontSize: 10 }} onClick={() => {
|
|
4682
|
+
void navigator.clipboard?.writeText(b.en).then(() => { pushProgress(`已复制「${detailRole.name}」${b.title}英文标签`, 'done') }).catch(() => { /* ignore */ })
|
|
4683
|
+
}}>EN</button>
|
|
4684
|
+
</span>
|
|
4685
|
+
</div>
|
|
4686
|
+
<div className={css.meta} style={{ fontSize: 11, lineHeight: 1.5, marginTop: 3 }}>{b.zh}</div>
|
|
4687
|
+
<details style={{ marginTop: 3 }}>
|
|
4688
|
+
<summary style={{ cursor: 'pointer', fontSize: 10, color: 'var(--nf-text-2)' }}>🌐 英文标签(点击展开)</summary>
|
|
4689
|
+
<div className={css.meta} style={{ fontSize: 9, fontFamily: 'monospace', marginTop: 2, wordBreak: 'break-all', color: 'var(--nf-text-2)' }}>{b.en}</div>
|
|
4690
|
+
</details>
|
|
4691
|
+
</div>
|
|
4692
|
+
))}
|
|
4693
|
+
<div className={css.meta} style={{ fontSize: 10 }}>关键词:{(anchor.tags ?? []).join('、')}</div>
|
|
4694
|
+
</div>
|
|
4695
|
+
)
|
|
4696
|
+
})()}
|
|
4697
|
+
</div>
|
|
4698
|
+
</div>
|
|
4699
|
+
|
|
4700
|
+
<div style={{ borderTop: '1px solid var(--nf-border)', paddingTop: 10, display: 'flex', flexWrap: 'wrap', gap: 6, alignItems: 'center' }}>
|
|
4701
|
+
{detailRole.imagePrompt === undefined && (
|
|
4702
|
+
<button type="button" className={`${css.button} ${css.buttonSmall}`} disabled={busy} onClick={() => { void handleRoleVisual(detailRole.name) }}>✨ 生成锚点</button>
|
|
4703
|
+
)}
|
|
4704
|
+
{detailRole.imagePrompt !== undefined && config?.imageApiEnabled === true && (
|
|
4705
|
+
<button type="button" className={`${css.button} ${css.buttonSmall}`} disabled={busy} onClick={() => { void handleRoleImageGenerate(detailRole.name) }}>豆包生成</button>
|
|
4706
|
+
)}
|
|
4707
|
+
{detailRole.imagePrompt !== undefined && (
|
|
4708
|
+
<button type="button" className={`${css.button} ${css.buttonSmall}`} onClick={() => {
|
|
4709
|
+
const text = [detailRole.imagePrompt!.en, detailRole.imagePrompt!.tags.join(', ')].filter(Boolean).join('\n')
|
|
4710
|
+
void navigator.clipboard?.writeText(text).then(() => { pushProgress(`已复制「${detailRole.name}」英文生图提示词`, 'done') }).catch(() => { /* ignore */ })
|
|
4711
|
+
}}>复制提示词</button>
|
|
4712
|
+
)}
|
|
4713
|
+
<input
|
|
4714
|
+
className={css.input}
|
|
4715
|
+
style={{ width: 140, fontSize: 12, padding: '3px 6px' }}
|
|
4716
|
+
placeholder="图集标签"
|
|
4717
|
+
value={detailUploadLabel}
|
|
4718
|
+
onChange={e => { setDetailUploadLabel(e.target.value) }}
|
|
4719
|
+
/>
|
|
4720
|
+
<button type="button" className={`${css.button} ${css.buttonSmall}`} disabled={busy} onClick={() => { uploadInDetail(detailRole) }}>📤 上传图</button>
|
|
4721
|
+
<select
|
|
4722
|
+
className={css.input}
|
|
4723
|
+
style={{ fontSize: 12, padding: '3px 4px', width: 90 }}
|
|
4724
|
+
value={detailRole.importance ?? 'support'}
|
|
4725
|
+
onChange={e => { void handleRoleImportance(detailRole, e.target.value as RoleRecord['importance']) }}
|
|
4726
|
+
title="漫剧重要性"
|
|
4727
|
+
>
|
|
4728
|
+
<option value="main">重要</option>
|
|
4729
|
+
<option value="support">配角</option>
|
|
4730
|
+
<option value="extra">路人</option>
|
|
4731
|
+
</select>
|
|
4732
|
+
</div>
|
|
4379
4733
|
</div>
|
|
4380
|
-
<div className={css.meta}>{ep.narrativeJob}</div>
|
|
4381
|
-
<div className={css.meta} style={{ color: 'var(--nf-info)' }}>开头钩子:{ep.openingHook}</div>
|
|
4382
|
-
<div className={css.meta} style={{ color: 'var(--nf-warn)' }}>结尾钩子:{ep.endingHook}</div>
|
|
4383
4734
|
</div>
|
|
4384
|
-
)
|
|
4735
|
+
)}
|
|
4736
|
+
</>
|
|
4737
|
+
)
|
|
4738
|
+
})()}
|
|
4739
|
+
</div>
|
|
4740
|
+
</div>
|
|
4741
|
+
)}
|
|
4742
|
+
|
|
4743
|
+
{activeTab === 'scenes' && (
|
|
4744
|
+
<div className={css.card} style={{ flex: 1, minHeight: 0 }}>
|
|
4745
|
+
<div className={css.row} style={{ justifyContent: 'space-between', flexWrap: 'wrap' }}>
|
|
4746
|
+
<span className={css.cardTitle} style={{ fontSize: 17, fontWeight: 700 }}>🏞️ 场景库</span>
|
|
4747
|
+
<span className={css.meta}>场景视觉锚点:提炼自正文,供漫剧分镜/生图锁定"在哪、什么氛围"</span>
|
|
4748
|
+
</div>
|
|
4749
|
+
|
|
4750
|
+
<div className={css.row} style={{ flexWrap: 'wrap', gap: 6, margin: '8px 0' }}>
|
|
4751
|
+
<button type="button" className={`${css.button} ${css.buttonPrimary}`} disabled={busy} onClick={() => { void (async () => {
|
|
4752
|
+
setBusy(true)
|
|
4753
|
+
setBusyLabel('✨ 从全书提炼场景…')
|
|
4754
|
+
setError('')
|
|
4755
|
+
try {
|
|
4756
|
+
const result = await api.scenes({ op: 'extract' })
|
|
4757
|
+
setSceneCandidates(result.candidates ?? [])
|
|
4758
|
+
pushProgress(`提炼出 ${(result.candidates ?? []).length} 个候选场景,可采纳或修改后采纳`, 'done')
|
|
4759
|
+
} catch (err) {
|
|
4760
|
+
setError((err as Error).message)
|
|
4761
|
+
pushProgress(`场景提炼失败:${(err as Error).message}`, 'error')
|
|
4762
|
+
} finally {
|
|
4763
|
+
setBusy(false)
|
|
4764
|
+
setBusyLabel('')
|
|
4765
|
+
}
|
|
4766
|
+
})() }}>{busy ? '⏳ 提炼中…' : '✨ 从全书提炼场景'}</button>
|
|
4767
|
+
<button type="button" className={`${css.button} ${css.buttonSmall}`} onClick={() => { void refresh(false); pushProgress('场景库已刷新', 'done') }}>🔄 刷新</button>
|
|
4768
|
+
{sceneCandidates !== null && (
|
|
4769
|
+
<button type="button" className={`${css.button} ${css.buttonSmall}`} onClick={() => { setSceneCandidates(null) }}>收起候选</button>
|
|
4770
|
+
)}
|
|
4771
|
+
</div>
|
|
4772
|
+
|
|
4773
|
+
{sceneDraft !== null && (
|
|
4774
|
+
<div style={{ border: '1px solid var(--nf-accent)', borderRadius: 10, padding: 10, marginBottom: 10, display: 'flex', flexDirection: 'column', gap: 6 }}>
|
|
4775
|
+
<b style={{ fontSize: 13 }}>编辑场景</b>
|
|
4776
|
+
<input className={css.input} style={{ fontSize: 12, padding: '4px 8px' }} placeholder="场景名" value={sceneDraft.name} onChange={e => { setSceneDraft({ ...sceneDraft, name: e.target.value }) }} />
|
|
4777
|
+
<input className={css.input} style={{ fontSize: 12, padding: '4px 8px' }} placeholder="一句话定位" value={sceneDraft.summary} onChange={e => { setSceneDraft({ ...sceneDraft, summary: e.target.value }) }} />
|
|
4778
|
+
<div className={css.row} style={{ gap: 6 }}>
|
|
4779
|
+
<input className={css.input} style={{ fontSize: 12, padding: '4px 8px', flex: 1 }} placeholder="幕归属(第一幕后场…)" value={sceneDraft.act ?? ''} onChange={e => { setSceneDraft({ ...sceneDraft, act: e.target.value }) }} />
|
|
4780
|
+
<input className={css.input} style={{ fontSize: 12, padding: '4px 8px', flex: 1 }} placeholder="时间光态(雨夜/闭店后…)" value={sceneDraft.moment ?? ''} onChange={e => { setSceneDraft({ ...sceneDraft, moment: e.target.value }) }} />
|
|
4781
|
+
</div>
|
|
4782
|
+
<textarea className={css.input} style={{ fontSize: 12, padding: '4px 8px', minHeight: 48 }} placeholder="关键镜头(每行一条:人物动作+情绪+镜头)" value={(sceneDraft.beats ?? []).join('\n')} onChange={e => { setSceneDraft({ ...sceneDraft, beats: e.target.value.split(/\n+/).map(x => x.trim()).filter(Boolean) }) }} />
|
|
4783
|
+
<input className={css.input} style={{ fontSize: 12, padding: '4px 8px' }} placeholder="人物在场状态(含标志物细节)" value={sceneDraft.characterState ?? ''} onChange={e => { setSceneDraft({ ...sceneDraft, characterState: e.target.value }) }} />
|
|
4784
|
+
<textarea className={css.input} style={{ fontSize: 12, padding: '4px 8px', minHeight: 60 }} placeholder="环境构成(每行一项)" value={(sceneDraft.elements ?? []).join('\n')} onChange={e => { setSceneDraft({ ...sceneDraft, elements: e.target.value.split(/\n+/).map(x => x.trim()).filter(Boolean) }) }} />
|
|
4785
|
+
<textarea className={css.input} style={{ fontSize: 12, padding: '4px 8px', minHeight: 60 }} placeholder="中文生图提示词" value={sceneDraft.zh} onChange={e => { setSceneDraft({ ...sceneDraft, zh: e.target.value }) }} />
|
|
4786
|
+
<textarea className={css.input} style={{ fontSize: 12, padding: '4px 8px', minHeight: 60 }} placeholder="英文生图提示词" value={sceneDraft.en} onChange={e => { setSceneDraft({ ...sceneDraft, en: e.target.value }) }} />
|
|
4787
|
+
<div className={css.row} style={{ gap: 6 }}>
|
|
4788
|
+
<button type="button" className={`${css.button} ${css.buttonPrimary}`} disabled={busy || sceneDraft.name.trim() === ''} onClick={() => { void (async () => {
|
|
4789
|
+
setBusy(true)
|
|
4790
|
+
try {
|
|
4791
|
+
await api.scenes({ op: 'update', scene: { ...sceneDraft, name: sceneDraft.name.trim() } })
|
|
4792
|
+
await refresh(false)
|
|
4793
|
+
setSceneDraft(null)
|
|
4794
|
+
pushProgress(`已保存场景「${sceneDraft.name.trim()}」`, 'done')
|
|
4795
|
+
} catch (err) {
|
|
4796
|
+
setError((err as Error).message)
|
|
4797
|
+
} finally {
|
|
4798
|
+
setBusy(false)
|
|
4799
|
+
}
|
|
4800
|
+
})() }}>保存</button>
|
|
4801
|
+
<button type="button" className={`${css.button} ${css.buttonSmall}`} onClick={() => { setSceneDraft(null) }}>取消</button>
|
|
4802
|
+
</div>
|
|
4803
|
+
</div>
|
|
4804
|
+
)}
|
|
4805
|
+
|
|
4806
|
+
{sceneCandidates !== null && sceneCandidates.length > 0 && (
|
|
4807
|
+
<div style={{ display: 'flex', flexDirection: 'column', gap: 6, marginBottom: 10 }}>
|
|
4808
|
+
{sceneCandidates.map(s => (
|
|
4809
|
+
<div key={s.name} style={{ border: '1px solid var(--nf-accent)', borderRadius: 10, padding: '8px 10px', fontSize: 12 }}>
|
|
4810
|
+
<div className={css.row} style={{ flexWrap: 'wrap', gap: 6, justifyContent: 'space-between' }}>
|
|
4811
|
+
<span><b>{s.name}</b> <span className={css.meta}>· {(s.moods ?? []).join('、')}</span></span>
|
|
4812
|
+
<span className={css.row} style={{ gap: 4 }}>
|
|
4813
|
+
<button type="button" className={`${css.button} ${css.buttonSmall}`} onClick={() => { void (async () => {
|
|
4814
|
+
try {
|
|
4815
|
+
await api.scenes({ op: 'adopt', scene: s })
|
|
4816
|
+
setSceneCandidates((sceneCandidates ?? []).filter(x => x.name !== s.name))
|
|
4817
|
+
await refresh(false)
|
|
4818
|
+
pushProgress(`已采纳场景「${s.name}」`, 'done')
|
|
4819
|
+
} catch (err) { setError((err as Error).message) }
|
|
4820
|
+
})() }}>采纳</button>
|
|
4821
|
+
<button type="button" className={`${css.button} ${css.buttonSmall}`} onClick={() => { setSceneDraft(s); setSceneCandidates(null) }}>修改后采纳</button>
|
|
4822
|
+
</span>
|
|
4385
4823
|
</div>
|
|
4824
|
+
<div className={css.meta} style={{ marginTop: 2 }}>{s.summary}</div>
|
|
4825
|
+
<div className={css.meta} style={{ marginTop: 2, fontSize: 11 }}>要素:{(s.elements ?? []).join(';')}</div>
|
|
4386
4826
|
</div>
|
|
4387
|
-
)}
|
|
4827
|
+
))}
|
|
4388
4828
|
</div>
|
|
4389
4829
|
)}
|
|
4390
4830
|
|
|
4391
|
-
{
|
|
4392
|
-
|
|
4393
|
-
|
|
4394
|
-
|
|
4395
|
-
|
|
4396
|
-
|
|
4397
|
-
|
|
4398
|
-
|
|
4399
|
-
|
|
4400
|
-
|
|
4401
|
-
|
|
4402
|
-
|
|
4403
|
-
|
|
4404
|
-
|
|
4405
|
-
|
|
4406
|
-
|
|
4407
|
-
|
|
4408
|
-
|
|
4409
|
-
|
|
4410
|
-
|
|
4411
|
-
|
|
4412
|
-
|
|
4413
|
-
|
|
4414
|
-
|
|
4415
|
-
|
|
4416
|
-
|
|
4417
|
-
|
|
4418
|
-
|
|
4419
|
-
|
|
4420
|
-
|
|
4421
|
-
|
|
4831
|
+
{(() => {
|
|
4832
|
+
const scenes = project?.scenes ?? []
|
|
4833
|
+
const detail = sceneDetailName !== null ? scenes.find(s => s.name === sceneDetailName) : undefined
|
|
4834
|
+
if (scenes.length === 0 && sceneCandidates === null) {
|
|
4835
|
+
return (
|
|
4836
|
+
<div className={css.shelfEmpty} style={{ minHeight: 140, flex: 1 }}>
|
|
4837
|
+
<span className={css.shelfEmptyIcon}>🏞️</span>
|
|
4838
|
+
<span className={css.shelfEmptyTitle}>场景库为空</span>
|
|
4839
|
+
<span className={css.meta}>点「✨ 从全书提炼场景」自动建立,或手动新增</span>
|
|
4840
|
+
</div>
|
|
4841
|
+
)
|
|
4842
|
+
}
|
|
4843
|
+
return (
|
|
4844
|
+
<>
|
|
4845
|
+
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(180px, 1fr))', gap: 10 }}>
|
|
4846
|
+
{scenes.map(s => {
|
|
4847
|
+
const cover = (s.gallery ?? [])[0]
|
|
4848
|
+
return (
|
|
4849
|
+
<div key={s.name} onClick={() => { setSceneDetailName(s.name); setSceneUploadLabel('全景') }} style={{ cursor: 'pointer', border: '1px solid var(--nf-border)', borderRadius: 12, overflow: 'hidden', background: 'var(--nf-bg-inset)', display: 'flex', flexDirection: 'column' }}>
|
|
4850
|
+
<div style={{ height: 100, display: 'flex', alignItems: 'center', justifyContent: 'center', background: 'var(--nf-bg-inset)', borderBottom: '1px solid var(--nf-border)', position: 'relative' }}>
|
|
4851
|
+
{cover !== undefined
|
|
4852
|
+
? <img src={cover.dataUrl} alt={s.name} style={{ width: '100%', height: '100%', objectFit: 'cover' }} />
|
|
4853
|
+
: <span className={css.meta}>暂无场景图</span>}
|
|
4854
|
+
<span style={{ position: 'absolute', bottom: 4, right: 4, background: 'rgba(0,0,0,0.6)', color: '#fff', fontSize: 10, borderRadius: 6, padding: '1px 6px' }}>🖼 {(s.gallery ?? []).length}</span>
|
|
4855
|
+
</div>
|
|
4856
|
+
<div style={{ padding: 6, display: 'flex', flexDirection: 'column', gap: 2 }}>
|
|
4857
|
+
<b style={{ fontSize: 13 }}>{s.name}</b>
|
|
4858
|
+
<div className={css.meta} style={{ fontSize: 10 }}>{(s.moods ?? []).join('、') || s.summary.slice(0, 16)}</div>
|
|
4859
|
+
</div>
|
|
4860
|
+
</div>
|
|
4861
|
+
)
|
|
4862
|
+
})}
|
|
4422
4863
|
</div>
|
|
4423
|
-
</div>
|
|
4424
4864
|
|
|
4425
|
-
|
|
4426
|
-
|
|
4427
|
-
|
|
4428
|
-
|
|
4429
|
-
|
|
4430
|
-
|
|
4431
|
-
|
|
4432
|
-
|
|
4433
|
-
<div className={css.meta}><b>本集:</b>{storyboardResult.title}</div>
|
|
4434
|
-
<div className={css.meta}><b>赛道节奏:</b>{storyboardResult.pacingNote}</div>
|
|
4435
|
-
<div className={css.meta}><b>本集钩子:</b>{storyboardResult.hook}</div>
|
|
4865
|
+
{detail !== undefined && (
|
|
4866
|
+
<div style={{ position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.55)', zIndex: 1000, display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 24 }} onClick={() => { setSceneDetailName(null) }}>
|
|
4867
|
+
<div style={{ background: 'var(--nf-bg)', border: '1px solid var(--nf-border)', borderRadius: 16, width: 'min(860px, 100%)', maxHeight: '92vh', overflowY: 'auto', padding: 20, position: 'relative' }} onClick={e => e.stopPropagation()}>
|
|
4868
|
+
<button type="button" className={css.iconButton} style={{ position: 'absolute', top: 12, right: 12, fontSize: 18 }} title="关闭" aria-label="关闭" onClick={() => { setSceneDetailName(null) }}>✕</button>
|
|
4869
|
+
<div style={{ marginBottom: 12 }}>
|
|
4870
|
+
<b style={{ fontSize: 20 }}>{detail.name}</b>
|
|
4871
|
+
<div className={css.meta} style={{ marginTop: 4 }}>{detail.summary}</div>
|
|
4872
|
+
</div>
|
|
4436
4873
|
|
|
4437
|
-
|
|
4438
|
-
|
|
4439
|
-
|
|
4440
|
-
|
|
4441
|
-
|
|
4442
|
-
|
|
4443
|
-
|
|
4444
|
-
|
|
4445
|
-
|
|
4446
|
-
|
|
4447
|
-
|
|
4448
|
-
|
|
4874
|
+
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(160px, 1fr))', gap: 8, marginBottom: 12 }}>
|
|
4875
|
+
{(detail.gallery ?? []).map(img => (
|
|
4876
|
+
<div key={img.label} style={{ border: '1px solid var(--nf-border)', borderRadius: 10, overflow: 'hidden', position: 'relative' }}>
|
|
4877
|
+
<img src={img.dataUrl} alt={img.label} style={{ width: '100%', height: 100, objectFit: 'cover', display: 'block' }} />
|
|
4878
|
+
<div style={{ background: 'rgba(0,0,0,0.55)', color: '#fff', fontSize: 10, padding: '1px 6px', textAlign: 'center' }}>{img.label}</div>
|
|
4879
|
+
<button type="button" className={css.iconButton} style={{ position: 'absolute', top: 2, right: 2, fontSize: 11 }} title="删除" aria-label="删除" onClick={() => { void (async () => {
|
|
4880
|
+
try {
|
|
4881
|
+
await api.scenes({ op: 'removeImage', name: detail.name, label: img.label })
|
|
4882
|
+
await refresh(false)
|
|
4883
|
+
pushProgress(`已删除「${detail.name}」${img.label}`, 'done')
|
|
4884
|
+
} catch (err) { setError((err as Error).message) }
|
|
4885
|
+
})() }}>×</button>
|
|
4886
|
+
</div>
|
|
4887
|
+
))}
|
|
4888
|
+
{(detail.gallery ?? []).length === 0 && <span className={css.meta}>暂无场景图</span>}
|
|
4889
|
+
</div>
|
|
4449
4890
|
|
|
4450
|
-
|
|
4451
|
-
|
|
4452
|
-
|
|
4453
|
-
|
|
4454
|
-
|
|
4455
|
-
|
|
4456
|
-
|
|
4457
|
-
|
|
4458
|
-
|
|
4459
|
-
|
|
4460
|
-
|
|
4461
|
-
|
|
4462
|
-
|
|
4463
|
-
|
|
4464
|
-
|
|
4465
|
-
|
|
4466
|
-
|
|
4467
|
-
|
|
4468
|
-
|
|
4469
|
-
|
|
4470
|
-
|
|
4891
|
+
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(260px, 1fr))', gap: 14, marginBottom: 12 }}>
|
|
4892
|
+
<div style={{ fontSize: 12, lineHeight: 1.8 }}>
|
|
4893
|
+
{(detail.act !== undefined || detail.moment !== undefined) && (
|
|
4894
|
+
<div style={{ marginBottom: 6 }}>
|
|
4895
|
+
{(detail.act !== undefined && detail.act !== '') && <span className={css.badge} style={{ borderColor: 'var(--nf-accent)', color: 'var(--nf-accent)', marginRight: 6 }}>{detail.act}</span>}
|
|
4896
|
+
{(detail.moment !== undefined && detail.moment !== '') && <span className={css.meta}>{detail.moment}</span>}
|
|
4897
|
+
</div>
|
|
4898
|
+
)}
|
|
4899
|
+
{(detail.beats ?? []).length > 0 && (
|
|
4900
|
+
<div style={{ marginBottom: 8 }}>
|
|
4901
|
+
<b style={{ fontSize: 13 }}>关键镜头</b>
|
|
4902
|
+
<div style={{ marginTop: 4 }}>{(detail.beats ?? []).map((b, i) => <div key={i} style={{ fontSize: 11, color: 'var(--nf-text-2)', marginTop: 2 }}>🎬 {b}</div>)}</div>
|
|
4903
|
+
</div>
|
|
4904
|
+
)}
|
|
4905
|
+
{detail.characterState !== undefined && detail.characterState !== '' && (
|
|
4906
|
+
<div style={{ marginBottom: 8 }}>
|
|
4907
|
+
<b style={{ fontSize: 13 }}>人物状态</b>
|
|
4908
|
+
<div className={css.meta} style={{ marginTop: 2 }}>{detail.characterState}</div>
|
|
4909
|
+
</div>
|
|
4910
|
+
)}
|
|
4911
|
+
<b style={{ fontSize: 13 }}>环境构成</b>
|
|
4912
|
+
<div style={{ marginTop: 4 }}>{(detail.elements ?? []).map(e => <span key={e} style={{ border: '1px solid var(--nf-border)', borderRadius: 999, padding: '1px 8px', fontSize: 11, margin: '0 4px 4px 0', display: 'inline-block' }}>{e}</span>)}</div>
|
|
4913
|
+
<div style={{ marginTop: 8 }}><b style={{ fontSize: 13 }}>色调光影</b></div>
|
|
4914
|
+
<div style={{ marginTop: 4 }}>{(detail.palette ?? []).join(';') || '—'}</div>
|
|
4915
|
+
<div style={{ marginTop: 8 }}><b style={{ fontSize: 13 }}>氛围</b></div>
|
|
4916
|
+
<div style={{ marginTop: 4 }}>{(detail.moods ?? []).join('、') || '—'}</div>
|
|
4917
|
+
<div style={{ marginTop: 8 }}><b style={{ fontSize: 13 }}>依据</b></div>
|
|
4918
|
+
<div className={css.meta} style={{ marginTop: 4 }}>{detail.source || '—'}</div>
|
|
4919
|
+
</div>
|
|
4920
|
+
<div>
|
|
4921
|
+
<b style={{ fontSize: 13 }}>生图提示词</b>
|
|
4922
|
+
<div className={css.meta} style={{ fontSize: 11, lineHeight: 1.6, marginTop: 4 }}>{detail.zh}</div>
|
|
4923
|
+
<div className={css.meta} style={{ fontSize: 10, fontFamily: 'monospace', marginTop: 4, wordBreak: 'break-all' }}>{detail.en}</div>
|
|
4924
|
+
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 4, marginTop: 8 }}>
|
|
4925
|
+
{(detail.tags ?? []).map(t => <span key={t} style={{ border: '1px solid var(--nf-border)', borderRadius: 999, padding: '2px 8px', fontSize: 11 }}>{t}</span>)}
|
|
4926
|
+
</div>
|
|
4927
|
+
</div>
|
|
4928
|
+
</div>
|
|
4929
|
+
|
|
4930
|
+
<div style={{ borderTop: '1px solid var(--nf-border)', paddingTop: 10, display: 'flex', flexWrap: 'wrap', gap: 6, alignItems: 'center' }}>
|
|
4931
|
+
<button type="button" className={`${css.button} ${css.buttonSmall}`} onClick={() => {
|
|
4932
|
+
const text = [detail.en, detail.tags.join(', ')].filter(Boolean).join('\n')
|
|
4933
|
+
void navigator.clipboard?.writeText(text).then(() => { pushProgress(`已复制「${detail.name}」英文生图提示词`, 'done') }).catch(() => { /* ignore */ })
|
|
4934
|
+
}}>复制提示词</button>
|
|
4935
|
+
<input className={css.input} style={{ width: 130, fontSize: 12, padding: '3px 6px' }} placeholder="图集标签" value={sceneUploadLabel} onChange={e => { setSceneUploadLabel(e.target.value) }} />
|
|
4936
|
+
<button type="button" className={`${css.button} ${css.buttonSmall}`} disabled={busy} onClick={() => {
|
|
4937
|
+
setRoleImageLabel(sceneUploadLabel.trim() !== '' ? sceneUploadLabel.trim() : '全景')
|
|
4938
|
+
setSceneUploadLabel(sceneUploadLabel.trim() !== '' ? sceneUploadLabel.trim() : '全景')
|
|
4939
|
+
setRoleImageTarget(detail.name + '||scene')
|
|
4940
|
+
roleImageInputRef.current?.click()
|
|
4941
|
+
}}>📤 上传场景图</button>
|
|
4942
|
+
<button type="button" className={`${css.button} ${css.buttonSmall}`} onClick={() => { setSceneDraft(detail); setSceneDetailName(null) }}>✏️ 编辑</button>
|
|
4943
|
+
<button type="button" className={`${css.button} ${css.buttonSmall}`} style={{ color: 'var(--nf-danger, #e05)' }} onClick={() => { void (async () => {
|
|
4944
|
+
if (!window.confirm(`确定删除场景「${detail.name}」?`)) return
|
|
4945
|
+
try {
|
|
4946
|
+
await api.scenes({ op: 'remove', name: detail.name })
|
|
4947
|
+
await refresh(false)
|
|
4948
|
+
setSceneDetailName(null)
|
|
4949
|
+
pushProgress(`已删除场景「${detail.name}」`, 'done')
|
|
4950
|
+
} catch (err) { setError((err as Error).message) }
|
|
4951
|
+
})() }}>🗑 删除</button>
|
|
4952
|
+
</div>
|
|
4471
4953
|
</div>
|
|
4472
|
-
<div className={css.meta}>{p.visual}</div>
|
|
4473
|
-
{p.dialogue !== '' && <div className={css.meta} style={{ color: 'var(--nf-info)' }}>💬 {p.dialogue}</div>}
|
|
4474
|
-
<div className={css.meta} style={{ fontFamily: 'monospace', fontSize: 11, color: 'var(--nf-text-2)' }}>{p.prompt}</div>
|
|
4475
4954
|
</div>
|
|
4476
|
-
)
|
|
4477
|
-
|
|
4478
|
-
|
|
4479
|
-
)}
|
|
4480
|
-
</>
|
|
4481
|
-
)}
|
|
4955
|
+
)}
|
|
4956
|
+
</>
|
|
4957
|
+
)
|
|
4958
|
+
})()}
|
|
4482
4959
|
</div>
|
|
4483
4960
|
)}
|
|
4961
|
+
|
|
4484
4962
|
</>)}
|
|
4485
4963
|
</div>
|
|
4486
4964
|
</div>
|