@sequent-org/moodboard 1.4.47 → 1.4.49

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.
@@ -2,6 +2,9 @@ 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
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';
5
8
  import { Events } from '../../core/events/Events.js';
6
9
 
7
10
  import { buildChatDom } from './ChatWindowRenderer.js';
@@ -9,6 +12,8 @@ import { formatChatErrorForDisplay } from './formatChatError.js';
9
12
  import { ChatMessageList } from './ChatMessageList.js';
10
13
  import { ChatComposer } from './ChatComposer.js';
11
14
  import { ChatPillMenu } from './ChatPillMenu.js';
15
+ import { ChatSettingsPopup } from './ChatSettingsPopup.js';
16
+ import { ChatVideoToolbarPills } from './ChatVideoToolbarPills.js';
12
17
  import { ChatExtendedPromptModal } from './ChatExtendedPromptModal.js';
13
18
  import { Model3dProgressOverlay } from './Model3dProgressOverlay.js';
14
19
  import { Model3dBoardSkeleton } from './Model3dBoardSkeleton.js';
@@ -19,19 +24,19 @@ const CONTENT_TYPE_OPTIONS = [
19
24
  id: 'image',
20
25
  label: 'Изображение',
21
26
  icon: ICONS.image,
22
- description: 'Создать по тексту или приложите ссылку'
27
+ description: 'По описанию или ссылке на референс'
23
28
  },
24
29
  {
25
30
  id: 'video',
26
31
  label: 'Видео',
27
32
  icon: ICONS.video,
28
- description: 'Создать по тексту или руководствуясь первым и последним кадром'
33
+ description: 'По описанию или по первому и последнему кадру'
29
34
  },
30
35
  {
31
36
  id: '3d',
32
37
  label: '3D-модель',
33
38
  icon: ICONS.cube,
34
- description: 'Создать 3D-модель по выбранному изображению'
39
+ description: 'По выбранному изображению'
35
40
  }
36
41
  ];
37
42
 
@@ -93,21 +98,27 @@ const FORMAT_OPTIONS = [
93
98
  { id: '2:3', label: '2:3', icon: RATIO_ICONS['2:3'] },
94
99
  { id: '9:16', label: '9:16', icon: RATIO_ICONS['9:16'] },
95
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'] },
96
103
  { id: '5:4', label: '5:4', icon: RATIO_ICONS['5:4'] },
97
104
  { id: '4:3', label: '4:3', icon: RATIO_ICONS['4:3'] },
98
105
  { id: '14:10', label: '14:10', icon: RATIO_ICONS['14:10'] },
99
106
  { id: '3:2', label: '3:2', icon: RATIO_ICONS['3:2'] },
100
107
  { id: '16:9', label: '16:9', icon: RATIO_ICONS['16:9'] },
101
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'] },
102
112
  { id: 'auto', label: 'Auto', icon: RATIO_ICONS['auto'] },
103
113
  ];
104
114
 
105
115
  const COUNT_OPTIONS = [
106
- { id: 'auto', label: 'Авто', icon: COUNT_ICONS.auto },
107
116
  { id: '1', label: '1 Изображение', icon: COUNT_ICONS[1] },
108
117
  { id: '2', label: '2 Изображения', icon: COUNT_ICONS[2] },
109
118
  { id: '3', label: '3 Изображения', icon: COUNT_ICONS[3] },
110
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] },
111
122
  ];
112
123
 
113
124
  const BOARD_IMAGE_WIDTH = 300;
@@ -124,38 +135,28 @@ const BOARD_IMAGE_LANE_REFERENCE_RATIO = [2, 3];
124
135
  const BOARD_IMAGE_LANE_CENTER_OFFSET = 250;
125
136
  const BOARD_IMAGE_LANE_UI_GAP = 16;
126
137
 
127
- const MODEL_OPTIONS = [
128
- {
129
- id: 'auto',
130
- label: 'Автоматический режим',
131
- icon: ICONS.modelBot,
132
- description: 'Мы подберем модель для ваших задач.'
133
- },
134
- {
135
- id: 'yandex',
136
- label: 'Алиса',
137
- icon: ICONS.modelAlice,
138
- description: 'YandexGPT'
139
- },
140
- {
141
- id: 'gpt',
142
- label: 'GPT',
143
- icon: ICONS.modelGpt,
144
- description: 'OpenAI'
145
- },
146
- {
147
- id: 'google',
148
- label: 'Google',
149
- icon: ICONS.modelGoogle,
150
- description: 'Gemini'
151
- },
152
- {
153
- id: 'qwen',
154
- label: 'Qwen',
155
- icon: '<img src="/icons/qwen.svg" alt="" aria-hidden="true">',
156
- 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;
157
147
  }
158
- ];
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
+ }
159
160
 
