@crafter/trx 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,24 @@
1
+ import { readFileSync } from "node:fs";
2
+ import { dirname, join } from "node:path";
3
+ import { Command } from "commander";
4
+ import { outputJSON } from "../utils/output.ts";
5
+
6
+ const SCHEMAS_DIR = join(dirname(dirname(import.meta.dir)), "schemas");
7
+
8
+ const AVAILABLE_SCHEMAS = ["transcribe", "init"] as const;
9
+
10
+ export function createSchemaCommand(): Command {
11
+ return new Command("schema")
12
+ .description("Introspect command schemas (agent self-service)")
13
+ .argument("<resource>", `Resource to describe: ${AVAILABLE_SCHEMAS.join(", ")}`)
14
+ .action((resource: string) => {
15
+ const schemaPath = join(SCHEMAS_DIR, `${resource}.json`);
16
+ try {
17
+ const schema = JSON.parse(readFileSync(schemaPath, "utf-8"));
18
+ outputJSON(schema);
19
+ } catch {
20
+ console.error(`Unknown schema: "${resource}". Available: ${AVAILABLE_SCHEMAS.join(", ")}`);
21
+ process.exit(1);
22
+ }
23
+ });
24
+ }
@@ -0,0 +1,131 @@
1
+ import { resolve } from "node:path";
2
+ import * as p from "@clack/prompts";
3
+ import { Command } from "commander";
4
+ import { type PipelineResult, runPipeline } from "../core/pipeline.ts";
5
+ import { readConfig } from "../utils/config.ts";
6
+ import { type OutputFormat, output, outputError } from "../utils/output.ts";
7
+ import { validateInput, validateLanguage, validateModel } from "../validation/input.ts";
8
+
9
+ function filterFields(result: PipelineResult, fields?: string): Record<string, unknown> {
10
+ if (!fields) return result;
11
+
12
+ const requested = fields.split(",").map((f) => f.trim());
13
+ const filtered: Record<string, unknown> = { success: true };
14
+
15
+ for (const field of requested) {
16
+ if (field === "text") filtered.text = result.text;
17
+ if (field === "srt") filtered.files = { srt: result.files.srt };
18
+ if (field === "metadata") filtered.metadata = result.metadata;
19
+ if (field === "files") filtered.files = result.files;
20
+ }
21
+
22
+ return filtered;
23
+ }
24
+
25
+ export function createTranscribeCommand(): Command {
26
+ return new Command("transcribe")
27
+ .description("Transcribe audio/video from URL or local file")
28
+ .argument("<input>", "URL or file path to transcribe")
29
+ .option("-l, --language <lang>", "language code (default: from config)")
30
+ .option("-m, --model <size>", "override model size")
31
+ .option("--fields <fields>", "limit output fields: text,srt,metadata,files")
32
+ .option("--dry-run", "validate input without transcribing")
33
+ .option("--json <payload>", "raw JSON input for agents")
34
+ .option("--output-dir <dir>", "output directory", ".")
35
+ .option("--no-download", "skip yt-dlp (input must be local)")
36
+ .option("--no-clean", "skip ffmpeg audio cleaning")
37
+ .action(async (inputArg, opts, cmd) => {
38
+ const format: OutputFormat = cmd.optsWithGlobals().output;
39
+ const isTTY = process.stdout.isTTY && format !== "json";
40
+
41
+ try {
42
+ const config = readConfig();
43
+ if (!config) {
44
+ outputError('No configuration found. Run "trx init" first.', format);
45
+ return;
46
+ }
47
+
48
+ let parsedInput: { type: "url" | "file"; value: string };
49
+ let language = opts.language;
50
+ let modelOverride = opts.model;
51
+
52
+ if (opts.json) {
53
+ const payload = JSON.parse(opts.json);
54
+ parsedInput = validateInput(payload.input || inputArg);
55
+ language = payload.language || language;
56
+ modelOverride = payload.model || modelOverride;
57
+ } else {
58
+ parsedInput = validateInput(inputArg);
59
+ }
60
+
61
+ if (language) validateLanguage(language);
62
+ if (modelOverride) validateModel(modelOverride);
63
+
64
+ const outputDir = resolve(opts.outputDir);
65
+
66
+ if (opts.dryRun) {
67
+ output(format, {
68
+ json: {
69
+ dryRun: true,
70
+ input: parsedInput.value,
71
+ inputType: parsedInput.type,
72
+ language: language || config.language,
73
+ model: modelOverride || config.modelSize,
74
+ outputDir,
75
+ steps: [
76
+ ...(parsedInput.type === "url" && opts.download !== false ? ["download via yt-dlp"] : []),
77
+ ...(opts.clean !== false ? ["clean audio via ffmpeg"] : []),
78
+ "transcribe via whisper-cli",
79
+ "generate .srt and .txt",
80
+ ],
81
+ },
82
+ });
83
+ return;
84
+ }
85
+
86
+ let spinner: ReturnType<typeof p.spinner> | null = null;
87
+ if (isTTY) {
88
+ spinner = p.spinner();
89
+ }
90
+
91
+ const result = await runPipeline({
92
+ input: parsedInput.value,
93
+ inputType: parsedInput.type,
94
+ config: modelOverride
95
+ ? {
96
+ ...config,
97
+ modelSize: modelOverride,
98
+ modelPath: config.modelPath.replace(/ggml-\w+\.bin/, `ggml-${modelOverride}.bin`),
99
+ }
100
+ : config,
101
+ outputDir,
102
+ language,
103
+ noDownload: opts.download === false,
104
+ noClean: opts.clean === false,
105
+ onStep: (step) => {
106
+ if (spinner) spinner.start(step);
107
+ },
108
+ });
109
+
110
+ if (spinner) spinner.stop("Done");
111
+
112
+ const filtered = opts.fields ? filterFields(result, opts.fields) : result;
113
+ output(format, {
114
+ json: filtered,
115
+ table: {
116
+ headers: ["Property", "Value"],
117
+ rows: [
118
+ ["Input", result.input],
119
+ ["Language", result.metadata.language],
120
+ ["Model", result.metadata.model],
121
+ ["SRT", result.files.srt],
122
+ ["TXT", result.files.txt],
123
+ ["WAV", result.files.wav],
124
+ ],
125
+ },
126
+ });
127
+ } catch (e) {
128
+ outputError((e as Error).message, format);
129
+ }
130
+ });
131
+ }
@@ -0,0 +1,28 @@
1
+ import { spawnOrThrow } from "../utils/spawn.ts";
2
+
3
+ export interface AudioResult {
4
+ wavPath: string;
5
+ }
6
+
7
+ export async function cleanAudio(inputPath: string, outputPath: string): Promise<AudioResult> {
8
+ await spawnOrThrow(
9
+ [
10
+ "ffmpeg",
11
+ "-i",
12
+ inputPath,
13
+ "-af",
14
+ "silenceremove=stop_periods=-1:stop_duration=1:stop_threshold=-40dB,dynaudnorm,afftdn=nf=-25",
15
+ "-ar",
16
+ "16000",
17
+ "-ac",
18
+ "1",
19
+ "-c:a",
20
+ "pcm_s16le",
21
+ outputPath,
22
+ "-y",
23
+ ],
24
+ "ffmpeg audio cleaning",
25
+ );
26
+
27
+ return { wavPath: outputPath };
28
+ }
@@ -0,0 +1,37 @@
1
+ import { existsSync } from "node:fs";
2
+ import { spawn } from "../utils/spawn.ts";
3
+
4
+ export interface DownloadResult {
5
+ filePath: string;
6
+ title: string;
7
+ }
8
+
9
+ export async function downloadMedia(url: string, outputDir: string): Promise<DownloadResult> {
10
+ const basename = `trx-${Date.now()}`;
11
+ const outputTemplate = `${outputDir}/${basename}.%(ext)s`;
12
+
13
+ const result = await spawn(["yt-dlp", "--no-playlist", "-o", outputTemplate, "--print", "after_move:filepath", url]);
14
+
15
+ if (result.exitCode !== 0) {
16
+ throw new Error(`yt-dlp download failed: ${result.stderr}`);
17
+ }
18
+
19
+ const downloadedPath = result.stdout.split("\n").pop()?.trim();
20
+ if (!downloadedPath || !existsSync(downloadedPath)) {
21
+ const possibleFiles = await findDownloadedFile(outputDir, basename);
22
+ if (!possibleFiles) {
23
+ throw new Error(`Download completed but file not found. yt-dlp output: ${result.stdout}`);
24
+ }
25
+ return { filePath: possibleFiles, title: basename };
26
+ }
27
+
28
+ return { filePath: downloadedPath, title: basename };
29
+ }
30
+
31
+ async function findDownloadedFile(dir: string, basename: string): Promise<string | null> {
32
+ const glob = new Bun.Glob(`${basename}.*`);
33
+ for await (const file of glob.scan({ cwd: dir })) {
34
+ return `${dir}/${file}`;
35
+ }
36
+ return null;
37
+ }
@@ -0,0 +1,71 @@
1
+ import { basename, resolve } from "node:path";
2
+ import type { TrxConfig } from "../utils/config.ts";
3
+ import { cleanAudio } from "./audio.ts";
4
+ import { downloadMedia } from "./download.ts";
5
+ import { transcribe } from "./whisper.ts";
6
+
7
+ export interface PipelineOptions {
8
+ input: string;
9
+ inputType: "url" | "file";
10
+ config: TrxConfig;
11
+ outputDir: string;
12
+ language?: string;
13
+ noDownload?: boolean;
14
+ noClean?: boolean;
15
+ onStep?: (step: string) => void;
16
+ }
17
+
18
+ export interface PipelineResult {
19
+ success: true;
20
+ input: string;
21
+ files: {
22
+ wav: string;
23
+ srt: string;
24
+ txt: string;
25
+ };
26
+ metadata: {
27
+ language: string;
28
+ model: string;
29
+ };
30
+ text: string;
31
+ }
32
+
33
+ export async function runPipeline(opts: PipelineOptions): Promise<PipelineResult> {
34
+ const { config, outputDir } = opts;
35
+ let inputFile: string;
36
+
37
+ if (opts.inputType === "url" && !opts.noDownload) {
38
+ opts.onStep?.("Downloading media...");
39
+ const downloaded = await downloadMedia(opts.input, outputDir);
40
+ inputFile = downloaded.filePath;
41
+ } else {
42
+ inputFile = resolve(opts.input);
43
+ }
44
+
45
+ const name = basename(inputFile).replace(/\.[^.]+$/, "");
46
+ const wavPath = resolve(outputDir, `${name}.wav`);
47
+
48
+ if (!opts.noClean) {
49
+ opts.onStep?.("Cleaning audio...");
50
+ await cleanAudio(inputFile, wavPath);
51
+ }
52
+
53
+ const whisperInput = opts.noClean ? inputFile : wavPath;
54
+ opts.onStep?.("Transcribing with Whisper...");
55
+ const result = await transcribe(whisperInput, config, opts.language);
56
+
57
+ return {
58
+ success: true,
59
+ input: opts.input,
60
+ files: {
61
+ wav: wavPath,
62
+ srt: result.srtPath,
63
+ txt: result.txtPath,
64
+ },
65
+ metadata: {
66
+ language: opts.language || config.language,
67
+ model: config.modelSize,
68
+ },
69
+ text: result.text,
70
+ };
71
+ }
@@ -0,0 +1,67 @@
1
+ import { existsSync, readFileSync } from "node:fs";
2
+ import type { TrxConfig } from "../utils/config.ts";
3
+ import { spawnOrThrow } from "../utils/spawn.ts";
4
+
5
+ export interface WhisperResult {
6
+ srtPath: string;
7
+ txtPath: string;
8
+ text: string;
9
+ }
10
+
11
+ export async function transcribe(
12
+ wavPath: string,
13
+ config: TrxConfig,
14
+ languageOverride?: string,
15
+ ): Promise<WhisperResult> {
16
+ if (!existsSync(config.modelPath)) {
17
+ throw new Error(`Whisper model not found: ${config.modelPath}\nRun "trx init" to download a model.`);
18
+ }
19
+
20
+ const language = languageOverride || config.language;
21
+ const args = [
22
+ "whisper-cli",
23
+ "-m",
24
+ config.modelPath,
25
+ "-f",
26
+ wavPath,
27
+ "-t",
28
+ String(config.threads),
29
+ "--max-len",
30
+ "0",
31
+ "--output-srt",
32
+ ];
33
+
34
+ if (language !== "auto") {
35
+ args.push("--language", language);
36
+ }
37
+
38
+ const flags = config.whisperFlags;
39
+ if (flags.suppressNst) args.push("--suppress-nst");
40
+ if (flags.noFallback) args.push("--no-fallback");
41
+ args.push("--max-context", String(flags.maxContext));
42
+ args.push("--entropy-thold", String(flags.entropyThold));
43
+ args.push("--logprob-thold", String(flags.logprobThold));
44
+
45
+ await spawnOrThrow(args, "whisper-cli transcription");
46
+
47
+ const srtPath = `${wavPath}.srt`;
48
+ if (!existsSync(srtPath)) {
49
+ throw new Error(`Whisper completed but SRT file not found: ${srtPath}`);
50
+ }
51
+
52
+ const srtContent = readFileSync(srtPath, "utf-8");
53
+ const text = srtToPlainText(srtContent);
54
+
55
+ const txtPath = wavPath.replace(/\.wav$/, ".txt");
56
+ await Bun.write(txtPath, text);
57
+
58
+ return { srtPath, txtPath, text };
59
+ }
60
+
61
+ function srtToPlainText(srt: string): string {
62
+ return srt
63
+ .split("\n")
64
+ .filter((line) => !/^\[|-->/.test(line))
65
+ .filter((line) => line.trim().length > 0)
66
+ .join("\n");
67
+ }
@@ -0,0 +1,72 @@
1
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
2
+ import { homedir } from "node:os";
3
+ import { join } from "node:path";
4
+
5
+ export interface TrxConfig {
6
+ modelPath: string;
7
+ modelSize: string;
8
+ language: string;
9
+ threads: number;
10
+ whisperFlags: {
11
+ suppressNst: boolean;
12
+ noFallback: boolean;
13
+ entropyThold: number;
14
+ logprobThold: number;
15
+ maxContext: number;
16
+ };
17
+ }
18
+
19
+ const TRX_DIR = join(homedir(), ".trx");
20
+ const CONFIG_PATH = join(TRX_DIR, "config.json");
21
+ const MODELS_DIR = join(TRX_DIR, "models");
22
+
23
+ export function getTrxDir(): string {
24
+ return TRX_DIR;
25
+ }
26
+
27
+ export function getModelsDir(): string {
28
+ return MODELS_DIR;
29
+ }
30
+
31
+ export function getConfigPath(): string {
32
+ return CONFIG_PATH;
33
+ }
34
+
35
+ export function ensureTrxDir(): void {
36
+ if (!existsSync(TRX_DIR)) {
37
+ mkdirSync(TRX_DIR, { recursive: true });
38
+ }
39
+ if (!existsSync(MODELS_DIR)) {
40
+ mkdirSync(MODELS_DIR, { recursive: true });
41
+ }
42
+ }
43
+
44
+ export function readConfig(): TrxConfig | null {
45
+ if (!existsSync(CONFIG_PATH)) return null;
46
+ try {
47
+ return JSON.parse(readFileSync(CONFIG_PATH, "utf-8")) as TrxConfig;
48
+ } catch {
49
+ return null;
50
+ }
51
+ }
52
+
53
+ export function writeConfig(config: TrxConfig): void {
54
+ ensureTrxDir();
55
+ writeFileSync(CONFIG_PATH, JSON.stringify(config, null, 2));
56
+ }
57
+
58
+ export function defaultConfig(modelSize: string, language: string): TrxConfig {
59
+ return {
60
+ modelPath: join(MODELS_DIR, `ggml-${modelSize}.bin`),
61
+ modelSize,
62
+ language,
63
+ threads: 8,
64
+ whisperFlags: {
65
+ suppressNst: true,
66
+ noFallback: true,
67
+ entropyThold: 2.8,
68
+ logprobThold: -1.0,
69
+ maxContext: 0,
70
+ },
71
+ };
72
+ }
@@ -0,0 +1,49 @@
1
+ export type OutputFormat = "json" | "table" | "auto";
2
+
3
+ export function outputJSON(data: unknown): void {
4
+ console.log(JSON.stringify(data, null, 2));
5
+ }
6
+
7
+ export function outputTable(headers: string[], rows: string[][]): void {
8
+ const widths = headers.map((h, i) => Math.max(h.length, ...rows.map((r) => (r[i] || "").length)));
9
+ const line = widths.map((w) => "-".repeat(w + 2)).join("+");
10
+ const formatRow = (row: string[]) => row.map((cell, i) => ` ${(cell || "").padEnd(widths[i])} `).join("|");
11
+
12
+ console.log(formatRow(headers));
13
+ console.log(line);
14
+ for (const row of rows) {
15
+ console.log(formatRow(row));
16
+ }
17
+ }
18
+
19
+ export function output(
20
+ format: OutputFormat,
21
+ data: { json: unknown; table?: { headers: string[]; rows: string[][] } },
22
+ ): void {
23
+ const resolved = format === "auto" ? (process.stdout.isTTY ? "table" : "json") : format;
24
+
25
+ if (resolved === "json") {
26
+ outputJSON(data.json);
27
+ } else if (data.table) {
28
+ outputTable(data.table.headers, data.table.rows);
29
+ } else {
30
+ outputJSON(data.json);
31
+ }
32
+ }
33
+
34
+ export function outputSuccess(message: string, format: OutputFormat): void {
35
+ if (format === "json") {
36
+ outputJSON({ success: true, message });
37
+ } else {
38
+ console.log(`\u2713 ${message}`);
39
+ }
40
+ }
41
+
42
+ export function outputError(error: string, format: OutputFormat): void {
43
+ if (format === "json") {
44
+ outputJSON({ success: false, error });
45
+ } else {
46
+ console.error(`\u2717 ${error}`);
47
+ }
48
+ process.exit(1);
49
+ }
@@ -0,0 +1,27 @@
1
+ export interface SpawnResult {
2
+ exitCode: number;
3
+ stdout: string;
4
+ stderr: string;
5
+ }
6
+
7
+ export async function spawn(cmd: string[], opts?: { cwd?: string; timeout?: number }): Promise<SpawnResult> {
8
+ const proc = Bun.spawn(cmd, {
9
+ cwd: opts?.cwd,
10
+ stdout: "pipe",
11
+ stderr: "pipe",
12
+ });
13
+
14
+ const [stdout, stderr] = await Promise.all([new Response(proc.stdout).text(), new Response(proc.stderr).text()]);
15
+
16
+ const exitCode = await proc.exited;
17
+
18
+ return { exitCode, stdout: stdout.trim(), stderr: stderr.trim() };
19
+ }
20
+
21
+ export async function spawnOrThrow(cmd: string[], context: string): Promise<string> {
22
+ const result = await spawn(cmd);
23
+ if (result.exitCode !== 0) {
24
+ throw new Error(`${context} failed (exit ${result.exitCode}): ${result.stderr || result.stdout}`);
25
+ }
26
+ return result.stdout;
27
+ }
@@ -0,0 +1,189 @@
1
+ import { existsSync } from "node:fs";
2
+
3
+ export function rejectControlChars(input: string): string {
4
+ for (let i = 0; i < input.length; i++) {
5
+ const code = input.charCodeAt(i);
6
+ if (code < 0x20 && code !== 0x0a && code !== 0x0d && code !== 0x09) {
7
+ throw new Error(`Input contains control character at position ${i} (0x${code.toString(16)})`);
8
+ }
9
+ }
10
+ return input;
11
+ }
12
+
13
+ export function validateUrl(url: string): string {
14
+ const cleaned = rejectControlChars(url.trim());
15
+ if (!/^https?:\/\//i.test(cleaned)) {
16
+ throw new Error(`Invalid URL: must start with http:// or https://, got "${cleaned}"`);
17
+ }
18
+ if (cleaned.includes("..")) {
19
+ throw new Error("URL contains path traversal (..) — rejected");
20
+ }
21
+ return cleaned;
22
+ }
23
+
24
+ export function validateFilePath(path: string): string {
25
+ const cleaned = rejectControlChars(path.trim());
26
+ if (cleaned.includes("..")) {
27
+ throw new Error("Path contains traversal (..) — rejected");
28
+ }
29
+ if (/%[0-9a-f]{2}/i.test(cleaned)) {
30
+ throw new Error("Path contains URL-encoded characters — pass raw path");
31
+ }
32
+ if (!existsSync(cleaned)) {
33
+ throw new Error(`File not found: "${cleaned}"`);
34
+ }
35
+ return cleaned;
36
+ }
37
+
38
+ const SUPPORTED_EXTENSIONS = [".mp4", ".m4a", ".ogg", ".wav", ".webm", ".mkv", ".avi", ".mov", ".flac", ".mp3"];
39
+
40
+ export function validateFileExtension(path: string): string {
41
+ const ext = path.slice(path.lastIndexOf(".")).toLowerCase();
42
+ if (!SUPPORTED_EXTENSIONS.includes(ext)) {
43
+ throw new Error(`Unsupported file type: "${ext}". Supported: ${SUPPORTED_EXTENSIONS.join(", ")}`);
44
+ }
45
+ return ext;
46
+ }
47
+
48
+ const WHISPER_LANGUAGES = [
49
+ "auto",
50
+ "af",
51
+ "am",
52
+ "ar",
53
+ "as",
54
+ "az",
55
+ "ba",
56
+ "be",
57
+ "bg",
58
+ "bn",
59
+ "bo",
60
+ "br",
61
+ "bs",
62
+ "ca",
63
+ "cs",
64
+ "cy",
65
+ "da",
66
+ "de",
67
+ "el",
68
+ "en",
69
+ "es",
70
+ "et",
71
+ "eu",
72
+ "fa",
73
+ "fi",
74
+ "fo",
75
+ "fr",
76
+ "gl",
77
+ "gu",
78
+ "ha",
79
+ "haw",
80
+ "he",
81
+ "hi",
82
+ "hr",
83
+ "ht",
84
+ "hu",
85
+ "hy",
86
+ "id",
87
+ "is",
88
+ "it",
89
+ "ja",
90
+ "jw",
91
+ "ka",
92
+ "kk",
93
+ "km",
94
+ "kn",
95
+ "ko",
96
+ "la",
97
+ "lb",
98
+ "ln",
99
+ "lo",
100
+ "lt",
101
+ "lv",
102
+ "mg",
103
+ "mi",
104
+ "mk",
105
+ "ml",
106
+ "mn",
107
+ "mr",
108
+ "ms",
109
+ "mt",
110
+ "my",
111
+ "ne",
112
+ "nl",
113
+ "nn",
114
+ "no",
115
+ "oc",
116
+ "pa",
117
+ "pl",
118
+ "ps",
119
+ "pt",
120
+ "ro",
121
+ "ru",
122
+ "sa",
123
+ "sd",
124
+ "si",
125
+ "sk",
126
+ "sl",
127
+ "sn",
128
+ "so",
129
+ "sq",
130
+ "sr",
131
+ "su",
132
+ "sv",
133
+ "sw",
134
+ "ta",
135
+ "te",
136
+ "tg",
137
+ "th",
138
+ "tk",
139
+ "tl",
140
+ "tr",
141
+ "tt",
142
+ "uk",
143
+ "ur",
144
+ "uz",
145
+ "vi",
146
+ "yi",
147
+ "yo",
148
+ "zh",
149
+ ] as const;
150
+
151
+ export type WhisperLanguage = (typeof WHISPER_LANGUAGES)[number];
152
+
153
+ export function validateLanguage(lang: string): WhisperLanguage {
154
+ const cleaned = lang.trim().toLowerCase();
155
+ if (!WHISPER_LANGUAGES.includes(cleaned as WhisperLanguage)) {
156
+ throw new Error(`Unsupported language: "${lang}". Use ISO 639-1 code or "auto".`);
157
+ }
158
+ return cleaned as WhisperLanguage;
159
+ }
160
+
161
+ const VALID_MODELS = [
162
+ "tiny",
163
+ "tiny.en",
164
+ "base",
165
+ "base.en",
166
+ "small",
167
+ "small.en",
168
+ "medium",
169
+ "medium.en",
170
+ "large",
171
+ ] as const;
172
+ export type WhisperModel = (typeof VALID_MODELS)[number];
173
+
174
+ export function validateModel(model: string): WhisperModel {
175
+ const cleaned = model.trim().toLowerCase();
176
+ if (!VALID_MODELS.includes(cleaned as WhisperModel)) {
177
+ throw new Error(`Unknown model: "${model}". Available: ${VALID_MODELS.join(", ")}`);
178
+ }
179
+ return cleaned as WhisperModel;
180
+ }
181
+
182
+ export function validateInput(input: string): { type: "url" | "file"; value: string } {
183
+ const cleaned = rejectControlChars(input.trim());
184
+ if (/^https?:\/\//i.test(cleaned)) {
185
+ return { type: "url", value: validateUrl(cleaned) };
186
+ }
187
+ validateFilePath(cleaned);
188
+ return { type: "file", value: cleaned };
189
+ }