harmar-ai 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 ADDED
@@ -0,0 +1,106 @@
1
+ # harmar-ai
2
+
3
+ Command line, MCP server and typed client for the [Harmar](https://harmar.ai)
4
+ subtitle API: word-timed transcripts, SRT/VTT, and **captioned videos** —
5
+ styled subtitles burned into the MP4, no watermark. Built for Armenian and
6
+ for speech that switches between Armenian, Russian and English
7
+ mid-sentence; 50+ other languages served through the same endpoint.
8
+
9
+ ```bash
10
+ export HARMAR_API_KEY=hk_live_… # https://harmar.ai/app/api — 10 free minutes
11
+ npx -y harmar-ai transcribe video.mp4 --lang auto --srt --out video.srt
12
+
13
+ # a captioned video in a saved style
14
+ npx -y harmar-ai save-style "Brand" --style '{"preset":"pill","font":"montserrat","accentColor":"#D4F25A"}'
15
+ npx -y harmar-ai transcribe reel.mp4 --lang auto --export --preset <id> --out reel-captioned.mp4
16
+ ```
17
+
18
+ ## CLI
19
+
20
+ ```
21
+ harmar transcribe <file> [--lang auto|hy|ru|en|…] [--translate-to xx]
22
+ [--srt|--vtt|--text] [--out path] [--no-wait]
23
+ harmar transcribe <file> --export (--preset <id> | --style-file s.json) --out captioned.mp4
24
+ harmar export <id> (--preset <id> | --style '{…}' | --style-file s.json) [--track xx] [--platform x] [--out f.mp4]
25
+ harmar export-status <id> · styles · presets · save-style <name> · delete-style <id>
26
+ harmar status <id> · srt <id> · vtt <id> · delete <id>
27
+ harmar languages · balance · usage · pricing
28
+ harmar mcp
29
+ ```
30
+
31
+ An export needs the transcript to have been made with `--keep-media`
32
+ (`--export` implies it); by default the source is deleted once the
33
+ transcript exists. Exports are charged per second of media at the
34
+ transcription rate and refunded if the render fails. One export at a time
35
+ per account; up to 60 minutes; 1080p.
36
+
37
+ `harmar --help` lists every flag. Progress goes to stderr, the result to
38
+ stdout (or `--out`). Exit codes: `0` ok, `1` API error (with the API's
39
+ error code — `insufficient_credits` carries `seconds_needed`), `2` usage,
40
+ `3` timed out waiting (the job keeps running; `harmar status <id>`).
41
+
42
+ ## MCP server — Claude Code, Claude Desktop, Cursor, Windsurf
43
+
44
+ ```bash
45
+ claude mcp add harmar -e HARMAR_API_KEY=hk_live_… -- npx -y harmar-ai mcp
46
+ ```
47
+
48
+ or in any `mcp.json`:
49
+
50
+ ```json
51
+ {
52
+ "mcpServers": {
53
+ "harmar": {
54
+ "command": "npx",
55
+ "args": ["-y", "harmar-ai", "mcp"],
56
+ "env": { "HARMAR_API_KEY": "hk_live_…" }
57
+ }
58
+ }
59
+ }
60
+ ```
61
+
62
+ Tools: `harmar_transcribe` (with `keep_media`), `harmar_get_transcript`,
63
+ `harmar_get_subtitles`, `harmar_list_styles`, `harmar_save_style`,
64
+ `harmar_list_style_presets`, `harmar_delete_style_preset`, `harmar_export`
65
+ (with `save_to` for a local MP4), `harmar_get_export`, `harmar_languages`,
66
+ `harmar_balance`, `harmar_usage`, `harmar_pricing`,
67
+ `harmar_delete_transcript`. Errors come back as tool results with the API's
68
+ code so the model can act on them.
69
+
70
+ ## Skill — Claude Code and other skill-aware agents
71
+
72
+ `skills/harmar/SKILL.md` teaches an agent the whole flow (CLI first, raw
73
+ HTTP fallback). Copy it into your project's `.claude/skills/harmar/` or
74
+ your agent's skills directory.
75
+
76
+ ## Library
77
+
78
+ ```ts
79
+ import { HarmarClient } from "harmar-ai";
80
+
81
+ const harmar = new HarmarClient(); // reads HARMAR_API_KEY
82
+ const t = await harmar.transcribe("talk.mp4", { sourceLang: "auto", translateTo: "en", keepMedia: true });
83
+ t.words; // [{ text, start, end, speaker? }, …]
84
+ await harmar.subtitles(t.id, "srt", "en"); // the translated track
85
+
86
+ const preset = await harmar.saveStylePreset("Brand", { preset: "pill", font: "montserrat", accentColor: "#D4F25A" });
87
+ await harmar.exportVideo(t.id, { stylePresetId: preset.id, lang: "en", outPath: "talk-en.mp4" });
88
+ ```
89
+
90
+ `transcribe()` = `upload()` + `submit()` + `wait()`; each is public.
91
+ `HarmarError` carries `status`, `code` and `params`.
92
+
93
+ ## API
94
+
95
+ Fifteen routes, documented at https://harmar.ai/developers. Base URL
96
+ `https://api.harmar.ai`, `Authorization: Bearer hk_live_…`. Prepaid credits
97
+ charged per second of media, refunded on failure. Files up to 60 minutes.
98
+
99
+ ## Development
100
+
101
+ ```bash
102
+ npm install && npm run build
103
+ HARMAR_API_URL=http://localhost:3901 HARMAR_API_KEY=… node dist/cli.js languages
104
+ ```
105
+
106
+ MIT.
package/dist/cli.d.ts ADDED
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ export {};
package/dist/cli.js ADDED
@@ -0,0 +1,460 @@
1
+ #!/usr/bin/env node
2
+ // harmar — command-line client for the Harmar subtitle API.
3
+ //
4
+ // harmar transcribe video.mp4 # Armenian, JSON to stdout
5
+ // harmar transcribe video.mp4 --lang auto --srt # identify language, SRT out
6
+ // harmar transcribe talk.mp4 --lang ru --translate-to hy --vtt --out talk.vtt
7
+ // harmar transcribe reel.mp4 --lang auto --export --preset <id> --out reel.mp4
8
+ // harmar status <id> | srt <id> | vtt <id> | delete <id>
9
+ // harmar styles | presets | save-style <name> --style-file s.json | delete-style <id>
10
+ // harmar export <id> --preset <id> --out captioned.mp4 | export-status <id>
11
+ // harmar languages | balance | usage | pricing
12
+ // harmar mcp # MCP server on stdio
13
+ //
14
+ // Auth: HARMAR_API_KEY (or --api-key). Base URL: HARMAR_API_URL (or --api-url).
15
+ // Exit codes: 0 ok · 1 API error · 2 usage error · 3 timed out (job still running).
16
+ import { parseArgs } from "node:util";
17
+ import { writeFile } from "node:fs/promises";
18
+ import { HarmarClient, HarmarError } from "./client.js";
19
+ const USAGE = `harmar — Harmar subtitle API (https://harmar.ai/developers)
20
+
21
+ Usage
22
+ harmar transcribe <file> [options] upload, transcribe, print the result
23
+ harmar status <id> job status / full transcript as JSON
24
+ harmar srt <id> [--lang xx] SRT for a finished job
25
+ harmar vtt <id> [--lang xx] VTT for a finished job
26
+ harmar delete <id> purge transcript + media
27
+ harmar export <id> [style options] burn a styled MP4 (needs --keep-media at transcribe)
28
+ harmar export-status <id> export state; prints the download URL when done
29
+ harmar styles the style catalog: presets, fonts per language, fields
30
+ harmar presets this account's saved styles
31
+ harmar save-style <name> --style-file f.json | --style '{…}'
32
+ harmar delete-style <id>
33
+ harmar languages languages source_lang / translate_to accept
34
+ harmar balance prepaid seconds left
35
+ harmar usage last 100 ledger entries
36
+ harmar pricing live per-minute rates and packs
37
+ harmar mcp run the MCP server on stdio (for Claude Code, Cursor, …)
38
+
39
+ transcribe options
40
+ --lang <code|auto> language of the speech (default hy; "auto" identifies it)
41
+ --translate-to <code> add a translated track, no surcharge
42
+ --script <file> align this exact text instead of transcribing
43
+ --webhook <https url> notify on completion
44
+ --timestamps word|segment|none (default word)
45
+ --no-punctuation strip punctuation
46
+ --no-speakers drop dialogue dashes and speaker ids
47
+ --lyrics include keep sung lines (default exclude)
48
+ --srt | --vtt | --text output format (default: JSON)
49
+ --out <path> write the output to a file instead of stdout
50
+ --keep-media keep the source so the job can be exported later
51
+ --export after transcribing, export a styled MP4 (implies --keep-media;
52
+ needs --preset or --style/--style-file; --out names the MP4)
53
+ --no-wait submit and print the id; poll with "harmar status"
54
+ --timeout <seconds> stop waiting after this long (default 1800)
55
+ --quiet no progress on stderr
56
+
57
+ style options (export, transcribe --export)
58
+ --preset <id> a saved style (harmar presets)
59
+ --style '<json>' an inline style object (see harmar styles)
60
+ --style-file <path> the same, from a file
61
+ --track <code> which track to burn: the source language (default) or translate-to's
62
+ --platform instagram|youtube|tiktok
63
+ --out <path> where to save the MP4 (default: <id>.mp4)
64
+
65
+ global
66
+ --api-key <hk_live_…> or HARMAR_API_KEY
67
+ --api-url <url> or HARMAR_API_URL (default https://api.harmar.ai)
68
+ --json machine-readable errors on stderr
69
+ `;
70
+ async function main(argv) {
71
+ let parsed;
72
+ try {
73
+ parsed = parseArgs({
74
+ args: argv,
75
+ allowPositionals: true,
76
+ allowNegative: true,
77
+ options: {
78
+ lang: { type: "string" },
79
+ "translate-to": { type: "string" },
80
+ script: { type: "string" },
81
+ webhook: { type: "string" },
82
+ timestamps: { type: "string" },
83
+ punctuation: { type: "boolean" },
84
+ speakers: { type: "boolean" },
85
+ lyrics: { type: "string" },
86
+ srt: { type: "boolean" },
87
+ vtt: { type: "boolean" },
88
+ text: { type: "boolean" },
89
+ out: { type: "string" },
90
+ wait: { type: "boolean" },
91
+ timeout: { type: "string" },
92
+ quiet: { type: "boolean" },
93
+ "api-key": { type: "string" },
94
+ "api-url": { type: "string" },
95
+ json: { type: "boolean" },
96
+ help: { type: "boolean" },
97
+ "keep-media": { type: "boolean" },
98
+ export: { type: "boolean" },
99
+ preset: { type: "string" },
100
+ style: { type: "string" },
101
+ "style-file": { type: "string" },
102
+ track: { type: "string" },
103
+ platform: { type: "string" },
104
+ },
105
+ });
106
+ }
107
+ catch (e) {
108
+ process.stderr.write(`${e.message}\n\n${USAGE}`);
109
+ return 2;
110
+ }
111
+ const flags = parsed.values;
112
+ const [cmd, arg] = parsed.positionals;
113
+ if (flags.help || !cmd) {
114
+ process.stdout.write(USAGE);
115
+ return cmd ? 0 : 2;
116
+ }
117
+ if (cmd === "mcp") {
118
+ // The MCP host owns stdio from here; the API key is read lazily on
119
+ // the first tool call so a missing key is a tool error, not a crash.
120
+ if (flags["api-key"])
121
+ process.env.HARMAR_API_KEY = flags["api-key"];
122
+ if (flags["api-url"])
123
+ process.env.HARMAR_API_URL = flags["api-url"];
124
+ const { runMcp } = await import("./mcp.js");
125
+ await runMcp();
126
+ return await new Promise(() => { });
127
+ }
128
+ const client = new HarmarClient({ apiKey: flags["api-key"], baseUrl: flags["api-url"] });
129
+ const log = flags.quiet ? () => { } : (s) => process.stderr.write(s + "\n");
130
+ switch (cmd) {
131
+ case "transcribe": {
132
+ if (!arg)
133
+ return usageError("transcribe needs a file path");
134
+ const format = flags.srt ? "srt" : flags.vtt ? "vtt" : flags.text ? "text" : "json";
135
+ const timeoutMs = flags.timeout ? Number(flags.timeout) * 1000 : undefined;
136
+ if (flags.timeout && !(Number(flags.timeout) > 0))
137
+ return usageError("--timeout must be a positive number of seconds");
138
+ if (flags.timestamps && !["word", "segment", "none"].includes(flags.timestamps)) {
139
+ return usageError("--timestamps must be word, segment or none");
140
+ }
141
+ if (flags.lyrics && !["exclude", "include"].includes(flags.lyrics)) {
142
+ return usageError("--lyrics must be exclude or include");
143
+ }
144
+ const scriptText = flags.script ? await readScript(flags.script) : undefined;
145
+ // --export: validate the style BEFORE paying for the transcript.
146
+ const exportOpts = flags.export ? await exportOptionsFrom(flags) : null;
147
+ if (flags.export && !exportOpts)
148
+ return 2;
149
+ if (flags.export && flags.wait === false)
150
+ return usageError("--export needs to wait for the transcript; drop --no-wait");
151
+ const result = await client.transcribe(arg, {
152
+ sourceLang: flags.lang,
153
+ translateTo: flags["translate-to"],
154
+ scriptText,
155
+ webhookUrl: flags.webhook,
156
+ keepMedia: flags["keep-media"] === true || flags.export === true,
157
+ options: {
158
+ ...(flags.timestamps ? { timestamps: flags.timestamps } : {}),
159
+ ...(flags.punctuation === false ? { punctuation: false } : {}),
160
+ ...(flags.speakers === false ? { speakers: false } : {}),
161
+ ...(flags.lyrics ? { lyrics: flags.lyrics } : {}),
162
+ },
163
+ wait: flags.wait !== false,
164
+ timeoutMs,
165
+ onProgress: progressLogger(log),
166
+ });
167
+ if (result.status === "processing" || result.status === "awaiting_upload") {
168
+ // --no-wait, or the timeout hit. The job is still running.
169
+ process.stdout.write(JSON.stringify({ id: result.id, status: result.status, progress: result.progress }, null, 2) + "\n");
170
+ log(`still ${result.status} — poll with: harmar status ${result.id}`);
171
+ return flags.wait === false ? 0 : 3;
172
+ }
173
+ if (result.status === "failed") {
174
+ return fail(flags, new HarmarError(0, result.error ?? "processing_failed", `Job ${result.id} failed: ${result.error ?? "processing_failed"} (credits refunded).`));
175
+ }
176
+ if (exportOpts) {
177
+ // The transcript is done; --out names the MP4, and the transcript
178
+ // itself goes to stdout as JSON so nothing is lost.
179
+ const outPath = flags.out ?? `${result.id}.mp4`;
180
+ const state = await client.exportVideo(result.id, {
181
+ ...exportOpts,
182
+ lang: flags.track,
183
+ timeoutMs,
184
+ outPath,
185
+ onProgress: progressLogger(log),
186
+ });
187
+ if (state.status !== "completed")
188
+ return exportNotDone(flags, state, log);
189
+ log(`wrote ${outPath}`);
190
+ process.stdout.write(JSON.stringify({ ...result, export: state, file: outPath }, null, 2) + "\n");
191
+ return 0;
192
+ }
193
+ let output;
194
+ if (format === "srt" || format === "vtt") {
195
+ output = await client.subtitles(result.id, format, subtitleTrack(flags, result));
196
+ }
197
+ else if (format === "text") {
198
+ output = (result.text ?? "") + "\n";
199
+ }
200
+ else {
201
+ output = JSON.stringify(result, null, 2) + "\n";
202
+ }
203
+ await emit(output, flags.out);
204
+ return 0;
205
+ }
206
+ case "export": {
207
+ if (!arg)
208
+ return usageError("export needs a transcript id");
209
+ const exportOpts = await exportOptionsFrom(flags);
210
+ if (!exportOpts)
211
+ return 2;
212
+ const timeoutMs = flags.timeout ? Number(flags.timeout) * 1000 : undefined;
213
+ const outPath = flags.out ?? `${arg}.mp4`;
214
+ const state = await client.exportVideo(arg, {
215
+ ...exportOpts,
216
+ lang: flags.track,
217
+ wait: flags.wait !== false,
218
+ timeoutMs,
219
+ outPath: flags.wait === false ? undefined : outPath,
220
+ onProgress: progressLogger(log),
221
+ });
222
+ if (flags.wait === false) {
223
+ process.stdout.write(JSON.stringify(state, null, 2) + "\n");
224
+ log(`poll with: harmar export-status ${arg}`);
225
+ return 0;
226
+ }
227
+ if (state.status !== "completed")
228
+ return exportNotDone(flags, state, log);
229
+ log(`wrote ${outPath}`);
230
+ process.stdout.write(JSON.stringify({ ...state, file: outPath }, null, 2) + "\n");
231
+ return 0;
232
+ }
233
+ case "export-status": {
234
+ if (!arg)
235
+ return usageError("export-status needs a transcript id");
236
+ const state = await client.getExport(arg);
237
+ if (flags.out && state.status === "completed") {
238
+ await client.downloadExport(state, flags.out, progressLogger(log));
239
+ log(`wrote ${flags.out}`);
240
+ }
241
+ process.stdout.write(JSON.stringify(state, null, 2) + "\n");
242
+ return state.status === "failed" ? 1 : 0;
243
+ }
244
+ case "styles": {
245
+ const cat = await client.styles();
246
+ if (flags.json) {
247
+ process.stdout.write(JSON.stringify(cat, null, 2) + "\n");
248
+ return 0;
249
+ }
250
+ process.stdout.write("presets\n");
251
+ for (const p of cat.presets)
252
+ process.stdout.write(` ${p.id.padEnd(9)} ${p.description}\n`);
253
+ process.stdout.write("\nfonts (key · label · languages it renders)\n");
254
+ for (const f of cat.fonts) {
255
+ const langs = f.languages.length > 12 ? `${f.languages.slice(0, 12).join(",")},… (${f.languages.length})` : f.languages.join(",");
256
+ process.stdout.write(` ${f.key.padEnd(22)} ${f.label.padEnd(22)} ${langs}\n`);
257
+ }
258
+ process.stdout.write("\nfields\n");
259
+ for (const [k, v] of Object.entries(cat.fields)) {
260
+ process.stdout.write(` ${k.padEnd(16)} ${v.type.padEnd(8)} ${(v.range ?? "").padEnd(34)} ${v.description}\n`);
261
+ }
262
+ process.stdout.write(`\nexport: up to ${cat.export.max_media_minutes} min, ${cat.export.max_resolution}, watermark: ${cat.export.watermark}. Use --json for the defaults per orientation.\n`);
263
+ return 0;
264
+ }
265
+ case "presets": {
266
+ const presets = await client.stylePresets();
267
+ if (flags.json)
268
+ process.stdout.write(JSON.stringify(presets, null, 2) + "\n");
269
+ else if (!presets.length)
270
+ process.stdout.write("no saved styles — harmar save-style <name> --style-file s.json\n");
271
+ else
272
+ for (const p of presets)
273
+ process.stdout.write(`${p.id} ${p.name} (${p.style.preset ?? "preset?"} · ${p.style.font ?? "font?"})\n`);
274
+ return 0;
275
+ }
276
+ case "save-style": {
277
+ if (!arg)
278
+ return usageError("save-style needs a name");
279
+ const style = await inlineStyleFrom(flags);
280
+ if (!style)
281
+ return usageError("save-style needs --style '<json>' or --style-file <path>");
282
+ const preset = await client.saveStylePreset(arg, style);
283
+ process.stdout.write(JSON.stringify(preset, null, 2) + "\n");
284
+ log(`saved — export with: harmar export <transcript id> --preset ${preset.id}`);
285
+ return 0;
286
+ }
287
+ case "delete-style": {
288
+ if (!arg)
289
+ return usageError("delete-style needs a preset id");
290
+ process.stdout.write(JSON.stringify(await client.deleteStylePreset(arg), null, 2) + "\n");
291
+ return 0;
292
+ }
293
+ case "status": {
294
+ if (!arg)
295
+ return usageError("status needs a transcript id");
296
+ await emit(JSON.stringify(await client.get(arg), null, 2) + "\n", flags.out);
297
+ return 0;
298
+ }
299
+ case "srt":
300
+ case "vtt": {
301
+ if (!arg)
302
+ return usageError(`${cmd} needs a transcript id`);
303
+ await emit(await client.subtitles(arg, cmd, flags.lang), flags.out);
304
+ return 0;
305
+ }
306
+ case "delete": {
307
+ if (!arg)
308
+ return usageError("delete needs a transcript id");
309
+ process.stdout.write(JSON.stringify(await client.delete(arg), null, 2) + "\n");
310
+ return 0;
311
+ }
312
+ case "languages": {
313
+ const langs = await client.languages();
314
+ if (flags.json) {
315
+ process.stdout.write(JSON.stringify(langs, null, 2) + "\n");
316
+ }
317
+ else {
318
+ process.stdout.write("auto (identify from the audio)\n");
319
+ for (const l of langs) {
320
+ process.stdout.write(`${l.code.padEnd(5)} ${l.name.padEnd(14)} ${l.native_name}${l.auto_detectable ? "" : " (name it explicitly — not auto-detectable)"}\n`);
321
+ }
322
+ }
323
+ return 0;
324
+ }
325
+ case "balance": {
326
+ const b = await client.balance();
327
+ process.stdout.write(flags.json ? JSON.stringify(b) + "\n" : `${b.minutes_remaining} min (${b.seconds_remaining} s) remaining\n`);
328
+ return 0;
329
+ }
330
+ case "usage":
331
+ process.stdout.write(JSON.stringify(await client.usage(), null, 2) + "\n");
332
+ return 0;
333
+ case "pricing":
334
+ process.stdout.write(JSON.stringify(await client.pricing(), null, 2) + "\n");
335
+ return 0;
336
+ default:
337
+ return usageError(`unknown command "${cmd}"`);
338
+ }
339
+ }
340
+ // --preset XOR (--style | --style-file), plus --platform. Returns null after
341
+ // printing the usage error, so callers can `return 2`.
342
+ async function exportOptionsFrom(flags) {
343
+ const style = await inlineStyleFrom(flags);
344
+ if (!!flags.preset === !!style) {
345
+ usageError("an export needs exactly one of --preset <id> or --style/--style-file");
346
+ return null;
347
+ }
348
+ if (flags.platform && !["instagram", "youtube", "tiktok"].includes(flags.platform)) {
349
+ usageError("--platform must be instagram, youtube or tiktok");
350
+ return null;
351
+ }
352
+ return {
353
+ ...(flags.preset ? { stylePresetId: flags.preset } : {}),
354
+ ...(style ? { style } : {}),
355
+ ...(flags.platform ? { platform: flags.platform } : {}),
356
+ };
357
+ }
358
+ async function inlineStyleFrom(flags) {
359
+ let raw = flags.style;
360
+ if (flags["style-file"]) {
361
+ const { readFile } = await import("node:fs/promises");
362
+ raw = await readFile(flags["style-file"], "utf8");
363
+ }
364
+ if (!raw)
365
+ return null;
366
+ try {
367
+ const parsed = JSON.parse(raw);
368
+ if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed))
369
+ throw new Error("not an object");
370
+ return parsed;
371
+ }
372
+ catch (e) {
373
+ usageError(`--style is not a JSON object: ${e.message}`);
374
+ return null;
375
+ }
376
+ }
377
+ function exportNotDone(flags, state, log) {
378
+ if (state.status === "failed") {
379
+ return fail(flags, new HarmarError(0, state.error ?? "render_failed", `Export ${state.id} failed: ${state.error ?? "render_failed"} (${state.seconds_refunded ?? 0} s refunded).`));
380
+ }
381
+ process.stdout.write(JSON.stringify(state, null, 2) + "\n");
382
+ log(`still ${state.status} — poll with: harmar export-status ${state.id}`);
383
+ return 3;
384
+ }
385
+ // Which track the --srt/--vtt output names: the translation when one was
386
+ // requested and landed, otherwise the source (omitted → the API's default).
387
+ function subtitleTrack(flags, result) {
388
+ if (flags["translate-to"] && result.translation)
389
+ return flags["translate-to"];
390
+ return undefined;
391
+ }
392
+ function progressLogger(log) {
393
+ let lastLine = "";
394
+ return (e) => {
395
+ let line;
396
+ switch (e.phase) {
397
+ case "uploading":
398
+ line = `uploading ${(e.bytes / 1e6).toFixed(1)} MB…`;
399
+ break;
400
+ case "submitted":
401
+ line = `submitted ${e.id} — ${e.duration_seconds ?? "?"} s of media, ${e.seconds_charged ?? "?"} s charged`;
402
+ break;
403
+ case "processing":
404
+ line = `processing${e.progress !== undefined ? ` ${e.progress}%` : ""}${e.detected_lang ? ` · language: ${e.detected_lang}` : ""}`;
405
+ break;
406
+ case "completed":
407
+ line = "completed";
408
+ break;
409
+ case "export_submitted":
410
+ line = `export submitted — ${e.seconds_charged} s charged`;
411
+ break;
412
+ case "export_rendering":
413
+ line = e.status === "queued"
414
+ ? `export queued${e.queue_position ? ` (position ${e.queue_position})` : ""}`
415
+ : `rendering${e.progress !== undefined ? ` ${e.progress}%` : ""}`;
416
+ break;
417
+ case "export_completed":
418
+ line = `export completed${e.size_bytes ? ` (${(e.size_bytes / 1e6).toFixed(1)} MB)` : ""}`;
419
+ break;
420
+ case "downloading":
421
+ line = "downloading…";
422
+ break;
423
+ }
424
+ if (line !== lastLine)
425
+ log(line);
426
+ lastLine = line;
427
+ };
428
+ }
429
+ async function readScript(path) {
430
+ const { readFile } = await import("node:fs/promises");
431
+ return readFile(path, "utf8");
432
+ }
433
+ async function emit(output, out) {
434
+ if (out) {
435
+ await writeFile(out, output);
436
+ process.stderr.write(`wrote ${out}\n`);
437
+ }
438
+ else {
439
+ process.stdout.write(output);
440
+ }
441
+ }
442
+ function usageError(msg) {
443
+ process.stderr.write(`harmar: ${msg}\n\n${USAGE}`);
444
+ return 2;
445
+ }
446
+ function fail(flags, e) {
447
+ if (e instanceof HarmarError) {
448
+ if (flags.json) {
449
+ process.stderr.write(JSON.stringify({ error: { code: e.code, message: e.message, status: e.status, ...e.params } }) + "\n");
450
+ }
451
+ else {
452
+ const extra = Object.keys(e.params).length ? ` ${JSON.stringify(e.params)}` : "";
453
+ process.stderr.write(`harmar: ${e.code}: ${e.message}${extra}\n`);
454
+ }
455
+ return 1;
456
+ }
457
+ process.stderr.write(`harmar: ${e?.message ?? String(e)}\n`);
458
+ return 1;
459
+ }
460
+ main(process.argv.slice(2)).then((code) => process.exit(code), (e) => process.exit(fail({ json: process.argv.includes("--json") }, e)));