vaersaagod / bunnymate
Keeping your bunny finely-tuned for a hoppy life, mate!
Requires
- php: ^8.3
- craftcms/cms: ^5.1.0
- craftcms/flysystem: ^2.0.0
- platformcommunity/flysystem-bunnycdn: ^3.4
Requires (Dev)
None
Suggests
None
Provides
None
Conflicts
None
Replaces
None
README
Keeping your bunny finely-tuned for a hoppy life, mate!
Description
BunnyMate integrates Bunny with Craft CMS. It does four things, and they can be used together or on their own:
Pull zone URLs. A bunnyPullUrl() Twig function that rewrites any path, asset URL or absolute URL to a Bunny pull zone, so existing files are served from the CDN without moving them. Several pull zones can be configured and picked between per call. → Usage
A Bunny Storage filesystem. Assets stored in a Bunny Edge Storage zone and served over the pull zone that fronts it. Asset URLs come from the pull zone config rather than a per-filesystem base URL, and changed files are purged from the CDN automatically. → Bunny Storage filesystem
Video streaming. Video assets backed by Bunny Stream, transcoded and delivered by Bunny while staying ordinary Craft assets. Uploads go straight from the browser to Bunny over a resumable protocol, so PHP's upload limits don't apply, and a signed webhook keeps encoding status in sync. No custom field type: playback URLs, poster frames and metadata hang off asset.bunnyVideo, asset queries take a bunnyVideo() parameter, and asset.getBunnyVideoTag() renders a player. → Bunny Stream
CDN cache purging. Changed files are purged from the edge when assets are added, replaced, moved or deleted, so a replaced file doesn't keep serving its old copy until the TTL expires. → Cache purging
Everything is configured in config/bunnymate.php, with every credential resolvable from an environment variable.
Requirements
This plugin requires Craft CMS 5.1.0 or later, and PHP 8.3 or later.
Migrating from BunnyMate 2.x
BunnyMate 3.0 is no longer a private plugin: the handle changed from _bunnymate to bunnymate.
It's the same Composer package, so Composer simply upgrades it — nothing to remove or re-require there. Craft is the part that cares: it keys installed plugins by handle, in the plugins table and in project config, and a changed handle reads to Craft as one plugin disappearing and a different one showing up. So the old handle needs uninstalling and the new one installing, at Craft's level only.
Nothing is at risk in the process. BunnyMate 2.x shipped no migrations and owned no database tables, so uninstalling it loses nothing beyond its row in the plugins table.
ddev craft plugin/uninstall _bunnymate— Craft only; the package stays where it isddev composer require vaersaagod/bunnymate:^3.0— upgrades in place- Rename
config/_bunnymate.phptoconfig/bunnymate.php ddev craft plugin/install bunnymate
Do step 1 before step 2, so the uninstall runs against a plugin Craft can still load. If you've already upgraded, it's recoverable — see below.
Settings carry over untouched: pullingEnabled, pullZones and defaultPullZone mean exactly what they meant in 2.x, and bunnyPullUrl() is unchanged. Templates need no edits. Everything else in 3.0 — the Bunny Storage filesystem, Bunny Stream — is additive and off until configured.
Steps 1 and 4 both write to project config, so committing project.yaml carries the change to your other environments: applying it there removes _bunnymate and installs bunnymate without further intervention.
If you've already upgraded
If Composer upgraded before you ran step 1, _bunnymate is left behind in the plugins table and in project config, and Craft can no longer load it. Pass --force, which lets Craft uninstall a plugin it can't load:
ddev craft plugin/uninstall _bunnymate --force
ddev craft plugin/install bunnymate
That clears the plugins row, the plugin's migration track and its project config entry. The only thing it skips is the plugin's own uninstall migration, since there's no class left to run it — which costs nothing here, because 2.x had no migrations and created no tables.
Migrating from vaersaagod/bunny
ddev craft plugin/uninstall bunny && ddev composer remove vaersaagod/bunnyddev composer require vaersaagod/bunnymate && ddev craft plugin/install bunnymate- Rename
config/bunny.phptoconfig/bunnymate.php
Configuration
<?php
return [
'pullingEnabled' => true,
'pullZones' => [
'default' => [
'hostname' => 'https://awesome-project.b-cdn.net',
'enabled' => true,
],
],
'defaultPullZone' => 'default',
// Account API key from the Bunny dashboard, used to purge the CDN cache.
// This is not a storage zone password.
'apiKey' => '$BUNNY_API_KEY',
// Whether to purge the CDN cache when assets change on a Bunny Storage filesystem
'purgeEnabled' => true,
];
Usage
BunnyMate provides a global Twig function bunnyPullUrl(), which can be used to generate a Bunny CDN pullzone URL:
{% set bunnyUrl = bunnyPullUrl(asset) %}
{% set bunnyUrl = bunnyPullUrl(siteUrl('lorem/ipsim') %}
{% set bunnyUrl = bunnyPullUrl('lorem/ipsum') %}
{% set bunnyUrl = bunnyPullUrl('/lorem/ipsum', 'anotherZone') %}
{% set bunnyUrl = bunnyPullUrl('https://example.com/foo/bar/video.mp4') %}
A path, or a URL on the current site, is moved onto the pull zone. So is an absolute URL on some other host, keeping its path, query string and fragment: https://example.com/foo/bar/video.mp4 becomes https://my-zone.b-cdn.net/foo/bar/video.mp4. That's for a pull zone whose origin is that host; BunnyMate can't know what a pull zone pulls from, so it takes your word for it.
A URL on a host BunnyMate already knows is left as it is: any site, any volume's base URL, any configured pull zone, and any video library. Those are what asset.url resolves to, so passing an asset's URL as a string, rather than the asset, doesn't move a file on S3 or Bunny Stream onto an origin that can't serve it. An asset on a remote volume keeps its URL for the same reason. With pullingEnabled off, or the pull zone disabled, absolute URLs are returned untouched.
Bunny Storage filesystem
BunnyMate provides a Bunny Storage filesystem type, which stores assets in a Bunny Edge Storage zone and serves them over a pull zone.
Create it under Settings → Filesystems, and configure:
| Setting | Notes |
|---|---|
| Storage Zone | The Edge Storage zone name. |
| Access Key | The storage zone's password, found under FTP & API Access in the Bunny dashboard. Not the account API key. |
| Region | The region the storage zone was created in. Getting this wrong causes every request to fail. |
| Subfolder | Optional path within the storage zone to use as the filesystem root. |
| Pull Zone | Which of the pull zones from config/bunnymate.php serves this storage zone. |
Note that the filesystem has no Base URL field. Asset URLs are built from the selected pull zone's hostname, plus the subfolder, so the hostname is configured in one place and shared with bunnyPullUrl().
The pullingEnabled and per-zone enabled settings do not apply to the filesystem. Those toggles let bunnyPullUrl() fall back to the site's own origin, but files in a storage zone have no origin to fall back on, so stripping their URLs would break them.
Cache purging
When apiKey is set and purgeEnabled is true, BunnyMate purges files from the CDN cache whenever assets on a Bunny Storage filesystem are added, replaced, copied, moved or deleted.
Only the affected URLs are purged, via Bunny's single URL purge endpoint. The pull zone is never purged as a whole. Deleting a folder purges it with a trailing slash, which Bunny treats as a wildcard, so everything beneath it is cleared too.
Paths are collected over the course of a request and handed to a single queue job afterwards, so bulk operations such as renaming a folder don't fire one blocking HTTP request per file.
apiKey is the account API key from the Bunny dashboard, not a storage zone password. Set purgeEnabled to false to leave the edge cache to expire on the pull zone's own TTL.
Bunny Stream
BunnyMate can back Craft video assets with Bunny Stream, so videos are transcoded and delivered by Bunny while remaining ordinary Craft assets.
Unlike a custom field type, nothing is added to an asset field layout and no video metadata is stored in the content table. The mapping between assets and Bunny videos lives in the bunnymate_videos table, and is exposed through a behavior.
Configuration
'videoLibraries' => [ 'default' => [ 'id' => '$BUNNY_STREAM_LIBRARY_ID', 'apiKey' => '$BUNNY_STREAM_API_KEY', 'readOnlyApiKey' => '$BUNNY_STREAM_READONLY_KEY', 'hostname' => 'vz-xxxxxxxx-xxx.b-cdn.net', // Only if token authentication is enabled on the library's pull zone 'tokenAuthKey' => '$BUNNY_STREAM_TOKEN_KEY', ], ], // Volumes not listed here are left alone 'volumeVideoLibraries' => [ 'videos' => 'default', ],
The key each library is listed under (default above) is a local name, referred to by volumeVideoLibraries. The library itself is identified by its id.
volumeVideoLibraries maps Craft volume handles to those names, and is what turns Bunny Stream on: a volume that isn't listed is left alone entirely.
The apiKey grants write access to the library, so it must never reach the browser. The readOnlyApiKey doubles as the webhook signing secret.
Webhook
Add this URL under Webhook URL in the video library's settings in the Bunny dashboard:
https://your-site.com/bunnymate/webhook
Every payload is verified as an HMAC-SHA256 of the raw request body, keyed on the library's read-only API key, so an unsigned or incorrectly signed request is rejected with a 403. Local environments need a publicly reachable URL, via ddev share, ngrok or similar.
Twig
{% set video = asset.bunnyVideo %}
{% if video and video.isReady %}
<video poster="{{ video.thumbnailUrl }}" controls>
<source src="{{ video.hlsUrl }}" type="application/x-mpegURL">
<source src="{{ video.mp4Url('720p') }}" type="video/mp4">
</video>
{% elseif video and video.isFailed %}
<p>{{ "This video could not be processed."|t }}</p>
{% elseif video %}
<p>{{ "Processing: {progress}%"|t({ progress: video.encodeProgress }) }}</p>
{% endif %}
| Property | Notes |
|---|---|
isReady / isFailed |
Whether the video is playable, or failed to encode |
isMissing |
Whether Bunny no longer has the video. See Videos deleted on Bunny. |
status |
The raw VideoStatus enum case from Bunny |
statusLabel |
A readable state, derived from encoding progress. See Video status. |
hlsUrl |
HLS playlist. Null until playable. |
mp4Url(resolution) |
MP4 rendition. Needs MP4 fallback enabled; highest available if no resolution given, and an unavailable one falls back to the closest below it. |
mp4Sources(map) |
MP4 sources for a player that manages src itself. See Driving playback yourself. |
thumbnailUrl(width, height) |
Poster frame. Available before encoding finishes. Dimensions only apply when optimizerEnabled is set. |
previewUrl |
Animated WebP preview |
embedUrl(params) |
Bunny's iframe player URL |
hasOriginal / originalUrl |
The uploaded file itself, played in the browser. See The original file. |
downloadUrl |
The same file, served as a download with its original filename |
downloadUrl(resolution) |
An MP4 rendition, served as a download. Null for a rendition that wasn't produced. |
originalSize |
The original file's size in bytes. Null when the library doesn't keep originals. |
hasPublicUrls |
Whether the video's volume has public URLs. Front-end downloads need it. |
mp4Size(resolution) / mp4Sizes |
An MP4 rendition's size in bytes, or all of them keyed by resolution. See Download lists. |
originalFilename |
What the file was uploaded as, before being renamed to .mp4 |
width, height, length, encodeProgress |
Metadata from Bunny |
library / libraryHandle |
The library the video lives in. Recorded by Bunny ID, so renaming a handle in videoLibraries doesn't strand existing videos. |
availableResolutions |
Every rendition Bunny encoded for HLS, ascending, e.g. ['240p', '360p', …]. The sidebar panel only shows the highest. |
availableMp4Resolutions |
Those an MP4 actually exists for, measured rather than assumed. See Renditions. |
The player
asset.getBunnyVideoTag() renders a <video> element for a ready video, and returns null for anything else, so it doubles as the "is this playable" check:
{{ asset.getBunnyVideoTag() }}
What comes out is a plain <video> with two sources — the HLS playlist and an MP4 rendition — and an aspect-ratio style so the page doesn't jump once metadata arrives. There's no poster unless you ask for one with poster: true, since a poster is a whole extra image request and templates that want one usually want it transformed. Where hlsJsUrl is set, a small script attaches hls.js as the video approaches the viewport, giving adaptive playback everywhere hls.js can run, and hands the HLS source to the browser where it can't, as on iOS. It's only registered when the tag actually needs it.
Until that script has run, the HLS source is held back in data-src, and a tag that isn't lazyloaded renders with preload="none", keeping the real value in data-bunnymate-preload. A browser picks its source as the markup is parsed, and Chrome now plays HLS natively, so otherwise it would start fetching the playlist, or preloading the MP4, only for hls.js to take over and cancel it. With JavaScript off, or blocked, the tag still degrades on its own: the MP4 plays when asked to. Without hlsJsUrl, or with hls: false, none of this applies, and the browser plays the HLS source natively where it can, as before.
{# A muted background loop: MP4 only, because for a silent loop a fixed rendition starts sooner and needs no player code #} {{ asset.getBunnyVideoTag({ inline: true, hls: false, resolution: '360p' }) }}
| Option | Default | Notes |
|---|---|---|
inline |
false |
Sets autoplay, muted, loop and drops controls, for a background loop |
hls |
true |
Set to false to emit only the MP4 source, and skip the player script |
lazyload |
lazyloadBunnyVideo (true) |
Holds the sources in data-src until the element scrolls into view. See Lazyloading. |
resolution |
videoUrlRendition |
Which MP4 rendition to use as the fallback source |
minResolution / maxResolution |
defaultMinResolution / defaultMaxResolution |
Bounds on the levels hls.js may pick, measured on the short side, so '720p' means 1280×720 or 720×1280 as the video requires. Adaptive playback only: they do nothing when hls is false, or on a browser without Media Source Extensions, where HLS plays natively and there's no hls.js to cap. |
poster |
false |
true for Bunny's poster frame, or a URL to use instead |
controls, playsinline, preload, autoplay, muted, loop |
— | Passed through to the element |
attributes |
— | Merged over everything above |
nonce |
— | Applied to the player script tag, for a strict CSP |
The return value is Markup, so Craft's |attr filter can add to it after the fact:
{{ asset.getBunnyVideoTag({ inline: true })|attr({ class: 'teaser__video' }) }}
Lazyloading
lazyload governs one thing: whether the <source> elements render with src or data-src. It's on by default, because this tag renders a self-contained player — nothing outside it is waiting to play the video, so deferring the media until it's needed is a straight win.
hls.js is a separate matter, and is attached as the video approaches the viewport whether or not the tag is lazyloaded. It starts buffering the moment it attaches, so attaching it to an off-screen video would only move the download earlier. Nothing above the fold waits for this: an element already in view intersects on the observer's first check.
Turn lazyload off only if you need the element to work before any JavaScript has run. If you're turning it off because your own code calls play(), that's the wrong tool — see Driving playback yourself.
Driving playback yourself
getBunnyVideoTag() is a standalone player: it owns its sources, its loading and its hls.js. That makes it the wrong shape for a video-loop component — the kind that plays on intersection, pauses off-screen, swaps rendition on a media query and falls back to a still image when playback doesn't start. Such a component needs to own the src itself.
For that, skip the tag and take the URLs:
{% set sources = asset.bunnyVideo.mp4Sources({
'(max-width: 767px)': '480p',
'(min-width: 768px)': '1080p',
}) %}
{{ tag('video', {
class: 'videoloop',
loop: true,
muted: true,
playsinline: true,
'data-sources': sources|json_encode,
}) }}
mp4Sources() returns [{ src, media }] in the order given, ready to json_encode. With no arguments it returns a single source at the highest available rendition. A rendition the video doesn't have falls back to the closest below it, exactly as mp4Url() does, so one map works across videos encoded differently. Passing a plain list throws rather than silently producing numeric media queries nothing will match.
Preconnecting to the library's hostname is worth it, since the first request is deferred:
{% html at head %}
<link rel="preconnect" href="https://{{ asset.bunnyVideo.library.hostname }}">
{% endhtml %}
MP4 renditions stop at 1080p — that's Bunny's MP4 fallback, not a BunnyMate limit. Where a loop has to fill a large viewport, hlsUrl carries the full ladder up to the source resolution, at the cost of needing a player.
Querying
Asset queries take a bunnyVideo() parameter, so finding video assets doesn't mean fetching everything and filtering in Twig:
{% set videos = craft.assets.bunnyVideo('ready').limit(6).all() %}
It's an ordinary asset query parameter and chains with the rest:
{% set videos = craft.assets
.volume('media')
.bunnyVideo('ready')
.orderBy('dateCreated DESC')
.all() %}
| Value | Matches |
|---|---|
true (the default) |
Assets with a Bunny Stream video, whatever state it's in |
false |
Assets without one. Combine with .kind('video') for videos that never made it to Bunny. |
'ready' |
Playable videos |
'encoding' |
Still uploading or encoding |
'failed' |
Failed to encode or upload, or gone from Bunny |
'missing' |
Bunny no longer has the video. See Videos deleted on Bunny. |
A VideoStatus case, a raw code, or an array of any of the above |
Exactly those statuses |
The parameter filters on BunnyMate's own table with a subquery, so it costs one IN (SELECT …) and works with count(), exists(), pagination and eager loading like any other parameter. Anything else throws, rather than quietly matching everything.
This works because BunnyMate attaches a behavior to every asset query through craft\db\Query::EVENT_DEFINE_BEHAVIORS — the same mechanism custom fields use, without a field existing.
Video status
Bunny reports a video's state as a numeric status, sent on the webhook and returned by the API. BunnyMate maps these onto the VideoStatus enum, available as asset.bunnyVideo.status:
| Code | Case | Meaning |
|---|---|---|
| -1 | Missing |
Not one of Bunny's codes. Set when Bunny turns out to no longer have the video. |
| 0 | Queued |
Waiting to be encoded |
| 1 | Processing |
Working out preview and format details |
| 2 | Encoding |
Encoding |
| 3 | Finished |
Encoding complete, every rendition available |
| 4 | ResolutionFinished |
One rendition available, so the video plays while encoding continues |
| 5 | Failed |
Encoding failed |
| 6 | PresignedUploadStarted |
A TUS upload began |
| 7 | PresignedUploadFinished |
A TUS upload completed |
| 8 | PresignedUploadFailed |
A TUS upload failed |
| 9 | CaptionsGenerated |
Automatic captions finished |
| 10 | TitleOrDescriptionGenerated |
Automatic title or description finished |
Three things about these are worth knowing, because none of them are obvious from the numbers.
9 and 10 arrive after a video is finished. They report generated metadata, not encoding progress, so treating them as lifecycle states would move a finished video backwards. VideoStatus::isLifecycle() marks them as not-lifecycle, and BunnyMate refreshes a video's metadata on those webhooks without touching its status.
Bunny's status settles on 4, not 3. A fully encoded video with every rendition still reports ResolutionFinished from the API. Finished only ever arrives as a passing webhook, so whether a video ends up stored as 3 or 4 is a matter of which webhook landed last. Both mean the video plays.
So use statusLabel, not status, for display. It reports encoding progress once a video is playable, which is stable where the status code isn't:
| Status | encodeProgress |
statusLabel |
isReady |
|---|---|---|---|
Queued |
0% | Queued | false |
Encoding |
45% | Encoding | false |
ResolutionFinished |
60% | Playable | true |
ResolutionFinished |
100% | Ready | true |
Finished |
100% | Ready | true |
Failed |
— | Failed | false |
isReady is true for 3 and 4, since either means the video plays, and also for 9 and 10, which can only arrive once encoding has finished. isFailed covers 5 and 8. In templates:
{% if video.isReady %}
{# plays, though it may still be encoding higher renditions #}
{% elseif video.isFailed %}
{# 5 or 8 #}
{% else %}
{{ video.statusLabel }} ({{ video.encodeProgress }}%)
{% endif %}
Renditions
Bunny only encodes up to the source resolution, so renditions are per video: a 720p upload never has a 1080p rendition, and two videos in the same library can offer different sets. availableResolutions is what Bunny actually produced, ascending.
availableResolutions describes the HLS renditions, not the MP4 ones. Bunny's MP4 fallback stops short of what it encodes for HLS: a video listing renditions up to 2160p may only have MP4s up to 1080p, and play_2160p.mp4 then returns a 404.
Nothing in the API reports where the cut is. The video payload carries availableResolutions, which is the HLS set, and hasMP4Fallback, which is a plain boolean. So BunnyMate measures it instead: once a video finishes encoding, it asks Bunny for the renditions from the top down until one answers, and stores the result with the video's metadata. MP4s are produced from the bottom up, so the highest one that exists settles the rest, which makes this a few requests rather than one per rendition. It runs when metadata is refreshed, never per page view.
availableMp4Resolutions is therefore measured rather than assumed. Until a video has been measured, renditions above 1080p are assumed absent, which is what Bunny does today.
mp4Url() and videoUrlRendition resolve against that list. A rendition that isn't in it falls back to the closest one below, or to the lowest available if the request was below everything on offer:
| Available | Asked for | Returns |
|---|---|---|
240p,360p,480p,720p,1080p |
— | 1080p |
240p,360p,480p,720p,1080p |
720p |
720p |
240p,360p,720p |
1080p |
720p |
720p,1080p |
240p |
720p |
mp4Url() returns null only when a video has no MP4 renditions at all, which means MP4 fallback is off for the library.
Use availableMp4Resolutions when you need the renditions an MP4 actually exists for, and availableResolutions for what Bunny encoded.
Asset URLs
Assets backed by Bunny Stream hold no file of their own, so asset.url would otherwise resolve to a path that 404s. BunnyMate overrides it via Asset::EVENT_BEFORE_DEFINE_URL, and a transform request returns the poster frame instead. Set overrideAssetUrls to false to opt out.
asset.url returns an MP4 rendition rather than the HLS playlist, because this URL is what ends up in plain <video> elements, including Craft's own on the asset edit screen, and only Safari plays HLS natively. Templates that want adaptive streaming should ask for asset.bunnyVideo.hlsUrl, and that needs a player like hls.js outside Safari.
Which rendition is set by videoUrlRendition, which defaults to the highest one Bunny produced:
'videoUrlRendition' => '1080p',
Renditions are per video: Bunny only encodes up to the source resolution, so a 720p upload never has a 1080p rendition. Rather than break, a rendition that wasn't produced falls back to the closest one below it, or to the lowest available if the request was below everything on offer. Bunny's own list of available resolutions is what's consulted, so neither asset.url nor mp4Url() ever returns a URL that 404s.
Uploading
Videos uploaded to a mapped volume through the regular Assets screen go straight from the browser to Bunny over the TUS resumable protocol. The file never passes through PHP, so upload_max_filesize, post_max_size and request timeouts don't apply, and an interrupted upload resumes rather than restarting.
This works by registering a custom uploader with Craft, via Craft.registerUploaderClass(). Craft dispatches uploaders by filesystem class, so BunnyMate registers for every filesystem used by a mapped volume. Volumes that share that filesystem but aren't mapped to a library are unaffected, and non-video files are always handled by Craft's own uploader.
Craft handles two small requests per file: one to create the video and its asset and hand back a signed upload credential, and one to refresh metadata when the upload finishes. The library's API key never reaches the browser. The signature is SHA256(libraryId + apiKey + expires + videoGuid), scoped to a single video and expiring on its own.
Only volumes listed in volumeVideoLibraries upload this way, and only for users with saveAssets permission on them.
Set tusUploadsEnabled to false on a library to leave its volumes to Craft's own uploader instead. Videos are then stored in the volume like any other file and sent to Bunny as described under Videos that arrive some other way, so no placeholder file is written. That suits a library of smaller clips that fit through PHP's upload limits anyway. It needs autoUploadVideos on and the volume reachable from the public internet, or the videos never reach Bunny. They also keep their own extension rather than being renamed to .mp4.
'videoLibraries' => [ 'clips' => [ // ... 'tusUploadsEnabled' => false, ], ],
Several videos dropped at once are queued and uploaded one at a time, the way Craft's own uploader handles files. Side by side wouldn't finish the batch any sooner, since every upload shares the same bandwidth, and one at a time gets each video to Bunny, and encoding, as early as possible. Set maxConcurrentUploads to allow more at once. Each video's upload credential is only issued when its turn comes, so a long queue can't outlive it.
Uploaded videos are named .mp4 whatever the source file was. The source is never stored, since Bunny keeps it and serves MP4 and HLS, and Craft derives an asset's MIME type from its extension, so a .mov asset would advertise video/quicktime for an MP4 URL and browsers would refuse to play it.
Videos that arrive some other way
A video that reaches a mapped volume by any other route, a normal CP upload, a feed import, or a programmatic save, is also sent to Bunny, by asking Bunny to fetch it from the asset's URL. That means the volume has to be reachable from the public internet: a local filesystem on a dev machine can't be pulled from, and the job will log an error. A volume on the Bunny Storage filesystem works well here, since the file is already on Bunny's network.
Replacing a video's file deletes the old Bunny video and sends the new file up in its place.
Set autoUploadVideos to false to turn this off and rely on the uploader alone.
Videos that were already there
Auto-upload only catches videos as they arrive. Mapping a volume that already holds videos sends none of them anywhere, and a resave won't either — the handler skips resaves on purpose, so an unrelated resave/assets can't push a whole volume to Bunny by accident.
For the back catalogue, there's a command:
php craft bunnymate/videos/create-missing
It finds video assets in mapped volumes with no Bunny video, reports what it found per volume, and asks before queuing anything. Assets that already have a video are skipped, so it's safe to re-run and safe to interrupt.
| Option | |
|---|---|
--volume |
Only this volume. Must be one that's mapped to a library. |
--limit |
Stop after this many assets, for working through a large volume in batches |
--dry-run |
Report what would be queued, and queue nothing |
Bunny fetches each file over HTTP from the asset's own URL, so this has to run where those URLs are publicly reachable. A local environment won't do, however correctly it's configured. The command checks a sample URL per volume up front and warns if it looks local, rather than letting a few hundred jobs fail one at a time.
Encoding progress arrives on the webhook as usual, so that needs to be reachable too, or the videos will sit at Queued until something refreshes them.
The Bunny Stream panel
Video assets backed by Bunny Stream get a Bunny Stream panel in their edit screen sidebar, showing encoding status, available resolutions, dimensions, duration and the video's GUID.
It also carries a Refresh from Bunny button, which pulls the video's current state down from Bunny on demand. The webhook normally keeps this in step, so the button is for when it doesn't: no webhook URL configured, an environment Bunny can't reach, or a delivery that was missed.
Control panel thumbnails
Video assets show Bunny's poster frame as their control panel thumbnail, instead of the generic file-type icon Craft would otherwise use. Craft can't generate one itself: the assets uploaded over TUS have no file at all, and the rest are videos, which its image drivers can't open.
Bunny serves the poster frame at full resolution, and only resizes from the URL when Bunny Optimizer is enabled on the pull zone. Left alone, a 4K video would mean a 4K JPEG for every thumbnail.
So where Imager X is installed, BunnyMate resizes with that instead. The frame is fetched once, transformed to whatever size Craft asked for and cached locally: a 120px thumbnail from a 4K poster comes out around 3KB rather than 150KB. Set useImagerForThumbnailTransforms to false to turn this off.
Imager downloads over curl, which sends no referrer, and Bunny libraries block referrer-less requests by default. BunnyMate passes the site's own URL as the referrer so this works either way.
imagerTransformDefaults and imagerTransformConfigOverrides are handed to Imager's transformImage() as its third and fourth arguments:
'imagerTransformDefaults' => ['format' => 'webp', 'quality' => 60], 'imagerTransformConfigOverrides' => ['useRemoteUrlQueryString' => true],
Defaults sit under the transform BunnyMate builds, so the width and height Craft asked for always win — mode, which defaults to crop, along with position, format, quality and the rest are yours to set. Config overrides take precedence over BunnyMate's own, with one exception: curlOptions is merged key by key rather than replaced, so setting an unrelated curl option can't quietly drop the referrer and turn every thumbnail into a 403. Setting CURLOPT_REFERER yourself does replace it.
Without Imager, the poster frame is used as Bunny serves it. If you have Bunny Optimizer enabled on the pull zone, set optimizerEnabled to true on the library config and BunnyMate will ask Bunny for the size Craft wanted.
Video thumbnails are also marked with a play icon, since a poster frame is a still image and would otherwise be indistinguishable from a photo in an asset index. A video that's still encoding has no usable poster yet, so it gets a plain placeholder with a spinner over it rather than a file-type icon that gives no sign anything is happening. Videos with no Bunny video are left alone.
Previewing a video asset in the control panel opens Bunny's player, rather than Craft's default <video> element, which can't play an HLS playlist in most browsers.
If MuxMate is also installed, it claims the preview for every video asset whether or not it has a Mux video, and Craft takes the last handler registered. Uninstall MuxMate, or guard its preview handler, or Bunny videos will preview as "No Mux playback ID".
Non-video files
Only video files are sent to Bunny Stream. An image, PDF or anything else uploaded to a mapped volume is stored in that volume exactly as it would be otherwise, with transforms and asset URLs untouched. On a volume using the Bunny Storage filesystem, that means it's served over the pull zone like any other file.
The original file
Where a library keeps original files, Bunny serves the upload back untouched, and
asset.bunnyVideo.originalUrl points at it. The asset edit screen offers it as a download.
{% if video.hasOriginal %}
<a href="{{ video.downloadUrl }}">{{ "Download original"|t }}</a>
{% endif %}
Use downloadUrl rather than originalUrl for anything meant to save the file. Bunny serves originals as video/mp4 with no Content-Disposition, and offers no way to change that: no query parameter sets it and the pull zone has no setting for it. A download attribute doesn't help either, since browsers ignore it cross-origin, so originalUrl opens the video in the browser rather than saving it.
downloadUrl goes through Craft, which sets the header and the filename and streams the file on from Bunny. On the front end it's a route of its own, /bunnymate/download/{videoGuid} for the original and /bunnymate/download/{videoGuid}/{resolution} for a rendition, e.g. /bunnymate/download/63e9c76d-3631-433c-8d4f-95d360d6ad4b/720p. Change the prefix with downloadPath, or set that to null for a plain action URL. It can differ per site, with an array keyed by site handle:
'downloadPath' => [ 'norwegian' => 'fotoarkiv/last-ned', 'english' => 'photo-archive/download', ],
A site that isn't listed uses the '*' entry, if there is one, and an action URL if not. In the control panel it's always an action URL, since control panel downloads are checked against the asset's permissions rather than allowOriginalDownloads.
Front-end downloads are identified by the video's GUID, never by the asset's ID, and the download controller refuses an ID from the front end. They're open to anyone while allowOriginalDownloads is on, and sequential IDs would let someone count through them and download every video there is. A GUID is random, and already public in every playback URL. A volume whose filesystem is set to have no public URLs doesn't serve front-end downloads either, and downloadUrl returns null for its videos on the front end, so templates can leave the link out. hasPublicUrls says which it is. Because uploads are renamed to .mp4, the filename it uses is the one the file was uploaded as, kept alongside the video's metadata; videos uploaded before that was recorded fall back to the asset's filename.
Download lists
The MP4 renditions download the same way, with a resolution, and each file's size is known, so a list of downloads can say what each one weighs:
{% set video = asset.bunnyVideo %}
<ul>
{% if video.hasOriginal %}
<li><a href="{{ video.downloadUrl }}">{{ 'Original'|t }}</a> {{ video.originalSize|filesize }}</li>
{% endif %}
{% for resolution in video.availableMp4Resolutions %}
<li>
<a href="{{ video.downloadUrl(resolution) }}">{{ resolution }}</a>
{% if video.mp4Size(resolution) %}{{ video.mp4Size(resolution)|filesize }}{% endif %}
</li>
{% endfor %}
</ul>
Bunny's API reports no per-file sizes, only a storageSize covering every file a video has, so BunnyMate asks for each one with a HEAD request when it refreshes a video's metadata, along with which renditions exist. That happens once a video finishes encoding, not per page view. Each request gives up after a few seconds, and a size that can't be read keeps the one measured last time. Videos last refreshed before BunnyMate measured rendition sizes have none until they're refreshed again, which refresh-existing does for all of them at once.
Front-end downloads are governed by allowOriginalDownloads. Control panel downloads aren't, and are gated on the asset's own view permission instead.
This matters more than it might look, because assets uploaded straight to Bunny hold no file of their own: the original on Bunny is the only copy of what was uploaded, and this is how to get it back.
Four things to weigh:
Unlike the MP4 renditions, this isn't capped. A 4K upload is served back at 4K, at its full original size, which may be hundreds of megabytes.
Keeping originals roughly doubles what a video costs to store, since the upload sits alongside every rendition.
The URL is guessable from the video's ID, so anyone holding it can download the full-resolution master. Enable token authentication on the library if that matters, and BunnyMate will sign this URL along with the others.
And downloadUrl streams through your server rather than straight from the CDN, since that's the only way to set the download header. That's fine for the occasional download, but it means your own bandwidth carries it. Set allowOriginalDownloads to false to keep that off the front end.
hasOriginal is false when the library discards originals after encoding, in which case there's nothing to recover.
Asset metadata
Assets uploaded straight to Bunny hold no file for Craft to read, so their dimensions, size and modified date would otherwise stay empty and an asset index would look half broken.
Bunny knows all of it, so it's written onto the asset whenever a video's metadata is refreshed: width and height as soon as Bunny reports them, and size and dateModified once encoding finishes.
The size reported is the uploaded file's, not Bunny's storageSize, which counts every rendition alongside the original and is several times larger than anything anyone uploaded. For one 4K video here that's 296 MB against a storageSize of 718 MB.
Videos uploaded before this existed fill in on their next refresh, whether that's a webhook or the panel's Refresh from Bunny button.
Videos deleted on Bunny
Deleting a video in Bunny's dashboard fires no webhook, so Craft has no way to hear about it. Worse, the asset carries on looking fine for a while, because the CDN edge and any cached thumbnails still hold copies. Once those expire, every URL 404s.
Pressing Refresh from Bunny on the asset finds out. When Bunny no longer has the video, the panel says Missing from Bunny and the asset stops pretending: asset.bunnyVideo.isMissing is true, isReady is false, the control panel thumbnail reverts to a file-type icon and asset.url stops returning a Bunny URL.
The record is kept rather than dropped, so the asset says what happened instead of quietly looking like an ordinary video with no file. Re-uploading is the fix; there's nothing to recover, since Bunny held the only copy.
Uninstalling
Uninstalling BunnyMate drops the bunnymate_videos table and nothing else. The videos stay on Bunny, encoded and billed as before.
That's deliberate — a local uninstall shouldn't destroy remote content you're paying for, and there'd be no getting it back. BunnyMate says so before it happens, logging a warning that counts the videos it's about to leave behind and in which libraries, and printing it too when the uninstall runs from the console. Three things follow.
The mapping is gone. Every video GUID lived only in that table. On Bunny the videos are identifiable by title alone, which is the asset's filename as it stood when the video was created, so anything renamed since carries its old name. Nothing on Bunny records which asset a video belonged to.
Reinstalling doesn't reconnect them. You get an empty table and assets that look like they've never been to Bunny, so create-missing would upload every one of them a second time — double the storage, with the originals orphaned and hard to tell from the new copies. If you expect to reinstall, keep a dump of the table.
Nothing prunes them afterwards. Orphan collection works from rows in that table, so once it's gone the videos can only be cleared out from Bunny's dashboard.
Access control
New Bunny video libraries ship with Block direct URL file access enabled, which rejects any request that arrives without a Referer header.
This is hotlink protection, not access control. A Referer header is set by whoever makes the request, so anyone who wants the file can send one and get it. What the setting reliably does is break legitimate consumers that don't send a referrer: sites using Referrer-Policy: no-referrer, native apps, and any server-side fetch.
With it off and nothing else configured, playback URLs are public to anyone holding them. Video GUIDs are random UUIDs, so they aren't guessable, which is usually fine for non-sensitive content.
Token authentication
For real access control, enable CDN token authentication on the library and set tokenAuthKey to the pull zone's security key. BunnyMate then signs every playback URL with an expiring token, and signedUrlDuration controls how long each stays valid — a number of seconds, or a date interval string like 'PT1H', whichever reads better.
HLS URLs carry their token in the path rather than the query string, as a bcdn_token=… segment ahead of the video's own path. Bunny's playlists refer to their renditions, audio and segments by relative URL, and a query string doesn't survive being resolved against, so a token there would only ever reach the master playlist. In the path, every request the player makes after it carries the token too. The token covers the video's whole directory, so it's one token for the lot.
A token is the URL-safe base64 of SHA256(securityKey + path + expires), and it covers exactly the path it was signed over. BunnyMate signs each URL as narrowly as it can: an MP4 rendition's token opens that rendition and nothing else.
HLS is the exception. A master playlist only names per-rendition sub-playlists, and those name segments, each fetched as its own request with no query string inherited from the manifest. So an HLS URL is signed over the video's directory instead, which Bunny extends to everything beneath it, nested paths included. BunnyMate's player then puts the same token back onto every request hls.js makes.
That makes an HLS token broader than it looks. It opens every file for that video, the unencoded original among them. Where that matters, serve MP4 renditions rather than HLS, since their tokens are per-file.
There's no equivalent of a signed claim either: a resolution cap is something the markup asks for, never something the CDN enforces.
The library's Embed view token authentication is a separate switch, guarding the iframe player at iframe.mediadelivery.net rather than the playback files. Set playerTokenAuthEnabled to match it; the two are enabled independently, and turning on one doesn't turn on the other.
It's signed differently — a hex digest over the video GUID, where a CDN token is base64 over a path — but keyed on the same pull zone security key, so tokenAuthKey is needed for either. A library configured with playerTokenAuthEnabled and no tokenAuthKey is rejected rather than silently emitting URLs the player won't accept.
Signed URLs and caching
A token carries an expiry, which puts signed URLs at odds with cached HTML: a token written into a {% cache %} block outlives itself, and the page then serves 403s for the rest of that cache's life.
Set deferSignedUrls to true and BunnyMate stops signing at render time. Site requests render a placeholder instead, and the real token is minted as the response is prepared — after any template cache has been read from or written to. Caches therefore store the placeholder, and each visitor gets a token minted for them. This holds through json_encode and HTML escaping, so URLs carried inside a data-sources attribute are substituted like any other, and it covers JSON responses as well as HTML.
It's off by default, because it only rewrites the response body. A URL that is rendered but doesn't travel in the response keeps its placeholder and reaches its reader unusable — an email built from a template during a site request is the obvious case. Before turning it on, check where else signed URLs are rendered.
Two things are unaffected either way. Anything BunnyMate fetches itself — the MP4 rendition probe, the original-size check, the download controller — signs immediately, as do control panel requests, which are never template-cached. thumbnailUrl also always signs immediately, because it's routinely handed to a server-side transformer like Imager, which would otherwise fetch an unsigned placeholder.
And neither setting can help a page served without booting Craft at all: full static caching at the web server or a CDN. There's no response to rewrite, so such a cache has to expire well inside signedUrlDuration. Where a plugin caches the response itself, which of it and BunnyMate runs first decides whether the placeholder or the token gets stored — worth checking before relying on it.
Placeholder files
Videos uploaded straight to Bunny have no bytes in the volume, which Craft's asset indexer treats as a missing file. That matters more than it sounds: the indexer's review dialog pre-selects every missing asset and its primary button deletes them, which would take the Bunny videos with them.
So an empty file is written at the asset's path when the upload starts, purely to give the indexer something to match. It has to be the asset's own filename, since a placeholder under any other name would itself be indexed as a new file.
The cost is that anything reading the asset's own file gets an empty one. Playback, thumbnails and metadata all come from Bunny, so they're unaffected. Set writePlaceholderFiles to false to turn this off.
Libraries with tusUploadsEnabled off never need one, since their videos are stored in the volume.
Known limitations
Deleting an asset outright removes its Bunny video. Moving one to the trash deliberately doesn't, since a trashed asset can be restored and a deleted video couldn't be: the video stays until the asset is either restored or purged for good.
When Craft's garbage collection eventually purges it, the video is deleted too. That needs a little care, because GC deletes elements with raw SQL and fires no element events, and Gc::run() hard-deletes before it fires EVENT_RUN. So the videos table's foreign key is ON DELETE SET NULL rather than a cascade: purging an asset leaves the row behind with its video ID intact and a null assetId, which is an unambiguous marker that the video outlived its asset. BunnyMate clears those on Gc::EVENT_RUN, which runs immediately afterwards.
That distinction matters. A null row is definitely a purged asset's video, never a video someone uploaded through Bunny's dashboard, so nothing has to guess and nothing needs scheduling. A video that fails to delete keeps its row and is retried on the next run.
If the uploader can't confirm in time that a folder is set up for Bunny Stream, it lets Craft handle the upload normally. The video still reaches Bunny via the fetch fallback, just without bypassing PHP's upload limits.
Console commands
bunnymate/videos/create-missing
Sends video assets in mapped volumes that have no Bunny video yet to Bunny Stream, by queuing a job for each that has Bunny fetch the file from the asset's URL. It's for a volume that already held videos when it was mapped to a library. It reports what it found per volume and asks before queuing anything, and assets that already have a video are skipped, so it's safe to re-run and to interrupt.
php craft bunnymate/videos/create-missing
Bunny fetches over the public internet, so this has to run where the assets' URLs are publicly reachable. See Videos that were already there.
bunnymate/videos/refresh-existing
Refreshes every video asset that has a Bunny video, exactly as the Refresh button in the Bunny Stream panel does: status, metadata, which MP4 renditions exist and how large each file is. Videos Bunny no longer has are marked missing. It reports how many videos it found and asks before refreshing any, unless it's run without a terminal.
php craft bunnymate/videos/refresh-existing
Worth running after upgrading to a version that stores something new about videos, since existing ones only pick it up the next time they're refreshed. It talks to Bunny directly rather than queuing: one API request per video, plus a HEAD request per MP4 rendition and one for the original.
It's also worth scheduling, say nightly. Bunny sends no webhook when a video is deleted, so a refresh is the only way BunnyMate finds out:
php craft bunnymate/videos/refresh-existing
Run without a terminal, as cron and Forge's scheduler run it, the prompt is skipped and the refresh goes ahead, so nothing needs adding. --interactive=0 does the same anywhere, and at a terminal the prompt defaults to yes.
Viewing stats (views, averageWatchTime and totalWatchTime) are refreshed along with everything else, but a change to those alone doesn't count as the video changing, so a scheduled run doesn't clear cached pages just because videos were watched.
Both commands take the same options:
| Option | |
|---|---|
--volume |
Only this volume. Must be one that's mapped to a library. |
--limit |
Stop after this many assets, for working through a large volume in batches |
--dry-run |
Report what would be done, and do nothing |
Settings reference
Every setting goes in config/bunnymate.php. Credentials and hostnames can be environment variables, e.g. '$BUNNY_API_KEY'.
General
| Setting | Default | |
|---|---|---|
pullingEnabled |
true |
Whether bunnyPullUrl() returns pull zone URLs at all. Off, it returns the path on the site's own origin. Asset URLs on a Bunny Storage filesystem ignore this, since those files have no origin to fall back on. |
pullZones |
[] |
Pull zones, keyed by a handle of your choosing. See Pull zones. |
defaultPullZone |
null |
The handle of the pull zone bunnyPullUrl() uses when none is given |
apiKey |
null |
The account API key from the Bunny dashboard, used to purge the CDN cache. Not a storage zone password. |
purgeEnabled |
true |
Whether files changed on a Bunny Storage filesystem are purged from the CDN cache. Individual URLs only, never the whole pull zone. |
Bunny Stream
| Setting | Default | |
|---|---|---|
videoLibraries |
[] |
Video libraries, keyed by a handle of your choosing. See Video libraries. |
volumeVideoLibraries |
[] |
Maps volume handles to library handles. This is what turns Bunny Stream on: volumes not listed are left alone. |
autoUploadVideos |
true |
Whether videos reaching a mapped volume other than through the TUS uploader are sent to Bunny, which fetches them from the asset's URL. See Videos that arrive some other way. |
writePlaceholderFiles |
true |
Whether an empty file is written for videos uploaded straight to Bunny, so Craft's asset indexer doesn't list them as missing. See Placeholder files. |
maxConcurrentUploads |
1 |
How many videos the control panel uploader sends to Bunny at once. The rest of a batch is queued. |
overrideAssetUrls |
true |
Whether asset.url returns the Bunny playback URL for videos. See Asset URLs. |
videoUrlRendition |
null |
Which MP4 rendition asset.url points at, e.g. '1080p'. Null uses the highest one produced. |
hlsJsUrl |
jsDelivr's hls.js@1 |
Where the player loads hls.js from, for adaptive playback outside Safari. Null never loads it, and the player falls back to an MP4 rendition. |
defaultMinResolution |
null |
The lowest rendition adaptive playback settles on, e.g. '720p'. HLS only. |
defaultMaxResolution |
null |
The highest rendition adaptive playback uses. HLS only. |
lazyloadBunnyVideo |
true |
Whether rendered video tags hold their sources in data-src until scrolled into view |
deferSignedUrls |
false |
Whether signed playback URLs are signed as the response goes out rather than when rendered, so an expiring token never lands in a {% cache %} block. See Access control. |
allowOriginalDownloads |
true |
Whether original files can be downloaded from the front end. Control panel downloads aren't affected. |
downloadPath |
'bunnymate/download' |
Where front-end downloads are served from: downloadUrl points at /{downloadPath}/{videoGuid}, plus /{resolution} for a rendition. null uses an action URL instead. An array keyed by site handle sets a path per site, with '*' as the fallback. See The original file. |
useImagerForThumbnailTransforms |
true |
Whether control panel thumbnails are resized with Imager X, where it's installed. See Control panel thumbnails. |
imagerTransformDefaults |
null |
Passed to Imager's transformImage() as transform defaults when resizing a thumbnail |
imagerTransformConfigOverrides |
null |
Passed to Imager's transformImage() as config overrides when resizing a thumbnail. curlOptions merges with the referrer BunnyMate sets. |
Video libraries
Each entry in videoLibraries:
| Key | Default | |
|---|---|---|
id |
— | The library's ID in Bunny. Required. |
apiKey |
— | The library's API key. Grants write access, so it stays server-side. Required. |
readOnlyApiKey |
— | The library's read-only API key, which doubles as the webhook signing secret. Required. |
hostname |
— | The library's playback hostname, e.g. vz-xxxxxxxx-xxx.b-cdn.net. Required. |
tokenAuthKey |
null |
The pull zone's security key, needed when CDN token authentication is enabled on the library |
playerTokenAuthEnabled |
false |
Set to match the library's embed view token authentication. Needs tokenAuthKey. |
signedUrlDuration |
3600 |
How long signed URLs stay valid: seconds, or a date interval string such as 'PT1H' |
optimizerEnabled |
false |
Set when Bunny Optimizer is enabled on the library's pull zone, so thumbnails can be requested at the size wanted |
tusUploadsEnabled |
true |
Whether the control panel uploader sends this library's videos straight to Bunny over TUS. Off leaves its volumes to Craft's own uploader. See Uploading. |
collectionId |
null |
A collection in the library to create videos in |
Pull zones
Each entry in pullZones:
| Key | Default | |
|---|---|---|
hostname |
— | The pull zone's hostname, optionally with a path prefix. Required. |
enabled |
true |
Whether bunnyPullUrl() uses this pull zone. Off, it behaves as pullingEnabled off does, and Bunny Storage filesystems ignore it the same way. |
Bunny Storage filesystem
These are set on the filesystem in the control panel, under Settings → Filesystems, rather than in config/bunnymate.php. Every field takes an environment variable.
| Field | |
|---|---|
| Storage zone | The storage zone's name |
| Access key | The storage zone's password, not the account API key |
| Region | The storage zone's primary region |
| Subfolder | An optional folder within the zone to keep this filesystem's files in |
| Pull zone | The pull zone asset URLs are built from, from pullZones |
Price, license and support
The plugin is released under the Craft license and could be subject to license fees. It's made for Værsågod and friends, and no support is given. Submitted issues are resolved if it scratches an itch.
Changelog
See CHANGELOG.md.