{"id":"339f5c1d-775d-40f4-b3f2-b3499f1c5176","slug":"upupload","kind":"skill","name":"upupload","summary":"A customizable file processing and uploading utility","owner":"vivsh1999","repoUrl":"https://github.com/vivsh1999/upupload","license":"MIT","licenseTier":"permissive","trust":"community","stars":2,"match":null,"fewStars":true,"format":"skill","createdAt":"2026-05-06T17:28:51.000Z","addedAt":"2026-10-22T01:04:10.189Z","logoUrl":"https://avatars.githubusercontent.com/u/36125286?u=b61a72d526b6017532059bc2669cd50ecb5699bd&v=4","linkOnly":false,"collection":null,"skillPath":null,"categories":["files-documents"],"tags":[],"runtime":"binary","local":true,"cloudCapable":false,"cloudCapability":"unknown","cloudReadyOnPlatform":false,"needsApiKey":false,"reviewed":false,"syncedAt":"2026-10-26T00:26:16.478Z","sample":false,"url":"https://conduits.dev/tools/upupload","language":"TypeScript","transports":["stdio"],"install":{},"requirements":{"env":[],"upstreamMayCharge":false},"tools":null,"readme":"# @vivsh1999/upupload\n\nClient-first, multi-stage file uploader/processor with a **plugin architecture** for custom processing.\n\n- **Background Web Worker Threading** — Offload intensive image scaling/compression to a pool of Web Workers via `useWorker: true`\n- **One-Decode Multi-Variant JPEGs** — `jpegCompressor.with({ variants: [...] })` encodes every size from one decode\n- **Progressive Memory Garbage Collection** — Auto-purges file buffers and revokes object URLs on successful uploads to keep RAM clean\n- **Pre-built Upload Adapters** — Zero-dependency, tree-shakable standard HTTP & S3/R2 presigned URL upload adapters\n- Pipeline engine handles validation, original passthrough, video posters, and safe fallback\n- **Plugin system** — every file-type-specific processor is a separate, tree-shakeable plugin\n- Ships built-in plugins: `rawToJpeg` (RAW/HEIC/TIFF), `jpegCompressor` (compress/thumbnail), `videoPoster`\n- **Zero-cost imports** — plugins are tree-shaken at the bundler level; pay only for what you use\n- **No auto-installed heavy deps** — plugin dependencies are never installed unless you add them\n- TypeScript-native, fully typed\n\n---\n\n## Who Is This For?\n\n| You want to…                                                                  | Start here                                                                       |\n| ----------------------------------------------------------------------------- | -------------------------------------------------------------------------------- |\n| **Use built-in plugins (or none at all)** to process images/video in your app | [Quick Start](#quick-start) ↓                                                    |\n| **Write your own custom plugin** for a specific file type or processing step  | [Custom Plugins](#custom-plugins) ↓ & [docs/plugins.md](docs/plugins.md)         |\n| **Publish a plugin** for the community (open-source extension)                | [Publishing Plugins](#publishing-plugins) ↓ & [docs/plugins.md](docs/plugins.md) |\n| **Contribute to the repo itself** — fix bugs, add features, improve docs      | [CONTRIBUTING.md](CONTRIBUTING.md)                                               |\n\n---\n\n## Agent Skills\n\nInstall the UpUpload agent skill for AI-powered guidance on plugin configuration, React hook usage, custom plugin development, and more:\n\n```sh\nnpx skills add vivsh1999/upupload\n```\n\nWorks with OpenCode, Claude Code, Cursor, Codex, and 50+ other coding agents.\n\n---\n\n## Installation\n\n```sh\nnpm add @vivsh1999/upupload\n```\n\nThe package itself has zero image-processing dependencies on first install.\n\n### Plugin dependencies (install only what you need)\n\n```sh\n# For JPEG/PNG/WebP compression\nnpm add browser-image-compression\n\n# For RAW camera files (CR3, DNG, NEF, ARW…)\nnpm add libraw-wasm\n\n# Optional: for HEIC/HEIF / TIFF decode via the raw-to-jpeg plugin\nnpm add heic-decode heic2any utif\n```\n\n---\n\n## Entry Points\n\n| Path                                          | Environment | Contents                                                 | Bundle cost |\n| --------------------------------------------- | ----------- | -------------------------------------------------------- | ----------- |\n| `@vivsh1999/upupload`                         | Browser     | Re-exports core (pipeline engine, types, result helpers) | —           |\n| `@vivsh1999/upupload/browser`                 | Browser     | Pipeline, allowlist, audio/canvas utils, plugins         | 8 kB        |\n| `@vivsh1999/upupload/core`                    | Universal   | Generic pipeline engine, types, result helpers           | 1 kB        |\n| `@vivsh1999/upupload/react`                   | Browser     | `useFileUpload` React hook                               | 60 kB       |\n| `@vivsh1999/upupload/adapters`                | Browser     | Pre-built fetch & S3/R2 upload adapters                  | 5 kB        |\n| `@vivsh1999/upupload/server`                  | Node        | Server entry (minimal)                                   | < 1 kB      |\n| `@vivsh1999/upupload/plugins`                 | Browser     | Barrel re-export of all plugins                          | N/A         |\n| `@vivsh1999/upupload/plugins/jpeg-compressor` | Browser     | JPEG/PNG/WebP compressor plugin                          | +4 kB       |\n| `@vivsh1999/upupload/plugins/raw-to-jpeg`     | Browser     | RAW/HEIC/TIFF decoder plugin                             | +12 kB      |\n| `@vivsh1999/upupload/plugins/video-poster`    | Browser     | Video poster frame plugin                                | +6 kB       |\n| `@vivsh1999/upupload/plugins/testing`         | Browser     | Plugin test utilities                                    | +1 kB       |\n| `@vivsh1999/upupload/preset`                  | Browser     | Zero-config `upload()` with auto-detected plugins        | +13 kB      |\n\nOnly the specific plugin path you import is added to your bundle.\n\n---\n\n## Quick Start\n\n### React (with built-in plugins)\n\n```tsx\nimport { useFileUpload } from \"@vivsh1999/upupload/react\";\nimport { jpegCompressor } from \"@vivsh1999/upupload/plugins\";\n\nfunction Uploader() {\n  const {\n    getDropTargetProps,\n    getFileInputProps,\n    queue,\n    startUpload,\n    cancelUpload,\n    isDragOver,\n    isBusy,\n  } = useFileUpload({\n    plugins: [jpegCompressor.with({ quality: 80, maxSizeMB: 1 })],\n    uploadAdapter: async (artifact, { onProgress, fileId, totalArtifacts, artifactIndex }) => {\n      for (let pct = 0; pct <= 100; pct += 10) {\n        await new Promise((r) => setTimeout(r, 10));\n        onProgress(pct);\n      }\n      await fetch(\"/api/upload\", { method: \"POST\", body: artifact.blob });\n    },\n  });\n  return (\n    <div {...getDropTargetProps()} style={{ border: isDragOver ? \"2px dashed blue\" : \"\" }}>\n      <input {...getFileInputProps()} />\n      {queue.map((item) => (\n        <div key={item.id}>\n          {!item.needsReselect && <img src={item.previewUrl} alt=\"\" width={40} />}\n          {item.name} — {item.status} ({item.progress}%)\n          {item.status === \"error\" && !item.needsReselect && (\n            <button onClick={() => cancelUpload(item.id)}>Cancel</button>\n          )}\n        </div>\n      ))}\n      <button onClick={() => startUpload()} disabled={isBusy}>\n        Upload\n      </button>\n    </div>\n  );\n}\n```\n\n### React (no plugins — validation + original passthrough only)\n\n```tsx\nimport { useFileUpload } from \"@vivsh1999/upupload/react\";\n\nfunction Uploader() {\n  const { getDropTargetProps, getFileInputProps, queue, startUpload } =\n    useFileUpload();\n  // No plugins passed — files pass through validation only.\n  // Queue items will have 1 artifact: variant \"original\".\n  return (/* … */);\n}\n```\n\n### Vanilla JS (with built-in plugins)\n\n```js\nimport { runDefaultBrowserPipeline } from \"@vivsh1999/upupload/browser\";\nimport { jpegCompressor } from \"@vivsh1999/upupload/plugins\";\n\nconst result = await runDefaultBrowserPipeline(source, opts, {\n  plugins: [jpegCompressor.with({ quality: 80, maxSizeMB: 1 })],\n});\n```\n\n### Vanilla JS (no plugins)\n\n```js\nimport { runDefaultBrowserPipeline } from \"@vivsh1999/upupload/browser\";\n\nconst result = await runDefaultBrowserPipeline({ file, name: file.name, type: file.type }, {});\n// result.artifacts has 1 item: variant \"original\"\n```\n\n### Preset (zero-config)\n\n```ts\nimport { upload } from \"@vivsh1999/upupload/preset\";\n\nconst result = await upload(file, { quality: 80 });\n```\n\n### React Hook Options\n\nThe hook accepts a `UseFileUploadOptions<TMeta, TPreload>` object. Key options:\n\n| Option                             | Description                                                               |\n| ---------------------------------- | ------------------------------------------------------------------------- |\n| `plugins`                          | Processing plugins to apply                                               |\n| `pipeline`                         | `PipelineDef[]` for per-type routing                                      |\n| `pipelineConfig`                   | Initial `BrowserPipelineOptions` — every field forwarded                  |\n| `uploadAdapter`                    | Function that receives each artifact and its helpers                      |\n| `tuning.maxConcurrency`            | Pipeline parallelism (default: CPU count, capped at 4)                    |\n| `tuning.maxUploadConcurrency`      | Upload adapter parallelism (defaults to `maxConcurrency`)                 |\n| `maxQueuedUploads`                 | Backpressure limit for `\"uploading\"` state                                |\n| `maxFileSize`                      | Reject files over N bytes                                                 |\n| `maxTotalBatchSize`                | Reject if total batch exceeds N bytes                                     |\n| `maxNumberOfFiles`                 | Cap on total queue items                                                  |\n| `persistence`                      | `\"memory\"` or `\"indexeddb\"` (survives page reload)                        |\n| `storageKeyPrefix`                 | Isolate IndexedDB database name                                           |\n| `retryMode`                        | `\"pipeline\"` (default) or `\"adapter-only\"` (skip re-compression on retry) |\n| `autoPreventTabClose`              | Prevent tab close during processing                                       |\n| `autoPauseOnOffline`               | Auto-pause on network disconnect                                          |\n| `autoWakeLock`                     | Prevent screen sleep during upload                                        |\n| `getMeta`                          | Attach custom metadata (`TMeta`) to each queue item                       |\n| `getPipelineContextMeta`           | Inject values into every file's pipeline shared context                   |\n| `onBeforeStart`                    | Batch pre-processing hook, returns `TPreload` for adapter                 |\n| `onFileProcessed`                  | Fires after pipeline, before upload                                       |\n| `onFileComplete`                   | Fires after pipeline + upload complete                                    |\n| `onBatchComplete`                  | Cumulated stats when batch finishes                                       |\n| `onBatchProgress`                  | Live progress during batch processing/uploads                             |\n| `onInfo` / `onWarning` / `onError` | Structured logging and error callbacks                                    |\n\nQueue items are a **discriminated union** — when restored from IndexedDB after page reload, `needsReselect: true` and `file` is unavailable. Check `item.needsReselect` before accessing `item.file`.\n\nFull reference: [docs/react.md](docs/react.md)\n\n### uploadAdapter\n\nThe `uploadAdapter` replaces manual `onFileComplete` iteration and provides helper fields:\n\n```tsx\nuseFileUpload<{ sessionId: string }, { token: string }>({\n  plugins: [jpegCompressor.with({ quality: 80 })],\n  onBeforeStart: async (files) => {\n    const res = await fetch(\"/api/bulk-init\", { method: \"POST\" });\n    return { token: await res.text() };\n  },\n  uploadAdapter: async (\n    artifact,\n    { onProgress, signal, fileId, totalArtifacts, artifactIndex, batch },\n  ) => {\n    // artifact: { variant, blob, filename, filetype }\n    // onProgress(0-100) — updates the queue item's progress\n    // signal — honour cancellation\n    // fileId, totalArtifacts, artifactIndex — per-file context\n    // batch.files, batch.batchId, batch.preload.token — batch context\n    for (let pct = 0; pct <= 100; pct += 10) {\n      await new Promise((r) => setTimeout(r, 10));\n      if (signal?.aborted) return;\n      onProgress(pct);\n    }\n    await fetch(\"/api/upload\", { method: \"POST\", body: artifact.blob });\n  },\n});\n```\n\nFor custom upload without the hook, use the core result helpers:\n\n```ts\nimport { upload } from \"@vivsh1999/upupload/preset\";\nconst result = await upload(file, { quality: 80 });\nfor (const artifact of result.artifacts) {\n  await fetch(\"/api/upload\", {\n    method: \"POST\",\n    body: artifact.file,\n    headers: { \"Content-Type\": artifact.filetype },\n  });\n}\n```\n\n---\n\n## File Processing Flow (React Hook)\n\nWhen you call `startUpload()`, files go through three throttle-controlled stages:\n\n```\nInput → queue (idle)\n         │\n         ▼\n┌──────────────────────────────────────────┐\n│ 1. Pipeline Processing (maxConcurrency)  │  ← compression, transcoding\n│    • Each file acquires a semaphore slot │     (0 – pipelineEndProgress%)\n│    • Progress derived from completed     │\n│    • stages / total stages × 90          │\n│    • Multiple files processed in parallel│\n│    • Plugins run sequentially per file   │\n└──────────────────────────────────────────┘\n         │\n         ▼\n┌──────────────────────────────────────────┐\n│ 2. Upload Adapter (per file, sequential) │  ← your adapter sends artifacts\n│    • Called once per artifact            │     (pipelineEndProgress – 99%)\n│    • All artifacts of a file are sent    │\n│      sequentially (one at a time)        │\n│    • Adapter receives batch context      │\n│      via `helpers.batch`                 │\n└──────────────────────────────────────────┘\n         │\n         ▼\n    File marked \"complete\"  →  onFileComplete fires\n```\n\n### Throttles (three independent controls)\n\n| Setting                       | Controls                                                                                    | Default                                       |\n| ----------------------------- | ------------------------------------------------------------------------------------------- | --------------------------------------------- |\n| `tuning.maxConcurrency`       | How many files run the **pipeline** simultaneously                                          | `navigator.hardwareConcurrency` (capped at 4) |\n| `tuning.maxUploadConcurrency` | How many files upload **artifacts** simultaneously                                          | Same as `maxConcurrency`                      |\n| `maxQueuedUploads`            | Backpressure: how many files can be in **\"uploading\"** state at once before new files pause | Unlimited                                     |\n\n**Important:** `maxConcurrency` and `maxUploadConcurrency` are independent semaphores.\n\n- Four files could be processing pipelines while two others are uploading artifacts.\n- `maxQueuedUploads` is a global ceiling on the number of files in `\"uploading\"` state.\n  When hit, files that have finished processing will **not** start uploading until a slot frees up.\n\n### retryUpload lifecycle\n\n`retryUpload(fileId)` re-runs **only** the upload adapter — the pipeline\n(compression, transcoding, etc.) is **not** re-executed. The existing\nartifacts from the original processing are re-used. This means:\n\n- The adapter must be **idempotent**: it may receive the same artifact blob\n  across multiple `retryUpload` calls.\n- If the pipeline failed (no artifacts), `retryUpload` returns early (no-op).\n  Call `retry(fileId)` instead to reset the file to `\"idle\"` and re-process\n  it through the full pipeline.\n\n### retryMode\n\nSet `retryMode: \"adapter-only\"` on the hook so `retry(fileId)` skips\nre-compression and re-runs only the upload adapter when artifacts exist.\nFalls back to full re-processing if no artifacts are available.\n\n---\n\n## Custom Plugins\n\nWrite your own plugin to handle file types or processing that the built-in plugins don't cover.\n\n### Minimal Example\n\n```ts\nimport { Plugin } from \"@vivsh1999/upupload/plugins\";\nimport { artifact } from \"@vivsh1999/upupload/core\";\n\nconst watermark = new Plugin<{ opacity: number }>({\n  id: \"watermark\",\n  name: \"Watermark Plugin\",\n  options: { opacity: 0.5 },\n  supports: (file) => file.type?.startsWith(\"image/\") ?? false,\n  run: async (input, opts, classif, ctx) => {\n    // opts.opacity is typed as number\n    // classif.stemName, classif.ext — file metadata\n    // ctx.shared — inter-plugin communication\n    // ctx.log(level, message, extra?) — structured logging\n    // ctx.signal?: AbortSignal — cancellation support\n    // ctx.reportProgress(percent) — surface progress during long ops\n    return artifact(\"watermarked\", input.file, classif.stemName + \".jpg\", \"image/jpeg\");\n  },\n});\n```\n\nRegister it like any built-in plugin:\n\n```ts\nuseFileUpload({ plugins: [watermark.with({ opacity: 0.3 })] });\n```\n\n### Build a Thumbnail Plugin\n\nA common use case is generating a smaller thumbnail variant alongside a full-size output. Here's a complete plugin that creates a 150×150 JPEG thumbnail using the Canvas API:\n\n```ts\nimport { Plugin } from \"@vivsh1999/upupload/plugins\";\nimport { artifact, emptyResult } from \"@vivsh1999/upupload/core\";\n\ninterface ThumbnailOpts {\n  /** Max width/height in pixels. Default: 150 */\n  size?: number;\n}\n\nconst thumbnailPlugin = new Plugin<ThumbnailOpts>({\n  id: \"thumbnail\",\n  name: \"Thumbnail Generator\",\n  options: { size: 150 },\n  supports: (file) => file.type?.startsWith(\"image/\") ?? false,\n  run: async (input, opts, classif, ctx) => {\n    if (typeof OffscreenCanvas === \"undefined\") return emptyResult();\n\n    const img = await createImageBitmap(input.file);\n    const scale = Math.min(opts.size / img.width, opts.size / img.height, 1);\n    const w = Math.round(img.width * scale);\n    const h = Math.round(img.height * scale);\n\n    ctx.reportProgress?.(50); // surface progress\n\n    const canvas = new OffscreenCanvas(w, h);\n    const ctx2d = canvas.getContext(\"2d\")!;\n    ctx2d.drawImage(img, 0, 0, w, h);\n    img.close();\n\n    const blob = await canvas.convertToBlob({ type: \"image/jpeg\", quality: 0.85 });\n    return artifact(\"thumb\", blob, `${classif.stemName}-thumb.jpg`, \"image/jpeg\");\n  },\n});\n\n// Usage:\nuseFileUpload({\n  plugins: [\n    jpegCompressor.with({ variant: \"full\", quality: 80 }),\n    thumbnailPlugin.with({ size: 150 }),\n  ],\n  // uploadAdapter receives both \"full\" and \"thumb\" artifacts per file\n  uploadAdapter: async (artifact, helpers) => {\n    if (artifact.variant === \"thumb\") {\n      // upload to thumbnail bucket\n    } else {\n      // upload full-size\n    }\n  },\n});\n```\n\n> **Tip for multi-artifact setups:** Each plugin variant produces a separate artifact. The `uploadAdapter` receives one call per artifact with `artifactIndex` and `totalArtifacts`, letting you coordinate uploads.\n\nFull guide: [docs/plugins.md](docs/plugins.md) — covers `createStages` for multi-stage plugins, shared context patterns, `after`/`before` ordering, error handling, and testing.\n\nReal example: [`examples/vanilla-html/custom-pipeline.js`](examples/vanilla-html/custom-pipeline.js) — a metadata-annotator plugin that reads image dimensions and writes JSON.\n\n---\n\n## Publishing Plugins\n\nIf you've built a plugin others can use, publish it as a standalone npm package. See [docs/plugins.md#publishing-a-plugin](docs/plugins.md#publishing-a-plugin) for the full checklist: naming conventions, `supports()` contract, shared keys, tree-shaking setup, JSR compliance, and testing requirements.\n\n---\n\n## Documentation\n\n| Topic                                               | File                                                                                                     |\n| --------------------------------------------------- | -------------------------------------------------------------------------------------------------------- |\n| Pipeline engine (stages, features, utilities)       | [docs/pipeline.md](docs/pipeline.md)                                                                     |\n| Plugin system (using, writing, publishing, testing) | [docs/plugins.md](docs/plugins.md)                                                                       |\n| React hook (useFileUpload, options, return value)   | [docs/react.md](docs/react.md)                                                                           |\n| Configuration reference (all types)                 | [docs/configuration.md](docs/configuration.md)                                                           |\n| Case study: e-commerce product photography          | [docs/case-studies/ecommerce-product-photography.md](docs/case-studies/ecommerce-product-photography.md) |\n| Case study: wedding photography client proofing     | [docs/case-studies/wedding-photography-uploader.md](docs/case-studies/wedding-photography-uploader.md)   |\n| Case study: podcast audio publishing                | [docs/case-studies/podcast-audio-publishing.md](docs/case-studies/podcast-audio-publishing.md)           |\n\n## Decoder Dependencies\n\nThe `rawToJpeg` plugin optionally imports decoders at runtime:\n\n| Package       | Format                           | Strategy                                |\n| ------------- | -------------------------------- | --------------------------------------- |\n| `libraw-wasm` | Camera RAW (CR3, DNG, NEF, ARW…) | Web Worker + WASM                       |\n| `heic-decode` | HEIC/HEIF                        | Raw pixels, smaller bundle              |\n| `heic2any`    | HEIC/HEIF                        | Fallback when `heic-decode` unavailable |\n| `utif`        | TIFF                             | Decodes to RGBA → JPEG                  |\n\n```sh\nnpm add libraw-wasm heic-decode utif\n```\n\n## Examples\n\n- [`examples/vanilla-html`](./examples/vanilla-html) — basic pipeline + custom pipeline with a metadata-annotator plugin. Demonstrates writing a `Plugin` class from scratch, composing multiple plugins, and inspecting the result.\n- [`examples/tanstack-start`](./examples/tanstack-start) — TanStack Start app with TUS uploads and the React hook. Shows end-to-end upload with the `useFileUpload` hook.\n\n<!-- benchmarks:start -->\n\n## Benchmarks\n\nAutogenerated from `vitest bench` (via GitHub Actions — pushed to main).\n\n### Internal Components\n\n| Benchmark                                                            | Ops/sec       | Prev Minor (v0.7.2) | Change    |\n| -------------------------------------------------------------------- | ------------- | ------------------- | --------- |\n| fileExtensionLower > .JPG → .jpg                                     | 9,615,695.44  | 13,372,582.93       | 🔴 -1.6%  |\n| fileExtensionLower > .Tar.Gz → .gz                                   | 10,184,943.51 | 13,614,580.97       | 🔴 -1.8%  |\n| fileExtensionLower > no extension → empty                            | 11,352,943.98 | 13,882,037.00       | 🔴 -1.0%  |\n| stem > photo.jpg → photo                                             | 11,690,591.06 | 14,029,372.46       | 🔴 -1.6%  |\n| stem > archive.tar.gz → archive.tar                                  | 11,907,393.14 | 14,354,386.88       | 🔴 -1.1%  |\n| stem > noext → noext                                                 | 11,707,688.00 | 14,669,504.53       | 🔴 -1.1%  |\n| toJpegName > photo.png → photo.jpg                                   | 10,713,663.49 | 14,692,609.71       | 🔴 -0.9%  |\n| toJpegName > img.heic → img.jpg                                      | 10,948,706.47 | 14,334,449.37       | 🔴 -1.6%  |\n| toThumbName > photo.png → photo.thumb.jpg                            | 10,585,701.65 | 14,562,644.83       | 🔴 -0.9%  |\n| toThumbName > img.heic → img.thumb.jpg                               | 10,371,815.27 | 14,592,224.72       | 🔴 -1.7%  |\n| info helper > level + message                                        | 12,687,683.59 | 16,873,548.41       | 🔴 -0.5%  |\n| info helper > level + message + code                                 | 11,091,943.31 | 14,994,037.43       | 🔴 -1.2%  |\n| audioBufferToWav > empty buffer (no samples, mono @ 44100)           | 421,914.63    | 535,655.93          | 🔴 -1.2%  |\n| audioBufferToWav > 1 sec mono @ 44100                                | 1,639.94      | 2,048.91            | 🔴 -1.2%  |\n| audioBufferToWav > 5 sec stereo @ 48000                              | 305.92        | 304.32              | 🟢 +0.5%  |\n| audioBufferToWav > 30 sec stereo @ 44100                             | 59.9565       | 60.55               | 🔴 -1.0%  |\n| Semaphore > new Semaphore(4)                                         | 16,081,222.55 | 17,642,588.69       | 🔴 -0.8%  |\n| Semaphore > acquire + release — uncontended (concurrency=10, 1 task) | 4,833,544.79  | 5,214,967.08        | 🔴 -0.9%  |\n| Semaphore > acquire — contended (concurrency=1, 2 tasks)             | 2,261,603.43  | 1,845,643.06        | 🟢 +22.5% |\n| Semaphore > run() — 10 concurrent resolved promises                  | 266,252.34    | 256,567.82          | 🟢 +3.8%  |\n| result helpers > emptyResult                                         | 15,791,180.67 | 18,712,871.97       | 🔴 -1.6%  |\n| result helpers > artifact                                            | 62,190.85     | 90,418.00           | 🔴 -1.7%  |\n| result helpers > warning                                             | 15,562,158.76 | 18,304,628.87       | 🔴 -1.4%  |\n| result helpers > infoMessage                                         | 16,059,819.04 | 17,560,848.56       | 🔴 -1.5%  |\n\n### Internal Composition\n\n| Benchmark                                                           | Ops/sec       | Prev Minor (v0.7.2) | Change    |\n| ------------------------------------------------------------------- | ------------- | ------------------- | --------- |\n| resolvePluginRefs > 5 bare Plugin instances (identity pass-through) | 11,970,347.11 | 15,642,920.84       | 🔴 -1.8%  |\n| resolvePluginRefs > 5 PluginRef with opts + .with() merging         | 1,675,890.96  | 1,553,125.11        | 🟢 +7.9%  |\n| resolvePluginRefs > 5 PluginRef with defaults (no registry lookup)  | 8,119,311.98  | 8,663,325.91        | 🔴 -1.7%  |\n| resolvePipeline > first match (image → photos)                      | 7,396,375.67  | 10,996,692.92       | 🔴 -1.2%  |\n| resolvePipeline > nested match (video → media → videos)             | 6,132,217.20  | 9,848,416.47        | 🔴 -1.1%  |\n| resolvePipeline > no match (text → null)                            | 8,532,523.88  | 12,852,350.97       | 🔴 -1.4%  |\n| validatePipeline > validatePipeline (valid)                         | 2,556,097.84  | 3,511,061.24        | 🔴 -1.3%  |\n| validatePipeline > validatePipeline (nested, depth 4)               | 1,541,929.50  | 2,023,763.66        | 🔴 -1.4%  |\n| compose / stage > stage() by id+run                                 | 15,930,262.18 | 17,867,257.39       | 🔴 -1.1%  |\n| compose / stage > compose() 3 defs                                  | 5,774,856.39  | 5,360,573.12        | 🟢 +7.7%  |\n| sharedGet / sharedSet > sharedSet + sharedGet                       | 12,142,821.68 | 14,727,983.94       | 🔴 -1.1%  |\n| createTimingMiddleware > wrap and run — no callback                 | 1,687,287.99  | 1,578,290.67        | 🟢 +6.9%  |\n| createTimingMiddleware > wrap and run — with callback               | 1,704,824.85  | 1,589,380.42        | 🟢 +7.3%  |\n| Pipeline factory > Pipeline() — 3 stages                            | 15,491,411.13 | 17,119,938.84       | 🔴 -1.6%  |\n| flattenPipeline > 10 flat stages                                    | 3,757,660.48  | 2,266,526.30        | 🟢 +65.8% |\n| flattenPipeline > 3 nested sub-pipelines (depth 3)                  | 5,156,515.98  | 3,521,964.45        | 🟢 +46.4% |\n| runPipelineFrom > 3 stages via factory                              | 711,396.84    | 687,971.02          | 🟢 +3.4%  |\n\n### Plugins (Individual)\n\n| Benchmark                                               | Ops/sec       | Prev Minor (v0.7.2) | Change   |\n| ------------------------------------------------------- | ------------- | ------------------- | -------- |\n| Plugin class > new Plugin() with run shorthand          | 15,855,312.86 | 17,113,468.39       | 🔴 -1.6% |\n| Plugin class > Plugin.supports()                        | 19,591,551.29 | 20,292,105.52       | 🔴 -0.8% |\n| Plugin class > Plugin.with()                            | 6,507,953.53  | 7,689,967.92        | 🔴 -1.4% |\n| Plugin class > Plugin.with() with instanceId            | 5,106,420.92  | 5,842,252.34        | 🔴 -1.4% |\n| Plugin class > Plugin.createStages()                    | 1,304,657.42  | 1,809,506.82        | 🔴 -0.9% |\n| PluginProvider > new PluginProvider()                   | 1,185,039.98  | 1,316,399.39        | 🔴 -0.6% |\n| PluginProvider > PluginProvider camelCase method        | 1,109,617.70  | 1,337,994.61        | 🔴 -1.4% |\n| PluginProvider > PluginProvider.getPlugin() — found     | 1,170,229.16  | 1,344,249.90        | 🔴 -0.5% |\n| PluginProvider > PluginProvider.getPlugin() — not found | 1,181,385.79  | 1,383,136.50        | 🔴 -1.2% |\n\n### Plugins (Pipeline Composition)\n\n| Benchmark                                                     | Ops/sec      | Prev Minor (v0.7.2) | Change    |\n| ------------------------------------------------------------- | ------------ | ------------------- | --------- |\n| runPipeline > 7 async stages (like real pipeline)             | 128.12       | 125.30              | 🟢 +2.3%  |\n| runPipeline > 7 stages with half skipped (when returns false) | 224.26       | 221.78              | 🟢 +1.1%  |\n| runPipeline > stage error → onError fallback                  | 298.67       | 293.40              | 🟢 +1.8%  |\n| runPipeline > stage error → onError skip                      | 301.36       | 299.06              | 🟢 +0.8%  |\n| pipeline control flow > skipGroup                             | 897,478.31   | 1,368,372.44        | 🔴 -1.4%  |\n| pipeline control flow > skipRemaining                         | 1,329,881.57 | 1,187,327.97        | 🟢 +12.0% |\n| pipeline control flow > removeFromQueue                       | 1,329,784.83 | 1,198,756.09        | 🟢 +10.9% |\n| parallel stages > 3 parallel stages                           | 565,538.93   | 566,186.61          | 🔴 -0.1%  |\n| dependsOn > 2 stages with dependsOn                           | 742,018.56   | 788,247.13          | 🔴 -0.7%  |\n\n<!-- benchmarks:end -->\n"}