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
3 changes: 2 additions & 1 deletion docs/cli-reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -1098,7 +1098,8 @@ Cost tier: `high`
- `media` _(string)_ **required** - Input media file (video or audio)
- `language` _(string)_ - Target language code (e.g., es, ko, ja) (required)
- `source` _(string)_ - Source language code (default: auto-detect)
- `voice` _(string)_ - ElevenLabs voice ID for output
- `voice` _(string)_ - Voice for the TTS provider (ElevenLabs name/ID, OpenAI voice, Kokoro voice)
- `tts` _(string)_ _(auto \| elevenlabs \| openai \| kokoro)_ _(default: `"auto"`)_ - TTS provider: auto (default), elevenlabs, openai, kokoro
- `analyzeOnly` _(boolean)_ - Only analyze and show timing, don't generate audio
- `output` _(string)_ - Output file path
- `dryRun` _(boolean)_ - Preview parameters without executing
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -149,8 +149,19 @@ exports[`CLI --describe schemas (drift detection) > vibe audio dub --describe 1`
"description": "Source language code (default: auto-detect)",
"type": "string",
},
"tts": {
"default": "auto",
"description": "TTS provider: auto (default), elevenlabs, openai, kokoro",
"enum": [
"auto",
"elevenlabs",
"openai",
"kokoro",
],
"type": "string",
},
"voice": {
"description": "ElevenLabs voice ID for output",
"description": "Voice for the TTS provider (ElevenLabs name/ID, OpenAI voice, Kokoro voice)",
"type": "string",
},
},
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,7 @@ describe("executeTranslateSrt", () => {

const result = await run("claude");
expect(result.success).toBe(false);
expect(result.error).toContain("404 Not Found");
expect(result.error).toContain("404");
expect(result.error).toContain("model: not_found");
});
});
112 changes: 11 additions & 101 deletions packages/cli/src/commands/_shared/edit/translate-srt.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
import { existsSync } from "node:fs";
import { readFile, writeFile } from "node:fs/promises";
import { formatSRT, parseSRT } from "../../../utils/subtitle.js";
import { defaultModel } from "@vibeframe/ai-providers";
import { translateTexts } from "../translate-texts.js";