160
161
  /**
161
162
  * Корневой контейнер чата ИИ-ассистента.
@@ -193,13 +194,25 @@ export class ChatWindow {
193
194
  this._extendedPromptModal = null;
194
195
  this._contentTypeId = 'image';
195
196
  this._contentTypeMenu = null;
196
- this._modelId = 'auto';
197
+ this._modelId = 'nano-banana-pro';
197
198
  this._modelMenu = null;
198
199
  this._formatId = 'auto';
199
200
  this._formatMenu = null;
200
201
  this._providers = [];
201
- this._countId = 'auto';
202
+ this._countId = '1';
202
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;
203
216
  this._unsubscribe = null;
204
217
  this._attached = false;
205
218
  this._3dFaceCountId = '1m';
@@ -241,6 +254,23 @@ export class ChatWindow {
241
254
  // приходит на каждый mousemove и не должен пушить превью в чат —
242
255
  // финальный набор картинок мы получим из BoxSelectCommit по strict-contains.
243
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;
244
274
  }
245
275
 
246
276
  attach() {
@@ -264,10 +294,19 @@ export class ChatWindow {
264
294
  if (this._contentTypeId === '3d') {
265
295
  return this._submit3d(text, attachments);
266
296
  }
297
+ if (this._contentTypeId === 'video') {
298
+ return this._submitVideo(text, attachments);
299
+ }
267
300
  this._clearBoardSelection();
268
301
  return this._session.send(text, { ...this._getImageRequestOptions(), referenceImages: attachments });
269
302
  },
270
- onAbort: () => this._session.abort()
303
+ onAbort: () => {
304
+ if (this._contentTypeId === 'video') {
305
+ this._videoSession?.abort();
306
+ } else {
307
+ this._session.abort();
308
+ }
309
+ }
271
310
  }
272
311
  );
273
312
  this._composer.attach();
@@ -298,6 +337,10 @@ export class ChatWindow {
298
337
  this._contentTypeMenu.refresh();
299
338
  this._update3dPillVisibility();
300
339
  this._update3dSendGate();
340
+ this._modelMenu?.refresh();
341
+ this._formatMenu?.refresh();
342
+ this._updateResolutionPillVisibility();
343
+ this._settingsPopupCtrl?.refresh();
301
344
  }
302
345
  }
303
346
  );
@@ -306,12 +349,24 @@ export class ChatWindow {
306
349
  this._modelMenu = new ChatPillMenu(
307
350
  { trigger: this._refs.modelPill, menu: this._refs.modelMenu, label: this._refs.modelLabel, icon: this._refs.modelIcon },
308
351
  {
309
- getOptions: () => MODEL_OPTIONS,
310
- 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,
311
359
  onSelect: (id) => {
312
- 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
+ }
313
367
  this._modelMenu.refresh();
314
- this._clampFormatToModel();
368
+ this._updateResolutionPillVisibility();
369
+ this._settingsPopupCtrl?.refresh();
315
370
  }
316
371
  }
317
372
  );
@@ -320,22 +375,52 @@ export class ChatWindow {
320
375
  this._formatMenu = new ChatPillMenu(
321
376
  { trigger: this._refs.formatPill, menu: this._refs.formatMenu, label: this._refs.formatLabel },
322
377
  {
323
- getOptions: () => this._getSupportedFormatOptions(),
324
- getActiveId: () => this._formatId,
378
+ getOptions: () => this._contentTypeId === 'video' ? this._getSupportedVideoFormatOptions() : this._getSupportedFormatOptions(),
379
+ getActiveId: () => this._contentTypeId === 'video' ? this._videoRatioId : this._formatId,
325
380
  onSelect: (id) => {
326
- 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
+ }
327
388
  this._formatMenu.refresh();
328
- this._updateFormatPillIcon();
329
- this._updateFormatPillLabel();
330
389
  }
331
390
  }
332
391
  );
333
392
  this._formatMenu.attach();
334
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
+
335
420
  this._countMenu = new ChatPillMenu(
336
421
  { trigger: this._refs.countPill, menu: this._refs.countMenu, label: this._refs.countLabel, icon: this._refs.countIcon },
337
422
  {
338
- getOptions: () => COUNT_OPTIONS,
423
+ getOptions: () => this._getSupportedCountOptions(),
339
424
  getActiveId: () => this._countId,
340
425
  onSelect: (id) => {
341
426
  this._countId = id;
@@ -346,7 +431,57 @@ export class ChatWindow {
346
431
  );
347
432
  this._countMenu.attach();
348
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
+
349
482
  this._attach3dPills();
483
+ this._attachVideoPills();
484
+ this._attachVideoToolbarPills();
350
485
  this._update3dPillVisibility();
351
486
 
352
487
  const initialState = this._session.getState();
@@ -400,12 +535,25 @@ export class ChatWindow {
400
535
  this._3dResultFormatMenu = null;
401
536
  this._3dResultFormatWrapper?.remove();
402
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;
403
547
  this._composer?.destroy();
404
548
  this._extendedPromptModal?.destroy();
405
549
  this._contentTypeMenu?.destroy();
406
550
  this._modelMenu?.destroy();
407
551
  this._formatMenu?.destroy();
552
+ this._resolutionMenu?.destroy();
553
+ this._resolutionMenu = null;
408
554
  this._countMenu?.destroy();
555
+ this._settingsPopupCtrl?.destroy();
556
+ this._settingsPopupCtrl = null;
409
557
  if (this._refs?.root && this._refs.root.parentNode === this._container) {
410
558
  this._container.removeChild(this._refs.root);
411
559
  }
@@ -520,12 +668,20 @@ export class ChatWindow {
520
668
 
521
669
  _update3dPillVisibility() {
522
670
  const is3d = this._contentTypeId === '3d';
671
+ const isVideo = this._contentTypeId === 'video';
523
672
  const formatWrapper = this._refs.formatPill?.closest('.moodboard-chat__pill-wrapper');
524
673
  const countWrapper = this._refs.countPill?.closest('.moodboard-chat__pill-wrapper');
525
674
  const modelWrapper = this._refs.modelPill?.closest('.moodboard-chat__pill-wrapper');
526
675
  if (formatWrapper) formatWrapper.style.display = is3d ? 'none' : '';
527
- if (countWrapper) countWrapper.style.display = is3d ? 'none' : '';
676
+ // В видео-режиме счётчик не нужен: у всех видео-моделей maxCount=1
677
+ if (countWrapper) countWrapper.style.display = (is3d || isVideo) ? 'none' : '';
528
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
+ }
529
685
  if (this._3dFaceCountWrapper) this._3dFaceCountWrapper.style.display = is3d ? '' : 'none';
530
686
  if (this._3dTypeWrapper) this._3dTypeWrapper.style.display = is3d ? '' : 'none';
531
687
  if (this._3dModeWrapper) this._3dModeWrapper.style.display = is3d ? '' : 'none';
@@ -534,6 +690,10 @@ export class ChatWindow {
534
690
  if (this._3dTextureStyleWrapper) {
535
691
  this._3dTextureStyleWrapper.style.display = (is3d && this._3dMode === 'text') ? '' : 'none';
536
692
  }
693
+ // Длительность — только в видео-режиме
694
+ if (this._videoDurationWrapper) this._videoDurationWrapper.style.display = isVideo ? '' : 'none';
695
+ // Видео-настройки (Звук/Водяной знак/Люди/Seed/CFG/Негатив) — пилюли тулбара
696
+ this._videoToolbarPills?.refresh();
537
697
  }
538
698
 
539
699
  _update3dSendGate() {
@@ -780,7 +940,9 @@ export class ChatWindow {
780
940
  const list = await this._aiClient.listProviders();
781
941
  this._providers = Array.isArray(list) ? list : [];
782
942
  this._session.setAvailableProviders(list);
783
- this._clampFormatToModel();
943
+ // Серверные supportedRatios могут дополнительно сузить список —
944
+ // после загрузки ещё раз клампим настройки на случай несоответствия.
945
+ this._clampSettingsToModel();
784
946
  } catch (err) {
785
947
  console.warn('[ChatWindow] cannot load providers:', err.message);
786
948
  this._providers = [];
@@ -789,52 +951,251 @@ export class ChatWindow {
789
951
  }
790
952
 
791
953
  /**
792
- * Возвращает id провайдера изображений для текущей модели.
793
- * Дублирует логику из _getImageRequestOptions, чтобы не привязываться к payload-контексту.
794
- *
795
- * @param {string} modelId
796
- * @returns {string}
797
- */
798
- _imageProviderForModel(modelId) {
799
- return modelId === 'gpt' ? 'openai-image' : 'yandex-art';
800
- }
801
-
802
- /**
803
- * Возвращает подмножество FORMAT_OPTIONS, поддерживаемое текущим провайдером.
804
- * Если провайдер не ограничивает форматы (supportedRatios === null) — возвращает все.
954
+ * Возвращает подмножество FORMAT_OPTIONS, поддерживаемое текущей моделью.
955
+ * Источник истины capability.ratios из imageModelCapabilities.
805
956
  * Формат 'auto' присутствует всегда.
806
957
  *
807
958
  * @returns {Array}
808
959
  */
