Miraiclip SDK
Build an editor

Asset import

Turn files the user picks or drops into assets and clips.

Importing a file takes four steps: get a File, make a URL for it, read its duration and size, then dispatch asset/add. The core stores metadata only; the renderer loads src when it draws or plays the asset.

Picking and dropping files

declare function importFile(file: File): Promise<string | null>; // below

const picker = document.querySelector<HTMLInputElement>("#file")!; // <input type="file" multiple accept="video/*,audio/*,image/*">
picker.addEventListener("change", () => {
  for (const file of picker.files ?? []) void importFile(file);
  picker.value = ""; // so picking the same file again fires "change"
});

const dropZone = document.querySelector<HTMLElement>("#timeline")!;
dropZone.addEventListener("dragover", (e) => e.preventDefault()); // required to allow a drop
dropZone.addEventListener("drop", (e) => {
  e.preventDefault();
  for (const file of e.dataTransfer?.files ?? []) void importFile(file);
});

Reading duration and size

KindRead withGives
VideoopenMediabunnyDemuxer from @miraiclip/rendererDuration, display size, frame rate, codec
AudioAn HTMLAudioElementDuration
ImagecreateImageBitmapWidth and height
import { openMediabunnyDemuxer } from "@miraiclip/renderer";

interface MediaInfo {
  durationUs?: number;
  width?: number;
  height?: number;
  fps?: number;
}

async function probeVideo(assetId: string, file: File): Promise<MediaInfo> {
  const demuxer = await openMediabunnyDemuxer(assetId, file); // throws UnsupportedMediaError without a video track
  try {
    const info = await demuxer.info();
    await demuxer.decoderConfig(); // throws UnsupportedMediaError if this browser can't decode the codec
    return { durationUs: info.durationUs, width: info.width, height: info.height, fps: info.fps };
  } finally {
    demuxer.dispose();
  }
}

function probeAudio(url: string): Promise<MediaInfo> {
  return new Promise((resolve, reject) => {
    const audio = new Audio();
    audio.preload = "metadata";
    audio.onloadedmetadata = () =>
      resolve(Number.isFinite(audio.duration) ? { durationUs: Math.round(audio.duration * 1_000_000) } : {});
    audio.onerror = () => reject(new Error("This browser can't read the audio file"));
    audio.src = url;
  });
}

async function probeImage(file: File): Promise<MediaInfo> {
  const bitmap = await createImageBitmap(file);
  const info = { width: bitmap.width, height: bitmap.height };
  bitmap.close();
  return info;
}

demuxer.info().durationUs is the video track's length in whole microseconds. Some files, such as WebM recordings from MediaRecorder, report audio.duration as Infinity; leave durationUs out then.

Adding the asset

import type { AssetKind } from "@miraiclip/core";

interface MediaInfo { durationUs?: number; width?: number; height?: number; fps?: number }
declare function probeVideo(assetId: string, file: File): Promise<MediaInfo>;
declare function probeAudio(url: string): Promise<MediaInfo>;
declare function probeImage(file: File): Promise<MediaInfo>;

function kindOf(file: File): Exclude<AssetKind, "font"> | null {
  if (file.type.startsWith("video/")) return "video";
  if (file.type.startsWith("audio/")) return "audio";
  if (file.type.startsWith("image/")) return "image";
  return null;
}

async function importFile(file: File): Promise<string | null> {
  const kind = kindOf(file);
  if (!kind) return null;
  const id = crypto.randomUUID();
  const src = URL.createObjectURL(file);
  try {
    const info =
      kind === "video" ? await probeVideo(id, file) : kind === "audio" ? await probeAudio(src) : await probeImage(file);
    project.dispatch({ type: "asset/add", payload: { id, kind, src, name: file.name, ...info } });
    return id;
  } catch (error) {
    URL.revokeObjectURL(src);
    throw error;
  }
}
asset/add fieldNotes
idYour id. Must be unique (duplicate-id)
kindvideo, audio, image or font
srcAny URL the browser can fetch, including blob: URLs
durationUsPositive integer. Timeline trims clamp against it (Timeline)
width, heightPositive integers
fpsPositive number
nameDisplay name for your media bin

Then place a clip. Images and text have no intrinsic length, so pick one:

const assetId = "photo-1";
const asset = project.getState().doc.assets[assetId]!;

project.dispatch({
  type: "clip/add",
  payload: {
    kind: "image",
    id: crypto.randomUUID(),
    trackId: "main",
    assetId,
    startUs: project.getState().playheadUs,
    durationUs: asset.durationUs ?? 5_000_000,
  },
});

To add the asset and its clip as one undo step, dispatch both inside project.transaction.

Fonts

A font asset needs family, the name text clips use in fontFamily. Files rarely carry a usable MIME type, so ask the user for the family or derive it from the file name.

function importFont(file: File, family: string, weight = 400): string {
  const id = crypto.randomUUID();
  project.dispatch({
    type: "asset/add",
    payload: { id, kind: "font", src: URL.createObjectURL(file), family, weight, name: file.name },
  });
  return id;
}

weight is 100–900 in steps of 100. For a variable font, pass weightRange: [100, 900] instead. The player loads font assets when they're added. See Assets → Fonts and Typography.

Object URLs

FactWhat to do
A blob: URL lives until URL.revokeObjectURL or the page unloadsKeep a map of asset id → URL
asset/remove can be undoneDon't revoke on remove; the asset can come back with a dead URL. Revoke when the project closes
Saved documents keep the blob: URLIt's dead after a reload. Store the file and make a new URL on load (below)
Servers can't fetch blob: URLsFor server export, upload the file and use its https: URL

Keeping files across reloads

Store each file under its asset id, for example in the origin private file system (OPFS) or IndexedDB. On load, swap in fresh object URLs before createProject: asset/set-property can't change src.

import { createProject, type ProjectDocument } from "@miraiclip/core";

async function storeFile(assetId: string, file: File) {
  const dir = await navigator.storage.getDirectory();
  const handle = await dir.getFileHandle(assetId, { create: true });
  const writable = await handle.createWritable();
  await writable.write(file);
  await writable.close();
}

async function openProject(saved: ProjectDocument) {
  const dir = await navigator.storage.getDirectory();
  const doc = structuredClone(saved);
  for (const asset of Object.values(doc.assets)) {
    if (!asset.src.startsWith("blob:")) continue;
    const file = await (await dir.getFileHandle(asset.id)).getFile();
    asset.src = URL.createObjectURL(file);
  }
  return createProject(doc);
}

Call storeFile(id, file) in importFile after asset/add. Browser storage can be cleared by the user or under storage pressure; navigator.storage.persist() asks the browser to keep it.

Stock and generated audio

@miraiclip/audio-sources searches stock libraries and runs audio generators. Its importAudio adds the asset (with source, license and attribution) and a clip in one transaction:

import { importAudio, type ResolvedAudio } from "@miraiclip/audio-sources";

declare const resolved: ResolvedAudio; // from library.resolve(item) or a finished generation job

const { assetId, clipId, trackId } = importAudio(project, resolved, {
  atUs: project.getState().playheadUs, // the default
  assetId: "music-1",
  clipId: "music-1-clip",
});
OptionDefault
atUsThe playhead
trackIdThe first audio track free for the clip's span, else a new track
durationUsThe file's duration, else 10 s
assetId, clipIdGenerated
assetOnlyfalse; true adds the asset without a clip

Providers, generators and licenses: Audio.

On this page