@kitsra/kavio-render 0.1.2 → 0.2.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.
@@ -3,11 +3,29 @@ export interface AssembleRenderCommandOptions {
3
3
  /** Composition with props resolved and the export preset already applied. */
4
4
  view: KavioDocument;
5
5
  preset: KavioExportPreset;
6
- /** printf-style path to the captured transparent overlay frames, e.g. work/overlay-%05d.png */
7
- framePattern: string;
6
+ /**
7
+ * printf-style path to the captured transparent overlay frames, e.g.
8
+ * work/overlay-%05d.png. When omitted, the command reads overlay frames from
9
+ * stdin as an image2pipe PNG stream so capture and encode can overlap.
10
+ */
11
+ framePattern?: string;
8
12
  /** Output file path. Defaults to `<preset.name>.<ext>`. */
9
13
  outputPath?: string;
10
14
  }
15
+ export interface AssembleDirectRenderCommandOptions {
16
+ /** Composition with props resolved and the export preset already applied. */
17
+ view: KavioDocument;
18
+ preset: KavioExportPreset;
19
+ /** Output file path. Defaults to `<preset.name>.<ext>`. */
20
+ outputPath?: string;
21
+ }
22
+ export type DirectRenderSupport = {
23
+ ok: true;
24
+ } | {
25
+ ok: false;
26
+ reason: string;
27
+ layerId?: string;
28
+ };
11
29
  /**
12
30
  * Fuse the FFmpeg planner's base-video, overlay, and audio-mix pieces into a single
13
31
  * runnable `ffmpeg` argument list. Pure: no IO, no process spawning.
@@ -16,4 +34,11 @@ export interface AssembleRenderCommandOptions {
16
34
  * in how the base stream is produced; everything downstream is identical.
17
35
  */
18
36
  export declare function assembleRenderCommand(options: AssembleRenderCommandOptions): string[];
37
+ /**
38
+ * Experimental FFmpeg-direct renderer for filtergraph-safe templates. This path
39
+ * deliberately avoids the browser/PNG overlay loop and compiles supported shape
40
+ * layers into drawbox filters over the normal base video/audio plan.
41
+ */
42
+ export declare function assembleDirectRenderCommand(options: AssembleDirectRenderCommandOptions): string[];
43
+ export declare function getDirectRenderSupport(view: KavioDocument): DirectRenderSupport;
19
44
  //# sourceMappingURL=assemble-command.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"assemble-command.d.ts","sourceRoot":"","sources":["../src/assemble-command.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAGV,aAAa,EACb,iBAAiB,EAGlB,MAAM,sBAAsB,CAAC;AAY9B,MAAM,WAAW,4BAA4B;IAC3C,6EAA6E;IAC7E,IAAI,EAAE,aAAa,CAAC;IACpB,MAAM,EAAE,iBAAiB,CAAC;IAC1B,+FAA+F;IAC/F,YAAY,EAAE,MAAM,CAAC;IACrB,2DAA2D;IAC3D,UAAU,CAAC,EAAE,MAAM,CAAC;CACrB;AAMD;;;;;;GAMG;AACH,wBAAgB,qBAAqB,CAAC,OAAO,EAAE,4BAA4B,GAAG,MAAM,EAAE,CA2ErF"}
1
+ {"version":3,"file":"assemble-command.d.ts","sourceRoot":"","sources":["../src/assemble-command.ts"],"names":[],"mappings":"AAEA,OAAO,KAAK,EAGV,aAAa,EACb,iBAAiB,EAKlB,MAAM,sBAAsB,CAAC;AAY9B,MAAM,WAAW,4BAA4B;IAC3C,6EAA6E;IAC7E,IAAI,EAAE,aAAa,CAAC;IACpB,MAAM,EAAE,iBAAiB,CAAC;IAC1B;;;;OAIG;IACH,YAAY,CAAC,EAAE,MAAM,CAAC;IACtB,2DAA2D;IAC3D,UAAU,CAAC,EAAE,MAAM,CAAC;CACrB;AAED,MAAM,WAAW,kCAAkC;IACjD,6EAA6E;IAC7E,IAAI,EAAE,aAAa,CAAC;IACpB,MAAM,EAAE,iBAAiB,CAAC;IAC1B,2DAA2D;IAC3D,UAAU,CAAC,EAAE,MAAM,CAAC;CACrB;AAED,MAAM,MAAM,mBAAmB,GAC3B;IAAE,EAAE,EAAE,IAAI,CAAA;CAAE,GACZ;IAAE,EAAE,EAAE,KAAK,CAAC;IAAC,MAAM,EAAE,MAAM,CAAC;IAAC,OAAO,CAAC,EAAE,MAAM,CAAA;CAAE,CAAC;AAMpD;;;;;;GAMG;AACH,wBAAgB,qBAAqB,CAAC,OAAO,EAAE,4BAA4B,GAAG,MAAM,EAAE,CA+ErF;AAED;;;;GAIG;AACH,wBAAgB,2BAA2B,CAAC,OAAO,EAAE,kCAAkC,GAAG,MAAM,EAAE,CA0EjG;AAED,wBAAgB,sBAAsB,CAAC,IAAI,EAAE,aAAa,GAAG,mBAAmB,CAiB/E"}
@@ -1,3 +1,4 @@
1
+ import { evaluateLayer, getCanvasDimensions, isLayerActive } from "@kitsra/kavio-core";
1
2
  import { extensionForFormat } from "@kitsra/kavio-schema";
2
3
  import { buildFilterComplexArgs, planAudioMix, planBaseVideoSequence, planOverlayCompositing } from "@kitsra/kavio-ffmpeg";
3
4
  import { renderError } from "./errors.js";
@@ -39,14 +40,19 @@ export function assembleRenderCommand(options) {
39
40
  baseLabel = `${inputIndex}:v`;
40
41
  inputIndex += 1;
41
42
  }
42
- // --- Transparent overlay frame sequence ---------------------------------
43
+ // --- Transparent overlay frame sequence (files or stdin pipe) -----------
43
44
  const overlayPlan = planOverlayCompositing({
44
45
  baseLabel,
45
- frames: { framePattern, fps, inputIndex, startNumber: 0, outputLabel: "overlay_frames" },
46
+ frames: { framePattern: framePattern ?? "-", fps, inputIndex, startNumber: 0, outputLabel: "overlay_frames" },
46
47
  outputLabel: VIDEO_OUT_LABEL,
47
48
  shortest: true
48
49
  });
49
- inputArgs.push(...planInputArgs(overlayPlan));
50
+ if (framePattern === undefined) {
51
+ inputArgs.push("-f", "image2pipe", "-framerate", String(fps), "-i", "-");
52
+ }
53
+ else {
54
+ inputArgs.push(...planInputArgs(overlayPlan));
55
+ }
50
56
  chains.push(...planFilterChains(overlayPlan));
51
57
  inputIndex += 1;
52
58
  // --- Audio: mix declared tracks, or synthesize silence ------------------
@@ -77,6 +83,89 @@ export function assembleRenderCommand(options) {
77
83
  outputPath
78
84
  ];
79
85
  }
