콘텐츠로 이동

브라우저에서 MP4로 export하기

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

이 레시피를 마치면 서버 없이 브라우저 안에서만 프로젝트를 MP4 파일로 export할 수 있습니다. 엔진이 워커에서 GPU로 프레임을 렌더링하고, 브라우저의 WebCodecs 인코더가 압축하고, mediabunny가 컨테이너를 씁니다.

이 레시피는 alpha2 패키지의 저수준 구성 요소를 씁니다. 편집기를 만든다면 @alpha2-sh/editor/browser가 가져오기, 프리뷰, export를 모두 bridge.startExport() 뒤에 감싸 둡니다. 편집기를 참고하십시오.

그라디언트 위의 제목입니다. 생성 소스만 있으므로 디코딩할 것 없이 폰트 하나만 있으면 됩니다.

{
"schema_version": 1,
"id": "browser-export",
"name": "Browser export",
"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.17,
"green": 0.36,
"blue": 0.99,
"alpha": 1
}
},
{
"offset": 1,
"color": {
"red": 0.7,
"green": 0.11,
"blue": 0.98,
"alpha": 1
}
}
]
}
}
},
{
"id": "title",
"kind": "generated",
"generator": {
"type": "text",
"text": "Exported in the browser",
"style": {
"font_family": "Hanken Grotesk",
"font_size": 0.1,
"position": {
"x": 0.5,
"y": 0.5
},
"anchor": {
"x": 0.5,
"y": 0.5
}
}
}
}
],
"timeline": {
"tracks": [
{
"id": "back",
"name": "Backdrop",
"kind": "video",
"clips": [
{
"id": "backdrop-clip",
"source_id": "backdrop",
"timeline_range": {
"start": 0,
"end": 240000
},
"source_range": {
"start": 0,
"end": 240000
}
}
],
"transitions": []
},
{
"id": "titles",
"name": "Title",
"kind": "video",
"clips": [
{
"id": "title-clip",
"source_id": "title",
"timeline_range": {
"start": 0,
"end": 240000
},
"source_range": {
"start": 0,
"end": 240000
},
"visual": {
"opacity": {
"mode": "keyframes",
"keyframes": [
{
"time": 0,
"value": 0,
"interpolation": {
"type": "cubic_bezier",
"x1": 0.2,
"y1": 0,
"x2": 0,
"y2": 1
}
},
{
"time": 72000,
"value": 1,
"interpolation": {
"type": "hold"
}
}
]
}
}
}
],
"transitions": []
}
]
}
}
터미널 창
npm install alpha2 mediabunny

