MantisDocs

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.

mantis/schema.ts
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.

app/lessons/[id]/page.tsx
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} />;
}
components/lesson-player.tsx
"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} />;
}
  • userId makes 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 403 and needs a reload.
  • Free lessons use the same path. Drop userId for 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.

routes/web.php
Route::get('/lessons/{lesson}', LessonController::class)->middleware('auth');
app/Http/Controllers/LessonController.php
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]);
    }
}
resources/views/lessons/show.blade.php
<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 auth middleware, and lasts four hours: long enough for a lesson with pauses. A tab left open overnight gets 403 and needs a reload.
  • A shared link plays for nobody else: userId locks it to the buyer, checked with the token your app signs (php artisan mantis:auth-key once, then the printed auth entry 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.

mantis/schema.ts
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:

worker/transcode.ts
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, and ffmpeg reads 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 failed before trying again.
  • The worker needs ffmpeg on its machine. A Docker image: apt-get install -y ffmpeg.
  • One clip at a time per CPU. ffmpeg uses 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, ready or failed.

Users send a raw .mp4 to your app; a queued job turns it into a package and stores it where the app plays from.

app/Http/Controllers/ClipController.php
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);
}
app/Jobs/PublishClip.php
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_filesize and post_max_size in php.ini, and your web server's body limit (client_max_body_size in nginx), to the largest clip you take.
  • The worker needs Node.js and ffmpeg on its machine, and a queue --timeout longer than a transcode.
  • Show progress by reading the clip's status on the page: uploaded, transcoding, ready or failed.
  • A large upload can go straight to storage from the browser instead, with the JavaScript SDK's storage.upload and the publishable key, then the job downloads it with Mantis::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.

lib/videos.ts
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. listPage returns only what the caller may read, and removing needs the folder's delete rule.
  • 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.

app/Support/Videos.php
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.

On this page