Skip to content

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 key
media.customProperty('alt', 'no alt text'); // with a fallback

generated_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'); // true
media.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; null falls 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 ratio
c.height(800)
c.fit(368, 232) // resize and crop to exactly this size
c.quality(80) // 1-100, defaults to 82
c.format(ImageFormat.webp) // re-encode regardless of source format

Conversion 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-readable name shown in the UI. Defaults to the sanitised file name without its extension.
  • withCustomProperties(Map) – arbitrary metadata, stored as JSON and read back with media.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 the media row, then writes the file, and returns the persisted Media.

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.sanitise strips any directory component (p.basename, after normalising \ to /) and replaces anything that isn’t [A-Za-z0-9._-] with _. A file_name of ../../etc/passwd is stored as passwd – 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 a StateError naming 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’s content-type header, which the client wrote. It is client-controlled and exactly as spoofable as the file extension: an attacker who renames evil.sh to photo.jpg can just as easily send Content-Type: image/jpeg with it. Nothing in guardMimeType reads 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, ordered
final one = await post.media.first('images'); // the first, or null
final url = await post.media.url(one!); // original URL
final thumbUrl = await post.media.url(one, 'thumb'); // conversion URL
final cover = await post.media.firstUrl('images'); // URL, fallbackUrl, or null
final 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 resolves media.pathFor() on media.disk.
  • With a conversion that has been generated, it resolves the conversion’s path – built with conversionExtension(name), never a guessed extension – on media.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 ordered
here. 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:

packages/senen/lib/src/media_cleanup.dart
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:

@override
bool 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:

packages/senen/lib/src/drivers/image_driver.dart
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 encoder
conversion.extensionFor('gif'); // 'jpg' -- always names formatFor's answer

c.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. DartImageDriver reflects this honestly: supports(ImageFormat.webp) is true (it can produce a WebP file), but lossyWebP is false.
  • 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(...) defaults queued to true – 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

packages/senen/lib/src/conversion_runner.dart
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

packages/senen/lib/src/perform_conversions_job.dart
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 format package:image reads (JPEG, PNG, GIF, BMP, TIFF, WebP) but only ever encodes the three values ImageFormat has – 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). And package:image 4.9.2’s WebP encoder is VP8L (lossless) only, and it has no AVIF encoder at all – supports(ImageFormat.webp) is true, but lossyWebP is false. 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 the magick CLI, 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. lossyWebP is true.

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:

config/media.dart
{'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:

Terminal window
maat media:regenerate # every media row
maat media:regenerate --model=posts # scoped to a model type
maat media:regenerate --collection=images # scoped to a collection
maat media:regenerate --media-id=42 # a single media row

The 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_type does not start with image/) – the same gate toCollection applies, so one text file in the table cannot abort an unscoped run;
  • no MediaSchema is registered for its model_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 missing SenenRegistry.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() – a QueueConnection that 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 the SenenRegistry wiring (driver, model registration, and the PerformConversions job 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.