@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,125 @@
|
|
|
1
|
+
import { Readable } from 'node:stream';
|
|
2
|
+
import { ResolvedImageVariant } from '@hoardodile/sdk-types/image-variant';
|
|
3
|
+
|
|
4
|
+
type FfmpegPaths = {
|
|
5
|
+
readonly ffmpeg: string;
|
|
6
|
+
readonly ffprobe: string;
|
|
7
|
+
};
|
|
8
|
+
type ResolveDeps = {
|
|
9
|
+
readonly env?: NodeJS.ProcessEnv;
|
|
10
|
+
/** Override for tests so they never hit the real module. */
|
|
11
|
+
readonly loadStatic?: () => string | undefined;
|
|
12
|
+
/** Override for tests so they never hit the real module. */
|
|
13
|
+
readonly loadStaticFfprobe?: () => string | undefined;
|
|
14
|
+
};
|
|
15
|
+
declare function resolveFfmpegPaths(deps?: ResolveDeps): FfmpegPaths;
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Level of downscaling applied to animated thumbnails: they use 1/3 the
|
|
19
|
+
* still-image area cap, which keeps animated WebP file sizes sane.
|
|
20
|
+
*/
|
|
21
|
+
declare const ANIMATED_AREA_DIVISOR = 3;
|
|
22
|
+
/**
|
|
23
|
+
* Recursively remove any `.writing-<pid>-<ts>` temp files left behind by a
|
|
24
|
+
* previous process crash. Safe to call fire-and-forget at startup.
|
|
25
|
+
*/
|
|
26
|
+
declare function cleanOrphanedTempFiles(thumbRoot: string): Promise<void>;
|
|
27
|
+
declare const WEBP_QUALITY = 82;
|
|
28
|
+
declare const PREVIEW_WEBP_QUALITY = 90;
|
|
29
|
+
declare const AVIF_QUALITY = 65;
|
|
30
|
+
declare const PREVIEW_AVIF_QUALITY = 70;
|
|
31
|
+
/**
|
|
32
|
+
* Level of CPU effort the AVIF encoder spends reducing file size
|
|
33
|
+
* (0 = fastest/largest … 9 = slowest/smallest; sharp's default is 4).
|
|
34
|
+
* Thumbs are cache-once artifacts re-encoded in bulk by the precache
|
|
35
|
+
* sweep, where AVIF encode dominates wall time; effort 2 cuts encode
|
|
36
|
+
* cost substantially for a few percent larger output — measured on the
|
|
37
|
+
* precache bench (see bench/precache.bench.ts).
|
|
38
|
+
*/
|
|
39
|
+
declare const AVIF_EFFORT = 2;
|
|
40
|
+
type RenderResult = {
|
|
41
|
+
readonly path: string;
|
|
42
|
+
readonly bytes: number;
|
|
43
|
+
};
|
|
44
|
+
type ImageThumbInput = string | Buffer | {
|
|
45
|
+
readonly openStream: () => Promise<Readable>;
|
|
46
|
+
/**
|
|
47
|
+
* Byte-range read into the underlying source. Lets the metadata
|
|
48
|
+
* step read a small header window instead of streaming the
|
|
49
|
+
* whole entry — libvips full-reads non-seekable input.
|
|
50
|
+
*/
|
|
51
|
+
readonly readRange?: (start: number, end: number) => Promise<Buffer>;
|
|
52
|
+
};
|
|
53
|
+
type ImageThumbRenderResult = RenderResult & {
|
|
54
|
+
readonly format: "webp" | "avif";
|
|
55
|
+
readonly displayWidth: number;
|
|
56
|
+
readonly displayHeight: number;
|
|
57
|
+
readonly animated: boolean;
|
|
58
|
+
};
|
|
59
|
+
type VideoFrameSource = string | Readable;
|
|
60
|
+
/**
|
|
61
|
+
* Encode an image thumb in a single sharp pipeline: one metadata read,
|
|
62
|
+
* animation detection, resize, and encode. Encodes to the variant's
|
|
63
|
+
* `format`, downgrading an animated source to WebP (sharp cannot encode
|
|
64
|
+
* animated AVIF).
|
|
65
|
+
*/
|
|
66
|
+
declare function renderImageThumbOnce(opts: {
|
|
67
|
+
readonly input: ImageThumbInput;
|
|
68
|
+
readonly resolveDest: (fmt: "webp" | "avif") => string;
|
|
69
|
+
/**
|
|
70
|
+
* The resolved render plan: codec, fit policy, area cap and
|
|
71
|
+
* per-format qualities. The pipeline is a pure consumer of the
|
|
72
|
+
* variant — everything that decides the output bytes lives in it.
|
|
73
|
+
*/
|
|
74
|
+
readonly variant: ResolvedImageVariant;
|
|
75
|
+
readonly ext?: string;
|
|
76
|
+
}): Promise<ImageThumbRenderResult>;
|
|
77
|
+
/**
|
|
78
|
+
* Encode `sourcePath` to WebP at `destPath`, sized so that
|
|
79
|
+
* `width × height < maxArea`. Animated sources are kept animated.
|
|
80
|
+
* @throws `Error` from sharp.
|
|
81
|
+
*/
|
|
82
|
+
declare function renderImageWithArea(sourcePath: string, destPath: string, maxArea: number, quality: number, animated: boolean): Promise<RenderResult>;
|
|
83
|
+
/**
|
|
84
|
+
* Capture a frame from a file path or readable stream via ffmpeg and
|
|
85
|
+
* encode, sized so that `width × height < maxArea`.
|
|
86
|
+
* @throws `Error` with ffmpeg stderr when ffmpeg exits non-zero.
|
|
87
|
+
*/
|
|
88
|
+
declare function renderVideoFrame(opts: {
|
|
89
|
+
readonly source: VideoFrameSource;
|
|
90
|
+
readonly destPath: string;
|
|
91
|
+
readonly ffmpeg: FfmpegPaths;
|
|
92
|
+
readonly maxArea: number;
|
|
93
|
+
readonly quality: number;
|
|
94
|
+
readonly format?: "webp" | "avif";
|
|
95
|
+
readonly timeSeconds?: number;
|
|
96
|
+
/** Required when `source` is a stream — ffmpeg cannot infer container from `pipe:0`. */
|
|
97
|
+
readonly ext?: string;
|
|
98
|
+
}): Promise<RenderResult>;
|
|
99
|
+
/**
|
|
100
|
+
* Extract an audio file's embedded artwork (ID3 APIC, FLAC PICTURE, MP4
|
|
101
|
+
* `covr`) and encode it as a thumbnail. ffmpeg exposes the artwork as a
|
|
102
|
+
* still video stream, so this rides the same still-frame pipeline as
|
|
103
|
+
* {@link renderVideoFrame} — only the container hint differs.
|
|
104
|
+
* @throws `Error` with ffmpeg stderr when the file carries no artwork.
|
|
105
|
+
*/
|
|
106
|
+
declare function renderAudioCoverArt(opts: {
|
|
107
|
+
readonly source: VideoFrameSource;
|
|
108
|
+
readonly destPath: string;
|
|
109
|
+
readonly ffmpeg: FfmpegPaths;
|
|
110
|
+
readonly maxArea: number;
|
|
111
|
+
readonly quality: number;
|
|
112
|
+
readonly format?: "webp" | "avif";
|
|
113
|
+
/** Required when `source` is a stream — ffmpeg cannot infer container from `pipe:0`. */
|
|
114
|
+
readonly ext?: string;
|
|
115
|
+
}): Promise<RenderResult>;
|
|
116
|
+
/**
|
|
117
|
+
* Compute target pixel dimensions that keep the aspect ratio while
|
|
118
|
+
* guaranteeing `width × height < maxArea`.
|
|
119
|
+
*/
|
|
120
|
+
declare function fitInsideArea(srcWidth: number, srcHeight: number, maxArea: number): {
|
|
121
|
+
readonly width: number;
|
|
122
|
+
readonly height: number;
|
|
123
|
+
};
|
|
124
|
+
|
|
125
|
+
export { ANIMATED_AREA_DIVISOR, AVIF_EFFORT, AVIF_QUALITY, type FfmpegPaths, type ImageThumbInput, type ImageThumbRenderResult, PREVIEW_AVIF_QUALITY, PREVIEW_WEBP_QUALITY, type RenderResult, type VideoFrameSource, WEBP_QUALITY, cleanOrphanedTempFiles, fitInsideArea, renderAudioCoverArt, renderImageThumbOnce, renderImageWithArea, renderVideoFrame, resolveFfmpegPaths };
|
|
@@ -0,0 +1,430 @@
|
|
|
1
|
+
import { createRequire } from 'module';
|
|
2
|
+
import { spawn } from 'child_process';
|
|
3
|
+
import { readdir, rm, mkdir, rename } from 'fs/promises';
|
|
4
|
+
import { join, extname, dirname } from 'path';
|
|
5
|
+
import sharp from 'sharp';
|
|
6
|
+
import { AUDIO_FFMPEG_INPUT_FORMAT } from '@hoardodile/sdk-types/media-exts';
|
|
7
|
+
import 'crypto';
|
|
8
|
+
import '@hoardodile/sdk-types';
|
|
9
|
+
|
|
10
|
+
// src/render/ffmpeg.ts
|
|
11
|
+
var requireCjs = createRequire(import.meta.url);
|
|
12
|
+
function resolveFfmpegPaths(deps = {}) {
|
|
13
|
+
const env = deps.env ?? process.env;
|
|
14
|
+
const ffmpegEnv = env.FFMPEG_PATH;
|
|
15
|
+
const ffprobeEnv = env.FFPROBE_PATH;
|
|
16
|
+
const staticFfmpeg = ffmpegEnv === void 0 || ffmpegEnv.length === 0 ? (deps.loadStatic ?? loadInstallerFfmpeg)() : void 0;
|
|
17
|
+
const ffmpeg = ffmpegEnv !== void 0 && ffmpegEnv.length > 0 ? ffmpegEnv : staticFfmpeg ?? "ffmpeg";
|
|
18
|
+
const staticFfprobe = ffprobeEnv === void 0 || ffprobeEnv.length === 0 ? (deps.loadStaticFfprobe ?? loadInstallerFfprobe)() : void 0;
|
|
19
|
+
const ffprobe = ffprobeEnv !== void 0 && ffprobeEnv.length > 0 ? ffprobeEnv : staticFfprobe ?? "ffprobe";
|
|
20
|
+
return { ffmpeg, ffprobe };
|
|
21
|
+
}
|
|
22
|
+
function loadInstallerFfmpeg() {
|
|
23
|
+
return loadInstallerPath("ffmpeg-static");
|
|
24
|
+
}
|
|
25
|
+
function loadInstallerFfprobe() {
|
|
26
|
+
return loadInstallerPath("@derhuerst/ffprobe-static");
|
|
27
|
+
}
|
|
28
|
+
function loadInstallerPath(moduleName) {
|
|
29
|
+
try {
|
|
30
|
+
const mod = requireCjs(moduleName);
|
|
31
|
+
if (typeof mod === "string" && mod.length > 0) return mod;
|
|
32
|
+
if (mod !== null && typeof mod === "object" && "path" in mod && typeof mod.path === "string") {
|
|
33
|
+
return mod.path;
|
|
34
|
+
}
|
|
35
|
+
return void 0;
|
|
36
|
+
} catch {
|
|
37
|
+
return void 0;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
function extToAudioInputFormat(ext) {
|
|
41
|
+
return AUDIO_FFMPEG_INPUT_FORMAT[ext.toLowerCase()];
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// src/probe/video.ts
|
|
45
|
+
var FFMPEG_INPUT_FORMAT = {
|
|
46
|
+
".mp4": "mp4",
|
|
47
|
+
".m4v": "mp4",
|
|
48
|
+
".webm": "webm",
|
|
49
|
+
".mov": "mov",
|
|
50
|
+
".mkv": "matroska",
|
|
51
|
+
".avi": "avi"
|
|
52
|
+
};
|
|
53
|
+
function extToFfmpegInputFormat(ext) {
|
|
54
|
+
return FFMPEG_INPUT_FORMAT[ext.toLowerCase()];
|
|
55
|
+
}
|
|
56
|
+
var PROBE_HEADER_BYTES = 256 * 1024;
|
|
57
|
+
|
|
58
|
+
// src/probe/image.ts
|
|
59
|
+
var THUMB_BUFFER_MAX_BYTES = 32 * 1024 * 1024;
|
|
60
|
+
var sharpPromise;
|
|
61
|
+
function loadSharp() {
|
|
62
|
+
sharpPromise ??= import('sharp').catch((err) => {
|
|
63
|
+
sharpPromise = void 0;
|
|
64
|
+
throw new Error(
|
|
65
|
+
`sharp is not installed \u2014 add "sharp" to your dependencies to enable image probing: ${err instanceof Error ? err.message : String(err)}`
|
|
66
|
+
);
|
|
67
|
+
});
|
|
68
|
+
return sharpPromise;
|
|
69
|
+
}
|
|
70
|
+
var ANIMATED_EXT_HINTS = /* @__PURE__ */ new Set([".gif", ".webp", ".avif"]);
|
|
71
|
+
async function sharpFromReadable(stream, options) {
|
|
72
|
+
if (typeof stream.read !== "function") {
|
|
73
|
+
throw new Error("expected a readable stream");
|
|
74
|
+
}
|
|
75
|
+
const { default: sharp2 } = await loadSharp();
|
|
76
|
+
const descriptor = options === void 0 || Object.keys(options).length === 0 ? void 0 : { ...options };
|
|
77
|
+
const instance = descriptor === void 0 ? sharp2() : sharp2(descriptor);
|
|
78
|
+
stream.pipe(instance);
|
|
79
|
+
return instance;
|
|
80
|
+
}
|
|
81
|
+
function isReadable(input) {
|
|
82
|
+
return typeof input === "object" && input !== null && typeof input.pipe === "function" && !("openStream" in input);
|
|
83
|
+
}
|
|
84
|
+
function isReopenableImageStream(input) {
|
|
85
|
+
return typeof input === "object" && input !== null && !Buffer.isBuffer(input) && "openStream" in input && typeof input.openStream === "function";
|
|
86
|
+
}
|
|
87
|
+
function sharpImageInputOpts(input, ext, pages, animated) {
|
|
88
|
+
const opts = { pages };
|
|
89
|
+
if (animated === true) opts.animated = true;
|
|
90
|
+
if (typeof input === "string" && (ext === ".jpg" || ext === ".jpeg" || ext === ".jfif")) {
|
|
91
|
+
opts.sequentialRead = true;
|
|
92
|
+
}
|
|
93
|
+
return opts;
|
|
94
|
+
}
|
|
95
|
+
function needsFullAnimationScan(meta, ext) {
|
|
96
|
+
if (ext === ".gif") return true;
|
|
97
|
+
if (ANIMATED_EXT_HINTS.has(ext) && (meta.pages ?? 1) > 1) return true;
|
|
98
|
+
if (meta.pageHeight !== void 0 && meta.height !== void 0 && meta.pageHeight !== meta.height) {
|
|
99
|
+
return true;
|
|
100
|
+
}
|
|
101
|
+
return false;
|
|
102
|
+
}
|
|
103
|
+
async function bufferCapped(input, maxBytes) {
|
|
104
|
+
const chunks = [];
|
|
105
|
+
let total = 0;
|
|
106
|
+
for await (const chunk of input) {
|
|
107
|
+
const bytes = typeof chunk === "string" ? Buffer.from(chunk) : chunk;
|
|
108
|
+
total += bytes.byteLength;
|
|
109
|
+
if (total > maxBytes) {
|
|
110
|
+
input.destroy();
|
|
111
|
+
throw new Error(`probe input exceeds the ${maxBytes}-byte cap`);
|
|
112
|
+
}
|
|
113
|
+
chunks.push(bytes);
|
|
114
|
+
}
|
|
115
|
+
return Buffer.concat(chunks);
|
|
116
|
+
}
|
|
117
|
+
async function readImageMetadata(input, ext) {
|
|
118
|
+
if (isReopenableImageStream(input)) {
|
|
119
|
+
if (input.readRange !== void 0 && ext !== ".gif") {
|
|
120
|
+
const head = await input.readRange(0, PROBE_HEADER_BYTES);
|
|
121
|
+
if (head.length > 0) {
|
|
122
|
+
try {
|
|
123
|
+
const { default: sharp3 } = await loadSharp();
|
|
124
|
+
const shallowMeta3 = await sharp3(
|
|
125
|
+
head,
|
|
126
|
+
sharpImageInputOpts(head, ext, 1)
|
|
127
|
+
).metadata();
|
|
128
|
+
if (!needsFullAnimationScan(shallowMeta3, ext)) {
|
|
129
|
+
return { meta: shallowMeta3, animated: false };
|
|
130
|
+
}
|
|
131
|
+
} catch {
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
const stream = await input.openStream();
|
|
136
|
+
const shallow2 = await sharpFromReadable(stream, { pages: 1 });
|
|
137
|
+
const shallowMeta2 = await shallow2.metadata();
|
|
138
|
+
if (!needsFullAnimationScan(shallowMeta2, ext)) {
|
|
139
|
+
return { meta: shallowMeta2, animated: false };
|
|
140
|
+
}
|
|
141
|
+
const fullStream = await input.openStream();
|
|
142
|
+
const fullMeta2 = await sharpFromReadable(fullStream, {
|
|
143
|
+
pages: -1,
|
|
144
|
+
animated: true
|
|
145
|
+
}).then((s) => s.metadata());
|
|
146
|
+
return { meta: fullMeta2, animated: (fullMeta2.pages ?? 1) > 1 };
|
|
147
|
+
}
|
|
148
|
+
if (isReadable(input)) {
|
|
149
|
+
if (ext === ".gif") {
|
|
150
|
+
const data = await bufferCapped(input, THUMB_BUFFER_MAX_BYTES);
|
|
151
|
+
return readImageMetadata(data, ext);
|
|
152
|
+
}
|
|
153
|
+
try {
|
|
154
|
+
const shallow2 = await sharpFromReadable(input, { pages: 1 });
|
|
155
|
+
const shallowMeta2 = await shallow2.metadata();
|
|
156
|
+
if (!needsFullAnimationScan(shallowMeta2, ext)) {
|
|
157
|
+
return { meta: shallowMeta2, animated: false };
|
|
158
|
+
}
|
|
159
|
+
return {
|
|
160
|
+
meta: shallowMeta2,
|
|
161
|
+
animated: (shallowMeta2.pages ?? 1) > 1
|
|
162
|
+
};
|
|
163
|
+
} finally {
|
|
164
|
+
input.destroy();
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
const { default: sharp2 } = await loadSharp();
|
|
168
|
+
const shallow = sharp2(input, sharpImageInputOpts(input, ext, 1));
|
|
169
|
+
const shallowMeta = await shallow.metadata();
|
|
170
|
+
if (!needsFullAnimationScan(shallowMeta, ext)) {
|
|
171
|
+
return { meta: shallowMeta, animated: false };
|
|
172
|
+
}
|
|
173
|
+
const fullMeta = await sharp2(
|
|
174
|
+
input,
|
|
175
|
+
sharpImageInputOpts(input, ext, -1, true)
|
|
176
|
+
).metadata();
|
|
177
|
+
return { meta: fullMeta, animated: (fullMeta.pages ?? 1) > 1 };
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
// src/render/pipeline.ts
|
|
181
|
+
var ANIMATED_AREA_DIVISOR = 3;
|
|
182
|
+
var ORPHANED_TEMP_RE = /\.writing-\d+-\d+$/;
|
|
183
|
+
async function cleanOrphanedTempFiles(thumbRoot) {
|
|
184
|
+
await removeOrphanedInDir(thumbRoot);
|
|
185
|
+
}
|
|
186
|
+
async function removeOrphanedInDir(dir) {
|
|
187
|
+
let entries;
|
|
188
|
+
try {
|
|
189
|
+
entries = await readdir(dir);
|
|
190
|
+
} catch {
|
|
191
|
+
return;
|
|
192
|
+
}
|
|
193
|
+
await Promise.all(
|
|
194
|
+
entries.map(async (entry) => {
|
|
195
|
+
const full = join(dir, entry);
|
|
196
|
+
if (ORPHANED_TEMP_RE.test(entry)) {
|
|
197
|
+
await rm(full, { force: true }).catch(() => {
|
|
198
|
+
});
|
|
199
|
+
} else {
|
|
200
|
+
await removeOrphanedInDir(full);
|
|
201
|
+
}
|
|
202
|
+
})
|
|
203
|
+
);
|
|
204
|
+
}
|
|
205
|
+
var WEBP_QUALITY = 82;
|
|
206
|
+
var PREVIEW_WEBP_QUALITY = 90;
|
|
207
|
+
var AVIF_QUALITY = 65;
|
|
208
|
+
var PREVIEW_AVIF_QUALITY = 70;
|
|
209
|
+
var AVIF_EFFORT = 2;
|
|
210
|
+
function isReopenableImageStream2(input) {
|
|
211
|
+
return typeof input === "object" && input !== null && !Buffer.isBuffer(input) && "openStream" in input;
|
|
212
|
+
}
|
|
213
|
+
async function renderImageThumbOnce(opts) {
|
|
214
|
+
const ext = opts.ext ?? (typeof opts.input === "string" ? extname(opts.input).toLowerCase() : "");
|
|
215
|
+
const { meta, animated } = await readImageMetadata(opts.input, ext);
|
|
216
|
+
const h = meta.pageHeight ?? meta.height;
|
|
217
|
+
if (meta.width === void 0 || h === void 0) {
|
|
218
|
+
throw new Error("image source is missing dimensions");
|
|
219
|
+
}
|
|
220
|
+
const requested = opts.variant.format;
|
|
221
|
+
const format = animated && requested === "avif" ? "webp" : requested;
|
|
222
|
+
const area = animated ? Math.floor(opts.variant.maxArea / ANIMATED_AREA_DIVISOR) : opts.variant.maxArea;
|
|
223
|
+
const dims = opts.variant.fit === "exact" ? { width: meta.width, height: h } : fitInsideArea(meta.width, h, area);
|
|
224
|
+
const destPath = opts.resolveDest(format);
|
|
225
|
+
await mkdir(dirname(destPath), { recursive: true });
|
|
226
|
+
const pipeline = isReopenableImageStream2(opts.input) ? await sharpFromReadable(await opts.input.openStream(), {
|
|
227
|
+
pages: animated ? -1 : 1,
|
|
228
|
+
animated: animated || void 0
|
|
229
|
+
}) : sharp(
|
|
230
|
+
opts.input,
|
|
231
|
+
sharpImageInputOpts(opts.input, ext, animated ? -1 : 1, animated)
|
|
232
|
+
);
|
|
233
|
+
const result = await encodeImageFile(
|
|
234
|
+
pipeline,
|
|
235
|
+
destPath,
|
|
236
|
+
{
|
|
237
|
+
// `exact` carries no resize bounds: the encoder writes the
|
|
238
|
+
// source pixels verbatim.
|
|
239
|
+
maxWidth: opts.variant.fit === "exact" ? void 0 : dims.width,
|
|
240
|
+
maxHeight: opts.variant.fit === "exact" ? void 0 : dims.height,
|
|
241
|
+
quality: format === "webp" ? opts.variant.webpQuality : opts.variant.avifQuality,
|
|
242
|
+
animated: animated || void 0
|
|
243
|
+
},
|
|
244
|
+
format
|
|
245
|
+
);
|
|
246
|
+
return {
|
|
247
|
+
...result,
|
|
248
|
+
format,
|
|
249
|
+
displayWidth: dims.width,
|
|
250
|
+
displayHeight: dims.height,
|
|
251
|
+
animated
|
|
252
|
+
};
|
|
253
|
+
}
|
|
254
|
+
async function renderImageWithArea(sourcePath, destPath, maxArea, quality, animated) {
|
|
255
|
+
await mkdir(dirname(destPath), { recursive: true });
|
|
256
|
+
return encodeSharpWithArea({
|
|
257
|
+
pipeline: sharp(sourcePath, { animated }),
|
|
258
|
+
destPath,
|
|
259
|
+
maxArea,
|
|
260
|
+
quality,
|
|
261
|
+
format: "webp",
|
|
262
|
+
animated
|
|
263
|
+
});
|
|
264
|
+
}
|
|
265
|
+
async function renderVideoFrame(opts) {
|
|
266
|
+
return renderFfmpegStill({
|
|
267
|
+
...opts,
|
|
268
|
+
inputFormat: opts.ext === void 0 ? void 0 : extToFfmpegInputFormat(opts.ext)
|
|
269
|
+
});
|
|
270
|
+
}
|
|
271
|
+
async function renderAudioCoverArt(opts) {
|
|
272
|
+
return renderFfmpegStill({
|
|
273
|
+
...opts,
|
|
274
|
+
inputFormat: opts.ext === void 0 ? void 0 : extToAudioInputFormat(opts.ext)
|
|
275
|
+
});
|
|
276
|
+
}
|
|
277
|
+
async function renderFfmpegStill(opts) {
|
|
278
|
+
await mkdir(dirname(opts.destPath), { recursive: true });
|
|
279
|
+
const jpeg = await extractFrameAtTimeJpeg({
|
|
280
|
+
source: opts.source,
|
|
281
|
+
timeSeconds: opts.timeSeconds ?? 0,
|
|
282
|
+
ffmpeg: opts.ffmpeg,
|
|
283
|
+
inputFormat: opts.inputFormat
|
|
284
|
+
});
|
|
285
|
+
return encodeSharpWithArea({
|
|
286
|
+
pipeline: sharp(jpeg),
|
|
287
|
+
destPath: opts.destPath,
|
|
288
|
+
maxArea: opts.maxArea,
|
|
289
|
+
quality: opts.quality,
|
|
290
|
+
format: opts.format ?? "avif",
|
|
291
|
+
animated: false
|
|
292
|
+
});
|
|
293
|
+
}
|
|
294
|
+
async function encodeSharpWithArea(opts) {
|
|
295
|
+
const meta = await opts.pipeline.metadata();
|
|
296
|
+
const h = meta.pageHeight ?? meta.height;
|
|
297
|
+
const dims = meta.width !== void 0 && h !== void 0 ? fitInsideArea(meta.width, h, opts.maxArea) : void 0;
|
|
298
|
+
return encodeImageFile(
|
|
299
|
+
opts.pipeline,
|
|
300
|
+
opts.destPath,
|
|
301
|
+
{
|
|
302
|
+
maxWidth: dims?.width,
|
|
303
|
+
maxHeight: dims?.height,
|
|
304
|
+
quality: opts.quality,
|
|
305
|
+
animated: opts.animated || void 0
|
|
306
|
+
},
|
|
307
|
+
opts.format
|
|
308
|
+
);
|
|
309
|
+
}
|
|
310
|
+
async function encodeImageFile(pipeline, destPath, opts, format) {
|
|
311
|
+
const tmp = `${destPath}.writing-${process.pid}-${Date.now()}`;
|
|
312
|
+
try {
|
|
313
|
+
const oriented = opts.animated === true ? pipeline : pipeline.rotate();
|
|
314
|
+
const resized = opts.maxWidth === void 0 && opts.maxHeight === void 0 ? oriented : oriented.resize({
|
|
315
|
+
width: opts.maxWidth,
|
|
316
|
+
height: opts.maxHeight,
|
|
317
|
+
fit: "inside",
|
|
318
|
+
withoutEnlargement: true
|
|
319
|
+
});
|
|
320
|
+
const encoded = format === "webp" ? resized.webp({ quality: opts.quality }) : resized.avif({ quality: opts.quality, effort: AVIF_EFFORT });
|
|
321
|
+
const info = await encoded.toFile(tmp);
|
|
322
|
+
await rename(tmp, destPath);
|
|
323
|
+
return { path: destPath, bytes: info.size };
|
|
324
|
+
} catch (err) {
|
|
325
|
+
await rm(tmp, { force: true }).catch(() => {
|
|
326
|
+
});
|
|
327
|
+
throw err;
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
function fitInsideArea(srcWidth, srcHeight, maxArea) {
|
|
331
|
+
const area = srcWidth * srcHeight;
|
|
332
|
+
if (area <= maxArea) return { width: srcWidth, height: srcHeight };
|
|
333
|
+
const scale = Math.sqrt(maxArea / area);
|
|
334
|
+
return {
|
|
335
|
+
width: Math.max(1, Math.floor(srcWidth * scale)),
|
|
336
|
+
height: Math.max(1, Math.floor(srcHeight * scale))
|
|
337
|
+
};
|
|
338
|
+
}
|
|
339
|
+
function extractFrameAtTimeJpeg(opts) {
|
|
340
|
+
if (typeof opts.source === "string") {
|
|
341
|
+
return extractFrameAtTimeJpegFromPath(opts.source, opts);
|
|
342
|
+
}
|
|
343
|
+
return extractFrameAtTimeJpegFromStream(opts.source, opts);
|
|
344
|
+
}
|
|
345
|
+
function extractFrameAtTimeJpegFromPath(sourcePath, opts) {
|
|
346
|
+
const args = ["-hide_banner", "-loglevel", "error"];
|
|
347
|
+
if (opts.timeSeconds > 0) {
|
|
348
|
+
args.push("-ss", String(opts.timeSeconds));
|
|
349
|
+
}
|
|
350
|
+
args.push("-i", sourcePath);
|
|
351
|
+
args.push(
|
|
352
|
+
"-vf",
|
|
353
|
+
"scale=iw*sar:ih,scale='min(1024,iw)':-2",
|
|
354
|
+
"-frames:v",
|
|
355
|
+
"1",
|
|
356
|
+
"-f",
|
|
357
|
+
"image2",
|
|
358
|
+
"-vcodec",
|
|
359
|
+
"mjpeg",
|
|
360
|
+
"pipe:1"
|
|
361
|
+
);
|
|
362
|
+
return runFfmpegJpegExtract(args, opts.ffmpeg, void 0);
|
|
363
|
+
}
|
|
364
|
+
function extractFrameAtTimeJpegFromStream(source, opts) {
|
|
365
|
+
if (opts.inputFormat === void 0) {
|
|
366
|
+
return Promise.reject(
|
|
367
|
+
new Error("stream video frame extraction requires an input format hint")
|
|
368
|
+
);
|
|
369
|
+
}
|
|
370
|
+
const args = ["-hide_banner", "-loglevel", "error"];
|
|
371
|
+
args.push("-probesize", "100M", "-analyzeduration", "100M");
|
|
372
|
+
args.push("-f", opts.inputFormat, "-i", "pipe:0");
|
|
373
|
+
if (opts.timeSeconds > 0) {
|
|
374
|
+
args.push("-ss", String(opts.timeSeconds));
|
|
375
|
+
}
|
|
376
|
+
args.push(
|
|
377
|
+
"-vf",
|
|
378
|
+
"scale=iw*sar:ih,scale='min(1024,iw)':-2",
|
|
379
|
+
"-frames:v",
|
|
380
|
+
"1",
|
|
381
|
+
"-f",
|
|
382
|
+
"image2",
|
|
383
|
+
"-vcodec",
|
|
384
|
+
"mjpeg",
|
|
385
|
+
"pipe:1"
|
|
386
|
+
);
|
|
387
|
+
return runFfmpegJpegExtract(args, opts.ffmpeg, source);
|
|
388
|
+
}
|
|
389
|
+
function runFfmpegJpegExtract(args, ffmpeg, source) {
|
|
390
|
+
return new Promise((resolve, reject) => {
|
|
391
|
+
const fromStream = source !== void 0;
|
|
392
|
+
const child = spawn(ffmpeg.ffmpeg, args, {
|
|
393
|
+
stdio: fromStream ? ["pipe", "pipe", "pipe"] : ["ignore", "pipe", "pipe"]
|
|
394
|
+
});
|
|
395
|
+
const stdout = child.stdout;
|
|
396
|
+
const stderr = child.stderr;
|
|
397
|
+
if (stdout === null || stderr === null) {
|
|
398
|
+
reject(new Error("ffmpeg stdio was not configured"));
|
|
399
|
+
return;
|
|
400
|
+
}
|
|
401
|
+
const stdoutChunks = [];
|
|
402
|
+
const stderrChunks = [];
|
|
403
|
+
stdout.on("data", (chunk) => stdoutChunks.push(chunk));
|
|
404
|
+
stderr.on("data", (chunk) => stderrChunks.push(chunk));
|
|
405
|
+
if (source !== void 0) {
|
|
406
|
+
source.on("error", reject);
|
|
407
|
+
source.pipe(child.stdin);
|
|
408
|
+
child.stdin?.on("error", () => {
|
|
409
|
+
});
|
|
410
|
+
}
|
|
411
|
+
child.on("error", reject);
|
|
412
|
+
child.on("close", (code) => {
|
|
413
|
+
if (code !== 0) {
|
|
414
|
+
const msg = Buffer.concat(stderrChunks).toString("utf8").trim();
|
|
415
|
+
reject(new Error(`ffmpeg exited ${code}${msg ? `: ${msg}` : ""}`));
|
|
416
|
+
return;
|
|
417
|
+
}
|
|
418
|
+
const out = Buffer.concat(stdoutChunks);
|
|
419
|
+
if (out.length === 0) {
|
|
420
|
+
reject(new Error("ffmpeg produced no output"));
|
|
421
|
+
return;
|
|
422
|
+
}
|
|
423
|
+
resolve(out);
|
|
424
|
+
});
|
|
425
|
+
});
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
export { ANIMATED_AREA_DIVISOR, AVIF_EFFORT, AVIF_QUALITY, PREVIEW_AVIF_QUALITY, PREVIEW_WEBP_QUALITY, WEBP_QUALITY, cleanOrphanedTempFiles, fitInsideArea, renderAudioCoverArt, renderImageThumbOnce, renderImageWithArea, renderVideoFrame, resolveFfmpegPaths };
|
|
429
|
+
//# sourceMappingURL=index.js.map
|
|
430
|
+
//# sourceMappingURL=index.js.map
|