Skip to content

Your first render

In this tutorial you write a project by hand, one step at a time, and watch the engine render each step on this page. At the end you run the finished project in the browser, in Node.js, from the command line, in Rust and in Python.

You need nothing installed to follow the first four steps: every preview here runs the engine in your browser.

A project starts with its settings (canvas size, frame rate, audio sample rate), its sources and its timeline. Here both sources are generated: the engine draws them, so there is no media to load. One is a gradient fill, the other a line of text.

Each source appears on the timeline through a clip on a track. Tracks are drawn back to front, so the title track comes after the background track. Times are in ticks, 120,000 to the second: 360000 is three seconds.

{
"schema_version": 1,
"id": "first-render",
"name": "First render",
"settings": { "width": 1280, "height": 720, "frame_rate": { "num": 30, "den": 1 }, "sample_rate": 48000 },
"sources": [
{
"id": "backdrop",
"kind": "generated",
"generator": {
"type": "fill",
"paint": {
"type": "linear_gradient",
"start": { "x": 0, "y": 0 },
"end": { "x": 1, "y": 1 },
"stops": [
{ "offset": 0, "color": { "red": 0.05, "green": 0.07, "blue": 0.2, "alpha": 1 } },
{ "offset": 1, "color": { "red": 0.35, "green": 0.08, "blue": 0.4, "alpha": 1 } }
]
}
}
},
{
"id": "title",
"kind": "generated",
"generator": {
"type": "text",
"text": "My first render",
"style": {
"font_family": "Hanken Grotesk",
"font_size": 0.1,
"position": { "x": 0.5, "y": 0.9 },
"anchor": { "x": 0.5, "y": 1 }
}
}
}
],
"timeline": {
"tracks": [
{
"id": "background",
"name": "Background",
"kind": "video",
"clips": [
{
"id": "backdrop-clip",
"source_id": "backdrop",
"timeline_range": { "start": 0, "end": 360000 },
"source_range": { "start": 0, "end": 360000 }
}
],
"transitions": []
},
{
"id": "titles",
"name": "Titles",
"kind": "video",
"clips": [
{
"id": "title-clip",
"source_id": "title",
"timeline_range": { "start": 0, "end": 360000 },
"source_range": { "start": 0, "end": 360000 }
}
],
"transitions": []
}
]
}
}
  • timeline_range is where the clip sits on the timeline, source_range the part of the source it plays. Both are half-open, [start, end).
  • Positions are fractions of the canvas, (0, 0) top-left to (1, 1) bottom-right. The text’s anchor (its bottom centre) is placed at position.
  • font_size is a fraction of the canvas’s shorter side.

Add an image source and a track for it between the background and the title. The locator is an opaque string the engine never opens: the host decides what asset://coast means. On this page it is a sample photo; on the command line you map it to a file.

The clip’s visual scales the photo to 80%, so the gradient shows around it. Fields you leave out keep their defaults: centred, unrotated, opaque.

{
"schema_version": 1,
"id": "first-render",
"name": "First render",
"settings": { "width": 1280, "height": 720, "frame_rate": { "num": 30, "den": 1 }, "sample_rate": 48000 },
"sources": [
{
"id": "backdrop",
"kind": "generated",
"generator": {
"type": "fill",
"paint": {
"type": "linear_gradient",
"start": { "x": 0, "y": 0 },
"end": { "x": 1, "y": 1 },
"stops": [
{ "offset": 0, "color": { "red": 0.05, "green": 0.07, "blue": 0.2, "alpha": 1 } },
{ "offset": 1, "color": { "red": 0.35, "green": 0.08, "blue": 0.4, "alpha": 1 } }
]
}
}
},
{
"id": "title",
"kind": "generated",
"generator": {
"type": "text",
"text": "My first render",
"style": {
"font_family": "Hanken Grotesk",
"font_size": 0.1,
"position": { "x": 0.5, "y": 0.9 },
"anchor": { "x": 0.5, "y": 1 }
}
}
},
{ "id": "coast", "kind": "image", "locator": "asset://coast" }
],
"timeline": {
"tracks": [
{
"id": "background",
"name": "Background",
"kind": "video",
"clips": [
{
"id": "backdrop-clip",
"source_id": "backdrop",
"timeline_range": { "start": 0, "end": 360000 },
"source_range": { "start": 0, "end": 360000 }
}
],
"transitions": []
},
{
"id": "photos",
"name": "Photos",
"kind": "video",
"clips": [
{
"id": "coast-clip",
"source_id": "coast",
"timeline_range": { "start": 0, "end": 360000 },
"source_range": { "start": 0, "end": 360000 },
"visual": { "scale": { "mode": "constant", "value": { "x": 0.8, "y": 0.8 } } }
}
],
"transitions": []
},
{
"id": "titles",
"name": "Titles",
"kind": "video",
"clips": [
{
"id": "title-clip",
"source_id": "title",
"timeline_range": { "start": 0, "end": 360000 },
"source_range": { "start": 0, "end": 360000 }
}
],
"transitions": []
}
]
}
}

