Skip to content
Open
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
3 changes: 2 additions & 1 deletion docs/guides/authentication.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,7 @@ vision key is available.
| **Voice (TTS)** | HeyGen → ElevenLabs → Kokoro | `HEYGEN_API_KEY` → `HYPERFRAMES_API_KEY` → `~/.heygen` · then `ELEVENLABS_API_KEY` | Kokoro: `pip install kokoro-onnx soundfile` |
| **Music (BGM)** | HeyGen library → Lyria → MusicGen | HeyGen credential (above) · then `GEMINI_API_KEY` → `GOOGLE_API_KEY` | MusicGen: `pip install transformers torch soundfile numpy` |
| **Sound effects** | HeyGen library → bundled library | HeyGen credential (above) | bundled — no deps |
| **Capture descriptions** | OpenRouter → Gemini | `OPENROUTER_API_KEY` → `GEMINI_API_KEY` | None; optional for [website capture](/guides/product-launch-video) |
| **Capture descriptions** | Your OpenAI-compatible endpoint → OpenRouter → Gemini | `HYPERFRAMES_VISION_*` → `OPENROUTER_API_KEY` → `GEMINI_API_KEY` | None; optional for [website capture](/guides/product-launch-video) |

Run `npx hyperframes doctor` to check which local dependencies are installed.
The media workflows run `hyperframes auth status` before generation and tell
Expand Down Expand Up @@ -144,6 +144,7 @@ to a shared space with `--space`.
| `GOOGLE_APPLICATION_CREDENTIALS` / `GCS_CREDS` | Gemini TTS service-account JSON file path / injected JSON. Used when neither API key is set. |
| `GOOGLE_CLOUD_PROJECT` / `GCLOUD_PROJECT_ID` | Gemini OAuth quota project; first set value wins, then the service account project. |
| `OPENROUTER_API_KEY` | Capture descriptions; takes priority over Gemini for that step. |
| `HYPERFRAMES_VISION_BASE_URL` / `HYPERFRAMES_VISION_API_KEY` / `HYPERFRAMES_VISION_MODEL` | Capture descriptions through any OpenAI-compatible vision endpoint; set all three. Takes priority over OpenRouter and Gemini. |

## Related topics

Expand Down
8 changes: 7 additions & 1 deletion docs/packages/cli.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -252,7 +252,13 @@ you build.
For AI image descriptions, set `GEMINI_API_KEY` in a `.env` file
(~$0.001/image), or `OPENROUTER_API_KEY` to route any vision model through
[OpenRouter](https://openrouter.ai) — it wins if both are set, and
`HYPERFRAMES_OPENROUTER_MODEL` overrides the model.
`HYPERFRAMES_OPENROUTER_MODEL` overrides the model. To use any other
OpenAI-compatible endpoint (a regional provider, or a local server such as
Ollama or vLLM), set `HYPERFRAMES_VISION_BASE_URL`, `HYPERFRAMES_VISION_API_KEY`
and `HYPERFRAMES_VISION_MODEL` together; this endpoint wins over both. Pick a
model that accepts image input, and give servers that ignore auth any non-empty
key. If only some of the three are set, capture warns and skips descriptions
instead of falling back to another provider.

### `transcribe`

Expand Down
169 changes: 169 additions & 0 deletions packages/cli/src/capture/contentExtractor.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { tmpdir } from "node:os";
import { join } from "node:path";
import {
captionImagesWithGemini,
resolveCustomVisionEndpoint,
resolveVisionPhaseCompletion,
type VisionCaptionOutcome,
} from "./contentExtractor.js";
Expand Down Expand Up @@ -410,6 +411,174 @@ describe("captionImagesWithGemini — OpenRouter provider", () => {
});
});

