Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
59 changes: 59 additions & 0 deletions bindings/typescript/README.md
Original file line number Diff line number Diff line change
@@ -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 <config>` (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 <file>`)

## 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,
});
```
29 changes: 29 additions & 0 deletions bindings/typescript/bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

29 changes: 29 additions & 0 deletions bindings/typescript/package.json
Original file line number Diff line number Diff line change
@@ -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"
]
}
35 changes: 35 additions & 0 deletions bindings/typescript/src/cli.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import { cliOptionsSchema, type CeracoderCliOptions } from "./types.js";

export function buildCeracoderArgs(options: CeracoderCliOptions): Array<string> {
const opts = cliOptionsSchema.parse(options);

const args: Array<string> = [
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;
}
165 changes: 165 additions & 0 deletions bindings/typescript/src/config.ts
Original file line number Diff line number Diff line change
@@ -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<string, string | number | undefined>) {
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<string>): Record<string, string> {
return lines.reduce<Record<string, string>>((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<string, Array<string>> = {};
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<typeof ceracoderConfigSchema>["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 };
}
23 changes: 23 additions & 0 deletions bindings/typescript/src/constants.ts
Original file line number Diff line number Diff line change
@@ -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";
7 changes: 7 additions & 0 deletions bindings/typescript/src/index.ts
Original file line number Diff line number Diff line change
@@ -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";
Loading