From 6fb6fe65baeb5728b552692031fcc80c96ef9102 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ren=C3=A9=20Zander?= <96420681+renezander030@users.noreply.github.com> Date: Wed, 4 Nov 2026 08:17:27 +0000 Subject: [PATCH] fix(translate): harden CapCut cloud translation from #169 translate --cloud and caption --translate (#169) now: - refuse a reply with more or fewer lines than were sent, instead of pairing translations with the wrong timings; - keep the original text, and its word timings, for a line that comes back without a translation, and count it in kept_original (an empty translation used to blank the text); - send nothing under --dry-run: caption --translate --dry-run reports the lines it would send under `translation`, and --cloud --dry-run says so; - warn on stderr how many lines go to ByteDance's servers, and say in the help and command reference that both flags are experimental and unofficial, as caption --cloud does. The install identity both cloud clients replay (app id, device id, locale, user agent) now lives once in capcut-cloud-headers.ts. Each client keeps the signature captured with its own requests, and the header sets are byte-identical to before, in the same order. The translation tests run offline against a stand-in fetch (request shape, 50-line batches, refused and partial replies, retries, cue word timings, the dry runs). The calls to ByteDance run only with CAPCUT_LIVE=1 and fail loudly there, instead of passing on any error. CI: the Python client's 3.9 job runs on ubuntu-24.04; ubuntu-latest (26.04) has no Python 3.9 build for setup-python. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/ci.yml | 4 +- CHANGELOG.md | 8 + docs/command-reference.json | 4 +- src/capcut-cloud-asr.ts | 26 +- src/capcut-cloud-headers.ts | 44 ++++ src/capcut-cloud-translate.ts | 94 +++---- src/caption.ts | 25 +- src/command-specs.ts | 4 +- src/index.ts | 18 +- src/translate.ts | 15 +- test/cloud-translate.test.mjs | 474 +++++++++++++++++++++++++--------- 11 files changed, 498 insertions(+), 218 deletions(-) create mode 100644 src/capcut-cloud-headers.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 32f61ee..e3a6e3e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -123,7 +123,9 @@ jobs: fail-fast: false matrix: include: - - os: ubuntu-latest + # The package supports 3.9 (requires-python), and ubuntu-latest + # (26.04) has no 3.9 build for setup-python; 24.04 still does. + - os: ubuntu-24.04 python: '3.9' - os: ubuntu-latest python: '3.14' diff --git a/CHANGELOG.md b/CHANGELOG.md index 6360052..d1d7315 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,14 @@ All notable changes to capcut-cli are documented here. The format follows [Keep ## [Unreleased] +### Added + +- `translate --cloud` and `caption --translate ` (experimental, unofficial) translate with the subtitle translation behind CapCut's auto-captions, on ByteDance's servers and without an API key. `translate --cloud` writes the translated draft as the Anthropic path does; `caption --translate` translates the cues before they are written and keeps CapCut's timings for the translated words, so `--karaoke` and `--word-reveal` work in the target language. Language names such as `Vietnamese`, `deutsch` or `simplified chinese` map to CapCut's codes. Like `caption --cloud`, it calls CapCut's private endpoints as the desktop app does, so ByteDance can change or refuse them at any time. Every run says on stderr how many lines go to ByteDance, and `--dry-run` sends nothing (`caption --translate --dry-run` reports the lines under `translation`). A reply with more or fewer lines than were sent is refused instead of pairing translations with the wrong timings, and a line that comes back without a translation keeps its original text and is counted in `kept_original` ([#169](https://github.com/renezander030/capcut-cli/pull/169), contributed by @datnt-97). + +### Changed + +- The Python client's 3.9 job runs on `ubuntu-24.04`. `ubuntu-latest` moved to 26.04, for which `setup-python` has no 3.9 build, so the job failed before it ran a test; the package still supports Python 3.9. + ### Fixed - `text-style` applies `--font-size`, `--color`, `--align`, `--x` and `--y` without `--preset`. The flags were declared (and shared with `add-text`) but only honoured through a preset, so a plain `text-style --font-size 22 --color #FFD60A` exited 0 and left the text unchanged. Font size and colour are written to the material and to every style block CapCut renders from, `--x`/`--y` move the segment as `restyle` does, and `--color` must be `#RRGGBB`. `compile`'s `text-style` operation accepts and validates `fontSize`, `alignment` and `color` too. diff --git a/docs/command-reference.json b/docs/command-reference.json index 16b5260..ee6f70d 100644 --- a/docs/command-reference.json +++ b/docs/command-reference.json @@ -4505,7 +4505,7 @@ ], "type": "string", "required": false, - "description": "Translate captions to this target language (e.g. 'vi', 'es', 'de') via CapCut cloud translation with word timings preserved." + "description": "Experimental and unofficial: sends the caption text to ByteDance's CapCut servers and translates it into this language (e.g. 'vi', 'es', 'de'), word timings kept. Nothing is sent under --dry-run." }, { "name": "karaoke", @@ -4696,7 +4696,7 @@ ], "type": "boolean", "required": false, - "description": "Translate via ByteDance's CapCut cloud translation (free, zero API key required)." + "description": "Experimental and unofficial: sends the text to ByteDance's CapCut servers and translates it there instead of with the Anthropic API. No API key needed." }, { "name": "api_key", diff --git a/src/capcut-cloud-asr.ts b/src/capcut-cloud-asr.ts index af02c60..35545b9 100644 --- a/src/capcut-cloud-asr.ts +++ b/src/capcut-cloud-asr.ts @@ -6,6 +6,7 @@ */ import { createHash, createHmac, randomUUID } from "node:crypto"; import { existsSync, readFileSync } from "node:fs"; +import { capcutEditorHeaders } from "./capcut-cloud-headers.js"; import type { CaptionCue, CaptionWord } from "./caption.js"; /** CRC32 lookup table for upload verification. */ @@ -118,26 +119,13 @@ export function signAwsV4( return { headers, url: urlObj.toString() }; } -export const DEFAULT_CAPCUT_HEADERS: Record = { - Host: "editor-api-sg.capcutapi.com", - "App-Sdk-Version": "8.4.0", - "Content-Type": "application/json", - appID: "359289", - appvr: "8.4.0", - ch: "capcutpc_web_product", - "device-time": "1776674107", - lan: "en", - loc: "VN", - pf: "4", +export const DEFAULT_CAPCUT_HEADERS: Record = capcutEditorHeaders({ + appVersion: "8.4.0", + time: "1776674107", sign: "4a60da398f60fefd56083e55ac9eda17", - "sign-ver": "1", - tdid: "7522322460692940289", - "x-ss-stub": "e8eff844fa0457999a30b4d5f6572dff", - "X-SS-DP": "359289", - "x-tt-trace-id": "00-aa077272106864a86c4b8b8601bcffff-aa077272106864a801", - "User-Agent": "Cronet/TTNetVersion:488a55ee 2025-07-10 QuicVersion:55af8b7a 2024-11-18", - "x-khronos": "1776674107", -}; + stub: "e8eff844fa0457999a30b4d5f6572dff", + traceId: "00-aa077272106864a86c4b8b8601bcffff-aa077272106864a801", +}); export interface CloudAsrWord { text: string; diff --git a/src/capcut-cloud-headers.ts b/src/capcut-cloud-headers.ts new file mode 100644 index 0000000..4b8b414 --- /dev/null +++ b/src/capcut-cloud-headers.ts @@ -0,0 +1,44 @@ +/** + * The headers CapCut's desktop app sends to its editor API, as captured from + * one install. Both experimental cloud clients replay them: `caption --cloud` + * (capcut-cloud-asr.ts) and `translate --cloud` / `caption --translate` + * (capcut-cloud-translate.ts). The install's identity is written once here, so + * when ByteDance stops accepting it there is one place to change; each client + * keeps the signature captured with its own requests. + */ + +/** The editor API host both clients call. */ +export const CAPCUT_EDITOR_HOST = "editor-api-sg.capcutapi.com"; + +/** The per-capture part of the headers: app version, capture time and its signature. */ +export interface CapcutCapturedSignature { + appVersion: string; + time: string; // device-time and x-khronos, Unix seconds + sign: string; + stub: string; // x-ss-stub + traceId: string; // x-tt-trace-id +} + +/** The full header set for one captured signature, in the order the app sends them. */ +export function capcutEditorHeaders(s: CapcutCapturedSignature): Record { + return { + Host: CAPCUT_EDITOR_HOST, + "App-Sdk-Version": s.appVersion, + "Content-Type": "application/json", + appID: "359289", + appvr: s.appVersion, + ch: "capcutpc_web_product", + "device-time": s.time, + lan: "en", + loc: "VN", + pf: "4", + sign: s.sign, + "sign-ver": "1", + tdid: "7522322460692940289", + "x-ss-stub": s.stub, + "X-SS-DP": "359289", + "x-tt-trace-id": s.traceId, + "User-Agent": "Cronet/TTNetVersion:488a55ee 2025-07-10 QuicVersion:55af8b7a 2024-11-18", + "x-khronos": s.time, + }; +} diff --git a/src/capcut-cloud-translate.ts b/src/capcut-cloud-translate.ts index 0df44d8..abc9875 100644 --- a/src/capcut-cloud-translate.ts +++ b/src/capcut-cloud-translate.ts @@ -5,31 +5,17 @@ * API: ByteDance can change or refuse them at any time. */ import { randomUUID } from "node:crypto"; +import { CAPCUT_EDITOR_HOST, capcutEditorHeaders } from "./capcut-cloud-headers.js"; import type { CaptionCue, CaptionWord } from "./caption.js"; -/** - * Standard HTTP headers accepted by ByteDance CapCut's sync task endpoint. - */ -export const DEFAULT_CAPCUT_TRANSLATE_HEADERS: Record = { - Host: "editor-api-sg.capcutapi.com", - "App-Sdk-Version": "8.5.0", - "Content-Type": "application/json", - appID: "359289", - appvr: "8.5.0", - ch: "capcutpc_web_product", - "device-time": "1776996536", - lan: "en", - loc: "VN", - pf: "4", +/** The captured headers this client replays; the shared identity is in capcut-cloud-headers.ts. */ +export const DEFAULT_CAPCUT_TRANSLATE_HEADERS: Record = capcutEditorHeaders({ + appVersion: "8.5.0", + time: "1776996536", sign: "279639a654a9a0928d629f54e79fd626", - "sign-ver": "1", - tdid: "7522322460692940289", - "x-ss-stub": "52ac308361babf4705e2f350bd2f7506", - "X-SS-DP": "359289", - "x-tt-trace-id": "00-bd3f507b106864a86c4b8b860105ffff-bd3f507b106864a801", - "User-Agent": "Cronet/TTNetVersion:488a55ee 2025-07-10 QuicVersion:55af8b7a 2024-11-18", - "x-khronos": "1776996536", -}; + stub: "52ac308361babf4705e2f350bd2f7506", + traceId: "00-bd3f507b106864a86c4b8b860105ffff-bd3f507b106864a801", +}); /** * Supported language mappings: friendly names / aliases to CapCut canonical codes. @@ -154,6 +140,8 @@ export interface CloudTranslateItem { text: string; translation_text: string; translation_words?: CloudTranslateWord[]; + /** The service sent no translation for this line, so translation_text is the original text. */ + kept_original?: boolean; } export interface CloudTranslateOptions { @@ -251,7 +239,7 @@ export async function translateUtterancesCapcutCloud( const chunkSize = opts?.chunkSize ?? 50; const timeoutMs = opts?.requestTimeoutMs ?? 60_000; const retryDelayMs = opts?.retryDelayMs ?? 1500; - const url = "https://editor-api-sg.capcutapi.com/lv/v1/common_task/sync"; + const url = `https://${CAPCUT_EDITOR_HOST}/lv/v1/common_task/sync`; const allResults: CloudTranslateItem[] = []; @@ -316,6 +304,14 @@ export async function translateUtterancesCapcutCloud( ); } + // Translations pair with the lines sent by position, so a reply with more + // or fewer lines would put text on the wrong timings: refuse it. + if (targetItems.length !== chunk.length) { + throw new Error( + `CapCut Cloud Translation returned ${targetItems.length} line(s) for the ${chunk.length} sent; nothing was translated. Try again, or translate without --cloud.`, + ); + } + for (let j = 0; j < chunk.length; j++) { const original = chunk[j]; const item = targetItems[j] as @@ -332,7 +328,7 @@ export async function translateUtterancesCapcutCloud( } | undefined; - const transText = item?.translation_text ?? original.text; + const translated = item?.translation_text?.trim() ? item.translation_text : undefined; const transWords: CloudTranslateWord[] | undefined = Array.isArray(item?.translation_words) ? item.translation_words .filter((w) => Boolean(w?.text?.trim())) @@ -347,8 +343,9 @@ export async function translateUtterancesCapcutCloud( start_time: original.start_time, end_time: original.end_time, text: original.text, - translation_text: transText, - translation_words: transWords && transWords.length > 0 ? transWords : undefined, + translation_text: translated ?? original.text, + translation_words: translated && transWords && transWords.length > 0 ? transWords : undefined, + ...(translated ? {} : { kept_original: true }), }); } } @@ -356,35 +353,17 @@ export async function translateUtterancesCapcutCloud( return allResults; } -/** - * Translate a list of text strings via CapCut Cloud Translation. - * Preserves exact array length and ordering. - */ -export async function translateTextsCapcutCloud( - texts: string[], - targetLang: string, - opts?: CloudTranslateOptions, -): Promise { - if (texts.length === 0) return []; - const utterances: CloudTranslateUtterance[] = texts.map((t) => ({ - start_time: 0, - end_time: 0, - text: t, - })); - const items = await translateUtterancesCapcutCloud(utterances, targetLang, opts); - return items.map((item) => item.translation_text); -} - /** * Translate caption cues via CapCut Cloud Translation, updating cue text and - * word-level karaoke timestamps in microseconds. + * word-level karaoke timestamps in microseconds. A cue the service sent no + * translation for keeps its text and its words, and is counted in kept_original. */ export async function translateCuesCapcutCloud( cues: CaptionCue[], targetLang: string, opts?: CloudTranslateOptions, -): Promise { - if (cues.length === 0) return []; +): Promise<{ cues: CaptionCue[]; kept_original: number }> { + if (cues.length === 0) return { cues: [], kept_original: 0 }; const utterances: CloudTranslateUtterance[] = cues.map((cue) => ({ start_time: Math.round(cue.startUs / 1000), @@ -394,22 +373,21 @@ export async function translateCuesCapcutCloud( const items = await translateUtterancesCapcutCloud(utterances, targetLang, opts); - return cues.map((cue, idx) => { + const translated = cues.map((cue, idx) => { const item = items[idx]; - const transText = item?.translation_text || cue.text; - const transWords: CaptionWord[] | undefined = item?.translation_words - ? item.translation_words.map((w) => ({ - word: w.text, - startUs: Math.round(w.start_time * 1000), - endUs: Math.round(w.end_time * 1000), - })) - : undefined; + if (item.kept_original) return cue; + const transWords: CaptionWord[] | undefined = item.translation_words?.map((w) => ({ + word: w.text, + startUs: Math.round(w.start_time * 1000), + endUs: Math.round(w.end_time * 1000), + })); return { startUs: cue.startUs, endUs: cue.endUs, - text: transText, + text: item.translation_text, words: transWords && transWords.length > 0 ? transWords : undefined, }; }); + return { cues: translated, kept_original: items.filter((item) => item.kept_original).length }; } diff --git a/src/caption.ts b/src/caption.ts index 4451265..3f6e63f 100644 --- a/src/caption.ts +++ b/src/caption.ts @@ -90,6 +90,8 @@ export interface CaptionResult { word_reveal?: boolean; audio_stream?: number; translated_to?: string; + /** --translate: what went to CapCut's translation, or under --dry-run what would have. */ + translation?: { language: string; lines: number; to: string; sent: boolean; kept_original?: number }; /** --words: the file the timings came from, its detected shape, and how * many entries had no usable timing and were left out. */ source_words?: string; @@ -313,12 +315,22 @@ export async function captionDraft(draft: Draft, opts: CaptionOptions): Promise< throw new Error("Whisper produced no cues. Check the audio file is not silent and the model name is valid."); } + let translation: CaptionResult["translation"]; if (opts.translate) { - const targetCode = normalizeLanguageCode(opts.translate); - process.stderr.write(`Translating captions to ${targetCode} via ByteDance CapCut cloud translation...\n`); - cues = await translateCuesCapcutCloud(cues, targetCode, { - from: opts.language, - }); + const language = normalizeLanguageCode(opts.translate); + const to = "CapCut Cloud translation (ByteDance servers)"; + if (isDryRun()) { + // A dry run sends nothing anywhere: the captions stay in their language + // and the result says what a real run would send. + translation = { language, lines: cues.length, to, sent: false }; + } else { + process.stderr.write( + `WARNING: --translate is experimental and unofficial: sending ${cues.length} caption line(s) to ByteDance's CapCut servers for translation into ${language}.\n`, + ); + const translated = await translateCuesCapcutCloud(cues, language, { from: opts.language }); + cues = translated.cues; + translation = { language, lines: cues.length, to, sent: true, kept_original: translated.kept_original }; + } } const trackName = opts.trackName ?? "captions"; @@ -429,7 +441,8 @@ export async function captionDraft(draft: Draft, opts: CaptionOptions): Promise< karaoke: opts.karaoke ?? false, word_reveal: opts.wordReveal || undefined, audio_stream: opts.audioStream, - translated_to: opts.translate ? normalizeLanguageCode(opts.translate) : undefined, + translated_to: translation?.sent ? translation.language : undefined, + translation, source_words: external ? (opts.wordsSource ?? "stdin") : undefined, words_format: external?.format, words_skipped: external?.skipped, diff --git a/src/command-specs.ts b/src/command-specs.ts index 05a379a..02dd61e 100644 --- a/src/command-specs.ts +++ b/src/command-specs.ts @@ -616,7 +616,7 @@ const optionsByCommand: Record = { "translate", ["--translate"], "string", - "Translate captions to this target language (e.g. 'vi', 'es', 'de') via CapCut cloud translation with word timings preserved.", + "Experimental and unofficial: sends the caption text to ByteDance's CapCut servers and translates it into this language (e.g. 'vi', 'es', 'de'), word timings kept. Nothing is sent under --dry-run.", ), option("karaoke", ["--karaoke"], "boolean", "Create word-highlight caption ranges."), option( @@ -657,7 +657,7 @@ const optionsByCommand: Record = { "cloud", ["--cloud"], "boolean", - "Translate via ByteDance's CapCut cloud translation (free, zero API key required).", + "Experimental and unofficial: sends the text to ByteDance's CapCut servers and translates it there instead of with the Anthropic API. No API key needed.", ), option("api_key", ["--api-key"], "string", "Anthropic API key."), option("model", ["--model"], "string", "Anthropic model."), diff --git a/src/index.ts b/src/index.ts index 819fd87..e87ee2d 100644 --- a/src/index.ts +++ b/src/index.ts @@ -719,8 +719,9 @@ Caption (v0.4 — real subtitle objects, fixes import-srt mimicry): --cloud Experimental, unofficial: upload the audio to ByteDance's CapCut servers and transcribe it there instead of with local Whisper (needs ffprobe) - --translate Translate captions to target language via CapCut - cloud translation with word timings preserved + --translate Experimental, unofficial: send the caption text to + ByteDance's CapCut servers and translate it there, + word timings kept (nothing is sent under --dry-run) --whisper-cmd Path to whisper binary (default: "whisper") --whisper-model Model name (default: "base") --audio-stream Zero-based audio stream in the input @@ -766,7 +767,8 @@ Translate (v0.4 — multi-language draft clone): translate --to --out [options] Translate every text segment via Anthropic API or CapCut cloud, write a new draft. Options: - --cloud Translate via ByteDance CapCut servers (zero API key, free) + --cloud Experimental, unofficial: send the text to ByteDance's + CapCut servers and translate it there (no API key) --from Source language (default: "auto") --api-key Override ANTHROPIC_API_KEY env var --model Model (default: claude-haiku-5-5) @@ -4070,6 +4072,7 @@ async function cmdCaption(draft: Draft, filePath: string, flags: Flags): Promise if (flags.cloud && isDryRun()) { // A dry run sends nothing anywhere: report what --cloud would upload. const { cloudLanguageWarning, planCloudUpload } = await import("./caption.js"); + const { normalizeLanguageCode } = await import("./capcut-cloud-translate.js"); let plan: ReturnType; try { plan = planCloudUpload(draft, captionOptions); @@ -4085,6 +4088,15 @@ async function cmdCaption(draft: Draft, filePath: string, flags: Flags): Promise engine: "capcut-cloud", uploaded: false, would_upload: { ...plan, to: "CapCut Cloud ASR (ByteDance servers)" }, + ...(flags.translate !== undefined + ? { + translation: { + language: normalizeLanguageCode(flags.translate), + to: "CapCut Cloud translation (ByteDance servers)", + sent: false, + }, + } + : {}), cues: 0, }, flags, diff --git a/src/translate.ts b/src/translate.ts index 51fd4c3..b355153 100644 --- a/src/translate.ts +++ b/src/translate.ts @@ -1,5 +1,5 @@ import { writeFileSync } from "node:fs"; -import { translateTextsCapcutCloud } from "./capcut-cloud-translate.js"; +import { translateUtterancesCapcutCloud } from "./capcut-cloud-translate.js"; import type { Draft } from "./draft.js"; import { extractText, updateTextContent } from "./draft.js"; @@ -22,6 +22,8 @@ export interface TranslateResult { pairs: Array<{ id: string; original: string; translated: string }>; dry_run: boolean; engine?: "anthropic" | "capcut-cloud"; + /** --cloud: segments the service sent no translation for, written with their original text. */ + kept_original?: number; } /** @@ -53,14 +55,20 @@ export async function translateDraft(draft: Draft, opts: TranslateOptions): Prom } let translations: string[]; + let keptOriginal = 0; const engine = opts.cloud ? "capcut-cloud" : "anthropic"; if (opts.cloud) { - translations = await translateTextsCapcutCloud( - collected.map((c) => c.original), + process.stderr.write( + `WARNING: --cloud is experimental and unofficial: sending ${collected.length} text segment(s) to ByteDance's CapCut servers for translation.\n`, + ); + const items = await translateUtterancesCapcutCloud( + collected.map((c) => ({ start_time: 0, end_time: 0, text: c.original })), opts.to, { from: opts.from }, ); + translations = items.map((item) => item.translation_text); + keptOriginal = items.filter((item) => item.kept_original).length; } else { const apiKey = opts.apiKey ?? process.env.ANTHROPIC_API_KEY; if (!apiKey) { @@ -100,6 +108,7 @@ export async function translateDraft(draft: Draft, opts: TranslateOptions): Prom pairs, dry_run: false, engine, + ...(keptOriginal > 0 ? { kept_original: keptOriginal } : {}), }; } diff --git a/test/cloud-translate.test.mjs b/test/cloud-translate.test.mjs index 104c171..2a9bcc7 100644 --- a/test/cloud-translate.test.mjs +++ b/test/cloud-translate.test.mjs @@ -2,176 +2,402 @@ import assert from "node:assert/strict"; import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; -import { after, describe, it } from "node:test"; +import { afterEach, describe, it } from "node:test"; +import { DEFAULT_CAPCUT_HEADERS } from "../dist/capcut-cloud-asr.js"; import { + DEFAULT_CAPCUT_TRANSLATE_HEADERS, normalizeLanguageCode, translateCuesCapcutCloud, - translateTextsCapcutCloud, translateUtterancesCapcutCloud, } from "../dist/capcut-cloud-translate.js"; +import { captionDraft } from "../dist/caption.js"; +import { extractText, loadDraft } from "../dist/draft.js"; +import { translateDraft } from "../dist/translate.js"; import { spawnCli } from "./helpers/spawn-cli.mjs"; import { tmpDraft } from "./helpers/tmp-draft.mjs"; +// The translation exchange runs offline: a stand-in fetch answers the way +// CapCut's common_task/sync does. The calls to ByteDance itself run only with +// CAPCUT_LIVE=1, so `npm test` and CI never send text anywhere. +const LIVE = process.env.CAPCUT_LIVE === "1"; + +const realFetch = globalThis.fetch; +afterEach(() => { + globalThis.fetch = realFetch; +}); + +const json = (body, init = {}) => + new Response(JSON.stringify(body), { status: 200, headers: { "content-type": "application/json" }, ...init }); + +/** A reply carrying one translated line per line sent, the payload as a JSON string as the service sends it. */ +function reply(lang, lines) { + return json({ + ret: "0", + errmsg: "success", + data: { tasks: [{ payload: JSON.stringify({ translation: { [lang]: lines } }) }] }, + }); +} + +/** Stand-in service: `answer(sentUtterances, request)` returns the Response; every request is recorded. */ +function service(answer) { + const calls = []; + globalThis.fetch = async (url, init = {}) => { + const body = JSON.parse(init.body); + calls.push({ url: String(url), method: init.method, headers: init.headers, body }); + return answer(body.tasks[0].payload.utterances, body); + }; + return calls; +} + +/** Translates each line by prefixing the language, with one timed word per source word. */ +const echo = (lang) => (sent) => + reply( + lang, + sent.map((u) => ({ + text: u.text, + start_time: u.start_time, + end_time: u.end_time, + translation_text: `${lang}:${u.text}`, + translation_words: u.text.split(" ").map((w, i) => ({ + text: `${lang}:${w}`, + start_time: u.start_time + i * 100, + end_time: u.start_time + (i + 1) * 100, + })), + })), + ); + +const FAST = { retryDelayMs: 0 }; + describe("capcut-cloud-translate — language normalization", () => { it("normalizes common language names and variants to canonical CapCut codes", () => { assert.equal(normalizeLanguageCode("English"), "en"); - assert.equal(normalizeLanguageCode("english"), "en"); - assert.equal(normalizeLanguageCode("en"), "en"); - - assert.equal(normalizeLanguageCode("Vietnamese"), "vi"); assert.equal(normalizeLanguageCode("tieng_viet"), "vi"); - assert.equal(normalizeLanguageCode("vi"), "vi"); - - assert.equal(normalizeLanguageCode("Spanish"), "es"); assert.equal(normalizeLanguageCode("espanol"), "es"); - assert.equal(normalizeLanguageCode("es"), "es"); - - assert.equal(normalizeLanguageCode("French"), "fr"); - assert.equal(normalizeLanguageCode("francais"), "fr"); - - assert.equal(normalizeLanguageCode("German"), "de"); assert.equal(normalizeLanguageCode("deutsch"), "de"); - assert.equal(normalizeLanguageCode("Chinese"), "zh-CN"); - assert.equal(normalizeLanguageCode("simplified chinese"), "zh-CN"); assert.equal(normalizeLanguageCode("traditional chinese"), "zh-TW"); assert.equal(normalizeLanguageCode("zh-tw"), "zh-TW"); - - assert.equal(normalizeLanguageCode("Japanese"), "ja"); assert.equal(normalizeLanguageCode("nihongo"), "ja"); - - assert.equal(normalizeLanguageCode("Korean"), "ko"); - assert.equal(normalizeLanguageCode("hangul"), "ko"); - - assert.equal(normalizeLanguageCode("Portuguese"), "pt"); - assert.equal(normalizeLanguageCode("Russian"), "ru"); assert.equal(normalizeLanguageCode("Arabic"), "ar-SA"); - assert.equal(normalizeLanguageCode("Turkish"), "tr"); - assert.equal(normalizeLanguageCode("Dutch"), "nl"); - assert.equal(normalizeLanguageCode("Italian"), "it"); - assert.equal(normalizeLanguageCode("Indonesian"), "id"); - assert.equal(normalizeLanguageCode("unknown-lang"), "unknown-lang"); }); }); -describe("capcut-cloud-translate — empty inputs", () => { - it("handles empty arrays immediately without network calls", async () => { - assert.deepEqual(await translateUtterancesCapcutCloud([], "vi"), []); - assert.deepEqual(await translateTextsCapcutCloud([], "vi"), []); - assert.deepEqual(await translateCuesCapcutCloud([], "vi"), []); +describe("capcut-cloud-translate — captured headers", () => { + it("both cloud clients send the same install identity and differ only in their captured signature", () => { + const signatureKeys = [ + "App-Sdk-Version", + "appvr", + "device-time", + "sign", + "x-ss-stub", + "x-tt-trace-id", + "x-khronos", + ]; + assert.deepEqual(Object.keys(DEFAULT_CAPCUT_TRANSLATE_HEADERS), Object.keys(DEFAULT_CAPCUT_HEADERS)); + for (const key of Object.keys(DEFAULT_CAPCUT_HEADERS)) { + if (signatureKeys.includes(key)) continue; + assert.equal(DEFAULT_CAPCUT_TRANSLATE_HEADERS[key], DEFAULT_CAPCUT_HEADERS[key], key); + } + assert.equal(DEFAULT_CAPCUT_HEADERS.sign, "4a60da398f60fefd56083e55ac9eda17"); + assert.equal(DEFAULT_CAPCUT_HEADERS["x-khronos"], DEFAULT_CAPCUT_HEADERS["device-time"]); + assert.equal(DEFAULT_CAPCUT_TRANSLATE_HEADERS.sign, "279639a654a9a0928d629f54e79fd626"); + assert.equal(DEFAULT_CAPCUT_TRANSLATE_HEADERS.appvr, "8.5.0"); }); }); -describe("capcut translate --cloud — CLI execution", () => { - const fix = tmpDraft(); - const outDir = mkdtempSync(join(tmpdir(), "capcut-cloud-translate-")); - const outPath = join(outDir, "translated-cloud.json"); - after(() => { - fix.cleanup(); - rmSync(outDir, { recursive: true, force: true }); +describe("capcut-cloud-translate — exchange (offline)", () => { + it("returns nothing and sends nothing for no lines", async () => { + const calls = service(echo("vi")); + assert.deepEqual(await translateUtterancesCapcutCloud([], "vi"), []); + assert.deepEqual(await translateCuesCapcutCloud([], "vi"), { cues: [], kept_original: 0 }); + assert.equal(calls.length, 0); }); - it("supports --cloud with --dry-run without API keys", () => { - const r = spawnCli(["translate", fix.path, "--to", "vi", "--out", outPath, "--cloud", "--dry-run"]); - assert.equal(r.status, 0, `stderr: ${r.stderr}`); - assert.ok(r.json.ok); - assert.equal(r.json.dry_run, true); - assert.equal(r.json.engine, "capcut-cloud"); - assert.equal(r.json.to, "vi"); - assert.ok(r.json.count >= 1); - assert.ok(existsSync(outPath)); + it("sends the subtitle task and pairs each translation with its line", async () => { + const calls = service(echo("vi")); + const res = await translateUtterancesCapcutCloud( + [ + { start_time: 0, end_time: 900.4, text: "hello world" }, + { start_time: 1000, end_time: 1500, text: "bye" }, + ], + "Vietnamese", + { from: "english", ...FAST }, + ); + assert.equal(calls.length, 1); + assert.equal(calls[0].url, "https://editor-api-sg.capcutapi.com/lv/v1/common_task/sync"); + assert.equal(calls[0].method, "POST"); + assert.equal(calls[0].headers, DEFAULT_CAPCUT_TRANSLATE_HEADERS); + const task = calls[0].body.tasks[0]; + assert.equal(task.req_key, "cc_audio_subtitle_translate"); + assert.equal(task.payload.source_language, "en"); + assert.deepEqual(task.payload.target_languages, ["vi"]); + assert.equal(task.payload.utterances[0].end_time, 900, "times go out as whole milliseconds"); + assert.deepEqual( + res.map((r) => r.translation_text), + ["vi:hello world", "vi:bye"], + ); + assert.equal(res[0].kept_original, undefined); + assert.equal(res[0].translation_words.length, 2); }); - it("performs live translation via CapCut Cloud when --cloud is used", async () => { - const r = spawnCli(["translate", fix.path, "--to", "vi", "--out", outPath, "--cloud"]); - if (r.status !== 0 && (r.stderr.includes("1014") || r.stderr.includes("fetch failed"))) { - // ByteDance server throttling / offline sandbox guard - return; - } - assert.equal(r.status, 0, `stderr: ${r.stderr}`); - assert.ok(r.json.ok); - assert.equal(r.json.engine, "capcut-cloud"); - assert.equal(r.json.to, "vi"); - assert.equal(r.json.dry_run, false); - assert.ok(r.json.pairs.length >= 1); - assert.ok(existsSync(outPath)); + it("detects the source language unless one is given", async () => { + const calls = service(echo("de")); + await translateUtterancesCapcutCloud([{ start_time: 0, end_time: 0, text: "x" }], "de", FAST); + assert.equal(calls[0].body.tasks[0].payload.source_language, "UNSPECIFIED"); + }); + + it("sends 50 lines per request and keeps their order", async () => { + const calls = service(echo("es")); + const lines = Array.from({ length: 51 }, (_, i) => ({ start_time: i, end_time: i + 1, text: `line ${i}` })); + const res = await translateUtterancesCapcutCloud(lines, "es", FAST); + assert.deepEqual( + calls.map((c) => c.body.tasks[0].payload.utterances.length), + [50, 1], + ); + assert.equal(res.length, 51); + assert.equal(res[50].translation_text, "es:line 50"); + }); - const translatedDraft = JSON.parse(readFileSync(outPath, "utf-8")); - assert.ok(translatedDraft.materials?.texts?.length > 0); + it("refuses a reply with fewer lines than were sent instead of shifting translations onto other lines", async () => { + service((sent) => echo("vi")(sent.slice(1))); + await assert.rejects( + translateUtterancesCapcutCloud( + [ + { start_time: 0, end_time: 1, text: "a" }, + { start_time: 1, end_time: 2, text: "b" }, + ], + "vi", + FAST, + ), + /returned 1 line\(s\) for the 2 sent; nothing was translated/, + ); + }); + + it("keeps the original text, marked, for a line the reply left untranslated", async () => { + service((sent) => + reply("vi", [ + { text: sent[0].text, translation_text: "" }, + { text: sent[1].text, translation_text: "vi:b" }, + ]), + ); + const res = await translateUtterancesCapcutCloud( + [ + { start_time: 0, end_time: 1, text: "a" }, + { start_time: 1, end_time: 2, text: "b" }, + ], + "vi", + FAST, + ); + assert.deepEqual( + res.map((r) => [r.translation_text, r.kept_original]), + [ + ["a", true], + ["vi:b", undefined], + ], + ); + }); + + it("names the service's refusals", async () => { + service(() => json({ ret: "1014", errmsg: "system busy" })); + await assert.rejects( + translateUtterancesCapcutCloud([{ start_time: 0, end_time: 1, text: "a" }], "vi", FAST), + /1014/, + ); + service(() => json({ ret: "2", errmsg: "bad lang" })); + await assert.rejects( + translateUtterancesCapcutCloud([{ start_time: 0, end_time: 1, text: "a" }], "vi", FAST), + /\[ret=2\]: bad lang/, + ); + service(() => reply("en", [])); + await assert.rejects( + translateUtterancesCapcutCloud([{ start_time: 0, end_time: 1, text: "a" }], "vi", FAST), + /"vi" \(vi\) was not recognized/, + ); + }); + + it("retries a 5xx answer and then succeeds", async () => { + let n = 0; + service((sent) => (++n === 1 ? new Response("down", { status: 503 }) : echo("vi")(sent))); + const res = await translateUtterancesCapcutCloud([{ start_time: 0, end_time: 1, text: "a" }], "vi", FAST); + assert.equal(n, 2); + assert.equal(res[0].translation_text, "vi:a"); + }); + + it("turns translated words into µs cue words and leaves an untranslated cue exactly as it was", async () => { + service((sent) => + reply("vi", [ + { + text: sent[0].text, + translation_text: "xin chào", + translation_words: [ + { text: "xin", start_time: 1000, end_time: 1500 }, + { text: " ", start_time: 1500, end_time: 1500 }, + { text: "chào", start_time: 1500, end_time: 2900 }, + ], + }, + { text: sent[1].text, translation_text: " " }, + ]), + ); + const untouched = { + startUs: 3_000_000, + endUs: 4_000_000, + text: "CapCut", + words: [{ word: "CapCut", startUs: 3_000_000, endUs: 4_000_000 }], + }; + const { cues, kept_original } = await translateCuesCapcutCloud( + [{ startUs: 1_000_000, endUs: 3_000_000, text: "hello there" }, untouched], + "vi", + FAST, + ); + assert.equal(kept_original, 1); + assert.deepEqual(cues[0], { + startUs: 1_000_000, + endUs: 3_000_000, + text: "xin chào", + words: [ + { word: "xin", startUs: 1_000_000, endUs: 1_500_000 }, + { word: "chào", startUs: 1_500_000, endUs: 2_900_000 }, + ], + }); + assert.equal(cues[1], untouched); + }); +}); + +describe("translate --cloud (offline)", () => { + it("writes the translations into the new draft and counts the segments kept in their language", async (t) => { + const fix = tmpDraft(); + t.after(fix.cleanup); + const outDir = mkdtempSync(join(tmpdir(), "capcut-cloud-translate-")); + t.after(() => rmSync(outDir, { recursive: true, force: true })); + const outPath = join(outDir, "translated.json"); + const calls = service(echo("vi")); + const res = await translateDraft(loadDraft(fix.path).draft, { to: "vi", outPath, cloud: true }); + assert.equal(calls.length, 1); + assert.equal(res.engine, "capcut-cloud"); + assert.equal(res.kept_original, undefined); + assert.ok(res.pairs.length >= 1); + for (const pair of res.pairs) assert.equal(pair.translated, `vi:${pair.original}`); + const written = loadDraft(outPath).draft; + const texts = written.materials.texts.map((m) => extractText(m.content)).filter((x) => x.trim()); + assert.deepEqual( + texts, + res.pairs.map((p) => p.translated), + ); + + service((sent) => + reply( + "vi", + sent.map((u) => ({ text: u.text, translation_text: "" })), + ), + ); + const kept = await translateDraft(loadDraft(fix.path).draft, { to: "vi", outPath, cloud: true }); + assert.equal(kept.kept_original, kept.pairs.length); + for (const pair of kept.pairs) assert.equal(pair.translated, pair.original); }); }); -describe("capcut caption --translate — CLI execution", () => { - function tmpWords(t, content) { +describe("caption --translate (offline)", () => { + it("translates the cues before they are written and reports what was sent", async (t) => { + const fix = tmpDraft(); + t.after(fix.cleanup); + const { draft } = loadDraft(fix.path); + service(echo("vi")); + const res = await captionDraft(draft, { + wordsJson: JSON.stringify([ + { word: "Hello", start: 0.1, end: 0.5 }, + { word: "world", start: 0.5, end: 1.0 }, + ]), + translate: "Vietnamese", + }); + assert.equal(res.translated_to, "vi"); + assert.deepEqual(res.translation, { + language: "vi", + lines: res.cues, + to: "CapCut Cloud translation (ByteDance servers)", + sent: true, + kept_original: 0, + }); + const track = draft.tracks.find((tr) => tr.type === "text" && tr.name === "captions"); + const ids = new Set(track.segments.map((s) => s.material_id)); + const written = draft.materials.texts.filter((m) => ids.has(m.id)).map((m) => extractText(m.content)); + assert.ok(written.length >= 1); + for (const text of written) assert.match(text, /^vi:/); + }); +}); + +describe("--dry-run sends nothing to CapCut's translation", () => { + function wordsFile(t) { const dir = mkdtempSync(join(tmpdir(), "capcut-caption-translate-words-")); t.after(() => rmSync(dir, { recursive: true, force: true })); const path = join(dir, "words.json"); - writeFileSync(path, typeof content === "string" ? content : JSON.stringify(content)); + writeFileSync( + path, + JSON.stringify([ + { word: "Hello", start: 0.1, end: 0.5 }, + { word: "world", start: 0.5, end: 1.0 }, + ]), + ); return path; } - it("translates captions when caption --translate is passed", (t) => { + it("caption --translate --dry-run reports the lines it would send and leaves the draft as it was", (t) => { const fix = tmpDraft(); - t.after(() => fix.cleanup()); - const wordsFile = tmpWords(t, [ - { word: "Hello", start: 0.1, end: 0.5 }, - { word: "world", start: 0.5, end: 1.0 }, - ]); - const r = spawnCli(["caption", fix.path, "--words", wordsFile, "--translate", "vi"]); - if (r.status !== 0 && (r.stderr.includes("1014") || r.stderr.includes("fetch failed"))) { - return; - } - assert.equal(r.status, 0, `stderr: ${r.stderr}`); - assert.ok(r.json.ok); - assert.equal(r.json.translated_to, "vi"); - assert.ok(r.json.cues >= 1); + t.after(fix.cleanup); + const before = readFileSync(fix.path, "utf-8"); + const r = spawnCli(["caption", fix.path, "--words", wordsFile(t), "--translate", "vi", "--dry-run"]); + assert.equal(r.status, 0, r.stderr); + assert.equal(r.json.translated_to, undefined); + assert.deepEqual(r.json.translation, { + language: "vi", + lines: r.json.cues, + to: "CapCut Cloud translation (ByteDance servers)", + sent: false, + }); + assert.doesNotMatch(r.stderr, /sending/); + assert.equal(readFileSync(fix.path, "utf-8"), before); + }); - const draft = JSON.parse(readFileSync(fix.path, "utf-8")); - const track = draft.tracks.find((tr) => tr.type === "text" && tr.name === "captions"); - assert.ok(track); - assert.ok(track.segments.length >= 1); + it("translate --cloud --dry-run needs no API key and sends nothing", (t) => { + const fix = tmpDraft(); + t.after(fix.cleanup); + const outDir = mkdtempSync(join(tmpdir(), "capcut-cloud-translate-")); + t.after(() => rmSync(outDir, { recursive: true, force: true })); + const outPath = join(outDir, "translated-cloud.json"); + const r = spawnCli(["translate", fix.path, "--to", "vi", "--out", outPath, "--cloud", "--dry-run"], { + env: { ANTHROPIC_API_KEY: "" }, + }); + assert.equal(r.status, 0, r.stderr); + assert.equal(r.json.dry_run, true); + assert.equal(r.json.engine, "capcut-cloud"); + assert.ok(r.json.count >= 1); + assert.ok(existsSync(outPath)); + assert.doesNotMatch(r.stderr, /sending/); }); }); -describe("capcut-cloud-translate — live translation functions", () => { - it("translates plain texts via CapCut Cloud", async () => { - try { - const res = await translateTextsCapcutCloud(["Hello world", "Good morning"], "vi"); - assert.equal(res.length, 2); - assert.ok(typeof res[0] === "string" && res[0].length > 0); - assert.ok(typeof res[1] === "string" && res[1].length > 0); - } catch (e) { - if (e.message.includes("1014") || e.message.includes("fetch failed")) { - return; // network throttling / offline guard - } - throw e; - } +describe("CapCut's translation service (live, CAPCUT_LIVE=1)", { + skip: !LIVE && "set CAPCUT_LIVE=1 to call ByteDance", +}, () => { + it("translates plain lines", async () => { + const res = await translateUtterancesCapcutCloud( + [ + { start_time: 0, end_time: 0, text: "Hello world" }, + { start_time: 0, end_time: 0, text: "Good morning" }, + ], + "vi", + ); + assert.equal(res.length, 2); + for (const item of res) assert.equal(item.kept_original, undefined, JSON.stringify(item)); }); - it("translates caption cues and preserves word timings", async () => { - try { - const inputCues = [ - { - startUs: 1_000_000, - endUs: 3_000_000, - text: "The quick brown fox jumps over the lazy dog", - }, - ]; - const translated = await translateCuesCapcutCloud(inputCues, "vi"); - assert.equal(translated.length, 1); - assert.equal(translated[0].startUs, 1_000_000); - assert.equal(translated[0].endUs, 3_000_000); - assert.ok(translated[0].text.length > 0); - assert.ok(Array.isArray(translated[0].words)); - assert.ok(translated[0].words.length > 0); - assert.ok(translated[0].words[0].startUs >= 1_000_000); - assert.ok(translated[0].words[translated[0].words.length - 1].endUs <= 3_000_000); - } catch (e) { - if (e.message.includes("1014") || e.message.includes("fetch failed")) { - return; - } - throw e; - } + it("translates a cue with word timings inside it", async () => { + const { cues } = await translateCuesCapcutCloud( + [{ startUs: 1_000_000, endUs: 3_000_000, text: "The quick brown fox jumps over the lazy dog" }], + "vi", + ); + assert.ok(cues[0].words?.length > 0); + assert.ok(cues[0].words[0].startUs >= 1_000_000); + assert.ok(cues[0].words.at(-1).endUs <= 3_000_000); }); });