@exccoder/dsh-media-workbench 0.1.2 → 0.2.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,32 @@
2
2
 
3
3
  All notable changes to this project are documented here. The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and the project uses [Semantic Versioning](https://semver.org/).
4
4
 
5
+ ## [0.2.2] - 2026-11-09
6
+
7
+ Image workflow and storage update.
8
+
9
+ ### Added
10
+
11
+ - Reference queue for Image Studio: up to 8 images, Ctrl+V paste, multi-file drop/select, per-image up/down reorder and removal, with「图一 / 图二」labels.
12
+ - Multi-reference image edits use ordered `image[]` fields; single references keep the existing `image` field.
13
+ - Reuse now carries the original reference images. If the source image had references, a confirmation asks whether to replace the current queue or reuse the prompt only.
14
+ - Custom **Library folder**: image/video files can move to another drive. Saving migrates existing files, removes them from the old folder, and rejects invalid, unwritable, nested, or name-conflicting targets.
15
+ - Image right-click menu: copy image to clipboard, download original, copy prompt, and open the containing folder (file is selected in Explorer/Finder).
16
+ - Settings cleanup: removed the read-only workbench data card; maintenance actions now live under「维护」.
17
+
18
+ ### Fixed
19
+
20
+ - Workbench height now fills the full DSH panel, including the bottom of the rail and composer.
21
+ - Settings copy now distinguishes workbench records from the actual media library folder.
22
+
23
+ ## [0.1.4] - 2026-11-07
24
+
25
+ Fixed video downloads that returned 401 from gateways which require the same Bearer token on the media URL. The downloader now retries the provider URL with authentication and falls back to the configured content endpoint.
26
+
27
+ ## [0.1.3] - 2026-11-07
28
+
29
+ Removed external inspiration attribution and the legacy data-directory migration from earlier prototypes. The four workbenches are unchanged.
30
+
5
31
  ## [0.1.2] - 2026-11-07
6
32
 
7
33
  First npm publication as **@exccoder/dsh-media-workbench**. The package was renamed from the `@modern` scope before publishing; there are no functional changes since 0.1.1. The GitHub `v0.1.1` release is superseded by `v0.1.2`.
@@ -13,7 +39,6 @@ Initial GitHub release under `@modern/dsh-media-workbench`; not published to npm
13
39
  ### Added
14
40
 
15
41
  - Package name `@modern/dsh-media-workbench`, sidebar title「摩登-媒体工作室」with the English label `Modern-MediaStudio`, following the DSH locale.
16
- - One-time migration from the earlier prototype directory `$DSH_HOME/levelup-workbench/` into `$DSH_HOME/media-workbench-modern/`; the old directory is kept as a backup.
17
42
  - **Image Studio**: prompt, negative prompt, nine style presets, six aspect ratios, 1–4 images, reference-image editing through `/images/edits`, favorites, search, history, lightbox, retry/download/reuse.
18
43
  - **Writing Studio**: local document library, autosave, Markdown export, selection-aware continue/polish/expand/rewrite/outline/title actions, streaming output with stop/apply/discard.
19
44
  - **Star Map**: typed node graph (prompt, writing, image, video, preview, note), drag-to-connect ports, type/single-input/cycle validation, topological parallel execution, run-to-node, undo/redo, templates, user blueprints, JSON import/export, responsive inspector.
package/README.en.md CHANGED
@@ -9,8 +9,6 @@ A [DeepSeek Harness](https://github.com/deepseek-ai/deepseek-harness) (DSH) plug
9
9
 
10
10
  Writing uses DSH's currently selected model. Image and video providers are configured separately. All works, images, videos and Star Maps stay on this machine. The UI is available in Chinese and English and follows DSH's language and light/dark theme.
11
11
 
12
- Inspired by the creative space in [LevelUpAgent](https://github.com/TippingGame/LevelUpAgent); this is an original implementation for DSH's plugin system.
13
-
14
12
  [中文说明](README.md)
15
13
 
16
14
  ## Install
@@ -62,7 +60,6 @@ No extra key. Writing uses the model selected in DSH. The plugin does not store
62
60
  - `state.json` — images, videos, documents, Star Maps, blueprints
63
61
  - `settings.json` — provider endpoints, model names and keys, mode `600`
64
62
  - `files/` — generated media and uploaded references
65
- - If an earlier build used the old directory `~/.dsh/levelup-workbench/`, its settings, works and files are copied once into the new directory on first start. The old directory is kept as a backup and can be deleted after verification.
66
63
  - Provider keys never reach the browser; the UI only receives `hasKey` and the last four characters.
67
64
  - Write routes validate the DSH connection service and same-origin headers. Static files are whitelisted. Generated files use a strict name pattern.
68
65
  - Remote image/video URLs are checked before download; loopback, private, link-local and metadata addresses are rejected.
package/README.md CHANGED
@@ -2,15 +2,13 @@
2
2
 
3
3
  一个 DeepSeek Harness(DSH)插件。装上之后,DSH 侧栏会多一个「摩登-媒体工作室」,里面是四个连在一起的工作台:
4
4
 
5
- - **生图工作台**:一句话出图,风格、画幅、数量、参考图、收藏与历史。
5
+ - **生图工作台**:一句话出图,风格、画幅、数量、最多 8 张参考图队列、收藏与历史。
6
6
  - **视频工作台**:文生视频、图生视频,以及不消耗视频额度的「分镜短片」。
7
7
  - **写作工作台**:作品库、长文编辑器、续写/润色/扩写/改写/大纲/起标题,流式生成。
8
8
  - **星图工作台**:把提示词、写作、图像、视频接成 DAG,逐波并行运行,可存成蓝图复用。
9
9
 
10
10
  写作走 DSH 当前默认模型;生图和视频各自配置自己的接口。作品、图片、视频、星图都留在本机,不上传云端。界面支持中文 / English,并跟随 DSH 的语言和明暗主题。
11
11
 
12
- 灵感来自 [LevelUpAgent](https://github.com/TippingGame/LevelUpAgent) 的创作空间,按 DSH 的插件体系重新实现。
13
-
14
12
  GitHub:[ExcCoder/modern-dsh-media-workbench](https://github.com/ExcCoder/modern-dsh-media-workbench)
15
13
 
16
14
  ## 界面
@@ -51,7 +49,9 @@ dsh plugin --profile desktop add /absolute/path/to/dsh-media-workbench
51
49
  2. 填接口地址、模型和密钥,保存,点「试画一张」确认能通。
52
50
  3. 回生图工作台写提示词,选风格和画幅,生成。
53
51
 
54
- 任何兼容 OpenAI Images API 的服务都能用:`POST {baseUrl}/images/generations`;上传参考图时走 `POST {baseUrl}/images/edits`(multipart,字段 `image`)。返回 `data[0].b64_json`、`images[0].url` 两种常见形状都支持。
52
+ 任何兼容 OpenAI Images API 的服务都能用:`POST {baseUrl}/images/generations`;上传参考图时走 `POST {baseUrl}/images/edits`(multipart,单张字段 `image`,多张按顺序用 `image[]`)。返回 `data[0].b64_json`、`images[0].url` 两种常见形状都支持。
53
+
54
+ 参考图支持拖入、文件多选和 **Ctrl+V 粘贴**,最多 8 张,按「图一、图二…」编号,可以上移/下移。图片右键可以复制图片到剪贴板、下载原图、复制提示词或打开所在目录;复用带参考图的记录时会询问是覆盖当前参考图,还是只复用提示词。
55
55
 
56
56
  ### 视频
57
57
 
@@ -92,11 +92,10 @@ dsh plugin --profile desktop add /absolute/path/to/dsh-media-workbench
92
92
 
93
93
  ## 数据与安全
94
94
 
95
- - 数据目录:`$DSH_HOME/media-workbench-modern/`(默认 `~/.dsh/media-workbench-modern/`)
95
+ - 数据目录:`$DSH_HOME/media-workbench-modern/`(默认 `~/.dsh/media-workbench-modern/`)。图片和视频的文件主目录可以在「设置 → 作品文件目录」换到其他盘,保存时会迁移已有文件并删除旧文件;作品记录和设置仍留在默认数据目录。
96
96
  - `state.json`:图片/视频记录、写作文档、星图、蓝图
97
97
  - `settings.json`:接口地址、模型、密钥,权限 `600`
98
98
  - `files/`:生成的图片、视频和上传的参考图
99
- - 如果之前装过旧名字的版本,第一次启动会把 `~/.dsh/levelup-workbench/` 里的设置、作品和素材复制一次到新目录;旧目录保留作备份,确认无误后可自行删除。
100
99
  - 密钥只存在本机,返回浏览器时只给「有没有密钥」和尾四位。
101
100
  - 所有写接口校验同源与 DSH 登录凭据;静态文件只服务 `web/` 白名单;文件下载只接受 `files/` 下的安全文件名。
102
101
  - 下载远端图片/视频前会拒绝本机、内网和云元数据地址,避免 SSRF。
@@ -135,8 +134,6 @@ tools/ 本地预览和离线验证脚本
135
134
  - 发布到 npm / GitHub 的步骤:[PUBLISHING.md](PUBLISHING.md)。
136
135
  - npm 包名 `@exccoder/dsh-media-workbench` 使用 npm 账号 `exccoder` 的用户 scope,发布不需要组织权限;改用其他 scope 时需要同步四处包名,见 [PUBLISHING.md](PUBLISHING.md)。
137
136
 
138
- 本项目的代码是原创实现;产品灵感来自 LevelUpAgent 的创作空间,未复制其源码或素材。
139
-
140
137
  ## 许可
141
138
 
142
139
  MIT。与 DeepSeek 官方无关;DSH 的名称和标识归其所有者。
package/lib/client.js CHANGED
@@ -47,21 +47,49 @@ window.__ModuleLoader__.load({
47
47
  const ref = React.useRef(null)
48
48
  const [src] = React.useState(() => `${SRC}?lang=${lang}&theme=${scheme}`)
49
49
  React.useEffect(() => {
50
- const frame = ref.current
51
- if (frame) frames.add(frame)
52
- return () => { if (frame) frames.delete(frame) }
50
+ const host = ref.current
51
+ if (!host) return
52
+ const frame = document.createElement('iframe')
53
+ frame.src = src
54
+ frame.title = TITLE[lang]
55
+ frame.allow = 'clipboard-write; fullscreen'
56
+ frame.style.border = '0'
57
+ frame.style.display = 'block'
58
+ frame.style.background = 'transparent'
59
+ frame.style.position = 'fixed'
60
+ frame.style.zIndex = '1'
61
+ frame.addEventListener('load', () => {
62
+ pushTheme(frame)
63
+ pushLocale(frame)
64
+ })
65
+ document.body.appendChild(frame)
66
+ frames.add(frame)
67
+ const fit = () => {
68
+ const box = host.getBoundingClientRect()
69
+ if (box.width < 200 || box.height < 80) {
70
+ frame.style.display = 'none'
71
+ return
72
+ }
73
+ frame.style.display = 'block'
74
+ frame.style.top = `${box.top}px`
75
+ frame.style.left = `${box.left}px`
76
+ frame.style.width = `${box.width}px`
77
+ frame.style.height = `${Math.max(200, window.innerHeight - box.top)}px`
78
+ }
79
+ requestAnimationFrame(fit)
80
+ const timer = setTimeout(fit, 80)
81
+ const observer = new ResizeObserver(fit)
82
+ observer.observe(host)
83
+ window.addEventListener('resize', fit)
84
+ return () => {
85
+ clearTimeout(timer)
86
+ observer.disconnect()
87
+ window.removeEventListener('resize', fit)
88
+ frames.delete(frame)
89
+ frame.remove()
90
+ }
53
91
  }, [])
54
- return h('iframe', {
55
- ref,
56
- src,
57
- title: TITLE[lang],
58
- allow: 'clipboard-write; fullscreen',
59
- onLoad: event => {
60
- pushTheme(event.currentTarget)
61
- pushLocale(event.currentTarget)
62
- },
63
- style: { width: '100%', height: '100%', border: 0, display: 'block', background: 'transparent' },
64
- })
92
+ return h('div', { ref, style: { width: '100%', height: '100%', minHeight: '100%' } })
65
93
  }
66
94
 
67
95
  function apply(ctx) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@exccoder/dsh-media-workbench",
3
- "version": "0.1.2",
3
+ "version": "0.2.2",
4
4
  "description": "DeepSeek Harness 插件「摩登-媒体工作室」:生图、写作、星图、视频四个工作台,带本机作品库与星图 DAG 编排。",
5
5
  "type": "module",
6
6
  "main": "src/index.js",
package/src/image.js CHANGED
@@ -66,7 +66,7 @@ function extraOf(settings) {
66
66
  /**
67
67
  * 生成一张图。
68
68
  * @param {object} settings 插件设置
69
- * @param {object} request {prompt, style, negative, size, quality, reference, model}
69
+ * @param {object} request {prompt, style, negative, size, quality, reference, references, model}
70
70
  * @param {{signal?: AbortSignal, filesDir?: string}} options
71
71
  * @returns {Promise<{buffer: Buffer, ext: string, width: number, height: number, revisedPrompt?: string}>}
72
72
  */
@@ -83,15 +83,24 @@ export async function generateImage(settings, request, { signal, filesDir } = {}
83
83
  const combined = signal ? AbortSignal.any([signal, timeout]) : timeout
84
84
  let result
85
85
 
86
- if (request.reference && filesDir) {
87
- const file = join(filesDir, String(request.reference).replace(/[^\w.-]/g, ''))
88
- let buffer
89
- try {
90
- buffer = await readFile(file)
91
- } catch {
92
- throw Object.assign(new Error('参考图不存在,重新上传一次'), { status: 400 })
86
+ const references = (Array.isArray(request.references) ? request.references : request.reference ? [request.reference] : [])
87
+ .map(name => String(name ?? '').replace(/[^\w.-]/g, ''))
88
+ .filter(Boolean)
89
+ .slice(0, 8)
90
+
91
+ if (references.length && filesDir) {
92
+ const images = []
93
+ for (const name of references) {
94
+ const file = join(filesDir, name)
95
+ let buffer
96
+ try {
97
+ buffer = await readFile(file)
98
+ } catch {
99
+ throw Object.assign(new Error(`参考图不存在:${name},重新上传一次`), { status: 400 })
100
+ }
101
+ const type = extname(file).toLowerCase() === '.jpg' ? 'image/jpeg' : extname(file).toLowerCase() === '.webp' ? 'image/webp' : 'image/png'
102
+ images.push({ buffer, type, ext: extname(file) || '.png', name })
93
103
  }
94
- const type = extname(file).toLowerCase() === '.jpg' ? 'image/jpeg' : extname(file).toLowerCase() === '.webp' ? 'image/webp' : 'image/png'
95
104
  const form = new FormData()
96
105
  form.set('model', model)
97
106
  form.set('prompt', prompt)
@@ -99,7 +108,11 @@ export async function generateImage(settings, request, { signal, filesDir } = {}
99
108
  if (request.size) form.set('size', request.size)
100
109
  if (request.quality) form.set('quality', request.quality)
101
110
  for (const [k, v] of Object.entries(extra)) form.set(k, typeof v === 'string' ? v : JSON.stringify(v))
102
- form.set('image', new Blob([buffer], { type }), `reference${extname(file) || '.png'}`)
111
+ if (images.length === 1) {
112
+ form.set('image', new Blob([images[0].buffer], { type: images[0].type }), `reference${images[0].ext}`)
113
+ } else {
114
+ images.forEach((image, index) => form.append('image[]', new Blob([image.buffer], { type: image.type }), `reference-${index + 1}${image.ext}`))
115
+ }
103
116
  const res = await fetch(`${settings.image.baseUrl}/images/edits`, {
104
117
  method: 'POST',
105
118
  headers: { authorization: `Bearer ${key}` },
package/src/index.js CHANGED
@@ -1,14 +1,16 @@
1
1
  // @exccoder/dsh-media-workbench · host 入口
2
2
  // 四个工作台共用一套本机存储:生图 / 视频 / 写作 / 星图
3
- import { writeFile } from 'node:fs/promises'
4
- import { dirname, join } from 'node:path'
3
+ import { constants } from 'node:fs'
4
+ import { spawn } from 'node:child_process'
5
+ import { copyFile, mkdir, readdir, stat, unlink, writeFile } from 'node:fs/promises'
6
+ import { dirname, isAbsolute, join, relative, resolve, sep } from 'node:path'
5
7
  import { fileURLToPath } from 'node:url'
6
8
  import { createNode, dependencyClosure, normalizeGraph, RATIOS, templateGraph, topoWaves } from './graph.js'
7
9
  import { apply as applyTools } from './tools.js'
8
10
  import { generateImage } from './image.js'
9
11
  import { modelInfo, streamText } from './llm.js'
10
12
  import { imageReady, IMAGE_PRESETS, publicSettings, Settings, VIDEO_PRESETS, videoReady } from './settings.js'
11
- import { Store, migrateLegacyData } from './store.js'
13
+ import { Store } from './store.js'
12
14
  import { clampInt, extensionOf, fail, imageSize, json, newId, readBuffer, readJson, safeJoin, sendFile, videoExtensionOf } from './util.js'
13
15
  import { generateStoryboard, generateVideo } from './video.js'
14
16
  import { cleanOutput, writingPrompts } from './writing.js'
@@ -16,7 +18,7 @@ import { cleanOutput, writingPrompts } from './writing.js'
16
18
  const ROOT = dirname(fileURLToPath(import.meta.url))
17
19
  const WEB_DIR = join(ROOT, '..', 'web')
18
20
  const BASE = '/media-workbench-modern'
19
- const VERSION = '0.1.2'
21
+ const VERSION = '0.2.2'
20
22
 
21
23
  const STATIC = new Map([
22
24
  ['', 'index.html'],
@@ -55,11 +57,18 @@ export function apply(ctx) {
55
57
  const graphJobs = new Map()
56
58
  let settled = false
57
59
 
58
- // 第一次改名后,把旧目录的数据复制一次;不阻塞页面静态资源
59
- const ready = migrateLegacyData(store.root).catch(err => {
60
- console.warn('[media-workbench-modern] legacy data migration failed:', err.message)
61
- return false
62
- })
60
+ const defaultFilesDir = join(store.root, 'files')
61
+ const ready = (async () => {
62
+ const current = await settings.read()
63
+ const outputDir = current.storage?.outputDir?.trim()
64
+ if (!outputDir) { await store.useFilesDir(defaultFilesDir); return }
65
+ try {
66
+ await store.useFilesDir(outputDir)
67
+ } catch (err) {
68
+ console.warn('[media-workbench-modern] custom library folder unavailable:', err.message)
69
+ await store.useFilesDir(defaultFilesDir)
70
+ }
71
+ })()
63
72
 
64
73
  const settleOnce = async () => {
65
74
  await ready
@@ -70,10 +79,60 @@ export function apply(ctx) {
70
79
 
71
80
  const saveImageBuffer = async (id, result) => {
72
81
  const file = `${id}${result.ext}`
73
- await writeFile(join(store.filesDir, file), result.buffer)
82
+ await saveOutput(file, result.buffer)
74
83
  return file
75
84
  }
76
85
 
86
+ async function saveOutput(name, buffer) {
87
+ await writeFile(join(store.filesDir, name), buffer)
88
+ }
89
+
90
+ async function fileExists(path) {
91
+ try { await stat(path); return true } catch { return false }
92
+ }
93
+
94
+ async function switchFilesDir(target) {
95
+ const from = resolve(store.filesDir)
96
+ const to = resolve(target || defaultFilesDir)
97
+ if (to === from) { await store.useFilesDir(to); return }
98
+ if (to.startsWith(from + sep)) throw fail('作品目录不能选在当前作品库里面', 400)
99
+ await mkdir(to, { recursive: true })
100
+ let entries = []
101
+ try { entries = await readdir(from, { withFileTypes: true }) } catch (err) {
102
+ if (err.code !== 'ENOENT') throw err
103
+ }
104
+ const moved = []
105
+ try {
106
+ for (const entry of entries) {
107
+ if (!entry.isFile()) continue
108
+ const source = join(from, entry.name)
109
+ const destination = join(to, entry.name)
110
+ let created = true
111
+ try {
112
+ await copyFile(source, destination, constants.COPYFILE_EXCL)
113
+ } catch (err) {
114
+ if (err.code === 'EEXIST') throw fail(`目标目录已有同名文件:${entry.name},请换一个空目录`, 400)
115
+ throw err
116
+ }
117
+ await unlink(source)
118
+ moved.push({ name: entry.name, created })
119
+ }
120
+ } catch (err) {
121
+ for (const item of moved.reverse()) {
122
+ const source = join(from, item.name)
123
+ const destination = join(to, item.name)
124
+ try {
125
+ if (!(await fileExists(source)) && await fileExists(destination)) await copyFile(destination, source)
126
+ if (item.created) await unlink(destination).catch(() => {})
127
+ } catch (rollbackError) {
128
+ console.warn('[media-workbench-modern] file migration rollback failed:', rollbackError.message)
129
+ }
130
+ }
131
+ throw err
132
+ }
133
+ await store.useFilesDir(to)
134
+ }
135
+
77
136
  const updateImage = (id, patch) => store.mutate(state => {
78
137
  const row = state.images.find(item => item.id === id)
79
138
  if (!row) return null
@@ -115,6 +174,10 @@ export function apply(ctx) {
115
174
  const ratio = RATIOS[body.ratio]
116
175
  const size = String(body.size ?? '').trim() || ratio?.size || current.image.size
117
176
  const reference = typeof body.reference === 'string' ? body.reference.replace(/[^\w.-]/g, '') : ''
177
+ const references = (Array.isArray(body.references) ? body.references : reference ? [reference] : [])
178
+ .map(name => String(name ?? '').replace(/[^\w.-]/g, ''))
179
+ .filter(Boolean)
180
+ .slice(0, 8)
118
181
  const records = []
119
182
  for (let i = 0; i < count; i++) {
120
183
  records.push({
@@ -124,7 +187,8 @@ export function apply(ctx) {
124
187
  style: String(body.style ?? '').slice(0, 40),
125
188
  model: String(body.model ?? current.image.model).slice(0, 120),
126
189
  size,
127
- reference,
190
+ reference: references[0] ?? '',
191
+ references,
128
192
  file: '',
129
193
  width: 0,
130
194
  height: 0,
@@ -193,7 +257,7 @@ export function apply(ctx) {
193
257
  const frames = []
194
258
  for (const [index, frame] of result.frames.entries()) {
195
259
  const name = `vid_${id}_${index + 1}${frame.ext}`
196
- await writeFile(join(store.filesDir, name), frame.buffer)
260
+ await saveOutput(name, frame.buffer)
197
261
  frames.push(name)
198
262
  }
199
263
  await patch({
@@ -210,7 +274,7 @@ export function apply(ctx) {
210
274
  onProgress: progress => { void patch({ progress }) },
211
275
  })
212
276
  const name = `vid_${id}${result.ext || videoExtensionOf(result.buffer)}`
213
- await writeFile(join(store.filesDir, name), result.buffer)
277
+ await saveOutput(name, result.buffer)
214
278
  await patch({ status: 'done', progress: 100, file: name })
215
279
  }
216
280
  } catch (err) {
@@ -423,7 +487,7 @@ export function apply(ctx) {
423
487
  reference: reference?.kind === 'image' ? reference.file : undefined,
424
488
  }, { signal: controller.signal, filesDir: store.filesDir })
425
489
  const name = `node_${node.id}_${Date.now().toString(36)}${result.ext}`
426
- await writeFile(join(store.filesDir, name), result.buffer)
490
+ await saveOutput(name, result.buffer)
427
491
  return { kind: 'image', file: name, width: result.width, height: result.height }
428
492
  }
429
493
  if (node.type === 'video') {
@@ -446,7 +510,7 @@ export function apply(ctx) {
446
510
  const frames = []
447
511
  for (const [index, frame] of result.frames.entries()) {
448
512
  const name = `node_${node.id}_${index + 1}${frame.ext}`
449
- await writeFile(join(store.filesDir, name), frame.buffer)
513
+ await saveOutput(name, frame.buffer)
450
514
  frames.push(name)
451
515
  }
452
516
  return { kind: 'video', file: '', frames, poster: frames[0] ?? '', storyboard: true }
@@ -454,7 +518,7 @@ export function apply(ctx) {
454
518
  if (!videoReady(appSettings)) throw new Error('未配置视频服务,可把节点模式改成「分镜」')
455
519
  const result = await generateVideo(appSettings, record, { signal: controller.signal, filesDir: store.filesDir })
456
520
  const name = `node_${node.id}_${Date.now().toString(36)}${result.ext || videoExtensionOf(result.buffer)}`
457
- await writeFile(join(store.filesDir, name), result.buffer)
521
+ await saveOutput(name, result.buffer)
458
522
  return { kind: 'video', file: name }
459
523
  }
460
524
  throw new Error(`不支持的节点类型:${node.type}`)
@@ -559,8 +623,40 @@ export function apply(ctx) {
559
623
 
560
624
  const body = await readJson(req)
561
625
 
626
+ if (api === 'reveal') {
627
+ const name = String(body.file ?? '').trim()
628
+ if (!/^[\w.-]+$/.test(name)) return json(res, 400, { error: '文件名无效' })
629
+ const file = join(store.filesDir, name)
630
+ const rel = relative(store.filesDir, file)
631
+ if (!rel || rel.startsWith('..') || isAbsolute(rel)) return json(res, 400, { error: '路径无效' })
632
+ try { await stat(file) } catch { return json(res, 404, { error: '文件不存在' }) }
633
+ try {
634
+ if (process.platform === 'win32') {
635
+ const child = spawn('explorer.exe', ['/select,', file], { detached: true, stdio: 'ignore' })
636
+ child.on('error', () => {})
637
+ child.unref()
638
+ } else if (process.platform === 'darwin') {
639
+ spawn('open', ['-R', file], { detached: true, stdio: 'ignore' }).unref()
640
+ } else {
641
+ spawn('xdg-open', [dirname(file)], { detached: true, stdio: 'ignore' }).unref()
642
+ }
643
+ } catch (err) {
644
+ return json(res, 500, { error: `打开目录失败:${err.message}` })
645
+ }
646
+ return json(res, 200, { ok: true })
647
+ }
562
648
  if (api === 'settings') {
649
+ const previous = await settings.read()
650
+ const changingDir = Boolean(body.storage && Object.prototype.hasOwnProperty.call(body.storage, 'outputDir'))
563
651
  const next = await settings.update(body)
652
+ if (changingDir) {
653
+ try {
654
+ await switchFilesDir(next.storage?.outputDir)
655
+ } catch (err) {
656
+ await settings.update({ storage: { outputDir: previous.storage?.outputDir ?? '' } }).catch(() => {})
657
+ throw err
658
+ }
659
+ }
564
660
  return json(res, 200, { settings: publicSettings(next) })
565
661
  }
566
662
  if (api === 'settings/test-image') {
package/src/settings.js CHANGED
@@ -1,5 +1,6 @@
1
1
  // 插件设置:接口地址、模型、密钥。密钥只留本机,返回浏览器时永远脱敏。
2
- import { chmod, readFile, rename, writeFile } from 'node:fs/promises'
2
+ import { access, chmod, mkdir, readFile, rename, unlink, writeFile } from 'node:fs/promises'
3
+ import { constants } from 'node:fs'
3
4
  import { join } from 'node:path'
4
5
 
5
6
  export const IMAGE_PRESETS = [
@@ -37,6 +38,9 @@ const DEFAULTS = {
37
38
  extra: '',
38
39
  },
39
40
  language: 'auto',
41
+ storage: {
42
+ outputDir: '',
43
+ },
40
44
  }
41
45
 
42
46
  function trim(value, max = 300) {
@@ -66,6 +70,28 @@ function normalizePath(value, fallback, max = 200) {
66
70
  return text
67
71
  }
68
72
 
73
+ function normalizeOutputDir(value) {
74
+ if (value === undefined) return undefined
75
+ if (typeof value !== 'string') {
76
+ const err = new Error('产物目录要是文本路径')
77
+ err.status = 400
78
+ throw err
79
+ }
80
+ let dir = value.trim()
81
+ if (/^[a-zA-Z]:[\\/]?$/.test(dir)) {
82
+ dir = `${dir.slice(0, 2)}\\`
83
+ } else {
84
+ dir = dir.replace(/[\\/]+$/, '')
85
+ }
86
+ if (!dir) return ''
87
+ if (!/^(?:[a-zA-Z]:[\\/]|\/)/.test(dir)) {
88
+ const err = new Error('产物目录要填绝对路径,例如 D:\\AI作品')
89
+ err.status = 400
90
+ throw err
91
+ }
92
+ return dir
93
+ }
94
+
69
95
  function normalizeExtra(value) {
70
96
  if (value === undefined) return undefined
71
97
  if (typeof value !== 'string') {
@@ -104,6 +130,7 @@ export class Settings {
104
130
  ...saved,
105
131
  image: { ...DEFAULTS.image, ...(saved.image ?? {}) },
106
132
  video: { ...DEFAULTS.video, ...(saved.video ?? {}) },
133
+ storage: { ...DEFAULTS.storage, ...(saved.storage ?? {}) },
107
134
  }
108
135
  } catch (err) {
109
136
  if (err.code !== 'ENOENT') throw err
@@ -144,6 +171,25 @@ export class Settings {
144
171
  if (typeof video.apiKey === 'string' && video.apiKey.trim()) next.video.apiKey = video.apiKey.trim().slice(0, 500)
145
172
  if (video.clearKey === true) next.video.apiKey = ''
146
173
 
174
+ const storage = patch.storage ?? {}
175
+ const outputDir = normalizeOutputDir(storage.outputDir)
176
+ if (outputDir !== undefined) {
177
+ if (outputDir) {
178
+ try {
179
+ await mkdir(outputDir, { recursive: true })
180
+ await access(outputDir, constants.W_OK)
181
+ const probe = join(outputDir, `.media-workbench-write-test-${process.pid}`)
182
+ await writeFile(probe, '')
183
+ await unlink(probe).catch(() => {})
184
+ } catch {
185
+ const err = new Error('产物目录不可写,换一个目录')
186
+ err.status = 400
187
+ throw err
188
+ }
189
+ }
190
+ next.storage.outputDir = outputDir
191
+ }
192
+
147
193
  if (['auto', 'zh', 'en'].includes(patch.language)) next.language = patch.language
148
194
 
149
195
  const tmp = `${this.file}.${process.pid}.tmp`
package/src/store.js CHANGED
@@ -1,5 +1,5 @@
1
1
  // 本机持久化:$DSH_HOME/media-workbench-modern/state.json
2
- import { chmod, cp, mkdir, readFile, rename, stat, writeFile } from 'node:fs/promises'
2
+ import { mkdir, readFile, rename, stat, writeFile } from 'node:fs/promises'
3
3
  import { homedir } from 'node:os'
4
4
  import { join } from 'node:path'
5
5
  import { templateGraph } from './graph.js'
@@ -10,48 +10,6 @@ export function dshHome(env = process.env) {
10
10
  return env.DSH_HOME || join(homedir(), '.dsh')
11
11
  }
12
12
 
13
- /** 改名前的旧目录,只用于一次性迁移 */
14
- export function legacyRoot(env = process.env) {
15
- return join(dshHome(env), 'levelup-workbench')
16
- }
17
-
18
- /**
19
- * 从旧插件目录复制一次数据(settings / state / files)。
20
- * 旧目录保留作备份;新目录已经有 state.json 时不再迁移。
21
- * @returns {Promise<boolean>} 是否执行了迁移
22
- */
23
- export async function migrateLegacyData(root, env = process.env) {
24
- const legacy = legacyRoot(env)
25
- if (root === legacy) return false
26
- try {
27
- await stat(join(root, 'state.json'))
28
- return false
29
- } catch (err) {
30
- if (err.code !== 'ENOENT') throw err
31
- }
32
- try {
33
- await stat(join(legacy, 'state.json'))
34
- } catch (err) {
35
- if (err.code === 'ENOENT') return false
36
- throw err
37
- }
38
- await mkdir(root, { recursive: true })
39
- // 先素材、再设置、最后 state:中途失败时下次还能重试
40
- for (const name of ['files', 'settings.json', 'state.json']) {
41
- const target = join(root, name)
42
- try {
43
- await stat(target)
44
- continue
45
- } catch (err) {
46
- if (err.code !== 'ENOENT') throw err
47
- }
48
- await cp(join(legacy, name), target, { recursive: true, force: false, errorOnExist: false })
49
- }
50
- await writeFile(join(root, '.migrated-from'), `${legacy}\n`, { mode: 0o600 })
51
- await chmod(join(root, 'settings.json'), 0o600).catch(() => {})
52
- return true
53
- }
54
-
55
13
  const WELCOME = `写作不是一次成形的。
56
14
 
57
15
  先把脑子里的画面写下来,不用管顺序;等它有了轮廓,再回头删掉多余的解释。留下动作、声音、光线,和人物没有说出口的那句话。
@@ -123,6 +81,9 @@ export function normalizeState(input) {
123
81
 
124
82
  function normalizeImage(row) {
125
83
  const status = ['queued', 'running', 'done', 'error'].includes(row.status) ? row.status : 'done'
84
+ const references = (Array.isArray(row.references) ? row.references : row.reference ? [row.reference] : [])
85
+ .filter(name => typeof name === 'string' && name)
86
+ .slice(0, 8)
126
87
  return {
127
88
  id: row.id,
128
89
  prompt: String(row.prompt ?? ''),
@@ -131,7 +92,8 @@ function normalizeImage(row) {
131
92
  model: String(row.model ?? ''),
132
93
  provider: String(row.provider ?? ''),
133
94
  size: String(row.size ?? ''),
134
- reference: String(row.reference ?? ''),
95
+ reference: String(row.reference ?? references[0] ?? ''),
96
+ references,
135
97
  file: String(row.file ?? ''),
136
98
  width: Number(row.width) || 0,
137
99
  height: Number(row.height) || 0,
@@ -184,6 +146,11 @@ export class Store {
184
146
  this.queue = Promise.resolve()
185
147
  }
186
148
 
149
+ async useFilesDir(dir) {
150
+ this.filesDir = dir
151
+ await mkdir(dir, { recursive: true })
152
+ }
153
+
187
154
  async refresh() {
188
155
  await mkdir(this.filesDir, { recursive: true })
189
156
  let info = null
package/src/video.js CHANGED
@@ -64,6 +64,25 @@ function progressOf(data) {
64
64
  return Math.max(0, Math.min(100, n <= 1 ? Math.round(n * 100) : Math.round(n)))
65
65
  }
66
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
+
67
86
  async function fetchVideoContent(settings, key, id, signal) {
68
87
  const path = settings.video.contentPath.replace('{id}', encodeURIComponent(id))
69
88
  const res = await fetch(`${settings.video.baseUrl}${path}`, { headers: { authorization: `Bearer ${key}` }, signal })
@@ -164,9 +183,16 @@ export async function generateVideo(settings, record, { signal, filesDir, onProg
164
183
  const url = findUrl(final)
165
184
  if (url) {
166
185
  const remote = assertRemoteUrl(url)
167
- const download = await fetch(remote, { signal: combined })
168
- if (!download.ok) throw new Error(`下载视频失败(${download.status})`)
169
- buffer = Buffer.from(await download.arrayBuffer())
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
+ }
170
196
  } else if (id && settings.video.contentPath) {
171
197
  buffer = await fetchVideoContent(settings, key, id, combined)
172
198
  }
package/web/lib/dom.js CHANGED
@@ -78,6 +78,33 @@ export async function copyText(text) {
78
78
  }
79
79
  }
80
80
 
81
+ async function blobToPng(blob) {
82
+ if (blob.type === 'image/png') return blob
83
+ const bitmap = await createImageBitmap(blob)
84
+ try {
85
+ const canvas = document.createElement('canvas')
86
+ canvas.width = bitmap.width
87
+ canvas.height = bitmap.height
88
+ const context = canvas.getContext('2d')
89
+ if (!context) throw new Error('Canvas is unavailable')
90
+ context.drawImage(bitmap, 0, 0)
91
+ return await new Promise((resolve, reject) => {
92
+ canvas.toBlob(next => next ? resolve(next) : reject(new Error('Could not encode PNG')), 'image/png')
93
+ })
94
+ } finally {
95
+ bitmap.close?.()
96
+ }
97
+ }
98
+
99
+ export async function copyImage(url) {
100
+ if (!navigator.clipboard?.write || typeof ClipboardItem === 'undefined') throw new Error('此环境不支持复制图片')
101
+ const response = await fetch(new URL(url, location.href), { credentials: 'same-origin' })
102
+ if (!response.ok) throw new Error(`图片读取失败(${response.status})`)
103
+ const png = await blobToPng(await response.blob())
104
+ await navigator.clipboard.write([new ClipboardItem({ 'image/png': png })])
105
+ return true
106
+ }
107
+
81
108
  export function downloadJson(data, filename) {
82
109
  const blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' })
83
110
  const url = URL.createObjectURL(blob)
package/web/styles.css CHANGED
@@ -104,9 +104,11 @@ pre { font-family: var(--mono); }
104
104
 
105
105
  /* ---------- 壳 ---------- */
106
106
  .app-shell {
107
- height: 100vh;
107
+ height: 100%;
108
+ min-height: 100vh;
108
109
  display: grid;
109
110
  grid-template-columns: var(--rail) minmax(0, 1fr);
111
+ grid-template-rows: minmax(0, 1fr);
110
112
  background: var(--bg);
111
113
  }
112
114
 
@@ -165,7 +167,14 @@ pre { font-family: var(--mono); }
165
167
  }
166
168
  .rail-btn { position: relative; }
167
169
 
168
- .rail-foot { margin-top: auto; display: flex; flex-direction: column; gap: 2px; }
170
+ .rail-foot {
171
+ margin-top: auto;
172
+ padding-top: 10px;
173
+ border-top: 1px solid var(--line);
174
+ display: flex;
175
+ flex-direction: column;
176
+ gap: 2px;
177
+ }
169
178
  .rail-theme { min-height: 38px; }
170
179
 
171
180
  .workspace {
@@ -434,6 +443,15 @@ textarea { resize: vertical; line-height: 1.6; min-height: 44px; }
434
443
  overflow: hidden;
435
444
  }
436
445
  .drop:hover, .drop.is-over { border-color: var(--accent); background: var(--accent-soft); }
446
+ .ref-drop { display: block; padding: 8px; }
447
+ .ref-list { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 6px; width: 100%; }
448
+ .ref-item { position: relative; margin: 0; aspect-ratio: 1; min-width: 0; overflow: hidden; border-radius: 9px; background: var(--surface-3); }
449
+ .ref-item img { width: 100%; height: 100%; object-fit: cover; }
450
+ .ref-badge { position: absolute; left: 5px; top: 5px; padding: 2px 5px; border-radius: 5px; background: rgba(20, 16, 12, 0.65); color: #fff; font-size: 9px; line-height: 1.35; pointer-events: none; }
451
+ .ref-tools { position: absolute; right: 4px; bottom: 4px; display: flex; gap: 2px; }
452
+ .ref-tools button { width: 20px; height: 20px; border-radius: 5px; background: rgba(20, 16, 12, 0.62); color: #fff; display: grid; place-items: center; font-size: 11px; line-height: 1; }
453
+ .ref-tools button:hover { background: rgba(20, 16, 12, 0.85); }
454
+ .ref-tools button:disabled { opacity: 0.3; }
437
455
  .drop-empty { display: flex; flex-direction: column; align-items: center; gap: 5px; color: var(--muted); font-size: 11.5px; text-align: center; padding: 14px; }
438
456
  .drop-empty small { font-size: 10.5px; opacity: 0.8; }
439
457
  .drop-preview { position: absolute; inset: 0; }
@@ -616,6 +634,35 @@ textarea { resize: vertical; line-height: 1.6; min-height: 44px; }
616
634
  .lb-meta b { font-weight: 550; color: var(--ink-2); text-align: right; max-width: 65%; overflow: hidden; text-overflow: ellipsis; }
617
635
  .lb-actions { display: grid; grid-template-columns: 1fr 1fr; gap: 7px; margin-top: auto; }
618
636
  .lb-actions .btn { height: 34px; font-size: 12px; }
637
+
638
+ .image-menu {
639
+ position: fixed;
640
+ z-index: 1200;
641
+ min-width: 184px;
642
+ padding: 5px;
643
+ border: 1px solid var(--line);
644
+ border-radius: 10px;
645
+ background: var(--surface);
646
+ box-shadow: var(--shadow);
647
+ display: flex;
648
+ flex-direction: column;
649
+ gap: 1px;
650
+ }
651
+ .image-menu button {
652
+ width: 100%;
653
+ min-height: 32px;
654
+ padding: 0 10px;
655
+ border-radius: 7px;
656
+ display: flex;
657
+ align-items: center;
658
+ gap: 8px;
659
+ text-align: left;
660
+ font-size: 12px;
661
+ color: var(--ink-2);
662
+ }
663
+ .image-menu button:hover { background: var(--surface-2); color: var(--ink); }
664
+ .image-menu button .ic { color: var(--muted); }
665
+ .image-menu-sep { height: 1px; margin: 4px 6px; background: var(--line); }
619
666
  .lb-close { position: absolute; top: 12px; right: 12px; z-index: 3; width: 32px; height: 32px; border-radius: 9px; background: rgba(20, 16, 12, 0.45); color: #fff; display: grid; place-items: center; }
620
667
  .lb-close:hover { background: rgba(20, 16, 12, 0.7); }
621
668
  .lb-nav { position: absolute; top: 50%; transform: translateY(-50%); z-index: 3; width: 38px; height: 58px; border-radius: 10px; background: rgba(20, 16, 12, 0.35); color: #fff; display: grid; place-items: center; }
@@ -1,5 +1,5 @@
1
1
  import { api, fileUrl, upload } from '../lib/api.js'
2
- import { $, $$, autoGrow, copyText, downloadUrl, esc, fmtTime, modKey } from '../lib/dom.js'
2
+ import { $, $$, autoGrow, copyImage, copyText, downloadUrl, esc, fmtTime, modKey } from '../lib/dom.js'
3
3
  import { icon } from '../lib/icons.js'
4
4
  import { app, t } from '../lib/state.js'
5
5
  import { INSPIRATIONS, RATIOS, STYLE_LABELS_EN, STYLES } from '../lib/constants.js'
@@ -9,7 +9,7 @@ export function mount(root) {
9
9
  let style = prefs.imageStyle ?? '电影感'
10
10
  let ratio = RATIOS.some(r => r.id === prefs.imageRatio) ? prefs.imageRatio : '16:9'
11
11
  let count = Math.min(4, Math.max(1, Number(prefs.imageCount) || 1))
12
- let reference = null
12
+ let references = []
13
13
  let filter = 'all'
14
14
  let query = ''
15
15
  let busy = false
@@ -18,6 +18,74 @@ export function mount(root) {
18
18
  const inspoTitle = item => (app.lang === 'en' ? item.titleEn : item.title)
19
19
  const inspoPrompt = item => (app.lang === 'en' ? item.promptEn : item.prompt)
20
20
 
21
+ let imageMenu = null
22
+ let imageMenuOff = null
23
+
24
+ function closeImageMenu() {
25
+ if (imageMenu) imageMenu.remove()
26
+ imageMenu = null
27
+ imageMenuOff?.()
28
+ imageMenuOff = null
29
+ }
30
+
31
+ function showImageMenu(event, row) {
32
+ if (!row?.file) return
33
+ closeImageMenu()
34
+ const menu = document.createElement('div')
35
+ menu.className = 'image-menu'
36
+ menu.setAttribute('role', 'menu')
37
+ menu.innerHTML = `
38
+ <button type="button" role="menuitem" data-image-menu="copy">${icon('copy', 14)}${esc(t('复制图片', 'Copy image'))}</button>
39
+ <button type="button" role="menuitem" data-image-menu="download">${icon('download', 14)}${esc(t('下载原图', 'Download original'))}</button>
40
+ <button type="button" role="menuitem" data-image-menu="reveal">${icon('folder', 14)}${esc(t('打开文件所在目录', 'Open containing folder'))}</button>
41
+ <span class="image-menu-sep"></span>
42
+ <button type="button" role="menuitem" data-image-menu="prompt">${icon('pen', 14)}${esc(t('复制提示词', 'Copy prompt'))}</button>
43
+ `
44
+ document.body.appendChild(menu)
45
+ const box = menu.getBoundingClientRect()
46
+ menu.style.left = `${Math.max(8, Math.min(event.clientX, innerWidth - box.width - 8))}px`
47
+ menu.style.top = `${Math.max(8, Math.min(event.clientY, innerHeight - box.height - 8))}px`
48
+ imageMenu = menu
49
+
50
+ const onOutside = next => {
51
+ if (!menu.contains(next.target)) closeImageMenu()
52
+ }
53
+ const onKey = next => { if (next.key === 'Escape') closeImageMenu() }
54
+ const onScroll = () => closeImageMenu()
55
+ window.addEventListener('pointerdown', onOutside, true)
56
+ window.addEventListener('keydown', onKey)
57
+ window.addEventListener('scroll', onScroll, true)
58
+ window.addEventListener('blur', onScroll)
59
+ imageMenuOff = () => {
60
+ window.removeEventListener('pointerdown', onOutside, true)
61
+ window.removeEventListener('keydown', onKey)
62
+ window.removeEventListener('scroll', onScroll, true)
63
+ window.removeEventListener('blur', onScroll)
64
+ }
65
+
66
+ menu.querySelectorAll('[data-image-menu]').forEach(button => {
67
+ button.addEventListener('click', async () => {
68
+ const action = button.dataset.imageMenu
69
+ closeImageMenu()
70
+ try {
71
+ if (action === 'copy') {
72
+ await copyImage(fileUrl(row.file))
73
+ app.toast(t('图片已复制到剪贴板', 'Image copied to clipboard'), 'success')
74
+ } else if (action === 'download') {
75
+ downloadUrl(fileUrl(row.file), `modern-media-${row.id}${row.file?.match(/\.\w+$/)?.[0] ?? '.png'}`)
76
+ } else if (action === 'reveal') {
77
+ await api('reveal', { file: row.file })
78
+ } else if (action === 'prompt') {
79
+ const ok = await copyText(row.prompt)
80
+ app.toast(ok ? t('提示词已复制', 'Prompt copied') : t('复制失败', 'Copy failed'), ok ? 'success' : 'error')
81
+ }
82
+ } catch (err) {
83
+ app.toast(err.message || t('复制失败', 'Copy failed'), 'error')
84
+ }
85
+ })
86
+ })
87
+ }
88
+
21
89
  root.innerHTML = `
22
90
  <section class="bench bench-image">
23
91
  <aside class="composer" id="composer">
@@ -68,10 +136,10 @@ export function mount(root) {
68
136
  <details class="advanced" id="advanced">
69
137
  <summary>${icon('layers', 14)}<span>${esc(t('参考图与高级参数', 'Reference & advanced'))}</span>${icon('chevron', 14)}</summary>
70
138
  <div class="advanced-body">
71
- <div class="drop" id="drop" tabindex="0" role="button" aria-label="${esc(t('上传参考图', 'Upload reference'))}">
72
- <input type="file" id="ref-input" accept="image/png,image/jpeg,image/webp" hidden>
73
- <div class="drop-empty">${icon('upload', 18)}<span>${esc(t('拖入或选择一张参考图', 'Drop or choose a reference image'))}</span><small>${esc(t('会走图生图 / 编辑接口', 'Uses the image edit endpoint'))}</small></div>
74
- <div class="drop-preview" id="drop-preview" hidden><img id="ref-img" alt=""><button type="button" class="mini-x" id="ref-clear" aria-label="移除">${icon('x', 12)}</button></div>
139
+ <div class="drop ref-drop" id="drop" tabindex="0" role="button" aria-label="${esc(t('上传参考图', 'Upload reference'))}">
140
+ <input type="file" id="ref-input" accept="image/png,image/jpeg,image/webp" multiple hidden>
141
+ <div class="drop-empty" id="ref-empty">${icon('upload', 18)}<span>${esc(t('拖入、选择或 Ctrl+V 粘贴参考图', 'Drop, choose or paste (Ctrl+V) reference images'))}</span><small>${esc(t('最多 8 张,按顺序对应图一、图二', 'Up to 8 images, ordered as figure 1, 2…'))}</small></div>
142
+ <div class="ref-list" id="ref-list"></div>
75
143
  </div>
76
144
  <label class="field">
77
145
  <span class="field-label">${esc(t('反向提示词', 'Negative prompt'))}</span>
@@ -153,16 +221,56 @@ export function mount(root) {
153
221
  if (event.target.closest('[data-act="settings"]')) app.navigate('settings')
154
222
  })
155
223
 
156
- // 参考图
224
+ // 参考图队列:最多 8 张,可粘贴、拖入、排序
157
225
  const refInput = $('#ref-input')
158
226
  const drop = $('#drop')
227
+ const refList = $('#ref-list')
228
+ const refEmpty = $('#ref-empty')
159
229
  const pickRef = () => refInput.click()
160
- drop.addEventListener('click', event => { if (!event.target.closest('#ref-clear')) pickRef() })
161
- drop.addEventListener('keydown', event => { if (event.key === 'Enter' || event.key === ' ') { event.preventDefault(); pickRef() } })
230
+
231
+ function renderReferences() {
232
+ const label = index => app.lang === 'en' ? `Fig. ${index + 1}` : `图${'一二三四五六七八'[index] ?? index + 1}`
233
+ refEmpty.hidden = references.length > 0
234
+ refList.innerHTML = references.map((ref, index) => `
235
+ <figure class="ref-item" data-ref-index="${index}">
236
+ <img src="${esc(ref.url)}" alt="${esc(label(index))}">
237
+ <span class="ref-badge">${esc(label(index))}</span>
238
+ <span class="ref-tools">
239
+ <button type="button" data-ref-act="up" title="${esc(t('上移', 'Move up'))}" ${index === 0 ? 'disabled' : ''}>↑</button>
240
+ <button type="button" data-ref-act="down" title="${esc(t('下移', 'Move down'))}" ${index === references.length - 1 ? 'disabled' : ''}>↓</button>
241
+ <button type="button" data-ref-act="remove" title="${esc(t('移除', 'Remove'))}">${icon('x', 11)}</button>
242
+ </span>
243
+ </figure>`).join('')
244
+ }
245
+
246
+ async function addReferences(files) {
247
+ const images = [...files].filter(file => file?.type?.startsWith('image/'))
248
+ if (!images.length) return
249
+ const room = 8 - references.length
250
+ if (room <= 0) return app.toast(t('最多 8 张参考图', 'Up to 8 references'), 'error')
251
+ for (const file of images.slice(0, room)) {
252
+ try {
253
+ const result = await upload(file)
254
+ references.push(result)
255
+ renderReferences()
256
+ } catch (err) {
257
+ app.toast(err.message, 'error')
258
+ }
259
+ }
260
+ if (images.length > room) app.toast(t('最多 8 张参考图,多出的已忽略', 'Up to 8 references; extras ignored'), 'error')
261
+ }
262
+
263
+ renderReferences()
264
+ drop.addEventListener('click', event => {
265
+ if (event.target.closest('.ref-item')) return
266
+ pickRef()
267
+ })
268
+ drop.addEventListener('keydown', event => {
269
+ if (event.target.closest('.ref-item')) return
270
+ if (event.key === 'Enter' || event.key === ' ') { event.preventDefault(); pickRef() }
271
+ })
162
272
  refInput.addEventListener('change', async () => {
163
- const file = refInput.files?.[0]
164
- if (!file) return
165
- await handleReference(file)
273
+ await addReferences([...(refInput.files ?? [])])
166
274
  refInput.value = ''
167
275
  })
168
276
  for (const type of ['dragenter', 'dragover']) {
@@ -172,30 +280,38 @@ export function mount(root) {
172
280
  drop.addEventListener(type, event => { event.preventDefault(); drop.classList.remove('is-over') })
173
281
  }
174
282
  drop.addEventListener('drop', event => {
175
- const file = [...(event.dataTransfer?.files ?? [])].find(f => f.type.startsWith('image/'))
176
- if (file) handleReference(file)
283
+ addReferences([...(event.dataTransfer?.files ?? [])])
177
284
  })
178
- $('#ref-clear').addEventListener('click', event => {
285
+ refList.addEventListener('click', event => {
286
+ const button = event.target.closest('[data-ref-act]')
287
+ if (!button) return
179
288
  event.stopPropagation()
180
- reference = null
181
- $('#drop-preview').hidden = true
182
- $('#drop').querySelector('.drop-empty').hidden = false
289
+ const index = Number(button.closest('.ref-item')?.dataset.refIndex)
290
+ if (!Number.isInteger(index) || !references[index]) return
291
+ if (button.dataset.refAct === 'remove') references.splice(index, 1)
292
+ if (button.dataset.refAct === 'up' && index > 0) {
293
+ [references[index - 1], references[index]] = [references[index], references[index - 1]]
294
+ }
295
+ if (button.dataset.refAct === 'down' && index < references.length - 1) {
296
+ [references[index + 1], references[index]] = [references[index], references[index + 1]]
297
+ }
298
+ renderReferences()
183
299
  })
184
300
 
185
- async function handleReference(file) {
186
- if (!file.type.startsWith('image/')) return app.toast(t('请选图片文件', 'Choose an image file'), 'error')
187
- try {
188
- const result = await upload(file)
189
- reference = result
190
- const img = $('#ref-img')
191
- img.src = result.url
192
- $('#drop-preview').hidden = false
193
- $('#drop').querySelector('.drop-empty').hidden = true
194
- app.toast(t('参考图已加入', 'Reference added'), 'success')
195
- } catch (err) {
196
- app.toast(err.message, 'error')
301
+ function onPaste(event) {
302
+ if (!drop.isConnected) {
303
+ document.removeEventListener('paste', onPaste)
304
+ return
197
305
  }
306
+ const files = [...(event.clipboardData?.items ?? [])]
307
+ .filter(item => item.type.startsWith('image/'))
308
+ .map(item => item.getAsFile())
309
+ .filter(Boolean)
310
+ if (!files.length) return
311
+ event.preventDefault()
312
+ addReferences(files)
198
313
  }
314
+ document.addEventListener('paste', onPaste)
199
315
 
200
316
  function paintChoices() {
201
317
  $$('#styles [data-style]').forEach(btn => btn.classList.toggle('is-on', btn.dataset.style === style))
@@ -295,6 +411,45 @@ export function mount(root) {
295
411
  galleryEl.innerHTML = list.map(card).join('')
296
412
  }
297
413
 
414
+ async function reuseImage(row) {
415
+ const original = (Array.isArray(row.references) && row.references.length ? row.references : row.reference ? [row.reference] : [])
416
+ .filter(name => typeof name === 'string' && name)
417
+ .slice(0, 8)
418
+ let usedReferences = false
419
+ if (original.length) {
420
+ const useReferences = await app.confirm({
421
+ title: t('这张图带了参考图', 'This image has references'),
422
+ message: t(
423
+ `这张图用了 ${original.length} 张参考图。要一起复用吗?选「覆盖参考图」会替换当前队列;选「只用提示词」会保留当前参考图。`,
424
+ `This image used ${original.length} reference image(s). Reuse them too? Choose "Use references" to replace the current queue, or "Prompt only" to keep what you have.`,
425
+ ),
426
+ confirmText: t('覆盖参考图', 'Use references'),
427
+ cancelText: t('只用提示词', 'Prompt only'),
428
+ })
429
+ if (useReferences) {
430
+ references = original.map(name => ({ name, url: fileUrl(name) }))
431
+ renderReferences()
432
+ usedReferences = true
433
+ }
434
+ } else {
435
+ references = []
436
+ renderReferences()
437
+ }
438
+ promptEl.value = row.prompt
439
+ promptEl.dispatchEvent(new Event('input'))
440
+ if (row.style && STYLES.includes(row.style)) style = row.style
441
+ if (row.size?.includes('x')) {
442
+ const match = RATIOS.find(item => item.hint.replaceAll(' × ', 'x') === row.size)
443
+ if (match) ratio = match.id
444
+ }
445
+ paintChoices()
446
+ promptEl.focus()
447
+ app.toast(
448
+ usedReferences ? t('已复用提示词和参考图', 'Prompt and references loaded') : t('已复用提示词', 'Prompt loaded'),
449
+ 'success',
450
+ )
451
+ }
452
+
298
453
  galleryEl.addEventListener('click', async event => {
299
454
  const btn = event.target.closest('[data-act]')
300
455
  if (!btn) return
@@ -317,16 +472,7 @@ export function mount(root) {
317
472
  return
318
473
  }
319
474
  if (act === 'reuse') {
320
- promptEl.value = row.prompt
321
- promptEl.dispatchEvent(new Event('input'))
322
- if (row.style && STYLES.includes(row.style)) style = row.style
323
- if (row.size?.includes('x')) {
324
- const match = RATIOS.find(item => item.hint.replaceAll(' × ', 'x') === row.size)
325
- if (match) ratio = match.id
326
- }
327
- paintChoices()
328
- promptEl.focus()
329
- app.toast(t('已填入提示词', 'Prompt loaded'), 'success')
475
+ await reuseImage(row)
330
476
  return
331
477
  }
332
478
  if (act === 'retry') {
@@ -353,6 +499,16 @@ export function mount(root) {
353
499
  openLightbox(media.dataset.id)
354
500
  })
355
501
 
502
+ galleryEl.addEventListener('contextmenu', event => {
503
+ const media = event.target.closest('.shot-media')
504
+ const image = event.target.closest('.shot-media img')
505
+ if (!media || !image) return
506
+ const row = (app.state?.images ?? []).find(item => item.id === media.closest('.shot')?.dataset.id)
507
+ if (!row) return
508
+ event.preventDefault()
509
+ showImageMenu(event, row)
510
+ })
511
+
356
512
  galleryEl.addEventListener('click', event => {
357
513
  const inspo = event.target.closest('[data-inspo]')
358
514
  if (!inspo) return
@@ -390,6 +546,7 @@ export function mount(root) {
390
546
  </div>
391
547
  <div class="lb-actions">
392
548
  <button class="btn btn-primary" data-lb="download">${icon('download', 15)}${esc(t('下载', 'Download'))}</button>
549
+ <button class="btn btn-ghost" data-lb="copy-image">${icon('copy', 15)}${esc(t('复制图片', 'Copy image'))}</button>
393
550
  <button class="btn btn-ghost" data-lb="fav">${icon('heart', 15)}${row.favorite ? t('取消收藏', 'Unsave') : t('收藏', 'Save')}</button>
394
551
  <button class="btn btn-ghost" data-lb="copy">${icon('copy', 15)}${esc(t('复制提示词', 'Copy prompt'))}</button>
395
552
  <button class="btn btn-ghost" data-lb="reuse">${icon('refresh', 15)}${esc(t('用它再画', 'Use again'))}</button>
@@ -403,14 +560,17 @@ export function mount(root) {
403
560
  if (act === 'prev') { index = (index - 1 + list.length) % list.length; paint(); return }
404
561
  if (act === 'next') { index = (index + 1) % list.length; paint(); return }
405
562
  if (act === 'download') { downloadUrl(fileUrl(row.file), `modern-media-${row.id}.png`); return }
563
+ if (act === 'copy-image') {
564
+ try {
565
+ await copyImage(fileUrl(row.file))
566
+ app.toast(t('图片已复制到剪贴板', 'Image copied to clipboard'), 'success')
567
+ } catch (err) { app.toast(err.message || t('复制失败', 'Copy failed'), 'error') }
568
+ return
569
+ }
406
570
  if (act === 'copy') { await copyText(row.prompt); app.toast(t('已复制', 'Copied'), 'success'); return }
407
571
  if (act === 'reuse') {
408
- promptEl.value = row.prompt
409
- promptEl.dispatchEvent(new Event('input'))
410
- if (row.style && STYLES.includes(row.style)) style = row.style
411
- paintChoices()
572
+ await reuseImage(row)
412
573
  closeRef.fn()
413
- promptEl.focus()
414
574
  return
415
575
  }
416
576
  if (act === 'fav') {
@@ -424,6 +584,10 @@ export function mount(root) {
424
584
  }
425
585
  })
426
586
  })
587
+ cardEl.querySelector('.lb-figure img')?.addEventListener('contextmenu', event => {
588
+ event.preventDefault()
589
+ showImageMenu(event, list[index])
590
+ })
427
591
  }
428
592
  paint()
429
593
  const onKey = event => {
@@ -458,7 +622,7 @@ export function mount(root) {
458
622
  count,
459
623
  quality: $('#quality').value,
460
624
  negative: $('#negative').value.trim(),
461
- reference: reference?.name ?? '',
625
+ references: references.map(item => item.name),
462
626
  })
463
627
  api('preferences', { imageStyle: style, imageRatio: ratio, imageCount: count }).catch(() => {})
464
628
  app.toast(t(`已提交 ${count} 张,稍等片刻`, `Queued ${count} image(s)`), 'success')
@@ -98,17 +98,31 @@ export function mount(root) {
98
98
 
99
99
  <section class="settings-card">
100
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>
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
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
103
  <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>
104
+ <button class="btn btn-small btn-ghost" id="export-all">${icon('download', 14)}${esc(t('导出记录 JSON', 'Export records JSON'))}</button>
106
105
  <button class="btn btn-small btn-ghost" id="clear-images">${esc(t('清空图片记录', 'Clear images'))}</button>
107
106
  <button class="btn btn-small btn-ghost" id="clear-videos">${esc(t('清空视频记录', 'Clear videos'))}</button>
108
107
  <button class="btn btn-small btn-ghost danger-quiet" id="clear-all">${esc(t('清空全部记录', 'Clear everything'))}</button>
109
108
  </div>
110
109
  </section>
111
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
+
112
126
  <section class="settings-card about-card">
113
127
  <div class="about-mark">${icon('spark', 22)}</div>
114
128
  <div>
@@ -185,6 +199,21 @@ export function mount(root) {
185
199
  field('image-save').addEventListener('click', saveImage)
186
200
  field('video-save').addEventListener('click', saveVideo)
187
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
+
188
217
  field('image-clear-key')?.addEventListener('click', async () => {
189
218
  try {
190
219
  const data = await api('settings', { image: { clearKey: true } })
@@ -218,10 +247,6 @@ export function mount(root) {
218
247
  finally { busy = false; btn.textContent = t('试画一张', 'Test image') }
219
248
  })
220
249
 
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
250
  field('export-all').addEventListener('click', () => { location.href = '/media-workbench-modern/api/export' })
226
251
 
227
252
  async function clearScope(scope) {