@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,336 @@
|
|
|
1
|
+
import { extname } from "node:path"
|
|
2
|
+
import type { Readable } from "node:stream"
|
|
3
|
+
import { IMAGE_EXTS } from "@hoardodile/sdk-types/media-exts"
|
|
4
|
+
import { PROBE_HEADER_BYTES } from "../archive/index.ts"
|
|
5
|
+
|
|
6
|
+
import type { ImageInfo } from "../types.ts"
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Max zip entry size read into memory for thumb synthesis. Larger
|
|
10
|
+
* entries are materialized to disk before sharp/ffmpeg runs.
|
|
11
|
+
*/
|
|
12
|
+
export const THUMB_BUFFER_MAX_BYTES = 32 * 1024 * 1024
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Image probing via sharp. sharp is loaded lazily and declared as an
|
|
16
|
+
* optional peer dependency: the host package stays free of native
|
|
17
|
+
* binaries, and consumers that never probe images (the browser-side mock,
|
|
18
|
+
* dependency-light CI installs) never pull it in. When sharp is missing,
|
|
19
|
+
* probe calls fail loudly instead of degrading silently.
|
|
20
|
+
*/
|
|
21
|
+
type SharpModule = typeof import("sharp")
|
|
22
|
+
|
|
23
|
+
let sharpPromise: Promise<SharpModule> | undefined
|
|
24
|
+
|
|
25
|
+
export function loadSharp(): Promise<SharpModule> {
|
|
26
|
+
sharpPromise ??= import("sharp").catch((err: unknown) => {
|
|
27
|
+
sharpPromise = undefined
|
|
28
|
+
throw new Error(
|
|
29
|
+
`sharp is not installed — add "sharp" to your dependencies to enable image probing: ${err instanceof Error ? err.message : String(err)}`,
|
|
30
|
+
)
|
|
31
|
+
})
|
|
32
|
+
return sharpPromise
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export type ImageSourceProbe = {
|
|
36
|
+
readonly width: number
|
|
37
|
+
readonly height: number
|
|
38
|
+
readonly animated: boolean
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const ANIMATED_EXT_HINTS = new Set([".gif", ".webp", ".avif"])
|
|
42
|
+
|
|
43
|
+
export { PROBE_HEADER_BYTES } from "../archive/index.ts"
|
|
44
|
+
|
|
45
|
+
/** True when `ext` names a format that can carry animation. */
|
|
46
|
+
export function isAnimatedCandidateExt(ext: string): boolean {
|
|
47
|
+
return ANIMATED_EXT_HINTS.has(ext)
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export type ImageMetadataInput =
|
|
51
|
+
| string
|
|
52
|
+
| Buffer
|
|
53
|
+
| Readable
|
|
54
|
+
| {
|
|
55
|
+
readonly openStream: () => Promise<Readable>
|
|
56
|
+
/**
|
|
57
|
+
* Optional byte-range read into the underlying source (e.g. a
|
|
58
|
+
* zip entry slice). When present, metadata probes read
|
|
59
|
+
* {@link PROBE_HEADER_BYTES} from the start instead of
|
|
60
|
+
* streaming the whole entry — libvips reads the ENTIRE file
|
|
61
|
+
* for metadata from a non-seekable stream, but only the header
|
|
62
|
+
* from a buffer.
|
|
63
|
+
*/
|
|
64
|
+
readonly readRange?: (start: number, end: number) => Promise<Buffer>
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Narrow a Node stream into sharp's readable input without assertion.
|
|
69
|
+
* Async because the sharp module itself is loaded lazily.
|
|
70
|
+
*
|
|
71
|
+
* sharp's constructor rejects `sharp(stream, opts)` — for stream input
|
|
72
|
+
* the input options must be passed AS the first argument (a plain object
|
|
73
|
+
* of input parameters like `pages`/`animated`) and the readable piped
|
|
74
|
+
* into the resulting Duplex.
|
|
75
|
+
*/
|
|
76
|
+
export async function sharpFromReadable(
|
|
77
|
+
stream: Readable,
|
|
78
|
+
options?: import("sharp").SharpOptions,
|
|
79
|
+
): Promise<import("sharp").Sharp> {
|
|
80
|
+
if (typeof stream.read !== "function") {
|
|
81
|
+
throw new Error("expected a readable stream")
|
|
82
|
+
}
|
|
83
|
+
const { default: sharp } = await loadSharp()
|
|
84
|
+
const descriptor =
|
|
85
|
+
options === undefined || Object.keys(options).length === 0
|
|
86
|
+
? undefined
|
|
87
|
+
: ({ ...options } as unknown as import("sharp").SharpInput)
|
|
88
|
+
const instance = descriptor === undefined ? sharp() : sharp(descriptor)
|
|
89
|
+
stream.pipe(instance)
|
|
90
|
+
return instance
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function isReadable(input: unknown): input is Readable {
|
|
94
|
+
return (
|
|
95
|
+
typeof input === "object" &&
|
|
96
|
+
input !== null &&
|
|
97
|
+
typeof (input as Readable).pipe === "function" &&
|
|
98
|
+
!("openStream" in input)
|
|
99
|
+
)
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function isReopenableImageStream(
|
|
103
|
+
input: ImageMetadataInput,
|
|
104
|
+
): input is { readonly openStream: () => Promise<Readable> } {
|
|
105
|
+
return (
|
|
106
|
+
typeof input === "object" &&
|
|
107
|
+
input !== null &&
|
|
108
|
+
!Buffer.isBuffer(input) &&
|
|
109
|
+
"openStream" in input &&
|
|
110
|
+
typeof (input as { openStream: unknown }).openStream === "function"
|
|
111
|
+
)
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Sharp options for image thumb/probe reads. Enables sequential read for
|
|
116
|
+
* large JPEGs on disk so libvips can shrink-on-load during resize.
|
|
117
|
+
*/
|
|
118
|
+
export function sharpImageInputOpts(
|
|
119
|
+
input: string | Buffer,
|
|
120
|
+
ext: string,
|
|
121
|
+
pages: number,
|
|
122
|
+
animated?: boolean,
|
|
123
|
+
): import("sharp").SharpOptions {
|
|
124
|
+
const opts: import("sharp").SharpOptions = { pages }
|
|
125
|
+
if (animated === true) opts.animated = true
|
|
126
|
+
if (
|
|
127
|
+
typeof input === "string" &&
|
|
128
|
+
(ext === ".jpg" || ext === ".jpeg" || ext === ".jfif")
|
|
129
|
+
) {
|
|
130
|
+
opts.sequentialRead = true
|
|
131
|
+
}
|
|
132
|
+
return opts
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* True when a shallow `{ pages: 1 }` metadata read is not enough to
|
|
137
|
+
* decide animation and the full multi-page scan is required.
|
|
138
|
+
*/
|
|
139
|
+
export function needsFullAnimationScan(
|
|
140
|
+
meta: Pick<
|
|
141
|
+
import("sharp").Metadata,
|
|
142
|
+
"width" | "height" | "pages" | "pageHeight"
|
|
143
|
+
>,
|
|
144
|
+
ext: string,
|
|
145
|
+
): boolean {
|
|
146
|
+
if (ext === ".gif") return true
|
|
147
|
+
if (ANIMATED_EXT_HINTS.has(ext) && (meta.pages ?? 1) > 1) return true
|
|
148
|
+
if (
|
|
149
|
+
meta.pageHeight !== undefined &&
|
|
150
|
+
meta.height !== undefined &&
|
|
151
|
+
meta.pageHeight !== meta.height
|
|
152
|
+
) {
|
|
153
|
+
return true
|
|
154
|
+
}
|
|
155
|
+
return false
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* Buffer a probe stream with a hard byte cap. Only used for formats whose
|
|
160
|
+
* animation scan needs the whole stream (GIF): probing only needs
|
|
161
|
+
* metadata, so an entry bigger than the cap is treated as unprobed rather
|
|
162
|
+
* than buffered whole into host memory.
|
|
163
|
+
*/
|
|
164
|
+
async function bufferCapped(
|
|
165
|
+
input: Readable,
|
|
166
|
+
maxBytes: number,
|
|
167
|
+
): Promise<Buffer> {
|
|
168
|
+
const chunks: Uint8Array[] = []
|
|
169
|
+
let total = 0
|
|
170
|
+
for await (const chunk of input) {
|
|
171
|
+
const bytes: Uint8Array =
|
|
172
|
+
typeof chunk === "string" ? Buffer.from(chunk) : chunk
|
|
173
|
+
total += bytes.byteLength
|
|
174
|
+
if (total > maxBytes) {
|
|
175
|
+
input.destroy()
|
|
176
|
+
throw new Error(`probe input exceeds the ${maxBytes}-byte cap`)
|
|
177
|
+
}
|
|
178
|
+
chunks.push(bytes)
|
|
179
|
+
}
|
|
180
|
+
return Buffer.concat(chunks)
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* Read image metadata with layered animation detection: static sources
|
|
185
|
+
* stop after a single-page read; animated containers escalate to
|
|
186
|
+
* `{ pages: -1 }` only when the shallow probe signals multi-frame input.
|
|
187
|
+
*/
|
|
188
|
+
export async function readImageMetadata(
|
|
189
|
+
input: ImageMetadataInput,
|
|
190
|
+
ext: string,
|
|
191
|
+
): Promise<{
|
|
192
|
+
readonly meta: import("sharp").Metadata
|
|
193
|
+
readonly animated: boolean
|
|
194
|
+
}> {
|
|
195
|
+
if (isReopenableImageStream(input)) {
|
|
196
|
+
// Header-slice fast path: probe a small leading window as a buffer
|
|
197
|
+
// (header-only libvips read) instead of streaming the whole entry
|
|
198
|
+
// (full-file read). Only definitive static results are accepted —
|
|
199
|
+
// animation candidates escalate inside the slice read, and a
|
|
200
|
+
// truncated buffer cannot complete a full scan, so those fall
|
|
201
|
+
// through to the stream path where frame counts stay correct. GIF
|
|
202
|
+
// is skipped outright (its frame count lives at the end).
|
|
203
|
+
if (input.readRange !== undefined && ext !== ".gif") {
|
|
204
|
+
const head = await input.readRange(0, PROBE_HEADER_BYTES)
|
|
205
|
+
if (head.length > 0) {
|
|
206
|
+
try {
|
|
207
|
+
const { default: sharp } = await loadSharp()
|
|
208
|
+
const shallowMeta = await sharp(
|
|
209
|
+
head,
|
|
210
|
+
sharpImageInputOpts(head, ext, 1),
|
|
211
|
+
).metadata()
|
|
212
|
+
if (!needsFullAnimationScan(shallowMeta, ext)) {
|
|
213
|
+
return { meta: shallowMeta, animated: false }
|
|
214
|
+
}
|
|
215
|
+
} catch {
|
|
216
|
+
// Truncated header unreadable — fall through to the
|
|
217
|
+
// stream path.
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
const stream = await input.openStream()
|
|
222
|
+
const shallow = await sharpFromReadable(stream, { pages: 1 })
|
|
223
|
+
const shallowMeta = await shallow.metadata()
|
|
224
|
+
if (!needsFullAnimationScan(shallowMeta, ext)) {
|
|
225
|
+
return { meta: shallowMeta, animated: false }
|
|
226
|
+
}
|
|
227
|
+
const fullStream = await input.openStream()
|
|
228
|
+
const fullMeta = await sharpFromReadable(fullStream, {
|
|
229
|
+
pages: -1,
|
|
230
|
+
animated: true,
|
|
231
|
+
}).then((s) => s.metadata())
|
|
232
|
+
return { meta: fullMeta, animated: (fullMeta.pages ?? 1) > 1 }
|
|
233
|
+
}
|
|
234
|
+
if (isReadable(input)) {
|
|
235
|
+
// GIF frame counts are not stored in the container header, so the
|
|
236
|
+
// full animation scan needs the whole stream — buffer cap-guarded
|
|
237
|
+
// and reuse the buffer path.
|
|
238
|
+
if (ext === ".gif") {
|
|
239
|
+
const data = await bufferCapped(input, THUMB_BUFFER_MAX_BYTES)
|
|
240
|
+
return readImageMetadata(data, ext)
|
|
241
|
+
}
|
|
242
|
+
// Static formats: hand the stream straight to sharp so the probe
|
|
243
|
+
// reads only the header bytes. (Buffering the whole entry first —
|
|
244
|
+
// the previous behavior, capped at THUMB_BUFFER_MAX_BYTES — turned
|
|
245
|
+
// every stream probe into a full read and made entries beyond the
|
|
246
|
+
// cap unprobeable.)
|
|
247
|
+
try {
|
|
248
|
+
const shallow = await sharpFromReadable(input, { pages: 1 })
|
|
249
|
+
const shallowMeta = await shallow.metadata()
|
|
250
|
+
if (!needsFullAnimationScan(shallowMeta, ext)) {
|
|
251
|
+
return { meta: shallowMeta, animated: false }
|
|
252
|
+
}
|
|
253
|
+
// Animated candidates (webp/avif/tiff) report their total page
|
|
254
|
+
// count from the container header, so the shallow read is
|
|
255
|
+
// conclusive — and the stream cannot be rewound for a full
|
|
256
|
+
// scan anyway.
|
|
257
|
+
return {
|
|
258
|
+
meta: shallowMeta,
|
|
259
|
+
animated: (shallowMeta.pages ?? 1) > 1,
|
|
260
|
+
}
|
|
261
|
+
} finally {
|
|
262
|
+
input.destroy()
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
const { default: sharp } = await loadSharp()
|
|
266
|
+
const shallow = sharp(input, sharpImageInputOpts(input, ext, 1))
|
|
267
|
+
const shallowMeta = await shallow.metadata()
|
|
268
|
+
if (!needsFullAnimationScan(shallowMeta, ext)) {
|
|
269
|
+
return { meta: shallowMeta, animated: false }
|
|
270
|
+
}
|
|
271
|
+
const fullMeta = await sharp(
|
|
272
|
+
input,
|
|
273
|
+
sharpImageInputOpts(input, ext, -1, true),
|
|
274
|
+
).metadata()
|
|
275
|
+
return { meta: fullMeta, animated: (fullMeta.pages ?? 1) > 1 }
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
/**
|
|
279
|
+
* Probe an image path or buffer and return pixel dimensions plus whether
|
|
280
|
+
* the source is animated. Probe failures return `undefined`.
|
|
281
|
+
*/
|
|
282
|
+
export async function probeImageSource(
|
|
283
|
+
input: ImageMetadataInput,
|
|
284
|
+
extHint?: string,
|
|
285
|
+
): Promise<ImageSourceProbe | undefined> {
|
|
286
|
+
const ext =
|
|
287
|
+
extHint ?? (typeof input === "string" ? extname(input).toLowerCase() : "")
|
|
288
|
+
if (ext.length > 0 && !IMAGE_EXTS.has(ext)) return undefined
|
|
289
|
+
try {
|
|
290
|
+
const { meta, animated } = await readImageMetadata(input, ext)
|
|
291
|
+
const h = meta.pageHeight ?? meta.height
|
|
292
|
+
if (meta.width === undefined || h === undefined) return undefined
|
|
293
|
+
return { width: meta.width, height: h, animated }
|
|
294
|
+
} catch {
|
|
295
|
+
return undefined
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
/**
|
|
300
|
+
* Probe an image source and return its pixel dimensions. Probe failures
|
|
301
|
+
* return `undefined` so callers can treat "not yet probed" and "probe
|
|
302
|
+
* failed" the same way.
|
|
303
|
+
*/
|
|
304
|
+
export async function probeImage(
|
|
305
|
+
source: ImageMetadataInput,
|
|
306
|
+
): Promise<ImageInfo | undefined> {
|
|
307
|
+
const probe = await probeImageSource(source)
|
|
308
|
+
if (probe === undefined) return undefined
|
|
309
|
+
return { width: probe.width, height: probe.height }
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
/**
|
|
313
|
+
* True when the image has more than one frame (animated GIF / WebP / APNG /
|
|
314
|
+
* AVIF). Uses layered detection so static JPEG/PNG avoid a full-frame scan.
|
|
315
|
+
* Errors are coerced to `false`.
|
|
316
|
+
*/
|
|
317
|
+
export async function probeAnimatedImage(
|
|
318
|
+
source: ImageMetadataInput,
|
|
319
|
+
): Promise<boolean> {
|
|
320
|
+
if (
|
|
321
|
+
typeof source === "string" &&
|
|
322
|
+
!IMAGE_EXTS.has(extname(source).toLowerCase())
|
|
323
|
+
) {
|
|
324
|
+
return false
|
|
325
|
+
}
|
|
326
|
+
try {
|
|
327
|
+
const probe = await probeImageSource(source)
|
|
328
|
+
return probe?.animated ?? false
|
|
329
|
+
} catch (err) {
|
|
330
|
+
const label = typeof source === "string" ? source : "stream"
|
|
331
|
+
console.warn(
|
|
332
|
+
`[probeAnimatedImage] sharp failed on ${label}: ${err instanceof Error ? err.message : String(err)}`,
|
|
333
|
+
)
|
|
334
|
+
return false
|
|
335
|
+
}
|
|
336
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Probe implementations for the {@link ResourceAPI} builder. Consumed by
|
|
3
|
+
* the server's plugin wiring and the CLI's bench — the same probing code
|
|
4
|
+
* on both sides keeps "test path = production path". sharp and ffprobe
|
|
5
|
+
* are optional peers loaded lazily; see `image.ts` / `video.ts` /
|
|
6
|
+
* `audio.ts`.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { probeAvMedia } from "./av.ts"
|
|
10
|
+
import { probeImageSource } from "./image.ts"
|
|
11
|
+
|
|
12
|
+
export {
|
|
13
|
+
audioInfoFromPayload,
|
|
14
|
+
extToAudioInputFormat,
|
|
15
|
+
parseFfprobeAudioJson,
|
|
16
|
+
probeAudio,
|
|
17
|
+
} from "./audio.ts"
|
|
18
|
+
export type { AvProbeOptions } from "./av.ts"
|
|
19
|
+
export { avResultFromPayload, probeAvMedia } from "./av.ts"
|
|
20
|
+
export { resolveFfprobePath } from "./ffprobe.ts"
|
|
21
|
+
export type { ImageMetadataInput, ImageSourceProbe } from "./image.ts"
|
|
22
|
+
export {
|
|
23
|
+
isAnimatedCandidateExt,
|
|
24
|
+
loadSharp,
|
|
25
|
+
needsFullAnimationScan,
|
|
26
|
+
PROBE_HEADER_BYTES,
|
|
27
|
+
probeAnimatedImage,
|
|
28
|
+
probeImage,
|
|
29
|
+
probeImageSource,
|
|
30
|
+
readImageMetadata,
|
|
31
|
+
sharpFromReadable,
|
|
32
|
+
sharpImageInputOpts,
|
|
33
|
+
THUMB_BUFFER_MAX_BYTES,
|
|
34
|
+
} from "./image.ts"
|
|
35
|
+
export { SNIFF_HEADER_BYTES, sniffBytes } from "./sniff.ts"
|
|
36
|
+
export type { ProbedVideoMeta } from "./video.ts"
|
|
37
|
+
export {
|
|
38
|
+
extToFfmpegInputFormat,
|
|
39
|
+
parseFfprobeJson,
|
|
40
|
+
probeVideo,
|
|
41
|
+
probeVideoMeta,
|
|
42
|
+
} from "./video.ts"
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* The host's real probe implementations, shaped for
|
|
46
|
+
* `createPluginResourceAPI({ view, ...mediaProbes })`. Every consumer
|
|
47
|
+
* (server import path, trash fallback, CLI) spreads this same object,
|
|
48
|
+
* so "what you test is what runs in production" holds by construction
|
|
49
|
+
* instead of by three copies of the same wiring.
|
|
50
|
+
*/
|
|
51
|
+
export const mediaProbes = {
|
|
52
|
+
probeImage: probeImageSource,
|
|
53
|
+
probeAv: probeAvMedia,
|
|
54
|
+
} as const
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import sharp from "sharp"
|
|
2
|
+
import { describe, expect, test } from "vitest"
|
|
3
|
+
import { sniffBytes } from "./sniff.ts"
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Sniffing is the layer that decides which probe backend a file reaches,
|
|
7
|
+
* so these cases pin the two branches that matter: a real signature
|
|
8
|
+
* wins over a lying name, and a signature-less format still gets named.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
async function pngBytes(): Promise<Buffer> {
|
|
12
|
+
return sharp({
|
|
13
|
+
create: {
|
|
14
|
+
width: 4,
|
|
15
|
+
height: 4,
|
|
16
|
+
channels: 3,
|
|
17
|
+
background: { r: 0, g: 0, b: 0 },
|
|
18
|
+
},
|
|
19
|
+
})
|
|
20
|
+
.png()
|
|
21
|
+
.toBuffer()
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
describe("sniffBytes", () => {
|
|
25
|
+
test("magic bytes win over the extension", async () => {
|
|
26
|
+
await expect(sniffBytes(await pngBytes(), "photo.jpg")).resolves.toEqual({
|
|
27
|
+
mime: "image/png",
|
|
28
|
+
ext: ".png",
|
|
29
|
+
kind: "image",
|
|
30
|
+
source: "magic",
|
|
31
|
+
})
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
test("identifies a file with no extension at all", async () => {
|
|
35
|
+
await expect(sniffBytes(await pngBytes(), "001")).resolves.toEqual({
|
|
36
|
+
mime: "image/png",
|
|
37
|
+
ext: ".png",
|
|
38
|
+
kind: "image",
|
|
39
|
+
source: "magic",
|
|
40
|
+
})
|
|
41
|
+
})
|
|
42
|
+
|
|
43
|
+
test("falls back to the extension for signature-less formats", async () => {
|
|
44
|
+
const text = new TextEncoder().encode("just words, no signature")
|
|
45
|
+
await expect(sniffBytes(text, "notes.txt")).resolves.toEqual({
|
|
46
|
+
mime: "text/plain",
|
|
47
|
+
ext: ".txt",
|
|
48
|
+
kind: "other",
|
|
49
|
+
source: "extension",
|
|
50
|
+
})
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
test("resolves to undefined when neither bytes nor name say anything", async () => {
|
|
54
|
+
const noise = Uint8Array.from([0x11, 0x22, 0x33, 0x44])
|
|
55
|
+
await expect(sniffBytes(noise, "blob.bin")).resolves.toBeUndefined()
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
test("an empty head still answers from the name", async () => {
|
|
59
|
+
await expect(sniffBytes(new Uint8Array(), "clip.mp4")).resolves.toEqual({
|
|
60
|
+
mime: "video/mp4",
|
|
61
|
+
ext: ".mp4",
|
|
62
|
+
kind: "video",
|
|
63
|
+
source: "extension",
|
|
64
|
+
})
|
|
65
|
+
})
|
|
66
|
+
|
|
67
|
+
test("container MIME types map to the family they usually carry", async () => {
|
|
68
|
+
const ogg = new TextEncoder().encode("OggS\0\0\0\0\0\0\0\0\0\0\0\0\0\0")
|
|
69
|
+
const sniffed = await sniffBytes(ogg, "track.ogg")
|
|
70
|
+
expect(sniffed?.kind).toBe("audio")
|
|
71
|
+
})
|
|
72
|
+
})
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import { fileTypeFromName } from "@hoardodile/sdk-types"
|
|
2
|
+
import { mimeToKind } from "@hoardodile/sdk-types/media-exts"
|
|
3
|
+
|
|
4
|
+
import type { FileType } from "../types.ts"
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Content sniffing: what a file's bytes say it is, with the filename as
|
|
8
|
+
* the fallback rather than the verdict.
|
|
9
|
+
*
|
|
10
|
+
* The layering mirrors what the ecosystem settled on (freedesktop's
|
|
11
|
+
* shared-mime-info, Apache Tika, WHATWG mimesniff): magic-byte matching
|
|
12
|
+
* decides for binary formats, and the extension only answers for
|
|
13
|
+
* formats that carry no signature at all — text, subtitles, CSV. That
|
|
14
|
+
* split is why a `.jpg` holding WebP bytes probes as WebP, while a
|
|
15
|
+
* `.txt` still resolves to `text/plain`.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Header window handed to the magic matcher. `file-type` documents 4100
|
|
20
|
+
* bytes as the sample size that makes its detection deterministic; the
|
|
21
|
+
* host reads exactly that much and no more, so sniffing an entry costs
|
|
22
|
+
* one small ranged read.
|
|
23
|
+
*/
|
|
24
|
+
export const SNIFF_HEADER_BYTES = 4100
|
|
25
|
+
|
|
26
|
+
type FileTypeModule = typeof import("file-type")
|
|
27
|
+
|
|
28
|
+
let fileTypePromise: Promise<FileTypeModule> | undefined
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Load the magic-byte matcher lazily. Sniffing is opt-in per host (the
|
|
32
|
+
* directory backend never sniffs), so an unused host pays nothing for
|
|
33
|
+
* the module graph.
|
|
34
|
+
*/
|
|
35
|
+
function loadFileType(): Promise<FileTypeModule> {
|
|
36
|
+
fileTypePromise ??= import("file-type")
|
|
37
|
+
return fileTypePromise
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Identify a file from a leading byte window plus its name. `head`
|
|
42
|
+
* should hold the first {@link SNIFF_HEADER_BYTES} bytes; a shorter
|
|
43
|
+
* slice still works for formats whose signature fits inside it.
|
|
44
|
+
*/
|
|
45
|
+
export async function sniffBytes(
|
|
46
|
+
head: Uint8Array,
|
|
47
|
+
path: string,
|
|
48
|
+
): Promise<FileType | undefined> {
|
|
49
|
+
if (head.byteLength > 0) {
|
|
50
|
+
try {
|
|
51
|
+
const { fileTypeFromBuffer } = await loadFileType()
|
|
52
|
+
const detected = await fileTypeFromBuffer(head)
|
|
53
|
+
if (detected !== undefined) {
|
|
54
|
+
return {
|
|
55
|
+
mime: detected.mime,
|
|
56
|
+
ext: `.${detected.ext}`,
|
|
57
|
+
kind: mimeToKind(detected.mime),
|
|
58
|
+
source: "magic",
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
} catch {
|
|
62
|
+
// A malformed or truncated header is not an error condition —
|
|
63
|
+
// fall through to the filename, same as a signature-less file.
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
return fileTypeFromName(path)
|
|
67
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { PassThrough, Readable } from "node:stream"
|
|
2
|
+
import { afterEach, describe, expect, test, vi } from "vitest"
|
|
3
|
+
import { probeVideoMeta } from "./video.ts"
|
|
4
|
+
|
|
5
|
+
vi.mock("execa", () => ({
|
|
6
|
+
execa: vi.fn(),
|
|
7
|
+
}))
|
|
8
|
+
|
|
9
|
+
describe("probeVideoMeta", () => {
|
|
10
|
+
afterEach(() => {
|
|
11
|
+
vi.clearAllMocks()
|
|
12
|
+
})
|
|
13
|
+
|
|
14
|
+
test("ffprobe receives pipe:0 for stream sources", async () => {
|
|
15
|
+
const { execa } = await import("execa")
|
|
16
|
+
const payload = JSON.stringify({
|
|
17
|
+
streams: [{ codec_type: "video", width: 640, height: 360 }],
|
|
18
|
+
format: { duration: "1.5" },
|
|
19
|
+
})
|
|
20
|
+
vi.mocked(execa).mockReturnValue(
|
|
21
|
+
Object.assign(
|
|
22
|
+
Promise.resolve({
|
|
23
|
+
stdout: payload,
|
|
24
|
+
stderr: "",
|
|
25
|
+
exitCode: 0,
|
|
26
|
+
}),
|
|
27
|
+
{ stdin: new PassThrough() },
|
|
28
|
+
) as never,
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
const stream = Readable.from(Buffer.from("fake-video"))
|
|
32
|
+
const meta = await probeVideoMeta(stream, "/bin/ffprobe", "mp4")
|
|
33
|
+
const args = vi.mocked(execa).mock.calls[0]?.[1] as string[]
|
|
34
|
+
expect(args).toContain("pipe:0")
|
|
35
|
+
expect(args).toContain("-f")
|
|
36
|
+
expect(args).toContain("mp4")
|
|
37
|
+
expect(meta.width).toBe(640)
|
|
38
|
+
expect(meta.height).toBe(360)
|
|
39
|
+
expect(meta.durationMs).toBe(1500)
|
|
40
|
+
})
|
|
41
|
+
})
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
import type { Readable } from "node:stream"
|
|
2
|
+
import type { VideoInfo } from "../types.ts"
|
|
3
|
+
import {
|
|
4
|
+
type FfprobePayload,
|
|
5
|
+
ffprobeDurationMs,
|
|
6
|
+
getFfprobePath,
|
|
7
|
+
parseFfprobePayload,
|
|
8
|
+
runFfprobeJson,
|
|
9
|
+
} from "./ffprobe.ts"
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* ffmpeg `-f` container name for piped zip entry bytes (no filename hint).
|
|
13
|
+
*/
|
|
14
|
+
const FFMPEG_INPUT_FORMAT: Readonly<Record<string, string>> = {
|
|
15
|
+
".mp4": "mp4",
|
|
16
|
+
".m4v": "mp4",
|
|
17
|
+
".webm": "webm",
|
|
18
|
+
".mov": "mov",
|
|
19
|
+
".mkv": "matroska",
|
|
20
|
+
".avi": "avi",
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** Resolve an extension to its ffmpeg `-f` container name, or `undefined` for unknown extensions. */
|
|
24
|
+
export function extToFfmpegInputFormat(ext: string): string | undefined {
|
|
25
|
+
return FFMPEG_INPUT_FORMAT[ext.toLowerCase()]
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Video probing via ffprobe (see `ffprobe.ts` for path resolution and the
|
|
30
|
+
* process plumbing).
|
|
31
|
+
*/
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Source-media metadata derived by ffprobe - pixel dimensions of the first
|
|
35
|
+
* video stream and the playable duration in milliseconds. Any field can be
|
|
36
|
+
* absent when ffprobe declines to report it (corrupt header, missing
|
|
37
|
+
* container metadata). Callers persist whichever fields are populated.
|
|
38
|
+
*/
|
|
39
|
+
export type ProbedVideoMeta = {
|
|
40
|
+
readonly width?: number
|
|
41
|
+
readonly height?: number
|
|
42
|
+
readonly durationMs?: number
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Run `ffprobe` against `source` and extract width, height and duration.
|
|
47
|
+
*
|
|
48
|
+
* @throws `Error` with stderr when ffprobe exits non-zero or emits no JSON.
|
|
49
|
+
*/
|
|
50
|
+
export async function probeVideoMeta(
|
|
51
|
+
source: string | Readable,
|
|
52
|
+
ffprobePath: string,
|
|
53
|
+
inputFormat?: string,
|
|
54
|
+
): Promise<ProbedVideoMeta> {
|
|
55
|
+
return videoMetaFromPayload(
|
|
56
|
+
await runFfprobeJson(source, ffprobePath, inputFormat),
|
|
57
|
+
)
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Pure parser; broken out so callers and tests can hit it without spawning
|
|
62
|
+
* ffprobe. Returns only the fields that survive type narrowing - partial
|
|
63
|
+
* payloads are normal for malformed media and we prefer surfacing what we
|
|
64
|
+
* have over rejecting the whole probe.
|
|
65
|
+
*/
|
|
66
|
+
export function parseFfprobeJson(json: string): ProbedVideoMeta {
|
|
67
|
+
return videoMetaFromPayload(parseFfprobePayload(json))
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export function videoMetaFromPayload(payload: FfprobePayload): ProbedVideoMeta {
|
|
71
|
+
const result: { width?: number; height?: number; durationMs?: number } = {}
|
|
72
|
+
const videoStream = payload.streams?.find((s) => s.codec_type === "video")
|
|
73
|
+
if (videoStream !== undefined) {
|
|
74
|
+
if (typeof videoStream.width === "number" && videoStream.width > 0) {
|
|
75
|
+
result.width = Math.round(videoStream.width)
|
|
76
|
+
}
|
|
77
|
+
if (typeof videoStream.height === "number" && videoStream.height > 0) {
|
|
78
|
+
result.height = Math.round(videoStream.height)
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
const durationMs = ffprobeDurationMs(payload.format)
|
|
82
|
+
if (durationMs !== undefined) result.durationMs = durationMs
|
|
83
|
+
return result
|
|
84
|
+
}
|
|
85
|
+
|
|
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
|
+
export async function probeVideo(
|
|
92
|
+
source: string | Readable,
|
|
93
|
+
extHint?: string,
|
|
94
|
+
): Promise<VideoInfo | undefined> {
|
|
95
|
+
try {
|
|
96
|
+
const inputFormat =
|
|
97
|
+
typeof source === "string" || extHint === undefined
|
|
98
|
+
? undefined
|
|
99
|
+
: extToFfmpegInputFormat(extHint)
|
|
100
|
+
const probed = await probeVideoMeta(source, getFfprobePath(), inputFormat)
|
|
101
|
+
if (
|
|
102
|
+
probed.width === undefined &&
|
|
103
|
+
probed.height === undefined &&
|
|
104
|
+
probed.durationMs === undefined
|
|
105
|
+
) {
|
|
106
|
+
return undefined
|
|
107
|
+
}
|
|
108
|
+
return {
|
|
109
|
+
width: probed.width,
|
|
110
|
+
height: probed.height,
|
|
111
|
+
durationMs: probed.durationMs,
|
|
112
|
+
}
|
|
113
|
+
} catch {
|
|
114
|
+
return undefined
|
|
115
|
+
}
|
|
116
|
+
}
|