@kitsra/kavio-render 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,171 @@
1
+ import { readFile } from "node:fs/promises";
2
+ import { createServer } from "node:http";
3
+ import { getCanvasDimensions } from "@kitsra/kavio-core";
4
+ import { createRenderHarnessHtml } from "@kitsra/kavio-browser-renderer";
5
+ /**
6
+ * Tiny localhost static server that feeds the headless render harness page to
7
+ * Chromium: the shared harness HTML, `/composition.json`, and the vendored
8
+ * `@kitsra/kavio-core` and `@kitsra/kavio-browser-renderer` ESM bundles.
9
+ */
10
+ export async function createRenderHarnessServer(options) {
11
+ const { composition, assets } = prepareHarnessComposition(options.composition);
12
+ const dimensions = getCanvasDimensions(composition.composition);
13
+ const html = createRenderHarnessHtml({
14
+ width: dimensions.width,
15
+ height: dimensions.height,
16
+ fps: composition.composition.fps,
17
+ durationFrames: composition.composition.durationFrames
18
+ });
19
+ const compositionJson = `${JSON.stringify(composition)}\n`;
20
+ const [coreSource, browserRendererSource] = await Promise.all([
21
+ readFile(new URL("../../core/dist/index.js", import.meta.url), "utf8"),
22
+ readFile(new URL("../../browser-renderer/dist/index.js", import.meta.url), "utf8")
23
+ ]);
24
+ const server = createServer((request, response) => {
25
+ handleRequest(request, response, { html, compositionJson, coreSource, browserRendererSource, assets });
26
+ });
27
+ const port = await listenOnLocalhost(server);
28
+ return {
29
+ url: `http://127.0.0.1:${port}/`,
30
+ close: () => closeServer(server)
31
+ };
32
+ }
33
+ function handleRequest(request, response, assets) {
34
+ const path = (request.url ?? "/").split("?", 1)[0] ?? "/";
35
+ if (path === "/" || path === "/index.html") {
36
+ send(response, 200, "text/html; charset=utf-8", assets.html);
37
+ return;
38
+ }
39
+ if (path === "/composition.json") {
40
+ send(response, 200, "application/json; charset=utf-8", assets.compositionJson);
41
+ return;
42
+ }
43
+ if (path === "/vendor/core/index.js") {
44
+ send(response, 200, "text/javascript; charset=utf-8", assets.coreSource);
45
+ return;
46
+ }
47
+ if (path === "/vendor/browser-renderer/index.js") {
48
+ send(response, 200, "text/javascript; charset=utf-8", assets.browserRendererSource);
49
+ return;
50
+ }
51
+ const assetId = assetIdFromPath(path);
52
+ if (assetId !== null) {
53
+ const asset = assets.assets.get(assetId);
54
+ if (asset === undefined) {
55
+ send(response, 404, "text/plain; charset=utf-8", "Not found.\n");
56
+ return;
57
+ }
58
+ void sendFile(response, asset);
59
+ return;
60
+ }
61
+ send(response, 404, "text/plain; charset=utf-8", "Not found.\n");
62
+ }
63
+ function send(response, statusCode, contentType, value) {
64
+ response.statusCode = statusCode;
65
+ response.setHeader("content-type", contentType);
66
+ response.setHeader("cache-control", "no-store");
67
+ response.end(value);
68
+ }
69
+ async function sendFile(response, asset) {
70
+ try {
71
+ const bytes = await readFile(asset.path);
72
+ response.statusCode = 200;
73
+ response.setHeader("content-type", asset.contentType);
74
+ response.setHeader("cache-control", "no-store");
75
+ response.end(bytes);
76
+ }
77
+ catch {
78
+ send(response, 404, "text/plain; charset=utf-8", "Not found.\n");
79
+ }
80
+ }
81
+ function prepareHarnessComposition(composition) {
82
+ const assets = new Map();
83
+ const clone = JSON.parse(JSON.stringify(composition));
84
+ for (const [assetId, asset] of Object.entries(clone.assets)) {
85
+ const localPath = localAssetPath(asset.src);
86
+ if (localPath === null) {
87
+ continue;
88
+ }
89
+ const servedId = encodeURIComponent(assetId);
90
+ assets.set(servedId, { path: localPath, contentType: contentTypeForPath(localPath, asset) });
91
+ asset.src = `/assets/${servedId}`;
92
+ }
93
+ return { composition: clone, assets };
94
+ }
95
+ function localAssetPath(src) {
96
+ if (src.startsWith("/")) {
97
+ return src;
98
+ }
99
+ if (src.startsWith("file://")) {
100
+ try {
101
+ return decodeURIComponent(new URL(src).pathname);
102
+ }
103
+ catch {
104
+ return null;
105
+ }
106
+ }
107
+ return null;
108
+ }
109
+ function assetIdFromPath(path) {
110
+ if (!path.startsWith("/assets/")) {
111
+ return null;
112
+ }
113
+ return path.slice("/assets/".length);
114
+ }
115
+ function contentTypeForPath(path, asset) {
116
+ const lower = path.toLowerCase();
117
+ if (lower.endsWith(".png")) {
118
+ return "image/png";
119
+ }
120
+ if (lower.endsWith(".jpg") || lower.endsWith(".jpeg")) {
121
+ return "image/jpeg";
122
+ }
123
+ if (lower.endsWith(".webp")) {
124
+ return "image/webp";
125
+ }
126
+ if (lower.endsWith(".svg")) {
127
+ return "image/svg+xml";
128
+ }
129
+ if (lower.endsWith(".mp4")) {
130
+ return "video/mp4";
131
+ }
132
+ if (lower.endsWith(".webm")) {
133
+ return "video/webm";
134
+ }
135
+ if (lower.endsWith(".wav")) {
136
+ return "audio/wav";
137
+ }
138
+ if (lower.endsWith(".mp3")) {
139
+ return "audio/mpeg";
140
+ }
141
+ if (lower.endsWith(".woff2")) {
142
+ return "font/woff2";
143
+ }
144
+ if (lower.endsWith(".woff")) {
145
+ return "font/woff";
146
+ }
147
+ return asset.type === "font" ? "font/woff2" : "application/octet-stream";
148
+ }
149
+ function listenOnLocalhost(server) {
150
+ return new Promise((resolve, reject) => {
151
+ server.listen(0, "127.0.0.1", () => {
152
+ const address = server.address();
153
+ if (address !== null && typeof address === "object" && typeof address.port === "number") {
154
+ resolve(address.port);
155
+ return;
156
+ }
157
+ reject(new Error("Harness server did not return a TCP address."));
158
+ });
159
+ });
160
+ }
161
+ function closeServer(server) {
162
+ return new Promise((resolve, reject) => {
163
+ server.close((error) => {
164
+ if (error) {
165
+ reject(error);
166
+ return;
167
+ }
168
+ resolve();
169
+ });
170
+ });
171
+ }
@@ -0,0 +1,12 @@
1
+ export declare const KAVIO_RENDER_PACKAGE = "@kitsra/kavio-render";
2
+ export { renderError, isRenderError, RENDER_ERROR_CODES, type RenderErrorCode, type RenderErrorOptions } from "./errors.js";
3
+ export { assembleRenderCommand, type AssembleRenderCommandOptions } from "./assemble-command.js";
4
+ export { resolveFfmpegPath } from "./binaries.js";
5
+ export { createFfmpegRunner, type FfmpegRunner, type FfmpegRunOptions, type FfmpegRunResult, type FfmpegSpawn, type FfmpegChildProcess, type CreateFfmpegRunnerOptions } from "./ffmpeg-runner.js";
6
+ export { createRenderHarnessServer, type RenderHarnessServer, type CreateRenderHarnessServerOptions } from "./harness-server.js";
7
+ export { PlaywrightDriver, type PlaywrightDriverOptions } from "./playwright-driver.js";
8
+ export { renderComposition, type RenderCompositionOptions, type RenderCompositionResult } from "./render-composition.js";
9
+ export { renderBatch, type RenderBatchOptions, type RenderBatchItemResult } from "./render-batch.js";
10
+ export type { RenderBatchInput, RenderBatchRow } from "@kitsra/kavio-render-worker";
11
+ export { FakeBrowserDriver, createFakeFfmpegRunner, type FakeFfmpegRunner, type CreateFakeFfmpegRunnerOptions } from "./testing.js";
12
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,eAAO,MAAM,oBAAoB,yBAAyB,CAAC;AAE3D,OAAO,EAAE,WAAW,EAAE,aAAa,EAAE,kBAAkB,EAAE,KAAK,eAAe,EAAE,KAAK,kBAAkB,EAAE,MAAM,aAAa,CAAC;AAC5H,OAAO,EAAE,qBAAqB,EAAE,KAAK,4BAA4B,EAAE,MAAM,uBAAuB,CAAC;AACjG,OAAO,EAAE,iBAAiB,EAAE,MAAM,eAAe,CAAC;AAClD,OAAO,EACL,kBAAkB,EAClB,KAAK,YAAY,EACjB,KAAK,gBAAgB,EACrB,KAAK,eAAe,EACpB,KAAK,WAAW,EAChB,KAAK,kBAAkB,EACvB,KAAK,yBAAyB,EAC/B,MAAM,oBAAoB,CAAC;AAC5B,OAAO,EACL,yBAAyB,EACzB,KAAK,mBAAmB,EACxB,KAAK,gCAAgC,EACtC,MAAM,qBAAqB,CAAC;AAC7B,OAAO,EAAE,gBAAgB,EAAE,KAAK,uBAAuB,EAAE,MAAM,wBAAwB,CAAC;AACxF,OAAO,EACL,iBAAiB,EACjB,KAAK,wBAAwB,EAC7B,KAAK,uBAAuB,EAC7B,MAAM,yBAAyB,CAAC;AACjC,OAAO,EAAE,WAAW,EAAE,KAAK,kBAAkB,EAAE,KAAK,qBAAqB,EAAE,MAAM,mBAAmB,CAAC;AACrG,YAAY,EAAE,gBAAgB,EAAE,cAAc,EAAE,MAAM,6BAA6B,CAAC;AACpF,OAAO,EACL,iBAAiB,EACjB,sBAAsB,EACtB,KAAK,gBAAgB,EACrB,KAAK,6BAA6B,EACnC,MAAM,cAAc,CAAC"}
package/dist/index.js ADDED
@@ -0,0 +1,10 @@
1
+ export const KAVIO_RENDER_PACKAGE = "@kitsra/kavio-render";
2
+ export { renderError, isRenderError, RENDER_ERROR_CODES } from "./errors.js";
3
+ export { assembleRenderCommand } from "./assemble-command.js";
4
+ export { resolveFfmpegPath } from "./binaries.js";
5
+ export { createFfmpegRunner } from "./ffmpeg-runner.js";
6
+ export { createRenderHarnessServer } from "./harness-server.js";
7
+ export { PlaywrightDriver } from "./playwright-driver.js";
8
+ export { renderComposition } from "./render-composition.js";
9
+ export { renderBatch } from "./render-batch.js";
10
+ export { FakeBrowserDriver, createFakeFfmpegRunner } from "./testing.js";
@@ -0,0 +1,27 @@
1
+ import { type BrowserDriver, type BrowserFrameCapture, type BrowserFrameCaptureOptions, type BrowserOpenOptions } from "@kitsra/kavio-render-worker";
2
+ import type { KavioDocument } from "@kitsra/kavio-schema";
3
+ export interface PlaywrightDriverOptions {
4
+ deviceScaleFactor?: number;
5
+ readyTimeoutMs?: number;
6
+ }
7
+ /**
8
+ * Concrete BrowserDriver backed by Playwright + bundled Chromium. Launches with
9
+ * the deterministic flags, serves the headless harness page, and captures one
10
+ * transparent PNG per frame. Real binaries are exercised only in the gated e2e.
11
+ */
12
+ export declare class PlaywrightDriver implements BrowserDriver {
13
+ private browser;
14
+ private context;
15
+ private page;
16
+ private server;
17
+ private viewport;
18
+ private readonly deviceScaleFactor;
19
+ private readonly readyTimeoutMs;
20
+ /** Chromium version string, available after open() for render metadata. */
21
+ chromiumVersion: string | null;
22
+ constructor(options?: PlaywrightDriverOptions);
23
+ open(composition: KavioDocument, options?: BrowserOpenOptions): Promise<void>;
24
+ renderFrame(frame: number, options?: BrowserFrameCaptureOptions): Promise<BrowserFrameCapture>;
25
+ close(): Promise<void>;
26
+ }
27
+ //# sourceMappingURL=playwright-driver.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"playwright-driver.d.ts","sourceRoot":"","sources":["../src/playwright-driver.ts"],"names":[],"mappings":"AAAA,OAAO,EAIL,KAAK,aAAa,EAClB,KAAK,mBAAmB,EACxB,KAAK,0BAA0B,EAC/B,KAAK,kBAAkB,EAExB,MAAM,6BAA6B,CAAC;AACrC,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,sBAAsB,CAAC;AA+C1D,MAAM,WAAW,uBAAuB;IACtC,iBAAiB,CAAC,EAAE,MAAM,CAAC;IAC3B,cAAc,CAAC,EAAE,MAAM,CAAC;CACzB;AAED;;;;GAIG;AACH,qBAAa,gBAAiB,YAAW,aAAa;IACpD,OAAO,CAAC,OAAO,CAAkC;IACjD,OAAO,CAAC,OAAO,CAAkC;IACjD,OAAO,CAAC,IAAI,CAA+B;IAC3C,OAAO,CAAC,MAAM,CAAoC;IAClD,OAAO,CAAC,QAAQ,CAAgC;IAChD,OAAO,CAAC,QAAQ,CAAC,iBAAiB,CAAS;IAC3C,OAAO,CAAC,QAAQ,CAAC,cAAc,CAAS;IAExC,2EAA2E;IAC3E,eAAe,EAAE,MAAM,GAAG,IAAI,CAAQ;gBAE1B,OAAO,GAAE,uBAA4B;IAK3C,IAAI,CAAC,WAAW,EAAE,aAAa,EAAE,OAAO,GAAE,kBAAuB,GAAG,OAAO,CAAC,IAAI,CAAC;IAsCjF,WAAW,CAAC,KAAK,EAAE,MAAM,EAAE,OAAO,GAAE,0BAA+B,GAAG,OAAO,CAAC,mBAAmB,CAAC;IAgBlG,KAAK,IAAI,OAAO,CAAC,IAAI,CAAC;CAY7B"}
@@ -0,0 +1,103 @@
1
+ import { createBrowserViewport, createPngFrameCapture, DEFAULT_CHROMIUM_LAUNCH_OPTIONS } from "@kitsra/kavio-render-worker";
2
+ import { renderError } from "./errors.js";
3
+ import { createRenderHarnessServer } from "./harness-server.js";
4
+ async function loadChromium() {
5
+ const specifier = "playwright";
6
+ try {
7
+ const mod = (await import(specifier));
8
+ return mod.chromium;
9
+ }
10
+ catch {
11
+ throw renderError({
12
+ code: "BINARY_MISSING",
13
+ stage: "render",
14
+ message: "Playwright (bundled Chromium) is not available.",
15
+ hint: "Install render browser binaries with 'corepack pnpm run install:render-browsers'."
16
+ });
17
+ }
18
+ }
19
+ /**
20
+ * Concrete BrowserDriver backed by Playwright + bundled Chromium. Launches with
21
+ * the deterministic flags, serves the headless harness page, and captures one
22
+ * transparent PNG per frame. Real binaries are exercised only in the gated e2e.
23
+ */
24
+ export class PlaywrightDriver {
25
+ browser = null;
26
+ context = null;
27
+ page = null;
28
+ server = null;
29
+ viewport = null;
30
+ deviceScaleFactor;
31
+ readyTimeoutMs;
32
+ /** Chromium version string, available after open() for render metadata. */
33
+ chromiumVersion = null;
34
+ constructor(options = {}) {
35
+ this.deviceScaleFactor = options.deviceScaleFactor ?? 1;
36
+ this.readyTimeoutMs = options.readyTimeoutMs ?? 30_000;
37
+ }
38
+ async open(composition, options = {}) {
39
+ const viewport = options.viewport ?? createBrowserViewport(composition, this.deviceScaleFactor);
40
+ this.viewport = viewport;
41
+ const chromium = await loadChromium();
42
+ try {
43
+ this.browser = await chromium.launch({
44
+ headless: DEFAULT_CHROMIUM_LAUNCH_OPTIONS.headless,
45
+ args: DEFAULT_CHROMIUM_LAUNCH_OPTIONS.args
46
+ });
47
+ }
48
+ catch (error) {
49
+ const message = error instanceof Error ? error.message : String(error);
50
+ if (missingBrowserExecutable(message)) {
51
+ throw renderError({
52
+ code: "BINARY_MISSING",
53
+ stage: "render",
54
+ message: "Playwright Chromium is not installed for Kavio rendering.",
55
+ hint: "Install render browser binaries with 'corepack pnpm run install:render-browsers'."
56
+ });
57
+ }
58
+ throw renderError({
59
+ code: "RENDER_FAILED",
60
+ stage: "render",
61
+ message: `Failed to launch Playwright Chromium: ${message}`
62
+ });
63
+ }
64
+ this.chromiumVersion = this.browser.version();
65
+ this.context = await this.browser.newContext({
66
+ viewport: { width: viewport.width, height: viewport.height },
67
+ deviceScaleFactor: viewport.deviceScaleFactor
68
+ });
69
+ this.page = await this.context.newPage();
70
+ this.server = await createRenderHarnessServer({ composition });
71
+ await this.page.goto(this.server.url);
72
+ await this.page.waitForFunction("window.__kavioReady === true", undefined, { timeout: this.readyTimeoutMs });
73
+ }
74
+ async renderFrame(frame, options = {}) {
75
+ if (this.page === null || this.viewport === null) {
76
+ throw renderError({
77
+ code: "RENDER_FRAME_FAILED",
78
+ stage: "render",
79
+ message: "PlaywrightDriver.renderFrame called before open()."
80
+ });
81
+ }
82
+ const omitBackground = options.omitBackground ?? true;
83
+ await this.page.evaluate(`window.__kavio.renderFrame(${frame})`);
84
+ const bytes = await this.page.screenshot({ type: "png", omitBackground });
85
+ return createPngFrameCapture({ frame, bytes, viewport: this.viewport, omitBackground });
86
+ }
87
+ async close() {
88
+ try {
89
+ await this.browser?.close();
90
+ }
91
+ finally {
92
+ await this.server?.close();
93
+ }
94
+ this.browser = null;
95
+ this.context = null;
96
+ this.page = null;
97
+ this.server = null;
98
+ this.viewport = null;
99
+ }
100
+ }
101
+ function missingBrowserExecutable(message) {
102
+ return message.includes("Executable doesn't exist") || message.includes("playwright install");
103
+ }
@@ -0,0 +1,20 @@
1
+ import { type BrowserDriver, type RenderBatchInput } from "@kitsra/kavio-render-worker";
2
+ import { type RenderCompositionResult } from "./render-composition.js";
3
+ import type { FfmpegRunner } from "./ffmpeg-runner.js";
4
+ export interface RenderBatchOptions {
5
+ outDir?: string;
6
+ driver?: BrowserDriver;
7
+ ffmpegRunner?: FfmpegRunner;
8
+ concurrency?: number;
9
+ failFast?: boolean;
10
+ signal?: AbortSignal;
11
+ continueOnFrameError?: boolean;
12
+ }
13
+ export interface RenderBatchItemResult {
14
+ id: string;
15
+ outputName: string;
16
+ result: RenderCompositionResult;
17
+ }
18
+ /** Expand a template × prop rows × export presets into jobs and render each one. */
19
+ export declare function renderBatch(input: RenderBatchInput, options?: RenderBatchOptions): Promise<RenderBatchItemResult[]>;
20
+ //# sourceMappingURL=render-batch.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"render-batch.d.ts","sourceRoot":"","sources":["../src/render-batch.ts"],"names":[],"mappings":"AAAA,OAAO,EAAqB,KAAK,aAAa,EAAE,KAAK,gBAAgB,EAAE,MAAM,6BAA6B,CAAC;AAC3G,OAAO,EAAoD,KAAK,uBAAuB,EAAE,MAAM,yBAAyB,CAAC;AACzH,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,oBAAoB,CAAC;AAEvD,MAAM,WAAW,kBAAkB;IACjC,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,MAAM,CAAC,EAAE,aAAa,CAAC;IACvB,YAAY,CAAC,EAAE,YAAY,CAAC;IAC5B,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,QAAQ,CAAC,EAAE,OAAO,CAAC;IACnB,MAAM,CAAC,EAAE,WAAW,CAAC;IACrB,oBAAoB,CAAC,EAAE,OAAO,CAAC;CAChC;AAED,MAAM,WAAW,qBAAqB;IACpC,EAAE,EAAE,MAAM,CAAC;IACX,UAAU,EAAE,MAAM,CAAC;IACnB,MAAM,EAAE,uBAAuB,CAAC;CACjC;AAED,oFAAoF;AACpF,wBAAsB,WAAW,CAC/B,KAAK,EAAE,gBAAgB,EACvB,OAAO,GAAE,kBAAuB,GAC/B,OAAO,CAAC,qBAAqB,EAAE,CAAC,CAgClC"}
@@ -0,0 +1,42 @@
1
+ import { expandRenderBatch } from "@kitsra/kavio-render-worker";
2
+ import { renderComposition } from "./render-composition.js";
3
+ /** Expand a template × prop rows × export presets into jobs and render each one. */
4
+ export async function renderBatch(input, options = {}) {
5
+ const jobs = expandRenderBatch(input);
6
+ const results = new Map();
7
+ // A shared injected driver cannot be driven concurrently; force sequential then.
8
+ const concurrency = options.driver !== undefined ? 1 : Math.max(1, Math.trunc(options.concurrency ?? 1));
9
+ let nextIndex = 0;
10
+ let aborted = false;
11
+ const worker = async () => {
12
+ while (!aborted) {
13
+ const index = nextIndex;
14
+ nextIndex += 1;
15
+ const job = jobs[index];
16
+ if (job === undefined) {
17
+ return;
18
+ }
19
+ const result = await renderComposition(job.document, buildRenderOptions(job, options));
20
+ results.set(index, { id: job.id, outputName: job.outputName, result });
21
+ if (!result.ok && options.failFast === true) {
22
+ aborted = true;
23
+ return;
24
+ }
25
+ }
26
+ };
27
+ const workerCount = Math.min(concurrency, jobs.length);
28
+ await Promise.all(Array.from({ length: workerCount }, () => worker()));
29
+ return [...results.entries()].sort(([left], [right]) => left - right).map(([, result]) => result);
30
+ }
31
+ function buildRenderOptions(job, options) {
32
+ return {
33
+ preset: job.preset,
34
+ propValues: job.props,
35
+ outputName: job.outputName,
36
+ ...(options.outDir !== undefined && { outDir: options.outDir }),
37
+ ...(options.driver !== undefined && { driver: options.driver }),
38
+ ...(options.ffmpegRunner !== undefined && { ffmpegRunner: options.ffmpegRunner }),
39
+ ...(options.signal !== undefined && { signal: options.signal }),
40
+ ...(options.continueOnFrameError !== undefined && { continueOnFrameError: options.continueOnFrameError })
41
+ };
42
+ }
@@ -0,0 +1,26 @@
1
+ import { type KavioDocument, type KavioError } from "@kitsra/kavio-schema";
2
+ import { type BrowserDriver, type RenderOutputMetadata } from "@kitsra/kavio-render-worker";
3
+ import { type FfmpegRunner } from "./ffmpeg-runner.js";
4
+ export interface RenderCompositionOptions {
5
+ preset: string | import("@kitsra/kavio-schema").KavioExportPreset;
6
+ propValues?: Record<string, unknown>;
7
+ outDir?: string;
8
+ outputName?: string;
9
+ driver?: BrowserDriver;
10
+ ffmpegRunner?: FfmpegRunner;
11
+ signal?: AbortSignal;
12
+ continueOnFrameError?: boolean;
13
+ ffmpegVersion?: string;
14
+ chromiumRevision?: string;
15
+ }
16
+ export type RenderCompositionResult = {
17
+ ok: true;
18
+ outputPath: string;
19
+ metadata: RenderOutputMetadata;
20
+ } | {
21
+ ok: false;
22
+ errors: KavioError[];
23
+ };
24
+ /** End-to-end render for one (composition × export): props → view → validate → capture → encode. */
25
+ export declare function renderComposition(doc: KavioDocument, options: RenderCompositionOptions): Promise<RenderCompositionResult>;
26
+ //# sourceMappingURL=render-composition.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"render-composition.d.ts","sourceRoot":"","sources":["../src/render-composition.ts"],"names":[],"mappings":"AAKA,OAAO,EAGL,KAAK,aAAa,EAClB,KAAK,UAAU,EAEhB,MAAM,sBAAsB,CAAC;AAC9B,OAAO,EAKL,KAAK,aAAa,EAElB,KAAK,oBAAoB,EAC1B,MAAM,6BAA6B,CAAC;AAErC,OAAO,EAAsB,KAAK,YAAY,EAAE,MAAM,oBAAoB,CAAC;AAK3E,MAAM,WAAW,wBAAwB;IACvC,MAAM,EAAE,MAAM,GAAG,OAAO,sBAAsB,EAAE,iBAAiB,CAAC;IAClE,UAAU,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;IACrC,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,MAAM,CAAC,EAAE,aAAa,CAAC;IACvB,YAAY,CAAC,EAAE,YAAY,CAAC;IAC5B,MAAM,CAAC,EAAE,WAAW,CAAC;IACrB,oBAAoB,CAAC,EAAE,OAAO,CAAC;IAC/B,aAAa,CAAC,EAAE,MAAM,CAAC;IACvB,gBAAgB,CAAC,EAAE,MAAM,CAAC;CAC3B;AAED,MAAM,MAAM,uBAAuB,GAC/B;IAAE,EAAE,EAAE,IAAI,CAAC;IAAC,UAAU,EAAE,MAAM,CAAC;IAAC,QAAQ,EAAE,oBAAoB,CAAA;CAAE,GAChE;IAAE,EAAE,EAAE,KAAK,CAAC;IAAC,MAAM,EAAE,UAAU,EAAE,CAAA;CAAE,CAAC;AAExC,oGAAoG;AACpG,wBAAsB,iBAAiB,CACrC,GAAG,EAAE,aAAa,EAClB,OAAO,EAAE,wBAAwB,GAChC,OAAO,CAAC,uBAAuB,CAAC,CAqFlC"}
@@ -0,0 +1,125 @@
1
+ import { createHash } from "node:crypto";
2
+ import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
3
+ import { tmpdir } from "node:os";
4
+ import { dirname, join } from "node:path";
5
+ import { applyExportPreset, collectCompositionResourceLimitInputs, collectResourceLimitViolations, resolveTemplateProps } from "@kitsra/kavio-core";
6
+ import { extensionForFormat, validateComposition } from "@kitsra/kavio-schema";
7
+ import { captureFrames, createRenderMetadata, createTemporaryFramesCleanupTask, withRenderCleanup } from "@kitsra/kavio-render-worker";
8
+ import { assembleRenderCommand } from "./assemble-command.js";
9
+ import { createFfmpegRunner } from "./ffmpeg-runner.js";
10
+ import { isRenderError, renderError } from "./errors.js";
11
+ import { PlaywrightDriver } from "./playwright-driver.js";
12
+ import { withEffectiveCodecs } from "./encoding.js";
13
+ /** End-to-end render for one (composition × export): props → view → validate → capture → encode. */
14
+ export async function renderComposition(doc, options) {
15
+ const resolution = resolveTemplateProps(doc, options.propValues ?? {});
16
+ if (!resolution.ok) {
17
+ return { ok: false, errors: resolution.errors };
18
+ }
19
+ let view;
20
+ try {
21
+ view = applyExportPreset(resolution.value, options.preset);
22
+ }
23
+ catch (error) {
24
+ return { ok: false, errors: [toKavioError(error)] };
25
+ }
26
+ const preset = view.exports[0];
27
+ if (preset === undefined) {
28
+ return {
29
+ ok: false,
30
+ errors: [renderError({ code: "RENDER_FAILED", stage: "render", message: "No export preset resolved for render." })]
31
+ };
32
+ }
33
+ const validation = validateComposition(view);
34
+ if (!validation.ok) {
35
+ return { ok: false, errors: validation.errors };
36
+ }
37
+ const violations = collectResourceLimitViolations(collectCompositionResourceLimitInputs(view));
38
+ if (violations.length > 0) {
39
+ return { ok: false, errors: violations };
40
+ }
41
+ const renderabilityError = validateRenderablePreset(preset);
42
+ if (renderabilityError !== null) {
43
+ return { ok: false, errors: [renderabilityError] };
44
+ }
45
+ const outputName = options.outputName ?? `${preset.name}.${extensionForFormat(preset.format)}`;
46
+ const outputPath = options.outDir === undefined ? outputName : join(options.outDir, outputName);
47
+ const metadataPreset = withEffectiveCodecs(preset);
48
+ try {
49
+ const metadata = await withRenderCleanup(async (cleanup) => {
50
+ const workDir = await mkdtemp(join(tmpdir(), "kavio-render-"));
51
+ cleanup.defer(createTemporaryFramesCleanupTask(async () => {
52
+ await rm(workDir, { recursive: true, force: true });
53
+ }, "workdir"));
54
+ const driver = options.driver ?? new PlaywrightDriver();
55
+ const framePattern = join(workDir, "overlay-%05d.png");
56
+ // captureFrames manages browser-context cleanup (open → capture → close).
57
+ await captureFrames({
58
+ driver,
59
+ composition: view,
60
+ continueOnFrameError: options.continueOnFrameError === true,
61
+ onFrame: async (capture) => {
62
+ const name = `overlay-${String(capture.frame).padStart(5, "0")}.png`;
63
+ await writeFile(join(workDir, name), capture.bytes);
64
+ }
65
+ });
66
+ await mkdir(dirname(outputPath), { recursive: true });
67
+ const args = assembleRenderCommand({ view, preset, framePattern, outputPath });
68
+ const ffmpegRunner = options.ffmpegRunner ?? createFfmpegRunner();
69
+ await ffmpegRunner.run(args, options.signal === undefined ? {} : { signal: options.signal });
70
+ const checksum = await sha256File(outputPath);
71
+ return createRenderMetadata({
72
+ composition: view.composition,
73
+ preset: metadataPreset,
74
+ outputName,
75
+ outputPath,
76
+ checksums: checksum,
77
+ ffmpegVersion: options.ffmpegVersion ?? "ffmpeg-static",
78
+ chromiumRevision: options.chromiumRevision ?? chromiumRevisionOf(driver)
79
+ });
80
+ });
81
+ return { ok: true, outputPath, metadata };
82
+ }
83
+ catch (error) {
84
+ return { ok: false, errors: [toKavioError(error)] };
85
+ }
86
+ }
87
+ function chromiumRevisionOf(driver) {
88
+ if (driver instanceof PlaywrightDriver && driver.chromiumVersion !== null) {
89
+ return driver.chromiumVersion;
90
+ }
91
+ return "unknown";
92
+ }
93
+ async function sha256File(path) {
94
+ const bytes = await readFile(path);
95
+ const value = createHash("sha256").update(bytes).digest("hex");
96
+ return { algorithm: "sha256", value, bytes: bytes.length };
97
+ }
98
+ function validateRenderablePreset(preset) {
99
+ if (preset.format === "gif" || preset.format === "png-sequence") {
100
+ return renderError({
101
+ code: "RENDER_FAILED",
102
+ stage: "render",
103
+ path: "exports.0.format",
104
+ message: `kavio render does not yet support ${preset.format} exports.`,
105
+ hint: "Use mp4, webm, or mov for the current render pipeline."
106
+ });
107
+ }
108
+ if (preset.background === "transparent") {
109
+ return renderError({
110
+ code: "RENDER_FAILED",
111
+ stage: "render",
112
+ path: "exports.0.background",
113
+ message: "kavio render does not yet support transparent final outputs.",
114
+ hint: "Use an opaque export background until alpha-capable encoding lands."
115
+ });
116
+ }
117
+ return null;
118
+ }
119
+ function toKavioError(error) {
120
+ if (isRenderError(error)) {
121
+ return error;
122
+ }
123
+ const message = error instanceof Error ? error.message : String(error);
124
+ return renderError({ code: "RENDER_FAILED", stage: "render", message });
125
+ }
@@ -0,0 +1,24 @@
1
+ import { type BrowserDriver, type BrowserFrameCapture, type BrowserFrameCaptureOptions, type BrowserOpenOptions } from "@kitsra/kavio-render-worker";
2
+ import type { KavioDocument } from "@kitsra/kavio-schema";
3
+ import type { FfmpegRunner } from "./ffmpeg-runner.js";
4
+ /** In-memory BrowserDriver that returns deterministic PNG bytes without Chromium. */
5
+ export declare class FakeBrowserDriver implements BrowserDriver {
6
+ opens: number;
7
+ closes: number;
8
+ renderedFrames: number[];
9
+ private viewport;
10
+ open(composition: KavioDocument, options?: BrowserOpenOptions): Promise<void>;
11
+ renderFrame(frame: number, options?: BrowserFrameCaptureOptions): Promise<BrowserFrameCapture>;
12
+ close(): Promise<void>;
13
+ }
14
+ export interface FakeFfmpegRunner extends FfmpegRunner {
15
+ /** Argument lists captured from each run() call. */
16
+ readonly calls: string[][];
17
+ }
18
+ export interface CreateFakeFfmpegRunnerOptions {
19
+ /** When true, run() rejects with FFMPEG_FAILED (for cleanup-on-failure tests). */
20
+ fail?: boolean;
21
+ }
22
+ /** FfmpegRunner that records args and writes a placeholder output file. */
23
+ export declare function createFakeFfmpegRunner(options?: CreateFakeFfmpegRunnerOptions): FakeFfmpegRunner;
24
+ //# sourceMappingURL=testing.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"testing.d.ts","sourceRoot":"","sources":["../src/testing.ts"],"names":[],"mappings":"AAEA,OAAO,EAGL,KAAK,aAAa,EAClB,KAAK,mBAAmB,EACxB,KAAK,0BAA0B,EAC/B,KAAK,kBAAkB,EAExB,MAAM,6BAA6B,CAAC;AACrC,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,sBAAsB,CAAC;AAE1D,OAAO,KAAK,EAAE,YAAY,EAAqC,MAAM,oBAAoB,CAAC;AAI1F,qFAAqF;AACrF,qBAAa,iBAAkB,YAAW,aAAa;IACrD,KAAK,SAAK;IACV,MAAM,SAAK;IACX,cAAc,EAAE,MAAM,EAAE,CAAM;IAC9B,OAAO,CAAC,QAAQ,CAAgC;IAE1C,IAAI,CAAC,WAAW,EAAE,aAAa,EAAE,OAAO,GAAE,kBAAuB,GAAG,OAAO,CAAC,IAAI,CAAC;IAKjF,WAAW,CAAC,KAAK,EAAE,MAAM,EAAE,OAAO,GAAE,0BAA+B,GAAG,OAAO,CAAC,mBAAmB,CAAC;IAiBlG,KAAK,IAAI,OAAO,CAAC,IAAI,CAAC;CAI7B;AAED,MAAM,WAAW,gBAAiB,SAAQ,YAAY;IACpD,oDAAoD;IACpD,QAAQ,CAAC,KAAK,EAAE,MAAM,EAAE,EAAE,CAAC;CAC5B;AAED,MAAM,WAAW,6BAA6B;IAC5C,kFAAkF;IAClF,IAAI,CAAC,EAAE,OAAO,CAAC;CAChB;AAED,2EAA2E;AAC3E,wBAAgB,sBAAsB,CAAC,OAAO,GAAE,6BAAkC,GAAG,gBAAgB,CAoBpG"}
@@ -0,0 +1,55 @@
1
+ import { mkdir, writeFile } from "node:fs/promises";
2
+ import { dirname } from "node:path";
3
+ import { createBrowserViewport, createPngFrameCapture } from "@kitsra/kavio-render-worker";
4
+ import { renderError } from "./errors.js";
5
+ const FAKE_PNG = new Uint8Array([137, 80, 78, 71, 13, 10, 26, 10]);
6
+ /** In-memory BrowserDriver that returns deterministic PNG bytes without Chromium. */
7
+ export class FakeBrowserDriver {
8
+ opens = 0;
9
+ closes = 0;
10
+ renderedFrames = [];
11
+ viewport = null;
12
+ async open(composition, options = {}) {
13
+ this.opens += 1;
14
+ this.viewport = options.viewport ?? createBrowserViewport(composition);
15
+ }
16
+ async renderFrame(frame, options = {}) {
17
+ if (this.viewport === null) {
18
+ throw renderError({
19
+ code: "RENDER_FRAME_FAILED",
20
+ stage: "render",
21
+ message: "FakeBrowserDriver.renderFrame called before open()."
22
+ });
23
+ }
24
+ this.renderedFrames.push(frame);
25
+ return createPngFrameCapture({
26
+ frame,
27
+ bytes: FAKE_PNG,
28
+ viewport: this.viewport,
29
+ omitBackground: options.omitBackground ?? true
30
+ });
31
+ }
32
+ async close() {
33
+ this.closes += 1;
34
+ this.viewport = null;
35
+ }
36
+ }
37
+ /** FfmpegRunner that records args and writes a placeholder output file. */
38
+ export function createFakeFfmpegRunner(options = {}) {
39
+ const calls = [];
40
+ return {
41
+ calls,
42
+ async run(args, _runOptions) {
43
+ calls.push([...args]);
44
+ if (options.fail === true) {
45
+ throw renderError({ code: "FFMPEG_FAILED", stage: "ffmpeg", message: "Fake ffmpeg failure." });
46
+ }
47
+ const outputPath = args[args.length - 1];
48
+ if (outputPath !== undefined && outputPath.length > 0) {
49
+ await mkdir(dirname(outputPath), { recursive: true });
50
+ await writeFile(outputPath, FAKE_PNG);
51
+ }
52
+ return { code: 0, stderr: "" };
53
+ }
54
+ };
55
+ }