MantisDocs

Upload videos

Transcode with your own ffmpeg and upload the package, from the command line or the SDK.

Uploading a video is two steps: ffmpeg turns the source into an HLS package on your disk, and the upload stores every file of it under one video id. The command line does both; the SDK lets you run them separately.

You need ffmpeg and ffprobe (any build from the last few years; ffprobe comes with ffmpeg), a folder that takes the package's types (see the overview), and the secret key: uploads of whole packages run on your machine or your server, never in a browser.

From the command line

npx shipmantis video intro.mp4 --folder videos
Transcoding intro.mp4 with ffmpeg…
  25%
  50%
  75%
  1080p, 720p, 480p, 360p, 6-second segments and a poster
Uploading 70 files to videos…
Uploaded. 70 files, 118.4 MB.
  id        videos/7e0f3c1a-…
  playlist  videos/7e0f3c1a-…/master.m3u8
  poster    videos/7e0f3c1a-…/poster.jpg
  link      https://cdn.shipmantis.com/<project>/videos/7e0f3c1a-…/master.m3u8
FlagWhat it does
--folder <name>The folder to store it in. Required.
--id <name>The video's name inside the folder. Default: a fresh uuid. Letters, digits, ., _ and -.
--out <dir>Keep the package in this directory. Without it the package is made in a temporary directory and removed after the upload.
--ffmpeg <path>The ffmpeg to run, when it isn't on the PATH. MANTIS_FFMPEG does the same. ffprobe is taken from beside it.

The command reads MANTIS_URL and MANTIS_SECRET_KEY from the environment or .env.local. link is printed only for a folder anyone may read.

Pass a directory instead of a file to upload a package you made already:

npx shipmantis video ./out/intro --folder videos --id intro

From the SDK

scripts/upload-video.ts
import { createClient } from "@shipmantis/sdk";
import { transcode, uploadVideo } from "@shipmantis/sdk/video";

const mantis = createClient({ url: process.env.MANTIS_URL!, key: process.env.MANTIS_SECRET_KEY! });

const made = await transcode("intro.mp4", "./out/intro", {
  onProgress: (fraction) => console.log(`${Math.round(fraction * 100)}%`),
});
// { dir: "./out/intro", source: { width: 1920, height: 1080, duration: 94.2, audio: true },
//   rungs: ["1080p", "720p", "480p", "360p"] }

const video = await uploadVideo(mantis, made.dir, {
  folder: "videos",
  id: "intro",
  onFile: (done, total) => console.log(`${done}/${total}`),
});
// { id: "videos/intro", playlist: "videos/intro/master.m3u8", poster: "videos/intro/poster.jpg",
//   publicUrl, posterUrl, files: 70, bytes: 124151808 }

@shipmantis/sdk/video runs on Node.js 20 or later (it spawns ffmpeg and reads files from disk), not in a browser. The command line needs Node.js 22.18 or later.

transcode(input, dir, options)

OptionWhat it does
ffmpegThe path to ffmpeg. Default: ffmpeg on the PATH.
ffprobeThe path to ffprobe. Default: beside ffmpeg, or ffprobe on the PATH.
onProgress(fraction)Called as the transcode runs, from 0 to 1. Not called when the source reports no duration.

It probes the source first, then makes every rung in one ffmpeg pass and the poster in a second. input is anything ffmpeg opens, a path or a URL.

uploadVideo(client, dir, options)

OptionWhat it does
folderThe folder to store it in. Required.
idThe video's name inside the folder. Default: a fresh uuid.
onFile(done, total)Called after each file is stored.

It returns the video's id (folder and name), the playlist and poster paths, and publicUrl and posterUrl when the folder is public (null otherwise), with the number of files and their bytes.

PHP has no transcoder of its own: run the command line from a queued job, on a machine with Node.js 22.18 or later and ffmpeg. Run from the app's folder, it reads MANTIS_URL and MANTIS_SECRET_KEY from .env.

app/Jobs/PublishVideo.php
use Illuminate\Support\Facades\Process;

class PublishVideo implements ShouldQueue
{
    use Queueable;

    public int $timeout = 3600;

    public function __construct(public Lesson $lesson, public string $source, public ?string $id = null)
    {
    }

    public function handle(): void
    {
        $id = $this->id ?? (string) $this->lesson->id;

        Process::path(base_path())
            ->timeout($this->timeout)
            ->run(['npx', 'shipmantis', 'video', $this->source, '--folder', 'videos', '--id', $id])
            ->throw();

        $this->lesson->update(['video' => "videos/{$id}"]);
    }
}
PublishVideo::dispatch($lesson, storage_path("app/uploads/{$lesson->id}.mp4"));
  • The id is yours: passing --id makes the video's id videos/<id>, so the job knows it without reading the output.
  • The timeout covers the transcode: about as long as the video for a 1080p source on a few cores. Set the queue worker's --timeout at least as high.
  • A failed run throws, with the command's output in the exception; nothing half-uploaded is left behind. Let the queue retry it, or mark the lesson failed in failed().
  • A package you made already, by your own ffmpeg settings, uploads the same way: pass its directory instead of the source file.

