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
4 changes: 3 additions & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,14 @@ All notable changes to capcut-cli are documented here. The format follows [Keep

## [Unreleased]

### Added

- `translate --cloud` and `caption --translate <lang>` (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 <project> <id> --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.
Expand Down
4 changes: 2 additions & 2 deletions docs/command-reference.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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",
Expand Down
26 changes: 7 additions & 19 deletions src/capcut-cloud-asr.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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. */
Expand Down Expand Up @@ -118,26 +119,13 @@ export function signAwsV4(
return { headers, url: urlObj.toString() };
}

export const DEFAULT_CAPCUT_HEADERS: Record<string, string> = {
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<string, string> = 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;
Expand Down
44 changes: 44 additions & 0 deletions src/capcut-cloud-headers.ts
Original file line number Diff line number Diff line change
@@ -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<string, string> {
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,
};
}
94 changes: 36 additions & 58 deletions src/capcut-cloud-translate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, string> = {
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<string, string> = 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.
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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[] = [];

Expand Down Expand Up @@ -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
Expand All @@ -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()))
Expand All @@ -347,44 +343,27 @@ 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 }),
});
}
}

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<string[]> {
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<CaptionCue[]> {
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),
Expand All @@ -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 };
}
25 changes: 19 additions & 6 deletions src/caption.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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";
Expand Down Expand Up @@ -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,
Expand Down
4 changes: 2 additions & 2 deletions src/command-specs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -616,7 +616,7 @@ const optionsByCommand: Record<string, OptionSpec[]> = {
"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(
Expand Down Expand Up @@ -657,7 +657,7 @@ const optionsByCommand: Record<string, OptionSpec[]> = {
"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."),
Expand Down
Loading
Loading