86
+ /**
87
+ * Experimental FFmpeg-direct renderer for filtergraph-safe templates. This path
88
+ * deliberately avoids the browser/PNG overlay loop and compiles supported shape
89
+ * layers into drawbox filters over the normal base video/audio plan.
90
+ */
91
+ export function assembleDirectRenderCommand(options) {
92
+ const support = getDirectRenderSupport(options.view);
93
+ if (!support.ok) {
94
+ throw renderError({
95
+ code: "RENDER_FAILED",
96
+ stage: "render",
97
+ path: support.layerId === undefined ? "layers" : `layers.${support.layerId}`,
98
+ message: `FFmpeg-direct render does not support this composition yet: ${support.reason}`
99
+ });
100
+ }
101
+ const { view, preset } = options;
102
+ const fps = preset.fps ?? view.composition.fps;
103
+ const width = preset.width;
104
+ const height = preset.height;
105
+ const background = preset.background ?? view.composition.background ?? "black";
106
+ const outputPath = options.outputPath ?? `${preset.name}.${extensionForFormat(preset.format)}`;
107
+ const durationSeconds = view.composition.durationFrames / fps;
108
+ const inputArgs = [];
109
+ const chains = [];
110
+ let inputIndex = 0;
111
+ const videoLayers = view.layers.filter((layer) => layer.type === "video");
112
+ let baseLabel;
113
+ if (videoLayers.length > 0) {
114
+ const segments = videoLayers.map((layer, index) => baseSegment(view, layer, { width, height }, fps, inputIndex + index, background));
115
+ const basePlan = planBaseVideoSequence({ segments, outputLabel: BASE_LABEL });
116
+ inputArgs.push(...planInputArgs(basePlan));
117
+ chains.push(...planFilterChains(basePlan));
118
+ inputIndex += segments.length;
119
+ baseLabel = BASE_LABEL;
120
+ }
121
+ else {
122
+ inputArgs.push("-f", "lavfi", "-i", `color=c=${escapeLavfi(background)}:s=${width}x${height}:r=${fps}:d=${formatSeconds(durationSeconds)}`);
123
+ baseLabel = `${inputIndex}:v`;
124
+ inputIndex += 1;
125
+ }
126
+ chains.push(buildDirectShapeFilterChain(view, baseLabel, VIDEO_OUT_LABEL));
127
+ const audioTracks = view.audio ?? [];
128
+ if (audioTracks.length > 0) {
129
+ const trackOptions = audioTracks.map((track, index) => audioOption(view, track, inputIndex + index));
130
+ const audioPlan = planAudioMix({ tracks: trackOptions, fps, outputLabel: AUDIO_OUT_LABEL, normalizeLoudness: true });
131
+ inputArgs.push(...planInputArgs(audioPlan));
132
+ chains.push(...planFilterChains(audioPlan));
133
+ inputIndex += trackOptions.length;
134
+ }
135
+ else {
136
+ inputArgs.push("-f", "lavfi", "-i", "anullsrc=channel_layout=stereo:sample_rate=48000");
137
+ chains.push(silentAudioChain(inputIndex));
138
+ inputIndex += 1;
139
+ }
140
+ return [
141
+ "-y",
142
+ ...inputArgs,
143
+ ...buildFilterComplexArgs(chains),
144
+ "-map",
145
+ `[${VIDEO_OUT_LABEL}]`,
146
+ "-map",
147
+ `[${AUDIO_OUT_LABEL}]`,
148
+ ...encodeArgs(preset, fps),
149
+ "-t",
150
+ formatSeconds(durationSeconds),
151
+ outputPath
152
+ ];
153
+ }
154
+ export function getDirectRenderSupport(view) {
155
+ for (const layer of view.layers) {
156
+ if (layer.type === "video") {
157
+ continue;
158
+ }
159
+ if (layer.type !== "shape") {
160
+ return unsupported(layer, `layer type "${layer.type}" requires browser rendering`);
161
+ }
162
+ const unsupportedShape = getUnsupportedDirectShapeReason(layer);
163
+ if (unsupportedShape !== null) {
164
+ return unsupported(layer, unsupportedShape);
165
+ }
166
+ }
167
+ return { ok: true };
168
+ }
80
169
  function baseSegment(view, layer, output, fps, inputIndex, background) {
81
170
  const asset = videoAsset(view, layer.asset, layer.id);
82
171
  const segment = {
@@ -114,6 +203,80 @@ function audioOption(view, track, inputIndex) {
114
203
  inputIndex
115
204
  };
116
205
  }
206
+ function buildDirectShapeFilterChain(view, inputLabel, outputLabel) {
207
+ const dimensions = getCanvasDimensions(view.composition);
208
+ const filters = view.layers
209
+ .map((layer, index) => ({ layer, order: layer.z ?? index }))
210
+ .sort((a, b) => a.order - b.order)
211
+ .flatMap(({ layer }) => (layer.type === "shape" ? directShapeFilters(layer, dimensions) : []));
212
+ const effectiveFilters = filters.length === 0 ? ["null"] : filters;
213
+ return {
214
+ inputLabels: [inputLabel],
215
+ filters: effectiveFilters,
216
+ outputLabel,
217
+ expression: `[${inputLabel}]${effectiveFilters.join(",")}[${outputLabel}]`
218
+ };
219
+ }
220
+ function directShapeFilters(layer, dimensions) {
221
+ const evaluation = evaluateLayer(layer, layer.startFrame, dimensions);
222
+ const width = evaluation.size.width ?? 0;
223
+ const height = evaluation.size.height ?? 0;
224
+ if (!isLayerActive(layer, layer.startFrame) || width <= 0 || height <= 0 || evaluation.opacity <= 0) {
225
+ return [];
226
+ }
227
+ const x = evaluation.topLeft.x ?? evaluation.position.x;
228
+ const y = evaluation.topLeft.y ?? evaluation.position.y;
229
+ const start = layer.startFrame;
230
+ const end = layer.startFrame + layer.durationFrames - 1;
231
+ const enable = `enable='between(n,${start},${end})'`;
232
+ const filters = [];
233
+ if (layer.fill !== undefined && layer.fill !== "transparent") {
234
+ filters.push(`drawbox=x=${formatFfmpegNumber(x)}:y=${formatFfmpegNumber(y)}:w=${formatFfmpegNumber(width)}:h=${formatFfmpegNumber(height)}:color=${formatDrawboxColor(layer.fill, evaluation.opacity)}:t=fill:${enable}`);
235
+ }
236
+ if (layer.stroke !== undefined && layer.stroke !== null && layer.stroke.width > 0) {
237
+ filters.push(`drawbox=x=${formatFfmpegNumber(x)}:y=${formatFfmpegNumber(y)}:w=${formatFfmpegNumber(width)}:h=${formatFfmpegNumber(height)}:color=${formatDrawboxColor(layer.stroke.color, evaluation.opacity)}:t=${formatFfmpegNumber(layer.stroke.width)}:${enable}`);
238
+ }
239
+ return filters;
240
+ }
241
+ function getUnsupportedDirectShapeReason(layer) {
242
+ if (layer.shape !== "rect") {
243
+ return `shape "${layer.shape}" is not supported`;
244
+ }
245
+ if (layer.radius !== undefined && layer.radius > 0) {
246
+ return "rounded shape corners are not supported";
247
+ }
248
+ if (layer.keyframes !== undefined && Object.keys(layer.keyframes).length > 0) {
249
+ return "animated shape keyframes are not supported";
250
+ }
251
+ if (layer.rotation !== undefined && layer.rotation !== 0) {
252
+ return "rotated shapes are not supported";
253
+ }
254
+ if (layer.scale !== undefined && layer.scale !== 1) {
255
+ return "scaled shapes are not supported";
256
+ }
257
+ if (layer.effects !== undefined && layer.effects.length > 0) {
258
+ return "shape effects are not supported";
259
+ }
260
+ if (layer.mask !== undefined && layer.mask !== null) {
261
+ return "shape masks are not supported";
262
+ }
263
+ if (layer.transitionIn !== undefined && layer.transitionIn !== null) {
264
+ return "shape transitions are not supported";
265
+ }
266
+ if (layer.transitionOut !== undefined && layer.transitionOut !== null) {
267
+ return "shape transitions are not supported";
268
+ }
269
+ if (layer.fill !== undefined && layer.fill !== "transparent" && !isDirectColorSupported(layer.fill)) {
270
+ return `fill color "${layer.fill}" is not supported`;
271
+ }
272
+ if (layer.stroke !== undefined && layer.stroke !== null && !isDirectColorSupported(layer.stroke.color)) {
273
+ return `stroke color "${layer.stroke.color}" is not supported`;
274
+ }
275
+ return null;
276
+ }
277
+ function unsupported(layer, reason) {
278
+ return { ok: false, reason, layerId: layer.id };
279
+ }
117
280
  function silentAudioChain(inputIndex) {
118
281
  return {
119
282
  inputLabels: [`${inputIndex}:a`],
@@ -189,6 +352,35 @@ function escapeLavfi(value) {
189
352
  .replace(/,/g, "\\,")
190
353
  .replace(/;/g, "\\;");
191
354
  }
355
+ function isDirectColorSupported(value) {
356
+ return value === "transparent" || /^#[0-9a-f]{6}([0-9a-f]{2})?$/i.test(value);
357
+ }
358
+ function formatDrawboxColor(value, opacity) {
359
+ const match = /^#([0-9a-f]{6})([0-9a-f]{2})?$/i.exec(value);
360
+ if (match === null) {
361
+ throw renderError({
362
+ code: "RENDER_FAILED",
363
+ stage: "render",
364
+ message: `FFmpeg-direct render only supports hex colors; received "${value}".`
365
+ });
366
+ }
367
+ const base = match[1];
368
+ const alpha = match[2] === undefined ? 1 : Number.parseInt(match[2], 16) / 255;
369
+ return `${escapeLavfi(`0x${base}`)}@${formatFfmpegNumber(alpha * opacity)}`;
370
+ }
371
+ function formatFfmpegNumber(value) {
372
+ if (!Number.isFinite(value)) {
373
+ throw renderError({
374
+ code: "RENDER_FAILED",
375
+ stage: "render",
376
+ message: "FFmpeg-direct render encountered a non-finite numeric value."
377
+ });
378
+ }
379
+ if (Number.isInteger(value)) {
380
+ return String(value);
381
+ }
382
+ return value.toFixed(6).replace(/0+$/, "").replace(/\.$/, "");
383
+ }
192
384
  function formatSeconds(seconds) {
193
385
  if (Number.isInteger(seconds)) {
194
386
  return String(seconds);
@@ -1,7 +1,14 @@
1
1
  export interface FfmpegChildStream {
2
2
  on(event: "data", listener: (chunk: unknown) => void): void;
3
3
  }
4
+ export interface FfmpegChildWritable {
5
+ write(chunk: Uint8Array): boolean;
6
+ end(): void;
7
+ on(event: "drain" | "error", listener: (...args: unknown[]) => void): void;
8
+ once(event: "close", listener: () => void): void;
9
+ }
4
10
  export interface FfmpegChildProcess {
11
+ stdin?: FfmpegChildWritable | null;
5
12
  stdout: FfmpegChildStream | null;
6
13
  stderr: FfmpegChildStream | null;
7
14
  on(event: "error", listener: (error: Error) => void): void;
@@ -12,6 +19,12 @@ export type FfmpegSpawn = (command: string, args: readonly string[]) => FfmpegCh
12
19
  export interface FfmpegRunOptions {
13
20
  onProgress?: (chunk: string) => void;
14
21
  signal?: AbortSignal;
22
+ /**
23
+ * Byte chunks piped to ffmpeg's stdin (e.g. an image2pipe PNG frame stream).
24
+ * The runner applies write backpressure and ends stdin when the source
25
+ * completes; a source failure kills ffmpeg and rejects with that error.
26
+ */
27
+ stdin?: AsyncIterable<Uint8Array>;
15
28
  }
16
29
  export interface FfmpegRunResult {
17
30
  code: number;
@@ -1 +1 @@
1
- {"version":3,"file":"ffmpeg-runner.d.ts","sourceRoot":"","sources":["../src/ffmpeg-runner.ts"],"names":[],"mappings":"AAGA,MAAM,WAAW,iBAAiB;IAChC,EAAE,CAAC,KAAK,EAAE,MAAM,EAAE,QAAQ,EAAE,CAAC,KAAK,EAAE,OAAO,KAAK,IAAI,GAAG,IAAI,CAAC;CAC7D;AAED,MAAM,WAAW,kBAAkB;IACjC,MAAM,EAAE,iBAAiB,GAAG,IAAI,CAAC;IACjC,MAAM,EAAE,iBAAiB,GAAG,IAAI,CAAC;IACjC,EAAE,CAAC,KAAK,EAAE,OAAO,EAAE,QAAQ,EAAE,CAAC,KAAK,EAAE,KAAK,KAAK,IAAI,GAAG,IAAI,CAAC;IAC3D,EAAE,CAAC,KAAK,EAAE,OAAO,EAAE,QAAQ,EAAE,CAAC,IAAI,EAAE,MAAM,GAAG,IAAI,KAAK,IAAI,GAAG,IAAI,CAAC;IAClE,IAAI,CAAC,MAAM,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;CAC7B;AAED,MAAM,MAAM,WAAW,GAAG,CAAC,OAAO,EAAE,MAAM,EAAE,IAAI,EAAE,SAAS,MAAM,EAAE,KAAK,kBAAkB,CAAC;AAE3F,MAAM,WAAW,gBAAgB;IAC/B,UAAU,CAAC,EAAE,CAAC,KAAK,EAAE,MAAM,KAAK,IAAI,CAAC;IACrC,MAAM,CAAC,EAAE,WAAW,CAAC;CACtB;AAED,MAAM,WAAW,eAAe;IAC9B,IAAI,EAAE,MAAM,CAAC;IACb,MAAM,EAAE,MAAM,CAAC;CAChB;AAED,MAAM,WAAW,YAAY;IAC3B,GAAG,CAAC,IAAI,EAAE,SAAS,MAAM,EAAE,EAAE,OAAO,CAAC,EAAE,gBAAgB,GAAG,OAAO,CAAC,eAAe,CAAC,CAAC;CACpF;AAED,MAAM,WAAW,yBAAyB;IACxC,KAAK,CAAC,EAAE,WAAW,CAAC;IACpB,aAAa,CAAC,EAAE,MAAM,MAAM,GAAG,OAAO,CAAC,MAAM,CAAC,CAAC;CAChD;AAID,wBAAgB,kBAAkB,CAAC,OAAO,GAAE,yBAA8B,GAAG,YAAY,CA2ExF"}
1
+ {"version":3,"file":"ffmpeg-runner.d.ts","sourceRoot":"","sources":["../src/ffmpeg-runner.ts"],"names":[],"mappings":"AAGA,MAAM,WAAW,iBAAiB;IAChC,EAAE,CAAC,KAAK,EAAE,MAAM,EAAE,QAAQ,EAAE,CAAC,KAAK,EAAE,OAAO,KAAK,IAAI,GAAG,IAAI,CAAC;CAC7D;AAED,MAAM,WAAW,mBAAmB;IAClC,KAAK,CAAC,KAAK,EAAE,UAAU,GAAG,OAAO,CAAC;IAClC,GAAG,IAAI,IAAI,CAAC;IACZ,EAAE,CAAC,KAAK,EAAE,OAAO,GAAG,OAAO,EAAE,QAAQ,EAAE,CAAC,GAAG,IAAI,EAAE,OAAO,EAAE,KAAK,IAAI,GAAG,IAAI,CAAC;IAC3E,IAAI,CAAC,KAAK,EAAE,OAAO,EAAE,QAAQ,EAAE,MAAM,IAAI,GAAG,IAAI,CAAC;CAClD;AAED,MAAM,WAAW,kBAAkB;IACjC,KAAK,CAAC,EAAE,mBAAmB,GAAG,IAAI,CAAC;IACnC,MAAM,EAAE,iBAAiB,GAAG,IAAI,CAAC;IACjC,MAAM,EAAE,iBAAiB,GAAG,IAAI,CAAC;IACjC,EAAE,CAAC,KAAK,EAAE,OAAO,EAAE,QAAQ,EAAE,CAAC,KAAK,EAAE,KAAK,KAAK,IAAI,GAAG,IAAI,CAAC;IAC3D,EAAE,CAAC,KAAK,EAAE,OAAO,EAAE,QAAQ,EAAE,CAAC,IAAI,EAAE,MAAM,GAAG,IAAI,KAAK,IAAI,GAAG,IAAI,CAAC;IAClE,IAAI,CAAC,MAAM,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;CAC7B;AAED,MAAM,MAAM,WAAW,GAAG,CAAC,OAAO,EAAE,MAAM,EAAE,IAAI,EAAE,SAAS,MAAM,EAAE,KAAK,kBAAkB,CAAC;AAE3F,MAAM,WAAW,gBAAgB;IAC/B,UAAU,CAAC,EAAE,CAAC,KAAK,EAAE,MAAM,KAAK,IAAI,CAAC;IACrC,MAAM,CAAC,EAAE,WAAW,CAAC;IACrB;;;;OAIG;IACH,KAAK,CAAC,EAAE,aAAa,CAAC,UAAU,CAAC,CAAC;CACnC;AAED,MAAM,WAAW,eAAe;IAC9B,IAAI,EAAE,MAAM,CAAC;IACb,MAAM,EAAE,MAAM,CAAC;CAChB;AAED,MAAM,WAAW,YAAY;IAC3B,GAAG,CAAC,IAAI,EAAE,SAAS,MAAM,EAAE,EAAE,OAAO,CAAC,EAAE,gBAAgB,GAAG,OAAO,CAAC,eAAe,CAAC,CAAC;CACpF;AAED,MAAM,WAAW,yBAAyB;IACxC,KAAK,CAAC,EAAE,WAAW,CAAC;IACpB,aAAa,CAAC,EAAE,MAAM,MAAM,GAAG,OAAO,CAAC,MAAM,CAAC,CAAC;CAChD;AAID,wBAAgB,kBAAkB,CAAC,OAAO,GAAE,yBAA8B,GAAG,YAAY,CAkGxF"}
@@ -15,6 +15,7 @@ export function createFfmpegRunner(options = {}) {
15
15
  const child = spawn(binary, args);
16
16
  let stderr = "";
17
17
  let settled = false;
18
+ let closed = false;
18
19
  const settle = (action) => {
19
20
  if (settled) {
20
21
  return;
@@ -32,6 +33,22 @@ export function createFfmpegRunner(options = {}) {
32
33
  if (signal !== undefined) {
33
34
  signal.addEventListener("abort", onAbort);
34
35
  }
36
+ if (runOptions.stdin !== undefined) {
37
+ const stdin = child.stdin;
38
+ if (stdin === undefined || stdin === null) {
39
+ child.kill("SIGKILL");
40
+ settle(() => reject(renderError({
41
+ code: "FFMPEG_FAILED",
42
+ stage: "ffmpeg",
43
+ message: "ffmpeg child process exposes no stdin to pipe frames into."
44
+ })));
45
+ return;
46
+ }
47
+ pumpStdin(stdin, runOptions.stdin, () => closed).catch((error) => {
48
+ child.kill("SIGKILL");
49
+ settle(() => reject(error));
50
+ });
51
+ }
35
52
  child.stderr?.on("data", (chunk) => {
36
53
  stderr = `${stderr}${String(chunk)}`.slice(-STDERR_TAIL_LENGTH);
37
54
  });
@@ -47,6 +64,7 @@ export function createFfmpegRunner(options = {}) {
47
64
  })));
48
65
  });
49
66
  child.on("close", (code) => {
67
+ closed = true;
50
68
  settle(() => {
51
69
  if (code === 0) {
52
70
  resolve({ code: 0, stderr });
@@ -62,6 +80,34 @@ export function createFfmpegRunner(options = {}) {
62
80
  }
63
81
  };
64
82
  }
83
+ async function pumpStdin(stdin, source, isClosed) {
84
+ // One permanent listener per event; each backpressure pause parks a single
85
+ // waiter that any of drain/error/close wakes. EPIPE from an early ffmpeg
86
+ // exit is swallowed here — the child close handler reports the real failure
87
+ // with stderr context.
88
+ let waiter = null;
89
+ const wake = () => {
90
+ const parked = waiter;
91
+ waiter = null;
92
+ parked?.();
93
+ };
94
+ stdin.on("drain", wake);
95
+ stdin.on("error", wake);
96
+ stdin.once("close", wake);
97
+ for await (const chunk of source) {
98
+ if (isClosed()) {
99
+ return;
100
+ }
101
+ if (!stdin.write(chunk)) {
102
+ await new Promise((resolve) => {
103
+ waiter = resolve;
104
+ });
105
+ }
106
+ }
107
+ if (!isClosed()) {
108
+ stdin.end();
109
+ }
110
+ }
65
111
  function cancelledError() {
66
112
  return renderError({
67
113
  code: "RENDER_CANCELLED",
@@ -0,0 +1,20 @@
1
+ /**
2
+ * Bounded async byte queue bridging frame capture (producer) and an ffmpeg
3
+ * stdin pump (consumer). push() resolves immediately while the buffered bytes
4
+ * stay under the cap, otherwise it waits for the consumer — so a slow encoder
5
+ * applies backpressure to capture instead of buffering the whole render.
6
+ */
7
+ export interface FrameByteQueue extends AsyncIterable<Uint8Array> {
8
+ /** Enqueue one chunk; resolves once the queue is willing to accept more. */
9
+ push(chunk: Uint8Array): Promise<void>;
10
+ /** Signal that no more chunks will arrive; iteration completes after drain. */
11
+ end(): void;
12
+ /** Poison the queue: pending and future pushes and iteration reject with the error. */
13
+ fail(error: unknown): void;
14
+ }
15
+ export interface CreateFrameByteQueueOptions {
16
+ /** Buffered-byte threshold above which push() waits for the consumer. Default 64 MiB. */
17
+ maxBufferedBytes?: number;
18
+ }
19
+ export declare function createFrameByteQueue(options?: CreateFrameByteQueueOptions): FrameByteQueue;
20
+ //# sourceMappingURL=frame-stream.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"frame-stream.d.ts","sourceRoot":"","sources":["../src/frame-stream.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AACH,MAAM,WAAW,cAAe,SAAQ,aAAa,CAAC,UAAU,CAAC;IAC/D,4EAA4E;IAC5E,IAAI,CAAC,KAAK,EAAE,UAAU,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;IACvC,+EAA+E;IAC/E,GAAG,IAAI,IAAI,CAAC;IACZ,uFAAuF;IACvF,IAAI,CAAC,KAAK,EAAE,OAAO,GAAG,IAAI,CAAC;CAC5B;AAED,MAAM,WAAW,2BAA2B;IAC1C,yFAAyF;IACzF,gBAAgB,CAAC,EAAE,MAAM,CAAC;CAC3B;AAID,wBAAgB,oBAAoB,CAAC,OAAO,GAAE,2BAAgC,GAAG,cAAc,CAsF9F"}
@@ -0,0 +1,82 @@
1
+ const DEFAULT_MAX_BUFFERED_BYTES = 64 * 1024 * 1024;
2
+ export function createFrameByteQueue(options = {}) {
3
+ const maxBufferedBytes = options.maxBufferedBytes ?? DEFAULT_MAX_BUFFERED_BYTES;
4
+ const chunks = [];
5
+ let bufferedBytes = 0;
6
+ let ended = false;
7
+ let failure = null;
8
+ let notifyConsumer = null;
9
+ const producerWaiters = [];
10
+ const wakeConsumer = () => {
11
+ notifyConsumer?.();
12
+ notifyConsumer = null;
13
+ };
14
+ const releaseProducers = () => {
15
+ while (producerWaiters.length > 0 && (failure !== null || bufferedBytes <= maxBufferedBytes)) {
16
+ const waiter = producerWaiters.shift();
17
+ if (waiter === undefined) {
18
+ return;
19
+ }
20
+ if (failure !== null) {
21
+ waiter.reject(failure.error);
22
+ }
23
+ else {
24
+ waiter.resolve();
25
+ }
26
+ }
27
+ };
28
+ return {
29
+ async push(chunk) {
30
+ if (failure !== null) {
31
+ throw failure.error;
32
+ }
33
+ if (ended) {
34
+ throw new Error("Cannot push to an ended frame byte queue.");
35
+ }
36
+ const hadCapacity = bufferedBytes < maxBufferedBytes;
37
+ chunks.push(chunk);
38
+ bufferedBytes += chunk.byteLength;
39
+ wakeConsumer();
40
+ if (hadCapacity) {
41
+ return;
42
+ }
43
+ await new Promise((resolve, reject) => {
44
+ producerWaiters.push({ resolve, reject });
45
+ });
46
+ },
47
+ end() {
48
+ ended = true;
49
+ wakeConsumer();
50
+ },
51
+ fail(error) {
52
+ if (failure !== null) {
53
+ return;
54
+ }
55
+ failure = { error };
56
+ chunks.length = 0;
57
+ bufferedBytes = 0;
58
+ wakeConsumer();
59
+ releaseProducers();
60
+ },
61
+ async *[Symbol.asyncIterator]() {
62
+ while (true) {
63
+ if (failure !== null) {
64
+ throw failure.error;
65
+ }
66
+ const chunk = chunks.shift();
67
+ if (chunk !== undefined) {
68
+ bufferedBytes -= chunk.byteLength;
69
+ releaseProducers();
70
+ yield chunk;
71
+ continue;
72
+ }
73
+ if (ended) {
74
+ return;
75
+ }
76
+ await new Promise((resolve) => {
77
+ notifyConsumer = resolve;
78
+ });
79
+ }
80
+ }
81
+ };
82
+ }
package/dist/index.d.ts CHANGED
@@ -1,11 +1,12 @@
1
1
  export declare const KAVIO_RENDER_PACKAGE = "@kitsra/kavio-render";
2
2
  export { renderError, isRenderError, RENDER_ERROR_CODES, type RenderErrorCode, type RenderErrorOptions } from "./errors.js";
3
- export { assembleRenderCommand, type AssembleRenderCommandOptions } from "./assemble-command.js";
3
+ export { assembleDirectRenderCommand, assembleRenderCommand, getDirectRenderSupport, type AssembleDirectRenderCommandOptions, type AssembleRenderCommandOptions, type DirectRenderSupport } from "./assemble-command.js";
4
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";
5
+ export { createFfmpegRunner, type FfmpegRunner, type FfmpegRunOptions, type FfmpegRunResult, type FfmpegSpawn, type FfmpegChildProcess, type FfmpegChildWritable, type CreateFfmpegRunnerOptions } from "./ffmpeg-runner.js";
6
+ export { createFrameByteQueue, type FrameByteQueue, type CreateFrameByteQueueOptions } from "./frame-stream.js";
6
7
  export { createRenderHarnessServer, type RenderHarnessServer, type CreateRenderHarnessServerOptions } from "./harness-server.js";
7
8
  export { PlaywrightDriver, type PlaywrightDriverOptions } from "./playwright-driver.js";
8
- export { renderComposition, type RenderCompositionOptions, type RenderCompositionResult } from "./render-composition.js";
9
+ export { renderComposition, type RenderCompositionMode, type RenderCompositionOptions, type RenderCompositionResult, type RenderStageTimings } from "./render-composition.js";
9
10
  export { renderBatch, type RenderBatchOptions, type RenderBatchItemResult } from "./render-batch.js";
10
11
  export type { RenderBatchInput, RenderBatchRow } from "@kitsra/kavio-render-worker";
11
12
  export { FakeBrowserDriver, createFakeFfmpegRunner, type FakeFfmpegRunner, type CreateFakeFfmpegRunnerOptions } from "./testing.js";
@@ -1 +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"}
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,EACL,2BAA2B,EAC3B,qBAAqB,EACrB,sBAAsB,EACtB,KAAK,kCAAkC,EACvC,KAAK,4BAA4B,EACjC,KAAK,mBAAmB,EACzB,MAAM,uBAAuB,CAAC;AAC/B,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,mBAAmB,EACxB,KAAK,yBAAyB,EAC/B,MAAM,oBAAoB,CAAC;AAC5B,OAAO,EAAE,oBAAoB,EAAE,KAAK,cAAc,EAAE,KAAK,2BAA2B,EAAE,MAAM,mBAAmB,CAAC;AAChH,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,qBAAqB,EAC1B,KAAK,wBAAwB,EAC7B,KAAK,uBAAuB,EAC5B,KAAK,kBAAkB,EACxB,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 CHANGED
@@ -1,8 +1,9 @@
1
1
  export const KAVIO_RENDER_PACKAGE = "@kitsra/kavio-render";
2
2
  export { renderError, isRenderError, RENDER_ERROR_CODES } from "./errors.js";
3
- export { assembleRenderCommand } from "./assemble-command.js";
3
+ export { assembleDirectRenderCommand, assembleRenderCommand, getDirectRenderSupport } from "./assemble-command.js";
4
4
  export { resolveFfmpegPath } from "./binaries.js";
5
5
  export { createFfmpegRunner } from "./ffmpeg-runner.js";
6
+ export { createFrameByteQueue } from "./frame-stream.js";
6
7
  export { createRenderHarnessServer } from "./harness-server.js";
7
8
  export { PlaywrightDriver } from "./playwright-driver.js";
8
9
  export { renderComposition } from "./render-composition.js";
@@ -13,6 +13,7 @@ export declare class PlaywrightDriver implements BrowserDriver {
13
13
  private browser;
14
14
  private context;
15
15
  private page;
16
+ private cdp;
16
17
  private server;
17
18
  private viewport;
18
19
  private readonly deviceScaleFactor;
@@ -22,6 +23,14 @@ export declare class PlaywrightDriver implements BrowserDriver {
22
23
  constructor(options?: PlaywrightDriverOptions);
23
24
  open(composition: KavioDocument, options?: BrowserOpenOptions): Promise<void>;
24
25
  renderFrame(frame: number, options?: BrowserFrameCaptureOptions): Promise<BrowserFrameCapture>;
26
+ /**
27
+ * Launch a sibling Chromium process against this driver's harness server,
28
+ * ready to render frames for the same composition. Chromium serializes
29
+ * screenshot capture inside one browser process, so true capture
30
+ * parallelism needs one process per worker; the harness server and its
31
+ * composition stay owned by this driver.
32
+ */
33
+ fork(): Promise<BrowserDriver>;
25
34
  close(): Promise<void>;
26
35
  }
27
36
  //# sourceMappingURL=playwright-driver.d.ts.map
@@ -1 +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"}
1
+ {"version":3,"file":"playwright-driver.d.ts","sourceRoot":"","sources":["../src/playwright-driver.ts"],"names":[],"mappings":"AACA,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;AAqD1D,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,GAAG,CAAqC;IAChD,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;IAuCjF,WAAW,CAAC,KAAK,EAAE,MAAM,EAAE,OAAO,GAAE,0BAA+B,GAAG,OAAO,CAAC,mBAAmB,CAAC;IAYxG;;;;;;OAMG;IACG,IAAI,IAAI,OAAO,CAAC,aAAa,CAAC;IA6B9B,KAAK,IAAI,OAAO,CAAC,IAAI,CAAC;CAY7B"}
@@ -1,3 +1,4 @@
1
+ import { Buffer } from "node:buffer";
1
2
  import { createBrowserViewport, createPngFrameCapture, DEFAULT_CHROMIUM_LAUNCH_OPTIONS } from "@kitsra/kavio-render-worker";
2
3
  import { renderError } from "./errors.js";
3
4
  import { createRenderHarnessServer } from "./harness-server.js";
@@ -25,6 +26,7 @@ export class PlaywrightDriver {
25
26
  browser = null;
26
27
  context = null;
27
28
  page = null;
29
+ cdp = null;
28
30
  server = null;
29
31
  viewport = null;
30
32
  deviceScaleFactor;
@@ -70,6 +72,7 @@ export class PlaywrightDriver {
70
72
  this.server = await createRenderHarnessServer({ composition });
71
73
  await this.page.goto(this.server.url);
72
74
  await this.page.waitForFunction("window.__kavioReady === true", undefined, { timeout: this.readyTimeoutMs });
75
+ this.cdp = await createFastScreenshotSession(this.context, this.page);
73
76
  }
74
77
  async renderFrame(frame, options = {}) {
75
78
  if (this.page === null || this.viewport === null) {
@@ -79,10 +82,43 @@ export class PlaywrightDriver {
79
82
  message: "PlaywrightDriver.renderFrame called before open()."
80
83
  });
81
84
  }
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 });
85
+ return renderFrameOnPage(this.page, this.cdp, this.viewport, frame, options);
86
+ }
87
+ /**
88
+ * Launch a sibling Chromium process against this driver's harness server,
89
+ * ready to render frames for the same composition. Chromium serializes
90
+ * screenshot capture inside one browser process, so true capture
91
+ * parallelism needs one process per worker; the harness server and its
92
+ * composition stay owned by this driver.
93
+ */
94
+ async fork() {
95
+ if (this.server === null || this.viewport === null) {
96
+ throw renderError({
97
+ code: "RENDER_FAILED",
98
+ stage: "render",
99
+ message: "PlaywrightDriver.fork called before open()."
100
+ });
101
+ }
102
+ const chromium = await loadChromium();
103
+ const browser = await chromium.launch({
104
+ headless: DEFAULT_CHROMIUM_LAUNCH_OPTIONS.headless,
105
+ args: DEFAULT_CHROMIUM_LAUNCH_OPTIONS.args
106
+ });
107
+ try {
108
+ const context = await browser.newContext({
109
+ viewport: { width: this.viewport.width, height: this.viewport.height },
110
+ deviceScaleFactor: this.viewport.deviceScaleFactor
111
+ });
112
+ const page = await context.newPage();
113
+ await page.goto(this.server.url);
114
+ await page.waitForFunction("window.__kavioReady === true", undefined, { timeout: this.readyTimeoutMs });
115
+ const cdp = await createFastScreenshotSession(context, page);
116
+ return new PlaywrightForkDriver(browser, page, cdp, this.viewport);
117
+ }
118
+ catch (error) {
119
+ await browser.close();
120
+ throw error;
121
+ }
86
122
  }
87
123
  async close() {
88
124
  try {
@@ -98,6 +134,76 @@ export class PlaywrightDriver {
98
134
  this.viewport = null;
99
135
  }
100
136
  }
137
+ /** Fork of a PlaywrightDriver: its own Chromium process on the shared harness server. */
138
+ class PlaywrightForkDriver {
139
+ browser;
140
+ page;
141
+ cdp;
142
+ viewport;
143
+ constructor(browser, page, cdp, viewport) {
144
+ this.browser = browser;
145
+ this.page = page;
146
+ this.cdp = cdp;
147
+ this.viewport = viewport;
148
+ }
149
+ async open() {
150
+ throw renderError({
151
+ code: "RENDER_FAILED",
152
+ stage: "render",
153
+ message: "Forked Playwright drivers are already open."
154
+ });
155
+ }
156
+ async renderFrame(frame, options = {}) {
157
+ return renderFrameOnPage(this.page, this.cdp, this.viewport, frame, options);
158
+ }
159
+ async close() {
160
+ await this.browser.close();
161
+ }
162
+ }
163
+ /**
164
+ * Prepare a raw CDP screenshot session: the transparent-background override is
165
+ * applied once here instead of per page.screenshot() call, and captures use
166
+ * Page.captureScreenshot with optimizeForSpeed (fastest PNG compression level;
167
+ * identical decoded pixels, so encoded outputs stay deterministic). Returns
168
+ * null when CDP is unavailable so capture falls back to page.screenshot().
169
+ */
170
+ async function createFastScreenshotSession(context, page) {
171
+ try {
172
+ const session = await context.newCDPSession(page);
173
+ await session.send("Emulation.setDefaultBackgroundColorOverride", {
174
+ color: { r: 0, g: 0, b: 0, a: 0 }
175
+ });
176
+ return session;
177
+ }
178
+ catch {
179
+ return null;
180
+ }
181
+ }
182
+ async function renderFrameOnPage(page, cdp, viewport, frame, options) {
183
+ const omitBackground = options.omitBackground ?? true;
184
+ const evaluateStart = performance.now();
185
+ await page.evaluate(`window.__kavio.renderFrame(${frame})`);
186
+ const screenshotStart = performance.now();
187
+ let bytes;
188
+ if (cdp !== null && omitBackground) {
189
+ const result = (await cdp.send("Page.captureScreenshot", {
190
+ format: "png",
191
+ optimizeForSpeed: true
192
+ }));
193
+ bytes = Buffer.from(result.data, "base64");
194
+ }
195
+ else {
196
+ bytes = await page.screenshot({ type: "png", omitBackground });
197
+ }
198
+ const screenshotEnd = performance.now();
199
+ return createPngFrameCapture({
200
+ frame,
201
+ bytes,
202
+ viewport,
203
+ omitBackground,
204
+ timing: { evaluateMs: screenshotStart - evaluateStart, screenshotMs: screenshotEnd - screenshotStart }
205
+ });
206
+ }
101
207
  function missingBrowserExecutable(message) {
102
208
  return message.includes("Executable doesn't exist") || message.includes("playwright install");
103
209
  }
@@ -1,11 +1,22 @@
1
1
  import { type KavioDocument, type KavioError } from "@kitsra/kavio-schema";
2
2
  import { type BrowserDriver, type RenderOutputMetadata } from "@kitsra/kavio-render-worker";
3
3
  import { type FfmpegRunner } from "./ffmpeg-runner.js";
4
+ export type RenderCompositionMode = "browser-overlay" | "ffmpeg-direct";
4
5
  export interface RenderCompositionOptions {
5
6
  preset: string | import("@kitsra/kavio-schema").KavioExportPreset;
6
7
  propValues?: Record<string, unknown>;
7
8
  outDir?: string;
8
9
  outputName?: string;
10
+ /**
11
+ * Experimental: "ffmpeg-direct" skips browser PNG capture for compositions
12
+ * that can be compiled directly into FFmpeg filters.
13
+ */
14
+ renderMode?: RenderCompositionMode;
15
+ /**
16
+ * Concurrent capture pages for browser-overlay renders. Defaults to
17
+ * min(4, cores - 1). Deterministic: output bytes match serial capture.
18
+ */
19
+ captureParallelism?: number;
9
20
  driver?: BrowserDriver;
10
21
  ffmpegRunner?: FfmpegRunner;
11
22
  signal?: AbortSignal;
@@ -13,10 +24,27 @@ export interface RenderCompositionOptions {
13
24
  ffmpegVersion?: string;
14
25
  chromiumRevision?: string;
15
26
  }
27
+ export interface RenderStageTimings {
28
+ /** Browser launch + frame capture wall time; absent for ffmpeg-direct renders. */
29
+ captureMs?: number;
30
+ /** Driver open wall time (browser launch + harness ready) within captureMs. */
31
+ browserOpenMs?: number;
32
+ /** Summed per-frame seek/evaluate time, when the driver reports it. */
33
+ captureEvaluateMs?: number;
34
+ /** Summed per-frame screenshot time, when the driver reports it. */
35
+ captureScreenshotMs?: number;
36
+ /** FFmpeg encode wall time. */
37
+ encodeMs: number;
38
+ /** Output checksum wall time. */
39
+ checksumMs: number;
40
+ /** Full renderComposition wall time, including validation and cleanup. */
41
+ totalMs: number;
42
+ }
16
43
  export type RenderCompositionResult = {
17
44
  ok: true;
18
45
  outputPath: string;
19
46
  metadata: RenderOutputMetadata;
47
+ timings: RenderStageTimings;
20
48
  } | {
21
49
  ok: false;
22
50
  errors: KavioError[];
@@ -1 +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"}
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,EAGL,KAAK,aAAa,EAElB,KAAK,oBAAoB,EAC1B,MAAM,6BAA6B,CAAC;AAErC,OAAO,EAAsB,KAAK,YAAY,EAAE,MAAM,oBAAoB,CAAC;AAM3E,MAAM,MAAM,qBAAqB,GAAG,iBAAiB,GAAG,eAAe,CAAC;AAExE,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;;;OAGG;IACH,UAAU,CAAC,EAAE,qBAAqB,CAAC;IACnC;;;OAGG;IACH,kBAAkB,CAAC,EAAE,MAAM,CAAC;IAC5B,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,WAAW,kBAAkB;IACjC,kFAAkF;IAClF,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,+EAA+E;IAC/E,aAAa,CAAC,EAAE,MAAM,CAAC;IACvB,uEAAuE;IACvE,iBAAiB,CAAC,EAAE,MAAM,CAAC;IAC3B,oEAAoE;IACpE,mBAAmB,CAAC,EAAE,MAAM,CAAC;IAC7B,+BAA+B;IAC/B,QAAQ,EAAE,MAAM,CAAC;IACjB,iCAAiC;IACjC,UAAU,EAAE,MAAM,CAAC;IACnB,0EAA0E;IAC1E,OAAO,EAAE,MAAM,CAAC;CACjB;AAED,MAAM,MAAM,uBAAuB,GAC/B;IAAE,EAAE,EAAE,IAAI,CAAC;IAAC,UAAU,EAAE,MAAM,CAAC;IAAC,QAAQ,EAAE,oBAAoB,CAAC;IAAC,OAAO,EAAE,kBAAkB,CAAA;CAAE,GAC7F;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,CA+IlC"}
@@ -1,17 +1,19 @@
1
1
  import { createHash } from "node:crypto";
2
- import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
3
- import { tmpdir } from "node:os";
2
+ import { mkdir, readFile } from "node:fs/promises";
3
+ import { availableParallelism } from "node:os";
4
4
  import { dirname, join } from "node:path";
5
5
  import { applyExportPreset, collectCompositionResourceLimitInputs, collectResourceLimitViolations, resolveTemplateProps } from "@kitsra/kavio-core";
6
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";
7
+ import { captureFrames, createRenderMetadata } from "@kitsra/kavio-render-worker";
8
+ import { assembleDirectRenderCommand, assembleRenderCommand } from "./assemble-command.js";
9
9
  import { createFfmpegRunner } from "./ffmpeg-runner.js";
10
+ import { createFrameByteQueue } from "./frame-stream.js";
10
11
  import { isRenderError, renderError } from "./errors.js";
11
12
  import { PlaywrightDriver } from "./playwright-driver.js";
12
13
  import { withEffectiveCodecs } from "./encoding.js";
13
14
  /** End-to-end render for one (composition × export): props → view → validate → capture → encode. */
14
15
  export async function renderComposition(doc, options) {
16
+ const totalStart = performance.now();
15
17
  const resolution = resolveTemplateProps(doc, options.propValues ?? {});
16
18
  if (!resolution.ok) {
17
19
  return { ok: false, errors: resolution.errors };
@@ -45,45 +47,100 @@ export async function renderComposition(doc, options) {
45
47
  const outputName = options.outputName ?? `${preset.name}.${extensionForFormat(preset.format)}`;
46
48
  const outputPath = options.outDir === undefined ? outputName : join(options.outDir, outputName);
47
49
  const metadataPreset = withEffectiveCodecs(preset);
50
+ const renderMode = options.renderMode ?? "browser-overlay";
48
51
  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");
52
+ let captureMs;
53
+ let browserOpenMs;
54
+ let captureEvaluateMs;
55
+ let captureScreenshotMs;
56
+ let encodeMs = 0;
57
+ await mkdir(dirname(outputPath), { recursive: true });
58
+ const ffmpegRunner = options.ffmpegRunner ?? createFfmpegRunner();
59
+ let browserDriver;
60
+ if (renderMode === "ffmpeg-direct") {
61
+ const args = assembleDirectRenderCommand({ view, preset, outputPath });
62
+ const encodeStart = performance.now();
63
+ await ffmpegRunner.run(args, options.signal === undefined ? {} : { signal: options.signal });
64
+ encodeMs = performance.now() - encodeStart;
65
+ }
66
+ else {
67
+ // Overlay frames stream straight into ffmpeg stdin so capture and encode
68
+ // overlap; the bounded queue applies backpressure instead of buffering
69
+ // the render or round-tripping PNG files through a temp directory.
70
+ const args = assembleRenderCommand({ view, preset, outputPath });
71
+ browserDriver = options.driver ?? new PlaywrightDriver();
72
+ const frames = createFrameByteQueue();
73
+ const captureStart = performance.now();
56
74
  // captureFrames manages browser-context cleanup (open → capture → close).
57
- await captureFrames({
58
- driver,
75
+ const capturePromise = captureFrames({
76
+ driver: browserDriver,
59
77
  composition: view,
78
+ parallelism: options.captureParallelism ?? defaultCaptureParallelism(),
60
79
  continueOnFrameError: options.continueOnFrameError === true,
61
80
  onFrame: async (capture) => {
62
- const name = `overlay-${String(capture.frame).padStart(5, "0")}.png`;
63
- await writeFile(join(workDir, name), capture.bytes);
81
+ await frames.push(capture.bytes);
64
82
  }
83
+ }).then((captureResult) => {
84
+ captureMs = performance.now() - captureStart;
85
+ browserOpenMs = captureResult.openMs;
86
+ captureEvaluateMs = captureResult.evaluateMs;
87
+ captureScreenshotMs = captureResult.screenshotMs;
88
+ frames.end();
89
+ }, (error) => {
90
+ frames.fail(error);
91
+ throw error;
65
92
  });
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)
93
+ const encodeStart = performance.now();
94
+ const ffmpegPromise = ffmpegRunner
95
+ .run(args, options.signal === undefined ? { stdin: frames } : { stdin: frames, signal: options.signal })
96
+ .then((result) => {
97
+ encodeMs = performance.now() - encodeStart;
98
+ return result;
99
+ }, (error) => {
100
+ // Stop capture instead of letting it fill the queue for a dead consumer.
101
+ frames.fail(error);
102
+ throw error;
79
103
  });
104
+ const [captureSettled, ffmpegSettled] = await Promise.allSettled([capturePromise, ffmpegPromise]);
105
+ // A genuine capture failure poisons the queue and surfaces through the
106
+ // ffmpeg rejection too, so prefer the ffmpeg outcome, then capture.
107
+ if (ffmpegSettled.status === "rejected") {
108
+ throw ffmpegSettled.reason;
109
+ }
110
+ if (captureSettled.status === "rejected") {
111
+ throw captureSettled.reason;
112
+ }
113
+ }
114
+ const checksumStart = performance.now();
115
+ const checksum = await sha256File(outputPath);
116
+ const checksumMs = performance.now() - checksumStart;
117
+ const metadata = createRenderMetadata({
118
+ composition: view.composition,
119
+ preset: metadataPreset,
120
+ outputName,
121
+ outputPath,
122
+ checksums: checksum,
123
+ ffmpegVersion: options.ffmpegVersion ?? "ffmpeg-static",
124
+ chromiumRevision: options.chromiumRevision ?? (renderMode === "ffmpeg-direct" ? "not-used" : chromiumRevisionOf(browserDriver))
80
125
  });
81
- return { ok: true, outputPath, metadata };
126
+ const timings = {
127
+ ...(captureMs !== undefined && { captureMs }),
128
+ ...(browserOpenMs !== undefined && { browserOpenMs }),
129
+ ...(captureEvaluateMs !== undefined && { captureEvaluateMs }),
130
+ ...(captureScreenshotMs !== undefined && { captureScreenshotMs }),
131
+ encodeMs,
132
+ checksumMs,
133
+ totalMs: performance.now() - totalStart
134
+ };
135
+ return { ok: true, outputPath, metadata, timings };
82
136
  }
83
137
  catch (error) {
84
138
  return { ok: false, errors: [toKavioError(error)] };
85
139
  }
86
140
  }
141
+ function defaultCaptureParallelism() {
142
+ return Math.max(1, Math.min(4, availableParallelism() - 1));
143
+ }
87
144
  function chromiumRevisionOf(driver) {
88
145
  if (driver instanceof PlaywrightDriver && driver.chromiumVersion !== null) {
89
146
  return driver.chromiumVersion;
package/dist/testing.d.ts CHANGED
@@ -5,15 +5,24 @@ import type { FfmpegRunner } from "./ffmpeg-runner.js";
5
5
  export declare class FakeBrowserDriver implements BrowserDriver {
6
6
  opens: number;
7
7
  closes: number;
8
+ /** Forks created across this driver and its descendants. */
9
+ forks: number;
10
+ /** Fork close() calls, aggregated on the root driver. */
11
+ forkCloses: number;
12
+ /** Frames rendered across this driver and all forks, in completion order. */
8
13
  renderedFrames: number[];
9
14
  private viewport;
15
+ private root;
10
16
  open(composition: KavioDocument, options?: BrowserOpenOptions): Promise<void>;
17
+ fork(): Promise<BrowserDriver>;
11
18
  renderFrame(frame: number, options?: BrowserFrameCaptureOptions): Promise<BrowserFrameCapture>;
12
19
  close(): Promise<void>;
13
20
  }
14
21
  export interface FakeFfmpegRunner extends FfmpegRunner {
15
22
  /** Argument lists captured from each run() call. */
16
23
  readonly calls: string[][];
24
+ /** Chunks consumed from the stdin stream across all run() calls. */
25
+ readonly stdinChunks: Uint8Array[];
17
26
  }
18
27
  export interface CreateFakeFfmpegRunnerOptions {
19
28
  /** When true, run() rejects with FFMPEG_FAILED (for cleanup-on-failure tests). */
@@ -1 +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"}
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,4DAA4D;IAC5D,KAAK,SAAK;IACV,yDAAyD;IACzD,UAAU,SAAK;IACf,6EAA6E;IAC7E,cAAc,EAAE,MAAM,EAAE,CAAM;IAC9B,OAAO,CAAC,QAAQ,CAAgC;IAChD,OAAO,CAAC,IAAI,CAAkC;IAExC,IAAI,CAAC,WAAW,EAAE,aAAa,EAAE,OAAO,GAAE,kBAAuB,GAAG,OAAO,CAAC,IAAI,CAAC;IAKjF,IAAI,IAAI,OAAO,CAAC,aAAa,CAAC;IAgB9B,WAAW,CAAC,KAAK,EAAE,MAAM,EAAE,OAAO,GAAE,0BAA+B,GAAG,OAAO,CAAC,mBAAmB,CAAC;IAkBlG,KAAK,IAAI,OAAO,CAAC,IAAI,CAAC;CAQ7B;AAED,MAAM,WAAW,gBAAiB,SAAQ,YAAY;IACpD,oDAAoD;IACpD,QAAQ,CAAC,KAAK,EAAE,MAAM,EAAE,EAAE,CAAC;IAC3B,oEAAoE;IACpE,QAAQ,CAAC,WAAW,EAAE,UAAU,EAAE,CAAC;CACpC;AAED,MAAM,WAAW,6BAA6B;IAC5C,kFAAkF;IAClF,IAAI,CAAC,EAAE,OAAO,CAAC;CAChB;AAED,2EAA2E;AAC3E,wBAAgB,sBAAsB,CAAC,OAAO,GAAE,6BAAkC,GAAG,gBAAgB,CA4BpG"}
package/dist/testing.js CHANGED
@@ -7,12 +7,33 @@ const FAKE_PNG = new Uint8Array([137, 80, 78, 71, 13, 10, 26, 10]);
7
7
  export class FakeBrowserDriver {
8
8
  opens = 0;
9
9
  closes = 0;
10
+ /** Forks created across this driver and its descendants. */
11
+ forks = 0;
12
+ /** Fork close() calls, aggregated on the root driver. */
13
+ forkCloses = 0;
14
+ /** Frames rendered across this driver and all forks, in completion order. */
10
15
  renderedFrames = [];
11
16
  viewport = null;
17
+ root = null;
12
18
  async open(composition, options = {}) {
13
19
  this.opens += 1;
14
20
  this.viewport = options.viewport ?? createBrowserViewport(composition);
15
21
  }
22
+ async fork() {
23
+ if (this.viewport === null) {
24
+ throw renderError({
25
+ code: "RENDER_FRAME_FAILED",
26
+ stage: "render",
27
+ message: "FakeBrowserDriver.fork called before open()."
28
+ });
29
+ }
30
+ const root = this.root ?? this;
31
+ root.forks += 1;
32
+ const child = new FakeBrowserDriver();
33
+ child.root = root;
34
+ child.viewport = this.viewport;
35
+ return child;
36
+ }
16
37
  async renderFrame(frame, options = {}) {
17
38
  if (this.viewport === null) {
18
39
  throw renderError({
@@ -21,29 +42,42 @@ export class FakeBrowserDriver {
21
42
  message: "FakeBrowserDriver.renderFrame called before open()."
22
43
  });
23
44
  }
24
- this.renderedFrames.push(frame);
45
+ (this.root ?? this).renderedFrames.push(frame);
25
46
  return createPngFrameCapture({
26
47
  frame,
27
48
  bytes: FAKE_PNG,
28
49
  viewport: this.viewport,
29
- omitBackground: options.omitBackground ?? true
50
+ omitBackground: options.omitBackground ?? true,
51
+ timing: { evaluateMs: 0, screenshotMs: 0 }
30
52
  });
31
53
  }
32
54
  async close() {
33
- this.closes += 1;
55
+ if (this.root !== null) {
56
+ this.root.forkCloses += 1;
57
+ }
58
+ else {
59
+ this.closes += 1;
60
+ }
34
61
  this.viewport = null;
35
62
  }
36
63
  }
37
64
  /** FfmpegRunner that records args and writes a placeholder output file. */
38
65
  export function createFakeFfmpegRunner(options = {}) {
39
66
  const calls = [];
67
+ const stdinChunks = [];
40
68
  return {
41
69
  calls,
42
- async run(args, _runOptions) {
70
+ stdinChunks,
71
+ async run(args, runOptions) {
43
72
  calls.push([...args]);
44
73
  if (options.fail === true) {
45
74
  throw renderError({ code: "FFMPEG_FAILED", stage: "ffmpeg", message: "Fake ffmpeg failure." });
46
75
  }
76
+ if (runOptions?.stdin !== undefined) {
77
+ for await (const chunk of runOptions.stdin) {
78
+ stdinChunks.push(chunk);
79
+ }
80
+ }
47
81
  const outputPath = args[args.length - 1];
48
82
  if (outputPath !== undefined && outputPath.length > 0) {
49
83
  await mkdir(dirname(outputPath), { recursive: true });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kitsra/kavio-render",
3
- "version": "0.1.2",
3
+ "version": "0.2.0",
4
4
  "description": "Render execution layer for Kavio: browser frame capture and FFmpeg encoding.",
5
5
  "license": "Elastic-2.0",
6
6
  "repository": {
@@ -13,11 +13,11 @@
13
13
  "access": "public"
14
14
  },
15
15
  "dependencies": {
16
- "@kitsra/kavio-browser-renderer": "0.1.2",
17
- "@kitsra/kavio-core": "0.1.2",
18
- "@kitsra/kavio-ffmpeg": "0.1.2",
19
- "@kitsra/kavio-render-worker": "0.1.2",
20
- "@kitsra/kavio-schema": "0.1.2"
16
+ "@kitsra/kavio-browser-renderer": "0.2.0",
17
+ "@kitsra/kavio-core": "0.2.0",
18
+ "@kitsra/kavio-ffmpeg": "0.2.0",
19
+ "@kitsra/kavio-render-worker": "0.2.0",
20
+ "@kitsra/kavio-schema": "0.2.0"
21
21
  },
22
22
  "optionalDependencies": {
23
23
  "ffmpeg-static": "5.3.0",