@exccoder/dsh-media-workbench 0.0.0-stage → 0.1.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/web/lib/api.js ADDED
@@ -0,0 +1,79 @@
1
+ const BASE = '/media-workbench-modern/api/'
2
+
3
+ async function parse(res) {
4
+ const data = await res.json().catch(() => ({}))
5
+ if (!res.ok) {
6
+ const err = new Error(data.error || `请求失败(${res.status})`)
7
+ err.status = res.status
8
+ throw err
9
+ }
10
+ return data
11
+ }
12
+
13
+ export async function api(path, body) {
14
+ const res = await fetch(BASE + path, body === undefined ? {} : {
15
+ method: 'POST',
16
+ headers: { 'content-type': 'application/json' },
17
+ body: JSON.stringify(body),
18
+ })
19
+ return parse(res)
20
+ }
21
+
22
+ export async function upload(file) {
23
+ const res = await fetch(BASE + 'upload', {
24
+ method: 'POST',
25
+ headers: { 'content-type': file.type || 'application/octet-stream' },
26
+ body: file,
27
+ })
28
+ return parse(res)
29
+ }
30
+
31
+ /**
32
+ * 写作流:NDJSON,每行一个 {type:'delta'|'done'|'error'}。
33
+ */
34
+ export async function streamWrite(body, { onDelta, signal } = {}) {
35
+ const res = await fetch(BASE + 'write/stream', {
36
+ method: 'POST',
37
+ headers: { 'content-type': 'application/json' },
38
+ body: JSON.stringify(body),
39
+ signal,
40
+ })
41
+ if (!res.ok) {
42
+ const data = await res.json().catch(() => ({}))
43
+ throw new Error(data.error || `请求失败(${res.status})`)
44
+ }
45
+ if (!res.body) throw new Error('浏览器不支持流式读取')
46
+ const reader = res.body.getReader()
47
+ const decoder = new TextDecoder()
48
+ let buffer = ''
49
+ let full = ''
50
+ let error = null
51
+ for (;;) {
52
+ const { value, done } = await reader.read()
53
+ if (done) break
54
+ buffer += decoder.decode(value, { stream: true })
55
+ let index
56
+ while ((index = buffer.indexOf('\n')) >= 0) {
57
+ const line = buffer.slice(0, index).trim()
58
+ buffer = buffer.slice(index + 1)
59
+ if (!line) continue
60
+ let payload
61
+ try { payload = JSON.parse(line) } catch { continue }
62
+ if (payload.type === 'delta') {
63
+ full += payload.text
64
+ onDelta?.(payload.text, full)
65
+ } else if (payload.type === 'done') {
66
+ if (typeof payload.text === 'string') full = payload.text
67
+ return { text: full, model: payload.model }
68
+ } else if (payload.type === 'error') {
69
+ error = new Error(payload.message || '生成失败')
70
+ }
71
+ }
72
+ }
73
+ if (error) throw error
74
+ return { text: full }
75
+ }
76
+
77
+ export function fileUrl(name) {
78
+ return `/media-workbench-modern/files/${encodeURIComponent(name)}`
79
+ }
@@ -0,0 +1,69 @@
1
+ export const STYLES = [
2
+ '电影感', '插画', '水墨', '赛博朋克', '胶片', '3D', '极简', '浮世绘', '概念艺术',
3
+ ]
4
+
5
+ export const STYLE_HINTS = {
6
+ 电影感: 'cinematic lighting, film still',
7
+ 插画: 'editorial illustration',
8
+ 水墨: 'ink painting',
9
+ 赛博朋克: 'cyberpunk neon',
10
+ 胶片: 'analog film',
11
+ '3D': 'stylized 3D render',
12
+ 极简: 'minimal composition',
13
+ 浮世绘: 'ukiyo-e print',
14
+ 概念艺术: 'concept art',
15
+ }
16
+
17
+ export const STYLE_LABELS_EN = {
18
+ 电影感: 'Cinematic',
19
+ 插画: 'Illustration',
20
+ 水墨: 'Ink',
21
+ 赛博朋克: 'Cyberpunk',
22
+ 胶片: 'Film',
23
+ '3D': '3D',
24
+ 极简: 'Minimal',
25
+ 浮世绘: 'Ukiyo-e',
26
+ 概念艺术: 'Concept art',
27
+ }
28
+
29
+ export const RATIOS = [
30
+ { id: '1:1', label: '1:1', hint: '1024 × 1024' },
31
+ { id: '4:3', label: '4:3', hint: '1152 × 864' },
32
+ { id: '3:2', label: '3:2', hint: '1216 × 832' },
33
+ { id: '16:9', label: '16:9', hint: '1344 × 768' },
34
+ { id: '9:16', label: '9:16', hint: '768 × 1344' },
35
+ { id: '2:3', label: '2:3', hint: '832 × 1216' },
36
+ ]
37
+
38
+ export const RATIO_VALUE = {
39
+ '1:1': 1,
40
+ '4:3': 4 / 3,
41
+ '3:2': 3 / 2,
42
+ '16:9': 16 / 9,
43
+ '9:16': 9 / 16,
44
+ '2:3': 2 / 3,
45
+ }
46
+
47
+ export const INSPIRATIONS = [
48
+ { title: '雨后的天台', titleEn: 'Rooftop after rain', prompt: '雨停后的城市天台,积水映着晚霞,一个人把耳机摘下来,远处的车流像潮水。', promptEn: 'A city rooftop after the rain, puddles holding the last light, someone taking off their headphones, traffic moving like a tide below.', style: '电影感', ratio: '16:9' },
49
+ { title: '云上的邮局', titleEn: 'Post office above the clouds', prompt: '一座建在云层上的旧邮局,木质柜台,窗外是翻涌的云海,暖黄的吊灯,安静而郑重。', promptEn: 'An old post office built on a sea of clouds, a wooden counter, warm hanging lamps, quiet and ceremonial.', style: '插画', ratio: '4:3' },
50
+ { title: '深夜书店', titleEn: 'Bookshop at 2am', prompt: '凌晨两点的独立书店,只留一盏台灯,猫趴在摊开的书上,玻璃窗外是细雨和霓虹。', promptEn: 'An independent bookshop at two in the morning, one lamp left on, a cat asleep on an open book, drizzle and neon beyond the glass.', style: '胶片', ratio: '3:2' },
51
+ { title: '墨色山谷', titleEn: 'Ink valley', prompt: '极简的水墨山谷,一叶小舟停在雾里,远处只有淡淡的山的轮廓,大量留白。', promptEn: 'A minimalist ink-wash valley, a small boat resting in mist, faint mountain outlines far away, generous empty space.', style: '水墨', ratio: '9:16' },
52
+ ]
53
+
54
+ export const ACTIONS = [
55
+ { id: 'continue', label: '续写', hint: '从结尾往下写' },
56
+ { id: 'polish', label: '润色', hint: '保留原意,改善节奏' },
57
+ { id: 'expand', label: '扩写', hint: '补充细节与画面' },
58
+ { id: 'rewrite', label: '改写', hint: '按指令换一种写法' },
59
+ { id: 'outline', label: '大纲', hint: '整理结构' },
60
+ { id: 'title', label: '起标题', hint: '给 6 个候选' },
61
+ ]
62
+
63
+ export const TONES = ['自然叙述', '克制冷静', '热烈', '幽默', '诗意', '硬朗']
64
+ export const POVS = ['第一人称', '第三人称', '全知视角']
65
+ export const LENGTHS = [
66
+ { id: 'short', label: '短 · 400 字' },
67
+ { id: 'medium', label: '中 · 900 字' },
68
+ { id: 'long', label: '长 · 1600 字' },
69
+ ]
package/web/lib/dom.js ADDED
@@ -0,0 +1,99 @@
1
+ export const $ = (selector, root = document) => root.querySelector(selector)
2
+ export const $$ = (selector, root = document) => [...root.querySelectorAll(selector)]
3
+
4
+ export function esc(value) {
5
+ return String(value ?? '').replace(/[&<>"']/g, ch => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[ch]))
6
+ }
7
+
8
+ export function clamp(value, min, max) {
9
+ return Math.min(max, Math.max(min, value))
10
+ }
11
+
12
+ /** 中英混排字数:CJK 按字,拉丁按词 */
13
+ export function wordCount(text) {
14
+ const value = String(text ?? '')
15
+ const cjk = (value.match(/[\u3400-\u9fff\uf900-\ufaff]/g) ?? []).length
16
+ const latin = (value.replace(/[\u3400-\u9fff\uf900-\ufaff]/g, ' ').match(/[A-Za-z0-9]+(?:['’-][A-Za-z0-9]+)*/g) ?? []).length
17
+ return cjk + latin
18
+ }
19
+
20
+ export function fmtTime(value) {
21
+ if (!value) return ''
22
+ const date = new Date(value)
23
+ if (Number.isNaN(date.getTime())) return ''
24
+ const now = new Date()
25
+ const diff = now - date
26
+ if (diff < 60_000) return '刚刚'
27
+ if (diff < 3_600_000) return `${Math.floor(diff / 60_000)} 分钟前`
28
+ if (date.toDateString() === now.toDateString()) return date.toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit' })
29
+ if (now - date < 7 * 86_400_000) return `${Math.floor(diff / 86_400_000)} 天前`
30
+ return date.toLocaleDateString('zh-CN', { month: '2-digit', day: '2-digit' })
31
+ }
32
+
33
+ export function fmtDate(value) {
34
+ if (!value) return ''
35
+ const date = new Date(value)
36
+ if (Number.isNaN(date.getTime())) return ''
37
+ return date.toLocaleDateString('zh-CN', { year: 'numeric', month: '2-digit', day: '2-digit' })
38
+ }
39
+
40
+ export function debounce(fn, wait = 300) {
41
+ let timer = null
42
+ const wrapped = (...args) => {
43
+ clearTimeout(timer)
44
+ timer = setTimeout(() => fn(...args), wait)
45
+ }
46
+ wrapped.flush = (...args) => {
47
+ clearTimeout(timer)
48
+ fn(...args)
49
+ }
50
+ wrapped.cancel = () => clearTimeout(timer)
51
+ return wrapped
52
+ }
53
+
54
+ export function downloadUrl(url, filename) {
55
+ const a = document.createElement('a')
56
+ a.href = url
57
+ a.download = filename || ''
58
+ a.rel = 'noopener'
59
+ document.body.appendChild(a)
60
+ a.click()
61
+ a.remove()
62
+ }
63
+
64
+ export async function copyText(text) {
65
+ try {
66
+ await navigator.clipboard.writeText(text)
67
+ return true
68
+ } catch {
69
+ const area = document.createElement('textarea')
70
+ area.value = text
71
+ area.style.position = 'fixed'
72
+ area.style.opacity = '0'
73
+ document.body.appendChild(area)
74
+ area.select()
75
+ const ok = document.execCommand('copy')
76
+ area.remove()
77
+ return ok
78
+ }
79
+ }
80
+
81
+ export function downloadJson(data, filename) {
82
+ const blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' })
83
+ const url = URL.createObjectURL(blob)
84
+ downloadUrl(url, filename)
85
+ setTimeout(() => URL.revokeObjectURL(url), 3000)
86
+ }
87
+
88
+ export function isMac() {
89
+ return /Mac|iPhone|iPad/.test(navigator.platform || '')
90
+ }
91
+
92
+ export function modKey() {
93
+ return isMac() ? '⌘' : 'Ctrl'
94
+ }
95
+
96
+ export function autoGrow(textarea, max = 320) {
97
+ textarea.style.height = 'auto'
98
+ textarea.style.height = `${Math.min(max, textarea.scrollHeight)}px`
99
+ }
@@ -0,0 +1,65 @@
1
+ // 小图标集:统一的线性图标,避免加载外部字体
2
+ const P = {
3
+ spark: '<path d="M12 3l1.8 5.2L19 10l-5.2 1.8L12 17l-1.8-5.2L5 10l5.2-1.8z"/><path d="M19 15l.8 2.2L22 18l-2.2.8L19 21l-.8-2.2L16 18l2.2-.8z"/>',
4
+ image: '<rect x="3" y="4" width="18" height="16" rx="2.5"/><circle cx="8.6" cy="9.4" r="1.6"/><path d="M3.5 17.5 9 12l4 3.8 3-2.8 4.5 4.5"/>',
5
+ pen: '<path d="M4 20l4.2-1 10.4-10.4a2.2 2.2 0 0 0-3.1-3.1L5.1 15.9 4 20z"/><path d="M13.8 6.4l3.8 3.8"/>',
6
+ star: '<circle cx="6" cy="7" r="2"/><circle cx="18" cy="6" r="2"/><circle cx="12" cy="17" r="2"/><path d="M7.6 8.2 10.4 15M16.4 7.6 13.6 15M8 7h8"/>',
7
+ film: '<rect x="3" y="5" width="18" height="14" rx="2.5"/><path d="M8 5v14M16 5v14M3 9h5M16 9h5M3 15h5M16 15h5"/>',
8
+ settings: '<circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.7 1.7 0 0 0 .3 1.9l.1.1a2 2 0 1 1-2.8 2.8l-.1-.1a1.7 1.7 0 0 0-1.9-.3 1.7 1.7 0 0 0-1 1.5V21a2 2 0 1 1-4 0v-.1a1.7 1.7 0 0 0-1.1-1.5 1.7 1.7 0 0 0-1.9.3l-.1.1a2 2 0 1 1-2.8-2.8l.1-.1a1.7 1.7 0 0 0 .3-1.9 1.7 1.7 0 0 0-1.5-1H3a2 2 0 1 1 0-4h.1a1.7 1.7 0 0 0 1.5-1.1 1.7 1.7 0 0 0-.3-1.9l-.1-.1a2 2 0 1 1 2.8-2.8l.1.1a1.7 1.7 0 0 0 1.9.3H9a1.7 1.7 0 0 0 1-1.5V3a2 2 0 1 1 4 0v.1a1.7 1.7 0 0 0 1 1.5 1.7 1.7 0 0 0 1.9-.3l.1-.1a2 2 0 1 1 2.8 2.8l-.1.1a1.7 1.7 0 0 0-.3 1.9V9a1.7 1.7 0 0 0 1.5 1H21a2 2 0 1 1 0 4h-.1a1.7 1.7 0 0 0-1.5 1z"/>',
9
+ plus: '<path d="M12 5v14M5 12h14"/>',
10
+ minus: '<path d="M5 12h14"/>',
11
+ heart: '<path d="M20.8 5.6a5.4 5.4 0 0 0-7.7 0L12 6.7l-1.1-1.1a5.4 5.4 0 1 0-7.7 7.7L12 22l8.8-8.7a5.4 5.4 0 0 0 0-7.7z"/>',
12
+ download: '<path d="M12 3v12M7 10l5 5 5-5M4 21h16"/>',
13
+ trash: '<path d="M4 7h16M9 7V4h6v3M6 7l1 14h10l1-14M10 11v6M14 11v6"/>',
14
+ copy: '<rect x="9" y="9" width="12" height="12" rx="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/>',
15
+ refresh: '<path d="M21 12a9 9 0 1 1-2.6-6.4M21 3v6h-6"/>',
16
+ undo: '<path d="M9 14 4 9l5-5"/><path d="M4 9h10a6 6 0 0 1 0 12h-3"/>',
17
+ redo: '<path d="M15 14l5-5-5-5"/><path d="M20 9H10a6 6 0 0 0 0 12h3"/>',
18
+ x: '<path d="M6 6l12 12M18 6 6 18"/>',
19
+ play: '<path d="M8 5l12 7-12 7z"/>',
20
+ pause: '<path d="M8 5v14M16 5v14"/>',
21
+ search: '<circle cx="11" cy="11" r="7"/><path d="M20 20l-3.6-3.6"/>',
22
+ chevron: '<path d="M6 9l6 6 6-6"/>',
23
+ arrowRight: '<path d="M4 12h15M13 6l6 6-6 6"/>',
24
+ wand: '<path d="M15 4V2M15 10V8M11 6H9M21 6h-2M18 9l1.4 1.4M18 3l1.4-1.4M4 20l9-9M6.5 6.5 5 5"/>',
25
+ book: '<path d="M4 5.5A2.5 2.5 0 0 1 6.5 3H20v18H6.5A2.5 2.5 0 0 1 4 18.5z"/><path d="M4 18.5A2.5 2.5 0 0 1 6.5 16H20"/>',
26
+ layers: '<path d="M12 3 2 8l10 5 10-5z"/><path d="m2 13 10 5 10-5"/><path d="m2 18 10 5 10-5"/>',
27
+ zoomIn: '<circle cx="11" cy="11" r="7"/><path d="M20 20l-3.6-3.6M8 11h6M11 8v6"/>',
28
+ zoomOut: '<circle cx="11" cy="11" r="7"/><path d="M20 20l-3.6-3.6M8 11h6"/>',
29
+ fit: '<path d="M4 9V4h5M20 9V4h-5M4 15v5h5M20 15v5h-5"/>',
30
+ link: '<path d="M10 13a5 5 0 0 0 7 0l3-3a5 5 0 0 0-7-7l-1.4 1.4"/><path d="M14 11a5 5 0 0 0-7 0l-3 3a5 5 0 0 0 7 7l1.4-1.4"/>',
31
+ check: '<path d="M4 12l5 5L20 6"/>',
32
+ alert: '<path d="M12 3 2 21h20z"/><path d="M12 9v5M12 17.5v.5"/>',
33
+ upload: '<path d="M12 16V4M7 9l5-5 5 5M4 20h16"/>',
34
+ more: '<circle cx="5" cy="12" r="1.2"/><circle cx="12" cy="12" r="1.2"/><circle cx="19" cy="12" r="1.2"/>',
35
+ eye: '<path d="M2 12s3.6-7 10-7 10 7 10 7-3.6 7-10 7S2 12 2 12z"/><circle cx="12" cy="12" r="3"/>',
36
+ note: '<path d="M4 4h11l5 5v11H4z"/><path d="M15 4v5h5M8 13h8M8 17h5"/>',
37
+ folder: '<path d="M3 6a2 2 0 0 1 2-2h5l2 3h7a2 2 0 0 1 2 2v9a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/>',
38
+ clock: '<circle cx="12" cy="12" r="9"/><path d="M12 7v5l3.5 2"/>',
39
+ stop: '<rect x="6" y="6" width="12" height="12" rx="2"/>',
40
+ sun: '<circle cx="12" cy="12" r="4"/><path d="M12 2v2M12 20v2M4.9 4.9l1.4 1.4M17.7 17.7l1.4 1.4M2 12h2M20 12h2M4.9 19.1l1.4-1.4M17.7 6.3l1.4-1.4"/>',
41
+ moon: '<path d="M20 14.5A8.5 8.5 0 1 1 9.5 4a7 7 0 0 0 10.5 10.5z"/>',
42
+ external: '<path d="M14 4h6v6M20 4l-9 9M19 14v5a1 1 0 0 1-1 1H5a1 1 0 0 1-1-1V6a1 1 0 0 1 1-1h5"/>',
43
+ edit: '<path d="M4 20h4l11-11a2.8 2.8 0 0 0-4-4L4 16z"/><path d="M13.5 6.5l4 4"/>',
44
+ duplicate: '<rect x="8" y="8" width="12" height="12" rx="2"/><path d="M16 8V5a1 1 0 0 0-1-1H5a1 1 0 0 0-1 1v10a1 1 0 0 0 1 1h3"/>',
45
+ grid: '<rect x="3" y="3" width="7" height="7" rx="1.5"/><rect x="14" y="3" width="7" height="7" rx="1.5"/><rect x="3" y="14" width="7" height="7" rx="1.5"/><rect x="14" y="14" width="7" height="7" rx="1.5"/>',
46
+ chevronLeft: '<path d="M15 18l-6-6 6-6"/>',
47
+ chevronRight: '<path d="M9 6l6 6-6 6"/>',
48
+ send: '<path d="M22 2 11 13M22 2l-7 20-4-9-9-4z"/>',
49
+ wandSpark: '<path d="M4 20 15 9M13 4l1 3 3 1-3 1-1 3-1-3-3-1 3-1zM19 12l.7 2 2 .7-2 .7-.7 2-.7-2-2-.7 2-.7z"/>',
50
+ info: '<circle cx="12" cy="12" r="9"/><path d="M12 11v6M12 7.5v.5"/>',
51
+ arrowUp: '<path d="M12 20V4M5 11l7-7 7 7"/>',
52
+ }
53
+
54
+ export function icon(name, size = 18, stroke = 1.6) {
55
+ const path = P[name] ?? P.spark
56
+ return `<svg class="ic" width="${size}" height="${size}" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="${stroke}" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">${path}</svg>`
57
+ }
58
+
59
+ export function brandMark(size = 22) {
60
+ return `<svg width="${size}" height="${size}" viewBox="0 0 24 24" fill="none" aria-hidden="true">
61
+ <path d="M12 2.6c.9 4.6 2.8 6.5 7.4 7.4-4.6.9-6.5 2.8-7.4 7.4-.9-4.6-2.8-6.5-7.4-7.4 4.6-.9 6.5-2.8 7.4-7.4z" fill="currentColor" opacity=".92"/>
62
+ <circle cx="18.6" cy="18.2" r="1.9" fill="currentColor" opacity=".55"/>
63
+ <circle cx="5.4" cy="17.2" r="1.2" fill="currentColor" opacity=".4"/>
64
+ </svg>`
65
+ }
@@ -0,0 +1,42 @@
1
+ export const app = {
2
+ lang: 'zh',
3
+ theme: 'light',
4
+ state: null,
5
+ settings: null,
6
+ imagePresets: [],
7
+ videoPresets: [],
8
+ model: null,
9
+ dataDir: '',
10
+ version: '',
11
+ view: null,
12
+ revision: 0,
13
+ polling: false,
14
+ /** 运行时由 app.js 注入 */
15
+ refresh: async () => {},
16
+ toast: () => {},
17
+ confirm: async () => false,
18
+ showLayer: () => () => {},
19
+ navigate: () => {},
20
+ openSettings: () => { location.hash = '#/settings' },
21
+ }
22
+
23
+ export function t(zh, en) {
24
+ return app.lang === 'en' ? en : zh
25
+ }
26
+
27
+ export function $(selector, root = document) {
28
+ return root.querySelector(selector)
29
+ }
30
+
31
+ export function providerChip(kind) {
32
+ if (kind === 'image') {
33
+ if (!app.settings?.imageReady) return { ok: false, text: t('生图未配置', 'Image not set') }
34
+ return { ok: true, text: app.settings.image.model || t('已连接', 'Ready') }
35
+ }
36
+ if (kind === 'video') {
37
+ if (!app.settings?.videoReady) return { ok: false, text: t('视频未配置', 'Video not set') }
38
+ return { ok: true, text: app.settings.video.model || t('已连接', 'Ready') }
39
+ }
40
+ if (!app.model) return { ok: false, text: t('模型未连接', 'No model') }
41
+ return { ok: true, text: app.model.label }
42
+ }