Video recipes
Paid courses, user uploads with a transcoding worker, hero videos, and replacing or removing a video.
A paid course
Lessons anyone may browse, videos only buyers may watch, links that play for the buyer alone.
export default defineSchema(
{
lessons: table({ title: t.text(), video: t.text(), free: t.boolean().default(false) }),
purchases: table({ userId: t.text(), courseId: t.uuid() }).index("userId"),
},
{
auth: { jwks: "https://your-auth.example.com/.well-known/jwks.json" },
storage: {
course_videos: {
maxBytes: 1024 * 1024 * 1024,
types: ["video/*", "application/vnd.apple.mpegurl", "image/jpeg"],
},
},
},
);The folder has no rules, so only the secret key reaches it: no user can list, read or upload. Your server decides who watches.
import { mantis } from "@/lib/mantis"; // the secret key, on the server
import { LessonPlayer } from "@/components/lesson-player";
export default async function Lesson({ params }: { params: Promise<{ id: string }> }) {
const { id } = await params;
const user = await currentUser();
const [lesson] = await mantis.from("lessons").select({ where: { id } });
const bought = await mantis.from("purchases").select({ where: { userId: user.id }, limit: 1 });
if (!lesson.free && bought.length === 0) return <Paywall />;
const src = await mantis.storage.playbackUrl(lesson.video, {
userId: user.id,
expiresIn: 4 * 60 * 60,
});
return <LessonPlayer src={src} />;
}"use client";
import { VideoPlayer } from "@/components/video-player";
export function LessonPlayer({ src }: { src: string }) {
const session = useSession(); // your auth library's hook for the current token
return <VideoPlayer src={src} token={() => session.token} />;
}userIdmakes a shared link useless to anyone else; the player sends the buyer's token on every request (see One viewer only). The token is read in a client component, because a server component can't pass a function to the browser.- Four hours covers a long lesson with pauses. A tab left open overnight gets
403and needs a reload. - Free lessons use the same path. Drop
userIdfor them if visitors who aren't signed in may watch.
Lessons anyone may browse, videos only buyers may watch. The folder has no rules, so only your server, with the secret key, reaches it; your Laravel auth decides who watches.
Route::get('/lessons/{lesson}', LessonController::class)->middleware('auth');use Shipmantis\Mantis\Laravel\Facades\Mantis;
class LessonController
{
public function __invoke(Request $request, Lesson $lesson)
{
$bought = $request->user()->purchases()->where('course_id', $lesson->course_id)->exists();
abort_unless($lesson->free || $bought, 402);
$src = Mantis::storage()->playbackUrl(
$lesson->video,
expiresIn: 4 * 60 * 60,
userId: (string) $request->user()->id,
);
return view('lessons.show', ['lesson' => $lesson, 'src' => $src]);
}
}<x-video-player :src="$src" locked />The player component plays it in every browser, sending the buyer's
token from the package's /mantis/token route.
- The link is made per page view, behind your
authmiddleware, and lasts four hours: long enough for a lesson with pauses. A tab left open overnight gets403and needs a reload. - A shared link plays for nobody else:
userIdlocks it to the buyer, checked with the token your app signs (php artisan mantis:auth-keyonce, then the printedauthentry in the schema). - The course's tables can be your own migrations and Eloquent models; only the video lives in a Mantis folder.
Users upload, the server transcodes
Users send a raw .mp4 from the browser. A worker on your server turns it into a package and
stores it where the app plays from.
export default defineSchema(
{
clips: table({
title: t.text(),
source: t.text(),
video: t.text().optional(),
status: t.text().default("uploaded"),
}).index("status"),
},
{
auth: { jwks: "https://your-auth.example.com/.well-known/jwks.json" },
storage: {
uploads: {
maxBytes: 2 * 1024 * 1024 * 1024,
types: ["video/*"],
rules: { insert: fileRule.signedIn(), read: fileRule.uploader(), delete: fileRule.uploader() },
},
videos: {
maxBytes: 100 * 1024 * 1024,
types: ["video/*", "application/vnd.apple.mpegurl", "image/jpeg"],
rules: { read: fileRule.public() },
},
},
},
);In the browser, with the publishable key and the user's token:
const path = `uploads/${crypto.randomUUID()}.mp4`;
await mantis.storage.upload(path, file); // straight to storage, 16 MB parts
await mantis.from("clips").insert({ title, source: path });On the server, a worker picks up each new clip:
import { createWriteStream } from "node:fs";
import { mkdtemp, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { Readable } from "node:stream";
import { pipeline } from "node:stream/promises";
import { transcode, uploadVideo } from "@shipmantis/sdk/video";
import { mantis } from "./mantis"; // the secret key
export async function transcodeNext() {
const [clip] = await mantis.from("clips").select({ where: { status: "uploaded" }, limit: 1 });
if (!clip) return false;
await mantis.from("clips").update({ set: { status: "transcoding" }, where: { id: clip.id } });
const scratch = await mkdtemp(join(tmpdir(), "clip-"));
try {
const source = join(scratch, "source.mp4");
const response = await fetch(await mantis.storage.url(clip.source));
await pipeline(Readable.fromWeb(response.body!), createWriteStream(source));
const made = await transcode(source, join(scratch, "package"));
const video = await uploadVideo(mantis, made.dir, { folder: "videos", id: clip.id });
await mantis.from("clips").update({ set: { video: video.id, status: "ready" }, where: { id: clip.id } });
await mantis.storage.remove(clip.source);
} catch (error) {
await mantis.from("clips").update({ set: { status: "failed" }, where: { id: clip.id } });
throw error;
} finally {
await rm(scratch, { recursive: true, force: true });
}
return true;
}- Download first, then transcode.
storage.url()lives five minutes, andffmpegreads its input more than once (probe, transcode, poster), so streaming from the link can fail halfway through a long video. - The clip's id is the video's id, so a rerun after a crash finds the same path; remove the
half-made video or mark the clip
failedbefore trying again. - The worker needs
ffmpegon its machine. A Docker image:apt-get install -y ffmpeg. - One clip at a time per CPU.
ffmpeguses every core it gets; run more workers on more machines, not more transcodes on one. - The raw upload is removed once the package is stored, so it stops counting against the plan.
- Show progress from the table: subscribe to the clip with a live query and
render
uploaded,transcoding,readyorfailed.
Users send a raw .mp4 to your app; a queued job turns it into a package and stores it where the
app plays from.
public function store(Request $request)
{
$request->validate(['title' => 'required', 'video' => ['required', 'file', 'mimetypes:video/mp4,video/quicktime', 'max:2097152']]);
$clip = $request->user()->clips()->create(['title' => $request->title, 'status' => 'uploaded']);
$source = $request->file('video')->storeAs('sources', "{$clip->id}.mp4", 'local');
PublishClip::dispatch($clip, Storage::disk('local')->path($source));
return redirect()->route('clips.show', $clip);
}public function handle(): void
{
$this->clip->update(['status' => 'transcoding']);
Process::path(base_path())
->timeout($this->timeout)
->run(['npx', 'shipmantis', 'video', $this->source, '--folder', 'videos', '--id', (string) $this->clip->id])
->throw();
$this->clip->update(['video' => "videos/{$this->clip->id}", 'status' => 'ready']);
@unlink($this->source);
}
public function failed(Throwable $error): void
{
$this->clip->update(['status' => 'failed']);
}- The upload goes to your server first, unlike the JavaScript version that uploads straight
to storage. Raise
upload_max_filesizeandpost_max_sizeinphp.ini, and your web server's body limit (client_max_body_sizein nginx), to the largest clip you take. - The worker needs Node.js and
ffmpegon its machine, and a queue--timeoutlonger than a transcode. - Show progress by reading the clip's
statuson the page:uploaded,transcoding,readyorfailed. - A large upload can go straight to storage from the browser instead, with the JavaScript
SDK's
storage.uploadand the publishable key, then the job downloads it withMantis::storage()->download($path, $target).
A hero video on the landing page
A short loop, public, cached at the edge:
<VideoPlayer src={heroUrl} poster={heroPoster} /><x-video-player :src="$heroUrl" :poster="$heroPoster" autoplay muted loop />For an autoplaying background loop, set autoplay muted loop playsinline on the <video>:
browsers only autoplay video without sound. Keep it short: a 15-second loop is about 20 MB across
all four sizes, and each viewer loads only the size their screen and connection pick.
Replace or remove a video
A video's files can't be overwritten, so replace by uploading under a new id, point the row at it, then remove the old one. Viewers mid-play keep the old video until their page reloads; nobody sees a half-replaced one.
import type { Client } from "@shipmantis/sdk";
export async function removeVideo(mantis: Client, id: string) {
await mantis.storage.remove(`${id}/master.m3u8`).catch(() => {});
let after: string | undefined;
do {
const page = await mantis.storage.listPage(`${id}/`, after);
await Promise.all(page.files.map((file) => mantis.storage.remove(file.path)));
after = page.next ?? undefined;
} while (after);
}const next = await uploadVideo(mantis, "./out/intro-v2", { folder: "videos" });
await mantis.from("lessons").update({ set: { video: next.id }, where: { id: lesson.id } });
await removeVideo(mantis, lesson.video);- The master playlist goes first, so the video stops being playable before its segments disappear.
- Run it with the secret key.
listPagereturns only what the caller may read, and removing needs the folder'sdeleterule. - Removing frees the room on the plan at once.
A video's files can't be overwritten, so replace by uploading under a new id, point the row at it, then remove the old one. Viewers mid-play keep the old video until their page reloads; nobody sees a half-replaced one.
use Shipmantis\Mantis\Laravel\Facades\Mantis;
use Shipmantis\Mantis\MantisException;
final class Videos
{
public static function remove(string $id): void
{
try {
Mantis::storage()->delete("{$id}/master.m3u8");
} catch (MantisException) {
}
$after = null;
do {
$page = Mantis::request('GET', '/v1/storage/files?'.http_build_query(array_filter([
'prefix' => "{$id}/",
'after' => $after,
])));
foreach ($page['files'] as $file) {
Mantis::storage()->delete($file['path']);
}
$after = $page['next'];
} while ($after !== null);
}
}$old = $lesson->video;
PublishVideo::dispatchSync($lesson, $newSource, (string) Str::uuid()); // a new id, then the row points at it
Videos::remove($old);- The master playlist goes first, so the video stops being playable before its segments disappear.
- PHP holds the secret key, so it lists and removes every file of the video.
- Removing frees the room on the plan at once.