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 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 --no-revise-srt
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
- 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
+ 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
- await client.downloadSrt(result.job_id, './video.srt');
72
+ console.log(result.output_path, result.timing);
71
73
  ```
72
74
 
73
- Methods return plain API data; result URLs include whichever artifacts the server produced.
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 wait |
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> Audio language
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 (unset values use server defaults):
45
- ${Object.entries(TRANSCRIPTION_OPTIONS).map(([key, type]) => ` --${key.replaceAll('_', '-')}${type === 'boolean' ? ' / --no-' + key.replaceAll('_', '-') : ' <' + type + '>'}`).join('\n')}`);
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
- const jobId = command === 'download' ? input : (await client.submitFile(input, settings)).job_id;
81
- console.log(await client.downloadSrt(jobId, output, settings));
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<JobStatus>;
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((await stat(source)).size) },
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
- return this.waitForJob(job.job_id, options);
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.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" },