fundus 0.0.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/dist/build-CZYFLLvl.js +191 -0
- package/dist/cli.d.ts +1 -0
- package/dist/cli.js +1420 -0
- package/dist/config.d.ts +2 -0
- package/dist/config.js +2 -0
- package/dist/core/image-Dqmz8FXS.js +54 -0
- package/dist/core/index.d.ts +499 -0
- package/dist/core/index.js +962 -0
- package/dist/core/reference-graph-CjuHbLDw.d.ts +280 -0
- package/dist/core/testing.d.ts +27 -0
- package/dist/core/testing.js +80 -0
- package/dist/editor/200.html +37 -0
- package/dist/editor/_app/immutable/assets/0.DLhIsYA_.css +1 -0
- package/dist/editor/_app/immutable/assets/2.DIbbW7U1.css +1 -0
- package/dist/editor/_app/immutable/assets/3.C-9vFQ9A.css +1 -0
- package/dist/editor/_app/immutable/assets/4.BS8ElpOV.css +1 -0
- package/dist/editor/_app/immutable/assets/ConfirmDialog.CHEISE-L.css +1 -0
- package/dist/editor/_app/immutable/assets/LibraryContextMenu.CDjCmdV-.css +1 -0
- package/dist/editor/_app/immutable/chunks/BfPE5wdt.js +1 -0
- package/dist/editor/_app/immutable/chunks/Bjy-W4x2.js +81 -0
- package/dist/editor/_app/immutable/chunks/CNRz3sSw.js +3 -0
- package/dist/editor/_app/immutable/chunks/CO4RBJfa.js +1 -0
- package/dist/editor/_app/immutable/chunks/Cx6qtxJg.js +1 -0
- package/dist/editor/_app/immutable/chunks/DjdrdRzT.js +1 -0
- package/dist/editor/_app/immutable/chunks/DjvOiDmq.js +1 -0
- package/dist/editor/_app/immutable/chunks/n7mvYc42.js +5 -0
- package/dist/editor/_app/immutable/chunks/xihTtKlq.js +1 -0
- package/dist/editor/_app/immutable/entry/app.BkqbfI8n.js +2 -0
- package/dist/editor/_app/immutable/entry/start.C8HHOLly.js +1 -0
- package/dist/editor/_app/immutable/nodes/0.xdMI5GEU.js +2 -0
- package/dist/editor/_app/immutable/nodes/1.D9p2tfoJ.js +1 -0
- package/dist/editor/_app/immutable/nodes/2.BrA0Hyzu.js +3 -0
- package/dist/editor/_app/immutable/nodes/3.WO7tZH_d.js +104 -0
- package/dist/editor/_app/immutable/nodes/4.DQtx17aW.js +2 -0
- package/dist/editor/_app/version.json +1 -0
- package/dist/index.d.ts +370 -0
- package/dist/index.js +4 -0
- package/dist/operations-DYM-5KVl.js +1797 -0
- package/dist/runtime/chroma-key-gl.d.ts +23 -0
- package/dist/runtime/chroma-key-gl.js +290 -0
- package/dist/runtime/components/ChromaKeyVideo.svelte +225 -0
- package/dist/runtime/components/Image.svelte +18 -0
- package/dist/runtime/components/Slice.svelte +35 -0
- package/dist/runtime/components/SliceCanvas.svelte +162 -0
- package/dist/runtime/components/SliceDom.svelte +104 -0
- package/dist/runtime/components/Video.svelte +60 -0
- package/dist/runtime/geometry.d.ts +75 -0
- package/dist/runtime/geometry.js +209 -0
- package/dist/runtime/index.d.ts +9 -0
- package/dist/runtime/index.js +9 -0
- package/dist/runtime/kind-handlers.d.ts +46 -0
- package/dist/runtime/kind-handlers.js +86 -0
- package/dist/runtime/manifest.d.ts +60 -0
- package/dist/runtime/manifest.js +117 -0
- package/dist/runtime/observe-border-box-size.d.ts +5 -0
- package/dist/runtime/observe-border-box-size.js +12 -0
- package/dist/runtime/preload-registry.d.ts +26 -0
- package/dist/runtime/preload-registry.js +68 -0
- package/dist/start-Bhnqt9Dc.js +279 -0
- package/dist/start-D2IXOrni.js +2 -0
- package/package.json +65 -0
|
@@ -0,0 +1,1797 @@
|
|
|
1
|
+
import { mkdir, readFile, readdir, rename, rm, stat, unlink, writeFile } from "node:fs/promises";
|
|
2
|
+
import { basename, dirname, extname, isAbsolute, join, resolve } from "node:path";
|
|
3
|
+
import { AUDIO_BUILT_IN_PRESETS, FOLDER_SENTINEL, IMAGE_BUILT_IN_PRESETS, SLICE_BUILT_IN_PRESETS, VIDEO_BUILT_IN_PRESETS, allDeliveredAssetIds, assetIdError, audioPlugin, canonicalizeParameters, chromaKeyVideoPlugin, collectAllReferences, compareCodeUnits, computeLibraryMembership, createEmptyLibrary, defaultInsetsForSize, folderNameError, folderNameOf, folderOfRawPath, folderPathError, imagePlugin, isAssetReference, isFolderOrDescendant, joinFolderPath, manifestColorError, manifestEmojiError, manifestNameError, normalizeFolderPath, parentFolderPath, parseLibrary, portableFolderPathKey, rawFileNameOfPath, referenceErrors, replaceFolderPrefix, serializeLibrary, slicePlugin, topologicalReferenceOrder, validateAudioPreset, validateImagePreset, validateVideoPreset, videoPlugin } from "#fundus/core";
|
|
4
|
+
import { existsSync } from "node:fs";
|
|
5
|
+
import { createJiti } from "jiti";
|
|
6
|
+
import { createHash } from "node:crypto";
|
|
7
|
+
import ffmpegStaticPath from "ffmpeg-static";
|
|
8
|
+
import { path } from "ffprobe-static";
|
|
9
|
+
import { execFile } from "node:child_process";
|
|
10
|
+
import { promisify } from "node:util";
|
|
11
|
+
import sharp from "sharp";
|
|
12
|
+
//#region src/config-loader.ts
|
|
13
|
+
/**
|
|
14
|
+
* Loads and resolves the host's `fundus.config.ts` through jiti, validates
|
|
15
|
+
* the shape, resolves all paths to
|
|
16
|
+
* absolute, and merges the built-in plugin preset defaults under the host's.
|
|
17
|
+
*/
|
|
18
|
+
function isRecord(value) {
|
|
19
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
20
|
+
}
|
|
21
|
+
function requiredString(config, key) {
|
|
22
|
+
const value = config[key];
|
|
23
|
+
if (typeof value !== "string" || value.length === 0) throw new Error(`fundus.config: "${key}" must be a non-empty string.`);
|
|
24
|
+
return value;
|
|
25
|
+
}
|
|
26
|
+
function resolvePath(projectRoot, value) {
|
|
27
|
+
return isAbsolute(value) ? value : resolve(projectRoot, value);
|
|
28
|
+
}
|
|
29
|
+
function mergePresets(label, builtIn, pluginConfig, validatePreset) {
|
|
30
|
+
const merged = { ...builtIn };
|
|
31
|
+
if (pluginConfig === void 0) return merged;
|
|
32
|
+
if (!isRecord(pluginConfig)) throw new Error(`fundus.config: "plugins.${label}" must be an object.`);
|
|
33
|
+
if (pluginConfig.presets === void 0) return merged;
|
|
34
|
+
if (!isRecord(pluginConfig.presets)) throw new Error(`fundus.config: "plugins.${label}.presets" must be an object.`);
|
|
35
|
+
for (const [name, preset] of Object.entries(pluginConfig.presets)) merged[name] = validatePreset(name, preset);
|
|
36
|
+
return merged;
|
|
37
|
+
}
|
|
38
|
+
/** `plugins.video.ffmpegPath` / `ffprobePath`: optional system-binary overrides. */
|
|
39
|
+
function binaryPathOverride(projectRoot, pluginConfig, key) {
|
|
40
|
+
if (!isRecord(pluginConfig) || pluginConfig[key] === void 0) return null;
|
|
41
|
+
const value = pluginConfig[key];
|
|
42
|
+
if (typeof value !== "string" || value.length === 0) throw new Error(`fundus.config: "plugins.video.${key}" must be a non-empty string.`);
|
|
43
|
+
return resolvePath(projectRoot, value);
|
|
44
|
+
}
|
|
45
|
+
/** Pure resolution step (testable without a config file on disk). */
|
|
46
|
+
function resolveConfig(projectRoot, value) {
|
|
47
|
+
if (!isRecord(value)) throw new Error("fundus.config must default-export a configuration object.");
|
|
48
|
+
const proxyBasePath = requiredString(value, "proxyBasePath");
|
|
49
|
+
if (!proxyBasePath.endsWith("/")) throw new Error("fundus.config: \"proxyBasePath\" must end with \"/\".");
|
|
50
|
+
const plugins = value.plugins === void 0 ? {} : value.plugins;
|
|
51
|
+
if (!isRecord(plugins)) throw new Error("fundus.config: \"plugins\" must be an object.");
|
|
52
|
+
return {
|
|
53
|
+
rawDir: resolvePath(projectRoot, requiredString(value, "rawDir")),
|
|
54
|
+
proxyDir: resolvePath(projectRoot, requiredString(value, "proxyDir")),
|
|
55
|
+
library: resolvePath(projectRoot, requiredString(value, "library")),
|
|
56
|
+
manifestsDir: resolvePath(projectRoot, requiredString(value, "manifestsDir")),
|
|
57
|
+
proxyBasePath,
|
|
58
|
+
plugins: {
|
|
59
|
+
image: { presets: mergePresets("image", IMAGE_BUILT_IN_PRESETS, plugins.image, validateImagePreset) },
|
|
60
|
+
slice: { presets: mergePresets("slice", SLICE_BUILT_IN_PRESETS, plugins.slice, validateImagePreset) },
|
|
61
|
+
video: {
|
|
62
|
+
presets: mergePresets("video", VIDEO_BUILT_IN_PRESETS, plugins.video, validateVideoPreset),
|
|
63
|
+
ffmpegPath: binaryPathOverride(projectRoot, plugins.video, "ffmpegPath"),
|
|
64
|
+
ffprobePath: binaryPathOverride(projectRoot, plugins.video, "ffprobePath")
|
|
65
|
+
},
|
|
66
|
+
audio: { presets: mergePresets("audio", AUDIO_BUILT_IN_PRESETS, plugins.audio, validateAudioPreset) }
|
|
67
|
+
}
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
/** Load `fundus.config.ts` from the host project root. */
|
|
71
|
+
async function loadConfig(projectRoot) {
|
|
72
|
+
const root = resolve(projectRoot);
|
|
73
|
+
const configPath = resolve(root, "fundus.config.ts");
|
|
74
|
+
if (!existsSync(configPath)) throw new Error(`No fundus.config.ts found in ${root}.`);
|
|
75
|
+
let module;
|
|
76
|
+
try {
|
|
77
|
+
module = await createJiti(import.meta.url, { interopDefault: false }).import(configPath);
|
|
78
|
+
} catch (cause) {
|
|
79
|
+
throw new Error(`Cannot load fundus.config.ts: ${cause instanceof Error ? cause.message : String(cause)}`);
|
|
80
|
+
}
|
|
81
|
+
return resolveConfig(root, isRecord(module) && "default" in module ? module.default : module);
|
|
82
|
+
}
|
|
83
|
+
//#endregion
|
|
84
|
+
//#region src/processing/ffmpeg.ts
|
|
85
|
+
/**
|
|
86
|
+
* The ffmpeg/ffprobe toolchain layer: binary resolution (bundled `-static`
|
|
87
|
+
* packages by default, with host-config overrides) and a spawn helper.
|
|
88
|
+
* Since proxies are committed, this toolchain is only needed where
|
|
89
|
+
* assets are authored/processed — host builds and CI never invoke it.
|
|
90
|
+
*/
|
|
91
|
+
function ffmpegBinaryPath(config) {
|
|
92
|
+
if (config.plugins.video.ffmpegPath) return config.plugins.video.ffmpegPath;
|
|
93
|
+
if (!ffmpegStaticPath) throw new Error("ffmpeg-static provides no binary for this platform — set \"plugins.video.ffmpegPath\" in fundus.config.ts.");
|
|
94
|
+
return ffmpegStaticPath;
|
|
95
|
+
}
|
|
96
|
+
function ffprobeBinaryPath(config) {
|
|
97
|
+
if (config.plugins.video.ffprobePath) return config.plugins.video.ffprobePath;
|
|
98
|
+
if (!path) throw new Error("ffprobe-static provides no binary for this platform — set \"plugins.video.ffprobePath\" in fundus.config.ts.");
|
|
99
|
+
return path;
|
|
100
|
+
}
|
|
101
|
+
/**
|
|
102
|
+
* Run a toolchain binary with an argument array (never a shell string) and
|
|
103
|
+
* return its stdout. A non-zero exit throws with the stderr tail — ffmpeg
|
|
104
|
+
* writes its diagnostics there.
|
|
105
|
+
*/
|
|
106
|
+
async function runToolchainBinary(binaryPath, commandArguments) {
|
|
107
|
+
try {
|
|
108
|
+
const { stdout } = await promisify(execFile)(binaryPath, commandArguments, {
|
|
109
|
+
encoding: "utf8",
|
|
110
|
+
maxBuffer: 16 * 1024 * 1024
|
|
111
|
+
});
|
|
112
|
+
return stdout;
|
|
113
|
+
} catch (cause) {
|
|
114
|
+
const failure = cause;
|
|
115
|
+
const diagnostics = (failure.stderr ?? failure.message).trim().split("\n").slice(-12).join("\n");
|
|
116
|
+
throw new Error(`${basename(binaryPath)} exited with code ${failure.code ?? "unknown"}:\n${diagnostics}`);
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
//#endregion
|
|
120
|
+
//#region src/processing/media-probe.ts
|
|
121
|
+
/** Parse ffprobe's JSON output (pure, testable without a binary). */
|
|
122
|
+
function parseProbeOutput(json, filePath) {
|
|
123
|
+
const output = JSON.parse(json);
|
|
124
|
+
const videoStream = output.streams?.find((stream) => stream.codec_type === "video" && stream.width && stream.height);
|
|
125
|
+
const durationSeconds = Number(output.format?.duration ?? output.streams?.find((stream) => stream.duration)?.duration);
|
|
126
|
+
if (!Number.isFinite(durationSeconds)) throw new Error(`Could not read the media duration of ${filePath}.`);
|
|
127
|
+
return {
|
|
128
|
+
...videoStream ? {
|
|
129
|
+
width: videoStream.width,
|
|
130
|
+
height: videoStream.height
|
|
131
|
+
} : {},
|
|
132
|
+
durationMs: Math.round(durationSeconds * 1e3)
|
|
133
|
+
};
|
|
134
|
+
}
|
|
135
|
+
async function probeMedia(filePath, config) {
|
|
136
|
+
return parseProbeOutput(await runToolchainBinary(ffprobeBinaryPath(config), [
|
|
137
|
+
"-v",
|
|
138
|
+
"error",
|
|
139
|
+
"-show_entries",
|
|
140
|
+
"stream=codec_type,width,height,duration:format=duration",
|
|
141
|
+
"-of",
|
|
142
|
+
"json",
|
|
143
|
+
filePath
|
|
144
|
+
]), filePath);
|
|
145
|
+
}
|
|
146
|
+
//#endregion
|
|
147
|
+
//#region src/processing/audio-processing.ts
|
|
148
|
+
/**
|
|
149
|
+
* The ffmpeg transcode step of the audio plugin: one audio-only proxy per
|
|
150
|
+
* preset (opus/webm or aac/m4a). Deterministic like the video step:
|
|
151
|
+
* metadata stripped, bitexact muxing.
|
|
152
|
+
*/
|
|
153
|
+
const ENCODER_BY_CODEC = {
|
|
154
|
+
opus: "libopus",
|
|
155
|
+
aac: "aac"
|
|
156
|
+
};
|
|
157
|
+
/** The full ffmpeg argument list for one audio transcode (pure, testable). */
|
|
158
|
+
function buildAudioTranscodeArguments(options) {
|
|
159
|
+
return [
|
|
160
|
+
"-hide_banner",
|
|
161
|
+
"-loglevel",
|
|
162
|
+
"error",
|
|
163
|
+
"-y",
|
|
164
|
+
"-i",
|
|
165
|
+
options.sourcePath,
|
|
166
|
+
"-vn",
|
|
167
|
+
"-c:a",
|
|
168
|
+
ENCODER_BY_CODEC[options.preset.codec],
|
|
169
|
+
"-b:a",
|
|
170
|
+
`${options.preset.bitrateKbps}k`,
|
|
171
|
+
"-map_metadata",
|
|
172
|
+
"-1",
|
|
173
|
+
"-fflags",
|
|
174
|
+
"+bitexact",
|
|
175
|
+
"-flags:a",
|
|
176
|
+
"+bitexact",
|
|
177
|
+
options.targetPath
|
|
178
|
+
];
|
|
179
|
+
}
|
|
180
|
+
async function processAudioFile(options) {
|
|
181
|
+
await mkdir(dirname(options.targetPath), { recursive: true });
|
|
182
|
+
await runToolchainBinary(ffmpegBinaryPath(options.config), buildAudioTranscodeArguments({
|
|
183
|
+
sourcePath: options.sourcePath,
|
|
184
|
+
targetPath: options.targetPath,
|
|
185
|
+
preset: options.preset
|
|
186
|
+
}));
|
|
187
|
+
return { durationMs: (await probeMedia(options.targetPath, options.config)).durationMs };
|
|
188
|
+
}
|
|
189
|
+
//#endregion
|
|
190
|
+
//#region src/plugins/contract.ts
|
|
191
|
+
/** Resolve a named preset out of a plugin's merged preset map, or throw listing the known names. */
|
|
192
|
+
function resolveNamedPreset(label, presetName, presets) {
|
|
193
|
+
const preset = presets[presetName];
|
|
194
|
+
if (!preset) throw new Error(`Unknown ${label} preset "${presetName}" (known: ${Object.keys(presets).sort().join(", ")}).`);
|
|
195
|
+
return preset;
|
|
196
|
+
}
|
|
197
|
+
/** A proxy-info field a given entry kind cannot do without. */
|
|
198
|
+
function requireProxyField(proxy, field) {
|
|
199
|
+
const value = proxy[field];
|
|
200
|
+
if (value === void 0) throw new Error(`Proxy info of "${proxy.assetId}" is missing "${field}".`);
|
|
201
|
+
return value;
|
|
202
|
+
}
|
|
203
|
+
//#endregion
|
|
204
|
+
//#region src/plugins/audio.ts
|
|
205
|
+
/**
|
|
206
|
+
* Audio plugin (server part): one ffmpeg transcode per preset.
|
|
207
|
+
* No dimensions — the proxy's only output metadata is its duration.
|
|
208
|
+
*/
|
|
209
|
+
const audioServerPlugin = {
|
|
210
|
+
descriptor: audioPlugin,
|
|
211
|
+
pluginConfig: (config) => config.plugins.audio,
|
|
212
|
+
presets: (config) => config.plugins.audio.presets,
|
|
213
|
+
resolvePreset: (parameters, config) => resolveNamedPreset("audio", parameters.preset, config.plugins.audio.presets),
|
|
214
|
+
proxyExtension: (preset) => preset.container,
|
|
215
|
+
process: (job) => processAudioFile({
|
|
216
|
+
sourcePath: job.sourcePath,
|
|
217
|
+
targetPath: job.targetPath,
|
|
218
|
+
preset: job.preset,
|
|
219
|
+
config: job.config
|
|
220
|
+
}),
|
|
221
|
+
readProxyInfo: (proxyPath, config) => probeMedia(proxyPath, config),
|
|
222
|
+
buildManifestEntry: ({ src, proxy }) => ({
|
|
223
|
+
kind: "audio",
|
|
224
|
+
src,
|
|
225
|
+
durationMs: requireProxyField(proxy, "durationMs")
|
|
226
|
+
})
|
|
227
|
+
};
|
|
228
|
+
//#endregion
|
|
229
|
+
//#region src/processing/video-processing.ts
|
|
230
|
+
/**
|
|
231
|
+
* The ffmpeg transcode step shared by the video and chroma-key-video plugins
|
|
232
|
+
* (chroma-key-video builds on video's processing; its keying data is
|
|
233
|
+
* entry-only and never reaches ffmpeg). Deterministic output: all
|
|
234
|
+
* metadata is stripped and bitexact muxing is forced, so re-running a job
|
|
235
|
+
* yields byte-identical proxy files.
|
|
236
|
+
*/
|
|
237
|
+
/**
|
|
238
|
+
* The delivered width the scale filter must produce, or null when no filter is
|
|
239
|
+
* needed. `maxWidth` is a downscale-only cap; yuv420p demands even dimensions,
|
|
240
|
+
* so an odd (capped or source) width rounds down and an odd source height
|
|
241
|
+
* forces a filter even without a cap (the `-2` idiom rounds it).
|
|
242
|
+
*/
|
|
243
|
+
function videoScaleWidth(sourceWidth, sourceHeight, maxWidth) {
|
|
244
|
+
const cappedWidth = maxWidth !== void 0 ? Math.min(maxWidth, sourceWidth) : sourceWidth;
|
|
245
|
+
const evenWidth = Math.max(2, cappedWidth - cappedWidth % 2);
|
|
246
|
+
if (evenWidth === sourceWidth && sourceHeight % 2 === 0) return null;
|
|
247
|
+
return evenWidth;
|
|
248
|
+
}
|
|
249
|
+
/**
|
|
250
|
+
* The full ffmpeg argument list for one video transcode (pure, testable
|
|
251
|
+
* without a binary). `-map_metadata -1` + bitexact flags keep the output
|
|
252
|
+
* byte-stable across runs (no embedded timestamps or encoder version tags);
|
|
253
|
+
* a source audio track is kept (re-encoded as AAC — a UI video may carry
|
|
254
|
+
* sound; nothing is stripped silently).
|
|
255
|
+
*/
|
|
256
|
+
function buildVideoTranscodeArguments(options) {
|
|
257
|
+
const { sourcePath, targetPath, preset, scaleWidth } = options;
|
|
258
|
+
return [
|
|
259
|
+
"-hide_banner",
|
|
260
|
+
"-loglevel",
|
|
261
|
+
"error",
|
|
262
|
+
"-y",
|
|
263
|
+
"-i",
|
|
264
|
+
sourcePath,
|
|
265
|
+
...scaleWidth !== null ? ["-vf", `scale=${scaleWidth}:-2`] : [],
|
|
266
|
+
"-c:v",
|
|
267
|
+
"libx264",
|
|
268
|
+
"-preset",
|
|
269
|
+
preset.encoderPreset,
|
|
270
|
+
...preset.tune !== void 0 ? ["-tune", preset.tune] : [],
|
|
271
|
+
"-crf",
|
|
272
|
+
String(preset.crf),
|
|
273
|
+
"-pix_fmt",
|
|
274
|
+
preset.pixelFormat,
|
|
275
|
+
"-c:a",
|
|
276
|
+
"aac",
|
|
277
|
+
"-b:a",
|
|
278
|
+
"128k",
|
|
279
|
+
"-map_metadata",
|
|
280
|
+
"-1",
|
|
281
|
+
"-fflags",
|
|
282
|
+
"+bitexact",
|
|
283
|
+
"-flags:v",
|
|
284
|
+
"+bitexact",
|
|
285
|
+
"-flags:a",
|
|
286
|
+
"+bitexact",
|
|
287
|
+
"-movflags",
|
|
288
|
+
"+faststart",
|
|
289
|
+
targetPath
|
|
290
|
+
];
|
|
291
|
+
}
|
|
292
|
+
async function processVideoFile(options) {
|
|
293
|
+
await mkdir(dirname(options.targetPath), { recursive: true });
|
|
294
|
+
const source = await probeMedia(options.sourcePath, options.config);
|
|
295
|
+
if (source.width === void 0 || source.height === void 0) throw new Error(`${options.sourcePath} has no video stream.`);
|
|
296
|
+
const scaleWidth = videoScaleWidth(source.width, source.height, options.maxWidth);
|
|
297
|
+
await runToolchainBinary(ffmpegBinaryPath(options.config), buildVideoTranscodeArguments({
|
|
298
|
+
sourcePath: options.sourcePath,
|
|
299
|
+
targetPath: options.targetPath,
|
|
300
|
+
preset: options.preset,
|
|
301
|
+
scaleWidth
|
|
302
|
+
}));
|
|
303
|
+
const output = await probeMedia(options.targetPath, options.config);
|
|
304
|
+
if (output.width === void 0 || output.height === void 0) throw new Error(`Transcoding ${options.sourcePath} produced no video stream.`);
|
|
305
|
+
return {
|
|
306
|
+
width: output.width,
|
|
307
|
+
height: output.height,
|
|
308
|
+
durationMs: output.durationMs
|
|
309
|
+
};
|
|
310
|
+
}
|
|
311
|
+
//#endregion
|
|
312
|
+
//#region src/plugins/chroma-key-video.ts
|
|
313
|
+
/**
|
|
314
|
+
* Chroma-key-video plugin (server part): shares the video plugin's transcode
|
|
315
|
+
* pipeline and preset namespace — the keying parameters and the
|
|
316
|
+
* mask/fallback references never reach ffmpeg, they are entry-only data
|
|
317
|
+
* (the descriptor's `processingParameters` keeps them out of the job hash).
|
|
318
|
+
*/
|
|
319
|
+
const chromaKeyVideoServerPlugin = {
|
|
320
|
+
descriptor: chromaKeyVideoPlugin,
|
|
321
|
+
pluginConfig: (config) => config.plugins.video,
|
|
322
|
+
presets: (config) => config.plugins.video.presets,
|
|
323
|
+
resolvePreset: (parameters, config) => resolveNamedPreset("video", parameters.preset, config.plugins.video.presets),
|
|
324
|
+
proxyExtension: (preset) => preset.container,
|
|
325
|
+
process: (job) => processVideoFile({
|
|
326
|
+
sourcePath: job.sourcePath,
|
|
327
|
+
targetPath: job.targetPath,
|
|
328
|
+
preset: job.preset,
|
|
329
|
+
maxWidth: job.parameters.maxWidth,
|
|
330
|
+
config: job.config
|
|
331
|
+
}),
|
|
332
|
+
readProxyInfo: (proxyPath, config) => probeMedia(proxyPath, config),
|
|
333
|
+
buildManifestEntry: ({ parameters, src, proxy }) => ({
|
|
334
|
+
kind: "chroma-key-video",
|
|
335
|
+
src,
|
|
336
|
+
width: requireProxyField(proxy, "width"),
|
|
337
|
+
height: requireProxyField(proxy, "height"),
|
|
338
|
+
durationMs: requireProxyField(proxy, "durationMs"),
|
|
339
|
+
chromaKey: parameters.chromaKey,
|
|
340
|
+
...parameters.mask ? { mask: parameters.mask } : {},
|
|
341
|
+
...parameters.fallback ? { fallback: parameters.fallback } : {}
|
|
342
|
+
})
|
|
343
|
+
};
|
|
344
|
+
//#endregion
|
|
345
|
+
//#region src/processing/image-processing.ts
|
|
346
|
+
/**
|
|
347
|
+
* The sharp step shared by the image and slice plugins: optional resize to a
|
|
348
|
+
* target width, format conversion per preset, write the proxy file. Returns
|
|
349
|
+
* the delivered (post-resize) dimensions.
|
|
350
|
+
*/
|
|
351
|
+
async function processImageFile(options) {
|
|
352
|
+
await mkdir(dirname(options.targetPath), { recursive: true });
|
|
353
|
+
let pipeline = sharp(options.sourcePath);
|
|
354
|
+
if (options.targetWidth !== void 0) pipeline = pipeline.resize({ width: options.targetWidth });
|
|
355
|
+
pipeline = pipeline.toFormat(options.preset.format, options.preset.quality !== void 0 ? { quality: options.preset.quality } : {});
|
|
356
|
+
const info = await pipeline.toFile(options.targetPath);
|
|
357
|
+
return {
|
|
358
|
+
width: info.width,
|
|
359
|
+
height: info.height
|
|
360
|
+
};
|
|
361
|
+
}
|
|
362
|
+
/** Pixel dimensions of an existing image file (for already-fresh proxies). */
|
|
363
|
+
async function readImageDimensions(filePath) {
|
|
364
|
+
const metadata = await sharp(filePath).metadata();
|
|
365
|
+
if (typeof metadata.width !== "number" || typeof metadata.height !== "number") throw new Error(`Could not read image dimensions of ${filePath}.`);
|
|
366
|
+
return {
|
|
367
|
+
width: metadata.width,
|
|
368
|
+
height: metadata.height
|
|
369
|
+
};
|
|
370
|
+
}
|
|
371
|
+
//#endregion
|
|
372
|
+
//#region src/plugins/image.ts
|
|
373
|
+
/**
|
|
374
|
+
* Image plugin (server part): sharp scale + convert per preset.
|
|
375
|
+
*/
|
|
376
|
+
/** The delivered file extension of an image-preset format. */
|
|
377
|
+
function extensionForImageFormat(format) {
|
|
378
|
+
return format === "jpeg" ? "jpg" : format;
|
|
379
|
+
}
|
|
380
|
+
const imageServerPlugin = {
|
|
381
|
+
descriptor: imagePlugin,
|
|
382
|
+
pluginConfig: (config) => config.plugins.image,
|
|
383
|
+
presets: (config) => config.plugins.image.presets,
|
|
384
|
+
resolvePreset: (parameters, config) => resolveNamedPreset("image", parameters.preset, config.plugins.image.presets),
|
|
385
|
+
proxyExtension: (preset) => extensionForImageFormat(preset.format),
|
|
386
|
+
process: (job) => processImageFile({
|
|
387
|
+
sourcePath: job.sourcePath,
|
|
388
|
+
targetPath: job.targetPath,
|
|
389
|
+
preset: job.preset,
|
|
390
|
+
targetWidth: job.parameters.targetWidth
|
|
391
|
+
}),
|
|
392
|
+
readProxyInfo: (proxyPath) => readImageDimensions(proxyPath),
|
|
393
|
+
buildManifestEntry: ({ src, proxy }) => ({
|
|
394
|
+
kind: "image",
|
|
395
|
+
src,
|
|
396
|
+
width: requireProxyField(proxy, "width"),
|
|
397
|
+
height: requireProxyField(proxy, "height")
|
|
398
|
+
})
|
|
399
|
+
};
|
|
400
|
+
//#endregion
|
|
401
|
+
//#region src/plugins/slice.ts
|
|
402
|
+
/**
|
|
403
|
+
* Slice plugin (server part): the same sharp conversion as the image plugin,
|
|
404
|
+
* never resized — insets are authored in source pixels, so the delivered proxy
|
|
405
|
+
* keeps the source dimensions. The slicing data is entry-only.
|
|
406
|
+
*/
|
|
407
|
+
function hasAnySide(insets) {
|
|
408
|
+
if (!insets) return false;
|
|
409
|
+
return insets.top > 0 || insets.right > 0 || insets.bottom > 0 || insets.left > 0;
|
|
410
|
+
}
|
|
411
|
+
/**
|
|
412
|
+
* Best-effort guess of the asset's export scale from a `@2x` / `@3x` suffix in
|
|
413
|
+
* the file name (also tolerates `-`/`_` separators, e.g. `dialog-3x.png`).
|
|
414
|
+
* Falls back to 1.
|
|
415
|
+
*/
|
|
416
|
+
function detectPixelRatio(fileName) {
|
|
417
|
+
const match = fileName.match(/(?:@|[-_])(\d)x(?=\.[^.]*$|$)/i);
|
|
418
|
+
const ratio = match ? Number(match[1]) : 1;
|
|
419
|
+
return ratio >= 1 ? ratio : 1;
|
|
420
|
+
}
|
|
421
|
+
const sliceServerPlugin = {
|
|
422
|
+
descriptor: slicePlugin,
|
|
423
|
+
pluginConfig: (config) => config.plugins.slice,
|
|
424
|
+
presets: (config) => config.plugins.slice.presets,
|
|
425
|
+
resolvePreset: (parameters, config) => resolveNamedPreset("slice", parameters.preset, config.plugins.slice.presets),
|
|
426
|
+
proxyExtension: (preset) => extensionForImageFormat(preset.format),
|
|
427
|
+
seedIngestParameters: async ({ rawPath, rawFileName, parameters }) => {
|
|
428
|
+
const dimensions = await readImageDimensions(rawPath);
|
|
429
|
+
return {
|
|
430
|
+
...parameters,
|
|
431
|
+
insets: defaultInsetsForSize(dimensions.width, dimensions.height),
|
|
432
|
+
pixelRatio: detectPixelRatio(rawFileName)
|
|
433
|
+
};
|
|
434
|
+
},
|
|
435
|
+
process: (job) => processImageFile({
|
|
436
|
+
sourcePath: job.sourcePath,
|
|
437
|
+
targetPath: job.targetPath,
|
|
438
|
+
preset: job.preset
|
|
439
|
+
}),
|
|
440
|
+
readProxyInfo: (proxyPath) => readImageDimensions(proxyPath),
|
|
441
|
+
buildManifestEntry: ({ parameters, src, proxy }) => {
|
|
442
|
+
return {
|
|
443
|
+
kind: "slice",
|
|
444
|
+
src,
|
|
445
|
+
slicing: {
|
|
446
|
+
mode: parameters.mode,
|
|
447
|
+
insets: parameters.insets,
|
|
448
|
+
pixelRatio: parameters.pixelRatio,
|
|
449
|
+
...hasAnySide(parameters.overdraw) ? { overdraw: parameters.overdraw } : {}
|
|
450
|
+
},
|
|
451
|
+
sourceWidth: requireProxyField(proxy, "width"),
|
|
452
|
+
sourceHeight: requireProxyField(proxy, "height")
|
|
453
|
+
};
|
|
454
|
+
}
|
|
455
|
+
};
|
|
456
|
+
//#endregion
|
|
457
|
+
//#region src/plugins/video.ts
|
|
458
|
+
/**
|
|
459
|
+
* Video plugin (server part): one ffmpeg transcode per preset.
|
|
460
|
+
*/
|
|
461
|
+
const videoServerPlugin = {
|
|
462
|
+
descriptor: videoPlugin,
|
|
463
|
+
pluginConfig: (config) => config.plugins.video,
|
|
464
|
+
presets: (config) => config.plugins.video.presets,
|
|
465
|
+
resolvePreset: (parameters, config) => resolveNamedPreset("video", parameters.preset, config.plugins.video.presets),
|
|
466
|
+
proxyExtension: (preset) => preset.container,
|
|
467
|
+
process: (job) => processVideoFile({
|
|
468
|
+
sourcePath: job.sourcePath,
|
|
469
|
+
targetPath: job.targetPath,
|
|
470
|
+
preset: job.preset,
|
|
471
|
+
maxWidth: job.parameters.maxWidth,
|
|
472
|
+
config: job.config
|
|
473
|
+
}),
|
|
474
|
+
readProxyInfo: (proxyPath, config) => probeMedia(proxyPath, config),
|
|
475
|
+
buildManifestEntry: ({ src, proxy }) => ({
|
|
476
|
+
kind: "video",
|
|
477
|
+
src,
|
|
478
|
+
width: requireProxyField(proxy, "width"),
|
|
479
|
+
height: requireProxyField(proxy, "height"),
|
|
480
|
+
durationMs: requireProxyField(proxy, "durationMs")
|
|
481
|
+
})
|
|
482
|
+
};
|
|
483
|
+
//#endregion
|
|
484
|
+
//#region src/plugins.ts
|
|
485
|
+
const serverPluginsByType = {
|
|
486
|
+
[imageServerPlugin.descriptor.type]: imageServerPlugin,
|
|
487
|
+
[sliceServerPlugin.descriptor.type]: sliceServerPlugin,
|
|
488
|
+
[videoServerPlugin.descriptor.type]: videoServerPlugin,
|
|
489
|
+
[chromaKeyVideoServerPlugin.descriptor.type]: chromaKeyVideoServerPlugin,
|
|
490
|
+
[audioServerPlugin.descriptor.type]: audioServerPlugin
|
|
491
|
+
};
|
|
492
|
+
function serverPluginForType(type, registry = serverPluginsByType) {
|
|
493
|
+
const plugin = registry[type];
|
|
494
|
+
if (!plugin) throw new Error(`Unknown asset type "${type}" (known: ${Object.keys(registry).sort().join(", ")}).`);
|
|
495
|
+
return plugin;
|
|
496
|
+
}
|
|
497
|
+
/** The core-descriptor view of a registry — what core's reference/graph helpers consume. */
|
|
498
|
+
function descriptorsByType(registry) {
|
|
499
|
+
return Object.fromEntries(Object.entries(registry).map(([type, plugin]) => [type, plugin.descriptor]));
|
|
500
|
+
}
|
|
501
|
+
/** The built-in registry's descriptor map (the common case; registry-aware code derives its own). */
|
|
502
|
+
const pluginsByType = descriptorsByType(serverPluginsByType);
|
|
503
|
+
/**
|
|
504
|
+
* The job-hash parameter input for an asset: the plugin-declared
|
|
505
|
+
* processing-relevant subset when the plugin distinguishes one (chroma-key-
|
|
506
|
+
* video: keying + references are entry-only), else the full parameter value.
|
|
507
|
+
*/
|
|
508
|
+
function processingParametersOf(plugin, parameters) {
|
|
509
|
+
return plugin.processingParameters ? plugin.processingParameters(parameters) : parameters;
|
|
510
|
+
}
|
|
511
|
+
//#endregion
|
|
512
|
+
//#region src/codegen.ts
|
|
513
|
+
/**
|
|
514
|
+
* Manifest module codegen: one `<manifest-name-kebab>.generated.ts`
|
|
515
|
+
* per manifest in the configured manifests directory, containing data only —
|
|
516
|
+
* entries constructed through the runtime's `createManifest`. Deterministic:
|
|
517
|
+
* sorted asset ids, fixed property order, tab indentation. Generated modules
|
|
518
|
+
* for deleted manifests are removed (only files carrying the fundus header).
|
|
519
|
+
*/
|
|
520
|
+
const GENERATED_HEADER = "// generated by fundus — do not edit\n";
|
|
521
|
+
/** `mainCatalog` → `main-catalog`. */
|
|
522
|
+
function kebabCaseFromCamel(name) {
|
|
523
|
+
return name.replace(/([a-z0-9])([A-Z])/g, "$1-$2").toLowerCase();
|
|
524
|
+
}
|
|
525
|
+
/** Generated module file name for a manifest. */
|
|
526
|
+
function manifestModuleFileName(manifestName) {
|
|
527
|
+
return `${kebabCaseFromCamel(manifestName)}.generated.ts`;
|
|
528
|
+
}
|
|
529
|
+
/**
|
|
530
|
+
* Build the entry data codegen emits for one asset — delegated to
|
|
531
|
+
* the owning server plugin's `buildManifestEntry`. Reference fields
|
|
532
|
+
* (chroma-key-video's mask/fallback) stay `{ $asset }` markers here — the
|
|
533
|
+
* generic `inlineAssetReferences` walk resolves them.
|
|
534
|
+
*/
|
|
535
|
+
function manifestEntryForAsset(record, proxy, config, registry = serverPluginsByType) {
|
|
536
|
+
const plugin = serverPluginForType(record.type, registry);
|
|
537
|
+
return plugin.buildManifestEntry({
|
|
538
|
+
assetId: proxy.assetId,
|
|
539
|
+
parameters: plugin.descriptor.validateParameters(record.parameters),
|
|
540
|
+
src: `${config.proxyBasePath}${proxy.relativePath.split("/").map(encodeURIComponent).join("/")}`,
|
|
541
|
+
proxy
|
|
542
|
+
});
|
|
543
|
+
}
|
|
544
|
+
/**
|
|
545
|
+
* Replace every `{ $asset }` marker in a built entry with the referenced
|
|
546
|
+
* asset's own (already inlined) entry data. Codegen inlines the
|
|
547
|
+
* resolved reference, so the runtime never resolves ids. Generic deep walk;
|
|
548
|
+
* plugin entry builders stay reference-unaware.
|
|
549
|
+
*/
|
|
550
|
+
function inlineAssetReferences(value, resolveEntry) {
|
|
551
|
+
return inlineValue(value, resolveEntry);
|
|
552
|
+
}
|
|
553
|
+
function inlineValue(value, resolveEntry) {
|
|
554
|
+
if (isAssetReference(value)) return resolveEntry(value.$asset);
|
|
555
|
+
if (Array.isArray(value)) return value.map((element) => inlineValue(element, resolveEntry));
|
|
556
|
+
if (typeof value === "object" && value !== null) {
|
|
557
|
+
const inlined = {};
|
|
558
|
+
for (const [key, nested] of Object.entries(value)) inlined[key] = inlineValue(nested, resolveEntry);
|
|
559
|
+
return inlined;
|
|
560
|
+
}
|
|
561
|
+
return value;
|
|
562
|
+
}
|
|
563
|
+
/**
|
|
564
|
+
* Build the entries for one manifest from its membership picture: the whole
|
|
565
|
+
* delivery set (explicit ∪ passive members — the shared membership
|
|
566
|
+
* computation) in dependency order (the closure is a DAG — a
|
|
567
|
+
* cycle throws), each entry's markers inlined from the already-built entries.
|
|
568
|
+
* Only the explicit members appear top-level — they are the manifest's API
|
|
569
|
+
* surface; passive members exist solely inlined.
|
|
570
|
+
*/
|
|
571
|
+
function buildManifestEntries(options) {
|
|
572
|
+
const buildEntry = options.entryBuilder ?? ((assetId, record, proxy, config) => manifestEntryForAsset(record, proxy, config, options.registry));
|
|
573
|
+
const buildOrder = topologicalReferenceOrder(options.membership.deliveredAssetIds, options.referencesByAsset);
|
|
574
|
+
const builtEntries = {};
|
|
575
|
+
const resolveEntry = (assetId) => {
|
|
576
|
+
if (!Object.hasOwn(builtEntries, assetId)) throw new Error(`Cannot inline reference to "${assetId}" — no entry was built for it.`);
|
|
577
|
+
return builtEntries[assetId];
|
|
578
|
+
};
|
|
579
|
+
for (const assetId of buildOrder) {
|
|
580
|
+
if (!Object.hasOwn(options.library.assets, assetId)) throw new Error(`Cannot build an entry for unknown asset "${assetId}".`);
|
|
581
|
+
const proxy = options.proxies[assetId];
|
|
582
|
+
if (!proxy) throw new Error(`No proxy info for asset "${assetId}". Run processing before codegen.`);
|
|
583
|
+
builtEntries[assetId] = inlineAssetReferences(buildEntry(assetId, options.library.assets[assetId], proxy, options.config), resolveEntry);
|
|
584
|
+
}
|
|
585
|
+
const memberEntries = {};
|
|
586
|
+
for (const assetId of options.membership.explicitMemberIds) memberEntries[assetId] = builtEntries[assetId];
|
|
587
|
+
return memberEntries;
|
|
588
|
+
}
|
|
589
|
+
/** Render one generated manifest module. */
|
|
590
|
+
function renderManifestModule(manifestName, entries) {
|
|
591
|
+
const assetIds = Object.keys(entries).sort(compareCodeUnits);
|
|
592
|
+
return `${GENERATED_HEADER}import { createManifest } from 'fundus';\n\n${assetIds.length === 0 ? `export const ${manifestName} = createManifest({});` : `export const ${manifestName} = createManifest({\n` + assetIds.map((assetId) => {
|
|
593
|
+
return `\t${assetId}: ${JSON.stringify(entries[assetId], null, " ").replace(/\n/g, "\n ")}`;
|
|
594
|
+
}).join(",\n") + `\n});`}\n`;
|
|
595
|
+
}
|
|
596
|
+
/** Fundus-owned generated modules not present in the expected file-name set. */
|
|
597
|
+
async function strayGeneratedModuleNames(manifestsDir, expectedFileNames) {
|
|
598
|
+
let fileNames;
|
|
599
|
+
try {
|
|
600
|
+
fileNames = await readdir(manifestsDir);
|
|
601
|
+
} catch (cause) {
|
|
602
|
+
if (cause.code === "ENOENT") return [];
|
|
603
|
+
throw cause;
|
|
604
|
+
}
|
|
605
|
+
const strayNames = [];
|
|
606
|
+
for (const fileName of fileNames) {
|
|
607
|
+
if (!fileName.endsWith(".generated.ts") || expectedFileNames.has(fileName)) continue;
|
|
608
|
+
if ((await readFile(join(manifestsDir, fileName), "utf8")).startsWith("// generated by fundus — do not edit\n")) strayNames.push(fileName);
|
|
609
|
+
}
|
|
610
|
+
return strayNames.sort(compareCodeUnits);
|
|
611
|
+
}
|
|
612
|
+
/**
|
|
613
|
+
* Write one module per manifest and remove generated modules whose manifest is
|
|
614
|
+
* gone. Only files starting with the fundus header are ever removed, so other
|
|
615
|
+
* generated files co-located in the directory stay untouched.
|
|
616
|
+
*/
|
|
617
|
+
async function generateManifestModules(config, library, proxies, registry = serverPluginsByType) {
|
|
618
|
+
await mkdir(config.manifestsDir, { recursive: true });
|
|
619
|
+
const report = {
|
|
620
|
+
written: [],
|
|
621
|
+
removed: []
|
|
622
|
+
};
|
|
623
|
+
const expectedFileNames = /* @__PURE__ */ new Set();
|
|
624
|
+
const namesByFileName = /* @__PURE__ */ new Map();
|
|
625
|
+
for (const manifestName of Object.keys(library.manifests)) {
|
|
626
|
+
const fileName = manifestModuleFileName(manifestName);
|
|
627
|
+
namesByFileName.set(fileName, [...namesByFileName.get(fileName) ?? [], manifestName]);
|
|
628
|
+
}
|
|
629
|
+
for (const [fileName, manifestNames] of namesByFileName) if (manifestNames.length > 1) throw new Error(`Manifests ${manifestNames.map((name) => `"${name}"`).join(" and ")} would both generate "${fileName}" — rename one so the kebab-case module names stay distinct.`);
|
|
630
|
+
const referencesByAsset = collectAllReferences(library, descriptorsByType(registry));
|
|
631
|
+
const membershipByManifest = computeLibraryMembership(library, referencesByAsset);
|
|
632
|
+
const renderedModules = [];
|
|
633
|
+
for (const manifestName of Object.keys(library.manifests).sort(compareCodeUnits)) {
|
|
634
|
+
const entries = buildManifestEntries({
|
|
635
|
+
membership: membershipByManifest[manifestName],
|
|
636
|
+
library,
|
|
637
|
+
proxies,
|
|
638
|
+
config,
|
|
639
|
+
referencesByAsset,
|
|
640
|
+
registry
|
|
641
|
+
});
|
|
642
|
+
const fileName = manifestModuleFileName(manifestName);
|
|
643
|
+
expectedFileNames.add(fileName);
|
|
644
|
+
renderedModules.push({
|
|
645
|
+
fileName,
|
|
646
|
+
content: renderManifestModule(manifestName, entries)
|
|
647
|
+
});
|
|
648
|
+
}
|
|
649
|
+
for (const { fileName, content } of renderedModules) {
|
|
650
|
+
await writeFile(join(config.manifestsDir, fileName), content);
|
|
651
|
+
report.written.push(fileName);
|
|
652
|
+
}
|
|
653
|
+
for (const fileName of await strayGeneratedModuleNames(config.manifestsDir, expectedFileNames)) {
|
|
654
|
+
await unlink(join(config.manifestsDir, fileName));
|
|
655
|
+
report.removed.push(fileName);
|
|
656
|
+
}
|
|
657
|
+
return report;
|
|
658
|
+
}
|
|
659
|
+
/**
|
|
660
|
+
* First 8 hex chars of sha256 over rawHash + canonical parameters + pipeline
|
|
661
|
+
* version — the `<jobHash>` segment of a proxy file name.
|
|
662
|
+
*/
|
|
663
|
+
function jobHash(rawHash, parameters, pipelineVersion = 1) {
|
|
664
|
+
return createHash("sha256").update(rawHash + canonicalizeParameters(parameters) + String(pipelineVersion)).digest("hex").slice(0, 8);
|
|
665
|
+
}
|
|
666
|
+
//#endregion
|
|
667
|
+
//#region src/folder-filesystem.ts
|
|
668
|
+
async function ensureManagedFolder(root, folder) {
|
|
669
|
+
const directory = join(root, folder);
|
|
670
|
+
await mkdir(directory, { recursive: true });
|
|
671
|
+
if (folder === "") return;
|
|
672
|
+
const sentinel = join(directory, FOLDER_SENTINEL);
|
|
673
|
+
if (!existsSync(sentinel)) await writeFile(sentinel, "");
|
|
674
|
+
}
|
|
675
|
+
async function syncManagedFolders(config, library) {
|
|
676
|
+
await Promise.all(library.folders.flatMap((folder) => [ensureManagedFolder(config.rawDir, folder), ensureManagedFolder(config.proxyDir, folder)]));
|
|
677
|
+
}
|
|
678
|
+
/** Recursively list files as POSIX-style paths relative to `root`. */
|
|
679
|
+
async function listRelativeFiles(root, folder = "") {
|
|
680
|
+
const directory = join(root, folder);
|
|
681
|
+
if (!existsSync(directory)) return [];
|
|
682
|
+
const result = [];
|
|
683
|
+
for (const entry of await readdir(directory, { withFileTypes: true })) {
|
|
684
|
+
const relativePath = folder === "" ? entry.name : `${folder}/${entry.name}`;
|
|
685
|
+
if (entry.isDirectory()) result.push(...await listRelativeFiles(root, relativePath));
|
|
686
|
+
else if (entry.isFile()) result.push(relativePath);
|
|
687
|
+
}
|
|
688
|
+
return result;
|
|
689
|
+
}
|
|
690
|
+
function encodeRelativePathForUrl(path) {
|
|
691
|
+
return path.split("/").map(encodeURIComponent).join("/");
|
|
692
|
+
}
|
|
693
|
+
//#endregion
|
|
694
|
+
//#region src/processing/processor.ts
|
|
695
|
+
/**
|
|
696
|
+
* The processing pipeline: every delivered asset gets exactly one
|
|
697
|
+
* proxy named `<assetId>.<jobHash>.<extension>`. Delivered = explicit or
|
|
698
|
+
* passive member of ≥ 1 manifest (codegen inlines referenced
|
|
699
|
+
* entries, so a passive member's proxy must exist even when the asset is in
|
|
700
|
+
* no manifest itself). Because the name is computable without running the job,
|
|
701
|
+
* staleness is a name check: expected name exists → up to date, else →
|
|
702
|
+
* process. Afterwards, files in the proxy directory that no asset expects are
|
|
703
|
+
* pruned.
|
|
704
|
+
*
|
|
705
|
+
* Type-agnostic: every per-type decision (preset resolution, extension, the
|
|
706
|
+
* sharp/ffmpeg step, proxy probing) lives on the server plugins; this module
|
|
707
|
+
* only consults the registry — which it takes as a parameter, so tests can
|
|
708
|
+
* drive the pipeline with fixture asset types.
|
|
709
|
+
*/
|
|
710
|
+
/** Proxy file name shape; its id segment is pinned to core's asset-id grammar. */
|
|
711
|
+
const PROXY_FILE_PATTERN = /^[a-z][A-Za-z0-9]*\.[0-9a-f]{8}\.[a-z0-9]+$/;
|
|
712
|
+
/** Whether a file name in the proxy directory is (shaped like) a fundus proxy. */
|
|
713
|
+
function isProxyFileName(fileName) {
|
|
714
|
+
return PROXY_FILE_PATTERN.test(fileName);
|
|
715
|
+
}
|
|
716
|
+
/** Whether a shaped proxy file belongs to one specific asset id. */
|
|
717
|
+
function isProxyFileNameFor(assetId, fileName) {
|
|
718
|
+
return isProxyFileName(fileName) && fileName.startsWith(`${assetId}.`);
|
|
719
|
+
}
|
|
720
|
+
/**
|
|
721
|
+
* The proxy file name an asset's current raw content + parameters demand.
|
|
722
|
+
* Throws when the parameters are invalid or reference an unknown preset.
|
|
723
|
+
*
|
|
724
|
+
* The hash input is the *effective* processing input: the asset's
|
|
725
|
+
* processing-relevant parameters (the plugin-declared subset — entry-only data
|
|
726
|
+
* like chroma-key tuning and references must not rename the proxy)
|
|
727
|
+
* plus the preset's **resolved settings** — parameters only carry the preset
|
|
728
|
+
* *name*, so hashing them alone would keep serving stale proxy bytes after a
|
|
729
|
+
* preset's quality/format is edited in the host config.
|
|
730
|
+
*/
|
|
731
|
+
function expectedProxyFileName(assetId, record, config, registry = serverPluginsByType) {
|
|
732
|
+
const plugin = serverPluginForType(record.type, registry);
|
|
733
|
+
const parameters = plugin.descriptor.validateParameters(record.parameters);
|
|
734
|
+
const preset = plugin.resolvePreset(parameters, config);
|
|
735
|
+
return `${assetId}.${jobHash(record.rawHash, {
|
|
736
|
+
parameters: processingParametersOf(plugin.descriptor, parameters),
|
|
737
|
+
presetSettings: preset
|
|
738
|
+
})}.${plugin.proxyExtension(preset)}`;
|
|
739
|
+
}
|
|
740
|
+
/** Expected proxy path relative to proxyDir, mirroring the raw asset folder. */
|
|
741
|
+
function expectedProxyRelativePath(assetId, record, config, registry = serverPluginsByType) {
|
|
742
|
+
const fileName = expectedProxyFileName(assetId, record, config, registry);
|
|
743
|
+
const folder = folderOfRawPath(record.raw);
|
|
744
|
+
return folder === "" ? fileName : `${folder}/${fileName}`;
|
|
745
|
+
}
|
|
746
|
+
/**
|
|
747
|
+
* Output metadata of an existing proxy file, probed per the owning plugin's
|
|
748
|
+
* strategy: image-backed proxies via sharp, media proxies via ffprobe.
|
|
749
|
+
*/
|
|
750
|
+
async function readProxyOutputInfo(type, proxyPath, config, registry = serverPluginsByType) {
|
|
751
|
+
return serverPluginForType(type, registry).readProxyInfo(proxyPath, config);
|
|
752
|
+
}
|
|
753
|
+
/** Read one asset's current proxy projection, or null when its expected file is absent. */
|
|
754
|
+
async function readCurrentProxy(assetId, record, config, registry = serverPluginsByType) {
|
|
755
|
+
const fileName = expectedProxyFileName(assetId, record, config, registry);
|
|
756
|
+
const relativePath = expectedProxyRelativePath(assetId, record, config, registry);
|
|
757
|
+
const proxyPath = join(config.proxyDir, relativePath);
|
|
758
|
+
if (!existsSync(proxyPath)) return null;
|
|
759
|
+
const [outputInfo, proxyStat] = await Promise.all([readProxyOutputInfo(record.type, proxyPath, config, registry), stat(proxyPath)]);
|
|
760
|
+
return {
|
|
761
|
+
assetId,
|
|
762
|
+
fileName,
|
|
763
|
+
relativePath,
|
|
764
|
+
...outputInfo,
|
|
765
|
+
fileSize: proxyStat.size
|
|
766
|
+
};
|
|
767
|
+
}
|
|
768
|
+
/**
|
|
769
|
+
* The delivered set: every explicit or passive member of ≥ 1 manifest —
|
|
770
|
+
* derived by the shared membership computation. Assets outside
|
|
771
|
+
* it are inert.
|
|
772
|
+
*/
|
|
773
|
+
function deliveredAssetIds(library, registry = serverPluginsByType) {
|
|
774
|
+
return allDeliveredAssetIds(computeLibraryMembership(library, collectAllReferences(library, descriptorsByType(registry))));
|
|
775
|
+
}
|
|
776
|
+
/** Run the owning plugin's processing job for one stale/missing proxy. */
|
|
777
|
+
async function processAssetFile(record, rawPath, proxyPath, config, registry) {
|
|
778
|
+
const plugin = serverPluginForType(record.type, registry);
|
|
779
|
+
const parameters = plugin.descriptor.validateParameters(record.parameters);
|
|
780
|
+
const extension = extname(proxyPath);
|
|
781
|
+
const stem = basename(proxyPath, extension);
|
|
782
|
+
const temporaryPath = join(dirname(proxyPath), `.${stem}.${crypto.randomUUID()}.tmp${extension}`);
|
|
783
|
+
try {
|
|
784
|
+
const output = await plugin.process({
|
|
785
|
+
sourcePath: rawPath,
|
|
786
|
+
targetPath: temporaryPath,
|
|
787
|
+
parameters,
|
|
788
|
+
preset: plugin.resolvePreset(parameters, config),
|
|
789
|
+
config
|
|
790
|
+
});
|
|
791
|
+
await rename(temporaryPath, proxyPath);
|
|
792
|
+
return output;
|
|
793
|
+
} catch (cause) {
|
|
794
|
+
await unlink(temporaryPath).catch(() => {});
|
|
795
|
+
throw cause;
|
|
796
|
+
}
|
|
797
|
+
}
|
|
798
|
+
/**
|
|
799
|
+
* Ensure every delivered asset's proxy exists (processing stale/missing ones)
|
|
800
|
+
* and prune orphans. Assets outside the delivered set are skipped entirely.
|
|
801
|
+
*/
|
|
802
|
+
async function processAssets(config, library, registry = serverPluginsByType) {
|
|
803
|
+
const report = {
|
|
804
|
+
processed: [],
|
|
805
|
+
reused: [],
|
|
806
|
+
pruned: [],
|
|
807
|
+
proxies: {}
|
|
808
|
+
};
|
|
809
|
+
await mkdir(config.proxyDir, { recursive: true });
|
|
810
|
+
await Promise.all(library.folders.map((folder) => ensureManagedFolder(config.proxyDir, folder)));
|
|
811
|
+
const delivered = deliveredAssetIds(library, registry);
|
|
812
|
+
const expectedRelativePaths = /* @__PURE__ */ new Set();
|
|
813
|
+
for (const [assetId, record] of Object.entries(library.assets)) {
|
|
814
|
+
if (!delivered.has(assetId)) continue;
|
|
815
|
+
const fileName = expectedProxyFileName(assetId, record, config, registry);
|
|
816
|
+
const relativePath = expectedProxyRelativePath(assetId, record, config, registry);
|
|
817
|
+
expectedRelativePaths.add(relativePath);
|
|
818
|
+
const proxyPath = join(config.proxyDir, relativePath);
|
|
819
|
+
const rawPath = join(config.rawDir, record.raw);
|
|
820
|
+
await mkdir(dirname(proxyPath), { recursive: true });
|
|
821
|
+
let proxy = await readCurrentProxy(assetId, record, config, registry);
|
|
822
|
+
if (proxy) report.reused.push(assetId);
|
|
823
|
+
else {
|
|
824
|
+
if (!existsSync(rawPath)) throw new Error(`Cannot process "${assetId}": raw file "${record.raw}" is missing.`);
|
|
825
|
+
const outputInfo = await processAssetFile(record, rawPath, proxyPath, config, registry);
|
|
826
|
+
report.processed.push(assetId);
|
|
827
|
+
const proxyStat = await stat(proxyPath);
|
|
828
|
+
proxy = {
|
|
829
|
+
assetId,
|
|
830
|
+
fileName,
|
|
831
|
+
relativePath,
|
|
832
|
+
...outputInfo,
|
|
833
|
+
fileSize: proxyStat.size
|
|
834
|
+
};
|
|
835
|
+
}
|
|
836
|
+
report.proxies[assetId] = proxy;
|
|
837
|
+
}
|
|
838
|
+
for (const relativePath of await listRelativeFiles(config.proxyDir)) {
|
|
839
|
+
if (!isProxyFileName(rawFileNameOfPath(relativePath))) continue;
|
|
840
|
+
if (expectedRelativePaths.has(relativePath)) continue;
|
|
841
|
+
await unlink(join(config.proxyDir, relativePath));
|
|
842
|
+
report.pruned.push(relativePath);
|
|
843
|
+
}
|
|
844
|
+
report.pruned.sort();
|
|
845
|
+
return report;
|
|
846
|
+
}
|
|
847
|
+
//#endregion
|
|
848
|
+
//#region src/library-store.ts
|
|
849
|
+
/**
|
|
850
|
+
* The fs-backed store over the library file (SSOT) and the raw directory.
|
|
851
|
+
* Every mutation is load → modify → deterministic write, so the file on disk
|
|
852
|
+
* is always the current truth. Proxy files are the pipeline's business — this
|
|
853
|
+
* store only removes them on asset deletion (the pipeline also prunes orphans).
|
|
854
|
+
*/
|
|
855
|
+
/** A single path segment with no traversal — guards against `../` escapes. */
|
|
856
|
+
const SAFE_SEGMENT = /^[a-zA-Z0-9][a-zA-Z0-9._-]*$/;
|
|
857
|
+
function assertSafeFileName(fileName) {
|
|
858
|
+
if (!SAFE_SEGMENT.test(fileName) || fileName.includes("/") || fileName.includes("\\")) throw new Error(`Unsafe file name "${fileName}".`);
|
|
859
|
+
}
|
|
860
|
+
/** Validate a persisted/served file path without allowing traversal outside its root. */
|
|
861
|
+
function assertSafeRelativeFilePath(filePath) {
|
|
862
|
+
if (filePath.startsWith("/") || filePath.endsWith("/") || filePath.includes("\\")) throw new Error(`Unsafe file path "${filePath}".`);
|
|
863
|
+
const segments = filePath.split("/");
|
|
864
|
+
if (segments.length === 0) throw new Error(`Unsafe file path "${filePath}".`);
|
|
865
|
+
assertSafeFileName(segments.at(-1) ?? "");
|
|
866
|
+
const folder = segments.slice(0, -1).join("/");
|
|
867
|
+
if (folder !== "") {
|
|
868
|
+
const problem = folderPathError(folder);
|
|
869
|
+
if (problem) throw new Error(problem);
|
|
870
|
+
}
|
|
871
|
+
}
|
|
872
|
+
/**
|
|
873
|
+
* Turn an arbitrary uploaded file name (spaces, umlauts, parentheses, nested
|
|
874
|
+
* paths) into a safe single segment: strip the directory, transliterate to
|
|
875
|
+
* ASCII-ish, replace runs of disallowed characters with `-`, and lower-case
|
|
876
|
+
* the extension. Never throws for a normal image name.
|
|
877
|
+
*/
|
|
878
|
+
function sanitizeFileName(original) {
|
|
879
|
+
const base = original.split(/[\\/]/).pop() ?? original;
|
|
880
|
+
const dot = base.lastIndexOf(".");
|
|
881
|
+
const rawStem = dot > 0 ? base.slice(0, dot) : base;
|
|
882
|
+
const rawExtension = dot > 0 ? base.slice(dot + 1) : "";
|
|
883
|
+
let stem = rawStem.normalize("NFKD").replace(/[^a-zA-Z0-9._-]+/g, "-").replace(/-{2,}/g, "-").replace(/^[-.]+|[-.]+$/g, "");
|
|
884
|
+
if (!stem) stem = "asset";
|
|
885
|
+
if (!/^[a-zA-Z0-9]/.test(stem)) stem = `raw-${stem}`;
|
|
886
|
+
const extension = rawExtension.toLowerCase().replace(/[^a-z0-9]/g, "");
|
|
887
|
+
return extension ? `${stem}.${extension}` : stem;
|
|
888
|
+
}
|
|
889
|
+
function sha256Hex(bytes) {
|
|
890
|
+
return createHash("sha256").update(bytes).digest("hex");
|
|
891
|
+
}
|
|
892
|
+
var LibraryStore = class {
|
|
893
|
+
#config;
|
|
894
|
+
#registry;
|
|
895
|
+
#queue = Promise.resolve();
|
|
896
|
+
constructor(config, registry = serverPluginsByType) {
|
|
897
|
+
this.#config = config;
|
|
898
|
+
this.#registry = registry;
|
|
899
|
+
}
|
|
900
|
+
get config() {
|
|
901
|
+
return this.#config;
|
|
902
|
+
}
|
|
903
|
+
/** Run a mutation after every previously enqueued one has settled. */
|
|
904
|
+
#enqueue(operation) {
|
|
905
|
+
const run = this.#queue.then(operation, operation);
|
|
906
|
+
this.#queue = run.then(() => {}, () => {});
|
|
907
|
+
return run;
|
|
908
|
+
}
|
|
909
|
+
/** Read + parse the library file; a missing file is an empty library. */
|
|
910
|
+
async read() {
|
|
911
|
+
if (!existsSync(this.#config.library)) return createEmptyLibrary();
|
|
912
|
+
return parseLibrary(await readFile(this.#config.library, "utf8"));
|
|
913
|
+
}
|
|
914
|
+
/** Deterministically serialize + write the library file. */
|
|
915
|
+
async write(library) {
|
|
916
|
+
await mkdir(join(this.#config.library, ".."), { recursive: true });
|
|
917
|
+
const temporaryPath = `${this.#config.library}.tmp`;
|
|
918
|
+
await writeFile(temporaryPath, serializeLibrary(library));
|
|
919
|
+
await rename(temporaryPath, this.#config.library);
|
|
920
|
+
}
|
|
921
|
+
/**
|
|
922
|
+
* Ingest a new asset: write the raw file into `rawDir` (under a sanitized,
|
|
923
|
+
* non-colliding name), hash its content, and create the library record with
|
|
924
|
+
* the owning plugin's default parameters. Returns the updated library.
|
|
925
|
+
*/
|
|
926
|
+
async ingest(options) {
|
|
927
|
+
return this.#enqueue(() => this.#ingest(options));
|
|
928
|
+
}
|
|
929
|
+
async #ingest(options) {
|
|
930
|
+
const idProblem = assetIdError(options.id);
|
|
931
|
+
if (idProblem) throw new Error(idProblem);
|
|
932
|
+
const plugin = serverPluginForType(options.type, this.#registry);
|
|
933
|
+
const extension = extname(sanitizeFileName(options.fileName)).slice(1).toLowerCase();
|
|
934
|
+
if (!plugin.descriptor.accepts.includes(extension)) throw new Error(`The ${options.type} type does not accept ".${extension}" files (accepts: ${plugin.descriptor.accepts.join(", ")}).`);
|
|
935
|
+
const library = await this.read();
|
|
936
|
+
if (Object.hasOwn(library.assets, options.id)) throw new Error(`An asset with id "${options.id}" already exists.`);
|
|
937
|
+
const folder = options.folder === void 0 ? "" : normalizeFolderPath(options.folder);
|
|
938
|
+
this.#requireFolder(library, folder);
|
|
939
|
+
await mkdir(join(this.#config.rawDir, folder), { recursive: true });
|
|
940
|
+
const storedName = this.#uniqueRawName(sanitizeFileName(options.fileName), folder);
|
|
941
|
+
const storedPath = folder === "" ? storedName : `${folder}/${storedName}`;
|
|
942
|
+
await writeFile(join(this.#config.rawDir, storedPath), options.bytes);
|
|
943
|
+
const record = {
|
|
944
|
+
type: options.type,
|
|
945
|
+
raw: storedPath,
|
|
946
|
+
rawHash: sha256Hex(options.bytes),
|
|
947
|
+
parameters: plugin.descriptor.defaultParameters(plugin.pluginConfig(this.#config)),
|
|
948
|
+
manifests: []
|
|
949
|
+
};
|
|
950
|
+
library.assets[options.id] = record;
|
|
951
|
+
await this.write(library);
|
|
952
|
+
return library;
|
|
953
|
+
}
|
|
954
|
+
/** Replace an asset's parameters (validated by the owning plugin). */
|
|
955
|
+
async updateParameters(id, parameters) {
|
|
956
|
+
return this.#enqueue(() => this.#updateParameters(id, parameters));
|
|
957
|
+
}
|
|
958
|
+
async #updateParameters(id, parameters) {
|
|
959
|
+
const library = await this.read();
|
|
960
|
+
const record = this.#requireAsset(library, id);
|
|
961
|
+
record.parameters = serverPluginForType(record.type, this.#registry).descriptor.validateParameters(parameters);
|
|
962
|
+
await this.write(library);
|
|
963
|
+
return library;
|
|
964
|
+
}
|
|
965
|
+
/** Replace an asset's manifest membership (every name must exist). */
|
|
966
|
+
async setManifests(id, manifests) {
|
|
967
|
+
return this.#enqueue(() => this.#setManifests(id, manifests));
|
|
968
|
+
}
|
|
969
|
+
async #setManifests(id, manifests) {
|
|
970
|
+
const library = await this.read();
|
|
971
|
+
const record = this.#requireAsset(library, id);
|
|
972
|
+
const unique = [...new Set(manifests)];
|
|
973
|
+
for (const name of unique) if (!Object.hasOwn(library.manifests, name)) throw new Error(`Unknown manifest "${name}".`);
|
|
974
|
+
record.manifests = unique.sort();
|
|
975
|
+
await this.write(library);
|
|
976
|
+
return library;
|
|
977
|
+
}
|
|
978
|
+
/** Rename an asset id (the raw file keeps its name — ids are the stable key). */
|
|
979
|
+
async renameAsset(id, newId) {
|
|
980
|
+
return this.#enqueue(() => this.#renameAsset(id, newId));
|
|
981
|
+
}
|
|
982
|
+
async #renameAsset(id, newId) {
|
|
983
|
+
const idProblem = assetIdError(newId);
|
|
984
|
+
if (idProblem) throw new Error(idProblem);
|
|
985
|
+
const library = await this.read();
|
|
986
|
+
const record = this.#requireAsset(library, id);
|
|
987
|
+
if (newId === id) return library;
|
|
988
|
+
if (Object.hasOwn(library.assets, newId)) throw new Error(`An asset with id "${newId}" already exists.`);
|
|
989
|
+
delete library.assets[id];
|
|
990
|
+
library.assets[newId] = record;
|
|
991
|
+
await this.write(library);
|
|
992
|
+
await this.#deleteProxiesOf(id);
|
|
993
|
+
return library;
|
|
994
|
+
}
|
|
995
|
+
/** Move an asset's raw file and current proxies without changing its stable id. */
|
|
996
|
+
async moveAsset(id, folder) {
|
|
997
|
+
return this.#enqueue(() => this.#moveAsset(id, folder));
|
|
998
|
+
}
|
|
999
|
+
async #moveAsset(id, requestedFolder) {
|
|
1000
|
+
const folder = normalizeFolderPath(requestedFolder);
|
|
1001
|
+
const library = await this.read();
|
|
1002
|
+
const record = this.#requireAsset(library, id);
|
|
1003
|
+
this.#requireFolder(library, folder);
|
|
1004
|
+
const currentFolder = folderOfRawPath(record.raw);
|
|
1005
|
+
if (folder === currentFolder) return library;
|
|
1006
|
+
const fileName = rawFileNameOfPath(record.raw);
|
|
1007
|
+
const nextRaw = folder === "" ? fileName : `${folder}/${fileName}`;
|
|
1008
|
+
const nextRawPath = join(this.#config.rawDir, nextRaw);
|
|
1009
|
+
if (existsSync(nextRawPath)) throw new Error(`Raw file "${nextRaw}" already exists.`);
|
|
1010
|
+
await ensureManagedFolder(this.#config.rawDir, folder);
|
|
1011
|
+
await ensureManagedFolder(this.#config.proxyDir, folder);
|
|
1012
|
+
const movedProxies = [];
|
|
1013
|
+
let rawMoved = false;
|
|
1014
|
+
try {
|
|
1015
|
+
await rename(join(this.#config.rawDir, record.raw), nextRawPath);
|
|
1016
|
+
rawMoved = true;
|
|
1017
|
+
for (const relativePath of await listRelativeFiles(this.#config.proxyDir, currentFolder)) {
|
|
1018
|
+
if (folderOfRawPath(relativePath) !== currentFolder) continue;
|
|
1019
|
+
const proxyFileName = rawFileNameOfPath(relativePath);
|
|
1020
|
+
if (!isProxyFileNameFor(id, proxyFileName)) continue;
|
|
1021
|
+
const nextRelativePath = folder === "" ? proxyFileName : `${folder}/${proxyFileName}`;
|
|
1022
|
+
await rename(join(this.#config.proxyDir, relativePath), join(this.#config.proxyDir, nextRelativePath));
|
|
1023
|
+
movedProxies.push({
|
|
1024
|
+
from: relativePath,
|
|
1025
|
+
to: nextRelativePath
|
|
1026
|
+
});
|
|
1027
|
+
}
|
|
1028
|
+
record.raw = nextRaw;
|
|
1029
|
+
await this.write(library);
|
|
1030
|
+
} catch (cause) {
|
|
1031
|
+
record.raw = currentFolder === "" ? fileName : `${currentFolder}/${fileName}`;
|
|
1032
|
+
for (const proxy of movedProxies.reverse()) await rename(join(this.#config.proxyDir, proxy.to), join(this.#config.proxyDir, proxy.from));
|
|
1033
|
+
if (rawMoved) await rename(nextRawPath, join(this.#config.rawDir, record.raw));
|
|
1034
|
+
throw cause;
|
|
1035
|
+
}
|
|
1036
|
+
return library;
|
|
1037
|
+
}
|
|
1038
|
+
async createFolder(path) {
|
|
1039
|
+
return this.#enqueue(() => this.#createFolder(path));
|
|
1040
|
+
}
|
|
1041
|
+
async #createFolder(requestedPath) {
|
|
1042
|
+
const path = normalizeFolderPath(requestedPath);
|
|
1043
|
+
const problem = folderPathError(path);
|
|
1044
|
+
if (problem) throw new Error(problem);
|
|
1045
|
+
const library = await this.read();
|
|
1046
|
+
const parent = parentFolderPath(path);
|
|
1047
|
+
this.#requireFolder(library, parent);
|
|
1048
|
+
this.#assertFolderAvailable(library, path);
|
|
1049
|
+
library.folders.push(path);
|
|
1050
|
+
library.folders.sort();
|
|
1051
|
+
await Promise.all([ensureManagedFolder(this.#config.rawDir, path), ensureManagedFolder(this.#config.proxyDir, path)]);
|
|
1052
|
+
await this.write(library);
|
|
1053
|
+
return library;
|
|
1054
|
+
}
|
|
1055
|
+
async renameFolder(path, newName) {
|
|
1056
|
+
return this.#enqueue(() => this.#renameFolder(path, newName));
|
|
1057
|
+
}
|
|
1058
|
+
async #renameFolder(requestedPath, requestedName) {
|
|
1059
|
+
const path = normalizeFolderPath(requestedPath);
|
|
1060
|
+
const newName = requestedName.normalize("NFC");
|
|
1061
|
+
const nameProblem = folderNameError(newName);
|
|
1062
|
+
if (nameProblem) throw new Error(nameProblem);
|
|
1063
|
+
const nextPath = joinFolderPath(parentFolderPath(path), newName);
|
|
1064
|
+
return this.#relocateFolder(path, nextPath);
|
|
1065
|
+
}
|
|
1066
|
+
/** Move a complete folder subtree below another folder (or the implicit root). */
|
|
1067
|
+
async moveFolder(path, parent) {
|
|
1068
|
+
return this.#enqueue(() => this.#moveFolder(path, parent));
|
|
1069
|
+
}
|
|
1070
|
+
async #moveFolder(requestedPath, requestedParent) {
|
|
1071
|
+
const path = normalizeFolderPath(requestedPath);
|
|
1072
|
+
const nextPath = joinFolderPath(normalizeFolderPath(requestedParent), folderNameOf(path));
|
|
1073
|
+
return this.#relocateFolder(path, nextPath);
|
|
1074
|
+
}
|
|
1075
|
+
async #relocateFolder(path, nextPath) {
|
|
1076
|
+
const library = await this.read();
|
|
1077
|
+
this.#requireFolder(library, path, false);
|
|
1078
|
+
const nextParent = parentFolderPath(nextPath);
|
|
1079
|
+
this.#requireFolder(library, nextParent);
|
|
1080
|
+
if (isFolderOrDescendant(nextParent, path)) throw new Error(`Folder "${path}" cannot be moved into itself or one of its descendants.`);
|
|
1081
|
+
if (nextPath === path) return library;
|
|
1082
|
+
this.#assertFolderAvailable(library, nextPath, path);
|
|
1083
|
+
const renamedFolders = library.folders.map((folder) => isFolderOrDescendant(folder, path) ? replaceFolderPrefix(folder, path, nextPath) : folder);
|
|
1084
|
+
const renamedKeys = /* @__PURE__ */ new Set();
|
|
1085
|
+
for (const folder of renamedFolders) {
|
|
1086
|
+
const key = portableFolderPathKey(folder);
|
|
1087
|
+
if (renamedKeys.has(key)) throw new Error(`Folder rename would collide at "${folder}".`);
|
|
1088
|
+
renamedKeys.add(key);
|
|
1089
|
+
}
|
|
1090
|
+
const rawFrom = join(this.#config.rawDir, path);
|
|
1091
|
+
const rawTo = join(this.#config.rawDir, nextPath);
|
|
1092
|
+
const proxyFrom = join(this.#config.proxyDir, path);
|
|
1093
|
+
const proxyTo = join(this.#config.proxyDir, nextPath);
|
|
1094
|
+
await mkdir(join(rawTo, ".."), { recursive: true });
|
|
1095
|
+
await mkdir(join(proxyTo, ".."), { recursive: true });
|
|
1096
|
+
const caseOnly = portableFolderPathKey(path) === portableFolderPathKey(nextPath);
|
|
1097
|
+
const rawTemporary = caseOnly ? `${rawFrom}.fundus-${crypto.randomUUID()}` : rawFrom;
|
|
1098
|
+
const proxyTemporary = caseOnly ? `${proxyFrom}.fundus-${crypto.randomUUID()}` : proxyFrom;
|
|
1099
|
+
if (caseOnly) {
|
|
1100
|
+
await rename(rawFrom, rawTemporary);
|
|
1101
|
+
try {
|
|
1102
|
+
await rename(proxyFrom, proxyTemporary);
|
|
1103
|
+
} catch (cause) {
|
|
1104
|
+
await rename(rawTemporary, rawFrom);
|
|
1105
|
+
throw cause;
|
|
1106
|
+
}
|
|
1107
|
+
}
|
|
1108
|
+
try {
|
|
1109
|
+
await rename(rawTemporary, rawTo);
|
|
1110
|
+
try {
|
|
1111
|
+
await rename(proxyTemporary, proxyTo);
|
|
1112
|
+
} catch (cause) {
|
|
1113
|
+
if (caseOnly) {
|
|
1114
|
+
await rename(rawTo, rawTemporary);
|
|
1115
|
+
await rename(rawTemporary, rawFrom);
|
|
1116
|
+
await rename(proxyTemporary, proxyFrom);
|
|
1117
|
+
} else await rename(rawTo, rawFrom);
|
|
1118
|
+
throw cause;
|
|
1119
|
+
}
|
|
1120
|
+
} catch (cause) {
|
|
1121
|
+
if (caseOnly) {
|
|
1122
|
+
if (existsSync(rawTemporary)) await rename(rawTemporary, rawFrom);
|
|
1123
|
+
if (existsSync(proxyTemporary)) await rename(proxyTemporary, proxyFrom);
|
|
1124
|
+
}
|
|
1125
|
+
throw cause;
|
|
1126
|
+
}
|
|
1127
|
+
library.folders = renamedFolders.sort();
|
|
1128
|
+
for (const record of Object.values(library.assets)) {
|
|
1129
|
+
const assetFolder = folderOfRawPath(record.raw);
|
|
1130
|
+
if (!isFolderOrDescendant(assetFolder, path)) continue;
|
|
1131
|
+
record.raw = `${replaceFolderPrefix(assetFolder, path, nextPath)}/${rawFileNameOfPath(record.raw)}`;
|
|
1132
|
+
}
|
|
1133
|
+
try {
|
|
1134
|
+
await this.write(library);
|
|
1135
|
+
} catch (cause) {
|
|
1136
|
+
if (caseOnly) {
|
|
1137
|
+
await rename(rawTo, rawTemporary);
|
|
1138
|
+
await rename(proxyTo, proxyTemporary);
|
|
1139
|
+
await rename(rawTemporary, rawFrom);
|
|
1140
|
+
await rename(proxyTemporary, proxyFrom);
|
|
1141
|
+
} else {
|
|
1142
|
+
await rename(rawTo, rawFrom);
|
|
1143
|
+
await rename(proxyTo, proxyFrom);
|
|
1144
|
+
}
|
|
1145
|
+
throw cause;
|
|
1146
|
+
}
|
|
1147
|
+
return library;
|
|
1148
|
+
}
|
|
1149
|
+
/** Recursively delete a folder and every asset stored below it. */
|
|
1150
|
+
async deleteFolder(path) {
|
|
1151
|
+
return this.#enqueue(() => this.#deleteFolder(path));
|
|
1152
|
+
}
|
|
1153
|
+
async #deleteFolder(requestedPath) {
|
|
1154
|
+
const path = normalizeFolderPath(requestedPath);
|
|
1155
|
+
const library = await this.read();
|
|
1156
|
+
this.#requireFolder(library, path, false);
|
|
1157
|
+
const deletedAssetIds = Object.entries(library.assets).filter(([, record]) => isFolderOrDescendant(folderOfRawPath(record.raw), path)).map(([id]) => id).sort();
|
|
1158
|
+
for (const id of deletedAssetIds) delete library.assets[id];
|
|
1159
|
+
library.folders = library.folders.filter((folder) => !isFolderOrDescendant(folder, path));
|
|
1160
|
+
const token = crypto.randomUUID();
|
|
1161
|
+
const rawPath = join(this.#config.rawDir, path);
|
|
1162
|
+
const proxyPath = join(this.#config.proxyDir, path);
|
|
1163
|
+
const rawTemporary = join(this.#config.rawDir, `.fundus-delete-${token}`);
|
|
1164
|
+
const proxyTemporary = join(this.#config.proxyDir, `.fundus-delete-${token}`);
|
|
1165
|
+
await rename(rawPath, rawTemporary);
|
|
1166
|
+
try {
|
|
1167
|
+
await rename(proxyPath, proxyTemporary);
|
|
1168
|
+
} catch (cause) {
|
|
1169
|
+
await rename(rawTemporary, rawPath);
|
|
1170
|
+
throw cause;
|
|
1171
|
+
}
|
|
1172
|
+
try {
|
|
1173
|
+
await this.write(library);
|
|
1174
|
+
} catch (cause) {
|
|
1175
|
+
await rename(rawTemporary, rawPath);
|
|
1176
|
+
await rename(proxyTemporary, proxyPath);
|
|
1177
|
+
throw cause;
|
|
1178
|
+
}
|
|
1179
|
+
await Promise.all([rm(rawTemporary, {
|
|
1180
|
+
recursive: true,
|
|
1181
|
+
force: true
|
|
1182
|
+
}), rm(proxyTemporary, {
|
|
1183
|
+
recursive: true,
|
|
1184
|
+
force: true
|
|
1185
|
+
})]);
|
|
1186
|
+
return {
|
|
1187
|
+
library,
|
|
1188
|
+
deletedAssetIds
|
|
1189
|
+
};
|
|
1190
|
+
}
|
|
1191
|
+
/** Delete an asset: remove the record, its raw file, and its proxies. */
|
|
1192
|
+
async deleteAsset(id) {
|
|
1193
|
+
return this.#enqueue(() => this.#deleteAsset(id));
|
|
1194
|
+
}
|
|
1195
|
+
async #deleteAsset(id) {
|
|
1196
|
+
return this.#deleteAssets([id]);
|
|
1197
|
+
}
|
|
1198
|
+
/** Delete several assets as one library/file transaction. */
|
|
1199
|
+
async deleteAssets(ids) {
|
|
1200
|
+
return this.#enqueue(() => this.#deleteAssets(ids));
|
|
1201
|
+
}
|
|
1202
|
+
async #deleteAssets(ids) {
|
|
1203
|
+
const uniqueIds = [...new Set(ids)];
|
|
1204
|
+
if (uniqueIds.length === 0) throw new Error("At least one asset id is required.");
|
|
1205
|
+
const library = await this.read();
|
|
1206
|
+
const records = uniqueIds.map((id) => ({
|
|
1207
|
+
id,
|
|
1208
|
+
record: this.#requireAsset(library, id)
|
|
1209
|
+
}));
|
|
1210
|
+
const deletedIds = new Set(uniqueIds);
|
|
1211
|
+
const token = crypto.randomUUID();
|
|
1212
|
+
const rawTemporaryRoot = join(this.#config.rawDir, `.fundus-delete-assets-${token}`);
|
|
1213
|
+
const proxyTemporaryRoot = join(this.#config.proxyDir, `.fundus-delete-assets-${token}`);
|
|
1214
|
+
const movedRawPaths = [];
|
|
1215
|
+
const movedProxyPaths = [];
|
|
1216
|
+
const stageFile = async (root, temporaryRoot, relativePath) => {
|
|
1217
|
+
const source = join(root, relativePath);
|
|
1218
|
+
if (!existsSync(source)) return;
|
|
1219
|
+
const target = join(temporaryRoot, relativePath);
|
|
1220
|
+
await mkdir(dirname(target), { recursive: true });
|
|
1221
|
+
await rename(source, target);
|
|
1222
|
+
};
|
|
1223
|
+
const restoreFiles = async (root, temporaryRoot, relativePaths) => {
|
|
1224
|
+
for (const relativePath of [...relativePaths].reverse()) {
|
|
1225
|
+
const source = join(temporaryRoot, relativePath);
|
|
1226
|
+
if (!existsSync(source)) continue;
|
|
1227
|
+
const target = join(root, relativePath);
|
|
1228
|
+
await mkdir(dirname(target), { recursive: true });
|
|
1229
|
+
await rename(source, target);
|
|
1230
|
+
}
|
|
1231
|
+
};
|
|
1232
|
+
try {
|
|
1233
|
+
for (const { record } of records) {
|
|
1234
|
+
await stageFile(this.#config.rawDir, rawTemporaryRoot, record.raw);
|
|
1235
|
+
movedRawPaths.push(record.raw);
|
|
1236
|
+
}
|
|
1237
|
+
if (existsSync(this.#config.proxyDir)) {
|
|
1238
|
+
for (const relativePath of await listRelativeFiles(this.#config.proxyDir)) if ([...deletedIds].some((id) => isProxyFileNameFor(id, rawFileNameOfPath(relativePath)))) {
|
|
1239
|
+
await stageFile(this.#config.proxyDir, proxyTemporaryRoot, relativePath);
|
|
1240
|
+
movedProxyPaths.push(relativePath);
|
|
1241
|
+
}
|
|
1242
|
+
}
|
|
1243
|
+
for (const id of uniqueIds) delete library.assets[id];
|
|
1244
|
+
await this.write(library);
|
|
1245
|
+
} catch (cause) {
|
|
1246
|
+
await restoreFiles(this.#config.proxyDir, proxyTemporaryRoot, movedProxyPaths);
|
|
1247
|
+
await restoreFiles(this.#config.rawDir, rawTemporaryRoot, movedRawPaths);
|
|
1248
|
+
await Promise.all([rm(rawTemporaryRoot, {
|
|
1249
|
+
recursive: true,
|
|
1250
|
+
force: true
|
|
1251
|
+
}), rm(proxyTemporaryRoot, {
|
|
1252
|
+
recursive: true,
|
|
1253
|
+
force: true
|
|
1254
|
+
})]);
|
|
1255
|
+
throw cause;
|
|
1256
|
+
}
|
|
1257
|
+
await Promise.all([rm(rawTemporaryRoot, {
|
|
1258
|
+
recursive: true,
|
|
1259
|
+
force: true
|
|
1260
|
+
}), rm(proxyTemporaryRoot, {
|
|
1261
|
+
recursive: true,
|
|
1262
|
+
force: true
|
|
1263
|
+
})]);
|
|
1264
|
+
return library;
|
|
1265
|
+
}
|
|
1266
|
+
async createManifest(name) {
|
|
1267
|
+
return this.#enqueue(() => this.#createManifest(name));
|
|
1268
|
+
}
|
|
1269
|
+
async #createManifest(name) {
|
|
1270
|
+
const nameProblem = manifestNameError(name);
|
|
1271
|
+
if (nameProblem) throw new Error(nameProblem);
|
|
1272
|
+
const library = await this.read();
|
|
1273
|
+
if (Object.hasOwn(library.manifests, name)) throw new Error(`A manifest named "${name}" already exists.`);
|
|
1274
|
+
const fileName = manifestModuleFileName(name);
|
|
1275
|
+
for (const existingName of Object.keys(library.manifests)) if (manifestModuleFileName(existingName) === fileName) throw new Error(`Manifest "${name}" would generate the same module ("${fileName}") as the existing manifest "${existingName}" — pick a name with a distinct kebab-case form.`);
|
|
1276
|
+
library.manifests[name] = {};
|
|
1277
|
+
await this.write(library);
|
|
1278
|
+
return library;
|
|
1279
|
+
}
|
|
1280
|
+
/**
|
|
1281
|
+
* Set (or clear) a manifest's editor appearance — its color and/or emoji.
|
|
1282
|
+
* An `undefined` field is left unchanged; a `null` field clears it. Values
|
|
1283
|
+
* are validated against the fixed palettes.
|
|
1284
|
+
*/
|
|
1285
|
+
async setManifestAppearance(name, appearance) {
|
|
1286
|
+
return this.#enqueue(() => this.#setManifestAppearance(name, appearance));
|
|
1287
|
+
}
|
|
1288
|
+
async #setManifestAppearance(name, appearance) {
|
|
1289
|
+
const library = await this.read();
|
|
1290
|
+
if (!Object.hasOwn(library.manifests, name)) throw new Error(`Unknown manifest "${name}".`);
|
|
1291
|
+
const record = library.manifests[name];
|
|
1292
|
+
if (appearance.color !== void 0) if (appearance.color === null) delete record.color;
|
|
1293
|
+
else {
|
|
1294
|
+
const problem = manifestColorError(appearance.color);
|
|
1295
|
+
if (problem) throw new Error(problem);
|
|
1296
|
+
record.color = appearance.color;
|
|
1297
|
+
}
|
|
1298
|
+
if (appearance.emoji !== void 0) if (appearance.emoji === null) delete record.emoji;
|
|
1299
|
+
else {
|
|
1300
|
+
const problem = manifestEmojiError(appearance.emoji);
|
|
1301
|
+
if (problem) throw new Error(problem);
|
|
1302
|
+
record.emoji = appearance.emoji;
|
|
1303
|
+
}
|
|
1304
|
+
await this.write(library);
|
|
1305
|
+
return library;
|
|
1306
|
+
}
|
|
1307
|
+
/** Rename a manifest and rewrite every asset's membership to the new name. */
|
|
1308
|
+
async renameManifest(name, newName) {
|
|
1309
|
+
return this.#enqueue(() => this.#renameManifest(name, newName));
|
|
1310
|
+
}
|
|
1311
|
+
async #renameManifest(name, newName) {
|
|
1312
|
+
const nameProblem = manifestNameError(newName);
|
|
1313
|
+
if (nameProblem) throw new Error(nameProblem);
|
|
1314
|
+
const library = await this.read();
|
|
1315
|
+
if (!Object.hasOwn(library.manifests, name)) throw new Error(`Unknown manifest "${name}".`);
|
|
1316
|
+
if (newName === name) return library;
|
|
1317
|
+
if (Object.hasOwn(library.manifests, newName)) throw new Error(`A manifest named "${newName}" already exists.`);
|
|
1318
|
+
const fileName = manifestModuleFileName(newName);
|
|
1319
|
+
for (const existingName of Object.keys(library.manifests)) if (existingName !== name && manifestModuleFileName(existingName) === fileName) throw new Error(`Manifest "${newName}" would generate the same module ("${fileName}") as the existing manifest "${existingName}" — pick a name with a distinct kebab-case form.`);
|
|
1320
|
+
const record = library.manifests[name];
|
|
1321
|
+
delete library.manifests[name];
|
|
1322
|
+
library.manifests[newName] = record;
|
|
1323
|
+
for (const assetRecord of Object.values(library.assets)) if (assetRecord.manifests.includes(name)) assetRecord.manifests = assetRecord.manifests.map((manifest) => manifest === name ? newName : manifest).sort();
|
|
1324
|
+
await this.write(library);
|
|
1325
|
+
return library;
|
|
1326
|
+
}
|
|
1327
|
+
/** Delete a manifest and strip it from every asset's membership. */
|
|
1328
|
+
async deleteManifest(name) {
|
|
1329
|
+
return this.#enqueue(() => this.#deleteManifest(name));
|
|
1330
|
+
}
|
|
1331
|
+
async #deleteManifest(name) {
|
|
1332
|
+
const library = await this.read();
|
|
1333
|
+
if (!Object.hasOwn(library.manifests, name)) throw new Error(`Unknown manifest "${name}".`);
|
|
1334
|
+
delete library.manifests[name];
|
|
1335
|
+
for (const record of Object.values(library.assets)) record.manifests = record.manifests.filter((manifest) => manifest !== name);
|
|
1336
|
+
await this.write(library);
|
|
1337
|
+
return library;
|
|
1338
|
+
}
|
|
1339
|
+
/**
|
|
1340
|
+
* Structural + referential validation, listed per asset: invalid id, unknown
|
|
1341
|
+
* type, dangling raw pointer, invalid parameters, unknown manifest names,
|
|
1342
|
+
* and reference errors (dangling target, type mismatch, cycles).
|
|
1343
|
+
*/
|
|
1344
|
+
async validate(library) {
|
|
1345
|
+
const issues = [];
|
|
1346
|
+
const folderKeys = /* @__PURE__ */ new Map();
|
|
1347
|
+
for (const folder of library.folders) {
|
|
1348
|
+
const problem = folderPathError(folder);
|
|
1349
|
+
if (problem) issues.push({ message: problem });
|
|
1350
|
+
if (folder !== normalizeFolderPath(folder)) issues.push({ message: `Folder path "${folder}" is not Unicode-normalized.` });
|
|
1351
|
+
const key = portableFolderPathKey(folder);
|
|
1352
|
+
const collision = folderKeys.get(key);
|
|
1353
|
+
if (collision !== void 0) issues.push({ message: `Folders "${collision}" and "${folder}" collide on disk.` });
|
|
1354
|
+
else folderKeys.set(key, folder);
|
|
1355
|
+
const parent = parentFolderPath(folder);
|
|
1356
|
+
if (parent !== "" && !library.folders.includes(parent)) issues.push({ message: `Folder "${folder}" has missing parent "${parent}".` });
|
|
1357
|
+
for (const root of [this.#config.rawDir, this.#config.proxyDir]) if (!existsSync(join(root, folder, ".gitkeep"))) issues.push({ message: `Folder "${folder}" is not mirrored with a .gitkeep in ${root}.` });
|
|
1358
|
+
}
|
|
1359
|
+
for (const [name, record] of Object.entries(library.manifests)) {
|
|
1360
|
+
const nameProblem = manifestNameError(name);
|
|
1361
|
+
if (nameProblem) issues.push({ message: nameProblem });
|
|
1362
|
+
if (record.color !== void 0) {
|
|
1363
|
+
const colorProblem = manifestColorError(record.color);
|
|
1364
|
+
if (colorProblem) issues.push({ message: `Manifest "${name}": ${colorProblem}` });
|
|
1365
|
+
}
|
|
1366
|
+
if (record.emoji !== void 0) {
|
|
1367
|
+
const emojiProblem = manifestEmojiError(record.emoji);
|
|
1368
|
+
if (emojiProblem) issues.push({ message: `Manifest "${name}": ${emojiProblem}` });
|
|
1369
|
+
}
|
|
1370
|
+
}
|
|
1371
|
+
const namesByModuleFile = /* @__PURE__ */ new Map();
|
|
1372
|
+
for (const name of Object.keys(library.manifests)) {
|
|
1373
|
+
const fileName = manifestModuleFileName(name);
|
|
1374
|
+
const existingName = namesByModuleFile.get(fileName);
|
|
1375
|
+
if (existingName !== void 0) issues.push({ message: `Manifests "${existingName}" and "${name}" would both generate "${fileName}".` });
|
|
1376
|
+
else namesByModuleFile.set(fileName, name);
|
|
1377
|
+
}
|
|
1378
|
+
for (const [assetId, record] of Object.entries(library.assets)) {
|
|
1379
|
+
const idProblem = assetIdError(assetId);
|
|
1380
|
+
if (idProblem) issues.push({
|
|
1381
|
+
assetId,
|
|
1382
|
+
message: idProblem
|
|
1383
|
+
});
|
|
1384
|
+
const plugin = this.#registry[record.type];
|
|
1385
|
+
if (!plugin) issues.push({
|
|
1386
|
+
assetId,
|
|
1387
|
+
message: `Unknown asset type "${record.type}".`
|
|
1388
|
+
});
|
|
1389
|
+
else try {
|
|
1390
|
+
plugin.descriptor.validateParameters(record.parameters);
|
|
1391
|
+
} catch (cause) {
|
|
1392
|
+
issues.push({
|
|
1393
|
+
assetId,
|
|
1394
|
+
message: `Invalid parameters: ${cause instanceof Error ? cause.message : String(cause)}`
|
|
1395
|
+
});
|
|
1396
|
+
}
|
|
1397
|
+
try {
|
|
1398
|
+
assertSafeRelativeFilePath(record.raw);
|
|
1399
|
+
} catch (cause) {
|
|
1400
|
+
issues.push({
|
|
1401
|
+
assetId,
|
|
1402
|
+
message: cause instanceof Error ? cause.message : String(cause)
|
|
1403
|
+
});
|
|
1404
|
+
}
|
|
1405
|
+
const assetFolder = folderOfRawPath(record.raw);
|
|
1406
|
+
if (assetFolder !== "" && !library.folders.includes(assetFolder)) issues.push({
|
|
1407
|
+
assetId,
|
|
1408
|
+
message: `Unknown asset folder "${assetFolder}".`
|
|
1409
|
+
});
|
|
1410
|
+
if (!existsSync(join(this.#config.rawDir, record.raw))) issues.push({
|
|
1411
|
+
assetId,
|
|
1412
|
+
message: `Raw file "${record.raw}" is missing from the raw directory.`
|
|
1413
|
+
});
|
|
1414
|
+
for (const manifest of record.manifests) if (!library.manifests[manifest]) issues.push({
|
|
1415
|
+
assetId,
|
|
1416
|
+
message: `Unknown manifest "${manifest}".`
|
|
1417
|
+
});
|
|
1418
|
+
}
|
|
1419
|
+
issues.push(...referenceErrors(library, collectAllReferences(library, descriptorsByType(this.#registry))));
|
|
1420
|
+
return issues;
|
|
1421
|
+
}
|
|
1422
|
+
async #deleteProxiesOf(assetId) {
|
|
1423
|
+
if (!existsSync(this.#config.proxyDir)) return;
|
|
1424
|
+
for (const relativePath of await listRelativeFiles(this.#config.proxyDir)) if (isProxyFileNameFor(assetId, rawFileNameOfPath(relativePath))) await unlink(join(this.#config.proxyDir, relativePath));
|
|
1425
|
+
}
|
|
1426
|
+
async syncFolders(library) {
|
|
1427
|
+
await syncManagedFolders(this.#config, library);
|
|
1428
|
+
}
|
|
1429
|
+
#requireAsset(library, id) {
|
|
1430
|
+
if (!Object.hasOwn(library.assets, id)) throw new Error(`Unknown asset "${id}".`);
|
|
1431
|
+
return library.assets[id];
|
|
1432
|
+
}
|
|
1433
|
+
/** Resolve a non-colliding raw file name, appending `-1`, `-2`, … before the extension. */
|
|
1434
|
+
#uniqueRawName(fileName, folder) {
|
|
1435
|
+
if (!existsSync(join(this.#config.rawDir, folder, fileName))) return fileName;
|
|
1436
|
+
const extension = extname(fileName);
|
|
1437
|
+
const stem = fileName.slice(0, fileName.length - extension.length);
|
|
1438
|
+
for (let index = 1;; index++) {
|
|
1439
|
+
const candidate = `${stem}-${index}${extension}`;
|
|
1440
|
+
if (!existsSync(join(this.#config.rawDir, folder, candidate))) return candidate;
|
|
1441
|
+
}
|
|
1442
|
+
}
|
|
1443
|
+
#requireFolder(library, folder, allowRoot = true) {
|
|
1444
|
+
if (folder === "" && allowRoot) return;
|
|
1445
|
+
if (!library.folders.includes(folder)) throw new Error(`Unknown folder "${folder}".`);
|
|
1446
|
+
}
|
|
1447
|
+
#assertFolderAvailable(library, path, except) {
|
|
1448
|
+
const key = portableFolderPathKey(path);
|
|
1449
|
+
const collision = library.folders.find((folder) => folder !== except && portableFolderPathKey(folder) === key);
|
|
1450
|
+
if (collision !== void 0) throw new Error(`Folder "${path}" collides with existing folder "${collision}".`);
|
|
1451
|
+
}
|
|
1452
|
+
};
|
|
1453
|
+
//#endregion
|
|
1454
|
+
//#region src/operations.ts
|
|
1455
|
+
/**
|
|
1456
|
+
* The one operations layer: builds the state payload and runs
|
|
1457
|
+
* every mutation through the autosave chain — write library → process affected
|
|
1458
|
+
* assets → regenerate codegen → return fresh state. Both the REST server
|
|
1459
|
+
* and the CLI consume this class, so every
|
|
1460
|
+
* operation has exactly one implementation and there is no other save path.
|
|
1461
|
+
*/
|
|
1462
|
+
/**
|
|
1463
|
+
* The CLI's `--parameters` merge semantics (M7): a SHALLOW merge at the top
|
|
1464
|
+
* level onto the current parameters — a nested object value replaces the whole
|
|
1465
|
+
* current object under that key, a top-level `null` clears an optional field,
|
|
1466
|
+
* and an asset reference is set with the normal `{"$asset": "<id>"}` marker.
|
|
1467
|
+
* The merged result still passes the owning plugin's full validation.
|
|
1468
|
+
*/
|
|
1469
|
+
function mergeParameterPatch(currentParameters, patch) {
|
|
1470
|
+
if (typeof patch !== "object" || patch === null || Array.isArray(patch)) throw new Error("The parameter patch must be a JSON object, e.g. {\"preset\": \"default\"}.");
|
|
1471
|
+
const merged = typeof currentParameters === "object" && currentParameters !== null && !Array.isArray(currentParameters) ? { ...currentParameters } : {};
|
|
1472
|
+
for (const [key, value] of Object.entries(patch)) if (value === null) delete merged[key];
|
|
1473
|
+
else merged[key] = value;
|
|
1474
|
+
return merged;
|
|
1475
|
+
}
|
|
1476
|
+
var FundusOperations = class {
|
|
1477
|
+
#config;
|
|
1478
|
+
#registry;
|
|
1479
|
+
#store;
|
|
1480
|
+
constructor(config, registry = serverPluginsByType) {
|
|
1481
|
+
this.#config = config;
|
|
1482
|
+
this.#registry = registry;
|
|
1483
|
+
this.#store = new LibraryStore(config, registry);
|
|
1484
|
+
}
|
|
1485
|
+
get config() {
|
|
1486
|
+
return this.#config;
|
|
1487
|
+
}
|
|
1488
|
+
get store() {
|
|
1489
|
+
return this.#store;
|
|
1490
|
+
}
|
|
1491
|
+
/** Read-only state snapshot (no processing). */
|
|
1492
|
+
async state() {
|
|
1493
|
+
return this.#buildState(await this.#store.read(), []);
|
|
1494
|
+
}
|
|
1495
|
+
/**
|
|
1496
|
+
* Ingest an upload: store the raw file + record, then let the owning
|
|
1497
|
+
* plugin's optional `seedIngestParameters` derive better starting
|
|
1498
|
+
* parameters from the stored raw file (slices: insets from the image
|
|
1499
|
+
* dimensions, pixel ratio from the file name — the schema default can't
|
|
1500
|
+
* know either). The CLI additionally applies a parameter patch and/or an
|
|
1501
|
+
* explicit membership set in the same operation; a failing application
|
|
1502
|
+
* rolls the whole ingest back, so a failed ingest leaves nothing behind.
|
|
1503
|
+
*/
|
|
1504
|
+
async ingest(options) {
|
|
1505
|
+
let library = await this.#store.ingest(options);
|
|
1506
|
+
try {
|
|
1507
|
+
const plugin = serverPluginForType(options.type, this.#registry);
|
|
1508
|
+
if (plugin.seedIngestParameters) {
|
|
1509
|
+
const record = library.assets[options.id];
|
|
1510
|
+
const seeded = await plugin.seedIngestParameters({
|
|
1511
|
+
rawPath: join(this.#config.rawDir, record.raw),
|
|
1512
|
+
rawFileName: rawFileNameOfPath(record.raw),
|
|
1513
|
+
parameters: plugin.descriptor.validateParameters(record.parameters),
|
|
1514
|
+
config: this.#config
|
|
1515
|
+
});
|
|
1516
|
+
if (seeded !== void 0) library = await this.#store.updateParameters(options.id, seeded);
|
|
1517
|
+
}
|
|
1518
|
+
if (options.parametersPatch !== void 0 || options.manifests !== void 0) library = await this.#applyAssetChanges(library, options.id, {
|
|
1519
|
+
parametersPatch: options.parametersPatch,
|
|
1520
|
+
manifests: options.manifests
|
|
1521
|
+
});
|
|
1522
|
+
} catch (cause) {
|
|
1523
|
+
await this.#store.deleteAsset(options.id);
|
|
1524
|
+
throw cause;
|
|
1525
|
+
}
|
|
1526
|
+
return this.#afterMutation(library);
|
|
1527
|
+
}
|
|
1528
|
+
/** Apply a PATCH body: parameters and/or manifests and/or a rename — in that order. */
|
|
1529
|
+
async patchAsset(id, body) {
|
|
1530
|
+
let library = await this.#store.read();
|
|
1531
|
+
this.#requireAsset(library, id);
|
|
1532
|
+
if (body.parameters !== void 0) library = await this.#store.updateParameters(id, body.parameters);
|
|
1533
|
+
if (body.manifests !== void 0) {
|
|
1534
|
+
if (!Array.isArray(body.manifests) || body.manifests.some((name) => typeof name !== "string")) throw new Error("\"manifests\" must be an array of manifest names.");
|
|
1535
|
+
library = await this.#store.setManifests(id, body.manifests);
|
|
1536
|
+
}
|
|
1537
|
+
if (body.renameTo !== void 0) {
|
|
1538
|
+
if (typeof body.renameTo !== "string") throw new Error("\"renameTo\" must be a string.");
|
|
1539
|
+
library = await this.#store.renameAsset(id, body.renameTo);
|
|
1540
|
+
}
|
|
1541
|
+
if (body.folder !== void 0) {
|
|
1542
|
+
if (typeof body.folder !== "string") throw new Error("\"folder\" must be a string.");
|
|
1543
|
+
library = await this.#store.moveAsset(body.renameTo ?? id, body.folder);
|
|
1544
|
+
}
|
|
1545
|
+
return this.#afterMutation(library);
|
|
1546
|
+
}
|
|
1547
|
+
async moveAsset(id, folder) {
|
|
1548
|
+
return this.#afterMutation(await this.#store.moveAsset(id, folder));
|
|
1549
|
+
}
|
|
1550
|
+
/** Move a selected asset set as one editor action and roll back on any failure. */
|
|
1551
|
+
async moveAssets(assetIds, folder) {
|
|
1552
|
+
const uniqueIds = [...new Set(assetIds)];
|
|
1553
|
+
if (uniqueIds.length === 0) throw new Error("At least one asset id is required.");
|
|
1554
|
+
const originalFolders = /* @__PURE__ */ new Map();
|
|
1555
|
+
const library = await this.#store.read();
|
|
1556
|
+
for (const id of uniqueIds) {
|
|
1557
|
+
this.#requireAsset(library, id);
|
|
1558
|
+
originalFolders.set(id, folderOfRawPath(library.assets[id].raw));
|
|
1559
|
+
}
|
|
1560
|
+
const movedIds = [];
|
|
1561
|
+
try {
|
|
1562
|
+
let updated = library;
|
|
1563
|
+
for (const id of uniqueIds) {
|
|
1564
|
+
if (originalFolders.get(id) === folder) continue;
|
|
1565
|
+
updated = await this.#store.moveAsset(id, folder);
|
|
1566
|
+
movedIds.push(id);
|
|
1567
|
+
}
|
|
1568
|
+
return this.#afterMutation(updated);
|
|
1569
|
+
} catch (cause) {
|
|
1570
|
+
for (const id of movedIds.reverse()) await this.#store.moveAsset(id, originalFolders.get(id) ?? "");
|
|
1571
|
+
throw cause;
|
|
1572
|
+
}
|
|
1573
|
+
}
|
|
1574
|
+
async createFolder(path) {
|
|
1575
|
+
return this.#afterMutation(await this.#store.createFolder(path));
|
|
1576
|
+
}
|
|
1577
|
+
async renameFolder(path, newName) {
|
|
1578
|
+
return this.#afterMutation(await this.#store.renameFolder(path, newName));
|
|
1579
|
+
}
|
|
1580
|
+
async moveFolder(path, parent) {
|
|
1581
|
+
return this.#afterMutation(await this.#store.moveFolder(path, parent));
|
|
1582
|
+
}
|
|
1583
|
+
async deleteFolder(path) {
|
|
1584
|
+
const result = await this.#store.deleteFolder(path);
|
|
1585
|
+
return this.#afterMutation(result.library);
|
|
1586
|
+
}
|
|
1587
|
+
/**
|
|
1588
|
+
* CLI-facing asset update (M7): shallow-merge a parameter patch and/or
|
|
1589
|
+
* replace the explicit manifest membership — validating the *result* before
|
|
1590
|
+
* anything is persisted, so a change that would leave the library invalid
|
|
1591
|
+
* (dangling or cyclic reference, unknown manifest, invalid parameters) is
|
|
1592
|
+
* rejected with nothing written. The REST `patchAsset` keeps the editor's
|
|
1593
|
+
* lenient replace semantics.
|
|
1594
|
+
*/
|
|
1595
|
+
async setAsset(id, changes) {
|
|
1596
|
+
const library = await this.#store.read();
|
|
1597
|
+
this.#requireAsset(library, id);
|
|
1598
|
+
return this.#afterMutation(await this.#applyAssetChanges(library, id, changes));
|
|
1599
|
+
}
|
|
1600
|
+
/**
|
|
1601
|
+
* CLI-facing rename (M7): rejects a rename that would break references —
|
|
1602
|
+
* a referrer keeps pointing at the old id, so renaming a referenced asset
|
|
1603
|
+
* introduces dangling-reference errors. Update the referrers first.
|
|
1604
|
+
*/
|
|
1605
|
+
async renameAsset(id, newId) {
|
|
1606
|
+
const library = await this.#store.read();
|
|
1607
|
+
this.#requireAsset(library, id);
|
|
1608
|
+
if (newId !== id && !Object.hasOwn(library.assets, newId)) {
|
|
1609
|
+
const candidate = structuredClone(library);
|
|
1610
|
+
candidate.assets[newId] = candidate.assets[id];
|
|
1611
|
+
delete candidate.assets[id];
|
|
1612
|
+
await this.#assertNoIntroducedIssues(library, candidate);
|
|
1613
|
+
}
|
|
1614
|
+
return this.#afterMutation(await this.#store.renameAsset(id, newId));
|
|
1615
|
+
}
|
|
1616
|
+
/**
|
|
1617
|
+
* Delete an asset: the record, its raw file, and its proxies. With
|
|
1618
|
+
* `failOnIntroducedIssues` (the CLI), a delete that would break references
|
|
1619
|
+
* (the asset is a reference target) is rejected before anything —
|
|
1620
|
+
* including the raw file — is removed; the REST route stays lenient.
|
|
1621
|
+
*/
|
|
1622
|
+
async deleteAsset(id, options) {
|
|
1623
|
+
if (options?.failOnIntroducedIssues) {
|
|
1624
|
+
const library = await this.#store.read();
|
|
1625
|
+
this.#requireAsset(library, id);
|
|
1626
|
+
const candidate = structuredClone(library);
|
|
1627
|
+
delete candidate.assets[id];
|
|
1628
|
+
await this.#assertNoIntroducedIssues(library, candidate);
|
|
1629
|
+
}
|
|
1630
|
+
return this.#afterMutation(await this.#store.deleteAsset(id));
|
|
1631
|
+
}
|
|
1632
|
+
/** Delete a selected asset set as one lenient editor mutation. */
|
|
1633
|
+
async deleteAssets(assetIds) {
|
|
1634
|
+
const uniqueIds = [...new Set(assetIds)];
|
|
1635
|
+
if (uniqueIds.length === 0) throw new Error("At least one asset id is required.");
|
|
1636
|
+
return this.#afterMutation(await this.#store.deleteAssets(uniqueIds));
|
|
1637
|
+
}
|
|
1638
|
+
async createManifest(name) {
|
|
1639
|
+
return this.#afterMutation(await this.#store.createManifest(name));
|
|
1640
|
+
}
|
|
1641
|
+
async setManifestAppearance(name, appearance) {
|
|
1642
|
+
return this.#afterMutation(await this.#store.setManifestAppearance(name, appearance));
|
|
1643
|
+
}
|
|
1644
|
+
/** Apply a PATCH body: appearance fields and/or a rename — in that order. */
|
|
1645
|
+
async patchManifest(name, body) {
|
|
1646
|
+
let library = await this.#store.setManifestAppearance(name, {
|
|
1647
|
+
color: body.color,
|
|
1648
|
+
emoji: body.emoji
|
|
1649
|
+
});
|
|
1650
|
+
if (body.renameTo !== void 0) {
|
|
1651
|
+
if (typeof body.renameTo !== "string") throw new Error("\"renameTo\" must be a string.");
|
|
1652
|
+
library = await this.#store.renameManifest(name, body.renameTo);
|
|
1653
|
+
}
|
|
1654
|
+
return this.#afterMutation(library);
|
|
1655
|
+
}
|
|
1656
|
+
/**
|
|
1657
|
+
* Rename a manifest: rewrites every asset's membership and replaces the
|
|
1658
|
+
* generated module (the old one is pruned as a stray). Host code importing
|
|
1659
|
+
* the module must be updated by hand — both the file name and the exported
|
|
1660
|
+
* const change with the manifest name.
|
|
1661
|
+
*/
|
|
1662
|
+
async renameManifest(name, newName) {
|
|
1663
|
+
return this.#afterMutation(await this.#store.renameManifest(name, newName));
|
|
1664
|
+
}
|
|
1665
|
+
async deleteManifest(name) {
|
|
1666
|
+
return this.#afterMutation(await this.#store.deleteManifest(name));
|
|
1667
|
+
}
|
|
1668
|
+
/** The autosave chain tail: process + regenerate, then report fresh state. */
|
|
1669
|
+
async #afterMutation(library) {
|
|
1670
|
+
const pipelineIssues = [];
|
|
1671
|
+
const issues = await this.#store.validate(library);
|
|
1672
|
+
if (issues.length === 0) try {
|
|
1673
|
+
const report = await processAssets(this.#config, library, this.#registry);
|
|
1674
|
+
await generateManifestModules(this.#config, library, report.proxies, this.#registry);
|
|
1675
|
+
} catch (cause) {
|
|
1676
|
+
pipelineIssues.push({ message: `Processing failed: ${cause instanceof Error ? cause.message : String(cause)}` });
|
|
1677
|
+
}
|
|
1678
|
+
return this.#buildState(library, pipelineIssues, issues);
|
|
1679
|
+
}
|
|
1680
|
+
async #buildState(library, extraIssues, precomputedIssues) {
|
|
1681
|
+
const issues = [...precomputedIssues ?? await this.#store.validate(library), ...extraIssues];
|
|
1682
|
+
const referencesByAsset = collectAllReferences(library, descriptorsByType(this.#registry));
|
|
1683
|
+
const membershipByManifest = computeLibraryMembership(library, referencesByAsset);
|
|
1684
|
+
const delivered = allDeliveredAssetIds(membershipByManifest);
|
|
1685
|
+
const passiveManifestsByAsset = /* @__PURE__ */ new Map();
|
|
1686
|
+
for (const manifestName of Object.keys(library.manifests).sort(compareCodeUnits)) for (const passiveMember of membershipByManifest[manifestName].passiveMembers) {
|
|
1687
|
+
const passiveManifests = passiveManifestsByAsset.get(passiveMember.assetId) ?? [];
|
|
1688
|
+
passiveManifests.push({
|
|
1689
|
+
manifest: manifestName,
|
|
1690
|
+
via: passiveMember.via
|
|
1691
|
+
});
|
|
1692
|
+
passiveManifestsByAsset.set(passiveMember.assetId, passiveManifests);
|
|
1693
|
+
}
|
|
1694
|
+
const incomingByAsset = /* @__PURE__ */ new Map();
|
|
1695
|
+
for (const [referrerId, references] of Object.entries(referencesByAsset)) for (const reference of references) {
|
|
1696
|
+
const incoming = incomingByAsset.get(reference.assetId) ?? [];
|
|
1697
|
+
incoming.push({
|
|
1698
|
+
field: reference.field,
|
|
1699
|
+
assetId: referrerId
|
|
1700
|
+
});
|
|
1701
|
+
incomingByAsset.set(reference.assetId, incoming);
|
|
1702
|
+
}
|
|
1703
|
+
const assets = [];
|
|
1704
|
+
for (const assetId of Object.keys(library.assets).sort(compareCodeUnits)) {
|
|
1705
|
+
const record = library.assets[assetId];
|
|
1706
|
+
const warnings = [];
|
|
1707
|
+
for (const issue of issues) if (issue.assetId === assetId) warnings.push(issue.message);
|
|
1708
|
+
if (record.manifests.length === 0 && !delivered.has(assetId)) warnings.push("In no manifest and not referenced by any delivered asset — nothing is processed or generated for it.");
|
|
1709
|
+
let proxy = null;
|
|
1710
|
+
if (delivered.has(assetId)) try {
|
|
1711
|
+
const currentProxy = await readCurrentProxy(assetId, record, this.#config, this.#registry);
|
|
1712
|
+
if (currentProxy) proxy = {
|
|
1713
|
+
url: `/files/proxy/${encodeRelativePathForUrl(currentProxy.relativePath)}`,
|
|
1714
|
+
upToDate: true,
|
|
1715
|
+
...currentProxy
|
|
1716
|
+
};
|
|
1717
|
+
else warnings.push("Proxy is stale or missing — it will be (re)processed on the next save.");
|
|
1718
|
+
} catch (cause) {
|
|
1719
|
+
warnings.push(cause instanceof Error ? cause.message : String(cause));
|
|
1720
|
+
}
|
|
1721
|
+
assets.push({
|
|
1722
|
+
id: assetId,
|
|
1723
|
+
type: record.type,
|
|
1724
|
+
raw: record.raw,
|
|
1725
|
+
folder: folderOfRawPath(record.raw),
|
|
1726
|
+
rawUrl: `/files/raw/${encodeRelativePathForUrl(record.raw)}`,
|
|
1727
|
+
rawHash: record.rawHash,
|
|
1728
|
+
parameters: record.parameters,
|
|
1729
|
+
manifests: record.manifests,
|
|
1730
|
+
passiveManifests: passiveManifestsByAsset.get(assetId) ?? [],
|
|
1731
|
+
proxy,
|
|
1732
|
+
warnings,
|
|
1733
|
+
references: {
|
|
1734
|
+
outgoing: referencesByAsset[assetId].map((reference) => ({
|
|
1735
|
+
field: reference.field,
|
|
1736
|
+
assetId: reference.assetId
|
|
1737
|
+
})),
|
|
1738
|
+
incoming: incomingByAsset.get(assetId) ?? []
|
|
1739
|
+
}
|
|
1740
|
+
});
|
|
1741
|
+
}
|
|
1742
|
+
const proxySizeByAssetId = new Map(assets.filter((asset) => asset.proxy).map((asset) => [asset.id, asset.proxy?.fileSize ?? 0]));
|
|
1743
|
+
const manifestMemberships = {};
|
|
1744
|
+
const manifestDeliveredBytes = {};
|
|
1745
|
+
for (const manifestName of Object.keys(library.manifests).sort(compareCodeUnits)) {
|
|
1746
|
+
const membership = membershipByManifest[manifestName];
|
|
1747
|
+
manifestMemberships[manifestName] = {
|
|
1748
|
+
explicitMemberIds: membership.explicitMemberIds,
|
|
1749
|
+
passiveMembers: membership.passiveMembers
|
|
1750
|
+
};
|
|
1751
|
+
let deliveredBytes = 0;
|
|
1752
|
+
for (const deliveredAssetId of membership.deliveredAssetIds) deliveredBytes += proxySizeByAssetId.get(deliveredAssetId) ?? 0;
|
|
1753
|
+
manifestDeliveredBytes[manifestName] = deliveredBytes;
|
|
1754
|
+
}
|
|
1755
|
+
const presetNames = {};
|
|
1756
|
+
for (const [type, plugin] of Object.entries(this.#registry)) presetNames[type] = Object.keys(plugin.presets(this.#config)).sort();
|
|
1757
|
+
return {
|
|
1758
|
+
library,
|
|
1759
|
+
assets,
|
|
1760
|
+
issues,
|
|
1761
|
+
presetNames,
|
|
1762
|
+
manifestMemberships,
|
|
1763
|
+
manifestDeliveredBytes
|
|
1764
|
+
};
|
|
1765
|
+
}
|
|
1766
|
+
#requireAsset(library, id) {
|
|
1767
|
+
if (!Object.hasOwn(library.assets, id)) throw new Error(`Unknown asset "${id}".`);
|
|
1768
|
+
}
|
|
1769
|
+
/**
|
|
1770
|
+
* Apply a CLI asset change to the store, validating the resulting library
|
|
1771
|
+
* on an in-memory candidate first — nothing is persisted when the change
|
|
1772
|
+
* would introduce validation issues. Pre-existing issues never block an
|
|
1773
|
+
* unrelated mutation (only *introduced* issues are rejected).
|
|
1774
|
+
*/
|
|
1775
|
+
async #applyAssetChanges(library, id, changes) {
|
|
1776
|
+
const candidate = structuredClone(library);
|
|
1777
|
+
const candidateRecord = candidate.assets[id];
|
|
1778
|
+
if (changes.parametersPatch !== void 0) candidateRecord.parameters = serverPluginForType(candidateRecord.type, this.#registry).descriptor.validateParameters(mergeParameterPatch(candidateRecord.parameters, changes.parametersPatch));
|
|
1779
|
+
if (changes.manifests !== void 0) candidateRecord.manifests = [...new Set(changes.manifests)].sort();
|
|
1780
|
+
await this.#assertNoIntroducedIssues(library, candidate);
|
|
1781
|
+
let updated = library;
|
|
1782
|
+
if (changes.parametersPatch !== void 0) updated = await this.#store.updateParameters(id, candidateRecord.parameters);
|
|
1783
|
+
if (changes.manifests !== void 0) updated = await this.#store.setManifests(id, changes.manifests);
|
|
1784
|
+
return updated;
|
|
1785
|
+
}
|
|
1786
|
+
/** Reject a candidate library that has validation issues the current one lacks. */
|
|
1787
|
+
async #assertNoIntroducedIssues(library, candidate) {
|
|
1788
|
+
const existingIssueKeys = new Set((await this.#store.validate(library)).map((issue) => `${issue.assetId ?? ""}|${issue.message}`));
|
|
1789
|
+
const introduced = (await this.#store.validate(candidate)).filter((issue) => !existingIssueKeys.has(`${issue.assetId ?? ""}|${issue.message}`));
|
|
1790
|
+
if (introduced.length > 0) {
|
|
1791
|
+
const lines = introduced.map((issue) => ` - ${issue.assetId ? `[${issue.assetId}] ` : ""}${issue.message}`);
|
|
1792
|
+
throw new Error(`Rejected — the change would leave the library invalid (nothing was written):\n${lines.join("\n")}`);
|
|
1793
|
+
}
|
|
1794
|
+
}
|
|
1795
|
+
};
|
|
1796
|
+
//#endregion
|
|
1797
|
+
export { runToolchainBinary as C, ffmpegBinaryPath as S, resolveConfig as T, strayGeneratedModuleNames as _, deliveredAssetIds as a, serverPluginForType as b, isProxyFileName as c, readProxyOutputInfo as d, listRelativeFiles as f, renderManifestModule as g, manifestModuleFileName as h, sha256Hex as i, processAssets as l, generateManifestModules as m, LibraryStore as n, expectedProxyFileName as o, buildManifestEntries as p, assertSafeRelativeFilePath as r, expectedProxyRelativePath as s, FundusOperations as t, readCurrentProxy as u, descriptorsByType as v, loadConfig as w, serverPluginsByType as x, pluginsByType as y };
|