lomasub 0.1.1 → 0.1.3
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 +20 -6
- package/cli.js +34 -7
- package/index.d.ts +14 -4
- package/index.js +29 -7
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -20,7 +20,7 @@ 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
26
|
lomasub --help
|
|
@@ -42,7 +42,7 @@ When ffmpeg is on `PATH`, audio is extracted into a temporary Matroska audio fil
|
|
|
42
42
|
}
|
|
43
43
|
```
|
|
44
44
|
|
|
45
|
-
|
|
45
|
+
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
46
|
|
|
47
47
|
| Settings | CLI flags |
|
|
48
48
|
| --- | --- |
|
|
@@ -50,10 +50,11 @@ Every current transcription config field has a kebab-case flag. Boolean fields s
|
|
|
50
50
|
| VAD | `--min-vad-duration-seconds`, `--max-vad-duration-seconds`, `--vad-threshold` |
|
|
51
51
|
| Subtitle cues | `--min-srt-duration-ms`, `--srt-max-chars`, `--srt-cue-words` |
|
|
52
52
|
| 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
53
|
|
|
55
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.
|
|
56
55
|
|
|
56
|
+
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.
|
|
57
|
+
|
|
57
58
|
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
59
|
|
|
59
60
|
## SDK
|
|
@@ -63,25 +64,38 @@ import { LomaClient } from 'lomasub';
|
|
|
63
64
|
|
|
64
65
|
const client = new LomaClient(); // LOMA_API_SECRET_KEY; or { apiKey, endpoint }
|
|
65
66
|
const result = await client.transcribeFile('./video.mp4', {
|
|
67
|
+
output: './video.srt',
|
|
66
68
|
language: 'th',
|
|
67
69
|
config: { speaker_diarization: true, srt_max_chars: 48 },
|
|
68
70
|
onProgress: event => console.error(event),
|
|
69
71
|
});
|
|
70
|
-
|
|
72
|
+
console.log(result.output_path, result.timing);
|
|
71
73
|
```
|
|
72
74
|
|
|
73
|
-
|
|
75
|
+
Results include API job data and whichever artifact URLs the server produced. `transcribeFile()` also returns timing and, when saved, `output_path`.
|
|
74
76
|
|
|
75
77
|
| Method | Behavior |
|
|
76
78
|
| --- | --- |
|
|
77
79
|
| `submitFile(file, options?)` | Extract/upload and return the newly created job |
|
|
78
80
|
| `getStatus(jobId, { signal }?)` | Read current status and fresh download URLs |
|
|
79
81
|
| `waitForJob(jobId, options?)` | Poll until READY; throw with the failed stage on FAILED |
|
|
80
|
-
| `transcribeFile(file, options?)` | Submit and
|
|
82
|
+
| `transcribeFile(file, options?)` | Submit, wait, optionally save SRT, and return timing |
|
|
81
83
|
| `downloadSrt(jobId, output, options?)` | Wait if needed, download original SRT, return absolute saved path |
|
|
82
84
|
|
|
83
85
|
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.
|
|
84
86
|
|
|
87
|
+
`transcribeFile()` additionally accepts `output` (SRT path) and `overwrite` (default `false`). Existing output files are rejected before uploading unless overwrite is enabled.
|
|
88
|
+
|
|
89
|
+
After successful CLI transcription, stderr includes a final summary:
|
|
90
|
+
|
|
91
|
+
```text
|
|
92
|
+
Content: 600.00s | Total: 60.00s | Speed: 10.00x realtime
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
The SDK returns `timing: { content_duration_seconds, total_seconds, speed_x }`. Total time uses a monotonic clock and includes extraction, upload, queueing, processing, and saving the SRT when `output` is provided. Without `output`, timing ends at READY. Speed is content duration divided by total time: 10x means ten seconds of content per second elapsed. SDK numbers retain full precision; CLI values show two decimal places.
|
|
96
|
+
|
|
97
|
+
Content duration comes from the worker's measured `audio_duration_seconds`, falling back to your explicit `duration_seconds` hint. Missing duration/speed is `null` in the SDK and `unknown` in the CLI; zero duration is valid. Failures and cancellation produce no success summary. Standalone `download` does not report whole-run timing because it cannot measure the earlier local steps.
|
|
98
|
+
|
|
85
99
|
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.
|
|
86
100
|
|
|
87
101
|
This package covers transcription and original results only. Account/login, job-history management, subtitle editing, and edited-subtitle uploads are outside its scope.
|
package/cli.js
CHANGED
|
@@ -17,6 +17,24 @@ for (const [key, type] of Object.entries(TRANSCRIPTION_OPTIONS)) {
|
|
|
17
17
|
if (type === 'boolean') options[`no-${flag}`] = { type: 'boolean' };
|
|
18
18
|
}
|
|
19
19
|
|
|
20
|
+
// Display-only production defaults; omitted options still use the API's settings.
|
|
21
|
+
const DEFAULT_HELP = {
|
|
22
|
+
forcealigner_level: 'default; choices: default, llmsplitting, none',
|
|
23
|
+
promodel: 'disabled (omitted/null); use pro to enable word correction',
|
|
24
|
+
min_vad_duration_seconds: '10',
|
|
25
|
+
max_vad_duration_seconds: '25',
|
|
26
|
+
vad_threshold: '0.30',
|
|
27
|
+
min_srt_duration_ms: '1200',
|
|
28
|
+
srt_max_chars: '48',
|
|
29
|
+
srt_cue_words: '6',
|
|
30
|
+
speaker_diarization: 'disabled',
|
|
31
|
+
speaker_local_distance_threshold: '0.60',
|
|
32
|
+
speaker_merge_distance_threshold: '0.35',
|
|
33
|
+
speaker_clustering_drop_dialogue_percentile: '0.02',
|
|
34
|
+
agglomeration_min_appear: '5',
|
|
35
|
+
agglomolation_group_size: '300',
|
|
36
|
+
};
|
|
37
|
+
|
|
20
38
|
function number(value, flag) {
|
|
21
39
|
const parsed = Number(value);
|
|
22
40
|
if (!value.trim() || !Number.isFinite(parsed)) throw new Error(`--${flag} requires a number.`);
|
|
@@ -32,17 +50,20 @@ async function main() {
|
|
|
32
50
|
|
|
33
51
|
Set LOMA_API_SECRET_KEY before running. Default endpoint: https://api.lomasub.ai
|
|
34
52
|
Options:
|
|
35
|
-
--endpoint <url> API endpoint
|
|
36
53
|
--output, -o <path> Output SRT (default: beside input)
|
|
37
54
|
--overwrite Replace existing output
|
|
38
55
|
--no-ffmpeg Upload original without audio extraction
|
|
39
|
-
--language <code>
|
|
56
|
+
--language <code> ASR language hint: th, en; server default: th
|
|
40
57
|
--filename <name> Job display filename
|
|
41
|
-
--duration-seconds N Optional media duration hint
|
|
42
58
|
--config <path> JSON transcription config; explicit flags override it
|
|
43
59
|
|
|
44
|
-
Transcription options
|
|
45
|
-
|
|
60
|
+
Transcription options:
|
|
61
|
+
Defaults shown are for api.lomasub.ai; omitted options use server settings.
|
|
62
|
+
${Object.entries(TRANSCRIPTION_OPTIONS).map(([key, type]) => {
|
|
63
|
+
const flag = key.replaceAll('_', '-');
|
|
64
|
+
const label = `--${flag}${type === 'boolean' ? ` / --no-${flag}` : ` <${type}>`}`;
|
|
65
|
+
return ` ${label.padEnd(56)}default: ${DEFAULT_HELP[key]}`;
|
|
66
|
+
}).join('\n')}`);
|
|
46
67
|
return;
|
|
47
68
|
}
|
|
48
69
|
const [command, input] = positionals;
|
|
@@ -77,8 +98,14 @@ ${Object.entries(TRANSCRIPTION_OPTIONS).map(([key, type]) => ` --${key.replaceA
|
|
|
77
98
|
process.once('SIGTERM', cancel);
|
|
78
99
|
try {
|
|
79
100
|
const client = new LomaClient({ endpoint: values.endpoint });
|
|
80
|
-
|
|
81
|
-
|
|
101
|
+
if (command === 'download') console.log(await client.downloadSrt(input, output, settings));
|
|
102
|
+
else {
|
|
103
|
+
const result = await client.transcribeFile(input, { ...settings, output });
|
|
104
|
+
const { content_duration_seconds, total_seconds, speed_x } = result.timing;
|
|
105
|
+
const format = (value, unit) => value === null ? 'unknown' : `${value.toFixed(2)}${unit}`;
|
|
106
|
+
console.error(`Content: ${format(content_duration_seconds, 's')} | Total: ${format(total_seconds, 's')} | Speed: ${format(speed_x, 'x realtime')}`);
|
|
107
|
+
console.log(result.output_path);
|
|
108
|
+
}
|
|
82
109
|
} finally {
|
|
83
110
|
process.removeListener('SIGINT', cancel);
|
|
84
111
|
process.removeListener('SIGTERM', cancel);
|
package/index.d.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
export interface TranscriptionConfig {
|
|
2
|
+
promodel?: 'pro' | 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
|
|
|
@@ -32,6 +30,8 @@ export interface WaitOptions {
|
|
|
32
30
|
}
|
|
33
31
|
|
|
34
32
|
export interface TranscribeOptions extends WaitOptions {
|
|
33
|
+
output?: string;
|
|
34
|
+
overwrite?: boolean;
|
|
35
35
|
ffmpeg?: boolean;
|
|
36
36
|
config?: TranscriptionConfig;
|
|
37
37
|
filename?: string;
|
|
@@ -47,6 +47,7 @@ export interface Job {
|
|
|
47
47
|
}
|
|
48
48
|
|
|
49
49
|
export interface JobStatus extends Job {
|
|
50
|
+
audio_duration_seconds?: number | null;
|
|
50
51
|
config?: TranscriptionConfig;
|
|
51
52
|
failed_stage?: string | null;
|
|
52
53
|
error?: string | null;
|
|
@@ -58,6 +59,15 @@ export interface JobStatus extends Job {
|
|
|
58
59
|
debug_vtt_url?: string;
|
|
59
60
|
}
|
|
60
61
|
|
|
62
|
+
export interface TranscriptionResult extends JobStatus {
|
|
63
|
+
output_path?: string;
|
|
64
|
+
timing: {
|
|
65
|
+
content_duration_seconds: number | null;
|
|
66
|
+
total_seconds: number;
|
|
67
|
+
speed_x: number | null;
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
|
|
61
71
|
export const TRANSCRIPTION_OPTIONS: Readonly<Record<string, 'string' | 'number' | 'boolean'>>;
|
|
62
72
|
|
|
63
73
|
export class LomaClient {
|
|
@@ -65,6 +75,6 @@ export class LomaClient {
|
|
|
65
75
|
submitFile(file: string, options?: TranscribeOptions): Promise<Job>;
|
|
66
76
|
getStatus(jobId: string, options?: Pick<WaitOptions, 'signal'>): Promise<JobStatus>;
|
|
67
77
|
waitForJob(jobId: string, options?: WaitOptions): Promise<JobStatus>;
|
|
68
|
-
transcribeFile(file: string, options?: TranscribeOptions): Promise<
|
|
78
|
+
transcribeFile(file: string, options?: TranscribeOptions): Promise<TranscriptionResult>;
|
|
69
79
|
downloadSrt(jobId: string, output: string, options?: WaitOptions & { overwrite?: boolean }): Promise<string>;
|
|
70
80
|
}
|
package/index.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { execFile } from 'node:child_process';
|
|
2
2
|
import { createReadStream, createWriteStream } from 'node:fs';
|
|
3
|
-
import { link, mkdtemp, rename, rm, stat } from 'node:fs/promises';
|
|
3
|
+
import { link, lstat, mkdtemp, rename, rm, stat } from 'node:fs/promises';
|
|
4
4
|
import { tmpdir } from 'node:os';
|
|
5
5
|
import { basename, dirname, extname, join, resolve } from 'node:path';
|
|
6
6
|
import { Readable } from 'node:stream';
|
|
@@ -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 = {
|
|
@@ -69,6 +67,9 @@ export class LomaClient {
|
|
|
69
67
|
async submitFile(file, options = {}) {
|
|
70
68
|
const { signal, onProgress = () => {}, ffmpeg = true, config, language, duration_seconds } = options;
|
|
71
69
|
signal?.throwIfAborted();
|
|
70
|
+
for (const key of ['revise_srt', 'revise_speakername', 'srt_revision_backend']) {
|
|
71
|
+
if (config && Object.hasOwn(config, key)) throw new Error(`config.${key} is not supported by the CLI/SDK.`);
|
|
72
|
+
}
|
|
72
73
|
const original = resolve(file);
|
|
73
74
|
if (!(await stat(original)).isFile()) throw new Error(`Not a regular file: ${file}`);
|
|
74
75
|
let source = original;
|
|
@@ -88,10 +89,12 @@ export class LomaClient {
|
|
|
88
89
|
}
|
|
89
90
|
}
|
|
90
91
|
signal?.throwIfAborted();
|
|
92
|
+
const size_bytes = (await stat(source)).size;
|
|
91
93
|
const upload = await this.request('/presigned-url', {
|
|
92
94
|
filename: basename(source),
|
|
93
95
|
content_type: MIME_TYPES[extname(source).toLowerCase()] ?? 'application/octet-stream',
|
|
94
96
|
duration_seconds,
|
|
97
|
+
size_bytes,
|
|
95
98
|
}, signal);
|
|
96
99
|
if (upload.method !== 'PUT' || !upload.upload_url || !upload.source_url || !upload.headers) {
|
|
97
100
|
throw new Error('Invalid /presigned-url response.');
|
|
@@ -100,7 +103,7 @@ export class LomaClient {
|
|
|
100
103
|
const stream = createReadStream(source);
|
|
101
104
|
try {
|
|
102
105
|
const response = await checkedFetch(upload.upload_url, {
|
|
103
|
-
method: upload.method, headers: { ...upload.headers, 'Content-Length': String(
|
|
106
|
+
method: upload.method, headers: { ...upload.headers, 'Content-Length': String(size_bytes) },
|
|
104
107
|
body: stream, duplex: 'half', signal,
|
|
105
108
|
}, 'Upload');
|
|
106
109
|
await response.arrayBuffer();
|
|
@@ -134,13 +137,32 @@ export class LomaClient {
|
|
|
134
137
|
}
|
|
135
138
|
|
|
136
139
|
async transcribeFile(file, options = {}) {
|
|
140
|
+
const started = performance.now();
|
|
141
|
+
if (options.output !== undefined && !options.overwrite) {
|
|
142
|
+
try {
|
|
143
|
+
await lstat(options.output);
|
|
144
|
+
throw new Error(`Output exists: ${options.output}. Set overwrite: true to replace it.`);
|
|
145
|
+
} catch (error) { if (error.code !== 'ENOENT') throw error; }
|
|
146
|
+
}
|
|
137
147
|
const job = await this.submitFile(file, options);
|
|
138
|
-
|
|
148
|
+
const status = await this.waitForJob(job.job_id, options);
|
|
149
|
+
const output_path = options.output === undefined ? undefined : await this.#saveSrt(status, options.output, options);
|
|
150
|
+
const total_seconds = (performance.now() - started) / 1000;
|
|
151
|
+
const content_duration_seconds = [status.audio_duration_seconds, options.duration_seconds]
|
|
152
|
+
.find(value => Number.isFinite(value) && value >= 0) ?? null;
|
|
153
|
+
const speed = content_duration_seconds === null ? null : content_duration_seconds / total_seconds;
|
|
154
|
+
return { ...status, ...(output_path === undefined ? {} : { output_path }), timing: {
|
|
155
|
+
content_duration_seconds, total_seconds, speed_x: Number.isFinite(speed) ? speed : null,
|
|
156
|
+
} };
|
|
139
157
|
}
|
|
140
158
|
|
|
141
159
|
async downloadSrt(jobId, output, options = {}) {
|
|
142
|
-
const { signal, overwrite = false, onProgress = () => {} } = options;
|
|
143
160
|
const status = await this.waitForJob(jobId, options);
|
|
161
|
+
return this.#saveSrt(status, output, options);
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
async #saveSrt(status, output, { signal, overwrite = false, onProgress = () => {} }) {
|
|
165
|
+
const jobId = status.job_id;
|
|
144
166
|
if (!status.srt_url) throw new Error(`Job ${jobId} has no SRT URL.`);
|
|
145
167
|
const target = resolve(output);
|
|
146
168
|
const temporary = await mkdtemp(join(dirname(target), '.lomasub-download-'));
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "lomasub",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.3",
|
|
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" },
|