@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,244 @@
|
|
|
1
|
+
import { api } from '../lib/api.js'
|
|
2
|
+
import { $, esc } from '../lib/dom.js'
|
|
3
|
+
import { icon } from '../lib/icons.js'
|
|
4
|
+
import { app, t } from '../lib/state.js'
|
|
5
|
+
|
|
6
|
+
export function mount(root) {
|
|
7
|
+
const settings = app.settings ?? {}
|
|
8
|
+
const image = settings.image ?? {}
|
|
9
|
+
const video = settings.video ?? {}
|
|
10
|
+
let testImage = ''
|
|
11
|
+
let busy = false
|
|
12
|
+
|
|
13
|
+
root.innerHTML = `
|
|
14
|
+
<section class="bench bench-settings">
|
|
15
|
+
<div class="settings-scroll">
|
|
16
|
+
<header class="settings-hero">
|
|
17
|
+
<span class="eyebrow">${esc(t('工作台设置', 'WORKBENCH SETTINGS'))}</span>
|
|
18
|
+
<h2>${esc(t('把接口接上,其余都在本机', 'Connect providers. Everything else stays local.'))}</h2>
|
|
19
|
+
<p>${esc(t('写作走 DSH 当前模型,不需要在这里填密钥;生图和视频各自填写自己的接口。密钥只保存在本机 settings.json(权限 600),不会回传给浏览器。', 'Writing uses the current DSH model. Image and video providers each have their own endpoint. Keys stay in a local settings.json (mode 600) and are never sent back to the browser.'))}</p>
|
|
20
|
+
</header>
|
|
21
|
+
|
|
22
|
+
<section class="settings-card" id="image-card">
|
|
23
|
+
<header class="settings-head">
|
|
24
|
+
<div class="settings-title"><span class="settings-ic" style="--c:#8a6fd1">${icon('image', 18)}</span><div><h3>${esc(t('生图服务', 'Image provider'))}</h3><p>${esc(t('兼容 OpenAI Images API:POST /images/generations,参考图走 /images/edits。', 'OpenAI Images compatible: POST /images/generations, edits at /images/edits.'))}</p></div></div>
|
|
25
|
+
<span class="ready-pill ${settings.imageReady ? 'is-ok' : 'is-off'}" id="image-ready"><i></i>${settings.imageReady ? t('已就绪', 'Ready') : t('未配置', 'Not set')}</span>
|
|
26
|
+
</header>
|
|
27
|
+
<div class="settings-grid">
|
|
28
|
+
<label class="field"><span class="field-label">${esc(t('服务预设', 'Preset'))}</span>
|
|
29
|
+
<select class="select" id="image-preset">${(app.imagePresets ?? []).map(p => `<option value="${p.id}" ${p.id === image.preset ? 'selected' : ''}>${esc(p.name)}</option>`).join('')}</select>
|
|
30
|
+
</label>
|
|
31
|
+
<label class="field"><span class="field-label">${esc(t('接口地址', 'Base URL'))}</span>
|
|
32
|
+
<input class="input" id="image-base" value="${esc(image.baseUrl ?? '')}" placeholder="https://api.openai.com/v1" spellcheck="false">
|
|
33
|
+
</label>
|
|
34
|
+
<label class="field"><span class="field-label">${esc(t('模型', 'Model'))}</span>
|
|
35
|
+
<input class="input" id="image-model" value="${esc(image.model ?? '')}" placeholder="gpt-image-1" spellcheck="false">
|
|
36
|
+
</label>
|
|
37
|
+
<label class="field"><span class="field-label">${esc(t('密钥', 'API key'))}</span>
|
|
38
|
+
<input class="input" id="image-key" type="password" autocomplete="off" placeholder="${image.hasKey ? esc(t('已保存(尾号 ', 'Saved (ends ') + esc(image.keyHint ?? '') + ')') : 'sk-…'}">
|
|
39
|
+
</label>
|
|
40
|
+
<label class="field"><span class="field-label">${esc(t('默认尺寸', 'Default size'))}</span>
|
|
41
|
+
<input class="input" id="image-size" value="${esc(image.size ?? '')}" placeholder="1024x1024" spellcheck="false">
|
|
42
|
+
</label>
|
|
43
|
+
<label class="field"><span class="field-label">${esc(t('质量', 'Quality'))}</span>
|
|
44
|
+
<input class="input" id="image-quality" value="${esc(image.quality ?? '')}" placeholder="auto / high" spellcheck="false">
|
|
45
|
+
</label>
|
|
46
|
+
<label class="field field-wide"><span class="field-label">${esc(t('附加参数 JSON', 'Extra JSON'))}</span>
|
|
47
|
+
<textarea id="image-extra" rows="2" spellcheck="false" placeholder='{"response_format":"b64_json"}'>${esc(image.extra ?? '')}</textarea>
|
|
48
|
+
</label>
|
|
49
|
+
</div>
|
|
50
|
+
<footer class="settings-actions">
|
|
51
|
+
<span class="fine">${image.hasKey ? esc(t('密钥已存在,留空表示不修改。', 'A key exists; leave blank to keep it.')) : esc(t('还没有密钥。', 'No key yet.'))}</span>
|
|
52
|
+
<span class="spacer"></span>
|
|
53
|
+
${image.hasKey ? `<button class="text-btn danger-quiet" id="image-clear-key">${esc(t('清除密钥', 'Clear key'))}</button>` : ''}
|
|
54
|
+
<button class="btn btn-small btn-ghost" id="image-test">${esc(t('试画一张', 'Test image'))}</button>
|
|
55
|
+
<button class="btn btn-small btn-primary" id="image-save">${esc(t('保存', 'Save'))}</button>
|
|
56
|
+
</footer>
|
|
57
|
+
<div class="test-result" id="test-result" hidden><img id="test-img" alt=""><span>${esc(t('接口通了。', 'The provider works.'))}</span></div>
|
|
58
|
+
</section>
|
|
59
|
+
|
|
60
|
+
<section class="settings-card" id="video-card">
|
|
61
|
+
<header class="settings-head">
|
|
62
|
+
<div class="settings-title"><span class="settings-ic" style="--c:#3f8f92">${icon('film', 18)}</span><div><h3>${esc(t('视频服务', 'Video provider'))}</h3><p>${esc(t('异步任务接口:POST 创建任务,轮询状态,完成后下载。', 'Async task API: POST to create, poll status, download when done.'))}</p></div></div>
|
|
63
|
+
<span class="ready-pill ${settings.videoReady ? 'is-ok' : 'is-off'}" id="video-ready"><i></i>${settings.videoReady ? t('已就绪', 'Ready') : t('未配置', 'Not set')}</span>
|
|
64
|
+
</header>
|
|
65
|
+
<div class="settings-grid">
|
|
66
|
+
<label class="field"><span class="field-label">${esc(t('服务预设', 'Preset'))}</span>
|
|
67
|
+
<select class="select" id="video-preset">${(app.videoPresets ?? []).map(p => `<option value="${p.id}" ${p.id === video.preset ? 'selected' : ''}>${esc(p.name)}</option>`).join('')}</select>
|
|
68
|
+
</label>
|
|
69
|
+
<label class="field"><span class="field-label">${esc(t('接口地址', 'Base URL'))}</span>
|
|
70
|
+
<input class="input" id="video-base" value="${esc(video.baseUrl ?? '')}" placeholder="https://api.openai.com/v1" spellcheck="false">
|
|
71
|
+
</label>
|
|
72
|
+
<label class="field"><span class="field-label">${esc(t('模型', 'Model'))}</span>
|
|
73
|
+
<input class="input" id="video-model" value="${esc(video.model ?? '')}" placeholder="sora-2" spellcheck="false">
|
|
74
|
+
</label>
|
|
75
|
+
<label class="field"><span class="field-label">${esc(t('密钥', 'API key'))}</span>
|
|
76
|
+
<input class="input" id="video-key" type="password" autocomplete="off" placeholder="${video.hasKey ? esc(t('已保存(尾号 ', 'Saved (ends ') + esc(video.keyHint ?? '') + ')') : 'sk-…'}">
|
|
77
|
+
</label>
|
|
78
|
+
<label class="field"><span class="field-label">${esc(t('创建路径', 'Create path'))}</span><input class="input" id="video-create" value="${esc(video.createPath ?? '/videos')}" spellcheck="false"></label>
|
|
79
|
+
<label class="field"><span class="field-label">${esc(t('状态路径', 'Status path'))}</span><input class="input" id="video-status" value="${esc(video.statusPath ?? '/videos/{id}')}" spellcheck="false"></label>
|
|
80
|
+
<label class="field"><span class="field-label">${esc(t('内容路径', 'Content path'))}</span><input class="input" id="video-content" value="${esc(video.contentPath ?? '/videos/{id}/content')}" spellcheck="false"></label>
|
|
81
|
+
<label class="field"><span class="field-label">${esc(t('首帧字段名', 'Image field'))}</span><input class="input" id="video-field" value="${esc(video.imageField ?? 'image')}" spellcheck="false"></label>
|
|
82
|
+
<label class="field field-wide"><span class="field-label">${esc(t('附加参数 JSON', 'Extra JSON'))}</span><textarea id="video-extra" rows="2" spellcheck="false" placeholder='{"resolution":"1080p"}'>${esc(video.extra ?? '')}</textarea></label>
|
|
83
|
+
</div>
|
|
84
|
+
<footer class="settings-actions">
|
|
85
|
+
<span class="fine">${esc(t('没有视频服务时,视频工作台仍可用「分镜短片」模式。', 'Without a video provider, storyboard mode still works.'))}</span>
|
|
86
|
+
<span class="spacer"></span>
|
|
87
|
+
${video.hasKey ? `<button class="text-btn danger-quiet" id="video-clear-key">${esc(t('清除密钥', 'Clear key'))}</button>` : ''}
|
|
88
|
+
<button class="btn btn-small btn-primary" id="video-save">${esc(t('保存', 'Save'))}</button>
|
|
89
|
+
</footer>
|
|
90
|
+
</section>
|
|
91
|
+
|
|
92
|
+
<section class="settings-card">
|
|
93
|
+
<header class="settings-head">
|
|
94
|
+
<div class="settings-title"><span class="settings-ic" style="--c:#b4833e">${icon('pen', 18)}</span><div><h3>${esc(t('写作模型', 'Writing model'))}</h3><p>${esc(t('使用 DSH 当前默认模型;在 DSH 的模型选择里切换。', 'Uses the current DSH default model; switch it in DSH.'))}</p></div></div>
|
|
95
|
+
<span class="ready-pill ${app.model ? 'is-ok' : 'is-off'}"><i></i>${app.model ? esc(app.model.label) : t('未连接', 'Not connected')}</span>
|
|
96
|
+
</header>
|
|
97
|
+
</section>
|
|
98
|
+
|
|
99
|
+
<section class="settings-card">
|
|
100
|
+
<header class="settings-head">
|
|
101
|
+
<div class="settings-title"><span class="settings-ic" style="--c:#6b7280">${icon('folder', 18)}</span><div><h3>${esc(t('本机数据', 'Local data'))}</h3><p>${esc(t('作品、图片、视频和星图都在这个目录,不上传云端。', 'Works, images, videos and star maps live here. Nothing is uploaded.'))}</p></div></div>
|
|
102
|
+
</header>
|
|
103
|
+
<div class="data-path"><code id="data-path">${esc(app.dataDir)}</code><button class="text-btn" id="copy-path">${icon('copy', 13)}${esc(t('复制路径', 'Copy path'))}</button></div>
|
|
104
|
+
<div class="settings-actions wrap">
|
|
105
|
+
<button class="btn btn-small btn-ghost" id="export-all">${icon('download', 14)}${esc(t('导出全部数据', 'Export all data'))}</button>
|
|
106
|
+
<button class="btn btn-small btn-ghost" id="clear-images">${esc(t('清空图片记录', 'Clear images'))}</button>
|
|
107
|
+
<button class="btn btn-small btn-ghost" id="clear-videos">${esc(t('清空视频记录', 'Clear videos'))}</button>
|
|
108
|
+
<button class="btn btn-small btn-ghost danger-quiet" id="clear-all">${esc(t('清空全部记录', 'Clear everything'))}</button>
|
|
109
|
+
</div>
|
|
110
|
+
</section>
|
|
111
|
+
|
|
112
|
+
<section class="settings-card about-card">
|
|
113
|
+
<div class="about-mark">${icon('spark', 22)}</div>
|
|
114
|
+
<div>
|
|
115
|
+
<h3>${esc(t('摩登-媒体工作室', 'Modern-MediaStudio'))} <span class="version">v${esc(app.version)}</span></h3>
|
|
116
|
+
<p>${esc(t('一个 DSH 插件:生图、写作、星图、视频四个工作台,作品留在本机。按 ', 'A DSH plugin with four benches: image, writing, star map and video. Press '))}<kbd>${navigator.platform.includes('Mac') ? '⌘' : 'Ctrl'}</kbd> + <kbd>,</kbd> ${esc(t('打开设置。', 'to open settings.'))}</p>
|
|
117
|
+
</div>
|
|
118
|
+
</section>
|
|
119
|
+
</div>
|
|
120
|
+
</section>
|
|
121
|
+
`
|
|
122
|
+
|
|
123
|
+
const field = id => $('#' + id)
|
|
124
|
+
|
|
125
|
+
// 预设联动
|
|
126
|
+
field('image-preset').addEventListener('change', () => {
|
|
127
|
+
const preset = (app.imagePresets ?? []).find(p => p.id === field('image-preset').value)
|
|
128
|
+
if (preset?.baseUrl) field('image-base').value = preset.baseUrl
|
|
129
|
+
if (preset?.model) field('image-model').value = preset.model
|
|
130
|
+
})
|
|
131
|
+
field('video-preset').addEventListener('change', () => {
|
|
132
|
+
const preset = (app.videoPresets ?? []).find(p => p.id === field('video-preset').value)
|
|
133
|
+
if (preset?.baseUrl) field('video-base').value = preset.baseUrl
|
|
134
|
+
if (preset?.model) field('video-model').value = preset.model
|
|
135
|
+
})
|
|
136
|
+
|
|
137
|
+
async function saveImage() {
|
|
138
|
+
if (busy) return
|
|
139
|
+
busy = true
|
|
140
|
+
try {
|
|
141
|
+
const data = await api('settings', {
|
|
142
|
+
image: {
|
|
143
|
+
preset: field('image-preset').value,
|
|
144
|
+
baseUrl: field('image-base').value,
|
|
145
|
+
model: field('image-model').value,
|
|
146
|
+
size: field('image-size').value,
|
|
147
|
+
quality: field('image-quality').value,
|
|
148
|
+
extra: field('image-extra').value,
|
|
149
|
+
apiKey: field('image-key').value,
|
|
150
|
+
},
|
|
151
|
+
})
|
|
152
|
+
app.settings = data.settings
|
|
153
|
+
field('image-key').value = ''
|
|
154
|
+
app.toast(t('生图设置已保存', 'Image settings saved'), 'success')
|
|
155
|
+
await app.refresh()
|
|
156
|
+
} catch (err) { app.toast(err.message, 'error') }
|
|
157
|
+
finally { busy = false }
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
async function saveVideo() {
|
|
161
|
+
if (busy) return
|
|
162
|
+
busy = true
|
|
163
|
+
try {
|
|
164
|
+
const data = await api('settings', {
|
|
165
|
+
video: {
|
|
166
|
+
preset: field('video-preset').value,
|
|
167
|
+
baseUrl: field('video-base').value,
|
|
168
|
+
model: field('video-model').value,
|
|
169
|
+
createPath: field('video-create').value,
|
|
170
|
+
statusPath: field('video-status').value,
|
|
171
|
+
contentPath: field('video-content').value,
|
|
172
|
+
imageField: field('video-field').value,
|
|
173
|
+
extra: field('video-extra').value,
|
|
174
|
+
apiKey: field('video-key').value,
|
|
175
|
+
},
|
|
176
|
+
})
|
|
177
|
+
app.settings = data.settings
|
|
178
|
+
field('video-key').value = ''
|
|
179
|
+
app.toast(t('视频设置已保存', 'Video settings saved'), 'success')
|
|
180
|
+
await app.refresh()
|
|
181
|
+
} catch (err) { app.toast(err.message, 'error') }
|
|
182
|
+
finally { busy = false }
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
field('image-save').addEventListener('click', saveImage)
|
|
186
|
+
field('video-save').addEventListener('click', saveVideo)
|
|
187
|
+
|
|
188
|
+
field('image-clear-key')?.addEventListener('click', async () => {
|
|
189
|
+
try {
|
|
190
|
+
const data = await api('settings', { image: { clearKey: true } })
|
|
191
|
+
app.settings = data.settings
|
|
192
|
+
app.toast(t('已清除生图密钥', 'Image key cleared'), 'success')
|
|
193
|
+
await app.refresh()
|
|
194
|
+
} catch (err) { app.toast(err.message, 'error') }
|
|
195
|
+
})
|
|
196
|
+
field('video-clear-key')?.addEventListener('click', async () => {
|
|
197
|
+
try {
|
|
198
|
+
const data = await api('settings', { video: { clearKey: true } })
|
|
199
|
+
app.settings = data.settings
|
|
200
|
+
app.toast(t('已清除视频密钥', 'Video key cleared'), 'success')
|
|
201
|
+
await app.refresh()
|
|
202
|
+
} catch (err) { app.toast(err.message, 'error') }
|
|
203
|
+
})
|
|
204
|
+
|
|
205
|
+
field('image-test').addEventListener('click', async () => {
|
|
206
|
+
if (busy) return
|
|
207
|
+
if (!field('image-key').value && !app.settings?.image?.hasKey) return app.toast(t('先填密钥并保存', 'Add a key and save first'), 'error')
|
|
208
|
+
busy = true
|
|
209
|
+
const btn = field('image-test')
|
|
210
|
+
btn.textContent = t('正在画…', 'Drawing…')
|
|
211
|
+
try {
|
|
212
|
+
const data = await api('settings/test-image', {})
|
|
213
|
+
testImage = data.url
|
|
214
|
+
field('test-result').hidden = false
|
|
215
|
+
field('test-img').src = testImage
|
|
216
|
+
app.toast(t('接口通了', 'Provider works'), 'success')
|
|
217
|
+
} catch (err) { app.toast(err.message, 'error') }
|
|
218
|
+
finally { busy = false; btn.textContent = t('试画一张', 'Test image') }
|
|
219
|
+
})
|
|
220
|
+
|
|
221
|
+
field('copy-path').addEventListener('click', async () => {
|
|
222
|
+
try { await navigator.clipboard.writeText(app.dataDir); app.toast(t('路径已复制', 'Path copied'), 'success') } catch { app.toast(t('复制失败', 'Copy failed'), 'error') }
|
|
223
|
+
})
|
|
224
|
+
|
|
225
|
+
field('export-all').addEventListener('click', () => { location.href = '/media-workbench-modern/api/export' })
|
|
226
|
+
|
|
227
|
+
async function clearScope(scope) {
|
|
228
|
+
const label = { images: t('图片记录', 'image records'), videos: t('视频记录', 'video records'), all: t('全部记录', 'all records') }[scope]
|
|
229
|
+
const ok = await app.confirm({ title: t('清空', 'Clear ') + label + '?', message: t('文件会留在本机目录,但工作台里不再显示。', 'Files stay on disk; the workbench stops listing them.'), confirmText: t('清空', 'Clear'), danger: true })
|
|
230
|
+
if (!ok) return
|
|
231
|
+
try {
|
|
232
|
+
await api('clear', { scope })
|
|
233
|
+
await app.refresh()
|
|
234
|
+
app.toast(t('已清空', 'Cleared'), 'success')
|
|
235
|
+
} catch (err) { app.toast(err.message, 'error') }
|
|
236
|
+
}
|
|
237
|
+
field('clear-images').addEventListener('click', () => clearScope('images'))
|
|
238
|
+
field('clear-videos').addEventListener('click', () => clearScope('videos'))
|
|
239
|
+
field('clear-all').addEventListener('click', () => clearScope('all'))
|
|
240
|
+
|
|
241
|
+
return {
|
|
242
|
+
update() { /* 设置页不跟随轮询刷新,避免覆盖正在编辑的值 */ },
|
|
243
|
+
}
|
|
244
|
+
}
|
|
@@ -0,0 +1,363 @@
|
|
|
1
|
+
import { api, fileUrl, upload } from '../lib/api.js'
|
|
2
|
+
import { $, $$, autoGrow, 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 { RATIOS } from '../lib/constants.js'
|
|
6
|
+
|
|
7
|
+
const MODES = [
|
|
8
|
+
{ id: 'text', label: ['文生视频', 'Text to video'], hint: ['只给描述,让画面动起来', 'Describe it, let it move'] },
|
|
9
|
+
{ id: 'image', label: ['图生视频', 'Image to video'], hint: ['用一张首帧,接住构图和光线', 'Start from a first frame'] },
|
|
10
|
+
{ id: 'storyboard', label: ['分镜短片', 'Storyboard'], hint: ['用 4 张关键帧拼出动态预览', 'Four keyframes, one moving preview'] },
|
|
11
|
+
]
|
|
12
|
+
|
|
13
|
+
export function mount(root) {
|
|
14
|
+
const prefs = app.state?.preferences ?? {}
|
|
15
|
+
let mode = MODES.some(item => item.id === prefs.videoMode) ? prefs.videoMode : 'text'
|
|
16
|
+
let ratio = RATIOS.some(r => r.id === prefs.videoRatio) ? prefs.videoRatio : '16:9'
|
|
17
|
+
let duration = [5, 8, 10, 15].includes(Number(prefs.videoDuration)) ? Number(prefs.videoDuration) : 5
|
|
18
|
+
let reference = null
|
|
19
|
+
let busy = false
|
|
20
|
+
|
|
21
|
+
root.innerHTML = `
|
|
22
|
+
<section class="bench bench-video">
|
|
23
|
+
<aside class="composer">
|
|
24
|
+
<div class="composer-head">
|
|
25
|
+
<div>
|
|
26
|
+
<span class="eyebrow">${esc(t('镜头', 'MOTION'))}</span>
|
|
27
|
+
<h2>${esc(t('视频工作台', 'Video Studio'))}</h2>
|
|
28
|
+
</div>
|
|
29
|
+
<button class="icon-btn" data-act="settings" title="${esc(t('视频设置', 'Video settings'))}">${icon('settings', 16)}</button>
|
|
30
|
+
</div>
|
|
31
|
+
<div class="provider-line" id="provider-line"></div>
|
|
32
|
+
<div class="field">
|
|
33
|
+
<span class="field-label">${esc(t('方式', 'Mode'))}</span>
|
|
34
|
+
<div class="mode-tabs" id="modes">
|
|
35
|
+
${MODES.map(item => `<button type="button" class="mode-tab" data-mode="${item.id}">
|
|
36
|
+
<b>${esc(t(item.label[0], item.label[1]))}</b>
|
|
37
|
+
<small>${esc(t(item.hint[0], item.hint[1]))}</small>
|
|
38
|
+
</button>`).join('')}
|
|
39
|
+
</div>
|
|
40
|
+
</div>
|
|
41
|
+
<label class="field field-prompt">
|
|
42
|
+
<span class="field-label">${esc(t('画面描述', 'Shot description'))}<em id="prompt-count">0</em></span>
|
|
43
|
+
<textarea id="prompt" rows="5" maxlength="2000" placeholder="${esc(t('镜头怎么运动、人物在做什么、光线和氛围…', 'How the camera moves, what happens, light and mood…'))}"></textarea>
|
|
44
|
+
</label>
|
|
45
|
+
<div class="field" id="ref-field">
|
|
46
|
+
<span class="field-label">${esc(t('首帧 / 参考图', 'First frame'))}</span>
|
|
47
|
+
<div class="drop drop-compact" id="drop" tabindex="0" role="button">
|
|
48
|
+
<input type="file" id="ref-input" accept="image/png,image/jpeg,image/webp" hidden>
|
|
49
|
+
<div class="drop-empty">${icon('upload', 16)}<span>${esc(t('选一张图(可选)', 'Choose an image (optional)'))}</span></div>
|
|
50
|
+
<div class="drop-preview" id="drop-preview" hidden><img id="ref-img" alt=""><button type="button" class="mini-x" id="ref-clear">${icon('x', 12)}</button></div>
|
|
51
|
+
</div>
|
|
52
|
+
</div>
|
|
53
|
+
<div class="field-row">
|
|
54
|
+
<div class="field">
|
|
55
|
+
<span class="field-label">${esc(t('画幅', 'Aspect'))}</span>
|
|
56
|
+
<select class="select" id="ratio">
|
|
57
|
+
${RATIOS.map(item => `<option value="${item.id}" ${item.id === ratio ? 'selected' : ''}>${item.id}</option>`).join('')}
|
|
58
|
+
</select>
|
|
59
|
+
</div>
|
|
60
|
+
<div class="field">
|
|
61
|
+
<span class="field-label">${esc(t('时长', 'Duration'))}</span>
|
|
62
|
+
<div class="chips chips-tight" id="duration">
|
|
63
|
+
${[5, 8, 10, 15].map(value => `<button type="button" class="chip" data-duration="${value}">${value}s</button>`).join('')}
|
|
64
|
+
</div>
|
|
65
|
+
</div>
|
|
66
|
+
</div>
|
|
67
|
+
<button class="btn btn-generate" id="generate">
|
|
68
|
+
<span class="gen-ic">${icon('film', 17)}</span>
|
|
69
|
+
<span class="gen-label">${esc(t('生成视频', 'Generate video'))}</span>
|
|
70
|
+
<kbd>${modKey()} ↵</kbd>
|
|
71
|
+
</button>
|
|
72
|
+
<p class="fine" id="mode-fine"></p>
|
|
73
|
+
</aside>
|
|
74
|
+
|
|
75
|
+
<section class="gallery-pane">
|
|
76
|
+
<header class="pane-head">
|
|
77
|
+
<div class="pane-title"><h2>${esc(t('视频历史', 'Video library'))}</h2><span class="count-pill" id="gallery-count">0</span></div>
|
|
78
|
+
<div class="gallery-tools">
|
|
79
|
+
<span class="quiet-note">${icon('clock', 14)}${esc(t('视频任务较慢,可以离开这一页', 'Video jobs keep running if you leave'))}</span>
|
|
80
|
+
</div>
|
|
81
|
+
</header>
|
|
82
|
+
<div id="gallery" class="video-list"></div>
|
|
83
|
+
</section>
|
|
84
|
+
</section>
|
|
85
|
+
`
|
|
86
|
+
|
|
87
|
+
const promptEl = $('#prompt')
|
|
88
|
+
promptEl.addEventListener('input', () => {
|
|
89
|
+
$('#prompt-count').textContent = String(promptEl.value.length)
|
|
90
|
+
autoGrow(promptEl, 240)
|
|
91
|
+
})
|
|
92
|
+
autoGrow(promptEl, 240)
|
|
93
|
+
|
|
94
|
+
$('#modes').addEventListener('click', event => {
|
|
95
|
+
const btn = event.target.closest('[data-mode]')
|
|
96
|
+
if (!btn) return
|
|
97
|
+
mode = btn.dataset.mode
|
|
98
|
+
paintModes()
|
|
99
|
+
})
|
|
100
|
+
$('#duration').addEventListener('click', event => {
|
|
101
|
+
const btn = event.target.closest('[data-duration]')
|
|
102
|
+
if (!btn) return
|
|
103
|
+
duration = Number(btn.dataset.duration)
|
|
104
|
+
paintModes()
|
|
105
|
+
})
|
|
106
|
+
$('#ratio').addEventListener('change', () => { ratio = $('#ratio').value })
|
|
107
|
+
$('#generate').addEventListener('click', generate)
|
|
108
|
+
promptEl.addEventListener('keydown', event => {
|
|
109
|
+
if ((event.metaKey || event.ctrlKey) && event.key === 'Enter') { event.preventDefault(); generate() }
|
|
110
|
+
})
|
|
111
|
+
root.addEventListener('click', event => {
|
|
112
|
+
if (event.target.closest('[data-act="settings"]')) app.navigate('settings')
|
|
113
|
+
})
|
|
114
|
+
|
|
115
|
+
const drop = $('#drop')
|
|
116
|
+
const refInput = $('#ref-input')
|
|
117
|
+
drop.addEventListener('click', event => { if (!event.target.closest('#ref-clear')) refInput.click() })
|
|
118
|
+
drop.addEventListener('keydown', event => { if (event.key === 'Enter') { event.preventDefault(); refInput.click() } })
|
|
119
|
+
refInput.addEventListener('change', async () => {
|
|
120
|
+
const file = refInput.files?.[0]
|
|
121
|
+
if (!file) return
|
|
122
|
+
try {
|
|
123
|
+
const result = await upload(file)
|
|
124
|
+
reference = result
|
|
125
|
+
$('#ref-img').src = result.url
|
|
126
|
+
$('#drop-preview').hidden = false
|
|
127
|
+
drop.querySelector('.drop-empty').hidden = true
|
|
128
|
+
} catch (err) { app.toast(err.message, 'error') }
|
|
129
|
+
refInput.value = ''
|
|
130
|
+
})
|
|
131
|
+
$('#ref-clear').addEventListener('click', event => {
|
|
132
|
+
event.stopPropagation()
|
|
133
|
+
reference = null
|
|
134
|
+
$('#drop-preview').hidden = true
|
|
135
|
+
drop.querySelector('.drop-empty').hidden = false
|
|
136
|
+
})
|
|
137
|
+
|
|
138
|
+
function paintModes() {
|
|
139
|
+
$$('#modes [data-mode]').forEach(btn => btn.classList.toggle('is-on', btn.dataset.mode === mode))
|
|
140
|
+
$$('#duration [data-duration]').forEach(btn => btn.classList.toggle('is-on', Number(btn.dataset.duration) === duration))
|
|
141
|
+
$('#ref-field').hidden = mode === 'text'
|
|
142
|
+
const fine = $('#mode-fine')
|
|
143
|
+
if (mode === 'storyboard') {
|
|
144
|
+
fine.textContent = t('分镜短片用生图服务生成 4 张关键帧,在浏览器里按节奏播放,不消耗视频额度。', 'Storyboard uses your image provider for four keyframes and plays them in the browser.')
|
|
145
|
+
} else if (!app.settings?.videoReady) {
|
|
146
|
+
fine.textContent = t('还没有配置视频服务。可以先用分镜短片,或去设置里填接口。', 'No video provider yet. Try storyboard, or configure one in settings.')
|
|
147
|
+
} else {
|
|
148
|
+
fine.textContent = t('视频生成通常需要 1–10 分钟,关掉这一页也会继续。', 'Video jobs usually take 1–10 minutes and keep running if you leave.')
|
|
149
|
+
}
|
|
150
|
+
const label = $('#generate .gen-label')
|
|
151
|
+
if (label) label.textContent = mode === 'storyboard' ? t('生成分镜短片', 'Generate storyboard') : t('生成视频', 'Generate video')
|
|
152
|
+
}
|
|
153
|
+
paintModes()
|
|
154
|
+
|
|
155
|
+
function renderProvider() {
|
|
156
|
+
const line = $('#provider-line')
|
|
157
|
+
const ready = mode === 'storyboard' ? app.settings?.imageReady : app.settings?.videoReady
|
|
158
|
+
const model = mode === 'storyboard' ? app.settings?.image?.model : app.settings?.video?.model
|
|
159
|
+
if (ready) {
|
|
160
|
+
line.className = 'provider-line is-ready'
|
|
161
|
+
line.innerHTML = `<i class="dot"></i><span>${esc(model || t('已连接', 'Ready'))}</span><em>${esc(mode === 'storyboard' ? t('分镜模式 · 使用生图', 'Storyboard · image provider') : '')}</em>`
|
|
162
|
+
} else {
|
|
163
|
+
line.className = 'provider-line is-off'
|
|
164
|
+
line.innerHTML = `<span>${esc(mode === 'storyboard' ? t('生图服务还没配置', 'Image provider is not configured') : t('视频服务还没配置', 'Video provider is not configured'))}</span><button type="button" class="link-btn" data-act="settings">${esc(t('去设置', 'Set up'))} ${icon('arrowRight', 13)}</button>`
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
function videoCard(row) {
|
|
169
|
+
if (row.status === 'queued' || row.status === 'running') {
|
|
170
|
+
return `<article class="vid-card is-busy" data-id="${row.id}">
|
|
171
|
+
<div class="vid-media shimmer"><span class="busy-label"><i></i>${esc(t('正在生成…', 'Rendering…'))}</span></div>
|
|
172
|
+
<div class="vid-body">
|
|
173
|
+
<div class="vid-head"><span class="status-dot is-running"></span><b>${esc(row.mode === 'storyboard' ? t('分镜短片', 'Storyboard') : t('视频', 'Video'))}</b><time>${esc(fmtTime(row.createdAt))}</time></div>
|
|
174
|
+
<p class="vid-prompt">${esc(row.prompt)}</p>
|
|
175
|
+
<div class="progress"><span style="width:${Math.max(4, row.progress || 4)}%"></span></div>
|
|
176
|
+
<div class="vid-actions">
|
|
177
|
+
<span class="fine">${esc(t('进度', 'Progress'))} ${row.progress || 0}%</span>
|
|
178
|
+
<button class="btn btn-small btn-ghost" data-act="cancel" data-id="${row.id}">${icon('stop', 13)}${esc(t('停止', 'Stop'))}</button>
|
|
179
|
+
</div>
|
|
180
|
+
</div>
|
|
181
|
+
</article>`
|
|
182
|
+
}
|
|
183
|
+
if (row.status === 'error') {
|
|
184
|
+
return `<article class="vid-card is-error" data-id="${row.id}">
|
|
185
|
+
<div class="vid-media error-media"><span>${icon('alert', 20)}</span></div>
|
|
186
|
+
<div class="vid-body">
|
|
187
|
+
<div class="vid-head"><span class="status-dot is-error"></span><b>${esc(t('生成失败', 'Failed'))}</b><time>${esc(fmtTime(row.createdAt))}</time></div>
|
|
188
|
+
<p class="vid-error">${esc(row.error || t('未知错误', 'Unknown error'))}</p>
|
|
189
|
+
<p class="vid-prompt">${esc(row.prompt)}</p>
|
|
190
|
+
<div class="vid-actions">
|
|
191
|
+
<button class="btn btn-small btn-ghost" data-act="retry" data-id="${row.id}">${icon('refresh', 13)}${esc(t('重试', 'Retry'))}</button>
|
|
192
|
+
<button class="btn btn-small btn-ghost danger-quiet" data-act="delete" data-id="${row.id}">${icon('trash', 13)}${esc(t('删除', 'Delete'))}</button>
|
|
193
|
+
</div>
|
|
194
|
+
</div>
|
|
195
|
+
</article>`
|
|
196
|
+
}
|
|
197
|
+
const frames = row.frames ?? []
|
|
198
|
+
const media = row.mode === 'storyboard' && frames.length
|
|
199
|
+
? `<div class="vid-media storyboard-media">${frames.slice(0, 4).map((file, index) => `<img src="${fileUrl(file)}" alt="" style="--i:${index}">`).join('')}
|
|
200
|
+
<button class="play-fab" data-act="play" data-id="${row.id}" aria-label="${esc(t('播放分镜', 'Play storyboard'))}">${icon('play', 18)}</button>
|
|
201
|
+
</div>`
|
|
202
|
+
: `<div class="vid-media">${row.file ? `<video src="${fileUrl(row.file)}" controls preload="metadata" playsinline></video>` : `<span class="muted">${esc(t('没有视频文件', 'No file'))}</span>`}</div>`
|
|
203
|
+
return `<article class="vid-card" data-id="${row.id}">
|
|
204
|
+
${media}
|
|
205
|
+
<div class="vid-body">
|
|
206
|
+
<div class="vid-head"><span class="status-dot is-done"></span><b>${esc(row.mode === 'storyboard' ? t('分镜短片', 'Storyboard') : t('视频', 'Video'))}</b><span class="tag">${esc(row.aspect)}</span><span class="tag">${row.duration}s</span><time>${esc(fmtTime(row.createdAt))}</time></div>
|
|
207
|
+
<p class="vid-prompt">${esc(row.prompt)}</p>
|
|
208
|
+
<div class="vid-actions">
|
|
209
|
+
${row.file ? `<button class="btn btn-small btn-ghost" data-act="download" data-id="${row.id}">${icon('download', 13)}${esc(t('下载', 'Download'))}</button>` : ''}
|
|
210
|
+
${row.mode === 'storyboard' ? `<button class="btn btn-small btn-ghost" data-act="download-frames" data-id="${row.id}">${icon('grid', 13)}${esc(t('下载全部分镜', 'Download frames'))}</button>` : ''}
|
|
211
|
+
<button class="btn btn-small btn-ghost" data-act="retry" data-id="${row.id}">${icon('refresh', 13)}${esc(t('再生成', 'Regenerate'))}</button>
|
|
212
|
+
<button class="btn btn-small btn-ghost danger-quiet" data-act="delete" data-id="${row.id}">${icon('trash', 13)}</button>
|
|
213
|
+
</div>
|
|
214
|
+
</div>
|
|
215
|
+
</article>`
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
function renderGallery() {
|
|
219
|
+
const list = app.state?.videos ?? []
|
|
220
|
+
const gallery = $('#gallery')
|
|
221
|
+
$('#gallery-count').textContent = String(list.length)
|
|
222
|
+
if (!list.length) {
|
|
223
|
+
gallery.innerHTML = `<div class="empty-gallery">
|
|
224
|
+
<div class="empty-art">${icon('film', 30)}</div>
|
|
225
|
+
<h3>${esc(t('给画面一点时间', 'Give the frame some time'))}</h3>
|
|
226
|
+
<p>${esc(t('写一个镜头,或者先用分镜短片试一次——不消耗视频额度也能看到运动。', 'Describe a shot, or start with a storyboard — motion without spending video credits.'))}</p>
|
|
227
|
+
<div class="inspirations">
|
|
228
|
+
<button class="inspo" data-inspo="0"><span class="inspo-index">01</span><b>${esc(t('清晨公路', 'Morning road'))}</b><small>${esc(t('清晨的海边公路,一辆旧巴士驶过,车窗里有人望向海面。', 'A coastal road at dawn, an old bus passing, someone watching the sea.'))}</small></button>
|
|
229
|
+
<button class="inspo" data-inspo="1"><span class="inspo-index">02</span><b>${esc(t('雨夜霓虹', 'Neon rain'))}</b><small>${esc(t('雨夜的巷口,霓虹在水面碎成一片,镜头慢慢向前推。', 'A rainy alley, neon broken on the water, camera pushes in slowly.'))}</small></button>
|
|
230
|
+
<button class="inspo" data-inspo="2"><span class="inspo-index">03</span><b>${esc(t('桌面微距', 'Desk macro'))}</b><small>${esc(t('一杯正在冷却的咖啡,热气在晨光里慢慢散开,微距镜头。', 'A cooling cup of coffee, steam drifting in morning light, macro lens.'))}</small></button>
|
|
231
|
+
</div>
|
|
232
|
+
</div>`
|
|
233
|
+
return
|
|
234
|
+
}
|
|
235
|
+
gallery.innerHTML = list.map(videoCard).join('')
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
$('#gallery').addEventListener('click', async event => {
|
|
239
|
+
const inspo = event.target.closest('[data-inspo]')
|
|
240
|
+
if (inspo) {
|
|
241
|
+
const prompts = [
|
|
242
|
+
'清晨的海边公路,一辆旧巴士驶过,车窗里有人望向海面。镜头缓慢跟随。',
|
|
243
|
+
'雨夜的巷口,霓虹在水面碎成一片,镜头慢慢向前推,雨丝清晰可见。',
|
|
244
|
+
'一杯正在冷却的咖啡,热气在晨光里慢慢散开,微距镜头,浅景深。',
|
|
245
|
+
]
|
|
246
|
+
promptEl.value = prompts[Number(inspo.dataset.inspo)] ?? ''
|
|
247
|
+
promptEl.dispatchEvent(new Event('input'))
|
|
248
|
+
promptEl.focus()
|
|
249
|
+
return
|
|
250
|
+
}
|
|
251
|
+
const btn = event.target.closest('[data-act]')
|
|
252
|
+
if (!btn) return
|
|
253
|
+
const id = btn.dataset.id
|
|
254
|
+
const row = (app.state?.videos ?? []).find(item => item.id === id)
|
|
255
|
+
if (!row) return
|
|
256
|
+
const act = btn.dataset.act
|
|
257
|
+
if (act === 'cancel') {
|
|
258
|
+
try { await api('video/cancel', { id }); app.toast(t('已停止', 'Stopped'), 'success'); await app.refresh() } catch (err) { app.toast(err.message, 'error') }
|
|
259
|
+
return
|
|
260
|
+
}
|
|
261
|
+
if (act === 'delete') {
|
|
262
|
+
const ok = await app.confirm({ title: t('删除这条视频?', 'Delete this video?'), message: t('记录会被删除。', 'The record will be removed.'), confirmText: t('删除', 'Delete'), danger: true })
|
|
263
|
+
if (!ok) return
|
|
264
|
+
try { await api('video/action', { id, action: 'delete' }); await app.refresh(); app.toast(t('已删除', 'Deleted'), 'success') } catch (err) { app.toast(err.message, 'error') }
|
|
265
|
+
return
|
|
266
|
+
}
|
|
267
|
+
if (act === 'retry') {
|
|
268
|
+
try { await api('video/action', { id, action: 'retry' }); await app.refresh() } catch (err) { app.toast(err.message, 'error') }
|
|
269
|
+
return
|
|
270
|
+
}
|
|
271
|
+
if (act === 'download') {
|
|
272
|
+
downloadUrl(fileUrl(row.file), `modern-media-${row.id}.mp4`)
|
|
273
|
+
return
|
|
274
|
+
}
|
|
275
|
+
if (act === 'download-frames') {
|
|
276
|
+
for (const [index, file] of (row.frames ?? []).entries()) downloadUrl(fileUrl(file), `modern-media-${row.id}-${index + 1}.png`)
|
|
277
|
+
return
|
|
278
|
+
}
|
|
279
|
+
if (act === 'play') playStoryboard(row)
|
|
280
|
+
})
|
|
281
|
+
|
|
282
|
+
function playStoryboard(row) {
|
|
283
|
+
const frames = row.frames ?? []
|
|
284
|
+
if (!frames.length) return
|
|
285
|
+
const card = document.createElement('div')
|
|
286
|
+
card.className = 'story-player'
|
|
287
|
+
card.innerHTML = `
|
|
288
|
+
<button class="lb-close" data-sp="close" aria-label="关闭">${icon('x', 18)}</button>
|
|
289
|
+
<div class="story-stage">
|
|
290
|
+
${frames.map((file, index) => `<img src="${fileUrl(file)}" alt="" class="${index === 0 ? 'is-on' : ''}" data-frame="${index}">`).join('')}
|
|
291
|
+
<div class="story-caption"><span class="eyebrow">${esc(t('分镜短片', 'STORYBOARD'))}</span><p>${esc(row.prompt)}</p></div>
|
|
292
|
+
</div>
|
|
293
|
+
<div class="story-controls">
|
|
294
|
+
<button class="icon-btn" data-sp="toggle">${icon('pause', 16)}</button>
|
|
295
|
+
<div class="story-dots">${frames.map((_, index) => `<i data-dot="${index}" class="${index === 0 ? 'is-on' : ''}"></i>`).join('')}</div>
|
|
296
|
+
<span class="fine">${frames.length} ${esc(t('个镜头', 'frames'))}</span>
|
|
297
|
+
</div>`
|
|
298
|
+
let index = 0
|
|
299
|
+
let playing = true
|
|
300
|
+
let timer = null
|
|
301
|
+
const imgs = [...card.querySelectorAll('[data-frame]')]
|
|
302
|
+
const dots = [...card.querySelectorAll('[data-dot]')]
|
|
303
|
+
const tick = () => {
|
|
304
|
+
index = (index + 1) % frames.length
|
|
305
|
+
imgs.forEach((img, i) => img.classList.toggle('is-on', i === index))
|
|
306
|
+
dots.forEach((dot, i) => dot.classList.toggle('is-on', i === index))
|
|
307
|
+
}
|
|
308
|
+
const start = () => { stop(); timer = setInterval(tick, 2400) }
|
|
309
|
+
const stop = () => { if (timer) clearInterval(timer); timer = null }
|
|
310
|
+
card.querySelector('[data-sp="toggle"]').addEventListener('click', event => {
|
|
311
|
+
playing = !playing
|
|
312
|
+
event.currentTarget.innerHTML = icon(playing ? 'pause' : 'play', 16)
|
|
313
|
+
playing ? start() : stop()
|
|
314
|
+
})
|
|
315
|
+
let close = () => {}
|
|
316
|
+
card.querySelector('[data-sp="close"]').addEventListener('click', () => close())
|
|
317
|
+
close = app.showLayer(card, { className: 'layer-story', onClose: stop })
|
|
318
|
+
start()
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
async function generate() {
|
|
322
|
+
if (busy) return
|
|
323
|
+
const prompt = promptEl.value.trim()
|
|
324
|
+
if (!prompt) { promptEl.focus(); return app.toast(t('先写一个镜头', 'Describe the shot first'), 'error') }
|
|
325
|
+
const ready = mode === 'storyboard' ? app.settings?.imageReady : app.settings?.videoReady
|
|
326
|
+
if (!ready) {
|
|
327
|
+
app.toast(mode === 'storyboard' ? t('分镜需要生图服务', 'Storyboard needs an image provider') : t('先配置视频服务,或改用分镜', 'Configure video, or switch to storyboard'), 'error')
|
|
328
|
+
return app.navigate('settings')
|
|
329
|
+
}
|
|
330
|
+
busy = true
|
|
331
|
+
const btn = $('#generate')
|
|
332
|
+
btn.classList.add('is-busy')
|
|
333
|
+
btn.querySelector('.gen-label').textContent = t('正在提交…', 'Sending…')
|
|
334
|
+
try {
|
|
335
|
+
await api('video/generate', {
|
|
336
|
+
prompt,
|
|
337
|
+
mode,
|
|
338
|
+
ratio,
|
|
339
|
+
duration,
|
|
340
|
+
reference: reference?.name ?? '',
|
|
341
|
+
})
|
|
342
|
+
api('preferences', { videoMode: mode, videoRatio: ratio, videoDuration: duration }).catch(() => {})
|
|
343
|
+
app.toast(mode === 'storyboard' ? t('分镜任务已开始', 'Storyboard started') : t('视频任务已开始', 'Video started'), 'success')
|
|
344
|
+
await app.refresh()
|
|
345
|
+
} catch (err) {
|
|
346
|
+
app.toast(err.message, 'error')
|
|
347
|
+
} finally {
|
|
348
|
+
busy = false
|
|
349
|
+
btn.classList.remove('is-busy')
|
|
350
|
+
paintModes()
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
function update() {
|
|
355
|
+
renderProvider()
|
|
356
|
+
paintModes()
|
|
357
|
+
renderGallery()
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
update()
|
|
361
|
+
|
|
362
|
+
return { update }
|
|
363
|
+
}
|