@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.
@@ -1,6 +1,10 @@
1
1
  import { AiClient } from '../../services/ai/AiClient.js';
2
2
  import { ChatHistoryStore } from '../../services/ai/ChatHistoryStore.js';
3
3
  import { ChatSessionController } from '../../services/ai/ChatSessionController.js';
4
+ import { Model3dSessionController } from '../../services/ai/Model3dSessionController.js';
5
+ import { IMAGE_MODELS, getImageModelCapability } from '../../services/ai/imageModelCapabilities.js';
6
+ import { VIDEO_MODELS, getVideoModelCapability } from '../../services/ai/videoModelCapabilities.js';
7
+ import { VideoSessionController } from '../../services/ai/VideoSessionController.js';
4
8
  import { Events } from '../../core/events/Events.js';
5
9
 
6
10
  import { buildChatDom } from './ChatWindowRenderer.js';
@@ -8,7 +12,11 @@ import { formatChatErrorForDisplay } from './formatChatError.js';
8
12
  import { ChatMessageList } from './ChatMessageList.js';
9
13
  import { ChatComposer } from './ChatComposer.js';
10
14
  import { ChatPillMenu } from './ChatPillMenu.js';
15
+ import { ChatSettingsPopup } from './ChatSettingsPopup.js';
16
+ import { ChatVideoToolbarPills } from './ChatVideoToolbarPills.js';
11
17
  import { ChatExtendedPromptModal } from './ChatExtendedPromptModal.js';
18
+ import { Model3dProgressOverlay } from './Model3dProgressOverlay.js';
19
+ import { Model3dBoardSkeleton } from './Model3dBoardSkeleton.js';
12
20
  import { ICONS, RATIO_ICONS, COUNT_ICONS } from './icons.js';
13
21
 
14
22
  const CONTENT_TYPE_OPTIONS = [
@@ -16,16 +24,71 @@ const CONTENT_TYPE_OPTIONS = [
16
24
  id: 'image',
17
25
  label: 'Изображение',
18
26
  icon: ICONS.image,
19
- description: 'Создать по тексту или приложите ссылку'
27
+ description: 'По описанию или ссылке на референс'
20
28
  },
21
29
  {
22
30
  id: 'video',
23
31
  label: 'Видео',
24
32
  icon: ICONS.video,
25
- description: 'Создать по тексту или руководствуясь первым и последним кадром'
33
+ description: 'По описанию или по первому и последнему кадру'
34
+ },
35
+ {
36
+ id: '3d',
37
+ label: '3D-модель',
38
+ icon: ICONS.cube,
39
+ description: 'По выбранному изображению'
26
40
  }
27
41
  ];
28
42
 
43
+ const FACE_COUNT_OPTIONS_3D = [
44
+ { id: '1.5m', label: '1.5M' },
45
+ { id: '1m', label: '1M' },
46
+ { id: '500k', label: '500k' },
47
+ { id: '50k', label: '50k' },
48
+ ];
49
+
50
+ const FACE_COUNT_MAP_3D = { '1.5m': 1500000, '1m': 1000000, '500k': 500000, '50k': 50000 };
51
+
52
+ const TYPE_3D_OPTIONS = [
53
+ { id: 'geo_tex', label: 'Геометрия + Текстура' },
54
+ { id: 'geo', label: 'Только геометрия' },
55
+ ];
56
+
57
+ const MODE_3D_OPTIONS = [
58
+ { id: 'image', label: 'Изображение' },
59
+ { id: 'multi', label: 'Несколько изображений' },
60
+ { id: 'text', label: 'Текст' },
61
+ ];
62
+
63
+ const TEXTURE_STYLE_OPTIONS_3D = [
64
+ { id: 'general', label: 'Общий' },
65
+ { id: 'stone_carving', label: 'Резьба по камню' },
66
+ { id: 'blue_white_porcelain', label: 'Сине-белый фарфор' },
67
+ { id: 'chinese_style', label: 'Китайский стиль' },
68
+ { id: 'cartoon', label: 'Мультфильм' },
69
+ { id: 'cyberpunk', label: 'Киберпанк' },
70
+ ];
71
+
72
+ const TEXTURE_STYLE_SUFFIX_3D = {
73
+ general: '',
74
+ stone_carving: ', stone carving style',
75
+ blue_white_porcelain: ', blue and white porcelain style',
76
+ chinese_style: ', Chinese style',
77
+ cartoon: ', cartoon style',
78
+ cyberpunk: ', cyberpunk style',
79
+ };
80
+
81
+ const RESULT_FORMAT_OPTIONS_3D = [
82
+ { id: 'glb', label: 'GLB' },
83
+ { id: 'obj', label: 'OBJ' },
84
+ { id: 'fbx', label: 'FBX' },
85
+ { id: 'stl', label: 'STL' },
86
+ ];
87
+
88
+ // ViewType для мультивью: 2-е..8-е вложение (1-е = фронт, без ViewType)
89
+ const VIEW_ORDER_3D = ['left', 'right', 'back', 'top', 'bottom', 'left_front', 'right_front'];
90
+ const VIEW_LABELS_3D = ['Лево', 'Право', 'Зад', 'Верх', 'Низ', 'Лево-перед', 'Право-перед'];
91
+
29
92
  /** Порядок: портрет (строка 1), альбом (строка 2), авто — крайнее правое */
