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.
- package/README.md +97 -0
- package/cli.js +91 -0
- package/index.d.ts +70 -0
- package/index.js +159 -0
- 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
|
+
}
|