@sogni-ai/sogni-client 5.56.2 → 5.57.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 CHANGED
@@ -1,3 +1,17 @@
1
+ # [5.57.0](https://github.com/Sogni-AI/sogni-client/compare/v5.56.3...v5.57.0) (2026-09-26)
2
+
3
+
4
+ ### Features
5
+
6
+ * **projects:** fetch a project's results by id and list recent completed projects ([89cd46d](https://github.com/Sogni-AI/sogni-client/commit/89cd46d4991fcddc83737d2abc3a0106d3734660))
7
+
8
+ ## [5.56.3](https://github.com/Sogni-AI/sogni-client/compare/v5.56.2...v5.56.3) (2026-09-26)
9
+
10
+
11
+ ### Bug Fixes
12
+
13
+ * **projects:** know the live Wan sound-to-video and Qwen3 TTS ids as media ([f621acb](https://github.com/Sogni-AI/sogni-client/commit/f621acb974de81659a958b594f0a5c52c4d957e4))
14
+
1
15
  ## [5.56.2](https://github.com/Sogni-AI/sogni-client/compare/v5.56.1...v5.56.2) (2026-09-26)
2
16
 
3
17
 
package/README.md CHANGED
@@ -525,6 +525,30 @@ when they finish.
525
525
  Recovery is per app instance: the server hands projects back to the `appId` that created them, so
526
526
  persist your `appId` (browsers: `localStorage`) and reuse it across reloads.
527
527
 
528
+ #### Results after you stopped waiting
529
+
530
+ The socket holds a project that finished while its client was disconnected for one hour. A client
531
+ that restarts, or a script or agent that exits before its projects finish, can still collect them:
532
+
533
+ - `sogni.projects.getResult(id)` returns a project's state and its renders at any time: while it is
534
+ queued (with the server's `waitingReason`, which says whether the account's own plan concurrency is
535
+ holding it or it is waiting for a worker) and after it finished, with signed download URLs for the
536
+ completed renders. Pass `{ kind: 'video' }` (or `image`, `audio`, `model`) when you know what it
537
+ produces and the model is not in this client's catalog.
538
+ - `sogni.projects.listRecent({ since })` lists this account's recently completed media projects,
539
+ newest first, from the durable history (up to 7 days back, 24 hours by default), including ones
540
+ that finished while no client was connected.
541
+
542
+ ```typescript
543
+ for (const project of await sogni.projects.listRecent({ since: Date.now() - 6 * 3600_000 })) {
544
+ const result = await sogni.projects.getResult(project.id);
545
+ for (const job of result.jobs) if (job.url) console.log(project.modelName, job.url);
546
+ }
547
+
548
+ const pending = await sogni.projects.getResult(projectId);
549
+ if (!pending.finished) console.log(pending.status, pending.waitingReason?.message);
550
+ ```
551
+
528
552
  ### Project parameters
529
553
 
530
554
  Here is a full list of project parameters that you can use:
@@ -7,6 +7,7 @@ import { AvailableLorasParams, LoraCatalog, LoraCatalogEntry, LoraConstraints }
7
7
  import PersonalLoras from './PersonalLoras.js';
8
8
  import { ProjectApiEvents, type ProjectSyncReason, type ProjectSyncResult } from './types/events.js';
9
9
  import { RawProject } from './types/RawProject.js';
10
+ import type { WaitingReason } from './types/WaitingReason.js';
10
11
  import { SupernetType } from '../ApiClient/WebSocketClient/types.js';
11
12
  import { type ResultMediaEvidence, type ResultMediaKind } from './utils/index.js';
12
13
  import { TokenType } from '../types/token.js';
@@ -62,6 +63,86 @@ export type ProjectStatusSnapshot = Partial<Omit<RawProject, 'status' | 'workerJ
62
63
  workerJobs: RawProject['workerJobs'];
63
64
  completedWorkerJobs: RawProject['completedWorkerJobs'];
64
65
  };
66
+ /**
67
+ * One render of a project returned by {@link ProjectsApi.getResult}.
68
+ */
69
+ export interface ProjectResultJob {
70
+ /** The render's result id (the `imgID` of its events). */
71
+ id: string;
72
+ /**
73
+ * `completed`, `failed` or `canceled` once it has finished; otherwise the
74
+ * worker job's in-flight status (`queued`, `assigned`, `jobStarted`, ...).
75
+ */
76
+ status: string;
77
+ /** Why a failed or cancelled render ended, e.g. `genfailure` or `artistCanceled`. */
78
+ reason?: string;
79
+ /** What the result is, when it is known. */
80
+ kind?: ResultMediaKind;
81
+ /** Signed download URL for a completed render's media. It expires; fetch a new one when it does. */
82
+ url?: string;
83
+ /**
84
+ * Why a completed render has no `url`: `sensitiveContent` when the Sensitive
85
+ * Content Filter withheld it, `unknownMediaKind` when neither the model nor
86
+ * the result says what media it is, `downloadUrlFailed` when the API refused
87
+ * or failed to sign one (retry later).
88
+ */
89
+ urlUnavailable?: 'sensitiveContent' | 'unknownMediaKind' | 'downloadUrlFailed';
90
+ /** Seed the render used, when recorded. */
91
+ seed?: number;
92
+ }
93
+ /**
94
+ * One of this account's projects as {@link ProjectsApi.getResult} sees it:
95
+ * its current state, why it is still waiting if it is, and every render with
96
+ * a download URL for the ones that completed.
97
+ */
98
+ export interface ProjectResult {
99
+ id: string;
100
+ status: ProjectLookupStatus;
101
+ /** `true` for `completed`, `failed` and `canceled`. */
102
+ finished: boolean;
103
+ modelId?: string;
104
+ /**
105
+ * While it is still queued: the server's explanation, e.g. the account's own
106
+ * plan concurrency limit (`concurrency_limit`, `model_concurrency_limit`)
107
+ * rather than a shortage of workers (`no_workers`). Absent when the server
108
+ * did not report one.
109
+ */
110
+ waitingReason?: WaitingReason | null;
111
+ jobs: ProjectResultJob[];
112
+ }
113
+ /** A render of a project returned by {@link ProjectsApi.listRecent}. */
114
+ export interface RecentProjectJob {
115
+ /** The render's result id; pass the project id to {@link ProjectsApi.getResult} for its URL. */
116
+ id: string;
117
+ status: string;
118
+ /** The Sensitive Content Filter withheld this render's media. */
119
+ sensitiveContentWithheld: boolean;
120
+ /** Milliseconds since the epoch. */
121
+ finishedAt?: number;
122
+ }
123
+ /** A project returned by {@link ProjectsApi.listRecent}. */
124
+ export interface RecentProject {
125
+ id: string;
126
+ modelId?: string;
127
+ modelName?: string;
128
+ /** The app that submitted it, when recorded (e.g. `sogni-creative-agent-skill`). */
129
+ appSource?: string;
130
+ /** When its latest render finished, in milliseconds since the epoch. */
131
+ finishedAt?: number;
132
+ jobs: RecentProjectJob[];
133
+ }
134
+ export interface ListRecentProjectsOptions {
135
+ /**
136
+ * Oldest finish time to include, as a `Date` or milliseconds since the epoch.
137
+ * Defaults to 24 hours ago. The history keeps 7 days, so anything older is
138
+ * read as 7 days ago.
139
+ */
140
+ since?: Date | number;
141
+ /** Renders to read, 1-100 (default 50). Projects are grouped from these. */
142
+ limit?: number;
143
+ /** Only projects submitted by this app source. */
144
+ appSource?: string;
145
+ }
65
146
  declare class ProjectsApi extends ApiGroup<ProjectApiEvents> {
66
147
  private _personalLoras?;
67
148
  /** Import, inspect, discover, and remove the signed-in account's personal LoRAs. */
@@ -418,6 +499,62 @@ declare class ProjectsApi extends ApiGroup<ProjectApiEvents> {
418
499
  * @param projectId
419
500
  */
420
501
  getStatus(projectId: string): Promise<ProjectStatusSnapshot>;
502
+ /**
503
+ * State and results of one of this account's projects by id, whenever it is
504
+ * asked: while it is queued or rendering, and after it finished, including
505
+ * one that finished while this client was offline, or after it stopped
506
+ * waiting, or long after the socket stopped holding it for a reconnect.
507
+ *
508
+ * Completed renders come back with signed download URLs, minted the same way
509
+ * a live result's are. A queued project carries the server's `waitingReason`,
510
+ * which says whether it is held by the account's own plan concurrency or is
511
+ * waiting for a worker. Needs an authenticated client; another account's or
512
+ * an unknown project rejects with a 404 `ApiError`.
513
+ *
514
+ * @example
515
+ * ```ts
516
+ * const result = await sogni.projects.getResult(projectId);
517
+ * if (result.finished) {
518
+ * for (const job of result.jobs) if (job.url) console.log(job.url);
519
+ * } else {
520
+ * console.log(result.status, result.waitingReason?.message);
521
+ * }
522
+ * ```
523
+ * @param projectId
524
+ * @param options.kind - What the project produces, when the caller knows it
525
+ * (`image`, `video`, `audio`, `model`). Used only when neither the model
526
+ * catalog nor the stored result says, so a URL is never minted from the
527
+ * wrong endpoint.
528
+ */
529
+ getResult(projectId: string, options?: {
530
+ kind?: ResultMediaKind;
531
+ }): Promise<ProjectResult>;
532
+ /**
533
+ * This account's recently completed projects that produced media, newest
534
+ * first, read from the durable history rather than the socket. It includes
535
+ * projects that finished while no client was connected, and ones the socket
536
+ * has stopped holding for a reconnect (it keeps a finished project for one
537
+ * hour). Pass a project's id to {@link getResult} for its download URLs.
538
+ *
539
+ * Reaches back at most 7 days. Needs a signed-in account.
540
+ *
541
+ * @example
542
+ * ```ts
543
+ * const recent = await sogni.projects.listRecent({ since: Date.now() - 6 * 3600_000 });
544
+ * for (const project of recent) {
545
+ * const result = await sogni.projects.getResult(project.id);
546
+ * }
547
+ * ```
548
+ */
549
+ listRecent(options?: ListRecentProjectsOptions): Promise<RecentProject[]>;
550
+ private _accountAddress?;
551
+ /**
552
+ * How {@link listRecent} learns the signed-in account's address. Set by
553
+ * `SogniClient`, which owns the account.
554
+ * @internal
555
+ */
556
+ _setAccountAddressResolver(resolve: () => Promise<string | undefined>): void;
557
+ private _resolveAccountAddress;
421
558
  /**
422
559
  * Ids of this account's projects that are currently live on the socket,
423
560
  * including ones still queued with no worker assigned.
@@ -313,6 +313,18 @@ const RESUBMIT_RECONNECT_TIMEOUT_MS = 60000;
313
313
  const MISSING_PROJECT_ATTEMPTS = 4;
314
314
  const MISSING_PROJECT_RETRY_MS = 2500;
315
315
  const IN_FLIGHT_LOOKUP_STATUSES = new Set(['pending', 'queued', 'processing']);
316
+ const RECENT_PROJECTS_DEFAULT_WINDOW_MS = 24 * 60 * 60 * 1000;
317
+ // The history API refuses a media-only lookup reaching back more than 7 days;
318
+ // stay a minute inside it so a slow clock cannot turn a 7-day request into a 400.
319
+ const RECENT_PROJECTS_MAX_WINDOW_MS = 7 * 24 * 60 * 60 * 1000 - 60 * 1000;
320
+ /** A worker job's status in the names {@link ProjectResultJob} uses. */
321
+ function resultJobStatus(job) {
322
+ if (job.status === 'jobCompleted')
323
+ return 'completed';
324
+ if (job.status === 'jobError')
325
+ return job.reason === 'artistCanceled' ? 'canceled' : 'failed';
326
+ return job.status || 'unknown';
327
+ }
316
328
  const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
317
329
  class ProjectsApi extends ApiGroup_js_1.default {
318
330
  /** Import, inspect, discover, and remove the signed-in account's personal LoRAs. */
@@ -2132,6 +2144,164 @@ class ProjectsApi extends ApiGroup_js_1.default {
2132
2144
  return data.project;
2133
2145
  });
2134
2146
  }
2147
+ /**
2148
+ * State and results of one of this account's projects by id, whenever it is
2149
+ * asked: while it is queued or rendering, and after it finished, including
2150
+ * one that finished while this client was offline, or after it stopped
2151
+ * waiting, or long after the socket stopped holding it for a reconnect.
2152
+ *
2153
+ * Completed renders come back with signed download URLs, minted the same way
2154
+ * a live result's are. A queued project carries the server's `waitingReason`,
2155
+ * which says whether it is held by the account's own plan concurrency or is
2156
+ * waiting for a worker. Needs an authenticated client; another account's or
2157
+ * an unknown project rejects with a 404 `ApiError`.
2158
+ *
2159
+ * @example
2160
+ * ```ts
2161
+ * const result = await sogni.projects.getResult(projectId);
2162
+ * if (result.finished) {
2163
+ * for (const job of result.jobs) if (job.url) console.log(job.url);
2164
+ * } else {
2165
+ * console.log(result.status, result.waitingReason?.message);
2166
+ * }
2167
+ * ```
2168
+ * @param projectId
2169
+ * @param options.kind - What the project produces, when the caller knows it
2170
+ * (`image`, `video`, `audio`, `model`). Used only when neither the model
2171
+ * catalog nor the stored result says, so a URL is never minted from the
2172
+ * wrong endpoint.
2173
+ */
2174
+ getResult(projectId_1) {
2175
+ return __awaiter(this, arguments, void 0, function* (projectId, options = {}) {
2176
+ var _a, _b, _c, _d, _e;
2177
+ const assertSession = (0, requestSession_js_1.captureRequestSession)(this.client.auth);
2178
+ const snapshot = yield this.getStatus(projectId);
2179
+ assertSession();
2180
+ const modelId = (_a = snapshot.model) === null || _a === void 0 ? void 0 : _a.id;
2181
+ // The stored record's model carries its media type even though ProjectModel does not declare it.
2182
+ const recordType = (_b = snapshot.model) === null || _b === void 0 ? void 0 : _b.type;
2183
+ const projectType = typeof recordType === 'string' ? recordType : undefined;
2184
+ const jobs = [];
2185
+ const seen = new Set();
2186
+ for (const job of [...(snapshot.completedWorkerJobs || []), ...(snapshot.workerJobs || [])]) {
2187
+ const id = job.imgID || job.id;
2188
+ if (!id || seen.has(id))
2189
+ continue;
2190
+ seen.add(id);
2191
+ const status = resultJobStatus(job);
2192
+ const entry = { id, status };
2193
+ if (status !== 'completed' && job.reason)
2194
+ entry.reason = job.reason;
2195
+ if (typeof job.seedUsed === 'number' && job.seedUsed >= 0)
2196
+ entry.seed = job.seedUsed;
2197
+ if (status === 'completed') {
2198
+ // Same rule as a live result: withheld only when the filter fired and
2199
+ // no advisory label says the media was delivered anyway.
2200
+ if (job.triggeredNSFWFilter === true && job.nsfwDetected !== true) {
2201
+ entry.urlUnavailable = 'sensitiveContent';
2202
+ }
2203
+ else if (typeof job.resultUrl === 'string' && job.resultUrl) {
2204
+ entry.url = job.resultUrl;
2205
+ }
2206
+ else {
2207
+ const result = (0, index_js_2.resultMediaEvidence)(Object.assign(Object.assign({}, (job.result || {})), { outputFormat: (_c = job.outputFormat) !== null && _c !== void 0 ? _c : (_d = job.result) === null || _d === void 0 ? void 0 : _d.outputFormat }));
2208
+ const kind = (_e = this._resultMediaKind({ modelId, projectType, result })) !== null && _e !== void 0 ? _e : options.kind;
2209
+ if (!kind) {
2210
+ entry.urlUnavailable = 'unknownMediaKind';
2211
+ }
2212
+ else {
2213
+ entry.kind = kind;
2214
+ try {
2215
+ entry.url = yield this._mintResultUrl({
2216
+ projectId: snapshot.id,
2217
+ jobId: id,
2218
+ kind,
2219
+ audioContentType: result === null || result === void 0 ? void 0 : result.contentType
2220
+ });
2221
+ }
2222
+ catch (error) {
2223
+ this.client.logger.error(`Failed to sign a download URL for ${snapshot.id}/${id}`);
2224
+ this.client.logger.error(error);
2225
+ entry.urlUnavailable = 'downloadUrlFailed';
2226
+ }
2227
+ assertSession();
2228
+ }
2229
+ }
2230
+ }
2231
+ jobs.push(entry);
2232
+ }
2233
+ return Object.assign(Object.assign(Object.assign({ id: snapshot.id, status: snapshot.status, finished: snapshot.finished }, (modelId ? { modelId } : {})), (snapshot.waitingReason !== undefined ? { waitingReason: snapshot.waitingReason } : {})), { jobs });
2234
+ });
2235
+ }
2236
+ /**
2237
+ * This account's recently completed projects that produced media, newest
2238
+ * first, read from the durable history rather than the socket. It includes
2239
+ * projects that finished while no client was connected, and ones the socket
2240
+ * has stopped holding for a reconnect (it keeps a finished project for one
2241
+ * hour). Pass a project's id to {@link getResult} for its download URLs.
2242
+ *
2243
+ * Reaches back at most 7 days. Needs a signed-in account.
2244
+ *
2245
+ * @example
2246
+ * ```ts
2247
+ * const recent = await sogni.projects.listRecent({ since: Date.now() - 6 * 3600_000 });
2248
+ * for (const project of recent) {
2249
+ * const result = await sogni.projects.getResult(project.id);
2250
+ * }
2251
+ * ```
2252
+ */
2253
+ listRecent() {
2254
+ return __awaiter(this, arguments, void 0, function* (options = {}) {
2255
+ var _a, _b, _c, _d, _e, _f, _g;
2256
+ const assertSession = (0, requestSession_js_1.captureRequestSession)(this.client.auth);
2257
+ const address = yield this._resolveAccountAddress();
2258
+ assertSession();
2259
+ if (!address) {
2260
+ throw new Error('listRecent needs a signed-in account');
2261
+ }
2262
+ const now = Date.now();
2263
+ const requestedSince = options.since instanceof Date
2264
+ ? options.since.getTime()
2265
+ : typeof options.since === 'number'
2266
+ ? options.since
2267
+ : now - RECENT_PROJECTS_DEFAULT_WINDOW_MS;
2268
+ const since = Math.max(requestedSince, now - RECENT_PROJECTS_MAX_WINDOW_MS);
2269
+ const limit = Math.min(100, Math.max(1, Math.floor((_a = options.limit) !== null && _a !== void 0 ? _a : 50)));
2270
+ const { data } = yield this.client.rest.get('/v1/jobs/list', Object.assign({ role: 'artist', address, state: 'completed', mediaOnly: true, since,
2271
+ limit }, (options.appSource ? { appSource: options.appSource } : {})));
2272
+ assertSession();
2273
+ const projects = new Map();
2274
+ for (const job of (data === null || data === void 0 ? void 0 : data.jobs) || []) {
2275
+ const projectId = (_b = job.parentRequest) === null || _b === void 0 ? void 0 : _b.id;
2276
+ if (!projectId)
2277
+ continue;
2278
+ let project = projects.get(projectId);
2279
+ if (!project) {
2280
+ project = Object.assign(Object.assign(Object.assign(Object.assign({ id: projectId }, (((_d = (_c = job.parentRequest) === null || _c === void 0 ? void 0 : _c.model) === null || _d === void 0 ? void 0 : _d.id) ? { modelId: job.parentRequest.model.id } : {})), (((_f = (_e = job.parentRequest) === null || _e === void 0 ? void 0 : _e.model) === null || _f === void 0 ? void 0 : _f.name) ? { modelName: job.parentRequest.model.name } : {})), (((_g = job.parentRequest) === null || _g === void 0 ? void 0 : _g.appSource) ? { appSource: job.parentRequest.appSource } : {})), { jobs: [] });
2281
+ projects.set(projectId, project);
2282
+ }
2283
+ const finishedAt = typeof job.endTime === 'number' && job.endTime > 0 ? job.endTime : undefined;
2284
+ project.jobs.push(Object.assign({ id: job.imgID || job.id, status: resultJobStatus(job), sensitiveContentWithheld: job.triggeredNSFWFilter === true && job.nsfwDetected !== true }, (finishedAt ? { finishedAt } : {})));
2285
+ if (finishedAt && (!project.finishedAt || finishedAt > project.finishedAt)) {
2286
+ project.finishedAt = finishedAt;
2287
+ }
2288
+ }
2289
+ return [...projects.values()].sort((a, b) => { var _a, _b; return ((_a = b.finishedAt) !== null && _a !== void 0 ? _a : 0) - ((_b = a.finishedAt) !== null && _b !== void 0 ? _b : 0); });
2290
+ });
2291
+ }
2292
+ /**
2293
+ * How {@link listRecent} learns the signed-in account's address. Set by
2294
+ * `SogniClient`, which owns the account.
2295
+ * @internal
2296
+ */
2297
+ _setAccountAddressResolver(resolve) {
2298
+ this._accountAddress = resolve;
2299
+ }
2300
+ _resolveAccountAddress() {
2301
+ return __awaiter(this, void 0, void 0, function* () {
2302
+ return this._accountAddress ? this._accountAddress() : undefined;
2303
+ });
2304
+ }
2135
2305
  /**
2136
2306
  * Ids of this account's projects that are currently live on the socket,
2137
2307
  * including ones still queued with no worker assigned.