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 videosTranscoding 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| Flag | What 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 introFrom the SDK
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)
| Option | What it does |
|---|---|
ffmpeg | The path to ffmpeg. Default: ffmpeg on the PATH. |
ffprobe | The 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)
| Option | What it does |
|---|---|
folder | The folder to store it in. Required. |
id | The 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.
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
--idmakes the video's idvideos/<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
--timeoutat 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
ffmpegsettings, uploads the same way: pass its directory instead of the source file.
The order of an upload
- Checked first, on your disk: every file name, every type, and every line of every playlist. Nothing is sent until the whole package passes.
- Playlists and the poster, then the segments, four files at a time, each straight to storage.
- 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.m3u8at 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:
| Extension | Content type | Folder types that admit it |
|---|---|---|
.m3u8 | application/vnd.apple.mpegurl | application/vnd.apple.mpegurl |
.ts | video/mp2t | video/* |
.m4s, .mp4 | video/iso.segment, video/mp4 (fMP4 / CMAF) | video/* |
.aac | audio/aac | audio/* |
.vtt | text/vtt (subtitles) | text/vtt |
.jpg, .jpeg, .png, .webp | images | image/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() },
},'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
| Case | What happens |
|---|---|
| A phone video recorded upright | The 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 sound | The package has video only; no silent audio track is added. |
| A 4K source | The ladder tops out at 1080p. Make your own package for a 2160p rung. |
| A source smaller than 360p | One 360p rung, scaled up. |
| A clip of two seconds or less | The poster is the first frame instead of the frame at one second. |
The same id again | Refused: a path that already holds a file is never overwritten. Upload under a new id, or remove the old video first. |
ffmpeg is not installed | ffmpeg was not found. Install ffmpeg (it brings ffprobe), or pass the path to it. Nothing is uploaded. |
ffmpeg fails on the source | The error ends with the last lines ffmpeg printed. Nothing is uploaded. |
A .key file in the package | Refused: keys never go into a folder. |
| The plan runs out of room halfway | That file is refused with 402, and the files already stored are removed. |
A file larger than the folder's maxBytes | Refused for that file; the upload is undone. Raise the folder's largest file or use shorter segments. |
| The connection drops halfway | The 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 branch | Refused: 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=3600The 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.