Effects live in compositions: small graphs of image operations. This one takes the clip’s picture ("type": "clip"), raises its saturation with alpha2.effect.color-controls and darkens its corners with alpha2.effect.vignette. Each effect node names its input, and output names the node whose image the clip shows.

The clip opts in with "composition": { "id": "look" }. Effect parameters are animatable values, so even a constant one is written { "mode": "constant", "value": ... }, with a typed value inside.

{
"schema_version": 1,
"id": "first-render",
"name": "First render",
"settings": { "width": 1280, "height": 720, "frame_rate": { "num": 30, "den": 1 }, "sample_rate": 48000 },
"sources": [
{
"id": "backdrop",
"kind": "generated",
"generator": {
"type": "fill",
"paint": {
"type": "linear_gradient",
"start": { "x": 0, "y": 0 },
"end": { "x": 1, "y": 1 },
"stops": [
{ "offset": 0, "color": { "red": 0.05, "green": 0.07, "blue": 0.2, "alpha": 1 } },
{ "offset": 1, "color": { "red": 0.35, "green": 0.08, "blue": 0.4, "alpha": 1 } }
]
}
}
},
{
"id": "title",
"kind": "generated",
"generator": {
"type": "text",
"text": "My first render",
"style": {
"font_family": "Hanken Grotesk",
"font_size": 0.1,
"position": { "x": 0.5, "y": 0.9 },
"anchor": { "x": 0.5, "y": 1 }
}
}
},
{ "id": "coast", "kind": "image", "locator": "asset://coast" }
],
"compositions": [
{
"id": "look",
"nodes": [
{ "id": "picture", "op": { "type": "clip" } },
{
"id": "grade",
"op": {
"type": "effect",
"input": "picture",
"effect": {
"id": "alpha2.effect.color-controls",
"version": 1,
"params": {
"saturation": { "mode": "constant", "value": { "type": "float", "value": 1.3 } }
}
}
}
},
{
"id": "vignette",
"op": {
"type": "effect",
"input": "grade",
"effect": {
"id": "alpha2.effect.vignette",
"version": 1,
"params": { "amount": { "mode": "constant", "value": { "type": "float", "value": 0.6 } } }
}
}
}
],
"output": "vignette"
}
],
"timeline": {
"tracks": [
{
"id": "background",
"name": "Background",
"kind": "video",
"clips": [
{
"id": "backdrop-clip",
"source_id": "backdrop",
"timeline_range": { "start": 0, "end": 360000 },
"source_range": { "start": 0, "end": 360000 }
}
],
"transitions": []
},
{
"id": "photos",
"name": "Photos",
"kind": "video",
"clips": [
{
"id": "coast-clip",
"source_id": "coast",
"timeline_range": { "start": 0, "end": 360000 },
"source_range": { "start": 0, "end": 360000 },
"visual": { "scale": { "mode": "constant", "value": { "x": 0.8, "y": 0.8 } } },
"composition": { "id": "look" }
}
],
"transitions": []
},
{
"id": "titles",
"name": "Titles",
"kind": "video",
"clips": [
{
"id": "title-clip",
"source_id": "title",
"timeline_range": { "start": 0, "end": 360000 },
"source_range": { "start": 0, "end": 360000 }
}
],
"transitions": []
}
]
}
}

Add a second photo on the same track. Clips on one track may overlap only where a transition joins them, so the two clips overlap by one second (2.5 s to 3.5 s) and a transition covers exactly that overlap. The background and title clips grow to six seconds.

Both photo clips reuse the look composition.

