@sequent-org/moodboard 1.4.45 → 1.4.48
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/package.json +1 -1
- package/src/core/events/Events.js +1 -0
- package/src/core/flows/ClipboardFlow.js +29 -1
- package/src/core/flows/Model3dFlow.js +15 -0
- package/src/core/index.js +4 -2
- package/src/moodboard/bootstrap/MoodBoardUiFactory.js +2 -0
- package/src/objects/Model3dScreenshotImageObject.js +8 -0
- package/src/objects/ObjectFactory.js +2 -0
- package/src/services/ai/AiClient.js +181 -2
- package/src/services/ai/Model3dSessionController.js +238 -0
- package/src/services/ai/VideoSessionController.js +175 -0
- package/src/services/ai/imageModelCapabilities.js +130 -0
- package/src/services/ai/videoModelCapabilities.js +120 -0
- package/src/ui/ImagePropertiesPanel.js +8 -0
- package/src/ui/chat/ChatComposer.js +50 -3
- package/src/ui/chat/ChatSettingsPopup.js +210 -44
- package/src/ui/chat/ChatVideoToolbarPills.js +284 -0
- package/src/ui/chat/ChatWindow.js +919 -92
- package/src/ui/chat/ChatWindowRenderer.js +29 -1
- package/src/ui/chat/Model3dBoardSkeleton.js +93 -0
- package/src/ui/chat/Model3dProgressOverlay.js +114 -0
- package/src/ui/chat/icons.js +27 -6
- package/src/ui/handles/HandlesDomRenderer.js +30 -0
- package/src/ui/styles/chat.css +235 -0
- package/src/ui/styles/panels.css +264 -0
package/package.json
CHANGED
|
@@ -70,6 +70,7 @@ export const Events = {
|
|
|
70
70
|
ZoomSelection: 'ui:zoom:selection',
|
|
71
71
|
ZoomPercent: 'ui:zoom:percent',
|
|
72
72
|
RevitShowInModel: 'ui:revit:show-in-model',
|
|
73
|
+
Model3dShowInViewer: 'ui:model3d:show-in-viewer',
|
|
73
74
|
MinimapGetData: 'ui:minimap:get-data',
|
|
74
75
|
MinimapCenterOn: 'ui:minimap:center-on',
|
|
75
76
|
TextEditStart: 'ui:text:edit:start',
|
|
@@ -286,8 +286,36 @@ export function setupClipboardFlow(core) {
|
|
|
286
286
|
}
|
|
287
287
|
});
|
|
288
288
|
|
|
289
|
-
core.eventBus.on(Events.UI.PasteImageAt, async ({ x, y, src, name, skipUpload, aiMessageId }) => {
|
|
289
|
+
core.eventBus.on(Events.UI.PasteImageAt, async ({ x, y, src, name, skipUpload, aiMessageId, objectType, modelUrl, format }) => {
|
|
290
290
|
if (!src) return;
|
|
291
|
+
|
|
292
|
+
// Отдельный путь для 3D-скрина: без resolveRevitImagePayload и без загрузки на сервер
|
|
293
|
+
if (objectType === 'model3d-screenshot-img') {
|
|
294
|
+
const world = core.pixi.worldLayer || core.pixi.app.stage;
|
|
295
|
+
const s = world?.scale?.x || 1;
|
|
296
|
+
const worldX = (x - (world?.x || 0)) / s;
|
|
297
|
+
const worldY = (y - (world?.y || 0)) / s;
|
|
298
|
+
const placeModel3d = (natW, natH) => {
|
|
299
|
+
const ar = (natW > 0 && natH > 0) ? natW / natH : 1;
|
|
300
|
+
const w = 300;
|
|
301
|
+
const h = Math.max(1, Math.round(w / ar));
|
|
302
|
+
const created = core.createObject(
|
|
303
|
+
'model3d-screenshot-img',
|
|
304
|
+
{ x: Math.round(worldX - Math.round(w / 2)), y: Math.round(worldY - Math.round(h / 2)) },
|
|
305
|
+
{ src, name: name || 'ai-3d-model', width: w, height: h, modelUrl, format }
|
|
306
|
+
);
|
|
307
|
+
if (skipUpload && created?.id) {
|
|
308
|
+
core._pendingPersistAckVisibilityIds?.delete(created.id);
|
|
309
|
+
core._setObjectVisibility?.(created.id, true);
|
|
310
|
+
}
|
|
311
|
+
};
|
|
312
|
+
const img = new Image();
|
|
313
|
+
img.onload = () => placeModel3d(img.naturalWidth, img.naturalHeight);
|
|
314
|
+
img.onerror = () => placeModel3d(0, 0);
|
|
315
|
+
img.src = src;
|
|
316
|
+
return;
|
|
317
|
+
}
|
|
318
|
+
|
|
291
319
|
const uploaded = await ensureServerImage({ src, name, skipUpload });
|
|
292
320
|
if (!uploaded?.src) return;
|
|
293
321
|
const world = core.pixi.worldLayer || core.pixi.app.stage;
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { Events } from '../events/Events.js';
|
|
2
|
+
|
|
3
|
+
export function setupModel3dFlow(core) {
|
|
4
|
+
core.eventBus.on(Events.UI.Model3dShowInViewer, ({ objectId, modelUrl, format }) => {
|
|
5
|
+
if (!modelUrl || typeof modelUrl !== 'string') {
|
|
6
|
+
return;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
if (typeof core.options?.onShowModel3dInViewer === 'function') {
|
|
10
|
+
core.options.onShowModel3dInViewer({ objectId, modelUrl, format });
|
|
11
|
+
} else {
|
|
12
|
+
console.warn('[Model3dFlow] onShowModel3dInViewer не задан в options. objectId:', objectId, 'modelUrl:', modelUrl);
|
|
13
|
+
}
|
|
14
|
+
});
|
|
15
|
+
}
|
package/src/core/index.js
CHANGED
|
@@ -17,6 +17,7 @@ import { setupClipboardFlow, setupClipboardKeyboardFlow } from './flows/Clipboar
|
|
|
17
17
|
import { setupObjectLifecycleFlow } from './flows/ObjectLifecycleFlow.js';
|
|
18
18
|
import { setupLayerAndViewportFlow } from './flows/LayerAndViewportFlow.js';
|
|
19
19
|
import { setupRevitFlow } from './flows/RevitFlow.js';
|
|
20
|
+
import { setupModel3dFlow } from './flows/Model3dFlow.js';
|
|
20
21
|
import { setupSaveFlow } from './flows/SaveFlow.js';
|
|
21
22
|
import {
|
|
22
23
|
logMindmapCompoundDebug,
|
|
@@ -133,6 +134,7 @@ export class CoreMoodBoard {
|
|
|
133
134
|
setupTransformFlow(this);
|
|
134
135
|
setupObjectLifecycleFlow(this);
|
|
135
136
|
setupRevitFlow(this);
|
|
137
|
+
setupModel3dFlow(this);
|
|
136
138
|
}
|
|
137
139
|
|
|
138
140
|
/**
|
|
@@ -432,7 +434,7 @@ export class CoreMoodBoard {
|
|
|
432
434
|
},
|
|
433
435
|
...extraData
|
|
434
436
|
};
|
|
435
|
-
if (type === 'image' || type === 'revit-screenshot-img' || type === 'file') {
|
|
437
|
+
if (type === 'image' || type === 'revit-screenshot-img' || type === 'model3d-screenshot-img' || type === 'file') {
|
|
436
438
|
const propSrc = typeof properties?.src === 'string' ? properties.src.trim() : '';
|
|
437
439
|
const propUrl = typeof properties?.url === 'string' ? properties.url.trim() : '';
|
|
438
440
|
const normalizedSrc = propSrc || propUrl;
|
|
@@ -557,7 +559,7 @@ export class CoreMoodBoard {
|
|
|
557
559
|
properties: objectData.properties || {},
|
|
558
560
|
existingObjects: this.state?.state?.objects || [],
|
|
559
561
|
});
|
|
560
|
-
if (objectData.type === 'image' || objectData.type === 'revit-screenshot-img' || objectData.type === 'file') {
|
|
562
|
+
if (objectData.type === 'image' || objectData.type === 'revit-screenshot-img' || objectData.type === 'model3d-screenshot-img' || objectData.type === 'file') {
|
|
561
563
|
const topSrc = typeof objectData.src === 'string' ? objectData.src.trim() : '';
|
|
562
564
|
const propSrc = typeof objectData.properties?.src === 'string' ? objectData.properties.src.trim() : '';
|
|
563
565
|
const topUrl = typeof objectData.url === 'string' ? objectData.url.trim() : '';
|
|
@@ -18,6 +18,7 @@ import { TextPropertiesPanel } from '../../ui/TextPropertiesPanel.js';
|
|
|
18
18
|
import { FramePropertiesPanel } from '../../ui/FramePropertiesPanel.js';
|
|
19
19
|
import { NotePropertiesPanel } from '../../ui/NotePropertiesPanel.js';
|
|
20
20
|
import { FilePropertiesPanel } from '../../ui/FilePropertiesPanel.js';
|
|
21
|
+
import { ImagePropertiesPanel } from '../../ui/ImagePropertiesPanel.js';
|
|
21
22
|
import { ConnectorPropertiesPanel } from '../../ui/ConnectorPropertiesPanel.js';
|
|
22
23
|
import { ShapePropertiesPanel } from '../../ui/ShapePropertiesPanel.js';
|
|
23
24
|
import { DrawingPropertiesPanel } from '../../ui/DrawingPropertiesPanel.js';
|
|
@@ -151,6 +152,7 @@ function initHtmlLayersAndPanels(board) {
|
|
|
151
152
|
board.framePropertiesPanel = new FramePropertiesPanel(board.coreMoodboard.eventBus, board.canvasContainer, board.coreMoodboard);
|
|
152
153
|
board.notePropertiesPanel = new NotePropertiesPanel(board.coreMoodboard.eventBus, board.canvasContainer, board.coreMoodboard);
|
|
153
154
|
board.filePropertiesPanel = new FilePropertiesPanel(board.coreMoodboard.eventBus, board.canvasContainer, board.coreMoodboard);
|
|
155
|
+
board.imagePropertiesPanel = new ImagePropertiesPanel(board.coreMoodboard.eventBus, board.canvasContainer, board.coreMoodboard);
|
|
154
156
|
board.connectorPropertiesPanel = new ConnectorPropertiesPanel(board.coreMoodboard.eventBus, board.canvasContainer, board.coreMoodboard);
|
|
155
157
|
board.shapePropertiesPanel = new ShapePropertiesPanel(board.coreMoodboard.eventBus, board.canvasContainer, board.coreMoodboard);
|
|
156
158
|
board.drawingPropertiesPanel = new DrawingPropertiesPanel(board.coreMoodboard.eventBus, board.canvasContainer, board.coreMoodboard);
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import { ImageObject } from './ImageObject.js';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Model3dScreenshotImageObject
|
|
5
|
+
* Специализированный тип для превью 3D-моделей.
|
|
6
|
+
* Визуально и по трансформациям полностью повторяет ImageObject.
|
|
7
|
+
*/
|
|
8
|
+
export class Model3dScreenshotImageObject extends ImageObject {}
|
|
@@ -5,6 +5,7 @@ import { TextObject } from './TextObject.js';
|
|
|
5
5
|
import { EmojiObject } from './EmojiObject.js';
|
|
6
6
|
import { ImageObject } from './ImageObject.js';
|
|
7
7
|
import { RevitScreenshotImageObject } from './RevitScreenshotImageObject.js';
|
|
8
|
+
import { Model3dScreenshotImageObject } from './Model3dScreenshotImageObject.js';
|
|
8
9
|
import { CommentObject } from './CommentObject.js';
|
|
9
10
|
import { NoteObject } from './NoteObject.js';
|
|
10
11
|
import { FileObject } from './FileObject.js';
|
|
@@ -25,6 +26,7 @@ export class ObjectFactory {
|
|
|
25
26
|
['emoji', EmojiObject],
|
|
26
27
|
['image', ImageObject],
|
|
27
28
|
['revit-screenshot-img', RevitScreenshotImageObject],
|
|
29
|
+
['model3d-screenshot-img', Model3dScreenshotImageObject],
|
|
28
30
|
['comment', CommentObject],
|
|
29
31
|
['note', NoteObject],
|
|
30
32
|
['file', FileObject],
|
|
@@ -141,6 +141,175 @@ export class AiClient {
|
|
|
141
141
|
}
|
|
142
142
|
return res.json();
|
|
143
143
|
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* Отправляет джоб генерации 3D-модели.
|
|
147
|
+
* @param {object} args
|
|
148
|
+
* @param {string} [args.provider='hunyuan-3d']
|
|
149
|
+
* @param {string} [args.mode='image'] 'text'|'image'|'multi'
|
|
150
|
+
* @param {string} [args.prompt]
|
|
151
|
+
* @param {File} [args.image]
|
|
152
|
+
* @param {Array<{file: File, viewType: string}>} [args.multiViewImages]
|
|
153
|
+
* @param {string} [args.model='3.1']
|
|
154
|
+
* @param {string} [args.generateType]
|
|
155
|
+
* @param {number} [args.faceCount]
|
|
156
|
+
* @param {boolean} [args.pbr]
|
|
157
|
+
* @param {string} [args.downloadFormat]
|
|
158
|
+
* @param {AbortSignal} [args.signal]
|
|
159
|
+
* @returns {Promise<{jobId: string}>}
|
|
160
|
+
*/
|
|
161
|
+
async submit3dModel({ provider = 'hunyuan-3d', mode = 'image', prompt, image, multiViewImages, model = '3.1', generateType, faceCount, pbr, downloadFormat, signal }) {
|
|
162
|
+
const body = { mode, model, downloadFormat };
|
|
163
|
+
if (generateType !== undefined) body.generateType = generateType;
|
|
164
|
+
if (faceCount !== undefined) body.faceCount = faceCount;
|
|
165
|
+
if (pbr !== undefined) body.pbr = pbr;
|
|
166
|
+
|
|
167
|
+
if (mode === 'text') {
|
|
168
|
+
body.prompt = prompt;
|
|
169
|
+
} else if (mode === 'multi') {
|
|
170
|
+
if (Array.isArray(multiViewImages) && multiViewImages.length) {
|
|
171
|
+
body.multiViewImages = await Promise.all(
|
|
172
|
+
multiViewImages.map(async ({ file, viewType }) => {
|
|
173
|
+
const [encoded] = await filesToBase64([file]);
|
|
174
|
+
return { ...encoded, viewType };
|
|
175
|
+
})
|
|
176
|
+
);
|
|
177
|
+
}
|
|
178
|
+
} else {
|
|
179
|
+
if (image) {
|
|
180
|
+
const [encoded] = await filesToBase64([image]);
|
|
181
|
+
body.image = encoded;
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
const res = await this._fetch(`${this._baseUrl}/${provider}/model3d`, {
|
|
186
|
+
method: 'POST',
|
|
187
|
+
headers: { 'Content-Type': 'application/json', 'Accept': 'application/json' },
|
|
188
|
+
body: JSON.stringify(body),
|
|
189
|
+
signal
|
|
190
|
+
});
|
|
191
|
+
if (!res.ok) {
|
|
192
|
+
const detail = await safeReadError(res);
|
|
193
|
+
throw new Error(`AiClient.submit3dModel (${res.status}): ${detail}`);
|
|
194
|
+
}
|
|
195
|
+
return res.json();
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/**
|
|
199
|
+
* Опрашивает статус джоба 3D-модели.
|
|
200
|
+
* @param {string} jobId
|
|
201
|
+
* @param {AbortSignal} [signal]
|
|
202
|
+
* @param {string} [provider='hunyuan-3d']
|
|
203
|
+
* @param {string} [format]
|
|
204
|
+
* @returns {Promise<object>}
|
|
205
|
+
*/
|
|
206
|
+
async poll3dModel(jobId, signal, provider = 'hunyuan-3d', format) {
|
|
207
|
+
const url = `${this._baseUrl}/${provider}/model3d/${jobId}${format ? `?format=${format}` : ''}`;
|
|
208
|
+
const res = await this._fetch(url, {
|
|
209
|
+
method: 'GET',
|
|
210
|
+
headers: { 'Accept': 'application/json' },
|
|
211
|
+
signal
|
|
212
|
+
});
|
|
213
|
+
if (!res.ok) {
|
|
214
|
+
const detail = await safeReadError(res);
|
|
215
|
+
throw new Error(`AiClient.poll3dModel (${res.status}): ${detail}`);
|
|
216
|
+
}
|
|
217
|
+
return res.json();
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/**
|
|
221
|
+
* Отправляет джоб генерации видео.
|
|
222
|
+
* @param {object} args
|
|
223
|
+
* @param {string} [args.provider='gemini-video']
|
|
224
|
+
* @param {string} args.prompt
|
|
225
|
+
* @param {string} [args.negativePrompt]
|
|
226
|
+
* @param {string} [args.model]
|
|
227
|
+
* @param {string} [args.ratio]
|
|
228
|
+
* @param {string} [args.resolution]
|
|
229
|
+
* @param {number} [args.duration]
|
|
230
|
+
* @param {number} [args.seed]
|
|
231
|
+
* @param {File[]} [args.referenceImages]
|
|
232
|
+
* @param {AbortSignal} [args.signal]
|
|
233
|
+
* @returns {Promise<{jobId: string}>}
|
|
234
|
+
*/
|
|
235
|
+
async submitVideo({ provider = 'gemini-video', signal, referenceImages: files, ...payload }) {
|
|
236
|
+
const referenceImages = await filesToBase64(files);
|
|
237
|
+
const body = referenceImages ? { ...payload, referenceImages } : payload;
|
|
238
|
+
const res = await this._fetch(`${this._baseUrl}/${provider}/video`, {
|
|
239
|
+
method: 'POST',
|
|
240
|
+
headers: { 'Content-Type': 'application/json', 'Accept': 'application/json' },
|
|
241
|
+
body: JSON.stringify(body),
|
|
242
|
+
signal
|
|
243
|
+
});
|
|
244
|
+
if (!res.ok) {
|
|
245
|
+
const detail = await safeReadError(res);
|
|
246
|
+
throw new Error(`AiClient.submitVideo (${res.status}): ${detail}`);
|
|
247
|
+
}
|
|
248
|
+
return res.json();
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
/**
|
|
252
|
+
* Опрашивает статус джоба генерации видео.
|
|
253
|
+
* @param {string} jobId
|
|
254
|
+
* @param {AbortSignal} [signal]
|
|
255
|
+
* @param {string} [provider='gemini-video']
|
|
256
|
+
* @returns {Promise<object>}
|
|
257
|
+
*/
|
|
258
|
+
async pollVideo(jobId, signal, provider = 'gemini-video') {
|
|
259
|
+
const res = await this._fetch(`${this._baseUrl}/${provider}/video/${jobId}`, {
|
|
260
|
+
method: 'GET',
|
|
261
|
+
headers: { 'Accept': 'application/json' },
|
|
262
|
+
signal
|
|
263
|
+
});
|
|
264
|
+
if (!res.ok) {
|
|
265
|
+
const detail = await safeReadError(res);
|
|
266
|
+
throw new Error(`AiClient.pollVideo (${res.status}): ${detail}`);
|
|
267
|
+
}
|
|
268
|
+
return res.json();
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
/**
|
|
272
|
+
* Отправляет джоб конвертации GLB -> FBX/STL.
|
|
273
|
+
* @param {object} args
|
|
274
|
+
* @param {string} args.glbUrl
|
|
275
|
+
* @param {string} args.format 'fbx'|'stl'
|
|
276
|
+
* @param {AbortSignal} [args.signal]
|
|
277
|
+
* @returns {Promise<{jobId: string}>}
|
|
278
|
+
*/
|
|
279
|
+
async submitConvert3d({ glbUrl, format, signal }) {
|
|
280
|
+
const res = await this._fetch(`${this._baseUrl}/convert3d`, {
|
|
281
|
+
method: 'POST',
|
|
282
|
+
headers: { 'Content-Type': 'application/json', 'Accept': 'application/json' },
|
|
283
|
+
body: JSON.stringify({ glbUrl, format }),
|
|
284
|
+
signal
|
|
285
|
+
});
|
|
286
|
+
if (!res.ok) {
|
|
287
|
+
const detail = await safeReadError(res);
|
|
288
|
+
throw new Error(`AiClient.submitConvert3d (${res.status}): ${detail}`);
|
|
289
|
+
}
|
|
290
|
+
return res.json();
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
/**
|
|
294
|
+
* Опрашивает статус джоба конвертации.
|
|
295
|
+
* @param {string} jobId
|
|
296
|
+
* @param {AbortSignal} [signal]
|
|
297
|
+
* @param {string} [format]
|
|
298
|
+
* @returns {Promise<object>}
|
|
299
|
+
*/
|
|
300
|
+
async pollConvert3d(jobId, signal, format) {
|
|
301
|
+
const url = `${this._baseUrl}/convert3d/${jobId}${format ? `?format=${format}` : ''}`;
|
|
302
|
+
const res = await this._fetch(url, {
|
|
303
|
+
method: 'GET',
|
|
304
|
+
headers: { 'Accept': 'application/json' },
|
|
305
|
+
signal
|
|
306
|
+
});
|
|
307
|
+
if (!res.ok) {
|
|
308
|
+
const detail = await safeReadError(res);
|
|
309
|
+
throw new Error(`AiClient.pollConvert3d (${res.status}): ${detail}`);
|
|
310
|
+
}
|
|
311
|
+
return res.json();
|
|
312
|
+
}
|
|
144
313
|
}
|
|
145
314
|
|
|
146
315
|
/**
|
|
@@ -221,12 +390,22 @@ function safeJson(text) {
|
|
|
221
390
|
}
|
|
222
391
|
|
|
223
392
|
async function safeReadError(res) {
|
|
393
|
+
const fallback = res.statusText || `HTTP ${res.status}`;
|
|
224
394
|
try {
|
|
225
395
|
const text = await res.text();
|
|
226
396
|
const json = safeJson(text);
|
|
227
|
-
|
|
397
|
+
if (json?.error) {
|
|
398
|
+
return typeof json.error === 'string' ? json.error : fallback;
|
|
399
|
+
}
|
|
400
|
+
// Не отдаём в UI сырой HTML/стектрейс (Laravel debug-страница) или
|
|
401
|
+
// слишком длинное тело — показываем лаконичный статус вместо «простыни».
|
|
402
|
+
const trimmed = (text || '').trim();
|
|
403
|
+
if (!trimmed || trimmed.startsWith('<') || trimmed.length > 200) {
|
|
404
|
+
return fallback;
|
|
405
|
+
}
|
|
406
|
+
return trimmed;
|
|
228
407
|
} catch {
|
|
229
|
-
return
|
|
408
|
+
return fallback;
|
|
230
409
|
}
|
|
231
410
|
}
|
|
232
411
|
|
|
@@ -0,0 +1,238 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Контроллер сессии генерации 3D-модели (Hunyuan 3D).
|
|
3
|
+
*
|
|
4
|
+
* Одна ответственность: оркестрирует submit -> poll-цикл через AiClient,
|
|
5
|
+
* держит состояние джоба и уведомляет подписчиков об изменениях.
|
|
6
|
+
* Не знает про DOM, доску и UI.
|
|
7
|
+
*
|
|
8
|
+
* Состояние:
|
|
9
|
+
* - status: 'idle' | 'submitting' | 'polling' | 'done' | 'error'
|
|
10
|
+
* - progress: number (0–100)
|
|
11
|
+
* - stage: 'geometry' | 'texture' | 'convert' | null
|
|
12
|
+
* - error: string | null
|
|
13
|
+
* - jobId: string | null
|
|
14
|
+
* - result: { previewBase64, mimeType, modelUrl, format } | null
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
const POLL_INTERVAL_MS = 2500;
|
|
18
|
+
const JOB_TIMEOUT_MS = 600_000; // 600с: покрывает оба джоба (генерация + конвертация)
|
|
19
|
+
|
|
20
|
+
export class Model3dSessionController {
|
|
21
|
+
/**
|
|
22
|
+
* @param {object} deps
|
|
23
|
+
* @param {import('./AiClient.js').AiClient} deps.aiClient
|
|
24
|
+
*/
|
|
25
|
+
constructor({ aiClient }) {
|
|
26
|
+
this._client = aiClient;
|
|
27
|
+
this._listeners = new Set();
|
|
28
|
+
this._abort = null;
|
|
29
|
+
|
|
30
|
+
this._state = {
|
|
31
|
+
status: 'idle',
|
|
32
|
+
progress: 0,
|
|
33
|
+
stage: null,
|
|
34
|
+
error: null,
|
|
35
|
+
jobId: null,
|
|
36
|
+
result: null
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
getState() {
|
|
41
|
+
return this._state;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* @param {function} listener
|
|
46
|
+
* @returns {function} unsubscribe
|
|
47
|
+
*/
|
|
48
|
+
subscribe(listener) {
|
|
49
|
+
this._listeners.add(listener);
|
|
50
|
+
return () => this._listeners.delete(listener);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Запускает двухфазный джоб: генерация -> (опционально) конвертация.
|
|
55
|
+
* @param {object} args
|
|
56
|
+
* @param {string} [args.mode='image'] 'text'|'image'|'multi'
|
|
57
|
+
* @param {string} [args.prompt]
|
|
58
|
+
* @param {File} [args.image]
|
|
59
|
+
* @param {Array<{file: File, viewType: string}>} [args.multiViewImages]
|
|
60
|
+
* @param {string} [args.model='3.1']
|
|
61
|
+
* @param {string} [args.generateType]
|
|
62
|
+
* @param {number} [args.faceCount]
|
|
63
|
+
* @param {boolean} [args.pbr]
|
|
64
|
+
* @param {string} [args.downloadFormat='glb']
|
|
65
|
+
* @param {AbortSignal} [args.signal]
|
|
66
|
+
* @returns {Promise<void>}
|
|
67
|
+
*/
|
|
68
|
+
async start({ mode = 'image', prompt, image, multiViewImages, model = '3.1', generateType, faceCount, pbr, downloadFormat = 'glb', signal: externalSignal } = {}) {
|
|
69
|
+
if (this._abort) this.abort();
|
|
70
|
+
|
|
71
|
+
const abort = new AbortController();
|
|
72
|
+
this._abort = abort;
|
|
73
|
+
const { signal } = abort;
|
|
74
|
+
|
|
75
|
+
if (externalSignal) {
|
|
76
|
+
if (externalSignal.aborted) { abort.abort(); return; }
|
|
77
|
+
externalSignal.addEventListener('abort', () => abort.abort(), { once: true });
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
this._setState({ status: 'submitting', progress: 0, stage: null, error: null, jobId: null, result: null });
|
|
81
|
+
|
|
82
|
+
const timeoutId = setTimeout(() => {
|
|
83
|
+
if (this._abort === abort) {
|
|
84
|
+
abort.abort();
|
|
85
|
+
this._setState({ status: 'error', error: 'Таймаут: джоб превысил 600 секунд' });
|
|
86
|
+
}
|
|
87
|
+
}, JOB_TIMEOUT_MS);
|
|
88
|
+
|
|
89
|
+
try {
|
|
90
|
+
const { jobId } = await this._client.submit3dModel({
|
|
91
|
+
mode, prompt, image, multiViewImages, model, generateType, faceCount, pbr, downloadFormat, signal
|
|
92
|
+
});
|
|
93
|
+
if (signal.aborted) return;
|
|
94
|
+
|
|
95
|
+
this._setState({ status: 'polling', jobId });
|
|
96
|
+
|
|
97
|
+
const genResult = await this._pollLoop(jobId, signal, downloadFormat);
|
|
98
|
+
if (!genResult || signal.aborted) return;
|
|
99
|
+
|
|
100
|
+
if (!genResult.needsConvert) {
|
|
101
|
+
const { previewBase64, mimeType, modelUrl, format } = genResult;
|
|
102
|
+
this._setState({ status: 'done', progress: 100, stage: null, result: { previewBase64, mimeType, modelUrl, format } });
|
|
103
|
+
return;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// Фаза 2: конвертация GLB -> FBX/STL
|
|
107
|
+
const { previewBase64: genPreview, mimeType: genMime, sourceGlbUrl } = genResult;
|
|
108
|
+
this._setState({ stage: 'convert', progress: 0 });
|
|
109
|
+
|
|
110
|
+
const { jobId: convertJobId } = await this._client.submitConvert3d({
|
|
111
|
+
glbUrl: sourceGlbUrl, format: downloadFormat, signal
|
|
112
|
+
});
|
|
113
|
+
if (signal.aborted) return;
|
|
114
|
+
|
|
115
|
+
this._setState({ jobId: convertJobId });
|
|
116
|
+
|
|
117
|
+
const convResult = await this._pollConvertLoop(convertJobId, signal, downloadFormat);
|
|
118
|
+
if (!convResult || signal.aborted) return;
|
|
119
|
+
|
|
120
|
+
this._setState({
|
|
121
|
+
status: 'done',
|
|
122
|
+
progress: 100,
|
|
123
|
+
stage: null,
|
|
124
|
+
result: { previewBase64: genPreview, mimeType: genMime, modelUrl: convResult.modelUrl, format: convResult.format }
|
|
125
|
+
});
|
|
126
|
+
} catch (err) {
|
|
127
|
+
if (err?.name === 'AbortError' || signal.aborted) return;
|
|
128
|
+
this._setState({ status: 'error', error: err?.message || 'Ошибка запроса' });
|
|
129
|
+
} finally {
|
|
130
|
+
clearTimeout(timeoutId);
|
|
131
|
+
if (this._abort === abort) this._abort = null;
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/** Прерывает submit или поллинг. */
|
|
136
|
+
abort() {
|
|
137
|
+
if (this._abort) {
|
|
138
|
+
try { this._abort.abort(); } catch { /* noop */ }
|
|
139
|
+
this._abort = null;
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
async _pollLoop(jobId, signal, format) {
|
|
144
|
+
while (!signal.aborted) {
|
|
145
|
+
await sleep(POLL_INTERVAL_MS, signal);
|
|
146
|
+
if (signal.aborted) return null;
|
|
147
|
+
|
|
148
|
+
let data;
|
|
149
|
+
try {
|
|
150
|
+
data = await this._client.poll3dModel(jobId, signal, undefined, format);
|
|
151
|
+
} catch (err) {
|
|
152
|
+
if (err?.name === 'AbortError' || signal.aborted) return null;
|
|
153
|
+
this._setState({ status: 'error', error: err?.message || 'Ошибка поллинга' });
|
|
154
|
+
return null;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
const { status, progress, stage, error } = data;
|
|
158
|
+
|
|
159
|
+
if (status === 'done') {
|
|
160
|
+
return data;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
if (status === 'error') {
|
|
164
|
+
this._setState({ status: 'error', error: error || 'Ошибка джоба' });
|
|
165
|
+
return null;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// 'pending' | 'running' — обновляем прогресс
|
|
169
|
+
this._setState({
|
|
170
|
+
progress: progress ?? this._state.progress,
|
|
171
|
+
stage: stage ?? this._state.stage
|
|
172
|
+
});
|
|
173
|
+
}
|
|
174
|
+
return null;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
async _pollConvertLoop(jobId, signal, format) {
|
|
178
|
+
while (!signal.aborted) {
|
|
179
|
+
await sleep(POLL_INTERVAL_MS, signal);
|
|
180
|
+
if (signal.aborted) return null;
|
|
181
|
+
|
|
182
|
+
let data;
|
|
183
|
+
try {
|
|
184
|
+
data = await this._client.pollConvert3d(jobId, signal, format);
|
|
185
|
+
} catch (err) {
|
|
186
|
+
if (err?.name === 'AbortError' || signal.aborted) return null;
|
|
187
|
+
this._setState({ status: 'error', error: err?.message || 'Ошибка конвертации' });
|
|
188
|
+
return null;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
const { status, progress, error } = data;
|
|
192
|
+
|
|
193
|
+
if (status === 'done') {
|
|
194
|
+
return data;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
if (status === 'error') {
|
|
198
|
+
this._setState({ status: 'error', error: error || 'Ошибка конвертации' });
|
|
199
|
+
return null;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
this._setState({ progress: progress ?? this._state.progress });
|
|
203
|
+
}
|
|
204
|
+
return null;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
_setState(patch) {
|
|
208
|
+
this._state = { ...this._state, ...patch };
|
|
209
|
+
this._emit();
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
_emit() {
|
|
213
|
+
for (const listener of this._listeners) {
|
|
214
|
+
try { listener(this._state); } catch (err) {
|
|
215
|
+
console.error('[Model3dSession] listener error:', err);
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/**
|
|
222
|
+
* Promise, который разрешается через ms миллисекунд или отклоняется по AbortSignal.
|
|
223
|
+
* @param {number} ms
|
|
224
|
+
* @param {AbortSignal} [signal]
|
|
225
|
+
*/
|
|
226
|
+
function sleep(ms, signal) {
|
|
227
|
+
return new Promise((resolve, reject) => {
|
|
228
|
+
if (signal?.aborted) {
|
|
229
|
+
reject(new DOMException('Aborted', 'AbortError'));
|
|
230
|
+
return;
|
|
231
|
+
}
|
|
232
|
+
const id = setTimeout(resolve, ms);
|
|
233
|
+
signal?.addEventListener('abort', () => {
|
|
234
|
+
clearTimeout(id);
|
|
235
|
+
reject(new DOMException('Aborted', 'AbortError'));
|
|
236
|
+
}, { once: true });
|
|
237
|
+
});
|
|
238
|
+
}
|