@hoardodile/host 0.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (144) hide show
  1. package/LICENSE +18 -0
  2. package/README.md +73 -0
  3. package/dist/chunks/worker-entry.mjs +422 -0
  4. package/dist/container-fKxefO7p.d.ts +58 -0
  5. package/dist/contract/index.d.ts +49 -0
  6. package/dist/contract/index.js +187 -0
  7. package/dist/contract/index.js.map +1 -0
  8. package/dist/hoard/index.d.ts +754 -0
  9. package/dist/hoard/index.js +1939 -0
  10. package/dist/hoard/index.js.map +1 -0
  11. package/dist/image-Dx2Be3Bj.d.ts +168 -0
  12. package/dist/index.d.ts +788 -0
  13. package/dist/index.js +3311 -0
  14. package/dist/index.js.map +1 -0
  15. package/dist/media/index.d.ts +96 -0
  16. package/dist/media/index.js +152 -0
  17. package/dist/media/index.js.map +1 -0
  18. package/dist/probe/index.d.ts +113 -0
  19. package/dist/probe/index.js +454 -0
  20. package/dist/probe/index.js.map +1 -0
  21. package/dist/probe-cache-BkRI7HEK.d.ts +25 -0
  22. package/dist/render/index.d.ts +125 -0
  23. package/dist/render/index.js +430 -0
  24. package/dist/render/index.js.map +1 -0
  25. package/package.json +103 -0
  26. package/src/__testutils__/zip-fixtures.ts +139 -0
  27. package/src/activation.ts +116 -0
  28. package/src/api-types.ts +60 -0
  29. package/src/api.test.ts +564 -0
  30. package/src/api.ts +501 -0
  31. package/src/archive/7z.test.ts +222 -0
  32. package/src/archive/7z.ts +261 -0
  33. package/src/archive/archive-ops.test.ts +654 -0
  34. package/src/archive/extract-archive.test.ts +237 -0
  35. package/src/archive/extract-archive.ts +534 -0
  36. package/src/archive/extract.ts +693 -0
  37. package/src/archive/format.ts +49 -0
  38. package/src/archive/index.ts +116 -0
  39. package/src/archive/listing.ts +121 -0
  40. package/src/archive/materialize.ts +88 -0
  41. package/src/archive/name-decode.test.ts +34 -0
  42. package/src/archive/name-decode.ts +287 -0
  43. package/src/archive/nested-cd-cache.ts +55 -0
  44. package/src/archive/nested-entry.test.ts +194 -0
  45. package/src/archive/nested-entry.ts +254 -0
  46. package/src/archive/pack.ts +40 -0
  47. package/src/archive/zip-entries.test.ts +56 -0
  48. package/src/archive/zip-entries.ts +336 -0
  49. package/src/capability-guard.ts +73 -0
  50. package/src/container.ts +62 -0
  51. package/src/contract/container-fixture.ts +74 -0
  52. package/src/contract/index.ts +10 -0
  53. package/src/contract/suite.test.ts +101 -0
  54. package/src/contract/suite.ts +205 -0
  55. package/src/define-plugin.test.ts +53 -0
  56. package/src/define-plugin.ts +12 -0
  57. package/src/dev-runner.test.ts +98 -0
  58. package/src/dev-runner.ts +73 -0
  59. package/src/directory-api.test.ts +229 -0
  60. package/src/directory-api.ts +42 -0
  61. package/src/directory-container.ts +141 -0
  62. package/src/discovery.ts +295 -0
  63. package/src/errors.ts +74 -0
  64. package/src/fixtures.ts +8 -0
  65. package/src/hash.test.ts +301 -0
  66. package/src/hash.ts +167 -0
  67. package/src/hoard/dir-size.ts +58 -0
  68. package/src/hoard/index.ts +84 -0
  69. package/src/hoard/migrate-script.test.ts +539 -0
  70. package/src/hoard/order-manifest.test.ts +115 -0
  71. package/src/hoard/order-manifest.ts +131 -0
  72. package/src/hoard/paths.test.ts +226 -0
  73. package/src/hoard/paths.ts +569 -0
  74. package/src/hoard/plugin-vault.test.ts +160 -0
  75. package/src/hoard/plugin-vault.ts +233 -0
  76. package/src/hoard/sanitize.test.ts +149 -0
  77. package/src/hoard/sanitize.ts +230 -0
  78. package/src/hoard/staging-dir.ts +133 -0
  79. package/src/hoard/version.test.ts +285 -0
  80. package/src/hoard/version.ts +216 -0
  81. package/src/hoard/versioned-folder-ops.ts +152 -0
  82. package/src/hoard/write-versioned.test.ts +67 -0
  83. package/src/hoard/write-versioned.ts +39 -0
  84. package/src/hooks.test.ts +528 -0
  85. package/src/hooks.ts +428 -0
  86. package/src/index.ts +135 -0
  87. package/src/loader.test.ts +442 -0
  88. package/src/loader.ts +213 -0
  89. package/src/media/index.ts +16 -0
  90. package/src/media/render-cache.ts +27 -0
  91. package/src/media/seekable.ts +35 -0
  92. package/src/media/thumb-input.test.ts +109 -0
  93. package/src/media/thumb-input.ts +194 -0
  94. package/src/nested-view.test.ts +223 -0
  95. package/src/nested-view.ts +239 -0
  96. package/src/probe/audio.test.ts +135 -0
  97. package/src/probe/audio.ts +140 -0
  98. package/src/probe/av.ts +84 -0
  99. package/src/probe/ffprobe.ts +197 -0
  100. package/src/probe/image.test.ts +251 -0
  101. package/src/probe/image.ts +336 -0
  102. package/src/probe/index.ts +54 -0
  103. package/src/probe/sniff.test.ts +72 -0
  104. package/src/probe/sniff.ts +67 -0
  105. package/src/probe/video.test.ts +41 -0
  106. package/src/probe/video.ts +116 -0
  107. package/src/probe-cache.test.ts +89 -0
  108. package/src/probe-cache.ts +65 -0
  109. package/src/render/ffmpeg.ts +78 -0
  110. package/src/render/index.ts +34 -0
  111. package/src/render/pipeline.test.ts +187 -0
  112. package/src/render/pipeline.ts +479 -0
  113. package/src/sandbox/call-timers.ts +68 -0
  114. package/src/sandbox/fixtures/api-error-plugin.mjs +11 -0
  115. package/src/sandbox/fixtures/asset-plugin.mjs +13 -0
  116. package/src/sandbox/fixtures/chatty-plugin.mjs +14 -0
  117. package/src/sandbox/fixtures/container-plugin.mjs +8 -0
  118. package/src/sandbox/fixtures/context-plugin.mjs +19 -0
  119. package/src/sandbox/fixtures/crash-plugin.mjs +6 -0
  120. package/src/sandbox/fixtures/echo-plugin.mjs +17 -0
  121. package/src/sandbox/fixtures/exit-plugin.mjs +6 -0
  122. package/src/sandbox/fixtures/fetch-plugin.mjs +8 -0
  123. package/src/sandbox/fixtures/flood-api-plugin.mjs +8 -0
  124. package/src/sandbox/fixtures/flood-log-plugin.mjs +8 -0
  125. package/src/sandbox/fixtures/hostile-dynamic-plugin.mjs +9 -0
  126. package/src/sandbox/fixtures/hostile-plugin.mjs +13 -0
  127. package/src/sandbox/fixtures/huge-result-plugin.mjs +8 -0
  128. package/src/sandbox/fixtures/logging-plugin.mjs +9 -0
  129. package/src/sandbox/fixtures/range-plugin.mjs +9 -0
  130. package/src/sandbox/fixtures/slow-api-plugin.mjs +11 -0
  131. package/src/sandbox/fixtures/spin-plugin.mjs +9 -0
  132. package/src/sandbox/fixtures/stuck-plugin.mjs +12 -0
  133. package/src/sandbox/fixtures/thrower-plugin.mjs +7 -0
  134. package/src/sandbox/host.test.ts +619 -0
  135. package/src/sandbox/host.ts +933 -0
  136. package/src/sandbox/host.unit.test.ts +330 -0
  137. package/src/sandbox/protocol.test.ts +85 -0
  138. package/src/sandbox/protocol.ts +147 -0
  139. package/src/sandbox/sandboxed-plugin.ts +30 -0
  140. package/src/sandbox/worker-entry.mjs +422 -0
  141. package/src/seed.test.ts +102 -0
  142. package/src/seed.ts +160 -0
  143. package/src/settings-store.ts +26 -0
  144. package/src/types.ts +21 -0
