lomasub 0.1.2 → 0.2.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.
- package/README.md +28 -5
- package/cli.js +72 -17
- package/index.d.ts +16 -4
- package/index.js +65 -10
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -20,13 +20,19 @@ For a Node.js project, run `npm install lomasub`. For a persistent CLI, run `npm
|
|
|
20
20
|
```bash
|
|
21
21
|
lomasub transcribe video.mp4 --output video.srt
|
|
22
22
|
lomasub transcribe video.mp4 --speaker-diarization --srt-max-chars 48 --srt-cue-words 6
|
|
23
|
-
lomasub transcribe video.mp4 --config options.json
|
|
23
|
+
lomasub transcribe video.mp4 --config options.json
|
|
24
24
|
lomasub transcribe video.mp4 --no-ffmpeg
|
|
25
25
|
lomasub download <job-id> --output video.srt
|
|
26
|
+
lomasub transcribe video.mp4 --format txt # prints only the text, one line
|
|
27
|
+
lomasub transcribe video.mp4 --format txt -v # same, plus progress on stderr
|
|
28
|
+
lomasub transcribe video.mp4 --format txt -o video.txt
|
|
29
|
+
lomasub download <job-id> --format txt --speakers
|
|
26
30
|
lomasub --help
|
|
27
31
|
```
|
|
28
32
|
|
|
29
|
-
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.
|
|
33
|
+
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. `--output -` prints the result itself to stdout instead of saving it.
|
|
34
|
+
|
|
35
|
+
`--format txt` returns the transcript as plain text: every cue joined by a single space on one line, with no line breaks at all (not even at the end), no numbers, timings or markup. It is made for scripts: by default it prints **only** the text to stdout, with no progress lines and no file. `--output <file>` saves it instead (silently), `--verbose` / `-v` shows the progress, timing and saved path as SRT does, and `download <job-id> --format txt` needs no `--output`. Errors are always reported on stderr with exit code 1, naming the job ID so it can be fetched later with `download`. It follows the editor: once a subtitle is edited and saved, the text reflects the edit. Speaker labels (`A: ...`, from `--speaker-diarization`) are dropped unless `--speakers` is given. The default format is `srt`. 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.
|
|
30
36
|
|
|
31
37
|
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.
|
|
32
38
|
|
|
@@ -42,7 +48,7 @@ When ffmpeg is on `PATH`, audio is extracted into a temporary Matroska audio fil
|
|
|
42
48
|
}
|
|
43
49
|
```
|
|
44
50
|
|
|
45
|
-
|
|
51
|
+
Supported transcription flags use kebab-case. Use `--promodel pro` (SDK: `config: { promodel: "pro" }`) for word correction before alignment. Revision settings (`revise_srt`, `revise_speakername`, `srt_revision_backend`) are not supported by this CLI/SDK, including through `--config` JSON. 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. Other additional config keys pass through unchanged.
|
|
46
52
|
|
|
47
53
|
| Settings | CLI flags |
|
|
48
54
|
| --- | --- |
|
|
@@ -50,10 +56,11 @@ Every current transcription config field has a kebab-case flag. Boolean fields s
|
|
|
50
56
|
| VAD | `--min-vad-duration-seconds`, `--max-vad-duration-seconds`, `--vad-threshold` |
|
|
51
57
|
| Subtitle cues | `--min-srt-duration-ms`, `--srt-max-chars`, `--srt-cue-words` |
|
|
52
58
|
| Diarization | `--speaker-diarization`, `--speaker-local-distance-threshold`, `--speaker-merge-distance-threshold`, `--speaker-clustering-drop-dialogue-percentile`, `--agglomeration-min-appear`, `--agglomolation-group-size` |
|
|
53
|
-
| Revision | `--revise-srt`, `--revise-speakername`, `--srt-revision-backend` (`agy`, `codex`) |
|
|
54
59
|
|
|
55
60
|
`--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.
|
|
56
61
|
|
|
62
|
+
The server defaults omitted, null, or blank language to `th`; `--language en` overrides it. The Thai hint permits Thai, English, and mixed transcripts: readable English is not discarded because of its script. This is a model hint, not automatic language detection.
|
|
63
|
+
|
|
57
64
|
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.
|
|
58
65
|
|
|
59
66
|
## SDK
|
|
@@ -80,10 +87,14 @@ Results include API job data and whichever artifact URLs the server produced. `t
|
|
|
80
87
|
| `waitForJob(jobId, options?)` | Poll until READY; throw with the failed stage on FAILED |
|
|
81
88
|
| `transcribeFile(file, options?)` | Submit, wait, optionally save SRT, and return timing |
|
|
82
89
|
| `downloadSrt(jobId, output, options?)` | Wait if needed, download original SRT, return absolute saved path |
|
|
90
|
+
| `downloadText(jobId, output, options?)` | Wait if needed, save the plain-text transcript, return absolute saved path |
|
|
91
|
+
| `getText(jobId, { signal, speakers }?)` | The plain-text transcript of a READY job as a string |
|
|
92
|
+
| `readResult(jobId, options?)` | Wait if needed and return the result as a string (`format`: `srt` or `txt`) |
|
|
93
|
+
| `resultText(status, options?)` | The result of a READY status (such as `transcribeFile`'s) as a string, without polling again |
|
|
83
94
|
|
|
84
95
|
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.
|
|
85
96
|
|
|
86
|
-
`transcribeFile()` additionally accepts `output` (
|
|
97
|
+
`transcribeFile()` additionally accepts `output` (result path), `format` (`srt` default, or `txt`), `speakers` (txt only, default `false`) and `overwrite` (default `false`). Existing output files are rejected before uploading unless overwrite is enabled.
|
|
87
98
|
|
|
88
99
|
After successful CLI transcription, stderr includes a final summary:
|
|
89
100
|
|
|
@@ -109,3 +120,15 @@ npm pack --dry-run
|
|
|
109
120
|
```
|
|
110
121
|
|
|
111
122
|
Tests use local mock HTTP servers. One optional ffmpeg test checks that stream-copy extraction preserves audio packet bytes. No production jobs are submitted.
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
### Translation
|
|
126
|
+
|
|
127
|
+
Use one output mode, for example `--promodel translate-zh` for **Simplified Chinese**.
|
|
128
|
+
Other targets: `translate-th` (Thai), `translate-en` (English), `translate-ja`
|
|
129
|
+
(Japanese), `translate-ko` (Korean). `translate-jp` and `translate-kr` are aliases.
|
|
130
|
+
SDK: `config: { promodel: "translate-zh" }`. `language` still names the source
|
|
131
|
+
language. Translation combines conservative correction and translation in one
|
|
132
|
+
batched stage; it never runs `pro` first. Omit the option for original subtitles.
|
|
133
|
+
Use alignment `default` or `none`; `llmsplitting` is incompatible with translation.
|
|
134
|
+
Translated results have fixed cue timing, plain SRT/VTT, and no word-level karaoke.
|
package/cli.js
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
import { lstat, readFile } from 'node:fs/promises';
|
|
3
3
|
import { extname, resolve } from 'node:path';
|
|
4
4
|
import { parseArgs } from 'node:util';
|
|
5
|
-
import { LomaClient, TRANSCRIPTION_OPTIONS } from './index.js';
|
|
5
|
+
import { LomaClient, RESULT_FORMATS, TRANSCRIPTION_OPTIONS } from './index.js';
|
|
6
6
|
|
|
7
7
|
const options = {
|
|
8
8
|
help: { type: 'boolean', short: 'h' },
|
|
@@ -10,6 +10,7 @@ const options = {
|
|
|
10
10
|
config: { type: 'string' }, language: { type: 'string' }, filename: { type: 'string' },
|
|
11
11
|
'duration-seconds': { type: 'string' },
|
|
12
12
|
'no-ffmpeg': { type: 'boolean' }, overwrite: { type: 'boolean' },
|
|
13
|
+
format: { type: 'string' }, speakers: { type: 'boolean' }, verbose: { type: 'boolean', short: 'v' },
|
|
13
14
|
};
|
|
14
15
|
for (const [key, type] of Object.entries(TRANSCRIPTION_OPTIONS)) {
|
|
15
16
|
const flag = key.replaceAll('_', '-');
|
|
@@ -17,6 +18,24 @@ for (const [key, type] of Object.entries(TRANSCRIPTION_OPTIONS)) {
|
|
|
17
18
|
if (type === 'boolean') options[`no-${flag}`] = { type: 'boolean' };
|
|
18
19
|
}
|
|
19
20
|
|
|
21
|
+
// Display-only production defaults; omitted options still use the API's settings.
|
|
22
|
+
const DEFAULT_HELP = {
|
|
23
|
+
forcealigner_level: 'default; choices: default, llmsplitting, none',
|
|
24
|
+
promodel: 'disabled; pro or translate-th/en/ja/ko/zh (zh = Simplified Chinese; jp/kr aliases)',
|
|
25
|
+
min_vad_duration_seconds: '10',
|
|
26
|
+
max_vad_duration_seconds: '25',
|
|
27
|
+
vad_threshold: '0.30',
|
|
28
|
+
min_srt_duration_ms: '1200',
|
|
29
|
+
srt_max_chars: '48',
|
|
30
|
+
srt_cue_words: '6',
|
|
31
|
+
speaker_diarization: 'disabled',
|
|
32
|
+
speaker_local_distance_threshold: '0.60',
|
|
33
|
+
speaker_merge_distance_threshold: '0.35',
|
|
34
|
+
speaker_clustering_drop_dialogue_percentile: '0.02',
|
|
35
|
+
agglomeration_min_appear: '5',
|
|
36
|
+
agglomolation_group_size: '300',
|
|
37
|
+
};
|
|
38
|
+
|
|
20
39
|
function number(value, flag) {
|
|
21
40
|
const parsed = Number(value);
|
|
22
41
|
if (!value.trim() || !Number.isFinite(parsed)) throw new Error(`--${flag} requires a number.`);
|
|
@@ -29,29 +48,43 @@ async function main() {
|
|
|
29
48
|
console.log(`Usage:
|
|
30
49
|
lomasub transcribe <file> [options]
|
|
31
50
|
lomasub download <job-id> --output <file.srt> [--overwrite]
|
|
51
|
+
lomasub download <job-id> --format txt [--output <file.txt>]
|
|
32
52
|
|
|
33
53
|
Set LOMA_API_SECRET_KEY before running. Default endpoint: https://api.lomasub.ai
|
|
34
54
|
Options:
|
|
35
|
-
--
|
|
36
|
-
--
|
|
55
|
+
--output, -o <path> Output file (srt default: beside input); - prints to stdout
|
|
56
|
+
--format <srt|txt> srt (default) or txt: all cues on one line, joined by spaces.
|
|
57
|
+
txt prints only the text to stdout unless --output is given
|
|
58
|
+
--speakers txt only: keep speaker labels (A: ...)
|
|
59
|
+
--verbose, -v txt only: show progress and timing on stderr (srt always does)
|
|
37
60
|
--overwrite Replace existing output
|
|
38
61
|
--no-ffmpeg Upload original without audio extraction
|
|
39
|
-
--language <code>
|
|
62
|
+
--language <code> ASR language hint: th, en; server default: th
|
|
40
63
|
--filename <name> Job display filename
|
|
41
|
-
--duration-seconds N Optional media duration hint
|
|
42
64
|
--config <path> JSON transcription config; explicit flags override it
|
|
43
65
|
|
|
44
|
-
Transcription options
|
|
45
|
-
|
|
66
|
+
Transcription options:
|
|
67
|
+
Defaults shown are for api.lomasub.ai; omitted options use server settings.
|
|
68
|
+
${Object.entries(TRANSCRIPTION_OPTIONS).map(([key, type]) => {
|
|
69
|
+
const flag = key.replaceAll('_', '-');
|
|
70
|
+
const label = `--${flag}${type === 'boolean' ? ` / --no-${flag}` : ` <${type}>`}`;
|
|
71
|
+
return ` ${label.padEnd(56)}default: ${DEFAULT_HELP[key]}`;
|
|
72
|
+
}).join('\n')}`);
|
|
46
73
|
return;
|
|
47
74
|
}
|
|
48
75
|
const [command, input] = positionals;
|
|
49
76
|
if (!['transcribe', 'download'].includes(command) || !input || positionals.length !== 2) {
|
|
50
77
|
throw new Error('Use transcribe <file> or download <job-id>. See --help.');
|
|
51
78
|
}
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
if (
|
|
79
|
+
const format = values.format ?? 'srt';
|
|
80
|
+
if (!RESULT_FORMATS.includes(format)) throw new Error(`--format must be one of: ${RESULT_FORMATS.join(', ')}.`);
|
|
81
|
+
if (values.speakers && format !== 'txt') throw new Error('--speakers requires --format txt.');
|
|
82
|
+
if (command === 'download' && !values.output && format !== 'txt') throw new Error('download requires --output.');
|
|
83
|
+
// txt is made for pipes: the text alone on stdout, nothing else unless --verbose.
|
|
84
|
+
const stdout = values.output === '-' || (format === 'txt' && values.output === undefined);
|
|
85
|
+
const verbose = format === 'srt' || values.verbose;
|
|
86
|
+
const output = stdout ? undefined : resolve(values.output ?? `${input.slice(0, input.length - extname(input).length)}.${format}`);
|
|
87
|
+
if (!stdout && !values.overwrite) {
|
|
55
88
|
try {
|
|
56
89
|
await lstat(output);
|
|
57
90
|
throw new Error(`Output exists: ${output}. Use --overwrite to replace it.`);
|
|
@@ -65,26 +98,46 @@ ${Object.entries(TRANSCRIPTION_OPTIONS).map(([key, type]) => ` --${key.replaceA
|
|
|
65
98
|
if (values[`no-${flag}`]) config[key] = false;
|
|
66
99
|
else if (values[flag] !== undefined) config[key] = type === 'number' ? number(values[flag], flag) : values[flag];
|
|
67
100
|
}
|
|
101
|
+
let jobId = command === 'download' ? input : undefined;
|
|
68
102
|
const controller = new AbortController();
|
|
69
103
|
const cancel = () => controller.abort();
|
|
70
104
|
const settings = {
|
|
71
105
|
signal: controller.signal, overwrite: values.overwrite, ffmpeg: !values['no-ffmpeg'],
|
|
106
|
+
format, speakers: values.speakers,
|
|
72
107
|
language: values.language, filename: values.filename, config,
|
|
73
108
|
duration_seconds: values['duration-seconds'] === undefined ? undefined : number(values['duration-seconds'], 'duration-seconds'),
|
|
74
|
-
onProgress: ({ stage, job_id, state, message }) =>
|
|
109
|
+
onProgress: ({ stage, job_id, state, message }) => {
|
|
110
|
+
if (job_id) jobId = job_id;
|
|
111
|
+
if (verbose) console.error([stage, job_id, state, message].filter(Boolean).join(': '));
|
|
112
|
+
},
|
|
75
113
|
};
|
|
76
114
|
process.once('SIGINT', cancel);
|
|
77
115
|
process.once('SIGTERM', cancel);
|
|
78
116
|
try {
|
|
79
117
|
const client = new LomaClient({ endpoint: values.endpoint });
|
|
80
|
-
|
|
81
|
-
|
|
118
|
+
// stdout carries exactly the result (txt has no trailing newline); progress stays on stderr.
|
|
119
|
+
const print = text => new Promise((done, fail) => process.stdout.write(text, error => error ? fail(error) : done()));
|
|
120
|
+
if (command === 'download') {
|
|
121
|
+
if (stdout) await print(await client.readResult(input, settings));
|
|
122
|
+
else {
|
|
123
|
+
const saved = format === 'txt' ? await client.downloadText(input, output, settings) : await client.downloadSrt(input, output, settings);
|
|
124
|
+
if (verbose) console.log(saved);
|
|
125
|
+
}
|
|
126
|
+
} else {
|
|
82
127
|
const result = await client.transcribeFile(input, { ...settings, output });
|
|
128
|
+
const text = stdout ? await client.resultText(result, settings) : undefined;
|
|
83
129
|
const { content_duration_seconds, total_seconds, speed_x } = result.timing;
|
|
84
|
-
const
|
|
85
|
-
|
|
86
|
-
console.
|
|
130
|
+
const show = (value, unit) => value === null ? 'unknown' : `${value.toFixed(2)}${unit}`;
|
|
131
|
+
// Before the text: a txt result ends without a newline, so nothing may follow it.
|
|
132
|
+
if (verbose) console.error(`Content: ${show(content_duration_seconds, 's')} | Total: ${show(total_seconds, 's')} | Speed: ${show(speed_x, 'x realtime')}`);
|
|
133
|
+
if (stdout) await print(text);
|
|
134
|
+
else if (verbose) console.log(result.output_path);
|
|
87
135
|
}
|
|
136
|
+
} catch (error) {
|
|
137
|
+
// Quiet mode never printed the job id, so a cancel or failure names it for `download`.
|
|
138
|
+
error.jobId = jobId;
|
|
139
|
+
if (jobId && !verbose && error.name !== 'AbortError' && !error.message.includes(jobId)) error.message = `${error.message} (job ${jobId})`;
|
|
140
|
+
throw error;
|
|
88
141
|
} finally {
|
|
89
142
|
process.removeListener('SIGINT', cancel);
|
|
90
143
|
process.removeListener('SIGTERM', cancel);
|
|
@@ -92,6 +145,8 @@ ${Object.entries(TRANSCRIPTION_OPTIONS).map(([key, type]) => ` --${key.replaceA
|
|
|
92
145
|
}
|
|
93
146
|
|
|
94
147
|
main().catch(error => {
|
|
95
|
-
console.error(error.name === 'AbortError'
|
|
148
|
+
console.error(error.name === 'AbortError'
|
|
149
|
+
? `Cancelled. An already-submitted job continues; resume with lomasub download ${error.jobId ?? '<job-id>'} --output <path>.`
|
|
150
|
+
: error.message);
|
|
96
151
|
process.exitCode = error.name === 'AbortError' ? 130 : 1;
|
|
97
152
|
});
|
package/index.d.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
export interface TranscriptionConfig {
|
|
2
|
+
promodel?: 'pro' | 'translate-th' | 'translate-en' | 'translate-ja' | 'translate-ko' | 'translate-zh' | 'translate-jp' | 'translate-kr' | null;
|
|
2
3
|
forcealigner_level?: 'default' | 'llmsplitting' | 'none';
|
|
3
4
|
min_vad_duration_seconds?: number;
|
|
4
5
|
max_vad_duration_seconds?: number;
|
|
@@ -12,9 +13,6 @@ export interface TranscriptionConfig {
|
|
|
12
13
|
speaker_clustering_drop_dialogue_percentile?: number;
|
|
13
14
|
agglomeration_min_appear?: number;
|
|
14
15
|
agglomolation_group_size?: number;
|
|
15
|
-
revise_srt?: boolean;
|
|
16
|
-
revise_speakername?: boolean;
|
|
17
|
-
srt_revision_backend?: 'agy' | 'codex';
|
|
18
16
|
[key: string]: unknown;
|
|
19
17
|
}
|
|
20
18
|
|
|
@@ -31,7 +29,16 @@ export interface WaitOptions {
|
|
|
31
29
|
pollIntervalMs?: number;
|
|
32
30
|
}
|
|
33
31
|
|
|
34
|
-
export
|
|
32
|
+
export type ResultFormat = 'srt' | 'txt';
|
|
33
|
+
|
|
34
|
+
export interface ResultOptions {
|
|
35
|
+
/** `srt` (default) or `txt`: every cue joined by one space, on a single line with no newline. */
|
|
36
|
+
format?: ResultFormat;
|
|
37
|
+
/** `txt` only: keep the diarization labels (`A: ...`). Default false. */
|
|
38
|
+
speakers?: boolean;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export interface TranscribeOptions extends WaitOptions, ResultOptions {
|
|
35
42
|
output?: string;
|
|
36
43
|
overwrite?: boolean;
|
|
37
44
|
ffmpeg?: boolean;
|
|
@@ -70,6 +77,7 @@ export interface TranscriptionResult extends JobStatus {
|
|
|
70
77
|
};
|
|
71
78
|
}
|
|
72
79
|
|
|
80
|
+
export const RESULT_FORMATS: readonly ResultFormat[];
|
|
73
81
|
export const TRANSCRIPTION_OPTIONS: Readonly<Record<string, 'string' | 'number' | 'boolean'>>;
|
|
74
82
|
|
|
75
83
|
export class LomaClient {
|
|
@@ -79,4 +87,8 @@ export class LomaClient {
|
|
|
79
87
|
waitForJob(jobId: string, options?: WaitOptions): Promise<JobStatus>;
|
|
80
88
|
transcribeFile(file: string, options?: TranscribeOptions): Promise<TranscriptionResult>;
|
|
81
89
|
downloadSrt(jobId: string, output: string, options?: WaitOptions & { overwrite?: boolean }): Promise<string>;
|
|
90
|
+
downloadText(jobId: string, output: string, options?: WaitOptions & { overwrite?: boolean; speakers?: boolean }): Promise<string>;
|
|
91
|
+
getText(jobId: string, options?: { signal?: AbortSignal; speakers?: boolean }): Promise<string>;
|
|
92
|
+
readResult(jobId: string, options?: WaitOptions & ResultOptions): Promise<string>;
|
|
93
|
+
resultText(status: JobStatus, options?: ResultOptions & Pick<WaitOptions, 'signal' | 'onProgress'>): Promise<string>;
|
|
82
94
|
}
|
package/index.js
CHANGED
|
@@ -13,6 +13,7 @@ const run = promisify(execFile);
|
|
|
13
13
|
// Shared with the CLI; validation and transcription defaults belong to the API.
|
|
14
14
|
export const TRANSCRIPTION_OPTIONS = {
|
|
15
15
|
forcealigner_level: 'string',
|
|
16
|
+
promodel: 'string',
|
|
16
17
|
min_vad_duration_seconds: 'number',
|
|
17
18
|
max_vad_duration_seconds: 'number',
|
|
18
19
|
vad_threshold: 'number',
|
|
@@ -25,9 +26,6 @@ export const TRANSCRIPTION_OPTIONS = {
|
|
|
25
26
|
speaker_clustering_drop_dialogue_percentile: 'number',
|
|
26
27
|
agglomeration_min_appear: 'number',
|
|
27
28
|
agglomolation_group_size: 'number',
|
|
28
|
-
revise_srt: 'boolean',
|
|
29
|
-
revise_speakername: 'boolean',
|
|
30
|
-
srt_revision_backend: 'string',
|
|
31
29
|
};
|
|
32
30
|
|
|
33
31
|
const MIME_TYPES = {
|
|
@@ -50,6 +48,13 @@ async function checkedFetch(url, options, stage) {
|
|
|
50
48
|
return response;
|
|
51
49
|
}
|
|
52
50
|
|
|
51
|
+
export const RESULT_FORMATS = ['srt', 'txt'];
|
|
52
|
+
|
|
53
|
+
function resultFormat(format = 'srt') {
|
|
54
|
+
if (!RESULT_FORMATS.includes(format)) throw new Error(`format must be one of: ${RESULT_FORMATS.join(', ')}.`);
|
|
55
|
+
return format;
|
|
56
|
+
}
|
|
57
|
+
|
|
53
58
|
export class LomaClient {
|
|
54
59
|
constructor({ apiKey = process.env.LOMA_API_SECRET_KEY, endpoint = 'https://api.lomasub.ai' } = {}) {
|
|
55
60
|
if (!apiKey) throw new Error('Set LOMA_API_SECRET_KEY or provide apiKey.');
|
|
@@ -69,6 +74,9 @@ export class LomaClient {
|
|
|
69
74
|
async submitFile(file, options = {}) {
|
|
70
75
|
const { signal, onProgress = () => {}, ffmpeg = true, config, language, duration_seconds } = options;
|
|
71
76
|
signal?.throwIfAborted();
|
|
77
|
+
for (const key of ['revise_srt', 'revise_speakername', 'srt_revision_backend']) {
|
|
78
|
+
if (config && Object.hasOwn(config, key)) throw new Error(`config.${key} is not supported by the CLI/SDK.`);
|
|
79
|
+
}
|
|
72
80
|
const original = resolve(file);
|
|
73
81
|
if (!(await stat(original)).isFile()) throw new Error(`Not a regular file: ${file}`);
|
|
74
82
|
let source = original;
|
|
@@ -88,10 +96,12 @@ export class LomaClient {
|
|
|
88
96
|
}
|
|
89
97
|
}
|
|
90
98
|
signal?.throwIfAborted();
|
|
99
|
+
const size_bytes = (await stat(source)).size;
|
|
91
100
|
const upload = await this.request('/presigned-url', {
|
|
92
101
|
filename: basename(source),
|
|
93
102
|
content_type: MIME_TYPES[extname(source).toLowerCase()] ?? 'application/octet-stream',
|
|
94
103
|
duration_seconds,
|
|
104
|
+
size_bytes,
|
|
95
105
|
}, signal);
|
|
96
106
|
if (upload.method !== 'PUT' || !upload.upload_url || !upload.source_url || !upload.headers) {
|
|
97
107
|
throw new Error('Invalid /presigned-url response.');
|
|
@@ -100,7 +110,7 @@ export class LomaClient {
|
|
|
100
110
|
const stream = createReadStream(source);
|
|
101
111
|
try {
|
|
102
112
|
const response = await checkedFetch(upload.upload_url, {
|
|
103
|
-
method: upload.method, headers: { ...upload.headers, 'Content-Length': String(
|
|
113
|
+
method: upload.method, headers: { ...upload.headers, 'Content-Length': String(size_bytes) },
|
|
104
114
|
body: stream, duplex: 'half', signal,
|
|
105
115
|
}, 'Upload');
|
|
106
116
|
await response.arrayBuffer();
|
|
@@ -135,6 +145,7 @@ export class LomaClient {
|
|
|
135
145
|
|
|
136
146
|
async transcribeFile(file, options = {}) {
|
|
137
147
|
const started = performance.now();
|
|
148
|
+
resultFormat(options.format);
|
|
138
149
|
if (options.output !== undefined && !options.overwrite) {
|
|
139
150
|
try {
|
|
140
151
|
await lstat(options.output);
|
|
@@ -143,7 +154,7 @@ export class LomaClient {
|
|
|
143
154
|
}
|
|
144
155
|
const job = await this.submitFile(file, options);
|
|
145
156
|
const status = await this.waitForJob(job.job_id, options);
|
|
146
|
-
const output_path = options.output === undefined ? undefined : await this.#
|
|
157
|
+
const output_path = options.output === undefined ? undefined : await this.#save(status, options.output, options);
|
|
147
158
|
const total_seconds = (performance.now() - started) / 1000;
|
|
148
159
|
const content_duration_seconds = [status.audio_duration_seconds, options.duration_seconds]
|
|
149
160
|
.find(value => Number.isFinite(value) && value >= 0) ?? null;
|
|
@@ -155,18 +166,62 @@ export class LomaClient {
|
|
|
155
166
|
|
|
156
167
|
async downloadSrt(jobId, output, options = {}) {
|
|
157
168
|
const status = await this.waitForJob(jobId, options);
|
|
158
|
-
return this.#
|
|
169
|
+
return this.#save(status, output, { ...options, format: 'srt' });
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
async downloadText(jobId, output, options = {}) {
|
|
173
|
+
const status = await this.waitForJob(jobId, options);
|
|
174
|
+
return this.#save(status, output, { ...options, format: 'txt' });
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// The current transcript of a READY job as one line: cues joined by a space, no newline.
|
|
178
|
+
async getText(jobId, { signal, speakers = false } = {}) {
|
|
179
|
+
return (await this.#fetchResult({ job_id: jobId }, 'txt', { signal, speakers })).text();
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
// Wait for the job, then return its result as a string (`format`: 'srt' or 'txt').
|
|
183
|
+
async readResult(jobId, options = {}) {
|
|
184
|
+
resultFormat(options.format);
|
|
185
|
+
return this.resultText(await this.waitForJob(jobId, options), options);
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
// The result of an already READY status (e.g. what transcribeFile returned) as a string.
|
|
189
|
+
async resultText(status, { format, speakers, signal, onProgress = () => {} } = {}) {
|
|
190
|
+
format = resultFormat(format);
|
|
191
|
+
onProgress({ stage: 'downloading', job_id: status.job_id });
|
|
192
|
+
return (await this.#fetchResult(status, format, { signal, speakers })).text();
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
async #fetchResult(status, format, { signal, speakers = false }) {
|
|
196
|
+
const jobId = status.job_id;
|
|
197
|
+
if (format === 'srt') {
|
|
198
|
+
if (!status.srt_url) throw new Error(`Job ${jobId} has no SRT URL.`);
|
|
199
|
+
return checkedFetch(status.srt_url, { signal }, 'Download');
|
|
200
|
+
}
|
|
201
|
+
// Text is derived by the API (it follows editor saves), so it needs the bearer key.
|
|
202
|
+
const query = speakers ? '?speakers=true' : '';
|
|
203
|
+
try {
|
|
204
|
+
return await checkedFetch(`${this.endpoint}/jobs/${encodeURIComponent(jobId)}.txt${query}`, {
|
|
205
|
+
signal, headers: { Authorization: `Bearer ${this.apiKey}` },
|
|
206
|
+
}, 'Download');
|
|
207
|
+
} catch (error) {
|
|
208
|
+
if (error.status === 404) {
|
|
209
|
+
error.message = `Job ${jobId} has no text transcript: it is not READY, not yours, or the server does not support .txt yet.`;
|
|
210
|
+
}
|
|
211
|
+
throw error;
|
|
212
|
+
}
|
|
159
213
|
}
|
|
160
214
|
|
|
161
|
-
async #
|
|
215
|
+
async #save(status, output, { signal, overwrite = false, onProgress = () => {}, format, speakers }) {
|
|
216
|
+
format = resultFormat(format);
|
|
162
217
|
const jobId = status.job_id;
|
|
163
|
-
if (!status.srt_url) throw new Error(`Job ${jobId} has no SRT URL.`);
|
|
218
|
+
if (format === 'srt' && !status.srt_url) throw new Error(`Job ${jobId} has no SRT URL.`);
|
|
164
219
|
const target = resolve(output);
|
|
165
220
|
const temporary = await mkdtemp(join(dirname(target), '.lomasub-download-'));
|
|
166
|
-
const partial = join(temporary,
|
|
221
|
+
const partial = join(temporary, `result.${format}`);
|
|
167
222
|
try {
|
|
168
223
|
onProgress({ stage: 'downloading', job_id: jobId });
|
|
169
|
-
const response = await
|
|
224
|
+
const response = await this.#fetchResult(status, format, { signal, speakers });
|
|
170
225
|
await pipeline(Readable.fromWeb(response.body), createWriteStream(partial), { signal });
|
|
171
226
|
signal?.throwIfAborted();
|
|
172
227
|
// link fails atomically if the destination exists; rename replaces it only when requested.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "lomasub",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
4
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
5
|
"type": "module",
|
|
6
6
|
"engines": { "node": ">=20" },
|