@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,479 @@
|
|
|
1
|
+
import { spawn } from "node:child_process"
|
|
2
|
+
import { mkdir, readdir, rename, rm } from "node:fs/promises"
|
|
3
|
+
import { dirname, extname, join } from "node:path"
|
|
4
|
+
import type { Readable } from "node:stream"
|
|
5
|
+
import type { ResolvedImageVariant } from "@hoardodile/sdk-types/image-variant"
|
|
6
|
+
import sharp, { type Sharp } from "sharp"
|
|
7
|
+
import {
|
|
8
|
+
extToAudioInputFormat,
|
|
9
|
+
extToFfmpegInputFormat,
|
|
10
|
+
readImageMetadata,
|
|
11
|
+
sharpFromReadable,
|
|
12
|
+
sharpImageInputOpts,
|
|
13
|
+
} from "../probe/index.ts"
|
|
14
|
+
import type { FfmpegPaths } from "./ffmpeg.ts"
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Level of downscaling applied to animated thumbnails: they use 1/3 the
|
|
18
|
+
* still-image area cap, which keeps animated WebP file sizes sane.
|
|
19
|
+
*/
|
|
20
|
+
export const ANIMATED_AREA_DIVISOR = 3
|
|
21
|
+
|
|
22
|
+
/** Regexp matching the temp-file suffix written by {@link encodeImageFile}. */
|
|
23
|
+
const ORPHANED_TEMP_RE = /\.writing-\d+-\d+$/
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Recursively remove any `.writing-<pid>-<ts>` temp files left behind by a
|
|
27
|
+
* previous process crash. Safe to call fire-and-forget at startup.
|
|
28
|
+
*/
|
|
29
|
+
export async function cleanOrphanedTempFiles(thumbRoot: string): Promise<void> {
|
|
30
|
+
await removeOrphanedInDir(thumbRoot)
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
async function removeOrphanedInDir(dir: string): Promise<void> {
|
|
34
|
+
let entries: string[]
|
|
35
|
+
try {
|
|
36
|
+
entries = await readdir(dir)
|
|
37
|
+
} catch {
|
|
38
|
+
return
|
|
39
|
+
}
|
|
40
|
+
await Promise.all(
|
|
41
|
+
entries.map(async (entry) => {
|
|
42
|
+
const full = join(dir, entry)
|
|
43
|
+
if (ORPHANED_TEMP_RE.test(entry)) {
|
|
44
|
+
await rm(full, { force: true }).catch(() => {})
|
|
45
|
+
} else {
|
|
46
|
+
await removeOrphanedInDir(full)
|
|
47
|
+
}
|
|
48
|
+
}),
|
|
49
|
+
)
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export const WEBP_QUALITY = 82
|
|
53
|
+
export const PREVIEW_WEBP_QUALITY = 90
|
|
54
|
+
export const AVIF_QUALITY = 65
|
|
55
|
+
export const PREVIEW_AVIF_QUALITY = 70
|
|
56
|
+
/**
|
|
57
|
+
* Level of CPU effort the AVIF encoder spends reducing file size
|
|
58
|
+
* (0 = fastest/largest … 9 = slowest/smallest; sharp's default is 4).
|
|
59
|
+
* Thumbs are cache-once artifacts re-encoded in bulk by the precache
|
|
60
|
+
* sweep, where AVIF encode dominates wall time; effort 2 cuts encode
|
|
61
|
+
* cost substantially for a few percent larger output — measured on the
|
|
62
|
+
* precache bench (see bench/precache.bench.ts).
|
|
63
|
+
*/
|
|
64
|
+
export const AVIF_EFFORT = 2
|
|
65
|
+
|
|
66
|
+
export type RenderResult = {
|
|
67
|
+
readonly path: string
|
|
68
|
+
readonly bytes: number
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export type ImageThumbInput =
|
|
72
|
+
| string
|
|
73
|
+
| Buffer
|
|
74
|
+
| {
|
|
75
|
+
readonly openStream: () => Promise<Readable>
|
|
76
|
+
/**
|
|
77
|
+
* Byte-range read into the underlying source. Lets the metadata
|
|
78
|
+
* step read a small header window instead of streaming the
|
|
79
|
+
* whole entry — libvips full-reads non-seekable input.
|
|
80
|
+
*/
|
|
81
|
+
readonly readRange?: (start: number, end: number) => Promise<Buffer>
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function isReopenableImageStream(input: ImageThumbInput): input is {
|
|
85
|
+
readonly openStream: () => Promise<Readable>
|
|
86
|
+
readonly readRange?: (start: number, end: number) => Promise<Buffer>
|
|
87
|
+
} {
|
|
88
|
+
return (
|
|
89
|
+
typeof input === "object" &&
|
|
90
|
+
input !== null &&
|
|
91
|
+
!Buffer.isBuffer(input) &&
|
|
92
|
+
"openStream" in input
|
|
93
|
+
)
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export type ImageThumbRenderResult = RenderResult & {
|
|
97
|
+
readonly format: "webp" | "avif"
|
|
98
|
+
readonly displayWidth: number
|
|
99
|
+
readonly displayHeight: number
|
|
100
|
+
readonly animated: boolean
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export type VideoFrameSource = string | Readable
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Encode an image thumb in a single sharp pipeline: one metadata read,
|
|
107
|
+
* animation detection, resize, and encode. Encodes to the variant's
|
|
108
|
+
* `format`, downgrading an animated source to WebP (sharp cannot encode
|
|
109
|
+
* animated AVIF).
|
|
110
|
+
*/
|
|
111
|
+
export async function renderImageThumbOnce(opts: {
|
|
112
|
+
readonly input: ImageThumbInput
|
|
113
|
+
readonly resolveDest: (fmt: "webp" | "avif") => string
|
|
114
|
+
/**
|
|
115
|
+
* The resolved render plan: codec, fit policy, area cap and
|
|
116
|
+
* per-format qualities. The pipeline is a pure consumer of the
|
|
117
|
+
* variant — everything that decides the output bytes lives in it.
|
|
118
|
+
*/
|
|
119
|
+
readonly variant: ResolvedImageVariant
|
|
120
|
+
readonly ext?: string
|
|
121
|
+
}): Promise<ImageThumbRenderResult> {
|
|
122
|
+
const ext =
|
|
123
|
+
opts.ext ??
|
|
124
|
+
(typeof opts.input === "string" ? extname(opts.input).toLowerCase() : "")
|
|
125
|
+
const { meta, animated } = await readImageMetadata(opts.input, ext)
|
|
126
|
+
const h = meta.pageHeight ?? meta.height
|
|
127
|
+
if (meta.width === undefined || h === undefined) {
|
|
128
|
+
throw new Error("image source is missing dimensions")
|
|
129
|
+
}
|
|
130
|
+
const requested = opts.variant.format
|
|
131
|
+
const format = animated && requested === "avif" ? "webp" : requested
|
|
132
|
+
const area = animated
|
|
133
|
+
? Math.floor(opts.variant.maxArea / ANIMATED_AREA_DIVISOR)
|
|
134
|
+
: opts.variant.maxArea
|
|
135
|
+
const dims =
|
|
136
|
+
opts.variant.fit === "exact"
|
|
137
|
+
? { width: meta.width, height: h }
|
|
138
|
+
: fitInsideArea(meta.width, h, area)
|
|
139
|
+
const destPath = opts.resolveDest(format)
|
|
140
|
+
await mkdir(dirname(destPath), { recursive: true })
|
|
141
|
+
const pipeline = isReopenableImageStream(opts.input)
|
|
142
|
+
? await sharpFromReadable(await opts.input.openStream(), {
|
|
143
|
+
pages: animated ? -1 : 1,
|
|
144
|
+
animated: animated || undefined,
|
|
145
|
+
})
|
|
146
|
+
: sharp(
|
|
147
|
+
opts.input,
|
|
148
|
+
sharpImageInputOpts(opts.input, ext, animated ? -1 : 1, animated),
|
|
149
|
+
)
|
|
150
|
+
const result = await encodeImageFile(
|
|
151
|
+
pipeline,
|
|
152
|
+
destPath,
|
|
153
|
+
{
|
|
154
|
+
// `exact` carries no resize bounds: the encoder writes the
|
|
155
|
+
// source pixels verbatim.
|
|
156
|
+
maxWidth: opts.variant.fit === "exact" ? undefined : dims.width,
|
|
157
|
+
maxHeight: opts.variant.fit === "exact" ? undefined : dims.height,
|
|
158
|
+
quality:
|
|
159
|
+
format === "webp" ? opts.variant.webpQuality : opts.variant.avifQuality,
|
|
160
|
+
animated: animated || undefined,
|
|
161
|
+
},
|
|
162
|
+
format,
|
|
163
|
+
)
|
|
164
|
+
return {
|
|
165
|
+
...result,
|
|
166
|
+
format,
|
|
167
|
+
displayWidth: dims.width,
|
|
168
|
+
displayHeight: dims.height,
|
|
169
|
+
animated,
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* Encode `sourcePath` to WebP at `destPath`, sized so that
|
|
175
|
+
* `width × height < maxArea`. Animated sources are kept animated.
|
|
176
|
+
* @throws `Error` from sharp.
|
|
177
|
+
*/
|
|
178
|
+
export async function renderImageWithArea(
|
|
179
|
+
sourcePath: string,
|
|
180
|
+
destPath: string,
|
|
181
|
+
maxArea: number,
|
|
182
|
+
quality: number,
|
|
183
|
+
animated: boolean,
|
|
184
|
+
): Promise<RenderResult> {
|
|
185
|
+
await mkdir(dirname(destPath), { recursive: true })
|
|
186
|
+
return encodeSharpWithArea({
|
|
187
|
+
pipeline: sharp(sourcePath, { animated }),
|
|
188
|
+
destPath,
|
|
189
|
+
maxArea,
|
|
190
|
+
quality,
|
|
191
|
+
format: "webp",
|
|
192
|
+
animated,
|
|
193
|
+
})
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/**
|
|
197
|
+
* Capture a frame from a file path or readable stream via ffmpeg and
|
|
198
|
+
* encode, sized so that `width × height < maxArea`.
|
|
199
|
+
* @throws `Error` with ffmpeg stderr when ffmpeg exits non-zero.
|
|
200
|
+
*/
|
|
201
|
+
export async function renderVideoFrame(opts: {
|
|
202
|
+
readonly source: VideoFrameSource
|
|
203
|
+
readonly destPath: string
|
|
204
|
+
readonly ffmpeg: FfmpegPaths
|
|
205
|
+
readonly maxArea: number
|
|
206
|
+
readonly quality: number
|
|
207
|
+
readonly format?: "webp" | "avif"
|
|
208
|
+
readonly timeSeconds?: number
|
|
209
|
+
/** Required when `source` is a stream — ffmpeg cannot infer container from `pipe:0`. */
|
|
210
|
+
readonly ext?: string
|
|
211
|
+
}): Promise<RenderResult> {
|
|
212
|
+
return renderFfmpegStill({
|
|
213
|
+
...opts,
|
|
214
|
+
inputFormat:
|
|
215
|
+
opts.ext === undefined ? undefined : extToFfmpegInputFormat(opts.ext),
|
|
216
|
+
})
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/**
|
|
220
|
+
* Extract an audio file's embedded artwork (ID3 APIC, FLAC PICTURE, MP4
|
|
221
|
+
* `covr`) and encode it as a thumbnail. ffmpeg exposes the artwork as a
|
|
222
|
+
* still video stream, so this rides the same still-frame pipeline as
|
|
223
|
+
* {@link renderVideoFrame} — only the container hint differs.
|
|
224
|
+
* @throws `Error` with ffmpeg stderr when the file carries no artwork.
|
|
225
|
+
*/
|
|
226
|
+
export async function renderAudioCoverArt(opts: {
|
|
227
|
+
readonly source: VideoFrameSource
|
|
228
|
+
readonly destPath: string
|
|
229
|
+
readonly ffmpeg: FfmpegPaths
|
|
230
|
+
readonly maxArea: number
|
|
231
|
+
readonly quality: number
|
|
232
|
+
readonly format?: "webp" | "avif"
|
|
233
|
+
/** Required when `source` is a stream — ffmpeg cannot infer container from `pipe:0`. */
|
|
234
|
+
readonly ext?: string
|
|
235
|
+
}): Promise<RenderResult> {
|
|
236
|
+
return renderFfmpegStill({
|
|
237
|
+
...opts,
|
|
238
|
+
inputFormat:
|
|
239
|
+
opts.ext === undefined ? undefined : extToAudioInputFormat(opts.ext),
|
|
240
|
+
})
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
async function renderFfmpegStill(opts: {
|
|
244
|
+
readonly source: VideoFrameSource
|
|
245
|
+
readonly destPath: string
|
|
246
|
+
readonly ffmpeg: FfmpegPaths
|
|
247
|
+
readonly maxArea: number
|
|
248
|
+
readonly quality: number
|
|
249
|
+
readonly format?: "webp" | "avif"
|
|
250
|
+
readonly timeSeconds?: number
|
|
251
|
+
readonly inputFormat?: string
|
|
252
|
+
}): Promise<RenderResult> {
|
|
253
|
+
await mkdir(dirname(opts.destPath), { recursive: true })
|
|
254
|
+
const jpeg = await extractFrameAtTimeJpeg({
|
|
255
|
+
source: opts.source,
|
|
256
|
+
timeSeconds: opts.timeSeconds ?? 0,
|
|
257
|
+
ffmpeg: opts.ffmpeg,
|
|
258
|
+
inputFormat: opts.inputFormat,
|
|
259
|
+
})
|
|
260
|
+
return encodeSharpWithArea({
|
|
261
|
+
pipeline: sharp(jpeg),
|
|
262
|
+
destPath: opts.destPath,
|
|
263
|
+
maxArea: opts.maxArea,
|
|
264
|
+
quality: opts.quality,
|
|
265
|
+
format: opts.format ?? "avif",
|
|
266
|
+
animated: false,
|
|
267
|
+
})
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
type EncodeImageFileOptions = {
|
|
271
|
+
/**
|
|
272
|
+
* Resize bounds (fit `inside`, never enlarged). When both are
|
|
273
|
+
* omitted the source pixels are encoded verbatim — the `exact`
|
|
274
|
+
* variant's transcode-only path.
|
|
275
|
+
*/
|
|
276
|
+
readonly maxWidth?: number
|
|
277
|
+
readonly maxHeight?: number
|
|
278
|
+
readonly quality: number
|
|
279
|
+
/**
|
|
280
|
+
* When `true`, skip `.rotate()` (sharp's auto-EXIF-orient call which
|
|
281
|
+
* collapses animated input to a single frame) so the encoded WebP
|
|
282
|
+
* keeps every page. Animated sources never carry useful EXIF
|
|
283
|
+
* orientation, so dropping the rotate is safe.
|
|
284
|
+
*/
|
|
285
|
+
readonly animated?: boolean
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
/**
|
|
289
|
+
* Encode a sharp pipeline to `destPath`, sized so that
|
|
290
|
+
* `width × height < maxArea`.
|
|
291
|
+
*/
|
|
292
|
+
async function encodeSharpWithArea(opts: {
|
|
293
|
+
pipeline: Sharp
|
|
294
|
+
destPath: string
|
|
295
|
+
maxArea: number
|
|
296
|
+
quality: number
|
|
297
|
+
format: "webp" | "avif"
|
|
298
|
+
animated: boolean
|
|
299
|
+
}): Promise<RenderResult> {
|
|
300
|
+
const meta = await opts.pipeline.metadata()
|
|
301
|
+
const h = meta.pageHeight ?? meta.height
|
|
302
|
+
const dims =
|
|
303
|
+
meta.width !== undefined && h !== undefined
|
|
304
|
+
? fitInsideArea(meta.width, h, opts.maxArea)
|
|
305
|
+
: undefined
|
|
306
|
+
return encodeImageFile(
|
|
307
|
+
opts.pipeline,
|
|
308
|
+
opts.destPath,
|
|
309
|
+
{
|
|
310
|
+
maxWidth: dims?.width,
|
|
311
|
+
maxHeight: dims?.height,
|
|
312
|
+
quality: opts.quality,
|
|
313
|
+
animated: opts.animated || undefined,
|
|
314
|
+
},
|
|
315
|
+
opts.format,
|
|
316
|
+
)
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
async function encodeImageFile(
|
|
320
|
+
pipeline: Sharp,
|
|
321
|
+
destPath: string,
|
|
322
|
+
opts: EncodeImageFileOptions,
|
|
323
|
+
format: "webp" | "avif",
|
|
324
|
+
): Promise<RenderResult> {
|
|
325
|
+
const tmp = `${destPath}.writing-${process.pid}-${Date.now()}`
|
|
326
|
+
try {
|
|
327
|
+
const oriented = opts.animated === true ? pipeline : pipeline.rotate()
|
|
328
|
+
const resized =
|
|
329
|
+
opts.maxWidth === undefined && opts.maxHeight === undefined
|
|
330
|
+
? oriented
|
|
331
|
+
: oriented.resize({
|
|
332
|
+
width: opts.maxWidth,
|
|
333
|
+
height: opts.maxHeight,
|
|
334
|
+
fit: "inside",
|
|
335
|
+
withoutEnlargement: true,
|
|
336
|
+
})
|
|
337
|
+
const encoded =
|
|
338
|
+
format === "webp"
|
|
339
|
+
? resized.webp({ quality: opts.quality })
|
|
340
|
+
: resized.avif({ quality: opts.quality, effort: AVIF_EFFORT })
|
|
341
|
+
const info = await encoded.toFile(tmp)
|
|
342
|
+
await rename(tmp, destPath)
|
|
343
|
+
return { path: destPath, bytes: info.size }
|
|
344
|
+
} catch (err) {
|
|
345
|
+
await rm(tmp, { force: true }).catch(() => {})
|
|
346
|
+
throw err
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
/**
|
|
351
|
+
* Compute target pixel dimensions that keep the aspect ratio while
|
|
352
|
+
* guaranteeing `width × height < maxArea`.
|
|
353
|
+
*/
|
|
354
|
+
export function fitInsideArea(
|
|
355
|
+
srcWidth: number,
|
|
356
|
+
srcHeight: number,
|
|
357
|
+
maxArea: number,
|
|
358
|
+
): { readonly width: number; readonly height: number } {
|
|
359
|
+
const area = srcWidth * srcHeight
|
|
360
|
+
if (area <= maxArea) return { width: srcWidth, height: srcHeight }
|
|
361
|
+
const scale = Math.sqrt(maxArea / area)
|
|
362
|
+
return {
|
|
363
|
+
width: Math.max(1, Math.floor(srcWidth * scale)),
|
|
364
|
+
height: Math.max(1, Math.floor(srcHeight * scale)),
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
function extractFrameAtTimeJpeg(opts: {
|
|
369
|
+
source: VideoFrameSource
|
|
370
|
+
timeSeconds: number
|
|
371
|
+
ffmpeg: FfmpegPaths
|
|
372
|
+
inputFormat?: string
|
|
373
|
+
}): Promise<Buffer> {
|
|
374
|
+
if (typeof opts.source === "string") {
|
|
375
|
+
return extractFrameAtTimeJpegFromPath(opts.source, opts)
|
|
376
|
+
}
|
|
377
|
+
return extractFrameAtTimeJpegFromStream(opts.source, opts)
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
function extractFrameAtTimeJpegFromPath(
|
|
381
|
+
sourcePath: string,
|
|
382
|
+
opts: {
|
|
383
|
+
timeSeconds: number
|
|
384
|
+
ffmpeg: FfmpegPaths
|
|
385
|
+
},
|
|
386
|
+
): Promise<Buffer> {
|
|
387
|
+
const args = ["-hide_banner", "-loglevel", "error"]
|
|
388
|
+
if (opts.timeSeconds > 0) {
|
|
389
|
+
args.push("-ss", String(opts.timeSeconds))
|
|
390
|
+
}
|
|
391
|
+
args.push("-i", sourcePath)
|
|
392
|
+
args.push(
|
|
393
|
+
"-vf",
|
|
394
|
+
"scale=iw*sar:ih,scale='min(1024,iw)':-2",
|
|
395
|
+
"-frames:v",
|
|
396
|
+
"1",
|
|
397
|
+
"-f",
|
|
398
|
+
"image2",
|
|
399
|
+
"-vcodec",
|
|
400
|
+
"mjpeg",
|
|
401
|
+
"pipe:1",
|
|
402
|
+
)
|
|
403
|
+
return runFfmpegJpegExtract(args, opts.ffmpeg, undefined)
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
function extractFrameAtTimeJpegFromStream(
|
|
407
|
+
source: Readable,
|
|
408
|
+
opts: {
|
|
409
|
+
timeSeconds: number
|
|
410
|
+
ffmpeg: FfmpegPaths
|
|
411
|
+
inputFormat?: string
|
|
412
|
+
},
|
|
413
|
+
): Promise<Buffer> {
|
|
414
|
+
if (opts.inputFormat === undefined) {
|
|
415
|
+
return Promise.reject(
|
|
416
|
+
new Error("stream video frame extraction requires an input format hint"),
|
|
417
|
+
)
|
|
418
|
+
}
|
|
419
|
+
const args = ["-hide_banner", "-loglevel", "error"]
|
|
420
|
+
args.push("-probesize", "100M", "-analyzeduration", "100M")
|
|
421
|
+
args.push("-f", opts.inputFormat, "-i", "pipe:0")
|
|
422
|
+
if (opts.timeSeconds > 0) {
|
|
423
|
+
args.push("-ss", String(opts.timeSeconds))
|
|
424
|
+
}
|
|
425
|
+
args.push(
|
|
426
|
+
"-vf",
|
|
427
|
+
"scale=iw*sar:ih,scale='min(1024,iw)':-2",
|
|
428
|
+
"-frames:v",
|
|
429
|
+
"1",
|
|
430
|
+
"-f",
|
|
431
|
+
"image2",
|
|
432
|
+
"-vcodec",
|
|
433
|
+
"mjpeg",
|
|
434
|
+
"pipe:1",
|
|
435
|
+
)
|
|
436
|
+
return runFfmpegJpegExtract(args, opts.ffmpeg, source)
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
function runFfmpegJpegExtract(
|
|
440
|
+
args: string[],
|
|
441
|
+
ffmpeg: FfmpegPaths,
|
|
442
|
+
source: Readable | undefined,
|
|
443
|
+
): Promise<Buffer> {
|
|
444
|
+
return new Promise((resolve, reject) => {
|
|
445
|
+
const fromStream = source !== undefined
|
|
446
|
+
const child = spawn(ffmpeg.ffmpeg, args, {
|
|
447
|
+
stdio: fromStream ? ["pipe", "pipe", "pipe"] : ["ignore", "pipe", "pipe"],
|
|
448
|
+
})
|
|
449
|
+
const stdout = child.stdout
|
|
450
|
+
const stderr = child.stderr
|
|
451
|
+
if (stdout === null || stderr === null) {
|
|
452
|
+
reject(new Error("ffmpeg stdio was not configured"))
|
|
453
|
+
return
|
|
454
|
+
}
|
|
455
|
+
const stdoutChunks: Buffer[] = []
|
|
456
|
+
const stderrChunks: Buffer[] = []
|
|
457
|
+
stdout.on("data", (chunk: Buffer) => stdoutChunks.push(chunk))
|
|
458
|
+
stderr.on("data", (chunk: Buffer) => stderrChunks.push(chunk))
|
|
459
|
+
if (source !== undefined) {
|
|
460
|
+
source.on("error", reject)
|
|
461
|
+
source.pipe(child.stdin!)
|
|
462
|
+
child.stdin?.on("error", () => {})
|
|
463
|
+
}
|
|
464
|
+
child.on("error", reject)
|
|
465
|
+
child.on("close", (code) => {
|
|
466
|
+
if (code !== 0) {
|
|
467
|
+
const msg = Buffer.concat(stderrChunks).toString("utf8").trim()
|
|
468
|
+
reject(new Error(`ffmpeg exited ${code}${msg ? `: ${msg}` : ""}`))
|
|
469
|
+
return
|
|
470
|
+
}
|
|
471
|
+
const out = Buffer.concat(stdoutChunks)
|
|
472
|
+
if (out.length === 0) {
|
|
473
|
+
reject(new Error("ffmpeg produced no output"))
|
|
474
|
+
return
|
|
475
|
+
}
|
|
476
|
+
resolve(out)
|
|
477
|
+
})
|
|
478
|
+
})
|
|
479
|
+
}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import type { ResourceAPI } from "../types.ts"
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* A hook invocation awaiting a worker response. Carries the call's
|
|
5
|
+
* progress state plus the two timers policing it: the inactivity
|
|
6
|
+
* watchdog and the absolute hard timeout.
|
|
7
|
+
*/
|
|
8
|
+
export type PendingCall = {
|
|
9
|
+
readonly api: ResourceAPI
|
|
10
|
+
readonly resolve: (value: unknown) => void
|
|
11
|
+
readonly reject: (err: Error) => void
|
|
12
|
+
/**
|
|
13
|
+
* Host-side API dispatches currently running for this call. While any
|
|
14
|
+
* are in flight the watchdog is paused — the plugin is blocked on the
|
|
15
|
+
* host, not hung.
|
|
16
|
+
*/
|
|
17
|
+
apiInFlight: number
|
|
18
|
+
watchdog?: NodeJS.Timeout
|
|
19
|
+
hardTimer?: NodeJS.Timeout
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export type CallTimers = {
|
|
23
|
+
/** (Re)arm the inactivity watchdog, replacing any previous one. */
|
|
24
|
+
readonly armWatchdog: (call: PendingCall, onExpire: () => void) => void
|
|
25
|
+
/** Arm the absolute per-invocation cap. */
|
|
26
|
+
readonly armHardTimer: (call: PendingCall, onExpire: () => void) => void
|
|
27
|
+
/** Suspend the watchdog while host-side API work is in flight. */
|
|
28
|
+
readonly pauseWatchdog: (call: PendingCall) => void
|
|
29
|
+
/** Cancel both timers of a call (settled or abandoned). */
|
|
30
|
+
readonly clearCallTimers: (call: PendingCall) => void
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Timer bookkeeping for sandboxed hook invocations: the inactivity
|
|
35
|
+
* watchdog (`watchdogMs`) and the absolute cap (`hardTimeoutMs`). Expiry
|
|
36
|
+
* callbacks are supplied per arm by the owner — which knows the owning
|
|
37
|
+
* state and worker — so this module stays decoupled from the sandbox
|
|
38
|
+
* lifecycle.
|
|
39
|
+
*/
|
|
40
|
+
export function createCallTimers(opts: {
|
|
41
|
+
readonly watchdogMs: number
|
|
42
|
+
readonly hardTimeoutMs: number
|
|
43
|
+
}): CallTimers {
|
|
44
|
+
function armWatchdog(call: PendingCall, onExpire: () => void): void {
|
|
45
|
+
if (call.watchdog !== undefined) clearTimeout(call.watchdog)
|
|
46
|
+
call.watchdog = setTimeout(onExpire, opts.watchdogMs)
|
|
47
|
+
call.watchdog.unref()
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function armHardTimer(call: PendingCall, onExpire: () => void): void {
|
|
51
|
+
if (call.hardTimer !== undefined) clearTimeout(call.hardTimer)
|
|
52
|
+
call.hardTimer = setTimeout(onExpire, opts.hardTimeoutMs)
|
|
53
|
+
call.hardTimer.unref()
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function pauseWatchdog(call: PendingCall): void {
|
|
57
|
+
if (call.watchdog === undefined) return
|
|
58
|
+
clearTimeout(call.watchdog)
|
|
59
|
+
call.watchdog = undefined
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function clearCallTimers(call: PendingCall): void {
|
|
63
|
+
if (call.watchdog !== undefined) clearTimeout(call.watchdog)
|
|
64
|
+
if (call.hardTimer !== undefined) clearTimeout(call.hardTimer)
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
return { armWatchdog, armHardTimer, pauseWatchdog, clearCallTimers }
|
|
68
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
// Fixture plugin that surfaces host-side API errors through the RPC boundary.
|
|
2
|
+
export default {
|
|
3
|
+
detect: async (api) => {
|
|
4
|
+
try {
|
|
5
|
+
await api.readFile("missing.bin")
|
|
6
|
+
} catch (err) {
|
|
7
|
+
return { ok: false, reasons: [`api said: ${err.message}`] }
|
|
8
|
+
}
|
|
9
|
+
return { ok: true }
|
|
10
|
+
},
|
|
11
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
// Sandbox fixture: exercises the plugin asset API within detect — reads
|
|
2
|
+
// the vault stat, then performs a consent-gated download. Used to verify
|
|
3
|
+
// the manifest permission gate and the wired plugin asset handler.
|
|
4
|
+
export default {
|
|
5
|
+
detect: async (api) => {
|
|
6
|
+
const stat = await api.statAsset("runtime/a.mjs")
|
|
7
|
+
const downloaded = await api.download({
|
|
8
|
+
url: "https://example.com/runtime/a.mjs",
|
|
9
|
+
dest: "runtime/a.mjs",
|
|
10
|
+
})
|
|
11
|
+
return { ok: true, stat, downloaded }
|
|
12
|
+
},
|
|
13
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
// Fixture plugin that keeps showing API activity for longer than the test
|
|
2
|
+
// watchdog window — must NOT be killed.
|
|
3
|
+
const sleep = (ms) => new Promise((r) => setTimeout(r, ms))
|
|
4
|
+
|
|
5
|
+
export default {
|
|
6
|
+
detect: async (api) => {
|
|
7
|
+
for (let i = 0; i < 5; i++) {
|
|
8
|
+
await sleep(100)
|
|
9
|
+
await api.statFile("tick")
|
|
10
|
+
api.logInfo("tick", { i })
|
|
11
|
+
}
|
|
12
|
+
return { ok: true }
|
|
13
|
+
},
|
|
14
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
// Fixture: uses the container API surface gated by the `container`
|
|
2
|
+
// manifest permission — without it the sandbox denies the RPC.
|
|
3
|
+
export default {
|
|
4
|
+
detect: async (api) => {
|
|
5
|
+
const listing = await api.listContainer("book.cbz")
|
|
6
|
+
return { ok: true, entries: listing.entries.length }
|
|
7
|
+
},
|
|
8
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
// Fixture plugin: detect returns its classification spread on ok(), and
|
|
2
|
+
// later hooks read it back via api.context.detect — the one-pass
|
|
3
|
+
// classification flow.
|
|
4
|
+
export default {
|
|
5
|
+
async detect(api) {
|
|
6
|
+
const files = await api.listFileNames()
|
|
7
|
+
return { ok: true, files, archive: files.length === 1 }
|
|
8
|
+
},
|
|
9
|
+
async sourceMeta(api) {
|
|
10
|
+
const shape = api.context.detect
|
|
11
|
+
return shape === undefined
|
|
12
|
+
? { fromContext: false }
|
|
13
|
+
: {
|
|
14
|
+
fromContext: true,
|
|
15
|
+
files: shape.files,
|
|
16
|
+
archive: shape.archive,
|
|
17
|
+
}
|
|
18
|
+
},
|
|
19
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
// Well-behaved fixture plugin: exercises listFiles/readFile/statFile and
|
|
2
|
+
// echoes values back so tests can assert RPC routing and binary transfer.
|
|
3
|
+
export default {
|
|
4
|
+
detect: async (api) => {
|
|
5
|
+
const files = await api.listFileNames()
|
|
6
|
+
return files.length > 0 ? { ok: true } : { ok: false, reasons: ["empty"] }
|
|
7
|
+
},
|
|
8
|
+
sourceMeta: async (api) => {
|
|
9
|
+
const bytes = await api.readFile("blob.bin")
|
|
10
|
+
return { bytes: Array.from(bytes) }
|
|
11
|
+
},
|
|
12
|
+
// No searchMeta / coverLocal on purpose — tests assert hook presence.
|
|
13
|
+
listFiles: async (api) => {
|
|
14
|
+
const stat = await api.statFile("id")
|
|
15
|
+
return [String(stat?.sizeBytes ?? -1)]
|
|
16
|
+
},
|
|
17
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
// Hostile fixture: the ambient `fetch` global must be scrubbed before the
|
|
2
|
+
// plugin code runs — calling it throws instead of reaching the network.
|
|
3
|
+
export default {
|
|
4
|
+
detect: async () => {
|
|
5
|
+
await fetch("https://example.com/exfil")
|
|
6
|
+
return { ok: true }
|
|
7
|
+
},
|
|
8
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
// Fixture: exceeds the per-hook API-call budget to prove a runaway RPC
|
|
2
|
+
// fan-out fails the hook instead of pinning the host's CPU.
|
|
3
|
+
export default {
|
|
4
|
+
detect: async (api) => {
|
|
5
|
+
for (let i = 0; i < 30; i++) await api.listFileNames()
|
|
6
|
+
return { ok: true }
|
|
7
|
+
},
|
|
8
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
// Hostile fixture: a computed dynamic import of a denied builtin. Static
|
|
2
|
+
// scans cannot catch it — the runtime policy gate must.
|
|
3
|
+
export default {
|
|
4
|
+
detect: async () => {
|
|
5
|
+
const mod = await import("node:" + "fs")
|
|
6
|
+
mod.readFileSync("/outside.txt")
|
|
7
|
+
return { ok: true }
|
|
8
|
+
},
|
|
9
|
+
}
|