mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-05 00:56:23 +00:00
Adds a `--timeout <ms>` CLI flag (and `HYPERFRAMES_TRANSCRIBE_TIMEOUT_MS` env var) plus a model-slowdown factor in the auto-scaled default so `hyperframes transcribe` doesn't hard-fail with `spawnSync ETIMEDOUT` on slow CPUs running heavier whisper models. Field-signal ts=1784165471 (win32/arm64 emulating x64 on Snapdragon, CLI 0.7.59) reported the failure on a 63s wav with `-m medium` at ~13x realtime — the historical 10x-realtime scale (PR #2463) gave 10.5 min while the machine needed ~13.7 min. Splitting audio and merging offsets was the manual workaround. - Add `--timeout <ms>` and `HYPERFRAMES_TRANSCRIBE_TIMEOUT_MS` (min 5000). Explicit override bypasses auto-scaling; still capped at 12h. - Add per-model slowdown factor (tiny 0.5, base 0.7, small 1, medium 2, large 4, large-v3-turbo 2). Multiplied into the 10s/audio-second baseline so medium/large get proportional headroom while `small.en` (the default) preserves the historical safety window. - Wrap whisper's spawn error with a discoverability hint naming `--timeout`, the env var, and the effective timeout when the child was killed by SIGTERM/ETIMEDOUT (mirrors PR #2504 protocol-timeout). - Docs: new `--timeout` row in `docs/packages/cli.mdx` Flags table. Regression coverage in `packages/cli/src/whisper/transcribe.test.ts` (56 tests) and `packages/cli/src/commands/transcribe.test.ts` (5 tests): - Model factor per known name + case-insensitive + safe unknown fallback. - 63s field-signal case on medium.en → 1_260_000ms (was 630_000ms). - Explicit override honored below the auto floor + capped at 12h. - Model factor ignored when overrideMs is set. - SIGTERM/ETIMEDOUT detection + augmented message contract. - CLI rejects below-minimum `--timeout` with error naming both the flag and the 5000ms floor. — Via Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> 🤖 Generated with [Claude Code](https://claude.com/claude-code)
152 lines
5.4 KiB
TypeScript
152 lines
5.4 KiB
TypeScript
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
|
import { writeFileSync, readFileSync, mkdtempSync, rmSync } from "node:fs";
|
|
import { join } from "node:path";
|
|
import { tmpdir } from "node:os";
|
|
import { WhisperUnavailableError } from "../whisper/manager.js";
|
|
|
|
// Make the whisper core report "unavailable" so we exercise the soft-skip path.
|
|
const transcribeMock = vi.fn();
|
|
vi.mock("../whisper/transcribe.js", () => ({ transcribe: transcribeMock }));
|
|
|
|
const trackTranscribeUnavailable = vi.fn();
|
|
const trackCommandFailure = vi.fn();
|
|
vi.mock("../telemetry/events.js", () => ({
|
|
trackTranscribeUnavailable: (...a: unknown[]) => trackTranscribeUnavailable(...a),
|
|
trackCommandFailure: (...a: unknown[]) => trackCommandFailure(...a),
|
|
}));
|
|
|
|
import transcribeCmd from "./transcribe.js";
|
|
|
|
function dummyAudio(): { dir: string; input: string } {
|
|
const dir = mkdtempSync(join(tmpdir(), "hf-transcribe-test-"));
|
|
const input = join(dir, "narration.wav");
|
|
writeFileSync(input, "not-real-audio");
|
|
return { dir, input };
|
|
}
|
|
|
|
describe("transcribe command", () => {
|
|
let dirs: string[] = [];
|
|
let priorExitCode: typeof process.exitCode;
|
|
|
|
beforeEach(() => {
|
|
dirs = [];
|
|
priorExitCode = process.exitCode;
|
|
process.exitCode = undefined;
|
|
transcribeMock.mockReset();
|
|
trackTranscribeUnavailable.mockReset();
|
|
trackCommandFailure.mockReset();
|
|
transcribeMock.mockRejectedValue(
|
|
new WhisperUnavailableError("whisper-cpp not found. Install: brew install whisper-cpp"),
|
|
);
|
|
vi.spyOn(console, "log").mockImplementation(() => {});
|
|
});
|
|
|
|
afterEach(() => {
|
|
process.exitCode = priorExitCode;
|
|
for (const d of dirs) rmSync(d, { recursive: true, force: true });
|
|
vi.restoreAllMocks();
|
|
});
|
|
|
|
it("explicit run exits non-zero and is NOT reported as a command failure", async () => {
|
|
const { dir, input } = dummyAudio();
|
|
dirs.push(dir);
|
|
await transcribeCmd.run!({ args: { input, json: true, optional: false } } as never);
|
|
|
|
expect(process.exitCode).toBe(1);
|
|
expect(trackTranscribeUnavailable).toHaveBeenCalledWith({ optional: false });
|
|
expect(trackCommandFailure).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("--optional skips cleanly with exit 0", async () => {
|
|
const { dir, input } = dummyAudio();
|
|
dirs.push(dir);
|
|
await transcribeCmd.run!({ args: { input, json: true, optional: true } } as never);
|
|
|
|
expect(process.exitCode).toBe(0);
|
|
expect(trackTranscribeUnavailable).toHaveBeenCalledWith({ optional: true });
|
|
expect(trackCommandFailure).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("imports an SRT and exports an SRT sidecar from transcript.json", async () => {
|
|
const dir = mkdtempSync(join(tmpdir(), "hf-transcribe-test-"));
|
|
dirs.push(dir);
|
|
const input = join(dir, "sample.srt");
|
|
const sample = `1
|
|
00:00:01,000 --> 00:00:03,500
|
|
Write HTML.
|
|
|
|
2
|
|
00:00:03,500 --> 00:00:06,000
|
|
Render video. Built for agents.
|
|
`;
|
|
writeFileSync(input, sample);
|
|
|
|
await transcribeCmd.run!({ args: { input, dir, json: true } } as never);
|
|
const transcriptPath = join(dir, "transcript.json");
|
|
|
|
await transcribeCmd.run!({ args: { input: transcriptPath, to: "srt", json: true } } as never);
|
|
const outputPath = join(dir, "transcript.srt");
|
|
|
|
expect(readFileSync(outputPath, "utf-8")).toBe(sample);
|
|
const log = vi.mocked(console.log).mock.calls.at(-1)?.[0];
|
|
expect(typeof log).toBe("string");
|
|
if (typeof log !== "string") throw new Error("Expected JSON log output");
|
|
expect(JSON.parse(log)).toEqual({
|
|
ok: true,
|
|
format: "srt",
|
|
wordCount: 2,
|
|
outputPath,
|
|
});
|
|
});
|
|
|
|
it("rejects a below-minimum --timeout with a discoverable error", async () => {
|
|
const { dir, input } = dummyAudio();
|
|
dirs.push(dir);
|
|
const consoleLog = vi.mocked(console.log);
|
|
const exitSpy = vi.spyOn(process, "exit").mockImplementation(((code?: number) => {
|
|
throw new Error(`__exit_${code}__`);
|
|
}) as never);
|
|
|
|
// 100 is well below the 5000ms minimum — must fail loud instead of silently
|
|
// reverting to the auto-scaled default (the whole point of the flag is
|
|
// that the user explicitly asked for a specific value).
|
|
await expect(
|
|
transcribeCmd.run!({ args: { input, json: true, timeout: "100" } } as never),
|
|
).rejects.toThrow("__exit_1__");
|
|
|
|
const log = consoleLog.mock.calls.at(-1)?.[0];
|
|
expect(typeof log).toBe("string");
|
|
if (typeof log !== "string") throw new Error("Expected JSON log output");
|
|
const parsed = JSON.parse(log);
|
|
expect(parsed.ok).toBe(false);
|
|
expect(parsed.error).toContain("--timeout");
|
|
expect(parsed.error).toContain("5000");
|
|
|
|
exitSpy.mockRestore();
|
|
});
|
|
|
|
it("--preserve-cues keeps single-word cues separate when exporting from JSON", async () => {
|
|
const dir = mkdtempSync(join(tmpdir(), "hf-transcribe-test-"));
|
|
dirs.push(dir);
|
|
// Single-word cues have no internal whitespace, so the whitespace heuristic
|
|
// can't tell them from word-level whisper output. --preserve-cues forces 1:1.
|
|
const transcriptPath = join(dir, "transcript.json");
|
|
writeFileSync(
|
|
transcriptPath,
|
|
JSON.stringify([
|
|
{ text: "Yes", start: 0, end: 1 },
|
|
{ text: "No", start: 1, end: 2 },
|
|
]),
|
|
);
|
|
|
|
await transcribeCmd.run!({
|
|
args: { input: transcriptPath, to: "srt", "preserve-cues": true, json: true },
|
|
} as never);
|
|
|
|
const output = readFileSync(join(dir, "transcript.srt"), "utf-8");
|
|
expect(output).toBe(
|
|
"1\n00:00:00,000 --> 00:00:01,000\nYes\n\n2\n00:00:01,000 --> 00:00:02,000\nNo\n",
|
|
);
|
|
});
|
|
});
|