809
960
  _getSupportedFormatOptions() {
810
- const providerId = this._imageProviderForModel(this._modelId);
811
- const provider = this._providers.find((p) => p.id === providerId);
812
- const ratios = provider?.supportedRatios;
813
-
814
- if (!Array.isArray(ratios) || ratios.length === 0) {
961
+ const cap = getImageModelCapability(this._modelId);
962
+ if (!cap || !Array.isArray(cap.ratios) || cap.ratios.length === 0) {
815
963
  return FORMAT_OPTIONS;
816
964
  }
965
+ return FORMAT_OPTIONS.filter((opt) => opt.id === 'auto' || cap.ratios.includes(opt.id));
966
+ }
817
967
 
818
- 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
+ });
819
981
  }
820
982
 
821
983
  /**
822
- * При смене модели проверяет, что текущий формат входит в список поддерживаемых.
823
- * Если нетсбрасывает на 'auto'.
984
+ * При смене модели приводит текущий формат, разрешение и количество
985
+ * к допустимым значениям. Невалидное значение — на дефолт.
824
986
  */
825
- _clampFormatToModel() {
826
- const supported = this._getSupportedFormatOptions();
827
- const stillValid = supported.some((opt) => opt.id === this._formatId);
828
- 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)) {
829
993
  this._formatId = 'auto';
830
994
  }