export interface TranslateSrtOptions {
/** Path to the source SRT subtitle file */
Expand Down Expand Up @@ -69,106 +69,16 @@ export async function executeTranslateSrt(
return { success: false, error: "No subtitle segments found in SRT file" };
}

// Batch translate segments (~30 at a time)
const batchSize = 30;
const translatedSegments: { startTime: number; endTime: number; text: string }[] = [];

for (let i = 0; i < segments.length; i += batchSize) {
const batch = segments.slice(i, i + batchSize);
const textsToTranslate = batch.map((s, idx) => `[${idx}] ${s.text}`).join("\n");

const translatePrompt =
`Translate the following subtitle texts to ${targetLanguage}.` +
(sourceLanguage ? ` The source language is ${sourceLanguage}.` : "") +
` Return ONLY the translated texts, one per line, preserving the [N] prefix format exactly. ` +
`Do not add explanations.\n\n${textsToTranslate}`;

let translatedText: string;

if (provider === "openai") {
const openaiKey = apiKey || process.env.OPENAI_API_KEY;
if (!openaiKey) {
return {
success: false,
error:
"OpenAI API key required for translation. Run 'vibe setup' or set OPENAI_API_KEY in .env",
};
}
const response = await fetch("https://api.openai.com/v1/chat/completions", {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${openaiKey}`,
},
body: JSON.stringify({
model: defaultModel("openai", "llm").id,
messages: [{ role: "user", content: translatePrompt }],
}),
});
if (!response.ok) {
return {
success: false,
error: `OpenAI API error: ${response.status} ${response.statusText}: ${await response.text()}`,
};
}
const data = (await response.json()) as {
choices: Array<{ message: { content: string } }>;
};
translatedText = data.choices[0]?.message?.content || "";
} else {
const claudeKey = apiKey || process.env.ANTHROPIC_API_KEY;
if (!claudeKey) {
return {
success: false,
error:
"Anthropic API key required for translation. Run 'vibe setup' or set ANTHROPIC_API_KEY in .env",
};
}
const response = await fetch("https://api.anthropic.com/v1/messages", {
method: "POST",
headers: {
"Content-Type": "application/json",
"x-api-key": claudeKey,
"anthropic-version": "2023-06-01",
},
body: JSON.stringify({
model: defaultModel("claude", "llm").id,
max_tokens: 16000,
messages: [{ role: "user", content: translatePrompt }],
}),
});
if (!response.ok) {
return {
success: false,
error: `Claude API error: ${response.status} ${response.statusText}: ${await response.text()}`,
};
}
const data = (await response.json()) as {
content: Array<{ type: string; text: string }>;
};
translatedText = data.content?.find((c) => c.type === "text")?.text || "";
}

// Parse translated lines
const translatedLines = translatedText.trim().split("\n");
for (let j = 0; j < batch.length; j++) {
const seg = batch[j];
// Try to match [N] prefix
const line = translatedLines[j];
let text: string;
if (line) {
text = line.replace(/^\[\d+\]\s*/, "").trim();
} else {
// Fallback: use original text if translation is missing
text = seg.text;
}
translatedSegments.push({
startTime: seg.startTime,
endTime: seg.endTime,
text,
});
}
}
const translated = await translateTexts(
segments.map((s) => s.text),
{ targetLanguage, sourceLanguage, provider, apiKey },
);
if (!translated.success) return { success: false, error: translated.error };
const translatedSegments = segments.map((s, i) => ({
startTime: s.startTime,
endTime: s.endTime,
text: translated.texts[i],
}));

// Format as SRT and write
const translatedSrt = formatSRT(translatedSegments);
Expand Down
64 changes: 64 additions & 0 deletions packages/cli/src/commands/_shared/translate-texts.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";

// Keys come only from process.env here, never this machine's config or .env.
vi.mock("../../utils/api-key.js", () => ({
getConfiguredApiKey: async (envVar: string, optionValue?: string) => optionValue ?? process.env[envVar],
}));

import { FakeProviderNetwork, jsonResponse } from "../../testing/fake-provider-network.js";
import { languageName, translateTexts } from "./translate-texts.js";

let net: FakeProviderNetwork;
const savedEnv = { ...process.env };

beforeEach(() => {
net = new FakeProviderNetwork();
net.install();
delete process.env.ANTHROPIC_API_KEY;
delete process.env.OPENAI_API_KEY;
});

afterEach(() => {
net.uninstall();
process.env = { ...savedEnv };
});

describe("translateTexts", () => {
it("matches lines by their [N] index, keeping the source text for any line left out", async () => {
process.env.ANTHROPIC_API_KEY = "k";
// Out of order, line 1 missing, and a chatty preamble.
net.on("POST", "api.anthropic.com", /\/v1\/messages$/, () =>
jsonResponse({ content: [{ type: "text", text: "Here you go:\n[2] 셋\n[0] 하나" }] })
);

const result = await translateTexts(["one", "two", "three"], { targetLanguage: "ko" });

expect(result).toEqual({ success: true, provider: "claude", texts: ["하나", "two", "셋"] });
expect(String((net.requests[0].body as { messages: Array<{ content: string }> }).messages[0].content)).toContain("to Korean");
});

it("uses OpenAI when there is no Anthropic key", async () => {
process.env.OPENAI_API_KEY = "k";
net.on("POST", "api.openai.com", /\/v1\/chat\/completions$/, () =>
jsonResponse({ choices: [{ message: { content: "[0] Hola" } }] })
);

await expect(translateTexts(["Hello"], { targetLanguage: "es" })).resolves.toMatchObject({ success: true, provider: "openai", texts: ["Hola"] });
});

it("batches long lists and reports a missing key as auth", async () => {
await expect(translateTexts(["x"], { targetLanguage: "ko" })).resolves.toMatchObject({ success: false, errorKind: "auth" });

process.env.ANTHROPIC_API_KEY = "k";
net.on("POST", "api.anthropic.com", /\/v1\/messages$/, () => jsonResponse({ content: [{ type: "text", text: "[0] a\n[1] b" }] }));
const result = await translateTexts(["1", "2", "3", "4"], { targetLanguage: "ko", batchSize: 2 });

expect(net.requests).toHaveLength(2);
expect(result).toMatchObject({ texts: ["a", "b", "a", "b"] });
});

it("names language codes for the prompt", () => {
expect(languageName("ja")).toBe("Japanese");
expect(languageName("Swahili")).toBe("Swahili");
});
});
92 changes: 92 additions & 0 deletions packages/cli/src/commands/_shared/translate-texts.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
/**
* Translate a list of short texts (subtitle cues, dub segments) with Claude
* or OpenAI, in batches, keeping each text's position. Lines come back
* tagged `[N]` and are matched by that index, so a skipped or merged line
* cannot shift every later translation; anything missing keeps its source
* text.
*/

import { defaultModel, isProviderError, providerRequest, type ProviderErrorKind } from "@vibeframe/ai-providers";
import { getConfiguredApiKey } from "../../utils/api-key.js";

export interface TranslateTextsOptions {
/** Target language name or code ("Korean", "ko"). */
targetLanguage: string;
sourceLanguage?: string;
/** Default: Claude when ANTHROPIC_API_KEY is set, else OpenAI. */
provider?: "claude" | "openai";
apiKey?: string;
/** Texts per request. Default 30. */
batchSize?: number;
}

export type TranslateTextsResult =
| { success: true; texts: string[]; provider: "claude" | "openai" }
| { success: false; error: string; errorKind?: ProviderErrorKind };

const LANGUAGE_NAMES: Readonly<Record<string, string>> = {
en: "English", es: "Spanish", fr: "French", de: "German", it: "Italian", pt: "Portuguese",
ja: "Japanese", ko: "Korean", zh: "Chinese", ar: "Arabic", ru: "Russian", hi: "Hindi",
};

/** "ko" → "Korean"; names pass through. */
export function languageName(code: string): string {
return LANGUAGE_NAMES[code.toLowerCase()] ?? code;
}

export async function translateTexts(texts: string[], options: TranslateTextsOptions): Promise<TranslateTextsResult> {
const claudeKey = options.provider !== "openai" ? await getConfiguredApiKey("ANTHROPIC_API_KEY", options.provider === "claude" ? options.apiKey : undefined) : undefined;
const provider = options.provider ?? (claudeKey ? "claude" : "openai");
const key = provider === "claude" ? claudeKey : await getConfiguredApiKey("OPENAI_API_KEY", options.apiKey);
if (!key) {
const envVar = provider === "claude" ? "ANTHROPIC_API_KEY" : "OPENAI_API_KEY";
return { success: false, error: `${envVar} required for translation. Run 'vibe setup' or set it in .env`, errorKind: "auth" };
}

const target = languageName(options.targetLanguage);
const batchSize = options.batchSize ?? 30;
const out = [...texts];
try {
for (let start = 0; start < texts.length; start += batchSize) {
const batch = texts.slice(start, start + batchSize);
const prompt =
`Translate each line below to ${target}.` +
(options.sourceLanguage ? ` The source language is ${languageName(options.sourceLanguage)}.` : "") +
" Return exactly one line per input, keeping its [N] prefix, and nothing else.\n\n" +
batch.map((text, i) => `[${i}] ${text.replace(/\s+/g, " ").trim()}`).join("\n");
const reply = provider === "claude" ? await askClaude(key, prompt) : await askOpenAI(key, prompt);
for (const line of reply.split("\n")) {
const m = line.match(/^\s*\[(\d+)\]\s*(.*)$/);
const index = m ? Number(m[1]) : -1;
if (m && index >= 0 && index < batch.length && m[2].trim()) out[start + index] = m[2].trim();
}
}
return { success: true, texts: out, provider };
} catch (error) {
return {
success: false,
error: `Translation failed: ${error instanceof Error ? error.message : String(error)}`,
errorKind: isProviderError(error) ? error.kind : undefined,
};
}
}

async function askClaude(apiKey: string, prompt: string): Promise<string> {
const response = await providerRequest("claude", "https://api.anthropic.com/v1/messages", {
method: "POST",
headers: { "Content-Type": "application/json", "x-api-key": apiKey, "anthropic-version": "2023-06-01" },
body: JSON.stringify({ model: defaultModel("claude", "llm").id, max_tokens: 16000, messages: [{ role: "user", content: prompt }] }),
});
const data = (await response.json()) as { content?: Array<{ type: string; text: string }> };
return data.content?.find((c) => c.type === "text")?.text ?? "";
}

async function askOpenAI(apiKey: string, prompt: string): Promise<string> {
const response = await providerRequest("openai", "https://api.openai.com/v1/chat/completions", {
method: "POST",
headers: { "Content-Type": "application/json", Authorization: `Bearer ${apiKey}` },
body: JSON.stringify({ model: defaultModel("openai", "llm").id, messages: [{ role: "user", content: prompt }] }),
});
const data = (await response.json()) as { choices?: Array<{ message?: { content?: string } }> };
return data.choices?.[0]?.message?.content ?? "";
}
Loading
Loading