@exccoder/dsh-media-workbench 0.0.0-stage → 0.1.2
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/CHANGELOG.md +35 -0
- package/LICENSE +21 -0
- package/README.en.md +111 -0
- package/README.md +141 -2
- package/SECURITY.md +29 -0
- package/cordis.patch.yml +4 -0
- package/docs/screenshot-graph.webp +0 -0
- package/docs/screenshot-image.webp +0 -0
- package/docs/screenshot-video.webp +0 -0
- package/docs/screenshot-write.webp +0 -0
- package/lib/client.js +110 -0
- package/package.json +79 -4
- package/src/graph.js +304 -0
- package/src/image.js +137 -0
- package/src/index.js +769 -0
- package/src/llm.js +60 -0
- package/src/settings.js +187 -0
- package/src/store.js +274 -0
- package/src/tools.js +354 -0
- package/src/util.js +217 -0
- package/src/video.js +219 -0
- package/src/writing.js +112 -0
- package/web/app.js +268 -0
- package/web/favicon.svg +12 -0
- package/web/index.html +16 -0
- package/web/lib/api.js +79 -0
- package/web/lib/constants.js +69 -0
- package/web/lib/dom.js +99 -0
- package/web/lib/icons.js +65 -0
- package/web/lib/state.js +42 -0
- package/web/styles.css +1096 -0
- package/web/views/constellation-model.js +63 -0
- package/web/views/constellation.js +1110 -0
- package/web/views/image.js +488 -0
- package/web/views/settings.js +244 -0
- package/web/views/video.js +363 -0
- package/web/views/write.js +513 -0
|
@@ -0,0 +1,488 @@
|
|
|
1
|
+
import { api, fileUrl, upload } from '../lib/api.js'
|
|
2
|
+
import { $, $$, autoGrow, copyText, downloadUrl, esc, fmtTime, modKey } from '../lib/dom.js'
|
|
3
|
+
import { icon } from '../lib/icons.js'
|
|
4
|
+
import { app, t } from '../lib/state.js'
|
|
5
|
+
import { INSPIRATIONS, RATIOS, STYLE_LABELS_EN, STYLES } from '../lib/constants.js'
|
|
6
|
+
|
|
7
|
+
export function mount(root) {
|
|
8
|
+
const prefs = app.state?.preferences ?? {}
|
|
9
|
+
let style = prefs.imageStyle ?? '电影感'
|
|
10
|
+
let ratio = RATIOS.some(r => r.id === prefs.imageRatio) ? prefs.imageRatio : '16:9'
|
|
11
|
+
let count = Math.min(4, Math.max(1, Number(prefs.imageCount) || 1))
|
|
12
|
+
let reference = null
|
|
13
|
+
let filter = 'all'
|
|
14
|
+
let query = ''
|
|
15
|
+
let busy = false
|
|
16
|
+
|
|
17
|
+
const styleName = value => (app.lang === 'en' ? (STYLE_LABELS_EN[value] ?? value) : value)
|
|
18
|
+
const inspoTitle = item => (app.lang === 'en' ? item.titleEn : item.title)
|
|
19
|
+
const inspoPrompt = item => (app.lang === 'en' ? item.promptEn : item.prompt)
|
|
20
|
+
|
|
21
|
+
root.innerHTML = `
|
|
22
|
+
<section class="bench bench-image">
|
|
23
|
+
<aside class="composer" id="composer">
|
|
24
|
+
<div class="composer-head">
|
|
25
|
+
<div>
|
|
26
|
+
<span class="eyebrow">${esc(t('画面', 'FRAME'))}</span>
|
|
27
|
+
<h2>${esc(t('生图工作台', 'Image Studio'))}</h2>
|
|
28
|
+
</div>
|
|
29
|
+
<button class="icon-btn" data-act="settings" title="${esc(t('生图设置', 'Image settings'))}">${icon('settings', 16)}</button>
|
|
30
|
+
</div>
|
|
31
|
+
<div class="provider-line" id="provider-line"></div>
|
|
32
|
+
<label class="field field-prompt">
|
|
33
|
+
<span class="field-label">${esc(t('提示词', 'Prompt'))}<em id="prompt-count">0</em></span>
|
|
34
|
+
<textarea id="prompt" rows="5" maxlength="2000" placeholder="${esc(t('描述主体、构图、光线、材质,以及不想出现的东西…', 'Describe subject, composition, light, material — and what to avoid…'))}"></textarea>
|
|
35
|
+
</label>
|
|
36
|
+
<div class="field">
|
|
37
|
+
<span class="field-label">${esc(t('风格', 'Style'))}</span>
|
|
38
|
+
<div class="chips" id="styles">
|
|
39
|
+
<button type="button" class="chip" data-style="">${esc(t('无', 'None'))}</button>
|
|
40
|
+
${STYLES.map(name => `<button type="button" class="chip" data-style="${esc(name)}">${esc(styleName(name))}</button>`).join('')}
|
|
41
|
+
</div>
|
|
42
|
+
</div>
|
|
43
|
+
<div class="field">
|
|
44
|
+
<span class="field-label">${esc(t('画幅', 'Aspect'))}</span>
|
|
45
|
+
<div class="chips chips-ratio" id="ratios">
|
|
46
|
+
${RATIOS.map(item => `<button type="button" class="chip chip-ratio" data-ratio="${item.id}" title="${item.hint}"><i class="ratio-shape" style="aspect-ratio:${item.id.replace(':', '/')}"></i>${item.label}</button>`).join('')}
|
|
47
|
+
</div>
|
|
48
|
+
</div>
|
|
49
|
+
<div class="field-row">
|
|
50
|
+
<div class="field">
|
|
51
|
+
<span class="field-label">${esc(t('数量', 'Count'))}</span>
|
|
52
|
+
<div class="stepper" id="count">
|
|
53
|
+
<button type="button" data-step="-1" aria-label="减少">${icon('minus', 14)}</button>
|
|
54
|
+
<b id="count-value">${count}</b>
|
|
55
|
+
<button type="button" data-step="1" aria-label="增加">${icon('plus', 14)}</button>
|
|
56
|
+
</div>
|
|
57
|
+
</div>
|
|
58
|
+
<div class="field">
|
|
59
|
+
<span class="field-label">${esc(t('质量', 'Quality'))}</span>
|
|
60
|
+
<select id="quality" class="select">
|
|
61
|
+
<option value="">${esc(t('自动', 'Auto'))}</option>
|
|
62
|
+
<option value="high">high</option>
|
|
63
|
+
<option value="medium">medium</option>
|
|
64
|
+
<option value="low">low</option>
|
|
65
|
+
</select>
|
|
66
|
+
</div>
|
|
67
|
+
</div>
|
|
68
|
+
<details class="advanced" id="advanced">
|
|
69
|
+
<summary>${icon('layers', 14)}<span>${esc(t('参考图与高级参数', 'Reference & advanced'))}</span>${icon('chevron', 14)}</summary>
|
|
70
|
+
<div class="advanced-body">
|
|
71
|
+
<div class="drop" id="drop" tabindex="0" role="button" aria-label="${esc(t('上传参考图', 'Upload reference'))}">
|
|
72
|
+
<input type="file" id="ref-input" accept="image/png,image/jpeg,image/webp" hidden>
|
|
73
|
+
<div class="drop-empty">${icon('upload', 18)}<span>${esc(t('拖入或选择一张参考图', 'Drop or choose a reference image'))}</span><small>${esc(t('会走图生图 / 编辑接口', 'Uses the image edit endpoint'))}</small></div>
|
|
74
|
+
<div class="drop-preview" id="drop-preview" hidden><img id="ref-img" alt=""><button type="button" class="mini-x" id="ref-clear" aria-label="移除">${icon('x', 12)}</button></div>
|
|
75
|
+
</div>
|
|
76
|
+
<label class="field">
|
|
77
|
+
<span class="field-label">${esc(t('反向提示词', 'Negative prompt'))}</span>
|
|
78
|
+
<textarea id="negative" rows="2" maxlength="800" placeholder="${esc(t('不想要的内容,例如:文字、水印、多余的手指', 'Things to avoid, e.g. text, watermark, extra fingers'))}"></textarea>
|
|
79
|
+
</label>
|
|
80
|
+
</div>
|
|
81
|
+
</details>
|
|
82
|
+
<button class="btn btn-generate" id="generate">
|
|
83
|
+
<span class="gen-ic">${icon('spark', 17)}</span>
|
|
84
|
+
<span class="gen-label">${esc(t('开始生成', 'Generate'))}</span>
|
|
85
|
+
<kbd>${modKey()} ↵</kbd>
|
|
86
|
+
</button>
|
|
87
|
+
<p class="fine">${esc(t('图片自动存进本机作品库,不会上传到别处。', 'Images stay on this machine in your local library.'))}</p>
|
|
88
|
+
</aside>
|
|
89
|
+
|
|
90
|
+
<section class="gallery-pane">
|
|
91
|
+
<header class="pane-head">
|
|
92
|
+
<div class="pane-title">
|
|
93
|
+
<h2>${esc(t('创作历史', 'Library'))}</h2>
|
|
94
|
+
<span class="count-pill" id="gallery-count">0</span>
|
|
95
|
+
</div>
|
|
96
|
+
<div class="gallery-tools">
|
|
97
|
+
<div class="seg" id="filter">
|
|
98
|
+
<button type="button" data-f="all" class="is-on">${esc(t('全部', 'All'))}</button>
|
|
99
|
+
<button type="button" data-f="fav">${esc(t('收藏', 'Saved'))}</button>
|
|
100
|
+
</div>
|
|
101
|
+
<label class="search">${icon('search', 15)}<input id="search" type="search" placeholder="${esc(t('搜提示词、风格、模型', 'Search prompts, styles, models'))}"></label>
|
|
102
|
+
</div>
|
|
103
|
+
</header>
|
|
104
|
+
<div id="gallery" class="gallery"></div>
|
|
105
|
+
</section>
|
|
106
|
+
</section>
|
|
107
|
+
`
|
|
108
|
+
|
|
109
|
+
const promptEl = $('#prompt')
|
|
110
|
+
const galleryEl = $('#gallery')
|
|
111
|
+
|
|
112
|
+
// ---- 初始化 ----
|
|
113
|
+
promptEl.addEventListener('input', () => {
|
|
114
|
+
$('#prompt-count').textContent = String(promptEl.value.length)
|
|
115
|
+
autoGrow(promptEl, 260)
|
|
116
|
+
})
|
|
117
|
+
autoGrow(promptEl, 260)
|
|
118
|
+
|
|
119
|
+
$('#styles').addEventListener('click', event => {
|
|
120
|
+
const btn = event.target.closest('[data-style]')
|
|
121
|
+
if (!btn) return
|
|
122
|
+
style = btn.dataset.style
|
|
123
|
+
paintChoices()
|
|
124
|
+
})
|
|
125
|
+
$('#ratios').addEventListener('click', event => {
|
|
126
|
+
const btn = event.target.closest('[data-ratio]')
|
|
127
|
+
if (!btn) return
|
|
128
|
+
ratio = btn.dataset.ratio
|
|
129
|
+
paintChoices()
|
|
130
|
+
})
|
|
131
|
+
$('#count').addEventListener('click', event => {
|
|
132
|
+
const btn = event.target.closest('[data-step]')
|
|
133
|
+
if (!btn) return
|
|
134
|
+
count = Math.min(4, Math.max(1, count + Number(btn.dataset.step)))
|
|
135
|
+
$('#count-value').textContent = String(count)
|
|
136
|
+
})
|
|
137
|
+
$('#filter').addEventListener('click', event => {
|
|
138
|
+
const btn = event.target.closest('[data-f]')
|
|
139
|
+
if (!btn) return
|
|
140
|
+
filter = btn.dataset.f
|
|
141
|
+
$$('#filter button').forEach(b => b.classList.toggle('is-on', b === btn))
|
|
142
|
+
renderGallery()
|
|
143
|
+
})
|
|
144
|
+
$('#search').addEventListener('input', () => {
|
|
145
|
+
query = $('#search').value.trim().toLowerCase()
|
|
146
|
+
renderGallery()
|
|
147
|
+
})
|
|
148
|
+
$('#generate').addEventListener('click', generate)
|
|
149
|
+
promptEl.addEventListener('keydown', event => {
|
|
150
|
+
if ((event.metaKey || event.ctrlKey) && event.key === 'Enter') { event.preventDefault(); generate() }
|
|
151
|
+
})
|
|
152
|
+
root.addEventListener('click', event => {
|
|
153
|
+
if (event.target.closest('[data-act="settings"]')) app.navigate('settings')
|
|
154
|
+
})
|
|
155
|
+
|
|
156
|
+
// 参考图
|
|
157
|
+
const refInput = $('#ref-input')
|
|
158
|
+
const drop = $('#drop')
|
|
159
|
+
const pickRef = () => refInput.click()
|
|
160
|
+
drop.addEventListener('click', event => { if (!event.target.closest('#ref-clear')) pickRef() })
|
|
161
|
+
drop.addEventListener('keydown', event => { if (event.key === 'Enter' || event.key === ' ') { event.preventDefault(); pickRef() } })
|
|
162
|
+
refInput.addEventListener('change', async () => {
|
|
163
|
+
const file = refInput.files?.[0]
|
|
164
|
+
if (!file) return
|
|
165
|
+
await handleReference(file)
|
|
166
|
+
refInput.value = ''
|
|
167
|
+
})
|
|
168
|
+
for (const type of ['dragenter', 'dragover']) {
|
|
169
|
+
drop.addEventListener(type, event => { event.preventDefault(); drop.classList.add('is-over') })
|
|
170
|
+
}
|
|
171
|
+
for (const type of ['dragleave', 'drop']) {
|
|
172
|
+
drop.addEventListener(type, event => { event.preventDefault(); drop.classList.remove('is-over') })
|
|
173
|
+
}
|
|
174
|
+
drop.addEventListener('drop', event => {
|
|
175
|
+
const file = [...(event.dataTransfer?.files ?? [])].find(f => f.type.startsWith('image/'))
|
|
176
|
+
if (file) handleReference(file)
|
|
177
|
+
})
|
|
178
|
+
$('#ref-clear').addEventListener('click', event => {
|
|
179
|
+
event.stopPropagation()
|
|
180
|
+
reference = null
|
|
181
|
+
$('#drop-preview').hidden = true
|
|
182
|
+
$('#drop').querySelector('.drop-empty').hidden = false
|
|
183
|
+
})
|
|
184
|
+
|
|
185
|
+
async function handleReference(file) {
|
|
186
|
+
if (!file.type.startsWith('image/')) return app.toast(t('请选图片文件', 'Choose an image file'), 'error')
|
|
187
|
+
try {
|
|
188
|
+
const result = await upload(file)
|
|
189
|
+
reference = result
|
|
190
|
+
const img = $('#ref-img')
|
|
191
|
+
img.src = result.url
|
|
192
|
+
$('#drop-preview').hidden = false
|
|
193
|
+
$('#drop').querySelector('.drop-empty').hidden = true
|
|
194
|
+
app.toast(t('参考图已加入', 'Reference added'), 'success')
|
|
195
|
+
} catch (err) {
|
|
196
|
+
app.toast(err.message, 'error')
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
function paintChoices() {
|
|
201
|
+
$$('#styles [data-style]').forEach(btn => btn.classList.toggle('is-on', btn.dataset.style === style))
|
|
202
|
+
$$('#ratios [data-ratio]').forEach(btn => btn.classList.toggle('is-on', btn.dataset.ratio === ratio))
|
|
203
|
+
}
|
|
204
|
+
paintChoices()
|
|
205
|
+
|
|
206
|
+
function renderProvider() {
|
|
207
|
+
const line = $('#provider-line')
|
|
208
|
+
if (!line) return
|
|
209
|
+
if (app.settings?.imageReady) {
|
|
210
|
+
line.className = 'provider-line is-ready'
|
|
211
|
+
line.innerHTML = `<i class="dot"></i><span>${esc(app.settings.image.model || t('已连接', 'Ready'))}</span><em>${esc(app.settings.image.keyHint ? `•••• ${app.settings.image.keyHint}` : '')}</em>`
|
|
212
|
+
} else {
|
|
213
|
+
line.className = 'provider-line is-off'
|
|
214
|
+
line.innerHTML = `<span>${esc(t('生图服务还没配置', 'Image provider is not configured'))}</span><button type="button" class="link-btn" data-act="settings">${esc(t('去设置', 'Set up'))} ${icon('arrowRight', 13)}</button>`
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
function currentList() {
|
|
219
|
+
const images = app.state?.images ?? []
|
|
220
|
+
return images.filter(row => {
|
|
221
|
+
if (filter === 'fav' && !row.favorite) return false
|
|
222
|
+
if (!query) return true
|
|
223
|
+
return `${row.prompt} ${row.style} ${row.model}`.toLowerCase().includes(query)
|
|
224
|
+
})
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
function card(row) {
|
|
228
|
+
const ratioValue = row.width && row.height ? `${row.width} / ${row.height}` : (row.size?.includes('x') ? row.size.replace('x', ' / ') : '16 / 9')
|
|
229
|
+
if (row.status === 'queued' || row.status === 'running') {
|
|
230
|
+
return `<article class="shot is-busy" data-id="${row.id}">
|
|
231
|
+
<div class="shot-media shimmer" style="aspect-ratio:${ratioValue}"><span class="busy-label"><i></i>${esc(t('正在生成…', 'Generating…'))}</span></div>
|
|
232
|
+
<div class="shot-body">
|
|
233
|
+
<p class="shot-prompt">${esc(row.prompt)}</p>
|
|
234
|
+
<div class="shot-meta"><span class="tag">${esc(styleName(row.style) || t('无风格', 'No style'))}</span><span>${esc(row.model || '')}</span><time>${esc(fmtTime(row.createdAt))}</time></div>
|
|
235
|
+
</div>
|
|
236
|
+
</article>`
|
|
237
|
+
}
|
|
238
|
+
if (row.status === 'error') {
|
|
239
|
+
return `<article class="shot is-error" data-id="${row.id}">
|
|
240
|
+
<div class="shot-media error-media" style="aspect-ratio:${ratioValue}">
|
|
241
|
+
<span>${icon('alert', 20)}</span>
|
|
242
|
+
<p>${esc(row.error || t('生成失败', 'Failed'))}</p>
|
|
243
|
+
<button type="button" class="btn btn-small btn-ghost" data-act="retry" data-id="${row.id}">${esc(t('重试', 'Retry'))}</button>
|
|
244
|
+
</div>
|
|
245
|
+
<div class="shot-body">
|
|
246
|
+
<p class="shot-prompt">${esc(row.prompt)}</p>
|
|
247
|
+
<div class="shot-meta"><span class="tag tag-error">${esc(t('失败', 'Failed'))}</span><time>${esc(fmtTime(row.createdAt))}</time></div>
|
|
248
|
+
</div>
|
|
249
|
+
</article>`
|
|
250
|
+
}
|
|
251
|
+
return `<article class="shot" data-id="${row.id}">
|
|
252
|
+
<div class="shot-media" data-act="open" data-id="${row.id}" style="aspect-ratio:${ratioValue}" role="button" tabindex="0" aria-label="${esc(t('查看大图', 'Open image'))}">
|
|
253
|
+
<img src="${fileUrl(row.file)}" alt="${esc(row.prompt.slice(0, 80))}" loading="lazy">
|
|
254
|
+
<div class="shot-top">
|
|
255
|
+
<button type="button" class="mini-btn ${row.favorite ? 'is-on' : ''}" data-act="fav" data-id="${row.id}" title="${esc(t('收藏', 'Save'))}">${icon('heart', 14)}</button>
|
|
256
|
+
<button type="button" class="mini-btn" data-act="download" data-id="${row.id}" title="${esc(t('下载', 'Download'))}">${icon('download', 14)}</button>
|
|
257
|
+
</div>
|
|
258
|
+
</div>
|
|
259
|
+
<div class="shot-body">
|
|
260
|
+
<p class="shot-prompt">${esc(row.prompt)}</p>
|
|
261
|
+
<div class="shot-meta"><span class="tag">${esc(styleName(row.style) || t('无风格', 'No style'))}</span><span class="ellipsis">${esc(row.model || '')}</span><time>${esc(fmtTime(row.createdAt))}</time></div>
|
|
262
|
+
<div class="shot-actions">
|
|
263
|
+
<button type="button" data-act="reuse" data-id="${row.id}">${icon('refresh', 13)}${esc(t('复用', 'Reuse'))}</button>
|
|
264
|
+
<button type="button" data-act="copy" data-id="${row.id}">${icon('copy', 13)}${esc(t('复制', 'Copy'))}</button>
|
|
265
|
+
<button type="button" data-act="delete" data-id="${row.id}" class="danger-quiet">${icon('trash', 13)}</button>
|
|
266
|
+
</div>
|
|
267
|
+
</div>
|
|
268
|
+
</article>`
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
function renderGallery() {
|
|
272
|
+
const list = currentList()
|
|
273
|
+
const total = app.state?.images?.length ?? 0
|
|
274
|
+
const countEl = $('#gallery-count')
|
|
275
|
+
if (countEl) countEl.textContent = String(total)
|
|
276
|
+
if (!list.length) {
|
|
277
|
+
if (!total && !query && filter === 'all') {
|
|
278
|
+
galleryEl.innerHTML = `<div class="empty-gallery">
|
|
279
|
+
<div class="empty-art">${icon('image', 30)}</div>
|
|
280
|
+
<h3>${esc(t('第一张画,从一句话开始', 'The first frame starts with a line'))}</h3>
|
|
281
|
+
<p>${esc(t('随便挑一个开头,或者在上面的提示词框里写你脑子里的画面。', 'Pick a starting point, or describe the picture in your head.'))}</p>
|
|
282
|
+
<div class="inspirations">
|
|
283
|
+
${INSPIRATIONS.map((item, index) => `<button type="button" class="inspo" data-inspo="${index}">
|
|
284
|
+
<span class="inspo-index">0${index + 1}</span>
|
|
285
|
+
<b>${esc(inspoTitle(item))}</b>
|
|
286
|
+
<small>${esc(inspoPrompt(item))}</small>
|
|
287
|
+
</button>`).join('')}
|
|
288
|
+
</div>
|
|
289
|
+
</div>`
|
|
290
|
+
} else {
|
|
291
|
+
galleryEl.innerHTML = `<div class="empty-gallery compact"><p>${esc(t('没有找到符合条件的作品。', 'No images match this filter.'))}</p></div>`
|
|
292
|
+
}
|
|
293
|
+
return
|
|
294
|
+
}
|
|
295
|
+
galleryEl.innerHTML = list.map(card).join('')
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
galleryEl.addEventListener('click', async event => {
|
|
299
|
+
const btn = event.target.closest('[data-act]')
|
|
300
|
+
if (!btn) return
|
|
301
|
+
const id = btn.dataset.id
|
|
302
|
+
const row = (app.state?.images ?? []).find(item => item.id === id)
|
|
303
|
+
if (!row) return
|
|
304
|
+
const act = btn.dataset.act
|
|
305
|
+
if (act === 'open') return openLightbox(id)
|
|
306
|
+
if (act === 'fav') {
|
|
307
|
+
try { await api('image/action', { id, action: 'favorite' }); await app.refresh() } catch (err) { app.toast(err.message, 'error') }
|
|
308
|
+
return
|
|
309
|
+
}
|
|
310
|
+
if (act === 'download') {
|
|
311
|
+
downloadUrl(fileUrl(row.file), `modern-media-${row.id}${row.file?.match(/\.\w+$/)?.[0] ?? '.png'}`)
|
|
312
|
+
return
|
|
313
|
+
}
|
|
314
|
+
if (act === 'copy') {
|
|
315
|
+
const ok = await copyText(row.prompt)
|
|
316
|
+
app.toast(ok ? t('提示词已复制', 'Prompt copied') : t('复制失败', 'Copy failed'), ok ? 'success' : 'error')
|
|
317
|
+
return
|
|
318
|
+
}
|
|
319
|
+
if (act === 'reuse') {
|
|
320
|
+
promptEl.value = row.prompt
|
|
321
|
+
promptEl.dispatchEvent(new Event('input'))
|
|
322
|
+
if (row.style && STYLES.includes(row.style)) style = row.style
|
|
323
|
+
if (row.size?.includes('x')) {
|
|
324
|
+
const match = RATIOS.find(item => item.hint.replaceAll(' × ', 'x') === row.size)
|
|
325
|
+
if (match) ratio = match.id
|
|
326
|
+
}
|
|
327
|
+
paintChoices()
|
|
328
|
+
promptEl.focus()
|
|
329
|
+
app.toast(t('已填入提示词', 'Prompt loaded'), 'success')
|
|
330
|
+
return
|
|
331
|
+
}
|
|
332
|
+
if (act === 'retry') {
|
|
333
|
+
try { await api('image/action', { id, action: 'retry' }); await app.refresh() } catch (err) { app.toast(err.message, 'error') }
|
|
334
|
+
return
|
|
335
|
+
}
|
|
336
|
+
if (act === 'delete') {
|
|
337
|
+
const ok = await app.confirm({
|
|
338
|
+
title: t('删除这张图?', 'Delete this image?'),
|
|
339
|
+
message: t('只删除工作台里的记录,本机文件不会立刻清理。', 'This removes the record from the workbench.'),
|
|
340
|
+
confirmText: t('删除', 'Delete'),
|
|
341
|
+
danger: true,
|
|
342
|
+
})
|
|
343
|
+
if (!ok) return
|
|
344
|
+
try { await api('image/action', { id, action: 'delete' }); await app.refresh(); app.toast(t('已删除', 'Deleted'), 'success') } catch (err) { app.toast(err.message, 'error') }
|
|
345
|
+
}
|
|
346
|
+
})
|
|
347
|
+
|
|
348
|
+
galleryEl.addEventListener('keydown', event => {
|
|
349
|
+
if (event.key !== 'Enter' && event.key !== ' ') return
|
|
350
|
+
const media = event.target.closest('[data-act="open"]')
|
|
351
|
+
if (!media) return
|
|
352
|
+
event.preventDefault()
|
|
353
|
+
openLightbox(media.dataset.id)
|
|
354
|
+
})
|
|
355
|
+
|
|
356
|
+
galleryEl.addEventListener('click', event => {
|
|
357
|
+
const inspo = event.target.closest('[data-inspo]')
|
|
358
|
+
if (!inspo) return
|
|
359
|
+
const item = INSPIRATIONS[Number(inspo.dataset.inspo)]
|
|
360
|
+
promptEl.value = inspoPrompt(item)
|
|
361
|
+
promptEl.dispatchEvent(new Event('input'))
|
|
362
|
+
style = item.style
|
|
363
|
+
ratio = item.ratio
|
|
364
|
+
paintChoices()
|
|
365
|
+
promptEl.focus()
|
|
366
|
+
})
|
|
367
|
+
|
|
368
|
+
function openLightbox(id) {
|
|
369
|
+
const list = currentList()
|
|
370
|
+
let index = list.findIndex(row => row.id === id)
|
|
371
|
+
if (index < 0) return
|
|
372
|
+
const cardEl = document.createElement('div')
|
|
373
|
+
cardEl.className = 'lightbox'
|
|
374
|
+
const closeRef = { fn: () => {} }
|
|
375
|
+
const paint = () => {
|
|
376
|
+
const row = list[index]
|
|
377
|
+
cardEl.innerHTML = `
|
|
378
|
+
<button class="lb-close" data-lb="close" aria-label="关闭">${icon('x', 18)}</button>
|
|
379
|
+
<button class="lb-nav lb-prev" data-lb="prev" aria-label="上一张">${icon('chevronLeft', 22)}</button>
|
|
380
|
+
<button class="lb-nav lb-next" data-lb="next" aria-label="下一张">${icon('chevronRight', 22)}</button>
|
|
381
|
+
<figure class="lb-figure"><img src="${fileUrl(row.file)}" alt="${esc(row.prompt)}"></figure>
|
|
382
|
+
<aside class="lb-side">
|
|
383
|
+
<span class="eyebrow">${esc(t('作品详情', 'DETAILS'))}</span>
|
|
384
|
+
<h3>${esc(styleName(row.style) || t('未命名', 'Untitled'))}</h3>
|
|
385
|
+
<p class="lb-prompt">${esc(row.prompt)}</p>
|
|
386
|
+
<div class="lb-meta">
|
|
387
|
+
<div><span>${esc(t('模型', 'Model'))}</span><b>${esc(row.model || '—')}</b></div>
|
|
388
|
+
<div><span>${esc(t('尺寸', 'Size'))}</span><b>${row.width ? `${row.width} × ${row.height}` : esc(row.size || '—')}</b></div>
|
|
389
|
+
<div><span>${esc(t('时间', 'Time'))}</span><b>${esc(fmtTime(row.createdAt))}</b></div>
|
|
390
|
+
</div>
|
|
391
|
+
<div class="lb-actions">
|
|
392
|
+
<button class="btn btn-primary" data-lb="download">${icon('download', 15)}${esc(t('下载', 'Download'))}</button>
|
|
393
|
+
<button class="btn btn-ghost" data-lb="fav">${icon('heart', 15)}${row.favorite ? t('取消收藏', 'Unsave') : t('收藏', 'Save')}</button>
|
|
394
|
+
<button class="btn btn-ghost" data-lb="copy">${icon('copy', 15)}${esc(t('复制提示词', 'Copy prompt'))}</button>
|
|
395
|
+
<button class="btn btn-ghost" data-lb="reuse">${icon('refresh', 15)}${esc(t('用它再画', 'Use again'))}</button>
|
|
396
|
+
</div>
|
|
397
|
+
<p class="fine">${esc(`${index + 1} / ${list.length}`)}</p>
|
|
398
|
+
</aside>`
|
|
399
|
+
cardEl.querySelectorAll('[data-lb]').forEach(btn => {
|
|
400
|
+
btn.addEventListener('click', async () => {
|
|
401
|
+
const act = btn.dataset.lb
|
|
402
|
+
if (act === 'close') return closeRef.fn()
|
|
403
|
+
if (act === 'prev') { index = (index - 1 + list.length) % list.length; paint(); return }
|
|
404
|
+
if (act === 'next') { index = (index + 1) % list.length; paint(); return }
|
|
405
|
+
if (act === 'download') { downloadUrl(fileUrl(row.file), `modern-media-${row.id}.png`); return }
|
|
406
|
+
if (act === 'copy') { await copyText(row.prompt); app.toast(t('已复制', 'Copied'), 'success'); return }
|
|
407
|
+
if (act === 'reuse') {
|
|
408
|
+
promptEl.value = row.prompt
|
|
409
|
+
promptEl.dispatchEvent(new Event('input'))
|
|
410
|
+
if (row.style && STYLES.includes(row.style)) style = row.style
|
|
411
|
+
paintChoices()
|
|
412
|
+
closeRef.fn()
|
|
413
|
+
promptEl.focus()
|
|
414
|
+
return
|
|
415
|
+
}
|
|
416
|
+
if (act === 'fav') {
|
|
417
|
+
try {
|
|
418
|
+
await api('image/action', { id: row.id, action: 'favorite' })
|
|
419
|
+
await app.refresh()
|
|
420
|
+
const fresh = (app.state.images ?? []).find(item => item.id === row.id)
|
|
421
|
+
if (fresh) list[index] = fresh
|
|
422
|
+
paint()
|
|
423
|
+
} catch (err) { app.toast(err.message, 'error') }
|
|
424
|
+
}
|
|
425
|
+
})
|
|
426
|
+
})
|
|
427
|
+
}
|
|
428
|
+
paint()
|
|
429
|
+
const onKey = event => {
|
|
430
|
+
if (event.key === 'Escape') closeRef.fn()
|
|
431
|
+
if (event.key === 'ArrowLeft') { index = (index - 1 + list.length) % list.length; paint() }
|
|
432
|
+
if (event.key === 'ArrowRight') { index = (index + 1) % list.length; paint() }
|
|
433
|
+
}
|
|
434
|
+
document.addEventListener('keydown', onKey)
|
|
435
|
+
closeRef.fn = app.showLayer(cardEl, { className: 'layer-lightbox', onClose: () => document.removeEventListener('keydown', onKey) })
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
async function generate() {
|
|
439
|
+
if (busy) return
|
|
440
|
+
const prompt = promptEl.value.trim()
|
|
441
|
+
if (!prompt) {
|
|
442
|
+
promptEl.focus()
|
|
443
|
+
return app.toast(t('先写一句提示词', 'Write a prompt first'), 'error')
|
|
444
|
+
}
|
|
445
|
+
if (!app.settings?.imageReady) {
|
|
446
|
+
app.toast(t('先配置生图服务', 'Configure an image provider first'), 'error')
|
|
447
|
+
return app.navigate('settings')
|
|
448
|
+
}
|
|
449
|
+
busy = true
|
|
450
|
+
const btn = $('#generate')
|
|
451
|
+
btn.classList.add('is-busy')
|
|
452
|
+
btn.querySelector('.gen-label').textContent = t('正在提交…', 'Sending…')
|
|
453
|
+
try {
|
|
454
|
+
await api('image/generate', {
|
|
455
|
+
prompt,
|
|
456
|
+
style: style || '',
|
|
457
|
+
ratio,
|
|
458
|
+
count,
|
|
459
|
+
quality: $('#quality').value,
|
|
460
|
+
negative: $('#negative').value.trim(),
|
|
461
|
+
reference: reference?.name ?? '',
|
|
462
|
+
})
|
|
463
|
+
api('preferences', { imageStyle: style, imageRatio: ratio, imageCount: count }).catch(() => {})
|
|
464
|
+
app.toast(t(`已提交 ${count} 张,稍等片刻`, `Queued ${count} image(s)`), 'success')
|
|
465
|
+
await app.refresh()
|
|
466
|
+
} catch (err) {
|
|
467
|
+
app.toast(err.message, 'error')
|
|
468
|
+
} finally {
|
|
469
|
+
busy = false
|
|
470
|
+
btn.classList.remove('is-busy')
|
|
471
|
+
btn.querySelector('.gen-label').textContent = t('开始生成', 'Generate')
|
|
472
|
+
}
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
function update() {
|
|
476
|
+
renderProvider()
|
|
477
|
+
renderGallery()
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
update()
|
|
481
|
+
|
|
482
|
+
return {
|
|
483
|
+
update,
|
|
484
|
+
unmount() {
|
|
485
|
+
/* 事件都挂在将被替换的节点上 */
|
|
486
|
+
},
|
|
487
|
+
}
|
|
488
|
+
}
|