@vibeo/renderer 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.
- package/dist/browser.d.ts +15 -0
- package/dist/browser.d.ts.map +1 -0
- package/dist/browser.js +40 -0
- package/dist/browser.js.map +1 -0
- package/dist/bundler.d.ts +10 -0
- package/dist/bundler.d.ts.map +1 -0
- package/dist/bundler.js +135 -0
- package/dist/bundler.js.map +1 -0
- package/dist/capture-frame.d.ts +12 -0
- package/dist/capture-frame.d.ts.map +1 -0
- package/dist/capture-frame.js +21 -0
- package/dist/capture-frame.js.map +1 -0
- package/dist/frame-range.d.ts +15 -0
- package/dist/frame-range.d.ts.map +1 -0
- package/dist/frame-range.js +55 -0
- package/dist/frame-range.js.map +1 -0
- package/dist/index.d.ts +11 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +18 -0
- package/dist/index.js.map +1 -0
- package/dist/parallel-render.d.ts +22 -0
- package/dist/parallel-render.d.ts.map +1 -0
- package/dist/parallel-render.js +69 -0
- package/dist/parallel-render.js.map +1 -0
- package/dist/render-composition.d.ts +19 -0
- package/dist/render-composition.d.ts.map +1 -0
- package/dist/render-composition.js +104 -0
- package/dist/render-composition.js.map +1 -0
- package/dist/seek-to-frame.d.ts +15 -0
- package/dist/seek-to-frame.d.ts.map +1 -0
- package/dist/seek-to-frame.js +58 -0
- package/dist/seek-to-frame.js.map +1 -0
- package/dist/stitch-audio.d.ts +7 -0
- package/dist/stitch-audio.d.ts.map +1 -0
- package/dist/stitch-audio.js +53 -0
- package/dist/stitch-audio.js.map +1 -0
- package/dist/stitch-frames.d.ts +11 -0
- package/dist/stitch-frames.d.ts.map +1 -0
- package/dist/stitch-frames.js +77 -0
- package/dist/stitch-frames.js.map +1 -0
- package/dist/types.d.ts +74 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +2 -0
- package/dist/types.js.map +1 -0
- package/package.json +33 -0
- package/src/browser.ts +48 -0
- package/src/bundler.ts +146 -0
- package/src/capture-frame.ts +39 -0
- package/src/frame-range.ts +81 -0
- package/src/index.ts +36 -0
- package/src/parallel-render.ts +134 -0
- package/src/render-composition.ts +144 -0
- package/src/seek-to-frame.ts +89 -0
- package/src/stitch-audio.ts +79 -0
- package/src/stitch-frames.ts +95 -0
- package/src/types.ts +80 -0
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"seek-to-frame.d.ts","sourceRoot":"","sources":["../src/seek-to-frame.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,IAAI,EAAE,MAAM,YAAY,CAAC;AAEvC;;;;;;;GAOG;AACH,wBAAsB,WAAW,CAC/B,IAAI,EAAE,IAAI,EACV,KAAK,EAAE,MAAM,EACb,aAAa,EAAE,MAAM,GACpB,OAAO,CAAC,IAAI,CAAC,CAqDf;AAED;;GAEG;AACH,wBAAsB,UAAU,CAC9B,IAAI,EAAE,IAAI,EACV,GAAG,EAAE,MAAM,GACV,OAAO,CAAC,IAAI,CAAC,CAaf"}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Navigate the headless browser page to render a specific frame.
|
|
3
|
+
*
|
|
4
|
+
* 1. Call window.vibeo_setFrame(frame, compositionId) to update the React tree.
|
|
5
|
+
* 2. Wait for React render to complete (poll for window.vibeo_ready flag).
|
|
6
|
+
* 3. Wait for all fonts to load.
|
|
7
|
+
* 4. Wait for any pending delayRender handles to resolve.
|
|
8
|
+
*/
|
|
9
|
+
export async function seekToFrame(page, frame, compositionId) {
|
|
10
|
+
// Set the frame via the global bridge function
|
|
11
|
+
await page.evaluate(({ frame, compositionId }) => {
|
|
12
|
+
const win = window;
|
|
13
|
+
if (typeof win.vibeo_setFrame === "function") {
|
|
14
|
+
win.vibeo_setFrame(frame, compositionId);
|
|
15
|
+
}
|
|
16
|
+
else {
|
|
17
|
+
throw new Error("window.vibeo_setFrame is not defined. " +
|
|
18
|
+
"Make sure the bundle registers this function.");
|
|
19
|
+
}
|
|
20
|
+
}, { frame, compositionId });
|
|
21
|
+
// Wait for React to finish rendering and all delays to resolve
|
|
22
|
+
await page.evaluate(() => {
|
|
23
|
+
return new Promise((resolve, reject) => {
|
|
24
|
+
const timeout = 30_000;
|
|
25
|
+
const start = Date.now();
|
|
26
|
+
function poll() {
|
|
27
|
+
const win = window;
|
|
28
|
+
if (Date.now() - start > timeout) {
|
|
29
|
+
reject(new Error("Timed out waiting for frame render"));
|
|
30
|
+
return;
|
|
31
|
+
}
|
|
32
|
+
const ready = win.vibeo_ready === true;
|
|
33
|
+
const noPendingDelays = win.vibeo_pendingDelays === undefined || win.vibeo_pendingDelays === 0;
|
|
34
|
+
if (ready && noPendingDelays) {
|
|
35
|
+
resolve();
|
|
36
|
+
}
|
|
37
|
+
else {
|
|
38
|
+
requestAnimationFrame(poll);
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
poll();
|
|
42
|
+
});
|
|
43
|
+
});
|
|
44
|
+
// Wait for all fonts to be ready
|
|
45
|
+
await page.evaluate(() => document.fonts.ready);
|
|
46
|
+
}
|
|
47
|
+
/**
|
|
48
|
+
* Load the bundled app in the browser page.
|
|
49
|
+
*/
|
|
50
|
+
export async function loadBundle(page, url) {
|
|
51
|
+
await page.goto(url, { waitUntil: "networkidle" });
|
|
52
|
+
// Wait for the vibeo_setFrame bridge to be available
|
|
53
|
+
await page.waitForFunction(() => {
|
|
54
|
+
const win = window;
|
|
55
|
+
return typeof win.vibeo_setFrame === "function";
|
|
56
|
+
}, { timeout: 30_000 });
|
|
57
|
+
}
|
|
58
|
+
//# sourceMappingURL=seek-to-frame.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"seek-to-frame.js","sourceRoot":"","sources":["../src/seek-to-frame.ts"],"names":[],"mappings":"AAEA;;;;;;;GAOG;AACH,MAAM,CAAC,KAAK,UAAU,WAAW,CAC/B,IAAU,EACV,KAAa,EACb,aAAqB;IAErB,+CAA+C;IAC/C,MAAM,IAAI,CAAC,QAAQ,CACjB,CAAC,EAAE,KAAK,EAAE,aAAa,EAAE,EAAE,EAAE;QAC3B,MAAM,GAAG,GAAG,MAEX,CAAC;QACF,IAAI,OAAO,GAAG,CAAC,cAAc,KAAK,UAAU,EAAE,CAAC;YAC7C,GAAG,CAAC,cAAc,CAAC,KAAK,EAAE,aAAa,CAAC,CAAC;QAC3C,CAAC;aAAM,CAAC;YACN,MAAM,IAAI,KAAK,CACb,wCAAwC;gBACtC,+CAA+C,CAClD,CAAC;QACJ,CAAC;IACH,CAAC,EACD,EAAE,KAAK,EAAE,aAAa,EAAE,CACzB,CAAC;IAEF,+DAA+D;IAC/D,MAAM,IAAI,CAAC,QAAQ,CAAC,GAAG,EAAE;QACvB,OAAO,IAAI,OAAO,CAAO,CAAC,OAAO,EAAE,MAAM,EAAE,EAAE;YAC3C,MAAM,OAAO,GAAG,MAAM,CAAC;YACvB,MAAM,KAAK,GAAG,IAAI,CAAC,GAAG,EAAE,CAAC;YAEzB,SAAS,IAAI;gBACX,MAAM,GAAG,GAAG,MAGX,CAAC;gBAEF,IAAI,IAAI,CAAC,GAAG,EAAE,GAAG,KAAK,GAAG,OAAO,EAAE,CAAC;oBACjC,MAAM,CAAC,IAAI,KAAK,CAAC,oCAAoC,CAAC,CAAC,CAAC;oBACxD,OAAO;gBACT,CAAC;gBAED,MAAM,KAAK,GAAG,GAAG,CAAC,WAAW,KAAK,IAAI,CAAC;gBACvC,MAAM,eAAe,GACnB,GAAG,CAAC,mBAAmB,KAAK,SAAS,IAAI,GAAG,CAAC,mBAAmB,KAAK,CAAC,CAAC;gBAEzE,IAAI,KAAK,IAAI,eAAe,EAAE,CAAC;oBAC7B,OAAO,EAAE,CAAC;gBACZ,CAAC;qBAAM,CAAC;oBACN,qBAAqB,CAAC,IAAI,CAAC,CAAC;gBAC9B,CAAC;YACH,CAAC;YAED,IAAI,EAAE,CAAC;QACT,CAAC,CAAC,CAAC;IACL,CAAC,CAAC,CAAC;IAEH,iCAAiC;IACjC,MAAM,IAAI,CAAC,QAAQ,CAAC,GAAG,EAAE,CAAC,QAAQ,CAAC,KAAK,CAAC,KAAK,CAAC,CAAC;AAClD,CAAC;AAED;;GAEG;AACH,MAAM,CAAC,KAAK,UAAU,UAAU,CAC9B,IAAU,EACV,GAAW;IAEX,MAAM,IAAI,CAAC,IAAI,CAAC,GAAG,EAAE,EAAE,SAAS,EAAE,aAAa,EAAE,CAAC,CAAC;IAEnD,qDAAqD;IACrD,MAAM,IAAI,CAAC,eAAe,CACxB,GAAG,EAAE;QACH,MAAM,GAAG,GAAG,MAEX,CAAC;QACF,OAAO,OAAO,GAAG,CAAC,cAAc,KAAK,UAAU,CAAC;IAClD,CAAC,EACD,EAAE,OAAO,EAAE,MAAM,EAAE,CACpB,CAAC;AACJ,CAAC"}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import type { AudioMuxOptions } from "./types.js";
|
|
2
|
+
/**
|
|
3
|
+
* Mux audio tracks into a video file using FFmpeg.
|
|
4
|
+
* If multiple audio files are provided, they are mixed together.
|
|
5
|
+
*/
|
|
6
|
+
export declare function stitchAudio(options: AudioMuxOptions): Promise<string>;
|
|
7
|
+
//# sourceMappingURL=stitch-audio.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"stitch-audio.d.ts","sourceRoot":"","sources":["../src/stitch-audio.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,eAAe,EAAE,MAAM,YAAY,CAAC;AAElD;;;GAGG;AACH,wBAAsB,WAAW,CAAC,OAAO,EAAE,eAAe,GAAG,OAAO,CAAC,MAAM,CAAC,CA6C3E"}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
/**
|
|
3
|
+
* Mux audio tracks into a video file using FFmpeg.
|
|
4
|
+
* If multiple audio files are provided, they are mixed together.
|
|
5
|
+
*/
|
|
6
|
+
export async function stitchAudio(options) {
|
|
7
|
+
const { videoPath, audioPaths, outputPath } = options;
|
|
8
|
+
if (audioPaths.length === 0) {
|
|
9
|
+
// No audio to mux — just copy the video
|
|
10
|
+
return videoPath;
|
|
11
|
+
}
|
|
12
|
+
const args = ["-y"];
|
|
13
|
+
// Input: video
|
|
14
|
+
args.push("-i", videoPath);
|
|
15
|
+
// Input: each audio track
|
|
16
|
+
for (const audioPath of audioPaths) {
|
|
17
|
+
args.push("-i", audioPath);
|
|
18
|
+
}
|
|
19
|
+
if (audioPaths.length === 1) {
|
|
20
|
+
// Simple case: one audio track, just mux
|
|
21
|
+
args.push("-c:v", "copy", "-c:a", "aac", "-b:a", "192k", "-shortest", outputPath);
|
|
22
|
+
}
|
|
23
|
+
else {
|
|
24
|
+
// Multiple audio tracks: use amix filter to mix them
|
|
25
|
+
const filterInputs = audioPaths.map((_, i) => `[${i + 1}:a]`).join("");
|
|
26
|
+
const filter = `${filterInputs}amix=inputs=${audioPaths.length}:duration=longest:dropout_transition=0[aout]`;
|
|
27
|
+
args.push("-filter_complex", filter, "-map", "0:v", "-map", "[aout]", "-c:v", "copy", "-c:a", "aac", "-b:a", "192k", "-shortest", outputPath);
|
|
28
|
+
}
|
|
29
|
+
return runFfmpeg(args);
|
|
30
|
+
}
|
|
31
|
+
function runFfmpeg(args) {
|
|
32
|
+
return new Promise((resolve, reject) => {
|
|
33
|
+
const proc = spawn("ffmpeg", args, {
|
|
34
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
35
|
+
});
|
|
36
|
+
let stderr = "";
|
|
37
|
+
proc.stderr.on("data", (data) => {
|
|
38
|
+
stderr += data.toString();
|
|
39
|
+
});
|
|
40
|
+
proc.on("close", (code) => {
|
|
41
|
+
if (code === 0) {
|
|
42
|
+
resolve(args[args.length - 1]);
|
|
43
|
+
}
|
|
44
|
+
else {
|
|
45
|
+
reject(new Error(`FFmpeg exited with code ${code}:\n${stderr}`));
|
|
46
|
+
}
|
|
47
|
+
});
|
|
48
|
+
proc.on("error", (err) => {
|
|
49
|
+
reject(new Error(`Failed to spawn FFmpeg: ${err.message}`));
|
|
50
|
+
});
|
|
51
|
+
});
|
|
52
|
+
}
|
|
53
|
+
//# sourceMappingURL=stitch-audio.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"stitch-audio.js","sourceRoot":"","sources":["../src/stitch-audio.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,KAAK,EAAE,MAAM,oBAAoB,CAAC;AAG3C;;;GAGG;AACH,MAAM,CAAC,KAAK,UAAU,WAAW,CAAC,OAAwB;IACxD,MAAM,EAAE,SAAS,EAAE,UAAU,EAAE,UAAU,EAAE,GAAG,OAAO,CAAC;IAEtD,IAAI,UAAU,CAAC,MAAM,KAAK,CAAC,EAAE,CAAC;QAC5B,wCAAwC;QACxC,OAAO,SAAS,CAAC;IACnB,CAAC;IAED,MAAM,IAAI,GAAa,CAAC,IAAI,CAAC,CAAC;IAE9B,eAAe;IACf,IAAI,CAAC,IAAI,CAAC,IAAI,EAAE,SAAS,CAAC,CAAC;IAE3B,0BAA0B;IAC1B,KAAK,MAAM,SAAS,IAAI,UAAU,EAAE,CAAC;QACnC,IAAI,CAAC,IAAI,CAAC,IAAI,EAAE,SAAS,CAAC,CAAC;IAC7B,CAAC;IAED,IAAI,UAAU,CAAC,MAAM,KAAK,CAAC,EAAE,CAAC;QAC5B,yCAAyC;QACzC,IAAI,CAAC,IAAI,CACP,MAAM,EAAE,MAAM,EACd,MAAM,EAAE,KAAK,EACb,MAAM,EAAE,MAAM,EACd,WAAW,EACX,UAAU,CACX,CAAC;IACJ,CAAC;SAAM,CAAC;QACN,qDAAqD;QACrD,MAAM,YAAY,GAAG,UAAU,CAAC,GAAG,CAAC,CAAC,CAAC,EAAE,CAAC,EAAE,EAAE,CAAC,IAAI,CAAC,GAAG,CAAC,KAAK,CAAC,CAAC,IAAI,CAAC,EAAE,CAAC,CAAC;QACvE,MAAM,MAAM,GAAG,GAAG,YAAY,eAAe,UAAU,CAAC,MAAM,8CAA8C,CAAC;QAE7G,IAAI,CAAC,IAAI,CACP,iBAAiB,EAAE,MAAM,EACzB,MAAM,EAAE,KAAK,EACb,MAAM,EAAE,QAAQ,EAChB,MAAM,EAAE,MAAM,EACd,MAAM,EAAE,KAAK,EACb,MAAM,EAAE,MAAM,EACd,WAAW,EACX,UAAU,CACX,CAAC;IACJ,CAAC;IAED,OAAO,SAAS,CAAC,IAAI,CAAC,CAAC;AACzB,CAAC;AAED,SAAS,SAAS,CAAC,IAAc;IAC/B,OAAO,IAAI,OAAO,CAAC,CAAC,OAAO,EAAE,MAAM,EAAE,EAAE;QACrC,MAAM,IAAI,GAAG,KAAK,CAAC,QAAQ,EAAE,IAAI,EAAE;YACjC,KAAK,EAAE,CAAC,QAAQ,EAAE,MAAM,EAAE,MAAM,CAAC;SAClC,CAAC,CAAC;QAEH,IAAI,MAAM,GAAG,EAAE,CAAC;QAEhB,IAAI,CAAC,MAAM,CAAC,EAAE,CAAC,MAAM,EAAE,CAAC,IAAY,EAAE,EAAE;YACtC,MAAM,IAAI,IAAI,CAAC,QAAQ,EAAE,CAAC;QAC5B,CAAC,CAAC,CAAC;QAEH,IAAI,CAAC,EAAE,CAAC,OAAO,EAAE,CAAC,IAAI,EAAE,EAAE;YACxB,IAAI,IAAI,KAAK,CAAC,EAAE,CAAC;gBACf,OAAO,CAAC,IAAI,CAAC,IAAI,CAAC,MAAM,GAAG,CAAC,CAAE,CAAC,CAAC;YAClC,CAAC;iBAAM,CAAC;gBACN,MAAM,CAAC,IAAI,KAAK,CAAC,2BAA2B,IAAI,MAAM,MAAM,EAAE,CAAC,CAAC,CAAC;YACnE,CAAC;QACH,CAAC,CAAC,CAAC;QAEH,IAAI,CAAC,EAAE,CAAC,OAAO,EAAE,CAAC,GAAG,EAAE,EAAE;YACvB,MAAM,CAAC,IAAI,KAAK,CAAC,2BAA2B,GAAG,CAAC,OAAO,EAAE,CAAC,CAAC,CAAC;QAC9D,CAAC,CAAC,CAAC;IACL,CAAC,CAAC,CAAC;AACL,CAAC"}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import type { StitchOptions } from "./types.js";
|
|
2
|
+
/**
|
|
3
|
+
* Get the output container format for the given codec.
|
|
4
|
+
*/
|
|
5
|
+
declare function getContainerExt(codec: string): string;
|
|
6
|
+
/**
|
|
7
|
+
* Stitch a sequence of frame images into a video using FFmpeg.
|
|
8
|
+
*/
|
|
9
|
+
export declare function stitchFrames(options: StitchOptions): Promise<string>;
|
|
10
|
+
export { getContainerExt };
|
|
11
|
+
//# sourceMappingURL=stitch-frames.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"stitch-frames.d.ts","sourceRoot":"","sources":["../src/stitch-frames.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,YAAY,CAAC;AAoBhD;;GAEG;AACH,iBAAS,eAAe,CAAC,KAAK,EAAE,MAAM,GAAG,MAAM,CAS9C;AAED;;GAEG;AACH,wBAAsB,YAAY,CAAC,OAAO,EAAE,aAAa,GAAG,OAAO,CAAC,MAAM,CAAC,CAwB1E;AAgCD,OAAO,EAAE,eAAe,EAAE,CAAC"}
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
/**
|
|
3
|
+
* Get the FFmpeg codec arguments for the given codec.
|
|
4
|
+
*/
|
|
5
|
+
function getCodecArgs(codec, quality) {
|
|
6
|
+
switch (codec) {
|
|
7
|
+
case "h264":
|
|
8
|
+
return ["-c:v", "libx264", "-crf", String(quality), "-preset", "fast", "-pix_fmt", "yuv420p"];
|
|
9
|
+
case "h265":
|
|
10
|
+
return ["-c:v", "libx265", "-crf", String(quality), "-preset", "fast", "-pix_fmt", "yuv420p"];
|
|
11
|
+
case "vp9":
|
|
12
|
+
return ["-c:v", "libvpx-vp9", "-crf", String(quality), "-b:v", "0", "-pix_fmt", "yuv420p"];
|
|
13
|
+
case "prores":
|
|
14
|
+
return ["-c:v", "prores_ks", "-profile:v", String(Math.min(quality, 5)), "-pix_fmt", "yuva444p10le"];
|
|
15
|
+
default:
|
|
16
|
+
throw new Error(`Unsupported codec: ${codec}`);
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
/**
|
|
20
|
+
* Get the output container format for the given codec.
|
|
21
|
+
*/
|
|
22
|
+
function getContainerExt(codec) {
|
|
23
|
+
switch (codec) {
|
|
24
|
+
case "vp9":
|
|
25
|
+
return "webm";
|
|
26
|
+
case "prores":
|
|
27
|
+
return "mov";
|
|
28
|
+
default:
|
|
29
|
+
return "mp4";
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* Stitch a sequence of frame images into a video using FFmpeg.
|
|
34
|
+
*/
|
|
35
|
+
export async function stitchFrames(options) {
|
|
36
|
+
const { framesDir, outputPath, fps, codec, imageFormat, quality, } = options;
|
|
37
|
+
const ext = imageFormat === "jpeg" ? "jpg" : "png";
|
|
38
|
+
const inputPattern = `${framesDir}/frame-%06d.${ext}`;
|
|
39
|
+
const codecArgs = getCodecArgs(codec, quality);
|
|
40
|
+
const args = [
|
|
41
|
+
"-y",
|
|
42
|
+
"-framerate", String(fps),
|
|
43
|
+
"-i", inputPattern,
|
|
44
|
+
...codecArgs,
|
|
45
|
+
"-r", String(fps),
|
|
46
|
+
outputPath,
|
|
47
|
+
];
|
|
48
|
+
return runFfmpeg(args);
|
|
49
|
+
}
|
|
50
|
+
/**
|
|
51
|
+
* Run an FFmpeg command and return the output path.
|
|
52
|
+
*/
|
|
53
|
+
function runFfmpeg(args) {
|
|
54
|
+
return new Promise((resolve, reject) => {
|
|
55
|
+
const proc = spawn("ffmpeg", args, {
|
|
56
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
57
|
+
});
|
|
58
|
+
let stderr = "";
|
|
59
|
+
proc.stderr.on("data", (data) => {
|
|
60
|
+
stderr += data.toString();
|
|
61
|
+
});
|
|
62
|
+
proc.on("close", (code) => {
|
|
63
|
+
if (code === 0) {
|
|
64
|
+
// The output path is the last argument
|
|
65
|
+
resolve(args[args.length - 1]);
|
|
66
|
+
}
|
|
67
|
+
else {
|
|
68
|
+
reject(new Error(`FFmpeg exited with code ${code}:\n${stderr}`));
|
|
69
|
+
}
|
|
70
|
+
});
|
|
71
|
+
proc.on("error", (err) => {
|
|
72
|
+
reject(new Error(`Failed to spawn FFmpeg: ${err.message}`));
|
|
73
|
+
});
|
|
74
|
+
});
|
|
75
|
+
}
|
|
76
|
+
export { getContainerExt };
|
|
77
|
+
//# sourceMappingURL=stitch-frames.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"stitch-frames.js","sourceRoot":"","sources":["../src/stitch-frames.ts"],"names":[],"mappings":"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"}
|
package/dist/types.d.ts
ADDED
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
export type Codec = "h264" | "h265" | "vp9" | "prores";
|
|
2
|
+
export type ImageFormat = "png" | "jpeg";
|
|
3
|
+
export type FrameRange = [startFrame: number, endFrame: number];
|
|
4
|
+
export interface RenderConfig {
|
|
5
|
+
/** Path to the entry file containing compositions */
|
|
6
|
+
entry: string;
|
|
7
|
+
/** Composition ID to render */
|
|
8
|
+
compositionId: string;
|
|
9
|
+
/** Output file path */
|
|
10
|
+
outputPath: string;
|
|
11
|
+
/** Video codec */
|
|
12
|
+
codec: Codec;
|
|
13
|
+
/** Image format for intermediate frames */
|
|
14
|
+
imageFormat: ImageFormat;
|
|
15
|
+
/** JPEG quality (0-100), only used when imageFormat is "jpeg" */
|
|
16
|
+
quality: number;
|
|
17
|
+
/** Frames per second override (uses composition fps if not set) */
|
|
18
|
+
fps: number | null;
|
|
19
|
+
/** Frame range to render [start, end] */
|
|
20
|
+
frameRange: FrameRange | null;
|
|
21
|
+
/** Number of parallel browser tabs */
|
|
22
|
+
concurrency: number;
|
|
23
|
+
/** Pixel format for ffmpeg */
|
|
24
|
+
pixelFormat: string;
|
|
25
|
+
/** Progress callback */
|
|
26
|
+
onProgress?: (progress: RenderProgress) => void;
|
|
27
|
+
}
|
|
28
|
+
export interface RenderProgress {
|
|
29
|
+
/** Number of frames rendered so far */
|
|
30
|
+
framesRendered: number;
|
|
31
|
+
/** Total frames to render */
|
|
32
|
+
totalFrames: number;
|
|
33
|
+
/** Progress as a fraction 0-1 */
|
|
34
|
+
percent: number;
|
|
35
|
+
/** Estimated time remaining in milliseconds */
|
|
36
|
+
etaMs: number | null;
|
|
37
|
+
}
|
|
38
|
+
export interface StitchOptions {
|
|
39
|
+
/** Directory containing frame images */
|
|
40
|
+
framesDir: string;
|
|
41
|
+
/** Output video file path */
|
|
42
|
+
outputPath: string;
|
|
43
|
+
/** Frames per second */
|
|
44
|
+
fps: number;
|
|
45
|
+
/** Video codec */
|
|
46
|
+
codec: Codec;
|
|
47
|
+
/** Image format of the frames */
|
|
48
|
+
imageFormat: ImageFormat;
|
|
49
|
+
/** Pixel format */
|
|
50
|
+
pixelFormat: string;
|
|
51
|
+
/** Quality (crf for h264/h265/vp9, profile for prores) */
|
|
52
|
+
quality: number;
|
|
53
|
+
/** Width of the video */
|
|
54
|
+
width: number;
|
|
55
|
+
/** Height of the video */
|
|
56
|
+
height: number;
|
|
57
|
+
}
|
|
58
|
+
export interface AudioMuxOptions {
|
|
59
|
+
/** Video file to mux audio into */
|
|
60
|
+
videoPath: string;
|
|
61
|
+
/** Audio file paths */
|
|
62
|
+
audioPaths: string[];
|
|
63
|
+
/** Output file path */
|
|
64
|
+
outputPath: string;
|
|
65
|
+
}
|
|
66
|
+
export interface BundleResult {
|
|
67
|
+
/** Directory containing the bundled output */
|
|
68
|
+
outDir: string;
|
|
69
|
+
/** URL to access the bundled app */
|
|
70
|
+
url: string;
|
|
71
|
+
/** Cleanup function to stop the server and remove temp files */
|
|
72
|
+
cleanup: () => Promise<void>;
|
|
73
|
+
}
|
|
74
|
+
//# sourceMappingURL=types.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../src/types.ts"],"names":[],"mappings":"AAAA,MAAM,MAAM,KAAK,GAAG,MAAM,GAAG,MAAM,GAAG,KAAK,GAAG,QAAQ,CAAC;AAEvD,MAAM,MAAM,WAAW,GAAG,KAAK,GAAG,MAAM,CAAC;AAEzC,MAAM,MAAM,UAAU,GAAG,CAAC,UAAU,EAAE,MAAM,EAAE,QAAQ,EAAE,MAAM,CAAC,CAAC;AAEhE,MAAM,WAAW,YAAY;IAC3B,qDAAqD;IACrD,KAAK,EAAE,MAAM,CAAC;IACd,+BAA+B;IAC/B,aAAa,EAAE,MAAM,CAAC;IACtB,uBAAuB;IACvB,UAAU,EAAE,MAAM,CAAC;IACnB,kBAAkB;IAClB,KAAK,EAAE,KAAK,CAAC;IACb,2CAA2C;IAC3C,WAAW,EAAE,WAAW,CAAC;IACzB,iEAAiE;IACjE,OAAO,EAAE,MAAM,CAAC;IAChB,mEAAmE;IACnE,GAAG,EAAE,MAAM,GAAG,IAAI,CAAC;IACnB,yCAAyC;IACzC,UAAU,EAAE,UAAU,GAAG,IAAI,CAAC;IAC9B,sCAAsC;IACtC,WAAW,EAAE,MAAM,CAAC;IACpB,8BAA8B;IAC9B,WAAW,EAAE,MAAM,CAAC;IACpB,wBAAwB;IACxB,UAAU,CAAC,EAAE,CAAC,QAAQ,EAAE,cAAc,KAAK,IAAI,CAAC;CACjD;AAED,MAAM,WAAW,cAAc;IAC7B,uCAAuC;IACvC,cAAc,EAAE,MAAM,CAAC;IACvB,6BAA6B;IAC7B,WAAW,EAAE,MAAM,CAAC;IACpB,iCAAiC;IACjC,OAAO,EAAE,MAAM,CAAC;IAChB,+CAA+C;IAC/C,KAAK,EAAE,MAAM,GAAG,IAAI,CAAC;CACtB;AAED,MAAM,WAAW,aAAa;IAC5B,wCAAwC;IACxC,SAAS,EAAE,MAAM,CAAC;IAClB,6BAA6B;IAC7B,UAAU,EAAE,MAAM,CAAC;IACnB,wBAAwB;IACxB,GAAG,EAAE,MAAM,CAAC;IACZ,kBAAkB;IAClB,KAAK,EAAE,KAAK,CAAC;IACb,iCAAiC;IACjC,WAAW,EAAE,WAAW,CAAC;IACzB,mBAAmB;IACnB,WAAW,EAAE,MAAM,CAAC;IACpB,0DAA0D;IAC1D,OAAO,EAAE,MAAM,CAAC;IAChB,yBAAyB;IACzB,KAAK,EAAE,MAAM,CAAC;IACd,0BAA0B;IAC1B,MAAM,EAAE,MAAM,CAAC;CAChB;AAED,MAAM,WAAW,eAAe;IAC9B,mCAAmC;IACnC,SAAS,EAAE,MAAM,CAAC;IAClB,uBAAuB;IACvB,UAAU,EAAE,MAAM,EAAE,CAAC;IACrB,uBAAuB;IACvB,UAAU,EAAE,MAAM,CAAC;CACpB;AAED,MAAM,WAAW,YAAY;IAC3B,8CAA8C;IAC9C,MAAM,EAAE,MAAM,CAAC;IACf,oCAAoC;IACpC,GAAG,EAAE,MAAM,CAAC;IACZ,gEAAgE;IAChE,OAAO,EAAE,MAAM,OAAO,CAAC,IAAI,CAAC,CAAC;CAC9B"}
|
package/dist/types.js
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"types.js","sourceRoot":"","sources":["../src/types.ts"],"names":[],"mappings":""}
|
package/package.json
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@vibeo/renderer",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"main": "./dist/index.js",
|
|
6
|
+
"types": "./dist/index.d.ts",
|
|
7
|
+
"exports": {
|
|
8
|
+
".": {
|
|
9
|
+
"import": "./dist/index.js",
|
|
10
|
+
"types": "./dist/index.d.ts"
|
|
11
|
+
}
|
|
12
|
+
},
|
|
13
|
+
"dependencies": {
|
|
14
|
+
"@vibeo/core": "0.1.0",
|
|
15
|
+
"@vibeo/player": "0.1.0",
|
|
16
|
+
"@vibeo/audio": "0.1.0",
|
|
17
|
+
"playwright": "^1.52.0"
|
|
18
|
+
},
|
|
19
|
+
"files": [
|
|
20
|
+
"dist",
|
|
21
|
+
"src"
|
|
22
|
+
],
|
|
23
|
+
"scripts": {
|
|
24
|
+
"build": "tsc -p tsconfig.build.json",
|
|
25
|
+
"clean": "rm -rf dist"
|
|
26
|
+
},
|
|
27
|
+
"repository": {
|
|
28
|
+
"type": "git",
|
|
29
|
+
"url": "https://github.com/patcito/vibeo.git",
|
|
30
|
+
"directory": "packages/renderer"
|
|
31
|
+
},
|
|
32
|
+
"license": "MIT"
|
|
33
|
+
}
|
package/src/browser.ts
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import type { Browser, Page } from "playwright";
|
|
2
|
+
|
|
3
|
+
let _browser: Browser | null = null;
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Launch a headless Chromium browser via Playwright.
|
|
7
|
+
* Reuses the existing browser if already launched.
|
|
8
|
+
*/
|
|
9
|
+
export async function launchBrowser(): Promise<Browser> {
|
|
10
|
+
if (_browser && _browser.isConnected()) {
|
|
11
|
+
return _browser;
|
|
12
|
+
}
|
|
13
|
+
const { chromium } = await import("playwright");
|
|
14
|
+
_browser = await chromium.launch({
|
|
15
|
+
headless: true,
|
|
16
|
+
args: [
|
|
17
|
+
"--disable-web-security",
|
|
18
|
+
"--allow-file-access-from-files",
|
|
19
|
+
"--autoplay-policy=no-user-gesture-required",
|
|
20
|
+
],
|
|
21
|
+
});
|
|
22
|
+
return _browser;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Close the browser and release resources.
|
|
27
|
+
*/
|
|
28
|
+
export async function closeBrowser(): Promise<void> {
|
|
29
|
+
if (_browser) {
|
|
30
|
+
await _browser.close();
|
|
31
|
+
_browser = null;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Create a new browser page with a viewport matching the composition dimensions.
|
|
37
|
+
*/
|
|
38
|
+
export async function createPage(
|
|
39
|
+
browser: Browser,
|
|
40
|
+
width: number,
|
|
41
|
+
height: number,
|
|
42
|
+
): Promise<Page> {
|
|
43
|
+
const page = await browser.newPage({
|
|
44
|
+
viewport: { width, height },
|
|
45
|
+
deviceScaleFactor: 1,
|
|
46
|
+
});
|
|
47
|
+
return page;
|
|
48
|
+
}
|
package/src/bundler.ts
ADDED
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
import { mkdtemp, rm } from "node:fs/promises";
|
|
2
|
+
import { join, dirname } from "node:path";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import type { BundleResult } from "./types.js";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Bundle the user's React entry point using Bun.build,
|
|
8
|
+
* then serve it via a local HTTP server for the headless browser to load.
|
|
9
|
+
*
|
|
10
|
+
* Generates a thin bootstrap entry that imports the user's Root export
|
|
11
|
+
* and mounts it with ReactDOM.createRoot.
|
|
12
|
+
*/
|
|
13
|
+
export async function bundle(entryPoint: string): Promise<BundleResult> {
|
|
14
|
+
const outDir = await mkdtemp(join(tmpdir(), "vibeo-bundle-"));
|
|
15
|
+
|
|
16
|
+
// Create a bootstrap entry next to the user's file so Bun.build
|
|
17
|
+
// resolves node_modules from the project root, not from /tmp.
|
|
18
|
+
const entryDir = dirname(entryPoint);
|
|
19
|
+
const bootstrapPath = join(entryDir, "__vibeo_entry.tsx");
|
|
20
|
+
await Bun.write(
|
|
21
|
+
bootstrapPath,
|
|
22
|
+
`import React from "react";
|
|
23
|
+
import { createRoot } from "react-dom/client";
|
|
24
|
+
import { Root } from ${JSON.stringify(entryPoint)};
|
|
25
|
+
import { Player } from "@vibeo/player";
|
|
26
|
+
import { VibeoRoot, useCompositionContext } from "@vibeo/core";
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* PreviewShell: renders <Root> to register compositions,
|
|
30
|
+
* then picks the first one and renders it inside a <Player>.
|
|
31
|
+
*/
|
|
32
|
+
function CompositionPlayer() {
|
|
33
|
+
const { compositions } = useCompositionContext();
|
|
34
|
+
|
|
35
|
+
// Derive comp directly during render — no useEffect delay
|
|
36
|
+
const hash = window.location.hash.slice(1);
|
|
37
|
+
let comp: any = null;
|
|
38
|
+
if (hash && compositions.has(hash)) {
|
|
39
|
+
comp = compositions.get(hash);
|
|
40
|
+
} else if (compositions.size > 0) {
|
|
41
|
+
// Get first value from map
|
|
42
|
+
for (const v of compositions.values()) { comp = v; break; }
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
if (!comp) {
|
|
46
|
+
return React.createElement("div", {
|
|
47
|
+
style: { color: "#888", fontFamily: "sans-serif", padding: 40 }
|
|
48
|
+
}, "Loading composition...");
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
return React.createElement(Player, {
|
|
52
|
+
component: comp.component,
|
|
53
|
+
durationInFrames: comp.durationInFrames,
|
|
54
|
+
compositionWidth: comp.width,
|
|
55
|
+
compositionHeight: comp.height,
|
|
56
|
+
fps: comp.fps,
|
|
57
|
+
controls: true,
|
|
58
|
+
loop: true,
|
|
59
|
+
autoPlay: true,
|
|
60
|
+
style: { width: "100vw", height: "100vh" },
|
|
61
|
+
inputProps: comp.defaultProps,
|
|
62
|
+
});
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function PreviewApp() {
|
|
66
|
+
return React.createElement(VibeoRoot, null,
|
|
67
|
+
React.createElement(Root),
|
|
68
|
+
React.createElement(CompositionPlayer),
|
|
69
|
+
);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const container = document.getElementById("root")!;
|
|
73
|
+
const root = createRoot(container);
|
|
74
|
+
root.render(React.createElement(PreviewApp));
|
|
75
|
+
|
|
76
|
+
window.vibeo_ready = true;
|
|
77
|
+
`,
|
|
78
|
+
);
|
|
79
|
+
|
|
80
|
+
try {
|
|
81
|
+
const result = await Bun.build({
|
|
82
|
+
entrypoints: [bootstrapPath],
|
|
83
|
+
outdir: outDir,
|
|
84
|
+
target: "browser",
|
|
85
|
+
format: "esm",
|
|
86
|
+
minify: false,
|
|
87
|
+
splitting: false,
|
|
88
|
+
define: {
|
|
89
|
+
"process.env.NODE_ENV": JSON.stringify("production"),
|
|
90
|
+
},
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
if (!result.success) {
|
|
94
|
+
const messages = result.logs.map((l) => l.message).join("\n");
|
|
95
|
+
throw new Error(`Bundle failed:\n${messages}`);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// Write a minimal HTML shell that loads the bundle
|
|
99
|
+
const bundleName = result.outputs[0]?.path.split("/").pop() ?? "__vibeo_entry.js";
|
|
100
|
+
const html = `<!DOCTYPE html>
|
|
101
|
+
<html>
|
|
102
|
+
<head>
|
|
103
|
+
<meta charset="utf-8" />
|
|
104
|
+
<style>
|
|
105
|
+
* { margin: 0; padding: 0; box-sizing: border-box; }
|
|
106
|
+
html, body, #root { width: 100%; height: 100%; overflow: hidden; }
|
|
107
|
+
</style>
|
|
108
|
+
</head>
|
|
109
|
+
<body>
|
|
110
|
+
<div id="root"></div>
|
|
111
|
+
<script type="module" src="/${bundleName}"></script>
|
|
112
|
+
</body>
|
|
113
|
+
</html>`;
|
|
114
|
+
|
|
115
|
+
await Bun.write(join(outDir, "index.html"), html);
|
|
116
|
+
|
|
117
|
+
// Start a local HTTP server to serve the bundle
|
|
118
|
+
const server = Bun.serve({
|
|
119
|
+
port: 0, // auto-assign
|
|
120
|
+
async fetch(req) {
|
|
121
|
+
const url = new URL(req.url);
|
|
122
|
+
const filePath = join(outDir, url.pathname === "/" ? "index.html" : url.pathname);
|
|
123
|
+
const file = Bun.file(filePath);
|
|
124
|
+
if (await file.exists()) {
|
|
125
|
+
return new Response(file);
|
|
126
|
+
}
|
|
127
|
+
return new Response("Not found", { status: 404 });
|
|
128
|
+
},
|
|
129
|
+
});
|
|
130
|
+
const serverPort = server.port ?? 0;
|
|
131
|
+
|
|
132
|
+
const cleanup = async () => {
|
|
133
|
+
server.stop(true);
|
|
134
|
+
await rm(outDir, { recursive: true, force: true });
|
|
135
|
+
};
|
|
136
|
+
|
|
137
|
+
return {
|
|
138
|
+
outDir,
|
|
139
|
+
url: `http://localhost:${serverPort}`,
|
|
140
|
+
cleanup,
|
|
141
|
+
};
|
|
142
|
+
} finally {
|
|
143
|
+
// Always clean up the bootstrap file we dropped next to the user's entry
|
|
144
|
+
await rm(bootstrapPath, { force: true });
|
|
145
|
+
}
|
|
146
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { join } from "node:path";
|
|
2
|
+
import type { Page } from "playwright";
|
|
3
|
+
import type { ImageFormat } from "./types.js";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Capture a screenshot of the current page state and write it to disk.
|
|
7
|
+
*
|
|
8
|
+
* @returns The file path of the saved frame.
|
|
9
|
+
*/
|
|
10
|
+
export async function captureFrame(
|
|
11
|
+
page: Page,
|
|
12
|
+
outputDir: string,
|
|
13
|
+
frameNumber: number,
|
|
14
|
+
options: {
|
|
15
|
+
imageFormat: ImageFormat;
|
|
16
|
+
quality?: number;
|
|
17
|
+
},
|
|
18
|
+
): Promise<string> {
|
|
19
|
+
const ext = options.imageFormat === "jpeg" ? "jpg" : "png";
|
|
20
|
+
const paddedFrame = String(frameNumber).padStart(6, "0");
|
|
21
|
+
const filePath = join(outputDir, `frame-${paddedFrame}.${ext}`);
|
|
22
|
+
|
|
23
|
+
const screenshotOptions: {
|
|
24
|
+
type: "png" | "jpeg";
|
|
25
|
+
path: string;
|
|
26
|
+
quality?: number;
|
|
27
|
+
} = {
|
|
28
|
+
type: options.imageFormat,
|
|
29
|
+
path: filePath,
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
if (options.imageFormat === "jpeg" && options.quality !== undefined) {
|
|
33
|
+
screenshotOptions.quality = options.quality;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
await page.screenshot(screenshotOptions);
|
|
37
|
+
|
|
38
|
+
return filePath;
|
|
39
|
+
}
|