# BW WebP — Architecture

**Version:** 0.1.0 | **Last Updated:** 2026-04-30

## High-level view

```
                       ┌──────────────────────────────┐
                       │  Admin UI (Settings → WebP)  │
                       └──────────────┬───────────────┘
                                      │
                                      │ REST /wp-json/bw-webp/v1/*
                                      ▼
              ┌────────────────────────────────────────────┐
              │            Bootstrap (bw-webp.php)         │
              │     wires: settings, converter, queue,     │
              │     manifest, rewrite, REST, admin, CLI    │
              └────────────────────────────────────────────┘
                  │            │            │           │
                  ▼            ▼            ▼           ▼
            ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐
            │ Manifest │ │  Queue   │ │ Converter│ │ Rewrite  │
            │  (DAO)   │ │ (Action  │ │ (cwebp / │ │(.htaccess│
            │          │ │Scheduler)│ │ Imagick /│ │ markers) │
            │          │ │          │ │   GD)    │ │          │
            └────┬─────┘ └────┬─────┘ └────┬─────┘ └──────────┘
                 │            │            │
                 ▼            ▼            ▼
           {prefix}bw_  scheduled    /tmp/bw-webp-*.log
           webp_jobs    actions      (per-job stderr)
```

## File Layout

```
bw-webp/
├── bw-webp.php                          Main plugin file, bootstrap
├── includes/
│   ├── class-bw-webp-plugin.php         Top-level wiring
│   ├── class-bw-webp-settings.php       Single options row, get/update
│   ├── class-bw-webp-manifest.php       Custom-table DAO
│   ├── class-bw-webp-queue.php          Scanner + Action Scheduler dispatcher
│   ├── class-bw-webp-rewrite.php        .htaccess marker manager
│   ├── class-bw-webp-rest.php           REST routes
│   ├── class-bw-webp-cli.php            WP-CLI commands
│   ├── class-bw-webp-activator.php      install/uninstall hooks
│   └── converters/
│       ├── interface-bw-webp-converter.php
│       ├── class-bw-webp-converter-factory.php
│       ├── class-bw-webp-converter-cwebp.php
│       ├── class-bw-webp-converter-imagick.php
│       └── class-bw-webp-converter-gd.php
├── admin/
│   ├── class-bw-webp-admin.php          Settings page, asset enqueue
│   └── views/settings.php
├── assets/
│   ├── css/admin.css
│   └── js/admin.js                      Bulk-convert button + progress poll
├── vendor/plugin-update-checker/        YahnisElsts PUC v5
├── languages/
└── docs/
```

## Core Classes

| Class | Responsibility |
|---|---|
| `BW_WebP_Plugin` | Top-level bootstrap; instantiates and wires everything on `plugins_loaded`. |
| `BW_WebP_Settings` | Reads/writes single option `bw_webp_settings`; merges with defaults. |
| `BW_WebP_Manifest` | DAO for `{prefix}bw_webp_jobs`. All SQL via `$wpdb->prepare()`. |
| `BW_WebP_Queue` | Scans uploads, upserts into manifest, dispatches Action Scheduler jobs. |
| `BW_WebP_Converter` (interface) | `is_available()`, `convert(src, dest, q)`, `name()`. |
| `BW_WebP_Converter_Cwebp` | `proc_open` of `cwebp` binary; argv-only, no shell. |
| `BW_WebP_Converter_Imagick` | `Imagick::setImageFormat('webp')`. |
| `BW_WebP_Converter_Gd` | `imagewebp()`. |
| `BW_WebP_Converter_Factory` | Detects best available; honours user override. |
| `BW_WebP_Rewrite` | `insert_with_markers()` writer for `.htaccess`; Nginx snippet helper. |
| `BW_WebP_Rest` | Registers `/wp-json/bw-webp/v1/*` routes; nonce + capability checks. |
| `BW_WebP_Admin` | Settings page; enqueues `assets/js/admin.js` only on its hook. |
| `BW_WebP_Cli` | `wp bw-webp convert|status|clear|scan`. |
| `BW_WebP_Activator` | `dbDelta` for manifest table; install/upgrade routines. |

## Hooks

### Actions (fired by the plugin)

- `bw_webp_before_convert` ($src, $dest) — fires before each conversion.
- `bw_webp_after_convert` ($src, $dest, $converter_name) — on success.
- `bw_webp_convert_failed` ($src, $error) — on failure.
- `bw_webp_bulk_complete` ($counts) — when bulk job drains the queue.

### Filters (consumed by the plugin)

- `bw_webp_quality` (int, default = settings) — per-image quality override.
- `bw_webp_skip_path` (bool, $path) — return true to skip a file.
- `bw_webp_workers` (int, default = settings) — override worker count.
- `bw_webp_converter_priority` (array) — reorder fallback chain.

## Data Storage

