beauticode-dsh 1.0.4 → 1.0.5

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/README.zh-CN.md CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  该 Cordis 插件向 DSH Web 注入 beautiCode 浏览器客户端,并提供本机鉴权接口。支持图片、MP4、播放位置、静音、摸鱼模式以及清除背景。
4
4
 
5
- 装好插件并运行 `dsh web` 后,侧栏「设置」上方会出现「背景」:可从文件夹选图片或 MP4、清除、开关声音、切换已保存主题,以及打开「皮肤中心」安装已审核的社区皮肤。网页控制台没有摸鱼;浅色/深色跟 DSH 自己的外观走。下次打开会恢复上次背景。设置 `BEAUTICODE_SKIN_CENTER` 或填写 `skin-center.json` 后,皮肤中心才会显示远程目录。
5
+ 装好插件并运行 `dsh web` 后,侧栏「设置」上方会出现「背景」:可从文件夹选图片或 MP4、清除、开关声音、切换或删除已保存主题,以及打开「皮肤中心」安装已审核的社区皮肤。导入的图片和视频会自动进入已保存主题。网页控制台没有摸鱼;浅色/深色跟 DSH 自己的外观走。下次打开会恢复上次背景。设置 `BEAUTICODE_SKIN_CENTER` 或填写 `skin-center.json` 后,皮肤中心才会显示远程目录。
6
6
 
7
7
  插件也会注册对话工具(`beauticode_*`)和斜杠命令(`/bg`、`/bg-theme`、`/bg-clear`)。不需要托盘;托盘若已在跑则复用它。
8
8
 
