브라우저에서 MP4로 export하기
이 레시피를 마치면 서버 없이 브라우저 안에서만 프로젝트를 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 mediabunnymediabunny는 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"도 있어야 합니다.
-
렌더 워커를 시작합니다. 워커가 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에도 프리뷰와 같은 폰트 파일을 넘기십시오. -
페이지에서 워커에 연결합니다.
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)); -
프레임을 예약합니다.
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초); -
싱크를 만듭니다. 먼저 브라우저가 이 크기의 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에 있습니다. -
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이며, 모든 브라우저에 있지는 않습니다.
