@sogni-ai/sogni-client 4.0.0-alpha.3 → 4.0.0-alpha.31

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.
Files changed (76) hide show
  1. package/CHANGELOG.md +220 -0
  2. package/README.md +279 -28
  3. package/dist/Account/index.d.ts +18 -16
  4. package/dist/Account/index.js +31 -20
  5. package/dist/Account/index.js.map +1 -1
  6. package/dist/ApiClient/WebSocketClient/BrowserWebSocketClient/ChannelCoordinator.d.ts +66 -0
  7. package/dist/ApiClient/WebSocketClient/BrowserWebSocketClient/ChannelCoordinator.js +332 -0
  8. package/dist/ApiClient/WebSocketClient/BrowserWebSocketClient/ChannelCoordinator.js.map +1 -0
  9. package/dist/ApiClient/WebSocketClient/BrowserWebSocketClient/index.d.ts +28 -0
  10. package/dist/ApiClient/WebSocketClient/BrowserWebSocketClient/index.js +203 -0
  11. package/dist/ApiClient/WebSocketClient/BrowserWebSocketClient/index.js.map +1 -0
  12. package/dist/ApiClient/WebSocketClient/events.d.ts +11 -0
  13. package/dist/ApiClient/WebSocketClient/index.d.ts +2 -2
  14. package/dist/ApiClient/WebSocketClient/index.js +13 -3
  15. package/dist/ApiClient/WebSocketClient/index.js.map +1 -1
  16. package/dist/ApiClient/WebSocketClient/types.d.ts +13 -0
  17. package/dist/ApiClient/index.d.ts +4 -4
  18. package/dist/ApiClient/index.js +23 -4
  19. package/dist/ApiClient/index.js.map +1 -1
  20. package/dist/Projects/Job.d.ts +44 -4
  21. package/dist/Projects/Job.js +83 -16
  22. package/dist/Projects/Job.js.map +1 -1
  23. package/dist/Projects/Project.d.ts +18 -0
  24. package/dist/Projects/Project.js +34 -6
  25. package/dist/Projects/Project.js.map +1 -1
  26. package/dist/Projects/createJobRequestMessage.js +109 -15
  27. package/dist/Projects/createJobRequestMessage.js.map +1 -1
  28. package/dist/Projects/index.d.ts +110 -11
  29. package/dist/Projects/index.js +423 -42
  30. package/dist/Projects/index.js.map +1 -1
  31. package/dist/Projects/types/EstimationResponse.d.ts +2 -0
  32. package/dist/Projects/types/SamplerParams.d.ts +13 -0
  33. package/dist/Projects/types/SamplerParams.js +26 -0
  34. package/dist/Projects/types/SamplerParams.js.map +1 -0
  35. package/dist/Projects/types/SchedulerParams.d.ts +14 -0
  36. package/dist/Projects/types/SchedulerParams.js +24 -0
  37. package/dist/Projects/types/SchedulerParams.js.map +1 -0
  38. package/dist/Projects/types/events.d.ts +5 -1
  39. package/dist/Projects/types/index.d.ts +150 -39
  40. package/dist/Projects/types/index.js +13 -0
  41. package/dist/Projects/types/index.js.map +1 -1
  42. package/dist/Projects/utils.d.ts +19 -1
  43. package/dist/Projects/utils.js +68 -0
  44. package/dist/Projects/utils.js.map +1 -1
  45. package/dist/index.d.ts +12 -4
  46. package/dist/index.js +12 -4
  47. package/dist/index.js.map +1 -1
  48. package/dist/lib/AuthManager/TokenAuthManager.js +0 -2
  49. package/dist/lib/AuthManager/TokenAuthManager.js.map +1 -1
  50. package/dist/lib/DataEntity.js +4 -2
  51. package/dist/lib/DataEntity.js.map +1 -1
  52. package/dist/lib/validation.d.ts +7 -0
  53. package/dist/lib/validation.js +36 -0
  54. package/dist/lib/validation.js.map +1 -1
  55. package/package.json +4 -4
  56. package/src/Account/index.ts +30 -19
  57. package/src/ApiClient/WebSocketClient/BrowserWebSocketClient/ChannelCoordinator.ts +426 -0
  58. package/src/ApiClient/WebSocketClient/BrowserWebSocketClient/index.ts +237 -0
  59. package/src/ApiClient/WebSocketClient/events.ts +13 -0
  60. package/src/ApiClient/WebSocketClient/index.ts +15 -5
  61. package/src/ApiClient/WebSocketClient/types.ts +16 -0
  62. package/src/ApiClient/index.ts +30 -8
  63. package/src/Projects/Job.ts +97 -16
  64. package/src/Projects/Project.ts +42 -9
  65. package/src/Projects/createJobRequestMessage.ts +155 -36
  66. package/src/Projects/index.ts +447 -46
  67. package/src/Projects/types/EstimationResponse.ts +2 -0
  68. package/src/Projects/types/SamplerParams.ts +24 -0
  69. package/src/Projects/types/SchedulerParams.ts +22 -0
  70. package/src/Projects/types/events.ts +6 -0
  71. package/src/Projects/types/index.ts +181 -47
  72. package/src/Projects/utils.ts +66 -1
  73. package/src/index.ts +38 -11
  74. package/src/lib/AuthManager/TokenAuthManager.ts +0 -2
  75. package/src/lib/DataEntity.ts +4 -2
  76. package/src/lib/validation.ts +41 -0