{
"schema_version": 1,
"id": "first-render",
"name": "First render",
"settings": { "width": 1280, "height": 720, "frame_rate": { "num": 30, "den": 1 }, "sample_rate": 48000 },
"sources": [
{
"id": "backdrop",
"kind": "generated",
"generator": {
"type": "fill",
"paint": {
"type": "linear_gradient",
"start": { "x": 0, "y": 0 },
"end": { "x": 1, "y": 1 },
"stops": [
{ "offset": 0, "color": { "red": 0.05, "green": 0.07, "blue": 0.2, "alpha": 1 } },
{ "offset": 1, "color": { "red": 0.35, "green": 0.08, "blue": 0.4, "alpha": 1 } }
]
}
}
},
{
"id": "title",
"kind": "generated",
"generator": {
"type": "text",
"text": "My first render",
"style": {
"font_family": "Hanken Grotesk",
"font_size": 0.1,
"position": { "x": 0.5, "y": 0.9 },
"anchor": { "x": 0.5, "y": 1 }
}
}
},
{ "id": "coast", "kind": "image", "locator": "asset://coast" },
{ "id": "forest", "kind": "image", "locator": "asset://forest" }
],
"compositions": [
{
"id": "look",
"nodes": [
{ "id": "picture", "op": { "type": "clip" } },
{
"id": "grade",
"op": {
"type": "effect",
"input": "picture",
"effect": {
"id": "alpha2.effect.color-controls",
"version": 1,
"params": {
"saturation": { "mode": "constant", "value": { "type": "float", "value": 1.3 } }
}
}
}
},
{
"id": "vignette",
"op": {
"type": "effect",
"input": "grade",
"effect": {
"id": "alpha2.effect.vignette",
"version": 1,
"params": { "amount": { "mode": "constant", "value": { "type": "float", "value": 0.6 } } }
}
}
}
],
"output": "vignette"
}
],
"timeline": {
"tracks": [
{
"id": "background",
"name": "Background",
"kind": "video",
"clips": [
{
"id": "backdrop-clip",
"source_id": "backdrop",
"timeline_range": { "start": 0, "end": 720000 },
"source_range": { "start": 0, "end": 720000 }
}
],
"transitions": []
},
{
"id": "photos",
"name": "Photos",
"kind": "video",
"clips": [
{
"id": "coast-clip",
"source_id": "coast",
"timeline_range": { "start": 0, "end": 420000 },
"source_range": { "start": 0, "end": 420000 },
"visual": { "scale": { "mode": "constant", "value": { "x": 0.8, "y": 0.8 } } },
"composition": { "id": "look" }
},
{
"id": "forest-clip",
"source_id": "forest",
"timeline_range": { "start": 300000, "end": 720000 },
"source_range": { "start": 0, "end": 420000 },
"visual": { "scale": { "mode": "constant", "value": { "x": 0.8, "y": 0.8 } } },
"composition": { "id": "look" }
}
],
"transitions": [
{
"from_clip": "coast-clip",
"to_clip": "forest-clip",
"timeline_range": { "start": 300000, "end": 420000 },
"easing": { "type": "linear" },
"effect": { "id": "alpha2.transition.fade", "version": 1 }
}
]
},
{
"id": "titles",
"name": "Titles",
"kind": "video",
"clips": [
{
"id": "title-clip",
"source_id": "title",
"timeline_range": { "start": 0, "end": 720000 },
"source_range": { "start": 0, "end": 720000 }
}
],
"transitions": []
}
]
}
}

This is the whole project. Save it as first-render.json.

Each target reads the same file. What changes is how the media and the font reach the engine: the project names sources coast and forest, and each host maps those ids to pictures.

npm install alpha2. Your app decodes the photos and uploads their pixels; the engine draws on a canvas.

import init, { WebEffectRegistry } from "alpha2/wasm";
await init();
const engine = await WebEffectRegistry.createForHtmlCanvas(document.querySelector("canvas"));
// Text draws with registered fonts: the family name the project asks for, the font bytes, the face index.
const font = new Uint8Array(await (await fetch("/fonts/HankenGrotesk.ttf")).arrayBuffer());
engine.addFont("Hanken Grotesk", font, 0);
const project = await (await fetch("/first-render.json")).json();
engine.loadProject(JSON.stringify(project));
// Premultiplied sRGB RGBA8. These photos are opaque, so canvas pixels are already premultiplied.
async function rgbaOf(url, width, height) {
const bitmap = await createImageBitmap(await (await fetch(url)).blob());
const canvas = new OffscreenCanvas(width, height);
const context = canvas.getContext("2d");
context.drawImage(bitmap, 0, 0, width, height);
return new Uint8Array(context.getImageData(0, 0, width, height).data.buffer);
}
engine.uploadRgbaImage("coast", 1280, 720, await rgbaOf("/coast.jpg", 1280, 720));
engine.uploadRgbaImage("forest", 1280, 720, await rgbaOf("/forest.jpg", 1280, 720));
engine.renderAt(360_000n); // the frame at 3 s, in the middle of the transition

renderAt returns JSON that names any source it had no frame for. To export a file, see Browser: beginExport walks the project’s frame grid and hands back I420 frames for your encoder.