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 +106 -0
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +460 -0
- package/dist/client.d.ts +298 -0
- package/dist/client.js +254 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/mcp.d.ts +1 -0
- package/dist/mcp.js +207 -0
- package/dist/test/mcp.test.d.ts +1 -0
- package/dist/test/mcp.test.js +111 -0
- package/package.json +32 -0
- package/skills/harmar/SKILL.md +139 -0
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
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)));
|