From 9e19be3d4d8e7c7a1926e589f446c53007a10647 Mon Sep 17 00:00:00 2001 From: szdziedzic Date: Tue, 6 Oct 2026 14:33:29 +0200 Subject: [PATCH 1/3] [build-tools] Encode macOS session previews with cwebp Homebrew's FFmpeg is built without libwebp, so every macOS preview failed with "Encoder not found" (exit code 8). Resize the screenshot with sips and encode it with cwebp from Homebrew's webp package instead. Android keeps using FFmpeg. --- .../__tests__/deviceRunSessionPreview.test.ts | 51 ++++++ .../utils/__tests__/deviceSessionHost.test.ts | 17 +- .../steps/utils/deviceRunSessionPreview.ts | 145 +++++++++++++----- .../src/steps/utils/deviceSessionHost.ts | 5 +- 4 files changed, 170 insertions(+), 48 deletions(-) diff --git a/packages/build-tools/src/steps/utils/__tests__/deviceRunSessionPreview.test.ts b/packages/build-tools/src/steps/utils/__tests__/deviceRunSessionPreview.test.ts index b1b248dc06..9e4a713ea0 100644 --- a/packages/build-tools/src/steps/utils/__tests__/deviceRunSessionPreview.test.ts +++ b/packages/build-tools/src/steps/utils/__tests__/deviceRunSessionPreview.test.ts @@ -10,6 +10,7 @@ import fetch from 'node-fetch'; import { type CustomBuildContext } from '../../../customBuildContext'; import { captureDeviceRunSessionPreviewAsync, + ensureMacosPreviewEncoderInstalledAsync, startDeviceRunSessionPreview, } from '../deviceRunSessionPreview'; @@ -270,6 +271,7 @@ describe('session preview with the real GraphQL client', () => { describe(captureDeviceRunSessionPreviewAsync, () => { const png = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0xff, 0x00]); const webp = Buffer.from('encoded-webp'); + const resized = Buffer.from('resized-png'); it.each([ [BuildRuntimePlatform.DARWIN, 'selected-device'], [BuildRuntimePlatform.LINUX, 'selected-device'], @@ -293,7 +295,16 @@ describe(captureDeviceRunSessionPreviewAsync, () => { if (command === 'xcrun') { expect(args.slice(0, 4)).toEqual(['simctl', 'io', 'selected-device', 'screenshot']); await writeFile(args[4], Uint8Array.from(png)); + } else if (command === 'sips') { + expect(args.slice(0, 2)).toEqual(['-Z', '320']); + expect(await readFile(args[2])).toEqual(png); + await writeFile(args[args.indexOf('--out') + 1], Uint8Array.from(resized)); + } else if (command === 'cwebp') { + expect(await readFile(args[args.indexOf('-o') - 1])).toEqual(resized); + await writeFile(args[args.indexOf('-o') + 1], Uint8Array.from(webp)); } else { + // Homebrew's FFmpeg cannot encode WebP, so macOS must not use it. + expect(runtimePlatform).toBe(BuildRuntimePlatform.LINUX); expect(command).toBe('ffmpeg'); expect(await readFile(args[args.indexOf('-i') + 1])).toEqual(png); expect(args).toContain('libwebp'); @@ -347,3 +358,43 @@ describe(captureDeviceRunSessionPreviewAsync, () => { ).toEqual([]); }); }); + +describe(ensureMacosPreviewEncoderInstalledAsync, () => { + const logger = { info: jest.fn() } as unknown as bunyan; + + it.each([true, false])( + 'installs webp only when cwebp is missing (present: %s)', + async present => { + jest.mocked(spawn).mockReset(); + jest + .mocked(spawn) + .mockImplementation( + command => + (command === 'cwebp' && !present + ? Promise.reject(new Error('command not found: cwebp')) + : Promise.resolve({ stdout: '', stderr: '' })) as ReturnType + ); + await ensureMacosPreviewEncoderInstalledAsync({ env: { HOME: '/Users/expo' }, logger }); + const brewCalls = jest.mocked(spawn).mock.calls.filter(([command]) => command === 'brew'); + if (present) { + expect(brewCalls).toEqual([]); + } else { + expect(brewCalls).toEqual([ + [ + 'brew', + ['install', 'webp'], + { env: { HOME: '/Users/expo', HOMEBREW_NO_AUTO_UPDATE: '1' }, logger }, + ], + ]); + } + } + ); + + it('rejects when webp cannot be installed', async () => { + jest.mocked(spawn).mockReset(); + jest.mocked(spawn).mockRejectedValue(new Error('brew failed')); + await expect(ensureMacosPreviewEncoderInstalledAsync({ env: {}, logger })).rejects.toThrow( + 'brew failed' + ); + }); +}); diff --git a/packages/build-tools/src/steps/utils/__tests__/deviceSessionHost.test.ts b/packages/build-tools/src/steps/utils/__tests__/deviceSessionHost.test.ts index 8c53d0fdc9..c3685bba04 100644 --- a/packages/build-tools/src/steps/utils/__tests__/deviceSessionHost.test.ts +++ b/packages/build-tools/src/steps/utils/__tests__/deviceSessionHost.test.ts @@ -12,7 +12,10 @@ import { findUnlistedDeviceScreenRecordingsAsync, uploadDeviceRunSessionScreenRecordingsAsync, } from '../deviceRunSessionScreenRecordings'; -import { startDeviceRunSessionPreview } from '../deviceRunSessionPreview'; +import { + ensureMacosPreviewEncoderInstalledAsync, + startDeviceRunSessionPreview, +} from '../deviceRunSessionPreview'; import { startDeviceSessionHostAsync } from '../deviceSessionHost'; import { ensureFfmpegInstalledOnceAsync, spawnDetached } from '../remoteDeviceRunSession'; @@ -33,6 +36,7 @@ jest.mock('../remoteDeviceRunSession', () => ({ })); jest.mock('../deviceRunSessionPreview', () => ({ ...jest.requireActual('../deviceRunSessionPreview'), + ensureMacosPreviewEncoderInstalledAsync: jest.fn(), startDeviceRunSessionPreview: jest.fn(), })); jest.mock('../deviceRunSessionScreenRecordings', () => ({ @@ -76,6 +80,7 @@ beforeEach(() => { stopSessionPreview.mockResolvedValue(undefined); jest.mocked(startDeviceRunSessionPreview).mockReturnValue({ stopAsync: stopSessionPreview }); jest.mocked(ensureFfmpegInstalledOnceAsync).mockResolvedValue(undefined); + jest.mocked(ensureMacosPreviewEncoderInstalledAsync).mockResolvedValue(undefined); stopServer.mockResolvedValue(undefined); closeTunnel.mockResolvedValue(undefined); jest.mocked(uploadDeviceRunSessionScreenRecordingsAsync).mockReset().mockResolvedValue(false); @@ -535,9 +540,9 @@ it.each([BuildRuntimePlatform.LINUX, BuildRuntimePlatform.DARWIN])( } ); -it('does not delay macOS readiness on FFmpeg setup or start the preview after finish', async () => { +it('does not delay macOS readiness on encoder setup or start the preview after finish', async () => { const install = deferred(); - jest.mocked(ensureFfmpegInstalledOnceAsync).mockReturnValueOnce(install.promise); + jest.mocked(ensureMacosPreviewEncoderInstalledAsync).mockReturnValueOnce(install.promise); const host = await startDeviceSessionHostAsync(ctx, { runtimePlatform: BuildRuntimePlatform.DARWIN, env, @@ -551,8 +556,10 @@ it('does not delay macOS readiness on FFmpeg setup or start the preview after fi expect(stopServer).toHaveBeenCalledTimes(1); }); -it('keeps the session running when FFmpeg setup for the preview fails', async () => { - jest.mocked(ensureFfmpegInstalledOnceAsync).mockRejectedValueOnce(new Error('brew failed')); +it('keeps the session running when encoder setup for the preview fails', async () => { + jest + .mocked(ensureMacosPreviewEncoderInstalledAsync) + .mockRejectedValueOnce(new Error('brew failed')); const host = await startDeviceSessionHostAsync(ctx, { runtimePlatform: BuildRuntimePlatform.DARWIN, env, diff --git a/packages/build-tools/src/steps/utils/deviceRunSessionPreview.ts b/packages/build-tools/src/steps/utils/deviceRunSessionPreview.ts index 05b22097f8..9a8bb1c123 100644 --- a/packages/build-tools/src/steps/utils/deviceRunSessionPreview.ts +++ b/packages/build-tools/src/steps/utils/deviceRunSessionPreview.ts @@ -1,4 +1,5 @@ import { type bunyan } from '@expo/logger'; +import { asyncResult } from '@expo/results'; import { BuildRuntimePlatform, type BuildStepEnv } from '@expo/steps'; import spawn from '@expo/turtle-spawn'; import { graphql } from 'gql.tada'; @@ -12,6 +13,8 @@ import path from 'node:path'; import { type CustomBuildContext } from '../../customBuildContext'; const PREVIEW_INTERVAL_MS = 60_000; +const PREVIEW_MAX_DIMENSION = 320; +const PREVIEW_QUALITY = 70; const MAX_PREVIEW_SIZE_BYTES = 5 * 1024 * 1024; const CREATE_PREVIEW_UPLOAD_SESSION_MUTATION = graphql(` mutation CreateDeviceRunSessionPreviewUploadSession($deviceRunSessionId: ID!) { @@ -136,6 +139,27 @@ export function startDeviceRunSessionPreview({ }; } +/** + * Homebrew's FFmpeg is built without libwebp, so macOS encodes previews with `cwebp` from + * Homebrew's webp package instead. Throws when it cannot be installed. + */ +export async function ensureMacosPreviewEncoderInstalledAsync({ + env, + logger, +}: { + env: BuildStepEnv; + logger: bunyan; +}): Promise { + if ((await asyncResult(spawn('cwebp', ['-version'], { env, stdio: 'pipe' }))).ok) { + return; + } + logger.info('Installing webp with Homebrew for the session preview.'); + await spawn('brew', ['install', 'webp'], { + env: { ...env, HOMEBREW_NO_AUTO_UPDATE: '1' }, + logger, + }); +} + export async function captureDeviceRunSessionPreviewAsync({ runtimePlatform, device, @@ -149,53 +173,92 @@ export async function captureDeviceRunSessionPreviewAsync({ }): Promise { const directory = await mkdtemp(path.join(os.tmpdir(), 'session-preview-')); try { - const screenshot = path.join(directory, 'screen.png'); - const preview = path.join(directory, 'preview.webp'); const options = { env, signal, timeout: 30_000, stdio: 'pipe' as const }; + const preview = path.join(directory, 'preview.webp'); if (runtimePlatform === BuildRuntimePlatform.DARWIN) { - await spawn('xcrun', ['simctl', 'io', device, 'screenshot', screenshot], options); + await captureIosPreviewAsync({ device, directory, preview, options }); } else { - let serial = device; - // expo-device-hub's readiness endpoint currently reports a placeholder instead of a serial. - if (serial === 'no-device-id') { - const result = await spawn('adb', ['devices'], options); - const devices = result.stdout - .split('\n') - .map(line => line.trim().split(/\s+/)) - .filter(([, status]) => status === 'device'); - if (devices.length !== 1) { - throw new Error('Expected exactly one connected Android device for the session preview.'); - } - serial = devices[0][0]; - } - const capture = spawn('adb', ['-s', serial, 'exec-out', 'screencap', '-p'], options); - // Preserve binary PNG output instead of spawn-async's UTF-8 stdout conversion. - await Promise.all([ - capture, - pipeline(capture.child.stdout!, createWriteStream(screenshot), { signal }), - ]); + await captureAndroidPreviewAsync({ device, directory, preview, options }); } - await spawn( - 'ffmpeg', - [ - '-nostdin', - '-y', - '-i', - screenshot, - '-vf', - 'scale=320:320:force_original_aspect_ratio=decrease', - '-frames:v', - '1', - '-c:v', - 'libwebp', - '-quality', - '70', - preview, - ], - options - ); return await readFile(preview); } finally { await rm(directory, { recursive: true, force: true }); } } + +type CaptureOptions = { + env: BuildStepEnv; + signal: AbortSignal; + timeout: number; + stdio: 'pipe'; +}; + +async function captureIosPreviewAsync({ + device, + directory, + preview, + options, +}: { + device: string; + directory: string; + preview: string; + options: CaptureOptions; +}): Promise { + const screenshot = path.join(directory, 'screen.png'); + const resized = path.join(directory, 'resized.png'); + await spawn('xcrun', ['simctl', 'io', device, 'screenshot', screenshot], options); + await spawn('sips', ['-Z', String(PREVIEW_MAX_DIMENSION), screenshot, '--out', resized], options); + await spawn('cwebp', ['-quiet', '-q', String(PREVIEW_QUALITY), resized, '-o', preview], options); +} + +async function captureAndroidPreviewAsync({ + device, + directory, + preview, + options, +}: { + device: string; + directory: string; + preview: string; + options: CaptureOptions; +}): Promise { + const screenshot = path.join(directory, 'screen.png'); + let serial = device; + // expo-device-hub's readiness endpoint currently reports a placeholder instead of a serial. + if (serial === 'no-device-id') { + const result = await spawn('adb', ['devices'], options); + const devices = result.stdout + .split('\n') + .map(line => line.trim().split(/\s+/)) + .filter(([, status]) => status === 'device'); + if (devices.length !== 1) { + throw new Error('Expected exactly one connected Android device for the session preview.'); + } + serial = devices[0][0]; + } + const capture = spawn('adb', ['-s', serial, 'exec-out', 'screencap', '-p'], options); + // Preserve binary PNG output instead of spawn-async's UTF-8 stdout conversion. + await Promise.all([ + capture, + pipeline(capture.child.stdout!, createWriteStream(screenshot), { signal: options.signal }), + ]); + await spawn( + 'ffmpeg', + [ + '-nostdin', + '-y', + '-i', + screenshot, + '-vf', + `scale=${PREVIEW_MAX_DIMENSION}:${PREVIEW_MAX_DIMENSION}:force_original_aspect_ratio=decrease`, + '-frames:v', + '1', + '-c:v', + 'libwebp', + '-quality', + String(PREVIEW_QUALITY), + preview, + ], + options + ); +} diff --git a/packages/build-tools/src/steps/utils/deviceSessionHost.ts b/packages/build-tools/src/steps/utils/deviceSessionHost.ts index e6db6a977c..2ddf19d877 100644 --- a/packages/build-tools/src/steps/utils/deviceSessionHost.ts +++ b/packages/build-tools/src/steps/utils/deviceSessionHost.ts @@ -23,6 +23,7 @@ import { } from './deviceRunSessionScreenRecordings'; import { captureDeviceRunSessionPreviewAsync, + ensureMacosPreviewEncoderInstalledAsync, startDeviceRunSessionPreview, } from './deviceRunSessionPreview'; import { startDeviceRunSessionScreenshotsAsync } from './deviceRunSessionScreenshots'; @@ -474,10 +475,10 @@ export async function startDeviceSessionHostAsync( IosSimulatorRecordingUtils.useServeSimPackage(packageSpec); } // Android installed FFmpeg before launching the host. The optional thumbnail must not delay - // readiness on macOS, so it starts after FFmpeg is installed there. + // readiness on macOS, so it starts after its encoder is installed there. void (async () => { if (!isAndroid) { - await ensureFfmpegInstalledOnceAsync({ runtimePlatform, env, logger }); + await ensureMacosPreviewEncoderInstalledAsync({ env, logger }); } if (!finishTask) { sessionPreview = startDeviceRunSessionPreview({ From 8af39ec5612e6f7ca9da13123a57a3774a464546 Mon Sep 17 00:00:00 2001 From: szdziedzic Date: Tue, 6 Oct 2026 14:49:07 +0200 Subject: [PATCH 2/3] [build-tools] Add changelog entry for macOS session preview encoding --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 778ab55f48..ccbce6ce5f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ This is the log of notable changes to EAS CLI and related packages. ### ๐Ÿ› Bug fixes +- [build-tools] Encode macOS simulator session previews with cwebp, because Homebrew's FFmpeg cannot encode WebP. ([#4549](https://github.com/expo/eas-cli/pull/4549) by [@szdziedzic](https://github.com/szdziedzic)) ### ๐Ÿงน Chores ## [24.11.0](https://github.com/expo/eas-cli/releases/tag/v24.11.0) - 2026-10-05 From 85bef18d95a916804e04d103ee7992c4322e5f20 Mon Sep 17 00:00:00 2001 From: szdziedzic Date: Tue, 6 Oct 2026 14:54:26 +0200 Subject: [PATCH 3/3] [build-tools] Format the changelog entry --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index ccbce6ce5f..21ccaaeccf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,7 @@ This is the log of notable changes to EAS CLI and related packages. ### ๐Ÿ› Bug fixes - [build-tools] Encode macOS simulator session previews with cwebp, because Homebrew's FFmpeg cannot encode WebP. ([#4549](https://github.com/expo/eas-cli/pull/4549) by [@szdziedzic](https://github.com/szdziedzic)) + ### ๐Ÿงน Chores ## [24.11.0](https://github.com/expo/eas-cli/releases/tag/v24.11.0) - 2026-10-05