The order of an upload

  1. Checked first, on your disk: every file name, every type, and every line of every playlist. Nothing is sent until the whole package passes.
  2. Playlists and the poster, then the segments, four files at a time, each straight to storage.
  3. The master playlist last. Until it lands the video can't be played, and the dashboard shows it as no master playlist yet.

If any file is refused, the files already stored are removed again, so a failed upload never leaves half a video behind. The error is the one the refused file got.

Your own packages

Any HLS package uploads as it is, from your own encoder settings, a pipeline you already run or a vendor's export, if it holds to these rules:

  • master.m3u8 at its top. It is the video's entry point; the rest may sit in any directories under it.
  • File names of letters, digits, ., _ and -, in every directory.
  • Playlists point only at files in the package, by relative path. An absolute URL (https://…, /…) or a file that isn't there is refused before anything is sent.
  • These types only:
ExtensionContent typeFolder types that admit it
.m3u8application/vnd.apple.mpegurlapplication/vnd.apple.mpegurl
.tsvideo/mp2tvideo/*
.m4s, .mp4video/iso.segment, video/mp4 (fMP4 / CMAF)video/*
.aacaudio/aacaudio/*
.vtttext/vtt (subtitles)text/vtt
.jpg, .jpeg, .png, .webpimagesimage/jpeg, or image/*

The Videos folder from the dashboard takes .m3u8, video segments and JPEG posters. A package with subtitles, separate audio or PNG thumbnails needs those types added to the folder in the schema (mantis/schema.ts, or database/mantis.php from PHP):

videos: {
  maxBytes: 1024 * 1024 * 1024,
  types: ["video/*", "audio/*", "application/vnd.apple.mpegurl", "text/vtt", "image/*"],
  rules: { read: fileRule.public() },
},
database/mantis.php
'videos' => [
    'maxBytes' => 1024 * 1024 * 1024,
    'types' => ['video/*', 'audio/*', 'application/vnd.apple.mpegurl', 'text/vtt', 'image/*'],
    'rules' => ['read' => ['public' => true]],
],

The first image at the package's top is the video's poster.

Edge cases

CaseWhat happens
A phone video recorded uprightThe rotation in its metadata is read, so it is laid out as portrait and the short side is its width.
A screen recording with no soundThe package has video only; no silent audio track is added.
A 4K sourceThe ladder tops out at 1080p. Make your own package for a 2160p rung.
A source smaller than 360pOne 360p rung, scaled up.
A clip of two seconds or lessThe poster is the first frame instead of the frame at one second.
The same id againRefused: a path that already holds a file is never overwritten. Upload under a new id, or remove the old video first.
ffmpeg is not installedffmpeg was not found. Install ffmpeg (it brings ffprobe), or pass the path to it. Nothing is uploaded.
ffmpeg fails on the sourceThe error ends with the last lines ffmpeg printed. Nothing is uploaded.
A .key file in the packageRefused: keys never go into a folder.
The plan runs out of room halfwayThat file is refused with 402, and the files already stored are removed.
A file larger than the folder's maxBytesRefused for that file; the upload is undone. Raise the folder's largest file or use shorter segments.
The connection drops halfwayThe upload throws and removes what it stored, as far as the connection allows. A file left behind shows on the Files page under the video's row; remove the video and upload again.
A client pointed at a branchRefused: files belong to main. The command line always uploads to main.

Uploading many videos

uploadVideo already sends four files at a time. Upload videos one after another rather than many at once; the throughput is the same, and a failure is easier to retry:

for (const source of sources) {
  const id = basename(source, extname(source));
  const made = await transcode(source, join("out", id));
  const video = await uploadVideo(mantis, made.dir, { folder: "videos", id });
  await mantis.from("lessons").update({ set: { video: video.id }, where: { slug: id } });
}

Give each video an id you can derive again, like the source's name or the row's id, so a rerun after a failure knows which ones are already there.

Dispatch one job per video and let the queue run them one at a time per worker. ffmpeg uses every core it gets, so more workers on more machines go faster; more at once on one machine doesn't:

foreach (Lesson::whereNull('video')->get() as $lesson) {
    PublishVideo::dispatch($lesson, storage_path("app/sources/{$lesson->slug}.mp4"))->onQueue('video');
}
php artisan queue:work --queue=video --timeout=3600

The lesson's id is the video's id, so a job that runs again after a failure uploads to the same path; remove the old video first if any of it was stored.

On this page