mediabunny는 alpha2의 선택적 의존성으로, alpha2/mediabunny가 씁니다. TypeScript에서는 @types/dom-webcodecs가 함께 설치되는데, 이 선언이 TypeScript 5.9의 DOM 라이브러리와 충돌합니다. skipLibCheck로 컴파일하거나 package.json에 "overrides": { "@types/dom-webcodecs": "0.1.19" }를 추가하십시오. alpha2/mediabunny를 import하는 앱은 lib에 "ESNext.Disposable"도 있어야 합니다.

  1. 렌더 워커를 시작합니다. 워커가 GPU 디바이스를 소유합니다. 엔진을 불러오고, 폰트를 등록하고, 프로젝트를 로드한 다음 페이지의 요청에 응답합니다.

    export-worker.js
    import init, { WebEffectRegistry } from "alpha2/wasm";
    import { createWasmRenderWorkerHost, installRenderWorker } from "alpha2/worker";
    self.onmessage = async ({ data }) => {
    await init();
    installRenderWorker(
    self,
    createWasmRenderWorkerHost(WebEffectRegistry, data.projectJson, {
    fonts: [{ family: "Hanken Grotesk", bytes: data.font }],
    }),
    );
    self.postMessage({ type: "ready" });
    };

    installRenderWorker는 self.onmessage를 자신의 요청 루프로 바꾸므로, 위 코드가 처리하는 메시지는 이 첫 메시지뿐입니다. 텍스트가 똑같이 그려지도록 export에도 프리뷰와 같은 폰트 파일을 넘기십시오.

  2. 페이지에서 워커에 연결합니다.

    export.js
    import { WorkerExportBackend, WorkerRenderBackend } from "alpha2/worker";
    import { Alpha2Exporter, exportToSink } from "alpha2/exporter";
    import { canEncodeVideoTrack, MediabunnySink } from "alpha2/mediabunny";
    const worker = new Worker(new URL("./export-worker.js", import.meta.url), { type: "module" });
    const font = new Uint8Array(await (await fetch("/fonts/HankenGrotesk.ttf")).arrayBuffer());
    const ready = new Promise((resolve) =>
    worker.addEventListener("message", function onReady(event) {
    if (event.data?.type !== "ready") return;
    worker.removeEventListener("message", onReady);
    resolve();
    }),
    );
    worker.postMessage({ projectJson: JSON.stringify(project), font });
    await ready;
    const backend = new WorkerRenderBackend(worker);
    // export는 프로젝트 크기로 오프스크린 렌더링합니다. 이 캔버스는 프리뷰용 표면일 뿐입니다.
    await backend.initialize(new OffscreenCanvas(64, 36));
  3. 프레임을 예약합니다. Alpha2Exporter는 틱 범위 안에서 프로젝트의 프레임 격자를 따라갑니다. WorkerExportBackend는 코덱에 맞는 픽셀 배치로 워커를 통해 프레임을 렌더링합니다.

    const { width, height, frame_rate } = project.settings;
    const rate = { num: BigInt(frame_rate.num), den: BigInt(frame_rate.den) };
    const exporter = new Alpha2Exporter(
    new WorkerExportBackend(backend, "h264"),
    rate,
    0n,
    240_000n, // 틱 단위 [start, end): 2초
    );
  4. 싱크를 만듭니다. 먼저 브라우저가 이 크기의 H.264를 인코딩할 수 있는지 물어봅니다. canEncodeVideoTrack은 인코딩할 수 없으면 null을, 할 수 있으면 인코더를 열 코덱 문자열을 돌려줍니다.

    const videoRateControl = { bitrate: 8_000_000, bitrateMode: "variable" };
    const encoding = await canEncodeVideoTrack("avc", { width, height, rate: videoRateControl });
    if (encoding === null) throw new Error("this browser has no H.264 encoder at this size");
    const sink = new MediabunnySink({
    container: "mp4",
    videoCodec: "avc",
    videoRateControl,
    ...(encoding.codecString === undefined ? {} : { videoCodecString: encoding.codecString }),
    frameRate: frame_rate,
    destination: { kind: "memory", maxBytes: 256 * 1024 * 1024 },
    });

    frameRate를 꼭 넘기십시오. 없으면 muxer가 부동소수점 차이로 프레임 길이를 계산해 영상이 떨립니다. 코덱은 컨테이너가 지원하는 것이어야 합니다. 비디오는 avc, hevc, vp9, av1, 오디오는 aac, opus이며, 어느 컨테이너가 무엇을 받는지는 alpha2/containers에 있습니다.

  5. export를 실행하고 파일을 내려받습니다.

    const controller = new AbortController(); // controller.abort()로 취소
    try {
    const result = await exportToSink(
    exporter,
    sink,
    async (waiting) => {
    throw new Error(`no media for ${waiting.missingSources.join(", ")}`);
    },
    { signal: controller.signal },
    );
    console.log(`${result.frames} frames at ${result.fps.toFixed(1)} fps`);
    } finally {
    backend.dispose();
    }
    const output = sink.output; // finish()가 끝나면 { kind: "memory", buffer }
    const url = URL.createObjectURL(new Blob([output.buffer], { type: "video/mp4" }));
    const link = Object.assign(document.createElement("a"), { href: url, download: "title.mp4" });
    link.click();
    URL.revokeObjectURL(url);

    세 번째 인자인 refill은 워커에 없는 소스 프레임이 필요할 때 호출됩니다. 이 프로젝트에는 디코딩할 것이 없으므로 호출되지 않습니다.

exportToSink는 기본적으로 프레임 3개, 리드백 128MiB까지 동시에 처리합니다(maxInFlightFrames, maxInFlightBytes). 실행 중에는 exporter.progress가 bigint로 된 { completed, total }을 돌려줍니다. signal을 abort하면 code가 cancelled인 ExportAbortedError로 거부되고, GPU를 잃으면 code가 device-lost입니다.

프로젝트에 영상 소스가 있으면 refill이 엔진이 요청한 프레임을 디코딩해 워커에 업로드합니다. MediabunnyVideoDecoder는 Blob이나 File을 디코딩합니다.

import { MediabunnyVideoDecoder } from "alpha2/mediabunny";
import { decoderConsumerId } from "alpha2/media";
const decoders = new Map();
for (const source of project.sources.filter((s) => s.kind === "video"))
decoders.set(source.id, await MediabunnyVideoDecoder.open(source.id, files.get(source.id)));
const refill = async (waiting) => {
for (const { clipId, readerId, sourceId, mediaTimeTicks } of waiting.missingFrames) {
const decoded = await decoders.get(sourceId).refill({
sourceId,
mediaTimeTicks,
reason: "prefetch",
generation: 0,
consumerId: decoderConsumerId(clipId, readerId),
});
await backend.uploadVideoFrame(decoded);
}
};

디코더는 export가 끝나면 닫으십시오(decoder.close()). refill을 네 번 해도 계속 기다리는 프레임이 있으면 export는 RESOURCE_LIMIT로 실패합니다.

메모리 대상은 파일 전체를 메모리에 들고 있으며, maxBytes를 넘으면 export가 RESOURCE_LIMIT로 실패합니다. 긴 export는 파일로 스트리밍하십시오. alpha2/export-destination의 WritableFileDestination은 FileSystemWritableFileStream처럼 위치를 지정해 쓸 수 있는 파일을 받습니다.

import { WritableFileDestination } from "alpha2/export-destination";
const handle = await window.showSaveFilePicker({ suggestedName: "title.mp4" });
const file = new WritableFileDestination(await handle.createWritable());
const sink = new MediabunnySink({
// ...위의 옵션에 더해:
destination: { kind: "stream", writable: file.writable },
});

showSaveFilePicker는 File System Access API이며, 모든 브라우저에 있지는 않습니다.