@exccoder/dsh-media-workbench 0.0.0-stage → 0.2.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 +60 -0
- package/LICENSE +21 -0
- package/README.en.md +108 -0
- package/README.md +138 -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 +138 -0
- package/package.json +79 -4
- package/src/graph.js +304 -0
- package/src/image.js +150 -0
- package/src/index.js +865 -0
- package/src/llm.js +60 -0
- package/src/settings.js +233 -0
- package/src/store.js +241 -0
- package/src/tools.js +354 -0
- package/src/util.js +217 -0
- package/src/video.js +245 -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 +126 -0
- package/web/lib/icons.js +65 -0
- package/web/lib/state.js +42 -0
- package/web/styles.css +1143 -0
- package/web/views/constellation-model.js +63 -0
- package/web/views/constellation.js +1110 -0
- package/web/views/image.js +652 -0
- package/web/views/settings.js +269 -0
- package/web/views/video.js +363 -0
- package/web/views/write.js +513 -0
|
@@ -0,0 +1,652 @@
|
|
|
1
|
+
import { api, fileUrl, upload } from '../lib/api.js'
|
|
2
|
+
import { $, $$, autoGrow, copyImage, 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 references = []
|
|
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
|
+
let imageMenu = null
|
|
22
|
+
let imageMenuOff = null
|
|
23
|
+
|
|
24
|
+
function closeImageMenu() {
|
|
25
|
+
if (imageMenu) imageMenu.remove()
|
|
26
|
+
imageMenu = null
|
|
27
|
+
imageMenuOff?.()
|
|
28
|
+
imageMenuOff = null
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function showImageMenu(event, row) {
|
|
32
|
+
if (!row?.file) return
|
|
33
|
+
closeImageMenu()
|
|
34
|
+
const menu = document.createElement('div')
|
|
35
|
+
menu.className = 'image-menu'
|
|
36
|
+
menu.setAttribute('role', 'menu')
|
|
37
|
+
menu.innerHTML = `
|
|
38
|
+
<button type="button" role="menuitem" data-image-menu="copy">${icon('copy', 14)}${esc(t('复制图片', 'Copy image'))}</button>
|
|
39
|
+
<button type="button" role="menuitem" data-image-menu="download">${icon('download', 14)}${esc(t('下载原图', 'Download original'))}</button>
|
|
40
|
+
<button type="button" role="menuitem" data-image-menu="reveal">${icon('folder', 14)}${esc(t('打开文件所在目录', 'Open containing folder'))}</button>
|
|
41
|
+
<span class="image-menu-sep"></span>
|
|
42
|
+
<button type="button" role="menuitem" data-image-menu="prompt">${icon('pen', 14)}${esc(t('复制提示词', 'Copy prompt'))}</button>
|
|
43
|
+
`
|
|
44
|
+
document.body.appendChild(menu)
|
|
45
|
+
const box = menu.getBoundingClientRect()
|
|
46
|
+
menu.style.left = `${Math.max(8, Math.min(event.clientX, innerWidth - box.width - 8))}px`
|
|
47
|
+
menu.style.top = `${Math.max(8, Math.min(event.clientY, innerHeight - box.height - 8))}px`
|
|
48
|
+
imageMenu = menu
|
|
49
|
+
|
|
50
|
+
const onOutside = next => {
|
|
51
|
+
if (!menu.contains(next.target)) closeImageMenu()
|
|
52
|
+
}
|
|
53
|
+
const onKey = next => { if (next.key === 'Escape') closeImageMenu() }
|
|
54
|
+
const onScroll = () => closeImageMenu()
|
|
55
|
+
window.addEventListener('pointerdown', onOutside, true)
|
|
56
|
+
window.addEventListener('keydown', onKey)
|
|
57
|
+
window.addEventListener('scroll', onScroll, true)
|
|
58
|
+
window.addEventListener('blur', onScroll)
|
|
59
|
+
imageMenuOff = () => {
|
|
60
|
+
window.removeEventListener('pointerdown', onOutside, true)
|
|
61
|
+
window.removeEventListener('keydown', onKey)
|
|
62
|
+
window.removeEventListener('scroll', onScroll, true)
|
|
63
|
+
window.removeEventListener('blur', onScroll)
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
menu.querySelectorAll('[data-image-menu]').forEach(button => {
|
|
67
|
+
button.addEventListener('click', async () => {
|
|
68
|
+
const action = button.dataset.imageMenu
|
|
69
|
+
closeImageMenu()
|
|
70
|
+
try {
|
|
71
|
+
if (action === 'copy') {
|
|
72
|
+
await copyImage(fileUrl(row.file))
|
|
73
|
+
app.toast(t('图片已复制到剪贴板', 'Image copied to clipboard'), 'success')
|
|
74
|
+
} else if (action === 'download') {
|
|
75
|
+
downloadUrl(fileUrl(row.file), `modern-media-${row.id}${row.file?.match(/\.\w+$/)?.[0] ?? '.png'}`)
|
|
76
|
+
} else if (action === 'reveal') {
|
|
77
|
+
await api('reveal', { file: row.file })
|
|
78
|
+
} else if (action === 'prompt') {
|
|
79
|
+
const ok = await copyText(row.prompt)
|
|
80
|
+
app.toast(ok ? t('提示词已复制', 'Prompt copied') : t('复制失败', 'Copy failed'), ok ? 'success' : 'error')
|
|
81
|
+
}
|
|
82
|
+
} catch (err) {
|
|
83
|
+
app.toast(err.message || t('复制失败', 'Copy failed'), 'error')
|
|
84
|
+
}
|
|
85
|
+
})
|
|
86
|
+
})
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
root.innerHTML = `
|
|
90
|
+
<section class="bench bench-image">
|
|
91
|
+
<aside class="composer" id="composer">
|
|
92
|
+
<div class="composer-head">
|
|
93
|
+
<div>
|
|
94
|
+
<span class="eyebrow">${esc(t('画面', 'FRAME'))}</span>
|
|
95
|
+
<h2>${esc(t('生图工作台', 'Image Studio'))}</h2>
|
|
96
|
+
</div>
|
|
97
|
+
<button class="icon-btn" data-act="settings" title="${esc(t('生图设置', 'Image settings'))}">${icon('settings', 16)}</button>
|
|
98
|
+
</div>
|
|
99
|
+
<div class="provider-line" id="provider-line"></div>
|
|
100
|
+
<label class="field field-prompt">
|
|
101
|
+
<span class="field-label">${esc(t('提示词', 'Prompt'))}<em id="prompt-count">0</em></span>
|
|
102
|
+
<textarea id="prompt" rows="5" maxlength="2000" placeholder="${esc(t('描述主体、构图、光线、材质,以及不想出现的东西…', 'Describe subject, composition, light, material — and what to avoid…'))}"></textarea>
|
|
103
|
+
</label>
|
|
104
|
+
<div class="field">
|
|
105
|
+
<span class="field-label">${esc(t('风格', 'Style'))}</span>
|
|
106
|
+
<div class="chips" id="styles">
|
|
107
|
+
<button type="button" class="chip" data-style="">${esc(t('无', 'None'))}</button>
|
|
108
|
+
${STYLES.map(name => `<button type="button" class="chip" data-style="${esc(name)}">${esc(styleName(name))}</button>`).join('')}
|
|
109
|
+
</div>
|
|
110
|
+
</div>
|
|
111
|
+
<div class="field">
|
|
112
|
+
<span class="field-label">${esc(t('画幅', 'Aspect'))}</span>
|
|
113
|
+
<div class="chips chips-ratio" id="ratios">
|
|
114
|
+
${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('')}
|
|
115
|
+
</div>
|
|
116
|
+
</div>
|
|
117
|
+
<div class="field-row">
|
|
118
|
+
<div class="field">
|
|
119
|
+
<span class="field-label">${esc(t('数量', 'Count'))}</span>
|
|
120
|
+
<div class="stepper" id="count">
|
|
121
|
+
<button type="button" data-step="-1" aria-label="减少">${icon('minus', 14)}</button>
|
|
122
|
+
<b id="count-value">${count}</b>
|
|
123
|
+
<button type="button" data-step="1" aria-label="增加">${icon('plus', 14)}</button>
|
|
124
|
+
</div>
|
|
125
|
+
</div>
|
|
126
|
+
<div class="field">
|
|
127
|
+
<span class="field-label">${esc(t('质量', 'Quality'))}</span>
|
|
128
|
+
<select id="quality" class="select">
|
|
129
|
+
<option value="">${esc(t('自动', 'Auto'))}</option>
|
|
130
|
+
<option value="high">high</option>
|
|
131
|
+
<option value="medium">medium</option>
|
|
132
|
+
<option value="low">low</option>
|
|
133
|
+
</select>
|
|
134
|
+
</div>
|
|
135
|
+
</div>
|
|
136
|
+
<details class="advanced" id="advanced">
|
|
137
|
+
<summary>${icon('layers', 14)}<span>${esc(t('参考图与高级参数', 'Reference & advanced'))}</span>${icon('chevron', 14)}</summary>
|
|
138
|
+
<div class="advanced-body">
|
|
139
|
+
<div class="drop ref-drop" id="drop" tabindex="0" role="button" aria-label="${esc(t('上传参考图', 'Upload reference'))}">
|
|
140
|
+
<input type="file" id="ref-input" accept="image/png,image/jpeg,image/webp" multiple hidden>
|
|
141
|
+
<div class="drop-empty" id="ref-empty">${icon('upload', 18)}<span>${esc(t('拖入、选择或 Ctrl+V 粘贴参考图', 'Drop, choose or paste (Ctrl+V) reference images'))}</span><small>${esc(t('最多 8 张,按顺序对应图一、图二', 'Up to 8 images, ordered as figure 1, 2…'))}</small></div>
|
|
142
|
+
<div class="ref-list" id="ref-list"></div>
|
|
143
|
+
</div>
|
|
144
|
+
<label class="field">
|
|
145
|
+
<span class="field-label">${esc(t('反向提示词', 'Negative prompt'))}</span>
|
|
146
|
+
<textarea id="negative" rows="2" maxlength="800" placeholder="${esc(t('不想要的内容,例如:文字、水印、多余的手指', 'Things to avoid, e.g. text, watermark, extra fingers'))}"></textarea>
|
|
147
|
+
</label>
|
|
148
|
+
</div>
|
|
149
|
+
</details>
|
|
150
|
+
<button class="btn btn-generate" id="generate">
|
|
151
|
+
<span class="gen-ic">${icon('spark', 17)}</span>
|
|
152
|
+
<span class="gen-label">${esc(t('开始生成', 'Generate'))}</span>
|
|
153
|
+
<kbd>${modKey()} ↵</kbd>
|
|
154
|
+
</button>
|
|
155
|
+
<p class="fine">${esc(t('图片自动存进本机作品库,不会上传到别处。', 'Images stay on this machine in your local library.'))}</p>
|
|
156
|
+
</aside>
|
|
157
|
+
|
|
158
|
+
<section class="gallery-pane">
|
|
159
|
+
<header class="pane-head">
|
|
160
|
+
<div class="pane-title">
|
|
161
|
+
<h2>${esc(t('创作历史', 'Library'))}</h2>
|
|
162
|
+
<span class="count-pill" id="gallery-count">0</span>
|
|
163
|
+
</div>
|
|
164
|
+
<div class="gallery-tools">
|
|
165
|
+
<div class="seg" id="filter">
|
|
166
|
+
<button type="button" data-f="all" class="is-on">${esc(t('全部', 'All'))}</button>
|
|
167
|
+
<button type="button" data-f="fav">${esc(t('收藏', 'Saved'))}</button>
|
|
168
|
+
</div>
|
|
169
|
+
<label class="search">${icon('search', 15)}<input id="search" type="search" placeholder="${esc(t('搜提示词、风格、模型', 'Search prompts, styles, models'))}"></label>
|
|
170
|
+
</div>
|
|
171
|
+
</header>
|
|
172
|
+
<div id="gallery" class="gallery"></div>
|
|
173
|
+
</section>
|
|
174
|
+
</section>
|
|
175
|
+
`
|
|
176
|
+
|
|
177
|
+
const promptEl = $('#prompt')
|
|
178
|
+
const galleryEl = $('#gallery')
|
|
179
|
+
|
|
180
|
+
// ---- 初始化 ----
|
|
181
|
+
promptEl.addEventListener('input', () => {
|
|
182
|
+
$('#prompt-count').textContent = String(promptEl.value.length)
|
|
183
|
+
autoGrow(promptEl, 260)
|
|
184
|
+
})
|
|
185
|
+
autoGrow(promptEl, 260)
|
|
186
|
+
|
|
187
|
+
$('#styles').addEventListener('click', event => {
|
|
188
|
+
const btn = event.target.closest('[data-style]')
|
|
189
|
+
if (!btn) return
|
|
190
|
+
style = btn.dataset.style
|
|
191
|
+
paintChoices()
|
|
192
|
+
})
|
|
193
|
+
$('#ratios').addEventListener('click', event => {
|
|
194
|
+
const btn = event.target.closest('[data-ratio]')
|
|
195
|
+
if (!btn) return
|
|
196
|
+
ratio = btn.dataset.ratio
|
|
197
|
+
paintChoices()
|
|
198
|
+
})
|
|
199
|
+
$('#count').addEventListener('click', event => {
|
|
200
|
+
const btn = event.target.closest('[data-step]')
|
|
201
|
+
if (!btn) return
|
|
202
|
+
count = Math.min(4, Math.max(1, count + Number(btn.dataset.step)))
|
|
203
|
+
$('#count-value').textContent = String(count)
|
|
204
|
+
})
|
|
205
|
+
$('#filter').addEventListener('click', event => {
|
|
206
|
+
const btn = event.target.closest('[data-f]')
|
|
207
|
+
if (!btn) return
|
|
208
|
+
filter = btn.dataset.f
|
|
209
|
+
$$('#filter button').forEach(b => b.classList.toggle('is-on', b === btn))
|
|
210
|
+
renderGallery()
|
|
211
|
+
})
|
|
212
|
+
$('#search').addEventListener('input', () => {
|
|
213
|
+
query = $('#search').value.trim().toLowerCase()
|
|
214
|
+
renderGallery()
|
|
215
|
+
})
|
|
216
|
+
$('#generate').addEventListener('click', generate)
|
|
217
|
+
promptEl.addEventListener('keydown', event => {
|
|
218
|
+
if ((event.metaKey || event.ctrlKey) && event.key === 'Enter') { event.preventDefault(); generate() }
|
|
219
|
+
})
|
|
220
|
+
root.addEventListener('click', event => {
|
|
221
|
+
if (event.target.closest('[data-act="settings"]')) app.navigate('settings')
|
|
222
|
+
})
|
|
223
|
+
|
|
224
|
+
// 参考图队列:最多 8 张,可粘贴、拖入、排序
|
|
225
|
+
const refInput = $('#ref-input')
|
|
226
|
+
const drop = $('#drop')
|
|
227
|
+
const refList = $('#ref-list')
|
|
228
|
+
const refEmpty = $('#ref-empty')
|
|
229
|
+
const pickRef = () => refInput.click()
|
|
230
|
+
|
|
231
|
+
function renderReferences() {
|
|
232
|
+
const label = index => app.lang === 'en' ? `Fig. ${index + 1}` : `图${'一二三四五六七八'[index] ?? index + 1}`
|
|
233
|
+
refEmpty.hidden = references.length > 0
|
|
234
|
+
refList.innerHTML = references.map((ref, index) => `
|
|
235
|
+
<figure class="ref-item" data-ref-index="${index}">
|
|
236
|
+
<img src="${esc(ref.url)}" alt="${esc(label(index))}">
|
|
237
|
+
<span class="ref-badge">${esc(label(index))}</span>
|
|
238
|
+
<span class="ref-tools">
|
|
239
|
+
<button type="button" data-ref-act="up" title="${esc(t('上移', 'Move up'))}" ${index === 0 ? 'disabled' : ''}>↑</button>
|
|
240
|
+
<button type="button" data-ref-act="down" title="${esc(t('下移', 'Move down'))}" ${index === references.length - 1 ? 'disabled' : ''}>↓</button>
|
|
241
|
+
<button type="button" data-ref-act="remove" title="${esc(t('移除', 'Remove'))}">${icon('x', 11)}</button>
|
|
242
|
+
</span>
|
|
243
|
+
</figure>`).join('')
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
async function addReferences(files) {
|
|
247
|
+
const images = [...files].filter(file => file?.type?.startsWith('image/'))
|
|
248
|
+
if (!images.length) return
|
|
249
|
+
const room = 8 - references.length
|
|
250
|
+
if (room <= 0) return app.toast(t('最多 8 张参考图', 'Up to 8 references'), 'error')
|
|
251
|
+
for (const file of images.slice(0, room)) {
|
|
252
|
+
try {
|
|
253
|
+
const result = await upload(file)
|
|
254
|
+
references.push(result)
|
|
255
|
+
renderReferences()
|
|
256
|
+
} catch (err) {
|
|
257
|
+
app.toast(err.message, 'error')
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
if (images.length > room) app.toast(t('最多 8 张参考图,多出的已忽略', 'Up to 8 references; extras ignored'), 'error')
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
renderReferences()
|
|
264
|
+
drop.addEventListener('click', event => {
|
|
265
|
+
if (event.target.closest('.ref-item')) return
|
|
266
|
+
pickRef()
|
|
267
|
+
})
|
|
268
|
+
drop.addEventListener('keydown', event => {
|
|
269
|
+
if (event.target.closest('.ref-item')) return
|
|
270
|
+
if (event.key === 'Enter' || event.key === ' ') { event.preventDefault(); pickRef() }
|
|
271
|
+
})
|
|
272
|
+
refInput.addEventListener('change', async () => {
|
|
273
|
+
await addReferences([...(refInput.files ?? [])])
|
|
274
|
+
refInput.value = ''
|
|
275
|
+
})
|
|
276
|
+
for (const type of ['dragenter', 'dragover']) {
|
|
277
|
+
drop.addEventListener(type, event => { event.preventDefault(); drop.classList.add('is-over') })
|
|
278
|
+
}
|
|
279
|
+
for (const type of ['dragleave', 'drop']) {
|
|
280
|
+
drop.addEventListener(type, event => { event.preventDefault(); drop.classList.remove('is-over') })
|
|
281
|
+
}
|
|
282
|
+
drop.addEventListener('drop', event => {
|
|
283
|
+
addReferences([...(event.dataTransfer?.files ?? [])])
|
|
284
|
+
})
|
|
285
|
+
refList.addEventListener('click', event => {
|
|
286
|
+
const button = event.target.closest('[data-ref-act]')
|
|
287
|
+
if (!button) return
|
|
288
|
+
event.stopPropagation()
|
|
289
|
+
const index = Number(button.closest('.ref-item')?.dataset.refIndex)
|
|
290
|
+
if (!Number.isInteger(index) || !references[index]) return
|
|
291
|
+
if (button.dataset.refAct === 'remove') references.splice(index, 1)
|
|
292
|
+
if (button.dataset.refAct === 'up' && index > 0) {
|
|
293
|
+
[references[index - 1], references[index]] = [references[index], references[index - 1]]
|
|
294
|
+
}
|
|
295
|
+
if (button.dataset.refAct === 'down' && index < references.length - 1) {
|
|
296
|
+
[references[index + 1], references[index]] = [references[index], references[index + 1]]
|
|
297
|
+
}
|
|
298
|
+
renderReferences()
|
|
299
|
+
})
|
|
300
|
+
|
|
301
|
+
function onPaste(event) {
|
|
302
|
+
if (!drop.isConnected) {
|
|
303
|
+
document.removeEventListener('paste', onPaste)
|
|
304
|
+
return
|
|
305
|
+
}
|
|
306
|
+
const files = [...(event.clipboardData?.items ?? [])]
|
|
307
|
+
.filter(item => item.type.startsWith('image/'))
|
|
308
|
+
.map(item => item.getAsFile())
|
|
309
|
+
.filter(Boolean)
|
|
310
|
+
if (!files.length) return
|
|
311
|
+
event.preventDefault()
|
|
312
|
+
addReferences(files)
|
|
313
|
+
}
|
|
314
|
+
document.addEventListener('paste', onPaste)
|
|
315
|
+
|
|
316
|
+
function paintChoices() {
|
|
317
|
+
$$('#styles [data-style]').forEach(btn => btn.classList.toggle('is-on', btn.dataset.style === style))
|
|
318
|
+
$$('#ratios [data-ratio]').forEach(btn => btn.classList.toggle('is-on', btn.dataset.ratio === ratio))
|
|
319
|
+
}
|
|
320
|
+
paintChoices()
|
|
321
|
+
|
|
322
|
+
function renderProvider() {
|
|
323
|
+
const line = $('#provider-line')
|
|
324
|
+
if (!line) return
|
|
325
|
+
if (app.settings?.imageReady) {
|
|
326
|
+
line.className = 'provider-line is-ready'
|
|
327
|
+
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>`
|
|
328
|
+
} else {
|
|
329
|
+
line.className = 'provider-line is-off'
|
|
330
|
+
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>`
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
function currentList() {
|
|
335
|
+
const images = app.state?.images ?? []
|
|
336
|
+
return images.filter(row => {
|
|
337
|
+
if (filter === 'fav' && !row.favorite) return false
|
|
338
|
+
if (!query) return true
|
|
339
|
+
return `${row.prompt} ${row.style} ${row.model}`.toLowerCase().includes(query)
|
|
340
|
+
})
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
function card(row) {
|
|
344
|
+
const ratioValue = row.width && row.height ? `${row.width} / ${row.height}` : (row.size?.includes('x') ? row.size.replace('x', ' / ') : '16 / 9')
|
|
345
|
+
if (row.status === 'queued' || row.status === 'running') {
|
|
346
|
+
return `<article class="shot is-busy" data-id="${row.id}">
|
|
347
|
+
<div class="shot-media shimmer" style="aspect-ratio:${ratioValue}"><span class="busy-label"><i></i>${esc(t('正在生成…', 'Generating…'))}</span></div>
|
|
348
|
+
<div class="shot-body">
|
|
349
|
+
<p class="shot-prompt">${esc(row.prompt)}</p>
|
|
350
|
+
<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>
|
|
351
|
+
</div>
|
|
352
|
+
</article>`
|
|
353
|
+
}
|
|
354
|
+
if (row.status === 'error') {
|
|
355
|
+
return `<article class="shot is-error" data-id="${row.id}">
|
|
356
|
+
<div class="shot-media error-media" style="aspect-ratio:${ratioValue}">
|
|
357
|
+
<span>${icon('alert', 20)}</span>
|
|
358
|
+
<p>${esc(row.error || t('生成失败', 'Failed'))}</p>
|
|
359
|
+
<button type="button" class="btn btn-small btn-ghost" data-act="retry" data-id="${row.id}">${esc(t('重试', 'Retry'))}</button>
|
|
360
|
+
</div>
|
|
361
|
+
<div class="shot-body">
|
|
362
|
+
<p class="shot-prompt">${esc(row.prompt)}</p>
|
|
363
|
+
<div class="shot-meta"><span class="tag tag-error">${esc(t('失败', 'Failed'))}</span><time>${esc(fmtTime(row.createdAt))}</time></div>
|
|
364
|
+
</div>
|
|
365
|
+
</article>`
|
|
366
|
+
}
|
|
367
|
+
return `<article class="shot" data-id="${row.id}">
|
|
368
|
+
<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'))}">
|
|
369
|
+
<img src="${fileUrl(row.file)}" alt="${esc(row.prompt.slice(0, 80))}" loading="lazy">
|
|
370
|
+
<div class="shot-top">
|
|
371
|
+
<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>
|
|
372
|
+
<button type="button" class="mini-btn" data-act="download" data-id="${row.id}" title="${esc(t('下载', 'Download'))}">${icon('download', 14)}</button>
|
|
373
|
+
</div>
|
|
374
|
+
</div>
|
|
375
|
+
<div class="shot-body">
|
|
376
|
+
<p class="shot-prompt">${esc(row.prompt)}</p>
|
|
377
|
+
<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>
|
|
378
|
+
<div class="shot-actions">
|
|
379
|
+
<button type="button" data-act="reuse" data-id="${row.id}">${icon('refresh', 13)}${esc(t('复用', 'Reuse'))}</button>
|
|
380
|
+
<button type="button" data-act="copy" data-id="${row.id}">${icon('copy', 13)}${esc(t('复制', 'Copy'))}</button>
|
|
381
|
+
<button type="button" data-act="delete" data-id="${row.id}" class="danger-quiet">${icon('trash', 13)}</button>
|
|
382
|
+
</div>
|
|
383
|
+
</div>
|
|
384
|
+
</article>`
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
function renderGallery() {
|
|
388
|
+
const list = currentList()
|
|
389
|
+
const total = app.state?.images?.length ?? 0
|
|
390
|
+
const countEl = $('#gallery-count')
|
|
391
|
+
if (countEl) countEl.textContent = String(total)
|
|
392
|
+
if (!list.length) {
|
|
393
|
+
if (!total && !query && filter === 'all') {
|
|
394
|
+
galleryEl.innerHTML = `<div class="empty-gallery">
|
|
395
|
+
<div class="empty-art">${icon('image', 30)}</div>
|
|
396
|
+
<h3>${esc(t('第一张画,从一句话开始', 'The first frame starts with a line'))}</h3>
|
|
397
|
+
<p>${esc(t('随便挑一个开头,或者在上面的提示词框里写你脑子里的画面。', 'Pick a starting point, or describe the picture in your head.'))}</p>
|
|
398
|
+
<div class="inspirations">
|
|
399
|
+
${INSPIRATIONS.map((item, index) => `<button type="button" class="inspo" data-inspo="${index}">
|
|
400
|
+
<span class="inspo-index">0${index + 1}</span>
|
|
401
|
+
<b>${esc(inspoTitle(item))}</b>
|
|
402
|
+
<small>${esc(inspoPrompt(item))}</small>
|
|
403
|
+
</button>`).join('')}
|
|
404
|
+
</div>
|
|
405
|
+
</div>`
|
|
406
|
+
} else {
|
|
407
|
+
galleryEl.innerHTML = `<div class="empty-gallery compact"><p>${esc(t('没有找到符合条件的作品。', 'No images match this filter.'))}</p></div>`
|
|
408
|
+
}
|
|
409
|
+
return
|
|
410
|
+
}
|
|
411
|
+
galleryEl.innerHTML = list.map(card).join('')
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
async function reuseImage(row) {
|
|
415
|
+
const original = (Array.isArray(row.references) && row.references.length ? row.references : row.reference ? [row.reference] : [])
|
|
416
|
+
.filter(name => typeof name === 'string' && name)
|
|
417
|
+
.slice(0, 8)
|
|
418
|
+
let usedReferences = false
|
|
419
|
+
if (original.length) {
|
|
420
|
+
const useReferences = await app.confirm({
|
|
421
|
+
title: t('这张图带了参考图', 'This image has references'),
|
|
422
|
+
message: t(
|
|
423
|
+
`这张图用了 ${original.length} 张参考图。要一起复用吗?选「覆盖参考图」会替换当前队列;选「只用提示词」会保留当前参考图。`,
|
|
424
|
+
`This image used ${original.length} reference image(s). Reuse them too? Choose "Use references" to replace the current queue, or "Prompt only" to keep what you have.`,
|
|
425
|
+
),
|
|
426
|
+
confirmText: t('覆盖参考图', 'Use references'),
|
|
427
|
+
cancelText: t('只用提示词', 'Prompt only'),
|
|
428
|
+
})
|
|
429
|
+
if (useReferences) {
|
|
430
|
+
references = original.map(name => ({ name, url: fileUrl(name) }))
|
|
431
|
+
renderReferences()
|
|
432
|
+
usedReferences = true
|
|
433
|
+
}
|
|
434
|
+
} else {
|
|
435
|
+
references = []
|
|
436
|
+
renderReferences()
|
|
437
|
+
}
|
|
438
|
+
promptEl.value = row.prompt
|
|
439
|
+
promptEl.dispatchEvent(new Event('input'))
|
|
440
|
+
if (row.style && STYLES.includes(row.style)) style = row.style
|
|
441
|
+
if (row.size?.includes('x')) {
|
|
442
|
+
const match = RATIOS.find(item => item.hint.replaceAll(' × ', 'x') === row.size)
|
|
443
|
+
if (match) ratio = match.id
|
|
444
|
+
}
|
|
445
|
+
paintChoices()
|
|
446
|
+
promptEl.focus()
|
|
447
|
+
app.toast(
|
|
448
|
+
usedReferences ? t('已复用提示词和参考图', 'Prompt and references loaded') : t('已复用提示词', 'Prompt loaded'),
|
|
449
|
+
'success',
|
|
450
|
+
)
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
galleryEl.addEventListener('click', async event => {
|
|
454
|
+
const btn = event.target.closest('[data-act]')
|
|
455
|
+
if (!btn) return
|
|
456
|
+
const id = btn.dataset.id
|
|
457
|
+
const row = (app.state?.images ?? []).find(item => item.id === id)
|
|
458
|
+
if (!row) return
|
|
459
|
+
const act = btn.dataset.act
|
|
460
|
+
if (act === 'open') return openLightbox(id)
|
|
461
|
+
if (act === 'fav') {
|
|
462
|
+
try { await api('image/action', { id, action: 'favorite' }); await app.refresh() } catch (err) { app.toast(err.message, 'error') }
|
|
463
|
+
return
|
|
464
|
+
}
|
|
465
|
+
if (act === 'download') {
|
|
466
|
+
downloadUrl(fileUrl(row.file), `modern-media-${row.id}${row.file?.match(/\.\w+$/)?.[0] ?? '.png'}`)
|
|
467
|
+
return
|
|
468
|
+
}
|
|
469
|
+
if (act === 'copy') {
|
|
470
|
+
const ok = await copyText(row.prompt)
|
|
471
|
+
app.toast(ok ? t('提示词已复制', 'Prompt copied') : t('复制失败', 'Copy failed'), ok ? 'success' : 'error')
|
|
472
|
+
return
|
|
473
|
+
}
|
|
474
|
+
if (act === 'reuse') {
|
|
475
|
+
await reuseImage(row)
|
|
476
|
+
return
|
|
477
|
+
}
|
|
478
|
+
if (act === 'retry') {
|
|
479
|
+
try { await api('image/action', { id, action: 'retry' }); await app.refresh() } catch (err) { app.toast(err.message, 'error') }
|
|
480
|
+
return
|
|
481
|
+
}
|
|
482
|
+
if (act === 'delete') {
|
|
483
|
+
const ok = await app.confirm({
|
|
484
|
+
title: t('删除这张图?', 'Delete this image?'),
|
|
485
|
+
message: t('只删除工作台里的记录,本机文件不会立刻清理。', 'This removes the record from the workbench.'),
|
|
486
|
+
confirmText: t('删除', 'Delete'),
|
|
487
|
+
danger: true,
|
|
488
|
+
})
|
|
489
|
+
if (!ok) return
|
|
490
|
+
try { await api('image/action', { id, action: 'delete' }); await app.refresh(); app.toast(t('已删除', 'Deleted'), 'success') } catch (err) { app.toast(err.message, 'error') }
|
|
491
|
+
}
|
|
492
|
+
})
|
|
493
|
+
|
|
494
|
+
galleryEl.addEventListener('keydown', event => {
|
|
495
|
+
if (event.key !== 'Enter' && event.key !== ' ') return
|
|
496
|
+
const media = event.target.closest('[data-act="open"]')
|
|
497
|
+
if (!media) return
|
|
498
|
+
event.preventDefault()
|
|
499
|
+
openLightbox(media.dataset.id)
|
|
500
|
+
})
|
|
501
|
+
|
|
502
|
+
galleryEl.addEventListener('contextmenu', event => {
|
|
503
|
+
const media = event.target.closest('.shot-media')
|
|
504
|
+
const image = event.target.closest('.shot-media img')
|
|
505
|
+
if (!media || !image) return
|
|
506
|
+
const row = (app.state?.images ?? []).find(item => item.id === media.closest('.shot')?.dataset.id)
|
|
507
|
+
if (!row) return
|
|
508
|
+
event.preventDefault()
|
|
509
|
+
showImageMenu(event, row)
|
|
510
|
+
})
|
|
511
|
+
|
|
512
|
+
galleryEl.addEventListener('click', event => {
|
|
513
|
+
const inspo = event.target.closest('[data-inspo]')
|
|
514
|
+
if (!inspo) return
|
|
515
|
+
const item = INSPIRATIONS[Number(inspo.dataset.inspo)]
|
|
516
|
+
promptEl.value = inspoPrompt(item)
|
|
517
|
+
promptEl.dispatchEvent(new Event('input'))
|
|
518
|
+
style = item.style
|
|
519
|
+
ratio = item.ratio
|
|
520
|
+
paintChoices()
|
|
521
|
+
promptEl.focus()
|
|
522
|
+
})
|
|
523
|
+
|
|
524
|
+
function openLightbox(id) {
|
|
525
|
+
const list = currentList()
|
|
526
|
+
let index = list.findIndex(row => row.id === id)
|
|
527
|
+
if (index < 0) return
|
|
528
|
+
const cardEl = document.createElement('div')
|
|
529
|
+
cardEl.className = 'lightbox'
|
|
530
|
+
const closeRef = { fn: () => {} }
|
|
531
|
+
const paint = () => {
|
|
532
|
+
const row = list[index]
|
|
533
|
+
cardEl.innerHTML = `
|
|
534
|
+
<button class="lb-close" data-lb="close" aria-label="关闭">${icon('x', 18)}</button>
|
|
535
|
+
<button class="lb-nav lb-prev" data-lb="prev" aria-label="上一张">${icon('chevronLeft', 22)}</button>
|
|
536
|
+
<button class="lb-nav lb-next" data-lb="next" aria-label="下一张">${icon('chevronRight', 22)}</button>
|
|
537
|
+
<figure class="lb-figure"><img src="${fileUrl(row.file)}" alt="${esc(row.prompt)}"></figure>
|
|
538
|
+
<aside class="lb-side">
|
|
539
|
+
<span class="eyebrow">${esc(t('作品详情', 'DETAILS'))}</span>
|
|
540
|
+
<h3>${esc(styleName(row.style) || t('未命名', 'Untitled'))}</h3>
|
|
541
|
+
<p class="lb-prompt">${esc(row.prompt)}</p>
|
|
542
|
+
<div class="lb-meta">
|
|
543
|
+
<div><span>${esc(t('模型', 'Model'))}</span><b>${esc(row.model || '—')}</b></div>
|
|
544
|
+
<div><span>${esc(t('尺寸', 'Size'))}</span><b>${row.width ? `${row.width} × ${row.height}` : esc(row.size || '—')}</b></div>
|
|
545
|
+
<div><span>${esc(t('时间', 'Time'))}</span><b>${esc(fmtTime(row.createdAt))}</b></div>
|
|
546
|
+
</div>
|
|
547
|
+
<div class="lb-actions">
|
|
548
|
+
<button class="btn btn-primary" data-lb="download">${icon('download', 15)}${esc(t('下载', 'Download'))}</button>
|
|
549
|
+
<button class="btn btn-ghost" data-lb="copy-image">${icon('copy', 15)}${esc(t('复制图片', 'Copy image'))}</button>
|
|
550
|
+
<button class="btn btn-ghost" data-lb="fav">${icon('heart', 15)}${row.favorite ? t('取消收藏', 'Unsave') : t('收藏', 'Save')}</button>
|
|
551
|
+
<button class="btn btn-ghost" data-lb="copy">${icon('copy', 15)}${esc(t('复制提示词', 'Copy prompt'))}</button>
|
|
552
|
+
<button class="btn btn-ghost" data-lb="reuse">${icon('refresh', 15)}${esc(t('用它再画', 'Use again'))}</button>
|
|
553
|
+
</div>
|
|
554
|
+
<p class="fine">${esc(`${index + 1} / ${list.length}`)}</p>
|
|
555
|
+
</aside>`
|
|
556
|
+
cardEl.querySelectorAll('[data-lb]').forEach(btn => {
|
|
557
|
+
btn.addEventListener('click', async () => {
|
|
558
|
+
const act = btn.dataset.lb
|
|
559
|
+
if (act === 'close') return closeRef.fn()
|
|
560
|
+
if (act === 'prev') { index = (index - 1 + list.length) % list.length; paint(); return }
|
|
561
|
+
if (act === 'next') { index = (index + 1) % list.length; paint(); return }
|
|
562
|
+
if (act === 'download') { downloadUrl(fileUrl(row.file), `modern-media-${row.id}.png`); return }
|
|
563
|
+
if (act === 'copy-image') {
|
|
564
|
+
try {
|
|
565
|
+
await copyImage(fileUrl(row.file))
|
|
566
|
+
app.toast(t('图片已复制到剪贴板', 'Image copied to clipboard'), 'success')
|
|
567
|
+
} catch (err) { app.toast(err.message || t('复制失败', 'Copy failed'), 'error') }
|
|
568
|
+
return
|
|
569
|
+
}
|
|
570
|
+
if (act === 'copy') { await copyText(row.prompt); app.toast(t('已复制', 'Copied'), 'success'); return }
|
|
571
|
+
if (act === 'reuse') {
|
|
572
|
+
await reuseImage(row)
|
|
573
|
+
closeRef.fn()
|
|
574
|
+
return
|
|
575
|
+
}
|
|
576
|
+
if (act === 'fav') {
|
|
577
|
+
try {
|
|
578
|
+
await api('image/action', { id: row.id, action: 'favorite' })
|
|
579
|
+
await app.refresh()
|
|
580
|
+
const fresh = (app.state.images ?? []).find(item => item.id === row.id)
|
|
581
|
+
if (fresh) list[index] = fresh
|
|
582
|
+
paint()
|
|
583
|
+
} catch (err) { app.toast(err.message, 'error') }
|
|
584
|
+
}
|
|
585
|
+
})
|
|
586
|
+
})
|
|
587
|
+
cardEl.querySelector('.lb-figure img')?.addEventListener('contextmenu', event => {
|
|
588
|
+
event.preventDefault()
|
|
589
|
+
showImageMenu(event, list[index])
|
|
590
|
+
})
|
|
591
|
+
}
|
|
592
|
+
paint()
|
|
593
|
+
const onKey = event => {
|
|
594
|
+
if (event.key === 'Escape') closeRef.fn()
|
|
595
|
+
if (event.key === 'ArrowLeft') { index = (index - 1 + list.length) % list.length; paint() }
|
|
596
|
+
if (event.key === 'ArrowRight') { index = (index + 1) % list.length; paint() }
|
|
597
|
+
}
|
|
598
|
+
document.addEventListener('keydown', onKey)
|
|
599
|
+
closeRef.fn = app.showLayer(cardEl, { className: 'layer-lightbox', onClose: () => document.removeEventListener('keydown', onKey) })
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
async function generate() {
|
|
603
|
+
if (busy) return
|
|
604
|
+
const prompt = promptEl.value.trim()
|
|
605
|
+
if (!prompt) {
|
|
606
|
+
promptEl.focus()
|
|
607
|
+
return app.toast(t('先写一句提示词', 'Write a prompt first'), 'error')
|
|
608
|
+
}
|
|
609
|
+
if (!app.settings?.imageReady) {
|
|
610
|
+
app.toast(t('先配置生图服务', 'Configure an image provider first'), 'error')
|
|
611
|
+
return app.navigate('settings')
|
|
612
|
+
}
|
|
613
|
+
busy = true
|
|
614
|
+
const btn = $('#generate')
|
|
615
|
+
btn.classList.add('is-busy')
|
|
616
|
+
btn.querySelector('.gen-label').textContent = t('正在提交…', 'Sending…')
|
|
617
|
+
try {
|
|
618
|
+
await api('image/generate', {
|
|
619
|
+
prompt,
|
|
620
|
+
style: style || '',
|
|
621
|
+
ratio,
|
|
622
|
+
count,
|
|
623
|
+
quality: $('#quality').value,
|
|
624
|
+
negative: $('#negative').value.trim(),
|
|
625
|
+
references: references.map(item => item.name),
|
|
626
|
+
})
|
|
627
|
+
api('preferences', { imageStyle: style, imageRatio: ratio, imageCount: count }).catch(() => {})
|
|
628
|
+
app.toast(t(`已提交 ${count} 张,稍等片刻`, `Queued ${count} image(s)`), 'success')
|
|
629
|
+
await app.refresh()
|
|
630
|
+
} catch (err) {
|
|
631
|
+
app.toast(err.message, 'error')
|
|
632
|
+
} finally {
|
|
633
|
+
busy = false
|
|
634
|
+
btn.classList.remove('is-busy')
|
|
635
|
+
btn.querySelector('.gen-label').textContent = t('开始生成', 'Generate')
|
|
636
|
+
}
|
|
637
|
+
}
|
|
638
|
+
|
|
639
|
+
function update() {
|
|
640
|
+
renderProvider()
|
|
641
|
+
renderGallery()
|
|
642
|
+
}
|
|
643
|
+
|
|
644
|
+
update()
|
|
645
|
+
|
|
646
|
+
return {
|
|
647
|
+
update,
|
|
648
|
+
unmount() {
|
|
649
|
+
/* 事件都挂在将被替换的节点上 */
|
|
650
|
+
},
|
|
651
|
+
}
|
|
652
|
+
}
|