import * as path from "node:path"; import { fileURLToPath } from "node:url"; import { createRequire } from "node:module"; import { parseArgs, type CliOptions } from "./args.js"; import { resolveOutput } from "./output.js"; import { transcribe } from "../asr/jianying.js"; import { writeSubtitle } from "../subtitle/index.js"; import type { AssStyleOptions } from "../subtitle/ass.js"; import { videoToAudio, cleanupTempFile } from "../utils/media.js"; const require = createRequire(import.meta.url); const pkg = require("../../package.json") as { version: string }; const PKG_VERSION = pkg.version; const AUDIO_EXTS = new Set(["mp3", "wav", "flac", "m4a"]); function printHelp(): void { process.stdout.write(`jianying-subtitle - JianYing (CapCut) free ASR subtitle transcription Usage: jianying-subtitle [options] Options: -o, --output Output file path (default: .json) Format is inferred from extension: .srt, .ass, .json, .txt --ass-style ASS style overrides as JSON (only for .ass output) Example: '{"fontSize":18,"primaryColour":"&H00FFFF00"}' -v, --verbose Verbose logging --version Show version -h, --help Show this help Examples: jianying-subtitle audio.mp3 # → audio.json jianying-subtitle video.mp4 -o out.srt jianying-subtitle video.mp4 -o out.ass jianying-subtitle video.mp4 -o out.ass --ass-style '{"fontSize":18}' jianying-subtitle video.mp4 -o out.json jianying-subtitle video.mp4 -o out.txt Batch usage is intentionally left to the shell: for f in audio/*.mp3; do jianying-subtitle "$f"; done `); } async function runSingle(opts: CliOptions): Promise { if (!opts.input) { throw new Error("No input file specified"); } const { outputPath, format } = resolveOutput(opts.input, opts.output); if (opts.verbose) { process.stderr.write(`Input : ${opts.input}\n`); process.stderr.write(`Output : ${outputPath}\n`); process.stderr.write(`Format : ${format}\n`); } const ext = path.extname(opts.input).slice(1).toLowerCase(); const isAudio = AUDIO_EXTS.has(ext); let audioInput = opts.input; let tempAudio: string | undefined; if (!isAudio) { if (opts.verbose) process.stderr.write("Converting video to audio...\n"); tempAudio = await videoToAudio(opts.input); audioInput = tempAudio; } try { const segments = await transcribe({ input: audioInput, onProgress: opts.verbose ? (pct, msg) => process.stderr.write(`[${pct}%] ${msg}\n`) : undefined, }); let assStyle: AssStyleOptions | undefined; if (opts.assStyle) { try { assStyle = JSON.parse(opts.assStyle) as AssStyleOptions; } catch { throw new Error( `Invalid --ass-style JSON: ${opts.assStyle}`, ); } } writeSubtitle({ segments, output: outputPath, format, assStyle }); process.stdout.write(`${outputPath}\n`); } finally { if (tempAudio) cleanupTempFile(tempAudio); } } export async function main(): Promise { const opts = parseArgs(process.argv.slice(2)); if (opts.help) { printHelp(); return; } if (opts.version) { console.log(`jianying-subtitle v${PKG_VERSION}`); return; } if (opts.input) { await runSingle(opts); } else { printHelp(); throw new Error("No input file specified"); } } const isEntrypoint = process.argv[1] !== undefined && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url); if (isEntrypoint) { main().catch((err) => { process.stderr.write( `${err instanceof Error ? err.message : String(err)}\n`, ); process.exit(1); }); }