@@ -1,7 +1,7 @@
1
1
  import { MessageType, SocketMessageMap } from './messages';
2
2
  import { SocketEventMap } from './events';
3
3
  import RestClient from '../../lib/RestClient';
4
- import { SupernetType } from './types';
4
+ import { IWebSocketClient, SupernetType } from './types';
5
5
  import WebSocket, { CloseEvent, ErrorEvent, MessageEvent } from 'isomorphic-ws';
6
6
  import { base64Decode, base64Encode } from '../../lib/base64';
7
7
  import isNodejs from '../../lib/isNodejs';
@@ -13,7 +13,7 @@ const PROTOCOL_VERSION = '3.0.0';
13
13
 
14
14
  const PING_INTERVAL = 15000;
15
15
 
16
- class WebSocketClient extends RestClient<SocketEventMap> {
16
+ class WebSocketClient extends RestClient<SocketEventMap> implements IWebSocketClient {
17
17
  appId: string;
18
18
  baseUrl: string;
19
19
  private socket: WebSocket | null = null;
@@ -86,7 +86,7 @@ class WebSocketClient extends RestClient<SocketEventMap> {
86
86
  socket.onmessage = null;
87
87
  socket.onopen = null;
88
88
  this.stopPing();
89
- socket.close();
89
+ socket.close(1000, 'Client disconnected');
90
90
  }
91
91
 
92
92
  private startPing(socket: WebSocket) {
@@ -148,9 +148,16 @@ class WebSocketClient extends RestClient<SocketEventMap> {
148
148
  }
149
149
 
150
150
  private handleClose(e: CloseEvent) {
151
- if (e.target === this.socket) {
151
+ const socket = e.target;
152
+ socket.onerror = null;
153
+ socket.onmessage = null;
154
+ socket.onopen = null;
155
+ if (socket === this.socket || !this.socket) {
152
156
  this._logger.info('WebSocket disconnected, cleanup', e);
153
- this.disconnect();
157
+ if (socket === this.socket) {
158
+ this.stopPing();
159
+ this.socket = null;
160
+ }
154
161
  this.emit('disconnected', {
155
162
  code: e.code,
156
163
  reason: e.reason
@@ -193,6 +200,9 @@ class WebSocketClient extends RestClient<SocketEventMap> {
193
200
  }
194
201
 
195
202
  async send<T extends MessageType>(messageType: T, data: SocketMessageMap[T]) {
203
+ if (!this.isConnected) {
204
+ await this.connect();
205
+ }
196
206
  await this.waitForConnection();
197
207
  this._logger.debug('WebSocket send:', messageType, data);
198
208
  this.socket!.send(
@@ -1 +1,17 @@
1
+ import { MessageType, SocketMessageMap } from './messages';
2
+ import RestClient from '../../lib/RestClient';
3
+ import { SocketEventMap } from './events';
4
+
1
5
  export type SupernetType = 'relaxed' | 'fast';
6
+
7
+ export interface IWebSocketClient extends RestClient<SocketEventMap> {
8
+ appId: string;
9
+ baseUrl: string;
10
+ isConnected: boolean;
11
+ supernetType: SupernetType;
12
+
13
+ connect(): Promise<void>;
14
+ disconnect(): void;
15
+ send<T extends MessageType>(messageType: T, data: SocketMessageMap[T]): Promise<void>;
16
+ switchNetwork(supernetType: SupernetType): Promise<SupernetType>;
17
+ }
@@ -3,12 +3,14 @@ import WebSocketClient from './WebSocketClient';
3
3
  import TypedEventEmitter from '../lib/TypedEventEmitter';
4
4
  import { ApiClientEvents } from './events';
5
5
  import { ServerConnectData, ServerDisconnectData } from './WebSocketClient/events';
6
- import { isNotRecoverable } from './WebSocketClient/ErrorCode';
6
+ import { ErrorCode, isNotRecoverable } from './WebSocketClient/ErrorCode';
7
7
  import { JSONValue } from '../types/json';
8
- import { SupernetType } from './WebSocketClient/types';
8
+ import { IWebSocketClient, SupernetType } from './WebSocketClient/types';
9
9
  import { Logger } from '../lib/DefaultLogger';
10
10
  import CookieAuthManager from '../lib/AuthManager/CookieAuthManager';
11
11
  import { AuthManager, TokenAuthManager } from '../lib/AuthManager';
12
+ import isNodejs from '../lib/isNodejs';
13
+ import BrowserWebSocketClient from './WebSocketClient/BrowserWebSocketClient';
12
14
 
13
15
  const WS_RECONNECT_ATTEMPTS = 5;
14
16
 
@@ -42,13 +44,14 @@ export interface ApiClientOptions {
42
44
  logger: Logger;
43
45
  authType: 'token' | 'cookies';
44
46
  disableSocket?: boolean;
47
+ multiInstance?: boolean;
45
48
  }
46
49
 
47
50
  class ApiClient extends TypedEventEmitter<ApiClientEvents> {
48
51
  readonly appId: string;
49
52
  readonly logger: Logger;
50
53
  private _rest: RestClient;
51
- private _socket: WebSocketClient;
54
+ private _socket: IWebSocketClient;
52
55
  private _auth: AuthManager;
53
56
  private _reconnectAttempts = WS_RECONNECT_ATTEMPTS;
54
57
  private _disableSocket: boolean = false;
@@ -60,7 +63,8 @@ class ApiClient extends TypedEventEmitter<ApiClientEvents> {
60
63
  networkType,
61
64
  authType,
62
65
  logger,
63
- disableSocket = false
66
+ disableSocket = false,
67
+ multiInstance = false
64
68
  }: ApiClientOptions) {
65
69
  super();
66
70
  this.appId = appId;
@@ -68,7 +72,13 @@ class ApiClient extends TypedEventEmitter<ApiClientEvents> {
68
72
  this._auth =
69
73
  authType === 'token' ? new TokenAuthManager(baseUrl, logger) : new CookieAuthManager(logger);
70
74
  this._rest = new RestClient(baseUrl, this._auth, logger);
71
- this._socket = new WebSocketClient(socketUrl, this._auth, appId, networkType, logger);
75
+ const supportMultiInstance = !isNodejs && this._auth instanceof CookieAuthManager;
76
+ if (supportMultiInstance && multiInstance) {
77
+ // Use coordinated WebSocket client to share single connection between tabs
78
+ this._socket = new BrowserWebSocketClient(socketUrl, this._auth, appId, networkType, logger);
79
+ } else {
80
+ this._socket = new WebSocketClient(socketUrl, this._auth, appId, networkType, logger);
81
+ }
72
82
  this._disableSocket = disableSocket;
73
83
  this._auth.on('updated', this.handleAuthUpdated.bind(this));
74
84
  this._socket.on('connected', this.handleSocketConnect.bind(this));
@@ -83,7 +93,7 @@ class ApiClient extends TypedEventEmitter<ApiClientEvents> {
83
93
  return this._auth;
84
94
  }
85
95
 
86
- get socket(): WebSocketClient {
96
+ get socket(): IWebSocketClient {
87
97
  return this._socket;
88
98
  }
89
99
 
@@ -101,14 +111,26 @@ class ApiClient extends TypedEventEmitter<ApiClientEvents> {
101
111
  }
102
112
 
103
113
  handleSocketDisconnect(data: ServerDisconnectData) {
114
+ // If user is not authenticated, we don't need to reconnect
115
+ if (!this.auth.isAuthenticated || data.code === 1000) {
116
+ this.emit('disconnected', data);
117
+ return;
118
+ }
104
119
  if (!data.code || isNotRecoverable(data.code)) {
120
+ // If this is browser, another tab is probably claiming the connection, so we don't need to reconnect
121
+ if (
122
+ this._socket instanceof BrowserWebSocketClient &&
123
+ data.code === ErrorCode.SWITCH_CONNECTION
124
+ ) {
125
+ this.logger.debug('Switching network connection, not reconnecting');
126
+ return;
127
+ }
105
128
  this.auth.clear();
106
129
  this.emit('disconnected', data);
107
130
  this.logger.error('Not recoverable socket error', data);
108
131
  return;
109
132
  }
110
133
  if (this._reconnectAttempts <= 0) {
111
- this.auth.clear();
112
134
  this.emit('disconnected', data);
113
135
  this._reconnectAttempts = WS_RECONNECT_ATTEMPTS;
114
136
  return;
@@ -122,7 +144,7 @@ class ApiClient extends TypedEventEmitter<ApiClientEvents> {
122
144
  if (this.socket.isConnected) {
123
145
  this.socket.disconnect();
124
146
  }
125
- } else if (!this._disableSocket) {
147
+ } else if (!this._disableSocket && !this.socket.isConnected) {
126
148
  this.socket.connect();
127
149
  }
128
150
  }
@@ -9,6 +9,7 @@ import Project from './Project';
9
9
  import { SupernetType } from '../ApiClient/WebSocketClient/types';
10
10
  import { getEnhacementStrength } from './utils';
11
11
  import { TokenType } from '../types/token';
12
+ import { has } from 'lodash';
12
13
 
13
14
  export const enhancementDefaults = {
14
15
  network: 'fast' as SupernetType,
@@ -19,7 +20,7 @@ export const enhancementDefaults = {
19
20
  startingImageStrength: 0.5,
20
21
  steps: 5,
21
22
  guidance: 1,
22
- numberOfImages: 1,
23
+ numberOfMedia: 1,
23
24
  numberOfPreviews: 0
24
25
  };
25
26
 
@@ -61,6 +62,17 @@ export interface JobData {
61
62
  positivePrompt?: string;
62
63
  negativePrompt?: string;
63
64
  jobIndex?: number;
65
+ /**
66
+ * Estimated time remaining in seconds (for long-running jobs like video generation).
67
+ * Updated by ComfyUI workers during inference.
68
+ * @deprecated Use `eta` instead.
69
+ */
70
+ etaSeconds?: number;
71
+ /**
72
+ * Estimate completion time of the job (for long-running jobs like video generation).
73
+ * Updated by ComfyUI workers during inference.
74
+ */
75
+ eta?: Date;
64
76
  }
65
77
 
66
78
  export interface JobEventMap extends EntityEvents {
@@ -178,10 +190,21 @@ class Job extends DataEntity<JobData, JobEventMap> {
178
190
  return this.data.error;
179
191
  }
180
192
 
181
- get hasResultImage() {
193
+ /**
194
+ * Whether this job has a result media file available for download.
195
+ * Returns true if completed and not NSFW filtered.
196
+ */
197
+ get hasResultMedia() {
182
198
  return this.status === 'completed' && !this.isNSFW;
183
199
  }
184
200
 
201
+ /**
202
+ * Whether this job produces video output (based on the model used)
203
+ */
204
+ get type(): 'image' | 'video' {
205
+ return this._api.isVideoModelId(this._project.params.modelId) ? 'video' : 'image';
206
+ }
207
+
185
208
  get enhancedImage() {
186
209
  if (!this._enhancementProject) {
187
210
  return null;
@@ -199,17 +222,27 @@ class Job extends DataEntity<JobData, JobEventMap> {
199
222
 
200
223
  /**
201
224
  * Get the result URL of the job. This method will make a request to the API to get signed URL.
202
- * IMPORTANT: URL expires after 30 minutes, so make sure to download the image as soon as possible.
225
+ * IMPORTANT: URL expires after 30 minutes, so make sure to download the result as soon as possible.
226
+ * For video jobs, this returns a video URL. For image jobs, this returns an image URL.
203
227
  */
204
228
  async getResultUrl(): Promise<string> {
205
229
  if (this.data.status !== 'completed') {
206
230
  throw new Error('Job is not completed yet');
207
231
  }
208
- const url = await this._api.downloadUrl({
209
- jobId: this.projectId,
210
- imageId: this.id,
211
- type: 'complete'
212
- });
232
+ let url: string;
233
+ if (this.type === 'video') {
234
+ url = await this._api.mediaDownloadUrl({
235
+ jobId: this.projectId,
236
+ id: this.id,
237
+ type: 'complete'
238
+ });
239
+ } else {
240
+ url = await this._api.downloadUrl({
241
+ jobId: this.projectId,
242
+ imageId: this.id,
243
+ type: 'complete'
244
+ });
245
+ }
213
246
  this._update({ resultUrl: url });
214
247
  return url;
215
248
  }
@@ -230,6 +263,26 @@ class Job extends DataEntity<JobData, JobEventMap> {
230
263
  return this.data.workerName;
231
264
  }
232
265
 
266
+ /**
267
+ * Estimated time remaining in seconds for long-running jobs (e.g., video generation).
268
+ * Only available for ComfyUI-based workers during inference.
269
+ * Returns undefined if no ETA has been received.
270
+ * @deprecated Use `timeLeft` instead.
271
+ */
272
+ get etaSeconds() {
273
+ return this.data.etaSeconds;
274
+ }
275
+
276
+ /**
277
+ * Estimate completion time of the job.
278
+ * Only available for ComfyUI-based workers during inference.
279
+ * Is useful when data is persisted
280
+ * Returns undefined if no ETA has been received.
281
+ */
282
+ get eta() {
283
+ return this.data.eta;
284
+ }
285
+
233
286
  /**
234
287
  * Syncs the job data with the data received from the REST API.
235
288
  * @internal
@@ -247,11 +300,19 @@ class Job extends DataEntity<JobData, JobEventMap> {
247
300
  }
248
301
  if (!this.data.resultUrl && delta.status === 'completed' && !data.triggeredNSFWFilter) {
249
302
  try {
250
- delta.resultUrl = await this._api.downloadUrl({
251
- jobId: this.projectId,
252
- imageId: this.id,
253
- type: 'complete'
254
- });
303
+ if (this.type === 'video') {
304
+ delta.resultUrl = await this._api.mediaDownloadUrl({
305
+ jobId: this.projectId,
306
+ id: this.id,
307
+ type: 'complete'
308
+ });
309
+ } else {
310
+ delta.resultUrl = await this._api.downloadUrl({
311
+ jobId: this.projectId,
312
+ imageId: this.id,
313
+ type: 'complete'
314
+ });
315
+ }
255
316
  } catch (error) {
256
317
  this._logger.error(error);
257
318
  }
@@ -259,6 +320,21 @@ class Job extends DataEntity<JobData, JobEventMap> {
259
320
  this._update(delta);
260
321
  }
261
322
 
323
+ /**
324
+ * Updates the job data with the provided delta.
325
+ * @internal
326
+ * @param delta
327
+ */
328
+ _update(delta: Partial<JobData>) {
329
+ if (has(delta, 'eta')) {
330
+ // Keeping etaSeconds for backwards compatibility
331
+ if (delta.eta) {
332
+ delta.etaSeconds = Math.round((delta.eta.getTime() - Date.now()) / 1000);
333
+ }
334
+ }
335
+ super._update(delta);
336
+ }
337
+
262
338
  private handleUpdated(keys: string[]) {
263
339
  if (keys.includes('step') || keys.includes('stepCount')) {
264
340
  this.emit('progress', this.progress);
@@ -276,8 +352,8 @@ class Job extends DataEntity<JobData, JobEventMap> {
276
352
  }
277
353
 
278
354
  async getResultData() {
279
- if (!this.hasResultImage) {
280
- throw new Error('No result image available');
355
+ if (!this.hasResultMedia) {
356
+ throw new Error('No result media available');
281
357
  }
282
358
  const url = await this.getResultUrl();
283
359
  const response = await fetch(url);
@@ -297,6 +373,10 @@ class Job extends DataEntity<JobData, JobEventMap> {
297
373
  strength: EnhancementStrength,
298
374
  overrides: { positivePrompt?: string; stylePrompt?: string; tokenType?: TokenType } = {}
299
375
  ) {
376
+ const parentProjectParams = this._project.params;
377
+ if (parentProjectParams.type !== 'image') {
378
+ throw new Error('Enhancement is only available for images');
379
+ }
300
380
  if (this.status !== 'completed') {
301
381
  throw new Error('Job is not completed yet');
302
382
  }
@@ -309,6 +389,7 @@ class Job extends DataEntity<JobData, JobEventMap> {
309
389
  }
310
390
  const imageData = await this.getResultData();
311
391
  const project = await this._api.create({
392
+ type: 'image',
312
393
  ...enhancementDefaults,
313
394
  positivePrompt: overrides.positivePrompt || this._project.params.positivePrompt,
314
395
  stylePrompt: overrides.stylePrompt || this._project.params.stylePrompt,
@@ -316,7 +397,7 @@ class Job extends DataEntity<JobData, JobEventMap> {
316
397
  seed: this.seed || this._project.params.seed,
317
398
  startingImage: imageData,
318
399
  startingImageStrength: 1 - getEnhacementStrength(strength),
319
- sizePreset: this._project.params.sizePreset
400
+ sizePreset: parentProjectParams.sizePreset
320
401
  });
321
402
  this._enhancementProject = project;
322
403
  this._enhancementProject.on('updated', this.handleEnhancementUpdate);
@@ -1,6 +1,6 @@
1
1
  import Job, { JobData } from './Job';
2
2
  import DataEntity, { EntityEvents } from '../lib/DataEntity';
3
- import { ProjectParams } from './types';
3
+ import { isImageParams, ProjectParams } from './types';
4
4
  import cloneDeep from 'lodash/cloneDeep';
5
5
  import ErrorData from '../types/ErrorData';
6
6
  import getUUID from '../lib/getUUID';
@@ -39,6 +39,11 @@ export interface ProjectData {
39
39
  params: ProjectParams;
40
40
  queuePosition: number;
41
41
  status: ProjectStatus;
42
+ /**
43
+ * Estimated completion time of the project (for long-running projects like video generation).
44
+ * Is equal to maximum job ETA
45
+ */
46
+ eta?: Date;
42
47
  error?: ErrorData;
43
48
  }
44
49
  /** @inline */
@@ -93,10 +98,22 @@ class Project extends DataEntity<ProjectData, ProjectEventMap> {
93
98
  return this.data.params;
94
99
  }
95
100
 
101
+ get type() {
102
+ return this.params.type;
103
+ }
104
+
96
105
  get status() {
97
106
  return this.data.status;
98
107
  }
99
108
 
109
+ /**
110
+ * Estimated time of completion in seconds (for long-running projects like video generation).
111
+ * Updated by ComfyUI workers during inference.
112
+ */
113
+ get eta() {
114
+ return this.data.eta;
115
+ }
116
+
100
117
  get finished() {
101
118
  return ['completed', 'failed', 'canceled'].includes(this.status);
102
119
  }
@@ -110,10 +127,10 @@ class Project extends DataEntity<ProjectData, ProjectEventMap> {
110
127
  */
111
128
  get progress() {
112
129
  // Worker can reduce the number of steps in the job, so we need to calculate the progress based on the actual number of steps
113
- const stepsPerJob = this.jobs.length ? this.jobs[0].stepCount : this.data.params.steps;
114
- const jobCount = this.data.params.numberOfImages;
130
+ const stepsPerJob = this.jobs.length ? this.jobs[0].stepCount : (this.data.params.steps ?? 0);
131
+ const jobCount = this.data.params.numberOfMedia;
115
132
  const stepsDone = this._jobs.reduce((acc, job) => acc + job.step, 0);
116
- return Math.round((stepsDone / (stepsPerJob * jobCount)) * 100);
133
+ return Math.round((stepsDone / ((stepsPerJob ?? 1) * jobCount)) * 100);
117
134
  }
118
135
 
119
136
  get queuePosition() {
@@ -192,7 +209,7 @@ class Project extends DataEntity<ProjectData, ProjectEventMap> {
192
209
  this._timeout = null;
193
210
  }
194
211
  if (keys.includes('status') || keys.includes('jobs')) {
195
- const allJobsStarted = this.jobs.length >= this.params.numberOfImages;
212
+ const allJobsStarted = this.jobs.length >= this.params.numberOfMedia;
196
213
  const allJobsDone = this.jobs.every((job) => job.finished);
197
214
  if (this.data.status === 'completed' && allJobsStarted && allJobsDone) {
198
215
  return this.emit('completed', this.resultUrls);
@@ -203,6 +220,16 @@ class Project extends DataEntity<ProjectData, ProjectEventMap> {
203
220
  }
204
221
  }
205
222
 
223
+ /**
224
+ * Refresh the lastUpdated timestamp to prevent timeout.
225
+ * Used when receiving socket events that indicate the project is still active
226
+ * (e.g., jobETA events during long-running video generation).
227
+ * @internal
228
+ */
229
+ _keepAlive() {
230
+ this.lastUpdated = new Date();
231
+ }
232
+
206
233
  /**
207
234
  * This is internal method to add a job to the project. Do not call this directly.
208
235
  * @internal
@@ -231,7 +258,11 @@ class Project extends DataEntity<ProjectData, ProjectEventMap> {
231
258
  private _checkForTimeout() {
232
259
  if (this.lastUpdated.getTime() + PROJECT_TIMEOUT < Date.now()) {
233
260
  this._syncToServer().catch((error) => {
234
- this._logger.error(error);
261
+ // 404 errors are expected when project is still initializing and not yet available via REST API
262
+ // Only log non-404 errors to avoid confusing users
263
+ if (error.status !== 404) {
264
+ this._logger.error(error);
265
+ }
235
266
  this._failedSyncAttempts++;
236
267
  if (this._failedSyncAttempts >= MAX_FAILED_SYNC_ATTEMPTS) {
237
268
  this._logger.error(
@@ -298,11 +329,13 @@ class Project extends DataEntity<ProjectData, ProjectEventMap> {
298
329
  const delta: Partial<ProjectData> = {
299
330
  params: {
300
331
  ...this.data.params,
301
- numberOfImages: data.imageCount,
302
- steps: data.stepCount,
303
- numberOfPreviews: data.previewCount
332
+ numberOfMedia: data.imageCount,
333
+ steps: data.stepCount
304
334
  }
305
335
  };
336
+ if (delta.params && isImageParams(delta.params)) {
337
+ delta.params.numberOfPreviews = data.previewCount;
338
+ }
306
339
  if (PROJECT_STATUS_MAP[data.status]) {
307
340
  delta.status = PROJECT_STATUS_MAP[data.status];
308
341
  }