콘텐츠로 이동

첫 렌더링

This content is for v0.4. Switch to the latest version for up-to-date documentation.

이 튜토리얼에서는 프로젝트를 한 단계씩 직접 작성하고, 각 단계를 엔진이 이 페이지에서 렌더링하는 모습을 확인합니다. 마지막에는 완성한 프로젝트를 브라우저, Node.js, 명령줄, Rust, Python에서 실행합니다.

처음 네 단계를 따라 하는 데는 아무것도 설치할 필요가 없습니다. 이 페이지의 프리뷰는 모두 브라우저 안에서 엔진을 실행합니다.

프로젝트는 settings(캔버스 크기, 프레임 레이트, 오디오 샘플 레이트), sources, timeline으로 시작합니다. 여기서는 두 소스 모두 제너레이터(generated) 소스입니다. 엔진이 직접 그리므로 불러올 미디어가 없습니다. 하나는 그라디언트 채우기이고, 다른 하나는 텍스트 한 줄입니다.

각 소스는 트랙 위의 클립을 통해 타임라인에 나타납니다. 트랙은 뒤에서 앞 순서로 그려지므로 타이틀 트랙이 배경 트랙 뒤에 옵니다. 시간 단위는 틱이며 1초가 120,000틱입니다. 360000은 3초입니다.

{
"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는 클립이 타임라인에서 차지하는 구간이고, source_range는 클립이 재생하는 소스의 구간입니다. 둘 다 반열린 구간 [start, end)입니다.
  • 위치는 캔버스에 대한 비율이며, (0, 0)이 왼쪽 위, (1, 1)이 오른쪽 아래입니다. 텍스트의 anchor(여기서는 아래쪽 가운데)가 position에 놓입니다.
  • font_size는 캔버스 짧은 변에 대한 비율입니다.

이미지 소스를 하나 추가하고, 배경과 타이틀 사이에 이 소스를 위한 트랙을 넣습니다. locator는 엔진이 절대 열지 않는 불투명한 문자열이며, asset://coast가 무엇을 뜻하는지는 호스트가 정합니다. 이 페이지에서는 샘플 사진을 가리키고, 명령줄에서는 파일에 대응시킵니다.

클립의 visual이 사진을 80%로 줄이므로 주변으로 그라디언트가 보입니다. 생략한 필드는 기본값을 따릅니다. 기본값은 가운데 정렬, 회전 없음, 불투명입니다.

{
"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": []
}
]
}
}

이펙트는 컴포지션, 즉 이미지 연산으로 이루어진 작은 그래프 안에 들어갑니다. 이 컴포지션은 클립의 그림("type": "clip")을 받아 alpha2.effect.color-controls로 채도를 높이고, alpha2.effect.vignette로 가장자리를 어둡게 합니다. 각 이펙트 노드는 input을 지정하고, output은 클립이 보여 줄 이미지를 내는 노드를 가리킵니다.

클립은 "composition": { "id": "look" }으로 이 컴포지션을 사용합니다. 이펙트 파라미터는 애니메이션할 수 있는 값이므로, 상수라도 { "mode": "constant", "value": ... } 형태로 쓰고 그 안에 타입이 있는 값을 넣습니다.

{
"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": []
}
]
}
}

같은 트랙에 사진을 하나 더 놓습니다. 한 트랙의 클립은 트랜지션이 이어 주는 구간에서만 겹칠 수 있으므로, 두 클립을 1초(2.5초~3.5초) 겹치게 하고 그 겹친 구간을 정확히 덮는 트랜지션을 넣습니다. 배경과 타이틀 클립은 6초로 늘립니다.

두 사진 클립은 모두 look 컴포지션을 다시 씁니다.

{
"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": []
}
]
}
}

이것으로 프로젝트가 완성되었습니다. first-render.json으로 저장하세요.

모든 대상 환경이 같은 파일을 읽습니다. 달라지는 것은 미디어와 폰트를 엔진에 전달하는 방법입니다. 프로젝트는 coast와 forest라는 소스를 사용하며, 각 호스트가 이 id를 실제 그림에 대응시킵니다.

npm install alpha2. 앱이 사진을 디코딩해서 픽셀을 업로드하고, 엔진은 캔버스에 그립니다.

import init, { WebEffectRegistry } from "alpha2/wasm";
await init();
const engine = await WebEffectRegistry.createForHtmlCanvas(document.querySelector("canvas"));
// 텍스트는 등록된 폰트로 그립니다: 프로젝트가 요청하는 패밀리 이름, 폰트 바이트, 페이스 인덱스.
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. 이 사진들은 불투명하므로 캔버스 픽셀이 이미 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); // 3초 지점, 트랜지션 한가운데의 프레임

renderAt은 프레임이 없었던 소스를 알려 주는 JSON을 반환합니다. 파일로 export하는 방법은 브라우저를 참고하세요. beginExport가 프로젝트의 프레임 그리드를 따라가며 인코더에 넘길 I420 프레임을 돌려줍니다.