---
title: Plugins
description: Lifecycle hooks, authoring a custom plugin, and built-in plugins.
createdAt: 2025-11-23T19:00:00+08:00
lastModified: 2025-11-23T19:40:52+08:00
order: 5
---

# Plugins

Plugins extend the builder without forking its core. They are plain objects with lifecycle hooks, optional `onInit`, and can be registered inline, as factories, or via async ESM importers.

## Lifecycle surface

Hook names map directly to `packages/builder/src/plugins/types.ts`:

- `onInit` — once per process, after config is resolved.
- `beforeBuild` / `afterBuild` — whole run.
- `afterManifestLoad` — existing manifest is available.
- `afterAllFilesListed` → `afterLivePhotoDetection` → `afterImagesListed` → `afterTasksPrepared` — storage discovery.
- `beforeProcessTasks` / `afterProcessTasks` — batch level.
- `beforePhotoProcess` / `afterPhotoProcess` / `photoProcessError` — per photo.
- `beforeAddManifestItem` — right before an item is pushed.
- `beforeSaveManifest` / `afterSaveManifest` — final write.
- `afterCleanup` — after orphaned thumbnail cleanup.
- `onError` — uncaught errors.

All hooks receive `{ builder, config, logger, options, pluginName, pluginOptions, runShared, payload }`.

## Writing your first plugin

Minimal inline plugin (skips GIFs):

```typescript
// builder.config.ts
import { defineBuilderConfig } from '@afilmory/builder'

const skipGifsPlugin = {
  name: 'skip-gifs',
  hooks: {
    afterTasksPrepared: async ({ payload }) => {
      payload.tasks = payload.tasks.filter((t) => !t.key.toLowerCase().endsWith('.gif'))
    },
  },
}

export default defineBuilderConfig(() => ({
  storage: { provider: 'local', basePath: './photos', baseUrl: '/photos' },
  plugins: [skipGifsPlugin],
}))
```

Factory with options:

```typescript
function watermarkPlugin(options: { text: string }) {
  return {
    name: 'watermark',
    hooks: {
      afterPhotoProcess: async ({ payload }) => {
        if (!payload.result.item) return
        payload.result.item.description += ` | ${options.text}`
      },
    },
  }
}

export default defineBuilderConfig(() => ({
  storage: { provider: 's3', bucket: process.env.S3_BUCKET_NAME! },
  plugins: [watermarkPlugin({ text: '© Afilmory' })],
}))
```

Async importer (keeps bundles slim):

```typescript
export default defineBuilderConfig(() => ({
  storage: { provider: 's3', bucket: process.env.S3_BUCKET_NAME! },
  plugins: [() => import('./plugins/geo-tag-plugin.js')],
}))
```

## Patterns

- **Shared state**: use `runShared` (a `Map`) to cache data across hooks within one run.
- **Storage extensions**: `registerStorageProvider` lets a plugin add a custom storage provider before discovery.
- **Manifest mutations**: prefer `beforeAddManifestItem` or `afterPhotoProcess` for per-item edits; `beforeSaveManifest` for global edits.
- **I/O**: keep uploads/downloads async and respect `options` flags; avoid blocking the event loop in hooks.

## Built-in plugins

### GitHub repo sync (`githubRepoSyncPlugin`)

Purpose: cache thumbnails and `photos-manifest.json` in a Git repo to speed up CI/CD and enable sharing of built assets.

What it does
- `beforeBuild`: clones or pulls the repo into `assets-git`, ensures branch exists, creates `thumbnails/` and `photos-manifest.json` if missing, then symlinks them into `apps/web/public/thumbnails` and `apps/web/src/data/photos-manifest.json`.
- `afterBuild`: if `autoPush` and there are changes, commits and pushes to the configured branch (requires token).

Config example:

```typescript
import { defineBuilderConfig, githubRepoSyncPlugin } from '@afilmory/builder'

export default defineBuilderConfig(() => ({
  storage: { provider: 's3', bucket: process.env.S3_BUCKET_NAME! },
  plugins: [
    githubRepoSyncPlugin({
      enable: true,
      autoPush: true,
      repo: {
        url: 'https://github.com/you/gallery-cache',
        token: process.env.GIT_TOKEN,
        branch: 'main',
      },
    }),
  ],
}))
```

Notes
- Requires Git in PATH.
- If token is absent, sync still reads but skips push.
- Safe to run repeatedly; it reuses `assets-git` and handles re-clone on pull failure.

### Thumbnail storage (`@afilmory/builder/plugins/thumbnail-storage`)

Purpose: upload generated thumbnails to your storage (default or a secondary storage config) and rewrite `thumbnailUrl` to point to the remote location.

What it does
- `beforeBuild`: resolves the target storage manager; can reuse default storage or a dedicated one you pass in `storageConfig`.
- `afterPhotoProcess`: uploads the thumbnail buffer once per run per file, caches remote URL, and mutates `payload.result.item.thumbnailUrl` to the remote URL.

Config example (reuse default storage):

```typescript
import thumbnailStoragePlugin from '@afilmory/builder/plugins/thumbnail-storage'

export default defineBuilderConfig(() => ({
  storage: { provider: 's3', bucket: process.env.S3_BUCKET_NAME! },
  plugins: [thumbnailStoragePlugin()],
}))
```

Config example (separate storage for thumbnails):

```typescript
import thumbnailStoragePlugin from '@afilmory/builder/plugins/thumbnail-storage'

export default defineBuilderConfig(() => ({
  storage: { provider: 's3', bucket: process.env.S3_BUCKET_NAME! },
  plugins: [
    thumbnailStoragePlugin({
      storageConfig: {
        provider: 'b2',
        bucketId: process.env.B2_BUCKET_ID!,
        applicationKeyId: process.env.B2_KEY_ID!,
        applicationKey: process.env.B2_KEY!,
      },
      prefix: 'thumbnails/',
      contentType: 'image/jpeg',
    }),
  ],
}))
```

Notes
- Deduplicates uploads per run via an in-memory set; safe for concurrent workers.
- If upload fails, it logs the error and leaves the local URL intact rather than failing the whole build.

Use these built-ins as references for structure, error handling, and hook usage when authoring your own plugins.
