Images, video and PDF
Images, variants and watermarks
Laracrate processes every image you upload into an optimized original plus any number of resized variants (thumbnails, medium, large, and so on). Variants are generated asynchronously in the pipeline, so the user's upload stays instant. Each variant is a real File row of its own with parent_id pointing at the original, type = image, and a variant name, so you can query and serve it like any other file. The original is never destroyed by variant generation, and the original is never watermarked.
You declare variants per file type inside a collection's config. The orchestrator (GenerateImageVariantsAction) iterates types.image.variants for the collection and dispatches GenerateImageVariantAction once per definition. A failure in one variant is logged and skipped, the rest still generate. A 360° photo (mode sphere) and a strip of frames (mode frames) are type image: they go through the same steps and read the same image block. The type's 'preview' => 'thumbnail' names the variant $file->preview() and preview_link return (see the Video and PDF variants section).
// config/laracrate.php 'collections' => [ 'gallery' => [ 'disk' => 'media', 'access' => 'public', 'types' => [ 'image' => [ 'variants' => [ 'thumbnail' => ['width' => 300, 'height' => 300, 'fit' => true], 'medium' => ['width' => 800, 'height' => 800], 'large' => ['width' => 1600, 'height' => 1600], ], ], ], ], ],
Where a variant lives
The variant binary is written under a variants/ subdirectory next to the original, using $file->variantKey($newName). The variant filename is the original base name plus the variant name, for example photo_thumbnail.webp. The variant row is created with $file->createVariant($name, $overrides), which inherits the parent's scope. Regeneration is idempotent: if a variant with the same name already exists it is force-deleted and rebuilt.
To read variant keys back, see the key, variantKey, and createVariant helpers covered in the Data model section, and use the model's variant accessors covered in the Working with files from your models section.
Variant options
Each variant definition accepts these keys. Per-variant values win, then the collection's types.image level, then the global defaults.image level, then a hardcoded fallback.
| Option | Type | Default | Meaning |
|---|---|---|---|
width |
int or null | null |
Target width. |
height |
int or null | null |
Target height. |
fit |
bool | false |
true crops to fill the box (cover). false scales down preserving aspect ratio without enlarging (scaleDown). |
quality |
int | 80 |
Encoder quality (0-100). |
format |
webp or jpg |
webp |
Output format. webp writes image/webp, jpg writes image/jpeg. |
watermark |
bool | false |
Bake the watermark into this variant. See below. |
Quality resolution is slightly special: a variant's own quality wins, otherwise types.image.variant_quality, then defaults.image.variant_quality (default 85), then the normal quality cascade.
Optimizing the original
The original is left untouched unless you opt in. OptimizeImageAction re-encodes a top-level image to WebP and downscales it to a maximum bounding box. It is a no-op for variants (variants are already generated optimized) and only runs when image.optimize_originals is true or the collection requests it.
// config/laracrate.php 'image' => [ 'driver' => 'imagick', // 'imagick' (recommended) or 'gd' 'optimize_originals' => false, 'max_width' => 1920, 'max_height' => 1920, 'quality' => 85, ],
The image.driver value selects the Intervention Image backend (imagick or gd) for all image work: optimization, variant generation, and watermarking. Image dimensions are captured separately by ExtractImageDimensionsAction and stored on width and height (it reads the binary with getimagesizefromstring and is a no-op once both are set).
Watermarks
A watermark is baked into the binary of specific variants at generation time. You opt in per variant with 'watermark' => true in that variant's definition. The original master file never carries a watermark, only the variants that explicitly ask for one.
// config/laracrate.php 'collections' => [ 'identity' => [ 'disk' => 'documents', 'access' => 'private', 'sensitive' => true, 'types' => [ 'image' => [ 'variants' => [ 'thumbnail' => ['width' => 300], // no watermark 'display' => ['width' => 1200, 'watermark' => true], // watermarked ], ], ], ], ],
The watermark itself is configured once in the top-level watermark block. It can overlay a PNG, a text string, or both. If neither a PNG nor text is configured, applying the watermark is a no-op.
// config/laracrate.php 'watermark' => [ // PNG to overlay. Absolute path, or relative to public_path(). null skips the image. 'image_path' => env('LARACRATE_WATERMARK_IMAGE', null), // Width as a fraction of the variant width (0.0 - 1.0). 0.40 = 40% of the width. 'size' => 0.40, // PNG opacity (0-100). 'opacity' => 30, // 'center' | 'top-left' | 'top-right' | 'bottom-left' | 'bottom-right'. 'position' => 'center', 'text' => [ // null = no text, a string = fixed text, or a closure(File): ?string for dynamic text. 'content' => null, // Font size as a fraction of the image width. 'font_size_ratio' => 0.0195, // CSS rgba color. 'color' => 'rgba(255, 255, 255, 0.60)', // 'bottom-left' | 'bottom-right' | 'top-left' | 'top-right'. 'position' => 'bottom-left', // Padding from the edge, in pixels. 'padding' => 20, // Path to a .ttf font. null uses the system font. 'font_path' => null, ], ],
The text content closure receives the File and may return null to skip the text, which is useful for stamping per-file data (an owner id, an order number) onto the watermark. Because a closure cannot live in env, set it by publishing the config or in a service provider.
Video and PDF variants
Videos and PDFs get variants too, declared in the type's variants like the image ones and named by your app. Each one says what it is with type:
type |
Of | What it makes |
|---|---|---|
video (the default for a video) |
a video | An MP4 copy, or a teaser when it has clips or a generator |
image |
a video | One frame, as a JPEG |
frames |
a video | A strip of frames in one JPEG, stored as type image with mode frames |
image |
a PDF | One page, as a PNG |
They run in the pipeline (GenerateVideoVariantsStep, GeneratePdfVariantsStep) and need external binaries (ffmpeg and ffprobe for video, an engine for PDF), so they are opt-in per collection. Every variant is a child File under its name; a variant's own variants are made from it, so teaser.mini is a copy of the teaser and portada.mini a resize of the frame. A bare name ('web') is a variant with no options. Regenerating replaces each variant.
Video
Dimensions and duration are read first with ffprobe (ExtractVideoDimensionsAction, stored on width, height, duration). Then the variants:
// config/laracrate.php 'collections' => [ 'deportes' => [ 'disk' => 'media', 'access' => 'private', 'types' => [ 'video' => [ 'accepted_extensions' => ['mp4', 'mov'], 'max_file_size' => 512000, 'variants' => [ // MP4 copies 'hd' => ['max_height' => 1080], 'sd' => ['max_height' => 480, 'bitrate_kbps' => 800], // a frame, and a small copy of it 'portada' => [ 'type' => 'image', 'frame_at' => '00:00:03', 'width' => 1280, 'variants' => ['mini' => ['width' => 300]], ], // a strip of 6 frames spread across the video 'escenas' => ['type' => 'frames', 'frames' => 6, 'width' => 320], // 5 one-second fragments joined, no audio, and a smaller copy of it 'teaser' => [ 'type' => 'video', 'clips' => 5, 'seconds' => 1, 'audio' => false, 'variants' => ['mini' => ['max_height' => 360]], ], ], 'preview' => 'portada.mini', ], ], ], ],
Uploading gol.mov leaves:
#50 gol.mov (original, untouched)
├─ #51 hd mp4
├─ #52 sd mp4
├─ #53 portada jpg (frame at 0:03)
│ └─ #54 mini jpg (300 px)
├─ #55 escenas jpg (type frames)
└─ #56 teaser mp4
└─ #57 mini mp4 (the teaser at 360)
<video controls src="{{ $video->variant('hd')->url() }}" poster="{{ $video->preview_link }}"></video> <video muted loop playsinline src="{{ $video->variant('teaser.mini')->url() }}"></video>
What each kind takes:
- Copy (
video, noclipsorgenerator):max_width,max_height,bitrate_kbps, defaulting tolaracrate.video. The copy is H.264 / AAC MP4, 8-bit 4:2:0, HDR tone-mapped to SDR and with its index at the start, so it plays in every browser. It is skipped when the original already plays in every browser and fits its size:variant()then falls back to the original. - Teaser (
videowithclipsorgenerator):clipsfragments (5) ofsecondseach (1), spread evenly over the video, joined into one MP4 within the samemax_*andbitrate_kbps.audio(false) keeps the sound when the video has any. Its metadata lists the fragments:{clips: [[start, length], ...]}. - Frame (
image):frame_atas a time ('00:01:20'), seconds (3.5) or a share of the length ('20%'),00:00:01by default; a moment past the end takes the last frame.widthandheightscale it. Its metadata keepsat. - Strip of frames (
frames):frames(6) spread evenly, each scaled towidth(320) orheight, side by side or in rows ofcolumns. Its metadata says how to cut it:{frames, columns, rows, width, height, at: [...]}, with the width and height of one frame. A strip is one image, so a hover preview moves the background through it with a single download.
Who picks the moments. A teaser or a strip of frames can take its moments from a class of yours, an AI looking for the action or the scenes an editor marked, with generator. Laracrate only cuts and joins:
'teaser' => ['type' => 'video', 'audio' => false, 'generator' => App\Media\AiTeaser::class],
use EduLazaro\Laracrate\Contracts\MomentsGenerator; class AiTeaser implements MomentsGenerator { /** @return list<array{at: float, seconds?: float}> start and length, in seconds */ public function generate(File $video, array $spec): array { return [['at' => 12.0, 'seconds' => 2], ['at' => 47.5, 'seconds' => 1.5]]; } }
A moment outside the video is dropped, a missing seconds takes the variant's seconds, and a strip of frames reads only at. When the generator throws or returns nothing usable, the moments are spread evenly and the failure is logged.
Replacing the original. 'transcode' => true on the video type still re-encodes the original in place to the same browser-safe MP4 (TranscodeVideoStep, before the variants), and its dimensions are re-read. Use it when nobody needs the upload as it was; declare a copy in variants when the original must survive (evidence, quality, metadata).
A document variant with 'type' => 'image' renders one page of a PDF to a PNG (RenderPdfPageAction):
'documents' => [ 'disk' => 'documents', 'access' => 'private', 'types' => [ 'document' => [ 'variants' => [ 'portada' => [ 'type' => 'image', 'page' => 1, // 1-indexed, 1 by default 'width' => 2000, // downscale only, 2000 by default 'variants' => ['thumbnail' => ['width' => 300], 'medium' => ['width' => 800]], ], ], 'preview' => 'portada.thumbnail', ], ], ],
The rasterization engine is selectable with pdf_preview_engine, or per variant with engine (and resolution, 150 dpi by default):
| Engine | Requires | Notes |
|---|---|---|
pdftoppm |
poppler-utils (pdftoppm binary) |
No Ghostscript and no ImageMagick policy.xml changes. Downscaling falls back to GD. |
imagick |
PHP imagick extension, Ghostscript (gs), and PDF enabled in ImageMagick's policy.xml |
Reads the PDF through Ghostscript. |
auto |
either of the above | Tries pdftoppm first, falls back to imagick if it is not available. |
When the engine is forced to pdftoppm and the binary is missing or fails, the page is logged and skipped rather than falling back.
preview: the variant that stands for a file
A type's preview names one of its variants, with dot notation for a nested one. It is a pointer, not a variant of its own:
'video' => ['variants' => [...], 'preview' => 'portada.mini'], 'image' => ['variants' => ['thumbnail' => ['width' => 300]], 'preview' => 'thumbnail'],
$file->preview()returns that variant, ornullwhen the type names none or it does not exist yet (the queue has not made it, or it failed). It never falls back to the original.$file->preview_linkispreview()'surl('image'); for an image without a preview, the image itself; for anything else, the image placeholder. The status endpoints answer it aspreview.
Upgrading a 1.x config
The 1.x settings fail with a message that says what to write instead (InvalidArgumentException when the variants or the preview are read, so the file's processing fails with it):
| 1.x | 2.0 |
|---|---|
'preview' => ['frame_at' => '00:00:01', 'variants' => [...]] |
'variants' => ['preview' => ['type' => 'image', 'frame_at' => '00:00:01', 'variants' => [...]]], 'preview' => 'preview.thumbnail' |
'preview' => ['page' => 1, ...] on a document |
the same, with 'type' => 'image', 'page' => 1 |
'transcode' => 'variant' and $file->playback() |
'variants' => ['playback'] and $file->variant('playback') |
Keeping the 1.x names (preview, playback) in the new config keeps the rows already generated under them.