diff --git a/demos/1-videoconferencing/index.ts b/demos/1-videoconferencing/index.ts deleted file mode 100644 index 7070c9b63..000000000 --- a/demos/1-videoconferencing/index.ts +++ /dev/null @@ -1,169 +0,0 @@ -import { runCompositorExample } from '../utils/run'; -import { gstStreamWebcam } from '../utils/gst'; -import { downloadAsync, sleepAsync } from '../utils/utils'; -import { Component, Resolution } from '../types/api.d'; -import { ffmpegSendVideoFromMp4, ffplayStartPlayerAsync } from '../utils/ffmpeg'; -import path from 'path'; -import { - registerImageAsync, - registerInputAsync, - registerOutputAsync, - startAsync, - updateOutputAsync, -} from '../utils/api'; - -const OUTPUT_RESOLUTION: Resolution = { - width: 1920, - height: 1080, -}; - -const INPUT_PORT = 8000; -const OUTPUT_PORT = 8002; -const IP = '127.0.0.1'; -const DISPLAY_LOGS = true; - -const BACKGROUND_URL = - 'https://raw.githubusercontent.com/membraneframework-labs/video_compositor_snapshot_tests/main/demo_assets/triangles_background.png'; -const CALL_URL = - 'https://raw.githubusercontent.com/membraneframework-labs/video_compositor_snapshot_tests/main/demo_assets/call.mp4'; - -async function exampleAsync() { - const useWebCam = process.env.LIVE_COMPOSITOR_WEBCAM !== 'false'; - await ffplayStartPlayerAsync(IP, DISPLAY_LOGS, OUTPUT_PORT); - - // sleep to make sure ffplay have a chance to start before compositor starts sending packets - await sleepAsync(2000); - - await registerImageAsync('background', { - asset_type: 'png', - url: BACKGROUND_URL, - }); - - await registerInputAsync('input_1', { - type: 'rtp_stream', - transport_protocol: useWebCam ? 'tcp_server' : 'udp', - port: INPUT_PORT, - video: { - decoder: 'ffmpeg_h264', - }, - }); - - await registerOutputAsync('output_1', { - type: 'rtp_stream', - ip: IP, - port: OUTPUT_PORT, - video: { - resolution: OUTPUT_RESOLUTION, - encoder: { - type: 'ffmpeg_h264', - preset: 'medium', - }, - initial: { - root: sceneWithInputs(1), - }, - }, - }); - - if (useWebCam) { - void gstStreamWebcam(IP, INPUT_PORT, DISPLAY_LOGS); - } else { - const callPath = path.join(__dirname, '../assets/call.mp4'); - await downloadAsync(CALL_URL, callPath); - void ffmpegSendVideoFromMp4(INPUT_PORT, callPath, DISPLAY_LOGS); - } - await startAsync(); - - const inputs = [ - 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 13, 12, 11, 10, 9, 8, 7, 6, 5, 4, 3, 2, 1, - ]; - - for (let i = 0; i < inputs.length; i++) { - await updateOutputAsync('output_1', { - video: { - root: sceneWithInputs(inputs[i]), - }, - schedule_time_ms: 2000 * (i + 1), - }); - } -} - -function sceneWithInputs(n: number): Component { - const children: Array = Array.from({ length: n }, (_, i) => { - const text: Component = { - type: 'text', - text: `InputStream ${i} 🚀`, - font_size: 25, - align: 'center', - color_rgba: '#FFFFFFFF', - background_color_rgba: '#FF0000FF', - font_family: 'Arial', - }; - - const inputStreamTile: Component = { - type: 'view', - children: [ - { - type: 'rescaler', - child: { - type: 'input_stream', - input_id: 'input_1', - }, - }, - { - type: 'view', - height: 50, - bottom: 0, - left: 0, - children: [{ type: 'view' }, text, { type: 'view' }], - }, - ], - }; - - return inputStreamTile; - }); - - const tiles: Component = { - type: 'tiles', - id: 'tile', - padding: 5, - children: children, - transition: { - duration_ms: 700, - easing_function: { - function_name: 'cubic_bezier', - points: [0.35, 0.22, 0.1, 0.8], - }, - }, - }; - - const background: Component = { - type: 'image', - image_id: 'background', - }; - - return { - type: 'view', - width: OUTPUT_RESOLUTION.width, - height: OUTPUT_RESOLUTION.height, - children: [ - { - type: 'view', - width: OUTPUT_RESOLUTION.width, - height: OUTPUT_RESOLUTION.height, - top: 0, - left: 0, - children: [background], - }, - { - type: 'view', - width: OUTPUT_RESOLUTION.width, - height: OUTPUT_RESOLUTION.height, - top: 0, - left: 0, - children: [tiles], - }, - ], - }; -} - -void runCompositorExample(exampleAsync, DISPLAY_LOGS); diff --git a/demos/1-videoconferencing/index.tsx b/demos/1-videoconferencing/index.tsx new file mode 100644 index 000000000..cdbbeddb9 --- /dev/null +++ b/demos/1-videoconferencing/index.tsx @@ -0,0 +1,130 @@ +import { gstStartWebcamStream } from '../utils/gst'; +import { downloadAsync } from '../utils/utils'; +import { ffmpegSendVideoFromMp4, ffplayStartPlayerAsync } from '../utils/ffmpeg'; +import path from 'path'; +import LiveCompositor from '@live-compositor/node'; +import { Rescaler, Text, Image, View, InputStream, Tiles } from 'live-compositor'; +import { useEffect, useState } from 'react'; + +const OUTPUT_RESOLUTION = { + width: 1920, + height: 1080, +}; + +const INPUT_PORT = 8002; +const OUTPUT_PORT = 8004; + +const BACKGROUND_URL = + 'https://raw.githubusercontent.com/membraneframework-labs/video_compositor_snapshot_tests/main/demo_assets/triangles_background.png'; +const CALL_URL = + 'https://raw.githubusercontent.com/membraneframework-labs/video_compositor_snapshot_tests/main/demo_assets/call.mp4'; + +/** + * Example is switching between following number of tiles + */ +const inputCountPhases = [ + 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 13, 12, 11, 10, 9, 8, 7, 6, 5, 4, 3, 2, 1, +]; + +function App() { + const [counter, setCounter] = useState(0); + useEffect(() => { + const timeout = setTimeout(() => { + setCounter(counter + 1); + }, 2000); + return () => clearTimeout(timeout); + }, [counter]); + return ; +} + +function CallWithMockedInputs({ inputCount }: { inputCount: number }) { + const tiles = [...Array(inputCount)].map((_value, index) => ( + + )); + return ( + + + + + + {tiles} + + + ); +} + +function VideoCallTile({ id }: { id: number }) { + return ( + + + + + + + + InputStream {id} 🚀 + + + + + ); +} + +async function exampleAsync() { + const useWebCam = process.env.LIVE_COMPOSITOR_WEBCAM !== 'false'; + const compositor = new LiveCompositor(); + await compositor.init(); + + await compositor.registerImage('background', { + assetType: 'png', + url: BACKGROUND_URL, + }); + + await compositor.registerInput('input_1', { + type: 'rtp_stream', + transportProtocol: useWebCam ? 'tcp_server' : 'udp', + port: INPUT_PORT, + video: { + decoder: 'ffmpeg_h264', + }, + }); + + await ffplayStartPlayerAsync(OUTPUT_PORT); + await compositor.registerOutput('output_1', { + type: 'rtp_stream', + ip: '127.0.0.1', + port: OUTPUT_PORT, + video: { + resolution: OUTPUT_RESOLUTION, + encoder: { + type: 'ffmpeg_h264', + preset: 'ultrafast', + }, + root: , + }, + }); + + if (useWebCam) { + await gstStartWebcamStream(INPUT_PORT); + } else { + const callPath = path.join(__dirname, '../assets/call.mp4'); + await downloadAsync(CALL_URL, callPath); + void ffmpegSendVideoFromMp4(INPUT_PORT, callPath); + } + await compositor.start(); +} + +void exampleAsync(); diff --git a/demos/2-tv_broadcast/index.ts b/demos/2-tv_broadcast/index.ts deleted file mode 100644 index 9a6c6e095..000000000 --- a/demos/2-tv_broadcast/index.ts +++ /dev/null @@ -1,321 +0,0 @@ -import { ffmpegSendVideoFromMp4, ffplayStartPlayerAsync } from '../utils/ffmpeg'; -import { runCompositorExample } from '../utils/run'; -import { downloadAsync, sleepAsync } from '../utils/utils'; -import fs from 'fs-extra'; -import path from 'path'; -import { Component, Resolution } from '../types/api.d'; -import { - registerImageAsync, - registerInputAsync, - registerOutputAsync, - registerShaderAsync, - startAsync, - updateOutputAsync, -} from '../utils/api'; - -const OUTPUT_RESOLUTION: Resolution = { - width: 1920, - height: 1080, -}; - -const INPUT_PORT = 9002; -const VIDEO_OUTPUT_PORT = 9004; -const AUDIO_OUTPUT_PORT = 9006; -const IP = '127.0.0.1'; - -const DISPLAY_LOGS = true; -const BUNNY_PATH = path.join(__dirname, '../assets/bunny.mp4'); -const TV_PATH = path.join(__dirname, '../assets/green_screen_example.mp4'); - -const REPORTER_URL = 'https://assets.mixkit.co/videos/28293/28293-1080.mp4'; -const BUNNY_URL = - 'https://commondatastorage.googleapis.com/gtv-videos-bucket/sample/BigBuckBunny.mp4'; -const BACKGROUND_URL = - 'https://raw.githubusercontent.com/membraneframework-labs/video_compositor_snapshot_tests/main/demo_assets/news_room.jpeg'; -const LOGO_URL = - 'https://raw.githubusercontent.com/membraneframework-labs/video_compositor_snapshot_tests/main/demo_assets/logo.png'; - -async function exampleAsync() { - void ffplayStartPlayerAsync(IP, DISPLAY_LOGS, VIDEO_OUTPUT_PORT, AUDIO_OUTPUT_PORT); - await sleepAsync(2000); - - process.env.LIVE_COMPOSITOR_LOGGER_LEVEL = 'debug'; - await downloadAsync(REPORTER_URL, TV_PATH); - - await downloadAsync(BUNNY_URL, BUNNY_PATH); - - await registerInputAsync('tv_input', { - type: 'rtp_stream', - port: INPUT_PORT, - video: { - decoder: 'ffmpeg_h264', - }, - }); - - await registerInputAsync('bunny', { - type: 'mp4', - path: BUNNY_PATH, - }); - - await registerShaderAsync('remove_green_screen', { - source: await fs.readFile(path.join(__dirname, 'remove_green_screen.wgsl'), 'utf-8'), - }); - - await registerImageAsync('background', { - asset_type: 'jpeg', - url: BACKGROUND_URL, - }); - - await registerImageAsync('logo', { - asset_type: 'png', - url: LOGO_URL, - }); - - await registerOutputAsync('output_video', { - type: 'rtp_stream', - ip: IP, - port: VIDEO_OUTPUT_PORT, - video: { - resolution: OUTPUT_RESOLUTION, - encoder: { - type: 'ffmpeg_h264', - preset: 'ultrafast', - }, - initial: { - root: scene(undefined), - }, - }, - }); - - await registerOutputAsync('output_audio', { - type: 'rtp_stream', - ip: IP, - port: AUDIO_OUTPUT_PORT, - audio: { - encoder: { - channels: 'stereo', - type: 'opus', - }, - initial: { - inputs: [], - }, - }, - }); - - void ffmpegSendVideoFromMp4(INPUT_PORT, TV_PATH, DISPLAY_LOGS); - await startAsync(); - - // First update to set start position of the bunny for transition - await updateOutputAsync('output_video', { - video: { - root: scene(bunnyOutside), - }, - schedule_time_ms: 7_000, - }); - - // Bunny transitions - await updateOutputAsync('output_video', { - video: { - root: scene(bunnyInside), - }, - schedule_time_ms: 7_000, - }); - - await updateOutputAsync('output_video', { - video: { - root: scene(finalBunnyPosition), - }, - schedule_time_ms: 13_000, - }); - - await updateOutputAsync('output_audio', { - audio: { - inputs: [{ input_id: 'bunny' }], - }, - schedule_time_ms: 13_000, - }); -} - -function scene(bunnyProducer?: () => Component): Component { - const components: Component[] = bunnyProducer - ? [news_report(), bunnyProducer(), logo(), breakingNewsText()] - : [news_report(), logo(), breakingNewsText()]; - - return { - type: 'view', - children: components, - }; -} - -function bunnyOutside(): Component { - return { - type: 'view', - id: 'bunny_view', - width: OUTPUT_RESOLUTION.width, - height: OUTPUT_RESOLUTION.height, - top: 0, - left: OUTPUT_RESOLUTION.width, - children: [bunny_input()], - }; -} - -function bunnyInside(): Component { - return { - type: 'view', - id: 'bunny_view', - width: OUTPUT_RESOLUTION.width, - height: OUTPUT_RESOLUTION.height, - top: 0, - left: 0, - children: [bunny_input()], - transition: { - duration_ms: 1000, - easing_function: { - function_name: 'bounce', - }, - }, - }; -} - -function finalBunnyPosition(): Component { - return { - type: 'view', - id: 'bunny_view', - width: OUTPUT_RESOLUTION.width / 4, - height: OUTPUT_RESOLUTION.height / 4, - top: 20, - right: 20, - rotation: 360, - children: [bunny_input()], - transition: { - duration_ms: 1000, - easing_function: { - function_name: 'linear', - }, - }, - }; -} - -function news_report(): Component { - const rescaledInputStream: Component = { - type: 'rescaler', - width: OUTPUT_RESOLUTION.width, - height: OUTPUT_RESOLUTION.height, - child: { - type: 'input_stream', - input_id: 'tv_input', - }, - }; - - const rescaledImage: Component = { - type: 'rescaler', - width: OUTPUT_RESOLUTION.width, - height: OUTPUT_RESOLUTION.height, - child: { - type: 'image', - image_id: 'background', - }, - }; - - return { - type: 'shader', - shader_id: 'remove_green_screen', - children: [rescaledInputStream, rescaledImage], - resolution: OUTPUT_RESOLUTION, - }; -} - -function bunny_input(): Component { - return { - type: 'rescaler', - child: { - type: 'view', - width: 1280, - height: 720, - children: [ - { - type: 'input_stream', - input_id: 'bunny', - }, - ], - }, - }; -} - -function breakingNewsText(): Component { - return { - type: 'view', - width: OUTPUT_RESOLUTION.width, - height: 180, - bottom: 0, - left: 0, - direction: 'column', - children: [ - { - type: 'text', - text: 'BREAKING NEWS', - width: 600, - height: 50, - font_size: 50, - weight: 'bold', - align: 'center', - color_rgba: '#FFFFFFFF', - background_color_rgba: '#FF0000FF', - }, - { - type: 'text', - text: 'LiveCompositor is rumored to allegedly compose video', - font_size: 65, - width: OUTPUT_RESOLUTION.width, - height: 80, - align: 'center', - color_rgba: '#FFFFFFFF', - background_color_rgba: '#808080FF', - }, - { - type: 'view', - width: OUTPUT_RESOLUTION.width, - height: 50, - children: [ - { - type: 'text', - text: '88:29', - font_size: 40, - width: 200, - height: 50, - align: 'center', - color_rgba: '#FFFFFFFF', - background_color_rgba: '#000000FF', - }, - { - type: 'text', - text: 'Leak docs can be found at https://compositor.live/docs/intro', - font_size: 40, - width: OUTPUT_RESOLUTION.width - 200, - height: 50, - align: 'center', - color_rgba: '#000000FF', - background_color_rgba: '#FFFF00FF', - }, - ], - }, - ], - }; -} - -function logo(): Component { - return { - type: 'view', - top: 50, - left: 50, - overflow: 'fit', - children: [ - { - type: 'image', - image_id: 'logo', - }, - ], - }; -} - -void runCompositorExample(exampleAsync, DISPLAY_LOGS); diff --git a/demos/2-tv_broadcast/index.tsx b/demos/2-tv_broadcast/index.tsx new file mode 100644 index 000000000..c01b50277 --- /dev/null +++ b/demos/2-tv_broadcast/index.tsx @@ -0,0 +1,262 @@ +import { ffmpegSendVideoFromMp4 } from '../utils/ffmpeg'; +import { downloadAsync } from '../utils/utils'; +import fs from 'fs-extra'; +import path from 'path'; +import { + View, + Image, + Text, + Rescaler, + Shader, + InputStream, + Transition, + useInputStreams, +} from 'live-compositor'; +import LiveCompositor from '@live-compositor/node'; +import { useEffect, useState } from 'react'; +import { gstStartPlayer } from '../utils/gst'; + +const OUTPUT_RESOLUTION = { + width: 1920, + height: 1080, +}; + +const INPUT_PORT = 9002; +const OUTPUT_PORT = 9004; + +const TV_PATH = path.join(__dirname, '../assets/green_screen_example.mp4'); +const TV_URL = 'https://assets.mixkit.co/videos/28293/28293-1080.mp4'; + +const BUNNY_PATH = path.join(__dirname, '../assets/bunny.mp4'); +const BUNNY_URL = + 'https://commondatastorage.googleapis.com/gtv-videos-bucket/sample/BigBuckBunny.mp4'; + +const BACKGROUND_IMAGE_URL = + 'https://raw.githubusercontent.com/membraneframework-labs/video_compositor_snapshot_tests/main/demo_assets/news_room.jpeg'; +const LOGO_URL = + 'https://raw.githubusercontent.com/membraneframework-labs/video_compositor_snapshot_tests/main/demo_assets/logo.png'; + +const FIRST_TRANSTION = { + durationMs: 1000, + easingFunction: 'bounce', +} as const; + +const SECOND_TRANSTION = { + durationMs: 1000, + easingFunction: 'linear', +} as const; + +function App() { + const inputs = useInputStreams(); + const [started, setStarted] = useState(false); + const [bunnyState, setBunnyState] = useState<'outside' | 'inside' | 'final' | null>(null); + + useEffect(() => { + if (!started && inputs['tv_input']?.videoState === 'playing') { + setStarted(true); + } + }, [started, inputs]); + + useEffect(() => { + if (!started) { + return; + } + const timeout = setTimeout(() => { + if (!bunnyState) { + setBunnyState('outside'); + } else if (bunnyState === 'outside') { + setBunnyState('inside'); + } else if (bunnyState === 'inside') { + setBunnyState('final'); + } + }, 5000); + return () => clearTimeout(timeout); + }, [bunnyState, started]); + + if (!started) { + return ; + } + + return ( + + + {bunnyState === 'outside' ? ( + + ) : bunnyState === 'inside' ? ( + + ) : bunnyState === 'final' ? ( + + ) : undefined} + + + + ); +} + +type BunnyProps = { + mute?: boolean; + top?: number; + left?: number; + right?: number; + rotation?: number; + width?: number; + height?: number; + transition?: Transition; +}; + +function Bunny(props: BunnyProps) { + const { mute, ...other } = props; + return ( + + + + ); +} + +function NewReport() { + return ( + + + + + + + + + ); +} + +function BreakingNewsText() { + return ( + + + BREAKING NEWS + + + LiveCompositor is rumored to allegedly compose video + + + + 88:29 + + + Leaked docs can be found at https://compositor.live/docs + + + + ); +} + +function Logo() { + return ( + + + + ); +} + +async function exampleAsync() { + await downloadAsync(TV_URL, TV_PATH); + await downloadAsync(BUNNY_URL, BUNNY_PATH); + + const compositor = new LiveCompositor(); + await compositor.init(); + process.env.LIVE_COMPOSITOR_LOGGER_LEVEL = 'debug'; + + await compositor.registerInput('tv_input', { + type: 'rtp_stream', + port: INPUT_PORT, + video: { + decoder: 'ffmpeg_h264', + }, + }); + void ffmpegSendVideoFromMp4(INPUT_PORT, TV_PATH); + + await compositor.registerInput('bunny', { + type: 'mp4', + serverPath: BUNNY_PATH, + }); + + await compositor.registerShader('remove_green_screen', { + source: await fs.readFile(path.join(__dirname, 'remove_green_screen.wgsl'), 'utf-8'), + }); + + await compositor.registerImage('background', { + assetType: 'jpeg', + url: BACKGROUND_IMAGE_URL, + }); + + await compositor.registerImage('logo', { + assetType: 'png', + url: LOGO_URL, + }); + + await compositor.registerOutput('output', { + type: 'rtp_stream', + port: OUTPUT_PORT, + transportProtocol: 'tcp_server', + video: { + resolution: OUTPUT_RESOLUTION, + encoder: { + type: 'ffmpeg_h264', + preset: 'ultrafast', + }, + root: , + }, + audio: { + encoder: { + type: 'opus', + channels: 'stereo', + }, + }, + }); + gstStartPlayer(OUTPUT_PORT); + + await compositor.start(); +} + +void exampleAsync(); diff --git a/demos/3-live_stream/index.ts b/demos/3-live_stream/index.ts deleted file mode 100644 index 4e6e35754..000000000 --- a/demos/3-live_stream/index.ts +++ /dev/null @@ -1,235 +0,0 @@ -import path from 'path'; -import * as readline from 'readline'; - -import { ffmpegSendVideoFromMp4, ffplayStartPlayerAsync } from '../utils/ffmpeg'; -import { runCompositorExample } from '../utils/run'; -import { downloadAsync, sleepAsync } from '../utils/utils'; -import { Component, Resolution } from '../types/api.d'; -import { gstStreamWebcam } from '../utils/gst'; -import { - registerImageAsync, - registerInputAsync, - registerOutputAsync, - startAsync, - updateOutputAsync, -} from '../utils/api'; - -const OUTPUT_RESOLUTION: Resolution = { - width: 1920, - height: 1080, -}; - -const WEBCAM_INPUT_PORT = 10000; -const GAMEPLAY_PORT = 10002; -const VIDEO_OUTPUT_PORT = 10004; -const AUDIO_OUTPUT_PORT = 10006; -const IP = '127.0.0.1'; -const DISPLAY_LOGS = false; - -const GAMEPLAY_URL = - 'https://raw.githubusercontent.com/membraneframework-labs/video_compositor_snapshot_tests/main/demo_assets/gameplay.mp4'; -const DONATE_URL = - 'https://raw.githubusercontent.com/membraneframework-labs/video_compositor_snapshot_tests/main/demo_assets/donate.gif'; -const CALL_URL = - 'https://raw.githubusercontent.com/membraneframework-labs/video_compositor_snapshot_tests/main/demo_assets/call.mp4'; - -async function exampleAsync() { - await ffplayStartPlayerAsync(IP, DISPLAY_LOGS, VIDEO_OUTPUT_PORT, AUDIO_OUTPUT_PORT); - - // sleep to make sure ffplay have a chance to start before compositor starts sending packets - await sleepAsync(2000); - - const gameplayPath = path.join(__dirname, '../assets/gameplay.mp4'); - await downloadAsync(GAMEPLAY_URL, gameplayPath); - - await registerImageAsync('donate', { - asset_type: 'gif', - url: DONATE_URL, - }); - - const useWebCam = process.env.LIVE_COMPOSITOR_WEBCAM !== 'false'; - await registerInputAsync('webcam_input', { - type: 'rtp_stream', - port: WEBCAM_INPUT_PORT, - transport_protocol: useWebCam ? 'tcp_server' : 'udp', - video: { - decoder: 'ffmpeg_h264', - }, - }); - - await registerInputAsync('gameplay', { - type: 'rtp_stream', - port: GAMEPLAY_PORT, - video: { - decoder: 'ffmpeg_h264', - }, - audio: { - decoder: 'opus', - }, - }); - - await registerOutputAsync('video_output', { - type: 'rtp_stream', - ip: IP, - port: VIDEO_OUTPUT_PORT, - video: { - resolution: OUTPUT_RESOLUTION, - encoder: { - type: 'ffmpeg_h264', - preset: 'ultrafast', - }, - initial: { - root: baseScene(), - }, - }, - }); - - await registerOutputAsync('audio_output', { - type: 'rtp_stream', - ip: IP, - port: AUDIO_OUTPUT_PORT, - audio: { - encoder: { - channels: 'stereo', - type: 'opus', - }, - initial: { - inputs: [{ input_id: 'gameplay' }], - }, - }, - }); - - if (useWebCam) { - void gstStreamWebcam(IP, WEBCAM_INPUT_PORT, DISPLAY_LOGS); - } else { - const callPath = path.join(__dirname, '../assets/call.mp4'); - await downloadAsync(CALL_URL, callPath); - void ffmpegSendVideoFromMp4(WEBCAM_INPUT_PORT, callPath, DISPLAY_LOGS); - } - void ffmpegSendVideoFromMp4(GAMEPLAY_PORT, gameplayPath, DISPLAY_LOGS); - - await sleepAsync(2000); - await startAsync(); - - const rl = readline.createInterface({ - input: process.stdin, - output: process.stdout, - }); - - rl.question('Enter donate content: ', async donate_content => { - console.log(`Donate content: ${donate_content}`); - await displayDonateAsync(donate_content); - }); -} - -async function displayDonateAsync(msg: string) { - await updateOutputAsync('video_output', { - video: { - root: { - type: 'view', - children: [baseScene(), donateCard(msg, 'start')], - }, - }, - }); - await updateOutputAsync('video_output', { - video: { - root: { - type: 'view', - children: [baseScene(), donateCard(msg, 'middle')], - }, - }, - }); - await sleepAsync(4500); - await updateOutputAsync('video_output', { - video: { - root: { - type: 'view', - children: [baseScene(), donateCard(msg, 'end')], - }, - }, - }); -} - -function donateCard(msg: string, stage: 'start' | 'middle' | 'end'): Component { - const width = 480; - let top; - if (stage === 'start' || stage === 'end') { - top = -270; - } else if (stage === 'middle') { - top = 30; - } - - return { - type: 'view', - id: 'donate_view', - width, - height: 270, - top, - left: OUTPUT_RESOLUTION.width / 2 - width / 2, - direction: 'column', - children: [ - { - type: 'view', - top: 0, - left: 0, - children: [ - { - type: 'image', - image_id: 'donate', - }, - ], - }, - { - type: 'text', - width, - text: msg, - weight: 'extra_bold', - font_size: 50, - align: 'center', - color_rgba: '#FF0000FF', - font_family: 'Comic Sans MS', - }, - ], - transition: { - duration_ms: 1000, - easing_function: { - function_name: 'bounce', - }, - }, - }; -} - -function baseScene(): Component { - return { - type: 'view', - children: [ - { - type: 'rescaler', - child: { - type: 'input_stream', - input_id: 'gameplay', - }, - }, - { - type: 'view', - width: 500, - height: 300, - top: 30, - right: 30, - children: [ - { - type: 'rescaler', - vertical_align: 'top', - horizontal_align: 'right', - child: { - type: 'input_stream', - input_id: 'webcam_input', - }, - }, - ], - }, - ], - }; -} - -void runCompositorExample(exampleAsync, DISPLAY_LOGS); diff --git a/demos/3-live_stream/index.tsx b/demos/3-live_stream/index.tsx new file mode 100644 index 000000000..4f2e940a6 --- /dev/null +++ b/demos/3-live_stream/index.tsx @@ -0,0 +1,165 @@ +import path from 'path'; +import * as readline from 'readline'; + +import { ffmpegSendVideoFromMp4 } from '../utils/ffmpeg'; +import { downloadAsync } from '../utils/utils'; +import { gstStartPlayer, gstStartWebcamStream } from '../utils/gst'; +import LiveCompositor from '@live-compositor/node'; +import { InputStream, Text, Image, Rescaler, View } from 'live-compositor'; +import { useEffect, useState } from 'react'; + +const OUTPUT_RESOLUTION = { + width: 1920, + height: 1080, +}; + +const WEBCAM_INPUT_PORT = 10000; +const OUTPUT_PORT = 10004; + +const GAMEPLAY_URL = + 'https://raw.githubusercontent.com/membraneframework-labs/video_compositor_snapshot_tests/main/demo_assets/gameplay.mp4'; +const DONATE_URL = + 'https://raw.githubusercontent.com/membraneframework-labs/video_compositor_snapshot_tests/main/demo_assets/donate.gif'; +const CALL_URL = + 'https://raw.githubusercontent.com/membraneframework-labs/video_compositor_snapshot_tests/main/demo_assets/call.mp4'; + +const rl = readline.createInterface({ + input: process.stdin, + output: process.stdout, +}); + +function App() { + return ( + + + + + + + + + + ); +} + +function Donates() { + const [message, setMessage] = useState('init'); + const [show, setShow] = useState(false); + + useEffect(() => { + if (!show) { + // show new terminal prompt if previous donation just finished + rl.question('Enter donate content: ', message => { + setMessage(message); + setShow(true); + }); + return; + } else { + const timeout = setTimeout(() => { + setShow(false); + }, 2000); + return () => clearTimeout(timeout); + } + }, [show]); + + return ; +} + +function DonateCard({ message, show }: { message: string; show: boolean }) { + const width = 480; + const top = show ? 30 : -330; + + return ( + + + + + + {message} + + + + + ); +} + +async function exampleAsync() { + const compositor = new LiveCompositor(); + await compositor.init(); + + const gameplayPath = path.join(__dirname, '../assets/gameplay.mp4'); + await downloadAsync(GAMEPLAY_URL, gameplayPath); + + await compositor.registerImage('donate', { + assetType: 'gif', + url: DONATE_URL, + }); + + const useWebCam = process.env.LIVE_COMPOSITOR_WEBCAM !== 'false'; + await compositor.registerInput('webcam_input', { + type: 'rtp_stream', + port: WEBCAM_INPUT_PORT, + transportProtocol: useWebCam ? 'tcp_server' : 'udp', + video: { + decoder: 'ffmpeg_h264', + }, + }); + + await compositor.registerInput('gameplay', { + type: 'mp4', + serverPath: gameplayPath, + }); + + await compositor.registerOutput('video_output', { + type: 'rtp_stream', + port: OUTPUT_PORT, + transportProtocol: 'tcp_server', + video: { + resolution: OUTPUT_RESOLUTION, + encoder: { + type: 'ffmpeg_h264', + preset: 'ultrafast', + }, + root: , + }, + audio: { + encoder: { + type: 'opus', + channels: 'stereo', + }, + }, + }); + gstStartPlayer(OUTPUT_PORT); + + if (useWebCam) { + await gstStartWebcamStream(WEBCAM_INPUT_PORT); + } else { + const callPath = path.join(__dirname, '../assets/call.mp4'); + await downloadAsync(CALL_URL, callPath); + void ffmpegSendVideoFromMp4(WEBCAM_INPUT_PORT, callPath); + } + + await compositor.start(); +} + +void exampleAsync(); diff --git a/demos/README.md b/demos/README.md index 07871ee61..89acb7d25 100644 --- a/demos/README.md +++ b/demos/README.md @@ -4,9 +4,9 @@ https://github.com/software-mansion/live-compositor/assets/104033489/e6f5ba7c-ab ## Technical requirements -- **FFmpeg** (FFmpeg6 on Linux, FFmpeg 7 on MacOS) -- **Gstreamer** -- NodeJS + npm +- **FFmpeg** (FFmpeg6 on Linux, FFmpeg 7 on macOS) +- **GStreamer** +- NodeJS + NPM Before running demos, install JS dependencies with: @@ -52,9 +52,9 @@ npm run 1-videoconferencing ``` This example simulates composing video conference footage. -It demonstrate how you can change output dynamically with smooth transitions. +It demonstrates how you can change output dynamically with smooth transitions. -This example also use your webcam. If you have problems with webcam footage, you can substitute it with prerecorded mp4 file: +This example will use your webcam. If you have problems with webcam footage, you can substitute it with prerecorded mp4 file: ```console export LIVE_COMPOSITOR_WEBCAM=false @@ -69,7 +69,7 @@ npm run 2-tv_broadcast ``` This example simulates TV broadcasting scenario. -It demonstrate how you can combine build-in components with own shaders, customizing LiveCompositor for specific use-case, while utilizing GPU rendering acceleration. +It demonstrates how you can combine built-in components with own shaders, customizing LiveCompositor for specific use-case, while utilizing GPU rendering acceleration. In this example, green-screen is removed from input stream with use of custom shader. Transformed input stream, background image, logo, and text are combined in output stream. ### 3. Live stream @@ -80,10 +80,10 @@ Run this example with: npm run 3-live_stream ``` -This example simulates live streaming screen footage with webcam. -It demonstrate how to setup simple output and add elements like donate notifications. +This example simulates live-streaming screen footage with webcam. +It demonstrates how to set up simple output and add elements like donate notifications. -This example also use your webcam. If you have problems with webcam footage, you can substitute it with prerecorded mp4 file: +This example will use your webcam. If you have problems with webcam footage, you can substitute it with prerecorded mp4 file: ```console export LIVE_COMPOSITOR_WEBCAM=false diff --git a/demos/package-lock.json b/demos/package-lock.json index 9a49b578b..231b5975f 100644 --- a/demos/package-lock.json +++ b/demos/package-lock.json @@ -9,6 +9,7 @@ "version": "1.0.0", "license": "BUSL-1.1", "dependencies": { + "@live-compositor/node": "^0.1.0", "@types/fs-extra": "^11.0.2", "@types/node": "^20.12.11", "@types/node-fetch": "^2.6.11", @@ -21,11 +22,16 @@ "eslint-plugin-prettier": "^5.0.0", "fs-extra": "^11.1.1", "json-schema-to-typescript": "^14.0.4", + "live-compositor": "^0.1.0", "node-fetch": "^2.7.0", "node-popup": "^0.1.14", "prettier": "^3.0.3", + "react": "^18.3.1", "ts-node": "^10.9.1", "typescript": "^5.2.2" + }, + "devDependencies": { + "@types/react": "^18.3.9" } }, "node_modules/@apidevtools/json-schema-ref-parser": { @@ -218,6 +224,17 @@ "url": "https://github.com/chalk/strip-ansi?sponsor=1" } }, + "node_modules/@isaacs/fs-minipass": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/@isaacs/fs-minipass/-/fs-minipass-4.0.1.tgz", + "integrity": "sha512-wgm9Ehl2jpeqP3zw/7mo3kRHFp5MEDhqAdwy1fTGkHAwnkGOVsgpvQhL8B5n1qlb01jV3n/bI0ZfZp5lWA1k4w==", + "dependencies": { + "minipass": "^7.0.4" + }, + "engines": { + "node": ">=18.0.0" + } + }, "node_modules/@jridgewell/resolve-uri": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", @@ -245,6 +262,50 @@ "resolved": "https://registry.npmjs.org/@jsdevtools/ono/-/ono-7.1.3.tgz", "integrity": "sha512-4JQNk+3mVzK3xh2rqd6RB4J46qUR19azEHBneZyTZM+c456qOrbbM/5xcR8huNCCcbVt7+UmizG6GuUvPvKUYg==" }, + "node_modules/@live-compositor/core": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/@live-compositor/core/-/core-0.1.0.tgz", + "integrity": "sha512-0ZqPH3kS1krO633j9930Wr/lBBNj6kdmwsEeOkmatgt9aApzcYTk6rxb2awdn4Oh5JcmDtVBf1zrLzgAT9iwcQ==", + "dependencies": { + "react-reconciler": "0.29.2" + }, + "peerDependencies": { + "live-compositor": "^0.1.0" + } + }, + "node_modules/@live-compositor/node": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/@live-compositor/node/-/node-0.1.0.tgz", + "integrity": "sha512-Wdnq5cyWE2rEDy12IVue+98hsrigZtDnZlWrbDLPT29/ERCl2eSFeOSlnAaZvLqw26xmeNAVkjpBXqMjI1O3bg==", + "dependencies": { + "@live-compositor/core": "^0.1.0", + "fs-extra": "^11.2.0", + "node-fetch": "^2.6.7", + "tar": "^7.4.3", + "uuid": "^10.0.0", + "ws": "^8.18.0" + } + }, + "node_modules/@live-compositor/node/node_modules/ws": { + "version": "8.18.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.18.0.tgz", + "integrity": "sha512-8VbfWfHLbbwu3+N6OKsOMpBdT4kXPDDB9cJk2bJ6mh9ucxdlnNvH1e+roYkKmN9Nxw2yjz7VzeO9oOz2zJ04Pw==", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, "node_modules/@nodelib/fs.scandir": { "version": "2.1.5", "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", @@ -366,6 +427,22 @@ "form-data": "^4.0.0" } }, + "node_modules/@types/prop-types": { + "version": "15.7.13", + "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.13.tgz", + "integrity": "sha512-hCZTSvwbzWGvhqxp/RqVqwU999pBf2vp7hzIjiYOsl8wqOmUxkQ6ddw1cV3l8811+kdUFus/q4d1Y3E3SyEifA==", + "dev": true + }, + "node_modules/@types/react": { + "version": "18.3.9", + "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.9.tgz", + "integrity": "sha512-+BpAVyTpJkNWWSSnaLBk6ePpHLOGJKnEQNbINNovPWzvEUyAe3e+/d494QdEh71RekM/qV7lw6jzf1HGrJyAtQ==", + "dev": true, + "dependencies": { + "@types/prop-types": "*", + "csstype": "^3.0.2" + } + }, "node_modules/@types/semver": { "version": "7.5.8", "resolved": "https://registry.npmjs.org/@types/semver/-/semver-7.5.8.tgz", @@ -878,6 +955,14 @@ "url": "https://github.com/chalk/chalk?sponsor=1" } }, + "node_modules/chownr": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/chownr/-/chownr-3.0.0.tgz", + "integrity": "sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g==", + "engines": { + "node": ">=18" + } + }, "node_modules/cli-color": { "version": "2.0.4", "resolved": "https://registry.npmjs.org/cli-color/-/cli-color-2.0.4.tgz", @@ -962,6 +1047,12 @@ "node": ">= 8" } }, + "node_modules/csstype": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz", + "integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==", + "dev": true + }, "node_modules/d": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/d/-/d-1.0.2.tgz", @@ -2508,6 +2599,11 @@ "@pkgjs/parseargs": "^0.11.0" } }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==" + }, "node_modules/js-yaml": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz", @@ -2619,6 +2715,14 @@ "node": ">= 0.8.0" } }, + "node_modules/live-compositor": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/live-compositor/-/live-compositor-0.1.0.tgz", + "integrity": "sha512-TAScLUrHcSBAkAzQPsfyqvZ3EDHyETABKnp6E0uY7x1Zsqj+Xz4RkoOPB2cBJDORpSUkQUtj6pbchKUSXQ4Mnw==", + "peerDependencies": { + "react": "*" + } + }, "node_modules/locate-path": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", @@ -2643,6 +2747,17 @@ "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==" }, + "node_modules/loose-envify": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", + "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", + "dependencies": { + "js-tokens": "^3.0.0 || ^4.0.0" + }, + "bin": { + "loose-envify": "cli.js" + } + }, "node_modules/lru-cache": { "version": "10.2.2", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.2.2.tgz", @@ -2752,13 +2867,39 @@ } }, "node_modules/minipass": { - "version": "7.1.1", - "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.1.tgz", - "integrity": "sha512-UZ7eQ+h8ywIRAW1hIEl2AqdwzJucU/Kp59+8kkZeSvafXhZjul247BvIJjEVFVeON6d7lM46XX1HXCduKAS8VA==", + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.2.tgz", + "integrity": "sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==", "engines": { "node": ">=16 || 14 >=14.17" } }, + "node_modules/minizlib": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/minizlib/-/minizlib-3.0.1.tgz", + "integrity": "sha512-umcy022ILvb5/3Djuu8LWeqUa8D68JaBzlttKeMWen48SjabqS3iY5w/vzeMzMUNhLDifyhbOwKDSznB1vvrwg==", + "dependencies": { + "minipass": "^7.0.4", + "rimraf": "^5.0.5" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/minizlib/node_modules/rimraf": { + "version": "5.0.10", + "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-5.0.10.tgz", + "integrity": "sha512-l0OE8wL34P4nJH/H2ffoaniAokM2qSmrtXHmlpvYr5AVVX8msAyW0l8NVJFDxlSK4u3Uh/f41cQheDVdnYijwQ==", + "dependencies": { + "glob": "^10.3.7" + }, + "bin": { + "rimraf": "dist/esm/bin.mjs" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/mkdirp": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-3.0.1.tgz", @@ -3216,6 +3357,32 @@ } ] }, + "node_modules/react": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", + "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", + "dependencies": { + "loose-envify": "^1.1.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-reconciler": { + "version": "0.29.2", + "resolved": "https://registry.npmjs.org/react-reconciler/-/react-reconciler-0.29.2.tgz", + "integrity": "sha512-zZQqIiYgDCTP/f1N/mAR10nJGrPD2ZR+jDSEsKWJHYC7Cm2wodlwbR3upZRdC3cjIjSlTLNVyO7Iu0Yy7t2AYg==", + "dependencies": { + "loose-envify": "^1.1.0", + "scheduler": "^0.23.2" + }, + "engines": { + "node": ">=0.10.0" + }, + "peerDependencies": { + "react": "^18.3.1" + } + }, "node_modules/readable-stream": { "version": "2.3.8", "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz", @@ -3398,6 +3565,14 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/scheduler": { + "version": "0.23.2", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz", + "integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==", + "dependencies": { + "loose-envify": "^1.1.0" + } + }, "node_modules/semver": { "version": "7.6.2", "resolved": "https://registry.npmjs.org/semver/-/semver-7.6.2.tgz", @@ -3687,6 +3862,22 @@ "url": "https://opencollective.com/unts" } }, + "node_modules/tar": { + "version": "7.4.3", + "resolved": "https://registry.npmjs.org/tar/-/tar-7.4.3.tgz", + "integrity": "sha512-5S7Va8hKfV7W5U6g3aYxXmlPoZVAwUMy9AOKyF2fVuZa2UD3qZjg578OrLRt8PcNN1PleVaL/5/yYATNL0ICUw==", + "dependencies": { + "@isaacs/fs-minipass": "^4.0.0", + "chownr": "^3.0.0", + "minipass": "^7.1.2", + "minizlib": "^3.0.1", + "mkdirp": "^3.0.1", + "yallist": "^5.0.0" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/text-table": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/text-table/-/text-table-0.2.0.tgz", @@ -3958,6 +4149,18 @@ "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==" }, + "node_modules/uuid": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-10.0.0.tgz", + "integrity": "sha512-8XkAphELsDnEGrDxUOHB3RGvXz6TeuYSGEZBOjtTtPm2lwhGBjLgOzLHB63IUWfBpNucQjND6d3AOudO+H3RWQ==", + "funding": [ + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" + ], + "bin": { + "uuid": "dist/bin/uuid" + } + }, "node_modules/v8-compile-cache-lib": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/v8-compile-cache-lib/-/v8-compile-cache-lib-3.0.1.tgz", @@ -4140,6 +4343,14 @@ "async-limiter": "~1.0.0" } }, + "node_modules/yallist": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-5.0.0.tgz", + "integrity": "sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw==", + "engines": { + "node": ">=18" + } + }, "node_modules/yauzl": { "version": "2.10.0", "resolved": "https://registry.npmjs.org/yauzl/-/yauzl-2.10.0.tgz", diff --git a/demos/package.json b/demos/package.json index 67f39583d..dcb838a5c 100644 --- a/demos/package.json +++ b/demos/package.json @@ -8,10 +8,9 @@ "typecheck": "tsc --noEmit", "watch": "tsc --noEmit --watch", "ts": "ts-node --transpile-only", - "1-videoconferencing": "npm run ts ./1-videoconferencing/index.ts", - "2-tv_broadcast": "npm run ts ./2-tv_broadcast/index.ts", - "3-live_stream": "npm run ts ./3-live_stream/index.ts", - "generate-types": "ts-node ./utils/generate_types.ts" + "1-videoconferencing": "npm run ts ./1-videoconferencing/index.tsx", + "2-tv_broadcast": "npm run ts ./2-tv_broadcast/index.tsx", + "3-live_stream": "npm run ts ./3-live_stream/index.tsx" }, "repository": { "type": "git", @@ -24,6 +23,7 @@ }, "homepage": "https://github.com/software-mansion/live-compositor#readme", "dependencies": { + "@live-compositor/node": "^0.1.0", "@types/fs-extra": "^11.0.2", "@types/node": "^20.12.11", "@types/node-fetch": "^2.6.11", @@ -36,10 +36,15 @@ "eslint-plugin-prettier": "^5.0.0", "fs-extra": "^11.1.1", "json-schema-to-typescript": "^14.0.4", + "live-compositor": "^0.1.0", "node-fetch": "^2.7.0", "node-popup": "^0.1.14", "prettier": "^3.0.3", + "react": "^18.3.1", "ts-node": "^10.9.1", "typescript": "^5.2.2" + }, + "devDependencies": { + "@types/react": "^18.3.9" } } diff --git a/demos/tsconfig.json b/demos/tsconfig.json index 9d42b7381..569e1ebdf 100644 --- a/demos/tsconfig.json +++ b/demos/tsconfig.json @@ -7,6 +7,7 @@ "noImplicitReturns": true, "noFallthroughCasesInSwitch": true, "esModuleInterop": true, - "declaration": true + "declaration": true, + "jsx": "react-jsx" } } diff --git a/demos/types/api.d.ts b/demos/types/api.d.ts deleted file mode 100644 index a149c843a..000000000 --- a/demos/types/api.d.ts +++ /dev/null @@ -1,751 +0,0 @@ -/* eslint-disable */ -/** - * This file was automatically generated by json-schema-to-typescript. - * DO NOT MODIFY IT BY HAND. Instead, modify the source JSONSchema file, - * and run json-schema-to-typescript to regenerate this file. - */ - -/** - * This enum is used to generate JSON schema for all API types. - * This prevents repeating types in generated schema. - */ -export type ApiTypes = RegisterInput | RegisterOutput | ImageSpec | WebRendererSpec | ShaderSpec | UpdateOutputRequest; -export type RegisterInput = - | { - type: "rtp_stream"; - /** - * UDP port or port range on which the compositor should listen for the stream. - */ - port: PortOrPortRange; - /** - * Transport protocol. - */ - transport_protocol?: TransportProtocol | null; - /** - * Parameters of a video source included in the RTP stream. - */ - video?: InputRtpVideoOptions | null; - /** - * Parameters of an audio source included in the RTP stream. - */ - audio?: InputRtpAudioOptions | null; - /** - * (**default=`false`**) If input is required and the stream is not delivered - * on time, then LiveCompositor will delay producing output frames. - */ - required?: boolean | null; - /** - * Offset in milliseconds relative to the pipeline start (start request). If the offset is - * not defined then the stream will be synchronized based on the delivery time of the initial - * frames. - */ - offset_ms?: number | null; - } - | { - type: "mp4"; - /** - * URL of the MP4 file. - */ - url?: string | null; - /** - * Path to the MP4 file. - */ - path?: string | null; - /** - * (**default=`false`**) If input is required and frames are not processed - * on time, then LiveCompositor will delay producing output frames. - */ - required?: boolean | null; - /** - * Offset in milliseconds relative to the pipeline start (start request). If offset is - * not defined then stream is synchronized based on the first frames delivery time. - */ - offset_ms?: number | null; - }; -export type PortOrPortRange = string | number; -export type TransportProtocol = "udp" | "tcp_server"; -export type InputRtpVideoOptions = { - decoder: "ffmpeg_h264"; -}; -export type InputRtpAudioOptions = - | { - decoder: "opus"; - /** - * (**default=`false`**) Specifies whether the stream uses forward error correction. - * It's specific for Opus codec. - * For more information, check out [RFC](https://datatracker.ietf.org/doc/html/rfc6716#section-2.1.7). - */ - forward_error_correction?: boolean | null; - } - | { - decoder: "aac"; - /** - * AudioSpecificConfig as described in MPEG-4 part 3, section 1.6.2.1 - * The config should be encoded as described in [RFC 3640](https://datatracker.ietf.org/doc/html/rfc3640#section-4.1). - * - * The simplest way to obtain this value when using ffmpeg to stream to the compositor is - * to pass the additional `-sdp_file FILENAME` option to ffmpeg. This will cause it to - * write out an sdp file, which will contain this field. Programs which have the ability - * to stream AAC to the compositor should provide this information. - * - * In MP4 files, the ASC is embedded inside the esds box (note that it is not the whole - * box, only a part of it). This also applies to fragmented MP4s downloaded over HLS, if - * the playlist uses MP4s instead of MPEG Transport Streams - * - * In FLV files and the RTMP protocol, the ASC can be found in the `AACAUDIODATA` tag. - */ - audio_specific_config: string; - /** - * (**default=`"high_bitrate"`**) - * Specifies the [RFC 3640 mode](https://datatracker.ietf.org/doc/html/rfc3640#section-3.3.1) - * that should be used when depacketizing this stream. - */ - rtp_mode?: AacRtpMode | null; - }; -export type AacRtpMode = "low_bitrate" | "high_bitrate"; -export type RegisterOutput = { - type: "rtp_stream"; - /** - * Depends on the value of the `transport_protocol` field: - * - `udp` - An UDP port number that RTP packets will be sent to. - * - `tcp_server` - A local TCP port number or a port range that LiveCompositor will listen for incoming connections. - */ - port: PortOrPortRange; - /** - * Only valid if `transport_protocol="udp"`. IP address where RTP packets should be sent to. - */ - ip?: string | null; - /** - * (**default=`"udp"`**) Transport layer protocol that will be used to send RTP packets. - */ - transport_protocol?: TransportProtocol | null; - video?: OutputRtpVideoOptions | null; - audio?: OutputRtpAudioOptions | null; -}; -export type InputId = string; -export type VideoEncoderOptions = { - type: "ffmpeg_h264"; - /** - * (**default=`"fast"`**) Preset for an encoder. See `FFmpeg` [docs](https://trac.ffmpeg.org/wiki/Encode/H.264#Preset) to learn more. - */ - preset: H264EncoderPreset; - /** - * Raw FFmpeg encoder options. See [docs](https://ffmpeg.org/ffmpeg-codecs.html) for more. - */ - ffmpeg_options?: { - [k: string]: string; - } | null; -}; -export type H264EncoderPreset = - | "ultrafast" - | "superfast" - | "veryfast" - | "faster" - | "fast" - | "medium" - | "slow" - | "slower" - | "veryslow" - | "placebo"; -export type Component = - | { - type: "input_stream"; - /** - * Id of a component. - */ - id?: ComponentId | null; - /** - * Id of an input. It identifies a stream registered using a [`RegisterInputStream`](../routes.md#register-input) request. - */ - input_id: InputId; - } - | { - type: "view"; - /** - * Id of a component. - */ - id?: ComponentId | null; - /** - * List of component's children. - */ - children?: Component[] | null; - /** - * Width of a component in pixels. Exact behavior might be different based on the parent - * component: - * - If the parent component is a layout, check sections "Absolute positioning" and "Static - * positioning" of that component. - * - If the parent component is not a layout, then this field is required. - */ - width?: number | null; - /** - * Height of a component in pixels. Exact behavior might be different based on the parent - * component: - * - If the parent component is a layout, check sections "Absolute positioning" and "Static - * positioning" of that component. - * - If the parent component is not a layout, then this field is required. - */ - height?: number | null; - /** - * Direction defines how static children are positioned inside a View component. - */ - direction?: ViewDirection | null; - /** - * Distance in pixels between this component's top edge and its parent's top edge. - * If this field is defined, then the component will ignore a layout defined by its parent. - */ - top?: number | null; - /** - * Distance in pixels between this component's left edge and its parent's left edge. - * If this field is defined, this element will be absolutely positioned, instead of being - * laid out by its parent. - */ - left?: number | null; - /** - * Distance in pixels between the bottom edge of this component and the bottom edge of its parent. - * If this field is defined, this element will be absolutely positioned, instead of being - * laid out by its parent. - */ - bottom?: number | null; - /** - * Distance in pixels between this component's right edge and its parent's right edge. - * If this field is defined, this element will be absolutely positioned, instead of being - * laid out by its parent. - */ - right?: number | null; - /** - * Rotation of a component in degrees. If this field is defined, this element will be - * absolutely positioned, instead of being laid out by its parent. - */ - rotation?: number | null; - /** - * Defines how this component will behave during a scene update. This will only have an - * effect if the previous scene already contained a View component with the same id. - */ - transition?: Transition | null; - /** - * (**default=`"hidden"`**) Controls what happens to content that is too big to fit into an area. - */ - overflow?: Overflow | null; - /** - * (**default=`"#00000000"`**) Background color in a `"#RRGGBBAA"` format. - */ - background_color_rgba?: RGBAColor | null; - } - | { - type: "web_view"; - /** - * Id of a component. - */ - id?: ComponentId | null; - /** - * List of component's children. - */ - children?: Component[] | null; - /** - * Id of a web renderer instance. It identifies an instance registered using a [`register web renderer`](../routes.md#register-web-renderer-instance) request. - * - * :::warning - * You can only refer to specific instances in one Component at a time. - * ::: - */ - instance_id: RendererId; - } - | { - type: "shader"; - /** - * Id of a component. - */ - id?: ComponentId | null; - /** - * List of component's children. - */ - children?: Component[] | null; - /** - * Id of a shader. It identifies a shader registered using a [`register shader`](../routes.md#register-shader) request. - */ - shader_id: RendererId; - /** - * Object that will be serialized into a `struct` and passed inside the shader as: - * - * ```wgsl - * @group(1) @binding(0) var - * ``` - * :::note - * This object's structure must match the structure defined in a shader source code. Currently, we do not handle memory layout automatically. - * To achieve the correct memory alignment, you might need to pad your data with additional fields. See [WGSL documentation](https://www.w3.org/TR/WGSL/#alignment-and-size) for more details. - * ::: - */ - shader_param?: ShaderParam | null; - /** - * Resolution of a texture where shader will be executed. - */ - resolution: Resolution; - } - | { - type: "image"; - /** - * Id of a component. - */ - id?: ComponentId | null; - /** - * Id of an image. It identifies an image registered using a [`register image`](../routes.md#register-image) request. - */ - image_id: RendererId; - } - | { - type: "text"; - /** - * Id of a component. - */ - id?: ComponentId | null; - /** - * Text that will be rendered. - */ - text: string; - /** - * Width of a texture that text will be rendered on. If not provided, the resulting texture - * will be sized based on the defined text but limited to `max_width` value. - */ - width?: number | null; - /** - * Height of a texture that text will be rendered on. If not provided, the resulting texture - * will be sized based on the defined text but limited to `max_height` value. - * It's an error to provide `height` if `width` is not defined. - */ - height?: number | null; - /** - * (**default=`7682`**) Maximal `width`. Limits the width of the texture that the text will be rendered on. - * Value is ignored if `width` is defined. - */ - max_width?: number | null; - /** - * (**default=`4320`**) Maximal `height`. Limits the height of the texture that the text will be rendered on. - * Value is ignored if height is defined. - */ - max_height?: number | null; - /** - * Font size in pixels. - */ - font_size: number; - /** - * Distance between lines in pixels. Defaults to the value of the `font_size` property. - */ - line_height?: number | null; - /** - * (**default=`"#FFFFFFFF"`**) Font color in `#RRGGBBAA` format. - */ - color_rgba?: RGBAColor | null; - /** - * (**default=`"#00000000"`**) Background color in `#RRGGBBAA` format. - */ - background_color_rgba?: RGBAColor | null; - /** - * (**default=`"Verdana"`**) Font family. Provide [family-name](https://www.w3.org/TR/2018/REC-css-fonts-3-20180920/#family-name-value) - * for a specific font. "generic-family" values like e.g. "sans-serif" will not work. - */ - font_family?: string | null; - /** - * (**default=`"normal"`**) Font style. The selected font needs to support the specified style. - */ - style?: TextStyle | null; - /** - * (**default=`"left"`**) Text align. - */ - align?: HorizontalAlign | null; - /** - * (**default=`"none"`**) Text wrapping options. - */ - wrap?: TextWrapMode | null; - /** - * (**default=`"normal"`**) Font weight. The selected font needs to support the specified weight. - */ - weight?: TextWeight | null; - } - | { - type: "tiles"; - /** - * Id of a component. - */ - id?: ComponentId | null; - /** - * List of component's children. - */ - children?: Component[] | null; - /** - * Width of a component in pixels. Exact behavior might be different based on the parent - * component: - * - If the parent component is a layout, check sections "Absolute positioning" and "Static - * positioning" of that component. - * - If the parent component is not a layout, then this field is required. - */ - width?: number | null; - /** - * Height of a component in pixels. Exact behavior might be different based on the parent - * component: - * - If the parent component is a layout, check sections "Absolute positioning" and "Static - * positioning" of that component. - * - If the parent component is not a layout, then this field is required. - */ - height?: number | null; - /** - * (**default=`"#00000000"`**) Background color in a `"#RRGGBBAA"` format. - */ - background_color_rgba?: RGBAColor | null; - /** - * (**default=`"16:9"`**) Aspect ratio of a tile in `"W:H"` format, where W and H are integers. - */ - tile_aspect_ratio?: AspectRatio | null; - /** - * (**default=`0`**) Margin of each tile in pixels. - */ - margin?: number | null; - /** - * (**default=`0`**) Padding on each tile in pixels. - */ - padding?: number | null; - /** - * (**default=`"center"`**) Horizontal alignment of tiles. - */ - horizontal_align?: HorizontalAlign | null; - /** - * (**default=`"center"`**) Vertical alignment of tiles. - */ - vertical_align?: VerticalAlign | null; - /** - * Defines how this component will behave during a scene update. This will only have an - * effect if the previous scene already contained a `Tiles` component with the same id. - */ - transition?: Transition | null; - } - | { - type: "rescaler"; - /** - * Id of a component. - */ - id?: ComponentId | null; - /** - * List of component's children. - */ - child: Component; - /** - * (**default=`"fit"`**) Resize mode: - */ - mode?: RescaleMode | null; - /** - * (**default=`"center"`**) Horizontal alignment. - */ - horizontal_align?: HorizontalAlign | null; - /** - * (**default=`"center"`**) Vertical alignment. - */ - vertical_align?: VerticalAlign | null; - /** - * Width of a component in pixels. Exact behavior might be different based on the parent - * component: - * - If the parent component is a layout, check sections "Absolute positioning" and "Static - * positioning" of that component. - * - If the parent component is not a layout, then this field is required. - */ - width?: number | null; - /** - * Height of a component in pixels. Exact behavior might be different based on the parent - * component: - * - If the parent component is a layout, check sections "Absolute positioning" and "Static - * positioning" of that component. - * - If the parent component is not a layout, then this field is required. - */ - height?: number | null; - /** - * Distance in pixels between this component's top edge and its parent's top edge. - * If this field is defined, then the component will ignore a layout defined by its parent. - */ - top?: number | null; - /** - * Distance in pixels between this component's left edge and its parent's left edge. - * If this field is defined, this element will be absolutely positioned, instead of being - * laid out by its parent. - */ - left?: number | null; - /** - * Distance in pixels between this component's bottom edge and its parent's bottom edge. - * If this field is defined, this element will be absolutely positioned, instead of being - * laid out by its parent. - */ - bottom?: number | null; - /** - * Distance in pixels between this component's right edge and its parent's right edge. - * If this field is defined, this element will be absolutely positioned, instead of being - * laid out by its parent. - */ - right?: number | null; - /** - * Rotation of a component in degrees. If this field is defined, this element will be - * absolutely positioned, instead of being laid out by its parent. - */ - rotation?: number | null; - /** - * Defines how this component will behave during a scene update. This will only have an - * effect if the previous scene already contained a View component with the same id. - */ - transition?: Transition | null; - }; -export type ComponentId = string; -export type ViewDirection = "row" | "column"; -/** - * Easing functions are used to interpolate between two values over time. - * - * Custom easing functions can be implemented with cubic Bézier. - * The control points are defined with `points` field by providing four numerical values: `x1`, `y1`, `x2` and `y2`. The `x1` and `x2` values have to be in the range `[0; 1]`. The cubic Bézier result is clamped to the range `[0; 1]`. - * You can find example control point configurations [here](https://easings.net/). - */ -export type EasingFunction = - | { - function_name: "linear"; - } - | { - function_name: "bounce"; - } - | { - function_name: "cubic_bezier"; - /** - * @minItems 4 - * @maxItems 4 - */ - points: [number, number, number, number]; - }; -export type Overflow = "visible" | "hidden" | "fit"; -export type RGBAColor = string; -export type RendererId = string; -export type ShaderParam = - | { - type: "f32"; - value: number; - } - | { - type: "u32"; - value: number; - } - | { - type: "i32"; - value: number; - } - | { - type: "list"; - value: ShaderParam[]; - } - | { - type: "struct"; - value: ShaderParamStructField[]; - }; -export type ShaderParamStructField = { - field_name: string; -} & ShaderParamStructField1; -export type ShaderParamStructField1 = - | { - type: "f32"; - value: number; - field_name?: string; - } - | { - type: "u32"; - value: number; - field_name?: string; - } - | { - type: "i32"; - value: number; - field_name?: string; - } - | { - type: "list"; - value: ShaderParam[]; - field_name?: string; - } - | { - type: "struct"; - value: ShaderParamStructField1[]; - field_name?: string; - }; -export type TextStyle = "normal" | "italic" | "oblique"; -export type HorizontalAlign = "left" | "right" | "justified" | "center"; -export type TextWrapMode = "none" | "glyph" | "word"; -/** - * Font weight, based on the [OpenType specification](https://learn.microsoft.com/en-gb/typography/opentype/spec/os2#usweightclass). - */ -export type TextWeight = - | "thin" - | "extra_light" - | "light" - | "normal" - | "medium" - | "semi_bold" - | "bold" - | "extra_bold" - | "black"; -export type AspectRatio = string; -export type VerticalAlign = "top" | "center" | "bottom" | "justified"; -export type RescaleMode = "fit" | "fill"; -export type MixingStrategy = "sum_clip" | "sum_scale"; -export type AudioEncoderOptions = { - type: "opus"; - channels: AudioChannels; - /** - * (**default="voip"**) Specifies preset for audio output encoder. - */ - preset?: OpusEncoderPreset | null; - /** - * (**default=`false`**) Specifies whether the stream use forward error correction. - * It's specific for Opus codec. - * For more information, check out [RFC](https://datatracker.ietf.org/doc/html/rfc6716#section-2.1.7). - */ - forward_error_correction?: boolean | null; -}; -export type AudioChannels = "mono" | "stereo"; -export type OpusEncoderPreset = "quality" | "voip" | "lowest_latency"; -export type ImageSpec = - | { - asset_type: "png"; - url?: string | null; - path?: string | null; - } - | { - asset_type: "jpeg"; - url?: string | null; - path?: string | null; - } - | { - asset_type: "svg"; - url?: string | null; - path?: string | null; - resolution?: Resolution | null; - } - | { - asset_type: "gif"; - url?: string | null; - path?: string | null; - }; -export type WebEmbeddingMethod = - | "chromium_embedding" - | "native_embedding_over_content" - | "native_embedding_under_content"; - -export interface OutputRtpVideoOptions { - /** - * Output resolution in pixels. - */ - resolution: Resolution; - /** - * Defines when output stream should end if some of the input streams are finished. If output includes both audio and video streams, then EOS needs to be sent on both. - */ - send_eos_when?: OutputEndCondition | null; - /** - * Video encoder options. - */ - encoder: VideoEncoderOptions; - /** - * Root of a component tree/scene that should be rendered for the output. Use [`update_output` request](../routes.md#update-output) to update this value after registration. [Learn more](../../concept/component.md). - */ - initial: Video; -} -export interface Resolution { - /** - * Width in pixels. - */ - width: number; - /** - * Height in pixels. - */ - height: number; -} -/** - * This type defines when end of an input stream should trigger end of the output stream. Only one of those fields can be set at the time. - * Unless specified otherwise the input stream is considered finished/ended when: - * - TCP connection was dropped/closed. - * - RTCP Goodbye packet (`BYE`) was received. - * - Mp4 track has ended. - * - Input was unregistered already (or never registered). - */ -export interface OutputEndCondition { - /** - * Terminate output stream if any of the input streams from the list are finished. - */ - any_of?: InputId[] | null; - /** - * Terminate output stream if all the input streams from the list are finished. - */ - all_of?: InputId[] | null; - /** - * Terminate output stream if any of the input streams ends. This includes streams added after the output was registered. In particular, output stream will **not be** terminated if no inputs were ever connected. - */ - any_input?: boolean | null; - /** - * Terminate output stream if all the input streams finish. In particular, output stream will **be** terminated if no inputs were ever connected. - */ - all_inputs?: boolean | null; -} -export interface Video { - root: Component; -} -export interface Transition { - /** - * Duration of a transition in milliseconds. - */ - duration_ms: number; - /** - * (**default=`"linear"`**) Easing function to be used for the transition. - */ - easing_function?: EasingFunction | null; -} -export interface OutputRtpAudioOptions { - /** - * (**default="sum_clip"**) Specifies how audio should be mixed. - */ - mixing_strategy?: MixingStrategy | null; - /** - * Condition for termination of output stream based on the input streams states. - */ - send_eos_when?: OutputEndCondition | null; - /** - * Audio encoder options. - */ - encoder: AudioEncoderOptions; - /** - * Initial audio mixer configuration for output. - */ - initial: Audio; -} -export interface Audio { - inputs: InputAudio[]; -} -export interface InputAudio { - input_id: InputId; - /** - * (**default=`1.0`**) float in `[0, 1]` range representing input volume - */ - volume?: number | null; -} -export interface WebRendererSpec { - /** - * Url of a website that you want to render. - */ - url: string; - /** - * Resolution. - */ - resolution: Resolution; - /** - * Mechanism used to render input frames on the website. - */ - embedding_method?: WebEmbeddingMethod | null; -} -export interface ShaderSpec { - /** - * Shader source code. [Learn more.](../../concept/shaders) - */ - source: string; -} -export interface UpdateOutputRequest { - video?: Video | null; - audio?: Audio | null; - schedule_time_ms?: number | null; -} diff --git a/demos/utils/api.ts b/demos/utils/api.ts deleted file mode 100644 index 462bac42d..000000000 --- a/demos/utils/api.ts +++ /dev/null @@ -1,100 +0,0 @@ -import fetch from 'node-fetch'; -import { - ImageSpec, - RegisterInput, - RegisterOutput, - ShaderSpec, - UpdateOutputRequest, - WebRendererSpec, -} from '../types/api.d'; - -const COMPOSITOR_URL = 'http://127.0.0.1:8081'; - -type CompositorRequestBody = - | RegisterInput - | RegisterOutput - | UpdateOutputRequest - | ShaderSpec - | WebRendererSpec - | ImageSpec; - -export async function registerInputAsync(inputId: string, body: RegisterInput): Promise { - return sendAsync(`/api/input/${inputId}/register`, body); -} - -export async function unregisterInputAsync(inputId: string): Promise { - return sendAsync(`/api/input/${inputId}/unregister`, {}); -} - -export async function registerOutputAsync(outputId: string, body: RegisterOutput): Promise { - return sendAsync(`/api/output/${outputId}/register`, body); -} - -export async function unregisterOutputAsync(outputId: string): Promise { - return sendAsync(`/api/output/${outputId}/unregister`, {}); -} - -export async function updateOutputAsync( - outputId: string, - body: UpdateOutputRequest -): Promise { - return sendAsync(`/api/output/${outputId}/update`, body); -} - -export async function registerShaderAsync(shaderId: string, body: ShaderSpec): Promise { - return sendAsync(`/api/shader/${shaderId}/register`, body); -} - -export async function unregisterShaderAsync(shaderId: string): Promise { - return sendAsync(`/api/shader/${shaderId}/unregister`, {}); -} - -export async function registerWebRendererAsync( - rendererId: string, - body: WebRendererSpec -): Promise { - return sendAsync(`/api/web_renderer/${rendererId}/register`, body); -} - -export async function unregisterWebRendererAsync(rendererId: string): Promise { - return sendAsync(`/api/web_renderer/${rendererId}/unregister`, {}); -} - -export async function registerImageAsync(imageId: string, body: ImageSpec): Promise { - return sendAsync(`/api/image/${imageId}/register`, body); -} - -export async function unregisterImageAsync(imageId: string): Promise { - return sendAsync(`/api/image/${imageId}/unregister`, {}); -} - -export async function startAsync(): Promise { - return sendAsync(`/api/start`, {}); -} - -async function sendAsync(endpoint: string, body: CompositorRequestBody): Promise { - let response; - try { - response = await fetch(COMPOSITOR_URL + endpoint, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify(body), - }); - } catch (err: any) { - err.endpoint = endpoint; - err.request = JSON.stringify(body); - throw err; - } - - if (response.status >= 400) { - const err: any = new Error(`Request to compositor failed.`); - err.endpoint = endpoint; - err.request = JSON.stringify(body); - err.status = await response.status; - err.response = await response.json(); - throw err; - } - return await response.json(); -} diff --git a/demos/utils/ffmpeg.ts b/demos/utils/ffmpeg.ts index bf0aeefd9..bf08a1ce8 100644 --- a/demos/utils/ffmpeg.ts +++ b/demos/utils/ffmpeg.ts @@ -1,34 +1,28 @@ -import { COMPOSITOR_DIR } from './prepare_compositor'; -import { SpawnPromise, spawn } from './utils'; +import { SpawnPromise, sleepAsync, spawn } from './utils'; import path from 'path'; import fs from 'fs-extra'; +const COMPOSITOR_DIR = path.join(__dirname, '../.live_compositor'); + export async function ffplayStartPlayerAsync( - ip: string, - displayOutput: boolean, video_port: number, audio_port: number | undefined = undefined -): Promise<{ spawn_promise: SpawnPromise }> { +): Promise { let sdpFilePath; + await fs.mkdirp(COMPOSITOR_DIR); if (audio_port === undefined) { sdpFilePath = path.join(COMPOSITOR_DIR, `video_input_${video_port}.sdp`); - await writeVideoSdpFile(ip, video_port, sdpFilePath); + await writeVideoSdpFile('127.0.0.1', video_port, sdpFilePath); } else { sdpFilePath = path.join(COMPOSITOR_DIR, `video_audio_input_${video_port}_${audio_port}.sdp`); - await writeVideoAudioSdpFile(ip, video_port, audio_port, sdpFilePath); + await writeVideoAudioSdpFile('127.0.0.1', video_port, audio_port, sdpFilePath); } - const promise = spawn('ffplay', ['-protocol_whitelist', 'file,rtp,udp', sdpFilePath], { - displayOutput, - }); - return { spawn_promise: promise }; + void spawn('ffplay', ['-protocol_whitelist', 'file,rtp,udp', sdpFilePath], {}); + await sleepAsync(2000); } -export function ffmpegSendVideoFromMp4( - port: number, - mp4Path: string, - displayOutput: boolean -): SpawnPromise { +export function ffmpegSendVideoFromMp4(port: number, mp4Path: string): SpawnPromise { return spawn( 'ffmpeg', [ @@ -44,11 +38,11 @@ export function ffmpegSendVideoFromMp4( 'rtp', `rtp://127.0.0.1:${port}?rtcpport=${port}`, ], - { displayOutput } + {} ); } -export function ffmpegStreamScreen(ip: string, port: number, displayOutput: boolean): SpawnPromise { +export function ffmpegStreamScreen(port: number): SpawnPromise { const platform = process.platform; let inputOptions: string[]; if (platform === 'darwin') { @@ -69,9 +63,9 @@ export function ffmpegStreamScreen(ip: string, port: number, displayOutput: bool 'libx264', '-f', 'rtp', - `rtp://${ip}:${port}?rtcpport=${port}`, + `rtp://127.0.0.1:${port}?rtcpport=${port}`, ], - { displayOutput } + {} ); } diff --git a/demos/utils/generate_types.ts b/demos/utils/generate_types.ts deleted file mode 100644 index 5eb5e32bf..000000000 --- a/demos/utils/generate_types.ts +++ /dev/null @@ -1,15 +0,0 @@ -import * as fs from 'fs'; -import * as path from 'path'; -import { compileFromFile } from 'json-schema-to-typescript'; - -async function generateTypes() { - const schemaPath = path.resolve(__dirname, '../../schemas/api_types.schema.json'); - const tsOutputPath = path.resolve(__dirname, '../types/api.d.ts'); - - const typesTs = await compileFromFile(schemaPath, { - additionalProperties: false, - }); - fs.writeFileSync(tsOutputPath, typesTs); -} - -void generateTypes(); diff --git a/demos/utils/gst.ts b/demos/utils/gst.ts index ed1b2ce91..5b84aa6e0 100644 --- a/demos/utils/gst.ts +++ b/demos/utils/gst.ts @@ -1,18 +1,18 @@ import { exec } from 'child_process'; -import { SpawnPromise, spawn } from './utils'; +import { spawn } from './utils'; -export function gstStartPlayer(ip: string, port: number, displayOutput: boolean): SpawnPromise { +export function gstStartPlayer(port: number) { const gstCommand = `gst-launch-1.0 -v ` + `rtpptdemux name=demux ` + - `tcpclientsrc host=${ip} port=${port} ! "application/x-rtp-stream" ! rtpstreamdepay ! demux. ` + + `tcpclientsrc host=127.0.0.1 port=${port} ! "application/x-rtp-stream" ! rtpstreamdepay ! queue ! demux. ` + `demux.src_96 ! "application/x-rtp,media=video,clock-rate=90000,encoding-name=H264" ! queue ! rtph264depay ! decodebin ! videoconvert ! autovideosink ` + `demux.src_97 ! "application/x-rtp,media=audio,clock-rate=48000,encoding-name=OPUS" ! queue ! rtpopusdepay ! decodebin ! audioconvert ! autoaudiosink `; - return spawn('bash', ['-c', gstCommand], { displayOutput }); + void spawn('bash', ['-c', gstCommand], {}); } -export function gstStreamWebcam(ip: string, port: number, displayOutput: boolean): SpawnPromise { +export async function gstStartWebcamStream(port: number): Promise { const isMacOS = process.platform === 'darwin'; const [gstWebcamSource, gstEncoder, gstEncoderOptions] = isMacOS @@ -20,13 +20,13 @@ export function gstStreamWebcam(ip: string, port: number, displayOutput: boolean : ['v4l2src', 'x264enc', 'tune=zerolatency bitrate=2000 speed-preset=superfast']; const plugins = [gstWebcamSource, 'videoconvert', gstEncoder, 'rtph264pay', 'udpsink']; - void checkGstPlugins(plugins); + await checkGstPlugins(plugins); const gstCommand = `gst-launch-1.0 -v ` + - `${gstWebcamSource} ! videoconvert ! ${gstEncoder} ${gstEncoderOptions} ! rtph264pay config-interval=1 pt=96 ! rtpstreampay ! queue ! tcpclientsink host=${ip} port=${port}`; + `${gstWebcamSource} ! videoconvert ! ${gstEncoder} ${gstEncoderOptions} ! rtph264pay config-interval=1 pt=96 ! rtpstreampay ! queue ! tcpclientsink host=127.0.0.1 port=${port}`; - return spawn('bash', ['-c', gstCommand], { displayOutput }); + void spawn('bash', ['-c', gstCommand], {}); } async function checkGstPlugins(plugins: string[]) { diff --git a/demos/utils/prepare_compositor.ts b/demos/utils/prepare_compositor.ts deleted file mode 100644 index a6e0eb495..000000000 --- a/demos/utils/prepare_compositor.ts +++ /dev/null @@ -1,61 +0,0 @@ -import fs from 'fs-extra'; -import path from 'path'; -import { downloadAsync, spawn } from './utils'; - -export const COMPOSITOR_DIR = path.join(__dirname, '../.live_compositor'); - -const VERSION = 'v0.3.0'; - -const COMPOSITOR_X86_64_LINUX_DOWNLOAD_URL = `https://github.com/software-mansion/live-compositor/releases/download/${VERSION}/live_compositor_linux_x86_64.tar.gz`; -const COMPOSITOR_ARM_LINUX_DOWNLOAD_URL = `https://github.com/software-mansion/live-compositor/releases/download/${VERSION}/live_compositor_linux_aarch64.tar.gz`; -const COMPOSITOR_X86_64_MAC_DOWNLOAD_URL = `https://github.com/software-mansion/live-compositor/releases/download/${VERSION}/live_compositor_darwin_x86_64.tar.gz`; -const COMPOSITOR_ARM_MAC_DOWNLOAD_URL = `https://github.com/software-mansion/live-compositor/releases/download/${VERSION}/live_compositor_darwin_aarch64.tar.gz`; - -export async function ensureCompositorReadyAsync(): Promise { - const versionFile = path.join(COMPOSITOR_DIR, '.version'); - if ( - (await fs.pathExists(versionFile)) && - (await fs.readFile(versionFile, 'utf8')).trim() === VERSION - ) { - return; - } - try { - await prepareCompositorAsync(); - } catch (err) { - await fs.remove(COMPOSITOR_DIR); - throw err; - } -} - -export async function prepareCompositorAsync() { - await fs.remove(COMPOSITOR_DIR); - await fs.mkdirp(COMPOSITOR_DIR); - console.log('Downloading live_compositor.'); - await downloadAsync( - getCompositorDownloadUrl(), - path.join(COMPOSITOR_DIR, 'live_compositor.tar.gz') - ); - console.log('Unpacking live_compositor.'); - await spawn('tar', ['-xvf', 'live_compositor.tar.gz'], { - displayOutput: true, - cwd: COMPOSITOR_DIR, - }); - await fs.writeFile(path.join(COMPOSITOR_DIR, '.version'), VERSION); -} - -function getCompositorDownloadUrl(): string { - if (process.platform === 'linux') { - if (process.arch === 'arm64') { - return COMPOSITOR_ARM_LINUX_DOWNLOAD_URL; - } else if (process.arch === 'x64') { - return COMPOSITOR_X86_64_LINUX_DOWNLOAD_URL; - } - } else if (process.platform === 'darwin') { - if (process.arch === 'x64') { - return COMPOSITOR_X86_64_MAC_DOWNLOAD_URL; - } else if (process.arch === 'arm64') { - return COMPOSITOR_ARM_MAC_DOWNLOAD_URL; - } - } - throw new Error('Unsupported platform.'); -} diff --git a/demos/utils/run.ts b/demos/utils/run.ts deleted file mode 100644 index 61daa408f..000000000 --- a/demos/utils/run.ts +++ /dev/null @@ -1,73 +0,0 @@ -import { sleepAsync, spawn } from './utils'; -import chalk from 'chalk'; -import { Response } from 'node-fetch'; -import path from 'path'; -import { ensureCompositorReadyAsync } from './prepare_compositor'; - -export async function runCompositorExample( - fn: () => Promise, - displayOutput: boolean -): Promise { - await ensureCompositorReadyAsync(); - const { command, args, cwd } = getCompositorRunCmd(); - try { - void spawn(command, args, { - displayOutput: displayOutput, - cwd: cwd ?? process.cwd(), - }); - - await sleepAsync(2000); - - await fn(); - } catch (err) { - await logError(err); - throw err; - } -} - -async function logError(err: any): Promise { - if (err.response instanceof Response) { - const body = await err.response.json(); - if (body.error_code && body.stack) { - console.error(); - console.error(chalk.red(`Request failed with error (${body.erorr_code}):`)); - for (const errLine of body.stack) { - console.error(chalk.red(` - ${errLine}`)); - } - } else { - console.error(); - console.error(chalk.red(`Request failed with status code ${err.response.status}`)); - console.error(chalk.red(JSON.stringify(body, null, 2))); - } - } else { - console.error(err); - } -} - -const COMPOSITOR_DIR = path.join(__dirname, '../.live_compositor'); - -function getCompositorRunCmd(): { - command: string; - args: string[]; - cwd?: string; -} { - if (process.env.LIVE_COMPOSITOR_SOURCE_DIR) { - return { - command: 'cargo', - args: ['run', '--release', '--bin', 'live_compositor'], - cwd: process.env.LIVE_COMPOSITOR_SOURCE_DIR, - }; - } else if (process.platform === 'linux') { - return { - command: path.join(COMPOSITOR_DIR, 'live_compositor/live_compositor'), - args: [], - }; - } else if (process.platform === 'darwin') { - return { - command: path.join(COMPOSITOR_DIR, 'live_compositor/live_compositor'), - args: [], - }; - } - - throw new Error('Unsupported platform.'); -} diff --git a/demos/utils/utils.ts b/demos/utils/utils.ts index 82b10bd2b..9d1b4cb36 100644 --- a/demos/utils/utils.ts +++ b/demos/utils/utils.ts @@ -14,14 +14,13 @@ process.on('exit', () => { }); type SpawnOptions = { - displayOutput: boolean; cwd?: string; }; export function spawn(command: string, args: string[], opts: SpawnOptions): SpawnPromise { console.log(`Spawning: ${command} ${args.join(' ')}`); const child = nodeSpawn(command, args, { - stdio: opts.displayOutput ? 'inherit' : 'ignore', + stdio: 'ignore', cwd: opts.cwd ?? cwd(), env: { ...process.env,