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:
-4
@@ -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;
|
||||
|
||||
+32
@@ -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,
|
||||
);
|
||||
+33
@@ -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'),
|
||||
|
||||
+55
@@ -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);
|
||||
});
|
||||
});
|
||||
+4
-28
@@ -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');
|
||||
|
||||
|
||||
+31
@@ -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();
|
||||
Reference in New Issue
Block a user