@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.
@@ -0,0 +1,269 @@
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('settings', 18)}</span><div><h3>${esc(t('维护', 'Maintenance'))}</h3><p>${esc(t('导出工作台记录,或者清空列表。这里只动记录,不会删除作品文件目录里的图片和视频。', 'Export workbench records or clear the lists. This only touches records; files in the Library folder are left alone.'))}</p></div></div>
102
+ </header>
103
+ <div class="settings-actions wrap">
104
+ <button class="btn btn-small btn-ghost" id="export-all">${icon('download', 14)}${esc(t('导出记录 JSON', 'Export records JSON'))}</button>
105
+ <button class="btn btn-small btn-ghost" id="clear-images">${esc(t('清空图片记录', 'Clear images'))}</button>
106
+ <button class="btn btn-small btn-ghost" id="clear-videos">${esc(t('清空视频记录', 'Clear videos'))}</button>
107
+ <button class="btn btn-small btn-ghost danger-quiet" id="clear-all">${esc(t('清空全部记录', 'Clear everything'))}</button>
108
+ </div>
109
+ </section>
110
+
111
+ <section class="settings-card">
112
+ <header class="settings-head">
113
+ <div class="settings-title"><span class="settings-ic" style="--c:#c96442">${icon('download', 18)}</span><div><h3>${esc(t('作品文件目录', 'Library folder'))}</h3><p>${esc(t('图片、视频的实际文件主目录。换到其他盘时会把已有文件迁移过去,释放 C 盘;状态和设置文件仍留在系统盘。留空恢复默认目录。', 'The actual storage folder for images and videos. Changing it migrates existing files and frees the system drive; state and settings stay on the system drive. Leave blank to use the default.'))}</p></div></div>
114
+ </header>
115
+ <label class="field"><span class="field-label">${esc(t('目录绝对路径', 'Absolute folder path'))}</span>
116
+ <input class="input" id="output-dir" value="${esc(settings.storage?.outputDir ?? '')}" placeholder="D:\\AI作品" spellcheck="false">
117
+ </label>
118
+ <div class="settings-actions">
119
+ <span class="fine" id="output-dir-hint">${esc(t('建议选空目录;保存时会创建目录并迁移现有文件,目标里有同名文件会拒绝迁移。', 'Prefer an empty folder. It is created and existing files are migrated; same-name files cause the move to be rejected.'))}</span>
120
+ <span class="spacer"></span>
121
+ <button class="btn btn-small btn-ghost" id="output-dir-clear">${esc(t('恢复默认目录', 'Use default'))}</button>
122
+ <button class="btn btn-small btn-primary" id="output-dir-save">${esc(t('保存目录', 'Save folder'))}</button>
123
+ </div>
124
+ </section>
125
+
126
+ <section class="settings-card about-card">
127
+ <div class="about-mark">${icon('spark', 22)}</div>
128
+ <div>
129
+ <h3>${esc(t('摩登-媒体工作室', 'Modern-MediaStudio'))} <span class="version">v${esc(app.version)}</span></h3>
130
+ <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>
131
+ </div>
132
+ </section>
133
+ </div>
134
+ </section>
135
+ `
136
+
137
+ const field = id => $('#' + id)
138
+
139
+ // 预设联动
140
+ field('image-preset').addEventListener('change', () => {
141
+ const preset = (app.imagePresets ?? []).find(p => p.id === field('image-preset').value)
142
+ if (preset?.baseUrl) field('image-base').value = preset.baseUrl
143
+ if (preset?.model) field('image-model').value = preset.model
144
+ })
145
+ field('video-preset').addEventListener('change', () => {
146
+ const preset = (app.videoPresets ?? []).find(p => p.id === field('video-preset').value)
147
+ if (preset?.baseUrl) field('video-base').value = preset.baseUrl
148
+ if (preset?.model) field('video-model').value = preset.model
149
+ })
150
+
151
+ async function saveImage() {
152
+ if (busy) return
153
+ busy = true
154
+ try {
155
+ const data = await api('settings', {
156
+ image: {
157
+ preset: field('image-preset').value,
158
+ baseUrl: field('image-base').value,
159
+ model: field('image-model').value,
160
+ size: field('image-size').value,
161
+ quality: field('image-quality').value,
162
+ extra: field('image-extra').value,
163
+ apiKey: field('image-key').value,
164
+ },
165
+ })
166
+ app.settings = data.settings
167
+ field('image-key').value = ''
168
+ app.toast(t('生图设置已保存', 'Image settings saved'), 'success')
169
+ await app.refresh()
170
+ } catch (err) { app.toast(err.message, 'error') }
171
+ finally { busy = false }
172
+ }
173
+
174
+ async function saveVideo() {
175
+ if (busy) return
176
+ busy = true
177
+ try {
178
+ const data = await api('settings', {
179
+ video: {
180
+ preset: field('video-preset').value,
181
+ baseUrl: field('video-base').value,
182
+ model: field('video-model').value,
183
+ createPath: field('video-create').value,
184
+ statusPath: field('video-status').value,
185
+ contentPath: field('video-content').value,
186
+ imageField: field('video-field').value,
187
+ extra: field('video-extra').value,
188
+ apiKey: field('video-key').value,
189
+ },
190
+ })
191
+ app.settings = data.settings
192
+ field('video-key').value = ''
193
+ app.toast(t('视频设置已保存', 'Video settings saved'), 'success')
194
+ await app.refresh()
195
+ } catch (err) { app.toast(err.message, 'error') }
196
+ finally { busy = false }
197
+ }
198
+
199
+ field('image-save').addEventListener('click', saveImage)
200
+ field('video-save').addEventListener('click', saveVideo)
201
+
202
+ async function saveOutputDir(value) {
203
+ if (busy) return
204
+ busy = true
205
+ try {
206
+ const data = await api('settings', { storage: { outputDir: value } })
207
+ app.settings = data.settings
208
+ field('output-dir').value = data.settings?.storage?.outputDir ?? ''
209
+ app.toast(value ? t('作品目录已迁移', 'Library folder moved') : t('已恢复默认目录', 'Default folder restored'), 'success')
210
+ await app.refresh()
211
+ } catch (err) { app.toast(err.message, 'error') }
212
+ finally { busy = false }
213
+ }
214
+ field('output-dir-save').addEventListener('click', () => saveOutputDir(field('output-dir').value))
215
+ field('output-dir-clear').addEventListener('click', () => { field('output-dir').value = ''; saveOutputDir('') })
216
+
217
+ field('image-clear-key')?.addEventListener('click', async () => {
218
+ try {
219
+ const data = await api('settings', { image: { clearKey: true } })
220
+ app.settings = data.settings
221
+ app.toast(t('已清除生图密钥', 'Image key cleared'), 'success')
222
+ await app.refresh()
223
+ } catch (err) { app.toast(err.message, 'error') }
224
+ })
225
+ field('video-clear-key')?.addEventListener('click', async () => {
226
+ try {
227
+ const data = await api('settings', { video: { clearKey: true } })
228
+ app.settings = data.settings
229
+ app.toast(t('已清除视频密钥', 'Video key cleared'), 'success')
230
+ await app.refresh()
231
+ } catch (err) { app.toast(err.message, 'error') }
232
+ })
233
+
234
+ field('image-test').addEventListener('click', async () => {
235
+ if (busy) return
236
+ if (!field('image-key').value && !app.settings?.image?.hasKey) return app.toast(t('先填密钥并保存', 'Add a key and save first'), 'error')
237
+ busy = true
238
+ const btn = field('image-test')
239
+ btn.textContent = t('正在画…', 'Drawing…')
240
+ try {
241
+ const data = await api('settings/test-image', {})
242
+ testImage = data.url
243
+ field('test-result').hidden = false
244
+ field('test-img').src = testImage
245
+ app.toast(t('接口通了', 'Provider works'), 'success')
246
+ } catch (err) { app.toast(err.message, 'error') }
247
+ finally { busy = false; btn.textContent = t('试画一张', 'Test image') }
248
+ })
249
+
250
+ field('export-all').addEventListener('click', () => { location.href = '/media-workbench-modern/api/export' })
251
+
252
+ async function clearScope(scope) {
253
+ const label = { images: t('图片记录', 'image records'), videos: t('视频记录', 'video records'), all: t('全部记录', 'all records') }[scope]
254
+ 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 })
255
+ if (!ok) return
256
+ try {
257
+ await api('clear', { scope })
258
+ await app.refresh()
259
+ app.toast(t('已清空', 'Cleared'), 'success')
260
+ } catch (err) { app.toast(err.message, 'error') }
261
+ }
262
+ field('clear-images').addEventListener('click', () => clearScope('images'))
263
+ field('clear-videos').addEventListener('click', () => clearScope('videos'))
264
+ field('clear-all').addEventListener('click', () => clearScope('all'))
265
+
266
+ return {
267
+ update() { /* 设置页不跟随轮询刷新,避免覆盖正在编辑的值 */ },
268
+ }
269
+ }
@@ -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
+ }