@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.
- package/package.json +1 -1
- package/src/core/events/Events.js +2 -0
- package/src/core/flows/ClipboardFlow.js +13 -0
- package/src/moodboard/bootstrap/MoodBoardUiFactory.js +1 -1
- package/src/objects/ImageObject.js +5 -0
- package/src/services/ai/AiClient.js +51 -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 +1473 -4
- package/src/ui/chat/ChatSettingsPopup.js +210 -44
- package/src/ui/chat/ChatVideoToolbarPills.js +284 -0
- package/src/ui/chat/ChatWindow.js +469 -89
- package/src/ui/chat/ChatWindowRenderer.js +29 -1
- package/src/ui/chat/icons.js +26 -6
- package/src/ui/comments/CommentThreadPopover.js +68 -0
- package/src/ui/connectors/ConnectionAnchorsLayer.js +8 -1
- package/src/ui/styles/chat.css +62 -0
- package/src/ui/styles/panels.css +388 -1
- package/src/utils/applyRoundedMask.js +39 -0
package/package.json
CHANGED
|
@@ -173,8 +173,10 @@ export const Events = {
|
|
|
173
173
|
ThreadDeleted: 'comment:thread:deleted',
|
|
174
174
|
DraftOpened: 'comment:draft:opened',
|
|
175
175
|
DraftClosed: 'comment:draft:closed',
|
|
176
|
+
PopoverClosed: 'comment:popover:closed',
|
|
176
177
|
ResolvedFilterChanged: 'comment:resolved:filter:changed',
|
|
177
178
|
ListOpened: 'comment:list:opened',
|
|
179
|
+
OpenImageDraft: 'comment:open:image:draft',
|
|
178
180
|
},
|
|
179
181
|
};
|
|
180
182
|
|
|
@@ -2,6 +2,16 @@ import { Events } from '../events/Events.js';
|
|
|
2
2
|
import { PasteObjectCommand } from '../commands/index.js';
|
|
3
3
|
import { RevitScreenshotMetadataService } from '../../services/RevitScreenshotMetadataService.js';
|
|
4
4
|
|
|
5
|
+
// Дубликат AI-изображения — полноправный член ряда генераций, но ему нужен
|
|
6
|
+
// собственный слот. Слот привязан к properties.aiMessageId, поэтому копия должна
|
|
7
|
+
// получить уникальный aiMessageId, иначе все копии делят один слот и ряд
|
|
8
|
+
// «расползается» при следующей генерации.
|
|
9
|
+
function reassignClonedAiMessageId(clonedData) {
|
|
10
|
+
if (clonedData?.properties?.aiMessageId) {
|
|
11
|
+
clonedData.properties.aiMessageId = `dup_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
|
|
5
15
|
export function setupClipboardFlow(core) {
|
|
6
16
|
const revitMetadataService = new RevitScreenshotMetadataService(console);
|
|
7
17
|
|
|
@@ -391,6 +401,7 @@ export function setupClipboardFlow(core) {
|
|
|
391
401
|
const clonedChild = JSON.parse(JSON.stringify(child));
|
|
392
402
|
clonedChild.properties = clonedChild.properties || {};
|
|
393
403
|
clonedChild.properties.frameId = newFrameId;
|
|
404
|
+
reassignClonedAiMessageId(clonedChild);
|
|
394
405
|
const targetPos = {
|
|
395
406
|
x: (child.position?.x || 0) + dx,
|
|
396
407
|
y: (child.position?.y || 0) + dy
|
|
@@ -431,6 +442,7 @@ export function setupClipboardFlow(core) {
|
|
|
431
442
|
type: 'object',
|
|
432
443
|
data: JSON.parse(JSON.stringify(original))
|
|
433
444
|
};
|
|
445
|
+
reassignClonedAiMessageId(core.clipboard.data);
|
|
434
446
|
try {
|
|
435
447
|
if (original.type === 'frame') {
|
|
436
448
|
core._dupTitleMap = core._dupTitleMap || new Map();
|
|
@@ -487,6 +499,7 @@ export function setupClipboardFlow(core) {
|
|
|
487
499
|
tempHandlers.set(originalId, handler);
|
|
488
500
|
core.eventBus.on(Events.Object.Pasted, handler);
|
|
489
501
|
core.clipboard = { type: 'object', data: JSON.parse(JSON.stringify(obj)) };
|
|
502
|
+
reassignClonedAiMessageId(core.clipboard.data);
|
|
490
503
|
try {
|
|
491
504
|
if (obj.type === 'frame') {
|
|
492
505
|
core._dupTitleMap = core._dupTitleMap || new Map();
|
|
@@ -152,7 +152,7 @@ function initHtmlLayersAndPanels(board) {
|
|
|
152
152
|
board.framePropertiesPanel = new FramePropertiesPanel(board.coreMoodboard.eventBus, board.canvasContainer, board.coreMoodboard);
|
|
153
153
|
board.notePropertiesPanel = new NotePropertiesPanel(board.coreMoodboard.eventBus, board.canvasContainer, board.coreMoodboard);
|
|
154
154
|
board.filePropertiesPanel = new FilePropertiesPanel(board.coreMoodboard.eventBus, board.canvasContainer, board.coreMoodboard);
|
|
155
|
-
board.imagePropertiesPanel = new ImagePropertiesPanel(board.coreMoodboard.eventBus, board.canvasContainer, board.coreMoodboard);
|
|
155
|
+
board.imagePropertiesPanel = new ImagePropertiesPanel(board.coreMoodboard.eventBus, board.canvasContainer, board.coreMoodboard, board.options.currentUser || null);
|
|
156
156
|
board.connectorPropertiesPanel = new ConnectorPropertiesPanel(board.coreMoodboard.eventBus, board.canvasContainer, board.coreMoodboard);
|
|
157
157
|
board.shapePropertiesPanel = new ShapePropertiesPanel(board.coreMoodboard.eventBus, board.canvasContainer, board.coreMoodboard);
|
|
158
158
|
board.drawingPropertiesPanel = new DrawingPropertiesPanel(board.coreMoodboard.eventBus, board.canvasContainer, board.coreMoodboard);
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import * as PIXI from 'pixi.js';
|
|
2
|
+
import { applyRoundedMask } from '../utils/applyRoundedMask.js';
|
|
2
3
|
|
|
3
4
|
/**
|
|
4
5
|
* ImageObject — отображение загруженного изображения как спрайт
|
|
@@ -42,6 +43,10 @@ export class ImageObject {
|
|
|
42
43
|
baseH: texH
|
|
43
44
|
}
|
|
44
45
|
};
|
|
46
|
+
const borderRadius = objectData.properties?.borderRadius;
|
|
47
|
+
if (borderRadius > 0) {
|
|
48
|
+
applyRoundedMask(this.sprite, borderRadius);
|
|
49
|
+
}
|
|
45
50
|
};
|
|
46
51
|
|
|
47
52
|
const onError = () => {
|
|
@@ -217,6 +217,57 @@ export class AiClient {
|
|
|
217
217
|
return res.json();
|
|
218
218
|
}
|
|
219
219
|
|
|
220
|
+
/**
|
|
221
|
+
* Отправляет джоб генерации видео.
|
|
222
|
+
* @param {object} args
|
|
223
|
+
* @param {string} [args.provider='gemini-video']
|
|
224
|
+
* @param {string} args.prompt
|
|
225
|
+
* @param {string} [args.negativePrompt]
|
|
226
|
+
* @param {string} [args.model]
|
|
227
|
+
* @param {string} [args.ratio]
|
|
228
|
+
* @param {string} [args.resolution]
|
|
229
|
+
* @param {number} [args.duration]
|
|
230
|
+
* @param {number} [args.seed]
|
|
231
|
+
* @param {File[]} [args.referenceImages]
|
|
232
|
+
* @param {AbortSignal} [args.signal]
|
|
233
|
+
* @returns {Promise<{jobId: string}>}
|
|
234
|
+
*/
|
|
235
|
+
async submitVideo({ provider = 'gemini-video', signal, referenceImages: files, ...payload }) {
|
|
236
|
+
const referenceImages = await filesToBase64(files);
|
|
237
|
+
const body = referenceImages ? { ...payload, referenceImages } : payload;
|
|
238
|
+
const res = await this._fetch(`${this._baseUrl}/${provider}/video`, {
|
|
239
|
+
method: 'POST',
|
|
240
|
+
headers: { 'Content-Type': 'application/json', 'Accept': 'application/json' },
|
|
241
|
+
body: JSON.stringify(body),
|
|
242
|
+
signal
|
|
243
|
+
});
|
|
244
|
+
if (!res.ok) {
|
|
245
|
+
const detail = await safeReadError(res);
|
|
246
|
+
throw new Error(`AiClient.submitVideo (${res.status}): ${detail}`);
|
|
247
|
+
}
|
|
248
|
+
return res.json();
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
/**
|
|
252
|
+
* Опрашивает статус джоба генерации видео.
|
|
253
|
+
* @param {string} jobId
|
|
254
|
+
* @param {AbortSignal} [signal]
|
|
255
|
+
* @param {string} [provider='gemini-video']
|
|
256
|
+
* @returns {Promise<object>}
|
|
257
|
+
*/
|
|
258
|
+
async pollVideo(jobId, signal, provider = 'gemini-video') {
|
|
259
|
+
const res = await this._fetch(`${this._baseUrl}/${provider}/video/${jobId}`, {
|
|
260
|
+
method: 'GET',
|
|
261
|
+
headers: { 'Accept': 'application/json' },
|
|
262
|
+
signal
|
|
263
|
+
});
|
|
264
|
+
if (!res.ok) {
|
|
265
|
+
const detail = await safeReadError(res);
|
|
266
|
+
throw new Error(`AiClient.pollVideo (${res.status}): ${detail}`);
|
|
267
|
+
}
|
|
268
|
+
return res.json();
|
|
269
|
+
}
|
|
270
|
+
|
|
220
271
|
/**
|
|
221
272
|
* Отправляет джоб конвертации GLB -> FBX/STL.
|
|
222
273
|
* @param {object} args
|
|
@@ -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
|
+
}
|