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.
1. A background and a title
Section titled “1. A background and a title”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_rangeis where the clip sits on the timeline,source_rangethe 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’sanchor(its bottom centre) is placed atposition. font_sizeis a fraction of the canvas’s shorter side.
2. Add a photo
Section titled “2. Add a photo”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": [] } ] }}3. Grade the photo with a composition
Section titled “3. Grade the photo with a composition”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": [] } ] }}4. Add a second photo and a transition
Section titled “4. Add a second photo and a transition”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.
Run it
Section titled “Run it”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 transitionrenderAt 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.
npm install @alpha2-sh/node, then npx @alpha2-sh/node install to fetch alpha2-host. The host decodes the photos and encodes the MP4 itself.
import { readFile } from "node:fs/promises";import { exportProject } from "@alpha2-sh/node";
const project = JSON.parse(await readFile("first-render.json", "utf8"));await exportProject({ project, assets: { coast: "coast.jpg", forest: "forest.jpg" }, // the file of each source id fonts: ["HankenGrotesk.ttf"], startTicks: 0, endTicks: 720_000, // six seconds frameRate: { num: 30, den: 1 }, output: "first-render.mp4",});See Node.js for progress, cancelling, stills and errors.
Get alpha2-host from a release archive (see CLI). Each --asset gives the file of one source, and --font a font file.
alpha2-host export --project first-render.json \ --asset coast=coast.jpg --asset forest=forest.jpg \ --font HankenGrotesk.ttf \ --out first-render.mp4The whole timeline is exported by default, at the project’s frame rate. The result is printed as JSON on standard output.
cargo add alpha2. The program reads and checks the document, then renders from frames it lends the engine.
use alpha2::{EngineProject, Project};
let project = Project::from_json(&std::fs::read_to_string("first-render.json")?)?;let project = EngineProject::new(project)?;// Lend the engine the decoded `coast` and `forest` pictures through a `FrameProvider`,// then render with `Player::render_at` or export with `Exporter::render_next`.The engine crate does not decode or encode files. Rust has the complete program: the GPU context, a FrameProvider, a preview frame and an export loop.
pip install alpha2, which also installs alpha2-host on the supported platforms. A project is a plain dict.
import json
import alpha2
with open("first-render.json") as file: project = json.load(file)
print(alpha2.validate_project(project)) # [] when the engine accepts itresult = alpha2.export( project, "first-render.mp4", assets={"coast": "coast.jpg", "forest": "forest.jpg"}, fonts=["HankenGrotesk.ttf"],)print(result["output"], result["frames"])See Python for stills, the authoring helpers and the compose API.
