Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import fetch from 'node-fetch';
import { type CustomBuildContext } from '../../../customBuildContext';
import {
captureDeviceRunSessionPreviewAsync,
ensureMacosPreviewEncoderInstalledAsync,
startDeviceRunSessionPreview,
} from '../deviceRunSessionPreview';

Expand Down Expand Up @@ -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'],
Expand All @@ -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');
Expand Down Expand Up @@ -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<typeof spawn>
);
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'
);
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand All @@ -33,6 +36,7 @@ jest.mock('../remoteDeviceRunSession', () => ({
}));
jest.mock('../deviceRunSessionPreview', () => ({
...jest.requireActual('../deviceRunSessionPreview'),
ensureMacosPreviewEncoderInstalledAsync: jest.fn(),
startDeviceRunSessionPreview: jest.fn(),
}));
jest.mock('../deviceRunSessionScreenRecordings', () => ({
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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<void>();
jest.mocked(ensureFfmpegInstalledOnceAsync).mockReturnValueOnce(install.promise);
jest.mocked(ensureMacosPreviewEncoderInstalledAsync).mockReturnValueOnce(install.promise);
const host = await startDeviceSessionHostAsync(ctx, {
runtimePlatform: BuildRuntimePlatform.DARWIN,
env,
Expand All @@ -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,
Expand Down
145 changes: 104 additions & 41 deletions packages/build-tools/src/steps/utils/deviceRunSessionPreview.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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!) {
Expand Down Expand Up @@ -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<void> {
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,
Expand All @@ -149,53 +173,92 @@ export async function captureDeviceRunSessionPreviewAsync({
}): Promise<Buffer> {
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<void> {
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<void> {
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
);
}
5 changes: 3 additions & 2 deletions packages/build-tools/src/steps/utils/deviceSessionHost.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import {
} from './deviceRunSessionScreenRecordings';
import {
captureDeviceRunSessionPreviewAsync,
ensureMacosPreviewEncoderInstalledAsync,
startDeviceRunSessionPreview,
} from './deviceRunSessionPreview';
import { startDeviceRunSessionScreenshotsAsync } from './deviceRunSessionScreenshots';
Expand Down Expand Up @@ -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({
Expand Down
Loading