@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,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
+ })