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
24 changes: 23 additions & 1 deletion .github/workflows/publish-on-merge.yml
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ on:
type: boolean

permissions:
id-token: write # Required for Trusted Publisher on NPMJS
contents: write

jobs:
Expand Down Expand Up @@ -41,12 +42,33 @@ jobs:
sudo chmod +x /usr/local/bin/logger

- name: Setup Node.js
id: setup-node
uses: actions/setup-node@v6
with:
node-version: 22
registry-url: "https://registry.npmjs.org"
cache: "yarn"

# npm Trusted Publisher requires CLI >= 11.5.1 for OIDC token exchange.
# Bypass the broken arborist in the cached Node 22 image by extracting the tarball directly.
- name: Upgrade NPM
env:
NODE_VERSION: ${{ steps.setup-node.outputs.node-version }}
NPM_VERSION: 11.5.1
run: |
NODE_VERSION_STRIPED=${NODE_VERSION#v}
NPM_DIR="/opt/hostedtoolcache/node/$NODE_VERSION_STRIPED/x64/lib/node_modules/npm"

logger -l debug -m "NODE_VERSION_STRIPED: $NODE_VERSION_STRIPED"
logger -l debug -m "NPM_DIR: $NPM_DIR"
logger -l debug -m "NPM_VERSION: $NPM_VERSION"

logger -l info -m "Upgrading NPM to version: $NPM_VERSION"
curl -fsSL https://registry.npmjs.org/npm/-/npm-$NPM_VERSION.tgz | tar -xz --strip-components=1 -C "$NPM_DIR" \
|| logger -l error -m "Failed to upgrade NPM"

logger -l info -m "Upgrade NPM succeeded"

- name: Install dependencies
run: |
logger -l info -m "Installing dependencies"
Expand Down Expand Up @@ -113,7 +135,7 @@ jobs:

- name: Publish to NPM
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_PACKAGE_UPLOAD_TOKEN }}
NODE_AUTH_TOKEN: '' # unset in order to use OIDC
run: |
if [ "${{ github.event.inputs.dry_run }}" = "true" ]; then
logger -l info -m "🔍 DRY RUN MODE: Would publish version ${{ steps.version.outputs.version }} with tag ${{ steps.version.outputs.tag }}"
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"name": "@d-id/client-sdk",
"private": false,
"version": "1.1.63",
"version": "1.1.65",
"type": "module",
"description": "d-id client sdk",
"repository": {
Expand Down
11 changes: 7 additions & 4 deletions src/auth/get-auth-header.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -128,15 +128,16 @@ describe('getAuthHeader', () => {
const auth: Auth = { type: 'key', clientKey: 'test-client-key' };
const result = getAuthHeader(auth);

expect(result).toBe('Client-Key test-client-key.generated-external-id');
expect(result).toBe('Client-Key test-client-key.generated-external-id_mocked-random-id');
});

it('should use provided externalId in Client-Key header', () => {
const auth: Auth = { type: 'key', clientKey: 'test-client-key' };
const externalId = 'user-123';
const result = getAuthHeader(auth, externalId);

expect(result).toBe('Client-Key test-client-key.user-123');
expect(result).toBe('Client-Key test-client-key.user-123_mocked-random-id');
expect(result).toContain('user-123');
});

it('should use externalId from localStorage when not provided', () => {
Expand All @@ -146,7 +147,8 @@ describe('getAuthHeader', () => {
const auth: Auth = { type: 'key', clientKey: 'test-client-key' };
const result = getAuthHeader(auth);

expect(result).toBe('Client-Key test-client-key.stored-user-id');
expect(result).toBe('Client-Key test-client-key.stored-user-id_mocked-random-id');
expect(result).toContain('stored-user-id');
});

it('should generate new externalId and store it when localStorage is empty', () => {
Expand All @@ -156,7 +158,8 @@ describe('getAuthHeader', () => {
const auth: Auth = { type: 'key', clientKey: 'test-client-key' };
const result = getAuthHeader(auth);

expect(result).toBe('Client-Key test-client-key.new-generated-id');
expect(result).toBe('Client-Key test-client-key.new-generated-id_mocked-random-id');
expect(result).toContain('new-generated-id');
expect(window.localStorage.getItem('did_external_key_id')).toBe(mockRandomId);
});

Expand Down
3 changes: 2 additions & 1 deletion src/auth/get-auth-header.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,13 +18,14 @@ export function getExternalId(externalId?: string): string {
return key;
}

let sessionKey = getRandom();
export function getAuthHeader(auth: Auth, externalId?: string) {
if (auth.type === 'bearer') {
return `Bearer ${auth.token}`;
} else if (auth.type === 'basic') {
return `Basic ${'token' in auth ? auth.token : btoa(`${auth.username}:${auth.password}`)}`;
} else if (auth.type === 'key') {
return `Client-Key ${auth.clientKey}.${getExternalId(externalId)}`;
return `Client-Key ${auth.clientKey}.${getExternalId(externalId)}_${sessionKey}`;
} else {
throw new Error(`Unknown auth type: ${auth}`);
}
Expand Down
157 changes: 157 additions & 0 deletions src/services/streaming-manager/stats/report.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,157 @@
import { formatStats } from './report';

type StatEntry = Record<string, any>;

function buildStats(entries: StatEntry[]): RTCStatsReport {
const map = new Map<string, StatEntry>();
for (const entry of entries) {
map.set(entry.id, entry);
}
return map as unknown as RTCStatsReport;
}

const inboundRtpVideo: StatEntry = {
id: 'IT_video',
type: 'inbound-rtp',
kind: 'video',
codecId: 'CIT_video_vp8',
timestamp: 1_700_000_000_000,
bytesReceived: 1_234_567,
packetsReceived: 1_000,
packetsLost: 2,
framesDropped: 1,
framesDecoded: 600,
jitter: 0.01,
jitterBufferDelay: 30,
jitterBufferEmittedCount: 600,
frameWidth: 1280,
frameHeight: 720,
framesPerSecond: 30,
freezeCount: 0,
totalFreezesDuration: 0,
};

const codecVp8: StatEntry = { id: 'CIT_video_vp8', type: 'codec', mimeType: 'video/VP8' };
const codecH264: StatEntry = { id: 'CIT_video_h264', type: 'codec', mimeType: 'video/H264' };
const codecAudio: StatEntry = { id: 'CIT_audio_opus', type: 'codec', mimeType: 'audio/opus' };
const inboundRtpAudio: StatEntry = { id: 'IT_audio', type: 'inbound-rtp', kind: 'audio' };

const nominatedPair: StatEntry = {
id: 'CP_nominated',
type: 'candidate-pair',
nominated: true,
currentRoundTripTime: 0.05,
};
const backupPair: StatEntry = {
id: 'CP_backup',
type: 'candidate-pair',
nominated: false,
currentRoundTripTime: 0.2,
};

describe('formatStats', () => {
describe('codec extraction', () => {
it.each([
['codec stat is iterated before inbound-rtp video', [codecVp8, inboundRtpVideo]],
['codec stat is iterated after inbound-rtp video', [inboundRtpVideo, codecVp8]],
])('returns the codec when %s', (_label, entries) => {
expect(formatStats(buildStats(entries)).codec).toBe('VP8');
});

it('returns the codec linked by codecId when multiple video codecs exist', () => {
const inboundUsingH264 = { ...inboundRtpVideo, codecId: codecH264.id };
expect(formatStats(buildStats([codecVp8, codecH264, inboundUsingH264])).codec).toBe('H264');
});

it.each([
['codecId does not match any codec entry', [codecVp8, { ...inboundRtpVideo, codecId: 'unknown-id' }]],
[
'inbound-rtp omits codecId entirely',
[
codecVp8,
(() => {
const { codecId, ...rest } = inboundRtpVideo;
return rest;
})(),
],
],
])('falls back to any video codec when %s', (_label, entries) => {
expect(formatStats(buildStats(entries)).codec).toBe('VP8');
});

it('ignores audio codec entries when picking a video codec', () => {
expect(formatStats(buildStats([codecAudio, codecVp8, inboundRtpVideo])).codec).toBe('VP8');
});

it('returns an empty codec string when no video codec is present', () => {
const inboundNoLink = { ...inboundRtpVideo, codecId: undefined };
expect(formatStats(buildStats([codecAudio, inboundNoLink])).codec).toBe('');
});

it('does not throw when a codec entry has no mimeType', () => {
const malformed = { id: 'CIT_bad', type: 'codec' };
expect(() => formatStats(buildStats([malformed, inboundRtpVideo]))).not.toThrow();
});
});

describe('inbound-rtp routing', () => {
it('returns an empty report when no video inbound-rtp is present', () => {
expect(formatStats(buildStats([codecVp8, inboundRtpAudio]))).toEqual({});
});

it('passes inbound-rtp fields through to the result', () => {
const result = formatStats(buildStats([codecVp8, inboundRtpVideo]));
expect(result).toMatchObject({
codec: 'VP8',
timestamp: inboundRtpVideo.timestamp,
bytesReceived: inboundRtpVideo.bytesReceived,
packetsReceived: inboundRtpVideo.packetsReceived,
packetsLost: inboundRtpVideo.packetsLost,
framesDropped: inboundRtpVideo.framesDropped,
framesDecoded: inboundRtpVideo.framesDecoded,
jitter: inboundRtpVideo.jitter,
jitterBufferDelay: inboundRtpVideo.jitterBufferDelay,
jitterBufferEmittedCount: inboundRtpVideo.jitterBufferEmittedCount,
frameWidth: inboundRtpVideo.frameWidth,
frameHeight: inboundRtpVideo.frameHeight,
framesPerSecond: inboundRtpVideo.framesPerSecond,
freezeCount: inboundRtpVideo.freezeCount,
freezeDuration: inboundRtpVideo.totalFreezesDuration,
});
});

it('derives avgJitterDelayInInterval as jitterBufferDelay / jitterBufferEmittedCount', () => {
const result = formatStats(buildStats([codecVp8, inboundRtpVideo]));
expect(result.avgJitterDelayInInterval).toBeCloseTo(
inboundRtpVideo.jitterBufferDelay / inboundRtpVideo.jitterBufferEmittedCount
);
});
});

describe('RTT priority', () => {
function rttFromPairs(pairs: StatEntry[]): number {
return formatStats(buildStats([...pairs, codecVp8, inboundRtpVideo])).rtt;
}

it('uses the nominated pair when no other pair is present', () => {
expect(rttFromPairs([nominatedPair])).toBe(nominatedPair.currentRoundTripTime);
});

it('uses the nominated pair when a backup pair appears before it', () => {
expect(rttFromPairs([backupPair, nominatedPair])).toBe(nominatedPair.currentRoundTripTime);
});

it('keeps the nominated pair when a backup pair appears after it', () => {
expect(rttFromPairs([nominatedPair, backupPair])).toBe(nominatedPair.currentRoundTripTime);
});

it('uses the backup pair when no nominated pair is present', () => {
expect(rttFromPairs([backupPair])).toBe(backupPair.currentRoundTripTime);
});

it('ignores candidate-pair entries with non-positive RTT', () => {
const zeroRttPair = { ...backupPair, currentRoundTripTime: 0 };
expect(rttFromPairs([zeroRttPair])).toBe(0);
});
});
});
85 changes: 51 additions & 34 deletions src/services/streaming-manager/stats/report.ts
Original file line number Diff line number Diff line change
Expand Up @@ -77,50 +77,67 @@ function extractAnomalies(stats: AnalyticsRTCStatsReport[]): AnalyticsRTCStatsRe
export function formatStats(stats: RTCStatsReport): SlimRTCStatsReport {
let codec = '';
let currRtt: number = 0;
let videoInboundRtp: RTCInboundRtpStreamStats | null = null;
const codecIdToMime = new Map<string, string>();

// RTCStatsReport iteration order is not guaranteed across browsers.
// Walk the full report once to collect codec/rtt/inbound-rtp before returning,
// otherwise we may return before the codec entry is seen and emit codec=''.
for (const report of stats.values()) {
if (report && report.type === 'codec' && report.mimeType.startsWith('video')) {
codec = report.mimeType.split('/')[1];
}
if (report && report.type === 'candidate-pair') {
const rtt = report.currentRoundTripTime;
const candidatePair = report as any;
const isNominated = candidatePair.nominated === true;

// Prioritize RTT from the nominated candidate-pair (the active connection path).
// This ensures we capture the actual network latency being used, not just any candidate.
// Only update if we have a valid positive RTT value to avoid overwriting with invalid data.
if (!report) continue;

if (report.type === 'codec' && report.mimeType?.startsWith('video')) {
codecIdToMime.set(report.id, report.mimeType.split('/')[1]);
} else if (report.type === 'candidate-pair') {
const pair = report as RTCIceCandidatePairStats;
const rtt = pair.currentRoundTripTime ?? 0;
// Prefer RTT from the nominated candidate-pair (the active connection path).
// Fall back to the first valid pair only until a nominated value arrives.
if (rtt > 0) {
if (isNominated) {
if (pair.nominated === true) {
currRtt = rtt;
} else if (currRtt === 0) {
currRtt = rtt;
}
}
}
if (report && report.type === 'inbound-rtp' && report.kind === 'video') {
return {
codec,
rtt: currRtt,
timestamp: report.timestamp,
bytesReceived: report.bytesReceived,
packetsReceived: report.packetsReceived,
packetsLost: report.packetsLost,
framesDropped: report.framesDropped,
framesDecoded: report.framesDecoded,
jitter: report.jitter,
jitterBufferDelay: report.jitterBufferDelay,
jitterBufferEmittedCount: report.jitterBufferEmittedCount,
avgJitterDelayInInterval: report.jitterBufferDelay / report.jitterBufferEmittedCount,
frameWidth: report.frameWidth,
frameHeight: report.frameHeight,
framesPerSecond: report.framesPerSecond,
freezeCount: report.freezeCount,
freezeDuration: report.totalFreezesDuration,
} as SlimRTCStatsReport;
} else if (report.type === 'inbound-rtp' && report.kind === 'video') {
videoInboundRtp = report as RTCInboundRtpStreamStats;
}
}
return {} as SlimRTCStatsReport;

if (!videoInboundRtp) {
return {} as SlimRTCStatsReport;
}

// WebRTC marks every numeric field optional, but SlimRTCStatsReport expects
// required values. Single boundary cast avoids per-field null checks below.
const inbound = videoInboundRtp as Required<RTCInboundRtpStreamStats>;

if (inbound.codecId && codecIdToMime.has(inbound.codecId)) {
codec = codecIdToMime.get(inbound.codecId)!;
} else if (codecIdToMime.size > 0) {
codec = codecIdToMime.values().next().value ?? '';
}

return {
codec,
rtt: currRtt,
timestamp: inbound.timestamp,
bytesReceived: inbound.bytesReceived,
packetsReceived: inbound.packetsReceived,
packetsLost: inbound.packetsLost,
framesDropped: inbound.framesDropped,
framesDecoded: inbound.framesDecoded,
jitter: inbound.jitter,
jitterBufferDelay: inbound.jitterBufferDelay,
jitterBufferEmittedCount: inbound.jitterBufferEmittedCount,
avgJitterDelayInInterval: inbound.jitterBufferDelay / inbound.jitterBufferEmittedCount,
frameWidth: inbound.frameWidth,
frameHeight: inbound.frameHeight,
framesPerSecond: inbound.framesPerSecond,
freezeCount: inbound.freezeCount,
freezeDuration: inbound.totalFreezesDuration,
} as SlimRTCStatsReport;
}

export function createVideoStatsReport(
Expand Down
Loading