@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.
Files changed (144) hide show
  1. package/LICENSE +18 -0
  2. package/README.md +73 -0
  3. package/dist/chunks/worker-entry.mjs +422 -0
  4. package/dist/container-fKxefO7p.d.ts +58 -0
  5. package/dist/contract/index.d.ts +49 -0
  6. package/dist/contract/index.js +187 -0
  7. package/dist/contract/index.js.map +1 -0
  8. package/dist/hoard/index.d.ts +754 -0
  9. package/dist/hoard/index.js +1939 -0
  10. package/dist/hoard/index.js.map +1 -0
  11. package/dist/image-Dx2Be3Bj.d.ts +168 -0
  12. package/dist/index.d.ts +788 -0
  13. package/dist/index.js +3311 -0
  14. package/dist/index.js.map +1 -0
  15. package/dist/media/index.d.ts +96 -0
  16. package/dist/media/index.js +152 -0
  17. package/dist/media/index.js.map +1 -0
  18. package/dist/probe/index.d.ts +113 -0
  19. package/dist/probe/index.js +454 -0
  20. package/dist/probe/index.js.map +1 -0
  21. package/dist/probe-cache-BkRI7HEK.d.ts +25 -0
  22. package/dist/render/index.d.ts +125 -0
  23. package/dist/render/index.js +430 -0
  24. package/dist/render/index.js.map +1 -0
  25. package/package.json +103 -0
  26. package/src/__testutils__/zip-fixtures.ts +139 -0
  27. package/src/activation.ts +116 -0
  28. package/src/api-types.ts +60 -0
  29. package/src/api.test.ts +564 -0
  30. package/src/api.ts +501 -0
  31. package/src/archive/7z.test.ts +222 -0
  32. package/src/archive/7z.ts +261 -0
  33. package/src/archive/archive-ops.test.ts +654 -0
  34. package/src/archive/extract-archive.test.ts +237 -0
  35. package/src/archive/extract-archive.ts +534 -0
  36. package/src/archive/extract.ts +693 -0
  37. package/src/archive/format.ts +49 -0
  38. package/src/archive/index.ts +116 -0
  39. package/src/archive/listing.ts +121 -0
  40. package/src/archive/materialize.ts +88 -0
  41. package/src/archive/name-decode.test.ts +34 -0
  42. package/src/archive/name-decode.ts +287 -0
  43. package/src/archive/nested-cd-cache.ts +55 -0
  44. package/src/archive/nested-entry.test.ts +194 -0
  45. package/src/archive/nested-entry.ts +254 -0
  46. package/src/archive/pack.ts +40 -0
  47. package/src/archive/zip-entries.test.ts +56 -0
  48. package/src/archive/zip-entries.ts +336 -0
  49. package/src/capability-guard.ts +73 -0
  50. package/src/container.ts +62 -0
  51. package/src/contract/container-fixture.ts +74 -0
  52. package/src/contract/index.ts +10 -0
  53. package/src/contract/suite.test.ts +101 -0
  54. package/src/contract/suite.ts +205 -0
  55. package/src/define-plugin.test.ts +53 -0
  56. package/src/define-plugin.ts +12 -0
  57. package/src/dev-runner.test.ts +98 -0
  58. package/src/dev-runner.ts +73 -0
  59. package/src/directory-api.test.ts +229 -0
  60. package/src/directory-api.ts +42 -0
  61. package/src/directory-container.ts +141 -0
  62. package/src/discovery.ts +295 -0
  63. package/src/errors.ts +74 -0
  64. package/src/fixtures.ts +8 -0
  65. package/src/hash.test.ts +301 -0
  66. package/src/hash.ts +167 -0
  67. package/src/hoard/dir-size.ts +58 -0
  68. package/src/hoard/index.ts +84 -0
  69. package/src/hoard/migrate-script.test.ts +539 -0
  70. package/src/hoard/order-manifest.test.ts +115 -0
  71. package/src/hoard/order-manifest.ts +131 -0
  72. package/src/hoard/paths.test.ts +226 -0
  73. package/src/hoard/paths.ts +569 -0
  74. package/src/hoard/plugin-vault.test.ts +160 -0
  75. package/src/hoard/plugin-vault.ts +233 -0
  76. package/src/hoard/sanitize.test.ts +149 -0
  77. package/src/hoard/sanitize.ts +230 -0
  78. package/src/hoard/staging-dir.ts +133 -0
  79. package/src/hoard/version.test.ts +285 -0
  80. package/src/hoard/version.ts +216 -0
  81. package/src/hoard/versioned-folder-ops.ts +152 -0
  82. package/src/hoard/write-versioned.test.ts +67 -0
  83. package/src/hoard/write-versioned.ts +39 -0
  84. package/src/hooks.test.ts +528 -0
  85. package/src/hooks.ts +428 -0
  86. package/src/index.ts +135 -0
  87. package/src/loader.test.ts +442 -0
  88. package/src/loader.ts +213 -0
  89. package/src/media/index.ts +16 -0
  90. package/src/media/render-cache.ts +27 -0
  91. package/src/media/seekable.ts +35 -0
  92. package/src/media/thumb-input.test.ts +109 -0
  93. package/src/media/thumb-input.ts +194 -0
  94. package/src/nested-view.test.ts +223 -0
  95. package/src/nested-view.ts +239 -0
  96. package/src/probe/audio.test.ts +135 -0
  97. package/src/probe/audio.ts +140 -0
  98. package/src/probe/av.ts +84 -0
  99. package/src/probe/ffprobe.ts +197 -0
  100. package/src/probe/image.test.ts +251 -0
  101. package/src/probe/image.ts +336 -0
  102. package/src/probe/index.ts +54 -0
  103. package/src/probe/sniff.test.ts +72 -0
  104. package/src/probe/sniff.ts +67 -0
  105. package/src/probe/video.test.ts +41 -0
  106. package/src/probe/video.ts +116 -0
  107. package/src/probe-cache.test.ts +89 -0
  108. package/src/probe-cache.ts +65 -0
  109. package/src/render/ffmpeg.ts +78 -0
  110. package/src/render/index.ts +34 -0
  111. package/src/render/pipeline.test.ts +187 -0
  112. package/src/render/pipeline.ts +479 -0
  113. package/src/sandbox/call-timers.ts +68 -0
  114. package/src/sandbox/fixtures/api-error-plugin.mjs +11 -0
  115. package/src/sandbox/fixtures/asset-plugin.mjs +13 -0
  116. package/src/sandbox/fixtures/chatty-plugin.mjs +14 -0
  117. package/src/sandbox/fixtures/container-plugin.mjs +8 -0
  118. package/src/sandbox/fixtures/context-plugin.mjs +19 -0
  119. package/src/sandbox/fixtures/crash-plugin.mjs +6 -0
  120. package/src/sandbox/fixtures/echo-plugin.mjs +17 -0
  121. package/src/sandbox/fixtures/exit-plugin.mjs +6 -0
  122. package/src/sandbox/fixtures/fetch-plugin.mjs +8 -0
  123. package/src/sandbox/fixtures/flood-api-plugin.mjs +8 -0
  124. package/src/sandbox/fixtures/flood-log-plugin.mjs +8 -0
  125. package/src/sandbox/fixtures/hostile-dynamic-plugin.mjs +9 -0
  126. package/src/sandbox/fixtures/hostile-plugin.mjs +13 -0
  127. package/src/sandbox/fixtures/huge-result-plugin.mjs +8 -0
  128. package/src/sandbox/fixtures/logging-plugin.mjs +9 -0
  129. package/src/sandbox/fixtures/range-plugin.mjs +9 -0
  130. package/src/sandbox/fixtures/slow-api-plugin.mjs +11 -0
  131. package/src/sandbox/fixtures/spin-plugin.mjs +9 -0
  132. package/src/sandbox/fixtures/stuck-plugin.mjs +12 -0
  133. package/src/sandbox/fixtures/thrower-plugin.mjs +7 -0
  134. package/src/sandbox/host.test.ts +619 -0
  135. package/src/sandbox/host.ts +933 -0
  136. package/src/sandbox/host.unit.test.ts +330 -0
  137. package/src/sandbox/protocol.test.ts +85 -0
  138. package/src/sandbox/protocol.ts +147 -0
  139. package/src/sandbox/sandboxed-plugin.ts +30 -0
  140. package/src/sandbox/worker-entry.mjs +422 -0
  141. package/src/seed.test.ts +102 -0
  142. package/src/seed.ts +160 -0
  143. package/src/settings-store.ts +26 -0
  144. 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
+ }