Hmbown/CodeWhale · error · ExecError

ffmpeg is required on the host to mux harmony…

Error message

ffmpeg is required on the host to mux harmony snapshot-series recordings

What it means

HarmonyOS has no CLI screen recorder, so the backend records by taking snapshot frames and muxing them into an mp4 with ffmpeg on the host machine at stop time. If `ffmpeg` is not found on the host PATH (checked via `have("ffmpeg")`), starting a recording would produce frames that can never be assembled, so it fails at start.

Solutions

  1. Install ffmpeg on the host machine (e.g. `apt install ffmpeg`, `brew install ffmpeg`).
  2. Ensure ffmpeg is on the PATH of the process running the backend.
  3. If containerized, rebuild the image to include ffmpeg.

Example fix

// before
const rec = await backend.recordingStart({ intervalMs: 400 }); // fails without ffmpeg
// after
const { execSync } = await import("node:child_process");
try { execSync("ffmpeg -version", { stdio: "ignore" }); } catch { throw new Error("install ffmpeg first"); }
const rec = await backend.recordingStart({ intervalMs: 400 });
Defensive patterns

Strategy: validation

Validate before calling

const { execFile } = await import("node:child_process");
await new Promise((res, rej) => execFile("ffmpeg", ["-version"], (e) => e ? rej(new Error("ffmpeg missing")) : res()));

Type guard

null

Try / catch

try { await backend.recordingStart({ intervalMs: 400 }); }
catch (e) {
  if (String(e.message).includes("ffmpeg")) throw new Error("Install ffmpeg: https://ffmpeg.org/download.html");
  throw e;
}

Prevention

When it happens

Trigger: Calling `recordingStart()` on the HarmonyOS backend when ffmpeg is not installed or not on PATH (backends/harmonyos.mjs:274).

Common situations: Minimal CI containers or fresh machines without ffmpeg; ffmpeg installed for the user but not the process env (missing PATH); Docker images lacking ffmpeg.

Understand the failure class

Background: "not installed", "pip install", "required for": how missing-dependency errors surface across open-source libraries — this error's family across 34 libraries.

Related errors


AI-assisted analysis of Hmbown/CodeWhale@73e0f67d83 (2026-09-22). Data as JSON: /api/errors/5913618c4faa77e1. Report an issue: GitHub.

Appendix: source

Thrown at crates/tui/plugins/computer-use/src/backends/harmonyos.mjs:274

    set_value: async ({ target, value }) => {
      const b = await centerOf(target);
      await uiInput(["click", String(b.cx), String(b.cy)]);
      await new Promise((r) => setTimeout(r, 300));
      await uiInput(["inputText", String(b.cx), String(b.cy), escDeviceText(String(value))]);
      return { action_sent: true, strategy: "uitest-element" };
    },
    select_text: async () => { throw new ExecError("select_text is not exposed by uitest dumpLayout/uiInput on the harmony backend"); },
    perform_action: async ({ target, action }) => {
      const b = await centerOf(target);
      if (action === "longClick") return uiInput(["longClick", String(b.cx), String(b.cy)]);
      return uiInput(["click", String(b.cx), String(b.cy)]);
    },
    read_clipboard: async () => { throw new ExecError("clipboard read is not exposed by hdc on current HarmonyOS builds"); },
    write_clipboard: async () => { throw new ExecError("clipboard write is not exposed by hdc on current HarmonyOS builds"); },
    cursor_position: async () => { throw new ExecError("cursor position does not exist on touch devices"); },
    recordingStart: async ({ intervalMs = 400 } = {}) => {
      if (recording) throw new ExecError(`recording ${recording.id} already running`);
      if (!(await have("ffmpeg"))) throw new ExecError("ffmpeg is required on the host to mux harmony snapshot-series recordings");
      const id = crypto.randomBytes(4).toString("hex");
      const dir = fs.mkdtempSync(path.join(os.tmpdir(), `cu-rec-${id}-`));
      const startedAt = new Date().toISOString();
      const rec = recording = { id, dir, startedAt, intervalMs, seq: 0, controller: new AbortController() };
      const tick = () => {
        if (rec.stopped || rec.pending) return rec.pending;
        rec.pending = (async () => {
          const opts = { timeoutMs: 15_000, signal: rec.controller.signal };
          const remote = `${DEVICE_TMP}-rec-${id}-${String(rec.seq).padStart(5, "0")}.jpeg`;
          try {
            await deviceOut(["snapshot_display", "-f", remote], opts);
            await exec.pullFile(remote, path.join(dir, `f${String(rec.seq).padStart(5, "0")}.jpeg`), opts);
            rec.seq++;
          } finally { await shell(["rm", "-f", remote], opts).catch(() => {}); }
        })().catch(() => {}).finally(() => { rec.pending = null; });
        return rec.pending;
      };
      const signal = currentSignal();

View on GitHub (pinned to 73e0f67d83)