sybio/gif-frame-extractor
PHP library to extract and split frames from animated GIFs. Read frame durations, colors, dimensions and disposal methods without Imagick, then export frames as separate GIF, JPEG or PNG images for thumbnails, previews or processing.
Install and confirm the extension
composer require sybio/gif-frame-extractor
php -m | grep -i gd # needs GD with GIF support
There is no service provider, facade, or config file — it is a single plain PHP class (Sybio\GifFrameExtractor\GifFrameExtractor) plus an exception class. That means zero Laravel integration work and also zero framework help: you own the wiring.
Where to look first
src/GifFrameExtractor/GifFrameExtractor.php — skim extract(), getFrames(), getFrameImage(), getFrameDuration(), isAnimatedGif(). It is a few hundred lines; you can read all of it.GifFrameExtractorException — the only error type you need to catch.First use case: a poster image for uploaded GIFs
The highest-value day-one use is generating a static poster/thumbnail so every GIF in your app has a real image for OG tags, feed previews, and loading="lazy" placeholders.
use Sybio\GifFrameExtractor\GifFrameExtractor;
$path = Storage::disk('local')->path($gifPath); // MUST be a local filesystem path
if (GifFrameExtractor::isAnimatedGif($path)) {
$extractor = new GifFrameExtractor();
$extractor->extract($path); // must be called first
$frame = $extractor->getFrameImage(0); // GD image (GdImage in PHP 8+)
ob_start();
imagepng($frame);
$png = ob_get_clean();
Storage::disk('public')->put("posters/{$id}.png", $png);
imagedestroy($frame);
}
That is the whole loop: isAnimatedGif() → extract() → getFrameImage()/getFrameDuration() → your own persistence.
Wrap it, don't sprinkle it. Put the library behind one small service so the rest of the codebase never touches GD handles or the extractor's stateful API:
final class GifFrameService
{
public function frames(string $localPath, int $limit = 24): array
{
$ex = new GifFrameExtractor();
$ex->extract($localPath);
return collect($ex->getFrames())
->take($limit)
->map(fn ($f, $i) => [
'index' => $i,
'duration_ms' => $ex->getFrameDuration($i) * 10, // centiseconds
'png' => $this->encodePng($ex->getFrameImage($i)),
])->all();
}
}
Bind it in a service provider and inject it into jobs/controllers. Because the extractor holds frame state on the instance, always construct a fresh one per file — do not make it a singleton.
Queue the work, always. Extraction is CPU- and memory-bound and takes tens to hundreds of milliseconds. Trigger it from an UploadedGif event into a queued job, write derived artifacts to Storage, and record their paths on the model. On re-render, read the cached artifact — never re-extract on a page request.
Upload flow that stays clean
mimes:gif plus an explicit isAnimatedGif() check in a FormRequest rule (static GIFs are a common surprise and should skip the pipeline entirely).Storage::path() (local disks only) into the extractor. For S3 or any remote disk, Storage::get() to a tmpfile()/tempnam() first — the library does not read streams.getTotalDuration()), and the poster frame as columns. These are cheap to query and save you re-opening files forever.Frame selection heuristics. Frame 0 is often a blank or logo frame in real-world GIFs. Two better options: pick the frame with the longest getFrameDuration() (usually the "held" visual), or sample e.g. 3 evenly spaced frames. getFrameImageSrc($i) returns a data: URI, which is convenient for an admin preview grid without touching the filesystem.
Contact sheet / sprite generation. Loop the frames and imagecopy() each into one canvas at fixed tiles. This is the natural fit for feed previews and is much smaller than shipping N PNGs. Keep the tile count bounded.
Downscale before you store. Extracted frames are full GIF dimensions. Run each through imagescale() (or Intervention Image if it's already in the project) before encoding; storing 800×800 PNGs from a 60-frame GIF will fill a disk fast.
Durations are centiseconds. GIF stores delay in 1/100s. Multiply by 10 for milliseconds. Many GIFs ship with 0 or 2 for frames, which browsers silently clamp — don't trust totals for anything user-facing without clamping.
Frame compositing is the weak spot. GIF frames are often stored as differences from the previous frame, with disposal methods deciding what's restored. Output for frame 2+ on optimized or transparent-heavy GIFs can look torn, doubled, or partial. Always eyeball a few real uploads from your user base before trusting the pipeline, and treat a wrong result as a library limitation rather than a bug you introduced. If fidelity matters, keep an Imagick or ffmpeg path behind a feature flag and compare.
Memory. Each frame is a full decoded GD image held on the extractor. A 500×500, 200-frame GIF is ~200 MB of raw bitmap. Set a hard frame cap (take($limit) after extraction is too late for memory — check frame count early or bound input dimensions), raise memory_limit in the job, and imagedestroy() every handle you create.
PHP 8 resource/GdImage split. In PHP 8+, imagecreatefromgif returns GdImage, not resource. Any wrapper code doing is_resource($img) type checks will silently break on upgrade. Check for GdImage|resource or just truthiness.
Order matters. Calling getFrameImage() before extract() throws GifFrameExtractorException. Also note isAnimatedGif() itself throws if the file does not exist — wrap it in try/catch rather than if (file_exists()) alone, since paths from storage can race.
Transparency. GIFs use a transparent palette index, not alpha. Convert to PNG and preserve transparency via imagesavealpha()/imagealphablending(false) on the destination, otherwise transparent regions come out black — the single most common visual complaint.
Don't trust save(). The built-in save helper writes files with its own naming and directory assumptions. Encoding the GD handle yourself with imagepng()/imagejpeg() into Storage gives you predictable paths, CDN-friendly keys, and no surprise writes outside your storage roots.
No extension points. It is a closed, single-class library with no hooks, events, or strategy interfaces. Treat it as an internal implementation detail behind your own interface — that way swapping to Imagick, ffmpeg, or a maintained fork later is a one-file change. It also has zero dependents on Packagist and receives little maintenance, so budget for the possibility of vendoring it.
Testing. Commit 3–4 small GIF fixtures: a simple animation, a transparent one, a static GIF (should return false), and one with long frame delays. Assert frame count and durations — those are stable. Do not snapshot-compare pixel output; it varies across GD builds.
How can I help you explore Laravel packages today?