From 1b4b36c018de19fd3e38e2dbff95708ce7c0c31c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ren=C3=A9=20Zander?= <96420681+renezander030@users.noreply.github.com> Date: Fri, 6 Nov 2026 11:05:17 +0000 Subject: [PATCH 01/11] Tidy-up: 4 behavior-preserving refactors - The CapCut cloud clients share one request routine with retries. - CapCut's text colour and fill format is written by one set of helpers. - Language-code normalization drops a fallback loop that could never return. - caption resolves, extracts and cleans up its audio in one helper. --- src/ass.ts | 13 +-- src/capcut-cloud-asr.ts | 112 ++++++---------------- src/capcut-cloud-headers.ts | 97 ++++++++++++++++++- src/capcut-cloud-translate.ts | 144 ++++++++-------------------- src/caption.ts | 52 ++++++---- src/decorators.ts | 42 +++++--- src/factory.ts | 10 +- src/preset.ts | 25 +++-- test/caption-stream-reveal.test.mjs | 52 +++++++++- test/cloud-translate.test.mjs | 47 +++++++++ 10 files changed, 335 insertions(+), 259 deletions(-) diff --git a/src/ass.ts b/src/ass.ts index fa020cb..fa941d4 100644 --- a/src/ass.ts +++ b/src/ass.ts @@ -9,6 +9,7 @@ // UTF-16 code units of the FINAL text (after tag removal, \N -> newline). // Every other override ({\an8}, \pos, \k, ...) is stripped as before. +import { fillSolid, rgb01ToHex } from "./decorators.js"; import { repairDoubledRanges } from "./text-offsets.js"; /** Segment-level defaults a Dialogue's Style line carries into the import. */ @@ -599,15 +600,6 @@ interface NormalizedBlock { alpha: number; } -function rgb01ToHex(rgb: [number, number, number]): string { - const byte = (c: number) => - Math.max(0, Math.min(255, Math.round(c * 255))) - .toString(16) - .padStart(2, "0") - .toUpperCase(); - return `#${byte(rgb[0])}${byte(rgb[1])}${byte(rgb[2])}`; -} - function normalizeBlocks(content: string): { text: string; blocks: NormalizedBlock[] } { let parsed: { text?: string; styles?: Array> }; try { @@ -625,8 +617,7 @@ function normalizeBlocks(content: string): { text: string; blocks: NormalizedBlo const blocks: NormalizedBlock[] = []; for (let i = 0; i < styles.length; i++) { const s = styles[i]; - const solid = (s.fill as { content?: { solid?: { color?: [number, number, number]; alpha?: number } } } | undefined) - ?.content?.solid; + const solid = fillSolid(s.fill); const start = Math.max(0, Math.min(repaired[i][0], text.length)); const end = Math.max(0, Math.min(repaired[i][1], text.length)); if (end <= start) continue; diff --git a/src/capcut-cloud-asr.ts b/src/capcut-cloud-asr.ts index 35545b9..592d9a8 100644 --- a/src/capcut-cloud-asr.ts +++ b/src/capcut-cloud-asr.ts @@ -6,7 +6,15 @@ */ import { createHash, createHmac, randomUUID } from "node:crypto"; import { existsSync, readFileSync } from "node:fs"; -import { capcutEditorHeaders } from "./capcut-cloud-headers.js"; +import { + CAPCUT_EDITOR_HOST, + type CapcutRequestTiming, + capcutEditorHeaders, + postEditorApi, + requireRetOk, + seconds, + sendCapcutRequest, +} from "./capcut-cloud-headers.js"; import type { CaptionCue, CaptionWord } from "./caption.js"; /** CRC32 lookup table for upload verification. */ @@ -168,57 +176,14 @@ export function pollBudgetMs(durationMs: number): number { return Math.min(30 * 60_000, 120_000 + Math.max(0, durationMs) / 2); } -interface Timing { - requestTimeoutMs: number; - retryDelayMs: number; +/** Request limits plus the recognition's polling. Signing, the upload-node lookup and the + * status polls are safe to repeat; the upload, its commit and the task creation are sent once. */ +interface Timing extends CapcutRequestTiming { pollIntervalMs: number; pollBudgetMs: number; } const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); -const seconds = (ms: number) => (ms >= 1000 ? Math.round(ms / 1000) : ms / 1000); - -/** Attempts for the calls that are safe to repeat (signing, upload-node lookup, status polls). */ -const RETRY_ATTEMPTS = 3; - -/** - * One request with a time limit. `retry` repeats a call that is safe to repeat - * when the network fails, it times out, or the service answers 429 or 5xx, - * pausing n x retryDelayMs before the n-th retry; the upload, its commit and - * the task creation are never repeated. Any other answer is returned as is. - */ -async function send( - url: string, - init: RequestInit, - step: string, - t: Timing, - retry: boolean, - timeoutMs = t.requestTimeoutMs, -): Promise { - const attempts = retry ? RETRY_ATTEMPTS : 1; - for (let attempt = 1; ; attempt++) { - let res: Response; - try { - res = await fetch(url, { ...init, signal: AbortSignal.timeout(timeoutMs) }); - } catch (e) { - if (attempt < attempts) { - await sleep(t.retryDelayMs * attempt); - continue; - } - const error = e as Error; - throw new Error( - error.name === "TimeoutError" - ? `CapCut ${step} request timed out after ${seconds(timeoutMs)} s${attempts > 1 ? ` (${attempts} tries)` : ""}.` - : `CapCut ${step} request failed: ${error.message}`, - ); - } - if (!res.ok && (res.status === 429 || res.status >= 500) && attempt < attempts) { - await sleep(t.retryDelayMs * attempt); - continue; - } - return res; - } -} export interface CloudAsrResult { cues: CaptionCue[]; @@ -228,35 +193,6 @@ export interface CloudAsrResult { duration_ms?: number; } -interface EditorApiReply { - ret: string; - errmsg: string; - data?: T; -} - -/** POST to CapCut's editor API; an HTTP failure names the step. */ -async function postEditorApi( - url: string, - headers: Record, - body: string, - step: string, - t: Timing, - retry: boolean, -): Promise> { - const res = await send(url, { method: "POST", headers, body }, step, t, retry); - if (!res.ok) { - throw new Error(`CapCut ${step} HTTP error: ${res.status} ${res.statusText}`); - } - return (await res.json()) as EditorApiReply; -} - -/** The editor API answers ret "0" (or 0) on success; any other ret is its refusal. */ -function requireRetOk(reply: EditorApiReply, step: string): void { - if (reply.ret !== "0" && reply.ret !== (0 as unknown as string)) { - throw new Error(`CapCut ${step} failed [ret=${reply.ret}]: ${reply.errmsg || "Unknown error"}`); - } -} - interface UploadSignatureData { access_key_id: string; secret_access_key: string; @@ -276,7 +212,7 @@ interface ApplyUploadResult { /** Step 1: Request upload signature from CapCut Web API. */ async function getUploadSignature(t: Timing): Promise { - const url = "https://editor-api-sg.capcutapi.com/lv/v1/upload_sign?aid=359289&device_platform=windows"; + const url = `https://${CAPCUT_EDITOR_HOST}/lv/v1/upload_sign?aid=359289&device_platform=windows`; const body = JSON.stringify({ biz: "cc_pc_text_recognize", key_version: "v5" }); const json = await postEditorApi(url, DEFAULT_CAPCUT_HEADERS, body, "upload_sign", t, true); requireRetOk(json, "upload_sign"); @@ -298,7 +234,13 @@ async function applyUpload(creds: UploadSignatureData, t: Timing): Promise { - const url = "https://editor-api-sg.capcutapi.com/lv/v1/common_task/query?aid=359289&device_platform=windows"; + const url = `https://${CAPCUT_EDITOR_HOST}/lv/v1/common_task/query?aid=359289&device_platform=windows`; const body = JSON.stringify({ tasks: [{ id: taskId, req_key: "cc_audio_subtitle_asr", task_version: "v3", token }], }); diff --git a/src/capcut-cloud-headers.ts b/src/capcut-cloud-headers.ts index 4b8b414..4fdc54a 100644 --- a/src/capcut-cloud-headers.ts +++ b/src/capcut-cloud-headers.ts @@ -1,10 +1,12 @@ /** - * 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's editor API as both experimental cloud clients call it: `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. + * (capcut-cloud-translate.ts). It holds the headers CapCut's desktop app sends, + * as captured from one install, and the request discipline both clients share: + * the time limit, the retries and how a failed call is named. 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. */ @@ -42,3 +44,88 @@ export function capcutEditorHeaders(s: CapcutCapturedSignature): Record new Promise((resolve) => setTimeout(resolve, ms)); + +/** A duration for a message: whole seconds from one second up, fractions below. */ +export const seconds = (ms: number) => (ms >= 1000 ? Math.round(ms / 1000) : ms / 1000); + +/** Attempts for the calls that are safe to repeat. */ +const RETRY_ATTEMPTS = 3; + +/** + * One request with a time limit. `retry` repeats a call that is safe to repeat + * when the network fails, it times out, or the service answers 429 or 5xx, + * pausing n x retryDelayMs before the n-th retry; a call that is not safe to + * repeat is sent once. Any other answer is returned as is. `step` names the + * call in the error. + */ +export async function sendCapcutRequest( + url: string, + init: RequestInit, + step: string, + t: CapcutRequestTiming, + retry: boolean, + timeoutMs = t.requestTimeoutMs, +): Promise { + const attempts = retry ? RETRY_ATTEMPTS : 1; + for (let attempt = 1; ; attempt++) { + let res: Response; + try { + res = await fetch(url, { ...init, signal: AbortSignal.timeout(timeoutMs) }); + } catch (e) { + if (attempt < attempts) { + await sleep(t.retryDelayMs * attempt); + continue; + } + const error = e as Error; + throw new Error( + error.name === "TimeoutError" + ? `CapCut ${step} request timed out after ${seconds(timeoutMs)} s${attempts > 1 ? ` (${attempts} tries)` : ""}.` + : `CapCut ${step} request failed: ${error.message}`, + ); + } + if (!res.ok && (res.status === 429 || res.status >= 500) && attempt < attempts) { + await sleep(t.retryDelayMs * attempt); + continue; + } + return res; + } +} + +/** The editor API's answer: ret "0" (or 0) on success, data with the result. */ +export interface EditorApiReply { + ret: string | number; + errmsg: string; + data?: T; +} + +/** POST to CapCut's editor API; an HTTP failure names the step. */ +export async function postEditorApi( + url: string, + headers: Record, + body: string, + step: string, + t: CapcutRequestTiming, + retry: boolean, +): Promise> { + const res = await sendCapcutRequest(url, { method: "POST", headers, body }, step, t, retry); + if (!res.ok) { + throw new Error(`CapCut ${step} HTTP error: ${res.status} ${res.statusText}`); + } + return (await res.json()) as EditorApiReply; +} + +/** The editor API answers ret "0" (or 0) on success; any other ret is its refusal. */ +export function requireRetOk(reply: EditorApiReply, step: string): void { + if (reply.ret !== "0" && reply.ret !== 0) { + throw new Error(`CapCut ${step} failed [ret=${reply.ret}]: ${reply.errmsg || "Unknown error"}`); + } +} diff --git a/src/capcut-cloud-translate.ts b/src/capcut-cloud-translate.ts index abc9875..9cadc1b 100644 --- a/src/capcut-cloud-translate.ts +++ b/src/capcut-cloud-translate.ts @@ -5,7 +5,7 @@ * 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 { CAPCUT_EDITOR_HOST, capcutEditorHeaders, postEditorApi, requireRetOk } from "./capcut-cloud-headers.js"; import type { CaptionCue, CaptionWord } from "./caption.js"; /** The captured headers this client replays; the shared identity is in capcut-cloud-headers.ts. */ @@ -106,19 +106,16 @@ const LANGUAGE_ALIASES: Record = { }; /** - * Normalizes user-supplied language string into CapCut's canonical language code. + * Normalizes user-supplied language string into CapCut's canonical language code, + * in any letter case. Every alias is written in lower case and every canonical + * code is also an alias of itself, so one lookup of the lower-cased input finds + * it; anything else passes through trimmed. */ export function normalizeLanguageCode(lang: string): string { const clean = lang.trim().toLowerCase(); if (LANGUAGE_ALIASES[clean]) { return LANGUAGE_ALIASES[clean]; } - // Try case-insensitive lookup - for (const [k, v] of Object.entries(LANGUAGE_ALIASES)) { - if (k.toLowerCase() === clean || v.toLowerCase() === clean) { - return v; - } - } return lang.trim(); } @@ -151,79 +148,32 @@ export interface CloudTranslateOptions { chunkSize?: number; } -const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); -const RETRY_ATTEMPTS = 3; - -/** - * Send POST request to CapCut editor API with retry and timeout. - */ -async function sendTranslateRequest( - url: string, - body: string, - timeoutMs: number, - retryDelayMs: number, -): Promise { - for (let attempt = 1; ; attempt++) { - try { - const res = await fetch(url, { - method: "POST", - headers: DEFAULT_CAPCUT_TRANSLATE_HEADERS, - body, - signal: AbortSignal.timeout(timeoutMs), - }); - - if (res.ok) return res; - - if ((res.status === 429 || res.status >= 500) && attempt < RETRY_ATTEMPTS) { - await sleep(retryDelayMs * attempt); - continue; - } - return res; - } catch (e) { - if (attempt < RETRY_ATTEMPTS) { - await sleep(retryDelayMs * attempt); - continue; - } - const err = e as Error; - throw new Error( - err.name === "TimeoutError" - ? `CapCut Cloud Translation request timed out after ${Math.round(timeoutMs / 1000)} s (${RETRY_ATTEMPTS} tries).` - : `CapCut Cloud Translation request failed: ${err.message}`, - ); - } - } +/** One translated line as the service sends it; every field may be missing. */ +interface CloudTranslatedLine { + text?: string; + start_time?: number; + end_time?: number; + translation_text?: string; + translation_words?: Array<{ + text?: string; + start_time?: number; + end_time?: number; + }>; } -interface CommonTaskSyncResponse { - ret: string | number; - errmsg: string; - data?: { - tasks?: Array<{ - id?: string; - req_key?: string; - status?: string; - payload?: - | string - | { - translation?: Record< - string, - Array<{ - text?: string; - start_time?: number; - end_time?: number; - translation_text?: string; - translation_words?: Array<{ - text?: string; - start_time?: number; - end_time?: number; - }>; - }> - >; - }; - }>; - }; +/** The data of a common_task/sync answer: the payload arrives as a JSON string or an object. */ +interface CommonTaskSyncData { + tasks?: Array<{ + id?: string; + req_key?: string; + status?: string; + payload?: string | { translation?: Record }; + }>; } +/** The step name a failed translation request carries ("CapCut Cloud Translation HTTP error: ..."). */ +const STEP = "Cloud Translation"; + /** * Translate a batch of utterances via ByteDance CapCut cloud translation. */ @@ -237,8 +187,7 @@ export async function translateUtterancesCapcutCloud( const targetCode = normalizeLanguageCode(targetLang); const sourceCode = opts?.from && opts.from !== "auto" ? normalizeLanguageCode(opts.from) : "UNSPECIFIED"; const chunkSize = opts?.chunkSize ?? 50; - const timeoutMs = opts?.requestTimeoutMs ?? 60_000; - const retryDelayMs = opts?.retryDelayMs ?? 1500; + const timing = { requestTimeoutMs: opts?.requestTimeoutMs ?? 60_000, retryDelayMs: opts?.retryDelayMs ?? 1500 }; const url = `https://${CAPCUT_EDITOR_HOST}/lv/v1/common_task/sync`; const allResults: CloudTranslateItem[] = []; @@ -264,22 +213,22 @@ export async function translateUtterancesCapcutCloud( ], }; - const res = await sendTranslateRequest(url, JSON.stringify(bodyObj), timeoutMs, retryDelayMs); - if (!res.ok) { - throw new Error(`CapCut Cloud Translation HTTP error: ${res.status} ${res.statusText}`); - } - - const reply = (await res.json()) as CommonTaskSyncResponse; + // The translation is safe to repeat: a lost answer is asked for again. + const reply = await postEditorApi( + url, + DEFAULT_CAPCUT_TRANSLATE_HEADERS, + JSON.stringify(bodyObj), + STEP, + timing, + true, + ); if (String(reply.ret) === "1014") { throw new Error( "CapCut Cloud Translation returned error 1014 (System Busy / Security Token). Try again later or use Anthropic translation.", ); } - - if (String(reply.ret) !== "0") { - throw new Error(`CapCut Cloud Translation failed [ret=${reply.ret}]: ${reply.errmsg || "Unknown error"}`); - } + requireRetOk(reply, STEP); const task = reply.data?.tasks?.[0]; if (!task) { @@ -295,7 +244,8 @@ export async function translateUtterancesCapcutCloud( } } - const translationMap = (payload as { translation?: Record })?.translation; + const translationMap = (payload as { translation?: Record } | undefined) + ?.translation; const targetItems = translationMap?.[targetCode]; if (!Array.isArray(targetItems)) { @@ -314,19 +264,7 @@ export async function translateUtterancesCapcutCloud( for (let j = 0; j < chunk.length; j++) { const original = chunk[j]; - const item = targetItems[j] as - | { - text?: string; - start_time?: number; - end_time?: number; - translation_text?: string; - translation_words?: Array<{ - text?: string; - start_time?: number; - end_time?: number; - }>; - } - | undefined; + const item: CloudTranslatedLine | undefined = targetItems[j]; const translated = item?.translation_text?.trim() ? item.translation_text : undefined; const transWords: CloudTranslateWord[] | undefined = Array.isArray(item?.translation_words) diff --git a/src/caption.ts b/src/caption.ts index 3f6e63f..ebeed2a 100644 --- a/src/caption.ts +++ b/src/caption.ts @@ -208,13 +208,7 @@ export async function captionDraft(draft: Draft, opts: CaptionOptions): Promise< } transcription = { cues: [], words: external.words, engine: "words" }; } else if (opts.cloud) { - sourceAudio = resolveAudio(draft, opts); - const selectedAudio = - opts.audioStream === undefined - ? undefined - : extractAudioStream(sourceAudio, opts.audioStream, opts.ffmpegCmd ?? "ffmpeg"); - try { - const cloudAudio = selectedAudio?.path ?? sourceAudio; + const heard = await withCaptionAudio(draft, opts, async (cloudAudio): Promise => { const durationMs = cloudAudioDurationMs(draft, opts, cloudAudio); if (isDryRun()) throw new Error("--cloud never uploads under --dry-run; plan the upload with planCloudUpload."); process.stderr.write( @@ -223,25 +217,18 @@ export async function captionDraft(draft: Draft, opts: CaptionOptions): Promise< const languageWarning = cloudLanguageWarning(opts); if (languageWarning) process.stderr.write(languageWarning); const cloudRes = await transcribeAudioCapcutCloud(cloudAudio, { durationMs }); - transcription = { + return { cues: cloudRes.cues, words: cloudRes.words, engine: "capcut-cloud", }; - } finally { - selectedAudio?.cleanup(); - } + }); + sourceAudio = heard.source; + transcription = heard.result; } else { - sourceAudio = resolveAudio(draft, opts); - const selectedAudio = - opts.audioStream === undefined - ? undefined - : extractAudioStream(sourceAudio, opts.audioStream, opts.ffmpegCmd ?? "ffmpeg"); - try { - transcription = runWhisper(selectedAudio?.path ?? sourceAudio, opts); - } finally { - selectedAudio?.cleanup(); - } + const heard = await withCaptionAudio(draft, opts, (audio) => runWhisper(audio, opts)); + sourceAudio = heard.source; + transcription = heard.result; } const recognizedWords = transcription.words.length > 0 ? transcription.words : wordsFromCues(transcription.cues); // The transcript's script decides how words join into a cue (no space inside @@ -457,6 +444,29 @@ export function buildAudioStreamInvocation(audio: string, stream: number, output return ["-y", "-i", audio, "-map", `0:a:${stream}`, "-vn", "-ac", "1", "-ar", "16000", "-c:a", "pcm_s16le", output]; } +/** + * The audio a transcription reads, for as long as `use` runs: --audio or the + * --from-segment's file, or with --audio-stream that stream extracted to a + * temporary WAV, which is removed afterwards whether `use` succeeds or throws. + * `source` is the file the result reports, never the temporary copy. + */ +async function withCaptionAudio( + draft: Draft, + opts: CaptionOptions, + use: (audioPath: string) => T | Promise, +): Promise<{ source: string; result: T }> { + const source = resolveAudio(draft, opts); + const selected = + opts.audioStream === undefined + ? undefined + : extractAudioStream(source, opts.audioStream, opts.ffmpegCmd ?? "ffmpeg"); + try { + return { source, result: await use(selected?.path ?? source) }; + } finally { + selected?.cleanup(); + } +} + function extractAudioStream(audio: string, stream: number, command: string): { path: string; cleanup: () => void } { const dir = mkdtempSync(join(tmpdir(), "capcut-caption-audio-")); const output = join(dir, "selected.wav"); diff --git a/src/decorators.ts b/src/decorators.ts index 0594c60..e71080e 100644 --- a/src/decorators.ts +++ b/src/decorators.ts @@ -689,10 +689,7 @@ export function setTextStyle( for (const block of content.styles) { if (opts.fontSize !== undefined) block.size = opts.fontSize; if (opts.color !== undefined) { - block.fill = { - alpha: 1, - content: { render_type: "solid", solid: { alpha: 1, color: hexToRgb01(opts.color) } }, - }; + block.fill = solidFill(hexToRgb01(opts.color)); } } text.content = JSON.stringify(content); @@ -1170,11 +1167,36 @@ export interface TextRangeInput { underline?: boolean; } +// --- Text colour as CapCut stores it --- +// A style block's colour is three channels in 0..1 inside a solid fill; the CLI +// speaks "#RRGGBB". These three are the only places that know either encoding. + export function hexToRgb01(hex: string): [number, number, number] { const h = hex.replace("#", ""); return [parseInt(h.slice(0, 2), 16) / 255, parseInt(h.slice(2, 4), 16) / 255, parseInt(h.slice(4, 6), 16) / 255]; } +/** Stored 0..1 channels back to "#RRGGBB" (upper case), each channel clamped to a byte. */ +export function rgb01ToHex(rgb: [number, number, number]): string { + const byte = (c: number) => + Math.max(0, Math.min(255, Math.round(c * 255))) + .toString(16) + .padStart(2, "0") + .toUpperCase(); + return `#${byte(rgb[0])}${byte(rgb[1])}${byte(rgb[2])}`; +} + +/** The fill a style block stores for one solid colour (0..1 channels) at `alpha`. */ +export function solidFill(color: [number, number, number], alpha = 1) { + return { alpha: 1, content: { render_type: "solid", solid: { alpha, color } } }; +} + +/** The solid colour and alpha of a stored fill, when it has one. */ +export function fillSolid(fill: unknown): { color?: [number, number, number]; alpha?: number } | undefined { + return (fill as { content?: { solid?: { color?: [number, number, number]; alpha?: number } } } | undefined)?.content + ?.solid; +} + export function setTextRanges( draft: Draft, segmentId: string, @@ -1192,9 +1214,7 @@ export function setTextRanges( // Baseline style inherited for fields the user didn't override. const base = content.styles?.[0] ?? {}; - const baseFill = ( - base.fill as { content?: { solid?: { color?: [number, number, number]; alpha?: number } } } | undefined - )?.content?.solid; + const baseFill = fillSolid(base.fill); const defaultColor: [number, number, number] = baseFill?.color ?? [1, 1, 1]; const defaultAlpha = baseFill?.alpha ?? 1; const defaultSize = (base.size as number | undefined) ?? 15; @@ -1232,13 +1252,7 @@ export function setTextRanges( bold: r?.bold ?? defaultBold, italic: r?.italic ?? defaultItalic, underline: r?.underline ?? defaultUnderline, - fill: { - alpha: 1, - content: { - render_type: "solid", - solid: { alpha, color }, - }, - }, + fill: solidFill(color, alpha), }; }; diff --git a/src/factory.ts b/src/factory.ts index 4178a24..c7c13be 100644 --- a/src/factory.ts +++ b/src/factory.ts @@ -18,7 +18,7 @@ import { } from "node:fs"; import { basename, dirname, resolve } from "node:path"; import { readJsonObjectFile, readTextIfPresent, stripBom } from "./bom.js"; -import { hexToRgb01, uuidHex } from "./decorators.js"; +import { hexToRgb01, solidFill, uuidHex } from "./decorators.js"; import { detectEncryption } from "./decrypt.js"; import type { Draft, Segment, Timerange, Track } from "./draft.js"; import { findMaterialGlobal, findSegment, loadedDraftProjectDir, makeTrack, writeAtomic } from "./draft.js"; @@ -1795,13 +1795,7 @@ function buildTextContent(text: string, fontSize: number, color: [number, number bold: false, italic: false, underline: false, - fill: { - alpha: 1, - content: { - render_type: "solid", - solid: { alpha: 1, color }, - }, - }, + fill: solidFill(color), }, ], text, diff --git a/src/preset.ts b/src/preset.ts index 38aca5c..8c99600 100644 --- a/src/preset.ts +++ b/src/preset.ts @@ -1,6 +1,14 @@ import { readFileSync } from "node:fs"; import { stripBom } from "./bom.js"; -import { hexToRgb01, requireTextMaterial, setBubble, setTextRanges, type TextRangeInput } from "./decorators.js"; +import { + hexToRgb01, + requireTextMaterial, + rgb01ToHex, + setBubble, + setTextRanges, + solidFill, + type TextRangeInput, +} from "./decorators.js"; import type { Draft, MaterialText } from "./draft.js"; import { STYLE_FIELDS } from "./factory.js"; import { fromStoredOffset, repairDoubledRanges, storedTextLength } from "./text-offsets.js"; @@ -25,14 +33,6 @@ export interface TextStylePreset { text_ranges?: TextRangeInput[]; } -function rgb01ToHex(rgb: [number, number, number]): string { - const channel = (v: number) => - Math.round(Math.min(1, Math.max(0, v)) * 255) - .toString(16) - .padStart(2, "0"); - return `#${channel(rgb[0])}${channel(rgb[1])}${channel(rgb[2])}`.toUpperCase(); -} - interface ContentStyleBlock { range?: [number, number]; size?: number; @@ -308,10 +308,7 @@ export function applyTextPreset( if (typeof preset.style.italic === "boolean") base.italic = preset.style.italic; if (typeof preset.style.underline === "boolean") base.underline = preset.style.underline; if (typeof preset.style.text_color === "string") { - base.fill = { - alpha: 1, - content: { render_type: "solid", solid: { alpha: 1, color: hexToRgb01(preset.style.text_color) } }, - }; + base.fill = solidFill(hexToRgb01(preset.style.text_color)); } if (typeof preset.style.font_path === "string") { base.font = { @@ -370,7 +367,7 @@ export function captionStyleFromPreset(preset: TextStylePreset): Record { }); }); +describe("caption audio stream clean-up", { skip: isWindows }, () => { + // The fake ffmpeg writes the extracted stream and records where, so the test + // can see the temporary copy is gone once the command has finished. + function fakeFfmpeg(dir) { + const record = join(dir, "extracted-to.txt"); + const ffmpeg = join(dir, "fake-ffmpeg"); + writeFileSync( + ffmpeg, + `#!/bin/sh\nfor last do :; done\nprintf selected > "$last"\nprintf %s "$last" > "${record}"\n`, + ); + chmodSync(ffmpeg, 0o755); + return { ffmpeg, extracted: () => readFileSync(record, "utf-8") }; + } + + for (const outcome of ["succeeds", "fails"]) { + it(`removes the extracted stream when the transcription ${outcome}`, (t) => { + const fix = tmpDraft(); + const s = scratch(); + t.after(() => fix.cleanup()); + t.after(s.cleanup); + const media = join(s.dir, "multi-audio.mov"); + const transcript = join(s.dir, "heard.srt"); + writeFileSync(media, "container"); + writeFileSync(transcript, "1\n00:00:00,000 --> 00:00:01,000\nselected voice\n"); + const failingWhisper = join(s.dir, "failing-whisper"); + writeFileSync(failingWhisper, "#!/bin/sh\necho no model >&2\nexit 3\n"); + chmodSync(failingWhisper, 0o755); + const { ffmpeg, extracted } = fakeFfmpeg(s.dir); + const r = spawnCli( + [ + "caption", + fix.path, + "--audio", + media, + "--audio-stream", + "0", + "--ffmpeg-cmd", + ffmpeg, + "--whisper-cmd", + outcome === "succeeds" ? FAKE_WHISPER : failingWhisper, + ], + { env: { FAKE_WHISPER_SOURCE: transcript } }, + ); + assert.equal(r.status, outcome === "succeeds" ? 0 : 1, `stderr: ${r.stderr}`); + assert.match(extracted(), /selected\.wav$/); + assert.equal(existsSync(dirname(extracted())), false); + }); + } +}); + describe("caption --word-reveal", { skip: isWindows }, () => { it("creates progressive prefix captions on word timestamps", (t) => { const fix = tmpDraft(); diff --git a/test/cloud-translate.test.mjs b/test/cloud-translate.test.mjs index 2a9bcc7..436df1a 100644 --- a/test/cloud-translate.test.mjs +++ b/test/cloud-translate.test.mjs @@ -81,6 +81,16 @@ describe("capcut-cloud-translate — language normalization", () => { assert.equal(normalizeLanguageCode("Arabic"), "ar-SA"); assert.equal(normalizeLanguageCode("unknown-lang"), "unknown-lang"); }); + + it("finds every canonical code in any letter case and passes unknown codes through trimmed", () => { + const canonical = ["en", "vi", "es", "fr", "de", "zh-CN", "zh-TW", "ja", "ko", "pt", "ru", "id", "th", "it"]; + for (const code of [...canonical, "ar-SA", "tr", "nl", "pl", "ms", "hi"]) { + assert.equal(normalizeLanguageCode(code), code); + assert.equal(normalizeLanguageCode(` ${code.toUpperCase()} `), code); + assert.equal(normalizeLanguageCode(code.toLowerCase()), code); + } + assert.equal(normalizeLanguageCode(" Klingon "), "Klingon"); + }); }); describe("capcut-cloud-translate — captured headers", () => { @@ -224,6 +234,43 @@ describe("capcut-cloud-translate — exchange (offline)", () => { assert.equal(res[0].translation_text, "vi:a"); }); + it("gives up after three tries on a 5xx answer and names the HTTP error", async () => { + const calls = service(() => new Response("down", { status: 503, statusText: "Busy" })); + await assert.rejects(translateUtterancesCapcutCloud([{ start_time: 0, end_time: 1, text: "a" }], "vi", FAST), { + message: "CapCut Cloud Translation HTTP error: 503 Busy", + }); + assert.equal(calls.length, 3); + }); + + it("sends a refused request once and names the HTTP error", async () => { + const calls = service(() => new Response("no", { status: 400, statusText: "Bad Request" })); + await assert.rejects(translateUtterancesCapcutCloud([{ start_time: 0, end_time: 1, text: "a" }], "vi", FAST), { + message: "CapCut Cloud Translation HTTP error: 400 Bad Request", + }); + assert.equal(calls.length, 1); + }); + + it("retries a network failure, then names it", async () => { + let n = 0; + globalThis.fetch = async () => { + n++; + throw new TypeError("fetch failed"); + }; + await assert.rejects(translateUtterancesCapcutCloud([{ start_time: 0, end_time: 1, text: "a" }], "vi", FAST), { + message: "CapCut Cloud Translation request failed: fetch failed", + }); + assert.equal(n, 3); + }); + + it("names a request that timed out on every try", async () => { + globalThis.fetch = async () => { + throw new DOMException("The operation was aborted due to timeout", "TimeoutError"); + }; + await assert.rejects(translateUtterancesCapcutCloud([{ start_time: 0, end_time: 1, text: "a" }], "vi", FAST), { + message: "CapCut Cloud Translation request timed out after 60 s (3 tries).", + }); + }); + it("turns translated words into µs cue words and leaves an untranslated cue exactly as it was", async () => { service((sent) => reply("vi", [ From ec1b59034252bdad41f5331ff83e2e409e9b9ebd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ren=C3=A9=20Zander?= <96420681+renezander030@users.noreply.github.com> Date: Fri, 6 Nov 2026 11:05:17 +0000 Subject: [PATCH 02/11] feat(loudness): gate a delivery with --tolerance and --max-true-peak loudness always answered ok:true and exited 0, so a script could not stop an export whose mix misses its delivery target. --tolerance (needs --target) fails the run when the estimated loudness sits further than that from the target; --max-true-peak fails it when the loudest clip's true peak, at its volume, is above the ceiling. With --apply the mix after this run's gain is judged (the --max-gain-db cap included), without it the mix as it stands. A miss exits 1 with ok:false and one reason line on stderr, and the result carries a gate object with the target, tolerance, estimate and each verdict. With --apply the gain is still written, as lint --fix writes its repairs before reporting what remains. Without either flag the result and exit code are unchanged. The verdict is computed by judgeLoudness beside measureLoudness, which checks the new options before any clip is measured. --- CHANGELOG.md | 1 + docs/command-reference.json | 30 ++- docs/command-reference.md | 2 +- docs/command-reference.zh-CN.md | 2 +- .../capcut-edit/references/api-reference.md | 6 + src/command-specs.ts | 19 +- src/index.ts | 41 +++- src/loudness.ts | 117 ++++++++- test/loudness-gate.test.mjs | 231 ++++++++++++++++++ 9 files changed, 428 insertions(+), 21 deletions(-) create mode 100644 test/loudness-gate.test.mjs diff --git a/CHANGELOG.md b/CHANGELOG.md index d1d7315..1749e59 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,7 @@ All notable changes to capcut-cli are documented here. The format follows [Keep ### 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). +- `loudness` can gate a delivery, so a script stops before exporting a mix that misses its target. `--tolerance ` (with `--target`) fails the run when the estimated loudness sits further than that from the target, and `--max-true-peak ` fails it when the loudest clip's true peak, at its volume, is above the ceiling. With `--apply` the mix after this run's gain is judged, `--max-gain-db` cap included; without it, the mix as it stands. A miss exits 1 with `ok: false` and the reason on one stderr line, and the result's `gate` carries the target, tolerance, estimate and each verdict. With `--apply` the gain is written either way (journaled, `restore` undoes it). Without either flag the result and exit code are unchanged. ### Changed diff --git a/docs/command-reference.json b/docs/command-reference.json index ee6f70d..59754ff 100644 --- a/docs/command-reference.json +++ b/docs/command-reference.json @@ -6711,7 +6711,7 @@ { "name": "loudness", "summary": "Measure each audible clip's loudness (EBU R128) and, with --target, set the gain that brings the export there.", - "usage": "capcut loudness [--target ] [--per-clip] [--max-gain-db ] [--apply]", + "usage": "capcut loudness [--target ] [--per-clip] [--max-gain-db ] [--apply] [--tolerance ] [--max-true-peak ]", "positionals": [ { "name": "project", @@ -6727,6 +6727,16 @@ "name": "db", "type": "string", "required": false + }, + { + "name": "lu", + "type": "string", + "required": false + }, + { + "name": "dbtp", + "type": "string", + "required": false } ], "options": [ @@ -6767,6 +6777,24 @@ "required": false, "description": "Write the gain as segment volume (default: report only)." }, + { + "name": "tolerance", + "flags": [ + "--tolerance" + ], + "type": "number", + "required": false, + "description": "Delivery gate (needs --target): exit 1 when the judged loudness sits more than this many LU from the target. With --apply the mix after this run's gain is judged, without it the mix as it stands." + }, + { + "name": "max_true_peak", + "flags": [ + "--max-true-peak" + ], + "type": "number", + "required": false, + "description": "Delivery gate: exit 1 when the loudest clip's true peak, at the judged volume, is above this level in dBTP." + }, { "name": "ffmpeg_cmd", "flags": [ diff --git a/docs/command-reference.md b/docs/command-reference.md index aa6cbcb..0867bbd 100644 --- a/docs/command-reference.md +++ b/docs/command-reference.md @@ -88,7 +88,7 @@ | `quickstart` | `capcut quickstart [--video ] [--audio ] [--srt ] [--drafts ] [--template auto\|bundled\|] [--ratio \| --width --height ]` | yes | One-command first draft: create + add one input + lint + print the open-in-CapCut step. | | `compile` | `capcut compile [--out \| --into ] [--template auto\|bundled\|] [--data ] [--check \| --plan]` | yes | Build a draft from a declarative JSON spec (the inverse of describe). | | `render` | `capcut render [--out ] [options]` | no | Render a low-res ffmpeg proxy preview (trim+speed+audio, --burn-captions); not CapCut's final render. | -| `loudness` | `capcut loudness [--target ] [--per-clip] [--max-gain-db ] [--apply]` | no | Measure each audible clip's loudness (EBU R128) and, with --target, set the gain that brings the export there. | +| `loudness` | `capcut loudness [--target ] [--per-clip] [--max-gain-db ] [--apply] [--tolerance ] [--max-true-peak ]` | no | Measure each audible clip's loudness (EBU R128) and, with --target, set the gain that brings the export there. | | `detect-scenes` | `capcut detect-scenes