diff --git a/CHANGELOG.md b/CHANGELOG.md index 778ab55f48..21ccaaeccf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,8 @@ 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 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({