@played/media (0.3.9)
Installation
@played:registry=https://git.unom.io/api/packages/played/npm/npm install @played/media@0.3.9"@played/media": "0.3.9"About this package
@played/media
Shared media stack for played games: drizzle tables, S3 presign + signed-read service, a tRPC router factory, pluggable transcoders (responsive images, GIF→video), an offloaded-processing worker, and React UI components.
Games don't reimplement any of this — they wire the factories to their own db / tRPC / queue and mount the components.
Exports
| Subpath | What |
|---|---|
./schema |
drizzle media + mediaDerivatives tables, enums, mediaRelations |
./zod |
MediaEntity, MediaWithDerivativesEntity, PresignInput, MIME_SIZE_CAPS, … |
./server |
MediaService, createMediaService, createMediaRouter, signMedia*, MediaTranscoder |
./sharp |
createImageTranscoder — jpeg/png/webp → AVIF + WebP responsive ladder |
./ffmpeg |
createFfmpegTranscoder — gif → mp4/webm/poster (Bun-only) |
./composite |
createCompositeTranscoder — combine transcoders for mixed source types |
./worker |
createMediaProcessingQueue — BullMQ queue+worker for async derivative generation |
./react |
MediaRenderer, MediaPicker, MediaManager, MediaUploader, MediaLibrary, MediaApi, uploadMedia |
Backend wiring
1. Schema + relations
Re-export the tables from your schema, and spread mediaRelations into your
relations:
// schema.ts
export { media, mediaDerivatives, mediaDerivativeKindEnum } from "@played/media/schema";
// relations.ts
import { mediaRelations } from "@played/media/schema";
export const relations = defineRelations(schema, (r) => ({
...mediaRelations(r),
// your media consumers, e.g.:
packs: { cover: r.one.media({ from: r.packs.coverId, to: r.media.id }) },
}));
The async columns (processing_status, width, height) and the
media_derivative_kind = 'image' value are part of the schema — make sure your
drizzle migrations include them (see the media_image_variants migration in any
adopted game).
2. Service + router
// entities/media/media.router.ts
import { createMediaService, createMediaRouter } from "@played/media/server";
import { createImageTranscoder } from "@played/media/sharp";
import { createFfmpegTranscoder } from "@played/media/ffmpeg";
import { createCompositeTranscoder } from "@played/media/composite";
import { createMediaProcessingQueue } from "@played/media/worker";
import { adminProcedure, authorizedProcedure, db, router } from "@/core";
export const mediaProcessingQueue =
createMediaProcessingQueue("<game>-media-processing"); // MUST be game-unique
export const mediaService = createMediaService({
db,
keyPrefix: "<game>", // namespaces S3 keys; NEVER change once live
transcoder: createCompositeTranscoder([
createImageTranscoder(), // jpeg/png/webp → AVIF/WebP
createFfmpegTranscoder(), // gif → mp4/webm/poster
]),
// Optional: game-specific "is this media referenced?" predicate, enabling the
// used/unused list filter:
usagePredicate: { used: usedSql, unused: notUsedSql },
onProcessingNeeded: (id) => mediaProcessingQueue.enqueue(id),
});
export const mediaRouter = createMediaRouter({
router, adminProcedure, authorizedProcedure, service: mediaService,
});
If you omit onProcessingNeeded, confirmUpload generates derivatives
synchronously (simpler, but blocks the request and rolls the upload back on
a transcode failure). Wiring the queue makes it async (record pending →
worker → ready/failed).
A single transcoder is fine too — pass createImageTranscoder() directly if you
only handle static images. Use the composite only when a game uploads more than
one source family.
3. Start the worker
// main.ts
import { mediaProcessingQueue, mediaService } from "@/entities/media/media.router.ts";
const mediaWorker = mediaProcessingQueue.createWorker((id) =>
mediaService.processMedia(id),
);
// in shutdown(), before stopping the server:
await mediaWorker.close();
await mediaProcessingQueue.close();
Share the same mediaService instance between the router and the worker.
Frontend wiring
Adapt your typed tRPC client to the MediaApi contract, then mount components:
// lib/upload-media.ts
import { type MediaApi, uploadMedia as uploadMediaLib } from "@played/media/react";
import { trpcClient } from "@/lib/trpc";
export const mediaApi: MediaApi = {
presign: (i) => trpcClient.media.presign.mutate(i),
confirm: (i) => trpcClient.media.confirm.mutate(i),
getAllMedia: (i) => trpcClient.media.getAllMedia.query(i),
getMediaById: (i) => trpcClient.media.getMediaById.query(i),
deleteMedia: (i) => trpcClient.media.deleteMedia.mutate(i),
regenerateDerivatives: (i) => trpcClient.media.regenerateDerivatives.mutate(i),
regenerateAll: () => trpcClient.media.regenerateAllDerivatives.mutate(),
};
export const uploadMedia = (file: File) => uploadMediaLib(mediaApi, file);
<MediaManager api={mediaApi} usageToggle onUploaded={…} />— admin library.<MediaPicker api={mediaApi} value={id} onChange={…} usageFilter="unused" />— single-select field.<MediaRenderer media={mediaWithDerivatives} alt="…" />—<picture>/<video>responsive playback.
All components take labels for i18n and accept to constrain to images vs
audio.
Notes / gotchas
keyPrefixis immutable. Changing it after objects exist makes the old S3 keys unreachable. Games that predate prefixes use""(bareuploads/…).sharpis a native addon (libvips). The API deploy image must ship it. It's lazy-loaded, so a missing binary doesn't crash boot — the first raster transcode just marks that jobfailed.bullmqis an optional peer, only needed by./worker. It must not be pulled into a browser bundle (keep it out of the./reactimport graph).- Worker queue names must be game-unique — all api-core processes share one valkey, so an unprefixed name lets another game's worker steal the job.
- After enabling/expanding transcoding on an existing library, call
regenerateAllDerivatives(admin "Regenerate all") to backfill variants for media uploaded before.
Dependencies
Dependencies
| ID | Version |
|---|---|
| @aws-sdk/client-s3 | ^3.1029.0 |
| @aws-sdk/s3-request-presigner | ^3.1045.0 |
| sharp | ^0.34.5 |
Development Dependencies
| ID | Version |
|---|---|
| @played/api-core | ^0.6.4 |
| @played/config | ^0.4.0 |
| @played/ui | ^0.8.15 |
| @trpc/server | ^11.17.0 |
| @types/bun | latest |
| @types/react | ^19.2.0 |
| bullmq | ^5.76.7 |
| drizzle-orm | 1.0.0-rc.2-6e355d0 |
| lucide-react | ^1.17.0 |
| react | ^19.2.6 |
| tsdown | ^0.22.0 |
| typescript | ^6.0.3 |
| zod | ^4.4.3 |
Peer Dependencies
| ID | Version |
|---|---|
| @played/api-core | >=0.3.0 |
| @played/config | >=0.2.0 |
| @played/ui | >=0.8.15 |
| @trpc/server | ^11.17.0 |
| bullmq | ^5.76.7 |
| drizzle-orm | 1.0.0-rc.2-6e355d0 |
| lucide-react | ^1.17.0 |
| react | ^19.2.6 |
| zod | ^4.4.3 |