played

@played/media (0.3.9)

Published 2026-06-03 12:26:10 +00:00 by enricobuehler

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

  • keyPrefix is immutable. Changing it after objects exist makes the old S3 keys unreachable. Games that predate prefixes use "" (bare uploads/…).
  • sharp is 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 job failed.
  • bullmq is an optional peer, only needed by ./worker. It must not be pulled into a browser bundle (keep it out of the ./react import 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
Details
npm
2026-06-03 12:26:10 +00:00
45
24 KiB
Assets (1)
Versions (18) View all
0.3.10 2026-06-03
0.3.9 2026-06-03
0.3.8 2026-06-03
0.3.7 2026-06-03
0.3.6 2026-06-03