diff --git a/packages/url-loader/CHANGELOG.md b/packages/url-loader/CHANGELOG.md index 527330c..d14bd37 100644 --- a/packages/url-loader/CHANGELOG.md +++ b/packages/url-loader/CHANGELOG.md @@ -1,3 +1,24 @@ +# [@cloudinary-util/url-loader-v5.3.0-beta.3](https://github.com/cloudinary-community/cloudinary-util/compare/@cloudinary-util/url-loader-v5.3.0-beta.2...@cloudinary-util/url-loader-v5.3.0-beta.3) (2024-05-23) + + +### Features + +* Throw error if no public ID for video player options ([#159](https://github.com/cloudinary-community/cloudinary-util/issues/159)) ([973d5fa](https://github.com/cloudinary-community/cloudinary-util/commit/973d5fa05801e40d99fbdc6bdbb634839b76c120)) + +# [@cloudinary-util/url-loader-v5.3.0-beta.2](https://github.com/cloudinary-community/cloudinary-util/compare/@cloudinary-util/url-loader-v5.3.0-beta.1...@cloudinary-util/url-loader-v5.3.0-beta.2) (2024-05-23) + + +### Bug Fixes + +* getVideoPlayerOptions Exports ([#158](https://github.com/cloudinary-community/cloudinary-util/issues/158)) ([980f423](https://github.com/cloudinary-community/cloudinary-util/commit/980f423790235e2c08acc1bdfbb17b2fa610f445)) + +# [@cloudinary-util/url-loader-v5.3.0-beta.1](https://github.com/cloudinary-community/cloudinary-util/compare/@cloudinary-util/url-loader-v5.2.2...@cloudinary-util/url-loader-v5.3.0-beta.1) (2024-05-23) + + +### Features + +* getVideoPlayerOptions ([#156](https://github.com/cloudinary-community/cloudinary-util/issues/156)) ([da6b56d](https://github.com/cloudinary-community/cloudinary-util/commit/da6b56d591efaf767669be722c0c954720bb9463)), closes [#155](https://github.com/cloudinary-community/cloudinary-util/issues/155) + # [@cloudinary-util/url-loader-v5.2.2](https://github.com/colbyfayock/cloudinary-util/compare/@cloudinary-util/url-loader-v5.2.1...@cloudinary-util/url-loader-v5.2.2) (2024-04-09) diff --git a/packages/url-loader/package.json b/packages/url-loader/package.json index fbbedb8..6cedccc 100644 --- a/packages/url-loader/package.json +++ b/packages/url-loader/package.json @@ -1,6 +1,6 @@ { "name": "@cloudinary-util/url-loader", - "version": "5.2.2", + "version": "5.3.0-beta.3", "main": "./dist/index.js", "module": "./dist/index.mjs", "types": "./dist/index.d.ts", @@ -39,6 +39,7 @@ "access": "public" }, "dependencies": { + "@cloudinary-util/types": "workspace:*", "@cloudinary-util/util": "workspace:*", "@cloudinary/url-gen": "1.15.0", "zod": "^3.22.4" diff --git a/packages/url-loader/src/index.ts b/packages/url-loader/src/index.ts index c4e819f..d6f4fdf 100644 --- a/packages/url-loader/src/index.ts +++ b/packages/url-loader/src/index.ts @@ -1,6 +1,9 @@ export { constructCloudinaryUrl, transformationPlugins } from './lib/cloudinary'; export type { ConstructUrlProps } from './lib/cloudinary'; +export { getVideoPlayerOptions } from './lib/video-player'; +export type { GetVideoPlayerOptions, GetVideoPlayerOptionsLogo } from './lib/video-player'; + export { effects, position, primary, text } from './constants/qualifiers'; export type { AssetOptions } from './types/asset'; diff --git a/packages/url-loader/src/lib/video-player.ts b/packages/url-loader/src/lib/video-player.ts new file mode 100644 index 0000000..b187cb8 --- /dev/null +++ b/packages/url-loader/src/lib/video-player.ts @@ -0,0 +1,173 @@ +import { CloudinaryVideoPlayerOptionsLogo, CloudinaryVideoPlayerOptions } from '@cloudinary-util/types'; +import { parseUrl } from '@cloudinary-util/util'; + +import { constructCloudinaryUrl, ConstructUrlProps } from './cloudinary'; +import { ConfigOptions } from '../types/config'; + +/** + * getVideoPlayerOptions + */ + +export type GetVideoPlayerOptions = Omit & { + logo?: boolean | GetVideoPlayerOptionsLogo; + poster?: string | ConstructUrlProps["options"]; + src: string; + quality?: string | number; +} + +export interface GetVideoPlayerOptionsLogo { + imageUrl?: CloudinaryVideoPlayerOptionsLogo['logoImageUrl']; + logo?: boolean; + onClickUrl?: CloudinaryVideoPlayerOptionsLogo['logoOnclickUrl']; +} + +export function getVideoPlayerOptions(options: GetVideoPlayerOptions, config: ConfigOptions) { + const { + autoplay, + controls = true, + height, + language, + languages, + logo = true, + loop = false, + muted = false, + poster, + src, + transformation, + quality = 'auto', + width, + ...otherCldVidPlayerOptions + } = options; + + // Configuration for Cloudinary account. Cloud name is required, + // so if one isn't present, throw. + + const { cloudName } = config?.cloud || {}; + const { secureDistribution, privateCdn } = config?.url || {}; + + if (!cloudName) { + throw new Error('A Cloudinary Cloud name is required, please make sure your environment variable is set and configured in your environment.'); + } + + // If the publicId/src is a URL, attempt to parse it as a Cloudinary URL + // to get the public ID alone + + let publicId = src || ""; + + if ( publicId.startsWith('http') ) { + try { + const parts = parseUrl(src); + if ( typeof parts?.publicId === 'string' ) { + publicId = parts?.publicId; + } + } catch(e) {} + } + + if ( !publicId ) { + throw new Error('Video Player requires a src, please make sure to configure your src as a public ID or Cloudinary URL.'); + } + + // Normalize player transformations as an array + + const playerTransformations = Array.isArray(transformation) ? transformation : [transformation]; + + // We want to apply a quality transformation which defaults + // to auto, but we want it to be in the beginning of the + // transformations array, in the event someone + // has already passed some in, giving them the opportunity + // to override if desired + + playerTransformations.unshift({ + quality + }); + + // Provide an object configuration option for player logos + + let logoOptions: CloudinaryVideoPlayerOptionsLogo = {}; + + if ( typeof logo === 'boolean' ) { + logoOptions.showLogo = logo; + } else if ( typeof logo === 'object' ) { + logoOptions = { + ...logoOptions, + showLogo: true, + logoImageUrl: logo.imageUrl, + logoOnclickUrl: logo.onClickUrl + } + } + + // Parse the value passed to 'autoplay'; + // if its a boolean or a boolean passed as string ("true") set it directly to browser standard prop autoplay else fallback to default; + // if its a string and not a boolean passed as string ("true") set it to cloudinary video player autoplayMode prop else fallback to undefined; + + let autoplayValue: boolean | 'true' | 'false' = false; + let autoplayModeValue: string | undefined = undefined; + + if (typeof autoplay === 'boolean' || autoplay === 'true' || autoplay === 'false') { + autoplayValue = autoplay; + } + + if (typeof autoplay === 'string' && autoplay !== 'true' && autoplay !== 'false') { + autoplayModeValue = autoplay; + } + + // Finally construct the Player Options object + + let playerOptions: CloudinaryVideoPlayerOptions = { + cloud_name: cloudName, + privateCdn, + secureDistribution, + + autoplayMode: autoplayModeValue, + autoplay: autoplayValue, + controls, + language, + languages, + loop, + muted, + publicId, + width, + height, + aspectRatio: `${width}:${height}`, + transformation: playerTransformations, + ...logoOptions, + ...otherCldVidPlayerOptions, + }; + + if ( typeof poster === 'string' ) { + // If poster is a string, assume it's either a public ID + // or a remote URL, in either case pass to `publicId` + playerOptions.posterOptions = { + publicId: poster + }; + } else if ( typeof poster === 'object' ) { + // If poster is an object, we can either customize the + // automatically generated image from the video or generate + // a completely new image from a separate public ID, so look + // to see if the src is explicitly set to determine whether + // or not to use the video's ID or just pass things along + + if ( typeof poster.src !== 'string' ) { + playerOptions.posterOptions = { + publicId: constructCloudinaryUrl({ + options: { + ...poster, + src: publicId, + assetType: 'video', + format: 'auto:image', + }, + config, + }) + }; + } else { + playerOptions.posterOptions = { + publicId: constructCloudinaryUrl({ + options: poster, + config, + }) + }; + } + } + + return playerOptions; +} \ No newline at end of file diff --git a/packages/url-loader/src/types/config.ts b/packages/url-loader/src/types/config.ts index 75fa0f0..6cc1022 100644 --- a/packages/url-loader/src/types/config.ts +++ b/packages/url-loader/src/types/config.ts @@ -1,5 +1,5 @@ import { z } from 'zod'; -import ICloudinaryConfigurations from '@cloudinary/url-gen/config/interfaces/Config/ICloudinaryConfigurations'; +import ICloudinaryConfigurations from '@cloudinary/url-gen/config/interfaces/Config/ICloudinaryAssetConfigurations'; export interface CloudinaryConfigurationOptions extends ICloudinaryConfigurations {} diff --git a/packages/url-loader/tests/lib/video-player.spec.js b/packages/url-loader/tests/lib/video-player.spec.js new file mode 100644 index 0000000..c866de6 --- /dev/null +++ b/packages/url-loader/tests/lib/video-player.spec.js @@ -0,0 +1,193 @@ +import { describe, it, expect } from 'vitest'; + +import { getVideoPlayerOptions } from '../../src/lib/video-player'; + +describe('video-player', () => { + describe('getVideoPlayerOptions', () => { + describe('Basic', () => { + it('should return create an options object with minimal config', () => { + const options = { + width: '1620', + height: '1080', + src: 'videos/mountain-stars', + } + + const config = { + cloud: { + cloudName: 'testcloud' + } + }; + + const expectedOptions = { + aspectRatio: '1620:1080', + autoplay: false, + autoplayMode: undefined, + cloud_name: 'testcloud', + controls: true, + height: '1080', + language: undefined, + languages: undefined, + loop: false, + muted: false, + privateCdn: undefined, + publicId: 'videos/mountain-stars', + secureDistribution: undefined, + showLogo: true, + transformation: [ + { quality: 'auto' }, + undefined + ], + width: '1620', + } + + expect(getVideoPlayerOptions(options, config)).toMatchObject(expectedOptions) + }); + + it('should configure custom quality', () => { + const options = { + width: '1620', + height: '1080', + src: 'videos/mountain-stars', + quality: 50, + } + + const config = { + cloud: { + cloudName: 'testcloud' + } + }; + + expect(getVideoPlayerOptions(options, config)).toMatchObject({ + transformation: [ + { quality: options.quality }, + undefined + ], + }) + }); + }) + + describe('Playback', () => { + it('should configure ABR via source types', () => { + const options = { + width: '1620', + height: '1080', + src: 'videos/mountain-stars', + transformation: { + streaming_profile: 'hd', + }, + sourceTypes: ['hls'], + } + + const config = { + cloud: { + cloudName: 'testcloud' + } + }; + + expect(getVideoPlayerOptions(options, config)).toMatchObject({ + transformation: [ + { quality: 'auto' }, + { streaming_profile: 'hd' } + ], + sourceTypes: ['hls'], + }); + }); + }) + + describe('Customization', () => { + it('should configure custom logo and colors', () => { + const options = { + width: '1620', + height: '1080', + src: 'videos/mountain-stars', + fontFace: 'Colby', + logo: { + imageUrl: 'https://image.com', + onClickUrl: 'https://spacejelly.dev' + }, + colors: { + accent: '#ff0000', + base: '#00ff00', + text: '#0000ff' + } + } + + const config = { + cloud: { + cloudName: 'testcloud' + } + }; + + expect(getVideoPlayerOptions(options, config)).toMatchObject({ + fontFace: options.fontFace, + logoImageUrl: options.logo.imageUrl, + logoOnclickUrl: options.logo.onClickUrl, + showLogo: true, + colors: options.colors + }); + }); + + it('should set a custom poster using a string', () => { + const options = { + width: '1620', + height: '1080', + src: 'videos/mountain-stars', + poster: 'string' + } + + const config = { + cloud: { + cloudName: 'testcloud' + } + }; + + const playerOptions = getVideoPlayerOptions(options, config); + + expect(playerOptions.posterOptions.publicId).toContain(options.poster) + }); + + it('should set a custom poster using constructCloudinaryUrl options syntax with public ID inherited', () => { + const options = { + width: '1620', + height: '1080', + src: 'videos/mountain-stars', + poster: { + tint: 'equalize:80:blue:blueviolet' + } + } + + const config = { + cloud: { + cloudName: 'testcloud' + } + }; + + const playerOptions = getVideoPlayerOptions(options, config); + + expect(playerOptions.posterOptions.publicId).toContain(`https://res.cloudinary.com/${config.cloud.cloudName}/video/upload/e_tint:${options.poster.tint}/f_auto:image/q_auto/v1/${options.src}`); + }); + + it('should set a custom poster using constructCloudinaryUrl options syntax with custom thumb public ID', () => { + const options = { + width: '1620', + height: '1080', + src: 'videos/mountain-stars', + poster: { + src: 'my-custom-public-id', + tint: 'equalize:80:blue:blueviolet' + } + } + + const config = { + cloud: { + cloudName: 'testcloud' + } + }; + + const playerOptions = getVideoPlayerOptions(options, config); + + expect(playerOptions.posterOptions.publicId).toContain(`https://res.cloudinary.com/${config.cloud.cloudName}/image/upload/e_tint:${options.poster.tint}/f_auto/q_auto/${options.poster.src}`); + }); + }) + }); +}); \ No newline at end of file diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index fa6d0fc..b48783c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -73,13 +73,13 @@ importers: dependencies: eslint-config-next: specifier: latest - version: 14.1.0(eslint@7.32.0)(typescript@5.3.3) + version: 14.2.3(eslint@7.32.0)(typescript@5.3.3) eslint-config-prettier: specifier: ^8.3.0 version: 8.5.0(eslint@7.32.0) eslint-config-turbo: specifier: latest - version: 1.11.3(eslint@7.32.0) + version: 1.13.3(eslint@7.32.0) eslint-plugin-react: specifier: 7.28.0 version: 7.28.0(eslint@7.32.0) @@ -104,6 +104,9 @@ importers: packages/url-loader: dependencies: + '@cloudinary-util/types': + specifier: workspace:* + version: link:../types '@cloudinary-util/util': specifier: workspace:* version: link:../util @@ -1895,8 +1898,8 @@ packages: resolution: {integrity: sha512-irQnbMLbUNQpP1wcE5NstJtbuA/69kRfzBrpAD7Gsn8zm/CY6YQYc3HQBz8QPxwISG26tIm5afvvVbu508oBeQ==} dev: false - /@next/eslint-plugin-next@14.1.0: - resolution: {integrity: sha512-x4FavbNEeXx/baD/zC/SdrvkjSby8nBn8KcCREqk6UuwvwoAPZmaV8TFCAuo/cpovBRTIY67mHhe86MQQm/68Q==} + /@next/eslint-plugin-next@14.2.3: + resolution: {integrity: sha512-L3oDricIIjgj1AVnRdRor21gI7mShlSwU/1ZGHmqM3LzHhXXhdkrfeNY5zif25Bi5Dd7fiJHsbhoZCHfXYvlAw==} dependencies: glob: 10.3.10 dev: false @@ -4383,8 +4386,8 @@ packages: resolution: {integrity: sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw==} engines: {node: '>=12'} - /eslint-config-next@14.1.0(eslint@7.32.0)(typescript@5.3.3): - resolution: {integrity: sha512-SBX2ed7DoRFXC6CQSLc/SbLY9Ut6HxNB2wPTcoIWjUMd7aF7O/SIE7111L8FdZ9TXsNV4pulUDnfthpyPtbFUg==} + /eslint-config-next@14.2.3(eslint@7.32.0)(typescript@5.3.3): + resolution: {integrity: sha512-ZkNztm3Q7hjqvB1rRlOX8P9E/cXRL9ajRcs8jufEtwMfTVYRqnmtnaSu57QqHyBlovMuiB8LEzfLBkh5RYV6Fg==} peerDependencies: eslint: ^7.23.0 || ^8.0.0 typescript: '>=3.3.1' @@ -4392,7 +4395,7 @@ packages: typescript: optional: true dependencies: - '@next/eslint-plugin-next': 14.1.0 + '@next/eslint-plugin-next': 14.2.3 '@rushstack/eslint-patch': 1.5.1 '@typescript-eslint/parser': 5.44.0(eslint@7.32.0)(typescript@5.3.3) eslint: 7.32.0 @@ -4417,13 +4420,13 @@ packages: eslint: 7.32.0 dev: false - /eslint-config-turbo@1.11.3(eslint@7.32.0): - resolution: {integrity: sha512-v7CHpAHodBKlj+r+R3B2DJlZbCjpZLnK7gO/vCRk/Lc+tlD/f04wM6rmHlerevOlchtmwARilRLBnmzNLffTyQ==} + /eslint-config-turbo@1.13.3(eslint@7.32.0): + resolution: {integrity: sha512-if/QtwEiWZ5b7Bg8yZBPSvS0TeCG2Zvfa/+XBYANS7uSYucjmW+BBC8enJB0PqpB/YLGGOumeo3x7h1Nuba9iw==} peerDependencies: eslint: '>6.6.0' dependencies: eslint: 7.32.0 - eslint-plugin-turbo: 1.11.3(eslint@7.32.0) + eslint-plugin-turbo: 1.13.3(eslint@7.32.0) dev: false /eslint-import-resolver-node@0.3.9: @@ -4603,8 +4606,8 @@ packages: string.prototype.matchall: 4.0.8 dev: false - /eslint-plugin-turbo@1.11.3(eslint@7.32.0): - resolution: {integrity: sha512-R5ftTTWQzEYaKzF5g6m/MInCU8pIN+2TLL+S50AYBr1enwUovdZmnZ1HDwFMaxIjJ8x5ah+jvAzql5IJE9VWaA==} + /eslint-plugin-turbo@1.13.3(eslint@7.32.0): + resolution: {integrity: sha512-RjmlnqYsEqnJ+U3M3IS5jLJDjWv5NsvReCpsC61n5pJ4JMHTZ/lU0EIoL1ccuL1L5wP0APzdXdByBxERcPQ+Nw==} peerDependencies: eslint: '>6.6.0' dependencies: