@sogni-ai/sogni-client 4.2.0-alpha.2 → 4.2.0-alpha.21

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 (109) hide show
  1. package/CHANGELOG.md +148 -0
  2. package/CLAUDE.md +25 -3
  3. package/README.md +411 -136
  4. package/dist/Account/index.d.ts +4 -2
  5. package/dist/Account/index.js +27 -23
  6. package/dist/Account/index.js.map +1 -1
  7. package/dist/Account/types.d.ts +7 -0
  8. package/dist/ApiClient/WebSocketClient/BrowserWebSocketClient/index.d.ts +3 -1
  9. package/dist/ApiClient/WebSocketClient/BrowserWebSocketClient/index.js +26 -2
  10. package/dist/ApiClient/WebSocketClient/BrowserWebSocketClient/index.js.map +1 -1
  11. package/dist/ApiClient/WebSocketClient/eventSubscriptions.d.ts +33 -0
  12. package/dist/ApiClient/WebSocketClient/eventSubscriptions.js +39 -0
  13. package/dist/ApiClient/WebSocketClient/eventSubscriptions.js.map +1 -0
  14. package/dist/ApiClient/WebSocketClient/events.d.ts +24 -7
  15. package/dist/ApiClient/WebSocketClient/index.d.ts +5 -1
  16. package/dist/ApiClient/WebSocketClient/index.js +24 -1
  17. package/dist/ApiClient/WebSocketClient/index.js.map +1 -1
  18. package/dist/ApiClient/WebSocketClient/messages.d.ts +2 -0
  19. package/dist/ApiClient/WebSocketClient/types.d.ts +2 -0
  20. package/dist/ApiClient/index.d.ts +6 -1
  21. package/dist/ApiClient/index.js +7 -3
  22. package/dist/ApiClient/index.js.map +1 -1
  23. package/dist/Chat/ChatTools.d.ts +5 -49
  24. package/dist/Chat/ChatTools.js +311 -88
  25. package/dist/Chat/ChatTools.js.map +1 -1
  26. package/dist/Chat/index.d.ts +11 -2
  27. package/dist/Chat/index.js +78 -4
  28. package/dist/Chat/index.js.map +1 -1
  29. package/dist/Chat/modelRouting.d.ts +100 -0
  30. package/dist/Chat/modelRouting.js +441 -0
  31. package/dist/Chat/modelRouting.js.map +1 -0
  32. package/dist/Chat/sogniHostedTools.generated.json +529 -0
  33. package/dist/Chat/tools.d.ts +9 -55
  34. package/dist/Chat/tools.js +72 -228
  35. package/dist/Chat/tools.js.map +1 -1
  36. package/dist/Chat/types.d.ts +91 -2
  37. package/dist/CreativeWorkflows/index.d.ts +23 -0
  38. package/dist/CreativeWorkflows/index.js +274 -0
  39. package/dist/CreativeWorkflows/index.js.map +1 -0
  40. package/dist/CreativeWorkflows/types.d.ts +106 -0
  41. package/dist/CreativeWorkflows/types.js +3 -0
  42. package/dist/CreativeWorkflows/types.js.map +1 -0
  43. package/dist/Projects/Job.d.ts +6 -0
  44. package/dist/Projects/Job.js +60 -5
  45. package/dist/Projects/Job.js.map +1 -1
  46. package/dist/Projects/Project.js +15 -3
  47. package/dist/Projects/Project.js.map +1 -1
  48. package/dist/Projects/createJobRequestMessage.js +140 -6
  49. package/dist/Projects/createJobRequestMessage.js.map +1 -1
  50. package/dist/Projects/index.d.ts +10 -1
  51. package/dist/Projects/index.js +197 -58
  52. package/dist/Projects/index.js.map +1 -1
  53. package/dist/Projects/types/ModelOptions.d.ts +3 -3
  54. package/dist/Projects/types/ModelOptions.js +12 -5
  55. package/dist/Projects/types/ModelOptions.js.map +1 -1
  56. package/dist/Projects/types/ModelTiersRaw.d.ts +7 -7
  57. package/dist/Projects/types/RawProject.d.ts +2 -0
  58. package/dist/Projects/types/events.d.ts +5 -4
  59. package/dist/Projects/types/index.d.ts +77 -7
  60. package/dist/Projects/types/index.js.map +1 -1
  61. package/dist/Projects/utils/index.d.ts +8 -1
  62. package/dist/Projects/utils/index.js +22 -8
  63. package/dist/Projects/utils/index.js.map +1 -1
  64. package/dist/index.d.ts +28 -3
  65. package/dist/index.js +19 -1
  66. package/dist/index.js.map +1 -1
  67. package/dist/lib/RestClient.d.ts +4 -1
  68. package/dist/lib/RestClient.js +17 -9
  69. package/dist/lib/RestClient.js.map +1 -1
  70. package/dist/lib/mediaValidation.d.ts +16 -0
  71. package/dist/lib/mediaValidation.js +280 -0
  72. package/dist/lib/mediaValidation.js.map +1 -0
  73. package/dist/lib/validation.d.ts +6 -1
  74. package/dist/lib/validation.js +28 -2
  75. package/dist/lib/validation.js.map +1 -1
  76. package/llms-full.txt +372 -133
  77. package/llms.txt +197 -86
  78. package/package.json +13 -4
  79. package/src/Account/index.ts +22 -2
  80. package/src/Account/types.ts +7 -0
  81. package/src/ApiClient/WebSocketClient/BrowserWebSocketClient/index.ts +47 -3
  82. package/src/ApiClient/WebSocketClient/eventSubscriptions.ts +92 -0
  83. package/src/ApiClient/WebSocketClient/events.ts +25 -7
  84. package/src/ApiClient/WebSocketClient/index.ts +33 -1
  85. package/src/ApiClient/WebSocketClient/messages.ts +2 -0
  86. package/src/ApiClient/WebSocketClient/types.ts +2 -0
  87. package/src/ApiClient/index.ts +32 -2
  88. package/src/Chat/ChatTools.ts +395 -95
  89. package/src/Chat/index.ts +149 -5
  90. package/src/Chat/modelRouting.ts +602 -0
  91. package/src/Chat/sogniHostedTools.generated.json +529 -0
  92. package/src/Chat/tools.ts +98 -245
  93. package/src/Chat/types.ts +100 -2
  94. package/src/CreativeWorkflows/index.ts +290 -0
  95. package/src/CreativeWorkflows/types.ts +134 -0
  96. package/src/Projects/Job.ts +76 -5
  97. package/src/Projects/Project.ts +13 -3
  98. package/src/Projects/createJobRequestMessage.ts +152 -13
  99. package/src/Projects/index.ts +230 -52
  100. package/src/Projects/types/ModelOptions.ts +15 -8
  101. package/src/Projects/types/ModelTiersRaw.ts +7 -7
  102. package/src/Projects/types/RawProject.ts +2 -0
  103. package/src/Projects/types/events.ts +5 -4
  104. package/src/Projects/types/index.ts +86 -6
  105. package/src/Projects/utils/index.ts +24 -8
  106. package/src/index.ts +93 -0
  107. package/src/lib/RestClient.ts +15 -5
  108. package/src/lib/mediaValidation.ts +367 -0
  109. package/src/lib/validation.ts +38 -2
@@ -0,0 +1,290 @@
1
+ import ApiGroup, { ApiConfig } from '../ApiGroup';
2
+ import { ApiError, ApiResponse } from '../ApiClient';
3
+ import {
4
+ CreativeWorkflowRecord,
5
+ CreativeWorkflowEvent,
6
+ CreativeWorkflowSseEvent,
7
+ ListCreativeWorkflowOptions,
8
+ StartCreativeWorkflowOptions,
9
+ StartCreativeWorkflowParams,
10
+ StartHostedToolSequenceWorkflowInput,
11
+ StartImageToVideoWorkflowInput,
12
+ StreamCreativeWorkflowEventsOptions
13
+ } from './types';
14
+
15
+ interface CreativeWorkflowEnvelope {
16
+ workflow?: CreativeWorkflowRecord;
17
+ workflows?: CreativeWorkflowRecord[];
18
+ events?: CreativeWorkflowEvent[];
19
+ cancelled?: boolean;
20
+ [key: string]: unknown;
21
+ }
22
+
23
+ const TERMINAL_STATUSES = new Set(['completed', 'failed', 'cancelled']);
24
+
25
+ function toQuery(params: Record<string, string | number | undefined>): string {
26
+ const query = new URLSearchParams();
27
+ for (const [key, value] of Object.entries(params)) {
28
+ if (value !== undefined) {
29
+ query.set(key, String(value));
30
+ }
31
+ }
32
+ const serialized = query.toString();
33
+ return serialized ? `?${serialized}` : '';
34
+ }
35
+
36
+ function isTerminalWorkflowStatus(value: unknown): boolean {
37
+ return typeof value === 'string' && TERMINAL_STATUSES.has(value);
38
+ }
39
+
40
+ function parseJsonResponse(text: string): unknown {
41
+ if (!text) return {};
42
+ try {
43
+ return JSON.parse(text);
44
+ } catch {
45
+ return { status: 'error', message: text, errorCode: 0 };
46
+ }
47
+ }
48
+
49
+ function parseEnvelope<T>(response: ApiResponse<CreativeWorkflowEnvelope>, key: string): T {
50
+ const data = response.data;
51
+ if (!data || typeof data !== 'object') {
52
+ throw new Error('Creative workflow response did not include data');
53
+ }
54
+ if (!(key in data)) {
55
+ throw new Error(`Creative workflow response did not include data.${key}`);
56
+ }
57
+ return data[key] as T;
58
+ }
59
+
60
+ export function parseCreativeWorkflowSseChunk(chunk: string): CreativeWorkflowSseEvent[] {
61
+ return chunk
62
+ .split(/\r?\n\r?\n/)
63
+ .map((block) => block.trim())
64
+ .filter(Boolean)
65
+ .map((block) => {
66
+ const frame: CreativeWorkflowSseEvent = {
67
+ event: 'message',
68
+ data: null,
69
+ raw: block
70
+ };
71
+ const dataLines: string[] = [];
72
+
73
+ for (const line of block.split(/\r?\n/)) {
74
+ if (!line || line.startsWith(':')) continue;
75
+ const separator = line.indexOf(':');
76
+ const field = separator === -1 ? line : line.slice(0, separator);
77
+ const value = separator === -1 ? '' : line.slice(separator + 1).replace(/^ /, '');
78
+
79
+ if (field === 'id') {
80
+ frame.id = value;
81
+ } else if (field === 'event') {
82
+ frame.event = value || 'message';
83
+ } else if (field === 'data') {
84
+ dataLines.push(value);
85
+ }
86
+ }
87
+
88
+ const data = dataLines.join('\n');
89
+ if (data) {
90
+ try {
91
+ frame.data = JSON.parse(data);
92
+ } catch {
93
+ frame.data = data;
94
+ }
95
+ }
96
+
97
+ return frame;
98
+ });
99
+ }
100
+
101
+ class CreativeWorkflowsApi extends ApiGroup {
102
+ constructor(config: ApiConfig) {
103
+ super(config);
104
+ }
105
+
106
+ async start(
107
+ params: StartCreativeWorkflowParams,
108
+ options: StartCreativeWorkflowOptions = {}
109
+ ): Promise<CreativeWorkflowRecord> {
110
+ const body: Record<string, unknown> = {
111
+ kind: params.kind,
112
+ input: params.input
113
+ };
114
+ const tokenType = params.token_type ?? params.tokenType;
115
+ if (tokenType) {
116
+ body.token_type = tokenType;
117
+ }
118
+
119
+ const response = await this.request<CreativeWorkflowEnvelope>('/v1/creative-agent/workflows', {
120
+ method: 'POST',
121
+ headers: {
122
+ 'Content-Type': 'application/json'
123
+ },
124
+ body: JSON.stringify(body),
125
+ signal: options.signal
126
+ });
127
+ return parseEnvelope<CreativeWorkflowRecord>(response, 'workflow');
128
+ }
129
+
130
+ startImageToVideo(
131
+ input: StartImageToVideoWorkflowInput,
132
+ options: StartCreativeWorkflowOptions & {
133
+ tokenType?: StartCreativeWorkflowParams['tokenType'];
134
+ } = {}
135
+ ): Promise<CreativeWorkflowRecord> {
136
+ return this.start(
137
+ {
138
+ kind: 'image_to_video',
139
+ input,
140
+ tokenType: options.tokenType
141
+ },
142
+ options
143
+ );
144
+ }
145
+
146
+ startHostedToolSequence(
147
+ input: StartHostedToolSequenceWorkflowInput,
148
+ options: StartCreativeWorkflowOptions & {
149
+ tokenType?: StartCreativeWorkflowParams['tokenType'];
150
+ } = {}
151
+ ): Promise<CreativeWorkflowRecord> {
152
+ return this.start(
153
+ {
154
+ kind: 'hosted_tool_sequence',
155
+ input,
156
+ tokenType: options.tokenType
157
+ },
158
+ options
159
+ );
160
+ }
161
+
162
+ async list(options: ListCreativeWorkflowOptions = {}): Promise<CreativeWorkflowRecord[]> {
163
+ const response = await this.request<CreativeWorkflowEnvelope>(
164
+ `/v1/creative-agent/workflows${toQuery({
165
+ limit: options.limit,
166
+ offset: options.offset
167
+ })}`
168
+ );
169
+ return parseEnvelope<CreativeWorkflowRecord[]>(response, 'workflows');
170
+ }
171
+
172
+ async get(workflowId: string): Promise<CreativeWorkflowRecord> {
173
+ const response = await this.request<CreativeWorkflowEnvelope>(
174
+ `/v1/creative-agent/workflows/${encodeURIComponent(workflowId)}`
175
+ );
176
+ return parseEnvelope<CreativeWorkflowRecord>(response, 'workflow');
177
+ }
178
+
179
+ async events(workflowId: string): Promise<CreativeWorkflowEvent[]> {
180
+ const response = await this.request<CreativeWorkflowEnvelope>(
181
+ `/v1/creative-agent/workflows/${encodeURIComponent(workflowId)}/events`
182
+ );
183
+ return parseEnvelope<CreativeWorkflowEvent[]>(response, 'events');
184
+ }
185
+
186
+ async cancel(workflowId: string): Promise<CreativeWorkflowRecord> {
187
+ const response = await this.request<CreativeWorkflowEnvelope>(
188
+ `/v1/creative-agent/workflows/${encodeURIComponent(workflowId)}/cancel`,
189
+ {
190
+ method: 'POST'
191
+ }
192
+ );
193
+ return parseEnvelope<CreativeWorkflowRecord>(response, 'workflow');
194
+ }
195
+
196
+ async *streamEvents(
197
+ workflowId: string,
198
+ options: StreamCreativeWorkflowEventsOptions = {}
199
+ ): AsyncIterableIterator<CreativeWorkflowSseEvent> {
200
+ const after = options.after ?? options.lastEventId;
201
+ const query = toQuery({ after });
202
+ const headers: Record<string, string> = {
203
+ Accept: 'text/event-stream'
204
+ };
205
+ if (options.lastEventId !== undefined) {
206
+ headers['Last-Event-ID'] = String(options.lastEventId);
207
+ }
208
+
209
+ const response = await this.fetch(
210
+ `/v1/creative-agent/workflows/${encodeURIComponent(workflowId)}/events/stream${query}`,
211
+ {
212
+ method: 'GET',
213
+ headers,
214
+ signal: options.signal
215
+ }
216
+ );
217
+
218
+ if (!response.ok) {
219
+ throw await this.toApiError(response);
220
+ }
221
+ if (!response.body) {
222
+ return;
223
+ }
224
+
225
+ const reader = response.body.getReader();
226
+ const decoder = new TextDecoder();
227
+ let buffer = '';
228
+
229
+ try {
230
+ while (true) {
231
+ const { done, value } = await reader.read();
232
+ if (done) break;
233
+
234
+ buffer += decoder.decode(value, { stream: true });
235
+ const parts = buffer.split(/\r?\n\r?\n/);
236
+ buffer = parts.pop() ?? '';
237
+
238
+ for (const frame of parseCreativeWorkflowSseChunk(parts.join('\n\n'))) {
239
+ yield frame;
240
+ const data = frame.data as { status?: unknown } | null;
241
+ if (data && isTerminalWorkflowStatus(data.status)) {
242
+ await reader.cancel();
243
+ return;
244
+ }
245
+ }
246
+ }
247
+
248
+ buffer += decoder.decode();
249
+ for (const frame of parseCreativeWorkflowSseChunk(buffer)) {
250
+ yield frame;
251
+ }
252
+ } finally {
253
+ reader.releaseLock();
254
+ }
255
+ }
256
+
257
+ private async request<T = CreativeWorkflowEnvelope>(
258
+ path: string,
259
+ options: RequestInit = {}
260
+ ): Promise<ApiResponse<T>> {
261
+ const response = await this.fetch(path, options);
262
+ if (!response.ok) {
263
+ throw await this.toApiError(response);
264
+ }
265
+ return (await response.json()) as ApiResponse<T>;
266
+ }
267
+
268
+ private async fetch(path: string, options: RequestInit = {}): Promise<Response> {
269
+ const url = new URL(path, this.client.rest.baseUrl).toString();
270
+ const authenticated = await this.client.auth.authenticateRequest(options);
271
+ return fetch(url, authenticated);
272
+ }
273
+
274
+ private async toApiError(response: Response): Promise<ApiError> {
275
+ if (response.status === 401 && this.client.auth.isAuthenticated) {
276
+ this.client.auth.clear();
277
+ }
278
+ const body = parseJsonResponse(await response.text()) as Record<string, unknown>;
279
+ const payload =
280
+ body.status === 'error' ? body : ((body.data as Record<string, unknown>) ?? body);
281
+ return new ApiError(response.status, {
282
+ status: 'error',
283
+ message: typeof payload.message === 'string' ? payload.message : response.statusText,
284
+ errorCode: typeof payload.errorCode === 'number' ? payload.errorCode : 0
285
+ });
286
+ }
287
+ }
288
+
289
+ export default CreativeWorkflowsApi;
290
+ export * from './types';
@@ -0,0 +1,134 @@
1
+ import { TokenType } from '../types/token';
2
+
3
+ export type CreativeWorkflowStatus =
4
+ | 'queued'
5
+ | 'running'
6
+ | 'completed'
7
+ | 'failed'
8
+ | 'cancelled'
9
+ | string;
10
+
11
+ export type CreativeWorkflowKind = 'image_to_video' | 'hosted_tool_sequence' | string;
12
+ export type CreativeWorkflowHostedToolName =
13
+ | 'sogni_generate_image'
14
+ | 'sogni_edit_image'
15
+ | 'sogni_generate_video'
16
+ | 'sogni_sound_to_video'
17
+ | 'sogni_video_to_video'
18
+ | 'sogni_generate_music';
19
+
20
+ export interface CreativeWorkflowArtifact {
21
+ id?: string;
22
+ url?: string;
23
+ type?: string;
24
+ mediaType?: string;
25
+ mimeType?: string;
26
+ width?: number;
27
+ height?: number;
28
+ duration?: number;
29
+ [key: string]: unknown;
30
+ }
31
+
32
+ export interface CreativeWorkflowEvent {
33
+ id?: string | number;
34
+ event?: string;
35
+ type?: string;
36
+ status?: CreativeWorkflowStatus;
37
+ message?: string;
38
+ timestamp?: number;
39
+ data?: unknown;
40
+ [key: string]: unknown;
41
+ }
42
+
43
+ export interface CreativeWorkflowRecord {
44
+ workflowId: string;
45
+ kind?: CreativeWorkflowKind;
46
+ title?: string;
47
+ status?: CreativeWorkflowStatus;
48
+ input?: Record<string, unknown>;
49
+ plan?: Record<string, unknown>;
50
+ steps?: unknown[];
51
+ events?: CreativeWorkflowEvent[];
52
+ artifacts?: CreativeWorkflowArtifact[];
53
+ billingPreview?: unknown;
54
+ billingPreviews?: unknown[];
55
+ createTime?: number;
56
+ updateTime?: number;
57
+ createdAt?: string;
58
+ updatedAt?: string;
59
+ error?: unknown;
60
+ [key: string]: unknown;
61
+ }
62
+
63
+ export interface StartImageToVideoWorkflowInput {
64
+ prompt: string;
65
+ videoPrompt?: string;
66
+ negativePrompt?: string;
67
+ width?: number;
68
+ height?: number;
69
+ duration?: number;
70
+ imageModel?: string;
71
+ videoModel?: string;
72
+ numberOfMedia?: number;
73
+ seed?: number;
74
+ [key: string]: unknown;
75
+ }
76
+
77
+ export interface StartHostedToolSequenceWorkflowDependency {
78
+ sourceStepId: string;
79
+ targetArgument: string;
80
+ transform: 'artifact_url' | 'artifact_data_uri';
81
+ sourceArtifactId?: string;
82
+ sourceArtifactIndex?: number;
83
+ mediaType?: 'image' | 'video' | 'audio';
84
+ required?: boolean;
85
+ }
86
+
87
+ export interface StartHostedToolSequenceWorkflowStep {
88
+ id?: string;
89
+ toolName: CreativeWorkflowHostedToolName;
90
+ arguments: Record<string, unknown>;
91
+ dependsOn?: StartHostedToolSequenceWorkflowDependency[];
92
+ }
93
+
94
+ export interface StartHostedToolSequenceWorkflowInput {
95
+ title?: string;
96
+ steps: StartHostedToolSequenceWorkflowStep[];
97
+ [key: string]: unknown;
98
+ }
99
+
100
+ export type StartCreativeWorkflowParams =
101
+ | {
102
+ kind: 'image_to_video';
103
+ input: StartImageToVideoWorkflowInput;
104
+ tokenType?: TokenType;
105
+ token_type?: TokenType;
106
+ }
107
+ | {
108
+ kind: 'hosted_tool_sequence';
109
+ input: StartHostedToolSequenceWorkflowInput;
110
+ tokenType?: TokenType;
111
+ token_type?: TokenType;
112
+ };
113
+
114
+ export interface StartCreativeWorkflowOptions {
115
+ signal?: AbortSignal;
116
+ }
117
+
118
+ export interface ListCreativeWorkflowOptions {
119
+ limit?: number;
120
+ offset?: number;
121
+ }
122
+
123
+ export interface StreamCreativeWorkflowEventsOptions {
124
+ after?: string | number;
125
+ lastEventId?: string | number;
126
+ signal?: AbortSignal;
127
+ }
128
+
129
+ export interface CreativeWorkflowSseEvent {
130
+ id?: string;
131
+ event: string;
132
+ data: unknown;
133
+ raw: string;
134
+ }
@@ -43,6 +43,44 @@ const JOB_STATUS_MAP: Record<RawJob['status'], JobStatus> = {
43
43
  jobError: 'failed'
44
44
  };
45
45
 
46
+ function clampProgress(value: number): number {
47
+ return Math.max(0, Math.min(100, Math.round(value)));
48
+ }
49
+
50
+ function normalizeProgressPercent(value: unknown): number | undefined {
51
+ if (typeof value !== 'number' || !Number.isFinite(value)) return undefined;
52
+ return clampProgress(value >= 0 && value <= 1 ? value * 100 : value);
53
+ }
54
+
55
+ function directResultUrlFromRawJob(rawJob: RawJob): string | null {
56
+ const legacy = rawJob as RawJob & {
57
+ imageUrl?: string | null;
58
+ imageFile?: string | null;
59
+ videoUrl?: string | null;
60
+ videoFile?: string | null;
61
+ };
62
+ return (
63
+ rawJob.resultUrl ||
64
+ legacy.imageUrl ||
65
+ legacy.imageFile ||
66
+ legacy.videoUrl ||
67
+ legacy.videoFile ||
68
+ null
69
+ );
70
+ }
71
+
72
+ function etaProgressPercent(
73
+ startedAt: Date | undefined,
74
+ eta: Date | undefined
75
+ ): number | undefined {
76
+ if (!startedAt || !eta) return undefined;
77
+ const totalMs = eta.getTime() - startedAt.getTime();
78
+ if (!Number.isFinite(totalMs) || totalMs <= 0) return undefined;
79
+ const elapsedMs = Date.now() - startedAt.getTime();
80
+ if (!Number.isFinite(elapsedMs) || elapsedMs <= 0) return 1;
81
+ return Math.max(1, Math.min(95, Math.round((elapsedMs / totalMs) * 100)));
82
+ }
83
+
46
84
  /**
47
85
  * @inline
48
86
  */
@@ -62,6 +100,11 @@ export interface JobData {
62
100
  positivePrompt?: string;
63
101
  negativePrompt?: string;
64
102
  jobIndex?: number;
103
+ /**
104
+ * Direct progress percentage from external API-backed workers. Values may be
105
+ * 0-1 or 0-100 depending on the upstream provider event.
106
+ */
107
+ externalProgress?: number;
65
108
  /**
66
109
  * Estimated time remaining in seconds (for long-running jobs like video generation).
67
110
  * Updated by ComfyUI workers during inference.
@@ -73,6 +116,7 @@ export interface JobData {
73
116
  * Updated by ComfyUI workers during inference.
74
117
  */
75
118
  eta?: Date;
119
+ etaStartedAt?: Date;
76
120
  }
77
121
 
78
122
  export interface JobEventMap extends EntityEvents {
@@ -98,7 +142,8 @@ class Job extends DataEntity<JobData, JobEventMap> {
98
142
  stepCount: rawProject.stepCount,
99
143
  workerName: rawJob.worker.name,
100
144
  seed: rawJob.seedUsed,
101
- isNSFW: rawJob.triggeredNSFWFilter
145
+ isNSFW: rawJob.triggeredNSFWFilter,
146
+ resultUrl: directResultUrlFromRawJob(rawJob)
102
147
  },
103
148
  options
104
149
  );
@@ -143,7 +188,13 @@ class Job extends DataEntity<JobData, JobEventMap> {
143
188
  * Progress of the job in percentage (0-100).
144
189
  */
145
190
  get progress() {
146
- return Math.round((this.data.step / this.data.stepCount) * 100);
191
+ if (this.status === 'completed') return 100;
192
+ const externalProgress = normalizeProgressPercent(this.data.externalProgress);
193
+ if (externalProgress !== undefined) return externalProgress;
194
+ if (this.data.stepCount > 0) {
195
+ return clampProgress((this.data.step / this.data.stepCount) * 100);
196
+ }
197
+ return etaProgressPercent(this.data.etaStartedAt, this.data.eta) ?? 0;
147
198
  }
148
199
 
149
200
  /**
@@ -261,6 +312,9 @@ class Job extends DataEntity<JobData, JobEventMap> {
261
312
  * For video jobs, this returns a video URL. For image jobs, this returns an image URL.
262
313
  */
263
314
  async getResultUrl(): Promise<string> {
315
+ if (this.data.resultUrl) {
316
+ return this.data.resultUrl;
317
+ }
264
318
  if (this.data.status !== 'completed') {
265
319
  throw new Error('Job is not completed yet');
266
320
  }
@@ -326,16 +380,25 @@ class Job extends DataEntity<JobData, JobEventMap> {
326
380
  * @param data
327
381
  */
328
382
  async _syncWithRestData(data: RawJob) {
383
+ const directResultUrl = directResultUrlFromRawJob(data);
329
384
  const delta: Partial<JobData> = {
330
385
  step: data.performedSteps,
331
- workerName: data.worker.name,
386
+ workerName: data.worker?.name,
332
387
  seed: data.seedUsed,
333
388
  isNSFW: data.triggeredNSFWFilter
334
389
  };
335
390
  if (JOB_STATUS_MAP[data.status]) {
336
391
  delta.status = JOB_STATUS_MAP[data.status];
337
392
  }
338
- if (!this.data.resultUrl && delta.status === 'completed' && !data.triggeredNSFWFilter) {
393
+ if (!this.data.resultUrl && directResultUrl) {
394
+ delta.resultUrl = directResultUrl;
395
+ }
396
+ if (
397
+ !this.data.resultUrl &&
398
+ !delta.resultUrl &&
399
+ delta.status === 'completed' &&
400
+ !data.triggeredNSFWFilter
401
+ ) {
339
402
  try {
340
403
  if (this.type === 'video' || this.type === 'audio') {
341
404
  delta.resultUrl = await this._api.mediaDownloadUrl({
@@ -369,13 +432,21 @@ class Job extends DataEntity<JobData, JobEventMap> {
369
432
  // Keeping etaSeconds for backwards compatibility
370
433
  if (delta.eta) {
371
434
  delta.etaSeconds = Math.round((delta.eta.getTime() - Date.now()) / 1000);
435
+ if (!this.data.etaStartedAt && !delta.etaStartedAt) {
436
+ delta.etaStartedAt = new Date();
437
+ }
372
438
  }
373
439
  }
374
440
  super._update(delta);
375
441
  }
376
442
 
377
443
  private handleUpdated(keys: string[]) {
378
- if (keys.includes('step') || keys.includes('stepCount')) {
444
+ if (
445
+ keys.includes('step') ||
446
+ keys.includes('stepCount') ||
447
+ keys.includes('externalProgress') ||
448
+ keys.includes('eta')
449
+ ) {
379
450
  this.emit('progress', this.progress);
380
451
  }
381
452
  if (keys.includes('status') && this.status === 'completed') {
@@ -126,11 +126,17 @@ class Project extends DataEntity<ProjectData, ProjectEventMap> {
126
126
  * Progress of the project in percentage (0-100).
127
127
  */
128
128
  get progress() {
129
+ if (this.status === 'completed') return 100;
129
130
  // Worker can reduce the number of steps in the job, so we need to calculate the progress based on the actual number of steps
130
- const stepsPerJob = this.jobs.length ? this.jobs[0].stepCount : (this.data.params.steps ?? 0);
131
- const jobCount = this.data.params.numberOfMedia;
131
+ const jobCount = Math.max(1, this.data.params.numberOfMedia);
132
+ if (this._jobs.length) {
133
+ const progressTotal = this._jobs.reduce((acc, job) => acc + job.progress, 0);
134
+ return Math.max(0, Math.min(100, Math.round(progressTotal / jobCount)));
135
+ }
136
+ const stepsPerJob = this.data.params.steps ?? 0;
137
+ if (stepsPerJob <= 0) return 0;
132
138
  const stepsDone = this._jobs.reduce((acc, job) => acc + job.step, 0);
133
- return Math.round((stepsDone / ((stepsPerJob ?? 1) * jobCount)) * 100);
139
+ return Math.max(0, Math.min(100, Math.round((stepsDone / (stepsPerJob * jobCount)) * 100)));
134
140
  }
135
141
 
136
142
  get queuePosition() {
@@ -268,6 +274,10 @@ class Project extends DataEntity<ProjectData, ProjectEventMap> {
268
274
  this._logger.error(
269
275
  `Failed to sync project data after ${MAX_FAILED_SYNC_ATTEMPTS} attempts. Stopping further attempts.`
270
276
  );
277
+ this._api._notifyProjectTimedOut(this.id).catch((cancelError) => {
278
+ this._logger.error(`Failed to notify socket server that project ${this.id} timed out`);
279
+ this._logger.error(cancelError);
280
+ });
271
281
  clearInterval(this._timeout!);
272
282
  this._timeout = null;
273
283
  this.jobs.forEach((job) => {