@contenthero/sdk 0.3.2 → 0.3.4
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/dist/client.d.ts +275 -14
- package/dist/client.d.ts.map +1 -1
- package/dist/client.js +517 -31
- package/dist/client.js.map +1 -1
- package/dist/credentials.d.ts +67 -0
- package/dist/credentials.d.ts.map +1 -0
- package/dist/credentials.js +96 -0
- package/dist/credentials.js.map +1 -0
- package/dist/errors.d.ts +22 -0
- package/dist/errors.d.ts.map +1 -1
- package/dist/errors.js +37 -0
- package/dist/errors.js.map +1 -1
- package/dist/index.d.ts +2 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -1
- package/dist/index.js.map +1 -1
- package/dist/types.d.ts +974 -4
- package/dist/types.d.ts.map +1 -1
- package/package.json +5 -1
package/dist/client.js
CHANGED
|
@@ -6,13 +6,20 @@
|
|
|
6
6
|
* SDK offers both a fire-and-forget `generate` and a `generateAndWait` that
|
|
7
7
|
* polls to a terminal state for you.
|
|
8
8
|
*/
|
|
9
|
-
import { errorFromResponse, GenerationFailedError, GenerationTimeoutError } from './errors.js';
|
|
9
|
+
import { errorFromResponse, GenerationFailedError, GenerationInterruptedError, GenerationTimeoutError, } from './errors.js';
|
|
10
10
|
const DEFAULT_BASE_URL = 'https://app.contenthero.ai';
|
|
11
11
|
const TERMINAL = new Set(['completed', 'failed']);
|
|
12
12
|
export class ContentHero {
|
|
13
13
|
apiKey;
|
|
14
14
|
baseUrl;
|
|
15
15
|
fetchImpl;
|
|
16
|
+
/** Per-project last-touched timestamp (ms). Presence is lit SERVER-SIDE (every project-scoped route broadcasts
|
|
17
|
+
* the badge), so this no longer drives per-call pings; it records which projects this client operated on so
|
|
18
|
+
* releaseProjectActivity / flushRelease can clear their badges promptly on exit, and debounces any explicit
|
|
19
|
+
* touchProjectActivity ping. */
|
|
20
|
+
activityPingedAt = new Map();
|
|
21
|
+
/** Min gap between explicit presence pings for the same project, so a burst does not spam. */
|
|
22
|
+
static ACTIVITY_DEBOUNCE_MS = 10_000;
|
|
16
23
|
constructor(options = {}) {
|
|
17
24
|
const apiKey = options.apiKey ?? readEnv('CONTENTHERO_API_KEY');
|
|
18
25
|
if (!apiKey) {
|
|
@@ -26,6 +33,43 @@ export class ContentHero {
|
|
|
26
33
|
this.baseUrl = (options.baseUrl ?? readEnv('CONTENTHERO_BASE_URL') ?? DEFAULT_BASE_URL).replace(/\/+$/, '');
|
|
27
34
|
this.fetchImpl = fetchImpl;
|
|
28
35
|
}
|
|
36
|
+
/**
|
|
37
|
+
* Explicitly signal PRESENCE for a project. Presence is normally lit SERVER-SIDE now (every project-scoped API
|
|
38
|
+
* route broadcasts the "Editing via MCP/CLI" badge for the resolved project), so the SDK's own methods no longer
|
|
39
|
+
* each call this. It remains for an external consumer that wants to signal presence ahead of working on a
|
|
40
|
+
* project, and it records the project for prompt release on exit. Debounced + fire-and-forget: never throws,
|
|
41
|
+
* never blocks, no-op without a projectId.
|
|
42
|
+
*/
|
|
43
|
+
touchProjectActivity(projectId) {
|
|
44
|
+
if (!projectId)
|
|
45
|
+
return;
|
|
46
|
+
const now = Date.now();
|
|
47
|
+
const last = this.activityPingedAt.get(projectId) ?? 0;
|
|
48
|
+
if (now - last < ContentHero.ACTIVITY_DEBOUNCE_MS)
|
|
49
|
+
return;
|
|
50
|
+
this.activityPingedAt.set(projectId, now);
|
|
51
|
+
void this.request('POST', '/api/v1/editor/activity', { projectId }).catch(() => { });
|
|
52
|
+
}
|
|
53
|
+
/**
|
|
54
|
+
* Explicitly RELEASE presence so the badge clears promptly (no TTL lingering). Use in a `finally` when a
|
|
55
|
+
* short-lived consumer (the CLI) finishes a command. Fire-and-forget.
|
|
56
|
+
*/
|
|
57
|
+
releaseProjectActivity(projectId) {
|
|
58
|
+
if (!projectId)
|
|
59
|
+
return;
|
|
60
|
+
this.activityPingedAt.delete(projectId);
|
|
61
|
+
void this.request('POST', '/api/v1/editor/activity', { projectId, release: true }).catch(() => { });
|
|
62
|
+
}
|
|
63
|
+
/**
|
|
64
|
+
* AWAIT a presence release for every project this client pinged. The CLI calls this before the process exits
|
|
65
|
+
* so the badge clears immediately (a short-lived process cannot rely on the sliding TTL). Best-effort:
|
|
66
|
+
* resolves even if the releases fail.
|
|
67
|
+
*/
|
|
68
|
+
async flushRelease() {
|
|
69
|
+
const ids = [...this.activityPingedAt.keys()];
|
|
70
|
+
this.activityPingedAt.clear();
|
|
71
|
+
await Promise.all(ids.map((projectId) => this.request('POST', '/api/v1/editor/activity', { projectId, release: true }).catch(() => { })));
|
|
72
|
+
}
|
|
29
73
|
/**
|
|
30
74
|
* Submit a generation. Returns immediately. For image/video the result is
|
|
31
75
|
* `status: 'processing'` (poll with `getGeneration` or use `generateAndWait`);
|
|
@@ -46,7 +90,10 @@ export class ContentHero {
|
|
|
46
90
|
*/
|
|
47
91
|
async generateAndWait(request, options = {}) {
|
|
48
92
|
const submitted = await this.generate(request);
|
|
49
|
-
|
|
93
|
+
const gen = await this.#waitAfterSubmit(submitted.outputId, options);
|
|
94
|
+
// Carry the placement outcome (known at submit time) through to the completed record so the caller learns
|
|
95
|
+
// where the asset landed + its id for chaining, without a separate lookup.
|
|
96
|
+
return submitted.placement ? { ...gen, placement: submitted.placement } : gen;
|
|
50
97
|
}
|
|
51
98
|
/**
|
|
52
99
|
* Submit a Reference Board: a dense multi-panel reference sheet built from a
|
|
@@ -65,7 +112,26 @@ export class ContentHero {
|
|
|
65
112
|
*/
|
|
66
113
|
async generateBoardAndWait(request, options = {}) {
|
|
67
114
|
const submitted = await this.generateBoard(request);
|
|
68
|
-
return this
|
|
115
|
+
return this.#waitAfterSubmit(submitted.outputId, options);
|
|
116
|
+
}
|
|
117
|
+
/**
|
|
118
|
+
* Poll a job that has ALREADY been submitted, converting any non-terminal failure into
|
|
119
|
+
* an error that still carries the outputId.
|
|
120
|
+
*
|
|
121
|
+
* Once the POST succeeds the job is running and charged, so the outputId is the only
|
|
122
|
+
* thing standing between a transient poll failure and a duplicate generation: a caller
|
|
123
|
+
* that loses it has no way to resume and will almost certainly resubmit. A genuine
|
|
124
|
+
* `GenerationFailedError` is terminal and passes through untouched.
|
|
125
|
+
*/
|
|
126
|
+
async #waitAfterSubmit(outputId, options) {
|
|
127
|
+
try {
|
|
128
|
+
return await this.waitForGeneration(outputId, options);
|
|
129
|
+
}
|
|
130
|
+
catch (err) {
|
|
131
|
+
if (err instanceof GenerationFailedError || err instanceof GenerationTimeoutError)
|
|
132
|
+
throw err;
|
|
133
|
+
throw new GenerationInterruptedError(outputId, err);
|
|
134
|
+
}
|
|
69
135
|
}
|
|
70
136
|
/**
|
|
71
137
|
* Estimate the credit cost of a generation without running it (the get_cost
|
|
@@ -92,7 +158,10 @@ export class ContentHero {
|
|
|
92
158
|
const deadline = Date.now() + timeoutMs;
|
|
93
159
|
while (true) {
|
|
94
160
|
const generation = await this.getGeneration(outputId);
|
|
95
|
-
|
|
161
|
+
// Terminal only when SETTLED: a placement-bearing generation is not "done" for a caller until its swap /
|
|
162
|
+
// cutout side-effect has landed (see Generation.settled). `settled !== false` keeps older servers (which
|
|
163
|
+
// omit the field) working as before, and a no-placement output is settled the moment it completes.
|
|
164
|
+
if (generation.status === 'completed' && generation.settled !== false)
|
|
96
165
|
return generation;
|
|
97
166
|
if (generation.status === 'failed') {
|
|
98
167
|
throw new GenerationFailedError(generation.outputId, generation.error ?? 'Generation failed');
|
|
@@ -114,6 +183,25 @@ export class ContentHero {
|
|
|
114
183
|
async transcribe(request) {
|
|
115
184
|
return this.request('POST', '/api/v1/studio/transcribe', request);
|
|
116
185
|
}
|
|
186
|
+
/**
|
|
187
|
+
* Transform existing audio with an audio-processing model. Two shapes:
|
|
188
|
+
*
|
|
189
|
+
* FILE mode (`sourceUrl`): process a standalone file into a new library asset. Voice isolation is
|
|
190
|
+
* synchronous and the processed URL comes back inline on `outputUrls`; enhancement is asynchronous and
|
|
191
|
+
* returns an `outputId` to poll.
|
|
192
|
+
*
|
|
193
|
+
* IN-PLACE mode (`projectId` + `clipIds` / `enhanceClips`): enhance the audio OF EXISTING CLIPS on a
|
|
194
|
+
* timeline. Returns one job per SOURCE on `outputs`, because the vendor estimates a noise profile per
|
|
195
|
+
* production, so a source's clips are concatenated and enhanced together while separate recordings stay
|
|
196
|
+
* separate jobs.
|
|
197
|
+
*/
|
|
198
|
+
async editAudio(request) {
|
|
199
|
+
return this.request('POST', '/api/v1/studio/audio/edit', request);
|
|
200
|
+
}
|
|
201
|
+
/** Cost preview for `editAudio` (nothing runs, nothing is charged). */
|
|
202
|
+
async estimateEditAudioCost(request) {
|
|
203
|
+
return this.request('POST', '/api/v1/studio/audio/edit', { ...request, getCost: true });
|
|
204
|
+
}
|
|
117
205
|
/** List the account's avatars (the list half of the list+get pair). */
|
|
118
206
|
async listAvatars() {
|
|
119
207
|
const data = await this.request('GET', '/api/v1/avatars');
|
|
@@ -124,8 +212,12 @@ export class ContentHero {
|
|
|
124
212
|
return this.request('GET', `/api/v1/avatars/${encodeURIComponent(avatarId)}`);
|
|
125
213
|
}
|
|
126
214
|
/** List the account's saved voices (the list half of the list+get pair). */
|
|
127
|
-
async listVoices() {
|
|
128
|
-
const
|
|
215
|
+
async listVoices(options = {}) {
|
|
216
|
+
const q = new URLSearchParams();
|
|
217
|
+
if (options.favorited)
|
|
218
|
+
q.set('favorited', 'true');
|
|
219
|
+
const qs = q.toString();
|
|
220
|
+
const data = await this.request('GET', `/api/v1/voices${qs ? `?${qs}` : ''}`);
|
|
129
221
|
return data.voices;
|
|
130
222
|
}
|
|
131
223
|
/** Get one voice's detail (the get half). Throws NotFoundError if absent. */
|
|
@@ -133,8 +225,50 @@ export class ContentHero {
|
|
|
133
225
|
return this.request('GET', `/api/v1/voices/${encodeURIComponent(voiceId)}`);
|
|
134
226
|
}
|
|
135
227
|
/** List the account's brand kits (the list half of the list+get pair). */
|
|
136
|
-
async listBrandKits() {
|
|
137
|
-
const
|
|
228
|
+
async listBrandKits(options = {}) {
|
|
229
|
+
const q = new URLSearchParams();
|
|
230
|
+
if (options.favorited)
|
|
231
|
+
q.set('favorited', 'true');
|
|
232
|
+
if (options.archived)
|
|
233
|
+
q.set('archived', 'true');
|
|
234
|
+
const qs = q.toString();
|
|
235
|
+
const data = await this.request('GET', `/api/v1/brand-kits${qs ? `?${qs}` : ''}`);
|
|
236
|
+
return data.brandKits;
|
|
237
|
+
}
|
|
238
|
+
/**
|
|
239
|
+
* Create a brand kit. Requires the `brandkit:write` scope.
|
|
240
|
+
*
|
|
241
|
+
* Three sources, and the input decides which: EMPTY (just a name), FROM A WEBSITE
|
|
242
|
+
* (`websiteUrl` + `extract: true`), or A COPY (`duplicateFrom`).
|
|
243
|
+
*
|
|
244
|
+
* ⚠️ WITH `extract` IT RETURNS IMMEDIATELY, before the kit has any content. That empty kit is the HANDLE:
|
|
245
|
+
* the thing to poll and the row the UI renders at once. Poll `extractionStatus` via `getBrandKit`.
|
|
246
|
+
*/
|
|
247
|
+
async createBrandKit(input) {
|
|
248
|
+
// A copy is a create with a source, so it shares this method rather than owning a verb of its own.
|
|
249
|
+
if (input.duplicateFrom) {
|
|
250
|
+
const { duplicateFrom, name } = input;
|
|
251
|
+
const data = await this.request('POST', `/api/v1/brand-kits/${encodeURIComponent(duplicateFrom)}/duplicate`, name ? { name } : {});
|
|
252
|
+
return { brandKit: data.brandKit };
|
|
253
|
+
}
|
|
254
|
+
return this.request('POST', '/api/v1/brand-kits', input);
|
|
255
|
+
}
|
|
256
|
+
/**
|
|
257
|
+
* Re-run website extraction for an existing kit. Returns at once; poll `extractionStatus`.
|
|
258
|
+
* Requires the kit to already have a `websiteUrl`, and the `brandkit:write` scope.
|
|
259
|
+
*/
|
|
260
|
+
async extractBrandKit(brandKitId) {
|
|
261
|
+
const data = await this.request('POST', `/api/v1/brand-kits/${encodeURIComponent(brandKitId)}/extract`);
|
|
262
|
+
return data.extraction;
|
|
263
|
+
}
|
|
264
|
+
/**
|
|
265
|
+
* Reorder the account's brand kits. Collection-level because ordering is a property of the SET: a per-kit
|
|
266
|
+
* position would let two kits claim one slot. Pass every id, in the order you want.
|
|
267
|
+
*/
|
|
268
|
+
async reorderBrandKits(orderedIds) {
|
|
269
|
+
const data = await this.request('PATCH', '/api/v1/brand-kits', {
|
|
270
|
+
orderedIds,
|
|
271
|
+
});
|
|
138
272
|
return data.brandKits;
|
|
139
273
|
}
|
|
140
274
|
/** Get one brand kit, fully assembled (the get half). Throws NotFoundError if absent. */
|
|
@@ -149,11 +283,6 @@ export class ContentHero {
|
|
|
149
283
|
async updateBrandKit(brandKitId, input) {
|
|
150
284
|
return this.request('PATCH', `/api/v1/brand-kits/${encodeURIComponent(brandKitId)}`, input);
|
|
151
285
|
}
|
|
152
|
-
/** Archive a brand kit (reversible). Requires the `brandkit:write` scope. */
|
|
153
|
-
async archiveBrandKit(brandKitId) {
|
|
154
|
-
const data = await this.request('PATCH', `/api/v1/brand-kits/${encodeURIComponent(brandKitId)}`, { archive: true });
|
|
155
|
-
return data.brandKit;
|
|
156
|
-
}
|
|
157
286
|
/** Add a curated section to a brand kit. Requires the `brandkit:write` scope. */
|
|
158
287
|
async addBrandKitSection(brandKitId, input) {
|
|
159
288
|
const data = await this.request('POST', `/api/v1/brand-kits/${encodeURIComponent(brandKitId)}/sections`, input);
|
|
@@ -164,11 +293,6 @@ export class ContentHero {
|
|
|
164
293
|
const data = await this.request('PATCH', `/api/v1/brand-kits/${encodeURIComponent(brandKitId)}/sections/${encodeURIComponent(sectionId)}`, input);
|
|
165
294
|
return data.section;
|
|
166
295
|
}
|
|
167
|
-
/** Archive a brand-kit section (soft delete, reversible). Requires `brandkit:write`. */
|
|
168
|
-
async archiveBrandKitSection(brandKitId, sectionId) {
|
|
169
|
-
const data = await this.request('DELETE', `/api/v1/brand-kits/${encodeURIComponent(brandKitId)}/sections/${encodeURIComponent(sectionId)}`);
|
|
170
|
-
return data.section;
|
|
171
|
-
}
|
|
172
296
|
// -------------------------------------------------------------------------
|
|
173
297
|
// Brand knowledge (a brand kit's knowledge base)
|
|
174
298
|
// -------------------------------------------------------------------------
|
|
@@ -206,9 +330,15 @@ export class ContentHero {
|
|
|
206
330
|
async removeBrandKnowledge(brandKitId, knowledgeId) {
|
|
207
331
|
return this.request('DELETE', `/api/v1/brand-kits/${encodeURIComponent(brandKitId)}/knowledge/${encodeURIComponent(knowledgeId)}`);
|
|
208
332
|
}
|
|
209
|
-
/**
|
|
333
|
+
/**
|
|
334
|
+
* List the account's recent media (the list half of the list+get pair). `source`
|
|
335
|
+
* selects the library: 'creations' (default, studio outputs) or 'uploads' (the
|
|
336
|
+
* editor Uploads tab).
|
|
337
|
+
*/
|
|
210
338
|
async listMedia(options = {}) {
|
|
211
339
|
const q = new URLSearchParams();
|
|
340
|
+
if (options.source)
|
|
341
|
+
q.set('source', options.source);
|
|
212
342
|
if (options.contentType) {
|
|
213
343
|
const types = Array.isArray(options.contentType) ? options.contentType : [options.contentType];
|
|
214
344
|
q.set('contentType', types.join(','));
|
|
@@ -217,6 +347,10 @@ export class ContentHero {
|
|
|
217
347
|
q.set('status', options.status);
|
|
218
348
|
if (options.kind)
|
|
219
349
|
q.set('kind', options.kind);
|
|
350
|
+
if (options.favorited)
|
|
351
|
+
q.set('favorited', 'true');
|
|
352
|
+
if (options.archived)
|
|
353
|
+
q.set('archived', 'true');
|
|
220
354
|
if (options.limit != null)
|
|
221
355
|
q.set('limit', String(options.limit));
|
|
222
356
|
if (options.offset != null)
|
|
@@ -226,12 +360,71 @@ export class ContentHero {
|
|
|
226
360
|
return data.media;
|
|
227
361
|
}
|
|
228
362
|
/**
|
|
229
|
-
* Get one
|
|
230
|
-
*
|
|
231
|
-
*
|
|
363
|
+
* Get one media item by id token (the get half). `source` selects the library the
|
|
364
|
+
* id belongs to: 'creations' (default, a studio output; token may be the full id,
|
|
365
|
+
* its first 8 characters, or either with a `-N` variation suffix), 'uploads' (an
|
|
366
|
+
* editor Uploads-tab file; full id or short id, no variations), or 'stock' (a used
|
|
367
|
+
* stock item; full id or short id, no variations). Pass the same source the item
|
|
368
|
+
* reported in listMedia. Throws NotFoundError if absent.
|
|
369
|
+
*/
|
|
370
|
+
async getMedia(idToken, options = {}) {
|
|
371
|
+
const qs = options.source ? `?source=${encodeURIComponent(options.source)}` : '';
|
|
372
|
+
return this.request('GET', `/api/v1/media/${encodeURIComponent(idToken)}${qs}`);
|
|
373
|
+
}
|
|
374
|
+
/**
|
|
375
|
+
* Semantically search the account's editable media library (creations, uploads, licensed stock, brand assets)
|
|
376
|
+
* by describing the content in natural language. Returns matching assets ranked by relevance, each with a
|
|
377
|
+
* description, tags, and, for videos, the timestamps of the scenes that matched, so a precise moment can be
|
|
378
|
+
* located. Searches only the account's own usable library, never inspiration, published posts, or knowledge.
|
|
379
|
+
*/
|
|
380
|
+
async searchMedia(query, options = {}) {
|
|
381
|
+
const q = new URLSearchParams({ query });
|
|
382
|
+
if (options.kinds && options.kinds.length > 0)
|
|
383
|
+
q.set('kinds', options.kinds.join(','));
|
|
384
|
+
if (options.limit != null)
|
|
385
|
+
q.set('limit', String(options.limit));
|
|
386
|
+
const data = await this.request('GET', `/api/v1/media/search?${q.toString()}`);
|
|
387
|
+
return data.results;
|
|
388
|
+
}
|
|
389
|
+
// ─── Library folders (Unified Content Library, Phase D) ────────────────────
|
|
390
|
+
/** List the account's folders (manual + smart, with parent links) plus the built-in derived folders. */
|
|
391
|
+
async listFolders() {
|
|
392
|
+
return this.request('GET', '/api/v1/library/folders');
|
|
393
|
+
}
|
|
394
|
+
/** A folder's contents. `folderId` is a folder id or a derived key (recents|favorites|edits|canvas|posts). */
|
|
395
|
+
async getFolder(folderId) {
|
|
396
|
+
const data = await this.request('GET', `/api/v1/library/folders/${encodeURIComponent(folderId)}`);
|
|
397
|
+
return { folder: data.folder, items: data.items };
|
|
398
|
+
}
|
|
399
|
+
async createFolder(input) {
|
|
400
|
+
const data = await this.request('POST', '/api/v1/library/folders', input);
|
|
401
|
+
return data.folder;
|
|
402
|
+
}
|
|
403
|
+
async updateFolder(folderId, patch) {
|
|
404
|
+
const data = await this.request('PATCH', `/api/v1/library/folders/${encodeURIComponent(folderId)}`, patch);
|
|
405
|
+
return data.folder;
|
|
406
|
+
}
|
|
407
|
+
async deleteFolder(folderId) {
|
|
408
|
+
await this.request('DELETE', `/api/v1/library/folders/${encodeURIComponent(folderId)}`);
|
|
409
|
+
}
|
|
410
|
+
/** File an item into a manual folder (a pointer; no bytes move). */
|
|
411
|
+
async addToFolder(folderId, ref) {
|
|
412
|
+
await this.request('POST', `/api/v1/library/folders/${encodeURIComponent(folderId)}/items`, ref);
|
|
413
|
+
}
|
|
414
|
+
async removeFromFolder(folderId, ref) {
|
|
415
|
+
await this.request('DELETE', `/api/v1/library/folders/${encodeURIComponent(folderId)}/items`, ref);
|
|
416
|
+
}
|
|
417
|
+
/**
|
|
418
|
+
* Resolve a batch of media references to vision-ready URLs + light metadata (the
|
|
419
|
+
* micro drill-in behind get_context's macro screenshot). Each item is a raw
|
|
420
|
+
* `{ url }` or an `{ mediaId, variation? }`; a mediaId with no variation resolves
|
|
421
|
+
* to only the primary variation (siblings listed in `otherVariations`), never a
|
|
422
|
+
* whole generation. Returns one entry per item, in order; a bad item comes back
|
|
423
|
+
* with `ok: false` rather than failing the batch. Max 10 items per call (the SDK
|
|
424
|
+
* returns URLs; the MCP layer is what turns them into image blocks for a model).
|
|
232
425
|
*/
|
|
233
|
-
async
|
|
234
|
-
return this.request('
|
|
426
|
+
async getMediaBatch(items) {
|
|
427
|
+
return this.request('POST', '/api/v1/media/batch', { items });
|
|
235
428
|
}
|
|
236
429
|
// -------------------------------------------------------------------------
|
|
237
430
|
// Media upload (bring your own file or URL -> first-class media)
|
|
@@ -266,11 +459,23 @@ export class ContentHero {
|
|
|
266
459
|
contentType: opts.contentType,
|
|
267
460
|
sizeBytes,
|
|
268
461
|
});
|
|
269
|
-
// The PUT goes to
|
|
270
|
-
// with
|
|
462
|
+
// The PUT goes straight to object storage (not our API), so it uses a bare
|
|
463
|
+
// fetch with none of our auth headers.
|
|
464
|
+
//
|
|
465
|
+
// The headers come from the SERVER rather than being assumed here. Storage
|
|
466
|
+
// used to be Supabase, where Content-Type alone is enough; it is moving to
|
|
467
|
+
// R2, where the presigned URL signs the owner in as `x-amz-meta-user_id` and
|
|
468
|
+
// a PUT missing it is rejected with SignatureDoesNotMatch (verified: 403
|
|
469
|
+
// with Content-Type alone, 200 with both). Letting the server say what to
|
|
470
|
+
// send means this client never has to know which store it is talking to,
|
|
471
|
+
// and the migration needs no coordinated release of it.
|
|
472
|
+
//
|
|
473
|
+
// Falls back to Content-Type so an older API that does not return
|
|
474
|
+
// uploadHeaders keeps working, which is what makes the two deployable in
|
|
475
|
+
// either order.
|
|
271
476
|
const put = await this.fetchImpl(created.uploadUrl, {
|
|
272
477
|
method: 'PUT',
|
|
273
|
-
headers: { 'Content-Type': opts.contentType },
|
|
478
|
+
headers: created.uploadHeaders ?? { 'Content-Type': opts.contentType },
|
|
274
479
|
body: data,
|
|
275
480
|
});
|
|
276
481
|
if (!put.ok) {
|
|
@@ -399,10 +604,6 @@ export class ContentHero {
|
|
|
399
604
|
const data = await this.request('PATCH', `/api/v1/posts/${encodeURIComponent(postId)}`, input);
|
|
400
605
|
return data.post;
|
|
401
606
|
}
|
|
402
|
-
/** Archive a post (status -> 'archived'). No hard delete. */
|
|
403
|
-
async archivePost(postId) {
|
|
404
|
-
return this.updatePost(postId, { status: 'archived' });
|
|
405
|
-
}
|
|
406
607
|
/**
|
|
407
608
|
* List the account's pipeline stages (sorted), seeding the defaults on first
|
|
408
609
|
* access. Use this to resolve a stage before placing a post; stages are
|
|
@@ -510,6 +711,8 @@ export class ContentHero {
|
|
|
510
711
|
q.set('sort_by', options.sortBy);
|
|
511
712
|
if (options.brandKitId)
|
|
512
713
|
q.set('brand_kit_id', options.brandKitId);
|
|
714
|
+
if (options.favorited)
|
|
715
|
+
q.set('favorited', 'true');
|
|
513
716
|
if (options.limit != null)
|
|
514
717
|
q.set('limit', String(options.limit));
|
|
515
718
|
if (options.offset != null)
|
|
@@ -545,6 +748,281 @@ export class ContentHero {
|
|
|
545
748
|
const data = await this.request('GET', `/api/v1/connected-accounts/${encodeURIComponent(accountId)}`);
|
|
546
749
|
return data.account;
|
|
547
750
|
}
|
|
751
|
+
// -------------------------------------------------------------------------
|
|
752
|
+
// Favorites & archive (universal set/clear across asset types)
|
|
753
|
+
// -------------------------------------------------------------------------
|
|
754
|
+
/**
|
|
755
|
+
* Mark an asset as favorited. Requires the `favorites:write` scope.
|
|
756
|
+
*
|
|
757
|
+
* Pass `{ assetType, id }` for a top-level asset (post, voice, brand_kit,
|
|
758
|
+
* project, inspiration_content, gallery), or `{ id, variationIndex }` to
|
|
759
|
+
* favorite a single studio output variation slot (id is a studio output id).
|
|
760
|
+
* Idempotent.
|
|
761
|
+
*/
|
|
762
|
+
async favorite(input) {
|
|
763
|
+
await this.request('POST', '/api/v1/favorite', input);
|
|
764
|
+
}
|
|
765
|
+
/**
|
|
766
|
+
* Clear the favorite flag on an asset. Requires the `favorites:write` scope.
|
|
767
|
+
* Same target shape as `favorite`. Idempotent.
|
|
768
|
+
*/
|
|
769
|
+
async unfavorite(input) {
|
|
770
|
+
await this.request('POST', '/api/v1/unfavorite', input);
|
|
771
|
+
}
|
|
772
|
+
/**
|
|
773
|
+
* Archive an asset. Requires the `favorites:write` scope.
|
|
774
|
+
*
|
|
775
|
+
* Pass `{ assetType, id }` for a top-level asset (post, brand_kit,
|
|
776
|
+
* brand_kit_section, project), or `{ id, variationIndex }` to archive a single
|
|
777
|
+
* studio output variation slot. Archiving a post sets its status to 'archived'.
|
|
778
|
+
* Idempotent.
|
|
779
|
+
*/
|
|
780
|
+
async archive(input) {
|
|
781
|
+
await this.request('POST', '/api/v1/archive', input);
|
|
782
|
+
}
|
|
783
|
+
/**
|
|
784
|
+
* Unarchive an asset. Requires the `favorites:write` scope. Same target shape
|
|
785
|
+
* as `archive`. Unarchiving a post restores it to 'draft'. Idempotent.
|
|
786
|
+
*/
|
|
787
|
+
async unarchive(input) {
|
|
788
|
+
await this.request('POST', '/api/v1/unarchive', input);
|
|
789
|
+
}
|
|
790
|
+
// -------------------------------------------------------------------------
|
|
791
|
+
// Editor / canvas ops (programmatic parity with the manual UI + in-app agent)
|
|
792
|
+
// -------------------------------------------------------------------------
|
|
793
|
+
/**
|
|
794
|
+
* List the caller's projects (both editor + canvas) as lightweight summaries. Filter by archived /
|
|
795
|
+
* favorited state, by `kind`, or by a title search. Requires the `editor:read` scope.
|
|
796
|
+
*/
|
|
797
|
+
async listProjects(input = {}) {
|
|
798
|
+
const q = new URLSearchParams();
|
|
799
|
+
if (input.filter)
|
|
800
|
+
q.set('filter', input.filter);
|
|
801
|
+
if (input.kind)
|
|
802
|
+
q.set('kind', input.kind);
|
|
803
|
+
if (input.search)
|
|
804
|
+
q.set('search', input.search);
|
|
805
|
+
const qs = q.toString();
|
|
806
|
+
const { projects } = await this.request('GET', `/api/v1/projects${qs ? `?${qs}` : ''}`);
|
|
807
|
+
return projects;
|
|
808
|
+
}
|
|
809
|
+
/**
|
|
810
|
+
* Read a single project (metadata + composition `state` + `revision`), to read-before-write. Pass the returned
|
|
811
|
+
* `revision` back as `applyEditorOps`'s `expectedRevision`. By DEFAULT `state` is a lightweight SUMMARY (per-clip
|
|
812
|
+
* / per-layer structure, heavy payloads dropped); pass `detail:'full'` for the complete composition. For a
|
|
813
|
+
* timeline, `fromFrame`/`toFrame` (+ `trackId`) scope the read to the clips overlapping that frame window; for a
|
|
814
|
+
* canvas, `slideId` scopes it to a single slide, and applies to `detail:'full'` too so asking for one slide's
|
|
815
|
+
* detail does not pay for the whole deck. A canvas summary echoes each text layer's `text` (truncated), which is
|
|
816
|
+
* what identifies it. Requires the `editor:read` scope.
|
|
817
|
+
*/
|
|
818
|
+
async getProject(projectId, options = {}) {
|
|
819
|
+
const params = new URLSearchParams();
|
|
820
|
+
if (options.includeRenderUrl)
|
|
821
|
+
params.set('includeRenderUrl', 'true');
|
|
822
|
+
if (options.detail === 'full')
|
|
823
|
+
params.set('detail', 'full');
|
|
824
|
+
if (typeof options.fromFrame === 'number')
|
|
825
|
+
params.set('fromFrame', String(options.fromFrame));
|
|
826
|
+
if (typeof options.toFrame === 'number')
|
|
827
|
+
params.set('toFrame', String(options.toFrame));
|
|
828
|
+
if (options.trackId)
|
|
829
|
+
params.set('trackId', options.trackId);
|
|
830
|
+
if (options.slideId)
|
|
831
|
+
params.set('slideId', options.slideId);
|
|
832
|
+
const qs = params.toString() ? `?${params.toString()}` : '';
|
|
833
|
+
const { project } = await this.request('GET', `/api/v1/projects/${encodeURIComponent(projectId)}${qs}`);
|
|
834
|
+
return project;
|
|
835
|
+
}
|
|
836
|
+
/**
|
|
837
|
+
* Read the LIVE context of what the user is currently viewing in the open app: the active surface + focus +
|
|
838
|
+
* selection, so an agent can operate on "what the user is looking at" like the internal assistant does. Fast
|
|
839
|
+
* and structured by default. Pass `capture: true` to also ping the live tab for a fresh viewport screenshot
|
|
840
|
+
* (returned as a short-lived `snapshotUrl`) when you need the user's screen as shown. To see the COMPOSED
|
|
841
|
+
* OUTPUT itself (the rendered editor frame or canvas slide), pass `render: true` (optionally `frame` for an
|
|
842
|
+
* editor project, or `slideId` / `slideIndex` for canvas); the render returns inline as a data URL, is
|
|
843
|
+
* ephemeral, and does not need a live tab. Returns the most-recent-active session's context plus the full live
|
|
844
|
+
* participant set. Optionally scope to one project. Requires the `context:read` scope.
|
|
845
|
+
*/
|
|
846
|
+
async getContext(input = {}) {
|
|
847
|
+
const params = new URLSearchParams();
|
|
848
|
+
if (input.projectId)
|
|
849
|
+
params.set('projectId', input.projectId);
|
|
850
|
+
if (input.capture)
|
|
851
|
+
params.set('capture', 'true');
|
|
852
|
+
if (input.render)
|
|
853
|
+
params.set('render', 'true');
|
|
854
|
+
if (input.mode)
|
|
855
|
+
params.set('mode', input.mode);
|
|
856
|
+
if (typeof input.frame === 'number')
|
|
857
|
+
params.set('frame', String(input.frame));
|
|
858
|
+
if (input.slideId)
|
|
859
|
+
params.set('slideId', input.slideId);
|
|
860
|
+
if (typeof input.slideIndex === 'number')
|
|
861
|
+
params.set('slideIndex', String(input.slideIndex));
|
|
862
|
+
if (typeof input.fromFrame === 'number')
|
|
863
|
+
params.set('fromFrame', String(input.fromFrame));
|
|
864
|
+
if (typeof input.toFrame === 'number')
|
|
865
|
+
params.set('toFrame', String(input.toFrame));
|
|
866
|
+
if (typeof input.count === 'number')
|
|
867
|
+
params.set('count', String(input.count));
|
|
868
|
+
if (typeof input.width === 'number')
|
|
869
|
+
params.set('width', String(input.width));
|
|
870
|
+
// Canonical URI encoding: URLSearchParams renders a space as '+', which is x-www-form-urlencoded, not the
|
|
871
|
+
// RFC-3986 query encoding; emit %20 so the URL is canonical (both decode to a space server-side).
|
|
872
|
+
const query = params.toString().replace(/\+/g, '%20');
|
|
873
|
+
const qs = query ? `?${query}` : '';
|
|
874
|
+
return this.request('GET', `/api/v1/context${qs}`);
|
|
875
|
+
}
|
|
876
|
+
/**
|
|
877
|
+
* Create an async PREVIEW render (ephemeral, never stored). Currently a short low-res COMPOSED VIDEO of an
|
|
878
|
+
* editor range, so you can assess motion, cuts, transitions, and pacing a still cannot show. Returns a job
|
|
879
|
+
* handle; poll it with `getPreview`. Requires the `context:read` scope.
|
|
880
|
+
*/
|
|
881
|
+
async createPreview(input) {
|
|
882
|
+
return this.request('POST', '/api/v1/preview', input);
|
|
883
|
+
}
|
|
884
|
+
/**
|
|
885
|
+
* Poll a preview started with `createPreview`. While rendering, returns `progress`; on completion, returns a
|
|
886
|
+
* short-lived signed `url` to the ephemeral output plus the estimated cost.
|
|
887
|
+
*/
|
|
888
|
+
async getPreview(job) {
|
|
889
|
+
const params = new URLSearchParams({ renderId: job.renderId, bucketName: job.bucketName });
|
|
890
|
+
return this.request('GET', `/api/v1/preview?${params.toString()}`);
|
|
891
|
+
}
|
|
892
|
+
/**
|
|
893
|
+
* Create a project. All fields optional; the server applies the same defaults as the in-app new-project
|
|
894
|
+
* flow (16:9 landscape, `editor` kind). A new canvas starts with one empty slide; a new editor starts with
|
|
895
|
+
* an empty timeline. Returns the full detail (with its id + starting revision) so you can immediately apply
|
|
896
|
+
* ops. Requires the `editor:write` scope.
|
|
897
|
+
*/
|
|
898
|
+
async createProject(input = {}) {
|
|
899
|
+
const { project } = await this.request('POST', '/api/v1/projects', input);
|
|
900
|
+
return project;
|
|
901
|
+
}
|
|
902
|
+
/**
|
|
903
|
+
* Import a PowerPoint / Google Slides file (by URL) or a Canva design (by id) into a NEW canvas project
|
|
904
|
+
* with editable layers, returning the created project's full detail. The Canva source uses the caller's
|
|
905
|
+
* Canva connection (a ConflictError-like 400 with code 'canva_not_connected' if not connected). Structured
|
|
906
|
+
* import can take a while (export + convert + parse). Requires the `editor:write` scope.
|
|
907
|
+
*/
|
|
908
|
+
async importProject(input) {
|
|
909
|
+
const { project } = await this.request('POST', '/api/v1/projects/import', input);
|
|
910
|
+
return project;
|
|
911
|
+
}
|
|
912
|
+
/**
|
|
913
|
+
* PERMANENTLY delete a project (irreversible hard delete, distinct from the reversible archive). The
|
|
914
|
+
* `?confirm=true` opt-in is sent for you. To reversibly hide a project instead, use `archive`. Requires
|
|
915
|
+
* the `editor:write` scope.
|
|
916
|
+
*/
|
|
917
|
+
async deleteProject(projectId) {
|
|
918
|
+
await this.request('DELETE', `/api/v1/projects/${encodeURIComponent(projectId)}?confirm=true`);
|
|
919
|
+
}
|
|
920
|
+
/**
|
|
921
|
+
* Apply a batch of ops to a project's composition (canvas slides or editor timeline) and persist
|
|
922
|
+
* atomically. The project's `surface` selects the op vocabulary; the ops run through the same reducers the manual
|
|
923
|
+
* UI and in-app agent use. Requires the `editor:write` scope.
|
|
924
|
+
*
|
|
925
|
+
* Optimistic concurrency: pass `expectedRevision` (from `getProject`) to fail with a 409 ConflictError if
|
|
926
|
+
* a concurrent edit landed, instead of clobbering it. Returns the new revision and the per-op results (a
|
|
927
|
+
* bad op is reported, never throws).
|
|
928
|
+
*
|
|
929
|
+
* Each op is given a client-generated `op_id` (uuid) here if it does not already have one, so the op has a
|
|
930
|
+
* stable identity from the point of intent: resending the same batch is idempotent (the server dedupes by
|
|
931
|
+
* op_id), and a live editor sees the edit as an attributed collaborator change keyed by that id. The
|
|
932
|
+
* assigned id is echoed back on each result's `opId`.
|
|
933
|
+
*/
|
|
934
|
+
async applyEditorOps(input) {
|
|
935
|
+
const ops = input.ops.map((op) => typeof op.op_id === 'string' && op.op_id ? op : { ...op, op_id: globalThis.crypto.randomUUID() });
|
|
936
|
+
return this.request('POST', '/api/v1/editor/ops', { ...input, ops });
|
|
937
|
+
}
|
|
938
|
+
/**
|
|
939
|
+
* Start an export (render) of a project's saved composition. `mp4` returns a job with status 'rendering'
|
|
940
|
+
* (poll with getExport or use exportProjectAndWait); canvas still/document formats (png/jpg/pdf/pptx) run
|
|
941
|
+
* synchronously and return 'completed' with the outputUrl. Requires the `editor:write` scope.
|
|
942
|
+
*/
|
|
943
|
+
async startExport(projectId, input = {}) {
|
|
944
|
+
return this.request('POST', `/api/v1/projects/${encodeURIComponent(projectId)}/export`, input);
|
|
945
|
+
}
|
|
946
|
+
/** Poll an export job by id. Requires the `editor:read` scope. */
|
|
947
|
+
async getExport(exportId) {
|
|
948
|
+
return this.request('GET', `/api/v1/exports/${encodeURIComponent(exportId)}`);
|
|
949
|
+
}
|
|
950
|
+
/** The kind-aware catalog of export formats + their options. Requires the `editor:read` scope. */
|
|
951
|
+
async getExportFormats() {
|
|
952
|
+
return this.request('GET', '/api/v1/export-formats');
|
|
953
|
+
}
|
|
954
|
+
/**
|
|
955
|
+
* Start an export and poll until it completes. Resolves with the completed job (outputUrl set), throws
|
|
956
|
+
* `GenerationFailedError` on failure, or `GenerationTimeoutError` if it does not finish within `timeoutMs`
|
|
957
|
+
* (the server job may still complete; re-poll with getExport).
|
|
958
|
+
*/
|
|
959
|
+
async exportProjectAndWait(projectId, input = {}, options = {}) {
|
|
960
|
+
const job = await this.startExport(projectId, input);
|
|
961
|
+
if (job.status === 'completed' || job.status === 'failed') {
|
|
962
|
+
if (job.status === 'failed')
|
|
963
|
+
throw new GenerationFailedError(job.exportId, job.errorMessage ?? 'Export failed');
|
|
964
|
+
return job;
|
|
965
|
+
}
|
|
966
|
+
return this.waitForExport(job.exportId, options);
|
|
967
|
+
}
|
|
968
|
+
/** Poll an export job to a terminal state. */
|
|
969
|
+
async waitForExport(exportId, options = {}) {
|
|
970
|
+
const { pollIntervalMs = 3000, timeoutMs = 600_000, signal } = options;
|
|
971
|
+
const deadline = Date.now() + timeoutMs;
|
|
972
|
+
while (true) {
|
|
973
|
+
const job = await this.getExport(exportId);
|
|
974
|
+
if (job.status === 'completed')
|
|
975
|
+
return job;
|
|
976
|
+
if (job.status === 'failed')
|
|
977
|
+
throw new GenerationFailedError(exportId, job.errorMessage ?? 'Export failed');
|
|
978
|
+
if (Date.now() >= deadline)
|
|
979
|
+
throw new GenerationTimeoutError(exportId);
|
|
980
|
+
await sleep(pollIntervalMs, signal);
|
|
981
|
+
}
|
|
982
|
+
}
|
|
983
|
+
/**
|
|
984
|
+
* The canvas layer-type catalog (types + editable props), so you know what `update_canvas` ops can
|
|
985
|
+
* create/edit. Requires the `editor:read` scope.
|
|
986
|
+
*/
|
|
987
|
+
async getLayerTypes() {
|
|
988
|
+
return this.request('GET', '/api/v1/editor/layer-types');
|
|
989
|
+
}
|
|
990
|
+
/**
|
|
991
|
+
* The editor timeline clip + track-type catalog (types + editable props), so you know what
|
|
992
|
+
* `update_timeline` ops can create/edit. Requires the `editor:read` scope.
|
|
993
|
+
*/
|
|
994
|
+
async getTimelineTypes() {
|
|
995
|
+
return this.request('GET', '/api/v1/editor/timeline-types');
|
|
996
|
+
}
|
|
997
|
+
/**
|
|
998
|
+
* Read an editor project's transcript mapped to its timeline clips. Returns one segment per transcribable
|
|
999
|
+
* primary-track clip, in timeline order, carrying the words spoken within it plus its current enabled/disabled
|
|
1000
|
+
* state, so you can read what is said, see which parts are already cut, and target exact clipIds with
|
|
1001
|
+
* update_timeline (disable_ranges / set_disabled / delete_ranges). Scope with `search` (substring) or
|
|
1002
|
+
* `startMs`/`endMs` (source-media time) to fetch only the part you need. Pass `granularity: 'word'` for
|
|
1003
|
+
* word-level timing (with absolute timeline frames), per-word confidence + speaker, derived silence gaps, and
|
|
1004
|
+
* non-speech audio events. `paceThresholdMs` (minimum pause to report as silence) and `paddingStartMs` /
|
|
1005
|
+
* `paddingEndMs` (breathing room kept around speech; negative tightens) tune the silence detection and default
|
|
1006
|
+
* to the project's saved pace/padding. Requires the `editor:read` scope.
|
|
1007
|
+
*/
|
|
1008
|
+
async getTranscript(projectId, options = {}) {
|
|
1009
|
+
const params = new URLSearchParams({ projectId });
|
|
1010
|
+
if (options.search)
|
|
1011
|
+
params.set('search', options.search);
|
|
1012
|
+
if (options.startMs !== undefined)
|
|
1013
|
+
params.set('startMs', String(options.startMs));
|
|
1014
|
+
if (options.endMs !== undefined)
|
|
1015
|
+
params.set('endMs', String(options.endMs));
|
|
1016
|
+
if (options.granularity)
|
|
1017
|
+
params.set('granularity', options.granularity);
|
|
1018
|
+
if (options.paceThresholdMs !== undefined)
|
|
1019
|
+
params.set('paceThresholdMs', String(options.paceThresholdMs));
|
|
1020
|
+
if (options.paddingStartMs !== undefined)
|
|
1021
|
+
params.set('paddingStartMs', String(options.paddingStartMs));
|
|
1022
|
+
if (options.paddingEndMs !== undefined)
|
|
1023
|
+
params.set('paddingEndMs', String(options.paddingEndMs));
|
|
1024
|
+
return this.request('GET', `/api/v1/editor/transcript?${params.toString()}`);
|
|
1025
|
+
}
|
|
548
1026
|
/** Issue an authenticated request and map non-2xx responses to typed errors. */
|
|
549
1027
|
async request(method, path, body) {
|
|
550
1028
|
const headers = {
|
|
@@ -553,6 +1031,14 @@ export class ContentHero {
|
|
|
553
1031
|
};
|
|
554
1032
|
if (body !== undefined)
|
|
555
1033
|
headers['Content-Type'] = 'application/json';
|
|
1034
|
+
// Release tracking (NOT a presence ping): presence is lit server-side, but the CLI still needs to clear its
|
|
1035
|
+
// badge promptly on exit, so record any project this client mutates by id. Local only, no extra request; the
|
|
1036
|
+
// activity endpoint manages its own lease and is skipped.
|
|
1037
|
+
if (body && typeof body === 'object' && !Array.isArray(body) && path !== '/api/v1/editor/activity') {
|
|
1038
|
+
const pid = body.projectId;
|
|
1039
|
+
if (typeof pid === 'string' && pid)
|
|
1040
|
+
this.activityPingedAt.set(pid, Date.now());
|
|
1041
|
+
}
|
|
556
1042
|
const response = await this.fetchImpl(`${this.baseUrl}${path}`, {
|
|
557
1043
|
method,
|
|
558
1044
|
headers,
|