@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/src/video.js ADDED
@@ -0,0 +1,245 @@
1
+ // 视频:兼容异步任务式视频接口;没有视频密钥时可以用「分镜短片」把画面串起来
2
+ import { readFile } from 'node:fs/promises'
3
+ import { extname, join } from 'node:path'
4
+ import { RATIOS } from './graph.js'
5
+ import { generateImage } from './image.js'
6
+ import { imageReady, videoKey } from './settings.js'
7
+ import { assertRemoteUrl, sleep, videoExtensionOf } from './util.js'
8
+
9
+ const POLL_MS = 4_000
10
+ const MAX_WAIT_MS = 20 * 60_000
11
+
12
+ function extraOf(settings) {
13
+ try {
14
+ const parsed = JSON.parse(settings.video.extra || '{}')
15
+ return parsed && typeof parsed === 'object' ? parsed : {}
16
+ } catch {
17
+ return {}
18
+ }
19
+ }
20
+
21
+ function statusOf(data) {
22
+ const raw = data?.status ?? data?.state ?? data?.data?.status ?? data?.data?.state ?? ''
23
+ return String(raw).toLowerCase()
24
+ }
25
+
26
+ function isFailed(status) {
27
+ return ['failed', 'error', 'cancelled', 'canceled', 'expired'].includes(status)
28
+ }
29
+
30
+ function isDone(status) {
31
+ return ['completed', 'succeeded', 'success', 'done', 'finished'].includes(status)
32
+ }
33
+
34
+ function findUrl(data) {
35
+ if (!data) return ''
36
+ const candidates = [
37
+ data.url,
38
+ data.video_url,
39
+ data.videoUrl,
40
+ data.output_url,
41
+ data.result_url,
42
+ data.output?.url,
43
+ data.output?.video_url,
44
+ Array.isArray(data.output) ? data.output[0]?.url ?? data.output[0] : '',
45
+ data.data?.url,
46
+ data.data?.video_url,
47
+ data.data?.output?.url,
48
+ data.result?.url,
49
+ data.result?.video_url,
50
+ Array.isArray(data.data) ? data.data[0]?.url : '',
51
+ ]
52
+ return candidates.find(v => typeof v === 'string' && /^https?:\/\//.test(v)) ?? ''
53
+ }
54
+
55
+ function findId(data) {
56
+ return String(data?.id ?? data?.task_id ?? data?.taskId ?? data?.data?.id ?? data?.data?.task_id ?? data?.result?.id ?? '')
57
+ }
58
+
59
+ function progressOf(data) {
60
+ const raw = data?.progress ?? data?.percent ?? data?.data?.progress
61
+ if (raw === undefined) return null
62
+ const n = Number(raw)
63
+ if (!Number.isFinite(n)) return null
64
+ return Math.max(0, Math.min(100, n <= 1 ? Math.round(n * 100) : Math.round(n)))
65
+ }
66
+
67
+ function sameSite(a, b) {
68
+ try {
69
+ const ha = new URL(a).hostname.toLowerCase()
70
+ const hb = new URL(b).hostname.toLowerCase()
71
+ return ha === hb || ha.endsWith(`.${hb}`) || hb.endsWith(`.${ha}`)
72
+ } catch {
73
+ return false
74
+ }
75
+ }
76
+
77
+ async function fetchRemoteVideo(url, settings, key, signal) {
78
+ let res = await fetch(url, { signal })
79
+ if (!res.ok && (res.status === 401 || res.status === 403) && sameSite(url, settings.video.baseUrl)) {
80
+ await res.body?.cancel().catch(() => {})
81
+ res = await fetch(url, { headers: { authorization: `Bearer ${key}` }, signal })
82
+ }
83
+ return res
84
+ }
85
+
86
+ async function fetchVideoContent(settings, key, id, signal) {
87
+ const path = settings.video.contentPath.replace('{id}', encodeURIComponent(id))
88
+ const res = await fetch(`${settings.video.baseUrl}${path}`, { headers: { authorization: `Bearer ${key}` }, signal })
89
+ if (!res.ok) throw new Error(`下载视频失败(${res.status})`)
90
+ return Buffer.from(await res.arrayBuffer())
91
+ }
92
+
93
+ /**
94
+ * 走异步接口生成视频,返回视频字节。
95
+ */
96
+ export async function generateVideo(settings, record, { signal, filesDir, onProgress } = {}) {
97
+ const key = videoKey(settings)
98
+ if (!key) throw Object.assign(new Error('还没有配置视频密钥。也可以把模式换成「分镜短片」,用图片拼出动态预览'), { status: 400 })
99
+ if (!settings.video.baseUrl || !settings.video.model) throw Object.assign(new Error('先填好视频接口地址和模型'), { status: 400 })
100
+ const timeout = AbortSignal.timeout(MAX_WAIT_MS)
101
+ const combined = signal ? AbortSignal.any([signal, timeout]) : timeout
102
+ const ratio = RATIOS[record.aspect] ?? RATIOS['16:9']
103
+
104
+ const body = {
105
+ model: settings.video.model,
106
+ prompt: record.prompt,
107
+ seconds: String(record.duration),
108
+ size: ratio.size,
109
+ ...extraOf(settings),
110
+ }
111
+
112
+ if (record.reference && filesDir) {
113
+ try {
114
+ const buffer = await readFile(join(filesDir, record.reference.replace(/[^\w.-]/g, '')))
115
+ const type = extname(record.reference).toLowerCase() === '.jpg' ? 'image/jpeg' : extname(record.reference).toLowerCase() === '.webp' ? 'image/webp' : 'image/png'
116
+ body[settings.video.imageField || 'image'] = `data:${type};base64,${buffer.toString('base64')}`
117
+ } catch {
118
+ throw Object.assign(new Error('首帧图片不存在,重新上传一次'), { status: 400 })
119
+ }
120
+ }
121
+
122
+ let create
123
+ try {
124
+ const res = await fetch(`${settings.video.baseUrl}${settings.video.createPath}`, {
125
+ method: 'POST',
126
+ headers: { authorization: `Bearer ${key}`, 'content-type': 'application/json' },
127
+ body: JSON.stringify(body),
128
+ signal: combined,
129
+ })
130
+ const raw = await res.text()
131
+ try { create = JSON.parse(raw) } catch { create = null }
132
+ if (!res.ok) {
133
+ const detail = create?.error?.message ?? create?.message ?? raw.slice(0, 300)
134
+ throw Object.assign(new Error(`视频服务返回 ${res.status}:${detail}`), { status: res.status })
135
+ }
136
+ } catch (err) {
137
+ if (err.name === 'TimeoutError') throw new Error('视频接口响应超时')
138
+ throw err
139
+ }
140
+
141
+ const id = findId(create)
142
+ let status = statusOf(create)
143
+ let final = create
144
+ onProgress?.(10)
145
+ if (id && !isDone(status)) {
146
+ const statusPath = settings.video.statusPath.replace('{id}', encodeURIComponent(id))
147
+ const started = Date.now()
148
+ let lastProgress = 10
149
+ for (;;) {
150
+ if (Date.now() - started > MAX_WAIT_MS) throw new Error('视频生成超时(20 分钟),可以稍后到服务商后台查看')
151
+ await sleep(POLL_MS, combined)
152
+ let res
153
+ try {
154
+ res = await fetch(`${settings.video.baseUrl}${statusPath}`, { headers: { authorization: `Bearer ${key}` }, signal: combined })
155
+ } catch (err) {
156
+ if (err.name === 'TimeoutError' || combined.aborted) throw err
157
+ continue
158
+ }
159
+ if (!res.ok) {
160
+ if (res.status === 404 && Date.now() - started < 30_000) continue
161
+ throw new Error(`查询视频进度失败(${res.status})`)
162
+ }
163
+ const raw = await res.text()
164
+ try { final = JSON.parse(raw) } catch { final = null }
165
+ status = statusOf(final) || status
166
+ const progress = progressOf(final)
167
+ if (progress !== null && progress > lastProgress) {
168
+ lastProgress = progress
169
+ onProgress?.(Math.min(96, progress))
170
+ } else {
171
+ const guess = Math.min(90, lastProgress + 2)
172
+ if (guess > lastProgress) { lastProgress = guess; onProgress?.(lastProgress) }
173
+ }
174
+ if (isFailed(status)) {
175
+ const message = final?.error?.message ?? final?.message ?? final?.data?.error?.message ?? '视频生成失败'
176
+ throw new Error(String(message).slice(0, 300))
177
+ }
178
+ if (isDone(status)) break
179
+ }
180
+ }
181
+
182
+ let buffer
183
+ const url = findUrl(final)
184
+ if (url) {
185
+ const remote = assertRemoteUrl(url)
186
+ const download = await fetchRemoteVideo(remote, settings, key, combined)
187
+ if (download.ok) {
188
+ buffer = Buffer.from(await download.arrayBuffer())
189
+ } else if (id && settings.video.contentPath) {
190
+ // 有些网关返回的下载地址需要额外鉴权或已经过期,回退到内容的官方路径。
191
+ await download.body?.cancel().catch(() => {})
192
+ buffer = await fetchVideoContent(settings, key, id, combined)
193
+ } else {
194
+ throw new Error(`下载视频失败(${download.status})`)
195
+ }
196
+ } else if (id && settings.video.contentPath) {
197
+ buffer = await fetchVideoContent(settings, key, id, combined)
198
+ }
199
+ if (!buffer?.length) throw new Error('视频服务没有返回视频文件')
200
+ onProgress?.(100)
201
+ return { buffer, ext: videoExtensionOf(buffer) }
202
+ }
203
+
204
+ const BEATS = [
205
+ 'establishing wide shot, the whole scene visible, atmospheric depth',
206
+ 'medium shot, the subject in motion, natural framing',
207
+ 'close-up detail, texture and light, shallow depth of field',
208
+ 'final wide shot, quiet aftermath, cinematic negative space',
209
+ ]
210
+
211
+ async function makeFrame(settings, record, beat, index, { signal, filesDir }) {
212
+ const ratio = RATIOS[record.aspect] ?? RATIOS['16:9']
213
+ const prompt = `${record.prompt}。${beat}`
214
+ const result = await generateImage(settings, {
215
+ prompt,
216
+ style: '电影感',
217
+ size: ratio.size,
218
+ reference: record.reference || undefined,
219
+ }, { signal, filesDir })
220
+ return result
221
+ }
222
+
223
+ /**
224
+ * 分镜短片:用生图服务生成 4 个关键帧,前端用交叉淡入播放。
225
+ */
226
+ export async function generateStoryboard(settings, record, { signal, filesDir, onProgress } = {}) {
227
+ if (!imageReady(settings)) throw Object.assign(new Error('分镜短片需要先配好生图服务'), { status: 400 })
228
+ const frames = []
229
+ const errors = []
230
+ for (let i = 0; i < 4; i++) {
231
+ if (signal?.aborted) throw new Error('已停止')
232
+ try {
233
+ frames.push(await makeFrame(settings, record, BEATS[i], i, { signal, filesDir }))
234
+ onProgress?.(Math.round(((i + 1) / 4) * 90))
235
+ } catch (err) {
236
+ errors.push(err.message)
237
+ if (i === 0) throw err
238
+ }
239
+ }
240
+ if (!frames.length) throw new Error(errors[0] ?? '分镜生成失败')
241
+ onProgress?.(100)
242
+ return { frames, errors }
243
+ }
244
+
245
+ export { findUrl, findId, progressOf, statusOf }
package/src/writing.js ADDED
@@ -0,0 +1,112 @@
1
+ // 写作动作 → 提示词
2
+ const LENGTH = { short: 400, medium: 900, long: 1600, micro: 140 }
3
+ const LENGTH_LABEL = { short: '约 400 字', medium: '约 900 字', long: '约 1600 字', micro: '约 100 字' }
4
+
5
+ const TONES = ['自然叙述', '克制冷静', '热烈', '幽默', '诗意', '硬朗']
6
+ const POVS = ['第一人称', '第三人称', '全知视角']
7
+
8
+ const ACTIONS = {
9
+ continue: '续写',
10
+ polish: '润色',
11
+ expand: '扩写',
12
+ rewrite: '改写',
13
+ outline: '大纲',
14
+ title: '起标题',
15
+ custom: '按指令写',
16
+ graph: '星图写作',
17
+ }
18
+
19
+ export { ACTIONS, TONES, POVS, LENGTH }
20
+
21
+ function clip(text, max) {
22
+ const value = String(text ?? '')
23
+ return value.length > max ? value.slice(-max) : value
24
+ }
25
+
26
+ function englishLine(action, lang, length) {
27
+ return lang === 'en'
28
+ ? `\n\nWrite in natural, fluent English. The length target is ${length} words or fewer. Output only the text the author can paste into the manuscript, with no preamble, no explanation, and no markdown fences.`
29
+ : ''
30
+ }
31
+
32
+ function buildSystem(body) {
33
+ const tone = TONES.includes(body.tone) ? body.tone : '自然叙述'
34
+ const pov = POVS.includes(body.pov) ? body.pov : '第三人称'
35
+ return [
36
+ '你是一位可靠的中文写作搭档,正在「摩登-媒体工作室」里和作者一起改稿。',
37
+ '你只输出可以直接放进正文的文字:不要解释你在做什么,不要写“好的”“以下是”,不要加代码块,不要用“---”分隔。',
38
+ `文风:${tone}。叙述人称:${pov}。保持作者已有的用词习惯和节奏,不擅自改人物名、数字和设定。`,
39
+ '不要为了凑字数重复已经写过的意思;没把握的地方宁可写得具体,也不要写空泛的总结。',
40
+ englishLine(body.action, body.lang, body.length),
41
+ ].filter(Boolean).join('\n')
42
+ }
43
+
44
+ function manuscriptBlock(body) {
45
+ const title = String(body.title ?? '').trim()
46
+ const content = clip(body.content ?? '', 8000)
47
+ const selection = String(body.selection ?? '').trim()
48
+ const parts = []
49
+ if (title) parts.push(`作品标题:${title}`)
50
+ if (content) parts.push(`<正文>\n${content}\n</正文>`)
51
+ if (selection) parts.push(`<选中的片段>\n${selection}\n</选中的片段>\n只处理这个片段。`)
52
+ if (body.outline) parts.push(`<大纲>\n${String(body.outline).slice(0, 2000)}\n</大纲>`)
53
+ return parts.join('\n\n')
54
+ }
55
+
56
+ export function writingPrompts(body) {
57
+ const action = ACTIONS[body.action] ? body.action : 'custom'
58
+ const length = LENGTH[body.length] ? body.length : 'medium'
59
+ const target = LENGTH_LABEL[length]
60
+ const instruction = String(body.instruction ?? '').trim().slice(0, 600)
61
+ const material = manuscriptBlock(body)
62
+ const system = buildSystem({ ...body, action })
63
+ let task = ''
64
+
65
+ switch (action) {
66
+ case 'continue':
67
+ task = `从正文结束的地方继续往下写,${target}。承接最后一句的语气和场景,不要复述已经写过的内容。只输出新写的那部分。`
68
+ break
69
+ case 'polish':
70
+ task = `润色${body.selection ? '选中的片段' : '正文'}:保留原意、信息和人物口吻,改善节奏、用词和标点,删掉多余的解释。只输出润色后的文字。`
71
+ break
72
+ case 'expand':
73
+ task = `把${body.selection ? '选中的片段' : '正文'}扩写到${target}:补充具体的动作、感官细节和环境,不要新增与上下文冲突的情节。只输出扩写后的文字。`
74
+ break
75
+ case 'rewrite':
76
+ task = `按这个要求重写${body.selection ? '选中的片段' : '正文'}:${instruction || '换一种更自然、更有画面感的写法'}。保留事实和人物关系,只输出重写后的文字。`
77
+ break
78
+ case 'outline':
79
+ task = `为这个作品整理一份可执行的大纲。输出三到五节,每节一行,格式是「## 小节名:这一节发生什么、情绪怎么走」。不要写正文。`
80
+ break
81
+ case 'title':
82
+ task = '给这个作品起 6 个标题。每行一个,只写标题,不编号、不解释。标题要具体、有画面感,不要浮夸的营销腔。'
83
+ break
84
+ case 'graph':
85
+ task = instruction || '根据素材写一段有画面感的文字,控制在 120 字以内。'
86
+ break
87
+ default:
88
+ task = instruction || '根据素材继续写。'
89
+ }
90
+
91
+ const user = [
92
+ material || '(还没有正文,按指令从零写。)',
93
+ '',
94
+ `任务:${task}`,
95
+ body.action === 'continue' ? `篇幅:${target}。` : '',
96
+ ].filter(Boolean).join('\n')
97
+
98
+ return { system, user, action, length }
99
+ }
100
+
101
+ /** 洗掉模型偶尔带出来的前缀和代码围栏 */
102
+ export function cleanOutput(text, action) {
103
+ let out = String(text ?? '').trim()
104
+ out = out.replace(/```(?:markdown|md|text)?\s*/gi, '').trim()
105
+ if (action !== 'outline' && action !== 'title') {
106
+ out = out.replace(/^(好的|当然|没问题)[,,。!!]\s*/u, '')
107
+ out = out.replace(/^(这是|以下是)?\s*(续写|润色|扩写|改写|正文|草稿)[::]\s*/u, '')
108
+ }
109
+ return out.trim()
110
+ }
111
+
112
+ export const WRITING_SYSTEM_HINT = '写作使用 DSH 当前默认模型;密钥由 DSH 管理,插件不保存。'
package/web/app.js ADDED
@@ -0,0 +1,268 @@
1
+ import { api } from './lib/api.js'
2
+ import { $, esc, modKey } from './lib/dom.js'
3
+ import { icon, brandMark } from './lib/icons.js'
4
+ import { app, t } from './lib/state.js'
5
+ import * as imageView from './views/image.js'
6
+ import * as videoView from './views/video.js'
7
+ import * as writeView from './views/write.js'
8
+ import * as constellationView from './views/constellation.js'
9
+ import * as settingsView from './views/settings.js'
10
+
11
+ const ROUTES = {
12
+ image: { mod: imageView, title: ['生图工作台', 'Image Studio'], eyebrow: ['TEXT TO IMAGE', 'TEXT TO IMAGE'], desc: ['一句话,一张画。', 'One line, one image.'] },
13
+ write: { mod: writeView, title: ['写作工作台', 'Writing Studio'], eyebrow: ['LONG FORM', 'LONG FORM'], desc: ['从灵感到成稿,留得住每一版。', 'From spark to manuscript, every draft kept.'] },
14
+ constellation: { mod: constellationView, title: ['星图工作台', 'Star Map'], eyebrow: ['NODE GRAPH', 'NODE GRAPH'], desc: ['把提示词、写作、画面与视频连成一条线。', 'Wire prompts, writing, image and video into one flow.'] },
15
+ video: { mod: videoView, title: ['视频工作台', 'Video Studio'], eyebrow: ['MOTION', 'MOTION'], desc: ['给画面一点时间。', 'Give the frame some time.'] },
16
+ settings: { mod: settingsView, title: ['设置', 'Settings'], eyebrow: ['WORKBENCH', 'WORKBENCH'], desc: ['接口、模型与本机数据。', 'Providers, models and local data.'] },
17
+ }
18
+
19
+ const params = new URLSearchParams(location.search)
20
+ app.lang = params.get('lang') === 'en' ? 'en' : 'zh'
21
+ const storedTheme = localStorage.getItem('media-workbench-modern:theme')
22
+ const theme = storedTheme === 'dark' || storedTheme === 'light' ? storedTheme : params.get('theme') === 'dark' ? 'dark' : 'light'
23
+
24
+ function applyTheme(next) {
25
+ app.theme = next
26
+ document.documentElement.dataset.theme = next
27
+ const btn = $('#theme-toggle')
28
+ if (btn) btn.innerHTML = icon(next === 'dark' ? 'sun' : 'moon', 17)
29
+ const meta = document.querySelector('meta[name="theme-color"]')
30
+ if (meta) meta.content = next === 'dark' ? '#141210' : '#f6f3ee'
31
+ }
32
+
33
+ function renderChrome() {
34
+ const shell = document.createElement('div')
35
+ shell.className = 'app-shell'
36
+ shell.innerHTML = `
37
+ <aside class="rail">
38
+ <div class="rail-brand" title="摩登-媒体工作室">${brandMark(24)}<span>MODERN</span></div>
39
+ <nav class="rail-nav" aria-label="${esc(t('工作台', 'Workbenches'))}">
40
+ <button class="rail-btn" data-route="image">${icon('image', 20)}<span>${esc(t('生图', 'Image'))}</span></button>
41
+ <button class="rail-btn" data-route="write">${icon('pen', 20)}<span>${esc(t('写作', 'Write'))}</span></button>
42
+ <button class="rail-btn" data-route="constellation">${icon('star', 20)}<span>${esc(t('星图', 'Map'))}</span></button>
43
+ <button class="rail-btn" data-route="video">${icon('film', 20)}<span>${esc(t('视频', 'Video'))}</span></button>
44
+ </nav>
45
+ <div class="rail-foot">
46
+ <button class="rail-btn" data-route="settings">${icon('settings', 19)}<span>${esc(t('设置', 'Setup'))}</span></button>
47
+ <button class="rail-btn rail-theme" id="theme-toggle" title="${esc(t('切换明暗', 'Toggle theme'))}">${icon(theme === 'dark' ? 'sun' : 'moon', 17)}</button>
48
+ </div>
49
+ </aside>
50
+ <div class="workspace">
51
+ <header class="topbar">
52
+ <div class="topbar-copy">
53
+ <span class="eyebrow" id="top-eyebrow"></span>
54
+ <h1 id="top-title"></h1>
55
+ <p class="topbar-desc" id="top-desc"></p>
56
+ </div>
57
+ <div class="topbar-side">
58
+ <button class="status-chip" id="model-chip" title="${esc(t('DSH 当前模型', 'Current DSH model'))}"></button>
59
+ <button class="status-chip" id="image-chip" title="${esc(t('打开设置', 'Open settings'))}"></button>
60
+ <button class="icon-btn" id="refresh-btn" title="${esc(t('刷新', 'Refresh'))}">${icon('refresh', 16)}</button>
61
+ </div>
62
+ </header>
63
+ <main class="view" id="view"><div class="boot"><span class="boot-mark">${brandMark(28)}</span><p>${esc(t('正在打开工作台…', 'Opening the studio…'))}</p></div></main>
64
+ </div>
65
+ <div id="layer-root"></div>
66
+ <div id="toasts" aria-live="polite"></div>
67
+ `
68
+ document.body.replaceChildren(shell)
69
+ document.documentElement.lang = app.lang === 'en' ? 'en' : 'zh-CN'
70
+ }
71
+
72
+ function updateChips() {
73
+ const modelChip = $('#model-chip')
74
+ const imageChip = $('#image-chip')
75
+ if (modelChip) {
76
+ const ok = Boolean(app.model)
77
+ modelChip.className = `status-chip ${ok ? 'is-ok' : 'is-off'}`
78
+ modelChip.innerHTML = `<i class="dot"></i><span>${esc(ok ? t('写作', 'Write') + ' · ' + app.model.label : t('模型未连接', 'No model'))}</span>`
79
+ }
80
+ if (imageChip) {
81
+ const ok = Boolean(app.settings?.imageReady)
82
+ imageChip.className = `status-chip ${ok ? 'is-ok' : 'is-off'}`
83
+ imageChip.innerHTML = `<i class="dot"></i><span>${esc(ok ? t('生图已连接', 'Image ready') : t('生图未配置', 'Image not set'))}</span>`
84
+ }
85
+ }
86
+
87
+ function paintRoute() {
88
+ const route = (location.hash.replace(/^#\/?/, '') || 'image').split('?')[0]
89
+ const name = ROUTES[route] ? route : 'image'
90
+ const conf = ROUTES[name]
91
+ for (const btn of document.querySelectorAll('.rail-btn[data-route]')) {
92
+ btn.classList.toggle('is-on', btn.dataset.route === name)
93
+ btn.setAttribute('aria-current', btn.dataset.route === name ? 'page' : 'false')
94
+ }
95
+ $('#top-eyebrow').textContent = 'MODERN MEDIA STUDIO'
96
+ $('#top-title').textContent = t('摩登-媒体工作室', 'Modern-MediaStudio')
97
+ $('#top-desc').textContent = t(conf.desc[0], conf.desc[1])
98
+ document.title = `${t(conf.title[0], conf.title[1])} · ${t('摩登-媒体工作室', 'Modern-MediaStudio')}`
99
+ const view = $('#view')
100
+ if (app.view?.unmount) {
101
+ try { app.view.unmount() } catch (err) { console.warn('[modern-media] unmount', err) }
102
+ }
103
+ view.replaceChildren()
104
+ app.view = conf.mod.mount(view, app) || null
105
+ app.viewName = name
106
+ updateChips()
107
+ }
108
+
109
+ async function refresh() {
110
+ const data = await api('bootstrap')
111
+ app.state = data.state
112
+ app.settings = data.settings
113
+ app.imagePresets = data.imagePresets ?? []
114
+ app.videoPresets = data.videoPresets ?? []
115
+ app.model = data.model
116
+ app.dataDir = data.dataDir
117
+ app.version = data.version
118
+ app.revision += 1
119
+ updateChips()
120
+ try { app.view?.update?.(app.state) } catch (err) { console.warn('[modern-media] update', err) }
121
+ return data
122
+ }
123
+
124
+ app.refresh = refresh
125
+
126
+ function bootToast(message, kind = 'info') {
127
+ const host = $('#toasts')
128
+ if (!host) return
129
+ const node = document.createElement('div')
130
+ node.className = `toast toast-${kind}`
131
+ node.innerHTML = `<span class="toast-ic">${icon(kind === 'error' ? 'alert' : kind === 'success' ? 'check' : 'info', 16)}</span><p>${esc(message)}</p><button class="toast-x" aria-label="关闭">${icon('x', 13)}</button>`
132
+ const close = () => {
133
+ node.classList.add('is-out')
134
+ setTimeout(() => node.remove(), 220)
135
+ }
136
+ node.querySelector('.toast-x').addEventListener('click', close)
137
+ host.appendChild(node)
138
+ setTimeout(close, kind === 'error' ? 6200 : 3600)
139
+ }
140
+
141
+ app.toast = bootToast
142
+
143
+ app.showLayer = (content, { className = '', onClose } = {}) => {
144
+ const root = $('#layer-root')
145
+ const overlay = document.createElement('div')
146
+ overlay.className = `layer ${className}`.trim()
147
+ overlay.appendChild(content)
148
+ const close = () => {
149
+ overlay.classList.add('is-out')
150
+ document.removeEventListener('keydown', onKey)
151
+ setTimeout(() => {
152
+ overlay.remove()
153
+ onClose?.()
154
+ }, 180)
155
+ }
156
+ const onKey = event => { if (event.key === 'Escape') { event.stopPropagation(); close() } }
157
+ overlay.addEventListener('pointerdown', event => { if (event.target === overlay) close() })
158
+ document.addEventListener('keydown', onKey)
159
+ root.appendChild(overlay)
160
+ requestAnimationFrame(() => overlay.classList.add('is-in'))
161
+ return close
162
+ }
163
+
164
+ app.confirm = ({ title, message, confirmText = '确定', cancelText = '取消', danger = false }) => new Promise(resolve => {
165
+ const card = document.createElement('div')
166
+ card.className = 'dialog'
167
+ card.innerHTML = `
168
+ <h3>${esc(title)}</h3>
169
+ <p>${esc(message)}</p>
170
+ <div class="dialog-actions">
171
+ <button class="btn btn-ghost" data-act="cancel">${esc(cancelText)}</button>
172
+ <button class="btn ${danger ? 'btn-danger' : 'btn-primary'}" data-act="ok">${esc(confirmText)}</button>
173
+ </div>`
174
+ let close = () => {}
175
+ const finish = value => { close(); resolve(value) }
176
+ card.querySelector('[data-act="cancel"]').addEventListener('click', () => finish(false))
177
+ card.querySelector('[data-act="ok"]').addEventListener('click', () => finish(true))
178
+ close = app.showLayer(card, { className: 'layer-dialog', onClose: () => resolve(false) })
179
+ card.querySelector('[data-act="ok"]').focus()
180
+ })
181
+
182
+ app.navigate = route => { location.hash = `#/${route}` }
183
+
184
+ function wire() {
185
+ document.addEventListener('click', event => {
186
+ const nav = event.target.closest('.rail-btn[data-route]')
187
+ if (nav) {
188
+ app.navigate(nav.dataset.route)
189
+ return
190
+ }
191
+ if (event.target.closest('#refresh-btn')) {
192
+ refresh().then(() => bootToast(t('已刷新', 'Refreshed'), 'success')).catch(err => bootToast(err.message, 'error'))
193
+ return
194
+ }
195
+ if (event.target.closest('#image-chip')) {
196
+ app.navigate('settings')
197
+ return
198
+ }
199
+ if (event.target.closest('#model-chip')) {
200
+ app.openSettings()
201
+ return
202
+ }
203
+ if (event.target.closest('#theme-toggle')) {
204
+ const next = app.theme === 'dark' ? 'light' : 'dark'
205
+ localStorage.setItem('media-workbench-modern:theme', next)
206
+ applyTheme(next)
207
+ }
208
+ })
209
+
210
+ window.addEventListener('hashchange', () => {
211
+ if (!app.state) return
212
+ paintRoute()
213
+ app.view?.update?.(app.state)
214
+ })
215
+
216
+ window.addEventListener('message', event => {
217
+ if (event.origin !== location.origin) return
218
+ const data = event.data
219
+ if (!data || typeof data !== 'object') return
220
+ if (data.type === 'media-workbench-modern:theme' && (data.scheme === 'dark' || data.scheme === 'light')) {
221
+ if (!localStorage.getItem('media-workbench-modern:theme')) applyTheme(data.scheme)
222
+ }
223
+ if (data.type === 'media-workbench-modern:locale' && data.lang !== app.lang && (data.lang === 'zh' || data.lang === 'en')) {
224
+ app.lang = data.lang
225
+ document.documentElement.lang = app.lang === 'en' ? 'en' : 'zh-CN'
226
+ renderChrome()
227
+ paintRoute()
228
+ app.view?.update?.(app.state)
229
+ }
230
+ })
231
+
232
+ document.addEventListener('keydown', event => {
233
+ if ((event.metaKey || event.ctrlKey) && event.key === ',') {
234
+ event.preventDefault()
235
+ app.navigate('settings')
236
+ }
237
+ })
238
+
239
+ // 轮询只用来推进异步任务;页面隐藏时暂停
240
+ setInterval(() => {
241
+ if (document.hidden || !app.state || app.polling) return
242
+ const hasRunning = (app.state.images ?? []).some(row => row.status === 'queued' || row.status === 'running')
243
+ || (app.state.videos ?? []).some(row => row.status === 'queued' || row.status === 'running')
244
+ || (app.state.graphs ?? []).some(graph => graph.run?.status === 'running')
245
+ if (!hasRunning) return
246
+ app.polling = true
247
+ refresh().catch(() => {}).finally(() => { app.polling = false })
248
+ }, 2200)
249
+ }
250
+
251
+ async function boot() {
252
+ renderChrome()
253
+ applyTheme(theme)
254
+ wire()
255
+ $('#refresh-btn').title = `${t('刷新', 'Refresh')} (${modKey()}+R)`
256
+ try {
257
+ await refresh()
258
+ } catch (err) {
259
+ $('#view').innerHTML = `<div class="fatal"><h2>${esc(t('工作台没有启动', 'Studio failed to start'))}</h2><p>${esc(err.message)}</p><p class="fine">${esc(t('确认插件已启用,然后刷新页面。', 'Check the plugin is enabled, then reload.'))}</p></div>`
260
+ return
261
+ }
262
+ paintRoute()
263
+ app.view?.update?.(app.state)
264
+ }
265
+
266
+ boot()
267
+
268
+ export { refresh }
@@ -0,0 +1,12 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
2
+ <defs>
3
+ <linearGradient id="g" x1="0" y1="0" x2="1" y2="1">
4
+ <stop offset="0" stop-color="#d97757"/>
5
+ <stop offset="1" stop-color="#e3a078"/>
6
+ </linearGradient>
7
+ </defs>
8
+ <rect width="64" height="64" rx="16" fill="#1e1a16"/>
9
+ <path d="M32 12c1.8 9.8 6.4 14.4 16.2 16.2C38.4 30.2 33.8 34.8 32 44.6 30.2 34.8 25.6 30.2 15.8 28.2 25.6 26.4 30.2 21.8 32 12z" fill="url(#g)"/>
10
+ <circle cx="46.5" cy="47.5" r="5" fill="#e3a078" opacity=".65"/>
11
+ <circle cx="18" cy="46" r="3" fill="#d97757" opacity=".55"/>
12
+ </svg>
package/web/index.html ADDED
@@ -0,0 +1,16 @@
1
+ <!doctype html>
2
+ <html lang="zh-CN" data-theme="light">
3
+ <head>
4
+ <meta charset="utf-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
6
+ <meta name="theme-color" content="#f6f3ee">
7
+ <title>摩登-媒体工作室</title>
8
+ <link rel="icon" href="./favicon.svg" type="image/svg+xml">
9
+ <link rel="stylesheet" href="./styles.css">
10
+ </head>
11
+ <body>
12
+ <div class="boot" id="boot"><p>正在打开摩登-媒体工作室…</p></div>
13
+ <script type="module" src="./app.js"></script>
14
+ <noscript>这个工作台需要 JavaScript。</noscript>
15
+ </body>
16
+ </html>