Skip to content

Commit 159f792

Browse files
committed
fix(server): local Android readiness requires ffmpeg with libx264
The local check only confirmed that ffmpeg resolved, so a build without libx264 still reported Android ready. It now runs the capability probe the device hub runs and reports an unusable binary and a missing encoder. A successful probe is remembered, so readiness checks do not spawn ffmpeg repeatedly.
1 parent 84bbe43 commit 159f792

2 files changed

Lines changed: 89 additions & 4 deletions

File tree

‎apps/server/src/device/LocalDeviceHost.test.ts‎

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -212,6 +212,65 @@ it.effect(
212212
}).pipe(Effect.scoped, Effect.provide(NodeServices.layer)),
213213
);
214214

215+
it.effect("requires an ffmpeg the device hub can encode with before reporting Android ready", () =>
216+
Effect.gen(function* () {
217+
const fs = yield* FileSystem.FileSystem;
218+
const baseDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-device-ffmpeg-" });
219+
for (const [directory, tool] of [
220+
["platform-tools", "adb"],
221+
["emulator", "emulator"],
222+
["cmdline-tools/latest/bin", "avdmanager"],
223+
]) {
224+
yield* fs.makeDirectory(`${baseDir}/sdk/${directory}`, { recursive: true });
225+
yield* fs.writeFileString(`${baseDir}/sdk/${directory}/${tool}`, "");
226+
}
227+
let probe = { stdout: " V....D libopenh264", code: 0 };
228+
const probed: Array<string> = [];
229+
const host = yield* LocalDeviceHost.make().pipe(
230+
Effect.provide(Layer.mergeAll(ServerConfig.layerTest(baseDir, baseDir), NetService.layer)),
231+
Effect.provideService(HostProcess.Environment, {
232+
HOME: baseDir,
233+
PATH: "/usr/bin",
234+
ANDROID_HOME: `${baseDir}/sdk`,
235+
}),
236+
Effect.provideService(HostProcess.Platform, "linux"),
237+
Effect.provideService(CommandAvailability, () => Effect.succeed(true)),
238+
Effect.provideService(
239+
HttpClient.HttpClient,
240+
HttpClient.make(() => Effect.die(new Error("Readiness must not make network requests"))),
241+
),
242+
Effect.provideService(ProcessRunner.ProcessRunner, {
243+
run: (input) => {
244+
probed.push([input.command, ...input.args].join(" "));
245+
return Effect.succeed({
246+
...probe,
247+
stderr: "",
248+
code: ChildProcessSpawner.ExitCode(probe.code),
249+
timedOut: false,
250+
stdoutTruncated: false,
251+
stderrTruncated: false,
252+
stdoutInvalidUtf8: false,
253+
stderrInvalidUtf8: false,
254+
});
255+
},
256+
}),
257+
);
258+
expect((yield* host.platformAvailability("android")).reason).toContain(
259+
"lacks the libx264 encoder",
260+
);
261+
probe = { stdout: "", code: 1 };
262+
expect((yield* host.platformAvailability("android")).reason).toContain("could not be run");
263+
probe = { stdout: " V....D libx264", code: 0 };
264+
expect(yield* host.platformAvailability("android")).toEqual({
265+
platform: "android",
266+
available: true,
267+
});
268+
// A usable ffmpeg is probed once, not on every readiness check.
269+
yield* host.platformAvailability("android");
270+
expect(probed).toEqual(Array.from({ length: 3 }, () => "ffmpeg -hide_banner -encoders"));
271+
}).pipe(Effect.scoped, Effect.provide(NodeServices.layer)),
272+
);
273+
215274
it.effect(
216275
"constructs and inspects an unconfigured host without installing or starting helpers",
217276
() =>

‎apps/server/src/device/LocalDeviceHost.ts‎

Lines changed: 30 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -251,13 +251,39 @@ export const make = Effect.fn("LocalDeviceHost.make")(function* () {
251251
const restartDelayRef = yield* Ref.make(0);
252252
const hostId = LOCAL_DEVICE_HOST_ID;
253253

254+
let usableFfmpeg: string | null = null;
255+
/**
256+
* Runs the capability probe the device hub runs before it encodes emulator
257+
* video. Only a success is remembered, so installing ffmpeg clears the reason.
258+
*/
259+
const ffmpegEncoderReason = Effect.gen(function* () {
260+
const ffmpeg = hostEnvironment.SERVE_EMU_FFMPEG?.trim() || "ffmpeg";
261+
if (usableFfmpeg === ffmpeg) return null;
262+
const encoders = yield* runner
263+
.run({
264+
command: ffmpeg,
265+
args: ["-hide_banner", "-encoders"],
266+
env: hostEnvironment,
267+
timeout: Duration.seconds(10),
268+
timeoutBehavior: "timedOutResult",
269+
})
270+
.pipe(Effect.option);
271+
if (Option.isNone(encoders) || encoders.value.timedOut || Number(encoders.value.code) !== 0)
272+
return `ffmpeg at ${ffmpeg} could not be run. Android emulator video needs a working ffmpeg with the libx264 encoder.`;
273+
if (!/\blibx264\b/.test(`${encoders.value.stdout}\n${encoders.value.stderr}`))
274+
return `ffmpeg at ${ffmpeg} lacks the libx264 encoder that Android emulator video needs. Install an ffmpeg build that includes libx264.`;
275+
usableFfmpeg = ffmpeg;
276+
return null;
277+
});
278+
254279
const platformAvailability = Effect.fn("LocalDeviceHost.platformAvailability")(function* (
255280
platform: DevicePlatform,
256281
): Effect.fn.Return<DevicePlatformAvailability> {
257-
const reason = yield* platformReason(platform).pipe(
258-
Effect.provideService(FileSystem.FileSystem, fs),
259-
Effect.provideService(Path.Path, path),
260-
);
282+
const reason =
283+
(yield* platformReason(platform).pipe(
284+
Effect.provideService(FileSystem.FileSystem, fs),
285+
Effect.provideService(Path.Path, path),
286+
)) ?? (platform === "android" ? yield* ffmpegEncoderReason : null);
261287
return reason === null ? { platform, available: true } : { platform, available: false, reason };
262288
});
263289

0 commit comments

Comments
 (0)