@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,89 @@
|
|
|
1
|
+
import { describe, expect, test } from "vitest"
|
|
2
|
+
import { createProbeCache } from "./probe-cache.ts"
|
|
3
|
+
|
|
4
|
+
describe("probe cache", () => {
|
|
5
|
+
test("a miss computes once and hits afterwards", async () => {
|
|
6
|
+
const cache = createProbeCache()
|
|
7
|
+
let calls = 0
|
|
8
|
+
const compute = async () => {
|
|
9
|
+
calls++
|
|
10
|
+
return { width: 1 }
|
|
11
|
+
}
|
|
12
|
+
await expect(cache.getOrCompute("a", compute)).resolves.toEqual({
|
|
13
|
+
width: 1,
|
|
14
|
+
})
|
|
15
|
+
await expect(cache.getOrCompute("a", compute)).resolves.toEqual({
|
|
16
|
+
width: 1,
|
|
17
|
+
})
|
|
18
|
+
expect(calls).toBe(1)
|
|
19
|
+
})
|
|
20
|
+
|
|
21
|
+
test("concurrent misses for the same key single-flight", async () => {
|
|
22
|
+
const cache = createProbeCache()
|
|
23
|
+
let calls = 0
|
|
24
|
+
const compute = async () => {
|
|
25
|
+
calls++
|
|
26
|
+
await new Promise((resolve) => setTimeout(resolve, 10))
|
|
27
|
+
return true
|
|
28
|
+
}
|
|
29
|
+
const [a, b] = await Promise.all([
|
|
30
|
+
cache.getOrCompute("k", compute),
|
|
31
|
+
cache.getOrCompute("k", compute),
|
|
32
|
+
])
|
|
33
|
+
expect(a).toBe(true)
|
|
34
|
+
expect(b).toBe(true)
|
|
35
|
+
expect(calls).toBe(1)
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
test("undefined results are cached too", async () => {
|
|
39
|
+
const cache = createProbeCache()
|
|
40
|
+
let calls = 0
|
|
41
|
+
const compute = async () => {
|
|
42
|
+
calls++
|
|
43
|
+
return undefined
|
|
44
|
+
}
|
|
45
|
+
await expect(cache.getOrCompute("x", compute)).resolves.toBeUndefined()
|
|
46
|
+
await expect(cache.getOrCompute("x", compute)).resolves.toBeUndefined()
|
|
47
|
+
expect(calls).toBe(1)
|
|
48
|
+
})
|
|
49
|
+
|
|
50
|
+
test("rejections are not cached — the next caller retries", async () => {
|
|
51
|
+
const cache = createProbeCache()
|
|
52
|
+
let calls = 0
|
|
53
|
+
const failing = async (): Promise<{ width: number }> => {
|
|
54
|
+
calls++
|
|
55
|
+
throw new Error("transient")
|
|
56
|
+
}
|
|
57
|
+
await expect(cache.getOrCompute("x", failing)).rejects.toThrow("transient")
|
|
58
|
+
// Let the rejection cleanup land.
|
|
59
|
+
await new Promise((resolve) => setTimeout(resolve, 0))
|
|
60
|
+
await expect(
|
|
61
|
+
cache.getOrCompute("x", async () => ({ width: 2 })),
|
|
62
|
+
).resolves.toEqual({ width: 2 })
|
|
63
|
+
expect(calls).toBe(1)
|
|
64
|
+
})
|
|
65
|
+
|
|
66
|
+
test("evicts the least recently used entry beyond the bound", async () => {
|
|
67
|
+
const cache = createProbeCache(2)
|
|
68
|
+
const compute = async () => true
|
|
69
|
+
await cache.getOrCompute("a", compute)
|
|
70
|
+
await cache.getOrCompute("b", compute)
|
|
71
|
+
// Touch "a" so "b" becomes the eviction candidate.
|
|
72
|
+
await cache.getOrCompute("a", compute)
|
|
73
|
+
await cache.getOrCompute("c", compute)
|
|
74
|
+
|
|
75
|
+
let aCalls = 0
|
|
76
|
+
await cache.getOrCompute("a", async () => {
|
|
77
|
+
aCalls++
|
|
78
|
+
return true
|
|
79
|
+
})
|
|
80
|
+
expect(aCalls).toBe(0) // "a" survived as recently used
|
|
81
|
+
|
|
82
|
+
let bCalls = 0
|
|
83
|
+
await cache.getOrCompute("b", async () => {
|
|
84
|
+
bCalls++
|
|
85
|
+
return true
|
|
86
|
+
})
|
|
87
|
+
expect(bCalls).toBe(1) // "b" was evicted and recomputed
|
|
88
|
+
})
|
|
89
|
+
})
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Process-wide LRU cache for host-side probe results (image/video/audio
|
|
3
|
+
* metadata and animation flags). Source archives are immutable per
|
|
4
|
+
* fileVersion, so entries never need explicit invalidation — the cache
|
|
5
|
+
* scope carries the version and the LRU bound handles eviction.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
/** Process-wide bound (entries) for the host-side probe result cache. */
|
|
9
|
+
export const PLUGIN_PROBE_CACHE_MAX_ENTRIES = 512
|
|
10
|
+
|
|
11
|
+
/** Cacheable probe result; `undefined` marks a probe that found nothing. */
|
|
12
|
+
type ProbeValue = object | boolean | undefined
|
|
13
|
+
|
|
14
|
+
export type PluginProbeCache = {
|
|
15
|
+
/**
|
|
16
|
+
* Return the cached result for `key`, computing and storing it on a
|
|
17
|
+
* miss. Concurrent callers for the same key share one computation.
|
|
18
|
+
*/
|
|
19
|
+
readonly getOrCompute: <T extends ProbeValue>(
|
|
20
|
+
key: string,
|
|
21
|
+
compute: () => Promise<T>,
|
|
22
|
+
) => Promise<T>
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Create the probe LRU cache. Keys are caller-composed (probe kind +
|
|
27
|
+
* version + path); concurrent misses single-flight through one shared
|
|
28
|
+
* computation; failures are evicted so the next caller retries.
|
|
29
|
+
*/
|
|
30
|
+
export function createProbeCache(
|
|
31
|
+
maxEntries = PLUGIN_PROBE_CACHE_MAX_ENTRIES,
|
|
32
|
+
): PluginProbeCache {
|
|
33
|
+
// Promise-valued cells: a miss is stored before it settles, so
|
|
34
|
+
// concurrent probes for the same key single-flight through one
|
|
35
|
+
// computation.
|
|
36
|
+
const cells = new Map<string, Promise<ProbeValue>>()
|
|
37
|
+
|
|
38
|
+
return {
|
|
39
|
+
getOrCompute<T extends ProbeValue>(
|
|
40
|
+
key: string,
|
|
41
|
+
compute: () => Promise<T>,
|
|
42
|
+
): Promise<T> {
|
|
43
|
+
const existing = cells.get(key)
|
|
44
|
+
if (existing !== undefined) {
|
|
45
|
+
// Refresh LRU position.
|
|
46
|
+
cells.delete(key)
|
|
47
|
+
cells.set(key, existing)
|
|
48
|
+
// Type-erased store: each key is namespaced by probe kind, so
|
|
49
|
+
// the stored promise always resolves to the caller's T.
|
|
50
|
+
return existing as Promise<T>
|
|
51
|
+
}
|
|
52
|
+
const promise = compute()
|
|
53
|
+
cells.set(key, promise)
|
|
54
|
+
promise.catch(() => {
|
|
55
|
+
// Failures are not cached — the next caller retries.
|
|
56
|
+
if (cells.get(key) === promise) cells.delete(key)
|
|
57
|
+
})
|
|
58
|
+
if (cells.size > maxEntries) {
|
|
59
|
+
const oldest = cells.keys().next().value
|
|
60
|
+
if (oldest !== undefined) cells.delete(oldest)
|
|
61
|
+
}
|
|
62
|
+
return promise
|
|
63
|
+
},
|
|
64
|
+
}
|
|
65
|
+
}
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Resolve the absolute paths to `ffmpeg` / `ffprobe` for this host.
|
|
3
|
+
*
|
|
4
|
+
* Precedence:
|
|
5
|
+
* 1. Explicit env vars (`FFMPEG_PATH`, `FFPROBE_PATH`).
|
|
6
|
+
* 2. `ffmpeg-static` and `@derhuerst/ffprobe-static` via lazy
|
|
7
|
+
* `createRequire`, so they stay out of the host tarball. Dev
|
|
8
|
+
* resolves them from this package's `node_modules`; `pnpm start`
|
|
9
|
+
* and the desktop sidecar resolve them from the server
|
|
10
|
+
* `dist/node_modules` copy.
|
|
11
|
+
* 3. A bare command name (`ffmpeg` / `ffprobe`), which lets operators
|
|
12
|
+
* who have the binaries on PATH still run the server directly.
|
|
13
|
+
*/
|
|
14
|
+
import { createRequire } from "node:module"
|
|
15
|
+
|
|
16
|
+
const requireCjs = createRequire(import.meta.url)
|
|
17
|
+
|
|
18
|
+
export type FfmpegPaths = {
|
|
19
|
+
readonly ffmpeg: string
|
|
20
|
+
readonly ffprobe: string
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
type ResolveDeps = {
|
|
24
|
+
readonly env?: NodeJS.ProcessEnv
|
|
25
|
+
/** Override for tests so they never hit the real module. */
|
|
26
|
+
readonly loadStatic?: () => string | undefined
|
|
27
|
+
/** Override for tests so they never hit the real module. */
|
|
28
|
+
readonly loadStaticFfprobe?: () => string | undefined
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function resolveFfmpegPaths(deps: ResolveDeps = {}): FfmpegPaths {
|
|
32
|
+
const env = deps.env ?? process.env
|
|
33
|
+
const ffmpegEnv = env.FFMPEG_PATH
|
|
34
|
+
const ffprobeEnv = env.FFPROBE_PATH
|
|
35
|
+
const staticFfmpeg =
|
|
36
|
+
ffmpegEnv === undefined || ffmpegEnv.length === 0
|
|
37
|
+
? (deps.loadStatic ?? loadInstallerFfmpeg)()
|
|
38
|
+
: undefined
|
|
39
|
+
const ffmpeg =
|
|
40
|
+
ffmpegEnv !== undefined && ffmpegEnv.length > 0
|
|
41
|
+
? ffmpegEnv
|
|
42
|
+
: (staticFfmpeg ?? "ffmpeg")
|
|
43
|
+
const staticFfprobe =
|
|
44
|
+
ffprobeEnv === undefined || ffprobeEnv.length === 0
|
|
45
|
+
? (deps.loadStaticFfprobe ?? loadInstallerFfprobe)()
|
|
46
|
+
: undefined
|
|
47
|
+
const ffprobe =
|
|
48
|
+
ffprobeEnv !== undefined && ffprobeEnv.length > 0
|
|
49
|
+
? ffprobeEnv
|
|
50
|
+
: (staticFfprobe ?? "ffprobe")
|
|
51
|
+
return { ffmpeg, ffprobe }
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function loadInstallerFfmpeg(): string | undefined {
|
|
55
|
+
return loadInstallerPath("ffmpeg-static")
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function loadInstallerFfprobe(): string | undefined {
|
|
59
|
+
return loadInstallerPath("@derhuerst/ffprobe-static")
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function loadInstallerPath(moduleName: string): string | undefined {
|
|
63
|
+
try {
|
|
64
|
+
const mod: unknown = requireCjs(moduleName)
|
|
65
|
+
if (typeof mod === "string" && mod.length > 0) return mod
|
|
66
|
+
if (
|
|
67
|
+
mod !== null &&
|
|
68
|
+
typeof mod === "object" &&
|
|
69
|
+
"path" in mod &&
|
|
70
|
+
typeof (mod as { path: unknown }).path === "string"
|
|
71
|
+
) {
|
|
72
|
+
return (mod as { path: string }).path
|
|
73
|
+
}
|
|
74
|
+
return undefined
|
|
75
|
+
} catch {
|
|
76
|
+
return undefined
|
|
77
|
+
}
|
|
78
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The thumbnail/preview render pipeline: sharp for images, ffmpeg for
|
|
3
|
+
* video frames and embedded audio artwork. It lives in the host — next
|
|
4
|
+
* to the probes it shares a sharp instance with — so every consumer
|
|
5
|
+
* renders identically: the server's thumb service in production, and
|
|
6
|
+
* `hoardodile plugin dev` when it serves preview variants and seek
|
|
7
|
+
* frames to the workbench.
|
|
8
|
+
*
|
|
9
|
+
* sharp is a required peer here (unlike probing, rendering cannot
|
|
10
|
+
* degrade); ffmpeg/ffprobe binaries are resolved lazily by `ffmpeg.ts`.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
export type { FfmpegPaths } from "./ffmpeg.ts"
|
|
14
|
+
export { resolveFfmpegPaths } from "./ffmpeg.ts"
|
|
15
|
+
export type {
|
|
16
|
+
ImageThumbInput,
|
|
17
|
+
ImageThumbRenderResult,
|
|
18
|
+
RenderResult,
|
|
19
|
+
VideoFrameSource,
|
|
20
|
+
} from "./pipeline.ts"
|
|
21
|
+
export {
|
|
22
|
+
ANIMATED_AREA_DIVISOR,
|
|
23
|
+
AVIF_EFFORT,
|
|
24
|
+
AVIF_QUALITY,
|
|
25
|
+
cleanOrphanedTempFiles,
|
|
26
|
+
fitInsideArea,
|
|
27
|
+
PREVIEW_AVIF_QUALITY,
|
|
28
|
+
PREVIEW_WEBP_QUALITY,
|
|
29
|
+
renderAudioCoverArt,
|
|
30
|
+
renderImageThumbOnce,
|
|
31
|
+
renderImageWithArea,
|
|
32
|
+
renderVideoFrame,
|
|
33
|
+
WEBP_QUALITY,
|
|
34
|
+
} from "./pipeline.ts"
|
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
import { mkdtempSync, rmSync } from "node:fs"
|
|
2
|
+
import { tmpdir } from "node:os"
|
|
3
|
+
import { join } from "node:path"
|
|
4
|
+
import { PassThrough, Readable } from "node:stream"
|
|
5
|
+
import sharp from "sharp"
|
|
6
|
+
import { afterEach, describe, expect, test, vi } from "vitest"
|
|
7
|
+
import { renderImageThumbOnce, renderVideoFrame } from "./pipeline.ts"
|
|
8
|
+
|
|
9
|
+
vi.mock("node:child_process", () => ({
|
|
10
|
+
spawn: vi.fn(),
|
|
11
|
+
execFile: vi.fn(),
|
|
12
|
+
}))
|
|
13
|
+
|
|
14
|
+
describe("renderImageThumbOnce stream input", () => {
|
|
15
|
+
afterEach(() => {
|
|
16
|
+
vi.resetModules()
|
|
17
|
+
vi.clearAllMocks()
|
|
18
|
+
})
|
|
19
|
+
|
|
20
|
+
test("reopenable stream input renders (piped sharp input)", async () => {
|
|
21
|
+
// Regression lock for the >32MB stream path: sharp's constructor
|
|
22
|
+
// rejects `sharp(stream, opts)`, so stream inputs must go through
|
|
23
|
+
// the piped-descriptor form in sharpFromReadable.
|
|
24
|
+
const png = await sharp({
|
|
25
|
+
create: {
|
|
26
|
+
width: 64,
|
|
27
|
+
height: 48,
|
|
28
|
+
channels: 3,
|
|
29
|
+
background: { r: 12, g: 34, b: 56 },
|
|
30
|
+
},
|
|
31
|
+
})
|
|
32
|
+
.png()
|
|
33
|
+
.toBuffer()
|
|
34
|
+
|
|
35
|
+
const destDir = mkdtempSync(join(tmpdir(), "stream-thumb-"))
|
|
36
|
+
try {
|
|
37
|
+
const rendered = await renderImageThumbOnce({
|
|
38
|
+
input: { openStream: async () => Readable.from(png) },
|
|
39
|
+
ext: ".png",
|
|
40
|
+
resolveDest: (fmt) => join(destDir, `cover.${fmt}`),
|
|
41
|
+
variant: {
|
|
42
|
+
format: "avif",
|
|
43
|
+
fit: "inside",
|
|
44
|
+
maxArea: 10_000,
|
|
45
|
+
webpQuality: 82,
|
|
46
|
+
avifQuality: 65,
|
|
47
|
+
},
|
|
48
|
+
})
|
|
49
|
+
expect(rendered.format).toBe("avif")
|
|
50
|
+
const meta = await sharp(rendered.path).metadata()
|
|
51
|
+
expect(meta.width).toBe(64)
|
|
52
|
+
expect(meta.height).toBe(48)
|
|
53
|
+
} finally {
|
|
54
|
+
rmSync(destDir, { recursive: true, force: true })
|
|
55
|
+
}
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
test("fit exact transcodes at source dimensions even under a tiny area cap", async () => {
|
|
59
|
+
const png = await sharp({
|
|
60
|
+
create: {
|
|
61
|
+
width: 64,
|
|
62
|
+
height: 48,
|
|
63
|
+
channels: 3,
|
|
64
|
+
background: { r: 12, g: 34, b: 56 },
|
|
65
|
+
},
|
|
66
|
+
})
|
|
67
|
+
.png()
|
|
68
|
+
.toBuffer()
|
|
69
|
+
|
|
70
|
+
const destDir = mkdtempSync(join(tmpdir(), "exact-thumb-"))
|
|
71
|
+
try {
|
|
72
|
+
const rendered = await renderImageThumbOnce({
|
|
73
|
+
input: png,
|
|
74
|
+
ext: ".png",
|
|
75
|
+
resolveDest: (fmt) => join(destDir, `exact.${fmt}`),
|
|
76
|
+
// A cap this small would shrink the source under `inside`;
|
|
77
|
+
// `exact` must ignore it entirely.
|
|
78
|
+
variant: {
|
|
79
|
+
format: "avif",
|
|
80
|
+
fit: "exact",
|
|
81
|
+
maxArea: 100,
|
|
82
|
+
webpQuality: 82,
|
|
83
|
+
avifQuality: 65,
|
|
84
|
+
},
|
|
85
|
+
})
|
|
86
|
+
expect(rendered.format).toBe("avif")
|
|
87
|
+
expect(rendered.displayWidth).toBe(64)
|
|
88
|
+
expect(rendered.displayHeight).toBe(48)
|
|
89
|
+
const meta = await sharp(rendered.path).metadata()
|
|
90
|
+
expect(meta.width).toBe(64)
|
|
91
|
+
expect(meta.height).toBe(48)
|
|
92
|
+
} finally {
|
|
93
|
+
rmSync(destDir, { recursive: true, force: true })
|
|
94
|
+
}
|
|
95
|
+
})
|
|
96
|
+
|
|
97
|
+
test("fit inside still downscales to the area cap", async () => {
|
|
98
|
+
const png = await sharp({
|
|
99
|
+
create: {
|
|
100
|
+
width: 64,
|
|
101
|
+
height: 48,
|
|
102
|
+
channels: 3,
|
|
103
|
+
background: { r: 12, g: 34, b: 56 },
|
|
104
|
+
},
|
|
105
|
+
})
|
|
106
|
+
.png()
|
|
107
|
+
.toBuffer()
|
|
108
|
+
|
|
109
|
+
const destDir = mkdtempSync(join(tmpdir(), "inside-thumb-"))
|
|
110
|
+
try {
|
|
111
|
+
const rendered = await renderImageThumbOnce({
|
|
112
|
+
input: png,
|
|
113
|
+
ext: ".png",
|
|
114
|
+
resolveDest: (fmt) => join(destDir, `inside.${fmt}`),
|
|
115
|
+
variant: {
|
|
116
|
+
format: "avif",
|
|
117
|
+
fit: "inside",
|
|
118
|
+
maxArea: 100,
|
|
119
|
+
webpQuality: 82,
|
|
120
|
+
avifQuality: 65,
|
|
121
|
+
},
|
|
122
|
+
})
|
|
123
|
+
const meta = await sharp(rendered.path).metadata()
|
|
124
|
+
const area = (meta.width ?? 0) * (meta.height ?? 0)
|
|
125
|
+
expect(area).toBeLessThan(100)
|
|
126
|
+
expect(meta.width).toBeLessThan(64)
|
|
127
|
+
} finally {
|
|
128
|
+
rmSync(destDir, { recursive: true, force: true })
|
|
129
|
+
}
|
|
130
|
+
})
|
|
131
|
+
})
|
|
132
|
+
|
|
133
|
+
describe("renderVideoFrame", () => {
|
|
134
|
+
afterEach(() => {
|
|
135
|
+
vi.resetModules()
|
|
136
|
+
vi.clearAllMocks()
|
|
137
|
+
})
|
|
138
|
+
|
|
139
|
+
test("ffmpeg receives pipe:0 for stream sources", async () => {
|
|
140
|
+
const { spawn } = await import("node:child_process")
|
|
141
|
+
const jpeg = await sharp({
|
|
142
|
+
create: {
|
|
143
|
+
width: 8,
|
|
144
|
+
height: 8,
|
|
145
|
+
channels: 3,
|
|
146
|
+
background: { r: 1, g: 2, b: 3 },
|
|
147
|
+
},
|
|
148
|
+
})
|
|
149
|
+
.jpeg()
|
|
150
|
+
.toBuffer()
|
|
151
|
+
const fakeChild = {
|
|
152
|
+
stdin: new PassThrough(),
|
|
153
|
+
stdout: {
|
|
154
|
+
on: vi.fn((_event: string, handler: (chunk: Buffer) => void) => {
|
|
155
|
+
handler(jpeg)
|
|
156
|
+
}),
|
|
157
|
+
},
|
|
158
|
+
stderr: { on: vi.fn() },
|
|
159
|
+
on: vi.fn((event: string, handler: (code: number) => void) => {
|
|
160
|
+
if (event === "close") handler(0)
|
|
161
|
+
}),
|
|
162
|
+
}
|
|
163
|
+
vi.mocked(spawn).mockReturnValue(fakeChild as never)
|
|
164
|
+
|
|
165
|
+
const destDir = mkdtempSync(join(tmpdir(), "pipe-frame-"))
|
|
166
|
+
try {
|
|
167
|
+
const destPath = join(destDir, "frame.avif")
|
|
168
|
+
const stream = Readable.from(Buffer.from("fake-video"))
|
|
169
|
+
await renderVideoFrame({
|
|
170
|
+
source: stream,
|
|
171
|
+
ext: ".mp4",
|
|
172
|
+
destPath,
|
|
173
|
+
ffmpeg: { ffmpeg: "/bin/ffmpeg", ffprobe: "/bin/ffprobe" },
|
|
174
|
+
maxArea: 10_000,
|
|
175
|
+
quality: 65,
|
|
176
|
+
})
|
|
177
|
+
const args = vi.mocked(spawn).mock.calls[0]?.[1] as string[]
|
|
178
|
+
expect(args).toContain("pipe:0")
|
|
179
|
+
expect(args).toContain("-f")
|
|
180
|
+
expect(args).toContain("mp4")
|
|
181
|
+
expect(args).not.toContain("-ss")
|
|
182
|
+
expect(args).not.toContain("fake-video")
|
|
183
|
+
} finally {
|
|
184
|
+
rmSync(destDir, { recursive: true, force: true })
|
|
185
|
+
}
|
|
186
|
+
})
|
|
187
|
+
})
|