@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 +26 -1
- package/README.en.md +0 -3
- package/README.md +5 -8
- package/lib/client.js +42 -14
- package/package.json +1 -1
- package/src/image.js +23 -10
- package/src/index.js +112 -16
- package/src/settings.js +47 -1
- package/src/store.js +11 -44
- package/src/video.js +29 -3
- package/web/lib/dom.js +27 -0
- package/web/styles.css +49 -2
- package/web/views/image.js +210 -46
- package/web/views/settings.js +32 -7
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
|
|
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
|
|
51
|
-
if (
|
|
52
|
-
|
|
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('
|
|
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
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
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
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
|
-
|
|
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 {
|
|
4
|
-
import {
|
|
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
|
|
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.
|
|
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 =
|
|
60
|
-
|
|
61
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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 {
|
|
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
|
|
168
|
-
if (
|
|
169
|
-
|
|
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:
|
|
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 {
|
|
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; }
|
package/web/views/image.js
CHANGED
|
@@ -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
|
|
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('
|
|
74
|
-
<div class="
|
|
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
|
-
|
|
161
|
-
|
|
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
|
-
|
|
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
|
-
|
|
176
|
-
if (file) handleReference(file)
|
|
283
|
+
addReferences([...(event.dataTransfer?.files ?? [])])
|
|
177
284
|
})
|
|
178
|
-
|
|
285
|
+
refList.addEventListener('click', event => {
|
|
286
|
+
const button = event.target.closest('[data-ref-act]')
|
|
287
|
+
if (!button) return
|
|
179
288
|
event.stopPropagation()
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
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
|
-
|
|
186
|
-
if (!
|
|
187
|
-
|
|
188
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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')
|
package/web/views/settings.js
CHANGED
|
@@ -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('
|
|
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('
|
|
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) {
|