- **Option:** `bw_webp_settings` (single row, array). Defaults: quality 82, workers 4, converter auto, skip_thumbnails false, enable_rewrite true, delete_on_uninstall false.
- **Custom table:** `{prefix}bw_webp_jobs` — see Manifest section below.
- **Transient:** `bw_webp_progress` — short-lived progress snapshot for the admin poll endpoint (5 second TTL).
- **Post meta:** none.

### Manifest table

```sql
CREATE TABLE {prefix}bw_webp_jobs (
    id           BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
    src_path     VARCHAR(512) NOT NULL,
    src_mtime    BIGINT UNSIGNED NOT NULL,
    src_size     BIGINT UNSIGNED NOT NULL,
    dest_path    VARCHAR(512) NOT NULL,
    status       VARCHAR(16) NOT NULL DEFAULT 'pending',
    error        TEXT NULL,
    converter    VARCHAR(16) NULL,
    converted_at BIGINT UNSIGNED NULL,
    PRIMARY KEY (id),
    UNIQUE KEY src_path (src_path(255)),
    KEY status (status),
    KEY src_mtime (src_mtime)
) {charset_collate};
```

## Data flow: bulk conversion

1. User clicks **Convert all** → `POST /wp-json/bw-webp/v1/convert`.
2. Handler runs `Queue::scan()` → `RecursiveIteratorIterator` over `wp-content/uploads/`, upserts manifest rows where source mtime > stored mtime.
3. Handler runs `Queue::enqueue_bulk(N)` → schedules N async actions on `bw_webp_process_batch`.
4. Each Action Scheduler worker claims up to 50 `pending` rows (atomic `UPDATE ... WHERE status='pending' LIMIT 50` + selecting back IDs), converts each via the active converter, updates manifest. Re-enqueues itself if `pending > 0`.
5. Admin UI polls `GET /progress` every 2s → reads `Manifest::counts()`.

## Data flow: new upload

1. WordPress fires `wp_generate_attachment_metadata` after upload.
2. Hook handler enqueues a single `bw_webp_process_one` action with the attachment ID.
3. Worker reads attachment metadata, gets original + intermediate sizes, upserts each into manifest, converts each.

## Why Action Scheduler

- Already shipped by WooCommerce, EDD, GravityForms — likely present on the same sites that need WebP.
- Each scheduled action can run in its own HTTP request, so N async actions == N parallel PHP processes. That's our parallelism.
- Survives PHP timeouts because each batch claim/release is independent.
- Has a built-in admin debug UI.
- We fall back to single-worker WP-Cron when AS isn't present.

## Failure modes

| Failure | Detection | Recovery |
|---|---|---|
| `cwebp` binary disappears mid-job | `proc_open` returns 127 | Mark row failed, log stderr; retry on next bulk run |
| Source file deleted | `is_readable()` returns false | Mark row failed with error="source missing" |
| Disk full | `proc_open`/`imagewebp` fails | Mark row failed; admin page surfaces `errno: 28` |
| Imagick missing WebP | Conversion throws on first file | Auto-fallback to next converter in priority order |
| `.htaccess` not writable | `insert_with_markers` returns false | Admin shows snippet for manual paste |
| Action Scheduler missing | `function_exists('as_enqueue_async_action')` false | Single-worker WP-Cron mode; admin shows warning |

## Performance budget

Reference target: 10,000 images × ~800KB JPEG average, 8-core host.

| Converter | Per-image | 10k images / 7 workers |
|---|---|---|
| cwebp | ~30 ms | **~45 s** |
| Imagick | ~80 ms | **~2 min** |
| GD | ~150 ms | **~3.5 min** |

WebP Express baseline (single worker, Imagick): ~13 min.

## External Dependencies

- `vendor/plugin-update-checker` — YahnisElsts plugin update checker v5 (auto-update from `plugins.bowden.works`).
- Action Scheduler — soft dependency; we use `function_exists('as_enqueue_async_action')` to detect.
- `cwebp` system binary — soft dependency; we fall back to Imagick / GD.

## Security Notes

- All converter binary invocations use `proc_open` with explicit argv arrays — no `shell_exec`, no `system`, no `exec`.
- Source paths are validated with `realpath()` and must be inside `wp_get_upload_dir()['basedir']` before being passed to any converter or written-to.
- Output paths are derived from source path + `.webp` suffix; never user-supplied.
- All REST routes require `manage_options` capability and `X-WP-Nonce`.
- All admin form submissions verify a nonce; all state-changing AJAX uses `check_ajax_referer`.
- All output through `esc_html`, `esc_attr`, `esc_url`, or `wp_kses_post` for the failure log.
- All SQL via `$wpdb->prepare()`; no string concatenation.
- `.htaccess` writes use WordPress's `insert_with_markers()` with our marker `BW WebP` — clean removal on deactivate.
- No user-supplied data is ever passed to `eval`, `assert`, `create_function`, or `base64_decode`.
