@widecast/sdk 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/CHANGELOG.md +21 -0
- package/LICENSE +190 -0
- package/README.md +94 -0
- package/dist/index.cjs +849 -0
- package/dist/index.d.cts +448 -0
- package/dist/index.d.ts +448 -0
- package/dist/index.js +788 -0
- package/package.json +55 -0
- package/src/index.ts +1109 -0
package/src/index.ts
ADDED
|
@@ -0,0 +1,1109 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* WideCast.ai TypeScript / JavaScript SDK — thin client for the public API.
|
|
3
|
+
*
|
|
4
|
+
* Works in Node 18+, Deno, Bun, browsers — uses the global `fetch`.
|
|
5
|
+
*
|
|
6
|
+
* @example
|
|
7
|
+
* import Widecast from "@widecast/sdk";
|
|
8
|
+
* const client = new Widecast({ apiKey: "wc_live_REPLACE_ME" });
|
|
9
|
+
* const video = await client.create_video({ script }).then(v => v.wait());
|
|
10
|
+
* console.log(video.review_url);
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
export const VERSION = "0.1.0";
|
|
14
|
+
|
|
15
|
+
const DEFAULT_BASE_URL =
|
|
16
|
+
(typeof process !== "undefined" && process.env?.WIDECAST_BASE_URL) ||
|
|
17
|
+
"https://widecast.ai/app/dashboard2";
|
|
18
|
+
|
|
19
|
+
const TERMINAL_STATUSES = ["completed", "failed"] as const;
|
|
20
|
+
|
|
21
|
+
// Field-requirement constants (LOCKED — A38 parity rule, 5 surfaces in sync).
|
|
22
|
+
//
|
|
23
|
+
// These mirror server enforcement in dashboard2.py
|
|
24
|
+
// (WIDECAST_SCRIPT_MIN_WORDS / MAX_WORDS / WIDECAST_IDEA_MIN_WORDS / MAX_WORDS /
|
|
25
|
+
// _WIDECAST_SOURCES / _WIDECAST_OUTPUT_TYPES / _WIDECAST_VIDEO_LENGTHS /
|
|
26
|
+
// _WIDECAST_LANGUAGES) and the OpenAPI `CreateVideoRequest` description.
|
|
27
|
+
// Any change must update server + OpenAPI + this constant + the markdown docs +
|
|
28
|
+
// the playground YAML/JS in the same commit — otherwise the parity tests fail
|
|
29
|
+
// and downstream tools (MCP / OpenAI tools / Postman) drift out of sync.
|
|
30
|
+
export const SCRIPT_MIN_WORDS = 80; // ~20s of narration (source="text")
|
|
31
|
+
export const SCRIPT_MAX_WORDS = 500; // ~120s / 2 min of narration (source="text")
|
|
32
|
+
export const IDEA_MIN_WORDS = 5; // source="idea" floor — reject if shorter
|
|
33
|
+
export const IDEA_MAX_WORDS = 1000; // source="idea" ceiling — auto-truncate not reject
|
|
34
|
+
export const BLOG_MIN_WORDS = 30; // source="blog" floor — reject if shorter (use idea)
|
|
35
|
+
export const BLOG_MAX_WORDS = 3000; // source="blog" ceiling — auto-truncate not reject
|
|
36
|
+
export const OUTPUT_TYPES = ["text", "scene", "video"] as const; // pipeline depth (A46)
|
|
37
|
+
export type OutputType = (typeof OUTPUT_TYPES)[number];
|
|
38
|
+
// blog = generative (mirrors idea, A48). video_*/audio_* = media-ingest (A49):
|
|
39
|
+
// the script already lives in the media; output_type="text" = Remake (A50).
|
|
40
|
+
export const SOURCES = ["text", "idea", "blog",
|
|
41
|
+
"video_url", "video_file", "audio_url", "audio_file"] as const;
|
|
42
|
+
export type Source = (typeof SOURCES)[number];
|
|
43
|
+
// faceless=true forces every scene to B-roll (no narrator A-roll). Only valid
|
|
44
|
+
// with output_type scene/video for these script-based sources.
|
|
45
|
+
export const FACELESS_SOURCES = ["text", "idea", "blog"] as const;
|
|
46
|
+
// Written content (/v1/create_content): friendly types → legacy content_type 3/4/5/6.
|
|
47
|
+
export const CONTENT_TYPES = ["blog", "facebook", "x", "linkedin"] as const;
|
|
48
|
+
export type ContentType = (typeof CONTENT_TYPES)[number];
|
|
49
|
+
// Enhance aggressiveness (/v1/enhance_script): 0=segment only, 1=natural, 2=max rewrite.
|
|
50
|
+
export const INTERVENTION_LEVELS = [0, 1, 2] as const;
|
|
51
|
+
export type InterventionLevel = (typeof INTERVENTION_LEVELS)[number];
|
|
52
|
+
// Social platforms WideCast can publish to (/v1/publish). Locked vocabulary
|
|
53
|
+
// mirroring the server's _WIDECAST_PUBLISH_PLATFORMS.
|
|
54
|
+
export const PUBLISH_PLATFORMS = ["youtube", "tiktok", "instagram", "facebook",
|
|
55
|
+
"linkedin", "x", "threads", "pinterest", "reddit", "bluesky",
|
|
56
|
+
"google_business"] as const;
|
|
57
|
+
export type PublishPlatform = (typeof PUBLISH_PLATFORMS)[number];
|
|
58
|
+
|
|
59
|
+
/** Options for client.create_content(). */
|
|
60
|
+
export interface CreateContentOptions {
|
|
61
|
+
/** A URL, an idea/topic, or pasted text the content is created from. */
|
|
62
|
+
content: string;
|
|
63
|
+
/** "blog" (default) / "facebook" / "x" / "linkedin". */
|
|
64
|
+
content_type?: ContentType;
|
|
65
|
+
/** Output language (e.g. "English"). Default "English". */
|
|
66
|
+
language?: string;
|
|
67
|
+
callback_url?: string;
|
|
68
|
+
metadata?: Record<string, unknown>;
|
|
69
|
+
idempotency_key?: string;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** Options for client.enhance_script(). */
|
|
73
|
+
export interface EnhanceScriptOptions {
|
|
74
|
+
/** The DRAFT script to enhance. */
|
|
75
|
+
script_text: string;
|
|
76
|
+
/** Output language; "" (default) keeps the draft's original language. */
|
|
77
|
+
language?: string;
|
|
78
|
+
/** 0=segment only, 1=natural enhance (default), 2=maximum rewrite. */
|
|
79
|
+
intervention_level?: InterventionLevel;
|
|
80
|
+
callback_url?: string;
|
|
81
|
+
metadata?: Record<string, unknown>;
|
|
82
|
+
idempotency_key?: string;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** Options for client.suggest_ideas(). */
|
|
86
|
+
export interface SuggestIdeasOptions {
|
|
87
|
+
/** Industry name (e.g. "Real Estate"). Falls back to your account industry if omitted. */
|
|
88
|
+
industry_id?: string;
|
|
89
|
+
/** How many ideas (1–20, default 5). */
|
|
90
|
+
num_topics?: number;
|
|
91
|
+
sub_industry?: string;
|
|
92
|
+
user_location?: string;
|
|
93
|
+
idempotency_key?: string;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** Options for client.collect_ideas(). */
|
|
97
|
+
export interface CollectIdeasOptions {
|
|
98
|
+
/** Product/service description (≥10 chars) to brainstorm ideas from. */
|
|
99
|
+
product_service_input: string;
|
|
100
|
+
sub_industry?: string;
|
|
101
|
+
user_location?: string;
|
|
102
|
+
idempotency_key?: string;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** Options for client.publish(). Provide EXACTLY ONE of topic_id / text / video_url. */
|
|
106
|
+
export interface PublishOptions {
|
|
107
|
+
/** Publish an existing WideCast video OR blog/social post (auto-detected). */
|
|
108
|
+
topic_id?: string;
|
|
109
|
+
/** Post arbitrary text (optionally with photo_urls). */
|
|
110
|
+
text?: string;
|
|
111
|
+
/** A direct video file URL to download + publish. Requires `title`. */
|
|
112
|
+
video_url?: string;
|
|
113
|
+
/** Caption/title. Required for video_url; optional override for topic_id. */
|
|
114
|
+
title?: string;
|
|
115
|
+
description?: string;
|
|
116
|
+
/** Image URLs to attach (with `text`). */
|
|
117
|
+
photo_urls?: string[];
|
|
118
|
+
/** Target platforms. Defaults to ALL connected platforms. */
|
|
119
|
+
platforms?: PublishPlatform[];
|
|
120
|
+
scheduled_date?: string;
|
|
121
|
+
timezone?: string;
|
|
122
|
+
callback_url?: string;
|
|
123
|
+
metadata?: Record<string, unknown>;
|
|
124
|
+
idempotency_key?: string;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/** Accepted-publish envelope (HTTP 202) returned by client.publish(). */
|
|
128
|
+
export interface PublishResponse {
|
|
129
|
+
object: string;
|
|
130
|
+
/** Primary upstream request_id — poll get_status(id). */
|
|
131
|
+
id: string;
|
|
132
|
+
/** All upstream request_ids (article spanning text+photo may return two). */
|
|
133
|
+
request_ids: string[];
|
|
134
|
+
status: "processing";
|
|
135
|
+
platforms: string[];
|
|
136
|
+
skipped?: string[];
|
|
137
|
+
metadata?: Record<string, unknown>;
|
|
138
|
+
links?: { status?: string };
|
|
139
|
+
meta?: { request_id?: string; widecast_version?: string };
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/** One idea in an IdeasResponse. */
|
|
143
|
+
export interface Idea {
|
|
144
|
+
title: string;
|
|
145
|
+
description: string;
|
|
146
|
+
industry?: string;
|
|
147
|
+
audience?: string;
|
|
148
|
+
professional?: string;
|
|
149
|
+
level?: string;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/** Synchronous response of suggest_ideas / collect_ideas. */
|
|
153
|
+
export interface IdeasResponse {
|
|
154
|
+
object: string;
|
|
155
|
+
industry?: string;
|
|
156
|
+
ideas: Idea[];
|
|
157
|
+
}
|
|
158
|
+
// Media-ingest sources (A49): audio/video by URL (YouTube/TikTok/FB) or by an
|
|
159
|
+
// uploaded file (multipart). field = the request key. output_type scene/video
|
|
160
|
+
// = full build; text = Remake (transcript only, A50). language/video_length/
|
|
161
|
+
// research_enabled do NOT apply.
|
|
162
|
+
export const MEDIA_SOURCES = {
|
|
163
|
+
video_url: { media_type: "video", input_kind: "url", field: "video_url" },
|
|
164
|
+
video_file: { media_type: "video", input_kind: "file", field: "video_file" },
|
|
165
|
+
audio_url: { media_type: "audio", input_kind: "url", field: "audio_url" },
|
|
166
|
+
audio_file: { media_type: "audio", input_kind: "file", field: "audio_file" },
|
|
167
|
+
} as const;
|
|
168
|
+
// Media caps (A49). Duration applies to ALL media and is enforced SERVER-SIDE
|
|
169
|
+
// (a thin SDK can't probe duration). File size applies to UPLOADS only and IS
|
|
170
|
+
// pre-validated client-side below. Mirror the dashboard2.py server constants.
|
|
171
|
+
export const MEDIA_MAX_DURATION_SECONDS = 120; // 2 min (media_too_long)
|
|
172
|
+
export const MEDIA_MAX_FILE_BYTES = 100 * 1024 * 1024; // 100 MB (file_too_large)
|
|
173
|
+
export const VIDEO_LENGTHS = ["short", "normal"] as const;
|
|
174
|
+
export type VideoLength = (typeof VIDEO_LENGTHS)[number];
|
|
175
|
+
export const LANGUAGES = ["English", "Vietnamese"] as const;
|
|
176
|
+
export type Language = (typeof LANGUAGES)[number];
|
|
177
|
+
|
|
178
|
+
// ────────────────────────────────────────────────────────────────────────────
|
|
179
|
+
// Types — mirror OpenAPI 3.1 schema, locked surface.
|
|
180
|
+
// ────────────────────────────────────────────────────────────────────────────
|
|
181
|
+
|
|
182
|
+
export type VideoStatus = "pending" | "processing" | "completed" | "failed";
|
|
183
|
+
|
|
184
|
+
export type ErrorCode =
|
|
185
|
+
| "account_expired"
|
|
186
|
+
| "credit_exhausted"
|
|
187
|
+
| "render_failed"
|
|
188
|
+
| "unknown_error"
|
|
189
|
+
| "scenes_not_ready"
|
|
190
|
+
| "export_failed"
|
|
191
|
+
| "script_too_short"
|
|
192
|
+
| "script_too_long"
|
|
193
|
+
| "invalid_output_type";
|
|
194
|
+
|
|
195
|
+
export interface StatusResult {
|
|
196
|
+
review_url: string;
|
|
197
|
+
/** Direct MP4 URL. Present only when output_type="video" OR after
|
|
198
|
+
* /v1/export_video completes. */
|
|
199
|
+
video_url?: string;
|
|
200
|
+
// v0.1.0 keeps `result` MINIMAL — no script dump, no scenes_count. A future
|
|
201
|
+
// `GET /v1/videos/{id}/script` will serve the full rendered script when needed.
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/**
|
|
205
|
+
* Fine-grained worker state surfaced from the legacy row.
|
|
206
|
+
* ⚠ DELIBERATE NAME CLASH: `details.status` is a free-form legacy string
|
|
207
|
+
* (e.g. "Completed", "Avatar videos downloaded"), DISTINCT from the
|
|
208
|
+
* top-level `status` enum. Gate logic on the top-level field only.
|
|
209
|
+
*/
|
|
210
|
+
export interface ProcessingDetails {
|
|
211
|
+
step: number;
|
|
212
|
+
status: string;
|
|
213
|
+
notes: string;
|
|
214
|
+
updated_at?: string;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
export interface StatusError {
|
|
218
|
+
code: ErrorCode | string;
|
|
219
|
+
message: string;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
export interface VideoResource {
|
|
223
|
+
object: "status";
|
|
224
|
+
id: string;
|
|
225
|
+
topic_id: string;
|
|
226
|
+
type: "video";
|
|
227
|
+
status: VideoStatus;
|
|
228
|
+
stage: string;
|
|
229
|
+
progress: number;
|
|
230
|
+
details: ProcessingDetails | null;
|
|
231
|
+
result: StatusResult | null;
|
|
232
|
+
error: StatusError | null;
|
|
233
|
+
callback_url?: string | null;
|
|
234
|
+
metadata: Record<string, unknown>;
|
|
235
|
+
usage: Record<string, unknown> | null;
|
|
236
|
+
links: { self: string };
|
|
237
|
+
meta: {
|
|
238
|
+
request_id: string;
|
|
239
|
+
widecast_version: string;
|
|
240
|
+
};
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
export interface CreateVideoOptions {
|
|
244
|
+
/** Input flow: "text" (default — backward-compat) requires `script_text`;
|
|
245
|
+
* "idea" requires `idea_text` (+ optional language / video_length /
|
|
246
|
+
* research_enabled). */
|
|
247
|
+
source?: Source;
|
|
248
|
+
/** Plain text script — required when `source="text"`. Used VERBATIM by the
|
|
249
|
+
* narrator (no AI rewriting). Must be `SCRIPT_MIN_WORDS`–`SCRIPT_MAX_WORDS`
|
|
250
|
+
* words (80–500, ~20s–2min). Word count = whitespace split.
|
|
251
|
+
* May contain inline image/video file URLs in either form:
|
|
252
|
+
* - Markdown image syntax (recommended for AI-chat callers; chat hosts
|
|
253
|
+
* render the picture inline so the end-user can visually approve each
|
|
254
|
+
* scene): ``.
|
|
255
|
+
* - Raw URL on its own line (backward compat): `… https://… …`.
|
|
256
|
+
* Direct file links only — .png/.jpg/.jpeg/.gif/.webp/.bmp/.avif/.svg or
|
|
257
|
+
* .mp4/.webm/.mov/.m4v/.avi (optional ?query). WideCast strips both forms
|
|
258
|
+
* from the narration and uses them as that scene's visual instead of
|
|
259
|
+
* auto-sourced B-roll. Page links (e.g. youtube.com/watch) are NOT
|
|
260
|
+
* inlined; use `source="video_url"` for a whole clip. */
|
|
261
|
+
script_text?: string;
|
|
262
|
+
/** Short idea description — required when `source="idea"`. Server writes
|
|
263
|
+
* a narration from this (AI) then continues into scene-sourcing. Bounds:
|
|
264
|
+
* `IDEA_MIN_WORDS` (5) min, `IDEA_MAX_WORDS` (1000) max — over-max is
|
|
265
|
+
* auto-truncated server-side, NOT rejected. Original word count surfaces
|
|
266
|
+
* in `details.input_truncated_from`. */
|
|
267
|
+
idea_text?: string;
|
|
268
|
+
/** Blog/article to repurpose — required when `source="blog"`. Same AI
|
|
269
|
+
* script-writer + pipeline as idea, just a longer input. Bounds:
|
|
270
|
+
* `BLOG_MIN_WORDS` (30) min, `BLOG_MAX_WORDS` (3000) max — over-max
|
|
271
|
+
* auto-truncated, surfaced in `details.input_truncated_from`. */
|
|
272
|
+
blog_text?: string;
|
|
273
|
+
/** Media URL (YouTube/TikTok/Facebook) — required when source="video_url" /
|
|
274
|
+
* "audio_url" (A49). The media's own audio becomes the narration / footage
|
|
275
|
+
* becomes b-roll. */
|
|
276
|
+
video_url?: string;
|
|
277
|
+
audio_url?: string;
|
|
278
|
+
/** Media file to upload — required when source="video_file" / "audio_file"
|
|
279
|
+
* (A49). Sent as multipart/form-data. Pass a Blob/File (in Node 18+ build one
|
|
280
|
+
* with `new Blob([buffer])` or `await openAsBlob(path)`). */
|
|
281
|
+
video_file?: Blob;
|
|
282
|
+
audio_file?: Blob;
|
|
283
|
+
/** Narration language (generative sources only). Default "English". Locked
|
|
284
|
+
* enum v0.1.0: see `LANGUAGES`. */
|
|
285
|
+
language?: Language;
|
|
286
|
+
/** Target video length (generative sources only). Default "short". "normal"
|
|
287
|
+
* caps at ~3 min. See `VIDEO_LENGTHS`. */
|
|
288
|
+
video_length?: VideoLength;
|
|
289
|
+
/** Whether the AI does research / fact-check during narration generation
|
|
290
|
+
* (generative sources only). Default true. */
|
|
291
|
+
research_enabled?: boolean;
|
|
292
|
+
/** Pipeline depth. "text" stops after the source→script phase (review_url →
|
|
293
|
+
* Script Editor; generative sources only, NOT source="text"). "scene"
|
|
294
|
+
* (default) stops at scenes-ready-for-review. "video" auto-chains into the
|
|
295
|
+
* renderer for the final MP4. */
|
|
296
|
+
output_type?: OutputType;
|
|
297
|
+
/** If true, every scene is B-roll (no narrator A-roll anywhere) — a
|
|
298
|
+
* "faceless" video. Default false (scenes mix A-roll + B-roll). Only valid
|
|
299
|
+
* with output_type scene/video for sources text/idea/blog (FACELESS_SOURCES);
|
|
300
|
+
* otherwise the server returns invalid_faceless. */
|
|
301
|
+
faceless?: boolean;
|
|
302
|
+
/** Extra direct image/video URLs you couldn't confidently place inline →
|
|
303
|
+
* added to the first scene's media library so the scene editor lists them
|
|
304
|
+
* for the user to drop into any scene. Direct file links only. */
|
|
305
|
+
media_pool?: string[];
|
|
306
|
+
wait_for_render?: boolean;
|
|
307
|
+
callback_url?: string;
|
|
308
|
+
metadata?: Record<string, unknown>;
|
|
309
|
+
idempotency_key?: string;
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
export interface WidecastConfig {
|
|
313
|
+
apiKey?: string;
|
|
314
|
+
baseUrl?: string;
|
|
315
|
+
timeoutMs?: number;
|
|
316
|
+
maxRetries?: number;
|
|
317
|
+
userAgent?: string;
|
|
318
|
+
fetchImpl?: typeof fetch;
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
export interface WaitOptions {
|
|
322
|
+
timeoutMs?: number;
|
|
323
|
+
initialIntervalMs?: number;
|
|
324
|
+
maxIntervalMs?: number;
|
|
325
|
+
backoffMultiplier?: number;
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
// ────────────────────────────────────────────────────────────────────────────
|
|
329
|
+
// Errors — one class per error.type for granular catching.
|
|
330
|
+
// ────────────────────────────────────────────────────────────────────────────
|
|
331
|
+
interface ErrorBody {
|
|
332
|
+
type?: string;
|
|
333
|
+
code?: string;
|
|
334
|
+
message?: string;
|
|
335
|
+
param?: string;
|
|
336
|
+
doc_url?: string;
|
|
337
|
+
request_id?: string;
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
export class WidecastError extends Error {
|
|
341
|
+
code: string;
|
|
342
|
+
requestId: string;
|
|
343
|
+
status: number;
|
|
344
|
+
docUrl: string;
|
|
345
|
+
param?: string;
|
|
346
|
+
responseJson: unknown;
|
|
347
|
+
constructor(message: string, opts: Partial<{
|
|
348
|
+
code: string; requestId: string; status: number;
|
|
349
|
+
docUrl: string; param: string; responseJson: unknown;
|
|
350
|
+
}> = {}) {
|
|
351
|
+
super(message);
|
|
352
|
+
this.name = "WidecastError";
|
|
353
|
+
this.code = opts.code ?? "";
|
|
354
|
+
this.requestId = opts.requestId ?? "";
|
|
355
|
+
this.status = opts.status ?? 0;
|
|
356
|
+
this.docUrl = opts.docUrl ?? "";
|
|
357
|
+
this.param = opts.param;
|
|
358
|
+
this.responseJson = opts.responseJson;
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
export class InvalidRequestError extends WidecastError { constructor(m: string, o = {}) { super(m, o); this.name = "InvalidRequestError"; } }
|
|
362
|
+
export class NotFoundError extends WidecastError { constructor(m: string, o = {}) { super(m, o); this.name = "NotFoundError"; } }
|
|
363
|
+
export class PreconditionFailedError extends WidecastError { constructor(m: string, o = {}) { super(m, o); this.name = "PreconditionFailedError"; } }
|
|
364
|
+
export class RateLimitError extends WidecastError { constructor(m: string, o = {}) { super(m, o); this.name = "RateLimitError"; } }
|
|
365
|
+
export class APIError extends WidecastError { constructor(m: string, o = {}) { super(m, o); this.name = "APIError"; } }
|
|
366
|
+
|
|
367
|
+
const ERROR_CLASSES: Record<string, new (m: string, o: object) => WidecastError> = {
|
|
368
|
+
invalid_request_error: InvalidRequestError,
|
|
369
|
+
not_found_error: NotFoundError,
|
|
370
|
+
precondition_failed: PreconditionFailedError,
|
|
371
|
+
rate_limit_error: RateLimitError,
|
|
372
|
+
api_error: APIError,
|
|
373
|
+
authentication_error: WidecastError,
|
|
374
|
+
permission_error: WidecastError,
|
|
375
|
+
};
|
|
376
|
+
|
|
377
|
+
// ────────────────────────────────────────────────────────────────────────────
|
|
378
|
+
// Video — wraps a status resource with .wait() helper + ergonomic accessors.
|
|
379
|
+
// ────────────────────────────────────────────────────────────────────────────
|
|
380
|
+
export class Video implements VideoResource {
|
|
381
|
+
object!: "status";
|
|
382
|
+
id!: string;
|
|
383
|
+
topic_id!: string;
|
|
384
|
+
type!: "video";
|
|
385
|
+
status!: VideoStatus;
|
|
386
|
+
stage!: string;
|
|
387
|
+
progress!: number;
|
|
388
|
+
details!: ProcessingDetails | null;
|
|
389
|
+
result!: StatusResult | null;
|
|
390
|
+
error!: StatusError | null;
|
|
391
|
+
callback_url?: string | null;
|
|
392
|
+
metadata!: Record<string, unknown>;
|
|
393
|
+
usage!: Record<string, unknown> | null;
|
|
394
|
+
links!: { self: string };
|
|
395
|
+
meta!: VideoResource["meta"];
|
|
396
|
+
|
|
397
|
+
#client: Widecast;
|
|
398
|
+
|
|
399
|
+
constructor(data: VideoResource, client: Widecast) {
|
|
400
|
+
Object.assign(this, data);
|
|
401
|
+
this.#client = client;
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
get isTerminal(): boolean {
|
|
405
|
+
return (TERMINAL_STATUSES as readonly string[]).includes(this.status);
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
// ── Ergonomic unwrapper for `result` ─────────────────────────────────────
|
|
409
|
+
/** URL where the user reviews the rendered scenes + audio. Present from the
|
|
410
|
+
* first response (pending / processing / completed) — the review page
|
|
411
|
+
* handles early arrival itself (spinner + in-page polling), so this is
|
|
412
|
+
* safe to share with the user before status='completed'. */
|
|
413
|
+
get review_url(): string | null { return this.result?.review_url ?? null; }
|
|
414
|
+
/** Direct MP4 URL — present only when status='completed' AND the video was
|
|
415
|
+
* created with output_type='video' (or exported via client.export_video). */
|
|
416
|
+
get video_url(): string | null { return this.result?.video_url ?? null; }
|
|
417
|
+
|
|
418
|
+
/** Poll /v1/status until terminal or timeout.
|
|
419
|
+
* Default: fixed 5-second polling (no backoff). Override kwargs for
|
|
420
|
+
* long-running polls where backoff is preferred. */
|
|
421
|
+
async wait(opts: WaitOptions = {}): Promise<Video> {
|
|
422
|
+
const timeoutMs = opts.timeoutMs ?? 600_000;
|
|
423
|
+
const maxIntervalMs = opts.maxIntervalMs ?? 5_000;
|
|
424
|
+
const backoff = opts.backoffMultiplier ?? 1.0;
|
|
425
|
+
let interval = opts.initialIntervalMs ?? 5_000;
|
|
426
|
+
const deadline = Date.now() + timeoutMs;
|
|
427
|
+
let latest: Video = this;
|
|
428
|
+
while (!latest.isTerminal && Date.now() < deadline) {
|
|
429
|
+
const remaining = deadline - Date.now();
|
|
430
|
+
await sleep(Math.min(interval, Math.max(100, remaining)));
|
|
431
|
+
latest = await this.#client.get_status(this.id);
|
|
432
|
+
interval = Math.min(interval * backoff, maxIntervalMs);
|
|
433
|
+
}
|
|
434
|
+
return latest;
|
|
435
|
+
}
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
// ────────────────────────────────────────────────────────────────────────────
|
|
439
|
+
// Widecast client
|
|
440
|
+
// ────────────────────────────────────────────────────────────────────────────
|
|
441
|
+
export class Widecast {
|
|
442
|
+
apiKey?: string;
|
|
443
|
+
baseUrl: string;
|
|
444
|
+
timeoutMs: number;
|
|
445
|
+
maxRetries: number;
|
|
446
|
+
userAgent: string;
|
|
447
|
+
#fetch: typeof fetch;
|
|
448
|
+
|
|
449
|
+
constructor(cfg: WidecastConfig = {}) {
|
|
450
|
+
this.apiKey = cfg.apiKey ?? (typeof process !== "undefined" ? process.env?.WIDECAST_API_KEY : undefined);
|
|
451
|
+
this.baseUrl = (cfg.baseUrl ?? DEFAULT_BASE_URL).replace(/\/+$/, "");
|
|
452
|
+
this.timeoutMs = cfg.timeoutMs ?? 60_000;
|
|
453
|
+
this.maxRetries = cfg.maxRetries ?? 3;
|
|
454
|
+
this.userAgent = cfg.userAgent ?? `widecast-js/${VERSION}`;
|
|
455
|
+
this.#fetch = cfg.fetchImpl ?? globalThis.fetch;
|
|
456
|
+
if (!this.#fetch) {
|
|
457
|
+
throw new Error("No fetch implementation found. Pass `fetchImpl` for environments without global fetch.");
|
|
458
|
+
}
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
// ── Public methods ──────────────────────────────────────────────────────
|
|
462
|
+
async create_video(opts: CreateVideoOptions): Promise<Video> {
|
|
463
|
+
if (!opts) {
|
|
464
|
+
throw new InvalidRequestError(
|
|
465
|
+
"create_video requires options.",
|
|
466
|
+
{ code: "missing_field", param: "options" },
|
|
467
|
+
);
|
|
468
|
+
}
|
|
469
|
+
const source: Source = (opts.source ?? "text") as Source;
|
|
470
|
+
if (!SOURCES.includes(source)) {
|
|
471
|
+
throw new InvalidRequestError(
|
|
472
|
+
`source must be one of ${JSON.stringify(SOURCES)} (got ${JSON.stringify(opts.source)}).`,
|
|
473
|
+
{ code: "invalid_source", param: "source" },
|
|
474
|
+
);
|
|
475
|
+
}
|
|
476
|
+
const outputType: OutputType = opts.output_type ?? "scene";
|
|
477
|
+
if (!OUTPUT_TYPES.includes(outputType)) {
|
|
478
|
+
throw new InvalidRequestError(
|
|
479
|
+
`output_type must be one of ${JSON.stringify(OUTPUT_TYPES)} (got ${JSON.stringify(opts.output_type)}).`,
|
|
480
|
+
{ code: "invalid_output_type", param: "output_type" },
|
|
481
|
+
);
|
|
482
|
+
}
|
|
483
|
+
// output_type='text' = stop after the source→script-text phase, which
|
|
484
|
+
// only exists for generative sources. source='text' already supplies the
|
|
485
|
+
// script, so 'text' output would echo the input (A46).
|
|
486
|
+
if (outputType === "text" && source === "text") {
|
|
487
|
+
throw new InvalidRequestError(
|
|
488
|
+
"output_type='text' requires a generative source (e.g. source='idea'). " +
|
|
489
|
+
"With source='text' you already supplied the script — use output_type 'scene' or 'video'.",
|
|
490
|
+
{ code: "invalid_output_type", param: "output_type" },
|
|
491
|
+
);
|
|
492
|
+
}
|
|
493
|
+
// faceless = force every scene to B-roll (no narrator A-roll). Only valid
|
|
494
|
+
// with scenes (scene/video) for the script-based sources. Mirrors the
|
|
495
|
+
// server's invalid_faceless rule.
|
|
496
|
+
const faceless = opts.faceless === true;
|
|
497
|
+
if (opts.faceless !== undefined && typeof opts.faceless !== "boolean") {
|
|
498
|
+
throw new InvalidRequestError(
|
|
499
|
+
`faceless must be a boolean (got ${JSON.stringify(opts.faceless)}).`,
|
|
500
|
+
{ code: "invalid_faceless", param: "faceless" },
|
|
501
|
+
);
|
|
502
|
+
}
|
|
503
|
+
if (faceless) {
|
|
504
|
+
if (outputType === "text") {
|
|
505
|
+
throw new InvalidRequestError(
|
|
506
|
+
"faceless controls A/B-roll for generated scenes; it has no effect " +
|
|
507
|
+
"with output_type='text'. Use output_type 'scene' or 'video'.",
|
|
508
|
+
{ code: "invalid_faceless", param: "faceless" },
|
|
509
|
+
);
|
|
510
|
+
}
|
|
511
|
+
if (!(FACELESS_SOURCES as readonly string[]).includes(source)) {
|
|
512
|
+
throw new InvalidRequestError(
|
|
513
|
+
`faceless is only supported for source in ${JSON.stringify(FACELESS_SOURCES)} ` +
|
|
514
|
+
`(got source=${JSON.stringify(source)}).`,
|
|
515
|
+
{ code: "invalid_faceless", param: "faceless" },
|
|
516
|
+
);
|
|
517
|
+
}
|
|
518
|
+
}
|
|
519
|
+
const body: Record<string, unknown> = { source, output_type: outputType };
|
|
520
|
+
if (faceless) body.faceless = true;
|
|
521
|
+
if (Array.isArray(opts.media_pool) && opts.media_pool.length) {
|
|
522
|
+
body.media_pool = opts.media_pool.filter((u) => typeof u === "string" && u.trim());
|
|
523
|
+
}
|
|
524
|
+
// Media file sources (video_file / audio_file) are sent as multipart;
|
|
525
|
+
// these hold the file + its field name until dispatch.
|
|
526
|
+
let uploadField: string | undefined;
|
|
527
|
+
let uploadFile: Blob | undefined;
|
|
528
|
+
|
|
529
|
+
if (source === "text") {
|
|
530
|
+
if (typeof opts.script_text !== "string" || !opts.script_text.trim()) {
|
|
531
|
+
throw new InvalidRequestError(
|
|
532
|
+
"script_text (non-empty string) is required when source='text'.",
|
|
533
|
+
{ code: "missing_field", param: "script_text" },
|
|
534
|
+
);
|
|
535
|
+
}
|
|
536
|
+
const wordCount = opts.script_text.trim().split(/\s+/).length;
|
|
537
|
+
if (wordCount < SCRIPT_MIN_WORDS) {
|
|
538
|
+
throw new InvalidRequestError(
|
|
539
|
+
`script_text has ${wordCount} words; minimum is ${SCRIPT_MIN_WORDS} (~20s of narration).`,
|
|
540
|
+
{ code: "script_too_short", param: "script_text" },
|
|
541
|
+
);
|
|
542
|
+
}
|
|
543
|
+
if (wordCount > SCRIPT_MAX_WORDS) {
|
|
544
|
+
throw new InvalidRequestError(
|
|
545
|
+
`script_text has ${wordCount} words; maximum is ${SCRIPT_MAX_WORDS} (~120s / 2 min of narration).`,
|
|
546
|
+
{ code: "script_too_long", param: "script_text" },
|
|
547
|
+
);
|
|
548
|
+
}
|
|
549
|
+
body.script_text = opts.script_text;
|
|
550
|
+
} else if (source === "idea" || source === "blog") { // generative — A48
|
|
551
|
+
// Pick the input field + bounds + error codes by source.
|
|
552
|
+
const genSpec = source === "idea"
|
|
553
|
+
? { field: "idea_text", value: opts.idea_text, min: IDEA_MIN_WORDS,
|
|
554
|
+
missingCode: "missing_idea_text", tooShortCode: "idea_too_short" }
|
|
555
|
+
: { field: "blog_text", value: opts.blog_text, min: BLOG_MIN_WORDS,
|
|
556
|
+
missingCode: "missing_blog_text", tooShortCode: "blog_too_short" };
|
|
557
|
+
if (typeof genSpec.value !== "string" || !genSpec.value.trim()) {
|
|
558
|
+
throw new InvalidRequestError(
|
|
559
|
+
`${genSpec.field} (non-empty string) is required when source='${source}'.`,
|
|
560
|
+
{ code: genSpec.missingCode, param: genSpec.field },
|
|
561
|
+
);
|
|
562
|
+
}
|
|
563
|
+
const wordCount = genSpec.value.trim().split(/\s+/).length;
|
|
564
|
+
if (wordCount < genSpec.min) {
|
|
565
|
+
throw new InvalidRequestError(
|
|
566
|
+
`${genSpec.field} has ${wordCount} words; minimum is ${genSpec.min}.`,
|
|
567
|
+
{ code: genSpec.tooShortCode, param: genSpec.field },
|
|
568
|
+
);
|
|
569
|
+
}
|
|
570
|
+
// NO upper-bound rejection — server auto-truncates over the max.
|
|
571
|
+
const language: Language = (opts.language ?? "English") as Language;
|
|
572
|
+
if (!LANGUAGES.includes(language)) {
|
|
573
|
+
throw new InvalidRequestError(
|
|
574
|
+
`language must be one of ${JSON.stringify(LANGUAGES)} (got ${JSON.stringify(opts.language)}).`,
|
|
575
|
+
{ code: "invalid_language", param: "language" },
|
|
576
|
+
);
|
|
577
|
+
}
|
|
578
|
+
const videoLength: VideoLength = (opts.video_length ?? "short") as VideoLength;
|
|
579
|
+
if (!VIDEO_LENGTHS.includes(videoLength)) {
|
|
580
|
+
throw new InvalidRequestError(
|
|
581
|
+
`video_length must be one of ${JSON.stringify(VIDEO_LENGTHS)} (got ${JSON.stringify(opts.video_length)}).`,
|
|
582
|
+
{ code: "invalid_video_length", param: "video_length" },
|
|
583
|
+
);
|
|
584
|
+
}
|
|
585
|
+
const researchEnabled = opts.research_enabled ?? true;
|
|
586
|
+
if (typeof researchEnabled !== "boolean") {
|
|
587
|
+
throw new InvalidRequestError(
|
|
588
|
+
`research_enabled must be a boolean (got ${typeof researchEnabled}).`,
|
|
589
|
+
{ code: "invalid_research_enabled", param: "research_enabled" },
|
|
590
|
+
);
|
|
591
|
+
}
|
|
592
|
+
body[genSpec.field] = genSpec.value;
|
|
593
|
+
body.language = language;
|
|
594
|
+
body.video_length = videoLength;
|
|
595
|
+
body.research_enabled = researchEnabled;
|
|
596
|
+
} else { // media-ingest source (audio/video, url/file) — A49/A50
|
|
597
|
+
const media = MEDIA_SOURCES[source as keyof typeof MEDIA_SOURCES];
|
|
598
|
+
if (media.input_kind === "url") {
|
|
599
|
+
const value = source === "video_url" ? opts.video_url : opts.audio_url;
|
|
600
|
+
if (typeof value !== "string" || !value.trim()) {
|
|
601
|
+
throw new InvalidRequestError(
|
|
602
|
+
`${media.field} (a media URL) is required when source='${source}'.`,
|
|
603
|
+
{ code: `missing_${media.field}`, param: media.field },
|
|
604
|
+
);
|
|
605
|
+
}
|
|
606
|
+
body[media.field] = value.trim();
|
|
607
|
+
} else { // file → multipart dispatch
|
|
608
|
+
const file = source === "video_file" ? opts.video_file : opts.audio_file;
|
|
609
|
+
if (!file) {
|
|
610
|
+
throw new InvalidRequestError(
|
|
611
|
+
`${media.field} (a Blob/File) is required when source='${source}'.`,
|
|
612
|
+
{ code: "missing_media_file", param: media.field },
|
|
613
|
+
);
|
|
614
|
+
}
|
|
615
|
+
// File-size pre-validation (uploads only — 100 MB). Duration is
|
|
616
|
+
// enforced server-side.
|
|
617
|
+
if (typeof file.size === "number" && file.size > MEDIA_MAX_FILE_BYTES) {
|
|
618
|
+
throw new InvalidRequestError(
|
|
619
|
+
`${media.field} is ${(file.size / 1024 / 1024).toFixed(1)} MB; maximum is ` +
|
|
620
|
+
`${Math.floor(MEDIA_MAX_FILE_BYTES / (1024 * 1024))} MB.`,
|
|
621
|
+
{ code: "file_too_large", param: media.field },
|
|
622
|
+
);
|
|
623
|
+
}
|
|
624
|
+
uploadField = media.field;
|
|
625
|
+
uploadFile = file;
|
|
626
|
+
}
|
|
627
|
+
}
|
|
628
|
+
|
|
629
|
+
if (opts.wait_for_render) body.wait_for_render = true;
|
|
630
|
+
if (opts.callback_url) body.callback_url = opts.callback_url;
|
|
631
|
+
if (opts.metadata) body.metadata = opts.metadata;
|
|
632
|
+
const idem = opts.idempotency_key ?? randomUuid();
|
|
633
|
+
|
|
634
|
+
// Media file sources go out as multipart/form-data (mirrors the UI upload);
|
|
635
|
+
// everything else is JSON.
|
|
636
|
+
let data: VideoResource;
|
|
637
|
+
if (uploadField) {
|
|
638
|
+
const form = new FormData();
|
|
639
|
+
for (const [k, v] of Object.entries(body)) {
|
|
640
|
+
form.set(k, typeof v === "object" && v !== null ? JSON.stringify(v) : String(v));
|
|
641
|
+
}
|
|
642
|
+
form.set(uploadField, uploadFile as Blob,
|
|
643
|
+
(uploadFile as { name?: string }).name ?? "upload");
|
|
644
|
+
data = await this.#requestMultipart<VideoResource>("/v1/create_video", form, idem);
|
|
645
|
+
} else {
|
|
646
|
+
data = await this.#request<VideoResource>("POST", "/v1/create_video", body, idem);
|
|
647
|
+
}
|
|
648
|
+
return new Video(data, this);
|
|
649
|
+
}
|
|
650
|
+
|
|
651
|
+
/** POST /v1/export_video — kick the final-MP4 renderer for an existing
|
|
652
|
+
* scene-output video. Idempotent: calling twice is a no-op.
|
|
653
|
+
* Throws `PreconditionFailedError` if scenes are not yet ready. */
|
|
654
|
+
async export_video(videoId: string): Promise<Video> {
|
|
655
|
+
if (!videoId || typeof videoId !== "string") {
|
|
656
|
+
throw new InvalidRequestError("video_id must be a non-empty string.", { code: "invalid_id", param: "video_id" });
|
|
657
|
+
}
|
|
658
|
+
const data = await this.#request<VideoResource>("POST", "/v1/export_video", { id: videoId });
|
|
659
|
+
return new Video(data, this);
|
|
660
|
+
}
|
|
661
|
+
|
|
662
|
+
async get_status(videoId: string): Promise<Video> {
|
|
663
|
+
if (!videoId || typeof videoId !== "string") {
|
|
664
|
+
throw new InvalidRequestError("video_id must be a non-empty string.", { code: "invalid_id", param: "video_id" });
|
|
665
|
+
}
|
|
666
|
+
const data = await this.#request<VideoResource>("GET", `/v1/status/${videoId}`);
|
|
667
|
+
return new Video(data, this);
|
|
668
|
+
}
|
|
669
|
+
|
|
670
|
+
/** POST /v1/create_content — generate written content (blog / social post)
|
|
671
|
+
* from a URL, an idea/topic, or pasted text. Async: returns a Video with
|
|
672
|
+
* `review_url` (the public content viewer) already populated — safe to
|
|
673
|
+
* share before completion; the viewer shows a spinner while content
|
|
674
|
+
* generates. Poll with `.wait()` for the final state. */
|
|
675
|
+
async create_content(opts: CreateContentOptions): Promise<Video> {
|
|
676
|
+
if (!opts || typeof opts.content !== "string" || !opts.content.trim()) {
|
|
677
|
+
throw new InvalidRequestError("content (a URL, idea, or text) is required.",
|
|
678
|
+
{ code: "missing_field", param: "content" });
|
|
679
|
+
}
|
|
680
|
+
const contentType = opts.content_type ?? "blog";
|
|
681
|
+
if (!(CONTENT_TYPES as readonly string[]).includes(contentType)) {
|
|
682
|
+
throw new InvalidRequestError(
|
|
683
|
+
`content_type must be one of ${JSON.stringify(CONTENT_TYPES)} (got ${JSON.stringify(opts.content_type)}).`,
|
|
684
|
+
{ code: "invalid_content_type", param: "content_type" });
|
|
685
|
+
}
|
|
686
|
+
const language = opts.language ?? "English";
|
|
687
|
+
if (typeof language !== "string" || !language.trim()) {
|
|
688
|
+
throw new InvalidRequestError('language (e.g. "English") is required.',
|
|
689
|
+
{ code: "missing_field", param: "language" });
|
|
690
|
+
}
|
|
691
|
+
const body: Record<string, unknown> = {
|
|
692
|
+
content: opts.content.trim(), content_type: contentType, language: language.trim(),
|
|
693
|
+
};
|
|
694
|
+
if (opts.callback_url) body.callback_url = opts.callback_url;
|
|
695
|
+
if (opts.metadata) body.metadata = opts.metadata;
|
|
696
|
+
const data = await this.#request<VideoResource>("POST", "/v1/create_content", body);
|
|
697
|
+
return new Video(data, this);
|
|
698
|
+
}
|
|
699
|
+
|
|
700
|
+
/** POST /v1/enhance_script — improve a DRAFT script with AI. Async: returns
|
|
701
|
+
* a Video with `review_url` (the Script Editor) already populated — safe
|
|
702
|
+
* to share before completion; the editor shows a spinner while enhancing.
|
|
703
|
+
* Poll with `.wait()` for the final enhanced script. */
|
|
704
|
+
async enhance_script(opts: EnhanceScriptOptions): Promise<Video> {
|
|
705
|
+
if (!opts || typeof opts.script_text !== "string" || !opts.script_text.trim()) {
|
|
706
|
+
throw new InvalidRequestError("script_text (the draft to enhance) is required.",
|
|
707
|
+
{ code: "missing_field", param: "script_text" });
|
|
708
|
+
}
|
|
709
|
+
const level = opts.intervention_level ?? 1;
|
|
710
|
+
if (!(INTERVENTION_LEVELS as readonly number[]).includes(level)) {
|
|
711
|
+
throw new InvalidRequestError(
|
|
712
|
+
`intervention_level must be one of ${JSON.stringify(INTERVENTION_LEVELS)} (got ${JSON.stringify(opts.intervention_level)}).`,
|
|
713
|
+
{ code: "invalid_intervention_level", param: "intervention_level" });
|
|
714
|
+
}
|
|
715
|
+
const body: Record<string, unknown> = {
|
|
716
|
+
script_text: opts.script_text.trim(), intervention_level: level,
|
|
717
|
+
};
|
|
718
|
+
if (typeof opts.language === "string" && opts.language.trim()) body.language = opts.language.trim();
|
|
719
|
+
if (opts.callback_url) body.callback_url = opts.callback_url;
|
|
720
|
+
if (opts.metadata) body.metadata = opts.metadata;
|
|
721
|
+
const data = await this.#request<VideoResource>("POST", "/v1/enhance_script", body);
|
|
722
|
+
return new Video(data, this);
|
|
723
|
+
}
|
|
724
|
+
|
|
725
|
+
/** POST /v1/suggest_ideas — SYNCHRONOUS. Returns video topic ideas for an
|
|
726
|
+
* industry immediately (no polling). `industry_id` falls back to your
|
|
727
|
+
* account industry if omitted. Consumes credits. */
|
|
728
|
+
async suggest_ideas(opts: SuggestIdeasOptions = {}): Promise<IdeasResponse> {
|
|
729
|
+
const body: Record<string, unknown> = { num_topics: opts.num_topics ?? 5 };
|
|
730
|
+
if (opts.industry_id) body.industry_id = opts.industry_id;
|
|
731
|
+
if (opts.sub_industry) body.sub_industry = opts.sub_industry;
|
|
732
|
+
if (opts.user_location) body.user_location = opts.user_location;
|
|
733
|
+
return await this.#request<IdeasResponse>("POST", "/v1/suggest_ideas", body);
|
|
734
|
+
}
|
|
735
|
+
|
|
736
|
+
/** POST /v1/collect_ideas — SYNCHRONOUS. Returns video ideas derived from a
|
|
737
|
+
* product/service description (≥10 chars) immediately. Consumes credits. */
|
|
738
|
+
async collect_ideas(opts: CollectIdeasOptions): Promise<IdeasResponse> {
|
|
739
|
+
if (!opts || typeof opts.product_service_input !== "string"
|
|
740
|
+
|| opts.product_service_input.trim().length < 10) {
|
|
741
|
+
throw new InvalidRequestError("product_service_input is required (≥10 chars).",
|
|
742
|
+
{ code: "missing_field", param: "product_service_input" });
|
|
743
|
+
}
|
|
744
|
+
const body: Record<string, unknown> = { product_service_input: opts.product_service_input.trim() };
|
|
745
|
+
if (opts.sub_industry) body.sub_industry = opts.sub_industry;
|
|
746
|
+
if (opts.user_location) body.user_location = opts.user_location;
|
|
747
|
+
return await this.#request<IdeasResponse>("POST", "/v1/collect_ideas", body);
|
|
748
|
+
}
|
|
749
|
+
|
|
750
|
+
/** POST /v1/publish — distribute content to connected social platforms.
|
|
751
|
+
* Provide EXACTLY ONE of topic_id / text / video_url. `platforms` defaults
|
|
752
|
+
* to ALL connected. Charges 1 credit. Returns the accepted-publish envelope
|
|
753
|
+
* (HTTP 202) — publishing is async on the platform side, so poll
|
|
754
|
+
* get_status(id) (with any of `request_ids`) for per-platform post URLs. */
|
|
755
|
+
async publish(opts: PublishOptions): Promise<PublishResponse> {
|
|
756
|
+
const modes = [opts.topic_id, opts.text, opts.video_url].filter(Boolean);
|
|
757
|
+
if (modes.length !== 1) {
|
|
758
|
+
throw new InvalidRequestError(
|
|
759
|
+
"Provide exactly one of topic_id, text, or video_url.",
|
|
760
|
+
{ code: "invalid_publish_input" });
|
|
761
|
+
}
|
|
762
|
+
if (opts.video_url && !(typeof opts.title === "string" && opts.title.trim())) {
|
|
763
|
+
throw new InvalidRequestError(
|
|
764
|
+
"title is required when posting an external video_url.",
|
|
765
|
+
{ code: "missing_field", param: "title" });
|
|
766
|
+
}
|
|
767
|
+
if (opts.platforms !== undefined) {
|
|
768
|
+
const bad = opts.platforms.filter(
|
|
769
|
+
(p) => !(PUBLISH_PLATFORMS as readonly string[]).includes(p));
|
|
770
|
+
if (bad.length) {
|
|
771
|
+
throw new InvalidRequestError(
|
|
772
|
+
`Unknown platform(s) ${JSON.stringify(bad)}. Valid: ${JSON.stringify(PUBLISH_PLATFORMS)}.`,
|
|
773
|
+
{ code: "invalid_platforms", param: "platforms" });
|
|
774
|
+
}
|
|
775
|
+
}
|
|
776
|
+
const body: Record<string, unknown> = {};
|
|
777
|
+
if (opts.topic_id) body.topic_id = opts.topic_id.trim();
|
|
778
|
+
if (opts.text) body.text = opts.text.trim();
|
|
779
|
+
if (opts.video_url) body.video_url = opts.video_url.trim();
|
|
780
|
+
if (opts.title) body.title = opts.title.trim();
|
|
781
|
+
if (opts.description) body.description = opts.description.trim();
|
|
782
|
+
if (opts.photo_urls) body.photo_urls = opts.photo_urls;
|
|
783
|
+
if (opts.platforms) body.platforms = opts.platforms;
|
|
784
|
+
if (opts.scheduled_date) body.scheduled_date = opts.scheduled_date;
|
|
785
|
+
if (opts.timezone) body.timezone = opts.timezone;
|
|
786
|
+
if (opts.callback_url) body.callback_url = opts.callback_url;
|
|
787
|
+
if (opts.metadata) body.metadata = opts.metadata;
|
|
788
|
+
return await this.#request<PublishResponse>("POST", "/v1/publish", body,
|
|
789
|
+
opts.idempotency_key);
|
|
790
|
+
}
|
|
791
|
+
|
|
792
|
+
// ── Read / library (Batch C — GET, synchronous, free) ───────────────────
|
|
793
|
+
#get<T>(path: string, params?: Record<string, unknown>): Promise<T> {
|
|
794
|
+
const qs = new URLSearchParams();
|
|
795
|
+
for (const [k, v] of Object.entries(params ?? {})) {
|
|
796
|
+
if (v !== undefined && v !== null && v !== "") qs.set(k, String(v));
|
|
797
|
+
}
|
|
798
|
+
const q = qs.toString();
|
|
799
|
+
return this.#request<T>("GET", q ? `${path}?${q}` : path);
|
|
800
|
+
}
|
|
801
|
+
|
|
802
|
+
/** GET /v1/videos — list the account's recent videos (20/page). Free. */
|
|
803
|
+
async list_videos(opts: { from_record?: number } = {}): Promise<any> {
|
|
804
|
+
return await this.#get("/v1/videos", { from_record: opts.from_record ?? 0 });
|
|
805
|
+
}
|
|
806
|
+
|
|
807
|
+
/** GET /v1/search — search the account's content by keywords. Free. */
|
|
808
|
+
async search(query: string, opts: { limit?: number } = {}): Promise<any> {
|
|
809
|
+
if (typeof query !== "string" || !query.trim()) {
|
|
810
|
+
throw new InvalidRequestError("query is required.", { code: "missing_field", param: "q" });
|
|
811
|
+
}
|
|
812
|
+
return await this.#get("/v1/search", { q: query.trim(), limit: opts.limit ?? 10 });
|
|
813
|
+
}
|
|
814
|
+
|
|
815
|
+
/** GET /v1/account — account profile + remaining credits. Free. */
|
|
816
|
+
async account(): Promise<any> {
|
|
817
|
+
return await this.#get("/v1/account");
|
|
818
|
+
}
|
|
819
|
+
|
|
820
|
+
/** GET /v1/analytics — social analytics dashboard. Free but SLOW. */
|
|
821
|
+
async analytics(opts: { period?: string; start_date?: string; end_date?: string } = {}): Promise<any> {
|
|
822
|
+
return await this.#get("/v1/analytics", {
|
|
823
|
+
period: opts.period ?? "last_week",
|
|
824
|
+
start_date: opts.start_date,
|
|
825
|
+
end_date: opts.end_date,
|
|
826
|
+
});
|
|
827
|
+
}
|
|
828
|
+
|
|
829
|
+
/** GET /v1/roadmap — the account's content roadmap. Free. */
|
|
830
|
+
async roadmap(opts: { cycle?: number } = {}): Promise<any> {
|
|
831
|
+
return await this.#get("/v1/roadmap", { cycle: opts.cycle ?? 1 });
|
|
832
|
+
}
|
|
833
|
+
|
|
834
|
+
/** GET /v1/production_plan — the weekly production plan. Free.
|
|
835
|
+
* NOTE: passing both week_start + week_end may backfill rows upstream. */
|
|
836
|
+
async production_plan(opts: { page?: number; week_start?: string; week_end?: string } = {}): Promise<any> {
|
|
837
|
+
return await this.#get("/v1/production_plan", {
|
|
838
|
+
page: opts.page ?? 0,
|
|
839
|
+
week_start: opts.week_start,
|
|
840
|
+
week_end: opts.week_end,
|
|
841
|
+
});
|
|
842
|
+
}
|
|
843
|
+
|
|
844
|
+
/** GET /v1/foundation_videos — curated foundation-video templates. Free. */
|
|
845
|
+
async foundation_videos(opts: { industry?: string; sub_industry?: string; page?: number } = {}): Promise<any> {
|
|
846
|
+
return await this.#get("/v1/foundation_videos", {
|
|
847
|
+
industry: opts.industry,
|
|
848
|
+
sub_industry: opts.sub_industry,
|
|
849
|
+
page: opts.page ?? 0,
|
|
850
|
+
});
|
|
851
|
+
}
|
|
852
|
+
|
|
853
|
+
/** GET /v1/recommendations — recommended video ideas for an industry. Free. */
|
|
854
|
+
async recommendations(opts: { industry?: string; page?: number } = {}): Promise<IdeasResponse> {
|
|
855
|
+
return await this.#get<IdeasResponse>("/v1/recommendations", {
|
|
856
|
+
industry: opts.industry,
|
|
857
|
+
page: opts.page ?? 0,
|
|
858
|
+
});
|
|
859
|
+
}
|
|
860
|
+
|
|
861
|
+
// ── Connections (Batch E — connect / accounts / configure, free) ────────
|
|
862
|
+
/** POST /v1/connect — get an OAuth link to connect a social platform. Free.
|
|
863
|
+
* Returns `{object:"connect", url, expires_in, ...}` — the USER opens `url`
|
|
864
|
+
* to complete the connection on the web (WideCast never performs the OAuth). */
|
|
865
|
+
async connect(opts: { platform?: PublishPlatform } = {}): Promise<any> {
|
|
866
|
+
if (opts.platform && !(PUBLISH_PLATFORMS as readonly string[]).includes(opts.platform)) {
|
|
867
|
+
throw new InvalidRequestError(
|
|
868
|
+
`Unknown platform ${JSON.stringify(opts.platform)}. Valid: ${JSON.stringify(PUBLISH_PLATFORMS)}.`,
|
|
869
|
+
{ code: "invalid_platforms", param: "platform" });
|
|
870
|
+
}
|
|
871
|
+
const body: Record<string, unknown> = {};
|
|
872
|
+
if (opts.platform) body.platform = opts.platform;
|
|
873
|
+
return await this.#request<any>("POST", "/v1/connect", body);
|
|
874
|
+
}
|
|
875
|
+
|
|
876
|
+
/** GET /v1/accounts — list the account's connected social platforms. Free. */
|
|
877
|
+
async accounts(): Promise<any> {
|
|
878
|
+
return await this.#get("/v1/accounts");
|
|
879
|
+
}
|
|
880
|
+
|
|
881
|
+
/** GET /v1/platform_settings — load saved per-platform publish settings. Free. */
|
|
882
|
+
async platform_settings(): Promise<any> {
|
|
883
|
+
return await this.#get("/v1/platform_settings");
|
|
884
|
+
}
|
|
885
|
+
|
|
886
|
+
/** POST /v1/platform_settings — save one platform's publish settings. Free. */
|
|
887
|
+
async set_platform_settings(platform: PublishPlatform, settings: Record<string, unknown>): Promise<any> {
|
|
888
|
+
if (!(PUBLISH_PLATFORMS as readonly string[]).includes(platform)) {
|
|
889
|
+
throw new InvalidRequestError(
|
|
890
|
+
`platform must be one of ${JSON.stringify(PUBLISH_PLATFORMS)} (got ${JSON.stringify(platform)}).`,
|
|
891
|
+
{ code: "invalid_platforms", param: "platform" });
|
|
892
|
+
}
|
|
893
|
+
if (!settings || typeof settings !== "object") {
|
|
894
|
+
throw new InvalidRequestError("settings (an object) is required.",
|
|
895
|
+
{ code: "missing_field", param: "settings" });
|
|
896
|
+
}
|
|
897
|
+
return await this.#request<any>("POST", "/v1/platform_settings", { platform, settings });
|
|
898
|
+
}
|
|
899
|
+
|
|
900
|
+
// ── HTTP plumbing ───────────────────────────────────────────────────────
|
|
901
|
+
async #request<T>(method: string, path: string, body?: unknown, idempotencyKey?: string): Promise<T> {
|
|
902
|
+
const url = this.baseUrl + path;
|
|
903
|
+
const headers: Record<string, string> = {
|
|
904
|
+
"Accept": "application/json",
|
|
905
|
+
"User-Agent": this.userAgent,
|
|
906
|
+
"X-Widecast-Sdk": `js/${VERSION}`,
|
|
907
|
+
};
|
|
908
|
+
// Telemetry header: opt-OUT via env. Only sends SDK name + version (no user data).
|
|
909
|
+
// Set WIDECAST_DISABLE_TELEMETRY=1 to suppress.
|
|
910
|
+
const disableTel = (typeof process !== "undefined" && process.env?.WIDECAST_DISABLE_TELEMETRY) || "";
|
|
911
|
+
if (!["1", "true", "yes"].includes(String(disableTel).toLowerCase())) {
|
|
912
|
+
headers["X-Widecast-Telemetry"] = `sdk=js/${VERSION}`;
|
|
913
|
+
}
|
|
914
|
+
if (body !== undefined) headers["Content-Type"] = "application/json";
|
|
915
|
+
if (this.apiKey) headers["Authorization"] = `Bearer ${this.apiKey}`;
|
|
916
|
+
if (idempotencyKey) headers["Idempotency-Key"] = idempotencyKey;
|
|
917
|
+
|
|
918
|
+
let lastErr: unknown;
|
|
919
|
+
for (let attempt = 0; attempt <= this.maxRetries; attempt++) {
|
|
920
|
+
const controller = new AbortController();
|
|
921
|
+
const tid = setTimeout(() => controller.abort(), this.timeoutMs);
|
|
922
|
+
try {
|
|
923
|
+
const resp = await this.#fetch(url, {
|
|
924
|
+
method,
|
|
925
|
+
headers,
|
|
926
|
+
body: body === undefined ? undefined : JSON.stringify(body),
|
|
927
|
+
signal: controller.signal,
|
|
928
|
+
});
|
|
929
|
+
clearTimeout(tid);
|
|
930
|
+
// retry on 5xx/429
|
|
931
|
+
if ((resp.status >= 500 || resp.status === 429) && attempt < this.maxRetries) {
|
|
932
|
+
const wait = resp.status === 429
|
|
933
|
+
? Number(resp.headers.get("Retry-After") ?? 2) * 1000
|
|
934
|
+
: backoffMs(attempt);
|
|
935
|
+
await sleep(wait);
|
|
936
|
+
continue;
|
|
937
|
+
}
|
|
938
|
+
return await decodeResponse<T>(resp);
|
|
939
|
+
} catch (e) {
|
|
940
|
+
clearTimeout(tid);
|
|
941
|
+
lastErr = e;
|
|
942
|
+
if (e instanceof WidecastError) throw e;
|
|
943
|
+
if (attempt >= this.maxRetries) {
|
|
944
|
+
throw new APIError(`Network error after ${attempt + 1} attempts: ${String(e)}`);
|
|
945
|
+
}
|
|
946
|
+
await sleep(backoffMs(attempt));
|
|
947
|
+
}
|
|
948
|
+
}
|
|
949
|
+
throw new APIError(`Exhausted retries: ${String(lastErr)}`);
|
|
950
|
+
}
|
|
951
|
+
|
|
952
|
+
/** POST multipart/form-data — for media file sources (video_file /
|
|
953
|
+
* audio_file). Single attempt (uploads aren't safe to blindly retry; the
|
|
954
|
+
* Idempotency-Key lets the server dedupe if you retry yourself). NOTE: no
|
|
955
|
+
* Content-Type header — fetch sets the multipart boundary from the FormData. */
|
|
956
|
+
async #requestMultipart<T>(path: string, form: FormData, idempotencyKey?: string): Promise<T> {
|
|
957
|
+
const url = this.baseUrl + path;
|
|
958
|
+
const headers: Record<string, string> = {
|
|
959
|
+
"Accept": "application/json",
|
|
960
|
+
"User-Agent": this.userAgent,
|
|
961
|
+
"X-Widecast-Sdk": `js/${VERSION}`,
|
|
962
|
+
};
|
|
963
|
+
const disableTel = (typeof process !== "undefined" && process.env?.WIDECAST_DISABLE_TELEMETRY) || "";
|
|
964
|
+
if (!["1", "true", "yes"].includes(String(disableTel).toLowerCase())) {
|
|
965
|
+
headers["X-Widecast-Telemetry"] = `sdk=js/${VERSION}`;
|
|
966
|
+
}
|
|
967
|
+
if (this.apiKey) headers["Authorization"] = `Bearer ${this.apiKey}`;
|
|
968
|
+
if (idempotencyKey) headers["Idempotency-Key"] = idempotencyKey;
|
|
969
|
+
|
|
970
|
+
const controller = new AbortController();
|
|
971
|
+
const tid = setTimeout(() => controller.abort(), this.timeoutMs);
|
|
972
|
+
try {
|
|
973
|
+
const resp = await this.#fetch(url, { method: "POST", headers, body: form, signal: controller.signal });
|
|
974
|
+
clearTimeout(tid);
|
|
975
|
+
return await decodeResponse<T>(resp);
|
|
976
|
+
} catch (e) {
|
|
977
|
+
clearTimeout(tid);
|
|
978
|
+
if (e instanceof WidecastError) throw e;
|
|
979
|
+
throw new APIError(`Network error during upload: ${String(e)}`);
|
|
980
|
+
}
|
|
981
|
+
}
|
|
982
|
+
}
|
|
983
|
+
|
|
984
|
+
// ────────────────────────────────────────────────────────────────────────────
|
|
985
|
+
// Module helpers
|
|
986
|
+
// ────────────────────────────────────────────────────────────────────────────
|
|
987
|
+
function sleep(ms: number): Promise<void> { return new Promise(r => setTimeout(r, ms)); }
|
|
988
|
+
|
|
989
|
+
function backoffMs(attempt: number): number {
|
|
990
|
+
const base = Math.min(500 * 2 ** attempt, 8_000);
|
|
991
|
+
return base + Math.random() * 250;
|
|
992
|
+
}
|
|
993
|
+
|
|
994
|
+
function randomUuid(): string {
|
|
995
|
+
if (typeof crypto !== "undefined" && (crypto as Crypto).randomUUID) {
|
|
996
|
+
return (crypto as Crypto).randomUUID();
|
|
997
|
+
}
|
|
998
|
+
return "xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx".replace(/[xy]/g, c => {
|
|
999
|
+
const r = (Math.random() * 16) | 0;
|
|
1000
|
+
return (c === "x" ? r : (r & 0x3) | 0x8).toString(16);
|
|
1001
|
+
});
|
|
1002
|
+
}
|
|
1003
|
+
|
|
1004
|
+
async function decodeResponse<T>(resp: Response): Promise<T> {
|
|
1005
|
+
const requestId = resp.headers.get("X-Request-Id") ?? "";
|
|
1006
|
+
let data: unknown = null;
|
|
1007
|
+
try { data = await resp.json(); } catch (_) { data = null; }
|
|
1008
|
+
|
|
1009
|
+
if (resp.ok) return data as T;
|
|
1010
|
+
|
|
1011
|
+
const err = ((data as { error?: ErrorBody })?.error) ?? {};
|
|
1012
|
+
const ErrClass = ERROR_CLASSES[err.type ?? "api_error"] ?? WidecastError;
|
|
1013
|
+
throw new ErrClass(err.message ?? `HTTP ${resp.status}`, {
|
|
1014
|
+
code: err.code ?? "",
|
|
1015
|
+
requestId: err.request_id ?? requestId,
|
|
1016
|
+
status: resp.status,
|
|
1017
|
+
docUrl: err.doc_url ?? "",
|
|
1018
|
+
param: err.param,
|
|
1019
|
+
responseJson: data,
|
|
1020
|
+
});
|
|
1021
|
+
}
|
|
1022
|
+
|
|
1023
|
+
// ────────────────────────────────────────────────────────────────────────────
|
|
1024
|
+
// Webhook signature verification.
|
|
1025
|
+
//
|
|
1026
|
+
// WideCast signs every webhook with HMAC-SHA256:
|
|
1027
|
+
// signed = `${timestamp}.${requestBody}`
|
|
1028
|
+
// sig = HMAC_SHA256(secret, signed).hexdigest()
|
|
1029
|
+
// header = `X-WideCast-Signature: t=<timestamp>,v1=<sig_hex>`
|
|
1030
|
+
//
|
|
1031
|
+
// Example (Node http):
|
|
1032
|
+
// import { verifyWebhook, WebhookVerificationError } from "@widecast/sdk";
|
|
1033
|
+
// const body = await readRawBody(req); // raw text, NOT re-stringified JSON
|
|
1034
|
+
// try {
|
|
1035
|
+
// const event = await verifyWebhook({
|
|
1036
|
+
// body,
|
|
1037
|
+
// signatureHeader: req.headers["x-widecast-signature"] as string,
|
|
1038
|
+
// secret: process.env.WIDECAST_WEBHOOK_SECRET!,
|
|
1039
|
+
// });
|
|
1040
|
+
// } catch (e) { ... }
|
|
1041
|
+
// ────────────────────────────────────────────────────────────────────────────
|
|
1042
|
+
export class WebhookVerificationError extends Error {
|
|
1043
|
+
constructor(m: string) { super(m); this.name = "WebhookVerificationError"; }
|
|
1044
|
+
}
|
|
1045
|
+
|
|
1046
|
+
export interface VerifyWebhookOptions {
|
|
1047
|
+
body: string; // raw request body as text — DO NOT re-stringify JSON
|
|
1048
|
+
signatureHeader: string; // full `t=…,v1=…` value
|
|
1049
|
+
secret: string;
|
|
1050
|
+
toleranceSeconds?: number; // default 300 (5 min)
|
|
1051
|
+
}
|
|
1052
|
+
|
|
1053
|
+
export async function verifyWebhook(opts: VerifyWebhookOptions): Promise<any> {
|
|
1054
|
+
const { body, signatureHeader, secret } = opts;
|
|
1055
|
+
const tolerance = opts.toleranceSeconds ?? 300;
|
|
1056
|
+
|
|
1057
|
+
if (!signatureHeader) throw new WebhookVerificationError("missing X-WideCast-Signature header");
|
|
1058
|
+
|
|
1059
|
+
const parts: Record<string, string> = {};
|
|
1060
|
+
for (const piece of signatureHeader.split(",")) {
|
|
1061
|
+
const [k, v] = piece.split("=", 2);
|
|
1062
|
+
if (k && v !== undefined) parts[k.trim()] = v;
|
|
1063
|
+
}
|
|
1064
|
+
const ts = parts["t"];
|
|
1065
|
+
const sig = parts["v1"];
|
|
1066
|
+
if (!ts || !sig) throw new WebhookVerificationError("signature header missing t= or v1=");
|
|
1067
|
+
const tsInt = Number(ts);
|
|
1068
|
+
if (!Number.isFinite(tsInt)) throw new WebhookVerificationError("t= is not an integer");
|
|
1069
|
+
if (Math.abs(Date.now() / 1000 - tsInt) > tolerance) {
|
|
1070
|
+
throw new WebhookVerificationError(
|
|
1071
|
+
`signature timestamp outside tolerance window (${tolerance}s) — possible replay`
|
|
1072
|
+
);
|
|
1073
|
+
}
|
|
1074
|
+
|
|
1075
|
+
const signed = `${ts}.${body}`;
|
|
1076
|
+
const expected = await hmacSha256Hex(secret, signed);
|
|
1077
|
+
if (!timingSafeEqual(expected, sig)) throw new WebhookVerificationError("signature mismatch");
|
|
1078
|
+
|
|
1079
|
+
try {
|
|
1080
|
+
return JSON.parse(body);
|
|
1081
|
+
} catch (e: any) {
|
|
1082
|
+
throw new WebhookVerificationError(`body is not valid JSON: ${e?.message}`);
|
|
1083
|
+
}
|
|
1084
|
+
}
|
|
1085
|
+
|
|
1086
|
+
async function hmacSha256Hex(secret: string, msg: string): Promise<string> {
|
|
1087
|
+
const enc = new TextEncoder();
|
|
1088
|
+
const subtle = (globalThis.crypto && (globalThis.crypto as any).subtle) || null;
|
|
1089
|
+
if (subtle) {
|
|
1090
|
+
const key = await subtle.importKey(
|
|
1091
|
+
"raw", enc.encode(secret), { name: "HMAC", hash: "SHA-256" }, false, ["sign"]
|
|
1092
|
+
);
|
|
1093
|
+
const buf = await subtle.sign("HMAC", key, enc.encode(msg));
|
|
1094
|
+
return Array.from(new Uint8Array(buf)).map(b => b.toString(16).padStart(2, "0")).join("");
|
|
1095
|
+
}
|
|
1096
|
+
// Node fallback
|
|
1097
|
+
const cryptoMod = await import("node:crypto");
|
|
1098
|
+
return cryptoMod.createHmac("sha256", secret).update(msg).digest("hex");
|
|
1099
|
+
}
|
|
1100
|
+
|
|
1101
|
+
function timingSafeEqual(a: string, b: string): boolean {
|
|
1102
|
+
if (a.length !== b.length) return false;
|
|
1103
|
+
let diff = 0;
|
|
1104
|
+
for (let i = 0; i < a.length; i++) diff |= a.charCodeAt(i) ^ b.charCodeAt(i);
|
|
1105
|
+
return diff === 0;
|
|
1106
|
+
}
|
|
1107
|
+
|
|
1108
|
+
// Default export for convenient `import Widecast from "@widecast/sdk"`.
|
|
1109
|
+
export default Widecast;
|