@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
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Контроллер сессии генерации видео.
|
|
3
|
+
*
|
|
4
|
+
* Одна ответственность: оркестрирует submit -> poll-цикл через AiClient,
|
|
5
|
+
* держит состояние джоба и уведомляет подписчиков об изменениях.
|
|
6
|
+
* Не знает про DOM, доску и UI.
|
|
7
|
+
*
|
|
8
|
+
* Состояние:
|
|
9
|
+
* - status: 'idle' | 'submitting' | 'polling' | 'done' | 'error'
|
|
10
|
+
* - progress: number (0–100)
|
|
11
|
+
* - error: string | null
|
|
12
|
+
* - jobId: string | null
|
|
13
|
+
* - result: { videoUrl: string, mimeType: string } | null
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
const POLL_INTERVAL_MS = 3000;
|
|
17
|
+
const JOB_TIMEOUT_MS = 300_000; // 300с — видео генерируется до нескольких минут
|
|
18
|
+
|
|
19
|
+
export class VideoSessionController {
|
|
20
|
+
/**
|
|
21
|
+
* @param {object} deps
|
|
22
|
+
* @param {import('./AiClient.js').AiClient} deps.aiClient
|
|
23
|
+
*/
|
|
24
|
+
constructor({ aiClient }) {
|
|
25
|
+
this._client = aiClient;
|
|
26
|
+
this._listeners = new Set();
|
|
27
|
+
this._abort = null;
|
|
28
|
+
|
|
29
|
+
this._state = {
|
|
30
|
+
status: 'idle',
|
|
31
|
+
progress: 0,
|
|
32
|
+
error: null,
|
|
33
|
+
jobId: null,
|
|
34
|
+
result: null,
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
getState() {
|
|
39
|
+
return this._state;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* @param {function} listener
|
|
44
|
+
* @returns {function} unsubscribe
|
|
45
|
+
*/
|
|
46
|
+
subscribe(listener) {
|
|
47
|
+
this._listeners.add(listener);
|
|
48
|
+
return () => this._listeners.delete(listener);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Запускает джоб генерации видео.
|
|
53
|
+
* @param {object} args
|
|
54
|
+
* @param {string} [args.provider='gemini-video']
|
|
55
|
+
* @param {string} args.prompt
|
|
56
|
+
* @param {string} [args.negativePrompt]
|
|
57
|
+
* @param {string} [args.model]
|
|
58
|
+
* @param {string} [args.ratio]
|
|
59
|
+
* @param {string} [args.resolution]
|
|
60
|
+
* @param {number} [args.duration]
|
|
61
|
+
* @param {number} [args.seed]
|
|
62
|
+
* @param {File[]} [args.referenceImages]
|
|
63
|
+
* @param {AbortSignal} [args.signal]
|
|
64
|
+
* @returns {Promise<void>}
|
|
65
|
+
*/
|
|
66
|
+
async start({ provider = 'gemini-video', signal: externalSignal, ...rest } = {}) {
|
|
67
|
+
if (this._abort) this.abort();
|
|
68
|
+
|
|
69
|
+
const abort = new AbortController();
|
|
70
|
+
this._abort = abort;
|
|
71
|
+
const { signal } = abort;
|
|
72
|
+
|
|
73
|
+
if (externalSignal) {
|
|
74
|
+
if (externalSignal.aborted) { abort.abort(); return; }
|
|
75
|
+
externalSignal.addEventListener('abort', () => abort.abort(), { once: true });
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
this._setState({ status: 'submitting', progress: 0, error: null, jobId: null, result: null });
|
|
79
|
+
|
|
80
|
+
const timeoutId = setTimeout(() => {
|
|
81
|
+
if (this._abort === abort) {
|
|
82
|
+
abort.abort();
|
|
83
|
+
this._setState({ status: 'error', error: 'Таймаут: джоб превысил 300 секунд' });
|
|
84
|
+
}
|
|
85
|
+
}, JOB_TIMEOUT_MS);
|
|
86
|
+
|
|
87
|
+
try {
|
|
88
|
+
const { jobId } = await this._client.submitVideo({ provider, signal, ...rest });
|
|
89
|
+
if (signal.aborted) return;
|
|
90
|
+
|
|
91
|
+
this._setState({ status: 'polling', jobId });
|
|
92
|
+
|
|
93
|
+
const result = await this._pollLoop(jobId, signal, provider);
|
|
94
|
+
if (!result || signal.aborted) return;
|
|
95
|
+
|
|
96
|
+
const { videoUrl, mimeType } = result;
|
|
97
|
+
this._setState({ status: 'done', progress: 100, result: { videoUrl, mimeType } });
|
|
98
|
+
} catch (err) {
|
|
99
|
+
if (err?.name === 'AbortError' || signal.aborted) return;
|
|
100
|
+
this._setState({ status: 'error', error: err?.message || 'Ошибка запроса' });
|
|
101
|
+
} finally {
|
|
102
|
+
clearTimeout(timeoutId);
|
|
103
|
+
if (this._abort === abort) this._abort = null;
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** Прерывает submit или поллинг. */
|
|
108
|
+
abort() {
|
|
109
|
+
if (this._abort) {
|
|
110
|
+
try { this._abort.abort(); } catch { /* noop */ }
|
|
111
|
+
this._abort = null;
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
async _pollLoop(jobId, signal, provider) {
|
|
116
|
+
while (!signal.aborted) {
|
|
117
|
+
await sleep(POLL_INTERVAL_MS, signal);
|
|
118
|
+
if (signal.aborted) return null;
|
|
119
|
+
|
|
120
|
+
let data;
|
|
121
|
+
try {
|
|
122
|
+
data = await this._client.pollVideo(jobId, signal, provider);
|
|
123
|
+
} catch (err) {
|
|
124
|
+
if (err?.name === 'AbortError' || signal.aborted) return null;
|
|
125
|
+
this._setState({ status: 'error', error: err?.message || 'Ошибка поллинга' });
|
|
126
|
+
return null;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
const { status, progress, error } = data;
|
|
130
|
+
|
|
131
|
+
if (status === 'done') return data;
|
|
132
|
+
|
|
133
|
+
if (status === 'error') {
|
|
134
|
+
this._setState({ status: 'error', error: error || 'Ошибка джоба' });
|
|
135
|
+
return null;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// 'pending' | 'running' — обновляем прогресс
|
|
139
|
+
this._setState({ progress: progress ?? this._state.progress });
|
|
140
|
+
}
|
|
141
|
+
return null;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
_setState(patch) {
|
|
145
|
+
this._state = { ...this._state, ...patch };
|
|
146
|
+
this._emit();
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
_emit() {
|
|
150
|
+
for (const listener of this._listeners) {
|
|
151
|
+
try { listener(this._state); } catch (err) {
|
|
152
|
+
console.error('[VideoSession] listener error:', err);
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* Promise, который разрешается через ms миллисекунд или отклоняется по AbortSignal.
|
|
160
|
+
* @param {number} ms
|
|
161
|
+
* @param {AbortSignal} [signal]
|
|
162
|
+
*/
|
|
163
|
+
function sleep(ms, signal) {
|
|
164
|
+
return new Promise((resolve, reject) => {
|
|
165
|
+
if (signal?.aborted) {
|
|
166
|
+
reject(new DOMException('Aborted', 'AbortError'));
|
|
167
|
+
return;
|
|
168
|
+
}
|
|
169
|
+
const id = setTimeout(resolve, ms);
|
|
170
|
+
signal?.addEventListener('abort', () => {
|
|
171
|
+
clearTimeout(id);
|
|
172
|
+
reject(new DOMException('Aborted', 'AbortError'));
|
|
173
|
+
}, { once: true });
|
|
174
|
+
});
|
|
175
|
+
}
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Конфигурация возможностей моделей генерации изображений.
|
|
3
|
+
*
|
|
4
|
+
* Одна ответственность: данные и чистые helper-функции.
|
|
5
|
+
* Никакого DOM, никаких импортов UI.
|
|
6
|
+
*
|
|
7
|
+
* @typedef {Object} ImageModelCapability
|
|
8
|
+
* @property {string} id - уникальный идентификатор
|
|
9
|
+
* @property {string} label - подпись в UI (бренд/название модели)
|
|
10
|
+
* @property {string} description - описание (провайдер · техническое имя)
|
|
11
|
+
* @property {string} provider - идентификатор провайдера для бэкенда
|
|
12
|
+
* @property {string} model - код модели для бэкенда
|
|
13
|
+
* @property {string|undefined} quality - 'high' | 'medium' | 'low' | undefined
|
|
14
|
+
* @property {string[]} ratios - поддерживаемые соотношения сторон (id из FORMAT_OPTIONS)
|
|
15
|
+
* @property {string[]} resolutions - допустимые разрешения; [] = выбор не нужен
|
|
16
|
+
* @property {string|null} defaultResolution - дефолтное разрешение или null
|
|
17
|
+
* @property {number} maxCount - максимальное количество изображений в одном запросе
|
|
18
|
+
* @property {{
|
|
19
|
+
* seed: boolean,
|
|
20
|
+
* negativePrompt: boolean,
|
|
21
|
+
* background: boolean,
|
|
22
|
+
* outputFormat: boolean,
|
|
23
|
+
* promptExtend: boolean,
|
|
24
|
+
* watermark: boolean
|
|
25
|
+
* }} supports - флаги поддерживаемых доп-настроек
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
/** @type {ImageModelCapability[]} */
|
|
29
|
+
export const IMAGE_MODELS = [
|
|
30
|
+
{
|
|
31
|
+
id: 'yandex-art',
|
|
32
|
+
label: 'Yandex GPT',
|
|
33
|
+
description: 'Yandex · Шедеврум',
|
|
34
|
+
provider: 'yandex-art',
|
|
35
|
+
model: 'yandex-art',
|
|
36
|
+
quality: undefined,
|
|
37
|
+
ratios: ['1:1', '16:9', '9:16', '3:4', '4:3'],
|
|
38
|
+
resolutions: [],
|
|
39
|
+
defaultResolution: null,
|
|
40
|
+
maxCount: 1,
|
|
41
|
+
supports: { seed: true, negativePrompt: true, background: false, outputFormat: false, promptExtend: false, watermark: false }
|
|
42
|
+
},
|
|
43
|
+
{
|
|
44
|
+
id: 'nano-banana-pro',
|
|
45
|
+
label: 'Nano Banana Pro',
|
|
46
|
+
description: 'Google · Gemini 3 Pro Image',
|
|
47
|
+
provider: 'gemini-image',
|
|
48
|
+
model: 'gemini-3-pro-image',
|
|
49
|
+
quality: undefined,
|
|
50
|
+
ratios: ['1:1', '2:3', '3:2', '3:4', '4:3', '4:5', '5:4', '9:16', '16:9', '21:9'],
|
|
51
|
+
resolutions: ['1K', '2K', '4K'],
|
|
52
|
+
defaultResolution: '1K',
|
|
53
|
+
maxCount: 4,
|
|
54
|
+
supports: { seed: false, negativePrompt: false, background: false, outputFormat: false, promptExtend: false, watermark: false }
|
|
55
|
+
},
|
|
56
|
+
{
|
|
57
|
+
id: 'nano-banana-2',
|
|
58
|
+
label: 'Nano Banana 2',
|
|
59
|
+
description: 'Google · Gemini 3.1 Flash Image',
|
|
60
|
+
provider: 'gemini-image',
|
|
61
|
+
model: 'gemini-3.1-flash-image',
|
|
62
|
+
quality: undefined,
|
|
63
|
+
ratios: ['1:1', '2:3', '3:2', '3:4', '4:3', '4:5', '5:4', '9:16', '16:9', '21:9', '1:4', '4:1', '1:8', '8:1'],
|
|
64
|
+
resolutions: ['512', '1K', '2K', '4K'],
|
|
65
|
+
defaultResolution: '1K',
|
|
66
|
+
maxCount: 4,
|
|
67
|
+
supports: { seed: false, negativePrompt: false, background: false, outputFormat: false, promptExtend: false, watermark: false }
|
|
68
|
+
},
|
|
69
|
+
{
|
|
70
|
+
id: 'gpt-image-2-high',
|
|
71
|
+
label: 'GPT-Image-2 High',
|
|
72
|
+
description: 'OpenAI · gpt-image-2 · High',
|
|
73
|
+
provider: 'openai-image',
|
|
74
|
+
model: 'gpt-image-2',
|
|
75
|
+
quality: 'high',
|
|
76
|
+
ratios: ['1:1', '3:2', '2:3', '16:9', '9:16'],
|
|
77
|
+
resolutions: ['1K', '2K', '4K'],
|
|
78
|
+
defaultResolution: '1K',
|
|
79
|
+
maxCount: 1,
|
|
80
|
+
supports: { seed: false, negativePrompt: false, background: true, outputFormat: true, promptExtend: false, watermark: false }
|
|
81
|
+
},
|
|
82
|
+
{
|
|
83
|
+
id: 'gpt-image-2-medium',
|
|
84
|
+
label: 'GPT-Image-2 Medium',
|
|
85
|
+
description: 'OpenAI · gpt-image-2 · Medium',
|
|
86
|
+
provider: 'openai-image',
|
|
87
|
+
model: 'gpt-image-2',
|
|
88
|
+
quality: 'medium',
|
|
89
|
+
ratios: ['1:1', '3:2', '2:3', '16:9', '9:16'],
|
|
90
|
+
resolutions: ['1K', '2K', '4K'],
|
|
91
|
+
defaultResolution: '1K',
|
|
92
|
+
maxCount: 1,
|
|
93
|
+
supports: { seed: false, negativePrompt: false, background: true, outputFormat: true, promptExtend: false, watermark: false }
|
|
94
|
+
},
|
|
95
|
+
{
|
|
96
|
+
id: 'gpt-image-2-low',
|
|
97
|
+
label: 'GPT-Image-2 Low',
|
|
98
|
+
description: 'OpenAI · gpt-image-2 · Low',
|
|
99
|
+
provider: 'openai-image',
|
|
100
|
+
model: 'gpt-image-2',
|
|
101
|
+
quality: 'low',
|
|
102
|
+
ratios: ['1:1', '3:2', '2:3', '16:9', '9:16'],
|
|
103
|
+
resolutions: ['1K', '2K', '4K'],
|
|
104
|
+
defaultResolution: '1K',
|
|
105
|
+
maxCount: 1,
|
|
106
|
+
supports: { seed: false, negativePrompt: false, background: true, outputFormat: true, promptExtend: false, watermark: false }
|
|
107
|
+
},
|
|
108
|
+
{
|
|
109
|
+
id: 'qwen-image-2-pro',
|
|
110
|
+
label: 'Qwen Image 2 Pro',
|
|
111
|
+
description: 'Alibaba · qwen-image-2.0-pro',
|
|
112
|
+
provider: 'qwen-image',
|
|
113
|
+
model: 'qwen-image-2.0-pro',
|
|
114
|
+
quality: undefined,
|
|
115
|
+
ratios: ['1:1', '16:9', '9:16', '4:3', '3:4', '2:3', '3:2', '21:9'],
|
|
116
|
+
resolutions: [],
|
|
117
|
+
defaultResolution: null,
|
|
118
|
+
maxCount: 6,
|
|
119
|
+
supports: { seed: true, negativePrompt: true, background: false, outputFormat: false, promptExtend: true, watermark: true }
|
|
120
|
+
}
|
|
121
|
+
];
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Возвращает объект возможностей модели по id или null, если модель не найдена.
|
|
125
|
+
* @param {string} id
|
|
126
|
+
* @returns {ImageModelCapability|null}
|
|
127
|
+
*/
|
|
128
|
+
export function getImageModelCapability(id) {
|
|
129
|
+
return IMAGE_MODELS.find((m) => m.id === id) ?? null;
|
|
130
|
+
}
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Конфигурация возможностей моделей генерации видео.
|
|
3
|
+
*
|
|
4
|
+
* Одна ответственность: данные и чистые helper-функции.
|
|
5
|
+
* Никакого DOM, никаких импортов UI.
|
|
6
|
+
*
|
|
7
|
+
* ВНИМАНИЕ: provider-id (gemini-video, seedance, veo, kling, openai-video) — предварительные;
|
|
8
|
+
* подлежат сверке с бэкендом Futurello перед включением в prod.
|
|
9
|
+
*
|
|
10
|
+
* @typedef {Object} VideoModelCapability
|
|
11
|
+
* @property {string} id - уникальный идентификатор
|
|
12
|
+
* @property {string} label - подпись в UI
|
|
13
|
+
* @property {string} description - описание (провайдер · техническое имя)
|
|
14
|
+
* @property {string} provider - идентификатор провайдера для бэкенда
|
|
15
|
+
* @property {string} model - код модели для бэкенда
|
|
16
|
+
* @property {string[]} ratios - поддерживаемые соотношения сторон
|
|
17
|
+
* @property {string[]} resolutions - допустимые разрешения; [] = выбор не нужен
|
|
18
|
+
* @property {number[]} durations - допустимые длительности в секундах
|
|
19
|
+
* @property {number|null} defaultDuration - дефолтная длительность или null
|
|
20
|
+
* @property {number} maxCount - максимальное количество видео в одном запросе
|
|
21
|
+
* @property {{
|
|
22
|
+
* seed: boolean,
|
|
23
|
+
* negativePrompt: boolean,
|
|
24
|
+
* audio: boolean,
|
|
25
|
+
* watermark: boolean,
|
|
26
|
+
* personGeneration: boolean,
|
|
27
|
+
* cfgScale: boolean
|
|
28
|
+
* }} supports
|
|
29
|
+
*/
|
|
30
|
+
|
|
31
|
+
/** @type {VideoModelCapability[]} */
|
|
32
|
+
export const VIDEO_MODELS = [
|
|
33
|
+
{
|
|
34
|
+
id: 'gemini-omni-flash',
|
|
35
|
+
label: 'Gemini-Omni-Flash',
|
|
36
|
+
description: 'Google · gemini-omni-flash',
|
|
37
|
+
provider: 'gemini-video',
|
|
38
|
+
model: 'gemini-omni-flash',
|
|
39
|
+
ratios: ['16:9', '9:16'],
|
|
40
|
+
resolutions: [],
|
|
41
|
+
durations: [4, 6, 8, 10],
|
|
42
|
+
defaultDuration: 4,
|
|
43
|
+
maxCount: 1,
|
|
44
|
+
supports: { seed: true, negativePrompt: false, audio: true, watermark: false, personGeneration: false, cfgScale: false },
|
|
45
|
+
},
|
|
46
|
+
{
|
|
47
|
+
id: 'seedance-2',
|
|
48
|
+
label: 'Seedance 2.0',
|
|
49
|
+
description: 'ByteDance · doubao-seedance-2-0',
|
|
50
|
+
provider: 'seedance',
|
|
51
|
+
model: 'doubao-seedance-2-0',
|
|
52
|
+
ratios: ['16:9', '9:16', '4:3', '3:4', '1:1', '21:9'],
|
|
53
|
+
resolutions: ['480p', '720p', '1080p'],
|
|
54
|
+
durations: [4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15],
|
|
55
|
+
defaultDuration: 5,
|
|
56
|
+
maxCount: 1,
|
|
57
|
+
supports: { seed: true, negativePrompt: true, audio: true, watermark: true, personGeneration: false, cfgScale: false },
|
|
58
|
+
},
|
|
59
|
+
{
|
|
60
|
+
id: 'veo-3-1',
|
|
61
|
+
label: 'Veo 3.1',
|
|
62
|
+
description: 'Google · veo-3.1-generate-preview',
|
|
63
|
+
provider: 'veo',
|
|
64
|
+
model: 'veo-3.1-generate-preview',
|
|
65
|
+
ratios: ['16:9', '9:16'],
|
|
66
|
+
resolutions: ['720p', '1080p'],
|
|
67
|
+
durations: [4, 6, 8],
|
|
68
|
+
defaultDuration: 4,
|
|
69
|
+
maxCount: 1,
|
|
70
|
+
supports: { seed: false, negativePrompt: true, audio: false, watermark: false, personGeneration: true, cfgScale: false },
|
|
71
|
+
},
|
|
72
|
+
{
|
|
73
|
+
id: 'kling-3-pro',
|
|
74
|
+
label: 'Kling 3.0 Pro',
|
|
75
|
+
description: 'Kuaishou · kling-v3-pro',
|
|
76
|
+
provider: 'kling',
|
|
77
|
+
model: 'kling-v3-pro',
|
|
78
|
+
ratios: ['16:9', '9:16', '1:1'],
|
|
79
|
+
resolutions: ['720p', '1080p', '4K'],
|
|
80
|
+
durations: [3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15],
|
|
81
|
+
defaultDuration: 5,
|
|
82
|
+
maxCount: 1,
|
|
83
|
+
supports: { seed: false, negativePrompt: true, audio: true, watermark: false, personGeneration: false, cfgScale: true },
|
|
84
|
+
},
|
|
85
|
+
{
|
|
86
|
+
id: 'kling-2-5-turbo-pro',
|
|
87
|
+
label: 'Kling 2.5 Turbo Pro',
|
|
88
|
+
description: 'Kuaishou · kling-v2.5-turbo-pro',
|
|
89
|
+
provider: 'kling',
|
|
90
|
+
model: 'kling-v2.5-turbo-pro',
|
|
91
|
+
ratios: ['16:9', '9:16', '1:1'],
|
|
92
|
+
resolutions: [],
|
|
93
|
+
durations: [5, 10],
|
|
94
|
+
defaultDuration: 5,
|
|
95
|
+
maxCount: 1,
|
|
96
|
+
supports: { seed: false, negativePrompt: true, audio: false, watermark: false, personGeneration: false, cfgScale: true },
|
|
97
|
+
},
|
|
98
|
+
{
|
|
99
|
+
id: 'sora-2',
|
|
100
|
+
label: 'Sora 2',
|
|
101
|
+
description: 'OpenAI · sora-2',
|
|
102
|
+
provider: 'openai-video',
|
|
103
|
+
model: 'sora-2',
|
|
104
|
+
ratios: ['16:9', '9:16'],
|
|
105
|
+
resolutions: [],
|
|
106
|
+
durations: [4, 8, 12],
|
|
107
|
+
defaultDuration: 4,
|
|
108
|
+
maxCount: 1,
|
|
109
|
+
supports: { seed: false, negativePrompt: false, audio: false, watermark: false, personGeneration: false, cfgScale: false },
|
|
110
|
+
},
|
|
111
|
+
];
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Возвращает объект возможностей видео-модели по id или null, если модель не найдена.
|
|
115
|
+
* @param {string} id
|
|
116
|
+
* @returns {VideoModelCapability|null}
|
|
117
|
+
*/
|
|
118
|
+
export function getVideoModelCapability(id) {
|
|
119
|
+
return VIDEO_MODELS.find((m) => m.id === id) ?? null;
|
|
120
|
+
}
|
|
@@ -32,6 +32,14 @@ export class ChatComposer {
|
|
|
32
32
|
* @type {{ file: File, sourceObjectId: string|null }[]}
|
|
33
33
|
*/
|
|
34
34
|
this._attachments = [];
|
|
35
|
+
this._attachmentLabelProvider = null;
|
|
36
|
+
/**
|
|
37
|
+
* Жёстко зафиксированный placeholder. Когда задан, логика вложений
|
|
38
|
+
* (`_renderAttachmentsPreview`) не перетирает текст. Нужно для 3D-режима
|
|
39
|
+
* «Изображение», где описание не требуется.
|
|
40
|
+
* @type {string|null}
|
|
41
|
+
*/
|
|
42
|
+
this._placeholderOverride = null;
|
|
35
43
|
}
|
|
36
44
|
|
|
37
45
|
attach() {
|
|
@@ -135,6 +143,39 @@ export class ChatComposer {
|
|
|
135
143
|
this._attachments = [];
|
|
136
144
|
}
|
|
137
145
|
|
|
146
|
+
/**
|
|
147
|
+
* Включает/выключает поле ввода текста.
|
|
148
|
+
* В 3D-режимах image/multi textarea должна быть заблокирована.
|
|
149
|
+
* @param {boolean} enabled
|
|
150
|
+
*/
|
|
151
|
+
setInputEnabled(enabled) {
|
|
152
|
+
this._textarea.disabled = !enabled;
|
|
153
|
+
this._textarea.style.opacity = enabled ? '' : '0.4';
|
|
154
|
+
this._textarea.style.cursor = enabled ? '' : 'not-allowed';
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* Фиксирует placeholder, который не перетирается логикой вложений.
|
|
159
|
+
* `null` — вернуть обычное поведение (текст зависит от наличия вложений).
|
|
160
|
+
* @param {string|null} text
|
|
161
|
+
*/
|
|
162
|
+
setPlaceholderOverride(text) {
|
|
163
|
+
this._placeholderOverride = text ?? null;
|
|
164
|
+
if (this._placeholderOverride != null) {
|
|
165
|
+
this._textarea.placeholder = this._placeholderOverride;
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* Устанавливает провайдер пользовательских лейблов для превью вложений.
|
|
171
|
+
* Используется в мультивью: i===0 → 'Фронт', i>0 → ракурс по порядку.
|
|
172
|
+
* @param {((index: number) => string) | null} fn
|
|
173
|
+
*/
|
|
174
|
+
setAttachmentLabelProvider(fn) {
|
|
175
|
+
this._attachmentLabelProvider = fn ?? null;
|
|
176
|
+
this._renderAttachmentsPreview();
|
|
177
|
+
}
|
|
178
|
+
|
|
138
179
|
_submit() {
|
|
139
180
|
const text = this._textarea.value;
|
|
140
181
|
const trimmed = text.trim();
|
|
@@ -180,13 +221,17 @@ export class ChatComposer {
|
|
|
180
221
|
if (this._attachments.length === 0) {
|
|
181
222
|
container.classList.remove('is-visible');
|
|
182
223
|
inputRow?.classList.remove('has-attachments');
|
|
183
|
-
this.
|
|
224
|
+
if (this._placeholderOverride == null) {
|
|
225
|
+
this._textarea.placeholder = 'Опишите то, что хотите сгенерировать';
|
|
226
|
+
}
|
|
184
227
|
return;
|
|
185
228
|
}
|
|
186
229
|
|
|
187
230
|
container.classList.add('is-visible');
|
|
188
231
|
inputRow?.classList.add('has-attachments');
|
|
189
|
-
this.
|
|
232
|
+
if (this._placeholderOverride == null) {
|
|
233
|
+
this._textarea.placeholder = 'Опишите правку, изменение или стилевое направление эталонного изображения';
|
|
234
|
+
}
|
|
190
235
|
for (let i = 0; i < this._attachments.length; i++) {
|
|
191
236
|
const entry = this._attachments[i];
|
|
192
237
|
const item = this._buildAttachmentItem(entry.file, i);
|
|
@@ -219,7 +264,9 @@ export class ChatComposer {
|
|
|
219
264
|
|
|
220
265
|
const badge = document.createElement('div');
|
|
221
266
|
badge.className = 'moodboard-chat__attachment-badge';
|
|
222
|
-
badge.textContent =
|
|
267
|
+
badge.textContent = this._attachmentLabelProvider
|
|
268
|
+
? (this._attachmentLabelProvider(index) ?? String(index + 1))
|
|
269
|
+
: String(index + 1);
|
|
223
270
|
item.appendChild(badge);
|
|
224
271
|
|
|
225
272
|
const remove = document.createElement('button');
|