Media Library
senen is a port of Laravel’s spatie/laravel-medialibrary: it attaches
files to any Seshat model, groups them into named collections, carries
arbitrary custom properties, keeps them ordered, and generates image
conversions on the Khepri queue.
Everything senen tracks about one stored file lives in a single media
row and its corresponding Media model.
The media Table
| Column | Type | Notes |
|---|---|---|
id |
id | Primary key |
model_type |
string | The owning model’s morph name |
model_id |
string | The owning model’s key, as text |
uuid |
uuid | Unique |
collection_name |
string | Defaults to 'default' |
name |
string | Human-readable name |
file_name |
string | Name of the file on disk |
mime_type |
string | Nullable |
disk |
string | Disk the original file lives on |
conversions_disk |
string | Disk generated conversions live on |
size |
bigint | File size in bytes |
custom_properties |
json | Arbitrary metadata |
generated_conversions |
json | See below |
order_column |
integer | Nullable, indexed |
created_at |
timestamp | Nullable |
updated_at |
timestamp | Nullable |
model_type and model_id are indexed together, so looking up a model’s
media is a single indexed lookup. model_id is a string rather than an
integer so both integer and UUID primary keys work as owners.
The Media Model
final media = Media.def.query().find(1);Media follows the same immutable model pattern as every other Seshat
model: a plain constructor, a static ModelDefinition<Media> def, and a
toMap() that mirrors it back. See the Seshat docs for the
general shape.
custom_properties
Freeform metadata attached to the file, decoded as a Map<String, Object?>:
media.customProperty('alt'); // reads a keymedia.customProperty('alt', 'no alt text'); // with a fallbackgenerated_conversions
Maps a conversion name to the file extension it actually produced, not to a boolean:
{'thumb': 'jpg', 'preview': 'png'}A conversion can emit a different format than its source — a PNG preview
generated from a JPEG original, for instance. Recording only true would
force whatever builds the conversion’s URL to guess the extension, and it
would guess wrong for exactly the files that don’t match their source
format.
media.hasGeneratedConversion('thumb'); // truemedia.conversionExtension('thumb'); // 'jpg'Declaring Collections and Conversions
A model opts into the media library with HasMedia and declares its
collections and conversions on a MediaSchema:
class Post extends Model<Post> with HasMedia { static final MediaSchema media = MediaSchema() ..collection( 'images', singleFile: true, accepts: ['image/jpeg', 'image/png'], fallbackUrl: '/img/placeholder.png', ) ..collection('attachments') ..conversion('thumb', (c) => c.fit(368, 232)) ..conversion('preview', (c) => c.width(1200).quality(80), queued: false) ..conversion('doc_cover', (c) => c.width(200), collections: ['attachments']);
@override MediaSchema get mediaSchema => media;}Why a static plus a one-line forwarder
Laravel’s Spatie package declares collections by overriding
registerMediaCollections(), which works because the base class calls the
override at runtime. Dart has no such hook, and Dart does not inherit
statics, so HasMedia cannot expose the schema as a static member itself.
Instead it declares an abstract mediaSchema getter, and every model
satisfies it with a one-line forwarder to its own static final. This is
the exact idiom Seshat already uses for def
(@override ModelDefinition<Post> get definition => def;), and for the
same reason.
Being a static, the schema is readable without constructing a model –
Post.media.collectionNames works with no instance – which is what keeps
collections and conversions enumerable for tooling (an MCP server, a
console command listing every model’s media setup, and so on).
Collections
collection(name, ...) declares one collection:
singleFile: true– attaching a new file replaces the existing one instead of adding to it (an avatar, a single cover image).accepts: [...]– a MIME type allowlist. Empty (the default) means no restriction.fallbackUrl– a URL to show when the collection is empty, so a view never has to special-case “no image yet”.disk/conversionsDisk– override where the collection’s originals and generated conversions are stored;nullfalls back to the model’s default disk.
An undeclared collection is legal. collectionFor('whatever') returns a
MediaCollection with default rules (not single-file, no MIME
restriction) rather than throwing – matching Spatie, where
toMediaCollection('whatever') just works with no prior registration.
There is always a 'default' collection for the same reason: media
attached without naming a collection has somewhere to live.
Declaring the same collection name twice is a mistake, not a valid
“redeclare to change the rules” pattern – it throws a StateError naming
the collection and telling you to remove the duplicate call.
Conversions
conversion(name, build, {queued, collections}) declares a named image
conversion. build receives a fresh Conversion recipe and returns it
after calling fluent methods on it:
c.width(1200) // resize, preserving aspect ratioc.height(800)c.fit(368, 232) // resize and crop to exactly this sizec.quality(80) // 1-100, defaults to 82c.format(ImageFormat.webp) // re-encode regardless of source formatConversion is deliberately just a recipe – a bag of fields, no image or
file I/O. It is looked up by name at execution time (Tasks 6-8 do the
actual generating), so a queued job’s payload carries the conversion’s
name, never a closure.
queued defaults to true: conversions run on the Khepri queue after the
original upload, not inline in the request. Pass queued: false for a
conversion cheap enough, or urgent enough, to generate synchronously.
Scoping – collections: [...] controls which collections a
conversion applies to:
- An empty list (the default) means the conversion applies to every collection on the model.
- A non-empty list scopes it to just those collections.
So in the example above, thumb and preview run for both images and
attachments, while doc_cover only runs for attachments.
conversionsFor(collectionName) returns the specs that apply, in
declaration order.
Declaring the same conversion name twice is also a StateError naming the
conversion and the fix, for the same reason as collections: it is very
likely a copy-paste mistake, not an intentional redefinition.
Attaching a File
A model opts in with HasMedia and a mediaSchema:
class Post extends Model<Post> with HasMedia { static final MediaSchema media = MediaSchema() ..collection('images', singleFile: true, accepts: ['image/jpeg', 'image/png']) ..collection('docs');
@override MediaSchema get mediaSchema => media;}Attach a file through post.media:
final media = await post.media .add(uploadedFile) // from a multipart request .toCollection('images');
final media = await post.media .addBytes(bytes, fileName: 'report.pdf', mimeType: 'application/pdf') .withCustomProperties({'author': 'A. Ghanem'}) .usingName('Q3 Report') .toCollection('docs');add(UploadedFile) reads file.bytes, file.name and file.mimeType.
addBytes is the lower-level entry point for content that didn’t arrive
through an HTTP upload – generated PDFs, files fetched from another
service, seed data.
Both return a PendingMedia, a small builder with four chained calls:
usingName(String)– sets the human-readablenameshown in the UI. Defaults to the sanitised file name without its extension.withCustomProperties(Map)– arbitrary metadata, stored as JSON and read back withmedia.customProperty('key').toDisk(String)– overrides the collection’s (or model’s) default disk for this one file.toCollection([name = 'default'])– the terminal call. Validates, writes themediarow, then writes the file, and returns the persistedMedia.
Attaching to a singleFile collection replaces whatever was there, so the
collection never holds more than one row.
The replacement is added first and the old one trimmed afterwards, which
is the order Spatie uses and the order that matters. toCollection reads
the rows already in the collection, writes the new row, writes the new file,
and only then deletes the superseded rows and their files. Nothing that is
already there is destroyed until its replacement is durable, so a failed
avatar upload leaves the user with the avatar they had – not with none.
The two failure modes that remain are both recoverable:
- The new file’s write fails. The row just inserted is deleted again and the error is rethrown, so a failure never leaves an orphaned row with no file behind it. The old file and row are untouched.
- Deleting a superseded file fails after the replacement is written. The collection is briefly left holding two rows rather than one, with no defined order between them, and the next successful attach clears both. A stale extra row is a better outcome than an empty collection, which is why the ordering runs this way round.
Why post.media, not post.addMedia()
Seshat models are immutable – save() and update() return a new
instance rather than mutating this. A method that reached out over the
network, wrote a database row and a file, and then left the receiving
post completely unchanged would misreport what it did: the returned
value (a Media), not post, is the thing that changed. post.media is
instead a small stateless accessor – MediaLibrary – that carries the
model’s schema and identity and does the actual work. It reads naturally
(post.media.add(...)) without implying that post itself was mutated.
Sanitisation and MIME Rules
Two rules are enforced on every attach, not just validated as a convenience:
-
The file name is sanitised, unconditionally.
PendingMedia.sanitisestrips any directory component (p.basename, after normalising\to/) and replaces anything that isn’t[A-Za-z0-9._-]with_. Afile_nameof../../etc/passwdis stored aspasswd– it can never climb out of the{media_id}/directory it’s written into. -
The collection’s declared MIME types are checked against the type the caller declared, not against the file’s content. A collection with
accepts: ['image/jpeg', 'image/png']rejects anything else with aStateErrornaming both the offending type and the accepted list.Be clear about what that does and does not buy you. For an upload, the type checked is
UploadedFile.mimeType– the multipart part’scontent-typeheader, which the client wrote. It is client-controlled and exactly as spoofable as the file extension: an attacker who renamesevil.shtophoto.jpgcan just as easily sendContent-Type: image/jpegwith it. Nothing inguardMimeTypereads a magic number, so this is a declaration check, not a content check. What it is good for is keeping an honest client from posting the wrong thing, and making a mismatch between the two declarations visible; what it is not is a guarantee about the bytes. A caller that needs certainty about what a file actually is must sniff the bytes itself before attaching. Content sniffing is a follow-up, not something v1 does.
Both are exercised by dedicated tests in attach_test.dart, not folded
into “happy path” assertions.
Path Layout
PathGenerator (a port of Spatie’s DefaultPathGenerator) computes where
a file lives, deliberately after the row exists:
{media_id}/{file_name}{media_id}/conversions/{file_name_without_extension}-{conversion}.{extension}Because the path is keyed by the row’s own id, toCollection inserts the
media row before writing the file – reordering that for tidiness would
write to a path containing null.
Retrieving Media and URLs
post.media also reads:
final all = await post.media.get('docs'); // every file, orderedfinal one = await post.media.first('images'); // the first, or null
final url = await post.media.url(one!); // original URLfinal thumbUrl = await post.media.url(one, 'thumb'); // conversion URL
final cover = await post.media.firstUrl('images'); // URL, fallbackUrl, or nullfinal thumb = await post.media.firstUrl('images', 'thumb');get and first reuse the same ordered query; nothing new to know there.
url and firstUrl
url(media, [conversion]) resolves a Media’s path through Storage:
- With no
conversion, it resolvesmedia.pathFor()onmedia.disk. - With a
conversionthat has been generated, it resolves the conversion’s path – built withconversionExtension(name), never a guessed extension – onmedia.conversionsDisk.
firstUrl([collection, conversion]) is the common case: the URL of the
first file in a collection, the collection’s declared fallbackUrl when
it’s empty, or null when there is neither a file nor a fallback.
Single-file collections replace, they don’t accumulate
Attaching to a singleFile collection a second time leaves exactly one
row behind – the new file, not the old one appended. The old row and its
files are purged after the replacement is written (see
“Attaching a File” above); retrieval_test.dart pins
the surviving row, and attach_test.dart pins both halves of the ordering:
a successful replace removes the old file, and a replace whose write fails
leaves the old file exactly where it was.
An ungenerated conversion falls back to the original – deliberately
If conversion is not yet present in generated_conversions –
because the queued job hasn’t run yet – url and firstUrl silently
fall back to the original file’s URL, on the original’s disk
(media.disk, not media.conversionsDisk).
This is a deliberate divergence from Spatie, which returns the conversion
URL regardless of whether it exists yet. That 404s until the queue drains.
Here, a page renders one large image instead of a broken one while the
worker catches up. Media.pathFor is where this lives:
String pathFor([String? conversion]) { if (conversion == null || !hasGeneratedConversion(conversion)) { return const PathGenerator().forOriginal(this); } return const PathGenerator() .forConversion(this, conversion, conversionExtension(conversion)!);}If this looks like a bug, it isn’t – it’s recorded and pinned by the test
'an ungenerated conversion falls back to the original'.
Ordering
setOrder writes a new position into order_column for every id given,
in the order given:
final a = await post.media.addBytes(...).toCollection('docs');final b = await post.media.addBytes(...).toCollection('docs');
await post.media.setOrder([b.id!, a.id!]);await post.media.get('docs'); // [b, a]Every id must already belong to this model. An id from another model’s
media is rejected with a StateError naming the offending id and listing
the ids this model actually owns – reordering across model boundaries
would silently corrupt another model’s collection, so it never happens
implicitly:
StateError: Media 7 does not belong to posts 1, so it cannot be orderedhere. Owned media: 3, 4, 5.Because Media rows are immutable, setOrder calls update() per row and
discards the returned instance – only the write matters, not a rebuilt
object the caller never asked for.
A newly attached file gets one past the highest order_column already in
the collection, as Spatie does – not the row count. The two differ as
soon as anything is deleted from the middle of a collection: with three
files at 0, 1, 2, deleting the middle one and attaching a fourth would give
it 2 by count, colliding with a position already taken and leaving
orderBy('order_column') to break the tie however the database likes. A
singleFile collection is the exception and always uses 0, since it holds
exactly one row.
Cascade Delete
Deleting a model does not, by itself, delete its media. senen has no way
to hook Model.delete() on its own: ModelEvents lives on
ModelDefinition, which is declared by the application’s model, not by
senen – reaching into it from here would make seshat, the standalone
ORM, aware media exists. So cascade delete is a function an application
wires in itself:
Future<void> deleteMediaFor(HasMedia model) async;wired into a model’s own def:
class Post extends Model<Post> with HasMedia { static final ModelDefinition<Post> def = ModelDefinition<Post>( table: 'posts', // ... events: ModelEvents(deleted: (post) => deleteMediaFor(post)), ); // ...}With that wired, post.delete() fires the deleted hook, which calls
deleteMediaFor(post). For every Media row owned by that model instance,
it deletes the row’s directory from media.disk, and – when a collection
declares a distinct conversionsDisk – the row’s directory from that disk
too, before deleting the row itself:
if (media.conversionsDisk != media.disk) { await Storage.disk(media.conversionsDisk).deleteDirectory('${media.id}');}Cascade delete is not transactional, and a partial failure is not
self-healing. The deleted event fires after the parent row is gone,
and deleteMediaFor walks the rows one at a time. If a disk throws
part-way through the loop, the parent is already deleted and the remaining
Media rows survive with nothing left to reach them from – and v1 ships no
media:clean to sweep orphans (it is explicitly out of scope). An
application that needs certainty here should either delete media explicitly
before deleting the model, or be prepared to clean up by model_type and
model_id itself.
Nothing runs unless the application wires the hook – this is opt-in by
design, not an oversight. A model that never adds events: ModelEvents(deleted: ...) keeps its media rows and files forever, even
after the model row is gone.
preserveMedia
A model whose preserveMedia getter returns true is left untouched by
deleteMediaFor, even when the deleted hook is wired – both the Media
rows and the files on disk survive. HasMedia defaults it to false;
override it to opt a model out of cascade deletion:
@overridebool get preserveMedia => true;One subtlety worth calling out: save() reconstructs the returned
instance through the model’s own fromMap, so a subclass that only
overrides preserveMedia does not survive a round trip through the
database if its parent’s fromMap always builds the parent type. A model
that needs preserveMedia to stick needs its own ModelDefinition (with
its own fromMap and, if it declares collections, its own MediaSchema)
rather than subclassing another model that has one – see
PreservingPost in packages/senen/test/support/test_model.dart.
Converting Images: Conversion and ImageDriver
A Conversion (see Conversions) is a recipe, not code: it
says what an output should look like – a target size, whether to crop, a
quality, an output format – without saying how to get there. ImageDriver
is the thing that turns a recipe plus source bytes into converted bytes:
abstract class ImageDriver { String get name; bool supports(ImageFormat format); bool get lossyWebP => false;
Future<List<int>> convert( List<int> source, Conversion conversion, { required String sourceExtension, });}A driver is a pure function from bytes to bytes: no storage, no queue, no
Media. Task 7 is what runs a driver inside a Khepri job and writes the
result to disk.
width/height vs fit
c.width(1200) (or .height(...)) is a plain resize: whichever dimension
is omitted is computed from the source’s aspect ratio, so the image is
never distorted, and the axis you didn’t ask for follows along.
c.width(1200).height(800) – both dimensions, no .fit() – is still
never distorted: the source is scaled to contain within that box,
the same convention Spatie uses for width+height without an explicit
fit. The binding axis (whichever needs more shrinking) lands exactly on
its target; the other axis follows the source’s aspect ratio and may
come out smaller than requested. No padding is added – the output
image is genuinely that smaller size, not a padded canvas.
c.fit(w, h) is different: it guarantees the exact output size wxh,
which a plain resize cannot promise unless the source happens to already
have that aspect ratio. It does this by resizing to cover the target
box (scaling so the smaller overflow axis lands exactly on target, which
means the other axis ends up equal to or larger than target), then
centre-cropping that larger axis down to size. The image is never
stretched – the crop always removes equal amounts from both sides of the
overflowing axis.
The output format, and the extension that names it
ImageFormat has exactly three values – jpeg, png, webp – and
Conversion.formatFor(sourceExtension) is the single decision about
which one a conversion encodes:
conversion.formatFor('gif'); // ImageFormat.jpeg -- no GIF encoderconversion.extensionFor('gif'); // 'jpg' -- always names formatFor's answerc.format(...) wins outright. With no explicit format, a png source
stays PNG and a webp source stays WebP; everything else becomes JPEG,
including a source package:image can happily decode but that
ImageFormat cannot name – GIF, BMP, TIFF. That is the same default
Spatie lands on.
The reason this lives on Conversion and not in a driver: ConversionRunner
asks extensionFor for the name to store and to record in
generated_conversions, and the driver asks formatFor for the bytes to
encode. When those were two separate mappings they disagreed for GIF, BMP
and TIFF sources – the pure-Dart driver wrote JPEG bytes to a .gif path.
Nothing 404s (write and read agree on the path); the file is simply served
under a lying extension and content type, and MagickDriver did not do it,
so the two drivers diverged silently. One mapping, both callers, and the
contract suite pins it with a GIF source.
DartImageDriver
DartImageDriver (packages/senen/lib/src/drivers/dart_image_driver.dart)
implements ImageDriver on top of package:image, with no native
dependency – it works in any container, including one with no
ImageMagick binary installed. Task 8 adds a MagickDriver behind the same
interface for when ImageMagick is available and a wider format set (or
lossy WebP/AVIF) matters.
Being honest about what package:image 4.9.2 can and cannot do, verified
by reading its source rather than assumed from its README:
- It encodes JPEG, PNG, GIF, BMP, TIFF, ICO, CUR, PVR, TGA, and WebP.
- Its WebP encoder is VP8L (lossless) only. There is no lossy WebP
mode, and no AVIF encoder at all. Lossless WebP is typically larger
than a JPEG of the same photograph, so it is not the “smaller modern
format” win people usually reach for WebP to get.
DartImageDriverreflects this honestly:supports(ImageFormat.webp)istrue(it can produce a WebP file), butlossyWebPisfalse. - A resize-and-encode pass costs roughly 200ms for a large photo (a
4000x3000 source resized to 400px wide measured at ~178ms for the cubic
resize plus ~10-25ms to encode, depending on format). That is too slow
to run inline on an upload request, which is why
conversion(...)defaultsqueuedtotrue– see Conversions.
The driver contract suite
imageDriverContract(name, ImageDriver Function() make)
(packages/senen/test/drivers/image_driver_contract.dart) is a shared
test suite, not specific to DartImageDriver. It asserts only on
decoded output – dimensions, decoded format, the error thrown on
non-image input – never on how a driver got there, so both
DartImageDriver here and Task 8’s MagickDriver (which shells out to a
binary instead of decoding in-process) run the exact same cases. A driver
that stretches instead of centre-crops, or that lies about the format it
produced, fails this suite regardless of implementation.
A decode failure raises a StateError that tells the caller to check the
file’s real type rather than trust its extension – the same rule the
attach-time MIME/extension check follows (see
Sanitisation and MIME Rules).
Queued Conversions
toCollection generates every conversion that applies to the collection,
right after the original is written – as long as the media is an
image. A conversion runs through an ImageDriver, so a PDF or a text
file has nothing to generate, and toCollection skips both the inline and
the queued path for one (the row itself is still attached, exactly as
asked). Without that gate an unscoped conversion – the default, which
applies to every collection on the model – would dispatch a
PerformConversions job for every non-image upload that could only ever
throw and exhaust its retries. Spatie gates the same way, skipping media
whose type has no image generator. The test is media.mimeType starting
with image/, so media attached with no MIME type at all is treated as a
non-image. queued: false conversions
(preview in the Conversions example) run inline,
synchronously, before toCollection returns – the Media it hands back
already reflects them. Every other conversion (queued defaults to
true) is dispatched as a PerformConversions job on the Khepri queue
instead, so a slow resize-and-encode pass (see
DartImageDriver) never runs on the request path.
ConversionRunner
class ConversionRunner { ConversionRunner(this.driver, {this.paths = const PathGenerator()});
Future<Media> run(Media media, List<ConversionSpec> specs) async { ... }}run returns the media row as it stands after the run. Media is
immutable, so the instance handed in is stale the moment the first
conversion is recorded; returning the fresh one saves every caller a
re-read and removes the chance of reading generated_conversions off the
stale receiver by accident.
ConversionRunner does the actual work – read the original, convert it
with an ImageDriver, write the result to media.conversionsDisk, record
it – and knows nothing about queues. PendingMedia calls it directly for
inline conversions; PerformConversions.handle() calls it for queued
ones; Task 8’s media:regenerate command calls it again to rebuild
conversions after a recipe changes. One implementation, three callers.
It records each conversion’s success as soon as that conversion finishes,
not after the whole batch: run loops over specs, and after each one
converts and uploads cleanly it immediately persists
generated_conversions with that name added. If a later conversion in
the same batch throws, the ones already written stay recorded –
hasGeneratedConversion is true for them and false only for the
conversion that failed (and any that never got to run).
generated_conversions records the extension, not true
The extension a conversion actually produces (spec.conversion .extensionFor(sourceExtension)) is computed before the driver runs, so
it’s known even though it isn’t a property of the raw output bytes. It is
what gets written into generated_conversions – {'thumb': 'jpg'}, not
{'thumb': true} – because a conversion can change format
(c.format(ImageFormat.png) on a JPEG source), and Media.pathFor /
conversionExtension (see The Media Model) need the
real extension to build a URL that doesn’t 404.
The payload carries only ids and names
class PerformConversions extends Job { PerformConversions({required this.mediaId, required this.conversions}); final int mediaId; final List<String> conversions; // names, not recipes ...}A Conversion recipe is built by a closure passed to
MediaSchema.conversion(name, build, ...) (see
Conversions), and a closure cannot survive a JSON round
trip or cross a process boundary to a worker. So the job payload carries
only what can: a media id and a list of conversion names. handle()
looks the recipes back up by name, from the model’s MediaSchema, at
execution time – which is exactly what SenenRegistry exists to make
possible.
SenenRegistry
A worker picking up a PerformConversions job has a media.model_type
string ("posts") and nothing else – no way to reflect from that string
back to the Post class and its MediaSchema, because Dart AOT has no
reflection. SenenRegistry is the explicit map that closes the gap, plus
the application’s configured ImageDriver.
Registering a model’s schema is always the application’s job – nothing
in senen can do this for you, since it has no way to enumerate every
model your application declares:
SenenRegistry.register('posts', Post.media);An application registers every model that declares media once where it
boots (its own service provider is the natural place). Registering the
same MediaSchema instance under the same morph type again is a no-op,
so a process that boots a second Application – a second
Application.configure().create() in one test file, or a worker rebuilding
its container – does not fail on the second boot. Only a different
schema under a name already taken throws, since that is the copy-paste
mistake worth catching. A worker asking for
a morph type nobody registered gets a StateError naming the fix, the same
convention MorphMap.definition and JobRegistry.resolve already follow –
never a silent no-op and never reflection.
SenenRegistry.driver and the PerformConversions job factory are the two
pieces senenExtension/SenenServiceProvider wire up for you automatically
– see Installing Senen. Set them by hand only in a
test harness that skips the extension, the way
packages/senen/test/support/queue_support.dart’s bootQueueing does:
SenenRegistry.driver = DartImageDriver();app.make<JobRegistry>().register<PerformConversions>( 'senen.perform_conversions', PerformConversions.fromPayload,);Choosing a Driver
Senen ships two ImageDrivers:
DartImageDriver(the default) – pure Dart, no native dependency, so it works in any container. It decodes every formatpackage:imagereads (JPEG, PNG, GIF, BMP, TIFF, WebP) but only ever encodes the three valuesImageFormathas – JPEG, PNG and WebP – so a GIF, BMP or TIFF source comes out as JPEG unless the conversion asks for something else (see The output format). Andpackage:image4.9.2’s WebP encoder is VP8L (lossless) only, and it has no AVIF encoder at all –supports(ImageFormat.webp)istrue, butlossyWebPisfalse. Lossless WebP is typically larger than a JPEG of the same photograph, so it is not the size win people expect from a “modern format”.MagickDriver(packages/senen/lib/src/drivers/magick_driver.dart) – shells out to themagickCLI, so it needs ImageMagick installed on the host or in the container image. In exchange, it gives lossy WebP and AVIF, both of which the pure-Dart driver cannot produce at all.lossyWebPistrue.
Both implement the same ImageDriver interface and pass the same
contract suite unmodified – fit centre-crops
rather than stretching, and width+height without fit contains within the
box with no padding, in both drivers. MagickDriver gets there with
-resize WxH^ (cover) plus -gravity center -extent WxH (crop) for fit,
and plain -resize WxH (no ^, no !, no -extent) for the
contain-without-padding case – ImageMagick’s ! (force) would stretch
instead of crop, and adding -extent to the plain-resize case would
letterbox instead of leaving the smaller axis genuinely smaller.
MagickDriver shells out with an explicit argument list (Process.run),
never a shell string, so a file name can never become an argument. A
non-zero exit is a StateError naming both the likely fix (check that
magick is on PATH) and the fallback (config('media.driver') set to
'dart').
Pick a driver with config('media.driver') in the host application’s
config, read by SenenServiceProvider:
{'driver': 'magick', 'disk': 'public'} // or 'dart' (the default)MagickDriver’s half of the contract suite
(packages/senen/test/drivers/magick_driver_test.dart) is gated on the
magick binary being present, the same way Shena’s S3 suite is gated on an
environment variable – it skips cleanly when ImageMagick is not installed,
so the package’s tests still pass everywhere. Set MAGICK_BINARY to point
at a specific binary, or just install imagemagick and it is picked up
from PATH.
Installing Senen
Command registration goes through senenExtension
(packages/senen/lib/src/senen_extension.dart), not
SenenServiceProvider directly – a ServiceProvider in this framework
registers bindings, not commands:
Application.configure(...) .withExtensions([senenExtension]) // Senen needs a queue for `PerformConversions`. .withProviders([QueueServiceProvider.new]) .create();SenenServiceProvider (installed by the extension) binds the configured
ImageDriver onto SenenRegistry and registers the PerformConversions
job factory. It does not register a model’s MediaSchema – that stays
the application’s job, once per model, where it boots:
SenenRegistry.register('posts', Post.media);media:regenerate
Adding a conversion to a model that already has media leaves that media
without it – the conversion only ever runs at attach time, and no
application code can fix already-attached media after the fact.
maat media:regenerate reuses ConversionRunner
directly (it is deliberately free of queue concerns) to rebuild
conversions for media that already exists:
maat media:regenerate # every media rowmaat media:regenerate --model=posts # scoped to a model typemaat media:regenerate --collection=images # scoped to a collectionmaat media:regenerate --media-id=42 # a single media rowThe flags combine (an application can pass --model and --collection
together). The command prints how many rows it actually processed; a row
matched by the scope is skipped, and not counted, when there is nothing to
regenerate for it:
- its collection declares no conversions;
- it is not an image (
mime_typedoes not start withimage/) – the same gatetoCollectionapplies, so one text file in the table cannot abort an unscoped run; - no
MediaSchemais registered for itsmodel_type. An unscoped run sweeps every row in the table, including morph types the current process never registered, so one of those logs a warning naming the missingSenenRegistry.register(...)call and moves on rather than taking the whole run down part-way through.
MediaRegenerateCommand.regenerate(...) is also a plain static method,
independent of the console harness, for an application that wants to
trigger regeneration from its own code (a migration, an admin action) –
the Command subclass is a thin CLI wrapper around it.
Testing with Storage.fake()
Every senen test fakes storage; none touches a real filesystem.
Storage.fake() (from shena_maat – see
Filesystem) replaces every disk
with an in-memory FakeDisk, and Storage.restore() in tearDown undoes
it:
setUp(() async { Storage.fake(); db = await _setUpDatabase(); // creates the `media` table, registers MorphMap});tearDown(() async { Storage.restore(); MorphMap.reset(); await db.close();});
test('stores the file and inserts a row', () async { final post = await Post(title: 'x').save(); final media = await post.media .addBytes([1, 2, 3], fileName: 'avatar.jpg', mimeType: 'image/jpeg') .toCollection('images');
(Storage.disk('public') as FakeDisk).assertStored('${media.id}/avatar.jpg');});Attaching media dispatches a job for every conversion that applies to the
collection and isn’t queued: false. test_model.dart’s shared Post
declares a thumb conversion with no collections filter, so it applies
to every collection – attaching any media to a Post in these tests
goes through dispatch(), which throws StateError: No Application has been created yet with no bound QueueConnection. Every senen test needs
one, even a test that never asserts on conversions or queueing itself.
packages/senen/test/support/queue_support.dart has two ready-made
helpers, reused across every senen test file:
bootNoopQueue()– aQueueConnectionthat silently discards every push. Use it for a suite that attaches media but never asserts on conversions or queueing itself (attach_test.dart,cascade_delete_test.dart,ordering_test.dart).bootQueueing(schema)– boots a real queue and does theSenenRegistrywiring (driver, model registration, and thePerformConversionsjob factory) that a test popping and running the dispatched job actually needs (conversions_test.dart,regenerate_test.dart).
resetQueueing() undoes either one in tearDown.
Both patterns cast Storage.disk(name) to FakeDisk to assert against
it – 'public' for a collection with no explicit disk, or whichever
disk a collection declares (the gallery collection in
test_model.dart stores originals on 'public' and conversions on
'cdn', so cascade_delete_test.dart asserts against both). Storage.fake()
fakes every disk name, and Storage.disk(name) returns the same
FakeDisk instance across calls for that name, so the code under test
can write and the assertion can read back the same disk.