package/agent.mjs CHANGED
@@ -1,3 +1,4 @@
1
+ import path from "node:path";
1
2
  import {
2
3
  callDshControl,
3
4
  formatStatusText,
@@ -5,7 +6,7 @@ import {
5
6
  matchSavedTheme,
6
7
  stripPathQuotes,
7
8
  } from "./control-client.mjs";
8
- import { resolveApplyBackend, stopInProcessSession } from "./host-apply.mjs";
9
+ import { loadAdapter, resolveApplyBackend, stopInProcessSession } from "./host-apply.mjs";
9
10
  import { ATMOSPHERE_PRESETS, effectsForPreset, presetImagePath } from "./presets.mjs";
10
11
 
11
12
  const APPLY_TIMEOUT_MS = 180_000;
@@ -32,6 +33,55 @@ function fail(error) {
32
33
  throw error instanceof Error ? error : new Error(String(error));
33
34
  }
34
35
 
36
+ export function themeNameFromFilePath(filePath, fallback = "主题") {
37
+ const base = path.basename(String(filePath ?? "").replaceAll("\\", "/"));
38
+ const ext = path.extname(base);
39
+ let name = (ext ? base.slice(0, -ext.length) : base).trim();
40
+ name = name
41
+ .replace(/[<>:"/\\|?*]/g, " ")
42
+ .replace(/[\u0000-\u001f]/g, "")
43
+ .replace(/\s+/g, " ")
44
+ .trim();
45
+ if (!name) name = fallback;
46
+ if (name.length > 80) name = name.slice(0, 80).trim();
47
+ if (!name) name = fallback;
48
+ return name;
49
+ }
50
+
51
+ async function chineseError(error) {
52
+ const raw = error instanceof Error ? error.message : String(error ?? "");
53
+ try {
54
+ const adapter = await loadAdapter();
55
+ if (typeof adapter.toChineseErrorMessage === "function") {
56
+ return adapter.toChineseErrorMessage(error);
57
+ }
58
+ } catch {
59
+ /* keep raw */
60
+ }
61
+ return raw || "操作失败。";
62
+ }
63
+
64
+ async function unwrapApplyResult(result, fallbackMode, message) {
65
+ if (!result || result.ok === false) {
66
+ fail(await chineseError(result?.error || "操作失败。"));
67
+ }
68
+ return {
69
+ ok: true,
70
+ generation: result.generation ?? null,
71
+ mode: result.mode ?? fallbackMode,
72
+ message,
73
+ ...(result.theme
74
+ ? {
75
+ theme: {
76
+ id: result.theme.id,
77
+ name: result.theme.name,
78
+ type: result.theme.type ?? null,
79
+ },
80
+ }
81
+ : {}),
82
+ };
83
+ }
84
+
35
85
  function commandResultFromError(error) {
36
86
  return {
37
87
  kind: "error",
@@ -57,6 +107,15 @@ function unwrapApply(result, fallbackMode, message) {
57
107
  generation: result.generation ?? null,
58
108
  mode: result.mode ?? fallbackMode,
59
109
  message,
110
+ ...(result.theme
111
+ ? {
112
+ theme: {
113
+ id: result.theme.id,
114
+ name: result.theme.name,
115
+ type: result.theme.type ?? null,
116
+ },
117
+ }
118
+ : {}),
60
119
  };
61
120
  }
62
121
 
@@ -69,6 +128,7 @@ function presentStatus(status) {
69
128
  muted: status.muted !== false,
70
129
  tone: status.tone ?? "dark",
71
130
  background: status.manifest?.background ?? status.background ?? null,
131
+ themeId: typeof status.themeId === "string" && status.themeId ? status.themeId : null,
72
132
  message: formatStatusText(status),
73
133
  };
74
134
  }
@@ -106,24 +166,40 @@ export function createBeauticodeActions(dataRootOrOptions) {
106
166
  fail("beauticode_apply_image 只接受图片文件。");
107
167
  }
108
168
  const effects = effectsForPreset(options?.effects?.preset) || options?.effects || null;
169
+ const persistTheme = options?.persistTheme !== false;
170
+ const themeName =
171
+ String(options?.themeName ?? "").trim() ||
172
+ themeNameFromFilePath(inspected.path, "图片");
109
173
  const input = { type: "image", imagePath: inspected.path };
110
174
  if (effects) input.effects = effects;
111
175
  const resolved = await backend();
112
176
  if (resolved.kind === "tray") {
113
- const body = { imagePath: inspected.path };
177
+ const body = { imagePath: inspected.path, persistTheme, themeName };
114
178
  if (effects) body.effects = effects;
115
- return unwrapApply(
116
- await request({
117
- method: "POST",
118
- path: "/apply/image",
119
- body,
120
- signal,
121
- }),
179
+ const result = await request({
180
+ method: "POST",
181
+ path: "/apply/image",
182
+ body,
183
+ signal,
184
+ });
185
+ return unwrapApplyResult(
186
+ result,
122
187
  "image",
123
- "已将图片设为背景。",
188
+ persistTheme
189
+ ? `已将「${result.theme?.name || themeName}」设为背景。`
190
+ : "已将图片设为背景。",
124
191
  );
125
192
  }
126
- return unwrapApply(await resolved.session.apply(input), "image", "已将图片设为背景。");
193
+ const applied = persistTheme
194
+ ? await resolved.session.applyImportedMedia(input, { name: themeName })
195
+ : await resolved.session.apply(input);
196
+ return unwrapApplyResult(
197
+ applied,
198
+ "image",
199
+ persistTheme
200
+ ? `已将「${applied.theme?.name || themeName}」设为背景。`
201
+ : "已将图片设为背景。",
202
+ );
127
203
  },
128
204
 
129
205
  async applyPreset(id, signal) {
@@ -132,6 +208,7 @@ export function createBeauticodeActions(dataRootOrOptions) {
132
208
  if (!preset || !imagePath) fail("未找到内置主题文件。");
133
209
  const result = await this.applyImage(imagePath, signal, {
134
210
  effects: effectsForPreset(id),
211
+ persistTheme: false,
135
212
  });
136
213
  try {
137
214
  await this.setTone(preset.tone, signal);
@@ -167,7 +244,11 @@ export function createBeauticodeActions(dataRootOrOptions) {
167
244
  if (inspected.kind !== "video") {
168
245
  fail("beauticode_apply_video 只接受 .mp4 文件。");
169
246
  }
170
- const body = { videoPath: inspected.path };
247
+ const persistTheme = input?.persistTheme !== false;
248
+ const themeName =
249
+ String(input?.themeName ?? "").trim() ||
250
+ themeNameFromFilePath(inspected.path, "视频");
251
+ const body = { videoPath: inspected.path, persistTheme, themeName };
171
252
  const localInput = { type: "video", videoPath: inspected.path };
172
253
  if (typeof input.poster === "string" && input.poster.trim()) {
173
254
  const poster = await inspectLocalMedia(input.poster);
@@ -186,21 +267,29 @@ export function createBeauticodeActions(dataRootOrOptions) {
186
267
  }
187
268
  const resolved = await backend();
188
269
  if (resolved.kind === "tray") {
189
- return unwrapApply(
190
- await request({
191
- method: "POST",
192
- path: "/apply/video",
193
- body,
194
- signal,
195
- }),
270
+ const result = await request({
271
+ method: "POST",
272
+ path: "/apply/video",
273
+ body,
274
+ signal,
275
+ });
276
+ return unwrapApplyResult(
277
+ result,
196
278
  "video",
197
- "已将视频设为背景。",
279
+ persistTheme
280
+ ? `已将「${result.theme?.name || themeName}」设为背景。`
281
+ : "已将视频设为背景。",
198
282
  );
199
283
  }
200
- return unwrapApply(
201
- await resolved.session.apply(localInput),
284
+ const applied = persistTheme
285
+ ? await resolved.session.applyImportedMedia(localInput, { name: themeName })
286
+ : await resolved.session.apply(localInput);
287
+ return unwrapApplyResult(
288
+ applied,
202
289
  "video",
203
- "已将视频设为背景。",
290
+ persistTheme
291
+ ? `已将「${applied.theme?.name || themeName}」设为背景。`
292
+ : "已将视频设为背景。",
204
293
  );
205
294
  },
206
295
 
@@ -311,6 +400,32 @@ export function createBeauticodeActions(dataRootOrOptions) {
311
400
  };
312
401
  },
313
402
 
403
+ async deleteTheme(id, signal) {
404
+ const themeId = String(id ?? "").trim();
405
+ if (!themeId) fail("必须提供主题。");
406
+ const resolved = await backend();
407
+ if (resolved.kind === "tray") {
408
+ const result = await request({
409
+ method: "POST",
410
+ path: "/theme/delete",
411
+ body: { id: themeId },
412
+ signal,
413
+ timeoutMs: QUICK_TIMEOUT_MS,
414
+ });
415
+ if (!result || result.ok === false) {
416
+ fail(await chineseError(result?.error || "删除主题失败。"));
417
+ }
418
+ return { ok: true, deleted: true, message: "已删除主题。" };
419
+ }
420
+ try {
421
+ const deleted = await resolved.session.deleteSavedTheme(themeId);
422
+ if (!deleted) fail("未找到已保存的主题。");
423
+ return { ok: true, deleted: true, message: "已删除主题。" };
424
+ } catch (error) {
425
+ fail(await chineseError(error));
426
+ }
427
+ },
428
+
314
429
  async setFish(enabled, signal) {
315
430
  const resolved = await backend();
316
431
  const want = Boolean(enabled);
package/console.js CHANGED
@@ -32,9 +32,13 @@ body:not([data-ds-dark-theme]) #beauticode-console-pop .bc-theme-toggle:hover{ba
32
32
  #beauticode-console-pop .bc-theme-toggle{width:100%;text-align:left}
33
33
  #beauticode-console-pop .bc-theme-list{margin:6px 0 0;max-height:160px;overflow:auto;border:1px solid rgba(255,255,255,.08);border-radius:12px;background:#232830;padding:4px}
34
34
  body:not([data-ds-dark-theme]) #beauticode-console-pop .bc-theme-list{border-color:rgba(0,0,0,.08);background:#f4f6f8}
35
- #beauticode-console-pop .bc-theme-item{cursor:pointer;display:block;width:100%;height:32px;padding:0 8px;border:none;border-radius:8px;background:transparent;color:inherit;font-size:13px;line-height:32px;text-align:left;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
35
+ #beauticode-console-pop .bc-theme-row{display:flex;gap:4px;align-items:center}
36
+ #beauticode-console-pop .bc-theme-item{cursor:pointer;display:block;flex:1;min-width:0;height:32px;padding:0 8px;border:none;border-radius:8px;background:transparent;color:inherit;font-size:13px;line-height:32px;text-align:left;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
36
37
  #beauticode-console-pop .bc-theme-item:hover{background:rgba(255,255,255,.08)}
37
38
  body:not([data-ds-dark-theme]) #beauticode-console-pop .bc-theme-item:hover{background:rgba(0,0,0,.06)}
39
+ #beauticode-console-pop .bc-theme-del{cursor:pointer;flex:none;width:32px;height:32px;padding:0;border:none;border-radius:8px;background:transparent;color:inherit;font-size:12px;opacity:.72}
40
+ #beauticode-console-pop .bc-theme-del:hover{background:rgba(255,255,255,.08);opacity:1}
41
+ body:not([data-ds-dark-theme]) #beauticode-console-pop .bc-theme-del:hover{background:rgba(0,0,0,.06)}
38
42
  #beauticode-console-pop .bc-msg{color:var(--dsw-alias-label-secondary,#9aa3ad);font-size:12px;line-height:16px;margin:6px 0 0;max-height:3.2em;overflow:hidden}
39
43
  #beauticode-console-file{display:none !important}
40
44
  `;
@@ -154,7 +158,11 @@ body:not([data-ds-dark-theme]) #beauticode-console-pop .bc-theme-item:hover{back
154
158
  ? "图片"
155
159
  : "无背景";
156
160
  statusEl.textContent = label;
157
- if (data.atmosphere === "gallery") currentThemeId = "builtin-gallery";
161
+ if (typeof data.themeId === "string" && data.themeId) {
162
+ currentThemeId = data.themeId;
163
+ } else if (data.atmosphere === "gallery") {
164
+ currentThemeId = "builtin-gallery";
165
+ }
158
166
  muted = data.muted !== false;
159
167
  soundBtn.classList.toggle("on", !muted);
160
168
  soundBtn.textContent = muted ? "声音" : "声音开";
@@ -168,10 +176,13 @@ body:not([data-ds-dark-theme]) #beauticode-console-pop .bc-theme-item:hover{back
168
176
  }
169
177
  themesBox.hidden = false;
170
178
  themeList.innerHTML = themes
171
- .map(
172
- (theme) =>
173
- `<button type="button" class="bc-theme-item" data-theme-id="${escapeAttr(theme.id)}">${escapeText(theme.name)}</button>`,
174
- )
179
+ .map((theme) => {
180
+ const del =
181
+ theme.bundled === true
182
+ ? ""
183
+ : `<button type="button" class="bc-theme-del" data-theme-delete="${escapeAttr(theme.id)}" data-theme-name="${escapeAttr(theme.name)}" aria-label="删除 ${escapeAttr(theme.name)}">删</button>`;
184
+ return `<div class="bc-theme-row"><button type="button" class="bc-theme-item" data-theme-id="${escapeAttr(theme.id)}">${escapeText(theme.name)}</button>${del}</div>`;
185
+ })
175
186
  .join("");
176
187
  const selected = themes.find((theme) => theme.id === currentThemeId);
177
188
  themeToggle.textContent = selected ? selected.name : "已保存主题";
@@ -213,17 +224,18 @@ body:not([data-ds-dark-theme]) #beauticode-console-pop .bc-theme-item:hover{back
213
224
  async function run(task) {
214
225
  if (busy) return;
215
226
  busy = true;
216
- for (const button of pop.querySelectorAll(".bc-btn, .bc-theme-toggle, .bc-theme-item")) button.disabled = true;
227
+ for (const button of pop.querySelectorAll(".bc-btn, .bc-theme-toggle, .bc-theme-item, .bc-theme-del")) button.disabled = true;
217
228
  showMessage("");
218
229
  try {
219
230
  const result = await task();
231
+ if (result?.theme?.id) currentThemeId = result.theme.id;
220
232
  if (result?.message) showMessage(result.message);
221
233
  await refresh();
222
234
  } catch (error) {
223
235
  showMessage(error instanceof Error ? error.message : String(error));
224
236
  } finally {
225
237
  busy = false;
226
- for (const button of pop.querySelectorAll(".bc-btn, .bc-theme-toggle, .bc-theme-item")) button.disabled = false;
238
+ for (const button of pop.querySelectorAll(".bc-btn, .bc-theme-toggle, .bc-theme-item, .bc-theme-del")) button.disabled = false;
227
239
  }
228
240
  }
229
241
 
@@ -268,6 +280,23 @@ body:not([data-ds-dark-theme]) #beauticode-console-pop .bc-theme-item:hover{back
268
280
  themeToggle.setAttribute("aria-expanded", open ? "true" : "false");
269
281
  });
270
282
  themeList.addEventListener("click", (event) => {
283
+ const del = event.target.closest("[data-theme-delete]");
284
+ if (del) {
285
+ event.stopPropagation();
286
+ const id = del.getAttribute("data-theme-delete") || "";
287
+ const name = del.getAttribute("data-theme-name") || "主题";
288
+ if (!id) return;
289
+ if (!window.confirm(`确定删除主题「${name}」?`)) return;
290
+ if (currentThemeId === id) currentThemeId = "";
291
+ void run(() =>
292
+ request("/__beauticode/ui/theme/delete", {
293
+ method: "POST",
294
+ headers: { "content-type": "application/json" },
295
+ body: JSON.stringify({ id }),
296
+ }),
297
+ );
298
+ return;
299
+ }
271
300
  const item = event.target.closest("[data-theme-id]");
272
301
  if (!item) return;
273
302
  currentThemeId = item.getAttribute("data-theme-id") || "";
@@ -288,7 +317,6 @@ body:not([data-ds-dark-theme]) #beauticode-console-pop .bc-theme-item:hover{back
288
317
  const file = fileInput.files?.[0];
289
318
  fileInput.value = "";
290
319
  if (!file) return;
291
- currentThemeId = "";
292
320
  void run(() =>
293
321
  request("/__beauticode/ui/import", {
294
322
  method: "POST",
package/index.mjs CHANGED
@@ -376,6 +376,11 @@ export function apply(ctx, config = {}) {
376
376
  path: "/__beauticode/ui/theme/use",
377
377
  handler: (req, res) => ui.useTheme(req, res),
378
378
  }),
379
+ ctx.webServer.register({
380
+ kind: "exact",
381
+ path: "/__beauticode/ui/theme/delete",
382
+ handler: (req, res) => ui.deleteTheme(req, res),
383
+ }),
379
384
  ctx.webServer.register({
380
385
  kind: "exact",
381
386
  path: "/__beauticode/ui/preset",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "beauticode-dsh",
3
- "version": "1.0.4",
3
+ "version": "1.0.5",
4
4
  "description": "Cordis plugin: image/video backgrounds for DeepSeek Harness web.",
5
5
  "type": "module",
6
6
  "main": "./index.mjs",
package/ui-host.mjs CHANGED
@@ -4,7 +4,7 @@ import path from "node:path";
4
4
  import crypto from "node:crypto";
5
5
  import { pipeline } from "node:stream/promises";
6
6
  import { Transform } from "node:stream";
7
- import { createBeauticodeActions } from "./agent.mjs";
7
+ import { createBeauticodeActions, themeNameFromFilePath } from "./agent.mjs";
8
8
  import { callDshControl } from "./control-client.mjs";
9
9
  import { createGalleryHandlers, resolveConfiguredSkinCenterUrl } from "./gallery-host.mjs";
10
10
  import { hasLiveTray, resolveApplyBackend, stopInProcessSession } from "./host-apply.mjs";
@@ -146,6 +146,7 @@ export function createBeauticodeUi({ dataRoot, baseUrl, getBaseUrl, sendJson, is
146
146
  media: background?.type ?? null,
147
147
  muted: status.muted !== false,
148
148
  atmosphere: background?.effects?.preset ?? null,
149
+ themeId: status.themeId || null,
149
150
  themes: publicThemes(listed.themes),
150
151
  message: status.message,
151
152
  skinCenter: { url: center, enabled: Boolean(center) },
@@ -187,10 +188,14 @@ export function createBeauticodeUi({ dataRoot, baseUrl, getBaseUrl, sendJson, is
187
188
  );
188
189
  try {
189
190
  await pipeline(req, limitBytes(parsed.maxBytes), fs.createWriteStream(tmpPath));
191
+ const themeName = themeNameFromFilePath(
192
+ parsed.name,
193
+ parsed.kind === "video" ? "视频" : "图片",
194
+ );
190
195
  const result =
191
196
  parsed.kind === "video"
192
- ? await actions.applyVideo({ path: tmpPath })
193
- : await actions.applyImage(tmpPath);
197
+ ? await actions.applyVideo({ path: tmpPath, themeName })
198
+ : await actions.applyImage(tmpPath, undefined, { themeName });
194
199
  sendJson(res, 200, result);
195
200
  } catch (error) {
196
201
  const status = error?.statusCode === 413 ? 413 : 422;
@@ -270,6 +275,30 @@ export function createBeauticodeUi({ dataRoot, baseUrl, getBaseUrl, sendJson, is
270
275
  }
271
276
  },
272
277
 
278
+ async deleteTheme(req, res) {
279
+ if (req.method !== "POST") {
280
+ res.writeHead(405).end();
281
+ return;
282
+ }
283
+ if (!isSameOrigin(req)) {
284
+ res.writeHead(403).end();
285
+ return;
286
+ }
287
+ const body = await readJson(req);
288
+ if (typeof body.id !== "string" || !body.id.trim()) {
289
+ sendJson(res, 400, { ok: false, error: "必须提供主题。" });
290
+ return;
291
+ }
292
+ try {
293
+ sendJson(res, 200, await actions.deleteTheme(body.id.trim()));
294
+ } catch (error) {
295
+ sendJson(res, 422, {
296
+ ok: false,
297
+ error: error instanceof Error ? error.message : String(error),
298
+ });
299
+ }
300
+ },
301
+
273
302
  async galleryConfig(req, res) {
274
303
  return gallery.config(req, res, sendJson, isSameOrigin);
275
304
  },
@@ -1,6 +1,6 @@
1
1
  import path from "node:path";
2
2
  import { fileURLToPath } from "node:url";
3
- import { ApplyTransaction, BackgroundStore, MediaServerController, buildHostApplyPayload, defaultDataRoot, resolveSessionBundledThemes, } from "../core/index.js";
3
+ import { ApplyTransaction, BackgroundStore, MediaServerController, buildHostApplyPayload, defaultDataRoot, persistAndApplyImportedMedia, resolveSessionBundledThemes, } from "../core/index.js";
4
4
  import { DshHostApplier, normalizeDshBaseUrl } from "./bridge.js";
5
5
  import { DSH_HOST_DESCRIPTOR } from "./host-descriptor.js";
6
6
  import { acquireDshInjectorLock } from "./injector-lock.js";
@@ -258,6 +258,7 @@ export class DshSession {
258
258
  fish: this.fishMode,
259
259
  muted: this.videoMuted,
260
260
  tone: this.backgroundTone,
261
+ themeId: this.activeThemeId,
261
262
  };
262
263
  }
263
264
  async saveCurrentTheme(name) {
@@ -274,18 +275,16 @@ export class DshSession {
274
275
  }
275
276
  }
276
277
  const theme = await this.store.saveCurrentTheme(name, { videoPositionSec });
277
- if (theme.type === "video") {
278
- this.activeThemeId = theme.id;
279
- this.lastProgressWriteSec = -1;
280
- }
281
- else {
282
- this.activeThemeId = null;
283
- }
278
+ this.activeThemeId = theme.id;
279
+ this.lastProgressWriteSec = -1;
284
280
  return theme;
285
281
  }
286
282
  async importSavedTheme(input) {
287
283
  return this.store.importSavedThemeFromMedia(input);
288
284
  }
285
+ async applyImportedMedia(input, opts = {}) {
286
+ return this.trackOperation(persistAndApplyImportedMedia(this, input, opts));
287
+ }
289
288
  async listSavedThemes() {
290
289
  return this.store.listSavedThemes();
291
290
  }
@@ -302,7 +301,7 @@ export class DshSession {
302
301
  const saved = await this.store.loadSavedTheme(themeId);
303
302
  const result = await this.apply(saved.input);
304
303
  if (result.ok) {
305
- this.activeThemeId = saved.input.type === "video" ? saved.themeId : null;
304
+ this.activeThemeId = saved.themeId;
306
305
  this.lastProgressWriteSec = -1;
307
306
  }
308
307
  return result;
@@ -707,15 +707,32 @@ export class BackgroundStore {
707
707
  if (existing)
708
708
  return existing;
709
709
  }
710
- const image = await validateImageFile(input.imagePath);
711
- const video = input.videoPath
712
- ? await validateVideoFile(input.videoPath)
713
- : null;
710
+ const imagePath = String(input.imagePath ?? "").trim();
711
+ const videoPath = String(input.videoPath ?? "").trim();
712
+ if (!imagePath && !videoPath) {
713
+ throw new MediaValidationError("Import requires an image or video file.");
714
+ }
715
+ const video = videoPath ? await validateVideoFile(videoPath) : null;
716
+ let image = null;
717
+ if (imagePath) {
718
+ image = await validateImageFile(imagePath);
719
+ }
720
+ else if (video) {
721
+ try {
722
+ const previous = await this.readActiveManifest();
723
+ if (previous.background?.image) {
724
+ image = await validateImageFile(path.join(this.paths.activeDir, previous.background.image));
725
+ }
726
+ }
727
+ catch {
728
+ image = null;
729
+ }
730
+ }
714
731
  const usage = await this.#savedThemeUsage();
715
732
  if (usage.count >= this.maxSavedThemes) {
716
733
  throw new MediaValidationError(`Saved theme limit reached (${this.maxSavedThemes}). Delete one before saving.`);
717
734
  }
718
- const incomingBytes = image.size + (video?.size ?? 0);
735
+ const incomingBytes = (image?.size ?? SYNTHETIC_POSTER_PNG.length) + (video?.size ?? 0);
719
736
  if (usage.bytes + incomingBytes > this.maxSavedBytes) {
720
737
  throw new MediaValidationError(`Saved theme storage limit exceeded (${this.maxSavedBytes} bytes). Delete a theme before saving.`);
721
738
  }
@@ -726,9 +743,17 @@ export class BackgroundStore {
726
743
  }
727
744
  const stagingDir = await this.#createStagingDir();
728
745
  try {
729
- const imageBasename = `poster${image.extension === ".jpeg" ? ".jpg" : image.extension}`;
730
- assertSafeBasename(imageBasename, "image");
731
- await copyFileAtomic(image.filePath, path.join(stagingDir, imageBasename));
746
+ let imageBasename;
747
+ if (image) {
748
+ imageBasename = `poster${image.extension === ".jpeg" ? ".jpg" : image.extension}`;
749
+ assertSafeBasename(imageBasename, "image");
750
+ await copyFileAtomic(image.filePath, path.join(stagingDir, imageBasename));
751
+ }
752
+ else {
753
+ imageBasename = "poster.png";
754
+ assertSafeBasename(imageBasename, "image");
755
+ await fs.writeFile(path.join(stagingDir, imageBasename), SYNTHETIC_POSTER_PNG);
756
+ }
732
757
  const background = video
733
758
  ? {
734
759
  type: "video",
@@ -760,6 +785,13 @@ export class BackgroundStore {
760
785
  };
761
786
  if (source)
762
787
  meta.source = source;
788
+ if (video) {
789
+ const pos = normalizeVideoPositionSec(input.videoPositionSec);
790
+ if (pos != null) {
791
+ meta.videoPositionSec = pos;
792
+ meta.videoPositionUpdatedAt = nowIso();
793
+ }
794
+ }
763
795
  await this.#writeJsonAtomic(path.join(stagingDir, SAVED_META_NAME), meta);
764
796
  await this.#validateTree(stagingDir, savedManifest);
765
797
  await fs.rename(stagingDir, dir);
@@ -1194,6 +1226,70 @@ export class BackgroundStore {
1194
1226
  return { count, bytes };
1195
1227
  }
1196
1228
  }
1229
+ /**
1230
+ * Display name for an imported file. Id stays a random ASCII folder; this is
1231
+ * only the label shown in 已保存主题.
1232
+ */
1233
+ export function themeNameFromFilePath(filePath, fallback = "主题") {
1234
+ const base = path.basename(String(filePath ?? "").replaceAll("\\", "/"));
1235
+ const ext = path.extname(base);
1236
+ let name = (ext ? base.slice(0, -ext.length) : base).trim();
1237
+ name = name
1238
+ .replace(/[<>:"/\\|?*]/g, " ")
1239
+ .replace(/[\u0000-\u001f]/g, "")
1240
+ .replace(/\s+/g, " ")
1241
+ .trim();
1242
+ if (!name)
1243
+ name = fallback;
1244
+ if (name.length > 80)
1245
+ name = name.slice(0, 80).trim();
1246
+ if (!name)
1247
+ name = fallback;
1248
+ return name;
1249
+ }
1250
+ export function importedThemeInputFromApply(input, name) {
1251
+ if (input.type === "image") {
1252
+ const next = {
1253
+ name: String(name ?? "").trim() || themeNameFromFilePath(input.imagePath, "图片"),
1254
+ imagePath: input.imagePath,
1255
+ };
1256
+ if (input.effects)
1257
+ next.effects = input.effects;
1258
+ return next;
1259
+ }
1260
+ const next = {
1261
+ name: String(name ?? "").trim() || themeNameFromFilePath(input.videoPath, "视频"),
1262
+ videoPath: input.videoPath,
1263
+ };
1264
+ if (input.imagePath)
1265
+ next.imagePath = input.imagePath;
1266
+ const pos = normalizeVideoPositionSec(input.startAt);
1267
+ if (pos != null)
1268
+ next.videoPositionSec = pos;
1269
+ return next;
1270
+ }
1271
+ export async function persistAndApplyImportedMedia(session, input, opts = {}) {
1272
+ if (input.type !== "image" && input.type !== "video") {
1273
+ return {
1274
+ ok: false,
1275
+ error: "Imported media must be an image or video.",
1276
+ rolledBack: false,
1277
+ };
1278
+ }
1279
+ let theme;
1280
+ try {
1281
+ theme = await session.importSavedTheme(importedThemeInputFromApply(input, opts.name));
1282
+ }
1283
+ catch (error) {
1284
+ return {
1285
+ ok: false,
1286
+ error: error instanceof Error ? error.message : String(error),
1287
+ rolledBack: false,
1288
+ };
1289
+ }
1290
+ const result = await session.useSavedTheme(theme.id);
1291
+ return { ...result, theme };
1292
+ }
1197
1293
  /** Normalize a playback position for theme meta. Invalid → null (caller treats as 0). */
1198
1294
  export function normalizeVideoPositionSec(value) {
1199
1295
  const n = typeof value === "number" ? value : Number(value);
@@ -126,6 +126,8 @@ export function toChineseErrorMessage(value) {
126
126
  [/No active background to save\.?/i, "当前没有可保存的背景。"],
127
127
  [/Saved theme limit reached \((\d+)\)\. Delete one before saving\.?/i, (match) => `已达到保存主题数量上限(${match[1]}),请先删除一个主题。`],
128
128
  [/Saved theme storage limit exceeded \((\d+) bytes\)\. Delete a theme before saving\.?/i, (match) => `已超过保存主题的存储上限(${match[1]} 字节),请先删除一个主题。`],
129
+ [/Import requires an image or video file\.?/i, "导入需要图片或视频文件。"],
130
+ [/Imported media must be an image or video\.?/i, "导入的媒体必须是图片或视频。"],
129
131
  [/Built-in theme cannot be deleted\.?/i, "内置主题不能删除。"],
130
132
  [/Invalid saved theme id\.?/i, "已保存主题 ID 无效。"],
131
133
  [/Invalid skin-center media path\.?/i, "皮肤中心地址无效。"],