lomasub 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.
Files changed (5) hide show
  1. package/README.md +97 -0
  2. package/cli.js +91 -0
  3. package/index.d.ts +70 -0
  4. package/index.js +159 -0
  5. package/package.json +12 -0
package/README.md ADDED
@@ -0,0 +1,97 @@
1
+ # Loma Sub: Node.js SDK and CLI
2
+
3
+ Create subtitles from a local audio or video file. Requires Node.js 20+ and a Loma Sub API key. No runtime npm dependencies.
4
+
5
+ ## Install
6
+
7
+ From this repository (the package has not been published to npm):
8
+
9
+ ```bash
10
+ npm install --global ./cli-sdk
11
+ export LOMA_API_SECRET_KEY="<your-api-key>"
12
+ lomasub transcribe ./video.mp4 --language th
13
+ ```
14
+
15
+ For a Node.js project, run `npm install /path/to/loma_fastsrt/cli-sdk` instead.
16
+
17
+ ## CLI
18
+
19
+ ```bash
20
+ lomasub transcribe video.mp4 --output video.srt
21
+ lomasub transcribe video.mp4 --speaker-diarization --srt-max-chars 48 --srt-cue-words 6
22
+ lomasub transcribe video.mp4 --config options.json --no-revise-srt
23
+ lomasub transcribe video.mp4 --no-ffmpeg
24
+ lomasub download <job-id> --output video.srt
25
+ lomasub --help
26
+ ```
27
+
28
+ The command uploads the file, creates the job, waits, and saves `<input-stem>.srt` beside the input. `--output` changes the destination; `--overwrite` permits replacing an existing file. Progress and the job ID go to stderr; the saved path goes to stdout. If interrupted, the server job continues: resume using `download` and the printed job ID. Errors exit with code 1; cancellation exits with code 130. Job creation is never automatically retried.
29
+
30
+ When ffmpeg is on `PATH`, audio is extracted into a temporary Matroska audio file using `-map 0:a:0 -vn -c:a copy`. This copies the first audio stream without encoding, resampling, or changing quality. It excludes video to reduce uploads. If ffmpeg is missing or extraction fails (including a codec unsupported by Matroska), the original file is uploaded. `--no-ffmpeg` disables extraction. Temporary files are removed afterward.
31
+
32
+ `--config` reads an object of API transcription settings, without a surrounding `config` key:
33
+
34
+ ```json
35
+ {
36
+ "forcealigner_level": "default",
37
+ "speaker_diarization": true,
38
+ "srt_max_chars": 48,
39
+ "srt_cue_words": 6,
40
+ "min_srt_duration_ms": 1200
41
+ }
42
+ ```
43
+
44
+ Every current transcription config field has a kebab-case flag. Boolean fields support `--name` and `--no-name`. Explicit flags override JSON values, including `false` and `0`. Unset settings use server defaults; the API validates settings and reports retired names. Additional config keys pass through unchanged.
45
+
46
+ | Settings | CLI flags |
47
+ | --- | --- |
48
+ | Alignment | `--forcealigner-level` (`default`, `llmsplitting`, `none`) |
49
+ | VAD | `--min-vad-duration-seconds`, `--max-vad-duration-seconds`, `--vad-threshold` |
50
+ | Subtitle cues | `--min-srt-duration-ms`, `--srt-max-chars`, `--srt-cue-words` |
51
+ | Diarization | `--speaker-diarization`, `--speaker-local-distance-threshold`, `--speaker-merge-distance-threshold`, `--speaker-clustering-drop-dialogue-percentile`, `--agglomeration-min-appear`, `--agglomolation-group-size` |
52
+ | Revision | `--revise-srt`, `--revise-speakername`, `--srt-revision-backend` (`agy`, `codex`) |
53
+
54
+ `--filename`, `--language`, and `--duration-seconds` are job metadata, outside config. The original local filename is preserved unless overridden. An explicit duration is forwarded to upload preparation and job creation; duration is not automatically measured.
55
+
56
+ Use `--endpoint http://localhost:8080` for another API server. The default is `https://api.lomasub.ai`. Bearer credentials are sent only to the API, never to storage upload/download URLs.
57
+
58
+ ## SDK
59
+
60
+ ```js
61
+ import { LomaClient } from 'lomasub';
62
+
63
+ const client = new LomaClient(); // LOMA_API_SECRET_KEY; or { apiKey, endpoint }
64
+ const result = await client.transcribeFile('./video.mp4', {
65
+ language: 'th',
66
+ config: { speaker_diarization: true, srt_max_chars: 48 },
67
+ onProgress: event => console.error(event),
68
+ });
69
+ await client.downloadSrt(result.job_id, './video.srt');
70
+ ```
71
+
72
+ Methods return plain API data; result URLs include whichever artifacts the server produced.
73
+
74
+ | Method | Behavior |
75
+ | --- | --- |
76
+ | `submitFile(file, options?)` | Extract/upload and return the newly created job |
77
+ | `getStatus(jobId, { signal }?)` | Read current status and fresh download URLs |
78
+ | `waitForJob(jobId, options?)` | Poll until READY; throw with the failed stage on FAILED |
79
+ | `transcribeFile(file, options?)` | Submit and wait |
80
+ | `downloadSrt(jobId, output, options?)` | Wait if needed, download original SRT, return absolute saved path |
81
+
82
+ Transcription options: `config`, `filename`, `language`, `duration_seconds`, `ffmpeg` (default `true`), `signal`, `onProgress`, and `pollIntervalMs` (default `2000`). Waiting/downloading accept `signal`, `onProgress`, and `pollIntervalMs`; downloading also accepts `overwrite` (default `false`). Output parent directories must already exist.
83
+
84
+ Pass an `AbortController`'s `signal` to stop local work. A submitted job continues on the server. HTTP errors include `status` and `detail`; ordinary network/file errors propagate. There are no automatic retries.
85
+
86
+ This package covers transcription and original results only. Account/login, job-history management, subtitle editing, and edited-subtitle uploads are outside its scope.
87
+
88
+ ## Development
89
+
90
+ ```bash
91
+ cd cli-sdk
92
+ npm test
93
+ npm run check
94
+ npm pack --dry-run
95
+ ```
96
+
97
+ Tests use local mock HTTP servers. One optional ffmpeg test checks that stream-copy extraction preserves audio packet bytes. No production jobs are submitted.
package/cli.js ADDED
@@ -0,0 +1,91 @@
1
+ #!/usr/bin/env node
2
+ import { lstat, readFile } from 'node:fs/promises';
3
+ import { extname, resolve } from 'node:path';
4
+ import { parseArgs } from 'node:util';
5
+ import { LomaClient, TRANSCRIPTION_OPTIONS } from './index.js';
6
+
7
+ const options = {
8
+ help: { type: 'boolean', short: 'h' },
9
+ endpoint: { type: 'string' }, output: { type: 'string', short: 'o' },
10
+ config: { type: 'string' }, language: { type: 'string' }, filename: { type: 'string' },
11
+ 'duration-seconds': { type: 'string' },
12
+ 'no-ffmpeg': { type: 'boolean' }, overwrite: { type: 'boolean' },
13
+ };
14
+ for (const [key, type] of Object.entries(TRANSCRIPTION_OPTIONS)) {
15
+ const flag = key.replaceAll('_', '-');
16
+ options[flag] = { type: type === 'boolean' ? 'boolean' : 'string' };
17
+ if (type === 'boolean') options[`no-${flag}`] = { type: 'boolean' };
18
+ }
19
+
20
+ function number(value, flag) {
21
+ const parsed = Number(value);
22
+ if (!value.trim() || !Number.isFinite(parsed)) throw new Error(`--${flag} requires a number.`);
23
+ return parsed;
24
+ }
25
+
26
+ async function main() {
27
+ const { values, positionals } = parseArgs({ options, allowPositionals: true });
28
+ if (values.help || !positionals.length) {
29
+ console.log(`Usage:
30
+ lomasub transcribe <file> [options]
31
+ lomasub download <job-id> --output <file.srt> [--overwrite]
32
+
33
+ Set LOMA_API_SECRET_KEY before running. Default endpoint: https://api.lomasub.ai
34
+ Options:
35
+ --endpoint <url> API endpoint
36
+ --output, -o <path> Output SRT (default: beside input)
37
+ --overwrite Replace existing output
38
+ --no-ffmpeg Upload original without audio extraction
39
+ --language <code> Audio language
40
+ --filename <name> Job display filename
41
+ --duration-seconds N Optional media duration hint
42
+ --config <path> JSON transcription config; explicit flags override it
43
+
44
+ Transcription options (unset values use server defaults):
45
+ ${Object.entries(TRANSCRIPTION_OPTIONS).map(([key, type]) => ` --${key.replaceAll('_', '-')}${type === 'boolean' ? ' / --no-' + key.replaceAll('_', '-') : ' <' + type + '>'}`).join('\n')}`);
46
+ return;
47
+ }
48
+ const [command, input] = positionals;
49
+ if (!['transcribe', 'download'].includes(command) || !input || positionals.length !== 2) {
50
+ throw new Error('Use transcribe <file> or download <job-id>. See --help.');
51
+ }
52
+ if (command === 'download' && !values.output) throw new Error('download requires --output.');
53
+ const output = resolve(values.output ?? `${input.slice(0, input.length - extname(input).length)}.srt`);
54
+ if (!values.overwrite) {
55
+ try {
56
+ await lstat(output);
57
+ throw new Error(`Output exists: ${output}. Use --overwrite to replace it.`);
58
+ } catch (error) { if (error.code !== 'ENOENT') throw error; }
59
+ }
60
+ const config = values.config ? JSON.parse(await readFile(values.config, 'utf8')) : {};
61
+ if (!config || typeof config !== 'object' || Array.isArray(config)) throw new Error('--config must contain a JSON object.');
62
+ for (const [key, type] of Object.entries(TRANSCRIPTION_OPTIONS)) {
63
+ const flag = key.replaceAll('_', '-');
64
+ if (values[flag] !== undefined && values[`no-${flag}`] !== undefined) throw new Error(`Conflicting --${flag} and --no-${flag}.`);
65
+ if (values[`no-${flag}`]) config[key] = false;
66
+ else if (values[flag] !== undefined) config[key] = type === 'number' ? number(values[flag], flag) : values[flag];
67
+ }
68
+ const controller = new AbortController();
69
+ const cancel = () => controller.abort();
70
+ const settings = {
71
+ signal: controller.signal, overwrite: values.overwrite, ffmpeg: !values['no-ffmpeg'],
72
+ language: values.language, filename: values.filename, config,
73
+ duration_seconds: values['duration-seconds'] === undefined ? undefined : number(values['duration-seconds'], 'duration-seconds'),
74
+ onProgress: ({ stage, job_id, state, message }) => console.error([stage, job_id, state, message].filter(Boolean).join(': ')),
75
+ };
76
+ process.once('SIGINT', cancel);
77
+ process.once('SIGTERM', cancel);
78
+ try {
79
+ const client = new LomaClient({ endpoint: values.endpoint });
80
+ const jobId = command === 'download' ? input : (await client.submitFile(input, settings)).job_id;
81
+ console.log(await client.downloadSrt(jobId, output, settings));
82
+ } finally {
83
+ process.removeListener('SIGINT', cancel);
84
+ process.removeListener('SIGTERM', cancel);
85
+ }
86
+ }
87
+
88
+ main().catch(error => {
89
+ console.error(error.name === 'AbortError' ? 'Cancelled. An already-submitted job continues; resume with lomasub download <job-id> --output <path>.' : error.message);
90
+ process.exitCode = error.name === 'AbortError' ? 130 : 1;
91
+ });
package/index.d.ts ADDED
@@ -0,0 +1,70 @@
1
+ export interface TranscriptionConfig {
2
+ forcealigner_level?: 'default' | 'llmsplitting' | 'none';
3
+ min_vad_duration_seconds?: number;
4
+ max_vad_duration_seconds?: number;
5
+ vad_threshold?: number;
6
+ min_srt_duration_ms?: number;
7
+ srt_max_chars?: number;
8
+ srt_cue_words?: number;
9
+ speaker_diarization?: boolean;
10
+ speaker_local_distance_threshold?: number;
11
+ speaker_merge_distance_threshold?: number;
12
+ speaker_clustering_drop_dialogue_percentile?: number;
13
+ agglomeration_min_appear?: number;
14
+ agglomolation_group_size?: number;
15
+ revise_srt?: boolean;
16
+ revise_speakername?: boolean;
17
+ srt_revision_backend?: 'agy' | 'codex';
18
+ [key: string]: unknown;
19
+ }
20
+
21
+ export interface Progress {
22
+ stage: 'extracting' | 'fallback' | 'uploading' | 'submitted' | 'processing' | 'downloading';
23
+ job_id?: string;
24
+ state?: string;
25
+ message?: string;
26
+ }
27
+
28
+ export interface WaitOptions {
29
+ signal?: AbortSignal;
30
+ onProgress?: (progress: Progress) => void;
31
+ pollIntervalMs?: number;
32
+ }
33
+
34
+ export interface TranscribeOptions extends WaitOptions {
35
+ ffmpeg?: boolean;
36
+ config?: TranscriptionConfig;
37
+ filename?: string;
38
+ language?: string;
39
+ duration_seconds?: number;
40
+ }
41
+
42
+ export interface Job {
43
+ job_id: string;
44
+ state: string;
45
+ status_url?: string;
46
+ [key: string]: unknown;
47
+ }
48
+
49
+ export interface JobStatus extends Job {
50
+ config?: TranscriptionConfig;
51
+ failed_stage?: string | null;
52
+ error?: string | null;
53
+ srt_url?: string;
54
+ vtt_url?: string;
55
+ compact_url?: string;
56
+ karaoke_vtt_url?: string;
57
+ words_url?: string;
58
+ debug_vtt_url?: string;
59
+ }
60
+
61
+ export const TRANSCRIPTION_OPTIONS: Readonly<Record<string, 'string' | 'number' | 'boolean'>>;
62
+
63
+ export class LomaClient {
64
+ constructor(options?: { apiKey?: string; endpoint?: string });
65
+ submitFile(file: string, options?: TranscribeOptions): Promise<Job>;
66
+ getStatus(jobId: string, options?: Pick<WaitOptions, 'signal'>): Promise<JobStatus>;
67
+ waitForJob(jobId: string, options?: WaitOptions): Promise<JobStatus>;
68
+ transcribeFile(file: string, options?: TranscribeOptions): Promise<JobStatus>;
69
+ downloadSrt(jobId: string, output: string, options?: WaitOptions & { overwrite?: boolean }): Promise<string>;
70
+ }
package/index.js ADDED
@@ -0,0 +1,159 @@
1
+ import { execFile } from 'node:child_process';
2
+ import { createReadStream, createWriteStream } from 'node:fs';
3
+ import { link, mkdtemp, rename, rm, stat } from 'node:fs/promises';
4
+ import { tmpdir } from 'node:os';
5
+ import { basename, dirname, extname, join, resolve } from 'node:path';
6
+ import { Readable } from 'node:stream';
7
+ import { pipeline } from 'node:stream/promises';
8
+ import { setTimeout as sleep } from 'node:timers/promises';
9
+ import { promisify } from 'node:util';
10
+
11
+ const run = promisify(execFile);
12
+
13
+ // Shared with the CLI; validation and transcription defaults belong to the API.
14
+ export const TRANSCRIPTION_OPTIONS = {
15
+ forcealigner_level: 'string',
16
+ min_vad_duration_seconds: 'number',
17
+ max_vad_duration_seconds: 'number',
18
+ vad_threshold: 'number',
19
+ min_srt_duration_ms: 'number',
20
+ srt_max_chars: 'number',
21
+ srt_cue_words: 'number',
22
+ speaker_diarization: 'boolean',
23
+ speaker_local_distance_threshold: 'number',
24
+ speaker_merge_distance_threshold: 'number',
25
+ speaker_clustering_drop_dialogue_percentile: 'number',
26
+ agglomeration_min_appear: 'number',
27
+ agglomolation_group_size: 'number',
28
+ revise_srt: 'boolean',
29
+ revise_speakername: 'boolean',
30
+ srt_revision_backend: 'string',
31
+ };
32
+
33
+ const MIME_TYPES = {
34
+ '.mp3': 'audio/mpeg', '.m4a': 'audio/mp4', '.mp4': 'video/mp4',
35
+ '.wav': 'audio/wav', '.flac': 'audio/flac', '.ogg': 'audio/ogg',
36
+ '.opus': 'audio/ogg', '.aac': 'audio/aac', '.webm': 'video/webm',
37
+ '.mov': 'video/quicktime', '.mkv': 'video/x-matroska', '.mka': 'audio/x-matroska',
38
+ };
39
+
40
+ async function checkedFetch(url, options, stage) {
41
+ const response = await fetch(url, { ...options, redirect: 'error' });
42
+ if (!response.ok) {
43
+ const text = await response.text();
44
+ let detail = text;
45
+ try { detail = JSON.parse(text).detail ?? text; } catch { /* Non-JSON storage errors. */ }
46
+ throw Object.assign(new Error(`${stage}: HTTP ${response.status}: ${typeof detail === 'string' ? detail : JSON.stringify(detail)}`), {
47
+ status: response.status, detail,
48
+ });
49
+ }
50
+ return response;
51
+ }
52
+
53
+ export class LomaClient {
54
+ constructor({ apiKey = process.env.LOMA_API_SECRET_KEY, endpoint = 'https://api.lomasub.ai' } = {}) {
55
+ if (!apiKey) throw new Error('Set LOMA_API_SECRET_KEY or provide apiKey.');
56
+ this.apiKey = apiKey;
57
+ this.endpoint = endpoint.replace(/\/+$/, '');
58
+ }
59
+
60
+ async request(path, body, signal) {
61
+ const response = await checkedFetch(`${this.endpoint}${path}`, {
62
+ method: body === undefined ? 'GET' : 'POST', signal,
63
+ headers: { Authorization: `Bearer ${this.apiKey}`, 'Content-Type': 'application/json' },
64
+ body: body === undefined ? undefined : JSON.stringify(body),
65
+ }, path);
66
+ return response.json();
67
+ }
68
+
69
+ async submitFile(file, options = {}) {
70
+ const { signal, onProgress = () => {}, ffmpeg = true, config, language, duration_seconds } = options;
71
+ signal?.throwIfAborted();
72
+ const original = resolve(file);
73
+ if (!(await stat(original)).isFile()) throw new Error(`Not a regular file: ${file}`);
74
+ let source = original;
75
+ let temporary;
76
+ try {
77
+ if (ffmpeg) {
78
+ temporary = await mkdtemp(join(tmpdir(), 'lomasub-audio-'));
79
+ const audio = join(temporary, 'audio.mka');
80
+ onProgress({ stage: 'extracting' });
81
+ try {
82
+ await run('ffmpeg', ['-hide_banner', '-loglevel', 'error', '-nostdin', '-i', original,
83
+ '-map', '0:a:0', '-vn', '-c:a', 'copy', audio], { signal });
84
+ source = audio;
85
+ } catch (error) {
86
+ signal?.throwIfAborted();
87
+ onProgress({ stage: 'fallback', message: `Audio extraction unavailable or failed; uploading original. ${error.message}` });
88
+ }
89
+ }
90
+ signal?.throwIfAborted();
91
+ const upload = await this.request('/presigned-url', {
92
+ filename: basename(source),
93
+ content_type: MIME_TYPES[extname(source).toLowerCase()] ?? 'application/octet-stream',
94
+ duration_seconds,
95
+ }, signal);
96
+ if (upload.method !== 'PUT' || !upload.upload_url || !upload.source_url || !upload.headers) {
97
+ throw new Error('Invalid /presigned-url response.');
98
+ }
99
+ onProgress({ stage: 'uploading' });
100
+ const stream = createReadStream(source);
101
+ try {
102
+ const response = await checkedFetch(upload.upload_url, {
103
+ method: upload.method, headers: { ...upload.headers, 'Content-Length': String((await stat(source)).size) },
104
+ body: stream, duplex: 'half', signal,
105
+ }, 'Upload');
106
+ await response.arrayBuffer();
107
+ } finally { stream.destroy(); }
108
+ const job = await this.request('/new-transcribe', {
109
+ source_url: upload.source_url, filename: options.filename ?? basename(original),
110
+ language, duration_seconds, config,
111
+ }, signal);
112
+ onProgress({ stage: 'submitted', job_id: job.job_id });
113
+ return job;
114
+ } finally {
115
+ if (temporary) await rm(temporary, { recursive: true, force: true });
116
+ }
117
+ }
118
+
119
+ getStatus(jobId, { signal } = {}) {
120
+ return this.request(`/status?${new URLSearchParams({ job_id: jobId })}`, undefined, signal);
121
+ }
122
+
123
+ async waitForJob(jobId, { signal, onProgress = () => {}, pollIntervalMs = 2000 } = {}) {
124
+ if (!Number.isFinite(pollIntervalMs) || pollIntervalMs <= 0) throw new Error('pollIntervalMs must be positive.');
125
+ let previous;
126
+ while (true) {
127
+ const status = await this.getStatus(jobId, { signal });
128
+ if (status.state !== previous) onProgress({ stage: 'processing', job_id: jobId, state: status.state });
129
+ previous = status.state;
130
+ if (status.state === 'READY') return status;
131
+ if (status.state === 'FAILED') throw new Error(`Job ${jobId} failed at ${status.failed_stage ?? 'unknown stage'}: ${status.error ?? 'Unknown error'}`);
132
+ await sleep(pollIntervalMs, undefined, { signal });
133
+ }
134
+ }
135
+
136
+ async transcribeFile(file, options = {}) {
137
+ const job = await this.submitFile(file, options);
138
+ return this.waitForJob(job.job_id, options);
139
+ }
140
+
141
+ async downloadSrt(jobId, output, options = {}) {
142
+ const { signal, overwrite = false, onProgress = () => {} } = options;
143
+ const status = await this.waitForJob(jobId, options);
144
+ if (!status.srt_url) throw new Error(`Job ${jobId} has no SRT URL.`);
145
+ const target = resolve(output);
146
+ const temporary = await mkdtemp(join(dirname(target), '.lomasub-download-'));
147
+ const partial = join(temporary, 'result.srt');
148
+ try {
149
+ onProgress({ stage: 'downloading', job_id: jobId });
150
+ const response = await checkedFetch(status.srt_url, { signal }, 'Download');
151
+ await pipeline(Readable.fromWeb(response.body), createWriteStream(partial), { signal });
152
+ signal?.throwIfAborted();
153
+ // link fails atomically if the destination exists; rename replaces it only when requested.
154
+ if (overwrite) await rename(partial, target);
155
+ else await link(partial, target);
156
+ return target;
157
+ } finally { await rm(temporary, { recursive: true, force: true }); }
158
+ }
159
+ }
package/package.json ADDED
@@ -0,0 +1,12 @@
1
+ {
2
+ "name": "lomasub",
3
+ "version": "0.1.0",
4
+ "description": "Lomasub cli is a command-line tool for creating subtitles from local audio or video in Node.js or the terminal, using lomasub.ai cloud service. ",
5
+ "type": "module",
6
+ "engines": { "node": ">=20" },
7
+ "exports": { ".": { "types": "./index.d.ts", "default": "./index.js" } },
8
+ "types": "./index.d.ts",
9
+ "bin": { "lomasub": "./cli.js" },
10
+ "files": ["index.js", "index.d.ts", "cli.js", "README.md"],
11
+ "scripts": { "test": "node --test", "check": "node --check index.js && node --check cli.js" }
12
+ }