@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,197 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared ffprobe plumbing for the media probes. `video.ts` and `audio.ts`
|
|
3
|
+
* both spawn the same binary with the same JSON output flags and only
|
|
4
|
+
* differ in how they read the payload, so the process handling and the
|
|
5
|
+
* payload shapes live here.
|
|
6
|
+
*
|
|
7
|
+
* The ffprobe binary path is resolved lazily. `@derhuerst/ffprobe-static`
|
|
8
|
+
* is an optional dependency of this package — not shipped in the host
|
|
9
|
+
* tarball. Dev resolves it from this package's `node_modules`; `pnpm
|
|
10
|
+
* start` and the desktop sidecar resolve it from the server
|
|
11
|
+
* `dist/node_modules` copy.
|
|
12
|
+
*/
|
|
13
|
+
import { createRequire } from "node:module"
|
|
14
|
+
import type { Readable } from "node:stream"
|
|
15
|
+
import { execa } from "execa"
|
|
16
|
+
|
|
17
|
+
type ResolveFfprobeDeps = {
|
|
18
|
+
readonly env?: NodeJS.ProcessEnv
|
|
19
|
+
/** Override for tests so they never hit the real module. */
|
|
20
|
+
readonly loadStatic?: () => string | undefined
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const requireCjs = createRequire(import.meta.url)
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Resolve the absolute path to `ffprobe` for this host.
|
|
27
|
+
*
|
|
28
|
+
* Precedence:
|
|
29
|
+
* 1. The `FFPROBE_PATH` env var.
|
|
30
|
+
* 2. `@derhuerst/ffprobe-static` via lazy `createRequire` — the
|
|
31
|
+
* installer package stays out of the host tarball.
|
|
32
|
+
* 3. A bare command name, letting operators with ffprobe on PATH run
|
|
33
|
+
* the CLI or server directly.
|
|
34
|
+
*/
|
|
35
|
+
export function resolveFfprobePath(deps: ResolveFfprobeDeps = {}): string {
|
|
36
|
+
const env = deps.env ?? process.env
|
|
37
|
+
const fromEnv = env.FFPROBE_PATH
|
|
38
|
+
if (fromEnv !== undefined && fromEnv.length > 0) return fromEnv
|
|
39
|
+
const staticPath = (deps.loadStatic ?? loadInstallerFfprobe)()
|
|
40
|
+
return staticPath ?? "ffprobe"
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function loadInstallerFfprobe(): string | undefined {
|
|
44
|
+
try {
|
|
45
|
+
const mod: unknown = requireCjs("@derhuerst/ffprobe-static")
|
|
46
|
+
if (typeof mod === "string" && mod.length > 0) return mod
|
|
47
|
+
if (
|
|
48
|
+
mod !== null &&
|
|
49
|
+
typeof mod === "object" &&
|
|
50
|
+
"path" in mod &&
|
|
51
|
+
typeof (mod as { path: unknown }).path === "string"
|
|
52
|
+
) {
|
|
53
|
+
return (mod as { path: string }).path
|
|
54
|
+
}
|
|
55
|
+
return undefined
|
|
56
|
+
} catch {
|
|
57
|
+
return undefined
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
let ffprobePathCache: string | undefined
|
|
62
|
+
|
|
63
|
+
/** Memoized {@link resolveFfprobePath} for the probe entry points. */
|
|
64
|
+
export function getFfprobePath(): string {
|
|
65
|
+
if (ffprobePathCache === undefined) ffprobePathCache = resolveFfprobePath()
|
|
66
|
+
return ffprobePathCache
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export type FfprobeStream = {
|
|
70
|
+
readonly codec_type?: unknown
|
|
71
|
+
readonly codec_name?: unknown
|
|
72
|
+
readonly width?: unknown
|
|
73
|
+
readonly height?: unknown
|
|
74
|
+
readonly sample_rate?: unknown
|
|
75
|
+
readonly channels?: unknown
|
|
76
|
+
readonly disposition?: unknown
|
|
77
|
+
readonly tags?: unknown
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export type FfprobeFormat = {
|
|
81
|
+
readonly duration?: unknown
|
|
82
|
+
readonly bit_rate?: unknown
|
|
83
|
+
readonly tags?: unknown
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export type FfprobePayload = {
|
|
87
|
+
readonly streams?: readonly FfprobeStream[]
|
|
88
|
+
readonly format?: FfprobeFormat
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Parse ffprobe's JSON output into the raw payload shape. Anything that
|
|
93
|
+
* is not a JSON object yields an empty payload — partial and malformed
|
|
94
|
+
* payloads are normal for damaged media, and callers prefer surfacing
|
|
95
|
+
* whatever survives narrowing over rejecting the whole probe.
|
|
96
|
+
*/
|
|
97
|
+
export function parseFfprobePayload(json: string): FfprobePayload {
|
|
98
|
+
const payload: unknown = JSON.parse(json)
|
|
99
|
+
if (typeof payload !== "object" || payload === null) return {}
|
|
100
|
+
return payload
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** Read `format.duration` (ffprobe emits it as a string) in milliseconds. */
|
|
104
|
+
export function ffprobeDurationMs(
|
|
105
|
+
format: FfprobeFormat | undefined,
|
|
106
|
+
): number | undefined {
|
|
107
|
+
const raw = format?.duration
|
|
108
|
+
if (typeof raw !== "string") return undefined
|
|
109
|
+
const seconds = Number.parseFloat(raw)
|
|
110
|
+
if (!Number.isFinite(seconds) || seconds < 0) return undefined
|
|
111
|
+
return Math.round(seconds * 1000)
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** Read a positive integer out of an ffprobe field (string or number). */
|
|
115
|
+
export function ffprobeInt(value: unknown): number | undefined {
|
|
116
|
+
const parsed =
|
|
117
|
+
typeof value === "number"
|
|
118
|
+
? value
|
|
119
|
+
: typeof value === "string"
|
|
120
|
+
? Number.parseInt(value, 10)
|
|
121
|
+
: Number.NaN
|
|
122
|
+
if (!Number.isFinite(parsed) || parsed <= 0) return undefined
|
|
123
|
+
return Math.round(parsed)
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Run `ffprobe` against `source` and return its parsed JSON payload.
|
|
128
|
+
* Failure paths reject (callers wrap the call and treat a missing probe
|
|
129
|
+
* as non-fatal) so this function only returns with a payload in hand.
|
|
130
|
+
*
|
|
131
|
+
* Why JSON / `-print_format json`: the alternative `-show_entries` flat
|
|
132
|
+
* format requires brittle line splitting and breaks on locale-specific
|
|
133
|
+
* decimal separators in `duration`. JSON gives the values verbatim.
|
|
134
|
+
*
|
|
135
|
+
* @throws `Error` with stderr when ffprobe exits non-zero or emits no JSON.
|
|
136
|
+
*/
|
|
137
|
+
export function runFfprobeJson(
|
|
138
|
+
source: string | Readable,
|
|
139
|
+
ffprobePath: string,
|
|
140
|
+
inputFormat?: string,
|
|
141
|
+
): Promise<FfprobePayload> {
|
|
142
|
+
const fromStream = typeof source !== "string"
|
|
143
|
+
return new Promise((resolve, reject) => {
|
|
144
|
+
const args = [
|
|
145
|
+
"-hide_banner",
|
|
146
|
+
"-loglevel",
|
|
147
|
+
"error",
|
|
148
|
+
"-print_format",
|
|
149
|
+
"json",
|
|
150
|
+
"-show_streams",
|
|
151
|
+
"-show_format",
|
|
152
|
+
]
|
|
153
|
+
if (fromStream) {
|
|
154
|
+
if (inputFormat === undefined) {
|
|
155
|
+
reject(new Error("stream ffprobe requires an input format hint"))
|
|
156
|
+
return
|
|
157
|
+
}
|
|
158
|
+
args.push("-probesize", "100M", "-analyzeduration", "100M")
|
|
159
|
+
args.push("-f", inputFormat)
|
|
160
|
+
}
|
|
161
|
+
args.push(fromStream ? "pipe:0" : source)
|
|
162
|
+
const child = execa(ffprobePath, args, {
|
|
163
|
+
stdin: fromStream ? "pipe" : "ignore",
|
|
164
|
+
reject: false,
|
|
165
|
+
stripFinalNewline: false,
|
|
166
|
+
})
|
|
167
|
+
if (fromStream) {
|
|
168
|
+
source.on("error", reject)
|
|
169
|
+
const stdin = child.stdin
|
|
170
|
+
if (stdin !== null) {
|
|
171
|
+
source.pipe(stdin)
|
|
172
|
+
stdin.on("error", () => {})
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
void child.then(
|
|
176
|
+
({ stdout, exitCode, stderr }) => {
|
|
177
|
+
if (exitCode !== 0) {
|
|
178
|
+
const msg = stderr.trim()
|
|
179
|
+
reject(
|
|
180
|
+
new Error(`ffprobe exited ${exitCode}${msg ? `: ${msg}` : ""}`),
|
|
181
|
+
)
|
|
182
|
+
return
|
|
183
|
+
}
|
|
184
|
+
if (stdout.length === 0) {
|
|
185
|
+
reject(new Error("ffprobe produced no output"))
|
|
186
|
+
return
|
|
187
|
+
}
|
|
188
|
+
try {
|
|
189
|
+
resolve(parseFfprobePayload(stdout))
|
|
190
|
+
} catch (err) {
|
|
191
|
+
reject(err instanceof Error ? err : new Error(String(err)))
|
|
192
|
+
}
|
|
193
|
+
},
|
|
194
|
+
(err) => reject(err instanceof Error ? err : new Error(String(err))),
|
|
195
|
+
)
|
|
196
|
+
})
|
|
197
|
+
}
|
|
@@ -0,0 +1,251 @@
|
|
|
1
|
+
import { mkdtempSync, rmSync, writeFileSync } from "node:fs"
|
|
2
|
+
import { tmpdir } from "node:os"
|
|
3
|
+
import { join } from "node:path"
|
|
4
|
+
import { Readable } from "node:stream"
|
|
5
|
+
import sharp from "sharp"
|
|
6
|
+
import { afterEach, beforeEach, describe, expect, test } from "vitest"
|
|
7
|
+
import {
|
|
8
|
+
needsFullAnimationScan,
|
|
9
|
+
PROBE_HEADER_BYTES,
|
|
10
|
+
probeAnimatedImage,
|
|
11
|
+
probeImageSource,
|
|
12
|
+
readImageMetadata,
|
|
13
|
+
THUMB_BUFFER_MAX_BYTES,
|
|
14
|
+
} from "./image.ts"
|
|
15
|
+
|
|
16
|
+
describe("probeAnimatedImage", () => {
|
|
17
|
+
test("returns false for video extensions without invoking sharp", async () => {
|
|
18
|
+
const dir = mkdtempSync(join(tmpdir(), "probe-anim-"))
|
|
19
|
+
try {
|
|
20
|
+
const path = join(dir, "clip.mp4")
|
|
21
|
+
writeFileSync(path, "not-a-real-mp4")
|
|
22
|
+
await expect(probeAnimatedImage(path)).resolves.toBe(false)
|
|
23
|
+
} finally {
|
|
24
|
+
rmSync(dir, { recursive: true, force: true })
|
|
25
|
+
}
|
|
26
|
+
})
|
|
27
|
+
})
|
|
28
|
+
|
|
29
|
+
describe("probeImageSource", () => {
|
|
30
|
+
test("static PNG is not animated after a single-page read", async () => {
|
|
31
|
+
const png = await sharp({
|
|
32
|
+
create: {
|
|
33
|
+
width: 32,
|
|
34
|
+
height: 32,
|
|
35
|
+
channels: 3,
|
|
36
|
+
background: { r: 10, g: 20, b: 30 },
|
|
37
|
+
},
|
|
38
|
+
})
|
|
39
|
+
.png()
|
|
40
|
+
.toBuffer()
|
|
41
|
+
const probe = await probeImageSource(png, ".png")
|
|
42
|
+
expect(probe).toEqual({
|
|
43
|
+
width: 32,
|
|
44
|
+
height: 32,
|
|
45
|
+
animated: false,
|
|
46
|
+
})
|
|
47
|
+
})
|
|
48
|
+
|
|
49
|
+
test("stream input probes normally", async () => {
|
|
50
|
+
const png = await sharp({
|
|
51
|
+
create: {
|
|
52
|
+
width: 16,
|
|
53
|
+
height: 8,
|
|
54
|
+
channels: 3,
|
|
55
|
+
background: { r: 1, g: 2, b: 3 },
|
|
56
|
+
},
|
|
57
|
+
})
|
|
58
|
+
.png()
|
|
59
|
+
.toBuffer()
|
|
60
|
+
const probe = await probeImageSource(Readable.from(png), ".png")
|
|
61
|
+
expect(probe).toEqual({ width: 16, height: 8, animated: false })
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
test("reopenable stream input probes normally", async () => {
|
|
65
|
+
const png = await sharp({
|
|
66
|
+
create: {
|
|
67
|
+
width: 16,
|
|
68
|
+
height: 8,
|
|
69
|
+
channels: 3,
|
|
70
|
+
background: { r: 1, g: 2, b: 3 },
|
|
71
|
+
},
|
|
72
|
+
})
|
|
73
|
+
.png()
|
|
74
|
+
.toBuffer()
|
|
75
|
+
const probe = await probeImageSource(
|
|
76
|
+
{ openStream: async () => Readable.from(png) },
|
|
77
|
+
".png",
|
|
78
|
+
)
|
|
79
|
+
expect(probe).toEqual({ width: 16, height: 8, animated: false })
|
|
80
|
+
})
|
|
81
|
+
|
|
82
|
+
test("valid stream input beyond the byte cap still probes (header-only read)", async () => {
|
|
83
|
+
const png = await sharp({
|
|
84
|
+
create: {
|
|
85
|
+
width: 16,
|
|
86
|
+
height: 8,
|
|
87
|
+
channels: 3,
|
|
88
|
+
background: { r: 1, g: 2, b: 3 },
|
|
89
|
+
},
|
|
90
|
+
})
|
|
91
|
+
.png()
|
|
92
|
+
.toBuffer()
|
|
93
|
+
// The PNG header sits at the start; the probe must read only what
|
|
94
|
+
// it needs instead of buffering the whole (oversized) entry.
|
|
95
|
+
const oversized = Buffer.concat([
|
|
96
|
+
png,
|
|
97
|
+
Buffer.alloc(THUMB_BUFFER_MAX_BYTES + 1),
|
|
98
|
+
])
|
|
99
|
+
const probe = await probeImageSource(Readable.from(oversized), ".png")
|
|
100
|
+
expect(probe).toEqual({ width: 16, height: 8, animated: false })
|
|
101
|
+
})
|
|
102
|
+
|
|
103
|
+
test("garbage stream input degrades to undefined", async () => {
|
|
104
|
+
const probe = await probeImageSource(
|
|
105
|
+
Readable.from(Buffer.alloc(THUMB_BUFFER_MAX_BYTES + 1, 0xab)),
|
|
106
|
+
".png",
|
|
107
|
+
)
|
|
108
|
+
expect(probe).toBeUndefined()
|
|
109
|
+
})
|
|
110
|
+
})
|
|
111
|
+
|
|
112
|
+
describe("needsFullAnimationScan", () => {
|
|
113
|
+
test("gif always escalates to full scan", () => {
|
|
114
|
+
expect(needsFullAnimationScan({ width: 1, height: 1 }, ".gif")).toBe(true)
|
|
115
|
+
})
|
|
116
|
+
|
|
117
|
+
test("static jpeg does not escalate", () => {
|
|
118
|
+
expect(
|
|
119
|
+
needsFullAnimationScan({ width: 4000, height: 3000, pages: 1 }, ".jpg"),
|
|
120
|
+
).toBe(false)
|
|
121
|
+
})
|
|
122
|
+
})
|
|
123
|
+
|
|
124
|
+
describe("readImageMetadata", () => {
|
|
125
|
+
let root: string
|
|
126
|
+
|
|
127
|
+
beforeEach(() => {
|
|
128
|
+
root = mkdtempSync(join(tmpdir(), "probe-meta-"))
|
|
129
|
+
})
|
|
130
|
+
|
|
131
|
+
afterEach(() => {
|
|
132
|
+
rmSync(root, { recursive: true, force: true })
|
|
133
|
+
})
|
|
134
|
+
|
|
135
|
+
test("reads static jpeg dimensions from buffer", async () => {
|
|
136
|
+
const jpeg = await sharp({
|
|
137
|
+
create: {
|
|
138
|
+
width: 120,
|
|
139
|
+
height: 80,
|
|
140
|
+
channels: 3,
|
|
141
|
+
background: { r: 1, g: 2, b: 3 },
|
|
142
|
+
},
|
|
143
|
+
})
|
|
144
|
+
.jpeg()
|
|
145
|
+
.toBuffer()
|
|
146
|
+
const { meta, animated } = await readImageMetadata(jpeg, ".jpg")
|
|
147
|
+
expect(animated).toBe(false)
|
|
148
|
+
expect(meta.width).toBe(120)
|
|
149
|
+
expect(meta.height).toBe(80)
|
|
150
|
+
})
|
|
151
|
+
})
|
|
152
|
+
|
|
153
|
+
describe("readImageMetadata header-slice fast path", () => {
|
|
154
|
+
async function makePng(): Promise<Buffer> {
|
|
155
|
+
return sharp({
|
|
156
|
+
create: {
|
|
157
|
+
width: 16,
|
|
158
|
+
height: 8,
|
|
159
|
+
channels: 3,
|
|
160
|
+
background: { r: 1, g: 2, b: 3 },
|
|
161
|
+
},
|
|
162
|
+
})
|
|
163
|
+
.png()
|
|
164
|
+
.toBuffer()
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
test("static image probes from the header slice without opening a stream", async () => {
|
|
168
|
+
const png = await makePng()
|
|
169
|
+
let streams = 0
|
|
170
|
+
const { meta, animated } = await readImageMetadata(
|
|
171
|
+
{
|
|
172
|
+
openStream: async () => {
|
|
173
|
+
streams++
|
|
174
|
+
return Readable.from(png)
|
|
175
|
+
},
|
|
176
|
+
readRange: async (start, end) => png.subarray(start, end),
|
|
177
|
+
},
|
|
178
|
+
".png",
|
|
179
|
+
)
|
|
180
|
+
expect(meta.width).toBe(16)
|
|
181
|
+
expect(meta.height).toBe(8)
|
|
182
|
+
expect(animated).toBe(false)
|
|
183
|
+
expect(streams).toBe(0)
|
|
184
|
+
})
|
|
185
|
+
|
|
186
|
+
test("an inconclusive slice falls back to the stream", async () => {
|
|
187
|
+
const png = await makePng()
|
|
188
|
+
const { meta, animated } = await readImageMetadata(
|
|
189
|
+
{
|
|
190
|
+
openStream: async () => Readable.from(png),
|
|
191
|
+
readRange: async () => Buffer.from([0xde, 0xad]),
|
|
192
|
+
},
|
|
193
|
+
".png",
|
|
194
|
+
)
|
|
195
|
+
expect(meta.width).toBe(16)
|
|
196
|
+
expect(animated).toBe(false)
|
|
197
|
+
})
|
|
198
|
+
|
|
199
|
+
test("gif probes never use the header slice (frame count needs the full scan)", async () => {
|
|
200
|
+
const gif = await sharp({
|
|
201
|
+
create: {
|
|
202
|
+
width: 8,
|
|
203
|
+
height: 8,
|
|
204
|
+
channels: 3,
|
|
205
|
+
background: { r: 1, g: 2, b: 3 },
|
|
206
|
+
},
|
|
207
|
+
})
|
|
208
|
+
.gif()
|
|
209
|
+
.toBuffer()
|
|
210
|
+
let sliceCalls = 0
|
|
211
|
+
const { meta } = await readImageMetadata(
|
|
212
|
+
{
|
|
213
|
+
openStream: async () => Readable.from(gif),
|
|
214
|
+
readRange: async () => {
|
|
215
|
+
sliceCalls++
|
|
216
|
+
return gif.subarray(0, PROBE_HEADER_BYTES)
|
|
217
|
+
},
|
|
218
|
+
},
|
|
219
|
+
".gif",
|
|
220
|
+
)
|
|
221
|
+
expect(meta.width).toBe(8)
|
|
222
|
+
expect(sliceCalls).toBe(0)
|
|
223
|
+
})
|
|
224
|
+
|
|
225
|
+
test("static webp probes from the header slice too", async () => {
|
|
226
|
+
const webp = await sharp({
|
|
227
|
+
create: {
|
|
228
|
+
width: 12,
|
|
229
|
+
height: 10,
|
|
230
|
+
channels: 3,
|
|
231
|
+
background: { r: 1, g: 2, b: 3 },
|
|
232
|
+
},
|
|
233
|
+
})
|
|
234
|
+
.webp()
|
|
235
|
+
.toBuffer()
|
|
236
|
+
let streams = 0
|
|
237
|
+
const { meta, animated } = await readImageMetadata(
|
|
238
|
+
{
|
|
239
|
+
openStream: async () => {
|
|
240
|
+
streams++
|
|
241
|
+
return Readable.from(webp)
|
|
242
|
+
},
|
|
243
|
+
readRange: async (start, end) => webp.subarray(start, end),
|
|
244
|
+
},
|
|
245
|
+
".webp",
|
|
246
|
+
)
|
|
247
|
+
expect(meta.width).toBe(12)
|
|
248
|
+
expect(animated).toBe(false)
|
|
249
|
+
expect(streams).toBe(0)
|
|
250
|
+
})
|
|
251
|
+
})
|