@@ -0,0 +1 @@
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"]}
package/package.json ADDED
@@ -0,0 +1,103 @@
1
+ {
2
+ "name": "@hoardodile/host",
3
+ "version": "0.0.0",
4
+ "license": "MIT",
5
+ "description": "hoardodile plugin runtime host: sandbox, hook strategy, ResourceAPI, containers and the test runner.",
6
+ "keywords": [
7
+ "hoardodile",
8
+ "plugin",
9
+ "host",
10
+ "sandbox",
11
+ "self-hosted"
12
+ ],
13
+ "repository": {
14
+ "type": "git",
15
+ "url": "git+https://github.com/hoardodile/hoardodile.git",
16
+ "directory": "plugins/host"
17
+ },
18
+ "type": "module",
19
+ "exports": {
20
+ ".": {
21
+ "types": "./dist/index.d.ts",
22
+ "development": "./src/index.ts",
23
+ "default": "./dist/index.js"
24
+ },
25
+ "./contract": {
26
+ "types": "./dist/contract/index.d.ts",
27
+ "development": "./src/contract/index.ts",
28
+ "default": "./dist/contract/index.js"
29
+ },
30
+ "./probe": {
31
+ "types": "./dist/probe/index.d.ts",
32
+ "development": "./src/probe/index.ts",
33
+ "default": "./dist/probe/index.js"
34
+ },
35
+ "./render": {
36
+ "types": "./dist/render/index.d.ts",
37
+ "development": "./src/render/index.ts",
38
+ "default": "./dist/render/index.js"
39
+ },
40
+ "./hoard": {
41
+ "types": "./dist/hoard/index.d.ts",
42
+ "development": "./src/hoard/index.ts",
43
+ "default": "./dist/hoard/index.js"
44
+ },
45
+ "./media": {
46
+ "types": "./dist/media/index.d.ts",
47
+ "development": "./src/media/index.ts",
48
+ "default": "./dist/media/index.js"
49
+ },
50
+ "./worker-entry": {
51
+ "default": "./dist/chunks/worker-entry.mjs"
52
+ }
53
+ },
54
+ "files": [
55
+ "dist",
56
+ "src"
57
+ ],
58
+ "sideEffects": false,
59
+ "engines": {
60
+ "node": ">=24"
61
+ },
62
+ "publishConfig": {
63
+ "access": "public"
64
+ },
65
+ "dependencies": {
66
+ "execa": "^10.0.1",
67
+ "file-type": "^22.0.2",
68
+ "yauzl": "^3.4.0",
69
+ "yazl": "^3.3.1",
70
+ "@hoardodile/sdk-types": "0.0.0"
71
+ },
72
+ "optionalDependencies": {
73
+ "@derhuerst/ffprobe-static": "^5.3.0",
74
+ "@hoardodile/7z-bin": "^1.1.0",
75
+ "ffmpeg-static": "^5.3.0"
76
+ },
77
+ "peerDependencies": {
78
+ "sharp": "^0.35.3",
79
+ "vitest": "^4.1.11"
80
+ },
81
+ "peerDependenciesMeta": {
82
+ "sharp": {
83
+ "optional": true
84
+ },
85
+ "vitest": {
86
+ "optional": true
87
+ }
88
+ },
89
+ "devDependencies": {
90
+ "@types/node": "^26.2.0",
91
+ "@types/yauzl": "^3.4.0",
92
+ "@types/yazl": "^3.3.1",
93
+ "sharp": "^0.35.3",
94
+ "tsup": "^8.5.1",
95
+ "typescript": "5.9.3",
96
+ "vitest": "^4.1.11"
97
+ },
98
+ "scripts": {
99
+ "build": "tsup && node scripts/postbuild.mjs",
100
+ "lint": "tsc --noEmit && node ../../scripts/guard-worker-entry.mjs",
101
+ "test": "vitest run"
102
+ }
103
+ }
@@ -0,0 +1,139 @@
1
+ import { deflateRawSync } from "node:zlib"
2
+
3
+ /**
4
+ * Test-only zip/tar writers shared by the host's container tests. Real
5
+ * archives built in memory so the nested/extraction paths exercise
6
+ * actual STORED/DEFLATE bytes without external dependencies.
7
+ */
8
+
9
+ const CRC_TABLE = (() => {
10
+ const table = new Uint32Array(256)
11
+ for (let n = 0; n < 256; n++) {
12
+ let c = n
13
+ for (let k = 0; k < 8; k++) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1
14
+ table[n] = c >>> 0
15
+ }
16
+ return table
17
+ })()
18
+
19
+ export function crc32(bytes: Uint8Array): number {
20
+ let c = 0xffffffff
21
+ for (const b of bytes) c = CRC_TABLE[(c ^ b) & 0xff]! ^ (c >>> 8)
22
+ return (c ^ 0xffffffff) >>> 0
23
+ }
24
+
25
+ export type ZipEntryInput = {
26
+ readonly name: string
27
+ readonly data: Uint8Array
28
+ /** 0 = STORED, 8 = DEFLATE. Default STORED. */
29
+ readonly method?: number
30
+ /** Set the general-purpose encryption bit (bit 0). */
31
+ readonly encrypted?: boolean
32
+ /**
33
+ * Write the name as raw latin1 bytes without the UTF-8 flag (bit 11) —
34
+ * the legacy cp437 shape 7-Zip decodes. Name chars above U+00FF are
35
+ * truncated to their low byte, exactly like a pre-UTF-8 zipper.
36
+ */
37
+ readonly legacyName?: boolean
38
+ }
39
+
40
+ /** Minimal zip writer: STORED or DEFLATE entries, UTF-8 names. */
41
+ export function makeZip(entries: readonly ZipEntryInput[]): Buffer {
42
+ const parts: Buffer[] = []
43
+ const records: Buffer[] = []
44
+ let offset = 0
45
+ for (const entry of entries) {
46
+ const name =
47
+ entry.legacyName === true
48
+ ? Buffer.from(entry.name, "latin1")
49
+ : Buffer.from(entry.name, "utf8")
50
+ const method = entry.method ?? 0
51
+ const flags =
52
+ (entry.legacyName === true ? 0 : 0x0800) |
53
+ (entry.encrypted === true ? 0x1 : 0)
54
+ const raw =
55
+ method === 8
56
+ ? deflateRawSync(Buffer.from(entry.data))
57
+ : Buffer.from(entry.data)
58
+ const crc = crc32(entry.data)
59
+ const local = Buffer.alloc(30)
60
+ local.writeUInt32LE(0x04034b50, 0)
61
+ local.writeUInt16LE(20, 4)
62
+ local.writeUInt16LE(flags, 6) // UTF-8 name flag (+ encryption bit)
63
+ local.writeUInt16LE(method, 8)
64
+ local.writeUInt16LE(0, 10)
65
+ local.writeUInt16LE(0, 12)
66
+ local.writeUInt32LE(crc, 14)
67
+ local.writeUInt32LE(raw.length, 18)
68
+ local.writeUInt32LE(entry.data.length, 22)
69
+ local.writeUInt16LE(name.length, 26)
70
+ local.writeUInt16LE(0, 28)
71
+ parts.push(local, name, raw)
72
+
73
+ const cd = Buffer.alloc(46)
74
+ cd.writeUInt32LE(0x02014b50, 0)
75
+ cd.writeUInt16LE(20, 4)
76
+ cd.writeUInt16LE(20, 6)
77
+ cd.writeUInt16LE(flags, 8)
78
+ cd.writeUInt16LE(method, 10)
79
+ cd.writeUInt16LE(0, 12)
80
+ cd.writeUInt16LE(0, 14)
81
+ cd.writeUInt32LE(crc, 16)
82
+ cd.writeUInt32LE(raw.length, 20)
83
+ cd.writeUInt32LE(entry.data.length, 24)
84
+ cd.writeUInt16LE(name.length, 28)
85
+ cd.writeUInt16LE(0, 30)
86
+ cd.writeUInt16LE(0, 32)
87
+ cd.writeUInt16LE(0, 34)
88
+ cd.writeUInt16LE(0, 36)
89
+ cd.writeUInt32LE(0, 38)
90
+ cd.writeUInt32LE(offset, 42)
91
+ records.push(Buffer.concat([cd, name]))
92
+ offset += local.length + name.length + raw.length
93
+ }
94
+ const cdStart = parts.reduce((sum, p) => sum + p.length, 0)
95
+ const cdBytes = Buffer.concat(records)
96
+ const eocd = Buffer.alloc(22)
97
+ eocd.writeUInt32LE(0x06054b50, 0)
98
+ eocd.writeUInt16LE(0, 4)
99
+ eocd.writeUInt16LE(0, 6)
100
+ eocd.writeUInt16LE(entries.length, 8)
101
+ eocd.writeUInt16LE(entries.length, 10)
102
+ eocd.writeUInt32LE(cdBytes.length, 12)
103
+ eocd.writeUInt32LE(cdStart, 16)
104
+ eocd.writeUInt16LE(0, 20)
105
+ return Buffer.concat([...parts, cdBytes, eocd])
106
+ }
107
+
108
+ /** Minimal plain-tar writer (ustar headers with checksums, no compression). */
109
+ export function makeTar(
110
+ entries: readonly { name: string; data: Uint8Array }[],
111
+ ): Buffer {
112
+ const parts: Buffer[] = []
113
+ for (const entry of entries) {
114
+ const header = Buffer.alloc(512)
115
+ header.write(entry.name, 0, 100, "utf8")
116
+ header.write("0000644\0", 100, 8, "ascii")
117
+ header.write(
118
+ entry.data.length.toString(8).padStart(11, "0"),
119
+ 124,
120
+ 12,
121
+ "ascii",
122
+ )
123
+ header.writeUInt8(0x30, 156)
124
+ header.write("ustar", 257, 5, "ascii")
125
+ header.write("00", 262, 2, "ascii")
126
+ // Header checksum: unsigned sum of all bytes with the checksum
127
+ // field treated as spaces, formatted as 6 octal digits + NUL.
128
+ let sum = 0
129
+ for (let i = 0; i < 512; i++) {
130
+ sum += i >= 148 && i < 156 ? 0x20 : header[i]!
131
+ }
132
+ header.write(sum.toString(8).padStart(6, "0"), 148, 6, "ascii")
133
+ parts.push(header, Buffer.from(entry.data))
134
+ const pad = (512 - (entry.data.length % 512)) % 512
135
+ if (pad > 0) parts.push(Buffer.alloc(pad))
136
+ }
137
+ parts.push(Buffer.alloc(1024))
138
+ return Buffer.concat(parts)
139
+ }
@@ -0,0 +1,116 @@
1
+ import { existsSync } from "node:fs"
2
+ import { join } from "node:path"
3
+ import type {
4
+ FoundPlugin,
5
+ MissingPlugin,
6
+ PluginDefinition,
7
+ PluginRegistryEntry,
8
+ } from "./api-types.ts"
9
+ import { createFailingPlugin } from "./define-plugin.ts"
10
+ import type { PluginSandbox } from "./sandbox/host.ts"
11
+
12
+ /**
13
+ * Plugin activation: turns discovered plugin candidates (found on disk
14
+ * or recorded as missing) into ready {@link PluginRegistryEntry}s by
15
+ * loading their `main.js` into the sandbox. Loads run in parallel —
16
+ * sequential worker spawns would multiply the boot cost on slow or
17
+ * antivirus-scanned disks.
18
+ */
19
+ export type PluginActivation = {
20
+ /** Load every found plugin's `main.js` into the sandbox and return the full registry entries. */
21
+ readonly activateAll: (
22
+ found: readonly FoundPlugin[],
23
+ ) => Promise<PluginRegistryEntry[]>
24
+ /**
25
+ * Build registry entries for plugins whose directory is missing, each
26
+ * carrying a failing definition so callers can still render them as
27
+ * broken in the UI.
28
+ */
29
+ readonly createFailingEntries: (
30
+ missing: readonly MissingPlugin[],
31
+ ) => PluginRegistryEntry[]
32
+ }
33
+
34
+ export type PluginActivationDeps = {
35
+ readonly sandbox: PluginSandbox
36
+ }
37
+
38
+ /**
39
+ * Create the activation pipeline. The sandbox decides worker lifetimes:
40
+ * enabled plugins keep their worker alive, disabled ones only probe the
41
+ * hook list and respawn lazily if a bound resource still invokes a hook.
42
+ */
43
+ export function createPluginActivation(
44
+ deps: PluginActivationDeps,
45
+ ): PluginActivation {
46
+ async function activateAll(
47
+ found: readonly FoundPlugin[],
48
+ ): Promise<PluginRegistryEntry[]> {
49
+ // Load plugin bundles in parallel — sequential worker spawns multiply
50
+ // the boot cost on slow or antivirus-scanned disks. Array order is
51
+ // kept by `map`, and `loadAll` re-sorts by priority afterwards anyway.
52
+ return Promise.all(
53
+ found.map(async (candidate) => {
54
+ const plugin = await loadDiskPlugin(deps.sandbox, candidate)
55
+ return {
56
+ id: candidate.id,
57
+ manifest: candidate.manifest,
58
+ enabled: candidate.enabled,
59
+ priority: candidate.priority,
60
+ pinned: candidate.pinned,
61
+ color: candidate.color,
62
+ missing: false,
63
+ builtin: candidate.source === "builtin",
64
+ dev: candidate.source === "dev",
65
+ plugin,
66
+ diskPath: candidate.diskPath,
67
+ }
68
+ }),
69
+ )
70
+ }
71
+
72
+ function createFailingEntries(
73
+ missing: readonly MissingPlugin[],
74
+ ): PluginRegistryEntry[] {
75
+ return missing.map((m) => ({
76
+ id: m.id,
77
+ manifest: m.manifest,
78
+ enabled: m.enabled,
79
+ priority: m.priority,
80
+ pinned: m.pinned,
81
+ color: m.color,
82
+ missing: true,
83
+ builtin: false,
84
+ dev: false,
85
+ plugin: createFailingPlugin(["plugin directory not found"]),
86
+ }))
87
+ }
88
+
89
+ return { activateAll, createFailingEntries }
90
+ }
91
+
92
+ async function loadDiskPlugin(
93
+ sandbox: PluginSandbox,
94
+ candidate: FoundPlugin,
95
+ ): Promise<PluginDefinition> {
96
+ const mainJsPath = join(candidate.diskPath, "main.js")
97
+ if (!existsSync(mainJsPath)) {
98
+ console.warn(`[plugin-activation] ${candidate.id}: no main.js, not loaded`)
99
+ return createFailingPlugin(["main.js not found"])
100
+ }
101
+
102
+ // Enabled plugins keep their worker alive; disabled ones only probe the
103
+ // hook list — their worker respawns lazily if a bound resource still
104
+ // invokes a hook.
105
+ const plugin = await sandbox.loadPlugin({
106
+ id: candidate.id,
107
+ mainPath: mainJsPath,
108
+ eager: candidate.enabled,
109
+ // The sandbox enforces manifest-gated API surface (container).
110
+ permissions: candidate.manifest.permissions,
111
+ })
112
+ if (plugin === undefined) {
113
+ return createFailingPlugin(["main.js load error"])
114
+ }
115
+ return plugin
116
+ }
@@ -0,0 +1,60 @@
1
+ import type { PluginManifest, PluginManifestId } from "@hoardodile/sdk-types"
2
+ import type { PluginDefinition } from "./types.ts"
3
+
4
+ export type { PluginDefinition }
5
+
6
+ /** A plugin discovered on disk — not yet imported/activated. */
7
+ export type FoundPlugin = {
8
+ readonly id: PluginManifestId
9
+ readonly manifest: PluginManifest
10
+ readonly diskPath: string
11
+ readonly source: "builtin" | "dev" | "disk"
12
+ readonly enabled: boolean
13
+ readonly priority: number
14
+ readonly pinned: boolean
15
+ readonly color: string
16
+ }
17
+
18
+ /** A plugin known to the DB but missing from disk. */
19
+ export type MissingPlugin = {
20
+ readonly id: PluginManifestId
21
+ readonly manifest: PluginManifest
22
+ readonly enabled: boolean
23
+ readonly priority: number
24
+ readonly pinned: boolean
25
+ readonly color: string
26
+ }
27
+
28
+ /** A loaded plugin entry in the runtime registry. */
29
+ export type PluginRegistryEntry = {
30
+ readonly id: PluginManifestId
31
+ readonly manifest: PluginManifest
32
+ readonly enabled: boolean
33
+ readonly priority: number
34
+ readonly pinned: boolean
35
+ readonly color: string
36
+ readonly missing: boolean
37
+ readonly builtin: boolean
38
+ readonly dev: boolean
39
+ readonly plugin: PluginDefinition
40
+ /** Absolute path to the plugin directory on disk. Undefined for builtin / in-memory plugins. */
41
+ readonly diskPath?: string
42
+ }
43
+
44
+ /** The plugin registry — built at startup and kept in memory. */
45
+ export type PluginRegistry = {
46
+ getAll(): readonly PluginRegistryEntry[]
47
+ getEnabled(): readonly PluginRegistryEntry[]
48
+ getById(id: PluginManifestId): PluginRegistryEntry | undefined
49
+ getBuiltin(): PluginRegistryEntry | undefined
50
+ getForResource(resPluginId: PluginManifestId): PluginRegistryEntry | undefined
51
+ updateEntry(
52
+ id: PluginManifestId,
53
+ patch: Partial<
54
+ Pick<
55
+ PluginRegistryEntry,
56
+ "enabled" | "priority" | "pinned" | "color" | "missing"
57
+ >
58
+ >,
59
+ ): void
60
+ }