Videos

Working with Videos

Videos in Val

A video field is s.video(). Like an image, its value is an object with a path, plus what Val read off the file - mimeType, width, height and duration - and what an editor chose in Val Studio: a poster, a start and end time, a focal point, a description and captions.

mimeType is required, because a page has to know whether it holds an .mp4 or an HLS stream before it can play it. Val Studio writes all of it on upload, and npx val validate --fix fills in what is read from the file for a video you added by hand.

Defining a video field

const pageSchema = s.object({
  heroVideo: s.video({ dir: "/public/val/videos" }),
  tutorialVideo: s.video({ accept: "video/mp4,video/webm" }).nullable(),
});

export default c.define("/content/page.val.ts", pageSchema, {
  heroVideo: {
    path: "/public/val/videos/hero_a1b2c.mp4",
    mimeType: "video/mp4",
    width: 1920,
    height: 1080,
    duration: 12.5,
    alt: "The office at sunrise",
  },
  tutorialVideo: null,
});

Streaming: HLS made in the browser

Set stream: { type: "hls" } and Val Studio converts every upload to an HLS stream before it is uploaded - in the browser, with no server or third-party service involved. It makes one rendition per height in renditions (default 1080, 720 and 480) that fits the upload - never upscaling: an upload shorter than all of them gets one rendition at its own height - and stores the stream as a directory whose master playlist is the path.

The conversion uses WebCodecs, so it needs a browser with an H.264 encoder, in a secure context (https or localhost). Where that is missing, the original file is uploaded as it is - so a field with stream set can still hold an .mp4, and should be rendered as either.

const pageSchema = s.object({
  intro: s.video({
    dir: "/public/val/videos",
    stream: { type: "hls", renditions: [720, 480] },
  }),
});

A directory of videos

For a shared pool of videos that many pages use, define a module with s.videodir() and pick from it with s.video(videosVal). The directory is the first argument. Each entry in the directory holds the file's metadata and, as defaults, its description, poster, start and end, focal point and captions; a field that picks the video stores only the path and whatever it overrides. (A description per locale is the exception: it stays in the directory, since the field's own alt is a single string.)

// content/videos.val.ts
export default c.define(
  "/content/videos.val.ts",
  s.videodir("/public/val/videos", { stream: { type: "hls" } }),
  {},
);

// content/page.val.ts
import videosVal from "./videos.val";

const pageSchema = s.object({
  intro: s.video(videosVal),
});

Rendering Videos

Render a video with ValVideo. It applies everything an editor can set - the poster, the caption tracks, the start and end time and the focal point - and keeps the video click-to-edit in Val Studio. It sets src, poster and the caption tracks itself, so it does not take those props; every other <video> prop, such as controls, autoPlay or className, is passed through.

Safari plays HLS on its own; most other browsers need hls.js, which Val does not install for you - add it to your app with npm install hls.js. Then pass a loader for it with the hls prop: it is only called when the video is a stream and the browser needs it, so a page of plain mp4s never downloads it.

ValVideo is a client component, and hls is a function, which a server component cannot pass to one. So render a stream from a client component, reading the content with useVal. A video without hls can be rendered from a server component too.

Rendering a video

"use client";
import { ValVideo } from "@valbuild/next";
import { useVal } from "@/val/val.client";
// page.val.ts: s.object({ heroVideo: s.video(), intro: s.video(videosVal) })
import pageVal from "./page.val";

export function Videos() {
  const { heroVideo, intro } = useVal(pageVal);

  return (
    <>
      <ValVideo src={heroVideo} autoPlay muted loop playsInline />
      <ValVideo src={intro} hls={() => import("hls.js")} controls />
    </>
  );
}

Remote Videos

Video files are large, so they are usually better kept out of your git repository. Add .remote() to store them on Val's remote server. See the Remote Files guide for details on setup and uploading.

Remote video example

const pageSchema = s.object({
  heroVideo: s.video().remote(),
});

// or a whole directory of them
s.videodir("/public/val/videos").remote();