From a162a7accf31a1c3a3441b4b3e8c7f7624222b4a Mon Sep 17 00:00:00 2001 From: Andres Cera Date: Sun, 11 Jan 2026 22:21:36 -0500 Subject: [PATCH] feat: add TypeScript bindings package (@ceralive/ceracoder) Introduces a comprehensive TypeScript abstraction layer for ceracoder: - PipelineBuilder: Generate hardware-specific GStreamer pipelines for Jetson, RK3588, N100, and Generic (software) hardware types - Zod v4 schemas: Config/CLI validation with proper defaults - Process helpers: spawn, SIGHUP reload, config/pipeline file writing - Per-hardware source support: HDMI, Cam Link, UVC H264, MJPEG, etc. The package enables CeraUI to dynamically build pipelines instead of using static pipeline files, with full type safety and validation. --- bindings/typescript/README.md | 59 ++++ bindings/typescript/bun.lock | 29 ++ bindings/typescript/package.json | 29 ++ bindings/typescript/src/cli.ts | 35 +++ bindings/typescript/src/config.ts | 165 +++++++++++ bindings/typescript/src/constants.ts | 23 ++ bindings/typescript/src/index.ts | 7 + bindings/typescript/src/pipeline/common.ts | 107 +++++++ .../src/pipeline/generic-builder.ts | 123 ++++++++ .../typescript/src/pipeline/index.test.ts | 279 ++++++++++++++++++ bindings/typescript/src/pipeline/index.ts | 156 ++++++++++ .../typescript/src/pipeline/jetson-builder.ts | 209 +++++++++++++ .../typescript/src/pipeline/n100-builder.ts | 184 ++++++++++++ .../typescript/src/pipeline/rk3588-builder.ts | 211 +++++++++++++ bindings/typescript/src/pipeline/types.ts | 135 +++++++++ bindings/typescript/src/process.ts | 193 ++++++++++++ bindings/typescript/src/run.test.ts | 87 ++++++ bindings/typescript/src/run.ts | 104 +++++++ bindings/typescript/src/types.ts | 69 +++++ bindings/typescript/tsconfig.json | 15 + docs/architecture.md | 50 +++- 21 files changed, 2266 insertions(+), 3 deletions(-) create mode 100644 bindings/typescript/README.md create mode 100644 bindings/typescript/bun.lock create mode 100644 bindings/typescript/package.json create mode 100644 bindings/typescript/src/cli.ts create mode 100644 bindings/typescript/src/config.ts create mode 100644 bindings/typescript/src/constants.ts create mode 100644 bindings/typescript/src/index.ts create mode 100644 bindings/typescript/src/pipeline/common.ts create mode 100644 bindings/typescript/src/pipeline/generic-builder.ts create mode 100644 bindings/typescript/src/pipeline/index.test.ts create mode 100644 bindings/typescript/src/pipeline/index.ts create mode 100644 bindings/typescript/src/pipeline/jetson-builder.ts create mode 100644 bindings/typescript/src/pipeline/n100-builder.ts create mode 100644 bindings/typescript/src/pipeline/rk3588-builder.ts create mode 100644 bindings/typescript/src/pipeline/types.ts create mode 100644 bindings/typescript/src/process.ts create mode 100644 bindings/typescript/src/run.test.ts create mode 100644 bindings/typescript/src/run.ts create mode 100644 bindings/typescript/src/types.ts create mode 100644 bindings/typescript/tsconfig.json diff --git a/bindings/typescript/README.md b/bindings/typescript/README.md new file mode 100644 index 0000000..cfe9ba3 --- /dev/null +++ b/bindings/typescript/README.md @@ -0,0 +1,59 @@ +# @ceralive/ceracoder (TypeScript bindings) + +Type-safe helpers for ceracoder integration: + +- Zod v4 schemas for config and CLI options +- Defaults aligned with the ceracoder C implementation +- Config generator (`buildCeracoderConfig`, `serializeCeracoderConfig`) +- CLI args builder (`buildCeracoderArgs`) that always prefers `-c ` (legacy `-b` removed) +- Pipeline builder (`PipelineBuilder`) to generate hardware-specific GStreamer launch strings +- Process helpers (`spawnCeracoder`, `sendHup`, `sendTerm`, `writeConfig`, `writePipeline`) + +## Pipeline Builder + +```ts +import { PipelineBuilder } from "@ceralive/ceracoder"; + +const result = PipelineBuilder.build({ + hardware: "rk3588", + source: "hdmi", + overrides: { resolution: "1080p", framerate: 30 }, +}); + +console.log(result.pipeline); // GStreamer launch string +``` + +Helpers: +- `PipelineBuilder.listHardwareTypes()` → `["jetson","rk3588","n100","generic"]` +- `PipelineBuilder.listSources(hardware)` → per-hardware sources +- `PipelineBuilder.build({ hardware, source, overrides, writeTo? })` → pipeline string and optional file path + +Notes: +- Pipelines are validated to contain `appsink` and encoder elements (`venc_bps`/`venc_kbps`) +- Resolution/framerate defaults come from per-source metadata +- `writeTo` writes the pipeline string to disk (for ceracoder `-p `) + +## Usage + +```ts +import { + buildCeracoderArgs, + buildCeracoderConfig, + serializeCeracoderConfig, +} from "@ceralive/ceracoder"; + +const { config, ini } = buildCeracoderConfig({ + general: { max_bitrate: 6000 }, + srt: { latency: 2000 }, +}); + +// Write ini to ceracoder.conf, then run ceracoder +const args = buildCeracoderArgs({ + pipelineFile: "/usr/share/ceracoder/pipelines/generic/h264_camlink_1080p", + host: "127.0.0.1", + port: 9000, + configFile: "/tmp/ceracoder.conf", + latencyMs: config.srt.latency, + algorithm: config.general.balancer, +}); +``` diff --git a/bindings/typescript/bun.lock b/bindings/typescript/bun.lock new file mode 100644 index 0000000..2ba34c8 --- /dev/null +++ b/bindings/typescript/bun.lock @@ -0,0 +1,29 @@ +{ + "lockfileVersion": 1, + "configVersion": 1, + "workspaces": { + "": { + "name": "@ceralive/ceracoder", + "dependencies": { + "zod": "^4.3.5", + }, + "devDependencies": { + "@types/bun": "^1.3.5", + "typescript": "^5.9.3", + }, + }, + }, + "packages": { + "@types/bun": ["@types/bun@1.3.5", "", { "dependencies": { "bun-types": "1.3.5" } }, "sha512-RnygCqNrd3srIPEWBd5LFeUYG7plCoH2Yw9WaZGyNmdTEei+gWaHqydbaIRkIkcbXwhBT94q78QljxN0Sk838w=="], + + "@types/node": ["@types/node@25.0.6", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-NNu0sjyNxpoiW3YuVFfNz7mxSQ+S4X2G28uqg2s+CzoqoQjLPsWSbsFFyztIAqt2vb8kfEAsJNepMGPTxFDx3Q=="], + + "bun-types": ["bun-types@1.3.5", "", { "dependencies": { "@types/node": "*" } }, "sha512-inmAYe2PFLs0SUbFOWSVD24sg1jFlMPxOjOSSCYqUgn4Hsc3rDc7dFvfVYjFPNHtov6kgUeulV4SxbuIV/stPw=="], + + "typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="], + + "undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="], + + "zod": ["zod@4.3.5", "", {}, "sha512-k7Nwx6vuWx1IJ9Bjuf4Zt1PEllcwe7cls3VNzm4CQ1/hgtFUK2bRNG3rvnpPUhFjmqJKAKtjV576KnUkHocg/g=="], + } +} diff --git a/bindings/typescript/package.json b/bindings/typescript/package.json new file mode 100644 index 0000000..99eb96c --- /dev/null +++ b/bindings/typescript/package.json @@ -0,0 +1,29 @@ +{ + "name": "@ceralive/ceracoder", + "version": "0.1.0", + "description": "Type-safe ceracoder bindings (config + CLI helpers) for CeraUI integration.", + "type": "module", + "main": "dist/index.js", + "types": "dist/index.d.ts", + "scripts": { + "build": "tsc -p tsconfig.json", + "lint": "tsc -p tsconfig.json --noEmit", + "test": "bun test" + }, + "dependencies": { + "zod": "^4.3.5" + }, + "devDependencies": { + "@types/bun": "^1.3.5", + "typescript": "^5.9.3" + }, + "license": "GPL-3.0", + "keywords": [ + "ceracoder", + "srt", + "gstreamer", + "ceralive", + "config", + "cli" + ] +} diff --git a/bindings/typescript/src/cli.ts b/bindings/typescript/src/cli.ts new file mode 100644 index 0000000..afa29f2 --- /dev/null +++ b/bindings/typescript/src/cli.ts @@ -0,0 +1,35 @@ +import { cliOptionsSchema, type CeracoderCliOptions } from "./types.js"; + +export function buildCeracoderArgs(options: CeracoderCliOptions): Array { + const opts = cliOptionsSchema.parse(options); + + const args: Array = [ + opts.pipelineFile, + opts.host, + String(opts.port), + "-c", + opts.configFile, + ]; + + if (opts.delayMs !== undefined) { + args.push("-d", String(opts.delayMs)); + } + + if (opts.streamId) { + args.push("-s", opts.streamId); + } + + if (opts.latencyMs !== undefined) { + args.push("-l", String(opts.latencyMs)); + } + + if (opts.reducedPacketSize) { + args.push("-r"); + } + + if (opts.algorithm) { + args.push("-a", opts.algorithm); + } + + return args; +} diff --git a/bindings/typescript/src/config.ts b/bindings/typescript/src/config.ts new file mode 100644 index 0000000..42a8398 --- /dev/null +++ b/bindings/typescript/src/config.ts @@ -0,0 +1,165 @@ +import { z } from "zod"; +import { + DEFAULT_ADAPTIVE, + DEFAULT_AIMD, + DEFAULT_BALANCER, + DEFAULT_MAX_BITRATE, + DEFAULT_MIN_BITRATE, + DEFAULT_SRT_LATENCY, +} from "./constants.js"; +import { + CeracoderConfig, + ceracoderConfigSchema, + PartialCeracoderConfig, +} from "./types.js"; + +type MutableConfig = CeracoderConfig & { + general: CeracoderConfig["general"]; +}; + +function applyDefaults(input?: PartialCeracoderConfig): CeracoderConfig { + const parsed = ceracoderConfigSchema.parse(input ?? {}); + const merged: MutableConfig = { + general: { + min_bitrate: + input?.general?.min_bitrate ?? parsed.general.min_bitrate ?? DEFAULT_MIN_BITRATE, + max_bitrate: + input?.general?.max_bitrate ?? parsed.general.max_bitrate ?? DEFAULT_MAX_BITRATE, + balancer: input?.general?.balancer ?? parsed.general.balancer ?? DEFAULT_BALANCER, + }, + srt: { + latency: input?.srt?.latency ?? parsed.srt.latency ?? DEFAULT_SRT_LATENCY, + }, + adaptive: + parsed.general.balancer === "adaptive" + ? { + ...DEFAULT_ADAPTIVE, + ...(parsed.adaptive ?? {}), + } + : undefined, + aimd: + parsed.general.balancer === "aimd" + ? { + ...DEFAULT_AIMD, + ...(parsed.aimd ?? {}), + } + : undefined, + }; + return merged; +} + +export function createCeracoderConfig( + input?: PartialCeracoderConfig, +): CeracoderConfig { + return applyDefaults(input); +} + +function formatSection(name: string, kv: Record) { + const lines = Object.entries(kv) + .filter(([, v]) => v !== undefined) + .map(([k, v]) => `${k} = ${v}`); + if (!lines.length) return ""; + return `[${name}]\n${lines.join("\n")}\n\n`; +} + +export function serializeCeracoderConfig(config: CeracoderConfig): string { + const general = formatSection("general", { + min_bitrate: config.general.min_bitrate, + max_bitrate: config.general.max_bitrate, + balancer: config.general.balancer, + }); + + const srt = formatSection("srt", { + latency: config.srt.latency, + }); + + const adaptive = formatSection("adaptive", { + incr_step: config.adaptive?.incr_step, + decr_step: config.adaptive?.decr_step, + incr_interval: config.adaptive?.incr_interval, + decr_interval: config.adaptive?.decr_interval, + loss_threshold: config.adaptive?.loss_threshold, + }); + + const aimd = formatSection("aimd", { + incr_step: config.aimd?.incr_step, + decr_mult: config.aimd?.decr_mult, + incr_interval: config.aimd?.incr_interval, + decr_interval: config.aimd?.decr_interval, + }); + + return `${general}${srt}${adaptive}${aimd}`.trimEnd() + "\n"; +} + +function parseSection(lines: Array): Record { + return lines.reduce>((acc, line) => { + const trimmed = line.trim(); + if (!trimmed || trimmed.startsWith("#") || trimmed.startsWith(";")) return acc; + const [key, ...rest] = trimmed.split("="); + if (!key || rest.length === 0) return acc; + acc[key.trim()] = rest.join("=").trim(); + return acc; + }, {}); +} + +export function parseCeracoderConfig(ini: string): CeracoderConfig { + const sections: Record> = {}; + let current: string | null = null; + ini.split(/\r?\n/).forEach((line) => { + const trimmed = line.trim(); + const sectionMatch = trimmed.match(/^\[(.+)]$/); + if (sectionMatch) { + current = sectionMatch[1].toLowerCase(); + sections[current] = []; + return; + } + if (current) { + sections[current].push(line); + } + }); + + const generalRaw = parseSection(sections.general ?? []); + const srtRaw = parseSection(sections.srt ?? []); + const adaptiveRaw = parseSection(sections.adaptive ?? []); + const aimdRaw = parseSection(sections.aimd ?? []); + + const parsed = ceracoderConfigSchema.parse({ + general: { + min_bitrate: generalRaw.min_bitrate ? Number(generalRaw.min_bitrate) : undefined, + max_bitrate: generalRaw.max_bitrate ? Number(generalRaw.max_bitrate) : undefined, + balancer: generalRaw.balancer as z.infer["general"]["balancer"], + }, + srt: { + latency: srtRaw.latency ? Number(srtRaw.latency) : undefined, + }, + adaptive: Object.keys(adaptiveRaw).length + ? { + incr_step: adaptiveRaw.incr_step ? Number(adaptiveRaw.incr_step) : undefined, + decr_step: adaptiveRaw.decr_step ? Number(adaptiveRaw.decr_step) : undefined, + incr_interval: adaptiveRaw.incr_interval ? Number(adaptiveRaw.incr_interval) : undefined, + decr_interval: adaptiveRaw.decr_interval ? Number(adaptiveRaw.decr_interval) : undefined, + loss_threshold: adaptiveRaw.loss_threshold + ? Number(adaptiveRaw.loss_threshold) + : undefined, + } + : undefined, + aimd: Object.keys(aimdRaw).length + ? { + incr_step: aimdRaw.incr_step ? Number(aimdRaw.incr_step) : undefined, + decr_mult: aimdRaw.decr_mult ? Number(aimdRaw.decr_mult) : undefined, + incr_interval: aimdRaw.incr_interval ? Number(aimdRaw.incr_interval) : undefined, + decr_interval: aimdRaw.decr_interval ? Number(aimdRaw.decr_interval) : undefined, + } + : undefined, + }); + + return applyDefaults(parsed); +} + +export function buildCeracoderConfig( + input?: PartialCeracoderConfig, +): { config: CeracoderConfig; ini: string } { + const config = createCeracoderConfig(input); + const ini = serializeCeracoderConfig(config); + return { config, ini }; +} diff --git a/bindings/typescript/src/constants.ts b/bindings/typescript/src/constants.ts new file mode 100644 index 0000000..e858d15 --- /dev/null +++ b/bindings/typescript/src/constants.ts @@ -0,0 +1,23 @@ +export const DEFAULT_MIN_BITRATE = 300; // Kbps +export const DEFAULT_MAX_BITRATE = 6000; // Kbps +export const DEFAULT_SRT_LATENCY = 2000; // ms +export const DEFAULT_BALANCER = "adaptive" as const; + +export const DEFAULT_ADAPTIVE = { + incr_step: 30, + decr_step: 100, + incr_interval: 500, + decr_interval: 200, + loss_threshold: 0.5, +} as const; + +export const DEFAULT_AIMD = { + incr_step: 50, + decr_mult: 0.75, + incr_interval: 500, + decr_interval: 200, +} as const; + +export const DEFAULT_PIPELINE_ROOT = "/usr/share/ceracoder/pipelines"; +export const TEMP_PIPELINE_PATH = "/tmp/ceracoder_pipeline"; +export const DEFAULT_CONFIG_PATH = "/tmp/ceracoder.conf"; diff --git a/bindings/typescript/src/index.ts b/bindings/typescript/src/index.ts new file mode 100644 index 0000000..f3b5ab0 --- /dev/null +++ b/bindings/typescript/src/index.ts @@ -0,0 +1,7 @@ +export * from "./constants.js"; +export * from "./types.js"; +export * from "./config.js"; +export * from "./cli.js"; +export * from "./run.js"; +export * from "./process.js"; +export * from "./pipeline/index.js"; diff --git a/bindings/typescript/src/pipeline/common.ts b/bindings/typescript/src/pipeline/common.ts new file mode 100644 index 0000000..31e6e4c --- /dev/null +++ b/bindings/typescript/src/pipeline/common.ts @@ -0,0 +1,107 @@ +import type { AudioCodec, Framerate, HardwareType, Resolution, VideoSource } from "./types.js"; +import { RESOLUTION_DIMS } from "./types.js"; + +// Default audio devices by hardware and source +const DEFAULT_AUDIO_DEVICES: Record> = { + jetson: { + default: "hw:2", + }, + rk3588: { + hdmi: "hw:CARD=rockchiphdmiin", + default: "hw:CARD=rockchiphdmiin", + }, + n100: { + default: "hw:1", + }, + generic: { + default: "hw:2", + }, +}; + +export function getDefaultAudioDevice(hardware: HardwareType, source: VideoSource): string { + const hwDevices = DEFAULT_AUDIO_DEVICES[hardware]; + return hwDevices[source] || hwDevices.default; +} + +export function buildVideoRateFilter(framerate?: Framerate): string { + if (!framerate) return ""; + // Handle decimal framerates + if (framerate === 29.97) { + return "videorate ! video/x-raw,framerate=30000/1001 ! "; + } + if (framerate === 59.94) { + return "videorate ! video/x-raw,framerate=60000/1001 ! "; + } + return `videorate ! video/x-raw,framerate=${framerate}/1 ! `; +} + +export function buildOverlay(enabled = true): string { + if (!enabled) return ""; + return "textoverlay text='' valignment=top halignment=right font-desc=\"Monospace, 5\" name=overlay ! queue ! "; +} + +export function buildIdentityChain(includePtsFix = true, includeDropFlags = true): string { + let chain = ""; + if (includePtsFix) { + chain += "identity name=ptsfixup signal-handoffs=TRUE ! "; + } + if (includeDropFlags) { + chain += "identity drop-buffer-flags=GST_BUFFER_FLAG_DROPPABLE ! "; + } + chain += "identity name=v_delay signal-handoffs=TRUE ! "; + return chain; +} + +export function buildAudioPipeline( + hardware: HardwareType, + source: VideoSource, + opts: { + audioDevice?: string; + audioCodec?: AudioCodec; + audioBitrate?: number; + volume?: number; + }, +): string { + const device = opts.audioDevice || getDefaultAudioDevice(hardware, source); + const volume = opts.volume ?? 1.0; + const codec = opts.audioCodec || "aac"; + const bitrate = opts.audioBitrate || 128000; + + let encoderPipeline: string; + if (codec === "opus") { + encoderPipeline = `audioresample quality=10 sinc-filter-mode=1 ! opusenc bitrate=${bitrate} ! opusparse !`; + } else { + // AAC - use avenc_aac for generic, voaacenc for hardware platforms + if (hardware === "generic") { + encoderPipeline = `audioconvert ! avenc_aac bitrate=${bitrate + 3072} ! aacparse !`; + } else { + encoderPipeline = `audioconvert ! voaacenc bitrate=${bitrate} ! aacparse !`; + } + } + + return `alsasrc device=${device} ! identity name=a_delay signal-handoffs=TRUE ! volume volume=${volume} ! ${encoderPipeline} queue max-size-time=10000000000 max-size-buffers=1000 ! mux. `; +} + +export function buildTestAudioPipeline(codec: AudioCodec = "aac", bitrate = 128000): string { + if (codec === "opus") { + return `audiotestsrc ! audio/x-raw,channels=2,rate=48000 ! audioresample quality=10 sinc-filter-mode=1 ! opusenc bitrate=${bitrate} ! opusparse ! queue max-size-time=10000000000 max-size-buffers=1000 ! mux. `; + } + return `audiotestsrc ! audio/x-raw,channels=2,rate=48000 ! voaacenc bitrate=${bitrate} ! aacparse ! queue max-size-time=10000000000 max-size-buffers=1000 ! mux. `; +} + +export function buildMuxAndSink(): string { + return "mpegtsmux name=mux ! appsink name=appsink"; +} + +export function buildVideoQueue(): string { + return "queue max-size-time=10000000000 max-size-buffers=1000 max-size-bytes=41943040 ! mux. "; +} + +export function getResolutionDims(resolution: Resolution): { width: number; height: number } { + return RESOLUTION_DIMS[resolution]; +} + +export function calculateGop(framerate: Framerate): number { + // GOP = 2 seconds worth of frames + return Math.round(framerate * 2); +} diff --git a/bindings/typescript/src/pipeline/generic-builder.ts b/bindings/typescript/src/pipeline/generic-builder.ts new file mode 100644 index 0000000..58372b0 --- /dev/null +++ b/bindings/typescript/src/pipeline/generic-builder.ts @@ -0,0 +1,123 @@ +import type { HardwareBuilder, PipelineOverrides, SourceMeta, VideoSource, X264Preset } from "./types.js"; +import { + buildAudioPipeline, + buildMuxAndSink, + buildOverlay, + buildTestAudioPipeline, + buildVideoRateFilter, + calculateGop, + getResolutionDims, +} from "./common.js"; + +const SUPPORTED_SOURCES: SourceMeta[] = [ + { + source: "camlink", + description: "Elgato Cam Link 4K (software x264 encoding)", + defaultResolution: "1080p", + defaultFramerate: 30, + supportsAudio: true, + supportsResolutionOverride: false, + supportsFramerateOverride: false, + }, + { + source: "v4l_mjpeg", + description: "USB MJPEG capture card (software x264 encoding)", + defaultResolution: "1080p", + defaultFramerate: 30, + supportsAudio: true, + supportsResolutionOverride: true, + supportsFramerateOverride: true, + }, + { + source: "test", + description: "Test pattern (no capture device required)", + defaultResolution: "1080p", + defaultFramerate: 30, + supportsAudio: true, + supportsResolutionOverride: true, + supportsFramerateOverride: true, + }, +]; + +// x264 speed preset values +const X264_PRESET_MAP: Record = { + superfast: 2, + veryfast: 3, + fast: 4, + medium: 5, +}; + +function buildX264Encoder(opts: PipelineOverrides): string { + const preset = opts.x264Preset || "superfast"; + const presetValue = X264_PRESET_MAP[preset]; + const gop = calculateGop(opts.framerate || 30); + return `x264enc speed-preset=${presetValue} key-int-max=${gop} name=venc_kbps ! h264parse config-interval=-1 ! queue max-size-time=10000000000 max-size-buffers=1000 max-size-bytes=41943040 ! mux. `; +} + +function buildCamlinkPipeline(opts: PipelineOverrides): string { + const overlay = buildOverlay(opts.bitrateOverlay); + + let pipeline = "v4l2src ! identity name=v_delay signal-handoffs=TRUE ! "; + pipeline += overlay; + pipeline += "videoconvert ! "; + pipeline += buildX264Encoder(opts); + pipeline += buildAudioPipeline("generic", "camlink", opts); + pipeline += buildMuxAndSink(); + + return pipeline; +} + +function buildV4lMjpegPipeline(opts: PipelineOverrides): string { + const resolution = opts.resolution || "1080p"; + const dims = getResolutionDims(resolution); + const fps = buildVideoRateFilter(opts.framerate); + const overlay = buildOverlay(opts.bitrateOverlay); + + let pipeline = `v4l2src ! image/jpeg,width=${dims.width},height=${dims.height} ! `; + pipeline += "jpegdec ! identity name=v_delay signal-handoffs=TRUE ! "; + pipeline += fps; + pipeline += overlay; + pipeline += "videoconvert ! "; + pipeline += buildX264Encoder(opts); + pipeline += buildAudioPipeline("generic", "v4l_mjpeg", opts); + pipeline += buildMuxAndSink(); + + return pipeline; +} + +function buildTestPipeline(opts: PipelineOverrides): string { + const resolution = opts.resolution || "1080p"; + const dims = getResolutionDims(resolution); + const framerate = opts.framerate || 30; + const overlay = buildOverlay(opts.bitrateOverlay); + + let pipeline = `videotestsrc ! video/x-raw,width=${dims.width},height=${dims.height},framerate=${framerate}/1 ! queue ! `; + pipeline += overlay; + pipeline += "videoconvert ! "; + pipeline += buildX264Encoder(opts); + pipeline += buildTestAudioPipeline(opts.audioCodec, opts.audioBitrate); + pipeline += buildMuxAndSink(); + + return pipeline; +} + +export const genericBuilder: HardwareBuilder = { + hardware: "generic", + + getSupportedSources(): SourceMeta[] { + return SUPPORTED_SOURCES; + }, + + buildPipeline(source: VideoSource, overrides: PipelineOverrides): string { + switch (source) { + case "camlink": + return buildCamlinkPipeline(overrides); + case "v4l_mjpeg": + return buildV4lMjpegPipeline(overrides); + case "test": + return buildTestPipeline(overrides); + default: + throw new Error(`Generic does not support source: ${source}`); + } + }, +}; diff --git a/bindings/typescript/src/pipeline/index.test.ts b/bindings/typescript/src/pipeline/index.test.ts new file mode 100644 index 0000000..1ff9845 --- /dev/null +++ b/bindings/typescript/src/pipeline/index.test.ts @@ -0,0 +1,279 @@ +import { describe, it, expect } from "bun:test"; +import { PipelineBuilder } from "./index.js"; + +describe("PipelineBuilder", () => { + describe("listHardwareTypes", () => { + it("returns all hardware types", () => { + const types = PipelineBuilder.listHardwareTypes(); + expect(types).toContain("jetson"); + expect(types).toContain("rk3588"); + expect(types).toContain("n100"); + expect(types).toContain("generic"); + }); + }); + + describe("listSources", () => { + it("returns sources for jetson", () => { + const sources = PipelineBuilder.listSources("jetson"); + expect(sources.length).toBeGreaterThan(0); + expect(sources.some((s) => s.source === "camlink")).toBe(true); + expect(sources.some((s) => s.source === "libuvch264")).toBe(true); + expect(sources.some((s) => s.source === "rtmp")).toBe(true); + }); + + it("returns sources for rk3588", () => { + const sources = PipelineBuilder.listSources("rk3588"); + expect(sources.some((s) => s.source === "hdmi")).toBe(true); + expect(sources.some((s) => s.source === "usb_mjpeg")).toBe(true); + }); + + it("returns sources for n100", () => { + const sources = PipelineBuilder.listSources("n100"); + expect(sources.some((s) => s.source === "decklink")).toBe(true); + expect(sources.some((s) => s.source === "libuvch264")).toBe(true); + }); + + it("returns sources for generic", () => { + const sources = PipelineBuilder.listSources("generic"); + expect(sources.some((s) => s.source === "camlink")).toBe(true); + expect(sources.some((s) => s.source === "v4l_mjpeg")).toBe(true); + }); + }); + + describe("supportsSource", () => { + it("returns true for supported source", () => { + expect(PipelineBuilder.supportsSource("jetson", "camlink")).toBe(true); + expect(PipelineBuilder.supportsSource("rk3588", "hdmi")).toBe(true); + }); + + it("returns false for unsupported source", () => { + expect(PipelineBuilder.supportsSource("jetson", "decklink")).toBe(false); + expect(PipelineBuilder.supportsSource("generic", "hdmi")).toBe(false); + }); + }); + + describe("buildPipeline", () => { + describe("Jetson", () => { + it("builds camlink pipeline", () => { + const result = PipelineBuilder.build({ + hardware: "jetson", + source: "camlink", + }); + expect(result.pipeline).toContain("v4l2src"); + expect(result.pipeline).toContain("nvv4l2h265enc"); + expect(result.pipeline).toContain("name=venc_bps"); + expect(result.pipeline).toContain("name=appsink"); + expect(result.hardware).toBe("jetson"); + expect(result.source).toBe("camlink"); + }); + + it("builds libuvch264 pipeline", () => { + const result = PipelineBuilder.build({ + hardware: "jetson", + source: "libuvch264", + }); + expect(result.pipeline).toContain("libuvch264src"); + expect(result.pipeline).toContain("nvv4l2decoder"); + expect(result.pipeline).toContain("nvv4l2h265enc"); + }); + + it("builds rtmp pipeline", () => { + const result = PipelineBuilder.build({ + hardware: "jetson", + source: "rtmp", + }); + expect(result.pipeline).toContain("rtmpsrc"); + expect(result.pipeline).toContain("flvdemux"); + }); + + it("builds test pipeline", () => { + const result = PipelineBuilder.build({ + hardware: "jetson", + source: "test", + }); + expect(result.pipeline).toContain("videotestsrc"); + expect(result.pipeline).toContain("audiotestsrc"); + }); + }); + + describe("RK3588", () => { + it("builds hdmi pipeline", () => { + const result = PipelineBuilder.build({ + hardware: "rk3588", + source: "hdmi", + }); + expect(result.pipeline).toContain("v4l2src device=/dev/hdmirx"); + expect(result.pipeline).toContain("mpph265enc"); + expect(result.pipeline).toContain("name=venc_bps"); + }); + + it("builds usb_mjpeg pipeline", () => { + const result = PipelineBuilder.build({ + hardware: "rk3588", + source: "usb_mjpeg", + }); + expect(result.pipeline).toContain("image/jpeg"); + expect(result.pipeline).toContain("jpegdec"); + expect(result.pipeline).toContain("mpph265enc"); + }); + }); + + describe("N100", () => { + it("builds libuvch264 pipeline", () => { + const result = PipelineBuilder.build({ + hardware: "n100", + source: "libuvch264", + }); + expect(result.pipeline).toContain("libuvch264src"); + expect(result.pipeline).toContain("qsvh264dec"); + expect(result.pipeline).toContain("qsvh265enc"); + expect(result.pipeline).toContain("name=venc_kbps"); + }); + + it("builds decklink pipeline", () => { + const result = PipelineBuilder.build({ + hardware: "n100", + source: "decklink", + }); + expect(result.pipeline).toContain("decklinkvideosrc"); + expect(result.pipeline).toContain("vapostproc"); + expect(result.pipeline).toContain("qsvh265enc"); + }); + }); + + describe("Generic", () => { + it("builds camlink pipeline with x264", () => { + const result = PipelineBuilder.build({ + hardware: "generic", + source: "camlink", + }); + expect(result.pipeline).toContain("v4l2src"); + expect(result.pipeline).toContain("x264enc"); + expect(result.pipeline).toContain("name=venc_kbps"); + }); + + it("applies x264 preset override", () => { + const result = PipelineBuilder.build({ + hardware: "generic", + source: "camlink", + overrides: { x264Preset: "veryfast" }, + }); + expect(result.pipeline).toContain("speed-preset=3"); + }); + }); + + describe("Overrides", () => { + it("applies resolution override", () => { + const result = PipelineBuilder.build({ + hardware: "jetson", + source: "camlink", + overrides: { resolution: "720p" }, + }); + expect(result.pipeline).toContain("width=1280,height=720"); + }); + + it("applies framerate override", () => { + const result = PipelineBuilder.build({ + hardware: "jetson", + source: "camlink", + overrides: { framerate: 60 }, + }); + expect(result.pipeline).toContain("framerate=60/1"); + }); + + it("disables overlay when requested", () => { + const result = PipelineBuilder.build({ + hardware: "jetson", + source: "camlink", + overrides: { bitrateOverlay: false }, + }); + expect(result.pipeline).not.toContain("textoverlay"); + }); + + it("applies audio codec override", () => { + const result = PipelineBuilder.build({ + hardware: "jetson", + source: "camlink", + overrides: { audioCodec: "opus" }, + }); + expect(result.pipeline).toContain("opusenc"); + }); + + it("applies audio device override", () => { + const result = PipelineBuilder.build({ + hardware: "jetson", + source: "camlink", + overrides: { audioDevice: "hw:5" }, + }); + expect(result.pipeline).toContain("alsasrc device=hw:5"); + }); + + it("applies volume override", () => { + const result = PipelineBuilder.build({ + hardware: "jetson", + source: "camlink", + overrides: { volume: 0.5 }, + }); + expect(result.pipeline).toContain("volume volume=0.5"); + }); + + it("applies rtmp url override", () => { + const result = PipelineBuilder.build({ + hardware: "jetson", + source: "rtmp", + overrides: { rtmpUrl: "rtmp://test.example.com/live" }, + }); + expect(result.pipeline).toContain("rtmp://test.example.com/live"); + }); + + it("applies srt port override", () => { + const result = PipelineBuilder.build({ + hardware: "jetson", + source: "srt", + overrides: { srtPort: 5000 }, + }); + expect(result.pipeline).toContain("srt://:5000"); + }); + }); + + describe("Validation", () => { + it("throws for unsupported source", () => { + expect(() => + PipelineBuilder.build({ + hardware: "generic", + source: "hdmi", + }), + ).toThrow("does not support source"); + }); + + it("throws for unknown hardware", () => { + expect(() => + PipelineBuilder.build({ + hardware: "unknown" as any, + source: "camlink", + }), + ).toThrow(); + }); + }); + + describe("File output", () => { + it("writes to file when specified", () => { + const fs = require("node:fs"); + const tmpPath = `/tmp/test_pipeline_${Date.now()}.txt`; + + const result = PipelineBuilder.build({ + hardware: "jetson", + source: "camlink", + writeTo: tmpPath, + }); + + expect(result.path).toBe(tmpPath); + expect(fs.existsSync(tmpPath)).toBe(true); + expect(fs.readFileSync(tmpPath, "utf-8")).toBe(result.pipeline); + + // Cleanup + fs.unlinkSync(tmpPath); + }); + }); + }); +}); diff --git a/bindings/typescript/src/pipeline/index.ts b/bindings/typescript/src/pipeline/index.ts new file mode 100644 index 0000000..256767e --- /dev/null +++ b/bindings/typescript/src/pipeline/index.ts @@ -0,0 +1,156 @@ +import fs from "node:fs"; +import type { + BuildPipelineRequest, + BuildPipelineResult, + HardwareBuilder, + HardwareType, + PipelineOverrides, + SourceMeta, + VideoSource, +} from "./types.js"; +import { jetsonBuilder } from "./jetson-builder.js"; +import { rk3588Builder } from "./rk3588-builder.js"; +import { n100Builder } from "./n100-builder.js"; +import { genericBuilder } from "./generic-builder.js"; + +// Re-export types +export * from "./types.js"; + +// Registry of all hardware builders +const BUILDERS: Record = { + jetson: jetsonBuilder, + rk3588: rk3588Builder, + n100: n100Builder, + generic: genericBuilder, +}; + +/** + * Pipeline Builder - single entry point for building GStreamer pipelines + * + * Supports multiple hardware platforms with hardware-specific encoders: + * - Jetson: NVIDIA nvv4l2h265enc + * - RK3588: Rockchip mpph265enc + * - N100: Intel QuickSync qsvh265enc + * - Generic: Software x264enc + */ +export class PipelineBuilder { + /** + * List all supported hardware types + */ + static listHardwareTypes(): HardwareType[] { + return Object.keys(BUILDERS) as HardwareType[]; + } + + /** + * List available video sources for a specific hardware type + */ + static listSources(hardware: HardwareType): SourceMeta[] { + const builder = BUILDERS[hardware]; + if (!builder) { + throw new Error(`Unknown hardware type: ${hardware}`); + } + return builder.getSupportedSources(); + } + + /** + * Check if a hardware type supports a specific source + */ + static supportsSource(hardware: HardwareType, source: VideoSource): boolean { + const sources = PipelineBuilder.listSources(hardware); + return sources.some((s) => s.source === source); + } + + /** + * Get metadata for a specific source on a hardware type + */ + static getSourceMeta(hardware: HardwareType, source: VideoSource): SourceMeta | undefined { + const sources = PipelineBuilder.listSources(hardware); + return sources.find((s) => s.source === source); + } + + /** + * Build a GStreamer pipeline + * + * @param request - Pipeline build request with hardware, source, and optional overrides + * @returns Pipeline string, optional file path, and metadata + * @throws Error if hardware or source is not supported + * + * @example + * ```typescript + * const result = PipelineBuilder.build({ + * hardware: "jetson", + * source: "camlink", + * overrides: { resolution: "1080p", framerate: 30 } + * }); + * console.log(result.pipeline); + * ``` + */ + static build(request: BuildPipelineRequest): BuildPipelineResult { + const { hardware, source, overrides = {}, writeTo } = request; + + // Validate hardware and source + if (!PipelineBuilder.supportsSource(hardware, source)) { + const supportedSources = PipelineBuilder.listSources(hardware).map((s) => s.source); + throw new Error( + `Hardware '${hardware}' does not support source '${source}'. ` + + `Supported sources: ${supportedSources.join(", ")}`, + ); + } + + const builder = BUILDERS[hardware]; + const meta = PipelineBuilder.getSourceMeta(hardware, source); + if (!meta) { + throw new Error(`Source metadata not found for ${hardware}/${source}`); + } + + // Apply default values from metadata if not specified + const effectiveOverrides: PipelineOverrides = { + ...overrides, + resolution: overrides.resolution ?? meta.defaultResolution, + framerate: overrides.framerate ?? meta.defaultFramerate, + bitrateOverlay: overrides.bitrateOverlay ?? true, + }; + + // Build the pipeline + const pipeline = builder.buildPipeline(source, effectiveOverrides); + + // Validate required elements + PipelineBuilder.validate(pipeline); + + // Optionally write to file + let path: string | undefined; + if (writeTo) { + fs.writeFileSync(writeTo, pipeline); + path = writeTo; + } + + return { + pipeline, + path, + hardware, + source, + meta, + }; + } + + /** + * Validate that a pipeline contains required elements + */ + private static validate(pipeline: string): void { + if (!pipeline.includes("name=appsink")) { + throw new Error("Pipeline must contain appsink with name=appsink"); + } + if (!pipeline.includes("name=venc_bps") && !pipeline.includes("name=venc_kbps")) { + throw new Error("Pipeline must contain encoder with name=venc_bps or name=venc_kbps"); + } + if (!pipeline.includes("mux")) { + throw new Error("Pipeline must contain mpegtsmux with name=mux"); + } + } +} + +// Re-export individual builders for advanced use cases +export { jetsonBuilder } from "./jetson-builder.js"; +export { rk3588Builder } from "./rk3588-builder.js"; +export { n100Builder } from "./n100-builder.js"; +export { genericBuilder } from "./generic-builder.js"; diff --git a/bindings/typescript/src/pipeline/jetson-builder.ts b/bindings/typescript/src/pipeline/jetson-builder.ts new file mode 100644 index 0000000..2ed8f0e --- /dev/null +++ b/bindings/typescript/src/pipeline/jetson-builder.ts @@ -0,0 +1,209 @@ +import type { HardwareBuilder, PipelineOverrides, SourceMeta, VideoSource } from "./types.js"; +import { + buildAudioPipeline, + buildIdentityChain, + buildMuxAndSink, + buildOverlay, + buildTestAudioPipeline, + buildVideoQueue, + buildVideoRateFilter, + calculateGop, + getResolutionDims, +} from "./common.js"; + +const SUPPORTED_SOURCES: SourceMeta[] = [ + { + source: "camlink", + description: "Elgato Cam Link 4K (uncompressed YUY2)", + defaultResolution: "1080p", + defaultFramerate: 30, + supportsAudio: true, + supportsResolutionOverride: true, + supportsFramerateOverride: true, + }, + { + source: "libuvch264", + description: "UVC H264 camera (hardware compressed)", + defaultResolution: "1080p", + defaultFramerate: 30, + supportsAudio: true, + supportsResolutionOverride: true, + supportsFramerateOverride: true, + }, + { + source: "v4l_mjpeg", + description: "USB MJPEG capture card", + defaultResolution: "1080p", + defaultFramerate: 30, + supportsAudio: true, + supportsResolutionOverride: true, + supportsFramerateOverride: true, + }, + { + source: "rtmp", + description: "RTMP ingest from local server", + defaultResolution: "1080p", + defaultFramerate: 25, + supportsAudio: true, + supportsResolutionOverride: false, + supportsFramerateOverride: true, + }, + { + source: "srt", + description: "SRT ingest on port 4000", + defaultResolution: "1080p", + defaultFramerate: 30, + supportsAudio: true, + supportsResolutionOverride: false, + supportsFramerateOverride: true, + }, + { + source: "test", + description: "Test pattern (no capture device required)", + defaultResolution: "1080p", + defaultFramerate: 30, + supportsAudio: true, + supportsResolutionOverride: true, + supportsFramerateOverride: true, + }, +]; + +function buildJetsonEncoder(opts: PipelineOverrides): string { + const gop = calculateGop(opts.framerate || 30); + return `nvv4l2h265enc control-rate=1 qp-range="28,50:0,36:0,50" iframeinterval=${gop} preset-level=4 maxperf-enable=true EnableTwopassCBR=true insert-sps-pps=true name=venc_bps ! h265parse config-interval=-1 ! ${buildVideoQueue()}`; +} + +function buildCamlinkPipeline(opts: PipelineOverrides): string { + const resolution = opts.resolution || "1080p"; + const dims = getResolutionDims(resolution); + const fps = buildVideoRateFilter(opts.framerate); + const overlay = buildOverlay(opts.bitrateOverlay); + + let pipeline = `v4l2src ! ${buildIdentityChain()}`; + pipeline += fps; + pipeline += overlay; + pipeline += `nvvidconv interpolation-method=5 ! video/x-raw(memory:NVMM),width=${dims.width},height=${dims.height} ! `; + pipeline += buildJetsonEncoder(opts); + pipeline += buildAudioPipeline("jetson", "camlink", opts); + pipeline += buildMuxAndSink(); + + return pipeline; +} + +function buildLibuvch264Pipeline(opts: PipelineOverrides): string { + const resolution = opts.resolution || "1080p"; + const dims = getResolutionDims(resolution); + const framerate = opts.framerate || 30; + const overlay = buildOverlay(opts.bitrateOverlay); + + let pipeline = `libuvch264src ! video/x-h264,width=${dims.width},height=${dims.height},framerate=${framerate}/1 ! `; + pipeline += "queue max-size-time=10000000000 max-size-buffers=1000 max-size-bytes=41943040 ! nvv4l2decoder ! nvvidconv interpolation-method=5 ! "; + pipeline += "identity name=v_delay signal-handoffs=TRUE ! "; + pipeline += overlay; + pipeline += "nvvidconv interpolation-method=5 ! "; + pipeline += buildJetsonEncoder(opts); + pipeline += buildAudioPipeline("jetson", "libuvch264", opts); + pipeline += buildMuxAndSink(); + + return pipeline; +} + +function buildV4lMjpegPipeline(opts: PipelineOverrides): string { + const resolution = opts.resolution || "1080p"; + const dims = getResolutionDims(resolution); + const fps = buildVideoRateFilter(opts.framerate); + const overlay = buildOverlay(opts.bitrateOverlay); + + let pipeline = `v4l2src ! image/jpeg,width=${dims.width},height=${dims.height} ! `; + pipeline += "identity name=v_delay signal-handoffs=TRUE ! "; + pipeline += "nvv4l2decoder mjpeg=1 enable-max-performance=true ! nvvidconv ! "; + pipeline += fps; + pipeline += overlay; + pipeline += "nvvidconv interpolation-method=5 ! "; + pipeline += buildJetsonEncoder(opts); + pipeline += buildAudioPipeline("jetson", "v4l_mjpeg", opts); + pipeline += buildMuxAndSink(); + + return pipeline; +} + +function buildRtmpPipeline(opts: PipelineOverrides): string { + const url = opts.rtmpUrl || "rtmp://127.0.0.1/publish/live"; + const fps = buildVideoRateFilter(opts.framerate || 25); + const overlay = buildOverlay(opts.bitrateOverlay); + + let pipeline = `rtmpsrc location=${url} ! flvdemux name=demux `; + pipeline += "demux.video ! identity name=v_delay signal-handoffs=TRUE ! h264parse ! nvv4l2decoder ! nvvidconv ! "; + pipeline += fps; + pipeline += overlay; + pipeline += "nvvidconv interpolation-method=5 ! "; + pipeline += buildJetsonEncoder(opts); + // RTMP audio comes from demuxer + const volume = opts.volume ?? 1.0; + pipeline += `demux.audio ! aacparse ! avdec_aac ! identity name=a_delay signal-handoffs=TRUE ! volume volume=${volume} ! audioconvert ! voaacenc bitrate=128000 ! aacparse ! queue max-size-time=10000000000 max-size-buffers=1000 ! mux. `; + pipeline += buildMuxAndSink(); + + return pipeline; +} + +function buildSrtPipeline(opts: PipelineOverrides): string { + const port = opts.srtPort || 4000; + const fps = buildVideoRateFilter(opts.framerate || 30); + const overlay = buildOverlay(opts.bitrateOverlay); + + let pipeline = `srtsrc uri=srt://:${port} ! tsdemux name=demux `; + pipeline += "demux.video ! identity name=v_delay signal-handoffs=TRUE ! h264parse ! nvv4l2decoder ! nvvidconv ! "; + pipeline += fps; + pipeline += overlay; + pipeline += "nvvidconv interpolation-method=5 ! "; + pipeline += buildJetsonEncoder(opts); + // SRT audio comes from demuxer + const volume = opts.volume ?? 1.0; + pipeline += `demux.audio ! aacparse ! avdec_aac ! identity name=a_delay signal-handoffs=TRUE ! volume volume=${volume} ! audioconvert ! voaacenc bitrate=128000 ! aacparse ! queue max-size-time=10000000000 max-size-buffers=1000 ! mux. `; + pipeline += buildMuxAndSink(); + + return pipeline; +} + +function buildTestPipeline(opts: PipelineOverrides): string { + const resolution = opts.resolution || "1080p"; + const dims = getResolutionDims(resolution); + const framerate = opts.framerate || 30; + const overlay = buildOverlay(opts.bitrateOverlay); + + let pipeline = `videotestsrc ! video/x-raw,width=${dims.width},height=${dims.height},framerate=${framerate}/1 ! queue ! `; + pipeline += overlay; + pipeline += "nvvidconv interpolation-method=5 ! "; + pipeline += buildJetsonEncoder(opts); + pipeline += buildTestAudioPipeline(opts.audioCodec, opts.audioBitrate); + pipeline += buildMuxAndSink(); + + return pipeline; +} + +export const jetsonBuilder: HardwareBuilder = { + hardware: "jetson", + + getSupportedSources(): SourceMeta[] { + return SUPPORTED_SOURCES; + }, + + buildPipeline(source: VideoSource, overrides: PipelineOverrides): string { + switch (source) { + case "camlink": + return buildCamlinkPipeline(overrides); + case "libuvch264": + return buildLibuvch264Pipeline(overrides); + case "v4l_mjpeg": + return buildV4lMjpegPipeline(overrides); + case "rtmp": + return buildRtmpPipeline(overrides); + case "srt": + return buildSrtPipeline(overrides); + case "test": + return buildTestPipeline(overrides); + default: + throw new Error(`Jetson does not support source: ${source}`); + } + }, +}; diff --git a/bindings/typescript/src/pipeline/n100-builder.ts b/bindings/typescript/src/pipeline/n100-builder.ts new file mode 100644 index 0000000..5ce2017 --- /dev/null +++ b/bindings/typescript/src/pipeline/n100-builder.ts @@ -0,0 +1,184 @@ +import type { HardwareBuilder, PipelineOverrides, SourceMeta, VideoSource } from "./types.js"; +import { + buildAudioPipeline, + buildIdentityChain, + buildMuxAndSink, + buildOverlay, + buildTestAudioPipeline, + buildVideoQueue, + buildVideoRateFilter, + calculateGop, + getResolutionDims, +} from "./common.js"; + +const SUPPORTED_SOURCES: SourceMeta[] = [ + { + source: "libuvch264", + description: "UVC H264 camera (hardware compressed)", + defaultResolution: "1080p", + defaultFramerate: 30, + supportsAudio: true, + supportsResolutionOverride: true, + supportsFramerateOverride: true, + }, + { + source: "v4l_mjpeg", + description: "USB MJPEG capture card", + defaultResolution: "1080p", + defaultFramerate: 30, + supportsAudio: true, + supportsResolutionOverride: true, + supportsFramerateOverride: true, + }, + { + source: "decklink", + description: "Blackmagic Decklink SDI capture", + defaultResolution: "1080p", + defaultFramerate: 50, + supportsAudio: true, + supportsResolutionOverride: false, + supportsFramerateOverride: true, + }, + { + source: "rtmp", + description: "RTMP ingest from local server", + defaultResolution: "1080p", + defaultFramerate: 30, + supportsAudio: true, + supportsResolutionOverride: false, + supportsFramerateOverride: true, + }, + { + source: "test", + description: "Test pattern (no capture device required)", + defaultResolution: "1080p", + defaultFramerate: 30, + supportsAudio: true, + supportsResolutionOverride: true, + supportsFramerateOverride: true, + }, +]; + +function buildN100Encoder(opts: PipelineOverrides): string { + const gop = calculateGop(opts.framerate || 30); + return `qsvh265enc gop-size=${gop} rate-control=1 target-usage=7 low-latency=true name=venc_kbps ! h265parse config-interval=-1 ! ${buildVideoQueue()}`; +} + +function buildLibuvch264Pipeline(opts: PipelineOverrides): string { + const resolution = opts.resolution || "1080p"; + const dims = getResolutionDims(resolution); + const framerate = opts.framerate || 30; + const overlay = buildOverlay(opts.bitrateOverlay); + + let pipeline = `libuvch264src ! video/x-h264,width=${dims.width},height=${dims.height},framerate=${framerate}/1,profile=high ! `; + pipeline += "identity name=ptsfixup signal-handoffs=TRUE ! "; + pipeline += "queue max-size-time=10000000000 max-size-buffers=1000 max-size-bytes=41943040 ! h264parse ! qsvh264dec ! "; + pipeline += "identity name=v_delay signal-handoffs=TRUE ! "; + pipeline += "video/x-raw,format=NV12 ! videoconvert ! "; + pipeline += overlay; + pipeline += buildN100Encoder(opts); + pipeline += buildAudioPipeline("n100", "libuvch264", opts); + pipeline += buildMuxAndSink(); + + return pipeline; +} + +function buildV4lMjpegPipeline(opts: PipelineOverrides): string { + const resolution = opts.resolution || "1080p"; + const dims = getResolutionDims(resolution); + const fps = buildVideoRateFilter(opts.framerate); + const overlay = buildOverlay(opts.bitrateOverlay); + + let pipeline = `v4l2src ! image/jpeg,width=${dims.width},height=${dims.height} ! `; + pipeline += "jpegdec ! identity name=ptsfixup signal-handoffs=TRUE ! "; + pipeline += "identity name=v_delay signal-handoffs=TRUE ! "; + pipeline += fps; + pipeline += "videoconvert ! video/x-raw,format=NV12 ! "; + pipeline += overlay; + pipeline += buildN100Encoder(opts); + pipeline += buildAudioPipeline("n100", "v4l_mjpeg", opts); + pipeline += buildMuxAndSink(); + + return pipeline; +} + +function buildDecklinkPipeline(opts: PipelineOverrides): string { + const framerate = opts.framerate || 50; + const overlay = buildOverlay(opts.bitrateOverlay); + + // Decklink mode 14 = 1080p50 + let mode = 14; + if (framerate === 25) mode = 13; + if (framerate === 30) mode = 11; + if (framerate === 60) mode = 15; + + let pipeline = `decklinkvideosrc device-number=0 connection=sdi mode=${mode} video-format=8bit-yuv ! `; + pipeline += buildIdentityChain(); + pipeline += "videoconvert ! video/x-raw,format=I420 ! vapostproc ! video/x-raw,format=NV12 ! "; + pipeline += overlay; + pipeline += buildN100Encoder(opts); + pipeline += buildAudioPipeline("n100", "decklink", opts); + pipeline += buildMuxAndSink(); + + return pipeline; +} + +function buildRtmpPipeline(opts: PipelineOverrides): string { + const url = opts.rtmpUrl || "rtmp://127.0.0.1/publish/live"; + const fps = buildVideoRateFilter(opts.framerate || 30); + const overlay = buildOverlay(opts.bitrateOverlay); + + let pipeline = `rtmpsrc location=${url} ! flvdemux name=demux `; + pipeline += "demux.video ! identity name=v_delay signal-handoffs=TRUE ! h264parse ! qsvh264dec ! "; + pipeline += fps; + pipeline += "videoconvert ! video/x-raw,format=NV12 ! "; + pipeline += overlay; + pipeline += buildN100Encoder(opts); + // RTMP audio comes from demuxer + const volume = opts.volume ?? 1.0; + pipeline += `demux.audio ! aacparse ! avdec_aac ! identity name=a_delay signal-handoffs=TRUE ! volume volume=${volume} ! audioconvert ! voaacenc bitrate=128000 ! aacparse ! queue max-size-time=10000000000 max-size-buffers=1000 ! mux. `; + pipeline += buildMuxAndSink(); + + return pipeline; +} + +function buildTestPipeline(opts: PipelineOverrides): string { + const resolution = opts.resolution || "1080p"; + const dims = getResolutionDims(resolution); + const framerate = opts.framerate || 30; + const overlay = buildOverlay(opts.bitrateOverlay); + + let pipeline = `videotestsrc ! video/x-raw,width=${dims.width},height=${dims.height},framerate=${framerate}/1 ! queue ! `; + pipeline += "videoconvert ! video/x-raw,format=NV12 ! "; + pipeline += overlay; + pipeline += buildN100Encoder(opts); + pipeline += buildTestAudioPipeline(opts.audioCodec, opts.audioBitrate); + pipeline += buildMuxAndSink(); + + return pipeline; +} + +export const n100Builder: HardwareBuilder = { + hardware: "n100", + + getSupportedSources(): SourceMeta[] { + return SUPPORTED_SOURCES; + }, + + buildPipeline(source: VideoSource, overrides: PipelineOverrides): string { + switch (source) { + case "libuvch264": + return buildLibuvch264Pipeline(overrides); + case "v4l_mjpeg": + return buildV4lMjpegPipeline(overrides); + case "decklink": + return buildDecklinkPipeline(overrides); + case "rtmp": + return buildRtmpPipeline(overrides); + case "test": + return buildTestPipeline(overrides); + default: + throw new Error(`N100 does not support source: ${source}`); + } + }, +}; diff --git a/bindings/typescript/src/pipeline/rk3588-builder.ts b/bindings/typescript/src/pipeline/rk3588-builder.ts new file mode 100644 index 0000000..fc5126d --- /dev/null +++ b/bindings/typescript/src/pipeline/rk3588-builder.ts @@ -0,0 +1,211 @@ +import type { HardwareBuilder, PipelineOverrides, SourceMeta, VideoSource } from "./types.js"; +import { + buildAudioPipeline, + buildIdentityChain, + buildMuxAndSink, + buildOverlay, + buildTestAudioPipeline, + buildVideoQueue, + buildVideoRateFilter, + calculateGop, + getResolutionDims, +} from "./common.js"; + +const SUPPORTED_SOURCES: SourceMeta[] = [ + { + source: "hdmi", + description: "HDMI capture via /dev/hdmirx", + defaultResolution: "1080p", + defaultFramerate: 30, + supportsAudio: true, + supportsResolutionOverride: true, + supportsFramerateOverride: true, + }, + { + source: "libuvch264", + description: "UVC H264 camera (hardware compressed)", + defaultResolution: "1080p", + defaultFramerate: 30, + supportsAudio: true, + supportsResolutionOverride: true, + supportsFramerateOverride: true, + }, + { + source: "usb_mjpeg", + description: "USB MJPEG capture card", + defaultResolution: "1080p", + defaultFramerate: 30, + supportsAudio: true, + supportsResolutionOverride: true, + supportsFramerateOverride: true, + }, + { + source: "rtmp", + description: "RTMP ingest from local server", + defaultResolution: "1080p", + defaultFramerate: 30, + supportsAudio: true, + supportsResolutionOverride: false, + supportsFramerateOverride: true, + }, + { + source: "srt", + description: "SRT ingest on port 4000", + defaultResolution: "1080p", + defaultFramerate: 30, + supportsAudio: true, + supportsResolutionOverride: false, + supportsFramerateOverride: true, + }, + { + source: "test", + description: "Test pattern (no capture device required)", + defaultResolution: "1080p", + defaultFramerate: 30, + supportsAudio: true, + supportsResolutionOverride: true, + supportsFramerateOverride: true, + }, +]; + +function buildRk3588Encoder(opts: PipelineOverrides): string { + const resolution = opts.resolution || "1080p"; + const dims = getResolutionDims(resolution); + const gop = calculateGop(opts.framerate || 30); + return `mpph265enc zero-copy-pkt=0 qp-max=51 gop=${gop} width=${dims.width} height=${dims.height} name=venc_bps ! h265parse config-interval=-1 ! ${buildVideoQueue()}`; +} + +function buildHdmiPipeline(opts: PipelineOverrides): string { + const device = opts.videoDevice || "/dev/hdmirx"; + const fps = buildVideoRateFilter(opts.framerate); + const overlay = buildOverlay(opts.bitrateOverlay); + + let pipeline = `v4l2src device=${device} ! ${buildIdentityChain()}`; + pipeline += fps; + pipeline += overlay; + pipeline += "queue ! "; + pipeline += buildRk3588Encoder(opts); + pipeline += buildAudioPipeline("rk3588", "hdmi", opts); + pipeline += buildMuxAndSink(); + + return pipeline; +} + +function buildLibuvch264Pipeline(opts: PipelineOverrides): string { + const resolution = opts.resolution || "1080p"; + const dims = getResolutionDims(resolution); + const framerate = opts.framerate || 30; + const fps = buildVideoRateFilter(opts.framerate); + const overlay = buildOverlay(opts.bitrateOverlay); + + let pipeline = `libuvch264src ! video/x-h264,width=${dims.width},height=${dims.height},framerate=${framerate}/1 ! `; + pipeline += "identity name=ptsfixup signal-handoffs=TRUE ! "; + pipeline += "queue max-size-time=10000000000 max-size-buffers=1000 max-size-bytes=41943040 ! h264parse ! mppvideodec ! "; + pipeline += "identity name=v_delay signal-handoffs=TRUE ! "; + pipeline += fps; + pipeline += overlay; + pipeline += buildRk3588Encoder(opts); + pipeline += buildAudioPipeline("rk3588", "libuvch264", opts); + pipeline += buildMuxAndSink(); + + return pipeline; +} + +function buildUsbMjpegPipeline(opts: PipelineOverrides): string { + const resolution = opts.resolution || "1080p"; + const dims = getResolutionDims(resolution); + const fps = buildVideoRateFilter(opts.framerate); + const overlay = buildOverlay(opts.bitrateOverlay); + + let pipeline = `v4l2src ! image/jpeg,width=${dims.width},height=${dims.height} ! `; + pipeline += "jpegdec ! identity name=ptsfixup signal-handoffs=TRUE ! "; + pipeline += "identity name=v_delay signal-handoffs=TRUE ! "; + pipeline += fps; + pipeline += overlay; + pipeline += "queue ! "; + pipeline += buildRk3588Encoder(opts); + pipeline += buildAudioPipeline("rk3588", "usb_mjpeg", opts); + pipeline += buildMuxAndSink(); + + return pipeline; +} + +function buildRtmpPipeline(opts: PipelineOverrides): string { + const url = opts.rtmpUrl || "rtmp://127.0.0.1/publish/live"; + const fps = buildVideoRateFilter(opts.framerate || 30); + const overlay = buildOverlay(opts.bitrateOverlay); + + let pipeline = `rtmpsrc location=${url} ! flvdemux name=demux `; + pipeline += "demux.video ! identity name=v_delay signal-handoffs=TRUE ! h264parse ! mppvideodec ! "; + pipeline += fps; + pipeline += overlay; + pipeline += "queue ! "; + pipeline += buildRk3588Encoder(opts); + // RTMP audio comes from demuxer + const volume = opts.volume ?? 1.0; + pipeline += `demux.audio ! aacparse ! avdec_aac ! identity name=a_delay signal-handoffs=TRUE ! volume volume=${volume} ! audioconvert ! voaacenc bitrate=128000 ! aacparse ! queue max-size-time=10000000000 max-size-buffers=1000 ! mux. `; + pipeline += buildMuxAndSink(); + + return pipeline; +} + +function buildSrtPipeline(opts: PipelineOverrides): string { + const port = opts.srtPort || 4000; + const fps = buildVideoRateFilter(opts.framerate || 30); + const overlay = buildOverlay(opts.bitrateOverlay); + + let pipeline = `srtsrc uri=srt://:${port} ! tsdemux name=demux `; + pipeline += "demux.video ! identity name=v_delay signal-handoffs=TRUE ! h264parse ! mppvideodec ! "; + pipeline += fps; + pipeline += overlay; + pipeline += "queue ! "; + pipeline += buildRk3588Encoder(opts); + // SRT audio comes from demuxer + const volume = opts.volume ?? 1.0; + pipeline += `demux.audio ! aacparse ! avdec_aac ! identity name=a_delay signal-handoffs=TRUE ! volume volume=${volume} ! audioconvert ! voaacenc bitrate=128000 ! aacparse ! queue max-size-time=10000000000 max-size-buffers=1000 ! mux. `; + pipeline += buildMuxAndSink(); + + return pipeline; +} + +function buildTestPipeline(opts: PipelineOverrides): string { + const resolution = opts.resolution || "1080p"; + const dims = getResolutionDims(resolution); + const framerate = opts.framerate || 30; + const overlay = buildOverlay(opts.bitrateOverlay); + + let pipeline = `videotestsrc ! video/x-raw,width=${dims.width},height=${dims.height},framerate=${framerate}/1 ! queue ! `; + pipeline += overlay; + pipeline += buildRk3588Encoder(opts); + pipeline += buildTestAudioPipeline(opts.audioCodec, opts.audioBitrate); + pipeline += buildMuxAndSink(); + + return pipeline; +} + +export const rk3588Builder: HardwareBuilder = { + hardware: "rk3588", + + getSupportedSources(): SourceMeta[] { + return SUPPORTED_SOURCES; + }, + + buildPipeline(source: VideoSource, overrides: PipelineOverrides): string { + switch (source) { + case "hdmi": + return buildHdmiPipeline(overrides); + case "libuvch264": + return buildLibuvch264Pipeline(overrides); + case "usb_mjpeg": + return buildUsbMjpegPipeline(overrides); + case "rtmp": + return buildRtmpPipeline(overrides); + case "srt": + return buildSrtPipeline(overrides); + case "test": + return buildTestPipeline(overrides); + default: + throw new Error(`RK3588 does not support source: ${source}`); + } + }, +}; diff --git a/bindings/typescript/src/pipeline/types.ts b/bindings/typescript/src/pipeline/types.ts new file mode 100644 index 0000000..83af1f9 --- /dev/null +++ b/bindings/typescript/src/pipeline/types.ts @@ -0,0 +1,135 @@ +import { z } from "zod"; + +// Hardware types +export const hardwareTypeSchema = z.enum(["jetson", "n100", "rk3588", "generic"]); +export type HardwareType = z.infer; + +// Video source types - all possible sources across all hardware +export const videoSourceSchema = z.enum([ + "camlink", // Elgato Cam Link 4K (uncompressed YUY2) + "libuvch264", // UVC H264 camera (hardware compressed) + "hdmi", // HDMI capture + "usb_mjpeg", // USB MJPEG capture card + "v4l_mjpeg", // V4L2 MJPEG capture + "rtmp", // RTMP ingest + "srt", // SRT ingest + "test", // Test pattern + "decklink", // Blackmagic Decklink SDI +]); +export type VideoSource = z.infer; + +// Video codec types +export const videoCodecSchema = z.enum(["h264", "h265", "x264"]); +export type VideoCodec = z.infer; + +// Audio codec types +export const audioCodecSchema = z.enum(["aac", "opus"]); +export type AudioCodec = z.infer; + +// Audio codec lookup for validation +export const AUDIO_CODECS: Record = { + aac: { name: "AAC" }, + opus: { name: "Opus" }, +}; + +// Video source labels (for UI display when translations not available) +export const VIDEO_SOURCE_LABELS: Record = { + camlink: "Cam Link 4K", + libuvch264: "UVC H264 Camera", + hdmi: "HDMI Capture", + usb_mjpeg: "USB MJPEG", + v4l_mjpeg: "V4L2 MJPEG", + rtmp: "RTMP Ingest", + srt: "SRT Ingest", + test: "Test Pattern", + decklink: "Decklink SDI", +}; + +// Hardware type labels (for UI display when translations not available) +export const HARDWARE_LABELS: Record = { + jetson: "NVIDIA Jetson", + rk3588: "Rockchip RK3588", + n100: "Intel N100", + generic: "Generic (Software)", +}; + +// Resolution presets +export const resolutionSchema = z.enum(["480p", "720p", "1080p", "1440p", "2160p", "4k"]); +export type Resolution = z.infer; + +// Frame rate values +export const framerateSchema = z.union([ + z.literal(25), + z.literal(29.97), + z.literal(30), + z.literal(50), + z.literal(59.94), + z.literal(60), +]); +export type Framerate = z.infer; + +// x264 preset types +export const x264PresetSchema = z.enum(["superfast", "veryfast", "fast", "medium"]); +export type X264Preset = z.infer; + +// Resolution to dimensions mapping +export const RESOLUTION_DIMS: Record = { + "480p": { width: 854, height: 480 }, + "720p": { width: 1280, height: 720 }, + "1080p": { width: 1920, height: 1080 }, + "1440p": { width: 2560, height: 1440 }, + "2160p": { width: 3840, height: 2160 }, + "4k": { width: 3840, height: 2160 }, +}; + +// Pipeline override options +export const pipelineOverridesSchema = z.object({ + resolution: resolutionSchema.optional(), + framerate: framerateSchema.optional(), + audioDevice: z.string().optional(), + audioCodec: audioCodecSchema.optional(), + audioBitrate: z.number().optional(), + bitrateOverlay: z.boolean().optional(), + videoDevice: z.string().optional(), + volume: z.number().optional(), + x264Preset: x264PresetSchema.optional(), + rtmpUrl: z.string().optional(), + srtPort: z.number().optional(), +}); +export type PipelineOverrides = z.infer; + +// Build pipeline request +export const buildPipelineRequestSchema = z.object({ + hardware: hardwareTypeSchema, + source: videoSourceSchema, + overrides: pipelineOverridesSchema.optional(), + writeTo: z.string().optional(), +}); +export type BuildPipelineRequest = z.infer; + +// Source metadata +export interface SourceMeta { + source: VideoSource; + description: string; + defaultResolution?: Resolution; + defaultFramerate?: Framerate; + supportsAudio: boolean; + supportsResolutionOverride: boolean; + supportsFramerateOverride: boolean; +} + +// Build pipeline result +export interface BuildPipelineResult { + pipeline: string; + path?: string; + hardware: HardwareType; + source: VideoSource; + meta: SourceMeta; +} + +// Hardware builder interface +export interface HardwareBuilder { + readonly hardware: HardwareType; + getSupportedSources(): SourceMeta[]; + buildPipeline(source: VideoSource, overrides: PipelineOverrides): string; +} diff --git a/bindings/typescript/src/process.ts b/bindings/typescript/src/process.ts new file mode 100644 index 0000000..3c22e1f --- /dev/null +++ b/bindings/typescript/src/process.ts @@ -0,0 +1,193 @@ +/** + * Process management for ceracoder + * + * Provides utilities for finding, spawning, and signaling the ceracoder process. + */ + +import { spawn, type ChildProcess, type SpawnOptions } from "node:child_process"; +import fs from "node:fs"; +import path from "node:path"; + +// Default paths +const DEFAULT_EXEC_NAME = "ceracoder"; +const DEFAULT_SYSTEM_PATH = "/usr/bin/ceracoder"; +const DEFAULT_CONFIG_PATH = "/tmp/ceracoder.conf"; +const DEFAULT_PIPELINE_PATH = "/tmp/ceracoder_pipeline"; + +/** + * Options for finding the ceracoder executable + */ +export interface CeracoderPathOptions { + /** + * Explicit path to ceracoder executable or directory containing it. + * If a directory, ceracoder binary is expected inside. + */ + execPath?: string; +} + +/** + * Resolve the full path to the ceracoder executable + * + * Resolution order: + * 1. If execPath is provided and is a file, use it directly + * 2. If execPath is a directory, look for ceracoder inside + * 3. Check if ceracoder is in PATH (returns just "ceracoder") + * 4. Fall back to /usr/bin/ceracoder + */ +export function getCeracoderExec(options: CeracoderPathOptions = {}): string { + const { execPath } = options; + + // If explicit path provided + if (execPath) { + // Check if it's a file (direct path to executable) + if (fs.existsSync(execPath) && fs.statSync(execPath).isFile()) { + return execPath; + } + // Check if it's a directory containing ceracoder + const inDir = path.join(execPath, DEFAULT_EXEC_NAME); + if (fs.existsSync(inDir) && fs.statSync(inDir).isFile()) { + return inDir; + } + // Return as-is (might be in PATH or will fail at spawn time) + return execPath.endsWith(DEFAULT_EXEC_NAME) + ? execPath + : path.join(execPath, DEFAULT_EXEC_NAME); + } + + // Check system path + if (fs.existsSync(DEFAULT_SYSTEM_PATH)) { + return DEFAULT_SYSTEM_PATH; + } + + // Assume it's in PATH + return DEFAULT_EXEC_NAME; +} + +/** + * Default paths used by ceracoder + */ +export const CERACODER_PATHS = { + /** Default config file path */ + config: DEFAULT_CONFIG_PATH, + /** Default pipeline file path */ + pipeline: DEFAULT_PIPELINE_PATH, + /** Default system executable */ + systemExec: DEFAULT_SYSTEM_PATH, +} as const; + +/** + * Options for spawning ceracoder + */ +export interface SpawnCeracoderOptions extends CeracoderPathOptions { + /** Command line arguments */ + args: string[]; + /** Spawn options (stdio, cwd, env, etc.) */ + spawnOptions?: SpawnOptions; +} + +/** + * Spawn a ceracoder process + * + * @param options - Spawn options including args and path + * @returns ChildProcess instance + */ +export function spawnCeracoder(options: SpawnCeracoderOptions): ChildProcess { + const exec = getCeracoderExec(options); + return spawn(exec, options.args, options.spawnOptions ?? {}); +} + +/** + * Options for sending signals to ceracoder + */ +export interface SignalCeracoderOptions { + /** + * Custom killall function. + * By default, uses the system killall command. + * This allows consumers to inject their own implementation. + */ + killall?: (args: string[]) => void | Promise; +} + +/** + * Send SIGHUP to reload ceracoder config + * + * Uses killall -HUP ceracoder by default. + * The config file is re-read when SIGHUP is received. + */ +export async function sendHup(options: SignalCeracoderOptions = {}): Promise { + const { killall } = options; + + if (killall) { + await killall(["-HUP", DEFAULT_EXEC_NAME]); + } else { + // Use system killall + return new Promise((resolve, reject) => { + const proc = spawn("killall", ["-HUP", DEFAULT_EXEC_NAME], { + stdio: "ignore", + }); + proc.on("close", (code) => { + // killall returns 1 if no process found, which is okay + resolve(); + }); + proc.on("error", reject); + }); + } +} + +/** + * Send SIGTERM to gracefully stop ceracoder + */ +export async function sendTerm(options: SignalCeracoderOptions = {}): Promise { + const { killall } = options; + + if (killall) { + await killall([DEFAULT_EXEC_NAME]); + } else { + return new Promise((resolve, reject) => { + const proc = spawn("killall", [DEFAULT_EXEC_NAME], { + stdio: "ignore", + }); + proc.on("close", () => resolve()); + proc.on("error", reject); + }); + } +} + +/** + * Check if ceracoder is currently running + */ +export async function isRunning(): Promise { + return new Promise((resolve) => { + const proc = spawn("pgrep", ["-x", DEFAULT_EXEC_NAME], { + stdio: "ignore", + }); + proc.on("close", (code) => { + resolve(code === 0); + }); + proc.on("error", () => resolve(false)); + }); +} + +/** + * Write config file to disk + */ +export function writeConfig(ini: string, configPath = DEFAULT_CONFIG_PATH): void { + fs.writeFileSync(configPath, ini); +} + +/** + * Check if config file exists + */ +export function configExists(configPath = DEFAULT_CONFIG_PATH): boolean { + return fs.existsSync(configPath); +} + +/** + * Write pipeline file to disk + */ +export function writePipeline( + pipeline: string, + pipelinePath = DEFAULT_PIPELINE_PATH, +): void { + fs.writeFileSync(pipelinePath, pipeline); +} diff --git a/bindings/typescript/src/run.test.ts b/bindings/typescript/src/run.test.ts new file mode 100644 index 0000000..8487eba --- /dev/null +++ b/bindings/typescript/src/run.test.ts @@ -0,0 +1,87 @@ +import { describe, it, expect } from "bun:test"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; + +import { buildCeracoderRunArtifacts } from "./run.js"; +import { serializeCeracoderConfig } from "./config.js"; +import { DEFAULT_ADAPTIVE, DEFAULT_AIMD } from "./constants.js"; + +function tmpFile(contents: string) { + const p = path.join(os.tmpdir(), `ceracoder_test_${Date.now()}_${Math.random()}.conf`); + fs.writeFileSync(p, contents); + return p; +} + +describe("buildCeracoderRunArtifacts", () => { + it("merges with existing config when fullOverride=false", () => { + const existingIni = serializeCeracoderConfig({ + general: { min_bitrate: 400, max_bitrate: 5000, balancer: "adaptive" }, + srt: { latency: 1500 }, + adaptive: { ...DEFAULT_ADAPTIVE, incr_step: 10 }, + aimd: undefined, + }); + const cfgPath = tmpFile(existingIni); + + const { config } = buildCeracoderRunArtifacts({ + pipelineFile: "p", + host: "h", + port: 1, + configFile: cfgPath, + config: { general: { max_bitrate: 6000 } }, + fullOverride: false, + }); + + expect(config.general.max_bitrate).toBe(6000); + expect(config.adaptive?.incr_step).toBe(10); // preserved from disk + }); + + it("requires adaptive when balancer=adaptive in full override", () => { + expect(() => + buildCeracoderRunArtifacts({ + pipelineFile: "p", + host: "h", + port: 1, + configFile: "/tmp/none", + config: { + general: { min_bitrate: 300, max_bitrate: 4000, balancer: "adaptive" }, + srt: { latency: 2000 }, + }, + fullOverride: true, + }), + ).toThrow(); + }); + + it("requires aimd when balancer=aimd in full override", () => { + expect(() => + buildCeracoderRunArtifacts({ + pipelineFile: "p", + host: "h", + port: 1, + configFile: "/tmp/none", + config: { + general: { min_bitrate: 300, max_bitrate: 4000, balancer: "aimd" }, + srt: { latency: 2000 }, + }, + fullOverride: true, + }), + ).toThrow(); + }); + + it("succeeds in full override when required sections provided", () => { + const { config } = buildCeracoderRunArtifacts({ + pipelineFile: "p", + host: "h", + port: 1, + configFile: "/tmp/none", + config: { + general: { min_bitrate: 300, max_bitrate: 4000, balancer: "aimd" }, + srt: { latency: 2000 }, + aimd: { ...DEFAULT_AIMD }, + }, + fullOverride: true, + }); + expect(config.general.max_bitrate).toBe(4000); + expect(config.aimd?.decr_mult).toBe(DEFAULT_AIMD.decr_mult); + }); +}); diff --git a/bindings/typescript/src/run.ts b/bindings/typescript/src/run.ts new file mode 100644 index 0000000..1276ddb --- /dev/null +++ b/bindings/typescript/src/run.ts @@ -0,0 +1,104 @@ +import { buildCeracoderConfig, parseCeracoderConfig, serializeCeracoderConfig } from "./config.js"; +import { buildCeracoderArgs } from "./cli.js"; +import type { PartialCeracoderConfig, CeracoderConfig, CeracoderCliOptions } from "./types.js"; +import fs from "node:fs"; + +export type CeracoderRunInput = { + pipelineFile: string; + host: string; + port: number; + configFile: string; + config?: PartialCeracoderConfig; + /** + * If true, ignore existing config file and require a full config payload. + * If false (default), merge provided fields into existing config (if present). + */ + fullOverride?: boolean; + delayMs?: number; + streamId?: string; + latencyMs?: number; + reducedPacketSize?: boolean; + algorithm?: CeracoderCliOptions["algorithm"]; +}; + +export type CeracoderRunArtifacts = { + config: CeracoderConfig; + ini: string; + args: Array; +}; + +/** + * Build ceracoder runtime artifacts (config object, INI text, CLI args). + * Does NOT perform any filesystem writes—callers can persist the INI as needed. + */ +export function buildCeracoderRunArtifacts( + input: CeracoderRunInput, +): CeracoderRunArtifacts { + let baseConfig: PartialCeracoderConfig | undefined; + + const fullOverride = input.fullOverride ?? false; + + if (!fullOverride) { + try { + const existing = fs.readFileSync(input.configFile, "utf8"); + baseConfig = parseCeracoderConfig(existing); + } catch { + baseConfig = undefined; + } + } + + if (fullOverride && !input.config) { + throw new Error("Full override requested but no config provided"); + } + + let mergedConfig: PartialCeracoderConfig; + + if (fullOverride) { + mergedConfig = input.config!; + } else { + mergedConfig = { + ...baseConfig, + ...input.config, + general: { + ...(baseConfig?.general ?? {}), + ...(input.config?.general ?? {}), + }, + srt: { + ...(baseConfig?.srt ?? {}), + ...(input.config?.srt ?? {}), + }, + adaptive: input.config?.adaptive ?? baseConfig?.adaptive, + aimd: input.config?.aimd ?? baseConfig?.aimd, + }; + } + + // Validate that balancer-specific sections are present when the balancer requires them in full override mode + if (fullOverride) { + if (!mergedConfig.general || !mergedConfig.srt) { + throw new Error("Full override requires general and srt sections"); + } + const balancer = mergedConfig.general?.balancer; + if (balancer === "adaptive" && !mergedConfig.adaptive) { + throw new Error("Full override requires adaptive section when balancer=adaptive"); + } + if (balancer === "aimd" && !mergedConfig.aimd) { + throw new Error("Full override requires aimd section when balancer=aimd"); + } + } + + const { config, ini } = buildCeracoderConfig(mergedConfig); + + const args = buildCeracoderArgs({ + pipelineFile: input.pipelineFile, + host: input.host, + port: input.port, + configFile: input.configFile, + delayMs: input.delayMs, + streamId: input.streamId, + latencyMs: input.latencyMs ?? config.srt.latency, + reducedPacketSize: input.reducedPacketSize, + algorithm: input.algorithm ?? config.general.balancer, + }); + + return { config, ini, args }; +} diff --git a/bindings/typescript/src/types.ts b/bindings/typescript/src/types.ts new file mode 100644 index 0000000..d8e1fd4 --- /dev/null +++ b/bindings/typescript/src/types.ts @@ -0,0 +1,69 @@ +import { z } from "zod"; +import { + DEFAULT_ADAPTIVE, + DEFAULT_AIMD, + DEFAULT_BALANCER, + DEFAULT_MAX_BITRATE, + DEFAULT_MIN_BITRATE, + DEFAULT_SRT_LATENCY, +} from "./constants.js"; + +export const balancerAlgorithmSchema = z.enum(["adaptive", "fixed", "aimd"]); +export type BalancerAlgorithm = z.infer; + +export const adaptiveSchema = z + .object({ + incr_step: z.number().int().positive(), + decr_step: z.number().int().positive(), + incr_interval: z.number().int().positive(), + decr_interval: z.number().int().positive(), + loss_threshold: z.number().positive(), + }) + .optional(); + +export const aimdSchema = z + .object({ + incr_step: z.number().int().positive(), + decr_mult: z.number().positive(), + incr_interval: z.number().int().positive(), + decr_interval: z.number().int().positive(), + }) + .optional(); + +export const ceracoderConfigSchema = z.object({ + general: z.object({ + min_bitrate: z.number().int().min(1).default(DEFAULT_MIN_BITRATE), + max_bitrate: z.number().int().min(1).default(DEFAULT_MAX_BITRATE), + balancer: balancerAlgorithmSchema.default(DEFAULT_BALANCER), + }), + srt: z + .object({ + latency: z + .number() + .int() + .min(100) + .max(10_000) + .default(DEFAULT_SRT_LATENCY), + }) + .default({ latency: DEFAULT_SRT_LATENCY }), + adaptive: adaptiveSchema, + aimd: aimdSchema, +}); + +export type CeracoderConfig = z.infer; +export type PartialCeracoderConfig = z.input; + +// CLI options +export const cliOptionsSchema = z.object({ + pipelineFile: z.string().min(1), + host: z.string().min(1), + port: z.number().int().min(1).max(65535), + configFile: z.string().min(1), + delayMs: z.number().int().min(-10_000).max(10_000).optional(), + streamId: z.string().optional(), + latencyMs: z.number().int().min(100).max(10_000).optional(), + reducedPacketSize: z.boolean().optional(), + algorithm: balancerAlgorithmSchema.optional(), +}); + +export type CeracoderCliOptions = z.infer; diff --git a/bindings/typescript/tsconfig.json b/bindings/typescript/tsconfig.json new file mode 100644 index 0000000..2d09a09 --- /dev/null +++ b/bindings/typescript/tsconfig.json @@ -0,0 +1,15 @@ +{ + "compilerOptions": { + "target": "ES2020", + "module": "ES2020", + "moduleResolution": "node", + "outDir": "dist", + "rootDir": "src", + "declaration": true, + "strict": true, + "skipLibCheck": true, + "esModuleInterop": true, + "resolveJsonModule": true + }, + "include": ["src/**/*"] +} diff --git a/docs/architecture.md b/docs/architecture.md index 934ad57..95cb7cb 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -72,19 +72,19 @@ ceracoder/ | AIMD Algorithm | `src/core/balancer_aimd.c` | TCP-style congestion control | | Camlink Workaround | `camlink_workaround/` | USB quirks for Elgato Cam Link | -## Runtime Dataflow +## Runtime Dataflow (Encoder Device) ```mermaid flowchart TD subgraph Input PipelineFile[Pipeline File] - CLI[CLI Args: host, port, streamid, latency, delay, bitrate file] + CLI[CLI Args: host, port, streamid, latency, delay, config] end subgraph GStreamer GstParseLaunch[gst_parse_launch] GstPipeline[GstPipeline] - Encoder[Video Encoder venc_bps / venc_kbps] + Encoder[Video Encoder (venc_bps/venc_kbps)] AppSink[appsink] end @@ -113,6 +113,50 @@ flowchart TD Controller -->|g_object_set bitrate| Encoder ``` +## End-to-End Streaming Flow (Encoder → Server) + +``` +ENCODER DEVICE (Field) SERVER (Ingest/Cloud) +====================== ===================== + +Video Source (HDMI/USB/etc) + | + v + ceracoder (GStreamer internal) + | + | SRT (localhost) + v + srtla (sender) + | + +----+----+----+ + | | | + Modem1 Modem2 WiFi + \ | / + \ | / + \ | / + Internet + | + v + srtla_rec + +--------------+ + | reassemble | + +------+-------+ + | + v + srt-live-transmit + +---------------+ + | relay/bridge | + +-------+-------+ + | + v + OBS / Player / CDN +``` + +## TypeScript Bindings (`@ceralive/ceracoder`) +- PipelineBuilder for hardware-specific GStreamer pipelines (Jetson, RK3588, N100, Generic) +- Zod schemas for config/CLI +- Process helpers: resolve executable, spawn ceracoder, send SIGHUP (reload), write config/pipeline files + ### Step-by-step flow 1. **Startup**: Parse CLI arguments (host, port, stream ID, latency, bitrate file, A/V delay).