@hoardodile/host 0.1.9 → 0.1.11

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/README.md CHANGED
@@ -37,7 +37,7 @@ pnpm add @hoardodile/host
37
37
 
38
38
  The content sniffer and the image/video/audio probes (`sniffBytes`,
39
39
  `probeImageSource`, `probeAvMedia`, ...) load `sharp` and the
40
- `ffmpeg-static` / `@derhuerst/ffprobe-static` binaries lazily at runtime;
40
+ `@hoardodile/ffmpeg-bin` / `@hoardodile/ffprobe-bin` binaries lazily at runtime;
41
41
  `sharp` is an optional peer, the binaries are optional dependencies and
42
42
  install automatically (skip with `--no-optional`; the loaders fall back
43
43
  to PATH).
@@ -18,14 +18,14 @@ type ExtractProgress = {
18
18
  type ResolveFfprobeDeps = {
19
19
  readonly env?: NodeJS.ProcessEnv;
20
20
  /** Override for tests so they never hit the real module. */
21
- readonly loadStatic?: () => string | undefined;
21
+ readonly loadStatic?: () => string | null | undefined;
22
22
  };
23
23
  /**
24
24
  * Resolve the absolute path to `ffprobe` for this host.
25
25
  *
26
26
  * Precedence:
27
27
  * 1. The `FFPROBE_PATH` env var.
28
- * 2. `@derhuerst/ffprobe-static` via lazy `createRequire` — the
28
+ * 2. `@hoardodile/ffprobe-bin` via lazy `createRequire` — the
29
29
  * installer package stays out of the host tarball.
30
30
  * 3. A bare command name, letting operators with ffprobe on PATH run
31
31
  * the CLI or server directly.
package/dist/index.d.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  import { PluginManifestId, PluginManifest, PluginDefinition, PluginDownloadRequest, PluginDownloadResult, PluginAssetDeleteResult, PluginPermissions, PluginSchema, ResourceAPI, ProbeResult, HookName, ImageHashKind, Detection, SerializedFileList, ImageHashesResult } from '@hoardodile/sdk-types';
2
2
  export { AudioCoverArt, AudioInfo, AudioTags, Detection, HOOK_NAMES, HookName, ImageInfo, Logger, PluginDefinition, ReadFileRange, ResourceAPI, ResourceAPIFixtureConfig, VideoInfo, assertPluginShape, createFailingPlugin, createResourceAPIFixture, definePlugin, isDetected, isMissed, stubLogger } from '@hoardodile/sdk-types';
3
3
  import { Readable } from 'node:stream';
4
- import { I as ImageMetadataInput, b as ImageSourceProbe, A as AvProbeOptions, E as ExtractProgress } from './image-Dx2Be3Bj.js';
4
+ import { I as ImageMetadataInput, b as ImageSourceProbe, A as AvProbeOptions, E as ExtractProgress } from './image-CewC_m2j.js';
5
5
  import { R as ResourceContainer } from './container-fKxefO7p.js';
6
6
  import { P as PluginProbeCache } from './probe-cache-BkRI7HEK.js';
7
7
  export { a as PLUGIN_PROBE_CACHE_MAX_ENTRIES, c as createProbeCache } from './probe-cache-BkRI7HEK.js';
@@ -1,5 +1,5 @@
1
- import { F as FfprobePayload, p as probeImageSource, a as probeAvMedia } from '../image-Dx2Be3Bj.js';
2
- export { A as AvProbeOptions, I as ImageMetadataInput, b as ImageSourceProbe, P as PROBE_HEADER_BYTES, T as THUMB_BUFFER_MAX_BYTES, c as avResultFromPayload, i as isAnimatedCandidateExt, l as loadSharp, n as needsFullAnimationScan, d as probeAnimatedImage, e as probeImage, r as readImageMetadata, f as resolveFfprobePath, s as sharpFromReadable, g as sharpImageInputOpts } from '../image-Dx2Be3Bj.js';
1
+ import { F as FfprobePayload, p as probeImageSource, a as probeAvMedia } from '../image-CewC_m2j.js';
2
+ export { A as AvProbeOptions, I as ImageMetadataInput, b as ImageSourceProbe, P as PROBE_HEADER_BYTES, T as THUMB_BUFFER_MAX_BYTES, c as avResultFromPayload, i as isAnimatedCandidateExt, l as loadSharp, n as needsFullAnimationScan, d as probeAnimatedImage, e as probeImage, r as readImageMetadata, f as resolveFfprobePath, s as sharpFromReadable, g as sharpImageInputOpts } from '../image-CewC_m2j.js';
3
3
  import { Readable } from 'node:stream';
4
4
  import { AudioInfo, FileType, VideoInfo } from '@hoardodile/sdk-types';
5
5
  import 'sharp';
@@ -17,7 +17,7 @@ function resolveFfprobePath(deps = {}) {
17
17
  }
18
18
  function loadInstallerFfprobe() {
19
19
  try {
20
- const mod = requireCjs("@derhuerst/ffprobe-static");
20
+ const mod = requireCjs("@hoardodile/ffprobe-bin");
21
21
  if (typeof mod === "string" && mod.length > 0) return mod;
22
22
  if (mod !== null && typeof mod === "object" && "path" in mod && typeof mod.path === "string") {
23
23
  return mod.path;
@@ -1 +1 @@
1
- {"version":3,"sources":["../../src/probe/ffprobe.ts","../../src/probe/audio.ts","../../src/probe/video.ts","../../src/probe/av.ts","../../src/archive/extract-archive.ts","../../src/probe/image.ts","../../src/probe/sniff.ts","../../src/probe/index.ts"],"names":["isAttachedPicture","sharp","shallowMeta","shallow","fullMeta","fileTypeFromName"],"mappings":"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","file":"index.js","sourcesContent":["/**\n * Shared ffprobe plumbing for the media probes. `video.ts` and `audio.ts`\n * both spawn the same binary with the same JSON output flags and only\n * differ in how they read the payload, so the process handling and the\n * payload shapes live here.\n *\n * The ffprobe binary path is resolved lazily. `@derhuerst/ffprobe-static`\n * is an optional dependency of this package — not shipped in the host\n * tarball. Dev resolves it from this package's `node_modules`; `pnpm\n * start` and the desktop sidecar resolve it from the server\n * `dist/node_modules` copy.\n */\nimport { createRequire } from \"node:module\"\nimport type { Readable } from \"node:stream\"\nimport { execa } from \"execa\"\n\ntype ResolveFfprobeDeps = {\n\treadonly env?: NodeJS.ProcessEnv\n\t/** Override for tests so they never hit the real module. */\n\treadonly loadStatic?: () => string | undefined\n}\n\nconst requireCjs = createRequire(import.meta.url)\n\n/**\n * Resolve the absolute path to `ffprobe` for this host.\n *\n * Precedence:\n * 1. The `FFPROBE_PATH` env var.\n * 2. `@derhuerst/ffprobe-static` via lazy `createRequire` — the\n * installer package stays out of the host tarball.\n * 3. A bare command name, letting operators with ffprobe on PATH run\n * the CLI or server directly.\n */\nexport function resolveFfprobePath(deps: ResolveFfprobeDeps = {}): string {\n\tconst env = deps.env ?? process.env\n\tconst fromEnv = env.FFPROBE_PATH\n\tif (fromEnv !== undefined && fromEnv.length > 0) return fromEnv\n\tconst staticPath = (deps.loadStatic ?? loadInstallerFfprobe)()\n\treturn staticPath ?? \"ffprobe\"\n}\n\nfunction loadInstallerFfprobe(): string | undefined {\n\ttry {\n\t\tconst mod: unknown = requireCjs(\"@derhuerst/ffprobe-static\")\n\t\tif (typeof mod === \"string\" && mod.length > 0) return mod\n\t\tif (\n\t\t\tmod !== null &&\n\t\t\ttypeof mod === \"object\" &&\n\t\t\t\"path\" in mod &&\n\t\t\ttypeof (mod as { path: unknown }).path === \"string\"\n\t\t) {\n\t\t\treturn (mod as { path: string }).path\n\t\t}\n\t\treturn undefined\n\t} catch {\n\t\treturn undefined\n\t}\n}\n\nlet ffprobePathCache: string | undefined\n\n/** Memoized {@link resolveFfprobePath} for the probe entry points. */\nexport function getFfprobePath(): string {\n\tif (ffprobePathCache === undefined) ffprobePathCache = resolveFfprobePath()\n\treturn ffprobePathCache\n}\n\nexport type FfprobeStream = {\n\treadonly codec_type?: unknown\n\treadonly codec_name?: unknown\n\treadonly width?: unknown\n\treadonly height?: unknown\n\treadonly sample_rate?: unknown\n\treadonly channels?: unknown\n\treadonly disposition?: unknown\n\treadonly tags?: unknown\n}\n\nexport type FfprobeFormat = {\n\treadonly duration?: unknown\n\treadonly bit_rate?: unknown\n\treadonly tags?: unknown\n}\n\nexport type FfprobePayload = {\n\treadonly streams?: readonly FfprobeStream[]\n\treadonly format?: FfprobeFormat\n}\n\n/**\n * Parse ffprobe's JSON output into the raw payload shape. Anything that\n * is not a JSON object yields an empty payload — partial and malformed\n * payloads are normal for damaged media, and callers prefer surfacing\n * whatever survives narrowing over rejecting the whole probe.\n */\nexport function parseFfprobePayload(json: string): FfprobePayload {\n\tconst payload: unknown = JSON.parse(json)\n\tif (typeof payload !== \"object\" || payload === null) return {}\n\treturn payload\n}\n\n/** Read `format.duration` (ffprobe emits it as a string) in milliseconds. */\nexport function ffprobeDurationMs(\n\tformat: FfprobeFormat | undefined,\n): number | undefined {\n\tconst raw = format?.duration\n\tif (typeof raw !== \"string\") return undefined\n\tconst seconds = Number.parseFloat(raw)\n\tif (!Number.isFinite(seconds) || seconds < 0) return undefined\n\treturn Math.round(seconds * 1000)\n}\n\n/** Read a positive integer out of an ffprobe field (string or number). */\nexport function ffprobeInt(value: unknown): number | undefined {\n\tconst parsed =\n\t\ttypeof value === \"number\"\n\t\t\t? value\n\t\t\t: typeof value === \"string\"\n\t\t\t\t? Number.parseInt(value, 10)\n\t\t\t\t: Number.NaN\n\tif (!Number.isFinite(parsed) || parsed <= 0) return undefined\n\treturn Math.round(parsed)\n}\n\n/**\n * Run `ffprobe` against `source` and return its parsed JSON payload.\n * Failure paths reject (callers wrap the call and treat a missing probe\n * as non-fatal) so this function only returns with a payload in hand.\n *\n * Why JSON / `-print_format json`: the alternative `-show_entries` flat\n * format requires brittle line splitting and breaks on locale-specific\n * decimal separators in `duration`. JSON gives the values verbatim.\n *\n * @throws `Error` with stderr when ffprobe exits non-zero or emits no JSON.\n */\nexport function runFfprobeJson(\n\tsource: string | Readable,\n\tffprobePath: string,\n\tinputFormat?: string,\n): Promise<FfprobePayload> {\n\tconst fromStream = typeof source !== \"string\"\n\treturn new Promise((resolve, reject) => {\n\t\tconst args = [\n\t\t\t\"-hide_banner\",\n\t\t\t\"-loglevel\",\n\t\t\t\"error\",\n\t\t\t\"-print_format\",\n\t\t\t\"json\",\n\t\t\t\"-show_streams\",\n\t\t\t\"-show_format\",\n\t\t]\n\t\tif (fromStream) {\n\t\t\tif (inputFormat === undefined) {\n\t\t\t\treject(new Error(\"stream ffprobe requires an input format hint\"))\n\t\t\t\treturn\n\t\t\t}\n\t\t\targs.push(\"-probesize\", \"100M\", \"-analyzeduration\", \"100M\")\n\t\t\targs.push(\"-f\", inputFormat)\n\t\t}\n\t\targs.push(fromStream ? \"pipe:0\" : source)\n\t\tconst child = execa(ffprobePath, args, {\n\t\t\tstdin: fromStream ? \"pipe\" : \"ignore\",\n\t\t\treject: false,\n\t\t\tstripFinalNewline: false,\n\t\t})\n\t\tif (fromStream) {\n\t\t\tsource.on(\"error\", reject)\n\t\t\tconst stdin = child.stdin\n\t\t\tif (stdin !== null) {\n\t\t\t\tsource.pipe(stdin)\n\t\t\t\tstdin.on(\"error\", () => {})\n\t\t\t}\n\t\t}\n\t\tvoid child.then(\n\t\t\t({ stdout, exitCode, stderr }) => {\n\t\t\t\tif (exitCode !== 0) {\n\t\t\t\t\tconst msg = stderr.trim()\n\t\t\t\t\treject(\n\t\t\t\t\t\tnew Error(`ffprobe exited ${exitCode}${msg ? `: ${msg}` : \"\"}`),\n\t\t\t\t\t)\n\t\t\t\t\treturn\n\t\t\t\t}\n\t\t\t\tif (stdout.length === 0) {\n\t\t\t\t\treject(new Error(\"ffprobe produced no output\"))\n\t\t\t\t\treturn\n\t\t\t\t}\n\t\t\t\ttry {\n\t\t\t\t\tresolve(parseFfprobePayload(stdout))\n\t\t\t\t} catch (err) {\n\t\t\t\t\treject(err instanceof Error ? err : new Error(String(err)))\n\t\t\t\t}\n\t\t\t},\n\t\t\t(err) => reject(err instanceof Error ? err : new Error(String(err))),\n\t\t)\n\t})\n}\n","import type { Readable } from \"node:stream\"\nimport { AUDIO_FFMPEG_INPUT_FORMAT } from \"@hoardodile/sdk-types/media-exts\"\nimport type { AudioCoverArt, AudioInfo, AudioTags } from \"../types.ts\"\nimport {\n\ttype FfprobePayload,\n\ttype FfprobeStream,\n\tffprobeDurationMs,\n\tffprobeInt,\n\tgetFfprobePath,\n\tparseFfprobePayload,\n\trunFfprobeJson,\n} from \"./ffprobe.ts\"\n\n/**\n * Audio probing via ffprobe (see `ffprobe.ts` for path resolution and the\n * process plumbing). One probe answers everything the host needs about an\n * audio file: playable duration, stream parameters, whether the container\n * carries embedded artwork, and the human-facing tags.\n */\n\n/** Resolve an extension to its ffmpeg `-f` container name for piped input. */\nexport function extToAudioInputFormat(ext: string): string | undefined {\n\treturn AUDIO_FFMPEG_INPUT_FORMAT[ext.toLowerCase()]\n}\n\n/**\n * Pure parser; broken out so callers and tests can hit it without\n * spawning ffprobe. Returns `undefined` when the payload carries no\n * audio stream at all — a video file probed as audio must not produce a\n * half-filled result.\n */\nexport function parseFfprobeAudioJson(json: string): AudioInfo | undefined {\n\treturn audioInfoFromPayload(parseFfprobePayload(json))\n}\n\nexport function audioInfoFromPayload(\n\tpayload: FfprobePayload,\n): AudioInfo | undefined {\n\tconst streams = payload.streams ?? []\n\tconst audioStream = streams.find((s) => s.codec_type === \"audio\")\n\tif (audioStream === undefined) return undefined\n\n\tconst result: {\n\t\tdurationMs?: number\n\t\tcodec?: string\n\t\tbitRate?: number\n\t\tsampleRate?: number\n\t\tchannels?: number\n\t\tcoverArt?: AudioCoverArt\n\t\ttags?: AudioTags\n\t} = {}\n\n\tconst durationMs = ffprobeDurationMs(payload.format)\n\tif (durationMs !== undefined) result.durationMs = durationMs\n\tif (\n\t\ttypeof audioStream.codec_name === \"string\" &&\n\t\taudioStream.codec_name.length > 0\n\t) {\n\t\tresult.codec = audioStream.codec_name\n\t}\n\tconst bitRate = ffprobeInt(payload.format?.bit_rate)\n\tif (bitRate !== undefined) result.bitRate = bitRate\n\tconst sampleRate = ffprobeInt(audioStream.sample_rate)\n\tif (sampleRate !== undefined) result.sampleRate = sampleRate\n\tconst channels = ffprobeInt(audioStream.channels)\n\tif (channels !== undefined) result.channels = channels\n\tconst picture = streams.find(isAttachedPicture)\n\tif (picture !== undefined) {\n\t\tresult.coverArt = {\n\t\t\twidth: ffprobeInt(picture.width),\n\t\t\theight: ffprobeInt(picture.height),\n\t\t}\n\t}\n\tconst tags = readAudioTags(payload)\n\tif (tags !== undefined) result.tags = tags\n\n\treturn result\n}\n\n/**\n * True for the still image ffmpeg exposes as a video stream: ID3 APIC\n * frames, FLAC PICTURE blocks and MP4 `covr` atoms all surface this way.\n */\nfunction isAttachedPicture(stream: FfprobeStream): boolean {\n\tif (stream.codec_type !== \"video\") return false\n\tconst disposition = stream.disposition\n\tif (typeof disposition !== \"object\" || disposition === null) return false\n\treturn (disposition as Record<string, unknown>).attached_pic === 1\n}\n\n/**\n * Read the well-known tags off the container, falling back to the audio\n * stream's own tag table (Vorbis comments in Matroska/Ogg land there).\n * Tag keys vary in case between muxers, so lookups are case-insensitive.\n */\nfunction readAudioTags(payload: FfprobePayload): AudioTags | undefined {\n\tconst tables = [\n\t\tpayload.format?.tags,\n\t\tpayload.streams?.find((s) => s.codec_type === \"audio\")?.tags,\n\t]\n\tconst lookup = new Map<string, string>()\n\tfor (const table of tables) {\n\t\tif (typeof table !== \"object\" || table === null) continue\n\t\tfor (const [key, value] of Object.entries(table)) {\n\t\t\tif (typeof value !== \"string\" || value.length === 0) continue\n\t\t\tconst normalized = key.toLowerCase()\n\t\t\tif (!lookup.has(normalized)) lookup.set(normalized, value)\n\t\t}\n\t}\n\tconst tags: { title?: string; artist?: string; album?: string } = {}\n\tconst title = lookup.get(\"title\")\n\tif (title !== undefined) tags.title = title\n\tconst artist = lookup.get(\"artist\") ?? lookup.get(\"album_artist\")\n\tif (artist !== undefined) tags.artist = artist\n\tconst album = lookup.get(\"album\")\n\tif (album !== undefined) tags.album = album\n\treturn Object.keys(tags).length === 0 ? undefined : tags\n}\n\n/**\n * Probe an audio stream or path with ffprobe. Probe failures — a missing\n * binary, an unreadable container, or a source with no audio stream —\n * return `undefined` so callers can treat \"not yet probed\" and \"probe\n * failed\" the same way.\n */\nexport async function probeAudio(\n\tsource: string | Readable,\n\textHint?: string,\n): Promise<AudioInfo | undefined> {\n\ttry {\n\t\tconst inputFormat =\n\t\t\ttypeof source === \"string\" || extHint === undefined\n\t\t\t\t? undefined\n\t\t\t\t: extToAudioInputFormat(extHint)\n\t\tconst payload = await runFfprobeJson(source, getFfprobePath(), inputFormat)\n\t\treturn audioInfoFromPayload(payload)\n\t} catch {\n\t\treturn undefined\n\t}\n}\n","import type { Readable } from \"node:stream\"\nimport type { VideoInfo } from \"../types.ts\"\nimport {\n\ttype FfprobePayload,\n\tffprobeDurationMs,\n\tgetFfprobePath,\n\tparseFfprobePayload,\n\trunFfprobeJson,\n} from \"./ffprobe.ts\"\n\n/**\n * ffmpeg `-f` container name for piped zip entry bytes (no filename hint).\n */\nconst FFMPEG_INPUT_FORMAT: Readonly<Record<string, string>> = {\n\t\".mp4\": \"mp4\",\n\t\".m4v\": \"mp4\",\n\t\".webm\": \"webm\",\n\t\".mov\": \"mov\",\n\t\".mkv\": \"matroska\",\n\t\".avi\": \"avi\",\n}\n\n/** Resolve an extension to its ffmpeg `-f` container name, or `undefined` for unknown extensions. */\nexport function extToFfmpegInputFormat(ext: string): string | undefined {\n\treturn FFMPEG_INPUT_FORMAT[ext.toLowerCase()]\n}\n\n/**\n * Video probing via ffprobe (see `ffprobe.ts` for path resolution and the\n * process plumbing).\n */\n\n/**\n * Source-media metadata derived by ffprobe - pixel dimensions of the first\n * video stream and the playable duration in milliseconds. Any field can be\n * absent when ffprobe declines to report it (corrupt header, missing\n * container metadata). Callers persist whichever fields are populated.\n */\nexport type ProbedVideoMeta = {\n\treadonly width?: number\n\treadonly height?: number\n\treadonly durationMs?: number\n}\n\n/**\n * Run `ffprobe` against `source` and extract width, height and duration.\n *\n * @throws `Error` with stderr when ffprobe exits non-zero or emits no JSON.\n */\nexport async function probeVideoMeta(\n\tsource: string | Readable,\n\tffprobePath: string,\n\tinputFormat?: string,\n): Promise<ProbedVideoMeta> {\n\treturn videoMetaFromPayload(\n\t\tawait runFfprobeJson(source, ffprobePath, inputFormat),\n\t)\n}\n\n/**\n * Pure parser; broken out so callers and tests can hit it without spawning\n * ffprobe. Returns only the fields that survive type narrowing - partial\n * payloads are normal for malformed media and we prefer surfacing what we\n * have over rejecting the whole probe.\n */\nexport function parseFfprobeJson(json: string): ProbedVideoMeta {\n\treturn videoMetaFromPayload(parseFfprobePayload(json))\n}\n\nexport function videoMetaFromPayload(payload: FfprobePayload): ProbedVideoMeta {\n\tconst result: { width?: number; height?: number; durationMs?: number } = {}\n\tconst videoStream = payload.streams?.find((s) => s.codec_type === \"video\")\n\tif (videoStream !== undefined) {\n\t\tif (typeof videoStream.width === \"number\" && videoStream.width > 0) {\n\t\t\tresult.width = Math.round(videoStream.width)\n\t\t}\n\t\tif (typeof videoStream.height === \"number\" && videoStream.height > 0) {\n\t\t\tresult.height = Math.round(videoStream.height)\n\t\t}\n\t}\n\tconst durationMs = ffprobeDurationMs(payload.format)\n\tif (durationMs !== undefined) result.durationMs = durationMs\n\treturn result\n}\n\n/**\n * Probe a video stream or path with ffprobe and return its pixel\n * dimensions and duration. Probe failures return `undefined` so callers\n * can treat \"not yet probed\" and \"probe failed\" the same way.\n */\nexport async function probeVideo(\n\tsource: string | Readable,\n\textHint?: string,\n): Promise<VideoInfo | undefined> {\n\ttry {\n\t\tconst inputFormat =\n\t\t\ttypeof source === \"string\" || extHint === undefined\n\t\t\t\t? undefined\n\t\t\t\t: extToFfmpegInputFormat(extHint)\n\t\tconst probed = await probeVideoMeta(source, getFfprobePath(), inputFormat)\n\t\tif (\n\t\t\tprobed.width === undefined &&\n\t\t\tprobed.height === undefined &&\n\t\t\tprobed.durationMs === undefined\n\t\t) {\n\t\t\treturn undefined\n\t\t}\n\t\treturn {\n\t\t\twidth: probed.width,\n\t\t\theight: probed.height,\n\t\t\tdurationMs: probed.durationMs,\n\t\t}\n\t} catch {\n\t\treturn undefined\n\t}\n}\n","import type { Readable } from \"node:stream\"\nimport type { ProbeResult } from \"@hoardodile/sdk-types\"\n\nimport { audioInfoFromPayload } from \"./audio.ts\"\nimport {\n\ttype FfprobePayload,\n\ttype FfprobeStream,\n\tgetFfprobePath,\n\trunFfprobeJson,\n} from \"./ffprobe.ts\"\nimport { videoMetaFromPayload } from \"./video.ts\"\n\n/**\n * The unified audio/video probe behind `ResourceAPI.probe`: one ffprobe\n * pass answers both \"which family is this\" and \"what are its\n * parameters\", the way ffprobe itself reports media (`format` plus a\n * `streams` array) instead of asking the caller to guess the family\n * first.\n *\n * Container formats that carry either kind (Ogg, Matroska, ISO-BMFF)\n * are settled here: the stream layout decides, not the extension and\n * not the container MIME.\n */\n\nexport type AvProbeOptions = {\n\t/** MIME type from content sniffing; carried into the result. */\n\treadonly mime: string\n\t/**\n\t * ffmpeg `-f` container name. Required for stream sources (a pipe\n\t * has no filename for ffprobe to key off); ignored for paths.\n\t */\n\treadonly inputFormat?: string\n}\n\n/**\n * True for the still image ffmpeg exposes as a video stream: ID3 APIC\n * frames, FLAC PICTURE blocks and MP4 `covr` atoms all surface this\n * way, and none of them make the file a video.\n */\nfunction isAttachedPicture(stream: FfprobeStream): boolean {\n\tconst disposition = stream.disposition\n\tif (typeof disposition !== \"object\" || disposition === null) return false\n\treturn (disposition as Record<string, unknown>).attached_pic === 1\n}\n\n/**\n * Classify an ffprobe payload into a {@link ProbeResult}. A real video\n * stream wins; otherwise an audio stream decides; a payload with\n * neither is a decode failure.\n */\nexport function avResultFromPayload(\n\tpayload: FfprobePayload,\n\tmime: string,\n): ProbeResult {\n\tconst streams = payload.streams ?? []\n\tconst hasVideo = streams.some(\n\t\t(stream) => stream.codec_type === \"video\" && !isAttachedPicture(stream),\n\t)\n\tif (hasVideo) {\n\t\treturn { kind: \"video\", mime, ...videoMetaFromPayload(payload) }\n\t}\n\tconst audio = audioInfoFromPayload(payload)\n\tif (audio !== undefined) return { kind: \"audio\", mime, ...audio }\n\treturn { kind: \"unknown\", reason: \"failed\" }\n}\n\n/**\n * Probe an audio/video source with ffprobe. Never rejects: a missing\n * binary, an unreadable container or a source ffprobe cannot demux all\n * resolve to `{ kind: \"unknown\", reason: \"failed\" }`.\n */\nexport async function probeAvMedia(\n\tsource: string | Readable,\n\topts: AvProbeOptions,\n): Promise<ProbeResult> {\n\ttry {\n\t\tconst inputFormat =\n\t\t\ttypeof source === \"string\" ? undefined : opts.inputFormat\n\t\tconst payload = await runFfprobeJson(source, getFfprobePath(), inputFormat)\n\t\treturn avResultFromPayload(payload, opts.mime)\n\t} catch {\n\t\treturn { kind: \"unknown\", reason: \"failed\" }\n\t}\n}\n","import { randomUUID } from \"node:crypto\"\nimport { mkdir, readFile, rename, rm, stat, writeFile } from \"node:fs/promises\"\nimport { join } from \"node:path\"\nimport type { MediaKind } from \"@hoardodile/sdk-types\"\nimport { fileTypeFromName } from \"@hoardodile/sdk-types\"\n\nimport { invalid } from \"../errors.ts\"\nimport {\n\textractSevenZipInto,\n\tlistSevenZipEntries,\n\tresolveSevenZipPath,\n} from \"./7z.ts\"\nimport {\n\tassertExtractedTree,\n\tdecodeZipNames,\n\tnormalizeExtractedTree,\n} from \"./extract.ts\"\nimport {\n\ttype ContainerFormat,\n\tSNIFF_WINDOW_BYTES,\n\tsniffContainerFormat,\n} from \"./format.ts\"\nimport { materializeFile } from \"./materialize.ts\"\nimport type { NestedCdCache } from \"./nested-cd-cache.ts\"\nimport {\n\ttype ArchiveEntry,\n\tcreateNestedResolver,\n\tcreateOuterArchiveSource,\n\ttype OuterEntrySource,\n} from \"./nested-entry.ts\"\nimport { streamRange } from \"./zip-entries.ts\"\n\n/**\n * Header window read before handing bytes to the injected image prober.\n * Shared with the host's sharp-backed probes so both sides feed the same\n * bounded window (JPEG SOF markers live in the first KBs; 256KB covers\n * even EXIF-bloated JPEGs).\n */\nexport const PROBE_HEADER_BYTES = 256 * 1024\n\n/** Input accepted by the injected image prober (host's sharp probe). */\nexport type ImageProbeInput = string | Buffer\n\n/** Result of the injected image prober. */\nexport type ImageProbeResult = {\n\treadonly width: number\n\treadonly height: number\n\treadonly animated: boolean\n}\n\n/**\n * Materialize a container entry (zip/tar/7z/rar/xz/gzip) into a cache\n * directory so the browser can serve the inner files over plain URLs.\n * This is the only disk-writing capability of the plugin API; everything\n * else about containers (cover probing, dimensions, hashes) reads\n * through virtual paths without materialization.\n *\n * Extraction is whole-archive through 7-Zip (budgets and paths validated\n * from its listing, then the extracted tree re-verified — see\n * `assertExtractedTree`). The manifest below is built from the listing,\n * so entries carry the exact names and sizes 7-Zip reported.\n *\n * Cache layout (per archive):\n * <cacheDir>/<archiveName>/index.json — completion marker + manifest\n * <cacheDir>/<archiveName>/<innerPath> — extracted file (subdirs kept)\n *\n * The `index.json` marker is written last and atomically: its presence\n * means extraction completed. A partial or failed run leaves files but no\n * marker, so the next call re-extracts idempotently.\n */\n\nexport const EXTRACT_INDEX_VERSION = 1\n\nexport type ExtractedEntry = {\n\treadonly path: string\n\treadonly sizeBytes: number\n\treadonly kind: MediaKind\n\treadonly width?: number\n\treadonly height?: number\n\treadonly animated?: boolean\n}\n\nexport type ExtractionResult = {\n\treadonly entries: readonly ExtractedEntry[]\n}\n\nexport type ExtractProgress = {\n\t/** Entries materialized so far (skipped cached files count too). */\n\treadonly done: number\n\treadonly total: number\n}\n\nexport type ExtractArchiveDeps = {\n\t/** Read access to the outer container (the resource's entries). */\n\treadonly outer: OuterEntrySource\n\t/**\n\t * Absolute cache directory for this resource's plugin extractions\n\t * (e.g. `<cacheRoot>/resources/<id>/extracted/v<N>/archives`).\n\t */\n\treadonly cacheDir: string\n\t/** Cumulative uncompressed byte budget for one archive. */\n\treadonly maxBytes: number\n\t/** Entry-count budget for one archive. */\n\treadonly maxEntries: number\n\t/** Optional image prober (sharp-backed) for per-entry dimensions. */\n\treadonly probeImage?: (\n\t\tsource: ImageProbeInput,\n\t\textHint?: string,\n\t) => Promise<ImageProbeResult | undefined>\n\t/**\n\t * Called as entries are materialized, so a long first extraction can\n\t * surface progress to the user.\n\t */\n\treadonly onProgress?: (progress: ExtractProgress) => void\n\t/** Shared nested-listing cache (see {@link NestedCdCache}). */\n\treadonly nestedCdCache?: NestedCdCache\n}\n\nexport type ArchiveExtractor = {\n\t/**\n\t * Materialize (or re-list) the container `archiveName`. Idempotent\n\t * and single-flighted per archive name.\n\t */\n\treadonly extract: (archiveName: string) => Promise<ExtractionResult>\n\t/**\n\t * List the container's entries without materializing anything into\n\t * the final cache layout. Resolves to `undefined` when `archiveName`\n\t * is not a container (or no 7-Zip binary exists for non-zip\n\t * formats).\n\t */\n\treadonly list: (\n\t\tarchiveName: string,\n\t) => Promise<readonly ArchiveEntry[] | undefined>\n}\n\n/** Build a single-flight, idempotent extractor over one outer container. */\nexport function createArchiveExtractor(\n\tdeps: ExtractArchiveDeps,\n): ArchiveExtractor {\n\tconst resolver = createNestedResolver(deps.outer, {\n\t\tcdCache: deps.nestedCdCache,\n\t})\n\tconst inflight = new Map<string, Promise<ExtractionResult>>()\n\n\tfunction extract(archiveName: string): Promise<ExtractionResult> {\n\t\tconst pending = inflight.get(archiveName)\n\t\tif (pending !== undefined) return pending\n\t\tconst work = extractOnce(archiveName)\n\t\tinflight.set(archiveName, work)\n\t\tvoid work.then(\n\t\t\t() => undefined,\n\t\t\t() => undefined,\n\t\t)\n\t\treturn work\n\t}\n\n\tasync function extractOnce(archiveName: string): Promise<ExtractionResult> {\n\t\tconst size = await deps.outer.sizeOf(archiveName)\n\t\tif (size === undefined) {\n\t\t\tthrow invalid(\n\t\t\t\t\"plugin.container_extract_failed\",\n\t\t\t\t`\"${archiveName}\" does not exist`,\n\t\t\t\t{ archiveName },\n\t\t\t)\n\t\t}\n\t\tconst markerPath = join(deps.cacheDir, archiveName, \"index.json\")\n\t\tconst existing = await readExistingManifest(markerPath, archiveName)\n\t\tif (existing !== undefined) return { entries: existing }\n\t\tif (size > 0) {\n\t\t\tconst head = await deps.outer.readSlice(\n\t\t\t\tarchiveName,\n\t\t\t\t0,\n\t\t\t\tMath.min(size, SNIFF_WINDOW_BYTES),\n\t\t\t)\n\t\t\tconst format = sniffContainerFormat(head)\n\t\t\tif (format !== undefined) {\n\t\t\t\treturn extractWithSevenZip(archiveName, size, markerPath, format)\n\t\t\t}\n\t\t}\n\t\tthrow invalid(\n\t\t\t\"plugin.container_extract_failed\",\n\t\t\t`\"${archiveName}\" is not a supported archive (zip/tar/7z/rar/xz/gzip)`,\n\t\t\t{ archiveName },\n\t\t)\n\t}\n\n\t/**\n\t * Whole-archive extraction through 7-Zip: materialize the outer entry\n\t * to a temp file (7-Zip needs a real path), validate budgets and\n\t * encryption from the listing, extract into the cache dir, then build\n\t * the idempotent manifest. Runs for every supported format — zip/tar\n\t * included, exactly like the 7z/rar/xz branch before them.\n\t */\n\tasync function extractWithSevenZip(\n\t\tarchiveName: string,\n\t\tsize: number,\n\t\tmarkerPath: string,\n\t\tformat: ContainerFormat,\n\t): Promise<ExtractionResult> {\n\t\tconst source = createOuterArchiveSource(deps.outer, archiveName, size)\n\t\tconst tempPath = join(\n\t\t\tdeps.cacheDir,\n\t\t\t`${archiveName}.7z-partial-${process.pid}-${randomUUID().slice(0, 8)}`,\n\t\t)\n\t\tawait mkdir(deps.cacheDir, { recursive: true })\n\t\ttry {\n\t\t\tawait materializeFile({\n\t\t\t\topenStream: () => streamRange(source, 0, size - 1),\n\t\t\t\ttarget: tempPath,\n\t\t\t\texpectedSize: size,\n\t\t\t})\n\t\t\tconst entries = await listSevenZipEntries(tempPath)\n\t\t\tconst files = entries.filter((e) => !e.folder)\n\t\t\tcheckExtractionBudgets(archiveName, files, deps.maxBytes, deps.maxEntries)\n\t\t\tif (entries.some((e) => e.encrypted)) {\n\t\t\t\tthrow invalid(\n\t\t\t\t\t\"plugin.container_extract_failed\",\n\t\t\t\t\t`archive \"${archiveName}\" is password-protected — encrypted archives are not supported`,\n\t\t\t\t\t{ archiveName },\n\t\t\t\t)\n\t\t\t}\n\t\t\t// 7-Zip's text listing drops invalid-UTF-8 bytes on POSIX, so\n\t\t\t// for zip archives the decoded names come from the archive\n\t\t\t// itself (yauzl): the manifest, the probes and the legacy-name\n\t\t\t// renames below all agree on them.\n\t\t\tconst decoded =\n\t\t\t\tformat === \"zip\"\n\t\t\t\t\t? await decodeZipNames(\n\t\t\t\t\t\t\ttempPath,\n\t\t\t\t\t\t\tfiles.map((e) => ({ name: e.name, sizeBytes: e.sizeBytes })),\n\t\t\t\t\t\t)\n\t\t\t\t\t: undefined\n\t\t\tconst materialized = decoded?.files ?? files\n\t\t\t// Pre-validate every path from the listing — 7-Zip itself\n\t\t\t// warns-and-skips unsafe names instead of failing.\n\t\t\tfor (const entry of materialized) sanitizeExtractPath(entry.name)\n\t\t\tconst root = join(deps.cacheDir, archiveName)\n\t\t\tawait mkdir(root, { recursive: true })\n\t\t\tawait extractSevenZipInto(tempPath, root)\n\t\t\t// 7-Zip writes legacy zip names verbatim on POSIX and restores\n\t\t\t// mode bits that can strip app access; fix both before the tree\n\t\t\t// is re-walked (symlink scan) or read for the manifest below.\n\t\t\t// The decoded names are the ground truth the legacy rename pass\n\t\t\t// matches against (see `renameLegacyZipNames` in extract.ts).\n\t\t\tawait normalizeExtractedTree(root, {\n\t\t\t\tlegacyZipNames: format === \"zip\",\n\t\t\t\texpectedNames: decoded?.paths,\n\t\t\t})\n\t\t\tawait assertExtractedTree(root, deps.maxBytes)\n\t\t\tdeps.onProgress?.({ done: files.length, total: files.length })\n\t\t\tconst manifest = await buildManifest(\n\t\t\t\tarchiveName,\n\t\t\t\tmaterialized.map((e) => ({ name: e.name, sizeBytes: e.sizeBytes })),\n\t\t\t)\n\t\t\tawait writeManifest(markerPath, archiveName, manifest)\n\t\t\t// The manifest now serves listings (and materialized virtual\n\t\t\t// paths); drop the size-keyed listing cache for this archive.\n\t\t\tawait rm(join(deps.cacheDir, `${archiveName}.7z-list`), {\n\t\t\t\tforce: true,\n\t\t\t}).catch(() => {})\n\t\t\tawait rm(join(deps.cacheDir, `${archiveName}.7z-list.json`), {\n\t\t\t\tforce: true,\n\t\t\t}).catch(() => {})\n\t\t\treturn { entries: manifest }\n\t\t} finally {\n\t\t\tawait rm(tempPath, { force: true }).catch(() => {})\n\t\t}\n\t}\n\n\t/** Shared bomb guards for one extraction: entry count and total bytes. */\n\tfunction checkExtractionBudgets(\n\t\tarchiveName: string,\n\t\tentries: readonly { readonly sizeBytes: number }[],\n\t\tmaxBytes: number,\n\t\tmaxEntries: number,\n\t): void {\n\t\tif (entries.length > maxEntries) {\n\t\t\tthrow invalid(\n\t\t\t\t\"plugin.container_extract_failed\",\n\t\t\t\t`archive \"${archiveName}\" has ${entries.length} entries, exceeding the limit of ${maxEntries}`,\n\t\t\t\t{ archiveName, entries: entries.length, maxEntries },\n\t\t\t)\n\t\t}\n\t\tlet totalBytes = 0\n\t\tfor (const entry of entries) totalBytes += entry.sizeBytes\n\t\tif (totalBytes > maxBytes) {\n\t\t\tthrow invalid(\n\t\t\t\t\"plugin.container_extract_failed\",\n\t\t\t\t`archive \"${archiveName}\" unpacks to ${totalBytes} bytes, exceeding the limit of ${maxBytes}`,\n\t\t\t\t{ archiveName, totalBytes, maxBytes },\n\t\t\t)\n\t\t}\n\t}\n\n\tasync function buildManifest(\n\t\tarchiveName: string,\n\t\tentries: readonly ArchiveEntry[],\n\t): Promise<readonly ExtractedEntry[]> {\n\t\tconst manifest: ExtractedEntry[] = []\n\t\tfor (const entry of entries) {\n\t\t\tconst kind = fileTypeFromName(entry.name)?.kind ?? \"other\"\n\t\t\tconst row: MutableExtractedEntry = {\n\t\t\t\tpath: entry.name,\n\t\t\t\tsizeBytes: entry.sizeBytes,\n\t\t\t\tkind,\n\t\t\t}\n\t\t\tif (kind === \"image\" && deps.probeImage !== undefined) {\n\t\t\t\tconst rel = sanitizeExtractPath(entry.name)\n\t\t\t\tconst filePath = join(deps.cacheDir, archiveName, rel)\n\t\t\t\tconst probe = await probeImageHead(\n\t\t\t\t\tdeps.probeImage,\n\t\t\t\t\tfilePath,\n\t\t\t\t\tentry.name,\n\t\t\t\t)\n\t\t\t\tif (probe !== undefined) {\n\t\t\t\t\trow.width = probe.width\n\t\t\t\t\trow.height = probe.height\n\t\t\t\t\trow.animated = probe.animated\n\t\t\t\t}\n\t\t\t}\n\t\t\tmanifest.push(row)\n\t\t}\n\t\treturn manifest\n\t}\n\n\t/**\n\t * List the container's entries. Zip lists from the nested resolver\n\t * (CD parse, nothing materialized); tar/7z/rar/xz list through the\n\t * 7-Zip binary over a size-keyed materialization cache in the cache\n\t * dir (7-Zip needs a real path; the bytes are written once per outer\n\t * size and reused across hook calls — every hook call builds a fresh\n\t * API instance, so per-call temp files would re-write the whole\n\t * archive each time). Resolves to `undefined` when the entry is not a\n\t * container, is gzip (whole-archive single-stream), or the 7-Zip\n\t * binary is absent (quiet degradation — callers treat it as \"not a\n\t * container\", matching the resolver semantics).\n\t */\n\tasync function list(\n\t\tarchiveName: string,\n\t): Promise<readonly ArchiveEntry[] | undefined> {\n\t\tconst size = await deps.outer.sizeOf(archiveName)\n\t\tif (size === undefined) return undefined\n\t\t// An extracted archive answers from its manifest — cheaper than\n\t\t// any listing, and the manifest is the source of truth for the\n\t\t// materialized virtual paths (see nested-view.ts).\n\t\tconst markerPath = join(deps.cacheDir, archiveName, \"index.json\")\n\t\tconst extracted = await readExistingManifest(markerPath, archiveName)\n\t\tif (extracted !== undefined) {\n\t\t\treturn extracted.map((e) => ({ name: e.path, sizeBytes: e.sizeBytes }))\n\t\t}\n\t\tif (size === 0) return undefined\n\t\tconst head = await deps.outer.readSlice(\n\t\t\tarchiveName,\n\t\t\t0,\n\t\t\tMath.min(size, SNIFF_WINDOW_BYTES),\n\t\t)\n\t\tconst format = sniffContainerFormat(head)\n\t\tif (format === \"zip\") {\n\t\t\tconst entries = await resolver.list(archiveName)\n\t\t\tif (entries === undefined) return undefined\n\t\t\treturn entries.map((e) => ({ name: e.name, sizeBytes: e.sizeBytes }))\n\t\t}\n\t\tif (\n\t\t\tformat === \"tar\" ||\n\t\t\tformat === \"7z\" ||\n\t\t\tformat === \"rar\" ||\n\t\t\tformat === \"xz\"\n\t\t) {\n\t\t\treturn listViaSevenZip(archiveName, size)\n\t\t}\n\t\treturn undefined\n\t}\n\n\t/**\n\t * Non-zip listing through 7-Zip. The outer entry is materialized once\n\t * per size into `<cacheDir>/<archiveName>.7z-list` (keyed by outer\n\t * size — the source is immutable per version, so a size match means\n\t * the cached bytes are the same file) and the parsed listing is\n\t * cached next to it. `extractWithSevenZip` removes both files once a\n\t * real extraction lands, so the manifest takes over afterwards.\n\t */\n\tasync function listViaSevenZip(\n\t\tarchiveName: string,\n\t\tsize: number,\n\t): Promise<readonly ArchiveEntry[] | undefined> {\n\t\tif (resolveSevenZipPath() === undefined) return undefined\n\t\tawait mkdir(deps.cacheDir, { recursive: true })\n\t\tconst cachePath = join(deps.cacheDir, `${archiveName}.7z-list`)\n\t\tconst entriesPath = join(deps.cacheDir, `${archiveName}.7z-list.json`)\n\t\tconst cached = await stat(cachePath).catch(() => undefined)\n\t\tif (cached?.size === size) {\n\t\t\tconst raw = await readFile(entriesPath, \"utf8\").catch(() => undefined)\n\t\t\tif (raw !== undefined) {\n\t\t\t\ttry {\n\t\t\t\t\treturn JSON.parse(raw) as readonly ArchiveEntry[]\n\t\t\t\t} catch {\n\t\t\t\t\t// Corrupt cache — fall through and re-materialize.\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t\ttry {\n\t\t\tconst source = createOuterArchiveSource(deps.outer, archiveName, size)\n\t\t\tawait materializeFile({\n\t\t\t\topenStream: () => streamRange(source, 0, size - 1),\n\t\t\t\ttarget: cachePath,\n\t\t\t\texpectedSize: size,\n\t\t\t})\n\t\t\tconst listed = await listSevenZipEntries(cachePath)\n\t\t\tconst files = listed\n\t\t\t\t.filter((e) => !e.folder)\n\t\t\t\t.map((e) => ({ name: e.name, sizeBytes: e.sizeBytes }))\n\t\t\tawait writeFile(entriesPath, JSON.stringify(files))\n\t\t\treturn files\n\t\t} catch (err) {\n\t\t\tawait rm(cachePath, { force: true }).catch(() => {})\n\t\t\tawait rm(entriesPath, { force: true }).catch(() => {})\n\t\t\tthrow err\n\t\t}\n\t}\n\n\treturn { extract, list }\n}\n\ntype MutableExtractedEntry = {\n\tpath: string\n\tsizeBytes: number\n\tkind: MediaKind\n\twidth?: number\n\theight?: number\n\tanimated?: boolean\n}\n\n/**\n * Read and validate a manifest written by {@link writeManifest}: version\n * and archive-name must match, otherwise the marker is stale garbage and\n * the caller re-extracts. Shared with the nested-view materialized-path\n * resolution.\n */\nexport async function readExistingManifest(\n\tmarkerPath: string,\n\tarchiveName: string,\n): Promise<readonly ExtractedEntry[] | undefined> {\n\tconst raw = await readFile(markerPath, \"utf8\").catch(() => undefined)\n\tif (raw === undefined) return undefined\n\ttry {\n\t\tconst parsed = JSON.parse(raw) as {\n\t\t\treadonly v?: number\n\t\t\treadonly archiveName?: string\n\t\t\treadonly entries?: readonly ExtractedEntry[]\n\t\t}\n\t\tif (\n\t\t\tparsed.v !== EXTRACT_INDEX_VERSION ||\n\t\t\tparsed.archiveName !== archiveName ||\n\t\t\t!Array.isArray(parsed.entries)\n\t\t) {\n\t\t\treturn undefined\n\t\t}\n\t\treturn parsed.entries\n\t} catch {\n\t\treturn undefined\n\t}\n}\n\nasync function writeManifest(\n\tmarkerPath: string,\n\tarchiveName: string,\n\tentries: readonly ExtractedEntry[],\n): Promise<void> {\n\tconst payload = JSON.stringify({\n\t\tv: EXTRACT_INDEX_VERSION,\n\t\tarchiveName,\n\t\tentries,\n\t})\n\tconst partial = `${markerPath}.partial-${process.pid}`\n\tawait writeFile(partial, payload)\n\tawait rename(partial, markerPath)\n}\n\n/**\n * Normalize an inner entry name to a safe relative path: backslashes\n * become forward slashes, traversal segments and control characters are\n * rejected, and the result must stay inside the extraction root.\n */\nexport function sanitizeExtractPath(name: string): string {\n\tconst normalized = name.replace(/\\\\/g, \"/\")\n\tif (normalized.startsWith(\"/\") || /^[A-Za-z]:/.test(normalized)) {\n\t\tthrow invalid(\n\t\t\t\"plugin.container_extract_failed\",\n\t\t\t`archive entry has an absolute path: \"${name}\"`,\n\t\t\t{ name },\n\t\t)\n\t}\n\tconst segments = normalized.split(\"/\")\n\tfor (const seg of segments) {\n\t\tif (seg.length === 0 || seg === \".\" || seg === \"..\") {\n\t\t\tthrow invalid(\n\t\t\t\t\"plugin.container_extract_failed\",\n\t\t\t\t`archive entry has an unsafe path: \"${name}\"`,\n\t\t\t\t{ name },\n\t\t\t)\n\t\t}\n\t\tif (hasControlChar(seg)) {\n\t\t\tthrow invalid(\n\t\t\t\t\"plugin.container_extract_failed\",\n\t\t\t\t`archive entry contains control characters: \"${name}\"`,\n\t\t\t\t{ name },\n\t\t\t)\n\t\t}\n\t}\n\treturn segments.join(\"/\")\n}\n\n/** True when any char code is a control character (C0 or DEL). */\nfunction hasControlChar(value: string): boolean {\n\tfor (let i = 0; i < value.length; i++) {\n\t\tconst code = value.charCodeAt(i)\n\t\tif (code < 0x20 || code === 0x7f) return true\n\t}\n\treturn false\n}\n\n/** Probe one extracted image's header window for dimensions. */\nasync function probeImageHead(\n\tprobeImage: NonNullable<ExtractArchiveDeps[\"probeImage\"]>,\n\tfilePath: string,\n\tname: string,\n): Promise<ImageProbeResult | undefined> {\n\tconst head = await readFile(filePath).then((b) =>\n\t\tb.subarray(0, PROBE_HEADER_BYTES),\n\t)\n\tif (head.length === 0) return undefined\n\tconst ext = name.slice(name.lastIndexOf(\".\")).toLowerCase()\n\treturn probeImage(head, ext)\n}\n","import { extname } from \"node:path\"\nimport type { Readable } from \"node:stream\"\nimport { IMAGE_EXTS } from \"@hoardodile/sdk-types/media-exts\"\nimport { PROBE_HEADER_BYTES } from \"../archive/index.ts\"\n\nimport type { ImageInfo } from \"../types.ts\"\n\n/**\n * Max zip entry size read into memory for thumb synthesis. Larger\n * entries are materialized to disk before sharp/ffmpeg runs.\n */\nexport const THUMB_BUFFER_MAX_BYTES = 32 * 1024 * 1024\n\n/**\n * Image probing via sharp. sharp is loaded lazily and declared as an\n * optional peer dependency: the host package stays free of native\n * binaries, and consumers that never probe images (the browser-side mock,\n * dependency-light CI installs) never pull it in. When sharp is missing,\n * probe calls fail loudly instead of degrading silently.\n */\ntype SharpModule = typeof import(\"sharp\")\n\nlet sharpPromise: Promise<SharpModule> | undefined\n\nexport function loadSharp(): Promise<SharpModule> {\n\tsharpPromise ??= import(\"sharp\").catch((err: unknown) => {\n\t\tsharpPromise = undefined\n\t\tthrow new Error(\n\t\t\t`sharp is not installed — add \"sharp\" to your dependencies to enable image probing: ${err instanceof Error ? err.message : String(err)}`,\n\t\t)\n\t})\n\treturn sharpPromise\n}\n\nexport type ImageSourceProbe = {\n\treadonly width: number\n\treadonly height: number\n\treadonly animated: boolean\n}\n\nconst ANIMATED_EXT_HINTS = new Set([\".gif\", \".webp\", \".avif\"])\n\nexport { PROBE_HEADER_BYTES } from \"../archive/index.ts\"\n\n/** True when `ext` names a format that can carry animation. */\nexport function isAnimatedCandidateExt(ext: string): boolean {\n\treturn ANIMATED_EXT_HINTS.has(ext)\n}\n\nexport type ImageMetadataInput =\n\t| string\n\t| Buffer\n\t| Readable\n\t| {\n\t\t\treadonly openStream: () => Promise<Readable>\n\t\t\t/**\n\t\t\t * Optional byte-range read into the underlying source (e.g. a\n\t\t\t * zip entry slice). When present, metadata probes read\n\t\t\t * {@link PROBE_HEADER_BYTES} from the start instead of\n\t\t\t * streaming the whole entry — libvips reads the ENTIRE file\n\t\t\t * for metadata from a non-seekable stream, but only the header\n\t\t\t * from a buffer.\n\t\t\t */\n\t\t\treadonly readRange?: (start: number, end: number) => Promise<Buffer>\n\t }\n\n/**\n * Narrow a Node stream into sharp's readable input without assertion.\n * Async because the sharp module itself is loaded lazily.\n *\n * sharp's constructor rejects `sharp(stream, opts)` — for stream input\n * the input options must be passed AS the first argument (a plain object\n * of input parameters like `pages`/`animated`) and the readable piped\n * into the resulting Duplex.\n */\nexport async function sharpFromReadable(\n\tstream: Readable,\n\toptions?: import(\"sharp\").SharpOptions,\n): Promise<import(\"sharp\").Sharp> {\n\tif (typeof stream.read !== \"function\") {\n\t\tthrow new Error(\"expected a readable stream\")\n\t}\n\tconst { default: sharp } = await loadSharp()\n\tconst descriptor =\n\t\toptions === undefined || Object.keys(options).length === 0\n\t\t\t? undefined\n\t\t\t: ({ ...options } as unknown as import(\"sharp\").SharpInput)\n\tconst instance = descriptor === undefined ? sharp() : sharp(descriptor)\n\tstream.pipe(instance)\n\treturn instance\n}\n\nfunction isReadable(input: unknown): input is Readable {\n\treturn (\n\t\ttypeof input === \"object\" &&\n\t\tinput !== null &&\n\t\ttypeof (input as Readable).pipe === \"function\" &&\n\t\t!(\"openStream\" in input)\n\t)\n}\n\nfunction isReopenableImageStream(\n\tinput: ImageMetadataInput,\n): input is { readonly openStream: () => Promise<Readable> } {\n\treturn (\n\t\ttypeof input === \"object\" &&\n\t\tinput !== null &&\n\t\t!Buffer.isBuffer(input) &&\n\t\t\"openStream\" in input &&\n\t\ttypeof (input as { openStream: unknown }).openStream === \"function\"\n\t)\n}\n\n/**\n * Sharp options for image thumb/probe reads. Enables sequential read for\n * large JPEGs on disk so libvips can shrink-on-load during resize.\n */\nexport function sharpImageInputOpts(\n\tinput: string | Buffer,\n\text: string,\n\tpages: number,\n\tanimated?: boolean,\n): import(\"sharp\").SharpOptions {\n\tconst opts: import(\"sharp\").SharpOptions = { pages }\n\tif (animated === true) opts.animated = true\n\tif (\n\t\ttypeof input === \"string\" &&\n\t\t(ext === \".jpg\" || ext === \".jpeg\" || ext === \".jfif\")\n\t) {\n\t\topts.sequentialRead = true\n\t}\n\treturn opts\n}\n\n/**\n * True when a shallow `{ pages: 1 }` metadata read is not enough to\n * decide animation and the full multi-page scan is required.\n */\nexport function needsFullAnimationScan(\n\tmeta: Pick<\n\t\timport(\"sharp\").Metadata,\n\t\t\"width\" | \"height\" | \"pages\" | \"pageHeight\"\n\t>,\n\text: string,\n): boolean {\n\tif (ext === \".gif\") return true\n\tif (ANIMATED_EXT_HINTS.has(ext) && (meta.pages ?? 1) > 1) return true\n\tif (\n\t\tmeta.pageHeight !== undefined &&\n\t\tmeta.height !== undefined &&\n\t\tmeta.pageHeight !== meta.height\n\t) {\n\t\treturn true\n\t}\n\treturn false\n}\n\n/**\n * Buffer a probe stream with a hard byte cap. Only used for formats whose\n * animation scan needs the whole stream (GIF): probing only needs\n * metadata, so an entry bigger than the cap is treated as unprobed rather\n * than buffered whole into host memory.\n */\nasync function bufferCapped(\n\tinput: Readable,\n\tmaxBytes: number,\n): Promise<Buffer> {\n\tconst chunks: Uint8Array[] = []\n\tlet total = 0\n\tfor await (const chunk of input) {\n\t\tconst bytes: Uint8Array =\n\t\t\ttypeof chunk === \"string\" ? Buffer.from(chunk) : chunk\n\t\ttotal += bytes.byteLength\n\t\tif (total > maxBytes) {\n\t\t\tinput.destroy()\n\t\t\tthrow new Error(`probe input exceeds the ${maxBytes}-byte cap`)\n\t\t}\n\t\tchunks.push(bytes)\n\t}\n\treturn Buffer.concat(chunks)\n}\n\n/**\n * Read image metadata with layered animation detection: static sources\n * stop after a single-page read; animated containers escalate to\n * `{ pages: -1 }` only when the shallow probe signals multi-frame input.\n */\nexport async function readImageMetadata(\n\tinput: ImageMetadataInput,\n\text: string,\n): Promise<{\n\treadonly meta: import(\"sharp\").Metadata\n\treadonly animated: boolean\n}> {\n\tif (isReopenableImageStream(input)) {\n\t\t// Header-slice fast path: probe a small leading window as a buffer\n\t\t// (header-only libvips read) instead of streaming the whole entry\n\t\t// (full-file read). Only definitive static results are accepted —\n\t\t// animation candidates escalate inside the slice read, and a\n\t\t// truncated buffer cannot complete a full scan, so those fall\n\t\t// through to the stream path where frame counts stay correct. GIF\n\t\t// is skipped outright (its frame count lives at the end).\n\t\tif (input.readRange !== undefined && ext !== \".gif\") {\n\t\t\tconst head = await input.readRange(0, PROBE_HEADER_BYTES)\n\t\t\tif (head.length > 0) {\n\t\t\t\ttry {\n\t\t\t\t\tconst { default: sharp } = await loadSharp()\n\t\t\t\t\tconst shallowMeta = await sharp(\n\t\t\t\t\t\thead,\n\t\t\t\t\t\tsharpImageInputOpts(head, ext, 1),\n\t\t\t\t\t).metadata()\n\t\t\t\t\tif (!needsFullAnimationScan(shallowMeta, ext)) {\n\t\t\t\t\t\treturn { meta: shallowMeta, animated: false }\n\t\t\t\t\t}\n\t\t\t\t} catch {\n\t\t\t\t\t// Truncated header unreadable — fall through to the\n\t\t\t\t\t// stream path.\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t\tconst stream = await input.openStream()\n\t\tconst shallow = await sharpFromReadable(stream, { pages: 1 })\n\t\tconst shallowMeta = await shallow.metadata()\n\t\tif (!needsFullAnimationScan(shallowMeta, ext)) {\n\t\t\treturn { meta: shallowMeta, animated: false }\n\t\t}\n\t\tconst fullStream = await input.openStream()\n\t\tconst fullMeta = await sharpFromReadable(fullStream, {\n\t\t\tpages: -1,\n\t\t\tanimated: true,\n\t\t}).then((s) => s.metadata())\n\t\treturn { meta: fullMeta, animated: (fullMeta.pages ?? 1) > 1 }\n\t}\n\tif (isReadable(input)) {\n\t\t// GIF frame counts are not stored in the container header, so the\n\t\t// full animation scan needs the whole stream — buffer cap-guarded\n\t\t// and reuse the buffer path.\n\t\tif (ext === \".gif\") {\n\t\t\tconst data = await bufferCapped(input, THUMB_BUFFER_MAX_BYTES)\n\t\t\treturn readImageMetadata(data, ext)\n\t\t}\n\t\t// Static formats: hand the stream straight to sharp so the probe\n\t\t// reads only the header bytes. (Buffering the whole entry first —\n\t\t// the previous behavior, capped at THUMB_BUFFER_MAX_BYTES — turned\n\t\t// every stream probe into a full read and made entries beyond the\n\t\t// cap unprobeable.)\n\t\ttry {\n\t\t\tconst shallow = await sharpFromReadable(input, { pages: 1 })\n\t\t\tconst shallowMeta = await shallow.metadata()\n\t\t\tif (!needsFullAnimationScan(shallowMeta, ext)) {\n\t\t\t\treturn { meta: shallowMeta, animated: false }\n\t\t\t}\n\t\t\t// Animated candidates (webp/avif/tiff) report their total page\n\t\t\t// count from the container header, so the shallow read is\n\t\t\t// conclusive — and the stream cannot be rewound for a full\n\t\t\t// scan anyway.\n\t\t\treturn {\n\t\t\t\tmeta: shallowMeta,\n\t\t\t\tanimated: (shallowMeta.pages ?? 1) > 1,\n\t\t\t}\n\t\t} finally {\n\t\t\tinput.destroy()\n\t\t}\n\t}\n\tconst { default: sharp } = await loadSharp()\n\tconst shallow = sharp(input, sharpImageInputOpts(input, ext, 1))\n\tconst shallowMeta = await shallow.metadata()\n\tif (!needsFullAnimationScan(shallowMeta, ext)) {\n\t\treturn { meta: shallowMeta, animated: false }\n\t}\n\tconst fullMeta = await sharp(\n\t\tinput,\n\t\tsharpImageInputOpts(input, ext, -1, true),\n\t).metadata()\n\treturn { meta: fullMeta, animated: (fullMeta.pages ?? 1) > 1 }\n}\n\n/**\n * Probe an image path or buffer and return pixel dimensions plus whether\n * the source is animated. Probe failures return `undefined`.\n */\nexport async function probeImageSource(\n\tinput: ImageMetadataInput,\n\textHint?: string,\n): Promise<ImageSourceProbe | undefined> {\n\tconst ext =\n\t\textHint ?? (typeof input === \"string\" ? extname(input).toLowerCase() : \"\")\n\tif (ext.length > 0 && !IMAGE_EXTS.has(ext)) return undefined\n\ttry {\n\t\tconst { meta, animated } = await readImageMetadata(input, ext)\n\t\tconst h = meta.pageHeight ?? meta.height\n\t\tif (meta.width === undefined || h === undefined) return undefined\n\t\treturn { width: meta.width, height: h, animated }\n\t} catch {\n\t\treturn undefined\n\t}\n}\n\n/**\n * Probe an image source and return its pixel dimensions. Probe failures\n * return `undefined` so callers can treat \"not yet probed\" and \"probe\n * failed\" the same way.\n */\nexport async function probeImage(\n\tsource: ImageMetadataInput,\n): Promise<ImageInfo | undefined> {\n\tconst probe = await probeImageSource(source)\n\tif (probe === undefined) return undefined\n\treturn { width: probe.width, height: probe.height }\n}\n\n/**\n * True when the image has more than one frame (animated GIF / WebP / APNG /\n * AVIF). Uses layered detection so static JPEG/PNG avoid a full-frame scan.\n * Errors are coerced to `false`.\n */\nexport async function probeAnimatedImage(\n\tsource: ImageMetadataInput,\n): Promise<boolean> {\n\tif (\n\t\ttypeof source === \"string\" &&\n\t\t!IMAGE_EXTS.has(extname(source).toLowerCase())\n\t) {\n\t\treturn false\n\t}\n\ttry {\n\t\tconst probe = await probeImageSource(source)\n\t\treturn probe?.animated ?? false\n\t} catch (err) {\n\t\tconst label = typeof source === \"string\" ? source : \"stream\"\n\t\tconsole.warn(\n\t\t\t`[probeAnimatedImage] sharp failed on ${label}: ${err instanceof Error ? err.message : String(err)}`,\n\t\t)\n\t\treturn false\n\t}\n}\n","import { fileTypeFromName } from \"@hoardodile/sdk-types\"\nimport { mimeToKind } from \"@hoardodile/sdk-types/media-exts\"\n\nimport type { FileType } from \"../types.ts\"\n\n/**\n * Content sniffing: what a file's bytes say it is, with the filename as\n * the fallback rather than the verdict.\n *\n * The layering mirrors what the ecosystem settled on (freedesktop's\n * shared-mime-info, Apache Tika, WHATWG mimesniff): magic-byte matching\n * decides for binary formats, and the extension only answers for\n * formats that carry no signature at all — text, subtitles, CSV. That\n * split is why a `.jpg` holding WebP bytes probes as WebP, while a\n * `.txt` still resolves to `text/plain`.\n */\n\n/**\n * Header window handed to the magic matcher. `file-type` documents 4100\n * bytes as the sample size that makes its detection deterministic; the\n * host reads exactly that much and no more, so sniffing an entry costs\n * one small ranged read.\n */\nexport const SNIFF_HEADER_BYTES = 4100\n\ntype FileTypeModule = typeof import(\"file-type\")\n\nlet fileTypePromise: Promise<FileTypeModule> | undefined\n\n/**\n * Load the magic-byte matcher lazily. Sniffing is opt-in per host (the\n * directory backend never sniffs), so an unused host pays nothing for\n * the module graph.\n */\nfunction loadFileType(): Promise<FileTypeModule> {\n\tfileTypePromise ??= import(\"file-type\")\n\treturn fileTypePromise\n}\n\n/**\n * Identify a file from a leading byte window plus its name. `head`\n * should hold the first {@link SNIFF_HEADER_BYTES} bytes; a shorter\n * slice still works for formats whose signature fits inside it.\n */\nexport async function sniffBytes(\n\thead: Uint8Array,\n\tpath: string,\n): Promise<FileType | undefined> {\n\tif (head.byteLength > 0) {\n\t\ttry {\n\t\t\tconst { fileTypeFromBuffer } = await loadFileType()\n\t\t\tconst detected = await fileTypeFromBuffer(head)\n\t\t\tif (detected !== undefined) {\n\t\t\t\treturn {\n\t\t\t\t\tmime: detected.mime,\n\t\t\t\t\text: `.${detected.ext}`,\n\t\t\t\t\tkind: mimeToKind(detected.mime),\n\t\t\t\t\tsource: \"magic\",\n\t\t\t\t}\n\t\t\t}\n\t\t} catch {\n\t\t\t// A malformed or truncated header is not an error condition —\n\t\t\t// fall through to the filename, same as a signature-less file.\n\t\t}\n\t}\n\treturn fileTypeFromName(path)\n}\n","/**\n * Probe implementations for the {@link ResourceAPI} builder. Consumed by\n * the server's plugin wiring and the CLI's bench — the same probing code\n * on both sides keeps \"test path = production path\". sharp and ffprobe\n * are optional peers loaded lazily; see `image.ts` / `video.ts` /\n * `audio.ts`.\n */\n\nimport { probeAvMedia } from \"./av.ts\"\nimport { probeImageSource } from \"./image.ts\"\n\nexport {\n\taudioInfoFromPayload,\n\textToAudioInputFormat,\n\tparseFfprobeAudioJson,\n\tprobeAudio,\n} from \"./audio.ts\"\nexport type { AvProbeOptions } from \"./av.ts\"\nexport { avResultFromPayload, probeAvMedia } from \"./av.ts\"\nexport { resolveFfprobePath } from \"./ffprobe.ts\"\nexport type { ImageMetadataInput, ImageSourceProbe } from \"./image.ts\"\nexport {\n\tisAnimatedCandidateExt,\n\tloadSharp,\n\tneedsFullAnimationScan,\n\tPROBE_HEADER_BYTES,\n\tprobeAnimatedImage,\n\tprobeImage,\n\tprobeImageSource,\n\treadImageMetadata,\n\tsharpFromReadable,\n\tsharpImageInputOpts,\n\tTHUMB_BUFFER_MAX_BYTES,\n} from \"./image.ts\"\nexport { SNIFF_HEADER_BYTES, sniffBytes } from \"./sniff.ts\"\nexport type { ProbedVideoMeta } from \"./video.ts\"\nexport {\n\textToFfmpegInputFormat,\n\tparseFfprobeJson,\n\tprobeVideo,\n\tprobeVideoMeta,\n} from \"./video.ts\"\n\n/**\n * The host's real probe implementations, shaped for\n * `createPluginResourceAPI({ view, ...mediaProbes })`. Every consumer\n * (server import path, trash fallback, CLI) spreads this same object,\n * so \"what you test is what runs in production\" holds by construction\n * instead of by three copies of the same wiring.\n */\nexport const mediaProbes = {\n\tprobeImage: probeImageSource,\n\tprobeAv: probeAvMedia,\n} as const\n"]}
1
+ {"version":3,"sources":["../../src/probe/ffprobe.ts","../../src/probe/audio.ts","../../src/probe/video.ts","../../src/probe/av.ts","../../src/archive/extract-archive.ts","../../src/probe/image.ts","../../src/probe/sniff.ts","../../src/probe/index.ts"],"names":["isAttachedPicture","sharp","shallowMeta","shallow","fullMeta","fileTypeFromName"],"mappings":"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","file":"index.js","sourcesContent":["/**\n * Shared ffprobe plumbing for the media probes. `video.ts` and `audio.ts`\n * both spawn the same binary with the same JSON output flags and only\n * differ in how they read the payload, so the process handling and the\n * payload shapes live here.\n *\n * The ffprobe binary path is resolved lazily. `@hoardodile/ffprobe-bin`\n * is an optional dependency of this package — not shipped in the host\n * tarball. Dev resolves it from this package's `node_modules`; `pnpm\n * start` and the desktop sidecar resolve it from the server\n * `dist/node_modules` copy.\n */\nimport { createRequire } from \"node:module\"\nimport type { Readable } from \"node:stream\"\nimport { execa } from \"execa\"\n\ntype ResolveFfprobeDeps = {\n\treadonly env?: NodeJS.ProcessEnv\n\t/** Override for tests so they never hit the real module. */\n\treadonly loadStatic?: () => string | null | undefined\n}\n\nconst requireCjs = createRequire(import.meta.url)\n\n/**\n * Resolve the absolute path to `ffprobe` for this host.\n *\n * Precedence:\n * 1. The `FFPROBE_PATH` env var.\n * 2. `@hoardodile/ffprobe-bin` via lazy `createRequire` — the\n * installer package stays out of the host tarball.\n * 3. A bare command name, letting operators with ffprobe on PATH run\n * the CLI or server directly.\n */\nexport function resolveFfprobePath(deps: ResolveFfprobeDeps = {}): string {\n\tconst env = deps.env ?? process.env\n\tconst fromEnv = env.FFPROBE_PATH\n\tif (fromEnv !== undefined && fromEnv.length > 0) return fromEnv\n\tconst staticPath = (deps.loadStatic ?? loadInstallerFfprobe)()\n\treturn staticPath ?? \"ffprobe\"\n}\n\nfunction loadInstallerFfprobe(): string | undefined {\n\ttry {\n\t\tconst mod: unknown = requireCjs(\"@hoardodile/ffprobe-bin\")\n\t\tif (typeof mod === \"string\" && mod.length > 0) return mod\n\t\tif (\n\t\t\tmod !== null &&\n\t\t\ttypeof mod === \"object\" &&\n\t\t\t\"path\" in mod &&\n\t\t\ttypeof (mod as { path: unknown }).path === \"string\"\n\t\t) {\n\t\t\treturn (mod as { path: string }).path\n\t\t}\n\t\treturn undefined\n\t} catch {\n\t\treturn undefined\n\t}\n}\n\nlet ffprobePathCache: string | undefined\n\n/** Memoized {@link resolveFfprobePath} for the probe entry points. */\nexport function getFfprobePath(): string {\n\tif (ffprobePathCache === undefined) ffprobePathCache = resolveFfprobePath()\n\treturn ffprobePathCache\n}\n\nexport type FfprobeStream = {\n\treadonly codec_type?: unknown\n\treadonly codec_name?: unknown\n\treadonly width?: unknown\n\treadonly height?: unknown\n\treadonly sample_rate?: unknown\n\treadonly channels?: unknown\n\treadonly disposition?: unknown\n\treadonly tags?: unknown\n}\n\nexport type FfprobeFormat = {\n\treadonly duration?: unknown\n\treadonly bit_rate?: unknown\n\treadonly tags?: unknown\n}\n\nexport type FfprobePayload = {\n\treadonly streams?: readonly FfprobeStream[]\n\treadonly format?: FfprobeFormat\n}\n\n/**\n * Parse ffprobe's JSON output into the raw payload shape. Anything that\n * is not a JSON object yields an empty payload — partial and malformed\n * payloads are normal for damaged media, and callers prefer surfacing\n * whatever survives narrowing over rejecting the whole probe.\n */\nexport function parseFfprobePayload(json: string): FfprobePayload {\n\tconst payload: unknown = JSON.parse(json)\n\tif (typeof payload !== \"object\" || payload === null) return {}\n\treturn payload\n}\n\n/** Read `format.duration` (ffprobe emits it as a string) in milliseconds. */\nexport function ffprobeDurationMs(\n\tformat: FfprobeFormat | undefined,\n): number | undefined {\n\tconst raw = format?.duration\n\tif (typeof raw !== \"string\") return undefined\n\tconst seconds = Number.parseFloat(raw)\n\tif (!Number.isFinite(seconds) || seconds < 0) return undefined\n\treturn Math.round(seconds * 1000)\n}\n\n/** Read a positive integer out of an ffprobe field (string or number). */\nexport function ffprobeInt(value: unknown): number | undefined {\n\tconst parsed =\n\t\ttypeof value === \"number\"\n\t\t\t? value\n\t\t\t: typeof value === \"string\"\n\t\t\t\t? Number.parseInt(value, 10)\n\t\t\t\t: Number.NaN\n\tif (!Number.isFinite(parsed) || parsed <= 0) return undefined\n\treturn Math.round(parsed)\n}\n\n/**\n * Run `ffprobe` against `source` and return its parsed JSON payload.\n * Failure paths reject (callers wrap the call and treat a missing probe\n * as non-fatal) so this function only returns with a payload in hand.\n *\n * Why JSON / `-print_format json`: the alternative `-show_entries` flat\n * format requires brittle line splitting and breaks on locale-specific\n * decimal separators in `duration`. JSON gives the values verbatim.\n *\n * @throws `Error` with stderr when ffprobe exits non-zero or emits no JSON.\n */\nexport function runFfprobeJson(\n\tsource: string | Readable,\n\tffprobePath: string,\n\tinputFormat?: string,\n): Promise<FfprobePayload> {\n\tconst fromStream = typeof source !== \"string\"\n\treturn new Promise((resolve, reject) => {\n\t\tconst args = [\n\t\t\t\"-hide_banner\",\n\t\t\t\"-loglevel\",\n\t\t\t\"error\",\n\t\t\t\"-print_format\",\n\t\t\t\"json\",\n\t\t\t\"-show_streams\",\n\t\t\t\"-show_format\",\n\t\t]\n\t\tif (fromStream) {\n\t\t\tif (inputFormat === undefined) {\n\t\t\t\treject(new Error(\"stream ffprobe requires an input format hint\"))\n\t\t\t\treturn\n\t\t\t}\n\t\t\targs.push(\"-probesize\", \"100M\", \"-analyzeduration\", \"100M\")\n\t\t\targs.push(\"-f\", inputFormat)\n\t\t}\n\t\targs.push(fromStream ? \"pipe:0\" : source)\n\t\tconst child = execa(ffprobePath, args, {\n\t\t\tstdin: fromStream ? \"pipe\" : \"ignore\",\n\t\t\treject: false,\n\t\t\tstripFinalNewline: false,\n\t\t})\n\t\tif (fromStream) {\n\t\t\tsource.on(\"error\", reject)\n\t\t\tconst stdin = child.stdin\n\t\t\tif (stdin !== null) {\n\t\t\t\tsource.pipe(stdin)\n\t\t\t\tstdin.on(\"error\", () => {})\n\t\t\t}\n\t\t}\n\t\tvoid child.then(\n\t\t\t({ stdout, exitCode, stderr }) => {\n\t\t\t\tif (exitCode !== 0) {\n\t\t\t\t\tconst msg = stderr.trim()\n\t\t\t\t\treject(\n\t\t\t\t\t\tnew Error(`ffprobe exited ${exitCode}${msg ? `: ${msg}` : \"\"}`),\n\t\t\t\t\t)\n\t\t\t\t\treturn\n\t\t\t\t}\n\t\t\t\tif (stdout.length === 0) {\n\t\t\t\t\treject(new Error(\"ffprobe produced no output\"))\n\t\t\t\t\treturn\n\t\t\t\t}\n\t\t\t\ttry {\n\t\t\t\t\tresolve(parseFfprobePayload(stdout))\n\t\t\t\t} catch (err) {\n\t\t\t\t\treject(err instanceof Error ? err : new Error(String(err)))\n\t\t\t\t}\n\t\t\t},\n\t\t\t(err) => reject(err instanceof Error ? err : new Error(String(err))),\n\t\t)\n\t})\n}\n","import type { Readable } from \"node:stream\"\nimport { AUDIO_FFMPEG_INPUT_FORMAT } from \"@hoardodile/sdk-types/media-exts\"\nimport type { AudioCoverArt, AudioInfo, AudioTags } from \"../types.ts\"\nimport {\n\ttype FfprobePayload,\n\ttype FfprobeStream,\n\tffprobeDurationMs,\n\tffprobeInt,\n\tgetFfprobePath,\n\tparseFfprobePayload,\n\trunFfprobeJson,\n} from \"./ffprobe.ts\"\n\n/**\n * Audio probing via ffprobe (see `ffprobe.ts` for path resolution and the\n * process plumbing). One probe answers everything the host needs about an\n * audio file: playable duration, stream parameters, whether the container\n * carries embedded artwork, and the human-facing tags.\n */\n\n/** Resolve an extension to its ffmpeg `-f` container name for piped input. */\nexport function extToAudioInputFormat(ext: string): string | undefined {\n\treturn AUDIO_FFMPEG_INPUT_FORMAT[ext.toLowerCase()]\n}\n\n/**\n * Pure parser; broken out so callers and tests can hit it without\n * spawning ffprobe. Returns `undefined` when the payload carries no\n * audio stream at all — a video file probed as audio must not produce a\n * half-filled result.\n */\nexport function parseFfprobeAudioJson(json: string): AudioInfo | undefined {\n\treturn audioInfoFromPayload(parseFfprobePayload(json))\n}\n\nexport function audioInfoFromPayload(\n\tpayload: FfprobePayload,\n): AudioInfo | undefined {\n\tconst streams = payload.streams ?? []\n\tconst audioStream = streams.find((s) => s.codec_type === \"audio\")\n\tif (audioStream === undefined) return undefined\n\n\tconst result: {\n\t\tdurationMs?: number\n\t\tcodec?: string\n\t\tbitRate?: number\n\t\tsampleRate?: number\n\t\tchannels?: number\n\t\tcoverArt?: AudioCoverArt\n\t\ttags?: AudioTags\n\t} = {}\n\n\tconst durationMs = ffprobeDurationMs(payload.format)\n\tif (durationMs !== undefined) result.durationMs = durationMs\n\tif (\n\t\ttypeof audioStream.codec_name === \"string\" &&\n\t\taudioStream.codec_name.length > 0\n\t) {\n\t\tresult.codec = audioStream.codec_name\n\t}\n\tconst bitRate = ffprobeInt(payload.format?.bit_rate)\n\tif (bitRate !== undefined) result.bitRate = bitRate\n\tconst sampleRate = ffprobeInt(audioStream.sample_rate)\n\tif (sampleRate !== undefined) result.sampleRate = sampleRate\n\tconst channels = ffprobeInt(audioStream.channels)\n\tif (channels !== undefined) result.channels = channels\n\tconst picture = streams.find(isAttachedPicture)\n\tif (picture !== undefined) {\n\t\tresult.coverArt = {\n\t\t\twidth: ffprobeInt(picture.width),\n\t\t\theight: ffprobeInt(picture.height),\n\t\t}\n\t}\n\tconst tags = readAudioTags(payload)\n\tif (tags !== undefined) result.tags = tags\n\n\treturn result\n}\n\n/**\n * True for the still image ffmpeg exposes as a video stream: ID3 APIC\n * frames, FLAC PICTURE blocks and MP4 `covr` atoms all surface this way.\n */\nfunction isAttachedPicture(stream: FfprobeStream): boolean {\n\tif (stream.codec_type !== \"video\") return false\n\tconst disposition = stream.disposition\n\tif (typeof disposition !== \"object\" || disposition === null) return false\n\treturn (disposition as Record<string, unknown>).attached_pic === 1\n}\n\n/**\n * Read the well-known tags off the container, falling back to the audio\n * stream's own tag table (Vorbis comments in Matroska/Ogg land there).\n * Tag keys vary in case between muxers, so lookups are case-insensitive.\n */\nfunction readAudioTags(payload: FfprobePayload): AudioTags | undefined {\n\tconst tables = [\n\t\tpayload.format?.tags,\n\t\tpayload.streams?.find((s) => s.codec_type === \"audio\")?.tags,\n\t]\n\tconst lookup = new Map<string, string>()\n\tfor (const table of tables) {\n\t\tif (typeof table !== \"object\" || table === null) continue\n\t\tfor (const [key, value] of Object.entries(table)) {\n\t\t\tif (typeof value !== \"string\" || value.length === 0) continue\n\t\t\tconst normalized = key.toLowerCase()\n\t\t\tif (!lookup.has(normalized)) lookup.set(normalized, value)\n\t\t}\n\t}\n\tconst tags: { title?: string; artist?: string; album?: string } = {}\n\tconst title = lookup.get(\"title\")\n\tif (title !== undefined) tags.title = title\n\tconst artist = lookup.get(\"artist\") ?? lookup.get(\"album_artist\")\n\tif (artist !== undefined) tags.artist = artist\n\tconst album = lookup.get(\"album\")\n\tif (album !== undefined) tags.album = album\n\treturn Object.keys(tags).length === 0 ? undefined : tags\n}\n\n/**\n * Probe an audio stream or path with ffprobe. Probe failures — a missing\n * binary, an unreadable container, or a source with no audio stream —\n * return `undefined` so callers can treat \"not yet probed\" and \"probe\n * failed\" the same way.\n */\nexport async function probeAudio(\n\tsource: string | Readable,\n\textHint?: string,\n): Promise<AudioInfo | undefined> {\n\ttry {\n\t\tconst inputFormat =\n\t\t\ttypeof source === \"string\" || extHint === undefined\n\t\t\t\t? undefined\n\t\t\t\t: extToAudioInputFormat(extHint)\n\t\tconst payload = await runFfprobeJson(source, getFfprobePath(), inputFormat)\n\t\treturn audioInfoFromPayload(payload)\n\t} catch {\n\t\treturn undefined\n\t}\n}\n","import type { Readable } from \"node:stream\"\nimport type { VideoInfo } from \"../types.ts\"\nimport {\n\ttype FfprobePayload,\n\tffprobeDurationMs,\n\tgetFfprobePath,\n\tparseFfprobePayload,\n\trunFfprobeJson,\n} from \"./ffprobe.ts\"\n\n/**\n * ffmpeg `-f` container name for piped zip entry bytes (no filename hint).\n */\nconst FFMPEG_INPUT_FORMAT: Readonly<Record<string, string>> = {\n\t\".mp4\": \"mp4\",\n\t\".m4v\": \"mp4\",\n\t\".webm\": \"webm\",\n\t\".mov\": \"mov\",\n\t\".mkv\": \"matroska\",\n\t\".avi\": \"avi\",\n}\n\n/** Resolve an extension to its ffmpeg `-f` container name, or `undefined` for unknown extensions. */\nexport function extToFfmpegInputFormat(ext: string): string | undefined {\n\treturn FFMPEG_INPUT_FORMAT[ext.toLowerCase()]\n}\n\n/**\n * Video probing via ffprobe (see `ffprobe.ts` for path resolution and the\n * process plumbing).\n */\n\n/**\n * Source-media metadata derived by ffprobe - pixel dimensions of the first\n * video stream and the playable duration in milliseconds. Any field can be\n * absent when ffprobe declines to report it (corrupt header, missing\n * container metadata). Callers persist whichever fields are populated.\n */\nexport type ProbedVideoMeta = {\n\treadonly width?: number\n\treadonly height?: number\n\treadonly durationMs?: number\n}\n\n/**\n * Run `ffprobe` against `source` and extract width, height and duration.\n *\n * @throws `Error` with stderr when ffprobe exits non-zero or emits no JSON.\n */\nexport async function probeVideoMeta(\n\tsource: string | Readable,\n\tffprobePath: string,\n\tinputFormat?: string,\n): Promise<ProbedVideoMeta> {\n\treturn videoMetaFromPayload(\n\t\tawait runFfprobeJson(source, ffprobePath, inputFormat),\n\t)\n}\n\n/**\n * Pure parser; broken out so callers and tests can hit it without spawning\n * ffprobe. Returns only the fields that survive type narrowing - partial\n * payloads are normal for malformed media and we prefer surfacing what we\n * have over rejecting the whole probe.\n */\nexport function parseFfprobeJson(json: string): ProbedVideoMeta {\n\treturn videoMetaFromPayload(parseFfprobePayload(json))\n}\n\nexport function videoMetaFromPayload(payload: FfprobePayload): ProbedVideoMeta {\n\tconst result: { width?: number; height?: number; durationMs?: number } = {}\n\tconst videoStream = payload.streams?.find((s) => s.codec_type === \"video\")\n\tif (videoStream !== undefined) {\n\t\tif (typeof videoStream.width === \"number\" && videoStream.width > 0) {\n\t\t\tresult.width = Math.round(videoStream.width)\n\t\t}\n\t\tif (typeof videoStream.height === \"number\" && videoStream.height > 0) {\n\t\t\tresult.height = Math.round(videoStream.height)\n\t\t}\n\t}\n\tconst durationMs = ffprobeDurationMs(payload.format)\n\tif (durationMs !== undefined) result.durationMs = durationMs\n\treturn result\n}\n\n/**\n * Probe a video stream or path with ffprobe and return its pixel\n * dimensions and duration. Probe failures return `undefined` so callers\n * can treat \"not yet probed\" and \"probe failed\" the same way.\n */\nexport async function probeVideo(\n\tsource: string | Readable,\n\textHint?: string,\n): Promise<VideoInfo | undefined> {\n\ttry {\n\t\tconst inputFormat =\n\t\t\ttypeof source === \"string\" || extHint === undefined\n\t\t\t\t? undefined\n\t\t\t\t: extToFfmpegInputFormat(extHint)\n\t\tconst probed = await probeVideoMeta(source, getFfprobePath(), inputFormat)\n\t\tif (\n\t\t\tprobed.width === undefined &&\n\t\t\tprobed.height === undefined &&\n\t\t\tprobed.durationMs === undefined\n\t\t) {\n\t\t\treturn undefined\n\t\t}\n\t\treturn {\n\t\t\twidth: probed.width,\n\t\t\theight: probed.height,\n\t\t\tdurationMs: probed.durationMs,\n\t\t}\n\t} catch {\n\t\treturn undefined\n\t}\n}\n","import type { Readable } from \"node:stream\"\nimport type { ProbeResult } from \"@hoardodile/sdk-types\"\n\nimport { audioInfoFromPayload } from \"./audio.ts\"\nimport {\n\ttype FfprobePayload,\n\ttype FfprobeStream,\n\tgetFfprobePath,\n\trunFfprobeJson,\n} from \"./ffprobe.ts\"\nimport { videoMetaFromPayload } from \"./video.ts\"\n\n/**\n * The unified audio/video probe behind `ResourceAPI.probe`: one ffprobe\n * pass answers both \"which family is this\" and \"what are its\n * parameters\", the way ffprobe itself reports media (`format` plus a\n * `streams` array) instead of asking the caller to guess the family\n * first.\n *\n * Container formats that carry either kind (Ogg, Matroska, ISO-BMFF)\n * are settled here: the stream layout decides, not the extension and\n * not the container MIME.\n */\n\nexport type AvProbeOptions = {\n\t/** MIME type from content sniffing; carried into the result. */\n\treadonly mime: string\n\t/**\n\t * ffmpeg `-f` container name. Required for stream sources (a pipe\n\t * has no filename for ffprobe to key off); ignored for paths.\n\t */\n\treadonly inputFormat?: string\n}\n\n/**\n * True for the still image ffmpeg exposes as a video stream: ID3 APIC\n * frames, FLAC PICTURE blocks and MP4 `covr` atoms all surface this\n * way, and none of them make the file a video.\n */\nfunction isAttachedPicture(stream: FfprobeStream): boolean {\n\tconst disposition = stream.disposition\n\tif (typeof disposition !== \"object\" || disposition === null) return false\n\treturn (disposition as Record<string, unknown>).attached_pic === 1\n}\n\n/**\n * Classify an ffprobe payload into a {@link ProbeResult}. A real video\n * stream wins; otherwise an audio stream decides; a payload with\n * neither is a decode failure.\n */\nexport function avResultFromPayload(\n\tpayload: FfprobePayload,\n\tmime: string,\n): ProbeResult {\n\tconst streams = payload.streams ?? []\n\tconst hasVideo = streams.some(\n\t\t(stream) => stream.codec_type === \"video\" && !isAttachedPicture(stream),\n\t)\n\tif (hasVideo) {\n\t\treturn { kind: \"video\", mime, ...videoMetaFromPayload(payload) }\n\t}\n\tconst audio = audioInfoFromPayload(payload)\n\tif (audio !== undefined) return { kind: \"audio\", mime, ...audio }\n\treturn { kind: \"unknown\", reason: \"failed\" }\n}\n\n/**\n * Probe an audio/video source with ffprobe. Never rejects: a missing\n * binary, an unreadable container or a source ffprobe cannot demux all\n * resolve to `{ kind: \"unknown\", reason: \"failed\" }`.\n */\nexport async function probeAvMedia(\n\tsource: string | Readable,\n\topts: AvProbeOptions,\n): Promise<ProbeResult> {\n\ttry {\n\t\tconst inputFormat =\n\t\t\ttypeof source === \"string\" ? undefined : opts.inputFormat\n\t\tconst payload = await runFfprobeJson(source, getFfprobePath(), inputFormat)\n\t\treturn avResultFromPayload(payload, opts.mime)\n\t} catch {\n\t\treturn { kind: \"unknown\", reason: \"failed\" }\n\t}\n}\n","import { randomUUID } from \"node:crypto\"\nimport { mkdir, readFile, rename, rm, stat, writeFile } from \"node:fs/promises\"\nimport { join } from \"node:path\"\nimport type { MediaKind } from \"@hoardodile/sdk-types\"\nimport { fileTypeFromName } from \"@hoardodile/sdk-types\"\n\nimport { invalid } from \"../errors.ts\"\nimport {\n\textractSevenZipInto,\n\tlistSevenZipEntries,\n\tresolveSevenZipPath,\n} from \"./7z.ts\"\nimport {\n\tassertExtractedTree,\n\tdecodeZipNames,\n\tnormalizeExtractedTree,\n} from \"./extract.ts\"\nimport {\n\ttype ContainerFormat,\n\tSNIFF_WINDOW_BYTES,\n\tsniffContainerFormat,\n} from \"./format.ts\"\nimport { materializeFile } from \"./materialize.ts\"\nimport type { NestedCdCache } from \"./nested-cd-cache.ts\"\nimport {\n\ttype ArchiveEntry,\n\tcreateNestedResolver,\n\tcreateOuterArchiveSource,\n\ttype OuterEntrySource,\n} from \"./nested-entry.ts\"\nimport { streamRange } from \"./zip-entries.ts\"\n\n/**\n * Header window read before handing bytes to the injected image prober.\n * Shared with the host's sharp-backed probes so both sides feed the same\n * bounded window (JPEG SOF markers live in the first KBs; 256KB covers\n * even EXIF-bloated JPEGs).\n */\nexport const PROBE_HEADER_BYTES = 256 * 1024\n\n/** Input accepted by the injected image prober (host's sharp probe). */\nexport type ImageProbeInput = string | Buffer\n\n/** Result of the injected image prober. */\nexport type ImageProbeResult = {\n\treadonly width: number\n\treadonly height: number\n\treadonly animated: boolean\n}\n\n/**\n * Materialize a container entry (zip/tar/7z/rar/xz/gzip) into a cache\n * directory so the browser can serve the inner files over plain URLs.\n * This is the only disk-writing capability of the plugin API; everything\n * else about containers (cover probing, dimensions, hashes) reads\n * through virtual paths without materialization.\n *\n * Extraction is whole-archive through 7-Zip (budgets and paths validated\n * from its listing, then the extracted tree re-verified — see\n * `assertExtractedTree`). The manifest below is built from the listing,\n * so entries carry the exact names and sizes 7-Zip reported.\n *\n * Cache layout (per archive):\n * <cacheDir>/<archiveName>/index.json — completion marker + manifest\n * <cacheDir>/<archiveName>/<innerPath> — extracted file (subdirs kept)\n *\n * The `index.json` marker is written last and atomically: its presence\n * means extraction completed. A partial or failed run leaves files but no\n * marker, so the next call re-extracts idempotently.\n */\n\nexport const EXTRACT_INDEX_VERSION = 1\n\nexport type ExtractedEntry = {\n\treadonly path: string\n\treadonly sizeBytes: number\n\treadonly kind: MediaKind\n\treadonly width?: number\n\treadonly height?: number\n\treadonly animated?: boolean\n}\n\nexport type ExtractionResult = {\n\treadonly entries: readonly ExtractedEntry[]\n}\n\nexport type ExtractProgress = {\n\t/** Entries materialized so far (skipped cached files count too). */\n\treadonly done: number\n\treadonly total: number\n}\n\nexport type ExtractArchiveDeps = {\n\t/** Read access to the outer container (the resource's entries). */\n\treadonly outer: OuterEntrySource\n\t/**\n\t * Absolute cache directory for this resource's plugin extractions\n\t * (e.g. `<cacheRoot>/resources/<id>/extracted/v<N>/archives`).\n\t */\n\treadonly cacheDir: string\n\t/** Cumulative uncompressed byte budget for one archive. */\n\treadonly maxBytes: number\n\t/** Entry-count budget for one archive. */\n\treadonly maxEntries: number\n\t/** Optional image prober (sharp-backed) for per-entry dimensions. */\n\treadonly probeImage?: (\n\t\tsource: ImageProbeInput,\n\t\textHint?: string,\n\t) => Promise<ImageProbeResult | undefined>\n\t/**\n\t * Called as entries are materialized, so a long first extraction can\n\t * surface progress to the user.\n\t */\n\treadonly onProgress?: (progress: ExtractProgress) => void\n\t/** Shared nested-listing cache (see {@link NestedCdCache}). */\n\treadonly nestedCdCache?: NestedCdCache\n}\n\nexport type ArchiveExtractor = {\n\t/**\n\t * Materialize (or re-list) the container `archiveName`. Idempotent\n\t * and single-flighted per archive name.\n\t */\n\treadonly extract: (archiveName: string) => Promise<ExtractionResult>\n\t/**\n\t * List the container's entries without materializing anything into\n\t * the final cache layout. Resolves to `undefined` when `archiveName`\n\t * is not a container (or no 7-Zip binary exists for non-zip\n\t * formats).\n\t */\n\treadonly list: (\n\t\tarchiveName: string,\n\t) => Promise<readonly ArchiveEntry[] | undefined>\n}\n\n/** Build a single-flight, idempotent extractor over one outer container. */\nexport function createArchiveExtractor(\n\tdeps: ExtractArchiveDeps,\n): ArchiveExtractor {\n\tconst resolver = createNestedResolver(deps.outer, {\n\t\tcdCache: deps.nestedCdCache,\n\t})\n\tconst inflight = new Map<string, Promise<ExtractionResult>>()\n\n\tfunction extract(archiveName: string): Promise<ExtractionResult> {\n\t\tconst pending = inflight.get(archiveName)\n\t\tif (pending !== undefined) return pending\n\t\tconst work = extractOnce(archiveName)\n\t\tinflight.set(archiveName, work)\n\t\tvoid work.then(\n\t\t\t() => undefined,\n\t\t\t() => undefined,\n\t\t)\n\t\treturn work\n\t}\n\n\tasync function extractOnce(archiveName: string): Promise<ExtractionResult> {\n\t\tconst size = await deps.outer.sizeOf(archiveName)\n\t\tif (size === undefined) {\n\t\t\tthrow invalid(\n\t\t\t\t\"plugin.container_extract_failed\",\n\t\t\t\t`\"${archiveName}\" does not exist`,\n\t\t\t\t{ archiveName },\n\t\t\t)\n\t\t}\n\t\tconst markerPath = join(deps.cacheDir, archiveName, \"index.json\")\n\t\tconst existing = await readExistingManifest(markerPath, archiveName)\n\t\tif (existing !== undefined) return { entries: existing }\n\t\tif (size > 0) {\n\t\t\tconst head = await deps.outer.readSlice(\n\t\t\t\tarchiveName,\n\t\t\t\t0,\n\t\t\t\tMath.min(size, SNIFF_WINDOW_BYTES),\n\t\t\t)\n\t\t\tconst format = sniffContainerFormat(head)\n\t\t\tif (format !== undefined) {\n\t\t\t\treturn extractWithSevenZip(archiveName, size, markerPath, format)\n\t\t\t}\n\t\t}\n\t\tthrow invalid(\n\t\t\t\"plugin.container_extract_failed\",\n\t\t\t`\"${archiveName}\" is not a supported archive (zip/tar/7z/rar/xz/gzip)`,\n\t\t\t{ archiveName },\n\t\t)\n\t}\n\n\t/**\n\t * Whole-archive extraction through 7-Zip: materialize the outer entry\n\t * to a temp file (7-Zip needs a real path), validate budgets and\n\t * encryption from the listing, extract into the cache dir, then build\n\t * the idempotent manifest. Runs for every supported format — zip/tar\n\t * included, exactly like the 7z/rar/xz branch before them.\n\t */\n\tasync function extractWithSevenZip(\n\t\tarchiveName: string,\n\t\tsize: number,\n\t\tmarkerPath: string,\n\t\tformat: ContainerFormat,\n\t): Promise<ExtractionResult> {\n\t\tconst source = createOuterArchiveSource(deps.outer, archiveName, size)\n\t\tconst tempPath = join(\n\t\t\tdeps.cacheDir,\n\t\t\t`${archiveName}.7z-partial-${process.pid}-${randomUUID().slice(0, 8)}`,\n\t\t)\n\t\tawait mkdir(deps.cacheDir, { recursive: true })\n\t\ttry {\n\t\t\tawait materializeFile({\n\t\t\t\topenStream: () => streamRange(source, 0, size - 1),\n\t\t\t\ttarget: tempPath,\n\t\t\t\texpectedSize: size,\n\t\t\t})\n\t\t\tconst entries = await listSevenZipEntries(tempPath)\n\t\t\tconst files = entries.filter((e) => !e.folder)\n\t\t\tcheckExtractionBudgets(archiveName, files, deps.maxBytes, deps.maxEntries)\n\t\t\tif (entries.some((e) => e.encrypted)) {\n\t\t\t\tthrow invalid(\n\t\t\t\t\t\"plugin.container_extract_failed\",\n\t\t\t\t\t`archive \"${archiveName}\" is password-protected — encrypted archives are not supported`,\n\t\t\t\t\t{ archiveName },\n\t\t\t\t)\n\t\t\t}\n\t\t\t// 7-Zip's text listing drops invalid-UTF-8 bytes on POSIX, so\n\t\t\t// for zip archives the decoded names come from the archive\n\t\t\t// itself (yauzl): the manifest, the probes and the legacy-name\n\t\t\t// renames below all agree on them.\n\t\t\tconst decoded =\n\t\t\t\tformat === \"zip\"\n\t\t\t\t\t? await decodeZipNames(\n\t\t\t\t\t\t\ttempPath,\n\t\t\t\t\t\t\tfiles.map((e) => ({ name: e.name, sizeBytes: e.sizeBytes })),\n\t\t\t\t\t\t)\n\t\t\t\t\t: undefined\n\t\t\tconst materialized = decoded?.files ?? files\n\t\t\t// Pre-validate every path from the listing — 7-Zip itself\n\t\t\t// warns-and-skips unsafe names instead of failing.\n\t\t\tfor (const entry of materialized) sanitizeExtractPath(entry.name)\n\t\t\tconst root = join(deps.cacheDir, archiveName)\n\t\t\tawait mkdir(root, { recursive: true })\n\t\t\tawait extractSevenZipInto(tempPath, root)\n\t\t\t// 7-Zip writes legacy zip names verbatim on POSIX and restores\n\t\t\t// mode bits that can strip app access; fix both before the tree\n\t\t\t// is re-walked (symlink scan) or read for the manifest below.\n\t\t\t// The decoded names are the ground truth the legacy rename pass\n\t\t\t// matches against (see `renameLegacyZipNames` in extract.ts).\n\t\t\tawait normalizeExtractedTree(root, {\n\t\t\t\tlegacyZipNames: format === \"zip\",\n\t\t\t\texpectedNames: decoded?.paths,\n\t\t\t})\n\t\t\tawait assertExtractedTree(root, deps.maxBytes)\n\t\t\tdeps.onProgress?.({ done: files.length, total: files.length })\n\t\t\tconst manifest = await buildManifest(\n\t\t\t\tarchiveName,\n\t\t\t\tmaterialized.map((e) => ({ name: e.name, sizeBytes: e.sizeBytes })),\n\t\t\t)\n\t\t\tawait writeManifest(markerPath, archiveName, manifest)\n\t\t\t// The manifest now serves listings (and materialized virtual\n\t\t\t// paths); drop the size-keyed listing cache for this archive.\n\t\t\tawait rm(join(deps.cacheDir, `${archiveName}.7z-list`), {\n\t\t\t\tforce: true,\n\t\t\t}).catch(() => {})\n\t\t\tawait rm(join(deps.cacheDir, `${archiveName}.7z-list.json`), {\n\t\t\t\tforce: true,\n\t\t\t}).catch(() => {})\n\t\t\treturn { entries: manifest }\n\t\t} finally {\n\t\t\tawait rm(tempPath, { force: true }).catch(() => {})\n\t\t}\n\t}\n\n\t/** Shared bomb guards for one extraction: entry count and total bytes. */\n\tfunction checkExtractionBudgets(\n\t\tarchiveName: string,\n\t\tentries: readonly { readonly sizeBytes: number }[],\n\t\tmaxBytes: number,\n\t\tmaxEntries: number,\n\t): void {\n\t\tif (entries.length > maxEntries) {\n\t\t\tthrow invalid(\n\t\t\t\t\"plugin.container_extract_failed\",\n\t\t\t\t`archive \"${archiveName}\" has ${entries.length} entries, exceeding the limit of ${maxEntries}`,\n\t\t\t\t{ archiveName, entries: entries.length, maxEntries },\n\t\t\t)\n\t\t}\n\t\tlet totalBytes = 0\n\t\tfor (const entry of entries) totalBytes += entry.sizeBytes\n\t\tif (totalBytes > maxBytes) {\n\t\t\tthrow invalid(\n\t\t\t\t\"plugin.container_extract_failed\",\n\t\t\t\t`archive \"${archiveName}\" unpacks to ${totalBytes} bytes, exceeding the limit of ${maxBytes}`,\n\t\t\t\t{ archiveName, totalBytes, maxBytes },\n\t\t\t)\n\t\t}\n\t}\n\n\tasync function buildManifest(\n\t\tarchiveName: string,\n\t\tentries: readonly ArchiveEntry[],\n\t): Promise<readonly ExtractedEntry[]> {\n\t\tconst manifest: ExtractedEntry[] = []\n\t\tfor (const entry of entries) {\n\t\t\tconst kind = fileTypeFromName(entry.name)?.kind ?? \"other\"\n\t\t\tconst row: MutableExtractedEntry = {\n\t\t\t\tpath: entry.name,\n\t\t\t\tsizeBytes: entry.sizeBytes,\n\t\t\t\tkind,\n\t\t\t}\n\t\t\tif (kind === \"image\" && deps.probeImage !== undefined) {\n\t\t\t\tconst rel = sanitizeExtractPath(entry.name)\n\t\t\t\tconst filePath = join(deps.cacheDir, archiveName, rel)\n\t\t\t\tconst probe = await probeImageHead(\n\t\t\t\t\tdeps.probeImage,\n\t\t\t\t\tfilePath,\n\t\t\t\t\tentry.name,\n\t\t\t\t)\n\t\t\t\tif (probe !== undefined) {\n\t\t\t\t\trow.width = probe.width\n\t\t\t\t\trow.height = probe.height\n\t\t\t\t\trow.animated = probe.animated\n\t\t\t\t}\n\t\t\t}\n\t\t\tmanifest.push(row)\n\t\t}\n\t\treturn manifest\n\t}\n\n\t/**\n\t * List the container's entries. Zip lists from the nested resolver\n\t * (CD parse, nothing materialized); tar/7z/rar/xz list through the\n\t * 7-Zip binary over a size-keyed materialization cache in the cache\n\t * dir (7-Zip needs a real path; the bytes are written once per outer\n\t * size and reused across hook calls — every hook call builds a fresh\n\t * API instance, so per-call temp files would re-write the whole\n\t * archive each time). Resolves to `undefined` when the entry is not a\n\t * container, is gzip (whole-archive single-stream), or the 7-Zip\n\t * binary is absent (quiet degradation — callers treat it as \"not a\n\t * container\", matching the resolver semantics).\n\t */\n\tasync function list(\n\t\tarchiveName: string,\n\t): Promise<readonly ArchiveEntry[] | undefined> {\n\t\tconst size = await deps.outer.sizeOf(archiveName)\n\t\tif (size === undefined) return undefined\n\t\t// An extracted archive answers from its manifest — cheaper than\n\t\t// any listing, and the manifest is the source of truth for the\n\t\t// materialized virtual paths (see nested-view.ts).\n\t\tconst markerPath = join(deps.cacheDir, archiveName, \"index.json\")\n\t\tconst extracted = await readExistingManifest(markerPath, archiveName)\n\t\tif (extracted !== undefined) {\n\t\t\treturn extracted.map((e) => ({ name: e.path, sizeBytes: e.sizeBytes }))\n\t\t}\n\t\tif (size === 0) return undefined\n\t\tconst head = await deps.outer.readSlice(\n\t\t\tarchiveName,\n\t\t\t0,\n\t\t\tMath.min(size, SNIFF_WINDOW_BYTES),\n\t\t)\n\t\tconst format = sniffContainerFormat(head)\n\t\tif (format === \"zip\") {\n\t\t\tconst entries = await resolver.list(archiveName)\n\t\t\tif (entries === undefined) return undefined\n\t\t\treturn entries.map((e) => ({ name: e.name, sizeBytes: e.sizeBytes }))\n\t\t}\n\t\tif (\n\t\t\tformat === \"tar\" ||\n\t\t\tformat === \"7z\" ||\n\t\t\tformat === \"rar\" ||\n\t\t\tformat === \"xz\"\n\t\t) {\n\t\t\treturn listViaSevenZip(archiveName, size)\n\t\t}\n\t\treturn undefined\n\t}\n\n\t/**\n\t * Non-zip listing through 7-Zip. The outer entry is materialized once\n\t * per size into `<cacheDir>/<archiveName>.7z-list` (keyed by outer\n\t * size — the source is immutable per version, so a size match means\n\t * the cached bytes are the same file) and the parsed listing is\n\t * cached next to it. `extractWithSevenZip` removes both files once a\n\t * real extraction lands, so the manifest takes over afterwards.\n\t */\n\tasync function listViaSevenZip(\n\t\tarchiveName: string,\n\t\tsize: number,\n\t): Promise<readonly ArchiveEntry[] | undefined> {\n\t\tif (resolveSevenZipPath() === undefined) return undefined\n\t\tawait mkdir(deps.cacheDir, { recursive: true })\n\t\tconst cachePath = join(deps.cacheDir, `${archiveName}.7z-list`)\n\t\tconst entriesPath = join(deps.cacheDir, `${archiveName}.7z-list.json`)\n\t\tconst cached = await stat(cachePath).catch(() => undefined)\n\t\tif (cached?.size === size) {\n\t\t\tconst raw = await readFile(entriesPath, \"utf8\").catch(() => undefined)\n\t\t\tif (raw !== undefined) {\n\t\t\t\ttry {\n\t\t\t\t\treturn JSON.parse(raw) as readonly ArchiveEntry[]\n\t\t\t\t} catch {\n\t\t\t\t\t// Corrupt cache — fall through and re-materialize.\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t\ttry {\n\t\t\tconst source = createOuterArchiveSource(deps.outer, archiveName, size)\n\t\t\tawait materializeFile({\n\t\t\t\topenStream: () => streamRange(source, 0, size - 1),\n\t\t\t\ttarget: cachePath,\n\t\t\t\texpectedSize: size,\n\t\t\t})\n\t\t\tconst listed = await listSevenZipEntries(cachePath)\n\t\t\tconst files = listed\n\t\t\t\t.filter((e) => !e.folder)\n\t\t\t\t.map((e) => ({ name: e.name, sizeBytes: e.sizeBytes }))\n\t\t\tawait writeFile(entriesPath, JSON.stringify(files))\n\t\t\treturn files\n\t\t} catch (err) {\n\t\t\tawait rm(cachePath, { force: true }).catch(() => {})\n\t\t\tawait rm(entriesPath, { force: true }).catch(() => {})\n\t\t\tthrow err\n\t\t}\n\t}\n\n\treturn { extract, list }\n}\n\ntype MutableExtractedEntry = {\n\tpath: string\n\tsizeBytes: number\n\tkind: MediaKind\n\twidth?: number\n\theight?: number\n\tanimated?: boolean\n}\n\n/**\n * Read and validate a manifest written by {@link writeManifest}: version\n * and archive-name must match, otherwise the marker is stale garbage and\n * the caller re-extracts. Shared with the nested-view materialized-path\n * resolution.\n */\nexport async function readExistingManifest(\n\tmarkerPath: string,\n\tarchiveName: string,\n): Promise<readonly ExtractedEntry[] | undefined> {\n\tconst raw = await readFile(markerPath, \"utf8\").catch(() => undefined)\n\tif (raw === undefined) return undefined\n\ttry {\n\t\tconst parsed = JSON.parse(raw) as {\n\t\t\treadonly v?: number\n\t\t\treadonly archiveName?: string\n\t\t\treadonly entries?: readonly ExtractedEntry[]\n\t\t}\n\t\tif (\n\t\t\tparsed.v !== EXTRACT_INDEX_VERSION ||\n\t\t\tparsed.archiveName !== archiveName ||\n\t\t\t!Array.isArray(parsed.entries)\n\t\t) {\n\t\t\treturn undefined\n\t\t}\n\t\treturn parsed.entries\n\t} catch {\n\t\treturn undefined\n\t}\n}\n\nasync function writeManifest(\n\tmarkerPath: string,\n\tarchiveName: string,\n\tentries: readonly ExtractedEntry[],\n): Promise<void> {\n\tconst payload = JSON.stringify({\n\t\tv: EXTRACT_INDEX_VERSION,\n\t\tarchiveName,\n\t\tentries,\n\t})\n\tconst partial = `${markerPath}.partial-${process.pid}`\n\tawait writeFile(partial, payload)\n\tawait rename(partial, markerPath)\n}\n\n/**\n * Normalize an inner entry name to a safe relative path: backslashes\n * become forward slashes, traversal segments and control characters are\n * rejected, and the result must stay inside the extraction root.\n */\nexport function sanitizeExtractPath(name: string): string {\n\tconst normalized = name.replace(/\\\\/g, \"/\")\n\tif (normalized.startsWith(\"/\") || /^[A-Za-z]:/.test(normalized)) {\n\t\tthrow invalid(\n\t\t\t\"plugin.container_extract_failed\",\n\t\t\t`archive entry has an absolute path: \"${name}\"`,\n\t\t\t{ name },\n\t\t)\n\t}\n\tconst segments = normalized.split(\"/\")\n\tfor (const seg of segments) {\n\t\tif (seg.length === 0 || seg === \".\" || seg === \"..\") {\n\t\t\tthrow invalid(\n\t\t\t\t\"plugin.container_extract_failed\",\n\t\t\t\t`archive entry has an unsafe path: \"${name}\"`,\n\t\t\t\t{ name },\n\t\t\t)\n\t\t}\n\t\tif (hasControlChar(seg)) {\n\t\t\tthrow invalid(\n\t\t\t\t\"plugin.container_extract_failed\",\n\t\t\t\t`archive entry contains control characters: \"${name}\"`,\n\t\t\t\t{ name },\n\t\t\t)\n\t\t}\n\t}\n\treturn segments.join(\"/\")\n}\n\n/** True when any char code is a control character (C0 or DEL). */\nfunction hasControlChar(value: string): boolean {\n\tfor (let i = 0; i < value.length; i++) {\n\t\tconst code = value.charCodeAt(i)\n\t\tif (code < 0x20 || code === 0x7f) return true\n\t}\n\treturn false\n}\n\n/** Probe one extracted image's header window for dimensions. */\nasync function probeImageHead(\n\tprobeImage: NonNullable<ExtractArchiveDeps[\"probeImage\"]>,\n\tfilePath: string,\n\tname: string,\n): Promise<ImageProbeResult | undefined> {\n\tconst head = await readFile(filePath).then((b) =>\n\t\tb.subarray(0, PROBE_HEADER_BYTES),\n\t)\n\tif (head.length === 0) return undefined\n\tconst ext = name.slice(name.lastIndexOf(\".\")).toLowerCase()\n\treturn probeImage(head, ext)\n}\n","import { extname } from \"node:path\"\nimport type { Readable } from \"node:stream\"\nimport { IMAGE_EXTS } from \"@hoardodile/sdk-types/media-exts\"\nimport { PROBE_HEADER_BYTES } from \"../archive/index.ts\"\n\nimport type { ImageInfo } from \"../types.ts\"\n\n/**\n * Max zip entry size read into memory for thumb synthesis. Larger\n * entries are materialized to disk before sharp/ffmpeg runs.\n */\nexport const THUMB_BUFFER_MAX_BYTES = 32 * 1024 * 1024\n\n/**\n * Image probing via sharp. sharp is loaded lazily and declared as an\n * optional peer dependency: the host package stays free of native\n * binaries, and consumers that never probe images (the browser-side mock,\n * dependency-light CI installs) never pull it in. When sharp is missing,\n * probe calls fail loudly instead of degrading silently.\n */\ntype SharpModule = typeof import(\"sharp\")\n\nlet sharpPromise: Promise<SharpModule> | undefined\n\nexport function loadSharp(): Promise<SharpModule> {\n\tsharpPromise ??= import(\"sharp\").catch((err: unknown) => {\n\t\tsharpPromise = undefined\n\t\tthrow new Error(\n\t\t\t`sharp is not installed — add \"sharp\" to your dependencies to enable image probing: ${err instanceof Error ? err.message : String(err)}`,\n\t\t)\n\t})\n\treturn sharpPromise\n}\n\nexport type ImageSourceProbe = {\n\treadonly width: number\n\treadonly height: number\n\treadonly animated: boolean\n}\n\nconst ANIMATED_EXT_HINTS = new Set([\".gif\", \".webp\", \".avif\"])\n\nexport { PROBE_HEADER_BYTES } from \"../archive/index.ts\"\n\n/** True when `ext` names a format that can carry animation. */\nexport function isAnimatedCandidateExt(ext: string): boolean {\n\treturn ANIMATED_EXT_HINTS.has(ext)\n}\n\nexport type ImageMetadataInput =\n\t| string\n\t| Buffer\n\t| Readable\n\t| {\n\t\t\treadonly openStream: () => Promise<Readable>\n\t\t\t/**\n\t\t\t * Optional byte-range read into the underlying source (e.g. a\n\t\t\t * zip entry slice). When present, metadata probes read\n\t\t\t * {@link PROBE_HEADER_BYTES} from the start instead of\n\t\t\t * streaming the whole entry — libvips reads the ENTIRE file\n\t\t\t * for metadata from a non-seekable stream, but only the header\n\t\t\t * from a buffer.\n\t\t\t */\n\t\t\treadonly readRange?: (start: number, end: number) => Promise<Buffer>\n\t }\n\n/**\n * Narrow a Node stream into sharp's readable input without assertion.\n * Async because the sharp module itself is loaded lazily.\n *\n * sharp's constructor rejects `sharp(stream, opts)` — for stream input\n * the input options must be passed AS the first argument (a plain object\n * of input parameters like `pages`/`animated`) and the readable piped\n * into the resulting Duplex.\n */\nexport async function sharpFromReadable(\n\tstream: Readable,\n\toptions?: import(\"sharp\").SharpOptions,\n): Promise<import(\"sharp\").Sharp> {\n\tif (typeof stream.read !== \"function\") {\n\t\tthrow new Error(\"expected a readable stream\")\n\t}\n\tconst { default: sharp } = await loadSharp()\n\tconst descriptor =\n\t\toptions === undefined || Object.keys(options).length === 0\n\t\t\t? undefined\n\t\t\t: ({ ...options } as unknown as import(\"sharp\").SharpInput)\n\tconst instance = descriptor === undefined ? sharp() : sharp(descriptor)\n\tstream.pipe(instance)\n\treturn instance\n}\n\nfunction isReadable(input: unknown): input is Readable {\n\treturn (\n\t\ttypeof input === \"object\" &&\n\t\tinput !== null &&\n\t\ttypeof (input as Readable).pipe === \"function\" &&\n\t\t!(\"openStream\" in input)\n\t)\n}\n\nfunction isReopenableImageStream(\n\tinput: ImageMetadataInput,\n): input is { readonly openStream: () => Promise<Readable> } {\n\treturn (\n\t\ttypeof input === \"object\" &&\n\t\tinput !== null &&\n\t\t!Buffer.isBuffer(input) &&\n\t\t\"openStream\" in input &&\n\t\ttypeof (input as { openStream: unknown }).openStream === \"function\"\n\t)\n}\n\n/**\n * Sharp options for image thumb/probe reads. Enables sequential read for\n * large JPEGs on disk so libvips can shrink-on-load during resize.\n */\nexport function sharpImageInputOpts(\n\tinput: string | Buffer,\n\text: string,\n\tpages: number,\n\tanimated?: boolean,\n): import(\"sharp\").SharpOptions {\n\tconst opts: import(\"sharp\").SharpOptions = { pages }\n\tif (animated === true) opts.animated = true\n\tif (\n\t\ttypeof input === \"string\" &&\n\t\t(ext === \".jpg\" || ext === \".jpeg\" || ext === \".jfif\")\n\t) {\n\t\topts.sequentialRead = true\n\t}\n\treturn opts\n}\n\n/**\n * True when a shallow `{ pages: 1 }` metadata read is not enough to\n * decide animation and the full multi-page scan is required.\n */\nexport function needsFullAnimationScan(\n\tmeta: Pick<\n\t\timport(\"sharp\").Metadata,\n\t\t\"width\" | \"height\" | \"pages\" | \"pageHeight\"\n\t>,\n\text: string,\n): boolean {\n\tif (ext === \".gif\") return true\n\tif (ANIMATED_EXT_HINTS.has(ext) && (meta.pages ?? 1) > 1) return true\n\tif (\n\t\tmeta.pageHeight !== undefined &&\n\t\tmeta.height !== undefined &&\n\t\tmeta.pageHeight !== meta.height\n\t) {\n\t\treturn true\n\t}\n\treturn false\n}\n\n/**\n * Buffer a probe stream with a hard byte cap. Only used for formats whose\n * animation scan needs the whole stream (GIF): probing only needs\n * metadata, so an entry bigger than the cap is treated as unprobed rather\n * than buffered whole into host memory.\n */\nasync function bufferCapped(\n\tinput: Readable,\n\tmaxBytes: number,\n): Promise<Buffer> {\n\tconst chunks: Uint8Array[] = []\n\tlet total = 0\n\tfor await (const chunk of input) {\n\t\tconst bytes: Uint8Array =\n\t\t\ttypeof chunk === \"string\" ? Buffer.from(chunk) : chunk\n\t\ttotal += bytes.byteLength\n\t\tif (total > maxBytes) {\n\t\t\tinput.destroy()\n\t\t\tthrow new Error(`probe input exceeds the ${maxBytes}-byte cap`)\n\t\t}\n\t\tchunks.push(bytes)\n\t}\n\treturn Buffer.concat(chunks)\n}\n\n/**\n * Read image metadata with layered animation detection: static sources\n * stop after a single-page read; animated containers escalate to\n * `{ pages: -1 }` only when the shallow probe signals multi-frame input.\n */\nexport async function readImageMetadata(\n\tinput: ImageMetadataInput,\n\text: string,\n): Promise<{\n\treadonly meta: import(\"sharp\").Metadata\n\treadonly animated: boolean\n}> {\n\tif (isReopenableImageStream(input)) {\n\t\t// Header-slice fast path: probe a small leading window as a buffer\n\t\t// (header-only libvips read) instead of streaming the whole entry\n\t\t// (full-file read). Only definitive static results are accepted —\n\t\t// animation candidates escalate inside the slice read, and a\n\t\t// truncated buffer cannot complete a full scan, so those fall\n\t\t// through to the stream path where frame counts stay correct. GIF\n\t\t// is skipped outright (its frame count lives at the end).\n\t\tif (input.readRange !== undefined && ext !== \".gif\") {\n\t\t\tconst head = await input.readRange(0, PROBE_HEADER_BYTES)\n\t\t\tif (head.length > 0) {\n\t\t\t\ttry {\n\t\t\t\t\tconst { default: sharp } = await loadSharp()\n\t\t\t\t\tconst shallowMeta = await sharp(\n\t\t\t\t\t\thead,\n\t\t\t\t\t\tsharpImageInputOpts(head, ext, 1),\n\t\t\t\t\t).metadata()\n\t\t\t\t\tif (!needsFullAnimationScan(shallowMeta, ext)) {\n\t\t\t\t\t\treturn { meta: shallowMeta, animated: false }\n\t\t\t\t\t}\n\t\t\t\t} catch {\n\t\t\t\t\t// Truncated header unreadable — fall through to the\n\t\t\t\t\t// stream path.\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t\tconst stream = await input.openStream()\n\t\tconst shallow = await sharpFromReadable(stream, { pages: 1 })\n\t\tconst shallowMeta = await shallow.metadata()\n\t\tif (!needsFullAnimationScan(shallowMeta, ext)) {\n\t\t\treturn { meta: shallowMeta, animated: false }\n\t\t}\n\t\tconst fullStream = await input.openStream()\n\t\tconst fullMeta = await sharpFromReadable(fullStream, {\n\t\t\tpages: -1,\n\t\t\tanimated: true,\n\t\t}).then((s) => s.metadata())\n\t\treturn { meta: fullMeta, animated: (fullMeta.pages ?? 1) > 1 }\n\t}\n\tif (isReadable(input)) {\n\t\t// GIF frame counts are not stored in the container header, so the\n\t\t// full animation scan needs the whole stream — buffer cap-guarded\n\t\t// and reuse the buffer path.\n\t\tif (ext === \".gif\") {\n\t\t\tconst data = await bufferCapped(input, THUMB_BUFFER_MAX_BYTES)\n\t\t\treturn readImageMetadata(data, ext)\n\t\t}\n\t\t// Static formats: hand the stream straight to sharp so the probe\n\t\t// reads only the header bytes. (Buffering the whole entry first —\n\t\t// the previous behavior, capped at THUMB_BUFFER_MAX_BYTES — turned\n\t\t// every stream probe into a full read and made entries beyond the\n\t\t// cap unprobeable.)\n\t\ttry {\n\t\t\tconst shallow = await sharpFromReadable(input, { pages: 1 })\n\t\t\tconst shallowMeta = await shallow.metadata()\n\t\t\tif (!needsFullAnimationScan(shallowMeta, ext)) {\n\t\t\t\treturn { meta: shallowMeta, animated: false }\n\t\t\t}\n\t\t\t// Animated candidates (webp/avif/tiff) report their total page\n\t\t\t// count from the container header, so the shallow read is\n\t\t\t// conclusive — and the stream cannot be rewound for a full\n\t\t\t// scan anyway.\n\t\t\treturn {\n\t\t\t\tmeta: shallowMeta,\n\t\t\t\tanimated: (shallowMeta.pages ?? 1) > 1,\n\t\t\t}\n\t\t} finally {\n\t\t\tinput.destroy()\n\t\t}\n\t}\n\tconst { default: sharp } = await loadSharp()\n\tconst shallow = sharp(input, sharpImageInputOpts(input, ext, 1))\n\tconst shallowMeta = await shallow.metadata()\n\tif (!needsFullAnimationScan(shallowMeta, ext)) {\n\t\treturn { meta: shallowMeta, animated: false }\n\t}\n\tconst fullMeta = await sharp(\n\t\tinput,\n\t\tsharpImageInputOpts(input, ext, -1, true),\n\t).metadata()\n\treturn { meta: fullMeta, animated: (fullMeta.pages ?? 1) > 1 }\n}\n\n/**\n * Probe an image path or buffer and return pixel dimensions plus whether\n * the source is animated. Probe failures return `undefined`.\n */\nexport async function probeImageSource(\n\tinput: ImageMetadataInput,\n\textHint?: string,\n): Promise<ImageSourceProbe | undefined> {\n\tconst ext =\n\t\textHint ?? (typeof input === \"string\" ? extname(input).toLowerCase() : \"\")\n\tif (ext.length > 0 && !IMAGE_EXTS.has(ext)) return undefined\n\ttry {\n\t\tconst { meta, animated } = await readImageMetadata(input, ext)\n\t\tconst h = meta.pageHeight ?? meta.height\n\t\tif (meta.width === undefined || h === undefined) return undefined\n\t\treturn { width: meta.width, height: h, animated }\n\t} catch {\n\t\treturn undefined\n\t}\n}\n\n/**\n * Probe an image source and return its pixel dimensions. Probe failures\n * return `undefined` so callers can treat \"not yet probed\" and \"probe\n * failed\" the same way.\n */\nexport async function probeImage(\n\tsource: ImageMetadataInput,\n): Promise<ImageInfo | undefined> {\n\tconst probe = await probeImageSource(source)\n\tif (probe === undefined) return undefined\n\treturn { width: probe.width, height: probe.height }\n}\n\n/**\n * True when the image has more than one frame (animated GIF / WebP / APNG /\n * AVIF). Uses layered detection so static JPEG/PNG avoid a full-frame scan.\n * Errors are coerced to `false`.\n */\nexport async function probeAnimatedImage(\n\tsource: ImageMetadataInput,\n): Promise<boolean> {\n\tif (\n\t\ttypeof source === \"string\" &&\n\t\t!IMAGE_EXTS.has(extname(source).toLowerCase())\n\t) {\n\t\treturn false\n\t}\n\ttry {\n\t\tconst probe = await probeImageSource(source)\n\t\treturn probe?.animated ?? false\n\t} catch (err) {\n\t\tconst label = typeof source === \"string\" ? source : \"stream\"\n\t\tconsole.warn(\n\t\t\t`[probeAnimatedImage] sharp failed on ${label}: ${err instanceof Error ? err.message : String(err)}`,\n\t\t)\n\t\treturn false\n\t}\n}\n","import { fileTypeFromName } from \"@hoardodile/sdk-types\"\nimport { mimeToKind } from \"@hoardodile/sdk-types/media-exts\"\n\nimport type { FileType } from \"../types.ts\"\n\n/**\n * Content sniffing: what a file's bytes say it is, with the filename as\n * the fallback rather than the verdict.\n *\n * The layering mirrors what the ecosystem settled on (freedesktop's\n * shared-mime-info, Apache Tika, WHATWG mimesniff): magic-byte matching\n * decides for binary formats, and the extension only answers for\n * formats that carry no signature at all — text, subtitles, CSV. That\n * split is why a `.jpg` holding WebP bytes probes as WebP, while a\n * `.txt` still resolves to `text/plain`.\n */\n\n/**\n * Header window handed to the magic matcher. `file-type` documents 4100\n * bytes as the sample size that makes its detection deterministic; the\n * host reads exactly that much and no more, so sniffing an entry costs\n * one small ranged read.\n */\nexport const SNIFF_HEADER_BYTES = 4100\n\ntype FileTypeModule = typeof import(\"file-type\")\n\nlet fileTypePromise: Promise<FileTypeModule> | undefined\n\n/**\n * Load the magic-byte matcher lazily. Sniffing is opt-in per host (the\n * directory backend never sniffs), so an unused host pays nothing for\n * the module graph.\n */\nfunction loadFileType(): Promise<FileTypeModule> {\n\tfileTypePromise ??= import(\"file-type\")\n\treturn fileTypePromise\n}\n\n/**\n * Identify a file from a leading byte window plus its name. `head`\n * should hold the first {@link SNIFF_HEADER_BYTES} bytes; a shorter\n * slice still works for formats whose signature fits inside it.\n */\nexport async function sniffBytes(\n\thead: Uint8Array,\n\tpath: string,\n): Promise<FileType | undefined> {\n\tif (head.byteLength > 0) {\n\t\ttry {\n\t\t\tconst { fileTypeFromBuffer } = await loadFileType()\n\t\t\tconst detected = await fileTypeFromBuffer(head)\n\t\t\tif (detected !== undefined) {\n\t\t\t\treturn {\n\t\t\t\t\tmime: detected.mime,\n\t\t\t\t\text: `.${detected.ext}`,\n\t\t\t\t\tkind: mimeToKind(detected.mime),\n\t\t\t\t\tsource: \"magic\",\n\t\t\t\t}\n\t\t\t}\n\t\t} catch {\n\t\t\t// A malformed or truncated header is not an error condition —\n\t\t\t// fall through to the filename, same as a signature-less file.\n\t\t}\n\t}\n\treturn fileTypeFromName(path)\n}\n","/**\n * Probe implementations for the {@link ResourceAPI} builder. Consumed by\n * the server's plugin wiring and the CLI's bench — the same probing code\n * on both sides keeps \"test path = production path\". sharp and ffprobe\n * are optional peers loaded lazily; see `image.ts` / `video.ts` /\n * `audio.ts`.\n */\n\nimport { probeAvMedia } from \"./av.ts\"\nimport { probeImageSource } from \"./image.ts\"\n\nexport {\n\taudioInfoFromPayload,\n\textToAudioInputFormat,\n\tparseFfprobeAudioJson,\n\tprobeAudio,\n} from \"./audio.ts\"\nexport type { AvProbeOptions } from \"./av.ts\"\nexport { avResultFromPayload, probeAvMedia } from \"./av.ts\"\nexport { resolveFfprobePath } from \"./ffprobe.ts\"\nexport type { ImageMetadataInput, ImageSourceProbe } from \"./image.ts\"\nexport {\n\tisAnimatedCandidateExt,\n\tloadSharp,\n\tneedsFullAnimationScan,\n\tPROBE_HEADER_BYTES,\n\tprobeAnimatedImage,\n\tprobeImage,\n\tprobeImageSource,\n\treadImageMetadata,\n\tsharpFromReadable,\n\tsharpImageInputOpts,\n\tTHUMB_BUFFER_MAX_BYTES,\n} from \"./image.ts\"\nexport { SNIFF_HEADER_BYTES, sniffBytes } from \"./sniff.ts\"\nexport type { ProbedVideoMeta } from \"./video.ts\"\nexport {\n\textToFfmpegInputFormat,\n\tparseFfprobeJson,\n\tprobeVideo,\n\tprobeVideoMeta,\n} from \"./video.ts\"\n\n/**\n * The host's real probe implementations, shaped for\n * `createPluginResourceAPI({ view, ...mediaProbes })`. Every consumer\n * (server import path, trash fallback, CLI) spreads this same object,\n * so \"what you test is what runs in production\" holds by construction\n * instead of by three copies of the same wiring.\n */\nexport const mediaProbes = {\n\tprobeImage: probeImageSource,\n\tprobeAv: probeAvMedia,\n} as const\n"]}
@@ -8,9 +8,9 @@ type FfmpegPaths = {
8
8
  type ResolveDeps = {
9
9
  readonly env?: NodeJS.ProcessEnv;
10
10
  /** Override for tests so they never hit the real module. */
11
- readonly loadStatic?: () => string | undefined;
11
+ readonly loadStatic?: () => string | null | undefined;
12
12
  /** Override for tests so they never hit the real module. */
13
- readonly loadStaticFfprobe?: () => string | undefined;
13
+ readonly loadStaticFfprobe?: () => string | null | undefined;
14
14
  };
15
15
  declare function resolveFfmpegPaths(deps?: ResolveDeps): FfmpegPaths;
16
16
 
@@ -20,10 +20,10 @@ function resolveFfmpegPaths(deps = {}) {
20
20
  return { ffmpeg, ffprobe };
21
21
  }
22
22
  function loadInstallerFfmpeg() {
23
- return loadInstallerPath("ffmpeg-static");
23
+ return loadInstallerPath("@hoardodile/ffmpeg-bin");
24
24
  }
25
25
  function loadInstallerFfprobe() {
26
- return loadInstallerPath("@derhuerst/ffprobe-static");
26
+ return loadInstallerPath("@hoardodile/ffprobe-bin");
27
27
  }
28
28
  function loadInstallerPath(moduleName) {
29
29
  try {
@@ -1 +1 @@
1
- {"version":3,"sources":["../../src/render/ffmpeg.ts","../../src/probe/audio.ts","../../src/probe/video.ts","../../src/archive/extract-archive.ts","../../src/probe/image.ts","../../src/render/pipeline.ts"],"names":["sharp","shallowMeta","shallow","fullMeta","join","rm","isReopenableImageStream","extname","mkdir","rename"],"mappings":"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","file":"index.js","sourcesContent":["/**\n * Resolve the absolute paths to `ffmpeg` / `ffprobe` for this host.\n *\n * Precedence:\n * 1. Explicit env vars (`FFMPEG_PATH`, `FFPROBE_PATH`).\n * 2. `ffmpeg-static` and `@derhuerst/ffprobe-static` via lazy\n * `createRequire`, so they stay out of the host tarball. Dev\n * resolves them from this package's `node_modules`; `pnpm start`\n * and the desktop sidecar resolve them from the server\n * `dist/node_modules` copy.\n * 3. A bare command name (`ffmpeg` / `ffprobe`), which lets operators\n * who have the binaries on PATH still run the server directly.\n */\nimport { createRequire } from \"node:module\"\n\nconst requireCjs = createRequire(import.meta.url)\n\nexport type FfmpegPaths = {\n\treadonly ffmpeg: string\n\treadonly ffprobe: string\n}\n\ntype ResolveDeps = {\n\treadonly env?: NodeJS.ProcessEnv\n\t/** Override for tests so they never hit the real module. */\n\treadonly loadStatic?: () => string | undefined\n\t/** Override for tests so they never hit the real module. */\n\treadonly loadStaticFfprobe?: () => string | undefined\n}\n\nexport function resolveFfmpegPaths(deps: ResolveDeps = {}): FfmpegPaths {\n\tconst env = deps.env ?? process.env\n\tconst ffmpegEnv = env.FFMPEG_PATH\n\tconst ffprobeEnv = env.FFPROBE_PATH\n\tconst staticFfmpeg =\n\t\tffmpegEnv === undefined || ffmpegEnv.length === 0\n\t\t\t? (deps.loadStatic ?? loadInstallerFfmpeg)()\n\t\t\t: undefined\n\tconst ffmpeg =\n\t\tffmpegEnv !== undefined && ffmpegEnv.length > 0\n\t\t\t? ffmpegEnv\n\t\t\t: (staticFfmpeg ?? \"ffmpeg\")\n\tconst staticFfprobe =\n\t\tffprobeEnv === undefined || ffprobeEnv.length === 0\n\t\t\t? (deps.loadStaticFfprobe ?? loadInstallerFfprobe)()\n\t\t\t: undefined\n\tconst ffprobe =\n\t\tffprobeEnv !== undefined && ffprobeEnv.length > 0\n\t\t\t? ffprobeEnv\n\t\t\t: (staticFfprobe ?? \"ffprobe\")\n\treturn { ffmpeg, ffprobe }\n}\n\nfunction loadInstallerFfmpeg(): string | undefined {\n\treturn loadInstallerPath(\"ffmpeg-static\")\n}\n\nfunction loadInstallerFfprobe(): string | undefined {\n\treturn loadInstallerPath(\"@derhuerst/ffprobe-static\")\n}\n\nfunction loadInstallerPath(moduleName: string): string | undefined {\n\ttry {\n\t\tconst mod: unknown = requireCjs(moduleName)\n\t\tif (typeof mod === \"string\" && mod.length > 0) return mod\n\t\tif (\n\t\t\tmod !== null &&\n\t\t\ttypeof mod === \"object\" &&\n\t\t\t\"path\" in mod &&\n\t\t\ttypeof (mod as { path: unknown }).path === \"string\"\n\t\t) {\n\t\t\treturn (mod as { path: string }).path\n\t\t}\n\t\treturn undefined\n\t} catch {\n\t\treturn undefined\n\t}\n}\n","import type { Readable } from \"node:stream\"\nimport { AUDIO_FFMPEG_INPUT_FORMAT } from \"@hoardodile/sdk-types/media-exts\"\nimport type { AudioCoverArt, AudioInfo, AudioTags } from \"../types.ts\"\nimport {\n\ttype FfprobePayload,\n\ttype FfprobeStream,\n\tffprobeDurationMs,\n\tffprobeInt,\n\tgetFfprobePath,\n\tparseFfprobePayload,\n\trunFfprobeJson,\n} from \"./ffprobe.ts\"\n\n/**\n * Audio probing via ffprobe (see `ffprobe.ts` for path resolution and the\n * process plumbing). One probe answers everything the host needs about an\n * audio file: playable duration, stream parameters, whether the container\n * carries embedded artwork, and the human-facing tags.\n */\n\n/** Resolve an extension to its ffmpeg `-f` container name for piped input. */\nexport function extToAudioInputFormat(ext: string): string | undefined {\n\treturn AUDIO_FFMPEG_INPUT_FORMAT[ext.toLowerCase()]\n}\n\n/**\n * Pure parser; broken out so callers and tests can hit it without\n * spawning ffprobe. Returns `undefined` when the payload carries no\n * audio stream at all — a video file probed as audio must not produce a\n * half-filled result.\n */\nexport function parseFfprobeAudioJson(json: string): AudioInfo | undefined {\n\treturn audioInfoFromPayload(parseFfprobePayload(json))\n}\n\nexport function audioInfoFromPayload(\n\tpayload: FfprobePayload,\n): AudioInfo | undefined {\n\tconst streams = payload.streams ?? []\n\tconst audioStream = streams.find((s) => s.codec_type === \"audio\")\n\tif (audioStream === undefined) return undefined\n\n\tconst result: {\n\t\tdurationMs?: number\n\t\tcodec?: string\n\t\tbitRate?: number\n\t\tsampleRate?: number\n\t\tchannels?: number\n\t\tcoverArt?: AudioCoverArt\n\t\ttags?: AudioTags\n\t} = {}\n\n\tconst durationMs = ffprobeDurationMs(payload.format)\n\tif (durationMs !== undefined) result.durationMs = durationMs\n\tif (\n\t\ttypeof audioStream.codec_name === \"string\" &&\n\t\taudioStream.codec_name.length > 0\n\t) {\n\t\tresult.codec = audioStream.codec_name\n\t}\n\tconst bitRate = ffprobeInt(payload.format?.bit_rate)\n\tif (bitRate !== undefined) result.bitRate = bitRate\n\tconst sampleRate = ffprobeInt(audioStream.sample_rate)\n\tif (sampleRate !== undefined) result.sampleRate = sampleRate\n\tconst channels = ffprobeInt(audioStream.channels)\n\tif (channels !== undefined) result.channels = channels\n\tconst picture = streams.find(isAttachedPicture)\n\tif (picture !== undefined) {\n\t\tresult.coverArt = {\n\t\t\twidth: ffprobeInt(picture.width),\n\t\t\theight: ffprobeInt(picture.height),\n\t\t}\n\t}\n\tconst tags = readAudioTags(payload)\n\tif (tags !== undefined) result.tags = tags\n\n\treturn result\n}\n\n/**\n * True for the still image ffmpeg exposes as a video stream: ID3 APIC\n * frames, FLAC PICTURE blocks and MP4 `covr` atoms all surface this way.\n */\nfunction isAttachedPicture(stream: FfprobeStream): boolean {\n\tif (stream.codec_type !== \"video\") return false\n\tconst disposition = stream.disposition\n\tif (typeof disposition !== \"object\" || disposition === null) return false\n\treturn (disposition as Record<string, unknown>).attached_pic === 1\n}\n\n/**\n * Read the well-known tags off the container, falling back to the audio\n * stream's own tag table (Vorbis comments in Matroska/Ogg land there).\n * Tag keys vary in case between muxers, so lookups are case-insensitive.\n */\nfunction readAudioTags(payload: FfprobePayload): AudioTags | undefined {\n\tconst tables = [\n\t\tpayload.format?.tags,\n\t\tpayload.streams?.find((s) => s.codec_type === \"audio\")?.tags,\n\t]\n\tconst lookup = new Map<string, string>()\n\tfor (const table of tables) {\n\t\tif (typeof table !== \"object\" || table === null) continue\n\t\tfor (const [key, value] of Object.entries(table)) {\n\t\t\tif (typeof value !== \"string\" || value.length === 0) continue\n\t\t\tconst normalized = key.toLowerCase()\n\t\t\tif (!lookup.has(normalized)) lookup.set(normalized, value)\n\t\t}\n\t}\n\tconst tags: { title?: string; artist?: string; album?: string } = {}\n\tconst title = lookup.get(\"title\")\n\tif (title !== undefined) tags.title = title\n\tconst artist = lookup.get(\"artist\") ?? lookup.get(\"album_artist\")\n\tif (artist !== undefined) tags.artist = artist\n\tconst album = lookup.get(\"album\")\n\tif (album !== undefined) tags.album = album\n\treturn Object.keys(tags).length === 0 ? undefined : tags\n}\n\n/**\n * Probe an audio stream or path with ffprobe. Probe failures — a missing\n * binary, an unreadable container, or a source with no audio stream —\n * return `undefined` so callers can treat \"not yet probed\" and \"probe\n * failed\" the same way.\n */\nexport async function probeAudio(\n\tsource: string | Readable,\n\textHint?: string,\n): Promise<AudioInfo | undefined> {\n\ttry {\n\t\tconst inputFormat =\n\t\t\ttypeof source === \"string\" || extHint === undefined\n\t\t\t\t? undefined\n\t\t\t\t: extToAudioInputFormat(extHint)\n\t\tconst payload = await runFfprobeJson(source, getFfprobePath(), inputFormat)\n\t\treturn audioInfoFromPayload(payload)\n\t} catch {\n\t\treturn undefined\n\t}\n}\n","import type { Readable } from \"node:stream\"\nimport type { VideoInfo } from \"../types.ts\"\nimport {\n\ttype FfprobePayload,\n\tffprobeDurationMs,\n\tgetFfprobePath,\n\tparseFfprobePayload,\n\trunFfprobeJson,\n} from \"./ffprobe.ts\"\n\n/**\n * ffmpeg `-f` container name for piped zip entry bytes (no filename hint).\n */\nconst FFMPEG_INPUT_FORMAT: Readonly<Record<string, string>> = {\n\t\".mp4\": \"mp4\",\n\t\".m4v\": \"mp4\",\n\t\".webm\": \"webm\",\n\t\".mov\": \"mov\",\n\t\".mkv\": \"matroska\",\n\t\".avi\": \"avi\",\n}\n\n/** Resolve an extension to its ffmpeg `-f` container name, or `undefined` for unknown extensions. */\nexport function extToFfmpegInputFormat(ext: string): string | undefined {\n\treturn FFMPEG_INPUT_FORMAT[ext.toLowerCase()]\n}\n\n/**\n * Video probing via ffprobe (see `ffprobe.ts` for path resolution and the\n * process plumbing).\n */\n\n/**\n * Source-media metadata derived by ffprobe - pixel dimensions of the first\n * video stream and the playable duration in milliseconds. Any field can be\n * absent when ffprobe declines to report it (corrupt header, missing\n * container metadata). Callers persist whichever fields are populated.\n */\nexport type ProbedVideoMeta = {\n\treadonly width?: number\n\treadonly height?: number\n\treadonly durationMs?: number\n}\n\n/**\n * Run `ffprobe` against `source` and extract width, height and duration.\n *\n * @throws `Error` with stderr when ffprobe exits non-zero or emits no JSON.\n */\nexport async function probeVideoMeta(\n\tsource: string | Readable,\n\tffprobePath: string,\n\tinputFormat?: string,\n): Promise<ProbedVideoMeta> {\n\treturn videoMetaFromPayload(\n\t\tawait runFfprobeJson(source, ffprobePath, inputFormat),\n\t)\n}\n\n/**\n * Pure parser; broken out so callers and tests can hit it without spawning\n * ffprobe. Returns only the fields that survive type narrowing - partial\n * payloads are normal for malformed media and we prefer surfacing what we\n * have over rejecting the whole probe.\n */\nexport function parseFfprobeJson(json: string): ProbedVideoMeta {\n\treturn videoMetaFromPayload(parseFfprobePayload(json))\n}\n\nexport function videoMetaFromPayload(payload: FfprobePayload): ProbedVideoMeta {\n\tconst result: { width?: number; height?: number; durationMs?: number } = {}\n\tconst videoStream = payload.streams?.find((s) => s.codec_type === \"video\")\n\tif (videoStream !== undefined) {\n\t\tif (typeof videoStream.width === \"number\" && videoStream.width > 0) {\n\t\t\tresult.width = Math.round(videoStream.width)\n\t\t}\n\t\tif (typeof videoStream.height === \"number\" && videoStream.height > 0) {\n\t\t\tresult.height = Math.round(videoStream.height)\n\t\t}\n\t}\n\tconst durationMs = ffprobeDurationMs(payload.format)\n\tif (durationMs !== undefined) result.durationMs = durationMs\n\treturn result\n}\n\n/**\n * Probe a video stream or path with ffprobe and return its pixel\n * dimensions and duration. Probe failures return `undefined` so callers\n * can treat \"not yet probed\" and \"probe failed\" the same way.\n */\nexport async function probeVideo(\n\tsource: string | Readable,\n\textHint?: string,\n): Promise<VideoInfo | undefined> {\n\ttry {\n\t\tconst inputFormat =\n\t\t\ttypeof source === \"string\" || extHint === undefined\n\t\t\t\t? undefined\n\t\t\t\t: extToFfmpegInputFormat(extHint)\n\t\tconst probed = await probeVideoMeta(source, getFfprobePath(), inputFormat)\n\t\tif (\n\t\t\tprobed.width === undefined &&\n\t\t\tprobed.height === undefined &&\n\t\t\tprobed.durationMs === undefined\n\t\t) {\n\t\t\treturn undefined\n\t\t}\n\t\treturn {\n\t\t\twidth: probed.width,\n\t\t\theight: probed.height,\n\t\t\tdurationMs: probed.durationMs,\n\t\t}\n\t} catch {\n\t\treturn undefined\n\t}\n}\n","import { randomUUID } from \"node:crypto\"\nimport { mkdir, readFile, rename, rm, stat, writeFile } from \"node:fs/promises\"\nimport { join } from \"node:path\"\nimport type { MediaKind } from \"@hoardodile/sdk-types\"\nimport { fileTypeFromName } from \"@hoardodile/sdk-types\"\n\nimport { invalid } from \"../errors.ts\"\nimport {\n\textractSevenZipInto,\n\tlistSevenZipEntries,\n\tresolveSevenZipPath,\n} from \"./7z.ts\"\nimport {\n\tassertExtractedTree,\n\tdecodeZipNames,\n\tnormalizeExtractedTree,\n} from \"./extract.ts\"\nimport {\n\ttype ContainerFormat,\n\tSNIFF_WINDOW_BYTES,\n\tsniffContainerFormat,\n} from \"./format.ts\"\nimport { materializeFile } from \"./materialize.ts\"\nimport type { NestedCdCache } from \"./nested-cd-cache.ts\"\nimport {\n\ttype ArchiveEntry,\n\tcreateNestedResolver,\n\tcreateOuterArchiveSource,\n\ttype OuterEntrySource,\n} from \"./nested-entry.ts\"\nimport { streamRange } from \"./zip-entries.ts\"\n\n/**\n * Header window read before handing bytes to the injected image prober.\n * Shared with the host's sharp-backed probes so both sides feed the same\n * bounded window (JPEG SOF markers live in the first KBs; 256KB covers\n * even EXIF-bloated JPEGs).\n */\nexport const PROBE_HEADER_BYTES = 256 * 1024\n\n/** Input accepted by the injected image prober (host's sharp probe). */\nexport type ImageProbeInput = string | Buffer\n\n/** Result of the injected image prober. */\nexport type ImageProbeResult = {\n\treadonly width: number\n\treadonly height: number\n\treadonly animated: boolean\n}\n\n/**\n * Materialize a container entry (zip/tar/7z/rar/xz/gzip) into a cache\n * directory so the browser can serve the inner files over plain URLs.\n * This is the only disk-writing capability of the plugin API; everything\n * else about containers (cover probing, dimensions, hashes) reads\n * through virtual paths without materialization.\n *\n * Extraction is whole-archive through 7-Zip (budgets and paths validated\n * from its listing, then the extracted tree re-verified — see\n * `assertExtractedTree`). The manifest below is built from the listing,\n * so entries carry the exact names and sizes 7-Zip reported.\n *\n * Cache layout (per archive):\n * <cacheDir>/<archiveName>/index.json — completion marker + manifest\n * <cacheDir>/<archiveName>/<innerPath> — extracted file (subdirs kept)\n *\n * The `index.json` marker is written last and atomically: its presence\n * means extraction completed. A partial or failed run leaves files but no\n * marker, so the next call re-extracts idempotently.\n */\n\nexport const EXTRACT_INDEX_VERSION = 1\n\nexport type ExtractedEntry = {\n\treadonly path: string\n\treadonly sizeBytes: number\n\treadonly kind: MediaKind\n\treadonly width?: number\n\treadonly height?: number\n\treadonly animated?: boolean\n}\n\nexport type ExtractionResult = {\n\treadonly entries: readonly ExtractedEntry[]\n}\n\nexport type ExtractProgress = {\n\t/** Entries materialized so far (skipped cached files count too). */\n\treadonly done: number\n\treadonly total: number\n}\n\nexport type ExtractArchiveDeps = {\n\t/** Read access to the outer container (the resource's entries). */\n\treadonly outer: OuterEntrySource\n\t/**\n\t * Absolute cache directory for this resource's plugin extractions\n\t * (e.g. `<cacheRoot>/resources/<id>/extracted/v<N>/archives`).\n\t */\n\treadonly cacheDir: string\n\t/** Cumulative uncompressed byte budget for one archive. */\n\treadonly maxBytes: number\n\t/** Entry-count budget for one archive. */\n\treadonly maxEntries: number\n\t/** Optional image prober (sharp-backed) for per-entry dimensions. */\n\treadonly probeImage?: (\n\t\tsource: ImageProbeInput,\n\t\textHint?: string,\n\t) => Promise<ImageProbeResult | undefined>\n\t/**\n\t * Called as entries are materialized, so a long first extraction can\n\t * surface progress to the user.\n\t */\n\treadonly onProgress?: (progress: ExtractProgress) => void\n\t/** Shared nested-listing cache (see {@link NestedCdCache}). */\n\treadonly nestedCdCache?: NestedCdCache\n}\n\nexport type ArchiveExtractor = {\n\t/**\n\t * Materialize (or re-list) the container `archiveName`. Idempotent\n\t * and single-flighted per archive name.\n\t */\n\treadonly extract: (archiveName: string) => Promise<ExtractionResult>\n\t/**\n\t * List the container's entries without materializing anything into\n\t * the final cache layout. Resolves to `undefined` when `archiveName`\n\t * is not a container (or no 7-Zip binary exists for non-zip\n\t * formats).\n\t */\n\treadonly list: (\n\t\tarchiveName: string,\n\t) => Promise<readonly ArchiveEntry[] | undefined>\n}\n\n/** Build a single-flight, idempotent extractor over one outer container. */\nexport function createArchiveExtractor(\n\tdeps: ExtractArchiveDeps,\n): ArchiveExtractor {\n\tconst resolver = createNestedResolver(deps.outer, {\n\t\tcdCache: deps.nestedCdCache,\n\t})\n\tconst inflight = new Map<string, Promise<ExtractionResult>>()\n\n\tfunction extract(archiveName: string): Promise<ExtractionResult> {\n\t\tconst pending = inflight.get(archiveName)\n\t\tif (pending !== undefined) return pending\n\t\tconst work = extractOnce(archiveName)\n\t\tinflight.set(archiveName, work)\n\t\tvoid work.then(\n\t\t\t() => undefined,\n\t\t\t() => undefined,\n\t\t)\n\t\treturn work\n\t}\n\n\tasync function extractOnce(archiveName: string): Promise<ExtractionResult> {\n\t\tconst size = await deps.outer.sizeOf(archiveName)\n\t\tif (size === undefined) {\n\t\t\tthrow invalid(\n\t\t\t\t\"plugin.container_extract_failed\",\n\t\t\t\t`\"${archiveName}\" does not exist`,\n\t\t\t\t{ archiveName },\n\t\t\t)\n\t\t}\n\t\tconst markerPath = join(deps.cacheDir, archiveName, \"index.json\")\n\t\tconst existing = await readExistingManifest(markerPath, archiveName)\n\t\tif (existing !== undefined) return { entries: existing }\n\t\tif (size > 0) {\n\t\t\tconst head = await deps.outer.readSlice(\n\t\t\t\tarchiveName,\n\t\t\t\t0,\n\t\t\t\tMath.min(size, SNIFF_WINDOW_BYTES),\n\t\t\t)\n\t\t\tconst format = sniffContainerFormat(head)\n\t\t\tif (format !== undefined) {\n\t\t\t\treturn extractWithSevenZip(archiveName, size, markerPath, format)\n\t\t\t}\n\t\t}\n\t\tthrow invalid(\n\t\t\t\"plugin.container_extract_failed\",\n\t\t\t`\"${archiveName}\" is not a supported archive (zip/tar/7z/rar/xz/gzip)`,\n\t\t\t{ archiveName },\n\t\t)\n\t}\n\n\t/**\n\t * Whole-archive extraction through 7-Zip: materialize the outer entry\n\t * to a temp file (7-Zip needs a real path), validate budgets and\n\t * encryption from the listing, extract into the cache dir, then build\n\t * the idempotent manifest. Runs for every supported format — zip/tar\n\t * included, exactly like the 7z/rar/xz branch before them.\n\t */\n\tasync function extractWithSevenZip(\n\t\tarchiveName: string,\n\t\tsize: number,\n\t\tmarkerPath: string,\n\t\tformat: ContainerFormat,\n\t): Promise<ExtractionResult> {\n\t\tconst source = createOuterArchiveSource(deps.outer, archiveName, size)\n\t\tconst tempPath = join(\n\t\t\tdeps.cacheDir,\n\t\t\t`${archiveName}.7z-partial-${process.pid}-${randomUUID().slice(0, 8)}`,\n\t\t)\n\t\tawait mkdir(deps.cacheDir, { recursive: true })\n\t\ttry {\n\t\t\tawait materializeFile({\n\t\t\t\topenStream: () => streamRange(source, 0, size - 1),\n\t\t\t\ttarget: tempPath,\n\t\t\t\texpectedSize: size,\n\t\t\t})\n\t\t\tconst entries = await listSevenZipEntries(tempPath)\n\t\t\tconst files = entries.filter((e) => !e.folder)\n\t\t\tcheckExtractionBudgets(archiveName, files, deps.maxBytes, deps.maxEntries)\n\t\t\tif (entries.some((e) => e.encrypted)) {\n\t\t\t\tthrow invalid(\n\t\t\t\t\t\"plugin.container_extract_failed\",\n\t\t\t\t\t`archive \"${archiveName}\" is password-protected — encrypted archives are not supported`,\n\t\t\t\t\t{ archiveName },\n\t\t\t\t)\n\t\t\t}\n\t\t\t// 7-Zip's text listing drops invalid-UTF-8 bytes on POSIX, so\n\t\t\t// for zip archives the decoded names come from the archive\n\t\t\t// itself (yauzl): the manifest, the probes and the legacy-name\n\t\t\t// renames below all agree on them.\n\t\t\tconst decoded =\n\t\t\t\tformat === \"zip\"\n\t\t\t\t\t? await decodeZipNames(\n\t\t\t\t\t\t\ttempPath,\n\t\t\t\t\t\t\tfiles.map((e) => ({ name: e.name, sizeBytes: e.sizeBytes })),\n\t\t\t\t\t\t)\n\t\t\t\t\t: undefined\n\t\t\tconst materialized = decoded?.files ?? files\n\t\t\t// Pre-validate every path from the listing — 7-Zip itself\n\t\t\t// warns-and-skips unsafe names instead of failing.\n\t\t\tfor (const entry of materialized) sanitizeExtractPath(entry.name)\n\t\t\tconst root = join(deps.cacheDir, archiveName)\n\t\t\tawait mkdir(root, { recursive: true })\n\t\t\tawait extractSevenZipInto(tempPath, root)\n\t\t\t// 7-Zip writes legacy zip names verbatim on POSIX and restores\n\t\t\t// mode bits that can strip app access; fix both before the tree\n\t\t\t// is re-walked (symlink scan) or read for the manifest below.\n\t\t\t// The decoded names are the ground truth the legacy rename pass\n\t\t\t// matches against (see `renameLegacyZipNames` in extract.ts).\n\t\t\tawait normalizeExtractedTree(root, {\n\t\t\t\tlegacyZipNames: format === \"zip\",\n\t\t\t\texpectedNames: decoded?.paths,\n\t\t\t})\n\t\t\tawait assertExtractedTree(root, deps.maxBytes)\n\t\t\tdeps.onProgress?.({ done: files.length, total: files.length })\n\t\t\tconst manifest = await buildManifest(\n\t\t\t\tarchiveName,\n\t\t\t\tmaterialized.map((e) => ({ name: e.name, sizeBytes: e.sizeBytes })),\n\t\t\t)\n\t\t\tawait writeManifest(markerPath, archiveName, manifest)\n\t\t\t// The manifest now serves listings (and materialized virtual\n\t\t\t// paths); drop the size-keyed listing cache for this archive.\n\t\t\tawait rm(join(deps.cacheDir, `${archiveName}.7z-list`), {\n\t\t\t\tforce: true,\n\t\t\t}).catch(() => {})\n\t\t\tawait rm(join(deps.cacheDir, `${archiveName}.7z-list.json`), {\n\t\t\t\tforce: true,\n\t\t\t}).catch(() => {})\n\t\t\treturn { entries: manifest }\n\t\t} finally {\n\t\t\tawait rm(tempPath, { force: true }).catch(() => {})\n\t\t}\n\t}\n\n\t/** Shared bomb guards for one extraction: entry count and total bytes. */\n\tfunction checkExtractionBudgets(\n\t\tarchiveName: string,\n\t\tentries: readonly { readonly sizeBytes: number }[],\n\t\tmaxBytes: number,\n\t\tmaxEntries: number,\n\t): void {\n\t\tif (entries.length > maxEntries) {\n\t\t\tthrow invalid(\n\t\t\t\t\"plugin.container_extract_failed\",\n\t\t\t\t`archive \"${archiveName}\" has ${entries.length} entries, exceeding the limit of ${maxEntries}`,\n\t\t\t\t{ archiveName, entries: entries.length, maxEntries },\n\t\t\t)\n\t\t}\n\t\tlet totalBytes = 0\n\t\tfor (const entry of entries) totalBytes += entry.sizeBytes\n\t\tif (totalBytes > maxBytes) {\n\t\t\tthrow invalid(\n\t\t\t\t\"plugin.container_extract_failed\",\n\t\t\t\t`archive \"${archiveName}\" unpacks to ${totalBytes} bytes, exceeding the limit of ${maxBytes}`,\n\t\t\t\t{ archiveName, totalBytes, maxBytes },\n\t\t\t)\n\t\t}\n\t}\n\n\tasync function buildManifest(\n\t\tarchiveName: string,\n\t\tentries: readonly ArchiveEntry[],\n\t): Promise<readonly ExtractedEntry[]> {\n\t\tconst manifest: ExtractedEntry[] = []\n\t\tfor (const entry of entries) {\n\t\t\tconst kind = fileTypeFromName(entry.name)?.kind ?? \"other\"\n\t\t\tconst row: MutableExtractedEntry = {\n\t\t\t\tpath: entry.name,\n\t\t\t\tsizeBytes: entry.sizeBytes,\n\t\t\t\tkind,\n\t\t\t}\n\t\t\tif (kind === \"image\" && deps.probeImage !== undefined) {\n\t\t\t\tconst rel = sanitizeExtractPath(entry.name)\n\t\t\t\tconst filePath = join(deps.cacheDir, archiveName, rel)\n\t\t\t\tconst probe = await probeImageHead(\n\t\t\t\t\tdeps.probeImage,\n\t\t\t\t\tfilePath,\n\t\t\t\t\tentry.name,\n\t\t\t\t)\n\t\t\t\tif (probe !== undefined) {\n\t\t\t\t\trow.width = probe.width\n\t\t\t\t\trow.height = probe.height\n\t\t\t\t\trow.animated = probe.animated\n\t\t\t\t}\n\t\t\t}\n\t\t\tmanifest.push(row)\n\t\t}\n\t\treturn manifest\n\t}\n\n\t/**\n\t * List the container's entries. Zip lists from the nested resolver\n\t * (CD parse, nothing materialized); tar/7z/rar/xz list through the\n\t * 7-Zip binary over a size-keyed materialization cache in the cache\n\t * dir (7-Zip needs a real path; the bytes are written once per outer\n\t * size and reused across hook calls — every hook call builds a fresh\n\t * API instance, so per-call temp files would re-write the whole\n\t * archive each time). Resolves to `undefined` when the entry is not a\n\t * container, is gzip (whole-archive single-stream), or the 7-Zip\n\t * binary is absent (quiet degradation — callers treat it as \"not a\n\t * container\", matching the resolver semantics).\n\t */\n\tasync function list(\n\t\tarchiveName: string,\n\t): Promise<readonly ArchiveEntry[] | undefined> {\n\t\tconst size = await deps.outer.sizeOf(archiveName)\n\t\tif (size === undefined) return undefined\n\t\t// An extracted archive answers from its manifest — cheaper than\n\t\t// any listing, and the manifest is the source of truth for the\n\t\t// materialized virtual paths (see nested-view.ts).\n\t\tconst markerPath = join(deps.cacheDir, archiveName, \"index.json\")\n\t\tconst extracted = await readExistingManifest(markerPath, archiveName)\n\t\tif (extracted !== undefined) {\n\t\t\treturn extracted.map((e) => ({ name: e.path, sizeBytes: e.sizeBytes }))\n\t\t}\n\t\tif (size === 0) return undefined\n\t\tconst head = await deps.outer.readSlice(\n\t\t\tarchiveName,\n\t\t\t0,\n\t\t\tMath.min(size, SNIFF_WINDOW_BYTES),\n\t\t)\n\t\tconst format = sniffContainerFormat(head)\n\t\tif (format === \"zip\") {\n\t\t\tconst entries = await resolver.list(archiveName)\n\t\t\tif (entries === undefined) return undefined\n\t\t\treturn entries.map((e) => ({ name: e.name, sizeBytes: e.sizeBytes }))\n\t\t}\n\t\tif (\n\t\t\tformat === \"tar\" ||\n\t\t\tformat === \"7z\" ||\n\t\t\tformat === \"rar\" ||\n\t\t\tformat === \"xz\"\n\t\t) {\n\t\t\treturn listViaSevenZip(archiveName, size)\n\t\t}\n\t\treturn undefined\n\t}\n\n\t/**\n\t * Non-zip listing through 7-Zip. The outer entry is materialized once\n\t * per size into `<cacheDir>/<archiveName>.7z-list` (keyed by outer\n\t * size — the source is immutable per version, so a size match means\n\t * the cached bytes are the same file) and the parsed listing is\n\t * cached next to it. `extractWithSevenZip` removes both files once a\n\t * real extraction lands, so the manifest takes over afterwards.\n\t */\n\tasync function listViaSevenZip(\n\t\tarchiveName: string,\n\t\tsize: number,\n\t): Promise<readonly ArchiveEntry[] | undefined> {\n\t\tif (resolveSevenZipPath() === undefined) return undefined\n\t\tawait mkdir(deps.cacheDir, { recursive: true })\n\t\tconst cachePath = join(deps.cacheDir, `${archiveName}.7z-list`)\n\t\tconst entriesPath = join(deps.cacheDir, `${archiveName}.7z-list.json`)\n\t\tconst cached = await stat(cachePath).catch(() => undefined)\n\t\tif (cached?.size === size) {\n\t\t\tconst raw = await readFile(entriesPath, \"utf8\").catch(() => undefined)\n\t\t\tif (raw !== undefined) {\n\t\t\t\ttry {\n\t\t\t\t\treturn JSON.parse(raw) as readonly ArchiveEntry[]\n\t\t\t\t} catch {\n\t\t\t\t\t// Corrupt cache — fall through and re-materialize.\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t\ttry {\n\t\t\tconst source = createOuterArchiveSource(deps.outer, archiveName, size)\n\t\t\tawait materializeFile({\n\t\t\t\topenStream: () => streamRange(source, 0, size - 1),\n\t\t\t\ttarget: cachePath,\n\t\t\t\texpectedSize: size,\n\t\t\t})\n\t\t\tconst listed = await listSevenZipEntries(cachePath)\n\t\t\tconst files = listed\n\t\t\t\t.filter((e) => !e.folder)\n\t\t\t\t.map((e) => ({ name: e.name, sizeBytes: e.sizeBytes }))\n\t\t\tawait writeFile(entriesPath, JSON.stringify(files))\n\t\t\treturn files\n\t\t} catch (err) {\n\t\t\tawait rm(cachePath, { force: true }).catch(() => {})\n\t\t\tawait rm(entriesPath, { force: true }).catch(() => {})\n\t\t\tthrow err\n\t\t}\n\t}\n\n\treturn { extract, list }\n}\n\ntype MutableExtractedEntry = {\n\tpath: string\n\tsizeBytes: number\n\tkind: MediaKind\n\twidth?: number\n\theight?: number\n\tanimated?: boolean\n}\n\n/**\n * Read and validate a manifest written by {@link writeManifest}: version\n * and archive-name must match, otherwise the marker is stale garbage and\n * the caller re-extracts. Shared with the nested-view materialized-path\n * resolution.\n */\nexport async function readExistingManifest(\n\tmarkerPath: string,\n\tarchiveName: string,\n): Promise<readonly ExtractedEntry[] | undefined> {\n\tconst raw = await readFile(markerPath, \"utf8\").catch(() => undefined)\n\tif (raw === undefined) return undefined\n\ttry {\n\t\tconst parsed = JSON.parse(raw) as {\n\t\t\treadonly v?: number\n\t\t\treadonly archiveName?: string\n\t\t\treadonly entries?: readonly ExtractedEntry[]\n\t\t}\n\t\tif (\n\t\t\tparsed.v !== EXTRACT_INDEX_VERSION ||\n\t\t\tparsed.archiveName !== archiveName ||\n\t\t\t!Array.isArray(parsed.entries)\n\t\t) {\n\t\t\treturn undefined\n\t\t}\n\t\treturn parsed.entries\n\t} catch {\n\t\treturn undefined\n\t}\n}\n\nasync function writeManifest(\n\tmarkerPath: string,\n\tarchiveName: string,\n\tentries: readonly ExtractedEntry[],\n): Promise<void> {\n\tconst payload = JSON.stringify({\n\t\tv: EXTRACT_INDEX_VERSION,\n\t\tarchiveName,\n\t\tentries,\n\t})\n\tconst partial = `${markerPath}.partial-${process.pid}`\n\tawait writeFile(partial, payload)\n\tawait rename(partial, markerPath)\n}\n\n/**\n * Normalize an inner entry name to a safe relative path: backslashes\n * become forward slashes, traversal segments and control characters are\n * rejected, and the result must stay inside the extraction root.\n */\nexport function sanitizeExtractPath(name: string): string {\n\tconst normalized = name.replace(/\\\\/g, \"/\")\n\tif (normalized.startsWith(\"/\") || /^[A-Za-z]:/.test(normalized)) {\n\t\tthrow invalid(\n\t\t\t\"plugin.container_extract_failed\",\n\t\t\t`archive entry has an absolute path: \"${name}\"`,\n\t\t\t{ name },\n\t\t)\n\t}\n\tconst segments = normalized.split(\"/\")\n\tfor (const seg of segments) {\n\t\tif (seg.length === 0 || seg === \".\" || seg === \"..\") {\n\t\t\tthrow invalid(\n\t\t\t\t\"plugin.container_extract_failed\",\n\t\t\t\t`archive entry has an unsafe path: \"${name}\"`,\n\t\t\t\t{ name },\n\t\t\t)\n\t\t}\n\t\tif (hasControlChar(seg)) {\n\t\t\tthrow invalid(\n\t\t\t\t\"plugin.container_extract_failed\",\n\t\t\t\t`archive entry contains control characters: \"${name}\"`,\n\t\t\t\t{ name },\n\t\t\t)\n\t\t}\n\t}\n\treturn segments.join(\"/\")\n}\n\n/** True when any char code is a control character (C0 or DEL). */\nfunction hasControlChar(value: string): boolean {\n\tfor (let i = 0; i < value.length; i++) {\n\t\tconst code = value.charCodeAt(i)\n\t\tif (code < 0x20 || code === 0x7f) return true\n\t}\n\treturn false\n}\n\n/** Probe one extracted image's header window for dimensions. */\nasync function probeImageHead(\n\tprobeImage: NonNullable<ExtractArchiveDeps[\"probeImage\"]>,\n\tfilePath: string,\n\tname: string,\n): Promise<ImageProbeResult | undefined> {\n\tconst head = await readFile(filePath).then((b) =>\n\t\tb.subarray(0, PROBE_HEADER_BYTES),\n\t)\n\tif (head.length === 0) return undefined\n\tconst ext = name.slice(name.lastIndexOf(\".\")).toLowerCase()\n\treturn probeImage(head, ext)\n}\n","import { extname } from \"node:path\"\nimport type { Readable } from \"node:stream\"\nimport { IMAGE_EXTS } from \"@hoardodile/sdk-types/media-exts\"\nimport { PROBE_HEADER_BYTES } from \"../archive/index.ts\"\n\nimport type { ImageInfo } from \"../types.ts\"\n\n/**\n * Max zip entry size read into memory for thumb synthesis. Larger\n * entries are materialized to disk before sharp/ffmpeg runs.\n */\nexport const THUMB_BUFFER_MAX_BYTES = 32 * 1024 * 1024\n\n/**\n * Image probing via sharp. sharp is loaded lazily and declared as an\n * optional peer dependency: the host package stays free of native\n * binaries, and consumers that never probe images (the browser-side mock,\n * dependency-light CI installs) never pull it in. When sharp is missing,\n * probe calls fail loudly instead of degrading silently.\n */\ntype SharpModule = typeof import(\"sharp\")\n\nlet sharpPromise: Promise<SharpModule> | undefined\n\nexport function loadSharp(): Promise<SharpModule> {\n\tsharpPromise ??= import(\"sharp\").catch((err: unknown) => {\n\t\tsharpPromise = undefined\n\t\tthrow new Error(\n\t\t\t`sharp is not installed — add \"sharp\" to your dependencies to enable image probing: ${err instanceof Error ? err.message : String(err)}`,\n\t\t)\n\t})\n\treturn sharpPromise\n}\n\nexport type ImageSourceProbe = {\n\treadonly width: number\n\treadonly height: number\n\treadonly animated: boolean\n}\n\nconst ANIMATED_EXT_HINTS = new Set([\".gif\", \".webp\", \".avif\"])\n\nexport { PROBE_HEADER_BYTES } from \"../archive/index.ts\"\n\n/** True when `ext` names a format that can carry animation. */\nexport function isAnimatedCandidateExt(ext: string): boolean {\n\treturn ANIMATED_EXT_HINTS.has(ext)\n}\n\nexport type ImageMetadataInput =\n\t| string\n\t| Buffer\n\t| Readable\n\t| {\n\t\t\treadonly openStream: () => Promise<Readable>\n\t\t\t/**\n\t\t\t * Optional byte-range read into the underlying source (e.g. a\n\t\t\t * zip entry slice). When present, metadata probes read\n\t\t\t * {@link PROBE_HEADER_BYTES} from the start instead of\n\t\t\t * streaming the whole entry — libvips reads the ENTIRE file\n\t\t\t * for metadata from a non-seekable stream, but only the header\n\t\t\t * from a buffer.\n\t\t\t */\n\t\t\treadonly readRange?: (start: number, end: number) => Promise<Buffer>\n\t }\n\n/**\n * Narrow a Node stream into sharp's readable input without assertion.\n * Async because the sharp module itself is loaded lazily.\n *\n * sharp's constructor rejects `sharp(stream, opts)` — for stream input\n * the input options must be passed AS the first argument (a plain object\n * of input parameters like `pages`/`animated`) and the readable piped\n * into the resulting Duplex.\n */\nexport async function sharpFromReadable(\n\tstream: Readable,\n\toptions?: import(\"sharp\").SharpOptions,\n): Promise<import(\"sharp\").Sharp> {\n\tif (typeof stream.read !== \"function\") {\n\t\tthrow new Error(\"expected a readable stream\")\n\t}\n\tconst { default: sharp } = await loadSharp()\n\tconst descriptor =\n\t\toptions === undefined || Object.keys(options).length === 0\n\t\t\t? undefined\n\t\t\t: ({ ...options } as unknown as import(\"sharp\").SharpInput)\n\tconst instance = descriptor === undefined ? sharp() : sharp(descriptor)\n\tstream.pipe(instance)\n\treturn instance\n}\n\nfunction isReadable(input: unknown): input is Readable {\n\treturn (\n\t\ttypeof input === \"object\" &&\n\t\tinput !== null &&\n\t\ttypeof (input as Readable).pipe === \"function\" &&\n\t\t!(\"openStream\" in input)\n\t)\n}\n\nfunction isReopenableImageStream(\n\tinput: ImageMetadataInput,\n): input is { readonly openStream: () => Promise<Readable> } {\n\treturn (\n\t\ttypeof input === \"object\" &&\n\t\tinput !== null &&\n\t\t!Buffer.isBuffer(input) &&\n\t\t\"openStream\" in input &&\n\t\ttypeof (input as { openStream: unknown }).openStream === \"function\"\n\t)\n}\n\n/**\n * Sharp options for image thumb/probe reads. Enables sequential read for\n * large JPEGs on disk so libvips can shrink-on-load during resize.\n */\nexport function sharpImageInputOpts(\n\tinput: string | Buffer,\n\text: string,\n\tpages: number,\n\tanimated?: boolean,\n): import(\"sharp\").SharpOptions {\n\tconst opts: import(\"sharp\").SharpOptions = { pages }\n\tif (animated === true) opts.animated = true\n\tif (\n\t\ttypeof input === \"string\" &&\n\t\t(ext === \".jpg\" || ext === \".jpeg\" || ext === \".jfif\")\n\t) {\n\t\topts.sequentialRead = true\n\t}\n\treturn opts\n}\n\n/**\n * True when a shallow `{ pages: 1 }` metadata read is not enough to\n * decide animation and the full multi-page scan is required.\n */\nexport function needsFullAnimationScan(\n\tmeta: Pick<\n\t\timport(\"sharp\").Metadata,\n\t\t\"width\" | \"height\" | \"pages\" | \"pageHeight\"\n\t>,\n\text: string,\n): boolean {\n\tif (ext === \".gif\") return true\n\tif (ANIMATED_EXT_HINTS.has(ext) && (meta.pages ?? 1) > 1) return true\n\tif (\n\t\tmeta.pageHeight !== undefined &&\n\t\tmeta.height !== undefined &&\n\t\tmeta.pageHeight !== meta.height\n\t) {\n\t\treturn true\n\t}\n\treturn false\n}\n\n/**\n * Buffer a probe stream with a hard byte cap. Only used for formats whose\n * animation scan needs the whole stream (GIF): probing only needs\n * metadata, so an entry bigger than the cap is treated as unprobed rather\n * than buffered whole into host memory.\n */\nasync function bufferCapped(\n\tinput: Readable,\n\tmaxBytes: number,\n): Promise<Buffer> {\n\tconst chunks: Uint8Array[] = []\n\tlet total = 0\n\tfor await (const chunk of input) {\n\t\tconst bytes: Uint8Array =\n\t\t\ttypeof chunk === \"string\" ? Buffer.from(chunk) : chunk\n\t\ttotal += bytes.byteLength\n\t\tif (total > maxBytes) {\n\t\t\tinput.destroy()\n\t\t\tthrow new Error(`probe input exceeds the ${maxBytes}-byte cap`)\n\t\t}\n\t\tchunks.push(bytes)\n\t}\n\treturn Buffer.concat(chunks)\n}\n\n/**\n * Read image metadata with layered animation detection: static sources\n * stop after a single-page read; animated containers escalate to\n * `{ pages: -1 }` only when the shallow probe signals multi-frame input.\n */\nexport async function readImageMetadata(\n\tinput: ImageMetadataInput,\n\text: string,\n): Promise<{\n\treadonly meta: import(\"sharp\").Metadata\n\treadonly animated: boolean\n}> {\n\tif (isReopenableImageStream(input)) {\n\t\t// Header-slice fast path: probe a small leading window as a buffer\n\t\t// (header-only libvips read) instead of streaming the whole entry\n\t\t// (full-file read). Only definitive static results are accepted —\n\t\t// animation candidates escalate inside the slice read, and a\n\t\t// truncated buffer cannot complete a full scan, so those fall\n\t\t// through to the stream path where frame counts stay correct. GIF\n\t\t// is skipped outright (its frame count lives at the end).\n\t\tif (input.readRange !== undefined && ext !== \".gif\") {\n\t\t\tconst head = await input.readRange(0, PROBE_HEADER_BYTES)\n\t\t\tif (head.length > 0) {\n\t\t\t\ttry {\n\t\t\t\t\tconst { default: sharp } = await loadSharp()\n\t\t\t\t\tconst shallowMeta = await sharp(\n\t\t\t\t\t\thead,\n\t\t\t\t\t\tsharpImageInputOpts(head, ext, 1),\n\t\t\t\t\t).metadata()\n\t\t\t\t\tif (!needsFullAnimationScan(shallowMeta, ext)) {\n\t\t\t\t\t\treturn { meta: shallowMeta, animated: false }\n\t\t\t\t\t}\n\t\t\t\t} catch {\n\t\t\t\t\t// Truncated header unreadable — fall through to the\n\t\t\t\t\t// stream path.\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t\tconst stream = await input.openStream()\n\t\tconst shallow = await sharpFromReadable(stream, { pages: 1 })\n\t\tconst shallowMeta = await shallow.metadata()\n\t\tif (!needsFullAnimationScan(shallowMeta, ext)) {\n\t\t\treturn { meta: shallowMeta, animated: false }\n\t\t}\n\t\tconst fullStream = await input.openStream()\n\t\tconst fullMeta = await sharpFromReadable(fullStream, {\n\t\t\tpages: -1,\n\t\t\tanimated: true,\n\t\t}).then((s) => s.metadata())\n\t\treturn { meta: fullMeta, animated: (fullMeta.pages ?? 1) > 1 }\n\t}\n\tif (isReadable(input)) {\n\t\t// GIF frame counts are not stored in the container header, so the\n\t\t// full animation scan needs the whole stream — buffer cap-guarded\n\t\t// and reuse the buffer path.\n\t\tif (ext === \".gif\") {\n\t\t\tconst data = await bufferCapped(input, THUMB_BUFFER_MAX_BYTES)\n\t\t\treturn readImageMetadata(data, ext)\n\t\t}\n\t\t// Static formats: hand the stream straight to sharp so the probe\n\t\t// reads only the header bytes. (Buffering the whole entry first —\n\t\t// the previous behavior, capped at THUMB_BUFFER_MAX_BYTES — turned\n\t\t// every stream probe into a full read and made entries beyond the\n\t\t// cap unprobeable.)\n\t\ttry {\n\t\t\tconst shallow = await sharpFromReadable(input, { pages: 1 })\n\t\t\tconst shallowMeta = await shallow.metadata()\n\t\t\tif (!needsFullAnimationScan(shallowMeta, ext)) {\n\t\t\t\treturn { meta: shallowMeta, animated: false }\n\t\t\t}\n\t\t\t// Animated candidates (webp/avif/tiff) report their total page\n\t\t\t// count from the container header, so the shallow read is\n\t\t\t// conclusive — and the stream cannot be rewound for a full\n\t\t\t// scan anyway.\n\t\t\treturn {\n\t\t\t\tmeta: shallowMeta,\n\t\t\t\tanimated: (shallowMeta.pages ?? 1) > 1,\n\t\t\t}\n\t\t} finally {\n\t\t\tinput.destroy()\n\t\t}\n\t}\n\tconst { default: sharp } = await loadSharp()\n\tconst shallow = sharp(input, sharpImageInputOpts(input, ext, 1))\n\tconst shallowMeta = await shallow.metadata()\n\tif (!needsFullAnimationScan(shallowMeta, ext)) {\n\t\treturn { meta: shallowMeta, animated: false }\n\t}\n\tconst fullMeta = await sharp(\n\t\tinput,\n\t\tsharpImageInputOpts(input, ext, -1, true),\n\t).metadata()\n\treturn { meta: fullMeta, animated: (fullMeta.pages ?? 1) > 1 }\n}\n\n/**\n * Probe an image path or buffer and return pixel dimensions plus whether\n * the source is animated. Probe failures return `undefined`.\n */\nexport async function probeImageSource(\n\tinput: ImageMetadataInput,\n\textHint?: string,\n): Promise<ImageSourceProbe | undefined> {\n\tconst ext =\n\t\textHint ?? (typeof input === \"string\" ? extname(input).toLowerCase() : \"\")\n\tif (ext.length > 0 && !IMAGE_EXTS.has(ext)) return undefined\n\ttry {\n\t\tconst { meta, animated } = await readImageMetadata(input, ext)\n\t\tconst h = meta.pageHeight ?? meta.height\n\t\tif (meta.width === undefined || h === undefined) return undefined\n\t\treturn { width: meta.width, height: h, animated }\n\t} catch {\n\t\treturn undefined\n\t}\n}\n\n/**\n * Probe an image source and return its pixel dimensions. Probe failures\n * return `undefined` so callers can treat \"not yet probed\" and \"probe\n * failed\" the same way.\n */\nexport async function probeImage(\n\tsource: ImageMetadataInput,\n): Promise<ImageInfo | undefined> {\n\tconst probe = await probeImageSource(source)\n\tif (probe === undefined) return undefined\n\treturn { width: probe.width, height: probe.height }\n}\n\n/**\n * True when the image has more than one frame (animated GIF / WebP / APNG /\n * AVIF). Uses layered detection so static JPEG/PNG avoid a full-frame scan.\n * Errors are coerced to `false`.\n */\nexport async function probeAnimatedImage(\n\tsource: ImageMetadataInput,\n): Promise<boolean> {\n\tif (\n\t\ttypeof source === \"string\" &&\n\t\t!IMAGE_EXTS.has(extname(source).toLowerCase())\n\t) {\n\t\treturn false\n\t}\n\ttry {\n\t\tconst probe = await probeImageSource(source)\n\t\treturn probe?.animated ?? false\n\t} catch (err) {\n\t\tconst label = typeof source === \"string\" ? source : \"stream\"\n\t\tconsole.warn(\n\t\t\t`[probeAnimatedImage] sharp failed on ${label}: ${err instanceof Error ? err.message : String(err)}`,\n\t\t)\n\t\treturn false\n\t}\n}\n","import { spawn } from \"node:child_process\"\nimport { mkdir, readdir, rename, rm } from \"node:fs/promises\"\nimport { dirname, extname, join } from \"node:path\"\nimport type { Readable } from \"node:stream\"\nimport type { ResolvedImageVariant } from \"@hoardodile/sdk-types/image-variant\"\nimport sharp, { type Sharp } from \"sharp\"\nimport {\n\textToAudioInputFormat,\n\textToFfmpegInputFormat,\n\treadImageMetadata,\n\tsharpFromReadable,\n\tsharpImageInputOpts,\n} from \"../probe/index.ts\"\nimport type { FfmpegPaths } from \"./ffmpeg.ts\"\n\n/**\n * Level of downscaling applied to animated thumbnails: they use 1/3 the\n * still-image area cap, which keeps animated WebP file sizes sane.\n */\nexport const ANIMATED_AREA_DIVISOR = 3\n\n/** Regexp matching the temp-file suffix written by {@link encodeImageFile}. */\nconst ORPHANED_TEMP_RE = /\\.writing-\\d+-\\d+$/\n\n/**\n * Recursively remove any `.writing-<pid>-<ts>` temp files left behind by a\n * previous process crash. Safe to call fire-and-forget at startup.\n */\nexport async function cleanOrphanedTempFiles(thumbRoot: string): Promise<void> {\n\tawait removeOrphanedInDir(thumbRoot)\n}\n\nasync function removeOrphanedInDir(dir: string): Promise<void> {\n\tlet entries: string[]\n\ttry {\n\t\tentries = await readdir(dir)\n\t} catch {\n\t\treturn\n\t}\n\tawait Promise.all(\n\t\tentries.map(async (entry) => {\n\t\t\tconst full = join(dir, entry)\n\t\t\tif (ORPHANED_TEMP_RE.test(entry)) {\n\t\t\t\tawait rm(full, { force: true }).catch(() => {})\n\t\t\t} else {\n\t\t\t\tawait removeOrphanedInDir(full)\n\t\t\t}\n\t\t}),\n\t)\n}\n\nexport const WEBP_QUALITY = 82\nexport const PREVIEW_WEBP_QUALITY = 90\nexport const AVIF_QUALITY = 65\nexport const PREVIEW_AVIF_QUALITY = 70\n/**\n * Level of CPU effort the AVIF encoder spends reducing file size\n * (0 = fastest/largest … 9 = slowest/smallest; sharp's default is 4).\n * Thumbs are cache-once artifacts re-encoded in bulk by the precache\n * sweep, where AVIF encode dominates wall time; effort 2 cuts encode\n * cost substantially for a few percent larger output — measured on the\n * precache bench (see bench/precache.bench.ts).\n */\nexport const AVIF_EFFORT = 2\n\nexport type RenderResult = {\n\treadonly path: string\n\treadonly bytes: number\n}\n\nexport type ImageThumbInput =\n\t| string\n\t| Buffer\n\t| {\n\t\t\treadonly openStream: () => Promise<Readable>\n\t\t\t/**\n\t\t\t * Byte-range read into the underlying source. Lets the metadata\n\t\t\t * step read a small header window instead of streaming the\n\t\t\t * whole entry — libvips full-reads non-seekable input.\n\t\t\t */\n\t\t\treadonly readRange?: (start: number, end: number) => Promise<Buffer>\n\t }\n\nfunction isReopenableImageStream(input: ImageThumbInput): input is {\n\treadonly openStream: () => Promise<Readable>\n\treadonly readRange?: (start: number, end: number) => Promise<Buffer>\n} {\n\treturn (\n\t\ttypeof input === \"object\" &&\n\t\tinput !== null &&\n\t\t!Buffer.isBuffer(input) &&\n\t\t\"openStream\" in input\n\t)\n}\n\nexport type ImageThumbRenderResult = RenderResult & {\n\treadonly format: \"webp\" | \"avif\"\n\treadonly displayWidth: number\n\treadonly displayHeight: number\n\treadonly animated: boolean\n}\n\nexport type VideoFrameSource = string | Readable\n\n/**\n * Encode an image thumb in a single sharp pipeline: one metadata read,\n * animation detection, resize, and encode. Encodes to the variant's\n * `format`, downgrading an animated source to WebP (sharp cannot encode\n * animated AVIF).\n */\nexport async function renderImageThumbOnce(opts: {\n\treadonly input: ImageThumbInput\n\treadonly resolveDest: (fmt: \"webp\" | \"avif\") => string\n\t/**\n\t * The resolved render plan: codec, fit policy, area cap and\n\t * per-format qualities. The pipeline is a pure consumer of the\n\t * variant — everything that decides the output bytes lives in it.\n\t */\n\treadonly variant: ResolvedImageVariant\n\treadonly ext?: string\n}): Promise<ImageThumbRenderResult> {\n\tconst ext =\n\t\topts.ext ??\n\t\t(typeof opts.input === \"string\" ? extname(opts.input).toLowerCase() : \"\")\n\tconst { meta, animated } = await readImageMetadata(opts.input, ext)\n\tconst h = meta.pageHeight ?? meta.height\n\tif (meta.width === undefined || h === undefined) {\n\t\tthrow new Error(\"image source is missing dimensions\")\n\t}\n\tconst requested = opts.variant.format\n\tconst format = animated && requested === \"avif\" ? \"webp\" : requested\n\tconst area = animated\n\t\t? Math.floor(opts.variant.maxArea / ANIMATED_AREA_DIVISOR)\n\t\t: opts.variant.maxArea\n\tconst dims =\n\t\topts.variant.fit === \"exact\"\n\t\t\t? { width: meta.width, height: h }\n\t\t\t: fitInsideArea(meta.width, h, area)\n\tconst destPath = opts.resolveDest(format)\n\tawait mkdir(dirname(destPath), { recursive: true })\n\tconst pipeline = isReopenableImageStream(opts.input)\n\t\t? await sharpFromReadable(await opts.input.openStream(), {\n\t\t\t\tpages: animated ? -1 : 1,\n\t\t\t\tanimated: animated || undefined,\n\t\t\t})\n\t\t: sharp(\n\t\t\t\topts.input,\n\t\t\t\tsharpImageInputOpts(opts.input, ext, animated ? -1 : 1, animated),\n\t\t\t)\n\tconst result = await encodeImageFile(\n\t\tpipeline,\n\t\tdestPath,\n\t\t{\n\t\t\t// `exact` carries no resize bounds: the encoder writes the\n\t\t\t// source pixels verbatim.\n\t\t\tmaxWidth: opts.variant.fit === \"exact\" ? undefined : dims.width,\n\t\t\tmaxHeight: opts.variant.fit === \"exact\" ? undefined : dims.height,\n\t\t\tquality:\n\t\t\t\tformat === \"webp\" ? opts.variant.webpQuality : opts.variant.avifQuality,\n\t\t\tanimated: animated || undefined,\n\t\t},\n\t\tformat,\n\t)\n\treturn {\n\t\t...result,\n\t\tformat,\n\t\tdisplayWidth: dims.width,\n\t\tdisplayHeight: dims.height,\n\t\tanimated,\n\t}\n}\n\n/**\n * Encode `sourcePath` to WebP at `destPath`, sized so that\n * `width × height < maxArea`. Animated sources are kept animated.\n * @throws `Error` from sharp.\n */\nexport async function renderImageWithArea(\n\tsourcePath: string,\n\tdestPath: string,\n\tmaxArea: number,\n\tquality: number,\n\tanimated: boolean,\n): Promise<RenderResult> {\n\tawait mkdir(dirname(destPath), { recursive: true })\n\treturn encodeSharpWithArea({\n\t\tpipeline: sharp(sourcePath, { animated }),\n\t\tdestPath,\n\t\tmaxArea,\n\t\tquality,\n\t\tformat: \"webp\",\n\t\tanimated,\n\t})\n}\n\n/**\n * Capture a frame from a file path or readable stream via ffmpeg and\n * encode, sized so that `width × height < maxArea`.\n * @throws `Error` with ffmpeg stderr when ffmpeg exits non-zero.\n */\nexport async function renderVideoFrame(opts: {\n\treadonly source: VideoFrameSource\n\treadonly destPath: string\n\treadonly ffmpeg: FfmpegPaths\n\treadonly maxArea: number\n\treadonly quality: number\n\treadonly format?: \"webp\" | \"avif\"\n\treadonly timeSeconds?: number\n\t/** Required when `source` is a stream — ffmpeg cannot infer container from `pipe:0`. */\n\treadonly ext?: string\n}): Promise<RenderResult> {\n\treturn renderFfmpegStill({\n\t\t...opts,\n\t\tinputFormat:\n\t\t\topts.ext === undefined ? undefined : extToFfmpegInputFormat(opts.ext),\n\t})\n}\n\n/**\n * Extract an audio file's embedded artwork (ID3 APIC, FLAC PICTURE, MP4\n * `covr`) and encode it as a thumbnail. ffmpeg exposes the artwork as a\n * still video stream, so this rides the same still-frame pipeline as\n * {@link renderVideoFrame} — only the container hint differs.\n * @throws `Error` with ffmpeg stderr when the file carries no artwork.\n */\nexport async function renderAudioCoverArt(opts: {\n\treadonly source: VideoFrameSource\n\treadonly destPath: string\n\treadonly ffmpeg: FfmpegPaths\n\treadonly maxArea: number\n\treadonly quality: number\n\treadonly format?: \"webp\" | \"avif\"\n\t/** Required when `source` is a stream — ffmpeg cannot infer container from `pipe:0`. */\n\treadonly ext?: string\n}): Promise<RenderResult> {\n\treturn renderFfmpegStill({\n\t\t...opts,\n\t\tinputFormat:\n\t\t\topts.ext === undefined ? undefined : extToAudioInputFormat(opts.ext),\n\t})\n}\n\nasync function renderFfmpegStill(opts: {\n\treadonly source: VideoFrameSource\n\treadonly destPath: string\n\treadonly ffmpeg: FfmpegPaths\n\treadonly maxArea: number\n\treadonly quality: number\n\treadonly format?: \"webp\" | \"avif\"\n\treadonly timeSeconds?: number\n\treadonly inputFormat?: string\n}): Promise<RenderResult> {\n\tawait mkdir(dirname(opts.destPath), { recursive: true })\n\tconst jpeg = await extractFrameAtTimeJpeg({\n\t\tsource: opts.source,\n\t\ttimeSeconds: opts.timeSeconds ?? 0,\n\t\tffmpeg: opts.ffmpeg,\n\t\tinputFormat: opts.inputFormat,\n\t})\n\treturn encodeSharpWithArea({\n\t\tpipeline: sharp(jpeg),\n\t\tdestPath: opts.destPath,\n\t\tmaxArea: opts.maxArea,\n\t\tquality: opts.quality,\n\t\tformat: opts.format ?? \"avif\",\n\t\tanimated: false,\n\t})\n}\n\ntype EncodeImageFileOptions = {\n\t/**\n\t * Resize bounds (fit `inside`, never enlarged). When both are\n\t * omitted the source pixels are encoded verbatim — the `exact`\n\t * variant's transcode-only path.\n\t */\n\treadonly maxWidth?: number\n\treadonly maxHeight?: number\n\treadonly quality: number\n\t/**\n\t * When `true`, skip `.rotate()` (sharp's auto-EXIF-orient call which\n\t * collapses animated input to a single frame) so the encoded WebP\n\t * keeps every page. Animated sources never carry useful EXIF\n\t * orientation, so dropping the rotate is safe.\n\t */\n\treadonly animated?: boolean\n}\n\n/**\n * Encode a sharp pipeline to `destPath`, sized so that\n * `width × height < maxArea`.\n */\nasync function encodeSharpWithArea(opts: {\n\tpipeline: Sharp\n\tdestPath: string\n\tmaxArea: number\n\tquality: number\n\tformat: \"webp\" | \"avif\"\n\tanimated: boolean\n}): Promise<RenderResult> {\n\tconst meta = await opts.pipeline.metadata()\n\tconst h = meta.pageHeight ?? meta.height\n\tconst dims =\n\t\tmeta.width !== undefined && h !== undefined\n\t\t\t? fitInsideArea(meta.width, h, opts.maxArea)\n\t\t\t: undefined\n\treturn encodeImageFile(\n\t\topts.pipeline,\n\t\topts.destPath,\n\t\t{\n\t\t\tmaxWidth: dims?.width,\n\t\t\tmaxHeight: dims?.height,\n\t\t\tquality: opts.quality,\n\t\t\tanimated: opts.animated || undefined,\n\t\t},\n\t\topts.format,\n\t)\n}\n\nasync function encodeImageFile(\n\tpipeline: Sharp,\n\tdestPath: string,\n\topts: EncodeImageFileOptions,\n\tformat: \"webp\" | \"avif\",\n): Promise<RenderResult> {\n\tconst tmp = `${destPath}.writing-${process.pid}-${Date.now()}`\n\ttry {\n\t\tconst oriented = opts.animated === true ? pipeline : pipeline.rotate()\n\t\tconst resized =\n\t\t\topts.maxWidth === undefined && opts.maxHeight === undefined\n\t\t\t\t? oriented\n\t\t\t\t: oriented.resize({\n\t\t\t\t\t\twidth: opts.maxWidth,\n\t\t\t\t\t\theight: opts.maxHeight,\n\t\t\t\t\t\tfit: \"inside\",\n\t\t\t\t\t\twithoutEnlargement: true,\n\t\t\t\t\t})\n\t\tconst encoded =\n\t\t\tformat === \"webp\"\n\t\t\t\t? resized.webp({ quality: opts.quality })\n\t\t\t\t: resized.avif({ quality: opts.quality, effort: AVIF_EFFORT })\n\t\tconst info = await encoded.toFile(tmp)\n\t\tawait rename(tmp, destPath)\n\t\treturn { path: destPath, bytes: info.size }\n\t} catch (err) {\n\t\tawait rm(tmp, { force: true }).catch(() => {})\n\t\tthrow err\n\t}\n}\n\n/**\n * Compute target pixel dimensions that keep the aspect ratio while\n * guaranteeing `width × height < maxArea`.\n */\nexport function fitInsideArea(\n\tsrcWidth: number,\n\tsrcHeight: number,\n\tmaxArea: number,\n): { readonly width: number; readonly height: number } {\n\tconst area = srcWidth * srcHeight\n\tif (area <= maxArea) return { width: srcWidth, height: srcHeight }\n\tconst scale = Math.sqrt(maxArea / area)\n\treturn {\n\t\twidth: Math.max(1, Math.floor(srcWidth * scale)),\n\t\theight: Math.max(1, Math.floor(srcHeight * scale)),\n\t}\n}\n\nfunction extractFrameAtTimeJpeg(opts: {\n\tsource: VideoFrameSource\n\ttimeSeconds: number\n\tffmpeg: FfmpegPaths\n\tinputFormat?: string\n}): Promise<Buffer> {\n\tif (typeof opts.source === \"string\") {\n\t\treturn extractFrameAtTimeJpegFromPath(opts.source, opts)\n\t}\n\treturn extractFrameAtTimeJpegFromStream(opts.source, opts)\n}\n\nfunction extractFrameAtTimeJpegFromPath(\n\tsourcePath: string,\n\topts: {\n\t\ttimeSeconds: number\n\t\tffmpeg: FfmpegPaths\n\t},\n): Promise<Buffer> {\n\tconst args = [\"-hide_banner\", \"-loglevel\", \"error\"]\n\tif (opts.timeSeconds > 0) {\n\t\targs.push(\"-ss\", String(opts.timeSeconds))\n\t}\n\targs.push(\"-i\", sourcePath)\n\targs.push(\n\t\t\"-vf\",\n\t\t\"scale=iw*sar:ih,scale='min(1024,iw)':-2\",\n\t\t\"-frames:v\",\n\t\t\"1\",\n\t\t\"-f\",\n\t\t\"image2\",\n\t\t\"-vcodec\",\n\t\t\"mjpeg\",\n\t\t\"pipe:1\",\n\t)\n\treturn runFfmpegJpegExtract(args, opts.ffmpeg, undefined)\n}\n\nfunction extractFrameAtTimeJpegFromStream(\n\tsource: Readable,\n\topts: {\n\t\ttimeSeconds: number\n\t\tffmpeg: FfmpegPaths\n\t\tinputFormat?: string\n\t},\n): Promise<Buffer> {\n\tif (opts.inputFormat === undefined) {\n\t\treturn Promise.reject(\n\t\t\tnew Error(\"stream video frame extraction requires an input format hint\"),\n\t\t)\n\t}\n\tconst args = [\"-hide_banner\", \"-loglevel\", \"error\"]\n\targs.push(\"-probesize\", \"100M\", \"-analyzeduration\", \"100M\")\n\targs.push(\"-f\", opts.inputFormat, \"-i\", \"pipe:0\")\n\tif (opts.timeSeconds > 0) {\n\t\targs.push(\"-ss\", String(opts.timeSeconds))\n\t}\n\targs.push(\n\t\t\"-vf\",\n\t\t\"scale=iw*sar:ih,scale='min(1024,iw)':-2\",\n\t\t\"-frames:v\",\n\t\t\"1\",\n\t\t\"-f\",\n\t\t\"image2\",\n\t\t\"-vcodec\",\n\t\t\"mjpeg\",\n\t\t\"pipe:1\",\n\t)\n\treturn runFfmpegJpegExtract(args, opts.ffmpeg, source)\n}\n\nfunction runFfmpegJpegExtract(\n\targs: string[],\n\tffmpeg: FfmpegPaths,\n\tsource: Readable | undefined,\n): Promise<Buffer> {\n\treturn new Promise((resolve, reject) => {\n\t\tconst fromStream = source !== undefined\n\t\tconst child = spawn(ffmpeg.ffmpeg, args, {\n\t\t\tstdio: fromStream ? [\"pipe\", \"pipe\", \"pipe\"] : [\"ignore\", \"pipe\", \"pipe\"],\n\t\t})\n\t\tconst stdout = child.stdout\n\t\tconst stderr = child.stderr\n\t\tif (stdout === null || stderr === null) {\n\t\t\treject(new Error(\"ffmpeg stdio was not configured\"))\n\t\t\treturn\n\t\t}\n\t\tconst stdoutChunks: Buffer[] = []\n\t\tconst stderrChunks: Buffer[] = []\n\t\tstdout.on(\"data\", (chunk: Buffer) => stdoutChunks.push(chunk))\n\t\tstderr.on(\"data\", (chunk: Buffer) => stderrChunks.push(chunk))\n\t\tif (source !== undefined) {\n\t\t\tsource.on(\"error\", reject)\n\t\t\tsource.pipe(child.stdin!)\n\t\t\tchild.stdin?.on(\"error\", () => {})\n\t\t}\n\t\tchild.on(\"error\", reject)\n\t\tchild.on(\"close\", (code) => {\n\t\t\tif (code !== 0) {\n\t\t\t\tconst msg = Buffer.concat(stderrChunks).toString(\"utf8\").trim()\n\t\t\t\treject(new Error(`ffmpeg exited ${code}${msg ? `: ${msg}` : \"\"}`))\n\t\t\t\treturn\n\t\t\t}\n\t\t\tconst out = Buffer.concat(stdoutChunks)\n\t\t\tif (out.length === 0) {\n\t\t\t\treject(new Error(\"ffmpeg produced no output\"))\n\t\t\t\treturn\n\t\t\t}\n\t\t\tresolve(out)\n\t\t})\n\t})\n}\n"]}
1
+ {"version":3,"sources":["../../src/render/ffmpeg.ts","../../src/probe/audio.ts","../../src/probe/video.ts","../../src/archive/extract-archive.ts","../../src/probe/image.ts","../../src/render/pipeline.ts"],"names":["sharp","shallowMeta","shallow","fullMeta","join","rm","isReopenableImageStream","extname","mkdir","rename"],"mappings":"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","file":"index.js","sourcesContent":["/**\n * Resolve the absolute paths to `ffmpeg` / `ffprobe` for this host.\n *\n * Precedence:\n * 1. Explicit env vars (`FFMPEG_PATH`, `FFPROBE_PATH`).\n * 2. `@hoardodile/ffmpeg-bin` and `@hoardodile/ffprobe-bin` via lazy\n * `createRequire`, so they stay out of the host tarball. Dev\n * resolves them from this package's `node_modules`; `pnpm start`\n * and the desktop sidecar resolve them from the server\n * `dist/node_modules` copy.\n * 3. A bare command name (`ffmpeg` / `ffprobe`), which lets operators\n * who have the binaries on PATH still run the server directly.\n */\nimport { createRequire } from \"node:module\"\n\nconst requireCjs = createRequire(import.meta.url)\n\nexport type FfmpegPaths = {\n\treadonly ffmpeg: string\n\treadonly ffprobe: string\n}\n\ntype ResolveDeps = {\n\treadonly env?: NodeJS.ProcessEnv\n\t/** Override for tests so they never hit the real module. */\n\treadonly loadStatic?: () => string | null | undefined\n\t/** Override for tests so they never hit the real module. */\n\treadonly loadStaticFfprobe?: () => string | null | undefined\n}\n\nexport function resolveFfmpegPaths(deps: ResolveDeps = {}): FfmpegPaths {\n\tconst env = deps.env ?? process.env\n\tconst ffmpegEnv = env.FFMPEG_PATH\n\tconst ffprobeEnv = env.FFPROBE_PATH\n\tconst staticFfmpeg =\n\t\tffmpegEnv === undefined || ffmpegEnv.length === 0\n\t\t\t? (deps.loadStatic ?? loadInstallerFfmpeg)()\n\t\t\t: undefined\n\tconst ffmpeg =\n\t\tffmpegEnv !== undefined && ffmpegEnv.length > 0\n\t\t\t? ffmpegEnv\n\t\t\t: (staticFfmpeg ?? \"ffmpeg\")\n\tconst staticFfprobe =\n\t\tffprobeEnv === undefined || ffprobeEnv.length === 0\n\t\t\t? (deps.loadStaticFfprobe ?? loadInstallerFfprobe)()\n\t\t\t: undefined\n\tconst ffprobe =\n\t\tffprobeEnv !== undefined && ffprobeEnv.length > 0\n\t\t\t? ffprobeEnv\n\t\t\t: (staticFfprobe ?? \"ffprobe\")\n\treturn { ffmpeg, ffprobe }\n}\n\nfunction loadInstallerFfmpeg(): string | undefined {\n\treturn loadInstallerPath(\"@hoardodile/ffmpeg-bin\")\n}\n\nfunction loadInstallerFfprobe(): string | undefined {\n\treturn loadInstallerPath(\"@hoardodile/ffprobe-bin\")\n}\n\nfunction loadInstallerPath(moduleName: string): string | undefined {\n\ttry {\n\t\tconst mod: unknown = requireCjs(moduleName)\n\t\tif (typeof mod === \"string\" && mod.length > 0) return mod\n\t\tif (\n\t\t\tmod !== null &&\n\t\t\ttypeof mod === \"object\" &&\n\t\t\t\"path\" in mod &&\n\t\t\ttypeof (mod as { path: unknown }).path === \"string\"\n\t\t) {\n\t\t\treturn (mod as { path: string }).path\n\t\t}\n\t\treturn undefined\n\t} catch {\n\t\treturn undefined\n\t}\n}\n","import type { Readable } from \"node:stream\"\nimport { AUDIO_FFMPEG_INPUT_FORMAT } from \"@hoardodile/sdk-types/media-exts\"\nimport type { AudioCoverArt, AudioInfo, AudioTags } from \"../types.ts\"\nimport {\n\ttype FfprobePayload,\n\ttype FfprobeStream,\n\tffprobeDurationMs,\n\tffprobeInt,\n\tgetFfprobePath,\n\tparseFfprobePayload,\n\trunFfprobeJson,\n} from \"./ffprobe.ts\"\n\n/**\n * Audio probing via ffprobe (see `ffprobe.ts` for path resolution and the\n * process plumbing). One probe answers everything the host needs about an\n * audio file: playable duration, stream parameters, whether the container\n * carries embedded artwork, and the human-facing tags.\n */\n\n/** Resolve an extension to its ffmpeg `-f` container name for piped input. */\nexport function extToAudioInputFormat(ext: string): string | undefined {\n\treturn AUDIO_FFMPEG_INPUT_FORMAT[ext.toLowerCase()]\n}\n\n/**\n * Pure parser; broken out so callers and tests can hit it without\n * spawning ffprobe. Returns `undefined` when the payload carries no\n * audio stream at all — a video file probed as audio must not produce a\n * half-filled result.\n */\nexport function parseFfprobeAudioJson(json: string): AudioInfo | undefined {\n\treturn audioInfoFromPayload(parseFfprobePayload(json))\n}\n\nexport function audioInfoFromPayload(\n\tpayload: FfprobePayload,\n): AudioInfo | undefined {\n\tconst streams = payload.streams ?? []\n\tconst audioStream = streams.find((s) => s.codec_type === \"audio\")\n\tif (audioStream === undefined) return undefined\n\n\tconst result: {\n\t\tdurationMs?: number\n\t\tcodec?: string\n\t\tbitRate?: number\n\t\tsampleRate?: number\n\t\tchannels?: number\n\t\tcoverArt?: AudioCoverArt\n\t\ttags?: AudioTags\n\t} = {}\n\n\tconst durationMs = ffprobeDurationMs(payload.format)\n\tif (durationMs !== undefined) result.durationMs = durationMs\n\tif (\n\t\ttypeof audioStream.codec_name === \"string\" &&\n\t\taudioStream.codec_name.length > 0\n\t) {\n\t\tresult.codec = audioStream.codec_name\n\t}\n\tconst bitRate = ffprobeInt(payload.format?.bit_rate)\n\tif (bitRate !== undefined) result.bitRate = bitRate\n\tconst sampleRate = ffprobeInt(audioStream.sample_rate)\n\tif (sampleRate !== undefined) result.sampleRate = sampleRate\n\tconst channels = ffprobeInt(audioStream.channels)\n\tif (channels !== undefined) result.channels = channels\n\tconst picture = streams.find(isAttachedPicture)\n\tif (picture !== undefined) {\n\t\tresult.coverArt = {\n\t\t\twidth: ffprobeInt(picture.width),\n\t\t\theight: ffprobeInt(picture.height),\n\t\t}\n\t}\n\tconst tags = readAudioTags(payload)\n\tif (tags !== undefined) result.tags = tags\n\n\treturn result\n}\n\n/**\n * True for the still image ffmpeg exposes as a video stream: ID3 APIC\n * frames, FLAC PICTURE blocks and MP4 `covr` atoms all surface this way.\n */\nfunction isAttachedPicture(stream: FfprobeStream): boolean {\n\tif (stream.codec_type !== \"video\") return false\n\tconst disposition = stream.disposition\n\tif (typeof disposition !== \"object\" || disposition === null) return false\n\treturn (disposition as Record<string, unknown>).attached_pic === 1\n}\n\n/**\n * Read the well-known tags off the container, falling back to the audio\n * stream's own tag table (Vorbis comments in Matroska/Ogg land there).\n * Tag keys vary in case between muxers, so lookups are case-insensitive.\n */\nfunction readAudioTags(payload: FfprobePayload): AudioTags | undefined {\n\tconst tables = [\n\t\tpayload.format?.tags,\n\t\tpayload.streams?.find((s) => s.codec_type === \"audio\")?.tags,\n\t]\n\tconst lookup = new Map<string, string>()\n\tfor (const table of tables) {\n\t\tif (typeof table !== \"object\" || table === null) continue\n\t\tfor (const [key, value] of Object.entries(table)) {\n\t\t\tif (typeof value !== \"string\" || value.length === 0) continue\n\t\t\tconst normalized = key.toLowerCase()\n\t\t\tif (!lookup.has(normalized)) lookup.set(normalized, value)\n\t\t}\n\t}\n\tconst tags: { title?: string; artist?: string; album?: string } = {}\n\tconst title = lookup.get(\"title\")\n\tif (title !== undefined) tags.title = title\n\tconst artist = lookup.get(\"artist\") ?? lookup.get(\"album_artist\")\n\tif (artist !== undefined) tags.artist = artist\n\tconst album = lookup.get(\"album\")\n\tif (album !== undefined) tags.album = album\n\treturn Object.keys(tags).length === 0 ? undefined : tags\n}\n\n/**\n * Probe an audio stream or path with ffprobe. Probe failures — a missing\n * binary, an unreadable container, or a source with no audio stream —\n * return `undefined` so callers can treat \"not yet probed\" and \"probe\n * failed\" the same way.\n */\nexport async function probeAudio(\n\tsource: string | Readable,\n\textHint?: string,\n): Promise<AudioInfo | undefined> {\n\ttry {\n\t\tconst inputFormat =\n\t\t\ttypeof source === \"string\" || extHint === undefined\n\t\t\t\t? undefined\n\t\t\t\t: extToAudioInputFormat(extHint)\n\t\tconst payload = await runFfprobeJson(source, getFfprobePath(), inputFormat)\n\t\treturn audioInfoFromPayload(payload)\n\t} catch {\n\t\treturn undefined\n\t}\n}\n","import type { Readable } from \"node:stream\"\nimport type { VideoInfo } from \"../types.ts\"\nimport {\n\ttype FfprobePayload,\n\tffprobeDurationMs,\n\tgetFfprobePath,\n\tparseFfprobePayload,\n\trunFfprobeJson,\n} from \"./ffprobe.ts\"\n\n/**\n * ffmpeg `-f` container name for piped zip entry bytes (no filename hint).\n */\nconst FFMPEG_INPUT_FORMAT: Readonly<Record<string, string>> = {\n\t\".mp4\": \"mp4\",\n\t\".m4v\": \"mp4\",\n\t\".webm\": \"webm\",\n\t\".mov\": \"mov\",\n\t\".mkv\": \"matroska\",\n\t\".avi\": \"avi\",\n}\n\n/** Resolve an extension to its ffmpeg `-f` container name, or `undefined` for unknown extensions. */\nexport function extToFfmpegInputFormat(ext: string): string | undefined {\n\treturn FFMPEG_INPUT_FORMAT[ext.toLowerCase()]\n}\n\n/**\n * Video probing via ffprobe (see `ffprobe.ts` for path resolution and the\n * process plumbing).\n */\n\n/**\n * Source-media metadata derived by ffprobe - pixel dimensions of the first\n * video stream and the playable duration in milliseconds. Any field can be\n * absent when ffprobe declines to report it (corrupt header, missing\n * container metadata). Callers persist whichever fields are populated.\n */\nexport type ProbedVideoMeta = {\n\treadonly width?: number\n\treadonly height?: number\n\treadonly durationMs?: number\n}\n\n/**\n * Run `ffprobe` against `source` and extract width, height and duration.\n *\n * @throws `Error` with stderr when ffprobe exits non-zero or emits no JSON.\n */\nexport async function probeVideoMeta(\n\tsource: string | Readable,\n\tffprobePath: string,\n\tinputFormat?: string,\n): Promise<ProbedVideoMeta> {\n\treturn videoMetaFromPayload(\n\t\tawait runFfprobeJson(source, ffprobePath, inputFormat),\n\t)\n}\n\n/**\n * Pure parser; broken out so callers and tests can hit it without spawning\n * ffprobe. Returns only the fields that survive type narrowing - partial\n * payloads are normal for malformed media and we prefer surfacing what we\n * have over rejecting the whole probe.\n */\nexport function parseFfprobeJson(json: string): ProbedVideoMeta {\n\treturn videoMetaFromPayload(parseFfprobePayload(json))\n}\n\nexport function videoMetaFromPayload(payload: FfprobePayload): ProbedVideoMeta {\n\tconst result: { width?: number; height?: number; durationMs?: number } = {}\n\tconst videoStream = payload.streams?.find((s) => s.codec_type === \"video\")\n\tif (videoStream !== undefined) {\n\t\tif (typeof videoStream.width === \"number\" && videoStream.width > 0) {\n\t\t\tresult.width = Math.round(videoStream.width)\n\t\t}\n\t\tif (typeof videoStream.height === \"number\" && videoStream.height > 0) {\n\t\t\tresult.height = Math.round(videoStream.height)\n\t\t}\n\t}\n\tconst durationMs = ffprobeDurationMs(payload.format)\n\tif (durationMs !== undefined) result.durationMs = durationMs\n\treturn result\n}\n\n/**\n * Probe a video stream or path with ffprobe and return its pixel\n * dimensions and duration. Probe failures return `undefined` so callers\n * can treat \"not yet probed\" and \"probe failed\" the same way.\n */\nexport async function probeVideo(\n\tsource: string | Readable,\n\textHint?: string,\n): Promise<VideoInfo | undefined> {\n\ttry {\n\t\tconst inputFormat =\n\t\t\ttypeof source === \"string\" || extHint === undefined\n\t\t\t\t? undefined\n\t\t\t\t: extToFfmpegInputFormat(extHint)\n\t\tconst probed = await probeVideoMeta(source, getFfprobePath(), inputFormat)\n\t\tif (\n\t\t\tprobed.width === undefined &&\n\t\t\tprobed.height === undefined &&\n\t\t\tprobed.durationMs === undefined\n\t\t) {\n\t\t\treturn undefined\n\t\t}\n\t\treturn {\n\t\t\twidth: probed.width,\n\t\t\theight: probed.height,\n\t\t\tdurationMs: probed.durationMs,\n\t\t}\n\t} catch {\n\t\treturn undefined\n\t}\n}\n","import { randomUUID } from \"node:crypto\"\nimport { mkdir, readFile, rename, rm, stat, writeFile } from \"node:fs/promises\"\nimport { join } from \"node:path\"\nimport type { MediaKind } from \"@hoardodile/sdk-types\"\nimport { fileTypeFromName } from \"@hoardodile/sdk-types\"\n\nimport { invalid } from \"../errors.ts\"\nimport {\n\textractSevenZipInto,\n\tlistSevenZipEntries,\n\tresolveSevenZipPath,\n} from \"./7z.ts\"\nimport {\n\tassertExtractedTree,\n\tdecodeZipNames,\n\tnormalizeExtractedTree,\n} from \"./extract.ts\"\nimport {\n\ttype ContainerFormat,\n\tSNIFF_WINDOW_BYTES,\n\tsniffContainerFormat,\n} from \"./format.ts\"\nimport { materializeFile } from \"./materialize.ts\"\nimport type { NestedCdCache } from \"./nested-cd-cache.ts\"\nimport {\n\ttype ArchiveEntry,\n\tcreateNestedResolver,\n\tcreateOuterArchiveSource,\n\ttype OuterEntrySource,\n} from \"./nested-entry.ts\"\nimport { streamRange } from \"./zip-entries.ts\"\n\n/**\n * Header window read before handing bytes to the injected image prober.\n * Shared with the host's sharp-backed probes so both sides feed the same\n * bounded window (JPEG SOF markers live in the first KBs; 256KB covers\n * even EXIF-bloated JPEGs).\n */\nexport const PROBE_HEADER_BYTES = 256 * 1024\n\n/** Input accepted by the injected image prober (host's sharp probe). */\nexport type ImageProbeInput = string | Buffer\n\n/** Result of the injected image prober. */\nexport type ImageProbeResult = {\n\treadonly width: number\n\treadonly height: number\n\treadonly animated: boolean\n}\n\n/**\n * Materialize a container entry (zip/tar/7z/rar/xz/gzip) into a cache\n * directory so the browser can serve the inner files over plain URLs.\n * This is the only disk-writing capability of the plugin API; everything\n * else about containers (cover probing, dimensions, hashes) reads\n * through virtual paths without materialization.\n *\n * Extraction is whole-archive through 7-Zip (budgets and paths validated\n * from its listing, then the extracted tree re-verified — see\n * `assertExtractedTree`). The manifest below is built from the listing,\n * so entries carry the exact names and sizes 7-Zip reported.\n *\n * Cache layout (per archive):\n * <cacheDir>/<archiveName>/index.json — completion marker + manifest\n * <cacheDir>/<archiveName>/<innerPath> — extracted file (subdirs kept)\n *\n * The `index.json` marker is written last and atomically: its presence\n * means extraction completed. A partial or failed run leaves files but no\n * marker, so the next call re-extracts idempotently.\n */\n\nexport const EXTRACT_INDEX_VERSION = 1\n\nexport type ExtractedEntry = {\n\treadonly path: string\n\treadonly sizeBytes: number\n\treadonly kind: MediaKind\n\treadonly width?: number\n\treadonly height?: number\n\treadonly animated?: boolean\n}\n\nexport type ExtractionResult = {\n\treadonly entries: readonly ExtractedEntry[]\n}\n\nexport type ExtractProgress = {\n\t/** Entries materialized so far (skipped cached files count too). */\n\treadonly done: number\n\treadonly total: number\n}\n\nexport type ExtractArchiveDeps = {\n\t/** Read access to the outer container (the resource's entries). */\n\treadonly outer: OuterEntrySource\n\t/**\n\t * Absolute cache directory for this resource's plugin extractions\n\t * (e.g. `<cacheRoot>/resources/<id>/extracted/v<N>/archives`).\n\t */\n\treadonly cacheDir: string\n\t/** Cumulative uncompressed byte budget for one archive. */\n\treadonly maxBytes: number\n\t/** Entry-count budget for one archive. */\n\treadonly maxEntries: number\n\t/** Optional image prober (sharp-backed) for per-entry dimensions. */\n\treadonly probeImage?: (\n\t\tsource: ImageProbeInput,\n\t\textHint?: string,\n\t) => Promise<ImageProbeResult | undefined>\n\t/**\n\t * Called as entries are materialized, so a long first extraction can\n\t * surface progress to the user.\n\t */\n\treadonly onProgress?: (progress: ExtractProgress) => void\n\t/** Shared nested-listing cache (see {@link NestedCdCache}). */\n\treadonly nestedCdCache?: NestedCdCache\n}\n\nexport type ArchiveExtractor = {\n\t/**\n\t * Materialize (or re-list) the container `archiveName`. Idempotent\n\t * and single-flighted per archive name.\n\t */\n\treadonly extract: (archiveName: string) => Promise<ExtractionResult>\n\t/**\n\t * List the container's entries without materializing anything into\n\t * the final cache layout. Resolves to `undefined` when `archiveName`\n\t * is not a container (or no 7-Zip binary exists for non-zip\n\t * formats).\n\t */\n\treadonly list: (\n\t\tarchiveName: string,\n\t) => Promise<readonly ArchiveEntry[] | undefined>\n}\n\n/** Build a single-flight, idempotent extractor over one outer container. */\nexport function createArchiveExtractor(\n\tdeps: ExtractArchiveDeps,\n): ArchiveExtractor {\n\tconst resolver = createNestedResolver(deps.outer, {\n\t\tcdCache: deps.nestedCdCache,\n\t})\n\tconst inflight = new Map<string, Promise<ExtractionResult>>()\n\n\tfunction extract(archiveName: string): Promise<ExtractionResult> {\n\t\tconst pending = inflight.get(archiveName)\n\t\tif (pending !== undefined) return pending\n\t\tconst work = extractOnce(archiveName)\n\t\tinflight.set(archiveName, work)\n\t\tvoid work.then(\n\t\t\t() => undefined,\n\t\t\t() => undefined,\n\t\t)\n\t\treturn work\n\t}\n\n\tasync function extractOnce(archiveName: string): Promise<ExtractionResult> {\n\t\tconst size = await deps.outer.sizeOf(archiveName)\n\t\tif (size === undefined) {\n\t\t\tthrow invalid(\n\t\t\t\t\"plugin.container_extract_failed\",\n\t\t\t\t`\"${archiveName}\" does not exist`,\n\t\t\t\t{ archiveName },\n\t\t\t)\n\t\t}\n\t\tconst markerPath = join(deps.cacheDir, archiveName, \"index.json\")\n\t\tconst existing = await readExistingManifest(markerPath, archiveName)\n\t\tif (existing !== undefined) return { entries: existing }\n\t\tif (size > 0) {\n\t\t\tconst head = await deps.outer.readSlice(\n\t\t\t\tarchiveName,\n\t\t\t\t0,\n\t\t\t\tMath.min(size, SNIFF_WINDOW_BYTES),\n\t\t\t)\n\t\t\tconst format = sniffContainerFormat(head)\n\t\t\tif (format !== undefined) {\n\t\t\t\treturn extractWithSevenZip(archiveName, size, markerPath, format)\n\t\t\t}\n\t\t}\n\t\tthrow invalid(\n\t\t\t\"plugin.container_extract_failed\",\n\t\t\t`\"${archiveName}\" is not a supported archive (zip/tar/7z/rar/xz/gzip)`,\n\t\t\t{ archiveName },\n\t\t)\n\t}\n\n\t/**\n\t * Whole-archive extraction through 7-Zip: materialize the outer entry\n\t * to a temp file (7-Zip needs a real path), validate budgets and\n\t * encryption from the listing, extract into the cache dir, then build\n\t * the idempotent manifest. Runs for every supported format — zip/tar\n\t * included, exactly like the 7z/rar/xz branch before them.\n\t */\n\tasync function extractWithSevenZip(\n\t\tarchiveName: string,\n\t\tsize: number,\n\t\tmarkerPath: string,\n\t\tformat: ContainerFormat,\n\t): Promise<ExtractionResult> {\n\t\tconst source = createOuterArchiveSource(deps.outer, archiveName, size)\n\t\tconst tempPath = join(\n\t\t\tdeps.cacheDir,\n\t\t\t`${archiveName}.7z-partial-${process.pid}-${randomUUID().slice(0, 8)}`,\n\t\t)\n\t\tawait mkdir(deps.cacheDir, { recursive: true })\n\t\ttry {\n\t\t\tawait materializeFile({\n\t\t\t\topenStream: () => streamRange(source, 0, size - 1),\n\t\t\t\ttarget: tempPath,\n\t\t\t\texpectedSize: size,\n\t\t\t})\n\t\t\tconst entries = await listSevenZipEntries(tempPath)\n\t\t\tconst files = entries.filter((e) => !e.folder)\n\t\t\tcheckExtractionBudgets(archiveName, files, deps.maxBytes, deps.maxEntries)\n\t\t\tif (entries.some((e) => e.encrypted)) {\n\t\t\t\tthrow invalid(\n\t\t\t\t\t\"plugin.container_extract_failed\",\n\t\t\t\t\t`archive \"${archiveName}\" is password-protected — encrypted archives are not supported`,\n\t\t\t\t\t{ archiveName },\n\t\t\t\t)\n\t\t\t}\n\t\t\t// 7-Zip's text listing drops invalid-UTF-8 bytes on POSIX, so\n\t\t\t// for zip archives the decoded names come from the archive\n\t\t\t// itself (yauzl): the manifest, the probes and the legacy-name\n\t\t\t// renames below all agree on them.\n\t\t\tconst decoded =\n\t\t\t\tformat === \"zip\"\n\t\t\t\t\t? await decodeZipNames(\n\t\t\t\t\t\t\ttempPath,\n\t\t\t\t\t\t\tfiles.map((e) => ({ name: e.name, sizeBytes: e.sizeBytes })),\n\t\t\t\t\t\t)\n\t\t\t\t\t: undefined\n\t\t\tconst materialized = decoded?.files ?? files\n\t\t\t// Pre-validate every path from the listing — 7-Zip itself\n\t\t\t// warns-and-skips unsafe names instead of failing.\n\t\t\tfor (const entry of materialized) sanitizeExtractPath(entry.name)\n\t\t\tconst root = join(deps.cacheDir, archiveName)\n\t\t\tawait mkdir(root, { recursive: true })\n\t\t\tawait extractSevenZipInto(tempPath, root)\n\t\t\t// 7-Zip writes legacy zip names verbatim on POSIX and restores\n\t\t\t// mode bits that can strip app access; fix both before the tree\n\t\t\t// is re-walked (symlink scan) or read for the manifest below.\n\t\t\t// The decoded names are the ground truth the legacy rename pass\n\t\t\t// matches against (see `renameLegacyZipNames` in extract.ts).\n\t\t\tawait normalizeExtractedTree(root, {\n\t\t\t\tlegacyZipNames: format === \"zip\",\n\t\t\t\texpectedNames: decoded?.paths,\n\t\t\t})\n\t\t\tawait assertExtractedTree(root, deps.maxBytes)\n\t\t\tdeps.onProgress?.({ done: files.length, total: files.length })\n\t\t\tconst manifest = await buildManifest(\n\t\t\t\tarchiveName,\n\t\t\t\tmaterialized.map((e) => ({ name: e.name, sizeBytes: e.sizeBytes })),\n\t\t\t)\n\t\t\tawait writeManifest(markerPath, archiveName, manifest)\n\t\t\t// The manifest now serves listings (and materialized virtual\n\t\t\t// paths); drop the size-keyed listing cache for this archive.\n\t\t\tawait rm(join(deps.cacheDir, `${archiveName}.7z-list`), {\n\t\t\t\tforce: true,\n\t\t\t}).catch(() => {})\n\t\t\tawait rm(join(deps.cacheDir, `${archiveName}.7z-list.json`), {\n\t\t\t\tforce: true,\n\t\t\t}).catch(() => {})\n\t\t\treturn { entries: manifest }\n\t\t} finally {\n\t\t\tawait rm(tempPath, { force: true }).catch(() => {})\n\t\t}\n\t}\n\n\t/** Shared bomb guards for one extraction: entry count and total bytes. */\n\tfunction checkExtractionBudgets(\n\t\tarchiveName: string,\n\t\tentries: readonly { readonly sizeBytes: number }[],\n\t\tmaxBytes: number,\n\t\tmaxEntries: number,\n\t): void {\n\t\tif (entries.length > maxEntries) {\n\t\t\tthrow invalid(\n\t\t\t\t\"plugin.container_extract_failed\",\n\t\t\t\t`archive \"${archiveName}\" has ${entries.length} entries, exceeding the limit of ${maxEntries}`,\n\t\t\t\t{ archiveName, entries: entries.length, maxEntries },\n\t\t\t)\n\t\t}\n\t\tlet totalBytes = 0\n\t\tfor (const entry of entries) totalBytes += entry.sizeBytes\n\t\tif (totalBytes > maxBytes) {\n\t\t\tthrow invalid(\n\t\t\t\t\"plugin.container_extract_failed\",\n\t\t\t\t`archive \"${archiveName}\" unpacks to ${totalBytes} bytes, exceeding the limit of ${maxBytes}`,\n\t\t\t\t{ archiveName, totalBytes, maxBytes },\n\t\t\t)\n\t\t}\n\t}\n\n\tasync function buildManifest(\n\t\tarchiveName: string,\n\t\tentries: readonly ArchiveEntry[],\n\t): Promise<readonly ExtractedEntry[]> {\n\t\tconst manifest: ExtractedEntry[] = []\n\t\tfor (const entry of entries) {\n\t\t\tconst kind = fileTypeFromName(entry.name)?.kind ?? \"other\"\n\t\t\tconst row: MutableExtractedEntry = {\n\t\t\t\tpath: entry.name,\n\t\t\t\tsizeBytes: entry.sizeBytes,\n\t\t\t\tkind,\n\t\t\t}\n\t\t\tif (kind === \"image\" && deps.probeImage !== undefined) {\n\t\t\t\tconst rel = sanitizeExtractPath(entry.name)\n\t\t\t\tconst filePath = join(deps.cacheDir, archiveName, rel)\n\t\t\t\tconst probe = await probeImageHead(\n\t\t\t\t\tdeps.probeImage,\n\t\t\t\t\tfilePath,\n\t\t\t\t\tentry.name,\n\t\t\t\t)\n\t\t\t\tif (probe !== undefined) {\n\t\t\t\t\trow.width = probe.width\n\t\t\t\t\trow.height = probe.height\n\t\t\t\t\trow.animated = probe.animated\n\t\t\t\t}\n\t\t\t}\n\t\t\tmanifest.push(row)\n\t\t}\n\t\treturn manifest\n\t}\n\n\t/**\n\t * List the container's entries. Zip lists from the nested resolver\n\t * (CD parse, nothing materialized); tar/7z/rar/xz list through the\n\t * 7-Zip binary over a size-keyed materialization cache in the cache\n\t * dir (7-Zip needs a real path; the bytes are written once per outer\n\t * size and reused across hook calls — every hook call builds a fresh\n\t * API instance, so per-call temp files would re-write the whole\n\t * archive each time). Resolves to `undefined` when the entry is not a\n\t * container, is gzip (whole-archive single-stream), or the 7-Zip\n\t * binary is absent (quiet degradation — callers treat it as \"not a\n\t * container\", matching the resolver semantics).\n\t */\n\tasync function list(\n\t\tarchiveName: string,\n\t): Promise<readonly ArchiveEntry[] | undefined> {\n\t\tconst size = await deps.outer.sizeOf(archiveName)\n\t\tif (size === undefined) return undefined\n\t\t// An extracted archive answers from its manifest — cheaper than\n\t\t// any listing, and the manifest is the source of truth for the\n\t\t// materialized virtual paths (see nested-view.ts).\n\t\tconst markerPath = join(deps.cacheDir, archiveName, \"index.json\")\n\t\tconst extracted = await readExistingManifest(markerPath, archiveName)\n\t\tif (extracted !== undefined) {\n\t\t\treturn extracted.map((e) => ({ name: e.path, sizeBytes: e.sizeBytes }))\n\t\t}\n\t\tif (size === 0) return undefined\n\t\tconst head = await deps.outer.readSlice(\n\t\t\tarchiveName,\n\t\t\t0,\n\t\t\tMath.min(size, SNIFF_WINDOW_BYTES),\n\t\t)\n\t\tconst format = sniffContainerFormat(head)\n\t\tif (format === \"zip\") {\n\t\t\tconst entries = await resolver.list(archiveName)\n\t\t\tif (entries === undefined) return undefined\n\t\t\treturn entries.map((e) => ({ name: e.name, sizeBytes: e.sizeBytes }))\n\t\t}\n\t\tif (\n\t\t\tformat === \"tar\" ||\n\t\t\tformat === \"7z\" ||\n\t\t\tformat === \"rar\" ||\n\t\t\tformat === \"xz\"\n\t\t) {\n\t\t\treturn listViaSevenZip(archiveName, size)\n\t\t}\n\t\treturn undefined\n\t}\n\n\t/**\n\t * Non-zip listing through 7-Zip. The outer entry is materialized once\n\t * per size into `<cacheDir>/<archiveName>.7z-list` (keyed by outer\n\t * size — the source is immutable per version, so a size match means\n\t * the cached bytes are the same file) and the parsed listing is\n\t * cached next to it. `extractWithSevenZip` removes both files once a\n\t * real extraction lands, so the manifest takes over afterwards.\n\t */\n\tasync function listViaSevenZip(\n\t\tarchiveName: string,\n\t\tsize: number,\n\t): Promise<readonly ArchiveEntry[] | undefined> {\n\t\tif (resolveSevenZipPath() === undefined) return undefined\n\t\tawait mkdir(deps.cacheDir, { recursive: true })\n\t\tconst cachePath = join(deps.cacheDir, `${archiveName}.7z-list`)\n\t\tconst entriesPath = join(deps.cacheDir, `${archiveName}.7z-list.json`)\n\t\tconst cached = await stat(cachePath).catch(() => undefined)\n\t\tif (cached?.size === size) {\n\t\t\tconst raw = await readFile(entriesPath, \"utf8\").catch(() => undefined)\n\t\t\tif (raw !== undefined) {\n\t\t\t\ttry {\n\t\t\t\t\treturn JSON.parse(raw) as readonly ArchiveEntry[]\n\t\t\t\t} catch {\n\t\t\t\t\t// Corrupt cache — fall through and re-materialize.\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t\ttry {\n\t\t\tconst source = createOuterArchiveSource(deps.outer, archiveName, size)\n\t\t\tawait materializeFile({\n\t\t\t\topenStream: () => streamRange(source, 0, size - 1),\n\t\t\t\ttarget: cachePath,\n\t\t\t\texpectedSize: size,\n\t\t\t})\n\t\t\tconst listed = await listSevenZipEntries(cachePath)\n\t\t\tconst files = listed\n\t\t\t\t.filter((e) => !e.folder)\n\t\t\t\t.map((e) => ({ name: e.name, sizeBytes: e.sizeBytes }))\n\t\t\tawait writeFile(entriesPath, JSON.stringify(files))\n\t\t\treturn files\n\t\t} catch (err) {\n\t\t\tawait rm(cachePath, { force: true }).catch(() => {})\n\t\t\tawait rm(entriesPath, { force: true }).catch(() => {})\n\t\t\tthrow err\n\t\t}\n\t}\n\n\treturn { extract, list }\n}\n\ntype MutableExtractedEntry = {\n\tpath: string\n\tsizeBytes: number\n\tkind: MediaKind\n\twidth?: number\n\theight?: number\n\tanimated?: boolean\n}\n\n/**\n * Read and validate a manifest written by {@link writeManifest}: version\n * and archive-name must match, otherwise the marker is stale garbage and\n * the caller re-extracts. Shared with the nested-view materialized-path\n * resolution.\n */\nexport async function readExistingManifest(\n\tmarkerPath: string,\n\tarchiveName: string,\n): Promise<readonly ExtractedEntry[] | undefined> {\n\tconst raw = await readFile(markerPath, \"utf8\").catch(() => undefined)\n\tif (raw === undefined) return undefined\n\ttry {\n\t\tconst parsed = JSON.parse(raw) as {\n\t\t\treadonly v?: number\n\t\t\treadonly archiveName?: string\n\t\t\treadonly entries?: readonly ExtractedEntry[]\n\t\t}\n\t\tif (\n\t\t\tparsed.v !== EXTRACT_INDEX_VERSION ||\n\t\t\tparsed.archiveName !== archiveName ||\n\t\t\t!Array.isArray(parsed.entries)\n\t\t) {\n\t\t\treturn undefined\n\t\t}\n\t\treturn parsed.entries\n\t} catch {\n\t\treturn undefined\n\t}\n}\n\nasync function writeManifest(\n\tmarkerPath: string,\n\tarchiveName: string,\n\tentries: readonly ExtractedEntry[],\n): Promise<void> {\n\tconst payload = JSON.stringify({\n\t\tv: EXTRACT_INDEX_VERSION,\n\t\tarchiveName,\n\t\tentries,\n\t})\n\tconst partial = `${markerPath}.partial-${process.pid}`\n\tawait writeFile(partial, payload)\n\tawait rename(partial, markerPath)\n}\n\n/**\n * Normalize an inner entry name to a safe relative path: backslashes\n * become forward slashes, traversal segments and control characters are\n * rejected, and the result must stay inside the extraction root.\n */\nexport function sanitizeExtractPath(name: string): string {\n\tconst normalized = name.replace(/\\\\/g, \"/\")\n\tif (normalized.startsWith(\"/\") || /^[A-Za-z]:/.test(normalized)) {\n\t\tthrow invalid(\n\t\t\t\"plugin.container_extract_failed\",\n\t\t\t`archive entry has an absolute path: \"${name}\"`,\n\t\t\t{ name },\n\t\t)\n\t}\n\tconst segments = normalized.split(\"/\")\n\tfor (const seg of segments) {\n\t\tif (seg.length === 0 || seg === \".\" || seg === \"..\") {\n\t\t\tthrow invalid(\n\t\t\t\t\"plugin.container_extract_failed\",\n\t\t\t\t`archive entry has an unsafe path: \"${name}\"`,\n\t\t\t\t{ name },\n\t\t\t)\n\t\t}\n\t\tif (hasControlChar(seg)) {\n\t\t\tthrow invalid(\n\t\t\t\t\"plugin.container_extract_failed\",\n\t\t\t\t`archive entry contains control characters: \"${name}\"`,\n\t\t\t\t{ name },\n\t\t\t)\n\t\t}\n\t}\n\treturn segments.join(\"/\")\n}\n\n/** True when any char code is a control character (C0 or DEL). */\nfunction hasControlChar(value: string): boolean {\n\tfor (let i = 0; i < value.length; i++) {\n\t\tconst code = value.charCodeAt(i)\n\t\tif (code < 0x20 || code === 0x7f) return true\n\t}\n\treturn false\n}\n\n/** Probe one extracted image's header window for dimensions. */\nasync function probeImageHead(\n\tprobeImage: NonNullable<ExtractArchiveDeps[\"probeImage\"]>,\n\tfilePath: string,\n\tname: string,\n): Promise<ImageProbeResult | undefined> {\n\tconst head = await readFile(filePath).then((b) =>\n\t\tb.subarray(0, PROBE_HEADER_BYTES),\n\t)\n\tif (head.length === 0) return undefined\n\tconst ext = name.slice(name.lastIndexOf(\".\")).toLowerCase()\n\treturn probeImage(head, ext)\n}\n","import { extname } from \"node:path\"\nimport type { Readable } from \"node:stream\"\nimport { IMAGE_EXTS } from \"@hoardodile/sdk-types/media-exts\"\nimport { PROBE_HEADER_BYTES } from \"../archive/index.ts\"\n\nimport type { ImageInfo } from \"../types.ts\"\n\n/**\n * Max zip entry size read into memory for thumb synthesis. Larger\n * entries are materialized to disk before sharp/ffmpeg runs.\n */\nexport const THUMB_BUFFER_MAX_BYTES = 32 * 1024 * 1024\n\n/**\n * Image probing via sharp. sharp is loaded lazily and declared as an\n * optional peer dependency: the host package stays free of native\n * binaries, and consumers that never probe images (the browser-side mock,\n * dependency-light CI installs) never pull it in. When sharp is missing,\n * probe calls fail loudly instead of degrading silently.\n */\ntype SharpModule = typeof import(\"sharp\")\n\nlet sharpPromise: Promise<SharpModule> | undefined\n\nexport function loadSharp(): Promise<SharpModule> {\n\tsharpPromise ??= import(\"sharp\").catch((err: unknown) => {\n\t\tsharpPromise = undefined\n\t\tthrow new Error(\n\t\t\t`sharp is not installed — add \"sharp\" to your dependencies to enable image probing: ${err instanceof Error ? err.message : String(err)}`,\n\t\t)\n\t})\n\treturn sharpPromise\n}\n\nexport type ImageSourceProbe = {\n\treadonly width: number\n\treadonly height: number\n\treadonly animated: boolean\n}\n\nconst ANIMATED_EXT_HINTS = new Set([\".gif\", \".webp\", \".avif\"])\n\nexport { PROBE_HEADER_BYTES } from \"../archive/index.ts\"\n\n/** True when `ext` names a format that can carry animation. */\nexport function isAnimatedCandidateExt(ext: string): boolean {\n\treturn ANIMATED_EXT_HINTS.has(ext)\n}\n\nexport type ImageMetadataInput =\n\t| string\n\t| Buffer\n\t| Readable\n\t| {\n\t\t\treadonly openStream: () => Promise<Readable>\n\t\t\t/**\n\t\t\t * Optional byte-range read into the underlying source (e.g. a\n\t\t\t * zip entry slice). When present, metadata probes read\n\t\t\t * {@link PROBE_HEADER_BYTES} from the start instead of\n\t\t\t * streaming the whole entry — libvips reads the ENTIRE file\n\t\t\t * for metadata from a non-seekable stream, but only the header\n\t\t\t * from a buffer.\n\t\t\t */\n\t\t\treadonly readRange?: (start: number, end: number) => Promise<Buffer>\n\t }\n\n/**\n * Narrow a Node stream into sharp's readable input without assertion.\n * Async because the sharp module itself is loaded lazily.\n *\n * sharp's constructor rejects `sharp(stream, opts)` — for stream input\n * the input options must be passed AS the first argument (a plain object\n * of input parameters like `pages`/`animated`) and the readable piped\n * into the resulting Duplex.\n */\nexport async function sharpFromReadable(\n\tstream: Readable,\n\toptions?: import(\"sharp\").SharpOptions,\n): Promise<import(\"sharp\").Sharp> {\n\tif (typeof stream.read !== \"function\") {\n\t\tthrow new Error(\"expected a readable stream\")\n\t}\n\tconst { default: sharp } = await loadSharp()\n\tconst descriptor =\n\t\toptions === undefined || Object.keys(options).length === 0\n\t\t\t? undefined\n\t\t\t: ({ ...options } as unknown as import(\"sharp\").SharpInput)\n\tconst instance = descriptor === undefined ? sharp() : sharp(descriptor)\n\tstream.pipe(instance)\n\treturn instance\n}\n\nfunction isReadable(input: unknown): input is Readable {\n\treturn (\n\t\ttypeof input === \"object\" &&\n\t\tinput !== null &&\n\t\ttypeof (input as Readable).pipe === \"function\" &&\n\t\t!(\"openStream\" in input)\n\t)\n}\n\nfunction isReopenableImageStream(\n\tinput: ImageMetadataInput,\n): input is { readonly openStream: () => Promise<Readable> } {\n\treturn (\n\t\ttypeof input === \"object\" &&\n\t\tinput !== null &&\n\t\t!Buffer.isBuffer(input) &&\n\t\t\"openStream\" in input &&\n\t\ttypeof (input as { openStream: unknown }).openStream === \"function\"\n\t)\n}\n\n/**\n * Sharp options for image thumb/probe reads. Enables sequential read for\n * large JPEGs on disk so libvips can shrink-on-load during resize.\n */\nexport function sharpImageInputOpts(\n\tinput: string | Buffer,\n\text: string,\n\tpages: number,\n\tanimated?: boolean,\n): import(\"sharp\").SharpOptions {\n\tconst opts: import(\"sharp\").SharpOptions = { pages }\n\tif (animated === true) opts.animated = true\n\tif (\n\t\ttypeof input === \"string\" &&\n\t\t(ext === \".jpg\" || ext === \".jpeg\" || ext === \".jfif\")\n\t) {\n\t\topts.sequentialRead = true\n\t}\n\treturn opts\n}\n\n/**\n * True when a shallow `{ pages: 1 }` metadata read is not enough to\n * decide animation and the full multi-page scan is required.\n */\nexport function needsFullAnimationScan(\n\tmeta: Pick<\n\t\timport(\"sharp\").Metadata,\n\t\t\"width\" | \"height\" | \"pages\" | \"pageHeight\"\n\t>,\n\text: string,\n): boolean {\n\tif (ext === \".gif\") return true\n\tif (ANIMATED_EXT_HINTS.has(ext) && (meta.pages ?? 1) > 1) return true\n\tif (\n\t\tmeta.pageHeight !== undefined &&\n\t\tmeta.height !== undefined &&\n\t\tmeta.pageHeight !== meta.height\n\t) {\n\t\treturn true\n\t}\n\treturn false\n}\n\n/**\n * Buffer a probe stream with a hard byte cap. Only used for formats whose\n * animation scan needs the whole stream (GIF): probing only needs\n * metadata, so an entry bigger than the cap is treated as unprobed rather\n * than buffered whole into host memory.\n */\nasync function bufferCapped(\n\tinput: Readable,\n\tmaxBytes: number,\n): Promise<Buffer> {\n\tconst chunks: Uint8Array[] = []\n\tlet total = 0\n\tfor await (const chunk of input) {\n\t\tconst bytes: Uint8Array =\n\t\t\ttypeof chunk === \"string\" ? Buffer.from(chunk) : chunk\n\t\ttotal += bytes.byteLength\n\t\tif (total > maxBytes) {\n\t\t\tinput.destroy()\n\t\t\tthrow new Error(`probe input exceeds the ${maxBytes}-byte cap`)\n\t\t}\n\t\tchunks.push(bytes)\n\t}\n\treturn Buffer.concat(chunks)\n}\n\n/**\n * Read image metadata with layered animation detection: static sources\n * stop after a single-page read; animated containers escalate to\n * `{ pages: -1 }` only when the shallow probe signals multi-frame input.\n */\nexport async function readImageMetadata(\n\tinput: ImageMetadataInput,\n\text: string,\n): Promise<{\n\treadonly meta: import(\"sharp\").Metadata\n\treadonly animated: boolean\n}> {\n\tif (isReopenableImageStream(input)) {\n\t\t// Header-slice fast path: probe a small leading window as a buffer\n\t\t// (header-only libvips read) instead of streaming the whole entry\n\t\t// (full-file read). Only definitive static results are accepted —\n\t\t// animation candidates escalate inside the slice read, and a\n\t\t// truncated buffer cannot complete a full scan, so those fall\n\t\t// through to the stream path where frame counts stay correct. GIF\n\t\t// is skipped outright (its frame count lives at the end).\n\t\tif (input.readRange !== undefined && ext !== \".gif\") {\n\t\t\tconst head = await input.readRange(0, PROBE_HEADER_BYTES)\n\t\t\tif (head.length > 0) {\n\t\t\t\ttry {\n\t\t\t\t\tconst { default: sharp } = await loadSharp()\n\t\t\t\t\tconst shallowMeta = await sharp(\n\t\t\t\t\t\thead,\n\t\t\t\t\t\tsharpImageInputOpts(head, ext, 1),\n\t\t\t\t\t).metadata()\n\t\t\t\t\tif (!needsFullAnimationScan(shallowMeta, ext)) {\n\t\t\t\t\t\treturn { meta: shallowMeta, animated: false }\n\t\t\t\t\t}\n\t\t\t\t} catch {\n\t\t\t\t\t// Truncated header unreadable — fall through to the\n\t\t\t\t\t// stream path.\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t\tconst stream = await input.openStream()\n\t\tconst shallow = await sharpFromReadable(stream, { pages: 1 })\n\t\tconst shallowMeta = await shallow.metadata()\n\t\tif (!needsFullAnimationScan(shallowMeta, ext)) {\n\t\t\treturn { meta: shallowMeta, animated: false }\n\t\t}\n\t\tconst fullStream = await input.openStream()\n\t\tconst fullMeta = await sharpFromReadable(fullStream, {\n\t\t\tpages: -1,\n\t\t\tanimated: true,\n\t\t}).then((s) => s.metadata())\n\t\treturn { meta: fullMeta, animated: (fullMeta.pages ?? 1) > 1 }\n\t}\n\tif (isReadable(input)) {\n\t\t// GIF frame counts are not stored in the container header, so the\n\t\t// full animation scan needs the whole stream — buffer cap-guarded\n\t\t// and reuse the buffer path.\n\t\tif (ext === \".gif\") {\n\t\t\tconst data = await bufferCapped(input, THUMB_BUFFER_MAX_BYTES)\n\t\t\treturn readImageMetadata(data, ext)\n\t\t}\n\t\t// Static formats: hand the stream straight to sharp so the probe\n\t\t// reads only the header bytes. (Buffering the whole entry first —\n\t\t// the previous behavior, capped at THUMB_BUFFER_MAX_BYTES — turned\n\t\t// every stream probe into a full read and made entries beyond the\n\t\t// cap unprobeable.)\n\t\ttry {\n\t\t\tconst shallow = await sharpFromReadable(input, { pages: 1 })\n\t\t\tconst shallowMeta = await shallow.metadata()\n\t\t\tif (!needsFullAnimationScan(shallowMeta, ext)) {\n\t\t\t\treturn { meta: shallowMeta, animated: false }\n\t\t\t}\n\t\t\t// Animated candidates (webp/avif/tiff) report their total page\n\t\t\t// count from the container header, so the shallow read is\n\t\t\t// conclusive — and the stream cannot be rewound for a full\n\t\t\t// scan anyway.\n\t\t\treturn {\n\t\t\t\tmeta: shallowMeta,\n\t\t\t\tanimated: (shallowMeta.pages ?? 1) > 1,\n\t\t\t}\n\t\t} finally {\n\t\t\tinput.destroy()\n\t\t}\n\t}\n\tconst { default: sharp } = await loadSharp()\n\tconst shallow = sharp(input, sharpImageInputOpts(input, ext, 1))\n\tconst shallowMeta = await shallow.metadata()\n\tif (!needsFullAnimationScan(shallowMeta, ext)) {\n\t\treturn { meta: shallowMeta, animated: false }\n\t}\n\tconst fullMeta = await sharp(\n\t\tinput,\n\t\tsharpImageInputOpts(input, ext, -1, true),\n\t).metadata()\n\treturn { meta: fullMeta, animated: (fullMeta.pages ?? 1) > 1 }\n}\n\n/**\n * Probe an image path or buffer and return pixel dimensions plus whether\n * the source is animated. Probe failures return `undefined`.\n */\nexport async function probeImageSource(\n\tinput: ImageMetadataInput,\n\textHint?: string,\n): Promise<ImageSourceProbe | undefined> {\n\tconst ext =\n\t\textHint ?? (typeof input === \"string\" ? extname(input).toLowerCase() : \"\")\n\tif (ext.length > 0 && !IMAGE_EXTS.has(ext)) return undefined\n\ttry {\n\t\tconst { meta, animated } = await readImageMetadata(input, ext)\n\t\tconst h = meta.pageHeight ?? meta.height\n\t\tif (meta.width === undefined || h === undefined) return undefined\n\t\treturn { width: meta.width, height: h, animated }\n\t} catch {\n\t\treturn undefined\n\t}\n}\n\n/**\n * Probe an image source and return its pixel dimensions. Probe failures\n * return `undefined` so callers can treat \"not yet probed\" and \"probe\n * failed\" the same way.\n */\nexport async function probeImage(\n\tsource: ImageMetadataInput,\n): Promise<ImageInfo | undefined> {\n\tconst probe = await probeImageSource(source)\n\tif (probe === undefined) return undefined\n\treturn { width: probe.width, height: probe.height }\n}\n\n/**\n * True when the image has more than one frame (animated GIF / WebP / APNG /\n * AVIF). Uses layered detection so static JPEG/PNG avoid a full-frame scan.\n * Errors are coerced to `false`.\n */\nexport async function probeAnimatedImage(\n\tsource: ImageMetadataInput,\n): Promise<boolean> {\n\tif (\n\t\ttypeof source === \"string\" &&\n\t\t!IMAGE_EXTS.has(extname(source).toLowerCase())\n\t) {\n\t\treturn false\n\t}\n\ttry {\n\t\tconst probe = await probeImageSource(source)\n\t\treturn probe?.animated ?? false\n\t} catch (err) {\n\t\tconst label = typeof source === \"string\" ? source : \"stream\"\n\t\tconsole.warn(\n\t\t\t`[probeAnimatedImage] sharp failed on ${label}: ${err instanceof Error ? err.message : String(err)}`,\n\t\t)\n\t\treturn false\n\t}\n}\n","import { spawn } from \"node:child_process\"\nimport { mkdir, readdir, rename, rm } from \"node:fs/promises\"\nimport { dirname, extname, join } from \"node:path\"\nimport type { Readable } from \"node:stream\"\nimport type { ResolvedImageVariant } from \"@hoardodile/sdk-types/image-variant\"\nimport sharp, { type Sharp } from \"sharp\"\nimport {\n\textToAudioInputFormat,\n\textToFfmpegInputFormat,\n\treadImageMetadata,\n\tsharpFromReadable,\n\tsharpImageInputOpts,\n} from \"../probe/index.ts\"\nimport type { FfmpegPaths } from \"./ffmpeg.ts\"\n\n/**\n * Level of downscaling applied to animated thumbnails: they use 1/3 the\n * still-image area cap, which keeps animated WebP file sizes sane.\n */\nexport const ANIMATED_AREA_DIVISOR = 3\n\n/** Regexp matching the temp-file suffix written by {@link encodeImageFile}. */\nconst ORPHANED_TEMP_RE = /\\.writing-\\d+-\\d+$/\n\n/**\n * Recursively remove any `.writing-<pid>-<ts>` temp files left behind by a\n * previous process crash. Safe to call fire-and-forget at startup.\n */\nexport async function cleanOrphanedTempFiles(thumbRoot: string): Promise<void> {\n\tawait removeOrphanedInDir(thumbRoot)\n}\n\nasync function removeOrphanedInDir(dir: string): Promise<void> {\n\tlet entries: string[]\n\ttry {\n\t\tentries = await readdir(dir)\n\t} catch {\n\t\treturn\n\t}\n\tawait Promise.all(\n\t\tentries.map(async (entry) => {\n\t\t\tconst full = join(dir, entry)\n\t\t\tif (ORPHANED_TEMP_RE.test(entry)) {\n\t\t\t\tawait rm(full, { force: true }).catch(() => {})\n\t\t\t} else {\n\t\t\t\tawait removeOrphanedInDir(full)\n\t\t\t}\n\t\t}),\n\t)\n}\n\nexport const WEBP_QUALITY = 82\nexport const PREVIEW_WEBP_QUALITY = 90\nexport const AVIF_QUALITY = 65\nexport const PREVIEW_AVIF_QUALITY = 70\n/**\n * Level of CPU effort the AVIF encoder spends reducing file size\n * (0 = fastest/largest … 9 = slowest/smallest; sharp's default is 4).\n * Thumbs are cache-once artifacts re-encoded in bulk by the precache\n * sweep, where AVIF encode dominates wall time; effort 2 cuts encode\n * cost substantially for a few percent larger output — measured on the\n * precache bench (see bench/precache.bench.ts).\n */\nexport const AVIF_EFFORT = 2\n\nexport type RenderResult = {\n\treadonly path: string\n\treadonly bytes: number\n}\n\nexport type ImageThumbInput =\n\t| string\n\t| Buffer\n\t| {\n\t\t\treadonly openStream: () => Promise<Readable>\n\t\t\t/**\n\t\t\t * Byte-range read into the underlying source. Lets the metadata\n\t\t\t * step read a small header window instead of streaming the\n\t\t\t * whole entry — libvips full-reads non-seekable input.\n\t\t\t */\n\t\t\treadonly readRange?: (start: number, end: number) => Promise<Buffer>\n\t }\n\nfunction isReopenableImageStream(input: ImageThumbInput): input is {\n\treadonly openStream: () => Promise<Readable>\n\treadonly readRange?: (start: number, end: number) => Promise<Buffer>\n} {\n\treturn (\n\t\ttypeof input === \"object\" &&\n\t\tinput !== null &&\n\t\t!Buffer.isBuffer(input) &&\n\t\t\"openStream\" in input\n\t)\n}\n\nexport type ImageThumbRenderResult = RenderResult & {\n\treadonly format: \"webp\" | \"avif\"\n\treadonly displayWidth: number\n\treadonly displayHeight: number\n\treadonly animated: boolean\n}\n\nexport type VideoFrameSource = string | Readable\n\n/**\n * Encode an image thumb in a single sharp pipeline: one metadata read,\n * animation detection, resize, and encode. Encodes to the variant's\n * `format`, downgrading an animated source to WebP (sharp cannot encode\n * animated AVIF).\n */\nexport async function renderImageThumbOnce(opts: {\n\treadonly input: ImageThumbInput\n\treadonly resolveDest: (fmt: \"webp\" | \"avif\") => string\n\t/**\n\t * The resolved render plan: codec, fit policy, area cap and\n\t * per-format qualities. The pipeline is a pure consumer of the\n\t * variant — everything that decides the output bytes lives in it.\n\t */\n\treadonly variant: ResolvedImageVariant\n\treadonly ext?: string\n}): Promise<ImageThumbRenderResult> {\n\tconst ext =\n\t\topts.ext ??\n\t\t(typeof opts.input === \"string\" ? extname(opts.input).toLowerCase() : \"\")\n\tconst { meta, animated } = await readImageMetadata(opts.input, ext)\n\tconst h = meta.pageHeight ?? meta.height\n\tif (meta.width === undefined || h === undefined) {\n\t\tthrow new Error(\"image source is missing dimensions\")\n\t}\n\tconst requested = opts.variant.format\n\tconst format = animated && requested === \"avif\" ? \"webp\" : requested\n\tconst area = animated\n\t\t? Math.floor(opts.variant.maxArea / ANIMATED_AREA_DIVISOR)\n\t\t: opts.variant.maxArea\n\tconst dims =\n\t\topts.variant.fit === \"exact\"\n\t\t\t? { width: meta.width, height: h }\n\t\t\t: fitInsideArea(meta.width, h, area)\n\tconst destPath = opts.resolveDest(format)\n\tawait mkdir(dirname(destPath), { recursive: true })\n\tconst pipeline = isReopenableImageStream(opts.input)\n\t\t? await sharpFromReadable(await opts.input.openStream(), {\n\t\t\t\tpages: animated ? -1 : 1,\n\t\t\t\tanimated: animated || undefined,\n\t\t\t})\n\t\t: sharp(\n\t\t\t\topts.input,\n\t\t\t\tsharpImageInputOpts(opts.input, ext, animated ? -1 : 1, animated),\n\t\t\t)\n\tconst result = await encodeImageFile(\n\t\tpipeline,\n\t\tdestPath,\n\t\t{\n\t\t\t// `exact` carries no resize bounds: the encoder writes the\n\t\t\t// source pixels verbatim.\n\t\t\tmaxWidth: opts.variant.fit === \"exact\" ? undefined : dims.width,\n\t\t\tmaxHeight: opts.variant.fit === \"exact\" ? undefined : dims.height,\n\t\t\tquality:\n\t\t\t\tformat === \"webp\" ? opts.variant.webpQuality : opts.variant.avifQuality,\n\t\t\tanimated: animated || undefined,\n\t\t},\n\t\tformat,\n\t)\n\treturn {\n\t\t...result,\n\t\tformat,\n\t\tdisplayWidth: dims.width,\n\t\tdisplayHeight: dims.height,\n\t\tanimated,\n\t}\n}\n\n/**\n * Encode `sourcePath` to WebP at `destPath`, sized so that\n * `width × height < maxArea`. Animated sources are kept animated.\n * @throws `Error` from sharp.\n */\nexport async function renderImageWithArea(\n\tsourcePath: string,\n\tdestPath: string,\n\tmaxArea: number,\n\tquality: number,\n\tanimated: boolean,\n): Promise<RenderResult> {\n\tawait mkdir(dirname(destPath), { recursive: true })\n\treturn encodeSharpWithArea({\n\t\tpipeline: sharp(sourcePath, { animated }),\n\t\tdestPath,\n\t\tmaxArea,\n\t\tquality,\n\t\tformat: \"webp\",\n\t\tanimated,\n\t})\n}\n\n/**\n * Capture a frame from a file path or readable stream via ffmpeg and\n * encode, sized so that `width × height < maxArea`.\n * @throws `Error` with ffmpeg stderr when ffmpeg exits non-zero.\n */\nexport async function renderVideoFrame(opts: {\n\treadonly source: VideoFrameSource\n\treadonly destPath: string\n\treadonly ffmpeg: FfmpegPaths\n\treadonly maxArea: number\n\treadonly quality: number\n\treadonly format?: \"webp\" | \"avif\"\n\treadonly timeSeconds?: number\n\t/** Required when `source` is a stream — ffmpeg cannot infer container from `pipe:0`. */\n\treadonly ext?: string\n}): Promise<RenderResult> {\n\treturn renderFfmpegStill({\n\t\t...opts,\n\t\tinputFormat:\n\t\t\topts.ext === undefined ? undefined : extToFfmpegInputFormat(opts.ext),\n\t})\n}\n\n/**\n * Extract an audio file's embedded artwork (ID3 APIC, FLAC PICTURE, MP4\n * `covr`) and encode it as a thumbnail. ffmpeg exposes the artwork as a\n * still video stream, so this rides the same still-frame pipeline as\n * {@link renderVideoFrame} — only the container hint differs.\n * @throws `Error` with ffmpeg stderr when the file carries no artwork.\n */\nexport async function renderAudioCoverArt(opts: {\n\treadonly source: VideoFrameSource\n\treadonly destPath: string\n\treadonly ffmpeg: FfmpegPaths\n\treadonly maxArea: number\n\treadonly quality: number\n\treadonly format?: \"webp\" | \"avif\"\n\t/** Required when `source` is a stream — ffmpeg cannot infer container from `pipe:0`. */\n\treadonly ext?: string\n}): Promise<RenderResult> {\n\treturn renderFfmpegStill({\n\t\t...opts,\n\t\tinputFormat:\n\t\t\topts.ext === undefined ? undefined : extToAudioInputFormat(opts.ext),\n\t})\n}\n\nasync function renderFfmpegStill(opts: {\n\treadonly source: VideoFrameSource\n\treadonly destPath: string\n\treadonly ffmpeg: FfmpegPaths\n\treadonly maxArea: number\n\treadonly quality: number\n\treadonly format?: \"webp\" | \"avif\"\n\treadonly timeSeconds?: number\n\treadonly inputFormat?: string\n}): Promise<RenderResult> {\n\tawait mkdir(dirname(opts.destPath), { recursive: true })\n\tconst jpeg = await extractFrameAtTimeJpeg({\n\t\tsource: opts.source,\n\t\ttimeSeconds: opts.timeSeconds ?? 0,\n\t\tffmpeg: opts.ffmpeg,\n\t\tinputFormat: opts.inputFormat,\n\t})\n\treturn encodeSharpWithArea({\n\t\tpipeline: sharp(jpeg),\n\t\tdestPath: opts.destPath,\n\t\tmaxArea: opts.maxArea,\n\t\tquality: opts.quality,\n\t\tformat: opts.format ?? \"avif\",\n\t\tanimated: false,\n\t})\n}\n\ntype EncodeImageFileOptions = {\n\t/**\n\t * Resize bounds (fit `inside`, never enlarged). When both are\n\t * omitted the source pixels are encoded verbatim — the `exact`\n\t * variant's transcode-only path.\n\t */\n\treadonly maxWidth?: number\n\treadonly maxHeight?: number\n\treadonly quality: number\n\t/**\n\t * When `true`, skip `.rotate()` (sharp's auto-EXIF-orient call which\n\t * collapses animated input to a single frame) so the encoded WebP\n\t * keeps every page. Animated sources never carry useful EXIF\n\t * orientation, so dropping the rotate is safe.\n\t */\n\treadonly animated?: boolean\n}\n\n/**\n * Encode a sharp pipeline to `destPath`, sized so that\n * `width × height < maxArea`.\n */\nasync function encodeSharpWithArea(opts: {\n\tpipeline: Sharp\n\tdestPath: string\n\tmaxArea: number\n\tquality: number\n\tformat: \"webp\" | \"avif\"\n\tanimated: boolean\n}): Promise<RenderResult> {\n\tconst meta = await opts.pipeline.metadata()\n\tconst h = meta.pageHeight ?? meta.height\n\tconst dims =\n\t\tmeta.width !== undefined && h !== undefined\n\t\t\t? fitInsideArea(meta.width, h, opts.maxArea)\n\t\t\t: undefined\n\treturn encodeImageFile(\n\t\topts.pipeline,\n\t\topts.destPath,\n\t\t{\n\t\t\tmaxWidth: dims?.width,\n\t\t\tmaxHeight: dims?.height,\n\t\t\tquality: opts.quality,\n\t\t\tanimated: opts.animated || undefined,\n\t\t},\n\t\topts.format,\n\t)\n}\n\nasync function encodeImageFile(\n\tpipeline: Sharp,\n\tdestPath: string,\n\topts: EncodeImageFileOptions,\n\tformat: \"webp\" | \"avif\",\n): Promise<RenderResult> {\n\tconst tmp = `${destPath}.writing-${process.pid}-${Date.now()}`\n\ttry {\n\t\tconst oriented = opts.animated === true ? pipeline : pipeline.rotate()\n\t\tconst resized =\n\t\t\topts.maxWidth === undefined && opts.maxHeight === undefined\n\t\t\t\t? oriented\n\t\t\t\t: oriented.resize({\n\t\t\t\t\t\twidth: opts.maxWidth,\n\t\t\t\t\t\theight: opts.maxHeight,\n\t\t\t\t\t\tfit: \"inside\",\n\t\t\t\t\t\twithoutEnlargement: true,\n\t\t\t\t\t})\n\t\tconst encoded =\n\t\t\tformat === \"webp\"\n\t\t\t\t? resized.webp({ quality: opts.quality })\n\t\t\t\t: resized.avif({ quality: opts.quality, effort: AVIF_EFFORT })\n\t\tconst info = await encoded.toFile(tmp)\n\t\tawait rename(tmp, destPath)\n\t\treturn { path: destPath, bytes: info.size }\n\t} catch (err) {\n\t\tawait rm(tmp, { force: true }).catch(() => {})\n\t\tthrow err\n\t}\n}\n\n/**\n * Compute target pixel dimensions that keep the aspect ratio while\n * guaranteeing `width × height < maxArea`.\n */\nexport function fitInsideArea(\n\tsrcWidth: number,\n\tsrcHeight: number,\n\tmaxArea: number,\n): { readonly width: number; readonly height: number } {\n\tconst area = srcWidth * srcHeight\n\tif (area <= maxArea) return { width: srcWidth, height: srcHeight }\n\tconst scale = Math.sqrt(maxArea / area)\n\treturn {\n\t\twidth: Math.max(1, Math.floor(srcWidth * scale)),\n\t\theight: Math.max(1, Math.floor(srcHeight * scale)),\n\t}\n}\n\nfunction extractFrameAtTimeJpeg(opts: {\n\tsource: VideoFrameSource\n\ttimeSeconds: number\n\tffmpeg: FfmpegPaths\n\tinputFormat?: string\n}): Promise<Buffer> {\n\tif (typeof opts.source === \"string\") {\n\t\treturn extractFrameAtTimeJpegFromPath(opts.source, opts)\n\t}\n\treturn extractFrameAtTimeJpegFromStream(opts.source, opts)\n}\n\nfunction extractFrameAtTimeJpegFromPath(\n\tsourcePath: string,\n\topts: {\n\t\ttimeSeconds: number\n\t\tffmpeg: FfmpegPaths\n\t},\n): Promise<Buffer> {\n\tconst args = [\"-hide_banner\", \"-loglevel\", \"error\"]\n\tif (opts.timeSeconds > 0) {\n\t\targs.push(\"-ss\", String(opts.timeSeconds))\n\t}\n\targs.push(\"-i\", sourcePath)\n\targs.push(\n\t\t\"-vf\",\n\t\t\"scale=iw*sar:ih,scale='min(1024,iw)':-2\",\n\t\t\"-frames:v\",\n\t\t\"1\",\n\t\t\"-f\",\n\t\t\"image2\",\n\t\t\"-vcodec\",\n\t\t\"mjpeg\",\n\t\t\"pipe:1\",\n\t)\n\treturn runFfmpegJpegExtract(args, opts.ffmpeg, undefined)\n}\n\nfunction extractFrameAtTimeJpegFromStream(\n\tsource: Readable,\n\topts: {\n\t\ttimeSeconds: number\n\t\tffmpeg: FfmpegPaths\n\t\tinputFormat?: string\n\t},\n): Promise<Buffer> {\n\tif (opts.inputFormat === undefined) {\n\t\treturn Promise.reject(\n\t\t\tnew Error(\"stream video frame extraction requires an input format hint\"),\n\t\t)\n\t}\n\tconst args = [\"-hide_banner\", \"-loglevel\", \"error\"]\n\targs.push(\"-probesize\", \"100M\", \"-analyzeduration\", \"100M\")\n\targs.push(\"-f\", opts.inputFormat, \"-i\", \"pipe:0\")\n\tif (opts.timeSeconds > 0) {\n\t\targs.push(\"-ss\", String(opts.timeSeconds))\n\t}\n\targs.push(\n\t\t\"-vf\",\n\t\t\"scale=iw*sar:ih,scale='min(1024,iw)':-2\",\n\t\t\"-frames:v\",\n\t\t\"1\",\n\t\t\"-f\",\n\t\t\"image2\",\n\t\t\"-vcodec\",\n\t\t\"mjpeg\",\n\t\t\"pipe:1\",\n\t)\n\treturn runFfmpegJpegExtract(args, opts.ffmpeg, source)\n}\n\nfunction runFfmpegJpegExtract(\n\targs: string[],\n\tffmpeg: FfmpegPaths,\n\tsource: Readable | undefined,\n): Promise<Buffer> {\n\treturn new Promise((resolve, reject) => {\n\t\tconst fromStream = source !== undefined\n\t\tconst child = spawn(ffmpeg.ffmpeg, args, {\n\t\t\tstdio: fromStream ? [\"pipe\", \"pipe\", \"pipe\"] : [\"ignore\", \"pipe\", \"pipe\"],\n\t\t})\n\t\tconst stdout = child.stdout\n\t\tconst stderr = child.stderr\n\t\tif (stdout === null || stderr === null) {\n\t\t\treject(new Error(\"ffmpeg stdio was not configured\"))\n\t\t\treturn\n\t\t}\n\t\tconst stdoutChunks: Buffer[] = []\n\t\tconst stderrChunks: Buffer[] = []\n\t\tstdout.on(\"data\", (chunk: Buffer) => stdoutChunks.push(chunk))\n\t\tstderr.on(\"data\", (chunk: Buffer) => stderrChunks.push(chunk))\n\t\tif (source !== undefined) {\n\t\t\tsource.on(\"error\", reject)\n\t\t\tsource.pipe(child.stdin!)\n\t\t\tchild.stdin?.on(\"error\", () => {})\n\t\t}\n\t\tchild.on(\"error\", reject)\n\t\tchild.on(\"close\", (code) => {\n\t\t\tif (code !== 0) {\n\t\t\t\tconst msg = Buffer.concat(stderrChunks).toString(\"utf8\").trim()\n\t\t\t\treject(new Error(`ffmpeg exited ${code}${msg ? `: ${msg}` : \"\"}`))\n\t\t\t\treturn\n\t\t\t}\n\t\t\tconst out = Buffer.concat(stdoutChunks)\n\t\t\tif (out.length === 0) {\n\t\t\t\treject(new Error(\"ffmpeg produced no output\"))\n\t\t\t\treturn\n\t\t\t}\n\t\t\tresolve(out)\n\t\t})\n\t})\n}\n"]}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hoardodile/host",
3
- "version": "0.1.9",
3
+ "version": "0.1.11",
4
4
  "license": "MIT",
5
5
  "description": "hoardodile plugin runtime host: sandbox, hook strategy, ResourceAPI, containers and the test runner.",
6
6
  "keywords": [
@@ -67,12 +67,12 @@
67
67
  "file-type": "^22.0.2",
68
68
  "yauzl": "^3.4.0",
69
69
  "yazl": "^3.3.1",
70
- "@hoardodile/sdk-types": "0.1.9"
70
+ "@hoardodile/sdk-types": "0.1.11"
71
71
  },
72
72
  "optionalDependencies": {
73
- "@derhuerst/ffprobe-static": "^5.3.0",
74
73
  "@hoardodile/7z-bin": "^1.1.3",
75
- "ffmpeg-static": "^5.3.0"
74
+ "@hoardodile/ffmpeg-bin": "^1.0.0",
75
+ "@hoardodile/ffprobe-bin": "^1.0.0"
76
76
  },
77
77
  "peerDependencies": {
78
78
  "sharp": "^0.35.3",
@@ -0,0 +1,36 @@
1
+ import { describe, expect, test } from "vitest"
2
+ import { resolveFfprobePath } from "./ffprobe.ts"
3
+
4
+ describe("resolveFfprobePath", () => {
5
+ test("honours an explicit FFPROBE_PATH env override", () => {
6
+ const path = resolveFfprobePath({
7
+ env: { FFPROBE_PATH: "C:/bin/ffprobe.exe" },
8
+ loadStatic: () => "C:/static/ffprobe.exe",
9
+ })
10
+ expect(path).toBe("C:/bin/ffprobe.exe")
11
+ })
12
+
13
+ test("uses the static installer path verbatim", () => {
14
+ const path = resolveFfprobePath({
15
+ env: {},
16
+ loadStatic: () =>
17
+ "/node_modules/@hoardodile/ffprobe-bin/bin/win32-x64/ffprobe.exe",
18
+ })
19
+ expect(path).toBe(
20
+ "/node_modules/@hoardodile/ffprobe-bin/bin/win32-x64/ffprobe.exe",
21
+ )
22
+ })
23
+
24
+ test("degrades to bare ffprobe when the installer reports null", () => {
25
+ const path = resolveFfprobePath({ env: {}, loadStatic: () => null })
26
+ expect(path).toBe("ffprobe")
27
+ })
28
+
29
+ test("degrades to bare ffprobe when the installer is unavailable", () => {
30
+ const path = resolveFfprobePath({
31
+ env: {},
32
+ loadStatic: () => undefined,
33
+ })
34
+ expect(path).toBe("ffprobe")
35
+ })
36
+ })
@@ -4,7 +4,7 @@
4
4
  * differ in how they read the payload, so the process handling and the
5
5
  * payload shapes live here.
6
6
  *
7
- * The ffprobe binary path is resolved lazily. `@derhuerst/ffprobe-static`
7
+ * The ffprobe binary path is resolved lazily. `@hoardodile/ffprobe-bin`
8
8
  * is an optional dependency of this package — not shipped in the host
9
9
  * tarball. Dev resolves it from this package's `node_modules`; `pnpm
10
10
  * start` and the desktop sidecar resolve it from the server
@@ -17,7 +17,7 @@ import { execa } from "execa"
17
17
  type ResolveFfprobeDeps = {
18
18
  readonly env?: NodeJS.ProcessEnv
19
19
  /** Override for tests so they never hit the real module. */
20
- readonly loadStatic?: () => string | undefined
20
+ readonly loadStatic?: () => string | null | undefined
21
21
  }
22
22
 
23
23
  const requireCjs = createRequire(import.meta.url)
@@ -27,7 +27,7 @@ const requireCjs = createRequire(import.meta.url)
27
27
  *
28
28
  * Precedence:
29
29
  * 1. The `FFPROBE_PATH` env var.
30
- * 2. `@derhuerst/ffprobe-static` via lazy `createRequire` — the
30
+ * 2. `@hoardodile/ffprobe-bin` via lazy `createRequire` — the
31
31
  * installer package stays out of the host tarball.
32
32
  * 3. A bare command name, letting operators with ffprobe on PATH run
33
33
  * the CLI or server directly.
@@ -42,7 +42,7 @@ export function resolveFfprobePath(deps: ResolveFfprobeDeps = {}): string {
42
42
 
43
43
  function loadInstallerFfprobe(): string | undefined {
44
44
  try {
45
- const mod: unknown = requireCjs("@derhuerst/ffprobe-static")
45
+ const mod: unknown = requireCjs("@hoardodile/ffprobe-bin")
46
46
  if (typeof mod === "string" && mod.length > 0) return mod
47
47
  if (
48
48
  mod !== null &&
@@ -0,0 +1,62 @@
1
+ import { describe, expect, test } from "vitest"
2
+ import { resolveFfmpegPaths } from "./ffmpeg.ts"
3
+
4
+ describe("resolveFfmpegPaths", () => {
5
+ test("honours explicit FFMPEG_PATH / FFPROBE_PATH env overrides", () => {
6
+ const paths = resolveFfmpegPaths({
7
+ env: {
8
+ FFMPEG_PATH: "C:/bin/ffmpeg.exe",
9
+ FFPROBE_PATH: "C:/bin/ffprobe.exe",
10
+ },
11
+ loadStatic: () => "C:/static/ffmpeg.exe",
12
+ loadStaticFfprobe: () => "C:/static/ffprobe.exe",
13
+ })
14
+ expect(paths).toEqual({
15
+ ffmpeg: "C:/bin/ffmpeg.exe",
16
+ ffprobe: "C:/bin/ffprobe.exe",
17
+ })
18
+ })
19
+
20
+ test("uses the static installer path verbatim", () => {
21
+ const paths = resolveFfmpegPaths({
22
+ env: {},
23
+ loadStatic: () =>
24
+ "/node_modules/@hoardodile/ffmpeg-bin/bin/win32-x64/ffmpeg.exe",
25
+ loadStaticFfprobe: () =>
26
+ "/node_modules/@hoardodile/ffprobe-bin/bin/win32-x64/ffprobe.exe",
27
+ })
28
+ expect(paths.ffmpeg).toBe(
29
+ "/node_modules/@hoardodile/ffmpeg-bin/bin/win32-x64/ffmpeg.exe",
30
+ )
31
+ expect(paths.ffprobe).toBe(
32
+ "/node_modules/@hoardodile/ffprobe-bin/bin/win32-x64/ffprobe.exe",
33
+ )
34
+ })
35
+
36
+ test("degrades to bare ffmpeg/ffprobe when the installer reports null", () => {
37
+ const paths = resolveFfmpegPaths({
38
+ env: {},
39
+ loadStatic: () => null,
40
+ loadStaticFfprobe: () => null,
41
+ })
42
+ expect(paths).toEqual({ ffmpeg: "ffmpeg", ffprobe: "ffprobe" })
43
+ })
44
+
45
+ test("degrades to bare ffmpeg/ffprobe when the installer is unavailable", () => {
46
+ const paths = resolveFfmpegPaths({
47
+ env: {},
48
+ loadStatic: () => undefined,
49
+ loadStaticFfprobe: () => undefined,
50
+ })
51
+ expect(paths).toEqual({ ffmpeg: "ffmpeg", ffprobe: "ffprobe" })
52
+ })
53
+
54
+ test("mixes one env override with a static fallback for the other binary", () => {
55
+ const paths = resolveFfmpegPaths({
56
+ env: { FFMPEG_PATH: "C:/bin/ffmpeg.exe" },
57
+ loadStaticFfprobe: () => "/pkg/ffprobe",
58
+ })
59
+ expect(paths.ffmpeg).toBe("C:/bin/ffmpeg.exe")
60
+ expect(paths.ffprobe).toBe("/pkg/ffprobe")
61
+ })
62
+ })
@@ -3,7 +3,7 @@
3
3
  *
4
4
  * Precedence:
5
5
  * 1. Explicit env vars (`FFMPEG_PATH`, `FFPROBE_PATH`).
6
- * 2. `ffmpeg-static` and `@derhuerst/ffprobe-static` via lazy
6
+ * 2. `@hoardodile/ffmpeg-bin` and `@hoardodile/ffprobe-bin` via lazy
7
7
  * `createRequire`, so they stay out of the host tarball. Dev
8
8
  * resolves them from this package's `node_modules`; `pnpm start`
9
9
  * and the desktop sidecar resolve them from the server
@@ -23,9 +23,9 @@ export type FfmpegPaths = {
23
23
  type ResolveDeps = {
24
24
  readonly env?: NodeJS.ProcessEnv
25
25
  /** Override for tests so they never hit the real module. */
26
- readonly loadStatic?: () => string | undefined
26
+ readonly loadStatic?: () => string | null | undefined
27
27
  /** Override for tests so they never hit the real module. */
28
- readonly loadStaticFfprobe?: () => string | undefined
28
+ readonly loadStaticFfprobe?: () => string | null | undefined
29
29
  }
30
30
 
31
31
  export function resolveFfmpegPaths(deps: ResolveDeps = {}): FfmpegPaths {
@@ -52,11 +52,11 @@ export function resolveFfmpegPaths(deps: ResolveDeps = {}): FfmpegPaths {
52
52
  }
53
53
 
54
54
  function loadInstallerFfmpeg(): string | undefined {
55
- return loadInstallerPath("ffmpeg-static")
55
+ return loadInstallerPath("@hoardodile/ffmpeg-bin")
56
56
  }
57
57
 
58
58
  function loadInstallerFfprobe(): string | undefined {
59
- return loadInstallerPath("@derhuerst/ffprobe-static")
59
+ return loadInstallerPath("@hoardodile/ffprobe-bin")
60
60
  }
61
61
 
62
62
  function loadInstallerPath(moduleName: string): string | undefined {