From 293ff4c462a620dc1c4362307a333602b4f6d5d7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rapha=C3=ABl=20Bosi?= <71827178+bosiraphael@users.noreply.github.com> Date: Tue, 23 Jun 2026 15:41:19 +0200 Subject: [PATCH] Auto-generate app cover images from the app logo (#22011) image ## What When an app is built (`twenty build` / `twenty publish`), the SDK now generates a marketplace cover image and sets it as the app's screenshot, but only when the app declares a `logoUrl` and has no `screenshots`. The cover composites the app's logo and the Twenty logo over the branded halftone backdrop, matching the design reference. ## Why Most apps ship a logo but no screenshots, so their marketplace detail page had no hero visual. This gives them a polished cover for free, with no per-app design work. ## Notes for reviewers - Generation lives in the build path (`operations/build.ts`), not `buildManifest`, so `twenty dev` and the shared manifest builder are untouched. It is best-effort: on failure it logs a warning and the build continues. - The cover is written to `.twenty/output` and registered as a public asset + screenshot, so the existing copy/checksum/serve pipeline handles it unchanged. No app source files are modified. - Adds `sharp` as a runtime dependency of `twenty-sdk` (a build-time tool, like `esbuild`); it is not bundled into built apps. Review in cubic --- packages/twenty-sdk/package.json | 1 + .../twenty-sdk/src/cli/operations/build.ts | 19 ++- .../build/common/build-application.ts | 40 ++++++- .../__tests__/apply-generated-cover.test.ts | 109 ++++++++++++++++++ .../__tests__/generate-cover-image.test.ts | 38 ++++++ .../build/cover/apply-generated-cover.ts | 67 +++++++++++ .../build/cover/assets/halftone-backdrop.png | Bin 0 -> 28326 bytes .../cover/assets/twenty-logo-mark-path.ts | 2 + .../build/cover/generate-cover-image.ts | 67 +++++++++++ .../build/cover/generated-asset.type.ts | 4 + .../build/cover/generated-cover-path.ts | 3 + packages/twenty-sdk/vite.config.node.ts | 16 +++ yarn.lock | 1 + 13 files changed, 365 insertions(+), 2 deletions(-) create mode 100644 packages/twenty-sdk/src/cli/utilities/build/cover/__tests__/apply-generated-cover.test.ts create mode 100644 packages/twenty-sdk/src/cli/utilities/build/cover/__tests__/generate-cover-image.test.ts create mode 100644 packages/twenty-sdk/src/cli/utilities/build/cover/apply-generated-cover.ts create mode 100644 packages/twenty-sdk/src/cli/utilities/build/cover/assets/halftone-backdrop.png create mode 100644 packages/twenty-sdk/src/cli/utilities/build/cover/assets/twenty-logo-mark-path.ts create mode 100644 packages/twenty-sdk/src/cli/utilities/build/cover/generate-cover-image.ts create mode 100644 packages/twenty-sdk/src/cli/utilities/build/cover/generated-asset.type.ts create mode 100644 packages/twenty-sdk/src/cli/utilities/build/cover/generated-cover-path.ts diff --git a/packages/twenty-sdk/package.json b/packages/twenty-sdk/package.json index 6d16fed3a8..76bc39f907 100644 --- a/packages/twenty-sdk/package.json +++ b/packages/twenty-sdk/package.json @@ -86,6 +86,7 @@ "react": "^19.2.0", "react-dom": "^19.2.0", "semver": "7.6.3", + "sharp": "^0.34.5", "tinyglobby": "^0.2.15", "twenty-client-sdk": "workspace:*", "typescript": "^5.9.3", diff --git a/packages/twenty-sdk/src/cli/operations/build.ts b/packages/twenty-sdk/src/cli/operations/build.ts index 99f3ef6ad1..b585bf18b5 100644 --- a/packages/twenty-sdk/src/cli/operations/build.ts +++ b/packages/twenty-sdk/src/cli/operations/build.ts @@ -1,6 +1,7 @@ import { execSync } from 'child_process'; import path from 'path'; +import { applyGeneratedCover } from '@/cli/utilities/build/cover/apply-generated-cover'; import { buildApplication } from '@/cli/utilities/build/common/build-application'; import { runTypecheck } from '@/cli/utilities/build/common/typecheck-plugin'; import { buildAndValidateManifest } from '@/cli/utilities/build/manifest/build-and-validate-manifest'; @@ -40,18 +41,34 @@ const innerAppBuild = async ( }; } - const { manifest, filePaths } = manifestResult; + const { filePaths } = manifestResult; for (const warning of manifestResult.warnings) { onProgress?.(`⚠ ${warning}`); } + const { manifest, generatedAssets } = await applyGeneratedCover({ + appPath, + manifest: manifestResult.manifest, + }).catch((error) => { + onProgress?.( + `⚠ Skipped cover image generation: ${error instanceof Error ? error.message : String(error)}`, + ); + + return { manifest: manifestResult.manifest, generatedAssets: [] }; + }); + + if (generatedAssets.length > 0) { + onProgress?.('Generated cover image from logo'); + } + onProgress?.('Building application files...'); const buildResult = await buildApplication({ appPath, manifest, filePaths, + generatedAssets, }); onProgress?.('Running typecheck...'); diff --git a/packages/twenty-sdk/src/cli/utilities/build/common/build-application.ts b/packages/twenty-sdk/src/cli/utilities/build/common/build-application.ts index cd38cda79f..4ae37d6b83 100644 --- a/packages/twenty-sdk/src/cli/utilities/build/common/build-application.ts +++ b/packages/twenty-sdk/src/cli/utilities/build/common/build-application.ts @@ -1,5 +1,5 @@ import crypto from 'crypto'; -import { readFile } from 'node:fs/promises'; +import { readFile, writeFile } from 'node:fs/promises'; import { dirname, join } from 'path'; import { NODE_ESM_CJS_BANNER, @@ -8,6 +8,7 @@ import { } from 'twenty-shared/application'; import { FileFolder } from 'twenty-shared/types'; +import { type GeneratedAsset } from '@/cli/utilities/build/cover/generated-asset.type'; import { esbuildOneShotBuild } from '@/cli/utilities/build/common/esbuild-one-shot-build'; import { LOGIC_FUNCTION_EXTERNAL_MODULES } from '@/cli/utilities/build/common/esbuild-watcher'; import { getBaseFrontComponentBuildOptions } from '@/cli/utilities/build/common/front-component-build/utils/get-base-front-component-build-options'; @@ -27,6 +28,7 @@ export type AppBuildOptions = { appPath: string; manifest: Manifest; filePaths: EntityFilePaths; + generatedAssets?: GeneratedAsset[]; }; export type BuiltFileInfo = { @@ -121,9 +123,45 @@ export const buildApplication = async ( collectFileBuilt, }); + for (const generatedAsset of options.generatedAssets ?? []) { + await writeGeneratedAsset({ + appPath: options.appPath, + generatedAsset, + collectFileBuilt, + }); + } + return { builtFileInfos }; }; +const writeGeneratedAsset = async ({ + appPath, + generatedAsset, + collectFileBuilt, +}: { + appPath: string; + generatedAsset: GeneratedAsset; + collectFileBuilt: OnFileBuiltCallback; +}) => { + const builtPath = join(OUTPUT_DIR, generatedAsset.relativePath); + const absoluteBuiltPath = join(appPath, builtPath); + + await ensureDir(dirname(absoluteBuiltPath)); + await writeFile(absoluteBuiltPath, generatedAsset.content); + + const checksum = crypto + .createHash('md5') + .update(generatedAsset.content) + .digest('hex'); + + collectFileBuilt({ + fileFolder: FileFolder.PublicAsset, + builtPath, + sourcePath: generatedAsset.relativePath, + checksum, + }); +}; + const copyStaticFiles = async ({ appPath, fileFolder, diff --git a/packages/twenty-sdk/src/cli/utilities/build/cover/__tests__/apply-generated-cover.test.ts b/packages/twenty-sdk/src/cli/utilities/build/cover/__tests__/apply-generated-cover.test.ts new file mode 100644 index 0000000000..199dbdbf5f --- /dev/null +++ b/packages/twenty-sdk/src/cli/utilities/build/cover/__tests__/apply-generated-cover.test.ts @@ -0,0 +1,109 @@ +import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { type Manifest } from 'twenty-shared/application'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { applyGeneratedCover } from '@/cli/utilities/build/cover/apply-generated-cover'; +import { generateCoverImage } from '@/cli/utilities/build/cover/generate-cover-image'; +import { GENERATED_COVER_PATH } from '@/cli/utilities/build/cover/generated-cover-path'; + +vi.mock('@/cli/utilities/build/cover/generate-cover-image'); + +const mockedGenerateCoverImage = vi.mocked(generateCoverImage); + +const COVER_BUFFER = Buffer.from('generated-cover'); + +const buildManifest = ( + application: Record, + publicAssets: Manifest['publicAssets'] = [], +): Manifest => + ({ + application, + publicAssets, + }) as unknown as Manifest; + +describe('applyGeneratedCover', () => { + let appPath: string; + + beforeEach(async () => { + vi.clearAllMocks(); + mockedGenerateCoverImage.mockResolvedValue(COVER_BUFFER); + appPath = await mkdtemp(join(tmpdir(), 'cover-test-')); + }); + + afterEach(async () => { + await rm(appPath, { recursive: true, force: true }); + }); + + const writeLogo = async (relativePath: string) => { + await mkdir(join(appPath, 'public'), { recursive: true }); + await writeFile(join(appPath, relativePath), Buffer.from('logo')); + }; + + it('generates a cover when a local logo exists and no screenshots are set', async () => { + await writeLogo('public/logo.png'); + const manifest = buildManifest({ logoUrl: 'public/logo.png' }); + + const result = await applyGeneratedCover({ appPath, manifest }); + + expect(mockedGenerateCoverImage).toHaveBeenCalledTimes(1); + expect(result.manifest.application.screenshots).toEqual([ + GENERATED_COVER_PATH, + ]); + expect( + result.manifest.publicAssets.some( + (asset) => asset.filePath === GENERATED_COVER_PATH, + ), + ).toBe(true); + expect(result.generatedAssets).toEqual([ + { relativePath: GENERATED_COVER_PATH, content: COVER_BUFFER }, + ]); + }); + + it('does nothing when there is no logo', async () => { + const manifest = buildManifest({}); + + const result = await applyGeneratedCover({ appPath, manifest }); + + expect(mockedGenerateCoverImage).not.toHaveBeenCalled(); + expect(result.generatedAssets).toEqual([]); + expect(result.manifest.application.screenshots).toBeUndefined(); + }); + + it('does nothing when screenshots are already provided', async () => { + await writeLogo('public/logo.png'); + const manifest = buildManifest({ + logoUrl: 'public/logo.png', + screenshots: ['public/shot.png'], + }); + + const result = await applyGeneratedCover({ appPath, manifest }); + + expect(mockedGenerateCoverImage).not.toHaveBeenCalled(); + expect(result.generatedAssets).toEqual([]); + expect(result.manifest.application.screenshots).toEqual([ + 'public/shot.png', + ]); + }); + + it('does nothing when the logo is an absolute url', async () => { + const manifest = buildManifest({ + logoUrl: 'https://example.com/logo.png', + }); + + const result = await applyGeneratedCover({ appPath, manifest }); + + expect(mockedGenerateCoverImage).not.toHaveBeenCalled(); + expect(result.generatedAssets).toEqual([]); + }); + + it('does nothing when the logo file is missing', async () => { + const manifest = buildManifest({ logoUrl: 'public/missing.png' }); + + const result = await applyGeneratedCover({ appPath, manifest }); + + expect(mockedGenerateCoverImage).not.toHaveBeenCalled(); + expect(result.generatedAssets).toEqual([]); + }); +}); diff --git a/packages/twenty-sdk/src/cli/utilities/build/cover/__tests__/generate-cover-image.test.ts b/packages/twenty-sdk/src/cli/utilities/build/cover/__tests__/generate-cover-image.test.ts new file mode 100644 index 0000000000..6090edde31 --- /dev/null +++ b/packages/twenty-sdk/src/cli/utilities/build/cover/__tests__/generate-cover-image.test.ts @@ -0,0 +1,38 @@ +import sharp from 'sharp'; +import { describe, expect, it } from 'vitest'; + +import { generateCoverImage } from '@/cli/utilities/build/cover/generate-cover-image'; + +const createLogoBuffer = () => + sharp({ + create: { + width: 200, + height: 200, + channels: 4, + background: { r: 108, g: 92, b: 224, alpha: 1 }, + }, + }) + .png() + .toBuffer(); + +describe('generateCoverImage', () => { + it('produces a 1600x1000 png from a logo buffer', async () => { + const logoBuffer = await createLogoBuffer(); + + const cover = await generateCoverImage({ logoBuffer }); + + const metadata = await sharp(cover).metadata(); + expect(metadata.format).toBe('png'); + expect(metadata.width).toBe(1388); + expect(metadata.height).toBe(858); + }); + + it('renders deterministically for the same logo', async () => { + const logoBuffer = await createLogoBuffer(); + + const first = await generateCoverImage({ logoBuffer }); + const second = await generateCoverImage({ logoBuffer }); + + expect(first.equals(second)).toBe(true); + }); +}); diff --git a/packages/twenty-sdk/src/cli/utilities/build/cover/apply-generated-cover.ts b/packages/twenty-sdk/src/cli/utilities/build/cover/apply-generated-cover.ts new file mode 100644 index 0000000000..3bc206f8a1 --- /dev/null +++ b/packages/twenty-sdk/src/cli/utilities/build/cover/apply-generated-cover.ts @@ -0,0 +1,67 @@ +import { readFile } from 'node:fs/promises'; +import { basename, extname, join } from 'path'; +import { type AssetManifest, type Manifest } from 'twenty-shared/application'; +import { isDefined } from 'twenty-shared/utils'; + +import { generateCoverImage } from '@/cli/utilities/build/cover/generate-cover-image'; +import { type GeneratedAsset } from '@/cli/utilities/build/cover/generated-asset.type'; +import { GENERATED_COVER_PATH } from '@/cli/utilities/build/cover/generated-cover-path'; +import { pathExists } from '@/cli/utilities/file/fs-utils'; + +const isAbsoluteUrl = (url: string): boolean => + url.startsWith('http://') || url.startsWith('https://'); + +export const applyGeneratedCover = async ({ + appPath, + manifest, +}: { + appPath: string; + manifest: Manifest; +}): Promise<{ manifest: Manifest; generatedAssets: GeneratedAsset[] }> => { + const { logoUrl, screenshots } = manifest.application; + + if (!isDefined(logoUrl) || isAbsoluteUrl(logoUrl)) { + return { manifest, generatedAssets: [] }; + } + + if ((screenshots ?? []).length > 0) { + return { manifest, generatedAssets: [] }; + } + + const logoPath = join(appPath, logoUrl); + + if (!(await pathExists(logoPath))) { + return { manifest, generatedAssets: [] }; + } + + const logoBuffer = await readFile(logoPath); + const coverBuffer = await generateCoverImage({ logoBuffer }); + + const coverAsset: AssetManifest = { + filePath: GENERATED_COVER_PATH, + fileName: basename(GENERATED_COVER_PATH), + fileType: extname(GENERATED_COVER_PATH).replace(/^\./, ''), + checksum: null, + }; + + const publicAssets = [ + ...manifest.publicAssets.filter( + (asset) => asset.filePath !== GENERATED_COVER_PATH, + ), + coverAsset, + ]; + + return { + manifest: { + ...manifest, + application: { + ...manifest.application, + screenshots: [GENERATED_COVER_PATH], + }, + publicAssets, + }, + generatedAssets: [ + { relativePath: GENERATED_COVER_PATH, content: coverBuffer }, + ], + }; +}; diff --git a/packages/twenty-sdk/src/cli/utilities/build/cover/assets/halftone-backdrop.png b/packages/twenty-sdk/src/cli/utilities/build/cover/assets/halftone-backdrop.png new file mode 100644 index 0000000000000000000000000000000000000000..7b994a6e96492ba9318f3818bd34a31b0552a854 GIT binary patch literal 28326 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 Iq-oIq0m1OoKL7v# literal 0 HcmV?d00001 diff --git a/packages/twenty-sdk/src/cli/utilities/build/cover/assets/twenty-logo-mark-path.ts b/packages/twenty-sdk/src/cli/utilities/build/cover/assets/twenty-logo-mark-path.ts new file mode 100644 index 0000000000..d4ba4a71ac --- /dev/null +++ b/packages/twenty-sdk/src/cli/utilities/build/cover/assets/twenty-logo-mark-path.ts @@ -0,0 +1,2 @@ +export const TWENTY_LOGO_MARK_PATH = + 'M6.822 14.174c0-2.435 1.996-4.411 4.456-4.411h8.574c.125 0 .241.075.293.19a.31.31 0 0 1-.056.344l-1.88 2.023c-.326.35-.787.552-1.27.552H11.3c-.738 0-1.338.594-1.338 1.325v3.336a.78.78 0 0 1-.783.777H7.61a.78.78 0 0 1-.783-.777v-3.36zM33.5 25.553c0 2.434-1.996 4.411-4.456 4.411h-3.642c-2.46 0-4.454-1.977-4.454-4.411v-6.315c0-.43.16-.842.456-1.16l2.124-2.285a.33.33 0 0 1 .355-.081.32.32 0 0 1 .205.295v9.527c0 .73.598 1.322 1.337 1.322h3.6a1.33 1.33 0 0 0 1.337-1.322V14.197c0-.73-.599-1.325-1.337-1.325H24.84c-.481 0-.938.201-1.265.547L11.088 26.856h7.503a.78.78 0 0 1 .784.778v1.552a.78.78 0 0 1-.784.778H8.481a1.655 1.655 0 0 1-1.662-1.644v-.824c0-.412.156-.809.44-1.114l13.999-15.06a4.9 4.9 0 0 1 3.594-1.56h4.189c2.46 0 4.454 1.977 4.454 4.412v11.379z'; diff --git a/packages/twenty-sdk/src/cli/utilities/build/cover/generate-cover-image.ts b/packages/twenty-sdk/src/cli/utilities/build/cover/generate-cover-image.ts new file mode 100644 index 0000000000..be6be36a92 --- /dev/null +++ b/packages/twenty-sdk/src/cli/utilities/build/cover/generate-cover-image.ts @@ -0,0 +1,67 @@ +import { readFile } from 'node:fs/promises'; +import { join } from 'node:path'; +import sharp from 'sharp'; + +import { TWENTY_LOGO_MARK_PATH } from '@/cli/utilities/build/cover/assets/twenty-logo-mark-path'; + +const readHalftoneBackdropDataUri = async (): Promise => { + const backdropBuffer = await readFile( + join(__dirname, 'assets', 'halftone-backdrop.png'), + ); + + return `data:image/png;base64,${backdropBuffer.toString('base64')}`; +}; + +const CANVAS_WIDTH = 1388; +const CANVAS_HEIGHT = 858; +const TILE_SIZE = 156; +const TILE_RADIUS = 16; +const TILE_Y = 351; +const LEFT_TILE_X = 434; +const RIGHT_TILE_X = 798; +const CENTER_X = CANVAS_WIDTH / 2; +const CENTER_Y = CANVAS_HEIGHT / 2; +const CROSS_ARM = 19; +const CROSS_STROKE = 8; +const CROSS_COLOR = '#b3b3b3'; +const TWENTY_MARK_VIEWBOX = 40; +const TWENTY_MARK_SCALE = TILE_SIZE / TWENTY_MARK_VIEWBOX; + +type GenerateCoverImageOptions = { + logoBuffer: Buffer; +}; + +export const generateCoverImage = async ({ + logoBuffer, +}: GenerateCoverImageOptions): Promise => { + const logoPngBuffer = await sharp(logoBuffer) + .resize(TILE_SIZE, TILE_SIZE, { fit: 'cover', position: 'centre' }) + .png() + .toBuffer(); + const logoDataUri = `data:image/png;base64,${logoPngBuffer.toString('base64')}`; + + const backdropDataUri = await readHalftoneBackdropDataUri(); + + const svg = [ + ``, + ``, + '', + '', + '', + '', + ``, + '', + '', + ``, + ``, + '', + ``, + ``, + ``, + ``, + ``, + '', + ].join(''); + + return sharp(Buffer.from(svg)).png().toBuffer(); +}; diff --git a/packages/twenty-sdk/src/cli/utilities/build/cover/generated-asset.type.ts b/packages/twenty-sdk/src/cli/utilities/build/cover/generated-asset.type.ts new file mode 100644 index 0000000000..2222092a7e --- /dev/null +++ b/packages/twenty-sdk/src/cli/utilities/build/cover/generated-asset.type.ts @@ -0,0 +1,4 @@ +export type GeneratedAsset = { + relativePath: string; + content: Buffer; +}; diff --git a/packages/twenty-sdk/src/cli/utilities/build/cover/generated-cover-path.ts b/packages/twenty-sdk/src/cli/utilities/build/cover/generated-cover-path.ts new file mode 100644 index 0000000000..df63407d2a --- /dev/null +++ b/packages/twenty-sdk/src/cli/utilities/build/cover/generated-cover-path.ts @@ -0,0 +1,3 @@ +import { ASSETS_DIR } from 'twenty-shared/application'; + +export const GENERATED_COVER_PATH = `${ASSETS_DIR}/cover.generated.png`; diff --git a/packages/twenty-sdk/vite.config.node.ts b/packages/twenty-sdk/vite.config.node.ts index fca9a0783f..4ebb1f325e 100644 --- a/packages/twenty-sdk/vite.config.node.ts +++ b/packages/twenty-sdk/vite.config.node.ts @@ -1,3 +1,4 @@ +import fs from 'fs'; import path from 'path'; import { type PackageJson } from 'type-fest'; import { defineConfig } from 'vite'; @@ -5,6 +6,20 @@ import tsconfigPaths from 'vite-tsconfig-paths'; import packageJson from './package.json'; +const copyCoverAssetsPlugin = () => ({ + name: 'copy-cover-assets', + closeBundle() { + const source = path.resolve( + __dirname, + 'src/cli/utilities/build/cover/assets/halftone-backdrop.png', + ); + const destinationDir = path.resolve(__dirname, 'dist/assets'); + + fs.mkdirSync(destinationDir, { recursive: true }); + fs.copyFileSync(source, path.join(destinationDir, 'halftone-backdrop.png')); + }, +}); + export default defineConfig(() => { return { root: __dirname, @@ -18,6 +33,7 @@ export default defineConfig(() => { tsconfigPaths({ root: __dirname, }), + copyCoverAssetsPlugin(), ], build: { emptyOutDir: false, diff --git a/yarn.lock b/yarn.lock index 0b294a3e9c..2083b245eb 100644 --- a/yarn.lock +++ b/yarn.lock @@ -53329,6 +53329,7 @@ __metadata: react-dom: "npm:^19.2.0" rollup-plugin-dts: "npm:^6.4.1" semver: "npm:7.6.3" + sharp: "npm:^0.34.5" tinyglobby: "npm:^0.2.15" ts-morph: "npm:^25.0.0" tsc-alias: "npm:^1.8.16"