30
93
  const FORMAT_OPTIONS = [
31
94
  { id: '1:1', label: '1:1', icon: RATIO_ICONS['1:1'] },
@@ -35,21 +98,27 @@ const FORMAT_OPTIONS = [
35
98
  { id: '2:3', label: '2:3', icon: RATIO_ICONS['2:3'] },
36
99
  { id: '9:16', label: '9:16', icon: RATIO_ICONS['9:16'] },
37
100
  { id: '1:2', label: '1:2', icon: RATIO_ICONS['1:2'] },
101
+ { id: '1:4', label: '1:4', icon: RATIO_ICONS['1:4'] },
102
+ { id: '1:8', label: '1:8', icon: RATIO_ICONS['1:8'] },
38
103
  { id: '5:4', label: '5:4', icon: RATIO_ICONS['5:4'] },
39
104
  { id: '4:3', label: '4:3', icon: RATIO_ICONS['4:3'] },
40
105
  { id: '14:10', label: '14:10', icon: RATIO_ICONS['14:10'] },
41
106
  { id: '3:2', label: '3:2', icon: RATIO_ICONS['3:2'] },
42
107
  { id: '16:9', label: '16:9', icon: RATIO_ICONS['16:9'] },
43
108
  { id: '2:1', label: '2:1', icon: RATIO_ICONS['2:1'] },
109
+ { id: '21:9', label: '21:9', icon: RATIO_ICONS['21:9'] },
110
+ { id: '4:1', label: '4:1', icon: RATIO_ICONS['4:1'] },
111
+ { id: '8:1', label: '8:1', icon: RATIO_ICONS['8:1'] },
44
112
  { id: 'auto', label: 'Auto', icon: RATIO_ICONS['auto'] },
45
113
  ];
46
114
 
47
115
  const COUNT_OPTIONS = [
48
- { id: 'auto', label: 'Авто', icon: COUNT_ICONS.auto },
49
116
  { id: '1', label: '1 Изображение', icon: COUNT_ICONS[1] },
50
117
  { id: '2', label: '2 Изображения', icon: COUNT_ICONS[2] },
51
118
  { id: '3', label: '3 Изображения', icon: COUNT_ICONS[3] },
52
119
  { id: '4', label: '4 Изображения', icon: COUNT_ICONS[4] },
120
+ { id: '5', label: '5 Изображений', icon: COUNT_ICONS[4] },
121
+ { id: '6', label: '6 Изображений', icon: COUNT_ICONS[4] },
53
122
  ];
54
123
 
55
124
  const BOARD_IMAGE_WIDTH = 300;
@@ -66,38 +135,28 @@ const BOARD_IMAGE_LANE_REFERENCE_RATIO = [2, 3];
66
135
  const BOARD_IMAGE_LANE_CENTER_OFFSET = 250;
67
136
  const BOARD_IMAGE_LANE_UI_GAP = 16;
68
137
 
69
- const MODEL_OPTIONS = [
70
- {
71
- id: 'auto',
72
- label: 'Автоматический режим',
73
- icon: ICONS.modelBot,
74
- description: 'Мы подберем модель для ваших задач.'
75
- },
76
- {
77
- id: 'yandex',
78
- label: 'Алиса',
79
- icon: ICONS.modelAlice,
80
- description: 'YandexGPT'
81
- },
82
- {
83
- id: 'gpt',
84
- label: 'GPT',
85
- icon: ICONS.modelGpt,
86
- description: 'OpenAI'
87
- },
88
- {
89
- id: 'google',
90
- label: 'Google',
91
- icon: ICONS.modelGoogle,
92
- description: 'Gemini'
93
- },
94
- {
95
- id: 'qwen',
96
- label: 'Qwen',
97
- icon: '<img src="/icons/qwen.svg" alt="" aria-hidden="true">',
98
- description: 'Alibaba'
138
+ /** Маппинг провайдера видео → иконка пилла модели. */
139
+ function _iconForVideoProvider(provider) {
140
+ switch (provider) {
141
+ case 'gemini-video': return ICONS.modelGoogle;
142
+ case 'veo': return ICONS.modelGoogle;
143
+ case 'openai-video': return ICONS.modelGpt;
144
+ case 'kling': return ICONS.modelKling;
145
+ case 'seedance': return ICONS.modelSeedance;
146
+ default: return ICONS.model;
99
147
  }
100
- ];
148
+ }
149
+
150
+ /** Маппинг провайдера → иконка пилла модели. */
151
+ function _iconForProvider(provider) {
152
+ switch (provider) {
153
+ case 'yandex-art': return ICONS.modelAlice;
154
+ case 'gemini-image': return ICONS.modelGoogle;
155
+ case 'openai-image': return ICONS.modelGpt;
156
+ case 'qwen-image': return ICONS.modelQwen;
157
+ default: return ICONS.model;
158
+ }
159
+ }
101
160
 
102
161
  /**
103
162
  * Корневой контейнер чата ИИ-ассистента.
@@ -135,15 +194,49 @@ export class ChatWindow {
135
194
  this._extendedPromptModal = null;
136
195
  this._contentTypeId = 'image';
137
196
  this._contentTypeMenu = null;
138
- this._modelId = 'auto';
197
+ this._modelId = 'nano-banana-pro';
139
198
  this._modelMenu = null;
140
199
  this._formatId = 'auto';
141
200
  this._formatMenu = null;
142
201
  this._providers = [];
143
- this._countId = 'auto';
202
+ this._countId = '1';
144
203
  this._countMenu = null;
204
+ // Разрешение — пилл, видим только когда модель имеет resolutions.length > 0
205
+ this._resolutionId = getImageModelCapability('nano-banana-pro')?.defaultResolution ?? null;
206
+ this._resolutionMenu = null;
207
+ // Попап настроек
208
+ this._settingsPopupCtrl = null;
209
+ // Доп-параметры генерации изображения (хранятся здесь, передаются в payload)
210
+ this._imageSeed = null;
211
+ this._imageNegativePrompt = '';
212
+ this._imageBackground = null;
213
+ this._imageOutputFormat = null;
214
+ this._imagePromptExtend = false;
215
+ this._imageWatermark = false;
145
216
  this._unsubscribe = null;
146
217
  this._attached = false;
218
+ this._3dFaceCountId = '1m';
219
+ this._3dTypeId = 'geo_tex';
220
+ this._3dFaceCountMenu = null;
221
+ this._3dTypeMenu = null;
222
+ this._3dFaceCountWrapper = null;
223
+ this._3dTypeWrapper = null;
224
+ this._3dMode = 'image';
225
+ this._3dTextureStyleId = 'general';
226
+ this._3dResultFormatId = 'glb';
227
+ this._3dModeMenu = null;
228
+ this._3dModeWrapper = null;
229
+ this._3dTextureStyleMenu = null;
230
+ this._3dTextureStyleWrapper = null;
231
+ this._3dResultFormatMenu = null;
232
+ this._3dResultFormatWrapper = null;
233
+ this._model3dSession = null;
234
+ this._model3dUnsubscribe = null;
235
+ this._model3dProgressOverlay = null;
236
+ this._model3dSkeleton = null;
237
+ // Мировые координаты центра скелетона — модель приземлится ровно сюда,
238
+ // независимо от пана/зума во время генерации.
239
+ this._model3dSkeletonWorld = null;
147
240
  this._boardImageMessageIds = new Set();
148
241
  this._shiftedForImageBatchKeys = new Set();
149
242
  this._boardImageShiftHistory = new Map();
@@ -161,6 +254,23 @@ export class ChatWindow {
161
254
  // приходит на каждый mousemove и не должен пушить превью в чат —
162
255
  // финальный набор картинок мы получим из BoxSelectCommit по strict-contains.
163
256
  this._boxSelectActive = false;
257
+
258
+ // Видео-генерация
259
+ this._videoModelId = VIDEO_MODELS[0].id;
260
+ this._videoRatioId = VIDEO_MODELS[0].ratios[0] ?? '16:9';
261
+ this._videoResolutionId = null;
262
+ this._videoDurationId = VIDEO_MODELS[0].defaultDuration ?? VIDEO_MODELS[0].durations[0] ?? 5;
263
+ this._videoSeed = null;
264
+ this._videoNegativePrompt = '';
265
+ this._videoAudio = false;
266
+ this._videoWatermark = false;
267
+ this._videoCfgScale = null;
268
+ this._videoPersonGeneration = 'allow_all';
269
+ this._videoDurationWrapper = null;
270
+ this._videoDurationMenu = null;
271
+ this._videoToolbarPills = null;
272
+ this._videoSession = null;
273
+ this._videoUnsubscribe = null;
164
274
  }
165
275
 
166
276
  attach() {
@@ -181,10 +291,22 @@ export class ChatWindow {
181
291
  },
182
292
  {
183
293
  onSubmit: (text, attachments) => {
294
+ if (this._contentTypeId === '3d') {
295
+ return this._submit3d(text, attachments);
296
+ }
297
+ if (this._contentTypeId === 'video') {
298
+ return this._submitVideo(text, attachments);
299
+ }
184
300
  this._clearBoardSelection();
185
301
  return this._session.send(text, { ...this._getImageRequestOptions(), referenceImages: attachments });
186
302
  },
187
- onAbort: () => this._session.abort()
303
+ onAbort: () => {
304
+ if (this._contentTypeId === 'video') {
305
+ this._videoSession?.abort();
306
+ } else {
307
+ this._session.abort();
308
+ }
309
+ }
188
310
  }
189
311
  );
190
312
  this._composer.attach();
@@ -213,6 +335,12 @@ export class ChatWindow {
213
335
  onSelect: (id) => {
214
336
  this._contentTypeId = id;
215
337
  this._contentTypeMenu.refresh();
338
+ this._update3dPillVisibility();
339
+ this._update3dSendGate();
340
+ this._modelMenu?.refresh();
341
+ this._formatMenu?.refresh();
342
+ this._updateResolutionPillVisibility();
343
+ this._settingsPopupCtrl?.refresh();
216
344
  }
217
345
  }
218
346
  );
@@ -221,12 +349,24 @@ export class ChatWindow {
221
349
  this._modelMenu = new ChatPillMenu(
222
350
  { trigger: this._refs.modelPill, menu: this._refs.modelMenu, label: this._refs.modelLabel, icon: this._refs.modelIcon },
223
351
  {
224
- getOptions: () => MODEL_OPTIONS,
225
- getActiveId: () => this._modelId,
352
+ getOptions: () => {
353
+ if (this._contentTypeId === 'video') {
354
+ return VIDEO_MODELS.map((m) => ({ id: m.id, label: m.label, description: m.description, icon: _iconForVideoProvider(m.provider) }));
355
+ }
356
+ return IMAGE_MODELS.map((m) => ({ id: m.id, label: m.label, description: m.description, icon: _iconForProvider(m.provider) }));
357
+ },
358
+ getActiveId: () => this._contentTypeId === 'video' ? this._videoModelId : this._modelId,
226
359
  onSelect: (id) => {
227
- this._modelId = id;
360
+ if (this._contentTypeId === 'video') {
361
+ this._videoModelId = id;
362
+ this._clampVideoSettingsToModel();
363
+ } else {
364
+ this._modelId = id;
365
+ this._clampSettingsToModel();
366
+ }
228
367
  this._modelMenu.refresh();
229
- this._clampFormatToModel();
368
+ this._updateResolutionPillVisibility();
369
+ this._settingsPopupCtrl?.refresh();
230
370
  }
231
371
  }
232
372
  );
@@ -235,22 +375,52 @@ export class ChatWindow {
235
375
  this._formatMenu = new ChatPillMenu(
236
376
  { trigger: this._refs.formatPill, menu: this._refs.formatMenu, label: this._refs.formatLabel },
237
377
  {
238
- getOptions: () => this._getSupportedFormatOptions(),
239
- getActiveId: () => this._formatId,
378
+ getOptions: () => this._contentTypeId === 'video' ? this._getSupportedVideoFormatOptions() : this._getSupportedFormatOptions(),
379
+ getActiveId: () => this._contentTypeId === 'video' ? this._videoRatioId : this._formatId,
240
380
  onSelect: (id) => {
241
- this._formatId = id;
381
+ if (this._contentTypeId === 'video') {
382
+ this._videoRatioId = id;
383
+ } else {
384
+ this._formatId = id;
385
+ this._updateFormatPillIcon();
386
+ this._updateFormatPillLabel();
387
+ }
242
388
  this._formatMenu.refresh();
243
- this._updateFormatPillIcon();
244
- this._updateFormatPillLabel();
245
389
  }
246
390
  }
247
391
  );
248
392
  this._formatMenu.attach();
249
393
 
394
+ this._resolutionMenu = new ChatPillMenu(
395
+ { trigger: this._refs.resolutionPill, menu: this._refs.resolutionMenu, label: this._refs.resolutionLabel },
396
+ {
397
+ getOptions: () => {
398
+ if (this._contentTypeId === 'video') {
399
+ const cap = getVideoModelCapability(this._videoModelId);
400
+ return (cap?.resolutions ?? []).map((r) => ({ id: r, label: r }));
401
+ }
402
+ const cap = getImageModelCapability(this._modelId);
403
+ return (cap?.resolutions ?? []).map((r) => ({ id: r, label: r }));
404
+ },
405
+ getActiveId: () => this._contentTypeId === 'video' ? this._videoResolutionId : this._resolutionId,
406
+ onSelect: (id) => {
407
+ if (this._contentTypeId === 'video') {
408
+ this._videoResolutionId = id;
409
+ } else {
410
+ this._resolutionId = id;
411
+ this._updateResolutionPillLabel();
412
+ }
413
+ this._resolutionMenu.refresh();
414
+ }
415
+ }
416
+ );
417
+ this._resolutionMenu.attach();
418
+ this._updateResolutionPillVisibility();
419
+
250
420
  this._countMenu = new ChatPillMenu(
251
421
  { trigger: this._refs.countPill, menu: this._refs.countMenu, label: this._refs.countLabel, icon: this._refs.countIcon },
252
422
  {
253
- getOptions: () => COUNT_OPTIONS,
423
+ getOptions: () => this._getSupportedCountOptions(),
254
424
  getActiveId: () => this._countId,
255
425
  onSelect: (id) => {
256
426
  this._countId = id;
@@ -261,6 +431,59 @@ export class ChatWindow {
261
431
  );
262
432
  this._countMenu.attach();
263
433
 
434
+ this._settingsPopupCtrl = new ChatSettingsPopup(
435
+ { trigger: this._refs.settingsTrigger, popup: this._refs.settingsPopup },
436
+ {
437
+ getSettings: () => ({
438
+ systemPrompt: this._session.getState().settings?.systemPrompt ?? '',
439
+ temperature: this._session.getState().settings?.temperature ?? 0.7,
440
+ maxTokens: this._session.getState().settings?.maxTokens ?? 2000,
441
+ seed: this._imageSeed,
442
+ negativePrompt: this._imageNegativePrompt,
443
+ background: this._imageBackground,
444
+ outputFormat: this._imageOutputFormat,
445
+ promptExtend: this._imagePromptExtend,
446
+ watermark: this._imageWatermark,
447
+ videoSeed: this._videoSeed,
448
+ videoNegativePrompt: this._videoNegativePrompt,
449
+ videoAudio: this._videoAudio,
450
+ videoWatermark: this._videoWatermark,
451
+ videoCfgScale: this._videoCfgScale,
452
+ videoPersonGeneration: this._videoPersonGeneration,
453
+ }),
454
+ getCapability: () => getImageModelCapability(this._modelId),
455
+ getVideoCapability: () => getVideoModelCapability(this._videoModelId),
456
+ getContentType: () => this._contentTypeId,
457
+ onChange: (patch) => {
458
+ const sessionPatch = {};
459
+ if ('systemPrompt' in patch) sessionPatch.systemPrompt = patch.systemPrompt;
460
+ if ('temperature' in patch) sessionPatch.temperature = patch.temperature;
461
+ if ('maxTokens' in patch) sessionPatch.maxTokens = patch.maxTokens;
462
+ if (Object.keys(sessionPatch).length > 0) this._session.updateSettings(sessionPatch);
463
+ if ('seed' in patch) this._imageSeed = patch.seed;
464
+ if ('negativePrompt' in patch) this._imageNegativePrompt = patch.negativePrompt;
465
+ if ('background' in patch) this._imageBackground = patch.background;
466
+ if ('outputFormat' in patch) this._imageOutputFormat = patch.outputFormat;
467
+ if ('promptExtend' in patch) this._imagePromptExtend = patch.promptExtend;
468
+ if ('watermark' in patch) this._imageWatermark = patch.watermark;
469
+ if ('videoSeed' in patch) this._videoSeed = patch.videoSeed;
470
+ if ('videoNegativePrompt' in patch) this._videoNegativePrompt = patch.videoNegativePrompt;
471
+ if ('videoAudio' in patch) this._videoAudio = patch.videoAudio;
472
+ if ('videoWatermark' in patch) this._videoWatermark = patch.videoWatermark;
473
+ if ('videoCfgScale' in patch) this._videoCfgScale = patch.videoCfgScale;
474
+ if ('videoPersonGeneration' in patch) this._videoPersonGeneration = patch.videoPersonGeneration;
475
+ },
476
+ onClearHistory: () => this._session.clearHistory()
477
+ }
478
+ );
479
+ this._settingsPopupCtrl.attach();
480
+ this._settingsPopupCtrl.refresh();
481
+
482
+ this._attach3dPills();
483
+ this._attachVideoPills();
484
+ this._attachVideoToolbarPills();
485
+ this._update3dPillVisibility();
486
+
264
487
  const initialState = this._session.getState();
265
488
  this._markExistingBoardImages(initialState.messages);
266
489
  this._reserveCurrentAiImageLaneSlots();
@@ -286,12 +509,51 @@ export class ChatWindow {
286
509
  this._pendingBatchOffsets.clear();
287
510
  this._aiImageLaneSlots.clear();
288
511
  this._draggingAiImageIds.clear();
512
+ this._model3dUnsubscribe?.();
513
+ this._model3dUnsubscribe = null;
514
+ this._model3dSession?.abort?.();
515
+ this._model3dProgressOverlay?.destroy();
516
+ this._model3dProgressOverlay = null;
517
+ this._clear3dSkeleton();
518
+ this._3dFaceCountMenu?.destroy();
519
+ this._3dFaceCountMenu = null;
520
+ this._3dTypeMenu?.destroy();
521
+ this._3dTypeMenu = null;
522
+ this._3dFaceCountWrapper?.remove();
523
+ this._3dFaceCountWrapper = null;
524
+ this._3dTypeWrapper?.remove();
525
+ this._3dTypeWrapper = null;
526
+ this._3dModeMenu?.destroy();
527
+ this._3dModeMenu = null;
528
+ this._3dModeWrapper?.remove();
529
+ this._3dModeWrapper = null;
530
+ this._3dTextureStyleMenu?.destroy();
531
+ this._3dTextureStyleMenu = null;
532
+ this._3dTextureStyleWrapper?.remove();
533
+ this._3dTextureStyleWrapper = null;
534
+ this._3dResultFormatMenu?.destroy();
535
+ this._3dResultFormatMenu = null;
536
+ this._3dResultFormatWrapper?.remove();
537
+ this._3dResultFormatWrapper = null;
538
+ if (this._videoUnsubscribe) { this._videoUnsubscribe(); this._videoUnsubscribe = null; }
539
+ this._videoSession?.abort?.();
540
+ this._videoSession = null;
541
+ this._videoDurationMenu?.destroy();
542
+ this._videoDurationMenu = null;
543
+ this._videoDurationWrapper?.remove();
544
+ this._videoDurationWrapper = null;
545
+ this._videoToolbarPills?.destroy();
546
+ this._videoToolbarPills = null;
289
547
  this._composer?.destroy();
290
548
  this._extendedPromptModal?.destroy();
291
549
  this._contentTypeMenu?.destroy();
292
550
  this._modelMenu?.destroy();
293
551
  this._formatMenu?.destroy();
552
+ this._resolutionMenu?.destroy();
553
+ this._resolutionMenu = null;
294
554
  this._countMenu?.destroy();
555
+ this._settingsPopupCtrl?.destroy();
556
+ this._settingsPopupCtrl = null;
295
557
  if (this._refs?.root && this._refs.root.parentNode === this._container) {
296
558
  this._container.removeChild(this._refs.root);
297
559
  }
@@ -310,6 +572,341 @@ export class ChatWindow {
310
572
  this.detach();
311
573
  }
312
574
 
575
+ _attach3dPills() {
576
+ const pillsContainer = this._refs.formatPill?.closest('.moodboard-chat__pills');
577
+ if (!pillsContainer) return;
578
+
579
+ const mk3dPill = (label, icon) => {
580
+ const wrapper = document.createElement('div');
581
+ wrapper.className = 'moodboard-chat__pill-wrapper';
582
+ const pill = document.createElement('button');
583
+ pill.type = 'button';
584
+ pill.className = 'moodboard-chat__pill';
585
+ pill.setAttribute('aria-haspopup', 'menu');
586
+ pill.setAttribute('aria-expanded', 'false');
587
+ const iconSpan = document.createElement('span');
588
+ iconSpan.className = 'moodboard-chat__pill-icon-wrap';
589
+ iconSpan.innerHTML = icon;
590
+ const labelEl = document.createElement('span');
591
+ labelEl.className = 'moodboard-chat__pill-label';
592
+ labelEl.textContent = label;
593
+ pill.appendChild(iconSpan);
594
+ pill.appendChild(labelEl);
595
+ const menu = document.createElement('div');
596
+ menu.className = 'moodboard-chat__menu';
597
+ menu.setAttribute('role', 'menu');
598
+ wrapper.appendChild(pill);
599
+ wrapper.appendChild(menu);
600
+ return { wrapper, pill, menu, labelEl, iconEl: iconSpan };
601
+ };
602
+
603
+ const fc = mk3dPill('1M', ICONS.sliders);
604
+ pillsContainer.appendChild(fc.wrapper);
605
+ this._3dFaceCountWrapper = fc.wrapper;
606
+ this._3dFaceCountMenu = new ChatPillMenu(
607
+ { trigger: fc.pill, menu: fc.menu, label: fc.labelEl, icon: fc.iconEl },
608
+ {
609
+ getOptions: () => FACE_COUNT_OPTIONS_3D,
610
+ getActiveId: () => this._3dFaceCountId,
611
+ onSelect: (id) => { this._3dFaceCountId = id; this._3dFaceCountMenu.refresh(); }
612
+ }
613
+ );
614
+ this._3dFaceCountMenu.attach();
615
+
616
+ const tp = mk3dPill('Геометрия + Текстура', ICONS.cube);
617
+ pillsContainer.appendChild(tp.wrapper);
618
+ this._3dTypeWrapper = tp.wrapper;
619
+ this._3dTypeMenu = new ChatPillMenu(
620
+ { trigger: tp.pill, menu: tp.menu, label: tp.labelEl, icon: tp.iconEl },
621
+ {
622
+ getOptions: () => TYPE_3D_OPTIONS,
623
+ getActiveId: () => this._3dTypeId,
624
+ onSelect: (id) => { this._3dTypeId = id; this._3dTypeMenu.refresh(); }
625
+ }
626
+ );
627
+ this._3dTypeMenu.attach();
628
+
629
+ const md = mk3dPill('Изображение', ICONS.image);
630
+ pillsContainer.appendChild(md.wrapper);
631
+ this._3dModeWrapper = md.wrapper;
632
+ this._3dModeMenu = new ChatPillMenu(
633
+ { trigger: md.pill, menu: md.menu, label: md.labelEl, icon: md.iconEl },
634
+ {
635
+ getOptions: () => MODE_3D_OPTIONS,
636
+ getActiveId: () => this._3dMode,
637
+ onSelect: (id) => { this._3dModeMenu.refresh(); this._update3dMode(id); }
638
+ }
639
+ );
640
+ this._3dModeMenu.attach();
641
+
642
+ const ts = mk3dPill('Общий', ICONS.palette);
643
+ pillsContainer.appendChild(ts.wrapper);
644
+ this._3dTextureStyleWrapper = ts.wrapper;
645
+ this._3dTextureStyleMenu = new ChatPillMenu(
646
+ { trigger: ts.pill, menu: ts.menu, label: ts.labelEl, icon: ts.iconEl },
647
+ {
648
+ getOptions: () => TEXTURE_STYLE_OPTIONS_3D,
649
+ getActiveId: () => this._3dTextureStyleId,
650
+ onSelect: (id) => { this._3dTextureStyleId = id; this._3dTextureStyleMenu.refresh(); }
651
+ }
652
+ );
653
+ this._3dTextureStyleMenu.attach();
654
+
655
+ const rf = mk3dPill('GLB', ICONS.sliders);
656
+ pillsContainer.appendChild(rf.wrapper);
657
+ this._3dResultFormatWrapper = rf.wrapper;
658
+ this._3dResultFormatMenu = new ChatPillMenu(
659
+ { trigger: rf.pill, menu: rf.menu, label: rf.labelEl, icon: rf.iconEl },
660
+ {
661
+ getOptions: () => RESULT_FORMAT_OPTIONS_3D,
662
+ getActiveId: () => this._3dResultFormatId,
663
+ onSelect: (id) => { this._3dResultFormatId = id; this._3dResultFormatMenu.refresh(); }
664
+ }
665
+ );
666
+ this._3dResultFormatMenu.attach();
667
+ }
668
+
669
+ _update3dPillVisibility() {
670
+ const is3d = this._contentTypeId === '3d';
671
+ const isVideo = this._contentTypeId === 'video';
672
+ const formatWrapper = this._refs.formatPill?.closest('.moodboard-chat__pill-wrapper');
673
+ const countWrapper = this._refs.countPill?.closest('.moodboard-chat__pill-wrapper');
674
+ const modelWrapper = this._refs.modelPill?.closest('.moodboard-chat__pill-wrapper');
675
+ if (formatWrapper) formatWrapper.style.display = is3d ? 'none' : '';
676
+ // В видео-режиме счётчик не нужен: у всех видео-моделей maxCount=1
677
+ if (countWrapper) countWrapper.style.display = (is3d || isVideo) ? 'none' : '';
678
+ if (modelWrapper) modelWrapper.style.display = is3d ? 'none' : '';
679
+ // Разрешение — скрываем в 3D-режиме; при возврате — восстанавливаем по capability
680
+ if (is3d) {
681
+ if (this._refs?.resolutionWrapper) this._refs.resolutionWrapper.style.display = 'none';
682
+ } else {
683
+ this._updateResolutionPillVisibility();
684
+ }
685
+ if (this._3dFaceCountWrapper) this._3dFaceCountWrapper.style.display = is3d ? '' : 'none';
686
+ if (this._3dTypeWrapper) this._3dTypeWrapper.style.display = is3d ? '' : 'none';
687
+ if (this._3dModeWrapper) this._3dModeWrapper.style.display = is3d ? '' : 'none';
688
+ if (this._3dResultFormatWrapper) this._3dResultFormatWrapper.style.display = is3d ? '' : 'none';
689
+ // Стиль текстуры — только в text-режиме
690
+ if (this._3dTextureStyleWrapper) {
691
+ this._3dTextureStyleWrapper.style.display = (is3d && this._3dMode === 'text') ? '' : 'none';
692
+ }
693
+ // Длительность — только в видео-режиме
694
+ if (this._videoDurationWrapper) this._videoDurationWrapper.style.display = isVideo ? '' : 'none';
695
+ // Видео-настройки (Звук/Водяной знак/Люди/Seed/CFG/Негатив) — пилюли тулбара
696
+ this._videoToolbarPills?.refresh();
697
+ }
698
+
699
+ _update3dSendGate() {
700
+ if (this._contentTypeId !== '3d') {
701
+ this._composer?.setInputEnabled(true);
702
+ this._composer?.setPlaceholderOverride(null);
703
+ if (this._refs?.textarea) {
704
+ this._refs.textarea.placeholder = 'Опишите то, что хотите сгенерировать';
705
+ }
706
+ return;
707
+ }
708
+ const inputEnabled = this._3dMode === 'text';
709
+ this._composer?.setInputEnabled(inputEnabled);
710
+ if (this._3dMode === 'image') {
711
+ this._composer?.setPlaceholderOverride('Описание не нужно — нажмите «Отправить»');
712
+ return;
713
+ }
714
+ this._composer?.setPlaceholderOverride(null);
715
+ if (this._refs?.textarea) {
716
+ if (this._3dMode === 'text') {
717
+ this._refs.textarea.placeholder = 'Опишите то, что хотите создать';
718
+ } else {
719
+ this._refs.textarea.placeholder = 'Выберите 2–8 изображений на доске';
720
+ }
721
+ }
722
+ }
723
+
724
+ _update3dMode(id) {
725
+ this._3dMode = id;
726
+ this._update3dPillVisibility();
727
+ this._update3dSendGate();
728
+ if (id === 'multi') {
729
+ this._composer?.setAttachmentLabelProvider((i) =>
730
+ i === 0 ? 'Фронт' : (VIEW_LABELS_3D[i - 1] ?? String(i + 1))
731
+ );
732
+ } else {
733
+ this._composer?.setAttachmentLabelProvider(null);
734
+ }
735
+ if (id === 'text') {
736
+ // В text-режиме вложения с доски недоступны — очищаем
737
+ this._composer?.removeAllBoardAttachments?.();
738
+ }
739
+ }
740
+
741
+ async _submit3d(text, attachments) {
742
+ const mode = this._3dMode;
743
+
744
+ if (mode === 'text') {
745
+ if (!text) return;
746
+ } else if (mode === 'image') {
747
+ if (!Array.isArray(attachments) || attachments.length !== 1) return;
748
+ } else if (mode === 'multi') {
749
+ if (!Array.isArray(attachments) || attachments.length < 2 || attachments.length > 8) return;
750
+ }
751
+
752
+ if (!this._model3dSession) {
753
+ this._model3dSession = new Model3dSessionController({ aiClient: this._aiClient });
754
+ }
755
+ this._model3dUnsubscribe?.();
756
+
757
+ if (!this._model3dProgressOverlay) {
758
+ this._model3dProgressOverlay = new Model3dProgressOverlay();
759
+ }
760
+ this._model3dProgressOverlay.attach(this._refs.composer);
761
+
762
+ this._model3dUnsubscribe = this._model3dSession.subscribe((state) => {
763
+ this._onModel3dState(state);
764
+ });
765
+
766
+ try {
767
+ this._show3dSkeleton();
768
+ } catch (e) {
769
+ console.warn('[ChatWindow] _show3dSkeleton failed:', e);
770
+ }
771
+
772
+ const suffix = TEXTURE_STYLE_SUFFIX_3D[this._3dTextureStyleId] ?? '';
773
+ const prompt = mode === 'text' ? (text + suffix) : undefined;
774
+
775
+ void this._model3dSession.start({
776
+ mode,
777
+ prompt,
778
+ image: mode !== 'text' ? attachments[0] : undefined,
779
+ multiViewImages: mode === 'multi'
780
+ ? attachments.slice(1).map((file, i) => ({ file, viewType: VIEW_ORDER_3D[i] }))
781
+ : undefined,
782
+ model: '3.1',
783
+ generateType: this._3dTypeId === 'geo' ? 'Geometry' : 'Normal',
784
+ faceCount: FACE_COUNT_MAP_3D[this._3dFaceCountId] ?? 1000000,
785
+ pbr: false,
786
+ downloadFormat: this._3dResultFormatId,
787
+ });
788
+ }
789
+
790
+ _onModel3dState(state) {
791
+ this._model3dProgressOverlay?.update(state);
792
+
793
+ if (state.status === 'done' && state.result) {
794
+ this._place3dModelOnBoard(state.result);
795
+ this._clear3dSkeleton();
796
+ // Оверлей убираем чуть позже скелетона, чтобы прогресс не «прыгал» —
797
+ // достаточно синхронного detach после размещения.
798
+ this._model3dProgressOverlay?.detach();
799
+ } else if (state.status === 'error') {
800
+ this._model3dProgressOverlay?.detach();
801
+ this._clear3dSkeleton();
802
+ if (this._refs?.errorBlock) {
803
+ const raw = state.error || 'Ошибка генерации 3D-модели';
804
+ // Убираем технический префикс вида "AiClient.submit3dModel (503): ".
805
+ const clean = raw.replace(/^AiClient\.\w+\s*\(\d+\):\s*/, '');
806
+ this._refs.errorBlock.textContent = clean || 'Ошибка генерации 3D-модели';
807
+ this._refs.errorBlock.classList.add('is-visible');
808
+ }
809
+ }
810
+ }
811
+
812
+ /**
813
+ * Экранный прямоугольник скелетона/модели в текущем вьюпорте из мировых координат центра.
814
+ * Размер фиксирован шириной BOARD_IMAGE_WIDTH в world-единицах (квадрат), как у заглушки.
815
+ * @param {{x:number,y:number}} worldCenter
816
+ */
817
+ _compute3dSkeletonRect(worldCenter) {
818
+ const world = this._boardCore?.pixi?.worldLayer || this._boardCore?.pixi?.app?.stage;
819
+ const s = world?.scale?.x || 1;
820
+ const size = Math.round(BOARD_IMAGE_WIDTH * s);
821
+ const screenX = Math.round(worldCenter.x * s + (world?.x || 0));
822
+ const screenY = Math.round(worldCenter.y * s + (world?.y || 0));
823
+ return {
824
+ left: Math.round(screenX - size / 2),
825
+ top: Math.round(screenY - size / 2),
826
+ width: size,
827
+ height: size,
828
+ radius: Math.max(2, Math.round(12 * s))
829
+ };
830
+ }
831
+
832
+ _show3dSkeleton() {
833
+ const world = this._boardCore?.pixi?.worldLayer || this._boardCore?.pixi?.app?.stage;
834
+ if (!world) return;
835
+ const s = world?.scale?.x || 1;
836
+
837
+ // Центр будущей модели в экранных координатах: над композером (как при размещении).
838
+ const composerRect = this._refs?.composer?.getBoundingClientRect?.();
839
+ const screenCenterX = composerRect
840
+ ? Math.round(composerRect.left + composerRect.width / 2)
841
+ : 400;
842
+ const screenCenterY = composerRect
843
+ ? Math.round(composerRect.top - 200)
844
+ : 200;
845
+
846
+ // _clear3dSkeleton() обнуляет _model3dSkeletonWorld — присваиваем ПОСЛЕ него.
847
+ this._clear3dSkeleton();
848
+
849
+ this._model3dSkeletonWorld = {
850
+ x: (screenCenterX - (world?.x || 0)) / s,
851
+ y: (screenCenterY - (world?.y || 0)) / s
852
+ };
853
+
854
+ this._model3dSkeleton = new Model3dBoardSkeleton({ iconSvg: ICONS.cube });
855
+ this._model3dSkeleton.attach(this._container ?? document.body);
856
+ this._model3dSkeleton.setRect(this._compute3dSkeletonRect(this._model3dSkeletonWorld), { animate: false });
857
+ this._scheduleAnimationFrame(() => this._model3dSkeleton?.enter());
858
+ }
859
+
860
+ _sync3dSkeletonToViewport({ disableTransition = false } = {}) {
861
+ if (!this._model3dSkeleton?.isAttached() || !this._model3dSkeletonWorld) return;
862
+ this._model3dSkeleton.setRect(
863
+ this._compute3dSkeletonRect(this._model3dSkeletonWorld),
864
+ { animate: !disableTransition }
865
+ );
866
+ }
867
+
868
+ _clear3dSkeleton() {
869
+ this._model3dSkeleton?.destroy();
870
+ this._model3dSkeleton = null;
871
+ this._model3dSkeletonWorld = null;
872
+ }
873
+
874
+ _place3dModelOnBoard(result) {
875
+ if (!this._boardCore?.eventBus) return;
876
+ const { previewBase64, mimeType, modelUrl, format } = result;
877
+ const src = previewBase64
878
+ ? `data:${mimeType || 'image/jpeg'};base64,${previewBase64}`
879
+ : modelUrl;
880
+ if (!src) return;
881
+
882
+ const world = this._boardCore.pixi?.worldLayer || this._boardCore.pixi?.app?.stage;
883
+ const s = world?.scale?.x || 1;
884
+
885
+ // Приоритет — мировая точка скелетона: модель приземляется ровно туда,
886
+ // где пользователь видел заглушку, даже если он панорамировал/зумил во время генерации.
887
+ let x;
888
+ let y;
889
+ if (this._model3dSkeletonWorld) {
890
+ x = Math.round(this._model3dSkeletonWorld.x * s + (world?.x || 0));
891
+ y = Math.round(this._model3dSkeletonWorld.y * s + (world?.y || 0));
892
+ } else {
893
+ const composerRect = this._refs?.composer?.getBoundingClientRect?.();
894
+ x = composerRect ? Math.round(composerRect.left + composerRect.width / 2) : 400;
895
+ y = composerRect ? Math.round(composerRect.top - 200) : 200;
896
+ }
897
+
898
+ this._boardCore.eventBus.emit(Events.UI.PasteImageAt, {
899
+ x,
900
+ y,
901
+ src,
902
+ name: 'ai-3d-model',
903
+ skipUpload: true,
904
+ objectType: 'model3d-screenshot-img',
905
+ modelUrl,
906
+ format
907
+ });
908
+ }
909
+
313
910
  _clearBoardSelection() {
314
911
  if (typeof this._boardCore?.selectTool?.clearSelection === 'function') {
315
912
  this._boardCore.selectTool.clearSelection();
@@ -343,7 +940,9 @@ export class ChatWindow {
343
940
  const list = await this._aiClient.listProviders();
344
941
  this._providers = Array.isArray(list) ? list : [];
345
942
  this._session.setAvailableProviders(list);
346
- this._clampFormatToModel();
943
+ // Серверные supportedRatios могут дополнительно сузить список —
944
+ // после загрузки ещё раз клампим настройки на случай несоответствия.
945
+ this._clampSettingsToModel();
347
946
  } catch (err) {
348
947
  console.warn('[ChatWindow] cannot load providers:', err.message);
349
948
  this._providers = [];
@@ -352,52 +951,251 @@ export class ChatWindow {
352
951
  }
353
952
 
354
953
  /**
355
- * Возвращает id провайдера изображений для текущей модели.
356
- * Дублирует логику из _getImageRequestOptions, чтобы не привязываться к payload-контексту.
357
- *
358
- * @param {string} modelId
359
- * @returns {string}
360
- */
361
- _imageProviderForModel(modelId) {
362
- return modelId === 'gpt' ? 'openai-image' : 'yandex-art';
363
- }
364
-
365
- /**
366
- * Возвращает подмножество FORMAT_OPTIONS, поддерживаемое текущим провайдером.
367
- * Если провайдер не ограничивает форматы (supportedRatios === null) — возвращает все.
954
+ * Возвращает подмножество FORMAT_OPTIONS, поддерживаемое текущей моделью.
955
+ * Источник истины capability.ratios из imageModelCapabilities.
368
956
  * Формат 'auto' присутствует всегда.
369
957
  *
370
958
  * @returns {Array}
371
959
  */
372
960
  _getSupportedFormatOptions() {
373
- const providerId = this._imageProviderForModel(this._modelId);
374
- const provider = this._providers.find((p) => p.id === providerId);
375
- const ratios = provider?.supportedRatios;
376
-
377
- if (!Array.isArray(ratios) || ratios.length === 0) {
961
+ const cap = getImageModelCapability(this._modelId);
962
+ if (!cap || !Array.isArray(cap.ratios) || cap.ratios.length === 0) {
378
963
  return FORMAT_OPTIONS;
379
964
  }
965
+ return FORMAT_OPTIONS.filter((opt) => opt.id === 'auto' || cap.ratios.includes(opt.id));
966
+ }
380
967
 
381
- return FORMAT_OPTIONS.filter((opt) => opt.id === 'auto' || ratios.includes(opt.id));
968
+ /**
969
+ * Возвращает подмножество COUNT_OPTIONS, поддерживаемое текущей моделью.
970
+ * Опции выше capability.maxCount скрываются.
971
+ *
972
+ * @returns {Array}
973
+ */
974
+ _getSupportedCountOptions() {
975
+ const cap = getImageModelCapability(this._modelId);
976
+ const maxCount = cap?.maxCount ?? 4;
977
+ return COUNT_OPTIONS.filter((opt) => {
978
+ const n = Number.parseInt(opt.id, 10);
979
+ return !Number.isFinite(n) || n <= maxCount;
980
+ });
382
981
  }
383
982
 
384
983
  /**
385
- * При смене модели проверяет, что текущий формат входит в список поддерживаемых.
386
- * Если нетсбрасывает на 'auto'.
984
+ * При смене модели приводит текущий формат, разрешение и количество
985
+ * к допустимым значениям. Невалидное значение — на дефолт.
387
986
  */
388
- _clampFormatToModel() {
389
- const supported = this._getSupportedFormatOptions();
390
- const stillValid = supported.some((opt) => opt.id === this._formatId);
391
- if (!stillValid) {
987
+ _clampSettingsToModel() {
988
+ const cap = getImageModelCapability(this._modelId);
989
+
990
+ // Формат
991
+ const supportedFormats = this._getSupportedFormatOptions();
992
+ if (!supportedFormats.some((opt) => opt.id === this._formatId)) {
392
993
  this._formatId = 'auto';
393
994
  }
394
- // refresh обновляет лейбл из option.label ('Auto'), поэтому _updateFormatPillLabel
395
- // вызывается после — она выставляет человекочитаемый текст 'Соотношение сторон'.
396
995
  this._formatMenu?.refresh();
397
- if (!stillValid) {
398
- this._updateFormatPillIcon();
399
- this._updateFormatPillLabel();
996
+ this._updateFormatPillIcon();
997
+ this._updateFormatPillLabel();
998
+
999
+ // Разрешение
1000
+ const resolutions = cap?.resolutions ?? [];
1001
+ if (resolutions.length === 0) {
1002
+ this._resolutionId = null;
1003
+ } else if (!resolutions.includes(this._resolutionId)) {
1004
+ this._resolutionId = cap.defaultResolution ?? resolutions[0] ?? null;
1005
+ }
1006
+ this._resolutionMenu?.refresh();
1007
+ this._updateResolutionPillLabel();
1008
+
1009
+ // Количество
1010
+ const maxCount = cap?.maxCount ?? 4;
1011
+ const n = Number.parseInt(this._countId, 10);
1012
+ if (Number.isFinite(n) && n > maxCount) {
1013
+ this._countId = '1';
1014
+ }
1015
+ this._countMenu?.refresh();
1016
+ this._updateCountPillIcon();
1017
+ }
1018
+
1019
+ _updateResolutionPillVisibility() {
1020
+ const isVideo = this._contentTypeId === 'video';
1021
+ const cap = isVideo ? getVideoModelCapability(this._videoModelId) : getImageModelCapability(this._modelId);
1022
+ const hasResolutions = Array.isArray(cap?.resolutions) && cap.resolutions.length > 0;
1023
+ const wrapper = this._refs?.resolutionWrapper;
1024
+ if (wrapper) wrapper.style.display = hasResolutions ? '' : 'none';
1025
+ }
1026
+
1027
+ _updateResolutionPillLabel() {
1028
+ const labelEl = this._refs?.resolutionLabel;
1029
+ if (!labelEl) return;
1030
+ labelEl.textContent = this._resolutionId ?? 'Разрешение';
1031
+ }
1032
+
1033
+ _getSupportedVideoFormatOptions() {
1034
+ const cap = getVideoModelCapability(this._videoModelId);
1035
+ return (cap?.ratios ?? ['16:9']).map((r) => ({ id: r, label: r }));
1036
+ }
1037
+
1038
+ _clampVideoSettingsToModel() {
1039
+ const cap = getVideoModelCapability(this._videoModelId);
1040
+
1041
+ const ratios = cap?.ratios ?? [];
1042
+ if (ratios.length > 0 && !ratios.includes(this._videoRatioId)) {
1043
+ this._videoRatioId = ratios[0];
1044
+ }
1045
+ this._formatMenu?.refresh();
1046
+
1047
+ const resolutions = cap?.resolutions ?? [];
1048
+ if (resolutions.length === 0) {
1049
+ this._videoResolutionId = null;
1050
+ } else if (!resolutions.includes(this._videoResolutionId)) {
1051
+ this._videoResolutionId = resolutions[0];
1052
+ }
1053
+ this._resolutionMenu?.refresh();
1054
+ this._updateResolutionPillVisibility();
1055
+
1056
+ const durations = cap?.durations ?? [];
1057
+ if (!durations.includes(this._videoDurationId)) {
1058
+ this._videoDurationId = cap?.defaultDuration ?? durations[0] ?? 5;
400
1059
  }
1060
+ this._videoDurationMenu?.refresh();
1061
+ this._settingsPopupCtrl?.refresh();
1062
+ this._videoToolbarPills?.refresh();
1063
+ }
1064
+
1065
+ _attachVideoPills() {
1066
+ const pillsContainer = this._refs.formatPill?.closest('.moodboard-chat__pills');
1067
+ if (!pillsContainer) return;
1068
+
1069
+ const wrapper = document.createElement('div');
1070
+ wrapper.className = 'moodboard-chat__pill-wrapper';
1071
+ const pill = document.createElement('button');
1072
+ pill.type = 'button';
1073
+ pill.className = 'moodboard-chat__pill';
1074
+ pill.setAttribute('aria-haspopup', 'menu');
1075
+ pill.setAttribute('aria-expanded', 'false');
1076
+ const iconSpan = document.createElement('span');
1077
+ iconSpan.className = 'moodboard-chat__pill-icon-wrap';
1078
+ iconSpan.innerHTML = ICONS.video;
1079
+ const labelEl = document.createElement('span');
1080
+ labelEl.className = 'moodboard-chat__pill-label';
1081
+ labelEl.textContent = `${this._videoDurationId} сек`;
1082
+ pill.appendChild(iconSpan);
1083
+ pill.appendChild(labelEl);
1084
+ const menu = document.createElement('div');
1085
+ menu.className = 'moodboard-chat__menu';
1086
+ menu.setAttribute('role', 'menu');
1087
+ wrapper.appendChild(pill);
1088
+ wrapper.appendChild(menu);
1089
+
1090
+ pillsContainer.appendChild(wrapper);
1091
+ this._videoDurationWrapper = wrapper;
1092
+ this._videoDurationMenu = new ChatPillMenu(
1093
+ { trigger: pill, menu, label: labelEl, icon: iconSpan },
1094
+ {
1095
+ getOptions: () => {
1096
+ const cap = getVideoModelCapability(this._videoModelId);
1097
+ return (cap?.durations ?? []).map((d) => ({ id: String(d), label: `${d} сек` }));
1098
+ },
1099
+ getActiveId: () => String(this._videoDurationId),
1100
+ onSelect: (id) => {
1101
+ this._videoDurationId = Number(id);
1102
+ this._videoDurationMenu.refresh();
1103
+ }
1104
+ }
1105
+ );
1106
+ this._videoDurationMenu.attach();
1107
+ this._videoDurationMenu.refresh();
1108
+ }
1109
+
1110
+ _attachVideoToolbarPills() {
1111
+ const pillsContainer = this._refs.formatPill?.closest('.moodboard-chat__pills');
1112
+ if (!pillsContainer) return;
1113
+ this._videoToolbarPills = new ChatVideoToolbarPills(pillsContainer, {
1114
+ getActive: () => this._contentTypeId === 'video',
1115
+ getModelId: () => this._videoModelId,
1116
+ getState: () => ({
1117
+ seed: this._videoSeed,
1118
+ negativePrompt: this._videoNegativePrompt,
1119
+ audio: this._videoAudio,
1120
+ watermark: this._videoWatermark,
1121
+ cfgScale: this._videoCfgScale,
1122
+ personGeneration: this._videoPersonGeneration,
1123
+ }),
1124
+ onChange: (patch) => {
1125
+ if ('seed' in patch) this._videoSeed = patch.seed;
1126
+ if ('negativePrompt' in patch) this._videoNegativePrompt = patch.negativePrompt;
1127
+ if ('audio' in patch) this._videoAudio = patch.audio;
1128
+ if ('watermark' in patch) this._videoWatermark = patch.watermark;
1129
+ if ('cfgScale' in patch) this._videoCfgScale = patch.cfgScale;
1130
+ if ('personGeneration' in patch) this._videoPersonGeneration = patch.personGeneration;
1131
+ this._videoToolbarPills?.refresh();
1132
+ },
1133
+ });
1134
+ this._videoToolbarPills.attach();
1135
+ this._videoToolbarPills.refresh();
1136
+ }
1137
+
1138
+ async _submitVideo(text, attachments) {
1139
+ if (!text) return;
1140
+ if (!this._videoSession) {
1141
+ this._videoSession = new VideoSessionController({ aiClient: this._aiClient });
1142
+ }
1143
+ if (this._videoUnsubscribe) {
1144
+ this._videoUnsubscribe();
1145
+ this._videoUnsubscribe = null;
1146
+ }
1147
+ this._videoUnsubscribe = this._videoSession.subscribe((state) => this._onVideoState(state));
1148
+ const opts = this._getVideoRequestOptions();
1149
+ void this._videoSession.start({ ...opts, prompt: text, referenceImages: attachments?.length ? attachments : undefined });
1150
+ }
1151
+
1152
+ _onVideoState(state) {
1153
+ if (state.status === 'submitting' || state.status === 'polling') {
1154
+ this._composer?.setStreaming(true);
1155
+ }
1156
+ if (state.status === 'done') {
1157
+ this._composer?.setStreaming(false);
1158
+ // Тип объекта «видео» на доске не существует. Событие Events.UI.PasteImageAt
1159
+ // ожидает data URL картинки — видео-URL несовместим. Размещение на доске
1160
+ // выходит за рамки текущей фазы (нет объекта типа video).
1161
+ console.info('[ChatWindow] Видео готово. Размещение на доске не поддерживается: нет объекта типа video.', state.result?.videoUrl);
1162
+ if (this._refs?.errorBlock) {
1163
+ this._refs.errorBlock.textContent = 'Видео сгенерировано. Размещение на доске появится в следующей фазе.';
1164
+ this._refs.errorBlock.classList.add('is-visible');
1165
+ }
1166
+ }
1167
+ if (state.status === 'error') {
1168
+ this._composer?.setStreaming(false);
1169
+ if (this._refs?.errorBlock) {
1170
+ const clean = (state.error || 'Ошибка генерации видео').replace(/^AiClient\.\w+\s*\(\d+\):\s*/, '');
1171
+ this._refs.errorBlock.textContent = clean;
1172
+ this._refs.errorBlock.classList.add('is-visible');
1173
+ }
1174
+ }
1175
+ if (state.status === 'idle') {
1176
+ this._composer?.setStreaming(false);
1177
+ }
1178
+ }
1179
+
1180
+ _getVideoRequestOptions() {
1181
+ const cap = getVideoModelCapability(this._videoModelId);
1182
+ const opts = {
1183
+ provider: cap?.provider ?? 'gemini-video',
1184
+ model: cap?.model,
1185
+ ratio: this._videoRatioId,
1186
+ duration: this._videoDurationId,
1187
+ };
1188
+ if (cap?.resolutions?.length > 0 && this._videoResolutionId) {
1189
+ opts.resolution = this._videoResolutionId;
1190
+ }
1191
+ const sup = cap?.supports ?? {};
1192
+ if (sup.seed && this._videoSeed != null) opts.seed = this._videoSeed;
1193
+ if (sup.negativePrompt && this._videoNegativePrompt) opts.negativePrompt = this._videoNegativePrompt;
1194
+ if (sup.audio) opts.audio = this._videoAudio;
1195
+ if (sup.watermark) opts.watermark = this._videoWatermark;
1196
+ if (sup.cfgScale && this._videoCfgScale != null) opts.cfgScale = this._videoCfgScale;
1197
+ if (sup.personGeneration) opts.personGeneration = this._videoPersonGeneration;
1198
+ return opts;
401
1199
  }
402
1200
 
403
1201
  _render(state) {
@@ -413,6 +1211,8 @@ export class ChatWindow {
413
1211
  this._formatMenu.refresh();
414
1212
  this._updateFormatPillIcon();
415
1213
  this._updateFormatPillLabel();
1214
+ this._resolutionMenu?.refresh();
1215
+ this._updateResolutionPillLabel();
416
1216
  this._countMenu.refresh();
417
1217
  this._updateCountPillIcon();
418
1218
  this._composer.setStreaming(state.status === 'streaming');
@@ -754,14 +1554,31 @@ export class ChatWindow {
754
1554
 
755
1555
  _getImageRequestOptions() {
756
1556
  const [widthRatio, heightRatio] = parseFormatRatio(this._formatId);
757
- const provider = this._modelId === 'gpt' ? 'openai-image' : 'yandex-art';
758
- return {
759
- provider,
760
- widthRatio,
761
- heightRatio,
762
- model: this._modelId === 'yandex' ? 'yandex-art' : undefined,
763
- imageCount: parseImageCount(this._countId)
764
- };
1557
+ const cap = getImageModelCapability(this._modelId);
1558
+ const provider = cap?.provider ?? 'yandex-art';
1559
+ const model = cap?.model;
1560
+ const imageCount = parseImageCount(this._countId);
1561
+
1562
+ const opts = { provider, widthRatio, heightRatio, model, imageCount };
1563
+
1564
+ // Качество (фиксировано для gpt-image-2-*)
1565
+ if (cap?.quality != null) opts.quality = cap.quality;
1566
+
1567
+ // Разрешение (только если модель поддерживает выбор)
1568
+ if (cap?.resolutions?.length > 0 && this._resolutionId) {
1569
+ opts.resolution = this._resolutionId;
1570
+ }
1571
+
1572
+ // Доп-параметры — только поддерживаемые моделью
1573
+ const sup = cap?.supports ?? {};
1574
+ if (sup.seed && this._imageSeed != null) opts.seed = this._imageSeed;
1575
+ if (sup.negativePrompt && this._imageNegativePrompt) opts.negativePrompt = this._imageNegativePrompt;
1576
+ if (sup.background && this._imageBackground) opts.background = this._imageBackground;
1577
+ if (sup.outputFormat && this._imageOutputFormat) opts.outputFormat = this._imageOutputFormat;
1578
+ if (sup.promptExtend) opts.promptExtend = this._imagePromptExtend;
1579
+ if (sup.watermark) opts.watermark = this._imageWatermark;
1580
+
1581
+ return opts;
765
1582
  }
766
1583
 
767
1584
  _computePendingOverlayScreenLayout(messages, messageId, scale = 1) {
@@ -862,9 +1679,11 @@ export class ChatWindow {
862
1679
 
863
1680
  const onPanUpdate = () => {
864
1681
  this._syncPendingOverlaysToViewport({ disableTransition: true });
1682
+ this._sync3dSkeletonToViewport({ disableTransition: true });
865
1683
  };
866
1684
  const onViewportChange = () => {
867
1685
  this._syncPendingOverlaysToViewport({ disableTransition: true, recomputeWorld: false });
1686
+ this._sync3dSkeletonToViewport({ disableTransition: true });
868
1687
  // Зум меняет проекцию композера в мир: после него существующее изображение может
869
1688
  // оказаться под заглушкой. Перепроверяем инвариант сразу, не дожидаясь рендера сессии.
870
1689
  this._ensureExistingImagesClearOfPending();
@@ -1006,9 +1825,11 @@ export class ChatWindow {
1006
1825
  const onSelectionRemove = (data) => {
1007
1826
  const objectId = data?.object;
1008
1827
  if (objectId) this._composer?.removeAttachmentForObject?.(objectId);
1828
+ this._update3dSendGate();
1009
1829
  };
1010
1830
  const onSelectionClear = () => {
1011
1831
  this._composer?.removeAllBoardAttachments?.();
1832
+ this._update3dSendGate();
1012
1833
  };
1013
1834
  const onBoxSelectStart = () => {
1014
1835
  this._boxSelectActive = true;
@@ -1139,8 +1960,8 @@ export class ChatWindow {
1139
1960
  return null;
1140
1961
  }
1141
1962
 
1142
- _clampImageGroupAnchorY(y, referenceHeight) {
1143
- const clearance = Math.round(referenceHeight / 2) + BOARD_IMAGE_LANE_UI_GAP;
1963
+ _clampImageGroupAnchorY(y, referenceHeight, reserveY = 0) {
1964
+ const clearance = Math.round(referenceHeight / 2) + BOARD_IMAGE_LANE_UI_GAP + reserveY;
1144
1965
 
1145
1966
  const errorBlock = this._refs?.errorBlock;
1146
1967
  if (errorBlock && errorBlock.classList.contains('is-visible')) {
@@ -1174,10 +1995,16 @@ export class ChatWindow {
1174
1995
  const existingCenterY = this._getAiImageLaneCenterScreenY();
1175
1996
  const [wr, hr] = parseFormatRatio(this._formatId);
1176
1997
  const actualHeight = Math.round(BOARD_IMAGE_WIDTH / (wr / hr));
1177
- let y = existingCenterY ?? (composerRect.top - Math.round(actualHeight / 2) - BOARD_IMAGE_LANE_UI_GAP);
1998
+
1999
+ let reserveY = 0;
2000
+ if (this._refs?.attachmentsPreview && !this._refs.attachmentsPreview.classList.contains('is-visible')) {
2001
+ reserveY = 74; // Резервируем место под ряд вложений (60px + padding)
2002
+ }
2003
+
2004
+ let y = existingCenterY ?? (composerRect.top - reserveY - Math.round(actualHeight / 2) - BOARD_IMAGE_LANE_UI_GAP);
1178
2005
 
1179
2006
  if (existingCenterY == null) {
1180
- y = this._clampImageGroupAnchorY(y, actualHeight);
2007
+ y = this._clampImageGroupAnchorY(y, actualHeight, reserveY);
1181
2008
  }
1182
2009
 
1183
2010
  return {
@@ -1691,7 +2518,7 @@ function parseImageCount(countId) {
1691
2518
  return 1;
1692
2519
  }
1693
2520
 
1694
- return Math.min(Math.max(count, 1), 4);
2521
+ return Math.min(Math.max(count, 1), 6);
1695
2522
  }
1696
2523
 
1697
2524
  function findImageGenerationBatch(messages, messageId) {
@@ -1762,7 +2589,7 @@ function rectsOverlap(a, b) {
1762
2589
 
1763
2590
  function isReferenceImageObject(object) {
1764
2591
  return Boolean(object?.id)
1765
- && (object.type === 'image' || object.type === 'revit-screenshot-img')
2592
+ && (object.type === 'image' || object.type === 'revit-screenshot-img' || object.type === 'model3d-screenshot-img')
1766
2593
  && typeof getImageObjectSource(object) === 'string';
1767
2594
  }
1768
2595