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
| Kind | Read with | Gives |
|---|---|---|
| Video | openMediabunnyDemuxer from @miraiclip/renderer | Duration, display size, frame rate, codec |
| Audio | An HTMLAudioElement | Duration |
| Image | createImageBitmap | Width 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 field | Notes |
|---|---|
id | Your id. Must be unique (duplicate-id) |
kind | video, audio, image or font |
src | Any URL the browser can fetch, including blob: URLs |
durationUs | Positive integer. Timeline trims clamp against it (Timeline) |
width, height | Positive integers |
fps | Positive number |
name | Display 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
| Fact | What to do |
|---|---|
A blob: URL lives until URL.revokeObjectURL or the page unloads | Keep a map of asset id → URL |
asset/remove can be undone | Don't revoke on remove; the asset can come back with a dead URL. Revoke when the project closes |
Saved documents keep the blob: URL | It's dead after a reload. Store the file and make a new URL on load (below) |
Servers can't fetch blob: URLs | For 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",
});| Option | Default |
|---|---|
atUs | The playhead |
trackId | The first audio track free for the clip's span, else a new track |
durationUs | The file's duration, else 10 s |
assetId, clipId | Generated |
assetOnly | false; true adds the asset without a clip |
Providers, generators and licenses: Audio.