831
- // refresh обновляет лейбл из option.label ('Auto'), поэтому _updateFormatPillLabel
832
- // вызывается после — она выставляет человекочитаемый текст 'Соотношение сторон'.
833
995
  this._formatMenu?.refresh();
834
- if (!stillValid) {
835
- this._updateFormatPillIcon();
836
- 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];
837
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;
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;
838
1199
  }
839
1200
 
840
1201
  _render(state) {
@@ -850,6 +1211,8 @@ export class ChatWindow {
850
1211
  this._formatMenu.refresh();
851
1212
  this._updateFormatPillIcon();
852
1213
  this._updateFormatPillLabel();
1214
+ this._resolutionMenu?.refresh();
1215
+ this._updateResolutionPillLabel();
853
1216
  this._countMenu.refresh();
854
1217
  this._updateCountPillIcon();
855
1218
  this._composer.setStreaming(state.status === 'streaming');
@@ -1191,14 +1554,31 @@ export class ChatWindow {
1191
1554
 
1192
1555
  _getImageRequestOptions() {
1193
1556
  const [widthRatio, heightRatio] = parseFormatRatio(this._formatId);
1194
- const provider = this._modelId === 'gpt' ? 'openai-image' : 'yandex-art';
1195
- return {
1196
- provider,
1197
- widthRatio,
1198
- heightRatio,
1199
- model: this._modelId === 'yandex' ? 'yandex-art' : undefined,
1200
- imageCount: parseImageCount(this._countId)
1201
- };
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;
1202
1582
  }
1203
1583
 
1204
1584
  _computePendingOverlayScreenLayout(messages, messageId, scale = 1) {
@@ -2138,7 +2518,7 @@ function parseImageCount(countId) {
2138
2518
  return 1;
2139
2519
  }
2140
2520
 
2141
- return Math.min(Math.max(count, 1), 4);
2521
+ return Math.min(Math.max(count, 1), 6);
2142
2522
  }
2143
2523
 
2144
2524
  function findImageGenerationBatch(messages, messageId) {