beauticode-dsh 1.0.7 → 1.0.13
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 +2 -2
- package/agent.mjs +69 -52
- package/client.js +102 -22
- package/console.js +166 -46
- package/control-client.mjs +6 -4
- package/index.mjs +22 -8
- package/package.json +1 -1
- package/themes/internal-beyond/bg-canvas.png +0 -0
- package/ui-host.mjs +459 -31
- package/vendor/adapter-dsh/bridge.js +2 -4
- package/vendor/adapter-dsh/session.js +61 -27
- package/vendor/adapter-dsh/tray-handoff.js +13 -11
- package/vendor/core/apply-transaction.js +87 -45
- package/vendor/core/background-store.js +211 -351
- package/vendor/core/error-message.js +0 -8
- package/vendor/core/file-lock.js +13 -34
- package/vendor/core/index.js +1 -2
- package/vendor/core/media-server.js +22 -40
- package/vendor/core/media-source.js +36 -0
- package/vendor/core/media-validation.js +26 -19
- package/vendor/core/paths.js +0 -1
- package/vendor/core/process-liveness.js +0 -205
- package/vendor/core/skin-pack.js +0 -64
|
@@ -4,11 +4,11 @@ import { AsyncLocalStorage } from "node:async_hooks";
|
|
|
4
4
|
import crypto from "node:crypto";
|
|
5
5
|
import { COMMIT_MARKER_NAME, COMMIT_MARKER_STALE_MS, DEFAULT_VIDEO_BASENAME, MANIFEST_NAME, SAVED_META_NAME, SCHEMA_ID, } from "./constants.js";
|
|
6
6
|
import { assertSafeBasename, MediaValidationError, validateImageFile, validateVideoFile, } from "./media-validation.js";
|
|
7
|
-
import { copyFileAtomic, emptyDir, ensureDataLayout, isPathInsideRoot,
|
|
7
|
+
import { copyFileAtomic, emptyDir, ensureDataLayout, isPathInsideRoot, resolveDataPaths, rmrf, } from "./paths.js";
|
|
8
8
|
import { normalizeBackgroundEffects } from "./types.js";
|
|
9
|
+
import { isLocalBackgroundSource, managedPrimaryFile, resolveBackgroundImagePath, resolveBackgroundVideoPath, } from "./media-source.js";
|
|
9
10
|
import { BUNDLED_GALLERY_THEME_ID, } from "./bundled-gallery.js";
|
|
10
11
|
import { acquireFileLock } from "./file-lock.js";
|
|
11
|
-
import { normalizeSavedThemeSource, } from "./skin-pack.js";
|
|
12
12
|
/** 1×1 transparent PNG — poster when video apply has no prior image. */
|
|
13
13
|
const SYNTHETIC_POSTER_PNG = Buffer.from("89504e470d0a1a0a0000000d49484452000000010000000108060000001f15c4890000000a49444154789c63000100000500010d0a2db40000000049454e44ae426082", "hex");
|
|
14
14
|
const COMMIT_JOURNAL_NAME = ".beauticode-commit.json";
|
|
@@ -41,9 +41,33 @@ function isManifest(value) {
|
|
|
41
41
|
const b = v.background;
|
|
42
42
|
if (b.type !== "image" && b.type !== "video")
|
|
43
43
|
return false;
|
|
44
|
-
if (typeof b.image !== "string")
|
|
44
|
+
if (b.image != null && typeof b.image !== "string")
|
|
45
45
|
return false;
|
|
46
|
-
if (b.
|
|
46
|
+
if (b.video != null && typeof b.video !== "string")
|
|
47
|
+
return false;
|
|
48
|
+
if (b.source != null) {
|
|
49
|
+
if (!b.source || typeof b.source !== "object")
|
|
50
|
+
return false;
|
|
51
|
+
const source = b.source;
|
|
52
|
+
if (source.kind === "local") {
|
|
53
|
+
if (typeof source.path !== "string" || !path.isAbsolute(source.path))
|
|
54
|
+
return false;
|
|
55
|
+
}
|
|
56
|
+
else if (source.kind === "managed") {
|
|
57
|
+
if (typeof source.file !== "string")
|
|
58
|
+
return false;
|
|
59
|
+
}
|
|
60
|
+
else {
|
|
61
|
+
return false;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
// Legacy v1 manifests have no source field. New local image manifests do
|
|
65
|
+
// not need an active copy; video manifests still retain a managed poster.
|
|
66
|
+
if (!b.source && b.type === "image" && typeof b.image !== "string")
|
|
67
|
+
return false;
|
|
68
|
+
if (!b.source && b.type === "video" && typeof b.video !== "string")
|
|
69
|
+
return false;
|
|
70
|
+
if (b.type === "video" && typeof b.image !== "string")
|
|
47
71
|
return false;
|
|
48
72
|
if (b.effects != null && !normalizeBackgroundEffects(b.effects))
|
|
49
73
|
return false;
|
|
@@ -56,26 +80,27 @@ export class BackgroundStore {
|
|
|
56
80
|
maxSavedBytes;
|
|
57
81
|
bundledThemes;
|
|
58
82
|
#runtimeSessionName = `session-${process.pid}-${crypto.randomUUID()}`;
|
|
59
|
-
#
|
|
83
|
+
#runtimeVideoCache = new Map();
|
|
60
84
|
#writeChain = Promise.resolve();
|
|
61
85
|
#writeContext = new AsyncLocalStorage();
|
|
62
86
|
constructor(opts = {}) {
|
|
63
87
|
this.paths = resolveDataPaths(opts.root);
|
|
64
88
|
this.commitMarkerStaleMs = opts.commitMarkerStaleMs ?? COMMIT_MARKER_STALE_MS;
|
|
65
89
|
this.maxSavedThemes = opts.maxSavedThemes ?? 20;
|
|
66
|
-
this.maxSavedBytes = opts.maxSavedBytes ??
|
|
90
|
+
this.maxSavedBytes = opts.maxSavedBytes ?? 2 * 1024 * 1024 * 1024;
|
|
67
91
|
this.bundledThemes = normalizeBundledThemes(opts.bundledThemes);
|
|
68
92
|
}
|
|
69
93
|
async init() {
|
|
70
94
|
await ensureDataLayout(this.paths);
|
|
71
95
|
const markerFresh = await this.#isCommitMarkerFresh();
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
else {
|
|
96
|
+
// A fresh marker always belongs to a live transaction. Readers and
|
|
97
|
+
// re-entrant writer calls must leave its journal/staging tree untouched;
|
|
98
|
+
// recovery is only safe after the marker becomes stale or disappears.
|
|
99
|
+
if (markerFresh) {
|
|
77
100
|
return;
|
|
78
101
|
}
|
|
102
|
+
await this.#recoverInterruptedCommit();
|
|
103
|
+
await fs.mkdir(this.paths.activeDir, { recursive: true });
|
|
79
104
|
const manifestPath = path.join(this.paths.activeDir, MANIFEST_NAME);
|
|
80
105
|
try {
|
|
81
106
|
await fs.access(manifestPath);
|
|
@@ -202,10 +227,15 @@ export class BackgroundStore {
|
|
|
202
227
|
throw new MediaValidationError("Active background manifest failed schema checks.");
|
|
203
228
|
}
|
|
204
229
|
if (parsed.background) {
|
|
205
|
-
|
|
230
|
+
if (parsed.background.image) {
|
|
231
|
+
assertSafeBasename(parsed.background.image, "background.image");
|
|
232
|
+
}
|
|
206
233
|
if (parsed.background.video) {
|
|
207
234
|
assertSafeBasename(parsed.background.video, "background.video");
|
|
208
235
|
}
|
|
236
|
+
if (parsed.background.source?.kind === "managed") {
|
|
237
|
+
assertSafeBasename(parsed.background.source.file, "background.source.file");
|
|
238
|
+
}
|
|
209
239
|
}
|
|
210
240
|
return parsed;
|
|
211
241
|
}
|
|
@@ -213,14 +243,14 @@ export class BackgroundStore {
|
|
|
213
243
|
const m = await this.readActiveManifest();
|
|
214
244
|
if (!m.background)
|
|
215
245
|
return null;
|
|
216
|
-
return
|
|
246
|
+
return resolveBackgroundImagePath(this.paths.activeDir, m.background);
|
|
217
247
|
}
|
|
218
248
|
async activeVideoPath() {
|
|
219
249
|
const m = await this.readActiveManifest();
|
|
220
|
-
if (!m.background || m.background.type !== "video"
|
|
250
|
+
if (!m.background || m.background.type !== "video") {
|
|
221
251
|
return null;
|
|
222
252
|
}
|
|
223
|
-
return
|
|
253
|
+
return resolveBackgroundVideoPath(this.paths.activeDir, m.background);
|
|
224
254
|
}
|
|
225
255
|
/**
|
|
226
256
|
* Serialize mutations. Prevents concurrent stage/commit interleaving
|
|
@@ -243,99 +273,68 @@ export class BackgroundStore {
|
|
|
243
273
|
return run;
|
|
244
274
|
}
|
|
245
275
|
/**
|
|
246
|
-
*
|
|
247
|
-
*
|
|
248
|
-
*
|
|
249
|
-
*
|
|
250
|
-
* matches the video runtime path.
|
|
251
|
-
*/
|
|
252
|
-
async prepareRuntimeImage(manifest) {
|
|
253
|
-
if (!manifest.background?.image)
|
|
254
|
-
return null;
|
|
255
|
-
return this.#prepareRuntimeMedia({
|
|
256
|
-
generation: manifest.generation,
|
|
257
|
-
kind: "image",
|
|
258
|
-
sourceName: manifest.background.image,
|
|
259
|
-
ext: path.extname(manifest.background.image) || ".png",
|
|
260
|
-
validate: (filePath) => validateImageFile(filePath),
|
|
261
|
-
});
|
|
262
|
-
}
|
|
263
|
-
/**
|
|
264
|
-
* Copy the active video to a renderer-only path outside `active`.
|
|
265
|
-
*
|
|
266
|
-
* Chromium keeps a Windows file handle for videos attached through
|
|
267
|
-
* DOM.setFileInputFiles. Pointing it at `active/background.mp4` prevents the
|
|
268
|
-
* next atomic active-directory rotation with EPERM. The detached copy keeps
|
|
269
|
-
* renderer lifetime independent from the disk transaction.
|
|
276
|
+
* Resolve the renderer video path. Managed videos get a detached runtime
|
|
277
|
+
* copy because Chromium can keep an active-directory handle open. Local
|
|
278
|
+
* references already live outside active, so returning the source path avoids
|
|
279
|
+
* both the active copy and the runtime copy.
|
|
270
280
|
*/
|
|
271
281
|
async prepareRuntimeVideo(manifest) {
|
|
272
|
-
if (manifest.background?.type !== "video" ||
|
|
273
|
-
!manifest.background.video) {
|
|
274
|
-
return null;
|
|
275
|
-
}
|
|
276
|
-
return this.#prepareRuntimeMedia({
|
|
277
|
-
generation: manifest.generation,
|
|
278
|
-
kind: "video",
|
|
279
|
-
sourceName: manifest.background.video,
|
|
280
|
-
ext: ".mp4",
|
|
281
|
-
validate: (filePath) => validateVideoFile(filePath),
|
|
282
|
-
});
|
|
283
|
-
}
|
|
284
|
-
async #prepareRuntimeMedia(opts) {
|
|
285
282
|
return this.#withWriteLock(async () => {
|
|
283
|
+
if (manifest.background?.type !== "video") {
|
|
284
|
+
return null;
|
|
285
|
+
}
|
|
286
286
|
await this.init();
|
|
287
|
-
const
|
|
288
|
-
|
|
287
|
+
const source = resolveBackgroundVideoPath(this.paths.activeDir, manifest.background);
|
|
288
|
+
if (!source)
|
|
289
|
+
return null;
|
|
290
|
+
if (isLocalBackgroundSource(manifest.background)) {
|
|
291
|
+
await validateVideoFile(source, { mode: "fast" });
|
|
292
|
+
return source;
|
|
293
|
+
}
|
|
294
|
+
const cached = this.#runtimeVideoCache.get(manifest.generation);
|
|
289
295
|
if (cached) {
|
|
290
296
|
try {
|
|
291
|
-
await
|
|
297
|
+
await validateVideoFile(cached);
|
|
292
298
|
return cached;
|
|
293
299
|
}
|
|
294
300
|
catch {
|
|
295
|
-
this.#
|
|
301
|
+
this.#runtimeVideoCache.delete(manifest.generation);
|
|
296
302
|
}
|
|
297
303
|
}
|
|
298
|
-
const
|
|
299
|
-
const
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
304
|
+
const validated = await validateVideoFile(source);
|
|
305
|
+
const sessionDir = path.join(this.paths.runtimeMediaDir, this.#runtimeSessionName);
|
|
306
|
+
if (!isPathInsideRoot(this.paths.runtimeMediaDir, sessionDir)) {
|
|
307
|
+
throw new MediaValidationError("Runtime media path escaped runtime-media root.");
|
|
308
|
+
}
|
|
309
|
+
const runtimeRootStat = await fs.lstat(this.paths.runtimeMediaDir);
|
|
310
|
+
if (!runtimeRootStat.isDirectory() ||
|
|
311
|
+
runtimeRootStat.isSymbolicLink()) {
|
|
312
|
+
throw new MediaValidationError("Runtime media root must be a real directory.");
|
|
313
|
+
}
|
|
314
|
+
await fs.mkdir(sessionDir, { recursive: true });
|
|
315
|
+
const sessionStat = await fs.lstat(sessionDir);
|
|
316
|
+
if (!sessionStat.isDirectory() || sessionStat.isSymbolicLink()) {
|
|
317
|
+
throw new MediaValidationError("Runtime media session must be a real directory.");
|
|
318
|
+
}
|
|
319
|
+
const [realRuntimeRoot, realSessionDir] = await Promise.all([
|
|
320
|
+
fs.realpath(this.paths.runtimeMediaDir),
|
|
321
|
+
fs.realpath(sessionDir),
|
|
322
|
+
]);
|
|
323
|
+
if (!isPathInsideRoot(realRuntimeRoot, realSessionDir)) {
|
|
324
|
+
throw new MediaValidationError("Runtime media session escaped runtime-media root.");
|
|
325
|
+
}
|
|
326
|
+
const destination = path.join(sessionDir, `video-${manifest.generation}-${validated.identity.slice(0, 16)}.mp4`);
|
|
305
327
|
await copyFileAtomic(validated.filePath, destination);
|
|
306
|
-
const copied = await
|
|
328
|
+
const copied = await validateVideoFile(destination);
|
|
307
329
|
if (copied.identity !== validated.identity) {
|
|
308
330
|
await fs.rm(destination, { force: true }).catch(() => { });
|
|
309
|
-
throw new MediaValidationError(
|
|
331
|
+
throw new MediaValidationError("Runtime video copy failed its identity check.");
|
|
310
332
|
}
|
|
311
|
-
this.#
|
|
312
|
-
await this.#pruneRuntimeMediaUnlocked(
|
|
333
|
+
this.#runtimeVideoCache.set(manifest.generation, destination);
|
|
334
|
+
await this.#pruneRuntimeMediaUnlocked(destination);
|
|
313
335
|
return destination;
|
|
314
336
|
});
|
|
315
337
|
}
|
|
316
|
-
async #ensureRuntimeSessionDir() {
|
|
317
|
-
const sessionDir = path.join(this.paths.runtimeMediaDir, this.#runtimeSessionName);
|
|
318
|
-
if (!isPathInsideRoot(this.paths.runtimeMediaDir, sessionDir)) {
|
|
319
|
-
throw new MediaValidationError("Runtime media path escaped runtime-media root.");
|
|
320
|
-
}
|
|
321
|
-
const runtimeRootStat = await fs.lstat(this.paths.runtimeMediaDir);
|
|
322
|
-
if (!runtimeRootStat.isDirectory() || runtimeRootStat.isSymbolicLink()) {
|
|
323
|
-
throw new MediaValidationError("Runtime media root must be a real directory.");
|
|
324
|
-
}
|
|
325
|
-
await fs.mkdir(sessionDir, { recursive: true });
|
|
326
|
-
const sessionStat = await fs.lstat(sessionDir);
|
|
327
|
-
if (!sessionStat.isDirectory() || sessionStat.isSymbolicLink()) {
|
|
328
|
-
throw new MediaValidationError("Runtime media session must be a real directory.");
|
|
329
|
-
}
|
|
330
|
-
const [realRuntimeRoot, realSessionDir] = await Promise.all([
|
|
331
|
-
fs.realpath(this.paths.runtimeMediaDir),
|
|
332
|
-
fs.realpath(sessionDir),
|
|
333
|
-
]);
|
|
334
|
-
if (!isPathInsideRoot(realRuntimeRoot, realSessionDir)) {
|
|
335
|
-
throw new MediaValidationError("Runtime media session escaped runtime-media root.");
|
|
336
|
-
}
|
|
337
|
-
return sessionDir;
|
|
338
|
-
}
|
|
339
338
|
/**
|
|
340
339
|
* Best-effort cleanup. Locked stale files are retained and retried on the
|
|
341
340
|
* next apply/start instead of turning successful theme switching into an
|
|
@@ -344,15 +343,14 @@ export class BackgroundStore {
|
|
|
344
343
|
async pruneRuntimeMedia(keepPath = null) {
|
|
345
344
|
await this.#withWriteLock(async () => {
|
|
346
345
|
await this.init();
|
|
347
|
-
|
|
348
|
-
await this.#pruneRuntimeMediaUnlocked(keep);
|
|
346
|
+
await this.#pruneRuntimeMediaUnlocked(keepPath);
|
|
349
347
|
});
|
|
350
348
|
}
|
|
351
|
-
async #pruneRuntimeMediaUnlocked(
|
|
349
|
+
async #pruneRuntimeMediaUnlocked(keepPath) {
|
|
352
350
|
const runtimeRoot = path.resolve(this.paths.runtimeMediaDir);
|
|
353
|
-
const keep =
|
|
354
|
-
.
|
|
355
|
-
|
|
351
|
+
const keep = keepPath && isPathInsideRoot(runtimeRoot, keepPath)
|
|
352
|
+
? path.resolve(keepPath)
|
|
353
|
+
: null;
|
|
356
354
|
let sessions;
|
|
357
355
|
try {
|
|
358
356
|
sessions = await fs.readdir(runtimeRoot);
|
|
@@ -366,8 +364,7 @@ export class BackgroundStore {
|
|
|
366
364
|
const sessionDir = path.join(runtimeRoot, name);
|
|
367
365
|
if (!isPathInsideRoot(runtimeRoot, sessionDir))
|
|
368
366
|
continue;
|
|
369
|
-
|
|
370
|
-
if (!keepInSession) {
|
|
367
|
+
if (!keep || !isPathInsideRoot(sessionDir, keep)) {
|
|
371
368
|
await rmrf(sessionDir).catch(() => { });
|
|
372
369
|
continue;
|
|
373
370
|
}
|
|
@@ -380,15 +377,15 @@ export class BackgroundStore {
|
|
|
380
377
|
}
|
|
381
378
|
for (const file of files) {
|
|
382
379
|
const candidate = path.resolve(sessionDir, file);
|
|
383
|
-
if (
|
|
380
|
+
if (candidate === keep || !isPathInsideRoot(sessionDir, candidate)) {
|
|
384
381
|
continue;
|
|
385
382
|
}
|
|
386
383
|
await fs.rm(candidate, { recursive: true, force: true }).catch(() => { });
|
|
387
384
|
}
|
|
388
385
|
}
|
|
389
|
-
for (const [
|
|
390
|
-
if (
|
|
391
|
-
this.#
|
|
386
|
+
for (const [generation, cached] of this.#runtimeVideoCache) {
|
|
387
|
+
if (path.resolve(cached) !== keep) {
|
|
388
|
+
this.#runtimeVideoCache.delete(generation);
|
|
392
389
|
}
|
|
393
390
|
}
|
|
394
391
|
}
|
|
@@ -411,14 +408,7 @@ export class BackgroundStore {
|
|
|
411
408
|
await emptyDir(dir);
|
|
412
409
|
await this.#writeJsonAtomic(path.join(dir, MANIFEST_NAME), manifest);
|
|
413
410
|
if (manifest.background) {
|
|
414
|
-
|
|
415
|
-
const imgDst = path.join(dir, manifest.background.image);
|
|
416
|
-
await linkOrCopyFileAtomic(imgSrc, imgDst);
|
|
417
|
-
if (manifest.background.video) {
|
|
418
|
-
const vSrc = path.join(this.paths.activeDir, manifest.background.video);
|
|
419
|
-
const vDst = path.join(dir, manifest.background.video);
|
|
420
|
-
await linkOrCopyFileAtomic(vSrc, vDst);
|
|
421
|
-
}
|
|
411
|
+
await this.#copyManagedBackgroundFiles(this.paths.activeDir, dir, manifest.background);
|
|
422
412
|
}
|
|
423
413
|
return { id, dir, manifest };
|
|
424
414
|
});
|
|
@@ -460,20 +450,45 @@ export class BackgroundStore {
|
|
|
460
450
|
try {
|
|
461
451
|
let background = null;
|
|
462
452
|
if (input.type === "image") {
|
|
463
|
-
const
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
await
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
if (
|
|
471
|
-
background
|
|
453
|
+
const mode = input.source ?? "managed";
|
|
454
|
+
if (mode !== "local" && mode !== "managed") {
|
|
455
|
+
throw new MediaValidationError(`Unsupported image import mode: ${mode}`);
|
|
456
|
+
}
|
|
457
|
+
const image = await validateImageFile(input.imagePath, {
|
|
458
|
+
mode: mode === "local" ? "fast" : "full",
|
|
459
|
+
});
|
|
460
|
+
if (mode === "local") {
|
|
461
|
+
background = {
|
|
462
|
+
type: "image",
|
|
463
|
+
source: { kind: "local", path: image.filePath },
|
|
464
|
+
};
|
|
465
|
+
const effects = normalizeBackgroundEffects(input.effects);
|
|
466
|
+
if (effects)
|
|
467
|
+
background.effects = effects;
|
|
468
|
+
}
|
|
469
|
+
else {
|
|
470
|
+
// Keep extension from source when it's an allowed one.
|
|
471
|
+
const basename = `poster${image.extension === ".jpeg" ? ".jpg" : image.extension}`;
|
|
472
|
+
assertSafeBasename(basename, "image");
|
|
473
|
+
await copyFileAtomic(image.filePath, path.join(stagingDir, basename));
|
|
474
|
+
background = { type: "image", image: basename };
|
|
475
|
+
const effects = normalizeBackgroundEffects(input.effects);
|
|
476
|
+
if (effects)
|
|
477
|
+
background.effects = effects;
|
|
478
|
+
}
|
|
472
479
|
}
|
|
473
480
|
else if (input.type === "video") {
|
|
474
|
-
const
|
|
475
|
-
|
|
476
|
-
|
|
481
|
+
const mode = input.source ?? "managed";
|
|
482
|
+
if (mode !== "local" && mode !== "managed") {
|
|
483
|
+
throw new MediaValidationError(`Unsupported video import mode: ${mode}`);
|
|
484
|
+
}
|
|
485
|
+
const video = await validateVideoFile(input.videoPath, {
|
|
486
|
+
mode: mode === "local" ? "fast" : "full",
|
|
487
|
+
});
|
|
488
|
+
if (mode === "managed") {
|
|
489
|
+
assertSafeBasename(DEFAULT_VIDEO_BASENAME, "video");
|
|
490
|
+
await copyFileAtomic(video.filePath, path.join(stagingDir, DEFAULT_VIDEO_BASENAME));
|
|
491
|
+
}
|
|
477
492
|
// Poster: explicit path → reuse active image → synthetic 1x1 PNG.
|
|
478
493
|
let imageBasename;
|
|
479
494
|
if (input.imagePath) {
|
|
@@ -482,8 +497,11 @@ export class BackgroundStore {
|
|
|
482
497
|
assertSafeBasename(imageBasename, "image");
|
|
483
498
|
await copyFileAtomic(image.filePath, path.join(stagingDir, imageBasename));
|
|
484
499
|
}
|
|
485
|
-
else if (previous.background
|
|
486
|
-
const prevImage =
|
|
500
|
+
else if (previous.background) {
|
|
501
|
+
const prevImage = resolveBackgroundImagePath(this.paths.activeDir, previous.background);
|
|
502
|
+
if (!prevImage) {
|
|
503
|
+
throw new MediaValidationError("Previous background has no poster image.");
|
|
504
|
+
}
|
|
487
505
|
const image = await validateImageFile(prevImage);
|
|
488
506
|
imageBasename = `poster${image.extension === ".jpeg" ? ".jpg" : image.extension}`;
|
|
489
507
|
assertSafeBasename(imageBasename, "image");
|
|
@@ -497,7 +515,9 @@ export class BackgroundStore {
|
|
|
497
515
|
background = {
|
|
498
516
|
type: "video",
|
|
499
517
|
image: imageBasename,
|
|
500
|
-
|
|
518
|
+
...(mode === "local"
|
|
519
|
+
? { source: { kind: "local", path: video.filePath } }
|
|
520
|
+
: { video: DEFAULT_VIDEO_BASENAME }),
|
|
501
521
|
};
|
|
502
522
|
}
|
|
503
523
|
else {
|
|
@@ -540,10 +560,7 @@ export class BackgroundStore {
|
|
|
540
560
|
try {
|
|
541
561
|
await this.#writeJsonAtomic(path.join(stagingDir, MANIFEST_NAME), restored);
|
|
542
562
|
if (restored.background) {
|
|
543
|
-
await
|
|
544
|
-
if (restored.background.video) {
|
|
545
|
-
await copyFileAtomic(path.join(snapshot.dir, restored.background.video), path.join(stagingDir, restored.background.video));
|
|
546
|
-
}
|
|
563
|
+
await this.#copyManagedBackgroundFiles(snapshot.dir, stagingDir, restored.background);
|
|
547
564
|
}
|
|
548
565
|
await this.#validateTree(stagingDir, restored);
|
|
549
566
|
await this.#promoteStagingToActive(stagingDir);
|
|
@@ -557,11 +574,29 @@ export class BackgroundStore {
|
|
|
557
574
|
async #validateTree(dir, manifest) {
|
|
558
575
|
if (!manifest.background)
|
|
559
576
|
return;
|
|
560
|
-
const imagePath =
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
577
|
+
const imagePath = resolveBackgroundImagePath(dir, manifest.background);
|
|
578
|
+
if (!imagePath) {
|
|
579
|
+
throw new MediaValidationError("Background has no image source.");
|
|
580
|
+
}
|
|
581
|
+
await validateImageFile(imagePath, {
|
|
582
|
+
mode: manifest.background.type === "image" && isLocalBackgroundSource(manifest.background)
|
|
583
|
+
? "fast"
|
|
584
|
+
: "full",
|
|
585
|
+
});
|
|
586
|
+
const videoPath = resolveBackgroundVideoPath(dir, manifest.background);
|
|
587
|
+
if (videoPath) {
|
|
588
|
+
await validateVideoFile(videoPath, {
|
|
589
|
+
mode: isLocalBackgroundSource(manifest.background) ? "fast" : "full",
|
|
590
|
+
});
|
|
591
|
+
}
|
|
592
|
+
}
|
|
593
|
+
async #copyManagedBackgroundFiles(sourceDir, destinationDir, background) {
|
|
594
|
+
if (background.image) {
|
|
595
|
+
await copyFileAtomic(path.join(sourceDir, background.image), path.join(destinationDir, background.image));
|
|
596
|
+
}
|
|
597
|
+
const primaryFile = managedPrimaryFile(background);
|
|
598
|
+
if (primaryFile && primaryFile !== background.image) {
|
|
599
|
+
await copyFileAtomic(path.join(sourceDir, primaryFile), path.join(destinationDir, primaryFile));
|
|
565
600
|
}
|
|
566
601
|
}
|
|
567
602
|
async #promoteStagingToActive(stagingDir) {
|
|
@@ -614,7 +649,7 @@ export class BackgroundStore {
|
|
|
614
649
|
if (!trimmed || trimmed.length > 80) {
|
|
615
650
|
throw new MediaValidationError("Theme name must be 1–80 characters.");
|
|
616
651
|
}
|
|
617
|
-
if (/[<>:"
|
|
652
|
+
if (/[<>:"/\\|?*]/.test(trimmed) || Array.from(trimmed).some((ch) => ch.charCodeAt(0) < 32)) {
|
|
618
653
|
throw new MediaValidationError("Theme name contains illegal characters.");
|
|
619
654
|
}
|
|
620
655
|
const manifest = await this.readActiveManifest();
|
|
@@ -625,9 +660,19 @@ export class BackgroundStore {
|
|
|
625
660
|
if (usage.count >= this.maxSavedThemes) {
|
|
626
661
|
throw new MediaValidationError(`Saved theme limit reached (${this.maxSavedThemes}). Delete one before saving.`);
|
|
627
662
|
}
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
663
|
+
const imagePath = resolveBackgroundImagePath(this.paths.activeDir, manifest.background);
|
|
664
|
+
const primaryPath = manifest.background.type === "video"
|
|
665
|
+
? resolveBackgroundVideoPath(this.paths.activeDir, manifest.background)
|
|
666
|
+
: imagePath;
|
|
667
|
+
if (!imagePath || !primaryPath) {
|
|
668
|
+
throw new MediaValidationError("Active background has no resolvable media source.");
|
|
669
|
+
}
|
|
670
|
+
let incomingBytes = 0;
|
|
671
|
+
if (manifest.background.type === "video") {
|
|
672
|
+
incomingBytes += (await fs.stat(imagePath)).size;
|
|
673
|
+
}
|
|
674
|
+
if (!isLocalBackgroundSource(manifest.background)) {
|
|
675
|
+
incomingBytes += (await fs.stat(primaryPath)).size;
|
|
631
676
|
}
|
|
632
677
|
if (usage.bytes + incomingBytes > this.maxSavedBytes) {
|
|
633
678
|
throw new MediaValidationError(`Saved theme storage limit exceeded (${this.maxSavedBytes} bytes). Delete a theme before saving.`);
|
|
@@ -639,11 +684,7 @@ export class BackgroundStore {
|
|
|
639
684
|
}
|
|
640
685
|
const stagingDir = await this.#createStagingDir();
|
|
641
686
|
try {
|
|
642
|
-
|
|
643
|
-
await copyFileAtomic(imageSrc, path.join(stagingDir, manifest.background.image));
|
|
644
|
-
if (manifest.background.video) {
|
|
645
|
-
await copyFileAtomic(path.join(this.paths.activeDir, manifest.background.video), path.join(stagingDir, manifest.background.video));
|
|
646
|
-
}
|
|
687
|
+
await this.#copyManagedBackgroundFiles(this.paths.activeDir, stagingDir, manifest.background);
|
|
647
688
|
// Persist media only — generation is assigned on restore.
|
|
648
689
|
const savedManifest = {
|
|
649
690
|
schema: SCHEMA_ID,
|
|
@@ -675,6 +716,7 @@ export class BackgroundStore {
|
|
|
675
716
|
type: manifest.background.type,
|
|
676
717
|
path: dir,
|
|
677
718
|
savedAt: meta.savedAt,
|
|
719
|
+
sourceMode: isLocalBackgroundSource(manifest.background) ? "local" : "managed",
|
|
678
720
|
};
|
|
679
721
|
if (typeof meta.videoPositionSec === "number") {
|
|
680
722
|
info.videoPositionSec = meta.videoPositionSec;
|
|
@@ -686,138 +728,6 @@ export class BackgroundStore {
|
|
|
686
728
|
}
|
|
687
729
|
});
|
|
688
730
|
}
|
|
689
|
-
/**
|
|
690
|
-
* Copy validated local media into saved/ without changing the active
|
|
691
|
-
* background. Used by the skin-center installer.
|
|
692
|
-
*/
|
|
693
|
-
async importSavedThemeFromMedia(input) {
|
|
694
|
-
return this.#withWriteLock(async () => {
|
|
695
|
-
await this.init();
|
|
696
|
-
const trimmed = String(input?.name ?? "").trim();
|
|
697
|
-
if (!trimmed || trimmed.length > 80) {
|
|
698
|
-
throw new MediaValidationError("Theme name must be 1–80 characters.");
|
|
699
|
-
}
|
|
700
|
-
if (/[<>:"/\\|?*]/.test(trimmed) ||
|
|
701
|
-
Array.from(trimmed).some((ch) => ch.charCodeAt(0) < 32)) {
|
|
702
|
-
throw new MediaValidationError("Theme name contains illegal characters.");
|
|
703
|
-
}
|
|
704
|
-
const source = normalizeSavedThemeSource(input.source);
|
|
705
|
-
if (source) {
|
|
706
|
-
const existing = await this.findSavedThemeBySource(source);
|
|
707
|
-
if (existing)
|
|
708
|
-
return existing;
|
|
709
|
-
}
|
|
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
|
-
}
|
|
731
|
-
const usage = await this.#savedThemeUsage();
|
|
732
|
-
if (usage.count >= this.maxSavedThemes) {
|
|
733
|
-
throw new MediaValidationError(`Saved theme limit reached (${this.maxSavedThemes}). Delete one before saving.`);
|
|
734
|
-
}
|
|
735
|
-
const incomingBytes = (image?.size ?? SYNTHETIC_POSTER_PNG.length) + (video?.size ?? 0);
|
|
736
|
-
if (usage.bytes + incomingBytes > this.maxSavedBytes) {
|
|
737
|
-
throw new MediaValidationError(`Saved theme storage limit exceeded (${this.maxSavedBytes} bytes). Delete a theme before saving.`);
|
|
738
|
-
}
|
|
739
|
-
const id = slugThemeId(trimmed);
|
|
740
|
-
const dir = path.join(this.paths.savedDir, id);
|
|
741
|
-
if (!isPathInsideRoot(this.paths.savedDir, dir)) {
|
|
742
|
-
throw new MediaValidationError("Saved theme path escaped saved root.");
|
|
743
|
-
}
|
|
744
|
-
const stagingDir = await this.#createStagingDir();
|
|
745
|
-
try {
|
|
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
|
-
}
|
|
757
|
-
const background = video
|
|
758
|
-
? {
|
|
759
|
-
type: "video",
|
|
760
|
-
image: imageBasename,
|
|
761
|
-
video: DEFAULT_VIDEO_BASENAME,
|
|
762
|
-
}
|
|
763
|
-
: { type: "image", image: imageBasename };
|
|
764
|
-
if (!video) {
|
|
765
|
-
const effects = normalizeBackgroundEffects(input.effects);
|
|
766
|
-
if (effects)
|
|
767
|
-
background.effects = effects;
|
|
768
|
-
}
|
|
769
|
-
if (video) {
|
|
770
|
-
assertSafeBasename(DEFAULT_VIDEO_BASENAME, "video");
|
|
771
|
-
await copyFileAtomic(video.filePath, path.join(stagingDir, DEFAULT_VIDEO_BASENAME));
|
|
772
|
-
}
|
|
773
|
-
const savedManifest = {
|
|
774
|
-
schema: SCHEMA_ID,
|
|
775
|
-
generation: 0,
|
|
776
|
-
background,
|
|
777
|
-
updatedAt: nowIso(),
|
|
778
|
-
};
|
|
779
|
-
await this.#writeJsonAtomic(path.join(stagingDir, MANIFEST_NAME), savedManifest);
|
|
780
|
-
const meta = {
|
|
781
|
-
id,
|
|
782
|
-
name: trimmed,
|
|
783
|
-
type: background.type,
|
|
784
|
-
savedAt: nowIso(),
|
|
785
|
-
};
|
|
786
|
-
if (source)
|
|
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
|
-
}
|
|
795
|
-
await this.#writeJsonAtomic(path.join(stagingDir, SAVED_META_NAME), meta);
|
|
796
|
-
await this.#validateTree(stagingDir, savedManifest);
|
|
797
|
-
await fs.rename(stagingDir, dir);
|
|
798
|
-
return {
|
|
799
|
-
id,
|
|
800
|
-
name: trimmed,
|
|
801
|
-
type: background.type,
|
|
802
|
-
path: dir,
|
|
803
|
-
savedAt: meta.savedAt,
|
|
804
|
-
...(source ? { source } : {}),
|
|
805
|
-
};
|
|
806
|
-
}
|
|
807
|
-
finally {
|
|
808
|
-
await rmrf(stagingDir);
|
|
809
|
-
}
|
|
810
|
-
});
|
|
811
|
-
}
|
|
812
|
-
async findSavedThemeBySource(source) {
|
|
813
|
-
const wanted = normalizeSavedThemeSource(source);
|
|
814
|
-
if (!wanted)
|
|
815
|
-
return null;
|
|
816
|
-
const listed = await this.listSavedThemes();
|
|
817
|
-
return (listed.find((theme) => theme.source?.kind === wanted.kind &&
|
|
818
|
-
theme.source.skinId === wanted.skinId &&
|
|
819
|
-
theme.source.centerUrl === wanted.centerUrl) ?? null);
|
|
820
|
-
}
|
|
821
731
|
async listSavedThemes() {
|
|
822
732
|
await this.init();
|
|
823
733
|
let entries = [];
|
|
@@ -847,17 +757,16 @@ export class BackgroundStore {
|
|
|
847
757
|
// Directory basename is the authoritative id (filesystem truth).
|
|
848
758
|
// Older builds stored non-ASCII meta.id that useSavedTheme rejected.
|
|
849
759
|
const pos = normalizeVideoPositionSec(meta.videoPositionSec);
|
|
850
|
-
const source = normalizeSavedThemeSource(meta.source);
|
|
851
760
|
out.push({
|
|
852
761
|
id: entry,
|
|
853
762
|
name: meta.name,
|
|
854
763
|
type: m.background.type,
|
|
855
764
|
path: dir,
|
|
856
765
|
savedAt: typeof meta.savedAt === "string" ? meta.savedAt : "",
|
|
766
|
+
sourceMode: isLocalBackgroundSource(m.background) ? "local" : "managed",
|
|
857
767
|
...(pos != null && m.background.type === "video"
|
|
858
768
|
? { videoPositionSec: pos }
|
|
859
769
|
: {}),
|
|
860
|
-
...(source ? { source } : {}),
|
|
861
770
|
});
|
|
862
771
|
}
|
|
863
772
|
catch {
|
|
@@ -1007,11 +916,18 @@ export class BackgroundStore {
|
|
|
1007
916
|
throw new MediaValidationError("Saved theme manifest is invalid.");
|
|
1008
917
|
}
|
|
1009
918
|
await this.#validateTree(dir, parsed);
|
|
1010
|
-
const imagePath =
|
|
919
|
+
const imagePath = resolveBackgroundImagePath(dir, parsed.background);
|
|
920
|
+
if (!imagePath) {
|
|
921
|
+
throw new MediaValidationError("Saved theme has no image source.");
|
|
922
|
+
}
|
|
923
|
+
const sourceMode = isLocalBackgroundSource(parsed.background)
|
|
924
|
+
? "local"
|
|
925
|
+
: "managed";
|
|
1011
926
|
if (parsed.background.type === "image") {
|
|
1012
927
|
const input = {
|
|
1013
928
|
type: "image",
|
|
1014
929
|
imagePath,
|
|
930
|
+
source: sourceMode,
|
|
1015
931
|
};
|
|
1016
932
|
const effects = normalizeBackgroundEffects(parsed.background.effects);
|
|
1017
933
|
if (effects)
|
|
@@ -1022,7 +938,8 @@ export class BackgroundStore {
|
|
|
1022
938
|
themeId: id,
|
|
1023
939
|
};
|
|
1024
940
|
}
|
|
1025
|
-
|
|
941
|
+
const videoPath = resolveBackgroundVideoPath(dir, parsed.background);
|
|
942
|
+
if (!videoPath) {
|
|
1026
943
|
throw new MediaValidationError("Saved video theme has no video file.");
|
|
1027
944
|
}
|
|
1028
945
|
let videoPositionSec = null;
|
|
@@ -1037,7 +954,8 @@ export class BackgroundStore {
|
|
|
1037
954
|
const input = {
|
|
1038
955
|
type: "video",
|
|
1039
956
|
imagePath,
|
|
1040
|
-
videoPath
|
|
957
|
+
videoPath,
|
|
958
|
+
source: sourceMode,
|
|
1041
959
|
};
|
|
1042
960
|
if (videoPositionSec != null && videoPositionSec > 0) {
|
|
1043
961
|
input.startAt = videoPositionSec;
|
|
@@ -1099,10 +1017,7 @@ export class BackgroundStore {
|
|
|
1099
1017
|
const stagingDir = await this.#createStagingDir();
|
|
1100
1018
|
try {
|
|
1101
1019
|
await this.#writeJsonAtomic(path.join(stagingDir, MANIFEST_NAME), restored);
|
|
1102
|
-
await
|
|
1103
|
-
if (restored.background.video) {
|
|
1104
|
-
await copyFileAtomic(path.join(dir, restored.background.video), path.join(stagingDir, restored.background.video));
|
|
1105
|
-
}
|
|
1020
|
+
await this.#copyManagedBackgroundFiles(dir, stagingDir, restored.background);
|
|
1106
1021
|
await this.#validateTree(stagingDir, restored);
|
|
1107
1022
|
await this.#promoteStagingToActive(stagingDir);
|
|
1108
1023
|
const manifest = await this.readActiveManifest();
|
|
@@ -1191,6 +1106,7 @@ export class BackgroundStore {
|
|
|
1191
1106
|
type: "image",
|
|
1192
1107
|
path: spec.imagePath,
|
|
1193
1108
|
savedAt: "",
|
|
1109
|
+
sourceMode: "managed",
|
|
1194
1110
|
bundled: true,
|
|
1195
1111
|
});
|
|
1196
1112
|
}
|
|
@@ -1214,9 +1130,17 @@ export class BackgroundStore {
|
|
|
1214
1130
|
const manifest = JSON.parse(raw);
|
|
1215
1131
|
if (!isManifest(manifest) || !manifest.background)
|
|
1216
1132
|
continue;
|
|
1217
|
-
|
|
1218
|
-
|
|
1219
|
-
|
|
1133
|
+
const imagePath = resolveBackgroundImagePath(theme.path, manifest.background);
|
|
1134
|
+
const primaryPath = manifest.background.type === "video"
|
|
1135
|
+
? resolveBackgroundVideoPath(theme.path, manifest.background)
|
|
1136
|
+
: imagePath;
|
|
1137
|
+
if (!imagePath || !primaryPath)
|
|
1138
|
+
continue;
|
|
1139
|
+
if (manifest.background.type === "video") {
|
|
1140
|
+
bytes += (await fs.stat(imagePath)).size;
|
|
1141
|
+
}
|
|
1142
|
+
if (!isLocalBackgroundSource(manifest.background)) {
|
|
1143
|
+
bytes += (await fs.stat(primaryPath)).size;
|
|
1220
1144
|
}
|
|
1221
1145
|
}
|
|
1222
1146
|
catch {
|
|
@@ -1226,70 +1150,6 @@ export class BackgroundStore {
|
|
|
1226
1150
|
return { count, bytes };
|
|
1227
1151
|
}
|
|
1228
1152
|
}
|
|
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
|
-
}
|
|
1293
1153
|
/** Normalize a playback position for theme meta. Invalid → null (caller treats as 0). */
|
|
1294
1154
|
export function normalizeVideoPositionSec(value) {
|
|
1295
1155
|
const n = typeof value === "number" ? value : Number(value);
|