@hoardodile/host 0.0.0
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/LICENSE +18 -0
- package/README.md +73 -0
- package/dist/chunks/worker-entry.mjs +422 -0
- package/dist/container-fKxefO7p.d.ts +58 -0
- package/dist/contract/index.d.ts +49 -0
- package/dist/contract/index.js +187 -0
- package/dist/contract/index.js.map +1 -0
- package/dist/hoard/index.d.ts +754 -0
- package/dist/hoard/index.js +1939 -0
- package/dist/hoard/index.js.map +1 -0
- package/dist/image-Dx2Be3Bj.d.ts +168 -0
- package/dist/index.d.ts +788 -0
- package/dist/index.js +3311 -0
- package/dist/index.js.map +1 -0
- package/dist/media/index.d.ts +96 -0
- package/dist/media/index.js +152 -0
- package/dist/media/index.js.map +1 -0
- package/dist/probe/index.d.ts +113 -0
- package/dist/probe/index.js +454 -0
- package/dist/probe/index.js.map +1 -0
- package/dist/probe-cache-BkRI7HEK.d.ts +25 -0
- package/dist/render/index.d.ts +125 -0
- package/dist/render/index.js +430 -0
- package/dist/render/index.js.map +1 -0
- package/package.json +103 -0
- package/src/__testutils__/zip-fixtures.ts +139 -0
- package/src/activation.ts +116 -0
- package/src/api-types.ts +60 -0
- package/src/api.test.ts +564 -0
- package/src/api.ts +501 -0
- package/src/archive/7z.test.ts +222 -0
- package/src/archive/7z.ts +261 -0
- package/src/archive/archive-ops.test.ts +654 -0
- package/src/archive/extract-archive.test.ts +237 -0
- package/src/archive/extract-archive.ts +534 -0
- package/src/archive/extract.ts +693 -0
- package/src/archive/format.ts +49 -0
- package/src/archive/index.ts +116 -0
- package/src/archive/listing.ts +121 -0
- package/src/archive/materialize.ts +88 -0
- package/src/archive/name-decode.test.ts +34 -0
- package/src/archive/name-decode.ts +287 -0
- package/src/archive/nested-cd-cache.ts +55 -0
- package/src/archive/nested-entry.test.ts +194 -0
- package/src/archive/nested-entry.ts +254 -0
- package/src/archive/pack.ts +40 -0
- package/src/archive/zip-entries.test.ts +56 -0
- package/src/archive/zip-entries.ts +336 -0
- package/src/capability-guard.ts +73 -0
- package/src/container.ts +62 -0
- package/src/contract/container-fixture.ts +74 -0
- package/src/contract/index.ts +10 -0
- package/src/contract/suite.test.ts +101 -0
- package/src/contract/suite.ts +205 -0
- package/src/define-plugin.test.ts +53 -0
- package/src/define-plugin.ts +12 -0
- package/src/dev-runner.test.ts +98 -0
- package/src/dev-runner.ts +73 -0
- package/src/directory-api.test.ts +229 -0
- package/src/directory-api.ts +42 -0
- package/src/directory-container.ts +141 -0
- package/src/discovery.ts +295 -0
- package/src/errors.ts +74 -0
- package/src/fixtures.ts +8 -0
- package/src/hash.test.ts +301 -0
- package/src/hash.ts +167 -0
- package/src/hoard/dir-size.ts +58 -0
- package/src/hoard/index.ts +84 -0
- package/src/hoard/migrate-script.test.ts +539 -0
- package/src/hoard/order-manifest.test.ts +115 -0
- package/src/hoard/order-manifest.ts +131 -0
- package/src/hoard/paths.test.ts +226 -0
- package/src/hoard/paths.ts +569 -0
- package/src/hoard/plugin-vault.test.ts +160 -0
- package/src/hoard/plugin-vault.ts +233 -0
- package/src/hoard/sanitize.test.ts +149 -0
- package/src/hoard/sanitize.ts +230 -0
- package/src/hoard/staging-dir.ts +133 -0
- package/src/hoard/version.test.ts +285 -0
- package/src/hoard/version.ts +216 -0
- package/src/hoard/versioned-folder-ops.ts +152 -0
- package/src/hoard/write-versioned.test.ts +67 -0
- package/src/hoard/write-versioned.ts +39 -0
- package/src/hooks.test.ts +528 -0
- package/src/hooks.ts +428 -0
- package/src/index.ts +135 -0
- package/src/loader.test.ts +442 -0
- package/src/loader.ts +213 -0
- package/src/media/index.ts +16 -0
- package/src/media/render-cache.ts +27 -0
- package/src/media/seekable.ts +35 -0
- package/src/media/thumb-input.test.ts +109 -0
- package/src/media/thumb-input.ts +194 -0
- package/src/nested-view.test.ts +223 -0
- package/src/nested-view.ts +239 -0
- package/src/probe/audio.test.ts +135 -0
- package/src/probe/audio.ts +140 -0
- package/src/probe/av.ts +84 -0
- package/src/probe/ffprobe.ts +197 -0
- package/src/probe/image.test.ts +251 -0
- package/src/probe/image.ts +336 -0
- package/src/probe/index.ts +54 -0
- package/src/probe/sniff.test.ts +72 -0
- package/src/probe/sniff.ts +67 -0
- package/src/probe/video.test.ts +41 -0
- package/src/probe/video.ts +116 -0
- package/src/probe-cache.test.ts +89 -0
- package/src/probe-cache.ts +65 -0
- package/src/render/ffmpeg.ts +78 -0
- package/src/render/index.ts +34 -0
- package/src/render/pipeline.test.ts +187 -0
- package/src/render/pipeline.ts +479 -0
- package/src/sandbox/call-timers.ts +68 -0
- package/src/sandbox/fixtures/api-error-plugin.mjs +11 -0
- package/src/sandbox/fixtures/asset-plugin.mjs +13 -0
- package/src/sandbox/fixtures/chatty-plugin.mjs +14 -0
- package/src/sandbox/fixtures/container-plugin.mjs +8 -0
- package/src/sandbox/fixtures/context-plugin.mjs +19 -0
- package/src/sandbox/fixtures/crash-plugin.mjs +6 -0
- package/src/sandbox/fixtures/echo-plugin.mjs +17 -0
- package/src/sandbox/fixtures/exit-plugin.mjs +6 -0
- package/src/sandbox/fixtures/fetch-plugin.mjs +8 -0
- package/src/sandbox/fixtures/flood-api-plugin.mjs +8 -0
- package/src/sandbox/fixtures/flood-log-plugin.mjs +8 -0
- package/src/sandbox/fixtures/hostile-dynamic-plugin.mjs +9 -0
- package/src/sandbox/fixtures/hostile-plugin.mjs +13 -0
- package/src/sandbox/fixtures/huge-result-plugin.mjs +8 -0
- package/src/sandbox/fixtures/logging-plugin.mjs +9 -0
- package/src/sandbox/fixtures/range-plugin.mjs +9 -0
- package/src/sandbox/fixtures/slow-api-plugin.mjs +11 -0
- package/src/sandbox/fixtures/spin-plugin.mjs +9 -0
- package/src/sandbox/fixtures/stuck-plugin.mjs +12 -0
- package/src/sandbox/fixtures/thrower-plugin.mjs +7 -0
- package/src/sandbox/host.test.ts +619 -0
- package/src/sandbox/host.ts +933 -0
- package/src/sandbox/host.unit.test.ts +330 -0
- package/src/sandbox/protocol.test.ts +85 -0
- package/src/sandbox/protocol.ts +147 -0
- package/src/sandbox/sandboxed-plugin.ts +30 -0
- package/src/sandbox/worker-entry.mjs +422 -0
- package/src/seed.test.ts +102 -0
- package/src/seed.ts +160 -0
- package/src/settings-store.ts +26 -0
- package/src/types.ts +21 -0
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import { Readable } from 'node:stream';
|
|
2
|
+
import { MediaKind } from '@hoardodile/sdk-types/media-exts';
|
|
3
|
+
import { R as ResourceContainer } from '../container-fKxefO7p.js';
|
|
4
|
+
import { P as PluginProbeCache } from '../probe-cache-BkRI7HEK.js';
|
|
5
|
+
import { ImageThumbInput } from '../render/index.js';
|
|
6
|
+
import '@hoardodile/sdk-types/image-variant';
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Minimal keyed task queue contract. Structural: the server's
|
|
10
|
+
* `createKeyedQueue` (and the CLI's bench queue) satisfy it.
|
|
11
|
+
*/
|
|
12
|
+
type KeyedTaskQueue<T> = {
|
|
13
|
+
readonly run: (key: string, job: () => Promise<T>) => Promise<T>;
|
|
14
|
+
};
|
|
15
|
+
/**
|
|
16
|
+
* Cache-first render gate: return the cached result when present, else
|
|
17
|
+
* run the render job through the queue — re-checking the cache inside
|
|
18
|
+
* the queued task so concurrent callers coalesce onto one render.
|
|
19
|
+
*/
|
|
20
|
+
declare function withCacheAndQueue<T>(queue: KeyedTaskQueue<T>, checkCache: () => Promise<T | undefined>, queueKey: string, job: () => Promise<T>): Promise<T>;
|
|
21
|
+
|
|
22
|
+
type ThumbInput = {
|
|
23
|
+
readonly kind: "path";
|
|
24
|
+
readonly path: string;
|
|
25
|
+
} | {
|
|
26
|
+
readonly kind: "buffer";
|
|
27
|
+
readonly buffer: Buffer;
|
|
28
|
+
} | {
|
|
29
|
+
readonly kind: "stream";
|
|
30
|
+
readonly openStream: () => Promise<Readable>;
|
|
31
|
+
readonly size: number;
|
|
32
|
+
/**
|
|
33
|
+
* Byte-range read into the entry (zip slice). Lets the render
|
|
34
|
+
* pipeline's metadata step read a small header window instead
|
|
35
|
+
* of streaming the whole entry — libvips full-reads
|
|
36
|
+
* non-seekable input for metadata.
|
|
37
|
+
*/
|
|
38
|
+
readonly readRange?: (start: number, end: number) => Promise<Buffer>;
|
|
39
|
+
};
|
|
40
|
+
declare function imageThumbSource(input: ThumbInput): ImageThumbInput;
|
|
41
|
+
/**
|
|
42
|
+
* Resolve an ffmpeg-bound entry (video, or audio whose embedded artwork
|
|
43
|
+
* ffmpeg demuxes as a still video stream) into a path or stream. ffmpeg
|
|
44
|
+
* reads both from `pipe:0`, so the two share one shape.
|
|
45
|
+
*/
|
|
46
|
+
declare function ffmpegThumbSource(input: ThumbInput): Promise<string | Readable>;
|
|
47
|
+
type WithThumbInputOptions = {
|
|
48
|
+
/**
|
|
49
|
+
* Identifier used in the missing-entry error message (e.g. the
|
|
50
|
+
* resource id).
|
|
51
|
+
*/
|
|
52
|
+
readonly label?: string;
|
|
53
|
+
/**
|
|
54
|
+
* Shared probe cache, keyed by `cacheScope` — pass the same instance
|
|
55
|
+
* and scope the plugin API uses so sniff results are reused across
|
|
56
|
+
* hook runs and thumb jobs for one resource version.
|
|
57
|
+
*/
|
|
58
|
+
readonly probeCache?: PluginProbeCache;
|
|
59
|
+
readonly cacheScope?: string;
|
|
60
|
+
};
|
|
61
|
+
/**
|
|
62
|
+
* Resolve a container entry into the cheapest readable form for thumb
|
|
63
|
+
* synthesis: small images are read into memory; larger images, videos
|
|
64
|
+
* and audio stream directly from the source file without extracted-cache
|
|
65
|
+
* writes.
|
|
66
|
+
*
|
|
67
|
+
* `expected` guards the caller's assumption — `"any"` accepts whatever
|
|
68
|
+
* the entry turns out to be. The callback receives the **sniffed**
|
|
69
|
+
* extension, so sharp and ffmpeg get the container hint the content
|
|
70
|
+
* actually warrants.
|
|
71
|
+
*/
|
|
72
|
+
declare function withThumbInput<T>(container: ResourceContainer, relPath: string, expected: MediaKind | "any", fn: (input: ThumbInput, ext: string, kind: MediaKind) => Promise<T>, opts?: WithThumbInputOptions): Promise<T>;
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Run a media pipeline over an entry, materializing it to a seekable
|
|
76
|
+
* path first when the container format needs seeking — ISO-BMFF
|
|
77
|
+
* (.mp4/.mov/.m4v/.m4a) keeps its moov index at the end of the file,
|
|
78
|
+
* unreachable from a forward-only pipe — or when the stream attempt
|
|
79
|
+
* fails for another reason.
|
|
80
|
+
*
|
|
81
|
+
* One implementation shared by the thumbnail pipeline and the cover-meta
|
|
82
|
+
* probes, so the "streamable vs materialize" split can never diverge
|
|
83
|
+
* between the two paths again.
|
|
84
|
+
*/
|
|
85
|
+
declare function withMediaSource<T>(opts: {
|
|
86
|
+
readonly relPath: string;
|
|
87
|
+
readonly ext: string;
|
|
88
|
+
readonly input: ThumbInput;
|
|
89
|
+
/** Extensions whose container is seekable from a forward-only pipe. */
|
|
90
|
+
readonly streamableExts: ReadonlySet<string>;
|
|
91
|
+
/** Materialize `relPath` to a seekable on-disk path. */
|
|
92
|
+
readonly materialize: (relPath: string) => Promise<string>;
|
|
93
|
+
readonly run: (source: string | Readable) => Promise<T>;
|
|
94
|
+
}): Promise<T>;
|
|
95
|
+
|
|
96
|
+
export { type KeyedTaskQueue, type ThumbInput, type WithThumbInputOptions, ffmpegThumbSource, imageThumbSource, withCacheAndQueue, withMediaSource, withThumbInput };
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
import { extname } from 'path';
|
|
2
|
+
import { mimeToKind } from '@hoardodile/sdk-types/media-exts';
|
|
3
|
+
import { fileTypeFromName } from '@hoardodile/sdk-types';
|
|
4
|
+
|
|
5
|
+
// src/media/render-cache.ts
|
|
6
|
+
async function withCacheAndQueue(queue, checkCache, queueKey, job) {
|
|
7
|
+
const cached = await checkCache();
|
|
8
|
+
if (cached !== void 0) return cached;
|
|
9
|
+
return queue.run(queueKey, async () => {
|
|
10
|
+
const raced = await checkCache();
|
|
11
|
+
if (raced !== void 0) return raced;
|
|
12
|
+
return job();
|
|
13
|
+
});
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
// src/errors.ts
|
|
17
|
+
var DomainError = class extends Error {
|
|
18
|
+
code;
|
|
19
|
+
kind;
|
|
20
|
+
details;
|
|
21
|
+
constructor(code, kind, message, details) {
|
|
22
|
+
super(message);
|
|
23
|
+
this.name = "DomainError";
|
|
24
|
+
this.code = code;
|
|
25
|
+
this.kind = kind;
|
|
26
|
+
if (details !== void 0) this.details = details;
|
|
27
|
+
}
|
|
28
|
+
};
|
|
29
|
+
function notFound(kind, message, details) {
|
|
30
|
+
return new DomainError("NOT_FOUND", kind, message, details);
|
|
31
|
+
}
|
|
32
|
+
var THUMB_BUFFER_MAX_BYTES = 32 * 1024 * 1024;
|
|
33
|
+
var SNIFF_HEADER_BYTES = 4100;
|
|
34
|
+
var fileTypePromise;
|
|
35
|
+
function loadFileType() {
|
|
36
|
+
fileTypePromise ??= import('file-type');
|
|
37
|
+
return fileTypePromise;
|
|
38
|
+
}
|
|
39
|
+
async function sniffBytes(head, path) {
|
|
40
|
+
if (head.byteLength > 0) {
|
|
41
|
+
try {
|
|
42
|
+
const { fileTypeFromBuffer } = await loadFileType();
|
|
43
|
+
const detected = await fileTypeFromBuffer(head);
|
|
44
|
+
if (detected !== void 0) {
|
|
45
|
+
return {
|
|
46
|
+
mime: detected.mime,
|
|
47
|
+
ext: `.${detected.ext}`,
|
|
48
|
+
kind: mimeToKind(detected.mime),
|
|
49
|
+
source: "magic"
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
} catch {
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
return fileTypeFromName(path);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// src/media/thumb-input.ts
|
|
59
|
+
function imageThumbSource(input) {
|
|
60
|
+
if (input.kind === "path") return input.path;
|
|
61
|
+
if (input.kind === "buffer") return input.buffer;
|
|
62
|
+
if (input.kind === "stream") {
|
|
63
|
+
return input.readRange === void 0 ? { openStream: input.openStream } : { openStream: input.openStream, readRange: input.readRange };
|
|
64
|
+
}
|
|
65
|
+
throw new Error("unsupported image thumb input");
|
|
66
|
+
}
|
|
67
|
+
async function ffmpegThumbSource(input) {
|
|
68
|
+
if (input.kind === "path") return input.path;
|
|
69
|
+
if (input.kind === "stream") return input.openStream();
|
|
70
|
+
throw new Error("unsupported ffmpeg thumb input");
|
|
71
|
+
}
|
|
72
|
+
function streamThumbInput(container, relPath, size) {
|
|
73
|
+
return {
|
|
74
|
+
kind: "stream",
|
|
75
|
+
openStream: () => container.openEntryStream(relPath).then((entry) => entry.stream),
|
|
76
|
+
readRange: (start, end) => container.readEntrySlice(relPath, start, end),
|
|
77
|
+
size
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
async function pathOrStreamThumbInput(container, relPath, size) {
|
|
81
|
+
const path = await container.resolveSeekablePath?.(relPath);
|
|
82
|
+
if (path !== void 0) return { kind: "path", path };
|
|
83
|
+
return streamThumbInput(container, relPath, size);
|
|
84
|
+
}
|
|
85
|
+
async function sniffEntry(container, relPath, opts) {
|
|
86
|
+
const readHead = async () => container.readEntrySlice(relPath, 0, SNIFF_HEADER_BYTES).catch(() => void 0);
|
|
87
|
+
const head = opts?.probeCache !== void 0 && opts.cacheScope !== void 0 ? await opts.probeCache.getOrCompute(
|
|
88
|
+
`${opts.cacheScope}:sniff:${relPath}`,
|
|
89
|
+
readHead
|
|
90
|
+
) : await readHead();
|
|
91
|
+
if (head === void 0) return void 0;
|
|
92
|
+
return sniffBytes(head, relPath);
|
|
93
|
+
}
|
|
94
|
+
async function withThumbInput(container, relPath, expected, fn, opts) {
|
|
95
|
+
const type = await sniffEntry(container, relPath, opts);
|
|
96
|
+
const kind = type?.kind ?? "other";
|
|
97
|
+
const ext = type?.ext ?? extname(relPath).toLowerCase();
|
|
98
|
+
const effective = type === void 0 && expected !== "any" ? expected : kind;
|
|
99
|
+
if (expected !== "any" && effective !== expected) {
|
|
100
|
+
throw new Error(`unsupported thumb media kind ${effective} for ${relPath}`);
|
|
101
|
+
}
|
|
102
|
+
if (effective === "image") {
|
|
103
|
+
const range = await requireEntryRange(container, relPath, opts?.label);
|
|
104
|
+
if (range.size <= THUMB_BUFFER_MAX_BYTES) {
|
|
105
|
+
const buffer = await container.readEntry(relPath);
|
|
106
|
+
return fn({ kind: "buffer", buffer }, ext, effective);
|
|
107
|
+
}
|
|
108
|
+
return fn(
|
|
109
|
+
await pathOrStreamThumbInput(container, relPath, range.size),
|
|
110
|
+
ext,
|
|
111
|
+
effective
|
|
112
|
+
);
|
|
113
|
+
}
|
|
114
|
+
if (effective === "video" || effective === "audio") {
|
|
115
|
+
const range = await requireEntryRange(container, relPath, opts?.label);
|
|
116
|
+
return fn(
|
|
117
|
+
await pathOrStreamThumbInput(container, relPath, range.size),
|
|
118
|
+
ext,
|
|
119
|
+
effective
|
|
120
|
+
);
|
|
121
|
+
}
|
|
122
|
+
throw new Error(`unsupported thumb media kind ${effective} for ${relPath}`);
|
|
123
|
+
}
|
|
124
|
+
async function requireEntryRange(container, relPath, label) {
|
|
125
|
+
const range = await container.resolveByteRange(relPath);
|
|
126
|
+
if (range === void 0) {
|
|
127
|
+
throw notFound(
|
|
128
|
+
"resource.file_not_found",
|
|
129
|
+
`resource ${label ?? "?"} has no entry ${relPath}`,
|
|
130
|
+
{ resId: label, relPath }
|
|
131
|
+
);
|
|
132
|
+
}
|
|
133
|
+
return range;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// src/media/seekable.ts
|
|
137
|
+
async function withMediaSource(opts) {
|
|
138
|
+
const { ext, input, streamableExts, materialize, relPath, run } = opts;
|
|
139
|
+
if (input.kind === "stream" && !streamableExts.has(ext)) {
|
|
140
|
+
return run(await materialize(relPath));
|
|
141
|
+
}
|
|
142
|
+
try {
|
|
143
|
+
return await run(await ffmpegThumbSource(input));
|
|
144
|
+
} catch (streamErr) {
|
|
145
|
+
if (input.kind !== "stream") throw streamErr;
|
|
146
|
+
return run(await materialize(relPath));
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
export { ffmpegThumbSource, imageThumbSource, withCacheAndQueue, withMediaSource, withThumbInput };
|
|
151
|
+
//# sourceMappingURL=index.js.map
|
|
152
|
+
//# sourceMappingURL=index.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../../src/media/render-cache.ts","../../src/errors.ts","../../src/probe/image.ts","../../src/probe/sniff.ts","../../src/media/thumb-input.ts","../../src/media/seekable.ts"],"names":["extname"],"mappings":"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","file":"index.js","sourcesContent":["/**\n * Minimal keyed task queue contract. Structural: the server's\n * `createKeyedQueue` (and the CLI's bench queue) satisfy it.\n */\nexport type KeyedTaskQueue<T> = {\n\treadonly run: (key: string, job: () => Promise<T>) => Promise<T>\n}\n\n/**\n * Cache-first render gate: return the cached result when present, else\n * run the render job through the queue — re-checking the cache inside\n * the queued task so concurrent callers coalesce onto one render.\n */\nexport async function withCacheAndQueue<T>(\n\tqueue: KeyedTaskQueue<T>,\n\tcheckCache: () => Promise<T | undefined>,\n\tqueueKey: string,\n\tjob: () => Promise<T>,\n): Promise<T> {\n\tconst cached = await checkCache()\n\tif (cached !== undefined) return cached\n\treturn queue.run(queueKey, async () => {\n\t\tconst raced = await checkCache()\n\t\tif (raced !== undefined) return raced\n\t\treturn job()\n\t})\n}\n","/**\n * Host-local domain error. Mirrors the wire-format taxonomy of the app's\n * domain errors (`@hoardodile/shared`) so errors raised here keep their\n * code/kind/details across the package boundary — the server's error\n * translation recognizes this shape structurally via `isDomainError`.\n *\n * The host deliberately keeps its dependency graph minimal (consts +\n * sdk-types only) and does not depend on `@hoardodile/shared`; the\n * mirror is kept in sync by the bridge test in\n * `apps/server/src/infra/host-errors.test.ts`.\n */\nexport const domainErrorCodes = [\n\t\"NOT_FOUND\",\n\t\"CONFLICT\",\n\t\"VALIDATION\",\n\t\"UNAUTHORIZED\",\n\t\"FORBIDDEN\",\n\t\"RATE_LIMITED\",\n\t\"UNSUPPORTED\",\n\t\"INTERNAL\",\n] as const\n\nexport type DomainErrorCode = (typeof domainErrorCodes)[number]\n\n/**\n * Host-local domain error carrying a wire-compatible code/kind/details\n * triple. Thrown by host services (containers, loaders, hooks) so the\n * server can translate them into its own domain errors verbatim.\n */\nexport class DomainError extends Error {\n\treadonly code: DomainErrorCode\n\treadonly kind: string\n\treadonly details?: Readonly<Record<string, unknown>>\n\n\tconstructor(\n\t\tcode: DomainErrorCode,\n\t\tkind: string,\n\t\tmessage: string,\n\t\tdetails?: Readonly<Record<string, unknown>>,\n\t) {\n\t\tsuper(message)\n\t\tthis.name = \"DomainError\"\n\t\tthis.code = code\n\t\tthis.kind = kind\n\t\tif (details !== undefined) this.details = details\n\t}\n}\n\n/** Convenience constructor for `VALIDATION` errors. */\nexport function invalid(\n\tkind: string,\n\tmessage: string,\n\tdetails?: Readonly<Record<string, unknown>>,\n): DomainError {\n\treturn new DomainError(\"VALIDATION\", kind, message, details)\n}\n\n/** Convenience constructor for `CONFLICT` errors. */\nexport function conflict(\n\tkind: string,\n\tmessage: string,\n\tdetails?: Readonly<Record<string, unknown>>,\n): DomainError {\n\treturn new DomainError(\"CONFLICT\", kind, message, details)\n}\n\n/** Convenience constructor for `NOT_FOUND` errors. */\nexport function notFound(\n\tkind: string,\n\tmessage: string,\n\tdetails?: Readonly<Record<string, unknown>>,\n): DomainError {\n\treturn new DomainError(\"NOT_FOUND\", kind, message, details)\n}\n","import { extname } from \"node:path\"\nimport type { Readable } from \"node:stream\"\nimport { IMAGE_EXTS } from \"@hoardodile/sdk-types/media-exts\"\nimport { PROBE_HEADER_BYTES } from \"../archive/index.ts\"\n\nimport type { ImageInfo } from \"../types.ts\"\n\n/**\n * Max zip entry size read into memory for thumb synthesis. Larger\n * entries are materialized to disk before sharp/ffmpeg runs.\n */\nexport const THUMB_BUFFER_MAX_BYTES = 32 * 1024 * 1024\n\n/**\n * Image probing via sharp. sharp is loaded lazily and declared as an\n * optional peer dependency: the host package stays free of native\n * binaries, and consumers that never probe images (the browser-side mock,\n * dependency-light CI installs) never pull it in. When sharp is missing,\n * probe calls fail loudly instead of degrading silently.\n */\ntype SharpModule = typeof import(\"sharp\")\n\nlet sharpPromise: Promise<SharpModule> | undefined\n\nexport function loadSharp(): Promise<SharpModule> {\n\tsharpPromise ??= import(\"sharp\").catch((err: unknown) => {\n\t\tsharpPromise = undefined\n\t\tthrow new Error(\n\t\t\t`sharp is not installed — add \"sharp\" to your dependencies to enable image probing: ${err instanceof Error ? err.message : String(err)}`,\n\t\t)\n\t})\n\treturn sharpPromise\n}\n\nexport type ImageSourceProbe = {\n\treadonly width: number\n\treadonly height: number\n\treadonly animated: boolean\n}\n\nconst ANIMATED_EXT_HINTS = new Set([\".gif\", \".webp\", \".avif\"])\n\nexport { PROBE_HEADER_BYTES } from \"../archive/index.ts\"\n\n/** True when `ext` names a format that can carry animation. */\nexport function isAnimatedCandidateExt(ext: string): boolean {\n\treturn ANIMATED_EXT_HINTS.has(ext)\n}\n\nexport type ImageMetadataInput =\n\t| string\n\t| Buffer\n\t| Readable\n\t| {\n\t\t\treadonly openStream: () => Promise<Readable>\n\t\t\t/**\n\t\t\t * Optional byte-range read into the underlying source (e.g. a\n\t\t\t * zip entry slice). When present, metadata probes read\n\t\t\t * {@link PROBE_HEADER_BYTES} from the start instead of\n\t\t\t * streaming the whole entry — libvips reads the ENTIRE file\n\t\t\t * for metadata from a non-seekable stream, but only the header\n\t\t\t * from a buffer.\n\t\t\t */\n\t\t\treadonly readRange?: (start: number, end: number) => Promise<Buffer>\n\t }\n\n/**\n * Narrow a Node stream into sharp's readable input without assertion.\n * Async because the sharp module itself is loaded lazily.\n *\n * sharp's constructor rejects `sharp(stream, opts)` — for stream input\n * the input options must be passed AS the first argument (a plain object\n * of input parameters like `pages`/`animated`) and the readable piped\n * into the resulting Duplex.\n */\nexport async function sharpFromReadable(\n\tstream: Readable,\n\toptions?: import(\"sharp\").SharpOptions,\n): Promise<import(\"sharp\").Sharp> {\n\tif (typeof stream.read !== \"function\") {\n\t\tthrow new Error(\"expected a readable stream\")\n\t}\n\tconst { default: sharp } = await loadSharp()\n\tconst descriptor =\n\t\toptions === undefined || Object.keys(options).length === 0\n\t\t\t? undefined\n\t\t\t: ({ ...options } as unknown as import(\"sharp\").SharpInput)\n\tconst instance = descriptor === undefined ? sharp() : sharp(descriptor)\n\tstream.pipe(instance)\n\treturn instance\n}\n\nfunction isReadable(input: unknown): input is Readable {\n\treturn (\n\t\ttypeof input === \"object\" &&\n\t\tinput !== null &&\n\t\ttypeof (input as Readable).pipe === \"function\" &&\n\t\t!(\"openStream\" in input)\n\t)\n}\n\nfunction isReopenableImageStream(\n\tinput: ImageMetadataInput,\n): input is { readonly openStream: () => Promise<Readable> } {\n\treturn (\n\t\ttypeof input === \"object\" &&\n\t\tinput !== null &&\n\t\t!Buffer.isBuffer(input) &&\n\t\t\"openStream\" in input &&\n\t\ttypeof (input as { openStream: unknown }).openStream === \"function\"\n\t)\n}\n\n/**\n * Sharp options for image thumb/probe reads. Enables sequential read for\n * large JPEGs on disk so libvips can shrink-on-load during resize.\n */\nexport function sharpImageInputOpts(\n\tinput: string | Buffer,\n\text: string,\n\tpages: number,\n\tanimated?: boolean,\n): import(\"sharp\").SharpOptions {\n\tconst opts: import(\"sharp\").SharpOptions = { pages }\n\tif (animated === true) opts.animated = true\n\tif (\n\t\ttypeof input === \"string\" &&\n\t\t(ext === \".jpg\" || ext === \".jpeg\" || ext === \".jfif\")\n\t) {\n\t\topts.sequentialRead = true\n\t}\n\treturn opts\n}\n\n/**\n * True when a shallow `{ pages: 1 }` metadata read is not enough to\n * decide animation and the full multi-page scan is required.\n */\nexport function needsFullAnimationScan(\n\tmeta: Pick<\n\t\timport(\"sharp\").Metadata,\n\t\t\"width\" | \"height\" | \"pages\" | \"pageHeight\"\n\t>,\n\text: string,\n): boolean {\n\tif (ext === \".gif\") return true\n\tif (ANIMATED_EXT_HINTS.has(ext) && (meta.pages ?? 1) > 1) return true\n\tif (\n\t\tmeta.pageHeight !== undefined &&\n\t\tmeta.height !== undefined &&\n\t\tmeta.pageHeight !== meta.height\n\t) {\n\t\treturn true\n\t}\n\treturn false\n}\n\n/**\n * Buffer a probe stream with a hard byte cap. Only used for formats whose\n * animation scan needs the whole stream (GIF): probing only needs\n * metadata, so an entry bigger than the cap is treated as unprobed rather\n * than buffered whole into host memory.\n */\nasync function bufferCapped(\n\tinput: Readable,\n\tmaxBytes: number,\n): Promise<Buffer> {\n\tconst chunks: Uint8Array[] = []\n\tlet total = 0\n\tfor await (const chunk of input) {\n\t\tconst bytes: Uint8Array =\n\t\t\ttypeof chunk === \"string\" ? Buffer.from(chunk) : chunk\n\t\ttotal += bytes.byteLength\n\t\tif (total > maxBytes) {\n\t\t\tinput.destroy()\n\t\t\tthrow new Error(`probe input exceeds the ${maxBytes}-byte cap`)\n\t\t}\n\t\tchunks.push(bytes)\n\t}\n\treturn Buffer.concat(chunks)\n}\n\n/**\n * Read image metadata with layered animation detection: static sources\n * stop after a single-page read; animated containers escalate to\n * `{ pages: -1 }` only when the shallow probe signals multi-frame input.\n */\nexport async function readImageMetadata(\n\tinput: ImageMetadataInput,\n\text: string,\n): Promise<{\n\treadonly meta: import(\"sharp\").Metadata\n\treadonly animated: boolean\n}> {\n\tif (isReopenableImageStream(input)) {\n\t\t// Header-slice fast path: probe a small leading window as a buffer\n\t\t// (header-only libvips read) instead of streaming the whole entry\n\t\t// (full-file read). Only definitive static results are accepted —\n\t\t// animation candidates escalate inside the slice read, and a\n\t\t// truncated buffer cannot complete a full scan, so those fall\n\t\t// through to the stream path where frame counts stay correct. GIF\n\t\t// is skipped outright (its frame count lives at the end).\n\t\tif (input.readRange !== undefined && ext !== \".gif\") {\n\t\t\tconst head = await input.readRange(0, PROBE_HEADER_BYTES)\n\t\t\tif (head.length > 0) {\n\t\t\t\ttry {\n\t\t\t\t\tconst { default: sharp } = await loadSharp()\n\t\t\t\t\tconst shallowMeta = await sharp(\n\t\t\t\t\t\thead,\n\t\t\t\t\t\tsharpImageInputOpts(head, ext, 1),\n\t\t\t\t\t).metadata()\n\t\t\t\t\tif (!needsFullAnimationScan(shallowMeta, ext)) {\n\t\t\t\t\t\treturn { meta: shallowMeta, animated: false }\n\t\t\t\t\t}\n\t\t\t\t} catch {\n\t\t\t\t\t// Truncated header unreadable — fall through to the\n\t\t\t\t\t// stream path.\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t\tconst stream = await input.openStream()\n\t\tconst shallow = await sharpFromReadable(stream, { pages: 1 })\n\t\tconst shallowMeta = await shallow.metadata()\n\t\tif (!needsFullAnimationScan(shallowMeta, ext)) {\n\t\t\treturn { meta: shallowMeta, animated: false }\n\t\t}\n\t\tconst fullStream = await input.openStream()\n\t\tconst fullMeta = await sharpFromReadable(fullStream, {\n\t\t\tpages: -1,\n\t\t\tanimated: true,\n\t\t}).then((s) => s.metadata())\n\t\treturn { meta: fullMeta, animated: (fullMeta.pages ?? 1) > 1 }\n\t}\n\tif (isReadable(input)) {\n\t\t// GIF frame counts are not stored in the container header, so the\n\t\t// full animation scan needs the whole stream — buffer cap-guarded\n\t\t// and reuse the buffer path.\n\t\tif (ext === \".gif\") {\n\t\t\tconst data = await bufferCapped(input, THUMB_BUFFER_MAX_BYTES)\n\t\t\treturn readImageMetadata(data, ext)\n\t\t}\n\t\t// Static formats: hand the stream straight to sharp so the probe\n\t\t// reads only the header bytes. (Buffering the whole entry first —\n\t\t// the previous behavior, capped at THUMB_BUFFER_MAX_BYTES — turned\n\t\t// every stream probe into a full read and made entries beyond the\n\t\t// cap unprobeable.)\n\t\ttry {\n\t\t\tconst shallow = await sharpFromReadable(input, { pages: 1 })\n\t\t\tconst shallowMeta = await shallow.metadata()\n\t\t\tif (!needsFullAnimationScan(shallowMeta, ext)) {\n\t\t\t\treturn { meta: shallowMeta, animated: false }\n\t\t\t}\n\t\t\t// Animated candidates (webp/avif/tiff) report their total page\n\t\t\t// count from the container header, so the shallow read is\n\t\t\t// conclusive — and the stream cannot be rewound for a full\n\t\t\t// scan anyway.\n\t\t\treturn {\n\t\t\t\tmeta: shallowMeta,\n\t\t\t\tanimated: (shallowMeta.pages ?? 1) > 1,\n\t\t\t}\n\t\t} finally {\n\t\t\tinput.destroy()\n\t\t}\n\t}\n\tconst { default: sharp } = await loadSharp()\n\tconst shallow = sharp(input, sharpImageInputOpts(input, ext, 1))\n\tconst shallowMeta = await shallow.metadata()\n\tif (!needsFullAnimationScan(shallowMeta, ext)) {\n\t\treturn { meta: shallowMeta, animated: false }\n\t}\n\tconst fullMeta = await sharp(\n\t\tinput,\n\t\tsharpImageInputOpts(input, ext, -1, true),\n\t).metadata()\n\treturn { meta: fullMeta, animated: (fullMeta.pages ?? 1) > 1 }\n}\n\n/**\n * Probe an image path or buffer and return pixel dimensions plus whether\n * the source is animated. Probe failures return `undefined`.\n */\nexport async function probeImageSource(\n\tinput: ImageMetadataInput,\n\textHint?: string,\n): Promise<ImageSourceProbe | undefined> {\n\tconst ext =\n\t\textHint ?? (typeof input === \"string\" ? extname(input).toLowerCase() : \"\")\n\tif (ext.length > 0 && !IMAGE_EXTS.has(ext)) return undefined\n\ttry {\n\t\tconst { meta, animated } = await readImageMetadata(input, ext)\n\t\tconst h = meta.pageHeight ?? meta.height\n\t\tif (meta.width === undefined || h === undefined) return undefined\n\t\treturn { width: meta.width, height: h, animated }\n\t} catch {\n\t\treturn undefined\n\t}\n}\n\n/**\n * Probe an image source and return its pixel dimensions. Probe failures\n * return `undefined` so callers can treat \"not yet probed\" and \"probe\n * failed\" the same way.\n */\nexport async function probeImage(\n\tsource: ImageMetadataInput,\n): Promise<ImageInfo | undefined> {\n\tconst probe = await probeImageSource(source)\n\tif (probe === undefined) return undefined\n\treturn { width: probe.width, height: probe.height }\n}\n\n/**\n * True when the image has more than one frame (animated GIF / WebP / APNG /\n * AVIF). Uses layered detection so static JPEG/PNG avoid a full-frame scan.\n * Errors are coerced to `false`.\n */\nexport async function probeAnimatedImage(\n\tsource: ImageMetadataInput,\n): Promise<boolean> {\n\tif (\n\t\ttypeof source === \"string\" &&\n\t\t!IMAGE_EXTS.has(extname(source).toLowerCase())\n\t) {\n\t\treturn false\n\t}\n\ttry {\n\t\tconst probe = await probeImageSource(source)\n\t\treturn probe?.animated ?? false\n\t} catch (err) {\n\t\tconst label = typeof source === \"string\" ? source : \"stream\"\n\t\tconsole.warn(\n\t\t\t`[probeAnimatedImage] sharp failed on ${label}: ${err instanceof Error ? err.message : String(err)}`,\n\t\t)\n\t\treturn false\n\t}\n}\n","import { fileTypeFromName } from \"@hoardodile/sdk-types\"\nimport { mimeToKind } from \"@hoardodile/sdk-types/media-exts\"\n\nimport type { FileType } from \"../types.ts\"\n\n/**\n * Content sniffing: what a file's bytes say it is, with the filename as\n * the fallback rather than the verdict.\n *\n * The layering mirrors what the ecosystem settled on (freedesktop's\n * shared-mime-info, Apache Tika, WHATWG mimesniff): magic-byte matching\n * decides for binary formats, and the extension only answers for\n * formats that carry no signature at all — text, subtitles, CSV. That\n * split is why a `.jpg` holding WebP bytes probes as WebP, while a\n * `.txt` still resolves to `text/plain`.\n */\n\n/**\n * Header window handed to the magic matcher. `file-type` documents 4100\n * bytes as the sample size that makes its detection deterministic; the\n * host reads exactly that much and no more, so sniffing an entry costs\n * one small ranged read.\n */\nexport const SNIFF_HEADER_BYTES = 4100\n\ntype FileTypeModule = typeof import(\"file-type\")\n\nlet fileTypePromise: Promise<FileTypeModule> | undefined\n\n/**\n * Load the magic-byte matcher lazily. Sniffing is opt-in per host (the\n * directory backend never sniffs), so an unused host pays nothing for\n * the module graph.\n */\nfunction loadFileType(): Promise<FileTypeModule> {\n\tfileTypePromise ??= import(\"file-type\")\n\treturn fileTypePromise\n}\n\n/**\n * Identify a file from a leading byte window plus its name. `head`\n * should hold the first {@link SNIFF_HEADER_BYTES} bytes; a shorter\n * slice still works for formats whose signature fits inside it.\n */\nexport async function sniffBytes(\n\thead: Uint8Array,\n\tpath: string,\n): Promise<FileType | undefined> {\n\tif (head.byteLength > 0) {\n\t\ttry {\n\t\t\tconst { fileTypeFromBuffer } = await loadFileType()\n\t\t\tconst detected = await fileTypeFromBuffer(head)\n\t\t\tif (detected !== undefined) {\n\t\t\t\treturn {\n\t\t\t\t\tmime: detected.mime,\n\t\t\t\t\text: `.${detected.ext}`,\n\t\t\t\t\tkind: mimeToKind(detected.mime),\n\t\t\t\t\tsource: \"magic\",\n\t\t\t\t}\n\t\t\t}\n\t\t} catch {\n\t\t\t// A malformed or truncated header is not an error condition —\n\t\t\t// fall through to the filename, same as a signature-less file.\n\t\t}\n\t}\n\treturn fileTypeFromName(path)\n}\n","import { extname } from \"node:path\"\nimport type { Readable } from \"node:stream\"\nimport type { FileType } from \"@hoardodile/sdk-types\"\nimport type { MediaKind } from \"@hoardodile/sdk-types/media-exts\"\nimport type { ResourceContainer } from \"../container.ts\"\nimport { notFound } from \"../errors.ts\"\nimport {\n\tSNIFF_HEADER_BYTES,\n\tsniffBytes,\n\tTHUMB_BUFFER_MAX_BYTES,\n} from \"../probe/index.ts\"\nimport type { PluginProbeCache } from \"../probe-cache.ts\"\nimport type { ImageThumbInput } from \"../render/index.ts\"\n\nexport type ThumbInput =\n\t| { readonly kind: \"path\"; readonly path: string }\n\t| { readonly kind: \"buffer\"; readonly buffer: Buffer }\n\t| {\n\t\t\treadonly kind: \"stream\"\n\t\t\treadonly openStream: () => Promise<Readable>\n\t\t\treadonly size: number\n\t\t\t/**\n\t\t\t * Byte-range read into the entry (zip slice). Lets the render\n\t\t\t * pipeline's metadata step read a small header window instead\n\t\t\t * of streaming the whole entry — libvips full-reads\n\t\t\t * non-seekable input for metadata.\n\t\t\t */\n\t\t\treadonly readRange?: (start: number, end: number) => Promise<Buffer>\n\t }\n\nexport function imageThumbSource(input: ThumbInput): ImageThumbInput {\n\tif (input.kind === \"path\") return input.path\n\tif (input.kind === \"buffer\") return input.buffer\n\tif (input.kind === \"stream\") {\n\t\treturn input.readRange === undefined\n\t\t\t? { openStream: input.openStream }\n\t\t\t: { openStream: input.openStream, readRange: input.readRange }\n\t}\n\tthrow new Error(\"unsupported image thumb input\")\n}\n\n/**\n * Resolve an ffmpeg-bound entry (video, or audio whose embedded artwork\n * ffmpeg demuxes as a still video stream) into a path or stream. ffmpeg\n * reads both from `pipe:0`, so the two share one shape.\n */\nexport async function ffmpegThumbSource(\n\tinput: ThumbInput,\n): Promise<string | Readable> {\n\tif (input.kind === \"path\") return input.path\n\tif (input.kind === \"stream\") return input.openStream()\n\tthrow new Error(\"unsupported ffmpeg thumb input\")\n}\n\nfunction streamThumbInput(\n\tcontainer: ResourceContainer,\n\trelPath: string,\n\tsize: number,\n): ThumbInput {\n\treturn {\n\t\tkind: \"stream\",\n\t\topenStream: () =>\n\t\t\tcontainer.openEntryStream(relPath).then((entry) => entry.stream),\n\t\treadRange: (start, end) => container.readEntrySlice(relPath, start, end),\n\t\tsize,\n\t}\n}\n\n/**\n * Prefer a seekable on-disk path over a stream when the container can\n * provide one (bare-file backends): libvips/ffmpeg open the file\n * directly — mmap + shrink-on-load for images, no pipe for video —\n * with none of the pipe-copy overhead. Containers without the\n * capability (in-memory fixtures, virtual `outer!inner` entries) fall\n * back to streaming.\n */\nasync function pathOrStreamThumbInput(\n\tcontainer: ResourceContainer,\n\trelPath: string,\n\tsize: number,\n): Promise<ThumbInput> {\n\tconst path = await container.resolveSeekablePath?.(relPath)\n\tif (path !== undefined) return { kind: \"path\", path }\n\treturn streamThumbInput(container, relPath, size)\n}\n\n/**\n * Identify a container entry from its bytes, falling back to its name.\n * The thumbnail pipeline and the plugins that pick cover files must\n * agree on what a file is, so both go through the same sniffer — an\n * entry the plugin selected as an image never lands in the ffmpeg branch\n * because of a misleading extension.\n */\nasync function sniffEntry(\n\tcontainer: ResourceContainer,\n\trelPath: string,\n\topts: WithThumbInputOptions | undefined,\n): Promise<FileType | undefined> {\n\tconst readHead = async (): Promise<Buffer | undefined> =>\n\t\tcontainer\n\t\t\t.readEntrySlice(relPath, 0, SNIFF_HEADER_BYTES)\n\t\t\t.catch(() => undefined)\n\tconst head =\n\t\topts?.probeCache !== undefined && opts.cacheScope !== undefined\n\t\t\t? await opts.probeCache.getOrCompute(\n\t\t\t\t\t`${opts.cacheScope}:sniff:${relPath}`,\n\t\t\t\t\treadHead,\n\t\t\t\t)\n\t\t\t: await readHead()\n\tif (head === undefined) return undefined\n\treturn sniffBytes(head, relPath)\n}\n\nexport type WithThumbInputOptions = {\n\t/**\n\t * Identifier used in the missing-entry error message (e.g. the\n\t * resource id).\n\t */\n\treadonly label?: string\n\t/**\n\t * Shared probe cache, keyed by `cacheScope` — pass the same instance\n\t * and scope the plugin API uses so sniff results are reused across\n\t * hook runs and thumb jobs for one resource version.\n\t */\n\treadonly probeCache?: PluginProbeCache\n\treadonly cacheScope?: string\n}\n\n/**\n * Resolve a container entry into the cheapest readable form for thumb\n * synthesis: small images are read into memory; larger images, videos\n * and audio stream directly from the source file without extracted-cache\n * writes.\n *\n * `expected` guards the caller's assumption — `\"any\"` accepts whatever\n * the entry turns out to be. The callback receives the **sniffed**\n * extension, so sharp and ffmpeg get the container hint the content\n * actually warrants.\n */\nexport async function withThumbInput<T>(\n\tcontainer: ResourceContainer,\n\trelPath: string,\n\texpected: MediaKind | \"any\",\n\tfn: (input: ThumbInput, ext: string, kind: MediaKind) => Promise<T>,\n\topts?: WithThumbInputOptions,\n): Promise<T> {\n\tconst type = await sniffEntry(container, relPath, opts)\n\tconst kind = type?.kind ?? \"other\"\n\t// An unidentified entry keeps the caller's expectation so the ffmpeg\n\t// path still gets its chance, exactly as the extension fallback did.\n\tconst ext = type?.ext ?? extname(relPath).toLowerCase()\n\tconst effective: MediaKind =\n\t\ttype === undefined && expected !== \"any\" ? expected : kind\n\tif (expected !== \"any\" && effective !== expected) {\n\t\tthrow new Error(`unsupported thumb media kind ${effective} for ${relPath}`)\n\t}\n\tif (effective === \"image\") {\n\t\tconst range = await requireEntryRange(container, relPath, opts?.label)\n\t\tif (range.size <= THUMB_BUFFER_MAX_BYTES) {\n\t\t\tconst buffer = await container.readEntry(relPath)\n\t\t\treturn fn({ kind: \"buffer\", buffer }, ext, effective)\n\t\t}\n\t\treturn fn(\n\t\t\tawait pathOrStreamThumbInput(container, relPath, range.size),\n\t\t\text,\n\t\t\teffective,\n\t\t)\n\t}\n\tif (effective === \"video\" || effective === \"audio\") {\n\t\tconst range = await requireEntryRange(container, relPath, opts?.label)\n\t\treturn fn(\n\t\t\tawait pathOrStreamThumbInput(container, relPath, range.size),\n\t\t\text,\n\t\t\teffective,\n\t\t)\n\t}\n\tthrow new Error(`unsupported thumb media kind ${effective} for ${relPath}`)\n}\n\nasync function requireEntryRange(\n\tcontainer: ResourceContainer,\n\trelPath: string,\n\tlabel: string | undefined,\n): Promise<{ readonly size: number }> {\n\tconst range = await container.resolveByteRange(relPath)\n\tif (range === undefined) {\n\t\tthrow notFound(\n\t\t\t\"resource.file_not_found\",\n\t\t\t`resource ${label ?? \"?\"} has no entry ${relPath}`,\n\t\t\t{ resId: label, relPath },\n\t\t)\n\t}\n\treturn range\n}\n","import type { Readable } from \"node:stream\"\nimport { ffmpegThumbSource, type ThumbInput } from \"./thumb-input.ts\"\n\n/**\n * Run a media pipeline over an entry, materializing it to a seekable\n * path first when the container format needs seeking — ISO-BMFF\n * (.mp4/.mov/.m4v/.m4a) keeps its moov index at the end of the file,\n * unreachable from a forward-only pipe — or when the stream attempt\n * fails for another reason.\n *\n * One implementation shared by the thumbnail pipeline and the cover-meta\n * probes, so the \"streamable vs materialize\" split can never diverge\n * between the two paths again.\n */\nexport async function withMediaSource<T>(opts: {\n\treadonly relPath: string\n\treadonly ext: string\n\treadonly input: ThumbInput\n\t/** Extensions whose container is seekable from a forward-only pipe. */\n\treadonly streamableExts: ReadonlySet<string>\n\t/** Materialize `relPath` to a seekable on-disk path. */\n\treadonly materialize: (relPath: string) => Promise<string>\n\treadonly run: (source: string | Readable) => Promise<T>\n}): Promise<T> {\n\tconst { ext, input, streamableExts, materialize, relPath, run } = opts\n\tif (input.kind === \"stream\" && !streamableExts.has(ext)) {\n\t\treturn run(await materialize(relPath))\n\t}\n\ttry {\n\t\treturn await run(await ffmpegThumbSource(input))\n\t} catch (streamErr) {\n\t\tif (input.kind !== \"stream\") throw streamErr\n\t\treturn run(await materialize(relPath))\n\t}\n}\n"]}
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
import { F as FfprobePayload, p as probeImageSource, a as probeAvMedia } from '../image-Dx2Be3Bj.js';
|
|
2
|
+
export { A as AvProbeOptions, I as ImageMetadataInput, b as ImageSourceProbe, P as PROBE_HEADER_BYTES, T as THUMB_BUFFER_MAX_BYTES, c as avResultFromPayload, i as isAnimatedCandidateExt, l as loadSharp, n as needsFullAnimationScan, d as probeAnimatedImage, e as probeImage, r as readImageMetadata, f as resolveFfprobePath, s as sharpFromReadable, g as sharpImageInputOpts } from '../image-Dx2Be3Bj.js';
|
|
3
|
+
import { Readable } from 'node:stream';
|
|
4
|
+
import { AudioInfo, FileType, VideoInfo } from '@hoardodile/sdk-types';
|
|
5
|
+
import 'sharp';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Audio probing via ffprobe (see `ffprobe.ts` for path resolution and the
|
|
9
|
+
* process plumbing). One probe answers everything the host needs about an
|
|
10
|
+
* audio file: playable duration, stream parameters, whether the container
|
|
11
|
+
* carries embedded artwork, and the human-facing tags.
|
|
12
|
+
*/
|
|
13
|
+
/** Resolve an extension to its ffmpeg `-f` container name for piped input. */
|
|
14
|
+
declare function extToAudioInputFormat(ext: string): string | undefined;
|
|
15
|
+
/**
|
|
16
|
+
* Pure parser; broken out so callers and tests can hit it without
|
|
17
|
+
* spawning ffprobe. Returns `undefined` when the payload carries no
|
|
18
|
+
* audio stream at all — a video file probed as audio must not produce a
|
|
19
|
+
* half-filled result.
|
|
20
|
+
*/
|
|
21
|
+
declare function parseFfprobeAudioJson(json: string): AudioInfo | undefined;
|
|
22
|
+
declare function audioInfoFromPayload(payload: FfprobePayload): AudioInfo | undefined;
|
|
23
|
+
/**
|
|
24
|
+
* Probe an audio stream or path with ffprobe. Probe failures — a missing
|
|
25
|
+
* binary, an unreadable container, or a source with no audio stream —
|
|
26
|
+
* return `undefined` so callers can treat "not yet probed" and "probe
|
|
27
|
+
* failed" the same way.
|
|
28
|
+
*/
|
|
29
|
+
declare function probeAudio(source: string | Readable, extHint?: string): Promise<AudioInfo | undefined>;
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Content sniffing: what a file's bytes say it is, with the filename as
|
|
33
|
+
* the fallback rather than the verdict.
|
|
34
|
+
*
|
|
35
|
+
* The layering mirrors what the ecosystem settled on (freedesktop's
|
|
36
|
+
* shared-mime-info, Apache Tika, WHATWG mimesniff): magic-byte matching
|
|
37
|
+
* decides for binary formats, and the extension only answers for
|
|
38
|
+
* formats that carry no signature at all — text, subtitles, CSV. That
|
|
39
|
+
* split is why a `.jpg` holding WebP bytes probes as WebP, while a
|
|
40
|
+
* `.txt` still resolves to `text/plain`.
|
|
41
|
+
*/
|
|
42
|
+
/**
|
|
43
|
+
* Header window handed to the magic matcher. `file-type` documents 4100
|
|
44
|
+
* bytes as the sample size that makes its detection deterministic; the
|
|
45
|
+
* host reads exactly that much and no more, so sniffing an entry costs
|
|
46
|
+
* one small ranged read.
|
|
47
|
+
*/
|
|
48
|
+
declare const SNIFF_HEADER_BYTES = 4100;
|
|
49
|
+
/**
|
|
50
|
+
* Identify a file from a leading byte window plus its name. `head`
|
|
51
|
+
* should hold the first {@link SNIFF_HEADER_BYTES} bytes; a shorter
|
|
52
|
+
* slice still works for formats whose signature fits inside it.
|
|
53
|
+
*/
|
|
54
|
+
declare function sniffBytes(head: Uint8Array, path: string): Promise<FileType | undefined>;
|
|
55
|
+
|
|
56
|
+
/** Resolve an extension to its ffmpeg `-f` container name, or `undefined` for unknown extensions. */
|
|
57
|
+
declare function extToFfmpegInputFormat(ext: string): string | undefined;
|
|
58
|
+
/**
|
|
59
|
+
* Video probing via ffprobe (see `ffprobe.ts` for path resolution and the
|
|
60
|
+
* process plumbing).
|
|
61
|
+
*/
|
|
62
|
+
/**
|
|
63
|
+
* Source-media metadata derived by ffprobe - pixel dimensions of the first
|
|
64
|
+
* video stream and the playable duration in milliseconds. Any field can be
|
|
65
|
+
* absent when ffprobe declines to report it (corrupt header, missing
|
|
66
|
+
* container metadata). Callers persist whichever fields are populated.
|
|
67
|
+
*/
|
|
68
|
+
type ProbedVideoMeta = {
|
|
69
|
+
readonly width?: number;
|
|
70
|
+
readonly height?: number;
|
|
71
|
+
readonly durationMs?: number;
|
|
72
|
+
};
|
|
73
|
+
/**
|
|
74
|
+
* Run `ffprobe` against `source` and extract width, height and duration.
|
|
75
|
+
*
|
|
76
|
+
* @throws `Error` with stderr when ffprobe exits non-zero or emits no JSON.
|
|
77
|
+
*/
|
|
78
|
+
declare function probeVideoMeta(source: string | Readable, ffprobePath: string, inputFormat?: string): Promise<ProbedVideoMeta>;
|
|
79
|
+
/**
|
|
80
|
+
* Pure parser; broken out so callers and tests can hit it without spawning
|
|
81
|
+
* ffprobe. Returns only the fields that survive type narrowing - partial
|
|
82
|
+
* payloads are normal for malformed media and we prefer surfacing what we
|
|
83
|
+
* have over rejecting the whole probe.
|
|
84
|
+
*/
|
|
85
|
+
declare function parseFfprobeJson(json: string): ProbedVideoMeta;
|
|
86
|
+
/**
|
|
87
|
+
* Probe a video stream or path with ffprobe and return its pixel
|
|
88
|
+
* dimensions and duration. Probe failures return `undefined` so callers
|
|
89
|
+
* can treat "not yet probed" and "probe failed" the same way.
|
|
90
|
+
*/
|
|
91
|
+
declare function probeVideo(source: string | Readable, extHint?: string): Promise<VideoInfo | undefined>;
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Probe implementations for the {@link ResourceAPI} builder. Consumed by
|
|
95
|
+
* the server's plugin wiring and the CLI's bench — the same probing code
|
|
96
|
+
* on both sides keeps "test path = production path". sharp and ffprobe
|
|
97
|
+
* are optional peers loaded lazily; see `image.ts` / `video.ts` /
|
|
98
|
+
* `audio.ts`.
|
|
99
|
+
*/
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* The host's real probe implementations, shaped for
|
|
103
|
+
* `createPluginResourceAPI({ view, ...mediaProbes })`. Every consumer
|
|
104
|
+
* (server import path, trash fallback, CLI) spreads this same object,
|
|
105
|
+
* so "what you test is what runs in production" holds by construction
|
|
106
|
+
* instead of by three copies of the same wiring.
|
|
107
|
+
*/
|
|
108
|
+
declare const mediaProbes: {
|
|
109
|
+
readonly probeImage: typeof probeImageSource;
|
|
110
|
+
readonly probeAv: typeof probeAvMedia;
|
|
111
|
+
};
|
|
112
|
+
|
|
113
|
+
export { type ProbedVideoMeta, SNIFF_HEADER_BYTES, audioInfoFromPayload, extToAudioInputFormat, extToFfmpegInputFormat, mediaProbes, parseFfprobeAudioJson, parseFfprobeJson, probeAudio, probeAvMedia, probeImageSource, probeVideo, probeVideoMeta, sniffBytes };
|