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.
@@ -0,0 +1,298 @@
1
+ export declare const DEFAULT_BASE_URL = "https://api.harmar.ai";
2
+ export type Timestamps = "word" | "segment" | "none";
3
+ export type Lyrics = "exclude" | "include";
4
+ export type TranscriptOptions = {
5
+ timestamps?: Timestamps;
6
+ punctuation?: boolean;
7
+ speakers?: boolean;
8
+ lyrics?: Lyrics;
9
+ };
10
+ export type SubmitOptions = {
11
+ /** Language of the speech. "auto" identifies it from the audio. Default "hy". */
12
+ sourceLang?: string;
13
+ /** Second subtitle track in this language, no surcharge. */
14
+ translateTo?: string;
15
+ /** Align a known script instead of transcribing (no Gemini call). */
16
+ scriptText?: string;
17
+ /** HTTPS URL that receives transcript.completed / transcript.failed (and export.*). */
18
+ webhookUrl?: string;
19
+ /** Keep the source media after transcription so the job can be exported as a styled video. Default false. */
20
+ keepMedia?: boolean;
21
+ options?: TranscriptOptions;
22
+ };
23
+ export type Word = {
24
+ text: string;
25
+ start: number;
26
+ end: number;
27
+ speaker?: number;
28
+ is_lyric?: boolean;
29
+ };
30
+ export type Segment = Word;
31
+ export type TranscriptTrack = {
32
+ text: string;
33
+ words?: Word[];
34
+ segments?: Segment[];
35
+ };
36
+ export type JobStatus = "awaiting_upload" | "processing" | "completed" | "failed";
37
+ export type Transcript = {
38
+ id: string;
39
+ status: JobStatus;
40
+ duration_seconds: number | null;
41
+ created_at: string;
42
+ completed_at: string | null;
43
+ /** 0–100 while processing. */
44
+ progress?: number;
45
+ /** Echoed when a non-default source language was requested. */
46
+ source_lang?: string;
47
+ /** What "auto" resolved to — present from the first seconds of processing. */
48
+ detected_lang?: string;
49
+ translate_to?: string;
50
+ seconds_charged?: number;
51
+ quality?: "ok" | "degraded";
52
+ /** Stable public reason on a failed job. */
53
+ error?: string;
54
+ text?: string;
55
+ words?: Word[];
56
+ segments?: Segment[];
57
+ translation?: TranscriptTrack;
58
+ translation_status?: "failed";
59
+ srt_url?: string;
60
+ vtt_url?: string;
61
+ /** True while the source media is still stored (keep_media jobs) — an export is possible. */
62
+ media_retained?: boolean;
63
+ /** Present once an export has been requested. Full state: getExport(). */
64
+ export?: {
65
+ status: ExportStatus;
66
+ };
67
+ };
68
+ /** A subtitle style. Every field is optional; GET /v1/styles lists them with ranges and defaults. */
69
+ export type Style = Record<string, unknown> & {
70
+ preset?: "karaoke" | "pill" | "popin" | "classic" | "reveal" | "stack" | "carousel";
71
+ font?: string;
72
+ fontByLang?: Record<string, string>;
73
+ fontSizePct?: number;
74
+ color?: string;
75
+ accentColor?: string;
76
+ bgColor?: string;
77
+ bgOpacity?: number;
78
+ position?: "top" | "center" | "bottom";
79
+ posX?: number;
80
+ posY?: number;
81
+ subtitleWidth?: number;
82
+ };
83
+ export type StylesCatalog = {
84
+ presets: {
85
+ id: string;
86
+ description: string;
87
+ }[];
88
+ fonts: {
89
+ key: string;
90
+ label: string;
91
+ category: string;
92
+ languages: string[];
93
+ }[];
94
+ fields: Record<string, {
95
+ type: string;
96
+ range?: string;
97
+ description: string;
98
+ }>;
99
+ defaults: Record<"vertical" | "horizontal" | "square", Style>;
100
+ platforms: readonly string[];
101
+ export: {
102
+ max_media_minutes: number;
103
+ max_resolution: string;
104
+ watermark: boolean;
105
+ requires: string;
106
+ };
107
+ };
108
+ export type StylePreset = {
109
+ id: string;
110
+ name: string;
111
+ style: Style;
112
+ created_at: string;
113
+ };
114
+ export type ExportStatus = "queued" | "rendering" | "completed" | "failed";
115
+ export type ExportOptions = {
116
+ /** A saved preset's id — exactly one of stylePresetId / style. */
117
+ stylePresetId?: string;
118
+ /** An inline style object. */
119
+ style?: Style;
120
+ /** Which track to burn: the source language (default) or translate_to's. */
121
+ lang?: string;
122
+ platform?: "instagram" | "youtube" | "tiktok";
123
+ };
124
+ export type ExportState = {
125
+ id: string;
126
+ status: ExportStatus;
127
+ requested_at: string;
128
+ completed_at?: string;
129
+ lang: string;
130
+ style_preset_id?: string;
131
+ seconds_charged: number;
132
+ seconds_refunded?: number;
133
+ /** 0–100 while rendering. */
134
+ progress?: number;
135
+ queue_position?: number;
136
+ error?: string;
137
+ size_bytes?: number | null;
138
+ /** Signed URL, valid download_expires_in_seconds. Present when completed. */
139
+ download_url?: string;
140
+ download_expires_in_seconds?: number;
141
+ };
142
+ export type UploadTicket = {
143
+ media_id: string;
144
+ upload_url: string;
145
+ content_type: string;
146
+ expires_in_seconds: number;
147
+ };
148
+ export type SubmitResult = {
149
+ id: string;
150
+ status: JobStatus;
151
+ duration_seconds: number | null;
152
+ seconds_charged?: number;
153
+ idempotent?: boolean;
154
+ };
155
+ export type Language = {
156
+ code: string;
157
+ name: string;
158
+ native_name: string;
159
+ script: string;
160
+ auto_detectable: boolean;
161
+ };
162
+ export type Balance = {
163
+ seconds_remaining: number;
164
+ minutes_remaining: number;
165
+ };
166
+ export type UsageEntry = {
167
+ kind: string;
168
+ delta_seconds: number;
169
+ transcript_id: string | null;
170
+ created_at: string;
171
+ };
172
+ export type Usage = {
173
+ entries: UsageEntry[];
174
+ window: string;
175
+ credited_seconds: number;
176
+ debited_seconds: number;
177
+ };
178
+ export type DeleteResult = {
179
+ id: string;
180
+ deleted: true;
181
+ already_deleted: boolean;
182
+ };
183
+ /** The API's error envelope: `{ error: { code, message, ...params } }`. */
184
+ export declare class HarmarError extends Error {
185
+ readonly status: number;
186
+ readonly code: string;
187
+ readonly params: Record<string, unknown>;
188
+ constructor(status: number, code: string, message: string, params?: Record<string, unknown>);
189
+ }
190
+ export type ClientConfig = {
191
+ apiKey?: string;
192
+ baseUrl?: string;
193
+ fetch?: typeof fetch;
194
+ };
195
+ export type ProgressEvent = {
196
+ phase: "uploading";
197
+ bytes: number;
198
+ } | {
199
+ phase: "submitted";
200
+ id: string;
201
+ seconds_charged?: number;
202
+ duration_seconds: number | null;
203
+ } | {
204
+ phase: "processing";
205
+ id: string;
206
+ progress?: number;
207
+ detected_lang?: string;
208
+ } | {
209
+ phase: "completed";
210
+ id: string;
211
+ } | {
212
+ phase: "export_submitted";
213
+ id: string;
214
+ seconds_charged: number;
215
+ } | {
216
+ phase: "export_rendering";
217
+ id: string;
218
+ status: ExportStatus;
219
+ progress?: number;
220
+ queue_position?: number;
221
+ } | {
222
+ phase: "export_completed";
223
+ id: string;
224
+ size_bytes?: number | null;
225
+ } | {
226
+ phase: "downloading";
227
+ id: string;
228
+ bytes?: number | null;
229
+ };
230
+ export declare class HarmarClient {
231
+ readonly baseUrl: string;
232
+ private readonly apiKey;
233
+ private readonly fetchImpl;
234
+ constructor(config?: ClientConfig);
235
+ createUpload(filename: string, fileSize: number): Promise<UploadTicket>;
236
+ submit(mediaId: string, opts?: SubmitOptions): Promise<SubmitResult>;
237
+ styles(): Promise<StylesCatalog>;
238
+ stylePresets(): Promise<StylePreset[]>;
239
+ saveStylePreset(name: string, style: Style): Promise<StylePreset>;
240
+ deleteStylePreset(id: string): Promise<{
241
+ id: string;
242
+ deleted: true;
243
+ }>;
244
+ /** Start a burned-in export. The transcript must have been submitted with keepMedia. */
245
+ startExport(id: string, opts: ExportOptions): Promise<{
246
+ id: string;
247
+ export: {
248
+ status: ExportStatus;
249
+ seconds_charged: number;
250
+ lang: string;
251
+ };
252
+ }>;
253
+ getExport(id: string): Promise<ExportState>;
254
+ /** Poll until the export is completed or failed (or timeoutMs elapses — it keeps rendering server-side). */
255
+ waitExport(id: string, { intervalMs, timeoutMs, onProgress }?: {
256
+ intervalMs?: number;
257
+ timeoutMs?: number;
258
+ onProgress?: (e: ProgressEvent) => void;
259
+ }): Promise<ExportState>;
260
+ /** Save a completed export's MP4 to a local path. */
261
+ downloadExport(state: ExportState, outPath: string, onProgress?: (e: ProgressEvent) => void): Promise<void>;
262
+ /** startExport + waitExport, and downloadExport when outPath is given. */
263
+ exportVideo(id: string, opts: ExportOptions & {
264
+ wait?: boolean;
265
+ timeoutMs?: number;
266
+ outPath?: string;
267
+ onProgress?: (e: ProgressEvent) => void;
268
+ }): Promise<ExportState>;
269
+ get(id: string): Promise<Transcript>;
270
+ /** SRT or VTT text. `lang` picks the track: omit for the source, name translate_to's language for the translation. */
271
+ subtitles(id: string, format: "srt" | "vtt", lang?: string): Promise<string>;
272
+ delete(id: string): Promise<DeleteResult>;
273
+ languages(): Promise<Language[]>;
274
+ pricing(): Promise<Record<string, unknown>>;
275
+ balance(): Promise<Balance>;
276
+ usage(): Promise<Usage>;
277
+ /** Upload a local file and return its media_id. Does not submit. */
278
+ upload(filePath: string, onProgress?: (e: ProgressEvent) => void): Promise<UploadTicket>;
279
+ /**
280
+ * Poll until the job leaves "processing". Resolves with the final
281
+ * transcript, or with the last status seen if `timeoutMs` elapses —
282
+ * the job keeps running server-side; call get(id) later.
283
+ */
284
+ wait(id: string, { intervalMs, timeoutMs, onProgress }?: {
285
+ intervalMs?: number;
286
+ timeoutMs?: number;
287
+ onProgress?: (e: ProgressEvent) => void;
288
+ }): Promise<Transcript>;
289
+ /** Upload, submit and (by default) wait. The whole flow in one call. */
290
+ transcribe(filePath: string, opts?: SubmitOptions & {
291
+ wait?: boolean;
292
+ timeoutMs?: number;
293
+ onProgress?: (e: ProgressEvent) => void;
294
+ }): Promise<Transcript>;
295
+ private request;
296
+ private requestText;
297
+ private send;
298
+ }
package/dist/client.js ADDED
@@ -0,0 +1,254 @@
1
+ // Typed client for the Harmar developer API — https://harmar.ai/developers
2
+ //
3
+ // The API is fifteen routes (docs/api.md §75.3, §137's GET /v1/languages,
4
+ // §186.4's styles, presets and export), and this file is the one place the
5
+ // SDK describes them. The CLI and the MCP
6
+ // server both call through here, so a route added to the backend is
7
+ // added here once and reaches both.
8
+ //
9
+ // No dependencies: Node 20's fetch, openAsBlob for the upload PUT (a Blob
10
+ // carries its size, so fetch sends Content-Length — a stream body would go
11
+ // chunked and R2's presigned PUT rejects chunked bodies).
12
+ import { openAsBlob, createWriteStream } from "node:fs";
13
+ import { stat } from "node:fs/promises";
14
+ import { basename } from "node:path";
15
+ import { Readable } from "node:stream";
16
+ import { pipeline } from "node:stream/promises";
17
+ export const DEFAULT_BASE_URL = "https://api.harmar.ai";
18
+ /** The API's error envelope: `{ error: { code, message, ...params } }`. */
19
+ export class HarmarError extends Error {
20
+ status;
21
+ code;
22
+ params;
23
+ constructor(status, code, message, params = {}) {
24
+ super(message);
25
+ this.name = "HarmarError";
26
+ this.status = status;
27
+ this.code = code;
28
+ this.params = params;
29
+ }
30
+ }
31
+ const EXT_TO_MIME = {
32
+ mp4: "video/mp4",
33
+ mov: "video/quicktime",
34
+ webm: "video/webm",
35
+ m4a: "audio/mp4",
36
+ mp3: "audio/mpeg",
37
+ wav: "audio/wav",
38
+ };
39
+ export class HarmarClient {
40
+ baseUrl;
41
+ apiKey;
42
+ fetchImpl;
43
+ constructor(config = {}) {
44
+ const key = config.apiKey ?? process.env.HARMAR_API_KEY;
45
+ if (!key) {
46
+ throw new HarmarError(0, "missing_api_key", "No API key. Set HARMAR_API_KEY (create one at https://harmar.ai/app/api).");
47
+ }
48
+ this.apiKey = key;
49
+ this.baseUrl = (config.baseUrl ?? process.env.HARMAR_API_URL ?? DEFAULT_BASE_URL).replace(/\/+$/, "");
50
+ this.fetchImpl = config.fetch ?? fetch;
51
+ }
52
+ // ── raw routes ──────────────────────────────────────────────────────
53
+ createUpload(filename, fileSize) {
54
+ return this.request("POST", "/v1/uploads", { filename, file_size: fileSize });
55
+ }
56
+ submit(mediaId, opts = {}) {
57
+ return this.request("POST", "/v1/transcripts", {
58
+ media_id: mediaId,
59
+ ...(opts.sourceLang ? { source_lang: opts.sourceLang } : {}),
60
+ ...(opts.translateTo ? { translate_to: opts.translateTo } : {}),
61
+ ...(opts.scriptText ? { script_text: opts.scriptText } : {}),
62
+ ...(opts.webhookUrl ? { webhook_url: opts.webhookUrl } : {}),
63
+ ...(opts.keepMedia ? { keep_media: true } : {}),
64
+ ...(opts.options ? { options: opts.options } : {}),
65
+ });
66
+ }
67
+ // ── styles & export ──────────────────────────────────────────────────
68
+ styles() {
69
+ return this.request("GET", "/v1/styles");
70
+ }
71
+ async stylePresets() {
72
+ const r = await this.request("GET", "/v1/style-presets");
73
+ return r.presets;
74
+ }
75
+ async saveStylePreset(name, style) {
76
+ const r = await this.request("POST", "/v1/style-presets", { name, style });
77
+ return r.preset;
78
+ }
79
+ deleteStylePreset(id) {
80
+ return this.request("DELETE", `/v1/style-presets/${encodeURIComponent(id)}`);
81
+ }
82
+ /** Start a burned-in export. The transcript must have been submitted with keepMedia. */
83
+ startExport(id, opts) {
84
+ return this.request("POST", `/v1/transcripts/${encodeURIComponent(id)}/export`, {
85
+ ...(opts.stylePresetId ? { style_preset_id: opts.stylePresetId } : {}),
86
+ ...(opts.style ? { style: opts.style } : {}),
87
+ ...(opts.lang ? { lang: opts.lang } : {}),
88
+ ...(opts.platform ? { platform: opts.platform } : {}),
89
+ });
90
+ }
91
+ getExport(id) {
92
+ return this.request("GET", `/v1/transcripts/${encodeURIComponent(id)}/export`);
93
+ }
94
+ /** Poll until the export is completed or failed (or timeoutMs elapses — it keeps rendering server-side). */
95
+ async waitExport(id, { intervalMs = 5000, timeoutMs = 60 * 60 * 1000, onProgress } = {}) {
96
+ const deadline = Date.now() + timeoutMs;
97
+ let last = await this.getExport(id);
98
+ while ((last.status === "queued" || last.status === "rendering") && Date.now() < deadline) {
99
+ onProgress?.({ phase: "export_rendering", id, status: last.status, progress: last.progress, queue_position: last.queue_position });
100
+ await new Promise((r) => setTimeout(r, intervalMs));
101
+ last = await this.getExport(id);
102
+ }
103
+ if (last.status === "completed")
104
+ onProgress?.({ phase: "export_completed", id, size_bytes: last.size_bytes });
105
+ return last;
106
+ }
107
+ /** Save a completed export's MP4 to a local path. */
108
+ async downloadExport(state, outPath, onProgress) {
109
+ if (state.status !== "completed" || !state.download_url) {
110
+ throw new HarmarError(0, "export_not_ready", `Export ${state.id} is ${state.status}; nothing to download.`);
111
+ }
112
+ onProgress?.({ phase: "downloading", id: state.id, bytes: state.size_bytes });
113
+ const res = await this.fetchImpl(state.download_url);
114
+ if (!res.ok || !res.body) {
115
+ throw new HarmarError(res.status, "download_failed", `Download answered ${res.status}.`);
116
+ }
117
+ await pipeline(Readable.fromWeb(res.body), createWriteStream(outPath));
118
+ }
119
+ /** startExport + waitExport, and downloadExport when outPath is given. */
120
+ async exportVideo(id, opts) {
121
+ const { wait = true, timeoutMs, outPath, onProgress, ...exportOpts } = opts;
122
+ const started = await this.startExport(id, exportOpts);
123
+ onProgress?.({ phase: "export_submitted", id, seconds_charged: started.export.seconds_charged });
124
+ if (!wait)
125
+ return this.getExport(id);
126
+ const state = await this.waitExport(id, { timeoutMs, onProgress });
127
+ if (outPath && state.status === "completed")
128
+ await this.downloadExport(state, outPath, onProgress);
129
+ return state;
130
+ }
131
+ get(id) {
132
+ return this.request("GET", `/v1/transcripts/${encodeURIComponent(id)}`);
133
+ }
134
+ /** SRT or VTT text. `lang` picks the track: omit for the source, name translate_to's language for the translation. */
135
+ subtitles(id, format, lang) {
136
+ const q = lang ? `?lang=${encodeURIComponent(lang)}` : "";
137
+ return this.requestText("GET", `/v1/transcripts/${encodeURIComponent(id)}/${format}${q}`);
138
+ }
139
+ delete(id) {
140
+ return this.request("DELETE", `/v1/transcripts/${encodeURIComponent(id)}`);
141
+ }
142
+ async languages() {
143
+ const r = await this.request("GET", "/v1/languages");
144
+ return r.languages;
145
+ }
146
+ pricing() {
147
+ return this.request("GET", "/v1/pricing");
148
+ }
149
+ balance() {
150
+ return this.request("GET", "/v1/balance");
151
+ }
152
+ usage() {
153
+ return this.request("GET", "/v1/usage");
154
+ }
155
+ // ── conveniences ────────────────────────────────────────────────────
156
+ /** Upload a local file and return its media_id. Does not submit. */
157
+ async upload(filePath, onProgress) {
158
+ const info = await stat(filePath);
159
+ const ticket = await this.createUpload(basename(filePath), info.size);
160
+ const ext = filePath.split(".").pop()?.toLowerCase() ?? "";
161
+ const body = await openAsBlob(filePath, { type: ticket.content_type || EXT_TO_MIME[ext] });
162
+ onProgress?.({ phase: "uploading", bytes: info.size });
163
+ const res = await this.fetchImpl(ticket.upload_url, {
164
+ method: "PUT",
165
+ headers: { "Content-Type": ticket.content_type },
166
+ body,
167
+ });
168
+ if (!res.ok) {
169
+ throw new HarmarError(res.status, "upload_failed", `Storage PUT answered ${res.status}.`);
170
+ }
171
+ return ticket;
172
+ }
173
+ /**
174
+ * Poll until the job leaves "processing". Resolves with the final
175
+ * transcript, or with the last status seen if `timeoutMs` elapses —
176
+ * the job keeps running server-side; call get(id) later.
177
+ */
178
+ async wait(id, { intervalMs = 5000, timeoutMs = 30 * 60 * 1000, onProgress } = {}) {
179
+ const deadline = Date.now() + timeoutMs;
180
+ let last = await this.get(id);
181
+ while ((last.status === "processing" || last.status === "awaiting_upload") && Date.now() < deadline) {
182
+ onProgress?.({ phase: "processing", id, progress: last.progress, detected_lang: last.detected_lang });
183
+ await new Promise((r) => setTimeout(r, intervalMs));
184
+ last = await this.get(id);
185
+ }
186
+ if (last.status === "completed")
187
+ onProgress?.({ phase: "completed", id });
188
+ return last;
189
+ }
190
+ /** Upload, submit and (by default) wait. The whole flow in one call. */
191
+ async transcribe(filePath, opts = {}) {
192
+ const { wait = true, timeoutMs, onProgress, ...submitOpts } = opts;
193
+ const ticket = await this.upload(filePath, onProgress);
194
+ const submitted = await this.submit(ticket.media_id, submitOpts);
195
+ onProgress?.({
196
+ phase: "submitted",
197
+ id: submitted.id,
198
+ seconds_charged: submitted.seconds_charged,
199
+ duration_seconds: submitted.duration_seconds,
200
+ });
201
+ if (!wait)
202
+ return this.get(submitted.id);
203
+ return this.wait(submitted.id, { timeoutMs, onProgress });
204
+ }
205
+ // ── transport ───────────────────────────────────────────────────────
206
+ async request(method, path, body) {
207
+ const res = await this.send(method, path, body);
208
+ const text = await res.text();
209
+ let json = null;
210
+ try {
211
+ json = text ? JSON.parse(text) : null;
212
+ }
213
+ catch {
214
+ /* non-JSON body handled below */
215
+ }
216
+ if (!res.ok)
217
+ throw errorFrom(res.status, json, text);
218
+ return json;
219
+ }
220
+ async requestText(method, path) {
221
+ const res = await this.send(method, path);
222
+ const text = await res.text();
223
+ if (!res.ok) {
224
+ let json = null;
225
+ try {
226
+ json = JSON.parse(text);
227
+ }
228
+ catch {
229
+ /* plain text error */
230
+ }
231
+ throw errorFrom(res.status, json, text);
232
+ }
233
+ return text;
234
+ }
235
+ send(method, path, body) {
236
+ return this.fetchImpl(`${this.baseUrl}${path}`, {
237
+ method,
238
+ headers: {
239
+ Authorization: `Bearer ${this.apiKey}`,
240
+ "User-Agent": "harmar-sdk/0.1.0",
241
+ ...(body !== undefined ? { "Content-Type": "application/json" } : {}),
242
+ },
243
+ body: body !== undefined ? JSON.stringify(body) : undefined,
244
+ });
245
+ }
246
+ }
247
+ function errorFrom(status, json, text) {
248
+ const env = json?.error;
249
+ if (env && typeof env === "object") {
250
+ const { code, message, ...params } = env;
251
+ return new HarmarError(status, typeof code === "string" ? code : "http_error", typeof message === "string" ? message : `HTTP ${status}`, params);
252
+ }
253
+ return new HarmarError(status, "http_error", text.slice(0, 200) || `HTTP ${status}`);
254
+ }
@@ -0,0 +1 @@
1
+ export * from "./client.js";
package/dist/index.js ADDED
@@ -0,0 +1 @@
1
+ export * from "./client.js";
package/dist/mcp.d.ts ADDED
@@ -0,0 +1 @@
1
+ export declare function runMcp(): Promise<void>;