diff --git a/.prettierignore b/.prettierignore index ba55e859..246cfe00 100644 --- a/.prettierignore +++ b/.prettierignore @@ -5,4 +5,5 @@ protos/ src/openapi/ README.md tsconfig.json -docker-compose-dev.yaml \ No newline at end of file +docker-compose-dev.yaml +packages/fishjam-openapi/src/generated/ diff --git a/packages/fishjam-openapi/src/generated/.openapi-generator/FILES b/packages/fishjam-openapi/src/generated/.openapi-generator/FILES index dacc5d7d..bf33ffe7 100644 --- a/packages/fishjam-openapi/src/generated/.openapi-generator/FILES +++ b/packages/fishjam-openapi/src/generated/.openapi-generator/FILES @@ -54,6 +54,8 @@ models/StreamsListingResponse.ts models/SubscribeMode.ts models/SubscribeTracksRequest.ts models/Subscriptions.ts +models/TemplateSource.ts +models/TemplateSourceResolution.ts models/Track.ts models/TrackForwarding.ts models/TrackForwardingInfo.ts diff --git a/packages/fishjam-openapi/src/generated/apis/RecordingsApi.ts b/packages/fishjam-openapi/src/generated/apis/RecordingsApi.ts index 9646865c..eaa67b54 100644 --- a/packages/fishjam-openapi/src/generated/apis/RecordingsApi.ts +++ b/packages/fishjam-openapi/src/generated/apis/RecordingsApi.ts @@ -34,7 +34,7 @@ import { RecordingStatusToJSON, } from '../models/RecordingStatus'; -export interface CreateRecordingRequest { +export interface CreateRecordingOperationRequest { recordingConfig: RecordingConfig; } @@ -63,7 +63,7 @@ export class RecordingsApi extends runtime.BaseAPI { /** * Creates request options for createRecording without sending the request */ - async createRecordingRequestOpts(requestParameters: CreateRecordingRequest): Promise { + async createRecordingRequestOpts(requestParameters: CreateRecordingOperationRequest): Promise { if (requestParameters['recordingConfig'] == null) { throw new runtime.RequiredError( 'recordingConfig', @@ -98,10 +98,10 @@ export class RecordingsApi extends runtime.BaseAPI { } /** - * Create a recording resource. Capturing starts synchronously, so it is returned with status `active`. + * Create a recording resource. Capturing starts synchronously, so it is returned with status `active`. A source naming an `outputId` records an output the composition already has and is sent as JSON. A template source renders its own scene and has to be sent as `multipart/form-data`: the configuration in a `config` part, the React bundle in a `template` part. * Create a recording */ - async createRecordingRaw(requestParameters: CreateRecordingRequest, initOverrides?: RequestInit | runtime.InitOverrideFunction): Promise> { + async createRecordingRaw(requestParameters: CreateRecordingOperationRequest, initOverrides?: RequestInit | runtime.InitOverrideFunction): Promise> { const requestOptions = await this.createRecordingRequestOpts(requestParameters); const response = await this.request(requestOptions, initOverrides); @@ -109,10 +109,10 @@ export class RecordingsApi extends runtime.BaseAPI { } /** - * Create a recording resource. Capturing starts synchronously, so it is returned with status `active`. + * Create a recording resource. Capturing starts synchronously, so it is returned with status `active`. A source naming an `outputId` records an output the composition already has and is sent as JSON. A template source renders its own scene and has to be sent as `multipart/form-data`: the configuration in a `config` part, the React bundle in a `template` part. * Create a recording */ - async createRecording(requestParameters: CreateRecordingRequest, initOverrides?: RequestInit | runtime.InitOverrideFunction): Promise { + async createRecording(requestParameters: CreateRecordingOperationRequest, initOverrides?: RequestInit | runtime.InitOverrideFunction): Promise { const response = await this.createRecordingRaw(requestParameters, initOverrides); return await response.value(); } diff --git a/packages/fishjam-openapi/src/generated/models/RecordingSource.ts b/packages/fishjam-openapi/src/generated/models/RecordingSource.ts index 95889377..ad33e866 100644 --- a/packages/fishjam-openapi/src/generated/models/RecordingSource.ts +++ b/packages/fishjam-openapi/src/generated/models/RecordingSource.ts @@ -12,41 +12,27 @@ * Do not edit the class manually. */ -import { mapValues } from '../runtime'; +import type { CompositionSource } from './CompositionSource'; +import { + instanceOfCompositionSource, + CompositionSourceFromJSON, + CompositionSourceFromJSONTyped, + CompositionSourceToJSON, +} from './CompositionSource'; +import type { TemplateSource } from './TemplateSource'; +import { + instanceOfTemplateSource, + TemplateSourceFromJSON, + TemplateSourceFromJSONTyped, + TemplateSourceToJSON, +} from './TemplateSource'; + /** + * @type RecordingSource * The source for the recording * @export - * @interface RecordingSource - */ -export interface RecordingSource { - /** - * URL of the composition to record - * @type {string} - * @memberof RecordingSource - */ - compositionURL: string; - /** - * Id of the output being recorded - * @type {string} - * @memberof RecordingSource - */ - outputId: string; - /** - * Scale factor for the recording relative to the source output (>0; may upscale or downscale). Defaults to 1. - * @type {number} - * @memberof RecordingSource - */ - scaleRatio?: number; -} - -/** - * Check if a given object implements the RecordingSource interface. */ -export function instanceOfRecordingSource(value: object): value is RecordingSource { - if (!('compositionURL' in value) || value['compositionURL'] === undefined) return false; - if (!('outputId' in value) || value['outputId'] === undefined) return false; - return true; -} +export type RecordingSource = CompositionSource | TemplateSource; export function RecordingSourceFromJSON(json: any): RecordingSource { return RecordingSourceFromJSONTyped(json, false); @@ -56,15 +42,19 @@ export function RecordingSourceFromJSONTyped(json: any, ignoreDiscriminator: boo if (json == null) { return json; } - return { - - 'compositionURL': json['compositionURL'], - 'outputId': json['outputId'], - 'scaleRatio': json['scaleRatio'] == null ? undefined : json['scaleRatio'], - }; + if (typeof json !== 'object') { + return json; + } + if (instanceOfCompositionSource(json)) { + return CompositionSourceFromJSONTyped(json, true); + } + if (instanceOfTemplateSource(json)) { + return TemplateSourceFromJSONTyped(json, true); + } + return {} as any; } -export function RecordingSourceToJSON(json: any): RecordingSource { +export function RecordingSourceToJSON(json: any): any { return RecordingSourceToJSONTyped(json, false); } @@ -72,12 +62,15 @@ export function RecordingSourceToJSONTyped(value?: RecordingSource | null, ignor if (value == null) { return value; } - - return { - - 'compositionURL': value['compositionURL'], - 'outputId': value['outputId'], - 'scaleRatio': value['scaleRatio'], - }; + if (typeof value !== 'object') { + return value; + } + if (instanceOfCompositionSource(value)) { + return CompositionSourceToJSON(value as CompositionSource); + } + if (instanceOfTemplateSource(value)) { + return TemplateSourceToJSON(value as TemplateSource); + } + return {}; } diff --git a/packages/fishjam-openapi/src/generated/models/TemplateSource.ts b/packages/fishjam-openapi/src/generated/models/TemplateSource.ts new file mode 100644 index 00000000..f0d6adf3 --- /dev/null +++ b/packages/fishjam-openapi/src/generated/models/TemplateSource.ts @@ -0,0 +1,90 @@ +/* tslint:disable */ +/* eslint-disable */ +/** + * Fishjam API + * API for managing Fishjam real-time media resources. ## Authentication ## Credentials (Fishjam ID, Fishjam Management Token) can be obtained at https://fishjam.io/app. All requests require HTTP Bearer authorization using the Fishjam Management Token. ## Fishjam SDKs ## For TypeScript and Python users, we provide SDKs that simplify using this API. We recommend using them instead of manually consuming the API in these languages. You can learn more about our SDKs in our [SDK Docs](http://fishjam.swmansion.com/docs/how-to/backend/server-setup) + * + * The version of the OpenAPI document: 0.30.0 + * Contact: contact@fishjam.io + * + * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). + * https://openapi-generator.tech + * Do not edit the class manually. + */ + +import { mapValues } from '../runtime'; +import type { TemplateSourceResolution } from './TemplateSourceResolution'; +import { + TemplateSourceResolutionFromJSON, + TemplateSourceResolutionFromJSONTyped, + TemplateSourceResolutionToJSON, + TemplateSourceResolutionToJSONTyped, +} from './TemplateSourceResolution'; + +/** + * Recording source that renders its own scene from a template, rather than cloning an existing output of the composition + * @export + * @interface TemplateSource + */ +export interface TemplateSource { + /** + * Whether the recording captures audio. Defaults to true. + * @type {boolean} + * @memberof TemplateSource + */ + audio?: boolean; + /** + * URL of the composition to record + * @type {string} + * @memberof TemplateSource + */ + compositionURL: string; + /** + * + * @type {TemplateSourceResolution} + * @memberof TemplateSource + */ + resolution?: TemplateSourceResolution; +} + +/** + * Check if a given object implements the TemplateSource interface. + */ +export function instanceOfTemplateSource(value: object): value is TemplateSource { + if (!('compositionURL' in value) || value['compositionURL'] === undefined) return false; + return true; +} + +export function TemplateSourceFromJSON(json: any): TemplateSource { + return TemplateSourceFromJSONTyped(json, false); +} + +export function TemplateSourceFromJSONTyped(json: any, ignoreDiscriminator: boolean): TemplateSource { + if (json == null) { + return json; + } + return { + + 'audio': json['audio'] == null ? undefined : json['audio'], + 'compositionURL': json['compositionURL'], + 'resolution': json['resolution'] == null ? undefined : TemplateSourceResolutionFromJSON(json['resolution']), + }; +} + +export function TemplateSourceToJSON(json: any): TemplateSource { + return TemplateSourceToJSONTyped(json, false); +} + +export function TemplateSourceToJSONTyped(value?: TemplateSource | null, ignoreDiscriminator: boolean = false): any { + if (value == null) { + return value; + } + + return { + + 'audio': value['audio'], + 'compositionURL': value['compositionURL'], + 'resolution': TemplateSourceResolutionToJSON(value['resolution']), + }; +} + diff --git a/packages/fishjam-openapi/src/generated/models/TemplateSourceResolution.ts b/packages/fishjam-openapi/src/generated/models/TemplateSourceResolution.ts new file mode 100644 index 00000000..8ef4acf8 --- /dev/null +++ b/packages/fishjam-openapi/src/generated/models/TemplateSourceResolution.ts @@ -0,0 +1,75 @@ +/* tslint:disable */ +/* eslint-disable */ +/** + * Fishjam API + * API for managing Fishjam real-time media resources. ## Authentication ## Credentials (Fishjam ID, Fishjam Management Token) can be obtained at https://fishjam.io/app. All requests require HTTP Bearer authorization using the Fishjam Management Token. ## Fishjam SDKs ## For TypeScript and Python users, we provide SDKs that simplify using this API. We recommend using them instead of manually consuming the API in these languages. You can learn more about our SDKs in our [SDK Docs](http://fishjam.swmansion.com/docs/how-to/backend/server-setup) + * + * The version of the OpenAPI document: 0.30.0 + * Contact: contact@fishjam.io + * + * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). + * https://openapi-generator.tech + * Do not edit the class manually. + */ + +import { mapValues } from '../runtime'; +/** + * Resolution the scene is rendered at. Defaults to 1280x720. + * @export + * @interface TemplateSourceResolution + */ +export interface TemplateSourceResolution { + /** + * + * @type {number} + * @memberof TemplateSourceResolution + */ + height: number; + /** + * + * @type {number} + * @memberof TemplateSourceResolution + */ + width: number; +} + +/** + * Check if a given object implements the TemplateSourceResolution interface. + */ +export function instanceOfTemplateSourceResolution(value: object): value is TemplateSourceResolution { + if (!('height' in value) || value['height'] === undefined) return false; + if (!('width' in value) || value['width'] === undefined) return false; + return true; +} + +export function TemplateSourceResolutionFromJSON(json: any): TemplateSourceResolution { + return TemplateSourceResolutionFromJSONTyped(json, false); +} + +export function TemplateSourceResolutionFromJSONTyped(json: any, ignoreDiscriminator: boolean): TemplateSourceResolution { + if (json == null) { + return json; + } + return { + + 'height': json['height'], + 'width': json['width'], + }; +} + +export function TemplateSourceResolutionToJSON(json: any): TemplateSourceResolution { + return TemplateSourceResolutionToJSONTyped(json, false); +} + +export function TemplateSourceResolutionToJSONTyped(value?: TemplateSourceResolution | null, ignoreDiscriminator: boolean = false): any { + if (value == null) { + return value; + } + + return { + + 'height': value['height'], + 'width': value['width'], + }; +} + diff --git a/packages/fishjam-openapi/src/generated/models/index.ts b/packages/fishjam-openapi/src/generated/models/index.ts index 52c434ce..b21f2e0d 100644 --- a/packages/fishjam-openapi/src/generated/models/index.ts +++ b/packages/fishjam-openapi/src/generated/models/index.ts @@ -46,6 +46,8 @@ export * from './StreamsListingResponse'; export * from './SubscribeMode'; export * from './SubscribeTracksRequest'; export * from './Subscriptions'; +export * from './TemplateSource'; +export * from './TemplateSourceResolution'; export * from './Track'; export * from './TrackForwarding'; export * from './TrackForwardingInfo'; diff --git a/packages/fishjam-proto/protos b/packages/fishjam-proto/protos index da96ef19..5f84ff1e 160000 --- a/packages/fishjam-proto/protos +++ b/packages/fishjam-proto/protos @@ -1 +1 @@ -Subproject commit da96ef19f67cefd9ebb818d5f9b8c4c5bece74fa +Subproject commit 5f84ff1ebcada392e43fb766dde0622e469076fd diff --git a/packages/js-server-sdk/src/client.ts b/packages/js-server-sdk/src/client.ts index 68ca7258..cd36f957 100644 --- a/packages/js-server-sdk/src/client.ts +++ b/packages/js-server-sdk/src/client.ts @@ -12,12 +12,22 @@ import { PeerOptionsAgent, MoqAccessConfig, type Middleware, - RecordingsApi, - RecordingConfig, } from '@fishjam-cloud/fishjam-openapi'; -import type { AgentCallbacks, FishjamConfig, PeerId, Recording, RecordingId, Room, RoomId, Peer } from './types'; +import type { + AgentCallbacks, + CompositionRecordingConfig, + FishjamConfig, + PeerId, + Recording, + RecordingId, + Room, + RoomId, + Peer, + TemplateRecordingConfig, +} from './types'; import { mapException } from './exceptions/mapper'; -import { getFishjamUrl, getLivestreamWhipUrl } from './utils'; +import { RecordingsApiWithTemplates } from './recordings'; +import { getFishjamUrl, getLivestreamWhipUrl, toBlob } from './utils'; import { FishjamAgent, TrackId } from './agent'; import packageJson from '../package.json'; @@ -32,7 +42,7 @@ export class FishjamClient { private readonly viewerApi: ViewersApi; private readonly streamerApi: StreamersApi; private readonly credentialsApi: CredentialsApi; - private readonly recordingsApi: RecordingsApi; + private readonly recordingsApi: RecordingsApiWithTemplates; private readonly trackForwardingsApi: TrackForwardingsApi; private readonly fishjamConfig: FishjamConfig; private deprecationWarningShown: boolean = false; @@ -74,7 +84,7 @@ export class FishjamClient { this.viewerApi = new ViewersApi(apiConfig); this.streamerApi = new StreamersApi(apiConfig); this.credentialsApi = new CredentialsApi(apiConfig); - this.recordingsApi = new RecordingsApi(apiConfig); + this.recordingsApi = new RecordingsApiWithTemplates(apiConfig); this.trackForwardingsApi = new TrackForwardingsApi(apiConfig); this.fishjamConfig = config; } @@ -348,9 +358,10 @@ export class FishjamClient { } /** - * Create a new recording. Capturing starts synchronously, so the returned recording is `active`. + * @deprecated Use {@link createCompositionRecording} instead. + * Create a new recording that mirrors existing composition output. Capturing starts synchronously, so the returned recording is `active`. */ - async createRecording(config: RecordingConfig): Promise { + async createRecording(config: CompositionRecordingConfig): Promise { try { const { data } = await this.recordingsApi.createRecording({ recordingConfig: config }); return data as Recording; @@ -359,6 +370,34 @@ export class FishjamClient { } } + /** + * Create a new recording that mirrors existing composition output. Capturing starts synchronously, so the returned recording is `active`. + */ + async createCompositionRecording(config: CompositionRecordingConfig): Promise { + try { + const { data } = await this.recordingsApi.createRecording({ recordingConfig: config }); + return data as Recording; + } catch (error) { + throw await mapException(error); + } + } + + /** + * Start a new recording that renders its own scene from a template. + * + * The template bundle is either a `Blob` or a path to read it from, and can weigh at most 1 MiB; + * Only valid template bundles are accepted. + * + */ + async createTemplateRecording(config: TemplateRecordingConfig, template: Blob | string): Promise { + try { + const { data } = await this.recordingsApi.createTemplateRecording(config, await toBlob(template)); + return data as Recording; + } catch (error) { + throw await mapException(error, 'recording'); + } + } + /** * Get details about a given recording. */ diff --git a/packages/js-server-sdk/src/index.ts b/packages/js-server-sdk/src/index.ts index 37473800..c43438d7 100644 --- a/packages/js-server-sdk/src/index.ts +++ b/packages/js-server-sdk/src/index.ts @@ -25,6 +25,8 @@ export { MoqAccessConfig, RecordingConfig, RecordingSource, + CompositionSource, + TemplateSource, } from '@fishjam-cloud/fishjam-openapi'; export type { diff --git a/packages/js-server-sdk/src/recordings.ts b/packages/js-server-sdk/src/recordings.ts new file mode 100644 index 00000000..0766584f --- /dev/null +++ b/packages/js-server-sdk/src/recordings.ts @@ -0,0 +1,23 @@ +import { + RecordingsApi, + RecordingConfigToJSON, + RecordingDetailsResponseFromJSON, + type RecordingConfig, + type RecordingDetailsResponse, +} from '@fishjam-cloud/fishjam-openapi'; + +/** + * Adds the one recording call the generated client does not cover: creating a recording from a + * template bundle, which is sent as `multipart/form-data` with the config and the bundle as parts. + */ +export class RecordingsApiWithTemplates extends RecordingsApi { + async createTemplateRecording(config: RecordingConfig, template: Blob): Promise { + const body = new FormData(); + body.append('config', new Blob([JSON.stringify(RecordingConfigToJSON(config))], { type: 'application/json' })); + body.append('template', template); + + const response = await this.request({ path: '/recordings', method: 'POST', headers: {}, body }); + + return RecordingDetailsResponseFromJSON(await response.json()); + } +} diff --git a/packages/js-server-sdk/src/types.ts b/packages/js-server-sdk/src/types.ts index 9a181127..6b8a379e 100644 --- a/packages/js-server-sdk/src/types.ts +++ b/packages/js-server-sdk/src/types.ts @@ -2,8 +2,11 @@ import { CompositionCreatedResponse } from '@fishjam-cloud/composition-openapi'; import { Peer as OpenApiPeer, PeerType as OpenApiPeerType, + CompositionSource, Recording as OpenApiRecording, + RecordingConfig, RoomConfig, + TemplateSource, TrackType as OpenApiTrackType, } from '@fishjam-cloud/fishjam-openapi'; @@ -75,6 +78,16 @@ export type RecordingStatus = 'active' | 'finished' | 'available' | 'failed'; */ export type Recording = Override; +/** + * Configuration of a recording that captures an existing composition. + */ +export type CompositionRecordingConfig = Override; + +/** + * Configuration of a recording that renders its own scene from a template. + */ +export type TemplateRecordingConfig = Override; + /** * ID of a composition. * Composition can be created with {@link CompositionClient.createComposition}. diff --git a/packages/js-server-sdk/tests/fixtures/template.js b/packages/js-server-sdk/tests/fixtures/template.js new file mode 100644 index 00000000..461f67a0 --- /dev/null +++ b/packages/js-server-sdk/tests/fixtures/template.js @@ -0,0 +1 @@ +export default () => null; diff --git a/packages/js-server-sdk/tests/recordings.test.ts b/packages/js-server-sdk/tests/recordings.test.ts new file mode 100644 index 00000000..95b118d0 --- /dev/null +++ b/packages/js-server-sdk/tests/recordings.test.ts @@ -0,0 +1,99 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { FishjamClient } from '../src/client'; +import { BadRequestException } from '../src/exceptions'; +import type { RecordingId } from '../src/types'; + +const COMPOSITION_URL = 'https://rtc.fishjam.test/api/composition/comp-1'; + +const client = () => new FishjamClient({ fishjamId: 'https://fishjam.test/api/v1/connect/x', managementToken: 'tok' }); + +const templateRecording = { + id: 'rec-1', + status: 'active', + files: [], + source: { compositionURL: COMPOSITION_URL, resolution: { width: 1920, height: 1080 }, audio: false }, +}; + +const stubFetch = (body: unknown, status = 201) => { + const fetch = vi + .fn() + .mockResolvedValue(new Response(JSON.stringify(body), { status, headers: { 'content-type': 'application/json' } })); + vi.stubGlobal('fetch', fetch); + return fetch; +}; + +afterEach(() => { + vi.unstubAllGlobals(); +}); + +describe('createTemplateRecording', () => { + it('uploads the bundle alongside the recording config', async () => { + const fetch = stubFetch({ data: templateRecording }); + + const recording = await client().createTemplateRecording( + { source: { compositionURL: COMPOSITION_URL, resolution: { width: 1920, height: 1080 }, audio: false } }, + new Blob(['bundle']) + ); + + const [url, init] = fetch.mock.calls[0]; + expect(String(url)).toMatch(/\/recordings$/); + expect(init.method).toBe('POST'); + expect(init.headers['Authorization']).toBe('Bearer tok'); + expect(init.headers['Content-Type']).toBeUndefined(); + + const form = init.body as FormData; + expect(await (form.get('template') as Blob).text()).toBe('bundle'); + expect(JSON.parse(await (form.get('config') as Blob).text())).toEqual({ + source: { compositionURL: COMPOSITION_URL, resolution: { width: 1920, height: 1080 }, audio: false }, + }); + + expect(recording.id).toBe('rec-1'); + expect(recording.status).toBe('active'); + }); + + it('reads the bundle from a path', async () => { + const fetch = stubFetch({ data: templateRecording }); + + await client().createTemplateRecording( + { source: { compositionURL: COMPOSITION_URL } }, + `${import.meta.dirname}/fixtures/template.js` + ); + + const form = fetch.mock.calls[0][1].body as FormData; + expect(await (form.get('template') as Blob).text()).toBe('export default () => null;\n'); + expect(JSON.parse(await (form.get('config') as Blob).text())).toEqual({ + source: { compositionURL: COMPOSITION_URL }, + }); + }); + + it('throws BadRequestException when the server rejects the bundle', async () => { + stubFetch({ errors: 'the template has no default export' }, 400); + + await expect( + client().createTemplateRecording({ source: { compositionURL: COMPOSITION_URL } }, new Blob(['bundle'])) + ).rejects.toBeInstanceOf(BadRequestException); + }); +}); + +describe('createRecording', () => { + it('accepts only a composition source', async () => { + stubFetch({ data: templateRecording }); + + // @ts-expect-error a template source needs its bundle, so it goes through createTemplateRecording + await client().createRecording({ source: { compositionURL: COMPOSITION_URL } }); + }); +}); + +describe('reading a template recording', () => { + it('parses the source the server echoes back', async () => { + stubFetch({ data: templateRecording }, 200); + + const recording = await client().getRecording('rec-1' as RecordingId); + + expect(recording.source).toEqual({ + compositionURL: COMPOSITION_URL, + resolution: { width: 1920, height: 1080 }, + audio: false, + }); + }); +});