describe("captionImagesWithGemini — custom OpenAI-compatible endpoint", () => {
const dirs: string[] = [];
afterEach(() => {
generateContentMock.mockReset();
clientOptions.length = 0;
vi.unstubAllGlobals();
vi.unstubAllEnvs();
for (const d of dirs) rmSync(d, { recursive: true, force: true });
dirs.length = 0;
});

function captionResponse(content: string): Response {
return new Response(JSON.stringify({ choices: [{ message: { content } }] }), {
status: 200,
headers: { "content-type": "application/json" },
});
}

it("captions through the configured endpoint, model and key", async () => {
const dir = makeProjectWithImages();
dirs.push(dir);
vi.stubEnv("HYPERFRAMES_VISION_BASE_URL", "https://vision.example.com/v2/");
vi.stubEnv("HYPERFRAMES_VISION_API_KEY", "custom-test-key");
vi.stubEnv("HYPERFRAMES_VISION_MODEL", "example-vl-model");

let capturedUrl: string | undefined;
let capturedInit: RequestInit | undefined;
const fetchMock = vi.fn(async (url: string, init?: RequestInit) => {
capturedUrl = url;
capturedInit = init;
return captionResponse("A white pricing table with green buttons.");
});
vi.stubGlobal("fetch", fetchMock);

const warnings: string[] = [];
const captions = await captionImagesWithGemini(dir, () => {}, warnings);

expect(captions).toEqual({ "hero.png": "A white pricing table with green buttons." });
expect(warnings).toEqual([]);
expect(capturedUrl).toBe("https://vision.example.com/v2/chat/completions");
expect(new Headers(capturedInit?.headers).get("authorization")).toBe("Bearer custom-test-key");
const body = JSON.parse(typeof capturedInit?.body === "string" ? capturedInit.body : "{}");
expect(body.model).toBe("example-vl-model");
expect(body.max_tokens).toBe(500);
const image = body.messages[0].content.find((p: { type: string }) => p.type === "image_url");
expect(image?.image_url?.url).toMatch(/^data:image\/png;base64,/);
});

it("takes priority over OpenRouter and Gemini when both are also configured", async () => {
const dir = makeProjectWithImages();
dirs.push(dir);
vi.stubEnv("HYPERFRAMES_VISION_BASE_URL", "https://vision.example.com/v2");
vi.stubEnv("HYPERFRAMES_VISION_API_KEY", "custom-test-key");
vi.stubEnv("HYPERFRAMES_VISION_MODEL", "example-vl-model");
vi.stubEnv("OPENROUTER_API_KEY", "or-test-key");
vi.stubEnv("GEMINI_API_KEY", "gemini-test-key");

const urls: string[] = [];
vi.stubGlobal(
"fetch",
vi.fn(async (url: string) => {
urls.push(url);
return captionResponse("A caption.");
}),
);

await captionImagesWithGemini(dir, () => {}, []);

expect(urls).toEqual(["https://vision.example.com/v2/chat/completions"]);
expect(generateContentMock).not.toHaveBeenCalled();
});

it("counts a rejected request as a provider failure", async () => {
const dir = makeProjectWithImages();
dirs.push(dir);
vi.stubEnv("HYPERFRAMES_VISION_BASE_URL", "https://vision.example.com/v2");
vi.stubEnv("HYPERFRAMES_VISION_API_KEY", "custom-test-key");
vi.stubEnv("HYPERFRAMES_VISION_MODEL", "example-vl-model");
vi.stubGlobal(
"fetch",
vi.fn(async () => new Response("bad request", { status: 400 })),
);

let outcome: VisionCaptionOutcome | undefined;
const captions = await captionImagesWithGemini(dir, () => {}, [], {
onOutcome: (value) => {
outcome = value;
},
});

expect(captions).toEqual({});
expect(outcome?.failedRequests).toBe(1);
});

it("refuses a half-configured endpoint instead of falling back to Gemini", async () => {
const dir = makeProjectWithImages();
dirs.push(dir);
vi.stubEnv("HYPERFRAMES_VISION_BASE_URL", "");
vi.stubEnv("HYPERFRAMES_VISION_API_KEY", "custom-secret-key");
vi.stubEnv("HYPERFRAMES_VISION_MODEL", "example-vl-model");
vi.stubEnv("OPENROUTER_API_KEY", "");
vi.stubEnv("GEMINI_API_KEY", "gemini-test-key");
const fetchMock = vi.fn();
vi.stubGlobal("fetch", fetchMock);

const warnings: string[] = [];
let outcome: VisionCaptionOutcome | undefined;
const captions = await captionImagesWithGemini(dir, () => {}, warnings, {
onOutcome: (value) => {
outcome = value;
},
});

expect(captions).toEqual({});
expect(fetchMock).not.toHaveBeenCalled();
expect(generateContentMock).not.toHaveBeenCalled();
expect(clientOptions).toHaveLength(0);
expect(warnings).toHaveLength(1);
expect(warnings[0]).toContain("missing HYPERFRAMES_VISION_BASE_URL.");
expect(warnings[0]).not.toContain("custom-secret-key");
if (!outcome) throw new Error("Expected vision caption outcome");
expect(resolveVisionPhaseCompletion(outcome, 10_000)).toEqual({
status: "degraded",
reason: "internal-error",
});
});

it("names every variable a base URL alone still needs", async () => {
const dir = makeProjectWithImages();
dirs.push(dir);
vi.stubEnv("HYPERFRAMES_VISION_BASE_URL", "http://localhost:11434/v1");
vi.stubEnv("HYPERFRAMES_VISION_API_KEY", "");
vi.stubEnv("HYPERFRAMES_VISION_MODEL", "");
vi.stubEnv("OPENROUTER_API_KEY", "or-test-key");
const fetchMock = vi.fn();
vi.stubGlobal("fetch", fetchMock);

const warnings: string[] = [];
await captionImagesWithGemini(dir, () => {}, warnings);

expect(fetchMock).not.toHaveBeenCalled();
expect(warnings[0]).toContain("missing HYPERFRAMES_VISION_API_KEY, HYPERFRAMES_VISION_MODEL.");
});
});

describe("resolveCustomVisionEndpoint", () => {
afterEach(() => {
vi.unstubAllEnvs();
});

it("is unset when none of the three variables is set", () => {
vi.stubEnv("HYPERFRAMES_VISION_BASE_URL", "");
vi.stubEnv("HYPERFRAMES_VISION_API_KEY", "");
vi.stubEnv("HYPERFRAMES_VISION_MODEL", "");
expect(resolveCustomVisionEndpoint()).toEqual({ kind: "unset" });
});

it("lists exactly the variables that are missing", () => {
vi.stubEnv("HYPERFRAMES_VISION_BASE_URL", "");
vi.stubEnv("HYPERFRAMES_VISION_API_KEY", "");
vi.stubEnv("HYPERFRAMES_VISION_MODEL", "example-vl-model");
expect(resolveCustomVisionEndpoint()).toEqual({
kind: "incomplete",
missing: ["HYPERFRAMES_VISION_BASE_URL", "HYPERFRAMES_VISION_API_KEY"],
});
});
});

describe("captionImagesWithGemini — Gemini provider", () => {
const dirs: string[] = [];

Expand Down
Loading