Fix call-recorder REC badge rendering as empty boxes (#23415)

## What was wrong

The bot camera image draws a "REC" pill on top of the workspace logo.
The label used an SVG `<text>` element, and sharp resolves SVG text
through the host's fonts. The runtimes that execute app logic functions
ship no fonts, so every character fell back to an empty box: the badge
showed "▯▯▯" instead of "REC" in real meetings. It looked fine locally
because dev machines have fonts.

## The fix

Draw the label as vector outlines instead of text. "REC" is outlined
once from Inter SemiBold and stored as an SVG path constant, so the
badge renders the same on any host with no font lookup. The pill width
is derived from its contents instead of hardcoded, and tests fail if
`<text>` or `font-family` ever comes back.

<img width="2120" height="1191" alt="CleanShot 2026-07-28 at 19 16 46"
src="https://github.com/user-attachments/assets/c5fa0958-35ba-48da-be9c-a6af81ec2fa0"
/>


1 -- the bug on prod
2 -- how it looks when its not bugged on prod
3 -- this branches changes

<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/23415?utm_source=github"
target="_blank" rel="noopener noreferrer"
data-no-image-dialog="true"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source
media="(prefers-color-scheme: light)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img
alt="Review in cubic"
src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a>
<!-- End of auto-generated description by cubic. -->
This commit is contained in:
nitin
2026-07-28 19:38:39 +05:30
committed by GitHub
parent 1fb1232a17
commit 7b81d9ab83
6 changed files with 155 additions and 32 deletions
@@ -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;
@@ -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 <text> 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,
);
@@ -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<Buffer> => {
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<Buffer> => {
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'),
@@ -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('<text');
expect(svg).not.toContain('font-family');
});
it('renders a badge sized to its contents', async () => {
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);
});
});
@@ -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<Buffer> => {
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 = [
`<svg xmlns="http://www.w3.org/2000/svg" width="${RECALL_BOT_IMAGE_RECORDING_STATUS_BADGE_WIDTH}" height="${RECALL_BOT_IMAGE_RECORDING_STATUS_BADGE_HEIGHT}">`,
`<rect width="${RECALL_BOT_IMAGE_RECORDING_STATUS_BADGE_WIDTH}" height="${RECALL_BOT_IMAGE_RECORDING_STATUS_BADGE_HEIGHT}" rx="${badgeCornerRadius}" fill="${RECORDING_STATUS_BADGE_BACKGROUND}" fill-opacity="0.96" />`,
`<circle cx="${recordingDotCenter}" cy="${recordingDotCenter}" r="${recordingDotRadius}" fill="${RECORDING_STATUS_BADGE_COLOR}" />`,
`<text x="44" y="${recordingStatusTextBaseline}" fill="${RECORDING_STATUS_BADGE_COLOR}" font-family="Arial, Helvetica, sans-serif" font-size="20" font-weight="600" letter-spacing="0">${RECORDING_STATUS_BADGE_TEXT}</text>`,
'</svg>',
].join('');
return sharp(Buffer.from(svg)).png().toBuffer();
};
const loadSharp = async (): Promise<SharpFactory> => {
const sharpModule = await import('sharp');
@@ -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 [
`<svg xmlns="http://www.w3.org/2000/svg" width="${RECORDING_STATUS_BADGE_WIDTH}" height="${RECORDING_STATUS_BADGE_HEIGHT}">`,
`<rect width="${RECORDING_STATUS_BADGE_WIDTH}" height="${RECORDING_STATUS_BADGE_HEIGHT}" rx="${RECORDING_STATUS_BADGE_HEIGHT / 2}" fill="${RECORDING_STATUS_BADGE_BACKGROUND}" fill-opacity="${RECORDING_STATUS_BADGE_BACKGROUND_OPACITY}" />`,
`<circle cx="${RECORDING_STATUS_BADGE_PADDING + dotRadius}" cy="${RECORDING_STATUS_BADGE_HEIGHT / 2}" r="${dotRadius}" fill="${RECORDING_STATUS_BADGE_COLOR}" />`,
`<path d="${RECORDING_STATUS_BADGE_LABEL_PATH}" transform="translate(${RECORDING_STATUS_BADGE_LABEL_LEFT} ${RECORDING_STATUS_BADGE_LABEL_TOP})" fill="${RECORDING_STATUS_BADGE_COLOR}" />`,
'</svg>',
].join('');
};
export const buildRecordingStatusBadge = (
sharp: SharpFactory,
): Promise<Buffer> =>
sharp(Buffer.from(buildRecordingStatusBadgeSvg())).png().toBuffer();