diff --git a/packages/twenty-apps/public/call-recorder/src/logic-functions/constants/recall-bot-image-config.ts b/packages/twenty-apps/public/call-recorder/src/logic-functions/constants/recall-bot-image-config.ts index 81a74534de..4e93838800 100644 --- a/packages/twenty-apps/public/call-recorder/src/logic-functions/constants/recall-bot-image-config.ts +++ b/packages/twenty-apps/public/call-recorder/src/logic-functions/constants/recall-bot-image-config.ts @@ -8,7 +8,3 @@ export const RECALL_BOT_IMAGE_JPEG_QUALITY = 90; export const RECALL_BOT_IMAGE_MIN_JPEG_QUALITY = 40; // Recall rejects images above 1.3MB; stay just under it. export const RECALL_BOT_IMAGE_MAX_BYTES = 1_300_000; -export const RECALL_BOT_IMAGE_RECORDING_STATUS_BADGE_WIDTH = 132; -export const RECALL_BOT_IMAGE_RECORDING_STATUS_BADGE_HEIGHT = 48; -export const RECALL_BOT_IMAGE_RECORDING_STATUS_BADGE_INSET = 56; -export const RECALL_BOT_IMAGE_RECORDING_STATUS_BADGE_DOT_DIAMETER = 12; diff --git a/packages/twenty-apps/public/call-recorder/src/logic-functions/constants/recording-status-badge-config.ts b/packages/twenty-apps/public/call-recorder/src/logic-functions/constants/recording-status-badge-config.ts new file mode 100644 index 0000000000..0ed5cc060d --- /dev/null +++ b/packages/twenty-apps/public/call-recorder/src/logic-functions/constants/recording-status-badge-config.ts @@ -0,0 +1,32 @@ +export const RECORDING_STATUS_BADGE_HEIGHT = 48; +export const RECORDING_STATUS_BADGE_INSET = 56; +export const RECORDING_STATUS_BADGE_PADDING = 18; +export const RECORDING_STATUS_BADGE_DOT_DIAMETER = 12; +export const RECORDING_STATUS_BADGE_DOT_LABEL_GAP = 12; +export const RECORDING_STATUS_BADGE_BACKGROUND = '#feebec'; +export const RECORDING_STATUS_BADGE_BACKGROUND_OPACITY = 0.96; +export const RECORDING_STATUS_BADGE_COLOR = '#ce2c31'; +export const RECORDING_STATUS_BADGE_LABEL_CAP_HEIGHT = 14; + +// "REC" outlined from Inter SemiBold (14px cap height, 2px tracking); the app runtimes ship no fonts, so SVG renders as boxes. +export const RECORDING_STATUS_BADGE_LABEL_PATH = + 'M3.92 14L1.38 14L1.38 0L6.63 0Q8.24 0 9.34 0.56Q10.44 1.12 11.00 2.13Q11.57 3.14 11.57 4.48L11.57 4.48Q11.57 5.82 11.00 6.81Q10.42 7.80 9.32 8.34Q8.21 8.87 6.60 8.87L6.60 8.87L2.86 8.87L2.86 6.77L6.25 6.77Q7.20 6.77 7.80 6.50Q8.40 6.24 8.69 5.73Q8.98 5.22 8.98 4.48L8.98 4.48Q8.98 3.73 8.69 3.20Q8.39 2.67 7.79 2.40Q7.19 2.12 6.24 2.12L6.24 2.12L3.92 2.12L3.92 14Z' + + 'M5.84 7.66L8.61 7.66L12.08 14L9.25 14L5.84 7.66Z' + + 'M25.06 14L15.90 14L15.90 0L25.01 0L25.01 2.13L18.44 2.13L18.44 5.93L24.54 5.93L24.54 8.05L18.44 8.05L18.44 11.87L25.06 11.87L25.06 14Z' + + 'M41.58 4.72L41.58 4.72L39.03 4.72Q38.92 4.09 38.62 3.61Q38.33 3.12 37.89 2.78Q37.46 2.43 36.90 2.26Q36.34 2.08 35.70 2.08L35.70 2.08Q34.56 2.08 33.67 2.66Q32.79 3.23 32.29 4.32Q31.79 5.42 31.79 7L31.79 7Q31.79 8.61 32.30 9.70Q32.80 10.80 33.68 11.36Q34.56 11.92 35.69 11.92L35.69 11.92Q36.32 11.92 36.87 11.75Q37.42 11.58 37.86 11.25Q38.30 10.92 38.61 10.45Q38.91 9.97 39.03 9.35L39.03 9.35L41.58 9.37Q41.44 10.36 40.97 11.24Q40.49 12.11 39.72 12.78Q38.96 13.45 37.93 13.82Q36.91 14.19 35.66 14.19L35.66 14.19Q33.81 14.19 32.36 13.34Q30.91 12.48 30.08 10.87Q29.25 9.26 29.25 7L29.25 7Q29.25 4.74 30.09 3.13Q30.93 1.52 32.38 0.66Q33.83-0.19 35.66-0.19L35.66-0.19Q36.83-0.19 37.83 0.14Q38.84 0.46 39.62 1.10Q40.41 1.73 40.92 2.64Q41.43 3.55 41.58 4.72Z'; + +export const RECORDING_STATUS_BADGE_LABEL_WIDTH = 42.53; + +export const RECORDING_STATUS_BADGE_LABEL_LEFT = + RECORDING_STATUS_BADGE_PADDING + + RECORDING_STATUS_BADGE_DOT_DIAMETER + + RECORDING_STATUS_BADGE_DOT_LABEL_GAP; + +export const RECORDING_STATUS_BADGE_LABEL_TOP = + (RECORDING_STATUS_BADGE_HEIGHT - RECORDING_STATUS_BADGE_LABEL_CAP_HEIGHT) / 2; + +export const RECORDING_STATUS_BADGE_WIDTH = Math.round( + RECORDING_STATUS_BADGE_LABEL_LEFT + + RECORDING_STATUS_BADGE_LABEL_WIDTH + + RECORDING_STATUS_BADGE_PADDING, +); diff --git a/packages/twenty-apps/public/call-recorder/src/logic-functions/domain/__tests__/build-bot-image.util.test.ts b/packages/twenty-apps/public/call-recorder/src/logic-functions/domain/__tests__/build-bot-image.util.test.ts index 0af066ccf8..1afd4c057f 100644 --- a/packages/twenty-apps/public/call-recorder/src/logic-functions/domain/__tests__/build-bot-image.util.test.ts +++ b/packages/twenty-apps/public/call-recorder/src/logic-functions/domain/__tests__/build-bot-image.util.test.ts @@ -7,6 +7,11 @@ import { RECALL_BOT_IMAGE_MAX_BYTES, RECALL_BOT_IMAGE_WIDTH, } from 'src/logic-functions/constants/recall-bot-image-config'; +import { + RECORDING_STATUS_BADGE_HEIGHT, + RECORDING_STATUS_BADGE_INSET, + RECORDING_STATUS_BADGE_WIDTH, +} from 'src/logic-functions/constants/recording-status-badge-config'; const createLogo = (format: 'png' | 'gif' | 'jpeg'): Promise => { const image = sharp({ @@ -83,6 +88,34 @@ describe('buildBotImage', () => { expect(metadata.format).toBe('jpeg'); }); + it('composites the recording badge at the badge inset', async () => { + const logoBuffer = await createLogo('png'); + + const readBadgeRegion = async ( + withRecordingStatusBadge: boolean, + ): Promise => { + const base64Jpeg = await buildBotImage({ + logoBuffer, + background: '#ffffff', + withRecordingStatusBadge, + }); + + return sharp(Buffer.from(base64Jpeg as string, 'base64')) + .extract({ + top: RECORDING_STATUS_BADGE_INSET, + left: RECORDING_STATUS_BADGE_INSET, + width: RECORDING_STATUS_BADGE_WIDTH, + height: RECORDING_STATUS_BADGE_HEIGHT, + }) + .raw() + .toBuffer(); + }; + + expect(await readBadgeRegion(true)).not.toEqual( + await readBadgeRegion(false), + ); + }); + it('returns undefined when the source cannot be decoded', async () => { const result = await buildBotImage({ logoBuffer: Buffer.from('not-an-image'), diff --git a/packages/twenty-apps/public/call-recorder/src/logic-functions/domain/__tests__/build-recording-status-badge.util.test.ts b/packages/twenty-apps/public/call-recorder/src/logic-functions/domain/__tests__/build-recording-status-badge.util.test.ts new file mode 100644 index 0000000000..ac9a6bccb4 --- /dev/null +++ b/packages/twenty-apps/public/call-recorder/src/logic-functions/domain/__tests__/build-recording-status-badge.util.test.ts @@ -0,0 +1,55 @@ +import sharp from 'sharp'; +import { describe, expect, it } from 'vitest'; + +import { + RECORDING_STATUS_BADGE_HEIGHT, + RECORDING_STATUS_BADGE_LABEL_LEFT, + RECORDING_STATUS_BADGE_WIDTH, +} from 'src/logic-functions/constants/recording-status-badge-config'; +import { + buildRecordingStatusBadge, + buildRecordingStatusBadgeSvg, +} from 'src/logic-functions/domain/build-recording-status-badge.util'; + +describe('buildRecordingStatusBadge', () => { + it('draws the label without depending on any system font', () => { + const svg = buildRecordingStatusBadgeSvg(); + + expect(svg).not.toContain(' { + const metadata = await sharp( + await buildRecordingStatusBadge(sharp), + ).metadata(); + + expect(metadata.width).toBe(RECORDING_STATUS_BADGE_WIDTH); + expect(metadata.height).toBe(RECORDING_STATUS_BADGE_HEIGHT); + }); + + it('rasterizes label ink to the right of the dot', async () => { + const { data, info } = await sharp(await buildRecordingStatusBadge(sharp)) + .ensureAlpha() + .raw() + .toBuffer({ resolveWithObject: true }); + + let labelInkPixelCount = 0; + + for (let y = 0; y < info.height; y++) { + for (let x = RECORDING_STATUS_BADGE_LABEL_LEFT; x < info.width; x++) { + const offset = (y * info.width + x) * info.channels; + + if ( + data[offset] > 150 && + data[offset + 1] < 120 && + data[offset + 2] < 120 + ) { + labelInkPixelCount++; + } + } + } + + expect(labelInkPixelCount).toBeGreaterThan(100); + }); +}); diff --git a/packages/twenty-apps/public/call-recorder/src/logic-functions/domain/build-bot-image.util.ts b/packages/twenty-apps/public/call-recorder/src/logic-functions/domain/build-bot-image.util.ts index 0362de4d84..aba046e598 100644 --- a/packages/twenty-apps/public/call-recorder/src/logic-functions/domain/build-bot-image.util.ts +++ b/packages/twenty-apps/public/call-recorder/src/logic-functions/domain/build-bot-image.util.ts @@ -7,17 +7,12 @@ import { RECALL_BOT_IMAGE_LOGO_MAX_WIDTH, RECALL_BOT_IMAGE_MAX_BYTES, RECALL_BOT_IMAGE_MIN_JPEG_QUALITY, - RECALL_BOT_IMAGE_RECORDING_STATUS_BADGE_DOT_DIAMETER, - RECALL_BOT_IMAGE_RECORDING_STATUS_BADGE_HEIGHT, - RECALL_BOT_IMAGE_RECORDING_STATUS_BADGE_INSET, - RECALL_BOT_IMAGE_RECORDING_STATUS_BADGE_WIDTH, RECALL_BOT_IMAGE_WIDTH, } from 'src/logic-functions/constants/recall-bot-image-config'; +import { RECORDING_STATUS_BADGE_INSET } from 'src/logic-functions/constants/recording-status-badge-config'; +import { buildRecordingStatusBadge } from 'src/logic-functions/domain/build-recording-status-badge.util'; const JPEG_QUALITY_STEP = 10; -const RECORDING_STATUS_BADGE_BACKGROUND = '#feebec'; -const RECORDING_STATUS_BADGE_COLOR = '#ce2c31'; -const RECORDING_STATUS_BADGE_TEXT = 'REC'; type SharpFactory = typeof import('sharp'); @@ -50,8 +45,8 @@ export const buildBotImage = async ({ if (withRecordingStatusBadge) { composites.push({ input: await buildRecordingStatusBadge(sharp), - top: RECALL_BOT_IMAGE_RECORDING_STATUS_BADGE_INSET, - left: RECALL_BOT_IMAGE_RECORDING_STATUS_BADGE_INSET, + top: RECORDING_STATUS_BADGE_INSET, + left: RECORDING_STATUS_BADGE_INSET, }); } @@ -107,25 +102,6 @@ const composeJpeg = ({ .jpeg({ quality, mozjpeg: true }) .toBuffer(); -const buildRecordingStatusBadge = (sharp: SharpFactory): Promise => { - const badgeCornerRadius = RECALL_BOT_IMAGE_RECORDING_STATUS_BADGE_HEIGHT / 2; - const recordingDotCenter = RECALL_BOT_IMAGE_RECORDING_STATUS_BADGE_HEIGHT / 2; - const recordingDotRadius = - RECALL_BOT_IMAGE_RECORDING_STATUS_BADGE_DOT_DIAMETER / 2; - const recordingStatusTextBaseline = - RECALL_BOT_IMAGE_RECORDING_STATUS_BADGE_HEIGHT / 2 + 7; - - const svg = [ - ``, - ``, - ``, - `${RECORDING_STATUS_BADGE_TEXT}`, - '', - ].join(''); - - return sharp(Buffer.from(svg)).png().toBuffer(); -}; - const loadSharp = async (): Promise => { const sharpModule = await import('sharp'); diff --git a/packages/twenty-apps/public/call-recorder/src/logic-functions/domain/build-recording-status-badge.util.ts b/packages/twenty-apps/public/call-recorder/src/logic-functions/domain/build-recording-status-badge.util.ts new file mode 100644 index 0000000000..b173e31d01 --- /dev/null +++ b/packages/twenty-apps/public/call-recorder/src/logic-functions/domain/build-recording-status-badge.util.ts @@ -0,0 +1,31 @@ +import { + RECORDING_STATUS_BADGE_BACKGROUND, + RECORDING_STATUS_BADGE_BACKGROUND_OPACITY, + RECORDING_STATUS_BADGE_COLOR, + RECORDING_STATUS_BADGE_DOT_DIAMETER, + RECORDING_STATUS_BADGE_HEIGHT, + RECORDING_STATUS_BADGE_LABEL_LEFT, + RECORDING_STATUS_BADGE_LABEL_PATH, + RECORDING_STATUS_BADGE_LABEL_TOP, + RECORDING_STATUS_BADGE_PADDING, + RECORDING_STATUS_BADGE_WIDTH, +} from 'src/logic-functions/constants/recording-status-badge-config'; + +type SharpFactory = typeof import('sharp'); + +export const buildRecordingStatusBadgeSvg = (): string => { + const dotRadius = RECORDING_STATUS_BADGE_DOT_DIAMETER / 2; + + return [ + ``, + ``, + ``, + ``, + '', + ].join(''); +}; + +export const buildRecordingStatusBadge = ( + sharp: SharpFactory, +): Promise => + sharp(Buffer.from(buildRecordingStatusBadgeSvg())).png().toBuffer();